diff --git a/src/config/config.legacy-config-detection.rejects-routing-allowfrom.e2e.test.ts b/src/config/config.legacy-config-detection.rejects-routing-allowfrom.e2e.test.ts index 23997c402..0e134188a 100644 --- a/src/config/config.legacy-config-detection.rejects-routing-allowfrom.e2e.test.ts +++ b/src/config/config.legacy-config-detection.rejects-routing-allowfrom.e2e.test.ts @@ -5,78 +5,104 @@ function getLegacyRouting(config: unknown) { return (config as { routing?: Record } | undefined)?.routing; } +function getChannelConfig(config: unknown, provider: string) { + const channels = (config as { channels?: Record> } | undefined) + ?.channels; + return channels?.[provider]; +} + describe("legacy config detection", () => { - it("rejects routing.allowFrom", async () => { - const res = validateConfigObject({ - routing: { allowFrom: ["+15555550123"] }, - }); - expect(res.ok).toBe(false); - if (!res.ok) { - expect(res.issues[0]?.path).toBe("routing.allowFrom"); + it("rejects legacy routing keys", async () => { + const cases = [ + { + name: "routing.allowFrom", + input: { routing: { allowFrom: ["+15555550123"] } }, + expectedPath: "routing.allowFrom", + }, + { + name: "routing.groupChat.requireMention", + input: { routing: { groupChat: { requireMention: false } } }, + expectedPath: "routing.groupChat.requireMention", + }, + ] as const; + for (const testCase of cases) { + const res = validateConfigObject(testCase.input); + expect(res.ok, testCase.name).toBe(false); + if (!res.ok) { + expect(res.issues[0]?.path, testCase.name).toBe(testCase.expectedPath); + } } }); - it("rejects routing.groupChat.requireMention", async () => { - const res = validateConfigObject({ - routing: { groupChat: { requireMention: false } }, - }); - expect(res.ok).toBe(false); - if (!res.ok) { - expect(res.issues[0]?.path).toBe("routing.groupChat.requireMention"); + + it("migrates or drops routing.allowFrom based on whatsapp configuration", async () => { + const cases = [ + { + name: "whatsapp configured", + input: { routing: { allowFrom: ["+15555550123"] }, channels: { whatsapp: {} } }, + expectedChange: "Moved routing.allowFrom → channels.whatsapp.allowFrom.", + expectWhatsappAllowFrom: true, + }, + { + name: "whatsapp missing", + input: { routing: { allowFrom: ["+15555550123"] } }, + expectedChange: "Removed routing.allowFrom (channels.whatsapp not configured).", + expectWhatsappAllowFrom: false, + }, + ] as const; + for (const testCase of cases) { + const res = migrateLegacyConfig(testCase.input); + expect(res.changes, testCase.name).toContain(testCase.expectedChange); + if (testCase.expectWhatsappAllowFrom) { + expect(res.config?.channels?.whatsapp?.allowFrom, testCase.name).toEqual(["+15555550123"]); + } else { + expect(res.config?.channels?.whatsapp, testCase.name).toBeUndefined(); + } + expect(getLegacyRouting(res.config)?.allowFrom, testCase.name).toBeUndefined(); } }); - it("migrates routing.allowFrom to channels.whatsapp.allowFrom when whatsapp configured", async () => { - const res = migrateLegacyConfig({ - routing: { allowFrom: ["+15555550123"] }, - channels: { whatsapp: {} }, - }); - expect(res.changes).toContain("Moved routing.allowFrom → channels.whatsapp.allowFrom."); - expect(res.config?.channels?.whatsapp?.allowFrom).toEqual(["+15555550123"]); - expect(getLegacyRouting(res.config)?.allowFrom).toBeUndefined(); - }); - it("drops routing.allowFrom when whatsapp missing", async () => { - const res = migrateLegacyConfig({ - routing: { allowFrom: ["+15555550123"] }, - }); - expect(res.changes).toContain("Removed routing.allowFrom (channels.whatsapp not configured)."); - expect(res.config?.channels?.whatsapp).toBeUndefined(); - expect(getLegacyRouting(res.config)?.allowFrom).toBeUndefined(); - }); - it("migrates routing.groupChat.requireMention to channels whatsapp/telegram/imessage groups when whatsapp configured", async () => { - const res = migrateLegacyConfig({ - routing: { groupChat: { requireMention: false } }, - channels: { whatsapp: {} }, - }); - expect(res.changes).toContain( - 'Moved routing.groupChat.requireMention → channels.whatsapp.groups."*".requireMention.', - ); - expect(res.changes).toContain( - 'Moved routing.groupChat.requireMention → channels.telegram.groups."*".requireMention.', - ); - expect(res.changes).toContain( - 'Moved routing.groupChat.requireMention → channels.imessage.groups."*".requireMention.', - ); - expect(res.config?.channels?.whatsapp?.groups?.["*"]?.requireMention).toBe(false); - expect(res.config?.channels?.telegram?.groups?.["*"]?.requireMention).toBe(false); - expect(res.config?.channels?.imessage?.groups?.["*"]?.requireMention).toBe(false); - expect(getLegacyRouting(res.config)?.groupChat).toBeUndefined(); - }); - it("migrates routing.groupChat.requireMention to telegram/imessage when whatsapp missing", async () => { - const res = migrateLegacyConfig({ - routing: { groupChat: { requireMention: false } }, - }); - expect(res.changes).toContain( - 'Moved routing.groupChat.requireMention → channels.telegram.groups."*".requireMention.', - ); - expect(res.changes).toContain( - 'Moved routing.groupChat.requireMention → channels.imessage.groups."*".requireMention.', - ); - expect(res.changes).not.toContain( - 'Moved routing.groupChat.requireMention → channels.whatsapp.groups."*".requireMention.', - ); - expect(res.config?.channels?.whatsapp).toBeUndefined(); - expect(res.config?.channels?.telegram?.groups?.["*"]?.requireMention).toBe(false); - expect(res.config?.channels?.imessage?.groups?.["*"]?.requireMention).toBe(false); - expect(getLegacyRouting(res.config)?.groupChat).toBeUndefined(); + + it("migrates routing.groupChat.requireMention to provider group defaults", async () => { + const cases = [ + { + name: "whatsapp configured", + input: { routing: { groupChat: { requireMention: false } }, channels: { whatsapp: {} } }, + expectWhatsapp: true, + }, + { + name: "whatsapp missing", + input: { routing: { groupChat: { requireMention: false } } }, + expectWhatsapp: false, + }, + ] as const; + for (const testCase of cases) { + const res = migrateLegacyConfig(testCase.input); + expect(res.changes, testCase.name).toContain( + 'Moved routing.groupChat.requireMention → channels.telegram.groups."*".requireMention.', + ); + expect(res.changes, testCase.name).toContain( + 'Moved routing.groupChat.requireMention → channels.imessage.groups."*".requireMention.', + ); + if (testCase.expectWhatsapp) { + expect(res.changes, testCase.name).toContain( + 'Moved routing.groupChat.requireMention → channels.whatsapp.groups."*".requireMention.', + ); + expect(res.config?.channels?.whatsapp?.groups?.["*"]?.requireMention, testCase.name).toBe( + false, + ); + } else { + expect(res.changes, testCase.name).not.toContain( + 'Moved routing.groupChat.requireMention → channels.whatsapp.groups."*".requireMention.', + ); + expect(res.config?.channels?.whatsapp, testCase.name).toBeUndefined(); + } + expect(res.config?.channels?.telegram?.groups?.["*"]?.requireMention, testCase.name).toBe( + false, + ); + expect(res.config?.channels?.imessage?.groups?.["*"]?.requireMention, testCase.name).toBe( + false, + ); + expect(getLegacyRouting(res.config)?.groupChat, testCase.name).toBeUndefined(); + } }); it("migrates routing.groupChat.mentionPatterns to messages.groupChat.mentionPatterns", async () => { const res = migrateLegacyConfig({ @@ -346,247 +372,238 @@ describe("legacy config detection", () => { expect(validated.config.gateway?.bind).toBe("tailnet"); } }); - it('rejects telegram.dmPolicy="open" without allowFrom "*"', async () => { - const res = validateConfigObject({ - channels: { telegram: { dmPolicy: "open", allowFrom: ["123456789"] } }, - }); - expect(res.ok).toBe(false); - if (!res.ok) { - expect(res.issues[0]?.path).toBe("channels.telegram.allowFrom"); + it('enforces dmPolicy="open" allowFrom wildcard for supported providers', async () => { + const cases = [ + { + provider: "telegram", + allowFrom: ["123456789"], + expectedIssuePath: "channels.telegram.allowFrom", + }, + { + provider: "whatsapp", + allowFrom: ["+15555550123"], + expectedIssuePath: "channels.whatsapp.allowFrom", + }, + { + provider: "signal", + allowFrom: ["+15555550123"], + expectedIssuePath: "channels.signal.allowFrom", + }, + { + provider: "imessage", + allowFrom: ["+15555550123"], + expectedIssuePath: "channels.imessage.allowFrom", + }, + ] as const; + for (const testCase of cases) { + const res = validateConfigObject({ + channels: { + [testCase.provider]: { dmPolicy: "open", allowFrom: testCase.allowFrom }, + }, + }); + expect(res.ok, testCase.provider).toBe(false); + if (!res.ok) { + expect(res.issues[0]?.path, testCase.provider).toBe(testCase.expectedIssuePath); + } } }); - it('accepts telegram.dmPolicy="open" with allowFrom "*"', async () => { - const res = validateConfigObject({ - channels: { telegram: { dmPolicy: "open", allowFrom: ["*"] } }, - }); - expect(res.ok).toBe(true); - if (res.ok) { - expect(res.config.channels?.telegram?.dmPolicy).toBe("open"); + + it('accepts dmPolicy="open" when allowFrom includes wildcard', async () => { + const providers = ["telegram", "whatsapp", "signal"] as const; + for (const provider of providers) { + const res = validateConfigObject({ + channels: { [provider]: { dmPolicy: "open", allowFrom: ["*"] } }, + }); + expect(res.ok, provider).toBe(true); + if (res.ok) { + const channel = getChannelConfig(res.config, provider); + expect(channel?.dmPolicy, provider).toBe("open"); + } } }); - it("defaults telegram.dmPolicy to pairing when telegram section exists", async () => { - const res = validateConfigObject({ channels: { telegram: {} } }); - expect(res.ok).toBe(true); - if (res.ok) { - expect(res.config.channels?.telegram?.dmPolicy).toBe("pairing"); + + it("defaults dm/group policy for configured providers", async () => { + const providers = ["telegram", "whatsapp", "signal"] as const; + for (const provider of providers) { + const res = validateConfigObject({ channels: { [provider]: {} } }); + expect(res.ok, provider).toBe(true); + if (res.ok) { + const channel = getChannelConfig(res.config, provider); + expect(channel?.dmPolicy, provider).toBe("pairing"); + expect(channel?.groupPolicy, provider).toBe("allowlist"); + if (provider === "telegram") { + expect(channel?.streaming, provider).toBe("off"); + expect(channel?.streamMode, provider).toBeUndefined(); + } + } } }); - it("defaults telegram.groupPolicy to allowlist when telegram section exists", async () => { - const res = validateConfigObject({ channels: { telegram: {} } }); - expect(res.ok).toBe(true); - if (res.ok) { - expect(res.config.channels?.telegram?.groupPolicy).toBe("allowlist"); - } - }); - it("defaults telegram.streaming to off when telegram section exists", async () => { - const res = validateConfigObject({ channels: { telegram: {} } }); - expect(res.ok).toBe(true); - if (res.ok) { - expect(res.config.channels?.telegram?.streaming).toBe("off"); - expect(res.config.channels?.telegram?.streamMode).toBeUndefined(); - } - }); - it("migrates legacy telegram.streamMode=off to streaming=off", async () => { - const res = validateConfigObject({ channels: { telegram: { streamMode: "off" } } }); - expect(res.ok).toBe(true); - if (res.ok) { - expect(res.config.channels?.telegram?.streaming).toBe("off"); - expect(res.config.channels?.telegram?.streamMode).toBeUndefined(); - } - }); - it("migrates legacy telegram.streamMode=block to streaming=block", async () => { - const res = validateConfigObject({ channels: { telegram: { streamMode: "block" } } }); - expect(res.ok).toBe(true); - if (res.ok) { - expect(res.config.channels?.telegram?.streaming).toBe("block"); - expect(res.config.channels?.telegram?.streamMode).toBeUndefined(); - } - }); - it("migrates legacy telegram.accounts.*.streamMode to streaming", async () => { - const res = validateConfigObject({ - channels: { - telegram: { - accounts: { - ops: { - streamMode: "off", + it("normalizes telegram legacy streamMode aliases", async () => { + const cases = [ + { + name: "top-level off", + input: { channels: { telegram: { streamMode: "off" } } }, + expectedTopLevel: "off", + }, + { + name: "top-level block", + input: { channels: { telegram: { streamMode: "block" } } }, + expectedTopLevel: "block", + }, + { + name: "per-account off", + input: { + channels: { + telegram: { + accounts: { + ops: { + streamMode: "off", + }, + }, }, }, }, + expectedAccountStreaming: "off", }, - }); - expect(res.ok).toBe(true); - if (res.ok) { - expect(res.config.channels?.telegram?.accounts?.ops?.streaming).toBe("off"); - expect(res.config.channels?.telegram?.accounts?.ops?.streamMode).toBeUndefined(); + ] as const; + for (const testCase of cases) { + const res = validateConfigObject(testCase.input); + expect(res.ok, testCase.name).toBe(true); + if (res.ok) { + if (testCase.expectedTopLevel !== undefined) { + expect(res.config.channels?.telegram?.streaming, testCase.name).toBe( + testCase.expectedTopLevel, + ); + expect(res.config.channels?.telegram?.streamMode, testCase.name).toBeUndefined(); + } + if (testCase.expectedAccountStreaming !== undefined) { + expect(res.config.channels?.telegram?.accounts?.ops?.streaming, testCase.name).toBe( + testCase.expectedAccountStreaming, + ); + expect( + res.config.channels?.telegram?.accounts?.ops?.streamMode, + testCase.name, + ).toBeUndefined(); + } + } } }); - it("normalizes channels.discord.streaming booleans in legacy migration", async () => { - const res = migrateLegacyConfig({ - channels: { - discord: { - streaming: true, - }, + + it("normalizes discord streaming fields during legacy migration", async () => { + const cases = [ + { + name: "boolean streaming=true", + input: { channels: { discord: { streaming: true } } }, + expectedChanges: ["Normalized channels.discord.streaming boolean → enum (partial)."], + expectedStreaming: "partial", }, - }); - expect(res.changes).toContain( - "Normalized channels.discord.streaming boolean → enum (partial).", - ); - expect(res.config?.channels?.discord?.streaming).toBe("partial"); - expect(res.config?.channels?.discord?.streamMode).toBeUndefined(); - }); - it("migrates channels.discord.streamMode to channels.discord.streaming in legacy migration", async () => { - const res = migrateLegacyConfig({ - channels: { - discord: { - streaming: false, - streamMode: "block", - }, + { + name: "streamMode with streaming boolean", + input: { channels: { discord: { streaming: false, streamMode: "block" } } }, + expectedChanges: [ + "Moved channels.discord.streamMode → channels.discord.streaming (block).", + "Normalized channels.discord.streaming boolean → enum (block).", + ], + expectedStreaming: "block", }, - }); - expect(res.changes).toContain( - "Moved channels.discord.streamMode → channels.discord.streaming (block).", - ); - expect(res.changes).toContain("Normalized channels.discord.streaming boolean → enum (block)."); - expect(res.config?.channels?.discord?.streaming).toBe("block"); - expect(res.config?.channels?.discord?.streamMode).toBeUndefined(); - }); - it("migrates discord.streaming=true to streaming=partial", async () => { - const res = validateConfigObject({ channels: { discord: { streaming: true } } }); - expect(res.ok).toBe(true); - if (res.ok) { - expect(res.config.channels?.discord?.streaming).toBe("partial"); - expect(res.config.channels?.discord?.streamMode).toBeUndefined(); + ] as const; + for (const testCase of cases) { + const res = migrateLegacyConfig(testCase.input); + for (const expectedChange of testCase.expectedChanges) { + expect(res.changes, testCase.name).toContain(expectedChange); + } + expect(res.config?.channels?.discord?.streaming, testCase.name).toBe( + testCase.expectedStreaming, + ); + expect(res.config?.channels?.discord?.streamMode, testCase.name).toBeUndefined(); } }); - it("migrates discord.streaming=false to streaming=off", async () => { - const res = validateConfigObject({ channels: { discord: { streaming: false } } }); - expect(res.ok).toBe(true); - if (res.ok) { - expect(res.config.channels?.discord?.streaming).toBe("off"); - expect(res.config.channels?.discord?.streamMode).toBeUndefined(); + + it("normalizes discord streaming fields during validation", async () => { + const cases = [ + { + name: "streaming=true", + input: { channels: { discord: { streaming: true } } }, + expectedStreaming: "partial", + }, + { + name: "streaming=false", + input: { channels: { discord: { streaming: false } } }, + expectedStreaming: "off", + }, + { + name: "streamMode overrides streaming boolean", + input: { channels: { discord: { streamMode: "block", streaming: false } } }, + expectedStreaming: "block", + }, + ] as const; + for (const testCase of cases) { + const res = validateConfigObject(testCase.input); + expect(res.ok, testCase.name).toBe(true); + if (res.ok) { + expect(res.config.channels?.discord?.streaming, testCase.name).toBe( + testCase.expectedStreaming, + ); + expect(res.config.channels?.discord?.streamMode, testCase.name).toBeUndefined(); + } } }); - it("keeps explicit discord.streamMode and normalizes to streaming", async () => { - const res = validateConfigObject({ - channels: { discord: { streamMode: "block", streaming: false } }, - }); - expect(res.ok).toBe(true); - if (res.ok) { - expect(res.config.channels?.discord?.streaming).toBe("block"); - expect(res.config.channels?.discord?.streamMode).toBeUndefined(); - } - }); - it("migrates discord.accounts.*.streaming alias to streaming enum", async () => { - const res = validateConfigObject({ - channels: { - discord: { - accounts: { - work: { - streaming: true, + it("normalizes account-level discord and slack streaming aliases", async () => { + const cases = [ + { + name: "discord account streaming boolean", + input: { + channels: { + discord: { + accounts: { + work: { + streaming: true, + }, + }, }, }, }, - }, - }); - expect(res.ok).toBe(true); - if (res.ok) { - expect(res.config.channels?.discord?.accounts?.work?.streaming).toBe("partial"); - expect(res.config.channels?.discord?.accounts?.work?.streamMode).toBeUndefined(); - } - }); - it("migrates slack.streamMode values to slack.streaming enum", async () => { - const res = validateConfigObject({ - channels: { - slack: { - streamMode: "status_final", + assert: (config: NonNullable) => { + expect(config.channels?.discord?.accounts?.work?.streaming).toBe("partial"); + expect(config.channels?.discord?.accounts?.work?.streamMode).toBeUndefined(); }, }, - }); - expect(res.ok).toBe(true); - if (res.ok) { - expect(res.config.channels?.slack?.streaming).toBe("progress"); - expect(res.config.channels?.slack?.streamMode).toBeUndefined(); - expect(res.config.channels?.slack?.nativeStreaming).toBe(true); - } - }); - it("migrates legacy slack.streaming boolean to nativeStreaming", async () => { - const res = validateConfigObject({ - channels: { - slack: { - streaming: false, + { + name: "slack streamMode alias", + input: { + channels: { + slack: { + streamMode: "status_final", + }, + }, + }, + assert: (config: NonNullable) => { + expect(config.channels?.slack?.streaming).toBe("progress"); + expect(config.channels?.slack?.streamMode).toBeUndefined(); + expect(config.channels?.slack?.nativeStreaming).toBe(true); }, }, - }); - expect(res.ok).toBe(true); - if (res.ok) { - expect(res.config.channels?.slack?.streaming).toBe("partial"); - expect(res.config.channels?.slack?.nativeStreaming).toBe(false); - } - }); - it('rejects whatsapp.dmPolicy="open" without allowFrom "*"', async () => { - const res = validateConfigObject({ - channels: { - whatsapp: { dmPolicy: "open", allowFrom: ["+15555550123"] }, + { + name: "slack streaming boolean legacy", + input: { + channels: { + slack: { + streaming: false, + }, + }, + }, + assert: (config: NonNullable) => { + expect(config.channels?.slack?.streaming).toBe("partial"); + expect(config.channels?.slack?.nativeStreaming).toBe(false); + }, }, - }); - expect(res.ok).toBe(false); - if (!res.ok) { - expect(res.issues[0]?.path).toBe("channels.whatsapp.allowFrom"); - } - }); - it('accepts whatsapp.dmPolicy="open" with allowFrom "*"', async () => { - const res = validateConfigObject({ - channels: { whatsapp: { dmPolicy: "open", allowFrom: ["*"] } }, - }); - expect(res.ok).toBe(true); - if (res.ok) { - expect(res.config.channels?.whatsapp?.dmPolicy).toBe("open"); - } - }); - it("defaults whatsapp.dmPolicy to pairing when whatsapp section exists", async () => { - const res = validateConfigObject({ channels: { whatsapp: {} } }); - expect(res.ok).toBe(true); - if (res.ok) { - expect(res.config.channels?.whatsapp?.dmPolicy).toBe("pairing"); - } - }); - it("defaults whatsapp.groupPolicy to allowlist when whatsapp section exists", async () => { - const res = validateConfigObject({ channels: { whatsapp: {} } }); - expect(res.ok).toBe(true); - if (res.ok) { - expect(res.config.channels?.whatsapp?.groupPolicy).toBe("allowlist"); - } - }); - it('rejects signal.dmPolicy="open" without allowFrom "*"', async () => { - const res = validateConfigObject({ - channels: { signal: { dmPolicy: "open", allowFrom: ["+15555550123"] } }, - }); - expect(res.ok).toBe(false); - if (!res.ok) { - expect(res.issues[0]?.path).toBe("channels.signal.allowFrom"); - } - }); - it('accepts signal.dmPolicy="open" with allowFrom "*"', async () => { - const res = validateConfigObject({ - channels: { signal: { dmPolicy: "open", allowFrom: ["*"] } }, - }); - expect(res.ok).toBe(true); - if (res.ok) { - expect(res.config.channels?.signal?.dmPolicy).toBe("open"); - } - }); - it("defaults signal.dmPolicy to pairing when signal section exists", async () => { - const res = validateConfigObject({ channels: { signal: {} } }); - expect(res.ok).toBe(true); - if (res.ok) { - expect(res.config.channels?.signal?.dmPolicy).toBe("pairing"); - } - }); - it("defaults signal.groupPolicy to allowlist when signal section exists", async () => { - const res = validateConfigObject({ channels: { signal: {} } }); - expect(res.ok).toBe(true); - if (res.ok) { - expect(res.config.channels?.signal?.groupPolicy).toBe("allowlist"); + ] as const; + for (const testCase of cases) { + const res = validateConfigObject(testCase.input); + expect(res.ok, testCase.name).toBe(true); + if (res.ok) { + testCase.assert(res.config); + } } }); it("accepts historyLimit overrides per provider and account", async () => { @@ -616,15 +633,4 @@ describe("legacy config detection", () => { expect(res.config.channels?.discord?.historyLimit).toBe(3); } }); - it('rejects imessage.dmPolicy="open" without allowFrom "*"', async () => { - const res = validateConfigObject({ - channels: { - imessage: { dmPolicy: "open", allowFrom: ["+15555550123"] }, - }, - }); - expect(res.ok).toBe(false); - if (!res.ok) { - expect(res.issues[0]?.path).toBe("channels.imessage.allowFrom"); - } - }); }); diff --git a/src/memory/qmd-manager.test.ts b/src/memory/qmd-manager.test.ts index ff69b5a16..84740266b 100644 --- a/src/memory/qmd-manager.test.ts +++ b/src/memory/qmd-manager.test.ts @@ -1212,40 +1212,52 @@ describe("QmdMemoryManager", () => { readFileSpy.mockRestore(); }); - it("returns empty text when a qmd workspace file does not exist", async () => { - const { manager } = await createManager(); - const result = await manager.readFile({ relPath: "ghost.md" }); - expect(result).toEqual({ text: "", path: "ghost.md" }); - await manager.close(); - }); - - it("returns empty text when a qmd file disappears before partial read", async () => { + it("returns empty text when qmd files are missing before or during read", async () => { const relPath = "qmd-window.md"; const absPath = path.join(workspaceDir, relPath); await fs.writeFile(absPath, "one\ntwo\nthree", "utf-8"); - const { manager } = await createManager(); + const cases = [ + { + name: "missing before read", + request: { relPath: "ghost.md" }, + expectedPath: "ghost.md", + }, + { + name: "disappears before partial read", + request: { relPath, from: 2, lines: 1 }, + expectedPath: relPath, + installOpenSpy: () => { + const realOpen = fs.open; + let injected = false; + const openSpy = vi + .spyOn(fs, "open") + .mockImplementation(async (...args: Parameters) => { + const [target, options] = args; + if (!injected && typeof target === "string" && path.resolve(target) === absPath) { + injected = true; + const err = new Error("gone") as NodeJS.ErrnoException; + err.code = "ENOENT"; + throw err; + } + return realOpen(target, options); + }); + return () => openSpy.mockRestore(); + }, + }, + ] as const; - const realOpen = fs.open; - let injected = false; - const openSpy = vi - .spyOn(fs, "open") - .mockImplementation(async (...args: Parameters) => { - const [target, options] = args; - if (!injected && typeof target === "string" && path.resolve(target) === absPath) { - injected = true; - const err = new Error("gone") as NodeJS.ErrnoException; - err.code = "ENOENT"; - throw err; - } - return realOpen(target, options); - }); - - const result = await manager.readFile({ relPath, from: 2, lines: 1 }); - expect(result).toEqual({ text: "", path: relPath }); - - openSpy.mockRestore(); - await manager.close(); + for (const testCase of cases) { + const { manager } = await createManager(); + const restoreOpen = testCase.installOpenSpy?.(); + try { + const result = await manager.readFile(testCase.request); + expect(result, testCase.name).toEqual({ text: "", path: testCase.expectedPath }); + } finally { + restoreOpen?.(); + await manager.close(); + } + } }); it("reuses exported session markdown files when inputs are unchanged", async () => { @@ -1295,67 +1307,86 @@ describe("QmdMemoryManager", () => { writeFileSpy.mockRestore(); }); - it("throws when sqlite index is busy", async () => { - const { manager } = await createManager(); - const inner = manager as unknown as { - db: { - prepare: () => { - all: () => never; - get: () => never; - }; - close: () => void; - } | null; - resolveDocLocation: (docid?: string) => Promise; - }; - const busyStmt: { all: () => never; get: () => never } = { - all: () => { - throw new Error("SQLITE_BUSY: database is locked"); - }, - get: () => { - throw new Error("SQLITE_BUSY: database is locked"); - }, - }; - - inner.db = { - prepare: () => busyStmt, - close: () => {}, - }; - await expect(inner.resolveDocLocation("abc123")).rejects.toThrow( - "qmd index busy while reading results", - ); - await manager.close(); - }); - - it("fails search when sqlite index is busy so caller can fallback", async () => { - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args[0] === "search") { - const child = createMockChild({ autoClose: false }); - emitAndClose( - child, - "stdout", - JSON.stringify([{ docid: "abc123", score: 1, snippet: "@@ -1,1\nremember this" }]), - ); - return child; - } - return createMockChild(); - }); - - const { manager } = await createManager(); - const inner = manager as unknown as { - db: { prepare: () => { all: () => never }; close: () => void } | null; - }; - inner.db = { - prepare: () => ({ - all: () => { - throw new Error("SQLITE_BUSY: database is locked"); + it("fails closed when sqlite index is busy during doc lookup or search", async () => { + const cases = [ + { + name: "resolveDocLocation", + run: async (manager: QmdMemoryManager) => { + const inner = manager as unknown as { + db: { + prepare: () => { + all: () => never; + get: () => never; + }; + close: () => void; + } | null; + resolveDocLocation: (docid?: string) => Promise; + }; + const busyStmt: { all: () => never; get: () => never } = { + all: () => { + throw new Error("SQLITE_BUSY: database is locked"); + }, + get: () => { + throw new Error("SQLITE_BUSY: database is locked"); + }, + }; + inner.db = { + prepare: () => busyStmt, + close: () => {}, + }; + await expect(inner.resolveDocLocation("abc123")).rejects.toThrow( + "qmd index busy while reading results", + ); }, - }), - close: () => {}, - }; - await expect( - manager.search("busy lookup", { sessionKey: "agent:main:slack:dm:u123" }), - ).rejects.toThrow("qmd index busy while reading results"); - await manager.close(); + }, + { + name: "search", + run: async (manager: QmdMemoryManager) => { + spawnMock.mockImplementation((_cmd: string, args: string[]) => { + if (args[0] === "search") { + const child = createMockChild({ autoClose: false }); + emitAndClose( + child, + "stdout", + JSON.stringify([{ docid: "abc123", score: 1, snippet: "@@ -1,1\nremember this" }]), + ); + return child; + } + return createMockChild(); + }); + const inner = manager as unknown as { + db: { prepare: () => { all: () => never }; close: () => void } | null; + }; + inner.db = { + prepare: () => ({ + all: () => { + throw new Error("SQLITE_BUSY: database is locked"); + }, + }), + close: () => {}, + }; + await expect( + manager.search("busy lookup", { sessionKey: "agent:main:slack:dm:u123" }), + ).rejects.toThrow("qmd index busy while reading results"); + }, + }, + ] as const; + + for (const testCase of cases) { + spawnMock.mockReset(); + spawnMock.mockImplementation(() => createMockChild()); + const { manager } = await createManager(); + try { + await testCase.run(manager); + } catch (error) { + throw new Error( + `${testCase.name}: ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, + ); + } finally { + await manager.close(); + } + } }); it("prefers exact docid match before prefix fallback for qmd document lookups", async () => { @@ -1581,56 +1612,68 @@ describe("QmdMemoryManager", () => { } }); - it("symlinks default model cache into custom XDG_CACHE_HOME on first run", async () => { - const { manager } = await createManager({ mode: "full" }); - expect(manager).toBeTruthy(); + it("handles first-run symlink, existing dir preservation, and missing default cache", async () => { + const cases: Array<{ + name: string; + setup?: () => Promise; + assert: () => Promise; + }> = [ + { + name: "symlinks default cache on first run", + assert: async () => { + const stat = await fs.lstat(customModelsDir); + expect(stat.isSymbolicLink()).toBe(true); + const target = await fs.readlink(customModelsDir); + expect(target).toBe(defaultModelsDir); + const content = await fs.readFile(path.join(customModelsDir, "model.bin"), "utf-8"); + expect(content).toBe("fake-model"); + }, + }, + { + name: "does not overwrite existing models directory", + setup: async () => { + await fs.mkdir(customModelsDir, { recursive: true }); + await fs.writeFile(path.join(customModelsDir, "custom-model.bin"), "custom"); + }, + assert: async () => { + const stat = await fs.lstat(customModelsDir); + expect(stat.isSymbolicLink()).toBe(false); + expect(stat.isDirectory()).toBe(true); + const content = await fs.readFile( + path.join(customModelsDir, "custom-model.bin"), + "utf-8", + ); + expect(content).toBe("custom"); + }, + }, + { + name: "skips symlink when default models are absent", + setup: async () => { + await fs.rm(defaultModelsDir, { recursive: true, force: true }); + }, + assert: async () => { + await expect(fs.lstat(customModelsDir)).rejects.toThrow(); + expect(logWarnMock).not.toHaveBeenCalledWith( + expect.stringContaining("failed to symlink qmd models directory"), + ); + }, + }, + ]; - const stat = await fs.lstat(customModelsDir); - expect(stat.isSymbolicLink()).toBe(true); - const target = await fs.readlink(customModelsDir); - expect(target).toBe(defaultModelsDir); - - // Models are accessible through the symlink. - const content = await fs.readFile(path.join(customModelsDir, "model.bin"), "utf-8"); - expect(content).toBe("fake-model"); - - await manager.close(); - }); - - it("does not overwrite existing models directory", async () => { - // Pre-create the custom models dir with different content. - await fs.mkdir(customModelsDir, { recursive: true }); - await fs.writeFile(path.join(customModelsDir, "custom-model.bin"), "custom"); - - const { manager } = await createManager({ mode: "full" }); - expect(manager).toBeTruthy(); - - // Should still be a real directory, not a symlink. - const stat = await fs.lstat(customModelsDir); - expect(stat.isSymbolicLink()).toBe(false); - expect(stat.isDirectory()).toBe(true); - - // Custom content should be preserved. - const content = await fs.readFile(path.join(customModelsDir, "custom-model.bin"), "utf-8"); - expect(content).toBe("custom"); - - await manager.close(); - }); - - it("skips symlink when no default models exist", async () => { - // Remove the default models dir. - await fs.rm(defaultModelsDir, { recursive: true, force: true }); - - const { manager } = await createManager({ mode: "full" }); - expect(manager).toBeTruthy(); - - // Custom models dir should not exist (no symlink created). - await expect(fs.lstat(customModelsDir)).rejects.toThrow(); - expect(logWarnMock).not.toHaveBeenCalledWith( - expect.stringContaining("failed to symlink qmd models directory"), - ); - - await manager.close(); + for (const testCase of cases) { + await fs.rm(customModelsDir, { recursive: true, force: true }); + await fs.mkdir(defaultModelsDir, { recursive: true }); + await fs.writeFile(path.join(defaultModelsDir, "model.bin"), "fake-model"); + logWarnMock.mockReset(); + await testCase.setup?.(); + const { manager } = await createManager({ mode: "full" }); + expect(manager, testCase.name).toBeTruthy(); + try { + await testCase.assert(); + } finally { + await manager.close(); + } + } }); }); }); diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index 77881612b..303bc55ce 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -182,32 +182,42 @@ describe("security audit", () => { expect(hasFinding(res, "gateway.auth_no_rate_limit", "warn")).toBe(true); }); - it("warns when gateway.tools.allow re-enables dangerous HTTP /tools/invoke tools (loopback)", async () => { - const cfg: OpenClawConfig = { - gateway: { - bind: "loopback", - auth: { token: "secret" }, - tools: { allow: ["sessions_spawn"] }, + it("scores dangerous gateway.tools.allow over HTTP by exposure", async () => { + const cases: Array<{ + name: string; + cfg: OpenClawConfig; + expectedSeverity: "warn" | "critical"; + }> = [ + { + name: "loopback bind", + cfg: { + gateway: { + bind: "loopback", + auth: { token: "secret" }, + tools: { allow: ["sessions_spawn"] }, + }, + }, + expectedSeverity: "warn", }, - }; - - const res = await audit(cfg, { env: {} }); - - expect(hasFinding(res, "gateway.tools_invoke_http.dangerous_allow", "warn")).toBe(true); - }); - - it("flags dangerous gateway.tools.allow over HTTP as critical when gateway binds beyond loopback", async () => { - const cfg: OpenClawConfig = { - gateway: { - bind: "lan", - auth: { token: "secret" }, - tools: { allow: ["sessions_spawn", "gateway"] }, + { + name: "non-loopback bind", + cfg: { + gateway: { + bind: "lan", + auth: { token: "secret" }, + tools: { allow: ["sessions_spawn", "gateway"] }, + }, + }, + expectedSeverity: "critical", }, - }; - - const res = await audit(cfg, { env: {} }); - - expect(hasFinding(res, "gateway.tools_invoke_http.dangerous_allow", "critical")).toBe(true); + ]; + 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); + } }); it("does not warn for auth rate limiting when configured", async () => { @@ -572,88 +582,88 @@ describe("security audit", () => { expect(res.findings.some((f) => f.checkId === "fs.config.perms_group_readable")).toBe(false); }); - it("warns when small models are paired with web/browser tools", async () => { - const cfg: OpenClawConfig = { - agents: { defaults: { model: { primary: "ollama/mistral-8b" } } }, - tools: { - web: { - search: { enabled: true }, - fetch: { enabled: true }, + it("scores small-model risk by tool/sandbox exposure", async () => { + const cases: Array<{ + name: string; + cfg: OpenClawConfig; + expectedSeverity: "info" | "critical"; + detailIncludes: string[]; + }> = [ + { + name: "small model with web and browser enabled", + cfg: { + agents: { defaults: { model: { primary: "ollama/mistral-8b" } } }, + tools: { web: { search: { enabled: true }, fetch: { enabled: true } } }, + browser: { enabled: true }, }, + expectedSeverity: "critical", + detailIncludes: ["mistral-8b", "web_search", "web_fetch", "browser"], }, - browser: { enabled: true }, - }; - - const res = await audit(cfg); - - const finding = res.findings.find((f) => f.checkId === "models.small_params"); - expect(finding?.severity).toBe("critical"); - expect(finding?.detail).toContain("mistral-8b"); - expect(finding?.detail).toContain("web_search"); - expect(finding?.detail).toContain("web_fetch"); - expect(finding?.detail).toContain("browser"); + { + name: "small model with sandbox all and web/browser disabled", + cfg: { + agents: { + defaults: { model: { primary: "ollama/mistral-8b" }, sandbox: { mode: "all" } }, + }, + tools: { web: { search: { enabled: false }, fetch: { enabled: false } } }, + browser: { enabled: false }, + }, + expectedSeverity: "info", + 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); + } + } }); - it("treats small models as safe when sandbox is on and web tools are disabled", async () => { - const cfg: OpenClawConfig = { - agents: { defaults: { model: { primary: "ollama/mistral-8b" }, sandbox: { mode: "all" } } }, - tools: { - web: { - search: { enabled: false }, - fetch: { enabled: false }, - }, - }, - browser: { enabled: false }, - }; - - const res = await audit(cfg); - - const finding = res.findings.find((f) => f.checkId === "models.small_params"); - expect(finding?.severity).toBe("info"); - expect(finding?.detail).toContain("mistral-8b"); - expect(finding?.detail).toContain("sandbox=all"); - }); - - it("flags sandbox docker config when sandbox mode is off", async () => { - const cfg: OpenClawConfig = { - agents: { - defaults: { - sandbox: { - mode: "off", - docker: { image: "ghcr.io/example/sandbox:latest" }, + it("checks sandbox docker mode-off findings with/without agent override", async () => { + const cases: Array<{ + name: string; + cfg: OpenClawConfig; + expectedPresent: boolean; + }> = [ + { + name: "mode off with docker config only", + cfg: { + agents: { + defaults: { + sandbox: { + mode: "off", + docker: { image: "ghcr.io/example/sandbox:latest" }, + }, + }, }, }, + expectedPresent: true, }, - }; - - const res = await audit(cfg); - - expect(res.findings).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - checkId: "sandbox.docker_config_mode_off", - severity: "warn", - }), - ]), - ); - }); - - it("does not flag global sandbox docker config when an agent enables sandbox mode", async () => { - const cfg: OpenClawConfig = { - agents: { - defaults: { - sandbox: { - mode: "off", - docker: { image: "ghcr.io/example/sandbox:latest" }, + { + name: "agent enables sandbox mode", + cfg: { + agents: { + defaults: { + sandbox: { + mode: "off", + docker: { image: "ghcr.io/example/sandbox:latest" }, + }, + }, + list: [{ id: "ops", sandbox: { mode: "all" } }], }, }, - list: [{ id: "ops", sandbox: { mode: "all" } }], + expectedPresent: false, }, - }; - - const res = await audit(cfg); - - expect(hasFinding(res, "sandbox.docker_config_mode_off")).toBe(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, + ); + } }); it("flags dangerous sandbox docker config (binds/network/seccomp/apparmor)", async () => { @@ -694,45 +704,58 @@ describe("security audit", () => { ); }); - it("warns when sandbox browser uses bridge network without cdpSourceRange", async () => { - const cfg: OpenClawConfig = { - agents: { - defaults: { - sandbox: { - mode: "all", - browser: { - enabled: true, - network: "bridge", + it("checks sandbox browser bridge-network restrictions", async () => { + const cases: Array<{ + name: string; + cfg: OpenClawConfig; + expectedPresent: boolean; + expectedSeverity?: "warn"; + detailIncludes?: string; + }> = [ + { + name: "bridge without cdpSourceRange", + cfg: { + agents: { + defaults: { + sandbox: { + mode: "all", + browser: { enabled: true, network: "bridge" }, + }, }, }, }, + expectedPresent: true, + expectedSeverity: "warn", + detailIncludes: "agents.defaults.sandbox.browser", }, - }; - - const res = await audit(cfg); - const finding = res.findings.find( - (f) => f.checkId === "sandbox.browser_cdp_bridge_unrestricted", - ); - expect(finding?.severity).toBe("warn"); - expect(finding?.detail).toContain("agents.defaults.sandbox.browser"); - }); - - it("does not warn when sandbox browser uses dedicated default network", async () => { - const cfg: OpenClawConfig = { - agents: { - defaults: { - sandbox: { - mode: "all", - browser: { - enabled: true, + { + name: "dedicated default network", + cfg: { + agents: { + defaults: { + sandbox: { + mode: "all", + browser: { enabled: true }, + }, }, }, }, + expectedPresent: false, }, - }; - - const res = await audit(cfg); - expect(hasFinding(res, "sandbox.browser_cdp_bridge_unrestricted")).toBe(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); + } + } + } }); it("flags ineffective gateway.nodes.denyCommands entries", async () => { @@ -929,109 +952,91 @@ describe("security audit", () => { expect(finding?.detail).toContain("tools.exec.applyPatch.workspaceOnly=false"); }); - it("flags trusted-proxy auth mode without generic shared-secret findings", async () => { - const cfg: OpenClawConfig = { - gateway: { - bind: "lan", - trustedProxies: ["10.0.0.1"], - auth: { - mode: "trusted-proxy", - trustedProxy: { - userHeader: "x-forwarded-user", + it("evaluates trusted-proxy auth guardrails", async () => { + const cases: Array<{ + name: string; + cfg: OpenClawConfig; + expectedCheckId: string; + expectedSeverity: "warn" | "critical"; + suppressesGenericSharedSecretFindings?: boolean; + }> = [ + { + name: "trusted-proxy base mode", + cfg: { + gateway: { + bind: "lan", + trustedProxies: ["10.0.0.1"], + auth: { + mode: "trusted-proxy", + trustedProxy: { userHeader: "x-forwarded-user" }, + }, }, }, + expectedCheckId: "gateway.trusted_proxy_auth", + expectedSeverity: "critical", + suppressesGenericSharedSecretFindings: true, }, - }; - - const res = await audit(cfg); - - expect(res.findings).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - checkId: "gateway.trusted_proxy_auth", - severity: "critical", - }), - ]), - ); - expect(hasFinding(res, "gateway.bind_no_auth")).toBe(false); - expect(hasFinding(res, "gateway.auth_no_rate_limit")).toBe(false); - }); - - it("flags trusted-proxy auth without trustedProxies configured", async () => { - const cfg: OpenClawConfig = { - gateway: { - bind: "lan", - trustedProxies: [], - auth: { - mode: "trusted-proxy", - trustedProxy: { - userHeader: "x-forwarded-user", + { + name: "missing trusted proxies", + cfg: { + gateway: { + bind: "lan", + trustedProxies: [], + auth: { + mode: "trusted-proxy", + trustedProxy: { userHeader: "x-forwarded-user" }, + }, }, }, + expectedCheckId: "gateway.trusted_proxy_no_proxies", + expectedSeverity: "critical", }, - }; - - const res = await audit(cfg); - - expect(res.findings).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - checkId: "gateway.trusted_proxy_no_proxies", - severity: "critical", - }), - ]), - ); - }); - - it("flags trusted-proxy auth without userHeader configured", async () => { - const cfg: OpenClawConfig = { - gateway: { - bind: "lan", - trustedProxies: ["10.0.0.1"], - auth: { - mode: "trusted-proxy", - trustedProxy: {} as never, - }, - }, - }; - - const res = await audit(cfg); - - expect(res.findings).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - checkId: "gateway.trusted_proxy_no_user_header", - severity: "critical", - }), - ]), - ); - }); - - it("warns when trusted-proxy auth allows all users", async () => { - const cfg: OpenClawConfig = { - gateway: { - bind: "lan", - trustedProxies: ["10.0.0.1"], - auth: { - mode: "trusted-proxy", - trustedProxy: { - userHeader: "x-forwarded-user", - allowUsers: [], + { + name: "missing user header", + cfg: { + gateway: { + bind: "lan", + trustedProxies: ["10.0.0.1"], + auth: { + mode: "trusted-proxy", + trustedProxy: {} as never, + }, }, }, + expectedCheckId: "gateway.trusted_proxy_no_user_header", + expectedSeverity: "critical", }, - }; + { + name: "missing user allowlist", + cfg: { + gateway: { + bind: "lan", + trustedProxies: ["10.0.0.1"], + auth: { + mode: "trusted-proxy", + trustedProxy: { + userHeader: "x-forwarded-user", + allowUsers: [], + }, + }, + }, + }, + expectedCheckId: "gateway.trusted_proxy_no_allowlist", + expectedSeverity: "warn", + }, + ]; - const res = await audit(cfg); - - expect(res.findings).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - checkId: "gateway.trusted_proxy_no_allowlist", - severity: "warn", - }), - ]), - ); + 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); + } + } }); it("warns when multiple DM senders share the main session", async () => { @@ -1416,91 +1421,84 @@ describe("security audit", () => { }); }); - it("adds a warning when deep probe fails", async () => { + it("adds probe_failed warnings for deep probe failure modes", async () => { const cfg: OpenClawConfig = { gateway: { mode: "local" } }; - - const res = await audit(cfg, { - deep: true, - deepTimeoutMs: 50, - probeGatewayFn: async () => ({ - ok: false, - url: "ws://127.0.0.1:18789", - connectLatencyMs: null, - error: "connect failed", - close: null, - health: null, - status: null, - presence: null, - configSnapshot: null, - }), - }); - - expect(res.findings).toEqual( - expect.arrayContaining([ - expect.objectContaining({ checkId: "gateway.probe_failed", severity: "warn" }), - ]), - ); - }); - - it("adds a warning when deep probe throws", async () => { - const cfg: OpenClawConfig = { gateway: { mode: "local" } }; - - const res = await audit(cfg, { - deep: true, - deepTimeoutMs: 50, - probeGatewayFn: async () => { - throw new Error("probe boom"); + const cases: Array<{ + name: string; + probeGatewayFn: NonNullable; + assertDeep?: (res: SecurityAuditReport) => void; + }> = [ + { + name: "probe returns failed result", + probeGatewayFn: async () => ({ + ok: false, + url: "ws://127.0.0.1:18789", + connectLatencyMs: null, + error: "connect failed", + close: null, + health: null, + status: null, + presence: null, + configSnapshot: null, + }), }, - }); - - 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" }), - ]), - ); + { + name: "probe throws", + probeGatewayFn: async () => { + throw new Error("probe boom"); + }, + assertDeep: (res) => { + expect(res.deep?.gateway?.ok).toBe(false); + expect(res.deep?.gateway?.error).toContain("probe boom"); + }, + }, + ]; + 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); + } }); - it("warns on legacy model configuration", async () => { - const cfg: OpenClawConfig = { - agents: { defaults: { model: { primary: "openai/gpt-3.5-turbo" } } }, - }; - - const res = await audit(cfg); - - expect(res.findings).toEqual( - expect.arrayContaining([ - expect.objectContaining({ checkId: "models.legacy", severity: "warn" }), - ]), - ); - }); - - it("warns on weak model tiers", async () => { - const cfg: OpenClawConfig = { - agents: { defaults: { model: { primary: "anthropic/claude-haiku-4-5" } } }, - }; - - const res = await audit(cfg); - - expect(res.findings).toEqual( - expect.arrayContaining([ - expect.objectContaining({ checkId: "models.weak_tier", severity: "warn" }), - ]), - ); - }); - - it("does not warn on Venice-style opus-45 model names", async () => { - // Venice uses "claude-opus-45" format (no dash between 4 and 5) - const cfg: OpenClawConfig = { - agents: { defaults: { model: { primary: "venice/claude-opus-45" } } }, - }; - - const res = await audit(cfg); - - // Should NOT contain weak_tier warning for opus-45 - const weakTierFinding = res.findings.find((f) => f.checkId === "models.weak_tier"); - expect(weakTierFinding).toBeUndefined(); + it("classifies legacy and weak-tier model identifiers", async () => { + const cases: Array<{ + name: string; + model: string; + expectedFindings?: Array<{ checkId: string; severity: "warn" }>; + expectedAbsentCheckId?: string; + }> = [ + { + name: "legacy model", + model: "openai/gpt-3.5-turbo", + expectedFindings: [{ checkId: "models.legacy", severity: "warn" }], + }, + { + name: "weak-tier model", + model: "anthropic/claude-haiku-4-5", + expectedFindings: [{ checkId: "models.weak_tier", severity: "warn" }], + }, + { + // Venice uses "claude-opus-45" format (no dash between 4 and 5). + name: "venice opus-45", + model: "venice/claude-opus-45", + 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); + } + } }); it("warns when hooks token looks short", async () => { @@ -1558,107 +1556,93 @@ describe("security audit", () => { ); }); - it("flags hooks request sessionKey override when enabled", async () => { - const cfg: OpenClawConfig = { - hooks: { - enabled: true, - token: "shared-gateway-token-1234567890", - defaultSessionKey: "hook:ingress", - allowRequestSessionKey: true, + it("scores hooks request sessionKey override by gateway exposure", async () => { + const baseHooks = { + enabled: true, + token: "shared-gateway-token-1234567890", + defaultSessionKey: "hook:ingress", + allowRequestSessionKey: true, + } satisfies NonNullable; + const cases: Array<{ + name: string; + cfg: OpenClawConfig; + expectedSeverity: "warn" | "critical"; + expectsPrefixesMissing?: boolean; + }> = [ + { + name: "local exposure", + cfg: { hooks: baseHooks }, + expectedSeverity: "warn", + expectsPrefixesMissing: true, }, - }; - - const res = await audit(cfg); - - expect(res.findings).toEqual( - expect.arrayContaining([ - expect.objectContaining({ checkId: "hooks.request_session_key_enabled", severity: "warn" }), - expect.objectContaining({ - checkId: "hooks.request_session_key_prefixes_missing", - severity: "warn", - }), - ]), - ); + { + name: "remote exposure", + cfg: { gateway: { bind: "lan" }, hooks: baseHooks }, + 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); + } + } }); - it("escalates hooks request sessionKey override when gateway is remotely exposed", async () => { - const cfg: OpenClawConfig = { - gateway: { bind: "lan" }, - hooks: { - enabled: true, - token: "shared-gateway-token-1234567890", - defaultSessionKey: "hook:ingress", - allowRequestSessionKey: true, - }, - }; - - const res = await audit(cfg); - - expect(res.findings).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - checkId: "hooks.request_session_key_enabled", - severity: "critical", - }), - ]), - ); - }); - - it("warns when gateway HTTP APIs run with auth.mode=none on loopback", async () => { - const cfg: OpenClawConfig = { - gateway: { - bind: "loopback", - auth: { mode: "none" }, - http: { - endpoints: { - chatCompletions: { enabled: true }, + it("scores gateway HTTP no-auth findings by exposure", async () => { + const cases: Array<{ + name: string; + cfg: OpenClawConfig; + expectedSeverity: "warn" | "critical"; + detailIncludes?: string[]; + }> = [ + { + name: "loopback no-auth", + cfg: { + gateway: { + bind: "loopback", + auth: { mode: "none" }, + http: { endpoints: { chatCompletions: { enabled: true } } }, }, }, + expectedSeverity: "warn", + detailIncludes: ["/tools/invoke", "/v1/chat/completions"], }, - }; - - const res = await runSecurityAudit({ - config: cfg, - env: {}, - includeFilesystem: false, - includeChannelSecurity: false, - }); - - expect(res.findings).toEqual( - expect.arrayContaining([ - expect.objectContaining({ checkId: "gateway.http.no_auth", severity: "warn" }), - ]), - ); - const finding = res.findings.find((entry) => entry.checkId === "gateway.http.no_auth"); - expect(finding?.detail).toContain("/tools/invoke"); - expect(finding?.detail).toContain("/v1/chat/completions"); - }); - - it("flags gateway HTTP APIs with auth.mode=none as critical when remotely exposed", async () => { - const cfg: OpenClawConfig = { - gateway: { - bind: "lan", - auth: { mode: "none" }, - http: { - endpoints: { - responses: { enabled: true }, + { + name: "remote no-auth", + cfg: { + gateway: { + bind: "lan", + auth: { mode: "none" }, + http: { endpoints: { responses: { enabled: true } } }, }, }, + expectedSeverity: "critical", }, - }; + ]; - const res = await runSecurityAudit({ - config: cfg, - env: {}, - includeFilesystem: false, - includeChannelSecurity: false, - }); - - expect(res.findings).toEqual( - expect.arrayContaining([ - expect.objectContaining({ checkId: "gateway.http.no_auth", severity: "critical" }), - ]), - ); + for (const testCase of cases) { + const res = await runSecurityAudit({ + config: testCase.cfg, + env: {}, + includeFilesystem: false, + includeChannelSecurity: false, + }); + expect( + hasFinding(res, "gateway.http.no_auth", testCase.expectedSeverity), + testCase.name, + ).toBe(true); + 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 () => { @@ -2266,135 +2250,120 @@ description: test skill }; }; - it("uses local auth when gateway.mode is local", async () => { - const { probeGatewayFn, getAuth } = makeProbeCapture(); - const cfg: OpenClawConfig = { - gateway: { - mode: "local", - auth: { token: "local-token-abc123" }, + const setProbeEnv = (env?: { token?: string; password?: string }) => { + delete process.env.OPENCLAW_GATEWAY_TOKEN; + delete process.env.OPENCLAW_GATEWAY_PASSWORD; + if (env?.token !== undefined) { + process.env.OPENCLAW_GATEWAY_TOKEN = env.token; + } + if (env?.password !== undefined) { + process.env.OPENCLAW_GATEWAY_PASSWORD = env.password; + } + }; + + it("applies token precedence across local/remote gateway modes", async () => { + const cases: Array<{ + name: string; + cfg: OpenClawConfig; + env?: { token?: string }; + expectedToken: string; + }> = [ + { + name: "uses local auth when gateway.mode is local", + cfg: { gateway: { mode: "local", auth: { token: "local-token-abc123" } } }, + expectedToken: "local-token-abc123", }, - }; - - await audit(cfg, { deep: true, deepTimeoutMs: 50, probeGatewayFn }); - - expect(getAuth()?.token).toBe("local-token-abc123"); - }); - - it("prefers env token over local config token", async () => { - process.env.OPENCLAW_GATEWAY_TOKEN = "env-token"; - const { probeGatewayFn, getAuth } = makeProbeCapture(); - const cfg: OpenClawConfig = { - gateway: { - mode: "local", - auth: { token: "local-token" }, + { + name: "prefers env token over local config token", + cfg: { gateway: { mode: "local", auth: { token: "local-token" } } }, + env: { token: "env-token" }, + expectedToken: "env-token", }, - }; - - await audit(cfg, { deep: true, deepTimeoutMs: 50, probeGatewayFn }); - - expect(getAuth()?.token).toBe("env-token"); - }); - - it("uses local auth when gateway.mode is undefined (default)", async () => { - const { probeGatewayFn, getAuth } = makeProbeCapture(); - const cfg: OpenClawConfig = { - gateway: { - auth: { token: "default-local-token" }, + { + name: "uses local auth when gateway.mode is undefined (default)", + cfg: { gateway: { auth: { token: "default-local-token" } } }, + expectedToken: "default-local-token", }, - }; - - await audit(cfg, { deep: true, deepTimeoutMs: 50, probeGatewayFn }); - - expect(getAuth()?.token).toBe("default-local-token"); - }); - - it("uses remote auth when gateway.mode is remote with URL", async () => { - const { probeGatewayFn, getAuth } = makeProbeCapture(); - const cfg: OpenClawConfig = { - gateway: { - mode: "remote", - auth: { token: "local-token-should-not-use" }, - remote: { - url: "wss://remote.example.com:18789", - token: "remote-token-xyz789", + { + name: "uses remote auth when gateway.mode is remote with URL", + cfg: { + gateway: { + mode: "remote", + auth: { token: "local-token-should-not-use" }, + remote: { url: "wss://remote.example.com:18789", token: "remote-token-xyz789" }, + }, }, + expectedToken: "remote-token-xyz789", }, - }; + { + name: "ignores env token when gateway.mode is remote", + cfg: { + gateway: { + mode: "remote", + auth: { token: "local-token-should-not-use" }, + remote: { url: "wss://remote.example.com:18789", token: "remote-token" }, + }, + }, + env: { token: "env-token" }, + expectedToken: "remote-token", + }, + { + name: "falls back to local auth when gateway.mode is remote but URL is missing", + cfg: { + gateway: { + mode: "remote", + auth: { token: "fallback-local-token" }, + remote: { token: "remote-token-should-not-use" }, + }, + }, + expectedToken: "fallback-local-token", + }, + ]; - await audit(cfg, { deep: true, deepTimeoutMs: 50, probeGatewayFn }); - - expect(getAuth()?.token).toBe("remote-token-xyz789"); + 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); + } }); - it("ignores env token when gateway.mode is remote", async () => { - process.env.OPENCLAW_GATEWAY_TOKEN = "env-token"; - const { probeGatewayFn, getAuth } = makeProbeCapture(); - const cfg: OpenClawConfig = { - gateway: { - mode: "remote", - auth: { token: "local-token-should-not-use" }, - remote: { - url: "wss://remote.example.com:18789", - token: "remote-token", + it("applies password precedence for remote gateways", async () => { + const cases: Array<{ + name: string; + cfg: OpenClawConfig; + env?: { password?: string }; + expectedPassword: string; + }> = [ + { + name: "uses remote password when env is unset", + cfg: { + gateway: { + mode: "remote", + remote: { url: "wss://remote.example.com:18789", password: "remote-pass" }, + }, }, + expectedPassword: "remote-pass", }, - }; - - await audit(cfg, { deep: true, deepTimeoutMs: 50, probeGatewayFn }); - - expect(getAuth()?.token).toBe("remote-token"); - }); - - it("uses remote password when env is unset", async () => { - const { probeGatewayFn, getAuth } = makeProbeCapture(); - const cfg: OpenClawConfig = { - gateway: { - mode: "remote", - remote: { - url: "wss://remote.example.com:18789", - password: "remote-pass", + { + name: "prefers env password over remote password", + cfg: { + gateway: { + mode: "remote", + remote: { url: "wss://remote.example.com:18789", password: "remote-pass" }, + }, }, + env: { password: "env-pass" }, + expectedPassword: "env-pass", }, - }; + ]; - await audit(cfg, { deep: true, deepTimeoutMs: 50, probeGatewayFn }); - - expect(getAuth()?.password).toBe("remote-pass"); - }); - - it("prefers env password over remote password", async () => { - process.env.OPENCLAW_GATEWAY_PASSWORD = "env-pass"; - const { probeGatewayFn, getAuth } = makeProbeCapture(); - const cfg: OpenClawConfig = { - gateway: { - mode: "remote", - remote: { - url: "wss://remote.example.com:18789", - password: "remote-pass", - }, - }, - }; - - await audit(cfg, { deep: true, deepTimeoutMs: 50, probeGatewayFn }); - - expect(getAuth()?.password).toBe("env-pass"); - }); - - it("falls back to local auth when gateway.mode is remote but URL is missing", async () => { - const { probeGatewayFn, getAuth } = makeProbeCapture(); - const cfg: OpenClawConfig = { - gateway: { - mode: "remote", - auth: { token: "fallback-local-token" }, - remote: { - token: "remote-token-should-not-use", - }, - }, - }; - - await audit(cfg, { deep: true, deepTimeoutMs: 50, probeGatewayFn }); - - expect(getAuth()?.token).toBe("fallback-local-token"); + 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); + } }); }); });