From 3e8d9bc6ea760d565c8798bc8a294e12f32b019a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 18:19:27 +0000 Subject: [PATCH 001/640] test: refine telegram token coverage --- src/telegram/token.test.ts | 154 ++++++++++++++++++++++++------------- 1 file changed, 100 insertions(+), 54 deletions(-) diff --git a/src/telegram/token.test.ts b/src/telegram/token.test.ts index f888ddbfc36..17e412cf584 100644 --- a/src/telegram/token.test.ts +++ b/src/telegram/token.test.ts @@ -7,50 +7,75 @@ import { withStateDirEnv } from "../test-helpers/state-dir-env.js"; import { resolveTelegramToken } from "./token.js"; import { readTelegramUpdateOffset, writeTelegramUpdateOffset } from "./update-offset-store.js"; -function withTempDir(): string { - return fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-telegram-token-")); -} - describe("resolveTelegramToken", () => { + const tempDirs: string[] = []; + + function createTempDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-telegram-token-")); + tempDirs.push(dir); + return dir; + } + + function createTokenFile(fileName: string, contents = "file-token\n"): string { + const dir = createTempDir(); + const tokenFile = path.join(dir, fileName); + fs.writeFileSync(tokenFile, contents, "utf-8"); + return tokenFile; + } + afterEach(() => { vi.unstubAllEnvs(); + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } }); - it("prefers config token over env", () => { - vi.stubEnv("TELEGRAM_BOT_TOKEN", "env-token"); - const cfg = { - channels: { telegram: { botToken: "cfg-token" } }, - } as OpenClawConfig; - const res = resolveTelegramToken(cfg); - expect(res.token).toBe("cfg-token"); - expect(res.source).toBe("config"); - }); - - it("uses env token when config is missing", () => { - vi.stubEnv("TELEGRAM_BOT_TOKEN", "env-token"); - const cfg = { - channels: { telegram: {} }, - } as OpenClawConfig; - const res = resolveTelegramToken(cfg); - expect(res.token).toBe("env-token"); - expect(res.source).toBe("env"); - }); - - it("uses tokenFile when configured", () => { - vi.stubEnv("TELEGRAM_BOT_TOKEN", ""); - const dir = withTempDir(); - const tokenFile = path.join(dir, "token.txt"); - fs.writeFileSync(tokenFile, "file-token\n", "utf-8"); - const cfg = { channels: { telegram: { tokenFile } } } as OpenClawConfig; - const res = resolveTelegramToken(cfg); - expect(res.token).toBe("file-token"); - expect(res.source).toBe("tokenFile"); - fs.rmSync(dir, { recursive: true, force: true }); + it.each([ + { + name: "prefers config token over env", + envToken: "env-token", + cfg: { + channels: { telegram: { botToken: "cfg-token" } }, + } as OpenClawConfig, + expected: { token: "cfg-token", source: "config" }, + }, + { + name: "uses env token when config is missing", + envToken: "env-token", + cfg: { + channels: { telegram: {} }, + } as OpenClawConfig, + expected: { token: "env-token", source: "env" }, + }, + { + name: "uses tokenFile when configured", + envToken: "", + cfg: { + channels: { telegram: { tokenFile: "" } }, + } as OpenClawConfig, + resolveCfg: () => + ({ + channels: { telegram: { tokenFile: createTokenFile("token.txt") } }, + }) as OpenClawConfig, + expected: { token: "file-token", source: "tokenFile" }, + }, + { + name: "falls back to config token when no env or tokenFile", + envToken: "", + cfg: { + channels: { telegram: { botToken: "cfg-token" } }, + } as OpenClawConfig, + expected: { token: "cfg-token", source: "config" }, + }, + ])("$name", ({ envToken, cfg, resolveCfg, expected }) => { + vi.stubEnv("TELEGRAM_BOT_TOKEN", envToken); + const res = resolveTelegramToken(resolveCfg ? resolveCfg() : cfg); + expect(res).toEqual(expected); }); it.runIf(process.platform !== "win32")("rejects symlinked tokenFile paths", () => { vi.stubEnv("TELEGRAM_BOT_TOKEN", ""); - const dir = withTempDir(); + const dir = createTempDir(); const tokenFile = path.join(dir, "token.txt"); const tokenLink = path.join(dir, "token-link.txt"); fs.writeFileSync(tokenFile, "file-token\n", "utf-8"); @@ -60,22 +85,11 @@ describe("resolveTelegramToken", () => { const res = resolveTelegramToken(cfg); expect(res.token).toBe(""); expect(res.source).toBe("none"); - fs.rmSync(dir, { recursive: true, force: true }); - }); - - it("falls back to config token when no env or tokenFile", () => { - vi.stubEnv("TELEGRAM_BOT_TOKEN", ""); - const cfg = { - channels: { telegram: { botToken: "cfg-token" } }, - } as OpenClawConfig; - const res = resolveTelegramToken(cfg); - expect(res.token).toBe("cfg-token"); - expect(res.source).toBe("config"); }); it("does not fall back to config when tokenFile is missing", () => { vi.stubEnv("TELEGRAM_BOT_TOKEN", ""); - const dir = withTempDir(); + const dir = createTempDir(); const tokenFile = path.join(dir, "missing-token.txt"); const cfg = { channels: { telegram: { tokenFile, botToken: "cfg-token" } }, @@ -83,7 +97,6 @@ describe("resolveTelegramToken", () => { const res = resolveTelegramToken(cfg); expect(res.token).toBe(""); expect(res.source).toBe("none"); - fs.rmSync(dir, { recursive: true, force: true }); }); it("resolves per-account tokens when the config account key casing doesn't match routing normalization", () => { @@ -121,14 +134,31 @@ describe("resolveTelegramToken", () => { expect(res.source).toBe("config"); }); - it("falls back to top-level tokenFile for non-default accounts", () => { - const dir = withTempDir(); - const tokenFile = path.join(dir, "token.txt"); - fs.writeFileSync(tokenFile, "file-token\n", "utf-8"); + it("uses account-level tokenFile before top-level fallbacks", () => { const cfg = { channels: { telegram: { - tokenFile, + botToken: "top-level-token", + tokenFile: createTokenFile("top-level-token.txt", "top-level-file-token\n"), + accounts: { + work: { + tokenFile: createTokenFile("account-token.txt", "account-file-token\n"), + }, + }, + }, + }, + } as OpenClawConfig; + + const res = resolveTelegramToken(cfg, { accountId: "work" }); + expect(res.token).toBe("account-file-token"); + expect(res.source).toBe("tokenFile"); + }); + + it("falls back to top-level tokenFile for non-default accounts", () => { + const cfg = { + channels: { + telegram: { + tokenFile: createTokenFile("token.txt"), accounts: { work: {}, }, @@ -139,7 +169,23 @@ describe("resolveTelegramToken", () => { const res = resolveTelegramToken(cfg, { accountId: "work" }); expect(res.token).toBe("file-token"); expect(res.source).toBe("tokenFile"); - fs.rmSync(dir, { recursive: true, force: true }); + }); + + it("does not use env token for non-default accounts", () => { + vi.stubEnv("TELEGRAM_BOT_TOKEN", "env-token"); + const cfg = { + channels: { + telegram: { + accounts: { + work: {}, + }, + }, + }, + } as OpenClawConfig; + + const res = resolveTelegramToken(cfg, { accountId: "work" }); + expect(res.token).toBe(""); + expect(res.source).toBe("none"); }); it("throws when botToken is an unresolved SecretRef object", () => { From f3d4bb4103d6cfa18925f524f5fb6c022bc88469 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 18:20:08 +0000 Subject: [PATCH 002/640] test: simplify ssrf hostname coverage --- src/infra/net/ssrf.test.ts | 41 +++++++++++++++++++++++--------------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/src/infra/net/ssrf.test.ts b/src/infra/net/ssrf.test.ts index 2698bf3db9e..637bd5c2e9e 100644 --- a/src/infra/net/ssrf.test.ts +++ b/src/infra/net/ssrf.test.ts @@ -111,19 +111,23 @@ describe("normalizeFingerprint", () => { }); describe("isBlockedHostnameOrIp", () => { - it("blocks localhost.localdomain and metadata hostname aliases", () => { - expect(isBlockedHostnameOrIp("localhost.localdomain")).toBe(true); - expect(isBlockedHostnameOrIp("metadata.google.internal")).toBe(true); + it.each([ + "localhost.localdomain", + "metadata.google.internal", + "api.localhost", + "svc.local", + "db.internal", + ])("blocks reserved hostname %s", (hostname) => { + expect(isBlockedHostnameOrIp(hostname)).toBe(true); }); - it("blocks private transition addresses via shared IP classifier", () => { - expect(isBlockedHostnameOrIp("2001:db8:1234::5efe:127.0.0.1")).toBe(true); - expect(isBlockedHostnameOrIp("2001:db8::1")).toBe(false); - }); - - it("blocks IPv4 special-use ranges but allows adjacent public ranges", () => { - expect(isBlockedHostnameOrIp("198.18.0.1")).toBe(true); - expect(isBlockedHostnameOrIp("198.20.0.1")).toBe(false); + it.each([ + ["2001:db8:1234::5efe:127.0.0.1", true], + ["2001:db8::1", false], + ["198.18.0.1", true], + ["198.20.0.1", false], + ])("returns %s => %s", (value, expected) => { + expect(isBlockedHostnameOrIp(value)).toBe(expected); }); it("supports opt-in policy to allow RFC2544 benchmark range", () => { @@ -134,10 +138,15 @@ describe("isBlockedHostnameOrIp", () => { expect(isBlockedHostnameOrIp("198.51.100.1", policy)).toBe(true); }); - it("blocks legacy IPv4 literal representations", () => { - expect(isBlockedHostnameOrIp("0177.0.0.1")).toBe(true); - expect(isBlockedHostnameOrIp("8.8.2056")).toBe(true); - expect(isBlockedHostnameOrIp("127.1")).toBe(true); - expect(isBlockedHostnameOrIp("2130706433")).toBe(true); + it.each(["0177.0.0.1", "8.8.2056", "127.1", "2130706433"])( + "blocks legacy IPv4 literal %s", + (address) => { + expect(isBlockedHostnameOrIp(address)).toBe(true); + }, + ); + + it("does not block ordinary hostnames", () => { + expect(isBlockedHostnameOrIp("example.com")).toBe(false); + expect(isBlockedHostnameOrIp("api.example.net")).toBe(false); }); }); From 5aa79f1ba42b52c6612d7d07a41b4af08e7264ce Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 18:21:02 +0000 Subject: [PATCH 003/640] test: harden guarded fetch redirect coverage --- src/infra/net/fetch-guard.ssrf.test.ts | 95 ++++++++++++++++++++++---- 1 file changed, 81 insertions(+), 14 deletions(-) diff --git a/src/infra/net/fetch-guard.ssrf.test.ts b/src/infra/net/fetch-guard.ssrf.test.ts index 1817cc7e7d6..f90df5271f1 100644 --- a/src/infra/net/fetch-guard.ssrf.test.ts +++ b/src/infra/net/fetch-guard.ssrf.test.ts @@ -13,6 +13,34 @@ function okResponse(body = "ok"): Response { return new Response(body, { status: 200 }); } +function getSecondRequestHeaders(fetchImpl: ReturnType): Headers { + const [, secondInit] = fetchImpl.mock.calls[1] as [string, RequestInit]; + return new Headers(secondInit.headers); +} + +async function expectRedirectFailure(params: { + url: string; + responses: Response[]; + expectedError: RegExp; + lookupFn?: NonNullable[0]["lookupFn"]>; + maxRedirects?: number; +}) { + const fetchImpl = vi.fn(); + for (const response of params.responses) { + fetchImpl.mockResolvedValueOnce(response); + } + + await expect( + fetchWithSsrFGuard({ + url: params.url, + fetchImpl, + ...(params.lookupFn ? { lookupFn: params.lookupFn } : {}), + ...(params.maxRedirects === undefined ? {} : { maxRedirects: params.maxRedirects }), + }), + ).rejects.toThrow(params.expectedError); + return fetchImpl; +} + describe("fetchWithSsrFGuard hardening", () => { type LookupFn = NonNullable[0]["lookupFn"]>; const CROSS_ORIGIN_REDIRECT_STRIPPED_HEADERS = [ @@ -33,11 +61,6 @@ describe("fetchWithSsrFGuard hardening", () => { const createPublicLookup = (): LookupFn => vi.fn(async () => [{ address: "93.184.216.34", family: 4 }]) as unknown as LookupFn; - const getSecondRequestHeaders = (fetchImpl: ReturnType): Headers => { - const [, secondInit] = fetchImpl.mock.calls[1] as [string, RequestInit]; - return new Headers(secondInit.headers); - }; - async function runProxyModeDispatcherTest(params: { mode: (typeof GUARDED_FETCH_MODE)[keyof typeof GUARDED_FETCH_MODE]; expectEnvProxy: boolean; @@ -112,15 +135,12 @@ describe("fetchWithSsrFGuard hardening", () => { it("blocks redirect chains that hop to private hosts", async () => { const lookupFn = createPublicLookup(); - const fetchImpl = vi.fn().mockResolvedValueOnce(redirectResponse("http://127.0.0.1:6379/")); - - await expect( - fetchWithSsrFGuard({ - url: "https://public.example/start", - fetchImpl, - lookupFn, - }), - ).rejects.toThrow(/private|internal|blocked/i); + const fetchImpl = await expectRedirectFailure({ + url: "https://public.example/start", + responses: [redirectResponse("http://127.0.0.1:6379/")], + expectedError: /private|internal|blocked/i, + lookupFn, + }); expect(fetchImpl).toHaveBeenCalledTimes(1); }); @@ -136,6 +156,18 @@ describe("fetchWithSsrFGuard hardening", () => { expect(fetchImpl).not.toHaveBeenCalled(); }); + it("does not let wildcard allowlists match the apex host", async () => { + const fetchImpl = vi.fn(); + await expect( + fetchWithSsrFGuard({ + url: "https://assets.example.com/pic.png", + fetchImpl, + policy: { hostnameAllowlist: ["*.assets.example.com"] }, + }), + ).rejects.toThrow(/allowlist/i); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + it("allows wildcard allowlisted hosts", async () => { const lookupFn = createPublicLookup(); const fetchImpl = vi.fn(async () => new Response("ok", { status: 200 })); @@ -211,6 +243,41 @@ describe("fetchWithSsrFGuard hardening", () => { await result.release(); }); + it.each([ + { + name: "rejects redirects without a location header", + responses: [new Response(null, { status: 302 })], + expectedError: /missing location header/i, + maxRedirects: undefined, + }, + { + name: "rejects redirect loops", + responses: [ + redirectResponse("https://public.example/next"), + redirectResponse("https://public.example/next"), + ], + expectedError: /redirect loop/i, + maxRedirects: undefined, + }, + { + name: "rejects too many redirects", + responses: [ + redirectResponse("https://public.example/one"), + redirectResponse("https://public.example/two"), + ], + expectedError: /too many redirects/i, + maxRedirects: 1, + }, + ])("$name", async ({ responses, expectedError, maxRedirects }) => { + await expectRedirectFailure({ + url: "https://public.example/start", + responses, + expectedError, + lookupFn: createPublicLookup(), + maxRedirects, + }); + }); + it("ignores env proxy by default to preserve DNS-pinned destination binding", async () => { await runProxyModeDispatcherTest({ mode: GUARDED_FETCH_MODE.STRICT, From e1b9250dea7abf57c898054a4789f521bff7fef0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 18:21:48 +0000 Subject: [PATCH 004/640] test: simplify method scope coverage --- src/gateway/method-scopes.test.ts | 39 +++++++++++++++++-------------- 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/src/gateway/method-scopes.test.ts b/src/gateway/method-scopes.test.ts index 18ff74509ee..3a91f8b8044 100644 --- a/src/gateway/method-scopes.test.ts +++ b/src/gateway/method-scopes.test.ts @@ -8,14 +8,15 @@ import { listGatewayMethods } from "./server-methods-list.js"; import { coreGatewayHandlers } from "./server-methods.js"; describe("method scope resolution", () => { - it("classifies sessions.resolve + config.schema.lookup as read and poll as write", () => { - expect(resolveLeastPrivilegeOperatorScopesForMethod("sessions.resolve")).toEqual([ - "operator.read", - ]); - expect(resolveLeastPrivilegeOperatorScopesForMethod("config.schema.lookup")).toEqual([ - "operator.read", - ]); - expect(resolveLeastPrivilegeOperatorScopesForMethod("poll")).toEqual(["operator.write"]); + it.each([ + ["sessions.resolve", ["operator.read"]], + ["config.schema.lookup", ["operator.read"]], + ["poll", ["operator.write"]], + ["config.patch", ["operator.admin"]], + ["wizard.start", ["operator.admin"]], + ["update.run", ["operator.admin"]], + ])("resolves least-privilege scopes for %s", (method, expected) => { + expect(resolveLeastPrivilegeOperatorScopesForMethod(method)).toEqual(expected); }); it("leaves node-only pending drain outside operator scopes", () => { @@ -28,16 +29,13 @@ describe("method scope resolution", () => { }); describe("operator scope authorization", () => { - it("allows read methods with operator.read or operator.write", () => { - expect(authorizeOperatorScopesForMethod("health", ["operator.read"])).toEqual({ - allowed: true, - }); - expect(authorizeOperatorScopesForMethod("health", ["operator.write"])).toEqual({ - allowed: true, - }); - expect(authorizeOperatorScopesForMethod("config.schema.lookup", ["operator.read"])).toEqual({ - allowed: true, - }); + it.each([ + ["health", ["operator.read"], { allowed: true }], + ["health", ["operator.write"], { allowed: true }], + ["config.schema.lookup", ["operator.read"], { allowed: true }], + ["config.patch", ["operator.admin"], { allowed: true }], + ])("authorizes %s for scopes %j", (method, scopes, expected) => { + expect(authorizeOperatorScopesForMethod(method, scopes)).toEqual(expected); }); it("requires operator.write for write methods", () => { @@ -63,6 +61,11 @@ describe("operator scope authorization", () => { }); describe("core gateway method classification", () => { + it("treats node-role methods as classified even without operator scopes", () => { + expect(isGatewayMethodClassified("node.pending.drain")).toBe(true); + expect(isGatewayMethodClassified("node.pending.pull")).toBe(true); + }); + it("classifies every exposed core gateway handler method", () => { const unclassified = Object.keys(coreGatewayHandlers).filter( (method) => !isGatewayMethodClassified(method), From fbc06f1926066bee90af28a65eda9ee940ad3779 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 18:24:02 +0000 Subject: [PATCH 005/640] test: simplify tailscale helper coverage --- src/infra/tailscale.test.ts | 120 ++++++++++++++++++------------------ 1 file changed, 60 insertions(+), 60 deletions(-) diff --git a/src/infra/tailscale.test.ts b/src/infra/tailscale.test.ts index db402e51521..37658c2b287 100644 --- a/src/infra/tailscale.test.ts +++ b/src/infra/tailscale.test.ts @@ -22,6 +22,13 @@ function createRuntimeWithExitError() { }; } +function expectServeFallbackCommand(params: { callArgs: string[]; sudoArgs: string[] }) { + return [ + [tailscaleBin, expect.arrayContaining(params.callArgs)], + ["sudo", expect.arrayContaining(["-n", tailscaleBin, ...params.sudoArgs])], + ]; +} + describe("tailscale helpers", () => { let envSnapshot: ReturnType; @@ -53,53 +60,62 @@ describe("tailscale helpers", () => { expect(host).toBe("100.2.2.2"); }); - it("ensureGoInstalled installs when missing and user agrees", async () => { - const exec = vi.fn().mockRejectedValueOnce(new Error("no go")).mockResolvedValue({}); // brew install go - const prompt = vi.fn().mockResolvedValue(true); - const runtime = createRuntimeWithExitError(); - await ensureGoInstalled(exec as never, prompt, runtime); - expect(exec).toHaveBeenCalledWith("brew", ["install", "go"]); + it("parses noisy JSON output from tailscale status", async () => { + const exec = vi.fn().mockResolvedValue({ + stdout: + 'warning: stale state\n{"Self":{"DNSName":"noisy.tailnet.ts.net.","TailscaleIPs":["100.9.9.9"]}}\n', + }); + const host = await getTailnetHostname(exec); + expect(host).toBe("noisy.tailnet.ts.net"); }); - it("ensureGoInstalled exits when missing and user declines install", async () => { - const exec = vi.fn().mockRejectedValueOnce(new Error("no go")); + it.each([ + { + name: "ensureGoInstalled installs when missing and user agrees", + fn: ensureGoInstalled, + missingError: new Error("no go"), + installCommand: ["brew", ["install", "go"]] as const, + promptResult: true, + }, + { + name: "ensureTailscaledInstalled installs when missing and user agrees", + fn: ensureTailscaledInstalled, + missingError: new Error("missing"), + installCommand: ["brew", ["install", "tailscale"]] as const, + promptResult: true, + }, + ])("$name", async ({ fn, missingError, installCommand, promptResult }) => { + const exec = vi.fn().mockRejectedValueOnce(missingError).mockResolvedValue({}); + const prompt = vi.fn().mockResolvedValue(promptResult); + const runtime = createRuntimeWithExitError(); + await fn(exec as never, prompt, runtime); + expect(exec).toHaveBeenCalledWith(installCommand[0], installCommand[1]); + }); + + it.each([ + { + name: "ensureGoInstalled exits when missing and user declines install", + fn: ensureGoInstalled, + missingError: new Error("no go"), + errorMessage: "Go is required to build tailscaled from source. Aborting.", + }, + { + name: "ensureTailscaledInstalled exits when missing and user declines install", + fn: ensureTailscaledInstalled, + missingError: new Error("missing"), + errorMessage: "tailscaled is required for user-space funnel. Aborting.", + }, + ])("$name", async ({ fn, missingError, errorMessage }) => { + const exec = vi.fn().mockRejectedValueOnce(missingError); const prompt = vi.fn().mockResolvedValue(false); const runtime = createRuntimeWithExitError(); - await expect(ensureGoInstalled(exec as never, prompt, runtime)).rejects.toThrow("exit 1"); - - expect(runtime.error).toHaveBeenCalledWith( - "Go is required to build tailscaled from source. Aborting.", - ); - expect(exec).toHaveBeenCalledTimes(1); - }); - - it("ensureTailscaledInstalled installs when missing and user agrees", async () => { - const exec = vi.fn().mockRejectedValueOnce(new Error("missing")).mockResolvedValue({}); - const prompt = vi.fn().mockResolvedValue(true); - const runtime = createRuntimeWithExitError(); - await ensureTailscaledInstalled(exec as never, prompt, runtime); - expect(exec).toHaveBeenCalledWith("brew", ["install", "tailscale"]); - }); - - it("ensureTailscaledInstalled exits when missing and user declines install", async () => { - const exec = vi.fn().mockRejectedValueOnce(new Error("missing")); - const prompt = vi.fn().mockResolvedValue(false); - const runtime = createRuntimeWithExitError(); - - await expect(ensureTailscaledInstalled(exec as never, prompt, runtime)).rejects.toThrow( - "exit 1", - ); - - expect(runtime.error).toHaveBeenCalledWith( - "tailscaled is required for user-space funnel. Aborting.", - ); + await expect(fn(exec as never, prompt, runtime)).rejects.toThrow("exit 1"); + expect(runtime.error).toHaveBeenCalledWith(errorMessage); expect(exec).toHaveBeenCalledTimes(1); }); it("enableTailscaleServe attempts normal first, then sudo", async () => { - // 1. First attempt fails - // 2. Second attempt (sudo) succeeds const exec = vi .fn() .mockRejectedValueOnce(new Error("permission denied")) @@ -107,19 +123,12 @@ describe("tailscale helpers", () => { await enableTailscaleServe(3000, exec as never); - expect(exec).toHaveBeenNthCalledWith( - 1, - tailscaleBin, - expect.arrayContaining(["serve", "--bg", "--yes", "3000"]), - expect.any(Object), - ); - - expect(exec).toHaveBeenNthCalledWith( - 2, - "sudo", - expect.arrayContaining(["-n", tailscaleBin, "serve", "--bg", "--yes", "3000"]), - expect.any(Object), - ); + const [firstCall, secondCall] = expectServeFallbackCommand({ + callArgs: ["serve", "--bg", "--yes", "3000"], + sudoArgs: ["serve", "--bg", "--yes", "3000"], + }); + expect(exec).toHaveBeenNthCalledWith(1, firstCall[0], firstCall[1], expect.any(Object)); + expect(exec).toHaveBeenNthCalledWith(2, secondCall[0], secondCall[1], expect.any(Object)); }); it("enableTailscaleServe does NOT use sudo if first attempt succeeds", async () => { @@ -153,10 +162,6 @@ describe("tailscale helpers", () => { }); it("ensureFunnel uses fallback for enabling", async () => { - // Mock exec: - // 1. status (success) - // 2. enable (fails) - // 3. enable sudo (success) const exec = vi .fn() .mockResolvedValueOnce({ stdout: JSON.stringify({ BackendState: "Running" }) }) // status @@ -172,22 +177,17 @@ describe("tailscale helpers", () => { await ensureFunnel(8080, exec as never, runtime, prompt); - // 1. status expect(exec).toHaveBeenNthCalledWith( 1, tailscaleBin, expect.arrayContaining(["funnel", "status", "--json"]), ); - - // 2. enable normal expect(exec).toHaveBeenNthCalledWith( 2, tailscaleBin, expect.arrayContaining(["funnel", "--yes", "--bg", "8080"]), expect.any(Object), ); - - // 3. enable sudo expect(exec).toHaveBeenNthCalledWith( 3, "sudo", From 1d300c416dfeee5923a1bba6aa4cd3ee6936a32d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 18:25:54 +0000 Subject: [PATCH 006/640] test: simplify home dir coverage --- src/infra/home-dir.test.ts | 124 +++++++++++++++++++++++++------------ 1 file changed, 86 insertions(+), 38 deletions(-) diff --git a/src/infra/home-dir.test.ts b/src/infra/home-dir.test.ts index f1f721cd7fe..3096dd1b0b4 100644 --- a/src/infra/home-dir.test.ts +++ b/src/infra/home-dir.test.ts @@ -3,37 +3,64 @@ import { describe, expect, it } from "vitest"; import { expandHomePrefix, resolveEffectiveHomeDir, resolveRequiredHomeDir } from "./home-dir.js"; describe("resolveEffectiveHomeDir", () => { - it("prefers OPENCLAW_HOME over HOME and USERPROFILE", () => { - const env = { - OPENCLAW_HOME: "/srv/openclaw-home", - HOME: "/home/other", - USERPROFILE: "C:/Users/other", - } as NodeJS.ProcessEnv; - - expect(resolveEffectiveHomeDir(env, () => "/fallback")).toBe( - path.resolve("/srv/openclaw-home"), - ); + it.each([ + { + name: "prefers OPENCLAW_HOME over HOME and USERPROFILE", + env: { + OPENCLAW_HOME: " /srv/openclaw-home ", + HOME: "/home/other", + USERPROFILE: "C:/Users/other", + } as NodeJS.ProcessEnv, + homedir: () => "/fallback", + expected: "/srv/openclaw-home", + }, + { + name: "falls back to HOME", + env: { HOME: " /home/alice " } as NodeJS.ProcessEnv, + expected: "/home/alice", + }, + { + name: "falls back to USERPROFILE when HOME is blank", + env: { + HOME: " ", + USERPROFILE: " C:/Users/alice ", + } as NodeJS.ProcessEnv, + expected: "C:/Users/alice", + }, + { + name: "falls back to homedir when env values are blank", + env: { + OPENCLAW_HOME: " ", + HOME: " ", + USERPROFILE: "\t", + } as NodeJS.ProcessEnv, + homedir: () => " /fallback ", + expected: "/fallback", + }, + ])("$name", ({ env, homedir, expected }) => { + expect(resolveEffectiveHomeDir(env, homedir)).toBe(path.resolve(expected)); }); - it("falls back to HOME then USERPROFILE then homedir", () => { - expect(resolveEffectiveHomeDir({ HOME: "/home/alice" } as NodeJS.ProcessEnv)).toBe( - path.resolve("/home/alice"), - ); - expect(resolveEffectiveHomeDir({ USERPROFILE: "C:/Users/alice" } as NodeJS.ProcessEnv)).toBe( - path.resolve("C:/Users/alice"), - ); - expect(resolveEffectiveHomeDir({} as NodeJS.ProcessEnv, () => "/fallback")).toBe( - path.resolve("/fallback"), - ); - }); - - it("expands OPENCLAW_HOME when set to ~", () => { - const env = { - OPENCLAW_HOME: "~/svc", - HOME: "/home/alice", - } as NodeJS.ProcessEnv; - - expect(resolveEffectiveHomeDir(env)).toBe(path.resolve("/home/alice/svc")); + it.each([ + { + name: "expands ~/ using HOME", + env: { + OPENCLAW_HOME: "~/svc", + HOME: "/home/alice", + } as NodeJS.ProcessEnv, + expected: "/home/alice/svc", + }, + { + name: "expands ~\\\\ using USERPROFILE", + env: { + OPENCLAW_HOME: "~\\svc", + HOME: " ", + USERPROFILE: "C:/Users/alice", + } as NodeJS.ProcessEnv, + expected: "C:/Users/alice\\svc", + }, + ])("$name", ({ env, expected }) => { + expect(resolveEffectiveHomeDir(env)).toBe(path.resolve(expected)); }); }); @@ -64,14 +91,35 @@ describe("resolveRequiredHomeDir", () => { }); describe("expandHomePrefix", () => { - it("expands tilde using effective home", () => { - const value = expandHomePrefix("~/x", { - env: { OPENCLAW_HOME: "/srv/openclaw-home" } as NodeJS.ProcessEnv, - }); - expect(value).toBe(`${path.resolve("/srv/openclaw-home")}/x`); - }); - - it("keeps non-tilde values unchanged", () => { - expect(expandHomePrefix("/tmp/x")).toBe("/tmp/x"); + it.each([ + { + name: "expands ~/ using effective home", + input: "~/x", + opts: { + env: { OPENCLAW_HOME: "/srv/openclaw-home" } as NodeJS.ProcessEnv, + }, + expected: `${path.resolve("/srv/openclaw-home")}/x`, + }, + { + name: "expands exact ~ using explicit home", + input: "~", + opts: { home: " /srv/openclaw-home " }, + expected: path.resolve("/srv/openclaw-home"), + }, + { + name: "expands ~\\\\ using resolved env home", + input: "~\\x", + opts: { + env: { HOME: "/home/alice" } as NodeJS.ProcessEnv, + }, + expected: `${path.resolve("/home/alice")}\\x`, + }, + { + name: "keeps non-tilde values unchanged", + input: "/tmp/x", + expected: "/tmp/x", + }, + ])("$name", ({ input, opts, expected }) => { + expect(expandHomePrefix(input, opts)).toBe(expected); }); }); From cc5168b5c31ff21aaf50263d90aac65ae17cacd6 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 13 Mar 2026 11:24:40 -0700 Subject: [PATCH 007/640] Fix plugin update dependency failures and dedupe warnings --- src/infra/install-package-dir.test.ts | 54 +++++++++++++++++++++++++++ src/infra/install-package-dir.ts | 4 +- src/plugins/loader.test.ts | 24 ++++++++++++ src/plugins/loader.ts | 8 ++++ 4 files changed, 89 insertions(+), 1 deletion(-) diff --git a/src/infra/install-package-dir.test.ts b/src/infra/install-package-dir.test.ts index 1386f6074fa..cacbcadf5cc 100644 --- a/src/infra/install-package-dir.test.ts +++ b/src/infra/install-package-dir.test.ts @@ -3,8 +3,17 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { runCommandWithTimeout } from "../process/exec.js"; import { installPackageDir } from "./install-package-dir.js"; +vi.mock("../process/exec.js", async () => { + const actual = await vi.importActual("../process/exec.js"); + return { + ...actual, + runCommandWithTimeout: vi.fn(actual.runCommandWithTimeout), + }; +}); + async function listMatchingDirs(root: string, prefix: string): Promise { const entries = await fs.readdir(root, { withFileTypes: true }); return entries @@ -263,4 +272,49 @@ describe("installPackageDir", () => { const backupRoot = path.join(preservedInstallRoot, ".openclaw-install-backups"); await expect(fs.readdir(backupRoot)).resolves.toHaveLength(1); }); + + it("installs peer dependencies for isolated plugin package installs", async () => { + fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-install-package-dir-")); + const sourceDir = path.join(fixtureRoot, "source"); + const targetDir = path.join(fixtureRoot, "plugins", "demo"); + await fs.mkdir(sourceDir, { recursive: true }); + await fs.writeFile( + path.join(sourceDir, "package.json"), + JSON.stringify({ + name: "demo-plugin", + version: "1.0.0", + dependencies: { + zod: "^4.0.0", + }, + }), + "utf-8", + ); + + vi.mocked(runCommandWithTimeout).mockResolvedValue({ + stdout: "", + stderr: "", + code: 0, + signal: null, + killed: false, + termination: "exit", + }); + + const result = await installPackageDir({ + sourceDir, + targetDir, + mode: "install", + timeoutMs: 1_000, + copyErrorPrefix: "failed to copy plugin", + hasDeps: true, + depsLogMessage: "Installing deps…", + }); + + expect(result).toEqual({ ok: true }); + expect(vi.mocked(runCommandWithTimeout)).toHaveBeenCalledWith( + ["npm", "install", "--omit=dev", "--silent", "--ignore-scripts"], + expect.objectContaining({ + cwd: expect.stringContaining(".openclaw-install-stage-"), + }), + ); + }); }); diff --git a/src/infra/install-package-dir.ts b/src/infra/install-package-dir.ts index 17878599160..45611b17ffe 100644 --- a/src/infra/install-package-dir.ts +++ b/src/infra/install-package-dir.ts @@ -189,7 +189,9 @@ export async function installPackageDir(params: { await sanitizeManifestForNpmInstall(stageDir); params.logger?.info?.(params.depsLogMessage); const npmRes = await runCommandWithTimeout( - ["npm", "install", "--omit=dev", "--omit=peer", "--silent", "--ignore-scripts"], + // Plugins install into isolated directories, so omitting peer deps can strip + // runtime requirements that npm would otherwise materialize for the package. + ["npm", "install", "--omit=dev", "--silent", "--ignore-scripts"], { timeoutMs: Math.max(params.timeoutMs, 300_000), cwd: stageDir, diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 031d75b31b7..d2ecfab18be 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -1472,6 +1472,30 @@ describe("loadOpenClawPlugins", () => { ).toBe(true); }); + it("dedupes the open allowlist warning for repeated loads of the same plugin set", () => { + useNoBundledPlugins(); + clearPluginLoaderCache(); + const plugin = writePlugin({ + id: "warn-open-allow-once", + body: `module.exports = { id: "warn-open-allow-once", register() {} };`, + }); + const warnings: string[] = []; + const options = { + cache: false, + logger: createWarningLogger(warnings), + config: { + plugins: { + load: { paths: [plugin.file] }, + }, + }, + } as const; + + loadOpenClawPlugins(options); + loadOpenClawPlugins(options); + + expect(warnings.filter((msg) => msg.includes("plugins.allow is empty"))).toHaveLength(1); + }); + it("does not auto-load workspace-discovered plugins unless explicitly trusted", () => { useNoBundledPlugins(); const workspaceDir = makeTempDir(); diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 40983b43347..75882a5105b 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -51,9 +51,11 @@ export type PluginLoadOptions = { const MAX_PLUGIN_REGISTRY_CACHE_ENTRIES = 32; const registryCache = new Map(); +const openAllowlistWarningCache = new Set(); export function clearPluginLoaderCache(): void { registryCache.clear(); + openAllowlistWarningCache.clear(); } const defaultLogger = () => createSubsystemLogger("plugins"); @@ -455,6 +457,7 @@ function warnWhenAllowlistIsOpen(params: { logger: PluginLogger; pluginsEnabled: boolean; allow: string[]; + warningCacheKey: string; discoverablePlugins: Array<{ id: string; source: string; origin: PluginRecord["origin"] }>; }) { if (!params.pluginsEnabled) { @@ -467,11 +470,15 @@ function warnWhenAllowlistIsOpen(params: { if (nonBundled.length === 0) { return; } + if (openAllowlistWarningCache.has(params.warningCacheKey)) { + return; + } const preview = nonBundled .slice(0, 6) .map((entry) => `${entry.id} (${entry.source})`) .join(", "); const extra = nonBundled.length > 6 ? ` (+${nonBundled.length - 6} more)` : ""; + openAllowlistWarningCache.add(params.warningCacheKey); params.logger.warn( `[plugins] plugins.allow is empty; discovered non-bundled plugins may auto-load: ${preview}${extra}. Set plugins.allow to explicit trusted ids.`, ); @@ -598,6 +605,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi logger, pluginsEnabled: normalized.enabled, allow: normalized.allow, + warningCacheKey: cacheKey, discoverablePlugins: manifestRegistry.plugins.map((plugin) => ({ id: plugin.id, source: plugin.source, From f5ab0c1d32d5ee9dbcf80f52e089845ec757b49c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 18:26:36 +0000 Subject: [PATCH 008/640] test: tighten retry helper coverage --- src/infra/retry.test.ts | 51 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 45 insertions(+), 6 deletions(-) diff --git a/src/infra/retry.test.ts b/src/infra/retry.test.ts index dfba7cabd6b..0eafafa6536 100644 --- a/src/infra/retry.test.ts +++ b/src/infra/retry.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { retryAsync } from "./retry.js"; +import { resolveRetryConfig, retryAsync } from "./retry.js"; async function runRetryAfterCase(params: { minDelayMs: number; @@ -48,22 +48,34 @@ describe("retryAsync", () => { }); it("stops when shouldRetry returns false", async () => { - const fn = vi.fn().mockRejectedValue(new Error("boom")); - await expect(retryAsync(fn, { attempts: 3, shouldRetry: () => false })).rejects.toThrow("boom"); + const err = new Error("boom"); + const fn = vi.fn().mockRejectedValue(err); + const shouldRetry = vi.fn(() => false); + await expect(retryAsync(fn, { attempts: 3, shouldRetry })).rejects.toThrow("boom"); expect(fn).toHaveBeenCalledTimes(1); + expect(shouldRetry).toHaveBeenCalledWith(err, 1); }); - it("calls onRetry before retrying", async () => { - const fn = vi.fn().mockRejectedValueOnce(new Error("boom")).mockResolvedValueOnce("ok"); + it("calls onRetry with retry metadata before retrying", async () => { + const err = new Error("boom"); + const fn = vi.fn().mockRejectedValueOnce(err).mockResolvedValueOnce("ok"); const onRetry = vi.fn(); const res = await retryAsync(fn, { attempts: 2, minDelayMs: 0, maxDelayMs: 0, + label: "telegram", onRetry, }); expect(res).toBe("ok"); - expect(onRetry).toHaveBeenCalledWith(expect.objectContaining({ attempt: 1, maxAttempts: 2 })); + expect(onRetry).toHaveBeenCalledWith( + expect.objectContaining({ + attempt: 1, + maxAttempts: 2, + err, + label: "telegram", + }), + ); }); it("clamps attempts to at least 1", async () => { @@ -89,3 +101,30 @@ describe("retryAsync", () => { expect(delays[0]).toBe(250); }); }); + +describe("resolveRetryConfig", () => { + it.each([ + { + name: "rounds attempts and delays", + overrides: { attempts: 2.6, minDelayMs: 10.4, maxDelayMs: 99.8, jitter: 0.4 }, + expected: { attempts: 3, minDelayMs: 10, maxDelayMs: 100, jitter: 0.4 }, + }, + { + name: "clamps attempts to at least one and maxDelayMs to minDelayMs", + overrides: { attempts: 0, minDelayMs: 250, maxDelayMs: 100, jitter: -1 }, + expected: { attempts: 1, minDelayMs: 250, maxDelayMs: 250, jitter: 0 }, + }, + { + name: "falls back for non-finite overrides and caps jitter at one", + overrides: { + attempts: Number.NaN, + minDelayMs: Number.POSITIVE_INFINITY, + maxDelayMs: Number.NaN, + jitter: 2, + }, + expected: { attempts: 3, minDelayMs: 300, maxDelayMs: 30000, jitter: 1 }, + }, + ])("$name", ({ overrides, expected }) => { + expect(resolveRetryConfig(undefined, overrides)).toEqual(expected); + }); +}); From cc3846d1b57e074bb75065608bfa4846b4c6e227 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 18:28:05 +0000 Subject: [PATCH 009/640] test: simplify numeric parsing coverage --- src/infra/parse-finite-number.test.ts | 59 +++++++++++++++------------ 1 file changed, 32 insertions(+), 27 deletions(-) diff --git a/src/infra/parse-finite-number.test.ts b/src/infra/parse-finite-number.test.ts index 99b093dfe3b..d3c838cf61a 100644 --- a/src/infra/parse-finite-number.test.ts +++ b/src/infra/parse-finite-number.test.ts @@ -7,47 +7,52 @@ import { } from "./parse-finite-number.js"; describe("parseFiniteNumber", () => { - it("returns finite numbers", () => { - expect(parseFiniteNumber(42)).toBe(42); + it.each([ + { value: 42, expected: 42 }, + { value: "3.14", expected: 3.14 }, + { value: " 3.14ms", expected: 3.14 }, + ])("parses %j", ({ value, expected }) => { + expect(parseFiniteNumber(value)).toBe(expected); }); - it("parses numeric strings", () => { - expect(parseFiniteNumber("3.14")).toBe(3.14); - }); - - it("returns undefined for non-finite or non-numeric values", () => { - expect(parseFiniteNumber(Number.NaN)).toBeUndefined(); - expect(parseFiniteNumber(Number.POSITIVE_INFINITY)).toBeUndefined(); - expect(parseFiniteNumber("not-a-number")).toBeUndefined(); - expect(parseFiniteNumber(null)).toBeUndefined(); - }); + it.each([Number.NaN, Number.POSITIVE_INFINITY, "not-a-number", " ", null])( + "returns undefined for %j", + (value) => { + expect(parseFiniteNumber(value)).toBeUndefined(); + }, + ); }); describe("parseStrictInteger", () => { - it("parses exact integers", () => { - expect(parseStrictInteger("42")).toBe(42); - expect(parseStrictInteger(" -7 ")).toBe(-7); + it.each([ + { value: "42", expected: 42 }, + { value: " -7 ", expected: -7 }, + { value: 12, expected: 12 }, + ])("parses %j", ({ value, expected }) => { + expect(parseStrictInteger(value)).toBe(expected); }); - it("rejects junk prefixes and suffixes", () => { - expect(parseStrictInteger("42ms")).toBeUndefined(); - expect(parseStrictInteger("0abc")).toBeUndefined(); - expect(parseStrictInteger("1.5")).toBeUndefined(); + it.each(["42ms", "0abc", "1.5", " ", Number.MAX_SAFE_INTEGER + 1])("rejects %j", (value) => { + expect(parseStrictInteger(value)).toBeUndefined(); }); }); describe("parseStrictPositiveInteger", () => { - it("accepts only positive integers", () => { - expect(parseStrictPositiveInteger("9")).toBe(9); - expect(parseStrictPositiveInteger("0")).toBeUndefined(); - expect(parseStrictPositiveInteger("-1")).toBeUndefined(); + it.each([ + { value: "9", expected: 9 }, + { value: "0", expected: undefined }, + { value: "-1", expected: undefined }, + ])("parses %j", ({ value, expected }) => { + expect(parseStrictPositiveInteger(value)).toBe(expected); }); }); describe("parseStrictNonNegativeInteger", () => { - it("accepts zero and positive integers only", () => { - expect(parseStrictNonNegativeInteger("0")).toBe(0); - expect(parseStrictNonNegativeInteger("9")).toBe(9); - expect(parseStrictNonNegativeInteger("-1")).toBeUndefined(); + it.each([ + { value: "0", expected: 0 }, + { value: "9", expected: 9 }, + { value: "-1", expected: undefined }, + ])("parses %j", ({ value, expected }) => { + expect(parseStrictNonNegativeInteger(value)).toBe(expected); }); }); From bc9a9cf9723c90d6fc9a587d03ca9bfea8700650 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 18:29:09 +0000 Subject: [PATCH 010/640] test: expand update channel helper coverage --- src/infra/update-channels.test.ts | 187 +++++++++++++++++++++++++++--- 1 file changed, 173 insertions(+), 14 deletions(-) diff --git a/src/infra/update-channels.test.ts b/src/infra/update-channels.test.ts index b17133bb7fa..c13476f356a 100644 --- a/src/infra/update-channels.test.ts +++ b/src/infra/update-channels.test.ts @@ -1,19 +1,178 @@ import { describe, expect, it } from "vitest"; -import { isBetaTag, isStableTag } from "./update-channels.js"; +import { + channelToNpmTag, + formatUpdateChannelLabel, + isBetaTag, + isStableTag, + normalizeUpdateChannel, + resolveEffectiveUpdateChannel, + resolveUpdateChannelDisplay, +} from "./update-channels.js"; describe("update-channels tag detection", () => { - it("recognizes both -beta and .beta formats", () => { - expect(isBetaTag("v2026.2.24-beta.1")).toBe(true); - expect(isBetaTag("v2026.2.24.beta.1")).toBe(true); - }); - - it("keeps legacy -x tags stable", () => { - expect(isBetaTag("v2026.2.24-1")).toBe(false); - expect(isStableTag("v2026.2.24-1")).toBe(true); - }); - - it("does not false-positive on non-beta words", () => { - expect(isBetaTag("v2026.2.24-alphabeta.1")).toBe(false); - expect(isStableTag("v2026.2.24")).toBe(true); + it.each([ + { tag: "v2026.2.24-beta.1", beta: true }, + { tag: "v2026.2.24.beta.1", beta: true }, + { tag: "v2026.2.24-BETA-1", beta: true }, + { tag: "v2026.2.24-1", beta: false }, + { tag: "v2026.2.24-alphabeta.1", beta: false }, + { tag: "v2026.2.24", beta: false }, + ])("classifies $tag", ({ tag, beta }) => { + expect(isBetaTag(tag)).toBe(beta); + expect(isStableTag(tag)).toBe(!beta); + }); +}); + +describe("normalizeUpdateChannel", () => { + it.each([ + { value: "stable", expected: "stable" }, + { value: " BETA ", expected: "beta" }, + { value: "Dev", expected: "dev" }, + { value: "", expected: null }, + { value: " nightly ", expected: null }, + { value: null, expected: null }, + { value: undefined, expected: null }, + ])("normalizes %j", ({ value, expected }) => { + expect(normalizeUpdateChannel(value)).toBe(expected); + }); +}); + +describe("channelToNpmTag", () => { + it.each([ + { channel: "stable", expected: "latest" }, + { channel: "beta", expected: "beta" }, + { channel: "dev", expected: "dev" }, + ])("maps $channel to $expected", ({ channel, expected }) => { + expect(channelToNpmTag(channel)).toBe(expected); + }); +}); + +describe("resolveEffectiveUpdateChannel", () => { + it.each([ + { + name: "prefers config over git metadata", + params: { + configChannel: "beta", + installKind: "git" as const, + git: { tag: "v2026.2.24", branch: "feature/test" }, + }, + expected: { channel: "beta", source: "config" }, + }, + { + name: "uses beta git tag", + params: { + installKind: "git" as const, + git: { tag: "v2026.2.24-beta.1" }, + }, + expected: { channel: "beta", source: "git-tag" }, + }, + { + name: "treats non-beta git tag as stable", + params: { + installKind: "git" as const, + git: { tag: "v2026.2.24-1" }, + }, + expected: { channel: "stable", source: "git-tag" }, + }, + { + name: "uses non-HEAD git branch as dev", + params: { + installKind: "git" as const, + git: { branch: "feature/test" }, + }, + expected: { channel: "dev", source: "git-branch" }, + }, + { + name: "falls back for detached HEAD git installs", + params: { + installKind: "git" as const, + git: { branch: "HEAD" }, + }, + expected: { channel: "dev", source: "default" }, + }, + { + name: "defaults package installs to stable", + params: { installKind: "package" as const }, + expected: { channel: "stable", source: "default" }, + }, + { + name: "defaults unknown installs to stable", + params: { installKind: "unknown" as const }, + expected: { channel: "stable", source: "default" }, + }, + ])("$name", ({ params, expected }) => { + expect(resolveEffectiveUpdateChannel(params)).toEqual(expected); + }); +}); + +describe("formatUpdateChannelLabel", () => { + it.each([ + { + name: "formats config labels", + params: { channel: "beta", source: "config" as const }, + expected: "beta (config)", + }, + { + name: "formats git tag labels with tag", + params: { + channel: "stable", + source: "git-tag" as const, + gitTag: "v2026.2.24", + }, + expected: "stable (v2026.2.24)", + }, + { + name: "formats git tag labels without tag", + params: { channel: "stable", source: "git-tag" as const }, + expected: "stable (tag)", + }, + { + name: "formats git branch labels with branch", + params: { + channel: "dev", + source: "git-branch" as const, + gitBranch: "feature/test", + }, + expected: "dev (feature/test)", + }, + { + name: "formats git branch labels without branch", + params: { channel: "dev", source: "git-branch" as const }, + expected: "dev (branch)", + }, + { + name: "formats default labels", + params: { channel: "stable", source: "default" as const }, + expected: "stable (default)", + }, + ])("$name", ({ params, expected }) => { + expect(formatUpdateChannelLabel(params)).toBe(expected); + }); +}); + +describe("resolveUpdateChannelDisplay", () => { + it("includes the derived label for git branches", () => { + expect( + resolveUpdateChannelDisplay({ + installKind: "git", + gitBranch: "feature/test", + }), + ).toEqual({ + channel: "dev", + source: "git-branch", + label: "dev (feature/test)", + }); + }); + + it("does not synthesize git metadata when both tag and branch are missing", () => { + expect( + resolveUpdateChannelDisplay({ + installKind: "package", + }), + ).toEqual({ + channel: "stable", + source: "default", + label: "stable (default)", + }); }); }); From f0a266cb860c294a5b0fd9f72e75d951858137a1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 18:29:56 +0000 Subject: [PATCH 011/640] test: expand archive path helper coverage --- src/infra/archive-path.test.ts | 99 +++++++++++++++++++++++++++++----- 1 file changed, 85 insertions(+), 14 deletions(-) diff --git a/src/infra/archive-path.test.ts b/src/infra/archive-path.test.ts index bc900c6964c..02ed7f4ff2d 100644 --- a/src/infra/archive-path.test.ts +++ b/src/infra/archive-path.test.ts @@ -1,18 +1,71 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; import { + isWindowsDrivePath, + normalizeArchiveEntryPath, resolveArchiveOutputPath, stripArchivePath, validateArchiveEntryPath, } from "./archive-path.js"; describe("archive path helpers", () => { - it("uses custom escape labels in traversal errors", () => { + it.each([ + { value: "C:\\temp\\file.txt", expected: true }, + { value: "D:/temp/file.txt", expected: true }, + { value: "tmp/file.txt", expected: false }, + { value: "/tmp/file.txt", expected: false }, + ])("detects Windows drive paths for %j", ({ value, expected }) => { + expect(isWindowsDrivePath(value)).toBe(expected); + }); + + it.each([ + { raw: "dir\\file.txt", expected: "dir/file.txt" }, + { raw: "dir/file.txt", expected: "dir/file.txt" }, + ])("normalizes archive separators for %j", ({ raw, expected }) => { + expect(normalizeArchiveEntryPath(raw)).toBe(expected); + }); + + it.each(["", ".", "./"])("accepts empty-like entry paths: %j", (entryPath) => { + expect(() => validateArchiveEntryPath(entryPath)).not.toThrow(); + }); + + it.each([ + { + name: "uses custom escape labels in traversal errors", + entryPath: "../escape.txt", + message: "archive entry escapes targetDir: ../escape.txt", + }, + { + name: "rejects Windows drive paths", + entryPath: "C:\\temp\\file.txt", + message: "archive entry uses a drive path: C:\\temp\\file.txt", + }, + { + name: "rejects absolute paths after normalization", + entryPath: "/tmp/file.txt", + message: "archive entry is absolute: /tmp/file.txt", + }, + { + name: "rejects double-slash absolute paths after normalization", + entryPath: "\\\\server\\share.txt", + message: "archive entry is absolute: \\\\server\\share.txt", + }, + ])("$name", ({ entryPath, message }) => { expect(() => - validateArchiveEntryPath("../escape.txt", { + validateArchiveEntryPath(entryPath, { escapeLabel: "targetDir", }), - ).toThrow("archive entry escapes targetDir: ../escape.txt"); + ).toThrow(message); + }); + + it.each([ + { entryPath: "a/../escape.txt", stripComponents: 1, expected: "../escape.txt" }, + { entryPath: "a//b/file.txt", stripComponents: 1, expected: "b/file.txt" }, + { entryPath: "./", stripComponents: 0, expected: null }, + { entryPath: "a", stripComponents: 3, expected: null }, + { entryPath: "dir\\sub\\file.txt", stripComponents: 1, expected: "sub/file.txt" }, + ])("strips archive paths for %j", ({ entryPath, stripComponents, expected }) => { + expect(stripArchivePath(entryPath, stripComponents)).toBe(expected); }); it("preserves strip-induced traversal for follow-up validation", () => { @@ -25,22 +78,40 @@ describe("archive path helpers", () => { ).toThrow("archive entry escapes targetDir: ../escape.txt"); }); - it("keeps resolved output paths inside the root", () => { - const rootDir = path.join(path.sep, "tmp", "archive-root"); - const safe = resolveArchiveOutputPath({ - rootDir, + it.each([ + { + name: "keeps resolved output paths inside the root", relPath: "sub/file.txt", originalPath: "sub/file.txt", - }); - expect(safe).toBe(path.resolve(rootDir, "sub/file.txt")); + expected: path.resolve(path.join(path.sep, "tmp", "archive-root"), "sub/file.txt"), + }, + { + name: "rejects output paths that escape the root", + relPath: "../escape.txt", + originalPath: "../escape.txt", + escapeLabel: "targetDir", + message: "archive entry escapes targetDir: ../escape.txt", + }, + ])("$name", ({ relPath, originalPath, escapeLabel, expected, message }) => { + const rootDir = path.join(path.sep, "tmp", "archive-root"); + if (message) { + expect(() => + resolveArchiveOutputPath({ + rootDir, + relPath, + originalPath, + escapeLabel, + }), + ).toThrow(message); + return; + } - expect(() => + expect( resolveArchiveOutputPath({ rootDir, - relPath: "../escape.txt", - originalPath: "../escape.txt", - escapeLabel: "targetDir", + relPath, + originalPath, }), - ).toThrow("archive entry escapes targetDir: ../escape.txt"); + ).toBe(expected); }); }); From 8cef6f21203f03190d59fd4351c7f5cf8416ffde Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 18:30:36 +0000 Subject: [PATCH 012/640] test: tighten cli root option coverage --- src/infra/cli-root-options.test.ts | 40 +++++++++++++++++++++--------- 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/src/infra/cli-root-options.test.ts b/src/infra/cli-root-options.test.ts index 514548586f7..e6907984ec0 100644 --- a/src/infra/cli-root-options.test.ts +++ b/src/infra/cli-root-options.test.ts @@ -1,16 +1,32 @@ import { describe, expect, it } from "vitest"; -import { consumeRootOptionToken } from "./cli-root-options.js"; +import { consumeRootOptionToken, isValueToken } from "./cli-root-options.js"; -describe("consumeRootOptionToken", () => { - it("consumes boolean and inline root options", () => { - expect(consumeRootOptionToken(["--dev"], 0)).toBe(1); - expect(consumeRootOptionToken(["--profile=work"], 0)).toBe(1); - expect(consumeRootOptionToken(["--log-level=debug"], 0)).toBe(1); - }); - - it("consumes split root value option only when next token is a value", () => { - expect(consumeRootOptionToken(["--profile", "work"], 0)).toBe(2); - expect(consumeRootOptionToken(["--profile", "--no-color"], 0)).toBe(1); - expect(consumeRootOptionToken(["--profile", "--"], 0)).toBe(1); +describe("isValueToken", () => { + it.each([ + { value: "work", expected: true }, + { value: "-1", expected: true }, + { value: "-1.5", expected: true }, + { value: "--", expected: false }, + { value: "--dev", expected: false }, + { value: undefined, expected: false }, + ])("classifies %j", ({ value, expected }) => { + expect(isValueToken(value)).toBe(expected); + }); +}); + +describe("consumeRootOptionToken", () => { + it.each([ + { args: ["--dev"], index: 0, expected: 1 }, + { args: ["--profile=work"], index: 0, expected: 1 }, + { args: ["--log-level=debug"], index: 0, expected: 1 }, + { args: ["--profile", "work"], index: 0, expected: 2 }, + { args: ["--profile", "-1"], index: 0, expected: 2 }, + { args: ["--log-level", "-1.5"], index: 0, expected: 2 }, + { args: ["--profile", "--no-color"], index: 0, expected: 1 }, + { args: ["--profile", "--"], index: 0, expected: 1 }, + { args: ["--unknown"], index: 0, expected: 0 }, + { args: [], index: 0, expected: 0 }, + ])("consumes %j at %d", ({ args, index, expected }) => { + expect(consumeRootOptionToken(args, index)).toBe(expected); }); }); From 9c343fb3db5c2d615793f62986b099d401e86989 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 18:31:59 +0000 Subject: [PATCH 013/640] test: tighten small infra helper coverage --- src/infra/path-safety.test.ts | 20 +++++++++++++------- src/infra/plain-object.test.ts | 24 ++++++++++++------------ src/infra/system-message.test.ts | 22 +++++++++++++++------- 3 files changed, 40 insertions(+), 26 deletions(-) diff --git a/src/infra/path-safety.test.ts b/src/infra/path-safety.test.ts index b05eeced172..9c85fbac63e 100644 --- a/src/infra/path-safety.test.ts +++ b/src/infra/path-safety.test.ts @@ -3,14 +3,20 @@ import { describe, expect, it } from "vitest"; import { isWithinDir, resolveSafeBaseDir } from "./path-safety.js"; describe("path-safety", () => { - it("resolves safe base dir with trailing separator", () => { - const base = resolveSafeBaseDir("/tmp/demo"); - expect(base.endsWith(path.sep)).toBe(true); + it.each([ + { rootDir: "/tmp/demo", expected: `${path.resolve("/tmp/demo")}${path.sep}` }, + { rootDir: `/tmp/demo${path.sep}`, expected: `${path.resolve("/tmp/demo")}${path.sep}` }, + ])("resolves safe base dir for %j", ({ rootDir, expected }) => { + expect(resolveSafeBaseDir(rootDir)).toBe(expected); }); - it("checks directory containment", () => { - expect(isWithinDir("/tmp/demo", "/tmp/demo")).toBe(true); - expect(isWithinDir("/tmp/demo", "/tmp/demo/sub/file.txt")).toBe(true); - expect(isWithinDir("/tmp/demo", "/tmp/demo/../escape.txt")).toBe(false); + it.each([ + { rootDir: "/tmp/demo", targetPath: "/tmp/demo", expected: true }, + { rootDir: "/tmp/demo", targetPath: "/tmp/demo/sub/file.txt", expected: true }, + { rootDir: "/tmp/demo", targetPath: "/tmp/demo/../escape.txt", expected: false }, + { rootDir: "/tmp/demo", targetPath: "/tmp/demo-sibling/file.txt", expected: false }, + { rootDir: "/tmp/demo", targetPath: "sub/file.txt", expected: false }, + ])("checks containment for %j", ({ rootDir, targetPath, expected }) => { + expect(isWithinDir(rootDir, targetPath)).toBe(expected); }); }); diff --git a/src/infra/plain-object.test.ts b/src/infra/plain-object.test.ts index b87e555b21a..892e5c89fab 100644 --- a/src/infra/plain-object.test.ts +++ b/src/infra/plain-object.test.ts @@ -2,17 +2,17 @@ import { describe, expect, it } from "vitest"; import { isPlainObject } from "./plain-object.js"; describe("isPlainObject", () => { - it("accepts plain objects", () => { - expect(isPlainObject({})).toBe(true); - expect(isPlainObject({ a: 1 })).toBe(true); - }); + it.each([{}, { a: 1 }, Object.create(null), new (class X {})()])( + "accepts object-tag values: %j", + (value) => { + expect(isPlainObject(value)).toBe(true); + }, + ); - it("rejects non-plain values", () => { - expect(isPlainObject(null)).toBe(false); - expect(isPlainObject([])).toBe(false); - expect(isPlainObject(new Date())).toBe(false); - expect(isPlainObject(/re/)).toBe(false); - expect(isPlainObject("x")).toBe(false); - expect(isPlainObject(42)).toBe(false); - }); + it.each([null, [], new Date(), /re/, "x", 42, () => null, new Map()])( + "rejects non-plain values: %j", + (value) => { + expect(isPlainObject(value)).toBe(false); + }, + ); }); diff --git a/src/infra/system-message.test.ts b/src/infra/system-message.test.ts index b0c32f31c35..5cb1d4be87f 100644 --- a/src/infra/system-message.test.ts +++ b/src/infra/system-message.test.ts @@ -2,8 +2,21 @@ import { describe, expect, it } from "vitest"; import { SYSTEM_MARK, hasSystemMark, prefixSystemMessage } from "./system-message.js"; describe("system-message", () => { - it("prepends the system mark once", () => { - expect(prefixSystemMessage("thread notice")).toBe(`${SYSTEM_MARK} thread notice`); + it.each([ + { input: "thread notice", expected: `${SYSTEM_MARK} thread notice` }, + { input: ` thread notice `, expected: `${SYSTEM_MARK} thread notice` }, + { input: " ", expected: "" }, + ])("prefixes %j", ({ input, expected }) => { + expect(prefixSystemMessage(input)).toBe(expected); + }); + + it.each([ + { input: `${SYSTEM_MARK} already prefixed`, expected: true }, + { input: ` ${SYSTEM_MARK} hello`, expected: true }, + { input: "", expected: false }, + { input: "hello", expected: false }, + ])("detects marks for %j", ({ input, expected }) => { + expect(hasSystemMark(input)).toBe(expected); }); it("does not double-prefix messages that already have the mark", () => { @@ -11,9 +24,4 @@ describe("system-message", () => { `${SYSTEM_MARK} already prefixed`, ); }); - - it("detects marked system text after trim normalization", () => { - expect(hasSystemMark(` ${SYSTEM_MARK} hello`)).toBe(true); - expect(hasSystemMark("hello")).toBe(false); - }); }); From 84a2a289e6a989f25ab1467e5469872972135f15 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 18:32:45 +0000 Subject: [PATCH 014/640] test: tighten scp host coverage --- src/infra/scp-host.test.ts | 38 +++++++++++++++++++++++++++----------- 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/src/infra/scp-host.test.ts b/src/infra/scp-host.test.ts index 178c738adfb..78498b997ce 100644 --- a/src/infra/scp-host.test.ts +++ b/src/infra/scp-host.test.ts @@ -2,18 +2,34 @@ import { describe, expect, it } from "vitest"; import { isSafeScpRemoteHost, normalizeScpRemoteHost } from "./scp-host.js"; describe("scp remote host", () => { - it("accepts host and user@host forms", () => { - expect(normalizeScpRemoteHost("gateway-host")).toBe("gateway-host"); - expect(normalizeScpRemoteHost("bot@gateway-host")).toBe("bot@gateway-host"); - expect(normalizeScpRemoteHost("bot@192.168.64.3")).toBe("bot@192.168.64.3"); - expect(normalizeScpRemoteHost("bot@[fe80::1]")).toBe("bot@[fe80::1]"); + it.each([ + { value: "gateway-host", expected: "gateway-host" }, + { value: " bot@gateway-host ", expected: "bot@gateway-host" }, + { value: "bot@192.168.64.3", expected: "bot@192.168.64.3" }, + { value: "bot@[fe80::1]", expected: "bot@[fe80::1]" }, + ])("normalizes safe hosts for %j", ({ value, expected }) => { + expect(normalizeScpRemoteHost(value)).toBe(expected); }); - it("rejects unsafe host tokens", () => { - expect(isSafeScpRemoteHost("-oProxyCommand=whoami")).toBe(false); - expect(isSafeScpRemoteHost("bot@gateway-host -oStrictHostKeyChecking=no")).toBe(false); - expect(isSafeScpRemoteHost("bot@host:22")).toBe(false); - expect(isSafeScpRemoteHost("bot@/tmp/host")).toBe(false); - expect(isSafeScpRemoteHost("bot@@host")).toBe(false); + it.each([ + null, + undefined, + "", + " ", + "-oProxyCommand=whoami", + "bot@gateway-host -oStrictHostKeyChecking=no", + "bot@host:22", + "bot@/tmp/host", + "bot@@host", + "@host", + "bot@", + "bot@host\\name", + "bot@-gateway-host", + "bot@fe80::1", + "bot@[fe80::1%en0]", + "bot name@gateway-host", + ])("rejects unsafe host tokens: %j", (value) => { + expect(normalizeScpRemoteHost(value)).toBeUndefined(); + expect(isSafeScpRemoteHost(value)).toBe(false); }); }); From 5ea03efe92d639169769762e5b5082b54d60dbb6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 18:33:59 +0000 Subject: [PATCH 015/640] fix: harden windows gateway lifecycle --- src/cli/daemon-cli/lifecycle.test.ts | 106 +++------- src/cli/daemon-cli/lifecycle.ts | 89 +-------- src/cli/daemon-cli/restart-health.test.ts | 26 +++ src/cli/daemon-cli/restart-health.ts | 19 +- src/daemon/schtasks.startup-fallback.test.ts | 10 +- src/daemon/schtasks.stop.test.ts | 197 +++++++++++++++++++ src/daemon/schtasks.ts | 180 ++++++++++++++++- src/gateway/client.ts | 7 +- src/gateway/probe.test.ts | 31 +++ src/gateway/probe.ts | 24 +++ src/infra/gateway-processes.ts | 162 +++++++++++++++ 11 files changed, 680 insertions(+), 171 deletions(-) create mode 100644 src/daemon/schtasks.stop.test.ts create mode 100644 src/infra/gateway-processes.ts diff --git a/src/cli/daemon-cli/lifecycle.test.ts b/src/cli/daemon-cli/lifecycle.test.ts index 61899e4e78c..7d03656f86b 100644 --- a/src/cli/daemon-cli/lifecycle.test.ts +++ b/src/cli/daemon-cli/lifecycle.test.ts @@ -1,8 +1,5 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -const mockReadFileSync = vi.hoisted(() => vi.fn()); -const mockSpawnSync = vi.hoisted(() => vi.fn()); - type RestartHealthSnapshot = { healthy: boolean; staleGatewayPids: number[]; @@ -35,7 +32,9 @@ const terminateStaleGatewayPids = vi.fn(); const renderGatewayPortHealthDiagnostics = vi.fn(() => ["diag: unhealthy port"]); const renderRestartDiagnostics = vi.fn(() => ["diag: unhealthy runtime"]); const resolveGatewayPort = vi.fn(() => 18789); -const findGatewayPidsOnPortSync = vi.fn<(port: number) => number[]>(() => []); +const findVerifiedGatewayListenerPidsOnPortSync = vi.fn<(port: number) => number[]>(() => []); +const signalVerifiedGatewayPidSync = vi.fn<(pid: number, signal: "SIGTERM" | "SIGUSR1") => void>(); +const formatGatewayPidList = vi.fn<(pids: number[]) => string>((pids) => pids.join(", ")); const probeGateway = vi.fn< (opts: { url: string; @@ -49,24 +48,18 @@ const probeGateway = vi.fn< const isRestartEnabled = vi.fn<(config?: { commands?: unknown }) => boolean>(() => true); const loadConfig = vi.fn(() => ({})); -vi.mock("node:fs", () => ({ - default: { - readFileSync: (...args: unknown[]) => mockReadFileSync(...args), - }, -})); - -vi.mock("node:child_process", () => ({ - spawnSync: (...args: unknown[]) => mockSpawnSync(...args), -})); - vi.mock("../../config/config.js", () => ({ loadConfig: () => loadConfig(), readBestEffortConfig: async () => loadConfig(), resolveGatewayPort, })); -vi.mock("../../infra/restart.js", () => ({ - findGatewayPidsOnPortSync: (port: number) => findGatewayPidsOnPortSync(port), +vi.mock("../../infra/gateway-processes.js", () => ({ + findVerifiedGatewayListenerPidsOnPortSync: (port: number) => + findVerifiedGatewayListenerPidsOnPortSync(port), + signalVerifiedGatewayPidSync: (pid: number, signal: "SIGTERM" | "SIGUSR1") => + signalVerifiedGatewayPidSync(pid, signal), + formatGatewayPidList: (pids: number[]) => formatGatewayPidList(pids), })); vi.mock("../../gateway/probe.js", () => ({ @@ -121,12 +114,12 @@ describe("runDaemonRestart health checks", () => { renderGatewayPortHealthDiagnostics.mockReset(); renderRestartDiagnostics.mockReset(); resolveGatewayPort.mockReset(); - findGatewayPidsOnPortSync.mockReset(); + findVerifiedGatewayListenerPidsOnPortSync.mockReset(); + signalVerifiedGatewayPidSync.mockReset(); + formatGatewayPidList.mockReset(); probeGateway.mockReset(); isRestartEnabled.mockReset(); loadConfig.mockReset(); - mockReadFileSync.mockReset(); - mockSpawnSync.mockReset(); service.readCommand.mockResolvedValue({ programArguments: ["openclaw", "gateway", "--port", "18789"], @@ -158,23 +151,8 @@ describe("runDaemonRestart health checks", () => { configSnapshot: { commands: { restart: true } }, }); isRestartEnabled.mockReturnValue(true); - mockReadFileSync.mockImplementation((path: string) => { - const match = path.match(/\/proc\/(\d+)\/cmdline$/); - if (!match) { - throw new Error(`unexpected path ${path}`); - } - const pid = Number.parseInt(match[1] ?? "", 10); - if ([4200, 4300].includes(pid)) { - return ["openclaw", "gateway", "--port", "18789", ""].join("\0"); - } - throw new Error(`unknown pid ${pid}`); - }); - mockSpawnSync.mockReturnValue({ - error: null, - status: 0, - stdout: "openclaw gateway --port 18789", - stderr: "", - }); + signalVerifiedGatewayPidSync.mockImplementation(() => {}); + formatGatewayPidList.mockImplementation((pids) => pids.join(", ")); }); afterEach(() => { @@ -242,38 +220,20 @@ describe("runDaemonRestart health checks", () => { }); it("signals an unmanaged gateway process on stop", async () => { - vi.spyOn(process, "platform", "get").mockReturnValue("win32"); - const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true); - findGatewayPidsOnPortSync.mockReturnValue([4200, 4200, 4300]); - mockSpawnSync.mockReturnValue({ - error: null, - status: 0, - stdout: - 'CommandLine="C:\\\\Program Files\\\\OpenClaw\\\\openclaw.exe" gateway --port 18789\r\n', - stderr: "", - }); + findVerifiedGatewayListenerPidsOnPortSync.mockReturnValue([4200, 4200, 4300]); runServiceStop.mockImplementation(async (params: { onNotLoaded?: () => Promise }) => { await params.onNotLoaded?.(); }); await runDaemonStop({ json: true }); - expect(findGatewayPidsOnPortSync).toHaveBeenCalledWith(18789); - expect(killSpy).toHaveBeenCalledWith(4200, "SIGTERM"); - expect(killSpy).toHaveBeenCalledWith(4300, "SIGTERM"); + expect(findVerifiedGatewayListenerPidsOnPortSync).toHaveBeenCalledWith(18789); + expect(signalVerifiedGatewayPidSync).toHaveBeenCalledWith(4200, "SIGTERM"); + expect(signalVerifiedGatewayPidSync).toHaveBeenCalledWith(4300, "SIGTERM"); }); it("signals a single unmanaged gateway process on restart", async () => { - vi.spyOn(process, "platform", "get").mockReturnValue("win32"); - const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true); - findGatewayPidsOnPortSync.mockReturnValue([4200]); - mockSpawnSync.mockReturnValue({ - error: null, - status: 0, - stdout: - 'CommandLine="C:\\\\Program Files\\\\OpenClaw\\\\openclaw.exe" gateway --port 18789\r\n', - stderr: "", - }); + findVerifiedGatewayListenerPidsOnPortSync.mockReturnValue([4200]); runServiceRestart.mockImplementation( async (params: RestartParams & { onNotLoaded?: () => Promise }) => { await params.onNotLoaded?.(); @@ -291,8 +251,8 @@ describe("runDaemonRestart health checks", () => { await runDaemonRestart({ json: true }); - expect(findGatewayPidsOnPortSync).toHaveBeenCalledWith(18789); - expect(killSpy).toHaveBeenCalledWith(4200, "SIGUSR1"); + expect(findVerifiedGatewayListenerPidsOnPortSync).toHaveBeenCalledWith(18789); + expect(signalVerifiedGatewayPidSync).toHaveBeenCalledWith(4200, "SIGUSR1"); expect(probeGateway).toHaveBeenCalledTimes(1); expect(waitForGatewayHealthyListener).toHaveBeenCalledTimes(1); expect(waitForGatewayHealthyRestart).not.toHaveBeenCalled(); @@ -301,15 +261,7 @@ describe("runDaemonRestart health checks", () => { }); it("fails unmanaged restart when multiple gateway listeners are present", async () => { - vi.spyOn(process, "platform", "get").mockReturnValue("win32"); - findGatewayPidsOnPortSync.mockReturnValue([4200, 4300]); - mockSpawnSync.mockReturnValue({ - error: null, - status: 0, - stdout: - 'CommandLine="C:\\\\Program Files\\\\OpenClaw\\\\openclaw.exe" gateway --port 18789\r\n', - stderr: "", - }); + findVerifiedGatewayListenerPidsOnPortSync.mockReturnValue([4200, 4300]); runServiceRestart.mockImplementation( async (params: RestartParams & { onNotLoaded?: () => Promise }) => { await params.onNotLoaded?.(); @@ -323,7 +275,7 @@ describe("runDaemonRestart health checks", () => { }); it("fails unmanaged restart when the running gateway has commands.restart disabled", async () => { - findGatewayPidsOnPortSync.mockReturnValue([4200]); + findVerifiedGatewayListenerPidsOnPortSync.mockReturnValue([4200]); probeGateway.mockResolvedValue({ ok: true, configSnapshot: { commands: { restart: false } }, @@ -342,21 +294,13 @@ describe("runDaemonRestart health checks", () => { }); it("skips unmanaged signaling for pids that are not live gateway processes", async () => { - const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true); - findGatewayPidsOnPortSync.mockReturnValue([4200]); - mockReadFileSync.mockReturnValue(["python", "-m", "http.server", ""].join("\0")); - mockSpawnSync.mockReturnValue({ - error: null, - status: 0, - stdout: "python -m http.server", - stderr: "", - }); + findVerifiedGatewayListenerPidsOnPortSync.mockReturnValue([]); runServiceStop.mockImplementation(async (params: { onNotLoaded?: () => Promise }) => { await params.onNotLoaded?.(); }); await runDaemonStop({ json: true }); - expect(killSpy).not.toHaveBeenCalled(); + expect(signalVerifiedGatewayPidSync).not.toHaveBeenCalled(); }); }); diff --git a/src/cli/daemon-cli/lifecycle.ts b/src/cli/daemon-cli/lifecycle.ts index 2b0775b0c48..53efaff9495 100644 --- a/src/cli/daemon-cli/lifecycle.ts +++ b/src/cli/daemon-cli/lifecycle.ts @@ -1,12 +1,12 @@ -import { spawnSync } from "node:child_process"; -import fsSync from "node:fs"; import { isRestartEnabled } from "../../config/commands.js"; import { readBestEffortConfig, resolveGatewayPort } from "../../config/config.js"; -import { parseCmdScriptCommandLine } from "../../daemon/cmd-argv.js"; import { resolveGatewayService } from "../../daemon/service.js"; import { probeGateway } from "../../gateway/probe.js"; -import { isGatewayArgv, parseProcCmdline } from "../../infra/gateway-process-argv.js"; -import { findGatewayPidsOnPortSync } from "../../infra/restart.js"; +import { + findVerifiedGatewayListenerPidsOnPortSync, + formatGatewayPidList, + signalVerifiedGatewayPidSync, +} from "../../infra/gateway-processes.js"; import { defaultRuntime } from "../../runtime.js"; import { theme } from "../../terminal/theme.js"; import { formatCliCommand } from "../command-format.js"; @@ -43,85 +43,12 @@ async function resolveGatewayLifecyclePort(service = resolveGatewayService()) { return portFromArgs ?? resolveGatewayPort(await readBestEffortConfig(), mergedEnv); } -function extractWindowsCommandLine(raw: string): string | null { - const lines = raw - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean); - for (const line of lines) { - if (!line.toLowerCase().startsWith("commandline=")) { - continue; - } - const value = line.slice("commandline=".length).trim(); - return value || null; - } - return lines.find((line) => line.toLowerCase() !== "commandline") ?? null; -} - -function readGatewayProcessArgsSync(pid: number): string[] | null { - if (process.platform === "linux") { - try { - return parseProcCmdline(fsSync.readFileSync(`/proc/${pid}/cmdline`, "utf8")); - } catch { - return null; - } - } - if (process.platform === "darwin") { - const ps = spawnSync("ps", ["-o", "command=", "-p", String(pid)], { - encoding: "utf8", - timeout: 1000, - }); - if (ps.error || ps.status !== 0) { - return null; - } - const command = ps.stdout.trim(); - return command ? command.split(/\s+/) : null; - } - if (process.platform === "win32") { - const wmic = spawnSync( - "wmic", - ["process", "where", `ProcessId=${pid}`, "get", "CommandLine", "/value"], - { - encoding: "utf8", - timeout: 1000, - }, - ); - if (wmic.error || wmic.status !== 0) { - return null; - } - const command = extractWindowsCommandLine(wmic.stdout); - return command ? parseCmdScriptCommandLine(command) : null; - } - return null; -} - -function resolveGatewayListenerPids(port: number): number[] { - return Array.from(new Set(findGatewayPidsOnPortSync(port))) - .filter((pid): pid is number => Number.isFinite(pid) && pid > 0) - .filter((pid) => { - const args = readGatewayProcessArgsSync(pid); - return args != null && isGatewayArgv(args, { allowGatewayBinary: true }); - }); -} - function resolveGatewayPortFallback(): Promise { return readBestEffortConfig() .then((cfg) => resolveGatewayPort(cfg, process.env)) .catch(() => resolveGatewayPort(undefined, process.env)); } -function signalGatewayPid(pid: number, signal: "SIGTERM" | "SIGUSR1") { - const args = readGatewayProcessArgsSync(pid); - if (!args || !isGatewayArgv(args, { allowGatewayBinary: true })) { - throw new Error(`refusing to signal non-gateway process pid ${pid}`); - } - process.kill(pid, signal); -} - -function formatGatewayPidList(pids: number[]): string { - return pids.join(", "); -} - async function assertUnmanagedGatewayRestartEnabled(port: number): Promise { const probe = await probeGateway({ url: `ws://127.0.0.1:${port}`, @@ -143,7 +70,7 @@ async function assertUnmanagedGatewayRestartEnabled(port: number): Promise } function resolveVerifiedGatewayListenerPids(port: number): number[] { - return resolveGatewayListenerPids(port).filter( + return findVerifiedGatewayListenerPidsOnPortSync(port).filter( (pid): pid is number => Number.isFinite(pid) && pid > 0, ); } @@ -154,7 +81,7 @@ async function stopGatewayWithoutServiceManager(port: number) { return null; } for (const pid of pids) { - signalGatewayPid(pid, "SIGTERM"); + signalVerifiedGatewayPidSync(pid, "SIGTERM"); } return { result: "stopped" as const, @@ -173,7 +100,7 @@ async function restartGatewayWithoutServiceManager(port: number) { `multiple gateway processes are listening on port ${port}: ${formatGatewayPidList(pids)}; use "openclaw gateway status --deep" before retrying restart`, ); } - signalGatewayPid(pids[0], "SIGUSR1"); + signalVerifiedGatewayPidSync(pids[0], "SIGUSR1"); return { result: "restarted" as const, message: `Gateway restart signal sent to unmanaged process on port ${port}: ${pids[0]}.`, diff --git a/src/cli/daemon-cli/restart-health.test.ts b/src/cli/daemon-cli/restart-health.test.ts index 0202f591cc2..1a26f1a80dc 100644 --- a/src/cli/daemon-cli/restart-health.test.ts +++ b/src/cli/daemon-cli/restart-health.test.ts @@ -190,6 +190,32 @@ describe("inspectGatewayRestart", () => { ); }); + it("treats a busy port as healthy when runtime status lags but the probe succeeds", async () => { + Object.defineProperty(process, "platform", { value: "win32", configurable: true }); + + const service = { + readRuntime: vi.fn(async () => ({ status: "stopped" })), + } as unknown as GatewayService; + + inspectPortUsage.mockResolvedValue({ + port: 18789, + status: "busy", + listeners: [{ pid: 9100, commandLine: "openclaw-gateway" }], + hints: [], + }); + classifyPortListener.mockReturnValue("gateway"); + probeGateway.mockResolvedValue({ + ok: true, + close: null, + }); + + const { inspectGatewayRestart } = await import("./restart-health.js"); + const snapshot = await inspectGatewayRestart({ service, port: 18789 }); + + expect(snapshot.healthy).toBe(true); + expect(snapshot.staleGatewayPids).toEqual([]); + }); + it("treats auth-closed probe as healthy gateway reachability", async () => { const snapshot = await inspectAmbiguousOwnershipWithProbe({ ok: false, diff --git a/src/cli/daemon-cli/restart-health.ts b/src/cli/daemon-cli/restart-health.ts index 13741d2e9c4..9bfe3476ee6 100644 --- a/src/cli/daemon-cli/restart-health.ts +++ b/src/cli/daemon-cli/restart-health.ts @@ -65,7 +65,8 @@ async function confirmGatewayReachable(port: number): Promise { const probe = await probeGateway({ url: `ws://127.0.0.1:${port}`, auth: token || password ? { token, password } : undefined, - timeoutMs: 1_000, + timeoutMs: 3_000, + includeDetails: false, }); return probe.ok || looksLikeAuthClose(probe.close?.code, probe.close?.reason); } @@ -123,6 +124,22 @@ export async function inspectGatewayRestart(params: { }; } + if (portUsage.status === "busy" && runtime.status !== "running") { + try { + const reachable = await confirmGatewayReachable(params.port); + if (reachable) { + return { + runtime, + portUsage, + healthy: true, + staleGatewayPids: [], + }; + } + } catch { + // Probe is best-effort; keep the ownership-based diagnostics. + } + } + const gatewayListeners = portUsage.status === "busy" ? portUsage.listeners.filter( diff --git a/src/daemon/schtasks.startup-fallback.test.ts b/src/daemon/schtasks.startup-fallback.test.ts index 8b26a98e4ed..1a949856a09 100644 --- a/src/daemon/schtasks.startup-fallback.test.ts +++ b/src/daemon/schtasks.startup-fallback.test.ts @@ -29,9 +29,13 @@ vi.mock("../process/kill-tree.js", () => ({ killProcessTree: (...args: unknown[]) => killProcessTree(...args), })); -vi.mock("node:child_process", () => ({ - spawn, -})); +vi.mock("node:child_process", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + spawn, + }; +}); const { installScheduledTask, diff --git a/src/daemon/schtasks.stop.test.ts b/src/daemon/schtasks.stop.test.ts new file mode 100644 index 00000000000..d2d43de3ca2 --- /dev/null +++ b/src/daemon/schtasks.stop.test.ts @@ -0,0 +1,197 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { PassThrough } from "node:stream"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const schtasksResponses = vi.hoisted( + () => [] as Array<{ code: number; stdout: string; stderr: string }>, +); +const schtasksCalls = vi.hoisted(() => [] as string[][]); +const inspectPortUsage = vi.hoisted(() => vi.fn()); +const killProcessTree = vi.hoisted(() => vi.fn()); +const findVerifiedGatewayListenerPidsOnPortSync = vi.hoisted(() => + vi.fn<(port: number) => number[]>(() => []), +); + +vi.mock("./schtasks-exec.js", () => ({ + execSchtasks: async (argv: string[]) => { + schtasksCalls.push(argv); + return schtasksResponses.shift() ?? { code: 0, stdout: "", stderr: "" }; + }, +})); + +vi.mock("../infra/ports.js", () => ({ + inspectPortUsage: (...args: unknown[]) => inspectPortUsage(...args), +})); + +vi.mock("../process/kill-tree.js", () => ({ + killProcessTree: (...args: unknown[]) => killProcessTree(...args), +})); + +vi.mock("../infra/gateway-processes.js", () => ({ + findVerifiedGatewayListenerPidsOnPortSync: (port: number) => + findVerifiedGatewayListenerPidsOnPortSync(port), +})); + +const { restartScheduledTask, resolveTaskScriptPath, stopScheduledTask } = + await import("./schtasks.js"); + +async function withWindowsEnv( + run: (params: { tmpDir: string; env: Record }) => Promise, +) { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-win-stop-")); + const env = { + USERPROFILE: tmpDir, + APPDATA: path.join(tmpDir, "AppData", "Roaming"), + OPENCLAW_PROFILE: "default", + OPENCLAW_GATEWAY_PORT: "18789", + }; + try { + await run({ tmpDir, env }); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } +} + +async function writeGatewayScript(env: Record, port = 18789) { + const scriptPath = resolveTaskScriptPath(env); + await fs.mkdir(path.dirname(scriptPath), { recursive: true }); + await fs.writeFile( + scriptPath, + [ + "@echo off", + `set "OPENCLAW_GATEWAY_PORT=${port}"`, + `"C:\\Program Files\\nodejs\\node.exe" "C:\\Users\\steipete\\AppData\\Roaming\\npm\\node_modules\\openclaw\\dist\\index.js" gateway --port ${port}`, + "", + ].join("\r\n"), + "utf8", + ); +} + +beforeEach(() => { + schtasksResponses.length = 0; + schtasksCalls.length = 0; + inspectPortUsage.mockReset(); + killProcessTree.mockReset(); + findVerifiedGatewayListenerPidsOnPortSync.mockReset(); + findVerifiedGatewayListenerPidsOnPortSync.mockReturnValue([]); + inspectPortUsage.mockResolvedValue({ + port: 18789, + status: "free", + listeners: [], + hints: [], + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("Scheduled Task stop/restart cleanup", () => { + it("kills lingering verified gateway listeners after schtasks stop", async () => { + await withWindowsEnv(async ({ env }) => { + await writeGatewayScript(env); + schtasksResponses.push( + { code: 0, stdout: "", stderr: "" }, + { code: 0, stdout: "", stderr: "" }, + { code: 0, stdout: "", stderr: "" }, + ); + findVerifiedGatewayListenerPidsOnPortSync.mockReturnValue([4242]); + inspectPortUsage + .mockResolvedValueOnce({ + port: 18789, + status: "busy", + listeners: [{ pid: 4242, command: "node.exe" }], + hints: [], + }) + .mockResolvedValueOnce({ + port: 18789, + status: "free", + listeners: [], + hints: [], + }); + + const stdout = new PassThrough(); + await stopScheduledTask({ env, stdout }); + + expect(findVerifiedGatewayListenerPidsOnPortSync).toHaveBeenCalledWith(18789); + expect(killProcessTree).toHaveBeenCalledWith(4242, { graceMs: 300 }); + expect(inspectPortUsage).toHaveBeenCalledTimes(2); + }); + }); + + it("falls back to inspected gateway listeners when sync verification misses on Windows", async () => { + await withWindowsEnv(async ({ env }) => { + await writeGatewayScript(env); + schtasksResponses.push( + { code: 0, stdout: "", stderr: "" }, + { code: 0, stdout: "", stderr: "" }, + { code: 0, stdout: "", stderr: "" }, + ); + findVerifiedGatewayListenerPidsOnPortSync.mockReturnValue([]); + inspectPortUsage + .mockResolvedValueOnce({ + port: 18789, + status: "busy", + listeners: [ + { + pid: 6262, + command: "node.exe", + commandLine: + '"C:\\Program Files\\nodejs\\node.exe" "C:\\Users\\steipete\\AppData\\Roaming\\npm\\node_modules\\openclaw\\dist\\index.js" gateway --port 18789', + }, + ], + hints: [], + }) + .mockResolvedValueOnce({ + port: 18789, + status: "free", + listeners: [], + hints: [], + }); + + const stdout = new PassThrough(); + await stopScheduledTask({ env, stdout }); + + expect(killProcessTree).toHaveBeenCalledWith(6262, { graceMs: 300 }); + expect(inspectPortUsage).toHaveBeenCalledTimes(2); + }); + }); + + it("kills lingering verified gateway listeners and waits for port release before restart", async () => { + await withWindowsEnv(async ({ env }) => { + await writeGatewayScript(env); + schtasksResponses.push( + { code: 0, stdout: "", stderr: "" }, + { code: 0, stdout: "", stderr: "" }, + { code: 0, stdout: "", stderr: "" }, + { code: 0, stdout: "", stderr: "" }, + ); + findVerifiedGatewayListenerPidsOnPortSync.mockReturnValue([5151]); + inspectPortUsage + .mockResolvedValueOnce({ + port: 18789, + status: "busy", + listeners: [{ pid: 5151, command: "node.exe" }], + hints: [], + }) + .mockResolvedValueOnce({ + port: 18789, + status: "free", + listeners: [], + hints: [], + }); + + const stdout = new PassThrough(); + await expect(restartScheduledTask({ env, stdout })).resolves.toEqual({ + outcome: "completed", + }); + + expect(findVerifiedGatewayListenerPidsOnPortSync).toHaveBeenCalledWith(18789); + expect(killProcessTree).toHaveBeenCalledWith(5151, { graceMs: 300 }); + expect(inspectPortUsage).toHaveBeenCalledTimes(2); + expect(schtasksCalls.at(-1)).toEqual(["/Run", "/TN", "OpenClaw Gateway"]); + }); + }); +}); diff --git a/src/daemon/schtasks.ts b/src/daemon/schtasks.ts index 2c74cf26a61..fcd8b08b1af 100644 --- a/src/daemon/schtasks.ts +++ b/src/daemon/schtasks.ts @@ -1,8 +1,11 @@ -import { spawn } from "node:child_process"; +import { spawn, spawnSync } from "node:child_process"; import fs from "node:fs/promises"; import path from "node:path"; +import { isGatewayArgv } from "../infra/gateway-process-argv.js"; +import { findVerifiedGatewayListenerPidsOnPortSync } from "../infra/gateway-processes.js"; import { inspectPortUsage } from "../infra/ports.js"; import { killProcessTree } from "../process/kill-tree.js"; +import { sleep } from "../utils.js"; import { parseCmdScriptCommandLine, quoteCmdScriptArg } from "./cmd-argv.js"; import { assertNoCmdLineBreak, parseCmdSetAssignment, renderCmdSetAssignment } from "./cmd-set.js"; import { resolveGatewayServiceDescription, resolveGatewayWindowsTaskName } from "./constants.js"; @@ -311,6 +314,155 @@ function resolveConfiguredGatewayPort(env: GatewayServiceEnv): number | null { return Number.isFinite(parsed) && parsed > 0 ? parsed : null; } +function parsePositivePort(raw: string | undefined): number | null { + const value = raw?.trim(); + if (!value) { + return null; + } + if (!/^\d+$/.test(value)) { + return null; + } + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) && parsed > 0 && parsed <= 65535 ? parsed : null; +} + +function parsePortFromProgramArguments(programArguments?: string[]): number | null { + if (!programArguments?.length) { + return null; + } + for (let i = 0; i < programArguments.length; i += 1) { + const arg = programArguments[i]; + if (!arg) { + continue; + } + const inlineMatch = arg.match(/^--port=(\d+)$/); + if (inlineMatch) { + return parsePositivePort(inlineMatch[1]); + } + if (arg === "--port") { + return parsePositivePort(programArguments[i + 1]); + } + } + return null; +} + +async function resolveScheduledTaskPort(env: GatewayServiceEnv): Promise { + const command = await readScheduledTaskCommand(env).catch(() => null); + return ( + parsePortFromProgramArguments(command?.programArguments) ?? + parsePositivePort(command?.environment?.OPENCLAW_GATEWAY_PORT) ?? + resolveConfiguredGatewayPort(env) + ); +} + +async function resolveScheduledTaskGatewayListenerPids(port: number): Promise { + const verified = findVerifiedGatewayListenerPidsOnPortSync(port); + if (verified.length > 0) { + return verified; + } + + const diagnostics = await inspectPortUsage(port).catch(() => null); + if (diagnostics?.status !== "busy") { + return []; + } + + const matchedGatewayPids = Array.from( + new Set( + diagnostics.listeners + .filter( + (listener) => + typeof listener.pid === "number" && + listener.commandLine && + isGatewayArgv(parseCmdScriptCommandLine(listener.commandLine), { + allowGatewayBinary: true, + }), + ) + .map((listener) => listener.pid as number), + ), + ); + if (matchedGatewayPids.length > 0) { + return matchedGatewayPids; + } + + return Array.from( + new Set( + diagnostics.listeners + .map((listener) => listener.pid) + .filter((pid): pid is number => Number.isFinite(pid) && pid > 0), + ), + ); +} + +async function terminateScheduledTaskGatewayListeners(env: GatewayServiceEnv): Promise { + const port = await resolveScheduledTaskPort(env); + if (!port) { + return []; + } + const pids = await resolveScheduledTaskGatewayListenerPids(port); + for (const pid of pids) { + await terminateGatewayProcessTree(pid, 300); + } + return pids; +} + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +async function waitForProcessExit(pid: number, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (!isProcessAlive(pid)) { + return true; + } + await sleep(100); + } + return !isProcessAlive(pid); +} + +async function terminateGatewayProcessTree(pid: number, graceMs: number): Promise { + if (process.platform !== "win32") { + killProcessTree(pid, { graceMs }); + return; + } + const taskkillPath = path.join( + process.env.SystemRoot ?? "C:\\Windows", + "System32", + "taskkill.exe", + ); + spawnSync(taskkillPath, ["/T", "/PID", String(pid)], { + stdio: "ignore", + timeout: 5_000, + windowsHide: true, + }); + if (await waitForProcessExit(pid, graceMs)) { + return; + } + spawnSync(taskkillPath, ["/F", "/T", "/PID", String(pid)], { + stdio: "ignore", + timeout: 5_000, + windowsHide: true, + }); + await waitForProcessExit(pid, 5_000); +} + +async function waitForGatewayPortRelease(port: number, timeoutMs = 5_000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const diagnostics = await inspectPortUsage(port).catch(() => null); + if (diagnostics?.status === "free") { + return true; + } + await sleep(250); + } + return false; +} + async function resolveFallbackRuntime(env: GatewayServiceEnv): Promise { const port = resolveConfiguredGatewayPort(env); if (!port) { @@ -343,18 +495,28 @@ async function stopStartupEntry( ): Promise { const runtime = await resolveFallbackRuntime(env); if (typeof runtime.pid === "number" && runtime.pid > 0) { - killProcessTree(runtime.pid, { graceMs: 300 }); + await terminateGatewayProcessTree(runtime.pid, 300); } stdout.write(`${formatLine("Stopped Windows login item", resolveTaskName(env))}\n`); } +async function terminateInstalledStartupRuntime(env: GatewayServiceEnv): Promise { + if (!(await isStartupEntryInstalled(env))) { + return; + } + const runtime = await resolveFallbackRuntime(env); + if (typeof runtime.pid === "number" && runtime.pid > 0) { + await terminateGatewayProcessTree(runtime.pid, 300); + } +} + async function restartStartupEntry( env: GatewayServiceEnv, stdout: NodeJS.WritableStream, ): Promise { const runtime = await resolveFallbackRuntime(env); if (typeof runtime.pid === "number" && runtime.pid > 0) { - killProcessTree(runtime.pid, { graceMs: 300 }); + await terminateGatewayProcessTree(runtime.pid, 300); } launchFallbackTaskScript(resolveTaskScriptPath(env)); stdout.write(`${formatLine("Restarted Windows login item", resolveTaskName(env))}\n`); @@ -489,6 +651,12 @@ export async function stopScheduledTask({ stdout, env }: GatewayServiceControlAr if (res.code !== 0 && !isTaskNotRunning(res)) { throw new Error(`schtasks end failed: ${res.stderr || res.stdout}`.trim()); } + const stopPort = await resolveScheduledTaskPort(effectiveEnv); + await terminateScheduledTaskGatewayListeners(effectiveEnv); + await terminateInstalledStartupRuntime(effectiveEnv); + if (stopPort) { + await waitForGatewayPortRelease(stopPort); + } stdout.write(`${formatLine("Stopped Scheduled Task", taskName)}\n`); } @@ -512,6 +680,12 @@ export async function restartScheduledTask({ } const taskName = resolveTaskName(effectiveEnv); await execSchtasks(["/End", "/TN", taskName]); + const restartPort = await resolveScheduledTaskPort(effectiveEnv); + await terminateScheduledTaskGatewayListeners(effectiveEnv); + await terminateInstalledStartupRuntime(effectiveEnv); + if (restartPort) { + await waitForGatewayPortRelease(restartPort); + } const res = await execSchtasks(["/Run", "/TN", taskName]); if (res.code !== 0) { throw new Error(`schtasks run failed: ${res.stderr || res.stdout}`.trim()); diff --git a/src/gateway/client.ts b/src/gateway/client.ts index 9e98a9bc0c4..f2c7a184dd8 100644 --- a/src/gateway/client.ts +++ b/src/gateway/client.ts @@ -95,7 +95,7 @@ export type GatewayClientOptions = { commands?: string[]; permissions?: Record; pathEnv?: string; - deviceIdentity?: DeviceIdentity; + deviceIdentity?: DeviceIdentity | null; minProtocol?: number; maxProtocol?: number; tlsFingerprint?: string; @@ -138,7 +138,10 @@ export class GatewayClient { constructor(opts: GatewayClientOptions) { this.opts = { ...opts, - deviceIdentity: opts.deviceIdentity ?? loadOrCreateDeviceIdentity(), + deviceIdentity: + opts.deviceIdentity === null + ? undefined + : (opts.deviceIdentity ?? loadOrCreateDeviceIdentity()), }; } diff --git a/src/gateway/probe.test.ts b/src/gateway/probe.test.ts index b5927389c4d..6cd7d64fc51 100644 --- a/src/gateway/probe.test.ts +++ b/src/gateway/probe.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest"; const gatewayClientState = vi.hoisted(() => ({ options: null as Record | null, + requests: [] as string[], })); class MockGatewayClient { @@ -10,6 +11,7 @@ class MockGatewayClient { constructor(opts: Record) { this.opts = opts; gatewayClientState.options = opts; + gatewayClientState.requests = []; } start(): void { @@ -26,6 +28,7 @@ class MockGatewayClient { stop(): void {} async request(method: string): Promise { + gatewayClientState.requests.push(method); if (method === "system-presence") { return []; } @@ -48,6 +51,34 @@ describe("probeGateway", () => { }); expect(gatewayClientState.options?.scopes).toEqual(["operator.read"]); + expect(gatewayClientState.options?.deviceIdentity).toBeNull(); + expect(gatewayClientState.requests).toEqual([ + "health", + "status", + "system-presence", + "config.get", + ]); expect(result.ok).toBe(true); }); + + it("keeps device identity enabled for remote probes", async () => { + await probeGateway({ + url: "wss://gateway.example/ws", + auth: { token: "secret" }, + timeoutMs: 1_000, + }); + + expect(gatewayClientState.options?.deviceIdentity).toBeUndefined(); + }); + + it("skips detail RPCs for lightweight reachability probes", async () => { + const result = await probeGateway({ + url: "ws://127.0.0.1:18789", + timeoutMs: 1_000, + includeDetails: false, + }); + + expect(result.ok).toBe(true); + expect(gatewayClientState.requests).toEqual([]); + }); }); diff --git a/src/gateway/probe.ts b/src/gateway/probe.ts index 0521e84d9c8..40740987fb0 100644 --- a/src/gateway/probe.ts +++ b/src/gateway/probe.ts @@ -4,6 +4,7 @@ import type { SystemPresence } from "../infra/system-presence.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; import { GatewayClient } from "./client.js"; import { READ_SCOPE } from "./method-scopes.js"; +import { isLoopbackHost } from "./net.js"; export type GatewayProbeAuth = { token?: string; @@ -32,6 +33,7 @@ export async function probeGateway(opts: { url: string; auth?: GatewayProbeAuth; timeoutMs: number; + includeDetails?: boolean; }): Promise { const startedAt = Date.now(); const instanceId = randomUUID(); @@ -39,6 +41,14 @@ export async function probeGateway(opts: { let connectError: string | null = null; let close: GatewayProbeClose | null = null; + const disableDeviceIdentity = (() => { + try { + return isLoopbackHost(new URL(opts.url).hostname); + } catch { + return false; + } + })(); + return await new Promise((resolve) => { let settled = false; const settle = (result: Omit) => { @@ -60,6 +70,7 @@ export async function probeGateway(opts: { clientVersion: "dev", mode: GATEWAY_CLIENT_MODES.PROBE, instanceId, + deviceIdentity: disableDeviceIdentity ? null : undefined, onConnectError: (err) => { connectError = formatErrorMessage(err); }, @@ -68,6 +79,19 @@ export async function probeGateway(opts: { }, onHelloOk: async () => { connectLatencyMs = Date.now() - startedAt; + if (opts.includeDetails === false) { + settle({ + ok: true, + connectLatencyMs, + error: null, + close, + health: null, + status: null, + presence: null, + configSnapshot: null, + }); + return; + } try { const [health, status, presence, configSnapshot] = await Promise.all([ client.request("health"), diff --git a/src/infra/gateway-processes.ts b/src/infra/gateway-processes.ts new file mode 100644 index 00000000000..340b54a259f --- /dev/null +++ b/src/infra/gateway-processes.ts @@ -0,0 +1,162 @@ +import { spawnSync } from "node:child_process"; +import fsSync from "node:fs"; +import { parseCmdScriptCommandLine } from "../daemon/cmd-argv.js"; +import { isGatewayArgv, parseProcCmdline } from "./gateway-process-argv.js"; +import { findGatewayPidsOnPortSync as findUnixGatewayPidsOnPortSync } from "./restart-stale-pids.js"; + +const WINDOWS_GATEWAY_DISCOVERY_TIMEOUT_MS = 5_000; + +function extractWindowsCommandLine(raw: string): string | null { + const lines = raw + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + for (const line of lines) { + if (!line.toLowerCase().startsWith("commandline=")) { + continue; + } + const value = line.slice("commandline=".length).trim(); + return value || null; + } + return lines.find((line) => line.toLowerCase() !== "commandline") ?? null; +} + +function readWindowsProcessArgsViaPowerShell(pid: number): string[] | null { + const ps = spawnSync( + "powershell", + [ + "-NoProfile", + "-Command", + `(Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}" | Select-Object -ExpandProperty CommandLine)`, + ], + { + encoding: "utf8", + timeout: WINDOWS_GATEWAY_DISCOVERY_TIMEOUT_MS, + windowsHide: true, + }, + ); + if (ps.error || ps.status !== 0) { + return null; + } + const command = ps.stdout.trim(); + return command ? parseCmdScriptCommandLine(command) : null; +} + +function readWindowsProcessArgsViaWmic(pid: number): string[] | null { + const wmic = spawnSync( + "wmic", + ["process", "where", `ProcessId=${pid}`, "get", "CommandLine", "/value"], + { + encoding: "utf8", + timeout: WINDOWS_GATEWAY_DISCOVERY_TIMEOUT_MS, + windowsHide: true, + }, + ); + if (wmic.error || wmic.status !== 0) { + return null; + } + const command = extractWindowsCommandLine(wmic.stdout); + return command ? parseCmdScriptCommandLine(command) : null; +} + +function readWindowsListeningPidsViaPowerShell(port: number): number[] | null { + const ps = spawnSync( + "powershell", + [ + "-NoProfile", + "-Command", + `(Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess)`, + ], + { + encoding: "utf8", + timeout: WINDOWS_GATEWAY_DISCOVERY_TIMEOUT_MS, + windowsHide: true, + }, + ); + if (ps.error || ps.status !== 0) { + return null; + } + return ps.stdout + .split(/\r?\n/) + .map((line) => Number.parseInt(line.trim(), 10)) + .filter((pid) => Number.isFinite(pid) && pid > 0); +} + +function readWindowsListeningPidsViaNetstat(port: number): number[] { + const netstat = spawnSync("netstat", ["-ano", "-p", "tcp"], { + encoding: "utf8", + timeout: WINDOWS_GATEWAY_DISCOVERY_TIMEOUT_MS, + windowsHide: true, + }); + if (netstat.error || netstat.status !== 0) { + return []; + } + const pids = new Set(); + for (const line of netstat.stdout.split(/\r?\n/)) { + const match = line.match(/^\s*TCP\s+(\S+):(\d+)\s+\S+\s+LISTENING\s+(\d+)\s*$/i); + if (!match) { + continue; + } + const parsedPort = Number.parseInt(match[2] ?? "", 10); + const pid = Number.parseInt(match[3] ?? "", 10); + if (parsedPort === port && Number.isFinite(pid) && pid > 0) { + pids.add(pid); + } + } + return [...pids]; +} + +function readWindowsListeningPidsOnPortSync(port: number): number[] { + return readWindowsListeningPidsViaPowerShell(port) ?? readWindowsListeningPidsViaNetstat(port); +} + +export function readGatewayProcessArgsSync(pid: number): string[] | null { + if (process.platform === "linux") { + try { + return parseProcCmdline(fsSync.readFileSync(`/proc/${pid}/cmdline`, "utf8")); + } catch { + return null; + } + } + if (process.platform === "darwin") { + const ps = spawnSync("ps", ["-o", "command=", "-p", String(pid)], { + encoding: "utf8", + timeout: 1000, + }); + if (ps.error || ps.status !== 0) { + return null; + } + const command = ps.stdout.trim(); + return command ? command.split(/\s+/) : null; + } + if (process.platform === "win32") { + return readWindowsProcessArgsViaPowerShell(pid) ?? readWindowsProcessArgsViaWmic(pid); + } + return null; +} + +export function signalVerifiedGatewayPidSync(pid: number, signal: "SIGTERM" | "SIGUSR1"): void { + const args = readGatewayProcessArgsSync(pid); + if (!args || !isGatewayArgv(args, { allowGatewayBinary: true })) { + throw new Error(`refusing to signal non-gateway process pid ${pid}`); + } + process.kill(pid, signal); +} + +export function findVerifiedGatewayListenerPidsOnPortSync(port: number): number[] { + const rawPids = + process.platform === "win32" + ? readWindowsListeningPidsOnPortSync(port) + : findUnixGatewayPidsOnPortSync(port); + + return Array.from(new Set(rawPids)) + .filter((pid): pid is number => Number.isFinite(pid) && pid > 0 && pid !== process.pid) + .filter((pid) => { + const args = readGatewayProcessArgsSync(pid); + return args != null && isGatewayArgv(args, { allowGatewayBinary: true }); + }); +} + +export function formatGatewayPidList(pids: number[]): string { + return pids.join(", "); +} From c1b3a4932039d784b382693aea0a6ea7b56de7ab Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 18:34:01 +0000 Subject: [PATCH 016/640] test: expand heartbeat event filter coverage --- src/infra/heartbeat-events-filter.test.ts | 100 +++++++++++++++++++--- 1 file changed, 86 insertions(+), 14 deletions(-) diff --git a/src/infra/heartbeat-events-filter.test.ts b/src/infra/heartbeat-events-filter.test.ts index dab2250dd0e..9cff6652537 100644 --- a/src/infra/heartbeat-events-filter.test.ts +++ b/src/infra/heartbeat-events-filter.test.ts @@ -1,21 +1,93 @@ import { describe, expect, it } from "vitest"; -import { buildCronEventPrompt, buildExecEventPrompt } from "./heartbeat-events-filter.js"; +import { + buildCronEventPrompt, + buildExecEventPrompt, + isCronSystemEvent, + isExecCompletionEvent, +} from "./heartbeat-events-filter.js"; describe("heartbeat event prompts", () => { - it("builds user-relay cron prompt by default", () => { - const prompt = buildCronEventPrompt(["Cron: rotate logs"]); - expect(prompt).toContain("Please relay this reminder to the user"); + it.each([ + { + name: "builds user-relay cron prompt by default", + events: ["Cron: rotate logs"], + expected: ["Cron: rotate logs", "Please relay this reminder to the user"], + unexpected: ["Handle this reminder internally", "Reply HEARTBEAT_OK."], + }, + { + name: "builds internal-only cron prompt when delivery is disabled", + events: ["Cron: rotate logs"], + opts: { deliverToUser: false }, + expected: ["Cron: rotate logs", "Handle this reminder internally"], + unexpected: ["Please relay this reminder to the user"], + }, + { + name: "falls back to bare heartbeat reply when cron content is empty", + events: ["", " "], + expected: ["Reply HEARTBEAT_OK."], + unexpected: ["Handle this reminder internally"], + }, + { + name: "uses internal empty-content fallback when delivery is disabled", + events: ["", " "], + opts: { deliverToUser: false }, + expected: ["Handle this internally", "HEARTBEAT_OK when nothing needs user-facing follow-up"], + unexpected: ["Please relay this reminder to the user"], + }, + ])("$name", ({ events, opts, expected, unexpected }) => { + const prompt = buildCronEventPrompt(events, opts); + for (const part of expected) { + expect(prompt).toContain(part); + } + for (const part of unexpected) { + expect(prompt).not.toContain(part); + } }); - it("builds internal-only cron prompt when delivery is disabled", () => { - const prompt = buildCronEventPrompt(["Cron: rotate logs"], { deliverToUser: false }); - expect(prompt).toContain("Handle this reminder internally"); - expect(prompt).not.toContain("Please relay this reminder to the user"); - }); - - it("builds internal-only exec prompt when delivery is disabled", () => { - const prompt = buildExecEventPrompt({ deliverToUser: false }); - expect(prompt).toContain("Handle the result internally"); - expect(prompt).not.toContain("Please relay the command output to the user"); + it.each([ + { + name: "builds user-relay exec prompt by default", + opts: undefined, + expected: ["Please relay the command output to the user", "If it failed"], + unexpected: ["Handle the result internally"], + }, + { + name: "builds internal-only exec prompt when delivery is disabled", + opts: { deliverToUser: false }, + expected: ["Handle the result internally"], + unexpected: ["Please relay the command output to the user"], + }, + ])("$name", ({ opts, expected, unexpected }) => { + const prompt = buildExecEventPrompt(opts); + for (const part of expected) { + expect(prompt).toContain(part); + } + for (const part of unexpected) { + expect(prompt).not.toContain(part); + } + }); +}); + +describe("heartbeat event classification", () => { + it.each([ + { value: "exec finished: ok", expected: true }, + { value: "Exec Finished: failed", expected: true }, + { value: "cron finished", expected: false }, + ])("classifies exec completion events for %j", ({ value, expected }) => { + expect(isExecCompletionEvent(value)).toBe(expected); + }); + + it.each([ + { value: "Cron: rotate logs", expected: true }, + { value: " Cron: rotate logs ", expected: true }, + { value: "", expected: false }, + { value: " ", expected: false }, + { value: "HEARTBEAT_OK", expected: false }, + { value: "heartbeat_ok: already handled", expected: false }, + { value: "heartbeat poll: noop", expected: false }, + { value: "heartbeat wake: noop", expected: false }, + { value: "exec finished: ok", expected: false }, + ])("classifies cron system events for %j", ({ value, expected }) => { + expect(isCronSystemEvent(value)).toBe(expected); }); }); From 54998a1042d471cc5063a6b4510fdcc23084e895 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 18:34:42 +0000 Subject: [PATCH 017/640] test: expand exec wrapper helper coverage --- src/infra/exec-wrapper-resolution.test.ts | 76 +++++++++++++++++++---- 1 file changed, 64 insertions(+), 12 deletions(-) diff --git a/src/infra/exec-wrapper-resolution.test.ts b/src/infra/exec-wrapper-resolution.test.ts index b271c97ee8d..58f1e696c22 100644 --- a/src/infra/exec-wrapper-resolution.test.ts +++ b/src/infra/exec-wrapper-resolution.test.ts @@ -1,16 +1,68 @@ import { describe, expect, test } from "vitest"; -import { normalizeExecutableToken } from "./exec-wrapper-resolution.js"; +import { + basenameLower, + isDispatchWrapperExecutable, + isShellWrapperExecutable, + normalizeExecutableToken, + unwrapKnownShellMultiplexerInvocation, +} from "./exec-wrapper-resolution.js"; -describe("normalizeExecutableToken", () => { - test("strips common windows executable suffixes", () => { - expect(normalizeExecutableToken("bun.cmd")).toBe("bun"); - expect(normalizeExecutableToken("deno.bat")).toBe("deno"); - expect(normalizeExecutableToken("pwsh.com")).toBe("pwsh"); - expect(normalizeExecutableToken("cmd.exe")).toBe("cmd"); - }); - - test("normalizes path-qualified windows shims", () => { - expect(normalizeExecutableToken("C:\\tools\\bun.cmd")).toBe("bun"); - expect(normalizeExecutableToken("/tmp/deno.exe")).toBe("deno"); +describe("basenameLower", () => { + test.each([ + { token: " Bun.CMD ", expected: "bun.cmd" }, + { token: "C:\\tools\\PwSh.EXE", expected: "pwsh.exe" }, + { token: "/tmp/bash", expected: "bash" }, + ])("normalizes basenames for %j", ({ token, expected }) => { + expect(basenameLower(token)).toBe(expected); + }); +}); + +describe("normalizeExecutableToken", () => { + test.each([ + { token: "bun.cmd", expected: "bun" }, + { token: "deno.bat", expected: "deno" }, + { token: "pwsh.com", expected: "pwsh" }, + { token: "cmd.exe", expected: "cmd" }, + { token: "C:\\tools\\bun.cmd", expected: "bun" }, + { token: "/tmp/deno.exe", expected: "deno" }, + { token: " /tmp/bash ", expected: "bash" }, + ])("normalizes executable tokens for %j", ({ token, expected }) => { + expect(normalizeExecutableToken(token)).toBe(expected); + }); +}); + +describe("wrapper classification", () => { + test.each([ + { token: "sudo", dispatch: true, shell: false }, + { token: "timeout.exe", dispatch: true, shell: false }, + { token: "bash", dispatch: false, shell: true }, + { token: "pwsh.exe", dispatch: false, shell: true }, + { token: "node", dispatch: false, shell: false }, + ])("classifies wrappers for %j", ({ token, dispatch, shell }) => { + expect(isDispatchWrapperExecutable(token)).toBe(dispatch); + expect(isShellWrapperExecutable(token)).toBe(shell); + }); +}); + +describe("unwrapKnownShellMultiplexerInvocation", () => { + test.each([ + { argv: [], expected: { kind: "not-wrapper" } }, + { argv: ["node", "-e", "1"], expected: { kind: "not-wrapper" } }, + { argv: ["busybox"], expected: { kind: "blocked", wrapper: "busybox" } }, + { argv: ["busybox", "ls"], expected: { kind: "blocked", wrapper: "busybox" } }, + { + argv: ["busybox", "sh", "-lc", "echo hi"], + expected: { kind: "unwrapped", wrapper: "busybox", argv: ["sh", "-lc", "echo hi"] }, + }, + { + argv: ["toybox", "--", "pwsh.exe", "-Command", "Get-Date"], + expected: { + kind: "unwrapped", + wrapper: "toybox", + argv: ["pwsh.exe", "-Command", "Get-Date"], + }, + }, + ])("unwraps shell multiplexers for %j", ({ argv, expected }) => { + expect(unwrapKnownShellMultiplexerInvocation(argv)).toEqual(expected); }); }); From 6a9285d1f544f3a052c03c4d024aba8eeefdf41c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 18:35:55 +0000 Subject: [PATCH 018/640] test: tighten byte count and file identity coverage --- src/infra/file-identity.test.ts | 82 ++++++++++++++++++++++--------- src/infra/json-utf8-bytes.test.ts | 26 ++++++++-- 2 files changed, 81 insertions(+), 27 deletions(-) diff --git a/src/infra/file-identity.test.ts b/src/infra/file-identity.test.ts index 12b3029cda1..2a28255a1ac 100644 --- a/src/infra/file-identity.test.ts +++ b/src/infra/file-identity.test.ts @@ -6,28 +6,64 @@ function stat(dev: number | bigint, ino: number | bigint): FileIdentityStat { } describe("sameFileIdentity", () => { - it("accepts exact dev+ino match", () => { - expect(sameFileIdentity(stat(7, 11), stat(7, 11), "linux")).toBe(true); - }); - - it("rejects inode mismatch", () => { - expect(sameFileIdentity(stat(7, 11), stat(7, 12), "linux")).toBe(false); - }); - - it("rejects dev mismatch on non-windows", () => { - expect(sameFileIdentity(stat(7, 11), stat(8, 11), "linux")).toBe(false); - }); - - it("accepts win32 dev mismatch when either side is 0", () => { - expect(sameFileIdentity(stat(0, 11), stat(8, 11), "win32")).toBe(true); - expect(sameFileIdentity(stat(7, 11), stat(0, 11), "win32")).toBe(true); - }); - - it("keeps dev strictness on win32 when both dev values are non-zero", () => { - expect(sameFileIdentity(stat(7, 11), stat(8, 11), "win32")).toBe(false); - }); - - it("handles bigint stats", () => { - expect(sameFileIdentity(stat(0n, 11n), stat(8n, 11n), "win32")).toBe(true); + it.each([ + { + name: "accepts exact dev+ino match", + left: stat(7, 11), + right: stat(7, 11), + platform: "linux" as const, + expected: true, + }, + { + name: "rejects inode mismatch", + left: stat(7, 11), + right: stat(7, 12), + platform: "linux" as const, + expected: false, + }, + { + name: "rejects dev mismatch on non-windows", + left: stat(7, 11), + right: stat(8, 11), + platform: "linux" as const, + expected: false, + }, + { + name: "keeps dev strictness on linux when one side is zero", + left: stat(0, 11), + right: stat(8, 11), + platform: "linux" as const, + expected: false, + }, + { + name: "accepts win32 dev mismatch when either side is 0", + left: stat(0, 11), + right: stat(8, 11), + platform: "win32" as const, + expected: true, + }, + { + name: "accepts win32 dev mismatch when right side is 0", + left: stat(7, 11), + right: stat(0, 11), + platform: "win32" as const, + expected: true, + }, + { + name: "keeps dev strictness on win32 when both dev values are non-zero", + left: stat(7, 11), + right: stat(8, 11), + platform: "win32" as const, + expected: false, + }, + { + name: "handles bigint stats", + left: stat(0n, 11n), + right: stat(8n, 11n), + platform: "win32" as const, + expected: true, + }, + ])("$name", ({ left, right, platform, expected }) => { + expect(sameFileIdentity(left, right, platform)).toBe(expected); }); }); diff --git a/src/infra/json-utf8-bytes.test.ts b/src/infra/json-utf8-bytes.test.ts index 3418359ae5f..e2f8e217be0 100644 --- a/src/infra/json-utf8-bytes.test.ts +++ b/src/infra/json-utf8-bytes.test.ts @@ -2,10 +2,24 @@ import { describe, expect, it } from "vitest"; import { jsonUtf8Bytes } from "./json-utf8-bytes.js"; describe("jsonUtf8Bytes", () => { - it("returns utf8 byte length for serializable values", () => { - expect(jsonUtf8Bytes({ a: "x", b: [1, 2, 3] })).toBe( - Buffer.byteLength(JSON.stringify({ a: "x", b: [1, 2, 3] }), "utf8"), - ); + it.each([ + { + name: "object payloads", + value: { a: "x", b: [1, 2, 3] }, + expected: Buffer.byteLength(JSON.stringify({ a: "x", b: [1, 2, 3] }), "utf8"), + }, + { + name: "strings", + value: "hello", + expected: Buffer.byteLength(JSON.stringify("hello"), "utf8"), + }, + { + name: "undefined via string fallback", + value: undefined, + expected: Buffer.byteLength("undefined", "utf8"), + }, + ])("returns utf8 byte length for $name", ({ value, expected }) => { + expect(jsonUtf8Bytes(value)).toBe(expected); }); it("falls back to string conversion when JSON serialization throws", () => { @@ -13,4 +27,8 @@ describe("jsonUtf8Bytes", () => { circular.self = circular; expect(jsonUtf8Bytes(circular)).toBe(Buffer.byteLength("[object Object]", "utf8")); }); + + it("uses string conversion for BigInt serialization failures", () => { + expect(jsonUtf8Bytes(12n)).toBe(Buffer.byteLength("12", "utf8")); + }); }); From 0bf930bdc7c74d0996907fda3844f667dbf4d0fe Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 18:36:23 +0000 Subject: [PATCH 019/640] test: harden agent event bus coverage --- src/infra/agent-events.test.ts | 61 ++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/src/infra/agent-events.test.ts b/src/infra/agent-events.test.ts index 9661ee13bfc..7f65ff5f752 100644 --- a/src/infra/agent-events.test.ts +++ b/src/infra/agent-events.test.ts @@ -83,4 +83,65 @@ describe("agent-events sequencing", () => { expect(receivedSessionKey).toBeUndefined(); }); + + test("merges later run context updates into existing runs", async () => { + resetAgentRunContextForTest(); + registerAgentRunContext("run-ctx", { + sessionKey: "session-main", + isControlUiVisible: true, + }); + registerAgentRunContext("run-ctx", { + verboseLevel: "high", + isHeartbeat: true, + }); + + expect(getAgentRunContext("run-ctx")).toEqual({ + sessionKey: "session-main", + verboseLevel: "high", + isHeartbeat: true, + isControlUiVisible: true, + }); + }); + + test("falls back to registered sessionKey when event sessionKey is blank", async () => { + resetAgentRunContextForTest(); + registerAgentRunContext("run-ctx", { sessionKey: "session-main" }); + + let receivedSessionKey: string | undefined; + const stop = onAgentEvent((evt) => { + receivedSessionKey = evt.sessionKey; + }); + emitAgentEvent({ + runId: "run-ctx", + stream: "assistant", + data: { text: "hi" }, + sessionKey: " ", + }); + stop(); + + expect(receivedSessionKey).toBe("session-main"); + }); + + test("keeps notifying later listeners when one throws", async () => { + const seen: string[] = []; + const stopBad = onAgentEvent(() => { + throw new Error("boom"); + }); + const stopGood = onAgentEvent((evt) => { + seen.push(evt.runId); + }); + + expect(() => + emitAgentEvent({ + runId: "run-safe", + stream: "assistant", + data: { text: "hi" }, + }), + ).not.toThrow(); + + stopGood(); + stopBad(); + + expect(seen).toEqual(["run-safe"]); + }); }); From 5b63f6486ff455ab0c0a0c9a1b51ee9b3cfcc9f4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 18:36:48 +0000 Subject: [PATCH 020/640] docs: note preferred fresh parallels macos snapshot --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index 45eed9ec2ad..de30fb15068 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -201,6 +201,7 @@ ## Agent-Specific Notes - Vocabulary: "makeup" = "mac app". +- Parallels macOS retests: use the snapshot most closely named like `macOS 26.3.1 fresh` when the user asks for a clean/fresh macOS rerun; avoid older Tahoe snapshots unless explicitly requested. - Never edit `node_modules` (global/Homebrew/npm/git installs too). Updates overwrite. Skill notes go in `tools.md` or `AGENTS.md`. - When adding a new `AGENTS.md` anywhere in the repo, also add a `CLAUDE.md` symlink pointing to it (example: `ln -s AGENTS.md CLAUDE.md`). - Signal: "update fly" => `fly ssh console -a flawd-bot -C "bash -lc 'cd /data/clawd/openclaw && git pull --rebase origin main'"` then `fly machines restart e825232f34d058 -a flawd-bot`. From fc408bba37be41477a8b2077db016c14d676a5b2 Mon Sep 17 00:00:00 2001 From: Keelan Fadden-Hopper Date: Fri, 13 Mar 2026 18:37:39 +0000 Subject: [PATCH 021/640] Fix incorrect rendering of brave costs in docs (#44989) Merged via squash. Prepared head SHA: 8c69de822273938cb57cf73371101fcc6e10ece2 Co-authored-by: keelanfh <19519457+keelanfh@users.noreply.github.com> Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com> Reviewed-by: @altaywtf --- CHANGELOG.md | 1 + docs/brave-search.md | 2 +- docs/reference/api-usage-costs.md | 4 ++-- docs/tools/web.md | 4 ++-- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f7679f4c5b0..79bc5bfb064 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -118,6 +118,7 @@ Docs: https://docs.openclaw.ai - Delivery/dedupe: trim completed direct-cron delivery cache correctly and keep mirrored transcript dedupe active even when transcript files contain malformed lines. (#44666) thanks @frankekn. - CLI/thinking help: add the missing `xhigh` level hints to `openclaw cron add`, `openclaw cron edit`, and `openclaw agent` so the help text matches the levels already accepted at runtime. (#44819) Thanks @kiki830621. - Agents/Anthropic replay: drop replayed assistant thinking blocks for native Anthropic and Bedrock Claude providers so persisted follow-up turns no longer fail on stored thinking blocks. (#44843) Thanks @jmcte. +- Docs/Brave pricing: escape literal dollar signs in Brave Search cost text so the docs render the free credit and per-request pricing correctly. (#44989) Thanks @keelanfh. ## 2026.3.11 diff --git a/docs/brave-search.md b/docs/brave-search.md index a8bba5c3e91..4a541690431 100644 --- a/docs/brave-search.md +++ b/docs/brave-search.md @@ -73,7 +73,7 @@ await web_search({ ## Notes - OpenClaw uses the Brave **Search** plan. If you have a legacy subscription (e.g. the original Free plan with 2,000 queries/month), it remains valid but does not include newer features like LLM Context or higher rate limits. -- Each Brave plan includes **$5/month in free credit** (renewing). The Search plan costs $5 per 1,000 requests, so the credit covers 1,000 queries/month. Set your usage limit in the Brave dashboard to avoid unexpected charges. See the [Brave API portal](https://brave.com/search/api/) for current plans. +- Each Brave plan includes **\$5/month in free credit** (renewing). The Search plan costs \$5 per 1,000 requests, so the credit covers 1,000 queries/month. Set your usage limit in the Brave dashboard to avoid unexpected charges. See the [Brave API portal](https://brave.com/search/api/) for current plans. - The Search plan includes the LLM Context endpoint and AI inference rights. Storing results to train or tune models requires a plan with explicit storage rights. See the Brave [Terms of Service](https://api-dashboard.search.brave.com/terms-of-service). - Results are cached for 15 minutes by default (configurable via `cacheTtlMinutes`). diff --git a/docs/reference/api-usage-costs.md b/docs/reference/api-usage-costs.md index baf4302ac0d..bbb1d90de87 100644 --- a/docs/reference/api-usage-costs.md +++ b/docs/reference/api-usage-costs.md @@ -85,8 +85,8 @@ See [Memory](/concepts/memory). - **Kimi (Moonshot)**: `KIMI_API_KEY`, `MOONSHOT_API_KEY`, or `tools.web.search.kimi.apiKey` - **Perplexity Search API**: `PERPLEXITY_API_KEY`, `OPENROUTER_API_KEY`, or `tools.web.search.perplexity.apiKey` -**Brave Search free credit:** Each Brave plan includes $5/month in renewing -free credit. The Search plan costs $5 per 1,000 requests, so the credit covers +**Brave Search free credit:** Each Brave plan includes \$5/month in renewing +free credit. The Search plan costs \$5 per 1,000 requests, so the credit covers 1,000 requests/month at no charge. Set your usage limit in the Brave dashboard to avoid unexpected charges. diff --git a/docs/tools/web.md b/docs/tools/web.md index e77d046ce5b..a2aa1d37bfd 100644 --- a/docs/tools/web.md +++ b/docs/tools/web.md @@ -65,8 +65,8 @@ Use `openclaw configure --section web` to set up your API key and choose a provi 2. In the dashboard, choose the **Search** plan and generate an API key. 3. Run `openclaw configure --section web` to store the key in config, or set `BRAVE_API_KEY` in your environment. -Each Brave plan includes **$5/month in free credit** (renewing). The Search -plan costs $5 per 1,000 requests, so the credit covers 1,000 queries/month. Set +Each Brave plan includes **\$5/month in free credit** (renewing). The Search +plan costs \$5 per 1,000 requests, so the credit covers 1,000 queries/month. Set your usage limit in the Brave dashboard to avoid unexpected charges. See the [Brave API portal](https://brave.com/search/api/) for current plans and pricing. From 06bdfc403e440118b6b0b1c951c08e75afe12a84 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 17:30:39 +0000 Subject: [PATCH 022/640] refactor: share system run command resolution --- src/infra/system-run-command.ts | 53 ++++++++------------------------- 1 file changed, 12 insertions(+), 41 deletions(-) diff --git a/src/infra/system-run-command.ts b/src/infra/system-run-command.ts index 12a5d32485d..3051a607683 100644 --- a/src/infra/system-run-command.ts +++ b/src/infra/system-run-command.ts @@ -167,52 +167,23 @@ export function resolveSystemRunCommand(params: { command?: unknown; rawCommand?: unknown; }): ResolvedSystemRunCommand { - const raw = normalizeRawCommandText(params.rawCommand); - const command = Array.isArray(params.command) ? params.command : []; - if (command.length === 0) { - if (raw) { - return { - ok: false, - message: "rawCommand requires params.command", - details: { code: "MISSING_COMMAND" }, - }; - } - return { - ok: true, - argv: [], - commandText: "", - shellPayload: null, - previewText: null, - }; - } - - const argv = command.map((v) => String(v)); - const validation = validateSystemRunCommandConsistency({ - argv, - rawCommand: raw, - allowLegacyShellText: false, - }); - if (!validation.ok) { - return { - ok: false, - message: validation.message, - details: validation.details ?? { code: "RAW_COMMAND_MISMATCH" }, - }; - } - - return { - ok: true, - argv, - commandText: validation.commandText, - shellPayload: validation.shellPayload, - previewText: validation.previewText, - }; + return resolveSystemRunCommandWithMode(params, false); } export function resolveSystemRunCommandRequest(params: { command?: unknown; rawCommand?: unknown; }): ResolvedSystemRunCommand { + return resolveSystemRunCommandWithMode(params, true); +} + +function resolveSystemRunCommandWithMode( + params: { + command?: unknown; + rawCommand?: unknown; + }, + allowLegacyShellText: boolean, +): ResolvedSystemRunCommand { const raw = normalizeRawCommandText(params.rawCommand); const command = Array.isArray(params.command) ? params.command : []; if (command.length === 0) { @@ -236,7 +207,7 @@ export function resolveSystemRunCommandRequest(params: { const validation = validateSystemRunCommandConsistency({ argv, rawCommand: raw, - allowLegacyShellText: true, + allowLegacyShellText, }); if (!validation.ok) { return { From db9c755045a96526f7b6e0f92d91fc50a7eeb7e9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 17:31:29 +0000 Subject: [PATCH 023/640] refactor: share readiness test harness --- src/gateway/server/readiness.test.ts | 286 ++++++++++++++------------- 1 file changed, 151 insertions(+), 135 deletions(-) diff --git a/src/gateway/server/readiness.test.ts b/src/gateway/server/readiness.test.ts index 2ad29d3655a..b333277f158 100644 --- a/src/gateway/server/readiness.test.ts +++ b/src/gateway/server/readiness.test.ts @@ -46,172 +46,188 @@ function createHealthyDiscordManager(startedAt: number, lastEventAt: number): Ch ); } +function withReadinessClock(run: () => void) { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-06T12:00:00Z")); + try { + run(); + } finally { + vi.useRealTimers(); + } +} + +function createReadinessHarness(params: { + startedAgoMs: number; + accounts: Record>; + cacheTtlMs?: number; +}) { + const startedAt = Date.now() - params.startedAgoMs; + const manager = createManager(snapshotWith(params.accounts)); + return { + manager, + readiness: createReadinessChecker({ + channelManager: manager, + startedAt, + cacheTtlMs: params.cacheTtlMs, + }), + }; +} + describe("createReadinessChecker", () => { it("reports ready when all managed channels are healthy", () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-03-06T12:00:00Z")); - const startedAt = Date.now() - 5 * 60_000; - const manager = createHealthyDiscordManager(startedAt, Date.now() - 1_000); + withReadinessClock(() => { + const startedAt = Date.now() - 5 * 60_000; + const manager = createHealthyDiscordManager(startedAt, Date.now() - 1_000); - const readiness = createReadinessChecker({ channelManager: manager, startedAt }); - expect(readiness()).toEqual({ ready: true, failing: [], uptimeMs: 300_000 }); - vi.useRealTimers(); + const readiness = createReadinessChecker({ channelManager: manager, startedAt }); + expect(readiness()).toEqual({ ready: true, failing: [], uptimeMs: 300_000 }); + }); }); it("ignores disabled and unconfigured channels", () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-03-06T12:00:00Z")); - const startedAt = Date.now() - 5 * 60_000; - const manager = createManager( - snapshotWith({ - discord: { - running: false, - enabled: false, - configured: true, - lastStartAt: startedAt, + withReadinessClock(() => { + const { readiness } = createReadinessHarness({ + startedAgoMs: 5 * 60_000, + accounts: { + discord: { + running: false, + enabled: false, + configured: true, + lastStartAt: Date.now() - 5 * 60_000, + }, + telegram: { + running: false, + enabled: true, + configured: false, + lastStartAt: Date.now() - 5 * 60_000, + }, }, - telegram: { - running: false, - enabled: true, - configured: false, - lastStartAt: startedAt, - }, - }), - ); - - const readiness = createReadinessChecker({ channelManager: manager, startedAt }); - expect(readiness()).toEqual({ ready: true, failing: [], uptimeMs: 300_000 }); - vi.useRealTimers(); + }); + expect(readiness()).toEqual({ ready: true, failing: [], uptimeMs: 300_000 }); + }); }); it("uses startup grace before marking disconnected channels not ready", () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-03-06T12:00:00Z")); - const startedAt = Date.now() - 30_000; - const manager = createManager( - snapshotWith({ - discord: { - running: true, - connected: false, - enabled: true, - configured: true, - lastStartAt: startedAt, + withReadinessClock(() => { + const { readiness } = createReadinessHarness({ + startedAgoMs: 30_000, + accounts: { + discord: { + running: true, + connected: false, + enabled: true, + configured: true, + lastStartAt: Date.now() - 30_000, + }, }, - }), - ); - - const readiness = createReadinessChecker({ channelManager: manager, startedAt }); - expect(readiness()).toEqual({ ready: true, failing: [], uptimeMs: 30_000 }); - vi.useRealTimers(); + }); + expect(readiness()).toEqual({ ready: true, failing: [], uptimeMs: 30_000 }); + }); }); it("reports disconnected managed channels after startup grace", () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-03-06T12:00:00Z")); - const startedAt = Date.now() - 5 * 60_000; - const manager = createManager( - snapshotWith({ - discord: { - running: true, - connected: false, - enabled: true, - configured: true, - lastStartAt: startedAt, + withReadinessClock(() => { + const { readiness } = createReadinessHarness({ + startedAgoMs: 5 * 60_000, + accounts: { + discord: { + running: true, + connected: false, + enabled: true, + configured: true, + lastStartAt: Date.now() - 5 * 60_000, + }, }, - }), - ); - - const readiness = createReadinessChecker({ channelManager: manager, startedAt }); - expect(readiness()).toEqual({ ready: false, failing: ["discord"], uptimeMs: 300_000 }); - vi.useRealTimers(); + }); + expect(readiness()).toEqual({ ready: false, failing: ["discord"], uptimeMs: 300_000 }); + }); }); it("keeps restart-pending channels ready during reconnect backoff", () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-03-06T12:00:00Z")); - const startedAt = Date.now() - 5 * 60_000; - const manager = createManager( - snapshotWith({ - discord: { - running: false, - restartPending: true, - reconnectAttempts: 3, - enabled: true, - configured: true, - lastStartAt: startedAt - 30_000, - lastStopAt: Date.now() - 5_000, + withReadinessClock(() => { + const startedAt = Date.now() - 5 * 60_000; + const { readiness } = createReadinessHarness({ + startedAgoMs: 5 * 60_000, + accounts: { + discord: { + running: false, + restartPending: true, + reconnectAttempts: 3, + enabled: true, + configured: true, + lastStartAt: startedAt - 30_000, + lastStopAt: Date.now() - 5_000, + }, }, - }), - ); - - const readiness = createReadinessChecker({ channelManager: manager, startedAt }); - expect(readiness()).toEqual({ ready: true, failing: [], uptimeMs: 300_000 }); - vi.useRealTimers(); + }); + expect(readiness()).toEqual({ ready: true, failing: [], uptimeMs: 300_000 }); + }); }); it("treats stale-socket channels as ready to avoid pulling healthy idle pods", () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-03-06T12:00:00Z")); - const startedAt = Date.now() - 31 * 60_000; - const manager = createManager( - snapshotWith({ - discord: { - running: true, - connected: true, - enabled: true, - configured: true, - lastStartAt: startedAt, - lastEventAt: Date.now() - 31 * 60_000, + withReadinessClock(() => { + const startedAt = Date.now() - 31 * 60_000; + const { readiness } = createReadinessHarness({ + startedAgoMs: 31 * 60_000, + accounts: { + discord: { + running: true, + connected: true, + enabled: true, + configured: true, + lastStartAt: startedAt, + lastEventAt: Date.now() - 31 * 60_000, + }, }, - }), - ); - - const readiness = createReadinessChecker({ channelManager: manager, startedAt }); - expect(readiness()).toEqual({ ready: true, failing: [], uptimeMs: 1_860_000 }); - vi.useRealTimers(); + }); + expect(readiness()).toEqual({ ready: true, failing: [], uptimeMs: 1_860_000 }); + }); }); it("keeps telegram long-polling channels ready without stale-socket classification", () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-03-06T12:00:00Z")); - const startedAt = Date.now() - 31 * 60_000; - const manager = createManager( - snapshotWith({ - telegram: { - running: true, - connected: true, - enabled: true, - configured: true, - lastStartAt: startedAt, - lastEventAt: null, + withReadinessClock(() => { + const startedAt = Date.now() - 31 * 60_000; + const { readiness } = createReadinessHarness({ + startedAgoMs: 31 * 60_000, + accounts: { + telegram: { + running: true, + connected: true, + enabled: true, + configured: true, + lastStartAt: startedAt, + lastEventAt: null, + }, }, - }), - ); - - const readiness = createReadinessChecker({ channelManager: manager, startedAt }); - expect(readiness()).toEqual({ ready: true, failing: [], uptimeMs: 1_860_000 }); - vi.useRealTimers(); + }); + expect(readiness()).toEqual({ ready: true, failing: [], uptimeMs: 1_860_000 }); + }); }); it("caches readiness snapshots briefly to keep repeated probes cheap", () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-03-06T12:00:00Z")); - const startedAt = Date.now() - 5 * 60_000; - const manager = createHealthyDiscordManager(startedAt, Date.now() - 1_000); + withReadinessClock(() => { + const { manager, readiness } = createReadinessHarness({ + startedAgoMs: 5 * 60_000, + accounts: { + discord: { + running: true, + connected: true, + enabled: true, + configured: true, + lastStartAt: Date.now() - 5 * 60_000, + lastEventAt: Date.now() - 1_000, + }, + }, + cacheTtlMs: 1_000, + }); + expect(readiness()).toEqual({ ready: true, failing: [], uptimeMs: 300_000 }); + vi.advanceTimersByTime(500); + expect(readiness()).toEqual({ ready: true, failing: [], uptimeMs: 300_500 }); + expect(manager.getRuntimeSnapshot).toHaveBeenCalledTimes(1); - const readiness = createReadinessChecker({ - channelManager: manager, - startedAt, - cacheTtlMs: 1_000, + vi.advanceTimersByTime(600); + expect(readiness()).toEqual({ ready: true, failing: [], uptimeMs: 301_100 }); + expect(manager.getRuntimeSnapshot).toHaveBeenCalledTimes(2); }); - expect(readiness()).toEqual({ ready: true, failing: [], uptimeMs: 300_000 }); - vi.advanceTimersByTime(500); - expect(readiness()).toEqual({ ready: true, failing: [], uptimeMs: 300_500 }); - expect(manager.getRuntimeSnapshot).toHaveBeenCalledTimes(1); - - vi.advanceTimersByTime(600); - expect(readiness()).toEqual({ ready: true, failing: [], uptimeMs: 301_100 }); - expect(manager.getRuntimeSnapshot).toHaveBeenCalledTimes(2); - vi.useRealTimers(); }); }); From 31c8bb91672d6a2fd16e2ff6ff8df64f38d83e48 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 17:32:44 +0000 Subject: [PATCH 024/640] refactor: share agent wait dedupe test entries --- .../server-methods/agent-wait-dedupe.test.ts | 198 +++++++++--------- 1 file changed, 94 insertions(+), 104 deletions(-) diff --git a/src/gateway/server-methods/agent-wait-dedupe.test.ts b/src/gateway/server-methods/agent-wait-dedupe.test.ts index e9a1899c88b..c5204271983 100644 --- a/src/gateway/server-methods/agent-wait-dedupe.test.ts +++ b/src/gateway/server-methods/agent-wait-dedupe.test.ts @@ -7,6 +7,25 @@ import { } from "./agent-wait-dedupe.js"; describe("agent wait dedupe helper", () => { + function setRunEntry(params: { + dedupe: Map; + kind: "agent" | "chat"; + runId: string; + ts?: number; + ok?: boolean; + payload: Record; + }) { + setGatewayDedupeEntry({ + dedupe: params.dedupe, + key: `${params.kind}:${params.runId}`, + entry: { + ts: params.ts ?? Date.now(), + ok: params.ok ?? true, + payload: params.payload, + }, + }); + } + beforeEach(() => { __testing.resetWaiters(); vi.useFakeTimers(); @@ -29,18 +48,15 @@ describe("agent wait dedupe helper", () => { await Promise.resolve(); expect(__testing.getWaiterCount(runId)).toBe(1); - setGatewayDedupeEntry({ + setRunEntry({ dedupe, - key: `chat:${runId}`, - entry: { - ts: Date.now(), - ok: true, - payload: { - runId, - status: "ok", - startedAt: 100, - endedAt: 200, - }, + kind: "chat", + runId, + payload: { + runId, + status: "ok", + startedAt: 100, + endedAt: 200, }, }); @@ -56,28 +72,22 @@ describe("agent wait dedupe helper", () => { it("keeps stale chat dedupe blocked while agent dedupe is in-flight", async () => { const dedupe = new Map(); const runId = "run-stale-chat"; - setGatewayDedupeEntry({ + setRunEntry({ dedupe, - key: `chat:${runId}`, - entry: { - ts: Date.now(), - ok: true, - payload: { - runId, - status: "ok", - }, + kind: "chat", + runId, + payload: { + runId, + status: "ok", }, }); - setGatewayDedupeEntry({ + setRunEntry({ dedupe, - key: `agent:${runId}`, - entry: { - ts: Date.now(), - ok: true, - payload: { - runId, - status: "accepted", - }, + kind: "agent", + runId, + payload: { + runId, + status: "accepted", }, }); @@ -100,30 +110,26 @@ describe("agent wait dedupe helper", () => { it("uses newer terminal chat snapshot when agent entry is non-terminal", () => { const dedupe = new Map(); const runId = "run-nonterminal-agent-with-newer-chat"; - setGatewayDedupeEntry({ + setRunEntry({ dedupe, - key: `agent:${runId}`, - entry: { - ts: 100, - ok: true, - payload: { - runId, - status: "accepted", - }, + kind: "agent", + runId, + ts: 100, + payload: { + runId, + status: "accepted", }, }); - setGatewayDedupeEntry({ + setRunEntry({ dedupe, - key: `chat:${runId}`, - entry: { - ts: 200, - ok: true, - payload: { - runId, - status: "ok", - startedAt: 1, - endedAt: 2, - }, + kind: "chat", + runId, + ts: 200, + payload: { + runId, + status: "ok", + startedAt: 1, + endedAt: 2, }, }); @@ -143,16 +149,13 @@ describe("agent wait dedupe helper", () => { it("ignores stale agent snapshots when waiting for an active chat run", async () => { const dedupe = new Map(); const runId = "run-chat-active-ignore-agent"; - setGatewayDedupeEntry({ + setRunEntry({ dedupe, - key: `agent:${runId}`, - entry: { - ts: Date.now(), - ok: true, - payload: { - runId, - status: "ok", - }, + kind: "agent", + runId, + payload: { + runId, + status: "ok", }, }); @@ -173,18 +176,15 @@ describe("agent wait dedupe helper", () => { await Promise.resolve(); expect(__testing.getWaiterCount(runId)).toBe(1); - setGatewayDedupeEntry({ + setRunEntry({ dedupe, - key: `chat:${runId}`, - entry: { - ts: Date.now(), - ok: true, - payload: { - runId, - status: "ok", - startedAt: 123, - endedAt: 456, - }, + kind: "chat", + runId, + payload: { + runId, + status: "ok", + startedAt: 123, + endedAt: 456, }, }); @@ -200,23 +200,20 @@ describe("agent wait dedupe helper", () => { const runId = "run-collision"; const dedupe = new Map(); - setGatewayDedupeEntry({ + setRunEntry({ dedupe, - key: `agent:${runId}`, - entry: { - ts: 100, - ok: true, - payload: { runId, status: "ok", startedAt: 10, endedAt: 20 }, - }, + kind: "agent", + runId, + ts: 100, + payload: { runId, status: "ok", startedAt: 10, endedAt: 20 }, }); - setGatewayDedupeEntry({ + setRunEntry({ dedupe, - key: `chat:${runId}`, - entry: { - ts: 200, - ok: false, - payload: { runId, status: "error", startedAt: 30, endedAt: 40, error: "chat failed" }, - }, + kind: "chat", + runId, + ts: 200, + ok: false, + payload: { runId, status: "error", startedAt: 30, endedAt: 40, error: "chat failed" }, }); expect( @@ -232,23 +229,19 @@ describe("agent wait dedupe helper", () => { }); const dedupeReverse = new Map(); - setGatewayDedupeEntry({ + setRunEntry({ dedupe: dedupeReverse, - key: `chat:${runId}`, - entry: { - ts: 100, - ok: true, - payload: { runId, status: "ok", startedAt: 1, endedAt: 2 }, - }, + kind: "chat", + runId, + ts: 100, + payload: { runId, status: "ok", startedAt: 1, endedAt: 2 }, }); - setGatewayDedupeEntry({ + setRunEntry({ dedupe: dedupeReverse, - key: `agent:${runId}`, - entry: { - ts: 200, - ok: true, - payload: { runId, status: "timeout", startedAt: 3, endedAt: 4, error: "still running" }, - }, + kind: "agent", + runId, + ts: 200, + payload: { runId, status: "timeout", startedAt: 3, endedAt: 4, error: "still running" }, }); expect( @@ -281,14 +274,11 @@ describe("agent wait dedupe helper", () => { await Promise.resolve(); expect(__testing.getWaiterCount(runId)).toBe(2); - setGatewayDedupeEntry({ + setRunEntry({ dedupe, - key: `chat:${runId}`, - entry: { - ts: Date.now(), - ok: true, - payload: { runId, status: "ok" }, - }, + kind: "chat", + runId, + payload: { runId, status: "ok" }, }); await expect(first).resolves.toEqual( From 3bf3ebf5143632680b99fdce0408f44c366aa6ef Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 17:33:45 +0000 Subject: [PATCH 025/640] refactor: share exec approval dm route checks --- src/infra/exec-approval-surface.ts | 33 +++++++++++++++++------------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/src/infra/exec-approval-surface.ts b/src/infra/exec-approval-surface.ts index bdefb933379..b20e31850b8 100644 --- a/src/infra/exec-approval-surface.ts +++ b/src/infra/exec-approval-surface.ts @@ -50,8 +50,18 @@ export function resolveExecApprovalInitiatingSurfaceState(params: { return { kind: "unsupported", channel, channelLabel }; } -export function hasConfiguredExecApprovalDmRoute(cfg: OpenClawConfig): boolean { - for (const account of listEnabledDiscordAccounts(cfg)) { +function hasExecApprovalDmRoute( + accounts: Array<{ + config: { + execApprovals?: { + enabled?: boolean; + approvers?: unknown[]; + target?: string; + }; + }; + }>, +): boolean { + for (const account of accounts) { const execApprovals = account.config.execApprovals; if (!execApprovals?.enabled || (execApprovals.approvers?.length ?? 0) === 0) { continue; @@ -61,17 +71,12 @@ export function hasConfiguredExecApprovalDmRoute(cfg: OpenClawConfig): boolean { return true; } } - - for (const account of listEnabledTelegramAccounts(cfg)) { - const execApprovals = account.config.execApprovals; - if (!execApprovals?.enabled || (execApprovals.approvers?.length ?? 0) === 0) { - continue; - } - const target = execApprovals.target ?? "dm"; - if (target === "dm" || target === "both") { - return true; - } - } - return false; } + +export function hasConfiguredExecApprovalDmRoute(cfg: OpenClawConfig): boolean { + return ( + hasExecApprovalDmRoute(listEnabledDiscordAccounts(cfg)) || + hasExecApprovalDmRoute(listEnabledTelegramAccounts(cfg)) + ); +} From b697c053545ff5d64bee48d95e1e3c34f22873e4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 17:34:41 +0000 Subject: [PATCH 026/640] refactor: share discord allowlist name matching --- src/discord/monitor/allow-list.ts | 31 +++++++++++++++++++------------ 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/src/discord/monitor/allow-list.ts b/src/discord/monitor/allow-list.ts index 7c1250cb8ef..583d4fa7cd2 100644 --- a/src/discord/monitor/allow-list.ts +++ b/src/discord/monitor/allow-list.ts @@ -103,6 +103,21 @@ export function normalizeDiscordSlug(value: string) { .replace(/^-+|-+$/g, ""); } +function resolveDiscordAllowListNameMatch( + list: DiscordAllowList, + candidate: { name?: string; tag?: string }, +): { matchKey: string; matchSource: "name" | "tag" } | null { + const nameSlug = candidate.name ? normalizeDiscordSlug(candidate.name) : ""; + if (nameSlug && list.names.has(nameSlug)) { + return { matchKey: nameSlug, matchSource: "name" }; + } + const tagSlug = candidate.tag ? normalizeDiscordSlug(candidate.tag) : ""; + if (tagSlug && list.names.has(tagSlug)) { + return { matchKey: tagSlug, matchSource: "tag" }; + } + return null; +} + export function allowListMatches( list: DiscordAllowList, candidate: { id?: string; name?: string; tag?: string }, @@ -115,11 +130,7 @@ export function allowListMatches( return true; } if (params?.allowNameMatching === true) { - const slug = candidate.name ? normalizeDiscordSlug(candidate.name) : ""; - if (slug && list.names.has(slug)) { - return true; - } - if (candidate.tag && list.names.has(normalizeDiscordSlug(candidate.tag))) { + if (resolveDiscordAllowListNameMatch(list, candidate)) { return true; } } @@ -139,13 +150,9 @@ export function resolveDiscordAllowListMatch(params: { return { allowed: true, matchKey: candidate.id, matchSource: "id" }; } if (params.allowNameMatching === true) { - const nameSlug = candidate.name ? normalizeDiscordSlug(candidate.name) : ""; - if (nameSlug && allowList.names.has(nameSlug)) { - return { allowed: true, matchKey: nameSlug, matchSource: "name" }; - } - const tagSlug = candidate.tag ? normalizeDiscordSlug(candidate.tag) : ""; - if (tagSlug && allowList.names.has(tagSlug)) { - return { allowed: true, matchKey: tagSlug, matchSource: "tag" }; + const namedMatch = resolveDiscordAllowListNameMatch(allowList, candidate); + if (namedMatch) { + return { allowed: true, ...namedMatch }; } } return { allowed: false }; From 8633d2e0a9cbd8980c657ef5e0d7e50ce5c57a20 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 17:35:07 +0000 Subject: [PATCH 027/640] refactor: share system presence version checks --- src/infra/system-presence.version.test.ts | 35 ++++++++++++----------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/src/infra/system-presence.version.test.ts b/src/infra/system-presence.version.test.ts index 8465466ef9c..9c6d725e34f 100644 --- a/src/infra/system-presence.version.test.ts +++ b/src/infra/system-presence.version.test.ts @@ -13,46 +13,47 @@ async function withPresenceModule( } describe("system-presence version fallback", () => { + async function expectSelfVersion( + env: Record, + expectedVersion: string | (() => Promise), + ) { + await withPresenceModule(env, async ({ listSystemPresence }) => { + const selfEntry = listSystemPresence().find((entry) => entry.reason === "self"); + const resolvedExpected = + typeof expectedVersion === "function" ? await expectedVersion() : expectedVersion; + expect(selfEntry?.version).toBe(resolvedExpected); + }); + } + it("uses runtime VERSION when OPENCLAW_VERSION is not set", async () => { - await withPresenceModule( + await expectSelfVersion( { OPENCLAW_SERVICE_VERSION: "2.4.6-service", npm_package_version: "1.0.0-package", }, - async ({ listSystemPresence }) => { - const { VERSION } = await import("../version.js"); - const selfEntry = listSystemPresence().find((entry) => entry.reason === "self"); - expect(selfEntry?.version).toBe(VERSION); - }, + async () => (await import("../version.js")).VERSION, ); }); it("prefers OPENCLAW_VERSION over runtime VERSION", async () => { - await withPresenceModule( + await expectSelfVersion( { OPENCLAW_VERSION: "9.9.9-cli", OPENCLAW_SERVICE_VERSION: "2.4.6-service", npm_package_version: "1.0.0-package", }, - ({ listSystemPresence }) => { - const selfEntry = listSystemPresence().find((entry) => entry.reason === "self"); - expect(selfEntry?.version).toBe("9.9.9-cli"); - }, + "9.9.9-cli", ); }); it("uses runtime VERSION when OPENCLAW_VERSION and OPENCLAW_SERVICE_VERSION are blank", async () => { - await withPresenceModule( + await expectSelfVersion( { OPENCLAW_VERSION: " ", OPENCLAW_SERVICE_VERSION: "\t", npm_package_version: "1.0.0-package", }, - async ({ listSystemPresence }) => { - const { VERSION } = await import("../version.js"); - const selfEntry = listSystemPresence().find((entry) => entry.reason === "self"); - expect(selfEntry?.version).toBe(VERSION); - }, + async () => (await import("../version.js")).VERSION, ); }); }); From b5349f7563a8953af3a3d19215aec9ce15ec1bbb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 17:37:04 +0000 Subject: [PATCH 028/640] refactor: share startup auth token assertions --- src/gateway/startup-auth.test.ts | 64 +++++++++++++++++--------------- 1 file changed, 34 insertions(+), 30 deletions(-) diff --git a/src/gateway/startup-auth.test.ts b/src/gateway/startup-auth.test.ts index c2ad8a51915..bfd1912f28c 100644 --- a/src/gateway/startup-auth.test.ts +++ b/src/gateway/startup-auth.test.ts @@ -53,6 +53,28 @@ describe("ensureGatewayStartupAuth", () => { expect(mocks.writeConfigFile).not.toHaveBeenCalled(); } + async function expectResolvedToken(params: { + cfg: OpenClawConfig; + env: NodeJS.ProcessEnv; + expectedToken: string; + expectedConfiguredToken?: unknown; + }) { + const result = await ensureGatewayStartupAuth({ + cfg: params.cfg, + env: params.env, + persist: true, + }); + + expect(result.generatedToken).toBeUndefined(); + expect(result.persistedGeneratedToken).toBe(false); + expect(result.auth.mode).toBe("token"); + expect(result.auth.token).toBe(params.expectedToken); + if ("expectedConfiguredToken" in params) { + expect(result.cfg.gateway?.auth?.token).toEqual(params.expectedConfiguredToken); + } + expect(mocks.writeConfigFile).not.toHaveBeenCalled(); + } + it("generates and persists a token when startup auth is missing", async () => { const result = await ensureGatewayStartupAuth({ cfg: {}, @@ -138,7 +160,7 @@ describe("ensureGatewayStartupAuth", () => { }); it("resolves gateway.auth.token SecretRef before startup auth checks", async () => { - const result = await ensureGatewayStartupAuth({ + await expectResolvedToken({ cfg: { gateway: { auth: { @@ -155,23 +177,17 @@ describe("ensureGatewayStartupAuth", () => { env: { GW_TOKEN: "resolved-token", } as NodeJS.ProcessEnv, - persist: true, + expectedToken: "resolved-token", + expectedConfiguredToken: { + source: "env", + provider: "default", + id: "GW_TOKEN", + }, }); - - expect(result.generatedToken).toBeUndefined(); - expect(result.persistedGeneratedToken).toBe(false); - expect(result.auth.mode).toBe("token"); - expect(result.auth.token).toBe("resolved-token"); - expect(result.cfg.gateway?.auth?.token).toEqual({ - source: "env", - provider: "default", - id: "GW_TOKEN", - }); - expect(mocks.writeConfigFile).not.toHaveBeenCalled(); }); it("resolves env-template gateway.auth.token before env-token short-circuiting", async () => { - const result = await ensureGatewayStartupAuth({ + await expectResolvedToken({ cfg: { gateway: { auth: { @@ -183,19 +199,13 @@ describe("ensureGatewayStartupAuth", () => { env: { OPENCLAW_GATEWAY_TOKEN: "resolved-token", } as NodeJS.ProcessEnv, - persist: true, + expectedToken: "resolved-token", + expectedConfiguredToken: "${OPENCLAW_GATEWAY_TOKEN}", }); - - expect(result.generatedToken).toBeUndefined(); - expect(result.persistedGeneratedToken).toBe(false); - expect(result.auth.mode).toBe("token"); - expect(result.auth.token).toBe("resolved-token"); - expect(result.cfg.gateway?.auth?.token).toBe("${OPENCLAW_GATEWAY_TOKEN}"); - expect(mocks.writeConfigFile).not.toHaveBeenCalled(); }); it("uses OPENCLAW_GATEWAY_TOKEN without resolving configured token SecretRef", async () => { - const result = await ensureGatewayStartupAuth({ + await expectResolvedToken({ cfg: { gateway: { auth: { @@ -212,14 +222,8 @@ describe("ensureGatewayStartupAuth", () => { env: { OPENCLAW_GATEWAY_TOKEN: "token-from-env", } as NodeJS.ProcessEnv, - persist: true, + expectedToken: "token-from-env", }); - - expect(result.generatedToken).toBeUndefined(); - expect(result.persistedGeneratedToken).toBe(false); - expect(result.auth.mode).toBe("token"); - expect(result.auth.token).toBe("token-from-env"); - expect(mocks.writeConfigFile).not.toHaveBeenCalled(); }); it("fails when gateway.auth.token SecretRef is active and unresolved", async () => { From 88b87d893d5cfbf11e97f6f17489f446a9355cc1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 17:39:32 +0000 Subject: [PATCH 029/640] refactor: share temp dir test helper --- .../sandbox/fs-bridge-mutation-helper.test.ts | 25 ++--- src/config/paths.test.ts | 19 +--- src/infra/boundary-path.test.ts | 21 ++-- src/infra/path-alias-guards.test.ts | 97 +++++++++---------- src/test-helpers/temp-dir.ts | 23 +++++ 5 files changed, 90 insertions(+), 95 deletions(-) create mode 100644 src/test-helpers/temp-dir.ts diff --git a/src/agents/sandbox/fs-bridge-mutation-helper.test.ts b/src/agents/sandbox/fs-bridge-mutation-helper.test.ts index 57f22cc84b6..973c81341d1 100644 --- a/src/agents/sandbox/fs-bridge-mutation-helper.test.ts +++ b/src/agents/sandbox/fs-bridge-mutation-helper.test.ts @@ -1,22 +1,13 @@ import { spawnSync } from "node:child_process"; import fs from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; +import { withTempDir } from "../../test-helpers/temp-dir.js"; import { buildPinnedWritePlan, SANDBOX_PINNED_MUTATION_PYTHON, } from "./fs-bridge-mutation-helper.js"; -async function withTempRoot(prefix: string, run: (root: string) => Promise): Promise { - const root = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); - try { - return await run(root); - } finally { - await fs.rm(root, { recursive: true, force: true }); - } -} - function runMutation(args: string[], input?: string) { return spawnSync("python3", ["-c", SANDBOX_PINNED_MUTATION_PYTHON, ...args], { input, @@ -56,7 +47,7 @@ function runWritePlan(args: string[], input?: string) { describe("sandbox pinned mutation helper", () => { it("writes through a pinned directory fd", async () => { - await withTempRoot("openclaw-mutation-helper-", async (root) => { + await withTempDir({ prefix: "openclaw-mutation-helper-" }, async (root) => { const workspace = path.join(root, "workspace"); await fs.mkdir(workspace, { recursive: true }); @@ -72,7 +63,7 @@ describe("sandbox pinned mutation helper", () => { it.runIf(process.platform !== "win32")( "preserves stdin payload bytes when the pinned write plan runs through sh", async () => { - await withTempRoot("openclaw-mutation-helper-", async (root) => { + await withTempDir({ prefix: "openclaw-mutation-helper-" }, async (root) => { const workspace = path.join(root, "workspace"); await fs.mkdir(workspace, { recursive: true }); @@ -92,7 +83,7 @@ describe("sandbox pinned mutation helper", () => { it.runIf(process.platform !== "win32")( "rejects symlink-parent writes instead of materializing a temp file outside the mount", async () => { - await withTempRoot("openclaw-mutation-helper-", async (root) => { + await withTempDir({ prefix: "openclaw-mutation-helper-" }, async (root) => { const workspace = path.join(root, "workspace"); const outside = path.join(root, "outside"); await fs.mkdir(workspace, { recursive: true }); @@ -108,7 +99,7 @@ describe("sandbox pinned mutation helper", () => { ); it.runIf(process.platform !== "win32")("rejects symlink segments during mkdirp", async () => { - await withTempRoot("openclaw-mutation-helper-", async (root) => { + await withTempDir({ prefix: "openclaw-mutation-helper-" }, async (root) => { const workspace = path.join(root, "workspace"); const outside = path.join(root, "outside"); await fs.mkdir(workspace, { recursive: true }); @@ -123,7 +114,7 @@ describe("sandbox pinned mutation helper", () => { }); it.runIf(process.platform !== "win32")("remove unlinks the symlink itself", async () => { - await withTempRoot("openclaw-mutation-helper-", async (root) => { + await withTempDir({ prefix: "openclaw-mutation-helper-" }, async (root) => { const workspace = path.join(root, "workspace"); const outside = path.join(root, "outside"); await fs.mkdir(workspace, { recursive: true }); @@ -144,7 +135,7 @@ describe("sandbox pinned mutation helper", () => { it.runIf(process.platform !== "win32")( "rejects symlink destination parents during rename", async () => { - await withTempRoot("openclaw-mutation-helper-", async (root) => { + await withTempDir({ prefix: "openclaw-mutation-helper-" }, async (root) => { const workspace = path.join(root, "workspace"); const outside = path.join(root, "outside"); await fs.mkdir(workspace, { recursive: true }); @@ -175,7 +166,7 @@ describe("sandbox pinned mutation helper", () => { it.runIf(process.platform !== "win32")( "copies directories across different mount roots during rename fallback", async () => { - await withTempRoot("openclaw-mutation-helper-", async (root) => { + await withTempDir({ prefix: "openclaw-mutation-helper-" }, async (root) => { const sourceRoot = path.join(root, "source"); const destRoot = path.join(root, "dest"); await fs.mkdir(path.join(sourceRoot, "dir", "nested"), { recursive: true }); diff --git a/src/config/paths.test.ts b/src/config/paths.test.ts index b8afe7674cb..6d2ffcfaf08 100644 --- a/src/config/paths.test.ts +++ b/src/config/paths.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; +import { withTempDir } from "../test-helpers/temp-dir.js"; import { resolveDefaultConfigCandidates, resolveConfigPathCandidate, @@ -37,15 +37,6 @@ describe("oauth paths", () => { }); describe("state + config path candidates", () => { - async function withTempRoot(prefix: string, run: (root: string) => Promise): Promise { - const root = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); - try { - await run(root); - } finally { - await fs.rm(root, { recursive: true, force: true }); - } - } - function expectOpenClawHomeDefaults(env: NodeJS.ProcessEnv): void { const configuredHome = env.OPENCLAW_HOME; if (!configuredHome) { @@ -107,7 +98,7 @@ describe("state + config path candidates", () => { }); it("prefers ~/.openclaw when it exists and legacy dir is missing", async () => { - await withTempRoot("openclaw-state-", async (root) => { + await withTempDir({ prefix: "openclaw-state-" }, async (root) => { const newDir = path.join(root, ".openclaw"); await fs.mkdir(newDir, { recursive: true }); const resolved = resolveStateDir({} as NodeJS.ProcessEnv, () => root); @@ -116,7 +107,7 @@ describe("state + config path candidates", () => { }); it("falls back to existing legacy state dir when ~/.openclaw is missing", async () => { - await withTempRoot("openclaw-state-legacy-", async (root) => { + await withTempDir({ prefix: "openclaw-state-legacy-" }, async (root) => { const legacyDir = path.join(root, ".clawdbot"); await fs.mkdir(legacyDir, { recursive: true }); const resolved = resolveStateDir({} as NodeJS.ProcessEnv, () => root); @@ -125,7 +116,7 @@ describe("state + config path candidates", () => { }); it("CONFIG_PATH prefers existing config when present", async () => { - await withTempRoot("openclaw-config-", async (root) => { + await withTempDir({ prefix: "openclaw-config-" }, async (root) => { const legacyDir = path.join(root, ".openclaw"); await fs.mkdir(legacyDir, { recursive: true }); const legacyPath = path.join(legacyDir, "openclaw.json"); @@ -137,7 +128,7 @@ describe("state + config path candidates", () => { }); it("respects state dir overrides when config is missing", async () => { - await withTempRoot("openclaw-config-override-", async (root) => { + await withTempDir({ prefix: "openclaw-config-override-" }, async (root) => { const legacyDir = path.join(root, ".openclaw"); await fs.mkdir(legacyDir, { recursive: true }); const legacyConfig = path.join(legacyDir, "openclaw.json"); diff --git a/src/infra/boundary-path.test.ts b/src/infra/boundary-path.test.ts index d28bb6cdffa..bf7b20ffcc0 100644 --- a/src/infra/boundary-path.test.ts +++ b/src/infra/boundary-path.test.ts @@ -1,19 +1,10 @@ import fs from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; +import { withTempDir } from "../test-helpers/temp-dir.js"; import { resolveBoundaryPath, resolveBoundaryPathSync } from "./boundary-path.js"; import { isPathInside } from "./path-guards.js"; -async function withTempRoot(prefix: string, run: (root: string) => Promise): Promise { - const root = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); - try { - return await run(root); - } finally { - await fs.rm(root, { recursive: true, force: true }); - } -} - function createSeededRandom(seed: number): () => number { let state = seed >>> 0; return () => { @@ -28,7 +19,7 @@ describe("resolveBoundaryPath", () => { return; } - await withTempRoot("openclaw-boundary-path-", async (base) => { + await withTempDir({ prefix: "openclaw-boundary-path-" }, async (base) => { const root = path.join(base, "workspace"); const targetDir = path.join(root, "target-dir"); const linkPath = path.join(root, "alias"); @@ -55,7 +46,7 @@ describe("resolveBoundaryPath", () => { return; } - await withTempRoot("openclaw-boundary-path-", async (base) => { + await withTempDir({ prefix: "openclaw-boundary-path-" }, async (base) => { const root = path.join(base, "workspace"); const outside = path.join(base, "outside"); const linkPath = path.join(root, "alias-out"); @@ -86,7 +77,7 @@ describe("resolveBoundaryPath", () => { return; } - await withTempRoot("openclaw-boundary-path-", async (base) => { + await withTempDir({ prefix: "openclaw-boundary-path-" }, async (base) => { const root = path.join(base, "workspace"); const outside = path.join(base, "outside"); const outsideFile = path.join(outside, "target.txt"); @@ -122,7 +113,7 @@ describe("resolveBoundaryPath", () => { return; } - await withTempRoot("openclaw-boundary-path-", async (base) => { + await withTempDir({ prefix: "openclaw-boundary-path-" }, async (base) => { const root = path.join(base, "workspace"); const aliasRoot = path.join(base, "workspace-alias"); const fileName = "plugin.js"; @@ -153,7 +144,7 @@ describe("resolveBoundaryPath", () => { return; } - await withTempRoot("openclaw-boundary-path-fuzz-", async (base) => { + await withTempDir({ prefix: "openclaw-boundary-path-fuzz-" }, async (base) => { const root = path.join(base, "workspace"); const outside = path.join(base, "outside"); const safeTarget = path.join(root, "safe-target"); diff --git a/src/infra/path-alias-guards.test.ts b/src/infra/path-alias-guards.test.ts index abc16c48847..7d70b79805a 100644 --- a/src/infra/path-alias-guards.test.ts +++ b/src/infra/path-alias-guards.test.ts @@ -1,76 +1,75 @@ import fs from "node:fs/promises"; import path from "node:path"; import { describe, expect, it } from "vitest"; +import { withTempDir } from "../test-helpers/temp-dir.js"; import { assertNoPathAliasEscape } from "./path-alias-guards.js"; -async function withTempRoot(run: (root: string) => Promise): Promise { - const base = await fs.mkdtemp(path.join(process.cwd(), "openclaw-path-alias-")); - const root = path.join(base, "root"); - await fs.mkdir(root, { recursive: true }); - try { - return await run(root); - } finally { - await fs.rm(base, { recursive: true, force: true }); - } -} - describe("assertNoPathAliasEscape", () => { it.runIf(process.platform !== "win32")( "rejects broken final symlink targets outside root", async () => { - await withTempRoot(async (root) => { - const outside = path.join(path.dirname(root), "outside"); - await fs.mkdir(outside, { recursive: true }); - const linkPath = path.join(root, "jump"); - await fs.symlink(path.join(outside, "owned.txt"), linkPath); + await withTempDir( + { prefix: "openclaw-path-alias-", parentDir: process.cwd(), subdir: "root" }, + async (root) => { + const outside = path.join(path.dirname(root), "outside"); + await fs.mkdir(outside, { recursive: true }); + const linkPath = path.join(root, "jump"); + await fs.symlink(path.join(outside, "owned.txt"), linkPath); - await expect( - assertNoPathAliasEscape({ - absolutePath: linkPath, - rootPath: root, - boundaryLabel: "sandbox root", - }), - ).rejects.toThrow(/Symlink escapes sandbox root/); - }); + await expect( + assertNoPathAliasEscape({ + absolutePath: linkPath, + rootPath: root, + boundaryLabel: "sandbox root", + }), + ).rejects.toThrow(/Symlink escapes sandbox root/); + }, + ); }, ); it.runIf(process.platform !== "win32")( "allows broken final symlink targets that remain inside root", async () => { - await withTempRoot(async (root) => { - const linkPath = path.join(root, "jump"); - await fs.symlink(path.join(root, "missing", "owned.txt"), linkPath); + await withTempDir( + { prefix: "openclaw-path-alias-", parentDir: process.cwd(), subdir: "root" }, + async (root) => { + const linkPath = path.join(root, "jump"); + await fs.symlink(path.join(root, "missing", "owned.txt"), linkPath); - await expect( - assertNoPathAliasEscape({ - absolutePath: linkPath, - rootPath: root, - boundaryLabel: "sandbox root", - }), - ).resolves.toBeUndefined(); - }); + await expect( + assertNoPathAliasEscape({ + absolutePath: linkPath, + rootPath: root, + boundaryLabel: "sandbox root", + }), + ).resolves.toBeUndefined(); + }, + ); }, ); it.runIf(process.platform !== "win32")( "rejects broken targets that traverse via an in-root symlink alias", async () => { - await withTempRoot(async (root) => { - const outside = path.join(path.dirname(root), "outside"); - await fs.mkdir(outside, { recursive: true }); - await fs.symlink(outside, path.join(root, "hop")); - const linkPath = path.join(root, "jump"); - await fs.symlink(path.join("hop", "missing", "owned.txt"), linkPath); + await withTempDir( + { prefix: "openclaw-path-alias-", parentDir: process.cwd(), subdir: "root" }, + async (root) => { + const outside = path.join(path.dirname(root), "outside"); + await fs.mkdir(outside, { recursive: true }); + await fs.symlink(outside, path.join(root, "hop")); + const linkPath = path.join(root, "jump"); + await fs.symlink(path.join("hop", "missing", "owned.txt"), linkPath); - await expect( - assertNoPathAliasEscape({ - absolutePath: linkPath, - rootPath: root, - boundaryLabel: "sandbox root", - }), - ).rejects.toThrow(/Symlink escapes sandbox root/); - }); + await expect( + assertNoPathAliasEscape({ + absolutePath: linkPath, + rootPath: root, + boundaryLabel: "sandbox root", + }), + ).rejects.toThrow(/Symlink escapes sandbox root/); + }, + ); }, ); }); diff --git a/src/test-helpers/temp-dir.ts b/src/test-helpers/temp-dir.ts new file mode 100644 index 00000000000..b5a55dfe03d --- /dev/null +++ b/src/test-helpers/temp-dir.ts @@ -0,0 +1,23 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +export async function withTempDir( + options: { + prefix: string; + parentDir?: string; + subdir?: string; + }, + run: (dir: string) => Promise, +): Promise { + const base = await fs.mkdtemp(path.join(options.parentDir ?? os.tmpdir(), options.prefix)); + const dir = options.subdir ? path.join(base, options.subdir) : base; + if (options.subdir) { + await fs.mkdir(dir, { recursive: true }); + } + try { + return await run(dir); + } finally { + await fs.rm(base, { recursive: true, force: true }); + } +} From 6464149031481767dae617fd4b16997a9a279fbe Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 17:43:52 +0000 Subject: [PATCH 030/640] refactor: share feishu webhook monitor harness --- .../feishu/src/monitor.webhook-e2e.test.ts | 102 ++--------------- .../src/monitor.webhook-security.test.ts | 106 ++---------------- .../src/monitor.webhook.test-helpers.ts | 98 ++++++++++++++++ 3 files changed, 114 insertions(+), 192 deletions(-) create mode 100644 extensions/feishu/src/monitor.webhook.test-helpers.ts diff --git a/extensions/feishu/src/monitor.webhook-e2e.test.ts b/extensions/feishu/src/monitor.webhook-e2e.test.ts index 2e73f973408..451ebe0d2bf 100644 --- a/extensions/feishu/src/monitor.webhook-e2e.test.ts +++ b/extensions/feishu/src/monitor.webhook-e2e.test.ts @@ -1,9 +1,7 @@ import crypto from "node:crypto"; -import { createServer } from "node:http"; -import type { AddressInfo } from "node:net"; -import type { ClawdbotConfig } from "openclaw/plugin-sdk/feishu"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createFeishuRuntimeMockModule } from "./monitor.test-mocks.js"; +import { withRunningWebhookMonitor } from "./monitor.webhook.test-helpers.js"; const probeFeishuMock = vi.hoisted(() => vi.fn()); @@ -23,61 +21,6 @@ vi.mock("./runtime.js", () => createFeishuRuntimeMockModule()); import { monitorFeishuProvider, stopFeishuMonitor } from "./monitor.js"; -async function getFreePort(): Promise { - const server = createServer(); - await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve())); - const address = server.address() as AddressInfo | null; - if (!address) { - throw new Error("missing server address"); - } - await new Promise((resolve) => server.close(() => resolve())); - return address.port; -} - -async function waitUntilServerReady(url: string): Promise { - for (let i = 0; i < 50; i += 1) { - try { - const response = await fetch(url, { method: "GET" }); - if (response.status >= 200 && response.status < 500) { - return; - } - } catch { - // retry - } - await new Promise((resolve) => setTimeout(resolve, 20)); - } - throw new Error(`server did not start: ${url}`); -} - -function buildConfig(params: { - accountId: string; - path: string; - port: number; - verificationToken?: string; - encryptKey?: string; -}): ClawdbotConfig { - return { - channels: { - feishu: { - enabled: true, - accounts: { - [params.accountId]: { - enabled: true, - appId: "cli_test", - appSecret: "secret_test", // pragma: allowlist secret - connectionMode: "webhook", - webhookHost: "127.0.0.1", - webhookPort: params.port, - webhookPath: params.path, - encryptKey: params.encryptKey, - verificationToken: params.verificationToken, - }, - }, - }, - }, - } as ClawdbotConfig; -} - function signFeishuPayload(params: { encryptKey: string; payload: Record; @@ -107,43 +50,6 @@ function encryptFeishuPayload(encryptKey: string, payload: Record Promise, -) { - const port = await getFreePort(); - const cfg = buildConfig({ - accountId: params.accountId, - path: params.path, - port, - encryptKey: params.encryptKey, - verificationToken: params.verificationToken, - }); - - const abortController = new AbortController(); - const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; - const monitorPromise = monitorFeishuProvider({ - config: cfg, - runtime, - abortSignal: abortController.signal, - }); - - const url = `http://127.0.0.1:${port}${params.path}`; - await waitUntilServerReady(url); - - try { - await run(url); - } finally { - abortController.abort(); - await monitorPromise; - } -} - afterEach(() => { stopFeishuMonitor(); }); @@ -159,6 +65,7 @@ describe("Feishu webhook signed-request e2e", () => { verificationToken: "verify_token", encryptKey: "encrypt_key", }, + monitorFeishuProvider, async (url) => { const payload = { type: "url_verification", challenge: "challenge-token" }; const response = await fetch(url, { @@ -185,6 +92,7 @@ describe("Feishu webhook signed-request e2e", () => { verificationToken: "verify_token", encryptKey: "encrypt_key", }, + monitorFeishuProvider, async (url) => { const response = await fetch(url, { method: "POST", @@ -208,6 +116,7 @@ describe("Feishu webhook signed-request e2e", () => { verificationToken: "verify_token", encryptKey: "encrypt_key", }, + monitorFeishuProvider, async (url) => { const response = await fetch(url, { method: "POST", @@ -231,6 +140,7 @@ describe("Feishu webhook signed-request e2e", () => { verificationToken: "verify_token", encryptKey: "encrypt_key", }, + monitorFeishuProvider, async (url) => { const payload = { type: "url_verification", challenge: "challenge-token" }; const response = await fetch(url, { @@ -255,6 +165,7 @@ describe("Feishu webhook signed-request e2e", () => { verificationToken: "verify_token", encryptKey: "encrypt_key", }, + monitorFeishuProvider, async (url) => { const payload = { schema: "2.0", @@ -283,6 +194,7 @@ describe("Feishu webhook signed-request e2e", () => { verificationToken: "verify_token", encryptKey: "encrypt_key", }, + monitorFeishuProvider, async (url) => { const payload = { encrypt: encryptFeishuPayload("encrypt_key", { diff --git a/extensions/feishu/src/monitor.webhook-security.test.ts b/extensions/feishu/src/monitor.webhook-security.test.ts index e9bfa8bf008..957d874cc3a 100644 --- a/extensions/feishu/src/monitor.webhook-security.test.ts +++ b/extensions/feishu/src/monitor.webhook-security.test.ts @@ -1,11 +1,13 @@ -import { createServer } from "node:http"; -import type { AddressInfo } from "node:net"; -import type { ClawdbotConfig } from "openclaw/plugin-sdk/feishu"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createFeishuClientMockModule, createFeishuRuntimeMockModule, } from "./monitor.test-mocks.js"; +import { + buildWebhookConfig, + getFreePort, + withRunningWebhookMonitor, +} from "./monitor.webhook.test-helpers.js"; const probeFeishuMock = vi.hoisted(() => vi.fn()); @@ -33,98 +35,6 @@ import { stopFeishuMonitor, } from "./monitor.js"; -async function getFreePort(): Promise { - const server = createServer(); - await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve())); - const address = server.address() as AddressInfo | null; - if (!address) { - throw new Error("missing server address"); - } - await new Promise((resolve) => server.close(() => resolve())); - return address.port; -} - -async function waitUntilServerReady(url: string): Promise { - for (let i = 0; i < 50; i += 1) { - try { - const response = await fetch(url, { method: "GET" }); - if (response.status >= 200 && response.status < 500) { - return; - } - } catch { - // retry - } - await new Promise((resolve) => setTimeout(resolve, 20)); - } - throw new Error(`server did not start: ${url}`); -} - -function buildConfig(params: { - accountId: string; - path: string; - port: number; - verificationToken?: string; - encryptKey?: string; -}): ClawdbotConfig { - return { - channels: { - feishu: { - enabled: true, - accounts: { - [params.accountId]: { - enabled: true, - appId: "cli_test", - appSecret: "secret_test", // pragma: allowlist secret - connectionMode: "webhook", - webhookHost: "127.0.0.1", - webhookPort: params.port, - webhookPath: params.path, - encryptKey: params.encryptKey, - verificationToken: params.verificationToken, - }, - }, - }, - }, - } as ClawdbotConfig; -} - -async function withRunningWebhookMonitor( - params: { - accountId: string; - path: string; - verificationToken: string; - encryptKey: string; - }, - run: (url: string) => Promise, -) { - const port = await getFreePort(); - const cfg = buildConfig({ - accountId: params.accountId, - path: params.path, - port, - encryptKey: params.encryptKey, - verificationToken: params.verificationToken, - }); - - const abortController = new AbortController(); - const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; - const monitorPromise = monitorFeishuProvider({ - config: cfg, - runtime, - abortSignal: abortController.signal, - }); - - const url = `http://127.0.0.1:${port}${params.path}`; - await waitUntilServerReady(url); - - try { - await run(url); - } finally { - abortController.abort(); - await monitorPromise; - } -} - afterEach(() => { clearFeishuWebhookRateLimitStateForTest(); stopFeishuMonitor(); @@ -134,7 +44,7 @@ describe("Feishu webhook security hardening", () => { it("rejects webhook mode without verificationToken", async () => { probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" }); - const cfg = buildConfig({ + const cfg = buildWebhookConfig({ accountId: "missing-token", path: "/hook-missing-token", port: await getFreePort(), @@ -148,7 +58,7 @@ describe("Feishu webhook security hardening", () => { it("rejects webhook mode without encryptKey", async () => { probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" }); - const cfg = buildConfig({ + const cfg = buildWebhookConfig({ accountId: "missing-encrypt-key", path: "/hook-missing-encrypt", port: await getFreePort(), @@ -167,6 +77,7 @@ describe("Feishu webhook security hardening", () => { verificationToken: "verify_token", encryptKey: "encrypt_key", }, + monitorFeishuProvider, async (url) => { const response = await fetch(url, { method: "POST", @@ -189,6 +100,7 @@ describe("Feishu webhook security hardening", () => { verificationToken: "verify_token", encryptKey: "encrypt_key", }, + monitorFeishuProvider, async (url) => { let saw429 = false; for (let i = 0; i < 130; i += 1) { diff --git a/extensions/feishu/src/monitor.webhook.test-helpers.ts b/extensions/feishu/src/monitor.webhook.test-helpers.ts new file mode 100644 index 00000000000..b9de2150bd4 --- /dev/null +++ b/extensions/feishu/src/monitor.webhook.test-helpers.ts @@ -0,0 +1,98 @@ +import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; +import type { ClawdbotConfig } from "openclaw/plugin-sdk/feishu"; +import { vi } from "vitest"; +import type { monitorFeishuProvider } from "./monitor.js"; + +export async function getFreePort(): Promise { + const server = createServer(); + await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve())); + const address = server.address() as AddressInfo | null; + if (!address) { + throw new Error("missing server address"); + } + await new Promise((resolve) => server.close(() => resolve())); + return address.port; +} + +async function waitUntilServerReady(url: string): Promise { + for (let i = 0; i < 50; i += 1) { + try { + const response = await fetch(url, { method: "GET" }); + if (response.status >= 200 && response.status < 500) { + return; + } + } catch { + // retry + } + await new Promise((resolve) => setTimeout(resolve, 20)); + } + throw new Error(`server did not start: ${url}`); +} + +export function buildWebhookConfig(params: { + accountId: string; + path: string; + port: number; + verificationToken?: string; + encryptKey?: string; +}): ClawdbotConfig { + return { + channels: { + feishu: { + enabled: true, + accounts: { + [params.accountId]: { + enabled: true, + appId: "cli_test", + appSecret: "secret_test", // pragma: allowlist secret + connectionMode: "webhook", + webhookHost: "127.0.0.1", + webhookPort: params.port, + webhookPath: params.path, + encryptKey: params.encryptKey, + verificationToken: params.verificationToken, + }, + }, + }, + }, + } as ClawdbotConfig; +} + +export async function withRunningWebhookMonitor( + params: { + accountId: string; + path: string; + verificationToken: string; + encryptKey: string; + }, + monitor: typeof monitorFeishuProvider, + run: (url: string) => Promise, +) { + const port = await getFreePort(); + const cfg = buildWebhookConfig({ + accountId: params.accountId, + path: params.path, + port, + encryptKey: params.encryptKey, + verificationToken: params.verificationToken, + }); + + const abortController = new AbortController(); + const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; + const monitorPromise = monitor({ + config: cfg, + runtime, + abortSignal: abortController.signal, + }); + + const url = `http://127.0.0.1:${port}${params.path}`; + await waitUntilServerReady(url); + + try { + await run(url); + } finally { + abortController.abort(); + await monitorPromise; + } +} From 198c2482eeb2133e78100b13753411d7c1fa0c29 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 17:52:00 +0000 Subject: [PATCH 031/640] refactor: share gateway session store migration --- src/gateway/server-methods/agent.ts | 14 ++++------- src/gateway/server-methods/sessions.ts | 33 ++++---------------------- src/gateway/server-node-events.ts | 15 +++--------- src/gateway/session-reset-service.ts | 33 +++++--------------------- src/gateway/session-utils.ts | 25 +++++++++++++++++++ src/gateway/sessions-resolve.ts | 11 ++++----- 6 files changed, 46 insertions(+), 85 deletions(-) diff --git a/src/gateway/server-methods/agent.ts b/src/gateway/server-methods/agent.ts index ee08425b7fd..5a7507345df 100644 --- a/src/gateway/server-methods/agent.ts +++ b/src/gateway/server-methods/agent.ts @@ -50,8 +50,7 @@ import { performGatewaySessionReset } from "../session-reset-service.js"; import { canonicalizeSpawnedByForAgent, loadSessionEntry, - pruneLegacyStoreKeys, - resolveGatewaySessionStoreTarget, + migrateAndPruneGatewaySessionStoreKey, } from "../session-utils.js"; import { formatForLog } from "../ws-log.js"; import { waitForAgentJob } from "./agent-job.js"; @@ -425,18 +424,13 @@ export const agentHandlers: GatewayRequestHandlers = { const mainSessionKey = resolveAgentMainSessionKey({ cfg, agentId }); if (storePath) { const persisted = await updateSessionStore(storePath, (store) => { - const target = resolveGatewaySessionStoreTarget({ + const { primaryKey } = migrateAndPruneGatewaySessionStoreKey({ cfg, key: requestedSessionKey, store, }); - pruneLegacyStoreKeys({ - store, - canonicalKey: target.canonicalKey, - candidates: target.storeKeys, - }); - const merged = mergeSessionEntry(store[canonicalSessionKey], nextEntryPatch); - store[canonicalSessionKey] = merged; + const merged = mergeSessionEntry(store[primaryKey], nextEntryPatch); + store[primaryKey] = merged; return merged; }); sessionEntry = persisted; diff --git a/src/gateway/server-methods/sessions.ts b/src/gateway/server-methods/sessions.ts index f2e3817bfa6..d5244116d33 100644 --- a/src/gateway/server-methods/sessions.ts +++ b/src/gateway/server-methods/sessions.ts @@ -31,7 +31,7 @@ import { listSessionsFromStore, loadCombinedSessionStoreForGateway, loadSessionEntry, - pruneLegacyStoreKeys, + migrateAndPruneGatewaySessionStoreKey, readSessionPreviewItemsFromTranscript, resolveGatewaySessionStoreTarget, resolveSessionModelRef, @@ -92,31 +92,6 @@ function rejectWebchatSessionMutation(params: { return true; } -function migrateAndPruneSessionStoreKey(params: { - cfg: ReturnType; - key: string; - store: Record; -}) { - const target = resolveGatewaySessionStoreTarget({ - cfg: params.cfg, - key: params.key, - store: params.store, - }); - const primaryKey = target.canonicalKey; - if (!params.store[primaryKey]) { - const existingKey = target.storeKeys.find((candidate) => Boolean(params.store[candidate])); - if (existingKey) { - params.store[primaryKey] = params.store[existingKey]; - } - } - pruneLegacyStoreKeys({ - store: params.store, - canonicalKey: primaryKey, - candidates: target.storeKeys, - }); - return { target, primaryKey, entry: params.store[primaryKey] }; -} - export const sessionsHandlers: GatewayRequestHandlers = { "sessions.list": ({ params, respond }) => { if (!assertValidParams(params, validateSessionsListParams, "sessions.list", respond)) { @@ -224,7 +199,7 @@ export const sessionsHandlers: GatewayRequestHandlers = { const { cfg, target, storePath } = resolveGatewaySessionTargetFromKey(key); const applied = await updateSessionStore(storePath, async (store) => { - const { primaryKey } = migrateAndPruneSessionStoreKey({ cfg, key, store }); + const { primaryKey } = migrateAndPruneGatewaySessionStoreKey({ cfg, key, store }); return await applySessionsPatchToStore({ cfg, store, @@ -316,7 +291,7 @@ export const sessionsHandlers: GatewayRequestHandlers = { } const sessionId = entry?.sessionId; const deleted = await updateSessionStore(storePath, (store) => { - const { primaryKey } = migrateAndPruneSessionStoreKey({ cfg, key, store }); + const { primaryKey } = migrateAndPruneGatewaySessionStoreKey({ cfg, key, store }); const hadEntry = Boolean(store[primaryKey]); if (hadEntry) { delete store[primaryKey]; @@ -385,7 +360,7 @@ export const sessionsHandlers: GatewayRequestHandlers = { const { cfg, target, storePath } = resolveGatewaySessionTargetFromKey(key); // Lock + read in a short critical section; transcript work happens outside. const compactTarget = await updateSessionStore(storePath, (store) => { - const { entry, primaryKey } = migrateAndPruneSessionStoreKey({ cfg, key, store }); + const { entry, primaryKey } = migrateAndPruneGatewaySessionStoreKey({ cfg, key, store }); return { entry, primaryKey }; }); const entry = compactTarget.entry; diff --git a/src/gateway/server-node-events.ts b/src/gateway/server-node-events.ts index b36ca9aca50..8ab24644101 100644 --- a/src/gateway/server-node-events.ts +++ b/src/gateway/server-node-events.ts @@ -16,11 +16,7 @@ import { defaultRuntime } from "../runtime.js"; import { parseMessageWithAttachments } from "./chat-attachments.js"; import { normalizeRpcAttachmentsToChatAttachments } from "./server-methods/attachment-normalize.js"; import type { NodeEvent, NodeEventContext } from "./server-node-events-types.js"; -import { - loadSessionEntry, - pruneLegacyStoreKeys, - resolveGatewaySessionStoreTarget, -} from "./session-utils.js"; +import { loadSessionEntry, migrateAndPruneGatewaySessionStoreKey } from "./session-utils.js"; import { formatForLog } from "./ws-log.js"; const MAX_EXEC_EVENT_OUTPUT_CHARS = 180; @@ -152,17 +148,12 @@ async function touchSessionStore(params: { return; } await updateSessionStore(storePath, (store) => { - const target = resolveGatewaySessionStoreTarget({ + const { primaryKey } = migrateAndPruneGatewaySessionStoreKey({ cfg: params.cfg, key: params.sessionKey, store, }); - pruneLegacyStoreKeys({ - store, - canonicalKey: target.canonicalKey, - candidates: target.storeKeys, - }); - store[params.canonicalKey] = { + store[primaryKey] = { sessionId: params.sessionId, updatedAt: params.now, thinkingLevel: params.entry?.thinkingLevel, diff --git a/src/gateway/session-reset-service.ts b/src/gateway/session-reset-service.ts index 15b9a0aa37f..b0a5b0a54f0 100644 --- a/src/gateway/session-reset-service.ts +++ b/src/gateway/session-reset-service.ts @@ -25,38 +25,13 @@ import { ErrorCodes, errorShape } from "./protocol/index.js"; import { archiveSessionTranscripts, loadSessionEntry, - pruneLegacyStoreKeys, + migrateAndPruneGatewaySessionStoreKey, resolveGatewaySessionStoreTarget, resolveSessionModelRef, } from "./session-utils.js"; const ACP_RUNTIME_CLEANUP_TIMEOUT_MS = 15_000; -function migrateAndPruneSessionStoreKey(params: { - cfg: ReturnType; - key: string; - store: Record; -}) { - const target = resolveGatewaySessionStoreTarget({ - cfg: params.cfg, - key: params.key, - store: params.store, - }); - const primaryKey = target.canonicalKey; - if (!params.store[primaryKey]) { - const existingKey = target.storeKeys.find((candidate) => Boolean(params.store[candidate])); - if (existingKey) { - params.store[primaryKey] = params.store[existingKey]; - } - } - pruneLegacyStoreKeys({ - store: params.store, - canonicalKey: primaryKey, - candidates: target.storeKeys, - }); - return { target, primaryKey, entry: params.store[primaryKey] }; -} - function stripRuntimeModelState(entry?: SessionEntry): SessionEntry | undefined { if (!entry) { return entry; @@ -311,7 +286,11 @@ export async function performGatewaySessionReset(params: { let oldSessionId: string | undefined; let oldSessionFile: string | undefined; const next = await updateSessionStore(storePath, (store) => { - const { primaryKey } = migrateAndPruneSessionStoreKey({ cfg, key: params.key, store }); + const { primaryKey } = migrateAndPruneGatewaySessionStoreKey({ + cfg, + key: params.key, + store, + }); const currentEntry = store[primaryKey]; const resetEntry = stripRuntimeModelState(currentEntry); const parsed = parseAgentSessionKey(primaryKey); diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index 591799879b9..00a2cb7747e 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -263,6 +263,31 @@ export function pruneLegacyStoreKeys(params: { } } +export function migrateAndPruneGatewaySessionStoreKey(params: { + cfg: ReturnType; + key: string; + store: Record; +}) { + const target = resolveGatewaySessionStoreTarget({ + cfg: params.cfg, + key: params.key, + store: params.store, + }); + const primaryKey = target.canonicalKey; + if (!params.store[primaryKey]) { + const existingKey = target.storeKeys.find((candidate) => Boolean(params.store[candidate])); + if (existingKey) { + params.store[primaryKey] = params.store[existingKey]; + } + } + pruneLegacyStoreKeys({ + store: params.store, + canonicalKey: primaryKey, + candidates: target.storeKeys, + }); + return { target, primaryKey, entry: params.store[primaryKey] }; +} + export function classifySessionKey(key: string, entry?: SessionEntry): GatewaySessionRow["kind"] { if (key === "global") { return "global"; diff --git a/src/gateway/sessions-resolve.ts b/src/gateway/sessions-resolve.ts index 21b6779573c..47ca47b86e3 100644 --- a/src/gateway/sessions-resolve.ts +++ b/src/gateway/sessions-resolve.ts @@ -10,7 +10,7 @@ import { import { listSessionsFromStore, loadCombinedSessionStoreForGateway, - pruneLegacyStoreKeys, + migrateAndPruneGatewaySessionStoreKey, resolveGatewaySessionStoreTarget, } from "./session-utils.js"; @@ -58,13 +58,10 @@ export async function resolveSessionKeyFromResolveParams(params: { }; } await updateSessionStore(target.storePath, (s) => { - const liveTarget = resolveGatewaySessionStoreTarget({ cfg, key, store: s }); - const canonicalKey = liveTarget.canonicalKey; - // Migrate the first legacy entry to the canonical key. - if (!s[canonicalKey] && s[legacyKey]) { - s[canonicalKey] = s[legacyKey]; + const { primaryKey } = migrateAndPruneGatewaySessionStoreKey({ cfg, key, store: s }); + if (!s[primaryKey] && s[legacyKey]) { + s[primaryKey] = s[legacyKey]; } - pruneLegacyStoreKeys({ store: s, canonicalKey, candidates: liveTarget.storeKeys }); }); return { ok: true, key: target.canonicalKey }; } From 7cb6553ce81913d36d814a86c1e66c0bef160b57 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 17:52:08 +0000 Subject: [PATCH 032/640] fix: pass injected config to session tools --- src/agents/openclaw-tools.ts | 3 +++ src/agents/tools/sessions-history-tool.ts | 5 +++-- src/agents/tools/sessions-list-tool.ts | 5 +++-- src/agents/tools/sessions-send-tool.ts | 5 +++-- src/gateway/server.sessions-send.test.ts | 13 ++++++++++++- 5 files changed, 24 insertions(+), 7 deletions(-) diff --git a/src/agents/openclaw-tools.ts b/src/agents/openclaw-tools.ts index 58b3570eb89..ea12b5121d8 100644 --- a/src/agents/openclaw-tools.ts +++ b/src/agents/openclaw-tools.ts @@ -174,15 +174,18 @@ export function createOpenClawTools( createSessionsListTool({ agentSessionKey: options?.agentSessionKey, sandboxed: options?.sandboxed, + config: options?.config, }), createSessionsHistoryTool({ agentSessionKey: options?.agentSessionKey, sandboxed: options?.sandboxed, + config: options?.config, }), createSessionsSendTool({ agentSessionKey: options?.agentSessionKey, agentChannel: options?.agentChannel, sandboxed: options?.sandboxed, + config: options?.config, }), createSessionsYieldTool({ sessionId: options?.sessionId, diff --git a/src/agents/tools/sessions-history-tool.ts b/src/agents/tools/sessions-history-tool.ts index 3d5deeadcdb..a3e8d4d9461 100644 --- a/src/agents/tools/sessions-history-tool.ts +++ b/src/agents/tools/sessions-history-tool.ts @@ -1,5 +1,5 @@ import { Type } from "@sinclair/typebox"; -import { loadConfig } from "../../config/config.js"; +import { type OpenClawConfig, loadConfig } from "../../config/config.js"; import { callGateway } from "../../gateway/call.js"; import { capArrayByJsonBytes } from "../../gateway/session-utils.fs.js"; import { jsonUtf8Bytes } from "../../infra/json-utf8-bytes.js"; @@ -169,6 +169,7 @@ function enforceSessionsHistoryHardCap(params: { export function createSessionsHistoryTool(opts?: { agentSessionKey?: string; sandboxed?: boolean; + config?: OpenClawConfig; }): AnyAgentTool { return { label: "Session History", @@ -180,7 +181,7 @@ export function createSessionsHistoryTool(opts?: { const sessionKeyParam = readStringParam(params, "sessionKey", { required: true, }); - const cfg = loadConfig(); + const cfg = opts?.config ?? loadConfig(); const { mainKey, alias, effectiveRequesterKey, restrictToSpawned } = resolveSandboxedSessionToolContext({ cfg, diff --git a/src/agents/tools/sessions-list-tool.ts b/src/agents/tools/sessions-list-tool.ts index 0cba87e5653..ff3f56212d2 100644 --- a/src/agents/tools/sessions-list-tool.ts +++ b/src/agents/tools/sessions-list-tool.ts @@ -1,6 +1,6 @@ import path from "node:path"; import { Type } from "@sinclair/typebox"; -import { loadConfig } from "../../config/config.js"; +import { type OpenClawConfig, loadConfig } from "../../config/config.js"; import { resolveSessionFilePath, resolveSessionFilePathOptions, @@ -33,6 +33,7 @@ const SessionsListToolSchema = Type.Object({ export function createSessionsListTool(opts?: { agentSessionKey?: string; sandboxed?: boolean; + config?: OpenClawConfig; }): AnyAgentTool { return { label: "Sessions", @@ -41,7 +42,7 @@ export function createSessionsListTool(opts?: { parameters: SessionsListToolSchema, execute: async (_toolCallId, args) => { const params = args as Record; - const cfg = loadConfig(); + const cfg = opts?.config ?? loadConfig(); const { mainKey, alias, requesterInternalKey, restrictToSpawned } = resolveSandboxedSessionToolContext({ cfg, diff --git a/src/agents/tools/sessions-send-tool.ts b/src/agents/tools/sessions-send-tool.ts index 82eff0adf7a..d9ad6e6b907 100644 --- a/src/agents/tools/sessions-send-tool.ts +++ b/src/agents/tools/sessions-send-tool.ts @@ -1,6 +1,6 @@ import crypto from "node:crypto"; import { Type } from "@sinclair/typebox"; -import { loadConfig } from "../../config/config.js"; +import { type OpenClawConfig, loadConfig } from "../../config/config.js"; import { callGateway } from "../../gateway/call.js"; import { normalizeAgentId, resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; import { SESSION_LABEL_MAX_LENGTH } from "../../sessions/session-label.js"; @@ -36,6 +36,7 @@ export function createSessionsSendTool(opts?: { agentSessionKey?: string; agentChannel?: GatewayMessageChannel; sandboxed?: boolean; + config?: OpenClawConfig; }): AnyAgentTool { return { label: "Session Send", @@ -46,7 +47,7 @@ export function createSessionsSendTool(opts?: { execute: async (_toolCallId, args) => { const params = args as Record; const message = readStringParam(params, "message", { required: true }); - const cfg = loadConfig(); + const cfg = opts?.config ?? loadConfig(); const { mainKey, alias, effectiveRequesterKey, restrictToSpawned } = resolveSandboxedSessionToolContext({ cfg, diff --git a/src/gateway/server.sessions-send.test.ts b/src/gateway/server.sessions-send.test.ts index 7f1e49e8f01..11fae253ff3 100644 --- a/src/gateway/server.sessions-send.test.ts +++ b/src/gateway/server.sessions-send.test.ts @@ -184,7 +184,18 @@ describe("sessions_send label lookup", () => { timeoutMs: 5000, }); - const tool = getSessionsSendTool(); + const tool = createOpenClawTools({ + config: { + tools: { + sessions: { + visibility: "all", + }, + }, + }, + }).find((candidate) => candidate.name === "sessions_send"); + if (!tool) { + throw new Error("missing sessions_send tool"); + } // Send using label instead of sessionKey const result = await tool.execute("call-by-label", { From 2f58647033ecaf0dbbc99bdd2a448d453fa9f45c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 17:52:40 +0000 Subject: [PATCH 033/640] refactor: share plugin route auth test harness --- src/gateway/server/plugins-http.test.ts | 67 ++++++++++++------------- 1 file changed, 31 insertions(+), 36 deletions(-) diff --git a/src/gateway/server/plugins-http.test.ts b/src/gateway/server/plugins-http.test.ts index 476f76f8850..e5062686246 100644 --- a/src/gateway/server/plugins-http.test.ts +++ b/src/gateway/server/plugins-http.test.ts @@ -86,6 +86,31 @@ async function createSubagentRuntime(): Promise { return call.runtimeOptions.subagent; } +function createSecurePluginRouteHandler(params: { + exactPluginHandler: () => boolean | Promise; + prefixGatewayHandler: () => boolean | Promise; +}) { + return createGatewayPluginRequestHandler({ + registry: createTestRegistry({ + httpRoutes: [ + createRoute({ + path: "/plugin/secure/report", + match: "exact", + auth: "plugin", + handler: params.exactPluginHandler, + }), + createRoute({ + path: "/plugin/secure", + match: "prefix", + auth: "gateway", + handler: params.prefixGatewayHandler, + }), + ], + }), + log: createPluginLog(), + }); +} + describe("createGatewayPluginRequestHandler", () => { it("caps unauthenticated plugin routes to non-admin subagent scopes", async () => { loadOpenClawPlugins.mockReset(); @@ -209,24 +234,9 @@ describe("createGatewayPluginRequestHandler", () => { it("fails closed when a matched gateway route reaches dispatch without auth", async () => { const exactPluginHandler = vi.fn(async () => false); const prefixGatewayHandler = vi.fn(async () => true); - const handler = createGatewayPluginRequestHandler({ - registry: createTestRegistry({ - httpRoutes: [ - createRoute({ - path: "/plugin/secure/report", - match: "exact", - auth: "plugin", - handler: exactPluginHandler, - }), - createRoute({ - path: "/plugin/secure", - match: "prefix", - auth: "gateway", - handler: prefixGatewayHandler, - }), - ], - }), - log: createPluginLog(), + const handler = createSecurePluginRouteHandler({ + exactPluginHandler, + prefixGatewayHandler, }); const { res } = makeMockHttpResponse(); @@ -246,24 +256,9 @@ describe("createGatewayPluginRequestHandler", () => { it("allows gateway route fallthrough only after gateway auth succeeds", async () => { const exactPluginHandler = vi.fn(async () => false); const prefixGatewayHandler = vi.fn(async () => true); - const handler = createGatewayPluginRequestHandler({ - registry: createTestRegistry({ - httpRoutes: [ - createRoute({ - path: "/plugin/secure/report", - match: "exact", - auth: "plugin", - handler: exactPluginHandler, - }), - createRoute({ - path: "/plugin/secure", - match: "prefix", - auth: "gateway", - handler: prefixGatewayHandler, - }), - ], - }), - log: createPluginLog(), + const handler = createSecurePluginRouteHandler({ + exactPluginHandler, + prefixGatewayHandler, }); const { res } = makeMockHttpResponse(); From 6cc86ad211b8652ba6f3e37932751bb0bcd55bb4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 17:53:25 +0000 Subject: [PATCH 034/640] refactor: share gateway credential secretref assertions --- src/gateway/credentials.test.ts | 178 ++++++++++++-------------------- 1 file changed, 66 insertions(+), 112 deletions(-) diff --git a/src/gateway/credentials.test.ts b/src/gateway/credentials.test.ts index a3f3a8b9f45..a927395e833 100644 --- a/src/gateway/credentials.test.ts +++ b/src/gateway/credentials.test.ts @@ -71,6 +71,43 @@ function resolveLocalModeWithUnresolvedPassword(mode: "none" | "trusted-proxy") }); } +function expectUnresolvedLocalAuthSecretRefFailure(params: { + authMode: "token" | "password"; + secretId: string; + errorPath: "gateway.auth.token" | "gateway.auth.password"; + remote?: { token?: string; password?: string }; +}) { + const localAuth = + params.authMode === "token" + ? { + mode: "token" as const, + token: { source: "env", provider: "default", id: params.secretId }, + } + : { + mode: "password" as const, + password: { source: "env", provider: "default", id: params.secretId }, + }; + + expect(() => + resolveGatewayCredentialsFromConfig({ + cfg: { + gateway: { + mode: "local", + auth: localAuth, + remote: params.remote, + }, + secrets: { + providers: { + default: { source: "env" }, + }, + }, + } as unknown as OpenClawConfig, + env: {} as NodeJS.ProcessEnv, + includeLegacyEnv: false, + }), + ).toThrow(params.errorPath); +} + describe("resolveGatewayCredentialsFromConfig", () => { it("prefers explicit credentials over config and environment", () => { const resolved = resolveGatewayCredentialsFor( @@ -159,78 +196,29 @@ describe("resolveGatewayCredentialsFromConfig", () => { }); it("fails closed when local token SecretRef is unresolved and remote token fallback exists", () => { - expect(() => - resolveGatewayCredentialsFromConfig({ - cfg: { - gateway: { - mode: "local", - auth: { - mode: "token", - token: { source: "env", provider: "default", id: "MISSING_LOCAL_TOKEN" }, - }, - remote: { - token: "remote-token", - }, - }, - secrets: { - providers: { - default: { source: "env" }, - }, - }, - } as unknown as OpenClawConfig, - env: {} as NodeJS.ProcessEnv, - includeLegacyEnv: false, - }), - ).toThrow("gateway.auth.token"); + expectUnresolvedLocalAuthSecretRefFailure({ + authMode: "token", + secretId: "MISSING_LOCAL_TOKEN", + errorPath: "gateway.auth.token", + remote: { token: "remote-token" }, + }); }); it("fails closed when local password SecretRef is unresolved and remote password fallback exists", () => { - expect(() => - resolveGatewayCredentialsFromConfig({ - cfg: { - gateway: { - mode: "local", - auth: { - mode: "password", - password: { source: "env", provider: "default", id: "MISSING_LOCAL_PASSWORD" }, - }, - remote: { - password: "remote-password", // pragma: allowlist secret - }, - }, - secrets: { - providers: { - default: { source: "env" }, - }, - }, - } as unknown as OpenClawConfig, - env: {} as NodeJS.ProcessEnv, - includeLegacyEnv: false, - }), - ).toThrow("gateway.auth.password"); + expectUnresolvedLocalAuthSecretRefFailure({ + authMode: "password", + secretId: "MISSING_LOCAL_PASSWORD", + errorPath: "gateway.auth.password", + remote: { password: "remote-password" }, // pragma: allowlist secret + }); }); it("throws when local password auth relies on an unresolved SecretRef", () => { - expect(() => - resolveGatewayCredentialsFromConfig({ - cfg: { - gateway: { - mode: "local", - auth: { - mode: "password", - password: { source: "env", provider: "default", id: "MISSING_GATEWAY_PASSWORD" }, - }, - }, - secrets: { - providers: { - default: { source: "env" }, - }, - }, - } as unknown as OpenClawConfig, - env: {} as NodeJS.ProcessEnv, - includeLegacyEnv: false, - }), - ).toThrow("gateway.auth.password"); + expectUnresolvedLocalAuthSecretRefFailure({ + authMode: "password", + secretId: "MISSING_GATEWAY_PASSWORD", + errorPath: "gateway.auth.password", + }); }); it("treats env-template local tokens as SecretRefs instead of plaintext", () => { @@ -275,55 +263,21 @@ describe("resolveGatewayCredentialsFromConfig", () => { }); it("throws when unresolved local token SecretRef would otherwise fall back to remote token", () => { - expect(() => - resolveGatewayCredentialsFromConfig({ - cfg: { - gateway: { - mode: "local", - auth: { - mode: "token", - token: { source: "env", provider: "default", id: "MISSING_LOCAL_TOKEN" }, - }, - remote: { - token: "remote-token", - }, - }, - secrets: { - providers: { - default: { source: "env" }, - }, - }, - } as unknown as OpenClawConfig, - env: {} as NodeJS.ProcessEnv, - includeLegacyEnv: false, - }), - ).toThrow("gateway.auth.token"); + expectUnresolvedLocalAuthSecretRefFailure({ + authMode: "token", + secretId: "MISSING_LOCAL_TOKEN", + errorPath: "gateway.auth.token", + remote: { token: "remote-token" }, + }); }); it("throws when unresolved local password SecretRef would otherwise fall back to remote password", () => { - expect(() => - resolveGatewayCredentialsFromConfig({ - cfg: { - gateway: { - mode: "local", - auth: { - mode: "password", - password: { source: "env", provider: "default", id: "MISSING_LOCAL_PASSWORD" }, - }, - remote: { - password: "remote-password", // pragma: allowlist secret - }, - }, - secrets: { - providers: { - default: { source: "env" }, - }, - }, - } as unknown as OpenClawConfig, - env: {} as NodeJS.ProcessEnv, - includeLegacyEnv: false, - }), - ).toThrow("gateway.auth.password"); + expectUnresolvedLocalAuthSecretRefFailure({ + authMode: "password", + secretId: "MISSING_LOCAL_PASSWORD", + errorPath: "gateway.auth.password", + remote: { password: "remote-password" }, // pragma: allowlist secret + }); }); it("ignores unresolved local password ref when local auth mode is none", () => { From 07dacec9047da78944a78f4889b385717c1d93aa Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 18:26:29 +0000 Subject: [PATCH 035/640] refactor: share embedded attempt test harness --- .../run/attempt.spawn-workspace.test.ts | 165 +++++++++--------- 1 file changed, 78 insertions(+), 87 deletions(-) diff --git a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test.ts b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test.ts index c18d439e632..53edfbbc6cc 100644 --- a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test.ts +++ b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test.ts @@ -249,6 +249,72 @@ function createSubscriptionMock() { }; } +function resetEmbeddedAttemptHarness( + params: { + includeSpawnSubagent?: boolean; + subscribeImpl?: () => ReturnType; + sessionMessages?: AgentMessage[]; + } = {}, +) { + if (params.includeSpawnSubagent) { + hoisted.spawnSubagentDirectMock.mockReset().mockResolvedValue({ + status: "accepted", + childSessionKey: "agent:main:subagent:child", + runId: "run-child", + }); + } + hoisted.createAgentSessionMock.mockReset(); + hoisted.sessionManagerOpenMock.mockReset().mockReturnValue(hoisted.sessionManager); + hoisted.resolveSandboxContextMock.mockReset(); + hoisted.acquireSessionWriteLockMock.mockReset().mockResolvedValue({ + release: async () => {}, + }); + hoisted.sessionManager.getLeafEntry.mockReset().mockReturnValue(null); + hoisted.sessionManager.branch.mockReset(); + hoisted.sessionManager.resetLeaf.mockReset(); + hoisted.sessionManager.buildSessionContext + .mockReset() + .mockReturnValue({ messages: params.sessionMessages ?? [] }); + hoisted.sessionManager.appendCustomEntry.mockReset(); + if (params.subscribeImpl) { + hoisted.subscribeEmbeddedPiSessionMock.mockReset().mockImplementation(params.subscribeImpl); + } +} + +async function cleanupTempPaths(tempPaths: string[]) { + while (tempPaths.length > 0) { + const target = tempPaths.pop(); + if (target) { + await fs.rm(target, { recursive: true, force: true }); + } + } +} + +function createDefaultEmbeddedSession(): MutableSession { + const session: MutableSession = { + sessionId: "embedded-session", + messages: [], + isCompacting: false, + isStreaming: false, + agent: { + replaceMessages: (messages: unknown[]) => { + session.messages = [...messages]; + }, + }, + prompt: async () => { + session.messages = [ + ...session.messages, + { role: "assistant", content: "done", timestamp: 2 }, + ]; + }, + abort: async () => {}, + dispose: () => {}, + steer: async () => {}, + }; + + return session; +} + const testModel = { api: "openai-completions", provider: "openai", @@ -269,32 +335,14 @@ describe("runEmbeddedAttempt sessions_spawn workspace inheritance", () => { const tempPaths: string[] = []; beforeEach(() => { - hoisted.spawnSubagentDirectMock.mockReset().mockResolvedValue({ - status: "accepted", - childSessionKey: "agent:main:subagent:child", - runId: "run-child", + resetEmbeddedAttemptHarness({ + includeSpawnSubagent: true, + subscribeImpl: createSubscriptionMock, }); - hoisted.createAgentSessionMock.mockReset(); - hoisted.sessionManagerOpenMock.mockReset().mockReturnValue(hoisted.sessionManager); - hoisted.resolveSandboxContextMock.mockReset(); - hoisted.subscribeEmbeddedPiSessionMock.mockReset().mockImplementation(createSubscriptionMock); - hoisted.acquireSessionWriteLockMock.mockReset().mockResolvedValue({ - release: async () => {}, - }); - hoisted.sessionManager.getLeafEntry.mockReset().mockReturnValue(null); - hoisted.sessionManager.branch.mockReset(); - hoisted.sessionManager.resetLeaf.mockReset(); - hoisted.sessionManager.buildSessionContext.mockReset().mockReturnValue({ messages: [] }); - hoisted.sessionManager.appendCustomEntry.mockReset(); }); afterEach(async () => { - while (tempPaths.length > 0) { - const target = tempPaths.pop(); - if (target) { - await fs.rm(target, { recursive: true, force: true }); - } - } + await cleanupTempPaths(tempPaths); }); it("passes the real workspace to sessions_spawn when workspaceAccess is ro", async () => { @@ -394,26 +442,11 @@ describe("runEmbeddedAttempt cache-ttl tracking after compaction", () => { const tempPaths: string[] = []; beforeEach(() => { - hoisted.createAgentSessionMock.mockReset(); - hoisted.sessionManagerOpenMock.mockReset().mockReturnValue(hoisted.sessionManager); - hoisted.resolveSandboxContextMock.mockReset(); - hoisted.acquireSessionWriteLockMock.mockReset().mockResolvedValue({ - release: async () => {}, - }); - hoisted.sessionManager.getLeafEntry.mockReset().mockReturnValue(null); - hoisted.sessionManager.branch.mockReset(); - hoisted.sessionManager.resetLeaf.mockReset(); - hoisted.sessionManager.buildSessionContext.mockReset().mockReturnValue({ messages: [] }); - hoisted.sessionManager.appendCustomEntry.mockReset(); + resetEmbeddedAttemptHarness(); }); afterEach(async () => { - while (tempPaths.length > 0) { - const target = tempPaths.pop(); - if (target) { - await fs.rm(target, { recursive: true, force: true }); - } - } + await cleanupTempPaths(tempPaths); }); async function runAttemptWithCacheTtl(compactionCount: number) { @@ -428,30 +461,9 @@ describe("runEmbeddedAttempt cache-ttl tracking after compaction", () => { getCompactionCount: () => compactionCount, })); - hoisted.createAgentSessionMock.mockImplementation(async () => { - const session: MutableSession = { - sessionId: "embedded-session", - messages: [], - isCompacting: false, - isStreaming: false, - agent: { - replaceMessages: (messages: unknown[]) => { - session.messages = [...messages]; - }, - }, - prompt: async () => { - session.messages = [ - ...session.messages, - { role: "assistant", content: "done", timestamp: 2 }, - ]; - }, - abort: async () => {}, - dispose: () => {}, - steer: async () => {}, - }; - - return { session }; - }); + hoisted.createAgentSessionMock.mockImplementation(async () => ({ + session: createDefaultEmbeddedSession(), + })); return await runEmbeddedAttempt({ sessionId: "embedded-session", @@ -591,30 +603,9 @@ describe("runEmbeddedAttempt context engine sessionKey forwarding", () => { .mockReset() .mockReturnValue({ messages: seedMessages }); - hoisted.createAgentSessionMock.mockImplementation(async () => { - const session: MutableSession = { - sessionId: "embedded-session", - messages: [], - isCompacting: false, - isStreaming: false, - agent: { - replaceMessages: (messages: unknown[]) => { - session.messages = [...messages]; - }, - }, - prompt: async () => { - session.messages = [ - ...session.messages, - { role: "assistant", content: "done", timestamp: 2 }, - ]; - }, - abort: async () => {}, - dispose: () => {}, - steer: async () => {}, - }; - - return { session }; - }); + hoisted.createAgentSessionMock.mockImplementation(async () => ({ + session: createDefaultEmbeddedSession(), + })); return await runEmbeddedAttempt({ sessionId: "embedded-session", From 6a1ba52ad59bce7a162e3c3bf2a96d0500c852d9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 17:54:11 +0000 Subject: [PATCH 036/640] refactor: share gateway probe auth warnings --- src/gateway/probe-auth.test.ts | 130 ++++++++++++++++----------------- 1 file changed, 63 insertions(+), 67 deletions(-) diff --git a/src/gateway/probe-auth.test.ts b/src/gateway/probe-auth.test.ts index 314702c33db..bbf034c882f 100644 --- a/src/gateway/probe-auth.test.ts +++ b/src/gateway/probe-auth.test.ts @@ -5,10 +5,21 @@ import { resolveGatewayProbeAuthWithSecretInputs, } from "./probe-auth.js"; +function expectUnresolvedProbeTokenWarning(cfg: OpenClawConfig) { + const result = resolveGatewayProbeAuthSafe({ + cfg, + mode: "local", + env: {} as NodeJS.ProcessEnv, + }); + + expect(result.auth).toEqual({}); + expect(result.warning).toContain("gateway.auth.token"); + expect(result.warning).toContain("unresolved"); +} + describe("resolveGatewayProbeAuthSafe", () => { - it.each([ - { - name: "returns probe auth credentials when available", + it("returns probe auth credentials when available", () => { + const result = resolveGatewayProbeAuthSafe({ cfg: { gateway: { auth: { @@ -16,65 +27,56 @@ describe("resolveGatewayProbeAuthSafe", () => { }, }, } as OpenClawConfig, - mode: "local" as const, + mode: "local", env: {} as NodeJS.ProcessEnv, - expected: { + }); + + expect(result).toEqual({ + auth: { + token: "token-value", + password: undefined, + }, + }); + }); + + it("returns warning and empty auth when token SecretRef is unresolved", () => { + expectUnresolvedProbeTokenWarning({ + gateway: { auth: { - token: "token-value", - password: undefined, + mode: "token", + token: { source: "env", provider: "default", id: "MISSING_GATEWAY_TOKEN" }, }, }, - }, - { - name: "returns warning and empty auth when a local token SecretRef is unresolved", - cfg: { - gateway: { - auth: { - mode: "token", - token: { source: "env", provider: "default", id: "MISSING_GATEWAY_TOKEN" }, - }, + secrets: { + providers: { + default: { source: "env" }, }, - secrets: { - providers: { - default: { source: "env" }, - }, - }, - } as OpenClawConfig, - mode: "local" as const, - env: {} as NodeJS.ProcessEnv, - expected: { - auth: {}, - warningIncludes: ["gateway.auth.token", "unresolved"], }, - }, - { - name: "does not fall through to remote token when the local SecretRef is unresolved", - cfg: { - gateway: { - mode: "local", - auth: { - mode: "token", - token: { source: "env", provider: "default", id: "MISSING_GATEWAY_TOKEN" }, - }, - remote: { - token: "remote-token", - }, + } as OpenClawConfig); + }); + + it("does not fall through to remote token when local token SecretRef is unresolved", () => { + expectUnresolvedProbeTokenWarning({ + gateway: { + mode: "local", + auth: { + mode: "token", + token: { source: "env", provider: "default", id: "MISSING_GATEWAY_TOKEN" }, }, - secrets: { - providers: { - default: { source: "env" }, - }, + remote: { + token: "remote-token", }, - } as OpenClawConfig, - mode: "local" as const, - env: {} as NodeJS.ProcessEnv, - expected: { - auth: {}, - warningIncludes: ["gateway.auth.token", "unresolved"], }, - }, - { - name: "ignores unresolved local token SecretRefs in remote mode", + secrets: { + providers: { + default: { source: "env" }, + }, + }, + } as OpenClawConfig); + }); + + it("ignores unresolved local token SecretRef in remote mode when remote-only auth is requested", () => { + const result = resolveGatewayProbeAuthSafe({ cfg: { gateway: { mode: "remote", @@ -92,22 +94,16 @@ describe("resolveGatewayProbeAuthSafe", () => { }, }, } as OpenClawConfig, - mode: "remote" as const, + mode: "remote", env: {} as NodeJS.ProcessEnv, - expected: { - auth: { - token: undefined, - password: undefined, - }, - }, - }, - ])("$name", ({ cfg, mode, env, expected }) => { - const result = resolveGatewayProbeAuthSafe({ cfg, mode, env }); + }); - expect(result.auth).toEqual(expected.auth); - for (const fragment of expected.warningIncludes ?? []) { - expect(result.warning).toContain(fragment); - } + expect(result).toEqual({ + auth: { + token: undefined, + password: undefined, + }, + }); }); }); From a3ece09d19b9c7ae2a0bbeafce46f859d8d4f070 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 17:54:52 +0000 Subject: [PATCH 037/640] refactor: share control ui hardlink asset setup --- src/gateway/control-ui.http.test.ts | 228 +++++++++++++--------------- 1 file changed, 104 insertions(+), 124 deletions(-) diff --git a/src/gateway/control-ui.http.test.ts b/src/gateway/control-ui.http.test.ts index 54cf972e79c..e6b74c3d135 100644 --- a/src/gateway/control-ui.http.test.ts +++ b/src/gateway/control-ui.http.test.ts @@ -40,25 +40,6 @@ describe("handleControlUiHttpRequest", () => { expect(params.end).toHaveBeenCalledWith("Not Found"); } - function expectUnhandledRoutes(params: { - urls: string[]; - method: "GET" | "POST"; - rootPath: string; - basePath?: string; - expectationLabel: string; - }) { - for (const url of params.urls) { - const { handled, end } = runControlUiRequest({ - url, - method: params.method, - rootPath: params.rootPath, - ...(params.basePath ? { basePath: params.basePath } : {}), - }); - expect(handled, `${params.expectationLabel}: ${url}`).toBe(false); - expect(end, `${params.expectationLabel}: ${url}`).not.toHaveBeenCalled(); - } - } - function runControlUiRequest(params: { url: string; method: "GET" | "HEAD" | "POST"; @@ -104,6 +85,13 @@ describe("handleControlUiHttpRequest", () => { return { assetsDir, filePath }; } + async function createHardlinkedAssetFile(rootPath: string) { + const { filePath } = await writeAssetFile(rootPath, "app.js", "console.log('hi');"); + const hardlinkPath = path.join(path.dirname(filePath), "app.hl.js"); + await fs.link(filePath, hardlinkPath); + return hardlinkPath; + } + async function withBasePathRootFixture(params: { siblingDir: string; fn: (paths: { root: string; sibling: string }) => Promise; @@ -166,80 +154,53 @@ describe("handleControlUiHttpRequest", () => { }); }); - it.each([ - { - name: "at root", - url: CONTROL_UI_BOOTSTRAP_CONFIG_PATH, - expectedBasePath: "", - assistantName: ".png", - expectedAvatarUrl: "/avatar/main", - }, - { - name: "under basePath", - url: `/openclaw${CONTROL_UI_BOOTSTRAP_CONFIG_PATH}`, - basePath: "/openclaw", - expectedBasePath: "/openclaw", - assistantName: "Ops", - assistantAvatar: "ops.png", - expectedAvatarUrl: "/openclaw/avatar/main", - }, - ])("serves bootstrap config JSON $name", async (testCase) => { + it("serves bootstrap config JSON", async () => { await withControlUiRoot({ fn: async (tmp) => { const { res, end } = makeMockHttpResponse(); const handled = handleControlUiHttpRequest( - { url: testCase.url, method: "GET" } as IncomingMessage, + { url: CONTROL_UI_BOOTSTRAP_CONFIG_PATH, method: "GET" } as IncomingMessage, res, { - ...(testCase.basePath ? { basePath: testCase.basePath } : {}), root: { kind: "resolved", path: tmp }, config: { agents: { defaults: { workspace: tmp } }, - ui: { - assistant: { - name: testCase.assistantName, - avatar: testCase.assistantAvatar, - }, - }, + ui: { assistant: { name: ".png" } }, }, }, ); expect(handled).toBe(true); const parsed = parseBootstrapPayload(end); - expect(parsed.basePath).toBe(testCase.expectedBasePath); - expect(parsed.assistantName).toBe(testCase.assistantName); - expect(parsed.assistantAvatar).toBe(testCase.expectedAvatarUrl); + expect(parsed.basePath).toBe(""); + expect(parsed.assistantName).toBe("