From d3e0c0b29c1846f943baaaebb5e37b496457b6dd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 2 Mar 2026 06:41:22 +0000 Subject: [PATCH] test(gateway): dedupe gateway and infra test scaffolds --- src/gateway/gateway-cli-backend.live.test.ts | 37 +- src/gateway/server-methods/agent.test.ts | 48 +- .../server-methods/agents-mutate.test.ts | 133 +-- src/gateway/server-methods/send.test.ts | 30 +- .../server-methods/server-methods.test.ts | 149 ++- src/gateway/server.plugin-http-auth.test.ts | 937 +++++++----------- src/gateway/server/plugins-http.test.ts | 84 +- src/gateway/session-utils.test.ts | 257 ++--- src/gateway/sessions-patch.test.ts | 456 ++++----- .../tools-invoke-http.cron-regression.test.ts | 12 +- src/infra/exec-approvals.test.ts | 37 +- .../targets.channel-resolution.test.ts | 16 +- src/infra/update-runner.test.ts | 81 +- src/node-host/invoke-system-run.test.ts | 542 ++++------ 14 files changed, 1126 insertions(+), 1693 deletions(-) diff --git a/src/gateway/gateway-cli-backend.live.test.ts b/src/gateway/gateway-cli-backend.live.test.ts index 7552924083f..c25463d796d 100644 --- a/src/gateway/gateway-cli-backend.live.test.ts +++ b/src/gateway/gateway-cli-backend.live.test.ts @@ -121,32 +121,39 @@ async function getFreeGatewayPort(): Promise { async function connectClient(params: { url: string; token: string }) { return await new Promise((resolve, reject) => { - let settled = false; - const stop = (err?: Error, client?: GatewayClient) => { - if (settled) { + let done = false; + const finish = (result: { client?: GatewayClient; error?: Error }) => { + if (done) { return; } - settled = true; - clearTimeout(timer); - if (err) { - reject(err); - } else { - resolve(client as GatewayClient); + done = true; + clearTimeout(connectTimeout); + if (result.error) { + reject(result.error); + return; } + resolve(result.client as GatewayClient); }; + + const failWithClose = (code: number, reason: string) => + finish({ error: new Error(`gateway closed during connect (${code}): ${reason}`) }); + const client = new GatewayClient({ url: params.url, token: params.token, clientName: GATEWAY_CLIENT_NAMES.TEST, clientVersion: "dev", mode: "test", - onHelloOk: () => stop(undefined, client), - onConnectError: (err) => stop(err), - onClose: (code, reason) => - stop(new Error(`gateway closed during connect (${code}): ${reason}`)), + onHelloOk: () => finish({ client }), + onConnectError: (error) => finish({ error }), + onClose: failWithClose, }); - const timer = setTimeout(() => stop(new Error("gateway connect timeout")), 10_000); - timer.unref(); + + const connectTimeout = setTimeout( + () => finish({ error: new Error("gateway connect timeout") }), + 10_000, + ); + connectTimeout.unref(); client.start(); }); } diff --git a/src/gateway/server-methods/agent.test.ts b/src/gateway/server-methods/agent.test.ts index 9aec19c04bc..783e03eb0b2 100644 --- a/src/gateway/server-methods/agent.test.ts +++ b/src/gateway/server-methods/agent.test.ts @@ -325,20 +325,33 @@ describe("gateway agent handler", () => { vi.useRealTimers(); }); - it("passes senderIsOwner=false for write-scoped gateway callers", async () => { + it.each([ + { + name: "passes senderIsOwner=false for write-scoped gateway callers", + scopes: ["operator.write"], + idempotencyKey: "test-sender-owner-write", + senderIsOwner: false, + }, + { + name: "passes senderIsOwner=true for admin-scoped gateway callers", + scopes: ["operator.admin"], + idempotencyKey: "test-sender-owner-admin", + senderIsOwner: true, + }, + ])("$name", async ({ scopes, idempotencyKey, senderIsOwner }) => { primeMainAgentRun(); await invokeAgent( { message: "owner-tools check", sessionKey: "agent:main:main", - idempotencyKey: "test-sender-owner-write", + idempotencyKey, }, { client: { connect: { role: "operator", - scopes: ["operator.write"], + scopes, client: { id: "test-client", mode: "gateway" }, }, } as unknown as AgentHandlerArgs["client"], @@ -349,34 +362,7 @@ describe("gateway agent handler", () => { const callArgs = mocks.agentCommand.mock.calls.at(-1)?.[0] as | { senderIsOwner?: boolean } | undefined; - expect(callArgs?.senderIsOwner).toBe(false); - }); - - it("passes senderIsOwner=true for admin-scoped gateway callers", async () => { - primeMainAgentRun(); - - await invokeAgent( - { - message: "owner-tools check", - sessionKey: "agent:main:main", - idempotencyKey: "test-sender-owner-admin", - }, - { - client: { - connect: { - role: "operator", - scopes: ["operator.admin"], - client: { id: "test-client", mode: "gateway" }, - }, - } as unknown as AgentHandlerArgs["client"], - }, - ); - - await vi.waitFor(() => expect(mocks.agentCommand).toHaveBeenCalled()); - const callArgs = mocks.agentCommand.mock.calls.at(-1)?.[0] as - | { senderIsOwner?: boolean } - | undefined; - expect(callArgs?.senderIsOwner).toBe(true); + expect(callArgs?.senderIsOwner).toBe(senderIsOwner); }); it("respects explicit bestEffortDeliver=false for main session runs", async () => { diff --git a/src/gateway/server-methods/agents-mutate.test.ts b/src/gateway/server-methods/agents-mutate.test.ts index 04d2a785188..646da63b340 100644 --- a/src/gateway/server-methods/agents-mutate.test.ts +++ b/src/gateway/server-methods/agents-mutate.test.ts @@ -201,6 +201,20 @@ function expectNotFoundResponseAndNoWrite(respond: ReturnType) { expect(mocks.writeConfigFile).not.toHaveBeenCalled(); } +async function expectUnsafeWorkspaceFile(method: "agents.files.get" | "agents.files.set") { + const params = + method === "agents.files.set" + ? { agentId: "main", name: "AGENTS.md", content: "x" } + : { agentId: "main", name: "AGENTS.md" }; + const { respond, promise } = makeCall(method, params); + await promise; + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ message: expect.stringContaining("unsafe workspace file") }), + ); +} + beforeEach(() => { mocks.fsReadFile.mockImplementation(async () => { throw createEnoentError(); @@ -517,7 +531,7 @@ describe("agents.files.get/set symlink safety", () => { mocks.fsMkdir.mockResolvedValue(undefined); }); - it("rejects agents.files.get when allowlisted file symlink escapes workspace", async () => { + function mockWorkspaceEscapeSymlink() { const workspace = "/workspace/test-agent"; const candidate = path.resolve(workspace, "AGENTS.md"); mocks.fsRealpath.mockImplementation(async (p: string) => { @@ -536,54 +550,21 @@ describe("agents.files.get/set symlink safety", () => { } throw createEnoentError(); }); + } - const { respond, promise } = makeCall("agents.files.get", { - agentId: "main", - name: "AGENTS.md", - }); - await promise; - - expect(respond).toHaveBeenCalledWith( - false, - undefined, - expect.objectContaining({ message: expect.stringContaining("unsafe workspace file") }), - ); - }); - - it("rejects agents.files.set when allowlisted file symlink escapes workspace", async () => { - const workspace = "/workspace/test-agent"; - const candidate = path.resolve(workspace, "AGENTS.md"); - mocks.fsRealpath.mockImplementation(async (p: string) => { - if (p === workspace) { - return workspace; + it.each([ + { method: "agents.files.get" as const, expectNoOpen: false }, + { method: "agents.files.set" as const, expectNoOpen: true }, + ])( + "rejects $method when allowlisted file symlink escapes workspace", + async ({ method, expectNoOpen }) => { + mockWorkspaceEscapeSymlink(); + await expectUnsafeWorkspaceFile(method); + if (expectNoOpen) { + expect(mocks.fsOpen).not.toHaveBeenCalled(); } - if (p === candidate) { - return "/outside/secret.txt"; - } - return p; - }); - mocks.fsLstat.mockImplementation(async (...args: unknown[]) => { - const p = typeof args[0] === "string" ? args[0] : ""; - if (p === candidate) { - return makeSymlinkStat(); - } - throw createEnoentError(); - }); - - const { respond, promise } = makeCall("agents.files.set", { - agentId: "main", - name: "AGENTS.md", - content: "x", - }); - await promise; - - expect(respond).toHaveBeenCalledWith( - false, - undefined, - expect.objectContaining({ message: expect.stringContaining("unsafe workspace file") }), - ); - expect(mocks.fsOpen).not.toHaveBeenCalled(); - }); + }, + ); it("allows in-workspace symlink targets for get/set", async () => { const workspace = "/workspace/test-agent"; @@ -654,7 +635,7 @@ describe("agents.files.get/set symlink safety", () => { ); }); - it("rejects agents.files.get when allowlisted file is a hardlinked alias", async () => { + function mockHardlinkedWorkspaceAlias() { const workspace = "/workspace/test-agent"; const candidate = path.resolve(workspace, "AGENTS.md"); mocks.fsRealpath.mockImplementation(async (p: string) => { @@ -670,49 +651,19 @@ describe("agents.files.get/set symlink safety", () => { } throw createEnoentError(); }); + } - const { respond, promise } = makeCall("agents.files.get", { - agentId: "main", - name: "AGENTS.md", - }); - await promise; - - expect(respond).toHaveBeenCalledWith( - false, - undefined, - expect.objectContaining({ message: expect.stringContaining("unsafe workspace file") }), - ); - }); - - it("rejects agents.files.set when allowlisted file is a hardlinked alias", async () => { - const workspace = "/workspace/test-agent"; - const candidate = path.resolve(workspace, "AGENTS.md"); - mocks.fsRealpath.mockImplementation(async (p: string) => { - if (p === workspace) { - return workspace; + it.each([ + { method: "agents.files.get" as const, expectNoOpen: false }, + { method: "agents.files.set" as const, expectNoOpen: true }, + ])( + "rejects $method when allowlisted file is a hardlinked alias", + async ({ method, expectNoOpen }) => { + mockHardlinkedWorkspaceAlias(); + await expectUnsafeWorkspaceFile(method); + if (expectNoOpen) { + expect(mocks.fsOpen).not.toHaveBeenCalled(); } - return p; - }); - mocks.fsLstat.mockImplementation(async (...args: unknown[]) => { - const p = typeof args[0] === "string" ? args[0] : ""; - if (p === candidate) { - return makeFileStat({ nlink: 2 }); - } - throw createEnoentError(); - }); - - const { respond, promise } = makeCall("agents.files.set", { - agentId: "main", - name: "AGENTS.md", - content: "x", - }); - await promise; - - expect(respond).toHaveBeenCalledWith( - false, - undefined, - expect.objectContaining({ message: expect.stringContaining("unsafe workspace file") }), - ); - expect(mocks.fsOpen).not.toHaveBeenCalled(); - }); + }, + ); }); diff --git a/src/gateway/server-methods/send.test.ts b/src/gateway/server-methods/send.test.ts index e3c3c168c31..aa3a6593bd2 100644 --- a/src/gateway/server-methods/send.test.ts +++ b/src/gateway/server-methods/send.test.ts @@ -30,6 +30,22 @@ vi.mock("../../channels/plugins/index.js", () => ({ normalizeChannelId: (value: string) => (value === "webchat" ? null : value), })); +const TEST_AGENT_WORKSPACE = "/tmp/openclaw-test-workspace"; + +function resolveAgentIdFromSessionKeyForTests(params: { sessionKey?: string }): string { + if (typeof params.sessionKey === "string") { + const match = params.sessionKey.match(/^agent:([^:]+)/i); + if (match?.[1]) { + return match[1]; + } + } + return "main"; +} + +function passthroughPluginAutoEnable(config: unknown) { + return { config, changes: [] as unknown[] }; +} + vi.mock("../../agents/agent-scope.js", () => ({ resolveSessionAgentId: ({ sessionKey, @@ -37,21 +53,13 @@ vi.mock("../../agents/agent-scope.js", () => ({ sessionKey?: string; config?: unknown; agentId?: string; - }) => { - if (typeof sessionKey === "string") { - const match = sessionKey.match(/^agent:([^:]+)/i); - if (match?.[1]) { - return match[1]; - } - } - return "main"; - }, + }) => resolveAgentIdFromSessionKeyForTests({ sessionKey }), resolveDefaultAgentId: () => "main", - resolveAgentWorkspaceDir: () => "/tmp/openclaw-test-workspace", + resolveAgentWorkspaceDir: () => TEST_AGENT_WORKSPACE, })); vi.mock("../../config/plugin-auto-enable.js", () => ({ - applyPluginAutoEnable: ({ config }: { config: unknown }) => ({ config, changes: [] }), + applyPluginAutoEnable: ({ config }: { config: unknown }) => passthroughPluginAutoEnable(config), })); vi.mock("../../plugins/loader.js", () => ({ diff --git a/src/gateway/server-methods/server-methods.test.ts b/src/gateway/server-methods/server-methods.test.ts index c3c049cfe4b..02e4c05cf32 100644 --- a/src/gateway/server-methods/server-methods.test.ts +++ b/src/gateway/server-methods/server-methods.test.ts @@ -22,18 +22,36 @@ vi.mock("../../commands/status.js", () => ({ })); describe("waitForAgentJob", () => { - it("maps lifecycle end events with aborted=true to timeout", async () => { - const runId = `run-timeout-${Date.now()}-${Math.random().toString(36).slice(2)}`; + async function runLifecycleScenario(params: { + runIdPrefix: string; + startedAt: number; + endedAt: number; + aborted?: boolean; + }) { + const runId = `${params.runIdPrefix}-${Date.now()}-${Math.random().toString(36).slice(2)}`; const waitPromise = waitForAgentJob({ runId, timeoutMs: 1_000 }); - emitAgentEvent({ runId, stream: "lifecycle", data: { phase: "start", startedAt: 100 } }); emitAgentEvent({ runId, stream: "lifecycle", - data: { phase: "end", endedAt: 200, aborted: true }, + data: { phase: "start", startedAt: params.startedAt }, + }); + emitAgentEvent({ + runId, + stream: "lifecycle", + data: { phase: "end", endedAt: params.endedAt, aborted: params.aborted }, }); - const snapshot = await waitPromise; + return waitPromise; + } + + it("maps lifecycle end events with aborted=true to timeout", async () => { + const snapshot = await runLifecycleScenario({ + runIdPrefix: "run-timeout", + startedAt: 100, + endedAt: 200, + aborted: true, + }); expect(snapshot).not.toBeNull(); expect(snapshot?.status).toBe("timeout"); expect(snapshot?.startedAt).toBe(100); @@ -41,13 +59,11 @@ describe("waitForAgentJob", () => { }); it("keeps non-aborted lifecycle end events as ok", async () => { - const runId = `run-ok-${Date.now()}-${Math.random().toString(36).slice(2)}`; - const waitPromise = waitForAgentJob({ runId, timeoutMs: 1_000 }); - - emitAgentEvent({ runId, stream: "lifecycle", data: { phase: "start", startedAt: 300 } }); - emitAgentEvent({ runId, stream: "lifecycle", data: { phase: "end", endedAt: 400 } }); - - const snapshot = await waitPromise; + const snapshot = await runLifecycleScenario({ + runIdPrefix: "run-ok", + startedAt: 300, + endedAt: 400, + }); expect(snapshot).not.toBeNull(); expect(snapshot?.status).toBe("ok"); expect(snapshot?.startedAt).toBe(300); @@ -359,47 +375,43 @@ describe("exec approval handlers", () => { return { handlers, broadcasts, respond, context }; } + function createForwardingExecApprovalFixture() { + const manager = new ExecApprovalManager(); + const forwarder = { + handleRequested: vi.fn(async () => false), + handleResolved: vi.fn(async () => {}), + stop: vi.fn(), + }; + const handlers = createExecApprovalHandlers(manager, { forwarder }); + const respond = vi.fn(); + const context = { + broadcast: (_event: string, _payload: unknown) => {}, + hasExecApprovalClients: () => false, + }; + return { manager, handlers, forwarder, respond, context }; + } + + async function drainApprovalRequestTicks() { + for (let idx = 0; idx < 20; idx += 1) { + await Promise.resolve(); + } + } + describe("ExecApprovalRequestParams validation", () => { - it("accepts request with resolvedPath omitted", () => { - const params = { - command: "echo hi", - cwd: "/tmp", - nodeId: "node-1", - host: "node", - }; - expect(validateExecApprovalRequestParams(params)).toBe(true); - }); + const baseParams = { + command: "echo hi", + cwd: "/tmp", + nodeId: "node-1", + host: "node", + }; - it("accepts request with resolvedPath as string", () => { - const params = { - command: "echo hi", - cwd: "/tmp", - nodeId: "node-1", - host: "node", - resolvedPath: "/usr/bin/echo", - }; - expect(validateExecApprovalRequestParams(params)).toBe(true); - }); - - it("accepts request with resolvedPath as undefined", () => { - const params = { - command: "echo hi", - cwd: "/tmp", - nodeId: "node-1", - host: "node", - resolvedPath: undefined, - }; - expect(validateExecApprovalRequestParams(params)).toBe(true); - }); - - it("accepts request with resolvedPath as null", () => { - const params = { - command: "echo hi", - cwd: "/tmp", - nodeId: "node-1", - host: "node", - resolvedPath: null, - }; + it.each([ + { label: "omitted", extra: {} }, + { label: "string", extra: { resolvedPath: "/usr/bin/echo" } }, + { label: "undefined", extra: { resolvedPath: undefined } }, + { label: "null", extra: { resolvedPath: null } }, + ])("accepts request with resolvedPath $label", ({ extra }) => { + const params = { ...baseParams, ...extra }; expect(validateExecApprovalRequestParams(params)).toBe(true); }); }); @@ -618,18 +630,7 @@ describe("exec approval handlers", () => { it("forwards turn-source metadata to exec approval forwarding", async () => { vi.useFakeTimers(); try { - const manager = new ExecApprovalManager(); - const forwarder = { - handleRequested: vi.fn(async () => false), - handleResolved: vi.fn(async () => {}), - stop: vi.fn(), - }; - const handlers = createExecApprovalHandlers(manager, { forwarder }); - const respond = vi.fn(); - const context = { - broadcast: (_event: string, _payload: unknown) => {}, - hasExecApprovalClients: () => false, - }; + const { handlers, forwarder, respond, context } = createForwardingExecApprovalFixture(); const requestPromise = requestExecApproval({ handlers, @@ -643,9 +644,7 @@ describe("exec approval handlers", () => { turnSourceThreadId: "1739201675.123", }, }); - for (let idx = 0; idx < 20; idx += 1) { - await Promise.resolve(); - } + await drainApprovalRequestTicks(); expect(forwarder.handleRequested).toHaveBeenCalledTimes(1); expect(forwarder.handleRequested).toHaveBeenCalledWith( expect.objectContaining({ @@ -668,18 +667,8 @@ describe("exec approval handlers", () => { it("expires immediately when no approver clients and no forwarding targets", async () => { vi.useFakeTimers(); try { - const manager = new ExecApprovalManager(); - const forwarder = { - handleRequested: vi.fn(async () => false), - handleResolved: vi.fn(async () => {}), - stop: vi.fn(), - }; - const handlers = createExecApprovalHandlers(manager, { forwarder }); - const respond = vi.fn(); - const context = { - broadcast: (_event: string, _payload: unknown) => {}, - hasExecApprovalClients: () => false, - }; + const { manager, handlers, forwarder, respond, context } = + createForwardingExecApprovalFixture(); const expireSpy = vi.spyOn(manager, "expire"); const requestPromise = requestExecApproval({ @@ -688,9 +677,7 @@ describe("exec approval handlers", () => { context, params: { timeoutMs: 60_000 }, }); - for (let idx = 0; idx < 20; idx += 1) { - await Promise.resolve(); - } + await drainApprovalRequestTicks(); expect(forwarder.handleRequested).toHaveBeenCalledTimes(1); expect(expireSpy).toHaveBeenCalledTimes(1); await vi.runOnlyPendingTimersAsync(); diff --git a/src/gateway/server.plugin-http-auth.test.ts b/src/gateway/server.plugin-http-auth.test.ts index 980521d295c..cfeefe33eec 100644 --- a/src/gateway/server.plugin-http-auth.test.ts +++ b/src/gateway/server.plugin-http-auth.test.ts @@ -7,6 +7,23 @@ import { canonicalizePathVariant, isProtectedPluginRoutePath } from "./security- import { createGatewayHttpServer, createHooksRequestHandler } from "./server-http.js"; import { withTempConfig } from "./test-temp-config.js"; +type GatewayHttpServer = ReturnType; +type GatewayServerOptions = Partial[0]>; + +const AUTH_NONE: ResolvedGatewayAuth = { + mode: "none", + token: undefined, + password: undefined, + allowTailscale: false, +}; + +const AUTH_TOKEN: ResolvedGatewayAuth = { + mode: "token", + token: "test-token", + password: undefined, + allowTailscale: false, +}; + function createRequest(params: { path: string; authorization?: string; @@ -60,7 +77,7 @@ function createResponse(): { } async function dispatchRequest( - server: ReturnType, + server: GatewayHttpServer, req: IncomingMessage, res: ServerResponse, ): Promise { @@ -68,6 +85,67 @@ async function dispatchRequest( await new Promise((resolve) => setImmediate(resolve)); } +async function withGatewayTempConfig(prefix: string, run: () => Promise): Promise { + await withTempConfig({ + cfg: { gateway: { trustedProxies: [] } }, + prefix, + run, + }); +} + +function createTestGatewayServer(options: { + resolvedAuth: ResolvedGatewayAuth; + overrides?: GatewayServerOptions; +}): GatewayHttpServer { + return createGatewayHttpServer({ + canvasHost: null, + clients: new Set(), + controlUiEnabled: false, + controlUiBasePath: "/__control__", + openAiChatCompletionsEnabled: false, + openResponsesEnabled: false, + handleHooksRequest: async () => false, + ...options.overrides, + resolvedAuth: options.resolvedAuth, + }); +} + +async function withGatewayServer(params: { + prefix: string; + resolvedAuth: ResolvedGatewayAuth; + overrides?: GatewayServerOptions; + run: (server: GatewayHttpServer) => Promise; +}): Promise { + await withGatewayTempConfig(params.prefix, async () => { + const server = createTestGatewayServer({ + resolvedAuth: params.resolvedAuth, + overrides: params.overrides, + }); + await params.run(server); + }); +} + +async function sendRequest( + server: GatewayHttpServer, + params: { + path: string; + authorization?: string; + method?: string; + }, +) { + const response = createResponse(); + await dispatchRequest(server, createRequest(params), response.res); + return response; +} + +function expectUnauthorizedResponse( + response: ReturnType, + label?: string, +): void { + expect(response.res.statusCode, label).toBe(401); + expect(response.getBody(), label).toContain("Unauthorized"); +} + function createHooksConfig(): HooksConfigResolved { return { basePath: "/hooks", @@ -91,6 +169,36 @@ function canonicalizePluginPath(pathname: string): string { return canonicalizePathVariant(pathname); } +function createCanonicalizedChannelPluginHandler() { + return vi.fn(async (req: IncomingMessage, res: ServerResponse) => { + const pathname = new URL(req.url ?? "/", "http://localhost").pathname; + const canonicalPath = canonicalizePluginPath(pathname); + if (canonicalPath !== "/api/channels/nostr/default/profile") { + return false; + } + res.statusCode = 200; + res.setHeader("Content-Type", "application/json; charset=utf-8"); + res.end(JSON.stringify({ ok: true, route: "channel-canonicalized" })); + return true; + }); +} + +function createHooksHandler(bindHost: string) { + return createHooksRequestHandler({ + getHooksConfig: () => createHooksConfig(), + bindHost, + port: 18789, + logHooks: { + warn: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + error: vi.fn(), + } as unknown as ReturnType, + dispatchWakeHook: () => {}, + dispatchAgentHook: () => "run-1", + }); +} + type RouteVariant = { label: string; path: string; @@ -149,32 +257,25 @@ function buildChannelPathFuzzCorpus(): RouteVariant[] { } async function expectUnauthorizedVariants(params: { - server: ReturnType; + server: GatewayHttpServer; variants: RouteVariant[]; }) { for (const variant of params.variants) { - const response = createResponse(); - await dispatchRequest(params.server, createRequest({ path: variant.path }), response.res); - expect(response.res.statusCode, variant.label).toBe(401); - expect(response.getBody(), variant.label).toContain("Unauthorized"); + const response = await sendRequest(params.server, { path: variant.path }); + expectUnauthorizedResponse(response, variant.label); } } async function expectAuthorizedVariants(params: { - server: ReturnType; + server: GatewayHttpServer; variants: RouteVariant[]; authorization: string; }) { for (const variant of params.variants) { - const response = createResponse(); - await dispatchRequest( - params.server, - createRequest({ - path: variant.path, - authorization: params.authorization, - }), - response.res, - ); + const response = await sendRequest(params.server, { + path: variant.path, + authorization: params.authorization, + }); expect(response.res.statusCode, variant.label).toBe(200); expect(response.getBody(), variant.label).toContain('"route":"channel-canonicalized"'); } @@ -182,90 +283,38 @@ async function expectAuthorizedVariants(params: { describe("gateway plugin HTTP auth boundary", () => { test("applies default security headers and optional strict transport security", async () => { - const resolvedAuth: ResolvedGatewayAuth = { - mode: "none", - token: undefined, - password: undefined, - allowTailscale: false, - }; + await withGatewayTempConfig("openclaw-plugin-http-security-headers-test-", async () => { + const withoutHsts = createTestGatewayServer({ resolvedAuth: AUTH_NONE }); + const withoutHstsResponse = await sendRequest(withoutHsts, { path: "/missing" }); + expect(withoutHstsResponse.setHeader).toHaveBeenCalledWith( + "X-Content-Type-Options", + "nosniff", + ); + expect(withoutHstsResponse.setHeader).toHaveBeenCalledWith("Referrer-Policy", "no-referrer"); + expect(withoutHstsResponse.setHeader).not.toHaveBeenCalledWith( + "Strict-Transport-Security", + expect.any(String), + ); - await withTempConfig({ - cfg: { gateway: { trustedProxies: [] } }, - prefix: "openclaw-plugin-http-security-headers-test-", - run: async () => { - const withoutHsts = createGatewayHttpServer({ - canvasHost: null, - clients: new Set(), - controlUiEnabled: false, - controlUiBasePath: "/__control__", - openAiChatCompletionsEnabled: false, - openResponsesEnabled: false, - handleHooksRequest: async () => false, - resolvedAuth, - }); - const withoutHstsResponse = createResponse(); - await dispatchRequest( - withoutHsts, - createRequest({ path: "/missing" }), - withoutHstsResponse.res, - ); - expect(withoutHstsResponse.setHeader).toHaveBeenCalledWith( - "X-Content-Type-Options", - "nosniff", - ); - expect(withoutHstsResponse.setHeader).toHaveBeenCalledWith( - "Referrer-Policy", - "no-referrer", - ); - expect(withoutHstsResponse.setHeader).not.toHaveBeenCalledWith( - "Strict-Transport-Security", - expect.any(String), - ); - - const withHsts = createGatewayHttpServer({ - canvasHost: null, - clients: new Set(), - controlUiEnabled: false, - controlUiBasePath: "/__control__", - openAiChatCompletionsEnabled: false, - openResponsesEnabled: false, + const withHsts = createTestGatewayServer({ + resolvedAuth: AUTH_NONE, + overrides: { strictTransportSecurityHeader: "max-age=31536000; includeSubDomains", - handleHooksRequest: async () => false, - resolvedAuth, - }); - const withHstsResponse = createResponse(); - await dispatchRequest(withHsts, createRequest({ path: "/missing" }), withHstsResponse.res); - expect(withHstsResponse.setHeader).toHaveBeenCalledWith( - "Strict-Transport-Security", - "max-age=31536000; includeSubDomains", - ); - }, + }, + }); + const withHstsResponse = await sendRequest(withHsts, { path: "/missing" }); + expect(withHstsResponse.setHeader).toHaveBeenCalledWith( + "Strict-Transport-Security", + "max-age=31536000; includeSubDomains", + ); }); }); test("serves unauthenticated liveness/readiness probe routes when no other route handles them", async () => { - const resolvedAuth: ResolvedGatewayAuth = { - mode: "token", - token: "test-token", - password: undefined, - allowTailscale: false, - }; - - await withTempConfig({ - cfg: { gateway: { trustedProxies: [] } }, + await withGatewayServer({ prefix: "openclaw-plugin-http-probes-test-", - run: async () => { - const server = createGatewayHttpServer({ - canvasHost: null, - clients: new Set(), - controlUiEnabled: false, - controlUiBasePath: "/__control__", - openAiChatCompletionsEnabled: false, - openResponsesEnabled: false, - handleHooksRequest: async () => false, - resolvedAuth, - }); - + resolvedAuth: AUTH_TOKEN, + run: async (server) => { const probeCases = [ { path: "/health", status: "live" }, { path: "/healthz", status: "live" }, @@ -274,8 +323,7 @@ describe("gateway plugin HTTP auth boundary", () => { ] as const; for (const probeCase of probeCases) { - const response = createResponse(); - await dispatchRequest(server, createRequest({ path: probeCase.path }), response.res); + const response = await sendRequest(server, { path: probeCase.path }); expect(response.res.statusCode, probeCase.path).toBe(200); expect(response.getBody(), probeCase.path).toBe( JSON.stringify({ ok: true, status: probeCase.status }), @@ -286,41 +334,23 @@ describe("gateway plugin HTTP auth boundary", () => { }); test("does not shadow plugin routes mounted on probe paths", async () => { - const resolvedAuth: ResolvedGatewayAuth = { - mode: "none", - token: undefined, - password: undefined, - allowTailscale: false, - }; + const handlePluginRequest = vi.fn(async (req: IncomingMessage, res: ServerResponse) => { + const pathname = new URL(req.url ?? "/", "http://localhost").pathname; + if (pathname === "/healthz") { + res.statusCode = 200; + res.setHeader("Content-Type", "application/json; charset=utf-8"); + res.end(JSON.stringify({ ok: true, route: "plugin-health" })); + return true; + } + return false; + }); - await withTempConfig({ - cfg: { gateway: { trustedProxies: [] } }, + await withGatewayServer({ prefix: "openclaw-plugin-http-probes-shadow-test-", - run: async () => { - const handlePluginRequest = vi.fn(async (req: IncomingMessage, res: ServerResponse) => { - const pathname = new URL(req.url ?? "/", "http://localhost").pathname; - if (pathname === "/healthz") { - res.statusCode = 200; - res.setHeader("Content-Type", "application/json; charset=utf-8"); - res.end(JSON.stringify({ ok: true, route: "plugin-health" })); - return true; - } - return false; - }); - const server = createGatewayHttpServer({ - canvasHost: null, - clients: new Set(), - controlUiEnabled: false, - controlUiBasePath: "/__control__", - openAiChatCompletionsEnabled: false, - openResponsesEnabled: false, - handleHooksRequest: async () => false, - handlePluginRequest, - resolvedAuth, - }); - - const response = createResponse(); - await dispatchRequest(server, createRequest({ path: "/healthz" }), response.res); + resolvedAuth: AUTH_NONE, + overrides: { handlePluginRequest }, + run: async (server) => { + const response = await sendRequest(server, { path: "/healthz" }); expect(response.res.statusCode).toBe(200); expect(response.getBody()).toBe(JSON.stringify({ ok: true, route: "plugin-health" })); expect(handlePluginRequest).toHaveBeenCalledTimes(1); @@ -329,44 +359,16 @@ describe("gateway plugin HTTP auth boundary", () => { }); test("rejects non-GET/HEAD methods on probe routes", async () => { - const resolvedAuth: ResolvedGatewayAuth = { - mode: "none", - token: undefined, - password: undefined, - allowTailscale: false, - }; - - await withTempConfig({ - cfg: { gateway: { trustedProxies: [] } }, + await withGatewayServer({ prefix: "openclaw-plugin-http-probes-method-test-", - run: async () => { - const server = createGatewayHttpServer({ - canvasHost: null, - clients: new Set(), - controlUiEnabled: false, - controlUiBasePath: "/__control__", - openAiChatCompletionsEnabled: false, - openResponsesEnabled: false, - handleHooksRequest: async () => false, - resolvedAuth, - }); - - const postResponse = createResponse(); - await dispatchRequest( - server, - createRequest({ path: "/healthz", method: "POST" }), - postResponse.res, - ); + resolvedAuth: AUTH_NONE, + run: async (server) => { + const postResponse = await sendRequest(server, { path: "/healthz", method: "POST" }); expect(postResponse.res.statusCode).toBe(405); expect(postResponse.setHeader).toHaveBeenCalledWith("Allow", "GET, HEAD"); expect(postResponse.getBody()).toBe("Method Not Allowed"); - const headResponse = createResponse(); - await dispatchRequest( - server, - createRequest({ path: "/readyz", method: "HEAD" }), - headResponse.res, - ); + const headResponse = await sendRequest(server, { path: "/readyz", method: "HEAD" }); expect(headResponse.res.statusCode).toBe(200); expect(headResponse.getBody()).toBe(""); }, @@ -374,94 +376,57 @@ describe("gateway plugin HTTP auth boundary", () => { }); test("requires gateway auth for protected plugin route space and allows authenticated pass-through", async () => { - const resolvedAuth: ResolvedGatewayAuth = { - mode: "token", - token: "test-token", - password: undefined, - allowTailscale: false, - }; + const handlePluginRequest = vi.fn(async (req: IncomingMessage, res: ServerResponse) => { + const pathname = new URL(req.url ?? "/", "http://localhost").pathname; + if (pathname === "/api/channels") { + res.statusCode = 200; + res.setHeader("Content-Type", "application/json; charset=utf-8"); + res.end(JSON.stringify({ ok: true, route: "channel-root" })); + return true; + } + if (pathname === "/api/channels/nostr/default/profile") { + res.statusCode = 200; + res.setHeader("Content-Type", "application/json; charset=utf-8"); + res.end(JSON.stringify({ ok: true, route: "channel" })); + return true; + } + if (pathname === "/plugin/public") { + res.statusCode = 200; + res.setHeader("Content-Type", "application/json; charset=utf-8"); + res.end(JSON.stringify({ ok: true, route: "public" })); + return true; + } + return false; + }); - await withTempConfig({ - cfg: { gateway: { trustedProxies: [] } }, + await withGatewayServer({ prefix: "openclaw-plugin-http-auth-test-", - run: async () => { - const handlePluginRequest = vi.fn(async (req: IncomingMessage, res: ServerResponse) => { - const pathname = new URL(req.url ?? "/", "http://localhost").pathname; - if (pathname === "/api/channels") { - res.statusCode = 200; - res.setHeader("Content-Type", "application/json; charset=utf-8"); - res.end(JSON.stringify({ ok: true, route: "channel-root" })); - return true; - } - if (pathname === "/api/channels/nostr/default/profile") { - res.statusCode = 200; - res.setHeader("Content-Type", "application/json; charset=utf-8"); - res.end(JSON.stringify({ ok: true, route: "channel" })); - return true; - } - if (pathname === "/plugin/public") { - res.statusCode = 200; - res.setHeader("Content-Type", "application/json; charset=utf-8"); - res.end(JSON.stringify({ ok: true, route: "public" })); - return true; - } - return false; + resolvedAuth: AUTH_TOKEN, + overrides: { + handlePluginRequest, + shouldEnforcePluginGatewayAuth: (requestPath) => + isProtectedPluginRoutePath(requestPath) || requestPath === "/plugin/public", + }, + run: async (server) => { + const unauthenticated = await sendRequest(server, { + path: "/api/channels/nostr/default/profile", }); - - const server = createGatewayHttpServer({ - canvasHost: null, - clients: new Set(), - controlUiEnabled: false, - controlUiBasePath: "/__control__", - openAiChatCompletionsEnabled: false, - openResponsesEnabled: false, - handleHooksRequest: async () => false, - handlePluginRequest, - shouldEnforcePluginGatewayAuth: (requestPath) => - isProtectedPluginRoutePath(requestPath) || requestPath === "/plugin/public", - resolvedAuth, - }); - - const unauthenticated = createResponse(); - await dispatchRequest( - server, - createRequest({ path: "/api/channels/nostr/default/profile" }), - unauthenticated.res, - ); - expect(unauthenticated.res.statusCode).toBe(401); - expect(unauthenticated.getBody()).toContain("Unauthorized"); + expectUnauthorizedResponse(unauthenticated); expect(handlePluginRequest).not.toHaveBeenCalled(); - const unauthenticatedRoot = createResponse(); - await dispatchRequest( - server, - createRequest({ path: "/api/channels" }), - unauthenticatedRoot.res, - ); - expect(unauthenticatedRoot.res.statusCode).toBe(401); - expect(unauthenticatedRoot.getBody()).toContain("Unauthorized"); + const unauthenticatedRoot = await sendRequest(server, { path: "/api/channels" }); + expectUnauthorizedResponse(unauthenticatedRoot); expect(handlePluginRequest).not.toHaveBeenCalled(); - const authenticated = createResponse(); - await dispatchRequest( - server, - createRequest({ - path: "/api/channels/nostr/default/profile", - authorization: "Bearer test-token", - }), - authenticated.res, - ); + const authenticated = await sendRequest(server, { + path: "/api/channels/nostr/default/profile", + authorization: "Bearer test-token", + }); expect(authenticated.res.statusCode).toBe(200); expect(authenticated.getBody()).toContain('"route":"channel"'); - const unauthenticatedPublic = createResponse(); - await dispatchRequest( - server, - createRequest({ path: "/plugin/public" }), - unauthenticatedPublic.res, - ); - expect(unauthenticatedPublic.res.statusCode).toBe(401); - expect(unauthenticatedPublic.getBody()).toContain("Unauthorized"); + const unauthenticatedPublic = await sendRequest(server, { path: "/plugin/public" }); + expectUnauthorizedResponse(unauthenticatedPublic); expect(handlePluginRequest).toHaveBeenCalledTimes(1); }, @@ -469,75 +434,43 @@ describe("gateway plugin HTTP auth boundary", () => { }); test("keeps wildcard plugin handlers ungated when auth enforcement predicate excludes their paths", async () => { - const resolvedAuth: ResolvedGatewayAuth = { - mode: "token", - token: "test-token", - password: undefined, - allowTailscale: false, - }; + const handlePluginRequest = vi.fn(async (req: IncomingMessage, res: ServerResponse) => { + const pathname = new URL(req.url ?? "/", "http://localhost").pathname; + if (pathname === "/plugin/routed") { + res.statusCode = 200; + res.setHeader("Content-Type", "application/json; charset=utf-8"); + res.end(JSON.stringify({ ok: true, route: "routed" })); + return true; + } + if (pathname === "/googlechat") { + res.statusCode = 200; + res.setHeader("Content-Type", "application/json; charset=utf-8"); + res.end(JSON.stringify({ ok: true, route: "wildcard-handler" })); + return true; + } + return false; + }); - await withTempConfig({ - cfg: { gateway: { trustedProxies: [] } }, + await withGatewayServer({ prefix: "openclaw-plugin-http-auth-wildcard-handler-test-", - run: async () => { - const handlePluginRequest = vi.fn(async (req: IncomingMessage, res: ServerResponse) => { - const pathname = new URL(req.url ?? "/", "http://localhost").pathname; - if (pathname === "/plugin/routed") { - res.statusCode = 200; - res.setHeader("Content-Type", "application/json; charset=utf-8"); - res.end(JSON.stringify({ ok: true, route: "routed" })); - return true; - } - if (pathname === "/googlechat") { - res.statusCode = 200; - res.setHeader("Content-Type", "application/json; charset=utf-8"); - res.end(JSON.stringify({ ok: true, route: "wildcard-handler" })); - return true; - } - return false; - }); + resolvedAuth: AUTH_TOKEN, + overrides: { + handlePluginRequest, + shouldEnforcePluginGatewayAuth: (requestPath) => + requestPath.startsWith("/api/channels") || requestPath === "/plugin/routed", + }, + run: async (server) => { + const unauthenticatedRouted = await sendRequest(server, { path: "/plugin/routed" }); + expectUnauthorizedResponse(unauthenticatedRouted); - const server = createGatewayHttpServer({ - canvasHost: null, - clients: new Set(), - controlUiEnabled: false, - controlUiBasePath: "/__control__", - openAiChatCompletionsEnabled: false, - openResponsesEnabled: false, - handleHooksRequest: async () => false, - handlePluginRequest, - shouldEnforcePluginGatewayAuth: (requestPath) => - requestPath.startsWith("/api/channels") || requestPath === "/plugin/routed", - resolvedAuth, - }); - - const unauthenticatedRouted = createResponse(); - await dispatchRequest( - server, - createRequest({ path: "/plugin/routed" }), - unauthenticatedRouted.res, - ); - expect(unauthenticatedRouted.res.statusCode).toBe(401); - expect(unauthenticatedRouted.getBody()).toContain("Unauthorized"); - - const unauthenticatedWildcard = createResponse(); - await dispatchRequest( - server, - createRequest({ path: "/googlechat" }), - unauthenticatedWildcard.res, - ); + const unauthenticatedWildcard = await sendRequest(server, { path: "/googlechat" }); expect(unauthenticatedWildcard.res.statusCode).toBe(200); expect(unauthenticatedWildcard.getBody()).toContain('"route":"wildcard-handler"'); - const authenticatedRouted = createResponse(); - await dispatchRequest( - server, - createRequest({ - path: "/plugin/routed", - authorization: "Bearer test-token", - }), - authenticatedRouted.res, - ); + const authenticatedRouted = await sendRequest(server, { + path: "/plugin/routed", + authorization: "Bearer test-token", + }); expect(authenticatedRouted.res.statusCode).toBe(200); expect(authenticatedRouted.getBody()).toContain('"route":"routed"'); }, @@ -545,81 +478,48 @@ describe("gateway plugin HTTP auth boundary", () => { }); test("uses /api/channels auth by default while keeping wildcard handlers ungated with no predicate", async () => { - const resolvedAuth: ResolvedGatewayAuth = { - mode: "token", - token: "test-token", - password: undefined, - allowTailscale: false, - }; + const handlePluginRequest = vi.fn(async (req: IncomingMessage, res: ServerResponse) => { + const pathname = new URL(req.url ?? "/", "http://localhost").pathname; + if (pathname === "/api/channels/nostr/default/profile") { + res.statusCode = 200; + res.setHeader("Content-Type", "application/json; charset=utf-8"); + res.end(JSON.stringify({ ok: true, route: "channel-default" })); + return true; + } + if (pathname === "/googlechat") { + res.statusCode = 200; + res.setHeader("Content-Type", "application/json; charset=utf-8"); + res.end(JSON.stringify({ ok: true, route: "wildcard-default" })); + return true; + } + return false; + }); - await withTempConfig({ - cfg: { gateway: { trustedProxies: [] } }, + await withGatewayServer({ prefix: "openclaw-plugin-http-auth-wildcard-default-test-", - run: async () => { - const handlePluginRequest = vi.fn(async (req: IncomingMessage, res: ServerResponse) => { - const pathname = new URL(req.url ?? "/", "http://localhost").pathname; - if (pathname === "/api/channels/nostr/default/profile") { - res.statusCode = 200; - res.setHeader("Content-Type", "application/json; charset=utf-8"); - res.end(JSON.stringify({ ok: true, route: "channel-default" })); - return true; - } - if (pathname === "/googlechat") { - res.statusCode = 200; - res.setHeader("Content-Type", "application/json; charset=utf-8"); - res.end(JSON.stringify({ ok: true, route: "wildcard-default" })); - return true; - } - return false; - }); - - const server = createGatewayHttpServer({ - canvasHost: null, - clients: new Set(), - controlUiEnabled: false, - controlUiBasePath: "/__control__", - openAiChatCompletionsEnabled: false, - openResponsesEnabled: false, - handleHooksRequest: async () => false, - handlePluginRequest, - resolvedAuth, - }); - - const unauthenticated = createResponse(); - await dispatchRequest(server, createRequest({ path: "/googlechat" }), unauthenticated.res); + resolvedAuth: AUTH_TOKEN, + overrides: { handlePluginRequest }, + run: async (server) => { + const unauthenticated = await sendRequest(server, { path: "/googlechat" }); expect(unauthenticated.res.statusCode).toBe(200); expect(unauthenticated.getBody()).toContain('"route":"wildcard-default"'); - const unauthenticatedChannel = createResponse(); - await dispatchRequest( - server, - createRequest({ path: "/api/channels/nostr/default/profile" }), - unauthenticatedChannel.res, - ); - expect(unauthenticatedChannel.res.statusCode).toBe(401); - expect(unauthenticatedChannel.getBody()).toContain("Unauthorized"); + const unauthenticatedChannel = await sendRequest(server, { + path: "/api/channels/nostr/default/profile", + }); + expectUnauthorizedResponse(unauthenticatedChannel); - const authenticated = createResponse(); - await dispatchRequest( - server, - createRequest({ - path: "/googlechat", - authorization: "Bearer test-token", - }), - authenticated.res, - ); + const authenticated = await sendRequest(server, { + path: "/googlechat", + authorization: "Bearer test-token", + }); expect(authenticated.res.statusCode).toBe(200); expect(authenticated.getBody()).toContain('"route":"wildcard-default"'); - const authenticatedChannel = createResponse(); - await dispatchRequest( - server, - createRequest({ - path: "/api/channels/nostr/default/profile", - authorization: "Bearer test-token", - }), - authenticatedChannel.res, - ); + const authenticatedChannel = await sendRequest(server, { + path: "/api/channels/nostr/default/profile", + authorization: "Bearer test-token", + }); expect(authenticatedChannel.res.statusCode).toBe(200); expect(authenticatedChannel.getBody()).toContain('"route":"channel-default"'); }, @@ -627,48 +527,31 @@ describe("gateway plugin HTTP auth boundary", () => { }); test("serves plugin routes before control ui spa fallback", async () => { - const resolvedAuth: ResolvedGatewayAuth = { - mode: "none", - token: undefined, - password: undefined, - allowTailscale: false, - }; + const handlePluginRequest = vi.fn(async (req: IncomingMessage, res: ServerResponse) => { + const pathname = new URL(req.url ?? "/", "http://localhost").pathname; + if (pathname === "/plugins/diffs/view/demo-id/demo-token") { + res.statusCode = 200; + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.end("diff-view"); + return true; + } + return false; + }); - await withTempConfig({ - cfg: { gateway: { trustedProxies: [] } }, + await withGatewayServer({ prefix: "openclaw-plugin-http-control-ui-precedence-test-", - run: async () => { - const handlePluginRequest = vi.fn(async (req: IncomingMessage, res: ServerResponse) => { - const pathname = new URL(req.url ?? "/", "http://localhost").pathname; - if (pathname === "/plugins/diffs/view/demo-id/demo-token") { - res.statusCode = 200; - res.setHeader("Content-Type", "text/html; charset=utf-8"); - res.end("diff-view"); - return true; - } - return false; + resolvedAuth: AUTH_NONE, + overrides: { + controlUiEnabled: true, + controlUiBasePath: "", + controlUiRoot: { kind: "missing" }, + handlePluginRequest, + }, + run: async (server) => { + const response = await sendRequest(server, { + path: "/plugins/diffs/view/demo-id/demo-token", }); - const server = createGatewayHttpServer({ - canvasHost: null, - clients: new Set(), - controlUiEnabled: true, - controlUiBasePath: "", - controlUiRoot: { kind: "missing" }, - openAiChatCompletionsEnabled: false, - openResponsesEnabled: false, - handleHooksRequest: async () => false, - handlePluginRequest, - resolvedAuth, - }); - - const response = createResponse(); - await dispatchRequest( - server, - createRequest({ path: "/plugins/diffs/view/demo-id/demo-token" }), - response.res, - ); - expect(response.res.statusCode).toBe(200); expect(response.getBody()).toContain("diff-view"); expect(handlePluginRequest).toHaveBeenCalledTimes(1); @@ -677,43 +560,28 @@ describe("gateway plugin HTTP auth boundary", () => { }); test("does not let plugin handlers shadow control ui routes", async () => { - const resolvedAuth: ResolvedGatewayAuth = { - mode: "none", - token: undefined, - password: undefined, - allowTailscale: false, - }; + const handlePluginRequest = vi.fn(async (req: IncomingMessage, res: ServerResponse) => { + const pathname = new URL(req.url ?? "/", "http://localhost").pathname; + if (pathname === "/chat") { + res.statusCode = 200; + res.setHeader("Content-Type", "text/plain; charset=utf-8"); + res.end("plugin-shadow"); + return true; + } + return false; + }); - await withTempConfig({ - cfg: { gateway: { trustedProxies: [] } }, + await withGatewayServer({ prefix: "openclaw-plugin-http-control-ui-shadow-test-", - run: async () => { - const handlePluginRequest = vi.fn(async (req: IncomingMessage, res: ServerResponse) => { - const pathname = new URL(req.url ?? "/", "http://localhost").pathname; - if (pathname === "/chat") { - res.statusCode = 200; - res.setHeader("Content-Type", "text/plain; charset=utf-8"); - res.end("plugin-shadow"); - return true; - } - return false; - }); - - const server = createGatewayHttpServer({ - canvasHost: null, - clients: new Set(), - controlUiEnabled: true, - controlUiBasePath: "", - controlUiRoot: { kind: "missing" }, - openAiChatCompletionsEnabled: false, - openResponsesEnabled: false, - handleHooksRequest: async () => false, - handlePluginRequest, - resolvedAuth, - }); - - const response = createResponse(); - await dispatchRequest(server, createRequest({ path: "/chat" }), response.res); + resolvedAuth: AUTH_NONE, + overrides: { + controlUiEnabled: true, + controlUiBasePath: "", + controlUiRoot: { kind: "missing" }, + handlePluginRequest, + }, + run: async (server) => { + const response = await sendRequest(server, { path: "/chat" }); expect(response.res.statusCode).toBe(503); expect(response.getBody()).toContain("Control UI assets not found"); @@ -723,42 +591,16 @@ describe("gateway plugin HTTP auth boundary", () => { }); test("requires gateway auth for canonicalized /api/channels variants", async () => { - const resolvedAuth: ResolvedGatewayAuth = { - mode: "token", - token: "test-token", - password: undefined, - allowTailscale: false, - }; + const handlePluginRequest = createCanonicalizedChannelPluginHandler(); - await withTempConfig({ - cfg: { gateway: { trustedProxies: [] } }, + await withGatewayServer({ prefix: "openclaw-plugin-http-auth-canonicalized-test-", - run: async () => { - const handlePluginRequest = vi.fn(async (req: IncomingMessage, res: ServerResponse) => { - const pathname = new URL(req.url ?? "/", "http://localhost").pathname; - const canonicalPath = canonicalizePluginPath(pathname); - if (canonicalPath === "/api/channels/nostr/default/profile") { - res.statusCode = 200; - res.setHeader("Content-Type", "application/json; charset=utf-8"); - res.end(JSON.stringify({ ok: true, route: "channel-canonicalized" })); - return true; - } - return false; - }); - - const server = createGatewayHttpServer({ - canvasHost: null, - clients: new Set(), - controlUiEnabled: false, - controlUiBasePath: "/__control__", - openAiChatCompletionsEnabled: false, - openResponsesEnabled: false, - handleHooksRequest: async () => false, - handlePluginRequest, - shouldEnforcePluginGatewayAuth: isProtectedPluginRoutePath, - resolvedAuth, - }); - + resolvedAuth: AUTH_TOKEN, + overrides: { + handlePluginRequest, + shouldEnforcePluginGatewayAuth: isProtectedPluginRoutePath, + }, + run: async (server) => { await expectUnauthorizedVariants({ server, variants: CANONICAL_UNAUTH_VARIANTS }); expect(handlePluginRequest).not.toHaveBeenCalled(); @@ -773,45 +615,18 @@ describe("gateway plugin HTTP auth boundary", () => { }); test("rejects unauthenticated plugin-channel fuzz corpus variants", async () => { - const resolvedAuth: ResolvedGatewayAuth = { - mode: "token", - token: "test-token", - password: undefined, - allowTailscale: false, - }; + const handlePluginRequest = createCanonicalizedChannelPluginHandler(); - await withTempConfig({ - cfg: { gateway: { trustedProxies: [] } }, + await withGatewayServer({ prefix: "openclaw-plugin-http-auth-fuzz-corpus-test-", - run: async () => { - const handlePluginRequest = vi.fn(async (req: IncomingMessage, res: ServerResponse) => { - const pathname = new URL(req.url ?? "/", "http://localhost").pathname; - const canonicalPath = canonicalizePluginPath(pathname); - if (canonicalPath === "/api/channels/nostr/default/profile") { - res.statusCode = 200; - res.setHeader("Content-Type", "application/json; charset=utf-8"); - res.end(JSON.stringify({ ok: true, route: "channel-canonicalized" })); - return true; - } - return false; - }); - - const server = createGatewayHttpServer({ - canvasHost: null, - clients: new Set(), - controlUiEnabled: false, - controlUiBasePath: "/__control__", - openAiChatCompletionsEnabled: false, - openResponsesEnabled: false, - handleHooksRequest: async () => false, - handlePluginRequest, - shouldEnforcePluginGatewayAuth: isProtectedPluginRoutePath, - resolvedAuth, - }); - + resolvedAuth: AUTH_TOKEN, + overrides: { + handlePluginRequest, + shouldEnforcePluginGatewayAuth: isProtectedPluginRoutePath, + }, + run: async (server) => { for (const variant of buildChannelPathFuzzCorpus()) { - const response = createResponse(); - await dispatchRequest(server, createRequest({ path: variant.path }), response.res); + const response = await sendRequest(server, { path: variant.path }); expect(response.res.statusCode, variant.label).not.toBe(200); expect(response.getBody(), variant.label).not.toContain( '"route":"channel-canonicalized"', @@ -824,97 +639,33 @@ describe("gateway plugin HTTP auth boundary", () => { test.each(["0.0.0.0", "::"])( "returns 404 (not 500) for non-hook routes with hooks enabled and bindHost=%s", async (bindHost) => { - const resolvedAuth: ResolvedGatewayAuth = { - mode: "none", - token: undefined, - password: undefined, - allowTailscale: false, - }; + await withGatewayTempConfig("openclaw-plugin-http-hooks-bindhost-", async () => { + const handleHooksRequest = createHooksHandler(bindHost); + const server = createTestGatewayServer({ + resolvedAuth: AUTH_NONE, + overrides: { handleHooksRequest }, + }); - await withTempConfig({ - cfg: { gateway: { trustedProxies: [] } }, - prefix: "openclaw-plugin-http-hooks-bindhost-", - run: async () => { - const handleHooksRequest = createHooksRequestHandler({ - getHooksConfig: () => createHooksConfig(), - bindHost, - port: 18789, - logHooks: { - warn: vi.fn(), - debug: vi.fn(), - info: vi.fn(), - error: vi.fn(), - } as unknown as ReturnType, - dispatchWakeHook: () => {}, - dispatchAgentHook: () => "run-1", - }); - const server = createGatewayHttpServer({ - canvasHost: null, - clients: new Set(), - controlUiEnabled: false, - controlUiBasePath: "/__control__", - openAiChatCompletionsEnabled: false, - openResponsesEnabled: false, - handleHooksRequest, - resolvedAuth, - }); + const response = await sendRequest(server, { path: "/" }); - const response = createResponse(); - await dispatchRequest(server, createRequest({ path: "/" }), response.res); - - expect(response.res.statusCode).toBe(404); - expect(response.getBody()).toBe("Not Found"); - }, + expect(response.res.statusCode).toBe(404); + expect(response.getBody()).toBe("Not Found"); }); }, ); test("rejects query-token hooks requests with bindHost=::", async () => { - const resolvedAuth: ResolvedGatewayAuth = { - mode: "none", - token: undefined, - password: undefined, - allowTailscale: false, - }; + await withGatewayTempConfig("openclaw-plugin-http-hooks-query-token-", async () => { + const handleHooksRequest = createHooksHandler("::"); + const server = createTestGatewayServer({ + resolvedAuth: AUTH_NONE, + overrides: { handleHooksRequest }, + }); - await withTempConfig({ - cfg: { gateway: { trustedProxies: [] } }, - prefix: "openclaw-plugin-http-hooks-query-token-", - run: async () => { - const handleHooksRequest = createHooksRequestHandler({ - getHooksConfig: () => createHooksConfig(), - bindHost: "::", - port: 18789, - logHooks: { - warn: vi.fn(), - debug: vi.fn(), - info: vi.fn(), - error: vi.fn(), - } as unknown as ReturnType, - dispatchWakeHook: () => {}, - dispatchAgentHook: () => "run-1", - }); - const server = createGatewayHttpServer({ - canvasHost: null, - clients: new Set(), - controlUiEnabled: false, - controlUiBasePath: "/__control__", - openAiChatCompletionsEnabled: false, - openResponsesEnabled: false, - handleHooksRequest, - resolvedAuth, - }); + const response = await sendRequest(server, { path: "/hooks/wake?token=bad" }); - const response = createResponse(); - await dispatchRequest( - server, - createRequest({ path: "/hooks/wake?token=bad" }), - response.res, - ); - - expect(response.res.statusCode).toBe(400); - expect(response.getBody()).toContain("Hook token must be provided"); - }, + expect(response.res.statusCode).toBe(400); + expect(response.getBody()).toContain("Hook token must be provided"); }); }); }); diff --git a/src/gateway/server/plugins-http.test.ts b/src/gateway/server/plugins-http.test.ts index 0420d48e379..535067dcaaa 100644 --- a/src/gateway/server/plugins-http.test.ts +++ b/src/gateway/server/plugins-http.test.ts @@ -8,11 +8,28 @@ import { shouldEnforceGatewayAuthForPluginPath, } from "./plugins-http.js"; +type PluginHandlerLog = Parameters[0]["log"]; + +function createPluginLog(): PluginHandlerLog { + return { warn: vi.fn() } as unknown as PluginHandlerLog; +} + +function createRoute(params: { + path: string; + pluginId?: string; + handler?: (req: IncomingMessage, res: ServerResponse) => void | Promise; +}) { + return { + pluginId: params.pluginId ?? "route", + path: params.path, + handler: params.handler ?? (() => {}), + source: params.pluginId ?? "route", + }; +} + describe("createGatewayPluginRequestHandler", () => { it("returns false when no handlers are registered", async () => { - const log = { warn: vi.fn() } as unknown as Parameters< - typeof createGatewayPluginRequestHandler - >[0]["log"]; + const log = createPluginLog(); const handler = createGatewayPluginRequestHandler({ registry: createTestRegistry(), log, @@ -32,9 +49,7 @@ describe("createGatewayPluginRequestHandler", () => { { pluginId: "second", handler: second, source: "second" }, ], }), - log: { warn: vi.fn() } as unknown as Parameters< - typeof createGatewayPluginRequestHandler - >[0]["log"], + log: createPluginLog(), }); const { res } = makeMockHttpResponse(); @@ -51,19 +66,10 @@ describe("createGatewayPluginRequestHandler", () => { const fallback = vi.fn(async () => true); const handler = createGatewayPluginRequestHandler({ registry: createTestRegistry({ - httpRoutes: [ - { - pluginId: "route", - path: "/demo", - handler: routeHandler, - source: "route", - }, - ], + httpRoutes: [createRoute({ path: "/demo", handler: routeHandler })], httpHandlers: [{ pluginId: "fallback", handler: fallback, source: "fallback" }], }), - log: { warn: vi.fn() } as unknown as Parameters< - typeof createGatewayPluginRequestHandler - >[0]["log"], + log: createPluginLog(), }); const { res } = makeMockHttpResponse(); @@ -80,19 +86,10 @@ describe("createGatewayPluginRequestHandler", () => { const fallback = vi.fn(async () => true); const handler = createGatewayPluginRequestHandler({ registry: createTestRegistry({ - httpRoutes: [ - { - pluginId: "route", - path: "/api/demo", - handler: routeHandler, - source: "route", - }, - ], + httpRoutes: [createRoute({ path: "/api/demo", handler: routeHandler })], httpHandlers: [{ pluginId: "fallback", handler: fallback, source: "fallback" }], }), - log: { warn: vi.fn() } as unknown as Parameters< - typeof createGatewayPluginRequestHandler - >[0]["log"], + log: createPluginLog(), }); const { res } = makeMockHttpResponse(); @@ -103,9 +100,7 @@ describe("createGatewayPluginRequestHandler", () => { }); it("logs and responds with 500 when a handler throws", async () => { - const log = { warn: vi.fn() } as unknown as Parameters< - typeof createGatewayPluginRequestHandler - >[0]["log"]; + const log = createPluginLog(); const handler = createGatewayPluginRequestHandler({ registry: createTestRegistry({ httpHandlers: [ @@ -134,14 +129,7 @@ describe("createGatewayPluginRequestHandler", () => { describe("plugin HTTP registry helpers", () => { it("detects registered route paths", () => { const registry = createTestRegistry({ - httpRoutes: [ - { - pluginId: "route", - path: "/demo", - handler: () => {}, - source: "route", - }, - ], + httpRoutes: [createRoute({ path: "/demo" })], }); expect(isRegisteredPluginHttpRoutePath(registry, "/demo")).toBe(true); expect(isRegisteredPluginHttpRoutePath(registry, "/missing")).toBe(false); @@ -149,14 +137,7 @@ describe("plugin HTTP registry helpers", () => { it("matches canonicalized variants of registered route paths", () => { const registry = createTestRegistry({ - httpRoutes: [ - { - pluginId: "route", - path: "/api/demo", - handler: () => {}, - source: "route", - }, - ], + httpRoutes: [createRoute({ path: "/api/demo" })], }); expect(isRegisteredPluginHttpRoutePath(registry, "/api//demo")).toBe(true); expect(isRegisteredPluginHttpRoutePath(registry, "/API/demo")).toBe(true); @@ -165,14 +146,7 @@ describe("plugin HTTP registry helpers", () => { it("enforces auth for protected and registered plugin routes", () => { const registry = createTestRegistry({ - httpRoutes: [ - { - pluginId: "route", - path: "/api/demo", - handler: () => {}, - source: "route", - }, - ], + httpRoutes: [createRoute({ path: "/api/demo" })], }); expect(shouldEnforceGatewayAuthForPluginPath(registry, "/api//demo")).toBe(true); expect(shouldEnforceGatewayAuthForPluginPath(registry, "/api/channels/status")).toBe(true); diff --git a/src/gateway/session-utils.test.ts b/src/gateway/session-utils.test.ts index b86e3be142e..e765210e207 100644 --- a/src/gateway/session-utils.test.ts +++ b/src/gateway/session-utils.test.ts @@ -40,6 +40,39 @@ function createSingleAgentAvatarConfig(workspace: string): OpenClawConfig { } as OpenClawConfig; } +function createModelDefaultsConfig(params: { + primary: string; + models?: Record>; +}): OpenClawConfig { + return { + agents: { + defaults: { + model: { primary: params.primary }, + models: params.models, + }, + }, + } as OpenClawConfig; +} + +function createLegacyRuntimeListConfig( + models?: Record>, +): OpenClawConfig { + return createModelDefaultsConfig({ + primary: "google-gemini-cli/gemini-3-pro-preview", + ...(models ? { models } : {}), + }); +} + +function createLegacyRuntimeStore(model: string): Record { + return { + "agent:main:main": { + sessionId: "sess-main", + updatedAt: Date.now(), + model, + } as SessionEntry, + }; +} + describe("gateway session utils", () => { test("capArrayByJsonBytes trims from the front", () => { const res = capArrayByJsonBytes(["a", "b", "c"], 10); @@ -281,13 +314,9 @@ describe("gateway session utils", () => { describe("resolveSessionModelRef", () => { test("prefers runtime model/provider from session entry", () => { - const cfg = { - agents: { - defaults: { - model: { primary: "anthropic/claude-opus-4-6" }, - }, - }, - } as OpenClawConfig; + const cfg = createModelDefaultsConfig({ + primary: "anthropic/claude-opus-4-6", + }); const resolved = resolveSessionModelRef(cfg, { sessionId: "s1", @@ -302,13 +331,9 @@ describe("resolveSessionModelRef", () => { }); test("preserves openrouter provider when model contains vendor prefix", () => { - const cfg = { - agents: { - defaults: { - model: { primary: "openrouter/minimax/minimax-m2.5" }, - }, - }, - } as OpenClawConfig; + const cfg = createModelDefaultsConfig({ + primary: "openrouter/minimax/minimax-m2.5", + }); const resolved = resolveSessionModelRef(cfg, { sessionId: "s-or", @@ -324,13 +349,9 @@ describe("resolveSessionModelRef", () => { }); test("falls back to override when runtime model is not recorded yet", () => { - const cfg = { - agents: { - defaults: { - model: { primary: "anthropic/claude-opus-4-6" }, - }, - }, - } as OpenClawConfig; + const cfg = createModelDefaultsConfig({ + primary: "anthropic/claude-opus-4-6", + }); const resolved = resolveSessionModelRef(cfg, { sessionId: "s2", @@ -342,13 +363,9 @@ describe("resolveSessionModelRef", () => { }); test("falls back to resolved provider for unprefixed legacy runtime model", () => { - const cfg = { - agents: { - defaults: { - model: { primary: "google-gemini-cli/gemini-3-pro-preview" }, - }, - }, - } as OpenClawConfig; + const cfg = createModelDefaultsConfig({ + primary: "google-gemini-cli/gemini-3-pro-preview", + }); const resolved = resolveSessionModelRef(cfg, { sessionId: "legacy-session", @@ -366,13 +383,9 @@ describe("resolveSessionModelRef", () => { test("preserves provider from slash-prefixed model when modelProvider is missing", () => { // When model string contains a provider prefix (e.g. "anthropic/claude-sonnet-4-6") // parseModelRef should extract it correctly even without modelProvider set. - const cfg = { - agents: { - defaults: { - model: { primary: "google-gemini-cli/gemini-3-pro-preview" }, - }, - }, - } as OpenClawConfig; + const cfg = createModelDefaultsConfig({ + primary: "google-gemini-cli/gemini-3-pro-preview", + }); const resolved = resolveSessionModelRef(cfg, { sessionId: "slash-model", @@ -387,13 +400,9 @@ describe("resolveSessionModelRef", () => { describe("resolveSessionModelIdentityRef", () => { test("does not inherit default provider for unprefixed legacy runtime model", () => { - const cfg = { - agents: { - defaults: { - model: { primary: "google-gemini-cli/gemini-3-pro-preview" }, - }, - }, - } as OpenClawConfig; + const cfg = createModelDefaultsConfig({ + primary: "google-gemini-cli/gemini-3-pro-preview", + }); const resolved = resolveSessionModelIdentityRef(cfg, { sessionId: "legacy-session", @@ -406,16 +415,12 @@ describe("resolveSessionModelIdentityRef", () => { }); test("infers provider from configured model allowlist when unambiguous", () => { - const cfg = { - agents: { - defaults: { - model: { primary: "google-gemini-cli/gemini-3-pro-preview" }, - models: { - "anthropic/claude-sonnet-4-6": {}, - }, - }, + const cfg = createModelDefaultsConfig({ + primary: "google-gemini-cli/gemini-3-pro-preview", + models: { + "anthropic/claude-sonnet-4-6": {}, }, - } as OpenClawConfig; + }); const resolved = resolveSessionModelIdentityRef(cfg, { sessionId: "legacy-session", @@ -428,17 +433,13 @@ describe("resolveSessionModelIdentityRef", () => { }); test("keeps provider unknown when configured models are ambiguous", () => { - const cfg = { - agents: { - defaults: { - model: { primary: "google-gemini-cli/gemini-3-pro-preview" }, - models: { - "anthropic/claude-sonnet-4-6": {}, - "minimax/claude-sonnet-4-6": {}, - }, - }, + const cfg = createModelDefaultsConfig({ + primary: "google-gemini-cli/gemini-3-pro-preview", + models: { + "anthropic/claude-sonnet-4-6": {}, + "minimax/claude-sonnet-4-6": {}, }, - } as OpenClawConfig; + }); const resolved = resolveSessionModelIdentityRef(cfg, { sessionId: "legacy-session", @@ -451,13 +452,9 @@ describe("resolveSessionModelIdentityRef", () => { }); test("preserves provider from slash-prefixed runtime model", () => { - const cfg = { - agents: { - defaults: { - model: { primary: "google-gemini-cli/gemini-3-pro-preview" }, - }, - }, - } as OpenClawConfig; + const cfg = createModelDefaultsConfig({ + primary: "google-gemini-cli/gemini-3-pro-preview", + }); const resolved = resolveSessionModelIdentityRef(cfg, { sessionId: "slash-model", @@ -470,16 +467,12 @@ describe("resolveSessionModelIdentityRef", () => { }); test("infers wrapper provider for slash-prefixed runtime model when allowlist match is unique", () => { - const cfg = { - agents: { - defaults: { - model: { primary: "google-gemini-cli/gemini-3-pro-preview" }, - models: { - "vercel-ai-gateway/anthropic/claude-sonnet-4-6": {}, - }, - }, + const cfg = createModelDefaultsConfig({ + primary: "google-gemini-cli/gemini-3-pro-preview", + models: { + "vercel-ai-gateway/anthropic/claude-sonnet-4-6": {}, }, - } as OpenClawConfig; + }); const resolved = resolveSessionModelIdentityRef(cfg, { sessionId: "slash-model", @@ -683,97 +676,37 @@ describe("listSessionsFromStore search", () => { expect(result.sessions.map((session) => session.key)).toEqual(["agent:main:cron:job-1"]); }); - test("does not guess provider for legacy runtime model without modelProvider", () => { - const cfg = { - session: { mainKey: "main" }, - agents: { - defaults: { - model: { primary: "google-gemini-cli/gemini-3-pro-preview" }, - }, - }, - } as OpenClawConfig; - const now = Date.now(); - const store: Record = { - "agent:main:main": { - sessionId: "sess-main", - updatedAt: now, - model: "claude-sonnet-4-6", - } as SessionEntry, - }; - + test.each([ + { + name: "does not guess provider for legacy runtime model without modelProvider", + cfg: createLegacyRuntimeListConfig(), + runtimeModel: "claude-sonnet-4-6", + expectedProvider: undefined, + }, + { + name: "infers provider for legacy runtime model when allowlist match is unique", + cfg: createLegacyRuntimeListConfig({ "anthropic/claude-sonnet-4-6": {} }), + runtimeModel: "claude-sonnet-4-6", + expectedProvider: "anthropic", + }, + { + name: "infers wrapper provider for slash-prefixed legacy runtime model when allowlist match is unique", + cfg: createLegacyRuntimeListConfig({ + "vercel-ai-gateway/anthropic/claude-sonnet-4-6": {}, + }), + runtimeModel: "anthropic/claude-sonnet-4-6", + expectedProvider: "vercel-ai-gateway", + }, + ])("$name", ({ cfg, runtimeModel, expectedProvider }) => { const result = listSessionsFromStore({ cfg, storePath: "/tmp/sessions.json", - store, + store: createLegacyRuntimeStore(runtimeModel), opts: {}, }); - expect(result.sessions[0]?.modelProvider).toBeUndefined(); - expect(result.sessions[0]?.model).toBe("claude-sonnet-4-6"); - }); - - test("infers provider for legacy runtime model when allowlist match is unique", () => { - const cfg = { - session: { mainKey: "main" }, - agents: { - defaults: { - model: { primary: "google-gemini-cli/gemini-3-pro-preview" }, - models: { - "anthropic/claude-sonnet-4-6": {}, - }, - }, - }, - } as OpenClawConfig; - const now = Date.now(); - const store: Record = { - "agent:main:main": { - sessionId: "sess-main", - updatedAt: now, - model: "claude-sonnet-4-6", - } as SessionEntry, - }; - - const result = listSessionsFromStore({ - cfg, - storePath: "/tmp/sessions.json", - store, - opts: {}, - }); - - expect(result.sessions[0]?.modelProvider).toBe("anthropic"); - expect(result.sessions[0]?.model).toBe("claude-sonnet-4-6"); - }); - - test("infers wrapper provider for slash-prefixed legacy runtime model when allowlist match is unique", () => { - const cfg = { - session: { mainKey: "main" }, - agents: { - defaults: { - model: { primary: "google-gemini-cli/gemini-3-pro-preview" }, - models: { - "vercel-ai-gateway/anthropic/claude-sonnet-4-6": {}, - }, - }, - }, - } as OpenClawConfig; - const now = Date.now(); - const store: Record = { - "agent:main:main": { - sessionId: "sess-main", - updatedAt: now, - model: "anthropic/claude-sonnet-4-6", - } as SessionEntry, - }; - - const result = listSessionsFromStore({ - cfg, - storePath: "/tmp/sessions.json", - store, - opts: {}, - }); - - expect(result.sessions[0]?.modelProvider).toBe("vercel-ai-gateway"); - expect(result.sessions[0]?.model).toBe("anthropic/claude-sonnet-4-6"); + expect(result.sessions[0]?.modelProvider).toBe(expectedProvider); + expect(result.sessions[0]?.model).toBe(runtimeModel); }); test("exposes unknown totals when freshness is stale or missing", () => { diff --git a/src/gateway/sessions-patch.test.ts b/src/gateway/sessions-patch.test.ts index 6bf20d32641..78d8a71aecb 100644 --- a/src/gateway/sessions-patch.test.ts +++ b/src/gateway/sessions-patch.test.ts @@ -5,26 +5,63 @@ import { applySessionsPatchToStore } from "./sessions-patch.js"; const SUBAGENT_MODEL = "synthetic/hf:moonshotai/Kimi-K2.5"; const KIMI_SUBAGENT_KEY = "agent:kimi:subagent:child"; +const MAIN_SESSION_KEY = "agent:main:main"; +const EMPTY_CFG = {} as OpenClawConfig; + +type ApplySessionsPatchArgs = Parameters[0]; + +async function runPatch(params: { + patch: ApplySessionsPatchArgs["patch"]; + store?: Record; + cfg?: OpenClawConfig; + storeKey?: string; + loadGatewayModelCatalog?: ApplySessionsPatchArgs["loadGatewayModelCatalog"]; +}) { + return applySessionsPatchToStore({ + cfg: params.cfg ?? EMPTY_CFG, + store: params.store ?? {}, + storeKey: params.storeKey ?? MAIN_SESSION_KEY, + patch: params.patch, + loadGatewayModelCatalog: params.loadGatewayModelCatalog, + }); +} + +function expectPatchOk( + result: Awaited>, +): SessionEntry { + expect(result.ok).toBe(true); + if (!result.ok) { + throw new Error(result.error.message); + } + return result.entry; +} + +function expectPatchError( + result: Awaited>, + message: string, +): void { + expect(result.ok).toBe(false); + if (result.ok) { + throw new Error(`Expected patch failure containing: ${message}`); + } + expect(result.error.message).toContain(message); +} async function applySubagentModelPatch(cfg: OpenClawConfig) { - const res = await applySessionsPatchToStore({ - cfg, - store: {}, - storeKey: KIMI_SUBAGENT_KEY, - patch: { - key: KIMI_SUBAGENT_KEY, - model: SUBAGENT_MODEL, - }, - loadGatewayModelCatalog: async () => [ - { provider: "anthropic", id: "claude-sonnet-4-6", name: "sonnet" }, - { provider: "synthetic", id: "hf:moonshotai/Kimi-K2.5", name: "kimi" }, - ], - }); - expect(res.ok).toBe(true); - if (!res.ok) { - throw new Error(res.error.message); - } - return res.entry; + return expectPatchOk( + await runPatch({ + cfg, + storeKey: KIMI_SUBAGENT_KEY, + patch: { + key: KIMI_SUBAGENT_KEY, + model: SUBAGENT_MODEL, + }, + loadGatewayModelCatalog: async () => [ + { provider: "anthropic", id: "claude-sonnet-4-6", name: "sonnet" }, + { provider: "synthetic", id: "hf:moonshotai/Kimi-K2.5", name: "kimi" }, + ], + }), + ); } function makeKimiSubagentCfg(params: { @@ -54,131 +91,100 @@ function makeKimiSubagentCfg(params: { } as OpenClawConfig; } +function createAllowlistedAnthropicModelCfg(): OpenClawConfig { + return { + agents: { + defaults: { + model: { primary: "openai/gpt-5.2" }, + models: { + "anthropic/claude-sonnet-4-6": { alias: "sonnet" }, + }, + }, + }, + } as OpenClawConfig; +} + describe("gateway sessions patch", () => { test("persists thinkingLevel=off (does not clear)", async () => { - const store: Record = {}; - const res = await applySessionsPatchToStore({ - cfg: {} as OpenClawConfig, - store, - storeKey: "agent:main:main", - patch: { key: "agent:main:main", thinkingLevel: "off" }, - }); - expect(res.ok).toBe(true); - if (!res.ok) { - return; - } - expect(res.entry.thinkingLevel).toBe("off"); + const entry = expectPatchOk( + await runPatch({ + patch: { key: MAIN_SESSION_KEY, thinkingLevel: "off" }, + }), + ); + expect(entry.thinkingLevel).toBe("off"); }); test("clears thinkingLevel when patch sets null", async () => { const store: Record = { - "agent:main:main": { thinkingLevel: "low" } as SessionEntry, + [MAIN_SESSION_KEY]: { thinkingLevel: "low" } as SessionEntry, }; - const res = await applySessionsPatchToStore({ - cfg: {} as OpenClawConfig, - store, - storeKey: "agent:main:main", - patch: { key: "agent:main:main", thinkingLevel: null }, - }); - expect(res.ok).toBe(true); - if (!res.ok) { - return; - } - expect(res.entry.thinkingLevel).toBeUndefined(); + const entry = expectPatchOk( + await runPatch({ + store, + patch: { key: MAIN_SESSION_KEY, thinkingLevel: null }, + }), + ); + expect(entry.thinkingLevel).toBeUndefined(); }); test("persists reasoningLevel=off (does not clear)", async () => { - const store: Record = {}; - const res = await applySessionsPatchToStore({ - cfg: {} as OpenClawConfig, - store, - storeKey: "agent:main:main", - patch: { key: "agent:main:main", reasoningLevel: "off" }, - }); - expect(res.ok).toBe(true); - if (!res.ok) { - return; - } - expect(res.entry.reasoningLevel).toBe("off"); + const entry = expectPatchOk( + await runPatch({ + patch: { key: MAIN_SESSION_KEY, reasoningLevel: "off" }, + }), + ); + expect(entry.reasoningLevel).toBe("off"); }); test("clears reasoningLevel when patch sets null", async () => { const store: Record = { - "agent:main:main": { reasoningLevel: "stream" } as SessionEntry, + [MAIN_SESSION_KEY]: { reasoningLevel: "stream" } as SessionEntry, }; - const res = await applySessionsPatchToStore({ - cfg: {} as OpenClawConfig, - store, - storeKey: "agent:main:main", - patch: { key: "agent:main:main", reasoningLevel: null }, - }); - expect(res.ok).toBe(true); - if (!res.ok) { - return; - } - expect(res.entry.reasoningLevel).toBeUndefined(); + const entry = expectPatchOk( + await runPatch({ + store, + patch: { key: MAIN_SESSION_KEY, reasoningLevel: null }, + }), + ); + expect(entry.reasoningLevel).toBeUndefined(); }); test("persists elevatedLevel=off (does not clear)", async () => { - const store: Record = {}; - const res = await applySessionsPatchToStore({ - cfg: {} as OpenClawConfig, - store, - storeKey: "agent:main:main", - patch: { key: "agent:main:main", elevatedLevel: "off" }, - }); - expect(res.ok).toBe(true); - if (!res.ok) { - return; - } - expect(res.entry.elevatedLevel).toBe("off"); + const entry = expectPatchOk( + await runPatch({ + patch: { key: MAIN_SESSION_KEY, elevatedLevel: "off" }, + }), + ); + expect(entry.elevatedLevel).toBe("off"); }); test("persists elevatedLevel=on", async () => { - const store: Record = {}; - const res = await applySessionsPatchToStore({ - cfg: {} as OpenClawConfig, - store, - storeKey: "agent:main:main", - patch: { key: "agent:main:main", elevatedLevel: "on" }, - }); - expect(res.ok).toBe(true); - if (!res.ok) { - return; - } - expect(res.entry.elevatedLevel).toBe("on"); + const entry = expectPatchOk( + await runPatch({ + patch: { key: MAIN_SESSION_KEY, elevatedLevel: "on" }, + }), + ); + expect(entry.elevatedLevel).toBe("on"); }); test("clears elevatedLevel when patch sets null", async () => { const store: Record = { - "agent:main:main": { elevatedLevel: "off" } as SessionEntry, + [MAIN_SESSION_KEY]: { elevatedLevel: "off" } as SessionEntry, }; - const res = await applySessionsPatchToStore({ - cfg: {} as OpenClawConfig, - store, - storeKey: "agent:main:main", - patch: { key: "agent:main:main", elevatedLevel: null }, - }); - expect(res.ok).toBe(true); - if (!res.ok) { - return; - } - expect(res.entry.elevatedLevel).toBeUndefined(); + const entry = expectPatchOk( + await runPatch({ + store, + patch: { key: MAIN_SESSION_KEY, elevatedLevel: null }, + }), + ); + expect(entry.elevatedLevel).toBeUndefined(); }); test("rejects invalid elevatedLevel values", async () => { - const store: Record = {}; - const res = await applySessionsPatchToStore({ - cfg: {} as OpenClawConfig, - store, - storeKey: "agent:main:main", - patch: { key: "agent:main:main", elevatedLevel: "maybe" }, + const result = await runPatch({ + patch: { key: MAIN_SESSION_KEY, elevatedLevel: "maybe" }, }); - expect(res.ok).toBe(false); - if (res.ok) { - return; - } - expect(res.error.message).toContain("invalid elevatedLevel"); + expectPatchError(result, "invalid elevatedLevel"); }); test("clears auth overrides when model patch changes", async () => { @@ -193,189 +199,107 @@ describe("gateway sessions patch", () => { authProfileOverrideCompactionCount: 3, } as SessionEntry, }; - const res = await applySessionsPatchToStore({ - cfg: {} as OpenClawConfig, - store, - storeKey: "agent:main:main", - patch: { key: "agent:main:main", model: "openai/gpt-5.2" }, - loadGatewayModelCatalog: async () => [{ provider: "openai", id: "gpt-5.2", name: "gpt-5.2" }], - }); - expect(res.ok).toBe(true); - if (!res.ok) { - return; - } - expect(res.entry.providerOverride).toBe("openai"); - expect(res.entry.modelOverride).toBe("gpt-5.2"); - expect(res.entry.authProfileOverride).toBeUndefined(); - expect(res.entry.authProfileOverrideSource).toBeUndefined(); - expect(res.entry.authProfileOverrideCompactionCount).toBeUndefined(); + const entry = expectPatchOk( + await runPatch({ + store, + patch: { key: MAIN_SESSION_KEY, model: "openai/gpt-5.2" }, + loadGatewayModelCatalog: async () => [ + { provider: "openai", id: "gpt-5.2", name: "gpt-5.2" }, + ], + }), + ); + expect(entry.providerOverride).toBe("openai"); + expect(entry.modelOverride).toBe("gpt-5.2"); + expect(entry.authProfileOverride).toBeUndefined(); + expect(entry.authProfileOverrideSource).toBeUndefined(); + expect(entry.authProfileOverrideCompactionCount).toBeUndefined(); }); - test("accepts explicit allowlisted provider/model refs from sessions.patch", async () => { - const store: Record = {}; - const cfg = { - agents: { - defaults: { - model: { primary: "openai/gpt-5.2" }, - models: { - "anthropic/claude-sonnet-4-6": { alias: "sonnet" }, - }, - }, - }, - } as OpenClawConfig; - - const res = await applySessionsPatchToStore({ - cfg, - store, - storeKey: "agent:main:main", - patch: { key: "agent:main:main", model: "anthropic/claude-sonnet-4-6" }, - loadGatewayModelCatalog: async () => [ + test.each([ + { + name: "accepts explicit allowlisted provider/model refs from sessions.patch", + catalog: [ { provider: "anthropic", id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, { provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" }, ], - }); - - expect(res.ok).toBe(true); - if (!res.ok) { - return; - } - expect(res.entry.providerOverride).toBe("anthropic"); - expect(res.entry.modelOverride).toBe("claude-sonnet-4-6"); - }); - - test("accepts explicit allowlisted refs absent from bundled catalog", async () => { - const store: Record = {}; - const cfg = { - agents: { - defaults: { - model: { primary: "openai/gpt-5.2" }, - models: { - "anthropic/claude-sonnet-4-6": { alias: "sonnet" }, - }, - }, - }, - } as OpenClawConfig; - - const res = await applySessionsPatchToStore({ - cfg, - store, - storeKey: "agent:main:main", - patch: { key: "agent:main:main", model: "anthropic/claude-sonnet-4-6" }, - loadGatewayModelCatalog: async () => [ + }, + { + name: "accepts explicit allowlisted refs absent from bundled catalog", + catalog: [ { provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" }, { provider: "openai", id: "gpt-5.2", name: "GPT-5.2" }, ], - }); - - expect(res.ok).toBe(true); - if (!res.ok) { - return; - } - expect(res.entry.providerOverride).toBe("anthropic"); - expect(res.entry.modelOverride).toBe("claude-sonnet-4-6"); + }, + ])("$name", async ({ catalog }) => { + const entry = expectPatchOk( + await runPatch({ + cfg: createAllowlistedAnthropicModelCfg(), + patch: { key: MAIN_SESSION_KEY, model: "anthropic/claude-sonnet-4-6" }, + loadGatewayModelCatalog: async () => catalog, + }), + ); + expect(entry.providerOverride).toBe("anthropic"); + expect(entry.modelOverride).toBe("claude-sonnet-4-6"); }); test("sets spawnDepth for subagent sessions", async () => { - const store: Record = {}; - const res = await applySessionsPatchToStore({ - cfg: {} as OpenClawConfig, - store, - storeKey: "agent:main:subagent:child", - patch: { key: "agent:main:subagent:child", spawnDepth: 2 }, - }); - expect(res.ok).toBe(true); - if (!res.ok) { - return; - } - expect(res.entry.spawnDepth).toBe(2); + const entry = expectPatchOk( + await runPatch({ + storeKey: "agent:main:subagent:child", + patch: { key: "agent:main:subagent:child", spawnDepth: 2 }, + }), + ); + expect(entry.spawnDepth).toBe(2); }); test("rejects spawnDepth on non-subagent sessions", async () => { - const store: Record = {}; - const res = await applySessionsPatchToStore({ - cfg: {} as OpenClawConfig, - store, - storeKey: "agent:main:main", - patch: { key: "agent:main:main", spawnDepth: 1 }, + const result = await runPatch({ + patch: { key: MAIN_SESSION_KEY, spawnDepth: 1 }, }); - expect(res.ok).toBe(false); - if (res.ok) { - return; - } - expect(res.error.message).toContain("spawnDepth is only supported"); + expectPatchError(result, "spawnDepth is only supported"); }); test("normalizes exec/send/group patches", async () => { - const store: Record = {}; - const res = await applySessionsPatchToStore({ - cfg: {} as OpenClawConfig, - store, - storeKey: "agent:main:main", - patch: { - key: "agent:main:main", - execHost: " NODE ", - execSecurity: " ALLOWLIST ", - execAsk: " ON-MISS ", - execNode: " worker-1 ", - sendPolicy: "DENY" as unknown as "allow", - groupActivation: "Always" as unknown as "mention", - }, - }); - expect(res.ok).toBe(true); - if (!res.ok) { - return; - } - expect(res.entry.execHost).toBe("node"); - expect(res.entry.execSecurity).toBe("allowlist"); - expect(res.entry.execAsk).toBe("on-miss"); - expect(res.entry.execNode).toBe("worker-1"); - expect(res.entry.sendPolicy).toBe("deny"); - expect(res.entry.groupActivation).toBe("always"); + const entry = expectPatchOk( + await runPatch({ + patch: { + key: MAIN_SESSION_KEY, + execHost: " NODE ", + execSecurity: " ALLOWLIST ", + execAsk: " ON-MISS ", + execNode: " worker-1 ", + sendPolicy: "DENY" as unknown as "allow", + groupActivation: "Always" as unknown as "mention", + }, + }), + ); + expect(entry.execHost).toBe("node"); + expect(entry.execSecurity).toBe("allowlist"); + expect(entry.execAsk).toBe("on-miss"); + expect(entry.execNode).toBe("worker-1"); + expect(entry.sendPolicy).toBe("deny"); + expect(entry.groupActivation).toBe("always"); }); test("rejects invalid execHost values", async () => { - const store: Record = {}; - const res = await applySessionsPatchToStore({ - cfg: {} as OpenClawConfig, - store, - storeKey: "agent:main:main", - patch: { key: "agent:main:main", execHost: "edge" }, + const result = await runPatch({ + patch: { key: MAIN_SESSION_KEY, execHost: "edge" }, }); - expect(res.ok).toBe(false); - if (res.ok) { - return; - } - expect(res.error.message).toContain("invalid execHost"); + expectPatchError(result, "invalid execHost"); }); test("rejects invalid sendPolicy values", async () => { - const store: Record = {}; - const res = await applySessionsPatchToStore({ - cfg: {} as OpenClawConfig, - store, - storeKey: "agent:main:main", - patch: { key: "agent:main:main", sendPolicy: "ask" as unknown as "allow" }, + const result = await runPatch({ + patch: { key: MAIN_SESSION_KEY, sendPolicy: "ask" as unknown as "allow" }, }); - expect(res.ok).toBe(false); - if (res.ok) { - return; - } - expect(res.error.message).toContain("invalid sendPolicy"); + expectPatchError(result, "invalid sendPolicy"); }); test("rejects invalid groupActivation values", async () => { - const store: Record = {}; - const res = await applySessionsPatchToStore({ - cfg: {} as OpenClawConfig, - store, - storeKey: "agent:main:main", - patch: { key: "agent:main:main", groupActivation: "never" as unknown as "mention" }, + const result = await runPatch({ + patch: { key: MAIN_SESSION_KEY, groupActivation: "never" as unknown as "mention" }, }); - expect(res.ok).toBe(false); - if (res.ok) { - return; - } - expect(res.error.message).toContain("invalid groupActivation"); + expectPatchError(result, "invalid groupActivation"); }); test("allows target agent own model for subagent session even when missing from global allowlist", async () => { diff --git a/src/gateway/tools-invoke-http.cron-regression.test.ts b/src/gateway/tools-invoke-http.cron-regression.test.ts index 509df14497f..dfee9be2c20 100644 --- a/src/gateway/tools-invoke-http.cron-regression.test.ts +++ b/src/gateway/tools-invoke-http.cron-regression.test.ts @@ -5,6 +5,10 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vites const TEST_GATEWAY_TOKEN = "test-gateway-token-1234567890"; let cfg: Record = {}; +const alwaysAuthorized = async () => ({ ok: true as const }); +const disableDefaultMemorySlot = () => false; +const noPluginToolMeta = () => undefined; +const noWarnLog = () => {}; vi.mock("../config/config.js", () => ({ loadConfig: () => cfg, @@ -15,19 +19,19 @@ vi.mock("../config/sessions.js", () => ({ })); vi.mock("./auth.js", () => ({ - authorizeHttpGatewayConnect: async () => ({ ok: true }), + authorizeHttpGatewayConnect: alwaysAuthorized, })); vi.mock("../logger.js", () => ({ - logWarn: () => {}, + logWarn: noWarnLog, })); vi.mock("../plugins/config-state.js", () => ({ - isTestDefaultMemorySlotDisabled: () => false, + isTestDefaultMemorySlotDisabled: disableDefaultMemorySlot, })); vi.mock("../plugins/tools.js", () => ({ - getPluginToolMeta: () => undefined, + getPluginToolMeta: noPluginToolMeta, })); vi.mock("../agents/openclaw-tools.js", () => { diff --git a/src/infra/exec-approvals.test.ts b/src/infra/exec-approvals.test.ts index 39ee8b3f3ed..bd61cc8eb5f 100644 --- a/src/infra/exec-approvals.test.ts +++ b/src/infra/exec-approvals.test.ts @@ -32,6 +32,21 @@ function buildNestedEnvShellCommand(params: { return [...Array(params.depth).fill(params.envExecutable), "/bin/sh", "-c", params.payload]; } +function analyzeEnvWrapperAllowlist(params: { argv: string[]; envPath: string; cwd: string }) { + const analysis = analyzeArgvCommand({ + argv: params.argv, + cwd: params.cwd, + env: makePathEnv(params.envPath), + }); + const allowlistEval = evaluateExecAllowlist({ + analysis, + allowlist: [{ pattern: params.envPath }], + safeBins: normalizeSafeBins([]), + cwd: params.cwd, + }); + return { analysis, allowlistEval }; +} + describe("exec approvals allowlist matching", () => { const baseResolution = { rawExecutable: "rg", @@ -288,16 +303,9 @@ describe("exec approvals command resolution", () => { if (process.platform !== "win32") { fs.chmodSync(envPath, 0o755); } - - const analysis = analyzeArgvCommand({ + const { analysis, allowlistEval } = analyzeEnvWrapperAllowlist({ argv: [envPath, "-S", 'sh -c "echo pwned"'], - cwd: dir, - env: makePathEnv(binDir), - }); - const allowlistEval = evaluateExecAllowlist({ - analysis, - allowlist: [{ pattern: envPath }], - safeBins: normalizeSafeBins([]), + envPath: envPath, cwd: dir, }); @@ -317,20 +325,13 @@ describe("exec approvals command resolution", () => { const envPath = path.join(binDir, "env"); fs.writeFileSync(envPath, "#!/bin/sh\n"); fs.chmodSync(envPath, 0o755); - - const analysis = analyzeArgvCommand({ + const { analysis, allowlistEval } = analyzeEnvWrapperAllowlist({ argv: buildNestedEnvShellCommand({ envExecutable: envPath, depth: 5, payload: "echo pwned", }), - cwd: dir, - env: makePathEnv(binDir), - }); - const allowlistEval = evaluateExecAllowlist({ - analysis, - allowlist: [{ pattern: envPath }], - safeBins: normalizeSafeBins([]), + envPath, cwd: dir, }); diff --git a/src/infra/outbound/targets.channel-resolution.test.ts b/src/infra/outbound/targets.channel-resolution.test.ts index 01779d0655c..c1632071d13 100644 --- a/src/infra/outbound/targets.channel-resolution.test.ts +++ b/src/infra/outbound/targets.channel-resolution.test.ts @@ -5,18 +5,28 @@ const mocks = vi.hoisted(() => ({ loadOpenClawPlugins: vi.fn(), })); +const TEST_WORKSPACE_ROOT = "/tmp/openclaw-test-workspace"; + +function normalizeChannel(value?: string) { + return value?.trim().toLowerCase() ?? undefined; +} + +function passthroughPluginAutoEnable(config: unknown) { + return { config, changes: [] as unknown[] }; +} + vi.mock("../../channels/plugins/index.js", () => ({ getChannelPlugin: mocks.getChannelPlugin, - normalizeChannelId: (channel?: string) => channel?.trim().toLowerCase() ?? undefined, + normalizeChannelId: normalizeChannel, })); vi.mock("../../agents/agent-scope.js", () => ({ resolveDefaultAgentId: () => "main", - resolveAgentWorkspaceDir: () => "/tmp/openclaw-test-workspace", + resolveAgentWorkspaceDir: () => TEST_WORKSPACE_ROOT, })); vi.mock("../../config/plugin-auto-enable.js", () => ({ - applyPluginAutoEnable: ({ config }: { config: unknown }) => ({ config, changes: [] }), + applyPluginAutoEnable: ({ config }: { config: unknown }) => passthroughPluginAutoEnable(config), })); vi.mock("../../plugins/loader.js", () => ({ diff --git a/src/infra/update-runner.test.ts b/src/infra/update-runner.test.ts index 26ae50a86a7..069bf1bea20 100644 --- a/src/infra/update-runner.test.ts +++ b/src/infra/update-runner.test.ts @@ -182,6 +182,39 @@ describe("runGatewayUpdate", () => { ); } + function createGlobalNpmUpdateRunner(params: { + pkgRoot: string; + nodeModules: string; + onBaseInstall?: () => Promise; + onOmitOptionalInstall?: () => Promise; + }) { + const baseInstallKey = "npm i -g openclaw@latest --no-fund --no-audit --loglevel=error"; + const omitOptionalInstallKey = + "npm i -g openclaw@latest --omit=optional --no-fund --no-audit --loglevel=error"; + + return async (argv: string[]): Promise => { + const key = argv.join(" "); + if (key === `git -C ${params.pkgRoot} rev-parse --show-toplevel`) { + return { stdout: "", stderr: "not a git repository", code: 128 }; + } + if (key === "npm root -g") { + return { stdout: params.nodeModules, stderr: "", code: 0 }; + } + if (key === "pnpm root -g") { + return { stdout: "", stderr: "", code: 1 }; + } + if (key === baseInstallKey) { + return (await params.onBaseInstall?.()) ?? { stdout: "ok", stderr: "", code: 0 }; + } + if (key === omitOptionalInstallKey) { + return ( + (await params.onOmitOptionalInstall?.()) ?? { stdout: "", stderr: "not found", code: 1 } + ); + } + return { stdout: "", stderr: "", code: 0 }; + }; + } + it("skips git update when worktree is dirty", async () => { await setupGitCheckout(); const { runner, calls } = createRunner({ @@ -392,23 +425,14 @@ describe("runGatewayUpdate", () => { await seedGlobalPackageRoot(pkgRoot); let stalePresentAtInstall = true; - const runCommand = async (argv: string[]) => { - const key = argv.join(" "); - if (key === `git -C ${pkgRoot} rev-parse --show-toplevel`) { - return { stdout: "", stderr: "not a git repository", code: 128 }; - } - if (key === "npm root -g") { - return { stdout: nodeModules, stderr: "", code: 0 }; - } - if (key === "pnpm root -g") { - return { stdout: "", stderr: "", code: 1 }; - } - if (key === "npm i -g openclaw@latest --no-fund --no-audit --loglevel=error") { + const runCommand = createGlobalNpmUpdateRunner({ + nodeModules, + pkgRoot, + onBaseInstall: async () => { stalePresentAtInstall = await pathExists(staleDir); return { stdout: "ok", stderr: "", code: 0 }; - } - return { stdout: "", stderr: "", code: 0 }; - }; + }, + }); const result = await runWithCommand(runCommand, { cwd: pkgRoot }); @@ -423,33 +447,22 @@ describe("runGatewayUpdate", () => { await seedGlobalPackageRoot(pkgRoot); let firstAttempt = true; - const runCommand = async (argv: string[]) => { - const key = argv.join(" "); - if (key === `git -C ${pkgRoot} rev-parse --show-toplevel`) { - return { stdout: "", stderr: "not a git repository", code: 128 }; - } - if (key === "npm root -g") { - return { stdout: nodeModules, stderr: "", code: 0 }; - } - if (key === "pnpm root -g") { - return { stdout: "", stderr: "", code: 1 }; - } - if (key === "npm i -g openclaw@latest --no-fund --no-audit --loglevel=error") { + const runCommand = createGlobalNpmUpdateRunner({ + nodeModules, + pkgRoot, + onBaseInstall: async () => { firstAttempt = false; return { stdout: "", stderr: "node-gyp failed", code: 1 }; - } - if ( - key === "npm i -g openclaw@latest --omit=optional --no-fund --no-audit --loglevel=error" - ) { + }, + onOmitOptionalInstall: async () => { await fs.writeFile( path.join(pkgRoot, "package.json"), JSON.stringify({ name: "openclaw", version: "2.0.0" }), "utf-8", ); return { stdout: "ok", stderr: "", code: 0 }; - } - return { stdout: "", stderr: "", code: 0 }; - }; + }, + }); const result = await runWithCommand(runCommand, { cwd: pkgRoot }); diff --git a/src/node-host/invoke-system-run.test.ts b/src/node-host/invoke-system-run.test.ts index d1e7557e6c4..97ed329e070 100644 --- a/src/node-host/invoke-system-run.test.ts +++ b/src/node-host/invoke-system-run.test.ts @@ -21,6 +21,61 @@ describe("formatSystemRunAllowlistMissMessage", () => { }); describe("handleSystemRunInvoke mac app exec host routing", () => { + function createLocalRunResult(stdout = "local-ok") { + return { + success: true, + stdout, + stderr: "", + timedOut: false, + truncated: false, + exitCode: 0, + error: null, + }; + } + + function expectInvokeOk( + sendInvokeResult: ReturnType, + params?: { payloadContains?: string }, + ) { + expect(sendInvokeResult).toHaveBeenCalledWith( + expect.objectContaining({ + ok: true, + ...(params?.payloadContains + ? { payloadJSON: expect.stringContaining(params.payloadContains) } + : {}), + }), + ); + } + + function expectInvokeErrorMessage( + sendInvokeResult: ReturnType, + params: { message: string; exact?: boolean }, + ) { + expect(sendInvokeResult).toHaveBeenCalledWith( + expect.objectContaining({ + ok: false, + error: expect.objectContaining({ + message: params.exact ? params.message : expect.stringContaining(params.message), + }), + }), + ); + } + + function expectApprovalRequiredDenied(params: { + sendNodeEvent: ReturnType; + sendInvokeResult: ReturnType; + }) { + expect(params.sendNodeEvent).toHaveBeenCalledWith( + expect.anything(), + "exec.denied", + expect.objectContaining({ reason: "approval-required" }), + ); + expectInvokeErrorMessage(params.sendInvokeResult, { + message: "SYSTEM_RUN_DENIED: approval required", + exact: true, + }); + } + function buildNestedEnvShellCommand(params: { depth: number; payload: string }): string[] { return [...Array(params.depth).fill("/usr/bin/env"), "/bin/sh", "-c", params.payload]; } @@ -45,6 +100,44 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { } } + async function withPathTokenCommand(params: { + tmpPrefix: string; + run: (ctx: { link: string; expected: string }) => Promise; + }): Promise { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), params.tmpPrefix)); + const binDir = path.join(tmp, "bin"); + fs.mkdirSync(binDir, { recursive: true }); + const link = path.join(binDir, "poccmd"); + fs.symlinkSync("/bin/echo", link); + const expected = fs.realpathSync(link); + const oldPath = process.env.PATH; + process.env.PATH = `${binDir}${path.delimiter}${oldPath ?? ""}`; + try { + return await params.run({ link, expected }); + } finally { + if (oldPath === undefined) { + delete process.env.PATH; + } else { + process.env.PATH = oldPath; + } + fs.rmSync(tmp, { recursive: true, force: true }); + } + } + + function expectCommandPinnedToCanonicalPath(params: { + runCommand: ReturnType; + expected: string; + commandTail: string[]; + cwd?: string; + }) { + expect(params.runCommand).toHaveBeenCalledWith( + [params.expected, ...params.commandTail], + params.cwd, + undefined, + undefined, + ); + } + async function runSystemInvoke(params: { preferMacAppExecHost: boolean; runViaResponse?: ExecHostResponse | null; @@ -53,26 +146,23 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { security?: "full" | "allowlist"; ask?: "off" | "on-miss" | "always"; approved?: boolean; + runCommand?: ReturnType; + runViaMacAppExecHost?: ReturnType; + sendInvokeResult?: ReturnType; + sendExecFinishedEvent?: ReturnType; + sendNodeEvent?: ReturnType; + skillBinsCurrent?: () => Promise>; }) { - const runCommand = vi.fn( - async ( - _command: string[], - _cwd?: string, - _env?: Record, - _timeoutMs?: number, - ) => ({ - success: true, - stdout: "local-ok", - stderr: "", - timedOut: false, - truncated: false, - exitCode: 0, - error: null, - }), - ); - const runViaMacAppExecHost = vi.fn(async () => params.runViaResponse ?? null); - const sendInvokeResult = vi.fn(async () => {}); - const sendExecFinishedEvent = vi.fn(async () => {}); + const runCommand = + params.runCommand ?? + vi.fn(async (_command: string[], _cwd?: string, _env?: Record) => + createLocalRunResult(), + ); + const runViaMacAppExecHost = + params.runViaMacAppExecHost ?? vi.fn(async () => params.runViaResponse ?? null); + const sendInvokeResult = params.sendInvokeResult ?? vi.fn(async () => {}); + const sendExecFinishedEvent = params.sendExecFinishedEvent ?? vi.fn(async () => {}); + const sendNodeEvent = params.sendNodeEvent ?? vi.fn(async () => {}); await handleSystemRunInvoke({ client: {} as never, @@ -83,7 +173,7 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { sessionKey: "agent:main:main", }, skillBins: { - current: async () => [], + current: params.skillBinsCurrent ?? (async () => []), }, execHostEnforced: false, execHostFallbackAllowed: true, @@ -93,7 +183,7 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { sanitizeEnv: () => undefined, runCommand, runViaMacAppExecHost, - sendNodeEvent: async () => {}, + sendNodeEvent, buildExecEventPayload: (payload) => payload, sendInvokeResult, sendExecFinishedEvent, @@ -110,12 +200,7 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { expect(runViaMacAppExecHost).not.toHaveBeenCalled(); expect(runCommand).toHaveBeenCalledTimes(1); - expect(sendInvokeResult).toHaveBeenCalledWith( - expect.objectContaining({ - ok: true, - payloadJSON: expect.stringContaining("local-ok"), - }), - ); + expectInvokeOk(sendInvokeResult, { payloadContains: "local-ok" }); }); it("uses mac app exec host when explicitly preferred", async () => { @@ -146,12 +231,7 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { }), }); expect(runCommand).not.toHaveBeenCalled(); - expect(sendInvokeResult).toHaveBeenCalledWith( - expect.objectContaining({ - ok: true, - payloadJSON: expect.stringContaining("app-ok"), - }), - ); + expectInvokeOk(sendInvokeResult, { payloadContains: "app-ok" }); }); it("forwards canonical cmdText to mac app exec host for positional-argv shell wrappers", async () => { @@ -188,14 +268,7 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { }); if (process.platform === "win32") { expect(runCommand).not.toHaveBeenCalled(); - expect(sendInvokeResult).toHaveBeenCalledWith( - expect.objectContaining({ - ok: false, - error: expect.objectContaining({ - message: expect.stringContaining("allowlist miss"), - }), - }), - ); + expectInvokeErrorMessage(sendInvokeResult, { message: "allowlist miss" }); return; } @@ -203,11 +276,7 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { expect(runArgs).toBeDefined(); expect(runArgs?.[0]).toMatch(/(^|[/\\])tr$/); expect(runArgs?.slice(1)).toEqual(["a", "b"]); - expect(sendInvokeResult).toHaveBeenCalledWith( - expect.objectContaining({ - ok: true, - }), - ); + expectInvokeOk(sendInvokeResult); }); it("denies semantic env wrappers in allowlist mode", async () => { @@ -217,139 +286,76 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { command: ["env", "FOO=bar", "tr", "a", "b"], }); expect(runCommand).not.toHaveBeenCalled(); - expect(sendInvokeResult).toHaveBeenCalledWith( - expect.objectContaining({ - ok: false, - error: expect.objectContaining({ - message: expect.stringContaining("allowlist miss"), - }), - }), - ); + expectInvokeErrorMessage(sendInvokeResult, { message: "allowlist miss" }); }); it.runIf(process.platform !== "win32")( "pins PATH-token executable to canonical path for approval-based runs", async () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-approval-path-pin-")); - const binDir = path.join(tmp, "bin"); - fs.mkdirSync(binDir, { recursive: true }); - const link = path.join(binDir, "poccmd"); - fs.symlinkSync("/bin/echo", link); - const expected = fs.realpathSync(link); - const oldPath = process.env.PATH; - process.env.PATH = `${binDir}${path.delimiter}${oldPath ?? ""}`; - try { - const { runCommand, sendInvokeResult } = await runSystemInvoke({ - preferMacAppExecHost: false, - command: ["poccmd", "-n", "SAFE"], - approved: true, - security: "full", - ask: "off", - }); - expect(runCommand).toHaveBeenCalledWith( - [expected, "-n", "SAFE"], - undefined, - undefined, - undefined, - ); - expect(sendInvokeResult).toHaveBeenCalledWith( - expect.objectContaining({ - ok: true, - }), - ); - } finally { - if (oldPath === undefined) { - delete process.env.PATH; - } else { - process.env.PATH = oldPath; - } - fs.rmSync(tmp, { recursive: true, force: true }); - } + await withPathTokenCommand({ + tmpPrefix: "openclaw-approval-path-pin-", + run: async ({ expected }) => { + const { runCommand, sendInvokeResult } = await runSystemInvoke({ + preferMacAppExecHost: false, + command: ["poccmd", "-n", "SAFE"], + approved: true, + security: "full", + ask: "off", + }); + expectCommandPinnedToCanonicalPath({ + runCommand, + expected, + commandTail: ["-n", "SAFE"], + }); + expectInvokeOk(sendInvokeResult); + }, + }); }, ); it.runIf(process.platform !== "win32")( "pins PATH-token executable to canonical path for allowlist runs", async () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-allowlist-path-pin-")); - const binDir = path.join(tmp, "bin"); - fs.mkdirSync(binDir, { recursive: true }); - const link = path.join(binDir, "poccmd"); - fs.symlinkSync("/bin/echo", link); - const expected = fs.realpathSync(link); - const oldPath = process.env.PATH; - process.env.PATH = `${binDir}${path.delimiter}${oldPath ?? ""}`; const runCommand = vi.fn(async () => ({ - success: true, - stdout: "local-ok", - stderr: "", - timedOut: false, - truncated: false, - exitCode: 0, - error: null, + ...createLocalRunResult(), })); const sendInvokeResult = vi.fn(async () => {}); - const sendNodeEvent = vi.fn(async () => {}); - try { - await withTempApprovalsHome({ - approvals: { - version: 1, - defaults: { - security: "allowlist", - ask: "off", - askFallback: "deny", - }, - agents: { - main: { - allowlist: [{ pattern: link }], + await withPathTokenCommand({ + tmpPrefix: "openclaw-allowlist-path-pin-", + run: async ({ link, expected }) => { + await withTempApprovalsHome({ + approvals: { + version: 1, + defaults: { + security: "allowlist", + ask: "off", + askFallback: "deny", + }, + agents: { + main: { + allowlist: [{ pattern: link }], + }, }, }, - }, - run: async () => { - await handleSystemRunInvoke({ - client: {} as never, - params: { + run: async () => { + await runSystemInvoke({ + preferMacAppExecHost: false, command: ["poccmd", "-n", "SAFE"], - sessionKey: "agent:main:main", - }, - skillBins: { - current: async () => [], - }, - execHostEnforced: false, - execHostFallbackAllowed: true, - resolveExecSecurity: () => "allowlist", - resolveExecAsk: () => "off", - isCmdExeInvocation: () => false, - sanitizeEnv: () => undefined, - runCommand, - runViaMacAppExecHost: vi.fn(async () => null), - sendNodeEvent, - buildExecEventPayload: (payload) => payload, - sendInvokeResult, - sendExecFinishedEvent: vi.fn(async () => {}), - preferMacAppExecHost: false, - }); - }, - }); - expect(runCommand).toHaveBeenCalledWith( - [expected, "-n", "SAFE"], - undefined, - undefined, - undefined, - ); - expect(sendInvokeResult).toHaveBeenCalledWith( - expect.objectContaining({ - ok: true, - }), - ); - } finally { - if (oldPath === undefined) { - delete process.env.PATH; - } else { - process.env.PATH = oldPath; - } - fs.rmSync(tmp, { recursive: true, force: true }); - } + security: "allowlist", + ask: "off", + runCommand, + sendInvokeResult, + }); + }, + }); + expectCommandPinnedToCanonicalPath({ + runCommand, + expected, + commandTail: ["-n", "SAFE"], + }); + expectInvokeOk(sendInvokeResult); + }, + }); }, ); @@ -374,14 +380,7 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { ask: "off", }); expect(runCommand).not.toHaveBeenCalled(); - expect(sendInvokeResult).toHaveBeenCalledWith( - expect.objectContaining({ - ok: false, - error: expect.objectContaining({ - message: expect.stringContaining("canonical cwd"), - }), - }), - ); + expectInvokeErrorMessage(sendInvokeResult, { message: "canonical cwd" }); } finally { fs.rmSync(tmp, { recursive: true, force: true }); } @@ -407,14 +406,7 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { ask: "off", }); expect(runCommand).not.toHaveBeenCalled(); - expect(sendInvokeResult).toHaveBeenCalledWith( - expect.objectContaining({ - ok: false, - error: expect.objectContaining({ - message: expect.stringContaining("no symlink path components"), - }), - }), - ); + expectInvokeErrorMessage(sendInvokeResult, { message: "no symlink path components" }); } finally { fs.rmSync(tmp, { recursive: true, force: true }); } @@ -435,17 +427,13 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { security: "full", ask: "off", }); - expect(runCommand).toHaveBeenCalledWith( - [fs.realpathSync(script), "--flag"], - fs.realpathSync(tmp), - undefined, - undefined, - ); - expect(sendInvokeResult).toHaveBeenCalledWith( - expect.objectContaining({ - ok: true, - }), - ); + expectCommandPinnedToCanonicalPath({ + runCommand, + expected: fs.realpathSync(script), + commandTail: ["--flag"], + cwd: fs.realpathSync(tmp), + }); + expectInvokeOk(sendInvokeResult); } finally { fs.rmSync(tmp, { recursive: true, force: true }); } @@ -454,58 +442,24 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { const marker = path.join(os.tmpdir(), `openclaw-wrapper-spoof-${process.pid}-${Date.now()}`); const runCommand = vi.fn(async () => { fs.writeFileSync(marker, "executed"); - return { - success: true, - stdout: "local-ok", - stderr: "", - timedOut: false, - truncated: false, - exitCode: 0, - error: null, - }; + return createLocalRunResult(); }); const sendInvokeResult = vi.fn(async () => {}); const sendNodeEvent = vi.fn(async () => {}); - await handleSystemRunInvoke({ - client: {} as never, - params: { - command: ["./sh", "-lc", "/bin/echo approved-only"], - sessionKey: "agent:main:main", - }, - skillBins: { - current: async () => [], - }, - execHostEnforced: false, - execHostFallbackAllowed: true, - resolveExecSecurity: () => "allowlist", - resolveExecAsk: () => "on-miss", - isCmdExeInvocation: () => false, - sanitizeEnv: () => undefined, - runCommand, - runViaMacAppExecHost: vi.fn(async () => null), - sendNodeEvent, - buildExecEventPayload: (payload) => payload, - sendInvokeResult, - sendExecFinishedEvent: vi.fn(async () => {}), + await runSystemInvoke({ preferMacAppExecHost: false, + command: ["./sh", "-lc", "/bin/echo approved-only"], + security: "allowlist", + ask: "on-miss", + runCommand, + sendInvokeResult, + sendNodeEvent, }); expect(runCommand).not.toHaveBeenCalled(); expect(fs.existsSync(marker)).toBe(false); - expect(sendNodeEvent).toHaveBeenCalledWith( - expect.anything(), - "exec.denied", - expect.objectContaining({ reason: "approval-required" }), - ); - expect(sendInvokeResult).toHaveBeenCalledWith( - expect.objectContaining({ - ok: false, - error: expect.objectContaining({ - message: "SYSTEM_RUN_DENIED: approval required", - }), - }), - ); + expectApprovalRequiredDenied({ sendNodeEvent, sendInvokeResult }); try { fs.unlinkSync(marker); } catch { @@ -514,15 +468,7 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { }); it("denies ./skill-bin even when autoAllowSkills trust entry exists", async () => { - const runCommand = vi.fn(async () => ({ - success: true, - stdout: "local-ok", - stderr: "", - timedOut: false, - truncated: false, - exitCode: 0, - error: null, - })); + const runCommand = vi.fn(async () => createLocalRunResult()); const sendInvokeResult = vi.fn(async () => {}); const sendNodeEvent = vi.fn(async () => {}); @@ -541,47 +487,22 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { const skillBinPath = path.join(tempHome, "skill-bin"); fs.writeFileSync(skillBinPath, "#!/bin/sh\necho should-not-run\n", { mode: 0o755 }); fs.chmodSync(skillBinPath, 0o755); - await handleSystemRunInvoke({ - client: {} as never, - params: { - command: ["./skill-bin", "--help"], - cwd: tempHome, - sessionKey: "agent:main:main", - }, - skillBins: { - current: async () => [{ name: "skill-bin", resolvedPath: skillBinPath }], - }, - execHostEnforced: false, - execHostFallbackAllowed: true, - resolveExecSecurity: () => "allowlist", - resolveExecAsk: () => "on-miss", - isCmdExeInvocation: () => false, - sanitizeEnv: () => undefined, - runCommand, - runViaMacAppExecHost: vi.fn(async () => null), - sendNodeEvent, - buildExecEventPayload: (payload) => payload, - sendInvokeResult, - sendExecFinishedEvent: vi.fn(async () => {}), + await runSystemInvoke({ preferMacAppExecHost: false, + command: ["./skill-bin", "--help"], + cwd: tempHome, + security: "allowlist", + ask: "on-miss", + skillBinsCurrent: async () => [{ name: "skill-bin", resolvedPath: skillBinPath }], + runCommand, + sendInvokeResult, + sendNodeEvent, }); }, }); expect(runCommand).not.toHaveBeenCalled(); - expect(sendNodeEvent).toHaveBeenCalledWith( - expect.anything(), - "exec.denied", - expect.objectContaining({ reason: "approval-required" }), - ); - expect(sendInvokeResult).toHaveBeenCalledWith( - expect.objectContaining({ - ok: false, - error: expect.objectContaining({ - message: "SYSTEM_RUN_DENIED: approval required", - }), - }), - ); + expectApprovalRequiredDenied({ sendNodeEvent, sendInvokeResult }); }); it("denies env -S shell payloads in allowlist mode", async () => { @@ -591,14 +512,7 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { command: ["env", "-S", 'sh -c "echo pwned"'], }); expect(runCommand).not.toHaveBeenCalled(); - expect(sendInvokeResult).toHaveBeenCalledWith( - expect.objectContaining({ - ok: false, - error: expect.objectContaining({ - message: expect.stringContaining("allowlist miss"), - }), - }), - ); + expectInvokeErrorMessage(sendInvokeResult, { message: "allowlist miss" }); }); it("denies semicolon-chained shell payloads in allowlist mode without explicit approval", async () => { @@ -615,14 +529,10 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { command, }); expect(runCommand, payload).not.toHaveBeenCalled(); - expect(sendInvokeResult, payload).toHaveBeenCalledWith( - expect.objectContaining({ - ok: false, - error: expect.objectContaining({ - message: "SYSTEM_RUN_DENIED: approval required", - }), - }), - ); + expectInvokeErrorMessage(sendInvokeResult, { + message: "SYSTEM_RUN_DENIED: approval required", + exact: true, + }); } }); @@ -652,49 +562,23 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { }, run: async ({ tempHome }) => { const marker = path.join(tempHome, "pwned.txt"); - await handleSystemRunInvoke({ - client: {} as never, - params: { - command: buildNestedEnvShellCommand({ - depth: 5, - payload: `echo PWNED > ${marker}`, - }), - sessionKey: "agent:main:main", - }, - skillBins: { - current: async () => [], - }, - execHostEnforced: false, - execHostFallbackAllowed: true, - resolveExecSecurity: () => "allowlist", - resolveExecAsk: () => "on-miss", - isCmdExeInvocation: () => false, - sanitizeEnv: () => undefined, - runCommand, - runViaMacAppExecHost: vi.fn(async () => null), - sendNodeEvent, - buildExecEventPayload: (payload) => payload, - sendInvokeResult, - sendExecFinishedEvent: vi.fn(async () => {}), + await runSystemInvoke({ preferMacAppExecHost: false, + command: buildNestedEnvShellCommand({ + depth: 5, + payload: `echo PWNED > ${marker}`, + }), + security: "allowlist", + ask: "on-miss", + runCommand, + sendInvokeResult, + sendNodeEvent, }); expect(fs.existsSync(marker)).toBe(false); }, }); expect(runCommand).not.toHaveBeenCalled(); - expect(sendNodeEvent).toHaveBeenCalledWith( - expect.anything(), - "exec.denied", - expect.objectContaining({ reason: "approval-required" }), - ); - expect(sendInvokeResult).toHaveBeenCalledWith( - expect.objectContaining({ - ok: false, - error: expect.objectContaining({ - message: "SYSTEM_RUN_DENIED: approval required", - }), - }), - ); + expectApprovalRequiredDenied({ sendNodeEvent, sendInvokeResult }); }); });