diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d892d3f30df..eaee7ea9412 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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] diff --git a/CHANGELOG.md b/CHANGELOG.md index 553fab9d3a8..2c8098c0578 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/plugins/community.md b/docs/plugins/community.md index 94c6ddbe00d..12df6c3eee0 100644 --- a/docs/plugins/community.md +++ b/docs/plugins/community.md @@ -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` diff --git a/extensions/signal/src/monitor.tool-result.test-harness.ts b/extensions/signal/src/monitor.tool-result.test-harness.ts index ad81a4d6da2..7f1c8b7d7cf 100644 --- a/extensions/signal/src/monitor.tool-result.test-harness.ts +++ b/extensions/signal/src/monitor.tool-result.test-harness.ts @@ -66,8 +66,12 @@ export function createMockSignalDaemonHandle( }; } -vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => { - const actual = await importOriginal(); +// 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( + "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(); +vi.mock("openclaw/plugin-sdk/reply-runtime", async () => { + const actual = await vi.importActual( + "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(); +vi.mock("./send.js", async () => { + const actual = await vi.importActual("./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(); +vi.mock("openclaw/plugin-sdk/conversation-runtime", async () => { + const actual = await vi.importActual( + "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(); +vi.mock("openclaw/plugin-sdk/security-runtime", async () => { + const actual = await vi.importActual( + "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(); +vi.mock("./daemon.js", async () => { + const actual = await vi.importActual("./daemon.js"); return { ...actual, spawnSignalDaemon: (...args: unknown[]) => spawnSignalDaemonMock(...args), }; }); -vi.mock("openclaw/plugin-sdk/infra-runtime", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("openclaw/plugin-sdk/infra-runtime", async () => { + const actual = await vi.importActual( + "openclaw/plugin-sdk/infra-runtime", + ); return { ...actual, waitForTransportReady: (...args: unknown[]) => waitForTransportReadyMock(...args), diff --git a/src/gateway/server-chat.agent-events.test.ts b/src/gateway/server-chat.agent-events.test.ts index 72eb09c8643..dd644955afc 100644 --- a/src/gateway/server-chat.agent-events.test.ts +++ b/src/gateway/server-chat.agent-events.test.ts @@ -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); diff --git a/src/gateway/server-chat.ts b/src/gateway/server-chat.ts index 0579f4083c0..7fda61b6c0c 100644 --- a/src/gateway/server-chat.ts +++ b/src/gateway/server-chat.ts @@ -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", diff --git a/src/gateway/server-node-events.test.ts b/src/gateway/server-node-events.test.ts index a5a7578ddbc..dbf1bde579f 100644 --- a/src/gateway/server-node-events.test.ts +++ b/src/gateway/server-node-events.test.ts @@ -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); }); }); diff --git a/src/gateway/server-node-events.ts b/src/gateway/server-node-events.ts index c2aa3c454c7..2e9e911725a 100644 --- a/src/gateway/server-node-events.ts +++ b/src/gateway/server-node-events.ts @@ -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, diff --git a/src/plugins/loader.git-path-regression.test.ts b/src/plugins/loader.git-path-regression.test.ts index c1eea647a40..23ab4f4243d 100644 --- a/src/plugins/loader.git-path-regression.test.ts +++ b/src/plugins/loader.git-path-regression.test.ts @@ -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 | 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), + }, + }); }); }); diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 3425a1fe14f..a4bf12fad15 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -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", diff --git a/test/fixtures/test-parallel.behavior.json b/test/fixtures/test-parallel.behavior.json index 2de992a45d5..954b5f87557 100644 --- a/test/fixtures/test-parallel.behavior.json +++ b/test/fixtures/test-parallel.behavior.json @@ -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."