diff --git a/src/cli/daemon-cli.coverage.test.ts b/src/cli/daemon-cli.coverage.test.ts index 7aa66c2bc..ca6fa109b 100644 --- a/src/cli/daemon-cli.coverage.test.ts +++ b/src/cli/daemon-cli.coverage.test.ts @@ -175,53 +175,38 @@ describe("daemon-cli coverage", () => { ); }); - it.each([ - { label: "plain output", includeJsonFlag: false }, - { label: "json output", includeJsonFlag: true }, - ])("installs the daemon ($label)", async ({ includeJsonFlag }) => { + it("installs the daemon (json output)", async () => { resetRuntimeCapture(); serviceIsLoaded.mockResolvedValueOnce(false); serviceInstall.mockClear(); - const args = includeJsonFlag - ? ["daemon", "install", "--port", "18789", "--json"] - : ["daemon", "install", "--port", "18789"]; - await runDaemonCommand(args); + await runDaemonCommand(["daemon", "install", "--port", "18789", "--json"]); expect(serviceInstall).toHaveBeenCalledTimes(1); - if (includeJsonFlag) { - const parsed = parseFirstJsonRuntimeLine<{ - ok?: boolean; - action?: string; - result?: string; - }>(); - expect(parsed.ok).toBe(true); - expect(parsed.action).toBe("install"); - expect(parsed.result).toBe("installed"); - } + const parsed = parseFirstJsonRuntimeLine<{ + ok?: boolean; + action?: string; + result?: string; + }>(); + expect(parsed.ok).toBe(true); + expect(parsed.action).toBe("install"); + expect(parsed.result).toBe("installed"); }); - it.each([ - { label: "plain output", includeJsonFlag: false }, - { label: "json output", includeJsonFlag: true }, - ])("starts and stops daemon ($label)", async ({ includeJsonFlag }) => { + it("starts and stops daemon (json output)", async () => { resetRuntimeCapture(); serviceRestart.mockClear(); serviceStop.mockClear(); serviceIsLoaded.mockResolvedValue(true); - const startArgs = includeJsonFlag ? ["daemon", "start", "--json"] : ["daemon", "start"]; - const stopArgs = includeJsonFlag ? ["daemon", "stop", "--json"] : ["daemon", "stop"]; - await runDaemonCommand(startArgs); - await runDaemonCommand(stopArgs); + await runDaemonCommand(["daemon", "start", "--json"]); + await runDaemonCommand(["daemon", "stop", "--json"]); expect(serviceRestart).toHaveBeenCalledTimes(1); expect(serviceStop).toHaveBeenCalledTimes(1); - if (includeJsonFlag) { - const jsonLines = runtimeLogs.filter((line) => line.trim().startsWith("{")); - const parsed = jsonLines.map((line) => JSON.parse(line) as { action?: string; ok?: boolean }); - expect(parsed.some((entry) => entry.action === "start" && entry.ok === true)).toBe(true); - expect(parsed.some((entry) => entry.action === "stop" && entry.ok === true)).toBe(true); - } + const jsonLines = runtimeLogs.filter((line) => line.trim().startsWith("{")); + const parsed = jsonLines.map((line) => JSON.parse(line) as { action?: string; ok?: boolean }); + expect(parsed.some((entry) => entry.action === "start" && entry.ok === true)).toBe(true); + expect(parsed.some((entry) => entry.action === "stop" && entry.ok === true)).toBe(true); }); }); diff --git a/src/cli/gateway-cli.coverage.test.ts b/src/cli/gateway-cli.coverage.test.ts index 063ebe1ee..c9b238dc8 100644 --- a/src/cli/gateway-cli.coverage.test.ts +++ b/src/cli/gateway-cli.coverage.test.ts @@ -112,7 +112,7 @@ describe("gateway-cli coverage", () => { expect(callGateway).toHaveBeenCalledTimes(1); expect(runtimeLogs.join("\n")).toContain('"ok": true'); - }, 60_000); + }); it("registers gateway probe and routes to gatewayStatusCommand", async () => { resetRuntimeCapture(); @@ -121,27 +121,9 @@ describe("gateway-cli coverage", () => { await runGatewayCommand(["gateway", "probe", "--json"]); expect(gatewayStatusCommand).toHaveBeenCalledTimes(1); - }, 60_000); + }); - it.each([ - { - label: "json output", - args: ["gateway", "discover", "--json"], - expectedOutput: ['"beacons"', '"wsUrl"', "ws://"], - }, - { - label: "human output", - args: ["gateway", "discover", "--timeout", "1"], - expectedOutput: [ - "Gateway Discovery", - "Found 1 gateway(s)", - "- Studio openclaw.internal.", - " tailnet: studio.tailnet.ts.net", - " host: studio.openclaw.internal", - " ws: ws://studio.openclaw.internal:18789", - ], - }, - ])("registers gateway discover and prints $label", async ({ args, expectedOutput }) => { + it("registers gateway discover and prints json output", async () => { resetRuntimeCapture(); discoverGatewayBeacons.mockClear(); discoverGatewayBeacons.mockResolvedValueOnce([ @@ -157,11 +139,11 @@ describe("gateway-cli coverage", () => { }, ]); - await runGatewayCommand(args); + await runGatewayCommand(["gateway", "discover", "--json"]); expect(discoverGatewayBeacons).toHaveBeenCalledTimes(1); const out = runtimeLogs.join("\n"); - for (const text of expectedOutput) { + for (const text of ['"beacons"', '"wsUrl"', "ws://"]) { expect(out).toContain(text); } }); diff --git a/src/gateway/server.auth.test.ts b/src/gateway/server.auth.test.ts index ec8e92bdd..5e8cb14f4 100644 --- a/src/gateway/server.auth.test.ts +++ b/src/gateway/server.auth.test.ts @@ -296,14 +296,14 @@ describe("gateway server auth/connect", () => { await server.close(); }); - test("closes silent handshakes after timeout", { timeout: 60_000 }, async () => { + test("closes silent handshakes after timeout", async () => { vi.useRealTimers(); const prevHandshakeTimeout = process.env.OPENCLAW_TEST_HANDSHAKE_TIMEOUT_MS; - process.env.OPENCLAW_TEST_HANDSHAKE_TIMEOUT_MS = "50"; + process.env.OPENCLAW_TEST_HANDSHAKE_TIMEOUT_MS = "20"; try { const ws = await openWs(port); const handshakeTimeoutMs = getHandshakeTimeoutMs(); - const closed = await waitForWsClose(ws, handshakeTimeoutMs + 250); + const closed = await waitForWsClose(ws, handshakeTimeoutMs + 120); expect(closed).toBe(true); } finally { if (prevHandshakeTimeout === undefined) { @@ -567,54 +567,50 @@ describe("gateway server auth/connect", () => { await new Promise((resolve) => ws.once("close", () => resolve())); }); - test( - "invalid connect params surface in response and close reason", - { timeout: 60_000 }, - async () => { - const ws = await openWs(port); - const closeInfoPromise = new Promise<{ code: number; reason: string }>((resolve) => { - ws.once("close", (code, reason) => resolve({ code, reason: reason.toString() })); - }); + test("invalid connect params surface in response and close reason", async () => { + const ws = await openWs(port); + const closeInfoPromise = new Promise<{ code: number; reason: string }>((resolve) => { + ws.once("close", (code, reason) => resolve({ code, reason: reason.toString() })); + }); - ws.send( - JSON.stringify({ - type: "req", - id: "h-bad", - method: "connect", - params: { - minProtocol: PROTOCOL_VERSION, - maxProtocol: PROTOCOL_VERSION, - client: { - id: "bad-client", - version: "dev", - platform: "web", - mode: "webchat", - }, - device: { - id: 123, - publicKey: "bad", - signature: "bad", - signedAt: "bad", - }, + ws.send( + JSON.stringify({ + type: "req", + id: "h-bad", + method: "connect", + params: { + minProtocol: PROTOCOL_VERSION, + maxProtocol: PROTOCOL_VERSION, + client: { + id: "bad-client", + version: "dev", + platform: "web", + mode: "webchat", }, - }), - ); + device: { + id: 123, + publicKey: "bad", + signature: "bad", + signedAt: "bad", + }, + }, + }), + ); - const res = await onceMessage<{ - ok: boolean; - error?: { message?: string }; - }>( - ws, - (o) => (o as { type?: string }).type === "res" && (o as { id?: string }).id === "h-bad", - ); - expect(res.ok).toBe(false); - expect(String(res.error?.message ?? "")).toContain("invalid connect params"); + const res = await onceMessage<{ + ok: boolean; + error?: { message?: string }; + }>( + ws, + (o) => (o as { type?: string }).type === "res" && (o as { id?: string }).id === "h-bad", + ); + expect(res.ok).toBe(false); + expect(String(res.error?.message ?? "")).toContain("invalid connect params"); - const closeInfo = await closeInfoPromise; - expect(closeInfo.code).toBe(1008); - expect(closeInfo.reason).toContain("invalid connect params"); - }, - ); + const closeInfo = await closeInfoPromise; + expect(closeInfo.code).toBe(1008); + expect(closeInfo.reason).toContain("invalid connect params"); + }); }); describe("password auth", () => { @@ -932,97 +928,85 @@ describe("gateway server auth/connect", () => { } }); - test("accepts device token auth for paired device", async () => { + test("device token auth matrix", async () => { const { server, ws, port, prevToken } = await startServerWithClient("secret"); const { deviceToken } = await ensurePairedDeviceTokenForCurrentIdentity(ws); - ws.close(); - const ws2 = await openWs(port); - const res2 = await connectReq(ws2, { token: deviceToken }); - expect(res2.ok).toBe(true); + const scenarios: Array<{ + name: string; + opts: Parameters[1]; + assert: (res: Awaited>) => void; + }> = [ + { + name: "accepts device token auth for paired device", + opts: { token: deviceToken }, + assert: (res) => { + expect(res.ok).toBe(true); + }, + }, + { + name: "accepts explicit auth.deviceToken when shared token is omitted", + opts: { + skipDefaultAuth: true, + deviceToken, + }, + assert: (res) => { + expect(res.ok).toBe(true); + }, + }, + { + name: "uses explicit auth.deviceToken fallback when shared token is wrong", + opts: { + token: "wrong", + deviceToken, + }, + assert: (res) => { + expect(res.ok).toBe(true); + }, + }, + { + name: "keeps shared token mismatch reason when fallback device-token check fails", + opts: { token: "wrong" }, + assert: (res) => { + expect(res.ok).toBe(false); + expect(res.error?.message ?? "").toContain("gateway token mismatch"); + expect(res.error?.message ?? "").not.toContain("device token mismatch"); + expect((res.error?.details as { code?: string } | undefined)?.code).toBe( + ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH, + ); + }, + }, + { + name: "reports device token mismatch when explicit auth.deviceToken is wrong", + opts: { + skipDefaultAuth: true, + deviceToken: "not-a-valid-device-token", + }, + assert: (res) => { + expect(res.ok).toBe(false); + expect(res.error?.message ?? "").toContain("device token mismatch"); + expect((res.error?.details as { code?: string } | undefined)?.code).toBe( + ConnectErrorDetailCodes.AUTH_DEVICE_TOKEN_MISMATCH, + ); + }, + }, + ]; - ws2.close(); - await server.close(); - restoreGatewayToken(prevToken); - }); - - test("accepts explicit auth.deviceToken when shared token is omitted", async () => { - const { server, ws, port, prevToken } = await startServerWithClient("secret"); - const { deviceToken } = await ensurePairedDeviceTokenForCurrentIdentity(ws); - - ws.close(); - - const ws2 = await openWs(port); - const res2 = await connectReq(ws2, { - skipDefaultAuth: true, - deviceToken, - }); - expect(res2.ok).toBe(true); - - ws2.close(); - await server.close(); - restoreGatewayToken(prevToken); - }); - - test("uses explicit auth.deviceToken fallback when shared token is wrong", async () => { - const { server, ws, port, prevToken } = await startServerWithClient("secret"); - const { deviceToken } = await ensurePairedDeviceTokenForCurrentIdentity(ws); - - ws.close(); - - const ws2 = await openWs(port); - const res2 = await connectReq(ws2, { - token: "wrong", - deviceToken, - }); - expect(res2.ok).toBe(true); - - ws2.close(); - await server.close(); - restoreGatewayToken(prevToken); - }); - - test("keeps shared token mismatch reason when token fallback device-token check fails", async () => { - const { server, ws, port, prevToken } = await startServerWithClient("secret"); - await ensurePairedDeviceTokenForCurrentIdentity(ws); - - ws.close(); - - const ws2 = await openWs(port); - const res2 = await connectReq(ws2, { token: "wrong" }); - expect(res2.ok).toBe(false); - expect(res2.error?.message ?? "").toContain("gateway token mismatch"); - expect(res2.error?.message ?? "").not.toContain("device token mismatch"); - expect((res2.error?.details as { code?: string } | undefined)?.code).toBe( - ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH, - ); - - ws2.close(); - await server.close(); - restoreGatewayToken(prevToken); - }); - - test("reports device token mismatch when explicit auth.deviceToken is wrong", async () => { - const { server, ws, port, prevToken } = await startServerWithClient("secret"); - await ensurePairedDeviceTokenForCurrentIdentity(ws); - - ws.close(); - - const ws2 = await openWs(port); - const res2 = await connectReq(ws2, { - skipDefaultAuth: true, - deviceToken: "not-a-valid-device-token", - }); - expect(res2.ok).toBe(false); - expect(res2.error?.message ?? "").toContain("device token mismatch"); - expect((res2.error?.details as { code?: string } | undefined)?.code).toBe( - ConnectErrorDetailCodes.AUTH_DEVICE_TOKEN_MISMATCH, - ); - - ws2.close(); - await server.close(); - restoreGatewayToken(prevToken); + try { + for (const scenario of scenarios) { + const ws2 = await openWs(port); + try { + const res = await connectReq(ws2, scenario.opts); + scenario.assert(res); + } finally { + ws2.close(); + } + } + } finally { + await server.close(); + restoreGatewayToken(prevToken); + } }); test("keeps shared-secret lockout separate from device-token auth", async () => { diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index 87d9af709..699e13720 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -1,10 +1,10 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import type { ChannelPlugin } from "../channels/plugins/types.js"; import type { OpenClawConfig } from "../config/config.js"; -import { captureEnv, withEnvAsync } from "../test-utils/env.js"; +import { withEnvAsync } from "../test-utils/env.js"; import { collectPluginsCodeSafetyFindings } from "./audit-extra.js"; import type { SecurityAuditOptions, SecurityAuditReport } from "./audit.js"; import { runSecurityAudit } from "./audit.js"; @@ -207,12 +207,14 @@ describe("security audit", () => { expectWarn: false, }, ]; - for (const testCase of cases) { - const res = await audit(testCase.cfg, { env: {} }); - expect(hasFinding(res, "gateway.auth_no_rate_limit", "warn"), testCase.name).toBe( - testCase.expectWarn, - ); - } + await Promise.all( + cases.map(async (testCase) => { + const res = await audit(testCase.cfg, { env: {} }); + expect(hasFinding(res, "gateway.auth_no_rate_limit", "warn"), testCase.name).toBe( + testCase.expectWarn, + ); + }), + ); }); it("scores dangerous gateway.tools.allow over HTTP by exposure", async () => { @@ -244,13 +246,15 @@ describe("security audit", () => { expectedSeverity: "critical", }, ]; - for (const testCase of cases) { - const res = await audit(testCase.cfg, { env: {} }); - expect( - hasFinding(res, "gateway.tools_invoke_http.dangerous_allow", testCase.expectedSeverity), - testCase.name, - ).toBe(true); - } + await Promise.all( + cases.map(async (testCase) => { + const res = await audit(testCase.cfg, { env: {} }); + expect( + hasFinding(res, "gateway.tools_invoke_http.dangerous_allow", testCase.expectedSeverity), + testCase.name, + ).toBe(true); + }), + ); }); it("warns when sandbox exec host is selected while sandbox mode is off", async () => { @@ -308,10 +312,12 @@ describe("security audit", () => { checkId: "tools.exec.host_sandbox_no_sandbox_agents", }, ]; - for (const testCase of cases) { - const res = await audit(testCase.cfg); - expect(hasFinding(res, testCase.checkId, "warn"), testCase.name).toBe(true); - } + await Promise.all( + cases.map(async (testCase) => { + const res = await audit(testCase.cfg); + expect(hasFinding(res, testCase.checkId, "warn"), testCase.name).toBe(true); + }), + ); }); it("warns for interpreter safeBins only when explicit profiles are missing", async () => { @@ -377,13 +383,15 @@ describe("security audit", () => { expected: false, }, ]; - for (const testCase of cases) { - const res = await audit(testCase.cfg); - expect( - hasFinding(res, "tools.exec.safe_bins_interpreter_unprofiled", "warn"), - testCase.name, - ).toBe(testCase.expected); - } + await Promise.all( + cases.map(async (testCase) => { + const res = await audit(testCase.cfg); + expect( + hasFinding(res, "tools.exec.safe_bins_interpreter_unprofiled", "warn"), + testCase.name, + ).toBe(testCase.expected); + }), + ); }); it("evaluates loopback control UI and logging exposure findings", async () => { @@ -430,10 +438,12 @@ describe("security audit", () => { severity: "warn", }, ]; - for (const testCase of cases) { - const res = await audit(testCase.cfg, testCase.opts); - expect(hasFinding(res, testCase.checkId, testCase.severity), testCase.name).toBe(true); - } + await Promise.all( + cases.map(async (testCase) => { + const res = await audit(testCase.cfg, testCase.opts); + expect(hasFinding(res, testCase.checkId, testCase.severity), testCase.name).toBe(true); + }), + ); }); it("treats Windows ACL-only perms as secure", async () => { @@ -705,14 +715,16 @@ describe("security audit", () => { detailIncludes: ["mistral-8b", "sandbox=all"], }, ]; - for (const testCase of cases) { - const res = await audit(testCase.cfg); - const finding = res.findings.find((f) => f.checkId === "models.small_params"); - expect(finding?.severity, testCase.name).toBe(testCase.expectedSeverity); - for (const text of testCase.detailIncludes) { - expect(finding?.detail, `${testCase.name}:${text}`).toContain(text); - } - } + await Promise.all( + cases.map(async (testCase) => { + const res = await audit(testCase.cfg); + const finding = res.findings.find((f) => f.checkId === "models.small_params"); + expect(finding?.severity, testCase.name).toBe(testCase.expectedSeverity); + for (const text of testCase.detailIncludes) { + expect(finding?.detail, `${testCase.name}:${text}`).toContain(text); + } + }), + ); }); it("checks sandbox docker mode-off findings with/without agent override", async () => { @@ -751,12 +763,14 @@ describe("security audit", () => { expectedPresent: false, }, ]; - for (const testCase of cases) { - const res = await audit(testCase.cfg); - expect(hasFinding(res, "sandbox.docker_config_mode_off"), testCase.name).toBe( - testCase.expectedPresent, - ); - } + await Promise.all( + cases.map(async (testCase) => { + const res = await audit(testCase.cfg); + expect(hasFinding(res, "sandbox.docker_config_mode_off"), testCase.name).toBe( + testCase.expectedPresent, + ); + }), + ); }); it("flags dangerous sandbox docker config (binds/network/seccomp/apparmor)", async () => { @@ -836,19 +850,21 @@ describe("security audit", () => { expectedPresent: false, }, ]; - for (const testCase of cases) { - const res = await audit(testCase.cfg); - const finding = res.findings.find( - (f) => f.checkId === "sandbox.browser_cdp_bridge_unrestricted", - ); - expect(Boolean(finding), testCase.name).toBe(testCase.expectedPresent); - if (testCase.expectedPresent) { - expect(finding?.severity, testCase.name).toBe(testCase.expectedSeverity); - if (testCase.detailIncludes) { - expect(finding?.detail, testCase.name).toContain(testCase.detailIncludes); + await Promise.all( + cases.map(async (testCase) => { + const res = await audit(testCase.cfg); + const finding = res.findings.find( + (f) => f.checkId === "sandbox.browser_cdp_bridge_unrestricted", + ); + expect(Boolean(finding), testCase.name).toBe(testCase.expectedPresent); + if (testCase.expectedPresent) { + expect(finding?.severity, testCase.name).toBe(testCase.expectedSeverity); + if (testCase.detailIncludes) { + expect(finding?.detail, testCase.name).toContain(testCase.detailIncludes); + } } - } - } + }), + ); }); it("flags ineffective gateway.nodes.denyCommands entries", async () => { @@ -898,15 +914,17 @@ describe("security audit", () => { }, ]; - for (const testCase of cases) { - const res = await audit(testCase.cfg); - const finding = res.findings.find( - (f) => f.checkId === "gateway.nodes.allow_commands_dangerous", - ); - expect(finding?.severity, testCase.name).toBe(testCase.expectedSeverity); - expect(finding?.detail, testCase.name).toContain("camera.snap"); - expect(finding?.detail, testCase.name).toContain("screen.record"); - } + await Promise.all( + cases.map(async (testCase) => { + const res = await audit(testCase.cfg); + const finding = res.findings.find( + (f) => f.checkId === "gateway.nodes.allow_commands_dangerous", + ); + expect(finding?.severity, testCase.name).toBe(testCase.expectedSeverity); + expect(finding?.detail, testCase.name).toContain("camera.snap"); + expect(finding?.detail, testCase.name).toContain("screen.record"); + }), + ); }); it("does not flag dangerous allowCommands entries when denied again", async () => { @@ -1148,13 +1166,15 @@ describe("security audit", () => { }, ]; - for (const testCase of cases) { - const res = await audit(testCase.cfg); - expect( - hasFinding(res, "gateway.real_ip_fallback_enabled", testCase.expectedSeverity), - testCase.name, - ).toBe(true); - } + await Promise.all( + cases.map(async (testCase) => { + const res = await audit(testCase.cfg); + expect( + hasFinding(res, "gateway.real_ip_fallback_enabled", testCase.expectedSeverity), + testCase.name, + ).toBe(true); + }), + ); }); it("scores mDNS full mode risk by gateway bind mode", async () => { @@ -1197,13 +1217,15 @@ describe("security audit", () => { }, ]; - for (const testCase of cases) { - const res = await audit(testCase.cfg); - expect( - hasFinding(res, "discovery.mdns_full_mode", testCase.expectedSeverity), - testCase.name, - ).toBe(true); - } + await Promise.all( + cases.map(async (testCase) => { + const res = await audit(testCase.cfg); + expect( + hasFinding(res, "discovery.mdns_full_mode", testCase.expectedSeverity), + testCase.name, + ).toBe(true); + }), + ); }); it("evaluates trusted-proxy auth guardrails", async () => { @@ -1280,17 +1302,19 @@ describe("security audit", () => { }, ]; - for (const testCase of cases) { - const res = await audit(testCase.cfg); - expect( - hasFinding(res, testCase.expectedCheckId, testCase.expectedSeverity), - testCase.name, - ).toBe(true); - if (testCase.suppressesGenericSharedSecretFindings) { - expect(hasFinding(res, "gateway.bind_no_auth"), testCase.name).toBe(false); - expect(hasFinding(res, "gateway.auth_no_rate_limit"), testCase.name).toBe(false); - } - } + await Promise.all( + cases.map(async (testCase) => { + const res = await audit(testCase.cfg); + expect( + hasFinding(res, testCase.expectedCheckId, testCase.expectedSeverity), + testCase.name, + ).toBe(true); + if (testCase.suppressesGenericSharedSecretFindings) { + expect(hasFinding(res, "gateway.bind_no_auth"), testCase.name).toBe(false); + expect(hasFinding(res, "gateway.auth_no_rate_limit"), testCase.name).toBe(false); + } + }), + ); }); it("warns when multiple DM senders share the main session", async () => { @@ -1707,15 +1731,17 @@ describe("security audit", () => { }, }, ]; - for (const testCase of cases) { - const res = await audit(cfg, { - deep: true, - deepTimeoutMs: 50, - probeGatewayFn: testCase.probeGatewayFn, - }); - testCase.assertDeep?.(res); - expect(hasFinding(res, "gateway.probe_failed", "warn"), testCase.name).toBe(true); - } + await Promise.all( + cases.map(async (testCase) => { + const res = await audit(cfg, { + deep: true, + deepTimeoutMs: 50, + probeGatewayFn: testCase.probeGatewayFn, + }); + testCase.assertDeep?.(res); + expect(hasFinding(res, "gateway.probe_failed", "warn"), testCase.name).toBe(true); + }), + ); }); it("classifies legacy and weak-tier model identifiers", async () => { @@ -1742,17 +1768,19 @@ describe("security audit", () => { expectedAbsentCheckId: "models.weak_tier", }, ]; - for (const testCase of cases) { - const res = await audit({ - agents: { defaults: { model: { primary: testCase.model } } }, - }); - for (const expected of testCase.expectedFindings ?? []) { - expect(hasFinding(res, expected.checkId, expected.severity), testCase.name).toBe(true); - } - if (testCase.expectedAbsentCheckId) { - expect(hasFinding(res, testCase.expectedAbsentCheckId), testCase.name).toBe(false); - } - } + await Promise.all( + cases.map(async (testCase) => { + const res = await audit({ + agents: { defaults: { model: { primary: testCase.model } } }, + }); + for (const expected of testCase.expectedFindings ?? []) { + expect(hasFinding(res, expected.checkId, expected.severity), testCase.name).toBe(true); + } + if (testCase.expectedAbsentCheckId) { + expect(hasFinding(res, testCase.expectedAbsentCheckId), testCase.name).toBe(false); + } + }), + ); }); it("warns when hooks token looks short", async () => { @@ -1819,16 +1847,18 @@ describe("security audit", () => { expectedSeverity: "critical", }, ]; - for (const testCase of cases) { - const res = await audit(testCase.cfg); - expect( - hasFinding(res, "hooks.request_session_key_enabled", testCase.expectedSeverity), - testCase.name, - ).toBe(true); - if (testCase.expectsPrefixesMissing) { - expect(hasFinding(res, "hooks.request_session_key_prefixes_missing", "warn")).toBe(true); - } - } + await Promise.all( + cases.map(async (testCase) => { + const res = await audit(testCase.cfg); + expect( + hasFinding(res, "hooks.request_session_key_enabled", testCase.expectedSeverity), + testCase.name, + ).toBe(true); + if (testCase.expectsPrefixesMissing) { + expect(hasFinding(res, "hooks.request_session_key_prefixes_missing", "warn")).toBe(true); + } + }), + ); }); it("scores gateway HTTP no-auth findings by exposure", async () => { @@ -1863,16 +1893,18 @@ describe("security audit", () => { }, ]; - for (const testCase of cases) { - const res = await audit(testCase.cfg, { env: {} }); - expectFinding(res, "gateway.http.no_auth", testCase.expectedSeverity); - if (testCase.detailIncludes) { - const finding = res.findings.find((entry) => entry.checkId === "gateway.http.no_auth"); - for (const text of testCase.detailIncludes) { - expect(finding?.detail, `${testCase.name}:${text}`).toContain(text); + await Promise.all( + cases.map(async (testCase) => { + const res = await audit(testCase.cfg, { env: {} }); + expectFinding(res, "gateway.http.no_auth", testCase.expectedSeverity); + if (testCase.detailIncludes) { + const finding = res.findings.find((entry) => entry.checkId === "gateway.http.no_auth"); + for (const text of testCase.detailIncludes) { + expect(finding?.detail, `${testCase.name}:${text}`).toContain(text); + } } - } - } + }), + ); }); it("does not report gateway.http.no_auth when auth mode is token", async () => { @@ -2481,18 +2513,6 @@ description: test skill }); describe("maybeProbeGateway auth selection", () => { - let envSnapshot: ReturnType; - - beforeEach(() => { - envSnapshot = captureEnv(["OPENCLAW_GATEWAY_TOKEN", "OPENCLAW_GATEWAY_PASSWORD"]); - delete process.env.OPENCLAW_GATEWAY_TOKEN; - delete process.env.OPENCLAW_GATEWAY_PASSWORD; - }); - - afterEach(() => { - envSnapshot.restore(); - }); - const makeProbeCapture = () => { let capturedAuth: { token?: string; password?: string } | undefined; return { @@ -2507,15 +2527,15 @@ description: test skill }; }; - const setProbeEnv = (env?: { token?: string; password?: string }) => { - delete process.env.OPENCLAW_GATEWAY_TOKEN; - delete process.env.OPENCLAW_GATEWAY_PASSWORD; + const makeProbeEnv = (env?: { token?: string; password?: string }) => { + const probeEnv: NodeJS.ProcessEnv = {}; if (env?.token !== undefined) { - process.env.OPENCLAW_GATEWAY_TOKEN = env.token; + probeEnv.OPENCLAW_GATEWAY_TOKEN = env.token; } if (env?.password !== undefined) { - process.env.OPENCLAW_GATEWAY_PASSWORD = env.password; + probeEnv.OPENCLAW_GATEWAY_PASSWORD = env.password; } + return probeEnv; }; it("applies token precedence across local/remote gateway modes", async () => { @@ -2577,12 +2597,18 @@ description: test skill }, ]; - for (const testCase of cases) { - setProbeEnv(testCase.env); - const { probeGatewayFn, getAuth } = makeProbeCapture(); - await audit(testCase.cfg, { deep: true, deepTimeoutMs: 50, probeGatewayFn }); - expect(getAuth()?.token, testCase.name).toBe(testCase.expectedToken); - } + await Promise.all( + cases.map(async (testCase) => { + const { probeGatewayFn, getAuth } = makeProbeCapture(); + await audit(testCase.cfg, { + deep: true, + deepTimeoutMs: 50, + probeGatewayFn, + env: makeProbeEnv(testCase.env), + }); + expect(getAuth()?.token, testCase.name).toBe(testCase.expectedToken); + }), + ); }); it("applies password precedence for remote gateways", async () => { @@ -2615,12 +2641,18 @@ description: test skill }, ]; - for (const testCase of cases) { - setProbeEnv(testCase.env); - const { probeGatewayFn, getAuth } = makeProbeCapture(); - await audit(testCase.cfg, { deep: true, deepTimeoutMs: 50, probeGatewayFn }); - expect(getAuth()?.password, testCase.name).toBe(testCase.expectedPassword); - } + await Promise.all( + cases.map(async (testCase) => { + const { probeGatewayFn, getAuth } = makeProbeCapture(); + await audit(testCase.cfg, { + deep: true, + deepTimeoutMs: 50, + probeGatewayFn, + env: makeProbeEnv(testCase.env), + }); + expect(getAuth()?.password, testCase.name).toBe(testCase.expectedPassword); + }), + ); }); }); }); diff --git a/src/security/audit.ts b/src/security/audit.ts index fdb3be9ce..e07fff189 100644 --- a/src/security/audit.ts +++ b/src/security/audit.ts @@ -763,6 +763,7 @@ function collectExecRuntimeFindings(cfg: OpenClawConfig): SecurityAuditFinding[] async function maybeProbeGateway(params: { cfg: OpenClawConfig; + env: NodeJS.ProcessEnv; timeoutMs: number; probe: typeof probeGateway; }): Promise { @@ -775,8 +776,8 @@ async function maybeProbeGateway(params: { const auth = !isRemoteMode || remoteUrlMissing - ? resolveGatewayProbeAuth({ cfg: params.cfg, mode: "local" }) - : resolveGatewayProbeAuth({ cfg: params.cfg, mode: "remote" }); + ? resolveGatewayProbeAuth({ cfg: params.cfg, env: params.env, mode: "local" }) + : resolveGatewayProbeAuth({ cfg: params.cfg, env: params.env, mode: "remote" }); const res = await params.probe({ url, auth, timeoutMs: params.timeoutMs }).catch((err) => ({ ok: false, url, @@ -874,6 +875,7 @@ export async function runSecurityAudit(opts: SecurityAuditOptions): Promise