diff --git a/src/gateway/call.test.ts b/src/gateway/call.test.ts index 57b8f8eda..bb0762671 100644 --- a/src/gateway/call.test.ts +++ b/src/gateway/call.test.ts @@ -91,6 +91,18 @@ describe("callGateway url resolution", () => { expect(lastClientOptions?.url).toBe("ws://127.0.0.1:18800"); }); + + it("uses url override in remote mode even when remote url is missing", async () => { + loadConfig.mockReturnValue({ + gateway: { mode: "remote", bind: "loopback", remote: {} }, + }); + resolveGatewayPort.mockReturnValue(18789); + pickPrimaryTailnetIPv4.mockReturnValue(undefined); + + await callGateway({ method: "health", url: "wss://override.example/ws" }); + + expect(lastClientOptions?.url).toBe("wss://override.example/ws"); + }); }); describe("buildGatewayConnectionDetails", () => { @@ -313,3 +325,43 @@ describe("callGateway password resolution", () => { expect(lastClientOptions?.password).toBe("from-env"); }); }); + +describe("callGateway token resolution", () => { + const originalEnvToken = process.env.CLAWDBOT_GATEWAY_TOKEN; + + beforeEach(() => { + loadConfig.mockReset(); + resolveGatewayPort.mockReset(); + pickPrimaryTailnetIPv4.mockReset(); + lastClientOptions = null; + startMode = "hello"; + closeCode = 1006; + closeReason = ""; + delete process.env.CLAWDBOT_GATEWAY_TOKEN; + resolveGatewayPort.mockReturnValue(18789); + pickPrimaryTailnetIPv4.mockReturnValue(undefined); + }); + + afterEach(() => { + if (originalEnvToken == null) { + delete process.env.CLAWDBOT_GATEWAY_TOKEN; + } else { + process.env.CLAWDBOT_GATEWAY_TOKEN = originalEnvToken; + } + }); + + it("uses remote token when remote mode uses url override", async () => { + process.env.CLAWDBOT_GATEWAY_TOKEN = "env-token"; + loadConfig.mockReturnValue({ + gateway: { + mode: "remote", + remote: { token: "remote-token" }, + auth: { token: "local-token" }, + }, + }); + + await callGateway({ method: "health", url: "wss://override.example/ws" }); + + expect(lastClientOptions?.token).toBe("remote-token"); + }); +}); diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index 60dd00a5f..4220c51c1 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import type { ClawdbotConfig } from "../config/config.js"; import type { ChannelPlugin } from "../channels/plugins/types.js"; @@ -251,6 +251,29 @@ describe("security audit", () => { ); }); + it("adds a warning when deep probe throws", async () => { + const cfg: ClawdbotConfig = { gateway: { mode: "local" } }; + + const res = await runSecurityAudit({ + config: cfg, + deep: true, + deepTimeoutMs: 50, + includeFilesystem: false, + includeChannelSecurity: false, + probeGatewayFn: async () => { + throw new Error("probe boom"); + }, + }); + + expect(res.deep?.gateway.ok).toBe(false); + expect(res.deep?.gateway.error).toContain("probe boom"); + expect(res.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ checkId: "gateway.probe_failed", severity: "warn" }), + ]), + ); + }); + it("warns on legacy model configuration", async () => { const cfg: ClawdbotConfig = { agents: { defaults: { model: { primary: "openai/gpt-3.5-turbo" } } }, @@ -403,6 +426,27 @@ describe("security audit", () => { }); describe("maybeProbeGateway auth selection", () => { + const originalEnvToken = process.env.CLAWDBOT_GATEWAY_TOKEN; + const originalEnvPassword = process.env.CLAWDBOT_GATEWAY_PASSWORD; + + beforeEach(() => { + delete process.env.CLAWDBOT_GATEWAY_TOKEN; + delete process.env.CLAWDBOT_GATEWAY_PASSWORD; + }); + + afterEach(() => { + if (originalEnvToken == null) { + delete process.env.CLAWDBOT_GATEWAY_TOKEN; + } else { + process.env.CLAWDBOT_GATEWAY_TOKEN = originalEnvToken; + } + if (originalEnvPassword == null) { + delete process.env.CLAWDBOT_GATEWAY_PASSWORD; + } else { + process.env.CLAWDBOT_GATEWAY_PASSWORD = originalEnvPassword; + } + }); + it("uses local auth when gateway.mode is local", async () => { let capturedAuth: { token?: string; password?: string } | undefined; const cfg: ClawdbotConfig = { @@ -437,6 +481,41 @@ describe("security audit", () => { expect(capturedAuth?.token).toBe("local-token-abc123"); }); + it("prefers env token over local config token", async () => { + process.env.CLAWDBOT_GATEWAY_TOKEN = "env-token"; + let capturedAuth: { token?: string; password?: string } | undefined; + const cfg: ClawdbotConfig = { + gateway: { + mode: "local", + auth: { token: "local-token" }, + }, + }; + + await runSecurityAudit({ + config: cfg, + deep: true, + deepTimeoutMs: 50, + includeFilesystem: false, + includeChannelSecurity: false, + probeGatewayFn: async (opts) => { + capturedAuth = opts.auth; + return { + ok: true, + url: opts.url, + connectLatencyMs: 10, + error: null, + close: null, + health: null, + status: null, + presence: null, + configSnapshot: null, + }; + }, + }); + + expect(capturedAuth?.token).toBe("env-token"); + }); + it("uses local auth when gateway.mode is undefined (default)", async () => { let capturedAuth: { token?: string; password?: string } | undefined; const cfg: ClawdbotConfig = { @@ -508,6 +587,120 @@ describe("security audit", () => { expect(capturedAuth?.token).toBe("remote-token-xyz789"); }); + it("ignores env token when gateway.mode is remote", async () => { + process.env.CLAWDBOT_GATEWAY_TOKEN = "env-token"; + let capturedAuth: { token?: string; password?: string } | undefined; + const cfg: ClawdbotConfig = { + gateway: { + mode: "remote", + auth: { token: "local-token-should-not-use" }, + remote: { + url: "ws://remote.example.com:18789", + token: "remote-token", + }, + }, + }; + + await runSecurityAudit({ + config: cfg, + deep: true, + deepTimeoutMs: 50, + includeFilesystem: false, + includeChannelSecurity: false, + probeGatewayFn: async (opts) => { + capturedAuth = opts.auth; + return { + ok: true, + url: opts.url, + connectLatencyMs: 10, + error: null, + close: null, + health: null, + status: null, + presence: null, + configSnapshot: null, + }; + }, + }); + + expect(capturedAuth?.token).toBe("remote-token"); + }); + + it("uses remote password when env is unset", async () => { + let capturedAuth: { token?: string; password?: string } | undefined; + const cfg: ClawdbotConfig = { + gateway: { + mode: "remote", + remote: { + url: "ws://remote.example.com:18789", + password: "remote-pass", + }, + }, + }; + + await runSecurityAudit({ + config: cfg, + deep: true, + deepTimeoutMs: 50, + includeFilesystem: false, + includeChannelSecurity: false, + probeGatewayFn: async (opts) => { + capturedAuth = opts.auth; + return { + ok: true, + url: opts.url, + connectLatencyMs: 10, + error: null, + close: null, + health: null, + status: null, + presence: null, + configSnapshot: null, + }; + }, + }); + + expect(capturedAuth?.password).toBe("remote-pass"); + }); + + it("prefers env password over remote password", async () => { + process.env.CLAWDBOT_GATEWAY_PASSWORD = "env-pass"; + let capturedAuth: { token?: string; password?: string } | undefined; + const cfg: ClawdbotConfig = { + gateway: { + mode: "remote", + remote: { + url: "ws://remote.example.com:18789", + password: "remote-pass", + }, + }, + }; + + await runSecurityAudit({ + config: cfg, + deep: true, + deepTimeoutMs: 50, + includeFilesystem: false, + includeChannelSecurity: false, + probeGatewayFn: async (opts) => { + capturedAuth = opts.auth; + return { + ok: true, + url: opts.url, + connectLatencyMs: 10, + error: null, + close: null, + health: null, + status: null, + presence: null, + configSnapshot: null, + }; + }, + }); + + expect(capturedAuth?.password).toBe("env-pass"); + }); + it("falls back to local auth when gateway.mode is remote but URL is missing", async () => { let capturedAuth: { token?: string; password?: string } | undefined; const cfg: ClawdbotConfig = {