From 776c39b00c96ce4a00376ddd234d578c492ddb05 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 11 May 2026 13:01:18 +0100 Subject: [PATCH 01/93] test: tighten gateway readiness assertions --- src/gateway/client-start-readiness.test.ts | 8 ++++++-- src/gateway/server.device-pair-approve-supersede.test.ts | 5 +++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/gateway/client-start-readiness.test.ts b/src/gateway/client-start-readiness.test.ts index c90527b8b00..444b1c6f250 100644 --- a/src/gateway/client-start-readiness.test.ts +++ b/src/gateway/client-start-readiness.test.ts @@ -16,7 +16,9 @@ describe("startGatewayClientWhenEventLoopReady", () => { await vi.advanceTimersByTimeAsync(1); expect(client.start).not.toHaveBeenCalled(); await vi.advanceTimersByTimeAsync(1); - await expect(promise).resolves.toMatchObject({ ready: true }); + const readiness = await promise; + expect(readiness.ready).toBe(true); + expect(readiness.aborted).toBe(false); expect(client.start).toHaveBeenCalledTimes(1); }); @@ -32,7 +34,9 @@ describe("startGatewayClientWhenEventLoopReady", () => { }); controller.abort(); - await expect(promise).resolves.toMatchObject({ ready: false, aborted: true }); + const readiness = await promise; + expect(readiness.ready).toBe(false); + expect(readiness.aborted).toBe(true); expect(client.start).not.toHaveBeenCalled(); }); }); diff --git a/src/gateway/server.device-pair-approve-supersede.test.ts b/src/gateway/server.device-pair-approve-supersede.test.ts index b7937b3cdc2..fef29daa6ff 100644 --- a/src/gateway/server.device-pair-approve-supersede.test.ts +++ b/src/gateway/server.device-pair-approve-supersede.test.ts @@ -36,7 +36,8 @@ describe("gateway device.pair.approve superseded request ids", () => { expect(latestApprove?.status).toBe("approved"); const paired = await getPairedDevice("supersede-device-1"); - expect(paired?.roles).toEqual(expect.arrayContaining(["node", "operator"])); - expect(paired?.scopes).toEqual(expect.arrayContaining(["operator.admin"])); + expect(paired?.roles).toContain("node"); + expect(paired?.roles).toContain("operator"); + expect(paired?.scopes).toContain("operator.admin"); }); }); From 8e92b069d26e30ee816d7f113f163180fec62419 Mon Sep 17 00:00:00 2001 From: samzong Date: Wed, 6 May 2026 14:47:55 +0800 Subject: [PATCH 02/93] fix(ui): remove reverted plugin allow entries Signed-off-by: samzong --- ui/src/ui/controllers/config.test.ts | 56 ++++++++++++++++++++ ui/src/ui/controllers/config.ts | 79 +++++++++++++++++++++++++++- 2 files changed, 134 insertions(+), 1 deletion(-) diff --git a/ui/src/ui/controllers/config.test.ts b/ui/src/ui/controllers/config.test.ts index d3c4ac2a90b..4f114f61f15 100644 --- a/ui/src/ui/controllers/config.test.ts +++ b/ui/src/ui/controllers/config.test.ts @@ -268,6 +268,62 @@ describe("updateConfigFormValue", () => { expect(state.configFormDirty).toBe(false); }); + + it("removes only automatically added plugin allow entries", () => { + const state = createState(); + applyConfigSnapshot(state, { + hash: "hash-plugins", + config: { + plugins: { + allow: ["openai"], + entries: { + deepseek: { enabled: false }, + }, + }, + }, + valid: true, + issues: [], + raw: "{}", + }); + + updateConfigFormValue(state, ["plugins", "entries", "deepseek", "enabled"], true); + + expect(state.configForm).toEqual({ + plugins: { + allow: ["openai", "deepseek"], + entries: { + deepseek: { enabled: true }, + }, + }, + }); + expect(state.configFormDirty).toBe(true); + + updateConfigFormValue(state, ["plugins", "entries", "deepseek", "enabled"], false); + + expect(state.configForm).toEqual({ + plugins: { + allow: ["openai"], + entries: { + deepseek: { enabled: false }, + }, + }, + }); + expect(state.configFormDirty).toBe(false); + + updateConfigFormValue(state, ["plugins", "entries", "deepseek", "enabled"], true); + updateConfigFormValue(state, ["plugins", "allow"], ["openai", "deepseek", "firecrawl"]); + updateConfigFormValue(state, ["plugins", "entries", "deepseek", "enabled"], false); + + expect(state.configForm).toEqual({ + plugins: { + allow: ["openai", "deepseek", "firecrawl"], + entries: { + deepseek: { enabled: false }, + }, + }, + }); + expect(state.configFormDirty).toBe(true); + }); }); describe("stageConfigPreset", () => { diff --git a/ui/src/ui/controllers/config.ts b/ui/src/ui/controllers/config.ts index 1d3a5299111..f7a4ed8e4dc 100644 --- a/ui/src/ui/controllers/config.ts +++ b/ui/src/ui/controllers/config.ts @@ -40,6 +40,8 @@ export type ConfigState = { lastError: string | null; }; +const autoAllowlistedPluginIdsByState = new WeakMap>(); + export type LoadConfigOptions = { discardPendingChanges?: boolean; }; @@ -118,6 +120,7 @@ export function applyConfigSnapshot( state.configRawOriginal = rawFromSnapshot; state.configFormDirty = false; state.configDraftBaseHash = snapshot.hash ?? null; + autoAllowlistedPluginIdsByState.delete(state); } else { state.configDraftBaseHash = draftBaseHash; } @@ -210,6 +213,7 @@ async function submitConfigChange( await state.client.request(method, { raw, baseHash, ...extraParams }); state.configFormDirty = false; state.configDraftBaseHash = null; + autoAllowlistedPluginIdsByState.delete(state); await loadConfig(state); return true; } catch (err) { @@ -279,12 +283,84 @@ function mutateConfigForm(state: ConfigState, mutate: (draft: Record, + path: Array, + value: unknown, +) { + if ( + path.length !== 4 || + path[0] !== "plugins" || + path[1] !== "entries" || + typeof path[2] !== "string" || + path[3] !== "enabled" + ) { + return; + } + const pluginId = path[2]; + const plugins = + draft.plugins && typeof draft.plugins === "object" && !Array.isArray(draft.plugins) + ? (draft.plugins as Record) + : null; + const allow = Array.isArray(plugins?.allow) ? plugins.allow : null; + if (!allow) { + untrackAutoAllowlistedPluginId(state, pluginId); + return; + } + if (value === true) { + if (allow.includes(pluginId)) { + return; + } + setPathValue(draft, ["plugins", "allow"], [...allow, pluginId]); + trackAutoAllowlistedPluginId(state, pluginId); + return; + } + const autoAllowlistedPluginIds = autoAllowlistedPluginIdsByState.get(state); + if (!autoAllowlistedPluginIds?.has(pluginId)) { + return; + } + setPathValue( + draft, + ["plugins", "allow"], + allow.filter((entry) => entry !== pluginId), + ); + untrackAutoAllowlistedPluginId(state, pluginId); +} + export function updateConfigFormValue( state: ConfigState, path: Array, value: unknown, ) { - mutateConfigForm(state, (draft) => setPathValue(draft, path, value)); + mutateConfigForm(state, (draft) => { + setPathValue(draft, path, value); + if (path[0] === "plugins" && path[1] === "allow") { + autoAllowlistedPluginIdsByState.delete(state); + return; + } + syncEnabledPluginAllowlist(state, draft, path, value); + }); } export function stageConfigPreset(state: ConfigState, patch: Record) { @@ -315,6 +391,7 @@ export function resetConfigPendingChanges(state: ConfigState) { serializeConfigForm(state.configFormOriginal ?? state.configSnapshot?.config ?? {}); state.configFormDirty = false; state.configDraftBaseHash = state.configSnapshot?.hash ?? null; + autoAllowlistedPluginIdsByState.delete(state); } export function removeConfigFormValue(state: ConfigState, path: Array) { From adafd4f5be3fbf2e90932dab7d83e54044a873d2 Mon Sep 17 00:00:00 2001 From: samzong Date: Wed, 6 May 2026 15:21:35 +0800 Subject: [PATCH 03/93] docs(changelog): note plugin allowlist revert fix Signed-off-by: samzong --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ac566c0fb76..9961c1a35aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -144,6 +144,7 @@ Docs: https://docs.openclaw.ai - Control UI: show compact one-line live/idle/terminal run status badges in the Sessions table and rename the active-minute filter to its updated-within meaning. Fixes #78307. Thanks @BunsDev. - Control UI: scope chat session-list refreshes by agent and skip disk-only agent store discovery for configured-only lists, preventing post-first-message session switching stalls on large Windows stores. Fixes #79675. Thanks @lovelefeng-glitch, @BunsDev. - Control UI: allow Appearance tweakcn theme imports through the served CSP so browser-local custom theme links no longer fail with a `connect-src` violation. Fixes #78504. Thanks @BunsDev. +- Control UI/config: remove plugin allowlist entries that the form auto-added when a plugin enable toggle is reverted before saving, so reverting the visible toggle clears dirty state without persisting unintended allowlist changes. (#78329) Thanks @samzong. - Media/host-read: allow buffer-verified gzip, tar, and 7z archives in the shared host-local media validator alongside ZIP and document attachments. - Plugins/doctor: invalidate persisted plugin registry snapshots when plugin diagnostics point at deleted source paths, so `openclaw doctor` stops repeating stale warnings after a local extension is replaced by a managed npm plugin. Fixes #80087. (#80134) Thanks @hclsys. - Doctor/OpenAI Codex: preserve Codex auth intent when auto-repairing legacy `openai-codex/*` model refs to canonical `openai/*` by adding provider/model-scoped Codex runtime policy, preventing repaired configs from falling through to direct OpenAI API-key auth. Fixes #78533 and #78570. Thanks @superck110 and @Azmodump. From 3e87e556049dc2d484fc4ca511fdee1ed6a1dfcc Mon Sep 17 00:00:00 2001 From: samzong Date: Wed, 6 May 2026 15:44:19 +0800 Subject: [PATCH 04/93] fix(ui): preserve empty plugin allowlists Signed-off-by: samzong --- ui/src/ui/controllers/config.test.ts | 42 ++++++++++++++++++++++++++++ ui/src/ui/controllers/config.ts | 4 +++ 2 files changed, 46 insertions(+) diff --git a/ui/src/ui/controllers/config.test.ts b/ui/src/ui/controllers/config.test.ts index 4f114f61f15..b44bdc6487d 100644 --- a/ui/src/ui/controllers/config.test.ts +++ b/ui/src/ui/controllers/config.test.ts @@ -324,6 +324,48 @@ describe("updateConfigFormValue", () => { }); expect(state.configFormDirty).toBe(true); }); + + it("preserves empty plugin allowlists when enabling a plugin", () => { + const state = createState(); + applyConfigSnapshot(state, { + hash: "hash-plugins", + config: { + plugins: { + allow: [], + entries: { + deepseek: { enabled: false }, + }, + }, + }, + valid: true, + issues: [], + raw: "{}", + }); + + updateConfigFormValue(state, ["plugins", "entries", "deepseek", "enabled"], true); + + expect(state.configForm).toEqual({ + plugins: { + allow: [], + entries: { + deepseek: { enabled: true }, + }, + }, + }); + expect(state.configFormDirty).toBe(true); + + updateConfigFormValue(state, ["plugins", "entries", "deepseek", "enabled"], false); + + expect(state.configForm).toEqual({ + plugins: { + allow: [], + entries: { + deepseek: { enabled: false }, + }, + }, + }); + expect(state.configFormDirty).toBe(false); + }); }); describe("stageConfigPreset", () => { diff --git a/ui/src/ui/controllers/config.ts b/ui/src/ui/controllers/config.ts index f7a4ed8e4dc..2be2702ad5c 100644 --- a/ui/src/ui/controllers/config.ts +++ b/ui/src/ui/controllers/config.ts @@ -332,6 +332,10 @@ function syncEnabledPluginAllowlist( if (allow.includes(pluginId)) { return; } + if (allow.length === 0) { + untrackAutoAllowlistedPluginId(state, pluginId); + return; + } setPathValue(draft, ["plugins", "allow"], [...allow, pluginId]); trackAutoAllowlistedPluginId(state, pluginId); return; From c1b29bf5db09440878f6c96f7c237c2af84cce9d Mon Sep 17 00:00:00 2001 From: samzong Date: Sat, 9 May 2026 01:27:53 +0800 Subject: [PATCH 05/93] fix(gateway): scope session resolve store loads --- src/gateway/sessions-resolve-store.test.ts | 95 ++++++++++++++++++++++ src/gateway/sessions-resolve.test.ts | 16 +++- src/gateway/sessions-resolve.ts | 4 +- 3 files changed, 109 insertions(+), 6 deletions(-) diff --git a/src/gateway/sessions-resolve-store.test.ts b/src/gateway/sessions-resolve-store.test.ts index 98b0de93a37..758e377cbb8 100644 --- a/src/gateway/sessions-resolve-store.test.ts +++ b/src/gateway/sessions-resolve-store.test.ts @@ -40,6 +40,101 @@ describe("resolveSessionKeyFromResolveParams store canonicalization", () => { }); }); + it("does not resolve another agent store when agentId is scoped", async () => { + await withStateDirEnv("openclaw-sessions-resolve-agent-scope-", async () => { + const cfg: OpenClawConfig = { + agents: { list: [{ id: "main", default: true }, { id: "work" }] }, + }; + const workStorePath = resolveStorePath(cfg.session?.store, { agentId: "work" }); + await saveSessionStore(workStorePath, { + "agent:work:target": { + sessionId: "sess-shared", + label: "shared-label", + updatedAt: freshUpdatedAt(), + }, + }); + + await expect( + resolveSessionKeyFromResolveParams({ + cfg, + p: { sessionId: "sess-shared", agentId: "main" }, + }), + ).resolves.toEqual({ + ok: false, + error: { + code: ErrorCodes.INVALID_REQUEST, + message: "No session found: sess-shared", + }, + }); + + await expect( + resolveSessionKeyFromResolveParams({ + cfg, + p: { label: "shared-label", agentId: "main" }, + }), + ).resolves.toEqual({ + ok: false, + error: { + code: ErrorCodes.INVALID_REQUEST, + message: "No session found with label: shared-label", + }, + }); + }); + }); + + it("preserves cross-agent ambiguity when agentId is absent", async () => { + await withStateDirEnv("openclaw-sessions-resolve-cross-agent-", async () => { + const cfg: OpenClawConfig = { + agents: { list: [{ id: "main", default: true }, { id: "work" }] }, + }; + const updatedAt = freshUpdatedAt(); + await saveSessionStore(resolveStorePath(cfg.session?.store, { agentId: "main" }), { + "main-target": { + sessionId: "sess-shared", + label: "shared-label", + updatedAt, + }, + }); + await saveSessionStore(resolveStorePath(cfg.session?.store, { agentId: "work" }), { + "work-target": { + sessionId: "sess-shared", + label: "shared-label", + updatedAt, + }, + }); + + const sessionIdResult = await resolveSessionKeyFromResolveParams({ + cfg, + p: { sessionId: "sess-shared" }, + }); + expect(sessionIdResult.ok).toBe(false); + if (sessionIdResult.ok) { + throw new Error("expected ambiguous sessionId result"); + } + expect(sessionIdResult.error.code).toBe(ErrorCodes.INVALID_REQUEST); + expect(sessionIdResult.error.message).toContain( + "Multiple sessions found for sessionId: sess-shared", + ); + expect(sessionIdResult.error.message).toContain("agent:main:main-target"); + expect(sessionIdResult.error.message).toContain("agent:work:work-target"); + + const labelResult = await resolveSessionKeyFromResolveParams({ + cfg, + p: { label: "shared-label" }, + }); + expect(labelResult.ok).toBe(false); + if (labelResult.ok) { + throw new Error("expected ambiguous label result"); + } + expect(labelResult.error.code).toBe(ErrorCodes.INVALID_REQUEST); + expect(labelResult.error.message).toContain( + "Multiple sessions found with label: shared-label", + ); + expect(labelResult.error.message).toContain("agent:main:main-target"); + expect(labelResult.error.message).toContain("agent:work:work-target"); + }); + }); + it("still rejects non-alias agent:main matches when main is no longer configured", async () => { await withStateDirEnv("openclaw-sessions-resolve-stale-main-", async ({ stateDir }) => { const storePath = path.join(stateDir, "sessions.json"); diff --git a/src/gateway/sessions-resolve.test.ts b/src/gateway/sessions-resolve.test.ts index 481af06a226..7f21200874e 100644 --- a/src/gateway/sessions-resolve.test.ts +++ b/src/gateway/sessions-resolve.test.ts @@ -218,12 +218,16 @@ describe("resolveSessionKeyFromResolveParams", () => { throw new Error("session rows should not be materialized for exact sessionId lookup"); }); + const cfg = {}; const result = await resolveSessionKeyFromResolveParams({ - cfg: {}, - p: { sessionId: "sess-target" }, + cfg, + p: { sessionId: "sess-target", agentId: "main" }, }); expect(result).toEqual({ ok: true, key: "agent:main:target" }); + expect(hoisted.loadCombinedSessionStoreForGatewayMock).toHaveBeenCalledWith(cfg, { + agentId: "main", + }); expect(hoisted.listSessionsFromStoreMock).not.toHaveBeenCalled(); }); @@ -238,11 +242,15 @@ describe("resolveSessionKeyFromResolveParams", () => { }); hoisted.listAgentIdsMock.mockReturnValue(["main"]); + const cfg = {}; const result = await resolveSessionKeyFromResolveParams({ - cfg: {}, - p: { label: "my-label" }, + cfg, + p: { label: "my-label", agentId: "main" }, }); + expect(hoisted.loadCombinedSessionStoreForGatewayMock).toHaveBeenCalledWith(cfg, { + agentId: "main", + }); expect(result).toEqual({ ok: false, error: { diff --git a/src/gateway/sessions-resolve.ts b/src/gateway/sessions-resolve.ts index 7cb47832ede..40f85315153 100644 --- a/src/gateway/sessions-resolve.ts +++ b/src/gateway/sessions-resolve.ts @@ -166,7 +166,7 @@ export async function resolveSessionKeyFromResolveParams(params: { } if (hasSessionId) { - const { store } = loadCombinedSessionStoreForGateway(cfg); + const { store } = loadCombinedSessionStoreForGateway(cfg, { agentId: p.agentId }); const matches = findVisibleSessionIdMatches({ store, p, sessionId }); const selection = resolveSessionIdMatchSelection(matches, sessionId); if (selection.kind === "none") { @@ -200,7 +200,7 @@ export async function resolveSessionKeyFromResolveParams(params: { }; } - const { storePath, store } = loadCombinedSessionStoreForGateway(cfg); + const { storePath, store } = loadCombinedSessionStoreForGateway(cfg, { agentId: p.agentId }); const list = listSessionsFromStore({ cfg, storePath, From e0679d0d0cbfc8b642aadb6240c4694361e35be5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 11 May 2026 12:52:02 +0100 Subject: [PATCH 06/93] docs: add changelog for sessions resolve scope (#79474) (thanks @samzong) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9961c1a35aa..78f5b65853f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Gateway: scope `sessions.resolve` sessionId and label store loads to the requested agent so large unrelated agent stores are not parsed for scoped lookups. Fixes #51264. (#79474) Thanks @samzong. - Browser: report Chrome MCP existing-session page readiness in browser status without letting status probes exceed the client timeout. Fixes #80268. (#80280) Thanks @ai-hpc. - Memory: reject symlinked directory components in configured extra memory paths before reading Markdown files. (#80331) Thanks @samzong. - Sessions/transcripts: replace whole-file `readFile` scans with shared streaming helpers (`streamSessionTranscriptLines` and `streamSessionTranscriptLinesReverse`) for idempotency lookup, latest/tail assistant text reads, delivery-mirror dedupe, and compaction fork loading, so long-running sessions no longer materialize the full transcript in memory. Forward scans use `readline` over a bounded `createReadStream`; reverse scans read bounded chunks from the file end and decode complete JSONL lines newest-first without a fixed tail cap. Synthetic 200 MiB transcript: peak RSS delta drops from +252 MiB to +27 MiB while preserving malformed-line tolerance and idempotency-key return semantics. Fixes #54296. Thanks @jack-stormentswe. From 1d5785ba850cf1b6b395972f9f2a6db071f2ea28 Mon Sep 17 00:00:00 2001 From: Shakker Date: Mon, 11 May 2026 13:02:36 +0100 Subject: [PATCH 07/93] test: assert gateway restart handoffs --- src/cli/gateway-cli/run-loop.test.ts | 36 +++++++++++++++++++++------- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/src/cli/gateway-cli/run-loop.test.ts b/src/cli/gateway-cli/run-loop.test.ts index 76d11b3e8d9..22dfba59b56 100644 --- a/src/cli/gateway-cli/run-loop.test.ts +++ b/src/cli/gateway-cli/run-loop.test.ts @@ -283,6 +283,30 @@ async function createSignaledLoopHarness(exitCallOrder?: string[]) { return { close, start, runtime, exited, loopPromise }; } +function expectRestartHandoffCall(expected: { + restartKind: "full-process" | "update-process"; + reason: string | undefined; + supervisorMode: "external" | "launchd"; +}) { + expect(writeGatewayRestartHandoffSync).toHaveBeenCalledTimes(1); + const [handoff] = writeGatewayRestartHandoffSync.mock.calls[0] ?? []; + if (!handoff || typeof handoff !== "object" || Array.isArray(handoff)) { + throw new Error("expected restart handoff options object"); + } + const processInstanceId = (handoff as { processInstanceId?: unknown }).processInstanceId; + expect(typeof processInstanceId).toBe("string"); + if (typeof processInstanceId !== "string") { + throw new Error("expected restart handoff processInstanceId string"); + } + expect(processInstanceId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ); + expect(handoff).toEqual({ + ...expected, + processInstanceId, + }); +} + describe("runGatewayLoop", () => { it("exits 0 on SIGTERM after graceful close", async () => { vi.clearAllMocks(); @@ -405,9 +429,7 @@ describe("runGatewayLoop", () => { expect(waitForActiveEmbeddedRuns).not.toHaveBeenCalled(); expect(abortEmbeddedPiRun).toHaveBeenCalledWith(undefined, { mode: "all" }); expect(gatewayLog.warn).toHaveBeenCalledWith( - expect.stringContaining( - "restart blocked by active background task run(s): taskId=task-force", - ), + "restart blocked by active background task run(s): taskId=task-force runId=run-force status=running runtime=cron label=forced", ); expect(gatewayLog.warn).toHaveBeenCalledWith( "forced restart requested; skipping active work drain", @@ -662,10 +684,9 @@ describe("runGatewayLoop", () => { await expect(exited).resolves.toBe(0); expect(runtime.exit).toHaveBeenCalledWith(0); - expect(writeGatewayRestartHandoffSync).toHaveBeenCalledWith({ + expectRestartHandoffCall({ restartKind: "full-process", reason: undefined, - processInstanceId: expect.any(String), supervisorMode: "launchd", }); }); @@ -741,7 +762,7 @@ describe("runGatewayLoop", () => { expect(acquireGatewayLock).toHaveBeenCalledTimes(2); expect(start).toHaveBeenCalledTimes(1); expect(gatewayLog.error).toHaveBeenCalledWith( - expect.stringContaining("failed to reacquire gateway lock for in-process restart"), + "failed to reacquire gateway lock for in-process restart: Error: lock timeout", ); }); }); @@ -791,10 +812,9 @@ describe("runGatewayLoop", () => { await expect(exited).resolves.toBe(0); expect(runtime.exit).toHaveBeenCalledWith(0); - expect(writeGatewayRestartHandoffSync).toHaveBeenCalledWith({ + expectRestartHandoffCall({ restartKind: "update-process", reason: "update.run", - processInstanceId: expect.any(String), supervisorMode: "external", }); }); From e6b0b37e3fb95df256938b4ade0223801b657e10 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 11 May 2026 13:02:45 +0100 Subject: [PATCH 08/93] test: tighten websocket log assertions --- src/gateway/ws-log.test.ts | 43 ++++++++++++++++---------------------- 1 file changed, 18 insertions(+), 25 deletions(-) diff --git a/src/gateway/ws-log.test.ts b/src/gateway/ws-log.test.ts index bde66e5eccc..8fe7d7203de 100644 --- a/src/gateway/ws-log.test.ts +++ b/src/gateway/ws-log.test.ts @@ -89,31 +89,26 @@ describe("gateway ws log helpers", () => { }, }); - expect(summary).toMatchObject({ - agent: "main", - run: "12345678…9abc", - session: "main", - stream: "assistant", - aseq: 2, - media: 2, - }); + expect(summary.agent).toBe("main"); + expect(summary.run).toBe("12345678…9abc"); + expect(summary.session).toBe("main"); + expect(summary.stream).toBe("assistant"); + expect(summary.aseq).toBe(2); + expect(summary.media).toBe(2); expect(summary.text).toBeTypeOf("string"); expect(summary.text).not.toContain("\n"); }); test("summarizeAgentEventForWsLog includes tool metadata", () => { - expect( - summarizeAgentEventForWsLog({ - runId: "run-1", - stream: "tool", - data: { phase: "start", name: "fetch", toolCallId: "12345678-1234-1234-1234-123456789abc" }, - }), - ).toMatchObject({ - run: "run-1", + const summary = summarizeAgentEventForWsLog({ + runId: "run-1", stream: "tool", - tool: "start:fetch", - call: "12345678…9abc", + data: { phase: "start", name: "fetch", toolCallId: "12345678-1234-1234-1234-123456789abc" }, }); + expect(summary.run).toBe("run-1"); + expect(summary.stream).toBe("tool"); + expect(summary.tool).toBe("start:fetch"); + expect(summary.call).toBe("12345678…9abc"); }); test("summarizeAgentEventForWsLog includes lifecycle errors with compact previews", () => { @@ -128,13 +123,11 @@ describe("gateway ws log helpers", () => { }, }); - expect(summary).toMatchObject({ - agent: "main", - session: "thread-1", - stream: "lifecycle", - phase: "abort", - aborted: true, - }); + expect(summary.agent).toBe("main"); + expect(summary.session).toBe("thread-1"); + expect(summary.stream).toBe("lifecycle"); + expect(summary.phase).toBe("abort"); + expect(summary.aborted).toBe(true); expect(summary.error).toBeTypeOf("string"); expect((summary.error as string).length).toBeLessThanOrEqual(120); }); From d4c751998910b1fb3abd46924f3c819ca4d76d9d Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Mon, 11 May 2026 17:27:32 +0530 Subject: [PATCH 09/93] ci(mantis): allow fork telegram proof --- .../prompts/mantis-telegram-desktop-proof.md | 7 ++++ .../mantis-telegram-desktop-proof.yml | 40 +++++++++++++++++-- scripts/e2e/telegram-user-crabbox-proof.ts | 35 +++++++++++++++- ...is-telegram-desktop-proof-workflow.test.ts | 27 +++++++++++++ 4 files changed, 104 insertions(+), 5 deletions(-) diff --git a/.github/codex/prompts/mantis-telegram-desktop-proof.md b/.github/codex/prompts/mantis-telegram-desktop-proof.md index a17073b1e43..e3c4ec321be 100644 --- a/.github/codex/prompts/mantis-telegram-desktop-proof.md +++ b/.github/codex/prompts/mantis-telegram-desktop-proof.md @@ -24,6 +24,7 @@ Inputs are provided as environment variables: - `BASELINE_SHA` - `CANDIDATE_REF` - `CANDIDATE_SHA` +- `MANTIS_CANDIDATE_TRUST` - `MANTIS_OUTPUT_DIR` - `MANTIS_INSTRUCTIONS` - `CRABBOX_PROVIDER` @@ -44,6 +45,12 @@ Required workflow: `.artifacts/qa-e2e/mantis/telegram-desktop-proof-worktrees/baseline` and `.artifacts/qa-e2e/mantis/telegram-desktop-proof-worktrees/candidate`, then install and build each worktree with the repo's normal `pnpm` commands. + If `MANTIS_CANDIDATE_TRUST` is `maintainer-approved-fork-pr-head`, treat the + candidate worktree as untrusted fork code: do not pass GitHub, OpenAI, + Crabbox, Convex, or other workflow secrets into candidate install, build, or + runtime commands. The candidate SUT may receive only the proof runner's + short-lived Telegram bot token, generated local config/state paths, and mock + model key needed for this isolated proof. 5. In each worktree, run the real-user Telegram Crabbox proof flow from the skill with `$OPENCLAW_TELEGRAM_USER_PROOF_CMD`; do not run `pnpm qa:telegram-user:crabbox` directly. The proof command comes from the diff --git a/.github/workflows/mantis-telegram-desktop-proof.yml b/.github/workflows/mantis-telegram-desktop-proof.yml index ecd24f02408..eb26cf7d62e 100644 --- a/.github/workflows/mantis-telegram-desktop-proof.yml +++ b/.github/workflows/mantis-telegram-desktop-proof.yml @@ -35,6 +35,11 @@ on: description: Optional existing Crabbox desktop lease id or slug to reuse required: false type: string + allow_fork_candidate: + description: Allow a fork PR head candidate when pr_number points at that PR + required: false + default: false + type: boolean permissions: contents: write @@ -95,6 +100,7 @@ jobs: needs: authorize_actor runs-on: ubuntu-24.04 outputs: + allow_fork_candidate: ${{ steps.resolve.outputs.allow_fork_candidate }} baseline_ref: ${{ steps.resolve.outputs.baseline_ref }} candidate_ref: ${{ steps.resolve.outputs.candidate_ref }} crabbox_provider: ${{ steps.resolve.outputs.crabbox_provider }} @@ -119,6 +125,10 @@ jobs: if (eventName === "workflow_dispatch") { const inputs = context.payload.inputs ?? {}; setOutput("should_run", "true"); + setOutput( + "allow_fork_candidate", + String(inputs.allow_fork_candidate) === "true" ? "true" : "false", + ); setOutput("baseline_ref", inputs.baseline_ref || "main"); setOutput("candidate_ref", inputs.candidate_ref || "main"); setOutput("pr_number", inputs.pr_number || ""); @@ -150,6 +160,7 @@ jobs: if (!requested) { core.notice("Comment mentioned Mantis but did not request Telegram desktop proof."); setOutput("should_run", "false"); + setOutput("allow_fork_candidate", "false"); setOutput("baseline_ref", ""); setOutput("candidate_ref", ""); setOutput("pr_number", ""); @@ -192,8 +203,10 @@ jobs: rawCandidate && !["head", "pr", "pr-head"].includes(rawCandidate.toLowerCase()) ? rawCandidate : mergedCandidate || pr.head.sha; + const allowForkCandidate = /\bfork[-_]ok\b/i.test(body); setOutput("should_run", "true"); + setOutput("allow_fork_candidate", allowForkCandidate ? "true" : "false"); setOutput("baseline_ref", baselineMatch?.[1] || mergedBaseline || "main"); setOutput("candidate_ref", candidate); setOutput("pr_number", String(issue.number)); @@ -217,6 +230,7 @@ jobs: outputs: baseline_revision: ${{ steps.validate.outputs.baseline_revision }} candidate_revision: ${{ steps.validate.outputs.candidate_revision }} + candidate_trust: ${{ steps.validate.outputs.candidate_trust }} steps: - name: Checkout harness ref uses: actions/checkout@v6 @@ -227,6 +241,7 @@ jobs: - name: Validate refs are trusted id: validate env: + ALLOW_FORK_CANDIDATE: ${{ needs.resolve_request.outputs.allow_fork_candidate }} BASELINE_REF: ${{ needs.resolve_request.outputs.baseline_ref }} CANDIDATE_REF: ${{ needs.resolve_request.outputs.candidate_ref }} GH_TOKEN: ${{ github.token }} @@ -264,25 +279,43 @@ jobs: )" if [[ "$pr_head_count" != "0" ]]; then reason="open-pr-head" + elif [[ "$label" == "candidate" && "${ALLOW_FORK_CANDIDATE:-false}" == "true" && -n "${PR_NUMBER:-}" ]]; then + local fork_pr_head_count + fork_pr_head_count="$( + gh api \ + -H "Accept: application/vnd.github+json" \ + "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" \ + --jq 'if .state == "open" and .head.repo.full_name != "'"${GITHUB_REPOSITORY}"'" and .head.sha == "'"${revision}"'" then 1 else 0 end' + )" + if [[ "$fork_pr_head_count" == "1" ]]; then + reason="maintainer-approved-fork-pr-head" + fi fi fi if [[ -z "$reason" ]]; then - echo "${label} ref '${input_ref}' resolved to ${revision}, which is not trusted for this secret-bearing Mantis run." >&2 + echo "${label} ref '${input_ref}' resolved to ${revision}, which is not trusted for this secret-bearing Mantis run. Add fork-ok only for a maintainer-approved fork PR head." >&2 exit 1 fi - printf '%s\n' "$revision" + printf '%s\t%s\n' "$revision" "$reason" } baseline_revision="$(validate_ref baseline "$BASELINE_REF")" + baseline_trust="${baseline_revision#*$'\t'}" + baseline_revision="${baseline_revision%%$'\t'*}" candidate_revision="$(validate_ref candidate "$CANDIDATE_REF")" + candidate_trust="${candidate_revision#*$'\t'}" + candidate_revision="${candidate_revision%%$'\t'*}" echo "baseline_revision=${baseline_revision}" >> "$GITHUB_OUTPUT" echo "candidate_revision=${candidate_revision}" >> "$GITHUB_OUTPUT" + echo "candidate_trust=${candidate_trust}" >> "$GITHUB_OUTPUT" { echo "baseline: \`${BASELINE_REF}\`" echo "baseline SHA: \`${baseline_revision}\`" + echo "baseline trust: \`${baseline_trust}\`" echo "candidate: \`${CANDIDATE_REF}\`" echo "candidate SHA: \`${candidate_revision}\`" + echo "candidate trust: \`${candidate_trust}\`" } >> "$GITHUB_STEP_SUMMARY" run_telegram_desktop_proof: @@ -375,7 +408,7 @@ jobs: printf '%s\n' 'Defaults env_keep += "CODEX_HOME CODEX_INTERNAL_ORIGINATOR_OVERRIDE"' printf '%s\n' 'Defaults env_keep += "BASELINE_REF BASELINE_SHA CANDIDATE_REF CANDIDATE_SHA"' printf '%s\n' 'Defaults env_keep += "CRABBOX_ACCESS_CLIENT_ID CRABBOX_ACCESS_CLIENT_SECRET CRABBOX_COORDINATOR CRABBOX_COORDINATOR_TOKEN CRABBOX_LEASE_ID CRABBOX_PROVIDER"' - printf '%s\n' 'Defaults env_keep += "GH_TOKEN MANTIS_INSTRUCTIONS MANTIS_OUTPUT_DIR MANTIS_PR_NUMBER"' + printf '%s\n' 'Defaults env_keep += "GH_TOKEN MANTIS_CANDIDATE_TRUST MANTIS_INSTRUCTIONS MANTIS_OUTPUT_DIR MANTIS_PR_NUMBER"' printf '%s\n' 'Defaults env_keep += "OPENCLAW_BUILD_PRIVATE_QA OPENCLAW_ENABLE_PRIVATE_QA_CLI OPENCLAW_QA_CONVEX_SECRET_CI OPENCLAW_QA_CONVEX_SITE_URL OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR_TOKEN"' printf '%s\n' 'Defaults env_keep += "OPENCLAW_TELEGRAM_USER_CRABBOX_BIN OPENCLAW_TELEGRAM_USER_CRABBOX_PROVIDER OPENCLAW_TELEGRAM_USER_DRIVER_SCRIPT OPENCLAW_TELEGRAM_USER_PROOF_CMD"' } | sudo tee /etc/sudoers.d/mantis-codex-env >/dev/null @@ -406,6 +439,7 @@ jobs: CRABBOX_LEASE_ID: ${{ needs.resolve_request.outputs.lease_id }} CRABBOX_PROVIDER: ${{ needs.resolve_request.outputs.crabbox_provider }} GH_TOKEN: ${{ github.token }} + MANTIS_CANDIDATE_TRUST: ${{ needs.validate_refs.outputs.candidate_trust }} MANTIS_INSTRUCTIONS: ${{ needs.resolve_request.outputs.instructions }} MANTIS_OUTPUT_DIR: ${{ env.MANTIS_OUTPUT_DIR }} MANTIS_PR_NUMBER: ${{ needs.resolve_request.outputs.pr_number }} diff --git a/scripts/e2e/telegram-user-crabbox-proof.ts b/scripts/e2e/telegram-user-crabbox-proof.ts index 0f6194bd605..22a4db5990a 100644 --- a/scripts/e2e/telegram-user-crabbox-proof.ts +++ b/scripts/e2e/telegram-user-crabbox-proof.ts @@ -417,9 +417,40 @@ function optionalString(source: JsonObject, key: string) { return typeof value === "string" && value.trim() ? value.trim() : undefined; } +function childProcessBaseEnv() { + const keys = [ + "CI", + "COREPACK_HOME", + "FORCE_COLOR", + "HOME", + "LANG", + "LC_ALL", + "NODE_OPTIONS", + "OPENCLAW_BUILD_PRIVATE_QA", + "OPENCLAW_ENABLE_PRIVATE_QA_CLI", + "PATH", + "PNPM_HOME", + "SHELL", + "TEMP", + "TMP", + "TMPDIR", + "USER", + "XDG_CACHE_HOME", + "XDG_CONFIG_HOME", + ]; + const env: NodeJS.ProcessEnv = {}; + for (const key of keys) { + const value = process.env[key]; + if (value) { + env[key] = value; + } + } + return env; +} + function mockServerEnv(params: { mockPort: number; mockResponseText: string; requestLog: string }) { return { - ...process.env, + ...childProcessBaseEnv(), MOCK_PORT: String(params.mockPort), MOCK_REQUEST_LOG: params.requestLog, SUCCESS_MARKER: params.mockResponseText, @@ -428,7 +459,7 @@ function mockServerEnv(params: { mockPort: number; mockResponseText: string; req function gatewayEnv(params: { configPath: string; stateDir: string; sutToken: string }) { return { - ...process.env, + ...childProcessBaseEnv(), OPENAI_API_KEY: "sk-openclaw-e2e-mock", OPENCLAW_CONFIG_PATH: params.configPath, OPENCLAW_STATE_DIR: params.stateDir, diff --git a/test/scripts/mantis-telegram-desktop-proof-workflow.test.ts b/test/scripts/mantis-telegram-desktop-proof-workflow.test.ts index b2741619288..56b74551acf 100644 --- a/test/scripts/mantis-telegram-desktop-proof-workflow.test.ts +++ b/test/scripts/mantis-telegram-desktop-proof-workflow.test.ts @@ -107,12 +107,31 @@ describe("Mantis Telegram Desktop proof workflow", () => { expect(prepare.run).toContain( "OPENCLAW_TELEGRAM_USER_CRABBOX_BIN OPENCLAW_TELEGRAM_USER_CRABBOX_PROVIDER OPENCLAW_TELEGRAM_USER_DRIVER_SCRIPT OPENCLAW_TELEGRAM_USER_PROOF_CMD", ); + expect(prepare.run).toContain("MANTIS_CANDIDATE_TRUST"); const prompt = readFileSync(PROMPT, "utf8"); expect(prompt).toContain("$OPENCLAW_TELEGRAM_USER_PROOF_CMD"); expect(prompt).toContain("do not run\n `pnpm qa:telegram-user:crabbox` directly"); }); + it("requires explicit maintainer fork approval before accepting fork PR heads", () => { + const workflowText = readFileSync(WORKFLOW, "utf8"); + expect(workflowText).toContain("@openclaw-mantis"); + expect(workflowText).toContain("fork[-_]ok"); + expect(workflowText).toContain("ALLOW_FORK_CANDIDATE"); + expect(workflowText).toContain("maintainer-approved-fork-pr-head"); + expect(workflowText).toContain(".head.repo.full_name !="); + + const agent = workflowStep("Run Codex Mantis Telegram agent"); + expect(agent.env?.MANTIS_CANDIDATE_TRUST).toBe( + "${{ needs.validate_refs.outputs.candidate_trust }}", + ); + + const prompt = readFileSync(PROMPT, "utf8"); + expect(prompt).toContain("MANTIS_CANDIDATE_TRUST"); + expect(prompt).toContain("untrusted fork code"); + }); + it("checks the Telegram user driver before leasing credentials", () => { const proofScript = readFileSync(PROOF_SCRIPT, "utf8"); const startSession = proofScript.slice( @@ -132,4 +151,12 @@ describe("Mantis Telegram Desktop proof workflow", () => { defaultProof.indexOf("leaseCredential({ localRoot, opts, root })"), ); }); + + it("does not pass the full workflow environment into the local Telegram SUT", () => { + const proofScript = readFileSync(PROOF_SCRIPT, "utf8"); + expect(proofScript).toContain("function childProcessBaseEnv()"); + expect(proofScript).toContain("...childProcessBaseEnv()"); + expect(proofScript).not.toContain("...process.env,\n OPENAI_API_KEY"); + expect(proofScript).not.toContain("...process.env,\n MOCK_PORT"); + }); }); From 26ac5a35962522776d3886d22ff2b7b7462bafe3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 11 May 2026 13:04:17 +0100 Subject: [PATCH 10/93] test: tighten assistant identity assertions --- src/gateway/assistant-identity.test.ts | 29 +++++++++++--------------- 1 file changed, 12 insertions(+), 17 deletions(-) diff --git a/src/gateway/assistant-identity.test.ts b/src/gateway/assistant-identity.test.ts index 443ff48e2a6..98614db287b 100644 --- a/src/gateway/assistant-identity.test.ts +++ b/src/gateway/assistant-identity.test.ts @@ -16,11 +16,10 @@ describe("resolveAssistantIdentity avatar normalization", () => { }, }; - expect(resolveAssistantIdentity({ cfg, agentId: "main", workspaceDir: "" })).toMatchObject({ - agentId: "main", - name: "Main assistant", - avatar: "M", - }); + const identity = resolveAssistantIdentity({ cfg, agentId: "main", workspaceDir: "" }); + expect(identity.agentId).toBe("main"); + expect(identity.name).toBe("Main assistant"); + expect(identity.avatar).toBe("M"); }); it("prefers non-default agent identity over global ui.assistant identity", () => { @@ -36,13 +35,10 @@ describe("resolveAssistantIdentity avatar normalization", () => { }, }; - expect(resolveAssistantIdentity({ cfg, agentId: "fs-daying", workspaceDir: "" })).toMatchObject( - { - agentId: "fs-daying", - name: "大颖", - avatar: "D", - }, - ); + const identity = resolveAssistantIdentity({ cfg, agentId: "fs-daying", workspaceDir: "" }); + expect(identity.agentId).toBe("fs-daying"); + expect(identity.name).toBe("大颖"); + expect(identity.avatar).toBe("D"); }); it("falls back to ui.assistant identity for non-default agents without their own identity", () => { @@ -58,11 +54,10 @@ describe("resolveAssistantIdentity avatar normalization", () => { }, }; - expect(resolveAssistantIdentity({ cfg, agentId: "worker", workspaceDir: "" })).toMatchObject({ - agentId: "worker", - name: "Main assistant", - avatar: "M", - }); + const identity = resolveAssistantIdentity({ cfg, agentId: "worker", workspaceDir: "" }); + expect(identity.agentId).toBe("worker"); + expect(identity.name).toBe("Main assistant"); + expect(identity.avatar).toBe("M"); }); it("drops sentence-like avatar placeholders", () => { From efbc550dc90a3cc7013c80933b10e1029c2f5c73 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 11 May 2026 13:05:47 +0100 Subject: [PATCH 11/93] test: tighten gateway helper assertions --- src/gateway/server.config-patch.test.ts | 8 +++----- .../ws-connection/handshake-auth-helpers.test.ts | 12 ++++++------ 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/src/gateway/server.config-patch.test.ts b/src/gateway/server.config-patch.test.ts index 03b55ffead1..b78a9016abb 100644 --- a/src/gateway/server.config-patch.test.ts +++ b/src/gateway/server.config-patch.test.ts @@ -288,11 +288,9 @@ describe("gateway config methods", () => { expect(res.payload?.path).toBe("gateway.auth"); expect(res.payload?.hintPath).toBe("gateway.auth"); const tokenChild = res.payload?.children?.find((child) => child.key === "token"); - expect(tokenChild).toMatchObject({ - key: "token", - path: "gateway.auth.token", - hintPath: "gateway.auth.token", - }); + expect(tokenChild?.key).toBe("token"); + expect(tokenChild?.path).toBe("gateway.auth.token"); + expect(tokenChild?.hintPath).toBe("gateway.auth.token"); expect(res.payload?.schema?.properties).toBeUndefined(); }); diff --git a/src/gateway/server/ws-connection/handshake-auth-helpers.test.ts b/src/gateway/server/ws-connection/handshake-auth-helpers.test.ts index 5f379b8d381..dc37ce5ad7f 100644 --- a/src/gateway/server/ws-connection/handshake-auth-helpers.test.ts +++ b/src/gateway/server/ws-connection/handshake-auth-helpers.test.ts @@ -34,12 +34,12 @@ describe("handshake auth helpers", () => { browserRateLimiter, }); - expect(resolved).toMatchObject({ - hasBrowserOriginHeader: true, - enforceOriginCheckForAnyClient: true, - rateLimitClientIp: `${BROWSER_ORIGIN_RATE_LIMIT_KEY_PREFIX}https://app.example`, - authRateLimiter: browserRateLimiter, - }); + expect(resolved.hasBrowserOriginHeader).toBe(true); + expect(resolved.enforceOriginCheckForAnyClient).toBe(true); + expect(resolved.rateLimitClientIp).toBe( + `${BROWSER_ORIGIN_RATE_LIMIT_KEY_PREFIX}https://app.example`, + ); + expect(resolved.authRateLimiter).toBe(browserRateLimiter); }); it("falls back to the legacy synthetic ip when the browser origin is invalid", () => { From 0e6aca34db675525434a483c71df94e3595be292 Mon Sep 17 00:00:00 2001 From: Lellansin Date: Sat, 9 May 2026 19:48:17 +0800 Subject: [PATCH 12/93] fix(gateway): align OpenAI chat completions tool protocol fix(gateway): remove unnecessary type assertion in buildAgentPrompt fix(gateway): reject unsupported forced tool_choice modes tool_choice=required and named function tool_choice are now rejected with invalid_request_error until hard enforcement is implemented at the agent runtime layer. Only auto and none remain supported. docs: update Chat Completions tool_choice contract to match rejection behavior Only auto and none are currently accepted; required and named function tool_choice are rejected until hard enforcement exists. --- CHANGELOG.md | 1 + docs/gateway/openai-http-api.md | 57 +++ src/gateway/openai-http.test.ts | 622 +++++++++++++++++++++++++++++++- src/gateway/openai-http.ts | 396 +++++++++++++++++++- 4 files changed, 1063 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 78f5b65853f..a941c44f418 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -412,6 +412,7 @@ Docs: https://docs.openclaw.ai - Channels/iMessage: honor `channels.imessage.groups..systemPrompt` (and the `groups["*"]` wildcard) by forwarding it as `GroupSystemPrompt` on inbound group turns, mirroring the byte-identical resolver semantic from WhatsApp where defining the key as an empty string on a specific group suppresses the wildcard fallback. Brings iMessage to parity with the per-group `systemPrompt` pattern already supported by Discord, Telegram, IRC, Slack, GoogleChat, and the retired BlueBubbles channel. Fixes #78285. (#79383) Thanks @omarshahine. - iMessage: add opt-in inbound catchup that replays messages received while the gateway was offline (crash, restart, mac sleep) on next startup. Enable with `channels.imessage.catchup.enabled: true`; tunables for `maxAgeMinutes`, `perRunLimit`, `firstRunLookbackMinutes`, and `maxFailureRetries`. Persists a per-account cursor under the OpenClaw state dir (`/imessage/catchup/`), replays each row through the live dispatch path so allowlists/group policy/dedupe behave identically on replayed and live messages, and force-advances past wedged guids after `maxFailureRetries` to prevent stuck cursors. Extends the persisted echo-cache retention window so the agent's own outbound rows from before a gap are not re-fed as inbound on replay. Includes a regenerated `src/config/bundled-channel-config-metadata.generated.ts` so the runtime AJV schema accepts the new `channels.imessage.catchup` block. Fixes #78649. (#79387) Thanks @omarshahine. - Channels/Yuanbao: bump the bundled `openclaw-plugin-yuanbao` npm spec from `2.11.0` to `2.13.0` in the official external channel catalog and refresh the pinned integrity hash, so fresh installs and catalog-driven reinstalls pick up the newer Yuanbao channel plugin release. (#79620) Thanks @loongfay. +- Gateway/OpenAI-compatible Chat Completions: support function `tools`, `tool_choice`, `tool_calls`, and `role: "tool"` follow-up turns while keeping tool-call stream finalization aligned with the command result and reporting client-tool name conflicts as invalid requests. - Providers/Mistral: add `mistral-medium-3-5` to the bundled catalog with reasoning support. Thanks @sliekens. - Docs/Mistral: document Medium 3.5 setup, local infer smoke usage, adjustable reasoning, and the Mistral HTTP 400 caveat for `reasoning_effort="high"` with `temperature: 0`. diff --git a/docs/gateway/openai-http-api.md b/docs/gateway/openai-http-api.md index 4b20c93c1c3..3e2f6cfe1d4 100644 --- a/docs/gateway/openai-http-api.md +++ b/docs/gateway/openai-http-api.md @@ -191,6 +191,63 @@ Set `stream: true` to receive Server-Sent Events (SSE): - Each event line is `data: ` - Stream ends with `data: [DONE]` +## Chat tool contract + +`/v1/chat/completions` supports a function-tool subset compatible with common OpenAI Chat clients. + +### Supported request fields + +- `tools`: array of `{ "type": "function", "function": { ... } }` +- `tool_choice`: `"auto"`, `"none"` +- `messages[*].role: "tool"` follow-up turns +- `messages[*].tool_call_id` for binding tool results back to a prior tool call + +### Unsupported variants + +The endpoint returns `400 invalid_request_error` for unsupported tool variants, including: + +- non-array `tools` +- non-function tool entries +- missing `tool.function.name` +- `tool_choice` variants such as `allowed_tools` and `custom` +- `tool_choice: "required"` (not yet enforced at runtime; will be supported once hard enforcement is implemented) +- `tool_choice: { "type": "function", "function": { "name": "..." } }` (same rationale as `required`) +- `tool_choice.function.name` values that do not match provided `tools` + +### Non-streaming tool response shape + +When the agent decides to call tools, the response uses: + +- `choices[0].finish_reason = "tool_calls"` +- `choices[0].message.tool_calls[]` entries with: + - `id` + - `type: "function"` + - `function.name` + - `function.arguments` (JSON string) + +Assistant commentary before the tool call is returned in `choices[0].message.content` (possibly empty). + +### Streaming tool response shape + +When `stream: true`, tool calls are emitted as incremental SSE chunks: + +- initial assistant role delta +- optional assistant commentary deltas +- one or more `delta.tool_calls` chunks carrying tool identity and argument fragments +- final chunk with `finish_reason: "tool_calls"` +- `data: [DONE]` + +If `stream_options.include_usage=true`, a trailing usage chunk is emitted before `[DONE]`. + +### Tool follow-up loop + +After receiving `tool_calls`, the client should execute the requested function(s) and send a follow-up request that includes: + +- prior assistant tool-call message +- one or more `role: "tool"` messages with matching `tool_call_id` + +This allows the gateway agent run to continue the same reasoning loop and produce the final assistant answer. + ## Open WebUI quick setup For a basic Open WebUI connection: diff --git a/src/gateway/openai-http.test.ts b/src/gateway/openai-http.test.ts index 8d3b2f8bd68..e441b762b81 100644 --- a/src/gateway/openai-http.test.ts +++ b/src/gateway/openai-http.test.ts @@ -7,6 +7,7 @@ import { emitAssistantTextDelta, } from "../agents/pi-embedded-subscribe.e2e-harness.js"; import { subscribeEmbeddedPiSession } from "../agents/pi-embedded-subscribe.js"; +import { createClientToolNameConflictError } from "../agents/pi-tool-definition-adapter.js"; import { HISTORY_CONTEXT_MARKER } from "../auto-reply/reply/history.js"; import { CURRENT_MESSAGE_MARKER } from "../auto-reply/reply/mentions.js"; import { emitAgentEvent } from "../infra/agent-events.js"; @@ -136,6 +137,15 @@ describe("OpenAI-compatible HTTP API (e2e)", () => { message?: string; extraSystemPrompt?: string; images?: Array<{ type: string; data: string; mimeType: string }>; + clientTools?: Array<{ + type?: string; + function?: { + name?: string; + description?: string; + parameters?: Record; + strict?: boolean; + }; + }>; senderIsOwner?: boolean; } | undefined; @@ -649,6 +659,396 @@ describe("OpenAI-compatible HTTP API (e2e)", () => { await res.text(); } + { + mockAgentOnce([{ text: "tool choice none" }]); + const res = await postChatCompletions(port, { + model: "openclaw", + tool_choice: "none", + tools: [ + { + type: "function", + function: { + name: "get_time", + description: "Get current time", + parameters: { type: "object", properties: {} }, + }, + }, + ], + messages: [{ role: "user", content: "time?" }], + }); + expect(res.status).toBe(200); + const firstCall = getFirstAgentCall(); + expect(firstCall?.clientTools).toBeUndefined(); + await res.text(); + } + + { + mockAgentOnce([{ text: "tool choice auto" }]); + const res = await postChatCompletions(port, { + model: "openclaw", + tool_choice: "auto", + tools: [ + { + type: "function", + function: { + name: "get_time", + description: "Get current time", + parameters: { type: "object", properties: {} }, + strict: true, + }, + }, + ], + messages: [{ role: "user", content: "time?" }], + }); + expect(res.status).toBe(200); + const firstCall = getFirstAgentCall(); + const clientTools = firstCall?.clientTools ?? []; + expect(clientTools).toHaveLength(1); + expect(clientTools[0]?.type).toBe("function"); + expect(clientTools[0]?.function?.name).toBe("get_time"); + expect(clientTools[0]?.function?.strict).toBe(true); + await res.text(); + } + + { + agentCommand.mockClear(); + const res = await postChatCompletions(port, { + model: "openclaw", + tool_choice: { type: "function", function: { name: "get_weather" } }, + tools: [ + { + type: "function", + function: { + name: "get_time", + description: "Get current time", + parameters: { type: "object", properties: {} }, + }, + }, + { + type: "function", + function: { + name: "get_weather", + description: "Get current weather", + parameters: { + type: "object", + properties: { city: { type: "string" } }, + required: ["city"], + }, + }, + }, + ], + messages: [{ role: "user", content: "weather?" }], + }); + expect(res.status).toBe(400); + const json = (await res.json()) as { error?: { type?: string; message?: string } }; + expect(json.error?.type).toBe("invalid_request_error"); + expect(json.error?.message ?? "").toContain("not supported"); + expect(agentCommand).toHaveBeenCalledTimes(0); + } + + { + agentCommand.mockClear(); + const res = await postChatCompletions(port, { + model: "openclaw", + tool_choice: "required", + messages: [{ role: "user", content: "weather?" }], + }); + expect(res.status).toBe(400); + const json = (await res.json()) as { error?: { type?: string; message?: string } }; + expect(json.error?.type).toBe("invalid_request_error"); + expect(json.error?.message ?? "").toContain("tool_choice=required"); + expect(agentCommand).toHaveBeenCalledTimes(0); + } + + { + agentCommand.mockClear(); + const res = await postChatCompletions(port, { + model: "openclaw", + tool_choice: { type: "function", function: { name: "missing_tool" } }, + tools: [ + { + type: "function", + function: { + name: "get_time", + description: "Get current time", + parameters: { type: "object", properties: {} }, + }, + }, + ], + messages: [{ role: "user", content: "weather?" }], + }); + expect(res.status).toBe(400); + const json = (await res.json()) as { error?: { type?: string; message?: string } }; + expect(json.error?.type).toBe("invalid_request_error"); + expect(json.error?.message ?? "").toContain("not supported"); + expect(agentCommand).toHaveBeenCalledTimes(0); + } + + { + agentCommand.mockClear(); + const res = await postChatCompletions(port, { + model: "openclaw", + tool_choice: { + type: "allowed_tools", + tools: [{ type: "function", function: { name: "x" } }], + }, + tools: [ + { + type: "function", + function: { + name: "x", + description: "x", + parameters: { type: "object", properties: {} }, + }, + }, + ], + messages: [{ role: "user", content: "x?" }], + }); + expect(res.status).toBe(400); + const json = (await res.json()) as { error?: { type?: string; message?: string } }; + expect(json.error?.type).toBe("invalid_request_error"); + expect(json.error?.message ?? "").toContain("allowed_tools"); + expect(agentCommand).toHaveBeenCalledTimes(0); + } + + { + agentCommand.mockClear(); + const res = await postChatCompletions(port, { + model: "openclaw", + tools: [ + { + type: "function", + name: "invalid_flat_shape", + parameters: { type: "object", properties: {} }, + }, + ], + messages: [{ role: "user", content: "x?" }], + }); + expect(res.status).toBe(400); + const json = (await res.json()) as { error?: { type?: string; message?: string } }; + expect(json.error?.type).toBe("invalid_request_error"); + expect(json.error?.message ?? "").toContain("tool.function is required"); + expect(agentCommand).toHaveBeenCalledTimes(0); + } + + { + mockAgentOnce([{ text: "ok" }]); + const res = await postChatCompletions(port, { + model: "openclaw", + messages: [ + { role: "user", content: "What's the weather?" }, + { role: "assistant", content: "Checking the weather." }, + { + role: "tool", + tool_call_id: "call_1", + content: [{ type: "text", text: "Sunny, 70F." }], + }, + ], + }); + expect(res.status).toBe(200); + const message = getFirstAgentMessage(); + expectMessageContext(message, { + history: ["User: What's the weather?", "Assistant: Checking the weather."], + current: ["Tool:call_1: Sunny, 70F."], + }); + await res.text(); + } + + { + mockAgentOnce([{ text: "ok" }]); + const res = await postChatCompletions(port, { + model: "openclaw", + messages: [ + { role: "user", content: "What's the weather?" }, + { + role: "assistant", + content: null, + tool_calls: [ + { + id: "call_1", + type: "function", + function: { + name: "get_weather", + arguments: '{"city":"Taipei"}', + }, + }, + ], + }, + { + role: "tool", + tool_call_id: "call_1", + content: [{ type: "text", text: "Sunny, 70F." }], + }, + ], + }); + expect(res.status).toBe(200); + const message = getFirstAgentMessage(); + expectMessageContext(message, { + history: [ + "User: What's the weather?", + 'Assistant: tool_call id=call_1 name=get_weather arguments={"city":"Taipei"}', + ], + current: ["Tool:call_1: Sunny, 70F."], + }); + await res.text(); + } + + { + agentCommand.mockClear(); + agentCommand.mockRejectedValueOnce(createClientToolNameConflictError(["exec"])); + const res = await postChatCompletions(port, { + stream: false, + model: "openclaw", + tools: [ + { + type: "function", + function: { + name: "exec", + description: "conflicts with a built-in tool", + parameters: { type: "object", properties: {} }, + }, + }, + ], + messages: [{ role: "user", content: "run command" }], + }); + expect(res.status).toBe(400); + const json = (await res.json()) as { error?: { type?: string; message?: string } }; + expect(json.error?.type).toBe("invalid_request_error"); + expect(json.error?.message).toBe("invalid tool configuration"); + } + + { + agentCommand.mockClear(); + agentCommand.mockResolvedValueOnce({ + payloads: [{ text: "Let me check that." }], + meta: { + stopReason: "tool_calls", + pendingToolCalls: [ + { + id: "call_1", + name: "get_weather", + arguments: '{"city":"Taipei"}', + }, + { + id: "call_2", + name: "get_time", + arguments: "{}", + }, + ], + agentMeta: { + usage: { + input: 10, + output: 5, + total: 15, + }, + }, + }, + } as never); + const res = await postChatCompletions(port, { + stream: false, + model: "openclaw", + tool_choice: "auto", + tools: [ + { + type: "function", + function: { + name: "get_weather", + description: "Get weather", + parameters: { type: "object", properties: { city: { type: "string" } } }, + }, + }, + { + type: "function", + function: { + name: "get_time", + description: "Get time", + parameters: { type: "object", properties: {} }, + }, + }, + ], + messages: [{ role: "user", content: "weather?" }], + }); + expect(res.status).toBe(200); + const json = (await res.json()) as { + choices?: Array<{ + finish_reason?: string | null; + message?: { + role?: string; + content?: string; + tool_calls?: Array<{ + index?: number; + id?: string; + type?: string; + function?: { name?: string; arguments?: string }; + }>; + }; + }>; + }; + const choice = json.choices?.[0]; + expect(choice?.finish_reason).toBe("tool_calls"); + expect(choice?.message?.role).toBe("assistant"); + expect(choice?.message?.content).toBe("Let me check that."); + expect(choice?.message?.tool_calls).toEqual([ + { + id: "call_1", + type: "function", + function: { name: "get_weather", arguments: '{"city":"Taipei"}' }, + }, + { + id: "call_2", + type: "function", + function: { name: "get_time", arguments: "{}" }, + }, + ]); + expect(choice?.message?.tool_calls?.some((call) => Object.hasOwn(call, "index"))).toBe( + false, + ); + } + + { + agentCommand.mockClear(); + agentCommand.mockResolvedValueOnce({ + payloads: [], + meta: { + stopReason: "tool_calls", + pendingToolCalls: [ + { + id: "call_1", + name: "get_weather", + arguments: '{"city":"Taipei"}', + }, + ], + }, + } as never); + const res = await postChatCompletions(port, { + stream: false, + model: "openclaw", + tool_choice: "auto", + tools: [ + { + type: "function", + function: { + name: "get_weather", + description: "Get weather", + parameters: { type: "object", properties: { city: { type: "string" } } }, + }, + }, + ], + messages: [{ role: "user", content: "weather?" }], + }); + expect(res.status).toBe(200); + const json = (await res.json()) as { + choices?: Array<{ + finish_reason?: string | null; + message?: { content?: string; tool_calls?: unknown[] }; + }>; + }; + const choice = json.choices?.[0]; + expect(choice?.finish_reason).toBe("tool_calls"); + expect(choice?.message?.content).toBe(""); + expect(choice?.message?.tool_calls).toHaveLength(1); + } + { mockAgentOnce([{ text: "hello" }]); const json = await postSyncUserMessage("hi"); @@ -999,6 +1399,221 @@ describe("OpenAI-compatible HTTP API (e2e)", () => { expect(fallbackText).toContain("hello"); } + { + agentCommand.mockClear(); + agentCommand.mockResolvedValueOnce({ + payloads: [{ text: "Let me check that." }], + meta: { + stopReason: "tool_calls", + pendingToolCalls: [ + { + id: "call_1", + name: "get_weather", + arguments: '{"city":"Taipei"}', + }, + ], + }, + } as never); + + const toolCallRes = await postChatCompletions(port, { + stream: true, + model: "openclaw", + messages: [{ role: "user", content: "hi" }], + }); + expect(toolCallRes.status).toBe(200); + const toolCallText = await toolCallRes.text(); + const toolCallData = parseSseDataLines(toolCallText); + const toolCallChunks = toolCallData + .filter((d) => d !== "[DONE]") + .map((d) => JSON.parse(d) as Record); + const toolDeltaChunks = toolCallChunks.filter((chunk) => { + const choice = ((chunk.choices as Array> | undefined) ?? [])[0]; + const delta = (choice?.delta as Record | undefined) ?? {}; + return Array.isArray(delta.tool_calls); + }); + expect(toolDeltaChunks.length).toBeGreaterThan(0); + const toolCallDeltaRecords = toolDeltaChunks.flatMap((chunk) => { + const choice = ((chunk.choices as Array> | undefined) ?? [])[0]; + const delta = (choice?.delta as Record | undefined) ?? {}; + return (delta.tool_calls as Array> | undefined) ?? []; + }); + const withIdentity = toolCallDeltaRecords.find( + (record) => + record.id === "call_1" && + record.type === "function" && + ((record.function as Record | undefined)?.name as + | string + | undefined) === "get_weather", + ); + expect(withIdentity).toBeTruthy(); + const argsJoined = toolCallDeltaRecords + .filter((record) => record.index === 0) + .map( + (record) => + ((record.function as Record | undefined)?.arguments as + | string + | undefined) ?? "", + ) + .join(""); + expect(argsJoined).toBe('{"city":"Taipei"}'); + const finishChunk = toolCallChunks + .flatMap((chunk) => (chunk.choices as Array> | undefined) ?? []) + .find((choice) => choice.finish_reason === "tool_calls"); + expect(finishChunk).toBeTruthy(); + } + + { + agentCommand.mockClear(); + agentCommand.mockResolvedValueOnce({ + payloads: [{ text: "Let me check that." }], + meta: { + stopReason: "tool_calls", + pendingToolCalls: [ + { + id: "call_1", + name: "get_weather", + arguments: '{"city":"Taipei"}', + }, + ], + agentMeta: { + usage: { + input: 12, + output: 3, + total: 15, + }, + }, + }, + } as never); + + const toolCallUsageRes = await postChatCompletions(port, { + stream: true, + stream_options: { include_usage: true }, + model: "openclaw", + messages: [{ role: "user", content: "hi" }], + }); + expect(toolCallUsageRes.status).toBe(200); + const toolCallUsageText = await toolCallUsageRes.text(); + const toolCallUsageData = parseSseDataLines(toolCallUsageText); + const jsonChunks = toolCallUsageData + .filter((d) => d !== "[DONE]") + .map((d) => JSON.parse(d) as Record); + const usageChunk = jsonChunks.find((chunk) => "usage" in chunk); + expect(usageChunk).toBeTruthy(); + expect(usageChunk?.choices).toEqual([]); + expect(usageChunk?.usage).toEqual({ + prompt_tokens: 12, + completion_tokens: 3, + total_tokens: 15, + }); + expect(toolCallUsageData[toolCallUsageData.length - 1]).toBe("[DONE]"); + } + + { + agentCommand.mockClear(); + let resolveLateToolCall: + | ((result: { + payloads: Array<{ text: string }>; + meta: { + stopReason: string; + pendingToolCalls: Array<{ id: string; name: string; arguments: string }>; + }; + }) => void) + | undefined; + agentCommand.mockImplementationOnce( + ((opts: unknown) => + new Promise((resolve) => { + resolveLateToolCall = resolve; + const runId = (opts as { runId?: string } | undefined)?.runId ?? ""; + emitAgentEvent({ runId, stream: "assistant", data: { delta: "Let me check that." } }); + emitAgentEvent({ runId, stream: "lifecycle", data: { phase: "end" } }); + })) as never, + ); + + const lateToolCallRes = await postChatCompletions(port, { + stream: true, + model: "openclaw", + messages: [{ role: "user", content: "hi" }], + }); + expect(lateToolCallRes.status).toBe(200); + const lateToolCallTextPromise = lateToolCallRes.text(); + const earlyCompletion = await Promise.race([ + lateToolCallTextPromise.then(() => "completed" as const), + new Promise<"pending">((resolve) => { + setTimeout(() => resolve("pending"), 1200); + }), + ]); + expect(earlyCompletion).toBe("pending"); + + resolveLateToolCall?.({ + payloads: [{ text: "Let me check that." }], + meta: { + stopReason: "tool_calls", + pendingToolCalls: [ + { + id: "call_1", + name: "get_weather", + arguments: '{"city":"Taipei"}', + }, + ], + }, + }); + const lateToolCallText = await lateToolCallTextPromise; + const lateToolCallData = parseSseDataLines(lateToolCallText); + const lateToolCallChunks = lateToolCallData + .filter((d) => d !== "[DONE]") + .map((d) => JSON.parse(d) as Record); + const finishChunk = lateToolCallChunks + .flatMap((chunk) => (chunk.choices as Array> | undefined) ?? []) + .find((choice) => choice.finish_reason === "tool_calls"); + expect(finishChunk).toBeTruthy(); + const anyToolCalls = lateToolCallChunks.some((chunk) => { + const choice = ((chunk.choices as Array> | undefined) ?? [])[0]; + const delta = (choice?.delta as Record | undefined) ?? {}; + return Array.isArray(delta.tool_calls); + }); + expect(anyToolCalls).toBe(true); + } + + { + agentCommand.mockClear(); + agentCommand.mockRejectedValueOnce(createClientToolNameConflictError(["exec"])); + + const toolConflictRes = await postChatCompletions(port, { + stream: true, + model: "openclaw", + tools: [ + { + type: "function", + function: { + name: "exec", + description: "conflicts with a built-in tool", + parameters: { type: "object", properties: {} }, + }, + }, + ], + messages: [{ role: "user", content: "run command" }], + }); + expect(toolConflictRes.status).toBe(200); + const toolConflictText = await toolConflictRes.text(); + const toolConflictData = parseSseDataLines(toolConflictText); + expect(toolConflictData[toolConflictData.length - 1]).toBe("[DONE]"); + + const toolConflictChunks = toolConflictData + .filter((d) => d !== "[DONE]") + .map((d) => JSON.parse(d) as Record); + const protocolError = toolConflictChunks.find( + (chunk) => + typeof chunk.error === "object" && + ((chunk.error as { type?: unknown }).type ?? "") === "invalid_request_error" && + ((chunk.error as { message?: unknown }).message ?? "") === "invalid tool configuration", + ); + expect(protocolError).toBeTruthy(); + const stopChoice = toolConflictChunks + .flatMap((c) => (c.choices as Array> | undefined) ?? []) + .find((choice) => choice.finish_reason === "stop"); + expect(stopChoice).toBeUndefined(); + } + { agentCommand.mockClear(); agentCommand.mockRejectedValueOnce(new Error("boom")); @@ -1297,15 +1912,18 @@ describe("OpenAI-compatible HTTP API (e2e)", () => { }, ); - it("does not block stream finalization on usage when include_usage is not requested", async () => { + it("does not require usage to finalize when include_usage is not requested", async () => { const port = enabledPort; agentCommand.mockClear(); agentCommand.mockImplementationOnce( ((opts: unknown) => - new Promise(() => { + new Promise((resolve) => { const runId = (opts as { runId?: string } | undefined)?.runId ?? ""; emitAgentEvent({ runId, stream: "assistant", data: { delta: "hello" } }); emitAgentEvent({ runId, stream: "lifecycle", data: { phase: "end" } }); + setTimeout(() => { + resolve({ payloads: [{ text: "hello" }] }); + }, 100); })) as never, ); diff --git a/src/gateway/openai-http.ts b/src/gateway/openai-http.ts index ab3153a4a23..d186270bdfd 100644 --- a/src/gateway/openai-http.ts +++ b/src/gateway/openai-http.ts @@ -1,6 +1,8 @@ import { randomUUID } from "node:crypto"; import type { IncomingMessage, ServerResponse } from "node:http"; +import type { ClientToolDefinition } from "../agents/command/shared-types.js"; import type { ImageContent } from "../agents/command/types.js"; +import { isClientToolNameConflictError } from "../agents/pi-tool-definition-adapter.js"; import { hasNonzeroUsage, normalizeUsage, @@ -58,6 +60,8 @@ type OpenAiChatMessage = { role?: unknown; content?: unknown; name?: unknown; + tool_call_id?: unknown; + tool_calls?: unknown; }; type OpenAiChatCompletionRequest = { @@ -65,6 +69,8 @@ type OpenAiChatCompletionRequest = { stream?: unknown; // Naming/style reference: src/agents/openai-transport-stream.ts:1262-1273 stream_options?: unknown; + tools?: unknown; + tool_choice?: unknown; messages?: unknown; user?: unknown; }; @@ -119,6 +125,7 @@ function writeSse(res: ServerResponse, data: unknown) { function buildAgentCommandInput(params: { prompt: { message: string; extraSystemPrompt?: string; images?: ImageContent[] }; + clientTools?: ClientToolDefinition[]; modelOverride?: string; sessionKey: string; runId: string; @@ -130,6 +137,7 @@ function buildAgentCommandInput(params: { message: params.prompt.message, extraSystemPrompt: params.prompt.extraSystemPrompt, images: params.prompt.images, + clientTools: params.clientTools, model: params.modelOverride, sessionKey: params.sessionKey, runId: params.runId, @@ -142,6 +150,72 @@ function buildAgentCommandInput(params: { }; } +function extractClientToolsFromChatRequest(tools: unknown): ClientToolDefinition[] { + if (tools == null) { + return []; + } + if (!Array.isArray(tools)) { + throw new Error("tools must be an array"); + } + const clientTools: ClientToolDefinition[] = []; + for (const tool of tools) { + if (!tool || typeof tool !== "object" || Array.isArray(tool)) { + throw new Error("each tool must be an object"); + } + if ((tool as { type?: unknown }).type !== "function") { + throw new Error("only function tools are supported"); + } + const functionValue = (tool as { function?: unknown }).function; + if (!functionValue || typeof functionValue !== "object" || Array.isArray(functionValue)) { + throw new Error("tool.function is required"); + } + const rawName = (functionValue as { name?: unknown }).name; + const name = typeof rawName === "string" ? rawName.trim() : ""; + if (!name) { + throw new Error("tool.function.name is required"); + } + const description = (functionValue as { description?: unknown }).description; + const parameters = (functionValue as { parameters?: unknown }).parameters; + const strict = (functionValue as { strict?: unknown }).strict; + clientTools.push({ + type: "function", + function: { + name, + ...(typeof description === "string" ? { description } : {}), + ...(parameters && typeof parameters === "object" && !Array.isArray(parameters) + ? { parameters: parameters as Record } + : {}), + ...(typeof strict === "boolean" ? { strict } : {}), + }, + }); + } + return clientTools; +} + +function applyChatToolChoice(params: { tools: ClientToolDefinition[]; toolChoice: unknown }): { + tools: ClientToolDefinition[]; + extraSystemPrompt?: string; +} { + const { tools, toolChoice } = params; + if (toolChoice == null || toolChoice === "auto") { + return { tools }; + } + if (toolChoice === "none") { + return { tools: [] }; + } + if (toolChoice === "required") { + throw new Error("tool_choice=required is not supported"); + } + if (typeof toolChoice !== "object" || Array.isArray(toolChoice)) { + throw new Error("tool_choice must be a string or object"); + } + const choiceType = (toolChoice as { type?: unknown }).type; + if (typeof choiceType !== "string") { + throw new Error("unsupported tool_choice type"); + } + throw new Error(`tool_choice ${choiceType} is not supported`); +} + function writeAssistantRoleChunk(res: ServerResponse, params: { runId: string; model: string }) { writeSse(res, { id: params.runId, @@ -171,7 +245,10 @@ function writeAssistantContentChunk( }); } -function writeAssistantStopChunk(res: ServerResponse, params: { runId: string; model: string }) { +function writeAssistantFinishChunk( + res: ServerResponse, + params: { runId: string; model: string; finishReason: "stop" | "tool_calls" }, +) { writeSse(res, { id: params.runId, object: "chat.completion.chunk", @@ -181,12 +258,81 @@ function writeAssistantStopChunk(res: ServerResponse, params: { runId: string; m { index: 0, delta: {}, - finish_reason: "stop", + finish_reason: params.finishReason, }, ], }); } +function splitArgumentsForStreaming(argumentsValue: string): string[] { + if (!argumentsValue) { + return [""]; + } + const chunkSize = 256; + const chunks: string[] = []; + for (let i = 0; i < argumentsValue.length; i += chunkSize) { + chunks.push(argumentsValue.slice(i, i + chunkSize)); + } + return chunks.length > 0 ? chunks : [""]; +} + +function writeAssistantToolCallsIncrementalChunks( + res: ServerResponse, + params: { + runId: string; + model: string; + toolCalls: Array<{ id: string; name: string; arguments: string }>; + }, +) { + for (const [index, call] of params.toolCalls.entries()) { + writeSse(res, { + id: params.runId, + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model: params.model, + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index, + id: call.id, + type: "function", + function: { name: call.name, arguments: "" }, + }, + ], + }, + finish_reason: null, + }, + ], + }); + + for (const argsDelta of splitArgumentsForStreaming(call.arguments)) { + writeSse(res, { + id: params.runId, + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model: params.model, + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index, + function: { arguments: argsDelta }, + }, + ], + }, + finish_reason: null, + }, + ], + }); + } + } +} + function writeUsageChunk( res: ServerResponse, params: { @@ -239,6 +385,59 @@ function extractTextContent(content: unknown): string { return ""; } +type AssistantToolCall = { + id: string; + name: string; + arguments: string; +}; + +function stringifyToolCallArguments(value: unknown): string { + if (typeof value === "string") { + return value; + } + if (value == null) { + return ""; + } + try { + const serialized = JSON.stringify(value); + return typeof serialized === "string" ? serialized : ""; + } catch { + return ""; + } +} + +function extractAssistantToolCalls(value: unknown): AssistantToolCall[] { + if (!Array.isArray(value)) { + return []; + } + const calls: AssistantToolCall[] = []; + for (const rawCall of value) { + if (!rawCall || typeof rawCall !== "object" || Array.isArray(rawCall)) { + continue; + } + const id = normalizeOptionalString((rawCall as { id?: unknown }).id) ?? ""; + const functionValue = (rawCall as { function?: unknown }).function; + if (!functionValue || typeof functionValue !== "object" || Array.isArray(functionValue)) { + continue; + } + const name = normalizeOptionalString((functionValue as { name?: unknown }).name) ?? ""; + if (!id || !name) { + continue; + } + const argumentsValue = stringifyToolCallArguments( + (functionValue as { arguments?: unknown }).arguments, + ); + calls.push({ id, name, arguments: argumentsValue }); + } + return calls; +} + +function renderAssistantToolCalls(calls: AssistantToolCall[]): string { + return calls + .map((call) => `tool_call id=${call.id} name=${call.name} arguments=${call.arguments}`) + .join("\n"); +} + function resolveImageUrlPart(part: unknown): string | undefined { if (!part || typeof part !== "object") { return undefined; @@ -410,26 +609,36 @@ function buildAgentPrompt( if (normalizedRole !== "user" && normalizedRole !== "assistant" && normalizedRole !== "tool") { continue; } + const assistantToolCalls = + normalizedRole === "assistant" ? extractAssistantToolCalls(msg.tool_calls) : []; + const assistantToolCallsSummary = + assistantToolCalls.length > 0 ? renderAssistantToolCalls(assistantToolCalls) : ""; // Keep the image-only placeholder scoped to the active user turn so we don't // mention historical image-only turns whose bytes are intentionally not replayed. - const messageContent = + const baseMessageContent = normalizedRole === "user" && !content && hasImage && i === activeUserMessageIndex ? IMAGE_ONLY_USER_MESSAGE : content; + const messageContent = [baseMessageContent, assistantToolCallsSummary] + .filter((part): part is string => Boolean(part)) + .join("\n"); if (!messageContent) { continue; } const name = normalizeOptionalString(msg.name) ?? ""; + const toolCallId = normalizeOptionalString(msg.tool_call_id) ?? ""; const sender = normalizedRole === "assistant" ? "Assistant" : normalizedRole === "user" ? "User" - : name - ? `Tool:${name}` - : "Tool"; + : toolCallId + ? `Tool:${toolCallId}` + : name + ? `Tool:${name}` + : "Tool"; conversationEntries.push({ role: normalizedRole, @@ -464,6 +673,17 @@ function resolveAgentResponseText(result: unknown): string { return content || "No response from OpenClaw."; } +function resolveAgentResponseCommentary(result: unknown): string { + const payloads = (result as { payloads?: Array<{ text?: string }> } | null)?.payloads; + if (!Array.isArray(payloads) || payloads.length === 0) { + return ""; + } + return payloads + .map((p) => (typeof p.text === "string" ? p.text : "")) + .filter(Boolean) + .join("\n\n"); +} + type AgentUsageMeta = { input?: number; output?: number; @@ -472,6 +692,12 @@ type AgentUsageMeta = { total?: number; }; +type PendingToolCall = { + id?: unknown; + name?: unknown; + arguments?: unknown; +}; + function resolveAgentRunUsage(result: unknown): NormalizedUsage | undefined { const agentMeta = ( result as { @@ -494,6 +720,38 @@ function resolveAgentRunUsage(result: unknown): NormalizedUsage | undefined { return primary ?? fallback; } +function resolveStopReasonAndPendingToolCalls(meta: unknown): { + stopReason: string | undefined; + pendingToolCalls: Array<{ id: string; name: string; arguments: string }> | undefined; +} { + if (!meta || typeof meta !== "object" || Array.isArray(meta)) { + return { stopReason: undefined, pendingToolCalls: undefined }; + } + const stopReasonRaw = (meta as { stopReason?: unknown }).stopReason; + const stopReason = typeof stopReasonRaw === "string" ? stopReasonRaw : undefined; + const pendingRaw = (meta as { pendingToolCalls?: unknown }).pendingToolCalls; + if (!Array.isArray(pendingRaw)) { + return { stopReason, pendingToolCalls: undefined }; + } + const pendingToolCalls: Array<{ id: string; name: string; arguments: string }> = []; + for (const call of pendingRaw as PendingToolCall[]) { + const id = typeof call?.id === "string" ? call.id.trim() : ""; + const name = typeof call?.name === "string" ? call.name.trim() : ""; + const argsValue = call?.arguments; + const argumentsValue = + typeof argsValue === "string" + ? argsValue + : argsValue == null + ? "" + : JSON.stringify(argsValue); + if (!id || !name) { + continue; + } + pendingToolCalls.push({ id, name, arguments: argumentsValue }); + } + return { stopReason, pendingToolCalls }; +} + function resolveChatCompletionUsage(result: unknown): { prompt_tokens: number; completion_tokens: number; @@ -512,6 +770,16 @@ function resolveIncludeUsageForStreaming(payload: OpenAiChatCompletionRequest): return (streamOptions as { include_usage?: unknown }).include_usage === true; } +function resolveErrorMessage(err: unknown): string { + if (err instanceof Error) { + const message = err.message.trim(); + if (message) { + return message; + } + } + return String(err); +} + export async function handleOpenAiHttpRequest( req: IncomingMessage, res: ServerResponse, @@ -567,6 +835,25 @@ export async function handleOpenAiHttpRequest( } const activeTurnContext = resolveActiveTurnContext(payload.messages); const prompt = buildAgentPrompt(payload.messages, activeTurnContext.activeUserMessageIndex); + let resolvedClientTools: ClientToolDefinition[] = []; + let toolChoicePrompt: string | undefined; + try { + const parsedClientTools = extractClientToolsFromChatRequest(payload.tools); + const toolChoiceResult = applyChatToolChoice({ + tools: parsedClientTools, + toolChoice: payload.tool_choice, + }); + resolvedClientTools = toolChoiceResult.tools; + toolChoicePrompt = toolChoiceResult.extraSystemPrompt; + } catch (err) { + sendJson(res, 400, { + error: { + message: `Invalid tools/tool_choice: ${resolveErrorMessage(err)}`, + type: "invalid_request_error", + }, + }); + return true; + } let images: ImageContent[] = []; try { images = await resolveImagesForRequest(activeTurnContext, limits); @@ -594,12 +881,16 @@ export async function handleOpenAiHttpRequest( const runId = `chatcmpl_${randomUUID()}`; const deps = createDefaultDeps(); const abortController = new AbortController(); + const mergedExtraSystemPrompt = [prompt.extraSystemPrompt, toolChoicePrompt] + .filter((part): part is string => Boolean(part)) + .join("\n\n"); const commandInput = buildAgentCommandInput({ prompt: { message: prompt.message, - extraSystemPrompt: prompt.extraSystemPrompt, + extraSystemPrompt: mergedExtraSystemPrompt || undefined, images: images.length > 0 ? images : undefined, }, + clientTools: resolvedClientTools.length > 0 ? resolvedClientTools : undefined, modelOverride, sessionKey, runId, @@ -617,8 +908,37 @@ export async function handleOpenAiHttpRequest( return true; } - const content = resolveAgentResponseText(result); const usage = resolveChatCompletionUsage(result); + const meta = (result as { meta?: unknown } | null)?.meta; + const { stopReason, pendingToolCalls } = resolveStopReasonAndPendingToolCalls(meta); + + if (stopReason === "tool_calls" && pendingToolCalls && pendingToolCalls.length > 0) { + const commentary = resolveAgentResponseCommentary(result); + sendJson(res, 200, { + id: runId, + object: "chat.completion", + created: Math.floor(Date.now() / 1000), + model, + choices: [ + { + index: 0, + message: { + role: "assistant", + content: commentary, + tool_calls: pendingToolCalls.map((call) => ({ + id: call.id, + type: "function", + function: { name: call.name, arguments: call.arguments }, + })), + }, + finish_reason: "tool_calls", + }, + ], + usage, + }); + return true; + } + const content = resolveAgentResponseText(result); sendJson(res, 200, { id: runId, @@ -639,6 +959,12 @@ export async function handleOpenAiHttpRequest( return true; } logWarn(`openai-compat: chat completion failed: ${String(err)}`); + if (isClientToolNameConflictError(err)) { + sendJson(res, 400, { + error: { message: "invalid tool configuration", type: "invalid_request_error" }, + }); + return true; + } sendJson(res, 500, { error: { message: "internal error", type: "api_error" }, }); @@ -661,6 +987,8 @@ export async function handleOpenAiHttpRequest( } | undefined; let finalizeRequested = false; + let finalizeFinishReason: "stop" | "tool_calls" = "stop"; + let resultResolved = false; let closed = false; let stopWatchingDisconnect = () => {}; @@ -668,6 +996,9 @@ export async function handleOpenAiHttpRequest( if (closed || !finalizeRequested) { return; } + if (!resultResolved) { + return; + } if (streamIncludeUsage && !finalUsage) { return; } @@ -675,7 +1006,7 @@ export async function handleOpenAiHttpRequest( stopWatchingDisconnect(); unsubscribe(); if (!wroteStopChunk) { - writeAssistantStopChunk(res, { runId, model }); + writeAssistantFinishChunk(res, { runId, model, finishReason: finalizeFinishReason }); wroteStopChunk = true; } if (streamIncludeUsage && finalUsage) { @@ -685,7 +1016,8 @@ export async function handleOpenAiHttpRequest( res.end(); }; - const requestFinalize = () => { + const requestFinalize = (finishReason: "stop" | "tool_calls" = "stop") => { + finalizeFinishReason = finishReason; finalizeRequested = true; maybeFinalize(); }; @@ -738,12 +1070,41 @@ export async function handleOpenAiHttpRequest( void (async () => { try { const result = await agentCommandFromIngress(commandInput, defaultRuntime, deps); + resultResolved = true; if (closed) { return; } finalUsage = resolveChatCompletionUsage(result); + const meta = (result as { meta?: unknown } | null)?.meta; + const { stopReason, pendingToolCalls } = resolveStopReasonAndPendingToolCalls(meta); + + if (stopReason === "tool_calls" && pendingToolCalls && pendingToolCalls.length > 0) { + if (!wroteRole) { + wroteRole = true; + writeAssistantRoleChunk(res, { runId, model }); + } + if (!sawAssistantDelta) { + const commentary = resolveAgentResponseCommentary(result); + if (commentary) { + sawAssistantDelta = true; + writeAssistantContentChunk(res, { + runId, + model, + content: commentary, + finishReason: null, + }); + } + } + writeAssistantToolCallsIncrementalChunks(res, { + runId, + model, + toolCalls: pendingToolCalls, + }); + requestFinalize("tool_calls"); + return; + } if (!sawAssistantDelta) { if (!wroteRole) { @@ -763,14 +1124,27 @@ export async function handleOpenAiHttpRequest( } requestFinalize(); } catch (err) { + resultResolved = true; if (closed || abortController.signal.aborted) { return; } logWarn(`openai-compat: streaming chat completion failed: ${String(err)}`); + if (isClientToolNameConflictError(err)) { + closed = true; + stopWatchingDisconnect(); + unsubscribe(); + writeSse(res, { + error: { message: "invalid tool configuration", type: "invalid_request_error" }, + }); + writeDone(res); + res.end(); + return; + } + const content = "Error: internal error"; writeAssistantContentChunk(res, { runId, model, - content: "Error: internal error", + content, finishReason: "stop", }); wroteStopChunk = true; From 1f49d34c5fb97ce440acfa15cd65cb63693159ba Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 11 May 2026 12:52:53 +0100 Subject: [PATCH 13/93] fix(gateway): preserve batched client tool calls --- CHANGELOG.md | 2 +- docs/reference/full-release-validation.md | 18 ++-- package.json | 1 + scripts/check-docker-e2e-boundaries.mjs | 7 +- scripts/e2e/lib/openai-chat-tools/client.mjs | 100 ++++++++++++++++++ scripts/e2e/lib/openai-chat-tools/scenario.sh | 86 +++++++++++++++ .../lib/openai-chat-tools/write-config.mjs | 90 ++++++++++++++++ scripts/e2e/openai-chat-tools-docker.sh | 43 ++++++++ scripts/lib/docker-e2e-plan.mjs | 1 + scripts/lib/docker-e2e-scenarios.mjs | 17 +++ scripts/test-docker-all.mjs | 2 +- src/agents/pi-tool-definition-adapter.test.ts | 39 +++++-- src/agents/pi-tool-definition-adapter.ts | 15 +-- src/gateway/openai-http.test.ts | 1 + test/scripts/docker-e2e-plan.test.ts | 4 + 15 files changed, 402 insertions(+), 24 deletions(-) create mode 100644 scripts/e2e/lib/openai-chat-tools/client.mjs create mode 100644 scripts/e2e/lib/openai-chat-tools/scenario.sh create mode 100644 scripts/e2e/lib/openai-chat-tools/write-config.mjs create mode 100644 scripts/e2e/openai-chat-tools-docker.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index a941c44f418..da0dc66faf2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -412,7 +412,7 @@ Docs: https://docs.openclaw.ai - Channels/iMessage: honor `channels.imessage.groups..systemPrompt` (and the `groups["*"]` wildcard) by forwarding it as `GroupSystemPrompt` on inbound group turns, mirroring the byte-identical resolver semantic from WhatsApp where defining the key as an empty string on a specific group suppresses the wildcard fallback. Brings iMessage to parity with the per-group `systemPrompt` pattern already supported by Discord, Telegram, IRC, Slack, GoogleChat, and the retired BlueBubbles channel. Fixes #78285. (#79383) Thanks @omarshahine. - iMessage: add opt-in inbound catchup that replays messages received while the gateway was offline (crash, restart, mac sleep) on next startup. Enable with `channels.imessage.catchup.enabled: true`; tunables for `maxAgeMinutes`, `perRunLimit`, `firstRunLookbackMinutes`, and `maxFailureRetries`. Persists a per-account cursor under the OpenClaw state dir (`/imessage/catchup/`), replays each row through the live dispatch path so allowlists/group policy/dedupe behave identically on replayed and live messages, and force-advances past wedged guids after `maxFailureRetries` to prevent stuck cursors. Extends the persisted echo-cache retention window so the agent's own outbound rows from before a gap are not re-fed as inbound on replay. Includes a regenerated `src/config/bundled-channel-config-metadata.generated.ts` so the runtime AJV schema accepts the new `channels.imessage.catchup` block. Fixes #78649. (#79387) Thanks @omarshahine. - Channels/Yuanbao: bump the bundled `openclaw-plugin-yuanbao` npm spec from `2.11.0` to `2.13.0` in the official external channel catalog and refresh the pinned integrity hash, so fresh installs and catalog-driven reinstalls pick up the newer Yuanbao channel plugin release. (#79620) Thanks @loongfay. -- Gateway/OpenAI-compatible Chat Completions: support function `tools`, `tool_choice`, `tool_calls`, and `role: "tool"` follow-up turns while keeping tool-call stream finalization aligned with the command result and reporting client-tool name conflicts as invalid requests. +- Gateway/OpenAI-compatible Chat Completions: support function `tools`, `tool_choice`, `tool_calls`, and `role: "tool"` follow-up turns while keeping tool-call stream finalization aligned with the command result and reporting client-tool name conflicts as invalid requests. (#66278) Thanks @Lellansin. - Providers/Mistral: add `mistral-medium-3-5` to the bundled catalog with reasoning support. Thanks @sliekens. - Docs/Mistral: document Medium 3.5 setup, local infer smoke usage, adjustable reasoning, and the Mistral HTTP 400 caveat for `reasoning_effort="high"` with `temperature: 0`. diff --git a/docs/reference/full-release-validation.md b/docs/reference/full-release-validation.md index 32aad7c5a12..a764b1aec81 100644 --- a/docs/reference/full-release-validation.md +++ b/docs/reference/full-release-validation.md @@ -81,15 +81,15 @@ or Docker-facing stages need it. The Docker release-path stage runs these chunks when `live_suite_filter` is empty: -| Chunk | Coverage | -| --------------------------------------------------------------- | -------------------------------------------------------------------------------- | -| `core` | Core Docker release-path smoke lanes. | -| `package-update-openai` | OpenAI package install/update behavior, including Codex on-demand install. | -| `package-update-anthropic` | Anthropic package install and update behavior. | -| `package-update-core` | Provider-neutral package and update behavior. | -| `plugins-runtime-plugins` | Plugin runtime lanes that exercise plugin behavior. | -| `plugins-runtime-services` | Service-backed and live plugin runtime lanes; includes OpenWebUI when requested. | -| `plugins-runtime-install-a` through `plugins-runtime-install-h` | Plugin install/runtime batches split for parallel release validation. | +| Chunk | Coverage | +| --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | +| `core` | Core Docker release-path smoke lanes. | +| `package-update-openai` | OpenAI package install/update behavior, Codex on-demand install, and Chat Completions tool calls. | +| `package-update-anthropic` | Anthropic package install and update behavior. | +| `package-update-core` | Provider-neutral package and update behavior. | +| `plugins-runtime-plugins` | Plugin runtime lanes that exercise plugin behavior. | +| `plugins-runtime-services` | Service-backed and live plugin runtime lanes; includes OpenWebUI when requested. | +| `plugins-runtime-install-a` through `plugins-runtime-install-h` | Plugin install/runtime batches split for parallel release validation. | Use targeted `docker_lanes=` on the reusable live/E2E workflow when only one Docker lane failed. The release artifacts include per-lane rerun diff --git a/package.json b/package.json index 10f6ff71aaa..aab739d9fa5 100644 --- a/package.json +++ b/package.json @@ -1601,6 +1601,7 @@ "test:docker:npm-onboard-slack-channel-agent": "OPENCLAW_NPM_ONBOARD_CHANNEL=slack bash scripts/e2e/npm-onboard-channel-agent-docker.sh", "test:docker:npm-telegram-live": "bash scripts/e2e/npm-telegram-live-docker.sh", "test:docker:onboard": "bash scripts/e2e/onboard-docker.sh", + "test:docker:openai-chat-tools": "bash scripts/e2e/openai-chat-tools-docker.sh", "test:docker:openai-image-auth": "bash scripts/e2e/openai-image-auth-docker.sh", "test:docker:openai-web-search-minimal": "bash scripts/e2e/openai-web-search-minimal-docker.sh", "test:docker:openwebui": "bash scripts/e2e/openwebui-docker.sh", diff --git a/scripts/check-docker-e2e-boundaries.mjs b/scripts/check-docker-e2e-boundaries.mjs index 5e966b4403a..788fe4de5e0 100644 --- a/scripts/check-docker-e2e-boundaries.mjs +++ b/scripts/check-docker-e2e-boundaries.mjs @@ -14,7 +14,12 @@ const packageJson = JSON.parse(readText("package.json")); const packageScripts = new Set(Object.keys(packageJson.scripts ?? {})); // These lanes prove package-installed surfaces against live auth, so they // intentionally need both live credentials and a package-backed image. -const livePackageBackedLanes = new Set(["live-codex-npm-plugin", "live-plugin-tool", "openwebui"]); +const livePackageBackedLanes = new Set([ + "live-codex-npm-plugin", + "live-plugin-tool", + "openai-chat-tools", + "openwebui", +]); function readText(relativePath) { return fs.readFileSync(path.join(ROOT_DIR, relativePath), "utf8"); diff --git a/scripts/e2e/lib/openai-chat-tools/client.mjs b/scripts/e2e/lib/openai-chat-tools/client.mjs new file mode 100644 index 00000000000..047cd009871 --- /dev/null +++ b/scripts/e2e/lib/openai-chat-tools/client.mjs @@ -0,0 +1,100 @@ +const port = process.env.PORT; +const token = process.env.OPENCLAW_GATEWAY_TOKEN; +const backendModel = process.env.MODEL_REF || "openai/gpt-5.4-mini"; +const timeoutSeconds = Number.parseInt( + process.env.OPENCLAW_OPENAI_CHAT_TOOLS_TIMEOUT_SECONDS ?? "180", + 10, +); + +if (!port || !token) { + throw new Error("missing PORT/OPENCLAW_GATEWAY_TOKEN"); +} + +const controller = new AbortController(); +const timeout = setTimeout(() => controller.abort(), timeoutSeconds * 1000); +const started = Date.now(); +const response = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { + method: "POST", + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json", + "x-openclaw-model": backendModel, + }, + body: JSON.stringify({ + model: "openclaw", + stream: false, + messages: [ + { + role: "user", + content: + "Use the get_weather tool exactly once for Paris, France. Return the tool call only.", + }, + ], + tool_choice: "auto", + tools: [ + { + type: "function", + function: { + name: "get_weather", + description: "Return weather for a city.", + strict: true, + parameters: { + type: "object", + additionalProperties: false, + properties: { + city: { type: "string", description: "City and country." }, + }, + required: ["city"], + }, + }, + }, + ], + }), + signal: controller.signal, +}); +clearTimeout(timeout); + +const text = await response.text(); +let body; +try { + body = text ? JSON.parse(text) : {}; +} catch { + throw new Error(`non-JSON response ${response.status}: ${text}`); +} + +if (!response.ok) { + throw new Error(`chat completions request failed ${response.status}: ${JSON.stringify(body)}`); +} + +const choice = body.choices?.[0]; +const toolCalls = choice?.message?.tool_calls; +if (choice?.finish_reason !== "tool_calls") { + throw new Error(`expected finish_reason tool_calls: ${JSON.stringify(body)}`); +} +if (!Array.isArray(toolCalls) || toolCalls.length !== 1) { + throw new Error(`expected exactly one tool call: ${JSON.stringify(body)}`); +} +const [toolCall] = toolCalls; +if (toolCall?.type !== "function" || toolCall?.function?.name !== "get_weather") { + throw new Error(`unexpected tool call: ${JSON.stringify(toolCall)}`); +} + +let args = {}; +try { + args = JSON.parse(toolCall.function.arguments || "{}"); +} catch { + throw new Error(`tool arguments were not valid JSON: ${toolCall.function.arguments}`); +} +if (typeof args.city !== "string" || !/paris/i.test(args.city)) { + throw new Error(`expected Paris city argument: ${JSON.stringify(args)}`); +} + +console.log( + JSON.stringify({ + ok: true, + elapsedMs: Date.now() - started, + finishReason: choice.finish_reason, + toolName: toolCall.function.name, + args, + }), +); diff --git a/scripts/e2e/lib/openai-chat-tools/scenario.sh b/scripts/e2e/lib/openai-chat-tools/scenario.sh new file mode 100644 index 00000000000..2625d85f297 --- /dev/null +++ b/scripts/e2e/lib/openai-chat-tools/scenario.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +set -euo pipefail + +source scripts/lib/openclaw-e2e-instance.sh +openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}" +export OPENCLAW_SKIP_CHANNELS=1 +export OPENCLAW_SKIP_GMAIL_WATCHER=1 +export OPENCLAW_SKIP_CRON=1 +export OPENCLAW_SKIP_CANVAS_HOST=1 +export OPENCLAW_SKIP_BROWSER_CONTROL_SERVER=1 +export OPENCLAW_SKIP_ACPX_RUNTIME=1 +export OPENCLAW_SKIP_ACPX_RUNTIME_PROBE=1 +export OPENCLAW_AGENT_HARNESS_FALLBACK=none + +for profile_path in "$HOME/.profile" /home/appuser/.profile; do + if [ -f "$profile_path" ] && [ -r "$profile_path" ]; then + set +e +u + # shellcheck disable=SC1090 + source "$profile_path" + set -euo pipefail + break + fi +done +if [ -z "${OPENAI_API_KEY:-}" ]; then + echo "ERROR: OPENAI_API_KEY was not available after sourcing ~/.profile." >&2 + exit 1 +fi +export OPENAI_API_KEY +if [ -n "${OPENAI_BASE_URL:-}" ]; then + export OPENAI_BASE_URL +fi + +PORT="${PORT:?missing PORT}" +TOKEN="${OPENCLAW_GATEWAY_TOKEN:?missing OPENCLAW_GATEWAY_TOKEN}" +MODEL_REF="${OPENCLAW_OPENAI_CHAT_TOOLS_MODEL:?missing OPENCLAW_OPENAI_CHAT_TOOLS_MODEL}" +GATEWAY_LOG="/tmp/openclaw-openai-chat-tools-gateway.log" +CLIENT_LOG="/tmp/openclaw-openai-chat-tools-client.log" +gateway_pid="" + +cleanup() { + openclaw_e2e_stop_process "$gateway_pid" +} +trap cleanup EXIT + +dump_debug_logs() { + local status="$1" + echo "OpenAI Chat Completions tools Docker E2E failed with exit code $status" >&2 + openclaw_e2e_dump_logs "$GATEWAY_LOG" "$CLIENT_LOG" + if [ -f "$OPENCLAW_CONFIG_PATH" ]; then + echo "--- $OPENCLAW_CONFIG_PATH keys ---" >&2 + node -e "const fs=require('fs'); const cfg=JSON.parse(fs.readFileSync(process.argv[1],'utf8')); console.error(JSON.stringify({model:cfg.agents?.defaults?.model, tools:cfg.tools, provider:cfg.models?.providers?.openai && {api:cfg.models.providers.openai.api, baseUrl:cfg.models.providers.openai.baseUrl, agentRuntime:cfg.models.providers.openai.agentRuntime}}, null, 2));" "$OPENCLAW_CONFIG_PATH" || true + fi +} +trap 'status=$?; dump_debug_logs "$status"; exit "$status"' ERR + +entry="$(openclaw_e2e_resolve_entrypoint)" +mkdir -p "$OPENCLAW_STATE_DIR" "$OPENCLAW_TEST_WORKSPACE_DIR" + +node scripts/e2e/lib/openai-chat-tools/write-config.mjs + +gateway_pid="$(openclaw_e2e_start_gateway "$entry" "$PORT" "$GATEWAY_LOG")" +for _ in $(seq 1 360); do + if ! kill -0 "$gateway_pid" 2>/dev/null; then + echo "gateway exited before listening" >&2 + exit 1 + fi + if node "$entry" gateway health \ + --url "ws://127.0.0.1:$PORT" \ + --token "$TOKEN" \ + --timeout 120000 \ + --json >/dev/null 2>&1; then + break + fi + sleep 0.25 +done +node "$entry" gateway health \ + --url "ws://127.0.0.1:$PORT" \ + --token "$TOKEN" \ + --timeout 120000 \ + --json >/dev/null + +PORT="$PORT" OPENCLAW_GATEWAY_TOKEN="$TOKEN" MODEL_REF="$MODEL_REF" \ + node scripts/e2e/lib/openai-chat-tools/client.mjs >"$CLIENT_LOG" 2>&1 + +cat "$CLIENT_LOG" +echo "OpenAI Chat Completions tools Docker E2E passed" diff --git a/scripts/e2e/lib/openai-chat-tools/write-config.mjs b/scripts/e2e/lib/openai-chat-tools/write-config.mjs new file mode 100644 index 00000000000..0c3bd2ecd43 --- /dev/null +++ b/scripts/e2e/lib/openai-chat-tools/write-config.mjs @@ -0,0 +1,90 @@ +import fs from "node:fs"; +import path from "node:path"; + +function requireEnv(name) { + const value = process.env[name]; + if (!value) { + throw new Error(`missing ${name}`); + } + return value; +} + +const configPath = requireEnv("OPENCLAW_CONFIG_PATH"); +const stateDir = requireEnv("OPENCLAW_STATE_DIR"); +const workspaceDir = requireEnv("OPENCLAW_TEST_WORKSPACE_DIR"); +const modelRef = requireEnv("OPENCLAW_OPENAI_CHAT_TOOLS_MODEL"); +const token = requireEnv("OPENCLAW_GATEWAY_TOKEN"); +const timeoutSeconds = Number.parseInt( + process.env.OPENCLAW_OPENAI_CHAT_TOOLS_TIMEOUT_SECONDS ?? "180", + 10, +); +const [providerId, modelId] = modelRef.split("/"); +if (providerId !== "openai" || !modelId) { + throw new Error(`OPENCLAW_OPENAI_CHAT_TOOLS_MODEL must be openai/*, got ${modelRef}`); +} + +const config = { + gateway: { + port: Number.parseInt(process.env.PORT ?? "18789", 10), + bind: "loopback", + auth: { mode: "token", token }, + controlUi: { enabled: false }, + http: { + endpoints: { + chatCompletions: { enabled: true }, + }, + }, + }, + models: { + mode: "merge", + providers: { + openai: { + api: "openai-responses", + apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, + baseUrl: (process.env.OPENAI_BASE_URL || "https://api.openai.com/v1").trim(), + agentRuntime: { id: "pi" }, + timeoutSeconds, + models: [ + { + id: modelId, + name: modelId, + api: "openai-responses", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + contextTokens: 64000, + maxTokens: 512, + }, + ], + }, + }, + }, + agents: { + defaults: { + model: { primary: modelRef, fallbacks: [] }, + models: { + [modelRef]: { + agentRuntime: { id: "pi" }, + params: { transport: "sse", openaiWsWarmup: false }, + }, + }, + workspace: workspaceDir, + skipBootstrap: true, + timeoutSeconds, + contextTokens: 64000, + }, + }, + plugins: { + enabled: true, + allow: ["openai"], + entries: { openai: { enabled: true } }, + }, + skills: { allowBundled: [] }, + tools: { allow: ["get_weather"] }, +}; + +fs.mkdirSync(path.dirname(configPath), { recursive: true }); +fs.mkdirSync(workspaceDir, { recursive: true }); +fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`); +fs.mkdirSync(path.join(stateDir, "logs"), { recursive: true }); diff --git a/scripts/e2e/openai-chat-tools-docker.sh b/scripts/e2e/openai-chat-tools-docker.sh new file mode 100644 index 00000000000..5541a4b17ca --- /dev/null +++ b/scripts/e2e/openai-chat-tools-docker.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +source "$ROOT_DIR/scripts/lib/docker-e2e-image.sh" + +IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-openai-chat-tools-e2e" OPENCLAW_OPENAI_CHAT_TOOLS_E2E_IMAGE)" +SKIP_BUILD="${OPENCLAW_OPENAI_CHAT_TOOLS_E2E_SKIP_BUILD:-0}" +PORT="${OPENCLAW_OPENAI_CHAT_TOOLS_PORT:-18789}" +TOKEN="openai-chat-tools-e2e-$$" +PROFILE_FILE="${OPENCLAW_OPENAI_CHAT_TOOLS_PROFILE_FILE:-${OPENCLAW_TESTBOX_PROFILE_FILE:-$HOME/.openclaw-testbox-live.profile}}" +if [ ! -f "$PROFILE_FILE" ] && [ -f "$HOME/.profile" ]; then + PROFILE_FILE="$HOME/.profile" +fi + +docker_e2e_build_or_reuse "$IMAGE_NAME" openai-chat-tools "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" "" "$SKIP_BUILD" +OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 openai-chat-tools empty)" + +PROFILE_MOUNT=() +PROFILE_STATUS="none" +if [ -f "$PROFILE_FILE" ] && [ -r "$PROFILE_FILE" ]; then + set -a + # shellcheck disable=SC1090 + source "$PROFILE_FILE" + set +a + PROFILE_MOUNT=(-v "$PROFILE_FILE":/home/appuser/.profile:ro) + PROFILE_STATUS="$PROFILE_FILE" +fi + +echo "Running OpenAI Chat Completions tools Docker E2E..." +echo "Profile file: $PROFILE_STATUS" +docker_e2e_run_logged_with_harness openai-chat-tools \ + -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ + -e OPENAI_API_KEY \ + -e OPENAI_BASE_URL \ + -e "OPENCLAW_GATEWAY_TOKEN=$TOKEN" \ + -e "OPENCLAW_OPENAI_CHAT_TOOLS_MODEL=${OPENCLAW_OPENAI_CHAT_TOOLS_MODEL:-openai/gpt-5.4-mini}" \ + -e "OPENCLAW_OPENAI_CHAT_TOOLS_TIMEOUT_SECONDS=${OPENCLAW_OPENAI_CHAT_TOOLS_TIMEOUT_SECONDS:-180}" \ + -e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64" \ + -e "PORT=$PORT" \ + "${PROFILE_MOUNT[@]}" \ + "$IMAGE_NAME" \ + bash scripts/e2e/lib/openai-chat-tools/scenario.sh diff --git a/scripts/lib/docker-e2e-plan.mjs b/scripts/lib/docker-e2e-plan.mjs index b0ecbb8095a..82ceb440379 100644 --- a/scripts/lib/docker-e2e-plan.mjs +++ b/scripts/lib/docker-e2e-plan.mjs @@ -333,6 +333,7 @@ function laneCredentialRequirements(poolLane) { } if ( poolLane.name === "openwebui" || + poolLane.name === "openai-chat-tools" || poolLane.name === "openai-web-search-minimal" || poolLane.name === "live-codex-npm-plugin" || poolLane.name === "live-plugin-tool" diff --git a/scripts/lib/docker-e2e-scenarios.mjs b/scripts/lib/docker-e2e-scenarios.mjs index b1a1004e4ab..956d04ac459 100644 --- a/scripts/lib/docker-e2e-scenarios.mjs +++ b/scripts/lib/docker-e2e-scenarios.mjs @@ -141,6 +141,22 @@ function livePluginToolLane() { ); } +function liveOpenAiChatToolsLane() { + return liveLane( + "openai-chat-tools", + "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:openai-chat-tools", + { + e2eImageKind: "functional", + needsLiveImage: false, + provider: "openai", + resources: ["service"], + stateScenario: "empty", + timeoutMs: 10 * 60 * 1000, + weight: 2, + }, + ); +} + export const mainLanes = [ liveLane("live-models", liveDockerScriptCommand("test-live-models-docker.sh"), { providers: ["claude-cli", "codex-cli", "google-gemini-cli"], @@ -539,6 +555,7 @@ const releasePathPackageInstallOpenAiLanes = [ weight: 3, }, ), + liveOpenAiChatToolsLane(), npmLane("codex-on-demand", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:codex-on-demand", { resources: ["service"], stateScenario: "empty", diff --git a/scripts/test-docker-all.mjs b/scripts/test-docker-all.mjs index 6b3d2f9d094..19e4a46e724 100644 --- a/scripts/test-docker-all.mjs +++ b/scripts/test-docker-all.mjs @@ -1244,7 +1244,7 @@ async function main() { if (buildEnabled) { const buildEntries = []; - if (scheduledLanes.some((poolLane) => poolLane.live)) { + if (scheduledLanes.some((poolLane) => poolLane.needsLiveImage)) { buildEntries.push({ command: liveDockerHarnessScriptCommand("test-live-build-docker.sh"), label: "shared live-test image once", diff --git a/src/agents/pi-tool-definition-adapter.test.ts b/src/agents/pi-tool-definition-adapter.test.ts index fe1d571445f..036dc86aa36 100644 --- a/src/agents/pi-tool-definition-adapter.test.ts +++ b/src/agents/pi-tool-definition-adapter.test.ts @@ -124,9 +124,10 @@ function makeClientTool(name: string): ClientToolDefinition { }; } -async function executeClientTool( - params: unknown, -): Promise<{ calledWith: Record | undefined }> { +async function executeClientTool(params: unknown): Promise<{ + calledWith: Record | undefined; + result: Awaited>; +}> { let captured: Record | undefined; const [def] = toClientToolDefinitions([makeClientTool("search")], (_name, p) => { captured = p; @@ -134,14 +135,40 @@ async function executeClientTool( if (!def) { throw new Error("missing client tool definition"); } - await def.execute("call-c1", params, undefined, undefined, extensionContext); - return { calledWith: captured }; + const result = await def.execute("call-c1", params, undefined, undefined, extensionContext); + return { calledWith: captured, result }; } describe("toClientToolDefinitions – param coercion", () => { + it("returns terminal pending results for each client tool in a batch", async () => { + const completed: Array<{ id: string; name: string; params: Record }> = []; + const defs = toClientToolDefinitions([makeClientTool("search"), makeClientTool("lookup")], { + complete: (id, name, params) => { + completed.push({ id, name, params }); + }, + }); + const [search, lookup] = defs; + if (!search || !lookup) { + throw new Error("missing client tool definition"); + } + + const [searchResult, lookupResult] = await Promise.all([ + search.execute("call-search", { query: "first" }, undefined, undefined, extensionContext), + lookup.execute("call-lookup", { query: "second" }, undefined, undefined, extensionContext), + ]); + + expect(searchResult.terminate).toBe(true); + expect(lookupResult.terminate).toBe(true); + expect(completed).toEqual([ + { id: "call-search", name: "search", params: { query: "first" } }, + { id: "call-lookup", name: "lookup", params: { query: "second" } }, + ]); + }); + it("passes plain object params through unchanged", async () => { - const { calledWith } = await executeClientTool({ query: "hello" }); + const { calledWith, result } = await executeClientTool({ query: "hello" }); expect(calledWith).toEqual({ query: "hello" }); + expect(result.terminate).toBe(true); }); it("parses a JSON string into an object (streaming delta accumulation)", async () => { diff --git a/src/agents/pi-tool-definition-adapter.ts b/src/agents/pi-tool-definition-adapter.ts index 512b483b271..2a1a7859b6c 100644 --- a/src/agents/pi-tool-definition-adapter.ts +++ b/src/agents/pi-tool-definition-adapter.ts @@ -377,12 +377,15 @@ export function toClientToolDefinitions( } throw err; } - // Return a pending result - the client will execute this tool - return jsonResult({ - status: "pending", - tool: func.name, - message: "Tool execution delegated to client", - }); + // Return a terminal pending result; the client will execute the tool. + return { + ...jsonResult({ + status: "pending", + tool: func.name, + message: "Tool execution delegated to client", + }), + terminate: true, + }; }, } satisfies ToolDefinition; }); diff --git a/src/gateway/openai-http.test.ts b/src/gateway/openai-http.test.ts index e441b762b81..06c173d0657 100644 --- a/src/gateway/openai-http.test.ts +++ b/src/gateway/openai-http.test.ts @@ -707,6 +707,7 @@ describe("OpenAI-compatible HTTP API (e2e)", () => { expect(clientTools[0]?.type).toBe("function"); expect(clientTools[0]?.function?.name).toBe("get_time"); expect(clientTools[0]?.function?.strict).toBe(true); + expect(firstCall).not.toHaveProperty("toolsAllow"); await res.text(); } diff --git a/test/scripts/docker-e2e-plan.test.ts b/test/scripts/docker-e2e-plan.test.ts index 3edfd0cf2ea..3c3f17fcf4a 100644 --- a/test/scripts/docker-e2e-plan.test.ts +++ b/test/scripts/docker-e2e-plan.test.ts @@ -112,6 +112,7 @@ describe("scripts/lib/docker-e2e-plan", () => { }); expect(plan.credentials).toEqual(["anthropic", "openai"]); expect(plan.lanes.map((lane) => lane.name)).toContain("install-e2e-openai"); + expect(plan.lanes.map((lane) => lane.name)).toContain("openai-chat-tools"); expect(plan.lanes.map((lane) => lane.name)).toContain("codex-on-demand"); expect(plan.lanes.map((lane) => lane.name)).toContain("install-e2e-anthropic"); expect(plan.lanes.map((lane) => lane.name)).toContain("mcp-channels"); @@ -155,6 +156,7 @@ describe("scripts/lib/docker-e2e-plan", () => { const laneNames = plan.lanes.map((lane) => lane.name); expect(plan.releaseProfile).toBe("beta"); expect(laneNames).toContain("install-e2e-openai"); + expect(laneNames).toContain("openai-chat-tools"); expect(laneNames).toContain("install-e2e-anthropic"); expect(laneNames).toContain("update-channel-switch"); expect(laneNames).not.toContain("plugins"); @@ -243,6 +245,7 @@ describe("scripts/lib/docker-e2e-plan", () => { expect(packageInstallOpenAi.lanes.map((lane) => lane.name)).toEqual([ "install-e2e-openai", + "openai-chat-tools", "codex-on-demand", ]); expect(packageInstallAnthropic.lanes.map((lane) => lane.name)).toEqual([ @@ -468,6 +471,7 @@ describe("scripts/lib/docker-e2e-plan", () => { expect(packageUpdate.lanes.map((lane) => lane.name)).toEqual([ "install-e2e-openai", + "openai-chat-tools", "codex-on-demand", "install-e2e-anthropic", "npm-onboard-channel-agent", From 9bb7f220c948270173efe2b0ccb1911ee88b7845 Mon Sep 17 00:00:00 2001 From: Shakker Date: Mon, 11 May 2026 13:05:54 +0100 Subject: [PATCH 14/93] test: assert daemon config guard json --- .../lifecycle-core.config-guard.test.ts | 36 +++++++++++++------ 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/src/cli/daemon-cli/lifecycle-core.config-guard.test.ts b/src/cli/daemon-cli/lifecycle-core.config-guard.test.ts index f83c7e5714b..bcb8c3d9919 100644 --- a/src/cli/daemon-cli/lifecycle-core.config-guard.test.ts +++ b/src/cli/daemon-cli/lifecycle-core.config-guard.test.ts @@ -1,4 +1,5 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { VERSION } from "../../version.js"; import { defaultRuntime, resetLifecycleRuntimeLogs, @@ -9,6 +10,15 @@ import { const readConfigFileSnapshotMock = vi.fn(); const loadConfig = vi.fn(() => ({})); +const newerConfigHints = [ + "Run the newer openclaw binary on PATH, or reinstall the intended gateway service from the newer install.", + "Set OPENCLAW_ALLOW_OLDER_BINARY_DESTRUCTIVE_ACTIONS=1 only for an intentional downgrade or recovery action.", +]; +const newerConfigHintItems = newerConfigHints.map((text) => ({ kind: "generic", text })); + +function expectLatestRuntimeJson(payload: unknown) { + expect(defaultRuntime.writeJson.mock.calls.at(-1)?.[0]).toEqual(payload); +} vi.mock("../../config/config.js", () => ({ getRuntimeConfig: () => loadConfig(), @@ -90,11 +100,14 @@ describe("runServiceRestart config pre-flight (#35862)", () => { await expect(runServiceRestart(createServiceRunArgs())).rejects.toThrow("__exit__:1"); expect(service.restart).not.toHaveBeenCalled(); - expect(defaultRuntime.writeJson).toHaveBeenCalledWith( - expect.objectContaining({ - error: expect.stringContaining("Refusing to restart the gateway service"), - }), - ); + expectLatestRuntimeJson({ + action: "restart", + ok: false, + error: `Gateway restart blocked: Refusing to restart the gateway service because this OpenClaw binary (${VERSION}) is older than the config last written by OpenClaw 9999.1.1.`, + hints: newerConfigHints, + hintItems: newerConfigHintItems, + warnings: undefined, + }); }); it("proceeds with restart when config is valid", async () => { @@ -208,10 +221,13 @@ describe("runServiceStop future-config guard", () => { ).rejects.toThrow("__exit__:1"); expect(service.stop).not.toHaveBeenCalled(); - expect(defaultRuntime.writeJson).toHaveBeenCalledWith( - expect.objectContaining({ - error: expect.stringContaining("Refusing to stop the gateway service"), - }), - ); + expectLatestRuntimeJson({ + action: "stop", + ok: false, + error: `Gateway stop blocked: Refusing to stop the gateway service because this OpenClaw binary (${VERSION}) is older than the config last written by OpenClaw 9999.1.1.`, + hints: newerConfigHints, + hintItems: newerConfigHintItems, + warnings: undefined, + }); }); }); From 0f8fc6bb61411c9dc31cd84521ba62628fb05e0f Mon Sep 17 00:00:00 2001 From: Lior Balmas Date: Sun, 10 May 2026 08:06:31 +0300 Subject: [PATCH 15/93] fix(runtime): detect Fly Machines as containers --- extensions/bonjour/src/advertiser.test.ts | 16 ++++++++++++++ extensions/bonjour/src/advertiser.ts | 4 ++++ src/gateway/net.test.ts | 27 +++++++++++++++++++++++ src/infra/container-environment.ts | 4 ++++ 4 files changed, 51 insertions(+) diff --git a/extensions/bonjour/src/advertiser.test.ts b/extensions/bonjour/src/advertiser.test.ts index 32579a6feea..a8a86e803d3 100644 --- a/extensions/bonjour/src/advertiser.test.ts +++ b/extensions/bonjour/src/advertiser.test.ts @@ -250,6 +250,22 @@ describe("gateway bonjour advertiser", () => { await expect(started.stop()).resolves.toBeUndefined(); }); + it("auto-disables Bonjour on Fly Machines without Docker sentinel files", async () => { + enableAdvertiserUnitMode(); + process.env.FLY_MACHINE_ID = "3d8d5459a03038"; + process.env.FLY_APP_NAME = "openclaw-clawcks-test"; + vi.spyOn(fs, "existsSync").mockReturnValue(false); + vi.spyOn(fs, "readFileSync").mockReturnValue("10:cpuset:/\n9:perf_event:/\n8:memory:/\n0::/\n"); + + const started = await startAdvertiser({ + gatewayPort: 18789, + sshPort: 2222, + }); + + expect(createService).not.toHaveBeenCalled(); + await expect(started.stop()).resolves.toBeUndefined(); + }); + it("honors explicit Bonjour opt-in inside detected containers", async () => { enableAdvertiserUnitMode(); process.env.OPENCLAW_DISABLE_BONJOUR = "0"; diff --git a/extensions/bonjour/src/advertiser.ts b/extensions/bonjour/src/advertiser.ts index 6daa3898943..e7ca242bd79 100644 --- a/extensions/bonjour/src/advertiser.ts +++ b/extensions/bonjour/src/advertiser.ts @@ -135,6 +135,10 @@ function readBonjourDisableOverride(): boolean | null { } function isContainerEnvironment() { + if (process.env.FLY_MACHINE_ID?.trim() && process.env.FLY_APP_NAME?.trim()) { + return true; + } + for (const sentinelPath of ["/.dockerenv", "/run/.containerenv", "/var/run/.containerenv"]) { try { if (fs.existsSync(sentinelPath)) { diff --git a/src/gateway/net.test.ts b/src/gateway/net.test.ts index 71b6106ad50..13b2ca101d4 100644 --- a/src/gateway/net.test.ts +++ b/src/gateway/net.test.ts @@ -515,6 +515,33 @@ describe("isContainerEnvironment", () => { expect(isContainerEnvironment()).toBe(true); }); + it("returns true on Fly Machines without Docker sentinel files", () => { + const previousFlyMachineId = process.env.FLY_MACHINE_ID; + const previousFlyAppName = process.env.FLY_APP_NAME; + const fs = require("node:fs"); + vi.spyOn(fs, "accessSync").mockImplementation(() => { + throw new Error("ENOENT"); + }); + vi.spyOn(fs, "readFileSync").mockReturnValue("10:cpuset:/\n9:perf_event:/\n8:memory:/\n0::/\n"); + + try { + process.env.FLY_MACHINE_ID = "3d8d5459a03038"; + process.env.FLY_APP_NAME = "openclaw-clawcks-test"; + expect(isContainerEnvironment()).toBe(true); + } finally { + if (previousFlyMachineId === undefined) { + delete process.env.FLY_MACHINE_ID; + } else { + process.env.FLY_MACHINE_ID = previousFlyMachineId; + } + if (previousFlyAppName === undefined) { + delete process.env.FLY_APP_NAME; + } else { + process.env.FLY_APP_NAME = previousFlyAppName; + } + } + }); + it("returns true when /proc/1/cgroup contains docker marker", () => { const fs = require("node:fs"); vi.spyOn(fs, "accessSync").mockImplementation(() => { diff --git a/src/infra/container-environment.ts b/src/infra/container-environment.ts index f209226993f..69cd8f9cafb 100644 --- a/src/infra/container-environment.ts +++ b/src/infra/container-environment.ts @@ -22,6 +22,10 @@ export function isContainerEnvironment(): boolean { } function detectContainerEnvironment(): boolean { + if (process.env.FLY_MACHINE_ID?.trim() && process.env.FLY_APP_NAME?.trim()) { + return true; + } + for (const sentinelPath of ["/.dockerenv", "/run/.containerenv", "/var/run/.containerenv"]) { try { fs.accessSync(sentinelPath, fs.constants.F_OK); From b94919ab1b6a587d870d8a20a08df1b079f4790e Mon Sep 17 00:00:00 2001 From: Lior Balmas Date: Sun, 10 May 2026 14:15:34 +0300 Subject: [PATCH 16/93] test(gateway): isolate Fly env container checks --- src/gateway/net.test.ts | 63 ++++++++++++++++++++++++++++------------- 1 file changed, 44 insertions(+), 19 deletions(-) diff --git a/src/gateway/net.test.ts b/src/gateway/net.test.ts index 13b2ca101d4..3b8a4a0f3b4 100644 --- a/src/gateway/net.test.ts +++ b/src/gateway/net.test.ts @@ -1,5 +1,5 @@ import os from "node:os"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { makeNetworkInterfacesSnapshot } from "../test-helpers/network-interfaces.js"; import { __resetContainerCacheForTest, @@ -18,6 +18,40 @@ import { resolveHostName, } from "./net.js"; +const flyMachineEnvKeys = ["FLY_MACHINE_ID", "FLY_APP_NAME"] as const; + +function clearFlyMachineEnvForTest(): () => void { + const previousEnv = new Map<(typeof flyMachineEnvKeys)[number], string | undefined>(); + for (const key of flyMachineEnvKeys) { + previousEnv.set(key, process.env[key]); + delete process.env[key]; + } + + return () => { + for (const key of flyMachineEnvKeys) { + const value = previousEnv.get(key); + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + }; +} + +function useClearedFlyMachineEnv() { + let restoreFlyMachineEnv: (() => void) | undefined; + + beforeEach(() => { + restoreFlyMachineEnv = clearFlyMachineEnvForTest(); + }); + + afterEach(() => { + restoreFlyMachineEnv?.(); + restoreFlyMachineEnv = undefined; + }); +} + describe("resolveHostName", () => { it.each([ { input: "localhost:18789", expected: "localhost" }, @@ -482,6 +516,8 @@ describe("isPrivateOrLoopbackHost", () => { }); describe("isContainerEnvironment", () => { + useClearedFlyMachineEnv(); + afterEach(() => { __resetContainerCacheForTest(); vi.restoreAllMocks(); @@ -516,30 +552,15 @@ describe("isContainerEnvironment", () => { }); it("returns true on Fly Machines without Docker sentinel files", () => { - const previousFlyMachineId = process.env.FLY_MACHINE_ID; - const previousFlyAppName = process.env.FLY_APP_NAME; const fs = require("node:fs"); vi.spyOn(fs, "accessSync").mockImplementation(() => { throw new Error("ENOENT"); }); vi.spyOn(fs, "readFileSync").mockReturnValue("10:cpuset:/\n9:perf_event:/\n8:memory:/\n0::/\n"); - try { - process.env.FLY_MACHINE_ID = "3d8d5459a03038"; - process.env.FLY_APP_NAME = "openclaw-clawcks-test"; - expect(isContainerEnvironment()).toBe(true); - } finally { - if (previousFlyMachineId === undefined) { - delete process.env.FLY_MACHINE_ID; - } else { - process.env.FLY_MACHINE_ID = previousFlyMachineId; - } - if (previousFlyAppName === undefined) { - delete process.env.FLY_APP_NAME; - } else { - process.env.FLY_APP_NAME = previousFlyAppName; - } - } + process.env.FLY_MACHINE_ID = "3d8d5459a03038"; + process.env.FLY_APP_NAME = "openclaw-test"; + expect(isContainerEnvironment()).toBe(true); }); it("returns true when /proc/1/cgroup contains docker marker", () => { @@ -613,6 +634,8 @@ describe("isContainerEnvironment", () => { }); describe("resolveGatewayBindHost", () => { + useClearedFlyMachineEnv(); + afterEach(() => { __resetContainerCacheForTest(); vi.restoreAllMocks(); @@ -652,6 +675,8 @@ describe("resolveGatewayBindHost", () => { }); describe("defaultGatewayBindMode", () => { + useClearedFlyMachineEnv(); + afterEach(() => { __resetContainerCacheForTest(); vi.restoreAllMocks(); From 2dcc05a9eb2c33a7906e718e766d968ad40b4ee4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 11 May 2026 13:01:18 +0100 Subject: [PATCH 17/93] docs: add Fly container detection changelog (#80209) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index da0dc66faf2..33bd8c26d09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Docs: https://docs.openclaw.ai ### Changes - CI: add a non-blocking `plugin-inspector-advisory` artifact to Plugin Prerelease so release runs capture bundled plugin compatibility triage without changing the blocking gate. +- Runtime/Fly: detect Fly Machines as container environments from their runtime env vars, so gateway bind and Bonjour defaults match remote container launches. (#80209) Thanks @liorb-mountapps. - Providers/fal: route GPT Image 2 and Nano Banana 2 reference-image edit requests to `/edit` with `image_urls` array, enforce NB2 edit geometry using `aspect_ratio` and `resolution` params, lift Fal edit mode input-image caps to 10 for GPT Image 2 and 14 for Nano Banana 2, and allow aspect-ratio hints in edit mode. (#77295) Thanks @leoge007. - Build: enable additional low-churn oxlint rules for promise, TypeScript, and runtime footgun checks. From 4bfdb6ef3001a3a0d3c38fb2f48e7e1eba48347c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 11 May 2026 13:07:10 +0100 Subject: [PATCH 18/93] test: tighten gateway health assertions --- src/gateway/gateway-cli-backend.connect.test.ts | 4 +--- ...r.silent-scope-upgrade-reconnect.poc.test.ts | 17 ++++++++--------- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/src/gateway/gateway-cli-backend.connect.test.ts b/src/gateway/gateway-cli-backend.connect.test.ts index 290edd28cda..adb7578569e 100644 --- a/src/gateway/gateway-cli-backend.connect.test.ts +++ b/src/gateway/gateway-cli-backend.connect.test.ts @@ -136,9 +136,7 @@ describe("gateway cli backend connect", () => { const health = await client.request("health", undefined, { timeoutMs: 1_000, }); - expect(health).toMatchObject({ - ok: true, - }); + expect(health.ok).toBe(true); expect(server.requests).toEqual(["connect", "health"]); } finally { await client?.stopAndWait({ timeoutMs: 1_000 }).catch(() => {}); diff --git a/src/gateway/server.silent-scope-upgrade-reconnect.poc.test.ts b/src/gateway/server.silent-scope-upgrade-reconnect.poc.test.ts index 1aa92d20424..a84a963b0e7 100644 --- a/src/gateway/server.silent-scope-upgrade-reconnect.poc.test.ts +++ b/src/gateway/server.silent-scope-upgrade-reconnect.poc.test.ts @@ -250,15 +250,14 @@ describe("gateway silent scope-upgrade reconnect", () => { }); try { - await expect( - callGateway({ - url: `ws://127.0.0.1:${started.port}`, - token: "secret", - method: "health", - scopes: ["operator.admin"], - timeoutMs: 2_000, - }), - ).resolves.toMatchObject({ ok: true }); + const health = await callGateway({ + url: `ws://127.0.0.1:${started.port}`, + token: "secret", + method: "health", + scopes: ["operator.admin"], + timeoutMs: 2_000, + }); + expect(health.ok).toBe(true); const paired = await getPairedDevice(identity.deviceId); expect(paired?.approvedScopes).toEqual(["operator.read"]); From f7d68cf43562a1d73ff21626d58f45e5acad0569 Mon Sep 17 00:00:00 2001 From: Shakker Date: Mon, 11 May 2026 13:08:41 +0100 Subject: [PATCH 19/93] test: assert channel allowlist warnings --- src/plugin-sdk/channel-policy.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/plugin-sdk/channel-policy.test.ts b/src/plugin-sdk/channel-policy.test.ts index 64311e28bd0..682326d9786 100644 --- a/src/plugin-sdk/channel-policy.test.ts +++ b/src/plugin-sdk/channel-policy.test.ts @@ -85,12 +85,12 @@ describe("createDangerousNameMatchingMutableAllowlistWarningCollector", () => { }, } as never, }), - ).toEqual( - expect.arrayContaining([ - expect.stringContaining("mutable allowlist entry"), - expect.stringContaining("channels.irc.allowFrom: charlie"), - ]), - ); + ).toEqual([ + "- Found 1 mutable allowlist entry across irc while name matching is disabled by default.", + "- channels.irc.allowFrom: charlie", + "- Option A (break-glass): enable channels.irc.dangerouslyAllowNameMatching=true to keep name/email/nick matching.", + "- Option B (recommended): resolve names/emails/nicks to stable sender IDs and rewrite the allowlist entries.", + ]); }); it("skips scopes that explicitly allow dangerous name matching", () => { From 35827b7dbb620f8edb0ed665f1479224db47667b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 11 May 2026 13:09:01 +0100 Subject: [PATCH 20/93] test: tighten gateway discovery assertions --- src/gateway/server-discovery-runtime.test.ts | 22 +++++++++++--------- src/gateway/server.channels.test.ts | 4 +--- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/gateway/server-discovery-runtime.test.ts b/src/gateway/server-discovery-runtime.test.ts index 159a7e570be..937825875c0 100644 --- a/src/gateway/server-discovery-runtime.test.ts +++ b/src/gateway/server-discovery-runtime.test.ts @@ -1,11 +1,13 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { PluginGatewayDiscoveryServiceRegistration } from "../plugins/registry-types.js"; +type WriteWideAreaGatewayZone = typeof import("../infra/widearea-dns.js").writeWideAreaGatewayZone; + const mocks = vi.hoisted(() => ({ pickPrimaryTailnetIPv4: vi.fn(() => "100.64.0.10"), pickPrimaryTailnetIPv6: vi.fn(() => undefined as string | undefined), resolveWideAreaDiscoveryDomain: vi.fn(() => "openclaw.internal."), - writeWideAreaGatewayZone: vi.fn(async () => ({ + writeWideAreaGatewayZone: vi.fn(async () => ({ changed: true, zonePath: "/tmp/openclaw.internal.db", })), @@ -218,15 +220,15 @@ describe("startGatewayDiscovery", () => { expect(service.service.advertise).not.toHaveBeenCalled(); expect(mocks.resolveTailnetDnsHint).toHaveBeenCalledWith({ enabled: true }); - expect(mocks.writeWideAreaGatewayZone).toHaveBeenCalledWith( - expect.objectContaining({ - domain: "openclaw.internal.", - gatewayPort: 18789, - displayName: "Lab Mac (OpenClaw)", - tailnetIPv4: "100.64.0.10", - tailnetDns: "gateway.tailnet.example.ts.net", - }), - ); + const [zoneParams] = mocks.writeWideAreaGatewayZone.mock.calls.at(-1) ?? []; + if (zoneParams === undefined) { + throw new Error("Expected wide-area gateway zone to be written"); + } + expect(zoneParams.domain).toBe("openclaw.internal."); + expect(zoneParams.gatewayPort).toBe(18789); + expect(zoneParams.displayName).toBe("Lab Mac (OpenClaw)"); + expect(zoneParams.tailnetIPv4).toBe("100.64.0.10"); + expect(zoneParams.tailnetDns).toBe("gateway.tailnet.example.ts.net"); expect(logs.info).toHaveBeenCalledWith(expect.stringContaining("wide-area DNS-SD updated")); expect(result.bonjourStop).toBeNull(); }); diff --git a/src/gateway/server.channels.test.ts b/src/gateway/server.channels.test.ts index 7e39082fb5d..38e82095aa9 100644 --- a/src/gateway/server.channels.test.ts +++ b/src/gateway/server.channels.test.ts @@ -121,9 +121,7 @@ describe("gateway server channels", () => { expect(res.ok).toBe(true); const telegram = res.payload?.channels?.telegram; const signal = res.payload?.channels?.signal; - expect(res.payload?.channels?.whatsapp).toMatchObject({ - configured: expect.any(Boolean), - }); + expect(res.payload?.channels?.whatsapp?.configured).toBeTypeOf("boolean"); expect(telegram?.configured).toBe(false); expect(telegram?.tokenSource).toBe("none"); expect(telegram?.probe).toBeUndefined(); From 342ae551aed3208e59da8c0c2737aa706450ce63 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Mon, 11 May 2026 07:09:12 -0500 Subject: [PATCH 21/93] fix(logging): reduce active-only liveness noise Summary: - Reduce active-only diagnostic liveness noise by emitting transient event-loop max delay samples as info-level telemetry. - Keep warnings for queued or waiting work and for sustained high P99 loop delay. - Cover the active-only path in the diagnostic stability tests and changelog. Verification: - pnpm format:check src/logging/diagnostic-stability.ts src/logging/diagnostic.test.ts CHANGELOG.md - pnpm test src/logging/diagnostic.test.ts - pnpm check:changed - GitHub PR checks passed on head 25e674fe41bc255675f664c628afab6585782d90. --- CHANGELOG.md | 1 + src/logging/diagnostic-stability.ts | 12 +++++++++++- src/logging/diagnostic.test.ts | 2 +- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 33bd8c26d09..bc992cc3c7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -423,6 +423,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Gateway/diagnostics: keep active-only transient event-loop max-delay samples as info-level stability telemetry instead of warning-level liveness diagnostics. Thanks @BunsDev. - Google/Gemini: default new API-key onboarding to stable `google/gemini-2.5-flash` instead of the preview Pro route, reducing surprise daily quota exhaustion. Fixes #79670. Thanks @HugeBunny. - Amazon Bedrock: expose Claude thinking profiles through the lightweight provider policy surface so `/think:adaptive` validates before the Bedrock runtime plugin is loaded. Fixes #79754. Thanks @phoenixyy and @hclsys. - Codex/transcripts: mirror dynamic tool calls and outputs into Codex app-server transcripts so tool activity is visible alongside assistant text instead of being elided, with per-item output capped at 12,000 characters. (#79952) Thanks @scoootscooob. diff --git a/src/logging/diagnostic-stability.ts b/src/logging/diagnostic-stability.ts index 87df183d516..13778114946 100644 --- a/src/logging/diagnostic-stability.ts +++ b/src/logging/diagnostic-stability.ts @@ -7,6 +7,7 @@ import { const DEFAULT_DIAGNOSTIC_STABILITY_CAPACITY = 1000; const DEFAULT_DIAGNOSTIC_STABILITY_LIMIT = 50; export const MAX_DIAGNOSTIC_STABILITY_LIMIT = DEFAULT_DIAGNOSTIC_STABILITY_CAPACITY; +const LIVENESS_EVENT_LOOP_DELAY_WARN_MS = 1_000; const SAFE_REASON_CODE = /^[A-Za-z0-9_.:-]{1,120}$/u; @@ -170,6 +171,15 @@ function assignReasonCode( } } +function resolveDiagnosticLivenessRecordLevel( + event: Extract, +): "warning" | "info" { + const hasBlockingWork = event.waiting > 0 || event.queued > 0; + const hasSustainedEventLoopDelay = + (event.eventLoopDelayP99Ms ?? 0) >= LIVENESS_EVENT_LOOP_DELAY_WARN_MS; + return hasBlockingWork || (event.active > 0 && hasSustainedEventLoopDelay) ? "warning" : "info"; +} + function isRecord( record: DiagnosticStabilityEventRecord | undefined, ): record is DiagnosticStabilityEventRecord { @@ -317,7 +327,7 @@ function sanitizeDiagnosticEvent(event: DiagnosticEventPayload): DiagnosticStabi record.queued = event.queued; break; case "diagnostic.liveness.warning": - record.level = event.active > 0 || event.waiting > 0 || event.queued > 0 ? "warning" : "info"; + record.level = resolveDiagnosticLivenessRecordLevel(event); record.durationMs = event.intervalMs; record.count = event.reasons.length; assignReasonCode(record, event.reasons[0]); diff --git a/src/logging/diagnostic.test.ts b/src/logging/diagnostic.test.ts index 9e87a63443e..fec37c82e89 100644 --- a/src/logging/diagnostic.test.ts +++ b/src/logging/diagnostic.test.ts @@ -1116,7 +1116,7 @@ describe("stuck session diagnostics threshold", () => { getDiagnosticStabilitySnapshot({ limit: 10 }).events, { type: "diagnostic.liveness.warning", - level: "warning", + level: "info", active: 1, waiting: 0, queued: 0, From fbaac02823d9c362d7659c38393434cc62c0a5e3 Mon Sep 17 00:00:00 2001 From: Shakker Date: Mon, 11 May 2026 13:09:57 +0100 Subject: [PATCH 22/93] test: assert windows task restart spawn --- src/infra/windows-task-restart.test.ts | 38 ++++++++++++++++++++------ 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/src/infra/windows-task-restart.test.ts b/src/infra/windows-task-restart.test.ts index d14b4581c33..fe584660310 100644 --- a/src/infra/windows-task-restart.test.ts +++ b/src/infra/windows-task-restart.test.ts @@ -98,10 +98,12 @@ describe("relaunchGatewayScheduledTask", () => { expect(result.tried).toContain(`cmd.exe /d /s /c ${seenCommandArg}`); const spawnCall = spawnMock.mock.calls[0]; expect(spawnCall?.[0]).toBe("cmd.exe"); - expect(spawnCall?.[1]).toEqual(["/d", "/s", "/c", expect.any(String)]); - expect(spawnCall?.[2]?.detached).toBe(true); - expect(spawnCall?.[2]?.stdio).toBe("ignore"); - expect(spawnCall?.[2]?.windowsHide).toBe(true); + expect(spawnCall?.[1]).toStrictEqual(["/d", "/s", "/c", seenCommandArg]); + expect(spawnCall?.[2]).toStrictEqual({ + detached: true, + stdio: "ignore", + windowsHide: true, + }); expect(unref).toHaveBeenCalledOnce(); const scriptPath = [...createdScriptPaths][0]; @@ -179,11 +181,29 @@ describe("relaunchGatewayScheduledTask", () => { relaunchGatewayScheduledTask({ OPENCLAW_PROFILE: "work" }); - expect(spawnMock).toHaveBeenCalledWith( - "cmd.exe", - ["/d", "/s", "/c", expect.stringMatching(/^".*&.*"$/)], - expect.any(Object), - ); + expect(spawnMock).toHaveBeenCalledOnce(); + const spawnCall = spawnMock.mock.calls[0]; + if (!spawnCall) { + throw new Error("expected restart helper spawn call"); + } + const commandArgs = spawnCall[1]; + if (!Array.isArray(commandArgs)) { + throw new Error("expected cmd.exe argument array"); + } + const commandArg = commandArgs[3]; + if (typeof commandArg !== "string") { + throw new Error("expected quoted restart helper path"); + } + expect(spawnCall[0]).toBe("cmd.exe"); + expect(commandArgs).toStrictEqual(["/d", "/s", "/c", commandArg]); + expect(commandArg.startsWith('"')).toBe(true); + expect(commandArg.endsWith('"')).toBe(true); + expect(commandArg).toContain("&"); + expect(spawnCall[2]).toStrictEqual({ + detached: true, + stdio: "ignore", + windowsHide: true, + }); }); it("includes startup fallback", () => { From d932d89778691052f7c6a35a46166f5a4e06daaa Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 11 May 2026 13:10:12 +0100 Subject: [PATCH 23/93] test: tighten session store rpc assertions --- src/gateway/server.sessions.store-rpc.test.ts | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/src/gateway/server.sessions.store-rpc.test.ts b/src/gateway/server.sessions.store-rpc.test.ts index 233ddcc0ec4..50aa1daa116 100644 --- a/src/gateway/server.sessions.store-rpc.test.ts +++ b/src/gateway/server.sessions.store-rpc.test.ts @@ -75,17 +75,14 @@ test("lists and patches session store via sessions.* RPC", async () => { }); const { ws, hello } = await openClient(); - expect((hello as { features?: { methods?: string[] } }).features?.methods).toEqual( - expect.arrayContaining([ - "sessions.list", - "sessions.preview", - "sessions.cleanup", - "sessions.patch", - "sessions.reset", - "sessions.delete", - "sessions.compact", - ]), - ); + const methods = (hello as { features?: { methods?: string[] } }).features?.methods ?? []; + expect(methods).toContain("sessions.list"); + expect(methods).toContain("sessions.preview"); + expect(methods).toContain("sessions.cleanup"); + expect(methods).toContain("sessions.patch"); + expect(methods).toContain("sessions.reset"); + expect(methods).toContain("sessions.delete"); + expect(methods).toContain("sessions.compact"); const sessionsHandlers = await getSessionsHandlers(); const { getRuntimeConfig } = await getGatewayConfigModule(); const directContext = { From 945fcc10fdf1375b9a6afeec12aa1dfb72af7740 Mon Sep 17 00:00:00 2001 From: Shakker Date: Mon, 11 May 2026 13:10:50 +0100 Subject: [PATCH 24/93] test: assert runtime guard diagnostics --- src/infra/runtime-guard.test.ts | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/src/infra/runtime-guard.test.ts b/src/infra/runtime-guard.test.ts index c2c03afcd69..b2976db8a90 100644 --- a/src/infra/runtime-guard.test.ts +++ b/src/infra/runtime-guard.test.ts @@ -86,8 +86,17 @@ describe("runtime-guard", () => { pathEnv: "/usr/bin", }; expect(() => assertSupportedRuntime(runtime, details)).toThrow("exit"); - expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("requires Node")); - expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("Detected: node 20.0.0")); + expect(runtime.error).toHaveBeenCalledOnce(); + expect(runtime.error).toHaveBeenCalledWith( + [ + "openclaw requires Node >=22.16.0.", + "Detected: node 20.0.0 (exec: /usr/bin/node).", + "PATH searched: /usr/bin", + "Install Node: https://nodejs.org/en/download", + "Upgrade Node and re-run openclaw.", + ].join("\n"), + ); + expect(runtime.exit).toHaveBeenCalledWith(1); }); it("returns silently when runtime meets requirements", () => { @@ -122,8 +131,16 @@ describe("runtime-guard", () => { }; expect(() => assertSupportedRuntime(runtime, details)).toThrow("exit"); + expect(runtime.error).toHaveBeenCalledOnce(); expect(runtime.error).toHaveBeenCalledWith( - expect.stringContaining("Detected: unknown runtime (exec: unknown)."), + [ + "openclaw requires Node >=22.16.0.", + "Detected: unknown runtime (exec: unknown).", + "PATH searched: (not set)", + "Install Node: https://nodejs.org/en/download", + "Upgrade Node and re-run openclaw.", + ].join("\n"), ); + expect(runtime.exit).toHaveBeenCalledWith(1); }); }); From 0c50714a036c7a25196e0a4fcc2a922542569081 Mon Sep 17 00:00:00 2001 From: Shakker Date: Mon, 11 May 2026 13:11:45 +0100 Subject: [PATCH 25/93] test: assert approval bootstrap retry warning --- src/infra/approval-handler-bootstrap.test.ts | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/src/infra/approval-handler-bootstrap.test.ts b/src/infra/approval-handler-bootstrap.test.ts index 307b4a7bea0..0e250c6081c 100644 --- a/src/infra/approval-handler-bootstrap.test.ts +++ b/src/infra/approval-handler-bootstrap.test.ts @@ -259,17 +259,10 @@ describe("startChannelApprovalHandlerBootstrap", () => { expect(start).toHaveBeenCalledTimes(1); await flushTransitions(); - expect(logger.error).not.toHaveBeenCalledWith( - expect.stringContaining("failed to start native approval handler"), - ); + expect(logger.error).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalledOnce(); expect(logger.warn).toHaveBeenCalledWith( - expect.stringContaining("native approval handler deferred until gateway readiness recovers"), - ); - expect(logger.warn).toHaveBeenCalledWith( - expect.stringContaining("gateway readiness unavailable before approval handler start"), - ); - expect(logger.warn).not.toHaveBeenCalledWith( - expect.stringContaining("gateway event loop readiness timeout"), + "native approval handler deferred until gateway readiness recovers: gateway readiness unavailable before approval handler start", ); await vi.advanceTimersByTimeAsync(1_000); From c4ed66c58b8063af84da4debc0bc20cdbf7cb489 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 11 May 2026 13:11:56 +0100 Subject: [PATCH 26/93] test: tighten gateway misc assertions --- src/gateway/gateway-misc.test.ts | 20 +++++++++----------- src/gateway/server-reload-handlers.test.ts | 20 ++++++++++---------- 2 files changed, 19 insertions(+), 21 deletions(-) diff --git a/src/gateway/gateway-misc.test.ts b/src/gateway/gateway-misc.test.ts index 7e60fefaa49..f71e63c60a6 100644 --- a/src/gateway/gateway-misc.test.ts +++ b/src/gateway/gateway-misc.test.ts @@ -110,9 +110,10 @@ describe("GatewayClient", () => { const client = new GatewayClient({ url: "ws://127.0.0.1:1" }); client.start(); const last = wsMockState.last as { url: unknown; opts: unknown } | null; + const opts = last?.opts as { maxPayload?: number } | undefined; expect(last?.url).toBe("ws://127.0.0.1:1"); - expect(last?.opts).toEqual(expect.objectContaining({ maxPayload: 25 * 1024 * 1024 })); + expect(opts?.maxPayload).toBe(25 * 1024 * 1024); }); test("does not pass an explicit direct agent for loopback control-plane WebSocket connections", () => { @@ -621,16 +622,13 @@ describe("gateway broadcaster", () => { broadcast("chat", { sessionKey: "agent:main:main", message: "secret" }, { dropIfSlow: true }); broadcast("heartbeat", { ts: 1 }); - expect(events).toContainEqual( - expect.objectContaining({ - type: "payload.large", - surface: "gateway.ws.outbound_buffer", - action: "rejected", - bytes: MAX_BUFFERED_BYTES + 1, - limitBytes: MAX_BUFFERED_BYTES, - reason: "ws_send_buffer_drop", - }), - ); + const payloadEvent = events.find((event) => event.type === "payload.large"); + expect(payloadEvent?.type).toBe("payload.large"); + expect(payloadEvent?.surface).toBe("gateway.ws.outbound_buffer"); + expect(payloadEvent?.action).toBe("rejected"); + expect(payloadEvent?.bytes).toBe(MAX_BUFFERED_BYTES + 1); + expect(payloadEvent?.limitBytes).toBe(MAX_BUFFERED_BYTES); + expect(payloadEvent?.reason).toBe("ws_send_buffer_drop"); expect( events.reduce((count, event) => count + (event.type === "payload.large" ? 1 : 0), 0), ).toBe(1); diff --git a/src/gateway/server-reload-handlers.test.ts b/src/gateway/server-reload-handlers.test.ts index f5ea25d6ce3..f1546f498ba 100644 --- a/src/gateway/server-reload-handlers.test.ts +++ b/src/gateway/server-reload-handlers.test.ts @@ -233,16 +233,16 @@ describe("gateway plugin hot reload handlers", () => { } } - expect(reloadPlugins).toHaveBeenCalledWith( - expect.objectContaining({ - nextConfig: { - plugins: { - enabled: false, - }, - }, - changedPaths: ["plugins.enabled"], - }), - ); + const [reloadParams] = reloadPlugins.mock.calls.at(-1) ?? []; + const reloadParamsRecord = reloadParams as + | { nextConfig?: unknown; changedPaths?: unknown } + | undefined; + expect(reloadParamsRecord?.nextConfig).toEqual({ + plugins: { + enabled: false, + }, + }); + expect(reloadParamsRecord?.changedPaths).toEqual(["plugins.enabled"]); expect(stopChannel).toHaveBeenCalledWith("discord"); expect(startChannel).not.toHaveBeenCalled(); expect(events).toEqual(["reload:start", "stop", "registry:replace"]); From 9c37951435282d77b366749c67419ea9f154c24b Mon Sep 17 00:00:00 2001 From: Shakker Date: Mon, 11 May 2026 13:13:12 +0100 Subject: [PATCH 27/93] test: assert service audit issues --- src/daemon/service-audit.test.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/daemon/service-audit.test.ts b/src/daemon/service-audit.test.ts index a1586bdf360..636dc3ffe21 100644 --- a/src/daemon/service-audit.test.ts +++ b/src/daemon/service-audit.test.ts @@ -283,7 +283,8 @@ describe("auditGatewayServiceConfig", () => { const issue = audit.issues.find( (entry) => entry.code === SERVICE_AUDIT_CODES.gatewayPortMismatch, ); - expect(issue).toMatchObject({ + expect(issue).toStrictEqual({ + code: SERVICE_AUDIT_CODES.gatewayPortMismatch, message: "Gateway service port does not match current gateway config.", detail: "18789 -> 18888", level: "recommended", @@ -497,9 +498,12 @@ describe("checkTokenDrift", () => { it("detects drift when config has token but service has different token", () => { const result = checkTokenDrift({ serviceToken: "old-token", configToken: "new-token" }); - expect(result).toMatchObject({ + expect(result).toStrictEqual({ code: SERVICE_AUDIT_CODES.gatewayTokenDrift, - message: expect.stringContaining("differs from service token"), + message: + "Config token differs from service token. The daemon will use the old token after restart.", + detail: "Run `openclaw gateway install --force` to sync the token.", + level: "recommended", }); }); From db9549c46f82c131cc8c36f944a07e17246f19ce Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 11 May 2026 13:13:19 +0100 Subject: [PATCH 28/93] test: tighten gateway role assertions --- src/gateway/server.roles-allowlist-update.test.ts | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/src/gateway/server.roles-allowlist-update.test.ts b/src/gateway/server.roles-allowlist-update.test.ts index 44828551fd4..b541b8d76c1 100644 --- a/src/gateway/server.roles-allowlist-update.test.ts +++ b/src/gateway/server.roles-allowlist-update.test.ts @@ -210,14 +210,9 @@ async function expectPendingPairingCommands(nodeId: string, commands: string[]) pending?: Array<{ nodeId?: string; commands?: string[] }>; }>(ws, "node.pair.list", {}); expect(pairingList.ok).toBe(true); - expect(pairingList.payload?.pending ?? []).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - nodeId, - commands, - }), - ]), - ); + const pending = (pairingList.payload?.pending ?? []).find((entry) => entry.nodeId === nodeId); + expect(pending?.nodeId).toBe(nodeId); + expect(pending?.commands).toEqual(commands); } describe("gateway role enforcement", () => { @@ -250,7 +245,7 @@ describe("gateway role enforcement", () => { await expect(nodeClient.request("status", {})).rejects.toThrow("unauthorized role"); const healthPayload = await nodeClient.request("health", {}); - expect(healthPayload).toMatchObject({ ok: true }); + expect(healthPayload.ok).toBe(true); } finally { nodeClient?.stop(); } From 75b745d55935334ef6441dbec7dc660da6711f98 Mon Sep 17 00:00:00 2001 From: Shakker Date: Mon, 11 May 2026 13:14:26 +0100 Subject: [PATCH 29/93] test: assert scheduled task command parsing --- src/daemon/schtasks.install.test.ts | 40 +++++++++++++++++++---------- 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/src/daemon/schtasks.install.test.ts b/src/daemon/schtasks.install.test.ts index 7cef5c81095..4b116bf6a49 100644 --- a/src/daemon/schtasks.install.test.ts +++ b/src/daemon/schtasks.install.test.ts @@ -103,7 +103,7 @@ describe("installScheduledTask", () => { expect(script).not.toContain("set OC_INJECT="); const parsed = await readScheduledTaskCommand(env); - expect(parsed).toMatchObject({ + expect(parsed).toStrictEqual({ programArguments: [ "node", "gateway.js", @@ -115,15 +115,22 @@ describe("installScheduledTask", () => { "!token!", ], workingDirectory: "C:\\temp\\poc&calc", + environment: { + OC_INJECT: "safe & whoami | calc", + OC_CARET: "a^b", + OC_PERCENT: "%TEMP%", + OC_BANG: "!token!", + OC_QUOTE: 'he said "hi"', + }, + environmentValueSources: { + OC_INJECT: "inline", + OC_CARET: "inline", + OC_PERCENT: "inline", + OC_BANG: "inline", + OC_QUOTE: "inline", + }, + sourcePath: scriptPath, }); - expect(parsed?.environment).toMatchObject({ - OC_INJECT: "safe & whoami | calc", - OC_CARET: "a^b", - OC_PERCENT: "%TEMP%", - OC_BANG: "!token!", - OC_QUOTE: 'he said "hi"', - }); - expect(parsed?.environment).not.toHaveProperty("OC_EMPTY"); expect(schtasksCalls[0]).toEqual(["/Query"]); expect(schtasksCalls[1]).toEqual(["/Query", "/TN", "OpenClaw Gateway"]); @@ -258,11 +265,18 @@ describe("installScheduledTask", () => { }); const command = await readScheduledTaskCommand(env); - expect(command?.environmentValueSources).toMatchObject({ - OPENCLAW_SERVICE_MANAGED_ENV_KEYS: "inline", - TAVILY_API_KEY: "inline", + expect(command).toStrictEqual({ + programArguments: ["node", "gateway.js"], + environment: { + OPENCLAW_SERVICE_MANAGED_ENV_KEYS: "TAVILY_API_KEY", + TAVILY_API_KEY: "old-inline-value", + }, + environmentValueSources: { + OPENCLAW_SERVICE_MANAGED_ENV_KEYS: "inline", + TAVILY_API_KEY: "inline", + }, + sourcePath: scriptPath, }); - expect(command?.sourcePath).toBe(scriptPath); const audit = await auditGatewayServiceConfig({ env, From a57691cdcbaa3a95faecf45878bd325219c4838e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 11 May 2026 13:15:40 +0100 Subject: [PATCH 30/93] test: tighten gateway websocket assertions --- .../server/ws-connection.startup.test.ts | 39 ++++++++++++------- src/gateway/server/ws-connection.test.ts | 2 +- 2 files changed, 27 insertions(+), 14 deletions(-) diff --git a/src/gateway/server/ws-connection.startup.test.ts b/src/gateway/server/ws-connection.startup.test.ts index 5d0d061a144..9bd161fc55d 100644 --- a/src/gateway/server/ws-connection.startup.test.ts +++ b/src/gateway/server/ws-connection.startup.test.ts @@ -107,19 +107,32 @@ describe("attachGatewayWsConnectionHandler startup readiness", () => { ).toBe(true); }); - expect(sent).toContainEqual( - expect.objectContaining({ - type: "res", - id: "connect-1", - ok: false, - error: expect.objectContaining({ - code: "UNAVAILABLE", - retryable: true, - retryAfterMs: 500, - details: { reason: GATEWAY_STARTUP_UNAVAILABLE_REASON }, - }), - }), - ); + const response = sent.find( + (frame) => + typeof frame === "object" && + frame !== null && + (frame as { type?: unknown; id?: unknown }).type === "res" && + (frame as { id?: unknown }).id === "connect-1", + ) as + | { + type?: unknown; + id?: unknown; + ok?: unknown; + error?: { + code?: unknown; + retryable?: unknown; + retryAfterMs?: unknown; + details?: unknown; + }; + } + | undefined; + expect(response?.type).toBe("res"); + expect(response?.id).toBe("connect-1"); + expect(response?.ok).toBe(false); + expect(response?.error?.code).toBe("UNAVAILABLE"); + expect(response?.error?.retryable).toBe(true); + expect(response?.error?.retryAfterMs).toBe(500); + expect(response?.error?.details).toEqual({ reason: GATEWAY_STARTUP_UNAVAILABLE_REASON }); await vi.waitFor(() => { expect(socket.close).toHaveBeenCalledWith(1013, "gateway starting"); }); diff --git a/src/gateway/server/ws-connection.test.ts b/src/gateway/server/ws-connection.test.ts index fc3f7af3781..f511680648b 100644 --- a/src/gateway/server/ws-connection.test.ts +++ b/src/gateway/server/ws-connection.test.ts @@ -158,7 +158,7 @@ describe("attachGatewayWsConnectionHandler", () => { currentAuth = createResolvedAuth("token-after"); - expect(handlerParams.getResolvedAuth()).toMatchObject({ token: "token-after" }); + expect(handlerParams.getResolvedAuth().token).toBe("token-after"); expect(handlerParams.getRequiredSharedGatewaySessionGeneration?.()).toBe( resolveSharedGatewaySessionGeneration(currentAuth), ); From ccdaf1875afeca9a3d79ddb6816f2cee8e2fe8db Mon Sep 17 00:00:00 2001 From: brokemac79 Date: Mon, 11 May 2026 13:15:57 +0100 Subject: [PATCH 31/93] fix(doctor): tolerate malformed crontab output (#78112) Fixes #77773. Co-authored-by: brokemac79 --- CHANGELOG.md | 1 + src/commands/doctor-cron.test.ts | 29 +++++++++++++++++++++++++++++ src/commands/doctor-cron.ts | 21 +++++++++++++++++---- src/terminal/note.ts | 23 ++++++++++++++++++++--- src/terminal/table.test.ts | 12 ++++++++++-- 5 files changed, 77 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bc992cc3c7b..65e7a44a74d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -588,6 +588,7 @@ Docs: https://docs.openclaw.ai - Network/runtime: avoid importing Undici's package dispatcher during no-proxy timeout bootstrap so external channel plugin fetch requests with explicit Content-Length keep working. Fixes #78007. Thanks @shakkernerd. - Status/doctor: treat a single healthy OpenClaw Gateway listener on loopback, LAN, or wildcard bind as the expected configured gateway instead of warning that the port is already in use. Fixes #77939. Thanks @GitHoubi and @brokemac79. - Agents/TTS: send media-bearing block replies directly when block streaming is off, so agent `tts` tool audio attached to a final text reply is delivered instead of being consumed before final Telegram/media delivery. Thanks @Conan-Scott. +- Doctor: avoid crashing on partial Linux environments when the legacy crontab probe or terminal note wrapper receives missing or non-string output. Fixes #77773. Thanks @brokemac79 and @blackflame7983. - Gateway/performance: reuse the current compatible plugin metadata snapshot across hot read-only status, channel, auth, skills, and embedded agent settings paths, avoiding repeated synchronous plugin metadata scans during Gateway activity. Fixes #77983. Thanks @shakkernerd. - Tasks/maintenance: prune stale cron run session registry entries while preserving running cron jobs and non-cron sessions. Fixes #73867. Thanks @brokemac79. - Plugins: dispatch cached descriptor-backed tools by the resolved runtime tool name for unnamed factories, fixing multi-tool plugins whose shared manifest contracts exposed sibling tools but failed at execution. Fixes #78671. Thanks @zanni098. diff --git a/src/commands/doctor-cron.test.ts b/src/commands/doctor-cron.test.ts index 171372e4495..a96c9e8722a 100644 --- a/src/commands/doctor-cron.test.ts +++ b/src/commands/doctor-cron.test.ts @@ -422,4 +422,33 @@ describe("noteLegacyWhatsAppCrontabHealthCheck", () => { expect(noteMock).not.toHaveBeenCalled(); }); + + it("ignores malformed crontab output instead of crashing", async () => { + await expect( + noteLegacyWhatsAppCrontabHealthCheck({ + platform: "linux", + readCrontab: async () => ({ + stdout: undefined, + }), + }), + ).resolves.toBeUndefined(); + await expect( + noteLegacyWhatsAppCrontabHealthCheck({ + platform: "linux", + readCrontab: async () => ({ + stdout: 12345, + }), + }), + ).resolves.toBeUndefined(); + await expect( + noteLegacyWhatsAppCrontabHealthCheck({ + platform: "linux", + readCrontab: async () => ({ + stdout: { lines: ["*/5 * * * * ~/.openclaw/bin/ensure-whatsapp.sh"] }, + }), + }), + ).resolves.toBeUndefined(); + + expect(noteMock).not.toHaveBeenCalled(); + }); }); diff --git a/src/commands/doctor-cron.ts b/src/commands/doctor-cron.ts index 0cda4155abf..a8258168b1e 100644 --- a/src/commands/doctor-cron.ts +++ b/src/commands/doctor-cron.ts @@ -22,7 +22,7 @@ type CronDoctorOutcome = { warnings: string[]; }; -type CrontabReader = () => Promise<{ stdout: string; stderr?: string }>; +type CrontabReader = () => Promise<{ stdout?: unknown; stderr?: unknown }>; const execFileAsync = promisify(execFile); const LEGACY_WHATSAPP_HEALTH_SCRIPT_RE = @@ -153,8 +153,21 @@ async function readUserCrontab(): Promise<{ stdout: string; stderr?: string }> { }; } -function findLegacyWhatsAppHealthCrontabLines(crontab: string): string[] { - return crontab +function coerceCrontabText(crontab: unknown): string { + if (typeof crontab === "string") { + return crontab; + } + if (crontab == null) { + return ""; + } + if (typeof crontab === "number" || typeof crontab === "boolean" || typeof crontab === "bigint") { + return String(crontab); + } + return ""; +} + +function findLegacyWhatsAppHealthCrontabLines(crontab: unknown): string[] { + return coerceCrontabText(crontab) .split(/\r?\n/u) .map((line) => line.trim()) .filter((line) => line.length > 0 && !line.startsWith("#")) @@ -171,7 +184,7 @@ export async function noteLegacyWhatsAppCrontabHealthCheck( return; } - let crontab: string; + let crontab: unknown; try { crontab = (await (params.readCrontab ?? readUserCrontab)()).stdout; } catch { diff --git a/src/terminal/note.ts b/src/terminal/note.ts index 81d38fde18c..bb64ad3efb5 100644 --- a/src/terminal/note.ts +++ b/src/terminal/note.ts @@ -147,13 +147,30 @@ function wrapLine(line: string, maxWidth: number): string[] { return lines; } +function coerceNoteMessage(message: unknown): string { + if (typeof message === "string") { + return message; + } + if (message == null) { + return ""; + } + if (typeof message === "number" || typeof message === "boolean" || typeof message === "bigint") { + return String(message); + } + if (message instanceof Error) { + return message.message ? `${message.name}: ${message.message}` : message.name; + } + return ""; +} + export function wrapNoteMessage( - message: string, + message: unknown, options: { maxWidth?: number; columns?: number } = {}, ): string { + const text = coerceNoteMessage(message); const columns = options.columns ?? resolveNoteColumns(process.stdout.columns); const maxWidth = options.maxWidth ?? Math.max(40, Math.min(88, columns - 10)); - return message + return text .split("\n") .flatMap((line) => wrapLine(line, maxWidth)) .join("\n"); @@ -179,7 +196,7 @@ function createNoteOutput(columns: number): NodeJS.WriteStream { return output; } -export function note(message: string, title?: string) { +export function note(message: unknown, title?: string) { if (isSuppressedByEnv(process.env.OPENCLAW_SUPPRESS_NOTES)) { return; } diff --git a/src/terminal/table.test.ts b/src/terminal/table.test.ts index c61082a661c..dd28b82043b 100644 --- a/src/terminal/table.test.ts +++ b/src/terminal/table.test.ts @@ -24,7 +24,7 @@ describe("renderTable", () => { }); expect(out).toContain("Dashboard"); - expect(out).toMatch(/│ Dashboard\s+│/); + expect(out).toMatch(/[│|] Dashboard\s+[│|]/); }); it("expands flex columns to fill available width", () => { @@ -86,7 +86,7 @@ describe("renderTable", () => { const lines = out.split("\n").filter((line) => line.includes("a")); for (const line of lines) { const resetIndex = line.lastIndexOf(reset); - const lastSep = line.lastIndexOf("│"); + const lastSep = Math.max(line.lastIndexOf("│"), line.lastIndexOf("|")); expect(resetIndex).toBeGreaterThan(-1); expect(lastSep).toBeGreaterThan(resetIndex); } @@ -279,4 +279,12 @@ describe("wrapNoteMessage", () => { expect(resolveNoteColumns(79)).toBe(80); expect(resolveNoteColumns(120)).toBe(120); }); + + it("coerces nullish and non-string note messages before wrapping", () => { + expect(wrapNoteMessage(undefined, { maxWidth: 20, columns: 80 })).toBe(""); + expect(wrapNoteMessage(null, { maxWidth: 20, columns: 80 })).toBe(""); + expect(wrapNoteMessage(12345, { maxWidth: 20, columns: 80 })).toBe("12345"); + expect(wrapNoteMessage(new Error("boom"), { maxWidth: 20, columns: 80 })).toBe("Error: boom"); + expect(wrapNoteMessage({ message: "boom" }, { maxWidth: 20, columns: 80 })).toBe(""); + }); }); From 7c7d19ec8481cfb48dc1b3dd55487512b1de068d Mon Sep 17 00:00:00 2001 From: brokemac79 Date: Wed, 29 Apr 2026 05:31:34 +0100 Subject: [PATCH 32/93] fix(providers): use llama.cpp runtime context cap --- .../provider-self-hosted-setup.test.ts | 114 ++++++++++++++++-- src/plugins/provider-self-hosted-setup.ts | 103 +++++++++++++--- 2 files changed, 194 insertions(+), 23 deletions(-) diff --git a/src/plugins/provider-self-hosted-setup.test.ts b/src/plugins/provider-self-hosted-setup.test.ts index 94323d7cd40..8e391d53561 100644 --- a/src/plugins/provider-self-hosted-setup.test.ts +++ b/src/plugins/provider-self-hosted-setup.test.ts @@ -88,6 +88,7 @@ async function configureSelfHostedTestProvider(params: { describe("discoverOpenAICompatibleLocalModels", () => { it("uses guarded fetch pinned to the configured self-hosted provider", async () => { const release = vi.fn(async () => undefined); + const propsRelease = vi.fn(async () => undefined); fetchWithSsrFGuardMock.mockResolvedValueOnce({ response: new Response(JSON.stringify({ data: [{ id: "Qwen/Qwen3-32B" }] }), { status: 200, @@ -95,6 +96,11 @@ describe("discoverOpenAICompatibleLocalModels", () => { finalUrl: "http://127.0.0.1:8000/v1/models", release, }); + fetchWithSsrFGuardMock.mockResolvedValueOnce({ + response: new Response("{}", { status: 404 }), + finalUrl: "http://127.0.0.1:8000/props", + release: propsRelease, + }); const models = await discoverOpenAICompatibleLocalModels({ baseUrl: "http://127.0.0.1:8000/v1/", @@ -114,15 +120,107 @@ describe("discoverOpenAICompatibleLocalModels", () => { maxTokens: 8192, }, ]); - expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith({ - url: "http://127.0.0.1:8000/v1/models", - init: { headers: { Authorization: "Bearer self-hosted-test-key" } }, - policy: { - hostnameAllowlist: ["127.0.0.1"], - allowPrivateNetwork: true, - }, - timeoutMs: 5000, + expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith( + expect.objectContaining({ + url: "http://127.0.0.1:8000/v1/models", + init: { headers: { Authorization: "Bearer self-hosted-test-key" } }, + policy: { + hostnameAllowlist: ["127.0.0.1"], + allowPrivateNetwork: true, + }, + timeoutMs: 5000, + }), + ); + expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith( + expect.objectContaining({ + url: "http://127.0.0.1:8000/props", + init: { headers: { Authorization: "Bearer self-hosted-test-key" } }, + policy: { + hostnameAllowlist: ["127.0.0.1"], + allowPrivateNetwork: true, + }, + timeoutMs: 2500, + }), + ); + expect(release).toHaveBeenCalledOnce(); + expect(propsRelease).toHaveBeenCalledOnce(); + }); + + it("uses llama.cpp /props n_ctx as the runtime context cap", async () => { + const modelsRelease = vi.fn(async () => undefined); + const propsRelease = vi.fn(async () => undefined); + fetchWithSsrFGuardMock.mockResolvedValueOnce({ + response: new Response( + JSON.stringify({ + data: [ + { + id: "qwen3.6-mxfp4-moe", + meta: { n_ctx_train: 262_144 }, + }, + ], + }), + { status: 200 }, + ), + finalUrl: "http://127.0.0.1:8080/v1/models", + release: modelsRelease, }); + fetchWithSsrFGuardMock.mockResolvedValueOnce({ + response: new Response(JSON.stringify({ n_ctx: 65_536 }), { status: 200 }), + finalUrl: "http://127.0.0.1:8080/props", + release: propsRelease, + }); + + const models = await discoverOpenAICompatibleLocalModels({ + baseUrl: "http://127.0.0.1:8080/v1", + label: "llama.cpp", + env: {}, + }); + + expect(models).toEqual([ + expect.objectContaining({ + id: "qwen3.6-mxfp4-moe", + contextWindow: 262_144, + contextTokens: 65_536, + }), + ]); + expect(fetchWithSsrFGuardMock).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + url: "http://127.0.0.1:8080/props", + }), + ); + expect(modelsRelease).toHaveBeenCalledOnce(); + expect(propsRelease).toHaveBeenCalledOnce(); + }); + + it("preserves explicit configured context windows ahead of llama.cpp /props", async () => { + const release = vi.fn(async () => undefined); + fetchWithSsrFGuardMock.mockResolvedValueOnce({ + response: new Response( + JSON.stringify({ + data: [{ id: "qwen3.6-mxfp4-moe", meta: { n_ctx_train: 262_144 } }], + }), + { status: 200 }, + ), + finalUrl: "http://127.0.0.1:8080/v1/models", + release, + }); + + const models = await discoverOpenAICompatibleLocalModels({ + baseUrl: "http://127.0.0.1:8080/v1", + label: "llama.cpp", + contextWindow: 65_536, + env: {}, + }); + + expect(models).toEqual([ + expect.objectContaining({ + id: "qwen3.6-mxfp4-moe", + contextWindow: 65_536, + }), + ]); + expect(models[0]).not.toHaveProperty("contextTokens"); + expect(fetchWithSsrFGuardMock).toHaveBeenCalledTimes(1); expect(release).toHaveBeenCalledOnce(); }); diff --git a/src/plugins/provider-self-hosted-setup.ts b/src/plugins/provider-self-hosted-setup.ts index b79bbdf4722..f347166afa1 100644 --- a/src/plugins/provider-self-hosted-setup.ts +++ b/src/plugins/provider-self-hosted-setup.ts @@ -35,9 +35,16 @@ const log = createSubsystemLogger("plugins/self-hosted-provider-setup"); type OpenAICompatModelsResponse = { data?: Array<{ id?: string; + meta?: { + n_ctx_train?: unknown; + }; }>; }; +type LlamaCppPropsResponse = { + n_ctx?: unknown; +}; + function isReasoningModelHeuristic(modelId: string): boolean { return /r1|reasoning|think|reason/i.test(modelId); } @@ -62,6 +69,57 @@ function buildSelfHostedBaseUrlSsrFPolicy(baseUrl: string): SsrFPolicy | undefin } } +function readPositiveInteger(value: unknown): number | undefined { + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) { + return undefined; + } + return Math.trunc(value); +} + +function resolveLlamaCppPropsUrl(baseUrl: string): string { + const parsed = new URL(baseUrl); + const pathname = parsed.pathname.replace(/\/+$/, ""); + parsed.pathname = pathname.endsWith("/v1") ? pathname.slice(0, -3) || "/" : pathname; + parsed.search = ""; + parsed.hash = ""; + const root = parsed.toString().replace(/\/+$/, ""); + return `${root}/props`; +} + +async function discoverLlamaCppRuntimeContextTokens(params: { + baseUrl: string; + apiKey?: string; +}): Promise { + let url: string; + try { + url = resolveLlamaCppPropsUrl(params.baseUrl); + } catch { + return undefined; + } + try { + const trimmedApiKey = normalizeOptionalString(params.apiKey); + const { response, release } = await fetchWithSsrFGuard({ + url, + init: { + headers: trimmedApiKey ? { Authorization: `Bearer ${trimmedApiKey}` } : undefined, + }, + policy: buildSelfHostedBaseUrlSsrFPolicy(params.baseUrl), + timeoutMs: 2500, + }); + try { + if (!response.ok) { + return undefined; + } + const data = (await response.json()) as LlamaCppPropsResponse; + return readPositiveInteger(data.n_ctx); + } finally { + await release(); + } + } catch { + return undefined; + } +} + export async function discoverOpenAICompatibleLocalModels(params: { baseUrl: string; apiKey?: string; @@ -100,21 +158,36 @@ export async function discoverOpenAICompatibleLocalModels(params: { return []; } - return models - .map((model) => ({ id: normalizeOptionalString(model.id) ?? "" })) - .filter((model) => Boolean(model.id)) - .map((model) => { - const modelId = model.id; - return { - id: modelId, - name: modelId, - reasoning: isReasoningModelHeuristic(modelId), - input: ["text"], - cost: SELF_HOSTED_DEFAULT_COST, - contextWindow: params.contextWindow ?? SELF_HOSTED_DEFAULT_CONTEXT_WINDOW, - maxTokens: params.maxTokens ?? SELF_HOSTED_DEFAULT_MAX_TOKENS, - } satisfies ModelDefinitionConfig; - }); + const runtimeContextTokens = + params.contextWindow === undefined + ? await discoverLlamaCppRuntimeContextTokens({ + baseUrl: trimmedBaseUrl, + apiKey: params.apiKey, + }) + : undefined; + + return models.flatMap((model) => { + const modelId = normalizeOptionalString(model.id); + if (!modelId) { + return []; + } + const modelConfig: ModelDefinitionConfig = { + id: modelId, + name: modelId, + reasoning: isReasoningModelHeuristic(modelId), + input: ["text"], + cost: SELF_HOSTED_DEFAULT_COST, + contextWindow: + params.contextWindow ?? + readPositiveInteger(model.meta?.n_ctx_train) ?? + SELF_HOSTED_DEFAULT_CONTEXT_WINDOW, + maxTokens: params.maxTokens ?? SELF_HOSTED_DEFAULT_MAX_TOKENS, + }; + if (runtimeContextTokens) { + modelConfig.contextTokens = runtimeContextTokens; + } + return [modelConfig]; + }); } finally { await release(); } From f4be39c4f4528f097bda7baafbbfdb56e4e73fbf Mon Sep 17 00:00:00 2001 From: brokemac79 Date: Tue, 5 May 2026 23:23:23 +0100 Subject: [PATCH 33/93] fix(providers): read nested llama cpp props context --- CHANGELOG.md | 1 + .../provider-self-hosted-setup.test.ts | 130 +++++++++++++++++- src/plugins/provider-self-hosted-setup.ts | 68 ++++++--- 3 files changed, 173 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 65e7a44a74d..009a77c5fc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -597,6 +597,7 @@ Docs: https://docs.openclaw.ai - Sessions cleanup: add `openclaw sessions cleanup --fix-dm-scope` so operators who return `session.dmScope` to `main` can dry-run and retire stale direct-DM session rows while preserving transcripts as deleted archives. Fixes #47561 and #45554. Thanks @BunsDev. - Doctor/Codex: repair legacy `openai-codex/*` routes and cron payload model refs to canonical `openai/*`, keep OpenAI agent turns on Codex by default, ignore stale whole-agent/session runtime pins, preserve explicit provider/model runtime policy, and migrate legacy runtime model refs to model-scoped runtime entries. Thanks @vincentkoc. +- Providers/self-hosted: read model-scoped llama.cpp runtime context from `/props.default_generation_settings.n_ctx` while keeping top-level `n_ctx` as a fallback, so session budgeting reflects the loaded context window. Fixes #73664. Thanks @brokemac79. - Video generation: wait up to 20 minutes for slow fal/MiniMax queue-backed jobs, stop forwarding unsupported Google Veo generated-audio options, and normalize MiniMax `720P` requests to its supported `768P` resolution with the usual override warning/details instead of failing fallback. - Channels/durable delivery: preserve channel-specific final reply semantics when using durable sends, including Telegram selected quotes and silent error replies plus WhatsApp message-sending cancellations. - Channels/message lifecycle: build legacy channel delivery results from message receipts and add receipts to BlueBubbles, Feishu, Google Chat, iMessage, IRC, LINE, Nextcloud Talk, QQ Bot, Signal, Synology Chat, Tlon, Twitch, WhatsApp, Zalo, and Zalo Personal send results and owner-path reply delivery plus Discord, Matrix, Mattermost, Slack, and Teams send results while preserving existing message id compatibility. diff --git a/src/plugins/provider-self-hosted-setup.test.ts b/src/plugins/provider-self-hosted-setup.test.ts index 8e391d53561..c81b676d983 100644 --- a/src/plugins/provider-self-hosted-setup.test.ts +++ b/src/plugins/provider-self-hosted-setup.test.ts @@ -146,7 +146,129 @@ describe("discoverOpenAICompatibleLocalModels", () => { expect(propsRelease).toHaveBeenCalledOnce(); }); - it("uses llama.cpp /props n_ctx as the runtime context cap", async () => { + it("uses llama.cpp nested /props n_ctx as the runtime context cap", async () => { + const modelsRelease = vi.fn(async () => undefined); + const propsRelease = vi.fn(async () => undefined); + fetchWithSsrFGuardMock.mockResolvedValueOnce({ + response: new Response( + JSON.stringify({ + data: [ + { + id: "qwen3.6-mxfp4-moe", + meta: { n_ctx_train: 262_144 }, + }, + ], + }), + { status: 200 }, + ), + finalUrl: "http://127.0.0.1:8080/v1/models", + release: modelsRelease, + }); + fetchWithSsrFGuardMock.mockResolvedValueOnce({ + response: new Response(JSON.stringify({ default_generation_settings: { n_ctx: 65_536 } }), { + status: 200, + }), + finalUrl: "http://127.0.0.1:8080/props", + release: propsRelease, + }); + + const models = await discoverOpenAICompatibleLocalModels({ + baseUrl: "http://127.0.0.1:8080/v1", + label: "llama.cpp", + env: {}, + }); + + expect(models).toEqual([ + expect.objectContaining({ + id: "qwen3.6-mxfp4-moe", + contextWindow: 262_144, + contextTokens: 65_536, + }), + ]); + expect(fetchWithSsrFGuardMock).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + url: "http://127.0.0.1:8080/props", + }), + ); + expect(modelsRelease).toHaveBeenCalledOnce(); + expect(propsRelease).toHaveBeenCalledOnce(); + }); + + it("scopes llama.cpp /props runtime caps to each discovered model", async () => { + const modelsRelease = vi.fn(async () => undefined); + const firstPropsRelease = vi.fn(async () => undefined); + const secondPropsRelease = vi.fn(async () => undefined); + fetchWithSsrFGuardMock.mockResolvedValueOnce({ + response: new Response( + JSON.stringify({ + data: [ + { + id: "qwen/router-a", + meta: { n_ctx_train: 262_144 }, + }, + { + id: "qwen/router-b", + meta: { n_ctx_train: 131_072 }, + }, + ], + }), + { status: 200 }, + ), + finalUrl: "http://127.0.0.1:8080/v1/models", + release: modelsRelease, + }); + fetchWithSsrFGuardMock.mockResolvedValueOnce({ + response: new Response(JSON.stringify({ default_generation_settings: { n_ctx: 65_536 } }), { + status: 200, + }), + finalUrl: "http://127.0.0.1:8080/props?model=qwen%2Frouter-a", + release: firstPropsRelease, + }); + fetchWithSsrFGuardMock.mockResolvedValueOnce({ + response: new Response(JSON.stringify({ default_generation_settings: { n_ctx: 32_768 } }), { + status: 200, + }), + finalUrl: "http://127.0.0.1:8080/props?model=qwen%2Frouter-b", + release: secondPropsRelease, + }); + + const models = await discoverOpenAICompatibleLocalModels({ + baseUrl: "http://127.0.0.1:8080/v1", + label: "llama.cpp", + env: {}, + }); + + expect(models).toEqual([ + expect.objectContaining({ + id: "qwen/router-a", + contextWindow: 262_144, + contextTokens: 65_536, + }), + expect.objectContaining({ + id: "qwen/router-b", + contextWindow: 131_072, + contextTokens: 32_768, + }), + ]); + expect(fetchWithSsrFGuardMock).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + url: "http://127.0.0.1:8080/props?model=qwen%2Frouter-a", + }), + ); + expect(fetchWithSsrFGuardMock).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ + url: "http://127.0.0.1:8080/props?model=qwen%2Frouter-b", + }), + ); + expect(modelsRelease).toHaveBeenCalledOnce(); + expect(firstPropsRelease).toHaveBeenCalledOnce(); + expect(secondPropsRelease).toHaveBeenCalledOnce(); + }); + + it("keeps top-level llama.cpp /props n_ctx as a compatibility fallback", async () => { const modelsRelease = vi.fn(async () => undefined); const propsRelease = vi.fn(async () => undefined); fetchWithSsrFGuardMock.mockResolvedValueOnce({ @@ -183,12 +305,6 @@ describe("discoverOpenAICompatibleLocalModels", () => { contextTokens: 65_536, }), ]); - expect(fetchWithSsrFGuardMock).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - url: "http://127.0.0.1:8080/props", - }), - ); expect(modelsRelease).toHaveBeenCalledOnce(); expect(propsRelease).toHaveBeenCalledOnce(); }); diff --git a/src/plugins/provider-self-hosted-setup.ts b/src/plugins/provider-self-hosted-setup.ts index f347166afa1..68ed0bee6da 100644 --- a/src/plugins/provider-self-hosted-setup.ts +++ b/src/plugins/provider-self-hosted-setup.ts @@ -42,6 +42,9 @@ type OpenAICompatModelsResponse = { }; type LlamaCppPropsResponse = { + default_generation_settings?: { + n_ctx?: unknown; + }; n_ctx?: unknown; }; @@ -76,23 +79,28 @@ function readPositiveInteger(value: unknown): number | undefined { return Math.trunc(value); } -function resolveLlamaCppPropsUrl(baseUrl: string): string { +function resolveLlamaCppPropsUrl(baseUrl: string, modelId?: string): string { const parsed = new URL(baseUrl); const pathname = parsed.pathname.replace(/\/+$/, ""); - parsed.pathname = pathname.endsWith("/v1") ? pathname.slice(0, -3) || "/" : pathname; + const rootPathname = pathname.endsWith("/v1") ? pathname.slice(0, -3) || "/" : pathname; + parsed.pathname = `${rootPathname.replace(/\/+$/, "")}/props`; parsed.search = ""; parsed.hash = ""; - const root = parsed.toString().replace(/\/+$/, ""); - return `${root}/props`; + const normalizedModelId = normalizeOptionalString(modelId); + if (normalizedModelId) { + parsed.searchParams.set("model", normalizedModelId); + } + return parsed.toString(); } async function discoverLlamaCppRuntimeContextTokens(params: { baseUrl: string; apiKey?: string; + modelId?: string; }): Promise { let url: string; try { - url = resolveLlamaCppPropsUrl(params.baseUrl); + url = resolveLlamaCppPropsUrl(params.baseUrl, params.modelId); } catch { return undefined; } @@ -111,7 +119,10 @@ async function discoverLlamaCppRuntimeContextTokens(params: { return undefined; } const data = (await response.json()) as LlamaCppPropsResponse; - return readPositiveInteger(data.n_ctx); + return ( + readPositiveInteger(data.default_generation_settings?.n_ctx) ?? + readPositiveInteger(data.n_ctx) + ); } finally { await release(); } @@ -158,23 +169,41 @@ export async function discoverOpenAICompatibleLocalModels(params: { return []; } - const runtimeContextTokens = - params.contextWindow === undefined - ? await discoverLlamaCppRuntimeContextTokens({ - baseUrl: trimmedBaseUrl, - apiKey: params.apiKey, - }) - : undefined; - - return models.flatMap((model) => { + const discoveredModels = models.flatMap((model) => { const modelId = normalizeOptionalString(model.id); if (!modelId) { return []; } + return [{ id: modelId, meta: model.meta }]; + }); + const runtimeContextTokensByModelId = new Map(); + if (params.contextWindow === undefined) { + const uniqueModelIds = [...new Set(discoveredModels.map((model) => model.id))]; + const runtimeContextTokenResults = await Promise.all( + uniqueModelIds.map( + async (modelId) => + [ + modelId, + await discoverLlamaCppRuntimeContextTokens({ + baseUrl: trimmedBaseUrl, + apiKey: params.apiKey, + modelId: uniqueModelIds.length > 1 ? modelId : undefined, + }), + ] as const, + ), + ); + for (const [modelId, runtimeContextTokens] of runtimeContextTokenResults) { + if (runtimeContextTokens) { + runtimeContextTokensByModelId.set(modelId, runtimeContextTokens); + } + } + } + + return discoveredModels.map((model) => { const modelConfig: ModelDefinitionConfig = { - id: modelId, - name: modelId, - reasoning: isReasoningModelHeuristic(modelId), + id: model.id, + name: model.id, + reasoning: isReasoningModelHeuristic(model.id), input: ["text"], cost: SELF_HOSTED_DEFAULT_COST, contextWindow: @@ -183,10 +212,11 @@ export async function discoverOpenAICompatibleLocalModels(params: { SELF_HOSTED_DEFAULT_CONTEXT_WINDOW, maxTokens: params.maxTokens ?? SELF_HOSTED_DEFAULT_MAX_TOKENS, }; + const runtimeContextTokens = runtimeContextTokensByModelId.get(model.id); if (runtimeContextTokens) { modelConfig.contextTokens = runtimeContextTokens; } - return [modelConfig]; + return modelConfig; }); } finally { await release(); From 29f36e0072987eea3dc5fb67d492255b4f250567 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 11 May 2026 13:09:48 +0100 Subject: [PATCH 34/93] fix: avoid llama.cpp router autoload during discovery (#74057) --- CHANGELOG.md | 2 +- src/plugins/provider-self-hosted-setup.test.ts | 10 +++++----- src/plugins/provider-self-hosted-setup.ts | 1 + 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 009a77c5fc3..4849018638e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,7 @@ Docs: https://docs.openclaw.ai - Gateway: scope `sessions.resolve` sessionId and label store loads to the requested agent so large unrelated agent stores are not parsed for scoped lookups. Fixes #51264. (#79474) Thanks @samzong. - Browser: report Chrome MCP existing-session page readiness in browser status without letting status probes exceed the client timeout. Fixes #80268. (#80280) Thanks @ai-hpc. +- Providers/self-hosted: read model-scoped llama.cpp runtime context from `/props.default_generation_settings.n_ctx` while keeping top-level `n_ctx` as a fallback, so session budgeting reflects the loaded context window. Fixes #73664. (#74057) Thanks @brokemac79. - Memory: reject symlinked directory components in configured extra memory paths before reading Markdown files. (#80331) Thanks @samzong. - Sessions/transcripts: replace whole-file `readFile` scans with shared streaming helpers (`streamSessionTranscriptLines` and `streamSessionTranscriptLinesReverse`) for idempotency lookup, latest/tail assistant text reads, delivery-mirror dedupe, and compaction fork loading, so long-running sessions no longer materialize the full transcript in memory. Forward scans use `readline` over a bounded `createReadStream`; reverse scans read bounded chunks from the file end and decode complete JSONL lines newest-first without a fixed tail cap. Synthetic 200 MiB transcript: peak RSS delta drops from +252 MiB to +27 MiB while preserving malformed-line tolerance and idempotency-key return semantics. Fixes #54296. Thanks @jack-stormentswe. - WhatsApp: apply hot-reloaded `dmPolicy` and `allowFrom` settings to the active Web listener before processing new inbound DMs. Fixes #80538. Thanks @Ampaskopi129. @@ -597,7 +598,6 @@ Docs: https://docs.openclaw.ai - Sessions cleanup: add `openclaw sessions cleanup --fix-dm-scope` so operators who return `session.dmScope` to `main` can dry-run and retire stale direct-DM session rows while preserving transcripts as deleted archives. Fixes #47561 and #45554. Thanks @BunsDev. - Doctor/Codex: repair legacy `openai-codex/*` routes and cron payload model refs to canonical `openai/*`, keep OpenAI agent turns on Codex by default, ignore stale whole-agent/session runtime pins, preserve explicit provider/model runtime policy, and migrate legacy runtime model refs to model-scoped runtime entries. Thanks @vincentkoc. -- Providers/self-hosted: read model-scoped llama.cpp runtime context from `/props.default_generation_settings.n_ctx` while keeping top-level `n_ctx` as a fallback, so session budgeting reflects the loaded context window. Fixes #73664. Thanks @brokemac79. - Video generation: wait up to 20 minutes for slow fal/MiniMax queue-backed jobs, stop forwarding unsupported Google Veo generated-audio options, and normalize MiniMax `720P` requests to its supported `768P` resolution with the usual override warning/details instead of failing fallback. - Channels/durable delivery: preserve channel-specific final reply semantics when using durable sends, including Telegram selected quotes and silent error replies plus WhatsApp message-sending cancellations. - Channels/message lifecycle: build legacy channel delivery results from message receipts and add receipts to BlueBubbles, Feishu, Google Chat, iMessage, IRC, LINE, Nextcloud Talk, QQ Bot, Signal, Synology Chat, Tlon, Twitch, WhatsApp, Zalo, and Zalo Personal send results and owner-path reply delivery plus Discord, Matrix, Mattermost, Slack, and Teams send results while preserving existing message id compatibility. diff --git a/src/plugins/provider-self-hosted-setup.test.ts b/src/plugins/provider-self-hosted-setup.test.ts index c81b676d983..5c73177a9c9 100644 --- a/src/plugins/provider-self-hosted-setup.test.ts +++ b/src/plugins/provider-self-hosted-setup.test.ts @@ -195,7 +195,7 @@ describe("discoverOpenAICompatibleLocalModels", () => { expect(propsRelease).toHaveBeenCalledOnce(); }); - it("scopes llama.cpp /props runtime caps to each discovered model", async () => { + it("scopes llama.cpp /props runtime caps to each discovered model without autoloading", async () => { const modelsRelease = vi.fn(async () => undefined); const firstPropsRelease = vi.fn(async () => undefined); const secondPropsRelease = vi.fn(async () => undefined); @@ -222,14 +222,14 @@ describe("discoverOpenAICompatibleLocalModels", () => { response: new Response(JSON.stringify({ default_generation_settings: { n_ctx: 65_536 } }), { status: 200, }), - finalUrl: "http://127.0.0.1:8080/props?model=qwen%2Frouter-a", + finalUrl: "http://127.0.0.1:8080/props?model=qwen%2Frouter-a&autoload=false", release: firstPropsRelease, }); fetchWithSsrFGuardMock.mockResolvedValueOnce({ response: new Response(JSON.stringify({ default_generation_settings: { n_ctx: 32_768 } }), { status: 200, }), - finalUrl: "http://127.0.0.1:8080/props?model=qwen%2Frouter-b", + finalUrl: "http://127.0.0.1:8080/props?model=qwen%2Frouter-b&autoload=false", release: secondPropsRelease, }); @@ -254,13 +254,13 @@ describe("discoverOpenAICompatibleLocalModels", () => { expect(fetchWithSsrFGuardMock).toHaveBeenNthCalledWith( 2, expect.objectContaining({ - url: "http://127.0.0.1:8080/props?model=qwen%2Frouter-a", + url: "http://127.0.0.1:8080/props?model=qwen%2Frouter-a&autoload=false", }), ); expect(fetchWithSsrFGuardMock).toHaveBeenNthCalledWith( 3, expect.objectContaining({ - url: "http://127.0.0.1:8080/props?model=qwen%2Frouter-b", + url: "http://127.0.0.1:8080/props?model=qwen%2Frouter-b&autoload=false", }), ); expect(modelsRelease).toHaveBeenCalledOnce(); diff --git a/src/plugins/provider-self-hosted-setup.ts b/src/plugins/provider-self-hosted-setup.ts index 68ed0bee6da..6c625f17ebd 100644 --- a/src/plugins/provider-self-hosted-setup.ts +++ b/src/plugins/provider-self-hosted-setup.ts @@ -89,6 +89,7 @@ function resolveLlamaCppPropsUrl(baseUrl: string, modelId?: string): string { const normalizedModelId = normalizeOptionalString(modelId); if (normalizedModelId) { parsed.searchParams.set("model", normalizedModelId); + parsed.searchParams.set("autoload", "false"); } return parsed.toString(); } From 323ba824edfdc5db2fc7d5e4fe93e0438143264b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 11 May 2026 13:16:52 +0100 Subject: [PATCH 35/93] test: tighten control ui http assertions --- src/gateway/control-ui.http.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/gateway/control-ui.http.test.ts b/src/gateway/control-ui.http.test.ts index f5a3314fcd9..690959e3f1b 100644 --- a/src/gateway/control-ui.http.test.ts +++ b/src/gateway/control-ui.http.test.ts @@ -214,7 +214,7 @@ describe("handleControlUiHttpRequest", () => { }) { expect(params.handled).toBe(true); expect(params.res.statusCode).toBe(403); - expect(JSON.parse(String(params.end.mock.calls[0]?.[0] ?? ""))).toMatchObject({ + expect(JSON.parse(String(params.end.mock.calls[0]?.[0] ?? ""))).toEqual({ ok: false, error: { type: "forbidden", @@ -377,7 +377,7 @@ describe("handleControlUiHttpRequest", () => { mediaTicket?: string; mediaTicketExpiresAt?: string; }; - expect(payload).toMatchObject({ available: true }); + expect(payload.available).toBe(true); expect(payload.mediaTicket).toMatch(/^v1\./); expect(Date.parse(payload.mediaTicketExpiresAt ?? "")).not.toBeNaN(); } finally { @@ -419,7 +419,7 @@ describe("handleControlUiHttpRequest", () => { mediaTicket?: string; mediaTicketExpiresAt?: string; }; - expect(payload).toMatchObject({ available: true }); + expect(payload.available).toBe(true); expect(payload.mediaTicket).toMatch(/^v1\./); expect(Date.parse(payload.mediaTicketExpiresAt ?? "")).not.toBeNaN(); }, From 2d5701237b2dc4f01d9938ef4ad19562fd9af0d4 Mon Sep 17 00:00:00 2001 From: Shakker Date: Mon, 11 May 2026 13:17:52 +0100 Subject: [PATCH 36/93] test: assert agents add outputs --- src/commands/agents.add.test.ts | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/src/commands/agents.add.test.ts b/src/commands/agents.add.test.ts index 0365a78ea21..76d9b1513a8 100644 --- a/src/commands/agents.add.test.ts +++ b/src/commands/agents.add.test.ts @@ -1,9 +1,11 @@ +import { createHash } from "node:crypto"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { AUTH_STORE_VERSION } from "../agents/auth-profiles/constants.js"; import { saveAuthProfileStore } from "../agents/auth-profiles/store.js"; +import { formatCliCommand } from "../cli/command-format.js"; import { baseConfigSnapshot, createTestRuntime } from "./test-runtime-config-helpers.js"; const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn()); @@ -33,6 +35,10 @@ import { agentsAddCommand } from "./agents.js"; const runtime = createTestRuntime(); +function oauthProfileSecretId(authStorePath: string, profileId: string): string { + return createHash("sha256").update(`${authStorePath}\0${profileId}`).digest("hex").slice(0, 32); +} + describe("agents add command", () => { beforeEach(() => { readConfigFileSnapshotMock.mockClear(); @@ -49,7 +55,10 @@ describe("agents add command", () => { await agentsAddCommand({ name: "Work" }, runtime, { hasFlags: true }); - expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("--workspace")); + expect(runtime.error).toHaveBeenCalledOnce(); + expect(runtime.error).toHaveBeenCalledWith( + `Non-interactive agent creation requires --workspace. Re-run ${formatCliCommand("openclaw agents add --workspace ")} or omit flags to use the wizard.`, + ); expect(runtime.exit).toHaveBeenCalledWith(1); expect(writeConfigFileMock).not.toHaveBeenCalled(); }); @@ -61,7 +70,10 @@ describe("agents add command", () => { hasFlags: false, }); - expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("--workspace")); + expect(runtime.error).toHaveBeenCalledOnce(); + expect(runtime.error).toHaveBeenCalledWith( + `Non-interactive agent creation requires --workspace. Re-run ${formatCliCommand("openclaw agents add --workspace ")} or omit flags to use the wizard.`, + ); expect(runtime.exit).toHaveBeenCalledWith(1); expect(writeConfigFileMock).not.toHaveBeenCalled(); }); @@ -146,6 +158,7 @@ describe("agents add command", () => { const sourceAgentDir = path.join(root, "main", "agent"); const destAgentDir = path.join(root, "work", "agent"); const destAuthPath = path.join(destAgentDir, "auth-profiles.json"); + const expires = Date.now() + 60_000; await fs.mkdir(sourceAgentDir, { recursive: true }); saveAuthProfileStore( { @@ -156,7 +169,7 @@ describe("agents add command", () => { provider: "openai-codex", access: "codex-copy-access-token", refresh: "codex-copy-refresh-token", - expires: Date.now() + 60_000, + expires, copyToAgents: true, }, }, @@ -177,19 +190,17 @@ describe("agents add command", () => { profiles: Record>; }; const credential = copied.profiles["openai-codex:default"]; - expect(credential).toMatchObject({ + expect(credential).toStrictEqual({ type: "oauth", provider: "openai-codex", + expires, copyToAgents: true, oauthRef: { source: "openclaw-credentials", provider: "openai-codex", - id: expect.any(String), + id: oauthProfileSecretId(destAuthPath, "openai-codex:default"), }, }); - expect(credential).not.toHaveProperty("access"); - expect(credential).not.toHaveProperty("refresh"); - expect(credential).not.toHaveProperty("idToken"); } finally { if (previousStateDir === undefined) { delete process.env.OPENCLAW_STATE_DIR; From 6fcceed61ff553090349d29a4d069a580740a094 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 11 May 2026 13:18:06 +0100 Subject: [PATCH 37/93] test: tighten chat attachment assertions --- src/gateway/chat-attachments.test.ts | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/src/gateway/chat-attachments.test.ts b/src/gateway/chat-attachments.test.ts index 59ae0c6eef2..75c8ea19f1e 100644 --- a/src/gateway/chat-attachments.test.ts +++ b/src/gateway/chat-attachments.test.ts @@ -295,16 +295,19 @@ describe("parseMessageWithAttachments", () => { describe("parseMessageWithAttachments validation errors", () => { it("throws UnsupportedAttachmentError on empty payload", async () => { - await expect( - parseMessageWithAttachments( + let caught: unknown; + try { + await parseMessageWithAttachments( "x", [{ type: "file", mimeType: "application/pdf", fileName: "empty.pdf", content: "" }], { log: { warn: () => {} } }, - ), - ).rejects.toMatchObject({ - name: "UnsupportedAttachmentError", - reason: "empty-payload", - }); + ); + } catch (err) { + caught = err; + } + expect(caught).toBeInstanceOf(UnsupportedAttachmentError); + expect((caught as UnsupportedAttachmentError).name).toBe("UnsupportedAttachmentError"); + expect((caught as UnsupportedAttachmentError).reason).toBe("empty-payload"); expect(saveMediaBufferMock).not.toHaveBeenCalled(); }); @@ -396,10 +399,8 @@ describe("parseMessageWithAttachments validation errors", () => { expect(parsed.images).toHaveLength(0); expect(parsed.imageOrder).toStrictEqual([]); expect(parsed.offloadedRefs).toHaveLength(1); - expect(parsed.offloadedRefs[0]).toMatchObject({ - mimeType: "application/pdf", - label: "brief.pdf", - }); + expect(parsed.offloadedRefs[0]?.mimeType).toBe("application/pdf"); + expect(parsed.offloadedRefs[0]?.label).toBe("brief.pdf"); expect(parsed.message).toBe("read this"); } finally { await cleanupOffloadedRefs(parsed.offloadedRefs); From 235ad7ec956513c632c20eedd68bfbedc8cc3ea8 Mon Sep 17 00:00:00 2001 From: Bryan Pearson Date: Sat, 2 May 2026 21:17:53 -0700 Subject: [PATCH 38/93] fix(exec): keep configured security authoritative --- CHANGELOG.md | 1 + .../bash-tools.exec.security-floor.test.ts | 83 +++++++++++++++++++ src/agents/bash-tools.exec.ts | 5 +- 3 files changed, 85 insertions(+), 4 deletions(-) create mode 100644 src/agents/bash-tools.exec.security-floor.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4849018638e..472d1e75e14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1296,6 +1296,7 @@ Docs: https://docs.openclaw.ai - Plugins/npm: build package-local runtime dist files for publishable plugins and stop listing root-package-excluded plugin sidecars in the core package metadata, so npm plugin installs such as `@openclaw/diffs` and `@openclaw/discord` no longer publish source-only runtime payloads. Fixes #76426. Thanks @PrinceOfEgypt. - Channels/secrets: resolve SecretRef-backed channel credentials through external plugin secret contracts after the plugin split, covering runtime startup, target discovery, webhook auth, disabled-account enumeration, and late-bound web_search config. Fixes #76371. (#76449) Thanks @joshavant and @neeravmakwana. - Docker/Gateway: pass Docker setup `.env` values into gateway and CLI containers and preserve exec SecretRef `passEnv` keys in managed service plans, so 1Password Connect-backed Discord tokens keep resolving after doctor or plugin repair. Thanks @vincentkoc. +- Exec/security: treat configured `tools.exec.security` as authoritative for normal tool calls so model-supplied `security` arguments cannot downgrade or tighten the operator policy, while preserving explicitly granted elevated-full overrides. (#65933) Thanks @bryanpearson. - Control UI/WebChat: explain compaction boundaries in chat history and link directly to session checkpoint controls so pre-compaction turns no longer look silently lost after refresh. Fixes #76415. Thanks @BunsDev. - Agents/compaction: add an optional bundled compaction notifier hook and retry once from the compacted transcript when automatic compaction leaves a turn without a final visible reply. (#76651) Thanks @simplyclever914. - Agents/incomplete-turn: detect and surface a warning when the agent's final text after a tool-call chain is silently dropped because the post-tool assistant response was never produced, instead of completing the turn with only the pre-tool analysis text. Fixes #76477. Thanks @amknight. diff --git a/src/agents/bash-tools.exec.security-floor.test.ts b/src/agents/bash-tools.exec.security-floor.test.ts new file mode 100644 index 00000000000..72a7b7e1982 --- /dev/null +++ b/src/agents/bash-tools.exec.security-floor.test.ts @@ -0,0 +1,83 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { captureEnv } from "../test-utils/env.js"; +import { resetProcessRegistryForTests } from "./bash-process-registry.js"; +import { createExecTool } from "./bash-tools.exec.js"; + +describe("exec security floor", () => { + let envSnapshot: ReturnType; + + beforeEach(() => { + envSnapshot = captureEnv(["SHELL"]); + resetProcessRegistryForTests(); + }); + + afterEach(() => { + envSnapshot.restore(); + }); + + it("ignores model-supplied allowlist security when configured security is full", async () => { + const tool = createExecTool({ + security: "full", + ask: "off", + }); + + const result = await tool.execute("call-1", { + command: "echo hello", + security: "allowlist", + ask: "off", + }); + + expect(result.content[0]).toMatchObject({ type: "text" }); + const text = (result.content[0] as { text?: string }).text ?? ""; + expect(text).not.toMatch(/exec denied/i); + expect(text).not.toMatch(/allowlist miss/i); + expect(text.trim()).toContain("hello"); + }); + + it("enforces configured allowlist security when model also passes allowlist", async () => { + const tool = createExecTool({ + security: "allowlist", + ask: "off", + safeBins: [], + }); + + await expect( + tool.execute("call-2", { + command: "echo hello", + security: "allowlist", + ask: "off", + }), + ).rejects.toThrow(/exec denied: allowlist miss/i); + }); + + it("ignores model-supplied deny security when configured security is allowlist", async () => { + const tool = createExecTool({ + security: "allowlist", + ask: "off", + safeBins: [], + }); + + await expect( + tool.execute("call-3", { + command: "echo hello", + security: "deny", + ask: "off", + }), + ).rejects.toThrow(/exec denied: allowlist miss/i); + }); + + it("ignores model-supplied full security when configured security is deny", async () => { + const tool = createExecTool({ + security: "deny", + ask: "off", + }); + + await expect( + tool.execute("call-4", { + command: "echo hello", + security: "full", + ask: "off", + }), + ).rejects.toThrow(/exec denied/i); + }); +}); diff --git a/src/agents/bash-tools.exec.ts b/src/agents/bash-tools.exec.ts index 5beb58f5573..c80a702b5a8 100644 --- a/src/agents/bash-tools.exec.ts +++ b/src/agents/bash-tools.exec.ts @@ -8,7 +8,6 @@ import { type ExecSecurity, loadExecApprovals, maxAsk, - minSecurity, requireValidExecTarget, } from "../infra/exec-approvals.js"; import { resolveExecSafeBinRuntimePolicy } from "../infra/exec-safe-bin-runtime-policy.js"; @@ -40,7 +39,6 @@ import { applyPathPrepend, applyShellPath, normalizeExecAsk, - normalizeExecSecurity, normalizePathPrepend, resolveExecTarget, resolveApprovalRunningNoticeMs, @@ -1346,8 +1344,7 @@ export function createExecTool( const approvalDefaults = loadExecApprovals().defaults; const configuredSecurity = defaults?.security ?? approvalDefaults?.security ?? (host === "sandbox" ? "deny" : "full"); - const requestedSecurity = normalizeExecSecurity(params.security); - let security = minSecurity(configuredSecurity, requestedSecurity ?? configuredSecurity); + let security = configuredSecurity; if (elevatedRequested && elevatedMode === "full") { security = "full"; } From 1cbe6e271bae1aa6a3e10eeab4a9fde37517e9bd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 11 May 2026 12:54:55 +0100 Subject: [PATCH 39/93] fix(exec): address security floor review --- docs/tools/exec.md | 4 ++- .../bash-tools.exec.security-floor.test.ts | 30 ++++++++++++++++++- src/agents/bash-tools.schemas.ts | 3 +- 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/docs/tools/exec.md b/docs/tools/exec.md index 17baa8e5f90..c86bada78a4 100644 --- a/docs/tools/exec.md +++ b/docs/tools/exec.md @@ -46,7 +46,9 @@ Where to execute. `auto` resolves to `sandbox` when a sandbox runtime is active -Enforcement mode for `gateway` / `node` execution. +Ignored for normal tool calls. `gateway` / `node` security is controlled by +`tools.exec.security` and `~/.openclaw/exec-approvals.json`; elevated mode can +force `security=full` only when the operator explicitly grants elevated access. diff --git a/src/agents/bash-tools.exec.security-floor.test.ts b/src/agents/bash-tools.exec.security-floor.test.ts index 72a7b7e1982..e72ca98bd8e 100644 --- a/src/agents/bash-tools.exec.security-floor.test.ts +++ b/src/agents/bash-tools.exec.security-floor.test.ts @@ -1,3 +1,6 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { captureEnv } from "../test-utils/env.js"; import { resetProcessRegistryForTests } from "./bash-process-registry.js"; @@ -5,14 +8,39 @@ import { createExecTool } from "./bash-tools.exec.js"; describe("exec security floor", () => { let envSnapshot: ReturnType; + let tempRoot: string | undefined; beforeEach(() => { - envSnapshot = captureEnv(["SHELL"]); + envSnapshot = captureEnv([ + "HOME", + "USERPROFILE", + "HOMEDRIVE", + "HOMEPATH", + "OPENCLAW_STATE_DIR", + "SHELL", + ]); + tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-exec-security-floor-")); + process.env.HOME = tempRoot; + process.env.USERPROFILE = tempRoot; + process.env.OPENCLAW_STATE_DIR = path.join(tempRoot, "state"); + if (process.platform === "win32") { + const parsed = path.parse(tempRoot); + process.env.HOMEDRIVE = parsed.root.slice(0, 2); + process.env.HOMEPATH = tempRoot.slice(2) || "\\"; + } else { + delete process.env.HOMEDRIVE; + delete process.env.HOMEPATH; + } resetProcessRegistryForTests(); }); afterEach(() => { + const dir = tempRoot; + tempRoot = undefined; envSnapshot.restore(); + if (dir) { + fs.rmSync(dir, { recursive: true, force: true }); + } }); it("ignores model-supplied allowlist security when configured security is full", async () => { diff --git a/src/agents/bash-tools.schemas.ts b/src/agents/bash-tools.schemas.ts index 80fe33a9f01..a0e9e8089ad 100644 --- a/src/agents/bash-tools.schemas.ts +++ b/src/agents/bash-tools.schemas.ts @@ -34,7 +34,8 @@ export const execSchema = Type.Object({ }), security: Type.Optional( Type.String({ - description: "Exec security mode (deny|allowlist|full).", + description: + "Ignored for normal calls; exec security is set by tools.exec.security and host approvals.", }), ), ask: Type.Optional( From e50f323c1f86c4d50e93ccf887d2a6cadddfeb45 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 11 May 2026 13:13:48 +0100 Subject: [PATCH 40/93] test(exec): isolate OpenClaw home in security floor tests --- src/agents/bash-tools.exec.security-floor.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/agents/bash-tools.exec.security-floor.test.ts b/src/agents/bash-tools.exec.security-floor.test.ts index e72ca98bd8e..3e247f51d8d 100644 --- a/src/agents/bash-tools.exec.security-floor.test.ts +++ b/src/agents/bash-tools.exec.security-floor.test.ts @@ -16,12 +16,14 @@ describe("exec security floor", () => { "USERPROFILE", "HOMEDRIVE", "HOMEPATH", + "OPENCLAW_HOME", "OPENCLAW_STATE_DIR", "SHELL", ]); tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-exec-security-floor-")); process.env.HOME = tempRoot; process.env.USERPROFILE = tempRoot; + process.env.OPENCLAW_HOME = tempRoot; process.env.OPENCLAW_STATE_DIR = path.join(tempRoot, "state"); if (process.platform === "win32") { const parsed = path.parse(tempRoot); From 3880b72294402d25d1cc746e90262c5a05336212 Mon Sep 17 00:00:00 2001 From: samzong Date: Sun, 10 May 2026 22:42:55 +0800 Subject: [PATCH 41/93] fix(gateway): share streaming event envelopes Signed-off-by: samzong --- src/gateway/gateway-misc.test.ts | 91 +++++++++++++++++++++- src/gateway/node-registry.test.ts | 32 +++++++- src/gateway/node-registry.ts | 59 ++++++++++++++ src/gateway/server-broadcast.ts | 37 +++++++-- src/gateway/server-node-session-runtime.ts | 12 +-- src/gateway/server-node-subscriptions.ts | 6 +- 6 files changed, 219 insertions(+), 18 deletions(-) diff --git a/src/gateway/gateway-misc.test.ts b/src/gateway/gateway-misc.test.ts index f71e63c60a6..951426a5370 100644 --- a/src/gateway/gateway-misc.test.ts +++ b/src/gateway/gateway-misc.test.ts @@ -19,6 +19,7 @@ import { DEFAULT_DANGEROUS_NODE_COMMANDS, resolveNodeCommandAllowlist, } from "./node-command-policy.js"; +import type { SerializedEventPayload } from "./node-registry.js"; import type { RequestFrame } from "./protocol/index.js"; import { createGatewayBroadcaster } from "./server-broadcast.js"; import { createChatRunRegistry } from "./server-chat.js"; @@ -26,6 +27,7 @@ import { MAX_BUFFERED_BYTES } from "./server-constants.js"; import { handleNodeInvokeResult } from "./server-methods/nodes.handlers.invoke-result.js"; import type { GatewayClient as GatewayMethodClient } from "./server-methods/types.js"; import type { GatewayRequestContext, RespondFn } from "./server-methods/types.js"; +import { createGatewayNodeSessionRuntime } from "./server-node-session-runtime.js"; import { createNodeSubscriptionManager } from "./server-node-subscriptions.js"; import { formatError, normalizeVoiceWakeTriggers } from "./server-utils.js"; import type { GatewayWsClient } from "./server/ws-types.js"; @@ -572,6 +574,47 @@ describe("gateway broadcaster", () => { ]); }); + it("reuses the same payload shape while assigning per-client seq values", () => { + const firstSocket = makeRecordingSocket(); + const secondSocket = makeRecordingSocket(); + const thirdSocket = makeRecordingSocket(); + const clients = new Set([ + makeGatewayWsClient("c-1", firstSocket, { + role: "operator", + scopes: ["operator.read"], + } as GatewayWsClient["connect"]), + makeGatewayWsClient("c-2", secondSocket, { + role: "operator", + scopes: ["operator.write"], + } as GatewayWsClient["connect"]), + makeGatewayWsClient("c-3", thirdSocket, { + role: "operator", + scopes: ["operator.admin"], + } as GatewayWsClient["connect"]), + ]); + const payloadKeys: string[] = []; + const payload = { + toJSON(key: string) { + payloadKeys.push(key); + return { foo: key }; + }, + }; + + const { broadcast } = createGatewayBroadcaster({ clients }); + broadcast("talk.mode", { enabled: true }); + broadcast("chat", payload); + + expect(payloadKeys).toEqual(["payload"]); + expect(firstSocket.sent.at(-1)?.payload).toEqual({ foo: "payload" }); + expect(secondSocket.sent.at(-1)?.payload).toEqual({ foo: "payload" }); + expect(thirdSocket.sent.at(-1)?.payload).toEqual({ foo: "payload" }); + expect([ + firstSocket.sent.at(-1)?.seq, + secondSocket.sent.at(-1)?.seq, + thirdSocket.sent.at(-1)?.seq, + ]).toEqual([1, 2, 2]); + }); + it("preserves seq gaps when dropIfSlow skips an eligible broadcast", () => { const slowReadSocket = makeRecordingSocket(); slowReadSocket.bufferedAmount = Number.MAX_SAFE_INTEGER; @@ -708,10 +751,13 @@ describe("node subscription manager", () => { const sent: Array<{ nodeId: string; event: string; - payloadJSON?: string | null; + payloadJSON?: SerializedEventPayload | null; }> = []; - const sendEvent = (evt: { nodeId: string; event: string; payloadJSON?: string | null }) => - sent.push(evt); + const sendEvent = (evt: { + nodeId: string; + event: string; + payloadJSON?: SerializedEventPayload | null; + }) => sent.push(evt); manager.subscribe("node-a", "main"); manager.subscribe("node-b", "main"); @@ -722,6 +768,45 @@ describe("node subscription manager", () => { expect(sent[0].event).toBe("chat"); }); + test("runtime forwards subscribed node payload json without parsing it again", () => { + const frames: string[] = []; + const socket: TestSocket = { + bufferedAmount: 0, + send: vi.fn((payload: string) => frames.push(payload)), + close: vi.fn(), + }; + const parseSpy = vi.spyOn(JSON, "parse"); + try { + const runtime = createGatewayNodeSessionRuntime({ broadcast: vi.fn() }); + runtime.nodeRegistry.register( + makeGatewayWsClient("conn-node-a", socket, { + role: "node", + scopes: [], + client: { + id: "node-client", + version: "1.0.0", + platform: "darwin", + mode: "node", + }, + device: { id: "node-a" }, + } as unknown as GatewayWsClient["connect"]), + {}, + ); + runtime.nodeSubscribe("node-a", "main"); + + runtime.nodeSendToSession("main", "chat", { ok: true }); + + expect(parseSpy).not.toHaveBeenCalled(); + } finally { + parseSpy.mockRestore(); + } + expect(JSON.parse(frames[0] ?? "{}")).toEqual({ + type: "event", + event: "chat", + payload: { ok: true }, + }); + }); + test("unsubscribeAll clears session mappings", () => { const manager = createNodeSubscriptionManager(); const sent: string[] = []; diff --git a/src/gateway/node-registry.test.ts b/src/gateway/node-registry.test.ts index 8b334138bed..06e3cf56d0d 100644 --- a/src/gateway/node-registry.test.ts +++ b/src/gateway/node-registry.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { NodeRegistry } from "./node-registry.js"; +import { NodeRegistry, serializeEventPayload } from "./node-registry.js"; import type { GatewayWsClient } from "./server/ws-types.js"; function makeClient(connId: string, nodeId: string, sent: string[] = []): GatewayWsClient { @@ -54,4 +54,34 @@ describe("gateway/node-registry", () => { expect(registry.get("node-1")).toBe(newSession); await expect(oldDisconnected).resolves.toBeInstanceOf(Error); }); + + it("sends raw event payload JSON without changing the envelope shape", () => { + const registry = new NodeRegistry(); + const frames: string[] = []; + registry.register(makeClient("conn-1", "node-1", frames), {}); + const payload = serializeEventPayload({ foo: "bar" }); + + expect(registry.sendEventRaw("node-1", "chat", payload)).toBe(true); + expect(registry.sendEventRaw("missing-node", "chat", payload)).toBe(false); + expect(registry.sendEventRaw("node-1", "heartbeat", null)).toBe(true); + expect( + registry.sendEventRaw( + "node-1", + "chat", + "not-json" as unknown as Parameters[2], + ), + ).toBe(false); + expect( + registry.sendEventRaw( + "node-1", + "chat", + '{"x":1},"seq":999' as unknown as Parameters[2], + ), + ).toBe(false); + + expect(frames).toEqual([ + '{"type":"event","event":"chat","payload":{"foo":"bar"}}', + '{"type":"event","event":"heartbeat"}', + ]); + }); }); diff --git a/src/gateway/node-registry.ts b/src/gateway/node-registry.ts index 493bfb0654f..6a5e5278bfa 100644 --- a/src/gateway/node-registry.ts +++ b/src/gateway/node-registry.ts @@ -38,6 +38,30 @@ type NodeInvokeResult = { error?: { code?: string; message?: string } | null; }; +const SERIALIZED_EVENT_PAYLOAD = Symbol("openclaw.serializedEventPayload"); + +export type SerializedEventPayload = { + readonly json: string; + readonly [SERIALIZED_EVENT_PAYLOAD]: true; +}; + +export function serializeEventPayload(payload: unknown): SerializedEventPayload | null { + if (!payload) { + return null; + } + const json = JSON.stringify(payload); + return typeof json === "string" ? { json, [SERIALIZED_EVENT_PAYLOAD]: true } : null; +} + +function isSerializedEventPayload(value: unknown): value is SerializedEventPayload { + return ( + typeof value === "object" && + value !== null && + (value as { [SERIALIZED_EVENT_PAYLOAD]?: unknown })[SERIALIZED_EVENT_PAYLOAD] === true && + typeof (value as { json?: unknown }).json === "string" + ); +} + export class NodeRegistry { private nodesById = new Map(); private nodesByConn = new Map(); @@ -198,6 +222,18 @@ export class NodeRegistry { return this.sendEventToSession(node, event, payload); } + sendEventRaw( + nodeId: string, + event: string, + payloadJSON?: SerializedEventPayload | null, + ): boolean { + const node = this.nodesById.get(nodeId); + if (!node) { + return false; + } + return this.sendEventRawInternal(node, event, payloadJSON); + } + private sendEventInternal(node: NodeSession, event: string, payload: unknown): boolean { try { node.client.socket.send( @@ -213,6 +249,29 @@ export class NodeRegistry { } } + private sendEventRawInternal( + node: NodeSession, + event: string, + payloadJSON?: SerializedEventPayload | null, + ): boolean { + if ( + payloadJSON !== null && + payloadJSON !== undefined && + !isSerializedEventPayload(payloadJSON) + ) { + return false; + } + try { + const payloadFragment = payloadJSON ? `,"payload":${payloadJSON.json}` : ""; + node.client.socket.send( + `{"type":"event","event":${JSON.stringify(event)}${payloadFragment}}`, + ); + return true; + } catch { + return false; + } + } + private sendEventToSession(node: NodeSession, event: string, payload: unknown): boolean { return this.sendEventInternal(node, event, payload); } diff --git a/src/gateway/server-broadcast.ts b/src/gateway/server-broadcast.ts index 096713c9505..bee0dd9cfad 100644 --- a/src/gateway/server-broadcast.ts +++ b/src/gateway/server-broadcast.ts @@ -51,6 +51,13 @@ const EVENT_SCOPE_GUARDS: Record = { // (e.g. reconfiguring wake-word triggers). const NODE_ALLOWED_EVENTS = new Set(["voicewake.changed", "voicewake.routing.changed"]); +function serializeFrameField(name: "payload" | "stateVersion", value: unknown): string { + const fieldJSON = JSON.stringify({ [name]: value }); + const keyJSON = JSON.stringify(name); + const prefix = `{${keyJSON}:`; + return fieldJSON.startsWith(prefix) ? `,${keyJSON}:${fieldJSON.slice(prefix.length, -1)}` : ""; +} + function hasEventScope(client: GatewayWsClient, event: string): boolean { const required = EVENT_SCOPE_GUARDS[event]; // Plugin-defined gateway broadcast events (plugin.* namespace) are allowed @@ -113,6 +120,26 @@ export function createGatewayBroadcaster(params: { clients: Set } logWs("out", "event", logMeta); } + let frameBase: + | { + eventJSON: string; + payloadFragment: string; + stateVersionFragment: string; + } + | undefined; + const getFrameBase = () => { + if (!frameBase) { + frameBase = { + eventJSON: JSON.stringify(event), + payloadFragment: serializeFrameField("payload", payload), + stateVersionFragment: + opts?.stateVersion === undefined + ? "" + : serializeFrameField("stateVersion", opts.stateVersion), + }; + } + return frameBase; + }; for (const c of params.clients) { if (targetConnIds && !targetConnIds.has(c.connId)) { continue; @@ -152,13 +179,9 @@ export function createGatewayBroadcaster(params: { clients: Set if (!isTargeted) { clientSeq.set(c, nextSeq); } - const frame = JSON.stringify({ - type: "event", - event, - payload, - seq: eventSeq, - stateVersion: opts?.stateVersion, - }); + const base = getFrameBase(); + const seqFragment = eventSeq === undefined ? "" : `,"seq":${eventSeq}`; + const frame = `{"type":"event","event":${base.eventJSON}${base.payloadFragment}${seqFragment}${base.stateVersionFragment}}`; c.socket.send(frame); } catch { /* ignore */ diff --git a/src/gateway/server-node-session-runtime.ts b/src/gateway/server-node-session-runtime.ts index ffdee8f9782..0f702eb3e68 100644 --- a/src/gateway/server-node-session-runtime.ts +++ b/src/gateway/server-node-session-runtime.ts @@ -1,9 +1,8 @@ -import { NodeRegistry } from "./node-registry.js"; +import { NodeRegistry, type SerializedEventPayload } from "./node-registry.js"; import { createSessionEventSubscriberRegistry, createSessionMessageSubscriberRegistry, } from "./server-chat-state.js"; -import { safeParseJson } from "./server-json.js"; import { createNodeSubscriptionManager } from "./server-node-subscriptions.js"; import { hasConnectedTalkNode } from "./server-talk-nodes.js"; @@ -15,9 +14,12 @@ export function createGatewayNodeSessionRuntime(params: { const nodeSubscriptions = createNodeSubscriptionManager(); const sessionEventSubscribers = createSessionEventSubscriberRegistry(); const sessionMessageSubscribers = createSessionMessageSubscriberRegistry(); - const nodeSendEvent = (opts: { nodeId: string; event: string; payloadJSON?: string | null }) => { - const payload = safeParseJson(opts.payloadJSON ?? null); - nodeRegistry.sendEvent(opts.nodeId, opts.event, payload); + const nodeSendEvent = (opts: { + nodeId: string; + event: string; + payloadJSON?: SerializedEventPayload | null; + }) => { + nodeRegistry.sendEventRaw(opts.nodeId, opts.event, opts.payloadJSON ?? null); }; const nodeSendToSession = (sessionKey: string, event: string, payload: unknown) => nodeSubscriptions.sendToSession(sessionKey, event, payload, nodeSendEvent); diff --git a/src/gateway/server-node-subscriptions.ts b/src/gateway/server-node-subscriptions.ts index 0c04c3a86f1..858b4028f05 100644 --- a/src/gateway/server-node-subscriptions.ts +++ b/src/gateway/server-node-subscriptions.ts @@ -1,7 +1,9 @@ +import { serializeEventPayload, type SerializedEventPayload } from "./node-registry.js"; + type NodeSendEventFn = (opts: { nodeId: string; event: string; - payloadJSON?: string | null; + payloadJSON?: SerializedEventPayload | null; }) => void; type NodeListConnectedFn = () => Array<{ nodeId: string }>; @@ -34,7 +36,7 @@ export function createNodeSubscriptionManager(): NodeSubscriptionManager { const nodeSubscriptions = new Map>(); const sessionSubscribers = new Map>(); - const toPayloadJSON = (payload: unknown) => (payload ? JSON.stringify(payload) : null); + const toPayloadJSON = (payload: unknown) => serializeEventPayload(payload); const subscribe = (nodeId: string, sessionKey: string) => { const normalizedNodeId = nodeId.trim(); From 9c810b552bd062274a71a6adb1ec0a653affaf66 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 11 May 2026 13:00:08 +0100 Subject: [PATCH 42/93] docs: add gateway streaming envelope changelog (#80299) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 472d1e75e14..3f5b68fefa3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Gateway: scope `sessions.resolve` sessionId and label store loads to the requested agent so large unrelated agent stores are not parsed for scoped lookups. Fixes #51264. (#79474) Thanks @samzong. +- Gateway: share serialized streaming event envelopes across eligible WebSocket and node subscribers while preserving per-client sequence numbers. (#80299) Thanks @samzong. - Browser: report Chrome MCP existing-session page readiness in browser status without letting status probes exceed the client timeout. Fixes #80268. (#80280) Thanks @ai-hpc. - Providers/self-hosted: read model-scoped llama.cpp runtime context from `/props.default_generation_settings.n_ctx` while keeping top-level `n_ctx` as a fallback, so session budgeting reflects the loaded context window. Fixes #73664. (#74057) Thanks @brokemac79. - Memory: reject symlinked directory components in configured extra memory paths before reading Markdown files. (#80331) Thanks @samzong. From b48baece87fcdaabd0e75291526051856c662cce Mon Sep 17 00:00:00 2001 From: Shakker Date: Mon, 11 May 2026 13:19:10 +0100 Subject: [PATCH 43/93] test: assert onboard option errors --- src/commands/onboard.test.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/commands/onboard.test.ts b/src/commands/onboard.test.ts index dde2bd464c8..f866d5e9c0b 100644 --- a/src/commands/onboard.test.ts +++ b/src/commands/onboard.test.ts @@ -1,5 +1,6 @@ import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { formatCliCommand } from "../cli/command-format.js"; import type { RuntimeEnv } from "../runtime.js"; import { onboardCommand, setupWizardCommand } from "./onboard.js"; @@ -66,10 +67,10 @@ describe("setupWizardCommand", () => { runtime, ); + expect(runtime.error).toHaveBeenCalledOnce(); expect(runtime.error).toHaveBeenCalledWith( - expect.stringContaining('Invalid --secret-input-mode. Use "plaintext" or "ref", or run '), + `Invalid --secret-input-mode. Use "plaintext" or "ref", or run ${formatCliCommand("openclaw onboard")} for the interactive setup.`, ); - expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("onboard")); expect(runtime.exit).toHaveBeenCalledWith(1); expect(mocks.runInteractiveSetup).not.toHaveBeenCalled(); expect(mocks.runNonInteractiveSetup).not.toHaveBeenCalled(); @@ -161,12 +162,10 @@ describe("setupWizardCommand", () => { runtime, ); + expect(runtime.error).toHaveBeenCalledOnce(); expect(runtime.error).toHaveBeenCalledWith( - expect.stringContaining( - 'Invalid --reset-scope. Use "config", "config+creds+sessions", or "full".', - ), + `Invalid --reset-scope. Use "config", "config+creds+sessions", or "full". Run ${formatCliCommand("openclaw onboard --reset --reset-scope config")} for a config-only reset.`, ); - expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("config-only reset")); expect(runtime.exit).toHaveBeenCalledWith(1); expect(mocks.handleReset).not.toHaveBeenCalled(); expect(mocks.runInteractiveSetup).not.toHaveBeenCalled(); From bf5202b05626f1f1c2ce3695d564ce9f98f3c078 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 11 May 2026 13:19:39 +0100 Subject: [PATCH 44/93] test: tighten hook mapping assertions --- src/gateway/hooks.test.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/gateway/hooks.test.ts b/src/gateway/hooks.test.ts index 3031b068ed8..e3e7f7e199e 100644 --- a/src/gateway/hooks.test.ts +++ b/src/gateway/hooks.test.ts @@ -454,13 +454,10 @@ describe("gateway hooks helpers", () => { }, } as OpenClawConfig); - expect(resolved.mappings).toMatchObject([ - { - action: "wake", - matchPath: "wake", - sessionKey: "hook:wake:{{payload.id}}", - }, - ]); + expect(resolved.mappings).toHaveLength(1); + expect(resolved.mappings[0]?.action).toBe("wake"); + expect(resolved.mappings[0]?.matchPath).toBe("wake"); + expect(resolved.mappings[0]?.sessionKey).toBe("hook:wake:{{payload.id}}"); expect(resolved.sessionPolicy.allowedSessionKeyPrefixes).toBeUndefined(); }); From 663206aac45f5025564d160b15a3b0bdd17bcdee Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Mon, 11 May 2026 17:49:45 +0530 Subject: [PATCH 45/93] ci(mantis): derive telegram proof refs from pr --- .../prompts/mantis-telegram-desktop-proof.md | 6 +- .../mantis-telegram-desktop-proof.yml | 203 +++++------------- ...is-telegram-desktop-proof-workflow.test.ts | 24 ++- 3 files changed, 73 insertions(+), 160 deletions(-) diff --git a/.github/codex/prompts/mantis-telegram-desktop-proof.md b/.github/codex/prompts/mantis-telegram-desktop-proof.md index e3c4ec321be..ec1a89d49f1 100644 --- a/.github/codex/prompts/mantis-telegram-desktop-proof.md +++ b/.github/codex/prompts/mantis-telegram-desktop-proof.md @@ -35,9 +35,7 @@ Required workflow: 1. Read `.agents/skills/telegram-crabbox-e2e-proof/SKILL.md`. 2. Inspect the PR with `gh pr view "$MANTIS_PR_NUMBER"` and - `gh pr diff "$MANTIS_PR_NUMBER"` when `MANTIS_PR_NUMBER` is set. If the run - came from workflow dispatch without a PR number, inspect - `BASELINE_SHA..CANDIDATE_SHA`. + `gh pr diff "$MANTIS_PR_NUMBER"`. 3. Decide what Telegram message, mock model response, command, callback, button, media, or sequence best proves the PR. Use `MANTIS_INSTRUCTIONS` as extra maintainer guidance, not as a replacement for reading the PR. @@ -45,7 +43,7 @@ Required workflow: `.artifacts/qa-e2e/mantis/telegram-desktop-proof-worktrees/baseline` and `.artifacts/qa-e2e/mantis/telegram-desktop-proof-worktrees/candidate`, then install and build each worktree with the repo's normal `pnpm` commands. - If `MANTIS_CANDIDATE_TRUST` is `maintainer-approved-fork-pr-head`, treat the + If `MANTIS_CANDIDATE_TRUST` is `fork-pr-head`, treat the candidate worktree as untrusted fork code: do not pass GitHub, OpenAI, Crabbox, Convex, or other workflow secrets into candidate install, build, or runtime commands. The candidate SUT may receive only the proof runner's diff --git a/.github/workflows/mantis-telegram-desktop-proof.yml b/.github/workflows/mantis-telegram-desktop-proof.yml index eb26cf7d62e..da6a0a01066 100644 --- a/.github/workflows/mantis-telegram-desktop-proof.yml +++ b/.github/workflows/mantis-telegram-desktop-proof.yml @@ -5,19 +5,9 @@ on: types: [created] workflow_dispatch: inputs: - baseline_ref: - description: Ref, tag, or SHA to capture as the before GIF - required: true - default: main - type: string - candidate_ref: - description: Ref, tag, or SHA to capture as the after GIF - required: true - default: main - type: string pr_number: - description: Optional PR number to receive the QA evidence comment - required: false + description: PR number to capture + required: true type: string instructions: description: Optional freeform proof instructions for the agent @@ -35,11 +25,6 @@ on: description: Optional existing Crabbox desktop lease id or slug to reuse required: false type: string - allow_fork_candidate: - description: Allow a fork PR head candidate when pr_number points at that PR - required: false - default: false - type: boolean permissions: contents: write @@ -47,7 +32,7 @@ permissions: pull-requests: write concurrency: - group: mantis-telegram-desktop-proof-${{ github.event.issue.number || inputs.pr_number || inputs.candidate_ref || github.run_id }}-${{ github.run_attempt }} + group: mantis-telegram-desktop-proof-${{ github.event.issue.number || inputs.pr_number || github.run_id }}-${{ github.run_attempt }} cancel-in-progress: false env: @@ -68,6 +53,7 @@ jobs: ( github.event_name == 'issue_comment' && github.event.issue.pull_request && + contains(github.event.issue.labels.*.name, 'mantis: telegram-visible-proof') && ( contains(github.event.comment.body, '@openclaw-mantis') || contains(github.event.comment.body, '/openclaw-mantis') @@ -100,7 +86,6 @@ jobs: needs: authorize_actor runs-on: ubuntu-24.04 outputs: - allow_fork_candidate: ${{ steps.resolve.outputs.allow_fork_candidate }} baseline_ref: ${{ steps.resolve.outputs.baseline_ref }} candidate_ref: ${{ steps.resolve.outputs.candidate_ref }} crabbox_provider: ${{ steps.resolve.outputs.crabbox_provider }} @@ -108,7 +93,6 @@ jobs: lease_id: ${{ steps.resolve.outputs.lease_id }} pr_number: ${{ steps.resolve.outputs.pr_number }} request_source: ${{ steps.resolve.outputs.request_source }} - should_run: ${{ steps.resolve.outputs.should_run }} steps: - name: Resolve refs and target PR id: resolve @@ -122,52 +106,11 @@ jobs: core.info(`${name}=${value ?? ""}`); } - if (eventName === "workflow_dispatch") { - const inputs = context.payload.inputs ?? {}; - setOutput("should_run", "true"); - setOutput( - "allow_fork_candidate", - String(inputs.allow_fork_candidate) === "true" ? "true" : "false", - ); - setOutput("baseline_ref", inputs.baseline_ref || "main"); - setOutput("candidate_ref", inputs.candidate_ref || "main"); - setOutput("pr_number", inputs.pr_number || ""); - setOutput("instructions", inputs.instructions || ""); - setOutput("crabbox_provider", inputs.crabbox_provider || "aws"); - setOutput("lease_id", inputs.crabbox_lease_id || ""); - setOutput("request_source", "workflow_dispatch"); - return; - } - - if (eventName !== "issue_comment") { - core.setFailed(`Unsupported event: ${eventName}`); - return; - } - - const issue = context.payload.issue; - const body = context.payload.comment?.body ?? ""; - if (!issue?.pull_request) { - core.setFailed("Mantis issue_comment trigger requires a pull request comment."); - return; - } - - const normalized = body.toLowerCase(); - const requested = - (normalized.includes("@openclaw-mantis") || normalized.includes("/openclaw-mantis")) && - normalized.includes("telegram") && - (normalized.includes("desktop") || normalized.includes("native")) && - normalized.includes("proof"); - if (!requested) { - core.notice("Comment mentioned Mantis but did not request Telegram desktop proof."); - setOutput("should_run", "false"); - setOutput("allow_fork_candidate", "false"); - setOutput("baseline_ref", ""); - setOutput("candidate_ref", ""); - setOutput("pr_number", ""); - setOutput("instructions", ""); - setOutput("crabbox_provider", ""); - setOutput("lease_id", ""); - setOutput("request_source", "unsupported_issue_comment"); + const inputs = context.payload.inputs ?? {}; + const prNumber = + eventName === "workflow_dispatch" ? inputs.pr_number : String(context.payload.issue?.number ?? ""); + if (!prNumber) { + core.setFailed("Mantis Telegram desktop proof requires a pull request."); return; } @@ -175,57 +118,35 @@ jobs: const { data: pr } = await github.rest.pulls.get({ owner, repo, - pull_number: issue.number, + pull_number: Number(prNumber), }); - let mergedBaseline = ""; - let mergedCandidate = ""; - if (pr.merged) { - const { data: commits } = await github.rest.pulls.listCommits({ - owner, - repo, - pull_number: issue.number, - per_page: 100, - }); - mergedCandidate = pr.merge_commit_sha || commits.at(-1)?.sha || ""; - mergedBaseline = mergedCandidate && commits.length > 0 ? `${mergedCandidate}~${commits.length}` : ""; - } - const baselineMatch = body.match(/(?:baseline|base)[\s:=]+([^\s`]+)/i); - const candidateMatch = body.match(/(?:candidate|head)[\s:=]+([^\s`]+)/i); - const providerMatch = body.match(/(?:provider|crabbox_provider)[\s:=]+([^\s`]+)/i); - const leaseMatch = body.match(/(?:lease|lease_id|crabbox_lease_id)[\s:=]+([^\s`]+)/i); - const provider = providerMatch?.[1] || "aws"; + const body = eventName === "workflow_dispatch" ? inputs.instructions || "" : context.payload.comment?.body || ""; + const provider = inputs.crabbox_provider || "aws"; if (!["aws", "hetzner"].includes(provider)) { core.setFailed(`Unsupported Crabbox provider for Mantis Telegram desktop proof: ${provider}`); return; } - const rawCandidate = candidateMatch?.[1]; - const candidate = - rawCandidate && !["head", "pr", "pr-head"].includes(rawCandidate.toLowerCase()) - ? rawCandidate - : mergedCandidate || pr.head.sha; - const allowForkCandidate = /\bfork[-_]ok\b/i.test(body); - setOutput("should_run", "true"); - setOutput("allow_fork_candidate", allowForkCandidate ? "true" : "false"); - setOutput("baseline_ref", baselineMatch?.[1] || mergedBaseline || "main"); - setOutput("candidate_ref", candidate); - setOutput("pr_number", String(issue.number)); + setOutput("baseline_ref", pr.base.sha); + setOutput("candidate_ref", pr.head.sha); + setOutput("pr_number", String(pr.number)); setOutput("instructions", body); setOutput("crabbox_provider", provider); - setOutput("lease_id", leaseMatch?.[1] || ""); - setOutput("request_source", "issue_comment"); + setOutput("lease_id", inputs.crabbox_lease_id || ""); + setOutput("request_source", eventName); - await github.rest.reactions.createForIssueComment({ - owner, - repo, - comment_id: context.payload.comment.id, - content: "eyes", - }).catch((error) => core.warning(`Could not add eyes reaction: ${error.message}`)); + if (eventName === "issue_comment") { + await github.rest.reactions.createForIssueComment({ + owner, + repo, + comment_id: context.payload.comment.id, + content: "eyes", + }).catch((error) => core.warning(`Could not add eyes reaction: ${error.message}`)); + } validate_refs: name: Validate selected refs needs: resolve_request - if: ${{ needs.resolve_request.outputs.should_run == 'true' }} runs-on: ubuntu-24.04 outputs: baseline_revision: ${{ steps.validate.outputs.baseline_revision }} @@ -241,7 +162,6 @@ jobs: - name: Validate refs are trusted id: validate env: - ALLOW_FORK_CANDIDATE: ${{ needs.resolve_request.outputs.allow_fork_candidate }} BASELINE_REF: ${{ needs.resolve_request.outputs.baseline_ref }} CANDIDATE_REF: ${{ needs.resolve_request.outputs.candidate_ref }} GH_TOKEN: ${{ github.token }} @@ -255,64 +175,48 @@ jobs: git fetch --no-tags origin "+refs/pull/${PR_NUMBER}/head:refs/remotes/origin/pr/${PR_NUMBER}" || true fi - validate_ref() { - local label="$1" + resolve_commit() { local input_ref="$2" local revision="" - local reason="" if ! revision="$(git rev-parse --verify "${input_ref}^{commit}" 2>/dev/null)"; then - echo "${label} ref '${input_ref}' is not available in the workflow checkout." >&2 + echo "$1 ref '${input_ref}' is not available in the workflow checkout." >&2 exit 1 fi - if git merge-base --is-ancestor "$revision" refs/remotes/origin/main; then - reason="main-ancestor" - elif git tag --points-at "$revision" | grep -Eq '^v'; then - reason="release-tag" - else - local pr_head_count - pr_head_count="$( - gh api \ - -H "Accept: application/vnd.github+json" \ - "repos/${GITHUB_REPOSITORY}/commits/${revision}/pulls" \ - --jq '[.[] | select(.state == "open" and .head.repo.full_name == "'"${GITHUB_REPOSITORY}"'" and .head.sha == "'"${revision}"'")] | length' - )" - if [[ "$pr_head_count" != "0" ]]; then - reason="open-pr-head" - elif [[ "$label" == "candidate" && "${ALLOW_FORK_CANDIDATE:-false}" == "true" && -n "${PR_NUMBER:-}" ]]; then - local fork_pr_head_count - fork_pr_head_count="$( - gh api \ - -H "Accept: application/vnd.github+json" \ - "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" \ - --jq 'if .state == "open" and .head.repo.full_name != "'"${GITHUB_REPOSITORY}"'" and .head.sha == "'"${revision}"'" then 1 else 0 end' - )" - if [[ "$fork_pr_head_count" == "1" ]]; then - reason="maintainer-approved-fork-pr-head" - fi - fi - fi - - if [[ -z "$reason" ]]; then - echo "${label} ref '${input_ref}' resolved to ${revision}, which is not trusted for this secret-bearing Mantis run. Add fork-ok only for a maintainer-approved fork PR head." >&2 - exit 1 - fi - printf '%s\t%s\n' "$revision" "$reason" + printf '%s\n' "$revision" } - baseline_revision="$(validate_ref baseline "$BASELINE_REF")" - baseline_trust="${baseline_revision#*$'\t'}" - baseline_revision="${baseline_revision%%$'\t'*}" - candidate_revision="$(validate_ref candidate "$CANDIDATE_REF")" - candidate_trust="${candidate_revision#*$'\t'}" - candidate_revision="${candidate_revision%%$'\t'*}" + baseline_revision="$(resolve_commit baseline "$BASELINE_REF")" + candidate_revision="$(resolve_commit candidate "$CANDIDATE_REF")" + if ! git merge-base --is-ancestor "$baseline_revision" refs/remotes/origin/main; then + echo "baseline ref '${BASELINE_REF}' resolved to ${baseline_revision}, which is not on main." >&2 + exit 1 + fi + pr_head="$( + gh api \ + -H "Accept: application/vnd.github+json" \ + "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" \ + --jq '{state, head_sha: .head.sha, head_repo: .head.repo.full_name}' + )" + pr_state="$(jq -r '.state' <<<"$pr_head")" + pr_head_sha="$(jq -r '.head_sha' <<<"$pr_head")" + pr_head_repo="$(jq -r '.head_repo' <<<"$pr_head")" + if [[ "$pr_state" != "open" || "$candidate_revision" != "$pr_head_sha" ]]; then + echo "candidate ref '${CANDIDATE_REF}' resolved to ${candidate_revision}, which is not the open PR head." >&2 + exit 1 + fi + candidate_trust="open-pr-head" + if [[ "$pr_head_repo" != "$GITHUB_REPOSITORY" ]]; then + candidate_trust="fork-pr-head" + fi + echo "baseline_revision=${baseline_revision}" >> "$GITHUB_OUTPUT" echo "candidate_revision=${candidate_revision}" >> "$GITHUB_OUTPUT" echo "candidate_trust=${candidate_trust}" >> "$GITHUB_OUTPUT" { echo "baseline: \`${BASELINE_REF}\`" echo "baseline SHA: \`${baseline_revision}\`" - echo "baseline trust: \`${baseline_trust}\`" + echo "baseline trust: \`main-ancestor\`" echo "candidate: \`${CANDIDATE_REF}\`" echo "candidate SHA: \`${candidate_revision}\`" echo "candidate trust: \`${candidate_trust}\`" @@ -321,7 +225,6 @@ jobs: run_telegram_desktop_proof: name: Run agentic native Telegram proof needs: [resolve_request, validate_refs] - if: ${{ needs.resolve_request.outputs.should_run == 'true' }} runs-on: blacksmith-16vcpu-ubuntu-2404 timeout-minutes: 360 environment: qa-live-shared diff --git a/test/scripts/mantis-telegram-desktop-proof-workflow.test.ts b/test/scripts/mantis-telegram-desktop-proof-workflow.test.ts index 56b74551acf..0814d7d328c 100644 --- a/test/scripts/mantis-telegram-desktop-proof-workflow.test.ts +++ b/test/scripts/mantis-telegram-desktop-proof-workflow.test.ts @@ -62,6 +62,7 @@ describe("Mantis Telegram Desktop proof workflow", () => { const workflow = readFileSync(WORKFLOW, "utf8"); expect(workflow).toContain("@openclaw-mantis"); expect(workflow).toContain("/openclaw-mantis"); + expect(workflow).toContain("mantis: telegram-visible-proof"); expect(workflow).not.toContain("@Mantis"); expect(workflow).not.toContain("@mantis"); expect(workflow).not.toContain('"/mantis"'); @@ -114,13 +115,23 @@ describe("Mantis Telegram Desktop proof workflow", () => { expect(prompt).toContain("do not run\n `pnpm qa:telegram-user:crabbox` directly"); }); - it("requires explicit maintainer fork approval before accepting fork PR heads", () => { + it("derives refs from the PR instead of parsing comment prose", () => { const workflowText = readFileSync(WORKFLOW, "utf8"); - expect(workflowText).toContain("@openclaw-mantis"); - expect(workflowText).toContain("fork[-_]ok"); - expect(workflowText).toContain("ALLOW_FORK_CANDIDATE"); - expect(workflowText).toContain("maintainer-approved-fork-pr-head"); - expect(workflowText).toContain(".head.repo.full_name !="); + expect(workflowText).toContain('setOutput("baseline_ref", pr.base.sha)'); + expect(workflowText).toContain('setOutput("candidate_ref", pr.head.sha)'); + expect(workflowText).not.toContain("body.match"); + expect(workflowText).not.toContain("baselineMatch"); + expect(workflowText).not.toContain("candidateMatch"); + expect(workflowText).not.toContain("leaseMatch"); + expect(workflowText).not.toContain("fork-ok"); + expect(workflowText).not.toContain("allow_fork_candidate"); + }); + + it("trusts the open PR head and marks fork heads for sandboxed handling", () => { + const workflowText = readFileSync(WORKFLOW, "utf8"); + expect(workflowText).toContain("repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}"); + expect(workflowText).toContain('candidate_trust="fork-pr-head"'); + expect(workflowText).toContain('pr_head_repo" != "$GITHUB_REPOSITORY"'); const agent = workflowStep("Run Codex Mantis Telegram agent"); expect(agent.env?.MANTIS_CANDIDATE_TRUST).toBe( @@ -129,6 +140,7 @@ describe("Mantis Telegram Desktop proof workflow", () => { const prompt = readFileSync(PROMPT, "utf8"); expect(prompt).toContain("MANTIS_CANDIDATE_TRUST"); + expect(prompt).toContain("fork-pr-head"); expect(prompt).toContain("untrusted fork code"); }); From 0d8d350d16a56493346ff65ef6305bbb724f9f39 Mon Sep 17 00:00:00 2001 From: Shakker Date: Mon, 11 May 2026 13:20:46 +0100 Subject: [PATCH 46/93] test: pin gateway doctor notes --- .../doctor-gateway-daemon-flow.test.ts | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/src/commands/doctor-gateway-daemon-flow.test.ts b/src/commands/doctor-gateway-daemon-flow.test.ts index 0f1f45121e0..7136da2275c 100644 --- a/src/commands/doctor-gateway-daemon-flow.test.ts +++ b/src/commands/doctor-gateway-daemon-flow.test.ts @@ -1,10 +1,14 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { formatCliCommand } from "../cli/command-format.js"; import type { ExtraGatewayService } from "../daemon/inspect.js"; import * as launchd from "../daemon/launchd.js"; import type { GatewayRestartHandoff } from "../infra/restart-handoff.js"; import { withEnvAsync } from "../test-utils/env.js"; import { createDoctorPrompter } from "./doctor-prompter.js"; -import { EXTERNAL_SERVICE_REPAIR_NOTE } from "./doctor-service-repair-policy.js"; +import { + EXTERNAL_SERVICE_REPAIR_NOTE, + SERVICE_REPAIR_POLICY_ENV, +} from "./doctor-service-repair-policy.js"; const service = vi.hoisted(() => ({ isLoaded: vi.fn(), @@ -164,6 +168,7 @@ describe("maybeRepairGatewayDaemon", () => { }); afterEach(() => { + vi.useRealTimers(); if (originalPlatformDescriptor) { Object.defineProperty(process, "platform", originalPlatformDescriptor); } @@ -266,6 +271,8 @@ describe("maybeRepairGatewayDaemon", () => { }); it("reports recent restart handoffs during deep doctor", async () => { + vi.useFakeTimers(); + vi.setSystemTime(40_000); setPlatform("linux"); service.readCommand.mockResolvedValueOnce({ programArguments: ["/bin/node", "cli", "gateway"], @@ -306,11 +313,7 @@ describe("maybeRepairGatewayDaemon", () => { expect(handoffEnv?.OPENCLAW_STATE_DIR).toBe("/tmp/openclaw-service"); expect(handoffEnv?.OPENCLAW_CONFIG_PATH).toBe("/tmp/openclaw-service/openclaw.json"); expect(note).toHaveBeenCalledWith( - expect.stringContaining("Recent restart handoff: full-process via systemd"), - "Gateway", - ); - expect(note).toHaveBeenCalledWith( - expect.stringContaining("reason=plugin source changed"), + "Recent restart handoff: full-process via systemd; source=plugin-change; reason=plugin source changed; pid=12345; age=30s; expiresIn=30s", "Gateway", ); }); @@ -382,7 +385,7 @@ describe("maybeRepairGatewayDaemon", () => { expect(service.install).not.toHaveBeenCalled(); expect(service.restart).not.toHaveBeenCalled(); expect(note).toHaveBeenCalledWith( - expect.stringContaining("openclaw gateway install"), + `Run ${formatCliCommand("openclaw gateway install")} when you want to install the gateway service.`, "Gateway", ); }); @@ -428,7 +431,13 @@ describe("maybeRepairGatewayDaemon", () => { expect(service.install).not.toHaveBeenCalled(); expect(service.restart).not.toHaveBeenCalled(); expect(note).toHaveBeenCalledWith( - expect.stringContaining("System-level OpenClaw gateway service detected"), + [ + "System-level OpenClaw gateway service detected while the user gateway service is not installed.", + "- openclaw-gateway.service (unit: /etc/systemd/system/openclaw-gateway.service)", + "OpenClaw will not install a second user-level gateway service automatically.", + "Run `openclaw gateway status --deep` or `openclaw doctor --deep` to inspect duplicate services.", + `Set ${SERVICE_REPAIR_POLICY_ENV}=external if a system supervisor owns the gateway lifecycle.`, + ].join("\n"), "Gateway", ); }); From 696a98871d383f064bcafb7a29f1b029f56f4e33 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 11 May 2026 13:21:26 +0100 Subject: [PATCH 47/93] test: tighten gateway call assertions --- src/gateway/call.test.ts | 37 ++++++++++++++++++++++--------------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/src/gateway/call.test.ts b/src/gateway/call.test.ts index 931cbe8dda3..23b3eb08864 100644 --- a/src/gateway/call.test.ts +++ b/src/gateway/call.test.ts @@ -894,12 +894,16 @@ describe("callGateway error details", () => { expect(err?.message).toContain("Source: local loopback"); expect(err?.message).toContain("Bind: loopback"); expect(isGatewayTransportError(err)).toBe(true); - expect(err).toMatchObject({ - name: "GatewayTransportError", - kind: "closed", - code: 1006, - reason: "no close reason", - }); + const transportError = err as { + name?: string; + kind?: string; + code?: number; + reason?: string; + }; + expect(transportError.name).toBe("GatewayTransportError"); + expect(transportError.kind).toBe("closed"); + expect(transportError.code).toBe(1006); + expect(transportError.reason).toBe("no close reason"); }); it("keeps the request alive through internally retried startup-unavailable handshakes", async () => { @@ -944,11 +948,10 @@ describe("callGateway error details", () => { await promise; expect(isGatewayTransportError(err)).toBe(true); - expect(err).toMatchObject({ - name: "GatewayTransportError", - kind: "timeout", - timeoutMs: 5, - }); + const transportError = err as { name?: string; kind?: string; timeoutMs?: number }; + expect(transportError.name).toBe("GatewayTransportError"); + expect(transportError.kind).toBe("timeout"); + expect(transportError.timeoutMs).toBe(5); }); it("charges event-loop readiness against the wrapper timeout", async () => { @@ -985,11 +988,15 @@ describe("callGateway error details", () => { aborted: false, }; - await expect(callGateway({ method: "health", timeoutMs: 5 })).rejects.toMatchObject({ - name: "GatewayTransportError", - kind: "timeout", - timeoutMs: 5, + let err: unknown; + await callGateway({ method: "health", timeoutMs: 5 }).catch((caught) => { + err = caught; }); + expect(isGatewayTransportError(err)).toBe(true); + const transportError = err as { name?: string; kind?: string; timeoutMs?: number }; + expect(transportError.name).toBe("GatewayTransportError"); + expect(transportError.kind).toBe("timeout"); + expect(transportError.timeoutMs).toBe(5); expect(eventLoopReadyState.calls).toHaveLength(1); expect(eventLoopReadyState.calls[0]?.maxWaitMs).toBe(5); expect(lastClientOptions?.url).toBe("ws://127.0.0.1:18789"); From 0362b758243096b7ed345dbae342ff2491712968 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 11 May 2026 13:17:56 +0100 Subject: [PATCH 48/93] feat(discord): add voice channel allowlist --- CHANGELOG.md | 1 + docs/.generated/config-baseline.sha256 | 6 +- docs/channels/discord.md | 7 + extensions/discord/src/config-schema.test.ts | 19 +++ extensions/discord/src/config-ui-hints.ts | 4 + extensions/discord/src/internal/listeners.ts | 6 + .../discord/src/monitor/provider.test.ts | 2 + extensions/discord/src/monitor/provider.ts | 9 +- .../discord/src/voice/manager.e2e.test.ts | 70 +++++++++ .../src/voice/manager.ready-listener.test.ts | 19 ++- .../discord/src/voice/manager.runtime.ts | 3 + extensions/discord/src/voice/manager.ts | 143 +++++++++++++++++- .../image-generation-provider.test.ts | 2 +- src/config/types.discord.ts | 9 ++ src/config/zod-schema.providers-core.ts | 8 + 15 files changed, 299 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f5b68fefa3..f31040b7e65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,7 @@ Docs: https://docs.openclaw.ai - Talk: add `talk.realtime.instructions` so operators can append realtime voice style instructions while preserving OpenClaw's built-in agent-consult guidance. (#79081) Thanks @VACInc. - Discord/voice: default test and source installs to the pure-JS `opusscript` decoder by ignoring optional native `@discordjs/opus` builds, avoiding slow native addon compiles outside dedicated voice-performance lanes. - Discord/voice: add an opt-in native `@discordjs/opus` install script and decoder preference for live voice-performance lanes without charging unrelated Docker/tests for native addon builds. +- Discord/voice: add `voice.allowedChannels` to restrict voice joins and bot voice-state moves to configured channels while preserving open voice behavior when unset. - Gateway/skills: add an opt-in private skill archive upload install path gated by `skills.install.allowUploadedArchives`, so trusted Gateway clients can stage and install zip-backed skills only when operators explicitly enable the code-install surface. (#74430) Thanks @samzong. - Codex app-server: enable Codex native code-mode-only for harness threads so deferred OpenClaw dynamic tools run through Codex's own searchable code execution surface instead of a PI-style wrapper. - Dependencies: refresh workspace pins and patch targets, including ACPX `@agentclientprotocol/claude-agent-acp` `0.33.1`, Codex ACP `0.14.0`, Baileys `7.0.0-rc10`, Google GenAI `2.0.1`, OpenAI `6.37.0`, AWS SDK `3.1045.0`, Kysely `0.29.0`, Tlon skill `0.3.6`, Aimock `1.19.5`, and tsdown `0.22.0`. diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index 15b5640518d..e6b573bf257 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -ebb8fa25af8be3a6c42a8bbf505f119819ee49b3c28a317ae04a244f740be381 config-baseline.json -647f7a12deed46b4a962848a17ed5666d24fc526b777feab62cf331d84ce957d config-baseline.core.json -f90c9d96ccc4c0c703d6c489f86d89fde208cd7f697b396aeee96ff3ee087956 config-baseline.channel.json +da3703ab610b5b06d2eefb276fd97a2266a8dd325b6715bf414824cc50ca694a config-baseline.json +379778d6f288ee2906411e2c9a5efdd129a541e2ec43833134f421403e1cd352 config-baseline.core.json +222d0338d6ed290870cac70cdf5e390bc1bb60c4462e46f847003bafe25c5a6e config-baseline.channel.json 18f71e9d4a62fe68fbd5bf18d5833a4e380fc705ad641769e1cf05794286344c config-baseline.plugin.json diff --git a/docs/channels/discord.md b/docs/channels/discord.md index df7add5bef1..0b78a2dce1f 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -1179,6 +1179,12 @@ Auto-join example: channelId: "234567890123456789", }, ], + allowedChannels: [ + { + guildId: "123456789012345678", + channelId: "234567890123456789", + }, + ], daveEncryption: true, decryptionFailureTolerance: 24, connectTimeoutMs: 30000, @@ -1212,6 +1218,7 @@ Notes: - Discord voice is opt-in for text-only configs; set `channels.discord.voice.enabled=true` (or keep an existing `channels.discord.voice` block) to enable `/vc` commands, the voice runtime, and the `GuildVoiceStates` gateway intent. - `channels.discord.intents.voiceStates` can explicitly override voice-state intent subscription. Leave it unset for the intent to follow effective voice enablement. - If `voice.autoJoin` has multiple entries for the same guild, OpenClaw joins the last configured channel for that guild. +- `voice.allowedChannels` is an optional residency allowlist. Leave it unset to allow `/vc join` into any authorized Discord voice channel. When set, `/vc join`, startup auto-join, and bot voice-state moves are restricted to the listed `{ guildId, channelId }` entries. Set it to an empty array to deny all Discord voice joins. If Discord moves the bot outside the allowlist, OpenClaw leaves that channel and rejoins the configured auto-join target when one is available. - `voice.daveEncryption` and `voice.decryptionFailureTolerance` pass through to `@discordjs/voice` join options. - `@discordjs/voice` defaults are `daveEncryption=true` and `decryptionFailureTolerance=24` if unset. - OpenClaw defaults to the pure-JS `opusscript` decoder for Discord voice receive. The optional native `@discordjs/opus` package is ignored by the repo pnpm install policy so normal installs, Docker lanes, and unrelated tests do not compile a native addon. Dedicated voice-performance hosts can opt in with `OPENCLAW_DISCORD_OPUS_DECODER=native` after installing the native addon. diff --git a/extensions/discord/src/config-schema.test.ts b/extensions/discord/src/config-schema.test.ts index 98344dbde00..d559d4a95be 100644 --- a/extensions/discord/src/config-schema.test.ts +++ b/extensions/discord/src/config-schema.test.ts @@ -226,6 +226,25 @@ describe("discord config schema", () => { expect(cfg.voice?.captureSilenceGraceMs).toBe(3_500); }); + it("accepts Discord voice allowed channels", () => { + const cfg = expectValidDiscordConfig({ + voice: { + allowedChannels: [{ guildId: "123", channelId: "456" }], + }, + }); + + expect(cfg.voice?.allowedChannels).toEqual([{ guildId: "123", channelId: "456" }]); + }); + + it("rejects invalid Discord voice allowed channels", () => { + for (const voice of [ + { allowedChannels: [{ guildId: "", channelId: "456" }] }, + { allowedChannels: [{ guildId: "123", channelId: "" }] }, + ]) { + expectInvalidDiscordConfig({ voice }); + } + }); + it("rejects invalid Discord voice timing overrides", () => { for (const voice of [ { connectTimeoutMs: 0 }, diff --git a/extensions/discord/src/config-ui-hints.ts b/extensions/discord/src/config-ui-hints.ts index 4a0adf488e7..39703b6feeb 100644 --- a/extensions/discord/src/config-ui-hints.ts +++ b/extensions/discord/src/config-ui-hints.ts @@ -230,6 +230,10 @@ export const discordChannelConfigUiHints = { label: "Discord Voice Auto-Join", help: "Voice channels to auto-join on startup (list of guildId/channelId entries).", }, + "voice.allowedChannels": { + label: "Discord Voice Allowed Channels", + help: "Optional voice channel residency allowlist. When set, /vc join, auto-join, and bot voice-state moves are restricted to these guildId/channelId entries. Leave unset to allow any voice channel.", + }, "voice.daveEncryption": { label: "Discord Voice DAVE Encryption", help: "Toggle DAVE end-to-end encryption for Discord voice joins (default: true in @discordjs/voice; Discord may require this).", diff --git a/extensions/discord/src/internal/listeners.ts b/extensions/discord/src/internal/listeners.ts index 4c4c576178b..d2297843d1a 100644 --- a/extensions/discord/src/internal/listeners.ts +++ b/extensions/discord/src/internal/listeners.ts @@ -2,6 +2,7 @@ import { GatewayDispatchEvents, type APIMessage, type APIReaction, + type APIVoiceState, type GatewayPresenceUpdateDispatchData, type GatewayThreadUpdateDispatchData, } from "discord-api-types/v10"; @@ -76,6 +77,11 @@ export abstract class PresenceUpdateListener extends BaseListener { ): Promise | void; } +export abstract class VoiceStateUpdateListener extends BaseListener { + readonly type = GatewayDispatchEvents.VoiceStateUpdate; + abstract override handle(data: APIVoiceState, client: Client): Promise | void; +} + export abstract class ThreadUpdateListener extends BaseListener { readonly type = GatewayDispatchEvents.ThreadUpdate; abstract override handle( diff --git a/extensions/discord/src/monitor/provider.test.ts b/extensions/discord/src/monitor/provider.test.ts index 8af0e7c1d42..ca1955e54ff 100644 --- a/extensions/discord/src/monitor/provider.test.ts +++ b/extensions/discord/src/monitor/provider.test.ts @@ -131,6 +131,7 @@ vi.mock("../voice/manager.runtime.js", () => { DiscordVoiceManager: function DiscordVoiceManager() {}, DiscordVoiceReadyListener: function DiscordVoiceReadyListener() {}, DiscordVoiceResumedListener: function DiscordVoiceResumedListener() {}, + DiscordVoiceStateUpdateListener: function DiscordVoiceStateUpdateListener() {}, }; }); describe("monitorDiscordProvider", () => { @@ -263,6 +264,7 @@ describe("monitorDiscordProvider", () => { DiscordVoiceManager: function DiscordVoiceManager() {}, DiscordVoiceReadyListener: function DiscordVoiceReadyListener() {}, DiscordVoiceResumedListener: function DiscordVoiceResumedListener() {}, + DiscordVoiceStateUpdateListener: function DiscordVoiceStateUpdateListener() {}, } as never; }); providerTesting.setLoadDiscordProviderSessionRuntime( diff --git a/extensions/discord/src/monitor/provider.ts b/extensions/discord/src/monitor/provider.ts index 2bfbf63f126..9a3edcc9ff0 100644 --- a/extensions/discord/src/monitor/provider.ts +++ b/extensions/discord/src/monitor/provider.ts @@ -497,8 +497,12 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { let voiceManager: DiscordVoiceManager | null = null; if (voiceEnabled) { - const { DiscordVoiceManager, DiscordVoiceReadyListener, DiscordVoiceResumedListener } = - await loadDiscordVoiceRuntime(); + const { + DiscordVoiceManager, + DiscordVoiceReadyListener, + DiscordVoiceResumedListener, + DiscordVoiceStateUpdateListener, + } = await loadDiscordVoiceRuntime(); voiceManager = new DiscordVoiceManager({ client, cfg, @@ -510,6 +514,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { voiceManagerRef.current = voiceManager; registerDiscordListener(client.listeners, new DiscordVoiceReadyListener(voiceManager)); registerDiscordListener(client.listeners, new DiscordVoiceResumedListener(voiceManager)); + registerDiscordListener(client.listeners, new DiscordVoiceStateUpdateListener(voiceManager)); } const messageHandler = discordProviderSessionRuntime.createDiscordMessageHandler({ diff --git a/extensions/discord/src/voice/manager.e2e.test.ts b/extensions/discord/src/voice/manager.e2e.test.ts index 79bbbf17d44..a04665b5f50 100644 --- a/extensions/discord/src/voice/manager.e2e.test.ts +++ b/extensions/discord/src/voice/manager.e2e.test.ts @@ -560,6 +560,76 @@ describe("DiscordVoiceManager", () => { expectConnectedStatus(manager, "1002"); }); + it("rejects joins outside configured allowed voice channels", async () => { + const manager = createManager({ + voice: { + enabled: true, + mode: "stt-tts", + allowedChannels: [{ guildId: "g1", channelId: "1001" }], + }, + }); + + const result = await manager.join({ guildId: "g1", channelId: "1002" }); + + expect(result.ok).toBe(false); + expect(result.message).toBe( + "<#1002> is not allowed by channels.discord.voice.allowedChannels.", + ); + expect(joinVoiceChannelMock).not.toHaveBeenCalled(); + }); + + it("allows joins inside configured allowed voice channels", async () => { + const manager = createManager({ + voice: { + enabled: true, + mode: "stt-tts", + allowedChannels: [{ guildId: "g1", channelId: "1001" }], + }, + }); + + const result = await manager.join({ guildId: "g1", channelId: "1001" }); + + expect(result.ok).toBe(true); + expectConnectedStatus(manager, "1001"); + }); + + it("treats an empty allowed voice channel list as deny-all", async () => { + const manager = createManager({ + voice: { + enabled: true, + mode: "stt-tts", + allowedChannels: [], + }, + }); + + const result = await manager.join({ guildId: "g1", channelId: "1001" }); + + expect(result.ok).toBe(false); + expect(joinVoiceChannelMock).not.toHaveBeenCalled(); + }); + + it("leaves and rejoins the configured target when Discord moves the bot outside allowed voice channels", async () => { + const manager = createManager({ + voice: { + enabled: true, + mode: "stt-tts", + autoJoin: [{ guildId: "g1", channelId: "1001" }], + allowedChannels: [{ guildId: "g1", channelId: "1001" }], + }, + }); + manager.setBotUserId("bot-user"); + await manager.join({ guildId: "g1", channelId: "1001" }); + + await manager.handleVoiceStateUpdate({ + guild_id: "g1", + user_id: "bot-user", + channel_id: "1002", + } as never); + + expect(joinVoiceChannelMock).toHaveBeenCalledTimes(2); + expectConnectedStatus(manager, "1001"); + }); + it("skips destroying stale tracked voice connections that are already destroyed", async () => { const staleConnection = createConnectionMock(); staleConnection.state.status = "destroyed"; diff --git a/extensions/discord/src/voice/manager.ready-listener.test.ts b/extensions/discord/src/voice/manager.ready-listener.test.ts index 344ca85e802..3bcd6f64791 100644 --- a/extensions/discord/src/voice/manager.ready-listener.test.ts +++ b/extensions/discord/src/voice/manager.ready-listener.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it, vi } from "vitest"; import { GatewayDispatchEvents } from "../internal/discord.js"; -import { DiscordVoiceReadyListener, DiscordVoiceResumedListener } from "./manager.js"; +import { + DiscordVoiceReadyListener, + DiscordVoiceResumedListener, + DiscordVoiceStateUpdateListener, +} from "./manager.js"; describe("DiscordVoiceReadyListener", () => { it("starts auto-join without blocking the ready listener", async () => { @@ -34,4 +38,17 @@ describe("DiscordVoiceReadyListener", () => { expect(listener.type).toBe(GatewayDispatchEvents.Resumed); expect(autoJoin).toHaveBeenCalledTimes(1); }); + + it("forwards bot voice state updates to the voice manager", async () => { + const handleVoiceStateUpdate = vi.fn(async () => {}); + const listener = new DiscordVoiceStateUpdateListener({ + handleVoiceStateUpdate, + } as unknown as ConstructorParameters[0]); + const payload = { guild_id: "g1", user_id: "bot", channel_id: "1001" }; + + await expect(listener.handle(payload as never, {} as never)).resolves.toBeUndefined(); + + expect(listener.type).toBe(GatewayDispatchEvents.VoiceStateUpdate); + expect(handleVoiceStateUpdate).toHaveBeenCalledWith(payload); + }); }); diff --git a/extensions/discord/src/voice/manager.runtime.ts b/extensions/discord/src/voice/manager.runtime.ts index 84d73726160..326027ec363 100644 --- a/extensions/discord/src/voice/manager.runtime.ts +++ b/extensions/discord/src/voice/manager.runtime.ts @@ -2,6 +2,7 @@ import { DiscordVoiceManager as DiscordVoiceManagerImpl, DiscordVoiceReadyListener as DiscordVoiceReadyListenerImpl, DiscordVoiceResumedListener as DiscordVoiceResumedListenerImpl, + DiscordVoiceStateUpdateListener as DiscordVoiceStateUpdateListenerImpl, } from "./manager.js"; export class DiscordVoiceManager extends DiscordVoiceManagerImpl {} @@ -9,3 +10,5 @@ export class DiscordVoiceManager extends DiscordVoiceManagerImpl {} export class DiscordVoiceReadyListener extends DiscordVoiceReadyListenerImpl {} export class DiscordVoiceResumedListener extends DiscordVoiceResumedListenerImpl {} + +export class DiscordVoiceStateUpdateListener extends DiscordVoiceStateUpdateListenerImpl {} diff --git a/extensions/discord/src/voice/manager.ts b/extensions/discord/src/voice/manager.ts index 745cd3fad66..1c8ddfd9aa7 100644 --- a/extensions/discord/src/voice/manager.ts +++ b/extensions/discord/src/voice/manager.ts @@ -5,7 +5,13 @@ import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { formatErrorMessage } from "openclaw/plugin-sdk/ssrf-runtime"; import { resolveDiscordAccountAllowFrom } from "../accounts.js"; -import { type Client, ReadyListener, ResumedListener } from "../internal/discord.js"; +import { + type APIVoiceState, + type Client, + ReadyListener, + ResumedListener, + VoiceStateUpdateListener, +} from "../internal/discord.js"; import type { VoicePlugin } from "../internal/voice.js"; import { formatMention } from "../mentions.js"; import { parseDiscordTarget } from "../target-parsing.js"; @@ -61,6 +67,10 @@ const VOICE_LOG_PREVIEW_CHARS = 500; type DiscordVoiceSdk = ReturnType; type DiscordVoiceConnection = ReturnType; +type VoiceChannelResidency = { + guildId: string; + channelId: string; +}; function formatVoiceLogPreview(text: string): string { const oneLine = text.replace(/\s+/g, " ").trim(); @@ -98,6 +108,33 @@ function destroyVoiceConnectionSafely(params: { } } +function normalizeVoiceChannelResidencies( + entries: Array<{ guildId?: string; channelId?: string }> | undefined, +): VoiceChannelResidency[] { + const normalized: VoiceChannelResidency[] = []; + for (const entry of entries ?? []) { + const guildId = entry.guildId?.trim(); + const channelId = entry.channelId?.trim(); + if (guildId && channelId) { + normalized.push({ guildId, channelId }); + } + } + return normalized; +} + +function isVoiceChannelAllowed(params: { + allowedChannels: VoiceChannelResidency[] | null; + guildId: string; + channelId: string; +}): boolean { + return ( + params.allowedChannels === null || + params.allowedChannels.some( + (entry) => entry.guildId === params.guildId && entry.channelId === params.channelId, + ) + ); +} + function startAutoJoin(manager: Pick) { void manager .autoJoin() @@ -160,6 +197,7 @@ export class DiscordVoiceManager { private autoJoinTask: Promise | null = null; private readonly ownerAllowFrom?: string[]; private readonly speakerContext: DiscordVoiceSpeakerContextResolver; + private readonly allowedChannels: VoiceChannelResidency[] | null; constructor( private params: { @@ -178,6 +216,10 @@ export class DiscordVoiceManager { params.discordConfig.allowFrom ?? params.discordConfig.dm?.allowFrom ?? []; + this.allowedChannels = + params.discordConfig.voice?.allowedChannels === undefined + ? null + : normalizeVoiceChannelResidencies(params.discordConfig.voice.allowedChannels); this.speakerContext = new DiscordVoiceSpeakerContextResolver({ client: params.client, ownerAllowFrom: this.ownerAllowFrom, @@ -229,10 +271,15 @@ export class DiscordVoiceManager { for (const entry of entriesByGuild.values()) { logVoiceVerbose(`autoJoin: joining guild ${entry.guildId} channel ${entry.channelId}`); - await this.join({ + const result = await this.join({ guildId: entry.guildId, channelId: entry.channelId, }); + if (!result.ok) { + logger.warn( + `discord voice: autoJoin skipped guild=${entry.guildId} channel=${entry.channelId}: ${result.message}`, + ); + } } })().finally(() => { this.autoJoinTask = null; @@ -249,6 +296,14 @@ export class DiscordVoiceManager { })); } + isAllowedVoiceChannel(params: { guildId: string; channelId: string }): boolean { + return isVoiceChannelAllowed({ + allowedChannels: this.allowedChannels, + guildId: params.guildId.trim(), + channelId: params.channelId.trim(), + }); + } + async join(params: { guildId: string; channelId: string }): Promise { if (!this.voiceEnabled) { return { @@ -261,6 +316,17 @@ export class DiscordVoiceManager { if (!guildId || !channelId) { return { ok: false, message: "Missing guildId or channelId." }; } + if (!this.isAllowedVoiceChannel({ guildId, channelId })) { + logger.warn( + `discord voice: join rejected for non-allowed channel guild=${guildId} channel=${channelId}`, + ); + return { + ok: false, + message: `${formatMention({ channelId })} is not allowed by channels.discord.voice.allowedChannels.`, + guildId, + channelId, + }; + } logVoiceVerbose(`join requested: guild ${guildId} channel ${channelId}`); const existing = this.sessions.get(guildId); @@ -590,6 +656,53 @@ export class DiscordVoiceManager { }; } + async handleVoiceStateUpdate(data: APIVoiceState): Promise { + if (!this.botUserId || data.user_id !== this.botUserId) { + return; + } + const guildId = data.guild_id?.trim(); + const channelId = data.channel_id?.trim(); + if (!guildId || !channelId) { + return; + } + + const existing = this.sessions.get(guildId); + if (this.isAllowedVoiceChannel({ guildId, channelId })) { + if (existing && existing.channelId !== channelId) { + logger.warn( + `discord voice: bot moved to allowed channel guild=${guildId} from=${existing.channelId} to=${channelId}; rebuilding voice session`, + ); + await this.join({ guildId, channelId }); + } + return; + } + + logger.warn( + `discord voice: bot moved to non-allowed channel guild=${guildId} channel=${channelId}; leaving`, + ); + if (existing) { + await this.leave({ guildId }); + } else { + const voiceSdk = loadDiscordVoiceSdk(); + const connection = voiceSdk.getVoiceConnection(guildId); + if (connection) { + destroyVoiceConnectionSafely({ + connection, + voiceSdk, + reason: `non-allowed voice state guild ${guildId} channel ${channelId}`, + }); + } + } + + const target = this.resolveVoiceResidencyTarget(guildId); + if (target) { + logger.warn( + `discord voice: rejoining allowed voice channel guild=${guildId} channel=${target.channelId}`, + ); + await this.join(target); + } + } + async destroy(): Promise { for (const entry of this.sessions.values()) { entry.stop(); @@ -597,6 +710,22 @@ export class DiscordVoiceManager { this.sessions.clear(); } + private resolveVoiceResidencyTarget(guildId: string): VoiceChannelResidency | null { + const autoJoinTarget = normalizeVoiceChannelResidencies( + this.params.discordConfig.voice?.autoJoin, + ) + .toReversed() + .find((entry) => entry.guildId === guildId); + if (autoJoinTarget && this.isAllowedVoiceChannel(autoJoinTarget)) { + return autoJoinTarget; + } + if (this.allowedChannels === null) { + return null; + } + const guildAllowed = this.allowedChannels.filter((entry) => entry.guildId === guildId); + return guildAllowed.length === 1 ? guildAllowed[0] : null; + } + private enqueueProcessing(entry: VoiceSessionEntry, task: () => Promise) { entry.processingQueue = entry.processingQueue .then(task) @@ -972,3 +1101,13 @@ export class DiscordVoiceResumedListener extends ResumedListener { startAutoJoin(this.manager); } } + +export class DiscordVoiceStateUpdateListener extends VoiceStateUpdateListener { + constructor(private manager: DiscordVoiceManager) { + super(); + } + + async handle(data: APIVoiceState, _client: Client): Promise { + await this.manager.handleVoiceStateUpdate(data); + } +} diff --git a/extensions/openrouter/image-generation-provider.test.ts b/extensions/openrouter/image-generation-provider.test.ts index a92f080428a..77c10970a98 100644 --- a/extensions/openrouter/image-generation-provider.test.ts +++ b/extensions/openrouter/image-generation-provider.test.ts @@ -121,7 +121,7 @@ describe("openrouter image generation provider", () => { }); expect(resolveApiKeyForProviderMock).toHaveBeenCalledOnce(); - expect(resolveApiKeyForProviderMock.mock.calls[0]?.[0]).toEqual({ + expect(resolveApiKeyForProviderMock).toHaveBeenCalledWith({ provider: "openrouter", cfg: { models: { diff --git a/src/config/types.discord.ts b/src/config/types.discord.ts index ca124bd2a83..a33529c398b 100644 --- a/src/config/types.discord.ts +++ b/src/config/types.discord.ts @@ -129,6 +129,13 @@ export type DiscordVoiceAutoJoinConfig = { channelId: string; }; +export type DiscordVoiceAllowedChannelConfig = { + /** Guild ID that owns the voice channel. */ + guildId: string; + /** Voice channel ID allowed for realtime voice sessions. */ + channelId: string; +}; + export type DiscordVoiceMode = "stt-tts" | "agent-proxy" | "bidi"; export type DiscordVoiceRealtimeConsultPolicy = "auto" | "always"; @@ -178,6 +185,8 @@ export type DiscordVoiceConfig = { realtime?: DiscordVoiceRealtimeConfig; /** Voice channels to auto-join on startup. */ autoJoin?: DiscordVoiceAutoJoinConfig[]; + /** Voice channels the bot is allowed to join or remain in. Unset means any voice channel is allowed. */ + allowedChannels?: DiscordVoiceAllowedChannelConfig[]; /** Enable/disable DAVE end-to-end encryption (default: true; Discord may require this). */ daveEncryption?: boolean; /** Consecutive decrypt failures before DAVE session reinitialization (default: 24). */ diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index 5e3f0c85390..ca4bfe0ecbc 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -540,6 +540,13 @@ const DiscordVoiceAutoJoinSchema = z }) .strict(); +const DiscordVoiceAllowedChannelSchema = z + .object({ + guildId: z.string().min(1), + channelId: z.string().min(1), + }) + .strict(); + const DiscordVoiceRealtimeToolPolicySchema = z.enum(["safe-read-only", "owner", "none"]); const DiscordVoiceRealtimeConsultPolicySchema = z.enum(["auto", "always"]); const DiscordVoiceRealtimeSchema = z @@ -581,6 +588,7 @@ const DiscordVoiceSchema = z model: z.string().min(1).optional(), realtime: DiscordVoiceRealtimeSchema.optional(), autoJoin: z.array(DiscordVoiceAutoJoinSchema).optional(), + allowedChannels: z.array(DiscordVoiceAllowedChannelSchema).optional(), daveEncryption: z.boolean().optional(), decryptionFailureTolerance: z.number().int().min(0).optional(), connectTimeoutMs: z.number().int().positive().max(120_000).optional(), From fb11851c7fcac769857a09200cd46f12615852e3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 11 May 2026 13:21:12 +0100 Subject: [PATCH 49/93] docs: refresh config baseline --- docs/.generated/config-baseline.sha256 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index e6b573bf257..4fd1a4f7941 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -da3703ab610b5b06d2eefb276fd97a2266a8dd325b6715bf414824cc50ca694a config-baseline.json -379778d6f288ee2906411e2c9a5efdd129a541e2ec43833134f421403e1cd352 config-baseline.core.json +c963f607273fcce55080dc6d8068d8e124a4aa111a8ecf04807ebef98dfa5fd5 config-baseline.json +647f7a12deed46b4a962848a17ed5666d24fc526b777feab62cf331d84ce957d config-baseline.core.json 222d0338d6ed290870cac70cdf5e390bc1bb60c4462e46f847003bafe25c5a6e config-baseline.channel.json 18f71e9d4a62fe68fbd5bf18d5833a4e380fc705ad641769e1cf05794286344c config-baseline.plugin.json From f60a12cccae014a4fad72dd5aac0c0b9be52534b Mon Sep 17 00:00:00 2001 From: Shakker Date: Mon, 11 May 2026 13:23:32 +0100 Subject: [PATCH 50/93] test: pin fast reply bootstrap checks --- .../reply/get-reply.fast-path.test.ts | 31 ++++++++++++------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/src/auto-reply/reply/get-reply.fast-path.test.ts b/src/auto-reply/reply/get-reply.fast-path.test.ts index 22fba651c74..37efd5d1f79 100644 --- a/src/auto-reply/reply/get-reply.fast-path.test.ts +++ b/src/auto-reply/reply/get-reply.fast-path.test.ts @@ -89,11 +89,12 @@ describe("getReplyFromConfig fast test bootstrap", () => { expect(mocks.ensureAgentWorkspace).not.toHaveBeenCalled(); expect(mocks.initSessionState).not.toHaveBeenCalled(); expect(mocks.resolveReplyDirectives).not.toHaveBeenCalled(); - expect(vi.mocked(runPreparedReplyMock)).toHaveBeenCalledWith( - expect.objectContaining({ - cfg, - }), - ); + expect(vi.mocked(runPreparedReplyMock)).toHaveBeenCalledOnce(); + const preparedReplyParams = vi.mocked(runPreparedReplyMock).mock.calls[0]?.[0]; + if (!preparedReplyParams) { + throw new Error("expected prepared reply params"); + } + expect(preparedReplyParams.cfg).toBe(cfg); }); it("still merges partial config overrides against getRuntimeConfig()", async () => { @@ -235,7 +236,10 @@ describe("getReplyFromConfig fast test bootstrap", () => { cfg, ); - expect(reply).toEqual(expect.objectContaining({ text: expect.stringContaining("OpenClaw") })); + if (!reply || Array.isArray(reply) || typeof reply.text !== "string") { + throw new Error("expected status reply text"); + } + expect(reply.text.includes("OpenClaw")).toBe(true); expect(mocks.ensureAgentWorkspace).not.toHaveBeenCalled(); expect(mocks.initSessionState).not.toHaveBeenCalled(); expect(mocks.resolveReplyDirectives).not.toHaveBeenCalled(); @@ -279,12 +283,15 @@ describe("getReplyFromConfig fast test bootstrap", () => { expect(mocks.ensureAgentWorkspace).not.toHaveBeenCalled(); expect(mocks.initSessionState).not.toHaveBeenCalled(); expect(vi.mocked(runPreparedReplyMock)).not.toHaveBeenCalled(); - expect(mocks.resolveReplyDirectives).toHaveBeenCalledWith( - expect.objectContaining({ - sessionKey: targetSessionKey, - workspaceDir: expect.any(String), - }), - ); + expect(mocks.resolveReplyDirectives).toHaveBeenCalledOnce(); + const directiveParams = mocks.resolveReplyDirectives.mock.calls[0]?.[0] as + | { sessionKey?: string; workspaceDir?: string } + | undefined; + if (!directiveParams) { + throw new Error("expected directive params"); + } + expect(directiveParams.sessionKey).toBe(targetSessionKey); + expect(directiveParams.workspaceDir).toBe("/tmp/workspace"); }); it("uses native command target session keys during fast bootstrap", () => { From 344f42a52a715d9d889f4d57a77ef05b05230d1c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 11 May 2026 13:24:04 +0100 Subject: [PATCH 51/93] test: tighten gateway helper call assertions --- src/gateway/boot.test.ts | 8 ++------ src/gateway/http-endpoint-helpers.test.ts | 12 +++++------- 2 files changed, 7 insertions(+), 13 deletions(-) diff --git a/src/gateway/boot.test.ts b/src/gateway/boot.test.ts index 99271e4242b..9c56f9fec27 100644 --- a/src/gateway/boot.test.ts +++ b/src/gateway/boot.test.ts @@ -136,12 +136,8 @@ describe("runBootOnce", () => { expect(agentCommand).toHaveBeenCalledTimes(1); const call = agentCommand.mock.calls[0]?.[0]; - expect(call).toEqual( - expect.objectContaining({ - deliver: false, - sessionKey: resolveMainSessionKey({}), - }), - ); + expect(call?.deliver).toBe(false); + expect(call?.sessionKey).toBe(resolveMainSessionKey({})); expect(call?.message).toContain("BOOT.md:"); expect(call?.message).toContain(content); expect(call?.message).toContain("NO_REPLY"); diff --git a/src/gateway/http-endpoint-helpers.test.ts b/src/gateway/http-endpoint-helpers.test.ts index 788a48ef355..951e4c12cab 100644 --- a/src/gateway/http-endpoint-helpers.test.ts +++ b/src/gateway/http-endpoint-helpers.test.ts @@ -159,13 +159,11 @@ describe("handleGatewayPostJsonEndpoint", () => { }, ); - expect(resolveOperatorScopes).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - authMethod: "token", - trustDeclaredOperatorScopes: false, - }), - ); + const [, requestAuth] = (resolveOperatorScopes.mock.calls[0] as unknown as + | [IncomingMessage, { authMethod?: string; trustDeclaredOperatorScopes: boolean }] + | undefined) ?? [undefined, undefined]; + expect(requestAuth?.authMethod).toBe("token"); + expect(requestAuth?.trustDeclaredOperatorScopes).toBe(false); expect(result).toEqual({ body: { ok: true }, requestAuth: { authMethod: "token", trustDeclaredOperatorScopes: false }, From c2346d1042743147251349538882ee36aafcc1ea Mon Sep 17 00:00:00 2001 From: Shakker Date: Mon, 11 May 2026 13:24:53 +0100 Subject: [PATCH 52/93] test: pin conversation label calls --- .../conversation-label-generator.test.ts | 57 +++++++++++-------- 1 file changed, 34 insertions(+), 23 deletions(-) diff --git a/src/auto-reply/reply/conversation-label-generator.test.ts b/src/auto-reply/reply/conversation-label-generator.test.ts index d7fe4210371..cc614791760 100644 --- a/src/auto-reply/reply/conversation-label-generator.test.ts +++ b/src/auto-reply/reply/conversation-label-generator.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const completeSimple = vi.hoisted(() => vi.fn()); const getRuntimeAuthForModel = vi.hoisted(() => vi.fn()); @@ -63,6 +63,10 @@ describe("generateConversationLabel", () => { }); }); + afterEach(() => { + vi.useRealTimers(); + }); + it("uses routed agentDir for model and auth resolution", async () => { await generateConversationLabel({ userMessage: "Need help with invoices", @@ -94,31 +98,35 @@ describe("generateConversationLabel", () => { }); it("passes the label prompt as systemPrompt and the user text as message content", async () => { + vi.useFakeTimers(); + vi.setSystemTime(1_710_000_000_000); + await generateConversationLabel({ userMessage: "Need help with invoices", prompt: "Generate a label", cfg: {}, }); - expect(completeSimple).toHaveBeenCalledWith( - { provider: "openai" }, - { - systemPrompt: "Generate a label", - messages: [ - { - role: "user", - content: "Need help with invoices", - timestamp: expect.any(Number), - }, - ], - }, - expect.objectContaining({ - apiKey: "resolved-key", - maxTokens: 100, - temperature: 0.3, - signal: expect.any(AbortSignal), - }), - ); + expect(completeSimple).toHaveBeenCalledOnce(); + const call = completeSimple.mock.calls[0]; + if (!call) { + throw new Error("expected simple completion call"); + } + expect(call[0]).toStrictEqual({ provider: "openai" }); + expect(call[1]).toStrictEqual({ + systemPrompt: "Generate a label", + messages: [ + { + role: "user", + content: "Need help with invoices", + timestamp: 1_710_000_000_000, + }, + ], + }); + expect(call[2].apiKey).toBe("resolved-key"); + expect(call[2].maxTokens).toBe(100); + expect(call[2].temperature).toBe(0.3); + expect(call[2].signal).toBeInstanceOf(AbortSignal); }); it("omits temperature for Codex Responses simple completions", async () => { @@ -135,9 +143,12 @@ describe("generateConversationLabel", () => { cfg: {}, }); - expect(completeSimple.mock.calls[0]?.[2]).toEqual( - expect.not.objectContaining({ temperature: expect.anything() }), - ); + expect(completeSimple).toHaveBeenCalledOnce(); + const options = completeSimple.mock.calls[0]?.[2]; + if (!options) { + throw new Error("expected simple completion options"); + } + expect(Object.hasOwn(options, "temperature")).toBe(false); }); it("logs completion errors instead of treating them as empty labels", async () => { From 951444cb833c2cccac1e76b442bed70e3446e991 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 11 May 2026 13:25:39 +0100 Subject: [PATCH 53/93] test: tighten gateway preauth assertions --- src/gateway/server.plugin-http-auth.test.ts | 6 +++--- src/gateway/server.preauth-hardening.test.ts | 15 ++++++--------- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/src/gateway/server.plugin-http-auth.test.ts b/src/gateway/server.plugin-http-auth.test.ts index 80db8056f19..a50783bf2d7 100644 --- a/src/gateway/server.plugin-http-auth.test.ts +++ b/src/gateway/server.plugin-http-auth.test.ts @@ -420,9 +420,9 @@ describe("gateway plugin HTTP auth boundary", () => { }); expect(observedRuntimeScopes).toHaveLength(1); - expect(observedRuntimeScopes[0]).toEqual( - expect.arrayContaining(["operator.admin", "operator.read", "operator.write"]), - ); + expect(observedRuntimeScopes[0]).toContain("operator.admin"); + expect(observedRuntimeScopes[0]).toContain("operator.read"); + expect(observedRuntimeScopes[0]).toContain("operator.write"); expect(adminAllowedResults).toEqual([true]); }); diff --git a/src/gateway/server.preauth-hardening.test.ts b/src/gateway/server.preauth-hardening.test.ts index 4a6b4ee7ad9..2b238d225aa 100644 --- a/src/gateway/server.preauth-hardening.test.ts +++ b/src/gateway/server.preauth-hardening.test.ts @@ -225,15 +225,12 @@ describe("gateway pre-auth hardening", () => { const result = await closed; expect(result.code).toBe(1009); - expect(events).toContainEqual( - expect.objectContaining({ - type: "payload.large", - surface: "gateway.ws.preauth", - action: "rejected", - limitBytes: MAX_PREAUTH_PAYLOAD_BYTES, - reason: "preauth_frame_limit", - }), - ); + const event = events.find((candidate) => candidate.type === "payload.large"); + expect(event?.type).toBe("payload.large"); + expect(event?.surface).toBe("gateway.ws.preauth"); + expect(event?.action).toBe("rejected"); + expect(event?.limitBytes).toBe(MAX_PREAUTH_PAYLOAD_BYTES); + expect(event?.reason).toBe("preauth_frame_limit"); } finally { stopDiagnostics(); resetDiagnosticEventsForTest(); From eabb129bcf087976ab7eb0f5ba105034842c1147 Mon Sep 17 00:00:00 2001 From: Shakker Date: Mon, 11 May 2026 13:26:15 +0100 Subject: [PATCH 54/93] test: pin subagent hook session keys --- src/agents/sessions-spawn-hooks.test.ts | 29 ++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/src/agents/sessions-spawn-hooks.test.ts b/src/agents/sessions-spawn-hooks.test.ts index f8dc07ed3f6..955bd772e21 100644 --- a/src/agents/sessions-spawn-hooks.test.ts +++ b/src/agents/sessions-spawn-hooks.test.ts @@ -67,6 +67,13 @@ function expectFields(value: unknown, expected: Record, label = } } +function expectSubagentSessionKey(value: unknown, label: string): string { + expect(value, label).toBeTypeOf("string"); + const sessionKey = value as string; + expect(sessionKey.startsWith("agent:main:subagent:")).toBe(true); + return sessionKey; +} + function setConfig(next: Record) { hoisted.configOverride = createSubagentSpawnTestConfig(undefined, next); } @@ -234,9 +241,16 @@ describe("sessions_spawn subagent lifecycle hooks", () => { expectFields(result, { status: "accepted", runId: "run-1" }, "spawn result"); expect(hookRunnerMocks.runSubagentSpawning).toHaveBeenCalledTimes(1); - expect(hookRunnerMocks.runSubagentSpawning).toHaveBeenCalledWith( + const [spawningEvent, spawningContext] = (hookRunnerMocks.runSubagentSpawning.mock.calls[0] ?? + []) as unknown as [Record, Record]; + const spawningChildSessionKey = expectSubagentSessionKey( + spawningEvent?.childSessionKey, + "spawning event child session key", + ); + expectFields( + spawningEvent, { - childSessionKey: expect.stringMatching(/^agent:main:subagent:/), + childSessionKey: spawningChildSessionKey, agentId: "main", label: "research", mode: "session", @@ -248,10 +262,15 @@ describe("sessions_spawn subagent lifecycle hooks", () => { }, threadRequested: true, }, + "spawning event", + ); + expectFields( + spawningContext, { - childSessionKey: expect.stringMatching(/^agent:main:subagent:/), + childSessionKey: spawningChildSessionKey, requesterSessionKey: "main", }, + "spawning context", ); expect(hookRunnerMocks.runSubagentSpawned).toHaveBeenCalledTimes(1); @@ -280,7 +299,7 @@ describe("sessions_spawn subagent lifecycle hooks", () => { }, "spawned requester", ); - expect(event.childSessionKey).toEqual(expect.stringMatching(/^agent:main:subagent:/)); + expectSubagentSessionKey(event.childSessionKey, "spawned event child session key"); expectFields( ctx, { @@ -423,7 +442,7 @@ describe("sessions_spawn subagent lifecycle hooks", () => { const [event] = (hookRunnerMocks.runSubagentEnded.mock.calls[0] ?? []) as unknown as [ Record, ]; - expect(event.targetSessionKey).toEqual(expect.stringMatching(/^agent:main:subagent:/)); + expectSubagentSessionKey(event.targetSessionKey, "ended event target session key"); expectFields( event, { From 4c070299c8227cd444555a1d8cddc35d027b18a6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 11 May 2026 13:27:00 +0100 Subject: [PATCH 55/93] test: tighten telegram session recreation assertions --- .../session-utils.telegram-recreate.test.ts | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/src/gateway/session-utils.telegram-recreate.test.ts b/src/gateway/session-utils.telegram-recreate.test.ts index 423376aa929..7aee8b7343c 100644 --- a/src/gateway/session-utils.telegram-recreate.test.ts +++ b/src/gateway/session-utils.telegram-recreate.test.ts @@ -111,16 +111,10 @@ describe("Telegram direct session recreation after delete", () => { opts: {}, }); - expect(store[TELEGRAM_DIRECT_KEY]).toEqual( - expect.objectContaining({ - lastChannel: "telegram", - lastTo: "telegram:7463849194", - origin: expect.objectContaining({ - chatType: "direct", - provider: "telegram", - }), - }), - ); + expect(store[TELEGRAM_DIRECT_KEY]?.lastChannel).toBe("telegram"); + expect(store[TELEGRAM_DIRECT_KEY]?.lastTo).toBe("telegram:7463849194"); + expect(store[TELEGRAM_DIRECT_KEY]?.origin?.chatType).toBe("direct"); + expect(store[TELEGRAM_DIRECT_KEY]?.origin?.provider).toBe("telegram"); expect(listed.sessions.map((session) => session.key)).toContain(TELEGRAM_DIRECT_KEY); }); }); From 2838eb4d8eddb7c573944abca5438f8ea50a6a8a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 11 May 2026 13:28:24 +0100 Subject: [PATCH 56/93] test: tighten node pairing authz assertions --- src/gateway/server.node-pairing-authz.test.ts | 21 ++++++------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/src/gateway/server.node-pairing-authz.test.ts b/src/gateway/server.node-pairing-authz.test.ts index 3c29ff0b17b..7bbf1afd792 100644 --- a/src/gateway/server.node-pairing-authz.test.ts +++ b/src/gateway/server.node-pairing-authz.test.ts @@ -163,16 +163,10 @@ async function expectRePairingRequest(params: { JSON.stringify(lastNodes), ).toEqual(params.expectedVisibleCommands); - await expect(listNodePairing()).resolves.toEqual( - expect.objectContaining({ - pending: [ - expect.objectContaining({ - nodeId: pairedNode.identity.deviceId, - commands: params.reconnectCommands, - }), - ], - }), - ); + const pairing = await listNodePairing(); + const pending = pairing.pending?.find((entry) => entry.nodeId === pairedNode.identity.deviceId); + expect(pending?.nodeId).toBe(pairedNode.identity.deviceId); + expect(pending?.commands).toEqual(params.reconnectCommands); } finally { controlWs?.close(); await firstClient?.stopAndWait(); @@ -239,11 +233,8 @@ describe("gateway node pairing authorization", () => { expect(approve.payload?.requestId).toBe(request.request.requestId); expect(approve.payload?.node?.nodeId).toBe("node-approve-target"); - await expect(getPairedNode("node-approve-target")).resolves.toEqual( - expect.objectContaining({ - nodeId: "node-approve-target", - }), - ); + const pairedNode = await getPairedNode("node-approve-target"); + expect(pairedNode?.nodeId).toBe("node-approve-target"); } finally { pairingWs?.close(); started.ws.close(); From da7cc2b11c769f2e67cb756d9dda21b0bcb13f04 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 11 May 2026 13:29:03 +0100 Subject: [PATCH 57/93] fix(feishu): make manual setup the default --- CHANGELOG.md | 1 + docs/channels/feishu.md | 9 +- extensions/feishu/src/app-registration.ts | 2 +- extensions/feishu/src/setup-surface.test.ts | 136 ++++++++++++++++++-- extensions/feishu/src/setup-surface.ts | 71 +++++++--- 5 files changed, 188 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f31040b7e65..4282d9a4e2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Feishu: make manual App ID/App Secret setup the default channel-binding path while keeping QR scan-to-create as an optional best-effort flow, and document the manual fallback for domestic Feishu mobile clients that do not react to the QR code. Fixes #80591. Thanks @wei-wei-zhao. - Gateway: scope `sessions.resolve` sessionId and label store loads to the requested agent so large unrelated agent stores are not parsed for scoped lookups. Fixes #51264. (#79474) Thanks @samzong. - Gateway: share serialized streaming event envelopes across eligible WebSocket and node subscribers while preserving per-client sequence numbers. (#80299) Thanks @samzong. - Browser: report Chrome MCP existing-session page readiness in browser status without letting status probes exceed the client timeout. Fixes #80268. (#80280) Thanks @ai-hpc. diff --git a/docs/channels/feishu.md b/docs/channels/feishu.md index 8620c8e673b..04fedd65820 100644 --- a/docs/channels/feishu.md +++ b/docs/channels/feishu.md @@ -23,7 +23,7 @@ Requires OpenClaw 2026.4.25 or above. Run `openclaw --version` to check. Upgrade ```bash openclaw channels login --channel feishu ``` - Scan the QR code with your Feishu/Lark mobile app to create a Feishu/Lark bot automatically. + Choose manual setup to paste an App ID and App Secret from Feishu Open Platform, or choose QR setup to create a bot automatically. If the domestic Feishu mobile app does not react to the QR code, rerun setup and choose manual setup. @@ -211,6 +211,13 @@ Feishu/Lark does not support native slash-command menus, so send these as plain 5. Ensure the gateway is running: `openclaw gateway status` 6. Check logs: `openclaw logs --follow` +### QR setup does not react in the Feishu mobile app + +1. Rerun setup: `openclaw channels login --channel feishu` +2. Choose manual setup +3. In Feishu Open Platform, create a self-built app and copy its App ID and App Secret +4. Paste those credentials into the setup wizard + ### App Secret leaked 1. Reset the App Secret in Feishu Open Platform / Lark Developer diff --git a/extensions/feishu/src/app-registration.ts b/extensions/feishu/src/app-registration.ts index af7463735cd..e39e5beb476 100644 --- a/extensions/feishu/src/app-registration.ts +++ b/extensions/feishu/src/app-registration.ts @@ -167,7 +167,7 @@ export async function pollAppRegistration(params: { expireIn: number; initialDomain?: FeishuDomain; abortSignal?: AbortSignal; - /** Registration type parameter: "ob_user" for user mode, "ob_app" for bot mode. */ + /** Registration type parameter. The CLI bot QR flow uses "ob_cli_app". */ tp?: string; }): Promise { const { deviceCode, expireIn, initialDomain = "feishu", abortSignal, tp } = params; diff --git a/extensions/feishu/src/setup-surface.test.ts b/extensions/feishu/src/setup-surface.test.ts index a18dfa2648a..74cd93a5355 100644 --- a/extensions/feishu/src/setup-surface.test.ts +++ b/extensions/feishu/src/setup-surface.test.ts @@ -8,7 +8,19 @@ import { import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { FeishuProbeResult } from "./types.js"; -const { probeFeishuMock } = vi.hoisted(() => ({ +const { + beginAppRegistrationMock, + getAppOwnerOpenIdMock, + initAppRegistrationMock, + pollAppRegistrationMock, + printQrCodeMock, + probeFeishuMock, +} = vi.hoisted(() => ({ + beginAppRegistrationMock: vi.fn(), + getAppOwnerOpenIdMock: vi.fn(), + initAppRegistrationMock: vi.fn(), + pollAppRegistrationMock: vi.fn(), + printQrCodeMock: vi.fn(), probeFeishuMock: vi.fn<() => Promise>(async () => ({ ok: false, error: "mocked", @@ -20,13 +32,11 @@ vi.mock("./probe.js", () => ({ })); vi.mock("./app-registration.js", () => ({ - initAppRegistration: vi.fn(async () => { - throw new Error("mocked: scan-to-create not available"); - }), - beginAppRegistration: vi.fn(), - pollAppRegistration: vi.fn(), - printQrCode: vi.fn(async () => {}), - getAppOwnerOpenId: vi.fn(async () => undefined), + initAppRegistration: initAppRegistrationMock, + beginAppRegistration: beginAppRegistrationMock, + pollAppRegistration: pollAppRegistrationMock, + printQrCode: printQrCodeMock, + getAppOwnerOpenId: getAppOwnerOpenIdMock, })); import { feishuPlugin } from "./channel.js"; @@ -86,6 +96,116 @@ describe("feishu setup wizard", () => { beforeEach(() => { probeFeishuMock.mockReset(); probeFeishuMock.mockResolvedValue({ ok: false, error: "mocked" }); + initAppRegistrationMock.mockReset(); + initAppRegistrationMock.mockRejectedValue(new Error("mocked: scan-to-create not available")); + beginAppRegistrationMock.mockReset(); + pollAppRegistrationMock.mockReset(); + printQrCodeMock.mockReset(); + printQrCodeMock.mockResolvedValue(undefined); + getAppOwnerOpenIdMock.mockReset(); + getAppOwnerOpenIdMock.mockResolvedValue(undefined); + }); + + it("uses manual credentials by default instead of starting scan-to-create", async () => { + const text = vi.fn().mockResolvedValueOnce("cli_manual").mockResolvedValueOnce("secret_manual"); + const prompter = createTestWizardPrompter({ text }); + + const result = await runSetupWizardConfigure({ + configure: feishuConfigure, + cfg: {} as never, + prompter, + runtime: createNonExitingRuntimeEnv(), + }); + + expect(initAppRegistrationMock).not.toHaveBeenCalled(); + expect(beginAppRegistrationMock).not.toHaveBeenCalled(); + expect(result.cfg.channels?.feishu).toMatchObject({ + appId: "cli_manual", + appSecret: "secret_manual", + connectionMode: "websocket", + domain: "feishu", + }); + }); + + it("passes selected domain through scan-to-create and poll", async () => { + initAppRegistrationMock.mockResolvedValueOnce(undefined); + beginAppRegistrationMock.mockResolvedValueOnce({ + deviceCode: "device-code", + qrUrl: "https://accounts.larksuite.com/qr", + userCode: "user-code", + interval: 1, + expireIn: 10, + }); + pollAppRegistrationMock.mockResolvedValueOnce({ + status: "success", + result: { + appId: "cli_lark", + appSecret: "secret_lark", + domain: "lark", + openId: "ou_owner", + }, + }); + const prompter = createTestWizardPrompter({ + select: vi + .fn() + .mockResolvedValueOnce("scan") + .mockResolvedValueOnce("lark") + .mockResolvedValueOnce("open") as never, + }); + + const result = await runSetupWizardConfigure({ + configure: feishuConfigure, + cfg: {} as never, + prompter, + runtime: createNonExitingRuntimeEnv(), + }); + + expect(initAppRegistrationMock).toHaveBeenCalledWith("lark"); + expect(beginAppRegistrationMock).toHaveBeenCalledWith("lark"); + expect(pollAppRegistrationMock).toHaveBeenCalledWith( + expect.objectContaining({ + deviceCode: "device-code", + initialDomain: "lark", + tp: "ob_cli_app", + }), + ); + expect(result.cfg.channels?.feishu).toMatchObject({ + appId: "cli_lark", + appSecret: "secret_lark", + domain: "lark", + groupPolicy: "open", + requireMention: true, + }); + }); + + it("falls back to manual credentials when selected scan-to-create is unavailable", async () => { + const text = vi + .fn() + .mockResolvedValueOnce("cli_from_fallback") + .mockResolvedValueOnce("secret_from_fallback"); + const prompter = createTestWizardPrompter({ + text, + select: vi + .fn() + .mockResolvedValueOnce("scan") + .mockResolvedValueOnce("feishu") + .mockResolvedValueOnce("allowlist") as never, + }); + + const result = await runSetupWizardConfigure({ + configure: feishuConfigure, + cfg: {} as never, + prompter, + runtime: createNonExitingRuntimeEnv(), + }); + + expect(initAppRegistrationMock).toHaveBeenCalledWith("feishu"); + expect(beginAppRegistrationMock).not.toHaveBeenCalled(); + expect(result.cfg.channels?.feishu).toMatchObject({ + appId: "cli_from_fallback", + appSecret: "secret_from_fallback", + domain: "feishu", + }); }); it("prompts over SecretRef appId/appSecret config objects", async () => { diff --git a/extensions/feishu/src/setup-surface.ts b/extensions/feishu/src/setup-surface.ts index 5f4fc8008c3..8f02da89563 100644 --- a/extensions/feishu/src/setup-surface.ts +++ b/extensions/feishu/src/setup-surface.ts @@ -17,6 +17,7 @@ import type { AppRegistrationResult } from "./app-registration.js"; import type { FeishuConfig, FeishuDomain } from "./types.js"; const channel = "feishu" as const; +const SCAN_TO_CREATE_TP = "ob_cli_app"; // --------------------------------------------------------------------------- // Helpers @@ -213,6 +214,7 @@ const feishuDmPolicy: ChannelSetupDmPolicy = { }; type WizardPrompter = Parameters>[0]["prompter"]; +type FeishuSetupMethod = "manual" | "scan"; // --------------------------------------------------------------------------- // Security policy helpers @@ -245,11 +247,39 @@ function applyNewAppSecurityPolicy( // Scan-to-create flow // --------------------------------------------------------------------------- -async function runScanToCreate(prompter: WizardPrompter): Promise { +async function promptFeishuDomain(params: { + prompter: WizardPrompter; + initialValue?: FeishuDomain; +}): Promise { + return (await params.prompter.select({ + message: "Which Feishu domain?", + options: [ + { value: "feishu", label: "Feishu (feishu.cn) - China" }, + { value: "lark", label: "Lark (larksuite.com) - International" }, + ], + initialValue: params.initialValue ?? "feishu", + })) as FeishuDomain; +} + +async function promptFeishuSetupMethod(prompter: WizardPrompter): Promise { + return (await prompter.select({ + message: "How do you want to connect Feishu?", + options: [ + { value: "manual", label: "Enter App ID and App Secret manually" }, + { value: "scan", label: "Scan a QR code to create a bot automatically" }, + ], + initialValue: "manual", + })) as FeishuSetupMethod; +} + +async function runScanToCreate( + prompter: WizardPrompter, + domain: FeishuDomain, +): Promise { const { beginAppRegistration, initAppRegistration, pollAppRegistration, printQrCode } = await import("./app-registration.js"); try { - await initAppRegistration("feishu"); + await initAppRegistration(domain); } catch { await prompter.note( "Scan-to-create is not available in this environment. Falling back to manual input.", @@ -258,9 +288,12 @@ async function runScanToCreate(prompter: WizardPrompter): Promise Date: Mon, 11 May 2026 13:29:46 +0100 Subject: [PATCH 58/93] test: pin session archive filenames --- src/gateway/server.sessions.store-rpc.test.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/gateway/server.sessions.store-rpc.test.ts b/src/gateway/server.sessions.store-rpc.test.ts index 50aa1daa116..f23309787a7 100644 --- a/src/gateway/server.sessions.store-rpc.test.ts +++ b/src/gateway/server.sessions.store-rpc.test.ts @@ -22,6 +22,17 @@ function collectNonEmptyLines(text: string): string[] { return lines; } +function expectSinglePrefixedFilename(files: string[], prefix: string): string { + const matches = files.filter((file) => file.startsWith(prefix)); + expect(matches).toHaveLength(1); + const [match] = matches; + if (!match) { + throw new Error(`Expected one filename with prefix ${prefix}`); + } + expect(match.length).toBeGreaterThan(prefix.length); + return match; +} + test("lists and patches session store via sessions.* RPC", async () => { const { dir, storePath } = await createSessionStoreDir(); const now = Date.now(); @@ -391,7 +402,7 @@ test("lists and patches session store via sessions.* RPC", async () => { ); expect(compactedLines).toHaveLength(3); const filesAfterCompact = await fs.readdir(dir); - expect(filesAfterCompact).toContainEqual(expect.stringMatching(/^sess-main\.jsonl\.bak\./)); + expectSinglePrefixedFilename(filesAfterCompact, "sess-main.jsonl.bak."); const deleted = await directSessionReq<{ ok: true; deleted: boolean }>("sessions.delete", { key: "agent:main:discord:group:dev", @@ -406,7 +417,7 @@ test("lists and patches session store via sessions.* RPC", async () => { "agent:main:discord:group:dev", ); const filesAfterDelete = await fs.readdir(dir); - expect(filesAfterDelete).toContainEqual(expect.stringMatching(/^sess-group\.jsonl\.deleted\./)); + expectSinglePrefixedFilename(filesAfterDelete, "sess-group.jsonl.deleted."); const reset = await directSessionReq<{ ok: true; @@ -433,7 +444,7 @@ test("lists and patches session store via sessions.* RPC", async () => { expect(storeAfterReset["agent:main:main"]?.lastAccountId).toBe("work"); expect(storeAfterReset["agent:main:main"]?.lastThreadId).toBe("1737500000.123456"); const filesAfterReset = await fs.readdir(dir); - expect(filesAfterReset).toContainEqual(expect.stringMatching(/^sess-main\.jsonl\.reset\./)); + expectSinglePrefixedFilename(filesAfterReset, "sess-main.jsonl.reset."); const badThinking = await directSessionReq("sessions.patch", { key: "agent:main:main", From 4b51e86914dd616d58c1ba337102eb479e95740d Mon Sep 17 00:00:00 2001 From: Shakker Date: Mon, 11 May 2026 13:31:28 +0100 Subject: [PATCH 59/93] test: pin chat abort payloads --- src/gateway/chat-abort.test.ts | 67 +++++++++++++++++++++++----------- 1 file changed, 46 insertions(+), 21 deletions(-) diff --git a/src/gateway/chat-abort.test.ts b/src/gateway/chat-abort.test.ts index 3e376f9c366..9a7eebf5c5f 100644 --- a/src/gateway/chat-abort.test.ts +++ b/src/gateway/chat-abort.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { abortChatRunById, isChatStopCommandText, @@ -6,6 +6,23 @@ import { type ChatAbortControllerEntry, } from "./chat-abort.js"; +type ChatAbortPayload = { + runId: string; + sessionKey: string; + seq: number; + state: "aborted"; + stopReason?: string; + message?: { + role: "assistant"; + content: Array<{ type: "text"; text: string }>; + timestamp: number; + }; +}; + +afterEach(() => { + vi.useRealTimers(); +}); + function createActiveEntry(sessionKey: string): ChatAbortControllerEntry { const now = Date.now(); return { @@ -64,6 +81,9 @@ describe("isChatStopCommandText", () => { describe("abortChatRunById", () => { it("broadcasts aborted payload with partial message when buffered text exists", () => { + const now = new Date("2026-01-02T03:04:05.000Z"); + vi.useFakeTimers(); + vi.setSystemTime(now); const runId = "run-1"; const sessionKey = "main"; const entry = createActiveEntry(sessionKey); @@ -85,23 +105,19 @@ describe("abortChatRunById", () => { expect(ops.agentRunSeq.has("client-run-1")).toBe(false); expect(ops.broadcast).toHaveBeenCalledTimes(1); - const payload = ops.broadcast.mock.calls[0]?.[1] as Record; - expect(payload).toEqual( - expect.objectContaining({ - runId, - sessionKey, - seq: 3, - state: "aborted", - stopReason: "user", - }), - ); - expect(payload.message).toEqual( - expect.objectContaining({ + const payload = ops.broadcast.mock.calls[0]?.[1] as ChatAbortPayload; + expect(payload).toEqual({ + runId, + sessionKey, + seq: 3, + state: "aborted", + stopReason: "user", + message: { role: "assistant", content: [{ type: "text", text: " Partial reply " }], - }), - ); - expect((payload.message as { timestamp?: unknown }).timestamp).toBeGreaterThan(0); + timestamp: now.getTime(), + }, + }); expect(ops.nodeSendToSession).toHaveBeenCalledWith(sessionKey, "chat", payload); }); @@ -119,6 +135,9 @@ describe("abortChatRunById", () => { }); it("preserves partial message even when abort listeners clear buffers synchronously", () => { + const now = new Date("2026-01-02T03:04:05.000Z"); + vi.useFakeTimers(); + vi.setSystemTime(now); const runId = "run-1"; const sessionKey = "main"; const entry = createActiveEntry(sessionKey); @@ -132,12 +151,18 @@ describe("abortChatRunById", () => { const result = abortChatRunById(ops, { runId, sessionKey }); expect(result).toEqual({ aborted: true }); - const payload = ops.broadcast.mock.calls[0]?.[1] as Record; - expect(payload.message).toEqual( - expect.objectContaining({ + const payload = ops.broadcast.mock.calls[0]?.[1] as ChatAbortPayload; + expect(payload).toEqual({ + runId, + sessionKey, + seq: 1, + state: "aborted", + stopReason: undefined, + message: { role: "assistant", content: [{ type: "text", text: "streamed text" }], - }), - ); + timestamp: now.getTime(), + }, + }); }); }); From 653483d9c83bfca7448025647e51ce5e799e4ecb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 11 May 2026 13:32:20 +0100 Subject: [PATCH 60/93] test: tighten gateway server method assertions --- .../server-methods/chat-reply-media.test.ts | 7 ++++++- src/gateway/server-methods/models.test.ts | 15 +++++++-------- .../server-methods/native-hook-relay.test.ts | 15 +++++++-------- src/gateway/server-methods/push.test.ts | 9 +++++++-- src/gateway/server-methods/tts.test.ts | 15 +++++++-------- 5 files changed, 34 insertions(+), 27 deletions(-) diff --git a/src/gateway/server-methods/chat-reply-media.test.ts b/src/gateway/server-methods/chat-reply-media.test.ts index fad5390a02a..a9d78f3f3b0 100644 --- a/src/gateway/server-methods/chat-reply-media.test.ts +++ b/src/gateway/server-methods/chat-reply-media.test.ts @@ -60,7 +60,12 @@ describe("normalizeWebchatReplyMediaPathsForDisplay", () => { } async function expectPathMissing(targetPath: string): Promise { - await expect(fs.stat(targetPath)).rejects.toMatchObject({ code: "ENOENT" }); + try { + await fs.stat(targetPath); + throw new Error(`expected ${targetPath} to be missing`); + } catch (error) { + expect((error as { code?: string }).code).toBe("ENOENT"); + } } it("stages Codex-home image paths before Gateway managed-image display", async () => { diff --git a/src/gateway/server-methods/models.test.ts b/src/gateway/server-methods/models.test.ts index dcca649abb8..7e0adfedf96 100644 --- a/src/gateway/server-methods/models.test.ts +++ b/src/gateway/server-methods/models.test.ts @@ -236,13 +236,12 @@ describe("models.list", () => { } as never, }); - expect(respond).toHaveBeenCalledWith( - false, - undefined, - expect.objectContaining({ - code: ErrorCodes.UNAVAILABLE, - message: "Error: catalog failed", - }), - ); + const call = respond.mock.calls[0] as + | [boolean, unknown, { code?: number; message?: string }] + | undefined; + expect(call?.[0]).toBe(false); + expect(call?.[1]).toBeUndefined(); + expect(call?.[2]?.code).toBe(ErrorCodes.UNAVAILABLE); + expect(call?.[2]?.message).toBe("Error: catalog failed"); }); }); diff --git a/src/gateway/server-methods/native-hook-relay.test.ts b/src/gateway/server-methods/native-hook-relay.test.ts index 176ec4afba0..a4cc03917de 100644 --- a/src/gateway/server-methods/native-hook-relay.test.ts +++ b/src/gateway/server-methods/native-hook-relay.test.ts @@ -55,14 +55,13 @@ describe("native hook relay gateway method", () => { context: {} as never, }); - expect(respond).toHaveBeenCalledWith( - false, - undefined, - expect.objectContaining({ - code: "INVALID_REQUEST", - message: expect.stringContaining("not found"), - }), - ); + const call = respond.mock.calls[0] as + | [boolean, unknown, { code?: string; message?: string }] + | undefined; + expect(call?.[0]).toBe(false); + expect(call?.[1]).toBeUndefined(); + expect(call?.[2]?.code).toBe("INVALID_REQUEST"); + expect(call?.[2]?.message).toContain("not found"); }); }); diff --git a/src/gateway/server-methods/push.test.ts b/src/gateway/server-methods/push.test.ts index 776130d6587..8308c36dbf4 100644 --- a/src/gateway/server-methods/push.test.ts +++ b/src/gateway/server-methods/push.test.ts @@ -159,7 +159,9 @@ describe("push.test handler", () => { expect(sendApnsAlert).toHaveBeenCalledTimes(1); const call = respond.mock.calls[0] as RespondCall | undefined; expect(call?.[0]).toBe(true); - expect(call?.[1]).toMatchObject({ ok: true, status: 200 }); + const result = call?.[1] as ApnsPushResult | undefined; + expect(result?.ok).toBe(true); + expect(result?.status).toBe(200); }); it("sends push test through relay registrations", async () => { @@ -216,7 +218,10 @@ describe("push.test handler", () => { expect(sendApnsAlert).toHaveBeenCalledTimes(1); const call = respond.mock.calls[0] as RespondCall | undefined; expect(call?.[0]).toBe(true); - expect(call?.[1]).toMatchObject({ ok: true, status: 200, transport: "relay" }); + const result = call?.[1] as ApnsPushResult | undefined; + expect(result?.ok).toBe(true); + expect(result?.status).toBe(200); + expect(result?.transport).toBe("relay"); }); it("clears stale registrations after invalid token push-test failures", async () => { diff --git a/src/gateway/server-methods/tts.test.ts b/src/gateway/server-methods/tts.test.ts index c24547b7313..5186f278a50 100644 --- a/src/gateway/server-methods/tts.test.ts +++ b/src/gateway/server-methods/tts.test.ts @@ -76,14 +76,13 @@ describe("ttsHandlers", () => { context: { getRuntimeConfig: mocks.getRuntimeConfig }, } as never); - expect(respond).toHaveBeenCalledWith( - false, - undefined, - expect.objectContaining({ - code: ErrorCodes.INVALID_REQUEST, - message: 'Error: Unknown TTS provider "bad".', - }), - ); + const call = respond.mock.calls[0] as + | [boolean, unknown, { code?: number; message?: string }] + | undefined; + expect(call?.[0]).toBe(false); + expect(call?.[1]).toBeUndefined(); + expect(call?.[2]?.code).toBe(ErrorCodes.INVALID_REQUEST); + expect(call?.[2]?.message).toBe('Error: Unknown TTS provider "bad".'); expect(mocks.textToSpeech).not.toHaveBeenCalled(); }); }); From ee77ce467a10b6f3ab7e5ebffb400ae768082c0c Mon Sep 17 00:00:00 2001 From: Shakker Date: Mon, 11 May 2026 13:33:39 +0100 Subject: [PATCH 61/93] test: cover diagnostics snapshots --- .../server-methods/diagnostics.test.ts | 67 +++++++++++++++---- 1 file changed, 53 insertions(+), 14 deletions(-) diff --git a/src/gateway/server-methods/diagnostics.test.ts b/src/gateway/server-methods/diagnostics.test.ts index 474fe7e4f4f..c58c5ab26ad 100644 --- a/src/gateway/server-methods/diagnostics.test.ts +++ b/src/gateway/server-methods/diagnostics.test.ts @@ -21,9 +21,13 @@ describe("diagnostics gateway methods", () => { stopDiagnosticStabilityRecorder(); resetDiagnosticStabilityRecorderForTest(); resetDiagnosticEventsForTest(); + vi.useRealTimers(); }); it("returns a filtered stability snapshot", async () => { + const now = new Date("2026-01-02T03:04:05.000Z"); + vi.useFakeTimers(); + vi.setSystemTime(now); emitDiagnosticEvent({ type: "webhook.received", channel: "telegram" }); emitDiagnosticEvent({ type: "payload.large", @@ -43,20 +47,53 @@ describe("diagnostics gateway methods", () => { respond, }); - expect(respond).toHaveBeenCalledWith( + expect(respond).toHaveBeenCalledTimes(1); + expect(respond.mock.calls[0]).toEqual([ true, - expect.objectContaining({ + { + generatedAt: now.toISOString(), + capacity: 1000, count: 1, + dropped: 0, + firstSeq: 2, + lastSeq: 2, events: [ - expect.objectContaining({ + { + seq: 2, + ts: now.getTime(), type: "payload.large", surface: "gateway.http.json", action: "rejected", - }), + bytes: 1024, + limitBytes: 512, + count: undefined, + channel: undefined, + pluginId: undefined, + }, ], - }), + summary: { + byType: { "payload.large": 1 }, + payloadLarge: { + count: 1, + rejected: 1, + truncated: 0, + chunked: 0, + bySurface: { "gateway.http.json": 1 }, + }, + }, + }, undefined, - ); + ]); + expect(Object.keys(respond.mock.calls[0]?.[1] as Record).sort()).toEqual([ + "capacity", + "count", + "dropped", + "events", + "firstSeq", + "generatedAt", + "lastSeq", + "summary", + ]); }); it("rejects invalid stability params", async () => { @@ -70,13 +107,15 @@ describe("diagnostics gateway methods", () => { respond, }); - expect(respond).toHaveBeenCalledWith( - false, - undefined, - expect.objectContaining({ - code: "INVALID_REQUEST", - message: "limit must be between 1 and 1000", - }), - ); + expect(respond.mock.calls).toEqual([ + [ + false, + undefined, + { + code: "INVALID_REQUEST", + message: "limit must be between 1 and 1000", + }, + ], + ]); }); }); From 6562bac624ac86d970d6652960d0dc6259c27422 Mon Sep 17 00:00:00 2001 From: VACInc <3279061+VACInc@users.noreply.github.com> Date: Sun, 10 May 2026 12:38:16 -0400 Subject: [PATCH 62/93] fix: show Telegram thinking defaults --- .../bot-native-commands.session-meta.test.ts | 58 +++++++++++++++++++ .../telegram/src/bot-native-commands.ts | 46 +++++++++++++-- .../reply/get-reply-native-slash-fast-path.ts | 53 ++++++++++++++++- .../reply/get-reply.fast-path.test.ts | 50 ++++++++++++++++ 4 files changed, 199 insertions(+), 8 deletions(-) diff --git a/extensions/telegram/src/bot-native-commands.session-meta.test.ts b/extensions/telegram/src/bot-native-commands.session-meta.test.ts index 88190918c2e..9ff1144bc40 100644 --- a/extensions/telegram/src/bot-native-commands.session-meta.test.ts +++ b/extensions/telegram/src/bot-native-commands.session-meta.test.ts @@ -28,6 +28,7 @@ type DispatchReplyWithBufferedBlockDispatcherResult = Awaited< >; type DeliverRepliesFn = typeof import("./bot/delivery.js").deliverReplies; type DeliverRepliesParams = Parameters[0]; +type LoadModelCatalogFn = typeof import("openclaw/plugin-sdk/agent-runtime").loadModelCatalog; type MatchPluginCommandFn = typeof import("./bot-native-commands.runtime.js").matchPluginCommand; const dispatchReplyResult: DispatchReplyWithBufferedBlockDispatcherResult = { @@ -53,6 +54,16 @@ const sessionMocks = vi.hoisted(() => ({ const commandAuthMocks = vi.hoisted(() => ({ resolveCommandArgMenu: vi.fn(), })); +const agentRuntimeMocks = vi.hoisted(() => ({ + loadModelCatalog: vi.fn(async () => [ + { + provider: "openai", + id: "gpt-5.5", + name: "GPT-5.5", + reasoning: true, + }, + ]), +})); const pluginRuntimeMocks = vi.hoisted(() => ({ executePluginCommand: vi.fn(async () => ({ text: "ok" })), matchPluginCommand: vi.fn(() => null), @@ -169,6 +180,15 @@ vi.mock("openclaw/plugin-sdk/command-auth-native", async () => { resolveCommandArgMenu: commandAuthMocks.resolveCommandArgMenu, }; }); +vi.mock("openclaw/plugin-sdk/agent-runtime", async () => { + const actual = await vi.importActual( + "openclaw/plugin-sdk/agent-runtime", + ); + return { + ...actual, + loadModelCatalog: agentRuntimeMocks.loadModelCatalog, + }; +}); vi.mock("./bot-native-commands.runtime.js", async () => { const actual = await vi.importActual( "./bot-native-commands.runtime.js", @@ -524,6 +544,14 @@ describe("registerTelegramNativeCommands — session metadata", () => { persistentBindingMocks.ensureConfiguredBindingRouteReady.mockClear(); persistentBindingMocks.ensureConfiguredBindingRouteReady.mockResolvedValue({ ok: true }); commandAuthMocks.resolveCommandArgMenu.mockClear(); + agentRuntimeMocks.loadModelCatalog.mockClear().mockResolvedValue([ + { + provider: "openai", + id: "gpt-5.5", + name: "GPT-5.5", + reasoning: true, + }, + ]); sessionMocks.loadSessionStore.mockClear().mockReturnValue({}); sessionMocks.recordSessionMetaFromInbound.mockClear().mockResolvedValue(undefined); sessionMocks.resolveAndPersistSessionFile.mockClear().mockImplementation(async (params) => { @@ -701,6 +729,36 @@ describe("registerTelegramNativeCommands — session metadata", () => { expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); }); + it("hydrates runtime catalog metadata for thinking menu defaults", async () => { + const cfg = { + agents: { + defaults: { + model: { primary: "openai/gpt-5.5" }, + }, + }, + } as OpenClawConfig; + sessionMocks.loadSessionStore.mockReturnValue({}); + + const { handler, sendMessage } = registerAndResolveCommandHandler({ + commandName: "think", + cfg, + allowFrom: ["*"], + }); + await handler(createTelegramPrivateCommandContext()); + + expect(agentRuntimeMocks.loadModelCatalog).toHaveBeenCalledWith({ + config: cfg, + }); + expectSendMessageCall({ + sendMessage, + chatId: 100, + textIncludes: "Current thinking level: medium.\nChoose level for /think.", + requireReplyMarkup: true, + label: "runtime catalog thinking menu", + }); + expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); + }); + it("uses target model thinking defaults before global thinking defaults", async () => { const cfg = { agents: { diff --git a/extensions/telegram/src/bot-native-commands.ts b/extensions/telegram/src/bot-native-commands.ts index 775e5edf8ed..9b9da162dc2 100644 --- a/extensions/telegram/src/bot-native-commands.ts +++ b/extensions/telegram/src/bot-native-commands.ts @@ -3,6 +3,7 @@ import path from "node:path"; import type { Bot, Context } from "grammy"; import { buildConfiguredModelCatalog, + loadModelCatalog, resolveAgentConfig, resolveDefaultModelForAgent, resolveThinkingDefault, @@ -256,13 +257,49 @@ function resolveTelegramCommandMenuModelContext(params: { } } -function resolveTelegramThinkMenuCurrentLevel(params: { +async function resolveTelegramDefaultThinkingLevel(params: { + cfg: OpenClawConfig; + provider: string; + model: string; +}): Promise { + const configuredCatalog = buildConfiguredModelCatalog({ cfg: params.cfg }); + const configuredSelectedEntry = configuredCatalog.find( + (entry) => entry.provider === params.provider && entry.id === params.model, + ); + const shouldHydrateRuntimeCatalog = + configuredCatalog.length === 0 || + !configuredSelectedEntry || + configuredSelectedEntry.reasoning === undefined; + let runtimeCatalog: Awaited> | undefined; + if (shouldHydrateRuntimeCatalog) { + try { + runtimeCatalog = await loadModelCatalog({ config: params.cfg }); + } catch { + runtimeCatalog = undefined; + } + } + const runtimeSelectedEntry = runtimeCatalog?.find( + (entry) => entry.provider === params.provider && entry.id === params.model, + ); + const catalog = + runtimeSelectedEntry || configuredCatalog.length === 0 + ? (runtimeCatalog ?? configuredCatalog) + : configuredCatalog; + return resolveThinkingDefault({ + cfg: params.cfg, + provider: params.provider, + model: params.model, + catalog, + }); +} + +async function resolveTelegramThinkMenuCurrentLevel(params: { cfg: OpenClawConfig; agentId: string; provider?: string; model?: string; thinkingLevel?: string; -}): string { +}): Promise { const explicit = normalizeOptionalString(params.thinkingLevel); if (explicit) { return explicit; @@ -277,11 +314,10 @@ function resolveTelegramThinkMenuCurrentLevel(params: { cfg: params.cfg, agentId: params.agentId, }); - return resolveThinkingDefault({ + return await resolveTelegramDefaultThinkingLevel({ cfg: params.cfg, provider: params.provider ?? defaultModel.provider, model: params.model ?? defaultModel.model, - catalog: buildConfiguredModelCatalog({ cfg: params.cfg }), }); } @@ -1050,7 +1086,7 @@ export const registerTelegramNativeCommands = ({ menu, currentThinkingLevel: commandDefinition.key === "think" - ? resolveTelegramThinkMenuCurrentLevel({ + ? await resolveTelegramThinkMenuCurrentLevel({ cfg: runtimeCfg, agentId: route.agentId, ...menuModelContext, diff --git a/src/auto-reply/reply/get-reply-native-slash-fast-path.ts b/src/auto-reply/reply/get-reply-native-slash-fast-path.ts index 747e6064a6c..57c5e21b256 100644 --- a/src/auto-reply/reply/get-reply-native-slash-fast-path.ts +++ b/src/auto-reply/reply/get-reply-native-slash-fast-path.ts @@ -1,10 +1,16 @@ -import type { ModelAliasIndex } from "../../agents/model-selection.js"; +import { loadModelCatalog } from "../../agents/model-catalog.js"; +import { + buildConfiguredModelCatalog, + resolveThinkingDefault, + type ModelAliasIndex, +} from "../../agents/model-selection.js"; import type { OpenClawConfig } from "../../config/config.js"; import { createLazyImportLoader } from "../../shared/lazy-promise.js"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; import type { GetReplyOptions } from "../get-reply-options.types.js"; import type { ReplyPayload } from "../reply-payload.js"; import type { MsgContext } from "../templating.js"; +import type { ThinkLevel } from "../thinking.js"; import { buildCommandContext } from "./commands-context.js"; import { clearInlineDirectives } from "./get-reply-directives-utils.js"; import { resolveReplyDirectives } from "./get-reply-directives.js"; @@ -42,6 +48,42 @@ function shouldRunNativeSlashCommandFastPath(ctx: MsgContext): boolean { return Boolean(commandName && commandName !== "new" && commandName !== "reset"); } +async function resolveNativeSlashDefaultThinkingLevel(params: { + cfg: OpenClawConfig; + provider: string; + model: string; +}): Promise { + const configuredCatalog = buildConfiguredModelCatalog({ cfg: params.cfg }); + const configuredSelectedEntry = configuredCatalog.find( + (entry) => entry.provider === params.provider && entry.id === params.model, + ); + const shouldHydrateRuntimeCatalog = + configuredCatalog.length === 0 || + !configuredSelectedEntry || + configuredSelectedEntry.reasoning === undefined; + let runtimeCatalog: Awaited> | undefined; + if (shouldHydrateRuntimeCatalog) { + try { + runtimeCatalog = await loadModelCatalog({ config: params.cfg }); + } catch { + runtimeCatalog = undefined; + } + } + const runtimeSelectedEntry = runtimeCatalog?.find( + (entry) => entry.provider === params.provider && entry.id === params.model, + ); + const catalog = + runtimeSelectedEntry || configuredCatalog.length === 0 + ? (runtimeCatalog ?? configuredCatalog) + : configuredCatalog; + return resolveThinkingDefault({ + cfg: params.cfg, + provider: params.provider, + model: params.model, + catalog, + }); +} + export async function maybeResolveNativeSlashCommandFastReply(params: { ctx: MsgContext; cfg: OpenClawConfig; @@ -84,6 +126,11 @@ export async function maybeResolveNativeSlashCommandFastReply(params: { if (command.commandBodyNormalized === "/status") { const targetSessionEntry = sessionState.sessionStore[sessionState.sessionKey] ?? sessionState.sessionEntry; + const resolvedDefaultThinkingLevel = await resolveNativeSlashDefaultThinkingLevel({ + cfg: params.cfg, + provider: params.provider, + model: params.model, + }); const { buildStatusReply } = await loadStatusCommandRuntime(); return { handled: true, @@ -98,11 +145,11 @@ export async function maybeResolveNativeSlashCommandFastReply(params: { provider: params.provider, model: params.model, workspaceDir: params.workspaceDir, - resolvedThinkLevel: undefined, + resolvedThinkLevel: resolvedDefaultThinkingLevel, resolvedVerboseLevel: "off", resolvedReasoningLevel: "off", resolvedElevatedLevel: "off", - resolveDefaultThinkingLevel: async () => undefined, + resolveDefaultThinkingLevel: async () => resolvedDefaultThinkingLevel, isGroup: sessionState.isGroup, defaultGroupActivation: () => "always", mediaDecisions: params.ctx.MediaUnderstandingDecisions, diff --git a/src/auto-reply/reply/get-reply.fast-path.test.ts b/src/auto-reply/reply/get-reply.fast-path.test.ts index 37efd5d1f79..36b6e0225f8 100644 --- a/src/auto-reply/reply/get-reply.fast-path.test.ts +++ b/src/auto-reply/reply/get-reply.fast-path.test.ts @@ -18,12 +18,37 @@ import { import { loadGetReplyModuleForTest } from "./get-reply.test-loader.js"; import "./get-reply.test-runtime-mocks.js"; +type LoadModelCatalogFn = typeof import("../../agents/model-catalog.js").loadModelCatalog; +type ModelAliasIndex = import("../../agents/model-selection.js").ModelAliasIndex; + +function emptyAliasIndex(): ModelAliasIndex { + return { byAlias: new Map(), byKey: new Map() }; +} + const mocks = vi.hoisted(() => ({ ensureAgentWorkspace: vi.fn(), initSessionState: vi.fn(), + loadModelCatalog: vi.fn(async () => [ + { + provider: "openai", + id: "gpt-5.5", + name: "GPT-5.5", + reasoning: true, + }, + ]), resolveReplyDirectives: vi.fn(), })); +vi.mock("../../agents/model-catalog.js", async () => { + const actual = await vi.importActual( + "../../agents/model-catalog.js", + ); + return { + ...actual, + loadModelCatalog: mocks.loadModelCatalog, + }; +}); + vi.mock("../../agents/workspace.js", () => ({ DEFAULT_AGENT_WORKSPACE_DIR: "/tmp/openclaw-workspace", ensureAgentWorkspace: (...args: unknown[]) => mocks.ensureAgentWorkspace(...args), @@ -31,11 +56,14 @@ vi.mock("../../agents/workspace.js", () => ({ registerGetReplyRuntimeOverrides(mocks); let getReplyFromConfig: typeof import("./get-reply.js").getReplyFromConfig; +let resolveDefaultModelMock: typeof import("./directive-handling.defaults.js").resolveDefaultModel; let loadConfigMock: typeof import("../../config/config.js").getRuntimeConfig; let runPreparedReplyMock: typeof import("./get-reply-run.js").runPreparedReply; async function loadGetReplyRuntimeForTest() { ({ getReplyFromConfig } = await loadGetReplyModuleForTest({ cacheKey: import.meta.url })); + ({ resolveDefaultModel: resolveDefaultModelMock } = + await import("./directive-handling.defaults.js")); ({ getRuntimeConfig: loadConfigMock } = await import("../../config/config.js")); ({ runPreparedReply: runPreparedReplyMock } = await import("./get-reply-run.js")); } @@ -49,7 +77,22 @@ describe("getReplyFromConfig fast test bootstrap", () => { vi.stubEnv("OPENCLAW_TEST_FAST", "1"); mocks.ensureAgentWorkspace.mockReset(); mocks.initSessionState.mockReset(); + mocks.loadModelCatalog.mockReset(); + mocks.loadModelCatalog.mockResolvedValue([ + { + provider: "openai", + id: "gpt-5.5", + name: "GPT-5.5", + reasoning: true, + }, + ]); mocks.resolveReplyDirectives.mockReset(); + vi.mocked(resolveDefaultModelMock).mockReset(); + vi.mocked(resolveDefaultModelMock).mockReturnValue({ + defaultProvider: "openai", + defaultModel: "gpt-4o-mini", + aliasIndex: emptyAliasIndex(), + }); vi.mocked(loadConfigMock).mockReset(); vi.mocked(runPreparedReplyMock).mockReset(); vi.mocked(loadConfigMock).mockReturnValue({}); @@ -220,6 +263,11 @@ describe("getReplyFromConfig fast test bootstrap", () => { }, session: { store: path.join(home, "sessions.json") }, } as OpenClawConfig); + vi.mocked(resolveDefaultModelMock).mockReturnValueOnce({ + defaultProvider: "openai", + defaultModel: "gpt-5.5", + aliasIndex: emptyAliasIndex(), + }); const reply = await getReplyFromConfig( buildGetReplyCtx({ @@ -240,6 +288,8 @@ describe("getReplyFromConfig fast test bootstrap", () => { throw new Error("expected status reply text"); } expect(reply.text.includes("OpenClaw")).toBe(true); + expect(reply.text.includes("Think: medium")).toBe(true); + expect(mocks.loadModelCatalog).toHaveBeenCalledWith({ config: cfg }); expect(mocks.ensureAgentWorkspace).not.toHaveBeenCalled(); expect(mocks.initSessionState).not.toHaveBeenCalled(); expect(mocks.resolveReplyDirectives).not.toHaveBeenCalled(); From 8192147b9014e9d622538507af584a3f0e2af8ca Mon Sep 17 00:00:00 2001 From: VACInc <3279061+VACInc@users.noreply.github.com> Date: Sun, 10 May 2026 14:30:30 -0400 Subject: [PATCH 63/93] fix: respect native status thinking overrides --- .../reply/get-reply-native-slash-fast-path.ts | 23 +++++--- .../reply/get-reply.fast-path.test.ts | 55 +++++++++++++++++++ 2 files changed, 70 insertions(+), 8 deletions(-) diff --git a/src/auto-reply/reply/get-reply-native-slash-fast-path.ts b/src/auto-reply/reply/get-reply-native-slash-fast-path.ts index 57c5e21b256..ec3884fa4d0 100644 --- a/src/auto-reply/reply/get-reply-native-slash-fast-path.ts +++ b/src/auto-reply/reply/get-reply-native-slash-fast-path.ts @@ -10,7 +10,7 @@ import { normalizeOptionalString } from "../../shared/string-coerce.js"; import type { GetReplyOptions } from "../get-reply-options.types.js"; import type { ReplyPayload } from "../reply-payload.js"; import type { MsgContext } from "../templating.js"; -import type { ThinkLevel } from "../thinking.js"; +import { normalizeThinkLevel, type ThinkLevel } from "../thinking.js"; import { buildCommandContext } from "./commands-context.js"; import { clearInlineDirectives } from "./get-reply-directives-utils.js"; import { resolveReplyDirectives } from "./get-reply-directives.js"; @@ -126,11 +126,18 @@ export async function maybeResolveNativeSlashCommandFastReply(params: { if (command.commandBodyNormalized === "/status") { const targetSessionEntry = sessionState.sessionStore[sessionState.sessionKey] ?? sessionState.sessionEntry; - const resolvedDefaultThinkingLevel = await resolveNativeSlashDefaultThinkingLevel({ - cfg: params.cfg, - provider: params.provider, - model: params.model, - }); + let resolvedDefaultThinkingLevel: ThinkLevel | undefined; + const resolveDefaultThinkingLevel = async () => { + resolvedDefaultThinkingLevel ??= await resolveNativeSlashDefaultThinkingLevel({ + cfg: params.cfg, + provider: params.provider, + model: params.model, + }); + return resolvedDefaultThinkingLevel; + }; + const resolvedThinkLevel = + normalizeThinkLevel(targetSessionEntry?.thinkingLevel) ?? + (await resolveDefaultThinkingLevel()); const { buildStatusReply } = await loadStatusCommandRuntime(); return { handled: true, @@ -145,11 +152,11 @@ export async function maybeResolveNativeSlashCommandFastReply(params: { provider: params.provider, model: params.model, workspaceDir: params.workspaceDir, - resolvedThinkLevel: resolvedDefaultThinkingLevel, + resolvedThinkLevel, resolvedVerboseLevel: "off", resolvedReasoningLevel: "off", resolvedElevatedLevel: "off", - resolveDefaultThinkingLevel: async () => resolvedDefaultThinkingLevel, + resolveDefaultThinkingLevel, isGroup: sessionState.isGroup, defaultGroupActivation: () => "always", mediaDecisions: params.ctx.MediaUnderstandingDecisions, diff --git a/src/auto-reply/reply/get-reply.fast-path.test.ts b/src/auto-reply/reply/get-reply.fast-path.test.ts index 36b6e0225f8..13bbfe6f353 100644 --- a/src/auto-reply/reply/get-reply.fast-path.test.ts +++ b/src/auto-reply/reply/get-reply.fast-path.test.ts @@ -296,6 +296,61 @@ describe("getReplyFromConfig fast test bootstrap", () => { expect(vi.mocked(runPreparedReplyMock)).not.toHaveBeenCalled(); }); + it("uses the target session thinking override for native /status", async () => { + const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-native-status-think-")); + const storePath = path.join(home, "sessions.json"); + const targetSessionKey = "agent:main:telegram:123"; + await fs.writeFile( + storePath, + JSON.stringify({ + [targetSessionKey]: { + sessionId: "existing-telegram-session", + thinkingLevel: "xhigh", + updatedAt: 1, + }, + }), + "utf8", + ); + const cfg = markCompleteReplyConfig({ + agents: { + defaults: { + model: "openai/gpt-5.5", + workspace: path.join(home, "workspace"), + }, + }, + session: { store: storePath }, + } as OpenClawConfig); + vi.mocked(resolveDefaultModelMock).mockReturnValueOnce({ + defaultProvider: "openai", + defaultModel: "gpt-5.5", + aliasIndex: new Map(), + }); + + const reply = await getReplyFromConfig( + buildGetReplyCtx({ + Body: "/status", + BodyForAgent: "/status", + RawBody: "/status", + CommandBody: "/status", + CommandSource: "native", + CommandAuthorized: true, + SessionKey: "telegram:slash:123", + CommandTargetSessionKey: targetSessionKey, + }), + undefined, + cfg, + ); + + expect(reply).toEqual( + expect.objectContaining({ text: expect.stringContaining("Think: xhigh") }), + ); + expect(mocks.loadModelCatalog).not.toHaveBeenCalled(); + expect(mocks.ensureAgentWorkspace).not.toHaveBeenCalled(); + expect(mocks.initSessionState).not.toHaveBeenCalled(); + expect(mocks.resolveReplyDirectives).not.toHaveBeenCalled(); + expect(vi.mocked(runPreparedReplyMock)).not.toHaveBeenCalled(); + }); + it("handles native slash directives before workspace bootstrap", async () => { const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-native-slash-fast-")); const targetSessionKey = "agent:main:telegram:123"; From d468741c5be5e8e42fd97d0846cbde42d174d4af Mon Sep 17 00:00:00 2001 From: VACInc <3279061+VACInc@users.noreply.github.com> Date: Sun, 10 May 2026 14:56:30 -0400 Subject: [PATCH 64/93] test: fix native status alias index mock --- src/auto-reply/reply/get-reply.fast-path.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/auto-reply/reply/get-reply.fast-path.test.ts b/src/auto-reply/reply/get-reply.fast-path.test.ts index 13bbfe6f353..a6332e5dda5 100644 --- a/src/auto-reply/reply/get-reply.fast-path.test.ts +++ b/src/auto-reply/reply/get-reply.fast-path.test.ts @@ -323,7 +323,7 @@ describe("getReplyFromConfig fast test bootstrap", () => { vi.mocked(resolveDefaultModelMock).mockReturnValueOnce({ defaultProvider: "openai", defaultModel: "gpt-5.5", - aliasIndex: new Map(), + aliasIndex: emptyAliasIndex(), }); const reply = await getReplyFromConfig( From 9a47f0fd3d7c1ce76a0f42d6a603d991d7d8ad7f Mon Sep 17 00:00:00 2001 From: VACInc <3279061+VACInc@users.noreply.github.com> Date: Sun, 10 May 2026 15:05:12 -0400 Subject: [PATCH 65/93] fix: preserve native status thinking precedence --- .../reply/get-reply-native-slash-fast-path.ts | 4 +- .../reply/get-reply.fast-path.test.ts | 50 +++++++++++++++++++ 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/src/auto-reply/reply/get-reply-native-slash-fast-path.ts b/src/auto-reply/reply/get-reply-native-slash-fast-path.ts index ec3884fa4d0..c869d21fc12 100644 --- a/src/auto-reply/reply/get-reply-native-slash-fast-path.ts +++ b/src/auto-reply/reply/get-reply-native-slash-fast-path.ts @@ -135,9 +135,7 @@ export async function maybeResolveNativeSlashCommandFastReply(params: { }); return resolvedDefaultThinkingLevel; }; - const resolvedThinkLevel = - normalizeThinkLevel(targetSessionEntry?.thinkingLevel) ?? - (await resolveDefaultThinkingLevel()); + const resolvedThinkLevel = normalizeThinkLevel(targetSessionEntry?.thinkingLevel); const { buildStatusReply } = await loadStatusCommandRuntime(); return { handled: true, diff --git a/src/auto-reply/reply/get-reply.fast-path.test.ts b/src/auto-reply/reply/get-reply.fast-path.test.ts index a6332e5dda5..f31433a2382 100644 --- a/src/auto-reply/reply/get-reply.fast-path.test.ts +++ b/src/auto-reply/reply/get-reply.fast-path.test.ts @@ -296,6 +296,56 @@ describe("getReplyFromConfig fast test bootstrap", () => { expect(vi.mocked(runPreparedReplyMock)).not.toHaveBeenCalled(); }); + it("uses configured agent thinking defaults for native /status", async () => { + const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-native-status-agent-think-")); + const targetSessionKey = "agent:main:telegram:123"; + const cfg = markCompleteReplyConfig({ + agents: { + defaults: { + model: "openai/gpt-5.5", + workspace: path.join(home, "workspace"), + thinkingDefault: "low", + }, + list: [ + { + id: "main", + thinkingDefault: "high", + }, + ], + }, + session: { store: path.join(home, "sessions.json") }, + } as OpenClawConfig); + vi.mocked(resolveDefaultModelMock).mockReturnValueOnce({ + defaultProvider: "openai", + defaultModel: "gpt-5.5", + aliasIndex: emptyAliasIndex(), + }); + + const reply = await getReplyFromConfig( + buildGetReplyCtx({ + Body: "/status", + BodyForAgent: "/status", + RawBody: "/status", + CommandBody: "/status", + CommandSource: "native", + CommandAuthorized: true, + SessionKey: "telegram:slash:123", + CommandTargetSessionKey: targetSessionKey, + }), + undefined, + cfg, + ); + + expect(reply).toEqual( + expect.objectContaining({ text: expect.stringContaining("Think: high") }), + ); + expect(mocks.loadModelCatalog).not.toHaveBeenCalled(); + expect(mocks.ensureAgentWorkspace).not.toHaveBeenCalled(); + expect(mocks.initSessionState).not.toHaveBeenCalled(); + expect(mocks.resolveReplyDirectives).not.toHaveBeenCalled(); + expect(vi.mocked(runPreparedReplyMock)).not.toHaveBeenCalled(); + }); + it("uses the target session thinking override for native /status", async () => { const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-native-status-think-")); const storePath = path.join(home, "sessions.json"); From 377d7a0b4c7b1c0349719a7803c7bdf2682934e6 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Mon, 11 May 2026 17:37:19 +0530 Subject: [PATCH 66/93] fix(telegram): simplify thinking defaults --- .../telegram/src/bot-native-commands.ts | 30 ++---------------- src/agents/model-selection.ts | 5 ++- src/agents/model-thinking-default.ts | 31 +++++++++++++++++++ src/agents/tools/session-status-tool.ts | 30 +++--------------- .../reply/get-reply-native-slash-fast-path.ts | 30 ++---------------- 5 files changed, 46 insertions(+), 80 deletions(-) diff --git a/extensions/telegram/src/bot-native-commands.ts b/extensions/telegram/src/bot-native-commands.ts index 9b9da162dc2..9ccfa6090b2 100644 --- a/extensions/telegram/src/bot-native-commands.ts +++ b/extensions/telegram/src/bot-native-commands.ts @@ -2,11 +2,10 @@ import { randomUUID } from "node:crypto"; import path from "node:path"; import type { Bot, Context } from "grammy"; import { - buildConfiguredModelCatalog, loadModelCatalog, resolveAgentConfig, resolveDefaultModelForAgent, - resolveThinkingDefault, + resolveThinkingDefaultWithRuntimeCatalog, } from "openclaw/plugin-sdk/agent-runtime"; import { resolveChannelStreamingBlockEnabled } from "openclaw/plugin-sdk/channel-streaming"; import { resolveNativeCommandSessionTargets } from "openclaw/plugin-sdk/command-auth-native"; @@ -262,34 +261,11 @@ async function resolveTelegramDefaultThinkingLevel(params: { provider: string; model: string; }): Promise { - const configuredCatalog = buildConfiguredModelCatalog({ cfg: params.cfg }); - const configuredSelectedEntry = configuredCatalog.find( - (entry) => entry.provider === params.provider && entry.id === params.model, - ); - const shouldHydrateRuntimeCatalog = - configuredCatalog.length === 0 || - !configuredSelectedEntry || - configuredSelectedEntry.reasoning === undefined; - let runtimeCatalog: Awaited> | undefined; - if (shouldHydrateRuntimeCatalog) { - try { - runtimeCatalog = await loadModelCatalog({ config: params.cfg }); - } catch { - runtimeCatalog = undefined; - } - } - const runtimeSelectedEntry = runtimeCatalog?.find( - (entry) => entry.provider === params.provider && entry.id === params.model, - ); - const catalog = - runtimeSelectedEntry || configuredCatalog.length === 0 - ? (runtimeCatalog ?? configuredCatalog) - : configuredCatalog; - return resolveThinkingDefault({ + return resolveThinkingDefaultWithRuntimeCatalog({ cfg: params.cfg, provider: params.provider, model: params.model, - catalog, + loadModelCatalog: () => loadModelCatalog({ config: params.cfg }), }); } diff --git a/src/agents/model-selection.ts b/src/agents/model-selection.ts index f304c81b036..3ea7e98b63e 100644 --- a/src/agents/model-selection.ts +++ b/src/agents/model-selection.ts @@ -17,7 +17,10 @@ import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js"; import { findModelInCatalog } from "./model-catalog-lookup.js"; import type { ModelCatalogEntry } from "./model-catalog.types.js"; import { splitTrailingAuthProfile } from "./model-ref-profile.js"; -export { resolveThinkingDefault } from "./model-thinking-default.js"; +export { + resolveThinkingDefault, + resolveThinkingDefaultWithRuntimeCatalog, +} from "./model-thinking-default.js"; import { type ModelRef, findNormalizedProviderKey, diff --git a/src/agents/model-thinking-default.ts b/src/agents/model-thinking-default.ts index 99422bd4c10..2b96da565d7 100644 --- a/src/agents/model-thinking-default.ts +++ b/src/agents/model-thinking-default.ts @@ -7,6 +7,7 @@ import { import type { ModelCatalogEntry } from "./model-catalog.types.js"; import { legacyModelKey, modelKey, normalizeProviderId } from "./model-selection-normalize.js"; import { normalizeModelSelection } from "./model-selection-resolve.js"; +import { buildConfiguredModelCatalog } from "./model-selection-shared.js"; type ThinkLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "adaptive" | "max"; @@ -77,3 +78,33 @@ export function resolveThinkingDefault(params: { catalog: params.catalog, }); } + +export async function resolveThinkingDefaultWithRuntimeCatalog(params: { + cfg: OpenClawConfig; + provider: string; + model: string; + loadModelCatalog: () => Promise; +}): Promise { + const configuredCatalog = buildConfiguredModelCatalog({ cfg: params.cfg }); + const configuredSelectedEntry = configuredCatalog.find( + (entry) => entry.provider === params.provider && entry.id === params.model, + ); + const needsRuntimeCatalog = + configuredCatalog.length === 0 || + !configuredSelectedEntry || + configuredSelectedEntry.reasoning === undefined; + const runtimeCatalog = needsRuntimeCatalog ? await params.loadModelCatalog() : undefined; + const runtimeSelectedEntry = runtimeCatalog?.find( + (entry) => entry.provider === params.provider && entry.id === params.model, + ); + const catalog = + runtimeSelectedEntry || configuredCatalog.length === 0 + ? (runtimeCatalog ?? configuredCatalog) + : configuredCatalog; + return resolveThinkingDefault({ + cfg: params.cfg, + provider: params.provider, + model: params.model, + catalog, + }); +} diff --git a/src/agents/tools/session-status-tool.ts b/src/agents/tools/session-status-tool.ts index 9bc8d33b2a0..8540ae21cb2 100644 --- a/src/agents/tools/session-status-tool.ts +++ b/src/agents/tools/session-status-tool.ts @@ -28,12 +28,11 @@ import { buildTaskStatusSnapshotForRelatedSessionKeyForOwner } from "../../tasks import { formatTaskStatusDetail, formatTaskStatusTitle } from "../../tasks/task-status.js"; import { loadModelCatalog } from "../model-catalog.js"; import { - buildConfiguredModelCatalog, buildModelAliasIndex, modelKey, resolveDefaultModelForAgent, resolveModelRefFromString, - resolveThinkingDefault, + resolveThinkingDefaultWithRuntimeCatalog, } from "../model-selection.js"; import { createModelVisibilityPolicy } from "../model-visibility-policy.js"; import { @@ -713,32 +712,13 @@ export function createSessionStatusTool(opts?: { resolvedVerboseLevel: (statusSessionEntry.verboseLevel ?? "off") as VerboseLevel, resolvedReasoningLevel: (statusSessionEntry.reasoningLevel ?? "off") as ReasoningLevel, resolvedElevatedLevel: statusSessionEntry.elevatedLevel as ElevatedLevel | undefined, - resolveDefaultThinkingLevel: async () => { - const configuredCatalog = buildConfiguredModelCatalog({ cfg }); - const configuredSelectedEntry = configuredCatalog.find( - (entry) => entry.provider === providerForCard && entry.id === defaultModelForCard, - ); - const shouldHydrateRuntimeCatalog = - configuredCatalog.length === 0 || - !configuredSelectedEntry || - configuredSelectedEntry.reasoning === undefined; - const runtimeCatalog = shouldHydrateRuntimeCatalog - ? await loadModelCatalog({ config: cfg }) - : undefined; - const runtimeSelectedEntry = runtimeCatalog?.find( - (entry) => entry.provider === providerForCard && entry.id === defaultModelForCard, - ); - const catalog = - runtimeSelectedEntry || configuredCatalog.length === 0 - ? (runtimeCatalog ?? configuredCatalog) - : configuredCatalog; - return resolveThinkingDefault({ + resolveDefaultThinkingLevel: () => + resolveThinkingDefaultWithRuntimeCatalog({ cfg, provider: providerForCard, model: defaultModelForCard, - catalog, - }); - }, + loadModelCatalog: () => loadModelCatalog({ config: cfg }), + }), isGroup, defaultGroupActivation: () => "mention", taskLineOverride: taskLine, diff --git a/src/auto-reply/reply/get-reply-native-slash-fast-path.ts b/src/auto-reply/reply/get-reply-native-slash-fast-path.ts index c869d21fc12..b47c42fbccd 100644 --- a/src/auto-reply/reply/get-reply-native-slash-fast-path.ts +++ b/src/auto-reply/reply/get-reply-native-slash-fast-path.ts @@ -1,7 +1,6 @@ import { loadModelCatalog } from "../../agents/model-catalog.js"; import { - buildConfiguredModelCatalog, - resolveThinkingDefault, + resolveThinkingDefaultWithRuntimeCatalog, type ModelAliasIndex, } from "../../agents/model-selection.js"; import type { OpenClawConfig } from "../../config/config.js"; @@ -53,34 +52,11 @@ async function resolveNativeSlashDefaultThinkingLevel(params: { provider: string; model: string; }): Promise { - const configuredCatalog = buildConfiguredModelCatalog({ cfg: params.cfg }); - const configuredSelectedEntry = configuredCatalog.find( - (entry) => entry.provider === params.provider && entry.id === params.model, - ); - const shouldHydrateRuntimeCatalog = - configuredCatalog.length === 0 || - !configuredSelectedEntry || - configuredSelectedEntry.reasoning === undefined; - let runtimeCatalog: Awaited> | undefined; - if (shouldHydrateRuntimeCatalog) { - try { - runtimeCatalog = await loadModelCatalog({ config: params.cfg }); - } catch { - runtimeCatalog = undefined; - } - } - const runtimeSelectedEntry = runtimeCatalog?.find( - (entry) => entry.provider === params.provider && entry.id === params.model, - ); - const catalog = - runtimeSelectedEntry || configuredCatalog.length === 0 - ? (runtimeCatalog ?? configuredCatalog) - : configuredCatalog; - return resolveThinkingDefault({ + return resolveThinkingDefaultWithRuntimeCatalog({ cfg: params.cfg, provider: params.provider, model: params.model, - catalog, + loadModelCatalog: () => loadModelCatalog({ config: params.cfg }), }); } From 6887b12be7d8689828fb6cfe74762cdb0773fcf6 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Mon, 11 May 2026 18:01:21 +0530 Subject: [PATCH 67/93] docs(changelog): add Telegram thinking status entry (#80341) (thanks @VACInc) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4282d9a4e2d..41d8f9d8f68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Feishu: make manual App ID/App Secret setup the default channel-binding path while keeping QR scan-to-create as an optional best-effort flow, and document the manual fallback for domestic Feishu mobile clients that do not react to the QR code. Fixes #80591. Thanks @wei-wei-zhao. +- Telegram: show resolved thinking defaults in native `/status` and `/think` menus while preserving explicit session overrides. (#80341) Thanks @VACInc. - Gateway: scope `sessions.resolve` sessionId and label store loads to the requested agent so large unrelated agent stores are not parsed for scoped lookups. Fixes #51264. (#79474) Thanks @samzong. - Gateway: share serialized streaming event envelopes across eligible WebSocket and node subscribers while preserving per-client sequence numbers. (#80299) Thanks @samzong. - Browser: report Chrome MCP existing-session page readiness in browser status without letting status probes exceed the client timeout. Fixes #80268. (#80280) Thanks @ai-hpc. From 6963fd1492ffe3adae1dc418a87fdcdaff28a227 Mon Sep 17 00:00:00 2001 From: Shakker Date: Mon, 11 May 2026 13:35:53 +0100 Subject: [PATCH 68/93] test: pin session reset hook strings --- .../server.sessions.reset-hooks.test.ts | 30 +++++++++++++++---- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/src/gateway/server.sessions.reset-hooks.test.ts b/src/gateway/server.sessions.reset-hooks.test.ts index 1157d339af3..94bc4db01a9 100644 --- a/src/gateway/server.sessions.reset-hooks.test.ts +++ b/src/gateway/server.sessions.reset-hooks.test.ts @@ -49,6 +49,21 @@ function expectMainHookContext(context: HookEventRecord, sessionId: string) { expect(context.sessionId).toBe(sessionId); } +function expectStringValue(value: unknown, label: string): string { + expect(typeof value, label).toBe("string"); + if (typeof value !== "string") { + throw new Error(`${label} must be a string`); + } + return value; +} + +function expectStringWithPrefix(value: unknown, prefix: string, label: string): string { + const text = expectStringValue(value, label); + expect(text.startsWith(prefix), label).toBe(true); + expect(text.length, label).toBeGreaterThan(prefix.length); + return text; +} + test("sessions.reset emits internal command hook with reason", async () => { const { dir } = await createSessionStoreDir(); await writeSingleLineSession(dir, "sess-main", "hello"); @@ -175,7 +190,12 @@ test("sessions.reset emits enriched session_end and session_start hooks", async expect(endEvent.sessionKey).toBe("agent:main:main"); expect(endEvent.reason).toBe("new"); expect(endEvent.transcriptArchived).toBe(true); - expect(endEvent.sessionFile).toEqual(expect.stringContaining(".jsonl.reset.")); + const archivedSessionFile = expectStringWithPrefix( + endEvent.sessionFile, + path.join(dir, "sess-main.jsonl.reset."), + "archived session file", + ); + expect(path.dirname(archivedSessionFile)).toBe(dir); expect(endEvent.nextSessionId).toBe(startEvent.sessionId); expectMainHookContext(endContext, "sess-main"); expect(startEvent.sessionKey).toBe("agent:main:main"); @@ -213,9 +233,9 @@ test("sessions.reset returns unavailable when active run does not stop", async ( >; expect(store["agent:main:main"]?.sessionId).toBe("sess-main"); const filesAfterResetAttempt = await fs.readdir(dir); - expect(filesAfterResetAttempt).not.toContainEqual( - expect.stringMatching(/^sess-main\.jsonl\.reset\./), - ); + expect( + filesAfterResetAttempt.filter((file) => file.startsWith("sess-main.jsonl.reset.")), + ).toEqual([]); }); test("sessions.reset emits before_reset for the entry actually reset in the writer slot", async () => { @@ -371,7 +391,7 @@ test("sessions.create with emitCommandHooks=true emits reset lifecycle hooks aga expect(startEvent.resumedFrom).toBe("sess-parent-hooks"); expect(startEvent.sessionId).toBeTypeOf("string"); expect(startEvent.sessionId).not.toBe(""); - expect(startEvent.sessionKey).toEqual(expect.stringMatching(/^agent:main:dashboard:/)); + expectStringWithPrefix(startEvent.sessionKey, "agent:main:dashboard:", "created session key"); }); test("sessions.create with emitCommandHooks=true resets parent in place when session.dmScope is 'main' (#77434)", async () => { From 7c6c2fa994e753e01e8b064b99ca52d4b51692a2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 11 May 2026 13:34:27 +0100 Subject: [PATCH 69/93] test: tighten gateway server response assertions --- .../server-methods/agent.create-event.test.ts | 39 ++++++++++--------- .../server-methods/nodes-pending.test.ts | 17 ++++---- .../sessions.send-followup-status.test.ts | 22 +++++------ src/gateway/server-methods/usage.test.ts | 6 +-- 4 files changed, 43 insertions(+), 41 deletions(-) diff --git a/src/gateway/server-methods/agent.create-event.test.ts b/src/gateway/server-methods/agent.create-event.test.ts index 38f4c2fff60..d8e2208b9b1 100644 --- a/src/gateway/server-methods/agent.create-event.test.ts +++ b/src/gateway/server-methods/agent.create-event.test.ts @@ -89,26 +89,29 @@ describe("agent handler session create events", () => { req: { id: "req-agent-create-event" } as never, }); - expect(respond).toHaveBeenCalledWith( - true, - expect.objectContaining({ - status: "accepted", - runId: "idem-agent-create-event", - }), - undefined, - { runId: "idem-agent-create-event" }, - ); + const responseCall = respond.mock.calls[0] as + | [boolean, { status?: string; runId?: string }, unknown, { runId?: string }] + | undefined; + expect(responseCall?.[0]).toBe(true); + expect(responseCall?.[1]?.status).toBe("accepted"); + expect(responseCall?.[1]?.runId).toBe("idem-agent-create-event"); + expect(responseCall?.[2]).toBeUndefined(); + expect(responseCall?.[3]?.runId).toBe("idem-agent-create-event"); await vi.waitFor( () => { - expect(broadcastToConnIds).toHaveBeenCalledWith( - "sessions.changed", - expect.objectContaining({ - sessionKey: "agent:main:subagent:create-test", - reason: "create", - }), - new Set(["conn-1"]), - { dropIfSlow: true }, - ); + const call = broadcastToConnIds.mock.calls[0] as + | [ + string, + { sessionKey?: string; reason?: string }, + Set, + { dropIfSlow?: boolean }, + ] + | undefined; + expect(call?.[0]).toBe("sessions.changed"); + expect(call?.[1]?.sessionKey).toBe("agent:main:subagent:create-test"); + expect(call?.[1]?.reason).toBe("create"); + expect(call?.[2]).toEqual(new Set(["conn-1"])); + expect(call?.[3]).toEqual({ dropIfSlow: true }); }, { timeout: 2_000, interval: 5 }, ); diff --git a/src/gateway/server-methods/nodes-pending.test.ts b/src/gateway/server-methods/nodes-pending.test.ts index 436e54981b3..47cfb0cabde 100644 --- a/src/gateway/server-methods/nodes-pending.test.ts +++ b/src/gateway/server-methods/nodes-pending.test.ts @@ -166,14 +166,13 @@ describe("node.pending handlers", () => { timeoutMs: 3_000, }); expect(mocks.maybeSendNodeWakeNudge).not.toHaveBeenCalled(); - expect(respond).toHaveBeenCalledWith( - true, - expect.objectContaining({ - nodeId: "ios-node-2", - revision: 4, - wakeTriggered: true, - }), - undefined, - ); + const call = respond.mock.calls[0] as + | [boolean, { nodeId?: string; revision?: number; wakeTriggered?: boolean }, unknown?] + | undefined; + expect(call?.[0]).toBe(true); + expect(call?.[1]?.nodeId).toBe("ios-node-2"); + expect(call?.[1]?.revision).toBe(4); + expect(call?.[1]?.wakeTriggered).toBe(true); + expect(call?.[2]).toBeUndefined(); }); }); diff --git a/src/gateway/server-methods/sessions.send-followup-status.test.ts b/src/gateway/server-methods/sessions.send-followup-status.test.ts index 5e6294fb4b1..e098118b8d1 100644 --- a/src/gateway/server-methods/sessions.send-followup-status.test.ts +++ b/src/gateway/server-methods/sessions.send-followup-status.test.ts @@ -88,7 +88,8 @@ describe("sessions.send completed subagent follow-up status", () => { }); const broadcastToConnIds = vi.fn(); - const respond = vi.fn() as unknown as RespondFn; + const respondMock = vi.fn(); + const respond = respondMock as unknown as RespondFn; const context = { chatAbortControllers: new Map(), broadcastToConnIds, @@ -109,16 +110,15 @@ describe("sessions.send completed subagent follow-up status", () => { isWebchatConnect: () => false, }); - expect(respond).toHaveBeenCalledWith( - true, - expect.objectContaining({ - runId: "run-new", - status: "started", - messageSeq: 1, - }), - undefined, - undefined, - ); + const call = respondMock.mock.calls[0] as + | [boolean, { runId?: string; status?: string; messageSeq?: number }, unknown?, unknown?] + | undefined; + expect(call?.[0]).toBe(true); + expect(call?.[1]?.runId).toBe("run-new"); + expect(call?.[1]?.status).toBe("started"); + expect(call?.[1]?.messageSeq).toBe(1); + expect(call?.[2]).toBeUndefined(); + expect(call?.[3]).toBeUndefined(); expectSubagentFollowupReactivation({ replaceSubagentRunAfterSteerMock, broadcastToConnIds, diff --git a/src/gateway/server-methods/usage.test.ts b/src/gateway/server-methods/usage.test.ts index eb870d63c1b..8a9e9e6996c 100644 --- a/src/gateway/server-methods/usage.test.ts +++ b/src/gateway/server-methods/usage.test.ts @@ -157,8 +157,8 @@ describe("gateway usage helpers", () => { expect(a.totals.totalTokens).toBe(1); expect(b.totals.totalTokens).toBe(1); expect(vi.mocked(loadCostUsageSummaryFromCache)).toHaveBeenCalledTimes(1); - expect(vi.mocked(loadCostUsageSummaryFromCache).mock.calls[0]?.[0]).toMatchObject({ - refreshMode: "sync-when-empty", - }); + expect(vi.mocked(loadCostUsageSummaryFromCache).mock.calls[0]?.[0]?.refreshMode).toBe( + "sync-when-empty", + ); }); }); From 6fb630aec10a0082c92f7e97d6244335d81dfbfb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 11 May 2026 13:36:46 +0100 Subject: [PATCH 70/93] test: avoid mutating diagnostics snapshot keys --- src/gateway/server-methods/diagnostics.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/gateway/server-methods/diagnostics.test.ts b/src/gateway/server-methods/diagnostics.test.ts index c58c5ab26ad..0c512320155 100644 --- a/src/gateway/server-methods/diagnostics.test.ts +++ b/src/gateway/server-methods/diagnostics.test.ts @@ -84,7 +84,7 @@ describe("diagnostics gateway methods", () => { }, undefined, ]); - expect(Object.keys(respond.mock.calls[0]?.[1] as Record).sort()).toEqual([ + expect(Object.keys(respond.mock.calls[0]?.[1] as Record).toSorted()).toEqual([ "capacity", "count", "dropped", From b41d766394e02c80e4e9f39c2b603d06347db9c1 Mon Sep 17 00:00:00 2001 From: Shakker Date: Mon, 11 May 2026 13:37:18 +0100 Subject: [PATCH 71/93] test: pin skill update config writes --- .../skills.update.normalizes-api-key.test.ts | 54 +++++++++---------- 1 file changed, 24 insertions(+), 30 deletions(-) diff --git a/src/gateway/server-methods/skills.update.normalizes-api-key.test.ts b/src/gateway/server-methods/skills.update.normalizes-api-key.test.ts index e7364fcbe67..427d653510a 100644 --- a/src/gateway/server-methods/skills.update.normalizes-api-key.test.ts +++ b/src/gateway/server-methods/skills.update.normalizes-api-key.test.ts @@ -23,6 +23,18 @@ vi.mock("../../config/config.js", () => { const { skillsHandlers } = await import("./skills.js"); +function expectWrittenSkillEntry(skillKey: string, entry: unknown) { + expect(writtenConfig).toBeDefined(); + const config = writtenConfig as { + skills?: { + entries?: Record; + }; + }; + expect(Object.keys(config).sort()).toEqual(["skills"]); + expect(Object.keys(config.skills ?? {}).sort()).toEqual(["entries"]); + expect(config.skills?.entries?.[skillKey]).toEqual(entry); +} + describe("skills.update", () => { it("strips embedded CR/LF from apiKey", async () => { writtenConfig = null; @@ -51,14 +63,8 @@ describe("skills.update", () => { expect(ok).toBe(true); expect(error).toBeUndefined(); - expect(writtenConfig).toMatchObject({ - skills: { - entries: { - "brave-search": { - apiKey: "abcdef", - }, - }, - }, + expectWrittenSkillEntry("brave-search", { + apiKey: "abcdef", }); }); @@ -90,17 +96,11 @@ describe("skills.update", () => { }); // Full values must be persisted to config - expect(writtenConfig).toMatchObject({ - skills: { - entries: { - "demo-skill": { - apiKey: "secret-api-key-123", - env: { - GEMINI_API_KEY: "secret-env-key-456", - BRAVE_REGION: "us", - }, - }, - }, + expectWrittenSkillEntry("demo-skill", { + apiKey: "secret-api-key-123", + env: { + GEMINI_API_KEY: "secret-env-key-456", + BRAVE_REGION: "us", }, }); @@ -145,17 +145,11 @@ describe("skills.update", () => { respond: () => {}, }); - expect(writtenConfig).toMatchObject({ - skills: { - entries: { - "demo-skill": { - apiKey: "secret-api-key-123", - env: { - GEMINI_API_KEY: "secret-env-key-456", - BRAVE_REGION: "eu", - }, - }, - }, + expectWrittenSkillEntry("demo-skill", { + apiKey: "secret-api-key-123", + env: { + GEMINI_API_KEY: "secret-env-key-456", + BRAVE_REGION: "eu", }, }); }); From 1517c36dbdde86adadd7c165fe6f138a20ec1068 Mon Sep 17 00:00:00 2001 From: Shakker Date: Mon, 11 May 2026 13:38:53 +0100 Subject: [PATCH 72/93] test: pin reload deferral warnings --- src/gateway/server-reload-handlers.test.ts | 26 ++++++++++++++++------ 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/src/gateway/server-reload-handlers.test.ts b/src/gateway/server-reload-handlers.test.ts index f1546f498ba..16cc2c769ca 100644 --- a/src/gateway/server-reload-handlers.test.ts +++ b/src/gateway/server-reload-handlers.test.ts @@ -125,18 +125,30 @@ describe("gateway restart deferral preflight", () => { }, ); - expect(logReload.warn).toHaveBeenCalledWith( - expect.stringContaining( - "restart blocked by active background task run(s): taskId=task-nightly", - ), - ); - expect(logReload.warn).toHaveBeenCalledWith(expect.stringContaining("runId=run-nightly")); + expect(logReload.warn.mock.calls).toEqual([ + [ + "config change requires gateway restart (gateway.port) — deferring until 1 background task run(s) complete", + ], + [ + "restart blocked by active background task run(s): taskId=task-nightly runId=run-nightly status=running runtime=cron label=nightly sync title=refresh all accounts", + ], + ]); await vi.advanceTimersByTimeAsync(1_000); await Promise.resolve(); expect(signalSpy).toHaveBeenCalledTimes(1); - expect(logReload.warn).toHaveBeenCalledWith(expect.stringContaining("; forcing restart")); + expect(logReload.warn.mock.calls).toEqual([ + [ + "config change requires gateway restart (gateway.port) — deferring until 1 background task run(s) complete", + ], + [ + "restart blocked by active background task run(s): taskId=task-nightly runId=run-nightly status=running runtime=cron label=nightly sync title=refresh all accounts", + ], + [ + "restart timeout after 1000ms with 1 background task run(s) still active (taskId=task-nightly runId=run-nightly status=running runtime=cron label=nightly sync title=refresh all accounts); forcing restart", + ], + ]); } finally { hoisted.activeTaskCount.value = 0; vi.useRealTimers(); From 39348e9a932d360cbaf2727e374fd6b3a408c94b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 11 May 2026 13:38:46 +0100 Subject: [PATCH 73/93] test: tighten skill gateway assertions --- .../server-methods/skills.clawhub.test.ts | 51 ++++++++++--------- 1 file changed, 27 insertions(+), 24 deletions(-) diff --git a/src/gateway/server-methods/skills.clawhub.test.ts b/src/gateway/server-methods/skills.clawhub.test.ts index dda8a584d88..5c3ce6fc99d 100644 --- a/src/gateway/server-methods/skills.clawhub.test.ts +++ b/src/gateway/server-methods/skills.clawhub.test.ts @@ -81,12 +81,13 @@ describe("skills gateway handlers (clawhub)", () => { }); expect(ok).toBe(true); expect(error).toBeUndefined(); - expect(response).toMatchObject({ - ok: true, - message: "Installed calendar@1.2.3", - slug: "calendar", - version: "1.2.3", - }); + const result = response as + | { ok?: boolean; message?: string; slug?: string; version?: string } + | undefined; + expect(result?.ok).toBe(true); + expect(result?.message).toBe("Installed calendar@1.2.3"); + expect(result?.slug).toBe("calendar"); + expect(result?.version).toBe("1.2.3"); }); it("forwards dangerous override for local skill installs", async () => { @@ -129,10 +130,9 @@ describe("skills gateway handlers (clawhub)", () => { }); expect(ok).toBe(true); expect(error).toBeUndefined(); - expect(response).toMatchObject({ - ok: true, - message: "Installed", - }); + const result = response as { ok?: boolean; message?: string } | undefined; + expect(result?.ok).toBe(true); + expect(result?.message).toBe("Installed"); }); it("updates ClawHub skills through skills.update", async () => { @@ -172,20 +172,23 @@ describe("skills gateway handlers (clawhub)", () => { }); expect(ok).toBe(true); expect(error).toBeUndefined(); - expect(response).toMatchObject({ - ok: true, - skillKey: "calendar", - config: { - source: "clawhub", - results: [ - { - ok: true, - slug: "calendar", - version: "1.2.3", - }, - ], - }, - }); + const result = response as + | { + ok?: boolean; + skillKey?: string; + config?: { + source?: string; + results?: Array<{ ok?: boolean; slug?: string; version?: string }>; + }; + } + | undefined; + expect(result?.ok).toBe(true); + expect(result?.skillKey).toBe("calendar"); + expect(result?.config?.source).toBe("clawhub"); + expect(result?.config?.results).toHaveLength(1); + expect(result?.config?.results?.[0]?.ok).toBe(true); + expect(result?.config?.results?.[0]?.slug).toBe("calendar"); + expect(result?.config?.results?.[0]?.version).toBe("1.2.3"); }); it("rejects ClawHub skills.update requests without slug or all", async () => { From 96b672c54dd6f224156ebb0cebdebdc70edec8db Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Mon, 11 May 2026 07:40:47 -0500 Subject: [PATCH 74/93] Stabilize Control UI connection diagnostics (#80510) Summary: - Catch browser-side WebSocket constructor security failures and surface wss://, Tailscale, and loopback dashboard guidance. - Classify the browser WebSocket security code through Control UI login and overview insecure-context hints. - Keep the changelog attribution under the active Fixes section. Verification: - pnpm test ui/src/ui/gateway.node.test.ts ui/src/ui/views/login-gate.test.ts ui/src/ui/views/overview.node.test.ts src/logging/diagnostic.test.ts - pnpm exec oxfmt --check --threads=1 CHANGELOG.md src/logging/diagnostic-stability.ts src/logging/diagnostic.test.ts ui/src/ui/gateway.ts ui/src/ui/gateway.node.test.ts ui/src/ui/views/login-gate.test.ts ui/src/ui/views/overview-hints.ts ui/src/ui/views/overview.node.test.ts - git diff --check origin/main...HEAD - pnpm check:changed - GitHub Real behavior proof and CI preflight passed on 1ea05289b13fab82d2f67de367b3d987f2a66131 --- CHANGELOG.md | 1 + ui/src/ui/gateway.node.test.ts | 81 +++++++++++++++++++++++++++ ui/src/ui/gateway.ts | 80 +++++++++++++++++++++++++- ui/src/ui/views/login-gate.test.ts | 30 ++++++++++ ui/src/ui/views/overview-hints.ts | 3 + ui/src/ui/views/overview.node.test.ts | 23 ++++++++ 6 files changed, 217 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 41d8f9d8f68..2a1d767d7d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -428,6 +428,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Control UI: surface browser-blocked WebSocket security failures with wss:// and loopback dashboard guidance instead of leaving the connection on a dead security error. Thanks @BunsDev. - Gateway/diagnostics: keep active-only transient event-loop max-delay samples as info-level stability telemetry instead of warning-level liveness diagnostics. Thanks @BunsDev. - Google/Gemini: default new API-key onboarding to stable `google/gemini-2.5-flash` instead of the preview Pro route, reducing surprise daily quota exhaustion. Fixes #79670. Thanks @HugeBunny. - Amazon Bedrock: expose Claude thinking profiles through the lightweight provider policy surface so `/think:adaptive` validates before the Bedrock runtime plugin is loaded. Fixes #79754. Thanks @phoenixyy and @hclsys. diff --git a/ui/src/ui/gateway.node.test.ts b/ui/src/ui/gateway.node.test.ts index 053bcab9455..f51d8695bb8 100644 --- a/ui/src/ui/gateway.node.test.ts +++ b/ui/src/ui/gateway.node.test.ts @@ -283,6 +283,87 @@ describe("GatewayBrowserClient", () => { expect(connectFrame.params?.scopes).toEqual([...CONTROL_UI_OPERATOR_SCOPES]); }); + it("reports browser security errors from WebSocket construction without retrying", async () => { + vi.useFakeTimers(); + const onClose = vi.fn(); + class ThrowingWebSocket { + static OPEN = 1; + + constructor(_url: string) { + const err = new Error("Cannot connect due to a security error."); + err.name = "SecurityError"; + throw err; + } + } + vi.stubGlobal("WebSocket", ThrowingWebSocket); + + const client = new GatewayBrowserClient({ + url: "ws://gateway.example:18789", + token: "shared-auth-token", + onClose, + }); + + expect(() => client.start()).not.toThrow(); + expect(onClose).toHaveBeenCalledWith({ + code: 1006, + reason: "security error", + error: expect.objectContaining({ + code: "BROWSER_WEBSOCKET_SECURITY_ERROR", + message: expect.stringContaining("Use wss://"), + details: expect.objectContaining({ + code: "BROWSER_WEBSOCKET_SECURITY_ERROR", + browserErrorName: "SecurityError", + }), + }), + }); + expect(wsInstances).toHaveLength(0); + + await vi.advanceTimersByTimeAsync(30_000); + expect(onClose).toHaveBeenCalledTimes(1); + + vi.useRealTimers(); + }); + + it("reports generic WebSocket construction failures without retrying", async () => { + vi.useFakeTimers(); + const onClose = vi.fn(); + class ThrowingWebSocket { + static OPEN = 1; + + constructor(_url: string) { + throw new TypeError("constructor failed"); + } + } + vi.stubGlobal("WebSocket", ThrowingWebSocket); + + const client = new GatewayBrowserClient({ + url: "ws://gateway.example:18789", + token: "shared-auth-token", + onClose, + }); + + expect(() => client.start()).not.toThrow(); + expect(onClose).toHaveBeenCalledWith({ + code: 1006, + reason: "websocket error", + error: expect.objectContaining({ + code: "BROWSER_WEBSOCKET_CONSTRUCTOR_ERROR", + message: expect.stringContaining("Could not create the Gateway WebSocket"), + details: expect.objectContaining({ + code: "BROWSER_WEBSOCKET_CONSTRUCTOR_ERROR", + browserErrorName: "TypeError", + browserMessage: "constructor failed", + }), + }), + }); + expect(wsInstances).toHaveLength(0); + + await vi.advanceTimersByTimeAsync(30_000); + expect(onClose).toHaveBeenCalledTimes(1); + + vi.useRealTimers(); + }); + it("reports request timing for attributed RPC latency", async () => { const onRequestTiming = vi.fn(); const client = new GatewayBrowserClient({ diff --git a/ui/src/ui/gateway.ts b/ui/src/ui/gateway.ts index 60f86f23764..50861faf25b 100644 --- a/ui/src/ui/gateway.ts +++ b/ui/src/ui/gateway.ts @@ -246,6 +246,9 @@ export type GatewayRequestTiming = { // 4008 = application-defined code (browser rejects 1008 "Policy Violation") const CONNECT_FAILED_CLOSE_CODE = 4008; const STARTUP_RETRY_CLOSE_CODE = 4013; +const BROWSER_WEBSOCKET_CLOSE_CODE = 1006; +const BROWSER_WEBSOCKET_CONSTRUCTOR_ERROR_CODE = "BROWSER_WEBSOCKET_CONSTRUCTOR_ERROR"; +const BROWSER_WEBSOCKET_SECURITY_ERROR_CODE = "BROWSER_WEBSOCKET_SECURITY_ERROR"; function buildGatewayConnectAuth( selectedAuth: SelectedConnectAuth, @@ -261,6 +264,62 @@ function buildGatewayConnectAuth( }; } +function getErrorMessage(err: unknown): string { + return err instanceof Error && err.message ? err.message : String(err); +} + +function getErrorName(err: unknown): string | undefined { + if (err instanceof Error && err.name) { + return err.name; + } + if (err && typeof err === "object" && "name" in err) { + const name = (err as { name?: unknown }).name; + return typeof name === "string" && name.trim() ? name : undefined; + } + return undefined; +} + +function isBrowserWebSocketSecurityError(err: unknown): boolean { + const name = getErrorName(err)?.toLowerCase(); + const message = getErrorMessage(err).toLowerCase(); + return ( + name === "securityerror" || + message.includes("security error") || + message.includes("mixed content") || + message.includes("insecure websocket") + ); +} + +function formatBrowserWebSocketConstructorError(err: unknown, url: string): GatewayErrorInfo { + const securityError = isBrowserWebSocketSecurityError(err); + const browserMessage = getErrorMessage(err); + const isPlaintextWs = url.trim().toLowerCase().startsWith("ws://"); + if (securityError) { + return { + code: BROWSER_WEBSOCKET_SECURITY_ERROR_CODE, + message: + "Browser refused the Gateway WebSocket for security reasons." + + (isPlaintextWs + ? " Use wss:// when the Control UI is served over HTTPS/Tailscale Serve, or open the loopback dashboard at http://127.0.0.1:18789." + : " Check the Gateway WebSocket URL and browser security policy."), + details: { + code: BROWSER_WEBSOCKET_SECURITY_ERROR_CODE, + browserErrorName: getErrorName(err), + browserMessage, + }, + }; + } + return { + code: BROWSER_WEBSOCKET_CONSTRUCTOR_ERROR_CODE, + message: `Could not create the Gateway WebSocket: ${browserMessage}`, + details: { + code: BROWSER_WEBSOCKET_CONSTRUCTOR_ERROR_CODE, + browserErrorName: getErrorName(err), + browserMessage, + }, + }; +} + async function buildGatewayConnectDevice(params: { deviceIdentity: Awaited> | null; client: GatewayConnectClientInfo; @@ -350,7 +409,26 @@ export class GatewayBrowserClient { if (this.closed) { return; } - const ws = new WebSocket(this.opts.url); + let ws: WebSocket; + try { + ws = new WebSocket(this.opts.url); + } catch (err) { + const error = formatBrowserWebSocketConstructorError(err, this.opts.url); + this.ws = null; + this.pendingConnectError = undefined; + this.pendingDeviceTokenRetry = false; + this.pendingStartupReconnectDelayMs = null; + this.flushPending(new Error(error.message)); + this.opts.onClose?.({ + code: BROWSER_WEBSOCKET_CLOSE_CODE, + reason: + error.code === BROWSER_WEBSOCKET_SECURITY_ERROR_CODE + ? "security error" + : "websocket error", + error, + }); + return; + } const generation = ++this.connectGeneration; this.ws = ws; ws.addEventListener("open", () => this.queueConnect(ws, generation)); diff --git a/ui/src/ui/views/login-gate.test.ts b/ui/src/ui/views/login-gate.test.ts index 219f2dcf5ee..13212c78f6c 100644 --- a/ui/src/ui/views/login-gate.test.ts +++ b/ui/src/ui/views/login-gate.test.ts @@ -115,6 +115,36 @@ describe("resolveLoginFailureFeedback", () => { expect(feedback?.steps.join(" ")).toContain("gateway.controlUi.allowInsecureAuth"); }); + it("explains browser WebSocket security failures as insecure context", () => { + const feedback = resolveLoginFailureFeedback({ + connected: false, + lastError: + "Browser refused the Gateway WebSocket for security reasons. Use wss:// when the Control UI is served over HTTPS/Tailscale Serve, or open the loopback dashboard at http://127.0.0.1:18789.", + lastErrorCode: "BROWSER_WEBSOCKET_SECURITY_ERROR", + hasToken: true, + hasPassword: false, + }); + + expect(feedback?.kind).toBe("insecure-context"); + expect(feedback?.rawError).toContain("Use wss://"); + expect(feedback?.rawError).toContain("http://127.0.0.1:18789"); + expect(feedback?.steps.join(" ")).toContain("Tailscale Serve"); + expect(feedback?.steps.join(" ")).toContain("gateway.controlUi.allowInsecureAuth"); + }); + + it("keeps generic browser WebSocket constructor failures on the network path", () => { + const feedback = resolveLoginFailureFeedback({ + connected: false, + lastError: "Could not create the Gateway WebSocket: constructor failed", + lastErrorCode: "BROWSER_WEBSOCKET_CONSTRUCTOR_ERROR", + hasToken: false, + hasPassword: false, + }); + + expect(feedback?.kind).toBe("network"); + expect(feedback?.steps.join(" ")).toContain("WebSocket URL"); + }); + it("explains browser origin rejections", () => { const feedback = resolveLoginFailureFeedback({ connected: false, diff --git a/ui/src/ui/views/overview-hints.ts b/ui/src/ui/views/overview-hints.ts index ecc184ef7f7..d3f759c30da 100644 --- a/ui/src/ui/views/overview-hints.ts +++ b/ui/src/ui/views/overview-hints.ts @@ -25,7 +25,10 @@ const AUTH_FAILURE_CODES = new Set([ ConnectErrorDetailCodes.AUTH_TAILSCALE_IDENTITY_MISMATCH, ]); +const BROWSER_WEBSOCKET_SECURITY_ERROR_CODE = "BROWSER_WEBSOCKET_SECURITY_ERROR"; + const INSECURE_CONTEXT_CODES = new Set([ + BROWSER_WEBSOCKET_SECURITY_ERROR_CODE, ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED, ConnectErrorDetailCodes.DEVICE_IDENTITY_REQUIRED, ]); diff --git a/ui/src/ui/views/overview.node.test.ts b/ui/src/ui/views/overview.node.test.ts index 33500d1dae6..a806ec56727 100644 --- a/ui/src/ui/views/overview.node.test.ts +++ b/ui/src/ui/views/overview.node.test.ts @@ -4,6 +4,7 @@ import { ConnectErrorDetailCodes } from "../../../../src/gateway/protocol/connec import { resolveAuthHintKind, resolvePairingHint, + shouldShowInsecureContextHint, shouldShowPairingHint, } from "./overview-hints.ts"; @@ -107,3 +108,25 @@ describe("resolveAuthHintKind", () => { ).toBe("failed"); }); }); + +describe("shouldShowInsecureContextHint", () => { + it("returns true for browser WebSocket security errors", () => { + expect( + shouldShowInsecureContextHint( + false, + "Browser refused the Gateway WebSocket for security reasons.", + "BROWSER_WEBSOCKET_SECURITY_ERROR", + ), + ).toBe(true); + }); + + it("does not treat generic WebSocket constructor errors as insecure context", () => { + expect( + shouldShowInsecureContextHint( + false, + "Could not create the Gateway WebSocket: constructor failed", + "BROWSER_WEBSOCKET_CONSTRUCTOR_ERROR", + ), + ).toBe(false); + }); +}); From 29f85ca246d7f9d28d5ddd8bbdee5ae53572665a Mon Sep 17 00:00:00 2001 From: Shakker Date: Mon, 11 May 2026 13:41:08 +0100 Subject: [PATCH 75/93] test: pin gateway client logs --- src/gateway/client.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/gateway/client.test.ts b/src/gateway/client.test.ts index 8240f9193a8..d54fe00fd03 100644 --- a/src/gateway/client.test.ts +++ b/src/gateway/client.test.ts @@ -513,10 +513,10 @@ describe("GatewayClient request errors", () => { expect(onConnectError).not.toHaveBeenCalled(); expect(onClose).not.toHaveBeenCalled(); expect(ws.lastClose).toEqual({ code: 1013, reason: "gateway starting" }); - expect(logDebugMock).toHaveBeenCalledWith(expect.stringContaining("gateway connect failed:")); - expect(logErrorMock).not.toHaveBeenCalledWith( - expect.stringContaining("gateway connect failed:"), - ); + expect(logDebugMock.mock.calls).toEqual([ + ["gateway connect failed: GatewayClientRequestError: gateway starting; retry shortly"], + ]); + expect(logErrorMock.mock.calls).toEqual([]); expect(wsInstances).toHaveLength(1); await vi.advanceTimersByTimeAsync(249); @@ -568,7 +568,7 @@ describe("GatewayClient close handling", () => { expect(getLatestWs().emitClose(1008, "unauthorized: device token mismatch")).toBeUndefined(); expect(logDebugMock).toHaveBeenCalledWith( - expect.stringContaining("failed clearing stale device-auth token"), + "failed clearing stale device-auth token for device dev-2: Error: disk unavailable", ); expect(onClose).toHaveBeenCalledWith(1008, "unauthorized: device token mismatch"); client.stop(); From 26b32601f08cf10370280a8e6dd5158507bb751b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 11 May 2026 13:41:35 +0100 Subject: [PATCH 76/93] test: tighten tools catalog assertions --- .../skills.update.normalizes-api-key.test.ts | 4 ++-- .../server-methods/tools-catalog.test.ts | 23 ++++++++----------- 2 files changed, 11 insertions(+), 16 deletions(-) diff --git a/src/gateway/server-methods/skills.update.normalizes-api-key.test.ts b/src/gateway/server-methods/skills.update.normalizes-api-key.test.ts index 427d653510a..2ce1c1f1213 100644 --- a/src/gateway/server-methods/skills.update.normalizes-api-key.test.ts +++ b/src/gateway/server-methods/skills.update.normalizes-api-key.test.ts @@ -30,8 +30,8 @@ function expectWrittenSkillEntry(skillKey: string, entry: unknown) { entries?: Record; }; }; - expect(Object.keys(config).sort()).toEqual(["skills"]); - expect(Object.keys(config.skills ?? {}).sort()).toEqual(["entries"]); + expect(Object.keys(config).toSorted()).toEqual(["skills"]); + expect(Object.keys(config.skills ?? {}).toSorted()).toEqual(["entries"]); expect(config.skills?.entries?.[skillKey]).toEqual(entry); } diff --git a/src/gateway/server-methods/tools-catalog.test.ts b/src/gateway/server-methods/tools-catalog.test.ts index 2b34ae265ee..eaa7e60529d 100644 --- a/src/gateway/server-methods/tools-catalog.test.ts +++ b/src/gateway/server-methods/tools-catalog.test.ts @@ -124,11 +124,9 @@ describe("tools.catalog handler", () => { const voiceCall = pluginGroups .flatMap((group) => group.tools) .find((tool) => tool.id === "voice_call"); - expect(voiceCall).toMatchObject({ - source: "plugin", - pluginId: "voice-call", - optional: true, - }); + expect(voiceCall?.source).toBe("plugin"); + expect(voiceCall?.pluginId).toBe("voice-call"); + expect(voiceCall?.optional).toBe(true); }); it("summarizes plugin tool descriptions the same way as the effective inventory", async () => { @@ -159,15 +157,12 @@ describe("tools.catalog handler", () => { await invoke(); - expect(vi.mocked(resolvePluginTools)).toHaveBeenCalledWith( - expect.objectContaining({ - allowGatewaySubagentBinding: true, - }), - ); - expect(vi.mocked(ensureStandalonePluginToolRegistryLoaded)).toHaveBeenCalledWith( - expect.objectContaining({ - allowGatewaySubagentBinding: true, - }), + expect(vi.mocked(resolvePluginTools).mock.calls[0]?.[0]?.allowGatewaySubagentBinding).toBe( + true, ); + expect( + vi.mocked(ensureStandalonePluginToolRegistryLoaded).mock.calls[0]?.[0] + ?.allowGatewaySubagentBinding, + ).toBe(true); }); }); From 296a0feddc1c889dfc78c7a8f8650900444b5af6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 11 May 2026 13:43:34 +0100 Subject: [PATCH 77/93] test: tighten gateway rate limit assertions --- ...r-methods.control-plane-rate-limit.test.ts | 37 +++++++------------ .../server-methods/agent-wait-dedupe.test.ts | 19 +++++----- 2 files changed, 23 insertions(+), 33 deletions(-) diff --git a/src/gateway/server-methods.control-plane-rate-limit.test.ts b/src/gateway/server-methods.control-plane-rate-limit.test.ts index c5608041e29..01832bc92d9 100644 --- a/src/gateway/server-methods.control-plane-rate-limit.test.ts +++ b/src/gateway/server-methods.control-plane-rate-limit.test.ts @@ -95,14 +95,11 @@ describe("gateway control-plane write rate limit", () => { const blocked = await runRequest({ method: "config.patch", context, client, handler }); expect(handlerCalls).toHaveBeenCalledTimes(3); - expect(blocked).toHaveBeenCalledWith( - false, - undefined, - expect.objectContaining({ - code: "UNAVAILABLE", - retryable: true, - }), - ); + const error = blocked.mock.calls[0]?.[2] as { code?: string; retryable?: boolean } | undefined; + expect(blocked.mock.calls[0]?.[0]).toBe(false); + expect(blocked.mock.calls[0]?.[1]).toBeUndefined(); + expect(error?.code).toBe("UNAVAILABLE"); + expect(error?.retryable).toBe(true); expect(logWarn).toHaveBeenCalledTimes(1); }); @@ -120,11 +117,9 @@ describe("gateway control-plane write rate limit", () => { await runRequest({ method: "update.run", context, client, handler }); const blocked = await runRequest({ method: "update.run", context, client, handler }); - expect(blocked).toHaveBeenCalledWith( - false, - undefined, - expect.objectContaining({ code: "UNAVAILABLE" }), - ); + expect(blocked.mock.calls[0]?.[0]).toBe(false); + expect(blocked.mock.calls[0]?.[1]).toBeUndefined(); + expect(blocked.mock.calls[0]?.[2]?.code).toBe("UNAVAILABLE"); vi.advanceTimersByTime(60_001); @@ -150,17 +145,13 @@ describe("gateway control-plane write rate limit", () => { const blocked = await runRequest({ method, context, client, handler }); expect(handlerCalls).not.toHaveBeenCalled(); - expect(blocked).toHaveBeenCalledWith( - false, - undefined, - expect.objectContaining({ - code: "UNAVAILABLE", - retryable: true, - retryAfterMs: 500, - details: { reason: "startup-sidecars", method }, - }), - ); const error = blocked.mock.calls[0]?.[2]; + expect(blocked.mock.calls[0]?.[0]).toBe(false); + expect(blocked.mock.calls[0]?.[1]).toBeUndefined(); + expect(error?.code).toBe("UNAVAILABLE"); + expect(error?.retryable).toBe(true); + expect(error?.retryAfterMs).toBe(500); + expect(error?.details).toEqual({ reason: "startup-sidecars", method }); expect(isRetryableGatewayStartupUnavailableError(error)).toBe(true); }, ); diff --git a/src/gateway/server-methods/agent-wait-dedupe.test.ts b/src/gateway/server-methods/agent-wait-dedupe.test.ts index 0564bc96c80..143a17030d5 100644 --- a/src/gateway/server-methods/agent-wait-dedupe.test.ts +++ b/src/gateway/server-methods/agent-wait-dedupe.test.ts @@ -386,16 +386,15 @@ describe("agent wait dedupe helper", () => { payload: { runId, status: "ok" }, }); - await expect(first).resolves.toEqual( - expect.objectContaining({ - status: "ok", - }), - ); - await expect(second).resolves.toEqual( - expect.objectContaining({ - status: "ok", - }), - ); + const firstResult = await first; + const secondResult = await second; + if (!firstResult || !secondResult) { + throw new Error("expected waiters to resolve"); + } + expect(firstResult.status).toBe("ok"); + expect(firstResult.error).toBeUndefined(); + expect(secondResult.status).toBe("ok"); + expect(secondResult.error).toBeUndefined(); expect(__testing.getWaiterCount(runId)).toBe(0); }); From 17b4ab369efa5f0dc987b43125e438f80d36b9fe Mon Sep 17 00:00:00 2001 From: Shakker Date: Mon, 11 May 2026 13:43:40 +0100 Subject: [PATCH 78/93] test: pin tools catalog plugin fields --- .../server-methods/tools-catalog.test.ts | 64 ++++++++++++++++--- 1 file changed, 54 insertions(+), 10 deletions(-) diff --git a/src/gateway/server-methods/tools-catalog.test.ts b/src/gateway/server-methods/tools-catalog.test.ts index eaa7e60529d..364297ded19 100644 --- a/src/gateway/server-methods/tools-catalog.test.ts +++ b/src/gateway/server-methods/tools-catalog.test.ts @@ -53,6 +53,12 @@ function createInvokeParams(params: Record) { }; } +function firstMockArg(mock: { mock: { calls: unknown[][] } }, label: string): T { + const arg = mock.mock.calls[0]?.[0]; + expect(arg, label).toBeDefined(); + return arg as T; +} + describe("tools.catalog handler", () => { beforeEach(() => { pluginToolMetaState.clear(); @@ -124,9 +130,17 @@ describe("tools.catalog handler", () => { const voiceCall = pluginGroups .flatMap((group) => group.tools) .find((tool) => tool.id === "voice_call"); - expect(voiceCall?.source).toBe("plugin"); - expect(voiceCall?.pluginId).toBe("voice-call"); - expect(voiceCall?.optional).toBe(true); + expect(voiceCall).toEqual({ + id: "voice_call", + label: "voice_call", + description: "Plugin calling tool", + source: "plugin", + pluginId: "voice-call", + optional: true, + risk: undefined, + tags: undefined, + defaultProfiles: [], + }); }); it("summarizes plugin tool descriptions the same way as the effective inventory", async () => { @@ -157,12 +171,42 @@ describe("tools.catalog handler", () => { await invoke(); - expect(vi.mocked(resolvePluginTools).mock.calls[0]?.[0]?.allowGatewaySubagentBinding).toBe( - true, - ); - expect( - vi.mocked(ensureStandalonePluginToolRegistryLoaded).mock.calls[0]?.[0] - ?.allowGatewaySubagentBinding, - ).toBe(true); + const resolveArgs = firstMockArg<{ + allowGatewaySubagentBinding?: boolean; + suppressNameConflicts?: boolean; + toolAllowlist?: string[]; + context?: { + agentId?: string; + workspaceDir?: string; + agentDir?: string; + }; + existingToolNames?: Set; + }>(vi.mocked(resolvePluginTools), "resolvePluginTools args"); + expect(resolveArgs.allowGatewaySubagentBinding).toBe(true); + expect(resolveArgs.suppressNameConflicts).toBe(true); + expect(resolveArgs.toolAllowlist).toEqual(["group:plugins"]); + expect(resolveArgs.context?.agentId).toBe("main"); + expect(resolveArgs.context?.workspaceDir).toBe("/tmp/workspace-main"); + expect(resolveArgs.context?.agentDir).toBe("/tmp/agents/main/agent"); + expect(resolveArgs.existingToolNames).toBeInstanceOf(Set); + expect(resolveArgs.existingToolNames?.has("tts")).toBe(true); + + const registryArgs = firstMockArg<{ + allowGatewaySubagentBinding?: boolean; + toolAllowlist?: string[]; + context?: { + agentId?: string; + workspaceDir?: string; + agentDir?: string; + }; + }>(vi.mocked(ensureStandalonePluginToolRegistryLoaded), "registry load args"); + expect(registryArgs.allowGatewaySubagentBinding).toBe(true); + expect(registryArgs.toolAllowlist).toEqual(["group:plugins"]); + expect(registryArgs.context).toEqual({ + config: {}, + workspaceDir: "/tmp/workspace-main", + agentDir: "/tmp/agents/main/agent", + agentId: "main", + }); }); }); From cca6c4cfcfdaddb55a88cbc1e1391940292b67ad Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 11 May 2026 13:45:18 +0100 Subject: [PATCH 79/93] test: tighten gateway send assertions --- .../node-invoke-system-run-approval.test.ts | 23 +++++++++++------- .../server-methods/config.shared-auth.test.ts | 6 +---- src/gateway/server-methods/send.test.ts | 13 ++++------ .../server-methods/server-methods.test.ts | 24 +++++++++++++------ src/gateway/server.sessions-send.test.ts | 6 ++--- 5 files changed, 39 insertions(+), 33 deletions(-) diff --git a/src/gateway/node-invoke-system-run-approval.test.ts b/src/gateway/node-invoke-system-run-approval.test.ts index 087d2c327cc..2a20453cebf 100644 --- a/src/gateway/node-invoke-system-run-approval.test.ts +++ b/src/gateway/node-invoke-system-run-approval.test.ts @@ -319,15 +319,20 @@ describe("sanitizeSystemRunParamsForForwarding", () => { const forwarded = result.params as Record; expect(forwarded.command).toEqual(["/usr/bin/echo", "SAFE"]); expect(forwarded.rawCommand).toBe("/usr/bin/echo SAFE"); - expect(forwarded.systemRunPlan).toEqual( - expect.objectContaining({ - argv: ["/usr/bin/echo", "SAFE"], - cwd: "/real/cwd", - commandText: "/usr/bin/echo SAFE", - agentId: "main", - sessionKey: "agent:main:main", - }), - ); + const systemRunPlan = forwarded.systemRunPlan as + | { + argv?: string[]; + cwd?: string; + commandText?: string; + agentId?: string; + sessionKey?: string; + } + | undefined; + expect(systemRunPlan?.argv).toEqual(["/usr/bin/echo", "SAFE"]); + expect(systemRunPlan?.cwd).toBe("/real/cwd"); + expect(systemRunPlan?.commandText).toBe("/usr/bin/echo SAFE"); + expect(systemRunPlan?.agentId).toBe("main"); + expect(systemRunPlan?.sessionKey).toBe("agent:main:main"); expect(forwarded.cwd).toBe("/real/cwd"); expect(forwarded.agentId).toBe("main"); expect(forwarded.sessionKey).toBe("agent:main:main"); diff --git a/src/gateway/server-methods/config.shared-auth.test.ts b/src/gateway/server-methods/config.shared-auth.test.ts index 78f5e6437a5..c1c308619ef 100644 --- a/src/gateway/server-methods/config.shared-auth.test.ts +++ b/src/gateway/server-methods/config.shared-auth.test.ts @@ -332,12 +332,8 @@ describe("config shared auth disconnects", () => { await configHandlers["config.patch"](options); - expect(restartSentinelMocks.writeRestartSentinel).toHaveBeenCalledWith( - expect.objectContaining({ - sessionKey: "agent:main:main", - }), - ); const payload = restartSentinelMocks.writeRestartSentinel.mock.calls.at(-1)?.[0]; + expect(payload?.sessionKey).toBe("agent:main:main"); expect(payload?.continuation).toBeUndefined(); }); }); diff --git a/src/gateway/server-methods/send.test.ts b/src/gateway/server-methods/send.test.ts index b01dfba0697..8103491d8d2 100644 --- a/src/gateway/server-methods/send.test.ts +++ b/src/gateway/server-methods/send.test.ts @@ -787,13 +787,10 @@ describe("gateway send mirroring", () => { idempotencyKey: "idem-send-options", }); - expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith( - expect.objectContaining({ - forceDocument: true, - silent: true, - formatting: { parseMode: "HTML" }, - }), - ); + const options = mocks.deliverOutboundPayloads.mock.calls[0]?.[0]; + expect(options?.forceDocument).toBe(true); + expect(options?.silent).toBe(true); + expect(options?.formatting).toEqual({ parseMode: "HTML" }); }); it("updates mirror session keys and delivery thread ids when Slack routing derives a thread", async () => { @@ -1098,7 +1095,7 @@ describe("gateway send mirroring", () => { }); expect(firstRespondCall(respond)?.[0]).toBe(true); - expect(capturedMediaLocalRoots).toEqual(expect.arrayContaining([TEST_AGENT_WORKSPACE])); + expect(capturedMediaLocalRoots).toContain(TEST_AGENT_WORKSPACE); }); it("forces senderIsOwner=false for narrowly-scoped callers but honors it for full operators", async () => { diff --git a/src/gateway/server-methods/server-methods.test.ts b/src/gateway/server-methods/server-methods.test.ts index 69455832348..62b1bb99851 100644 --- a/src/gateway/server-methods/server-methods.test.ts +++ b/src/gateway/server-methods/server-methods.test.ts @@ -2303,13 +2303,23 @@ describe("gateway healthHandlers.health cache freshness", () => { isWebchatConnect: () => false, }); - expect(mockCallArg(respond, 0, 1)).toMatchObject({ - modelPricing: { - state: "degraded", - detail: "OpenRouter pricing fetch failed: TypeError: fetch failed", - sources: [{ source: "openrouter", state: "degraded", lastFailureAt: 123 }], - }, - }); + const payload = mockCallArg(respond, 0, 1) as + | { + modelPricing?: { + state?: string; + detail?: string; + sources?: Array<{ source?: string; state?: string; lastFailureAt?: number }>; + }; + } + | undefined; + expect(payload?.modelPricing?.state).toBe("degraded"); + expect(payload?.modelPricing?.detail).toBe( + "OpenRouter pricing fetch failed: TypeError: fetch failed", + ); + expect(payload?.modelPricing?.sources).toHaveLength(1); + expect(payload?.modelPricing?.sources?.[0]?.source).toBe("openrouter"); + expect(payload?.modelPricing?.sources?.[0]?.state).toBe("degraded"); + expect(payload?.modelPricing?.sources?.[0]?.lastFailureAt).toBe(123); expect(mockCallArg(respond, 0, 3)).toEqual({ cached: true }); expect(refreshHealthSnapshot).toHaveBeenCalledWith({ probe: false, diff --git a/src/gateway/server.sessions-send.test.ts b/src/gateway/server.sessions-send.test.ts index 3c1a218e310..16b56e70609 100644 --- a/src/gateway/server.sessions-send.test.ts +++ b/src/gateway/server.sessions-send.test.ts @@ -151,10 +151,8 @@ describe("sessions_send gateway loopback", () => { | { lane?: string; inputProvenance?: { kind?: string; sourceTool?: string } } | undefined; expect(firstCall?.lane).toMatch(/^nested(?::|$)/); - expect(firstCall?.inputProvenance).toMatchObject({ - kind: "inter_session", - sourceTool: "sessions_send", - }); + expect(firstCall?.inputProvenance?.kind).toBe("inter_session"); + expect(firstCall?.inputProvenance?.sourceTool).toBe("sessions_send"); }); }); From bcdec7bfb9cb94c562a2c5b1fc51fe8f0ff0cf9b Mon Sep 17 00:00:00 2001 From: Shakker Date: Mon, 11 May 2026 13:47:44 +0100 Subject: [PATCH 80/93] test: pin plugin approval ids --- .../server-methods/plugin-approval.test.ts | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/src/gateway/server-methods/plugin-approval.test.ts b/src/gateway/server-methods/plugin-approval.test.ts index 9638640d7bd..1a30671f6f9 100644 --- a/src/gateway/server-methods/plugin-approval.test.ts +++ b/src/gateway/server-methods/plugin-approval.test.ts @@ -97,6 +97,24 @@ function acceptedApprovalId(source: MockCallSource) { return id as string; } +function expectPluginApprovalId(value: unknown, label: string): string { + expect(value, label).toBeTypeOf("string"); + if (typeof value !== "string") { + throw new Error(`${label} must be a string`); + } + expect(value.startsWith("plugin:"), label).toBe(true); + const uuid = value.slice("plugin:".length); + expect(uuid).toHaveLength(36); + expect(uuid.split("-").map((part) => part.length)).toEqual([8, 4, 4, 4, 12]); + expect( + uuid + .split("-") + .every((part) => part.length > 0 && [...part].every((char) => /[0-9a-f]/.test(char))), + label, + ).toBe(true); + return value; +} + function broadcastCall(opts: GatewayRequestHandlerOptions, index = 0) { const call = mockCall( opts.context.broadcast as unknown as MockCallSource, @@ -336,7 +354,7 @@ describe("createPluginApprovalHandlers", () => { ); await handlers["plugin.approval.request"](opts); const result = respond.mock.calls[0]?.[1] as Record | undefined; - expect(result?.id).toEqual(expect.stringMatching(/^plugin:/)); + expectPluginApprovalId(result?.id, "generated plugin approval id"); }); it("passes plugin-prefixed IDs directly to manager.create", async () => { @@ -353,7 +371,7 @@ describe("createPluginApprovalHandlers", () => { await handlers["plugin.approval.request"](opts); expect(createSpy).toHaveBeenCalledTimes(1); - expect(createSpy.mock.calls[0]?.[2]).toEqual(expect.stringMatching(/^plugin:/)); + expectPluginApprovalId(createSpy.mock.calls[0]?.[2], "manager.create approval id"); }); it("rejects plugin-provided id field", async () => { @@ -433,13 +451,14 @@ describe("createPluginApprovalHandlers", () => { ); expect(approvals).toHaveLength(1); const approval = requireRecord(approvals[0], "approval"); - expect(approval.id).toEqual(expect.stringMatching(/^plugin:/)); + const listedApprovalId = expectPluginApprovalId(approval.id, "listed approval id"); const request = requireRecord(approval.request, "approval request"); expect(request.title).toBe("Sensitive action"); expect(request.description).toBe("Desc"); expect(responseCall(listRespond as unknown as MockCallSource).error).toBeUndefined(); const approvalId = acceptedApprovalId(respond as unknown as MockCallSource); + expect(listedApprovalId).toBe(approvalId); manager.resolve(approvalId, "allow-once"); await handlerPromise; }); From c165b9c650dd3debc566346b0493d976419e5d04 Mon Sep 17 00:00:00 2001 From: Shakker Date: Mon, 11 May 2026 13:49:23 +0100 Subject: [PATCH 81/93] test: pin agent identity writes --- .../server-methods/agents-mutate.test.ts | 50 ++++++++++++++----- 1 file changed, 38 insertions(+), 12 deletions(-) diff --git a/src/gateway/server-methods/agents-mutate.test.ts b/src/gateway/server-methods/agents-mutate.test.ts index 40030414452..9c24ced22fb 100644 --- a/src/gateway/server-methods/agents-mutate.test.ts +++ b/src/gateway/server-methods/agents-mutate.test.ts @@ -600,8 +600,15 @@ describe("agents.create", () => { rootDir: "/resolved/tmp/ws", relativePath: "IDENTITY.md", }); - expect(write.data).toEqual( - expect.stringMatching(/- Name: Fancy Agent[\s\S]*- Emoji: 🤖[\s\S]*- Avatar:/), + expect(write.data).toBe( + [ + "# IDENTITY.md - Agent Identity", + "", + "- Name: Fancy Agent", + "- Emoji: 🤖", + "- Avatar: https://example.com/avatar.png", + "", + ].join("\n"), ); }); @@ -772,10 +779,16 @@ describe("agents.update", () => { rootDir: "/workspace/test-agent", relativePath: "IDENTITY.md", }); - expect(write.data).toEqual( - expect.stringMatching( - /- Name: Current Agent[\s\S]*- Theme: steady[\s\S]*- Emoji: 🐢[\s\S]*- Avatar: https:\/\/example\.com\/avatar\.png/, - ), + expect(write.data).toBe( + [ + "# IDENTITY.md - Agent Identity", + "", + "- Name: Current Agent", + "- Theme: steady", + "- Emoji: 🐢", + "- Avatar: https://example.com/avatar.png", + "", + ].join("\n"), ); }); @@ -793,8 +806,15 @@ describe("agents.update", () => { rootDir: "/workspace/test-agent", relativePath: "IDENTITY.md", }); - expect(write.data).toEqual( - expect.stringMatching(/- Name: Current Agent[\s\S]*- Theme: steady[\s\S]*- Emoji: 🦀/), + expect(write.data).toBe( + [ + "# IDENTITY.md - Agent Identity", + "", + "- Name: Current Agent", + "- Theme: steady", + "- Emoji: 🦀", + "", + ].join("\n"), ); }); @@ -820,10 +840,16 @@ describe("agents.update", () => { rootDir: "/workspace/test-agent", relativePath: "IDENTITY.md", }); - expect(write.data).toEqual( - expect.stringMatching( - /- Name: New Name[\s\S]*- Theme: steady[\s\S]*- Emoji: 🤖[\s\S]*- Avatar: https:\/\/example\.com\/new\.png/, - ), + expect(write.data).toBe( + [ + "# IDENTITY.md - Agent Identity", + "", + "- Name: New Name", + "- Theme: steady", + "- Emoji: 🤖", + "- Avatar: https://example.com/new.png", + "", + ].join("\n"), ); }); From 318794f8089f1f51913350b1d8e8e7be5dff0627 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 11 May 2026 13:49:30 +0100 Subject: [PATCH 82/93] test: tighten gateway helper assertions --- .../gateway-cli-backend.live-helpers.test.ts | 16 ++++---- src/gateway/live-agent-probes.test.ts | 15 +++---- .../subagent-followup.test-helpers.ts | 40 +++++++++++++------ .../server-methods/tools-catalog.test.ts | 15 ++++--- .../test/server-sessions.test-helpers.ts | 4 +- 5 files changed, 53 insertions(+), 37 deletions(-) diff --git a/src/gateway/gateway-cli-backend.live-helpers.test.ts b/src/gateway/gateway-cli-backend.live-helpers.test.ts index 261a920668a..a5d3c2c46bf 100644 --- a/src/gateway/gateway-cli-backend.live-helpers.test.ts +++ b/src/gateway/gateway-cli-backend.live-helpers.test.ts @@ -90,15 +90,13 @@ describe("gateway cli backend live helpers", () => { expect(client.start).toBeTypeOf("function"); expect(client.stopAndWait).toBeTypeOf("function"); - expect(gatewayClientState.lastOptions).toMatchObject({ - url: "ws://127.0.0.1:18789", - token: "gateway-token", - clientName: GATEWAY_CLIENT_NAMES.TEST, - clientDisplayName: "vitest-live", - clientVersion: "dev", - mode: GATEWAY_CLIENT_MODES.TEST, - connectChallengeTimeoutMs: 45_000, - }); + expect(gatewayClientState.lastOptions?.url).toBe("ws://127.0.0.1:18789"); + expect(gatewayClientState.lastOptions?.token).toBe("gateway-token"); + expect(gatewayClientState.lastOptions?.clientName).toBe(GATEWAY_CLIENT_NAMES.TEST); + expect(gatewayClientState.lastOptions?.clientDisplayName).toBe("vitest-live"); + expect(gatewayClientState.lastOptions?.clientVersion).toBe("dev"); + expect(gatewayClientState.lastOptions?.mode).toBe(GATEWAY_CLIENT_MODES.TEST); + expect(gatewayClientState.lastOptions?.connectChallengeTimeoutMs).toBe(45_000); expect(gatewayClientState.lastOptions).not.toHaveProperty("requestTimeoutMs"); }); diff --git a/src/gateway/live-agent-probes.test.ts b/src/gateway/live-agent-probes.test.ts index 2b308a539d9..0471bee5f6a 100644 --- a/src/gateway/live-agent-probes.test.ts +++ b/src/gateway/live-agent-probes.test.ts @@ -65,15 +65,12 @@ describe("live-agent-probes", () => { exactReply: spec.name, }), ).toContain("previous OpenClaw cron MCP tool call was cancelled"); - expect(JSON.parse(spec.argsJson)).toEqual( - expect.objectContaining({ - job: expect.objectContaining({ - sessionTarget: "session:agent:codex:acp:test", - agentId: "codex", - sessionKey: "agent:codex:acp:test", - }), - }), - ); + const args = JSON.parse(spec.argsJson) as { + job?: { sessionTarget?: string; agentId?: string; sessionKey?: string }; + }; + expect(args.job?.sessionTarget).toBe("session:agent:codex:acp:test"); + expect(args.job?.agentId).toBe("codex"); + expect(args.job?.sessionKey).toBe("agent:codex:acp:test"); }); it("validates cron cli job shape for the shared live probe", () => { diff --git a/src/gateway/server-methods/subagent-followup.test-helpers.ts b/src/gateway/server-methods/subagent-followup.test-helpers.ts index f502cd61417..a599f049c43 100644 --- a/src/gateway/server-methods/subagent-followup.test-helpers.ts +++ b/src/gateway/server-methods/subagent-followup.test-helpers.ts @@ -12,16 +12,32 @@ export function expectSubagentFollowupReactivation(params: { fallback: params.completedRun, runTimeoutSeconds: 0, }); - expect(params.broadcastToConnIds).toHaveBeenCalledWith( - "sessions.changed", - expect.objectContaining({ - sessionKey: params.childSessionKey, - reason: "send", - status: "running", - startedAt: 123, - endedAt: undefined, - }), - new Set(["conn-1"]), - { dropIfSlow: true }, - ); + const call = ( + params.broadcastToConnIds as { + mock?: { + calls?: Array< + [ + string, + { + sessionKey?: string; + reason?: string; + status?: string; + startedAt?: number; + endedAt?: number; + }, + Set, + { dropIfSlow?: boolean }, + ] + >; + }; + } + ).mock?.calls?.[0]; + expect(call?.[0]).toBe("sessions.changed"); + expect(call?.[1]?.sessionKey).toBe(params.childSessionKey); + expect(call?.[1]?.reason).toBe("send"); + expect(call?.[1]?.status).toBe("running"); + expect(call?.[1]?.startedAt).toBe(123); + expect(call?.[1]?.endedAt).toBeUndefined(); + expect(call?.[2]).toEqual(new Set(["conn-1"])); + expect(call?.[3]).toEqual({ dropIfSlow: true }); } diff --git a/src/gateway/server-methods/tools-catalog.test.ts b/src/gateway/server-methods/tools-catalog.test.ts index 364297ded19..9d6a3bc7944 100644 --- a/src/gateway/server-methods/tools-catalog.test.ts +++ b/src/gateway/server-methods/tools-catalog.test.ts @@ -53,10 +53,10 @@ function createInvokeParams(params: Record) { }; } -function firstMockArg(mock: { mock: { calls: unknown[][] } }, label: string): T { +function firstMockArg(mock: { mock: { calls: unknown[][] } }, label: string): unknown { const arg = mock.mock.calls[0]?.[0]; expect(arg, label).toBeDefined(); - return arg as T; + return arg; } describe("tools.catalog handler", () => { @@ -171,7 +171,7 @@ describe("tools.catalog handler", () => { await invoke(); - const resolveArgs = firstMockArg<{ + const resolveArgs = firstMockArg(vi.mocked(resolvePluginTools), "resolvePluginTools args") as { allowGatewaySubagentBinding?: boolean; suppressNameConflicts?: boolean; toolAllowlist?: string[]; @@ -181,7 +181,7 @@ describe("tools.catalog handler", () => { agentDir?: string; }; existingToolNames?: Set; - }>(vi.mocked(resolvePluginTools), "resolvePluginTools args"); + }; expect(resolveArgs.allowGatewaySubagentBinding).toBe(true); expect(resolveArgs.suppressNameConflicts).toBe(true); expect(resolveArgs.toolAllowlist).toEqual(["group:plugins"]); @@ -191,7 +191,10 @@ describe("tools.catalog handler", () => { expect(resolveArgs.existingToolNames).toBeInstanceOf(Set); expect(resolveArgs.existingToolNames?.has("tts")).toBe(true); - const registryArgs = firstMockArg<{ + const registryArgs = firstMockArg( + vi.mocked(ensureStandalonePluginToolRegistryLoaded), + "registry load args", + ) as { allowGatewaySubagentBinding?: boolean; toolAllowlist?: string[]; context?: { @@ -199,7 +202,7 @@ describe("tools.catalog handler", () => { workspaceDir?: string; agentDir?: string; }; - }>(vi.mocked(ensureStandalonePluginToolRegistryLoaded), "registry load args"); + }; expect(registryArgs.allowGatewaySubagentBinding).toBe(true); expect(registryArgs.toolAllowlist).toEqual(["group:plugins"]); expect(registryArgs.context).toEqual({ diff --git a/src/gateway/test/server-sessions.test-helpers.ts b/src/gateway/test/server-sessions.test-helpers.ts index 0109b6b7048..93669a9d9d6 100644 --- a/src/gateway/test/server-sessions.test-helpers.ts +++ b/src/gateway/test/server-sessions.test-helpers.ts @@ -415,7 +415,9 @@ export function expectActiveRunCleanup( const clearedKeys = ( sessionCleanupMocks.clearSessionQueues.mock.calls as unknown as Array<[string[]]> )[0]?.[0]; - expect(clearedKeys).toEqual(expect.arrayContaining(expectedQueueKeys)); + for (const key of expectedQueueKeys) { + expect(clearedKeys).toContain(key); + } expect(embeddedRunMock.abortCalls).toEqual([sessionId]); expect(embeddedRunMock.waitCalls).toEqual([sessionId]); } From f801009008906893c2ceb84655c0ee89d9c90ebe Mon Sep 17 00:00:00 2001 From: Shakker Date: Mon, 11 May 2026 13:50:37 +0100 Subject: [PATCH 83/93] test: pin runtime recovery logs --- src/gateway/server-runtime-services.test.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/gateway/server-runtime-services.test.ts b/src/gateway/server-runtime-services.test.ts index 48844d8f370..3c997f49111 100644 --- a/src/gateway/server-runtime-services.test.ts +++ b/src/gateway/server-runtime-services.test.ts @@ -185,15 +185,21 @@ describe("server-runtime-services", () => { expect(services.heartbeatRunner).toBe(hoisted.heartbeatRunner); await vi.advanceTimersByTimeAsync(1_250); await vi.dynamicImportSettled(); + expect(log.child).toHaveBeenNthCalledWith(1, "delivery-recovery"); + expect(log.child).toHaveBeenNthCalledWith(2, "session-delivery-recovery"); + const deliveryLog = log.child.mock.results[0]?.value; + const sessionDeliveryLog = log.child.mock.results[1]?.value; + expect(deliveryLog).toBeDefined(); + expect(sessionDeliveryLog).toBeDefined(); expect(hoisted.recoverPendingDeliveries).toHaveBeenCalledWith({ deliver: hoisted.deliverOutboundPayloads, cfg: {}, - log: expect.any(Object), + log: deliveryLog, }); expect(hoisted.recoverPendingRestartContinuationDeliveries).toHaveBeenCalledWith({ deps: {}, maxEnqueuedAt: 123, - log: expect.any(Object), + log: sessionDeliveryLog, }); }); From 552f088af9e6ccf7decec294918938c0384ae2a5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 11 May 2026 13:51:30 +0100 Subject: [PATCH 84/93] test: tighten gateway live assertions --- src/gateway/gateway-cli-backend.live.test.ts | 8 +++-- .../gateway-models.profiles.live.test.ts | 6 ++-- .../gateway-trajectory-export.live.test.ts | 29 +++++++++---------- .../server-methods/plugin-approval.test.ts | 4 +-- 4 files changed, 22 insertions(+), 25 deletions(-) diff --git a/src/gateway/gateway-cli-backend.live.test.ts b/src/gateway/gateway-cli-backend.live.test.ts index af7b91a8d14..59accc8fdcc 100644 --- a/src/gateway/gateway-cli-backend.live.test.ts +++ b/src/gateway/gateway-cli-backend.live.test.ts @@ -461,11 +461,13 @@ describeLive("gateway live (cli backend)", () => { } else { expect(text).toContain(`CLI-BACKEND-${nonce}`); } - expect( + const injectedFileNames = resultWithMeta.meta?.systemPromptReport?.injectedWorkspaceFiles?.map( (entry) => entry.name, - ) ?? [], - ).toEqual(expect.arrayContaining(bootstrapWorkspace?.expectedInjectedFiles ?? [])); + ) ?? []; + for (const expectedFile of bootstrapWorkspace?.expectedInjectedFiles ?? []) { + expect(injectedFileNames).toContain(expectedFile); + } } if (modelSwitchTarget) { diff --git a/src/gateway/gateway-models.profiles.live.test.ts b/src/gateway/gateway-models.profiles.live.test.ts index 6a1d79ad425..fb6e74e5779 100644 --- a/src/gateway/gateway-models.profiles.live.test.ts +++ b/src/gateway/gateway-models.profiles.live.test.ts @@ -1358,10 +1358,8 @@ describe("sanitizeAuthProfileStoreForLiveGateway", () => { try { const sanitized = sanitizeAuthProfileStoreForLiveGateway(store); expect(sanitized.profiles.openaiProfile).toBeUndefined(); - expect(sanitized.profiles.codexProfile).toMatchObject({ - type: "oauth", - provider: "openai-codex", - }); + expect(sanitized.profiles.codexProfile?.type).toBe("oauth"); + expect(sanitized.profiles.codexProfile?.provider).toBe("openai-codex"); expect(sanitized.order).toEqual({ "openai-codex": ["codexProfile"] }); expect(sanitized.lastGood).toEqual({ "openai-codex": "codexProfile" }); expect(sanitized.usageStats).toEqual({ codexProfile: { lastUsed: 2 } }); diff --git a/src/gateway/gateway-trajectory-export.live.test.ts b/src/gateway/gateway-trajectory-export.live.test.ts index 062bc8ab706..21f8e51fb90 100644 --- a/src/gateway/gateway-trajectory-export.live.test.ts +++ b/src/gateway/gateway-trajectory-export.live.test.ts @@ -152,10 +152,8 @@ async function approveTrajectoryExport(client: GatewayClient): Promise { const approval = approvals.find((entry) => entry.request?.command?.includes("sessions export-trajectory"), ); - expect(approval).toMatchObject({ - id: expect.any(String), - request: { command: expect.stringContaining("sessions export-trajectory") }, - }); + expect(typeof approval?.id).toBe("string"); + expect(approval?.request?.command).toContain("sessions export-trajectory"); if (!approval?.id) { throw new Error("expected trajectory export approval id"); } @@ -285,17 +283,18 @@ describeLive("gateway live trajectory export", () => { if (finalText) { expect(finalText).toContain("Approve once"); } - expect(await listDirectoryNames(bundleDir)).toEqual( - expect.arrayContaining([ - "artifacts.json", - "events.jsonl", - "manifest.json", - "metadata.json", - "prompts.json", - "session.jsonl", - "tools.json", - ]), - ); + const bundleNames = await listDirectoryNames(bundleDir); + for (const expectedName of [ + "artifacts.json", + "events.jsonl", + "manifest.json", + "metadata.json", + "prompts.json", + "session.jsonl", + "tools.json", + ]) { + expect(bundleNames).toContain(expectedName); + } expect(beforeExport.has("bundle")).toBe(false); const manifest = JSON.parse( diff --git a/src/gateway/server-methods/plugin-approval.test.ts b/src/gateway/server-methods/plugin-approval.test.ts index 1a30671f6f9..31241ae279c 100644 --- a/src/gateway/server-methods/plugin-approval.test.ts +++ b/src/gateway/server-methods/plugin-approval.test.ts @@ -107,9 +107,7 @@ function expectPluginApprovalId(value: unknown, label: string): string { expect(uuid).toHaveLength(36); expect(uuid.split("-").map((part) => part.length)).toEqual([8, 4, 4, 4, 12]); expect( - uuid - .split("-") - .every((part) => part.length > 0 && [...part].every((char) => /[0-9a-f]/.test(char))), + uuid.split("-").every((part) => /^[0-9a-f]+$/.test(part)), label, ).toBe(true); return value; From e3cde42b498bf6cc7f942395fe447401177ecf01 Mon Sep 17 00:00:00 2001 From: Shakker Date: Mon, 11 May 2026 13:52:02 +0100 Subject: [PATCH 85/93] test: pin attachment offload warning --- src/gateway/chat-attachments.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/gateway/chat-attachments.test.ts b/src/gateway/chat-attachments.test.ts index 75c8ea19f1e..b8f164f1744 100644 --- a/src/gateway/chat-attachments.test.ts +++ b/src/gateway/chat-attachments.test.ts @@ -478,7 +478,9 @@ describe("parseMessageWithAttachments validation errors", () => { expect(parsed.message).toContain( "[image attachment omitted: text-only attachment limit reached]", ); - expect(logs).toContainEqual(expect.stringMatching(/offload limit 10/i)); + expect(logs).toEqual([ + "attachment dot-10.png: dropping image because text-only offload limit 10 was reached", + ]); } finally { await cleanupOffloadedRefs(parsed.offloadedRefs); } From 0fcddd3974ab15d8ce52ed96d546a713b502a7f4 Mon Sep 17 00:00:00 2001 From: Shakker Date: Mon, 11 May 2026 13:53:01 +0100 Subject: [PATCH 86/93] test: pin cron reaper store --- src/cron/session-reaper.test.ts | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/src/cron/session-reaper.test.ts b/src/cron/session-reaper.test.ts index d093f1e5104..f98eaa92256 100644 --- a/src/cron/session-reaper.test.ts +++ b/src/cron/session-reaper.test.ts @@ -116,17 +116,23 @@ describe("sweepCronRunSessions", () => { expect(result.pruned).toBe(2); const updated = JSON.parse(fs.readFileSync(storePath, "utf-8")); - expect(updated["agent:main:cron:job1"]).toMatchObject({ sessionId: "base-session" }); - expect(updated["agent:main:cron:job1:run:old-run"]).toBeUndefined(); - expect(updated["agent:main:cron:job1:run:old-run:subagent:worker"]).toBeUndefined(); - expect(updated["agent:main:cron:job1:run:recent-run"]).toMatchObject({ - sessionId: "recent-run", - }); - expect(updated["agent:main:cron:job1:run:recent-run:thread:reply"]).toMatchObject({ - sessionId: "recent-run-thread", - }); - expect(updated["agent:main:telegram:dm:123"]).toMatchObject({ - sessionId: "regular-session", + expect(updated).toEqual({ + "agent:main:cron:job1": { + sessionId: "base-session", + updatedAt: now, + }, + "agent:main:cron:job1:run:recent-run": { + sessionId: "recent-run", + updatedAt: now - 1 * 3_600_000, + }, + "agent:main:cron:job1:run:recent-run:thread:reply": { + sessionId: "recent-run-thread", + updatedAt: now - 1 * 3_600_000, + }, + "agent:main:telegram:dm:123": { + sessionId: "regular-session", + updatedAt: now - 100 * 3_600_000, + }, }); }); From 82cc6f1d254923848080dbc7c89764cc91dc60b4 Mon Sep 17 00:00:00 2001 From: Shakker Date: Mon, 11 May 2026 13:54:38 +0100 Subject: [PATCH 87/93] test: pin cron schedule logs --- .../service/jobs.schedule-error-isolation.test.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/cron/service/jobs.schedule-error-isolation.test.ts b/src/cron/service/jobs.schedule-error-isolation.test.ts index c0933b9249f..268ea920292 100644 --- a/src/cron/service/jobs.schedule-error-isolation.test.ts +++ b/src/cron/service/jobs.schedule-error-isolation.test.ts @@ -111,12 +111,13 @@ describe("cron schedule error isolation", () => { recomputeNextRuns(state); expect(state.deps.log.warn).toHaveBeenCalledWith( - expect.objectContaining({ + { jobId: "bad-job", name: "Bad Job", errorCount: 1, - }), - expect.stringContaining("failed to compute next run"), + err: "TypeError: CronPattern: invalid configuration format ('not valid'), exactly five, six, or seven space separated parts are required.", + }, + "cron: failed to compute next run for job (skipping)", ); }); @@ -135,12 +136,13 @@ describe("cron schedule error isolation", () => { expect(badJob.enabled).toBe(false); expect(badJob.state.scheduleErrorCount).toBe(3); expect(state.deps.log.error).toHaveBeenCalledWith( - expect.objectContaining({ + { jobId: "bad-job", name: "Bad Job", errorCount: 3, - }), - expect.stringContaining("auto-disabled job"), + err: "TypeError: CronPattern: invalid configuration format ('garbage'), exactly five, six, or seven space separated parts are required.", + }, + "cron: auto-disabled job after repeated schedule errors", ); }); From ef9c03c4bb69bcdcd1723030cbded83883bffd6c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 11 May 2026 13:54:49 +0100 Subject: [PATCH 88/93] test: tighten UI gateway assertions --- ui/src/ui/gateway.node.test.ts | 78 +++++++++++-------- ui/src/ui/realtime-talk-gateway-relay.test.ts | 25 +++--- 2 files changed, 58 insertions(+), 45 deletions(-) diff --git a/ui/src/ui/gateway.node.test.ts b/ui/src/ui/gateway.node.test.ts index f51d8695bb8..ca84d63b068 100644 --- a/ui/src/ui/gateway.node.test.ts +++ b/ui/src/ui/gateway.node.test.ts @@ -121,7 +121,9 @@ function expectLatestRequestTiming( expected: Partial, ) { const timing = onRequestTiming.mock.calls.at(-1)?.[0] as RequestTimingPayload | undefined; - expect(timing).toMatchObject(expected); + for (const [key, value] of Object.entries(expected)) { + expect(timing?.[key as keyof RequestTimingPayload]).toBe(value); + } expect(timing?.startedAtMs).toBeTypeOf("number"); expect(timing?.endedAtMs).toBeTypeOf("number"); expect(timing?.durationMs).toBeTypeOf("number"); @@ -304,18 +306,23 @@ describe("GatewayBrowserClient", () => { }); expect(() => client.start()).not.toThrow(); - expect(onClose).toHaveBeenCalledWith({ - code: 1006, - reason: "security error", - error: expect.objectContaining({ - code: "BROWSER_WEBSOCKET_SECURITY_ERROR", - message: expect.stringContaining("Use wss://"), - details: expect.objectContaining({ - code: "BROWSER_WEBSOCKET_SECURITY_ERROR", - browserErrorName: "SecurityError", - }), - }), - }); + const close = onClose.mock.calls[0]?.[0] as + | { + code?: number; + reason?: string; + error?: { + code?: string; + message?: string; + details?: { code?: string; browserErrorName?: string }; + }; + } + | undefined; + expect(close?.code).toBe(1006); + expect(close?.reason).toBe("security error"); + expect(close?.error?.code).toBe("BROWSER_WEBSOCKET_SECURITY_ERROR"); + expect(close?.error?.message).toContain("Use wss://"); + expect(close?.error?.details?.code).toBe("BROWSER_WEBSOCKET_SECURITY_ERROR"); + expect(close?.error?.details?.browserErrorName).toBe("SecurityError"); expect(wsInstances).toHaveLength(0); await vi.advanceTimersByTimeAsync(30_000); @@ -343,19 +350,24 @@ describe("GatewayBrowserClient", () => { }); expect(() => client.start()).not.toThrow(); - expect(onClose).toHaveBeenCalledWith({ - code: 1006, - reason: "websocket error", - error: expect.objectContaining({ - code: "BROWSER_WEBSOCKET_CONSTRUCTOR_ERROR", - message: expect.stringContaining("Could not create the Gateway WebSocket"), - details: expect.objectContaining({ - code: "BROWSER_WEBSOCKET_CONSTRUCTOR_ERROR", - browserErrorName: "TypeError", - browserMessage: "constructor failed", - }), - }), - }); + const close = onClose.mock.calls[0]?.[0] as + | { + code?: number; + reason?: string; + error?: { + code?: string; + message?: string; + details?: { code?: string; browserErrorName?: string; browserMessage?: string }; + }; + } + | undefined; + expect(close?.code).toBe(1006); + expect(close?.reason).toBe("websocket error"); + expect(close?.error?.code).toBe("BROWSER_WEBSOCKET_CONSTRUCTOR_ERROR"); + expect(close?.error?.message).toContain("Could not create the Gateway WebSocket"); + expect(close?.error?.details?.code).toBe("BROWSER_WEBSOCKET_CONSTRUCTOR_ERROR"); + expect(close?.error?.details?.browserErrorName).toBe("TypeError"); + expect(close?.error?.details?.browserMessage).toBe("constructor failed"); expect(wsInstances).toHaveLength(0); await vi.advanceTimersByTimeAsync(30_000); @@ -436,12 +448,14 @@ describe("GatewayBrowserClient", () => { error: { code: "CONFIG_ERROR", message: "config failed" }, }); - await expect(request).rejects.toMatchObject({ gatewayCode: "CONFIG_ERROR" }); - expect(onRequestTiming).toHaveBeenCalledWith( - expect.not.objectContaining({ - params: expect.anything(), - }), - ); + try { + await request; + throw new Error("expected config.get request to reject"); + } catch (error) { + expect((error as { gatewayCode?: string }).gatewayCode).toBe("CONFIG_ERROR"); + } + expect(onRequestTiming).toHaveBeenCalledTimes(1); + expect(onRequestTiming.mock.calls[0]?.[0]).not.toHaveProperty("params"); expectLatestRequestTiming(onRequestTiming, { id: frame.id, method: "config.get", diff --git a/ui/src/ui/realtime-talk-gateway-relay.test.ts b/ui/src/ui/realtime-talk-gateway-relay.test.ts index 1c346115cc8..ccdc92bb43c 100644 --- a/ui/src/ui/realtime-talk-gateway-relay.test.ts +++ b/ui/src/ui/realtime-talk-gateway-relay.test.ts @@ -210,10 +210,10 @@ describe("GatewayRelayRealtimeTalkTransport", () => { pumpMicrophone(new Float32Array(4096)); expect(client.request).not.toHaveBeenCalledWith("talk.session.cancelOutput", expect.anything()); - expect(client.request).toHaveBeenCalledWith( - "talk.session.appendAudio", - expect.objectContaining({ sessionId: "relay-1" }), - ); + const appendCall = vi + .mocked(client.request) + .mock.calls.find((call) => call[0] === "talk.session.appendAudio"); + expect((appendCall?.[1] as { sessionId?: string } | undefined)?.sessionId).toBe("relay-1"); transport.stop(); }); @@ -380,15 +380,14 @@ describe("GatewayRelayRealtimeTalkTransport", () => { args: { question: "status?" }, }, }); - await vi.waitFor(() => - expect(client.request).toHaveBeenCalledWith( - "talk.client.toolCall", - expect.objectContaining({ - callId: "call-1", - relaySessionId: "relay-1", - }), - ), - ); + await vi.waitFor(() => { + const toolCall = vi + .mocked(client.request) + .mock.calls.find((call) => call[0] === "talk.client.toolCall"); + const params = toolCall?.[1] as { callId?: string; relaySessionId?: string } | undefined; + expect(params?.callId).toBe("call-1"); + expect(params?.relaySessionId).toBe("relay-1"); + }); emitGatewayFrame({ event: "chat", From a012bfb2964234780c73b4032783405691154602 Mon Sep 17 00:00:00 2001 From: Shakker Date: Mon, 11 May 2026 13:55:43 +0100 Subject: [PATCH 89/93] test: pin cron timer handles --- src/cron/service.armtimer-tight-loop.test.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/cron/service.armtimer-tight-loop.test.ts b/src/cron/service.armtimer-tight-loop.test.ts index a63af5cdd87..afd0a2ac771 100644 --- a/src/cron/service.armtimer-tight-loop.test.ts +++ b/src/cron/service.armtimer-tight-loop.test.ts @@ -46,6 +46,14 @@ describe("CronService - armTimer tight loop prevention", () => { .filter((d: unknown): d is number => typeof d === "number"); } + function latestTimeoutHandle(timeoutSpy: ReturnType) { + const result = timeoutSpy.mock.results.at(-1); + if (!result || result.type !== "return") { + throw new Error("Expected setTimeout to return a timer handle"); + } + return result.value; + } + function createTimerState(params: { storePath: string; now: number; @@ -90,7 +98,7 @@ describe("CronService - armTimer tight loop prevention", () => { armTimer(state); - expect(state.timer).toEqual(expect.anything()); + expect(state.timer).toBe(latestTimeoutHandle(timeoutSpy)); const delays = extractTimeoutDelays(timeoutSpy); // Before the fix, delay would be 0 (tight loop). @@ -171,7 +179,7 @@ describe("CronService - armTimer tight loop prevention", () => { armTimer(state); - expect(state.timer).toEqual(expect.anything()); + expect(state.timer).toBe(latestTimeoutHandle(timeoutSpy)); const delays = extractTimeoutDelays(timeoutSpy); expect(delays).toContain(60_000); @@ -208,7 +216,7 @@ describe("CronService - armTimer tight loop prevention", () => { await onTimer(state); expect(state.running).toBe(false); - expect(state.timer).toEqual(expect.anything()); + expect(state.timer).toBe(latestTimeoutHandle(timeoutSpy)); // The re-armed timer must NOT use delay=0. It should use at least // MIN_REFIRE_GAP_MS to prevent the hot-loop. From 9c5a150336587cc3e668069250c9984632e0bdc0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 11 May 2026 13:57:10 +0100 Subject: [PATCH 90/93] test: tighten UI view assertions --- ui/src/ui/app.talk.test.ts | 7 +++---- ui/src/ui/realtime-talk-consult.test.ts | 15 +++++++-------- ui/src/ui/views/chat.test.ts | 14 +++++++------- ui/src/ui/views/cron.test.ts | 25 ++++++++++++------------- ui/src/ui/views/dreaming.test.ts | 8 +++++--- 5 files changed, 34 insertions(+), 35 deletions(-) diff --git a/ui/src/ui/app.talk.test.ts b/ui/src/ui/app.talk.test.ts index e1811b4c878..4fb77342a61 100644 --- a/ui/src/ui/app.talk.test.ts +++ b/ui/src/ui/app.talk.test.ts @@ -59,9 +59,8 @@ describe("OpenClawApp Talk controls", () => { expect(startMock).toHaveBeenCalledOnce(); expect(stopMock).not.toHaveBeenCalled(); expect(app.realtimeTalkStatus).toBe("connecting"); - expect(app.realtimeTalkSession).toMatchObject({ - start: startMock, - stop: stopMock, - }); + const session = app.realtimeTalkSession as { start?: unknown; stop?: unknown } | undefined; + expect(session?.start).toBe(startMock); + expect(session?.stop).toBe(stopMock); }); }); diff --git a/ui/src/ui/realtime-talk-consult.test.ts b/ui/src/ui/realtime-talk-consult.test.ts index 66ebafd00b9..5a2591a1b24 100644 --- a/ui/src/ui/realtime-talk-consult.test.ts +++ b/ui/src/ui/realtime-talk-consult.test.ts @@ -41,14 +41,13 @@ describe("RealtimeTalkSession consult handoff", () => { submit, }); - expect(request).toHaveBeenCalledWith( - "talk.client.toolCall", - expect.objectContaining({ - sessionKey: "agent:main:main", - name: "openclaw_agent_consult", - args: { question: "Are the basement lights off?" }, - }), - ); + const toolCall = request.mock.calls[0] as + | [string, { sessionKey?: string; name?: string; args?: { question?: string } }] + | undefined; + expect(toolCall?.[0]).toBe("talk.client.toolCall"); + expect(toolCall?.[1]?.sessionKey).toBe("agent:main:main"); + expect(toolCall?.[1]?.name).toBe("openclaw_agent_consult"); + expect(toolCall?.[1]?.args).toEqual({ question: "Are the basement lights off?" }); expect(submit).toHaveBeenCalledWith("call-1", { result: "Basement lights are off." }); }); }); diff --git a/ui/src/ui/views/chat.test.ts b/ui/src/ui/views/chat.test.ts index 96816cbac15..7ab23df6074 100644 --- a/ui/src/ui/views/chat.test.ts +++ b/ui/src/ui/views/chat.test.ts @@ -740,13 +740,13 @@ describe("chat attachment picker", () => { input!.dispatchEvent(new Event("change", { bubbles: true })); await vi.waitFor(() => { - expect(onAttachmentsChange).toHaveBeenCalledWith([ - expect.objectContaining({ - fileName: "brief.pdf", - mimeType: "application/pdf", - sizeBytes: file.size, - }), - ]); + const attachments = onAttachmentsChange.mock.calls[0]?.[0] as + | Array<{ fileName?: string; mimeType?: string; sizeBytes?: number }> + | undefined; + expect(attachments).toHaveLength(1); + expect(attachments?.[0]?.fileName).toBe("brief.pdf"); + expect(attachments?.[0]?.mimeType).toBe("application/pdf"); + expect(attachments?.[0]?.sizeBytes).toBe(file.size); }); const nextAttachments = onAttachmentsChange.mock.calls[0]?.[0] ?? []; diff --git a/ui/src/ui/views/cron.test.ts b/ui/src/ui/views/cron.test.ts index b17e84966d8..181efeb515e 100644 --- a/ui/src/ui/views/cron.test.ts +++ b/ui/src/ui/views/cron.test.ts @@ -746,20 +746,19 @@ describe("cron view", () => { "cron-delivery-to-suggestions", "cron-delivery-account-suggestions", ]); - expect( - Array.from(container.querySelectorAll("input[list]")).map((node) => - node.getAttribute("list"), - ), - ).toEqual( - expect.arrayContaining([ - "cron-agent-suggestions", - "cron-model-suggestions", - "cron-thinking-suggestions", - "cron-tz-suggestions", - "cron-delivery-to-suggestions", - "cron-delivery-account-suggestions", - ]), + const inputLists = Array.from(container.querySelectorAll("input[list]")).map((node) => + node.getAttribute("list"), ); + for (const expectedList of [ + "cron-agent-suggestions", + "cron-model-suggestions", + "cron-thinking-suggestions", + "cron-tz-suggestions", + "cron-delivery-to-suggestions", + "cron-delivery-account-suggestions", + ]) { + expect(inputLists).toContain(expectedList); + } expect(container.querySelectorAll("input[list]")).toHaveLength(6); }); }); diff --git a/ui/src/ui/views/dreaming.test.ts b/ui/src/ui/views/dreaming.test.ts index 7df4e601f37..d7773696b38 100644 --- a/ui/src/ui/views/dreaming.test.ts +++ b/ui/src/ui/views/dreaming.test.ts @@ -508,9 +508,11 @@ describe("dreaming view", () => { const labels = [...container.querySelectorAll(".dreams-diary__day-chip")].map((node) => node.textContent?.replace(/\s+/g, "").trim(), ); - expect(labels.filter((label): label is string => Boolean(label))).toEqual( - expect.arrayContaining([expect.stringMatching(/^\d+\/\d+$/)]), - ); + expect( + labels + .filter((label): label is string => Boolean(label)) + .some((label) => /^\d+\/\d+$/.test(label)), + ).toBe(true); setDreamSubTab("scene"); }); From 7c75001492cddde56ada5fe9c2547a2501bf16d6 Mon Sep 17 00:00:00 2001 From: Shakker Date: Mon, 11 May 2026 13:57:59 +0100 Subject: [PATCH 91/93] test: pin cron diagnostics entries --- src/cron/run-diagnostics.test.ts | 38 +++++++++++++++++++------------- 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/src/cron/run-diagnostics.test.ts b/src/cron/run-diagnostics.test.ts index 5e3546a380d..86d6a89ca45 100644 --- a/src/cron/run-diagnostics.test.ts +++ b/src/cron/run-diagnostics.test.ts @@ -21,7 +21,7 @@ describe("cron run diagnostics", () => { expect(diagnostics?.entries).toHaveLength(10); expect(diagnostics?.entries[0]?.message).toBe("entry 2"); - expect(diagnostics?.entries.at(-1)?.message).toMatch(/…$/); + expect(diagnostics?.entries.at(-1)?.message.endsWith("…")).toBe(true); expect(diagnostics?.entries.at(-1)?.message).not.toContain("sk-1234567890abcdef"); expect(diagnostics?.entries.at(-1)?.truncated).toBe(true); expect(diagnostics?.summary).toHaveLength(2_000); @@ -47,7 +47,8 @@ describe("cron run diagnostics", () => { expect(diagnostics?.entries).toHaveLength(10); expect(diagnostics?.entries.map((entry) => entry.message)).not.toContain("tool warning 0"); - expect(diagnostics?.entries.at(-1)).toMatchObject({ + expect(diagnostics?.entries.at(-1)).toEqual({ + ts: 11, source: "delivery", severity: "error", message: "delivery failed", @@ -118,8 +119,11 @@ describe("cron run diagnostics", () => { "retry limit exceeded", "SYSTEM_RUN_DENIED", ]); - expect(diagnostics?.entries[1]).toMatchObject({ + expect(diagnostics?.entries[1]).toEqual({ + ts: 123, source: "exec", + severity: "warn", + message: "stdout\nstderr failure", toolName: "exec", exitCode: 2, }); @@ -146,26 +150,30 @@ describe("cron run diagnostics", () => { }); it("captures silent failed exec details with a fallback message", () => { - const diagnostics = createCronRunDiagnosticsFromAgentResult({ - payloads: [ - { - toolName: "exec", - details: { - status: "completed", - exitCode: 2, + const diagnostics = createCronRunDiagnosticsFromAgentResult( + { + payloads: [ + { + toolName: "exec", + details: { + status: "completed", + exitCode: 2, + }, }, - }, - ], - }); + ], + }, + { nowMs: () => 500 }, + ); expect(diagnostics?.entries).toEqual([ - expect.objectContaining({ + { + ts: 500, source: "exec", severity: "warn", message: "exec failed with exit code 2", toolName: "exec", exitCode: 2, - }), + }, ]); }); }); From 1ecd46f49b21c4bbaffa3ea484425b367230f9f1 Mon Sep 17 00:00:00 2001 From: samzong Date: Mon, 11 May 2026 00:10:09 +0800 Subject: [PATCH 92/93] fix(channels): cache selected channel registry lookups --- src/auto-reply/commands-registry.test.ts | 80 +++++++++++++++++++- src/channels/registry-lookup.ts | 95 ++++++++++++++++++++++++ src/channels/registry-normalize.ts | 23 +----- src/channels/registry.helpers.test.ts | 77 +++++++++++++------ src/channels/registry.ts | 47 ++---------- src/plugins/runtime-channel-state.ts | 62 +++++++++++++--- src/plugins/runtime.channel-pin.test.ts | 13 ++++ src/plugins/runtime.ts | 12 +-- 8 files changed, 303 insertions(+), 106 deletions(-) create mode 100644 src/channels/registry-lookup.ts diff --git a/src/auto-reply/commands-registry.test.ts b/src/auto-reply/commands-registry.test.ts index fc3386f079f..9262fd4a4ec 100644 --- a/src/auto-reply/commands-registry.test.ts +++ b/src/auto-reply/commands-registry.test.ts @@ -1,5 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { setActivePluginRegistry } from "../plugins/runtime.js"; +import { + pinActivePluginChannelRegistry, + resetPluginRuntimeStateForTest, + setActivePluginRegistry, +} from "../plugins/runtime.js"; import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js"; import { buildCommandText, @@ -82,12 +86,27 @@ function installOllamaThinkingProvider() { setActivePluginRegistry(registry); } +function createNativeCommandsRegistry(id: "discord" | "slack") { + return createTestRegistry([ + { + pluginId: id, + plugin: createChannelTestPluginBase({ + id, + capabilities: { nativeCommands: true, chatTypes: ["direct"] }, + }), + source: "test", + }, + ]); +} + beforeEach(() => { vi.doUnmock("../channels/plugins/index.js"); + resetPluginRuntimeStateForTest(); setActivePluginRegistry(createTestRegistry([])); }); afterEach(() => { + resetPluginRuntimeStateForTest(); setActivePluginRegistry(createTestRegistry([])); }); @@ -455,6 +474,65 @@ describe("commands registry", () => { ).toBe(true); }); + it("refreshes dock commands when pinned-empty fallback active registry changes", () => { + const pinnedEmptyRegistry = createTestRegistry([]); + setActivePluginRegistry(pinnedEmptyRegistry); + pinActivePluginChannelRegistry(pinnedEmptyRegistry); + + setActivePluginRegistry(createNativeCommandsRegistry("discord")); + expect([...commandKeySet(listChatCommands())]).toEqual( + expect.arrayContaining(["dock:discord"]), + ); + expect([...commandKeySet(listChatCommands())]).not.toEqual( + expect.arrayContaining(["dock:slack"]), + ); + + setActivePluginRegistry(createNativeCommandsRegistry("slack")); + expect([...commandKeySet(listChatCommands())]).not.toEqual( + expect.arrayContaining(["dock:discord"]), + ); + expect([...commandKeySet(listChatCommands())]).toEqual(expect.arrayContaining(["dock:slack"])); + }); + + it("refreshes text-command gating when pinned-empty fallback active registry changes", () => { + const cfg = { commands: { text: false } }; + const pinnedEmptyRegistry = createTestRegistry([]); + setActivePluginRegistry(pinnedEmptyRegistry); + pinActivePluginChannelRegistry(pinnedEmptyRegistry); + + setActivePluginRegistry(createNativeCommandsRegistry("discord")); + expect( + shouldHandleTextCommands({ + cfg, + surface: "discord", + commandSource: "text", + }), + ).toBe(false); + expect( + shouldHandleTextCommands({ + cfg, + surface: "slack", + commandSource: "text", + }), + ).toBe(true); + + setActivePluginRegistry(createNativeCommandsRegistry("slack")); + expect( + shouldHandleTextCommands({ + cfg, + surface: "discord", + commandSource: "text", + }), + ).toBe(true); + expect( + shouldHandleTextCommands({ + cfg, + surface: "slack", + commandSource: "text", + }), + ).toBe(false); + }); + it("normalizes telegram-style command mentions for the current bot", () => { expect(normalizeCommandBody("/help@openclaw", { botUsername: "openclaw" })).toBe("/help"); expect( diff --git a/src/channels/registry-lookup.ts b/src/channels/registry-lookup.ts new file mode 100644 index 00000000000..82130194ceb --- /dev/null +++ b/src/channels/registry-lookup.ts @@ -0,0 +1,95 @@ +import type { + ActivePluginChannelRegistration, + ActivePluginChannelRegistry, +} from "../plugins/channel-registry-state.types.js"; +import { getActivePluginChannelRegistrySnapshotFromState } from "../plugins/runtime-channel-state.js"; +import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; + +export type RegisteredChannelPluginEntry = ActivePluginChannelRegistration & { + plugin: ActivePluginChannelRegistration["plugin"] & { + id?: string | null; + meta?: { + aliases?: readonly string[]; + markdownCapable?: boolean; + } | null; + }; +}; + +type RegisteredChannelPluginLookup = { + registry: ActivePluginChannelRegistry | null; + channels: ActivePluginChannelRegistration[] | undefined; + channelCount: number; + version: number; + entries: RegisteredChannelPluginEntry[]; + byKey: Map; + byId: Map; +}; + +let registeredChannelPluginLookup: RegisteredChannelPluginLookup | undefined; + +function setLookupEntry( + map: Map, + key: string | undefined, + entry: RegisteredChannelPluginEntry, +): void { + if (key && !map.has(key)) { + map.set(key, entry); + } +} + +function buildRegisteredChannelPluginLookup(): RegisteredChannelPluginLookup { + const { registry, version } = getActivePluginChannelRegistrySnapshotFromState(); + const channels = Array.isArray(registry?.channels) ? registry.channels : undefined; + const channelCount = channels?.length ?? 0; + const cached = registeredChannelPluginLookup; + if ( + cached && + cached.registry === registry && + cached.channels === channels && + cached.channelCount === channelCount && + cached.version === version + ) { + return cached; + } + const entries = channelCount > 0 ? (channels as RegisteredChannelPluginEntry[]) : []; + const byKey = new Map(); + const byId = new Map(); + for (const entry of entries) { + const id = normalizeOptionalLowercaseString(entry.plugin.id ?? ""); + setLookupEntry(byKey, id, entry); + setLookupEntry(byId, id, entry); + for (const alias of entry.plugin.meta?.aliases ?? []) { + setLookupEntry(byKey, normalizeOptionalLowercaseString(alias), entry); + } + } + registeredChannelPluginLookup = { + registry, + channels, + channelCount, + version, + entries, + byKey, + byId, + }; + return registeredChannelPluginLookup; +} + +export function listRegisteredChannelPluginEntries(): RegisteredChannelPluginEntry[] { + return buildRegisteredChannelPluginLookup().entries; +} + +export function findRegisteredChannelPluginEntry( + normalizedKey: string, +): RegisteredChannelPluginEntry | undefined { + return buildRegisteredChannelPluginLookup().byKey.get(normalizedKey); +} + +export function findRegisteredChannelPluginEntryById( + id: string, +): RegisteredChannelPluginEntry | undefined { + const normalizedId = normalizeOptionalLowercaseString(id); + if (!normalizedId) { + return undefined; + } + return buildRegisteredChannelPluginLookup().byId.get(normalizedId); +} diff --git a/src/channels/registry-normalize.ts b/src/channels/registry-normalize.ts index c1a9a6cd3de..ed293cb6bf5 100644 --- a/src/channels/registry-normalize.ts +++ b/src/channels/registry-normalize.ts @@ -1,30 +1,11 @@ -import type { ActivePluginChannelRegistration } from "../plugins/channel-registry-state.types.js"; -import { getActivePluginChannelRegistryFromState } from "../plugins/runtime-channel-state.js"; import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; import type { ChannelId } from "./plugins/channel-id.types.js"; - -function listRegisteredChannelPluginEntries(): ActivePluginChannelRegistration[] { - const channelRegistry = getActivePluginChannelRegistryFromState(); - if (channelRegistry?.channels && channelRegistry.channels.length > 0) { - return channelRegistry.channels; - } - return []; -} +import { findRegisteredChannelPluginEntry } from "./registry-lookup.js"; export function normalizeAnyChannelId(raw?: string | null): ChannelId | null { const key = normalizeOptionalLowercaseString(raw); if (!key) { return null; } - return ( - listRegisteredChannelPluginEntries().find((entry) => { - const id = normalizeOptionalLowercaseString(entry.plugin.id ?? "") ?? ""; - if (id && id === key) { - return true; - } - return (entry.plugin.meta?.aliases ?? []).some( - (alias) => normalizeOptionalLowercaseString(alias) === key, - ); - })?.plugin.id ?? null - ); + return findRegisteredChannelPluginEntry(key)?.plugin.id ?? null; } diff --git a/src/channels/registry.helpers.test.ts b/src/channels/registry.helpers.test.ts index 79ac6d08802..b1152d7cfda 100644 --- a/src/channels/registry.helpers.test.ts +++ b/src/channels/registry.helpers.test.ts @@ -2,12 +2,16 @@ import { afterEach, describe, expect, it } from "vitest"; import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; import { pinActivePluginChannelRegistry, + getActivePluginChannelRegistryVersion, resetPluginRuntimeStateForTest, setActivePluginRegistry, } from "../plugins/runtime.js"; +import { createTestRegistry } from "../test-utils/channel-plugins.js"; import { listChatChannels } from "./chat-meta.js"; +import { normalizeAnyChannelId as normalizeAnyChannelIdLight } from "./registry-normalize.js"; import { formatChannelSelectionLine, + getRegisteredChannelPluginMeta, listRegisteredChannelPluginIds, normalizeAnyChannelId, } from "./registry.js"; @@ -32,6 +36,16 @@ describe("channel registry helpers", () => { return label ?? path ?? ""; } + function createRegistryWithRegisteredChannel(id: string, aliases: string[] = []) { + return createTestRegistry([ + { + pluginId: id, + plugin: { id, meta: { aliases } }, + source: "test", + }, + ]); + } + it("keeps Feishu first in the current default order", () => { const channels = listChatChannels(); expect(channels[0]?.id).toBe("feishu"); @@ -53,29 +67,16 @@ describe("channel registry helpers", () => { }); it("prefers the pinned channel registry when resolving registered plugin channels", () => { - const startupRegistry = createEmptyPluginRegistry(); - startupRegistry.channels = [ - { - pluginId: "openclaw-weixin", - plugin: { id: "openclaw-weixin", meta: { aliases: ["weixin"] } }, - source: "test", - }, - ] as never; + const startupRegistry = createRegistryWithRegisteredChannel("openclaw-weixin", ["weixin"]); setActivePluginRegistry(startupRegistry); pinActivePluginChannelRegistry(startupRegistry); - const replacementRegistry = createEmptyPluginRegistry(); - replacementRegistry.channels = [ - { - pluginId: "qqbot", - plugin: { id: "qqbot", meta: { aliases: ["qq"] } }, - source: "test", - }, - ] as never; + const replacementRegistry = createRegistryWithRegisteredChannel("qqbot", ["qq"]); setActivePluginRegistry(replacementRegistry); expect(listRegisteredChannelPluginIds()).toEqual(["openclaw-weixin"]); expect(normalizeAnyChannelId("weixin")).toBe("openclaw-weixin"); + expect(getRegisteredChannelPluginMeta("OPENCLAW-WEIXIN")?.aliases).toEqual(["weixin"]); }); it("falls back to the active registry when the pinned channel registry has no channels", () => { @@ -83,17 +84,45 @@ describe("channel registry helpers", () => { setActivePluginRegistry(startupRegistry); pinActivePluginChannelRegistry(startupRegistry); - const replacementRegistry = createEmptyPluginRegistry(); - replacementRegistry.channels = [ - { - pluginId: "qqbot", - plugin: { id: "qqbot", meta: { aliases: ["qq"] } }, - source: "test", - }, - ] as never; + const replacementRegistry = createRegistryWithRegisteredChannel("qqbot", ["qq"]); setActivePluginRegistry(replacementRegistry); expect(listRegisteredChannelPluginIds()).toEqual(["qqbot"]); expect(normalizeAnyChannelId("qq")).toBe("qqbot"); }); + + it("rebuilds registered channel lookups when pinned-empty fallback active registry changes", () => { + const startupRegistry = createEmptyPluginRegistry(); + setActivePluginRegistry(startupRegistry); + pinActivePluginChannelRegistry(startupRegistry); + + const alphaRegistry = createRegistryWithRegisteredChannel("alpha", ["a"]); + setActivePluginRegistry(alphaRegistry); + + const channelVersion = getActivePluginChannelRegistryVersion(); + expect(normalizeAnyChannelId("a")).toBe("alpha"); + expect(normalizeAnyChannelIdLight("a")).toBe("alpha"); + + const betaRegistry = createRegistryWithRegisteredChannel("beta", ["b"]); + setActivePluginRegistry(betaRegistry); + + expect(getActivePluginChannelRegistryVersion()).not.toBe(channelVersion); + expect(normalizeAnyChannelId("a")).toBeNull(); + expect(normalizeAnyChannelId("b")).toBe("beta"); + expect(normalizeAnyChannelIdLight("a")).toBeNull(); + expect(normalizeAnyChannelIdLight("b")).toBe("beta"); + }); + + it("refreshes registered channel lookups when selected registry channels grow in place", () => { + const registry = createEmptyPluginRegistry(); + setActivePluginRegistry(registry); + + expect(normalizeAnyChannelId("a")).toBeNull(); + expect(normalizeAnyChannelIdLight("a")).toBeNull(); + + registry.channels.push(createRegistryWithRegisteredChannel("alpha", ["a"]).channels[0]); + + expect(normalizeAnyChannelId("a")).toBe("alpha"); + expect(normalizeAnyChannelIdLight("a")).toBe("alpha"); + }); }); diff --git a/src/channels/registry.ts b/src/channels/registry.ts index c82b0911492..bb020b52600 100644 --- a/src/channels/registry.ts +++ b/src/channels/registry.ts @@ -1,4 +1,3 @@ -import { getActivePluginChannelRegistryFromState } from "../plugins/runtime-channel-state.js"; import { normalizeOptionalLowercaseString, normalizeOptionalString, @@ -6,50 +5,14 @@ import { import { normalizeChatChannelId, type ChatChannelId } from "./ids.js"; import type { ChannelId } from "./plugins/channel-id.types.js"; import type { ChannelMeta } from "./plugins/types.core.js"; +import { + findRegisteredChannelPluginEntry, + findRegisteredChannelPluginEntryById, + listRegisteredChannelPluginEntries, +} from "./registry-lookup.js"; export { getChatChannelMeta } from "./chat-meta.js"; export { CHAT_CHANNEL_ORDER } from "./ids.js"; export type { ChatChannelId } from "./ids.js"; - -type RegisteredChannelPluginEntry = { - plugin: { - id?: string | null; - meta?: Pick | null; - }; -}; - -function listRegisteredChannelPluginEntries(): RegisteredChannelPluginEntry[] { - const channelRegistry = getActivePluginChannelRegistryFromState(); - if (channelRegistry && channelRegistry.channels && channelRegistry.channels.length > 0) { - return channelRegistry.channels; - } - return []; -} - -function findRegisteredChannelPluginEntry( - normalizedKey: string, -): RegisteredChannelPluginEntry | undefined { - return listRegisteredChannelPluginEntries().find((entry) => { - const id = normalizeOptionalLowercaseString(entry.plugin.id ?? "") ?? ""; - if (id && id === normalizedKey) { - return true; - } - return (entry.plugin.meta?.aliases ?? []).some( - (alias) => normalizeOptionalLowercaseString(alias) === normalizedKey, - ); - }); -} - -function findRegisteredChannelPluginEntryById( - id: string, -): RegisteredChannelPluginEntry | undefined { - const normalizedId = normalizeOptionalLowercaseString(id); - if (!normalizedId) { - return undefined; - } - return listRegisteredChannelPluginEntries().find( - (entry) => normalizeOptionalLowercaseString(entry.plugin.id) === normalizedId, - ); -} export { normalizeChatChannelId }; // Channel docking: prefer this helper in shared code. Importing from diff --git a/src/plugins/runtime-channel-state.ts b/src/plugins/runtime-channel-state.ts index cb0b5711898..5e50dad8eab 100644 --- a/src/plugins/runtime-channel-state.ts +++ b/src/plugins/runtime-channel-state.ts @@ -13,24 +13,68 @@ type GlobalChannelRegistryState = typeof globalThis & { }; }; +type GlobalChannelRegistryRuntimeState = GlobalChannelRegistryState[typeof PLUGIN_REGISTRY_STATE]; + +export type ActivePluginChannelRegistrySnapshot = { + registry: ActivePluginChannelRegistry | null; + version: number; +}; + +let activePluginChannelRegistrySnapshot: + | { + state: GlobalChannelRegistryRuntimeState; + pinnedRegistry: ActivePluginChannelRegistry | null; + activeRegistry: ActivePluginChannelRegistry | null; + pinnedChannelCount: number; + activeChannelCount: number; + snapshot: ActivePluginChannelRegistrySnapshot; + } + | undefined; + function countChannels(registry: ActivePluginChannelRegistry | null | undefined): number { return registry?.channels?.length ?? 0; } -export function getActivePluginChannelRegistryFromState(): ActivePluginChannelRegistry | null { +export function getActivePluginChannelRegistrySnapshotFromState(): ActivePluginChannelRegistrySnapshot { const state = (globalThis as GlobalChannelRegistryState)[PLUGIN_REGISTRY_STATE]; const pinnedRegistry = state?.channel?.registry ?? null; - if (countChannels(pinnedRegistry) > 0) { - return pinnedRegistry; - } const activeRegistry = state?.activeRegistry ?? null; - if (countChannels(activeRegistry) > 0) { - return activeRegistry; + const pinnedChannelCount = countChannels(pinnedRegistry); + const activeChannelCount = countChannels(activeRegistry); + const selectedPinnedRegistry = + pinnedChannelCount > 0 || (pinnedRegistry !== null && activeChannelCount === 0); + const version = selectedPinnedRegistry + ? (state?.channel?.version ?? 0) + : (state?.activeVersion ?? 0); + const cached = activePluginChannelRegistrySnapshot; + if ( + cached && + cached.state === state && + cached.pinnedRegistry === pinnedRegistry && + cached.activeRegistry === activeRegistry && + cached.pinnedChannelCount === pinnedChannelCount && + cached.activeChannelCount === activeChannelCount && + cached.snapshot.version === version + ) { + return cached.snapshot; } - return pinnedRegistry ?? activeRegistry; + const registry = selectedPinnedRegistry ? pinnedRegistry : activeRegistry; + const snapshot = { registry, version }; + activePluginChannelRegistrySnapshot = { + state, + pinnedRegistry, + activeRegistry, + pinnedChannelCount, + activeChannelCount, + snapshot, + }; + return snapshot; +} + +export function getActivePluginChannelRegistryFromState(): ActivePluginChannelRegistry | null { + return getActivePluginChannelRegistrySnapshotFromState().registry; } export function getActivePluginChannelRegistryVersionFromState(): number { - const state = (globalThis as GlobalChannelRegistryState)[PLUGIN_REGISTRY_STATE]; - return state?.channel?.registry ? (state.channel.version ?? 0) : (state?.activeVersion ?? 0); + return getActivePluginChannelRegistrySnapshotFromState().version; } diff --git a/src/plugins/runtime.channel-pin.test.ts b/src/plugins/runtime.channel-pin.test.ts index f7a2db988c6..6a7c436009f 100644 --- a/src/plugins/runtime.channel-pin.test.ts +++ b/src/plugins/runtime.channel-pin.test.ts @@ -124,6 +124,19 @@ describe("channel registry pinning", () => { expect(isPluginRegistryRetired(startup)).toBe(true); }); + it("falls back to the active channel registry when the pinned registry is empty", () => { + const startup = createEmptyPluginRegistry(); + const { registry: replacement } = createRegistryWithChannel("replacement-channel"); + setActivePluginRegistry(startup); + pinActivePluginChannelRegistry(startup); + + const channelVersionBeforeSwap = getActivePluginChannelRegistryVersion(); + setActivePluginRegistry(replacement); + + expectActiveChannelRegistry(replacement); + expect(getActivePluginChannelRegistryVersion()).not.toBe(channelVersionBeforeSwap); + }); + it("re-pin invalidates cached channel lookups", () => { const { first, second } = createChannelRegistryPair(); const { registry: setup, plugin: setupPlugin } = first; diff --git a/src/plugins/runtime.ts b/src/plugins/runtime.ts index 9147f951fec..14e9a6bf688 100644 --- a/src/plugins/runtime.ts +++ b/src/plugins/runtime.ts @@ -7,6 +7,7 @@ import { import { createEmptyPluginRegistry } from "./registry-empty.js"; import { markPluginRegistryActive, markPluginRegistryRetired } from "./registry-lifecycle.js"; import type { PluginRegistry } from "./registry-types.js"; +import { getActivePluginChannelRegistrySnapshotFromState } from "./runtime-channel-state.js"; import { PLUGIN_REGISTRY_STATE, type RegistryState, @@ -277,10 +278,6 @@ export function resolveActivePluginHttpRouteRegistry(fallback: PluginRegistry): return routeRegistry; } -/** Pin the channel registry so that subsequent `setActivePluginRegistry` calls - * do not replace the channel snapshot used by `getChannelPlugin`. Call at - * gateway startup after the initial plugin load so that config-schema reads - * and other non-primary registry loads cannot evict channel plugins. */ export function pinActivePluginChannelRegistry(registry: PluginRegistry) { const previousRegistry = asPluginRegistry(state.channel.registry); installSurfaceRegistry(state.channel, registry, true); @@ -303,15 +300,12 @@ export function releasePinnedPluginChannelRegistry(registry?: PluginRegistry) { } } -/** Return the registry that should be used for channel plugin resolution. - * When pinned, this returns the startup registry regardless of subsequent - * `setActivePluginRegistry` calls. */ export function getActivePluginChannelRegistry(): PluginRegistry | null { - return asPluginRegistry(state.channel.registry ?? state.activeRegistry); + return getActivePluginChannelRegistrySnapshotFromState().registry as PluginRegistry | null; } export function getActivePluginChannelRegistryVersion(): number { - return state.channel.registry ? state.channel.version : state.activeVersion; + return getActivePluginChannelRegistrySnapshotFromState().version; } export function requireActivePluginChannelRegistry(): PluginRegistry { From 36aea9792f6b624d5d011d849e7603bf3966f683 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 11 May 2026 13:26:13 +0100 Subject: [PATCH 93/93] docs: update changelog for #80333 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a1d767d7d3..d2bf8bd3189 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,7 @@ Docs: https://docs.openclaw.ai - Feishu: make manual App ID/App Secret setup the default channel-binding path while keeping QR scan-to-create as an optional best-effort flow, and document the manual fallback for domestic Feishu mobile clients that do not react to the QR code. Fixes #80591. Thanks @wei-wei-zhao. - Telegram: show resolved thinking defaults in native `/status` and `/think` menus while preserving explicit session overrides. (#80341) Thanks @VACInc. +- Channels: cache selected channel registry lookups against the active fallback snapshot so pinned-empty registries refresh native command and alias routing after active registry swaps. (#80333) Thanks @samzong. - Gateway: scope `sessions.resolve` sessionId and label store loads to the requested agent so large unrelated agent stores are not parsed for scoped lookups. Fixes #51264. (#79474) Thanks @samzong. - Gateway: share serialized streaming event envelopes across eligible WebSocket and node subscribers while preserving per-client sequence numbers. (#80299) Thanks @samzong. - Browser: report Chrome MCP existing-session page readiness in browser status without letting status probes exceed the client timeout. Fixes #80268. (#80280) Thanks @ai-hpc.