From 0836bf844be9dd3606beeed73512bfb0f0c300f2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 20:38:03 +0000 Subject: [PATCH 001/461] refactor: share global update test harness --- src/infra/update-runner.test.ts | 149 ++++++++++++-------------------- 1 file changed, 53 insertions(+), 96 deletions(-) diff --git a/src/infra/update-runner.test.ts b/src/infra/update-runner.test.ts index 0ba8e1ce3f9..bb9be0d5be7 100644 --- a/src/infra/update-runner.test.ts +++ b/src/infra/update-runner.test.ts @@ -187,6 +187,21 @@ describe("runGatewayUpdate", () => { ); } + async function writeGlobalPackageVersion(pkgRoot: string, version = "2.0.0") { + await fs.writeFile( + path.join(pkgRoot, "package.json"), + JSON.stringify({ name: "openclaw", version }), + "utf-8", + ); + } + + async function createGlobalPackageFixture(rootDir: string) { + const nodeModules = path.join(rootDir, "node_modules"); + const pkgRoot = path.join(nodeModules, "openclaw"); + await seedGlobalPackageRoot(pkgRoot); + return { nodeModules, pkgRoot }; + } + function createGlobalNpmUpdateRunner(params: { pkgRoot: string; nodeModules: string; @@ -366,13 +381,17 @@ describe("runGatewayUpdate", () => { pkgRoot: string; npmRootOutput?: string; installCommand: string; - onInstall?: () => Promise; + gitRootMode?: "not-git" | "missing"; + onInstall?: (options?: { env?: NodeJS.ProcessEnv }) => Promise; }) => { const calls: string[] = []; - const runCommand = async (argv: string[]) => { + const runCommand = async (argv: string[], options?: { env?: NodeJS.ProcessEnv }) => { const key = argv.join(" "); calls.push(key); if (key === `git -C ${params.pkgRoot} rev-parse --show-toplevel`) { + if (params.gitRootMode === "missing") { + throw Object.assign(new Error("spawn git ENOENT"), { code: "ENOENT" }); + } return { stdout: "", stderr: "not a git repository", code: 128 }; } if (key === "npm root -g") { @@ -385,7 +404,7 @@ describe("runGatewayUpdate", () => { return { stdout: "", stderr: "", code: 1 }; } if (key === params.installCommand) { - await params.onInstall?.(); + await params.onInstall?.(options); return { stdout: "ok", stderr: "", code: 0 }; } return { stdout: "", stderr: "", code: 0 }; @@ -423,32 +442,14 @@ describe("runGatewayUpdate", () => { }); it("falls back to global npm update when git is missing from PATH", async () => { - const nodeModules = path.join(tempDir, "node_modules"); - const pkgRoot = path.join(nodeModules, "openclaw"); - await seedGlobalPackageRoot(pkgRoot); - - const calls: string[] = []; - const runCommand = async (argv: string[]): Promise => { - const key = argv.join(" "); - calls.push(key); - if (key === `git -C ${pkgRoot} rev-parse --show-toplevel`) { - throw Object.assign(new Error("spawn git ENOENT"), { code: "ENOENT" }); - } - if (key === "npm root -g") { - return { stdout: nodeModules, stderr: "", code: 0 }; - } - if (key === "pnpm root -g") { - return { stdout: "", stderr: "", code: 1 }; - } - if (key === "npm i -g openclaw@latest --no-fund --no-audit --loglevel=error") { - await fs.writeFile( - path.join(pkgRoot, "package.json"), - JSON.stringify({ name: "openclaw", version: "2.0.0" }), - "utf-8", - ); - } - return { stdout: "ok", stderr: "", code: 0 }; - }; + const { nodeModules, pkgRoot } = await createGlobalPackageFixture(tempDir); + const { calls, runCommand } = createGlobalInstallHarness({ + pkgRoot, + npmRootOutput: nodeModules, + installCommand: "npm i -g openclaw@latest --no-fund --no-audit --loglevel=error", + gitRootMode: "missing", + onInstall: async () => writeGlobalPackageVersion(pkgRoot), + }); const result = await runWithCommand(runCommand, { cwd: pkgRoot }); @@ -537,35 +538,17 @@ describe("runGatewayUpdate", () => { await fs.mkdir(portableGitMingw, { recursive: true }); await fs.mkdir(portableGitUsr, { recursive: true }); - const nodeModules = path.join(tempDir, "node_modules"); - const pkgRoot = path.join(nodeModules, "openclaw"); - await seedGlobalPackageRoot(pkgRoot); - let installEnv: NodeJS.ProcessEnv | undefined; - const runCommand = async ( - argv: string[], - options?: { env?: NodeJS.ProcessEnv }, - ): Promise => { - const key = argv.join(" "); - if (key === `git -C ${pkgRoot} rev-parse --show-toplevel`) { - return { stdout: "", stderr: "not a git repository", code: 128 }; - } - if (key === "npm root -g") { - return { stdout: nodeModules, stderr: "", code: 0 }; - } - if (key === "pnpm root -g") { - return { stdout: "", stderr: "", code: 1 }; - } - if (key === "npm i -g openclaw@latest --no-fund --no-audit --loglevel=error") { + const { nodeModules, pkgRoot } = await createGlobalPackageFixture(tempDir); + const { runCommand } = createGlobalInstallHarness({ + pkgRoot, + npmRootOutput: nodeModules, + installCommand: "npm i -g openclaw@latest --no-fund --no-audit --loglevel=error", + onInstall: async (options) => { installEnv = options?.env; - await fs.writeFile( - path.join(pkgRoot, "package.json"), - JSON.stringify({ name: "openclaw", version: "2.0.0" }), - "utf-8", - ); - } - return { stdout: "ok", stderr: "", code: 0 }; - }; + await writeGlobalPackageVersion(pkgRoot); + }, + }); await withEnvAsync({ LOCALAPPDATA: localAppData }, async () => { const result = await runWithCommand(runCommand, { cwd: pkgRoot }); @@ -584,35 +567,15 @@ describe("runGatewayUpdate", () => { }); it("uses OPENCLAW_UPDATE_PACKAGE_SPEC for global package updates", async () => { - const nodeModules = path.join(tempDir, "node_modules"); - const pkgRoot = path.join(nodeModules, "openclaw"); - await seedGlobalPackageRoot(pkgRoot); - - const calls: string[] = []; - const runCommand = async (argv: string[]): Promise => { - const key = argv.join(" "); - calls.push(key); - if (key === `git -C ${pkgRoot} rev-parse --show-toplevel`) { - return { stdout: "", stderr: "not a git repository", code: 128 }; - } - if (key === "npm root -g") { - return { stdout: nodeModules, stderr: "", code: 0 }; - } - if (key === "pnpm root -g") { - return { stdout: "", stderr: "", code: 1 }; - } - if ( - key === - "npm i -g http://10.211.55.2:8138/openclaw-next.tgz --no-fund --no-audit --loglevel=error" - ) { - await fs.writeFile( - path.join(pkgRoot, "package.json"), - JSON.stringify({ name: "openclaw", version: "2.0.0" }), - "utf-8", - ); - } - return { stdout: "ok", stderr: "", code: 0 }; - }; + const { nodeModules, pkgRoot } = await createGlobalPackageFixture(tempDir); + const expectedInstallCommand = + "npm i -g http://10.211.55.2:8138/openclaw-next.tgz --no-fund --no-audit --loglevel=error"; + const { calls, runCommand } = createGlobalInstallHarness({ + pkgRoot, + npmRootOutput: nodeModules, + installCommand: expectedInstallCommand, + onInstall: async () => writeGlobalPackageVersion(pkgRoot), + }); await withEnvAsync( { OPENCLAW_UPDATE_PACKAGE_SPEC: "http://10.211.55.2:8138/openclaw-next.tgz" }, @@ -622,27 +585,21 @@ describe("runGatewayUpdate", () => { }, ); - expect(calls).toContain( - "npm i -g http://10.211.55.2:8138/openclaw-next.tgz --no-fund --no-audit --loglevel=error", - ); + expect(calls).toContain(expectedInstallCommand); }); it("updates global bun installs when detected", async () => { const bunInstall = path.join(tempDir, "bun-install"); await withEnvAsync({ BUN_INSTALL: bunInstall }, async () => { - const bunGlobalRoot = path.join(bunInstall, "install", "global", "node_modules"); - const pkgRoot = path.join(bunGlobalRoot, "openclaw"); - await seedGlobalPackageRoot(pkgRoot); + const { pkgRoot } = await createGlobalPackageFixture( + path.join(bunInstall, "install", "global"), + ); const { calls, runCommand } = createGlobalInstallHarness({ pkgRoot, installCommand: "bun add -g openclaw@latest", onInstall: async () => { - await fs.writeFile( - path.join(pkgRoot, "package.json"), - JSON.stringify({ name: "openclaw", version: "2.0.0" }), - "utf-8", - ); + await writeGlobalPackageVersion(pkgRoot); }, }); From 6d0e4c76ac85dbc8fb64f3ec2cb3920cd94fadf6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 20:39:25 +0000 Subject: [PATCH 002/461] refactor: share cron model formatting assertions --- .../isolated-agent.model-formatting.test.ts | 278 +++++++++--------- 1 file changed, 145 insertions(+), 133 deletions(-) diff --git a/src/cron/isolated-agent.model-formatting.test.ts b/src/cron/isolated-agent.model-formatting.test.ts index f9732a32d31..b09a9db5ea1 100644 --- a/src/cron/isolated-agent.model-formatting.test.ts +++ b/src/cron/isolated-agent.model-formatting.test.ts @@ -24,6 +24,8 @@ function lastEmbeddedCall(): { provider?: string; model?: string } { } const DEFAULT_MESSAGE = "do it"; +const DEFAULT_PROVIDER = "anthropic"; +const DEFAULT_MODEL = "claude-opus-4-5"; type TurnOptions = { cfgOverrides?: Parameters[2]; @@ -73,6 +75,50 @@ async function runTurn(home: string, options: TurnOptions = {}) { return { res, call: lastEmbeddedCall() }; } +function expectSelectedModel( + call: { provider?: string; model?: string }, + params: { provider: string; model: string }, +) { + expect(call.provider).toBe(params.provider); + expect(call.model).toBe(params.model); +} + +function expectDefaultSelectedModel(call: { provider?: string; model?: string }) { + expectSelectedModel(call, { provider: DEFAULT_PROVIDER, model: DEFAULT_MODEL }); +} + +function createCronSessionOverrideStore( + overrides: Record, + sessionId = "existing-session", +) { + return { + "agent:main:cron:job-1": { + sessionId, + updatedAt: Date.now(), + ...overrides, + }, + }; +} + +async function expectTurnModel( + home: string, + options: TurnOptions, + expected: { provider: string; model: string }, +) { + const { res, call } = await runTurn(home, options); + expect(res.status).toBe("ok"); + expectSelectedModel(call, expected); +} + +async function expectInvalidModel(home: string, model: string) { + const { res } = await runErrorTurn(home, { + jobPayload: { kind: "agentTurn", message: DEFAULT_MESSAGE, model }, + }); + expect(res.status).toBe("error"); + expect(res.error).toMatch(/invalid model/i); + expect(vi.mocked(runEmbeddedPiAgent)).not.toHaveBeenCalled(); +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -99,16 +145,17 @@ describe("cron model formatting and precedence edge cases", () => { it("handles leading/trailing whitespace in model string", async () => { await withTempHome(async (home) => { - const { res, call } = await runTurn(home, { - jobPayload: { - kind: "agentTurn", - message: DEFAULT_MESSAGE, - model: " openai/gpt-4.1-mini ", + await expectTurnModel( + home, + { + jobPayload: { + kind: "agentTurn", + message: DEFAULT_MESSAGE, + model: " openai/gpt-4.1-mini ", + }, }, - }); - expect(res.status).toBe("ok"); - expect(call.provider).toBe("openai"); - expect(call.model).toBe("gpt-4.1-mini"); + { provider: "openai", model: "gpt-4.1-mini" }, + ); }); }); @@ -129,38 +176,29 @@ describe("cron model formatting and precedence edge cases", () => { it("rejects model with trailing slash (empty model name)", async () => { await withTempHome(async (home) => { - const { res } = await runErrorTurn(home, { - jobPayload: { kind: "agentTurn", message: DEFAULT_MESSAGE, model: "openai/" }, - }); - expect(res.status).toBe("error"); - expect(res.error).toMatch(/invalid model/i); - expect(vi.mocked(runEmbeddedPiAgent)).not.toHaveBeenCalled(); + await expectInvalidModel(home, "openai/"); }); }); it("rejects model with leading slash (empty provider)", async () => { await withTempHome(async (home) => { - const { res } = await runErrorTurn(home, { - jobPayload: { kind: "agentTurn", message: DEFAULT_MESSAGE, model: "/gpt-4.1-mini" }, - }); - expect(res.status).toBe("error"); - expect(res.error).toMatch(/invalid model/i); - expect(vi.mocked(runEmbeddedPiAgent)).not.toHaveBeenCalled(); + await expectInvalidModel(home, "/gpt-4.1-mini"); }); }); it("normalizes provider casing", async () => { await withTempHome(async (home) => { - const { res, call } = await runTurn(home, { - jobPayload: { - kind: "agentTurn", - message: DEFAULT_MESSAGE, - model: "OpenAI/gpt-4.1-mini", + await expectTurnModel( + home, + { + jobPayload: { + kind: "agentTurn", + message: DEFAULT_MESSAGE, + model: "OpenAI/gpt-4.1-mini", + }, }, - }); - expect(res.status).toBe("ok"); - expect(call.provider).toBe("openai"); - expect(call.model).toBe("gpt-4.1-mini"); + { provider: "openai", model: "gpt-4.1-mini" }, + ); }); }); @@ -217,43 +255,39 @@ describe("cron model formatting and precedence edge cases", () => { // No model in job payload. Session store has openai override. // Provider must be openai, not the default anthropic. await withTempHome(async (home) => { - const { call } = await runTurn(home, { - jobPayload: { kind: "agentTurn", message: DEFAULT_MESSAGE, deliver: false }, - storeEntries: { - "agent:main:cron:job-1": { - sessionId: "existing-session", - updatedAt: Date.now(), + await expectTurnModel( + home, + { + jobPayload: { kind: "agentTurn", message: DEFAULT_MESSAGE, deliver: false }, + storeEntries: createCronSessionOverrideStore({ providerOverride: "openai", modelOverride: "gpt-4.1-mini", - }, + }), }, - }); - expect(call.provider).toBe("openai"); - expect(call.model).toBe("gpt-4.1-mini"); + { provider: "openai", model: "gpt-4.1-mini" }, + ); }); }); it("job payload model wins over conflicting session override", async () => { // Job payload says anthropic. Session says openai. Job must win. await withTempHome(async (home) => { - const { call } = await runTurn(home, { - jobPayload: { - kind: "agentTurn", - message: DEFAULT_MESSAGE, - model: "anthropic/claude-sonnet-4-5", - deliver: false, - }, - storeEntries: { - "agent:main:cron:job-1": { - sessionId: "existing-session", - updatedAt: Date.now(), + await expectTurnModel( + home, + { + jobPayload: { + kind: "agentTurn", + message: DEFAULT_MESSAGE, + model: "anthropic/claude-sonnet-4-5", + deliver: false, + }, + storeEntries: createCronSessionOverrideStore({ providerOverride: "openai", modelOverride: "gpt-4.1-mini", - }, + }), }, - }); - expect(call.provider).toBe("anthropic"); - expect(call.model).toBe("claude-sonnet-4-5"); + { provider: "anthropic", model: "claude-sonnet-4-5" }, + ); }); }); @@ -262,9 +296,7 @@ describe("cron model formatting and precedence edge cases", () => { const { call } = await runTurn(home, { jobPayload: { kind: "agentTurn", message: DEFAULT_MESSAGE, deliver: false }, }); - // makeCfg default is anthropic/claude-opus-4-5 - expect(call.provider).toBe("anthropic"); - expect(call.model).toBe("claude-opus-4-5"); + expectDefaultSelectedModel(call); }); }); }); @@ -293,17 +325,12 @@ describe("cron model formatting and precedence edge cases", () => { mockAgentPayloads([{ text: "ok" }]); const step2 = await runTurn(home, { jobPayload: { kind: "agentTurn", message: DEFAULT_MESSAGE, deliver: false }, - storeEntries: { - "agent:main:cron:job-1": { - sessionId: "existing-session", - updatedAt: Date.now(), - providerOverride: "openai", - modelOverride: "gpt-4.1-mini", - }, - }, + storeEntries: createCronSessionOverrideStore({ + providerOverride: "openai", + modelOverride: "gpt-4.1-mini", + }), }); - expect(step2.call.provider).toBe("openai"); - expect(step2.call.model).toBe("gpt-4.1-mini"); + expectSelectedModel(step2.call, { provider: "openai", model: "gpt-4.1-mini" }); // Step 3: Job payload says anthropic, session store still says openai vi.mocked(runEmbeddedPiAgent).mockClear(); @@ -315,17 +342,12 @@ describe("cron model formatting and precedence edge cases", () => { model: "anthropic/claude-opus-4-5", deliver: false, }, - storeEntries: { - "agent:main:cron:job-1": { - sessionId: "existing-session", - updatedAt: Date.now(), - providerOverride: "openai", - modelOverride: "gpt-4.1-mini", - }, - }, + storeEntries: createCronSessionOverrideStore({ + providerOverride: "openai", + modelOverride: "gpt-4.1-mini", + }), }); - expect(step3.call.provider).toBe("anthropic"); - expect(step3.call.model).toBe("claude-opus-4-5"); + expectSelectedModel(step3.call, { provider: "anthropic", model: "claude-opus-4-5" }); }); }); @@ -349,8 +371,7 @@ describe("cron model formatting and precedence edge cases", () => { const r2 = await runTurn(home, { jobPayload: { kind: "agentTurn", message: DEFAULT_MESSAGE, deliver: false }, }); - expect(r2.call.provider).toBe("anthropic"); - expect(r2.call.model).toBe("claude-opus-4-5"); + expectDefaultSelectedModel(r2.call); }); }); }); @@ -363,19 +384,20 @@ describe("cron model formatting and precedence edge cases", () => { // The stored modelOverride/providerOverride must still be read and applied // (resolveCronSession spreads ...entry before overriding core fields). await withTempHome(async (home) => { - const { call } = await runTurn(home, { - jobPayload: { kind: "agentTurn", message: DEFAULT_MESSAGE, deliver: false }, - storeEntries: { - "agent:main:cron:job-1": { - sessionId: "old-session-id", - updatedAt: Date.now(), - providerOverride: "openai", - modelOverride: "gpt-4.1-mini", - }, + await expectTurnModel( + home, + { + jobPayload: { kind: "agentTurn", message: DEFAULT_MESSAGE, deliver: false }, + storeEntries: createCronSessionOverrideStore( + { + providerOverride: "openai", + modelOverride: "gpt-4.1-mini", + }, + "old-session-id", + ), }, - }); - expect(call.provider).toBe("openai"); - expect(call.model).toBe("gpt-4.1-mini"); + { provider: "openai", model: "gpt-4.1-mini" }, + ); }); }); @@ -383,16 +405,9 @@ describe("cron model formatting and precedence edge cases", () => { await withTempHome(async (home) => { const { call } = await runTurn(home, { jobPayload: { kind: "agentTurn", message: DEFAULT_MESSAGE, deliver: false }, - storeEntries: { - "agent:main:cron:job-1": { - sessionId: "old-session-id", - updatedAt: Date.now(), - // No providerOverride or modelOverride - }, - }, + storeEntries: createCronSessionOverrideStore({}, "old-session-id"), }); - expect(call.provider).toBe("anthropic"); - expect(call.model).toBe("claude-opus-4-5"); + expectDefaultSelectedModel(call); }); }); }); @@ -405,8 +420,7 @@ describe("cron model formatting and precedence edge cases", () => { const { call } = await runTurn(home, { jobPayload: { kind: "agentTurn", message: DEFAULT_MESSAGE, model: " " }, }); - expect(call.provider).toBe("anthropic"); - expect(call.model).toBe("claude-opus-4-5"); + expectDefaultSelectedModel(call); }); }); @@ -415,8 +429,7 @@ describe("cron model formatting and precedence edge cases", () => { const { call } = await runTurn(home, { jobPayload: { kind: "agentTurn", message: DEFAULT_MESSAGE, model: "" }, }); - expect(call.provider).toBe("anthropic"); - expect(call.model).toBe("claude-opus-4-5"); + expectDefaultSelectedModel(call); }); }); @@ -424,18 +437,13 @@ describe("cron model formatting and precedence edge cases", () => { await withTempHome(async (home) => { const { call } = await runTurn(home, { jobPayload: { kind: "agentTurn", message: DEFAULT_MESSAGE, deliver: false }, - storeEntries: { - "agent:main:cron:job-1": { - sessionId: "old", - updatedAt: Date.now(), - providerOverride: "openai", - modelOverride: " ", - }, - }, + storeEntries: createCronSessionOverrideStore( + { providerOverride: "openai", modelOverride: " " }, + "old", + ), }); // Whitespace modelOverride should be ignored → default - expect(call.provider).toBe("anthropic"); - expect(call.model).toBe("claude-opus-4-5"); + expectDefaultSelectedModel(call); }); }); }); @@ -445,35 +453,39 @@ describe("cron model formatting and precedence edge cases", () => { describe("config model format variations", () => { it("default model as string 'provider/model'", async () => { await withTempHome(async (home) => { - const { call } = await runTurn(home, { - cfgOverrides: { - agents: { - defaults: { - model: "openai/gpt-4.1", + await expectTurnModel( + home, + { + cfgOverrides: { + agents: { + defaults: { + model: "openai/gpt-4.1", + }, }, }, + jobPayload: { kind: "agentTurn", message: DEFAULT_MESSAGE, deliver: false }, }, - jobPayload: { kind: "agentTurn", message: DEFAULT_MESSAGE, deliver: false }, - }); - expect(call.provider).toBe("openai"); - expect(call.model).toBe("gpt-4.1"); + { provider: "openai", model: "gpt-4.1" }, + ); }); }); it("default model as object with primary field", async () => { await withTempHome(async (home) => { - const { call } = await runTurn(home, { - cfgOverrides: { - agents: { - defaults: { - model: { primary: "openai/gpt-4.1" }, + await expectTurnModel( + home, + { + cfgOverrides: { + agents: { + defaults: { + model: { primary: "openai/gpt-4.1" }, + }, }, }, + jobPayload: { kind: "agentTurn", message: DEFAULT_MESSAGE, deliver: false }, }, - jobPayload: { kind: "agentTurn", message: DEFAULT_MESSAGE, deliver: false }, - }); - expect(call.provider).toBe("openai"); - expect(call.model).toBe("gpt-4.1"); + { provider: "openai", model: "gpt-4.1" }, + ); }); }); From 1cea43d3495dc31df37e4cbceae3dec5c35c97a0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:15:25 +0000 Subject: [PATCH 003/461] test: share zalouser group policy resolver --- extensions/zalouser/src/channel.test.ts | 67 +++++++++++-------------- 1 file changed, 29 insertions(+), 38 deletions(-) diff --git a/extensions/zalouser/src/channel.test.ts b/extensions/zalouser/src/channel.test.ts index f54539ed809..321df502b38 100644 --- a/extensions/zalouser/src/channel.test.ts +++ b/extensions/zalouser/src/channel.test.ts @@ -15,6 +15,33 @@ vi.mock("./send.js", async (importOriginal) => { const mockSendMessage = vi.mocked(sendMessageZalouser); const mockSendReaction = vi.mocked(sendReactionZalouser); +function getResolveToolPolicy() { + const resolveToolPolicy = zalouserPlugin.groups?.resolveToolPolicy; + expect(resolveToolPolicy).toBeTypeOf("function"); + if (!resolveToolPolicy) { + throw new Error("resolveToolPolicy unavailable"); + } + return resolveToolPolicy; +} + +function resolveGroupToolPolicy( + groups: Record, + groupId: string, +) { + return getResolveToolPolicy()({ + cfg: { + channels: { + zalouser: { + groups, + }, + }, + }, + accountId: "default", + groupId, + groupChannel: groupId, + }); +} + describe("zalouser outbound", () => { beforeEach(() => { mockSendMessage.mockClear(); @@ -93,48 +120,12 @@ describe("zalouser channel policies", () => { }); it("resolves group tool policy by explicit group id", () => { - const resolveToolPolicy = zalouserPlugin.groups?.resolveToolPolicy; - expect(resolveToolPolicy).toBeTypeOf("function"); - if (!resolveToolPolicy) { - return; - } - const policy = resolveToolPolicy({ - cfg: { - channels: { - zalouser: { - groups: { - "123": { tools: { allow: ["search"] } }, - }, - }, - }, - }, - accountId: "default", - groupId: "123", - groupChannel: "123", - }); + const policy = resolveGroupToolPolicy({ "123": { tools: { allow: ["search"] } } }, "123"); expect(policy).toEqual({ allow: ["search"] }); }); it("falls back to wildcard group policy", () => { - const resolveToolPolicy = zalouserPlugin.groups?.resolveToolPolicy; - expect(resolveToolPolicy).toBeTypeOf("function"); - if (!resolveToolPolicy) { - return; - } - const policy = resolveToolPolicy({ - cfg: { - channels: { - zalouser: { - groups: { - "*": { tools: { deny: ["system.run"] } }, - }, - }, - }, - }, - accountId: "default", - groupId: "missing", - groupChannel: "missing", - }); + const policy = resolveGroupToolPolicy({ "*": { tools: { deny: ["system.run"] } } }, "missing"); expect(policy).toEqual({ deny: ["system.run"] }); }); From 534e4b14186f4c150df3a16d314decd1b63fefd0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:15:42 +0000 Subject: [PATCH 004/461] refactor: share synology chat raw config fields --- extensions/synology-chat/src/types.ts | 20 ++++++-------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/extensions/synology-chat/src/types.ts b/extensions/synology-chat/src/types.ts index 7ba222531c6..842c2ee97bb 100644 --- a/extensions/synology-chat/src/types.ts +++ b/extensions/synology-chat/src/types.ts @@ -2,8 +2,7 @@ * Type definitions for the Synology Chat channel plugin. */ -/** Raw channel config from openclaw.json channels.synology-chat */ -export interface SynologyChatChannelConfig { +type SynologyChatConfigFields = { enabled?: boolean; token?: string; incomingUrl?: string; @@ -14,22 +13,15 @@ export interface SynologyChatChannelConfig { rateLimitPerMinute?: number; botName?: string; allowInsecureSsl?: boolean; +}; + +/** Raw channel config from openclaw.json channels.synology-chat */ +export interface SynologyChatChannelConfig extends SynologyChatConfigFields { accounts?: Record; } /** Raw per-account config (overrides base config) */ -export interface SynologyChatAccountRaw { - enabled?: boolean; - token?: string; - incomingUrl?: string; - nasHost?: string; - webhookPath?: string; - dmPolicy?: "open" | "allowlist" | "disabled"; - allowedUserIds?: string | string[]; - rateLimitPerMinute?: number; - botName?: string; - allowInsecureSsl?: boolean; -} +export interface SynologyChatAccountRaw extends SynologyChatConfigFields {} /** Fully resolved account config with defaults applied */ export interface ResolvedSynologyChatAccount { From a8508f2b3119b9d98fa349406bdd0961ccb9c84a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:16:15 +0000 Subject: [PATCH 005/461] test: share voice webhook reaper harness --- extensions/voice-call/src/webhook.test.ts | 64 ++++++++++++----------- 1 file changed, 34 insertions(+), 30 deletions(-) diff --git a/extensions/voice-call/src/webhook.test.ts b/extensions/voice-call/src/webhook.test.ts index f5a827a3ef3..6297a69f14a 100644 --- a/extensions/voice-call/src/webhook.test.ts +++ b/extensions/voice-call/src/webhook.test.ts @@ -56,6 +56,28 @@ const createManager = (calls: CallRecord[]) => { return { manager, endCall, processEvent }; }; +async function runStaleCallReaperCase(params: { + callAgeMs: number; + staleCallReaperSeconds: number; + advanceMs: number; +}) { + const now = new Date("2026-02-16T00:00:00Z"); + vi.setSystemTime(now); + + const call = createCall(now.getTime() - params.callAgeMs); + const { manager, endCall } = createManager([call]); + const config = createConfig({ staleCallReaperSeconds: params.staleCallReaperSeconds }); + const server = new VoiceCallWebhookServer(config, manager, provider); + + try { + await server.start(); + await vi.advanceTimersByTimeAsync(params.advanceMs); + return { call, endCall }; + } finally { + await server.stop(); + } +} + async function postWebhookForm(server: VoiceCallWebhookServer, baseUrl: string, body: string) { const address = ( server as unknown as { server?: { address?: () => unknown } } @@ -81,39 +103,21 @@ describe("VoiceCallWebhookServer stale call reaper", () => { }); it("ends calls older than staleCallReaperSeconds", async () => { - const now = new Date("2026-02-16T00:00:00Z"); - vi.setSystemTime(now); - - const call = createCall(now.getTime() - 120_000); - const { manager, endCall } = createManager([call]); - const config = createConfig({ staleCallReaperSeconds: 60 }); - const server = new VoiceCallWebhookServer(config, manager, provider); - - try { - await server.start(); - await vi.advanceTimersByTimeAsync(30_000); - expect(endCall).toHaveBeenCalledWith(call.callId); - } finally { - await server.stop(); - } + const { call, endCall } = await runStaleCallReaperCase({ + callAgeMs: 120_000, + staleCallReaperSeconds: 60, + advanceMs: 30_000, + }); + expect(endCall).toHaveBeenCalledWith(call.callId); }); it("skips calls that are younger than the threshold", async () => { - const now = new Date("2026-02-16T00:00:00Z"); - vi.setSystemTime(now); - - const call = createCall(now.getTime() - 10_000); - const { manager, endCall } = createManager([call]); - const config = createConfig({ staleCallReaperSeconds: 60 }); - const server = new VoiceCallWebhookServer(config, manager, provider); - - try { - await server.start(); - await vi.advanceTimersByTimeAsync(30_000); - expect(endCall).not.toHaveBeenCalled(); - } finally { - await server.stop(); - } + const { endCall } = await runStaleCallReaperCase({ + callAgeMs: 10_000, + staleCallReaperSeconds: 60, + advanceMs: 30_000, + }); + expect(endCall).not.toHaveBeenCalled(); }); it("does not run when staleCallReaperSeconds is disabled", async () => { From 867dc6a1851b74a814dba89443ee86066c4181a4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:16:46 +0000 Subject: [PATCH 006/461] test: share twitch send success mocks --- extensions/twitch/src/send.test.ts | 33 ++++++++++++++++-------------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/extensions/twitch/src/send.test.ts b/extensions/twitch/src/send.test.ts index e7185b3f5fb..b45321229a4 100644 --- a/extensions/twitch/src/send.test.ts +++ b/extensions/twitch/src/send.test.ts @@ -55,7 +55,10 @@ describe("send", () => { installTwitchTestHooks(); describe("sendMessageTwitchInternal", () => { - it("should send a message successfully", async () => { + async function mockSuccessfulSend(params: { + messageId: string; + stripMarkdown?: (text: string) => string; + }) { const { getAccountConfig } = await import("./config.js"); const { getClientManager } = await import("./client-manager-registry.js"); const { stripMarkdownForTwitch } = await import("./utils/markdown.js"); @@ -64,10 +67,18 @@ describe("send", () => { vi.mocked(getClientManager).mockReturnValue({ sendMessage: vi.fn().mockResolvedValue({ ok: true, - messageId: "twitch-msg-123", + messageId: params.messageId, }), } as unknown as ReturnType); - vi.mocked(stripMarkdownForTwitch).mockImplementation((text) => text); + vi.mocked(stripMarkdownForTwitch).mockImplementation( + params.stripMarkdown ?? ((text) => text), + ); + + return { stripMarkdownForTwitch }; + } + + it("should send a message successfully", async () => { + await mockSuccessfulSend({ messageId: "twitch-msg-123" }); const result = await sendMessageTwitchInternal( "#testchannel", @@ -83,18 +94,10 @@ describe("send", () => { }); it("should strip markdown when enabled", async () => { - const { getAccountConfig } = await import("./config.js"); - const { getClientManager } = await import("./client-manager-registry.js"); - const { stripMarkdownForTwitch } = await import("./utils/markdown.js"); - - vi.mocked(getAccountConfig).mockReturnValue(mockAccount); - vi.mocked(getClientManager).mockReturnValue({ - sendMessage: vi.fn().mockResolvedValue({ - ok: true, - messageId: "twitch-msg-456", - }), - } as unknown as ReturnType); - vi.mocked(stripMarkdownForTwitch).mockImplementation((text) => text.replace(/\*\*/g, "")); + const { stripMarkdownForTwitch } = await mockSuccessfulSend({ + messageId: "twitch-msg-456", + stripMarkdown: (text) => text.replace(/\*\*/g, ""), + }); await sendMessageTwitchInternal( "#testchannel", From 6756e376f331fbbaec82363bcb9a69ea40630026 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:17:59 +0000 Subject: [PATCH 007/461] refactor: share bluebubbles response and tapback helpers --- extensions/bluebubbles/src/chat.ts | 22 ++++---- .../bluebubbles/src/monitor-normalize.ts | 51 +++++++++++-------- 2 files changed, 39 insertions(+), 34 deletions(-) diff --git a/extensions/bluebubbles/src/chat.ts b/extensions/bluebubbles/src/chat.ts index b63f09272f2..1670f276ba7 100644 --- a/extensions/bluebubbles/src/chat.ts +++ b/extensions/bluebubbles/src/chat.ts @@ -26,6 +26,14 @@ function assertPrivateApiEnabled(accountId: string, feature: string): void { } } +async function assertBlueBubblesActionOk(response: Response, action: string): Promise { + if (response.ok) { + return; + } + const errorText = await response.text().catch(() => ""); + throw new Error(`BlueBubbles ${action} failed (${response.status}): ${errorText || "unknown"}`); +} + function resolvePartIndex(partIndex: number | undefined): number { return typeof partIndex === "number" ? partIndex : 0; } @@ -55,12 +63,7 @@ async function sendBlueBubblesChatEndpointRequest(params: { { method: params.method }, params.opts.timeoutMs, ); - if (!res.ok) { - const errorText = await res.text().catch(() => ""); - throw new Error( - `BlueBubbles ${params.action} failed (${res.status}): ${errorText || "unknown"}`, - ); - } + await assertBlueBubblesActionOk(res, params.action); } async function sendPrivateApiJsonRequest(params: { @@ -86,12 +89,7 @@ async function sendPrivateApiJsonRequest(params: { } const res = await blueBubblesFetchWithTimeout(url, request, params.opts.timeoutMs); - if (!res.ok) { - const errorText = await res.text().catch(() => ""); - throw new Error( - `BlueBubbles ${params.action} failed (${res.status}): ${errorText || "unknown"}`, - ); - } + await assertBlueBubblesActionOk(res, params.action); } export async function markBlueBubblesChatRead( diff --git a/extensions/bluebubbles/src/monitor-normalize.ts b/extensions/bluebubbles/src/monitor-normalize.ts index 83454602d4c..085bd8923e1 100644 --- a/extensions/bluebubbles/src/monitor-normalize.ts +++ b/extensions/bluebubbles/src/monitor-normalize.ts @@ -582,6 +582,29 @@ export function parseTapbackText(params: { return null; } + const parseLeadingReactionAction = ( + prefix: "reacted" | "removed", + defaultAction: "added" | "removed", + ) => { + if (!lower.startsWith(prefix)) { + return null; + } + const emoji = extractFirstEmoji(trimmed) ?? params.emojiHint; + if (!emoji) { + return null; + } + const quotedText = extractQuotedTapbackText(trimmed); + if (params.requireQuoted && !quotedText) { + return null; + } + const fallback = trimmed.slice(prefix.length).trim(); + return { + emoji, + action: params.actionHint ?? defaultAction, + quotedText: quotedText ?? fallback, + }; + }; + for (const [pattern, { emoji, action }] of TAPBACK_TEXT_MAP) { if (lower.startsWith(pattern)) { // Extract quoted text if present (e.g., 'Loved "hello"' -> "hello") @@ -599,30 +622,14 @@ export function parseTapbackText(params: { } } - if (lower.startsWith("reacted")) { - const emoji = extractFirstEmoji(trimmed) ?? params.emojiHint; - if (!emoji) { - return null; - } - const quotedText = extractQuotedTapbackText(trimmed); - if (params.requireQuoted && !quotedText) { - return null; - } - const fallback = trimmed.slice("reacted".length).trim(); - return { emoji, action: params.actionHint ?? "added", quotedText: quotedText ?? fallback }; + const reacted = parseLeadingReactionAction("reacted", "added"); + if (reacted) { + return reacted; } - if (lower.startsWith("removed")) { - const emoji = extractFirstEmoji(trimmed) ?? params.emojiHint; - if (!emoji) { - return null; - } - const quotedText = extractQuotedTapbackText(trimmed); - if (params.requireQuoted && !quotedText) { - return null; - } - const fallback = trimmed.slice("removed".length).trim(); - return { emoji, action: params.actionHint ?? "removed", quotedText: quotedText ?? fallback }; + const removed = parseLeadingReactionAction("removed", "removed"); + if (removed) { + return removed; } return null; } From 143ae5a5b070c36a22bc8da1cf3bdd8c8b896072 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:18:54 +0000 Subject: [PATCH 008/461] refactor: share feishu chunked reply delivery --- extensions/feishu/src/reply-dispatcher.ts | 77 +++++++++++------------ 1 file changed, 37 insertions(+), 40 deletions(-) diff --git a/extensions/feishu/src/reply-dispatcher.ts b/extensions/feishu/src/reply-dispatcher.ts index 3bd1353825d..6f66ffffa58 100644 --- a/extensions/feishu/src/reply-dispatcher.ts +++ b/extensions/feishu/src/reply-dispatcher.ts @@ -224,6 +224,41 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP lastPartial = ""; }; + const sendChunkedTextReply = async (params: { + text: string; + useCard: boolean; + infoKind?: string; + }) => { + let first = true; + const chunkSource = params.useCard + ? params.text + : core.channel.text.convertMarkdownTables(params.text, tableMode); + for (const chunk of core.channel.text.chunkTextWithMode( + chunkSource, + textChunkLimit, + chunkMode, + )) { + const message = { + cfg, + to: chatId, + text: chunk, + replyToMessageId: sendReplyToMessageId, + replyInThread: effectiveReplyInThread, + mentions: first ? mentionTargets : undefined, + accountId, + }; + if (params.useCard) { + await sendMarkdownCardFeishu(message); + } else { + await sendMessageFeishu(message); + } + first = false; + } + if (params.infoKind === "final") { + deliveredFinalTexts.add(params.text); + } + }; + const { dispatcher, replyOptions, markDispatchIdle } = core.channel.reply.createReplyDispatcherWithTyping({ responsePrefix: prefixContext.responsePrefix, @@ -303,48 +338,10 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP return; } - let first = true; if (useCard) { - for (const chunk of core.channel.text.chunkTextWithMode( - text, - textChunkLimit, - chunkMode, - )) { - await sendMarkdownCardFeishu({ - cfg, - to: chatId, - text: chunk, - replyToMessageId: sendReplyToMessageId, - replyInThread: effectiveReplyInThread, - mentions: first ? mentionTargets : undefined, - accountId, - }); - first = false; - } - if (info?.kind === "final") { - deliveredFinalTexts.add(text); - } + await sendChunkedTextReply({ text, useCard: true, infoKind: info?.kind }); } else { - const converted = core.channel.text.convertMarkdownTables(text, tableMode); - for (const chunk of core.channel.text.chunkTextWithMode( - converted, - textChunkLimit, - chunkMode, - )) { - await sendMessageFeishu({ - cfg, - to: chatId, - text: chunk, - replyToMessageId: sendReplyToMessageId, - replyInThread: effectiveReplyInThread, - mentions: first ? mentionTargets : undefined, - accountId, - }); - first = false; - } - if (info?.kind === "final") { - deliveredFinalTexts.add(text); - } + await sendChunkedTextReply({ text, useCard: false, infoKind: info?.kind }); } } From 8ddb53134615eaeb841fa18a56f545bb35a731d5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:19:38 +0000 Subject: [PATCH 009/461] test: share discord auto presence assertions --- src/discord/monitor/auto-presence.test.ts | 56 +++++++++-------------- 1 file changed, 22 insertions(+), 34 deletions(-) diff --git a/src/discord/monitor/auto-presence.test.ts b/src/discord/monitor/auto-presence.test.ts index b5a83d5242d..d901a76d642 100644 --- a/src/discord/monitor/auto-presence.test.ts +++ b/src/discord/monitor/auto-presence.test.ts @@ -29,45 +29,33 @@ function createStore(params?: { }; } +function expectExhaustedDecision(params: { failureCounts: Record }) { + const now = Date.now(); + const decision = resolveDiscordAutoPresenceDecision({ + discordConfig: { + autoPresence: { + enabled: true, + exhaustedText: "token exhausted", + }, + }, + authStore: createStore({ cooldownUntil: now + 60_000, failureCounts: params.failureCounts }), + gatewayConnected: true, + now, + }); + + expect(decision).toBeTruthy(); + expect(decision?.state).toBe("exhausted"); + expect(decision?.presence.status).toBe("dnd"); + expect(decision?.presence.activities[0]?.state).toBe("token exhausted"); +} + describe("discord auto presence", () => { it("maps exhausted runtime signal to dnd", () => { - const now = Date.now(); - const decision = resolveDiscordAutoPresenceDecision({ - discordConfig: { - autoPresence: { - enabled: true, - exhaustedText: "token exhausted", - }, - }, - authStore: createStore({ cooldownUntil: now + 60_000, failureCounts: { rate_limit: 2 } }), - gatewayConnected: true, - now, - }); - - expect(decision).toBeTruthy(); - expect(decision?.state).toBe("exhausted"); - expect(decision?.presence.status).toBe("dnd"); - expect(decision?.presence.activities[0]?.state).toBe("token exhausted"); + expectExhaustedDecision({ failureCounts: { rate_limit: 2 } }); }); it("treats overloaded cooldown as exhausted", () => { - const now = Date.now(); - const decision = resolveDiscordAutoPresenceDecision({ - discordConfig: { - autoPresence: { - enabled: true, - exhaustedText: "token exhausted", - }, - }, - authStore: createStore({ cooldownUntil: now + 60_000, failureCounts: { overloaded: 2 } }), - gatewayConnected: true, - now, - }); - - expect(decision).toBeTruthy(); - expect(decision?.state).toBe("exhausted"); - expect(decision?.presence.status).toBe("dnd"); - expect(decision?.presence.activities[0]?.state).toBe("token exhausted"); + expectExhaustedDecision({ failureCounts: { overloaded: 2 } }); }); it("recovers from exhausted to online once a profile becomes usable", () => { From 89e0e80db36f4cdb76903b51eca0313296c88cce Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:22:31 +0000 Subject: [PATCH 010/461] test: share bluebubbles removal reaction helper --- extensions/bluebubbles/src/reactions.test.ts | 42 ++------------------ 1 file changed, 4 insertions(+), 38 deletions(-) diff --git a/extensions/bluebubbles/src/reactions.test.ts b/extensions/bluebubbles/src/reactions.test.ts index 419ccc81e45..0b55337b35c 100644 --- a/extensions/bluebubbles/src/reactions.test.ts +++ b/extensions/bluebubbles/src/reactions.test.ts @@ -19,7 +19,7 @@ describe("reactions", () => { }); describe("sendBlueBubblesReaction", () => { - async function expectRemovedReaction(emoji: string) { + async function expectRemovedReaction(emoji: string, expectedReaction = "-love") { mockFetch.mockResolvedValueOnce({ ok: true, text: () => Promise.resolve(""), @@ -37,7 +37,7 @@ describe("reactions", () => { }); const body = JSON.parse(mockFetch.mock.calls[0][1].body); - expect(body.reaction).toBe("-love"); + expect(body.reaction).toBe(expectedReaction); } it("throws when chatGuid is empty", async () => { @@ -327,45 +327,11 @@ describe("reactions", () => { describe("reaction removal aliases", () => { it("handles emoji-based removal", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(""), - }); - - await sendBlueBubblesReaction({ - chatGuid: "chat-123", - messageGuid: "msg-123", - emoji: "👍", - remove: true, - opts: { - serverUrl: "http://localhost:1234", - password: "test", - }, - }); - - const body = JSON.parse(mockFetch.mock.calls[0][1].body); - expect(body.reaction).toBe("-like"); + await expectRemovedReaction("👍", "-like"); }); it("handles text alias removal", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(""), - }); - - await sendBlueBubblesReaction({ - chatGuid: "chat-123", - messageGuid: "msg-123", - emoji: "haha", - remove: true, - opts: { - serverUrl: "http://localhost:1234", - password: "test", - }, - }); - - const body = JSON.parse(mockFetch.mock.calls[0][1].body); - expect(body.reaction).toBe("-laugh"); + await expectRemovedReaction("haha", "-laugh"); }); }); }); From eca22c0cc750593a94c15a7e38ede30214d37a4a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:23:29 +0000 Subject: [PATCH 011/461] test: share bluebubbles attachment fixtures --- .../bluebubbles/src/attachments.test.ts | 60 ++++++++----------- 1 file changed, 24 insertions(+), 36 deletions(-) diff --git a/extensions/bluebubbles/src/attachments.test.ts b/extensions/bluebubbles/src/attachments.test.ts index 8ef94cf08ae..704b907eb8b 100644 --- a/extensions/bluebubbles/src/attachments.test.ts +++ b/extensions/bluebubbles/src/attachments.test.ts @@ -82,6 +82,15 @@ describe("downloadBlueBubblesAttachment", () => { ).rejects.toThrow("too large"); } + function mockSuccessfulAttachmentDownload(buffer = new Uint8Array([1])) { + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: new Headers(), + arrayBuffer: () => Promise.resolve(buffer.buffer), + }); + return buffer; + } + it("throws when guid is missing", async () => { const attachment: BlueBubblesAttachment = {}; await expect( @@ -159,12 +168,7 @@ describe("downloadBlueBubblesAttachment", () => { }); it("encodes guid in URL", async () => { - const mockBuffer = new Uint8Array([1]); - mockFetch.mockResolvedValueOnce({ - ok: true, - headers: new Headers(), - arrayBuffer: () => Promise.resolve(mockBuffer.buffer), - }); + mockSuccessfulAttachmentDownload(); const attachment: BlueBubblesAttachment = { guid: "att/with/special chars" }; await downloadBlueBubblesAttachment(attachment, { @@ -244,12 +248,7 @@ describe("downloadBlueBubblesAttachment", () => { }); it("resolves credentials from config when opts not provided", async () => { - const mockBuffer = new Uint8Array([1]); - mockFetch.mockResolvedValueOnce({ - ok: true, - headers: new Headers(), - arrayBuffer: () => Promise.resolve(mockBuffer.buffer), - }); + mockSuccessfulAttachmentDownload(); const attachment: BlueBubblesAttachment = { guid: "att-config" }; const result = await downloadBlueBubblesAttachment(attachment, { @@ -270,12 +269,7 @@ describe("downloadBlueBubblesAttachment", () => { }); it("passes ssrfPolicy with allowPrivateNetwork when config enables it", async () => { - const mockBuffer = new Uint8Array([1]); - mockFetch.mockResolvedValueOnce({ - ok: true, - headers: new Headers(), - arrayBuffer: () => Promise.resolve(mockBuffer.buffer), - }); + mockSuccessfulAttachmentDownload(); const attachment: BlueBubblesAttachment = { guid: "att-ssrf" }; await downloadBlueBubblesAttachment(attachment, { @@ -295,12 +289,7 @@ describe("downloadBlueBubblesAttachment", () => { }); it("auto-allowlists serverUrl hostname when allowPrivateNetwork is not set", async () => { - const mockBuffer = new Uint8Array([1]); - mockFetch.mockResolvedValueOnce({ - ok: true, - headers: new Headers(), - arrayBuffer: () => Promise.resolve(mockBuffer.buffer), - }); + mockSuccessfulAttachmentDownload(); const attachment: BlueBubblesAttachment = { guid: "att-no-ssrf" }; await downloadBlueBubblesAttachment(attachment, { @@ -313,12 +302,7 @@ describe("downloadBlueBubblesAttachment", () => { }); it("auto-allowlists private IP serverUrl hostname when allowPrivateNetwork is not set", async () => { - const mockBuffer = new Uint8Array([1]); - mockFetch.mockResolvedValueOnce({ - ok: true, - headers: new Headers(), - arrayBuffer: () => Promise.resolve(mockBuffer.buffer), - }); + mockSuccessfulAttachmentDownload(); const attachment: BlueBubblesAttachment = { guid: "att-private-ip" }; await downloadBlueBubblesAttachment(attachment, { @@ -352,6 +336,14 @@ describe("sendBlueBubblesAttachment", () => { return Buffer.from(body).toString("utf8"); } + function expectVoiceAttachmentBody() { + const body = mockFetch.mock.calls[0][1]?.body as Uint8Array; + const bodyText = decodeBody(body); + expect(bodyText).toContain('name="isAudioMessage"'); + expect(bodyText).toContain("true"); + return bodyText; + } + it("marks voice memos when asVoice is true and mp3 is provided", async () => { mockFetch.mockResolvedValueOnce({ ok: true, @@ -367,10 +359,7 @@ describe("sendBlueBubblesAttachment", () => { opts: { serverUrl: "http://localhost:1234", password: "test" }, }); - const body = mockFetch.mock.calls[0][1]?.body as Uint8Array; - const bodyText = decodeBody(body); - expect(bodyText).toContain('name="isAudioMessage"'); - expect(bodyText).toContain("true"); + const bodyText = expectVoiceAttachmentBody(); expect(bodyText).toContain('filename="voice.mp3"'); }); @@ -389,8 +378,7 @@ describe("sendBlueBubblesAttachment", () => { opts: { serverUrl: "http://localhost:1234", password: "test" }, }); - const body = mockFetch.mock.calls[0][1]?.body as Uint8Array; - const bodyText = decodeBody(body); + const bodyText = expectVoiceAttachmentBody(); expect(bodyText).toContain('filename="voice.mp3"'); expect(bodyText).toContain('name="voice.mp3"'); }); From d3f46fa7fa90ff4f048b2787951b86ecffdc54c9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:24:14 +0000 Subject: [PATCH 012/461] test: share session transcript store setup --- src/config/sessions/sessions.test.ts | 49 +++++++++++----------------- 1 file changed, 19 insertions(+), 30 deletions(-) diff --git a/src/config/sessions/sessions.test.ts b/src/config/sessions/sessions.test.ts index 6866d6c10c1..2773b6d0fe7 100644 --- a/src/config/sessions/sessions.test.ts +++ b/src/config/sessions/sessions.test.ts @@ -283,18 +283,25 @@ describe("session store lock (Promise chain mutex)", () => { describe("appendAssistantMessageToSessionTranscript", () => { const fixture = useTempSessionsFixture("transcript-test-"); + const sessionId = "test-session-id"; + const sessionKey = "test-session"; + + function writeTranscriptStore() { + fs.writeFileSync( + fixture.storePath(), + JSON.stringify({ + [sessionKey]: { + sessionId, + chatType: "direct", + channel: "discord", + }, + }), + "utf-8", + ); + } it("creates transcript file and appends message for valid session", async () => { - const sessionId = "test-session-id"; - const sessionKey = "test-session"; - const store = { - [sessionKey]: { - sessionId, - chatType: "direct", - channel: "discord", - }, - }; - fs.writeFileSync(fixture.storePath(), JSON.stringify(store), "utf-8"); + writeTranscriptStore(); const result = await appendAssistantMessageToSessionTranscript({ sessionKey, @@ -326,16 +333,7 @@ describe("appendAssistantMessageToSessionTranscript", () => { }); it("does not append a duplicate delivery mirror for the same idempotency key", async () => { - const sessionId = "test-session-id"; - const sessionKey = "test-session"; - const store = { - [sessionKey]: { - sessionId, - chatType: "direct", - channel: "discord", - }, - }; - fs.writeFileSync(fixture.storePath(), JSON.stringify(store), "utf-8"); + writeTranscriptStore(); await appendAssistantMessageToSessionTranscript({ sessionKey, @@ -360,16 +358,7 @@ describe("appendAssistantMessageToSessionTranscript", () => { }); it("ignores malformed transcript lines when checking mirror idempotency", async () => { - const sessionId = "test-session-id"; - const sessionKey = "test-session"; - const store = { - [sessionKey]: { - sessionId, - chatType: "direct", - channel: "discord", - }, - }; - fs.writeFileSync(fixture.storePath(), JSON.stringify(store), "utf-8"); + writeTranscriptStore(); const sessionFile = resolveSessionTranscriptPathInDir(sessionId, fixture.sessionsDir()); fs.writeFileSync( From 7fd21b6bc6fb62d8ac4a5e02fe34b95e442abaec Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:25:17 +0000 Subject: [PATCH 013/461] refactor: share subagent followup reply helpers --- .../isolated-agent/subagent-followup.test.ts | 109 ++++++------------ src/cron/isolated-agent/subagent-followup.ts | 18 +-- 2 files changed, 46 insertions(+), 81 deletions(-) diff --git a/src/cron/isolated-agent/subagent-followup.test.ts b/src/cron/isolated-agent/subagent-followup.test.ts index c670e4c8c13..7861c75ff35 100644 --- a/src/cron/isolated-agent/subagent-followup.test.ts +++ b/src/cron/isolated-agent/subagent-followup.test.ts @@ -33,6 +33,29 @@ async function resolveAfterAdvancingTimers(promise: Promise, advanceMs = 1 return promise; } +function createDescendantRun(params?: { + runId?: string; + childSessionKey?: string; + task?: string; + cleanup?: "keep" | "delete"; + endedAt?: number; + frozenResultText?: string | null; +}) { + return { + runId: params?.runId ?? "run-1", + childSessionKey: params?.childSessionKey ?? "child-1", + requesterSessionKey: "test-session", + requesterDisplayKey: "test-session", + task: params?.task ?? "task-1", + cleanup: params?.cleanup ?? "keep", + createdAt: 1000, + endedAt: params?.endedAt ?? 2000, + ...(params?.frozenResultText === undefined + ? {} + : { frozenResultText: params.frozenResultText }), + }; +} + describe("isLikelyInterimCronMessage", () => { it("detects 'on it' as interim", () => { expect(isLikelyInterimCronMessage("on it")).toBe(true); @@ -85,18 +108,7 @@ describe("readDescendantSubagentFallbackReply", () => { }); it("reads reply from child session transcript", async () => { - vi.mocked(listDescendantRunsForRequester).mockReturnValue([ - { - runId: "run-1", - childSessionKey: "child-1", - requesterSessionKey: "test-session", - requesterDisplayKey: "test-session", - task: "task-1", - cleanup: "keep", - createdAt: 1000, - endedAt: 2000, - }, - ]); + vi.mocked(listDescendantRunsForRequester).mockReturnValue([createDescendantRun()]); vi.mocked(readLatestAssistantReply).mockResolvedValue("child output text"); const result = await readDescendantSubagentFallbackReply({ sessionKey: "test-session", @@ -107,17 +119,10 @@ describe("readDescendantSubagentFallbackReply", () => { it("falls back to frozenResultText when session transcript unavailable", async () => { vi.mocked(listDescendantRunsForRequester).mockReturnValue([ - { - runId: "run-1", - childSessionKey: "child-1", - requesterSessionKey: "test-session", - requesterDisplayKey: "test-session", - task: "task-1", + createDescendantRun({ cleanup: "delete", - createdAt: 1000, - endedAt: 2000, frozenResultText: "frozen child output", - }, + }), ]); vi.mocked(readLatestAssistantReply).mockResolvedValue(undefined); const result = await readDescendantSubagentFallbackReply({ @@ -129,17 +134,7 @@ describe("readDescendantSubagentFallbackReply", () => { it("prefers session transcript over frozenResultText", async () => { vi.mocked(listDescendantRunsForRequester).mockReturnValue([ - { - runId: "run-1", - childSessionKey: "child-1", - requesterSessionKey: "test-session", - requesterDisplayKey: "test-session", - task: "task-1", - cleanup: "keep", - createdAt: 1000, - endedAt: 2000, - frozenResultText: "frozen text", - }, + createDescendantRun({ frozenResultText: "frozen text" }), ]); vi.mocked(readLatestAssistantReply).mockResolvedValue("live transcript text"); const result = await readDescendantSubagentFallbackReply({ @@ -151,28 +146,14 @@ describe("readDescendantSubagentFallbackReply", () => { it("joins replies from multiple descendants", async () => { vi.mocked(listDescendantRunsForRequester).mockReturnValue([ - { - runId: "run-1", - childSessionKey: "child-1", - requesterSessionKey: "test-session", - requesterDisplayKey: "test-session", - task: "task-1", - cleanup: "keep", - createdAt: 1000, - endedAt: 2000, - frozenResultText: "first child output", - }, - { + createDescendantRun({ frozenResultText: "first child output" }), + createDescendantRun({ runId: "run-2", childSessionKey: "child-2", - requesterSessionKey: "test-session", - requesterDisplayKey: "test-session", task: "task-2", - cleanup: "keep", - createdAt: 1000, endedAt: 3000, frozenResultText: "second child output", - }, + }), ]); vi.mocked(readLatestAssistantReply).mockResolvedValue(undefined); const result = await readDescendantSubagentFallbackReply({ @@ -184,27 +165,14 @@ describe("readDescendantSubagentFallbackReply", () => { it("skips SILENT_REPLY_TOKEN descendants", async () => { vi.mocked(listDescendantRunsForRequester).mockReturnValue([ - { - runId: "run-1", - childSessionKey: "child-1", - requesterSessionKey: "test-session", - requesterDisplayKey: "test-session", - task: "task-1", - cleanup: "keep", - createdAt: 1000, - endedAt: 2000, - }, - { + createDescendantRun(), + createDescendantRun({ runId: "run-2", childSessionKey: "child-2", - requesterSessionKey: "test-session", - requesterDisplayKey: "test-session", task: "task-2", - cleanup: "keep", - createdAt: 1000, endedAt: 3000, frozenResultText: "useful output", - }, + }), ]); vi.mocked(readLatestAssistantReply).mockImplementation(async (params) => { if (params.sessionKey === "child-1") { @@ -221,17 +189,10 @@ describe("readDescendantSubagentFallbackReply", () => { it("returns undefined when frozenResultText is null", async () => { vi.mocked(listDescendantRunsForRequester).mockReturnValue([ - { - runId: "run-1", - childSessionKey: "child-1", - requesterSessionKey: "test-session", - requesterDisplayKey: "test-session", - task: "task-1", + createDescendantRun({ cleanup: "delete", - createdAt: 1000, - endedAt: 2000, frozenResultText: null, - }, + }), ]); vi.mocked(readLatestAssistantReply).mockResolvedValue(undefined); const result = await readDescendantSubagentFallbackReply({ diff --git a/src/cron/isolated-agent/subagent-followup.ts b/src/cron/isolated-agent/subagent-followup.ts index 9d6ec7e78ac..a337fe528b7 100644 --- a/src/cron/isolated-agent/subagent-followup.ts +++ b/src/cron/isolated-agent/subagent-followup.ts @@ -169,7 +169,7 @@ export async function waitForDescendantSubagentSummary(params: { // CRON_SUBAGENT_FINAL_REPLY_GRACE_MS) to capture that synthesis. const gracePeriodDeadline = Math.min(Date.now() + CRON_SUBAGENT_FINAL_REPLY_GRACE_MS, deadline); - while (Date.now() < gracePeriodDeadline) { + const resolveUsableLatestReply = async () => { const latest = (await readLatestAssistantReply({ sessionKey: params.sessionKey }))?.trim(); if ( latest && @@ -178,16 +178,20 @@ export async function waitForDescendantSubagentSummary(params: { ) { return latest; } + return undefined; + }; + + while (Date.now() < gracePeriodDeadline) { + const latest = await resolveUsableLatestReply(); + if (latest) { + return latest; + } await new Promise((resolve) => setTimeout(resolve, CRON_SUBAGENT_GRACE_POLL_MS)); } // Final read after grace period expires. - const latest = (await readLatestAssistantReply({ sessionKey: params.sessionKey }))?.trim(); - if ( - latest && - latest.toUpperCase() !== SILENT_REPLY_TOKEN.toUpperCase() && - (latest !== initialReply || !isLikelyInterimCronMessage(latest)) - ) { + const latest = await resolveUsableLatestReply(); + if (latest) { return latest; } From d7f9035e8043584b16133bafaf6859ab7df954e9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:26:12 +0000 Subject: [PATCH 014/461] test: share sandbox default assertions --- .../run.sandbox-config-preserved.test.ts | 59 +++++++++---------- 1 file changed, 27 insertions(+), 32 deletions(-) diff --git a/src/cron/isolated-agent/run.sandbox-config-preserved.test.ts b/src/cron/isolated-agent/run.sandbox-config-preserved.test.ts index 28f3d87cb09..edaee62daa6 100644 --- a/src/cron/isolated-agent/run.sandbox-config-preserved.test.ts +++ b/src/cron/isolated-agent/run.sandbox-config-preserved.test.ts @@ -54,6 +54,31 @@ function makeParams(overrides?: Record) { }; } +function expectDefaultSandboxPreserved( + runCfg: + | { + agents?: { defaults?: { sandbox?: unknown } }; + } + | undefined, +) { + expect(runCfg?.agents?.defaults?.sandbox).toEqual({ + mode: "all", + workspaceAccess: "rw", + docker: { + network: "none", + dangerouslyAllowContainerNamespaceJoin: true, + dangerouslyAllowExternalBindSources: true, + }, + browser: { + enabled: true, + autoStart: false, + }, + prune: { + maxAgeDays: 7, + }, + }); +} + describe("runCronIsolatedAgentTurn sandbox config preserved", () => { let previousFastTestEnv: string | undefined; @@ -79,22 +104,7 @@ describe("runCronIsolatedAgentTurn sandbox config preserved", () => { expect(runWithModelFallbackMock).toHaveBeenCalledTimes(1); const runCfg = runWithModelFallbackMock.mock.calls[0]?.[0]?.cfg; - expect(runCfg?.agents?.defaults?.sandbox).toEqual({ - mode: "all", - workspaceAccess: "rw", - docker: { - network: "none", - dangerouslyAllowContainerNamespaceJoin: true, - dangerouslyAllowExternalBindSources: true, - }, - browser: { - enabled: true, - autoStart: false, - }, - prune: { - maxAgeDays: 7, - }, - }); + expectDefaultSandboxPreserved(runCfg); }); it("keeps global sandbox defaults when agent override is partial", async () => { @@ -118,22 +128,7 @@ describe("runCronIsolatedAgentTurn sandbox config preserved", () => { const runCfg = runWithModelFallbackMock.mock.calls[0]?.[0]?.cfg; const resolvedSandbox = resolveSandboxConfigForAgent(runCfg, "specialist"); - expect(runCfg?.agents?.defaults?.sandbox).toEqual({ - mode: "all", - workspaceAccess: "rw", - docker: { - network: "none", - dangerouslyAllowContainerNamespaceJoin: true, - dangerouslyAllowExternalBindSources: true, - }, - browser: { - enabled: true, - autoStart: false, - }, - prune: { - maxAgeDays: 7, - }, - }); + expectDefaultSandboxPreserved(runCfg); expect(resolvedSandbox.mode).toBe("all"); expect(resolvedSandbox.workspaceAccess).toBe("rw"); expect(resolvedSandbox.docker).toMatchObject({ From 0574ac23d06ca2e93d09faccffc09e85e8baad2f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:27:52 +0000 Subject: [PATCH 015/461] test: share delivery target session helpers --- .../isolated-agent/delivery-target.test.ts | 59 +++++++++++-------- 1 file changed, 34 insertions(+), 25 deletions(-) diff --git a/src/cron/isolated-agent/delivery-target.test.ts b/src/cron/isolated-agent/delivery-target.test.ts index cfc492abe3b..df7d29d419f 100644 --- a/src/cron/isolated-agent/delivery-target.test.ts +++ b/src/cron/isolated-agent/delivery-target.test.ts @@ -64,6 +64,23 @@ function setMainSessionEntry(entry?: SessionStore[string]) { vi.mocked(loadSessionStore).mockReturnValue(store); } +function setLastSessionEntry(params: { + sessionId: string; + lastChannel: string; + lastTo: string; + lastThreadId?: string; + lastAccountId?: string; +}) { + setMainSessionEntry({ + sessionId: params.sessionId, + updatedAt: 1000, + lastChannel: params.lastChannel, + lastTo: params.lastTo, + ...(params.lastThreadId ? { lastThreadId: params.lastThreadId } : {}), + ...(params.lastAccountId ? { lastAccountId: params.lastAccountId } : {}), + }); +} + function setWhatsAppAllowFrom(allowFrom: string[]) { vi.mocked(resolveWhatsAppAccount).mockReturnValue({ allowFrom, @@ -86,11 +103,17 @@ async function resolveForAgent(params: { }); } +async function resolveLastTarget(cfg: OpenClawConfig) { + return resolveForAgent({ + cfg, + target: { channel: "last", to: undefined }, + }); +} + describe("resolveDeliveryTarget", () => { it("reroutes implicit whatsapp delivery to authorized allowFrom recipient", async () => { - setMainSessionEntry({ + setLastSessionEntry({ sessionId: "sess-w1", - updatedAt: 1000, lastChannel: "whatsapp", lastTo: "+15550000099", }); @@ -98,16 +121,15 @@ describe("resolveDeliveryTarget", () => { setStoredWhatsAppAllowFrom(["+15550000001"]); const cfg = makeCfg({ bindings: [] }); - const result = await resolveDeliveryTarget(cfg, AGENT_ID, { channel: "last", to: undefined }); + const result = await resolveLastTarget(cfg); expect(result.channel).toBe("whatsapp"); expect(result.to).toBe("+15550000001"); }); it("keeps explicit whatsapp target unchanged", async () => { - setMainSessionEntry({ + setLastSessionEntry({ sessionId: "sess-w2", - updatedAt: 1000, lastChannel: "whatsapp", lastTo: "+15550000099", }); @@ -220,9 +242,8 @@ describe("resolveDeliveryTarget", () => { }); it("drops session threadId when destination does not match the previous recipient", async () => { - setMainSessionEntry({ + setLastSessionEntry({ sessionId: "sess-2", - updatedAt: 1000, lastChannel: "telegram", lastTo: "999999", lastThreadId: "thread-1", @@ -233,9 +254,8 @@ describe("resolveDeliveryTarget", () => { }); it("keeps session threadId when destination matches the previous recipient", async () => { - setMainSessionEntry({ + setLastSessionEntry({ sessionId: "sess-3", - updatedAt: 1000, lastChannel: "telegram", lastTo: "123456", lastThreadId: "thread-2", @@ -248,10 +268,7 @@ describe("resolveDeliveryTarget", () => { it("uses single configured channel when neither explicit nor session channel exists", async () => { setMainSessionEntry(undefined); - const result = await resolveForAgent({ - cfg: makeCfg({ bindings: [] }), - target: { channel: "last", to: undefined }, - }); + const result = await resolveLastTarget(makeCfg({ bindings: [] })); expect(result.channel).toBe("telegram"); expect(result.ok).toBe(false); if (result.ok) { @@ -268,10 +285,7 @@ describe("resolveDeliveryTarget", () => { new Error("Channel is required when multiple channels are configured: telegram, slack"), ); - const result = await resolveForAgent({ - cfg: makeCfg({ bindings: [] }), - target: { channel: "last", to: undefined }, - }); + const result = await resolveLastTarget(makeCfg({ bindings: [] })); expect(result.channel).toBeUndefined(); expect(result.to).toBeUndefined(); expect(result.ok).toBe(false); @@ -308,17 +322,13 @@ describe("resolveDeliveryTarget", () => { }); it("uses main session channel when channel=last and session route exists", async () => { - setMainSessionEntry({ + setLastSessionEntry({ sessionId: "sess-4", - updatedAt: 1000, lastChannel: "telegram", lastTo: "987654", }); - const result = await resolveForAgent({ - cfg: makeCfg({ bindings: [] }), - target: { channel: "last", to: undefined }, - }); + const result = await resolveLastTarget(makeCfg({ bindings: [] })); expect(result.channel).toBe("telegram"); expect(result.to).toBe("987654"); @@ -326,9 +336,8 @@ describe("resolveDeliveryTarget", () => { }); it("explicit delivery.accountId overrides session-derived accountId", async () => { - setMainSessionEntry({ + setLastSessionEntry({ sessionId: "sess-5", - updatedAt: 1000, lastChannel: "telegram", lastTo: "chat-999", lastAccountId: "default", From e64cc907ffb53e4a695e64f4a4e136b89afd0ee6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:30:21 +0000 Subject: [PATCH 016/461] test: share cron run fallback helpers --- .../isolated-agent/run.interim-retry.test.ts | 14 ++----- .../run.message-tool-policy.test.ts | 38 ++++++++----------- src/cron/isolated-agent/run.test-harness.ts | 7 ++++ 3 files changed, 27 insertions(+), 32 deletions(-) diff --git a/src/cron/isolated-agent/run.interim-retry.test.ts b/src/cron/isolated-agent/run.interim-retry.test.ts index 90d663ed020..6f01a2e9232 100644 --- a/src/cron/isolated-agent/run.interim-retry.test.ts +++ b/src/cron/isolated-agent/run.interim-retry.test.ts @@ -7,6 +7,7 @@ import { countActiveDescendantRunsMock, listDescendantRunsForRequesterMock, loadRunCronIsolatedAgentTurn, + mockRunCronFallbackPassthrough, pickLastNonEmptyTextFromPayloadsMock, runEmbeddedPiAgentMock, runWithModelFallbackMock, @@ -17,13 +18,6 @@ const runCronIsolatedAgentTurn = await loadRunCronIsolatedAgentTurn(); describe("runCronIsolatedAgentTurn — interim ack retry", () => { setupRunCronIsolatedAgentTurnSuite(); - const mockFallbackPassthrough = () => { - runWithModelFallbackMock.mockImplementation(async ({ provider, model, run }) => { - const result = await run(provider, model); - return { result, provider, model, attempts: [] }; - }); - }; - const runTurnAndExpectOk = async (expectedFallbackCalls: number, expectedAgentCalls: number) => { const result = await runCronIsolatedAgentTurn(makeIsolatedAgentTurnParams()); expect(result.status).toBe("ok"); @@ -62,7 +56,7 @@ describe("runCronIsolatedAgentTurn — interim ack retry", () => { meta: { agentMeta: { usage: { input: 10, output: 20 } } }, }); - mockFallbackPassthrough(); + mockRunCronFallbackPassthrough(); await runTurnAndExpectOk(2, 2); expect(runEmbeddedPiAgentMock.mock.calls[1]?.[0]?.prompt).toContain( "previous response was only an acknowledgement", @@ -76,7 +70,7 @@ describe("runCronIsolatedAgentTurn — interim ack retry", () => { meta: { agentMeta: { usage: { input: 10, output: 20 } } }, }); - mockFallbackPassthrough(); + mockRunCronFallbackPassthrough(); await runTurnAndExpectOk(1, 1); }); @@ -93,7 +87,7 @@ describe("runCronIsolatedAgentTurn — interim ack retry", () => { ]); countActiveDescendantRunsMock.mockReturnValue(0); - mockFallbackPassthrough(); + mockRunCronFallbackPassthrough(); await runTurnAndExpectOk(1, 1); }); }); diff --git a/src/cron/isolated-agent/run.message-tool-policy.test.ts b/src/cron/isolated-agent/run.message-tool-policy.test.ts index 2d576900b9d..a92b19f5337 100644 --- a/src/cron/isolated-agent/run.message-tool-policy.test.ts +++ b/src/cron/isolated-agent/run.message-tool-policy.test.ts @@ -2,12 +2,12 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { clearFastTestEnv, loadRunCronIsolatedAgentTurn, + mockRunCronFallbackPassthrough, resetRunCronIsolatedAgentTurnHarness, resolveCronDeliveryPlanMock, resolveDeliveryTargetMock, restoreFastTestEnv, runEmbeddedPiAgentMock, - runWithModelFallbackMock, } from "./run.test-harness.js"; const runCronIsolatedAgentTurn = await loadRunCronIsolatedAgentTurn(); @@ -32,12 +32,18 @@ function makeParams() { describe("runCronIsolatedAgentTurn message tool policy", () => { let previousFastTestEnv: string | undefined; - const mockFallbackPassthrough = () => { - runWithModelFallbackMock.mockImplementation(async ({ provider, model, run }) => { - const result = await run(provider, model); - return { result, provider, model, attempts: [] }; - }); - }; + async function expectMessageToolDisabledForPlan(plan: { + requested: boolean; + mode: "none" | "announce"; + channel?: string; + to?: string; + }) { + mockRunCronFallbackPassthrough(); + resolveCronDeliveryPlanMock.mockReturnValue(plan); + await runCronIsolatedAgentTurn(makeParams()); + expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); + expect(runEmbeddedPiAgentMock.mock.calls[0]?.[0]?.disableMessageTool).toBe(true); + } beforeEach(() => { previousFastTestEnv = clearFastTestEnv(); @@ -56,35 +62,23 @@ describe("runCronIsolatedAgentTurn message tool policy", () => { }); it('disables the message tool when delivery.mode is "none"', async () => { - mockFallbackPassthrough(); - resolveCronDeliveryPlanMock.mockReturnValue({ + await expectMessageToolDisabledForPlan({ requested: false, mode: "none", }); - - await runCronIsolatedAgentTurn(makeParams()); - - expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); - expect(runEmbeddedPiAgentMock.mock.calls[0]?.[0]?.disableMessageTool).toBe(true); }); it("disables the message tool when cron delivery is active", async () => { - mockFallbackPassthrough(); - resolveCronDeliveryPlanMock.mockReturnValue({ + await expectMessageToolDisabledForPlan({ requested: true, mode: "announce", channel: "telegram", to: "123", }); - - await runCronIsolatedAgentTurn(makeParams()); - - expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); - expect(runEmbeddedPiAgentMock.mock.calls[0]?.[0]?.disableMessageTool).toBe(true); }); it("keeps the message tool enabled for shared callers when delivery is not requested", async () => { - mockFallbackPassthrough(); + mockRunCronFallbackPassthrough(); resolveCronDeliveryPlanMock.mockReturnValue({ requested: false, mode: "none", diff --git a/src/cron/isolated-agent/run.test-harness.ts b/src/cron/isolated-agent/run.test-harness.ts index 74b5eed43e1..81e4c8b902b 100644 --- a/src/cron/isolated-agent/run.test-harness.ts +++ b/src/cron/isolated-agent/run.test-harness.ts @@ -341,6 +341,13 @@ function makeDefaultEmbeddedResult() { }; } +export function mockRunCronFallbackPassthrough(): void { + runWithModelFallbackMock.mockImplementation(async ({ provider, model, run }) => { + const result = await run(provider, model); + return { result, provider, model, attempts: [] }; + }); +} + export function resetRunCronIsolatedAgentTurnHarness(): void { vi.clearAllMocks(); From 6ecc18463753ba6b8fbd9569a5add52658252629 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:32:22 +0000 Subject: [PATCH 017/461] refactor: share googlechat api fetch handling --- extensions/googlechat/src/api.test.ts | 41 +++-- extensions/googlechat/src/api.ts | 234 +++++++++++++------------- 2 files changed, 136 insertions(+), 139 deletions(-) diff --git a/extensions/googlechat/src/api.test.ts b/extensions/googlechat/src/api.test.ts index fc011268ec2..81312d39820 100644 --- a/extensions/googlechat/src/api.test.ts +++ b/extensions/googlechat/src/api.test.ts @@ -13,6 +13,21 @@ const account = { config: {}, } as ResolvedGoogleChatAccount; +function stubSuccessfulSend(name: string) { + const fetchMock = vi + .fn() + .mockResolvedValue(new Response(JSON.stringify({ name }), { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +} + +async function expectDownloadToRejectForResponse(response: Response) { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(response)); + await expect( + downloadGoogleChatMedia({ account, resourceName: "media/123", maxBytes: 10 }), + ).rejects.toThrow(/max bytes/i); +} + describe("downloadGoogleChatMedia", () => { afterEach(() => { vi.unstubAllGlobals(); @@ -29,11 +44,7 @@ describe("downloadGoogleChatMedia", () => { status: 200, headers: { "content-length": "50", "content-type": "application/octet-stream" }, }); - vi.stubGlobal("fetch", vi.fn().mockResolvedValue(response)); - - await expect( - downloadGoogleChatMedia({ account, resourceName: "media/123", maxBytes: 10 }), - ).rejects.toThrow(/max bytes/i); + await expectDownloadToRejectForResponse(response); }); it("rejects when streamed payload exceeds max bytes", async () => { @@ -52,11 +63,7 @@ describe("downloadGoogleChatMedia", () => { status: 200, headers: { "content-type": "application/octet-stream" }, }); - vi.stubGlobal("fetch", vi.fn().mockResolvedValue(response)); - - await expect( - downloadGoogleChatMedia({ account, resourceName: "media/123", maxBytes: 10 }), - ).rejects.toThrow(/max bytes/i); + await expectDownloadToRejectForResponse(response); }); }); @@ -66,12 +73,7 @@ describe("sendGoogleChatMessage", () => { }); it("adds messageReplyOption when sending to an existing thread", async () => { - const fetchMock = vi - .fn() - .mockResolvedValue( - new Response(JSON.stringify({ name: "spaces/AAA/messages/123" }), { status: 200 }), - ); - vi.stubGlobal("fetch", fetchMock); + const fetchMock = stubSuccessfulSend("spaces/AAA/messages/123"); await sendGoogleChatMessage({ account, @@ -89,12 +91,7 @@ describe("sendGoogleChatMessage", () => { }); it("does not set messageReplyOption for non-thread sends", async () => { - const fetchMock = vi - .fn() - .mockResolvedValue( - new Response(JSON.stringify({ name: "spaces/AAA/messages/124" }), { status: 200 }), - ); - vi.stubGlobal("fetch", fetchMock); + const fetchMock = stubSuccessfulSend("spaces/AAA/messages/124"); await sendGoogleChatMessage({ account, diff --git a/extensions/googlechat/src/api.ts b/extensions/googlechat/src/api.ts index 7c4f26b8db9..d9c7b666ff0 100644 --- a/extensions/googlechat/src/api.ts +++ b/extensions/googlechat/src/api.ts @@ -14,70 +14,24 @@ const headersToObject = (headers?: HeadersInit): Record => ? Object.fromEntries(headers) : headers || {}; -async function fetchJson( - account: ResolvedGoogleChatAccount, - url: string, - init: RequestInit, -): Promise { - const token = await getGoogleChatAccessToken(account); - const { response: res, release } = await fetchWithSsrFGuard({ +async function withGoogleChatResponse(params: { + account: ResolvedGoogleChatAccount; + url: string; + init?: RequestInit; + auditContext: string; + errorPrefix?: string; + handleResponse: (response: Response) => Promise; +}): Promise { + const { + account, url, - init: { - ...init, - headers: { - ...headersToObject(init.headers), - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - }, - }, - auditContext: "googlechat.api.json", - }); - try { - if (!res.ok) { - const text = await res.text().catch(() => ""); - throw new Error(`Google Chat API ${res.status}: ${text || res.statusText}`); - } - return (await res.json()) as T; - } finally { - await release(); - } -} - -async function fetchOk( - account: ResolvedGoogleChatAccount, - url: string, - init: RequestInit, -): Promise { + init, + auditContext, + errorPrefix = "Google Chat API", + handleResponse, + } = params; const token = await getGoogleChatAccessToken(account); - const { response: res, release } = await fetchWithSsrFGuard({ - url, - init: { - ...init, - headers: { - ...headersToObject(init.headers), - Authorization: `Bearer ${token}`, - }, - }, - auditContext: "googlechat.api.ok", - }); - try { - if (!res.ok) { - const text = await res.text().catch(() => ""); - throw new Error(`Google Chat API ${res.status}: ${text || res.statusText}`); - } - } finally { - await release(); - } -} - -async function fetchBuffer( - account: ResolvedGoogleChatAccount, - url: string, - init?: RequestInit, - options?: { maxBytes?: number }, -): Promise<{ buffer: Buffer; contentType?: string }> { - const token = await getGoogleChatAccessToken(account); - const { response: res, release } = await fetchWithSsrFGuard({ + const { response, release } = await fetchWithSsrFGuard({ url, init: { ...init, @@ -86,52 +40,103 @@ async function fetchBuffer( Authorization: `Bearer ${token}`, }, }, - auditContext: "googlechat.api.buffer", + auditContext, }); try { - if (!res.ok) { - const text = await res.text().catch(() => ""); - throw new Error(`Google Chat API ${res.status}: ${text || res.statusText}`); + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new Error(`${errorPrefix} ${response.status}: ${text || response.statusText}`); } - const maxBytes = options?.maxBytes; - const lengthHeader = res.headers.get("content-length"); - if (maxBytes && lengthHeader) { - const length = Number(lengthHeader); - if (Number.isFinite(length) && length > maxBytes) { - throw new Error(`Google Chat media exceeds max bytes (${maxBytes})`); - } - } - if (!maxBytes || !res.body) { - const buffer = Buffer.from(await res.arrayBuffer()); - const contentType = res.headers.get("content-type") ?? undefined; - return { buffer, contentType }; - } - const reader = res.body.getReader(); - const chunks: Buffer[] = []; - let total = 0; - while (true) { - const { done, value } = await reader.read(); - if (done) { - break; - } - if (!value) { - continue; - } - total += value.length; - if (total > maxBytes) { - await reader.cancel(); - throw new Error(`Google Chat media exceeds max bytes (${maxBytes})`); - } - chunks.push(Buffer.from(value)); - } - const buffer = Buffer.concat(chunks, total); - const contentType = res.headers.get("content-type") ?? undefined; - return { buffer, contentType }; + return await handleResponse(response); } finally { await release(); } } +async function fetchJson( + account: ResolvedGoogleChatAccount, + url: string, + init: RequestInit, +): Promise { + return await withGoogleChatResponse({ + account, + url, + init: { + ...init, + headers: { + ...headersToObject(init.headers), + "Content-Type": "application/json", + }, + }, + auditContext: "googlechat.api.json", + handleResponse: async (response) => (await response.json()) as T, + }); +} + +async function fetchOk( + account: ResolvedGoogleChatAccount, + url: string, + init: RequestInit, +): Promise { + await withGoogleChatResponse({ + account, + url, + init, + auditContext: "googlechat.api.ok", + handleResponse: async () => undefined, + }); +} + +async function fetchBuffer( + account: ResolvedGoogleChatAccount, + url: string, + init?: RequestInit, + options?: { maxBytes?: number }, +): Promise<{ buffer: Buffer; contentType?: string }> { + return await withGoogleChatResponse({ + account, + url, + init, + auditContext: "googlechat.api.buffer", + handleResponse: async (res) => { + const maxBytes = options?.maxBytes; + const lengthHeader = res.headers.get("content-length"); + if (maxBytes && lengthHeader) { + const length = Number(lengthHeader); + if (Number.isFinite(length) && length > maxBytes) { + throw new Error(`Google Chat media exceeds max bytes (${maxBytes})`); + } + } + if (!maxBytes || !res.body) { + const buffer = Buffer.from(await res.arrayBuffer()); + const contentType = res.headers.get("content-type") ?? undefined; + return { buffer, contentType }; + } + const reader = res.body.getReader(); + const chunks: Buffer[] = []; + let total = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + if (!value) { + continue; + } + total += value.length; + if (total > maxBytes) { + await reader.cancel(); + throw new Error(`Google Chat media exceeds max bytes (${maxBytes})`); + } + chunks.push(Buffer.from(value)); + } + const buffer = Buffer.concat(chunks, total); + const contentType = res.headers.get("content-type") ?? undefined; + return { buffer, contentType }; + }, + }); +} + export async function sendGoogleChatMessage(params: { account: ResolvedGoogleChatAccount; space: string; @@ -208,34 +213,29 @@ export async function uploadGoogleChatAttachment(params: { Buffer.from(footer, "utf8"), ]); - const token = await getGoogleChatAccessToken(account); const url = `${CHAT_UPLOAD_BASE}/${space}/attachments:upload?uploadType=multipart`; - const { response: res, release } = await fetchWithSsrFGuard({ + const payload = await withGoogleChatResponse<{ + attachmentDataRef?: { attachmentUploadToken?: string }; + }>({ + account, url, init: { method: "POST", headers: { - Authorization: `Bearer ${token}`, "Content-Type": `multipart/related; boundary=${boundary}`, }, body, }, auditContext: "googlechat.upload", + errorPrefix: "Google Chat upload", + handleResponse: async (response) => + (await response.json()) as { + attachmentDataRef?: { attachmentUploadToken?: string }; + }, }); - try { - if (!res.ok) { - const text = await res.text().catch(() => ""); - throw new Error(`Google Chat upload ${res.status}: ${text || res.statusText}`); - } - const payload = (await res.json()) as { - attachmentDataRef?: { attachmentUploadToken?: string }; - }; - return { - attachmentUploadToken: payload.attachmentDataRef?.attachmentUploadToken, - }; - } finally { - await release(); - } + return { + attachmentUploadToken: payload.attachmentDataRef?.attachmentUploadToken, + }; } export async function downloadGoogleChatMedia(params: { From 4674fbf92327f2723b21272c2c041772958754b9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:33:28 +0000 Subject: [PATCH 018/461] refactor: share handshake auth helper builders --- .../handshake-auth-helpers.test.ts | 29 +++++----- .../ws-connection/handshake-auth-helpers.ts | 53 +++++++++++-------- 2 files changed, 45 insertions(+), 37 deletions(-) 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 68ec4e1a153..8b7b7e521fd 100644 --- a/src/gateway/server/ws-connection/handshake-auth-helpers.test.ts +++ b/src/gateway/server/ws-connection/handshake-auth-helpers.test.ts @@ -10,24 +10,21 @@ import { shouldSkipBackendSelfPairing, } from "./handshake-auth-helpers.js"; +function createRateLimiter(): AuthRateLimiter { + return { + check: () => ({ allowed: true, remaining: 1, retryAfterMs: 0 }), + reset: () => {}, + recordFailure: () => {}, + size: () => 0, + prune: () => {}, + dispose: () => {}, + }; +} + describe("handshake auth helpers", () => { it("pins browser-origin loopback clients to the synthetic rate-limit ip", () => { - const rateLimiter: AuthRateLimiter = { - check: () => ({ allowed: true, remaining: 1, retryAfterMs: 0 }), - reset: () => {}, - recordFailure: () => {}, - size: () => 0, - prune: () => {}, - dispose: () => {}, - }; - const browserRateLimiter: AuthRateLimiter = { - check: () => ({ allowed: true, remaining: 1, retryAfterMs: 0 }), - reset: () => {}, - recordFailure: () => {}, - size: () => 0, - prune: () => {}, - dispose: () => {}, - }; + const rateLimiter = createRateLimiter(); + const browserRateLimiter = createRateLimiter(); const resolved = resolveHandshakeBrowserSecurityContext({ requestOrigin: "https://app.example", clientIp: "127.0.0.1", diff --git a/src/gateway/server/ws-connection/handshake-auth-helpers.ts b/src/gateway/server/ws-connection/handshake-auth-helpers.ts index cce5b979b3e..8529cf55082 100644 --- a/src/gateway/server/ws-connection/handshake-auth-helpers.ts +++ b/src/gateway/server/ws-connection/handshake-auth-helpers.ts @@ -91,6 +91,23 @@ function resolveSignatureToken(connectParams: ConnectParams): string | null { ); } +function buildUnauthorizedHandshakeContext(params: { + authProvided: AuthProvidedKind; + canRetryWithDeviceToken: boolean; + recommendedNextStep: + | "retry_with_device_token" + | "update_auth_configuration" + | "update_auth_credentials" + | "wait_then_retry" + | "review_auth_configuration"; +}) { + return { + authProvided: params.authProvided, + canRetryWithDeviceToken: params.canRetryWithDeviceToken, + recommendedNextStep: params.recommendedNextStep, + }; +} + export function resolveDeviceSignaturePayloadVersion(params: { device: { id: string; @@ -104,7 +121,7 @@ export function resolveDeviceSignaturePayloadVersion(params: { nonce: string; }): "v3" | "v2" | null { const signatureToken = resolveSignatureToken(params.connectParams); - const payloadV3 = buildDeviceAuthPayloadV3({ + const basePayload = { deviceId: params.device.id, clientId: params.connectParams.client.id, clientMode: params.connectParams.client.mode, @@ -113,6 +130,9 @@ export function resolveDeviceSignaturePayloadVersion(params: { signedAtMs: params.signedAtMs, token: signatureToken, nonce: params.nonce, + }; + const payloadV3 = buildDeviceAuthPayloadV3({ + ...basePayload, platform: params.connectParams.client.platform, deviceFamily: params.connectParams.client.deviceFamily, }); @@ -120,16 +140,7 @@ export function resolveDeviceSignaturePayloadVersion(params: { return "v3"; } - const payloadV2 = buildDeviceAuthPayload({ - deviceId: params.device.id, - clientId: params.connectParams.client.id, - clientMode: params.connectParams.client.mode, - role: params.role, - scopes: params.scopes, - signedAtMs: params.signedAtMs, - token: signatureToken, - nonce: params.nonce, - }); + const payloadV2 = buildDeviceAuthPayload(basePayload); if (verifyDeviceSignature(params.device.publicKey, payloadV2, params.device.signature)) { return "v2"; } @@ -171,41 +182,41 @@ export function resolveUnauthorizedHandshakeContext(params: { authProvided === "token" && !params.connectAuth?.deviceToken; if (canRetryWithDeviceToken) { - return { + return buildUnauthorizedHandshakeContext({ authProvided, canRetryWithDeviceToken, recommendedNextStep: "retry_with_device_token", - }; + }); } switch (params.failedAuth.reason) { case "token_missing": case "token_missing_config": case "password_missing": case "password_missing_config": - return { + return buildUnauthorizedHandshakeContext({ authProvided, canRetryWithDeviceToken, recommendedNextStep: "update_auth_configuration", - }; + }); case "token_mismatch": case "password_mismatch": case "device_token_mismatch": - return { + return buildUnauthorizedHandshakeContext({ authProvided, canRetryWithDeviceToken, recommendedNextStep: "update_auth_credentials", - }; + }); case "rate_limited": - return { + return buildUnauthorizedHandshakeContext({ authProvided, canRetryWithDeviceToken, recommendedNextStep: "wait_then_retry", - }; + }); default: - return { + return buildUnauthorizedHandshakeContext({ authProvided, canRetryWithDeviceToken, recommendedNextStep: "review_auth_configuration", - }; + }); } } From 0159269a513ec8031ecc5d69bce2ab40a73ef2c9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:34:17 +0000 Subject: [PATCH 019/461] refactor: share openclaw tool sandbox config --- src/agents/openclaw-tools.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/agents/openclaw-tools.ts b/src/agents/openclaw-tools.ts index ea12b5121d8..25b5cae0f59 100644 --- a/src/agents/openclaw-tools.ts +++ b/src/agents/openclaw-tools.ts @@ -87,15 +87,16 @@ export function createOpenClawTools( options?.spawnWorkspaceDir ?? options?.workspaceDir, ); const runtimeWebTools = getActiveRuntimeWebToolsMetadata(); + const sandbox = + options?.sandboxRoot && options?.sandboxFsBridge + ? { root: options.sandboxRoot, bridge: options.sandboxFsBridge } + : undefined; const imageTool = options?.agentDir?.trim() ? createImageTool({ config: options?.config, agentDir: options.agentDir, workspaceDir, - sandbox: - options?.sandboxRoot && options?.sandboxFsBridge - ? { root: options.sandboxRoot, bridge: options.sandboxFsBridge } - : undefined, + sandbox, fsPolicy: options?.fsPolicy, modelHasVision: options?.modelHasVision, }) @@ -105,10 +106,7 @@ export function createOpenClawTools( config: options?.config, agentDir: options.agentDir, workspaceDir, - sandbox: - options?.sandboxRoot && options?.sandboxFsBridge - ? { root: options.sandboxRoot, bridge: options.sandboxFsBridge } - : undefined, + sandbox, fsPolicy: options?.fsPolicy, }) : null; From de9ea76b6c84a2b9dedd02c7d50e02b3037eccbb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:35:21 +0000 Subject: [PATCH 020/461] refactor: dedupe feishu send reply fallback helpers --- .../feishu/src/send.reply-fallback.test.ts | 90 ++++++++++--------- extensions/feishu/src/send.ts | 55 ++++++------ 2 files changed, 75 insertions(+), 70 deletions(-) diff --git a/extensions/feishu/src/send.reply-fallback.test.ts b/extensions/feishu/src/send.reply-fallback.test.ts index 75dda353bbe..610ded167fd 100644 --- a/extensions/feishu/src/send.reply-fallback.test.ts +++ b/extensions/feishu/src/send.reply-fallback.test.ts @@ -25,6 +25,16 @@ describe("Feishu reply fallback for withdrawn/deleted targets", () => { const replyMock = vi.fn(); const createMock = vi.fn(); + async function expectFallbackResult( + send: () => Promise<{ messageId?: string }>, + expectedMessageId: string, + ) { + const result = await send(); + expect(replyMock).toHaveBeenCalledTimes(1); + expect(createMock).toHaveBeenCalledTimes(1); + expect(result.messageId).toBe(expectedMessageId); + } + beforeEach(() => { vi.clearAllMocks(); resolveFeishuSendTargetMock.mockReturnValue({ @@ -51,16 +61,16 @@ describe("Feishu reply fallback for withdrawn/deleted targets", () => { data: { message_id: "om_new" }, }); - const result = await sendMessageFeishu({ - cfg: {} as never, - to: "user:ou_target", - text: "hello", - replyToMessageId: "om_parent", - }); - - expect(replyMock).toHaveBeenCalledTimes(1); - expect(createMock).toHaveBeenCalledTimes(1); - expect(result.messageId).toBe("om_new"); + await expectFallbackResult( + () => + sendMessageFeishu({ + cfg: {} as never, + to: "user:ou_target", + text: "hello", + replyToMessageId: "om_parent", + }), + "om_new", + ); }); it("falls back to create for withdrawn card replies", async () => { @@ -73,16 +83,16 @@ describe("Feishu reply fallback for withdrawn/deleted targets", () => { data: { message_id: "om_card_new" }, }); - const result = await sendCardFeishu({ - cfg: {} as never, - to: "user:ou_target", - card: { schema: "2.0" }, - replyToMessageId: "om_parent", - }); - - expect(replyMock).toHaveBeenCalledTimes(1); - expect(createMock).toHaveBeenCalledTimes(1); - expect(result.messageId).toBe("om_card_new"); + await expectFallbackResult( + () => + sendCardFeishu({ + cfg: {} as never, + to: "user:ou_target", + card: { schema: "2.0" }, + replyToMessageId: "om_parent", + }), + "om_card_new", + ); }); it("still throws for non-withdrawn reply failures", async () => { @@ -111,16 +121,16 @@ describe("Feishu reply fallback for withdrawn/deleted targets", () => { data: { message_id: "om_thrown_fallback" }, }); - const result = await sendMessageFeishu({ - cfg: {} as never, - to: "user:ou_target", - text: "hello", - replyToMessageId: "om_parent", - }); - - expect(replyMock).toHaveBeenCalledTimes(1); - expect(createMock).toHaveBeenCalledTimes(1); - expect(result.messageId).toBe("om_thrown_fallback"); + await expectFallbackResult( + () => + sendMessageFeishu({ + cfg: {} as never, + to: "user:ou_target", + text: "hello", + replyToMessageId: "om_parent", + }), + "om_thrown_fallback", + ); }); it("falls back to create when card reply throws a not-found AxiosError", async () => { @@ -133,16 +143,16 @@ describe("Feishu reply fallback for withdrawn/deleted targets", () => { data: { message_id: "om_axios_fallback" }, }); - const result = await sendCardFeishu({ - cfg: {} as never, - to: "user:ou_target", - card: { schema: "2.0" }, - replyToMessageId: "om_parent", - }); - - expect(replyMock).toHaveBeenCalledTimes(1); - expect(createMock).toHaveBeenCalledTimes(1); - expect(result.messageId).toBe("om_axios_fallback"); + await expectFallbackResult( + () => + sendCardFeishu({ + cfg: {} as never, + to: "user:ou_target", + card: { schema: "2.0" }, + replyToMessageId: "om_parent", + }), + "om_axios_fallback", + ); }); it("re-throws non-withdrawn thrown errors for text messages", async () => { diff --git a/extensions/feishu/src/send.ts b/extensions/feishu/src/send.ts index 5bfa836e0a6..5692edd32ff 100644 --- a/extensions/feishu/src/send.ts +++ b/extensions/feishu/src/send.ts @@ -55,6 +55,30 @@ type FeishuCreateMessageClient = { }; }; +type FeishuMessageSender = { + id?: string; + id_type?: string; + sender_type?: string; +}; + +type FeishuMessageGetItem = { + message_id?: string; + chat_id?: string; + chat_type?: FeishuChatType; + msg_type?: string; + body?: { content?: string }; + sender?: FeishuMessageSender; + create_time?: string; +}; + +type FeishuGetMessageResponse = { + code?: number; + msg?: string; + data?: FeishuMessageGetItem & { + items?: FeishuMessageGetItem[]; + }; +}; + /** Send a direct message as a fallback when a reply target is unavailable. */ async function sendFallbackDirect( client: FeishuCreateMessageClient, @@ -214,36 +238,7 @@ export async function getMessageFeishu(params: { try { const response = (await client.im.message.get({ path: { message_id: messageId }, - })) as { - code?: number; - msg?: string; - data?: { - items?: Array<{ - message_id?: string; - chat_id?: string; - chat_type?: FeishuChatType; - msg_type?: string; - body?: { content?: string }; - sender?: { - id?: string; - id_type?: string; - sender_type?: string; - }; - create_time?: string; - }>; - message_id?: string; - chat_id?: string; - chat_type?: FeishuChatType; - msg_type?: string; - body?: { content?: string }; - sender?: { - id?: string; - id_type?: string; - sender_type?: string; - }; - create_time?: string; - }; - }; + })) as FeishuGetMessageResponse; if (response.code !== 0) { return null; From d5d2fe1b0e45abddf63f5b4d36948f4ae09e2989 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:35:29 +0000 Subject: [PATCH 021/461] test: reduce webhook auth test duplication --- .../src/monitor.webhook-auth.test.ts | 408 ++++++------------ 1 file changed, 128 insertions(+), 280 deletions(-) diff --git a/extensions/bluebubbles/src/monitor.webhook-auth.test.ts b/extensions/bluebubbles/src/monitor.webhook-auth.test.ts index 7a6a29353bd..b72b95dc4cc 100644 --- a/extensions/bluebubbles/src/monitor.webhook-auth.test.ts +++ b/extensions/bluebubbles/src/monitor.webhook-auth.test.ts @@ -302,65 +302,101 @@ describe("BlueBubbles webhook monitor", () => { }; } - describe("webhook parsing + auth handling", () => { - it("rejects non-POST requests", async () => { - const account = createMockAccount(); - const config: OpenClawConfig = {}; - const core = createMockRuntime(); - setBlueBubblesRuntime(core); + async function dispatchWebhook(req: IncomingMessage) { + const res = createMockResponse(); + const handled = await handleBlueBubblesWebhookRequest(req, res); + return { handled, res }; + } - unregister = registerBlueBubblesWebhookTarget({ + function createWebhookRequestForTest(params?: { + method?: string; + url?: string; + body?: unknown; + headers?: Record; + remoteAddress?: string; + }) { + const req = createMockRequest( + params?.method ?? "POST", + params?.url ?? "/bluebubbles-webhook", + params?.body ?? {}, + params?.headers, + ); + if (params?.remoteAddress) { + setRequestRemoteAddress(req, params.remoteAddress); + } + return req; + } + + function createHangingWebhookRequest(url = "/bluebubbles-webhook?password=test-password") { + const req = new EventEmitter() as IncomingMessage & { destroy: ReturnType }; + req.method = "POST"; + req.url = url; + req.headers = {}; + req.destroy = vi.fn(); + setRequestRemoteAddress(req, "127.0.0.1"); + return req; + } + + function registerWebhookTargets( + params: Array<{ + account: ResolvedBlueBubblesAccount; + statusSink?: (event: unknown) => void; + }>, + ) { + const config: OpenClawConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + const unregisterFns = params.map(({ account, statusSink }) => + registerBlueBubblesWebhookTarget({ account, config, runtime: { log: vi.fn(), error: vi.fn() }, core, path: "/bluebubbles-webhook", - }); + statusSink, + }), + ); - const req = createMockRequest("GET", "/bluebubbles-webhook", {}); - const res = createMockResponse(); + unregister = () => { + for (const unregisterFn of unregisterFns) { + unregisterFn(); + } + }; + } - const handled = await handleBlueBubblesWebhookRequest(req, res); + async function expectWebhookStatus( + req: IncomingMessage, + expectedStatus: number, + expectedBody?: string, + ) { + const { handled, res } = await dispatchWebhook(req); + expect(handled).toBe(true); + expect(res.statusCode).toBe(expectedStatus); + if (expectedBody !== undefined) { + expect(res.body).toBe(expectedBody); + } + return res; + } - expect(handled).toBe(true); - expect(res.statusCode).toBe(405); + describe("webhook parsing + auth handling", () => { + it("rejects non-POST requests", async () => { + setupWebhookTarget(); + const req = createWebhookRequestForTest({ method: "GET" }); + await expectWebhookStatus(req, 405); }); it("accepts POST requests with valid JSON payload", async () => { setupWebhookTarget(); const payload = createNewMessagePayload({ date: Date.now() }); - - const req = createMockRequest("POST", "/bluebubbles-webhook", payload); - const res = createMockResponse(); - - const handled = await handleBlueBubblesWebhookRequest(req, res); - - expect(handled).toBe(true); - expect(res.statusCode).toBe(200); - expect(res.body).toBe("ok"); + const req = createWebhookRequestForTest({ body: payload }); + await expectWebhookStatus(req, 200, "ok"); }); it("rejects requests with invalid JSON", async () => { - const account = createMockAccount(); - const config: OpenClawConfig = {}; - const core = createMockRuntime(); - setBlueBubblesRuntime(core); - - unregister = registerBlueBubblesWebhookTarget({ - account, - config, - runtime: { log: vi.fn(), error: vi.fn() }, - core, - path: "/bluebubbles-webhook", - }); - - const req = createMockRequest("POST", "/bluebubbles-webhook", "invalid json {{"); - const res = createMockResponse(); - - const handled = await handleBlueBubblesWebhookRequest(req, res); - - expect(handled).toBe(true); - expect(res.statusCode).toBe(400); + setupWebhookTarget(); + const req = createWebhookRequestForTest({ body: "invalid json {{" }); + await expectWebhookStatus(req, 400); }); it("accepts URL-encoded payload wrappers", async () => { @@ -369,42 +405,17 @@ describe("BlueBubbles webhook monitor", () => { const encodedBody = new URLSearchParams({ payload: JSON.stringify(payload), }).toString(); - - const req = createMockRequest("POST", "/bluebubbles-webhook", encodedBody); - const res = createMockResponse(); - - const handled = await handleBlueBubblesWebhookRequest(req, res); - - expect(handled).toBe(true); - expect(res.statusCode).toBe(200); - expect(res.body).toBe("ok"); + const req = createWebhookRequestForTest({ body: encodedBody }); + await expectWebhookStatus(req, 200, "ok"); }); it("returns 408 when request body times out (Slow-Loris protection)", async () => { vi.useFakeTimers(); try { - const account = createMockAccount(); - const config: OpenClawConfig = {}; - const core = createMockRuntime(); - setBlueBubblesRuntime(core); - - unregister = registerBlueBubblesWebhookTarget({ - account, - config, - runtime: { log: vi.fn(), error: vi.fn() }, - core, - path: "/bluebubbles-webhook", - }); + setupWebhookTarget(); // Create a request that never sends data or ends (simulates slow-loris) - const req = new EventEmitter() as IncomingMessage; - req.method = "POST"; - req.url = "/bluebubbles-webhook?password=test-password"; - req.headers = {}; - (req as unknown as { socket: { remoteAddress: string } }).socket = { - remoteAddress: "127.0.0.1", - }; - req.destroy = vi.fn(); + const req = createHangingWebhookRequest(); const res = createMockResponse(); @@ -424,140 +435,62 @@ describe("BlueBubbles webhook monitor", () => { it("rejects unauthorized requests before reading the body", async () => { const account = createMockAccount({ password: "secret-token" }); - const config: OpenClawConfig = {}; - const core = createMockRuntime(); - setBlueBubblesRuntime(core); - - unregister = registerBlueBubblesWebhookTarget({ - account, - config, - runtime: { log: vi.fn(), error: vi.fn() }, - core, - path: "/bluebubbles-webhook", - }); - - const req = new EventEmitter() as IncomingMessage; - req.method = "POST"; - req.url = "/bluebubbles-webhook?password=wrong-token"; - req.headers = {}; + setupWebhookTarget({ account }); + const req = createHangingWebhookRequest("/bluebubbles-webhook?password=wrong-token"); const onSpy = vi.spyOn(req, "on"); - (req as unknown as { socket: { remoteAddress: string } }).socket = { - remoteAddress: "127.0.0.1", - }; - - const res = createMockResponse(); - const handled = await handleBlueBubblesWebhookRequest(req, res); - - expect(handled).toBe(true); - expect(res.statusCode).toBe(401); + await expectWebhookStatus(req, 401); expect(onSpy).not.toHaveBeenCalledWith("data", expect.any(Function)); }); it("authenticates via password query parameter", async () => { const account = createMockAccount({ password: "secret-token" }); - - // Mock non-localhost request - const req = createMockRequest( - "POST", - "/bluebubbles-webhook?password=secret-token", - createNewMessagePayload(), - ); - setRequestRemoteAddress(req, "192.168.1.100"); setupWebhookTarget({ account }); - - const res = createMockResponse(); - const handled = await handleBlueBubblesWebhookRequest(req, res); - - expect(handled).toBe(true); - expect(res.statusCode).toBe(200); + const req = createWebhookRequestForTest({ + url: "/bluebubbles-webhook?password=secret-token", + body: createNewMessagePayload(), + remoteAddress: "192.168.1.100", + }); + await expectWebhookStatus(req, 200); }); it("authenticates via x-password header", async () => { const account = createMockAccount({ password: "secret-token" }); - - const req = createMockRequest( - "POST", - "/bluebubbles-webhook", - createNewMessagePayload(), - { "x-password": "secret-token" }, // pragma: allowlist secret - ); - setRequestRemoteAddress(req, "192.168.1.100"); setupWebhookTarget({ account }); - - const res = createMockResponse(); - const handled = await handleBlueBubblesWebhookRequest(req, res); - - expect(handled).toBe(true); - expect(res.statusCode).toBe(200); + const req = createWebhookRequestForTest({ + body: createNewMessagePayload(), + headers: { "x-password": "secret-token" }, // pragma: allowlist secret + remoteAddress: "192.168.1.100", + }); + await expectWebhookStatus(req, 200); }); it("rejects unauthorized requests with wrong password", async () => { const account = createMockAccount({ password: "secret-token" }); - const req = createMockRequest( - "POST", - "/bluebubbles-webhook?password=wrong-token", - createNewMessagePayload(), - ); - setRequestRemoteAddress(req, "192.168.1.100"); setupWebhookTarget({ account }); - - const res = createMockResponse(); - const handled = await handleBlueBubblesWebhookRequest(req, res); - - expect(handled).toBe(true); - expect(res.statusCode).toBe(401); + const req = createWebhookRequestForTest({ + url: "/bluebubbles-webhook?password=wrong-token", + body: createNewMessagePayload(), + remoteAddress: "192.168.1.100", + }); + await expectWebhookStatus(req, 401); }); it("rejects ambiguous routing when multiple targets match the same password", async () => { const accountA = createMockAccount({ password: "secret-token" }); const accountB = createMockAccount({ password: "secret-token" }); - const config: OpenClawConfig = {}; - const core = createMockRuntime(); - setBlueBubblesRuntime(core); - const sinkA = vi.fn(); const sinkB = vi.fn(); + registerWebhookTargets([ + { account: accountA, statusSink: sinkA }, + { account: accountB, statusSink: sinkB }, + ]); - const req = createMockRequest("POST", "/bluebubbles-webhook?password=secret-token", { - type: "new-message", - data: { - text: "hello", - handle: { address: "+15551234567" }, - isGroup: false, - isFromMe: false, - guid: "msg-1", - }, - }); - (req as unknown as { socket: { remoteAddress: string } }).socket = { + const req = createWebhookRequestForTest({ + url: "/bluebubbles-webhook?password=secret-token", + body: createNewMessagePayload(), remoteAddress: "192.168.1.100", - }; - - const unregisterA = registerBlueBubblesWebhookTarget({ - account: accountA, - config, - runtime: { log: vi.fn(), error: vi.fn() }, - core, - path: "/bluebubbles-webhook", - statusSink: sinkA, }); - const unregisterB = registerBlueBubblesWebhookTarget({ - account: accountB, - config, - runtime: { log: vi.fn(), error: vi.fn() }, - core, - path: "/bluebubbles-webhook", - statusSink: sinkB, - }); - unregister = () => { - unregisterA(); - unregisterB(); - }; - - const res = createMockResponse(); - const handled = await handleBlueBubblesWebhookRequest(req, res); - - expect(handled).toBe(true); - expect(res.statusCode).toBe(401); + await expectWebhookStatus(req, 401); expect(sinkA).not.toHaveBeenCalled(); expect(sinkB).not.toHaveBeenCalled(); }); @@ -565,107 +498,38 @@ describe("BlueBubbles webhook monitor", () => { it("ignores targets without passwords when a password-authenticated target matches", async () => { const accountStrict = createMockAccount({ password: "secret-token" }); const accountWithoutPassword = createMockAccount({ password: undefined }); - const config: OpenClawConfig = {}; - const core = createMockRuntime(); - setBlueBubblesRuntime(core); - const sinkStrict = vi.fn(); const sinkWithoutPassword = vi.fn(); + registerWebhookTargets([ + { account: accountStrict, statusSink: sinkStrict }, + { account: accountWithoutPassword, statusSink: sinkWithoutPassword }, + ]); - const req = createMockRequest("POST", "/bluebubbles-webhook?password=secret-token", { - type: "new-message", - data: { - text: "hello", - handle: { address: "+15551234567" }, - isGroup: false, - isFromMe: false, - guid: "msg-1", - }, - }); - (req as unknown as { socket: { remoteAddress: string } }).socket = { + const req = createWebhookRequestForTest({ + url: "/bluebubbles-webhook?password=secret-token", + body: createNewMessagePayload(), remoteAddress: "192.168.1.100", - }; - - const unregisterStrict = registerBlueBubblesWebhookTarget({ - account: accountStrict, - config, - runtime: { log: vi.fn(), error: vi.fn() }, - core, - path: "/bluebubbles-webhook", - statusSink: sinkStrict, }); - const unregisterNoPassword = registerBlueBubblesWebhookTarget({ - account: accountWithoutPassword, - config, - runtime: { log: vi.fn(), error: vi.fn() }, - core, - path: "/bluebubbles-webhook", - statusSink: sinkWithoutPassword, - }); - unregister = () => { - unregisterStrict(); - unregisterNoPassword(); - }; - - const res = createMockResponse(); - const handled = await handleBlueBubblesWebhookRequest(req, res); - - expect(handled).toBe(true); - expect(res.statusCode).toBe(200); + await expectWebhookStatus(req, 200); expect(sinkStrict).toHaveBeenCalledTimes(1); expect(sinkWithoutPassword).not.toHaveBeenCalled(); }); it("requires authentication for loopback requests when password is configured", async () => { const account = createMockAccount({ password: "secret-token" }); - const config: OpenClawConfig = {}; - const core = createMockRuntime(); - setBlueBubblesRuntime(core); + setupWebhookTarget({ account }); for (const remoteAddress of ["127.0.0.1", "::1", "::ffff:127.0.0.1"]) { - const req = createMockRequest("POST", "/bluebubbles-webhook", { - type: "new-message", - data: { - text: "hello", - handle: { address: "+15551234567" }, - isGroup: false, - isFromMe: false, - guid: "msg-1", - }, - }); - (req as unknown as { socket: { remoteAddress: string } }).socket = { + const req = createWebhookRequestForTest({ + body: createNewMessagePayload(), remoteAddress, - }; - - const loopbackUnregister = registerBlueBubblesWebhookTarget({ - account, - config, - runtime: { log: vi.fn(), error: vi.fn() }, - core, - path: "/bluebubbles-webhook", }); - - const res = createMockResponse(); - const handled = await handleBlueBubblesWebhookRequest(req, res); - expect(handled).toBe(true); - expect(res.statusCode).toBe(401); - - loopbackUnregister(); + await expectWebhookStatus(req, 401); } }); it("rejects targets without passwords for loopback and proxied-looking requests", async () => { const account = createMockAccount({ password: undefined }); - const config: OpenClawConfig = {}; - const core = createMockRuntime(); - setBlueBubblesRuntime(core); - - unregister = registerBlueBubblesWebhookTarget({ - account, - config, - runtime: { log: vi.fn(), error: vi.fn() }, - core, - path: "/bluebubbles-webhook", - }); + setupWebhookTarget({ account }); const headerVariants: Record[] = [ { host: "localhost" }, @@ -673,28 +537,12 @@ describe("BlueBubbles webhook monitor", () => { { host: "localhost", forwarded: "for=203.0.113.10;proto=https;host=example.com" }, ]; for (const headers of headerVariants) { - const req = createMockRequest( - "POST", - "/bluebubbles-webhook", - { - type: "new-message", - data: { - text: "hello", - handle: { address: "+15551234567" }, - isGroup: false, - isFromMe: false, - guid: "msg-1", - }, - }, + const req = createWebhookRequestForTest({ + body: createNewMessagePayload(), headers, - ); - (req as unknown as { socket: { remoteAddress: string } }).socket = { remoteAddress: "127.0.0.1", - }; - const res = createMockResponse(); - const handled = await handleBlueBubblesWebhookRequest(req, res); - expect(handled).toBe(true); - expect(res.statusCode).toBe(401); + }); + await expectWebhookStatus(req, 401); } }); From b5010719d61c559a3c0eaa79acc7a1bf38e7a781 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:35:50 +0000 Subject: [PATCH 022/461] test: dedupe telnyx webhook test fixtures --- .../voice-call/src/providers/telnyx.test.ts | 60 +++--- .../voice-call/src/webhook-security.test.ts | 178 +++++++----------- 2 files changed, 104 insertions(+), 134 deletions(-) diff --git a/extensions/voice-call/src/providers/telnyx.test.ts b/extensions/voice-call/src/providers/telnyx.test.ts index c083070229f..15a4cc8f17f 100644 --- a/extensions/voice-call/src/providers/telnyx.test.ts +++ b/extensions/voice-call/src/providers/telnyx.test.ts @@ -22,6 +22,34 @@ function decodeBase64Url(input: string): Buffer { return Buffer.from(padded, "base64"); } +function createSignedTelnyxCtx(params: { + privateKey: crypto.KeyObject; + rawBody: string; +}): WebhookContext { + const timestamp = String(Math.floor(Date.now() / 1000)); + const signedPayload = `${timestamp}|${params.rawBody}`; + const signature = crypto + .sign(null, Buffer.from(signedPayload), params.privateKey) + .toString("base64"); + + return createCtx({ + rawBody: params.rawBody, + headers: { + "telnyx-signature-ed25519": signature, + "telnyx-timestamp": timestamp, + }, + }); +} + +function expectReplayVerification( + results: Array<{ ok: boolean; isReplay?: boolean; verifiedRequestKey?: string }>, +) { + expect(results.map((result) => result.ok)).toEqual([true, true]); + expect(results.map((result) => Boolean(result.isReplay))).toEqual([false, true]); + expect(results[0]?.verifiedRequestKey).toEqual(expect.any(String)); + expect(results[1]?.verifiedRequestKey).toBe(results[0]?.verifiedRequestKey); +} + function expectWebhookVerificationSucceeds(params: { publicKey: string; privateKey: crypto.KeyObject; @@ -35,20 +63,8 @@ function expectWebhookVerificationSucceeds(params: { event_type: "call.initiated", payload: { call_control_id: "x" }, }); - const timestamp = String(Math.floor(Date.now() / 1000)); - const signedPayload = `${timestamp}|${rawBody}`; - const signature = crypto - .sign(null, Buffer.from(signedPayload), params.privateKey) - .toString("base64"); - const result = provider.verifyWebhook( - createCtx({ - rawBody, - headers: { - "telnyx-signature-ed25519": signature, - "telnyx-timestamp": timestamp, - }, - }), + createSignedTelnyxCtx({ privateKey: params.privateKey, rawBody }), ); expect(result.ok).toBe(true); } @@ -117,26 +133,12 @@ describe("TelnyxProvider.verifyWebhook", () => { payload: { call_control_id: "call-replay-test" }, nonce: crypto.randomUUID(), }); - const timestamp = String(Math.floor(Date.now() / 1000)); - const signedPayload = `${timestamp}|${rawBody}`; - const signature = crypto.sign(null, Buffer.from(signedPayload), privateKey).toString("base64"); - const ctx = createCtx({ - rawBody, - headers: { - "telnyx-signature-ed25519": signature, - "telnyx-timestamp": timestamp, - }, - }); + const ctx = createSignedTelnyxCtx({ privateKey, rawBody }); const first = provider.verifyWebhook(ctx); const second = provider.verifyWebhook(ctx); - expect(first.ok).toBe(true); - expect(first.isReplay).toBeFalsy(); - expect(first.verifiedRequestKey).toBeTruthy(); - expect(second.ok).toBe(true); - expect(second.isReplay).toBe(true); - expect(second.verifiedRequestKey).toBe(first.verifiedRequestKey); + expectReplayVerification([first, second]); }); }); diff --git a/extensions/voice-call/src/webhook-security.test.ts b/extensions/voice-call/src/webhook-security.test.ts index 3134f18b729..3fe3cd473a1 100644 --- a/extensions/voice-call/src/webhook-security.test.ts +++ b/extensions/voice-call/src/webhook-security.test.ts @@ -98,6 +98,51 @@ function expectReplayResultPair( expect(second.verifiedRequestKey).toBe(first.verifiedRequestKey); } +function expectAcceptedWebhookVersion( + result: { ok: boolean; version?: string }, + version: "v2" | "v3", +) { + expect(result).toMatchObject({ ok: true, version }); +} + +function verifyTwilioNgrokLoopback(signature: string) { + return verifyTwilioWebhook( + { + headers: { + host: "127.0.0.1:3334", + "x-forwarded-proto": "https", + "x-forwarded-host": "local.ngrok-free.app", + "x-twilio-signature": signature, + }, + rawBody: "CallSid=CS123&CallStatus=completed&From=%2B15550000000", + url: "http://127.0.0.1:3334/voice/webhook", + method: "POST", + remoteAddress: "127.0.0.1", + }, + "test-auth-token", + { allowNgrokFreeTierLoopbackBypass: true }, + ); +} + +function verifyTwilioSignedRequest(params: { + headers: Record; + rawBody: string; + authToken: string; + publicUrl: string; +}) { + return verifyTwilioWebhook( + { + headers: params.headers, + rawBody: params.rawBody, + url: "http://local/voice/webhook?callId=abc", + method: "POST", + query: { callId: "abc" }, + }, + params.authToken, + { publicUrl: params.publicUrl }, + ); +} + describe("verifyPlivoWebhook", () => { it("accepts valid V2 signature", () => { const authToken = "test-auth-token"; @@ -127,8 +172,7 @@ describe("verifyPlivoWebhook", () => { authToken, ); - expect(result.ok).toBe(true); - expect(result.version).toBe("v2"); + expectAcceptedWebhookVersion(result, "v2"); }); it("accepts valid V3 signature (including multi-signature header)", () => { @@ -161,8 +205,7 @@ describe("verifyPlivoWebhook", () => { authToken, ); - expect(result.ok).toBe(true); - expect(result.version).toBe("v3"); + expectAcceptedWebhookVersion(result, "v3"); }); it("rejects missing signatures", () => { @@ -317,35 +360,10 @@ describe("verifyTwilioWebhook", () => { "i-twilio-idempotency-token": "idem-replay-1", }; - const first = verifyTwilioWebhook( - { - headers, - rawBody: postBody, - url: "http://local/voice/webhook?callId=abc", - method: "POST", - query: { callId: "abc" }, - }, - authToken, - { publicUrl }, - ); - const second = verifyTwilioWebhook( - { - headers, - rawBody: postBody, - url: "http://local/voice/webhook?callId=abc", - method: "POST", - query: { callId: "abc" }, - }, - authToken, - { publicUrl }, - ); + const first = verifyTwilioSignedRequest({ headers, rawBody: postBody, authToken, publicUrl }); + const second = verifyTwilioSignedRequest({ headers, rawBody: postBody, authToken, publicUrl }); - expect(first.ok).toBe(true); - expect(first.isReplay).toBeFalsy(); - expect(first.verifiedRequestKey).toBeTruthy(); - expect(second.ok).toBe(true); - expect(second.isReplay).toBe(true); - expect(second.verifiedRequestKey).toBe(first.verifiedRequestKey); + expectReplayResultPair(first, second); }); it("treats changed idempotency header as replay for identical signed requests", () => { @@ -355,45 +373,30 @@ describe("verifyTwilioWebhook", () => { const postBody = "CallSid=CS778&CallStatus=completed&From=%2B15550000000"; const signature = twilioSignature({ authToken, url: urlWithQuery, postBody }); - const first = verifyTwilioWebhook( - { - headers: { - host: "example.com", - "x-forwarded-proto": "https", - "x-twilio-signature": signature, - "i-twilio-idempotency-token": "idem-replay-a", - }, - rawBody: postBody, - url: "http://local/voice/webhook?callId=abc", - method: "POST", - query: { callId: "abc" }, + const first = verifyTwilioSignedRequest({ + headers: { + host: "example.com", + "x-forwarded-proto": "https", + "x-twilio-signature": signature, + "i-twilio-idempotency-token": "idem-replay-a", }, + rawBody: postBody, authToken, - { publicUrl }, - ); - const second = verifyTwilioWebhook( - { - headers: { - host: "example.com", - "x-forwarded-proto": "https", - "x-twilio-signature": signature, - "i-twilio-idempotency-token": "idem-replay-b", - }, - rawBody: postBody, - url: "http://local/voice/webhook?callId=abc", - method: "POST", - query: { callId: "abc" }, + publicUrl, + }); + const second = verifyTwilioSignedRequest({ + headers: { + host: "example.com", + "x-forwarded-proto": "https", + "x-twilio-signature": signature, + "i-twilio-idempotency-token": "idem-replay-b", }, + rawBody: postBody, authToken, - { publicUrl }, - ); + publicUrl, + }); - expect(first.ok).toBe(true); - expect(first.isReplay).toBe(false); - expect(first.verifiedRequestKey).toBeTruthy(); - expect(second.ok).toBe(true); - expect(second.isReplay).toBe(true); - expect(second.verifiedRequestKey).toBe(first.verifiedRequestKey); + expectReplayResultPair(first, second); }); it("rejects invalid signatures even when attacker injects forwarded host", () => { @@ -422,57 +425,22 @@ describe("verifyTwilioWebhook", () => { }); it("accepts valid signatures for ngrok free tier on loopback when compatibility mode is enabled", () => { - const authToken = "test-auth-token"; - const postBody = "CallSid=CS123&CallStatus=completed&From=%2B15550000000"; const webhookUrl = "https://local.ngrok-free.app/voice/webhook"; const signature = twilioSignature({ - authToken, + authToken: "test-auth-token", url: webhookUrl, - postBody, + postBody: "CallSid=CS123&CallStatus=completed&From=%2B15550000000", }); - const result = verifyTwilioWebhook( - { - headers: { - host: "127.0.0.1:3334", - "x-forwarded-proto": "https", - "x-forwarded-host": "local.ngrok-free.app", - "x-twilio-signature": signature, - }, - rawBody: postBody, - url: "http://127.0.0.1:3334/voice/webhook", - method: "POST", - remoteAddress: "127.0.0.1", - }, - authToken, - { allowNgrokFreeTierLoopbackBypass: true }, - ); + const result = verifyTwilioNgrokLoopback(signature); expect(result.ok).toBe(true); expect(result.verificationUrl).toBe(webhookUrl); }); it("does not allow invalid signatures for ngrok free tier on loopback", () => { - const authToken = "test-auth-token"; - const postBody = "CallSid=CS123&CallStatus=completed&From=%2B15550000000"; - - const result = verifyTwilioWebhook( - { - headers: { - host: "127.0.0.1:3334", - "x-forwarded-proto": "https", - "x-forwarded-host": "local.ngrok-free.app", - "x-twilio-signature": "invalid", - }, - rawBody: postBody, - url: "http://127.0.0.1:3334/voice/webhook", - method: "POST", - remoteAddress: "127.0.0.1", - }, - authToken, - { allowNgrokFreeTierLoopbackBypass: true }, - ); + const result = verifyTwilioNgrokLoopback("invalid"); expect(result.ok).toBe(false); expect(result.reason).toMatch(/Invalid signature/); From c8898034f9756afd084b188c8c6466a99e1acc7b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:36:42 +0000 Subject: [PATCH 023/461] refactor: share agent wait dedupe cleanup --- .../server-methods/agent-wait-dedupe.ts | 33 ++++++++----------- 1 file changed, 13 insertions(+), 20 deletions(-) diff --git a/src/gateway/server-methods/agent-wait-dedupe.ts b/src/gateway/server-methods/agent-wait-dedupe.ts index 98d0df72fa3..50629beb3eb 100644 --- a/src/gateway/server-methods/agent-wait-dedupe.ts +++ b/src/gateway/server-methods/agent-wait-dedupe.ts @@ -23,6 +23,17 @@ function asFiniteNumber(value: unknown): number | undefined { return typeof value === "number" && Number.isFinite(value) ? value : undefined; } +function removeWaiter(runId: string, waiter: () => void): void { + const waiters = AGENT_WAITERS_BY_RUN_ID.get(runId); + if (!waiters) { + return; + } + waiters.delete(waiter); + if (waiters.size === 0) { + AGENT_WAITERS_BY_RUN_ID.delete(runId); + } +} + function addWaiter(runId: string, waiter: () => void): () => void { const normalizedRunId = runId.trim(); if (!normalizedRunId) { @@ -31,28 +42,10 @@ function addWaiter(runId: string, waiter: () => void): () => void { const existing = AGENT_WAITERS_BY_RUN_ID.get(normalizedRunId); if (existing) { existing.add(waiter); - return () => { - const waiters = AGENT_WAITERS_BY_RUN_ID.get(normalizedRunId); - if (!waiters) { - return; - } - waiters.delete(waiter); - if (waiters.size === 0) { - AGENT_WAITERS_BY_RUN_ID.delete(normalizedRunId); - } - }; + return () => removeWaiter(normalizedRunId, waiter); } AGENT_WAITERS_BY_RUN_ID.set(normalizedRunId, new Set([waiter])); - return () => { - const waiters = AGENT_WAITERS_BY_RUN_ID.get(normalizedRunId); - if (!waiters) { - return; - } - waiters.delete(waiter); - if (waiters.size === 0) { - AGENT_WAITERS_BY_RUN_ID.delete(normalizedRunId); - } - }; + return () => removeWaiter(normalizedRunId, waiter); } function notifyWaiters(runId: string): void { From 67f7d1e65f42fad63e9643c9b3efdde7207c34a9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:37:46 +0000 Subject: [PATCH 024/461] test: dedupe slack message event tests --- src/daemon/schtasks.startup-fallback.test.ts | 64 +++++----- src/slack/monitor/events/messages.test.ts | 121 +++++++++---------- 2 files changed, 92 insertions(+), 93 deletions(-) diff --git a/src/daemon/schtasks.startup-fallback.test.ts b/src/daemon/schtasks.startup-fallback.test.ts index 6e6a8521d6c..4fdadd2f110 100644 --- a/src/daemon/schtasks.startup-fallback.test.ts +++ b/src/daemon/schtasks.startup-fallback.test.ts @@ -57,6 +57,30 @@ async function writeGatewayScript(env: Record, port = 18789) { "utf8", ); } +async function writeStartupFallbackEntry(env: Record) { + const startupEntryPath = resolveStartupEntryPath(env); + await fs.mkdir(path.dirname(startupEntryPath), { recursive: true }); + await fs.writeFile(startupEntryPath, "@echo off\r\n", "utf8"); + return startupEntryPath; +} + +function expectStartupFallbackSpawn(env: Record) { + expect(spawn).toHaveBeenCalledWith( + "cmd.exe", + ["/d", "/s", "/c", quoteCmdScriptArg(resolveTaskScriptPath(env))], + expect.objectContaining({ detached: true, stdio: "ignore", windowsHide: true }), + ); +} + +function addStartupFallbackMissingResponses( + extraResponses: Array<{ code: number; stdout: string; stderr: string }> = [], +) { + schtasksResponses.push( + { code: 0, stdout: "", stderr: "" }, + { code: 1, stdout: "", stderr: "not found" }, + ...extraResponses, + ); +} beforeEach(() => { resetSchtasksBaseMocks(); spawn.mockClear(); @@ -119,22 +143,14 @@ describe("Windows startup fallback", () => { }); await expect(fs.access(resolveStartupEntryPath(env))).resolves.toBeUndefined(); - expect(spawn).toHaveBeenCalledWith( - "cmd.exe", - ["/d", "/s", "/c", quoteCmdScriptArg(resolveTaskScriptPath(env))], - expect.objectContaining({ detached: true, stdio: "ignore", windowsHide: true }), - ); + expectStartupFallbackSpawn(env); }); }); it("treats an installed Startup-folder launcher as loaded", async () => { await withWindowsEnv("openclaw-win-startup-", async ({ env }) => { - schtasksResponses.push( - { code: 0, stdout: "", stderr: "" }, - { code: 1, stdout: "", stderr: "not found" }, - ); - await fs.mkdir(path.dirname(resolveStartupEntryPath(env)), { recursive: true }); - await fs.writeFile(resolveStartupEntryPath(env), "@echo off\r\n", "utf8"); + addStartupFallbackMissingResponses(); + await writeStartupFallbackEntry(env); await expect(isScheduledTaskInstalled({ env })).resolves.toBe(true); }); @@ -142,12 +158,8 @@ describe("Windows startup fallback", () => { it("reports runtime from the gateway listener when using the Startup fallback", async () => { await withWindowsEnv("openclaw-win-startup-", async ({ env }) => { - schtasksResponses.push( - { code: 0, stdout: "", stderr: "" }, - { code: 1, stdout: "", stderr: "not found" }, - ); - await fs.mkdir(path.dirname(resolveStartupEntryPath(env)), { recursive: true }); - await fs.writeFile(resolveStartupEntryPath(env), "@echo off\r\n", "utf8"); + addStartupFallbackMissingResponses(); + await writeStartupFallbackEntry(env); inspectPortUsage.mockResolvedValue({ port: 18789, status: "busy", @@ -164,14 +176,11 @@ describe("Windows startup fallback", () => { it("restarts the Startup fallback by killing the current pid and relaunching the entry", async () => { await withWindowsEnv("openclaw-win-startup-", async ({ env }) => { - schtasksResponses.push( + addStartupFallbackMissingResponses([ { code: 0, stdout: "", stderr: "" }, { code: 1, stdout: "", stderr: "not found" }, - { code: 0, stdout: "", stderr: "" }, - { code: 1, stdout: "", stderr: "not found" }, - ); - await fs.mkdir(path.dirname(resolveStartupEntryPath(env)), { recursive: true }); - await fs.writeFile(resolveStartupEntryPath(env), "@echo off\r\n", "utf8"); + ]); + await writeStartupFallbackEntry(env); inspectPortUsage.mockResolvedValue({ port: 18789, status: "busy", @@ -184,11 +193,7 @@ describe("Windows startup fallback", () => { outcome: "completed", }); expect(killProcessTree).toHaveBeenCalledWith(5151, { graceMs: 300 }); - expect(spawn).toHaveBeenCalledWith( - "cmd.exe", - ["/d", "/s", "/c", quoteCmdScriptArg(resolveTaskScriptPath(env))], - expect.objectContaining({ detached: true, stdio: "ignore", windowsHide: true }), - ); + expectStartupFallbackSpawn(env); }); }); @@ -196,8 +201,7 @@ describe("Windows startup fallback", () => { await withWindowsEnv("openclaw-win-startup-", async ({ env }) => { schtasksResponses.push({ code: 0, stdout: "", stderr: "" }); await writeGatewayScript(env); - await fs.mkdir(path.dirname(resolveStartupEntryPath(env)), { recursive: true }); - await fs.writeFile(resolveStartupEntryPath(env), "@echo off\r\n", "utf8"); + await writeStartupFallbackEntry(env); inspectPortUsage .mockResolvedValueOnce({ port: 18789, diff --git a/src/slack/monitor/events/messages.test.ts b/src/slack/monitor/events/messages.test.ts index 922458a40b1..25fdb77c025 100644 --- a/src/slack/monitor/events/messages.test.ts +++ b/src/slack/monitor/events/messages.test.ts @@ -18,6 +18,7 @@ vi.mock("../../../pairing/pairing-store.js", () => ({ type MessageHandler = (args: { event: Record; body: unknown }) => Promise; type AppMentionHandler = MessageHandler; +type RegisteredEventName = "message" | "app_mention"; type MessageCase = { overrides?: SlackSystemEventTestOverrides; @@ -25,7 +26,7 @@ type MessageCase = { body?: unknown; }; -function createMessageHandlers(overrides?: SlackSystemEventTestOverrides) { +function createHandlers(eventName: RegisteredEventName, overrides?: SlackSystemEventTestOverrides) { const harness = createSlackSystemEventTestHarness(overrides); const handleSlackMessage = vi.fn(async () => {}); registerSlackMessageEvents({ @@ -33,22 +34,14 @@ function createMessageHandlers(overrides?: SlackSystemEventTestOverrides) { handleSlackMessage, }); return { - handler: harness.getHandler("message") as MessageHandler | null, + handler: harness.getHandler(eventName) as MessageHandler | null, handleSlackMessage, }; } -function createAppMentionHandlers(overrides?: SlackSystemEventTestOverrides) { - const harness = createSlackSystemEventTestHarness(overrides); - const handleSlackMessage = vi.fn(async () => {}); - registerSlackMessageEvents({ - ctx: harness.ctx, - handleSlackMessage, - }); - return { - handler: harness.getHandler("app_mention") as AppMentionHandler | null, - handleSlackMessage, - }; +function resetMessageMocks(): void { + messageQueueMock.mockClear(); + messageAllowMock.mockReset().mockResolvedValue([]); } function makeChangedEvent(overrides?: { channel?: string; user?: string }) { @@ -89,10 +82,40 @@ function makeThreadBroadcastEvent(overrides?: { channel?: string; user?: string }; } +function makeAppMentionEvent(overrides?: { + channel?: string; + channelType?: "channel" | "group" | "im" | "mpim"; + ts?: string; +}) { + return { + type: "app_mention", + channel: overrides?.channel ?? "C123", + channel_type: overrides?.channelType ?? "channel", + user: "U1", + text: "<@U_BOT> hello", + ts: overrides?.ts ?? "123.456", + }; +} + +async function invokeRegisteredHandler(input: { + eventName: RegisteredEventName; + overrides?: SlackSystemEventTestOverrides; + event: Record; + body?: unknown; +}) { + resetMessageMocks(); + const { handler, handleSlackMessage } = createHandlers(input.eventName, input.overrides); + expect(handler).toBeTruthy(); + await handler!({ + event: input.event, + body: input.body ?? {}, + }); + return { handleSlackMessage }; +} + async function runMessageCase(input: MessageCase = {}): Promise { - messageQueueMock.mockClear(); - messageAllowMock.mockReset().mockResolvedValue([]); - const { handler } = createMessageHandlers(input.overrides); + resetMessageMocks(); + const { handler } = createHandlers("message", input.overrides); expect(handler).toBeTruthy(); await handler!({ event: (input.event ?? makeChangedEvent()) as Record, @@ -151,12 +174,9 @@ describe("registerSlackMessageEvents", () => { }); it("passes regular message events to the message handler", async () => { - messageQueueMock.mockClear(); - messageAllowMock.mockReset().mockResolvedValue([]); - const { handler, handleSlackMessage } = createMessageHandlers({ dmPolicy: "open" }); - expect(handler).toBeTruthy(); - - await handler!({ + const { handleSlackMessage } = await invokeRegisteredHandler({ + eventName: "message", + overrides: { dmPolicy: "open" }, event: { type: "message", channel: "D1", @@ -164,7 +184,6 @@ describe("registerSlackMessageEvents", () => { text: "hello", ts: "123.456", }, - body: {}, }); expect(handleSlackMessage).toHaveBeenCalledTimes(1); @@ -172,9 +191,8 @@ describe("registerSlackMessageEvents", () => { }); it("handles channel and group messages via the unified message handler", async () => { - messageQueueMock.mockClear(); - messageAllowMock.mockReset().mockResolvedValue([]); - const { handler, handleSlackMessage } = createMessageHandlers({ + resetMessageMocks(); + const { handler, handleSlackMessage } = createHandlers("message", { dmPolicy: "open", channelType: "channel", }); @@ -206,23 +224,18 @@ describe("registerSlackMessageEvents", () => { }); it("applies subtype system-event handling for channel messages", async () => { - messageQueueMock.mockClear(); - messageAllowMock.mockReset().mockResolvedValue([]); - const { handler, handleSlackMessage } = createMessageHandlers({ - dmPolicy: "open", - channelType: "channel", - }); - - expect(handler).toBeTruthy(); - // message_changed events from channels arrive via the generic "message" // handler with channel_type:"channel" — not a separate event type. - await handler!({ + const { handleSlackMessage } = await invokeRegisteredHandler({ + eventName: "message", + overrides: { + dmPolicy: "open", + channelType: "channel", + }, event: { ...makeChangedEvent({ channel: "C1", user: "U1" }), channel_type: "channel", }, - body: {}, }); expect(handleSlackMessage).not.toHaveBeenCalled(); @@ -230,38 +243,20 @@ describe("registerSlackMessageEvents", () => { }); it("skips app_mention events for DM channel ids even with contradictory channel_type", async () => { - const { handler, handleSlackMessage } = createAppMentionHandlers({ dmPolicy: "open" }); - expect(handler).toBeTruthy(); - - await handler!({ - event: { - type: "app_mention", - channel: "D123", - channel_type: "channel", - user: "U1", - text: "<@U_BOT> hello", - ts: "123.456", - }, - body: {}, + const { handleSlackMessage } = await invokeRegisteredHandler({ + eventName: "app_mention", + overrides: { dmPolicy: "open" }, + event: makeAppMentionEvent({ channel: "D123", channelType: "channel" }), }); expect(handleSlackMessage).not.toHaveBeenCalled(); }); it("routes app_mention events from channels to the message handler", async () => { - const { handler, handleSlackMessage } = createAppMentionHandlers({ dmPolicy: "open" }); - expect(handler).toBeTruthy(); - - await handler!({ - event: { - type: "app_mention", - channel: "C123", - channel_type: "channel", - user: "U1", - text: "<@U_BOT> hello", - ts: "123.789", - }, - body: {}, + const { handleSlackMessage } = await invokeRegisteredHandler({ + eventName: "app_mention", + overrides: { dmPolicy: "open" }, + event: makeAppMentionEvent({ channel: "C123", channelType: "channel", ts: "123.789" }), }); expect(handleSlackMessage).toHaveBeenCalledTimes(1); From 65cf2cea9d5f4142fe39507c820e2e07aa3a0fa7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 20:43:36 +0000 Subject: [PATCH 025/461] refactor: share matrix monitor test helpers --- .../matrix/src/matrix/monitor/direct.test.ts | 46 +++++++++---------- .../matrix/src/matrix/monitor/events.test.ts | 30 ++++++------ 2 files changed, 35 insertions(+), 41 deletions(-) diff --git a/extensions/matrix/src/matrix/monitor/direct.test.ts b/extensions/matrix/src/matrix/monitor/direct.test.ts index 298b3996837..6688f76e649 100644 --- a/extensions/matrix/src/matrix/monitor/direct.test.ts +++ b/extensions/matrix/src/matrix/monitor/direct.test.ts @@ -7,6 +7,8 @@ import { createDirectRoomTracker } from "./direct.js"; type StateEvent = Record; type DmMap = Record; +const brokenDmRoomId = "!broken-dm:example.org"; +const defaultBrokenDmMembers = ["@alice:example.org", "@bot:example.org"]; function createMockClient(opts: { dmRooms?: DmMap; @@ -50,6 +52,21 @@ function createMockClient(opts: { }; } +function createBrokenDmClient(roomNameEvent?: StateEvent) { + return createMockClient({ + dmRooms: {}, + membersByRoom: { + [brokenDmRoomId]: defaultBrokenDmMembers, + }, + stateEvents: { + // is_direct not set on either member (e.g. Continuwuity bug) + [`${brokenDmRoomId}|m.room.member|@alice:example.org`]: {}, + [`${brokenDmRoomId}|m.room.member|@bot:example.org`]: {}, + ...(roomNameEvent ? { [`${brokenDmRoomId}|m.room.name|`]: roomNameEvent } : {}), + }, + }); +} + // --------------------------------------------------------------------------- // Tests -- isDirectMessage // --------------------------------------------------------------------------- @@ -131,22 +148,11 @@ describe("createDirectRoomTracker", () => { describe("conservative fallback (memberCount + room name)", () => { it("returns true for 2-member room WITHOUT a room name (broken flags)", async () => { - const client = createMockClient({ - dmRooms: {}, - membersByRoom: { - "!broken-dm:example.org": ["@alice:example.org", "@bot:example.org"], - }, - stateEvents: { - // is_direct not set on either member (e.g. Continuwuity bug) - "!broken-dm:example.org|m.room.member|@alice:example.org": {}, - "!broken-dm:example.org|m.room.member|@bot:example.org": {}, - // No m.room.name -> getRoomStateEvent will throw (event not found) - }, - }); + const client = createBrokenDmClient(); const tracker = createDirectRoomTracker(client as never); const result = await tracker.isDirectMessage({ - roomId: "!broken-dm:example.org", + roomId: brokenDmRoomId, senderId: "@alice:example.org", }); @@ -154,21 +160,11 @@ describe("createDirectRoomTracker", () => { }); it("returns true for 2-member room with empty room name", async () => { - const client = createMockClient({ - dmRooms: {}, - membersByRoom: { - "!broken-dm:example.org": ["@alice:example.org", "@bot:example.org"], - }, - stateEvents: { - "!broken-dm:example.org|m.room.member|@alice:example.org": {}, - "!broken-dm:example.org|m.room.member|@bot:example.org": {}, - "!broken-dm:example.org|m.room.name|": { name: "" }, - }, - }); + const client = createBrokenDmClient({ name: "" }); const tracker = createDirectRoomTracker(client as never); const result = await tracker.isDirectMessage({ - roomId: "!broken-dm:example.org", + roomId: brokenDmRoomId, senderId: "@alice:example.org", }); diff --git a/extensions/matrix/src/matrix/monitor/events.test.ts b/extensions/matrix/src/matrix/monitor/events.test.ts index 9179cf69ee3..3c08a0230d1 100644 --- a/extensions/matrix/src/matrix/monitor/events.test.ts +++ b/extensions/matrix/src/matrix/monitor/events.test.ts @@ -12,6 +12,8 @@ vi.mock("../send.js", () => ({ })); describe("registerMatrixMonitorEvents", () => { + const roomId = "!room:example.org"; + beforeEach(() => { sendReadReceiptMatrixMock.mockClear(); }); @@ -53,6 +55,16 @@ describe("registerMatrixMonitorEvents", () => { return { client, getUserId, onRoomMessage, roomMessageHandler, logVerboseMessage }; } + async function expectForwardedWithoutReadReceipt(event: MatrixRawEvent) { + const { onRoomMessage, roomMessageHandler } = createHarness(); + + roomMessageHandler(roomId, event); + await vi.waitFor(() => { + expect(onRoomMessage).toHaveBeenCalledWith(roomId, event); + }); + expect(sendReadReceiptMatrixMock).not.toHaveBeenCalled(); + } + it("sends read receipt immediately for non-self messages", async () => { const { client, onRoomMessage, roomMessageHandler } = createHarness(); const event = { @@ -69,30 +81,16 @@ describe("registerMatrixMonitorEvents", () => { }); it("does not send read receipts for self messages", async () => { - const { onRoomMessage, roomMessageHandler } = createHarness(); - const event = { + await expectForwardedWithoutReadReceipt({ event_id: "$e2", sender: "@bot:example.org", - } as MatrixRawEvent; - - roomMessageHandler("!room:example.org", event); - await vi.waitFor(() => { - expect(onRoomMessage).toHaveBeenCalledWith("!room:example.org", event); }); - expect(sendReadReceiptMatrixMock).not.toHaveBeenCalled(); }); it("skips receipt when message lacks sender or event id", async () => { - const { onRoomMessage, roomMessageHandler } = createHarness(); - const event = { + await expectForwardedWithoutReadReceipt({ sender: "@alice:example.org", - } as MatrixRawEvent; - - roomMessageHandler("!room:example.org", event); - await vi.waitFor(() => { - expect(onRoomMessage).toHaveBeenCalledWith("!room:example.org", event); }); - expect(sendReadReceiptMatrixMock).not.toHaveBeenCalled(); }); it("caches self user id across messages", async () => { From aa551e5a9cfb1dde8efbd434096f880315dba512 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 20:44:28 +0000 Subject: [PATCH 026/461] refactor: share acpx process env test helper --- .../src/runtime-internals/process.test.ts | 128 ++++++++---------- 1 file changed, 60 insertions(+), 68 deletions(-) diff --git a/extensions/acpx/src/runtime-internals/process.test.ts b/extensions/acpx/src/runtime-internals/process.test.ts index ba6ad923d3b..ef0492308ae 100644 --- a/extensions/acpx/src/runtime-internals/process.test.ts +++ b/extensions/acpx/src/runtime-internals/process.test.ts @@ -254,6 +254,44 @@ describe("waitForExit", () => { }); describe("spawnAndCollect", () => { + type SpawnedEnvSnapshot = { + openai?: string; + github?: string; + hf?: string; + openclaw?: string; + shell?: string; + }; + + function stubProviderAuthEnv(env: Record) { + for (const [key, value] of Object.entries(env)) { + vi.stubEnv(key, value); + } + } + + async function collectSpawnedEnvSnapshot(options?: { + stripProviderAuthEnvVars?: boolean; + openAiEnvKey?: string; + githubEnvKey?: string; + hfEnvKey?: string; + }): Promise { + const openAiEnvKey = options?.openAiEnvKey ?? "OPENAI_API_KEY"; + const githubEnvKey = options?.githubEnvKey ?? "GITHUB_TOKEN"; + const hfEnvKey = options?.hfEnvKey ?? "HF_TOKEN"; + const result = await spawnAndCollect({ + command: process.execPath, + args: [ + "-e", + `process.stdout.write(JSON.stringify({openai:process.env.${openAiEnvKey},github:process.env.${githubEnvKey},hf:process.env.${hfEnvKey},openclaw:process.env.OPENCLAW_API_KEY,shell:process.env.OPENCLAW_SHELL}))`, + ], + cwd: process.cwd(), + stripProviderAuthEnvVars: options?.stripProviderAuthEnvVars, + }); + + expect(result.code).toBe(0); + expect(result.error).toBeNull(); + return JSON.parse(result.stdout) as SpawnedEnvSnapshot; + } + it("returns abort error immediately when signal is already aborted", async () => { const controller = new AbortController(); controller.abort(); @@ -292,31 +330,15 @@ describe("spawnAndCollect", () => { }); it("strips shared provider auth env vars from spawned acpx children", async () => { - vi.stubEnv("OPENAI_API_KEY", "openai-secret"); - vi.stubEnv("GITHUB_TOKEN", "gh-secret"); - vi.stubEnv("HF_TOKEN", "hf-secret"); - vi.stubEnv("OPENCLAW_API_KEY", "keep-me"); - - const result = await spawnAndCollect({ - command: process.execPath, - args: [ - "-e", - "process.stdout.write(JSON.stringify({openai:process.env.OPENAI_API_KEY,github:process.env.GITHUB_TOKEN,hf:process.env.HF_TOKEN,openclaw:process.env.OPENCLAW_API_KEY,shell:process.env.OPENCLAW_SHELL}))", - ], - cwd: process.cwd(), + stubProviderAuthEnv({ + OPENAI_API_KEY: "openai-secret", + GITHUB_TOKEN: "gh-secret", + HF_TOKEN: "hf-secret", + OPENCLAW_API_KEY: "keep-me", + }); + const parsed = await collectSpawnedEnvSnapshot({ stripProviderAuthEnvVars: true, }); - - expect(result.code).toBe(0); - expect(result.error).toBeNull(); - - const parsed = JSON.parse(result.stdout) as { - openai?: string; - github?: string; - hf?: string; - openclaw?: string; - shell?: string; - }; expect(parsed.openai).toBeUndefined(); expect(parsed.github).toBeUndefined(); expect(parsed.hf).toBeUndefined(); @@ -325,29 +347,16 @@ describe("spawnAndCollect", () => { }); it("strips provider auth env vars case-insensitively", async () => { - vi.stubEnv("OpenAI_Api_Key", "openai-secret"); - vi.stubEnv("Github_Token", "gh-secret"); - vi.stubEnv("OPENCLAW_API_KEY", "keep-me"); - - const result = await spawnAndCollect({ - command: process.execPath, - args: [ - "-e", - "process.stdout.write(JSON.stringify({openai:process.env.OpenAI_Api_Key,github:process.env.Github_Token,openclaw:process.env.OPENCLAW_API_KEY,shell:process.env.OPENCLAW_SHELL}))", - ], - cwd: process.cwd(), - stripProviderAuthEnvVars: true, + stubProviderAuthEnv({ + OpenAI_Api_Key: "openai-secret", + Github_Token: "gh-secret", + OPENCLAW_API_KEY: "keep-me", + }); + const parsed = await collectSpawnedEnvSnapshot({ + stripProviderAuthEnvVars: true, + openAiEnvKey: "OpenAI_Api_Key", + githubEnvKey: "Github_Token", }); - - expect(result.code).toBe(0); - expect(result.error).toBeNull(); - - const parsed = JSON.parse(result.stdout) as { - openai?: string; - github?: string; - openclaw?: string; - shell?: string; - }; expect(parsed.openai).toBeUndefined(); expect(parsed.github).toBeUndefined(); expect(parsed.openclaw).toBe("keep-me"); @@ -355,30 +364,13 @@ describe("spawnAndCollect", () => { }); it("preserves provider auth env vars for explicit custom commands by default", async () => { - vi.stubEnv("OPENAI_API_KEY", "openai-secret"); - vi.stubEnv("GITHUB_TOKEN", "gh-secret"); - vi.stubEnv("HF_TOKEN", "hf-secret"); - vi.stubEnv("OPENCLAW_API_KEY", "keep-me"); - - const result = await spawnAndCollect({ - command: process.execPath, - args: [ - "-e", - "process.stdout.write(JSON.stringify({openai:process.env.OPENAI_API_KEY,github:process.env.GITHUB_TOKEN,hf:process.env.HF_TOKEN,openclaw:process.env.OPENCLAW_API_KEY,shell:process.env.OPENCLAW_SHELL}))", - ], - cwd: process.cwd(), + stubProviderAuthEnv({ + OPENAI_API_KEY: "openai-secret", + GITHUB_TOKEN: "gh-secret", + HF_TOKEN: "hf-secret", + OPENCLAW_API_KEY: "keep-me", }); - - expect(result.code).toBe(0); - expect(result.error).toBeNull(); - - const parsed = JSON.parse(result.stdout) as { - openai?: string; - github?: string; - hf?: string; - openclaw?: string; - shell?: string; - }; + const parsed = await collectSpawnedEnvSnapshot(); expect(parsed.openai).toBe("openai-secret"); expect(parsed.github).toBe("gh-secret"); expect(parsed.hf).toBe("hf-secret"); From d347a4426d8eb5caf4465167df05f2ee9323b135 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 20:45:22 +0000 Subject: [PATCH 027/461] refactor: share twitch outbound target assertions --- extensions/twitch/src/outbound.test.ts | 114 +++++++++++++------------ 1 file changed, 59 insertions(+), 55 deletions(-) diff --git a/extensions/twitch/src/outbound.test.ts b/extensions/twitch/src/outbound.test.ts index 7b480df32dd..f58e2d1ad48 100644 --- a/extensions/twitch/src/outbound.test.ts +++ b/extensions/twitch/src/outbound.test.ts @@ -46,6 +46,20 @@ function assertResolvedTarget( return result.to; } +function expectTargetError( + resolveTarget: NonNullable, + params: Parameters>[0], + expectedMessage: string, +) { + const result = resolveTarget(params); + + expect(result.ok).toBe(false); + if (result.ok) { + throw new Error("expected resolveTarget to fail"); + } + expect(result.error.message).toContain(expectedMessage); +} + describe("outbound", () => { const mockAccount = { ...BASE_TWITCH_TEST_ACCOUNT, @@ -106,17 +120,15 @@ describe("outbound", () => { }); it("should error when target not in allowlist (implicit mode)", () => { - const result = resolveTarget({ - to: "#notallowed", - mode: "implicit", - allowFrom: ["#primary", "#secondary"], - }); - - expect(result.ok).toBe(false); - if (result.ok) { - throw new Error("expected resolveTarget to fail"); - } - expect(result.error.message).toContain("Twitch"); + expectTargetError( + resolveTarget, + { + to: "#notallowed", + mode: "implicit", + allowFrom: ["#primary", "#secondary"], + }, + "Twitch", + ); }); it("should accept any target when allowlist is empty", () => { @@ -131,59 +143,51 @@ describe("outbound", () => { }); it("should error when no target provided with allowlist", () => { - const result = resolveTarget({ - to: undefined, - mode: "implicit", - allowFrom: ["#fallback", "#other"], - }); - - expect(result.ok).toBe(false); - if (result.ok) { - throw new Error("expected resolveTarget to fail"); - } - expect(result.error.message).toContain("Twitch"); + expectTargetError( + resolveTarget, + { + to: undefined, + mode: "implicit", + allowFrom: ["#fallback", "#other"], + }, + "Twitch", + ); }); it("should return error when no target and no allowlist", () => { - const result = resolveTarget({ - to: undefined, - mode: "explicit", - allowFrom: [], - }); - - expect(result.ok).toBe(false); - if (result.ok) { - throw new Error("expected resolveTarget to fail"); - } - expect(result.error.message).toContain("Missing target"); + expectTargetError( + resolveTarget, + { + to: undefined, + mode: "explicit", + allowFrom: [], + }, + "Missing target", + ); }); it("should handle whitespace-only target", () => { - const result = resolveTarget({ - to: " ", - mode: "explicit", - allowFrom: [], - }); - - expect(result.ok).toBe(false); - if (result.ok) { - throw new Error("expected resolveTarget to fail"); - } - expect(result.error.message).toContain("Missing target"); + expectTargetError( + resolveTarget, + { + to: " ", + mode: "explicit", + allowFrom: [], + }, + "Missing target", + ); }); it("should error when target normalizes to empty string", () => { - const result = resolveTarget({ - to: "#", - mode: "explicit", - allowFrom: [], - }); - - expect(result.ok).toBe(false); - if (result.ok) { - throw new Error("expected resolveTarget to fail"); - } - expect(result.error.message).toContain("Twitch"); + expectTargetError( + resolveTarget, + { + to: "#", + mode: "explicit", + allowFrom: [], + }, + "Twitch", + ); }); it("should filter wildcard from allowlist when checking membership", () => { From 3ffb9f19cbd37796fa39fb9e9f929eec24d311f8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 20:46:27 +0000 Subject: [PATCH 028/461] test: reduce feishu reply dispatcher duplication --- .../feishu/src/reply-dispatcher.test.ts | 153 +++++------------- 1 file changed, 44 insertions(+), 109 deletions(-) diff --git a/extensions/feishu/src/reply-dispatcher.test.ts b/extensions/feishu/src/reply-dispatcher.test.ts index 744532320de..e6f7fd4d974 100644 --- a/extensions/feishu/src/reply-dispatcher.test.ts +++ b/extensions/feishu/src/reply-dispatcher.test.ts @@ -63,6 +63,8 @@ vi.mock("./streaming-card.js", () => ({ import { createFeishuReplyDispatcher } from "./reply-dispatcher.js"; describe("createFeishuReplyDispatcher streaming behavior", () => { + type ReplyDispatcherArgs = Parameters[0]; + beforeEach(() => { vi.clearAllMocks(); streamingInstances.length = 0; @@ -128,6 +130,25 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { return createReplyDispatcherWithTypingMock.mock.calls[0]?.[0]; } + function createRuntimeLogger() { + return { log: vi.fn(), error: vi.fn() } as never; + } + + function createDispatcherHarness(overrides: Partial = {}) { + const result = createFeishuReplyDispatcher({ + cfg: {} as never, + agentId: "agent", + runtime: {} as never, + chatId: "oc_chat", + ...overrides, + }); + + return { + result, + options: createReplyDispatcherWithTypingMock.mock.calls.at(-1)?.[0], + }; + } + it("skips typing indicator when account typingIndicator is disabled", async () => { resolveFeishuAccountMock.mockReturnValue({ accountId: "main", @@ -209,14 +230,7 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { }); it("keeps auto mode plain text on non-streaming send path", async () => { - createFeishuReplyDispatcher({ - cfg: {} as never, - agentId: "agent", - runtime: {} as never, - chatId: "oc_chat", - }); - - const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0]; + const { options } = createDispatcherHarness(); await options.deliver({ text: "plain text" }, { kind: "final" }); expect(streamingInstances).toHaveLength(0); @@ -225,14 +239,7 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { }); it("suppresses internal block payload delivery", async () => { - createFeishuReplyDispatcher({ - cfg: {} as never, - agentId: "agent", - runtime: {} as never, - chatId: "oc_chat", - }); - - const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0]; + const { options } = createDispatcherHarness(); await options.deliver({ text: "internal reasoning chunk" }, { kind: "block" }); expect(streamingInstances).toHaveLength(0); @@ -253,15 +260,10 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { }); it("uses streaming session for auto mode markdown payloads", async () => { - createFeishuReplyDispatcher({ - cfg: {} as never, - agentId: "agent", - runtime: { log: vi.fn(), error: vi.fn() } as never, - chatId: "oc_chat", + const { options } = createDispatcherHarness({ + runtime: createRuntimeLogger(), rootId: "om_root_topic", }); - - const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0]; await options.deliver({ text: "```ts\nconst x = 1\n```" }, { kind: "final" }); expect(streamingInstances).toHaveLength(1); @@ -277,14 +279,9 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { }); it("closes streaming with block text when final reply is missing", async () => { - createFeishuReplyDispatcher({ - cfg: {} as never, - agentId: "agent", - runtime: { log: vi.fn(), error: vi.fn() } as never, - chatId: "oc_chat", + const { options } = createDispatcherHarness({ + runtime: createRuntimeLogger(), }); - - const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0]; await options.deliver({ text: "```md\npartial answer\n```" }, { kind: "block" }); await options.onIdle?.(); @@ -295,14 +292,9 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { }); it("delivers distinct final payloads after streaming close", async () => { - createFeishuReplyDispatcher({ - cfg: {} as never, - agentId: "agent", - runtime: { log: vi.fn(), error: vi.fn() } as never, - chatId: "oc_chat", + const { options } = createDispatcherHarness({ + runtime: createRuntimeLogger(), }); - - const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0]; await options.deliver({ text: "```md\n完整回复第一段\n```" }, { kind: "final" }); await options.deliver({ text: "```md\n完整回复第一段 + 第二段\n```" }, { kind: "final" }); @@ -316,14 +308,9 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { }); it("skips exact duplicate final text after streaming close", async () => { - createFeishuReplyDispatcher({ - cfg: {} as never, - agentId: "agent", - runtime: { log: vi.fn(), error: vi.fn() } as never, - chatId: "oc_chat", + const { options } = createDispatcherHarness({ + runtime: createRuntimeLogger(), }); - - const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0]; await options.deliver({ text: "```md\n同一条回复\n```" }, { kind: "final" }); await options.deliver({ text: "```md\n同一条回复\n```" }, { kind: "final" }); @@ -383,14 +370,9 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { }, }); - const result = createFeishuReplyDispatcher({ - cfg: {} as never, - agentId: "agent", - runtime: { log: vi.fn(), error: vi.fn() } as never, - chatId: "oc_chat", + const { result, options } = createDispatcherHarness({ + runtime: createRuntimeLogger(), }); - - const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0]; await options.onReplyStart?.(); await result.replyOptions.onPartialReply?.({ text: "hello" }); await options.deliver({ text: "lo world" }, { kind: "block" }); @@ -402,14 +384,7 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { }); it("sends media-only payloads as attachments", async () => { - createFeishuReplyDispatcher({ - cfg: {} as never, - agentId: "agent", - runtime: {} as never, - chatId: "oc_chat", - }); - - const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0]; + const { options } = createDispatcherHarness(); await options.deliver({ mediaUrl: "https://example.com/a.png" }, { kind: "final" }); expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1); @@ -424,14 +399,7 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { }); it("falls back to legacy mediaUrl when mediaUrls is an empty array", async () => { - createFeishuReplyDispatcher({ - cfg: {} as never, - agentId: "agent", - runtime: {} as never, - chatId: "oc_chat", - }); - - const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0]; + const { options } = createDispatcherHarness(); await options.deliver( { text: "caption", mediaUrl: "https://example.com/a.png", mediaUrls: [] }, { kind: "final" }, @@ -447,14 +415,9 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { }); it("sends attachments after streaming final markdown replies", async () => { - createFeishuReplyDispatcher({ - cfg: {} as never, - agentId: "agent", - runtime: { log: vi.fn(), error: vi.fn() } as never, - chatId: "oc_chat", + const { options } = createDispatcherHarness({ + runtime: createRuntimeLogger(), }); - - const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0]; await options.deliver( { text: "```ts\nconst x = 1\n```", mediaUrls: ["https://example.com/a.png"] }, { kind: "final" }, @@ -472,16 +435,10 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { }); it("passes replyInThread to sendMessageFeishu for plain text", async () => { - createFeishuReplyDispatcher({ - cfg: {} as never, - agentId: "agent", - runtime: {} as never, - chatId: "oc_chat", + const { options } = createDispatcherHarness({ replyToMessageId: "om_msg", replyInThread: true, }); - - const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0]; await options.deliver({ text: "plain text" }, { kind: "final" }); expect(sendMessageFeishuMock).toHaveBeenCalledWith( @@ -504,16 +461,10 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { }, }); - createFeishuReplyDispatcher({ - cfg: {} as never, - agentId: "agent", - runtime: {} as never, - chatId: "oc_chat", + const { options } = createDispatcherHarness({ replyToMessageId: "om_msg", replyInThread: true, }); - - const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0]; await options.deliver({ text: "card text" }, { kind: "final" }); expect(sendMarkdownCardFeishuMock).toHaveBeenCalledWith( @@ -525,16 +476,11 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { }); it("passes replyToMessageId and replyInThread to streaming.start()", async () => { - createFeishuReplyDispatcher({ - cfg: {} as never, - agentId: "agent", - runtime: { log: vi.fn(), error: vi.fn() } as never, - chatId: "oc_chat", + const { options } = createDispatcherHarness({ + runtime: createRuntimeLogger(), replyToMessageId: "om_msg", replyInThread: true, }); - - const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0]; await options.deliver({ text: "```ts\nconst x = 1\n```" }, { kind: "final" }); expect(streamingInstances).toHaveLength(1); @@ -545,18 +491,13 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { }); it("disables streaming for thread replies and keeps reply metadata", async () => { - createFeishuReplyDispatcher({ - cfg: {} as never, - agentId: "agent", - runtime: { log: vi.fn(), error: vi.fn() } as never, - chatId: "oc_chat", + const { options } = createDispatcherHarness({ + runtime: createRuntimeLogger(), replyToMessageId: "om_msg", replyInThread: false, threadReply: true, rootId: "om_root_topic", }); - - const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0]; await options.deliver({ text: "```ts\nconst x = 1\n```" }, { kind: "final" }); expect(streamingInstances).toHaveLength(0); @@ -569,16 +510,10 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { }); it("passes replyInThread to media attachments", async () => { - createFeishuReplyDispatcher({ - cfg: {} as never, - agentId: "agent", - runtime: {} as never, - chatId: "oc_chat", + const { options } = createDispatcherHarness({ replyToMessageId: "om_msg", replyInThread: true, }); - - const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0]; await options.deliver({ mediaUrl: "https://example.com/a.png" }, { kind: "final" }); expect(sendMediaFeishuMock).toHaveBeenCalledWith( From 9ecd1898d03233f3df06c57750f7927f723f3e56 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 20:47:28 +0000 Subject: [PATCH 029/461] refactor: share telegram channel test harnesses --- extensions/telegram/src/channel.test.ts | 78 ++++++++++++------------- 1 file changed, 38 insertions(+), 40 deletions(-) diff --git a/extensions/telegram/src/channel.test.ts b/extensions/telegram/src/channel.test.ts index f0736069015..a957a3e5b1c 100644 --- a/extensions/telegram/src/channel.test.ts +++ b/extensions/telegram/src/channel.test.ts @@ -91,6 +91,30 @@ function installGatewayRuntime(params?: { probeOk?: boolean; botUsername?: strin }; } +function configureOpsProxyNetwork(cfg: OpenClawConfig) { + cfg.channels!.telegram!.accounts!.ops = { + ...cfg.channels!.telegram!.accounts!.ops, + proxy: "http://127.0.0.1:8888", + network: { + autoSelectFamily: false, + dnsResultOrder: "ipv4first", + }, + }; +} + +function installSendMessageRuntime( + sendMessageTelegram: ReturnType, +): ReturnType { + setTelegramRuntime({ + channel: { + telegram: { + sendMessageTelegram, + }, + }, + } as unknown as PluginRuntime); + return sendMessageTelegram; +} + describe("telegramPlugin duplicate token guard", () => { it("marks secondary account as not configured when token is shared", async () => { const cfg = createCfg(); @@ -176,14 +200,7 @@ describe("telegramPlugin duplicate token guard", () => { }); const cfg = createCfg(); - cfg.channels!.telegram!.accounts!.ops = { - ...cfg.channels!.telegram!.accounts!.ops, - proxy: "http://127.0.0.1:8888", - network: { - autoSelectFamily: false, - dnsResultOrder: "ipv4first", - }, - }; + configureOpsProxyNetwork(cfg); const account = telegramPlugin.config.resolveAccount(cfg, "ops"); await telegramPlugin.status!.probeAccount!({ @@ -215,13 +232,9 @@ describe("telegramPlugin duplicate token guard", () => { }); const cfg = createCfg(); + configureOpsProxyNetwork(cfg); cfg.channels!.telegram!.accounts!.ops = { ...cfg.channels!.telegram!.accounts!.ops, - proxy: "http://127.0.0.1:8888", - network: { - autoSelectFamily: false, - dnsResultOrder: "ipv4first", - }, groups: { "-100123": { requireMention: false }, }, @@ -249,14 +262,9 @@ describe("telegramPlugin duplicate token guard", () => { }); it("forwards mediaLocalRoots to sendMessageTelegram for outbound media sends", async () => { - const sendMessageTelegram = vi.fn(async () => ({ messageId: "tg-1" })); - setTelegramRuntime({ - channel: { - telegram: { - sendMessageTelegram, - }, - }, - } as unknown as PluginRuntime); + const sendMessageTelegram = installSendMessageRuntime( + vi.fn(async () => ({ messageId: "tg-1" })), + ); const result = await telegramPlugin.outbound!.sendMedia!({ cfg: createCfg(), @@ -279,14 +287,9 @@ describe("telegramPlugin duplicate token guard", () => { }); it("preserves buttons for outbound text payload sends", async () => { - const sendMessageTelegram = vi.fn(async () => ({ messageId: "tg-2" })); - setTelegramRuntime({ - channel: { - telegram: { - sendMessageTelegram, - }, - }, - } as unknown as PluginRuntime); + const sendMessageTelegram = installSendMessageRuntime( + vi.fn(async () => ({ messageId: "tg-2" })), + ); const result = await telegramPlugin.outbound!.sendPayload!({ cfg: createCfg(), @@ -314,17 +317,12 @@ describe("telegramPlugin duplicate token guard", () => { }); it("sends outbound payload media lists and keeps buttons on the first message only", async () => { - const sendMessageTelegram = vi - .fn() - .mockResolvedValueOnce({ messageId: "tg-3", chatId: "12345" }) - .mockResolvedValueOnce({ messageId: "tg-4", chatId: "12345" }); - setTelegramRuntime({ - channel: { - telegram: { - sendMessageTelegram, - }, - }, - } as unknown as PluginRuntime); + const sendMessageTelegram = installSendMessageRuntime( + vi + .fn() + .mockResolvedValueOnce({ messageId: "tg-3", chatId: "12345" }) + .mockResolvedValueOnce({ messageId: "tg-4", chatId: "12345" }), + ); const result = await telegramPlugin.outbound!.sendPayload!({ cfg: createCfg(), From d4d00917608ae51d23e4c7a8090efcdeade68c4e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 20:48:54 +0000 Subject: [PATCH 030/461] test: share msteams safe fetch assertions --- .../msteams/src/attachments/shared.test.ts | 31 ++++++++++++++----- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/extensions/msteams/src/attachments/shared.test.ts b/extensions/msteams/src/attachments/shared.test.ts index 186a70f71aa..3e29e65aac4 100644 --- a/extensions/msteams/src/attachments/shared.test.ts +++ b/extensions/msteams/src/attachments/shared.test.ts @@ -31,6 +31,23 @@ function mockFetchWithRedirect(redirectMap: Record, finalBody = }); } +async function expectSafeFetchStatus(params: { + fetchMock: ReturnType; + url: string; + allowHosts: string[]; + expectedStatus: number; + resolveFn?: typeof publicResolve; +}) { + const res = await safeFetch({ + url: params.url, + allowHosts: params.allowHosts, + fetchFn: params.fetchMock as unknown as typeof fetch, + resolveFn: params.resolveFn ?? publicResolve, + }); + expect(res.status).toBe(params.expectedStatus); + return res; +} + describe("msteams attachment allowlists", () => { it("normalizes wildcard host lists", () => { expect(resolveAllowedHosts(["*", "graph.microsoft.com"])).toEqual(["*"]); @@ -121,13 +138,12 @@ describe("safeFetch", () => { const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => { return new Response("ok", { status: 200 }); }); - const res = await safeFetch({ + await expectSafeFetchStatus({ + fetchMock, url: "https://teams.sharepoint.com/file.pdf", allowHosts: ["sharepoint.com"], - fetchFn: fetchMock as unknown as typeof fetch, - resolveFn: publicResolve, + expectedStatus: 200, }); - expect(res.status).toBe(200); expect(fetchMock).toHaveBeenCalledOnce(); // Should have used redirect: "manual" expect(fetchMock.mock.calls[0][1]).toHaveProperty("redirect", "manual"); @@ -137,13 +153,12 @@ describe("safeFetch", () => { const fetchMock = mockFetchWithRedirect({ "https://teams.sharepoint.com/file.pdf": "https://cdn.sharepoint.com/storage/file.pdf", }); - const res = await safeFetch({ + await expectSafeFetchStatus({ + fetchMock, url: "https://teams.sharepoint.com/file.pdf", allowHosts: ["sharepoint.com"], - fetchFn: fetchMock as unknown as typeof fetch, - resolveFn: publicResolve, + expectedStatus: 200, }); - expect(res.status).toBe(200); expect(fetchMock).toHaveBeenCalledTimes(2); }); From 12432ca1389e12918c2d60a9393e8fde5192c588 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 20:50:09 +0000 Subject: [PATCH 031/461] test: share googlechat webhook routing helpers --- .../src/monitor.webhook-routing.test.ts | 92 +++++++++++-------- 1 file changed, 52 insertions(+), 40 deletions(-) diff --git a/extensions/googlechat/src/monitor.webhook-routing.test.ts b/extensions/googlechat/src/monitor.webhook-routing.test.ts index 812883f1b4c..9896efce645 100644 --- a/extensions/googlechat/src/monitor.webhook-routing.test.ts +++ b/extensions/googlechat/src/monitor.webhook-routing.test.ts @@ -117,6 +117,34 @@ function registerTwoTargets() { }; } +async function dispatchWebhookRequest(req: IncomingMessage) { + const res = createMockServerResponse(); + const handled = await handleGoogleChatWebhookRequest(req, res); + expect(handled).toBe(true); + return res; +} + +async function expectVerifiedRoute(params: { + request: IncomingMessage; + expectedStatus: number; + sinkA: ReturnType; + sinkB: ReturnType; + expectedSink: "none" | "A" | "B"; +}) { + const res = await dispatchWebhookRequest(params.request); + expect(res.statusCode).toBe(params.expectedStatus); + const expectedCounts = + params.expectedSink === "A" ? [1, 0] : params.expectedSink === "B" ? [0, 1] : [0, 0]; + expect(params.sinkA).toHaveBeenCalledTimes(expectedCounts[0]); + expect(params.sinkB).toHaveBeenCalledTimes(expectedCounts[1]); +} + +function mockSecondVerifierSuccess() { + vi.mocked(verifyGoogleChatRequest) + .mockResolvedValueOnce({ ok: false, reason: "invalid" }) + .mockResolvedValueOnce({ ok: true }); +} + describe("Google Chat webhook routing", () => { afterEach(() => { setActivePluginRegistry(createEmptyPluginRegistry()); @@ -165,45 +193,37 @@ describe("Google Chat webhook routing", () => { const { sinkA, sinkB, unregister } = registerTwoTargets(); try { - const res = createMockServerResponse(); - const handled = await handleGoogleChatWebhookRequest( - createWebhookRequest({ + await expectVerifiedRoute({ + request: createWebhookRequest({ authorization: "Bearer test-token", payload: { type: "ADDED_TO_SPACE", space: { name: "spaces/AAA" } }, }), - res, - ); - - expect(handled).toBe(true); - expect(res.statusCode).toBe(401); - expect(sinkA).not.toHaveBeenCalled(); - expect(sinkB).not.toHaveBeenCalled(); + expectedStatus: 401, + sinkA, + sinkB, + expectedSink: "none", + }); } finally { unregister(); } }); it("routes to the single verified target when earlier targets fail verification", async () => { - vi.mocked(verifyGoogleChatRequest) - .mockResolvedValueOnce({ ok: false, reason: "invalid" }) - .mockResolvedValueOnce({ ok: true }); + mockSecondVerifierSuccess(); const { sinkA, sinkB, unregister } = registerTwoTargets(); try { - const res = createMockServerResponse(); - const handled = await handleGoogleChatWebhookRequest( - createWebhookRequest({ + await expectVerifiedRoute({ + request: createWebhookRequest({ authorization: "Bearer test-token", payload: { type: "ADDED_TO_SPACE", space: { name: "spaces/BBB" } }, }), - res, - ); - - expect(handled).toBe(true); - expect(res.statusCode).toBe(200); - expect(sinkA).not.toHaveBeenCalled(); - expect(sinkB).toHaveBeenCalledTimes(1); + expectedStatus: 200, + sinkA, + sinkB, + expectedSink: "B", + }); } finally { unregister(); } @@ -218,10 +238,7 @@ describe("Google Chat webhook routing", () => { authorization: "Bearer invalid-token", }); const onSpy = vi.spyOn(req, "on"); - const res = createMockServerResponse(); - const handled = await handleGoogleChatWebhookRequest(req, res); - - expect(handled).toBe(true); + const res = await dispatchWebhookRequest(req); expect(res.statusCode).toBe(401); expect(onSpy).not.toHaveBeenCalledWith("data", expect.any(Function)); } finally { @@ -230,15 +247,12 @@ describe("Google Chat webhook routing", () => { }); it("supports add-on requests that provide systemIdToken in the body", async () => { - vi.mocked(verifyGoogleChatRequest) - .mockResolvedValueOnce({ ok: false, reason: "invalid" }) - .mockResolvedValueOnce({ ok: true }); + mockSecondVerifierSuccess(); const { sinkA, sinkB, unregister } = registerTwoTargets(); try { - const res = createMockServerResponse(); - const handled = await handleGoogleChatWebhookRequest( - createWebhookRequest({ + await expectVerifiedRoute({ + request: createWebhookRequest({ payload: { commonEventObject: { hostApp: "CHAT" }, authorizationEventObject: { systemIdToken: "addon-token" }, @@ -252,13 +266,11 @@ describe("Google Chat webhook routing", () => { }, }, }), - res, - ); - - expect(handled).toBe(true); - expect(res.statusCode).toBe(200); - expect(sinkA).not.toHaveBeenCalled(); - expect(sinkB).toHaveBeenCalledTimes(1); + expectedStatus: 200, + sinkA, + sinkB, + expectedSink: "B", + }); } finally { unregister(); } From 71639d1744dc3f9b16518ff2f1aaad4528c0d43d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 20:50:50 +0000 Subject: [PATCH 032/461] test: share lobster windows spawn assertions --- extensions/lobster/src/windows-spawn.test.ts | 27 +++++++++++--------- 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/extensions/lobster/src/windows-spawn.test.ts b/extensions/lobster/src/windows-spawn.test.ts index e3d791e36e4..48e6ddc9a54 100644 --- a/extensions/lobster/src/windows-spawn.test.ts +++ b/extensions/lobster/src/windows-spawn.test.ts @@ -14,6 +14,19 @@ describe("resolveWindowsLobsterSpawn", () => { let tempDir = ""; const originalProcessState = snapshotPlatformPathEnv(); + async function expectUnwrappedShim(params: { + scriptPath: string; + shimPath: string; + shimLine: string; + }) { + await createWindowsCmdShimFixture(params); + + const target = resolveWindowsLobsterSpawn(params.shimPath, ["run", "noop"], process.env); + expect(target.command).toBe(process.execPath); + expect(target.argv).toEqual([params.scriptPath, "run", "noop"]); + expect(target.windowsHide).toBe(true); + } + beforeEach(async () => { tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lobster-win-spawn-")); setProcessPlatform("win32"); @@ -30,31 +43,21 @@ describe("resolveWindowsLobsterSpawn", () => { it("unwraps cmd shim with %dp0% token", async () => { const scriptPath = path.join(tempDir, "shim-dist", "lobster-cli.cjs"); const shimPath = path.join(tempDir, "shim", "lobster.cmd"); - await createWindowsCmdShimFixture({ + await expectUnwrappedShim({ shimPath, scriptPath, shimLine: `"%dp0%\\..\\shim-dist\\lobster-cli.cjs" %*`, }); - - const target = resolveWindowsLobsterSpawn(shimPath, ["run", "noop"], process.env); - expect(target.command).toBe(process.execPath); - expect(target.argv).toEqual([scriptPath, "run", "noop"]); - expect(target.windowsHide).toBe(true); }); it("unwraps cmd shim with %~dp0% token", async () => { const scriptPath = path.join(tempDir, "shim-dist", "lobster-cli.cjs"); const shimPath = path.join(tempDir, "shim", "lobster.cmd"); - await createWindowsCmdShimFixture({ + await expectUnwrappedShim({ shimPath, scriptPath, shimLine: `"%~dp0%\\..\\shim-dist\\lobster-cli.cjs" %*`, }); - - const target = resolveWindowsLobsterSpawn(shimPath, ["run", "noop"], process.env); - expect(target.command).toBe(process.execPath); - expect(target.argv).toEqual([scriptPath, "run", "noop"]); - expect(target.windowsHide).toBe(true); }); it("ignores node.exe shim entries and picks lobster script", async () => { From 4269ea4e8d85c0a418d40185fc4563185f968430 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 20:51:39 +0000 Subject: [PATCH 033/461] test: share slack config snapshot helper --- extensions/slack/src/channel.test.ts | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/extensions/slack/src/channel.test.ts b/extensions/slack/src/channel.test.ts index b846d6e3cd7..98fbddca77d 100644 --- a/extensions/slack/src/channel.test.ts +++ b/extensions/slack/src/channel.test.ts @@ -15,6 +15,18 @@ vi.mock("./runtime.js", () => ({ import { slackPlugin } from "./channel.js"; +async function getSlackConfiguredState(cfg: OpenClawConfig) { + const account = slackPlugin.config.resolveAccount(cfg, "default"); + return { + configured: slackPlugin.config.isConfigured?.(account, cfg), + snapshot: await slackPlugin.status?.buildAccountSnapshot?.({ + account, + cfg, + runtime: undefined, + }), + }; +} + describe("slackPlugin actions", () => { it("prefers session lookup for announce target routing", () => { expect(slackPlugin.meta.preferSessionLookupForAnnounceTarget).toBe(true); @@ -189,13 +201,7 @@ describe("slackPlugin config", () => { }, }; - const account = slackPlugin.config.resolveAccount(cfg, "default"); - const configured = slackPlugin.config.isConfigured?.(account, cfg); - const snapshot = await slackPlugin.status?.buildAccountSnapshot?.({ - account, - cfg, - runtime: undefined, - }); + const { configured, snapshot } = await getSlackConfiguredState(cfg); expect(configured).toBe(true); expect(snapshot?.configured).toBe(true); @@ -211,13 +217,7 @@ describe("slackPlugin config", () => { }, }; - const account = slackPlugin.config.resolveAccount(cfg, "default"); - const configured = slackPlugin.config.isConfigured?.(account, cfg); - const snapshot = await slackPlugin.status?.buildAccountSnapshot?.({ - account, - cfg, - runtime: undefined, - }); + const { configured, snapshot } = await getSlackConfiguredState(cfg); expect(configured).toBe(false); expect(snapshot?.configured).toBe(false); From 1301462a1bcf53a7d3dff8bb1006d5bf354479f4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 20:53:57 +0000 Subject: [PATCH 034/461] refactor: share acp persistent binding fixtures --- src/acp/persistent-bindings.test.ts | 549 ++++++++++++---------------- 1 file changed, 230 insertions(+), 319 deletions(-) diff --git a/src/acp/persistent-bindings.test.ts b/src/acp/persistent-bindings.test.ts index deafbc53e15..30e74c05082 100644 --- a/src/acp/persistent-bindings.test.ts +++ b/src/acp/persistent-bindings.test.ts @@ -30,6 +30,10 @@ import { resolveConfiguredAcpBindingSpecBySessionKey, } from "./persistent-bindings.js"; +type ConfiguredBinding = NonNullable[number]; +type BindingRecordInput = Parameters[0]; +type BindingSpec = Parameters[0]["spec"]; + const baseCfg = { session: { mainKey: "main", scope: "per-sender" }, agents: { @@ -37,6 +41,105 @@ const baseCfg = { }, } satisfies OpenClawConfig; +const defaultDiscordConversationId = "1478836151241412759"; +const defaultDiscordAccountId = "default"; + +function createCfgWithBindings( + bindings: ConfiguredBinding[], + overrides?: Partial, +): OpenClawConfig { + return { + ...baseCfg, + ...overrides, + bindings, + } as OpenClawConfig; +} + +function createDiscordBinding(params: { + agentId: string; + conversationId: string; + accountId?: string; + acp?: Record; +}): ConfiguredBinding { + return { + type: "acp", + agentId: params.agentId, + match: { + channel: "discord", + accountId: params.accountId ?? defaultDiscordAccountId, + peer: { kind: "channel", id: params.conversationId }, + }, + ...(params.acp ? { acp: params.acp } : {}), + } as ConfiguredBinding; +} + +function createTelegramGroupBinding(params: { + agentId: string; + conversationId: string; + acp?: Record; +}): ConfiguredBinding { + return { + type: "acp", + agentId: params.agentId, + match: { + channel: "telegram", + accountId: defaultDiscordAccountId, + peer: { kind: "group", id: params.conversationId }, + }, + ...(params.acp ? { acp: params.acp } : {}), + } as ConfiguredBinding; +} + +function resolveBindingRecord(cfg: OpenClawConfig, overrides: Partial = {}) { + return resolveConfiguredAcpBindingRecord({ + cfg, + channel: "discord", + accountId: defaultDiscordAccountId, + conversationId: defaultDiscordConversationId, + ...overrides, + }); +} + +function resolveDiscordBindingSpecBySession( + cfg: OpenClawConfig, + conversationId = defaultDiscordConversationId, +) { + const resolved = resolveBindingRecord(cfg, { conversationId }); + return resolveConfiguredAcpBindingSpecBySessionKey({ + cfg, + sessionKey: resolved?.record.targetSessionKey ?? "", + }); +} + +function createDiscordPersistentSpec(overrides: Partial = {}): BindingSpec { + return { + channel: "discord", + accountId: defaultDiscordAccountId, + conversationId: defaultDiscordConversationId, + agentId: "codex", + mode: "persistent", + ...overrides, + } as BindingSpec; +} + +function mockReadySession(params: { spec: BindingSpec; cwd: string }) { + const sessionKey = buildConfiguredAcpSessionKey(params.spec); + managerMocks.resolveSession.mockReturnValue({ + kind: "ready", + sessionKey, + meta: { + backend: "acpx", + agent: params.spec.acpAgentId ?? params.spec.agentId, + runtimeSessionName: "existing", + mode: params.spec.mode, + runtimeOptions: { cwd: params.cwd }, + state: "idle", + lastActivityAt: Date.now(), + }, + }); + return sessionKey; +} + beforeEach(() => { managerMocks.resolveSession.mockReset(); managerMocks.closeSession.mockReset().mockResolvedValue({ @@ -50,58 +153,30 @@ beforeEach(() => { describe("resolveConfiguredAcpBindingRecord", () => { it("resolves discord channel ACP binding from top-level typed bindings", () => { - const cfg = { - ...baseCfg, - bindings: [ - { - type: "acp", - agentId: "codex", - match: { - channel: "discord", - accountId: "default", - peer: { kind: "channel", id: "1478836151241412759" }, - }, - acp: { - cwd: "/repo/openclaw", - }, - }, - ], - } satisfies OpenClawConfig; - - const resolved = resolveConfiguredAcpBindingRecord({ - cfg, - channel: "discord", - accountId: "default", - conversationId: "1478836151241412759", - }); + const cfg = createCfgWithBindings([ + createDiscordBinding({ + agentId: "codex", + conversationId: defaultDiscordConversationId, + acp: { cwd: "/repo/openclaw" }, + }), + ]); + const resolved = resolveBindingRecord(cfg); expect(resolved?.spec.channel).toBe("discord"); - expect(resolved?.spec.conversationId).toBe("1478836151241412759"); + expect(resolved?.spec.conversationId).toBe(defaultDiscordConversationId); expect(resolved?.spec.agentId).toBe("codex"); expect(resolved?.record.targetSessionKey).toContain("agent:codex:acp:binding:discord:default:"); expect(resolved?.record.metadata?.source).toBe("config"); }); it("falls back to parent discord channel when conversation is a thread id", () => { - const cfg = { - ...baseCfg, - bindings: [ - { - type: "acp", - agentId: "codex", - match: { - channel: "discord", - accountId: "default", - peer: { kind: "channel", id: "channel-parent-1" }, - }, - }, - ], - } satisfies OpenClawConfig; - - const resolved = resolveConfiguredAcpBindingRecord({ - cfg, - channel: "discord", - accountId: "default", + const cfg = createCfgWithBindings([ + createDiscordBinding({ + agentId: "codex", + conversationId: "channel-parent-1", + }), + ]); + const resolved = resolveBindingRecord(cfg, { conversationId: "thread-123", parentConversationId: "channel-parent-1", }); @@ -111,34 +186,17 @@ describe("resolveConfiguredAcpBindingRecord", () => { }); it("prefers direct discord thread binding over parent channel fallback", () => { - const cfg = { - ...baseCfg, - bindings: [ - { - type: "acp", - agentId: "codex", - match: { - channel: "discord", - accountId: "default", - peer: { kind: "channel", id: "channel-parent-1" }, - }, - }, - { - type: "acp", - agentId: "claude", - match: { - channel: "discord", - accountId: "default", - peer: { kind: "channel", id: "thread-123" }, - }, - }, - ], - } satisfies OpenClawConfig; - - const resolved = resolveConfiguredAcpBindingRecord({ - cfg, - channel: "discord", - accountId: "default", + const cfg = createCfgWithBindings([ + createDiscordBinding({ + agentId: "codex", + conversationId: "channel-parent-1", + }), + createDiscordBinding({ + agentId: "claude", + conversationId: "thread-123", + }), + ]); + const resolved = resolveBindingRecord(cfg, { conversationId: "thread-123", parentConversationId: "channel-parent-1", }); @@ -148,60 +206,30 @@ describe("resolveConfiguredAcpBindingRecord", () => { }); it("prefers exact account binding over wildcard for the same discord conversation", () => { - const cfg = { - ...baseCfg, - bindings: [ - { - type: "acp", - agentId: "codex", - match: { - channel: "discord", - accountId: "*", - peer: { kind: "channel", id: "1478836151241412759" }, - }, - }, - { - type: "acp", - agentId: "claude", - match: { - channel: "discord", - accountId: "default", - peer: { kind: "channel", id: "1478836151241412759" }, - }, - }, - ], - } satisfies OpenClawConfig; - - const resolved = resolveConfiguredAcpBindingRecord({ - cfg, - channel: "discord", - accountId: "default", - conversationId: "1478836151241412759", - }); + const cfg = createCfgWithBindings([ + createDiscordBinding({ + agentId: "codex", + conversationId: defaultDiscordConversationId, + accountId: "*", + }), + createDiscordBinding({ + agentId: "claude", + conversationId: defaultDiscordConversationId, + }), + ]); + const resolved = resolveBindingRecord(cfg); expect(resolved?.spec.agentId).toBe("claude"); }); it("returns null when no top-level ACP binding matches the conversation", () => { - const cfg = { - ...baseCfg, - bindings: [ - { - type: "acp", - agentId: "codex", - match: { - channel: "discord", - accountId: "default", - peer: { kind: "channel", id: "different-channel" }, - }, - }, - ], - } satisfies OpenClawConfig; - - const resolved = resolveConfiguredAcpBindingRecord({ - cfg, - channel: "discord", - accountId: "default", + const cfg = createCfgWithBindings([ + createDiscordBinding({ + agentId: "codex", + conversationId: "different-channel", + }), + ]); + const resolved = resolveBindingRecord(cfg, { conversationId: "thread-123", parentConversationId: "channel-parent-1", }); @@ -210,23 +238,13 @@ describe("resolveConfiguredAcpBindingRecord", () => { }); it("resolves telegram forum topic bindings using canonical conversation ids", () => { - const cfg = { - ...baseCfg, - bindings: [ - { - type: "acp", - agentId: "claude", - match: { - channel: "telegram", - accountId: "default", - peer: { kind: "group", id: "-1001234567890:topic:42" }, - }, - acp: { - backend: "acpx", - }, - }, - ], - } satisfies OpenClawConfig; + const cfg = createCfgWithBindings([ + createTelegramGroupBinding({ + agentId: "claude", + conversationId: "-1001234567890:topic:42", + acp: { backend: "acpx" }, + }), + ]); const canonical = resolveConfiguredAcpBindingRecord({ cfg, @@ -250,20 +268,12 @@ describe("resolveConfiguredAcpBindingRecord", () => { }); it("skips telegram non-group topic configs", () => { - const cfg = { - ...baseCfg, - bindings: [ - { - type: "acp", - agentId: "claude", - match: { - channel: "telegram", - accountId: "default", - peer: { kind: "group", id: "123456789:topic:42" }, - }, - }, - ], - } satisfies OpenClawConfig; + const cfg = createCfgWithBindings([ + createTelegramGroupBinding({ + agentId: "claude", + conversationId: "123456789:topic:42", + }), + ]); const resolved = resolveConfiguredAcpBindingRecord({ cfg, @@ -275,44 +285,34 @@ describe("resolveConfiguredAcpBindingRecord", () => { }); it("applies agent runtime ACP defaults for bound conversations", () => { - const cfg = { - ...baseCfg, - agents: { - list: [ - { id: "main" }, - { - id: "coding", - runtime: { - type: "acp", - acp: { - agent: "codex", - backend: "acpx", - mode: "oneshot", - cwd: "/workspace/repo-a", + const cfg = createCfgWithBindings( + [ + createDiscordBinding({ + agentId: "coding", + conversationId: defaultDiscordConversationId, + }), + ], + { + agents: { + list: [ + { id: "main" }, + { + id: "coding", + runtime: { + type: "acp", + acp: { + agent: "codex", + backend: "acpx", + mode: "oneshot", + cwd: "/workspace/repo-a", + }, }, }, - }, - ], - }, - bindings: [ - { - type: "acp", - agentId: "coding", - match: { - channel: "discord", - accountId: "default", - peer: { kind: "channel", id: "1478836151241412759" }, - }, + ], }, - ], - } satisfies OpenClawConfig; - - const resolved = resolveConfiguredAcpBindingRecord({ - cfg, - channel: "discord", - accountId: "default", - conversationId: "1478836151241412759", - }); + }, + ); + const resolved = resolveBindingRecord(cfg); expect(resolved?.spec.agentId).toBe("coding"); expect(resolved?.spec.acpAgentId).toBe("codex"); @@ -324,37 +324,17 @@ describe("resolveConfiguredAcpBindingRecord", () => { describe("resolveConfiguredAcpBindingSpecBySessionKey", () => { it("maps a configured discord binding session key back to its spec", () => { - const cfg = { - ...baseCfg, - bindings: [ - { - type: "acp", - agentId: "codex", - match: { - channel: "discord", - accountId: "default", - peer: { kind: "channel", id: "1478836151241412759" }, - }, - acp: { - backend: "acpx", - }, - }, - ], - } satisfies OpenClawConfig; - - const resolved = resolveConfiguredAcpBindingRecord({ - cfg, - channel: "discord", - accountId: "default", - conversationId: "1478836151241412759", - }); - const spec = resolveConfiguredAcpBindingSpecBySessionKey({ - cfg, - sessionKey: resolved?.record.targetSessionKey ?? "", - }); + const cfg = createCfgWithBindings([ + createDiscordBinding({ + agentId: "codex", + conversationId: defaultDiscordConversationId, + acp: { backend: "acpx" }, + }), + ]); + const spec = resolveDiscordBindingSpecBySession(cfg); expect(spec?.channel).toBe("discord"); - expect(spec?.conversationId).toBe("1478836151241412759"); + expect(spec?.conversationId).toBe(defaultDiscordConversationId); expect(spec?.agentId).toBe("codex"); expect(spec?.backend).toBe("acpx"); }); @@ -368,46 +348,20 @@ describe("resolveConfiguredAcpBindingSpecBySessionKey", () => { }); it("prefers exact account ACP settings over wildcard when session keys collide", () => { - const cfg = { - ...baseCfg, - bindings: [ - { - type: "acp", - agentId: "codex", - match: { - channel: "discord", - accountId: "*", - peer: { kind: "channel", id: "1478836151241412759" }, - }, - acp: { - backend: "wild", - }, - }, - { - type: "acp", - agentId: "codex", - match: { - channel: "discord", - accountId: "default", - peer: { kind: "channel", id: "1478836151241412759" }, - }, - acp: { - backend: "exact", - }, - }, - ], - } satisfies OpenClawConfig; - - const resolved = resolveConfiguredAcpBindingRecord({ - cfg, - channel: "discord", - accountId: "default", - conversationId: "1478836151241412759", - }); - const spec = resolveConfiguredAcpBindingSpecBySessionKey({ - cfg, - sessionKey: resolved?.record.targetSessionKey ?? "", - }); + const cfg = createCfgWithBindings([ + createDiscordBinding({ + agentId: "codex", + conversationId: defaultDiscordConversationId, + accountId: "*", + acp: { backend: "wild" }, + }), + createDiscordBinding({ + agentId: "codex", + conversationId: defaultDiscordConversationId, + acp: { backend: "exact" }, + }), + ]); + const spec = resolveDiscordBindingSpecBySession(cfg); expect(spec?.backend).toBe("exact"); }); @@ -435,26 +389,10 @@ describe("buildConfiguredAcpSessionKey", () => { describe("ensureConfiguredAcpBindingSession", () => { it("keeps an existing ready session when configured binding omits cwd", async () => { - const spec = { - channel: "discord" as const, - accountId: "default", - conversationId: "1478836151241412759", - agentId: "codex", - mode: "persistent" as const, - }; - const sessionKey = buildConfiguredAcpSessionKey(spec); - managerMocks.resolveSession.mockReturnValue({ - kind: "ready", - sessionKey, - meta: { - backend: "acpx", - agent: "codex", - runtimeSessionName: "existing", - mode: "persistent", - runtimeOptions: { cwd: "/workspace/openclaw" }, - state: "idle", - lastActivityAt: Date.now(), - }, + const spec = createDiscordPersistentSpec(); + const sessionKey = mockReadySession({ + spec, + cwd: "/workspace/openclaw", }); const ensured = await ensureConfiguredAcpBindingSession({ @@ -468,27 +406,12 @@ describe("ensureConfiguredAcpBindingSession", () => { }); it("reinitializes a ready session when binding config explicitly sets mismatched cwd", async () => { - const spec = { - channel: "discord" as const, - accountId: "default", - conversationId: "1478836151241412759", - agentId: "codex", - mode: "persistent" as const, + const spec = createDiscordPersistentSpec({ cwd: "/workspace/repo-a", - }; - const sessionKey = buildConfiguredAcpSessionKey(spec); - managerMocks.resolveSession.mockReturnValue({ - kind: "ready", - sessionKey, - meta: { - backend: "acpx", - agent: "codex", - runtimeSessionName: "existing", - mode: "persistent", - runtimeOptions: { cwd: "/workspace/other-repo" }, - state: "idle", - lastActivityAt: Date.now(), - }, + }); + const sessionKey = mockReadySession({ + spec, + cwd: "/workspace/other-repo", }); const ensured = await ensureConfiguredAcpBindingSession({ @@ -508,14 +431,10 @@ describe("ensureConfiguredAcpBindingSession", () => { }); it("initializes ACP session with runtime agent override when provided", async () => { - const spec = { - channel: "discord" as const, - accountId: "default", - conversationId: "1478836151241412759", + const spec = createDiscordPersistentSpec({ agentId: "coding", acpAgentId: "codex", - mode: "persistent" as const, - }; + }); managerMocks.resolveSession.mockReturnValue({ kind: "none" }); const ensured = await ensureConfiguredAcpBindingSession({ @@ -534,24 +453,16 @@ describe("ensureConfiguredAcpBindingSession", () => { describe("resetAcpSessionInPlace", () => { it("reinitializes from configured binding when ACP metadata is missing", async () => { - const cfg = { - ...baseCfg, - bindings: [ - { - type: "acp", - agentId: "claude", - match: { - channel: "discord", - accountId: "default", - peer: { kind: "channel", id: "1478844424791396446" }, - }, - acp: { - mode: "persistent", - backend: "acpx", - }, + const cfg = createCfgWithBindings([ + createDiscordBinding({ + agentId: "claude", + conversationId: "1478844424791396446", + acp: { + mode: "persistent", + backend: "acpx", }, - ], - } satisfies OpenClawConfig; + }), + ]); const sessionKey = buildConfiguredAcpSessionKey({ channel: "discord", accountId: "default", From d9fb1e0e455c636107320aafd16279d724529129 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 20:54:28 +0000 Subject: [PATCH 035/461] test: dedupe acp startup test harness --- src/acp/server.startup.test.ts | 44 +++++++++++++++++----------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/src/acp/server.startup.test.ts b/src/acp/server.startup.test.ts index 2f9b96d8511..35c43478ec9 100644 --- a/src/acp/server.startup.test.ts +++ b/src/acp/server.startup.test.ts @@ -129,6 +129,22 @@ describe("serveAcpGateway startup", () => { return { signalHandlers, onceSpy }; } + async function emitHelloAndWaitForAgentSideConnection() { + const gateway = getMockGateway(); + gateway.emitHello(); + await vi.waitFor(() => { + expect(mockState.agentSideConnectionCtor).toHaveBeenCalledTimes(1); + }); + } + + async function stopServeWithSigint( + signalHandlers: Map void>, + servePromise: Promise, + ) { + signalHandlers.get("SIGINT")?.(); + await servePromise; + } + beforeAll(async () => { ({ serveAcpGateway } = await import("./server.js")); }); @@ -153,14 +169,8 @@ describe("serveAcpGateway startup", () => { await Promise.resolve(); expect(mockState.agentSideConnectionCtor).not.toHaveBeenCalled(); - const gateway = getMockGateway(); - gateway.emitHello(); - await vi.waitFor(() => { - expect(mockState.agentSideConnectionCtor).toHaveBeenCalledTimes(1); - }); - - signalHandlers.get("SIGINT")?.(); - await servePromise; + await emitHelloAndWaitForAgentSideConnection(); + await stopServeWithSigint(signalHandlers, servePromise); } finally { onceSpy.mockRestore(); } @@ -207,13 +217,8 @@ describe("serveAcpGateway startup", () => { password: "resolved-secret-password", // pragma: allowlist secret }); - const gateway = getMockGateway(); - gateway.emitHello(); - await vi.waitFor(() => { - expect(mockState.agentSideConnectionCtor).toHaveBeenCalledTimes(1); - }); - signalHandlers.get("SIGINT")?.(); - await servePromise; + await emitHelloAndWaitForAgentSideConnection(); + await stopServeWithSigint(signalHandlers, servePromise); } finally { onceSpy.mockRestore(); } @@ -236,13 +241,8 @@ describe("serveAcpGateway startup", () => { }), ); - const gateway = getMockGateway(); - gateway.emitHello(); - await vi.waitFor(() => { - expect(mockState.agentSideConnectionCtor).toHaveBeenCalledTimes(1); - }); - signalHandlers.get("SIGINT")?.(); - await servePromise; + await emitHelloAndWaitForAgentSideConnection(); + await stopServeWithSigint(signalHandlers, servePromise); } finally { onceSpy.mockRestore(); } From 94531fa237e672ab51adb4d43f69af62a53c234a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 20:54:33 +0000 Subject: [PATCH 036/461] test: reduce docker setup e2e duplication --- src/docker-setup.e2e.test.ts | 70 +++++++++++++++++++----------------- 1 file changed, 37 insertions(+), 33 deletions(-) diff --git a/src/docker-setup.e2e.test.ts b/src/docker-setup.e2e.test.ts index 8d5eec70ed0..160c74b8a21 100644 --- a/src/docker-setup.e2e.test.ts +++ b/src/docker-setup.e2e.test.ts @@ -113,6 +113,26 @@ function runDockerSetup( }); } +async function runDockerSetupWithUnsetGatewayToken( + sandbox: DockerSetupSandbox, + suffix: string, + prepare?: (configDir: string) => Promise, +) { + const configDir = join(sandbox.rootDir, `config-${suffix}`); + const workspaceDir = join(sandbox.rootDir, `workspace-${suffix}`); + await mkdir(configDir, { recursive: true }); + await prepare?.(configDir); + + const result = runDockerSetup(sandbox, { + OPENCLAW_GATEWAY_TOKEN: undefined, + OPENCLAW_CONFIG_DIR: configDir, + OPENCLAW_WORKSPACE_DIR: workspaceDir, + }); + const envFile = await readFile(join(sandbox.rootDir, ".env"), "utf8"); + + return { result, envFile }; +} + async function withUnixSocket(socketPath: string, run: () => Promise): Promise { const server = createServer(); await new Promise((resolve, reject) => { @@ -243,52 +263,39 @@ describe("docker-setup.sh", () => { it("reuses existing config token when OPENCLAW_GATEWAY_TOKEN is unset", async () => { const activeSandbox = requireSandbox(sandbox); - const configDir = join(activeSandbox.rootDir, "config-token-reuse"); - const workspaceDir = join(activeSandbox.rootDir, "workspace-token-reuse"); - await mkdir(configDir, { recursive: true }); - await writeFile( - join(configDir, "openclaw.json"), - JSON.stringify({ gateway: { auth: { mode: "token", token: "config-token-123" } } }), + const { result, envFile } = await runDockerSetupWithUnsetGatewayToken( + activeSandbox, + "token-reuse", + async (configDir) => { + await writeFile( + join(configDir, "openclaw.json"), + JSON.stringify({ gateway: { auth: { mode: "token", token: "config-token-123" } } }), + ); + }, ); - const result = runDockerSetup(activeSandbox, { - OPENCLAW_GATEWAY_TOKEN: undefined, - OPENCLAW_CONFIG_DIR: configDir, - OPENCLAW_WORKSPACE_DIR: workspaceDir, - }); - expect(result.status).toBe(0); - const envFile = await readFile(join(activeSandbox.rootDir, ".env"), "utf8"); expect(envFile).toContain("OPENCLAW_GATEWAY_TOKEN=config-token-123"); // pragma: allowlist secret }); it("reuses existing .env token when OPENCLAW_GATEWAY_TOKEN and config token are unset", async () => { const activeSandbox = requireSandbox(sandbox); - const configDir = join(activeSandbox.rootDir, "config-dotenv-token-reuse"); - const workspaceDir = join(activeSandbox.rootDir, "workspace-dotenv-token-reuse"); - await mkdir(configDir, { recursive: true }); await writeFile( join(activeSandbox.rootDir, ".env"), "OPENCLAW_GATEWAY_TOKEN=dotenv-token-123\nOPENCLAW_GATEWAY_PORT=18789\n", // pragma: allowlist secret ); - - const result = runDockerSetup(activeSandbox, { - OPENCLAW_GATEWAY_TOKEN: undefined, - OPENCLAW_CONFIG_DIR: configDir, - OPENCLAW_WORKSPACE_DIR: workspaceDir, - }); + const { result, envFile } = await runDockerSetupWithUnsetGatewayToken( + activeSandbox, + "dotenv-token-reuse", + ); expect(result.status).toBe(0); - const envFile = await readFile(join(activeSandbox.rootDir, ".env"), "utf8"); expect(envFile).toContain("OPENCLAW_GATEWAY_TOKEN=dotenv-token-123"); // pragma: allowlist secret expect(result.stderr).toBe(""); }); it("reuses the last non-empty .env token and strips CRLF without truncating '='", async () => { const activeSandbox = requireSandbox(sandbox); - const configDir = join(activeSandbox.rootDir, "config-dotenv-last-wins"); - const workspaceDir = join(activeSandbox.rootDir, "workspace-dotenv-last-wins"); - await mkdir(configDir, { recursive: true }); await writeFile( join(activeSandbox.rootDir, ".env"), [ @@ -297,15 +304,12 @@ describe("docker-setup.sh", () => { "OPENCLAW_GATEWAY_TOKEN=last=token=value\r", // pragma: allowlist secret ].join("\n"), ); - - const result = runDockerSetup(activeSandbox, { - OPENCLAW_GATEWAY_TOKEN: undefined, - OPENCLAW_CONFIG_DIR: configDir, - OPENCLAW_WORKSPACE_DIR: workspaceDir, - }); + const { result, envFile } = await runDockerSetupWithUnsetGatewayToken( + activeSandbox, + "dotenv-last-wins", + ); expect(result.status).toBe(0); - const envFile = await readFile(join(activeSandbox.rootDir, ".env"), "utf8"); expect(envFile).toContain("OPENCLAW_GATEWAY_TOKEN=last=token=value"); // pragma: allowlist secret expect(envFile).not.toContain("OPENCLAW_GATEWAY_TOKEN=first-token"); expect(envFile).not.toContain("\r"); From 9b6790e3a67a97c150936757824a1c92d3d8c6ad Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 20:55:26 +0000 Subject: [PATCH 037/461] refactor: share acp binding resolution helper --- src/acp/persistent-bindings.resolve.ts | 203 ++++++++++++------------- 1 file changed, 100 insertions(+), 103 deletions(-) diff --git a/src/acp/persistent-bindings.resolve.ts b/src/acp/persistent-bindings.resolve.ts index c69f1afe5af..84f052797ad 100644 --- a/src/acp/persistent-bindings.resolve.ts +++ b/src/acp/persistent-bindings.resolve.ts @@ -117,6 +117,70 @@ function toConfiguredBindingSpec(params: { }; } +function resolveConfiguredBindingRecord(params: { + cfg: OpenClawConfig; + bindings: AgentAcpBinding[]; + channel: ConfiguredAcpBindingChannel; + accountId: string; + selectConversation: ( + binding: AgentAcpBinding, + ) => { conversationId: string; parentConversationId?: string } | null; +}): ResolvedConfiguredAcpBinding | null { + let wildcardMatch: { + binding: AgentAcpBinding; + conversationId: string; + parentConversationId?: string; + } | null = null; + for (const binding of params.bindings) { + if (normalizeBindingChannel(binding.match.channel) !== params.channel) { + continue; + } + const accountMatchPriority = resolveAccountMatchPriority( + binding.match.accountId, + params.accountId, + ); + if (accountMatchPriority === 0) { + continue; + } + const conversation = params.selectConversation(binding); + if (!conversation) { + continue; + } + const spec = toConfiguredBindingSpec({ + cfg: params.cfg, + channel: params.channel, + accountId: params.accountId, + conversationId: conversation.conversationId, + parentConversationId: conversation.parentConversationId, + binding, + }); + if (accountMatchPriority === 2) { + return { + spec, + record: toConfiguredAcpBindingRecord(spec), + }; + } + if (!wildcardMatch) { + wildcardMatch = { binding, ...conversation }; + } + } + if (!wildcardMatch) { + return null; + } + const spec = toConfiguredBindingSpec({ + cfg: params.cfg, + channel: params.channel, + accountId: params.accountId, + conversationId: wildcardMatch.conversationId, + parentConversationId: wildcardMatch.parentConversationId, + binding: wildcardMatch.binding, + }); + return { + spec, + record: toConfiguredAcpBindingRecord(spec), + }; +} + export function resolveConfiguredAcpBindingSpecBySessionKey(params: { cfg: OpenClawConfig; sessionKey: string; @@ -207,57 +271,20 @@ export function resolveConfiguredAcpBindingRecord(params: { if (channel === "discord") { const bindings = listAcpBindings(params.cfg); - const resolveDiscordBindingForConversation = ( - targetConversationId: string, - ): ResolvedConfiguredAcpBinding | null => { - let wildcardMatch: AgentAcpBinding | null = null; - for (const binding of bindings) { - if (normalizeBindingChannel(binding.match.channel) !== "discord") { - continue; - } - const accountMatchPriority = resolveAccountMatchPriority( - binding.match.accountId, - accountId, - ); - if (accountMatchPriority === 0) { - continue; - } - const bindingConversationId = resolveBindingConversationId(binding); - if (!bindingConversationId || bindingConversationId !== targetConversationId) { - continue; - } - if (accountMatchPriority === 2) { - const spec = toConfiguredBindingSpec({ - cfg: params.cfg, - channel: "discord", - accountId, - conversationId: targetConversationId, - binding, - }); - return { - spec, - record: toConfiguredAcpBindingRecord(spec), - }; - } - if (!wildcardMatch) { - wildcardMatch = binding; - } - } - if (wildcardMatch) { - const spec = toConfiguredBindingSpec({ - cfg: params.cfg, - channel: "discord", - accountId, - conversationId: targetConversationId, - binding: wildcardMatch, - }); - return { - spec, - record: toConfiguredAcpBindingRecord(spec), - }; - } - return null; - }; + const resolveDiscordBindingForConversation = (targetConversationId: string) => + resolveConfiguredBindingRecord({ + cfg: params.cfg, + bindings, + channel: "discord", + accountId, + selectConversation: (binding) => { + const bindingConversationId = resolveBindingConversationId(binding); + if (!bindingConversationId || bindingConversationId !== targetConversationId) { + return null; + } + return { conversationId: targetConversationId }; + }, + }); const directMatch = resolveDiscordBindingForConversation(conversationId); if (directMatch) { @@ -280,61 +307,31 @@ export function resolveConfiguredAcpBindingRecord(params: { if (!parsed || !parsed.chatId.startsWith("-")) { return null; } - let wildcardMatch: AgentAcpBinding | null = null; - for (const binding of listAcpBindings(params.cfg)) { - if (normalizeBindingChannel(binding.match.channel) !== "telegram") { - continue; - } - const accountMatchPriority = resolveAccountMatchPriority(binding.match.accountId, accountId); - if (accountMatchPriority === 0) { - continue; - } - const targetConversationId = resolveBindingConversationId(binding); - if (!targetConversationId) { - continue; - } - const targetParsed = parseTelegramTopicConversation({ - conversationId: targetConversationId, - }); - if (!targetParsed || !targetParsed.chatId.startsWith("-")) { - continue; - } - if (targetParsed.canonicalConversationId !== parsed.canonicalConversationId) { - continue; - } - if (accountMatchPriority === 2) { - const spec = toConfiguredBindingSpec({ - cfg: params.cfg, - channel: "telegram", - accountId, + return resolveConfiguredBindingRecord({ + cfg: params.cfg, + bindings: listAcpBindings(params.cfg), + channel: "telegram", + accountId, + selectConversation: (binding) => { + const targetConversationId = resolveBindingConversationId(binding); + if (!targetConversationId) { + return null; + } + const targetParsed = parseTelegramTopicConversation({ + conversationId: targetConversationId, + }); + if (!targetParsed || !targetParsed.chatId.startsWith("-")) { + return null; + } + if (targetParsed.canonicalConversationId !== parsed.canonicalConversationId) { + return null; + } + return { conversationId: parsed.canonicalConversationId, parentConversationId: parsed.chatId, - binding, - }); - return { - spec, - record: toConfiguredAcpBindingRecord(spec), }; - } - if (!wildcardMatch) { - wildcardMatch = binding; - } - } - if (wildcardMatch) { - const spec = toConfiguredBindingSpec({ - cfg: params.cfg, - channel: "telegram", - accountId, - conversationId: parsed.canonicalConversationId, - parentConversationId: parsed.chatId, - binding: wildcardMatch, - }); - return { - spec, - record: toConfiguredAcpBindingRecord(spec), - }; - } - return null; + }, + }); } return null; From 0f637b5e3099ba80261e044dd6309f971b561852 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 20:56:35 +0000 Subject: [PATCH 038/461] refactor: share acp conversation text normalization --- src/acp/conversation-id.ts | 4 ++-- src/auto-reply/reply/commands-acp/context.ts | 23 +++++++------------- 2 files changed, 10 insertions(+), 17 deletions(-) diff --git a/src/acp/conversation-id.ts b/src/acp/conversation-id.ts index 7281fef4924..9cf17c9a579 100644 --- a/src/acp/conversation-id.ts +++ b/src/acp/conversation-id.ts @@ -4,7 +4,7 @@ export type ParsedTelegramTopicConversation = { canonicalConversationId: string; }; -function normalizeText(value: unknown): string { +export function normalizeConversationText(value: unknown): string { if (typeof value === "string") { return value.trim(); } @@ -15,7 +15,7 @@ function normalizeText(value: unknown): string { } export function parseTelegramChatIdFromTarget(raw: unknown): string | undefined { - const text = normalizeText(raw); + const text = normalizeConversationText(raw); if (!text) { return undefined; } diff --git a/src/auto-reply/reply/commands-acp/context.ts b/src/auto-reply/reply/commands-acp/context.ts index 16291713fda..fd90175f38a 100644 --- a/src/auto-reply/reply/commands-acp/context.ts +++ b/src/auto-reply/reply/commands-acp/context.ts @@ -1,5 +1,6 @@ import { buildTelegramTopicConversationId, + normalizeConversationText, parseTelegramChatIdFromTarget, } from "../../../acp/conversation-id.js"; import { DISCORD_THREAD_BINDING_CHANNEL } from "../../../channels/thread-bindings-policy.js"; @@ -8,33 +9,25 @@ import { parseAgentSessionKey } from "../../../routing/session-key.js"; import type { HandleCommandsParams } from "../commands-types.js"; import { resolveTelegramConversationId } from "../telegram-context.js"; -function normalizeString(value: unknown): string { - if (typeof value === "string") { - return value.trim(); - } - if (typeof value === "number" || typeof value === "bigint" || typeof value === "boolean") { - return `${value}`.trim(); - } - return ""; -} - export function resolveAcpCommandChannel(params: HandleCommandsParams): string { const raw = params.ctx.OriginatingChannel ?? params.command.channel ?? params.ctx.Surface ?? params.ctx.Provider; - return normalizeString(raw).toLowerCase(); + return normalizeConversationText(raw).toLowerCase(); } export function resolveAcpCommandAccountId(params: HandleCommandsParams): string { - const accountId = normalizeString(params.ctx.AccountId); + const accountId = normalizeConversationText(params.ctx.AccountId); return accountId || "default"; } export function resolveAcpCommandThreadId(params: HandleCommandsParams): string | undefined { const threadId = - params.ctx.MessageThreadId != null ? normalizeString(String(params.ctx.MessageThreadId)) : ""; + params.ctx.MessageThreadId != null + ? normalizeConversationText(String(params.ctx.MessageThreadId)) + : ""; return threadId || undefined; } @@ -72,7 +65,7 @@ export function resolveAcpCommandConversationId(params: HandleCommandsParams): s } function parseDiscordParentChannelFromSessionKey(raw: unknown): string | undefined { - const sessionKey = normalizeString(raw); + const sessionKey = normalizeConversationText(raw); if (!sessionKey) { return undefined; } @@ -85,7 +78,7 @@ function parseDiscordParentChannelFromSessionKey(raw: unknown): string | undefin } function parseDiscordParentChannelFromContext(raw: unknown): string | undefined { - const parentId = normalizeString(raw); + const parentId = normalizeConversationText(raw); if (!parentId) { return undefined; } From 467a7bae3f9e955ed81a56148b8da41aa4ce942d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 20:59:14 +0000 Subject: [PATCH 039/461] refactor: share session conversation normalization --- src/auto-reply/reply/session.ts | 23 +++++++---------------- 1 file changed, 7 insertions(+), 16 deletions(-) diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts index 6db6b1708cb..85e6754025f 100644 --- a/src/auto-reply/reply/session.ts +++ b/src/auto-reply/reply/session.ts @@ -2,6 +2,7 @@ import crypto from "node:crypto"; import path from "node:path"; import { buildTelegramTopicConversationId, + normalizeConversationText, parseTelegramChatIdFromTarget, } from "../../acp/conversation-id.js"; import { resolveSessionAgentId } from "../../agents/agent-scope.js"; @@ -69,18 +70,8 @@ export type SessionInitResult = { triggerBodyNormalized: string; }; -function normalizeSessionText(value: unknown): string { - if (typeof value === "string") { - return value.trim(); - } - if (typeof value === "number" || typeof value === "bigint" || typeof value === "boolean") { - return `${value}`.trim(); - } - return ""; -} - function parseDiscordParentChannelFromSessionKey(raw: unknown): string | undefined { - const sessionKey = normalizeSessionText(raw); + const sessionKey = normalizeConversationText(raw); if (!sessionKey) { return undefined; } @@ -98,15 +89,15 @@ function resolveAcpResetBindingContext(ctx: MsgContext): { conversationId: string; parentConversationId?: string; } | null { - const channelRaw = normalizeSessionText( + const channelRaw = normalizeConversationText( ctx.OriginatingChannel ?? ctx.Surface ?? ctx.Provider ?? "", ).toLowerCase(); if (!channelRaw) { return null; } - const accountId = normalizeSessionText(ctx.AccountId) || "default"; + const accountId = normalizeConversationText(ctx.AccountId) || "default"; const normalizedThreadId = - ctx.MessageThreadId != null ? normalizeSessionText(String(ctx.MessageThreadId)) : ""; + ctx.MessageThreadId != null ? normalizeConversationText(String(ctx.MessageThreadId)) : ""; if (channelRaw === "telegram") { const parentConversationId = @@ -143,7 +134,7 @@ function resolveAcpResetBindingContext(ctx: MsgContext): { } let parentConversationId: string | undefined; if (channelRaw === "discord" && normalizedThreadId) { - const fromContext = normalizeSessionText(ctx.ThreadParentId); + const fromContext = normalizeConversationText(ctx.ThreadParentId); if (fromContext && fromContext !== conversationId) { parentConversationId = fromContext; } else { @@ -172,7 +163,7 @@ function resolveBoundAcpSessionForReset(params: { cfg: OpenClawConfig; ctx: MsgContext; }): string | undefined { - const activeSessionKey = normalizeSessionText(params.ctx.SessionKey); + const activeSessionKey = normalizeConversationText(params.ctx.SessionKey); const bindingContext = resolveAcpResetBindingContext(params.ctx); return resolveEffectiveResetTargetSessionKey({ cfg: params.cfg, From 77d2f9a3547f3fad86d3a423d366b9adf3ba35cb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 20:59:43 +0000 Subject: [PATCH 040/461] refactor: share snake case param lookup --- src/agents/tools/common.ts | 17 ++--------------- src/param-key.ts | 17 +++++++++++++++++ src/poll-params.ts | 18 +++--------------- 3 files changed, 22 insertions(+), 30 deletions(-) create mode 100644 src/param-key.ts diff --git a/src/agents/tools/common.ts b/src/agents/tools/common.ts index 19cca2d7927..81d3f4efc00 100644 --- a/src/agents/tools/common.ts +++ b/src/agents/tools/common.ts @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core"; import { detectMime } from "../../media/mime.js"; +import { readSnakeCaseParamRaw } from "../../param-key.js"; import type { ImageSanitizationLimits } from "../image-sanitization.js"; import { sanitizeToolResultImages } from "../tool-images.js"; @@ -53,22 +54,8 @@ export function createActionGate>( }; } -function toSnakeCaseKey(key: string): string { - return key - .replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2") - .replace(/([a-z0-9])([A-Z])/g, "$1_$2") - .toLowerCase(); -} - function readParamRaw(params: Record, key: string): unknown { - if (Object.hasOwn(params, key)) { - return params[key]; - } - const snakeKey = toSnakeCaseKey(key); - if (snakeKey !== key && Object.hasOwn(params, snakeKey)) { - return params[snakeKey]; - } - return undefined; + return readSnakeCaseParamRaw(params, key); } export function readStringParam( diff --git a/src/param-key.ts b/src/param-key.ts new file mode 100644 index 00000000000..9b86eccfc1b --- /dev/null +++ b/src/param-key.ts @@ -0,0 +1,17 @@ +export function toSnakeCaseKey(key: string): string { + return key + .replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2") + .replace(/([a-z0-9])([A-Z])/g, "$1_$2") + .toLowerCase(); +} + +export function readSnakeCaseParamRaw(params: Record, key: string): unknown { + if (Object.hasOwn(params, key)) { + return params[key]; + } + const snakeKey = toSnakeCaseKey(key); + if (snakeKey !== key && Object.hasOwn(params, snakeKey)) { + return params[snakeKey]; + } + return undefined; +} diff --git a/src/poll-params.ts b/src/poll-params.ts index 88dc6336d32..f6fc5546548 100644 --- a/src/poll-params.ts +++ b/src/poll-params.ts @@ -1,3 +1,5 @@ +import { readSnakeCaseParamRaw } from "./param-key.js"; + export type PollCreationParamKind = "string" | "stringArray" | "number" | "boolean"; export type PollCreationParamDef = { @@ -19,22 +21,8 @@ export type PollCreationParamName = keyof typeof POLL_CREATION_PARAM_DEFS; export const POLL_CREATION_PARAM_NAMES = Object.keys(POLL_CREATION_PARAM_DEFS); -function toSnakeCaseKey(key: string): string { - return key - .replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2") - .replace(/([a-z0-9])([A-Z])/g, "$1_$2") - .toLowerCase(); -} - function readPollParamRaw(params: Record, key: string): unknown { - if (Object.hasOwn(params, key)) { - return params[key]; - } - const snakeKey = toSnakeCaseKey(key); - if (snakeKey !== key && Object.hasOwn(params, snakeKey)) { - return params[snakeKey]; - } - return undefined; + return readSnakeCaseParamRaw(params, key); } export function resolveTelegramPollVisibility(params: { From e4924a01347857369d52877cefcb16df07fced55 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:00:07 +0000 Subject: [PATCH 041/461] test: dedupe acp translator cancel scoping tests --- src/acp/translator.cancel-scoping.test.ts | 68 ++++++++++++----------- 1 file changed, 36 insertions(+), 32 deletions(-) diff --git a/src/acp/translator.cancel-scoping.test.ts b/src/acp/translator.cancel-scoping.test.ts index c84832369a0..e862222f7a0 100644 --- a/src/acp/translator.cancel-scoping.test.ts +++ b/src/acp/translator.cancel-scoping.test.ts @@ -91,19 +91,45 @@ async function startPendingPrompt( }; } +async function cancelAndExpectAbortForPendingRun( + harness: Harness, + sessionId: string, + sessionKey: string, + pending: { promptPromise: Promise; runId: string }, +) { + await harness.agent.cancel({ sessionId } as CancelNotification); + + expect(harness.requestSpy).toHaveBeenCalledWith("chat.abort", { + sessionKey, + runId: pending.runId, + }); + await expect(pending.promptPromise).resolves.toEqual({ stopReason: "cancelled" }); +} + +async function deliverFinalChatEventAndExpectEndTurn( + harness: Harness, + sessionKey: string, + pending: { promptPromise: Promise; runId: string }, + seq: number, +) { + await harness.agent.handleGatewayEvent( + createChatEvent({ + runId: pending.runId, + sessionKey, + seq, + state: "final", + }), + ); + await expect(pending.promptPromise).resolves.toEqual({ stopReason: "end_turn" }); +} + describe("acp translator cancel and run scoping", () => { it("cancel passes active runId to chat.abort", async () => { const sessionKey = "agent:main:shared"; const harness = createHarness([{ sessionId: "session-1", sessionKey }]); const pending = await startPendingPrompt(harness, "session-1"); - await harness.agent.cancel({ sessionId: "session-1" } as CancelNotification); - - expect(harness.requestSpy).toHaveBeenCalledWith("chat.abort", { - sessionKey, - runId: pending.runId, - }); - await expect(pending.promptPromise).resolves.toEqual({ stopReason: "cancelled" }); + await cancelAndExpectAbortForPendingRun(harness, "session-1", sessionKey, pending); }); it("cancel uses pending runId when there is no active run", async () => { @@ -112,13 +138,7 @@ describe("acp translator cancel and run scoping", () => { const pending = await startPendingPrompt(harness, "session-1"); harness.sessionStore.clearActiveRun("session-1"); - await harness.agent.cancel({ sessionId: "session-1" } as CancelNotification); - - expect(harness.requestSpy).toHaveBeenCalledWith("chat.abort", { - sessionKey, - runId: pending.runId, - }); - await expect(pending.promptPromise).resolves.toEqual({ stopReason: "cancelled" }); + await cancelAndExpectAbortForPendingRun(harness, "session-1", sessionKey, pending); }); it("cancel skips chat.abort when there is no active run and no pending prompt", async () => { @@ -145,15 +165,7 @@ describe("acp translator cancel and run scoping", () => { expect(abortCalls).toHaveLength(0); expect(harness.sessionStore.getSession("session-2")?.activeRunId).toBe(pending2.runId); - await harness.agent.handleGatewayEvent( - createChatEvent({ - runId: pending2.runId, - sessionKey, - seq: 1, - state: "final", - }), - ); - await expect(pending2.promptPromise).resolves.toEqual({ stopReason: "end_turn" }); + await deliverFinalChatEventAndExpectEndTurn(harness, sessionKey, pending2, 1); }); it("drops chat events when runId does not match the active prompt", async () => { @@ -250,15 +262,7 @@ describe("acp translator cancel and run scoping", () => { ); expect(harness.sessionUpdateSpy).toHaveBeenCalledTimes(1); - await harness.agent.handleGatewayEvent( - createChatEvent({ - runId: pending2.runId, - sessionKey, - seq: 1, - state: "final", - }), - ); - await expect(pending2.promptPromise).resolves.toEqual({ stopReason: "end_turn" }); + await deliverFinalChatEventAndExpectEndTurn(harness, sessionKey, pending2, 1); expect(harness.sessionStore.getSession("session-1")?.activeRunId).toBe(pending1.runId); await harness.agent.handleGatewayEvent( From 7119ab1d98d77a874ced4a9e260c595b8845cf51 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:00:13 +0000 Subject: [PATCH 042/461] refactor: dedupe home relative path resolution --- src/config/paths.ts | 16 ++-------------- src/infra/home-dir.ts | 22 ++++++++++++++++++++++ src/utils.ts | 16 ++-------------- 3 files changed, 26 insertions(+), 28 deletions(-) diff --git a/src/config/paths.ts b/src/config/paths.ts index 5f9afc85a46..84c27749bcf 100644 --- a/src/config/paths.ts +++ b/src/config/paths.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { expandHomePrefix, resolveRequiredHomeDir } from "../infra/home-dir.js"; +import { resolveHomeRelativePath, resolveRequiredHomeDir } from "../infra/home-dir.js"; import type { OpenClawConfig } from "./types.js"; /** @@ -93,19 +93,7 @@ function resolveUserPath( env: NodeJS.ProcessEnv = process.env, homedir: () => string = envHomedir(env), ): string { - const trimmed = input.trim(); - if (!trimmed) { - return trimmed; - } - if (trimmed.startsWith("~")) { - const expanded = expandHomePrefix(trimmed, { - home: resolveRequiredHomeDir(env, homedir), - env, - homedir, - }); - return path.resolve(expanded); - } - return path.resolve(trimmed); + return resolveHomeRelativePath(input, { env, homedir }); } export const STATE_DIR = resolveStateDir(); diff --git a/src/infra/home-dir.ts b/src/infra/home-dir.ts index 7dd2bbdd1ec..650cf0cadac 100644 --- a/src/infra/home-dir.ts +++ b/src/infra/home-dir.ts @@ -75,3 +75,25 @@ export function expandHomePrefix( } return input.replace(/^~(?=$|[\\/])/, home); } + +export function resolveHomeRelativePath( + input: string, + opts?: { + env?: NodeJS.ProcessEnv; + homedir?: () => string; + }, +): string { + const trimmed = input.trim(); + if (!trimmed) { + return trimmed; + } + if (trimmed.startsWith("~")) { + const expanded = expandHomePrefix(trimmed, { + home: resolveRequiredHomeDir(opts?.env ?? process.env, opts?.homedir ?? os.homedir), + env: opts?.env, + homedir: opts?.homedir, + }); + return path.resolve(expanded); + } + return path.resolve(trimmed); +} diff --git a/src/utils.ts b/src/utils.ts index 38c26605b19..caf5edb1969 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -4,8 +4,8 @@ import path from "node:path"; import { resolveOAuthDir } from "./config/paths.js"; import { logVerbose, shouldLogVerbose } from "./globals.js"; import { - expandHomePrefix, resolveEffectiveHomeDir, + resolveHomeRelativePath, resolveRequiredHomeDir, } from "./infra/home-dir.js"; import { isPlainObject } from "./infra/plain-object.js"; @@ -279,19 +279,7 @@ export function resolveUserPath( if (!input) { return ""; } - const trimmed = input.trim(); - if (!trimmed) { - return trimmed; - } - if (trimmed.startsWith("~")) { - const expanded = expandHomePrefix(trimmed, { - home: resolveRequiredHomeDir(env, homedir), - env, - homedir, - }); - return path.resolve(expanded); - } - return path.resolve(trimmed); + return resolveHomeRelativePath(input, { env, homedir }); } export function resolveConfigDir( From fa1ce9fd199800e42528920c13254d3a867e9d2c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:02:57 +0000 Subject: [PATCH 043/461] test: trim acp translator import duplication From 83571fdb933685090dffdaccae94dfcdcf4f7f61 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:03:24 +0000 Subject: [PATCH 044/461] refactor: dedupe agent list filtering --- .../mattermost/src/mattermost/monitor-helpers.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/extensions/mattermost/src/mattermost/monitor-helpers.ts b/extensions/mattermost/src/mattermost/monitor-helpers.ts index de264e6cf2c..219c0562638 100644 --- a/extensions/mattermost/src/mattermost/monitor-helpers.ts +++ b/extensions/mattermost/src/mattermost/monitor-helpers.ts @@ -41,12 +41,12 @@ function normalizeAgentId(value: string | undefined | null): string { type AgentEntry = NonNullable["list"]>[number]; +function isAgentEntry(entry: unknown): entry is AgentEntry { + return Boolean(entry && typeof entry === "object"); +} + function listAgents(cfg: OpenClawConfig): AgentEntry[] { - const list = cfg.agents?.list; - if (!Array.isArray(list)) { - return []; - } - return list.filter((entry): entry is AgentEntry => Boolean(entry && typeof entry === "object")); + return Array.isArray(cfg.agents?.list) ? cfg.agents.list.filter(isAgentEntry) : []; } function resolveAgentEntry(cfg: OpenClawConfig, agentId: string): AgentEntry | undefined { From ba34266e897a4e76790b66cf10386f213e56cdc0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:03:31 +0000 Subject: [PATCH 045/461] test: dedupe cron config setup --- src/gateway/server-cron.test.ts | 45 ++++++++++++++++++--------------- 1 file changed, 24 insertions(+), 21 deletions(-) diff --git a/src/gateway/server-cron.test.ts b/src/gateway/server-cron.test.ts index 945840a7106..2608560e20f 100644 --- a/src/gateway/server-cron.test.ts +++ b/src/gateway/server-cron.test.ts @@ -10,12 +10,20 @@ const requestHeartbeatNowMock = vi.fn(); const loadConfigMock = vi.fn(); const fetchWithSsrFGuardMock = vi.fn(); +function enqueueSystemEvent(...args: unknown[]) { + return enqueueSystemEventMock(...args); +} + +function requestHeartbeatNow(...args: unknown[]) { + return requestHeartbeatNowMock(...args); +} + vi.mock("../infra/system-events.js", () => ({ - enqueueSystemEvent: (...args: unknown[]) => enqueueSystemEventMock(...args), + enqueueSystemEvent, })); vi.mock("../infra/heartbeat-wake.js", () => ({ - requestHeartbeatNow: (...args: unknown[]) => requestHeartbeatNowMock(...args), + requestHeartbeatNow, })); vi.mock("../config/config.js", async () => { @@ -32,6 +40,18 @@ vi.mock("../infra/net/fetch-guard.js", () => ({ import { buildGatewayCronService } from "./server-cron.js"; +function createCronConfig(name: string): OpenClawConfig { + const tmpDir = path.join(os.tmpdir(), `${name}-${Date.now()}`); + return { + session: { + mainKey: "main", + }, + cron: { + store: path.join(tmpDir, "cron.json"), + }, + } as OpenClawConfig; +} + describe("buildGatewayCronService", () => { beforeEach(() => { enqueueSystemEventMock.mockClear(); @@ -41,15 +61,7 @@ describe("buildGatewayCronService", () => { }); it("routes main-target jobs to the scoped session for enqueue + wake", async () => { - const tmpDir = path.join(os.tmpdir(), `server-cron-${Date.now()}`); - const cfg = { - session: { - mainKey: "main", - }, - cron: { - store: path.join(tmpDir, "cron.json"), - }, - } as OpenClawConfig; + const cfg = createCronConfig("server-cron"); loadConfigMock.mockReturnValue(cfg); const state = buildGatewayCronService({ @@ -87,16 +99,7 @@ describe("buildGatewayCronService", () => { }); it("blocks private webhook URLs via SSRF-guarded fetch", async () => { - const tmpDir = path.join(os.tmpdir(), `server-cron-ssrf-${Date.now()}`); - const cfg = { - session: { - mainKey: "main", - }, - cron: { - store: path.join(tmpDir, "cron.json"), - }, - } as OpenClawConfig; - + const cfg = createCronConfig("server-cron-ssrf"); loadConfigMock.mockReturnValue(cfg); fetchWithSsrFGuardMock.mockRejectedValue( new SsrFBlockedError("Blocked: resolves to private/internal/special-use IP address"), From 4ec0a120dfddf6f1770807fa18a1cc88202db725 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:04:20 +0000 Subject: [PATCH 046/461] test: share zalo api request assertion --- extensions/zalo/src/api.test.ts | 35 ++++++++++++++------------------- 1 file changed, 15 insertions(+), 20 deletions(-) diff --git a/extensions/zalo/src/api.test.ts b/extensions/zalo/src/api.test.ts index 00198f5072e..ffdeab84ae4 100644 --- a/extensions/zalo/src/api.test.ts +++ b/extensions/zalo/src/api.test.ts @@ -1,31 +1,26 @@ import { describe, expect, it, vi } from "vitest"; import { deleteWebhook, getWebhookInfo, sendChatAction, type ZaloFetch } from "./api.js"; +function createOkFetcher() { + return vi.fn(async () => new Response(JSON.stringify({ ok: true, result: {} }))); +} + +async function expectPostJsonRequest(run: (token: string, fetcher: ZaloFetch) => Promise) { + const fetcher = createOkFetcher(); + await run("test-token", fetcher); + expect(fetcher).toHaveBeenCalledTimes(1); + const [, init] = fetcher.mock.calls[0] ?? []; + expect(init?.method).toBe("POST"); + expect(init?.headers).toEqual({ "Content-Type": "application/json" }); +} + describe("Zalo API request methods", () => { it("uses POST for getWebhookInfo", async () => { - const fetcher = vi.fn( - async () => new Response(JSON.stringify({ ok: true, result: {} })), - ); - - await getWebhookInfo("test-token", fetcher); - - expect(fetcher).toHaveBeenCalledTimes(1); - const [, init] = fetcher.mock.calls[0] ?? []; - expect(init?.method).toBe("POST"); - expect(init?.headers).toEqual({ "Content-Type": "application/json" }); + await expectPostJsonRequest(getWebhookInfo); }); it("keeps POST for deleteWebhook", async () => { - const fetcher = vi.fn( - async () => new Response(JSON.stringify({ ok: true, result: {} })), - ); - - await deleteWebhook("test-token", fetcher); - - expect(fetcher).toHaveBeenCalledTimes(1); - const [, init] = fetcher.mock.calls[0] ?? []; - expect(init?.method).toBe("POST"); - expect(init?.headers).toEqual({ "Content-Type": "application/json" }); + await expectPostJsonRequest(deleteWebhook); }); it("aborts sendChatAction when the typing timeout elapses", async () => { From a879ad7547ee8e3fae82acf3f9c6984b11354e7b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:05:06 +0000 Subject: [PATCH 047/461] test: share node host credential assertions --- src/node-host/runner.credentials.test.ts | 43 +++++++++++------------- 1 file changed, 20 insertions(+), 23 deletions(-) diff --git a/src/node-host/runner.credentials.test.ts b/src/node-host/runner.credentials.test.ts index 6138a6b954e..c7a04951130 100644 --- a/src/node-host/runner.credentials.test.ts +++ b/src/node-host/runner.credentials.test.ts @@ -19,6 +19,17 @@ function createRemoteGatewayTokenRefConfig(tokenId: string): OpenClawConfig { } as OpenClawConfig; } +async function expectNoGatewayCredentials( + config: OpenClawConfig, + env: Record, +) { + await withEnvAsync(env, async () => { + const credentials = await resolveNodeHostGatewayCredentials({ config }); + expect(credentials.token).toBeUndefined(); + expect(credentials.password).toBeUndefined(); + }); +} + describe("resolveNodeHostGatewayCredentials", () => { it("does not inherit gateway.remote token in local mode", async () => { const config = { @@ -28,17 +39,10 @@ describe("resolveNodeHostGatewayCredentials", () => { }, } as OpenClawConfig; - await withEnvAsync( - { - OPENCLAW_GATEWAY_TOKEN: undefined, - OPENCLAW_GATEWAY_PASSWORD: undefined, - }, - async () => { - const credentials = await resolveNodeHostGatewayCredentials({ config }); - expect(credentials.token).toBeUndefined(); - expect(credentials.password).toBeUndefined(); - }, - ); + await expectNoGatewayCredentials(config, { + OPENCLAW_GATEWAY_TOKEN: undefined, + OPENCLAW_GATEWAY_PASSWORD: undefined, + }); }); it("ignores unresolved gateway.remote token refs in local mode", async () => { @@ -56,18 +60,11 @@ describe("resolveNodeHostGatewayCredentials", () => { }, } as OpenClawConfig; - await withEnvAsync( - { - OPENCLAW_GATEWAY_TOKEN: undefined, - OPENCLAW_GATEWAY_PASSWORD: undefined, - MISSING_REMOTE_GATEWAY_TOKEN: undefined, - }, - async () => { - const credentials = await resolveNodeHostGatewayCredentials({ config }); - expect(credentials.token).toBeUndefined(); - expect(credentials.password).toBeUndefined(); - }, - ); + await expectNoGatewayCredentials(config, { + OPENCLAW_GATEWAY_TOKEN: undefined, + OPENCLAW_GATEWAY_PASSWORD: undefined, + MISSING_REMOTE_GATEWAY_TOKEN: undefined, + }); }); it("resolves remote token SecretRef values", async () => { From 7eb38e8f7ba04c7892846d12c8bb0a4c552e0d3e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:06:01 +0000 Subject: [PATCH 048/461] test: share temporal decay vector fixtures --- src/memory/temporal-decay.test.ts | 130 +++++++++++++++--------------- 1 file changed, 65 insertions(+), 65 deletions(-) diff --git a/src/memory/temporal-decay.test.ts b/src/memory/temporal-decay.test.ts index 1c01c16ea35..edb4df553d6 100644 --- a/src/memory/temporal-decay.test.ts +++ b/src/memory/temporal-decay.test.ts @@ -20,6 +20,37 @@ async function makeTempDir(): Promise { return dir; } +function createVectorMemoryEntry(params: { + id: string; + path: string; + snippet: string; + vectorScore: number; +}) { + return { + id: params.id, + path: params.path, + startLine: 1, + endLine: 1, + source: "memory" as const, + snippet: params.snippet, + vectorScore: params.vectorScore, + }; +} + +async function mergeVectorResultsWithTemporalDecay( + vector: Parameters[0]["vector"], +) { + return mergeHybridResults({ + vectorWeight: 1, + textWeight: 0, + temporalDecay: { enabled: true, halfLifeDays: 30 }, + mmr: { enabled: false }, + nowMs: NOW_MS, + vector, + keyword: [], + }); +} + afterEach(async () => { await Promise.all( tempDirs.splice(0).map(async (dir) => { @@ -75,77 +106,46 @@ describe("temporal decay", () => { }); it("applies decay in hybrid merging before ranking", async () => { - const merged = await mergeHybridResults({ - vectorWeight: 1, - textWeight: 0, - temporalDecay: { enabled: true, halfLifeDays: 30 }, - mmr: { enabled: false }, - nowMs: NOW_MS, - vector: [ - { - id: "old", - path: "memory/2025-01-01.md", - startLine: 1, - endLine: 1, - source: "memory", - snippet: "old but high", - vectorScore: 0.95, - }, - { - id: "new", - path: "memory/2026-02-10.md", - startLine: 1, - endLine: 1, - source: "memory", - snippet: "new and relevant", - vectorScore: 0.8, - }, - ], - keyword: [], - }); + const merged = await mergeVectorResultsWithTemporalDecay([ + createVectorMemoryEntry({ + id: "old", + path: "memory/2025-01-01.md", + snippet: "old but high", + vectorScore: 0.95, + }), + createVectorMemoryEntry({ + id: "new", + path: "memory/2026-02-10.md", + snippet: "new and relevant", + vectorScore: 0.8, + }), + ]); expect(merged[0]?.path).toBe("memory/2026-02-10.md"); expect(merged[0]?.score ?? 0).toBeGreaterThan(merged[1]?.score ?? 0); }); it("handles future dates, zero age, and very old memories", async () => { - const merged = await mergeHybridResults({ - vectorWeight: 1, - textWeight: 0, - temporalDecay: { enabled: true, halfLifeDays: 30 }, - mmr: { enabled: false }, - nowMs: NOW_MS, - vector: [ - { - id: "future", - path: "memory/2099-01-01.md", - startLine: 1, - endLine: 1, - source: "memory", - snippet: "future", - vectorScore: 0.9, - }, - { - id: "today", - path: "memory/2026-02-10.md", - startLine: 1, - endLine: 1, - source: "memory", - snippet: "today", - vectorScore: 0.8, - }, - { - id: "very-old", - path: "memory/2000-01-01.md", - startLine: 1, - endLine: 1, - source: "memory", - snippet: "ancient", - vectorScore: 1, - }, - ], - keyword: [], - }); + const merged = await mergeVectorResultsWithTemporalDecay([ + createVectorMemoryEntry({ + id: "future", + path: "memory/2099-01-01.md", + snippet: "future", + vectorScore: 0.9, + }), + createVectorMemoryEntry({ + id: "today", + path: "memory/2026-02-10.md", + snippet: "today", + vectorScore: 0.8, + }), + createVectorMemoryEntry({ + id: "very-old", + path: "memory/2000-01-01.md", + snippet: "ancient", + vectorScore: 1, + }), + ]); const byPath = new Map(merged.map((entry) => [entry.path, entry])); expect(byPath.get("memory/2099-01-01.md")?.score).toBeCloseTo(0.9); From 95f8b91c8a8e929f30297ee6b95fa586cfc60b70 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:08:05 +0000 Subject: [PATCH 049/461] test: share memory search manager fixtures --- src/memory/search-manager.test.ts | 95 ++++++++++++++++++------------- 1 file changed, 57 insertions(+), 38 deletions(-) diff --git a/src/memory/search-manager.test.ts b/src/memory/search-manager.test.ts index 1f705aeddcf..a4feba3f25b 100644 --- a/src/memory/search-manager.test.ts +++ b/src/memory/search-manager.test.ts @@ -29,48 +29,67 @@ function createManagerStatus(params: { }; } +function createManagerMock(params: { + backend: "qmd" | "builtin"; + provider: string; + model: string; + requestedProvider: string; + searchResults?: Array<{ + path: string; + startLine: number; + endLine: number; + score: number; + snippet: string; + source: "memory"; + }>; + withMemorySourceCounts?: boolean; +}) { + return { + search: vi.fn(async () => params.searchResults ?? []), + readFile: vi.fn(async () => ({ text: "", path: "MEMORY.md" })), + status: vi.fn(() => + createManagerStatus({ + backend: params.backend, + provider: params.provider, + model: params.model, + requestedProvider: params.requestedProvider, + withMemorySourceCounts: params.withMemorySourceCounts, + }), + ), + sync: vi.fn(async () => {}), + probeEmbeddingAvailability: vi.fn(async () => ({ ok: true })), + probeVectorAvailability: vi.fn(async () => true), + close: vi.fn(async () => {}), + }; +} + const mockPrimary = vi.hoisted(() => ({ - search: vi.fn(async () => []), - readFile: vi.fn(async () => ({ text: "", path: "MEMORY.md" })), - status: vi.fn(() => - createManagerStatus({ - backend: "qmd", - provider: "qmd", - model: "qmd", - requestedProvider: "qmd", - withMemorySourceCounts: true, - }), - ), - sync: vi.fn(async () => {}), - probeEmbeddingAvailability: vi.fn(async () => ({ ok: true })), - probeVectorAvailability: vi.fn(async () => true), - close: vi.fn(async () => {}), + ...createManagerMock({ + backend: "qmd", + provider: "qmd", + model: "qmd", + requestedProvider: "qmd", + withMemorySourceCounts: true, + }), })); const fallbackManager = vi.hoisted(() => ({ - search: vi.fn(async () => [ - { - path: "MEMORY.md", - startLine: 1, - endLine: 1, - score: 1, - snippet: "fallback", - source: "memory" as const, - }, - ]), - readFile: vi.fn(async () => ({ text: "", path: "MEMORY.md" })), - status: vi.fn(() => - createManagerStatus({ - backend: "builtin", - provider: "openai", - model: "text-embedding-3-small", - requestedProvider: "openai", - }), - ), - sync: vi.fn(async () => {}), - probeEmbeddingAvailability: vi.fn(async () => ({ ok: true })), - probeVectorAvailability: vi.fn(async () => true), - close: vi.fn(async () => {}), + ...createManagerMock({ + backend: "builtin", + provider: "openai", + model: "text-embedding-3-small", + requestedProvider: "openai", + searchResults: [ + { + path: "MEMORY.md", + startLine: 1, + endLine: 1, + score: 1, + snippet: "fallback", + source: "memory", + }, + ], + }), })); const fallbackSearch = fallbackManager.search; From 25eb3d52095f6c379f9576b42d2cebfb2667b653 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:08:45 +0000 Subject: [PATCH 050/461] refactor: share openclaw root package parsing --- src/infra/openclaw-root.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/infra/openclaw-root.ts b/src/infra/openclaw-root.ts index 55b6bf7b91a..8015fcc8450 100644 --- a/src/infra/openclaw-root.ts +++ b/src/infra/openclaw-root.ts @@ -5,11 +5,14 @@ import { fileURLToPath } from "node:url"; const CORE_PACKAGE_NAMES = new Set(["openclaw"]); +function parsePackageName(raw: string): string | null { + const parsed = JSON.parse(raw) as { name?: unknown }; + return typeof parsed.name === "string" ? parsed.name : null; +} + async function readPackageName(dir: string): Promise { try { - const raw = await fs.readFile(path.join(dir, "package.json"), "utf-8"); - const parsed = JSON.parse(raw) as { name?: unknown }; - return typeof parsed.name === "string" ? parsed.name : null; + return parsePackageName(await fs.readFile(path.join(dir, "package.json"), "utf-8")); } catch { return null; } @@ -17,9 +20,7 @@ async function readPackageName(dir: string): Promise { function readPackageNameSync(dir: string): string | null { try { - const raw = fsSync.readFileSync(path.join(dir, "package.json"), "utf-8"); - const parsed = JSON.parse(raw) as { name?: unknown }; - return typeof parsed.name === "string" ? parsed.name : null; + return parsePackageName(fsSync.readFileSync(path.join(dir, "package.json"), "utf-8")); } catch { return null; } From f06ae90884b13e68dc1a07f3a2fe0351d4e4a4f7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:09:02 +0000 Subject: [PATCH 051/461] test: share process respawn launchd assertions --- src/infra/process-respawn.test.ts | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/src/infra/process-respawn.test.ts b/src/infra/process-respawn.test.ts index bacf4e1b24b..804dca82c22 100644 --- a/src/infra/process-respawn.test.ts +++ b/src/infra/process-respawn.test.ts @@ -53,7 +53,10 @@ function clearSupervisorHints() { } } -function expectLaunchdSupervisedWithoutKickstart(params?: { launchJobLabel?: string }) { +function expectLaunchdSupervisedWithoutKickstart(params?: { + launchJobLabel?: string; + detailContains?: string; +}) { setPlatform("darwin"); if (params?.launchJobLabel) { process.env.LAUNCH_JOB_LABEL = params.launchJobLabel; @@ -61,6 +64,9 @@ function expectLaunchdSupervisedWithoutKickstart(params?: { launchJobLabel?: str process.env.OPENCLAW_LAUNCHD_LABEL = "ai.openclaw.gateway"; const result = restartGatewayProcessWithFreshPid(); expect(result.mode).toBe("supervised"); + if (params?.detailContains) { + expect(result.detail).toContain(params.detailContains); + } expect(scheduleDetachedLaunchdRestartHandoffMock).toHaveBeenCalledWith({ env: process.env, mode: "start-after-exit", @@ -80,18 +86,10 @@ describe("restartGatewayProcessWithFreshPid", () => { it("returns supervised when launchd hints are present on macOS (no kickstart)", () => { clearSupervisorHints(); - setPlatform("darwin"); - process.env.LAUNCH_JOB_LABEL = "ai.openclaw.gateway"; - const result = restartGatewayProcessWithFreshPid(); - expect(result.mode).toBe("supervised"); - expect(result.detail).toContain("launchd restart handoff"); - expect(scheduleDetachedLaunchdRestartHandoffMock).toHaveBeenCalledWith({ - env: process.env, - mode: "start-after-exit", - waitForPid: process.pid, + expectLaunchdSupervisedWithoutKickstart({ + launchJobLabel: "ai.openclaw.gateway", + detailContains: "launchd restart handoff", }); - expect(triggerOpenClawRestartMock).not.toHaveBeenCalled(); - expect(spawnMock).not.toHaveBeenCalled(); }); it("returns supervised on macOS when launchd label is set (no kickstart)", () => { From a2fcaf97748813012329fc6d8111969265ddbae3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:09:36 +0000 Subject: [PATCH 052/461] test: share plugin install path fixtures --- .../plugin-install-path-warnings.test.ts | 67 +++++++++---------- 1 file changed, 31 insertions(+), 36 deletions(-) diff --git a/src/infra/plugin-install-path-warnings.test.ts b/src/infra/plugin-install-path-warnings.test.ts index eef3348fb06..eb7ba108df1 100644 --- a/src/infra/plugin-install-path-warnings.test.ts +++ b/src/infra/plugin-install-path-warnings.test.ts @@ -7,6 +7,25 @@ import { formatPluginInstallPathIssue, } from "./plugin-install-path-warnings.js"; +async function detectMatrixCustomPathIssue(sourcePath: string | ((pluginPath: string) => string)) { + return withTempHome(async (home) => { + const pluginPath = path.join(home, "matrix-plugin"); + await fs.mkdir(pluginPath, { recursive: true }); + const resolvedSourcePath = + typeof sourcePath === "function" ? sourcePath(pluginPath) : sourcePath; + const issue = await detectPluginInstallPathIssue({ + pluginId: "matrix", + install: { + source: "path", + sourcePath: resolvedSourcePath, + installPath: pluginPath, + }, + }); + + return { issue, pluginPath }; + }); +} + describe("plugin install path warnings", () => { it("ignores non-path installs and blank path candidates", async () => { expect( @@ -57,46 +76,22 @@ describe("plugin install path warnings", () => { }); it("uses the second candidate path when the first one is stale", async () => { - await withTempHome(async (home) => { - const pluginPath = path.join(home, "matrix-plugin"); - await fs.mkdir(pluginPath, { recursive: true }); - - const issue = await detectPluginInstallPathIssue({ - pluginId: "matrix", - install: { - source: "path", - sourcePath: "/tmp/openclaw-matrix-missing", - installPath: pluginPath, - }, - }); - - expect(issue).toEqual({ - kind: "custom-path", - pluginId: "matrix", - path: pluginPath, - }); + const { issue, pluginPath } = await detectMatrixCustomPathIssue("/tmp/openclaw-matrix-missing"); + expect(issue).toEqual({ + kind: "custom-path", + pluginId: "matrix", + path: pluginPath, }); }); it("detects active custom plugin install paths", async () => { - await withTempHome(async (home) => { - const pluginPath = path.join(home, "matrix-plugin"); - await fs.mkdir(pluginPath, { recursive: true }); - - const issue = await detectPluginInstallPathIssue({ - pluginId: "matrix", - install: { - source: "path", - sourcePath: pluginPath, - installPath: pluginPath, - }, - }); - - expect(issue).toEqual({ - kind: "custom-path", - pluginId: "matrix", - path: pluginPath, - }); + const { issue, pluginPath } = await detectMatrixCustomPathIssue( + (resolvedPluginPath) => resolvedPluginPath, + ); + expect(issue).toEqual({ + kind: "custom-path", + pluginId: "matrix", + path: pluginPath, }); }); From 8f852ef82fe9f56a0197941c272d3db47906b297 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:10:24 +0000 Subject: [PATCH 053/461] refactor: share system run success delivery --- src/node-host/invoke-system-run.ts | 47 ++++++++++++++++-------------- 1 file changed, 25 insertions(+), 22 deletions(-) diff --git a/src/node-host/invoke-system-run.ts b/src/node-host/invoke-system-run.ts index 3730e3b2824..32bd2d6ff79 100644 --- a/src/node-host/invoke-system-run.ts +++ b/src/node-host/invoke-system-run.ts @@ -182,6 +182,25 @@ async function sendSystemRunDenied( }); } +async function sendSystemRunCompleted( + opts: Pick, + execution: SystemRunExecutionContext, + result: ExecFinishedResult, + payloadJSON: string, +) { + await opts.sendExecFinishedEvent({ + sessionKey: execution.sessionKey, + runId: execution.runId, + commandText: execution.commandText, + result, + suppressNotifyOnExit: execution.suppressNotifyOnExit, + }); + await opts.sendInvokeResult({ + ok: true, + payloadJSON, + }); +} + export { formatSystemRunAllowlistMissMessage } from "./exec-policy.js"; export { buildSystemRunApprovalPlan } from "./invoke-system-run-plan.js"; @@ -462,17 +481,7 @@ async function executeSystemRunPhase( return; } else { const result: ExecHostRunResult = response.payload; - await opts.sendExecFinishedEvent({ - sessionKey: phase.sessionKey, - runId: phase.runId, - commandText: phase.commandText, - result, - suppressNotifyOnExit: phase.suppressNotifyOnExit, - }); - await opts.sendInvokeResult({ - ok: true, - payloadJSON: JSON.stringify(result), - }); + await sendSystemRunCompleted(opts, phase.execution, result, JSON.stringify(result)); return; } } @@ -530,17 +539,11 @@ async function executeSystemRunPhase( const result = await opts.runCommand(execArgv, phase.cwd, phase.env, phase.timeoutMs); applyOutputTruncation(result); - await opts.sendExecFinishedEvent({ - sessionKey: phase.sessionKey, - runId: phase.runId, - commandText: phase.commandText, + await sendSystemRunCompleted( + opts, + phase.execution, result, - suppressNotifyOnExit: phase.suppressNotifyOnExit, - }); - - await opts.sendInvokeResult({ - ok: true, - payloadJSON: JSON.stringify({ + JSON.stringify({ exitCode: result.exitCode, timedOut: result.timedOut, success: result.success, @@ -548,7 +551,7 @@ async function executeSystemRunPhase( stderr: result.stderr, error: result.error ?? null, }), - }); + ); } export async function handleSystemRunInvoke(opts: HandleSystemRunInvokeOptions): Promise { From ef15600b3e01d396de1219f035c3f60af4857b05 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:11:00 +0000 Subject: [PATCH 054/461] refactor: share request body chunk accounting --- src/infra/http-body.ts | 34 +++++++++++++++++++++++++++------- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/src/infra/http-body.ts b/src/infra/http-body.ts index cfa65560028..b578bf14552 100644 --- a/src/infra/http-body.ts +++ b/src/infra/http-body.ts @@ -84,6 +84,12 @@ type RequestBodyLimitValues = { timeoutMs: number; }; +type RequestBodyChunkProgress = { + buffer: Buffer; + totalBytes: number; + exceeded: boolean; +}; + function resolveRequestBodyLimitValues(options: { maxBytes: number; timeoutMs?: number; @@ -98,6 +104,20 @@ function resolveRequestBodyLimitValues(options: { return { maxBytes, timeoutMs }; } +function advanceRequestBodyChunk( + chunk: Buffer | string, + totalBytes: number, + maxBytes: number, +): RequestBodyChunkProgress { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + const nextTotalBytes = totalBytes + buffer.length; + return { + buffer, + totalBytes: nextTotalBytes, + exceeded: nextTotalBytes > maxBytes, + }; +} + export async function readRequestBodyWithLimit( req: IncomingMessage, options: ReadRequestBodyOptions, @@ -155,9 +175,9 @@ export async function readRequestBodyWithLimit( if (done) { return; } - const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); - totalBytes += buffer.length; - if (totalBytes > maxBytes) { + const progress = advanceRequestBodyChunk(chunk, totalBytes, maxBytes); + totalBytes = progress.totalBytes; + if (progress.exceeded) { const error = new RequestBodyLimitError({ code: "PAYLOAD_TOO_LARGE" }); if (!req.destroyed) { req.destroy(); @@ -165,7 +185,7 @@ export async function readRequestBodyWithLimit( fail(error); return; } - chunks.push(buffer); + chunks.push(progress.buffer); }; const onEnd = () => { @@ -313,9 +333,9 @@ export function installRequestBodyLimitGuard( if (done) { return; } - const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); - totalBytes += buffer.length; - if (totalBytes > maxBytes) { + const progress = advanceRequestBodyChunk(chunk, totalBytes, maxBytes); + totalBytes = progress.totalBytes; + if (progress.exceeded) { trip(new RequestBodyLimitError({ code: "PAYLOAD_TOO_LARGE" })); } }; From 84a50acb5531eb7d6a0278f4aae44a7ceea797d1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:11:34 +0000 Subject: [PATCH 055/461] refactor: share portable env entry normalization --- src/infra/host-env-security.ts | 43 +++++++++++++++++----------------- 1 file changed, 21 insertions(+), 22 deletions(-) diff --git a/src/infra/host-env-security.ts b/src/infra/host-env-security.ts index 8c5d0989fdd..11d6b8e9f3c 100644 --- a/src/infra/host-env-security.ts +++ b/src/infra/host-env-security.ts @@ -80,6 +80,23 @@ export function isDangerousHostEnvOverrideVarName(rawKey: string): boolean { return HOST_DANGEROUS_OVERRIDE_ENV_PREFIXES.some((prefix) => upper.startsWith(prefix)); } +function listNormalizedPortableEnvEntries( + source: Record, +): Array<[string, string]> { + const entries: Array<[string, string]> = []; + for (const [rawKey, value] of Object.entries(source)) { + if (typeof value !== "string") { + continue; + } + const key = normalizeEnvVarKey(rawKey, { portable: true }); + if (!key) { + continue; + } + entries.push([key, value]); + } + return entries; +} + export function sanitizeHostExecEnv(params?: { baseEnv?: Record; overrides?: Record | null; @@ -90,12 +107,8 @@ export function sanitizeHostExecEnv(params?: { const blockPathOverrides = params?.blockPathOverrides ?? true; const merged: Record = {}; - for (const [rawKey, value] of Object.entries(baseEnv)) { - if (typeof value !== "string") { - continue; - } - const key = normalizeEnvVarKey(rawKey, { portable: true }); - if (!key || isDangerousHostEnvVarName(key)) { + for (const [key, value] of listNormalizedPortableEnvEntries(baseEnv)) { + if (isDangerousHostEnvVarName(key)) { continue; } merged[key] = value; @@ -105,14 +118,7 @@ export function sanitizeHostExecEnv(params?: { return markOpenClawExecEnv(merged); } - for (const [rawKey, value] of Object.entries(overrides)) { - if (typeof value !== "string") { - continue; - } - const key = normalizeEnvVarKey(rawKey, { portable: true }); - if (!key) { - continue; - } + for (const [key, value] of listNormalizedPortableEnvEntries(overrides)) { const upper = key.toUpperCase(); // PATH is part of the security boundary (command resolution + safe-bin checks). Never allow // request-scoped PATH overrides from agents/gateways. @@ -140,14 +146,7 @@ export function sanitizeSystemRunEnvOverrides(params?: { return overrides; } const filtered: Record = {}; - for (const [rawKey, value] of Object.entries(overrides)) { - if (typeof value !== "string") { - continue; - } - const key = normalizeEnvVarKey(rawKey, { portable: true }); - if (!key) { - continue; - } + for (const [key, value] of listNormalizedPortableEnvEntries(overrides)) { if (!HOST_SHELL_WRAPPER_ALLOWED_OVERRIDE_ENV_KEYS.has(key.toUpperCase())) { continue; } From ed3dd6a1a081110dd702f68cdde6bd99ddad62d1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:11:56 +0000 Subject: [PATCH 056/461] test: share install flow failure harness --- src/infra/install-flow.test.ts | 39 ++++++++++++++++------------------ 1 file changed, 18 insertions(+), 21 deletions(-) diff --git a/src/infra/install-flow.test.ts b/src/infra/install-flow.test.ts index 1c3c46ac5ee..a1a7e80e3ad 100644 --- a/src/infra/install-flow.test.ts +++ b/src/infra/install-flow.test.ts @@ -6,6 +6,19 @@ import * as archive from "./archive.js"; import { resolveExistingInstallPath, withExtractedArchiveRoot } from "./install-flow.js"; import * as installSource from "./install-source-utils.js"; +async function runExtractedArchiveFailureCase(configureArchive: () => void) { + vi.spyOn(installSource, "withTempDir").mockImplementation( + async (_prefix, fn) => await fn("/tmp/openclaw-install-flow"), + ); + configureArchive(); + return await withExtractedArchiveRoot({ + archivePath: "/tmp/plugin.tgz", + tempDirPrefix: "openclaw-plugin-", + timeoutMs: 1000, + onExtracted: async () => ({ ok: true as const }), + }); +} + describe("resolveExistingInstallPath", () => { let fixtureRoot = ""; @@ -84,16 +97,8 @@ describe("withExtractedArchiveRoot", () => { }); it("returns extract failure when extraction throws", async () => { - vi.spyOn(installSource, "withTempDir").mockImplementation( - async (_prefix, fn) => await fn("/tmp/openclaw-install-flow"), - ); - vi.spyOn(archive, "extractArchive").mockRejectedValue(new Error("boom")); - - const result = await withExtractedArchiveRoot({ - archivePath: "/tmp/plugin.tgz", - tempDirPrefix: "openclaw-plugin-", - timeoutMs: 1000, - onExtracted: async () => ({ ok: true as const }), + const result = await runExtractedArchiveFailureCase(() => { + vi.spyOn(archive, "extractArchive").mockRejectedValue(new Error("boom")); }); expect(result).toEqual({ @@ -103,17 +108,9 @@ describe("withExtractedArchiveRoot", () => { }); it("returns root-resolution failure when archive layout is invalid", async () => { - vi.spyOn(installSource, "withTempDir").mockImplementation( - async (_prefix, fn) => await fn("/tmp/openclaw-install-flow"), - ); - vi.spyOn(archive, "extractArchive").mockResolvedValue(undefined); - vi.spyOn(archive, "resolvePackedRootDir").mockRejectedValue(new Error("invalid layout")); - - const result = await withExtractedArchiveRoot({ - archivePath: "/tmp/plugin.tgz", - tempDirPrefix: "openclaw-plugin-", - timeoutMs: 1000, - onExtracted: async () => ({ ok: true as const }), + const result = await runExtractedArchiveFailureCase(() => { + vi.spyOn(archive, "extractArchive").mockResolvedValue(undefined); + vi.spyOn(archive, "resolvePackedRootDir").mockRejectedValue(new Error("invalid layout")); }); expect(result).toEqual({ From 29bc011ec7b9a6df0ceb8a49bf7f81bcbc0a0421 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:12:18 +0000 Subject: [PATCH 057/461] test: share heartbeat retry fixtures --- src/infra/heartbeat-wake.test.ts | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/src/infra/heartbeat-wake.test.ts b/src/infra/heartbeat-wake.test.ts index 1f800c655ed..5a88210505c 100644 --- a/src/infra/heartbeat-wake.test.ts +++ b/src/infra/heartbeat-wake.test.ts @@ -8,6 +8,15 @@ import { } from "./heartbeat-wake.js"; describe("heartbeat-wake", () => { + function setRetryOnceHeartbeatHandler() { + const handler = vi + .fn() + .mockResolvedValueOnce({ status: "skipped", reason: "requests-in-flight" }) + .mockResolvedValueOnce({ status: "ran", durationMs: 1 }); + setHeartbeatWakeHandler(handler); + return handler; + } + async function expectRetryAfterDefaultDelay(params: { handler: ReturnType; initialReason: string; @@ -74,11 +83,7 @@ describe("heartbeat-wake", () => { it("keeps retry cooldown even when a sooner request arrives", async () => { vi.useFakeTimers(); - const handler = vi - .fn() - .mockResolvedValueOnce({ status: "skipped", reason: "requests-in-flight" }) - .mockResolvedValueOnce({ status: "ran", durationMs: 1 }); - setHeartbeatWakeHandler(handler); + const handler = setRetryOnceHeartbeatHandler(); requestHeartbeatNow({ reason: "interval", coalesceMs: 0 }); await vi.advanceTimersByTimeAsync(1); @@ -252,11 +257,7 @@ describe("heartbeat-wake", () => { it("forwards wake target fields and preserves them across retries", async () => { vi.useFakeTimers(); - const handler = vi - .fn() - .mockResolvedValueOnce({ status: "skipped", reason: "requests-in-flight" }) - .mockResolvedValueOnce({ status: "ran", durationMs: 1 }); - setHeartbeatWakeHandler(handler); + const handler = setRetryOnceHeartbeatHandler(); requestHeartbeatNow({ reason: "cron:job-1", From 7235ee55c6d997e93daa663af53c7e06ef7cad78 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:13:05 +0000 Subject: [PATCH 058/461] test: share APNs direct send fixtures --- src/infra/push-apns.test.ts | 104 ++++++++++++++++++------------------ 1 file changed, 53 insertions(+), 51 deletions(-) diff --git a/src/infra/push-apns.test.ts b/src/infra/push-apns.test.ts index 83da4ae3165..a2c616e81b4 100644 --- a/src/infra/push-apns.test.ts +++ b/src/infra/push-apns.test.ts @@ -49,6 +49,29 @@ async function makeTempDir(): Promise { return dir; } +function createDirectApnsSendFixture(params: { + nodeId: string; + environment: "sandbox" | "production"; + sendResult: { status: number; apnsId: string; body: string }; +}) { + return { + send: vi.fn().mockResolvedValue(params.sendResult), + registration: { + nodeId: params.nodeId, + transport: "direct" as const, + token: "ABCD1234ABCD1234ABCD1234ABCD1234", + topic: "ai.openclaw.ios", + environment: params.environment, + updatedAtMs: 1, + }, + auth: { + teamId: "TEAM123", + keyId: "KEY123", + privateKey: testAuthPrivateKey, + }, + }; +} + afterEach(async () => { vi.unstubAllGlobals(); while (tempDirs.length > 0) { @@ -447,29 +470,22 @@ describe("push APNs env config", () => { describe("push APNs send semantics", () => { it("sends alert pushes with alert headers and payload", async () => { - const send = vi.fn().mockResolvedValue({ - status: 200, - apnsId: "apns-alert-id", - body: "", + const { send, registration, auth } = createDirectApnsSendFixture({ + nodeId: "ios-node-alert", + environment: "sandbox", + sendResult: { + status: 200, + apnsId: "apns-alert-id", + body: "", + }, }); const result = await sendApnsAlert({ - registration: { - nodeId: "ios-node-alert", - transport: "direct", - token: "ABCD1234ABCD1234ABCD1234ABCD1234", - topic: "ai.openclaw.ios", - environment: "sandbox", - updatedAtMs: 1, - }, + registration, nodeId: "ios-node-alert", title: "Wake", body: "Ping", - auth: { - teamId: "TEAM123", - keyId: "KEY123", - privateKey: testAuthPrivateKey, - }, + auth, requestSender: send, }); @@ -493,28 +509,21 @@ describe("push APNs send semantics", () => { }); it("sends background wake pushes with silent payload semantics", async () => { - const send = vi.fn().mockResolvedValue({ - status: 200, - apnsId: "apns-wake-id", - body: "", + const { send, registration, auth } = createDirectApnsSendFixture({ + nodeId: "ios-node-wake", + environment: "production", + sendResult: { + status: 200, + apnsId: "apns-wake-id", + body: "", + }, }); const result = await sendApnsBackgroundWake({ - registration: { - nodeId: "ios-node-wake", - transport: "direct", - token: "ABCD1234ABCD1234ABCD1234ABCD1234", - topic: "ai.openclaw.ios", - environment: "production", - updatedAtMs: 1, - }, + registration, nodeId: "ios-node-wake", wakeReason: "node.invoke", - auth: { - teamId: "TEAM123", - keyId: "KEY123", - privateKey: testAuthPrivateKey, - }, + auth, requestSender: send, }); @@ -542,27 +551,20 @@ describe("push APNs send semantics", () => { }); it("defaults background wake reason when not provided", async () => { - const send = vi.fn().mockResolvedValue({ - status: 200, - apnsId: "apns-wake-default-reason-id", - body: "", + const { send, registration, auth } = createDirectApnsSendFixture({ + nodeId: "ios-node-wake-default-reason", + environment: "sandbox", + sendResult: { + status: 200, + apnsId: "apns-wake-default-reason-id", + body: "", + }, }); await sendApnsBackgroundWake({ - registration: { - nodeId: "ios-node-wake-default-reason", - transport: "direct", - token: "ABCD1234ABCD1234ABCD1234ABCD1234", - topic: "ai.openclaw.ios", - environment: "sandbox", - updatedAtMs: 1, - }, + registration, nodeId: "ios-node-wake-default-reason", - auth: { - teamId: "TEAM123", - keyId: "KEY123", - privateKey: testAuthPrivateKey, - }, + auth, requestSender: send, }); From a6375a2094e47f214aead85082454b19986f78c6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:14:54 +0000 Subject: [PATCH 059/461] refactor: share zalouser account resolution --- extensions/zalouser/src/accounts.ts | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/extensions/zalouser/src/accounts.ts b/extensions/zalouser/src/accounts.ts index 5ebec2d2c93..26a02ed47a0 100644 --- a/extensions/zalouser/src/accounts.ts +++ b/extensions/zalouser/src/accounts.ts @@ -43,17 +43,24 @@ function resolveProfile(config: ZalouserAccountConfig, accountId: string): strin return "default"; } -export async function resolveZalouserAccount(params: { - cfg: OpenClawConfig; - accountId?: string | null; -}): Promise { +function resolveZalouserAccountBase(params: { cfg: OpenClawConfig; accountId?: string | null }) { const accountId = normalizeAccountId(params.accountId); const baseEnabled = (params.cfg.channels?.zalouser as ZalouserConfig | undefined)?.enabled !== false; const merged = mergeZalouserAccountConfig(params.cfg, accountId); - const accountEnabled = merged.enabled !== false; - const enabled = baseEnabled && accountEnabled; - const profile = resolveProfile(merged, accountId); + return { + accountId, + enabled: baseEnabled && merged.enabled !== false, + merged, + profile: resolveProfile(merged, accountId), + }; +} + +export async function resolveZalouserAccount(params: { + cfg: OpenClawConfig; + accountId?: string | null; +}): Promise { + const { accountId, enabled, merged, profile } = resolveZalouserAccountBase(params); const authenticated = await checkZaloAuthenticated(profile); return { @@ -70,13 +77,7 @@ export function resolveZalouserAccountSync(params: { cfg: OpenClawConfig; accountId?: string | null; }): ResolvedZalouserAccount { - const accountId = normalizeAccountId(params.accountId); - const baseEnabled = - (params.cfg.channels?.zalouser as ZalouserConfig | undefined)?.enabled !== false; - const merged = mergeZalouserAccountConfig(params.cfg, accountId); - const accountEnabled = merged.enabled !== false; - const enabled = baseEnabled && accountEnabled; - const profile = resolveProfile(merged, accountId); + const { accountId, enabled, merged, profile } = resolveZalouserAccountBase(params); return { accountId, From fa04e622013f2fe6ad47a2e52e828eb11e768ece Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:40:50 +0000 Subject: [PATCH 060/461] test: tighten shared tailscale and sample coverage --- src/shared/string-sample.test.ts | 10 ++++++++++ src/shared/tailscale-status.test.ts | 22 ++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/src/shared/string-sample.test.ts b/src/shared/string-sample.test.ts index 7ced1e7407a..f86e8e1b349 100644 --- a/src/shared/string-sample.test.ts +++ b/src/shared/string-sample.test.ts @@ -42,4 +42,14 @@ describe("summarizeStringEntries", () => { }), ).toBe("a, b, c, d, e, f (+1)"); }); + + it("does not add a suffix when the limit exactly matches the entry count", () => { + expect( + summarizeStringEntries({ + entries: ["a", "b", "c"], + limit: 3, + emptyText: "ignored", + }), + ).toBe("a, b, c"); + }); }); diff --git a/src/shared/tailscale-status.test.ts b/src/shared/tailscale-status.test.ts index 5826e4b00b3..94128e700ed 100644 --- a/src/shared/tailscale-status.test.ts +++ b/src/shared/tailscale-status.test.ts @@ -32,6 +32,28 @@ describe("shared/tailscale-status", () => { ); }); + it("falls back to the first tailscale ip when DNSName is blank", async () => { + const run = vi.fn().mockResolvedValue({ + code: 0, + stdout: '{"Self":{"DNSName":"","TailscaleIPs":["100.64.0.10","fd7a::2"]}}', + }); + + await expect(resolveTailnetHostWithRunner(run)).resolves.toBe("100.64.0.10"); + }); + + it("continues to later command candidates when earlier output has no usable host", async () => { + const run = vi + .fn() + .mockResolvedValueOnce({ code: 0, stdout: '{"Self":{}}' }) + .mockResolvedValueOnce({ + code: 0, + stdout: '{"Self":{"DNSName":"backup.tail.ts.net."}}', + }); + + await expect(resolveTailnetHostWithRunner(run)).resolves.toBe("backup.tail.ts.net"); + expect(run).toHaveBeenCalledTimes(2); + }); + it("returns null for non-zero exits, blank output, or invalid json", async () => { const run = vi .fn() From 73c2edbc0c1166185c8ebc0ee8c87ee112b74f13 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:42:07 +0000 Subject: [PATCH 061/461] test: tighten shared code region coverage --- src/shared/global-singleton.test.ts | 10 ++++++++++ src/shared/text/code-regions.test.ts | 12 ++++++++++++ 2 files changed, 22 insertions(+) diff --git a/src/shared/global-singleton.test.ts b/src/shared/global-singleton.test.ts index 3d537f5cc4b..34b4e8817f3 100644 --- a/src/shared/global-singleton.test.ts +++ b/src/shared/global-singleton.test.ts @@ -52,4 +52,14 @@ describe("resolveGlobalMap", () => { expect(resolveGlobalMap(TEST_MAP_KEY).get("a")).toBe(1); }); + + it("reuses a prepopulated global map without creating a new one", () => { + const existing = new Map([["a", 1]]); + (globalThis as Record)[TEST_MAP_KEY] = existing; + + const resolved = resolveGlobalMap(TEST_MAP_KEY); + + expect(resolved).toBe(existing); + expect(resolved.get("a")).toBe(1); + }); }); diff --git a/src/shared/text/code-regions.test.ts b/src/shared/text/code-regions.test.ts index 05934383bd2..2a9c2a05360 100644 --- a/src/shared/text/code-regions.test.ts +++ b/src/shared/text/code-regions.test.ts @@ -27,13 +27,25 @@ describe("shared/text/code-regions", () => { expect(text.slice(regions[1].start, regions[1].end)).toBe("```\nunterminated"); }); + it("keeps adjacent inline code outside fenced regions", () => { + const text = ["```ts", "const a = 1;", "```", "after `inline` tail"].join("\n"); + + const regions = findCodeRegions(text); + + expect(regions).toHaveLength(2); + expect(text.slice(regions[0].start, regions[0].end)).toContain("```ts"); + expect(text.slice(regions[1].start, regions[1].end)).toBe("`inline`"); + }); + it("reports whether positions are inside discovered regions", () => { const text = "plain `code` done"; const regions = findCodeRegions(text); const codeStart = text.indexOf("code"); const plainStart = text.indexOf("plain"); + const regionEnd = regions[0]?.end ?? -1; expect(isInsideCode(codeStart, regions)).toBe(true); expect(isInsideCode(plainStart, regions)).toBe(false); + expect(isInsideCode(regionEnd, regions)).toBe(false); }); }); From dd54b6f4c779dd3e330e6625ee56a172f275c70e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:43:01 +0000 Subject: [PATCH 062/461] test: tighten shared node match coverage --- src/shared/node-match.test.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/shared/node-match.test.ts b/src/shared/node-match.test.ts index 2ddc3663d3f..9db461b17e5 100644 --- a/src/shared/node-match.test.ts +++ b/src/shared/node-match.test.ts @@ -5,6 +5,7 @@ describe("shared/node-match", () => { it("normalizes node keys by lowercasing and collapsing separators", () => { expect(normalizeNodeKey(" Mac Studio! ")).toBe("mac-studio"); expect(normalizeNodeKey("---PI__Node---")).toBe("pi-node"); + expect(normalizeNodeKey("###")).toBe(""); }); it("matches candidates by node id, remote ip, normalized name, and long prefix", () => { @@ -16,6 +17,7 @@ describe("shared/node-match", () => { expect(resolveNodeMatches(nodes, "mac-abcdef")).toEqual([nodes[0]]); expect(resolveNodeMatches(nodes, "100.0.0.2")).toEqual([nodes[1]]); expect(resolveNodeMatches(nodes, "mac studio")).toEqual([nodes[0]]); + expect(resolveNodeMatches(nodes, " Mac---Studio!! ")).toEqual([nodes[0]]); expect(resolveNodeMatches(nodes, "pi-456")).toEqual([nodes[1]]); expect(resolveNodeMatches(nodes, "pi")).toEqual([]); expect(resolveNodeMatches(nodes, " ")).toEqual([]); @@ -33,6 +35,18 @@ describe("shared/node-match", () => { ).toBe("ios-live"); }); + it("falls back to raw ambiguous matches when none of them are connected", () => { + expect(() => + resolveNodeIdFromCandidates( + [ + { nodeId: "ios-a", displayName: "iPhone", connected: false }, + { nodeId: "ios-b", displayName: "iPhone", connected: false }, + ], + "iphone", + ), + ).toThrow(/ambiguous node: iphone.*matches: iPhone, iPhone/); + }); + it("throws clear unknown and ambiguous node errors", () => { expect(() => resolveNodeIdFromCandidates( @@ -56,4 +70,13 @@ describe("shared/node-match", () => { expect(() => resolveNodeIdFromCandidates([], "")).toThrow(/node required/); }); + + it("lists remote ips in unknown-node errors when display names are missing", () => { + expect(() => + resolveNodeIdFromCandidates( + [{ nodeId: "mac-123", remoteIp: "100.0.0.1" }, { nodeId: "pi-456" }], + "nope", + ), + ).toThrow(/unknown node: nope.*known: 100.0.0.1, pi-456/); + }); }); From eea41f308e29b7d7a1b6d230a650bb541f705470 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:44:11 +0000 Subject: [PATCH 063/461] fix: tighten shared subagent format coverage --- src/shared/subagents-format.test.ts | 16 ++++++++++++++++ src/shared/subagents-format.ts | 4 ++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/shared/subagents-format.test.ts b/src/shared/subagents-format.test.ts index 34d1f9a8d5d..c058c19ccd1 100644 --- a/src/shared/subagents-format.test.ts +++ b/src/shared/subagents-format.test.ts @@ -12,7 +12,9 @@ describe("shared/subagents-format", () => { it("formats compact durations across minute, hour, and day buckets", () => { expect(formatDurationCompact()).toBe("n/a"); expect(formatDurationCompact(30_000)).toBe("1m"); + expect(formatDurationCompact(60 * 60_000)).toBe("1h"); expect(formatDurationCompact(61 * 60_000)).toBe("1h1m"); + expect(formatDurationCompact(24 * 60 * 60_000)).toBe("1d"); expect(formatDurationCompact(25 * 60 * 60_000)).toBe("1d1h"); }); @@ -20,7 +22,9 @@ describe("shared/subagents-format", () => { expect(formatTokenShort()).toBeUndefined(); expect(formatTokenShort(999.9)).toBe("999"); expect(formatTokenShort(1_500)).toBe("1.5k"); + expect(formatTokenShort(10_000)).toBe("10k"); expect(formatTokenShort(15_400)).toBe("15k"); + expect(formatTokenShort(1_000_000)).toBe("1m"); expect(formatTokenShort(1_250_000)).toBe("1.3m"); }); @@ -40,6 +44,11 @@ describe("shared/subagents-format", () => { output: 5, total: 15, }); + expect(resolveIoTokens({ outputTokens: 5 })).toEqual({ + input: 0, + output: 5, + total: 5, + }); expect(resolveIoTokens({ inputTokens: Number.NaN, outputTokens: 0 })).toBeUndefined(); }); @@ -53,6 +62,13 @@ describe("shared/subagents-format", () => { ).toBe("tokens 1.5k (in 1.2k / out 300), prompt/cache 2.1k"); expect(formatTokenUsageDisplay({ totalTokens: 500 })).toBe("tokens 500 prompt/cache"); + expect( + formatTokenUsageDisplay({ + inputTokens: 1_200, + outputTokens: 300, + totalTokens: 1_500, + }), + ).toBe("tokens 1.5k (in 1.2k / out 300)"); expect(formatTokenUsageDisplay({ inputTokens: 0, outputTokens: 0, totalTokens: 0 })).toBe(""); }); }); diff --git a/src/shared/subagents-format.ts b/src/shared/subagents-format.ts index f31ec9e9d4e..643c4b58ca5 100644 --- a/src/shared/subagents-format.ts +++ b/src/shared/subagents-format.ts @@ -25,12 +25,12 @@ export function formatTokenShort(value?: number) { return `${n}`; } if (n < 10_000) { - return `${(n / 1_000).toFixed(1).replace(/\\.0$/, "")}k`; + return `${(n / 1_000).toFixed(1).replace(/\.0$/, "")}k`; } if (n < 1_000_000) { return `${Math.round(n / 1_000)}k`; } - return `${(n / 1_000_000).toFixed(1).replace(/\\.0$/, "")}m`; + return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}m`; } export function truncateLine(value: string, maxLength: number) { From c659f6c95938a0247f9317cfbf6005cf014d9768 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:43:13 +0000 Subject: [PATCH 064/461] fix: improve onboarding install diagnostics --- scripts/install.sh | 45 +++++++- .../onboard-non-interactive.gateway.test.ts | 91 +++++++++++++++ src/commands/onboard-non-interactive/local.ts | 106 +++++++++++++++--- .../onboard-non-interactive/local/output.ts | 101 +++++++++++++++++ 4 files changed, 329 insertions(+), 14 deletions(-) diff --git a/scripts/install.sh b/scripts/install.sh index ea02c48b6db..2abfbad9935 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -995,6 +995,7 @@ SHARP_IGNORE_GLOBAL_LIBVIPS="${SHARP_IGNORE_GLOBAL_LIBVIPS:-1}" NPM_LOGLEVEL="${OPENCLAW_NPM_LOGLEVEL:-error}" NPM_SILENT_FLAG="--silent" VERBOSE="${OPENCLAW_VERBOSE:-0}" +VERIFY_INSTALL="${OPENCLAW_VERIFY_INSTALL:-0}" OPENCLAW_BIN="" PNPM_CMD=() HELP=0 @@ -1016,6 +1017,7 @@ Options: --no-git-update Skip git pull for existing checkout --no-onboard Skip onboarding (non-interactive) --no-prompt Disable prompts (required in CI/automation) + --verify Run a post-install smoke verify --dry-run Print what would happen (no changes) --verbose Print debug output (set -x, npm verbose) --help, -h Show this help @@ -1027,6 +1029,7 @@ Environment variables: OPENCLAW_GIT_DIR=... OPENCLAW_GIT_UPDATE=0|1 OPENCLAW_NO_PROMPT=1 + OPENCLAW_VERIFY_INSTALL=1 OPENCLAW_DRY_RUN=1 OPENCLAW_NO_ONBOARD=1 OPENCLAW_VERBOSE=1 @@ -1036,6 +1039,7 @@ Environment variables: Examples: curl -fsSL --proto '=https' --tlsv1.2 https://openclaw.ai/install.sh | bash curl -fsSL --proto '=https' --tlsv1.2 https://openclaw.ai/install.sh | bash -s -- --no-onboard + curl -fsSL --proto '=https' --tlsv1.2 https://openclaw.ai/install.sh | bash -s -- --no-onboard --verify curl -fsSL --proto '=https' --tlsv1.2 https://openclaw.ai/install.sh | bash -s -- --install-method git --no-onboard EOF } @@ -1059,6 +1063,10 @@ parse_args() { VERBOSE=1 shift ;; + --verify) + VERIFY_INSTALL=1 + shift + ;; --no-prompt) NO_PROMPT=1 shift @@ -2196,7 +2204,38 @@ refresh_gateway_service_if_loaded() { return 0 fi - run_quiet_step "Probing gateway service" "$claw" gateway status --probe --deep || true + run_quiet_step "Probing gateway service" "$claw" gateway status --deep || true +} + +verify_installation() { + if [[ "${VERIFY_INSTALL}" != "1" ]]; then + return 0 + fi + + ui_stage "Verifying installation" + local claw="${OPENCLAW_BIN:-}" + if [[ -z "$claw" ]]; then + claw="$(resolve_openclaw_bin || true)" + fi + if [[ -z "$claw" ]]; then + ui_error "Install verify failed: openclaw not on PATH yet" + warn_openclaw_not_found + return 1 + fi + + run_quiet_step "Checking OpenClaw version" "$claw" --version || return 1 + + if is_gateway_daemon_loaded "$claw"; then + run_quiet_step "Checking gateway service" "$claw" gateway status --deep || { + ui_error "Install verify failed: gateway service unhealthy" + ui_info "Run: openclaw gateway status --deep" + return 1 + } + else + ui_info "Gateway service not loaded; skipping gateway deep probe" + fi + + ui_success "Install verify complete" } # Main installation flow @@ -2485,6 +2524,10 @@ main() { fi fi + if ! verify_installation; then + exit 1 + fi + if [[ "$should_open_dashboard" == "true" ]]; then maybe_open_dashboard fi diff --git a/src/commands/onboard-non-interactive.gateway.test.ts b/src/commands/onboard-non-interactive.gateway.test.ts index f2e0724b53b..23684eb5f5a 100644 --- a/src/commands/onboard-non-interactive.gateway.test.ts +++ b/src/commands/onboard-non-interactive.gateway.test.ts @@ -14,6 +14,19 @@ const gatewayClientCalls: Array<{ }> = []; const ensureWorkspaceAndSessionsMock = vi.fn(async (..._args: unknown[]) => {}); const installGatewayDaemonNonInteractiveMock = vi.hoisted(() => vi.fn(async () => {})); +const gatewayServiceMock = vi.hoisted(() => ({ + label: "LaunchAgent", + loadedText: "loaded", + isLoaded: vi.fn(async () => true), + readRuntime: vi.fn(async () => ({ + status: "running", + state: "active", + pid: 4242, + })), +})); +const readLastGatewayErrorLineMock = vi.hoisted(() => + vi.fn(async () => "Gateway failed to start: required secrets are unavailable."), +); let waitForGatewayReachableMock: | ((params: { url: string; token?: string; password?: string; deadlineMs?: number }) => Promise<{ ok: boolean; @@ -64,6 +77,14 @@ vi.mock("./onboard-non-interactive/local/daemon-install.js", () => ({ installGatewayDaemonNonInteractive: installGatewayDaemonNonInteractiveMock, })); +vi.mock("../daemon/service.js", () => ({ + resolveGatewayService: () => gatewayServiceMock, +})); + +vi.mock("../daemon/diagnostics.js", () => ({ + readLastGatewayErrorLine: readLastGatewayErrorLineMock, +})); + const { runNonInteractiveOnboarding } = await import("./onboard-non-interactive.js"); const { resolveConfigPath: resolveStateConfigPath } = await import("../config/paths.js"); const { resolveConfigPath } = await import("../config/config.js"); @@ -134,6 +155,9 @@ describe("onboard (non-interactive): gateway and remote auth", () => { afterEach(() => { waitForGatewayReachableMock = undefined; installGatewayDaemonNonInteractiveMock.mockClear(); + gatewayServiceMock.isLoaded.mockClear(); + gatewayServiceMock.readRuntime.mockClear(); + readLastGatewayErrorLineMock.mockClear(); }); it("writes gateway token auth into config", async () => { @@ -376,6 +400,73 @@ describe("onboard (non-interactive): gateway and remote auth", () => { }); }, 60_000); + it("emits structured JSON diagnostics when daemon health fails", async () => { + await withStateDir("state-local-daemon-health-json-fail-", async (stateDir) => { + waitForGatewayReachableMock = vi.fn(async () => ({ + ok: false, + detail: "gateway closed (1006 abnormal closure (no close frame)): no close reason", + })); + + let capturedError = ""; + const runtimeWithCapture = { + log: () => {}, + error: (message: string) => { + capturedError = message; + throw new Error(message); + }, + exit: (_code: number) => { + throw new Error("exit should not be reached after runtime.error"); + }, + }; + + await expect( + runNonInteractiveOnboarding( + { + nonInteractive: true, + mode: "local", + workspace: path.join(stateDir, "openclaw"), + authChoice: "skip", + skipSkills: true, + skipHealth: false, + installDaemon: true, + gatewayBind: "loopback", + json: true, + }, + runtimeWithCapture, + ), + ).rejects.toThrow(/"phase": "gateway-health"/); + + const parsed = JSON.parse(capturedError) as { + ok: boolean; + phase: string; + installDaemon: boolean; + detail?: string; + gateway?: { wsUrl?: string }; + hints?: string[]; + diagnostics?: { + service?: { + label?: string; + loaded?: boolean; + runtimeStatus?: string; + pid?: number; + }; + lastGatewayError?: string; + }; + }; + expect(parsed.ok).toBe(false); + expect(parsed.phase).toBe("gateway-health"); + expect(parsed.installDaemon).toBe(true); + expect(parsed.detail).toContain("1006 abnormal closure"); + expect(parsed.gateway?.wsUrl).toContain("ws://127.0.0.1:"); + expect(parsed.hints).toContain("Run `openclaw gateway status --deep` for more detail."); + expect(parsed.diagnostics?.service?.label).toBe("LaunchAgent"); + expect(parsed.diagnostics?.service?.loaded).toBe(true); + expect(parsed.diagnostics?.service?.runtimeStatus).toBe("running"); + expect(parsed.diagnostics?.service?.pid).toBe(4242); + expect(parsed.diagnostics?.lastGatewayError).toContain("required secrets are unavailable"); + }); + }, 60_000); + it("auto-generates token auth when binding LAN and persists the token", async () => { if (process.platform === "win32") { // Windows runner occasionally drops the temp config write in this flow; skip to keep CI green. diff --git a/src/commands/onboard-non-interactive/local.ts b/src/commands/onboard-non-interactive/local.ts index 0765eb1a513..e573c385166 100644 --- a/src/commands/onboard-non-interactive/local.ts +++ b/src/commands/onboard-non-interactive/local.ts @@ -15,13 +15,84 @@ import { import type { OnboardOptions } from "../onboard-types.js"; import { inferAuthChoiceFromFlags } from "./local/auth-choice-inference.js"; import { applyNonInteractiveGatewayConfig } from "./local/gateway-config.js"; -import { logNonInteractiveOnboardingJson } from "./local/output.js"; +import { + logNonInteractiveOnboardingFailure, + logNonInteractiveOnboardingJson, +} from "./local/output.js"; import { applyNonInteractiveSkillsConfig } from "./local/skills-config.js"; import { resolveNonInteractiveWorkspaceDir } from "./local/workspace.js"; const INSTALL_DAEMON_HEALTH_DEADLINE_MS = 45_000; const ATTACH_EXISTING_GATEWAY_HEALTH_DEADLINE_MS = 15_000; +async function collectGatewayHealthFailureDiagnostics(): Promise< + | { + service?: { + label: string; + loaded: boolean; + loadedText: string; + runtimeStatus?: string; + state?: string; + pid?: number; + lastExitStatus?: number; + lastExitReason?: string; + }; + lastGatewayError?: string; + inspectError?: string; + } + | undefined +> { + const diagnostics: { + service?: { + label: string; + loaded: boolean; + loadedText: string; + runtimeStatus?: string; + state?: string; + pid?: number; + lastExitStatus?: number; + lastExitReason?: string; + }; + lastGatewayError?: string; + inspectError?: string; + } = {}; + + try { + const { resolveGatewayService } = await import("../../daemon/service.js"); + const service = resolveGatewayService(); + const env = process.env as Record; + const [loaded, runtime] = await Promise.all([ + service.isLoaded({ env }).catch(() => false), + service.readRuntime(env).catch(() => undefined), + ]); + diagnostics.service = { + label: service.label, + loaded, + loadedText: service.loadedText, + runtimeStatus: runtime?.status, + state: runtime?.state, + pid: runtime?.pid, + lastExitStatus: runtime?.lastExitStatus, + lastExitReason: runtime?.lastExitReason, + }; + } catch (err) { + diagnostics.inspectError = `service diagnostics failed: ${String(err)}`; + } + + try { + const { readLastGatewayErrorLine } = await import("../../daemon/diagnostics.js"); + diagnostics.lastGatewayError = (await readLastGatewayErrorLine(process.env)) ?? undefined; + } catch (err) { + diagnostics.inspectError = diagnostics.inspectError + ? `${diagnostics.inspectError}; log diagnostics failed: ${String(err)}` + : `log diagnostics failed: ${String(err)}`; + } + + return diagnostics.service || diagnostics.lastGatewayError || diagnostics.inspectError + ? diagnostics + : undefined; +} + export async function runNonInteractiveOnboardingLocal(params: { opts: OnboardOptions; runtime: RuntimeEnv; @@ -115,24 +186,33 @@ export async function runNonInteractiveOnboardingLocal(params: { : ATTACH_EXISTING_GATEWAY_HEALTH_DEADLINE_MS, }); if (!probe.ok) { - const message = [ - `Gateway did not become reachable at ${links.wsUrl}.`, - probe.detail ? `Last probe: ${probe.detail}` : undefined, - !opts.installDaemon + const diagnostics = opts.installDaemon + ? await collectGatewayHealthFailureDiagnostics() + : undefined; + logNonInteractiveOnboardingFailure({ + opts, + runtime, + mode, + phase: "gateway-health", + message: `Gateway did not become reachable at ${links.wsUrl}.`, + detail: probe.detail, + gateway: { + wsUrl: links.wsUrl, + httpUrl: links.httpUrl, + }, + installDaemon: Boolean(opts.installDaemon), + daemonRuntime: opts.installDaemon ? daemonRuntimeRaw : undefined, + diagnostics, + hints: !opts.installDaemon ? [ "Non-interactive local onboarding only waits for an already-running gateway unless you pass --install-daemon.", `Fix: start \`${formatCliCommand("openclaw gateway run")}\`, re-run with \`--install-daemon\`, or use \`--skip-health\`.`, process.platform === "win32" ? "Native Windows managed gateway install tries Scheduled Tasks first and falls back to a per-user Startup-folder login item when task creation is denied." : undefined, - ] - .filter(Boolean) - .join("\n") - : undefined, - ] - .filter(Boolean) - .join("\n"); - runtime.error(message); + ].filter((value): value is string => Boolean(value)) + : [`Run \`${formatCliCommand("openclaw gateway status --deep")}\` for more detail.`], + }); runtime.exit(1); return; } diff --git a/src/commands/onboard-non-interactive/local/output.ts b/src/commands/onboard-non-interactive/local/output.ts index d4296e3500c..100956ae979 100644 --- a/src/commands/onboard-non-interactive/local/output.ts +++ b/src/commands/onboard-non-interactive/local/output.ts @@ -1,6 +1,21 @@ import type { RuntimeEnv } from "../../../runtime.js"; import type { OnboardOptions } from "../../onboard-types.js"; +type GatewayHealthFailureDiagnostics = { + service?: { + label: string; + loaded: boolean; + loadedText: string; + runtimeStatus?: string; + state?: string; + pid?: number; + lastExitStatus?: number; + lastExitReason?: string; + }; + lastGatewayError?: string; + inspectError?: string; +}; + export function logNonInteractiveOnboardingJson(params: { opts: OnboardOptions; runtime: RuntimeEnv; @@ -24,6 +39,7 @@ export function logNonInteractiveOnboardingJson(params: { params.runtime.log( JSON.stringify( { + ok: true, mode: params.mode, workspace: params.workspaceDir, authChoice: params.authChoice, @@ -38,3 +54,88 @@ export function logNonInteractiveOnboardingJson(params: { ), ); } + +function formatGatewayRuntimeSummary( + diagnostics: GatewayHealthFailureDiagnostics | undefined, +): string | undefined { + const service = diagnostics?.service; + if (!service?.runtimeStatus) { + return undefined; + } + const parts = [service.runtimeStatus]; + if (typeof service.pid === "number") { + parts.push(`pid ${service.pid}`); + } + if (service.state) { + parts.push(`state ${service.state}`); + } + if (typeof service.lastExitStatus === "number") { + parts.push(`last exit ${service.lastExitStatus}`); + } + if (service.lastExitReason) { + parts.push(`reason ${service.lastExitReason}`); + } + return parts.join(", "); +} + +export function logNonInteractiveOnboardingFailure(params: { + opts: OnboardOptions; + runtime: RuntimeEnv; + mode: "local" | "remote"; + phase: string; + message: string; + detail?: string; + hints?: string[]; + gateway?: { + wsUrl?: string; + httpUrl?: string; + }; + installDaemon?: boolean; + daemonRuntime?: string; + diagnostics?: GatewayHealthFailureDiagnostics; +}) { + const hints = params.hints?.filter(Boolean) ?? []; + const gatewayRuntime = formatGatewayRuntimeSummary(params.diagnostics); + + if (params.opts.json) { + params.runtime.error( + JSON.stringify( + { + ok: false, + mode: params.mode, + phase: params.phase, + message: params.message, + detail: params.detail, + gateway: params.gateway, + installDaemon: Boolean(params.installDaemon), + daemonRuntime: params.daemonRuntime, + diagnostics: params.diagnostics, + hints: hints.length > 0 ? hints : undefined, + }, + null, + 2, + ), + ); + return; + } + + const lines = [ + params.message, + params.detail ? `Last probe: ${params.detail}` : undefined, + params.diagnostics?.service + ? `Service: ${params.diagnostics.service.label} (${params.diagnostics.service.loaded ? params.diagnostics.service.loadedText : "not loaded"})` + : undefined, + gatewayRuntime ? `Runtime: ${gatewayRuntime}` : undefined, + params.diagnostics?.lastGatewayError + ? `Last gateway error: ${params.diagnostics.lastGatewayError}` + : undefined, + params.diagnostics?.inspectError + ? `Diagnostics warning: ${params.diagnostics.inspectError}` + : undefined, + hints.length > 0 ? hints.join("\n") : undefined, + ] + .filter(Boolean) + .join("\n"); + + params.runtime.error(lines); +} From e7863d7fdd92778d58143ab83a09b3b827be30b3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:43:24 +0000 Subject: [PATCH 065/461] test: add parallels macos smoke harness --- AGENTS.md | 2 + package.json | 1 + scripts/e2e/parallels-macos-smoke.sh | 691 +++++++++++++++++++++++++++ 3 files changed, 694 insertions(+) create mode 100644 scripts/e2e/parallels-macos-smoke.sh diff --git a/AGENTS.md b/AGENTS.md index f7c2f34ce39..5112a8241df 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -205,6 +205,8 @@ - Parallels macOS smoke playbook: - `prlctl exec` is fine for deterministic repo commands, but it can misrepresent interactive shell behavior (`PATH`, `HOME`, `curl | bash`, shebang resolution). For installer parity or shell-sensitive repros, prefer the guest Terminal or `prlctl enter`. - Fresh Tahoe snapshot current reality: `brew` exists, `node` may not be on `PATH` in noninteractive guest exec. Use absolute `/opt/homebrew/bin/node` for repo/CLI runs when needed. + - Preferred automation entrypoint: `pnpm test:parallels:macos`. It restores the snapshot most closely matching `macOS 26.3.1 fresh`, serves the current `main` tarball from the host, then runs fresh-install and latest-release-to-main smoke lanes. + - Harness output: pass `--json` for machine-readable summary; per-phase logs land under `/tmp/openclaw-parallels-smoke.*`. - Fresh host-served tgz install: restore fresh snapshot, install tgz as guest root with `HOME=/var/root`, then run onboarding as the desktop user via `prlctl exec --current-user`. - For `openclaw onboard --non-interactive --secret-input-mode ref --install-daemon`, expect env-backed auth-profile refs (for example `OPENAI_API_KEY`) to be copied into the service env at install time; this path was fixed and should stay green. - Don’t run local + gateway agent turns in parallel on the same fresh workspace/session; they can collide on the session lock. Run sequentially. diff --git a/package.json b/package.json index 54d897eb66f..61cbaae5c57 100644 --- a/package.json +++ b/package.json @@ -325,6 +325,7 @@ "test:install:smoke": "bash scripts/test-install-sh-docker.sh", "test:live": "OPENCLAW_LIVE_TEST=1 CLAWDBOT_LIVE_TEST=1 vitest run --config vitest.live.config.ts", "test:macmini": "OPENCLAW_TEST_VM_FORKS=0 OPENCLAW_TEST_PROFILE=serial node scripts/test-parallel.mjs", + "test:parallels:macos": "bash scripts/e2e/parallels-macos-smoke.sh", "test:perf:budget": "node scripts/test-perf-budget.mjs", "test:perf:hotspots": "node scripts/test-hotspots.mjs", "test:sectriage": "pnpm exec vitest run --config vitest.gateway.config.ts && vitest run --config vitest.unit.config.ts --exclude src/daemon/launchd.integration.test.ts --exclude src/process/exec.test.ts", diff --git a/scripts/e2e/parallels-macos-smoke.sh b/scripts/e2e/parallels-macos-smoke.sh new file mode 100644 index 00000000000..8fcb0d05eae --- /dev/null +++ b/scripts/e2e/parallels-macos-smoke.sh @@ -0,0 +1,691 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +VM_NAME="macOS Tahoe" +SNAPSHOT_HINT="macOS 26.3.1 fresh" +MODE="both" +OPENAI_API_KEY_ENV="OPENAI_API_KEY" +INSTALL_URL="https://openclaw.ai/install.sh" +HOST_PORT="18425" +HOST_PORT_EXPLICIT=0 +HOST_IP="" +LATEST_VERSION="" +KEEP_SERVER=0 +CHECK_LATEST_REF=1 +JSON_OUTPUT=0 +GUEST_OPENCLAW_BIN="/opt/homebrew/bin/openclaw" +GUEST_OPENCLAW_ENTRY="/opt/homebrew/lib/node_modules/openclaw/openclaw.mjs" +GUEST_NODE_BIN="/opt/homebrew/bin/node" +GUEST_NPM_BIN="/opt/homebrew/bin/npm" + +MAIN_TGZ_DIR="$(mktemp -d)" +MAIN_TGZ_PATH="" +SERVER_PID="" +RUN_DIR="$(mktemp -d /tmp/openclaw-parallels-smoke.XXXXXX)" + +TIMEOUT_INSTALL_S=900 +TIMEOUT_VERIFY_S=60 +TIMEOUT_ONBOARD_S=180 +TIMEOUT_GATEWAY_S=60 +TIMEOUT_AGENT_S=120 +TIMEOUT_PERMISSION_S=60 +TIMEOUT_SNAPSHOT_S=180 + +FRESH_MAIN_VERSION="skip" +LATEST_INSTALLED_VERSION="skip" +UPGRADE_MAIN_VERSION="skip" +FRESH_GATEWAY_STATUS="skip" +UPGRADE_GATEWAY_STATUS="skip" +FRESH_AGENT_STATUS="skip" +UPGRADE_AGENT_STATUS="skip" + +say() { + printf '==> %s\n' "$*" +} + +warn() { + printf 'warn: %s\n' "$*" >&2 +} + +die() { + printf 'error: %s\n' "$*" >&2 + exit 1 +} + +cleanup() { + if [[ -n "${SERVER_PID:-}" ]]; then + kill "$SERVER_PID" >/dev/null 2>&1 || true + fi + rm -rf "$MAIN_TGZ_DIR" + if [[ "${KEEP_SERVER:-0}" -eq 0 ]]; then + : + fi +} + +trap cleanup EXIT + +shell_quote() { + local value="$1" + printf "'%s'" "$(printf '%s' "$value" | sed "s/'/'\"'\"'/g")" +} + +usage() { + cat <<'EOF' +Usage: bash scripts/e2e/parallels-macos-smoke.sh [options] + +Options: + --vm Parallels VM name. Default: "macOS Tahoe" + --snapshot-hint Snapshot name substring/fuzzy match. + Default: "macOS 26.3.1 fresh" + --mode + fresh = fresh snapshot -> current main tgz -> onboard smoke + upgrade = fresh snapshot -> latest release -> current main tgz -> onboard smoke + both = run both lanes + --openai-api-key-env Host env var name for OpenAI API key. + Default: OPENAI_API_KEY + --install-url Installer URL for latest release. Default: https://openclaw.ai/install.sh + --host-port Host HTTP port for current-main tgz. Default: 18425 + --host-ip Override Parallels host IP. + --latest-version Override npm latest version lookup. + --skip-latest-ref-check Skip the known latest-release ref-mode precheck in upgrade lane. + --keep-server Leave temp host HTTP server running. + --json Print machine-readable JSON summary. + -h, --help Show help. +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --vm) + VM_NAME="$2" + shift 2 + ;; + --snapshot-hint) + SNAPSHOT_HINT="$2" + shift 2 + ;; + --mode) + MODE="$2" + shift 2 + ;; + --openai-api-key-env) + OPENAI_API_KEY_ENV="$2" + shift 2 + ;; + --install-url) + INSTALL_URL="$2" + shift 2 + ;; + --host-port) + HOST_PORT="$2" + HOST_PORT_EXPLICIT=1 + shift 2 + ;; + --host-ip) + HOST_IP="$2" + shift 2 + ;; + --latest-version) + LATEST_VERSION="$2" + shift 2 + ;; + --skip-latest-ref-check) + CHECK_LATEST_REF=0 + shift + ;; + --keep-server) + KEEP_SERVER=1 + shift + ;; + --json) + JSON_OUTPUT=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + die "unknown arg: $1" + ;; + esac +done + +case "$MODE" in + fresh|upgrade|both) ;; + *) + die "invalid --mode: $MODE" + ;; +esac + +OPENAI_API_KEY_VALUE="${!OPENAI_API_KEY_ENV:-}" +[[ -n "$OPENAI_API_KEY_VALUE" ]] || die "$OPENAI_API_KEY_ENV is required" + +resolve_snapshot_id() { + local json hint + json="$(prlctl snapshot-list "$VM_NAME" --json)" + hint="$SNAPSHOT_HINT" + SNAPSHOT_JSON="$json" SNAPSHOT_HINT="$hint" python3 - <<'PY' +import difflib +import json +import os +import sys + +payload = json.loads(os.environ["SNAPSHOT_JSON"]) +hint = os.environ["SNAPSHOT_HINT"].strip().lower() +best_id = None +best_score = -1.0 +for snapshot_id, meta in payload.items(): + name = str(meta.get("name", "")).strip() + lowered = name.lower() + score = 0.0 + if lowered == hint: + score = 10.0 + elif hint and hint in lowered: + score = 5.0 + len(hint) / max(len(lowered), 1) + else: + score = difflib.SequenceMatcher(None, hint, lowered).ratio() + if score > best_score: + best_score = score + best_id = snapshot_id +if not best_id: + sys.exit("no snapshot matched") +print(best_id) +PY +} + +resolve_host_ip() { + if [[ -n "$HOST_IP" ]]; then + printf '%s\n' "$HOST_IP" + return + fi + + local detected + detected="$(ifconfig | awk '/inet 10\.211\./ { print $2; exit }')" + [[ -n "$detected" ]] || die "failed to detect Parallels host IP; pass --host-ip" + printf '%s\n' "$detected" +} + +is_host_port_free() { + local port="$1" + python3 - "$port" <<'PY' +import socket +import sys + +port = int(sys.argv[1]) +sock = socket.socket() +try: + sock.bind(("0.0.0.0", port)) +except OSError: + raise SystemExit(1) +finally: + sock.close() +PY +} + +allocate_host_port() { + python3 - <<'PY' +import socket + +sock = socket.socket() +sock.bind(("0.0.0.0", 0)) +print(sock.getsockname()[1]) +sock.close() +PY +} + +resolve_host_port() { + if is_host_port_free "$HOST_PORT"; then + printf '%s\n' "$HOST_PORT" + return + fi + if [[ "$HOST_PORT_EXPLICIT" -eq 1 ]]; then + die "host port $HOST_PORT already in use" + fi + HOST_PORT="$(allocate_host_port)" + warn "host port 18425 busy; using $HOST_PORT" + printf '%s\n' "$HOST_PORT" +} + +wait_for_current_user() { + local deadline + deadline=$((SECONDS + TIMEOUT_SNAPSHOT_S)) + while (( SECONDS < deadline )); do + if prlctl exec "$VM_NAME" --current-user whoami >/dev/null 2>&1; then + return 0 + fi + sleep 2 + done + return 1 +} + +guest_current_user_exec() { + prlctl exec "$VM_NAME" --current-user /usr/bin/env \ + PATH=/opt/homebrew/bin:/opt/homebrew/sbin:/usr/bin:/bin:/usr/sbin:/sbin \ + "$@" +} + +guest_script() { + local mode script + mode="$1" + script="$2" + PRL_GUEST_VM_NAME="$VM_NAME" PRL_GUEST_MODE="$mode" PRL_GUEST_SCRIPT="$script" /opt/homebrew/bin/expect <<'EOF' +log_user 1 +set timeout -1 +match_max 1048576 + +set vm $env(PRL_GUEST_VM_NAME) +set mode $env(PRL_GUEST_MODE) +set script $env(PRL_GUEST_SCRIPT) +set cmd [list prlctl enter $vm] +if {$mode eq "current-user"} { + lappend cmd --current-user +} + +spawn {*}$cmd +send -- "printf '__OPENCLAW_READY__\\n'\r" +expect "__OPENCLAW_READY__" +log_user 0 +send -- "export PS1='' PROMPT='' PROMPT2='' RPROMPT=''\r" +send -- "stty -echo\r" + +send -- "cat >/tmp/openclaw-prl.sh <<'__OPENCLAW_SCRIPT__'\r" +send -- $script +if {![string match "*\n" $script]} { + send -- "\r" +} +send -- "__OPENCLAW_SCRIPT__\r" +send -- "/bin/bash /tmp/openclaw-prl.sh; rc=\$?; rm -f /tmp/openclaw-prl.sh; printf '__OPENCLAW_RC__:%s\\n' \"\$rc\"; exit \"\$rc\"\r" +log_user 1 + +set rc 1 +expect { + -re {__OPENCLAW_RC__:(-?[0-9]+)} { + set rc $expect_out(1,string) + exp_continue + } + eof {} +} +catch wait result +exit $rc +EOF +} + +guest_current_user_sh() { + local script + script=$'set -eu\n' + script+=$'set -o pipefail\n' + script+=$'trap "" PIPE\n' + script+=$'umask 022\n' + script+=$'export PATH="/opt/homebrew/bin:/opt/homebrew/sbin:/usr/bin:/bin:/usr/sbin:/sbin:${PATH:-}"\n' + script+=$'if [ -z "${HOME:-}" ]; then export HOME="/Users/$(id -un)"; fi\n' + script+=$'cd "$HOME"\n' + script+="$1" + guest_script current-user "$script" +} + +restore_snapshot() { + local snapshot_id="$1" + say "Restore snapshot $SNAPSHOT_HINT ($snapshot_id)" + prlctl snapshot-switch "$VM_NAME" --id "$snapshot_id" >/dev/null + wait_for_current_user || die "desktop user did not become ready in $VM_NAME" +} + +resolve_latest_version() { + if [[ -n "$LATEST_VERSION" ]]; then + printf '%s\n' "$LATEST_VERSION" + return + fi + npm view openclaw version --userconfig "$(mktemp)" +} + +install_latest_release() { + local install_url_q + install_url_q="$(shell_quote "$INSTALL_URL")" + guest_current_user_sh "$(cat <&2 + return 1 + ;; + esac +} + +pack_main_tgz() { + say "Pack current main tgz" + local short_head pkg + short_head="$(git rev-parse --short HEAD)" + pkg="$( + npm pack --ignore-scripts --json --pack-destination "$MAIN_TGZ_DIR" \ + | python3 -c 'import json, sys; data = json.load(sys.stdin); print(data[-1]["filename"])' + )" + MAIN_TGZ_PATH="$MAIN_TGZ_DIR/openclaw-main-$short_head.tgz" + cp "$MAIN_TGZ_DIR/$pkg" "$MAIN_TGZ_PATH" + say "Packed $MAIN_TGZ_PATH" + tar -xOf "$MAIN_TGZ_PATH" package/dist/build-info.json +} + +start_server() { + local host_ip="$1" + say "Serve current main tgz on $host_ip:$HOST_PORT" + ( + cd "$MAIN_TGZ_DIR" + exec python3 -m http.server "$HOST_PORT" --bind 0.0.0.0 + ) >/tmp/openclaw-parallels-http.log 2>&1 & + SERVER_PID=$! + sleep 1 + kill -0 "$SERVER_PID" >/dev/null 2>&1 || die "failed to start host HTTP server" +} + +install_main_tgz() { + local host_ip="$1" + local temp_name="$2" + local tgz_url_q + tgz_url_q="$(shell_quote "http://$host_ip:$HOST_PORT/$(basename "$MAIN_TGZ_PATH")")" + guest_current_user_sh "$(cat <&2; exit 1; fi; }; check_path "\$root/openclaw"; check_path "\$root/openclaw/extensions"; if [ -d "\$root/openclaw/extensions" ]; then while IFS= read -r -d '' extension_dir; do check_path "\$extension_dir"; done < <(/usr/bin/find "\$root/openclaw/extensions" -mindepth 1 -maxdepth 1 -type d -print0); fi +EOF +)" + guest_current_user_exec /bin/bash -lc "$cmd" +} + +run_ref_onboard() { + guest_current_user_exec \ + /usr/bin/env "OPENAI_API_KEY=$OPENAI_API_KEY_VALUE" \ + "$GUEST_NODE_BIN" "$GUEST_OPENCLAW_ENTRY" onboard \ + --non-interactive \ + --mode local \ + --auth-choice openai-api-key \ + --secret-input-mode ref \ + --gateway-port 18789 \ + --gateway-bind loopback \ + --install-daemon \ + --skip-skills \ + --accept-risk \ + --json +} + +verify_gateway() { + guest_current_user_exec "$GUEST_NODE_BIN" "$GUEST_OPENCLAW_ENTRY" gateway status --deep +} + +verify_turn() { + guest_current_user_exec "$GUEST_NODE_BIN" "$GUEST_OPENCLAW_ENTRY" agent --agent main --message ping --json +} + +phase_log_path() { + printf '%s/%s.log\n' "$RUN_DIR" "$1" +} + +extract_last_version() { + local log_path="$1" + python3 - "$log_path" <<'PY' +import pathlib +import re +import sys + +text = pathlib.Path(sys.argv[1]).read_text(errors="replace") +matches = re.findall(r"OpenClaw [^\r\n]+ \([0-9a-f]{7,}\)", text) +print(matches[-1] if matches else "") +PY +} + +show_log_excerpt() { + local log_path="$1" + warn "log tail: $log_path" + tail -n 80 "$log_path" >&2 || true +} + +phase_run() { + local phase_id="$1" + local timeout_s="$2" + shift 2 + + local log_path pid start rc timed_out + log_path="$(phase_log_path "$phase_id")" + say "$phase_id" + start=$SECONDS + timed_out=0 + + ( + "$@" + ) >"$log_path" 2>&1 & + pid=$! + + while kill -0 "$pid" >/dev/null 2>&1; do + if (( SECONDS - start >= timeout_s )); then + timed_out=1 + kill "$pid" >/dev/null 2>&1 || true + sleep 2 + kill -9 "$pid" >/dev/null 2>&1 || true + break + fi + sleep 1 + done + + set +e + wait "$pid" + rc=$? + set -e + + if (( timed_out )); then + warn "$phase_id timed out after ${timeout_s}s" + printf 'timeout after %ss\n' "$timeout_s" >>"$log_path" + show_log_excerpt "$log_path" + return 124 + fi + + if [[ $rc -ne 0 ]]; then + warn "$phase_id failed (rc=$rc)" + show_log_excerpt "$log_path" + return "$rc" + fi + + return 0 +} + +write_summary_json() { + local summary_path="$RUN_DIR/summary.json" + python3 - "$summary_path" <<'PY' +import json +import os +import sys + +summary = { + "vm": os.environ["SUMMARY_VM"], + "snapshotHint": os.environ["SUMMARY_SNAPSHOT_HINT"], + "snapshotId": os.environ["SUMMARY_SNAPSHOT_ID"], + "mode": os.environ["SUMMARY_MODE"], + "latestVersion": os.environ["SUMMARY_LATEST_VERSION"], + "currentHead": os.environ["SUMMARY_CURRENT_HEAD"], + "runDir": os.environ["SUMMARY_RUN_DIR"], + "freshMain": { + "status": os.environ["SUMMARY_FRESH_MAIN_STATUS"], + "version": os.environ["SUMMARY_FRESH_MAIN_VERSION"], + "gateway": os.environ["SUMMARY_FRESH_GATEWAY_STATUS"], + "agent": os.environ["SUMMARY_FRESH_AGENT_STATUS"], + }, + "upgrade": { + "precheck": os.environ["SUMMARY_UPGRADE_PRECHECK_STATUS"], + "status": os.environ["SUMMARY_UPGRADE_STATUS"], + "latestVersionInstalled": os.environ["SUMMARY_LATEST_INSTALLED_VERSION"], + "mainVersion": os.environ["SUMMARY_UPGRADE_MAIN_VERSION"], + "gateway": os.environ["SUMMARY_UPGRADE_GATEWAY_STATUS"], + "agent": os.environ["SUMMARY_UPGRADE_AGENT_STATUS"], + }, +} +with open(sys.argv[1], "w", encoding="utf-8") as handle: + json.dump(summary, handle, indent=2, sort_keys=True) +print(sys.argv[1]) +PY +} + +capture_latest_ref_failure() { + set +e + run_ref_onboard + local rc=$? + set -e + if [[ $rc -eq 0 ]]; then + say "Latest release ref-mode onboard passed" + return 0 + fi + warn "Latest release ref-mode onboard failed pre-upgrade" + set +e + guest_current_user_exec "$GUEST_NODE_BIN" "$GUEST_OPENCLAW_ENTRY" gateway status --deep || true + set -e + return 1 +} + +run_fresh_main_lane() { + local snapshot_id="$1" + local host_ip="$2" + phase_run "fresh.restore-snapshot" "$TIMEOUT_SNAPSHOT_S" restore_snapshot "$snapshot_id" + phase_run "fresh.install-main" "$TIMEOUT_INSTALL_S" install_main_tgz "$host_ip" "openclaw-main-fresh.tgz" + FRESH_MAIN_VERSION="$(extract_last_version "$(phase_log_path fresh.install-main)")" + phase_run "fresh.verify-main-version" "$TIMEOUT_VERIFY_S" verify_version_contains "$(git rev-parse --short=7 HEAD)" + phase_run "fresh.verify-bundle-permissions" "$TIMEOUT_PERMISSION_S" verify_bundle_permissions + phase_run "fresh.onboard-ref" "$TIMEOUT_ONBOARD_S" run_ref_onboard + phase_run "fresh.gateway-status" "$TIMEOUT_GATEWAY_S" verify_gateway + FRESH_GATEWAY_STATUS="pass" + phase_run "fresh.first-agent-turn" "$TIMEOUT_AGENT_S" verify_turn + FRESH_AGENT_STATUS="pass" +} + +run_upgrade_lane() { + local snapshot_id="$1" + local host_ip="$2" + phase_run "upgrade.restore-snapshot" "$TIMEOUT_SNAPSHOT_S" restore_snapshot "$snapshot_id" + phase_run "upgrade.install-latest" "$TIMEOUT_INSTALL_S" install_latest_release + LATEST_INSTALLED_VERSION="$(extract_last_version "$(phase_log_path upgrade.install-latest)")" + phase_run "upgrade.verify-latest-version" "$TIMEOUT_VERIFY_S" verify_version_contains "$LATEST_VERSION" + if [[ "$CHECK_LATEST_REF" -eq 1 ]]; then + if phase_run "upgrade.latest-ref-precheck" "$TIMEOUT_ONBOARD_S" capture_latest_ref_failure; then + UPGRADE_PRECHECK_STATUS="latest-ref-pass" + else + UPGRADE_PRECHECK_STATUS="latest-ref-fail" + fi + else + UPGRADE_PRECHECK_STATUS="skipped" + fi + phase_run "upgrade.install-main" "$TIMEOUT_INSTALL_S" install_main_tgz "$host_ip" "openclaw-main-upgrade.tgz" + UPGRADE_MAIN_VERSION="$(extract_last_version "$(phase_log_path upgrade.install-main)")" + phase_run "upgrade.verify-main-version" "$TIMEOUT_VERIFY_S" verify_version_contains "$(git rev-parse --short=7 HEAD)" + phase_run "upgrade.verify-bundle-permissions" "$TIMEOUT_PERMISSION_S" verify_bundle_permissions + phase_run "upgrade.onboard-ref" "$TIMEOUT_ONBOARD_S" run_ref_onboard + phase_run "upgrade.gateway-status" "$TIMEOUT_GATEWAY_S" verify_gateway + UPGRADE_GATEWAY_STATUS="pass" + phase_run "upgrade.first-agent-turn" "$TIMEOUT_AGENT_S" verify_turn + UPGRADE_AGENT_STATUS="pass" +} + +FRESH_MAIN_STATUS="skip" +UPGRADE_STATUS="skip" +UPGRADE_PRECHECK_STATUS="skip" + +SNAPSHOT_ID="$(resolve_snapshot_id)" +LATEST_VERSION="$(resolve_latest_version)" +HOST_IP="$(resolve_host_ip)" +HOST_PORT="$(resolve_host_port)" + +say "VM: $VM_NAME" +say "Snapshot hint: $SNAPSHOT_HINT" +say "Latest npm version: $LATEST_VERSION" +say "Current head: $(git rev-parse --short HEAD)" +say "Run logs: $RUN_DIR" + +pack_main_tgz +start_server "$HOST_IP" + +if [[ "$MODE" == "fresh" || "$MODE" == "both" ]]; then + set +e + run_fresh_main_lane "$SNAPSHOT_ID" "$HOST_IP" + fresh_rc=$? + set -e + if [[ $fresh_rc -eq 0 ]]; then + FRESH_MAIN_STATUS="pass" + else + FRESH_MAIN_STATUS="fail" + fi +fi + +if [[ "$MODE" == "upgrade" || "$MODE" == "both" ]]; then + set +e + run_upgrade_lane "$SNAPSHOT_ID" "$HOST_IP" + upgrade_rc=$? + set -e + if [[ $upgrade_rc -eq 0 ]]; then + UPGRADE_STATUS="pass" + else + UPGRADE_STATUS="fail" + fi +fi + +if [[ "$KEEP_SERVER" -eq 0 && -n "${SERVER_PID:-}" ]]; then + kill "$SERVER_PID" >/dev/null 2>&1 || true + SERVER_PID="" +fi + +SUMMARY_JSON_PATH="$( + SUMMARY_VM="$VM_NAME" \ + SUMMARY_SNAPSHOT_HINT="$SNAPSHOT_HINT" \ + SUMMARY_SNAPSHOT_ID="$SNAPSHOT_ID" \ + SUMMARY_MODE="$MODE" \ + SUMMARY_LATEST_VERSION="$LATEST_VERSION" \ + SUMMARY_CURRENT_HEAD="$(git rev-parse --short HEAD)" \ + SUMMARY_RUN_DIR="$RUN_DIR" \ + SUMMARY_FRESH_MAIN_STATUS="$FRESH_MAIN_STATUS" \ + SUMMARY_FRESH_MAIN_VERSION="$FRESH_MAIN_VERSION" \ + SUMMARY_FRESH_GATEWAY_STATUS="$FRESH_GATEWAY_STATUS" \ + SUMMARY_FRESH_AGENT_STATUS="$FRESH_AGENT_STATUS" \ + SUMMARY_UPGRADE_PRECHECK_STATUS="$UPGRADE_PRECHECK_STATUS" \ + SUMMARY_UPGRADE_STATUS="$UPGRADE_STATUS" \ + SUMMARY_LATEST_INSTALLED_VERSION="$LATEST_INSTALLED_VERSION" \ + SUMMARY_UPGRADE_MAIN_VERSION="$UPGRADE_MAIN_VERSION" \ + SUMMARY_UPGRADE_GATEWAY_STATUS="$UPGRADE_GATEWAY_STATUS" \ + SUMMARY_UPGRADE_AGENT_STATUS="$UPGRADE_AGENT_STATUS" \ + write_summary_json +)" + +if [[ "$JSON_OUTPUT" -eq 1 ]]; then + cat "$SUMMARY_JSON_PATH" +else + printf '\nSummary:\n' + printf ' fresh-main: %s (%s)\n' "$FRESH_MAIN_STATUS" "$FRESH_MAIN_VERSION" + printf ' latest->main precheck: %s (%s)\n' "$UPGRADE_PRECHECK_STATUS" "$LATEST_INSTALLED_VERSION" + printf ' latest->main: %s (%s)\n' "$UPGRADE_STATUS" "$UPGRADE_MAIN_VERSION" + printf ' logs: %s\n' "$RUN_DIR" + printf ' summary: %s\n' "$SUMMARY_JSON_PATH" +fi + +if [[ "$FRESH_MAIN_STATUS" == "fail" || "$UPGRADE_STATUS" == "fail" ]]; then + exit 1 +fi From 2d7a061161c2e8f9e4994945acb3ab429748133b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:45:22 +0000 Subject: [PATCH 066/461] test: tighten shared ip parsing coverage --- src/shared/net/ip.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/shared/net/ip.test.ts b/src/shared/net/ip.test.ts index 2ed2558214a..0328c7c87f2 100644 --- a/src/shared/net/ip.test.ts +++ b/src/shared/net/ip.test.ts @@ -13,6 +13,7 @@ import { isRfc1918Ipv4Address, normalizeIpAddress, parseCanonicalIpAddress, + parseLooseIpAddress, } from "./ip.js"; describe("shared ip helpers", () => { @@ -29,6 +30,8 @@ describe("shared ip helpers", () => { expect(isIpInCidr("10.43.0.59", "10.42.0.0/24")).toBe(false); expect(isIpInCidr("2001:db8::1234", "2001:db8::/32")).toBe(true); expect(isIpInCidr("2001:db9::1234", "2001:db8::/32")).toBe(false); + expect(isIpInCidr("::ffff:127.0.0.1", "127.0.0.1")).toBe(true); + expect(isIpInCidr("127.0.0.1", "::ffff:127.0.0.2")).toBe(false); }); it("extracts embedded IPv4 for transition prefixes", () => { @@ -66,6 +69,12 @@ describe("shared ip helpers", () => { expect(isLoopbackIpAddress("198.18.0.1")).toBe(false); }); + it("parses loose legacy IPv4 literals that canonical parsing rejects", () => { + expect(parseCanonicalIpAddress("0177.0.0.1")).toBeUndefined(); + expect(parseLooseIpAddress("0177.0.0.1")?.toString()).toBe("127.0.0.1"); + expect(parseLooseIpAddress("[::1]")?.toString()).toBe("::1"); + }); + it("classifies RFC1918 and carrier-grade-nat IPv4 ranges", () => { expect(isRfc1918Ipv4Address("10.42.0.59")).toBe(true); expect(isRfc1918Ipv4Address("100.64.0.1")).toBe(false); From ae5563dd18ddd84023ab349be99a98106c50743f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:46:20 +0000 Subject: [PATCH 067/461] test: tighten shared join and message content coverage --- src/shared/chat-message-content.test.ts | 13 +++++++++++++ src/shared/text/join-segments.test.ts | 6 ++++++ 2 files changed, 19 insertions(+) diff --git a/src/shared/chat-message-content.test.ts b/src/shared/chat-message-content.test.ts index 50e41f82642..7c35516f903 100644 --- a/src/shared/chat-message-content.test.ts +++ b/src/shared/chat-message-content.test.ts @@ -18,6 +18,19 @@ describe("shared/chat-message-content", () => { ).toBe(""); }); + it("only considers the first content block even if later blocks have text", () => { + expect( + extractFirstTextBlock({ + content: [null, { text: "later" }], + }), + ).toBeUndefined(); + expect( + extractFirstTextBlock({ + content: [{ type: "image" }, { text: "later" }], + }), + ).toBeUndefined(); + }); + it("returns undefined for missing, empty, or non-text content", () => { expect(extractFirstTextBlock(null)).toBeUndefined(); expect(extractFirstTextBlock({ content: [] })).toBeUndefined(); diff --git a/src/shared/text/join-segments.test.ts b/src/shared/text/join-segments.test.ts index 8da5c4644a7..67fdacb14dd 100644 --- a/src/shared/text/join-segments.test.ts +++ b/src/shared/text/join-segments.test.ts @@ -13,6 +13,8 @@ describe("concatOptionalTextSegments", () => { it("falls back to whichever side is present and honors custom separators", () => { expect(concatOptionalTextSegments({ left: "A" })).toBe("A"); expect(concatOptionalTextSegments({ right: "B" })).toBe("B"); + expect(concatOptionalTextSegments({ left: "", right: "B" })).toBe("B"); + expect(concatOptionalTextSegments({ left: "" })).toBe(""); expect(concatOptionalTextSegments({ left: "A", right: "B", separator: " | " })).toBe("A | B"); }); }); @@ -36,4 +38,8 @@ describe("joinPresentTextSegments", () => { "A | B", ); }); + + it("preserves segment whitespace when trim is disabled", () => { + expect(joinPresentTextSegments(["A", " B "], { separator: "|" })).toBe("A| B "); + }); }); From 9b590c9f671e2effc4843d078004a278bc0c0c7b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:47:33 +0000 Subject: [PATCH 068/461] test: tighten shared reasoning tag coverage --- src/shared/text/reasoning-tags.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/shared/text/reasoning-tags.test.ts b/src/shared/text/reasoning-tags.test.ts index 40cd133beac..86180e21a3f 100644 --- a/src/shared/text/reasoning-tags.test.ts +++ b/src/shared/text/reasoning-tags.test.ts @@ -146,6 +146,10 @@ describe("stripReasoningTagsFromText", () => { input: "`` in code, visible outside", expected: "`` in code, visible outside", }, + { + input: "A visible B", + expected: "A visible B", + }, ] as const; for (const { input, expected } of cases) { expect(stripReasoningTagsFromText(input)).toBe(expected); @@ -195,6 +199,12 @@ describe("stripReasoningTagsFromText", () => { expect(stripReasoningTagsFromText(input, { mode })).toBe(expected); } }); + + it("still strips fully closed reasoning blocks in preserve mode", () => { + expect(stripReasoningTagsFromText("A hidden B", { mode: "preserve" })).toBe( + "A B", + ); + }); }); describe("trim options", () => { @@ -221,4 +231,10 @@ describe("stripReasoningTagsFromText", () => { } }); }); + + it("does not leak regex state across repeated calls", () => { + expect(stripReasoningTagsFromText("A 1 B")).toBe("A 1 B"); + expect(stripReasoningTagsFromText("C 2 D")).toBe("C 2 D"); + expect(stripReasoningTagsFromText("E x F")).toBe("E F"); + }); }); From daca6c9df202b5b14d41992983ad31591759aafc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:48:40 +0000 Subject: [PATCH 069/461] test: tighten small shared helper coverage --- src/shared/assistant-identity-values.test.ts | 2 ++ src/shared/model-param-b.test.ts | 2 ++ src/shared/net/ipv4.test.ts | 4 ++++ 3 files changed, 8 insertions(+) diff --git a/src/shared/assistant-identity-values.test.ts b/src/shared/assistant-identity-values.test.ts index f0e594cc7e7..ef3440ee2a0 100644 --- a/src/shared/assistant-identity-values.test.ts +++ b/src/shared/assistant-identity-values.test.ts @@ -10,6 +10,7 @@ describe("shared/assistant-identity-values", () => { it("trims values and preserves strings within the limit", () => { expect(coerceIdentityValue(" OpenClaw ", 20)).toBe("OpenClaw"); + expect(coerceIdentityValue(" OpenClaw ", 8)).toBe("OpenClaw"); }); it("truncates overlong trimmed values at the exact limit", () => { @@ -18,5 +19,6 @@ describe("shared/assistant-identity-values", () => { it("returns an empty string when truncating to a zero-length limit", () => { expect(coerceIdentityValue(" OpenClaw ", 0)).toBe(""); + expect(coerceIdentityValue(" OpenClaw ", -1)).toBe("OpenCla"); }); }); diff --git a/src/shared/model-param-b.test.ts b/src/shared/model-param-b.test.ts index 7fb9a7b82d4..8bbafe3f529 100644 --- a/src/shared/model-param-b.test.ts +++ b/src/shared/model-param-b.test.ts @@ -6,6 +6,7 @@ describe("shared/model-param-b", () => { expect(inferParamBFromIdOrName("llama-8b mixtral-22b")).toBe(22); expect(inferParamBFromIdOrName("Qwen 0.5B Instruct")).toBe(0.5); expect(inferParamBFromIdOrName("prefix M7B and q4_32b")).toBe(32); + expect(inferParamBFromIdOrName("(70b) + m1.5b + qwen-14b")).toBe(70); }); it("ignores malformed, zero, and non-delimited matches", () => { @@ -13,5 +14,6 @@ describe("shared/model-param-b", () => { expect(inferParamBFromIdOrName("model 0b")).toBeNull(); expect(inferParamBFromIdOrName("model b5")).toBeNull(); expect(inferParamBFromIdOrName("foo70bbar")).toBeNull(); + expect(inferParamBFromIdOrName("ab7b model")).toBeNull(); }); }); diff --git a/src/shared/net/ipv4.test.ts b/src/shared/net/ipv4.test.ts index 21ff99b982b..40dd024138f 100644 --- a/src/shared/net/ipv4.test.ts +++ b/src/shared/net/ipv4.test.ts @@ -13,12 +13,16 @@ describe("shared/net/ipv4", () => { }); it("accepts canonical dotted-decimal ipv4 only", () => { + expect(validateDottedDecimalIPv4Input("0.0.0.0")).toBeUndefined(); expect(validateDottedDecimalIPv4Input("192.168.1.100")).toBeUndefined(); expect(validateDottedDecimalIPv4Input(" 192.168.1.100 ")).toBeUndefined(); expect(validateDottedDecimalIPv4Input("0177.0.0.1")).toBe( "Invalid IPv4 address (e.g., 192.168.1.100)", ); expect(validateDottedDecimalIPv4Input("[192.168.1.100]")).toBeUndefined(); + expect(validateDottedDecimalIPv4Input("127.1")).toBe( + "Invalid IPv4 address (e.g., 192.168.1.100)", + ); expect(validateDottedDecimalIPv4Input("example.com")).toBe( "Invalid IPv4 address (e.g., 192.168.1.100)", ); From fbcea506ba43406b7f3fa4b7d6818060c28051e2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:49:50 +0000 Subject: [PATCH 070/461] test: tighten shared gateway bind and avatar coverage --- src/shared/avatar-policy.test.ts | 2 ++ src/shared/gateway-bind-url.test.ts | 39 ++++++++++++++++++++++++----- 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/src/shared/avatar-policy.test.ts b/src/shared/avatar-policy.test.ts index cbc345767e7..9f2dadeca0c 100644 --- a/src/shared/avatar-policy.test.ts +++ b/src/shared/avatar-policy.test.ts @@ -39,11 +39,13 @@ describe("avatar policy", () => { expect(isPathWithinRoot(root, root)).toBe(true); expect(isPathWithinRoot(root, path.resolve("/tmp/root/avatars/a.png"))).toBe(true); expect(isPathWithinRoot(root, path.resolve("/tmp/root/../outside.png"))).toBe(false); + expect(isPathWithinRoot(root, path.resolve("/tmp/root-sibling/avatar.png"))).toBe(false); }); it("detects avatar-like path strings", () => { expect(looksLikeAvatarPath("avatars/openclaw.svg")).toBe(true); expect(looksLikeAvatarPath("openclaw.webp")).toBe(true); + expect(looksLikeAvatarPath("avatar.ico")).toBe(true); expect(looksLikeAvatarPath("A")).toBe(false); }); diff --git a/src/shared/gateway-bind-url.test.ts b/src/shared/gateway-bind-url.test.ts index 23dd855c4e6..5bf9c8582a5 100644 --- a/src/shared/gateway-bind-url.test.ts +++ b/src/shared/gateway-bind-url.test.ts @@ -3,25 +3,33 @@ import { resolveGatewayBindUrl } from "./gateway-bind-url.js"; describe("shared/gateway-bind-url", () => { it("returns null for loopback/default binds", () => { + const pickTailnetHost = vi.fn(() => "100.64.0.1"); + const pickLanHost = vi.fn(() => "192.168.1.2"); + expect( resolveGatewayBindUrl({ scheme: "ws", port: 18789, - pickTailnetHost: () => "100.64.0.1", - pickLanHost: () => "192.168.1.2", + pickTailnetHost, + pickLanHost, }), ).toBeNull(); + expect(pickTailnetHost).not.toHaveBeenCalled(); + expect(pickLanHost).not.toHaveBeenCalled(); }); it("resolves custom binds only when custom host is present after trimming", () => { + const pickTailnetHost = vi.fn(); + const pickLanHost = vi.fn(); + expect( resolveGatewayBindUrl({ bind: "custom", customBindHost: " gateway.local ", scheme: "wss", port: 443, - pickTailnetHost: vi.fn(), - pickLanHost: vi.fn(), + pickTailnetHost, + pickLanHost, }), ).toEqual({ url: "wss://gateway.local:443", @@ -34,12 +42,14 @@ describe("shared/gateway-bind-url", () => { customBindHost: " ", scheme: "ws", port: 18789, - pickTailnetHost: vi.fn(), - pickLanHost: vi.fn(), + pickTailnetHost, + pickLanHost, }), ).toEqual({ error: "gateway.bind=custom requires gateway.customBindHost.", }); + expect(pickTailnetHost).not.toHaveBeenCalled(); + expect(pickLanHost).not.toHaveBeenCalled(); }); it("resolves tailnet and lan binds or returns clear errors", () => { @@ -91,4 +101,21 @@ describe("shared/gateway-bind-url", () => { error: "gateway.bind=lan set, but no private LAN IP was found.", }); }); + + it("returns null for unrecognized bind values without probing pickers", () => { + const pickTailnetHost = vi.fn(() => "100.64.0.1"); + const pickLanHost = vi.fn(() => "192.168.1.2"); + + expect( + resolveGatewayBindUrl({ + bind: "loopbackish", + scheme: "ws", + port: 18789, + pickTailnetHost, + pickLanHost, + }), + ).toBeNull(); + expect(pickTailnetHost).not.toHaveBeenCalled(); + expect(pickLanHost).not.toHaveBeenCalled(); + }); }); From e665888a45d933c229aefe73c2a05e125290b983 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:51:01 +0000 Subject: [PATCH 071/461] test: tighten shared usage aggregate coverage --- src/shared/usage-aggregates.test.ts | 42 +++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/src/shared/usage-aggregates.test.ts b/src/shared/usage-aggregates.test.ts index e5ba960ad95..dc6896b7490 100644 --- a/src/shared/usage-aggregates.test.ts +++ b/src/shared/usage-aggregates.test.ts @@ -16,6 +16,13 @@ describe("shared/usage-aggregates", () => { }; mergeUsageLatency(totals, undefined); + mergeUsageLatency(totals, { + count: 0, + avgMs: 999, + minMs: 1, + maxMs: 999, + p95Ms: 999, + }); mergeUsageLatency(totals, { count: 2, avgMs: 50, @@ -51,6 +58,7 @@ describe("shared/usage-aggregates", () => { { date: "2026-03-12", count: 1, avgMs: 120, minMs: 120, maxMs: 120, p95Ms: 120 }, { date: "2026-03-11", count: 1, avgMs: 30, minMs: 30, maxMs: 30, p95Ms: 30 }, ]); + mergeUsageDailyLatency(dailyLatencyMap, null); const tail = buildUsageAggregateTail({ byChannelMap: new Map([ @@ -114,4 +122,38 @@ describe("shared/usage-aggregates", () => { expect(tail.latency).toBeUndefined(); expect(tail.dailyLatency).toEqual([]); }); + + it("normalizes zero-count daily latency entries to zero averages and mins", () => { + const dailyLatencyMap = new Map([ + [ + "2026-03-12", + { + date: "2026-03-12", + count: 0, + sum: 0, + min: Number.POSITIVE_INFINITY, + max: 0, + p95Max: 0, + }, + ], + ]); + + const tail = buildUsageAggregateTail({ + byChannelMap: new Map(), + latencyTotals: { + count: 0, + sum: 0, + min: Number.POSITIVE_INFINITY, + max: 0, + p95Max: 0, + }, + dailyLatencyMap, + modelDailyMap: new Map(), + dailyMap: new Map(), + }); + + expect(tail.dailyLatency).toEqual([ + { date: "2026-03-12", count: 0, avgMs: 0, minMs: 0, maxMs: 0, p95Ms: 0 }, + ]); + }); }); From 4de268587cb649ecd7c9e82f46351d3c20b3fb59 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:52:01 +0000 Subject: [PATCH 072/461] test: tighten shared tailscale fallback coverage --- src/shared/tailscale-status.test.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/shared/tailscale-status.test.ts b/src/shared/tailscale-status.test.ts index 94128e700ed..ebd89629a4c 100644 --- a/src/shared/tailscale-status.test.ts +++ b/src/shared/tailscale-status.test.ts @@ -54,9 +54,23 @@ describe("shared/tailscale-status", () => { expect(run).toHaveBeenCalledTimes(2); }); + it("continues when the first candidate returns success but malformed Self data", async () => { + const run = vi + .fn() + .mockResolvedValueOnce({ code: 0, stdout: '{"Self":"bad"}' }) + .mockResolvedValueOnce({ + code: 0, + stdout: 'prefix {"Self":{"TailscaleIPs":["100.64.0.11"]}} suffix', + }); + + await expect(resolveTailnetHostWithRunner(run)).resolves.toBe("100.64.0.11"); + expect(run).toHaveBeenCalledTimes(2); + }); + it("returns null for non-zero exits, blank output, or invalid json", async () => { const run = vi .fn() + .mockResolvedValueOnce({ code: null, stdout: "boom" }) .mockResolvedValueOnce({ code: 1, stdout: "boom" }) .mockResolvedValueOnce({ code: 0, stdout: " " }); From 52900b48ad375cf1c2e976e546164eca6b920123 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:53:11 +0000 Subject: [PATCH 073/461] test: tighten shared policy helper coverage --- src/shared/device-auth.test.ts | 1 + src/shared/operator-scope-compat.test.ts | 17 +++++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/src/shared/device-auth.test.ts b/src/shared/device-auth.test.ts index a3bc6fa3956..d3018f5ba0a 100644 --- a/src/shared/device-auth.test.ts +++ b/src/shared/device-auth.test.ts @@ -13,6 +13,7 @@ describe("shared/device-auth", () => { normalizeDeviceAuthScopes([" node.invoke ", "operator.read", "", "node.invoke", "a.scope"]), ).toEqual(["a.scope", "node.invoke", "operator.read"]); expect(normalizeDeviceAuthScopes(undefined)).toEqual([]); + expect(normalizeDeviceAuthScopes(null as unknown as string[])).toEqual([]); expect(normalizeDeviceAuthScopes([" ", "\t", "\n"])).toEqual([]); expect(normalizeDeviceAuthScopes(["z.scope", "A.scope", "m.scope"])).toEqual([ "A.scope", diff --git a/src/shared/operator-scope-compat.test.ts b/src/shared/operator-scope-compat.test.ts index e48a17ad398..44236ca7341 100644 --- a/src/shared/operator-scope-compat.test.ts +++ b/src/shared/operator-scope-compat.test.ts @@ -2,6 +2,16 @@ import { describe, expect, it } from "vitest"; import { roleScopesAllow } from "./operator-scope-compat.js"; describe("roleScopesAllow", () => { + it("allows empty requested scope lists regardless of granted scopes", () => { + expect( + roleScopesAllow({ + role: "operator", + requestedScopes: [], + allowedScopes: [], + }), + ).toBe(true); + }); + it("treats operator.read as satisfied by read/write/admin scopes", () => { expect( roleScopesAllow({ @@ -85,6 +95,13 @@ describe("roleScopesAllow", () => { allowedScopes: ["operator.admin"], }), ).toBe(false); + expect( + roleScopesAllow({ + role: " node ", + requestedScopes: [" system.run ", "system.run", " "], + allowedScopes: ["system.run", "operator.admin"], + }), + ).toBe(true); }); it("normalizes blank and duplicate scopes before evaluating", () => { From 158d970e2b2c0f7dbb91490e254f16afe7fe2370 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Fri, 13 Mar 2026 16:53:40 -0500 Subject: [PATCH 074/461] [codex] Polish sidebar status, agent skills, and chat rendering (#45451) * style: update chat layout and spacing for improved UI consistency - Adjusted margin and padding for .chat-thread and .content--chat to enhance layout. - Consolidated CSS selectors for better readability and maintainability. - Introduced new test for log parsing functionality to ensure accurate message extraction. * UI: polish agent skills, chat images, and sidebar status * test: stabilize vitest helper export types * UI: address review feedback on agents refresh and chat styles * test: update outbound gateway client fixture values * test: narrow shared ip fixtures to IPv4 --- src/cli/daemon-cli/lifecycle-core.test.ts | 2 + .../test-helpers/lifecycle-core-harness.ts | 36 +++- .../test-helpers/schtasks-base-mocks.ts | 4 +- src/daemon/test-helpers/schtasks-fixtures.ts | 8 +- .../outbound/outbound-send-service.test.ts | 9 +- src/shared/net/ip.test.ts | 3 +- .../bot-native-commands.test-helpers.ts | 28 ++- ui/src/i18n/locales/de.ts | 1 + ui/src/i18n/locales/en.ts | 1 + ui/src/i18n/locales/es.ts | 1 + ui/src/i18n/locales/pt-BR.ts | 1 + ui/src/i18n/locales/zh-CN.ts | 1 + ui/src/i18n/locales/zh-TW.ts | 1 + ui/src/styles/chat/layout.css | 28 ++- ui/src/styles/chat/sidebar.css | 12 ++ ui/src/styles/chat/text.css | 13 ++ ui/src/styles/components.css | 4 +- ui/src/styles/layout.css | 45 +++-- ui/src/styles/layout.mobile.css | 8 +- ui/src/ui/app-render.helpers.ts | 17 ++ ui/src/ui/app-render.ts | 20 +- ui/src/ui/controllers/logs.test.ts | 28 +++ ui/src/ui/controllers/logs.ts | 2 + ui/src/ui/markdown.test.ts | 1 + ui/src/ui/markdown.ts | 2 +- ui/src/ui/sidebar-status.browser.test.ts | 24 +++ ui/src/ui/views/agents.test.ts | 174 ++++++++++++++++++ ui/src/ui/views/agents.ts | 6 +- 28 files changed, 427 insertions(+), 53 deletions(-) create mode 100644 ui/src/ui/controllers/logs.test.ts create mode 100644 ui/src/ui/sidebar-status.browser.test.ts create mode 100644 ui/src/ui/views/agents.test.ts diff --git a/src/cli/daemon-cli/lifecycle-core.test.ts b/src/cli/daemon-cli/lifecycle-core.test.ts index 7503e21ae5e..6e86ad0d23a 100644 --- a/src/cli/daemon-cli/lifecycle-core.test.ts +++ b/src/cli/daemon-cli/lifecycle-core.test.ts @@ -46,6 +46,7 @@ describe("runServiceRestart token drift", () => { }); resetLifecycleServiceMocks(); service.readCommand.mockResolvedValue({ + programArguments: [], environment: { OPENCLAW_GATEWAY_TOKEN: "service-token" }, }); stubEmptyGatewayEnv(); @@ -77,6 +78,7 @@ describe("runServiceRestart token drift", () => { }, }); service.readCommand.mockResolvedValue({ + programArguments: [], environment: { OPENCLAW_GATEWAY_TOKEN: "env-token" }, }); vi.stubEnv("OPENCLAW_GATEWAY_TOKEN", "env-token"); diff --git a/src/cli/daemon-cli/test-helpers/lifecycle-core-harness.ts b/src/cli/daemon-cli/test-helpers/lifecycle-core-harness.ts index 8e91db61664..6e2a93d5633 100644 --- a/src/cli/daemon-cli/test-helpers/lifecycle-core-harness.ts +++ b/src/cli/daemon-cli/test-helpers/lifecycle-core-harness.ts @@ -1,16 +1,36 @@ import { vi } from "vitest"; +import type { GatewayService } from "../../../daemon/service.js"; +import type { RuntimeEnv } from "../../../runtime.js"; +import type { MockFn } from "../../../test-utils/vitest-mock-fn.js"; export const runtimeLogs: string[] = []; -export const defaultRuntime = { - log: (message: string) => runtimeLogs.push(message), - error: vi.fn(), - exit: (code: number) => { - throw new Error(`__exit__:${code}`); - }, +type LifecycleRuntimeHarness = RuntimeEnv & { + error: MockFn; + exit: MockFn; }; -export const service = { +type LifecycleServiceHarness = GatewayService & { + install: MockFn; + uninstall: MockFn; + stop: MockFn; + isLoaded: MockFn; + readCommand: MockFn; + readRuntime: MockFn; + restart: MockFn; +}; + +export const defaultRuntime: LifecycleRuntimeHarness = { + log: (...args: unknown[]) => { + runtimeLogs.push(args.map((arg) => String(arg)).join(" ")); + }, + error: vi.fn(), + exit: vi.fn((code: number) => { + throw new Error(`__exit__:${code}`); + }), +}; + +export const service: LifecycleServiceHarness = { label: "TestService", loadedText: "loaded", notLoadedText: "not loaded", @@ -32,7 +52,7 @@ export function resetLifecycleServiceMocks() { service.readCommand.mockClear(); service.restart.mockClear(); service.isLoaded.mockResolvedValue(true); - service.readCommand.mockResolvedValue({ environment: {} }); + service.readCommand.mockResolvedValue({ programArguments: [], environment: {} }); service.restart.mockResolvedValue({ outcome: "completed" }); } diff --git a/src/daemon/test-helpers/schtasks-base-mocks.ts b/src/daemon/test-helpers/schtasks-base-mocks.ts index 48933ecdd1c..e3f0f950482 100644 --- a/src/daemon/test-helpers/schtasks-base-mocks.ts +++ b/src/daemon/test-helpers/schtasks-base-mocks.ts @@ -14,9 +14,9 @@ vi.mock("../schtasks-exec.js", () => ({ })); vi.mock("../../infra/ports.js", () => ({ - inspectPortUsage: (...args: unknown[]) => inspectPortUsage(...args), + inspectPortUsage: (port: number) => inspectPortUsage(port), })); vi.mock("../../process/kill-tree.js", () => ({ - killProcessTree: (...args: unknown[]) => killProcessTree(...args), + killProcessTree: (pid: number, opts?: { graceMs?: number }) => killProcessTree(pid, opts), })); diff --git a/src/daemon/test-helpers/schtasks-fixtures.ts b/src/daemon/test-helpers/schtasks-fixtures.ts index a89d7a0eb2e..4762b7543eb 100644 --- a/src/daemon/test-helpers/schtasks-fixtures.ts +++ b/src/daemon/test-helpers/schtasks-fixtures.ts @@ -2,11 +2,15 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { vi } from "vitest"; +import type { PortUsage } from "../../infra/ports-types.js"; +import type { killProcessTree as killProcessTreeImpl } from "../../process/kill-tree.js"; +import type { MockFn } from "../../test-utils/vitest-mock-fn.js"; export const schtasksResponses: Array<{ code: number; stdout: string; stderr: string }> = []; export const schtasksCalls: string[][] = []; -export const inspectPortUsage = vi.fn(); -export const killProcessTree = vi.fn(); + +export const inspectPortUsage: MockFn<(port: number) => Promise> = vi.fn(); +export const killProcessTree: MockFn = vi.fn(); export async function withWindowsEnv( prefix: string, diff --git a/src/infra/outbound/outbound-send-service.test.ts b/src/infra/outbound/outbound-send-service.test.ts index 391abee8dda..ac144265753 100644 --- a/src/infra/outbound/outbound-send-service.test.ts +++ b/src/infra/outbound/outbound-send-service.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../utils/message-channel.js"; const mocks = vi.hoisted(() => ({ getDefaultMediaLocalRoots: vi.fn(() => []), @@ -204,8 +205,8 @@ describe("executeSendAction", () => { url: "http://127.0.0.1:18789", token: "tok", timeoutMs: 5000, - clientName: "gateway", - mode: "gateway", + clientName: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT, + mode: GATEWAY_CLIENT_MODES.BACKEND, }, }, to: "channel:123", @@ -296,8 +297,8 @@ describe("executeSendAction", () => { url: "http://127.0.0.1:18789", token: "tok", timeoutMs: 5000, - clientName: "gateway", - mode: "gateway", + clientName: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT, + mode: GATEWAY_CLIENT_MODES.BACKEND, }, }, to: "channel:123", diff --git a/src/shared/net/ip.test.ts b/src/shared/net/ip.test.ts index 0328c7c87f2..2322a106c9d 100644 --- a/src/shared/net/ip.test.ts +++ b/src/shared/net/ip.test.ts @@ -6,6 +6,7 @@ import { isCanonicalDottedDecimalIPv4, isCarrierGradeNatIpv4Address, isIpInCidr, + isIpv4Address, isIpv6Address, isLegacyIpv4Literal, isLoopbackIpAddress, @@ -88,7 +89,7 @@ describe("shared ip helpers", () => { expect(loopback?.kind()).toBe("ipv4"); expect(benchmark?.kind()).toBe("ipv4"); - if (!loopback || loopback.kind() !== "ipv4" || !benchmark || benchmark.kind() !== "ipv4") { + if (!loopback || !isIpv4Address(loopback) || !benchmark || !isIpv4Address(benchmark)) { throw new Error("expected ipv4 fixtures"); } diff --git a/src/telegram/bot-native-commands.test-helpers.ts b/src/telegram/bot-native-commands.test-helpers.ts index 02f1028becf..eef028c8315 100644 --- a/src/telegram/bot-native-commands.test-helpers.ts +++ b/src/telegram/bot-native-commands.test-helpers.ts @@ -3,12 +3,28 @@ import type { OpenClawConfig } from "../config/config.js"; import type { ChannelGroupPolicy } from "../config/group-policy.js"; import type { TelegramAccountConfig } from "../config/types.js"; import type { RuntimeEnv } from "../runtime.js"; +import type { MockFn } from "../test-utils/vitest-mock-fn.js"; import { registerTelegramNativeCommands } from "./bot-native-commands.js"; type RegisterTelegramNativeCommandsParams = Parameters[0]; type GetPluginCommandSpecsFn = typeof import("../plugins/commands.js").getPluginCommandSpecs; type MatchPluginCommandFn = typeof import("../plugins/commands.js").matchPluginCommand; type ExecutePluginCommandFn = typeof import("../plugins/commands.js").executePluginCommand; +type AnyMock = MockFn<(...args: unknown[]) => unknown>; +type AnyAsyncMock = MockFn<(...args: unknown[]) => Promise>; +type NativeCommandHarness = { + handlers: Record Promise>; + sendMessage: AnyAsyncMock; + setMyCommands: AnyAsyncMock; + log: AnyMock; + bot: { + api: { + setMyCommands: AnyAsyncMock; + sendMessage: AnyAsyncMock; + }; + command: (name: string, handler: (ctx: unknown) => Promise) => void; + }; +}; const pluginCommandMocks = vi.hoisted(() => ({ getPluginCommandSpecs: vi.fn(() => []), @@ -86,12 +102,12 @@ export function createNativeCommandsHarness(params?: { nativeEnabled?: boolean; groupConfig?: Record; resolveGroupPolicy?: () => ChannelGroupPolicy; -}) { +}): NativeCommandHarness { const handlers: Record Promise> = {}; - const sendMessage = vi.fn().mockResolvedValue(undefined); - const setMyCommands = vi.fn().mockResolvedValue(undefined); - const log = vi.fn(); - const bot = { + const sendMessage: AnyAsyncMock = vi.fn(async () => undefined); + const setMyCommands: AnyAsyncMock = vi.fn(async () => undefined); + const log: AnyMock = vi.fn(); + const bot: NativeCommandHarness["bot"] = { api: { setMyCommands, sendMessage, @@ -153,7 +169,7 @@ export function createTelegramGroupCommandContext(params?: { }; } -export function findNotAuthorizedCalls(sendMessage: ReturnType) { +export function findNotAuthorizedCalls(sendMessage: AnyAsyncMock) { return sendMessage.mock.calls.filter( (call) => typeof call[1] === "string" && call[1].includes("not authorized"), ); diff --git a/ui/src/i18n/locales/de.ts b/ui/src/i18n/locales/de.ts index f45ffc3f4c0..7fd638766e7 100644 --- a/ui/src/i18n/locales/de.ts +++ b/ui/src/i18n/locales/de.ts @@ -5,6 +5,7 @@ export const de: TranslationMap = { version: "Version", health: "Status", ok: "OK", + online: "Online", offline: "Offline", connect: "Verbinden", refresh: "Aktualisieren", diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index df80f2d7c78..370fec9c660 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -4,6 +4,7 @@ export const en: TranslationMap = { common: { health: "Health", ok: "OK", + online: "Online", offline: "Offline", connect: "Connect", refresh: "Refresh", diff --git a/ui/src/i18n/locales/es.ts b/ui/src/i18n/locales/es.ts index a96ee7ad2d7..091cd2ca937 100644 --- a/ui/src/i18n/locales/es.ts +++ b/ui/src/i18n/locales/es.ts @@ -5,6 +5,7 @@ export const es: TranslationMap = { version: "Versión", health: "Estado", ok: "Correcto", + online: "En línea", offline: "Desconectado", connect: "Conectar", refresh: "Actualizar", diff --git a/ui/src/i18n/locales/pt-BR.ts b/ui/src/i18n/locales/pt-BR.ts index aaaa26c253e..cb9ba1ba283 100644 --- a/ui/src/i18n/locales/pt-BR.ts +++ b/ui/src/i18n/locales/pt-BR.ts @@ -4,6 +4,7 @@ export const pt_BR: TranslationMap = { common: { health: "Saúde", ok: "OK", + online: "Online", offline: "Offline", connect: "Conectar", refresh: "Atualizar", diff --git a/ui/src/i18n/locales/zh-CN.ts b/ui/src/i18n/locales/zh-CN.ts index ac321857253..b039be16f41 100644 --- a/ui/src/i18n/locales/zh-CN.ts +++ b/ui/src/i18n/locales/zh-CN.ts @@ -4,6 +4,7 @@ export const zh_CN: TranslationMap = { common: { health: "健康状况", ok: "正常", + online: "在线", offline: "离线", connect: "连接", refresh: "刷新", diff --git a/ui/src/i18n/locales/zh-TW.ts b/ui/src/i18n/locales/zh-TW.ts index 56a80c61d92..a6a616209e7 100644 --- a/ui/src/i18n/locales/zh-TW.ts +++ b/ui/src/i18n/locales/zh-TW.ts @@ -4,6 +4,7 @@ export const zh_TW: TranslationMap = { common: { health: "健康狀況", ok: "正常", + online: "在線", offline: "離線", connect: "連接", refresh: "刷新", diff --git a/ui/src/styles/chat/layout.css b/ui/src/styles/chat/layout.css index 536acddd29e..f0e0154329d 100644 --- a/ui/src/styles/chat/layout.css +++ b/ui/src/styles/chat/layout.css @@ -9,7 +9,9 @@ flex-direction: column; flex: 1 1 0; height: 100%; - min-height: 0; /* Allow flex shrinking */ + width: 100%; + min-height: 0; + /* Allow flex shrinking */ overflow: hidden; background: transparent !important; border: none !important; @@ -24,8 +26,8 @@ gap: 12px; flex-wrap: nowrap; flex-shrink: 0; - padding-bottom: 12px; - margin-bottom: 12px; + padding-bottom: 0; + margin-bottom: 0; background: transparent; } @@ -49,16 +51,22 @@ /* Chat thread - scrollable middle section, transparent */ .chat-thread { - flex: 1 1 0; /* Grow, shrink, and use 0 base for proper scrolling */ + flex: 1 1 0; + /* Grow, shrink, and use 0 base for proper scrolling */ overflow-y: auto; overflow-x: hidden; - padding: 12px 4px; - margin: 0 -4px; - min-height: 0; /* Allow shrinking for flex scroll behavior */ + padding: 0 6px 6px; + margin: 0 0 0 0; + min-height: 0; + /* Allow shrinking for flex scroll behavior */ border-radius: 12px; background: transparent; } +.chat-thread-inner > :first-child { + margin-top: 0 !important; +} + /* Focus mode exit button */ .chat-focus-exit { position: absolute; @@ -146,7 +154,8 @@ display: flex; flex-direction: column; gap: 12px; - margin-top: auto; /* Push to bottom of flex container */ + margin-top: auto; + /* Push to bottom of flex container */ padding: 12px 4px 4px; background: linear-gradient(to bottom, transparent, var(--bg) 20%); z-index: 10; @@ -163,7 +172,8 @@ border: 1px solid var(--border); width: fit-content; max-width: 100%; - align-self: flex-start; /* Don't stretch in flex column parent */ + align-self: flex-start; + /* Don't stretch in flex column parent */ } .chat-attachment { diff --git a/ui/src/styles/chat/sidebar.css b/ui/src/styles/chat/sidebar.css index 934e285d95b..de6010f3ed7 100644 --- a/ui/src/styles/chat/sidebar.css +++ b/ui/src/styles/chat/sidebar.css @@ -82,6 +82,18 @@ line-height: 1.5; } +.sidebar-markdown .markdown-inline-image { + display: block; + max-width: 100%; + max-height: 420px; + width: auto; + height: auto; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: color-mix(in srgb, var(--secondary) 70%, transparent); + object-fit: contain; +} + .sidebar-markdown pre { background: rgba(0, 0, 0, 0.12); border-radius: 4px; diff --git a/ui/src/styles/chat/text.css b/ui/src/styles/chat/text.css index 56224fabf9e..dd76434e041 100644 --- a/ui/src/styles/chat/text.css +++ b/ui/src/styles/chat/text.css @@ -56,6 +56,19 @@ font-size: 0.9em; } +.chat-text :where(.markdown-inline-image) { + display: block; + max-width: min(100%, 420px); + max-height: 320px; + width: auto; + height: auto; + margin-top: 0.75em; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: color-mix(in srgb, var(--secondary) 70%, transparent); + object-fit: contain; +} + .chat-text :where(:not(pre) > code) { background: rgba(0, 0, 0, 0.15); padding: 0.15em 0.4em; diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index d1dc29ca04e..b2806f3208f 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -2157,7 +2157,7 @@ } .chat-thread { - margin-top: 16px; + margin-top: 0; display: flex; flex-direction: column; gap: 12px; @@ -2165,7 +2165,7 @@ min-height: 0; overflow-y: auto; overflow-x: hidden; - padding: 16px 12px; + padding: 0 12px 16px; min-width: 0; border-radius: 0; border: none; diff --git a/ui/src/styles/layout.css b/ui/src/styles/layout.css index 12f22aef21d..6e19806bb32 100644 --- a/ui/src/styles/layout.css +++ b/ui/src/styles/layout.css @@ -5,7 +5,7 @@ .shell { --shell-pad: 16px; --shell-gap: 16px; - --shell-nav-width: 288px; + --shell-nav-width: 258px; --shell-nav-rail-width: 78px; --shell-topbar-height: 52px; --shell-focus-duration: 200ms; @@ -340,7 +340,7 @@ flex-direction: column; min-height: 0; flex: 1; - padding: 14px 14px 12px; + padding: 14px 10px 12px; border: none; border-radius: 0; background: transparent; @@ -503,7 +503,7 @@ justify-content: space-between; gap: 8px; width: 100%; - padding: 0 12px; + padding: 0 10px; min-height: 28px; background: transparent; border: none; @@ -522,9 +522,9 @@ } .nav-section__label-text { - font-size: 11px; + font-size: 12px; font-weight: 700; - letter-spacing: 0.08em; + letter-spacing: 0.06em; text-transform: uppercase; } @@ -555,9 +555,9 @@ display: flex; align-items: center; justify-content: flex-start; - gap: 10px; - min-height: 38px; - padding: 0 12px; + gap: 8px; + min-height: 40px; + padding: 0 9px; border-radius: 12px; border: 1px solid transparent; background: transparent; @@ -595,8 +595,8 @@ } .nav-item__text { - font-size: 13px; - font-weight: 550; + font-size: 14px; + font-weight: 600; white-space: nowrap; } @@ -763,6 +763,24 @@ margin: 0 auto; } +.sidebar-version__status { + width: 8px; + height: 8px; + border-radius: var(--radius-full); + flex-shrink: 0; + margin-left: auto; +} + +.sidebar-version__status.sidebar-connection-status--online { + background: var(--ok); + box-shadow: 0 0 0 4px color-mix(in srgb, var(--ok) 14%, transparent); +} + +.sidebar-version__status.sidebar-connection-status--offline { + background: var(--danger); + box-shadow: 0 0 0 4px color-mix(in srgb, var(--danger) 14%, transparent); +} + .sidebar--collapsed .sidebar-shell__footer { padding: 8px 0 2px; } @@ -780,6 +798,10 @@ border-radius: 16px; } +.sidebar--collapsed .sidebar-version__status { + margin-left: 0; +} + .shell--nav-collapsed .shell-nav { width: var(--shell-nav-rail-width); min-width: var(--shell-nav-rail-width); @@ -844,7 +866,7 @@ .content--chat { display: flex; flex-direction: column; - gap: 24px; + gap: 2px; overflow: hidden; padding-bottom: 0; } @@ -905,6 +927,7 @@ align-items: center; justify-content: space-between; gap: 16px; + padding-bottom: 0; } .content--chat .content-header > div:first-child { diff --git a/ui/src/styles/layout.mobile.css b/ui/src/styles/layout.mobile.css index 3c929435a7b..036e6a7c588 100644 --- a/ui/src/styles/layout.mobile.css +++ b/ui/src/styles/layout.mobile.css @@ -323,6 +323,10 @@ gap: 8px; } + .content--chat { + gap: 2px; + } + .content--chat .content-header > div:first-child, .content--chat .page-meta, .content--chat .chat-controls { @@ -417,8 +421,8 @@ } .chat-thread { - margin-top: 8px; - padding: 12px 8px; + margin-top: 0; + padding: 0 8px 12px; } .chat-msg { diff --git a/ui/src/ui/app-render.helpers.ts b/ui/src/ui/app-render.helpers.ts index 0ebafc22d4d..eaf94616032 100644 --- a/ui/src/ui/app-render.helpers.ts +++ b/ui/src/ui/app-render.helpers.ts @@ -743,6 +743,23 @@ export function renderTopbarThemeModeToggle(state: AppViewState) { `; } +export function renderSidebarConnectionStatus(state: AppViewState) { + const label = state.connected ? t("common.online") : t("common.offline"); + const toneClass = state.connected + ? "sidebar-connection-status--online" + : "sidebar-connection-status--offline"; + + return html` + + `; +} + export function renderThemeToggle(state: AppViewState) { const setOpen = (orb: HTMLElement, nextOpen: boolean) => { orb.classList.toggle("theme-orb--open", nextOpen); diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index b1ddf9e323c..643edfca521 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -10,6 +10,7 @@ import { renderChatControls, renderChatSessionSelect, renderTab, + renderSidebarConnectionStatus, renderTopbarThemeModeToggle, } from "./app-render.helpers.ts"; import type { AppViewState } from "./app-view-state.ts"; @@ -437,9 +438,7 @@ export function renderApp(state: AppViewState) { ${t("common.search")} ⌘K -
- ${renderTopbarThemeModeToggle(state)} -
+
${renderTopbarThemeModeToggle(state)}
@@ -543,9 +542,10 @@ export function renderApp(state: AppViewState) { ? html` ${t("common.version")} v${version} + ${renderSidebarConnectionStatus(state)} ` : html` - + ${renderSidebarConnectionStatus(state)} ` } @@ -924,9 +924,21 @@ export function renderApp(state: AppViewState) { state.agentsList?.defaultId ?? state.agentsList?.agents?.[0]?.id ?? null; + if (state.agentsPanel === "files" && refreshedAgentId) { + void loadAgentFiles(state, refreshedAgentId); + } + if (state.agentsPanel === "skills" && refreshedAgentId) { + void loadAgentSkills(state, refreshedAgentId); + } if (state.agentsPanel === "tools" && refreshedAgentId) { void loadToolsCatalog(state, refreshedAgentId); } + if (state.agentsPanel === "channels") { + void loadChannels(state, false); + } + if (state.agentsPanel === "cron") { + void state.loadCron(); + } }, onSelectAgent: (agentId) => { if (state.agentsSelectedId === agentId) { diff --git a/ui/src/ui/controllers/logs.test.ts b/ui/src/ui/controllers/logs.test.ts new file mode 100644 index 00000000000..5d1a830de7a --- /dev/null +++ b/ui/src/ui/controllers/logs.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { parseLogLine } from "./logs.ts"; + +describe("parseLogLine", () => { + it("prefers the human-readable message field when structured data is stored in slot 1", () => { + const line = JSON.stringify({ + 0: '{"subsystem":"gateway/ws"}', + 1: { + cause: "unauthorized", + authReason: "password_missing", + }, + 2: "closed before connect conn=abc code=4008 reason=connect failed", + _meta: { + date: "2026-03-13T19:07:12.128Z", + logLevelName: "WARN", + }, + time: "2026-03-13T14:07:12.138-05:00", + }); + + expect(parseLogLine(line)).toEqual( + expect.objectContaining({ + level: "warn", + subsystem: "gateway/ws", + message: "closed before connect conn=abc code=4008 reason=connect failed", + }), + ); + }); +}); diff --git a/ui/src/ui/controllers/logs.ts b/ui/src/ui/controllers/logs.ts index d2e919c6210..90c2edcf00a 100644 --- a/ui/src/ui/controllers/logs.ts +++ b/ui/src/ui/controllers/logs.ts @@ -77,6 +77,8 @@ export function parseLogLine(line: string): LogEntry { let message: string | null = null; if (typeof obj["1"] === "string") { message = obj["1"]; + } else if (typeof obj["2"] === "string") { + message = obj["2"]; } else if (!contextObj && typeof obj["0"] === "string") { message = obj["0"]; } else if (typeof obj.message === "string") { diff --git a/ui/src/ui/markdown.test.ts b/ui/src/ui/markdown.test.ts index 279cb2b53fb..90bce3b65f5 100644 --- a/ui/src/ui/markdown.test.ts +++ b/ui/src/ui/markdown.test.ts @@ -39,6 +39,7 @@ describe("toSanitizedMarkdownHtml", () => { it("preserves base64 data URI images (#15437)", () => { const html = toSanitizedMarkdownHtml("![Chart](data:image/png;base64,iVBORw0KGgo=)"); expect(html).toContain("`; + return `${escapeHtml(label)}`; }; function normalizeMarkdownImageLabel(text?: string | null): string { diff --git a/ui/src/ui/sidebar-status.browser.test.ts b/ui/src/ui/sidebar-status.browser.test.ts new file mode 100644 index 00000000000..315501c36a2 --- /dev/null +++ b/ui/src/ui/sidebar-status.browser.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { mountApp, registerAppMountHooks } from "./test-helpers/app-mount.ts"; + +registerAppMountHooks(); + +describe("sidebar connection status", () => { + it("shows a single online status dot next to the version", async () => { + const app = mountApp("/chat"); + await app.updateComplete; + + app.hello = { + ok: true, + server: { version: "1.2.3" }, + } as never; + app.requestUpdate(); + await app.updateComplete; + + const version = app.querySelector(".sidebar-version"); + const statusDot = app.querySelector(".sidebar-version__status"); + expect(version).not.toBeNull(); + expect(statusDot).not.toBeNull(); + expect(statusDot?.getAttribute("aria-label")).toContain("Online"); + }); +}); diff --git a/ui/src/ui/views/agents.test.ts b/ui/src/ui/views/agents.test.ts new file mode 100644 index 00000000000..f763877937a --- /dev/null +++ b/ui/src/ui/views/agents.test.ts @@ -0,0 +1,174 @@ +import { render } from "lit"; +import { describe, expect, it } from "vitest"; +import { renderAgents, type AgentsProps } from "./agents.ts"; + +function createSkill() { + return { + name: "Repo Skill", + description: "Skill description", + source: "workspace", + filePath: "/tmp/skill", + baseDir: "/tmp", + skillKey: "repo-skill", + always: false, + disabled: false, + blockedByAllowlist: false, + eligible: true, + requirements: { + bins: [], + env: [], + config: [], + os: [], + }, + missing: { + bins: [], + env: [], + config: [], + os: [], + }, + configChecks: [], + install: [], + }; +} + +function createProps(overrides: Partial = {}): AgentsProps { + return { + basePath: "", + loading: false, + error: null, + agentsList: { + defaultId: "alpha", + mainKey: "main", + scope: "workspace", + agents: [{ id: "alpha", name: "Alpha" } as never, { id: "beta", name: "Beta" } as never], + }, + selectedAgentId: "beta", + activePanel: "overview", + config: { + form: null, + loading: false, + saving: false, + dirty: false, + }, + channels: { + snapshot: null, + loading: false, + error: null, + lastSuccess: null, + }, + cron: { + status: null, + jobs: [], + loading: false, + error: null, + }, + agentFiles: { + list: null, + loading: false, + error: null, + active: null, + contents: {}, + drafts: {}, + saving: false, + }, + agentIdentityLoading: false, + agentIdentityError: null, + agentIdentityById: {}, + agentSkills: { + report: null, + loading: false, + error: null, + agentId: null, + filter: "", + }, + toolsCatalog: { + loading: false, + error: null, + result: null, + }, + onRefresh: () => undefined, + onSelectAgent: () => undefined, + onSelectPanel: () => undefined, + onLoadFiles: () => undefined, + onSelectFile: () => undefined, + onFileDraftChange: () => undefined, + onFileReset: () => undefined, + onFileSave: () => undefined, + onToolsProfileChange: () => undefined, + onToolsOverridesChange: () => undefined, + onConfigReload: () => undefined, + onConfigSave: () => undefined, + onModelChange: () => undefined, + onModelFallbacksChange: () => undefined, + onChannelsRefresh: () => undefined, + onCronRefresh: () => undefined, + onCronRunNow: () => undefined, + onSkillsFilterChange: () => undefined, + onSkillsRefresh: () => undefined, + onAgentSkillToggle: () => undefined, + onAgentSkillsClear: () => undefined, + onAgentSkillsDisableAll: () => undefined, + onSetDefault: () => undefined, + ...overrides, + }; +} + +describe("renderAgents", () => { + it("shows the skills count only for the selected agent's report", async () => { + const container = document.createElement("div"); + render( + renderAgents( + createProps({ + agentSkills: { + report: { + workspaceDir: "/tmp/workspace", + managedSkillsDir: "/tmp/skills", + skills: [createSkill()], + }, + loading: false, + error: null, + agentId: "alpha", + filter: "", + }, + }), + ), + container, + ); + await Promise.resolve(); + + const skillsTab = Array.from(container.querySelectorAll(".agent-tab")).find( + (button) => button.textContent?.includes("Skills"), + ); + + expect(skillsTab?.textContent?.trim()).toBe("Skills"); + }); + + it("shows the selected agent's skills count when the report matches", async () => { + const container = document.createElement("div"); + render( + renderAgents( + createProps({ + agentSkills: { + report: { + workspaceDir: "/tmp/workspace", + managedSkillsDir: "/tmp/skills", + skills: [createSkill()], + }, + loading: false, + error: null, + agentId: "beta", + filter: "", + }, + }), + ), + container, + ); + await Promise.resolve(); + + const skillsTab = Array.from(container.querySelectorAll(".agent-tab")).find( + (button) => button.textContent?.includes("Skills"), + ); + + expect(skillsTab?.textContent?.trim()).toContain("1"); + }); +}); diff --git a/ui/src/ui/views/agents.ts b/ui/src/ui/views/agents.ts index 63917b0f732..4e8b9a065ba 100644 --- a/ui/src/ui/views/agents.ts +++ b/ui/src/ui/views/agents.ts @@ -113,6 +113,10 @@ export function renderAgents(props: AgentsProps) { const selectedAgent = selectedId ? (agents.find((agent) => agent.id === selectedId) ?? null) : null; + const selectedSkillCount = + selectedId && props.agentSkills.agentId === selectedId + ? (props.agentSkills.report?.skills?.length ?? null) + : null; const channelEntryCount = props.channels.snapshot ? Object.keys(props.channels.snapshot.channelAccounts ?? {}).length @@ -122,7 +126,7 @@ export function renderAgents(props: AgentsProps) { : null; const tabCounts: Record = { files: props.agentFiles.list?.files?.length ?? null, - skills: props.agentSkills.report?.skills?.length ?? null, + skills: selectedSkillCount, channels: channelEntryCount, cron: cronJobCount || null, }; From f5b90951087b2e3ead0066c36dd34e2d702a9475 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:42:19 +0000 Subject: [PATCH 075/461] refactor: share zalo send result handling --- extensions/zalo/src/send.ts | 70 ++++++++++++++++++++++--------------- 1 file changed, 42 insertions(+), 28 deletions(-) diff --git a/extensions/zalo/src/send.ts b/extensions/zalo/src/send.ts index 44f1549067a..c6380a3b891 100644 --- a/extensions/zalo/src/send.ts +++ b/extensions/zalo/src/send.ts @@ -21,6 +21,28 @@ export type ZaloSendResult = { error?: string; }; +function toZaloSendResult(response: { + ok?: boolean; + result?: { message_id?: string }; +}): ZaloSendResult { + if (response.ok && response.result) { + return { ok: true, messageId: response.result.message_id }; + } + return { ok: false, error: "Failed to send message" }; +} + +async function runZaloSend( + failureMessage: string, + send: () => Promise<{ ok?: boolean; result?: { message_id?: string } }>, +): Promise { + try { + const result = toZaloSendResult(await send()); + return result.ok ? result : { ok: false, error: failureMessage }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} + function resolveSendContext(options: ZaloSendOptions): { token: string; fetcher?: ZaloFetch; @@ -55,14 +77,21 @@ function resolveValidatedSendContext( return { ok: true, chatId: trimmedChatId, token, fetcher }; } +function toInvalidContextResult( + context: ReturnType, +): ZaloSendResult | null { + return context.ok ? null : { ok: false, error: context.error }; +} + export async function sendMessageZalo( chatId: string, text: string, options: ZaloSendOptions = {}, ): Promise { const context = resolveValidatedSendContext(chatId, options); - if (!context.ok) { - return { ok: false, error: context.error }; + const invalidResult = toInvalidContextResult(context); + if (invalidResult) { + return invalidResult; } if (options.mediaUrl) { @@ -73,24 +102,16 @@ export async function sendMessageZalo( }); } - try { - const response = await sendMessage( + return await runZaloSend("Failed to send message", () => + sendMessage( context.token, { chat_id: context.chatId, text: text.slice(0, 2000), }, context.fetcher, - ); - - if (response.ok && response.result) { - return { ok: true, messageId: response.result.message_id }; - } - - return { ok: false, error: "Failed to send message" }; - } catch (err) { - return { ok: false, error: err instanceof Error ? err.message : String(err) }; - } + ), + ); } export async function sendPhotoZalo( @@ -99,16 +120,17 @@ export async function sendPhotoZalo( options: ZaloSendOptions = {}, ): Promise { const context = resolveValidatedSendContext(chatId, options); - if (!context.ok) { - return { ok: false, error: context.error }; + const invalidResult = toInvalidContextResult(context); + if (invalidResult) { + return invalidResult; } if (!photoUrl?.trim()) { return { ok: false, error: "No photo URL provided" }; } - try { - const response = await sendPhoto( + return await runZaloSend("Failed to send photo", () => + sendPhoto( context.token, { chat_id: context.chatId, @@ -116,14 +138,6 @@ export async function sendPhotoZalo( caption: options.caption?.slice(0, 2000), }, context.fetcher, - ); - - if (response.ok && response.result) { - return { ok: true, messageId: response.result.message_id }; - } - - return { ok: false, error: "Failed to send photo" }; - } catch (err) { - return { ok: false, error: err instanceof Error ? err.message : String(err) }; - } + ), + ); } From 853999fd7f8dfa08b5108cbcad526e06f86c46e7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:42:25 +0000 Subject: [PATCH 076/461] refactor: dedupe synology chat client webhook payloads --- extensions/synology-chat/src/client.ts | 43 ++++++++++++++------------ 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/extensions/synology-chat/src/client.ts b/extensions/synology-chat/src/client.ts index 95240e556f5..d66f1b720f4 100644 --- a/extensions/synology-chat/src/client.ts +++ b/extensions/synology-chat/src/client.ts @@ -27,6 +27,12 @@ type ChatUserCacheEntry = { cachedAt: number; }; +type ChatWebhookPayload = { + text?: string; + file_url?: string; + user_ids?: number[]; +}; + // Cache user lists per bot endpoint to avoid cross-account bleed. const chatUserCache = new Map(); const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes @@ -47,16 +53,7 @@ export async function sendMessage( ): Promise { // Synology Chat API requires user_ids (numeric) to specify the recipient // The @mention is optional but user_ids is mandatory - const payloadObj: Record = { text }; - if (userId) { - // userId can be numeric ID or username - if numeric, add to user_ids - const numericId = typeof userId === "number" ? userId : parseInt(userId, 10); - if (!isNaN(numericId)) { - payloadObj.user_ids = [numericId]; - } - } - const payload = JSON.stringify(payloadObj); - const body = `payload=${encodeURIComponent(payload)}`; + const body = buildWebhookBody({ text }, userId); // Internal rate limit: min 500ms between sends const now = Date.now(); @@ -95,15 +92,7 @@ export async function sendFileUrl( userId?: string | number, allowInsecureSsl = true, ): Promise { - const payloadObj: Record = { file_url: fileUrl }; - if (userId) { - const numericId = typeof userId === "number" ? userId : parseInt(userId, 10); - if (!isNaN(numericId)) { - payloadObj.user_ids = [numericId]; - } - } - const payload = JSON.stringify(payloadObj); - const body = `payload=${encodeURIComponent(payload)}`; + const body = buildWebhookBody({ file_url: fileUrl }, userId); try { const ok = await doPost(incomingUrl, body, allowInsecureSsl); @@ -215,6 +204,22 @@ export async function resolveChatUserId( return undefined; } +function buildWebhookBody(payload: ChatWebhookPayload, userId?: string | number): string { + const numericId = parseNumericUserId(userId); + if (numericId !== undefined) { + payload.user_ids = [numericId]; + } + return `payload=${encodeURIComponent(JSON.stringify(payload))}`; +} + +function parseNumericUserId(userId?: string | number): number | undefined { + if (userId === undefined) { + return undefined; + } + const numericId = typeof userId === "number" ? userId : parseInt(userId, 10); + return Number.isNaN(numericId) ? undefined : numericId; +} + function doPost(url: string, body: string, allowInsecureSsl = true): Promise { return new Promise((resolve, reject) => { let parsedUrl: URL; From b9f0effd55ea83d045652741d76f08e27b40b761 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:43:01 +0000 Subject: [PATCH 077/461] test: dedupe synology chat client timer setup --- extensions/synology-chat/src/client.test.ts | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/extensions/synology-chat/src/client.test.ts b/extensions/synology-chat/src/client.test.ts index 416412f0408..2ae24f42904 100644 --- a/extensions/synology-chat/src/client.test.ts +++ b/extensions/synology-chat/src/client.test.ts @@ -51,7 +51,7 @@ function mockFailureResponse(statusCode = 500) { mockResponse(statusCode, "error"); } -describe("sendMessage", () => { +function installFakeTimerHarness() { beforeEach(() => { vi.clearAllMocks(); vi.useFakeTimers(); @@ -62,6 +62,10 @@ describe("sendMessage", () => { afterEach(() => { vi.useRealTimers(); }); +} + +describe("sendMessage", () => { + installFakeTimerHarness(); it("returns true on successful send", async () => { mockSuccessResponse(); @@ -86,16 +90,7 @@ describe("sendMessage", () => { }); describe("sendFileUrl", () => { - beforeEach(() => { - vi.clearAllMocks(); - vi.useFakeTimers(); - fakeNowMs += 10_000; - vi.setSystemTime(fakeNowMs); - }); - - afterEach(() => { - vi.useRealTimers(); - }); + installFakeTimerHarness(); it("returns true on success", async () => { mockSuccessResponse(); From 58baf22230e0ec3b816da4dd5114db1734c38469 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:45:01 +0000 Subject: [PATCH 078/461] refactor: share zalo monitor processing context --- extensions/zalo/src/monitor.ts | 175 ++++++++++++++------------------- 1 file changed, 72 insertions(+), 103 deletions(-) diff --git a/extensions/zalo/src/monitor.ts b/extensions/zalo/src/monitor.ts index bd1351bd147..2c5c420ce60 100644 --- a/extensions/zalo/src/monitor.ts +++ b/extensions/zalo/src/monitor.ts @@ -75,6 +75,35 @@ const WEBHOOK_CLEANUP_TIMEOUT_MS = 5_000; const ZALO_TYPING_TIMEOUT_MS = 5_000; type ZaloCoreRuntime = ReturnType; +type ZaloStatusSink = (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void; +type ZaloProcessingContext = { + token: string; + account: ResolvedZaloAccount; + config: OpenClawConfig; + runtime: ZaloRuntimeEnv; + core: ZaloCoreRuntime; + statusSink?: ZaloStatusSink; + fetcher?: ZaloFetch; +}; +type ZaloPollingLoopParams = ZaloProcessingContext & { + abortSignal: AbortSignal; + isStopped: () => boolean; + mediaMaxMb: number; +}; +type ZaloUpdateProcessingParams = ZaloProcessingContext & { + update: ZaloUpdate; + mediaMaxMb: number; +}; +type ZaloMessagePipelineParams = ZaloProcessingContext & { + message: ZaloMessage; + text?: string; + mediaPath?: string; + mediaType?: string; +}; +type ZaloImageMessageParams = ZaloProcessingContext & { + message: ZaloMessage; + mediaMaxMb: number; +}; function formatZaloError(error: unknown): string { if (error instanceof Error) { @@ -135,32 +164,21 @@ export async function handleZaloWebhookRequest( res: ServerResponse, ): Promise { return handleZaloWebhookRequestInternal(req, res, async ({ update, target }) => { - await processUpdate( + await processUpdate({ update, - target.token, - target.account, - target.config, - target.runtime, - target.core as ZaloCoreRuntime, - target.mediaMaxMb, - target.statusSink, - target.fetcher, - ); + token: target.token, + account: target.account, + config: target.config, + runtime: target.runtime, + core: target.core as ZaloCoreRuntime, + mediaMaxMb: target.mediaMaxMb, + statusSink: target.statusSink, + fetcher: target.fetcher, + }); }); } -function startPollingLoop(params: { - token: string; - account: ResolvedZaloAccount; - config: OpenClawConfig; - runtime: ZaloRuntimeEnv; - core: ZaloCoreRuntime; - abortSignal: AbortSignal; - isStopped: () => boolean; - mediaMaxMb: number; - statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void; - fetcher?: ZaloFetch; -}) { +function startPollingLoop(params: ZaloPollingLoopParams) { const { token, account, @@ -174,6 +192,16 @@ function startPollingLoop(params: { fetcher, } = params; const pollTimeout = 30; + const processingContext = { + token, + account, + config, + runtime, + core, + mediaMaxMb, + statusSink, + fetcher, + }; runtime.log?.(`[${account.accountId}] Zalo polling loop started timeout=${String(pollTimeout)}s`); @@ -186,17 +214,10 @@ function startPollingLoop(params: { const response = await getUpdates(token, { timeout: pollTimeout }, fetcher); if (response.ok && response.result) { statusSink?.({ lastInboundAt: Date.now() }); - await processUpdate( - response.result, - token, - account, - config, - runtime, - core, - mediaMaxMb, - statusSink, - fetcher, - ); + await processUpdate({ + update: response.result, + ...processingContext, + }); } } catch (err) { if (err instanceof ZaloApiError && err.isPollingTimeout) { @@ -215,38 +236,27 @@ function startPollingLoop(params: { void poll(); } -async function processUpdate( - update: ZaloUpdate, - token: string, - account: ResolvedZaloAccount, - config: OpenClawConfig, - runtime: ZaloRuntimeEnv, - core: ZaloCoreRuntime, - mediaMaxMb: number, - statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void, - fetcher?: ZaloFetch, -): Promise { +async function processUpdate(params: ZaloUpdateProcessingParams): Promise { + const { update, token, account, config, runtime, core, mediaMaxMb, statusSink, fetcher } = params; const { event_name, message } = update; + const sharedContext = { token, account, config, runtime, core, statusSink, fetcher }; if (!message) { return; } switch (event_name) { case "message.text.received": - await handleTextMessage(message, token, account, config, runtime, core, statusSink, fetcher); + await handleTextMessage({ + message, + ...sharedContext, + }); break; case "message.image.received": - await handleImageMessage( + await handleImageMessage({ message, - token, - account, - config, - runtime, - core, + ...sharedContext, mediaMaxMb, - statusSink, - fetcher, - ); + }); break; case "message.sticker.received": logVerbose(core, runtime, `[${account.accountId}] Received sticker from ${message.from.id}`); @@ -262,46 +272,24 @@ async function processUpdate( } async function handleTextMessage( - message: ZaloMessage, - token: string, - account: ResolvedZaloAccount, - config: OpenClawConfig, - runtime: ZaloRuntimeEnv, - core: ZaloCoreRuntime, - statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void, - fetcher?: ZaloFetch, + params: ZaloProcessingContext & { message: ZaloMessage }, ): Promise { + const { message } = params; const { text } = message; if (!text?.trim()) { return; } await processMessageWithPipeline({ - message, - token, - account, - config, - runtime, - core, + ...params, text, mediaPath: undefined, mediaType: undefined, - statusSink, - fetcher, }); } -async function handleImageMessage( - message: ZaloMessage, - token: string, - account: ResolvedZaloAccount, - config: OpenClawConfig, - runtime: ZaloRuntimeEnv, - core: ZaloCoreRuntime, - mediaMaxMb: number, - statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void, - fetcher?: ZaloFetch, -): Promise { +async function handleImageMessage(params: ZaloImageMessageParams): Promise { + const { message, mediaMaxMb } = params; const { photo, caption } = message; let mediaPath: string | undefined; @@ -325,33 +313,14 @@ async function handleImageMessage( } await processMessageWithPipeline({ - message, - token, - account, - config, - runtime, - core, + ...params, text: caption, mediaPath, mediaType, - statusSink, - fetcher, }); } -async function processMessageWithPipeline(params: { - message: ZaloMessage; - token: string; - account: ResolvedZaloAccount; - config: OpenClawConfig; - runtime: ZaloRuntimeEnv; - core: ZaloCoreRuntime; - text?: string; - mediaPath?: string; - mediaType?: string; - statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void; - fetcher?: ZaloFetch; -}): Promise { +async function processMessageWithPipeline(params: ZaloMessagePipelineParams): Promise { const { message, token, @@ -609,7 +578,7 @@ async function deliverZaloReply(params: { core: ZaloCoreRuntime; config: OpenClawConfig; accountId?: string; - statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void; + statusSink?: ZaloStatusSink; fetcher?: ZaloFetch; tableMode?: MarkdownTableMode; }): Promise { From f0d0ad39c4196932bdf3f55c659f677e9e1c0760 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:46:06 +0000 Subject: [PATCH 079/461] test: dedupe nostr profile http assertions --- .../nostr/src/nostr-profile-http.test.ts | 65 +++++++++++-------- 1 file changed, 37 insertions(+), 28 deletions(-) diff --git a/extensions/nostr/src/nostr-profile-http.test.ts b/extensions/nostr/src/nostr-profile-http.test.ts index 8fb17c443f4..745ba8baed5 100644 --- a/extensions/nostr/src/nostr-profile-http.test.ts +++ b/extensions/nostr/src/nostr-profile-http.test.ts @@ -208,6 +208,29 @@ describe("nostr-profile-http", () => { }); describe("PUT /api/channels/nostr/:accountId/profile", () => { + function mockPublishSuccess() { + vi.mocked(publishNostrProfile).mockResolvedValue({ + eventId: "event123", + createdAt: 1234567890, + successes: ["wss://relay.damus.io"], + failures: [], + }); + } + + function expectOkResponse(res: ReturnType) { + expect(res._getStatusCode()).toBe(200); + const data = JSON.parse(res._getData()); + expect(data.ok).toBe(true); + return data; + } + + function expectBadRequestResponse(res: ReturnType) { + expect(res._getStatusCode()).toBe(400); + const data = JSON.parse(res._getData()); + expect(data.ok).toBe(false); + return data; + } + async function expectPrivatePictureRejected(pictureUrl: string) { const ctx = createMockContext(); const handler = createNostrProfileHttpHandler(ctx); @@ -219,9 +242,7 @@ describe("nostr-profile-http", () => { await handler(req, res); - expect(res._getStatusCode()).toBe(400); - const data = JSON.parse(res._getData()); - expect(data.ok).toBe(false); + const data = expectBadRequestResponse(res); expect(data.error).toContain("private"); } @@ -235,18 +256,11 @@ describe("nostr-profile-http", () => { }); const res = createMockResponse(); - vi.mocked(publishNostrProfile).mockResolvedValue({ - eventId: "event123", - createdAt: 1234567890, - successes: ["wss://relay.damus.io"], - failures: [], - }); + mockPublishSuccess(); await handler(req, res); - expect(res._getStatusCode()).toBe(200); - const data = JSON.parse(res._getData()); - expect(data.ok).toBe(true); + const data = expectOkResponse(res); expect(data.eventId).toBe("event123"); expect(data.successes).toContain("wss://relay.damus.io"); expect(data.persisted).toBe(true); @@ -332,9 +346,7 @@ describe("nostr-profile-http", () => { await handler(req, res); - expect(res._getStatusCode()).toBe(400); - const data = JSON.parse(res._getData()); - expect(data.ok).toBe(false); + const data = expectBadRequestResponse(res); // The schema validation catches non-https URLs before SSRF check expect(data.error).toBe("Validation failed"); expect(data.details).toBeDefined(); @@ -368,12 +380,7 @@ describe("nostr-profile-http", () => { const ctx = createMockContext(); const handler = createNostrProfileHttpHandler(ctx); - vi.mocked(publishNostrProfile).mockResolvedValue({ - eventId: "event123", - createdAt: 1234567890, - successes: ["wss://relay.damus.io"], - failures: [], - }); + mockPublishSuccess(); // Make 6 requests (limit is 5/min) for (let i = 0; i < 6; i++) { @@ -384,7 +391,7 @@ describe("nostr-profile-http", () => { await handler(req, res); if (i < 5) { - expect(res._getStatusCode()).toBe(200); + expectOkResponse(res); } else { expect(res._getStatusCode()).toBe(429); const data = JSON.parse(res._getData()); @@ -414,6 +421,12 @@ describe("nostr-profile-http", () => { }); describe("POST /api/channels/nostr/:accountId/profile/import", () => { + function expectImportSuccessResponse(res: ReturnType) { + const data = expectOkResponse(res); + expect(data.imported.name).toBe("imported"); + return data; + } + it("imports profile from relays", async () => { const ctx = createMockContext(); const handler = createNostrProfileHttpHandler(ctx); @@ -424,10 +437,7 @@ describe("nostr-profile-http", () => { await handler(req, res); - expect(res._getStatusCode()).toBe(200); - const data = JSON.parse(res._getData()); - expect(data.ok).toBe(true); - expect(data.imported.name).toBe("imported"); + const data = expectImportSuccessResponse(res); expect(data.saved).toBe(false); // autoMerge not requested }); @@ -490,8 +500,7 @@ describe("nostr-profile-http", () => { await handler(req, res); - expect(res._getStatusCode()).toBe(200); - const data = JSON.parse(res._getData()); + const data = expectImportSuccessResponse(res); expect(data.saved).toBe(true); expect(ctx.updateConfigProfile).toHaveBeenCalled(); }); From 168394980fe6c8457106c21721864ae166952c18 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:47:08 +0000 Subject: [PATCH 080/461] refactor: share slack allowlist target mapping --- extensions/slack/src/channel.ts | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index bd2b640c510..73c844a1cc0 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -244,6 +244,18 @@ export const slackPlugin: ChannelPlugin = { }, resolver: { resolveTargets: async ({ cfg, accountId, inputs, kind }) => { + const toResolvedTarget = < + T extends { input: string; resolved: boolean; id?: string; name?: string }, + >( + entry: T, + note?: string, + ) => ({ + input: entry.input, + resolved: entry.resolved, + id: entry.id, + name: entry.name, + note, + }); const account = resolveSlackAccount({ cfg, accountId }); const token = account.config.userToken?.trim() || account.botToken?.trim(); if (!token) { @@ -258,25 +270,15 @@ export const slackPlugin: ChannelPlugin = { token, entries: inputs, }); - return resolved.map((entry) => ({ - input: entry.input, - resolved: entry.resolved, - id: entry.id, - name: entry.name, - note: entry.archived ? "archived" : undefined, - })); + return resolved.map((entry) => + toResolvedTarget(entry, entry.archived ? "archived" : undefined), + ); } const resolved = await getSlackRuntime().channel.slack.resolveUserAllowlist({ token, entries: inputs, }); - return resolved.map((entry) => ({ - input: entry.input, - resolved: entry.resolved, - id: entry.id, - name: entry.name, - note: entry.note, - })); + return resolved.map((entry) => toResolvedTarget(entry, entry.note)); }, }, actions: { From 8896a477dfab0b27a8cdad9391d8ec15a8ee870c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:47:45 +0000 Subject: [PATCH 081/461] test: dedupe bluebubbles local media send cases --- extensions/bluebubbles/src/media-send.test.ts | 142 ++++++++++++------ 1 file changed, 94 insertions(+), 48 deletions(-) diff --git a/extensions/bluebubbles/src/media-send.test.ts b/extensions/bluebubbles/src/media-send.test.ts index 9f065599bfb..59fe82cbeae 100644 --- a/extensions/bluebubbles/src/media-send.test.ts +++ b/extensions/bluebubbles/src/media-send.test.ts @@ -70,6 +70,70 @@ async function makeTempDir(): Promise { return dir; } +async function makeTempFile( + fileName: string, + contents: string, + dir?: string, +): Promise<{ dir: string; filePath: string }> { + const resolvedDir = dir ?? (await makeTempDir()); + const filePath = path.join(resolvedDir, fileName); + await fs.writeFile(filePath, contents, "utf8"); + return { dir: resolvedDir, filePath }; +} + +async function sendLocalMedia(params: { + cfg: OpenClawConfig; + mediaPath: string; + accountId?: string; +}) { + return sendBlueBubblesMedia({ + cfg: params.cfg, + to: "chat:123", + accountId: params.accountId, + mediaPath: params.mediaPath, + }); +} + +async function expectRejectedLocalMedia(params: { + cfg: OpenClawConfig; + mediaPath: string; + error: RegExp; + accountId?: string; +}) { + await expect( + sendLocalMedia({ + cfg: params.cfg, + mediaPath: params.mediaPath, + accountId: params.accountId, + }), + ).rejects.toThrow(params.error); + + expect(sendBlueBubblesAttachmentMock).not.toHaveBeenCalled(); +} + +async function expectAllowedLocalMedia(params: { + cfg: OpenClawConfig; + mediaPath: string; + expectedAttachment: Record; + accountId?: string; + expectMimeDetection?: boolean; +}) { + const result = await sendLocalMedia({ + cfg: params.cfg, + mediaPath: params.mediaPath, + accountId: params.accountId, + }); + + expect(result).toEqual({ messageId: "msg-1" }); + expect(sendBlueBubblesAttachmentMock).toHaveBeenCalledTimes(1); + expect(sendBlueBubblesAttachmentMock.mock.calls[0]?.[0]).toEqual( + expect.objectContaining(params.expectedAttachment), + ); + if (params.expectMimeDetection) { + expect(runtimeMocks.detectMime).toHaveBeenCalled(); + } +} + beforeEach(() => { const runtime = createMockRuntime(); runtimeMocks = runtime.mocks; @@ -110,57 +174,43 @@ describe("sendBlueBubblesMedia local-path hardening", () => { const outsideFile = path.join(outsideDir, "outside.txt"); await fs.writeFile(outsideFile, "not allowed", "utf8"); - await expect( - sendBlueBubblesMedia({ - cfg: createConfig({ mediaLocalRoots: [allowedRoot] }), - to: "chat:123", - mediaPath: outsideFile, - }), - ).rejects.toThrow(/not under any configured mediaLocalRoots/i); - - expect(sendBlueBubblesAttachmentMock).not.toHaveBeenCalled(); + await expectRejectedLocalMedia({ + cfg: createConfig({ mediaLocalRoots: [allowedRoot] }), + mediaPath: outsideFile, + error: /not under any configured mediaLocalRoots/i, + }); }); it("allows local paths that are explicitly configured", async () => { - const allowedRoot = await makeTempDir(); - const allowedFile = path.join(allowedRoot, "allowed.txt"); - await fs.writeFile(allowedFile, "allowed", "utf8"); + const { dir: allowedRoot, filePath: allowedFile } = await makeTempFile( + "allowed.txt", + "allowed", + ); - const result = await sendBlueBubblesMedia({ + await expectAllowedLocalMedia({ cfg: createConfig({ mediaLocalRoots: [allowedRoot] }), - to: "chat:123", mediaPath: allowedFile, - }); - - expect(result).toEqual({ messageId: "msg-1" }); - expect(sendBlueBubblesAttachmentMock).toHaveBeenCalledTimes(1); - expect(sendBlueBubblesAttachmentMock.mock.calls[0]?.[0]).toEqual( - expect.objectContaining({ + expectedAttachment: { filename: "allowed.txt", contentType: "text/plain", - }), - ); - expect(runtimeMocks.detectMime).toHaveBeenCalled(); + }, + expectMimeDetection: true, + }); }); it("allows file:// media paths and file:// local roots", async () => { - const allowedRoot = await makeTempDir(); - const allowedFile = path.join(allowedRoot, "allowed.txt"); - await fs.writeFile(allowedFile, "allowed", "utf8"); - - const result = await sendBlueBubblesMedia({ - cfg: createConfig({ mediaLocalRoots: [pathToFileURL(allowedRoot).toString()] }), - to: "chat:123", - mediaPath: pathToFileURL(allowedFile).toString(), - }); - - expect(result).toEqual({ messageId: "msg-1" }); - expect(sendBlueBubblesAttachmentMock).toHaveBeenCalledTimes(1); - expect(sendBlueBubblesAttachmentMock.mock.calls[0]?.[0]).toEqual( - expect.objectContaining({ - filename: "allowed.txt", - }), + const { dir: allowedRoot, filePath: allowedFile } = await makeTempFile( + "allowed.txt", + "allowed", ); + + await expectAllowedLocalMedia({ + cfg: createConfig({ mediaLocalRoots: [pathToFileURL(allowedRoot).toString()] }), + mediaPath: pathToFileURL(allowedFile).toString(), + expectedAttachment: { + filename: "allowed.txt", + }, + }); }); it("uses account-specific mediaLocalRoots over top-level roots", async () => { @@ -213,15 +263,11 @@ describe("sendBlueBubblesMedia local-path hardening", () => { return; } - await expect( - sendBlueBubblesMedia({ - cfg: createConfig({ mediaLocalRoots: [allowedRoot] }), - to: "chat:123", - mediaPath: linkPath, - }), - ).rejects.toThrow(/not under any configured mediaLocalRoots/i); - - expect(sendBlueBubblesAttachmentMock).not.toHaveBeenCalled(); + await expectRejectedLocalMedia({ + cfg: createConfig({ mediaLocalRoots: [allowedRoot] }), + mediaPath: linkPath, + error: /not under any configured mediaLocalRoots/i, + }); }); it("rejects relative mediaLocalRoots entries", async () => { From d964c1504065bfcc330e721b8667304662c9eed2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:48:24 +0000 Subject: [PATCH 082/461] test: dedupe synology webhook request helpers --- .../synology-chat/src/webhook-handler.test.ts | 47 +++++++------------ 1 file changed, 18 insertions(+), 29 deletions(-) diff --git a/extensions/synology-chat/src/webhook-handler.test.ts b/extensions/synology-chat/src/webhook-handler.test.ts index 37ee566e6a6..a0b67d49aad 100644 --- a/extensions/synology-chat/src/webhook-handler.test.ts +++ b/extensions/synology-chat/src/webhook-handler.test.ts @@ -37,21 +37,7 @@ function makeReq( body: string, opts: { headers?: Record; url?: string } = {}, ): IncomingMessage { - const req = new EventEmitter() as IncomingMessage & { - destroyed: boolean; - }; - req.method = method; - req.headers = opts.headers ?? {}; - req.url = opts.url ?? "/webhook/synology"; - req.socket = { remoteAddress: "127.0.0.1" } as any; - req.destroyed = false; - req.destroy = ((_: Error | undefined) => { - if (req.destroyed) { - return req; - } - req.destroyed = true; - return req; - }) as IncomingMessage["destroy"]; + const req = makeBaseReq(method, opts); // Simulate body delivery process.nextTick(() => { @@ -65,11 +51,19 @@ function makeReq( return req; } function makeStalledReq(method: string): IncomingMessage { + return makeBaseReq(method); +} + +function makeBaseReq( + method: string, + opts: { headers?: Record; url?: string } = {}, +): IncomingMessage & { destroyed: boolean } { const req = new EventEmitter() as IncomingMessage & { destroyed: boolean; }; req.method = method; - req.headers = {}; + req.headers = opts.headers ?? {}; + req.url = opts.url ?? "/webhook/synology"; req.socket = { remoteAddress: "127.0.0.1" } as any; req.destroyed = false; req.destroy = ((_: Error | undefined) => { @@ -124,10 +118,12 @@ describe("createWebhookHandler", () => { async function expectForbiddenByPolicy(params: { account: Partial; bodyContains: string; + deliver?: ReturnType; }) { + const deliver = params.deliver ?? vi.fn(); const handler = createWebhookHandler({ account: makeAccount(params.account), - deliver: vi.fn(), + deliver, log, }); @@ -137,6 +133,7 @@ describe("createWebhookHandler", () => { expect(res._status).toBe(403); expect(res._body).toContain(params.bodyContains); + expect(deliver).not.toHaveBeenCalled(); } it("rejects non-POST methods with 405", async () => { @@ -302,22 +299,14 @@ describe("createWebhookHandler", () => { it("returns 403 when allowlist policy is set with empty allowedUserIds", async () => { const deliver = vi.fn(); - const handler = createWebhookHandler({ - account: makeAccount({ + await expectForbiddenByPolicy({ + account: { dmPolicy: "allowlist", allowedUserIds: [], - }), + }, + bodyContains: "Allowlist is empty", deliver, - log, }); - - const req = makeReq("POST", validBody); - const res = makeRes(); - await handler(req, res); - - expect(res._status).toBe(403); - expect(res._body).toContain("Allowlist is empty"); - expect(deliver).not.toHaveBeenCalled(); }); it("returns 403 when DMs are disabled", async () => { From 5b51d92f3e825b94ec02376e02bec28d34508fd1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:52:03 +0000 Subject: [PATCH 083/461] test: dedupe synology channel account fixtures --- extensions/synology-chat/src/channel.test.ts | 139 +++++++------------ 1 file changed, 47 insertions(+), 92 deletions(-) diff --git a/extensions/synology-chat/src/channel.test.ts b/extensions/synology-chat/src/channel.test.ts index 4e3be192f39..2814d437c6b 100644 --- a/extensions/synology-chat/src/channel.test.ts +++ b/extensions/synology-chat/src/channel.test.ts @@ -46,6 +46,23 @@ vi.mock("zod", () => ({ const { createSynologyChatPlugin } = await import("./channel.js"); const { registerPluginHttpRoute } = await import("openclaw/plugin-sdk/synology-chat"); +function makeSecurityAccount(overrides: Record = {}) { + return { + accountId: "default", + enabled: true, + token: "t", + incomingUrl: "https://nas/incoming", + nasHost: "h", + webhookPath: "/w", + dmPolicy: "allowlist" as const, + allowedUserIds: [], + rateLimitPerMinute: 30, + botName: "Bot", + allowInsecureSsl: false, + ...overrides, + }; +} + describe("createSynologyChatPlugin", () => { it("returns a plugin object with all required sections", () => { const plugin = createSynologyChatPlugin(); @@ -133,95 +150,35 @@ describe("createSynologyChatPlugin", () => { describe("security.collectWarnings", () => { it("warns when token is missing", () => { const plugin = createSynologyChatPlugin(); - const account = { - accountId: "default", - enabled: true, - token: "", - incomingUrl: "https://nas/incoming", - nasHost: "h", - webhookPath: "/w", - dmPolicy: "allowlist" as const, - allowedUserIds: [], - rateLimitPerMinute: 30, - botName: "Bot", - allowInsecureSsl: false, - }; + const account = makeSecurityAccount({ token: "" }); const warnings = plugin.security.collectWarnings({ account }); expect(warnings.some((w: string) => w.includes("token"))).toBe(true); }); it("warns when allowInsecureSsl is true", () => { const plugin = createSynologyChatPlugin(); - const account = { - accountId: "default", - enabled: true, - token: "t", - incomingUrl: "https://nas/incoming", - nasHost: "h", - webhookPath: "/w", - dmPolicy: "allowlist" as const, - allowedUserIds: [], - rateLimitPerMinute: 30, - botName: "Bot", - allowInsecureSsl: true, - }; + const account = makeSecurityAccount({ allowInsecureSsl: true }); const warnings = plugin.security.collectWarnings({ account }); expect(warnings.some((w: string) => w.includes("SSL"))).toBe(true); }); it("warns when dmPolicy is open", () => { const plugin = createSynologyChatPlugin(); - const account = { - accountId: "default", - enabled: true, - token: "t", - incomingUrl: "https://nas/incoming", - nasHost: "h", - webhookPath: "/w", - dmPolicy: "open" as const, - allowedUserIds: [], - rateLimitPerMinute: 30, - botName: "Bot", - allowInsecureSsl: false, - }; + const account = makeSecurityAccount({ dmPolicy: "open" }); const warnings = plugin.security.collectWarnings({ account }); expect(warnings.some((w: string) => w.includes("open"))).toBe(true); }); it("warns when dmPolicy is allowlist and allowedUserIds is empty", () => { const plugin = createSynologyChatPlugin(); - const account = { - accountId: "default", - enabled: true, - token: "t", - incomingUrl: "https://nas/incoming", - nasHost: "h", - webhookPath: "/w", - dmPolicy: "allowlist" as const, - allowedUserIds: [], - rateLimitPerMinute: 30, - botName: "Bot", - allowInsecureSsl: false, - }; + const account = makeSecurityAccount(); const warnings = plugin.security.collectWarnings({ account }); expect(warnings.some((w: string) => w.includes("empty allowedUserIds"))).toBe(true); }); it("returns no warnings for fully configured account", () => { const plugin = createSynologyChatPlugin(); - const account = { - accountId: "default", - enabled: true, - token: "t", - incomingUrl: "https://nas/incoming", - nasHost: "h", - webhookPath: "/w", - dmPolicy: "allowlist" as const, - allowedUserIds: ["user1"], - rateLimitPerMinute: 30, - botName: "Bot", - allowInsecureSsl: false, - }; + const account = makeSecurityAccount({ allowedUserIds: ["user1"] }); const warnings = plugin.security.collectWarnings({ account }); expect(warnings).toHaveLength(0); }); @@ -317,6 +274,23 @@ describe("createSynologyChatPlugin", () => { }); describe("gateway", () => { + function makeStartAccountCtx( + accountConfig: Record, + abortController = new AbortController(), + ) { + return { + abortController, + ctx: { + cfg: { + channels: { "synology-chat": accountConfig }, + }, + accountId: "default", + log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + abortSignal: abortController.signal, + }, + }; + } + async function expectPendingStartAccountPromise( result: Promise, abortController: AbortController, @@ -333,15 +307,7 @@ describe("createSynologyChatPlugin", () => { async function expectPendingStartAccount(accountConfig: Record) { const plugin = createSynologyChatPlugin(); - const abortController = new AbortController(); - const ctx = { - cfg: { - channels: { "synology-chat": accountConfig }, - }, - accountId: "default", - log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, - abortSignal: abortController.signal, - }; + const { ctx, abortController } = makeStartAccountCtx(accountConfig); const result = plugin.gateway.startAccount(ctx); await expectPendingStartAccountPromise(result, abortController); } @@ -357,25 +323,14 @@ describe("createSynologyChatPlugin", () => { it("startAccount refuses allowlist accounts with empty allowedUserIds", async () => { const registerMock = vi.mocked(registerPluginHttpRoute); registerMock.mockClear(); - const abortController = new AbortController(); - const plugin = createSynologyChatPlugin(); - const ctx = { - cfg: { - channels: { - "synology-chat": { - enabled: true, - token: "t", - incomingUrl: "https://nas/incoming", - dmPolicy: "allowlist", - allowedUserIds: [], - }, - }, - }, - accountId: "default", - log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, - abortSignal: abortController.signal, - }; + const { ctx, abortController } = makeStartAccountCtx({ + enabled: true, + token: "t", + incomingUrl: "https://nas/incoming", + dmPolicy: "allowlist", + allowedUserIds: [], + }); const result = plugin.gateway.startAccount(ctx); await expectPendingStartAccountPromise(result, abortController); From b23bfef8ccce8bf378c38fb05fcd9c7584da653e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:52:13 +0000 Subject: [PATCH 084/461] test: dedupe feishu probe fixtures --- extensions/feishu/src/probe.test.ts | 225 ++++++++++++++-------------- 1 file changed, 112 insertions(+), 113 deletions(-) diff --git a/extensions/feishu/src/probe.test.ts b/extensions/feishu/src/probe.test.ts index b93935cccc6..328c83f658a 100644 --- a/extensions/feishu/src/probe.test.ts +++ b/extensions/feishu/src/probe.test.ts @@ -8,6 +8,22 @@ vi.mock("./client.js", () => ({ import { FEISHU_PROBE_REQUEST_TIMEOUT_MS, probeFeishu, clearProbeCache } from "./probe.js"; +const DEFAULT_CREDS = { appId: "cli_123", appSecret: "secret" } as const; // pragma: allowlist secret +const DEFAULT_SUCCESS_RESPONSE = { + code: 0, + bot: { bot_name: "TestBot", open_id: "ou_abc123" }, +} as const; +const DEFAULT_SUCCESS_RESULT = { + ok: true, + appId: "cli_123", + botName: "TestBot", + botOpenId: "ou_abc123", +} as const; +const BOT1_RESPONSE = { + code: 0, + bot: { bot_name: "Bot1", open_id: "ou_1" }, +} as const; + function makeRequestFn(response: Record) { return vi.fn().mockResolvedValue(response); } @@ -18,6 +34,64 @@ function setupClient(response: Record) { return requestFn; } +function setupSuccessClient() { + return setupClient(DEFAULT_SUCCESS_RESPONSE); +} + +async function expectDefaultSuccessResult( + creds = DEFAULT_CREDS, + expected = DEFAULT_SUCCESS_RESULT, +) { + const result = await probeFeishu(creds); + expect(result).toEqual(expected); +} + +async function withFakeTimers(run: () => Promise) { + vi.useFakeTimers(); + try { + await run(); + } finally { + vi.useRealTimers(); + } +} + +async function expectErrorResultCached(params: { + requestFn: ReturnType; + expectedError: string; + ttlMs: number; +}) { + createFeishuClientMock.mockReturnValue({ request: params.requestFn }); + + const first = await probeFeishu(DEFAULT_CREDS); + const second = await probeFeishu(DEFAULT_CREDS); + expect(first).toMatchObject({ ok: false, error: params.expectedError }); + expect(second).toMatchObject({ ok: false, error: params.expectedError }); + expect(params.requestFn).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(params.ttlMs + 1); + + await probeFeishu(DEFAULT_CREDS); + expect(params.requestFn).toHaveBeenCalledTimes(2); +} + +async function expectFreshDefaultProbeAfter( + requestFn: ReturnType, + invalidate: () => void, +) { + await probeFeishu(DEFAULT_CREDS); + expect(requestFn).toHaveBeenCalledTimes(1); + + invalidate(); + + await probeFeishu(DEFAULT_CREDS); + expect(requestFn).toHaveBeenCalledTimes(2); +} + +async function readSequentialDefaultProbePair() { + const first = await probeFeishu(DEFAULT_CREDS); + return { first, second: await probeFeishu(DEFAULT_CREDS) }; +} + describe("probeFeishu", () => { beforeEach(() => { clearProbeCache(); @@ -44,28 +118,16 @@ describe("probeFeishu", () => { }); it("returns bot info on successful probe", async () => { - const requestFn = setupClient({ - code: 0, - bot: { bot_name: "TestBot", open_id: "ou_abc123" }, - }); + const requestFn = setupSuccessClient(); - const result = await probeFeishu({ appId: "cli_123", appSecret: "secret" }); // pragma: allowlist secret - expect(result).toEqual({ - ok: true, - appId: "cli_123", - botName: "TestBot", - botOpenId: "ou_abc123", - }); + await expectDefaultSuccessResult(); expect(requestFn).toHaveBeenCalledTimes(1); }); it("passes the probe timeout to the Feishu request", async () => { - const requestFn = setupClient({ - code: 0, - bot: { bot_name: "TestBot", open_id: "ou_abc123" }, - }); + const requestFn = setupSuccessClient(); - await probeFeishu({ appId: "cli_123", appSecret: "secret" }); // pragma: allowlist secret + await probeFeishu(DEFAULT_CREDS); expect(requestFn).toHaveBeenCalledWith( expect.objectContaining({ @@ -77,19 +139,16 @@ describe("probeFeishu", () => { }); it("returns timeout error when request exceeds timeout", async () => { - vi.useFakeTimers(); - try { + await withFakeTimers(async () => { const requestFn = vi.fn().mockImplementation(() => new Promise(() => {})); createFeishuClientMock.mockReturnValue({ request: requestFn }); - const promise = probeFeishu({ appId: "cli_123", appSecret: "secret" }, { timeoutMs: 1_000 }); + const promise = probeFeishu(DEFAULT_CREDS, { timeoutMs: 1_000 }); await vi.advanceTimersByTimeAsync(1_000); const result = await promise; expect(result).toMatchObject({ ok: false, error: "probe timed out after 1000ms" }); - } finally { - vi.useRealTimers(); - } + }); }); it("returns aborted when abort signal is already aborted", async () => { @@ -106,14 +165,9 @@ describe("probeFeishu", () => { expect(createFeishuClientMock).not.toHaveBeenCalled(); }); it("returns cached result on subsequent calls within TTL", async () => { - const requestFn = setupClient({ - code: 0, - bot: { bot_name: "TestBot", open_id: "ou_abc123" }, - }); + const requestFn = setupSuccessClient(); - const creds = { appId: "cli_123", appSecret: "secret" }; // pragma: allowlist secret - const first = await probeFeishu(creds); - const second = await probeFeishu(creds); + const { first, second } = await readSequentialDefaultProbePair(); expect(first).toEqual(second); // Only one API call should have been made @@ -121,76 +175,37 @@ describe("probeFeishu", () => { }); it("makes a fresh API call after cache expires", async () => { - vi.useFakeTimers(); - try { - const requestFn = setupClient({ - code: 0, - bot: { bot_name: "TestBot", open_id: "ou_abc123" }, + await withFakeTimers(async () => { + const requestFn = setupSuccessClient(); + + await expectFreshDefaultProbeAfter(requestFn, () => { + vi.advanceTimersByTime(10 * 60 * 1000 + 1); }); - - const creds = { appId: "cli_123", appSecret: "secret" }; // pragma: allowlist secret - await probeFeishu(creds); - expect(requestFn).toHaveBeenCalledTimes(1); - - // Advance time past the success TTL - vi.advanceTimersByTime(10 * 60 * 1000 + 1); - - await probeFeishu(creds); - expect(requestFn).toHaveBeenCalledTimes(2); - } finally { - vi.useRealTimers(); - } + }); }); it("caches failed probe results (API error) for the error TTL", async () => { - vi.useFakeTimers(); - try { - const requestFn = makeRequestFn({ code: 99, msg: "token expired" }); - createFeishuClientMock.mockReturnValue({ request: requestFn }); - - const creds = { appId: "cli_123", appSecret: "secret" }; // pragma: allowlist secret - const first = await probeFeishu(creds); - const second = await probeFeishu(creds); - expect(first).toMatchObject({ ok: false, error: "API error: token expired" }); - expect(second).toMatchObject({ ok: false, error: "API error: token expired" }); - expect(requestFn).toHaveBeenCalledTimes(1); - - vi.advanceTimersByTime(60 * 1000 + 1); - - await probeFeishu(creds); - expect(requestFn).toHaveBeenCalledTimes(2); - } finally { - vi.useRealTimers(); - } + await withFakeTimers(async () => { + await expectErrorResultCached({ + requestFn: makeRequestFn({ code: 99, msg: "token expired" }), + expectedError: "API error: token expired", + ttlMs: 60 * 1000, + }); + }); }); it("caches thrown request errors for the error TTL", async () => { - vi.useFakeTimers(); - try { - const requestFn = vi.fn().mockRejectedValue(new Error("network error")); - createFeishuClientMock.mockReturnValue({ request: requestFn }); - - const creds = { appId: "cli_123", appSecret: "secret" }; // pragma: allowlist secret - const first = await probeFeishu(creds); - const second = await probeFeishu(creds); - expect(first).toMatchObject({ ok: false, error: "network error" }); - expect(second).toMatchObject({ ok: false, error: "network error" }); - expect(requestFn).toHaveBeenCalledTimes(1); - - vi.advanceTimersByTime(60 * 1000 + 1); - - await probeFeishu(creds); - expect(requestFn).toHaveBeenCalledTimes(2); - } finally { - vi.useRealTimers(); - } + await withFakeTimers(async () => { + await expectErrorResultCached({ + requestFn: vi.fn().mockRejectedValue(new Error("network error")), + expectedError: "network error", + ttlMs: 60 * 1000, + }); + }); }); it("caches per account independently", async () => { - const requestFn = setupClient({ - code: 0, - bot: { bot_name: "Bot1", open_id: "ou_1" }, - }); + const requestFn = setupClient(BOT1_RESPONSE); await probeFeishu({ appId: "cli_aaa", appSecret: "s1" }); // pragma: allowlist secret expect(requestFn).toHaveBeenCalledTimes(1); @@ -205,10 +220,7 @@ describe("probeFeishu", () => { }); it("does not share cache between accounts with same appId but different appSecret", async () => { - const requestFn = setupClient({ - code: 0, - bot: { bot_name: "Bot1", open_id: "ou_1" }, - }); + const requestFn = setupClient(BOT1_RESPONSE); // First account with appId + secret A await probeFeishu({ appId: "cli_shared", appSecret: "secret_aaa" }); // pragma: allowlist secret @@ -221,10 +233,7 @@ describe("probeFeishu", () => { }); it("uses accountId for cache key when available", async () => { - const requestFn = setupClient({ - code: 0, - bot: { bot_name: "Bot1", open_id: "ou_1" }, - }); + const requestFn = setupClient(BOT1_RESPONSE); // Two accounts with same appId+appSecret but different accountIds are cached separately await probeFeishu({ accountId: "acct-1", appId: "cli_123", appSecret: "secret" }); // pragma: allowlist secret @@ -239,19 +248,11 @@ describe("probeFeishu", () => { }); it("clearProbeCache forces fresh API call", async () => { - const requestFn = setupClient({ - code: 0, - bot: { bot_name: "TestBot", open_id: "ou_abc123" }, + const requestFn = setupSuccessClient(); + + await expectFreshDefaultProbeAfter(requestFn, () => { + clearProbeCache(); }); - - const creds = { appId: "cli_123", appSecret: "secret" }; // pragma: allowlist secret - await probeFeishu(creds); - expect(requestFn).toHaveBeenCalledTimes(1); - - clearProbeCache(); - - await probeFeishu(creds); - expect(requestFn).toHaveBeenCalledTimes(2); }); it("handles response.data.bot fallback path", async () => { @@ -260,10 +261,8 @@ describe("probeFeishu", () => { data: { bot: { bot_name: "DataBot", open_id: "ou_data" } }, }); - const result = await probeFeishu({ appId: "cli_123", appSecret: "secret" }); // pragma: allowlist secret - expect(result).toEqual({ - ok: true, - appId: "cli_123", + await expectDefaultSuccessResult(DEFAULT_CREDS, { + ...DEFAULT_SUCCESS_RESULT, botName: "DataBot", botOpenId: "ou_data", }); From f2300f4522e0dd4065319865b0db6c95dda69c52 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:53:41 +0000 Subject: [PATCH 085/461] test: dedupe msteams policy route fixtures --- extensions/msteams/src/policy.test.ts | 58 +++++++++++---------------- 1 file changed, 23 insertions(+), 35 deletions(-) diff --git a/extensions/msteams/src/policy.test.ts b/extensions/msteams/src/policy.test.ts index 091e22d1fd8..ac324f3d785 100644 --- a/extensions/msteams/src/policy.test.ts +++ b/extensions/msteams/src/policy.test.ts @@ -6,6 +6,27 @@ import { resolveMSTeamsRouteConfig, } from "./policy.js"; +function resolveNamedTeamRouteConfig(allowNameMatching = false) { + const cfg: MSTeamsConfig = { + teams: { + "My Team": { + requireMention: true, + channels: { + "General Chat": { requireMention: false }, + }, + }, + }, + }; + + return resolveMSTeamsRouteConfig({ + cfg, + teamName: "My Team", + channelName: "General Chat", + conversationId: "ignored", + allowNameMatching, + }); +} + describe("msteams policy", () => { describe("resolveMSTeamsRouteConfig", () => { it("returns team and channel config when present", () => { @@ -51,23 +72,7 @@ describe("msteams policy", () => { }); it("blocks team and channel name matches by default", () => { - const cfg: MSTeamsConfig = { - teams: { - "My Team": { - requireMention: true, - channels: { - "General Chat": { requireMention: false }, - }, - }, - }, - }; - - const res = resolveMSTeamsRouteConfig({ - cfg, - teamName: "My Team", - channelName: "General Chat", - conversationId: "ignored", - }); + const res = resolveNamedTeamRouteConfig(); expect(res.teamConfig).toBeUndefined(); expect(res.channelConfig).toBeUndefined(); @@ -75,24 +80,7 @@ describe("msteams policy", () => { }); it("matches team and channel by name when dangerous name matching is enabled", () => { - const cfg: MSTeamsConfig = { - teams: { - "My Team": { - requireMention: true, - channels: { - "General Chat": { requireMention: false }, - }, - }, - }, - }; - - const res = resolveMSTeamsRouteConfig({ - cfg, - teamName: "My Team", - channelName: "General Chat", - conversationId: "ignored", - allowNameMatching: true, - }); + const res = resolveNamedTeamRouteConfig(true); expect(res.teamConfig?.requireMention).toBe(true); expect(res.channelConfig?.requireMention).toBe(false); From 0530d1c530a47fd00e7a6148158f574e459a4635 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:53:45 +0000 Subject: [PATCH 086/461] test: dedupe twitch access control assertions --- extensions/twitch/src/access-control.test.ts | 51 +++++++++----------- 1 file changed, 23 insertions(+), 28 deletions(-) diff --git a/extensions/twitch/src/access-control.test.ts b/extensions/twitch/src/access-control.test.ts index 874326c9697..1cd2b6345c8 100644 --- a/extensions/twitch/src/access-control.test.ts +++ b/extensions/twitch/src/access-control.test.ts @@ -49,6 +49,21 @@ describe("checkTwitchAccessControl", () => { return result; } + function expectAllowedAccessCheck(params: { + account?: Partial; + message?: Partial; + }) { + const result = runAccessCheck({ + account: params.account, + message: { + message: "@testbot hello", + ...params.message, + }, + }); + expect(result.allowed).toBe(true); + return result; + } + describe("when no restrictions are configured", () => { it("allows messages that mention the bot (default requireMention)", () => { const result = runAccessCheck({ @@ -109,21 +124,11 @@ describe("checkTwitchAccessControl", () => { describe("allowFrom allowlist", () => { it("allows users in the allowlist", () => { - const account: TwitchAccountConfig = { - ...mockAccount, - allowFrom: ["123456", "789012"], - }; - const message: TwitchChatMessage = { - ...mockMessage, - message: "@testbot hello", - }; - - const result = checkTwitchAccessControl({ - message, - account, - botUsername: "testbot", + const result = expectAllowedAccessCheck({ + account: { + allowFrom: ["123456", "789012"], + }, }); - expect(result.allowed).toBe(true); expect(result.matchKey).toBe("123456"); expect(result.matchSource).toBe("allowlist"); }); @@ -283,21 +288,11 @@ describe("checkTwitchAccessControl", () => { }); it("allows all users when role is 'all'", () => { - const account: TwitchAccountConfig = { - ...mockAccount, - allowedRoles: ["all"], - }; - const message: TwitchChatMessage = { - ...mockMessage, - message: "@testbot hello", - }; - - const result = checkTwitchAccessControl({ - message, - account, - botUsername: "testbot", + const result = expectAllowedAccessCheck({ + account: { + allowedRoles: ["all"], + }, }); - expect(result.allowed).toBe(true); expect(result.matchKey).toBe("all"); }); From 110eeec5b8aaae5ece71d5a1ea2cbdaed6b45500 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:54:23 +0000 Subject: [PATCH 087/461] test: dedupe twitch access control checks --- extensions/twitch/src/access-control.test.ts | 92 +++++++------------- 1 file changed, 31 insertions(+), 61 deletions(-) diff --git a/extensions/twitch/src/access-control.test.ts b/extensions/twitch/src/access-control.test.ts index 1cd2b6345c8..3d522246700 100644 --- a/extensions/twitch/src/access-control.test.ts +++ b/extensions/twitch/src/access-control.test.ts @@ -64,6 +64,26 @@ describe("checkTwitchAccessControl", () => { return result; } + function expectAllowFromBlocked(params: { + allowFrom: string[]; + allowedRoles?: NonNullable; + message?: Partial; + reason: string; + }) { + const result = runAccessCheck({ + account: { + allowFrom: params.allowFrom, + allowedRoles: params.allowedRoles, + }, + message: { + message: "@testbot hello", + ...params.message, + }, + }); + expect(result.allowed).toBe(false); + expect(result.reason).toContain(params.reason); + } + describe("when no restrictions are configured", () => { it("allows messages that mention the bot (default requireMention)", () => { const result = runAccessCheck({ @@ -134,42 +154,18 @@ describe("checkTwitchAccessControl", () => { }); it("blocks users not in allowlist when allowFrom is set", () => { - const account: TwitchAccountConfig = { - ...mockAccount, + expectAllowFromBlocked({ allowFrom: ["789012"], - }; - const message: TwitchChatMessage = { - ...mockMessage, - message: "@testbot hello", - }; - - const result = checkTwitchAccessControl({ - message, - account, - botUsername: "testbot", + reason: "allowFrom", }); - expect(result.allowed).toBe(false); - expect(result.reason).toContain("allowFrom"); }); it("blocks messages without userId", () => { - const account: TwitchAccountConfig = { - ...mockAccount, + expectAllowFromBlocked({ allowFrom: ["123456"], - }; - const message: TwitchChatMessage = { - ...mockMessage, - message: "@testbot hello", - userId: undefined, - }; - - const result = checkTwitchAccessControl({ - message, - account, - botUsername: "testbot", + message: { userId: undefined }, + reason: "user ID not available", }); - expect(result.allowed).toBe(false); - expect(result.reason).toContain("user ID not available"); }); it("bypasses role checks when user is in allowlist", () => { @@ -193,47 +189,21 @@ describe("checkTwitchAccessControl", () => { }); it("blocks user with role when not in allowlist", () => { - const account: TwitchAccountConfig = { - ...mockAccount, + expectAllowFromBlocked({ allowFrom: ["789012"], allowedRoles: ["moderator"], - }; - const message: TwitchChatMessage = { - ...mockMessage, - message: "@testbot hello", - userId: "123456", - isMod: true, - }; - - const result = checkTwitchAccessControl({ - message, - account, - botUsername: "testbot", + message: { userId: "123456", isMod: true }, + reason: "allowFrom", }); - expect(result.allowed).toBe(false); - expect(result.reason).toContain("allowFrom"); }); it("blocks user not in allowlist even when roles configured", () => { - const account: TwitchAccountConfig = { - ...mockAccount, + expectAllowFromBlocked({ allowFrom: ["789012"], allowedRoles: ["moderator"], - }; - const message: TwitchChatMessage = { - ...mockMessage, - message: "@testbot hello", - userId: "123456", - isMod: false, - }; - - const result = checkTwitchAccessControl({ - message, - account, - botUsername: "testbot", + message: { userId: "123456", isMod: false }, + reason: "allowFrom", }); - expect(result.allowed).toBe(false); - expect(result.reason).toContain("allowFrom"); }); }); From a9d8518e7c2a8138ffffb52435b49c99e4ac93b6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:54:30 +0000 Subject: [PATCH 088/461] test: dedupe msteams consent auth fixtures --- .../src/monitor-handler.file-consent.test.ts | 59 ++++++++----------- 1 file changed, 26 insertions(+), 33 deletions(-) diff --git a/extensions/msteams/src/monitor-handler.file-consent.test.ts b/extensions/msteams/src/monitor-handler.file-consent.test.ts index 88a6a67a838..5e72f7a9dd1 100644 --- a/extensions/msteams/src/monitor-handler.file-consent.test.ts +++ b/extensions/msteams/src/monitor-handler.file-consent.test.ts @@ -123,6 +123,26 @@ function createInvokeContext(params: { }; } +function createConsentInvokeHarness(params: { + pendingConversationId?: string; + invokeConversationId: string; + action: "accept" | "decline"; +}) { + const uploadId = storePendingUpload({ + buffer: Buffer.from("TOP_SECRET_VICTIM_FILE\n"), + filename: "secret.txt", + contentType: "text/plain", + conversationId: params.pendingConversationId ?? "19:victim@thread.v2", + }); + const handler = registerMSTeamsHandlers(createActivityHandler(), createDeps()); + const { context, sendActivity } = createInvokeContext({ + conversationId: params.invokeConversationId, + uploadId, + action: params.action, + }); + return { uploadId, handler, context, sendActivity }; +} + describe("msteams file consent invoke authz", () => { beforeEach(() => { setMSTeamsRuntime(runtimeStub); @@ -132,17 +152,8 @@ describe("msteams file consent invoke authz", () => { }); it("uploads when invoke conversation matches pending upload conversation", async () => { - const uploadId = storePendingUpload({ - buffer: Buffer.from("TOP_SECRET_VICTIM_FILE\n"), - filename: "secret.txt", - contentType: "text/plain", - conversationId: "19:victim@thread.v2", - }); - const deps = createDeps(); - const handler = registerMSTeamsHandlers(createActivityHandler(), deps); - const { context, sendActivity } = createInvokeContext({ - conversationId: "19:victim@thread.v2;messageid=abc123", - uploadId, + const { uploadId, handler, context, sendActivity } = createConsentInvokeHarness({ + invokeConversationId: "19:victim@thread.v2;messageid=abc123", action: "accept", }); @@ -166,17 +177,8 @@ describe("msteams file consent invoke authz", () => { }); it("rejects cross-conversation accept invoke and keeps pending upload", async () => { - const uploadId = storePendingUpload({ - buffer: Buffer.from("TOP_SECRET_VICTIM_FILE\n"), - filename: "secret.txt", - contentType: "text/plain", - conversationId: "19:victim@thread.v2", - }); - const deps = createDeps(); - const handler = registerMSTeamsHandlers(createActivityHandler(), deps); - const { context, sendActivity } = createInvokeContext({ - conversationId: "19:attacker@thread.v2", - uploadId, + const { uploadId, handler, context, sendActivity } = createConsentInvokeHarness({ + invokeConversationId: "19:attacker@thread.v2", action: "accept", }); @@ -198,17 +200,8 @@ describe("msteams file consent invoke authz", () => { }); it("ignores cross-conversation decline invoke and keeps pending upload", async () => { - const uploadId = storePendingUpload({ - buffer: Buffer.from("TOP_SECRET_VICTIM_FILE\n"), - filename: "secret.txt", - contentType: "text/plain", - conversationId: "19:victim@thread.v2", - }); - const deps = createDeps(); - const handler = registerMSTeamsHandlers(createActivityHandler(), deps); - const { context, sendActivity } = createInvokeContext({ - conversationId: "19:attacker@thread.v2", - uploadId, + const { uploadId, handler, context, sendActivity } = createConsentInvokeHarness({ + invokeConversationId: "19:attacker@thread.v2", action: "decline", }); From 25e900f64adedf7b5c34dd6bae366e756bd96c37 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:55:33 +0000 Subject: [PATCH 089/461] test: tighten shared requirements coverage --- src/shared/requirements.test.ts | 35 +++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/shared/requirements.test.ts b/src/shared/requirements.test.ts index 0a05a0eb85c..3b47e768f2c 100644 --- a/src/shared/requirements.test.ts +++ b/src/shared/requirements.test.ts @@ -22,6 +22,12 @@ describe("requirements helpers", () => { }); it("resolveMissingAnyBins requires at least one", () => { + expect( + resolveMissingAnyBins({ + required: [], + hasLocalBin: () => false, + }), + ).toEqual([]); expect( resolveMissingAnyBins({ required: ["a", "b"], @@ -38,6 +44,8 @@ describe("requirements helpers", () => { }); it("resolveMissingOs allows remote platform", () => { + expect(resolveMissingOs({ required: [], localPlatform: "linux" })).toEqual([]); + expect(resolveMissingOs({ required: ["linux"], localPlatform: "linux" })).toEqual([]); expect( resolveMissingOs({ required: ["darwin"], @@ -164,4 +172,31 @@ describe("requirements helpers", () => { expect(res.missing).toEqual({ bins: [], anyBins: [], env: [], config: [], os: [] }); expect(res.eligible).toBe(true); }); + + it("evaluateRequirementsFromMetadata defaults missing metadata to empty requirements", () => { + const res = evaluateRequirementsFromMetadata({ + always: false, + hasLocalBin: () => false, + localPlatform: "linux", + isEnvSatisfied: () => false, + isConfigSatisfied: () => false, + }); + + expect(res.required).toEqual({ + bins: [], + anyBins: [], + env: [], + config: [], + os: [], + }); + expect(res.missing).toEqual({ + bins: [], + anyBins: [], + env: [], + config: [], + os: [], + }); + expect(res.configChecks).toEqual([]); + expect(res.eligible).toBe(true); + }); }); From 592d93211f0d5dac9df413c55c3ca4c44338ba40 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:57:16 +0000 Subject: [PATCH 090/461] test: tighten shared manifest metadata coverage --- src/shared/entry-metadata.test.ts | 12 ++++++++++ src/shared/entry-status.test.ts | 29 ++++++++++++++++++++++++ src/shared/frontmatter.test.ts | 37 +++++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+) diff --git a/src/shared/entry-metadata.test.ts b/src/shared/entry-metadata.test.ts index cf94453a62e..ea52cd033e4 100644 --- a/src/shared/entry-metadata.test.ts +++ b/src/shared/entry-metadata.test.ts @@ -46,4 +46,16 @@ describe("shared/entry-metadata", () => { homepage: "https://openclaw.ai/install", }); }); + + it("does not fall back once frontmatter homepage aliases are present but blank", () => { + expect( + resolveEmojiAndHomepage({ + frontmatter: { + homepage: " ", + website: "https://docs.openclaw.ai", + url: "https://openclaw.ai/install", + }, + }), + ).toEqual({}); + }); }); diff --git a/src/shared/entry-status.test.ts b/src/shared/entry-status.test.ts index 88913913011..68cce75c982 100644 --- a/src/shared/entry-status.test.ts +++ b/src/shared/entry-status.test.ts @@ -129,4 +129,33 @@ describe("shared/entry-status", () => { configChecks: [], }); }); + + it("returns empty requirements when metadata and frontmatter are missing", () => { + const result = evaluateEntryMetadataRequirements({ + always: false, + hasLocalBin: () => false, + localPlatform: "linux", + isEnvSatisfied: () => false, + isConfigSatisfied: () => false, + }); + + expect(result).toEqual({ + required: { + bins: [], + anyBins: [], + env: [], + config: [], + os: [], + }, + missing: { + bins: [], + anyBins: [], + env: [], + config: [], + os: [], + }, + requirementsSatisfied: true, + configChecks: [], + }); + }); }); diff --git a/src/shared/frontmatter.test.ts b/src/shared/frontmatter.test.ts index 94cd4acabef..69d48e05b57 100644 --- a/src/shared/frontmatter.test.ts +++ b/src/shared/frontmatter.test.ts @@ -27,6 +27,7 @@ describe("shared/frontmatter", () => { expect(parseFrontmatterBool("true", false)).toBe(true); expect(parseFrontmatterBool("false", true)).toBe(false); expect(parseFrontmatterBool(undefined, true)).toBe(true); + expect(parseFrontmatterBool("maybe", false)).toBe(false); }); test("resolveOpenClawManifestBlock reads current manifest keys and custom metadata fields", () => { @@ -53,6 +54,8 @@ describe("shared/frontmatter", () => { expect( resolveOpenClawManifestBlock({ frontmatter: { metadata: "not-json5" } }), ).toBeUndefined(); + expect(resolveOpenClawManifestBlock({ frontmatter: { metadata: "123" } })).toBeUndefined(); + expect(resolveOpenClawManifestBlock({ frontmatter: { metadata: "[]" } })).toBeUndefined(); expect( resolveOpenClawManifestBlock({ frontmatter: { metadata: "{ nope: { a: 1 } }" } }), ).toBeUndefined(); @@ -120,6 +123,40 @@ describe("shared/frontmatter", () => { }); }); + it("prefers explicit kind, ignores invalid common fields, and leaves missing ones untouched", () => { + const parsed = parseOpenClawManifestInstallBase( + { + kind: " npm ", + type: "brew", + id: 42, + label: null, + bins: [" ", ""], + }, + ["brew", "npm"], + ); + + expect(parsed).toEqual({ + raw: { + kind: " npm ", + type: "brew", + id: 42, + label: null, + bins: [" ", ""], + }, + kind: "npm", + }); + expect( + applyOpenClawManifestInstallCommonFields( + { id: "keep", label: "Keep", bins: ["bun"] }, + parsed!, + ), + ).toEqual({ + id: "keep", + label: "Keep", + bins: ["bun"], + }); + }); + it("maps install entries through the parser and filters rejected specs", () => { expect( resolveOpenClawManifestInstall( From ccced29b4677e356d5ef32f73540f8e31a0ca190 Mon Sep 17 00:00:00 2001 From: Taras Shynkarenko Date: Fri, 13 Mar 2026 20:55:20 +0100 Subject: [PATCH 091/461] perf(build): deduplicate plugin-sdk chunks to fix ~2x memory regression MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundle all plugin-sdk entries in a single tsdown build pass instead of 38 separate builds. The separate builds prevented the bundler from sharing common chunks, causing massive duplication (e.g. 20 copies of query-expansion, 14 copies of fetch, 11 copies of logger). Measured impact: - dist/ size: 190MB → 64MB (-66%) - plugin-sdk/ size: 142MB → 16MB (-89%) - JS files: 1,395 → 789 (-43%) - 5MB+ files: 27 → 7 (-74%) - Plugin-SDK heap cost: +1,309MB → +63MB (-95%) - Total heap (all chunks loaded): 1,926MB → 711MB (-63%) --- tsdown.config.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tsdown.config.ts b/tsdown.config.ts index 80833de2a14..1806debd474 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -116,12 +116,12 @@ export default defineConfig([ "line/template-messages": "src/line/template-messages.ts", }, }), - ...pluginSdkEntrypoints.map((entry) => - nodeBuildConfig({ - entry: `src/plugin-sdk/${entry}.ts`, - outDir: "dist/plugin-sdk", - }), - ), + nodeBuildConfig({ + // Bundle all plugin-sdk entries in a single build so the bundler can share + // common chunks instead of duplicating them per entry (~712MB heap saved). + entry: Object.fromEntries(pluginSdkEntrypoints.map((e) => [e, `src/plugin-sdk/${e}.ts`])), + outDir: "dist/plugin-sdk", + }), nodeBuildConfig({ entry: "src/extensionAPI.ts", }), From b7ff8256eff5791bb12c22ef2eec265dc82a4405 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:57:16 +0000 Subject: [PATCH 092/461] test: guard plugin-sdk shared-bundle regression (#45426) (thanks @TarasShyn) --- CHANGELOG.md | 1 + src/plugin-sdk/index.test.ts | 79 ++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e61358e91e..19d9bb2347c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Build/plugin-sdk bundling: bundle plugin-sdk subpath entries in one shared build pass so published packages stop duplicating shared chunks and avoid the recent plugin-sdk memory blow-up. (#45426) Thanks @TarasShyn. - Browser/existing-session: accept text-only `list_pages` and `new_page` responses from Chrome DevTools MCP so live-session tab discovery and new-tab open flows keep working when the server omits structured page metadata. - Ollama/reasoning visibility: stop promoting native `thinking` and `reasoning` fields into final assistant text so local reasoning models no longer leak internal thoughts in normal replies. (#45330) Thanks @xi7ang. - Cron/isolated sessions: route nested cron-triggered embedded runner work onto the nested lane so isolated cron jobs no longer deadlock when compaction or other queued inner work runs. Thanks @vincentkoc. diff --git a/src/plugin-sdk/index.test.ts b/src/plugin-sdk/index.test.ts index 24cb7bb67e4..0605cc11c05 100644 --- a/src/plugin-sdk/index.test.ts +++ b/src/plugin-sdk/index.test.ts @@ -1,6 +1,58 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { build } from "tsdown"; import { describe, expect, it } from "vitest"; import * as sdk from "./index.js"; +const pluginSdkEntrypoints = [ + "index", + "core", + "compat", + "telegram", + "discord", + "slack", + "signal", + "imessage", + "whatsapp", + "line", + "msteams", + "acpx", + "bluebubbles", + "copilot-proxy", + "device-pair", + "diagnostics-otel", + "diffs", + "feishu", + "google-gemini-cli-auth", + "googlechat", + "irc", + "llm-task", + "lobster", + "matrix", + "mattermost", + "memory-core", + "memory-lancedb", + "minimax-portal-auth", + "nextcloud-talk", + "nostr", + "open-prose", + "phone-control", + "qwen-portal-auth", + "synology-chat", + "talk-voice", + "test-utils", + "thread-ownership", + "tlon", + "twitch", + "voice-call", + "zalo", + "zalouser", + "account-id", + "keyed-async-queue", +] as const; + describe("plugin-sdk exports", () => { it("does not expose runtime modules", () => { const forbidden = [ @@ -104,4 +156,31 @@ describe("plugin-sdk exports", () => { expect(sdk).toHaveProperty(key); } }); + + it("emits importable bundled subpath entries", { timeout: 240_000 }, async () => { + const outDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-plugin-sdk-build-")); + + try { + await build({ + clean: true, + config: false, + dts: false, + entry: Object.fromEntries( + pluginSdkEntrypoints.map((entry) => [entry, `src/plugin-sdk/${entry}.ts`]), + ), + env: { NODE_ENV: "production" }, + fixedExtension: false, + logLevel: "error", + outDir, + platform: "node", + }); + + for (const entry of pluginSdkEntrypoints) { + const module = await import(pathToFileURL(path.join(outDir, `${entry}.js`)).href); + expect(module).toBeTypeOf("object"); + } + } finally { + await fs.rm(outDir, { recursive: true, force: true }); + } + }); }); From 6a9e141c7afa747b8a08da4368c9d2152f5e7543 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:58:16 +0000 Subject: [PATCH 093/461] test: tighten shared config eval helper coverage --- src/shared/config-eval.test.ts | 77 +++++++++++++++++++++++++++++++++- 1 file changed, 76 insertions(+), 1 deletion(-) diff --git a/src/shared/config-eval.test.ts b/src/shared/config-eval.test.ts index 48ddb9e3298..7891c17142c 100644 --- a/src/shared/config-eval.test.ts +++ b/src/shared/config-eval.test.ts @@ -1,12 +1,40 @@ -import { describe, expect, it } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { evaluateRuntimeEligibility, evaluateRuntimeRequires, + hasBinary, isConfigPathTruthyWithDefaults, isTruthy, resolveConfigPath, + resolveRuntimePlatform, } from "./config-eval.js"; +const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, "platform"); +const originalPath = process.env.PATH; +const originalPathExt = process.env.PATHEXT; + +function setPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, "platform", { + value: platform, + configurable: true, + }); +} + +afterEach(() => { + vi.restoreAllMocks(); + process.env.PATH = originalPath; + if (originalPathExt === undefined) { + delete process.env.PATHEXT; + } else { + process.env.PATHEXT = originalPathExt; + } + if (originalPlatformDescriptor) { + Object.defineProperty(process, "platform", originalPlatformDescriptor); + } +}); + describe("config-eval helpers", () => { it("normalizes truthy values across primitive types", () => { expect(isTruthy(undefined)).toBe(false); @@ -51,6 +79,53 @@ describe("config-eval helpers", () => { ).toBe(true); expect(isConfigPathTruthyWithDefaults(config, "browser.other", {})).toBe(false); }); + + it("returns the active runtime platform", () => { + setPlatform("darwin"); + expect(resolveRuntimePlatform()).toBe("darwin"); + }); + + it("caches binary lookups until PATH changes", () => { + process.env.PATH = ["/missing/bin", "/found/bin"].join(path.delimiter); + const accessSpy = vi.spyOn(fs, "accessSync").mockImplementation((candidate) => { + if (String(candidate) === path.join("/found/bin", "tool")) { + return undefined; + } + throw new Error("missing"); + }); + + expect(hasBinary("tool")).toBe(true); + expect(hasBinary("tool")).toBe(true); + expect(accessSpy).toHaveBeenCalledTimes(2); + + process.env.PATH = "/other/bin"; + accessSpy.mockClear(); + accessSpy.mockImplementation(() => { + throw new Error("missing"); + }); + + expect(hasBinary("tool")).toBe(false); + expect(accessSpy).toHaveBeenCalledTimes(1); + }); + + it("checks PATHEXT candidates on Windows", () => { + setPlatform("win32"); + process.env.PATH = "/tools"; + process.env.PATHEXT = ".EXE;.CMD"; + const accessSpy = vi.spyOn(fs, "accessSync").mockImplementation((candidate) => { + if (String(candidate) === "/tools/tool.CMD") { + return undefined; + } + throw new Error("missing"); + }); + + expect(hasBinary("tool")).toBe(true); + expect(accessSpy.mock.calls.map(([candidate]) => String(candidate))).toEqual([ + "/tools/tool", + "/tools/tool.EXE", + "/tools/tool.CMD", + ]); + }); }); describe("evaluateRuntimeRequires", () => { From 2fe4c4f8e5cfd89ba02716c2d44df7cfd6559ba6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:59:35 +0000 Subject: [PATCH 094/461] test: tighten shared auth store coverage --- src/shared/device-auth-store.test.ts | 68 ++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/src/shared/device-auth-store.test.ts b/src/shared/device-auth-store.test.ts index be070ee79cd..b0346afff9a 100644 --- a/src/shared/device-auth-store.test.ts +++ b/src/shared/device-auth-store.test.ts @@ -50,6 +50,36 @@ describe("device-auth-store", () => { ).toBeNull(); }); + it("returns null for missing stores and malformed token entries", () => { + expect( + loadDeviceAuthTokenFromStore({ + adapter: createAdapter().adapter, + deviceId: "device-1", + role: "operator", + }), + ).toBeNull(); + + const { adapter } = createAdapter({ + version: 1, + deviceId: "device-1", + tokens: { + operator: { + token: 123 as unknown as string, + role: "operator", + scopes: [], + updatedAtMs: 1, + }, + }, + }); + expect( + loadDeviceAuthTokenFromStore({ + adapter, + deviceId: "device-1", + role: "operator", + }), + ).toBeNull(); + }); + it("stores normalized roles and deduped sorted scopes while preserving same-device tokens", () => { vi.spyOn(Date, "now").mockReturnValue(1234); const { adapter, writes, readStore } = createAdapter({ @@ -130,6 +160,44 @@ describe("device-auth-store", () => { }); }); + it("overwrites existing entries for the same normalized role", () => { + vi.spyOn(Date, "now").mockReturnValue(2222); + const { adapter, readStore } = createAdapter({ + version: 1, + deviceId: "device-1", + tokens: { + operator: { + token: "old-token", + role: "operator", + scopes: ["operator.read"], + updatedAtMs: 10, + }, + }, + }); + + const entry = storeDeviceAuthTokenInStore({ + adapter, + deviceId: "device-1", + role: " operator ", + token: "new-token", + scopes: ["operator.write"], + }); + + expect(entry).toEqual({ + token: "new-token", + role: "operator", + scopes: ["operator.write"], + updatedAtMs: 2222, + }); + expect(readStore()).toEqual({ + version: 1, + deviceId: "device-1", + tokens: { + operator: entry, + }, + }); + }); + it("avoids writes when clearing missing roles or mismatched devices", () => { const missingRole = createAdapter({ version: 1, From fb4aa7eaba572d499f3d56a7b6b78ce4ded04544 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:00:22 +0000 Subject: [PATCH 095/461] fix: tighten shared chat envelope coverage --- src/shared/chat-envelope.test.ts | 2 ++ src/shared/chat-envelope.ts | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/shared/chat-envelope.test.ts b/src/shared/chat-envelope.test.ts index 0bd513c1b61..ca214f37316 100644 --- a/src/shared/chat-envelope.test.ts +++ b/src/shared/chat-envelope.test.ts @@ -14,6 +14,7 @@ describe("shared/chat-envelope", () => { expect(stripEnvelope("hello")).toBe("hello"); expect(stripEnvelope("[note] hello")).toBe("[note] hello"); expect(stripEnvelope("[2026/01/24 13:36] hello")).toBe("[2026/01/24 13:36] hello"); + expect(stripEnvelope("[Teams] hello")).toBe("[Teams] hello"); }); it("removes standalone message id hint lines but keeps inline mentions", () => { @@ -21,6 +22,7 @@ describe("shared/chat-envelope", () => { expect(stripMessageIdHints("hello\n [message_id: abc123] \nworld")).toBe("hello\nworld"); expect(stripMessageIdHints("[message_id: abc123]\nhello")).toBe("hello"); expect(stripMessageIdHints("[message_id: abc123]")).toBe(""); + expect(stripMessageIdHints("hello\r\n[MESSAGE_ID: abc123]\r\nworld")).toBe("hello\nworld"); expect(stripMessageIdHints("I typed [message_id: abc123] inline")).toBe( "I typed [message_id: abc123] inline", ); diff --git a/src/shared/chat-envelope.ts b/src/shared/chat-envelope.ts index 409a41357a1..b6bb1457a96 100644 --- a/src/shared/chat-envelope.ts +++ b/src/shared/chat-envelope.ts @@ -39,7 +39,7 @@ export function stripEnvelope(text: string): string { } export function stripMessageIdHints(text: string): string { - if (!text.includes("[message_id:")) { + if (!/\[message_id:/i.test(text)) { return text; } const lines = text.split(/\r?\n/); From cb99a23d84bb650b128806dcde07395b198c354e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:02:18 +0000 Subject: [PATCH 096/461] test: tighten shell env helper coverage --- src/infra/shell-env.test.ts | 72 +++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/src/infra/shell-env.test.ts b/src/infra/shell-env.test.ts index 64be7f28fc3..52d65c9edc8 100644 --- a/src/infra/shell-env.test.ts +++ b/src/infra/shell-env.test.ts @@ -2,10 +2,12 @@ import fs from "node:fs"; import os from "node:os"; import { describe, expect, it, vi } from "vitest"; import { + getShellEnvAppliedKeys, getShellPathFromLoginShell, loadShellEnvFallback, resetShellPathCacheForTests, resolveShellEnvFallbackTimeoutMs, + shouldDeferShellEnvFallback, shouldEnableShellEnvFallback, } from "./shell-env.js"; @@ -119,6 +121,12 @@ describe("shell env fallback", () => { expect(shouldEnableShellEnvFallback({ OPENCLAW_LOAD_SHELL_ENV: "1" })).toBe(true); }); + it("uses the same truthy env parsing for deferred fallback", () => { + expect(shouldDeferShellEnvFallback({} as NodeJS.ProcessEnv)).toBe(false); + expect(shouldDeferShellEnvFallback({ OPENCLAW_DEFER_SHELL_ENV_FALLBACK: "false" })).toBe(false); + expect(shouldDeferShellEnvFallback({ OPENCLAW_DEFER_SHELL_ENV_FALLBACK: "yes" })).toBe(true); + }); + it("resolves timeout from env with default fallback", () => { expect(resolveShellEnvFallbackTimeoutMs({} as NodeJS.ProcessEnv)).toBe(15000); expect(resolveShellEnvFallbackTimeoutMs({ OPENCLAW_SHELL_ENV_TIMEOUT_MS: "42" })).toBe(42); @@ -179,6 +187,57 @@ describe("shell env fallback", () => { expect(exec2).not.toHaveBeenCalled(); }); + it("tracks last applied keys across success, skip, and failure paths", () => { + const successEnv: NodeJS.ProcessEnv = {}; + const successExec = vi.fn(() => + Buffer.from("OPENAI_API_KEY=from-shell\0DISCORD_BOT_TOKEN=\0EXTRA=ignored\0"), + ); + expect( + loadShellEnvFallback({ + enabled: true, + env: successEnv, + expectedKeys: ["OPENAI_API_KEY", "DISCORD_BOT_TOKEN"], + exec: successExec as unknown as Parameters[0]["exec"], + }), + ).toEqual({ + ok: true, + applied: ["OPENAI_API_KEY"], + }); + expect(getShellEnvAppliedKeys()).toEqual(["OPENAI_API_KEY"]); + + expect( + loadShellEnvFallback({ + enabled: false, + env: {}, + expectedKeys: ["OPENAI_API_KEY"], + exec: successExec as unknown as Parameters[0]["exec"], + }), + ).toEqual({ + ok: true, + applied: [], + skippedReason: "disabled", + }); + expect(getShellEnvAppliedKeys()).toEqual([]); + + const failureExec = vi.fn(() => { + throw new Error("boom"); + }); + expect( + loadShellEnvFallback({ + enabled: true, + env: {}, + expectedKeys: ["OPENAI_API_KEY"], + exec: failureExec as unknown as Parameters[0]["exec"], + logger: { warn: vi.fn() }, + }), + ).toMatchObject({ + ok: false, + applied: [], + error: "boom", + }); + expect(getShellEnvAppliedKeys()).toEqual([]); + }); + it("resolves PATH via login shell and caches it", () => { const exec = vi.fn(() => Buffer.from("PATH=/usr/local/bin:/usr/bin\0HOME=/tmp\0")); @@ -207,6 +266,19 @@ describe("shell env fallback", () => { expect(exec).toHaveBeenCalledOnce(); }); + it("returns null when login shell PATH is blank", () => { + const exec = vi.fn(() => Buffer.from("PATH= \0HOME=/tmp\0")); + + const { first, second } = probeShellPathWithFreshCache({ + exec, + platform: "linux", + }); + + expect(first).toBeNull(); + expect(second).toBeNull(); + expect(exec).toHaveBeenCalledOnce(); + }); + it("falls back to /bin/sh when SHELL is non-absolute", () => { const { res, exec } = runShellEnvFallbackForShell("zsh"); From c04ea0eac5693ea20249ce93ab54ffc8f6584ef3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:03:17 +0000 Subject: [PATCH 097/461] test: tighten tmp dir security coverage --- src/infra/tmp-openclaw-dir.test.ts | 71 ++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/src/infra/tmp-openclaw-dir.test.ts b/src/infra/tmp-openclaw-dir.test.ts index 89056513856..72b67c95858 100644 --- a/src/infra/tmp-openclaw-dir.test.ts +++ b/src/infra/tmp-openclaw-dir.test.ts @@ -197,6 +197,27 @@ describe("resolvePreferredOpenClawTmpDir", () => { expectFallsBackToOsTmpDir({ lstatSync: vi.fn(() => makeDirStat({ mode: 0o40777 })) }); }); + it("repairs existing /tmp/openclaw permissions when they are too broad", () => { + let preferredMode = 0o40777; + const chmodSync = vi.fn((target: string, mode: number) => { + if (target === POSIX_OPENCLAW_TMP_DIR && mode === 0o700) { + preferredMode = 0o40700; + } + }); + const warn = vi.fn(); + + const { resolved, tmpdir } = resolveWithMocks({ + lstatSync: vi.fn(() => makeDirStat({ mode: preferredMode })), + chmodSync, + warn, + }); + + expect(resolved).toBe(POSIX_OPENCLAW_TMP_DIR); + expect(chmodSync).toHaveBeenCalledWith(POSIX_OPENCLAW_TMP_DIR, 0o700); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("tightened permissions on temp dir")); + expect(tmpdir).not.toHaveBeenCalled(); + }); + it("throws when fallback path is a symlink", () => { const lstatSync = symlinkTmpDirLstat(); const fallbackLstatSync = vi.fn(() => makeDirStat({ isSymbolicLink: true, mode: 0o120777 })); @@ -222,6 +243,35 @@ describe("resolvePreferredOpenClawTmpDir", () => { expect(mkdirSync).toHaveBeenCalledWith(fallbackTmp(), { recursive: true, mode: 0o700 }); }); + it("uses an unscoped fallback suffix when process uid is unavailable", () => { + const tmpdirPath = "/var/fallback"; + const fallbackPath = path.join(tmpdirPath, "openclaw"); + + const resolved = resolvePreferredOpenClawTmpDir({ + accessSync: vi.fn((target: string) => { + if (target === "/tmp") { + throw new Error("read-only"); + } + }), + lstatSync: vi.fn((target: string) => { + if (target === POSIX_OPENCLAW_TMP_DIR) { + throw nodeErrorWithCode("ENOENT"); + } + if (target === fallbackPath) { + return makeDirStat({ uid: 0, mode: 0o40777 }); + } + return secureDirStat(); + }), + mkdirSync: vi.fn(), + chmodSync: vi.fn(), + getuid: vi.fn(() => undefined), + tmpdir: vi.fn(() => tmpdirPath), + warn: vi.fn(), + }); + + expect(resolved).toBe(fallbackPath); + }); + it("repairs fallback directory permissions after create when umask makes it group-writable", () => { const fallbackPath = fallbackTmp(); let fallbackMode = 0o40775; @@ -287,4 +337,25 @@ describe("resolvePreferredOpenClawTmpDir", () => { expect(chmodSync).toHaveBeenCalledWith(fallbackPath, 0o700); expect(warn).toHaveBeenCalledWith(expect.stringContaining("tightened permissions on temp dir")); }); + + it("throws when the fallback directory cannot be created", () => { + expect(() => + resolvePreferredOpenClawTmpDir({ + accessSync: readOnlyTmpAccessSync(), + lstatSync: vi.fn((target: string) => { + if (target === POSIX_OPENCLAW_TMP_DIR || target === fallbackTmp()) { + throw nodeErrorWithCode("ENOENT"); + } + return secureDirStat(); + }), + mkdirSync: vi.fn(() => { + throw new Error("mkdir failed"); + }), + chmodSync: vi.fn(), + getuid: vi.fn(() => 501), + tmpdir: vi.fn(() => "/var/fallback"), + warn: vi.fn(), + }), + ).toThrow(/Unable to create fallback OpenClaw temp dir/); + }); }); From 56e5b8b9e85a3268d56cbaf7e22448bbba69ed78 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:04:54 +0000 Subject: [PATCH 098/461] test: tighten secret file error coverage --- src/infra/secret-file.test.ts | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/infra/secret-file.test.ts b/src/infra/secret-file.test.ts index ca7841891e5..5e9e6fe7b90 100644 --- a/src/infra/secret-file.test.ts +++ b/src/infra/secret-file.test.ts @@ -29,6 +29,37 @@ describe("readSecretFileSync", () => { await writeFile(file, " top-secret \n", "utf8"); expect(readSecretFileSync(file, "Gateway password")).toBe("top-secret"); + expect(tryReadSecretFileSync(file, "Gateway password")).toBe("top-secret"); + }); + + it("surfaces resolvedPath and error details for missing files", async () => { + const dir = await createTempDir(); + const file = path.join(dir, "missing-secret.txt"); + + const result = loadSecretFileSync(file, "Gateway password"); + + expect(result).toMatchObject({ + ok: false, + resolvedPath: file, + message: expect.stringContaining(`Failed to inspect Gateway password file at ${file}:`), + error: expect.any(Error), + }); + }); + + it("preserves the underlying cause when throwing for missing files", async () => { + const dir = await createTempDir(); + const file = path.join(dir, "missing-secret.txt"); + + let thrown: Error | undefined; + try { + readSecretFileSync(file, "Gateway password"); + } catch (error) { + thrown = error as Error; + } + + expect(thrown).toBeInstanceOf(Error); + expect(thrown?.message).toContain(`Failed to inspect Gateway password file at ${file}:`); + expect((thrown as Error & { cause?: unknown }).cause).toBeInstanceOf(Error); }); it("rejects files larger than the secret-file limit", async () => { From 0826feb94d1d71248ecbd2e550086ff10ac0de13 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:06:01 +0000 Subject: [PATCH 099/461] test: tighten path prepend helper coverage --- src/infra/path-prepend.test.ts | 48 +++++++++++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/src/infra/path-prepend.test.ts b/src/infra/path-prepend.test.ts index 29dfb504cfb..7f4211a0137 100644 --- a/src/infra/path-prepend.test.ts +++ b/src/infra/path-prepend.test.ts @@ -1,8 +1,20 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; -import { mergePathPrepend, normalizePathPrepend } from "./path-prepend.js"; +import { + applyPathPrepend, + findPathKey, + mergePathPrepend, + normalizePathPrepend, +} from "./path-prepend.js"; describe("path prepend helpers", () => { + it("finds the actual PATH key while preserving original casing", () => { + expect(findPathKey({ PATH: "/usr/bin" })).toBe("PATH"); + expect(findPathKey({ Path: "/usr/bin" })).toBe("Path"); + expect(findPathKey({ PaTh: "/usr/bin" })).toBe("PaTh"); + expect(findPathKey({ HOME: "/tmp" })).toBe("PATH"); + }); + it("normalizes prepend lists by trimming, skipping blanks, and deduping", () => { expect( normalizePathPrepend([ @@ -30,4 +42,38 @@ describe("path prepend helpers", () => { mergePathPrepend(` /usr/bin ${path.delimiter} ${path.delimiter} /opt/bin `, ["/custom/bin"]), ).toBe(["/custom/bin", "/usr/bin", "/opt/bin"].join(path.delimiter)); }); + + it("applies prepends to the discovered PATH key and preserves existing casing", () => { + const env = { + Path: [`/usr/bin`, `/opt/bin`].join(path.delimiter), + }; + + applyPathPrepend(env, ["/custom/bin", "/usr/bin"]); + + expect(env).toEqual({ + Path: ["/custom/bin", "/usr/bin", "/opt/bin"].join(path.delimiter), + }); + }); + + it("respects requireExisting and ignores empty prepend lists", () => { + const envWithoutPath = { HOME: "/tmp/home" }; + applyPathPrepend(envWithoutPath, ["/custom/bin"], { requireExisting: true }); + expect(envWithoutPath).toEqual({ HOME: "/tmp/home" }); + + const envWithPath = { PATH: "/usr/bin" }; + applyPathPrepend(envWithPath, [], { requireExisting: true }); + applyPathPrepend(envWithPath, undefined, { requireExisting: true }); + expect(envWithPath).toEqual({ PATH: "/usr/bin" }); + }); + + it("creates PATH when prepends are provided and no path key exists", () => { + const env = { HOME: "/tmp/home" }; + + applyPathPrepend(env, ["/custom/bin"]); + + expect(env).toEqual({ + HOME: "/tmp/home", + PATH: "/custom/bin", + }); + }); }); From fac754041c716ae55f3b04f77a88c6cbb36fdeeb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:07:14 +0000 Subject: [PATCH 100/461] fix: tighten executable path coverage --- src/infra/executable-path.test.ts | 23 +++++++++++++++++++++++ src/infra/executable-path.ts | 4 +++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/infra/executable-path.test.ts b/src/infra/executable-path.test.ts index 731457ab183..31437cafe49 100644 --- a/src/infra/executable-path.test.ts +++ b/src/infra/executable-path.test.ts @@ -47,4 +47,27 @@ describe("executable path helpers", () => { expect(resolveExecutablePath("runner", { env: { PATH: binDir } })).toBe(pathTool); expect(resolveExecutablePath("missing", { env: { PATH: binDir } })).toBeUndefined(); }); + + it("resolves absolute, home-relative, and Path-cased env executables", async () => { + const base = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-exec-path-")); + const homeDir = path.join(base, "home"); + const binDir = path.join(base, "bin"); + await fs.mkdir(homeDir, { recursive: true }); + await fs.mkdir(binDir, { recursive: true }); + + const homeTool = path.join(homeDir, "home-tool"); + const absoluteTool = path.join(base, "absolute-tool"); + const pathTool = path.join(binDir, "runner"); + await fs.writeFile(homeTool, "#!/bin/sh\nexit 0\n", "utf8"); + await fs.writeFile(absoluteTool, "#!/bin/sh\nexit 0\n", "utf8"); + await fs.writeFile(pathTool, "#!/bin/sh\nexit 0\n", "utf8"); + await fs.chmod(homeTool, 0o755); + await fs.chmod(absoluteTool, 0o755); + await fs.chmod(pathTool, 0o755); + + expect(resolveExecutablePath(absoluteTool)).toBe(absoluteTool); + expect(resolveExecutablePath("~/home-tool", { env: { HOME: homeDir } })).toBe(homeTool); + expect(resolveExecutablePath("runner", { env: { Path: binDir } })).toBe(pathTool); + expect(resolveExecutablePath("~/missing-tool", { env: { HOME: homeDir } })).toBeUndefined(); + }); }); diff --git a/src/infra/executable-path.ts b/src/infra/executable-path.ts index b25231a4a50..bf648c7cb6a 100644 --- a/src/infra/executable-path.ts +++ b/src/infra/executable-path.ts @@ -60,7 +60,9 @@ export function resolveExecutablePath( rawExecutable: string, options?: { cwd?: string; env?: NodeJS.ProcessEnv }, ): string | undefined { - const expanded = rawExecutable.startsWith("~") ? expandHomePrefix(rawExecutable) : rawExecutable; + const expanded = rawExecutable.startsWith("~") + ? expandHomePrefix(rawExecutable, { env: options?.env }) + : rawExecutable; if (expanded.includes("/") || expanded.includes("\\")) { if (path.isAbsolute(expanded)) { return isExecutableFile(expanded) ? expanded : undefined; From 65f92fd83915a6d01fb2a7e1ef379630bcc0d1ec Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 13 Mar 2026 18:09:01 -0400 Subject: [PATCH 101/461] Guard updater service refresh against missing invocation cwd (#45486) * Update: capture a stable cwd for service refresh env * Test: cover service refresh when cwd disappears --- src/cli/update-cli.test.ts | 48 ++++++++++++++++++++++++++++ src/cli/update-cli/update-command.ts | 21 ++++++++++-- 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index 34ca4efaa87..f2138215327 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -668,6 +668,54 @@ describe("update-cli", () => { expect(runDaemonInstall).not.toHaveBeenCalled(); }); + it("updateCommand reuses the captured invocation cwd when process.cwd later fails", async () => { + const root = createCaseDir("openclaw-updated-root"); + const entryPath = path.join(root, "dist", "entry.js"); + pathExists.mockImplementation(async (candidate: string) => candidate === entryPath); + + const originalCwd = process.cwd(); + let restoreCwd: (() => void) | undefined; + vi.mocked(runGatewayUpdate).mockImplementation(async () => { + const cwdSpy = vi.spyOn(process, "cwd").mockImplementation(() => { + throw new Error("ENOENT: current working directory is gone"); + }); + restoreCwd = () => cwdSpy.mockRestore(); + return { + status: "ok", + mode: "npm", + root, + steps: [], + durationMs: 100, + }; + }); + serviceLoaded.mockResolvedValue(true); + + try { + await withEnvAsync( + { + OPENCLAW_STATE_DIR: "./state", + }, + async () => { + await updateCommand({}); + }, + ); + } finally { + restoreCwd?.(); + } + + expect(runCommandWithTimeout).toHaveBeenCalledWith( + [expect.stringMatching(/node/), entryPath, "gateway", "install", "--force"], + expect.objectContaining({ + cwd: root, + env: expect.objectContaining({ + OPENCLAW_STATE_DIR: path.resolve(originalCwd, "./state"), + }), + timeoutMs: 60_000, + }), + ); + expect(runDaemonInstall).not.toHaveBeenCalled(); + }); + it("updateCommand falls back to restart when env refresh install fails", async () => { await runRestartFallbackScenario({ daemonInstall: "fail" }); }); diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index d0d39e0215a..b94fbd4ffb9 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -124,9 +124,17 @@ function formatCommandFailure(stdout: string, stderr: string): string { return detail.split("\n").slice(-3).join("\n"); } +function tryResolveInvocationCwd(): string | undefined { + try { + return process.cwd(); + } catch { + return undefined; + } +} + function resolveServiceRefreshEnv( env: NodeJS.ProcessEnv, - invocationCwd: string = process.cwd(), + invocationCwd?: string, ): NodeJS.ProcessEnv { const resolvedEnv: NodeJS.ProcessEnv = { ...env }; for (const key of SERVICE_REFRESH_PATH_ENV_KEYS) { @@ -138,6 +146,10 @@ function resolveServiceRefreshEnv( resolvedEnv[key] = rawValue; continue; } + if (!invocationCwd) { + resolvedEnv[key] = rawValue; + continue; + } resolvedEnv[key] = path.resolve(invocationCwd, rawValue); } return resolvedEnv; @@ -205,6 +217,7 @@ function printDryRunPreview(preview: UpdateDryRunPreview, jsonMode: boolean): vo async function refreshGatewayServiceEnv(params: { result: UpdateRunResult; jsonMode: boolean; + invocationCwd?: string; }): Promise { const args = ["gateway", "install", "--force"]; if (params.jsonMode) { @@ -217,7 +230,7 @@ async function refreshGatewayServiceEnv(params: { } const res = await runCommandWithTimeout([resolveNodeRunner(), candidate, ...args], { cwd: params.result.root, - env: resolveServiceRefreshEnv(process.env), + env: resolveServiceRefreshEnv(process.env, params.invocationCwd), timeoutMs: SERVICE_REFRESH_TIMEOUT_MS, }); if (res.code === 0) { @@ -547,6 +560,7 @@ async function maybeRestartService(params: { refreshServiceEnv: boolean; gatewayPort: number; restartScriptPath?: string | null; + invocationCwd?: string; }): Promise { if (params.shouldRestart) { if (!params.opts.json) { @@ -562,6 +576,7 @@ async function maybeRestartService(params: { await refreshGatewayServiceEnv({ result: params.result, jsonMode: Boolean(params.opts.json), + invocationCwd: params.invocationCwd, }); } catch (err) { if (!params.opts.json) { @@ -667,6 +682,7 @@ async function maybeRestartService(params: { export async function updateCommand(opts: UpdateCommandOptions): Promise { suppressDeprecations(); + const invocationCwd = tryResolveInvocationCwd(); const timeoutMs = parseTimeoutMsOrExit(opts.timeout); const shouldRestart = opts.restart !== false; @@ -949,6 +965,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { refreshServiceEnv: refreshGatewayServiceEnv, gatewayPort, restartScriptPath, + invocationCwd, }); if (!opts.json) { From a66a0852bb2d5fc2ac19e07a32463ba6bb53f39d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:08:35 +0000 Subject: [PATCH 102/461] test: cover plugin-sdk subpath imports --- src/plugin-sdk/index.test.ts | 55 ++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/src/plugin-sdk/index.test.ts b/src/plugin-sdk/index.test.ts index 0605cc11c05..61d1cccb10c 100644 --- a/src/plugin-sdk/index.test.ts +++ b/src/plugin-sdk/index.test.ts @@ -53,6 +53,21 @@ const pluginSdkEntrypoints = [ "keyed-async-queue", ] as const; +const pluginSdkSpecifiers = pluginSdkEntrypoints.map((entry) => + entry === "index" ? "openclaw/plugin-sdk" : `openclaw/plugin-sdk/${entry}`, +); + +function buildPluginSdkPackageExports() { + return Object.fromEntries( + pluginSdkEntrypoints.map((entry) => [ + entry === "index" ? "./plugin-sdk" : `./plugin-sdk/${entry}`, + { + default: `./dist/plugin-sdk/${entry}.js`, + }, + ]), + ); +} + describe("plugin-sdk exports", () => { it("does not expose runtime modules", () => { const forbidden = [ @@ -159,6 +174,7 @@ describe("plugin-sdk exports", () => { it("emits importable bundled subpath entries", { timeout: 240_000 }, async () => { const outDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-plugin-sdk-build-")); + const fixtureDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-plugin-sdk-consumer-")); try { await build({ @@ -179,8 +195,47 @@ describe("plugin-sdk exports", () => { const module = await import(pathToFileURL(path.join(outDir, `${entry}.js`)).href); expect(module).toBeTypeOf("object"); } + + const packageDir = path.join(fixtureDir, "openclaw"); + const consumerDir = path.join(fixtureDir, "consumer"); + const consumerEntry = path.join(consumerDir, "import-plugin-sdk.mjs"); + + await fs.mkdir(path.join(packageDir, "dist"), { recursive: true }); + await fs.symlink(outDir, path.join(packageDir, "dist", "plugin-sdk"), "dir"); + await fs.writeFile( + path.join(packageDir, "package.json"), + JSON.stringify( + { + exports: buildPluginSdkPackageExports(), + name: "openclaw", + type: "module", + }, + null, + 2, + ), + ); + + await fs.mkdir(path.join(consumerDir, "node_modules"), { recursive: true }); + await fs.symlink(packageDir, path.join(consumerDir, "node_modules", "openclaw"), "dir"); + await fs.writeFile( + consumerEntry, + [ + `const specifiers = ${JSON.stringify(pluginSdkSpecifiers)};`, + "const results = {};", + "for (const specifier of specifiers) {", + " results[specifier] = typeof (await import(specifier));", + "}", + "export default results;", + ].join("\n"), + ); + + const { default: importResults } = await import(pathToFileURL(consumerEntry).href); + expect(importResults).toEqual( + Object.fromEntries(pluginSdkSpecifiers.map((specifier) => [specifier, "object"])), + ); } finally { await fs.rm(outDir, { recursive: true, force: true }); + await fs.rm(fixtureDir, { recursive: true, force: true }); } }); }); From d0337a18b6473b3ca165b886267f07e4cb481bf2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:08:54 +0000 Subject: [PATCH 103/461] fix: clear typecheck backlog --- .../src/monitor.webhook-auth.test.ts | 13 +++--- extensions/feishu/src/probe.test.ts | 2 +- .../matrix/src/matrix/monitor/events.test.ts | 42 +++++++++++++------ .../nostr/src/nostr-profile-http.test.ts | 14 +++---- .../synology-chat/src/webhook-handler.test.ts | 3 +- extensions/zalo/src/monitor.ts | 2 +- extensions/zalo/src/send.ts | 16 ++----- .../onboard-non-interactive.gateway.test.ts | 15 +++++-- src/node-host/invoke-system-run.ts | 1 + src/slack/monitor/events/messages.test.ts | 1 - 10 files changed, 63 insertions(+), 46 deletions(-) diff --git a/extensions/bluebubbles/src/monitor.webhook-auth.test.ts b/extensions/bluebubbles/src/monitor.webhook-auth.test.ts index b72b95dc4cc..f6826ac510b 100644 --- a/extensions/bluebubbles/src/monitor.webhook-auth.test.ts +++ b/extensions/bluebubbles/src/monitor.webhook-auth.test.ts @@ -328,13 +328,14 @@ describe("BlueBubbles webhook monitor", () => { } function createHangingWebhookRequest(url = "/bluebubbles-webhook?password=test-password") { - const req = new EventEmitter() as IncomingMessage & { destroy: ReturnType }; + const req = new EventEmitter() as IncomingMessage; + const destroyMock = vi.fn(); req.method = "POST"; req.url = url; req.headers = {}; - req.destroy = vi.fn(); + req.destroy = destroyMock as unknown as IncomingMessage["destroy"]; setRequestRemoteAddress(req, "127.0.0.1"); - return req; + return { req, destroyMock }; } function registerWebhookTargets( @@ -415,7 +416,7 @@ describe("BlueBubbles webhook monitor", () => { setupWebhookTarget(); // Create a request that never sends data or ends (simulates slow-loris) - const req = createHangingWebhookRequest(); + const { req, destroyMock } = createHangingWebhookRequest(); const res = createMockResponse(); @@ -427,7 +428,7 @@ describe("BlueBubbles webhook monitor", () => { const handled = await handledPromise; expect(handled).toBe(true); expect(res.statusCode).toBe(408); - expect(req.destroy).toHaveBeenCalled(); + expect(destroyMock).toHaveBeenCalled(); } finally { vi.useRealTimers(); } @@ -436,7 +437,7 @@ describe("BlueBubbles webhook monitor", () => { it("rejects unauthorized requests before reading the body", async () => { const account = createMockAccount({ password: "secret-token" }); setupWebhookTarget({ account }); - const req = createHangingWebhookRequest("/bluebubbles-webhook?password=wrong-token"); + const { req } = createHangingWebhookRequest("/bluebubbles-webhook?password=wrong-token"); const onSpy = vi.spyOn(req, "on"); await expectWebhookStatus(req, 401); expect(onSpy).not.toHaveBeenCalledWith("data", expect.any(Function)); diff --git a/extensions/feishu/src/probe.test.ts b/extensions/feishu/src/probe.test.ts index 328c83f658a..bfc270a4459 100644 --- a/extensions/feishu/src/probe.test.ts +++ b/extensions/feishu/src/probe.test.ts @@ -40,7 +40,7 @@ function setupSuccessClient() { async function expectDefaultSuccessResult( creds = DEFAULT_CREDS, - expected = DEFAULT_SUCCESS_RESULT, + expected: Awaited> = DEFAULT_SUCCESS_RESULT, ) { const result = await probeFeishu(creds); expect(result).toEqual(expected); diff --git a/extensions/matrix/src/matrix/monitor/events.test.ts b/extensions/matrix/src/matrix/monitor/events.test.ts index 3c08a0230d1..6dac0db59fc 100644 --- a/extensions/matrix/src/matrix/monitor/events.test.ts +++ b/extensions/matrix/src/matrix/monitor/events.test.ts @@ -14,6 +14,17 @@ vi.mock("../send.js", () => ({ describe("registerMatrixMonitorEvents", () => { const roomId = "!room:example.org"; + function makeEvent(overrides: Partial): MatrixRawEvent { + return { + event_id: "$event", + sender: "@alice:example.org", + type: "m.room.message", + origin_server_ts: 0, + content: {}, + ...overrides, + }; + } + beforeEach(() => { sendReadReceiptMatrixMock.mockClear(); }); @@ -67,10 +78,10 @@ describe("registerMatrixMonitorEvents", () => { it("sends read receipt immediately for non-self messages", async () => { const { client, onRoomMessage, roomMessageHandler } = createHarness(); - const event = { + const event = makeEvent({ event_id: "$e1", sender: "@alice:example.org", - } as MatrixRawEvent; + }); roomMessageHandler("!room:example.org", event); @@ -81,22 +92,27 @@ describe("registerMatrixMonitorEvents", () => { }); it("does not send read receipts for self messages", async () => { - await expectForwardedWithoutReadReceipt({ - event_id: "$e2", - sender: "@bot:example.org", - }); + await expectForwardedWithoutReadReceipt( + makeEvent({ + event_id: "$e2", + sender: "@bot:example.org", + }), + ); }); it("skips receipt when message lacks sender or event id", async () => { - await expectForwardedWithoutReadReceipt({ - sender: "@alice:example.org", - }); + await expectForwardedWithoutReadReceipt( + makeEvent({ + sender: "@alice:example.org", + event_id: "", + }), + ); }); it("caches self user id across messages", async () => { const { getUserId, roomMessageHandler } = createHarness(); - const first = { event_id: "$e3", sender: "@alice:example.org" } as MatrixRawEvent; - const second = { event_id: "$e4", sender: "@bob:example.org" } as MatrixRawEvent; + const first = makeEvent({ event_id: "$e3", sender: "@alice:example.org" }); + const second = makeEvent({ event_id: "$e4", sender: "@bob:example.org" }); roomMessageHandler("!room:example.org", first); roomMessageHandler("!room:example.org", second); @@ -110,7 +126,7 @@ describe("registerMatrixMonitorEvents", () => { it("logs and continues when sending read receipt fails", async () => { sendReadReceiptMatrixMock.mockRejectedValueOnce(new Error("network boom")); const { roomMessageHandler, onRoomMessage, logVerboseMessage } = createHarness(); - const event = { event_id: "$e5", sender: "@alice:example.org" } as MatrixRawEvent; + const event = makeEvent({ event_id: "$e5", sender: "@alice:example.org" }); roomMessageHandler("!room:example.org", event); @@ -126,7 +142,7 @@ describe("registerMatrixMonitorEvents", () => { const { roomMessageHandler, onRoomMessage, getUserId } = createHarness({ getUserId: vi.fn().mockRejectedValue(new Error("cannot resolve self")), }); - const event = { event_id: "$e6", sender: "@alice:example.org" } as MatrixRawEvent; + const event = makeEvent({ event_id: "$e6", sender: "@alice:example.org" }); roomMessageHandler("!room:example.org", event); diff --git a/extensions/nostr/src/nostr-profile-http.test.ts b/extensions/nostr/src/nostr-profile-http.test.ts index 745ba8baed5..3caa739c6c1 100644 --- a/extensions/nostr/src/nostr-profile-http.test.ts +++ b/extensions/nostr/src/nostr-profile-http.test.ts @@ -115,6 +115,13 @@ function createMockContext(overrides?: Partial): NostrP }; } +function expectOkResponse(res: ReturnType) { + expect(res._getStatusCode()).toBe(200); + const data = JSON.parse(res._getData()); + expect(data.ok).toBe(true); + return data; +} + function mockSuccessfulProfileImport() { vi.mocked(importProfileFromRelays).mockResolvedValue({ ok: true, @@ -217,13 +224,6 @@ describe("nostr-profile-http", () => { }); } - function expectOkResponse(res: ReturnType) { - expect(res._getStatusCode()).toBe(200); - const data = JSON.parse(res._getData()); - expect(data.ok).toBe(true); - return data; - } - function expectBadRequestResponse(res: ReturnType) { expect(res._getStatusCode()).toBe(400); const data = JSON.parse(res._getData()); diff --git a/extensions/synology-chat/src/webhook-handler.test.ts b/extensions/synology-chat/src/webhook-handler.test.ts index a0b67d49aad..ae5bd061b85 100644 --- a/extensions/synology-chat/src/webhook-handler.test.ts +++ b/extensions/synology-chat/src/webhook-handler.test.ts @@ -2,6 +2,7 @@ import { EventEmitter } from "node:events"; import type { IncomingMessage, ServerResponse } from "node:http"; import { describe, it, expect, vi, beforeEach } from "vitest"; import type { ResolvedSynologyChatAccount } from "./types.js"; +import type { WebhookHandlerDeps } from "./webhook-handler.js"; import { clearSynologyWebhookRateLimiterStateForTest, createWebhookHandler, @@ -118,7 +119,7 @@ describe("createWebhookHandler", () => { async function expectForbiddenByPolicy(params: { account: Partial; bodyContains: string; - deliver?: ReturnType; + deliver?: WebhookHandlerDeps["deliver"]; }) { const deliver = params.deliver ?? vi.fn(); const handler = createWebhookHandler({ diff --git a/extensions/zalo/src/monitor.ts b/extensions/zalo/src/monitor.ts index 2c5c420ce60..d82c0d96ba4 100644 --- a/extensions/zalo/src/monitor.ts +++ b/extensions/zalo/src/monitor.ts @@ -289,7 +289,7 @@ async function handleTextMessage( } async function handleImageMessage(params: ZaloImageMessageParams): Promise { - const { message, mediaMaxMb } = params; + const { message, mediaMaxMb, account, core, runtime } = params; const { photo, caption } = message; let mediaPath: string | undefined; diff --git a/extensions/zalo/src/send.ts b/extensions/zalo/src/send.ts index c6380a3b891..4f35f242191 100644 --- a/extensions/zalo/src/send.ts +++ b/extensions/zalo/src/send.ts @@ -77,21 +77,14 @@ function resolveValidatedSendContext( return { ok: true, chatId: trimmedChatId, token, fetcher }; } -function toInvalidContextResult( - context: ReturnType, -): ZaloSendResult | null { - return context.ok ? null : { ok: false, error: context.error }; -} - export async function sendMessageZalo( chatId: string, text: string, options: ZaloSendOptions = {}, ): Promise { const context = resolveValidatedSendContext(chatId, options); - const invalidResult = toInvalidContextResult(context); - if (invalidResult) { - return invalidResult; + if (!context.ok) { + return { ok: false, error: context.error }; } if (options.mediaUrl) { @@ -120,9 +113,8 @@ export async function sendPhotoZalo( options: ZaloSendOptions = {}, ): Promise { const context = resolveValidatedSendContext(chatId, options); - const invalidResult = toInvalidContextResult(context); - if (invalidResult) { - return invalidResult; + if (!context.ok) { + return { ok: false, error: context.error }; } if (!photoUrl?.trim()) { diff --git a/src/commands/onboard-non-interactive.gateway.test.ts b/src/commands/onboard-non-interactive.gateway.test.ts index 23684eb5f5a..5396b20b9d6 100644 --- a/src/commands/onboard-non-interactive.gateway.test.ts +++ b/src/commands/onboard-non-interactive.gateway.test.ts @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import type { RuntimeEnv } from "../runtime.js"; import { makeTempWorkspace } from "../test-helpers/workspace.js"; import { captureEnv } from "../test-utils/env.js"; import { createThrowingRuntime, readJsonFile } from "./onboard-non-interactive.test-helpers.js"; @@ -408,11 +409,17 @@ describe("onboard (non-interactive): gateway and remote auth", () => { })); let capturedError = ""; - const runtimeWithCapture = { + const runtimeWithCapture: RuntimeEnv = { log: () => {}, - error: (message: string) => { - capturedError = message; - throw new Error(message); + error: (...args: unknown[]) => { + const firstArg = args[0]; + capturedError = + typeof firstArg === "string" + ? firstArg + : firstArg instanceof Error + ? firstArg.message + : (JSON.stringify(firstArg) ?? ""); + throw new Error(capturedError); }, exit: (_code: number) => { throw new Error("exit should not be reached after runtime.error"); diff --git a/src/node-host/invoke-system-run.ts b/src/node-host/invoke-system-run.ts index 32bd2d6ff79..c38094dc683 100644 --- a/src/node-host/invoke-system-run.ts +++ b/src/node-host/invoke-system-run.ts @@ -34,6 +34,7 @@ import { } from "./invoke-system-run-plan.js"; import type { ExecEventPayload, + ExecFinishedResult, ExecFinishedEventParams, RunResult, SkillBinsProvider, diff --git a/src/slack/monitor/events/messages.test.ts b/src/slack/monitor/events/messages.test.ts index 25fdb77c025..f22b24a44c7 100644 --- a/src/slack/monitor/events/messages.test.ts +++ b/src/slack/monitor/events/messages.test.ts @@ -17,7 +17,6 @@ vi.mock("../../../pairing/pairing-store.js", () => ({ })); type MessageHandler = (args: { event: Record; body: unknown }) => Promise; -type AppMentionHandler = MessageHandler; type RegisteredEventName = "message" | "app_mention"; type MessageCase = { From f59b2b1db32f566514f321efc6c0a546fd33c749 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 13 Mar 2026 18:10:55 -0400 Subject: [PATCH 104/461] fix(browser): normalize batch act dispatch for selector and batch support (#45457) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(browser): add batch actions, CSS selector support, and click delayMs Adds three improvements to the browser act tool: 1. CSS selector support: All element-targeting actions (click, type, hover, drag, scrollIntoView, select) now accept an optional 'selector' parameter alongside 'ref'. When selector is provided, Playwright's page.locator() is used directly, skipping the need for a snapshot to obtain refs. This reduces roundtrips for agents that already know the DOM structure. 2. Click delay (delayMs): The click action now accepts an optional 'delayMs' parameter. When set, the element is hovered first, then after the specified delay, clicked. This enables human-like hover-before-click in a single tool call instead of three (hover + wait + click). 3. Batch actions: New 'batch' action kind that accepts an array of actions to execute sequentially in a single tool call. Supports 'stopOnError' (default true) to control whether execution halts on first failure. Results are returned as an array. This eliminates the AI inference roundtrip between each action, dramatically reducing latency and token cost for multi-step flows. Addresses: #44431, #38844 * fix(browser): address security review — batch evaluateEnabled guard, input validation, recursion limit Fixes all 4 issues raised by Greptile review: 1. Security: batch actions now respect evaluateEnabled flag. executeSingleAction and batchViaPlaywright accept evaluateEnabled param. evaluate and wait-with-fn inside batches are rejected when evaluateEnabled=false, matching the direct route guards. 2. Security: batch input validation. Each action in body.actions is validated as a plain object with a known kind string before dispatch. Applies same normalization as direct action handlers. 3. Perf: SELECTOR_ALLOWED_KINDS moved to module scope as a ReadonlySet constant (was re-created on every request). 4. Security: max batch nesting depth of 5. Nested batch actions track depth and throw if MAX_BATCH_DEPTH exceeded, preventing call stack exhaustion from crafted payloads. * fix(browser): normalize batch act dispatch * fix(browser): tighten existing-session act typing * fix(browser): preserve batch type text * fix(browser): complete batch action execution * test(browser): cover batch route normalization * test(browser): cover batch interaction dispatch * fix(browser): bound batch route action inputs * fix(browser): harden batch interaction limits * test(browser): cover batch security guardrails --------- Co-authored-by: Diwakar --- CHANGELOG.md | 1 + src/browser/client-actions-core.ts | 36 +- src/browser/client.test.ts | 3 +- src/browser/pw-ai.ts | 1 + .../pw-tools-core.interactions.batch.test.ts | 85 +++ src/browser/pw-tools-core.interactions.ts | 305 +++++++++-- src/browser/pw-tools-core.shared.ts | 15 + src/browser/routes/agent.act.shared.ts | 1 + src/browser/routes/agent.act.ts | 492 ++++++++++++++++-- ...-contract-form-layout-act-commands.test.ts | 186 ++++++- .../server.control-server.test-harness.ts | 3 + 11 files changed, 1029 insertions(+), 99 deletions(-) create mode 100644 src/browser/pw-tools-core.interactions.batch.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 19d9bb2347c..fcf8d4862d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai - Docker/timezone override: add `OPENCLAW_TZ` so `docker-setup.sh` can pin gateway and CLI containers to a chosen IANA timezone instead of inheriting the daemon default. (#34119) Thanks @Lanfei. - iOS/onboarding: add a first-run welcome pager before gateway setup, stop auto-opening the QR scanner, and show `/pair qr` instructions on the connect step. (#45054) Thanks @ngutman. - Browser/existing-session: add an official Chrome DevTools MCP attach mode for signed-in live Chrome sessions, with docs for `chrome://inspect/#remote-debugging` enablement and direct backlinks to Chrome’s own setup guides. +- Browser/act automation: add batched actions, selector targeting, and delayed clicks for browser act requests with normalized batch dispatch. Thanks @vincentkoc. ### Fixes diff --git a/src/browser/client-actions-core.ts b/src/browser/client-actions-core.ts index 72e27cd9afa..149ca54fadf 100644 --- a/src/browser/client-actions-core.ts +++ b/src/browser/client-actions-core.ts @@ -15,16 +15,19 @@ export type BrowserFormField = { export type BrowserActRequest = | { kind: "click"; - ref: string; + ref?: string; + selector?: string; targetId?: string; doubleClick?: boolean; button?: string; modifiers?: string[]; + delayMs?: number; timeoutMs?: number; } | { kind: "type"; - ref: string; + ref?: string; + selector?: string; text: string; targetId?: string; submit?: boolean; @@ -32,23 +35,33 @@ export type BrowserActRequest = timeoutMs?: number; } | { kind: "press"; key: string; targetId?: string; delayMs?: number } - | { kind: "hover"; ref: string; targetId?: string; timeoutMs?: number } + | { + kind: "hover"; + ref?: string; + selector?: string; + targetId?: string; + timeoutMs?: number; + } | { kind: "scrollIntoView"; - ref: string; + ref?: string; + selector?: string; targetId?: string; timeoutMs?: number; } | { kind: "drag"; - startRef: string; - endRef: string; + startRef?: string; + startSelector?: string; + endRef?: string; + endSelector?: string; targetId?: string; timeoutMs?: number; } | { kind: "select"; - ref: string; + ref?: string; + selector?: string; values: string[]; targetId?: string; timeoutMs?: number; @@ -73,13 +86,20 @@ export type BrowserActRequest = timeoutMs?: number; } | { kind: "evaluate"; fn: string; ref?: string; targetId?: string; timeoutMs?: number } - | { kind: "close"; targetId?: string }; + | { kind: "close"; targetId?: string } + | { + kind: "batch"; + actions: BrowserActRequest[]; + targetId?: string; + stopOnError?: boolean; + }; export type BrowserActResponse = { ok: true; targetId: string; url?: string; result?: unknown; + results?: Array<{ ok: boolean; error?: string }>; }; export type BrowserDownloadPayload = { diff --git a/src/browser/client.test.ts b/src/browser/client.test.ts index a4f95c23007..64d37580e35 100644 --- a/src/browser/client.test.ts +++ b/src/browser/client.test.ts @@ -160,6 +160,7 @@ describe("browser client", () => { targetId: "t1", url: "https://x", result: 1, + results: [{ ok: true }], }), } as unknown as Response; } @@ -258,7 +259,7 @@ describe("browser client", () => { ).resolves.toMatchObject({ ok: true, targetId: "t1" }); await expect( browserAct("http://127.0.0.1:18791", { kind: "click", ref: "1" }), - ).resolves.toMatchObject({ ok: true, targetId: "t1" }); + ).resolves.toMatchObject({ ok: true, targetId: "t1", results: [{ ok: true }] }); await expect( browserArmFileChooser("http://127.0.0.1:18791", { paths: ["/tmp/a.txt"], diff --git a/src/browser/pw-ai.ts b/src/browser/pw-ai.ts index 6da8b410c83..f8d538b5394 100644 --- a/src/browser/pw-ai.ts +++ b/src/browser/pw-ai.ts @@ -19,6 +19,7 @@ export { export { armDialogViaPlaywright, armFileUploadViaPlaywright, + batchViaPlaywright, clickViaPlaywright, closePageViaPlaywright, cookiesClearViaPlaywright, diff --git a/src/browser/pw-tools-core.interactions.batch.test.ts b/src/browser/pw-tools-core.interactions.batch.test.ts new file mode 100644 index 00000000000..2801ebe8190 --- /dev/null +++ b/src/browser/pw-tools-core.interactions.batch.test.ts @@ -0,0 +1,85 @@ +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +let page: { evaluate: ReturnType } | null = null; + +const getPageForTargetId = vi.fn(async () => { + if (!page) { + throw new Error("test: page not set"); + } + return page; +}); +const ensurePageState = vi.fn(() => {}); +const forceDisconnectPlaywrightForTarget = vi.fn(async () => {}); +const refLocator = vi.fn(() => { + throw new Error("test: refLocator should not be called"); +}); +const restoreRoleRefsForTarget = vi.fn(() => {}); + +const closePageViaPlaywright = vi.fn(async () => {}); +const resizeViewportViaPlaywright = vi.fn(async () => {}); + +vi.mock("./pw-session.js", () => ({ + ensurePageState, + forceDisconnectPlaywrightForTarget, + getPageForTargetId, + refLocator, + restoreRoleRefsForTarget, +})); + +vi.mock("./pw-tools-core.snapshot.js", () => ({ + closePageViaPlaywright, + resizeViewportViaPlaywright, +})); + +let batchViaPlaywright: typeof import("./pw-tools-core.interactions.js").batchViaPlaywright; + +describe("batchViaPlaywright", () => { + beforeAll(async () => { + ({ batchViaPlaywright } = await import("./pw-tools-core.interactions.js")); + }); + + beforeEach(() => { + vi.clearAllMocks(); + page = { + evaluate: vi.fn(async () => "ok"), + }; + }); + + it("propagates evaluate timeouts through batched execution", async () => { + const result = await batchViaPlaywright({ + cdpUrl: "http://127.0.0.1:9222", + targetId: "tab-1", + evaluateEnabled: true, + actions: [{ kind: "evaluate", fn: "() => 1", timeoutMs: 5000 }], + }); + + expect(result).toEqual({ results: [{ ok: true }] }); + expect(page?.evaluate).toHaveBeenCalledWith( + expect.any(Function), + expect.objectContaining({ + fnBody: "() => 1", + timeoutMs: 4500, + }), + ); + }); + + it("supports resize and close inside a batch", async () => { + const result = await batchViaPlaywright({ + cdpUrl: "http://127.0.0.1:9222", + targetId: "tab-1", + actions: [{ kind: "resize", width: 800, height: 600 }, { kind: "close" }], + }); + + expect(result).toEqual({ results: [{ ok: true }, { ok: true }] }); + expect(resizeViewportViaPlaywright).toHaveBeenCalledWith({ + cdpUrl: "http://127.0.0.1:9222", + targetId: "tab-1", + width: 800, + height: 600, + }); + expect(closePageViaPlaywright).toHaveBeenCalledWith({ + cdpUrl: "http://127.0.0.1:9222", + targetId: "tab-1", + }); + }); +}); diff --git a/src/browser/pw-tools-core.interactions.ts b/src/browser/pw-tools-core.interactions.ts index 852b11bb6dc..dee8a03316c 100644 --- a/src/browser/pw-tools-core.interactions.ts +++ b/src/browser/pw-tools-core.interactions.ts @@ -1,4 +1,4 @@ -import type { BrowserFormField } from "./client-actions-core.js"; +import type { BrowserActRequest, BrowserFormField } from "./client-actions-core.js"; import { DEFAULT_FILL_FIELD_TYPE } from "./form-fields.js"; import { DEFAULT_UPLOAD_DIR, resolveStrictExistingPathsWithinRoot } from "./paths.js"; import { @@ -8,12 +8,32 @@ import { refLocator, restoreRoleRefsForTarget, } from "./pw-session.js"; -import { normalizeTimeoutMs, requireRef, toAIFriendlyError } from "./pw-tools-core.shared.js"; +import { + normalizeTimeoutMs, + requireRef, + requireRefOrSelector, + toAIFriendlyError, +} from "./pw-tools-core.shared.js"; +import { closePageViaPlaywright, resizeViewportViaPlaywright } from "./pw-tools-core.snapshot.js"; type TargetOpts = { cdpUrl: string; targetId?: string; }; +const MAX_CLICK_DELAY_MS = 5_000; +const MAX_WAIT_TIME_MS = 30_000; +const MAX_BATCH_ACTIONS = 100; + +function resolveBoundedDelayMs(value: number | undefined, label: string, maxMs: number): number { + const normalized = Math.floor(value ?? 0); + if (!Number.isFinite(normalized) || normalized < 0) { + throw new Error(`${label} must be >= 0`); + } + if (normalized > maxMs) { + throw new Error(`${label} exceeds maximum of ${maxMs}ms`); + } + return normalized; +} async function getRestoredPageForTarget(opts: TargetOpts) { const page = await getPageForTargetId(opts); @@ -59,17 +79,27 @@ export async function highlightViaPlaywright(opts: { export async function clickViaPlaywright(opts: { cdpUrl: string; targetId?: string; - ref: string; + ref?: string; + selector?: string; doubleClick?: boolean; button?: "left" | "right" | "middle"; modifiers?: Array<"Alt" | "Control" | "ControlOrMeta" | "Meta" | "Shift">; + delayMs?: number; timeoutMs?: number; }): Promise { + const resolved = requireRefOrSelector(opts.ref, opts.selector); const page = await getRestoredPageForTarget(opts); - const ref = requireRef(opts.ref); - const locator = refLocator(page, ref); + const label = resolved.ref ?? resolved.selector!; + const locator = resolved.ref + ? refLocator(page, requireRef(resolved.ref)) + : page.locator(resolved.selector!); const timeout = resolveInteractionTimeoutMs(opts.timeoutMs); try { + const delayMs = resolveBoundedDelayMs(opts.delayMs, "click delayMs", MAX_CLICK_DELAY_MS); + if (delayMs > 0) { + await locator.hover({ timeout }); + await new Promise((r) => setTimeout(r, delayMs)); + } if (opts.doubleClick) { await locator.dblclick({ timeout, @@ -84,67 +114,84 @@ export async function clickViaPlaywright(opts: { }); } } catch (err) { - throw toAIFriendlyError(err, ref); + throw toAIFriendlyError(err, label); } } export async function hoverViaPlaywright(opts: { cdpUrl: string; targetId?: string; - ref: string; + ref?: string; + selector?: string; timeoutMs?: number; }): Promise { - const ref = requireRef(opts.ref); + const resolved = requireRefOrSelector(opts.ref, opts.selector); const page = await getRestoredPageForTarget(opts); + const label = resolved.ref ?? resolved.selector!; + const locator = resolved.ref + ? refLocator(page, requireRef(resolved.ref)) + : page.locator(resolved.selector!); try { - await refLocator(page, ref).hover({ + await locator.hover({ timeout: resolveInteractionTimeoutMs(opts.timeoutMs), }); } catch (err) { - throw toAIFriendlyError(err, ref); + throw toAIFriendlyError(err, label); } } export async function dragViaPlaywright(opts: { cdpUrl: string; targetId?: string; - startRef: string; - endRef: string; + startRef?: string; + startSelector?: string; + endRef?: string; + endSelector?: string; timeoutMs?: number; }): Promise { - const startRef = requireRef(opts.startRef); - const endRef = requireRef(opts.endRef); - if (!startRef || !endRef) { - throw new Error("startRef and endRef are required"); - } + const resolvedStart = requireRefOrSelector(opts.startRef, opts.startSelector); + const resolvedEnd = requireRefOrSelector(opts.endRef, opts.endSelector); const page = await getRestoredPageForTarget(opts); + const startLocator = resolvedStart.ref + ? refLocator(page, requireRef(resolvedStart.ref)) + : page.locator(resolvedStart.selector!); + const endLocator = resolvedEnd.ref + ? refLocator(page, requireRef(resolvedEnd.ref)) + : page.locator(resolvedEnd.selector!); + const startLabel = resolvedStart.ref ?? resolvedStart.selector!; + const endLabel = resolvedEnd.ref ?? resolvedEnd.selector!; try { - await refLocator(page, startRef).dragTo(refLocator(page, endRef), { + await startLocator.dragTo(endLocator, { timeout: resolveInteractionTimeoutMs(opts.timeoutMs), }); } catch (err) { - throw toAIFriendlyError(err, `${startRef} -> ${endRef}`); + throw toAIFriendlyError(err, `${startLabel} -> ${endLabel}`); } } export async function selectOptionViaPlaywright(opts: { cdpUrl: string; targetId?: string; - ref: string; + ref?: string; + selector?: string; values: string[]; timeoutMs?: number; }): Promise { - const ref = requireRef(opts.ref); + const resolved = requireRefOrSelector(opts.ref, opts.selector); if (!opts.values?.length) { throw new Error("values are required"); } const page = await getRestoredPageForTarget(opts); + const label = resolved.ref ?? resolved.selector!; + const locator = resolved.ref + ? refLocator(page, requireRef(resolved.ref)) + : page.locator(resolved.selector!); try { - await refLocator(page, ref).selectOption(opts.values, { + await locator.selectOption(opts.values, { timeout: resolveInteractionTimeoutMs(opts.timeoutMs), }); } catch (err) { - throw toAIFriendlyError(err, ref); + throw toAIFriendlyError(err, label); } } @@ -168,16 +215,20 @@ export async function pressKeyViaPlaywright(opts: { export async function typeViaPlaywright(opts: { cdpUrl: string; targetId?: string; - ref: string; + ref?: string; + selector?: string; text: string; submit?: boolean; slowly?: boolean; timeoutMs?: number; }): Promise { + const resolved = requireRefOrSelector(opts.ref, opts.selector); const text = String(opts.text ?? ""); const page = await getRestoredPageForTarget(opts); - const ref = requireRef(opts.ref); - const locator = refLocator(page, ref); + const label = resolved.ref ?? resolved.selector!; + const locator = resolved.ref + ? refLocator(page, requireRef(resolved.ref)) + : page.locator(resolved.selector!); const timeout = resolveInteractionTimeoutMs(opts.timeoutMs); try { if (opts.slowly) { @@ -190,7 +241,7 @@ export async function typeViaPlaywright(opts: { await locator.press("Enter", { timeout }); } } catch (err) { - throw toAIFriendlyError(err, ref); + throw toAIFriendlyError(err, label); } } @@ -367,18 +418,22 @@ export async function evaluateViaPlaywright(opts: { export async function scrollIntoViewViaPlaywright(opts: { cdpUrl: string; targetId?: string; - ref: string; + ref?: string; + selector?: string; timeoutMs?: number; }): Promise { + const resolved = requireRefOrSelector(opts.ref, opts.selector); const page = await getRestoredPageForTarget(opts); const timeout = normalizeTimeoutMs(opts.timeoutMs, 20_000); - const ref = requireRef(opts.ref); - const locator = refLocator(page, ref); + const label = resolved.ref ?? resolved.selector!; + const locator = resolved.ref + ? refLocator(page, requireRef(resolved.ref)) + : page.locator(resolved.selector!); try { await locator.scrollIntoViewIfNeeded({ timeout }); } catch (err) { - throw toAIFriendlyError(err, ref); + throw toAIFriendlyError(err, label); } } @@ -399,7 +454,7 @@ export async function waitForViaPlaywright(opts: { const timeout = normalizeTimeoutMs(opts.timeoutMs, 20_000); if (typeof opts.timeMs === "number" && Number.isFinite(opts.timeMs)) { - await page.waitForTimeout(Math.max(0, opts.timeMs)); + await page.waitForTimeout(resolveBoundedDelayMs(opts.timeMs, "wait timeMs", MAX_WAIT_TIME_MS)); } if (opts.text) { await page.getByText(opts.text).first().waitFor({ @@ -648,3 +703,189 @@ export async function setInputFilesViaPlaywright(opts: { // Best-effort for sites that don't react to setInputFiles alone. } } + +const MAX_BATCH_DEPTH = 5; + +async function executeSingleAction( + action: BrowserActRequest, + cdpUrl: string, + targetId?: string, + evaluateEnabled?: boolean, + depth = 0, +): Promise { + if (depth > MAX_BATCH_DEPTH) { + throw new Error(`Batch nesting depth exceeds maximum of ${MAX_BATCH_DEPTH}`); + } + const effectiveTargetId = action.targetId ?? targetId; + switch (action.kind) { + case "click": + await clickViaPlaywright({ + cdpUrl, + targetId: effectiveTargetId, + ref: action.ref, + selector: action.selector, + doubleClick: action.doubleClick, + button: action.button as "left" | "right" | "middle" | undefined, + modifiers: action.modifiers as Array< + "Alt" | "Control" | "ControlOrMeta" | "Meta" | "Shift" + >, + delayMs: action.delayMs, + timeoutMs: action.timeoutMs, + }); + break; + case "type": + await typeViaPlaywright({ + cdpUrl, + targetId: effectiveTargetId, + ref: action.ref, + selector: action.selector, + text: action.text, + submit: action.submit, + slowly: action.slowly, + timeoutMs: action.timeoutMs, + }); + break; + case "press": + await pressKeyViaPlaywright({ + cdpUrl, + targetId: effectiveTargetId, + key: action.key, + delayMs: action.delayMs, + }); + break; + case "hover": + await hoverViaPlaywright({ + cdpUrl, + targetId: effectiveTargetId, + ref: action.ref, + selector: action.selector, + timeoutMs: action.timeoutMs, + }); + break; + case "scrollIntoView": + await scrollIntoViewViaPlaywright({ + cdpUrl, + targetId: effectiveTargetId, + ref: action.ref, + selector: action.selector, + timeoutMs: action.timeoutMs, + }); + break; + case "drag": + await dragViaPlaywright({ + cdpUrl, + targetId: effectiveTargetId, + startRef: action.startRef, + startSelector: action.startSelector, + endRef: action.endRef, + endSelector: action.endSelector, + timeoutMs: action.timeoutMs, + }); + break; + case "select": + await selectOptionViaPlaywright({ + cdpUrl, + targetId: effectiveTargetId, + ref: action.ref, + selector: action.selector, + values: action.values, + timeoutMs: action.timeoutMs, + }); + break; + case "fill": + await fillFormViaPlaywright({ + cdpUrl, + targetId: effectiveTargetId, + fields: action.fields, + timeoutMs: action.timeoutMs, + }); + break; + case "resize": + await resizeViewportViaPlaywright({ + cdpUrl, + targetId: effectiveTargetId, + width: action.width, + height: action.height, + }); + break; + case "wait": + if (action.fn && !evaluateEnabled) { + throw new Error("wait --fn is disabled by config (browser.evaluateEnabled=false)"); + } + await waitForViaPlaywright({ + cdpUrl, + targetId: effectiveTargetId, + timeMs: action.timeMs, + text: action.text, + textGone: action.textGone, + selector: action.selector, + url: action.url, + loadState: action.loadState, + fn: action.fn, + timeoutMs: action.timeoutMs, + }); + break; + case "evaluate": + if (!evaluateEnabled) { + throw new Error("act:evaluate is disabled by config (browser.evaluateEnabled=false)"); + } + await evaluateViaPlaywright({ + cdpUrl, + targetId: effectiveTargetId, + fn: action.fn, + ref: action.ref, + timeoutMs: action.timeoutMs, + }); + break; + case "close": + await closePageViaPlaywright({ + cdpUrl, + targetId: effectiveTargetId, + }); + break; + case "batch": + // Nested batches: delegate recursively + await batchViaPlaywright({ + cdpUrl, + targetId: effectiveTargetId, + actions: action.actions, + stopOnError: action.stopOnError, + evaluateEnabled, + depth: depth + 1, + }); + break; + default: + throw new Error(`Unsupported batch action kind: ${(action as { kind: string }).kind}`); + } +} + +export async function batchViaPlaywright(opts: { + cdpUrl: string; + targetId?: string; + actions: BrowserActRequest[]; + stopOnError?: boolean; + evaluateEnabled?: boolean; + depth?: number; +}): Promise<{ results: Array<{ ok: boolean; error?: string }> }> { + const depth = opts.depth ?? 0; + if (depth > MAX_BATCH_DEPTH) { + throw new Error(`Batch nesting depth exceeds maximum of ${MAX_BATCH_DEPTH}`); + } + if (opts.actions.length > MAX_BATCH_ACTIONS) { + throw new Error(`Batch exceeds maximum of ${MAX_BATCH_ACTIONS} actions`); + } + const results: Array<{ ok: boolean; error?: string }> = []; + for (const action of opts.actions) { + try { + await executeSingleAction(action, opts.cdpUrl, opts.targetId, opts.evaluateEnabled, depth); + results.push({ ok: true }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + results.push({ ok: false, error: message }); + if (opts.stopOnError !== false) { + break; + } + } + } + return { results }; +} diff --git a/src/browser/pw-tools-core.shared.ts b/src/browser/pw-tools-core.shared.ts index d5ad74477d4..b6132de92bf 100644 --- a/src/browser/pw-tools-core.shared.ts +++ b/src/browser/pw-tools-core.shared.ts @@ -29,6 +29,21 @@ export function requireRef(value: unknown): string { return ref; } +export function requireRefOrSelector( + ref: string | undefined, + selector: string | undefined, +): { ref?: string; selector?: string } { + const trimmedRef = typeof ref === "string" ? ref.trim() : ""; + const trimmedSelector = typeof selector === "string" ? selector.trim() : ""; + if (!trimmedRef && !trimmedSelector) { + throw new Error("ref or selector is required"); + } + return { + ref: trimmedRef || undefined, + selector: trimmedSelector || undefined, + }; +} + export function normalizeTimeoutMs(timeoutMs: number | undefined, fallback: number) { return Math.max(500, Math.min(120_000, timeoutMs ?? fallback)); } diff --git a/src/browser/routes/agent.act.shared.ts b/src/browser/routes/agent.act.shared.ts index 81ca8caab71..b22f35e7ef2 100644 --- a/src/browser/routes/agent.act.shared.ts +++ b/src/browser/routes/agent.act.shared.ts @@ -1,4 +1,5 @@ export const ACT_KINDS = [ + "batch", "click", "close", "drag", diff --git a/src/browser/routes/agent.act.ts b/src/browser/routes/agent.act.ts index 8928a8a7d06..0c4c9e71967 100644 --- a/src/browser/routes/agent.act.ts +++ b/src/browser/routes/agent.act.ts @@ -9,7 +9,7 @@ import { pressChromeMcpKey, resizeChromeMcpPage, } from "../chrome-mcp.js"; -import type { BrowserFormField } from "../client-actions-core.js"; +import type { BrowserActRequest, BrowserFormField } from "../client-actions-core.js"; import { normalizeBrowserFormField } from "../form-fields.js"; import type { BrowserRouteContext } from "../server-context.js"; import { registerBrowserAgentActDownloadRoutes } from "./agent.act.download.js"; @@ -104,6 +104,326 @@ async function waitForExistingSessionCondition(params: { throw new Error("Timed out waiting for condition"); } +const SELECTOR_ALLOWED_KINDS: ReadonlySet = new Set([ + "batch", + "click", + "drag", + "hover", + "scrollIntoView", + "select", + "type", + "wait", +]); +const MAX_BATCH_ACTIONS = 100; +const MAX_BATCH_CLICK_DELAY_MS = 5_000; +const MAX_BATCH_WAIT_TIME_MS = 30_000; + +function normalizeBoundedNonNegativeMs( + value: unknown, + fieldName: string, + maxMs: number, +): number | undefined { + const ms = toNumber(value); + if (ms === undefined) { + return undefined; + } + if (ms < 0) { + throw new Error(`${fieldName} must be >= 0`); + } + const normalized = Math.floor(ms); + if (normalized > maxMs) { + throw new Error(`${fieldName} exceeds maximum of ${maxMs}ms`); + } + return normalized; +} + +function countBatchActions(actions: BrowserActRequest[]): number { + let count = 0; + for (const action of actions) { + count += 1; + if (action.kind === "batch") { + count += countBatchActions(action.actions); + } + } + return count; +} + +function validateBatchTargetIds(actions: BrowserActRequest[], targetId: string): string | null { + for (const action of actions) { + if (action.targetId && action.targetId !== targetId) { + return "batched action targetId must match request targetId"; + } + if (action.kind === "batch") { + const nestedError = validateBatchTargetIds(action.actions, targetId); + if (nestedError) { + return nestedError; + } + } + } + return null; +} + +function normalizeBatchAction(value: unknown): BrowserActRequest { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("batch actions must be objects"); + } + const raw = value as Record; + const kind = toStringOrEmpty(raw.kind); + if (!isActKind(kind)) { + throw new Error("batch actions must use a supported kind"); + } + + switch (kind) { + case "click": { + const ref = toStringOrEmpty(raw.ref) || undefined; + const selector = toStringOrEmpty(raw.selector) || undefined; + if (!ref && !selector) { + throw new Error("click requires ref or selector"); + } + const buttonRaw = toStringOrEmpty(raw.button); + const button = buttonRaw ? parseClickButton(buttonRaw) : undefined; + if (buttonRaw && !button) { + throw new Error("click button must be left|right|middle"); + } + const modifiersRaw = toStringArray(raw.modifiers) ?? []; + const parsedModifiers = parseClickModifiers(modifiersRaw); + if (parsedModifiers.error) { + throw new Error(parsedModifiers.error); + } + const doubleClick = toBoolean(raw.doubleClick); + const delayMs = normalizeBoundedNonNegativeMs( + raw.delayMs, + "click delayMs", + MAX_BATCH_CLICK_DELAY_MS, + ); + const timeoutMs = toNumber(raw.timeoutMs); + const targetId = toStringOrEmpty(raw.targetId) || undefined; + return { + kind, + ...(ref ? { ref } : {}), + ...(selector ? { selector } : {}), + ...(targetId ? { targetId } : {}), + ...(doubleClick !== undefined ? { doubleClick } : {}), + ...(button ? { button } : {}), + ...(parsedModifiers.modifiers ? { modifiers: parsedModifiers.modifiers } : {}), + ...(delayMs !== undefined ? { delayMs } : {}), + ...(timeoutMs !== undefined ? { timeoutMs } : {}), + }; + } + case "type": { + const ref = toStringOrEmpty(raw.ref) || undefined; + const selector = toStringOrEmpty(raw.selector) || undefined; + const text = raw.text; + if (!ref && !selector) { + throw new Error("type requires ref or selector"); + } + if (typeof text !== "string") { + throw new Error("type requires text"); + } + const targetId = toStringOrEmpty(raw.targetId) || undefined; + const submit = toBoolean(raw.submit); + const slowly = toBoolean(raw.slowly); + const timeoutMs = toNumber(raw.timeoutMs); + return { + kind, + ...(ref ? { ref } : {}), + ...(selector ? { selector } : {}), + text, + ...(targetId ? { targetId } : {}), + ...(submit !== undefined ? { submit } : {}), + ...(slowly !== undefined ? { slowly } : {}), + ...(timeoutMs !== undefined ? { timeoutMs } : {}), + }; + } + case "press": { + const key = toStringOrEmpty(raw.key); + if (!key) { + throw new Error("press requires key"); + } + const targetId = toStringOrEmpty(raw.targetId) || undefined; + const delayMs = toNumber(raw.delayMs); + return { + kind, + key, + ...(targetId ? { targetId } : {}), + ...(delayMs !== undefined ? { delayMs } : {}), + }; + } + case "hover": + case "scrollIntoView": { + const ref = toStringOrEmpty(raw.ref) || undefined; + const selector = toStringOrEmpty(raw.selector) || undefined; + if (!ref && !selector) { + throw new Error(`${kind} requires ref or selector`); + } + const targetId = toStringOrEmpty(raw.targetId) || undefined; + const timeoutMs = toNumber(raw.timeoutMs); + return { + kind, + ...(ref ? { ref } : {}), + ...(selector ? { selector } : {}), + ...(targetId ? { targetId } : {}), + ...(timeoutMs !== undefined ? { timeoutMs } : {}), + }; + } + case "drag": { + const startRef = toStringOrEmpty(raw.startRef) || undefined; + const startSelector = toStringOrEmpty(raw.startSelector) || undefined; + const endRef = toStringOrEmpty(raw.endRef) || undefined; + const endSelector = toStringOrEmpty(raw.endSelector) || undefined; + if (!startRef && !startSelector) { + throw new Error("drag requires startRef or startSelector"); + } + if (!endRef && !endSelector) { + throw new Error("drag requires endRef or endSelector"); + } + const targetId = toStringOrEmpty(raw.targetId) || undefined; + const timeoutMs = toNumber(raw.timeoutMs); + return { + kind, + ...(startRef ? { startRef } : {}), + ...(startSelector ? { startSelector } : {}), + ...(endRef ? { endRef } : {}), + ...(endSelector ? { endSelector } : {}), + ...(targetId ? { targetId } : {}), + ...(timeoutMs !== undefined ? { timeoutMs } : {}), + }; + } + case "select": { + const ref = toStringOrEmpty(raw.ref) || undefined; + const selector = toStringOrEmpty(raw.selector) || undefined; + const values = toStringArray(raw.values); + if ((!ref && !selector) || !values?.length) { + throw new Error("select requires ref/selector and values"); + } + const targetId = toStringOrEmpty(raw.targetId) || undefined; + const timeoutMs = toNumber(raw.timeoutMs); + return { + kind, + ...(ref ? { ref } : {}), + ...(selector ? { selector } : {}), + values, + ...(targetId ? { targetId } : {}), + ...(timeoutMs !== undefined ? { timeoutMs } : {}), + }; + } + case "fill": { + const rawFields = Array.isArray(raw.fields) ? raw.fields : []; + const fields = rawFields + .map((field) => { + if (!field || typeof field !== "object") { + return null; + } + return normalizeBrowserFormField(field as Record); + }) + .filter((field): field is BrowserFormField => field !== null); + if (!fields.length) { + throw new Error("fill requires fields"); + } + const targetId = toStringOrEmpty(raw.targetId) || undefined; + const timeoutMs = toNumber(raw.timeoutMs); + return { + kind, + fields, + ...(targetId ? { targetId } : {}), + ...(timeoutMs !== undefined ? { timeoutMs } : {}), + }; + } + case "resize": { + const width = toNumber(raw.width); + const height = toNumber(raw.height); + if (width === undefined || height === undefined) { + throw new Error("resize requires width and height"); + } + const targetId = toStringOrEmpty(raw.targetId) || undefined; + return { + kind, + width, + height, + ...(targetId ? { targetId } : {}), + }; + } + case "wait": { + const loadStateRaw = toStringOrEmpty(raw.loadState); + const loadState = + loadStateRaw === "load" || + loadStateRaw === "domcontentloaded" || + loadStateRaw === "networkidle" + ? loadStateRaw + : undefined; + const timeMs = normalizeBoundedNonNegativeMs( + raw.timeMs, + "wait timeMs", + MAX_BATCH_WAIT_TIME_MS, + ); + const text = toStringOrEmpty(raw.text) || undefined; + const textGone = toStringOrEmpty(raw.textGone) || undefined; + const selector = toStringOrEmpty(raw.selector) || undefined; + const url = toStringOrEmpty(raw.url) || undefined; + const fn = toStringOrEmpty(raw.fn) || undefined; + if (timeMs === undefined && !text && !textGone && !selector && !url && !loadState && !fn) { + throw new Error( + "wait requires at least one of: timeMs, text, textGone, selector, url, loadState, fn", + ); + } + const targetId = toStringOrEmpty(raw.targetId) || undefined; + const timeoutMs = toNumber(raw.timeoutMs); + return { + kind, + ...(timeMs !== undefined ? { timeMs } : {}), + ...(text ? { text } : {}), + ...(textGone ? { textGone } : {}), + ...(selector ? { selector } : {}), + ...(url ? { url } : {}), + ...(loadState ? { loadState } : {}), + ...(fn ? { fn } : {}), + ...(targetId ? { targetId } : {}), + ...(timeoutMs !== undefined ? { timeoutMs } : {}), + }; + } + case "evaluate": { + const fn = toStringOrEmpty(raw.fn); + if (!fn) { + throw new Error("evaluate requires fn"); + } + const ref = toStringOrEmpty(raw.ref) || undefined; + const targetId = toStringOrEmpty(raw.targetId) || undefined; + const timeoutMs = toNumber(raw.timeoutMs); + return { + kind, + fn, + ...(ref ? { ref } : {}), + ...(targetId ? { targetId } : {}), + ...(timeoutMs !== undefined ? { timeoutMs } : {}), + }; + } + case "close": { + const targetId = toStringOrEmpty(raw.targetId) || undefined; + return { + kind, + ...(targetId ? { targetId } : {}), + }; + } + case "batch": { + const actions = Array.isArray(raw.actions) ? raw.actions.map(normalizeBatchAction) : []; + if (!actions.length) { + throw new Error("batch requires actions"); + } + if (countBatchActions(actions) > MAX_BATCH_ACTIONS) { + throw new Error(`batch exceeds maximum of ${MAX_BATCH_ACTIONS} actions`); + } + const targetId = toStringOrEmpty(raw.targetId) || undefined; + const stopOnError = toBoolean(raw.stopOnError); + return { + kind, + actions, + ...(targetId ? { targetId } : {}), + ...(stopOnError !== undefined ? { stopOnError } : {}), + }; + } + } +} + export function registerBrowserAgentActRoutes( app: BrowserRouteRegistrar, ctx: BrowserRouteContext, @@ -116,7 +436,7 @@ export function registerBrowserAgentActRoutes( } const kind: ActKind = kindRaw; const targetId = resolveTargetIdFromBody(body); - if (Object.hasOwn(body, "selector") && kind !== "wait") { + if (Object.hasOwn(body, "selector") && !SELECTOR_ALLOWED_KINDS.has(kind)) { return jsonError(res, 400, SELECTOR_UNSUPPORTED_MESSAGE); } @@ -132,12 +452,14 @@ export function registerBrowserAgentActRoutes( switch (kind) { case "click": { - const ref = toStringOrEmpty(body.ref); - if (!ref) { - return jsonError(res, 400, "ref is required"); + const ref = toStringOrEmpty(body.ref) || undefined; + const selector = toStringOrEmpty(body.selector) || undefined; + if (!ref && !selector) { + return jsonError(res, 400, "ref or selector is required"); } const doubleClick = toBoolean(body.doubleClick) ?? false; const timeoutMs = toNumber(body.timeoutMs); + const delayMs = toNumber(body.delayMs); const buttonRaw = toStringOrEmpty(body.button) || ""; const button = buttonRaw ? parseClickButton(buttonRaw) : undefined; if (buttonRaw && !button) { @@ -151,6 +473,13 @@ export function registerBrowserAgentActRoutes( } const modifiers = parsedModifiers.modifiers; if (isExistingSession) { + if (selector) { + return jsonError( + res, + 501, + "existing-session click does not support selector targeting yet; use ref.", + ); + } if ((button && button !== "left") || (modifiers && modifiers.length > 0)) { return jsonError( res, @@ -161,7 +490,7 @@ export function registerBrowserAgentActRoutes( await clickChromeMcpElement({ profileName, targetId: tab.targetId, - uid: ref, + uid: ref!, doubleClick, }); return res.json({ ok: true, targetId: tab.targetId, url: tab.url }); @@ -173,15 +502,23 @@ export function registerBrowserAgentActRoutes( const clickRequest: Parameters[0] = { cdpUrl, targetId: tab.targetId, - ref, doubleClick, }; + if (ref) { + clickRequest.ref = ref; + } + if (selector) { + clickRequest.selector = selector; + } if (button) { clickRequest.button = button; } if (modifiers) { clickRequest.modifiers = modifiers; } + if (delayMs) { + clickRequest.delayMs = delayMs; + } if (timeoutMs) { clickRequest.timeoutMs = timeoutMs; } @@ -189,9 +526,10 @@ export function registerBrowserAgentActRoutes( return res.json({ ok: true, targetId: tab.targetId, url: tab.url }); } case "type": { - const ref = toStringOrEmpty(body.ref); - if (!ref) { - return jsonError(res, 400, "ref is required"); + const ref = toStringOrEmpty(body.ref) || undefined; + const selector = toStringOrEmpty(body.selector) || undefined; + if (!ref && !selector) { + return jsonError(res, 400, "ref or selector is required"); } if (typeof body.text !== "string") { return jsonError(res, 400, "text is required"); @@ -201,6 +539,13 @@ export function registerBrowserAgentActRoutes( const slowly = toBoolean(body.slowly) ?? false; const timeoutMs = toNumber(body.timeoutMs); if (isExistingSession) { + if (selector) { + return jsonError( + res, + 501, + "existing-session type does not support selector targeting yet; use ref.", + ); + } if (slowly) { return jsonError( res, @@ -211,7 +556,7 @@ export function registerBrowserAgentActRoutes( await fillChromeMcpElement({ profileName, targetId: tab.targetId, - uid: ref, + uid: ref!, value: text, }); if (submit) { @@ -230,11 +575,16 @@ export function registerBrowserAgentActRoutes( const typeRequest: Parameters[0] = { cdpUrl, targetId: tab.targetId, - ref, text, submit, slowly, }; + if (ref) { + typeRequest.ref = ref; + } + if (selector) { + typeRequest.selector = selector; + } if (timeoutMs) { typeRequest.timeoutMs = timeoutMs; } @@ -267,12 +617,20 @@ export function registerBrowserAgentActRoutes( return res.json({ ok: true, targetId: tab.targetId }); } case "hover": { - const ref = toStringOrEmpty(body.ref); - if (!ref) { - return jsonError(res, 400, "ref is required"); + const ref = toStringOrEmpty(body.ref) || undefined; + const selector = toStringOrEmpty(body.selector) || undefined; + if (!ref && !selector) { + return jsonError(res, 400, "ref or selector is required"); } const timeoutMs = toNumber(body.timeoutMs); if (isExistingSession) { + if (selector) { + return jsonError( + res, + 501, + "existing-session hover does not support selector targeting yet; use ref.", + ); + } if (timeoutMs) { return jsonError( res, @@ -280,7 +638,7 @@ export function registerBrowserAgentActRoutes( "existing-session hover does not support timeoutMs overrides.", ); } - await hoverChromeMcpElement({ profileName, targetId: tab.targetId, uid: ref }); + await hoverChromeMcpElement({ profileName, targetId: tab.targetId, uid: ref! }); return res.json({ ok: true, targetId: tab.targetId }); } const pw = await requirePwAi(res, `act:${kind}`); @@ -291,17 +649,26 @@ export function registerBrowserAgentActRoutes( cdpUrl, targetId: tab.targetId, ref, + selector, timeoutMs: timeoutMs ?? undefined, }); return res.json({ ok: true, targetId: tab.targetId }); } case "scrollIntoView": { - const ref = toStringOrEmpty(body.ref); - if (!ref) { - return jsonError(res, 400, "ref is required"); + const ref = toStringOrEmpty(body.ref) || undefined; + const selector = toStringOrEmpty(body.selector) || undefined; + if (!ref && !selector) { + return jsonError(res, 400, "ref or selector is required"); } const timeoutMs = toNumber(body.timeoutMs); if (isExistingSession) { + if (selector) { + return jsonError( + res, + 501, + "existing-session scrollIntoView does not support selector targeting yet; use ref.", + ); + } if (timeoutMs) { return jsonError( res, @@ -313,7 +680,7 @@ export function registerBrowserAgentActRoutes( profileName, targetId: tab.targetId, fn: `(el) => { el.scrollIntoView({ block: "center", inline: "center" }); return true; }`, - args: [ref], + args: [ref!], }); return res.json({ ok: true, targetId: tab.targetId }); } @@ -324,8 +691,13 @@ export function registerBrowserAgentActRoutes( const scrollRequest: Parameters[0] = { cdpUrl, targetId: tab.targetId, - ref, }; + if (ref) { + scrollRequest.ref = ref; + } + if (selector) { + scrollRequest.selector = selector; + } if (timeoutMs) { scrollRequest.timeoutMs = timeoutMs; } @@ -333,13 +705,25 @@ export function registerBrowserAgentActRoutes( return res.json({ ok: true, targetId: tab.targetId }); } case "drag": { - const startRef = toStringOrEmpty(body.startRef); - const endRef = toStringOrEmpty(body.endRef); - if (!startRef || !endRef) { - return jsonError(res, 400, "startRef and endRef are required"); + const startRef = toStringOrEmpty(body.startRef) || undefined; + const startSelector = toStringOrEmpty(body.startSelector) || undefined; + const endRef = toStringOrEmpty(body.endRef) || undefined; + const endSelector = toStringOrEmpty(body.endSelector) || undefined; + if (!startRef && !startSelector) { + return jsonError(res, 400, "startRef or startSelector is required"); + } + if (!endRef && !endSelector) { + return jsonError(res, 400, "endRef or endSelector is required"); } const timeoutMs = toNumber(body.timeoutMs); if (isExistingSession) { + if (startSelector || endSelector) { + return jsonError( + res, + 501, + "existing-session drag does not support selector targeting yet; use startRef/endRef.", + ); + } if (timeoutMs) { return jsonError( res, @@ -350,8 +734,8 @@ export function registerBrowserAgentActRoutes( await dragChromeMcpElement({ profileName, targetId: tab.targetId, - fromUid: startRef, - toUid: endRef, + fromUid: startRef!, + toUid: endRef!, }); return res.json({ ok: true, targetId: tab.targetId }); } @@ -363,19 +747,29 @@ export function registerBrowserAgentActRoutes( cdpUrl, targetId: tab.targetId, startRef, + startSelector, endRef, + endSelector, timeoutMs: timeoutMs ?? undefined, }); return res.json({ ok: true, targetId: tab.targetId }); } case "select": { - const ref = toStringOrEmpty(body.ref); + const ref = toStringOrEmpty(body.ref) || undefined; + const selector = toStringOrEmpty(body.selector) || undefined; const values = toStringArray(body.values); - if (!ref || !values?.length) { - return jsonError(res, 400, "ref and values are required"); + if ((!ref && !selector) || !values?.length) { + return jsonError(res, 400, "ref/selector and values are required"); } const timeoutMs = toNumber(body.timeoutMs); if (isExistingSession) { + if (selector) { + return jsonError( + res, + 501, + "existing-session select does not support selector targeting yet; use ref.", + ); + } if (values.length !== 1) { return jsonError( res, @@ -393,7 +787,7 @@ export function registerBrowserAgentActRoutes( await fillChromeMcpElement({ profileName, targetId: tab.targetId, - uid: ref, + uid: ref!, value: values[0] ?? "", }); return res.json({ ok: true, targetId: tab.targetId }); @@ -406,6 +800,7 @@ export function registerBrowserAgentActRoutes( cdpUrl, targetId: tab.targetId, ref, + selector, values, timeoutMs: timeoutMs ?? undefined, }); @@ -627,6 +1022,41 @@ export function registerBrowserAgentActRoutes( await pw.closePageViaPlaywright({ cdpUrl, targetId: tab.targetId }); return res.json({ ok: true, targetId: tab.targetId }); } + case "batch": { + if (isExistingSession) { + return jsonError( + res, + 501, + "existing-session batch is not supported yet; send actions individually.", + ); + } + const pw = await requirePwAi(res, `act:${kind}`); + if (!pw) { + return; + } + let actions: BrowserActRequest[]; + try { + actions = Array.isArray(body.actions) ? body.actions.map(normalizeBatchAction) : []; + } catch (err) { + return jsonError(res, 400, err instanceof Error ? err.message : String(err)); + } + if (!actions.length) { + return jsonError(res, 400, "actions are required"); + } + const targetIdError = validateBatchTargetIds(actions, tab.targetId); + if (targetIdError) { + return jsonError(res, 403, targetIdError); + } + const stopOnError = toBoolean(body.stopOnError) ?? true; + const result = await pw.batchViaPlaywright({ + cdpUrl, + targetId: tab.targetId, + actions, + stopOnError, + evaluateEnabled, + }); + return res.json({ ok: true, targetId: tab.targetId, results: result.results }); + } default: { return jsonError(res, 400, "unsupported kind"); } diff --git a/src/browser/server.agent-contract-form-layout-act-commands.test.ts b/src/browser/server.agent-contract-form-layout-act-commands.test.ts index 738bf8b7e2d..912d024916c 100644 --- a/src/browser/server.agent-contract-form-layout-act-commands.test.ts +++ b/src/browser/server.agent-contract-form-layout-act-commands.test.ts @@ -51,12 +51,14 @@ describe("browser control server", () => { values: ["a", "b"], }); expect(select.ok).toBe(true); - expect(pwMocks.selectOptionViaPlaywright).toHaveBeenCalledWith({ - cdpUrl: state.cdpBaseUrl, - targetId: "abcd1234", - ref: "5", - values: ["a", "b"], - }); + expect(pwMocks.selectOptionViaPlaywright).toHaveBeenCalledWith( + expect.objectContaining({ + cdpUrl: expect.any(String), + targetId: "abcd1234", + ref: "5", + values: ["a", "b"], + }), + ); const fillCases: Array<{ input: Record; @@ -81,11 +83,13 @@ describe("browser control server", () => { fields: [input], }); expect(fill.ok).toBe(true); - expect(pwMocks.fillFormViaPlaywright).toHaveBeenCalledWith({ - cdpUrl: state.cdpBaseUrl, - targetId: "abcd1234", - fields: [expected], - }); + expect(pwMocks.fillFormViaPlaywright).toHaveBeenCalledWith( + expect.objectContaining({ + cdpUrl: expect.any(String), + targetId: "abcd1234", + fields: [expected], + }), + ); } const resize = await postJson<{ ok: boolean }>(`${base}/act`, { @@ -94,12 +98,14 @@ describe("browser control server", () => { height: 600, }); expect(resize.ok).toBe(true); - expect(pwMocks.resizeViewportViaPlaywright).toHaveBeenCalledWith({ - cdpUrl: state.cdpBaseUrl, - targetId: "abcd1234", - width: 800, - height: 600, - }); + expect(pwMocks.resizeViewportViaPlaywright).toHaveBeenCalledWith( + expect.objectContaining({ + cdpUrl: expect.any(String), + targetId: "abcd1234", + width: 800, + height: 600, + }), + ); const wait = await postJson<{ ok: boolean }>(`${base}/act`, { kind: "wait", @@ -157,6 +163,130 @@ describe("browser control server", () => { slowTimeoutMs, ); + it( + "normalizes batch actions and threads evaluateEnabled into the batch executor", + async () => { + const base = await startServerAndBase(); + + const batchRes = await postJson<{ ok: boolean; results?: Array<{ ok: boolean }> }>( + `${base}/act`, + { + kind: "batch", + stopOnError: "false", + actions: [ + { kind: "click", selector: "button.save", doubleClick: "true", delayMs: "25" }, + { kind: "wait", fn: " () => window.ready === true " }, + ], + }, + ); + + expect(batchRes.ok).toBe(true); + expect(pwMocks.batchViaPlaywright).toHaveBeenCalledWith( + expect.objectContaining({ + cdpUrl: expect.any(String), + targetId: "abcd1234", + stopOnError: false, + evaluateEnabled: true, + actions: [ + { + kind: "click", + selector: "button.save", + doubleClick: true, + delayMs: 25, + }, + { + kind: "wait", + fn: "() => window.ready === true", + }, + ], + }), + ); + }, + slowTimeoutMs, + ); + + it( + "preserves exact type text in batch normalization", + async () => { + const base = await startServerAndBase(); + + const batchRes = await postJson<{ ok: boolean }>(`${base}/act`, { + kind: "batch", + actions: [ + { kind: "type", selector: "input.name", text: " padded " }, + { kind: "type", selector: "input.clearable", text: "" }, + ], + }); + + expect(batchRes.ok).toBe(true); + expect(pwMocks.batchViaPlaywright).toHaveBeenCalledWith( + expect.objectContaining({ + actions: [ + { + kind: "type", + selector: "input.name", + text: " padded ", + }, + { + kind: "type", + selector: "input.clearable", + text: "", + }, + ], + }), + ); + }, + slowTimeoutMs, + ); + + it( + "rejects malformed batch actions before dispatch", + async () => { + const base = await startServerAndBase(); + + const batchRes = await postJson<{ error?: string }>(`${base}/act`, { + kind: "batch", + actions: [{ kind: "click", ref: {} }], + }); + + expect(batchRes.error).toContain("click requires ref or selector"); + expect(pwMocks.batchViaPlaywright).not.toHaveBeenCalled(); + }, + slowTimeoutMs, + ); + + it( + "rejects batched action targetId overrides before dispatch", + async () => { + const base = await startServerAndBase(); + + const batchRes = await postJson<{ error?: string }>(`${base}/act`, { + kind: "batch", + actions: [{ kind: "click", ref: "5", targetId: "other-tab" }], + }); + + expect(batchRes.error).toContain("batched action targetId must match request targetId"); + expect(pwMocks.batchViaPlaywright).not.toHaveBeenCalled(); + }, + slowTimeoutMs, + ); + + it( + "rejects oversized batch delays before dispatch", + async () => { + const base = await startServerAndBase(); + + const batchRes = await postJson<{ error?: string }>(`${base}/act`, { + kind: "batch", + actions: [{ kind: "click", selector: "button.save", delayMs: 5001 }], + }); + + expect(batchRes.error).toContain("click delayMs exceeds maximum of 5000ms"); + expect(pwMocks.batchViaPlaywright).not.toHaveBeenCalled(); + }, + slowTimeoutMs, + ); + it("agent contract: hooks + response + downloads + screenshot", async () => { const base = await startServerAndBase(); @@ -165,13 +295,15 @@ describe("browser control server", () => { timeoutMs: 1234, }); expect(upload).toMatchObject({ ok: true }); - expect(pwMocks.armFileUploadViaPlaywright).toHaveBeenCalledWith({ - cdpUrl: state.cdpBaseUrl, - targetId: "abcd1234", - // The server resolves paths (which adds a drive letter on Windows for `\\tmp\\...` style roots). - paths: [path.resolve(DEFAULT_UPLOAD_DIR, "a.txt")], - timeoutMs: 1234, - }); + expect(pwMocks.armFileUploadViaPlaywright).toHaveBeenCalledWith( + expect.objectContaining({ + cdpUrl: expect.any(String), + targetId: "abcd1234", + // The server resolves paths (which adds a drive letter on Windows for `\\tmp\\...` style roots). + paths: [path.resolve(DEFAULT_UPLOAD_DIR, "a.txt")], + timeoutMs: 1234, + }), + ); const uploadWithRef = await postJson(`${base}/hooks/file-chooser`, { paths: ["b.txt"], @@ -280,7 +412,7 @@ describe("browser control server", () => { expect(res.path).toContain("safe-trace.zip"); expect(pwMocks.traceStopViaPlaywright).toHaveBeenCalledWith( expect.objectContaining({ - cdpUrl: state.cdpBaseUrl, + cdpUrl: expect.any(String), targetId: "abcd1234", path: expect.stringContaining("safe-trace.zip"), }), @@ -369,7 +501,7 @@ describe("browser control server", () => { expect(res.ok).toBe(true); expect(pwMocks.waitForDownloadViaPlaywright).toHaveBeenCalledWith( expect.objectContaining({ - cdpUrl: state.cdpBaseUrl, + cdpUrl: expect.any(String), targetId: "abcd1234", path: expect.stringContaining("safe-wait.pdf"), }), @@ -385,7 +517,7 @@ describe("browser control server", () => { expect(res.ok).toBe(true); expect(pwMocks.downloadViaPlaywright).toHaveBeenCalledWith( expect.objectContaining({ - cdpUrl: state.cdpBaseUrl, + cdpUrl: expect.any(String), targetId: "abcd1234", ref: "e12", path: expect.stringContaining("safe-download.pdf"), diff --git a/src/browser/server.control-server.test-harness.ts b/src/browser/server.control-server.test-harness.ts index 5721d9eb17b..3a54f6662d5 100644 --- a/src/browser/server.control-server.test-harness.ts +++ b/src/browser/server.control-server.test-harness.ts @@ -77,6 +77,7 @@ export function getCdpMocks(): { createTargetViaCdp: MockFn; snapshotAria: MockF const pwMocks = vi.hoisted(() => ({ armDialogViaPlaywright: vi.fn(async () => {}), armFileUploadViaPlaywright: vi.fn(async () => {}), + batchViaPlaywright: vi.fn(async () => ({ results: [] })), clickViaPlaywright: vi.fn(async () => {}), closePageViaPlaywright: vi.fn(async () => {}), closePlaywrightBrowserConnection: vi.fn(async () => {}), @@ -210,7 +211,9 @@ vi.mock("./cdp.js", () => ({ vi.mock("./pw-ai.js", () => pwMocks); vi.mock("../media/store.js", () => ({ + MEDIA_MAX_BYTES: 5 * 1024 * 1024, ensureMediaDir: vi.fn(async () => {}), + getMediaDir: vi.fn(() => "/tmp"), saveMediaBuffer: vi.fn(async () => ({ path: "/tmp/fake.png" })), })); From 1ef0aa443b2f5c9bc4825659d83a532998d8307b Mon Sep 17 00:00:00 2001 From: Eyal En Gad <36604865+eengad@users.noreply.github.com> Date: Fri, 13 Mar 2026 15:14:53 -0700 Subject: [PATCH 105/461] docs(android): note that app is not publicly released yet (#23051) Co-authored-by: Eyal --- docs/platforms/android.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/platforms/android.md b/docs/platforms/android.md index 4df71b83e73..6bd5effb361 100644 --- a/docs/platforms/android.md +++ b/docs/platforms/android.md @@ -9,6 +9,8 @@ title: "Android App" # Android App (Node) +> **Note:** The Android app has not been publicly released yet. The source code is available in the [OpenClaw repository](https://github.com/openclaw/openclaw) under `apps/android`. You can build it yourself using Java 17 and the Android SDK (`./gradlew :app:assembleDebug`). See [apps/android/README.md](https://github.com/openclaw/openclaw/blob/main/apps/android/README.md) for build instructions. + ## Support snapshot - Role: companion node app (Android does not host the Gateway). From 7e49e98f79073b11134beac27fdff547ba5a4a02 Mon Sep 17 00:00:00 2001 From: Robin Waslander Date: Fri, 13 Mar 2026 23:12:17 +0100 Subject: [PATCH 106/461] fix(telegram): validate webhook secret before reading request body Refs: GHSA-jq3f-vjww-8rq7 --- CHANGELOG.md | 1 + src/telegram/webhook.test.ts | 93 +++++++++++++++++++++++++++++++++++- src/telegram/webhook.ts | 32 ++++++++++++- 3 files changed, 123 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fcf8d4862d8..48290d3389f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Telegram/webhook auth: validate the Telegram webhook secret before reading or parsing request bodies, so unauthenticated requests are rejected immediately instead of consuming up to 1 MB first. Thanks @space08. - Build/plugin-sdk bundling: bundle plugin-sdk subpath entries in one shared build pass so published packages stop duplicating shared chunks and avoid the recent plugin-sdk memory blow-up. (#45426) Thanks @TarasShyn. - Browser/existing-session: accept text-only `list_pages` and `new_page` responses from Chrome DevTools MCP so live-session tab discovery and new-tab open flows keep working when the server omits structured page metadata. - Ollama/reasoning visibility: stop promoting native `thinking` and `reasoning` fields into final assistant text so local reasoning models no longer leak internal thoughts in normal replies. (#45330) Thanks @xi7ang. diff --git a/src/telegram/webhook.test.ts b/src/telegram/webhook.test.ts index 1b630b034df..0f2736a30b9 100644 --- a/src/telegram/webhook.test.ts +++ b/src/telegram/webhook.test.ts @@ -88,6 +88,70 @@ async function postWebhookJson(params: { ); } +async function postWebhookHeadersOnly(params: { + port: number; + path: string; + declaredLength: number; + secret?: string; + timeoutMs?: number; +}): Promise<{ statusCode: number; body: string }> { + return await new Promise((resolve, reject) => { + let settled = false; + const finishResolve = (value: { statusCode: number; body: string }) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timeout); + resolve(value); + }; + const finishReject = (error: unknown) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timeout); + reject(error); + }; + + const req = request( + { + hostname: "127.0.0.1", + port: params.port, + path: params.path, + method: "POST", + headers: { + "content-type": "application/json", + "content-length": String(params.declaredLength), + ...(params.secret ? { "x-telegram-bot-api-secret-token": params.secret } : {}), + }, + }, + (res) => { + collectResponseBody(res, (payload) => { + finishResolve(payload); + req.destroy(); + }); + }, + ); + + const timeout = setTimeout(() => { + req.destroy( + new Error(`webhook header-only post timed out after ${params.timeoutMs ?? 5_000}ms`), + ); + finishReject(new Error("timed out waiting for webhook response")); + }, params.timeoutMs ?? 5_000); + + req.on("error", (error) => { + if (settled && (error as NodeJS.ErrnoException).code === "ECONNRESET") { + return; + } + finishReject(error); + }); + + req.flushHeaders(); + }); +} + function createDeterministicRng(seed: number): () => number { let state = seed >>> 0; return () => { @@ -399,7 +463,34 @@ describe("startTelegramWebhook", () => { secret: TELEGRAM_SECRET, }); expect(response.status).toBe(200); - expect(handlerSpy).toHaveBeenCalled(); + expect(handlerSpy).toHaveBeenCalledWith( + JSON.parse(payload), + expect.any(Function), + TELEGRAM_SECRET, + expect.any(Function), + ); + }, + ); + }); + + it("rejects unauthenticated requests before reading the request body", async () => { + handlerSpy.mockClear(); + await withStartedWebhook( + { + secret: TELEGRAM_SECRET, + path: TELEGRAM_WEBHOOK_PATH, + }, + async ({ port }) => { + const response = await postWebhookHeadersOnly({ + port, + path: TELEGRAM_WEBHOOK_PATH, + declaredLength: 1_024 * 1_024, + secret: "wrong-secret", + }); + + expect(response.statusCode).toBe(401); + expect(response.body).toBe("unauthorized"); + expect(handlerSpy).not.toHaveBeenCalled(); }, ); }); diff --git a/src/telegram/webhook.ts b/src/telegram/webhook.ts index 1de38b1bb36..c049089a2ad 100644 --- a/src/telegram/webhook.ts +++ b/src/telegram/webhook.ts @@ -1,3 +1,4 @@ +import { timingSafeEqual } from "node:crypto"; import { createServer } from "node:http"; import { InputFile, webhookCallback } from "grammy"; import type { OpenClawConfig } from "../config/config.js"; @@ -74,6 +75,28 @@ async function initializeTelegramWebhookBot(params: { }); } +function resolveSingleHeaderValue(header: string | string[] | undefined): string | undefined { + if (typeof header === "string") { + return header; + } + if (Array.isArray(header) && header.length === 1) { + return header[0]; + } + return undefined; +} + +function hasValidTelegramWebhookSecret( + secretHeader: string | undefined, + expectedSecret: string, +): boolean { + if (typeof secretHeader !== "string") { + return false; + } + const actual = Buffer.from(secretHeader, "utf-8"); + const expected = Buffer.from(expectedSecret, "utf-8"); + return actual.length === expected.length && timingSafeEqual(actual, expected); +} + export async function startTelegramWebhook(opts: { token: string; accountId?: string; @@ -147,6 +170,13 @@ export async function startTelegramWebhook(opts: { if (diagnosticsEnabled) { logWebhookReceived({ channel: "telegram", updateType: "telegram-post" }); } + const secretHeader = resolveSingleHeaderValue(req.headers["x-telegram-bot-api-secret-token"]); + if (!hasValidTelegramWebhookSecret(secretHeader, secret)) { + res.shouldKeepAlive = false; + res.setHeader("Connection", "close"); + respondText(401, "unauthorized"); + return; + } void (async () => { const body = await readJsonBodyWithLimit(req, { maxBytes: TELEGRAM_WEBHOOK_MAX_BODY_BYTES, @@ -189,8 +219,6 @@ export async function startTelegramWebhook(opts: { replied = true; respondText(401, "unauthorized"); }; - const secretHeaderRaw = req.headers["x-telegram-bot-api-secret-token"]; - const secretHeader = Array.isArray(secretHeaderRaw) ? secretHeaderRaw[0] : secretHeaderRaw; await handler(body.value, reply, secretHeader, unauthorized); if (!replied) { From e82ba71911ad971d0be9219e9f8d064c61a57746 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 13 Mar 2026 18:39:28 -0400 Subject: [PATCH 107/461] fix(browser): follow up batch failure and limit handling (#45506) * fix(browser): propagate nested batch failures * fix(browser): validate top-level batch limits * test(browser): cover nested batch failures * test(browser): cover top-level batch limits --- .../pw-tools-core.interactions.batch.test.ts | 19 +++++++++++++++++++ src/browser/pw-tools-core.interactions.ts | 6 +++++- src/browser/routes/agent.act.ts | 3 +++ ...-contract-form-layout-act-commands.test.ts | 16 ++++++++++++++++ 4 files changed, 43 insertions(+), 1 deletion(-) diff --git a/src/browser/pw-tools-core.interactions.batch.test.ts b/src/browser/pw-tools-core.interactions.batch.test.ts index 2801ebe8190..fbd2de4cbc6 100644 --- a/src/browser/pw-tools-core.interactions.batch.test.ts +++ b/src/browser/pw-tools-core.interactions.batch.test.ts @@ -82,4 +82,23 @@ describe("batchViaPlaywright", () => { targetId: "tab-1", }); }); + + it("propagates nested batch failures to the parent batch result", async () => { + const result = await batchViaPlaywright({ + cdpUrl: "http://127.0.0.1:9222", + targetId: "tab-1", + actions: [ + { + kind: "batch", + actions: [{ kind: "evaluate", fn: "() => 1" }], + }, + ], + }); + + expect(result).toEqual({ + results: [ + { ok: false, error: "act:evaluate is disabled by config (browser.evaluateEnabled=false)" }, + ], + }); + }); }); diff --git a/src/browser/pw-tools-core.interactions.ts b/src/browser/pw-tools-core.interactions.ts index dee8a03316c..da0efa0c145 100644 --- a/src/browser/pw-tools-core.interactions.ts +++ b/src/browser/pw-tools-core.interactions.ts @@ -845,7 +845,7 @@ async function executeSingleAction( break; case "batch": // Nested batches: delegate recursively - await batchViaPlaywright({ + const nestedResult = await batchViaPlaywright({ cdpUrl, targetId: effectiveTargetId, actions: action.actions, @@ -853,6 +853,10 @@ async function executeSingleAction( evaluateEnabled, depth: depth + 1, }); + const nestedFailure = nestedResult.results.find((result) => !result.ok); + if (nestedFailure) { + throw new Error(nestedFailure.error ?? "Nested batch action failed"); + } break; default: throw new Error(`Unsupported batch action kind: ${(action as { kind: string }).kind}`); diff --git a/src/browser/routes/agent.act.ts b/src/browser/routes/agent.act.ts index 0c4c9e71967..05557fe1129 100644 --- a/src/browser/routes/agent.act.ts +++ b/src/browser/routes/agent.act.ts @@ -1043,6 +1043,9 @@ export function registerBrowserAgentActRoutes( if (!actions.length) { return jsonError(res, 400, "actions are required"); } + if (countBatchActions(actions) > MAX_BATCH_ACTIONS) { + return jsonError(res, 400, `batch exceeds maximum of ${MAX_BATCH_ACTIONS} actions`); + } const targetIdError = validateBatchTargetIds(actions, tab.targetId); if (targetIdError) { return jsonError(res, 403, targetIdError); diff --git a/src/browser/server.agent-contract-form-layout-act-commands.test.ts b/src/browser/server.agent-contract-form-layout-act-commands.test.ts index 912d024916c..16ade600bec 100644 --- a/src/browser/server.agent-contract-form-layout-act-commands.test.ts +++ b/src/browser/server.agent-contract-form-layout-act-commands.test.ts @@ -287,6 +287,22 @@ describe("browser control server", () => { slowTimeoutMs, ); + it( + "rejects oversized top-level batches before dispatch", + async () => { + const base = await startServerAndBase(); + + const batchRes = await postJson<{ error?: string }>(`${base}/act`, { + kind: "batch", + actions: Array.from({ length: 101 }, () => ({ kind: "press", key: "Enter" })), + }); + + expect(batchRes.error).toContain("batch exceeds maximum of 100 actions"); + expect(pwMocks.batchViaPlaywright).not.toHaveBeenCalled(); + }, + slowTimeoutMs, + ); + it("agent contract: hooks + response + downloads + screenshot", async () => { const base = await startServerAndBase(); From ae1a1fccfeac598a747a9b4a6c9871c93061229c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:38:28 +0000 Subject: [PATCH 108/461] fix: stabilize browser existing-session control --- src/browser/chrome-mcp.test.ts | 24 +++ src/browser/chrome-mcp.ts | 36 +++- src/browser/profiles-service.ts | 19 +- src/browser/resolved-config-refresh.ts | 16 +- src/browser/routes/agent.act.ts | 47 +++-- .../routes/agent.existing-session.test.ts | 198 ++++++++++++++++++ src/browser/routes/agent.snapshot.ts | 14 +- ...server-context.hot-reload-profiles.test.ts | 1 + ...-contract-form-layout-act-commands.test.ts | 1 - .../server.control-server.test-harness.ts | 102 ++++++++- .../browser-cli-manage.timeout-option.test.ts | 44 ++++ src/cli/browser-cli-manage.ts | 20 +- 12 files changed, 462 insertions(+), 60 deletions(-) create mode 100644 src/browser/routes/agent.existing-session.test.ts diff --git a/src/browser/chrome-mcp.test.ts b/src/browser/chrome-mcp.test.ts index 3b64054c407..ec6f21339ed 100644 --- a/src/browser/chrome-mcp.test.ts +++ b/src/browser/chrome-mcp.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { + evaluateChromeMcpScript, listChromeMcpTabs, openChromeMcpTab, resetChromeMcpSessionsForTest, @@ -48,6 +49,16 @@ function createFakeSession(): ChromeMcpSession { ], }; } + if (name === "evaluate_script") { + return { + content: [ + { + type: "text", + text: "```json\n123\n```", + }, + ], + }; + } throw new Error(`unexpected tool ${name}`); }); @@ -105,4 +116,17 @@ describe("chrome MCP page parsing", () => { type: "page", }); }); + + it("parses evaluate_script text responses when structuredContent is missing", async () => { + const factory: ChromeMcpSessionFactory = async () => createFakeSession(); + setChromeMcpSessionFactoryForTest(factory); + + const result = await evaluateChromeMcpScript({ + profileName: "chrome-live", + targetId: "1", + fn: "() => 123", + }); + + expect(result).toBe(123); + }); }); diff --git a/src/browser/chrome-mcp.ts b/src/browser/chrome-mcp.ts index 7719a2338e3..ecd196547d3 100644 --- a/src/browser/chrome-mcp.ts +++ b/src/browser/chrome-mcp.ts @@ -33,6 +33,8 @@ const DEFAULT_CHROME_MCP_ARGS = [ "-y", "chrome-devtools-mcp@latest", "--autoConnect", + // Direct chrome-devtools-mcp launches do not enable structuredContent by default. + "--experimentalStructuredContent", "--experimental-page-id-routing", ]; @@ -133,6 +135,33 @@ function extractJsonBlock(text: string): unknown { return raw ? JSON.parse(raw) : null; } +function extractMessageText(result: ChromeMcpToolResult): string { + const message = extractStructuredContent(result).message; + if (typeof message === "string" && message.trim()) { + return message; + } + const blocks = extractTextContent(result); + return blocks.find((block) => block.trim()) ?? ""; +} + +function extractJsonMessage(result: ChromeMcpToolResult): unknown { + const candidates = [extractMessageText(result), ...extractTextContent(result)].filter((text) => + text.trim(), + ); + let lastError: unknown; + for (const candidate of candidates) { + try { + return extractJsonBlock(candidate); + } catch (err) { + lastError = err; + } + } + if (lastError) { + throw lastError; + } + return null; +} + async function createRealSession(profileName: string): Promise { const transport = new StdioClientTransport({ command: DEFAULT_CHROME_MCP_COMMAND, @@ -457,12 +486,7 @@ export async function evaluateChromeMcpScript(params: { function: params.fn, ...(params.args?.length ? { args: params.args } : {}), }); - const message = extractStructuredContent(result).message; - const text = typeof message === "string" ? message : ""; - if (!text.trim()) { - return null; - } - return extractJsonBlock(text); + return extractJsonMessage(result); } export async function waitForChromeMcpText(params: { diff --git a/src/browser/profiles-service.ts b/src/browser/profiles-service.ts index 936a55c1ffa..25c0461f795 100644 --- a/src/browser/profiles-service.ts +++ b/src/browser/profiles-service.ts @@ -6,7 +6,6 @@ import { deriveDefaultBrowserCdpPortRange } from "../config/port-defaults.js"; import { isLoopbackHost } from "../gateway/net.js"; import { resolveOpenClawUserDataDir } from "./chrome.js"; import { parseHttpUrl, resolveProfile } from "./config.js"; -import { DEFAULT_BROWSER_DEFAULT_PROFILE_NAME } from "./constants.js"; import { BrowserConflictError, BrowserProfileNotFoundError, @@ -110,7 +109,12 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) { let profileConfig: BrowserProfileConfig; if (rawCdpUrl) { - const parsed = parseHttpUrl(rawCdpUrl, "browser.profiles.cdpUrl"); + let parsed: ReturnType; + try { + parsed = parseHttpUrl(rawCdpUrl, "browser.profiles.cdpUrl"); + } catch (err) { + throw new BrowserValidationError(String(err)); + } if (driver === "extension") { if (!isLoopbackHost(parsed.parsed.hostname)) { throw new BrowserValidationError( @@ -189,21 +193,20 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) { throw new BrowserValidationError("invalid profile name"); } + const state = ctx.state(); const cfg = loadConfig(); const profiles = cfg.browser?.profiles ?? {}; - if (!(name in profiles)) { - throw new BrowserProfileNotFoundError(`profile "${name}" not found`); - } - - const defaultProfile = cfg.browser?.defaultProfile ?? DEFAULT_BROWSER_DEFAULT_PROFILE_NAME; + const defaultProfile = cfg.browser?.defaultProfile ?? state.resolved.defaultProfile; if (name === defaultProfile) { throw new BrowserValidationError( `cannot delete the default profile "${name}"; change browser.defaultProfile first`, ); } + if (!(name in profiles)) { + throw new BrowserProfileNotFoundError(`profile "${name}" not found`); + } let deleted = false; - const state = ctx.state(); const resolved = resolveProfile(state.resolved, name); if (resolved?.cdpIsLoopback && resolved.driver === "openclaw") { diff --git a/src/browser/resolved-config-refresh.ts b/src/browser/resolved-config-refresh.ts index fe934069a80..999a7ca1229 100644 --- a/src/browser/resolved-config-refresh.ts +++ b/src/browser/resolved-config-refresh.ts @@ -1,4 +1,4 @@ -import { createConfigIO, loadConfig } from "../config/config.js"; +import { createConfigIO, getRuntimeConfigSnapshot } from "../config/config.js"; import { resolveBrowserConfig, resolveProfile, type ResolvedBrowserProfile } from "./config.js"; import type { BrowserServerState } from "./server-context.types.js"; @@ -29,7 +29,13 @@ function applyResolvedConfig( current: BrowserServerState, freshResolved: BrowserServerState["resolved"], ) { - current.resolved = freshResolved; + current.resolved = { + ...freshResolved, + // Keep the runtime evaluate gate stable across request-time profile refreshes. + // Security-sensitive behavior should only change via full runtime config reload, + // not as a side effect of resolving profiles/tabs during a request. + evaluateEnabled: current.resolved.evaluateEnabled, + }; for (const [name, runtime] of current.profiles) { const nextProfile = resolveProfile(freshResolved, name); if (nextProfile) { @@ -63,7 +69,11 @@ export function refreshResolvedBrowserConfigFromDisk(params: { if (!params.refreshConfigFromDisk) { return; } - const cfg = params.mode === "fresh" ? createConfigIO().loadConfig() : loadConfig(); + + // Route-level browser config hot reload should observe on-disk changes immediately. + // The shared loadConfig() helper may return a cached snapshot for the configured TTL, + // which can leave request-time browser guards stale (for example evaluateEnabled). + const cfg = getRuntimeConfigSnapshot() ?? createConfigIO().loadConfig(); const freshResolved = resolveBrowserConfig(cfg.browser, cfg); applyResolvedConfig(params.current, freshResolved); } diff --git a/src/browser/routes/agent.act.ts b/src/browser/routes/agent.act.ts index 05557fe1129..a8d3a09ce83 100644 --- a/src/browser/routes/agent.act.ts +++ b/src/browser/routes/agent.act.ts @@ -34,6 +34,15 @@ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } +function browserEvaluateDisabledMessage(action: "wait" | "evaluate"): string { + return [ + action === "wait" + ? "wait --fn is disabled by config (browser.evaluateEnabled=false)." + : "act:evaluate is disabled by config (browser.evaluateEnabled=false).", + "Docs: /gateway/configuration#browser-openclaw-managed-browser", + ].join("\n"); +} + function buildExistingSessionWaitPredicate(params: { text?: string; textGone?: string; @@ -57,7 +66,7 @@ function buildExistingSessionWaitPredicate(params: { } if (params.loadState === "domcontentloaded") { checks.push(`document.readyState === "interactive" || document.readyState === "complete"`); - } else if (params.loadState === "load" || params.loadState === "networkidle") { + } else if (params.loadState === "load") { checks.push(`document.readyState === "complete"`); } if (params.fn) { @@ -439,6 +448,17 @@ export function registerBrowserAgentActRoutes( if (Object.hasOwn(body, "selector") && !SELECTOR_ALLOWED_KINDS.has(kind)) { return jsonError(res, 400, SELECTOR_UNSUPPORTED_MESSAGE); } + const earlyFn = kind === "wait" || kind === "evaluate" ? toStringOrEmpty(body.fn) : ""; + if ( + (kind === "evaluate" || (kind === "wait" && earlyFn)) && + !ctx.state().resolved.evaluateEnabled + ) { + return jsonError( + res, + 403, + browserEvaluateDisabledMessage(kind === "evaluate" ? "evaluate" : "wait"), + ); + } await withRouteTabContext({ req, @@ -893,14 +913,7 @@ export function registerBrowserAgentActRoutes( const fn = toStringOrEmpty(body.fn) || undefined; const timeoutMs = toNumber(body.timeoutMs) ?? undefined; if (fn && !evaluateEnabled) { - return jsonError( - res, - 403, - [ - "wait --fn is disabled by config (browser.evaluateEnabled=false).", - "Docs: /gateway/configuration#browser-openclaw-managed-browser", - ].join("\n"), - ); + return jsonError(res, 403, browserEvaluateDisabledMessage("wait")); } if ( timeMs === undefined && @@ -918,6 +931,13 @@ export function registerBrowserAgentActRoutes( ); } if (isExistingSession) { + if (loadState === "networkidle") { + return jsonError( + res, + 501, + "existing-session wait does not support loadState=networkidle yet.", + ); + } await waitForExistingSessionCondition({ profileName, targetId: tab.targetId, @@ -952,14 +972,7 @@ export function registerBrowserAgentActRoutes( } case "evaluate": { if (!evaluateEnabled) { - return jsonError( - res, - 403, - [ - "act:evaluate is disabled by config (browser.evaluateEnabled=false).", - "Docs: /gateway/configuration#browser-openclaw-managed-browser", - ].join("\n"), - ); + return jsonError(res, 403, browserEvaluateDisabledMessage("evaluate")); } const fn = toStringOrEmpty(body.fn); if (!fn) { diff --git a/src/browser/routes/agent.existing-session.test.ts b/src/browser/routes/agent.existing-session.test.ts new file mode 100644 index 00000000000..077889d16f9 --- /dev/null +++ b/src/browser/routes/agent.existing-session.test.ts @@ -0,0 +1,198 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { registerBrowserAgentActRoutes } from "./agent.act.js"; +import { registerBrowserAgentSnapshotRoutes } from "./agent.snapshot.js"; +import type { + BrowserRequest, + BrowserResponse, + BrowserRouteHandler, + BrowserRouteRegistrar, +} from "./types.js"; + +const routeState = vi.hoisted(() => ({ + profileCtx: { + profile: { + driver: "existing-session" as const, + name: "chrome-live", + }, + ensureTabAvailable: vi.fn(async () => ({ + targetId: "7", + url: "https://example.com", + })), + }, + tab: { + targetId: "7", + url: "https://example.com", + }, +})); + +const chromeMcpMocks = vi.hoisted(() => ({ + evaluateChromeMcpScript: vi.fn(async () => true), + navigateChromeMcpPage: vi.fn(async ({ url }: { url: string }) => ({ url })), + takeChromeMcpScreenshot: vi.fn(async () => Buffer.from("png")), + takeChromeMcpSnapshot: vi.fn(async () => ({ + id: "root", + role: "document", + name: "Example", + children: [{ id: "btn-1", role: "button", name: "Continue" }], + })), +})); + +vi.mock("../chrome-mcp.js", () => ({ + clickChromeMcpElement: vi.fn(async () => {}), + closeChromeMcpTab: vi.fn(async () => {}), + dragChromeMcpElement: vi.fn(async () => {}), + evaluateChromeMcpScript: chromeMcpMocks.evaluateChromeMcpScript, + fillChromeMcpElement: vi.fn(async () => {}), + fillChromeMcpForm: vi.fn(async () => {}), + hoverChromeMcpElement: vi.fn(async () => {}), + navigateChromeMcpPage: chromeMcpMocks.navigateChromeMcpPage, + pressChromeMcpKey: vi.fn(async () => {}), + resizeChromeMcpPage: vi.fn(async () => {}), + takeChromeMcpScreenshot: chromeMcpMocks.takeChromeMcpScreenshot, + takeChromeMcpSnapshot: chromeMcpMocks.takeChromeMcpSnapshot, +})); + +vi.mock("../cdp.js", () => ({ + captureScreenshot: vi.fn(), + snapshotAria: vi.fn(), +})); + +vi.mock("../navigation-guard.js", () => ({ + assertBrowserNavigationAllowed: vi.fn(async () => {}), + assertBrowserNavigationResultAllowed: vi.fn(async () => {}), + withBrowserNavigationPolicy: vi.fn(() => ({})), +})); + +vi.mock("../screenshot.js", () => ({ + DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES: 128, + DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE: 64, + normalizeBrowserScreenshot: vi.fn(async (buffer: Buffer) => ({ + buffer, + contentType: "image/png", + })), +})); + +vi.mock("../../media/store.js", () => ({ + ensureMediaDir: vi.fn(async () => {}), + saveMediaBuffer: vi.fn(async () => ({ path: "/tmp/fake.png" })), +})); + +vi.mock("./agent.shared.js", () => ({ + getPwAiModule: vi.fn(async () => null), + handleRouteError: vi.fn(), + readBody: vi.fn((req: BrowserRequest) => req.body ?? {}), + requirePwAi: vi.fn(async () => { + throw new Error("Playwright should not be used for existing-session tests"); + }), + resolveProfileContext: vi.fn(() => routeState.profileCtx), + resolveTargetIdFromBody: vi.fn((body: Record) => + typeof body.targetId === "string" ? body.targetId : undefined, + ), + withPlaywrightRouteContext: vi.fn(), + withRouteTabContext: vi.fn(async ({ run }: { run: (args: unknown) => Promise }) => { + await run({ + profileCtx: routeState.profileCtx, + cdpUrl: "http://127.0.0.1:18800", + tab: routeState.tab, + }); + }), +})); + +function createApp() { + const getHandlers = new Map(); + const postHandlers = new Map(); + const deleteHandlers = new Map(); + const app: BrowserRouteRegistrar = { + get: (path, handler) => void getHandlers.set(path, handler), + post: (path, handler) => void postHandlers.set(path, handler), + delete: (path, handler) => void deleteHandlers.set(path, handler), + }; + return { app, getHandlers, postHandlers, deleteHandlers }; +} + +function createResponse() { + let statusCode = 200; + let jsonBody: unknown; + const res: BrowserResponse = { + status(code) { + statusCode = code; + return res; + }, + json(body) { + jsonBody = body; + }, + }; + return { + res, + get statusCode() { + return statusCode; + }, + get body() { + return jsonBody; + }, + }; +} + +describe("existing-session browser routes", () => { + beforeEach(() => { + routeState.profileCtx.ensureTabAvailable.mockClear(); + chromeMcpMocks.evaluateChromeMcpScript.mockReset(); + chromeMcpMocks.navigateChromeMcpPage.mockClear(); + chromeMcpMocks.takeChromeMcpScreenshot.mockClear(); + chromeMcpMocks.takeChromeMcpSnapshot.mockClear(); + chromeMcpMocks.evaluateChromeMcpScript + .mockResolvedValueOnce({ labels: 1, skipped: 0 } as never) + .mockResolvedValueOnce(true); + }); + + it("allows labeled AI snapshots for existing-session profiles", async () => { + const { app, getHandlers } = createApp(); + registerBrowserAgentSnapshotRoutes(app, { + state: () => ({ resolved: { ssrfPolicy: undefined } }), + } as never); + const handler = getHandlers.get("/snapshot"); + expect(handler).toBeTypeOf("function"); + + const response = createResponse(); + await handler?.({ params: {}, query: { format: "ai", labels: "1" } }, response.res); + + expect(response.statusCode).toBe(200); + expect(response.body).toMatchObject({ + ok: true, + format: "ai", + labels: true, + labelsCount: 1, + labelsSkipped: 0, + }); + expect(chromeMcpMocks.takeChromeMcpSnapshot).toHaveBeenCalledWith({ + profileName: "chrome-live", + targetId: "7", + }); + expect(chromeMcpMocks.takeChromeMcpScreenshot).toHaveBeenCalled(); + }); + + it("fails closed for existing-session networkidle waits", async () => { + const { app, postHandlers } = createApp(); + registerBrowserAgentActRoutes(app, { + state: () => ({ resolved: { evaluateEnabled: true } }), + } as never); + const handler = postHandlers.get("/act"); + expect(handler).toBeTypeOf("function"); + + const response = createResponse(); + await handler?.( + { + params: {}, + query: {}, + body: { kind: "wait", loadState: "networkidle" }, + }, + response.res, + ); + + expect(response.statusCode).toBe(501); + expect(response.body).toMatchObject({ + error: expect.stringContaining("loadState=networkidle"), + }); + expect(chromeMcpMocks.evaluateChromeMcpScript).not.toHaveBeenCalled(); + }); +}); diff --git a/src/browser/routes/agent.snapshot.ts b/src/browser/routes/agent.snapshot.ts index 1b8626141b5..3d090de149c 100644 --- a/src/browser/routes/agent.snapshot.ts +++ b/src/browser/routes/agent.snapshot.ts @@ -153,7 +153,10 @@ export async function resolveTargetIdAfterNavigate(opts: { }): Promise { let currentTargetId = opts.oldTargetId; try { - const pickReplacement = (tabs: Array<{ targetId: string; url: string }>) => { + const pickReplacement = ( + tabs: Array<{ targetId: string; url: string }>, + options?: { allowSingleTabFallback?: boolean }, + ) => { if (tabs.some((tab) => tab.targetId === opts.oldTargetId)) { return opts.oldTargetId; } @@ -165,7 +168,7 @@ export async function resolveTargetIdAfterNavigate(opts: { if (uniqueReplacement.length === 1) { return uniqueReplacement[0]?.targetId ?? opts.oldTargetId; } - if (tabs.length === 1) { + if (options?.allowSingleTabFallback && tabs.length === 1) { return tabs[0]?.targetId ?? opts.oldTargetId; } return opts.oldTargetId; @@ -174,7 +177,9 @@ export async function resolveTargetIdAfterNavigate(opts: { currentTargetId = pickReplacement(await opts.listTabs()); if (currentTargetId === opts.oldTargetId) { await new Promise((r) => setTimeout(r, 800)); - currentTargetId = pickReplacement(await opts.listTabs()); + currentTargetId = pickReplacement(await opts.listTabs(), { + allowSingleTabFallback: true, + }); } } catch { // Best-effort: fall back to pre-navigation targetId @@ -380,9 +385,6 @@ export function registerBrowserAgentSnapshotRoutes( return jsonError(res, 400, "labels/mode=efficient require format=ai"); } if (profileCtx.profile.driver === "existing-session") { - if (plan.labels) { - return jsonError(res, 501, "labels are not supported for existing-session profiles yet."); - } if (plan.selectorValue || plan.frameSelectorValue) { return jsonError( res, diff --git a/src/browser/server-context.hot-reload-profiles.test.ts b/src/browser/server-context.hot-reload-profiles.test.ts index ec0c7e072aa..f9eb2452ce2 100644 --- a/src/browser/server-context.hot-reload-profiles.test.ts +++ b/src/browser/server-context.hot-reload-profiles.test.ts @@ -30,6 +30,7 @@ vi.mock("../config/config.js", () => ({ return buildConfig(); }, }), + getRuntimeConfigSnapshot: () => null, loadConfig: () => { // simulate stale loadConfig that doesn't see updates unless cache cleared if (!cachedConfig) { diff --git a/src/browser/server.agent-contract-form-layout-act-commands.test.ts b/src/browser/server.agent-contract-form-layout-act-commands.test.ts index 16ade600bec..c8b76c4b886 100644 --- a/src/browser/server.agent-contract-form-layout-act-commands.test.ts +++ b/src/browser/server.agent-contract-form-layout-act-commands.test.ts @@ -156,7 +156,6 @@ describe("browser control server", () => { kind: "evaluate", fn: "() => 1", }); - expect(res.error).toContain("browser.evaluateEnabled=false"); expect(pwMocks.evaluateViaPlaywright).not.toHaveBeenCalled(); }, diff --git a/src/browser/server.control-server.test-harness.ts b/src/browser/server.control-server.test-harness.ts index 3a54f6662d5..118c83dbb73 100644 --- a/src/browser/server.control-server.test-harness.ts +++ b/src/browser/server.control-server.test-harness.ts @@ -11,6 +11,17 @@ type HarnessState = { reachable: boolean; cfgAttachOnly: boolean; cfgEvaluateEnabled: boolean; + cfgDefaultProfile: string; + cfgProfiles: Record< + string, + { + cdpPort?: number; + cdpUrl?: string; + color: string; + driver?: "openclaw" | "extension" | "existing-session"; + attachOnly?: boolean; + } + >; createTargetId: string | null; prevGatewayPort: string | undefined; prevGatewayToken: string | undefined; @@ -23,6 +34,8 @@ const state: HarnessState = { reachable: false, cfgAttachOnly: false, cfgEvaluateEnabled: true, + cfgDefaultProfile: "openclaw", + cfgProfiles: {}, createTargetId: null, prevGatewayPort: undefined, prevGatewayToken: undefined, @@ -61,6 +74,14 @@ export function setBrowserControlServerReachable(reachable: boolean): void { state.reachable = reachable; } +export function setBrowserControlServerProfiles( + profiles: HarnessState["cfgProfiles"], + defaultProfile = Object.keys(profiles)[0] ?? "openclaw", +): void { + state.cfgProfiles = profiles; + state.cfgDefaultProfile = defaultProfile; +} + const cdpMocks = vi.hoisted(() => ({ createTargetViaCdp: vi.fn<() => Promise<{ targetId: string }>>(async () => { throw new Error("cdp disabled"); @@ -122,6 +143,44 @@ export function getPwMocks(): Record { return pwMocks as unknown as Record; } +const chromeMcpMocks = vi.hoisted(() => ({ + clickChromeMcpElement: vi.fn(async () => {}), + closeChromeMcpSession: vi.fn(async () => true), + closeChromeMcpTab: vi.fn(async () => {}), + dragChromeMcpElement: vi.fn(async () => {}), + ensureChromeMcpAvailable: vi.fn(async () => {}), + evaluateChromeMcpScript: vi.fn(async () => true), + fillChromeMcpElement: vi.fn(async () => {}), + fillChromeMcpForm: vi.fn(async () => {}), + focusChromeMcpTab: vi.fn(async () => {}), + getChromeMcpPid: vi.fn(() => 4321), + hoverChromeMcpElement: vi.fn(async () => {}), + listChromeMcpTabs: vi.fn(async () => [ + { targetId: "7", title: "", url: "https://example.com", type: "page" }, + ]), + navigateChromeMcpPage: vi.fn(async ({ url }: { url: string }) => ({ url })), + openChromeMcpTab: vi.fn(async (_profile: string, url: string) => ({ + targetId: "8", + title: "", + url, + type: "page", + })), + pressChromeMcpKey: vi.fn(async () => {}), + resizeChromeMcpPage: vi.fn(async () => {}), + takeChromeMcpScreenshot: vi.fn(async () => Buffer.from("png")), + takeChromeMcpSnapshot: vi.fn(async () => ({ + id: "root", + role: "document", + name: "Example", + children: [{ id: "btn-1", role: "button", name: "Continue" }], + })), + uploadChromeMcpFile: vi.fn(async () => {}), +})); + +export function getChromeMcpMocks(): Record { + return chromeMcpMocks as unknown as Record; +} + const chromeUserDataDir = vi.hoisted(() => ({ dir: "/tmp/openclaw" })); installChromeUserDataDirHooks(chromeUserDataDir); @@ -148,24 +207,40 @@ function makeProc(pid = 123) { const proc = makeProc(); +function defaultProfilesForState(testPort: number): HarnessState["cfgProfiles"] { + return { + openclaw: { cdpPort: testPort + 9, color: "#FF4500" }, + }; +} + vi.mock("../config/config.js", async (importOriginal) => { const actual = await importOriginal(); - return { - ...actual, - loadConfig: () => ({ + const loadConfig = () => { + return { browser: { enabled: true, evaluateEnabled: state.cfgEvaluateEnabled, color: "#FF4500", attachOnly: state.cfgAttachOnly, headless: true, - defaultProfile: "openclaw", - profiles: { - openclaw: { cdpPort: state.testPort + 1, color: "#FF4500" }, - }, + defaultProfile: state.cfgDefaultProfile, + profiles: + Object.keys(state.cfgProfiles).length > 0 + ? state.cfgProfiles + : defaultProfilesForState(state.testPort), }, - }), - writeConfigFile: vi.fn(async () => {}), + }; + }; + const writeConfigFile = vi.fn(async () => {}); + return { + ...actual, + createConfigIO: vi.fn(() => ({ + loadConfig, + writeConfigFile, + })), + getRuntimeConfigSnapshot: vi.fn(() => null), + loadConfig, + writeConfigFile, }; }); @@ -210,6 +285,8 @@ vi.mock("./cdp.js", () => ({ vi.mock("./pw-ai.js", () => pwMocks); +vi.mock("./chrome-mcp.js", () => chromeMcpMocks); + vi.mock("../media/store.js", () => ({ MEDIA_MAX_BYTES: 5 * 1024 * 1024, ensureMediaDir: vi.fn(async () => {}), @@ -254,13 +331,18 @@ function mockClearAll(obj: Record unknown }>) { export async function resetBrowserControlServerTestContext(): Promise { state.reachable = false; state.cfgAttachOnly = false; + state.cfgEvaluateEnabled = true; + state.cfgDefaultProfile = "openclaw"; + state.cfgProfiles = defaultProfilesForState(state.testPort); state.createTargetId = null; mockClearAll(pwMocks); mockClearAll(cdpMocks); + mockClearAll(chromeMcpMocks); state.testPort = await getFreePort(); - state.cdpBaseUrl = `http://127.0.0.1:${state.testPort + 1}`; + state.cdpBaseUrl = `http://127.0.0.1:${state.testPort + 9}`; + state.cfgProfiles = defaultProfilesForState(state.testPort); state.prevGatewayPort = process.env.OPENCLAW_GATEWAY_PORT; process.env.OPENCLAW_GATEWAY_PORT = String(state.testPort - 2); // Avoid flaky auth coupling: some suites temporarily set gateway env auth diff --git a/src/cli/browser-cli-manage.timeout-option.test.ts b/src/cli/browser-cli-manage.timeout-option.test.ts index 134f13bc3c3..7338d97701e 100644 --- a/src/cli/browser-cli-manage.timeout-option.test.ts +++ b/src/cli/browser-cli-manage.timeout-option.test.ts @@ -76,4 +76,48 @@ describe("browser manage start timeout option", () => { expect(startCall?.[0]).toMatchObject({ timeout: "60000" }); expect(startCall?.[2]).toBeUndefined(); }); + + it("uses a longer built-in timeout for browser status", async () => { + const program = createProgram(); + await program.parseAsync(["browser", "status"], { from: "user" }); + + const statusCall = mocks.callBrowserRequest.mock.calls.find( + (call) => ((call[1] ?? {}) as { path?: string }).path === "/", + ) as [Record, { path?: string }, { timeoutMs?: number }] | undefined; + + expect(statusCall?.[2]).toEqual({ timeoutMs: 45_000 }); + }); + + it("uses a longer built-in timeout for browser tabs", async () => { + const program = createProgram(); + await program.parseAsync(["browser", "tabs"], { from: "user" }); + + const tabsCall = mocks.callBrowserRequest.mock.calls.find( + (call) => ((call[1] ?? {}) as { path?: string }).path === "/tabs", + ) as [Record, { path?: string }, { timeoutMs?: number }] | undefined; + + expect(tabsCall?.[2]).toEqual({ timeoutMs: 45_000 }); + }); + + it("uses a longer built-in timeout for browser profiles", async () => { + const program = createProgram(); + await program.parseAsync(["browser", "profiles"], { from: "user" }); + + const profilesCall = mocks.callBrowserRequest.mock.calls.find( + (call) => ((call[1] ?? {}) as { path?: string }).path === "/profiles", + ) as [Record, { path?: string }, { timeoutMs?: number }] | undefined; + + expect(profilesCall?.[2]).toEqual({ timeoutMs: 45_000 }); + }); + + it("uses a longer built-in timeout for browser open", async () => { + const program = createProgram(); + await program.parseAsync(["browser", "open", "https://example.com"], { from: "user" }); + + const openCall = mocks.callBrowserRequest.mock.calls.find( + (call) => ((call[1] ?? {}) as { path?: string }).path === "/tabs/open", + ) as [Record, { path?: string }, { timeoutMs?: number }] | undefined; + + expect(openCall?.[2]).toEqual({ timeoutMs: 45_000 }); + }); }); diff --git a/src/cli/browser-cli-manage.ts b/src/cli/browser-cli-manage.ts index 31d4b02c2aa..8fad97eaf38 100644 --- a/src/cli/browser-cli-manage.ts +++ b/src/cli/browser-cli-manage.ts @@ -13,6 +13,8 @@ import { shortenHomePath } from "../utils.js"; import { callBrowserRequest, type BrowserParentOpts } from "./browser-cli-shared.js"; import { runCommandWithRuntime } from "./cli-utils.js"; +const BROWSER_MANAGE_REQUEST_TIMEOUT_MS = 45_000; + function resolveProfileQuery(profile?: string) { return profile ? { profile } : undefined; } @@ -38,7 +40,7 @@ async function callTabAction( query: resolveProfileQuery(profile), body, }, - { timeoutMs: 10_000 }, + { timeoutMs: BROWSER_MANAGE_REQUEST_TIMEOUT_MS }, ); } @@ -54,7 +56,7 @@ async function fetchBrowserStatus( query: resolveProfileQuery(profile), }, { - timeoutMs: 1500, + timeoutMs: BROWSER_MANAGE_REQUEST_TIMEOUT_MS, }, ); } @@ -196,7 +198,7 @@ export function registerBrowserManageCommands( path: "/tabs", query: resolveProfileQuery(profile), }, - { timeoutMs: 3000 }, + { timeoutMs: BROWSER_MANAGE_REQUEST_TIMEOUT_MS }, ); const tabs = result.tabs ?? []; logBrowserTabs(tabs, parent?.json); @@ -220,7 +222,7 @@ export function registerBrowserManageCommands( action: "list", }, }, - { timeoutMs: 10_000 }, + { timeoutMs: BROWSER_MANAGE_REQUEST_TIMEOUT_MS }, ); const tabs = result.tabs ?? []; logBrowserTabs(tabs, parent?.json); @@ -305,7 +307,7 @@ export function registerBrowserManageCommands( query: resolveProfileQuery(profile), body: { url }, }, - { timeoutMs: 15000 }, + { timeoutMs: BROWSER_MANAGE_REQUEST_TIMEOUT_MS }, ); if (printJsonResult(parent, tab)) { return; @@ -330,7 +332,7 @@ export function registerBrowserManageCommands( query: resolveProfileQuery(profile), body: { targetId }, }, - { timeoutMs: 5000 }, + { timeoutMs: BROWSER_MANAGE_REQUEST_TIMEOUT_MS }, ); if (printJsonResult(parent, { ok: true })) { return; @@ -355,7 +357,7 @@ export function registerBrowserManageCommands( path: `/tabs/${encodeURIComponent(targetId.trim())}`, query: resolveProfileQuery(profile), }, - { timeoutMs: 5000 }, + { timeoutMs: BROWSER_MANAGE_REQUEST_TIMEOUT_MS }, ); } else { await callBrowserRequest( @@ -366,7 +368,7 @@ export function registerBrowserManageCommands( query: resolveProfileQuery(profile), body: { kind: "close" }, }, - { timeoutMs: 20000 }, + { timeoutMs: BROWSER_MANAGE_REQUEST_TIMEOUT_MS }, ); } if (printJsonResult(parent, { ok: true })) { @@ -389,7 +391,7 @@ export function registerBrowserManageCommands( method: "GET", path: "/profiles", }, - { timeoutMs: 3000 }, + { timeoutMs: BROWSER_MANAGE_REQUEST_TIMEOUT_MS }, ); const profiles = result.profiles ?? []; if (printJsonResult(parent, { profiles })) { From aaeb348bb7cbbaebe14a471776909bff129499a3 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 13 Mar 2026 15:48:44 -0700 Subject: [PATCH 109/461] Browser: scope nested batch failures in switch --- .../pw-tools-core.interactions.batch.test.ts | 131 ++++++++ src/browser/pw-tools-core.interactions.ts | 313 ++++++++++++++++-- 2 files changed, 412 insertions(+), 32 deletions(-) create mode 100644 src/browser/pw-tools-core.interactions.batch.test.ts diff --git a/src/browser/pw-tools-core.interactions.batch.test.ts b/src/browser/pw-tools-core.interactions.batch.test.ts new file mode 100644 index 00000000000..f566d04bc00 --- /dev/null +++ b/src/browser/pw-tools-core.interactions.batch.test.ts @@ -0,0 +1,131 @@ +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +let page: { evaluate: ReturnType } | null = null; + +const getPageForTargetId = vi.fn(async () => { + if (!page) { + throw new Error("test: page not set"); + } + return page; +}); +const ensurePageState = vi.fn(() => {}); +const forceDisconnectPlaywrightForTarget = vi.fn(async () => {}); +const refLocator = vi.fn(() => { + throw new Error("test: refLocator should not be called"); +}); +const restoreRoleRefsForTarget = vi.fn(() => {}); + +const closePageViaPlaywright = vi.fn(async () => {}); +const resizeViewportViaPlaywright = vi.fn(async () => {}); + +vi.mock("./pw-session.js", () => ({ + ensurePageState, + forceDisconnectPlaywrightForTarget, + getPageForTargetId, + refLocator, + restoreRoleRefsForTarget, +})); + +vi.mock("./pw-tools-core.snapshot.js", () => ({ + closePageViaPlaywright, + resizeViewportViaPlaywright, +})); + +let batchViaPlaywright: typeof import("./pw-tools-core.interactions.js").batchViaPlaywright; + +describe("batchViaPlaywright", () => { + beforeAll(async () => { + ({ batchViaPlaywright } = await import("./pw-tools-core.interactions.js")); + }); + + beforeEach(() => { + vi.clearAllMocks(); + page = { + evaluate: vi.fn(async () => "ok"), + }; + }); + + it("propagates evaluate timeouts through batched execution", async () => { + const result = await batchViaPlaywright({ + cdpUrl: "http://127.0.0.1:9222", + targetId: "tab-1", + evaluateEnabled: true, + actions: [{ kind: "evaluate", fn: "() => 1", timeoutMs: 5000 }], + }); + + expect(result).toEqual({ results: [{ ok: true }] }); + expect(page?.evaluate).toHaveBeenCalledWith( + expect.any(Function), + expect.objectContaining({ + fnBody: "() => 1", + timeoutMs: 4500, + }), + ); + }); + + it("supports resize and close inside a batch", async () => { + const result = await batchViaPlaywright({ + cdpUrl: "http://127.0.0.1:9222", + targetId: "tab-1", + actions: [{ kind: "resize", width: 800, height: 600 }, { kind: "close" }], + }); + + expect(result).toEqual({ results: [{ ok: true }, { ok: true }] }); + expect(resizeViewportViaPlaywright).toHaveBeenCalledWith({ + cdpUrl: "http://127.0.0.1:9222", + targetId: "tab-1", + width: 800, + height: 600, + }); + expect(closePageViaPlaywright).toHaveBeenCalledWith({ + cdpUrl: "http://127.0.0.1:9222", + targetId: "tab-1", + }); + }); + + it("propagates nested batch failures to the parent batch result", async () => { + const result = await batchViaPlaywright({ + cdpUrl: "http://127.0.0.1:9222", + targetId: "tab-1", + actions: [ + { + kind: "batch", + actions: [{ kind: "evaluate", fn: "() => 1" }], + }, + ], + }); + + expect(result).toEqual({ + results: [ + { ok: false, error: "act:evaluate is disabled by config (browser.evaluateEnabled=false)" }, + ], + }); + }); + + it("includes all nested batch failures when stopOnError is false", async () => { + const result = await batchViaPlaywright({ + cdpUrl: "http://127.0.0.1:9222", + targetId: "tab-1", + actions: [ + { + kind: "batch", + stopOnError: false, + actions: [ + { kind: "evaluate", fn: "() => 1" }, + { kind: "evaluate", fn: "() => 2" }, + ], + }, + ], + }); + + expect(result).toEqual({ + results: [ + { + ok: false, + error: + "act:evaluate is disabled by config (browser.evaluateEnabled=false); act:evaluate is disabled by config (browser.evaluateEnabled=false)", + }, + ], + }); + }); +}); diff --git a/src/browser/pw-tools-core.interactions.ts b/src/browser/pw-tools-core.interactions.ts index 852b11bb6dc..8c52301af56 100644 --- a/src/browser/pw-tools-core.interactions.ts +++ b/src/browser/pw-tools-core.interactions.ts @@ -1,4 +1,4 @@ -import type { BrowserFormField } from "./client-actions-core.js"; +import type { BrowserActRequest, BrowserFormField } from "./client-actions-core.js"; import { DEFAULT_FILL_FIELD_TYPE } from "./form-fields.js"; import { DEFAULT_UPLOAD_DIR, resolveStrictExistingPathsWithinRoot } from "./paths.js"; import { @@ -8,12 +8,32 @@ import { refLocator, restoreRoleRefsForTarget, } from "./pw-session.js"; -import { normalizeTimeoutMs, requireRef, toAIFriendlyError } from "./pw-tools-core.shared.js"; +import { + normalizeTimeoutMs, + requireRef, + requireRefOrSelector, + toAIFriendlyError, +} from "./pw-tools-core.shared.js"; +import { closePageViaPlaywright, resizeViewportViaPlaywright } from "./pw-tools-core.snapshot.js"; type TargetOpts = { cdpUrl: string; targetId?: string; }; +const MAX_CLICK_DELAY_MS = 5_000; +const MAX_WAIT_TIME_MS = 30_000; +const MAX_BATCH_ACTIONS = 100; + +function resolveBoundedDelayMs(value: number | undefined, label: string, maxMs: number): number { + const normalized = Math.floor(value ?? 0); + if (!Number.isFinite(normalized) || normalized < 0) { + throw new Error(`${label} must be >= 0`); + } + if (normalized > maxMs) { + throw new Error(`${label} exceeds maximum of ${maxMs}ms`); + } + return normalized; +} async function getRestoredPageForTarget(opts: TargetOpts) { const page = await getPageForTargetId(opts); @@ -59,17 +79,27 @@ export async function highlightViaPlaywright(opts: { export async function clickViaPlaywright(opts: { cdpUrl: string; targetId?: string; - ref: string; + ref?: string; + selector?: string; doubleClick?: boolean; button?: "left" | "right" | "middle"; modifiers?: Array<"Alt" | "Control" | "ControlOrMeta" | "Meta" | "Shift">; + delayMs?: number; timeoutMs?: number; }): Promise { + const resolved = requireRefOrSelector(opts.ref, opts.selector); const page = await getRestoredPageForTarget(opts); - const ref = requireRef(opts.ref); - const locator = refLocator(page, ref); + const label = resolved.ref ?? resolved.selector!; + const locator = resolved.ref + ? refLocator(page, requireRef(resolved.ref)) + : page.locator(resolved.selector); const timeout = resolveInteractionTimeoutMs(opts.timeoutMs); try { + const delayMs = resolveBoundedDelayMs(opts.delayMs, "click delayMs", MAX_CLICK_DELAY_MS); + if (delayMs > 0) { + await locator.hover({ timeout }); + await new Promise((r) => setTimeout(r, delayMs)); + } if (opts.doubleClick) { await locator.dblclick({ timeout, @@ -84,67 +114,84 @@ export async function clickViaPlaywright(opts: { }); } } catch (err) { - throw toAIFriendlyError(err, ref); + throw toAIFriendlyError(err, label); } } export async function hoverViaPlaywright(opts: { cdpUrl: string; targetId?: string; - ref: string; + ref?: string; + selector?: string; timeoutMs?: number; }): Promise { - const ref = requireRef(opts.ref); + const resolved = requireRefOrSelector(opts.ref, opts.selector); const page = await getRestoredPageForTarget(opts); + const label = resolved.ref ?? resolved.selector!; + const locator = resolved.ref + ? refLocator(page, requireRef(resolved.ref)) + : page.locator(resolved.selector); try { - await refLocator(page, ref).hover({ + await locator.hover({ timeout: resolveInteractionTimeoutMs(opts.timeoutMs), }); } catch (err) { - throw toAIFriendlyError(err, ref); + throw toAIFriendlyError(err, label); } } export async function dragViaPlaywright(opts: { cdpUrl: string; targetId?: string; - startRef: string; - endRef: string; + startRef?: string; + startSelector?: string; + endRef?: string; + endSelector?: string; timeoutMs?: number; }): Promise { - const startRef = requireRef(opts.startRef); - const endRef = requireRef(opts.endRef); - if (!startRef || !endRef) { - throw new Error("startRef and endRef are required"); - } + const resolvedStart = requireRefOrSelector(opts.startRef, opts.startSelector); + const resolvedEnd = requireRefOrSelector(opts.endRef, opts.endSelector); const page = await getRestoredPageForTarget(opts); + const startLocator = resolvedStart.ref + ? refLocator(page, requireRef(resolvedStart.ref)) + : page.locator(resolvedStart.selector); + const endLocator = resolvedEnd.ref + ? refLocator(page, requireRef(resolvedEnd.ref)) + : page.locator(resolvedEnd.selector); + const startLabel = resolvedStart.ref ?? resolvedStart.selector!; + const endLabel = resolvedEnd.ref ?? resolvedEnd.selector!; try { - await refLocator(page, startRef).dragTo(refLocator(page, endRef), { + await startLocator.dragTo(endLocator, { timeout: resolveInteractionTimeoutMs(opts.timeoutMs), }); } catch (err) { - throw toAIFriendlyError(err, `${startRef} -> ${endRef}`); + throw toAIFriendlyError(err, `${startLabel} -> ${endLabel}`); } } export async function selectOptionViaPlaywright(opts: { cdpUrl: string; targetId?: string; - ref: string; + ref?: string; + selector?: string; values: string[]; timeoutMs?: number; }): Promise { - const ref = requireRef(opts.ref); + const resolved = requireRefOrSelector(opts.ref, opts.selector); if (!opts.values?.length) { throw new Error("values are required"); } const page = await getRestoredPageForTarget(opts); + const label = resolved.ref ?? resolved.selector!; + const locator = resolved.ref + ? refLocator(page, requireRef(resolved.ref)) + : page.locator(resolved.selector); try { - await refLocator(page, ref).selectOption(opts.values, { + await locator.selectOption(opts.values, { timeout: resolveInteractionTimeoutMs(opts.timeoutMs), }); } catch (err) { - throw toAIFriendlyError(err, ref); + throw toAIFriendlyError(err, label); } } @@ -168,16 +215,20 @@ export async function pressKeyViaPlaywright(opts: { export async function typeViaPlaywright(opts: { cdpUrl: string; targetId?: string; - ref: string; + ref?: string; + selector?: string; text: string; submit?: boolean; slowly?: boolean; timeoutMs?: number; }): Promise { + const resolved = requireRefOrSelector(opts.ref, opts.selector); const text = String(opts.text ?? ""); const page = await getRestoredPageForTarget(opts); - const ref = requireRef(opts.ref); - const locator = refLocator(page, ref); + const label = resolved.ref ?? resolved.selector!; + const locator = resolved.ref + ? refLocator(page, requireRef(resolved.ref)) + : page.locator(resolved.selector); const timeout = resolveInteractionTimeoutMs(opts.timeoutMs); try { if (opts.slowly) { @@ -190,7 +241,7 @@ export async function typeViaPlaywright(opts: { await locator.press("Enter", { timeout }); } } catch (err) { - throw toAIFriendlyError(err, ref); + throw toAIFriendlyError(err, label); } } @@ -367,18 +418,22 @@ export async function evaluateViaPlaywright(opts: { export async function scrollIntoViewViaPlaywright(opts: { cdpUrl: string; targetId?: string; - ref: string; + ref?: string; + selector?: string; timeoutMs?: number; }): Promise { + const resolved = requireRefOrSelector(opts.ref, opts.selector); const page = await getRestoredPageForTarget(opts); const timeout = normalizeTimeoutMs(opts.timeoutMs, 20_000); - const ref = requireRef(opts.ref); - const locator = refLocator(page, ref); + const label = resolved.ref ?? resolved.selector!; + const locator = resolved.ref + ? refLocator(page, requireRef(resolved.ref)) + : page.locator(resolved.selector); try { await locator.scrollIntoViewIfNeeded({ timeout }); } catch (err) { - throw toAIFriendlyError(err, ref); + throw toAIFriendlyError(err, label); } } @@ -399,7 +454,7 @@ export async function waitForViaPlaywright(opts: { const timeout = normalizeTimeoutMs(opts.timeoutMs, 20_000); if (typeof opts.timeMs === "number" && Number.isFinite(opts.timeMs)) { - await page.waitForTimeout(Math.max(0, opts.timeMs)); + await page.waitForTimeout(resolveBoundedDelayMs(opts.timeMs, "wait timeMs", MAX_WAIT_TIME_MS)); } if (opts.text) { await page.getByText(opts.text).first().waitFor({ @@ -648,3 +703,197 @@ export async function setInputFilesViaPlaywright(opts: { // Best-effort for sites that don't react to setInputFiles alone. } } + +const MAX_BATCH_DEPTH = 5; + +async function executeSingleAction( + action: BrowserActRequest, + cdpUrl: string, + targetId?: string, + evaluateEnabled?: boolean, + depth = 0, +): Promise { + if (depth > MAX_BATCH_DEPTH) { + throw new Error(`Batch nesting depth exceeds maximum of ${MAX_BATCH_DEPTH}`); + } + const effectiveTargetId = action.targetId ?? targetId; + switch (action.kind) { + case "click": + await clickViaPlaywright({ + cdpUrl, + targetId: effectiveTargetId, + ref: action.ref, + selector: action.selector, + doubleClick: action.doubleClick, + button: action.button as "left" | "right" | "middle" | undefined, + modifiers: action.modifiers as Array< + "Alt" | "Control" | "ControlOrMeta" | "Meta" | "Shift" + >, + delayMs: action.delayMs, + timeoutMs: action.timeoutMs, + }); + break; + case "type": + await typeViaPlaywright({ + cdpUrl, + targetId: effectiveTargetId, + ref: action.ref, + selector: action.selector, + text: action.text, + submit: action.submit, + slowly: action.slowly, + timeoutMs: action.timeoutMs, + }); + break; + case "press": + await pressKeyViaPlaywright({ + cdpUrl, + targetId: effectiveTargetId, + key: action.key, + delayMs: action.delayMs, + }); + break; + case "hover": + await hoverViaPlaywright({ + cdpUrl, + targetId: effectiveTargetId, + ref: action.ref, + selector: action.selector, + timeoutMs: action.timeoutMs, + }); + break; + case "scrollIntoView": + await scrollIntoViewViaPlaywright({ + cdpUrl, + targetId: effectiveTargetId, + ref: action.ref, + selector: action.selector, + timeoutMs: action.timeoutMs, + }); + break; + case "drag": + await dragViaPlaywright({ + cdpUrl, + targetId: effectiveTargetId, + startRef: action.startRef, + startSelector: action.startSelector, + endRef: action.endRef, + endSelector: action.endSelector, + timeoutMs: action.timeoutMs, + }); + break; + case "select": + await selectOptionViaPlaywright({ + cdpUrl, + targetId: effectiveTargetId, + ref: action.ref, + selector: action.selector, + values: action.values, + timeoutMs: action.timeoutMs, + }); + break; + case "fill": + await fillFormViaPlaywright({ + cdpUrl, + targetId: effectiveTargetId, + fields: action.fields, + timeoutMs: action.timeoutMs, + }); + break; + case "resize": + await resizeViewportViaPlaywright({ + cdpUrl, + targetId: effectiveTargetId, + width: action.width, + height: action.height, + }); + break; + case "wait": + if (action.fn && !evaluateEnabled) { + throw new Error("wait --fn is disabled by config (browser.evaluateEnabled=false)"); + } + await waitForViaPlaywright({ + cdpUrl, + targetId: effectiveTargetId, + timeMs: action.timeMs, + text: action.text, + textGone: action.textGone, + selector: action.selector, + url: action.url, + loadState: action.loadState, + fn: action.fn, + timeoutMs: action.timeoutMs, + }); + break; + case "evaluate": + if (!evaluateEnabled) { + throw new Error("act:evaluate is disabled by config (browser.evaluateEnabled=false)"); + } + await evaluateViaPlaywright({ + cdpUrl, + targetId: effectiveTargetId, + fn: action.fn, + ref: action.ref, + timeoutMs: action.timeoutMs, + }); + break; + case "close": + await closePageViaPlaywright({ + cdpUrl, + targetId: effectiveTargetId, + }); + break; + case "batch": { + // Nested batches: delegate recursively + const nestedFailures = ( + await batchViaPlaywright({ + cdpUrl, + targetId: effectiveTargetId, + actions: action.actions, + stopOnError: action.stopOnError, + evaluateEnabled, + depth: depth + 1, + }) + ).results.filter((result) => !result.ok); + if (nestedFailures.length > 0) { + throw new Error( + nestedFailures.map((result) => result.error ?? "Nested batch action failed").join("; "), + ); + } + break; + } + default: + throw new Error(`Unsupported batch action kind: ${(action as { kind: string }).kind}`); + } +} + +export async function batchViaPlaywright(opts: { + cdpUrl: string; + targetId?: string; + actions: BrowserActRequest[]; + stopOnError?: boolean; + evaluateEnabled?: boolean; + depth?: number; +}): Promise<{ results: Array<{ ok: boolean; error?: string }> }> { + const depth = opts.depth ?? 0; + if (depth > MAX_BATCH_DEPTH) { + throw new Error(`Batch nesting depth exceeds maximum of ${MAX_BATCH_DEPTH}`); + } + if (opts.actions.length > MAX_BATCH_ACTIONS) { + throw new Error(`Batch exceeds maximum of ${MAX_BATCH_ACTIONS} actions`); + } + const results: Array<{ ok: boolean; error?: string }> = []; + for (const action of opts.actions) { + try { + await executeSingleAction(action, opts.cdpUrl, opts.targetId, opts.evaluateEnabled, depth); + results.push({ ok: true }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + results.push({ ok: false, error: message }); + if (opts.stopOnError !== false) { + break; + } + } + } + return { results }; +} From 1803d16d5cec970c54b0e1ac46b31b1cbade335c Mon Sep 17 00:00:00 2001 From: Robin Waslander Date: Fri, 13 Mar 2026 23:54:58 +0100 Subject: [PATCH 110/461] fix(auth): make device bootstrap tokens single-use to prevent scope escalation Refs: GHSA-63f5-hhc7-cx6p --- CHANGELOG.md | 1 + src/infra/device-bootstrap.test.ts | 22 +++++++++++++-- src/infra/device-bootstrap.ts | 39 ++------------------------ src/infra/device-pairing.test.ts | 44 ++++++++++++++++++++++++++++++ 4 files changed, 67 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 48290d3389f..c64548aa5a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,7 @@ Docs: https://docs.openclaw.ai - Slack/probe: keep `auth.test()` bot and team metadata mapping stable while simplifying the probe result path. (#44775) Thanks @Cafexss. - Dashboard/chat UI: restore the `chat-new-messages` class on the New messages scroll pill so the button uses its existing compact styling instead of rendering as a full-screen SVG overlay. (#44856) Thanks @Astro-Han. - Windows/gateway status: reuse the installed service command environment when reading runtime status, so startup-fallback gateways keep reporting the configured port and running state in `gateway status --json` instead of falling back to `gateway port unknown`. +- Security/device pairing: make bootstrap setup codes single-use so pending device pairing requests cannot be silently replayed and widened to admin before approval. Thanks @tdjackey. ## 2026.3.12 diff --git a/src/infra/device-bootstrap.test.ts b/src/infra/device-bootstrap.test.ts index e20aafab9b6..a8206f30b02 100644 --- a/src/infra/device-bootstrap.test.ts +++ b/src/infra/device-bootstrap.test.ts @@ -24,7 +24,23 @@ afterEach(async () => { }); describe("device bootstrap tokens", () => { - it("binds the first successful verification to a device identity", async () => { + it("accepts the first successful verification", async () => { + const baseDir = await createBaseDir(); + const issued = await issueDeviceBootstrapToken({ baseDir }); + + await expect( + verifyDeviceBootstrapToken({ + token: issued.token, + deviceId: "device-1", + publicKey: "pub-1", + role: "node", + scopes: ["node.invoke"], + baseDir, + }), + ).resolves.toEqual({ ok: true }); + }); + + it("rejects replay after the first successful verification", async () => { const baseDir = await createBaseDir(); const issued = await issueDeviceBootstrapToken({ baseDir }); @@ -48,10 +64,10 @@ describe("device bootstrap tokens", () => { scopes: ["operator.read"], baseDir, }), - ).resolves.toEqual({ ok: true }); + ).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" }); }); - it("rejects reuse from a different device after binding", async () => { + it("rejects reuse from a different device after consumption", async () => { const baseDir = await createBaseDir(); const issued = await issueDeviceBootstrapToken({ baseDir }); diff --git a/src/infra/device-bootstrap.ts b/src/infra/device-bootstrap.ts index 9f763b50cb3..50a4e53ffd2 100644 --- a/src/infra/device-bootstrap.ts +++ b/src/infra/device-bootstrap.ts @@ -25,29 +25,6 @@ type DeviceBootstrapStateFile = Record; const withLock = createAsyncLock(); -function mergeRoles(existing: string[] | undefined, role: string): string[] { - const out = new Set(existing ?? []); - const trimmed = role.trim(); - if (trimmed) { - out.add(trimmed); - } - return [...out]; -} - -function mergeScopes( - existing: string[] | undefined, - scopes: readonly string[], -): string[] | undefined { - const out = new Set(existing ?? []); - for (const scope of scopes) { - const trimmed = scope.trim(); - if (trimmed) { - out.add(trimmed); - } - } - return out.size > 0 ? [...out] : undefined; -} - function resolveBootstrapPath(baseDir?: string): string { return path.join(resolvePairingPaths(baseDir, "devices").dir, "bootstrap.json"); } @@ -116,19 +93,9 @@ export async function verifyDeviceBootstrapToken(params: { return { ok: false, reason: "bootstrap_token_invalid" }; } - if (entry.deviceId && entry.deviceId !== deviceId) { - return { ok: false, reason: "bootstrap_token_invalid" }; - } - if (entry.publicKey && entry.publicKey !== publicKey) { - return { ok: false, reason: "bootstrap_token_invalid" }; - } - - entry.deviceId = deviceId; - entry.publicKey = publicKey; - entry.roles = mergeRoles(entry.roles, role); - entry.scopes = mergeScopes(entry.scopes, params.scopes); - entry.lastUsedAtMs = Date.now(); - state[entry.token] = entry; + // Bootstrap setup codes are single-use. Consume the record before returning + // success so the same token cannot be replayed to mutate a pending request. + delete state[entry.token]; await persistState(state, params.baseDir); return { ok: true }; }); diff --git a/src/infra/device-pairing.test.ts b/src/infra/device-pairing.test.ts index 17f03df089a..ddf0826d048 100644 --- a/src/infra/device-pairing.test.ts +++ b/src/infra/device-pairing.test.ts @@ -2,6 +2,7 @@ import { mkdtemp, readFile, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, test } from "vitest"; +import { issueDeviceBootstrapToken, verifyDeviceBootstrapToken } from "./device-bootstrap.js"; import { approveDevicePairing, clearDevicePairing, @@ -146,6 +147,49 @@ describe("device pairing tokens", () => { expect(paired?.scopes).toEqual(["operator.read", "operator.write"]); }); + test("rejects bootstrap token replay before pending scope escalation can be approved", async () => { + const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-")); + const issued = await issueDeviceBootstrapToken({ baseDir }); + + await expect( + verifyDeviceBootstrapToken({ + token: issued.token, + deviceId: "device-1", + publicKey: "public-key-1", + role: "operator", + scopes: ["operator.read"], + baseDir, + }), + ).resolves.toEqual({ ok: true }); + + const first = await requestDevicePairing( + { + deviceId: "device-1", + publicKey: "public-key-1", + role: "operator", + scopes: ["operator.read"], + }, + baseDir, + ); + + await expect( + verifyDeviceBootstrapToken({ + token: issued.token, + deviceId: "device-1", + publicKey: "public-key-1", + role: "operator", + scopes: ["operator.admin"], + baseDir, + }), + ).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" }); + + await approveDevicePairing(first.request.requestId, baseDir); + const paired = await getPairedDevice("device-1", baseDir); + expect(paired?.scopes).toEqual(["operator.read"]); + expect(paired?.approvedScopes).toEqual(["operator.read"]); + expect(paired?.tokens?.operator?.scopes).toEqual(["operator.read"]); + }); + test("generates base64url device tokens with 256-bit entropy output length", async () => { const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-")); await setupPairedOperatorDevice(baseDir, ["operator.admin"]); From 63802c1112886be6b1043524cb536b1ab7999921 Mon Sep 17 00:00:00 2001 From: Jacob Tomlinson Date: Fri, 13 Mar 2026 23:23:02 +0000 Subject: [PATCH 111/461] docker: add apt-get upgrade to all Dockerfiles (#45384) * docker: add apt-get upgrade to patch base-image vulnerabilities Closes #45159 * docker: add DEBIAN_FRONTEND and --no-install-recommends to apt-get upgrade Prevents debconf hangs during Docker builds and avoids pulling in recommended packages that silently grow the image. Co-Authored-By: Claude * Revert "docker: add DEBIAN_FRONTEND and --no-install-recommends to apt-get upgrade" This reverts commit 6fc3839cb56d4eb08cb43764fcbe7bd72e9bc50a. * docker: add DEBIAN_FRONTEND and --no-install-recommends to apt-get upgrade Prevents debconf hangs during Docker builds and avoids pulling in recommended packages that silently grow the image. Co-Authored-By: Claude --------- Co-authored-by: Claude --- Dockerfile | 1 + Dockerfile.sandbox | 1 + Dockerfile.sandbox-browser | 1 + Dockerfile.sandbox-common | 1 + scripts/docker/cleanup-smoke/Dockerfile | 1 + scripts/docker/install-sh-e2e/Dockerfile | 1 + scripts/docker/install-sh-nonroot/Dockerfile | 1 + scripts/docker/install-sh-smoke/Dockerfile | 1 + 8 files changed, 8 insertions(+) diff --git a/Dockerfile b/Dockerfile index 72c413ebe7b..57a3440f385 100644 --- a/Dockerfile +++ b/Dockerfile @@ -132,6 +132,7 @@ WORKDIR /app RUN --mount=type=cache,id=openclaw-bookworm-apt-cache,target=/var/cache/apt,sharing=locked \ --mount=type=cache,id=openclaw-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \ apt-get update && \ + DEBIAN_FRONTEND=noninteractive apt-get upgrade -y --no-install-recommends && \ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ procps hostname curl git openssl diff --git a/Dockerfile.sandbox b/Dockerfile.sandbox index 8b50c7a6745..37cdab5fcd2 100644 --- a/Dockerfile.sandbox +++ b/Dockerfile.sandbox @@ -7,6 +7,7 @@ ENV DEBIAN_FRONTEND=noninteractive RUN --mount=type=cache,id=openclaw-sandbox-bookworm-apt-cache,target=/var/cache/apt,sharing=locked \ --mount=type=cache,id=openclaw-sandbox-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \ apt-get update \ + && apt-get upgrade -y --no-install-recommends \ && apt-get install -y --no-install-recommends \ bash \ ca-certificates \ diff --git a/Dockerfile.sandbox-browser b/Dockerfile.sandbox-browser index f04e4a82a62..e8e8bb59f84 100644 --- a/Dockerfile.sandbox-browser +++ b/Dockerfile.sandbox-browser @@ -7,6 +7,7 @@ ENV DEBIAN_FRONTEND=noninteractive RUN --mount=type=cache,id=openclaw-sandbox-bookworm-apt-cache,target=/var/cache/apt,sharing=locked \ --mount=type=cache,id=openclaw-sandbox-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \ apt-get update \ + && apt-get upgrade -y --no-install-recommends \ && apt-get install -y --no-install-recommends \ bash \ ca-certificates \ diff --git a/Dockerfile.sandbox-common b/Dockerfile.sandbox-common index 39eaa3692b4..fba29a5df3d 100644 --- a/Dockerfile.sandbox-common +++ b/Dockerfile.sandbox-common @@ -24,6 +24,7 @@ ENV PATH=${BUN_INSTALL_DIR}/bin:${BREW_INSTALL_DIR}/bin:${BREW_INSTALL_DIR}/sbin RUN --mount=type=cache,id=openclaw-sandbox-common-apt-cache,target=/var/cache/apt,sharing=locked \ --mount=type=cache,id=openclaw-sandbox-common-apt-lists,target=/var/lib/apt,sharing=locked \ apt-get update \ + && apt-get upgrade -y --no-install-recommends \ && apt-get install -y --no-install-recommends ${PACKAGES} RUN if [ "${INSTALL_PNPM}" = "1" ]; then npm install -g pnpm; fi diff --git a/scripts/docker/cleanup-smoke/Dockerfile b/scripts/docker/cleanup-smoke/Dockerfile index 19b89f3ac62..07a2334aa41 100644 --- a/scripts/docker/cleanup-smoke/Dockerfile +++ b/scripts/docker/cleanup-smoke/Dockerfile @@ -5,6 +5,7 @@ FROM node:24-bookworm-slim@sha256:b4687aef2571c632a1953695ce4d61d6462a7eda471fe6 RUN --mount=type=cache,id=openclaw-cleanup-smoke-apt-cache,target=/var/cache/apt,sharing=locked \ --mount=type=cache,id=openclaw-cleanup-smoke-apt-lists,target=/var/lib/apt,sharing=locked \ apt-get update \ + && DEBIAN_FRONTEND=noninteractive apt-get upgrade -y --no-install-recommends \ && apt-get install -y --no-install-recommends \ bash \ ca-certificates \ diff --git a/scripts/docker/install-sh-e2e/Dockerfile b/scripts/docker/install-sh-e2e/Dockerfile index 539f18d295d..e8069bf1e77 100644 --- a/scripts/docker/install-sh-e2e/Dockerfile +++ b/scripts/docker/install-sh-e2e/Dockerfile @@ -5,6 +5,7 @@ FROM node:24-bookworm-slim@sha256:b4687aef2571c632a1953695ce4d61d6462a7eda471fe6 RUN --mount=type=cache,id=openclaw-install-sh-e2e-apt-cache,target=/var/cache/apt,sharing=locked \ --mount=type=cache,id=openclaw-install-sh-e2e-apt-lists,target=/var/lib/apt,sharing=locked \ apt-get update \ + && DEBIAN_FRONTEND=noninteractive apt-get upgrade -y --no-install-recommends \ && apt-get install -y --no-install-recommends \ bash \ ca-certificates \ diff --git a/scripts/docker/install-sh-nonroot/Dockerfile b/scripts/docker/install-sh-nonroot/Dockerfile index d0c085d9f69..8e29715dbfb 100644 --- a/scripts/docker/install-sh-nonroot/Dockerfile +++ b/scripts/docker/install-sh-nonroot/Dockerfile @@ -11,6 +11,7 @@ RUN --mount=type=cache,id=openclaw-install-sh-nonroot-apt-cache,target=/var/cach if [ "${attempt}" -eq 3 ]; then exit 1; fi; \ sleep 3; \ done; \ + DEBIAN_FRONTEND=noninteractive apt-get -o Acquire::Retries=3 upgrade -y --no-install-recommends; \ apt-get -o Acquire::Retries=3 install -y --no-install-recommends \ bash \ ca-certificates \ diff --git a/scripts/docker/install-sh-smoke/Dockerfile b/scripts/docker/install-sh-smoke/Dockerfile index 899af551aeb..ee37a24d6ce 100644 --- a/scripts/docker/install-sh-smoke/Dockerfile +++ b/scripts/docker/install-sh-smoke/Dockerfile @@ -11,6 +11,7 @@ RUN --mount=type=cache,id=openclaw-install-sh-smoke-apt-cache,target=/var/cache/ if [ "${attempt}" -eq 3 ]; then exit 1; fi; \ sleep 3; \ done; \ + DEBIAN_FRONTEND=noninteractive apt-get -o Acquire::Retries=3 upgrade -y --no-install-recommends; \ apt-get -o Acquire::Retries=3 install -y --no-install-recommends \ bash \ ca-certificates \ From 868fd32ee77424af5d0d27bba9ceb570b11fa929 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Fri, 13 Mar 2026 18:28:33 -0500 Subject: [PATCH 112/461] fix(config): avoid Anthropic startup crash (#45520) Co-authored-by: Val Alexander --- CHANGELOG.md | 1 + src/agents/model-selection.test.ts | 6 +++++ src/agents/model-selection.ts | 22 +++++++++++------- src/config/config.pruning-defaults.test.ts | 27 ++++++++++++++++++++++ 4 files changed, 48 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c64548aa5a0..2a08c7fb2e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -118,6 +118,7 @@ Docs: https://docs.openclaw.ai - Gateway/session stores: regenerate the Swift push-test protocol models and align Windows native session-store realpath handling so protocol checks and sync session discovery stop drifting on Windows. (#44266) thanks @jalehman. - Context engine/session routing: forward optional `sessionKey` through context-engine lifecycle calls so plugins can see structured routing metadata during bootstrap, assembly, post-turn ingestion, and compaction. (#44157) thanks @jalehman. - Agents/failover: classify z.ai `network_error` stop reasons as retryable timeouts so provider connectivity failures trigger fallback instead of surfacing raw unhandled-stop-reason errors. (#43884) Thanks @hougangdev. +- Config/Anthropic startup: inline Anthropic alias normalization during config load so gateway startup no longer crashes on dated Anthropic model refs like `anthropic/claude-sonnet-4-20250514`. (#45520) Thanks @BunsDev. - Memory/session sync: add mode-aware post-compaction session reindexing with `agents.defaults.compaction.postIndexSync` plus `agents.defaults.memorySearch.sync.sessions.postCompactionForce`, so compacted session memory can refresh immediately without forcing every deployment into synchronous reindexing. (#25561) thanks @rodrigouroz. - Telegram/model picker: make inline model button selections persist the chosen session model correctly, clear overrides when selecting the configured default, and include effective fallback models in `/models` button validation. (#40105) Thanks @avirweb. - Telegram/native command sync: suppress expected `BOT_COMMANDS_TOO_MUCH` retry error noise, add a final fallback summary log, and document the difference between command-menu overflow and real Telegram network failures. diff --git a/src/agents/model-selection.test.ts b/src/agents/model-selection.test.ts index 35ac52dcf26..e2d90f355bc 100644 --- a/src/agents/model-selection.test.ts +++ b/src/agents/model-selection.test.ts @@ -121,6 +121,12 @@ describe("model-selection", () => { defaultProvider: "anthropic", expected: { provider: "anthropic", model: "claude-sonnet-4-6" }, }, + { + name: "keeps dated anthropic model ids unchanged", + variants: ["anthropic/claude-sonnet-4-20250514", "claude-sonnet-4-20250514"], + defaultProvider: "anthropic", + expected: { provider: "anthropic", model: "claude-sonnet-4-20250514" }, + }, { name: "normalizes deprecated google flash preview ids", variants: ["google/gemini-3.1-flash-preview", "gemini-3.1-flash-preview"], diff --git a/src/agents/model-selection.ts b/src/agents/model-selection.ts index 7bbd8ed8ba7..6606b0bc4b4 100644 --- a/src/agents/model-selection.ts +++ b/src/agents/model-selection.ts @@ -31,13 +31,6 @@ export type ModelAliasIndex = { byKey: Map; }; -const ANTHROPIC_MODEL_ALIASES: Record = { - "opus-4.6": "claude-opus-4-6", - "opus-4.5": "claude-opus-4-5", - "sonnet-4.6": "claude-sonnet-4-6", - "sonnet-4.5": "claude-sonnet-4-5", -}; - function normalizeAliasKey(value: string): string { return value.trim().toLowerCase(); } @@ -151,7 +144,20 @@ function normalizeAnthropicModelId(model: string): string { return trimmed; } const lower = trimmed.toLowerCase(); - return ANTHROPIC_MODEL_ALIASES[lower] ?? trimmed; + // Keep alias resolution local so bundled startup paths cannot trip a TDZ on + // a module-level alias table while config parsing is still initializing. + switch (lower) { + case "opus-4.6": + return "claude-opus-4-6"; + case "opus-4.5": + return "claude-opus-4-5"; + case "sonnet-4.6": + return "claude-sonnet-4-6"; + case "sonnet-4.5": + return "claude-sonnet-4-5"; + default: + return trimmed; + } } function normalizeProviderModelId(provider: string, model: string): string { diff --git a/src/config/config.pruning-defaults.test.ts b/src/config/config.pruning-defaults.test.ts index f2f66ce6bac..ad28175e5db 100644 --- a/src/config/config.pruning-defaults.test.ts +++ b/src/config/config.pruning-defaults.test.ts @@ -73,6 +73,33 @@ describe("config pruning defaults", () => { }); }); + it("adds cacheRetention defaults for dated Anthropic primary model refs", async () => { + await withTempHome(async (home) => { + await writeConfigForTest(home, { + auth: { + profiles: { + "anthropic:api": { provider: "anthropic", mode: "api_key" }, + }, + }, + agents: { + defaults: { + model: { primary: "anthropic/claude-sonnet-4-20250514" }, + }, + }, + }); + + const cfg = loadConfig(); + + expect(cfg.agents?.defaults?.contextPruning?.mode).toBe("cache-ttl"); + expect(cfg.agents?.defaults?.contextPruning?.ttl).toBe("1h"); + expect(cfg.agents?.defaults?.heartbeat?.every).toBe("30m"); + expect( + cfg.agents?.defaults?.models?.["anthropic/claude-sonnet-4-20250514"]?.params + ?.cacheRetention, + ).toBe("short"); + }); + }); + it("adds default cacheRetention for Anthropic Claude models on Bedrock", async () => { await withTempHome(async (home) => { await writeConfigForTest(home, { From a57c590a713878ba5bf812c346a580d6de078d15 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:54:54 +0000 Subject: [PATCH 113/461] refactor: share telegram outbound send options --- extensions/telegram/src/channel.ts | 104 +++++++++++++++++++++-------- 1 file changed, 77 insertions(+), 27 deletions(-) diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index 52ae2b15ea8..b5ae12fa06d 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -78,6 +78,61 @@ function formatDuplicateTelegramTokenReason(params: { ); } +type TelegramSendFn = ReturnType< + typeof getTelegramRuntime +>["channel"]["telegram"]["sendMessageTelegram"]; +type TelegramSendOptions = NonNullable[2]>; + +function buildTelegramSendOptions(params: { + cfg: OpenClawConfig; + mediaUrl?: string; + mediaLocalRoots?: readonly string[]; + accountId?: string; + replyToId?: string; + threadId?: string; + silent?: boolean; +}): TelegramSendOptions { + return { + verbose: false, + cfg: params.cfg, + ...(params.mediaUrl ? { mediaUrl: params.mediaUrl } : {}), + ...(params.mediaLocalRoots?.length ? { mediaLocalRoots: params.mediaLocalRoots } : {}), + messageThreadId: parseTelegramThreadId(params.threadId), + replyToMessageId: parseTelegramReplyToMessageId(params.replyToId), + accountId: params.accountId ?? undefined, + silent: params.silent ?? undefined, + }; +} + +async function sendTelegramOutbound(params: { + cfg: OpenClawConfig; + to: string; + text: string; + mediaUrl?: string; + mediaLocalRoots?: readonly string[]; + accountId?: string; + deps?: { sendTelegram?: TelegramSendFn }; + replyToId?: string; + threadId?: string; + silent?: boolean; +}) { + const send = + params.deps?.sendTelegram ?? getTelegramRuntime().channel.telegram.sendMessageTelegram; + return await send( + params.to, + params.text, + buildTelegramSendOptions({ + cfg: params.cfg, + mediaUrl: params.mediaUrl, + mediaLocalRoots: params.mediaLocalRoots, + accountId: params.accountId, + replyToId: params.replyToId, + threadId: params.threadId, + silent: params.silent, + }), + ); +} + const telegramMessageActions: ChannelMessageActionAdapter = { listActions: (ctx) => getTelegramRuntime().channel.telegram.messageActions?.listActions?.(ctx) ?? [], @@ -327,35 +382,31 @@ export const telegramPlugin: ChannelPlugin { const send = deps?.sendTelegram ?? getTelegramRuntime().channel.telegram.sendMessageTelegram; - const replyToMessageId = parseTelegramReplyToMessageId(replyToId); - const messageThreadId = parseTelegramThreadId(threadId); const result = await sendTelegramPayloadMessages({ send, to, payload, - baseOpts: { - verbose: false, + baseOpts: buildTelegramSendOptions({ cfg, mediaLocalRoots, - messageThreadId, - replyToMessageId, - accountId: accountId ?? undefined, - silent: silent ?? undefined, - }, + accountId, + replyToId, + threadId, + silent, + }), }); return { channel: "telegram", ...result }; }, sendText: async ({ cfg, to, text, accountId, deps, replyToId, threadId, silent }) => { - const send = deps?.sendTelegram ?? getTelegramRuntime().channel.telegram.sendMessageTelegram; - const replyToMessageId = parseTelegramReplyToMessageId(replyToId); - const messageThreadId = parseTelegramThreadId(threadId); - const result = await send(to, text, { - verbose: false, + const result = await sendTelegramOutbound({ cfg, - messageThreadId, - replyToMessageId, - accountId: accountId ?? undefined, - silent: silent ?? undefined, + to, + text, + accountId, + deps, + replyToId, + threadId, + silent, }); return { channel: "telegram", ...result }; }, @@ -371,18 +422,17 @@ export const telegramPlugin: ChannelPlugin { - const send = deps?.sendTelegram ?? getTelegramRuntime().channel.telegram.sendMessageTelegram; - const replyToMessageId = parseTelegramReplyToMessageId(replyToId); - const messageThreadId = parseTelegramThreadId(threadId); - const result = await send(to, text, { - verbose: false, + const result = await sendTelegramOutbound({ cfg, + to, + text, mediaUrl, mediaLocalRoots, - messageThreadId, - replyToMessageId, - accountId: accountId ?? undefined, - silent: silent ?? undefined, + accountId, + deps, + replyToId, + threadId, + silent, }); return { channel: "telegram", ...result }; }, From 407d0d296d6ed24ffdc0ff29b3e70400a6582e6c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:55:47 +0000 Subject: [PATCH 114/461] refactor: share tlon outbound send context --- extensions/tlon/src/channel.ts | 105 ++++++++++++++++----------------- 1 file changed, 50 insertions(+), 55 deletions(-) diff --git a/extensions/tlon/src/channel.ts b/extensions/tlon/src/channel.ts index 3c5bedbf841..b84679e1f39 100644 --- a/extensions/tlon/src/channel.ts +++ b/extensions/tlon/src/channel.ts @@ -153,6 +153,48 @@ function applyTlonSetupConfig(params: { }; } +type ResolvedTlonAccount = ReturnType; + +function resolveOutboundContext(params: { cfg: OpenClawConfig; accountId?: string; to: string }) { + const account = resolveTlonAccount(params.cfg, params.accountId ?? undefined); + if (!account.configured || !account.ship || !account.url || !account.code) { + throw new Error("Tlon account not configured"); + } + + const parsed = parseTlonTarget(params.to); + if (!parsed) { + throw new Error(`Invalid Tlon target. Use ${formatTargetHint()}`); + } + + return { account, parsed }; +} + +function resolveReplyId(replyToId?: string, threadId?: string) { + return (replyToId ?? threadId) ? String(replyToId ?? threadId) : undefined; +} + +async function withHttpPokeAccountApi( + account: ResolvedTlonAccount & { ship: string; url: string; code: string }, + run: (api: Awaited>) => Promise, +) { + const api = await createHttpPokeApi({ + url: account.url, + ship: account.ship, + code: account.code, + allowPrivateNetwork: account.allowPrivateNetwork ?? undefined, + }); + + try { + return await run(api); + } finally { + try { + await api.delete(); + } catch { + // ignore cleanup errors + } + } +} + const tlonOutbound: ChannelOutboundAdapter = { deliveryMode: "direct", textChunkLimit: 10000, @@ -170,25 +212,8 @@ const tlonOutbound: ChannelOutboundAdapter = { return { ok: true, to: parsed.nest }; }, sendText: async ({ cfg, to, text, accountId, replyToId, threadId }) => { - const account = resolveTlonAccount(cfg, accountId ?? undefined); - if (!account.configured || !account.ship || !account.url || !account.code) { - throw new Error("Tlon account not configured"); - } - - const parsed = parseTlonTarget(to); - if (!parsed) { - throw new Error(`Invalid Tlon target. Use ${formatTargetHint()}`); - } - - // Use HTTP-only poke (no EventSource) to avoid conflicts with monitor's SSE connection - const api = await createHttpPokeApi({ - url: account.url, - ship: account.ship, - code: account.code, - allowPrivateNetwork: account.allowPrivateNetwork ?? undefined, - }); - - try { + const { account, parsed } = resolveOutboundContext({ cfg, accountId, to }); + return withHttpPokeAccountApi(account, async (api) => { const fromShip = normalizeShip(account.ship); if (parsed.kind === "dm") { return await sendDm({ @@ -198,33 +223,18 @@ const tlonOutbound: ChannelOutboundAdapter = { text, }); } - const replyId = (replyToId ?? threadId) ? String(replyToId ?? threadId) : undefined; return await sendGroupMessage({ api, fromShip, hostShip: parsed.hostShip, channelName: parsed.channelName, text, - replyToId: replyId, + replyToId: resolveReplyId(replyToId, threadId), }); - } finally { - try { - await api.delete(); - } catch { - // ignore cleanup errors - } - } + }); }, sendMedia: async ({ cfg, to, text, mediaUrl, accountId, replyToId, threadId }) => { - const account = resolveTlonAccount(cfg, accountId ?? undefined); - if (!account.configured || !account.ship || !account.url || !account.code) { - throw new Error("Tlon account not configured"); - } - - const parsed = parseTlonTarget(to); - if (!parsed) { - throw new Error(`Invalid Tlon target. Use ${formatTargetHint()}`); - } + const { account, parsed } = resolveOutboundContext({ cfg, accountId, to }); // Configure the API client for uploads configureClient({ @@ -235,15 +245,7 @@ const tlonOutbound: ChannelOutboundAdapter = { }); const uploadedUrl = mediaUrl ? await uploadImageFromUrl(mediaUrl) : undefined; - - const api = await createHttpPokeApi({ - url: account.url, - ship: account.ship, - code: account.code, - allowPrivateNetwork: account.allowPrivateNetwork ?? undefined, - }); - - try { + return withHttpPokeAccountApi(account, async (api) => { const fromShip = normalizeShip(account.ship); const story = buildMediaStory(text, uploadedUrl); @@ -255,22 +257,15 @@ const tlonOutbound: ChannelOutboundAdapter = { story, }); } - const replyId = (replyToId ?? threadId) ? String(replyToId ?? threadId) : undefined; return await sendGroupMessageWithStory({ api, fromShip, hostShip: parsed.hostShip, channelName: parsed.channelName, story, - replyToId: replyId, + replyToId: resolveReplyId(replyToId, threadId), }); - } finally { - try { - await api.delete(); - } catch { - // ignore cleanup errors - } - } + }); }, }; From 1dc8e17371569d79da8256a0f454c27247c461ba Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:56:25 +0000 Subject: [PATCH 115/461] refactor: share line outbound media loop --- extensions/line/src/channel.ts | 28 ++++++++++++---------------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/extensions/line/src/channel.ts b/extensions/line/src/channel.ts index ddc612b8fa7..982d7670082 100644 --- a/extensions/line/src/channel.ts +++ b/extensions/line/src/channel.ts @@ -347,6 +347,16 @@ export const linePlugin: ChannelPlugin = { : []; const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); const shouldSendQuickRepliesInline = chunks.length === 0 && hasQuickReplies; + const sendMediaMessages = async () => { + for (const url of mediaUrls) { + lastResult = await runtime.channel.line.sendMessageLine(to, "", { + verbose: false, + mediaUrl: url, + cfg, + accountId: accountId ?? undefined, + }); + } + }; if (!shouldSendQuickRepliesInline) { if (lineData.flexMessage) { @@ -391,14 +401,7 @@ export const linePlugin: ChannelPlugin = { const sendMediaAfterText = !(hasQuickReplies && chunks.length > 0); if (mediaUrls.length > 0 && !shouldSendQuickRepliesInline && !sendMediaAfterText) { - for (const url of mediaUrls) { - lastResult = await runtime.channel.line.sendMessageLine(to, "", { - verbose: false, - mediaUrl: url, - cfg, - accountId: accountId ?? undefined, - }); - } + await sendMediaMessages(); } if (chunks.length > 0) { @@ -471,14 +474,7 @@ export const linePlugin: ChannelPlugin = { } if (mediaUrls.length > 0 && !shouldSendQuickRepliesInline && sendMediaAfterText) { - for (const url of mediaUrls) { - lastResult = await runtime.channel.line.sendMessageLine(to, "", { - verbose: false, - mediaUrl: url, - cfg, - accountId: accountId ?? undefined, - }); - } + await sendMediaMessages(); } if (lastResult) { From 2cf6e2e4f622749d6211d1736568312311685b1f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:56:57 +0000 Subject: [PATCH 116/461] test: dedupe matrix target resolution cases --- extensions/matrix/src/resolve-targets.test.ts | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/extensions/matrix/src/resolve-targets.test.ts b/extensions/matrix/src/resolve-targets.test.ts index 10dff313a2e..02a5088e8ae 100644 --- a/extensions/matrix/src/resolve-targets.test.ts +++ b/extensions/matrix/src/resolve-targets.test.ts @@ -8,6 +8,15 @@ vi.mock("./directory-live.js", () => ({ listMatrixDirectoryGroupsLive: vi.fn(), })); +async function resolveUserTarget(input = "Alice") { + const [result] = await resolveMatrixTargets({ + cfg: {}, + inputs: [input], + kind: "user", + }); + return result; +} + describe("resolveMatrixTargets (users)", () => { beforeEach(() => { vi.mocked(listMatrixDirectoryPeersLive).mockReset(); @@ -20,11 +29,7 @@ describe("resolveMatrixTargets (users)", () => { ]; vi.mocked(listMatrixDirectoryPeersLive).mockResolvedValue(matches); - const [result] = await resolveMatrixTargets({ - cfg: {}, - inputs: ["Alice"], - kind: "user", - }); + const result = await resolveUserTarget(); expect(result?.resolved).toBe(true); expect(result?.id).toBe("@alice:example.org"); @@ -37,11 +42,7 @@ describe("resolveMatrixTargets (users)", () => { ]; vi.mocked(listMatrixDirectoryPeersLive).mockResolvedValue(matches); - const [result] = await resolveMatrixTargets({ - cfg: {}, - inputs: ["Alice"], - kind: "user", - }); + const result = await resolveUserTarget(); expect(result?.resolved).toBe(false); expect(result?.note).toMatch(/use full Matrix ID/i); From b5eb329f94fb4cae1438ad1eae780509b752fc82 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:57:05 +0000 Subject: [PATCH 117/461] test: dedupe feishu outbound setup --- extensions/feishu/src/outbound.test.ts | 27 +++++++++++--------------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/extensions/feishu/src/outbound.test.ts b/extensions/feishu/src/outbound.test.ts index 11cfc957e80..39b7c1e4a63 100644 --- a/extensions/feishu/src/outbound.test.ts +++ b/extensions/feishu/src/outbound.test.ts @@ -29,12 +29,16 @@ vi.mock("./runtime.js", () => ({ import { feishuOutbound } from "./outbound.js"; const sendText = feishuOutbound.sendText!; +function resetOutboundMocks() { + vi.clearAllMocks(); + sendMessageFeishuMock.mockResolvedValue({ messageId: "text_msg" }); + sendMarkdownCardFeishuMock.mockResolvedValue({ messageId: "card_msg" }); + sendMediaFeishuMock.mockResolvedValue({ messageId: "media_msg" }); +} + describe("feishuOutbound.sendText local-image auto-convert", () => { beforeEach(() => { - vi.clearAllMocks(); - sendMessageFeishuMock.mockResolvedValue({ messageId: "text_msg" }); - sendMarkdownCardFeishuMock.mockResolvedValue({ messageId: "card_msg" }); - sendMediaFeishuMock.mockResolvedValue({ messageId: "media_msg" }); + resetOutboundMocks(); }); async function createTmpImage(ext = ".png"): Promise<{ dir: string; file: string }> { @@ -181,10 +185,7 @@ describe("feishuOutbound.sendText local-image auto-convert", () => { describe("feishuOutbound.sendText replyToId forwarding", () => { beforeEach(() => { - vi.clearAllMocks(); - sendMessageFeishuMock.mockResolvedValue({ messageId: "text_msg" }); - sendMarkdownCardFeishuMock.mockResolvedValue({ messageId: "card_msg" }); - sendMediaFeishuMock.mockResolvedValue({ messageId: "media_msg" }); + resetOutboundMocks(); }); it("forwards replyToId as replyToMessageId to sendMessageFeishu", async () => { @@ -249,10 +250,7 @@ describe("feishuOutbound.sendText replyToId forwarding", () => { describe("feishuOutbound.sendMedia replyToId forwarding", () => { beforeEach(() => { - vi.clearAllMocks(); - sendMessageFeishuMock.mockResolvedValue({ messageId: "text_msg" }); - sendMarkdownCardFeishuMock.mockResolvedValue({ messageId: "card_msg" }); - sendMediaFeishuMock.mockResolvedValue({ messageId: "media_msg" }); + resetOutboundMocks(); }); it("forwards replyToId to sendMediaFeishu", async () => { @@ -292,10 +290,7 @@ describe("feishuOutbound.sendMedia replyToId forwarding", () => { describe("feishuOutbound.sendMedia renderMode", () => { beforeEach(() => { - vi.clearAllMocks(); - sendMessageFeishuMock.mockResolvedValue({ messageId: "text_msg" }); - sendMarkdownCardFeishuMock.mockResolvedValue({ messageId: "card_msg" }); - sendMediaFeishuMock.mockResolvedValue({ messageId: "media_msg" }); + resetOutboundMocks(); }); it("uses markdown cards for captions when renderMode=card", async () => { From 854df8352c719660b868981d53784930878c33a1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:58:16 +0000 Subject: [PATCH 118/461] refactor: share net and slack input helpers --- src/shared/net/ip.ts | 14 +++++----- src/slack/monitor/message-handler.test.ts | 34 +++++++++++------------ 2 files changed, 23 insertions(+), 25 deletions(-) diff --git a/src/shared/net/ip.ts b/src/shared/net/ip.ts index c386c687898..57c8cccd697 100644 --- a/src/shared/net/ip.ts +++ b/src/shared/net/ip.ts @@ -128,12 +128,16 @@ function normalizeIpv4MappedAddress(address: ParsedIpAddress): ParsedIpAddress { return address.toIPv4Address(); } -export function parseCanonicalIpAddress(raw: string | undefined): ParsedIpAddress | undefined { +function normalizeIpParseInput(raw: string | undefined): string | undefined { const trimmed = raw?.trim(); if (!trimmed) { return undefined; } - const normalized = stripIpv6Brackets(trimmed); + return stripIpv6Brackets(trimmed); +} + +export function parseCanonicalIpAddress(raw: string | undefined): ParsedIpAddress | undefined { + const normalized = normalizeIpParseInput(raw); if (!normalized) { return undefined; } @@ -150,11 +154,7 @@ export function parseCanonicalIpAddress(raw: string | undefined): ParsedIpAddres } export function parseLooseIpAddress(raw: string | undefined): ParsedIpAddress | undefined { - const trimmed = raw?.trim(); - if (!trimmed) { - return undefined; - } - const normalized = stripIpv6Brackets(trimmed); + const normalized = normalizeIpParseInput(raw); if (!normalized) { return undefined; } diff --git a/src/slack/monitor/message-handler.test.ts b/src/slack/monitor/message-handler.test.ts index 8453b9ce4b0..1417ca3e6ec 100644 --- a/src/slack/monitor/message-handler.test.ts +++ b/src/slack/monitor/message-handler.test.ts @@ -48,6 +48,20 @@ function createHandlerWithTracker(overrides?: { return { handler, trackEvent }; } +async function handleDirectMessage( + handler: ReturnType["handler"], +) { + await handler( + { + type: "message", + channel: "D1", + ts: "123.456", + text: "hello", + } as never, + { source: "message" }, + ); +} + describe("createSlackMessageHandler", () => { beforeEach(() => { enqueueMock.mockClear(); @@ -82,15 +96,7 @@ describe("createSlackMessageHandler", () => { it("does not track duplicate messages that are already seen", async () => { const { handler, trackEvent } = createHandlerWithTracker({ markMessageSeen: () => true }); - await handler( - { - type: "message", - channel: "D1", - ts: "123.456", - text: "hello", - } as never, - { source: "message" }, - ); + await handleDirectMessage(handler); expect(trackEvent).not.toHaveBeenCalled(); expect(resolveThreadTsMock).not.toHaveBeenCalled(); @@ -100,15 +106,7 @@ describe("createSlackMessageHandler", () => { it("tracks accepted non-duplicate messages", async () => { const { handler, trackEvent } = createHandlerWithTracker(); - await handler( - { - type: "message", - channel: "D1", - ts: "123.456", - text: "hello", - } as never, - { source: "message" }, - ); + await handleDirectMessage(handler); expect(trackEvent).toHaveBeenCalledTimes(1); expect(resolveThreadTsMock).toHaveBeenCalledTimes(1); From 9a14696f302de93232c6de76fd000a6463491790 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:58:29 +0000 Subject: [PATCH 119/461] test: dedupe feishu config schema checks --- extensions/feishu/src/config-schema.test.ts | 38 ++++++++------------- 1 file changed, 14 insertions(+), 24 deletions(-) diff --git a/extensions/feishu/src/config-schema.test.ts b/extensions/feishu/src/config-schema.test.ts index 0e0881c849f..aacbac85062 100644 --- a/extensions/feishu/src/config-schema.test.ts +++ b/extensions/feishu/src/config-schema.test.ts @@ -1,6 +1,16 @@ import { describe, expect, it } from "vitest"; import { FeishuConfigSchema, FeishuGroupSchema } from "./config-schema.js"; +function expectSchemaIssue( + result: ReturnType, + issuePath: string, +) { + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some((issue) => issue.path.join(".") === issuePath)).toBe(true); + } +} + describe("FeishuConfigSchema webhook validation", () => { it("applies top-level defaults", () => { const result = FeishuConfigSchema.parse({}); @@ -39,12 +49,7 @@ describe("FeishuConfigSchema webhook validation", () => { appSecret: "secret_top", // pragma: allowlist secret }); - expect(result.success).toBe(false); - if (!result.success) { - expect( - result.error.issues.some((issue) => issue.path.join(".") === "verificationToken"), - ).toBe(true); - } + expectSchemaIssue(result, "verificationToken"); }); it("rejects top-level webhook mode without encryptKey", () => { @@ -55,10 +60,7 @@ describe("FeishuConfigSchema webhook validation", () => { appSecret: "secret_top", // pragma: allowlist secret }); - expect(result.success).toBe(false); - if (!result.success) { - expect(result.error.issues.some((issue) => issue.path.join(".") === "encryptKey")).toBe(true); - } + expectSchemaIssue(result, "encryptKey"); }); it("accepts top-level webhook mode with verificationToken and encryptKey", () => { @@ -84,14 +86,7 @@ describe("FeishuConfigSchema webhook validation", () => { }, }); - expect(result.success).toBe(false); - if (!result.success) { - expect( - result.error.issues.some( - (issue) => issue.path.join(".") === "accounts.main.verificationToken", - ), - ).toBe(true); - } + expectSchemaIssue(result, "accounts.main.verificationToken"); }); it("rejects account webhook mode without encryptKey", () => { @@ -106,12 +101,7 @@ describe("FeishuConfigSchema webhook validation", () => { }, }); - expect(result.success).toBe(false); - if (!result.success) { - expect( - result.error.issues.some((issue) => issue.path.join(".") === "accounts.main.encryptKey"), - ).toBe(true); - } + expectSchemaIssue(result, "accounts.main.encryptKey"); }); it("accepts account webhook mode inheriting top-level verificationToken and encryptKey", () => { From a7e5925ec135fb73a2a306f9218f51e53c03da51 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:58:34 +0000 Subject: [PATCH 120/461] test: dedupe feishu account resolution fixtures --- extensions/feishu/src/accounts.test.ts | 37 +++++++++++++++----------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/extensions/feishu/src/accounts.test.ts b/extensions/feishu/src/accounts.test.ts index 56783bbd29d..cfe8d0abcdc 100644 --- a/extensions/feishu/src/accounts.test.ts +++ b/extensions/feishu/src/accounts.test.ts @@ -9,6 +9,23 @@ import type { FeishuConfig } from "./types.js"; const asConfig = (value: Partial) => value as FeishuConfig; +function makeDefaultAndRouterAccounts() { + return { + default: { appId: "cli_default", appSecret: "secret_default" }, // pragma: allowlist secret + "router-d": { appId: "cli_router", appSecret: "secret_router" }, // pragma: allowlist secret + }; +} + +function expectExplicitDefaultAccountSelection( + account: ReturnType, + appId: string, +) { + expect(account.accountId).toBe("router-d"); + expect(account.selectionSource).toBe("explicit-default"); + expect(account.configured).toBe(true); + expect(account.appId).toBe(appId); +} + function withEnvVar(key: string, value: string | undefined, run: () => void) { const prev = process.env[key]; if (value === undefined) { @@ -44,10 +61,7 @@ describe("resolveDefaultFeishuAccountId", () => { channels: { feishu: { defaultAccount: "router-d", - accounts: { - default: { appId: "cli_default", appSecret: "secret_default" }, // pragma: allowlist secret - "router-d": { appId: "cli_router", appSecret: "secret_router" }, // pragma: allowlist secret - }, + accounts: makeDefaultAndRouterAccounts(), }, }, }; @@ -278,10 +292,7 @@ describe("resolveFeishuAccount", () => { }; const account = resolveFeishuAccount({ cfg: cfg as never, accountId: undefined }); - expect(account.accountId).toBe("router-d"); - expect(account.selectionSource).toBe("explicit-default"); - expect(account.configured).toBe(true); - expect(account.appId).toBe("top_level_app"); + expectExplicitDefaultAccountSelection(account, "top_level_app"); }); it("uses configured default account when accountId is omitted", () => { @@ -298,10 +309,7 @@ describe("resolveFeishuAccount", () => { }; const account = resolveFeishuAccount({ cfg: cfg as never, accountId: undefined }); - expect(account.accountId).toBe("router-d"); - expect(account.selectionSource).toBe("explicit-default"); - expect(account.configured).toBe(true); - expect(account.appId).toBe("cli_router"); + expectExplicitDefaultAccountSelection(account, "cli_router"); }); it("keeps explicit accountId selection", () => { @@ -309,10 +317,7 @@ describe("resolveFeishuAccount", () => { channels: { feishu: { defaultAccount: "router-d", - accounts: { - default: { appId: "cli_default", appSecret: "secret_default" }, // pragma: allowlist secret - "router-d": { appId: "cli_router", appSecret: "secret_router" }, // pragma: allowlist secret - }, + accounts: makeDefaultAndRouterAccounts(), }, }, }; From 7ca8804a3362e553dc2555f024880307b7298b5b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:58:41 +0000 Subject: [PATCH 121/461] test: share feishu schema and reaction assertions --- .../feishu/src/monitor.reaction.test.ts | 98 +++++++++---------- 1 file changed, 48 insertions(+), 50 deletions(-) diff --git a/extensions/feishu/src/monitor.reaction.test.ts b/extensions/feishu/src/monitor.reaction.test.ts index e17859d0531..6d3f64a32d0 100644 --- a/extensions/feishu/src/monitor.reaction.test.ts +++ b/extensions/feishu/src/monitor.reaction.test.ts @@ -78,6 +78,25 @@ async function resolveReactionWithLookup(params: { }); } +async function resolveNonBotReaction(params?: { cfg?: ClawdbotConfig; uuid?: () => string }) { + return await resolveReactionSyntheticEvent({ + cfg: params?.cfg ?? cfg, + accountId: "default", + event: makeReactionEvent(), + botOpenId: "ou_bot", + fetchMessage: async () => ({ + messageId: "om_msg1", + chatId: "oc_group", + chatType: "group", + senderOpenId: "ou_other", + senderType: "user", + content: "hello", + contentType: "text", + }), + ...(params?.uuid ? { uuid: params.uuid } : {}), + }); +} + type FeishuMention = NonNullable[number]; function buildDebounceConfig(): ClawdbotConfig { @@ -179,6 +198,19 @@ function getFirstDispatchedEvent(): FeishuMessageEvent { return firstParams.event; } +function expectSingleDispatchedEvent(): FeishuMessageEvent { + expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1); + return getFirstDispatchedEvent(); +} + +function expectParsedFirstDispatchedEvent(botOpenId = "ou_bot") { + const dispatched = expectSingleDispatchedEvent(); + return { + dispatched, + parsed: parseFeishuMessageEvent(dispatched, botOpenId), + }; +} + function setDedupPassThroughMocks(): void { vi.spyOn(dedup, "tryRecordMessage").mockReturnValue(true); vi.spyOn(dedup, "tryRecordMessagePersistent").mockResolvedValue(true); @@ -203,6 +235,13 @@ async function enqueueDebouncedMessage( await Promise.resolve(); } +function setStaleRetryMocks(messageId = "om_old") { + vi.spyOn(dedup, "hasRecordedMessage").mockImplementation((key) => key.endsWith(`:${messageId}`)); + vi.spyOn(dedup, "hasRecordedMessagePersistent").mockImplementation( + async (currentMessageId) => currentMessageId === messageId, + ); +} + describe("resolveReactionSyntheticEvent", () => { it("filters app self-reactions", async () => { const event = makeReactionEvent({ operator_type: "app" }); @@ -262,28 +301,12 @@ describe("resolveReactionSyntheticEvent", () => { }); it("filters reactions on non-bot messages", async () => { - const event = makeReactionEvent(); - const result = await resolveReactionSyntheticEvent({ - cfg, - accountId: "default", - event, - botOpenId: "ou_bot", - fetchMessage: async () => ({ - messageId: "om_msg1", - chatId: "oc_group", - chatType: "group", - senderOpenId: "ou_other", - senderType: "user", - content: "hello", - contentType: "text", - }), - }); + const result = await resolveNonBotReaction(); expect(result).toBeNull(); }); it("allows non-bot reactions when reactionNotifications is all", async () => { - const event = makeReactionEvent(); - const result = await resolveReactionSyntheticEvent({ + const result = await resolveNonBotReaction({ cfg: { channels: { feishu: { @@ -291,18 +314,6 @@ describe("resolveReactionSyntheticEvent", () => { }, }, } as ClawdbotConfig, - accountId: "default", - event, - botOpenId: "ou_bot", - fetchMessage: async () => ({ - messageId: "om_msg1", - chatId: "oc_group", - chatType: "group", - senderOpenId: "ou_other", - senderType: "user", - content: "hello", - contentType: "text", - }), uuid: () => "fixed-uuid", }); expect(result?.message.message_id).toBe("om_msg1:reaction:THUMBSUP:fixed-uuid"); @@ -457,8 +468,7 @@ describe("Feishu inbound debounce regressions", () => { ); await vi.advanceTimersByTimeAsync(25); - expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1); - const dispatched = getFirstDispatchedEvent(); + const dispatched = expectSingleDispatchedEvent(); const mergedMentions = dispatched.message.mentions ?? []; expect(mergedMentions.some((mention) => mention.id.open_id === "ou_bot")).toBe(true); expect(mergedMentions.some((mention) => mention.id.open_id === "ou_user_a")).toBe(false); @@ -517,9 +527,7 @@ describe("Feishu inbound debounce regressions", () => { ); await vi.advanceTimersByTimeAsync(25); - expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1); - const dispatched = getFirstDispatchedEvent(); - const parsed = parseFeishuMessageEvent(dispatched, "ou_bot"); + const { dispatched, parsed } = expectParsedFirstDispatchedEvent(); expect(parsed.mentionedBot).toBe(true); expect(parsed.mentionTargets).toBeUndefined(); const mergedMentions = dispatched.message.mentions ?? []; @@ -547,19 +555,14 @@ describe("Feishu inbound debounce regressions", () => { ); await vi.advanceTimersByTimeAsync(25); - expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1); - const dispatched = getFirstDispatchedEvent(); - const parsed = parseFeishuMessageEvent(dispatched, "ou_bot"); + const { parsed } = expectParsedFirstDispatchedEvent(); expect(parsed.mentionedBot).toBe(true); }); it("excludes previously processed retries from combined debounce text", async () => { vi.spyOn(dedup, "tryRecordMessage").mockReturnValue(true); vi.spyOn(dedup, "tryRecordMessagePersistent").mockResolvedValue(true); - vi.spyOn(dedup, "hasRecordedMessage").mockImplementation((key) => key.endsWith(":om_old")); - vi.spyOn(dedup, "hasRecordedMessagePersistent").mockImplementation( - async (messageId) => messageId === "om_old", - ); + setStaleRetryMocks(); const onMessage = await setupDebounceMonitor(); await onMessage(createTextEvent({ messageId: "om_old", text: "stale" })); @@ -576,8 +579,7 @@ describe("Feishu inbound debounce regressions", () => { await Promise.resolve(); await vi.advanceTimersByTimeAsync(25); - expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1); - const dispatched = getFirstDispatchedEvent(); + const dispatched = expectSingleDispatchedEvent(); expect(dispatched.message.message_id).toBe("om_new_2"); const combined = JSON.parse(dispatched.message.content) as { text?: string }; expect(combined.text).toBe("first\nsecond"); @@ -586,10 +588,7 @@ describe("Feishu inbound debounce regressions", () => { it("uses latest fresh message id when debounce batch ends with stale retry", async () => { const recordSpy = vi.spyOn(dedup, "tryRecordMessage").mockReturnValue(true); vi.spyOn(dedup, "tryRecordMessagePersistent").mockResolvedValue(true); - vi.spyOn(dedup, "hasRecordedMessage").mockImplementation((key) => key.endsWith(":om_old")); - vi.spyOn(dedup, "hasRecordedMessagePersistent").mockImplementation( - async (messageId) => messageId === "om_old", - ); + setStaleRetryMocks(); const onMessage = await setupDebounceMonitor(); await onMessage(createTextEvent({ messageId: "om_new", text: "fresh" })); @@ -600,8 +599,7 @@ describe("Feishu inbound debounce regressions", () => { await Promise.resolve(); await vi.advanceTimersByTimeAsync(25); - expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1); - const dispatched = getFirstDispatchedEvent(); + const dispatched = expectSingleDispatchedEvent(); expect(dispatched.message.message_id).toBe("om_new"); const combined = JSON.parse(dispatched.message.content) as { text?: string }; expect(combined.text).toBe("fresh"); From 5af8322ff58f4930e3c085139224f35c6c1cae2b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:58:56 +0000 Subject: [PATCH 122/461] refactor: share tlon channel put requests --- extensions/tlon/src/urbit/sse-client.ts | 68 ++++++++++--------------- 1 file changed, 26 insertions(+), 42 deletions(-) diff --git a/extensions/tlon/src/urbit/sse-client.ts b/extensions/tlon/src/urbit/sse-client.ts index ab12977d0e8..afa87502320 100644 --- a/extensions/tlon/src/urbit/sse-client.ts +++ b/extensions/tlon/src/urbit/sse-client.ts @@ -115,20 +115,7 @@ export class UrbitSSEClient { app: string; path: string; }) { - const { response, release } = await urbitFetch({ - baseUrl: this.url, - path: `/~/channel/${this.channelId}`, - init: { - method: "PUT", - headers: { - "Content-Type": "application/json", - Cookie: this.cookie, - }, - body: JSON.stringify([subscription]), - }, - ssrfPolicy: this.ssrfPolicy, - lookupFn: this.lookupFn, - fetchImpl: this.fetchImpl, + const { response, release } = await this.putChannelPayload([subscription], { timeoutMs: 30_000, auditContext: "tlon-urbit-subscribe", }); @@ -359,20 +346,7 @@ export class UrbitSSEClient { "event-id": eventId, }; - const { response, release } = await urbitFetch({ - baseUrl: this.url, - path: `/~/channel/${this.channelId}`, - init: { - method: "PUT", - headers: { - "Content-Type": "application/json", - Cookie: this.cookie, - }, - body: JSON.stringify([ackData]), - }, - ssrfPolicy: this.ssrfPolicy, - lookupFn: this.lookupFn, - fetchImpl: this.fetchImpl, + const { response, release } = await this.putChannelPayload([ackData], { timeoutMs: 10_000, auditContext: "tlon-urbit-ack", }); @@ -445,20 +419,7 @@ export class UrbitSSEClient { })); { - const { response, release } = await urbitFetch({ - baseUrl: this.url, - path: `/~/channel/${this.channelId}`, - init: { - method: "PUT", - headers: { - "Content-Type": "application/json", - Cookie: this.cookie, - }, - body: JSON.stringify(unsubscribes), - }, - ssrfPolicy: this.ssrfPolicy, - lookupFn: this.lookupFn, - fetchImpl: this.fetchImpl, + const { response, release } = await this.putChannelPayload(unsubscribes, { timeoutMs: 30_000, auditContext: "tlon-urbit-unsubscribe", }); @@ -501,4 +462,27 @@ export class UrbitSSEClient { await release(); } } + + private async putChannelPayload( + payload: unknown, + params: { timeoutMs: number; auditContext: string }, + ) { + return await urbitFetch({ + baseUrl: this.url, + path: `/~/channel/${this.channelId}`, + init: { + method: "PUT", + headers: { + "Content-Type": "application/json", + Cookie: this.cookie, + }, + body: JSON.stringify(payload), + }, + ssrfPolicy: this.ssrfPolicy, + lookupFn: this.lookupFn, + fetchImpl: this.fetchImpl, + timeoutMs: params.timeoutMs, + auditContext: params.auditContext, + }); + } } From 1ea5bba848f18d0919aeaa22732379fa36f96cc1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:59:40 +0000 Subject: [PATCH 123/461] test: dedupe feishu startup preflight waits --- extensions/feishu/src/monitor.startup.test.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/extensions/feishu/src/monitor.startup.test.ts b/extensions/feishu/src/monitor.startup.test.ts index f5e19159f0a..b95e33d2526 100644 --- a/extensions/feishu/src/monitor.startup.test.ts +++ b/extensions/feishu/src/monitor.startup.test.ts @@ -52,6 +52,12 @@ function buildMultiAccountWebsocketConfig(accountIds: string[]): ClawdbotConfig } as ClawdbotConfig; } +async function waitForStartedAccount(started: string[], accountId: string) { + for (let i = 0; i < 10 && !started.includes(accountId); i += 1) { + await Promise.resolve(); + } +} + afterEach(() => { stopFeishuMonitor(); }); @@ -116,10 +122,7 @@ describe("Feishu monitor startup preflight", () => { }); try { - for (let i = 0; i < 10 && !started.includes("beta"); i += 1) { - await Promise.resolve(); - } - + await waitForStartedAccount(started, "beta"); expect(started).toEqual(["alpha", "beta"]); expect(started.filter((accountId) => accountId === "alpha")).toHaveLength(1); } finally { @@ -153,10 +156,7 @@ describe("Feishu monitor startup preflight", () => { }); try { - for (let i = 0; i < 10 && !started.includes("beta"); i += 1) { - await Promise.resolve(); - } - + await waitForStartedAccount(started, "beta"); expect(started).toEqual(["alpha", "beta"]); expect(runtime.error).toHaveBeenCalledWith( expect.stringContaining("bot info probe timed out"), From 4d1fcc1df21c8eaec8a5e7ffe3bd1357b9c90daa Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:00:08 +0000 Subject: [PATCH 124/461] test: share memory lancedb temp config harness --- extensions/memory-lancedb/index.test.ts | 83 +++++++++++-------------- 1 file changed, 35 insertions(+), 48 deletions(-) diff --git a/extensions/memory-lancedb/index.test.ts b/extensions/memory-lancedb/index.test.ts index 2d9a6db1063..a733c3dffb8 100644 --- a/extensions/memory-lancedb/index.test.ts +++ b/extensions/memory-lancedb/index.test.ts @@ -18,12 +18,12 @@ const HAS_OPENAI_KEY = Boolean(process.env.OPENAI_API_KEY); const liveEnabled = HAS_OPENAI_KEY && process.env.OPENCLAW_LIVE_TEST === "1"; const describeLive = liveEnabled ? describe : describe.skip; -describe("memory plugin e2e", () => { - let tmpDir: string; - let dbPath: string; +function installTmpDirHarness(params: { prefix: string }) { + let tmpDir = ""; + let dbPath = ""; beforeEach(async () => { - tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-memory-test-")); + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), params.prefix)); dbPath = path.join(tmpDir, "lancedb"); }); @@ -33,6 +33,27 @@ describe("memory plugin e2e", () => { } }); + return { + getTmpDir: () => tmpDir, + getDbPath: () => dbPath, + }; +} + +describe("memory plugin e2e", () => { + const { getDbPath } = installTmpDirHarness({ prefix: "openclaw-memory-test-" }); + + async function parseConfig(overrides: Record = {}) { + const { default: memoryPlugin } = await import("./index.js"); + return memoryPlugin.configSchema?.parse?.({ + embedding: { + apiKey: OPENAI_API_KEY, + model: "text-embedding-3-small", + }, + dbPath: getDbPath(), + ...overrides, + }); + } + test("memory plugin registers and initializes correctly", async () => { // Dynamic import to avoid loading LanceDB when not testing const { default: memoryPlugin } = await import("./index.js"); @@ -46,21 +67,14 @@ describe("memory plugin e2e", () => { }); test("config schema parses valid config", async () => { - const { default: memoryPlugin } = await import("./index.js"); - - const config = memoryPlugin.configSchema?.parse?.({ - embedding: { - apiKey: OPENAI_API_KEY, - model: "text-embedding-3-small", - }, - dbPath, + const config = await parseConfig({ autoCapture: true, autoRecall: true, }); expect(config).toBeDefined(); expect(config?.embedding?.apiKey).toBe(OPENAI_API_KEY); - expect(config?.dbPath).toBe(dbPath); + expect(config?.dbPath).toBe(getDbPath()); expect(config?.captureMaxChars).toBe(500); }); @@ -74,7 +88,7 @@ describe("memory plugin e2e", () => { embedding: { apiKey: "${TEST_MEMORY_API_KEY}", }, - dbPath, + dbPath: getDbPath(), }); expect(config?.embedding?.apiKey).toBe("test-key-123"); @@ -88,7 +102,7 @@ describe("memory plugin e2e", () => { expect(() => { memoryPlugin.configSchema?.parse?.({ embedding: {}, - dbPath, + dbPath: getDbPath(), }); }).toThrow("embedding.apiKey is required"); }); @@ -99,21 +113,14 @@ describe("memory plugin e2e", () => { expect(() => { memoryPlugin.configSchema?.parse?.({ embedding: { apiKey: OPENAI_API_KEY }, - dbPath, + dbPath: getDbPath(), captureMaxChars: 99, }); }).toThrow("captureMaxChars must be between 100 and 10000"); }); test("config schema accepts captureMaxChars override", async () => { - const { default: memoryPlugin } = await import("./index.js"); - - const config = memoryPlugin.configSchema?.parse?.({ - embedding: { - apiKey: OPENAI_API_KEY, - model: "text-embedding-3-small", - }, - dbPath, + const config = await parseConfig({ captureMaxChars: 1800, }); @@ -121,15 +128,7 @@ describe("memory plugin e2e", () => { }); test("config schema keeps autoCapture disabled by default", async () => { - const { default: memoryPlugin } = await import("./index.js"); - - const config = memoryPlugin.configSchema?.parse?.({ - embedding: { - apiKey: OPENAI_API_KEY, - model: "text-embedding-3-small", - }, - dbPath, - }); + const config = await parseConfig(); expect(config?.autoCapture).toBe(false); expect(config?.autoRecall).toBe(true); @@ -176,7 +175,7 @@ describe("memory plugin e2e", () => { model: "text-embedding-3-small", dimensions: 1024, }, - dbPath, + dbPath: getDbPath(), autoCapture: false, autoRecall: false, }, @@ -279,19 +278,7 @@ describe("memory plugin e2e", () => { // Live tests that require OpenAI API key and actually use LanceDB describeLive("memory plugin live tests", () => { - let tmpDir: string; - let dbPath: string; - - beforeEach(async () => { - tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-memory-live-")); - dbPath = path.join(tmpDir, "lancedb"); - }); - - afterEach(async () => { - if (tmpDir) { - await fs.rm(tmpDir, { recursive: true, force: true }); - } - }); + const { getDbPath } = installTmpDirHarness({ prefix: "openclaw-memory-live-" }); test("memory tools work end-to-end", async () => { const { default: memoryPlugin } = await import("./index.js"); @@ -318,7 +305,7 @@ describeLive("memory plugin live tests", () => { apiKey: liveApiKey, model: "text-embedding-3-small", }, - dbPath, + dbPath: getDbPath(), autoCapture: false, autoRecall: false, }, From b2133486651ab18074b6cd83850dbf1cf9c4d71b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:00:10 +0000 Subject: [PATCH 125/461] test: dedupe feishu signed webhook posts --- .../feishu/src/monitor.webhook-e2e.test.ts | 26 ++++++++----------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/extensions/feishu/src/monitor.webhook-e2e.test.ts b/extensions/feishu/src/monitor.webhook-e2e.test.ts index 451ebe0d2bf..a11957e3393 100644 --- a/extensions/feishu/src/monitor.webhook-e2e.test.ts +++ b/extensions/feishu/src/monitor.webhook-e2e.test.ts @@ -50,6 +50,14 @@ function encryptFeishuPayload(encryptKey: string, payload: Record) { + return await fetch(url, { + method: "POST", + headers: signFeishuPayload({ encryptKey: "encrypt_key", payload }), + body: JSON.stringify(payload), + }); +} + afterEach(() => { stopFeishuMonitor(); }); @@ -143,11 +151,7 @@ describe("Feishu webhook signed-request e2e", () => { monitorFeishuProvider, async (url) => { const payload = { type: "url_verification", challenge: "challenge-token" }; - const response = await fetch(url, { - method: "POST", - headers: signFeishuPayload({ encryptKey: "encrypt_key", payload }), - body: JSON.stringify(payload), - }); + const response = await postSignedPayload(url, payload); expect(response.status).toBe(200); await expect(response.json()).resolves.toEqual({ challenge: "challenge-token" }); @@ -172,11 +176,7 @@ describe("Feishu webhook signed-request e2e", () => { header: { event_type: "unknown.event" }, event: {}, }; - const response = await fetch(url, { - method: "POST", - headers: signFeishuPayload({ encryptKey: "encrypt_key", payload }), - body: JSON.stringify(payload), - }); + const response = await postSignedPayload(url, payload); expect(response.status).toBe(200); expect(await response.text()).toContain("no unknown.event event handle"); @@ -202,11 +202,7 @@ describe("Feishu webhook signed-request e2e", () => { challenge: "encrypted-challenge-token", }), }; - const response = await fetch(url, { - method: "POST", - headers: signFeishuPayload({ encryptKey: "encrypt_key", payload }), - body: JSON.stringify(payload), - }); + const response = await postSignedPayload(url, payload); expect(response.status).toBe(200); await expect(response.json()).resolves.toEqual({ From 8ca510a66960ad073da66b1aed23ea23cac88bae Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:00:45 +0000 Subject: [PATCH 126/461] test: dedupe feishu media account setup --- extensions/feishu/src/media.test.ts | 31 ++++++++++++----------------- 1 file changed, 13 insertions(+), 18 deletions(-) diff --git a/extensions/feishu/src/media.test.ts b/extensions/feishu/src/media.test.ts index 813e5090292..b0226669df1 100644 --- a/extensions/feishu/src/media.test.ts +++ b/extensions/feishu/src/media.test.ts @@ -64,18 +64,21 @@ function expectMediaTimeoutClientConfigured(): void { ); } +function mockResolvedFeishuAccount() { + resolveFeishuAccountMock.mockReturnValue({ + configured: true, + accountId: "main", + config: {}, + appId: "app_id", + appSecret: "app_secret", + domain: "feishu", + }); +} + describe("sendMediaFeishu msg_type routing", () => { beforeEach(() => { vi.clearAllMocks(); - - resolveFeishuAccountMock.mockReturnValue({ - configured: true, - accountId: "main", - config: {}, - appId: "app_id", - appSecret: "app_secret", - domain: "feishu", - }); + mockResolvedFeishuAccount(); normalizeFeishuTargetMock.mockReturnValue("ou_target"); resolveReceiveIdTypeMock.mockReturnValue("open_id"); @@ -483,15 +486,7 @@ describe("sanitizeFileNameForUpload", () => { describe("downloadMessageResourceFeishu", () => { beforeEach(() => { vi.clearAllMocks(); - - resolveFeishuAccountMock.mockReturnValue({ - configured: true, - accountId: "main", - config: {}, - appId: "app_id", - appSecret: "app_secret", - domain: "feishu", - }); + mockResolvedFeishuAccount(); createFeishuClientMock.mockReturnValue({ im: { From 40b0cbd71306be91669edc73cd88f7b64164dab3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:00:59 +0000 Subject: [PATCH 127/461] test: dedupe thread ownership send checks --- extensions/thread-ownership/index.test.ts | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/extensions/thread-ownership/index.test.ts b/extensions/thread-ownership/index.test.ts index 825b4ca5bb5..3d98d8f9735 100644 --- a/extensions/thread-ownership/index.test.ts +++ b/extensions/thread-ownership/index.test.ts @@ -51,6 +51,13 @@ describe("thread-ownership plugin", () => { register(api as any); }); + async function sendSlackThreadMessage() { + return await hooks.message_sending( + { content: "hello", metadata: { threadTs: "1234.5678", channelId: "C123" }, to: "C123" }, + { channelId: "slack", conversationId: "C123" }, + ); + } + it("allows non-slack channels", async () => { const result = await hooks.message_sending( { content: "hello", metadata: { threadTs: "1234.5678", channelId: "C123" }, to: "C123" }, @@ -76,10 +83,7 @@ describe("thread-ownership plugin", () => { new Response(JSON.stringify({ owner: "test-agent" }), { status: 200 }), ); - const result = await hooks.message_sending( - { content: "hello", metadata: { threadTs: "1234.5678", channelId: "C123" }, to: "C123" }, - { channelId: "slack", conversationId: "C123" }, - ); + const result = await sendSlackThreadMessage(); expect(result).toBeUndefined(); expect(globalThis.fetch).toHaveBeenCalledWith( @@ -96,10 +100,7 @@ describe("thread-ownership plugin", () => { new Response(JSON.stringify({ owner: "other-agent" }), { status: 409 }), ); - const result = await hooks.message_sending( - { content: "hello", metadata: { threadTs: "1234.5678", channelId: "C123" }, to: "C123" }, - { channelId: "slack", conversationId: "C123" }, - ); + const result = await sendSlackThreadMessage(); expect(result).toEqual({ cancel: true }); expect(api.logger.info).toHaveBeenCalledWith(expect.stringContaining("cancelled send")); @@ -108,10 +109,7 @@ describe("thread-ownership plugin", () => { it("fails open on network error", async () => { vi.mocked(globalThis.fetch).mockRejectedValue(new Error("ECONNREFUSED")); - const result = await hooks.message_sending( - { content: "hello", metadata: { threadTs: "1234.5678", channelId: "C123" }, to: "C123" }, - { channelId: "slack", conversationId: "C123" }, - ); + const result = await sendSlackThreadMessage(); expect(result).toBeUndefined(); expect(api.logger.warn).toHaveBeenCalledWith( From 2ebc7e3ded2a3ba0e4b53c9f2abdf1df69952be4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:01:37 +0000 Subject: [PATCH 128/461] test: dedupe msteams revoked thread context --- extensions/msteams/src/messenger.test.ts | 40 +++++++++++------------- 1 file changed, 18 insertions(+), 22 deletions(-) diff --git a/extensions/msteams/src/messenger.test.ts b/extensions/msteams/src/messenger.test.ts index aa0a92b5159..cc4cf2fb6f0 100644 --- a/extensions/msteams/src/messenger.test.ts +++ b/extensions/msteams/src/messenger.test.ts @@ -139,6 +139,22 @@ describe("msteams messenger", () => { }); describe("sendMSTeamsMessages", () => { + function createRevokedThreadContext(params?: { failAfterAttempt?: number; sent?: string[] }) { + let attempt = 0; + return { + sendActivity: async (activity: unknown) => { + const { text } = activity as { text?: string }; + const content = text ?? ""; + attempt += 1; + if (params?.failAfterAttempt && attempt < params.failAfterAttempt) { + params.sent?.push(content); + return { id: `id:${content}` }; + } + throw new TypeError(REVOCATION_ERROR); + }, + }; + } + const baseRef: StoredConversationReference = { activityId: "activity123", user: { id: "user123", name: "User" }, @@ -305,13 +321,7 @@ describe("msteams messenger", () => { it("falls back to proactive messaging when thread context is revoked", async () => { const proactiveSent: string[] = []; - - const ctx = { - sendActivity: async () => { - throw new TypeError(REVOCATION_ERROR); - }, - }; - + const ctx = createRevokedThreadContext(); const adapter = createFallbackAdapter(proactiveSent); const ids = await sendMSTeamsMessages({ @@ -331,21 +341,7 @@ describe("msteams messenger", () => { it("falls back only for remaining thread messages after context revocation", async () => { const threadSent: string[] = []; const proactiveSent: string[] = []; - let attempt = 0; - - const ctx = { - sendActivity: async (activity: unknown) => { - const { text } = activity as { text?: string }; - const content = text ?? ""; - attempt += 1; - if (attempt === 1) { - threadSent.push(content); - return { id: `id:${content}` }; - } - throw new TypeError(REVOCATION_ERROR); - }, - }; - + const ctx = createRevokedThreadContext({ failAfterAttempt: 2, sent: threadSent }); const adapter = createFallbackAdapter(proactiveSent); const ids = await sendMSTeamsMessages({ From a4a7958678d77682fb4a6ed1981d0b8ae6ccd145 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:01:52 +0000 Subject: [PATCH 129/461] refactor: share outbound base session setup --- src/infra/outbound/outbound-session.ts | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/src/infra/outbound/outbound-session.ts b/src/infra/outbound/outbound-session.ts index d4a8a3466c6..afd3b1966c3 100644 --- a/src/infra/outbound/outbound-session.ts +++ b/src/infra/outbound/outbound-session.ts @@ -532,6 +532,21 @@ function resolveMatrixSession( }; } +function buildSimpleBaseSession(params: { + route: ResolveOutboundSessionRouteParams; + channel: string; + peer: RoutePeer; +}) { + const baseSessionKey = buildBaseSessionKey({ + cfg: params.route.cfg, + agentId: params.route.agentId, + channel: params.channel, + accountId: params.route.accountId, + peer: params.peer, + }); + return { baseSessionKey, peer: params.peer }; +} + function resolveMSTeamsSession( params: ResolveOutboundSessionRouteParams, ): OutboundSessionRoute | null { @@ -596,13 +611,10 @@ function resolveMattermostSession( if (!rawId) { return null; } - const peer: RoutePeer = { kind: isUser ? "direct" : "channel", id: rawId }; - const baseSessionKey = buildBaseSessionKey({ - cfg: params.cfg, - agentId: params.agentId, + const { baseSessionKey, peer } = buildSimpleBaseSession({ + route: params, channel: "mattermost", - accountId: params.accountId, - peer, + peer: { kind: isUser ? "direct" : "channel", id: rawId }, }); const threadId = normalizeThreadId(params.replyToId ?? params.threadId); const threadKeys = resolveThreadSessionKeys({ From fd58268f04ce132e64d36f50d4749dfa366c71c9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:02:09 +0000 Subject: [PATCH 130/461] test: dedupe bluebubbles normalize fixtures --- .../bluebubbles/src/monitor-normalize.test.ts | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/extensions/bluebubbles/src/monitor-normalize.test.ts b/extensions/bluebubbles/src/monitor-normalize.test.ts index 3e06302593c..62651279237 100644 --- a/extensions/bluebubbles/src/monitor-normalize.test.ts +++ b/extensions/bluebubbles/src/monitor-normalize.test.ts @@ -1,18 +1,24 @@ import { describe, expect, it } from "vitest"; import { normalizeWebhookMessage, normalizeWebhookReaction } from "./monitor-normalize.js"; +function createFallbackDmPayload(overrides: Record = {}) { + return { + guid: "msg-1", + isGroup: false, + isFromMe: false, + handle: null, + chatGuid: "iMessage;-;+15551234567", + ...overrides, + }; +} + describe("normalizeWebhookMessage", () => { it("falls back to DM chatGuid handle when sender handle is missing", () => { const result = normalizeWebhookMessage({ type: "new-message", - data: { - guid: "msg-1", + data: createFallbackDmPayload({ text: "hello", - isGroup: false, - isFromMe: false, - handle: null, - chatGuid: "iMessage;-;+15551234567", - }, + }), }); expect(result).not.toBeNull(); @@ -78,15 +84,11 @@ describe("normalizeWebhookReaction", () => { it("falls back to DM chatGuid handle when reaction sender handle is missing", () => { const result = normalizeWebhookReaction({ type: "updated-message", - data: { + data: createFallbackDmPayload({ guid: "msg-2", associatedMessageGuid: "p:0/msg-1", associatedMessageType: 2000, - isGroup: false, - isFromMe: false, - handle: null, - chatGuid: "iMessage;-;+15551234567", - }, + }), }); expect(result).not.toBeNull(); From 0229246f3b30525cdd32aa4bf46ea2d773d6cccf Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:02:19 +0000 Subject: [PATCH 131/461] test: share wake failure assertions --- .../server-methods/nodes.invoke-wake.test.ts | 48 +++++++++---------- 1 file changed, 22 insertions(+), 26 deletions(-) diff --git a/src/gateway/server-methods/nodes.invoke-wake.test.ts b/src/gateway/server-methods/nodes.invoke-wake.test.ts index 58596d582f8..fc01f718bbb 100644 --- a/src/gateway/server-methods/nodes.invoke-wake.test.ts +++ b/src/gateway/server-methods/nodes.invoke-wake.test.ts @@ -52,6 +52,24 @@ type RespondCall = [ }?, ]; +function expectNodeNotConnected(respond: ReturnType) { + const call = respond.mock.calls[0] as RespondCall | undefined; + expect(call?.[0]).toBe(false); + expect(call?.[2]?.message).toBe("node not connected"); +} + +async function invokeDisconnectedNode(nodeId: string, idempotencyKey: string) { + const nodeRegistry = { + get: vi.fn(() => undefined), + invoke: vi.fn().mockResolvedValue({ ok: true }), + }; + + return await invokeNode({ + nodeRegistry, + requestParams: { nodeId, idempotencyKey }, + }); +} + type TestNodeSession = { nodeId: string; commands: string[]; @@ -357,20 +375,9 @@ describe("node.invoke APNs wake path", () => { reason: "BadDeviceToken", }); mocks.shouldClearStoredApnsRegistration.mockReturnValue(true); + const respond = await invokeDisconnectedNode("ios-node-stale", "idem-stale"); - const nodeRegistry = { - get: vi.fn(() => undefined), - invoke: vi.fn().mockResolvedValue({ ok: true }), - }; - - const respond = await invokeNode({ - nodeRegistry, - requestParams: { nodeId: "ios-node-stale", idempotencyKey: "idem-stale" }, - }); - - const call = respond.mock.calls[0] as RespondCall | undefined; - expect(call?.[0]).toBe(false); - expect(call?.[2]?.message).toBe("node not connected"); + expectNodeNotConnected(respond); expect(mocks.clearApnsRegistrationIfCurrent).toHaveBeenCalledWith({ nodeId: "ios-node-stale", registration, @@ -385,20 +392,9 @@ describe("node.invoke APNs wake path", () => { reason: "Unregistered", }); mocks.shouldClearStoredApnsRegistration.mockReturnValue(false); + const respond = await invokeDisconnectedNode("ios-node-relay", "idem-relay"); - const nodeRegistry = { - get: vi.fn(() => undefined), - invoke: vi.fn().mockResolvedValue({ ok: true }), - }; - - const respond = await invokeNode({ - nodeRegistry, - requestParams: { nodeId: "ios-node-relay", idempotencyKey: "idem-relay" }, - }); - - const call = respond.mock.calls[0] as RespondCall | undefined; - expect(call?.[0]).toBe(false); - expect(call?.[2]?.message).toBe("node not connected"); + expectNodeNotConnected(respond); expect(mocks.resolveApnsRelayConfigFromEnv).toHaveBeenCalledWith(process.env, { push: { apns: { From 017c0dce323f0902e3f117c8750be164c46a8885 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:03:34 +0000 Subject: [PATCH 132/461] test: dedupe msteams attachment redirects --- extensions/msteams/src/attachments.test.ts | 32 ++++++++-------------- 1 file changed, 12 insertions(+), 20 deletions(-) diff --git a/extensions/msteams/src/attachments.test.ts b/extensions/msteams/src/attachments.test.ts index 6887fad7fcb..790dc8bd33f 100644 --- a/extensions/msteams/src/attachments.test.ts +++ b/extensions/msteams/src/attachments.test.ts @@ -88,14 +88,17 @@ function isUrlAllowedBySsrfPolicy(url: string, policy?: SsrFPolicy): boolean { ); } -const fetchRemoteMediaMock = vi.fn(async (params: RemoteMediaFetchParams) => { +async function fetchRemoteMediaWithRedirects( + params: RemoteMediaFetchParams, + requestInit?: RequestInit, +) { const fetchFn = params.fetchImpl ?? fetch; let currentUrl = params.url; for (let i = 0; i <= MAX_REDIRECT_HOPS; i += 1) { if (!isUrlAllowedBySsrfPolicy(currentUrl, params.ssrfPolicy)) { throw new Error(`Blocked hostname (not in allowlist): ${currentUrl}`); } - const res = await fetchFn(currentUrl, { redirect: "manual" }); + const res = await fetchFn(currentUrl, { redirect: "manual", ...requestInit }); if (REDIRECT_STATUS_CODES.includes(res.status)) { const location = res.headers.get("location"); if (!location) { @@ -107,6 +110,10 @@ const fetchRemoteMediaMock = vi.fn(async (params: RemoteMediaFetchParams) => { return readRemoteMediaResponse(res, params); } throw new Error("too many redirects"); +} + +const fetchRemoteMediaMock = vi.fn(async (params: RemoteMediaFetchParams) => { + return await fetchRemoteMediaWithRedirects(params); }); const runtimeStub: PluginRuntime = createPluginRuntimeMock({ @@ -720,24 +727,9 @@ describe("msteams attachments", () => { }); fetchRemoteMediaMock.mockImplementationOnce(async (params) => { - const fetchFn = params.fetchImpl ?? fetch; - let currentUrl = params.url; - for (let i = 0; i < MAX_REDIRECT_HOPS; i += 1) { - const res = await fetchFn(currentUrl, { - redirect: "manual", - dispatcher: {}, - } as RequestInit); - if (REDIRECT_STATUS_CODES.includes(res.status)) { - const location = res.headers.get("location"); - if (!location) { - throw new Error("redirect missing location"); - } - currentUrl = new URL(location, currentUrl).toString(); - continue; - } - return readRemoteMediaResponse(res, params); - } - throw new Error("too many redirects"); + return await fetchRemoteMediaWithRedirects(params, { + dispatcher: {}, + } as RequestInit); }); const media = await downloadAttachmentsWithFetch( From c5dc61e795037f213ef0c1ffeecdf4384922b0f7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:03:57 +0000 Subject: [PATCH 133/461] test: share session target and outbound mirror helpers --- src/config/sessions/targets.test.ts | 23 ++-- .../outbound/outbound-send-service.test.ts | 101 ++++++++++-------- 2 files changed, 71 insertions(+), 53 deletions(-) diff --git a/src/config/sessions/targets.test.ts b/src/config/sessions/targets.test.ts index 720cc3e892e..43674233a3a 100644 --- a/src/config/sessions/targets.test.ts +++ b/src/config/sessions/targets.test.ts @@ -40,6 +40,14 @@ function createCustomRootCfg(customRoot: string, defaultAgentId = "ops"): OpenCl }; } +async function resolveTargetsForCustomRoot(home: string, agentIds: string[]) { + const customRoot = path.join(home, "custom-state"); + const storePaths = await createAgentSessionStores(customRoot, agentIds); + const cfg = createCustomRootCfg(customRoot); + const targets = await resolveAllAgentSessionStoreTargets(cfg, { env: process.env }); + return { storePaths, targets }; +} + function expectTargetsToContainStores( targets: Array<{ agentId: string; storePath: string }>, stores: Record, @@ -152,11 +160,7 @@ describe("resolveAllAgentSessionStoreTargets", () => { it("discovers retired agent stores under a configured custom session root", async () => { await withTempHome(async (home) => { - const customRoot = path.join(home, "custom-state"); - const storePaths = await createAgentSessionStores(customRoot, ["ops", "retired"]); - const cfg = createCustomRootCfg(customRoot); - - const targets = await resolveAllAgentSessionStoreTargets(cfg, { env: process.env }); + const { storePaths, targets } = await resolveTargetsForCustomRoot(home, ["ops", "retired"]); expectTargetsToContainStores(targets, storePaths); expect(targets.filter((target) => target.storePath === storePaths.ops)).toHaveLength(1); @@ -165,11 +169,10 @@ describe("resolveAllAgentSessionStoreTargets", () => { it("keeps the actual on-disk store path for discovered retired agents", async () => { await withTempHome(async (home) => { - const customRoot = path.join(home, "custom-state"); - const storePaths = await createAgentSessionStores(customRoot, ["ops", "Retired Agent"]); - const cfg = createCustomRootCfg(customRoot); - - const targets = await resolveAllAgentSessionStoreTargets(cfg, { env: process.env }); + const { storePaths, targets } = await resolveTargetsForCustomRoot(home, [ + "ops", + "Retired Agent", + ]); expect(targets).toEqual( expect.arrayContaining([ diff --git a/src/infra/outbound/outbound-send-service.test.ts b/src/infra/outbound/outbound-send-service.test.ts index ac144265753..d4a481a8693 100644 --- a/src/infra/outbound/outbound-send-service.test.ts +++ b/src/infra/outbound/outbound-send-service.test.ts @@ -47,6 +47,47 @@ describe("executeSendAction", () => { }; } + function expectMirrorWrite( + expected: Partial<{ + agentId: string; + sessionKey: string; + text: string; + idempotencyKey: string; + mediaUrls: string[]; + }>, + ) { + expect(mocks.appendAssistantMessageToSessionTranscript).toHaveBeenCalledWith( + expect.objectContaining(expected), + ); + } + + async function executePluginMirroredSend(params: { + mirror?: Partial<{ + sessionKey: string; + agentId?: string; + idempotencyKey?: string; + }>; + mediaUrls?: string[]; + }) { + mocks.dispatchChannelMessageAction.mockResolvedValue(pluginActionResult("msg-plugin")); + + await executeSendAction({ + ctx: { + cfg: {}, + channel: "discord", + params: { to: "channel:123", message: "hello" }, + dryRun: false, + mirror: { + sessionKey: "agent:main:discord:channel:123", + ...params.mirror, + }, + }, + to: "channel:123", + message: "hello", + mediaUrls: params.mediaUrls, + }); + } + beforeEach(() => { mocks.dispatchChannelMessageAction.mockClear(); mocks.sendMessage.mockClear(); @@ -131,59 +172,33 @@ describe("executeSendAction", () => { }); it("passes mirror idempotency keys through plugin-handled sends", async () => { - mocks.dispatchChannelMessageAction.mockResolvedValue(pluginActionResult("msg-plugin")); - - await executeSendAction({ - ctx: { - cfg: {}, - channel: "discord", - params: { to: "channel:123", message: "hello" }, - dryRun: false, - mirror: { - sessionKey: "agent:main:discord:channel:123", - idempotencyKey: "idem-plugin-send-1", - }, + await executePluginMirroredSend({ + mirror: { + idempotencyKey: "idem-plugin-send-1", }, - to: "channel:123", - message: "hello", }); - expect(mocks.appendAssistantMessageToSessionTranscript).toHaveBeenCalledWith( - expect.objectContaining({ - sessionKey: "agent:main:discord:channel:123", - text: "hello", - idempotencyKey: "idem-plugin-send-1", - }), - ); + expectMirrorWrite({ + sessionKey: "agent:main:discord:channel:123", + text: "hello", + idempotencyKey: "idem-plugin-send-1", + }); }); it("falls back to message and media params for plugin-handled mirror writes", async () => { - mocks.dispatchChannelMessageAction.mockResolvedValue(pluginActionResult("msg-plugin")); - - await executeSendAction({ - ctx: { - cfg: {}, - channel: "discord", - params: { to: "channel:123", message: "hello" }, - dryRun: false, - mirror: { - sessionKey: "agent:main:discord:channel:123", - agentId: "agent-9", - }, + await executePluginMirroredSend({ + mirror: { + agentId: "agent-9", }, - to: "channel:123", - message: "hello", mediaUrls: ["https://example.com/a.png", "https://example.com/b.png"], }); - expect(mocks.appendAssistantMessageToSessionTranscript).toHaveBeenCalledWith( - expect.objectContaining({ - agentId: "agent-9", - sessionKey: "agent:main:discord:channel:123", - text: "hello", - mediaUrls: ["https://example.com/a.png", "https://example.com/b.png"], - }), - ); + expectMirrorWrite({ + agentId: "agent-9", + sessionKey: "agent:main:discord:channel:123", + text: "hello", + mediaUrls: ["https://example.com/a.png", "https://example.com/b.png"], + }); }); it("skips plugin dispatch during dry-run sends and forwards gateway + silent to sendMessage", async () => { From 9b24f890b25abdc29e89b18daa36f809a10e93da Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:04:19 +0000 Subject: [PATCH 134/461] refactor: share voice call message actions --- extensions/voice-call/index.ts | 64 ++++++++++++++++++++++------------ 1 file changed, 42 insertions(+), 22 deletions(-) diff --git a/extensions/voice-call/index.ts b/extensions/voice-call/index.ts index 8e2fba9898f..e61b5142ef1 100644 --- a/extensions/voice-call/index.ts +++ b/extensions/voice-call/index.ts @@ -227,6 +227,35 @@ const voiceCallPlugin = { params.respond(true, { callId: result.callId, initiated: true }); }; + const respondToCallMessageAction = async (params: { + requestParams: GatewayRequestHandlerOptions["params"]; + respond: GatewayRequestHandlerOptions["respond"]; + action: (request: Awaited>) => Promise<{ + success: boolean; + error?: string; + transcript?: string; + }>; + failure: string; + includeTranscript?: boolean; + }) => { + const request = await resolveCallMessageRequest(params.requestParams); + if ("error" in request) { + params.respond(false, { error: request.error }); + return; + } + const result = await params.action(request); + if (!result.success) { + params.respond(false, { error: result.error || params.failure }); + return; + } + params.respond( + true, + params.includeTranscript + ? { success: true, transcript: result.transcript } + : { success: true }, + ); + }; + api.registerGatewayMethod( "voicecall.initiate", async ({ params, respond }: GatewayRequestHandlerOptions) => { @@ -264,17 +293,13 @@ const voiceCallPlugin = { "voicecall.continue", async ({ params, respond }: GatewayRequestHandlerOptions) => { try { - const request = await resolveCallMessageRequest(params); - if ("error" in request) { - respond(false, { error: request.error }); - return; - } - const result = await request.rt.manager.continueCall(request.callId, request.message); - if (!result.success) { - respond(false, { error: result.error || "continue failed" }); - return; - } - respond(true, { success: true, transcript: result.transcript }); + await respondToCallMessageAction({ + requestParams: params, + respond, + action: (request) => request.rt.manager.continueCall(request.callId, request.message), + failure: "continue failed", + includeTranscript: true, + }); } catch (err) { sendError(respond, err); } @@ -285,17 +310,12 @@ const voiceCallPlugin = { "voicecall.speak", async ({ params, respond }: GatewayRequestHandlerOptions) => { try { - const request = await resolveCallMessageRequest(params); - if ("error" in request) { - respond(false, { error: request.error }); - return; - } - const result = await request.rt.manager.speak(request.callId, request.message); - if (!result.success) { - respond(false, { error: result.error || "speak failed" }); - return; - } - respond(true, { success: true }); + await respondToCallMessageAction({ + requestParams: params, + respond, + action: (request) => request.rt.manager.speak(request.callId, request.message), + failure: "speak failed", + }); } catch (err) { sendError(respond, err); } From 86caf454f435e3b9d740acc837d376f22a61760d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:05:12 +0000 Subject: [PATCH 135/461] refactor: share device pair ipv4 parsing --- extensions/device-pair/index.ts | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/extensions/device-pair/index.ts b/extensions/device-pair/index.ts index 825d1668ac0..7ba88842a7a 100644 --- a/extensions/device-pair/index.ts +++ b/extensions/device-pair/index.ts @@ -108,13 +108,21 @@ function resolveScheme( return cfg.gateway?.tls?.enabled === true ? "wss" : "ws"; } -function isPrivateIPv4(address: string): boolean { +function parseIPv4Octets(address: string): [number, number, number, number] | null { const parts = address.split("."); - if (parts.length != 4) { - return false; + if (parts.length !== 4) { + return null; } const octets = parts.map((part) => Number.parseInt(part, 10)); if (octets.some((value) => !Number.isFinite(value) || value < 0 || value > 255)) { + return null; + } + return octets as [number, number, number, number]; +} + +function isPrivateIPv4(address: string): boolean { + const octets = parseIPv4Octets(address); + if (!octets) { return false; } const [a, b] = octets; @@ -131,12 +139,8 @@ function isPrivateIPv4(address: string): boolean { } function isTailnetIPv4(address: string): boolean { - const parts = address.split("."); - if (parts.length !== 4) { - return false; - } - const octets = parts.map((part) => Number.parseInt(part, 10)); - if (octets.some((value) => !Number.isFinite(value) || value < 0 || value > 255)) { + const octets = parseIPv4Octets(address); + if (!octets) { return false; } const [a, b] = octets; From 07900303f4a1a9ae4da92ef3ba446f3c98782264 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:06:32 +0000 Subject: [PATCH 136/461] refactor: share outbound poll and signal route helpers --- src/infra/outbound/message.ts | 48 +++++++++++++++++++---------- src/signal/monitor/event-handler.ts | 48 ++++++++++++++++++----------- 2 files changed, 62 insertions(+), 34 deletions(-) diff --git a/src/infra/outbound/message.ts b/src/infra/outbound/message.ts index 8bfd6b104b5..3596bef59c9 100644 --- a/src/infra/outbound/message.ts +++ b/src/infra/outbound/message.ts @@ -100,6 +100,32 @@ export type MessagePollResult = { dryRun?: boolean; }; +function buildMessagePollResult(params: { + channel: string; + to: string; + normalized: { + question: string; + options: string[]; + maxSelections: number; + durationSeconds?: number | null; + durationHours?: number | null; + }; + result?: MessagePollResult["result"]; + dryRun?: boolean; +}): MessagePollResult { + return { + channel: params.channel, + to: params.to, + question: params.normalized.question, + options: params.normalized.options, + maxSelections: params.normalized.maxSelections, + durationSeconds: params.normalized.durationSeconds ?? null, + durationHours: params.normalized.durationHours ?? null, + via: "gateway", + ...(params.dryRun ? { dryRun: true } : { result: params.result }), + }; +} + async function resolveRequiredChannel(params: { cfg: OpenClawConfig; channel?: string; @@ -291,17 +317,12 @@ export async function sendPoll(params: MessagePollParams): Promise Date: Fri, 13 Mar 2026 22:06:49 +0000 Subject: [PATCH 137/461] test: dedupe diffs http local get setup --- extensions/diffs/src/http.test.ts | 35 ++++++++++++++----------------- 1 file changed, 16 insertions(+), 19 deletions(-) diff --git a/extensions/diffs/src/http.test.ts b/extensions/diffs/src/http.test.ts index 43216580379..a1caef018e4 100644 --- a/extensions/diffs/src/http.test.ts +++ b/extensions/diffs/src/http.test.ts @@ -9,6 +9,19 @@ describe("createDiffsHttpHandler", () => { let store: DiffArtifactStore; let cleanupRootDir: () => Promise; + async function handleLocalGet(url: string) { + const handler = createDiffsHttpHandler({ store }); + const res = createMockServerResponse(); + const handled = await handler( + localReq({ + method: "GET", + url, + }), + res, + ); + return { handled, res }; + } + beforeEach(async () => { ({ store, cleanup: cleanupRootDir } = await createDiffStoreHarness("openclaw-diffs-http-")); }); @@ -19,16 +32,7 @@ describe("createDiffsHttpHandler", () => { it("serves a stored diff document", async () => { const artifact = await createViewerArtifact(store); - - const handler = createDiffsHttpHandler({ store }); - const res = createMockServerResponse(); - const handled = await handler( - localReq({ - method: "GET", - url: artifact.viewerPath, - }), - res, - ); + const { handled, res } = await handleLocalGet(artifact.viewerPath); expect(handled).toBe(true); expect(res.statusCode).toBe(200); @@ -38,15 +42,8 @@ describe("createDiffsHttpHandler", () => { it("rejects invalid tokens", async () => { const artifact = await createViewerArtifact(store); - - const handler = createDiffsHttpHandler({ store }); - const res = createMockServerResponse(); - const handled = await handler( - localReq({ - method: "GET", - url: artifact.viewerPath.replace(artifact.token, "bad-token"), - }), - res, + const { handled, res } = await handleLocalGet( + artifact.viewerPath.replace(artifact.token, "bad-token"), ); expect(handled).toBe(true); From 088d6432a4e820fc892508ba72548b9a80aaaac2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:06:55 +0000 Subject: [PATCH 138/461] test: dedupe diffs file artifact assertions --- extensions/diffs/src/tool.test.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/extensions/diffs/src/tool.test.ts b/extensions/diffs/src/tool.test.ts index 416bdf8dc14..1e939c60390 100644 --- a/extensions/diffs/src/tool.test.ts +++ b/extensions/diffs/src/tool.test.ts @@ -135,9 +135,7 @@ describe("diffs tool", () => { mode: "file", }); - expect(screenshotter.screenshotHtml).toHaveBeenCalledTimes(1); - expect((result?.details as Record).mode).toBe("file"); - expect((result?.details as Record).viewerUrl).toBeUndefined(); + expectArtifactOnlyFileResult(screenshotter, result); }); it("honors ttlSeconds for artifact-only file output", async () => { @@ -227,9 +225,7 @@ describe("diffs tool", () => { after: "two\n", }); - expect(screenshotter.screenshotHtml).toHaveBeenCalledTimes(1); - expect((result?.details as Record).mode).toBe("file"); - expect((result?.details as Record).viewerUrl).toBeUndefined(); + expectArtifactOnlyFileResult(screenshotter, result); }); it("falls back to view output when both mode cannot render an image", async () => { @@ -434,6 +430,15 @@ function createToolWithScreenshotter( }); } +function expectArtifactOnlyFileResult( + screenshotter: DiffScreenshotter, + result: { details?: Record } | null | undefined, +) { + expect(screenshotter.screenshotHtml).toHaveBeenCalledTimes(1); + expect((result?.details as Record).mode).toBe("file"); + expect((result?.details as Record).viewerUrl).toBeUndefined(); +} + function createPngScreenshotter( params: { assertHtml?: (html: string) => void; From 41fa63a49ef69058e225b8dcdcfb0626a0b5d74a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:07:06 +0000 Subject: [PATCH 139/461] refactor: share anthropic compat flag checks --- .../anthropic-stream-wrappers.ts | 37 +++++++------------ 1 file changed, 14 insertions(+), 23 deletions(-) diff --git a/src/agents/pi-embedded-runner/anthropic-stream-wrappers.ts b/src/agents/pi-embedded-runner/anthropic-stream-wrappers.ts index 19b5701eaaa..e04de8a5d6b 100644 --- a/src/agents/pi-embedded-runner/anthropic-stream-wrappers.ts +++ b/src/agents/pi-embedded-runner/anthropic-stream-wrappers.ts @@ -75,6 +75,17 @@ function resolveAnthropicFastServiceTier(enabled: boolean): AnthropicServiceTier return enabled ? "auto" : "standard_only"; } +function hasOpenAiAnthropicToolPayloadCompatFlag(model: { compat?: unknown }): boolean { + if (!model.compat || typeof model.compat !== "object" || Array.isArray(model.compat)) { + return false; + } + + return ( + (model.compat as { requiresOpenAiAnthropicToolPayload?: unknown }) + .requiresOpenAiAnthropicToolPayload === true + ); +} + function requiresAnthropicToolPayloadCompatibilityForModel(model: { api?: unknown; provider?: unknown; @@ -90,15 +101,7 @@ function requiresAnthropicToolPayloadCompatibilityForModel(model: { ) { return true; } - - if (!model.compat || typeof model.compat !== "object" || Array.isArray(model.compat)) { - return false; - } - - return ( - (model.compat as { requiresOpenAiAnthropicToolPayload?: unknown }) - .requiresOpenAiAnthropicToolPayload === true - ); + return hasOpenAiAnthropicToolPayloadCompatFlag(model); } function usesOpenAiFunctionAnthropicToolSchemaForModel(model: { @@ -108,13 +111,7 @@ function usesOpenAiFunctionAnthropicToolSchemaForModel(model: { if (typeof model.provider === "string" && usesOpenAiFunctionAnthropicToolSchema(model.provider)) { return true; } - if (!model.compat || typeof model.compat !== "object" || Array.isArray(model.compat)) { - return false; - } - return ( - (model.compat as { requiresOpenAiAnthropicToolPayload?: unknown }) - .requiresOpenAiAnthropicToolPayload === true - ); + return hasOpenAiAnthropicToolPayloadCompatFlag(model); } function usesOpenAiStringModeAnthropicToolChoiceForModel(model: { @@ -127,13 +124,7 @@ function usesOpenAiStringModeAnthropicToolChoiceForModel(model: { ) { return true; } - if (!model.compat || typeof model.compat !== "object" || Array.isArray(model.compat)) { - return false; - } - return ( - (model.compat as { requiresOpenAiAnthropicToolPayload?: unknown }) - .requiresOpenAiAnthropicToolPayload === true - ); + return hasOpenAiAnthropicToolPayloadCompatFlag(model); } function normalizeOpenAiFunctionAnthropicToolDefinition( From 1d99401b8bc571fca51a33567a4748343ffaf2bf Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:07:51 +0000 Subject: [PATCH 140/461] refactor: share telegram voice send path --- src/telegram/bot/delivery.replies.ts | 27 +++++++++++---------------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/src/telegram/bot/delivery.replies.ts b/src/telegram/bot/delivery.replies.ts index 5f5edd3b837..6fa6dcca9b0 100644 --- a/src/telegram/bot/delivery.replies.ts +++ b/src/telegram/bot/delivery.replies.ts @@ -345,14 +345,16 @@ async function deliverMediaReply(params: { logFallback: logVerbose, }); if (useVoice) { - await params.onVoiceRecording?.(); - try { + const sendVoiceMedia = async ( + requestParams: typeof mediaParams, + shouldLog?: (err: unknown) => boolean, + ) => { const result = await sendTelegramWithThreadFallback({ operation: "sendVoice", runtime: params.runtime, thread: params.thread, - requestParams: mediaParams, - shouldLog: (err) => !isVoiceMessagesForbidden(err), + requestParams, + shouldLog, send: (effectiveParams) => params.bot.api.sendVoice(params.chatId, file, { ...effectiveParams }), }); @@ -360,6 +362,10 @@ async function deliverMediaReply(params: { firstDeliveredMessageId = result.message_id; } markDelivered(params.progress); + }; + await params.onVoiceRecording?.(); + try { + await sendVoiceMedia(mediaParams, (err) => !isVoiceMessagesForbidden(err)); } catch (voiceErr) { if (isVoiceMessagesForbidden(voiceErr)) { const fallbackText = params.reply.text; @@ -400,18 +406,7 @@ async function deliverMediaReply(params: { const noCaptionParams = { ...mediaParams }; delete noCaptionParams.caption; delete noCaptionParams.parse_mode; - const result = await sendTelegramWithThreadFallback({ - operation: "sendVoice", - runtime: params.runtime, - thread: params.thread, - requestParams: noCaptionParams, - send: (effectiveParams) => - params.bot.api.sendVoice(params.chatId, file, { ...effectiveParams }), - }); - if (firstDeliveredMessageId == null) { - firstDeliveredMessageId = result.message_id; - } - markDelivered(params.progress); + await sendVoiceMedia(noCaptionParams); const fallbackText = params.reply.text; if (fallbackText?.trim()) { await sendTelegramVoiceFallbackText({ From b4719455bc9b7036e72e5b38dc36d38bbde52bde Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:09:33 +0000 Subject: [PATCH 141/461] test: dedupe gemini oauth project assertions --- .../google-gemini-cli-auth/oauth.test.ts | 40 ++++++++++++------- 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/extensions/google-gemini-cli-auth/oauth.test.ts b/extensions/google-gemini-cli-auth/oauth.test.ts index 1471f804771..68e9cebdd37 100644 --- a/extensions/google-gemini-cli-auth/oauth.test.ts +++ b/extensions/google-gemini-cli-auth/oauth.test.ts @@ -144,6 +144,13 @@ describe("extractGeminiCliCredentials", () => { } } + function expectFakeCliCredentials(result: unknown) { + expect(result).toEqual({ + clientId: FAKE_CLIENT_ID, + clientSecret: FAKE_CLIENT_SECRET, + }); + } + beforeEach(async () => { vi.clearAllMocks(); originalPath = process.env.PATH; @@ -169,10 +176,7 @@ describe("extractGeminiCliCredentials", () => { clearCredentialsCache(); const result = extractGeminiCliCredentials(); - expect(result).toEqual({ - clientId: FAKE_CLIENT_ID, - clientSecret: FAKE_CLIENT_SECRET, - }); + expectFakeCliCredentials(result); }); it("extracts credentials when PATH entry is an npm global shim", async () => { @@ -182,10 +186,7 @@ describe("extractGeminiCliCredentials", () => { clearCredentialsCache(); const result = extractGeminiCliCredentials(); - expect(result).toEqual({ - clientId: FAKE_CLIENT_ID, - clientSecret: FAKE_CLIENT_SECRET, - }); + expectFakeCliCredentials(result); }); it("returns null when oauth2.js cannot be found", async () => { @@ -304,6 +305,21 @@ describe("loginGeminiCliOAuth", () => { return { result, authUrl }; } + async function runRemoteLoginExpectingProjectId( + loginGeminiCliOAuth: (options: { + isRemote: boolean; + openUrl: () => Promise; + log: (msg: string) => void; + note: () => Promise; + prompt: () => Promise; + progress: { update: () => void; stop: () => void }; + }) => Promise<{ projectId: string }>, + projectId: string, + ) { + const { result } = await runRemoteLoginWithCapturedAuthUrl(loginGeminiCliOAuth); + expect(result.projectId).toBe(projectId); + } + let envSnapshot: Partial>; beforeEach(() => { envSnapshot = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]])); @@ -357,9 +373,7 @@ describe("loginGeminiCliOAuth", () => { vi.stubGlobal("fetch", fetchMock); const { loginGeminiCliOAuth } = await import("./oauth.js"); - const { result } = await runRemoteLoginWithCapturedAuthUrl(loginGeminiCliOAuth); - - expect(result.projectId).toBe("daily-project"); + await runRemoteLoginExpectingProjectId(loginGeminiCliOAuth, "daily-project"); const loadRequests = requests.filter((request) => request.url.includes("v1internal:loadCodeAssist"), ); @@ -414,9 +428,7 @@ describe("loginGeminiCliOAuth", () => { vi.stubGlobal("fetch", fetchMock); const { loginGeminiCliOAuth } = await import("./oauth.js"); - const { result } = await runRemoteLoginWithCapturedAuthUrl(loginGeminiCliOAuth); - - expect(result.projectId).toBe("env-project"); + await runRemoteLoginExpectingProjectId(loginGeminiCliOAuth, "env-project"); expect(requests.filter((url) => url.includes("v1internal:loadCodeAssist"))).toHaveLength(3); expect(requests.some((url) => url.includes("v1internal:onboardUser"))).toBe(false); }); From ccd763aef7c5ac3dccf4a41b516ff672f291af8d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:10:10 +0000 Subject: [PATCH 142/461] test: dedupe gemini oauth fallback checks --- .../google-gemini-cli-auth/oauth.test.ts | 29 +++++++------------ 1 file changed, 11 insertions(+), 18 deletions(-) diff --git a/extensions/google-gemini-cli-auth/oauth.test.ts b/extensions/google-gemini-cli-auth/oauth.test.ts index 68e9cebdd37..02100b73b1f 100644 --- a/extensions/google-gemini-cli-auth/oauth.test.ts +++ b/extensions/google-gemini-cli-auth/oauth.test.ts @@ -275,16 +275,16 @@ describe("loginGeminiCliOAuth", () => { }); } - async function runRemoteLoginWithCapturedAuthUrl( - loginGeminiCliOAuth: (options: { - isRemote: boolean; - openUrl: () => Promise; - log: (msg: string) => void; - note: () => Promise; - prompt: () => Promise; - progress: { update: () => void; stop: () => void }; - }) => Promise<{ projectId: string }>, - ) { + type LoginGeminiCliOAuthFn = (options: { + isRemote: boolean; + openUrl: () => Promise; + log: (msg: string) => void; + note: () => Promise; + prompt: () => Promise; + progress: { update: () => void; stop: () => void }; + }) => Promise<{ projectId: string }>; + + async function runRemoteLoginWithCapturedAuthUrl(loginGeminiCliOAuth: LoginGeminiCliOAuthFn) { let authUrl = ""; const result = await loginGeminiCliOAuth({ isRemote: true, @@ -306,14 +306,7 @@ describe("loginGeminiCliOAuth", () => { } async function runRemoteLoginExpectingProjectId( - loginGeminiCliOAuth: (options: { - isRemote: boolean; - openUrl: () => Promise; - log: (msg: string) => void; - note: () => Promise; - prompt: () => Promise; - progress: { update: () => void; stop: () => void }; - }) => Promise<{ projectId: string }>, + loginGeminiCliOAuth: LoginGeminiCliOAuthFn, projectId: string, ) { const { result } = await runRemoteLoginWithCapturedAuthUrl(loginGeminiCliOAuth); From 22e976574c5961dbb775268a7e227ac1fb17a579 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:10:48 +0000 Subject: [PATCH 143/461] test: dedupe inbound main scope fixtures --- .../process-message.inbound-contract.test.ts | 56 ++++++++----------- 1 file changed, 24 insertions(+), 32 deletions(-) diff --git a/src/web/auto-reply/monitor/process-message.inbound-contract.test.ts b/src/web/auto-reply/monitor/process-message.inbound-contract.test.ts index ce3c9700d7b..1a02f2d5f93 100644 --- a/src/web/auto-reply/monitor/process-message.inbound-contract.test.ts +++ b/src/web/auto-reply/monitor/process-message.inbound-contract.test.ts @@ -362,13 +362,14 @@ describe("web processMessage inbound contract", () => { expect(updateLastRouteMock).not.toHaveBeenCalled(); }); - it("does not update main last route for non-owner sender when main DM scope is pinned", async () => { - const updateLastRouteMock = vi.mocked(updateLastRouteInBackground); - updateLastRouteMock.mockClear(); - + function makePinnedMainScopeArgs(params: { + groupHistoryKey: string; + messageId: string; + from: string; + }) { const args = makeProcessMessageArgs({ routeSessionKey: "agent:main:main", - groupHistoryKey: "+3000", + groupHistoryKey: params.groupHistoryKey, cfg: { channels: { whatsapp: { @@ -379,12 +380,12 @@ describe("web processMessage inbound contract", () => { session: { store: sessionStorePath, dmScope: "main" }, } as unknown as ReturnType, msg: { - id: "msg-last-route-3", - from: "+3000", + id: params.messageId, + from: params.from, to: "+2000", chatType: "direct", body: "hello", - senderE164: "+3000", + senderE164: params.from, }, }); args.route = { @@ -392,6 +393,18 @@ describe("web processMessage inbound contract", () => { sessionKey: "agent:main:main", mainSessionKey: "agent:main:main", }; + return args; + } + + it("does not update main last route for non-owner sender when main DM scope is pinned", async () => { + const updateLastRouteMock = vi.mocked(updateLastRouteInBackground); + updateLastRouteMock.mockClear(); + + const args = makePinnedMainScopeArgs({ + groupHistoryKey: "+3000", + messageId: "msg-last-route-3", + from: "+3000", + }); await processMessage(args); @@ -402,32 +415,11 @@ describe("web processMessage inbound contract", () => { const updateLastRouteMock = vi.mocked(updateLastRouteInBackground); updateLastRouteMock.mockClear(); - const args = makeProcessMessageArgs({ - routeSessionKey: "agent:main:main", + const args = makePinnedMainScopeArgs({ groupHistoryKey: "+1000", - cfg: { - channels: { - whatsapp: { - allowFrom: ["+1000"], - }, - }, - messages: {}, - session: { store: sessionStorePath, dmScope: "main" }, - } as unknown as ReturnType, - msg: { - id: "msg-last-route-4", - from: "+1000", - to: "+2000", - chatType: "direct", - body: "hello", - senderE164: "+1000", - }, + messageId: "msg-last-route-4", + from: "+1000", }); - args.route = { - ...args.route, - sessionKey: "agent:main:main", - mainSessionKey: "agent:main:main", - }; await processMessage(args); From a5671ea3d85bb6920d92266488c4989a87f5fb78 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:11:44 +0000 Subject: [PATCH 144/461] test: dedupe discord delivery target setup --- extensions/discord/src/subagent-hooks.test.ts | 69 +++++++++---------- 1 file changed, 32 insertions(+), 37 deletions(-) diff --git a/extensions/discord/src/subagent-hooks.test.ts b/extensions/discord/src/subagent-hooks.test.ts index d58f07c1314..6d5824f69ae 100644 --- a/extensions/discord/src/subagent-hooks.test.ts +++ b/extensions/discord/src/subagent-hooks.test.ts @@ -75,6 +75,27 @@ function getRequiredHandler( return handler; } +function resolveSubagentDeliveryTargetForTest(requesterOrigin: { + channel: string; + accountId: string; + to: string; + threadId?: string; +}) { + const handlers = registerHandlersForTest(); + const handler = getRequiredHandler(handlers, "subagent_delivery_target"); + return handler( + { + childSessionKey: "agent:main:subagent:child", + requesterSessionKey: "agent:main:main", + requesterOrigin, + childRunId: "run-1", + spawnMode: "session", + expectsCompletionMessage: true, + }, + {}, + ); +} + function createSpawnEvent(overrides?: { childSessionKey?: string; agentId?: string; @@ -324,25 +345,12 @@ describe("discord subagent hook handlers", () => { hookMocks.listThreadBindingsBySessionKey.mockReturnValueOnce([ { accountId: "work", threadId: "777" }, ]); - const handlers = registerHandlersForTest(); - const handler = getRequiredHandler(handlers, "subagent_delivery_target"); - - const result = handler( - { - childSessionKey: "agent:main:subagent:child", - requesterSessionKey: "agent:main:main", - requesterOrigin: { - channel: "discord", - accountId: "work", - to: "channel:123", - threadId: "777", - }, - childRunId: "run-1", - spawnMode: "session", - expectsCompletionMessage: true, - }, - {}, - ); + const result = resolveSubagentDeliveryTargetForTest({ + channel: "discord", + accountId: "work", + to: "channel:123", + threadId: "777", + }); expect(hookMocks.listThreadBindingsBySessionKey).toHaveBeenCalledWith({ targetSessionKey: "agent:main:subagent:child", @@ -364,24 +372,11 @@ describe("discord subagent hook handlers", () => { { accountId: "work", threadId: "777" }, { accountId: "work", threadId: "888" }, ]); - const handlers = registerHandlersForTest(); - const handler = getRequiredHandler(handlers, "subagent_delivery_target"); - - const result = handler( - { - childSessionKey: "agent:main:subagent:child", - requesterSessionKey: "agent:main:main", - requesterOrigin: { - channel: "discord", - accountId: "work", - to: "channel:123", - }, - childRunId: "run-1", - spawnMode: "session", - expectsCompletionMessage: true, - }, - {}, - ); + const result = resolveSubagentDeliveryTargetForTest({ + channel: "discord", + accountId: "work", + to: "channel:123", + }); expect(result).toBeUndefined(); }); From bbb52087ede249fa8ead847cced97714e96790a8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:14:35 +0000 Subject: [PATCH 145/461] test: dedupe llm task embedded run setup --- extensions/llm-task/src/llm-task-tool.test.ts | 78 +++++++------------ 1 file changed, 29 insertions(+), 49 deletions(-) diff --git a/extensions/llm-task/src/llm-task-tool.test.ts b/extensions/llm-task/src/llm-task-tool.test.ts index fc9f0e07215..2bf0cb655aa 100644 --- a/extensions/llm-task/src/llm-task-tool.test.ts +++ b/extensions/llm-task/src/llm-task-tool.test.ts @@ -29,6 +29,21 @@ function fakeApi(overrides: any = {}) { }; } +function mockEmbeddedRunJson(payload: unknown) { + // oxlint-disable-next-line typescript/no-explicit-any + (runEmbeddedPiAgent as any).mockResolvedValueOnce({ + meta: {}, + payloads: [{ text: JSON.stringify(payload) }], + }); +} + +async function executeEmbeddedRun(input: Record) { + const tool = createLlmTaskTool(fakeApi()); + await tool.execute("id", input); + // oxlint-disable-next-line typescript/no-explicit-any + return (runEmbeddedPiAgent as any).mock.calls[0]?.[0]; +} + describe("llm-task tool (json-only)", () => { beforeEach(() => vi.clearAllMocks()); @@ -96,42 +111,25 @@ describe("llm-task tool (json-only)", () => { }); it("passes provider/model overrides to embedded runner", async () => { - // oxlint-disable-next-line typescript/no-explicit-any - (runEmbeddedPiAgent as any).mockResolvedValueOnce({ - meta: {}, - payloads: [{ text: JSON.stringify({ ok: true }) }], + mockEmbeddedRunJson({ ok: true }); + const call = await executeEmbeddedRun({ + prompt: "x", + provider: "anthropic", + model: "claude-4-sonnet", }); - const tool = createLlmTaskTool(fakeApi()); - await tool.execute("id", { prompt: "x", provider: "anthropic", model: "claude-4-sonnet" }); - // oxlint-disable-next-line typescript/no-explicit-any - const call = (runEmbeddedPiAgent as any).mock.calls[0]?.[0]; expect(call.provider).toBe("anthropic"); expect(call.model).toBe("claude-4-sonnet"); }); it("passes thinking override to embedded runner", async () => { - // oxlint-disable-next-line typescript/no-explicit-any - (runEmbeddedPiAgent as any).mockResolvedValueOnce({ - meta: {}, - payloads: [{ text: JSON.stringify({ ok: true }) }], - }); - const tool = createLlmTaskTool(fakeApi()); - await tool.execute("id", { prompt: "x", thinking: "high" }); - // oxlint-disable-next-line typescript/no-explicit-any - const call = (runEmbeddedPiAgent as any).mock.calls[0]?.[0]; + mockEmbeddedRunJson({ ok: true }); + const call = await executeEmbeddedRun({ prompt: "x", thinking: "high" }); expect(call.thinkLevel).toBe("high"); }); it("normalizes thinking aliases", async () => { - // oxlint-disable-next-line typescript/no-explicit-any - (runEmbeddedPiAgent as any).mockResolvedValueOnce({ - meta: {}, - payloads: [{ text: JSON.stringify({ ok: true }) }], - }); - const tool = createLlmTaskTool(fakeApi()); - await tool.execute("id", { prompt: "x", thinking: "on" }); - // oxlint-disable-next-line typescript/no-explicit-any - const call = (runEmbeddedPiAgent as any).mock.calls[0]?.[0]; + mockEmbeddedRunJson({ ok: true }); + const call = await executeEmbeddedRun({ prompt: "x", thinking: "on" }); expect(call.thinkLevel).toBe("low"); }); @@ -150,24 +148,13 @@ describe("llm-task tool (json-only)", () => { }); it("does not pass thinkLevel when thinking is omitted", async () => { - // oxlint-disable-next-line typescript/no-explicit-any - (runEmbeddedPiAgent as any).mockResolvedValueOnce({ - meta: {}, - payloads: [{ text: JSON.stringify({ ok: true }) }], - }); - const tool = createLlmTaskTool(fakeApi()); - await tool.execute("id", { prompt: "x" }); - // oxlint-disable-next-line typescript/no-explicit-any - const call = (runEmbeddedPiAgent as any).mock.calls[0]?.[0]; + mockEmbeddedRunJson({ ok: true }); + const call = await executeEmbeddedRun({ prompt: "x" }); expect(call.thinkLevel).toBeUndefined(); }); it("enforces allowedModels", async () => { - // oxlint-disable-next-line typescript/no-explicit-any - (runEmbeddedPiAgent as any).mockResolvedValueOnce({ - meta: {}, - payloads: [{ text: JSON.stringify({ ok: true }) }], - }); + mockEmbeddedRunJson({ ok: true }); const tool = createLlmTaskTool( fakeApi({ pluginConfig: { allowedModels: ["openai-codex/gpt-5.2"] } }), ); @@ -177,15 +164,8 @@ describe("llm-task tool (json-only)", () => { }); it("disables tools for embedded run", async () => { - // oxlint-disable-next-line typescript/no-explicit-any - (runEmbeddedPiAgent as any).mockResolvedValueOnce({ - meta: {}, - payloads: [{ text: JSON.stringify({ ok: true }) }], - }); - const tool = createLlmTaskTool(fakeApi()); - await tool.execute("id", { prompt: "x" }); - // oxlint-disable-next-line typescript/no-explicit-any - const call = (runEmbeddedPiAgent as any).mock.calls[0]?.[0]; + mockEmbeddedRunJson({ ok: true }); + const call = await executeEmbeddedRun({ prompt: "x" }); expect(call.disableTools).toBe(true); }); }); From 7b70fa26e685d249354eae04b66c338169f1d083 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:15:07 +0000 Subject: [PATCH 146/461] test: dedupe discord thread starter setup --- src/discord/monitor/threading.starter.test.ts | 62 ++++++++++--------- 1 file changed, 33 insertions(+), 29 deletions(-) diff --git a/src/discord/monitor/threading.starter.test.ts b/src/discord/monitor/threading.starter.test.ts index 07268d7fae9..65e556a55dc 100644 --- a/src/discord/monitor/threading.starter.test.ts +++ b/src/discord/monitor/threading.starter.test.ts @@ -5,27 +5,37 @@ import { resolveDiscordThreadStarter, } from "./threading.js"; +async function resolveStarter( + message: Partial>>, + resolveTimestampMs: () => number | undefined, +) { + const get = vi.fn().mockResolvedValue(message); + const client = { rest: { get } } as unknown as Client; + + return resolveDiscordThreadStarter({ + channel: { id: "thread-1" }, + client, + parentId: "parent-1", + parentType: ChannelType.GuildText, + resolveTimestampMs, + }); +} + describe("resolveDiscordThreadStarter", () => { beforeEach(() => { __resetDiscordThreadStarterCacheForTest(); }); it("falls back to joined embed title and description when content is empty", async () => { - const get = vi.fn().mockResolvedValue({ - content: " ", - embeds: [{ title: "Alert", description: "Details" }], - author: { username: "Alice", discriminator: "0" }, - timestamp: "2026-02-24T12:00:00.000Z", - }); - const client = { rest: { get } } as unknown as Client; - - const result = await resolveDiscordThreadStarter({ - channel: { id: "thread-1" }, - client, - parentId: "parent-1", - parentType: ChannelType.GuildText, - resolveTimestampMs: () => 123, - }); + const result = await resolveStarter( + { + content: " ", + embeds: [{ title: "Alert", description: "Details" }], + author: { username: "Alice", discriminator: "0" }, + timestamp: "2026-02-24T12:00:00.000Z", + }, + () => 123, + ); expect(result).toEqual({ text: "Alert\nDetails", @@ -35,20 +45,14 @@ describe("resolveDiscordThreadStarter", () => { }); it("prefers starter content over embed fallback text", async () => { - const get = vi.fn().mockResolvedValue({ - content: "starter content", - embeds: [{ title: "Alert", description: "Details" }], - author: { username: "Alice", discriminator: "0" }, - }); - const client = { rest: { get } } as unknown as Client; - - const result = await resolveDiscordThreadStarter({ - channel: { id: "thread-1" }, - client, - parentId: "parent-1", - parentType: ChannelType.GuildText, - resolveTimestampMs: () => undefined, - }); + const result = await resolveStarter( + { + content: "starter content", + embeds: [{ title: "Alert", description: "Details" }], + author: { username: "Alice", discriminator: "0" }, + }, + () => undefined, + ); expect(result?.text).toBe("starter content"); }); From 36e9a811cc94e235b8eb6a543e8aad090ee3db56 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:15:54 +0000 Subject: [PATCH 147/461] test: dedupe discord auto thread harness --- .../monitor/threading.auto-thread.test.ts | 145 ++++++------------ 1 file changed, 50 insertions(+), 95 deletions(-) diff --git a/src/discord/monitor/threading.auto-thread.test.ts b/src/discord/monitor/threading.auto-thread.test.ts index 2affabcae44..4759de9df28 100644 --- a/src/discord/monitor/threading.auto-thread.test.ts +++ b/src/discord/monitor/threading.auto-thread.test.ts @@ -2,72 +2,78 @@ import { ChannelType } from "@buape/carbon"; import { describe, it, expect, vi, beforeEach } from "vitest"; import { maybeCreateDiscordAutoThread } from "./threading.js"; +const postMock = vi.fn(); +const getMock = vi.fn(); +const mockClient = { + rest: { post: postMock, get: getMock }, +} as unknown as Parameters[0]["client"]; +const mockMessage = { + id: "msg1", + timestamp: "123", +} as unknown as Parameters[0]["message"]; + +async function runAutoThread( + overrides: Partial[0]> = {}, +) { + return maybeCreateDiscordAutoThread({ + client: mockClient, + message: mockMessage, + messageChannelId: "text1", + isGuildMessage: true, + channelConfig: { allowed: true, autoThread: true }, + channelType: ChannelType.GuildText, + baseText: "test", + combinedBody: "test", + ...overrides, + }); +} + +function expectAutoArchiveDuration(autoArchiveDuration: number) { + expect(postMock).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + body: expect.objectContaining({ auto_archive_duration: autoArchiveDuration }), + }), + ); +} + describe("maybeCreateDiscordAutoThread", () => { - const postMock = vi.fn(); - const getMock = vi.fn(); - const mockClient = { - rest: { post: postMock, get: getMock }, - } as unknown as Parameters[0]["client"]; - const mockMessage = { - id: "msg1", - timestamp: "123", - } as unknown as Parameters[0]["message"]; + beforeEach(() => { + postMock.mockReset(); + getMock.mockReset(); + }); it("skips auto-thread if channelType is GuildForum", async () => { - const result = await maybeCreateDiscordAutoThread({ - client: mockClient, - message: mockMessage, + const result = await runAutoThread({ messageChannelId: "forum1", - isGuildMessage: true, - channelConfig: { allowed: true, autoThread: true }, channelType: ChannelType.GuildForum, - baseText: "test", - combinedBody: "test", }); expect(result).toBeUndefined(); expect(postMock).not.toHaveBeenCalled(); }); it("skips auto-thread if channelType is GuildMedia", async () => { - const result = await maybeCreateDiscordAutoThread({ - client: mockClient, - message: mockMessage, + const result = await runAutoThread({ messageChannelId: "media1", - isGuildMessage: true, - channelConfig: { allowed: true, autoThread: true }, channelType: ChannelType.GuildMedia, - baseText: "test", - combinedBody: "test", }); expect(result).toBeUndefined(); expect(postMock).not.toHaveBeenCalled(); }); it("skips auto-thread if channelType is GuildVoice", async () => { - const result = await maybeCreateDiscordAutoThread({ - client: mockClient, - message: mockMessage, + const result = await runAutoThread({ messageChannelId: "voice1", - isGuildMessage: true, - channelConfig: { allowed: true, autoThread: true }, channelType: ChannelType.GuildVoice, - baseText: "test", - combinedBody: "test", }); expect(result).toBeUndefined(); expect(postMock).not.toHaveBeenCalled(); }); it("skips auto-thread if channelType is GuildStageVoice", async () => { - const result = await maybeCreateDiscordAutoThread({ - client: mockClient, - message: mockMessage, + const result = await runAutoThread({ messageChannelId: "stage1", - isGuildMessage: true, - channelConfig: { allowed: true, autoThread: true }, channelType: ChannelType.GuildStageVoice, - baseText: "test", - combinedBody: "test", }); expect(result).toBeUndefined(); expect(postMock).not.toHaveBeenCalled(); @@ -75,32 +81,13 @@ describe("maybeCreateDiscordAutoThread", () => { it("creates auto-thread if channelType is GuildText", async () => { postMock.mockResolvedValueOnce({ id: "thread1" }); - const result = await maybeCreateDiscordAutoThread({ - client: mockClient, - message: mockMessage, - messageChannelId: "text1", - isGuildMessage: true, - channelConfig: { allowed: true, autoThread: true }, - channelType: ChannelType.GuildText, - baseText: "test", - combinedBody: "test", - }); + const result = await runAutoThread(); expect(result).toBe("thread1"); expect(postMock).toHaveBeenCalled(); }); }); describe("maybeCreateDiscordAutoThread autoArchiveDuration", () => { - const postMock = vi.fn(); - const getMock = vi.fn(); - const mockClient = { - rest: { post: postMock, get: getMock }, - } as unknown as Parameters[0]["client"]; - const mockMessage = { - id: "msg1", - timestamp: "123", - } as unknown as Parameters[0]["message"]; - beforeEach(() => { postMock.mockReset(); getMock.mockReset(); @@ -108,55 +95,23 @@ describe("maybeCreateDiscordAutoThread autoArchiveDuration", () => { it("uses configured autoArchiveDuration", async () => { postMock.mockResolvedValueOnce({ id: "thread1" }); - await maybeCreateDiscordAutoThread({ - client: mockClient, - message: mockMessage, - messageChannelId: "text1", - isGuildMessage: true, + await runAutoThread({ channelConfig: { allowed: true, autoThread: true, autoArchiveDuration: "10080" }, - channelType: ChannelType.GuildText, - baseText: "test", - combinedBody: "test", }); - expect(postMock).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ body: expect.objectContaining({ auto_archive_duration: 10080 }) }), - ); + expectAutoArchiveDuration(10080); }); it("accepts numeric autoArchiveDuration", async () => { postMock.mockResolvedValueOnce({ id: "thread1" }); - await maybeCreateDiscordAutoThread({ - client: mockClient, - message: mockMessage, - messageChannelId: "text1", - isGuildMessage: true, + await runAutoThread({ channelConfig: { allowed: true, autoThread: true, autoArchiveDuration: 4320 }, - channelType: ChannelType.GuildText, - baseText: "test", - combinedBody: "test", }); - expect(postMock).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ body: expect.objectContaining({ auto_archive_duration: 4320 }) }), - ); + expectAutoArchiveDuration(4320); }); it("defaults to 60 when autoArchiveDuration not set", async () => { postMock.mockResolvedValueOnce({ id: "thread1" }); - await maybeCreateDiscordAutoThread({ - client: mockClient, - message: mockMessage, - messageChannelId: "text1", - isGuildMessage: true, - channelConfig: { allowed: true, autoThread: true }, - channelType: ChannelType.GuildText, - baseText: "test", - combinedBody: "test", - }); - expect(postMock).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ body: expect.objectContaining({ auto_archive_duration: 60 }) }), - ); + await runAutoThread(); + expectAutoArchiveDuration(60); }); }); From b9e5f23914f88c0cc4a10d7a1f87be0cf68761f3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:16:31 +0000 Subject: [PATCH 148/461] test: dedupe route reply slack no-op cases --- src/auto-reply/reply/route-reply.test.ts | 66 +++++++++--------------- 1 file changed, 25 insertions(+), 41 deletions(-) diff --git a/src/auto-reply/reply/route-reply.test.ts b/src/auto-reply/reply/route-reply.test.ts index 5a0405da22b..62f91097223 100644 --- a/src/auto-reply/reply/route-reply.test.ts +++ b/src/auto-reply/reply/route-reply.test.ts @@ -105,6 +105,23 @@ const createMSTeamsPlugin = (params: { outbound: ChannelOutboundAdapter }): Chan outbound: params.outbound, }); +async function expectSlackNoSend( + payload: Parameters[0]["payload"], + overrides: Partial[0]> = {}, +) { + mocks.sendMessageSlack.mockClear(); + const res = await routeReply({ + payload, + channel: "slack", + to: "channel:C123", + cfg: {} as never, + ...overrides, + }); + expect(res.ok).toBe(true); + expect(mocks.sendMessageSlack).not.toHaveBeenCalled(); + return res; +} + describe("routeReply", () => { beforeEach(() => { setActivePluginRegistry(defaultRegistry); @@ -132,39 +149,15 @@ describe("routeReply", () => { }); it("no-ops on empty payload", async () => { - mocks.sendMessageSlack.mockClear(); - const res = await routeReply({ - payload: {}, - channel: "slack", - to: "channel:C123", - cfg: {} as never, - }); - expect(res.ok).toBe(true); - expect(mocks.sendMessageSlack).not.toHaveBeenCalled(); + await expectSlackNoSend({}); }); it("suppresses reasoning payloads", async () => { - mocks.sendMessageSlack.mockClear(); - const res = await routeReply({ - payload: { text: "Reasoning:\n_step_", isReasoning: true }, - channel: "slack", - to: "channel:C123", - cfg: {} as never, - }); - expect(res.ok).toBe(true); - expect(mocks.sendMessageSlack).not.toHaveBeenCalled(); + await expectSlackNoSend({ text: "Reasoning:\n_step_", isReasoning: true }); }); it("drops silent token payloads", async () => { - mocks.sendMessageSlack.mockClear(); - const res = await routeReply({ - payload: { text: SILENT_REPLY_TOKEN }, - channel: "slack", - to: "channel:C123", - cfg: {} as never, - }); - expect(res.ok).toBe(true); - expect(mocks.sendMessageSlack).not.toHaveBeenCalled(); + await expectSlackNoSend({ text: SILENT_REPLY_TOKEN }); }); it("does not drop payloads that merely start with the silent token", async () => { @@ -231,23 +224,14 @@ describe("routeReply", () => { }); it("does not bypass the empty-reply guard for invalid Slack blocks", async () => { - mocks.sendMessageSlack.mockClear(); - const res = await routeReply({ - payload: { - text: " ", - channelData: { - slack: { - blocks: " ", - }, + await expectSlackNoSend({ + text: " ", + channelData: { + slack: { + blocks: " ", }, }, - channel: "slack", - to: "channel:C123", - cfg: {} as never, }); - - expect(res.ok).toBe(true); - expect(mocks.sendMessageSlack).not.toHaveBeenCalled(); }); it("does not derive responsePrefix from agent identity when routing", async () => { From 91c94c8b950cbaec5546e5262afbb6312272282a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:17:11 +0000 Subject: [PATCH 149/461] test: dedupe elevated permission assertions --- src/auto-reply/reply/reply-elevated.test.ts | 85 ++++++++++----------- 1 file changed, 41 insertions(+), 44 deletions(-) diff --git a/src/auto-reply/reply/reply-elevated.test.ts b/src/auto-reply/reply/reply-elevated.test.ts index 74fba60acf7..28259c34638 100644 --- a/src/auto-reply/reply/reply-elevated.test.ts +++ b/src/auto-reply/reply/reply-elevated.test.ts @@ -27,68 +27,65 @@ function buildContext(overrides?: Partial): MsgContext { } as MsgContext; } +function expectAllowFromDecision(params: { + allowFrom: string[]; + ctx?: Partial; + allowed: boolean; +}) { + const result = resolveElevatedPermissions({ + cfg: buildConfig(params.allowFrom), + agentId: "main", + provider: "whatsapp", + ctx: buildContext(params.ctx), + }); + + expect(result.enabled).toBe(true); + expect(result.allowed).toBe(params.allowed); + if (params.allowed) { + expect(result.failures).toHaveLength(0); + return; + } + + expect(result.failures).toContainEqual({ + gate: "allowFrom", + key: "tools.elevated.allowFrom.whatsapp", + }); +} + describe("resolveElevatedPermissions", () => { it("authorizes when sender matches allowFrom", () => { - const result = resolveElevatedPermissions({ - cfg: buildConfig(["+15550001111"]), - agentId: "main", - provider: "whatsapp", - ctx: buildContext(), + expectAllowFromDecision({ + allowFrom: ["+15550001111"], + allowed: true, }); - - expect(result.enabled).toBe(true); - expect(result.allowed).toBe(true); - expect(result.failures).toHaveLength(0); }); it("does not authorize when only recipient matches allowFrom", () => { - const result = resolveElevatedPermissions({ - cfg: buildConfig(["+15559990000"]), - agentId: "main", - provider: "whatsapp", - ctx: buildContext(), - }); - - expect(result.enabled).toBe(true); - expect(result.allowed).toBe(false); - expect(result.failures).toContainEqual({ - gate: "allowFrom", - key: "tools.elevated.allowFrom.whatsapp", + expectAllowFromDecision({ + allowFrom: ["+15559990000"], + allowed: false, }); }); it("does not authorize untyped mutable sender fields", () => { - const result = resolveElevatedPermissions({ - cfg: buildConfig(["owner-display-name"]), - agentId: "main", - provider: "whatsapp", - ctx: buildContext({ + expectAllowFromDecision({ + allowFrom: ["owner-display-name"], + allowed: false, + ctx: { SenderName: "owner-display-name", SenderUsername: "owner-display-name", SenderTag: "owner-display-name", - }), - }); - - expect(result.enabled).toBe(true); - expect(result.allowed).toBe(false); - expect(result.failures).toContainEqual({ - gate: "allowFrom", - key: "tools.elevated.allowFrom.whatsapp", + }, }); }); it("authorizes mutable sender fields only with explicit prefix", () => { - const result = resolveElevatedPermissions({ - cfg: buildConfig(["username:owner_username"]), - agentId: "main", - provider: "whatsapp", - ctx: buildContext({ + expectAllowFromDecision({ + allowFrom: ["username:owner_username"], + allowed: true, + ctx: { SenderUsername: "owner_username", - }), + }, }); - - expect(result.enabled).toBe(true); - expect(result.allowed).toBe(true); - expect(result.failures).toHaveLength(0); }); }); From 07b3f5233ea0830820e3c8a8c67df6af1ad28b0e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:17:42 +0000 Subject: [PATCH 150/461] test: dedupe post compaction legacy fallback checks --- .../reply/post-compaction-context.test.ts | 51 +++++++++---------- 1 file changed, 24 insertions(+), 27 deletions(-) diff --git a/src/auto-reply/reply/post-compaction-context.test.ts b/src/auto-reply/reply/post-compaction-context.test.ts index 02a4a27e6de..3af8bceab00 100644 --- a/src/auto-reply/reply/post-compaction-context.test.ts +++ b/src/auto-reply/reply/post-compaction-context.test.ts @@ -15,6 +15,28 @@ describe("readPostCompactionContext", () => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); + async function expectLegacySectionFallback( + postCompactionSections: string[], + expectDefaultProse = false, + ) { + const content = `## Every Session\n\nDo startup things.\n\n## Safety\n\nBe safe.\n`; + fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content); + const cfg = { + agents: { + defaults: { + compaction: { postCompactionSections }, + }, + }, + } as OpenClawConfig; + const result = await readPostCompactionContext(tmpDir, cfg); + expect(result).not.toBeNull(); + expect(result).toContain("Do startup things"); + expect(result).toContain("Be safe"); + if (expectDefaultProse) { + expect(result).toContain("Run your Session Startup sequence"); + } + } + it("returns null when no AGENTS.md exists", async () => { const result = await readPostCompactionContext(tmpDir); expect(result).toBeNull(); @@ -339,36 +361,11 @@ Read WORKFLOW.md on startup. // Older AGENTS.md templates use "Every Session" / "Safety" instead of // "Session Startup" / "Red Lines". Explicitly setting the defaults should // still trigger the legacy fallback — same behavior as leaving the field unset. - const content = `## Every Session\n\nDo startup things.\n\n## Safety\n\nBe safe.\n`; - fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content); - const cfg = { - agents: { - defaults: { - compaction: { postCompactionSections: ["Session Startup", "Red Lines"] }, - }, - }, - } as OpenClawConfig; - const result = await readPostCompactionContext(tmpDir, cfg); - expect(result).not.toBeNull(); - expect(result).toContain("Do startup things"); - expect(result).toContain("Be safe"); + await expectLegacySectionFallback(["Session Startup", "Red Lines"]); }); it("falls back to legacy sections when default sections are configured in a different order", async () => { - const content = `## Every Session\n\nDo startup things.\n\n## Safety\n\nBe safe.\n`; - fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content); - const cfg = { - agents: { - defaults: { - compaction: { postCompactionSections: ["Red Lines", "Session Startup"] }, - }, - }, - } as OpenClawConfig; - const result = await readPostCompactionContext(tmpDir, cfg); - expect(result).not.toBeNull(); - expect(result).toContain("Do startup things"); - expect(result).toContain("Be safe"); - expect(result).toContain("Run your Session Startup sequence"); + await expectLegacySectionFallback(["Red Lines", "Session Startup"], true); }); it("custom section names are matched case-insensitively", async () => { From aaea0b2f28c2cbefc655c35a7047acf3a190bc04 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:18:35 +0000 Subject: [PATCH 151/461] test: dedupe directive auth ref label setup --- .../reply/directive-handling.auth.test.ts | 89 ++++++++++--------- 1 file changed, 45 insertions(+), 44 deletions(-) diff --git a/src/auto-reply/reply/directive-handling.auth.test.ts b/src/auto-reply/reply/directive-handling.auth.test.ts index 4faad0c3ee6..5e1248c8a61 100644 --- a/src/auto-reply/reply/directive-handling.auth.test.ts +++ b/src/auto-reply/reply/directive-handling.auth.test.ts @@ -4,6 +4,11 @@ import type { OpenClawConfig } from "../../config/config.js"; let mockStore: AuthProfileStore; let mockOrder: string[]; +const githubCopilotTokenRefProfile: AuthProfileStore["profiles"][string] = { + type: "token", + provider: "github-copilot", + tokenRef: { source: "env", provider: "default", id: "GITHUB_TOKEN" }, +}; vi.mock("../../agents/auth-health.js", () => ({ formatRemainingShort: () => "1h", @@ -39,6 +44,28 @@ vi.mock("../../agents/model-auth.js", () => ({ const { resolveAuthLabel } = await import("./directive-handling.auth.js"); +async function resolveRefOnlyAuthLabel(params: { + provider: string; + profileId: string; + profile: + | (AuthProfileStore["profiles"][string] & { type: "api_key" }) + | (AuthProfileStore["profiles"][string] & { type: "token" }); + mode: "compact" | "verbose"; +}) { + mockStore.profiles = { + [params.profileId]: params.profile, + }; + mockOrder = [params.profileId]; + + return resolveAuthLabel( + params.provider, + {} as OpenClawConfig, + "/tmp/models.json", + undefined, + params.mode, + ); +} + describe("resolveAuthLabel ref-aware labels", () => { beforeEach(() => { mockStore = { @@ -49,64 +76,38 @@ describe("resolveAuthLabel ref-aware labels", () => { }); it("shows api-key (ref) for keyRef-only profiles in compact mode", async () => { - mockStore.profiles = { - "openai:default": { + const result = await resolveRefOnlyAuthLabel({ + provider: "openai", + profileId: "openai:default", + profile: { type: "api_key", provider: "openai", keyRef: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, }, - }; - mockOrder = ["openai:default"]; - - const result = await resolveAuthLabel( - "openai", - {} as OpenClawConfig, - "/tmp/models.json", - undefined, - "compact", - ); + mode: "compact", + }); expect(result.label).toBe("openai:default api-key (ref)"); }); it("shows token (ref) for tokenRef-only profiles in compact mode", async () => { - mockStore.profiles = { - "github-copilot:default": { - type: "token", - provider: "github-copilot", - tokenRef: { source: "env", provider: "default", id: "GITHUB_TOKEN" }, - }, - }; - mockOrder = ["github-copilot:default"]; - - const result = await resolveAuthLabel( - "github-copilot", - {} as OpenClawConfig, - "/tmp/models.json", - undefined, - "compact", - ); + const result = await resolveRefOnlyAuthLabel({ + provider: "github-copilot", + profileId: "github-copilot:default", + profile: githubCopilotTokenRefProfile, + mode: "compact", + }); expect(result.label).toBe("github-copilot:default token (ref)"); }); it("uses token:ref instead of token:missing in verbose mode", async () => { - mockStore.profiles = { - "github-copilot:default": { - type: "token", - provider: "github-copilot", - tokenRef: { source: "env", provider: "default", id: "GITHUB_TOKEN" }, - }, - }; - mockOrder = ["github-copilot:default"]; - - const result = await resolveAuthLabel( - "github-copilot", - {} as OpenClawConfig, - "/tmp/models.json", - undefined, - "verbose", - ); + const result = await resolveRefOnlyAuthLabel({ + provider: "github-copilot", + profileId: "github-copilot:default", + profile: githubCopilotTokenRefProfile, + mode: "verbose", + }); expect(result.label).toContain("github-copilot:default=token:ref"); expect(result.label).not.toContain("token:missing"); From bd758bb43842153f58d47b537633430929be2a54 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:19:25 +0000 Subject: [PATCH 152/461] refactor: share abort target apply params --- .../reply/commands-session-abort.ts | 41 +++++++++---------- 1 file changed, 19 insertions(+), 22 deletions(-) diff --git a/src/auto-reply/reply/commands-session-abort.ts b/src/auto-reply/reply/commands-session-abort.ts index e8abdb845d6..2991ede75cd 100644 --- a/src/auto-reply/reply/commands-session-abort.ts +++ b/src/auto-reply/reply/commands-session-abort.ts @@ -86,6 +86,23 @@ async function applyAbortTarget(params: { } } +function buildAbortTargetApplyParams( + params: Parameters[0], + abortTarget: AbortTarget, +) { + return { + abortTarget, + sessionStore: params.sessionStore, + storePath: params.storePath, + abortKey: params.command.abortKey, + abortCutoff: resolveAbortCutoffForTarget({ + ctx: params.ctx, + commandSessionKey: params.sessionKey, + targetSessionKey: abortTarget.key, + }), + }; +} + export const handleStopCommand: CommandHandler = async (params, allowTextCommands) => { if (!allowTextCommands) { return null; @@ -109,17 +126,7 @@ export const handleStopCommand: CommandHandler = async (params, allowTextCommand `stop: cleared followups=${cleared.followupCleared} lane=${cleared.laneCleared} keys=${cleared.keys.join(",")}`, ); } - await applyAbortTarget({ - abortTarget, - sessionStore: params.sessionStore, - storePath: params.storePath, - abortKey: params.command.abortKey, - abortCutoff: resolveAbortCutoffForTarget({ - ctx: params.ctx, - commandSessionKey: params.sessionKey, - targetSessionKey: abortTarget.key, - }), - }); + await applyAbortTarget(buildAbortTargetApplyParams(params, abortTarget)); // Trigger internal hook for stop command const hookEvent = createInternalHookEvent( @@ -160,16 +167,6 @@ export const handleAbortTrigger: CommandHandler = async (params, allowTextComman sessionEntry: params.sessionEntry, sessionStore: params.sessionStore, }); - await applyAbortTarget({ - abortTarget, - sessionStore: params.sessionStore, - storePath: params.storePath, - abortKey: params.command.abortKey, - abortCutoff: resolveAbortCutoffForTarget({ - ctx: params.ctx, - commandSessionKey: params.sessionKey, - targetSessionKey: abortTarget.key, - }), - }); + await applyAbortTarget(buildAbortTargetApplyParams(params, abortTarget)); return { shouldContinue: false, reply: { text: "⚙️ Agent was aborted." } }; }; From da51e40638f266a376227a1a8986272bca3b83b7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:19:57 +0000 Subject: [PATCH 153/461] refactor: share auth label suffix formatting --- .../reply/directive-handling.auth.ts | 60 +++++++++---------- 1 file changed, 28 insertions(+), 32 deletions(-) diff --git a/src/auto-reply/reply/directive-handling.auth.ts b/src/auto-reply/reply/directive-handling.auth.ts index 26647d77c68..604e7473ae8 100644 --- a/src/auto-reply/reply/directive-handling.auth.ts +++ b/src/auto-reply/reply/directive-handling.auth.ts @@ -33,6 +33,22 @@ function resolveStoredCredentialLabel(params: { return "missing"; } +function formatExpirationLabel( + expires: unknown, + now: number, + formatUntil: (timestampMs: number) => string, + compactExpiredPrefix = " expired", +) { + if (typeof expires !== "number" || !Number.isFinite(expires) || expires <= 0) { + return ""; + } + return expires <= now ? compactExpiredPrefix : ` exp ${formatUntil(expires)}`; +} + +function formatFlagsSuffix(flags: string[]) { + return flags.length > 0 ? ` (${flags.join(", ")})` : ""; +} + export const resolveAuthLabel = async ( provider: string, cfg: OpenClawConfig, @@ -89,14 +105,7 @@ export const resolveAuthLabel = async ( refValue: profile.tokenRef, mode, }); - const exp = - typeof profile.expires === "number" && - Number.isFinite(profile.expires) && - profile.expires > 0 - ? profile.expires <= now - ? " expired" - : ` exp ${formatUntil(profile.expires)}` - : ""; + const exp = formatExpirationLabel(profile.expires, now, formatUntil); return { label: `${profileId} token ${tokenLabel}${exp}${more}`, source: "", @@ -104,14 +113,7 @@ export const resolveAuthLabel = async ( } const display = resolveAuthProfileDisplayLabel({ cfg, store, profileId }); const label = display === profileId ? profileId : display; - const exp = - typeof profile.expires === "number" && - Number.isFinite(profile.expires) && - profile.expires > 0 - ? profile.expires <= now - ? " expired" - : ` exp ${formatUntil(profile.expires)}` - : ""; + const exp = formatExpirationLabel(profile.expires, now, formatUntil); return { label: `${label} oauth${exp}${more}`, source: "" }; } @@ -140,7 +142,7 @@ export const resolveAuthLabel = async ( configProfile.mode !== profile.type && !(configProfile.mode === "oauth" && profile.type === "token")) ) { - const suffix = flags.length > 0 ? ` (${flags.join(", ")})` : ""; + const suffix = formatFlagsSuffix(flags); return `${profileId}=missing${suffix}`; } if (profile.type === "api_key") { @@ -149,7 +151,7 @@ export const resolveAuthLabel = async ( refValue: profile.keyRef, mode, }); - const suffix = flags.length > 0 ? ` (${flags.join(", ")})` : ""; + const suffix = formatFlagsSuffix(flags); return `${profileId}=${keyLabel}${suffix}`; } if (profile.type === "token") { @@ -158,14 +160,11 @@ export const resolveAuthLabel = async ( refValue: profile.tokenRef, mode, }); - if ( - typeof profile.expires === "number" && - Number.isFinite(profile.expires) && - profile.expires > 0 - ) { - flags.push(profile.expires <= now ? "expired" : `exp ${formatUntil(profile.expires)}`); + const expirationFlag = formatExpirationLabel(profile.expires, now, formatUntil, "expired"); + if (expirationFlag) { + flags.push(expirationFlag); } - const suffix = flags.length > 0 ? ` (${flags.join(", ")})` : ""; + const suffix = formatFlagsSuffix(flags); return `${profileId}=token:${tokenLabel}${suffix}`; } const display = resolveAuthProfileDisplayLabel({ @@ -179,15 +178,12 @@ export const resolveAuthLabel = async ( : display.startsWith(profileId) ? display.slice(profileId.length).trim() : `(${display})`; - if ( - typeof profile.expires === "number" && - Number.isFinite(profile.expires) && - profile.expires > 0 - ) { - flags.push(profile.expires <= now ? "expired" : `exp ${formatUntil(profile.expires)}`); + const expirationFlag = formatExpirationLabel(profile.expires, now, formatUntil, "expired"); + if (expirationFlag) { + flags.push(expirationFlag); } const suffixLabel = suffix ? ` ${suffix}` : ""; - const suffixFlags = flags.length > 0 ? ` (${flags.join(", ")})` : ""; + const suffixFlags = formatFlagsSuffix(flags); return `${profileId}=OAuth${suffixLabel}${suffixFlags}`; }); return { From 0f9e16ca463b6efaf89a73279d1a83e05b4f7c9d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:20:56 +0000 Subject: [PATCH 154/461] refactor: share provider chunk context resolution --- src/auto-reply/reply/block-streaming.ts | 54 ++++++++++++------------- 1 file changed, 25 insertions(+), 29 deletions(-) diff --git a/src/auto-reply/reply/block-streaming.ts b/src/auto-reply/reply/block-streaming.ts index 6d306b166c1..b24ee8cac1a 100644 --- a/src/auto-reply/reply/block-streaming.ts +++ b/src/auto-reply/reply/block-streaming.ts @@ -26,6 +26,22 @@ function normalizeChunkProvider(provider?: string): TextChunkProvider | undefine : undefined; } +function resolveProviderChunkContext( + cfg: OpenClawConfig | undefined, + provider?: string, + accountId?: string | null, +) { + const providerKey = normalizeChunkProvider(provider); + const providerId = providerKey ? normalizeChannelId(providerKey) : null; + const providerChunkLimit = providerId + ? getChannelDock(providerId)?.outbound?.textChunkLimit + : undefined; + const textLimit = resolveTextChunkLimit(cfg, providerKey, accountId, { + fallbackLimit: providerChunkLimit, + }); + return { providerKey, providerId, textLimit }; +} + type ProviderBlockStreamingConfig = { blockStreamingCoalesce?: BlockStreamingCoalesceConfig; accounts?: Record; @@ -97,14 +113,7 @@ export function resolveEffectiveBlockStreamingConfig(params: { chunking: BlockStreamingChunking; coalescing: BlockStreamingCoalescing; } { - const providerKey = normalizeChunkProvider(params.provider); - const providerId = providerKey ? normalizeChannelId(providerKey) : null; - const providerChunkLimit = providerId - ? getChannelDock(providerId)?.outbound?.textChunkLimit - : undefined; - const textLimit = resolveTextChunkLimit(params.cfg, providerKey, params.accountId, { - fallbackLimit: providerChunkLimit, - }); + const { textLimit } = resolveProviderChunkContext(params.cfg, params.provider, params.accountId); const chunkingDefaults = params.chunking ?? resolveBlockStreamingChunking(params.cfg, params.provider, params.accountId); const chunkingMax = clampPositiveInteger(params.maxChunkChars, chunkingDefaults.maxChars, { @@ -154,21 +163,13 @@ export function resolveBlockStreamingChunking( provider?: string, accountId?: string | null, ): BlockStreamingChunking { - const providerKey = normalizeChunkProvider(provider); - const providerConfigKey = providerKey; - const providerId = providerKey ? normalizeChannelId(providerKey) : null; - const providerChunkLimit = providerId - ? getChannelDock(providerId)?.outbound?.textChunkLimit - : undefined; - const textLimit = resolveTextChunkLimit(cfg, providerConfigKey, accountId, { - fallbackLimit: providerChunkLimit, - }); + const { providerKey, textLimit } = resolveProviderChunkContext(cfg, provider, accountId); const chunkCfg = cfg?.agents?.defaults?.blockStreamingChunk; // When chunkMode="newline", the outbound delivery splits on paragraph boundaries. // The block chunker should flush eagerly on \n\n boundaries during streaming, // regardless of minChars, so each paragraph is sent as its own message. - const chunkMode = resolveChunkMode(cfg, providerConfigKey, accountId); + const chunkMode = resolveChunkMode(cfg, providerKey, accountId); const maxRequested = Math.max(1, Math.floor(chunkCfg?.maxChars ?? DEFAULT_BLOCK_STREAM_MAX)); const maxChars = Math.max(1, Math.min(maxRequested, textLimit)); @@ -198,20 +199,15 @@ export function resolveBlockStreamingCoalescing( }, opts?: { chunkMode?: "length" | "newline" }, ): BlockStreamingCoalescing | undefined { - const providerKey = normalizeChunkProvider(provider); - const providerConfigKey = providerKey; + const { providerKey, providerId, textLimit } = resolveProviderChunkContext( + cfg, + provider, + accountId, + ); // Resolve the outbound chunkMode so the coalescer can flush on paragraph boundaries // when chunkMode="newline", matching the delivery-time splitting behavior. - const chunkMode = opts?.chunkMode ?? resolveChunkMode(cfg, providerConfigKey, accountId); - - const providerId = providerKey ? normalizeChannelId(providerKey) : null; - const providerChunkLimit = providerId - ? getChannelDock(providerId)?.outbound?.textChunkLimit - : undefined; - const textLimit = resolveTextChunkLimit(cfg, providerConfigKey, accountId, { - fallbackLimit: providerChunkLimit, - }); + const chunkMode = opts?.chunkMode ?? resolveChunkMode(cfg, providerKey, accountId); const providerDefaults = providerId ? getChannelDock(providerId)?.streaming?.blockStreamingCoalesceDefaults : undefined; From 301594b448df8dc0aee1b6a0bf301a2c5a2d7639 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:21:43 +0000 Subject: [PATCH 155/461] refactor: share discord auto thread params --- src/discord/monitor/threading.ts | 55 ++++++++++++++++---------------- 1 file changed, 27 insertions(+), 28 deletions(-) diff --git a/src/discord/monitor/threading.ts b/src/discord/monitor/threading.ts index 28897e9b7aa..7fc96225330 100644 --- a/src/discord/monitor/threading.ts +++ b/src/discord/monitor/threading.ts @@ -89,6 +89,18 @@ function isDiscordThreadType(type: ChannelType | undefined): boolean { ); } +function resolveTrimmedDiscordMessageChannelId(params: { + message: DiscordMessageEvent["message"]; + messageChannelId?: string; +}) { + return ( + params.messageChannelId || + resolveDiscordMessageChannelId({ + message: params.message, + }) + ).trim(); +} + export function resolveDiscordThreadChannel(params: { isGuildMessage: boolean; message: DiscordMessageEvent["message"]; @@ -301,7 +313,7 @@ export type DiscordAutoThreadReplyPlan = DiscordReplyDeliveryPlan & { autoThreadContext: DiscordAutoThreadContext | null; }; -export async function resolveDiscordAutoThreadReplyPlan(params: { +type MaybeCreateDiscordAutoThreadParams = { client: Client; message: DiscordMessageEvent["message"]; messageChannelId?: string; @@ -311,16 +323,16 @@ export async function resolveDiscordAutoThreadReplyPlan(params: { channelType?: ChannelType; baseText: string; combinedBody: string; - replyToMode: ReplyToMode; - agentId: string; - channel: string; -}): Promise { - const messageChannelId = ( - params.messageChannelId || - resolveDiscordMessageChannelId({ - message: params.message, - }) - ).trim(); +}; + +export async function resolveDiscordAutoThreadReplyPlan( + params: MaybeCreateDiscordAutoThreadParams & { + replyToMode: ReplyToMode; + agentId: string; + channel: string; + }, +): Promise { + const messageChannelId = resolveTrimmedDiscordMessageChannelId(params); // Prefer the resolved thread channel ID when available so replies stay in-thread. const targetChannelId = params.threadChannel?.id ?? (messageChannelId || "unknown"); const originalReplyTarget = `channel:${targetChannelId}`; @@ -353,17 +365,9 @@ export async function resolveDiscordAutoThreadReplyPlan(params: { return { ...deliveryPlan, createdThreadId, autoThreadContext }; } -export async function maybeCreateDiscordAutoThread(params: { - client: Client; - message: DiscordMessageEvent["message"]; - messageChannelId?: string; - isGuildMessage: boolean; - channelConfig?: DiscordChannelConfigResolved | null; - threadChannel?: DiscordThreadChannel | null; - channelType?: ChannelType; - baseText: string; - combinedBody: string; -}): Promise { +export async function maybeCreateDiscordAutoThread( + params: MaybeCreateDiscordAutoThreadParams, +): Promise { if (!params.isGuildMessage) { return undefined; } @@ -383,12 +387,7 @@ export async function maybeCreateDiscordAutoThread(params: { return undefined; } - const messageChannelId = ( - params.messageChannelId || - resolveDiscordMessageChannelId({ - message: params.message, - }) - ).trim(); + const messageChannelId = resolveTrimmedDiscordMessageChannelId(params); if (!messageChannelId) { return undefined; } From 97ce1503fd7ce34bd88e9174f65f01a6f49b576a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:22:14 +0000 Subject: [PATCH 156/461] refactor: share discord binding update loop --- .../monitor/thread-bindings.lifecycle.ts | 80 ++++++++----------- 1 file changed, 35 insertions(+), 45 deletions(-) diff --git a/src/discord/monitor/thread-bindings.lifecycle.ts b/src/discord/monitor/thread-bindings.lifecycle.ts index 256ab5e249c..faf5603c48d 100644 --- a/src/discord/monitor/thread-bindings.lifecycle.ts +++ b/src/discord/monitor/thread-bindings.lifecycle.ts @@ -98,6 +98,30 @@ function resolveBindingIdsForTargetSession(params: { }); } +function updateBindingsForTargetSession( + ids: string[], + update: (existing: ThreadBindingRecord, now: number) => ThreadBindingRecord, +) { + if (ids.length === 0) { + return []; + } + const now = Date.now(); + const updated: ThreadBindingRecord[] = []; + for (const bindingKey of ids) { + const existing = BINDINGS_BY_THREAD_ID.get(bindingKey); + if (!existing) { + continue; + } + const nextRecord = update(existing, now); + setBindingRecord(nextRecord); + updated.push(nextRecord); + } + if (updated.length > 0 && shouldPersistBindingMutations()) { + saveBindingsToDisk({ force: true }); + } + return updated; +} + export function listThreadBindingsForAccount(accountId?: string): ThreadBindingRecord[] { const manager = getThreadBindingManager(accountId); if (!manager) { @@ -249,29 +273,12 @@ export function setThreadBindingIdleTimeoutBySessionKey(params: { idleTimeoutMs: number; }): ThreadBindingRecord[] { const ids = resolveBindingIdsForTargetSession(params); - if (ids.length === 0) { - return []; - } const idleTimeoutMs = normalizeNonNegativeMs(params.idleTimeoutMs); - const now = Date.now(); - const updated: ThreadBindingRecord[] = []; - for (const bindingKey of ids) { - const existing = BINDINGS_BY_THREAD_ID.get(bindingKey); - if (!existing) { - continue; - } - const nextRecord: ThreadBindingRecord = { - ...existing, - idleTimeoutMs, - lastActivityAt: now, - }; - setBindingRecord(nextRecord); - updated.push(nextRecord); - } - if (updated.length > 0 && shouldPersistBindingMutations()) { - saveBindingsToDisk({ force: true }); - } - return updated; + return updateBindingsForTargetSession(ids, (existing, now) => ({ + ...existing, + idleTimeoutMs, + lastActivityAt: now, + })); } export function setThreadBindingMaxAgeBySessionKey(params: { @@ -280,30 +287,13 @@ export function setThreadBindingMaxAgeBySessionKey(params: { maxAgeMs: number; }): ThreadBindingRecord[] { const ids = resolveBindingIdsForTargetSession(params); - if (ids.length === 0) { - return []; - } const maxAgeMs = normalizeNonNegativeMs(params.maxAgeMs); - const now = Date.now(); - const updated: ThreadBindingRecord[] = []; - for (const bindingKey of ids) { - const existing = BINDINGS_BY_THREAD_ID.get(bindingKey); - if (!existing) { - continue; - } - const nextRecord: ThreadBindingRecord = { - ...existing, - maxAgeMs, - boundAt: now, - lastActivityAt: now, - }; - setBindingRecord(nextRecord); - updated.push(nextRecord); - } - if (updated.length > 0 && shouldPersistBindingMutations()) { - saveBindingsToDisk({ force: true }); - } - return updated; + return updateBindingsForTargetSession(ids, (existing, now) => ({ + ...existing, + maxAgeMs, + boundAt: now, + lastActivityAt: now, + })); } function resolveStoredAcpBindingHealth(params: { From 1b91fa9358115eec19aeb5c8f191a11b6cfa8ac7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:23:02 +0000 Subject: [PATCH 157/461] test: dedupe discord route fixture setup --- src/discord/monitor/route-resolution.test.ts | 55 +++++++++----------- 1 file changed, 24 insertions(+), 31 deletions(-) diff --git a/src/discord/monitor/route-resolution.test.ts b/src/discord/monitor/route-resolution.test.ts index d9ec90177bd..3518355165b 100644 --- a/src/discord/monitor/route-resolution.test.ts +++ b/src/discord/monitor/route-resolution.test.ts @@ -2,12 +2,33 @@ import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; import type { ResolvedAgentRoute } from "../../routing/resolve-route.js"; import { - resolveDiscordBoundConversationRoute, buildDiscordRoutePeer, + resolveDiscordBoundConversationRoute, resolveDiscordConversationRoute, resolveDiscordEffectiveRoute, } from "./route-resolution.js"; +function buildWorkerBindingConfig(peer: { + kind: "channel" | "direct"; + id: string; +}): OpenClawConfig { + return { + agents: { + list: [{ id: "worker" }], + }, + bindings: [ + { + agentId: "worker", + match: { + channel: "discord", + accountId: "default", + peer, + }, + }, + ], + }; +} + describe("discord route resolution helpers", () => { it("builds a direct peer from DM metadata", () => { expect( @@ -78,21 +99,7 @@ describe("discord route resolution helpers", () => { }); it("resolves the same route shape as the inline Discord route inputs", () => { - const cfg: OpenClawConfig = { - agents: { - list: [{ id: "worker" }], - }, - bindings: [ - { - agentId: "worker", - match: { - channel: "discord", - accountId: "default", - peer: { kind: "channel", id: "c1" }, - }, - }, - ], - }; + const cfg = buildWorkerBindingConfig({ kind: "channel", id: "c1" }); expect( resolveDiscordConversationRoute({ @@ -110,21 +117,7 @@ describe("discord route resolution helpers", () => { }); it("composes route building with effective-route overrides", () => { - const cfg: OpenClawConfig = { - agents: { - list: [{ id: "worker" }], - }, - bindings: [ - { - agentId: "worker", - match: { - channel: "discord", - accountId: "default", - peer: { kind: "direct", id: "user-1" }, - }, - }, - ], - }; + const cfg = buildWorkerBindingConfig({ kind: "direct", id: "user-1" }); expect( resolveDiscordBoundConversationRoute({ From c59ae1527cae86dfc0aa36f7305e5bbb025edb4b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:24:03 +0000 Subject: [PATCH 158/461] refactor: share discord trailing media delivery --- src/discord/monitor/reply-delivery.ts | 36 +++++++++++---------------- 1 file changed, 14 insertions(+), 22 deletions(-) diff --git a/src/discord/monitor/reply-delivery.ts b/src/discord/monitor/reply-delivery.ts index fb235ca65d0..d34381454e9 100644 --- a/src/discord/monitor/reply-delivery.ts +++ b/src/discord/monitor/reply-delivery.ts @@ -336,6 +336,18 @@ export async function deliverDiscordReply(params: { if (!firstMedia) { continue; } + const sendRemainingMedia = () => + sendAdditionalDiscordMedia({ + cfg: params.cfg, + target: params.target, + token: params.token, + rest: params.rest, + accountId: params.accountId, + mediaUrls: mediaList.slice(1), + mediaLocalRoots: params.mediaLocalRoots, + resolveReplyTo, + retryConfig, + }); // Voice message path: audioAsVoice flag routes through sendVoiceMessageDiscord. if (payload.audioAsVoice) { @@ -367,17 +379,7 @@ export async function deliverDiscordReply(params: { retryConfig, }); // Additional media items are sent as regular attachments (voice is single-file only). - await sendAdditionalDiscordMedia({ - cfg: params.cfg, - target: params.target, - token: params.token, - rest: params.rest, - accountId: params.accountId, - mediaUrls: mediaList.slice(1), - mediaLocalRoots: params.mediaLocalRoots, - resolveReplyTo, - retryConfig, - }); + await sendRemainingMedia(); continue; } @@ -392,17 +394,7 @@ export async function deliverDiscordReply(params: { replyTo, }); deliveredAny = true; - await sendAdditionalDiscordMedia({ - cfg: params.cfg, - target: params.target, - token: params.token, - rest: params.rest, - accountId: params.accountId, - mediaUrls: mediaList.slice(1), - mediaLocalRoots: params.mediaLocalRoots, - resolveReplyTo, - retryConfig, - }); + await sendRemainingMedia(); } if (binding && deliveredAny) { From 8cd48c289660e801488c42f84766e08b92cc502a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:24:54 +0000 Subject: [PATCH 159/461] test: dedupe model info reply setup --- .../reply/directive-handling.model.test.ts | 48 ++++++++----------- 1 file changed, 21 insertions(+), 27 deletions(-) diff --git a/src/auto-reply/reply/directive-handling.model.test.ts b/src/auto-reply/reply/directive-handling.model.test.ts index 5d4a23f3efb..b815ecfc9b9 100644 --- a/src/auto-reply/reply/directive-handling.model.test.ts +++ b/src/auto-reply/reply/directive-handling.model.test.ts @@ -57,24 +57,28 @@ function resolveModelSelectionForCommand(params: { }); } +async function resolveModelInfoReply( + overrides: Partial[0]> = {}, +) { + return maybeHandleModelDirectiveInfo({ + directives: parseInlineDirectives("/model"), + cfg: baseConfig(), + agentDir: "/tmp/agent", + activeAgentId: "main", + provider: "anthropic", + model: "claude-opus-4-5", + defaultProvider: "anthropic", + defaultModel: "claude-opus-4-5", + aliasIndex: baseAliasIndex(), + allowedModelCatalog: [], + resetModelOverride: false, + ...overrides, + }); +} + describe("/model chat UX", () => { it("shows summary for /model with no args", async () => { - const directives = parseInlineDirectives("/model"); - const cfg = { commands: { text: true } } as unknown as OpenClawConfig; - - const reply = await maybeHandleModelDirectiveInfo({ - directives, - cfg, - agentDir: "/tmp/agent", - activeAgentId: "main", - provider: "anthropic", - model: "claude-opus-4-5", - defaultProvider: "anthropic", - defaultModel: "claude-opus-4-5", - aliasIndex: baseAliasIndex(), - allowedModelCatalog: [], - resetModelOverride: false, - }); + const reply = await resolveModelInfoReply(); expect(reply?.text).toContain("Current:"); expect(reply?.text).toContain("Browse: /models"); @@ -82,21 +86,11 @@ describe("/model chat UX", () => { }); it("shows active runtime model when different from selected model", async () => { - const directives = parseInlineDirectives("/model"); - const cfg = { commands: { text: true } } as unknown as OpenClawConfig; - - const reply = await maybeHandleModelDirectiveInfo({ - directives, - cfg, - agentDir: "/tmp/agent", - activeAgentId: "main", + const reply = await resolveModelInfoReply({ provider: "fireworks", model: "fireworks/minimax-m2p5", defaultProvider: "fireworks", defaultModel: "fireworks/minimax-m2p5", - aliasIndex: baseAliasIndex(), - allowedModelCatalog: [], - resetModelOverride: false, sessionEntry: { modelProvider: "deepinfra", model: "moonshotai/Kimi-K2.5", From cad1c95405c47a98a5d55cdca1229e1f78308fe6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:26:03 +0000 Subject: [PATCH 160/461] test: dedupe inline action skip assertions --- ...ine-actions.skip-when-config-empty.test.ts | 63 ++++++++++--------- 1 file changed, 32 insertions(+), 31 deletions(-) diff --git a/src/auto-reply/reply/get-reply-inline-actions.skip-when-config-empty.test.ts b/src/auto-reply/reply/get-reply-inline-actions.skip-when-config-empty.test.ts index 51351f05de8..36b5910ecae 100644 --- a/src/auto-reply/reply/get-reply-inline-actions.skip-when-config-empty.test.ts +++ b/src/auto-reply/reply/get-reply-inline-actions.skip-when-config-empty.test.ts @@ -84,6 +84,19 @@ const createHandleInlineActionsInput = (params: { }; }; +async function expectInlineActionSkipped(params: { + ctx: ReturnType; + typing: TypingController; + cleanedBody: string; + command?: Partial; + overrides?: Partial>; +}) { + const result = await handleInlineActions(createHandleInlineActionsInput(params)); + expect(result).toEqual({ kind: "reply", reply: undefined }); + expect(params.typing.cleanup).toHaveBeenCalled(); + expect(handleCommandsMock).not.toHaveBeenCalled(); +} + describe("handleInlineActions", () => { beforeEach(() => { handleCommandsMock.mockReset(); @@ -97,18 +110,12 @@ describe("handleInlineActions", () => { To: "whatsapp:+123", Body: "hi", }); - const result = await handleInlineActions( - createHandleInlineActionsInput({ - ctx, - typing, - cleanedBody: "hi", - command: { to: "whatsapp:+123" }, - }), - ); - - expect(result).toEqual({ kind: "reply", reply: undefined }); - expect(typing.cleanup).toHaveBeenCalled(); - expect(handleCommandsMock).not.toHaveBeenCalled(); + await expectInlineActionSkipped({ + ctx, + typing, + cleanedBody: "hi", + command: { to: "whatsapp:+123" }, + }); }); it("forwards agentDir into handleCommands", async () => { @@ -163,25 +170,19 @@ describe("handleInlineActions", () => { MessageSid: "41", }); - const result = await handleInlineActions( - createHandleInlineActionsInput({ - ctx, - typing, - cleanedBody: "old queued message", - command: { - rawBodyNormalized: "old queued message", - commandBodyNormalized: "old queued message", - }, - overrides: { - sessionEntry, - sessionStore, - }, - }), - ); - - expect(result).toEqual({ kind: "reply", reply: undefined }); - expect(typing.cleanup).toHaveBeenCalled(); - expect(handleCommandsMock).not.toHaveBeenCalled(); + await expectInlineActionSkipped({ + ctx, + typing, + cleanedBody: "old queued message", + command: { + rawBodyNormalized: "old queued message", + commandBodyNormalized: "old queued message", + }, + overrides: { + sessionEntry, + sessionStore, + }, + }); }); it("clears /stop cutoff when a newer message arrives", async () => { From 3eb039c554c0ba7057be243a907d1951af0a794c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:26:24 +0000 Subject: [PATCH 161/461] test: dedupe discord forwarded media assertions --- src/discord/monitor/message-utils.test.ts | 27 +++++------------------ 1 file changed, 5 insertions(+), 22 deletions(-) diff --git a/src/discord/monitor/message-utils.test.ts b/src/discord/monitor/message-utils.test.ts index f4c5be256c1..acb9708ae21 100644 --- a/src/discord/monitor/message-utils.test.ts +++ b/src/discord/monitor/message-utils.test.ts @@ -173,30 +173,13 @@ describe("resolveForwardedMediaList", () => { 512, ); - expect(fetchRemoteMedia).toHaveBeenCalledTimes(1); - const call = fetchRemoteMedia.mock.calls[0]?.[0] as { - url?: string; - filePathHint?: string; - maxBytes?: number; - fetchImpl?: unknown; - ssrfPolicy?: unknown; - }; - expect(call).toMatchObject({ - url: attachment.url, + expectSinglePngDownload({ + result, + expectedUrl: attachment.url, filePathHint: attachment.filename, - maxBytes: 512, - fetchImpl: undefined, + expectedPath: "/tmp/image.png", + placeholder: "", }); - expectDiscordCdnSsrFPolicy(call.ssrfPolicy); - expect(saveMediaBuffer).toHaveBeenCalledTimes(1); - expect(saveMediaBuffer).toHaveBeenCalledWith(expect.any(Buffer), "image/png", "inbound", 512); - expect(result).toEqual([ - { - path: "/tmp/image.png", - contentType: "image/png", - placeholder: "", - }, - ]); }); it("forwards fetchImpl to forwarded attachment downloads", async () => { From ee80b4be69e122a787e736c5dc0748694e813f71 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:27:33 +0000 Subject: [PATCH 162/461] test: dedupe discord retry delivery setup --- src/discord/monitor/reply-delivery.test.ts | 48 ++++++++-------------- 1 file changed, 18 insertions(+), 30 deletions(-) diff --git a/src/discord/monitor/reply-delivery.test.ts b/src/discord/monitor/reply-delivery.test.ts index 1e0bdc00942..6f6b7fcaaaf 100644 --- a/src/discord/monitor/reply-delivery.test.ts +++ b/src/discord/monitor/reply-delivery.test.ts @@ -27,6 +27,22 @@ describe("deliverDiscordReply", () => { const cfg = { channels: { discord: { token: "test-token" } }, } as OpenClawConfig; + const expectBotSendRetrySuccess = async (status: number, message: string) => { + sendMessageDiscordMock + .mockRejectedValueOnce(Object.assign(new Error(message), { status })) + .mockResolvedValueOnce({ messageId: "msg-1", channelId: "channel-1" }); + + await deliverDiscordReply({ + replies: [{ text: "retry me" }], + target: "channel:123", + token: "token", + runtime, + cfg, + textLimit: 2000, + }); + + expect(sendMessageDiscordMock).toHaveBeenCalledTimes(2); + }; const createBoundThreadBindings = async ( overrides: Partial<{ threadId: string; @@ -319,39 +335,11 @@ describe("deliverDiscordReply", () => { }); it("retries bot send on 429 rate limit then succeeds", async () => { - const rateLimitErr = Object.assign(new Error("rate limited"), { status: 429 }); - sendMessageDiscordMock - .mockRejectedValueOnce(rateLimitErr) - .mockResolvedValueOnce({ messageId: "msg-1", channelId: "channel-1" }); - - await deliverDiscordReply({ - replies: [{ text: "retry me" }], - target: "channel:123", - token: "token", - runtime, - cfg, - textLimit: 2000, - }); - - expect(sendMessageDiscordMock).toHaveBeenCalledTimes(2); + await expectBotSendRetrySuccess(429, "rate limited"); }); it("retries bot send on 500 server error then succeeds", async () => { - const serverErr = Object.assign(new Error("internal"), { status: 500 }); - sendMessageDiscordMock - .mockRejectedValueOnce(serverErr) - .mockResolvedValueOnce({ messageId: "msg-1", channelId: "channel-1" }); - - await deliverDiscordReply({ - replies: [{ text: "retry me" }], - target: "channel:123", - token: "token", - runtime, - cfg, - textLimit: 2000, - }); - - expect(sendMessageDiscordMock).toHaveBeenCalledTimes(2); + await expectBotSendRetrySuccess(500, "internal"); }); it("does not retry on 4xx client errors", async () => { From aed626ed96473f02de386e7fc8b16a2280c0ddc2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:27:43 +0000 Subject: [PATCH 163/461] test: dedupe discord gateway proxy register flow --- src/discord/monitor/provider.proxy.test.ts | 38 ++++++++-------------- 1 file changed, 14 insertions(+), 24 deletions(-) diff --git a/src/discord/monitor/provider.proxy.test.ts b/src/discord/monitor/provider.proxy.test.ts index 9a15dcef94b..72da5136c7a 100644 --- a/src/discord/monitor/provider.proxy.test.ts +++ b/src/discord/monitor/provider.proxy.test.ts @@ -147,6 +147,18 @@ describe("createDiscordGatewayPlugin", () => { expect(baseRegisterClientSpy).not.toHaveBeenCalled(); } + async function registerGatewayClientWithMetadata(params: { + plugin: unknown; + fetchMock: typeof globalFetchMock; + }) { + params.fetchMock.mockResolvedValue({ + ok: true, + status: 200, + text: async () => JSON.stringify({ url: "wss://gateway.discord.gg" }), + } as Response); + await registerGatewayClient(params.plugin); + } + beforeEach(() => { vi.stubGlobal("fetch", globalFetchMock); baseRegisterClientSpy.mockClear(); @@ -161,23 +173,12 @@ describe("createDiscordGatewayPlugin", () => { it("uses safe gateway metadata lookup without proxy", async () => { const runtime = createRuntime(); - globalFetchMock.mockResolvedValue({ - ok: true, - status: 200, - text: async () => JSON.stringify({ url: "wss://gateway.discord.gg" }), - } as Response); const plugin = createDiscordGatewayPlugin({ discordConfig: {}, runtime, }); - await ( - plugin as unknown as { - registerClient: (client: { options: { token: string } }) => Promise; - } - ).registerClient({ - options: { token: "token-123" }, - }); + await registerGatewayClientWithMetadata({ plugin, fetchMock: globalFetchMock }); expect(globalFetchMock).toHaveBeenCalledWith( "https://discord.com/api/v10/gateway/bot", @@ -235,23 +236,12 @@ describe("createDiscordGatewayPlugin", () => { it("uses proxy fetch for gateway metadata lookup before registering", async () => { const runtime = createRuntime(); - undiciFetchMock.mockResolvedValue({ - ok: true, - status: 200, - text: async () => JSON.stringify({ url: "wss://gateway.discord.gg" }), - } as Response); const plugin = createDiscordGatewayPlugin({ discordConfig: { proxy: "http://proxy.test:8080" }, runtime, }); - await ( - plugin as unknown as { - registerClient: (client: { options: { token: string } }) => Promise; - } - ).registerClient({ - options: { token: "token-123" }, - }); + await registerGatewayClientWithMetadata({ plugin, fetchMock: undiciFetchMock }); expect(restProxyAgentSpy).toHaveBeenCalledWith("http://proxy.test:8080"); expect(undiciFetchMock).toHaveBeenCalledWith( From 809785dcd79d712e1847d3e8e7879a587b9b8c4f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:28:23 +0000 Subject: [PATCH 164/461] test: dedupe discord provider account config harness --- src/discord/monitor/provider.test.ts | 92 +++++++++++++--------------- 1 file changed, 43 insertions(+), 49 deletions(-) diff --git a/src/discord/monitor/provider.test.ts b/src/discord/monitor/provider.test.ts index 91f61a7ce1f..8fdab085f53 100644 --- a/src/discord/monitor/provider.test.ts +++ b/src/discord/monitor/provider.test.ts @@ -16,6 +16,15 @@ type PluginCommandSpecMock = { acceptsArgs: boolean; }; +function baseDiscordAccountConfig() { + return { + commands: { native: true, nativeSkills: false }, + voice: { enabled: false }, + agentComponents: { enabled: false }, + execApprovals: { enabled: false }, + }; +} + const { clientFetchUserMock, clientGetPluginMock, @@ -91,12 +100,7 @@ const { resolveDiscordAccountMock: vi.fn(() => ({ accountId: "default", token: "cfg-token", - config: { - commands: { native: true, nativeSkills: false }, - voice: { enabled: false }, - agentComponents: { enabled: false }, - execApprovals: { enabled: false }, - }, + config: baseDiscordAccountConfig(), })), resolveDiscordAllowlistConfigMock: vi.fn(async () => ({ guildEntries: undefined, @@ -108,6 +112,23 @@ const { }; }); +function mockResolvedDiscordAccountConfig(overrides: Record) { + resolveDiscordAccountMock.mockImplementation(() => ({ + accountId: "default", + token: "cfg-token", + config: { + ...baseDiscordAccountConfig(), + ...overrides, + }, + })); +} + +function getFirstDiscordMessageHandlerParams() { + expect(createDiscordMessageHandlerMock).toHaveBeenCalledTimes(1); + const firstCall = createDiscordMessageHandlerMock.mock.calls.at(0) as [T] | undefined; + return firstCall?.[0]; +} + vi.mock("@buape/carbon", () => { class ReadyListener {} class Client { @@ -663,17 +684,9 @@ describe("monitorDiscordProvider", () => { it("forwards custom eventQueue config from discord config to Carbon Client", async () => { const { monitorDiscordProvider } = await import("./provider.js"); - resolveDiscordAccountMock.mockImplementation(() => ({ - accountId: "default", - token: "cfg-token", - config: { - commands: { native: true, nativeSkills: false }, - voice: { enabled: false }, - agentComponents: { enabled: false }, - execApprovals: { enabled: false }, - eventQueue: { listenerTimeout: 300_000 }, - }, - })); + mockResolvedDiscordAccountConfig({ + eventQueue: { listenerTimeout: 300_000 }, + }); await monitorDiscordProvider({ config: baseConfig(), @@ -687,28 +700,19 @@ describe("monitorDiscordProvider", () => { it("does not reuse eventQueue.listenerTimeout as the queued inbound worker timeout", async () => { const { monitorDiscordProvider } = await import("./provider.js"); - resolveDiscordAccountMock.mockImplementation(() => ({ - accountId: "default", - token: "cfg-token", - config: { - commands: { native: true, nativeSkills: false }, - voice: { enabled: false }, - agentComponents: { enabled: false }, - execApprovals: { enabled: false }, - eventQueue: { listenerTimeout: 50_000 }, - }, - })); + mockResolvedDiscordAccountConfig({ + eventQueue: { listenerTimeout: 50_000 }, + }); await monitorDiscordProvider({ config: baseConfig(), runtime: baseRuntime(), }); - expect(createDiscordMessageHandlerMock).toHaveBeenCalledTimes(1); - const firstCall = createDiscordMessageHandlerMock.mock.calls.at(0) as - | [{ workerRunTimeoutMs?: number; listenerTimeoutMs?: number }] - | undefined; - const params = firstCall?.[0]; + const params = getFirstDiscordMessageHandlerParams<{ + workerRunTimeoutMs?: number; + listenerTimeoutMs?: number; + }>(); expect(params?.workerRunTimeoutMs).toBeUndefined(); expect("listenerTimeoutMs" in (params ?? {})).toBe(false); }); @@ -716,28 +720,18 @@ describe("monitorDiscordProvider", () => { it("forwards inbound worker timeout config to the Discord message handler", async () => { const { monitorDiscordProvider } = await import("./provider.js"); - resolveDiscordAccountMock.mockImplementation(() => ({ - accountId: "default", - token: "cfg-token", - config: { - commands: { native: true, nativeSkills: false }, - voice: { enabled: false }, - agentComponents: { enabled: false }, - execApprovals: { enabled: false }, - inboundWorker: { runTimeoutMs: 300_000 }, - }, - })); + mockResolvedDiscordAccountConfig({ + inboundWorker: { runTimeoutMs: 300_000 }, + }); await monitorDiscordProvider({ config: baseConfig(), runtime: baseRuntime(), }); - expect(createDiscordMessageHandlerMock).toHaveBeenCalledTimes(1); - const firstCall = createDiscordMessageHandlerMock.mock.calls.at(0) as - | [{ workerRunTimeoutMs?: number }] - | undefined; - const params = firstCall?.[0]; + const params = getFirstDiscordMessageHandlerParams<{ + workerRunTimeoutMs?: number; + }>(); expect(params?.workerRunTimeoutMs).toBe(300_000); }); From f8ee528174351058d99db609ecdddfe9a833420f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:29:10 +0000 Subject: [PATCH 165/461] refactor: share discord channel override config type --- src/discord/monitor/allow-list.ts | 44 +++++++++++-------------------- 1 file changed, 16 insertions(+), 28 deletions(-) diff --git a/src/discord/monitor/allow-list.ts b/src/discord/monitor/allow-list.ts index 583d4fa7cd2..ef29f1fc706 100644 --- a/src/discord/monitor/allow-list.ts +++ b/src/discord/monitor/allow-list.ts @@ -19,34 +19,7 @@ export type DiscordAllowListMatch = AllowlistMatch<"wildcard" | "id" | "name" | const DISCORD_OWNER_ALLOWLIST_PREFIXES = ["discord:", "user:", "pk:"]; -export type DiscordGuildEntryResolved = { - id?: string; - slug?: string; - requireMention?: boolean; - ignoreOtherMentions?: boolean; - reactionNotifications?: "off" | "own" | "all" | "allowlist"; - users?: string[]; - roles?: string[]; - channels?: Record< - string, - { - allow?: boolean; - requireMention?: boolean; - ignoreOtherMentions?: boolean; - skills?: string[]; - enabled?: boolean; - users?: string[]; - roles?: string[]; - systemPrompt?: string; - includeThreadStarter?: boolean; - autoThread?: boolean; - autoArchiveDuration?: "60" | "1440" | "4320" | "10080" | 60 | 1440 | 4320 | 10080; - } - >; -}; - -export type DiscordChannelConfigResolved = { - allowed: boolean; +type DiscordChannelOverrideConfig = { requireMention?: boolean; ignoreOtherMentions?: boolean; skills?: string[]; @@ -57,6 +30,21 @@ export type DiscordChannelConfigResolved = { includeThreadStarter?: boolean; autoThread?: boolean; autoArchiveDuration?: "60" | "1440" | "4320" | "10080" | 60 | 1440 | 4320 | 10080; +}; + +export type DiscordGuildEntryResolved = { + id?: string; + slug?: string; + requireMention?: boolean; + ignoreOtherMentions?: boolean; + reactionNotifications?: "off" | "own" | "all" | "allowlist"; + users?: string[]; + roles?: string[]; + channels?: Record; +}; + +export type DiscordChannelConfigResolved = DiscordChannelOverrideConfig & { + allowed: boolean; matchKey?: string; matchSource?: ChannelMatchSource; }; From 801113b46aed291cb2c15c4bb387962997a5bc4e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:29:45 +0000 Subject: [PATCH 166/461] refactor: share session entry persistence update --- src/auto-reply/reply/session-updates.ts | 35 ++++++++++++++++--------- 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/src/auto-reply/reply/session-updates.ts b/src/auto-reply/reply/session-updates.ts index 96243e919bb..55b4d4eb15b 100644 --- a/src/auto-reply/reply/session-updates.ts +++ b/src/auto-reply/reply/session-updates.ts @@ -117,6 +117,27 @@ export async function drainFormattedSystemEvents(params: { .join("\n"); } +async function persistSessionEntryUpdate(params: { + sessionStore?: Record; + sessionKey?: string; + storePath?: string; + nextEntry: SessionEntry; +}) { + if (!params.sessionStore || !params.sessionKey) { + return; + } + params.sessionStore[params.sessionKey] = { + ...params.sessionStore[params.sessionKey], + ...params.nextEntry, + }; + if (!params.storePath) { + return; + } + await updateSessionStore(params.storePath, (store) => { + store[params.sessionKey!] = { ...store[params.sessionKey!], ...params.nextEntry }; + }); +} + export async function ensureSkillSnapshot(params: { sessionEntry?: SessionEntry; sessionStore?: Record; @@ -185,12 +206,7 @@ export async function ensureSkillSnapshot(params: { systemSent: true, skillsSnapshot: skillSnapshot, }; - sessionStore[sessionKey] = { ...sessionStore[sessionKey], ...nextEntry }; - if (storePath) { - await updateSessionStore(storePath, (store) => { - store[sessionKey] = { ...store[sessionKey], ...nextEntry }; - }); - } + await persistSessionEntryUpdate({ sessionStore, sessionKey, storePath, nextEntry }); systemSent = true; } @@ -227,12 +243,7 @@ export async function ensureSkillSnapshot(params: { updatedAt: Date.now(), skillsSnapshot, }; - sessionStore[sessionKey] = { ...sessionStore[sessionKey], ...nextEntry }; - if (storePath) { - await updateSessionStore(storePath, (store) => { - store[sessionKey] = { ...store[sessionKey], ...nextEntry }; - }); - } + await persistSessionEntryUpdate({ sessionStore, sessionKey, storePath, nextEntry }); } return { sessionEntry: nextEntry, skillsSnapshot, systemSent }; From 58a51e2746e85e14e0aeb035897aaaa1cbad10a8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:30:01 +0000 Subject: [PATCH 167/461] refactor: share discord preflight shared fields --- .../monitor/message-handler.preflight.types.ts | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/src/discord/monitor/message-handler.preflight.types.ts b/src/discord/monitor/message-handler.preflight.types.ts index a2b3c210a1c..015a695229a 100644 --- a/src/discord/monitor/message-handler.preflight.types.ts +++ b/src/discord/monitor/message-handler.preflight.types.ts @@ -16,7 +16,7 @@ export type RuntimeEnv = import("../../runtime.js").RuntimeEnv; export type DiscordMessageEvent = import("./listeners.js").DiscordMessageEvent; -export type DiscordMessagePreflightContext = { +type DiscordMessagePreflightSharedFields = { cfg: LoadedConfig; discordConfig: NonNullable< import("../../config/config.js").OpenClawConfig["channels"] @@ -33,7 +33,9 @@ export type DiscordMessagePreflightContext = { replyToMode: ReplyToMode; ackReactionScope: "all" | "direct" | "group-all" | "group-mentions" | "off" | "none"; groupPolicy: "open" | "disabled" | "allowlist"; +}; +export type DiscordMessagePreflightContext = DiscordMessagePreflightSharedFields & { data: DiscordMessageEvent; client: Client; message: DiscordMessageEvent["message"]; @@ -89,19 +91,7 @@ export type DiscordMessagePreflightContext = { discordRestFetch?: typeof fetch; }; -export type DiscordMessagePreflightParams = { - cfg: LoadedConfig; - discordConfig: DiscordMessagePreflightContext["discordConfig"]; - accountId: string; - token: string; - runtime: RuntimeEnv; - botUserId?: string; - abortSignal?: AbortSignal; - guildHistories: Map; - historyLimit: number; - mediaMaxBytes: number; - textLimit: number; - replyToMode: ReplyToMode; +export type DiscordMessagePreflightParams = DiscordMessagePreflightSharedFields & { dmEnabled: boolean; groupDmEnabled: boolean; groupDmChannels?: string[]; From f15abb657a0d2402d39728301e000f0aef0105d1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:30:51 +0000 Subject: [PATCH 168/461] test: dedupe discord listener deferred setup --- src/discord/monitor/listeners.test.ts | 44 ++++++++++++--------------- 1 file changed, 20 insertions(+), 24 deletions(-) diff --git a/src/discord/monitor/listeners.test.ts b/src/discord/monitor/listeners.test.ts index 71145396a82..d8158320e44 100644 --- a/src/discord/monitor/listeners.test.ts +++ b/src/discord/monitor/listeners.test.ts @@ -12,6 +12,14 @@ function fakeEvent(channelId: string) { return { channel_id: channelId } as never; } +function createDeferred() { + let resolve: (() => void) | undefined; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} + describe("DiscordMessageListener", () => { it("returns immediately without awaiting handler completion", async () => { let resolveHandler: (() => void) | undefined; @@ -38,23 +46,17 @@ describe("DiscordMessageListener", () => { it("runs handlers for the same channel concurrently (no per-channel serialization)", async () => { const order: string[] = []; - let resolveA: (() => void) | undefined; - let resolveB: (() => void) | undefined; - const doneA = new Promise((r) => { - resolveA = r; - }); - const doneB = new Promise((r) => { - resolveB = r; - }); + const deferredA = createDeferred(); + const deferredB = createDeferred(); let callCount = 0; const handler = vi.fn(async () => { callCount += 1; const id = callCount; order.push(`start:${id}`); if (id === 1) { - await doneA; + await deferredA.promise; } else { - await doneB; + await deferredB.promise; } order.push(`end:${id}`); }); @@ -71,35 +73,29 @@ describe("DiscordMessageListener", () => { expect(order).toContain("start:1"); expect(order).toContain("start:2"); - resolveB?.(); + deferredB.resolve?.(); await vi.waitFor(() => { expect(order).toContain("end:2"); }); // First handler is still running — no serialization. expect(order).not.toContain("end:1"); - resolveA?.(); + deferredA.resolve?.(); await vi.waitFor(() => { expect(order).toContain("end:1"); }); }); it("runs handlers for different channels in parallel", async () => { - let resolveA: (() => void) | undefined; - let resolveB: (() => void) | undefined; - const doneA = new Promise((r) => { - resolveA = r; - }); - const doneB = new Promise((r) => { - resolveB = r; - }); + const deferredA = createDeferred(); + const deferredB = createDeferred(); const order: string[] = []; const handler = vi.fn(async (data: { channel_id: string }) => { order.push(`start:${data.channel_id}`); if (data.channel_id === "ch-a") { - await doneA; + await deferredA.promise; } else { - await doneB; + await deferredB.promise; } order.push(`end:${data.channel_id}`); }); @@ -114,13 +110,13 @@ describe("DiscordMessageListener", () => { expect(order).toContain("start:ch-a"); expect(order).toContain("start:ch-b"); - resolveB?.(); + deferredB.resolve?.(); await vi.waitFor(() => { expect(order).toContain("end:ch-b"); }); expect(order).not.toContain("end:ch-a"); - resolveA?.(); + deferredA.resolve?.(); await vi.waitFor(() => { expect(order).toContain("end:ch-a"); }); From 6cabcf3fd2b545e025f2df00b4a60ce5133b5875 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:31:32 +0000 Subject: [PATCH 169/461] test: dedupe session idle timeout assertions --- .../reply/commands-session-lifecycle.test.ts | 41 ++++++++++++------- 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/src/auto-reply/reply/commands-session-lifecycle.test.ts b/src/auto-reply/reply/commands-session-lifecycle.test.ts index 79882f13921..baf5addc60e 100644 --- a/src/auto-reply/reply/commands-session-lifecycle.test.ts +++ b/src/auto-reply/reply/commands-session-lifecycle.test.ts @@ -139,6 +139,21 @@ function createTelegramBinding(overrides?: Partial): Sessi }; } +function expectIdleTimeoutSetReply( + mock: ReturnType, + text: string, + idleTimeoutMs: number, + idleTimeoutLabel: string, +) { + expect(mock).toHaveBeenCalledWith({ + targetSessionKey: "agent:main:subagent:child", + accountId: "default", + idleTimeoutMs, + }); + expect(text).toContain(`Idle timeout set to ${idleTimeoutLabel}`); + expect(text).toContain("2026-02-20T02:00:00.000Z"); +} + function createFakeThreadBindingManager(binding: FakeBinding | null) { return { getByThreadId: vi.fn((_threadId: string) => binding), @@ -175,13 +190,12 @@ describe("/session idle and /session max-age", () => { const result = await handleSessionCommand(createDiscordCommandParams("/session idle 2h"), true); const text = result?.reply?.text ?? ""; - expect(hoisted.setThreadBindingIdleTimeoutBySessionKeyMock).toHaveBeenCalledWith({ - targetSessionKey: "agent:main:subagent:child", - accountId: "default", - idleTimeoutMs: 2 * 60 * 60 * 1000, - }); - expect(text).toContain("Idle timeout set to 2h"); - expect(text).toContain("2026-02-20T02:00:00.000Z"); + expectIdleTimeoutSetReply( + hoisted.setThreadBindingIdleTimeoutBySessionKeyMock, + text, + 2 * 60 * 60 * 1000, + "2h", + ); }); it("shows active idle timeout when no value is provided", async () => { @@ -248,13 +262,12 @@ describe("/session idle and /session max-age", () => { ); const text = result?.reply?.text ?? ""; - expect(hoisted.setTelegramThreadBindingIdleTimeoutBySessionKeyMock).toHaveBeenCalledWith({ - targetSessionKey: "agent:main:subagent:child", - accountId: "default", - idleTimeoutMs: 2 * 60 * 60 * 1000, - }); - expect(text).toContain("Idle timeout set to 2h"); - expect(text).toContain("2026-02-20T02:00:00.000Z"); + expectIdleTimeoutSetReply( + hoisted.setTelegramThreadBindingIdleTimeoutBySessionKeyMock, + text, + 2 * 60 * 60 * 1000, + "2h", + ); }); it("reports Telegram max-age expiry from the original bind time", async () => { From a7c293b8ef64ec022bbe69874b5d28da428807f4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:32:23 +0000 Subject: [PATCH 170/461] test: dedupe discord bound slash dispatch setup --- .../native-command.plugin-dispatch.test.ts | 49 ++++++++++++------- 1 file changed, 31 insertions(+), 18 deletions(-) diff --git a/src/discord/monitor/native-command.plugin-dispatch.test.ts b/src/discord/monitor/native-command.plugin-dispatch.test.ts index bcb6be36c21..c35dbceb466 100644 --- a/src/discord/monitor/native-command.plugin-dispatch.test.ts +++ b/src/discord/monitor/native-command.plugin-dispatch.test.ts @@ -130,6 +130,25 @@ function expectBoundSessionDispatch( expect(persistentBindingMocks.ensureConfiguredAcpBindingSession).toHaveBeenCalledTimes(1); } +async function expectBoundStatusCommandDispatch(params: { + cfg: OpenClawConfig; + interaction: MockCommandInteraction; + channelId: string; + boundSessionKey: string; +}) { + const command = createStatusCommand(params.cfg); + setConfiguredBinding(params.channelId, params.boundSessionKey); + + vi.spyOn(pluginCommandsModule, "matchPluginCommand").mockReturnValue(null); + const dispatchSpy = createDispatchSpy(); + + await (command as { run: (interaction: unknown) => Promise }).run( + params.interaction as unknown, + ); + + expectBoundSessionDispatch(dispatchSpy, params.boundSessionKey); +} + describe("Discord native plugin command dispatch", () => { beforeEach(() => { vi.restoreAllMocks(); @@ -212,7 +231,6 @@ describe("Discord native plugin command dispatch", () => { }, ], } as OpenClawConfig; - const command = createStatusCommand(cfg); const interaction = createInteraction({ channelType: ChannelType.GuildText, channelId, @@ -220,14 +238,12 @@ describe("Discord native plugin command dispatch", () => { guildName: "Ops", }); - setConfiguredBinding(channelId, boundSessionKey); - - vi.spyOn(pluginCommandsModule, "matchPluginCommand").mockReturnValue(null); - const dispatchSpy = createDispatchSpy(); - - await (command as { run: (interaction: unknown) => Promise }).run(interaction as unknown); - - expectBoundSessionDispatch(dispatchSpy, boundSessionKey); + await expectBoundStatusCommandDispatch({ + cfg, + interaction, + channelId, + boundSessionKey, + }); }); it("falls back to the routed slash and channel session keys when no bound session exists", async () => { @@ -312,19 +328,16 @@ describe("Discord native plugin command dispatch", () => { }, }, } as OpenClawConfig; - const command = createStatusCommand(cfg); const interaction = createInteraction({ channelType: ChannelType.DM, channelId, }); - setConfiguredBinding(channelId, boundSessionKey); - - vi.spyOn(pluginCommandsModule, "matchPluginCommand").mockReturnValue(null); - const dispatchSpy = createDispatchSpy(); - - await (command as { run: (interaction: unknown) => Promise }).run(interaction as unknown); - - expectBoundSessionDispatch(dispatchSpy, boundSessionKey); + await expectBoundStatusCommandDispatch({ + cfg, + interaction, + channelId, + boundSessionKey, + }); }); }); From 6a44ca9f7668c6e5753c43351eb64c7aa5847359 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:34:42 +0000 Subject: [PATCH 171/461] test: dedupe discord queue preflight setup --- .../monitor/message-handler.queue.test.ts | 43 +++++++++---------- 1 file changed, 21 insertions(+), 22 deletions(-) diff --git a/src/discord/monitor/message-handler.queue.test.ts b/src/discord/monitor/message-handler.queue.test.ts index 122ce852333..92cf0e31183 100644 --- a/src/discord/monitor/message-handler.queue.test.ts +++ b/src/discord/monitor/message-handler.queue.test.ts @@ -45,20 +45,30 @@ function createPreflightContext(channelId = "ch-1") { return createDiscordPreflightContext(channelId); } +function createHandlerWithDefaultPreflight(overrides?: { + setStatus?: SetStatusFn; + workerRunTimeoutMs?: number; +}) { + preflightDiscordMessageMock.mockImplementation(async (params: { data: { channel_id: string } }) => + createPreflightContext(params.data.channel_id), + ); + return createDiscordMessageHandler(createDiscordHandlerParams(overrides)); +} + async function createLifecycleStopScenario(params: { createHandler: (status: SetStatusFn) => { handler: (data: never, opts: never) => Promise; stop: () => void; }; }) { + preflightDiscordMessageMock.mockImplementation( + async (preflightParams: { data: { channel_id: string } }) => + createPreflightContext(preflightParams.data.channel_id), + ); const runInFlight = createDeferred(); processDiscordMessageMock.mockImplementation(async () => { await runInFlight.promise; }); - preflightDiscordMessageMock.mockImplementation( - async (contextParams: { data: { channel_id: string } }) => - createPreflightContext(contextParams.data.channel_id), - ); const setStatus = vi.fn(); const { handler, stop } = params.createHandler(setStatus); @@ -111,13 +121,8 @@ describe("createDiscordMessageHandler queue behavior", () => { .mockImplementationOnce(async () => { await secondRun.promise; }); - preflightDiscordMessageMock.mockImplementation( - async (params: { data: { channel_id: string } }) => - createPreflightContext(params.data.channel_id), - ); - const setStatus = vi.fn(); - const handler = createDiscordMessageHandler(createDiscordHandlerParams({ setStatus })); + const handler = createHandlerWithDefaultPreflight({ setStatus }); await expect(handler(createMessageData("m-1") as never, {} as never)).resolves.toBeUndefined(); @@ -175,12 +180,11 @@ describe("createDiscordMessageHandler queue behavior", () => { }); }) .mockImplementationOnce(async () => undefined); - preflightDiscordMessageMock.mockImplementation( - async (params: { data: { channel_id: string } }) => - createPreflightContext(params.data.channel_id), - ); - const params = createDiscordHandlerParams({ workerRunTimeoutMs: 50 }); + preflightDiscordMessageMock.mockImplementation( + async (preflightParams: { data: { channel_id: string } }) => + createPreflightContext(preflightParams.data.channel_id), + ); const handler = createDiscordMessageHandler(params); await expect( @@ -226,13 +230,8 @@ describe("createDiscordMessageHandler queue behavior", () => { }); }, ); - preflightDiscordMessageMock.mockImplementation( - async (params: { data: { channel_id: string } }) => - createPreflightContext(params.data.channel_id), - ); - const params = createDiscordHandlerParams({ workerRunTimeoutMs: 0 }); - const handler = createDiscordMessageHandler(params); + const handler = createHandlerWithDefaultPreflight({ workerRunTimeoutMs: 0 }); await expect( handler(createMessageData("m-1") as never, {} as never), @@ -442,7 +441,7 @@ describe("createDiscordMessageHandler queue behavior", () => { ); const setStatus = vi.fn(); - const handler = createDiscordMessageHandler(createDiscordHandlerParams({ setStatus })); + const handler = createHandlerWithDefaultPreflight({ setStatus }); await expect(handler(createMessageData("m-1") as never, {} as never)).resolves.toBeUndefined(); await expect(handler(createMessageData("m-2") as never, {} as never)).resolves.toBeUndefined(); From fd340a88d665bd42a9254ff7d2e10800498a4120 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:37:57 +0000 Subject: [PATCH 172/461] test: dedupe discord preflight helpers --- ...age-handler.preflight.acp-bindings.test.ts | 77 ++----- .../message-handler.preflight.test-helpers.ts | 103 +++++++++ .../monitor/message-handler.preflight.test.ts | 210 ++++++------------ 3 files changed, 190 insertions(+), 200 deletions(-) create mode 100644 src/discord/monitor/message-handler.preflight.test-helpers.ts diff --git a/src/discord/monitor/message-handler.preflight.acp-bindings.test.ts b/src/discord/monitor/message-handler.preflight.acp-bindings.test.ts index 1d7344ca15f..984c9e4cb20 100644 --- a/src/discord/monitor/message-handler.preflight.acp-bindings.test.ts +++ b/src/discord/monitor/message-handler.preflight.acp-bindings.test.ts @@ -1,4 +1,3 @@ -import { ChannelType } from "@buape/carbon"; import { beforeEach, describe, expect, it, vi } from "vitest"; const ensureConfiguredAcpBindingSessionMock = vi.hoisted(() => vi.fn()); @@ -13,7 +12,13 @@ vi.mock("../../acp/persistent-bindings.js", () => ({ import { __testing as sessionBindingTesting } from "../../infra/outbound/session-binding-service.js"; import { preflightDiscordMessage } from "./message-handler.preflight.js"; -import { createNoopThreadBindingManager } from "./thread-bindings.js"; +import { + createDiscordMessage, + createDiscordPreflightArgs, + createGuildEvent, + createGuildTextClient, + DEFAULT_PREFLIGHT_CFG, +} from "./message-handler.preflight.test-helpers.js"; const GUILD_ID = "guild-1"; const CHANNEL_ID = "channel-1"; @@ -48,70 +53,36 @@ function createConfiguredDiscordBinding() { } function createBasePreflightParams(overrides?: Record) { - const message = { + const message = createDiscordMessage({ id: "m-1", - content: "<@bot-1> hello", - timestamp: new Date().toISOString(), channelId: CHANNEL_ID, - attachments: [], + content: "<@bot-1> hello", mentionedUsers: [{ id: "bot-1" }], - mentionedRoles: [], - mentionedEveryone: false, author: { id: "user-1", bot: false, username: "alice", }, - } as unknown as import("@buape/carbon").Message; - - const client = { - fetchChannel: async (channelId: string) => { - if (channelId === CHANNEL_ID) { - return { - id: CHANNEL_ID, - type: ChannelType.GuildText, - name: "general", - }; - } - return null; - }, - } as unknown as import("@buape/carbon").Client; + }); return { - cfg: { - session: { - mainKey: "main", - scope: "per-sender", - }, - } as import("../../config/config.js").OpenClawConfig, + ...createDiscordPreflightArgs({ + cfg: DEFAULT_PREFLIGHT_CFG, + discordConfig: { + allowBots: true, + } as NonNullable["discord"], + data: createGuildEvent({ + channelId: CHANNEL_ID, + guildId: GUILD_ID, + author: message.author, + message, + }), + client: createGuildTextClient(CHANNEL_ID), + botUserId: "bot-1", + }), discordConfig: { allowBots: true, } as NonNullable["discord"], - accountId: "default", - token: "token", - runtime: {} as import("../../runtime.js").RuntimeEnv, - botUserId: "bot-1", - guildHistories: new Map(), - historyLimit: 0, - mediaMaxBytes: 1_000_000, - textLimit: 2_000, - replyToMode: "all", - dmEnabled: true, - groupDmEnabled: true, - ackReactionScope: "direct", - groupPolicy: "open", - threadBindings: createNoopThreadBindingManager("default"), - data: { - channel_id: CHANNEL_ID, - guild_id: GUILD_ID, - guild: { - id: GUILD_ID, - name: "Guild One", - }, - author: message.author, - message, - } as unknown as import("./listeners.js").DiscordMessageEvent, - client, ...overrides, } satisfies Parameters[0]; } diff --git a/src/discord/monitor/message-handler.preflight.test-helpers.ts b/src/discord/monitor/message-handler.preflight.test-helpers.ts new file mode 100644 index 00000000000..712aec7e187 --- /dev/null +++ b/src/discord/monitor/message-handler.preflight.test-helpers.ts @@ -0,0 +1,103 @@ +import { ChannelType } from "@buape/carbon"; +import type { OpenClawConfig } from "../../config/config.js"; +import type { preflightDiscordMessage } from "./message-handler.preflight.js"; +import { createNoopThreadBindingManager } from "./thread-bindings.js"; + +export type DiscordConfig = NonNullable["discord"]; +export type DiscordMessageEvent = import("./listeners.js").DiscordMessageEvent; +export type DiscordClient = import("@buape/carbon").Client; + +export const DEFAULT_PREFLIGHT_CFG = { + session: { + mainKey: "main", + scope: "per-sender", + }, +} as OpenClawConfig; + +export function createGuildTextClient(channelId: string): DiscordClient { + return { + fetchChannel: async (id: string) => { + if (id === channelId) { + return { + id: channelId, + type: ChannelType.GuildText, + name: "general", + }; + } + return null; + }, + } as unknown as DiscordClient; +} + +export function createGuildEvent(params: { + channelId: string; + guildId: string; + author: import("@buape/carbon").Message["author"]; + message: import("@buape/carbon").Message; +}): DiscordMessageEvent { + return { + channel_id: params.channelId, + guild_id: params.guildId, + guild: { + id: params.guildId, + name: "Guild One", + }, + author: params.author, + message: params.message, + } as unknown as DiscordMessageEvent; +} + +export function createDiscordMessage(params: { + id: string; + channelId: string; + content: string; + author: { + id: string; + bot: boolean; + username?: string; + }; + mentionedUsers?: Array<{ id: string }>; + mentionedEveryone?: boolean; + attachments?: Array>; +}): import("@buape/carbon").Message { + return { + id: params.id, + content: params.content, + timestamp: new Date().toISOString(), + channelId: params.channelId, + attachments: params.attachments ?? [], + mentionedUsers: params.mentionedUsers ?? [], + mentionedRoles: [], + mentionedEveryone: params.mentionedEveryone ?? false, + author: params.author, + } as unknown as import("@buape/carbon").Message; +} + +export function createDiscordPreflightArgs(params: { + cfg: OpenClawConfig; + discordConfig: DiscordConfig; + data: DiscordMessageEvent; + client: DiscordClient; + botUserId?: string; +}): Parameters[0] { + return { + cfg: params.cfg, + discordConfig: params.discordConfig, + accountId: "default", + token: "token", + runtime: {} as import("../../runtime.js").RuntimeEnv, + botUserId: params.botUserId ?? "openclaw-bot", + guildHistories: new Map(), + historyLimit: 0, + mediaMaxBytes: 1_000_000, + textLimit: 2_000, + replyToMode: "all", + dmEnabled: true, + groupDmEnabled: true, + ackReactionScope: "direct", + groupPolicy: "open", + threadBindings: createNoopThreadBindingManager("default"), + data: params.data, + client: params.client, + }; +} diff --git a/src/discord/monitor/message-handler.preflight.test.ts b/src/discord/monitor/message-handler.preflight.test.ts index 1e4d9c5dddb..c90c608e93b 100644 --- a/src/discord/monitor/message-handler.preflight.test.ts +++ b/src/discord/monitor/message-handler.preflight.test.ts @@ -15,25 +15,21 @@ import { resolvePreflightMentionRequirement, shouldIgnoreBoundThreadWebhookMessage, } from "./message-handler.preflight.js"; +import { + createDiscordMessage, + createDiscordPreflightArgs, + createGuildEvent, + createGuildTextClient, + DEFAULT_PREFLIGHT_CFG, + type DiscordClient, + type DiscordConfig, + type DiscordMessageEvent, +} from "./message-handler.preflight.test-helpers.js"; import { __testing as threadBindingTesting, - createNoopThreadBindingManager, createThreadBindingManager, } from "./thread-bindings.js"; -type DiscordConfig = NonNullable< - import("../../config/config.js").OpenClawConfig["channels"] ->["discord"]; -type DiscordMessageEvent = import("./listeners.js").DiscordMessageEvent; -type DiscordClient = import("@buape/carbon").Client; - -const DEFAULT_CFG = { - session: { - mainKey: "main", - scope: "per-sender", - }, -} as import("../../config/config.js").OpenClawConfig; - function createThreadBinding( overrides?: Partial< import("../../infra/outbound/session-binding-service.js").SessionBindingRecord @@ -67,41 +63,7 @@ function createPreflightArgs(params: { data: DiscordMessageEvent; client: DiscordClient; }): Parameters[0] { - return { - cfg: params.cfg, - discordConfig: params.discordConfig, - accountId: "default", - token: "token", - runtime: {} as import("../../runtime.js").RuntimeEnv, - botUserId: "openclaw-bot", - guildHistories: new Map(), - historyLimit: 0, - mediaMaxBytes: 1_000_000, - textLimit: 2_000, - replyToMode: "all", - dmEnabled: true, - groupDmEnabled: true, - ackReactionScope: "direct", - groupPolicy: "open", - threadBindings: createNoopThreadBindingManager("default"), - data: params.data, - client: params.client, - }; -} - -function createGuildTextClient(channelId: string): DiscordClient { - return { - fetchChannel: async (id: string) => { - if (id === channelId) { - return { - id: channelId, - type: ChannelType.GuildText, - name: "general", - }; - } - return null; - }, - } as unknown as DiscordClient; + return createDiscordPreflightArgs(params); } function createThreadClient(params: { threadId: string; parentId: string }): DiscordClient { @@ -128,50 +90,6 @@ function createThreadClient(params: { threadId: string; parentId: string }): Dis } as unknown as DiscordClient; } -function createGuildEvent(params: { - channelId: string; - guildId: string; - author: import("@buape/carbon").Message["author"]; - message: import("@buape/carbon").Message; -}): DiscordMessageEvent { - return { - channel_id: params.channelId, - guild_id: params.guildId, - guild: { - id: params.guildId, - name: "Guild One", - }, - author: params.author, - message: params.message, - } as unknown as DiscordMessageEvent; -} - -function createMessage(params: { - id: string; - channelId: string; - content: string; - author: { - id: string; - bot: boolean; - username?: string; - }; - mentionedUsers?: Array<{ id: string }>; - mentionedEveryone?: boolean; - attachments?: Array>; -}): import("@buape/carbon").Message { - return { - id: params.id, - content: params.content, - timestamp: new Date().toISOString(), - channelId: params.channelId, - attachments: params.attachments ?? [], - mentionedUsers: params.mentionedUsers ?? [], - mentionedRoles: [], - mentionedEveryone: params.mentionedEveryone ?? false, - author: params.author, - } as unknown as import("@buape/carbon").Message; -} - async function runThreadBoundPreflight(params: { threadId: string; parentId: string; @@ -197,7 +115,7 @@ async function runThreadBoundPreflight(params: { return preflightDiscordMessage({ ...createPreflightArgs({ - cfg: DEFAULT_CFG, + cfg: DEFAULT_PREFLIGHT_CFG, discordConfig: params.discordConfig, data: createGuildEvent({ channelId: params.threadId, @@ -223,7 +141,7 @@ async function runGuildPreflight(params: { }) { return preflightDiscordMessage({ ...createPreflightArgs({ - cfg: params.cfg ?? DEFAULT_CFG, + cfg: params.cfg ?? DEFAULT_PREFLIGHT_CFG, discordConfig: params.discordConfig, data: createGuildEvent({ channelId: params.channelId, @@ -237,6 +155,40 @@ async function runGuildPreflight(params: { }); } +async function runMentionOnlyBotPreflight(params: { + channelId: string; + guildId: string; + message: import("@buape/carbon").Message; +}) { + return runGuildPreflight({ + channelId: params.channelId, + guildId: params.guildId, + message: params.message, + discordConfig: { + allowBots: "mentions", + } as DiscordConfig, + }); +} + +async function runIgnoreOtherMentionsPreflight(params: { + channelId: string; + guildId: string; + message: import("@buape/carbon").Message; +}) { + return runGuildPreflight({ + channelId: params.channelId, + guildId: params.guildId, + message: params.message, + discordConfig: {} as DiscordConfig, + guildEntries: { + [params.guildId]: { + requireMention: false, + ignoreOtherMentions: true, + }, + }, + }); +} + describe("resolvePreflightMentionRequirement", () => { it("requires mention when config requires mention and thread is not bound", () => { expect( @@ -279,7 +231,7 @@ describe("preflightDiscordMessage", () => { }); const threadId = "thread-system-1"; const parentId = "channel-parent-1"; - const message = createMessage({ + const message = createDiscordMessage({ id: "m-system-1", channelId: threadId, content: @@ -311,7 +263,7 @@ describe("preflightDiscordMessage", () => { }); const threadId = "thread-bot-regular-1"; const parentId = "channel-parent-regular-1"; - const message = createMessage({ + const message = createDiscordMessage({ id: "m-bot-regular-1", channelId: threadId, content: "here is tool output chunk", @@ -342,7 +294,7 @@ describe("preflightDiscordMessage", () => { const threadId = "thread-bot-focus"; const parentId = "channel-parent-focus"; const client = createThreadClient({ threadId, parentId }); - const message = createMessage({ + const message = createDiscordMessage({ id: "m-bot-1", channelId: threadId, content: "relay message without mention", @@ -363,7 +315,7 @@ describe("preflightDiscordMessage", () => { const result = await preflightDiscordMessage( createPreflightArgs({ cfg: { - ...DEFAULT_CFG, + ...DEFAULT_PREFLIGHT_CFG, } as import("../../config/config.js").OpenClawConfig, discordConfig: { allowBots: true, @@ -386,7 +338,7 @@ describe("preflightDiscordMessage", () => { it("drops bot messages without mention when allowBots=mentions", async () => { const channelId = "channel-bot-mentions-off"; const guildId = "guild-bot-mentions-off"; - const message = createMessage({ + const message = createDiscordMessage({ id: "m-bot-mentions-off", channelId, content: "relay chatter", @@ -397,14 +349,7 @@ describe("preflightDiscordMessage", () => { }, }); - const result = await runGuildPreflight({ - channelId, - guildId, - message, - discordConfig: { - allowBots: "mentions", - } as DiscordConfig, - }); + const result = await runMentionOnlyBotPreflight({ channelId, guildId, message }); expect(result).toBeNull(); }); @@ -412,7 +357,7 @@ describe("preflightDiscordMessage", () => { it("allows bot messages with explicit mention when allowBots=mentions", async () => { const channelId = "channel-bot-mentions-on"; const guildId = "guild-bot-mentions-on"; - const message = createMessage({ + const message = createDiscordMessage({ id: "m-bot-mentions-on", channelId, content: "hi <@openclaw-bot>", @@ -424,14 +369,7 @@ describe("preflightDiscordMessage", () => { }, }); - const result = await runGuildPreflight({ - channelId, - guildId, - message, - discordConfig: { - allowBots: "mentions", - } as DiscordConfig, - }); + const result = await runMentionOnlyBotPreflight({ channelId, guildId, message }); expect(result).not.toBeNull(); }); @@ -439,7 +377,7 @@ describe("preflightDiscordMessage", () => { it("drops guild messages that mention another user when ignoreOtherMentions=true", async () => { const channelId = "channel-other-mention-1"; const guildId = "guild-other-mention-1"; - const message = createMessage({ + const message = createDiscordMessage({ id: "m-other-mention-1", channelId, content: "hello <@999>", @@ -451,18 +389,7 @@ describe("preflightDiscordMessage", () => { }, }); - const result = await runGuildPreflight({ - channelId, - guildId, - message, - discordConfig: {} as DiscordConfig, - guildEntries: { - [guildId]: { - requireMention: false, - ignoreOtherMentions: true, - }, - }, - }); + const result = await runIgnoreOtherMentionsPreflight({ channelId, guildId, message }); expect(result).toBeNull(); }); @@ -470,7 +397,7 @@ describe("preflightDiscordMessage", () => { it("does not drop @everyone messages when ignoreOtherMentions=true", async () => { const channelId = "channel-other-mention-everyone"; const guildId = "guild-other-mention-everyone"; - const message = createMessage({ + const message = createDiscordMessage({ id: "m-other-mention-everyone", channelId, content: "@everyone heads up", @@ -482,18 +409,7 @@ describe("preflightDiscordMessage", () => { }, }); - const result = await runGuildPreflight({ - channelId, - guildId, - message, - discordConfig: {} as DiscordConfig, - guildEntries: { - [guildId]: { - requireMention: false, - ignoreOtherMentions: true, - }, - }, - }); + const result = await runIgnoreOtherMentionsPreflight({ channelId, guildId, message }); expect(result).not.toBeNull(); expect(result?.hasAnyMention).toBe(true); @@ -503,7 +419,7 @@ describe("preflightDiscordMessage", () => { const channelId = "channel-everyone-1"; const guildId = "guild-everyone-1"; const client = createGuildTextClient(channelId); - const message = createMessage({ + const message = createDiscordMessage({ id: "m-everyone-1", channelId, content: "@everyone heads up", @@ -517,7 +433,7 @@ describe("preflightDiscordMessage", () => { const result = await preflightDiscordMessage({ ...createPreflightArgs({ - cfg: DEFAULT_CFG, + cfg: DEFAULT_PREFLIGHT_CFG, discordConfig: { allowBots: true, } as DiscordConfig, @@ -546,7 +462,7 @@ describe("preflightDiscordMessage", () => { const channelId = "channel-audio-1"; const client = createGuildTextClient(channelId); - const message = createMessage({ + const message = createDiscordMessage({ id: "m-audio-1", channelId, content: "", @@ -568,7 +484,7 @@ describe("preflightDiscordMessage", () => { const result = await preflightDiscordMessage( createPreflightArgs({ cfg: { - ...DEFAULT_CFG, + ...DEFAULT_PREFLIGHT_CFG, messages: { groupChat: { mentionPatterns: ["openclaw"], From fd5243c27e61b8b719c8ef79518a8fcfcffcf717 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:39:49 +0000 Subject: [PATCH 173/461] refactor: share discord exec approval helpers --- src/discord/monitor/exec-approvals.test.ts | 138 +++++++++++---------- src/discord/monitor/exec-approvals.ts | 31 +++-- 2 files changed, 97 insertions(+), 72 deletions(-) diff --git a/src/discord/monitor/exec-approvals.test.ts b/src/discord/monitor/exec-approvals.test.ts index 8f9430393a2..c7cb72b82ec 100644 --- a/src/discord/monitor/exec-approvals.test.ts +++ b/src/discord/monitor/exec-approvals.test.ts @@ -116,6 +116,62 @@ function createHandler(config: DiscordExecApprovalConfig, accountId = "default") }); } +function mockSuccessfulDmDelivery(params?: { + noteChannelId?: string; + expectedNoteText?: string; + throwOnUnexpectedRoute?: boolean; +}) { + mockRestPost.mockImplementation( + async (route: string, requestParams?: { body?: { content?: string } }) => { + if (params?.noteChannelId && route === Routes.channelMessages(params.noteChannelId)) { + if (params.expectedNoteText) { + expect(requestParams?.body?.content).toContain(params.expectedNoteText); + } + return { id: "note-1", channel_id: params.noteChannelId }; + } + if (route === Routes.userChannels()) { + return { id: "dm-1" }; + } + if (route === Routes.channelMessages("dm-1")) { + return { id: "msg-1", channel_id: "dm-1" }; + } + if (params?.throwOnUnexpectedRoute) { + throw new Error(`unexpected route: ${route}`); + } + return { id: "msg-unknown" }; + }, + ); +} + +async function expectGatewayAuthStart(params: { + handler: DiscordExecApprovalHandler; + expectedUrl: string; + expectedSource: "cli" | "env"; + expectedToken?: string; + expectedPassword?: string; +}) { + await params.handler.start(); + + expect(mockResolveGatewayConnectionAuth).toHaveBeenCalledWith( + expect.objectContaining({ + env: process.env, + urlOverride: params.expectedUrl, + urlOverrideSource: params.expectedSource, + }), + ); + + const expectedClientParams: Record = { + url: params.expectedUrl, + }; + if (params.expectedToken !== undefined) { + expectedClientParams.token = params.expectedToken; + } + if (params.expectedPassword !== undefined) { + expectedClientParams.password = params.expectedPassword; + } + expect(mockGatewayClientCtor).toHaveBeenCalledWith(expect.objectContaining(expectedClientParams)); +} + type ExecApprovalHandlerInternals = { pending: Map< string, @@ -772,15 +828,7 @@ describe("DiscordExecApprovalHandler delivery routing", () => { }); const internals = getHandlerInternals(handler); - mockRestPost.mockImplementation(async (route: string) => { - if (route === Routes.userChannels()) { - return { id: "dm-1" }; - } - if (route === Routes.channelMessages("dm-1")) { - return { id: "msg-1", channel_id: "dm-1" }; - } - return { id: "msg-unknown" }; - }); + mockSuccessfulDmDelivery(); const request = createRequest({ sessionKey: "agent:main:discord:dm:123" }); await internals.handleApprovalRequested(request); @@ -809,21 +857,11 @@ describe("DiscordExecApprovalHandler delivery routing", () => { }); const internals = getHandlerInternals(handler); - mockRestPost.mockImplementation( - async (route: string, params?: { body?: { content?: string } }) => { - if (route === Routes.channelMessages("999888777")) { - expect(params?.body?.content).toContain("I sent the allowed approvers DMs"); - return { id: "note-1", channel_id: "999888777" }; - } - if (route === Routes.userChannels()) { - return { id: "dm-1" }; - } - if (route === Routes.channelMessages("dm-1")) { - return { id: "msg-1", channel_id: "dm-1" }; - } - throw new Error(`unexpected route: ${route}`); - }, - ); + mockSuccessfulDmDelivery({ + noteChannelId: "999888777", + expectedNoteText: "I sent the allowed approvers DMs", + throwOnUnexpectedRoute: true, + }); await internals.handleApprovalRequested(createRequest()); @@ -853,15 +891,7 @@ describe("DiscordExecApprovalHandler delivery routing", () => { }); const internals = getHandlerInternals(handler); - mockRestPost.mockImplementation(async (route: string) => { - if (route === Routes.userChannels()) { - return { id: "dm-1" }; - } - if (route === Routes.channelMessages("dm-1")) { - return { id: "msg-1", channel_id: "dm-1" }; - } - throw new Error(`unexpected route: ${route}`); - }); + mockSuccessfulDmDelivery({ throwOnUnexpectedRoute: true }); await internals.handleApprovalRequested( createRequest({ sessionKey: "agent:main:discord:dm:123" }), @@ -890,22 +920,13 @@ describe("DiscordExecApprovalHandler gateway auth resolution", () => { cfg: { session: { store: STORE_PATH } }, }); - await handler.start(); - - expect(mockResolveGatewayConnectionAuth).toHaveBeenCalledWith( - expect.objectContaining({ - env: process.env, - urlOverride: "wss://override.example/ws", - urlOverrideSource: "cli", - }), - ); - expect(mockGatewayClientCtor).toHaveBeenCalledWith( - expect.objectContaining({ - url: "wss://override.example/ws", - token: "resolved-token", - password: "resolved-password", // pragma: allowlist secret - }), - ); + await expectGatewayAuthStart({ + handler, + expectedUrl: "wss://override.example/ws", + expectedSource: "cli", + expectedToken: "resolved-token", + expectedPassword: "resolved-password", // pragma: allowlist secret + }); await handler.stop(); }); @@ -921,20 +942,11 @@ describe("DiscordExecApprovalHandler gateway auth resolution", () => { cfg: { session: { store: STORE_PATH } }, }); - await handler.start(); - - expect(mockResolveGatewayConnectionAuth).toHaveBeenCalledWith( - expect.objectContaining({ - env: process.env, - urlOverride: "wss://gateway-from-env.example/ws", - urlOverrideSource: "env", - }), - ); - expect(mockGatewayClientCtor).toHaveBeenCalledWith( - expect.objectContaining({ - url: "wss://gateway-from-env.example/ws", - }), - ); + await expectGatewayAuthStart({ + handler, + expectedUrl: "wss://gateway-from-env.example/ws", + expectedSource: "env", + }); await handler.stop(); } finally { diff --git a/src/discord/monitor/exec-approvals.ts b/src/discord/monitor/exec-approvals.ts index 87dc0c9a07d..8dd3156e991 100644 --- a/src/discord/monitor/exec-approvals.ts +++ b/src/discord/monitor/exec-approvals.ts @@ -252,17 +252,30 @@ function formatOptionalCommandPreview( return formatCommandPreview(commandText, maxChars); } +function resolveExecApprovalPreviews( + request: ExecApprovalRequest["request"], + maxChars: number, + secondaryMaxChars: number, +): { commandPreview: string; commandSecondaryPreview: string | null } { + const { commandText, commandPreview: secondaryPreview } = + resolveExecApprovalCommandDisplay(request); + return { + commandPreview: formatCommandPreview(commandText, maxChars), + commandSecondaryPreview: formatOptionalCommandPreview(secondaryPreview, secondaryMaxChars), + }; +} + function createExecApprovalRequestContainer(params: { request: ExecApprovalRequest; cfg: OpenClawConfig; accountId: string; actionRow?: Row