From d25f6f183381ab55d20f2f85f7598903428e1187 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 20 Mar 2026 00:37:31 -0700 Subject: [PATCH 01/11] fix(ci): restore full loader regression coverage --- src/plugins/loader.git-path-regression.test.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/plugins/loader.git-path-regression.test.ts b/src/plugins/loader.git-path-regression.test.ts index 019f15f6870..f69ae9fbe84 100644 --- a/src/plugins/loader.git-path-regression.test.ts +++ b/src/plugins/loader.git-path-regression.test.ts @@ -107,8 +107,6 @@ if (runtimeProbe !== "function") { () => loadOpenClawPlugins({ cache: false, - activate: false, - mode: "validate", workspaceDir: gitExtensionRoot, config: { plugins: { From 68a274c7b36a2e2b1e0d3ab60a11b7b806cc6797 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 20 Mar 2026 00:43:03 -0700 Subject: [PATCH 02/11] fix(ci): isolate loader git-path regression env roots --- src/plugins/loader.git-path-regression.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/plugins/loader.git-path-regression.test.ts b/src/plugins/loader.git-path-regression.test.ts index f69ae9fbe84..f9f56ba7cf0 100644 --- a/src/plugins/loader.git-path-regression.test.ts +++ b/src/plugins/loader.git-path-regression.test.ts @@ -38,6 +38,7 @@ describe("loadOpenClawPlugins", () => { "extensions", pluginId, ); + const stateDir = makeTempDir(); const gitSourceDir = path.join(gitExtensionRoot, "src"); mkdirSafe(gitSourceDir); @@ -103,6 +104,8 @@ if (runtimeProbe !== "function") { NODE_ENV: "production", VITEST: undefined, OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins", + OPENCLAW_HOME: undefined, + OPENCLAW_STATE_DIR: stateDir, }, () => loadOpenClawPlugins({ From f0a0a6a5b4a326ca6a840d7a534628dc5f9ab9b7 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 20 Mar 2026 00:57:25 -0700 Subject: [PATCH 03/11] test(plugins): isolate git path alias regression --- .../loader.git-path-regression.test.ts | 144 +++++++----------- src/plugins/loader.test.ts | 61 -------- 2 files changed, 58 insertions(+), 147 deletions(-) diff --git a/src/plugins/loader.git-path-regression.test.ts b/src/plugins/loader.git-path-regression.test.ts index f9f56ba7cf0..911f8584af2 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 { loadOpenClawPlugins } from "./loader.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,97 +32,66 @@ afterEach(() => { } }); -describe("loadOpenClawPlugins", () => { - it("loads git-style package extension entries through the plugin loader when they import plugin-sdk channel-runtime (#49806)", () => { - const pluginId = "imessage-loader-regression"; - const gitExtensionRoot = path.join( - makeTempDir(), - "git-source-checkout", - "extensions", - pluginId, - ); - const stateDir = makeTempDir(); - 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; -} +export const copiedRuntimeMarker = { + resolveOutboundSendDep, + PAIRING_APPROVED_MESSAGE, +}; `, "utf-8", ); fs.writeFileSync( - path.join(gitSourceDir, "index.ts"), - `import { runtimeProbeType } from "./channel.runtime.ts"; - -const runtimeProbe = runtimeProbeType(); - -export default { - id: ${JSON.stringify(pluginId)}, - runtimeProbe, - register() {}, -}; - -if (runtimeProbe !== "function") { - throw new Error("channel-runtime import did not resolve"); + 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 registry = withEnv( - { - NODE_ENV: "production", - VITEST: undefined, - OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins", - OPENCLAW_HOME: undefined, - OPENCLAW_STATE_DIR: stateDir, - }, - () => - loadOpenClawPlugins({ - cache: false, - workspaceDir: gitExtensionRoot, - config: { - plugins: { - load: { paths: [gitExtensionRoot] }, - allow: [pluginId], - }, - }, - }), + 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 record = registry.plugins.find((entry) => entry.id === pluginId); - expect(record?.status).toBe("loaded"); - }, 120_000); + + 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), + }, + }); + }); }); diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 3425a1fe14f..6ead443d2b2 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -3612,67 +3612,6 @@ export const syntheticRuntimeMarker = { }); }, 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", From 95f890a8b2246b82171080f097e85ff01c1be0c9 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 20 Mar 2026 01:07:29 -0700 Subject: [PATCH 04/11] test(plugins): relax jiti error string assertions --- src/plugins/loader.git-path-regression.test.ts | 7 ++++--- src/plugins/loader.test.ts | 7 ++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/plugins/loader.git-path-regression.test.ts b/src/plugins/loader.git-path-regression.test.ts index 911f8584af2..fde7d6554bc 100644 --- a/src/plugins/loader.git-path-regression.test.ts +++ b/src/plugins/loader.git-path-regression.test.ts @@ -77,9 +77,10 @@ export const copiedRuntimeMarker = { ...__testing.buildPluginLoaderJitiOptions({}), tryNative: false, }); - await expect(withoutAlias.import(copiedChannelRuntime)).rejects.toThrow( - /plugin-sdk\/channel-runtime/, - ); + // Jiti's pre-alias failure text varies across Node versions and platforms. + // The contract is simply that the source import rejects until the scoped + // plugin-sdk alias is applied. + await expect(withoutAlias.import(copiedChannelRuntime)).rejects.toThrow(); const withAlias = createJiti(jitiBaseUrl, { ...__testing.buildPluginLoaderJitiOptions({ diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 6ead443d2b2..4f6132a3bd5 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -3595,9 +3595,10 @@ export const syntheticRuntimeMarker = { ...__testing.buildPluginLoaderJitiOptions({}), tryNative: false, }); - await expect(withoutAlias.import(copiedChannelRuntime)).rejects.toThrow( - /plugin-sdk\/channel-runtime/, - ); + // Jiti's pre-alias failure text varies across Node versions and platforms. + // This boundary only needs to prove the source import rejects until the + // plugin-sdk alias is present. + await expect(withoutAlias.import(copiedChannelRuntime)).rejects.toThrow(); const withAlias = createJiti(jitiBaseUrl, { ...__testing.buildPluginLoaderJitiOptions({ From 0fae764f107dbd787df046f1496436fde0359c74 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 20 Mar 2026 01:12:05 -0700 Subject: [PATCH 05/11] test(plugins): use sync jiti regression path --- src/plugins/loader.git-path-regression.test.ts | 9 ++++----- src/plugins/loader.test.ts | 9 ++++----- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/src/plugins/loader.git-path-regression.test.ts b/src/plugins/loader.git-path-regression.test.ts index fde7d6554bc..23ab4f4243d 100644 --- a/src/plugins/loader.git-path-regression.test.ts +++ b/src/plugins/loader.git-path-regression.test.ts @@ -77,10 +77,9 @@ export const copiedRuntimeMarker = { ...__testing.buildPluginLoaderJitiOptions({}), tryNative: false, }); - // Jiti's pre-alias failure text varies across Node versions and platforms. - // The contract is simply that the source import rejects until the scoped - // plugin-sdk alias is applied. - await expect(withoutAlias.import(copiedChannelRuntime)).rejects.toThrow(); + // 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({ @@ -88,7 +87,7 @@ export const copiedRuntimeMarker = { }), tryNative: false, }); - await expect(withAlias.import(copiedChannelRuntime)).resolves.toMatchObject({ + 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 4f6132a3bd5..a4bf12fad15 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -3595,10 +3595,9 @@ export const syntheticRuntimeMarker = { ...__testing.buildPluginLoaderJitiOptions({}), tryNative: false, }); - // Jiti's pre-alias failure text varies across Node versions and platforms. - // This boundary only needs to prove the source import rejects until the - // plugin-sdk alias is present. - await expect(withoutAlias.import(copiedChannelRuntime)).rejects.toThrow(); + // 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({ @@ -3606,7 +3605,7 @@ export const syntheticRuntimeMarker = { }), tryNative: false, }); - await expect(withAlias.import(copiedChannelRuntime)).resolves.toMatchObject({ + expect(withAlias(copiedChannelRuntime)).toMatchObject({ syntheticRuntimeMarker: { resolveOutboundSendDep: expect.any(Function), }, From dc06e4fd2223834bb7b21b73ea58c205ba96eb23 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 20 Mar 2026 01:20:12 -0700 Subject: [PATCH 06/11] ci: collapse extra workflow guards into check-additional --- .github/workflows/ci.yml | 140 ++++++++++++++++----------------------- 1 file changed, 56 insertions(+), 84 deletions(-) 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] From d774b3f274e17c55eb4f0c5c2ccd84dd9f463c93 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 20 Mar 2026 01:24:32 -0700 Subject: [PATCH 07/11] fix(ci): isolate jiti-mocked test files --- test/fixtures/test-parallel.behavior.json | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) 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." From df536c324841542636a576bd7b1a70bf8a0c467f Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 20 Mar 2026 01:33:16 -0700 Subject: [PATCH 08/11] test(signal): harden tool-result infra-runtime mock --- .../src/monitor.tool-result.test-harness.ts | 40 ++++++++++++------- 1 file changed, 26 insertions(+), 14 deletions(-) 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), From 6cb2fc501ace7f6319b7c1fcf854db9e44d50f3a Mon Sep 17 00:00:00 2001 From: Bijin <38134380+sliverp@users.noreply.github.com> Date: Fri, 20 Mar 2026 16:51:32 +0800 Subject: [PATCH 09/11] Community plugins - Add QQbot (#29898) Merged via squash. Prepared head SHA: c776a12d15d029e4a4858ba12653ba9bafcf6949 Co-authored-by: sliverp <38134380+sliverp@users.noreply.github.com> Co-authored-by: frankekn <4488090+frankekn@users.noreply.github.com> Reviewed-by: @frankekn --- CHANGELOG.md | 1 + docs/plugins/community.md | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 553fab9d3a8..697bdd2e29b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,7 @@ 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 QQbot plugin listing to the docs catalog. (#29898) Thanks @sliverp. ### Fixes diff --git a/docs/plugins/community.md b/docs/plugins/community.md index 94c6ddbe00d..ebd660ccdbd 100644 --- a/docs/plugins/community.md +++ b/docs/plugins/community.md @@ -45,6 +45,11 @@ Use this format when adding entries: ## Listed plugins +- **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` From 192f85932535adca53a0d9ef11b90500da11f280 Mon Sep 17 00:00:00 2001 From: Bijin <38134380+sliverp@users.noreply.github.com> Date: Fri, 20 Mar 2026 16:58:51 +0800 Subject: [PATCH 10/11] Add Community plugins - openclaw-dingtalk (#29913) Merged via squash. Prepared head SHA: e8e99997cb83b8f88cc89abb7fc0b96570ef313f Co-authored-by: sliverp <38134380+sliverp@users.noreply.github.com> Co-authored-by: frankekn <4488090+frankekn@users.noreply.github.com> Reviewed-by: @frankekn --- CHANGELOG.md | 1 + docs/plugins/community.md | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 697bdd2e29b..35b5bdcad66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,7 @@ 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 diff --git a/docs/plugins/community.md b/docs/plugins/community.md index ebd660ccdbd..12df6c3eee0 100644 --- a/docs/plugins/community.md +++ b/docs/plugins/community.md @@ -45,6 +45,10 @@ 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` From 57f1cf66ad334b8835dd780ac42b5dcebf7972fa Mon Sep 17 00:00:00 2001 From: caesargattuso Date: Fri, 20 Mar 2026 17:26:54 +0800 Subject: [PATCH 11/11] fix(gateway): skip seq-gap broadcast for stale post-lifecycle events (#43751) * fix: stop stale gateway seq-gap errors (#43751) (thanks @caesargattuso) * fix: keep agent.request run ids session-scoped --------- Co-authored-by: Ayaan Zaidi --- CHANGELOG.md | 1 + src/gateway/server-chat.agent-events.test.ts | 40 ++++++++++++++++++++ src/gateway/server-chat.ts | 2 +- src/gateway/server-node-events.test.ts | 9 +++++ src/gateway/server-node-events.ts | 8 ++-- 5 files changed, 56 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 35b5bdcad66..2c8098c0578 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -118,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/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,