Merge upstream/main
This commit is contained in:
commit
043a922a7f
140
.github/workflows/ci.yml
vendored
140
.github/workflows/ci.yml
vendored
@ -312,11 +312,8 @@ jobs:
|
||||
- name: Strict TS build smoke
|
||||
run: pnpm build:strict-smoke
|
||||
|
||||
- name: Enforce safe external URL opening policy
|
||||
run: pnpm lint:ui:no-raw-window-open
|
||||
|
||||
plugin-extension-boundary:
|
||||
name: "plugin-extension-boundary"
|
||||
check-additional:
|
||||
name: "check-additional"
|
||||
needs: [docs-scope, changed-scope]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true'
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
@ -333,68 +330,71 @@ jobs:
|
||||
use-sticky-disk: "false"
|
||||
|
||||
- name: Run plugin extension boundary guard
|
||||
id: plugin_extension_boundary
|
||||
continue-on-error: true
|
||||
run: pnpm run lint:plugins:no-extension-imports
|
||||
|
||||
web-search-provider-boundary:
|
||||
name: "web-search-provider-boundary"
|
||||
needs: [docs-scope, changed-scope]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true'
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
use-sticky-disk: "false"
|
||||
|
||||
- name: Run web search provider boundary guard
|
||||
id: web_search_provider_boundary
|
||||
continue-on-error: true
|
||||
run: pnpm run lint:web-search-provider-boundaries
|
||||
|
||||
extension-src-outside-plugin-sdk-boundary:
|
||||
name: "extension-src-outside-plugin-sdk-boundary"
|
||||
needs: [docs-scope, changed-scope]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true'
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
use-sticky-disk: "false"
|
||||
|
||||
- name: Run extension src boundary guard
|
||||
id: extension_src_outside_plugin_sdk_boundary
|
||||
continue-on-error: true
|
||||
run: pnpm run lint:extensions:no-src-outside-plugin-sdk
|
||||
|
||||
extension-plugin-sdk-internal-boundary:
|
||||
name: "extension-plugin-sdk-internal-boundary"
|
||||
needs: [docs-scope, changed-scope]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true'
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
use-sticky-disk: "false"
|
||||
|
||||
- name: Run extension plugin-sdk-internal guard
|
||||
id: extension_plugin_sdk_internal_boundary
|
||||
continue-on-error: true
|
||||
run: pnpm run lint:extensions:no-plugin-sdk-internal
|
||||
|
||||
- name: Enforce safe external URL opening policy
|
||||
id: no_raw_window_open
|
||||
continue-on-error: true
|
||||
run: pnpm lint:ui:no-raw-window-open
|
||||
|
||||
- name: Run gateway watch regression harness
|
||||
id: gateway_watch_regression
|
||||
continue-on-error: true
|
||||
run: pnpm test:gateway:watch-regression
|
||||
|
||||
- name: Upload gateway watch regression artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: gateway-watch-regression
|
||||
path: .local/gateway-watch-regression/
|
||||
retention-days: 7
|
||||
|
||||
- name: Fail if any additional check failed
|
||||
if: always()
|
||||
env:
|
||||
PLUGIN_EXTENSION_BOUNDARY_OUTCOME: ${{ steps.plugin_extension_boundary.outcome }}
|
||||
WEB_SEARCH_PROVIDER_BOUNDARY_OUTCOME: ${{ steps.web_search_provider_boundary.outcome }}
|
||||
EXTENSION_SRC_OUTSIDE_PLUGIN_SDK_BOUNDARY_OUTCOME: ${{ steps.extension_src_outside_plugin_sdk_boundary.outcome }}
|
||||
EXTENSION_PLUGIN_SDK_INTERNAL_BOUNDARY_OUTCOME: ${{ steps.extension_plugin_sdk_internal_boundary.outcome }}
|
||||
NO_RAW_WINDOW_OPEN_OUTCOME: ${{ steps.no_raw_window_open.outcome }}
|
||||
GATEWAY_WATCH_REGRESSION_OUTCOME: ${{ steps.gateway_watch_regression.outcome }}
|
||||
run: |
|
||||
failures=0
|
||||
for result in \
|
||||
"plugin-extension-boundary|$PLUGIN_EXTENSION_BOUNDARY_OUTCOME" \
|
||||
"web-search-provider-boundary|$WEB_SEARCH_PROVIDER_BOUNDARY_OUTCOME" \
|
||||
"extension-src-outside-plugin-sdk-boundary|$EXTENSION_SRC_OUTSIDE_PLUGIN_SDK_BOUNDARY_OUTCOME" \
|
||||
"extension-plugin-sdk-internal-boundary|$EXTENSION_PLUGIN_SDK_INTERNAL_BOUNDARY_OUTCOME" \
|
||||
"lint:ui:no-raw-window-open|$NO_RAW_WINDOW_OPEN_OUTCOME" \
|
||||
"gateway-watch-regression|$GATEWAY_WATCH_REGRESSION_OUTCOME"; do
|
||||
name="${result%%|*}"
|
||||
outcome="${result#*|}"
|
||||
if [ "$outcome" != "success" ]; then
|
||||
echo "::error title=${name} failed::${name} outcome: ${outcome}"
|
||||
failures=1
|
||||
fi
|
||||
done
|
||||
|
||||
exit "$failures"
|
||||
|
||||
build-smoke:
|
||||
name: "build-smoke"
|
||||
needs: [docs-scope, changed-scope]
|
||||
@ -427,34 +427,6 @@ jobs:
|
||||
- name: Check CLI startup memory
|
||||
run: pnpm test:startup:memory
|
||||
|
||||
gateway-watch-regression:
|
||||
name: "gateway-watch-regression"
|
||||
needs: [docs-scope, changed-scope]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true'
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
use-sticky-disk: "false"
|
||||
|
||||
- name: Run gateway watch regression harness
|
||||
run: pnpm test:gateway:watch-regression
|
||||
|
||||
- name: Upload gateway watch regression artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: gateway-watch-regression
|
||||
path: .local/gateway-watch-regression/
|
||||
retention-days: 7
|
||||
|
||||
# Validate docs (format, lint, broken links) only when docs files changed.
|
||||
check-docs:
|
||||
needs: [docs-scope]
|
||||
|
||||
@ -49,6 +49,8 @@ Docs: https://docs.openclaw.ai
|
||||
- Plugins/Matrix: add `allowBots` room policy so configured Matrix bot accounts can talk to each other, with optional mention-only gating. Thanks @gumadeiras.
|
||||
- Plugins/Matrix: add per-account `allowPrivateNetwork` opt-in for private/internal homeservers, while keeping public cleartext homeservers blocked. Thanks @gumadeiras.
|
||||
- Web tools/Tavily: add Tavily as a bundled web-search provider with dedicated `tavily_search` and `tavily_extract` tools, using canonical plugin-owned config under `plugins.entries.tavily.config.webSearch.*`. (#49200) thanks @lakshyaag-tavily.
|
||||
- Docs/plugins: add the community DingTalk plugin listing to the docs catalog. (#29913) Thanks @sliverp.
|
||||
- Docs/plugins: add the community QQbot plugin listing to the docs catalog. (#29898) Thanks @sliverp.
|
||||
|
||||
### Fixes
|
||||
|
||||
@ -116,6 +118,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Gateway/config validation: stop treating the implicit default memory slot as a required explicit plugin config, so startup no longer fails with `plugins.slots.memory: plugin not found: memory-core` when `memory-core` was only inferred. (#47494) Thanks @ngutman.
|
||||
- Tlon: honor explicit empty allowlists and defer cite expansion. (#46788) Thanks @zpbrent and @vincentkoc.
|
||||
- Tlon/DM auth: defer cited-message expansion until after DM authorization and owner command handling, so unauthorized DMs and owner approval/admin commands no longer trigger cross-channel cite fetches before the deny or command path.
|
||||
- Gateway/agent events: stop broadcasting false end-of-run `seq gap` errors to clients, and isolate node-driven ingress turns with per-turn run IDs so stale tail events cannot leak into later session runs. (#43751) Thanks @caesargattuso.
|
||||
- Docs/security audit: spell out that `gateway.controlUi.allowedOrigins: ["*"]` is an explicit allow-all browser-origin policy and should be avoided outside tightly controlled local testing.
|
||||
- Gateway/auth: clear self-declared scopes for device-less trusted-proxy Control UI sessions so proxy-authenticated connects cannot claim admin or secrets scopes without a bound device identity.
|
||||
- Nodes/pending actions: re-check queued foreground actions against the current node command policy before returning them to the node. (#46815) Thanks @zpbrent and @vincentkoc.
|
||||
|
||||
@ -45,6 +45,15 @@ Use this format when adding entries:
|
||||
|
||||
## Listed plugins
|
||||
|
||||
- **openclaw-dingtalk** — The OpenClaw DingTalk channel plugin enables the integration of enterprise robots using the Stream mode. It supports text, images and file messages via any DingTalk client.
|
||||
npm: `@largezhou/ddingtalk`
|
||||
repo: `https://github.com/largezhou/openclaw-dingtalk`
|
||||
install: `openclaw plugins install @largezhou/ddingtalk`
|
||||
- **QQbot** — Connect OpenClaw to QQ via the QQ Bot API. Supports private chats, group mentions, channel messages, and rich media including voice, images, videos, and files.
|
||||
npm: `@sliverp/qqbot`
|
||||
repo: `https://github.com/sliverp/qqbot`
|
||||
install: `openclaw plugins install @sliverp/qqbot`
|
||||
|
||||
- **WeChat** — Connect OpenClaw to WeChat personal accounts via WeChatPadPro (iPad protocol). Supports text, image, and file exchange with keyword-triggered conversations.
|
||||
npm: `@icesword760/openclaw-wechat`
|
||||
repo: `https://github.com/icesword0760/openclaw-wechat`
|
||||
|
||||
@ -66,8 +66,12 @@ export function createMockSignalDaemonHandle(
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/config-runtime")>();
|
||||
// Use importActual so shared-worker mocks from earlier test files do not leak
|
||||
// into this harness's partial overrides.
|
||||
vi.mock("openclaw/plugin-sdk/config-runtime", async () => {
|
||||
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/config-runtime")>(
|
||||
"openclaw/plugin-sdk/config-runtime",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: () => config,
|
||||
@ -78,8 +82,10 @@ vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/reply-runtime")>();
|
||||
vi.mock("openclaw/plugin-sdk/reply-runtime", async () => {
|
||||
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/reply-runtime")>(
|
||||
"openclaw/plugin-sdk/reply-runtime",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
getReplyFromConfig: (...args: unknown[]) => replyMock(...args),
|
||||
@ -104,8 +110,8 @@ vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./send.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./send.js")>();
|
||||
vi.mock("./send.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./send.js")>("./send.js");
|
||||
return {
|
||||
...actual,
|
||||
sendMessageSignal: (...args: unknown[]) => sendMock(...args),
|
||||
@ -114,8 +120,10 @@ vi.mock("./send.js", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/conversation-runtime")>();
|
||||
vi.mock("openclaw/plugin-sdk/conversation-runtime", async () => {
|
||||
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/conversation-runtime")>(
|
||||
"openclaw/plugin-sdk/conversation-runtime",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args),
|
||||
@ -123,8 +131,10 @@ vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/security-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/security-runtime")>();
|
||||
vi.mock("openclaw/plugin-sdk/security-runtime", async () => {
|
||||
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/security-runtime")>(
|
||||
"openclaw/plugin-sdk/security-runtime",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
readStoreAllowFromForDmPolicy: (...args: unknown[]) => readAllowFromStoreMock(...args),
|
||||
@ -137,16 +147,18 @@ vi.mock("./client.js", () => ({
|
||||
signalRpcRequest: (...args: unknown[]) => signalRpcRequestMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./daemon.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./daemon.js")>();
|
||||
vi.mock("./daemon.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./daemon.js")>("./daemon.js");
|
||||
return {
|
||||
...actual,
|
||||
spawnSignalDaemon: (...args: unknown[]) => spawnSignalDaemonMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/infra-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/infra-runtime")>();
|
||||
vi.mock("openclaw/plugin-sdk/infra-runtime", async () => {
|
||||
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/infra-runtime")>(
|
||||
"openclaw/plugin-sdk/infra-runtime",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
waitForTransportReady: (...args: unknown[]) => waitForTransportReadyMock(...args),
|
||||
|
||||
@ -487,6 +487,46 @@ describe("agent event handler", () => {
|
||||
nowSpy?.mockRestore();
|
||||
});
|
||||
|
||||
it("drops stale events that arrive after lifecycle completion", () => {
|
||||
const { broadcast, nodeSendToSession, chatRunState, handler, nowSpy } = createHarness({
|
||||
now: 2_500,
|
||||
});
|
||||
chatRunState.registry.add("run-stale-tail", {
|
||||
sessionKey: "session-stale-tail",
|
||||
clientRunId: "client-stale-tail",
|
||||
});
|
||||
|
||||
handler({
|
||||
runId: "run-stale-tail",
|
||||
seq: 1,
|
||||
stream: "assistant",
|
||||
ts: Date.now(),
|
||||
data: { text: "done" },
|
||||
});
|
||||
emitLifecycleEnd(handler, "run-stale-tail");
|
||||
const errorCallsBeforeStaleEvent = broadcast.mock.calls.filter(
|
||||
([event, payload]) =>
|
||||
event === "agent" && (payload as { stream?: string }).stream === "error",
|
||||
).length;
|
||||
const sessionChatCallsBeforeStaleEvent = sessionChatCalls(nodeSendToSession).length;
|
||||
|
||||
handler({
|
||||
runId: "run-stale-tail",
|
||||
seq: 3,
|
||||
stream: "assistant",
|
||||
ts: Date.now(),
|
||||
data: { text: "late tail" },
|
||||
});
|
||||
|
||||
const errorCalls = broadcast.mock.calls.filter(
|
||||
([event, payload]) =>
|
||||
event === "agent" && (payload as { stream?: string }).stream === "error",
|
||||
);
|
||||
expect(errorCalls).toHaveLength(errorCallsBeforeStaleEvent);
|
||||
expect(sessionChatCalls(nodeSendToSession)).toHaveLength(sessionChatCallsBeforeStaleEvent);
|
||||
nowSpy?.mockRestore();
|
||||
});
|
||||
|
||||
it("flushes buffered chat delta before tool start events", () => {
|
||||
let now = 12_000;
|
||||
const nowSpy = vi.spyOn(Date, "now").mockImplementation(() => now);
|
||||
|
||||
@ -710,7 +710,7 @@ export function createAgentEventHandler({
|
||||
: { ...eventForClients, data };
|
||||
})()
|
||||
: agentPayload;
|
||||
if (evt.seq !== last + 1) {
|
||||
if (last > 0 && evt.seq !== last + 1) {
|
||||
broadcast("agent", {
|
||||
runId: eventRunId,
|
||||
stream: "error",
|
||||
|
||||
@ -410,7 +410,9 @@ describe("voice transcript events", () => {
|
||||
});
|
||||
|
||||
it("forwards transcript with voice provenance", async () => {
|
||||
const addChatRun = vi.fn();
|
||||
const ctx = buildCtx();
|
||||
ctx.addChatRun = addChatRun;
|
||||
|
||||
await handleNodeEvent(ctx, "node-v2", {
|
||||
event: "voice.transcript",
|
||||
@ -432,6 +434,12 @@ describe("voice transcript events", () => {
|
||||
sourceTool: "gateway.voice.transcript",
|
||||
},
|
||||
});
|
||||
expect(typeof opts.runId).toBe("string");
|
||||
expect(opts.runId).not.toBe(opts.sessionId);
|
||||
expect(addChatRun).toHaveBeenCalledWith(
|
||||
opts.runId,
|
||||
expect.objectContaining({ clientRunId: expect.stringMatching(/^voice-/) }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not block agent dispatch when session-store touch fails", async () => {
|
||||
@ -674,5 +682,6 @@ describe("agent request events", () => {
|
||||
channel: "telegram",
|
||||
to: "123",
|
||||
});
|
||||
expect(opts.runId).toBe(opts.sessionId);
|
||||
});
|
||||
});
|
||||
|
||||
@ -288,16 +288,18 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt
|
||||
sessionId,
|
||||
now,
|
||||
});
|
||||
const runId = randomUUID();
|
||||
|
||||
// Ensure chat UI clients refresh when this run completes (even though it wasn't started via chat.send).
|
||||
// This maps agent bus events (keyed by sessionId) to chat events (keyed by clientRunId).
|
||||
ctx.addChatRun(sessionId, {
|
||||
// This maps agent bus events (keyed by per-turn runId) to chat events (keyed by clientRunId).
|
||||
ctx.addChatRun(runId, {
|
||||
sessionKey: canonicalKey,
|
||||
clientRunId: `voice-${randomUUID()}`,
|
||||
});
|
||||
|
||||
void agentCommandFromIngress(
|
||||
{
|
||||
runId,
|
||||
message: text,
|
||||
sessionId,
|
||||
sessionKey: canonicalKey,
|
||||
@ -404,7 +406,6 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt
|
||||
const deliver = deliverRequested && Boolean(channel && to);
|
||||
const deliveryChannel = deliver ? channel : undefined;
|
||||
const deliveryTo = deliver ? to : undefined;
|
||||
|
||||
if (deliverRequested && !deliver) {
|
||||
ctx.logGateway.warn(
|
||||
`agent delivery disabled node=${nodeId}: missing session delivery route (channel=${channel ?? "-"} to=${to ?? "-"})`,
|
||||
@ -430,6 +431,7 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt
|
||||
|
||||
void agentCommandFromIngress(
|
||||
{
|
||||
runId: sessionId,
|
||||
message,
|
||||
images,
|
||||
sessionId,
|
||||
|
||||
@ -1,15 +1,18 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { withEnv } from "../test-utils/env.js";
|
||||
import { __testing } from "./loader.js";
|
||||
|
||||
const EMPTY_PLUGIN_SCHEMA = {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {},
|
||||
} as const;
|
||||
type CreateJiti = typeof import("jiti").createJiti;
|
||||
|
||||
let createJitiPromise: Promise<CreateJiti> | undefined;
|
||||
|
||||
async function getCreateJiti() {
|
||||
createJitiPromise ??= import("jiti").then(({ createJiti }) => createJiti);
|
||||
return createJitiPromise;
|
||||
}
|
||||
|
||||
const tempRoots: string[] = [];
|
||||
|
||||
@ -29,92 +32,66 @@ afterEach(() => {
|
||||
}
|
||||
});
|
||||
|
||||
describe("loadOpenClawPlugins", () => {
|
||||
it("builds plugin-loader aliases for git-style package extension entries that import plugin-sdk channel-runtime (#49806)", () => {
|
||||
const pluginId = "imessage-loader-regression";
|
||||
const gitExtensionRoot = path.join(
|
||||
makeTempDir(),
|
||||
"git-source-checkout",
|
||||
"extensions",
|
||||
pluginId,
|
||||
);
|
||||
const gitSourceDir = path.join(gitExtensionRoot, "src");
|
||||
mkdirSafe(gitSourceDir);
|
||||
describe("plugin loader git path regression", () => {
|
||||
it("loads git-style package extension entries when they import plugin-sdk channel-runtime (#49806)", async () => {
|
||||
const copiedExtensionRoot = path.join(makeTempDir(), "extensions", "imessage");
|
||||
const copiedSourceDir = path.join(copiedExtensionRoot, "src");
|
||||
const copiedPluginSdkDir = path.join(copiedExtensionRoot, "plugin-sdk");
|
||||
mkdirSafe(copiedSourceDir);
|
||||
mkdirSafe(copiedPluginSdkDir);
|
||||
|
||||
const jitiBaseFile = path.join(copiedSourceDir, "__jiti-base__.mjs");
|
||||
fs.writeFileSync(jitiBaseFile, "export {};\n", "utf-8");
|
||||
fs.writeFileSync(
|
||||
path.join(gitExtensionRoot, "package.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
name: `@openclaw/${pluginId}`,
|
||||
version: "0.0.1",
|
||||
type: "module",
|
||||
openclaw: {
|
||||
extensions: ["./src/index.ts"],
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(gitExtensionRoot, "openclaw.plugin.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
id: pluginId,
|
||||
configSchema: EMPTY_PLUGIN_SCHEMA,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(gitSourceDir, "channel.runtime.ts"),
|
||||
path.join(copiedSourceDir, "channel.runtime.ts"),
|
||||
`import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime";
|
||||
import { PAIRING_APPROVED_MESSAGE } from "../runtime-api.js";
|
||||
|
||||
export function runtimeProbeType() {
|
||||
return typeof resolveOutboundSendDep;
|
||||
}
|
||||
`,
|
||||
"utf-8",
|
||||
);
|
||||
const entryFile = path.join(gitSourceDir, "index.ts");
|
||||
fs.writeFileSync(
|
||||
entryFile,
|
||||
`import { runtimeProbeType } from "./channel.runtime.ts";
|
||||
|
||||
export default {
|
||||
id: ${JSON.stringify(pluginId)},
|
||||
register() {
|
||||
if (runtimeProbeType() !== "function") {
|
||||
throw new Error("channel-runtime import did not resolve");
|
||||
}
|
||||
},
|
||||
export const copiedRuntimeMarker = {
|
||||
resolveOutboundSendDep,
|
||||
PAIRING_APPROVED_MESSAGE,
|
||||
};
|
||||
`,
|
||||
"utf-8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(copiedExtensionRoot, "runtime-api.ts"),
|
||||
`export const PAIRING_APPROVED_MESSAGE = "paired";
|
||||
`,
|
||||
"utf-8",
|
||||
);
|
||||
const copiedChannelRuntimeShim = path.join(copiedPluginSdkDir, "channel-runtime.ts");
|
||||
fs.writeFileSync(
|
||||
copiedChannelRuntimeShim,
|
||||
`export function resolveOutboundSendDep() {
|
||||
return "shimmed";
|
||||
}
|
||||
`,
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const { aliasMap, runtimeModulePath, tryNative } = withEnv(
|
||||
{
|
||||
NODE_ENV: "production",
|
||||
VITEST: undefined,
|
||||
OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins",
|
||||
},
|
||||
() => ({
|
||||
aliasMap: __testing.buildPluginLoaderAliasMap(entryFile),
|
||||
runtimeModulePath: __testing.resolvePluginRuntimeModulePath({ modulePath: entryFile }),
|
||||
tryNative: __testing.shouldPreferNativeJiti(entryFile),
|
||||
const copiedChannelRuntime = path.join(copiedExtensionRoot, "src", "channel.runtime.ts");
|
||||
const jitiBaseUrl = pathToFileURL(jitiBaseFile).href;
|
||||
const createJiti = await getCreateJiti();
|
||||
const withoutAlias = createJiti(jitiBaseUrl, {
|
||||
...__testing.buildPluginLoaderJitiOptions({}),
|
||||
tryNative: false,
|
||||
});
|
||||
// The production loader uses sync Jiti evaluation, so this regression test
|
||||
// should exercise the same seam instead of Jiti's async import helper.
|
||||
expect(() => withoutAlias(copiedChannelRuntime)).toThrow();
|
||||
|
||||
const withAlias = createJiti(jitiBaseUrl, {
|
||||
...__testing.buildPluginLoaderJitiOptions({
|
||||
"openclaw/plugin-sdk/channel-runtime": copiedChannelRuntimeShim,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(tryNative).toBe(false);
|
||||
expect(aliasMap).toHaveProperty("openclaw/plugin-sdk");
|
||||
expect(aliasMap["openclaw/plugin-sdk"]).toMatch(/plugin-sdk[\\/]root-alias\.cjs$/);
|
||||
expect(aliasMap["openclaw/plugin-sdk/channel-runtime"]).toMatch(
|
||||
/plugin-sdk[\\/](channel-runtime\.ts|channel-runtime\.js)$/,
|
||||
);
|
||||
expect(runtimeModulePath).toMatch(/plugins[\\/]runtime[\\/]index\.(ts|js)$/);
|
||||
tryNative: false,
|
||||
});
|
||||
expect(withAlias(copiedChannelRuntime)).toMatchObject({
|
||||
copiedRuntimeMarker: {
|
||||
PAIRING_APPROVED_MESSAGE: "paired",
|
||||
resolveOutboundSendDep: expect.any(Function),
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -3595,9 +3595,9 @@ export const syntheticRuntimeMarker = {
|
||||
...__testing.buildPluginLoaderJitiOptions({}),
|
||||
tryNative: false,
|
||||
});
|
||||
await expect(withoutAlias.import(copiedChannelRuntime)).rejects.toThrow(
|
||||
/plugin-sdk\/channel-runtime/,
|
||||
);
|
||||
// The production loader uses sync Jiti evaluation, so this boundary should
|
||||
// follow the same path instead of the async import helper.
|
||||
expect(() => withoutAlias(copiedChannelRuntime)).toThrow();
|
||||
|
||||
const withAlias = createJiti(jitiBaseUrl, {
|
||||
...__testing.buildPluginLoaderJitiOptions({
|
||||
@ -3605,74 +3605,13 @@ export const syntheticRuntimeMarker = {
|
||||
}),
|
||||
tryNative: false,
|
||||
});
|
||||
await expect(withAlias.import(copiedChannelRuntime)).resolves.toMatchObject({
|
||||
expect(withAlias(copiedChannelRuntime)).toMatchObject({
|
||||
syntheticRuntimeMarker: {
|
||||
resolveOutboundSendDep: expect.any(Function),
|
||||
},
|
||||
});
|
||||
}, 240_000);
|
||||
|
||||
it("loads copied imessage runtime sources from git-style paths with plugin-sdk aliases (#49806)", async () => {
|
||||
const copiedExtensionRoot = path.join(makeTempDir(), "extensions", "imessage");
|
||||
const copiedSourceDir = path.join(copiedExtensionRoot, "src");
|
||||
const copiedPluginSdkDir = path.join(copiedExtensionRoot, "plugin-sdk");
|
||||
mkdirSafe(copiedSourceDir);
|
||||
mkdirSafe(copiedPluginSdkDir);
|
||||
const jitiBaseFile = path.join(copiedSourceDir, "__jiti-base__.mjs");
|
||||
fs.writeFileSync(jitiBaseFile, "export {};\n", "utf-8");
|
||||
fs.writeFileSync(
|
||||
path.join(copiedSourceDir, "channel.runtime.ts"),
|
||||
`import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime";
|
||||
import { PAIRING_APPROVED_MESSAGE } from "../runtime-api.js";
|
||||
|
||||
export const copiedRuntimeMarker = {
|
||||
resolveOutboundSendDep,
|
||||
PAIRING_APPROVED_MESSAGE,
|
||||
};
|
||||
`,
|
||||
"utf-8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(copiedExtensionRoot, "runtime-api.ts"),
|
||||
`export const PAIRING_APPROVED_MESSAGE = "paired";
|
||||
`,
|
||||
"utf-8",
|
||||
);
|
||||
const copiedChannelRuntimeShim = path.join(copiedPluginSdkDir, "channel-runtime.ts");
|
||||
fs.writeFileSync(
|
||||
copiedChannelRuntimeShim,
|
||||
`export function resolveOutboundSendDep() {
|
||||
return "shimmed";
|
||||
}
|
||||
`,
|
||||
"utf-8",
|
||||
);
|
||||
const copiedChannelRuntime = path.join(copiedExtensionRoot, "src", "channel.runtime.ts");
|
||||
const jitiBaseUrl = pathToFileURL(jitiBaseFile).href;
|
||||
|
||||
const createJiti = await getCreateJiti();
|
||||
const withoutAlias = createJiti(jitiBaseUrl, {
|
||||
...__testing.buildPluginLoaderJitiOptions({}),
|
||||
tryNative: false,
|
||||
});
|
||||
await expect(withoutAlias.import(copiedChannelRuntime)).rejects.toThrow(
|
||||
/plugin-sdk\/channel-runtime/,
|
||||
);
|
||||
|
||||
const withAlias = createJiti(jitiBaseUrl, {
|
||||
...__testing.buildPluginLoaderJitiOptions({
|
||||
"openclaw/plugin-sdk/channel-runtime": copiedChannelRuntimeShim,
|
||||
}),
|
||||
tryNative: false,
|
||||
});
|
||||
await expect(withAlias.import(copiedChannelRuntime)).resolves.toMatchObject({
|
||||
copiedRuntimeMarker: {
|
||||
PAIRING_APPROVED_MESSAGE: "paired",
|
||||
resolveOutboundSendDep: expect.any(Function),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("loads source TypeScript plugins that route through local runtime shims", () => {
|
||||
const plugin = writePlugin({
|
||||
id: "source-runtime-shim",
|
||||
|
||||
20
test/fixtures/test-parallel.behavior.json
vendored
20
test/fixtures/test-parallel.behavior.json
vendored
@ -183,6 +183,26 @@
|
||||
"file": "src/infra/heartbeat-runner.returns-default-unset.test.ts",
|
||||
"reason": "Heartbeat default-unset coverage retained a large shared unit-fast heap spike on Linux Node 22 CI."
|
||||
},
|
||||
{
|
||||
"file": "src/infra/heartbeat-runner.ghost-reminder.test.ts",
|
||||
"reason": "Mocks jiti at file scope, so it is safer outside shared Vitest workers."
|
||||
},
|
||||
{
|
||||
"file": "src/infra/heartbeat-runner.transcript-prune.test.ts",
|
||||
"reason": "Mocks jiti at file scope, so it is safer outside shared Vitest workers."
|
||||
},
|
||||
{
|
||||
"file": "src/infra/heartbeat-runner.sender-prefers-delivery-target.test.ts",
|
||||
"reason": "Mocks jiti at file scope, so it is safer outside shared Vitest workers."
|
||||
},
|
||||
{
|
||||
"file": "src/infra/heartbeat-runner.model-override.test.ts",
|
||||
"reason": "Mocks jiti at file scope, so it is safer outside shared Vitest workers."
|
||||
},
|
||||
{
|
||||
"file": "src/plugins/loader.git-path-regression.test.ts",
|
||||
"reason": "Constructs a real Jiti boundary and is safer outside shared workers that may have mocked jiti earlier."
|
||||
},
|
||||
{
|
||||
"file": "src/infra/outbound/outbound-session.test.ts",
|
||||
"reason": "Outbound session coverage retained a large shared unit-fast heap spike on Linux Node 22 CI."
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user