test: streamline config, audit, and qmd coverage

This commit is contained in:
Peter Steinberger
2026-02-21 22:23:12 +00:00
parent a9227f571b
commit 0608587bc3
3 changed files with 957 additions and 939 deletions

View File

@@ -5,78 +5,104 @@ function getLegacyRouting(config: unknown) {
return (config as { routing?: Record<string, unknown> } | undefined)?.routing; return (config as { routing?: Record<string, unknown> } | undefined)?.routing;
} }
function getChannelConfig(config: unknown, provider: string) {
const channels = (config as { channels?: Record<string, Record<string, unknown>> } | undefined)
?.channels;
return channels?.[provider];
}
describe("legacy config detection", () => { describe("legacy config detection", () => {
it("rejects routing.allowFrom", async () => { it("rejects legacy routing keys", async () => {
const res = validateConfigObject({ const cases = [
routing: { allowFrom: ["+15555550123"] }, {
}); name: "routing.allowFrom",
expect(res.ok).toBe(false); 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) { if (!res.ok) {
expect(res.issues[0]?.path).toBe("routing.allowFrom"); expect(res.issues[0]?.path, testCase.name).toBe(testCase.expectedPath);
}
} }
}); });
it("rejects routing.groupChat.requireMention", async () => {
const res = validateConfigObject({ it("migrates or drops routing.allowFrom based on whatsapp configuration", async () => {
routing: { groupChat: { requireMention: false } }, const cases = [
}); {
expect(res.ok).toBe(false); name: "whatsapp configured",
if (!res.ok) { input: { routing: { allowFrom: ["+15555550123"] }, channels: { whatsapp: {} } },
expect(res.issues[0]?.path).toBe("routing.groupChat.requireMention"); 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({ it("migrates routing.groupChat.requireMention to provider group defaults", async () => {
routing: { allowFrom: ["+15555550123"] }, const cases = [
channels: { whatsapp: {} }, {
}); name: "whatsapp configured",
expect(res.changes).toContain("Moved routing.allowFrom → channels.whatsapp.allowFrom."); input: { routing: { groupChat: { requireMention: false } }, channels: { whatsapp: {} } },
expect(res.config?.channels?.whatsapp?.allowFrom).toEqual(["+15555550123"]); expectWhatsapp: true,
expect(getLegacyRouting(res.config)?.allowFrom).toBeUndefined(); },
}); {
it("drops routing.allowFrom when whatsapp missing", async () => { name: "whatsapp missing",
const res = migrateLegacyConfig({ input: { routing: { groupChat: { requireMention: false } } },
routing: { allowFrom: ["+15555550123"] }, expectWhatsapp: false,
}); },
expect(res.changes).toContain("Removed routing.allowFrom (channels.whatsapp not configured)."); ] as const;
expect(res.config?.channels?.whatsapp).toBeUndefined(); for (const testCase of cases) {
expect(getLegacyRouting(res.config)?.allowFrom).toBeUndefined(); const res = migrateLegacyConfig(testCase.input);
}); expect(res.changes, testCase.name).toContain(
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.', 'Moved routing.groupChat.requireMention → channels.telegram.groups."*".requireMention.',
); );
expect(res.changes).toContain( expect(res.changes, testCase.name).toContain(
'Moved routing.groupChat.requireMention → channels.imessage.groups."*".requireMention.', 'Moved routing.groupChat.requireMention → channels.imessage.groups."*".requireMention.',
); );
expect(res.config?.channels?.whatsapp?.groups?.["*"]?.requireMention).toBe(false); if (testCase.expectWhatsapp) {
expect(res.config?.channels?.telegram?.groups?.["*"]?.requireMention).toBe(false); expect(res.changes, testCase.name).toContain(
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.', 'Moved routing.groupChat.requireMention → channels.whatsapp.groups."*".requireMention.',
); );
expect(res.config?.channels?.whatsapp).toBeUndefined(); expect(res.config?.channels?.whatsapp?.groups?.["*"]?.requireMention, testCase.name).toBe(
expect(res.config?.channels?.telegram?.groups?.["*"]?.requireMention).toBe(false); false,
expect(res.config?.channels?.imessage?.groups?.["*"]?.requireMention).toBe(false); );
expect(getLegacyRouting(res.config)?.groupChat).toBeUndefined(); } 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 () => { it("migrates routing.groupChat.mentionPatterns to messages.groupChat.mentionPatterns", async () => {
const res = migrateLegacyConfig({ const res = migrateLegacyConfig({
@@ -346,64 +372,87 @@ describe("legacy config detection", () => {
expect(validated.config.gateway?.bind).toBe("tailnet"); expect(validated.config.gateway?.bind).toBe("tailnet");
} }
}); });
it('rejects telegram.dmPolicy="open" without allowFrom "*"', async () => { 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({ const res = validateConfigObject({
channels: { telegram: { dmPolicy: "open", allowFrom: ["123456789"] } }, channels: {
[testCase.provider]: { dmPolicy: "open", allowFrom: testCase.allowFrom },
},
}); });
expect(res.ok).toBe(false); expect(res.ok, testCase.provider).toBe(false);
if (!res.ok) { if (!res.ok) {
expect(res.issues[0]?.path).toBe("channels.telegram.allowFrom"); expect(res.issues[0]?.path, testCase.provider).toBe(testCase.expectedIssuePath);
}
} }
}); });
it('accepts telegram.dmPolicy="open" with allowFrom "*"', async () => {
it('accepts dmPolicy="open" when allowFrom includes wildcard', async () => {
const providers = ["telegram", "whatsapp", "signal"] as const;
for (const provider of providers) {
const res = validateConfigObject({ const res = validateConfigObject({
channels: { telegram: { dmPolicy: "open", allowFrom: ["*"] } }, channels: { [provider]: { dmPolicy: "open", allowFrom: ["*"] } },
}); });
expect(res.ok).toBe(true); expect(res.ok, provider).toBe(true);
if (res.ok) { if (res.ok) {
expect(res.config.channels?.telegram?.dmPolicy).toBe("open"); 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: {} } }); it("defaults dm/group policy for configured providers", async () => {
expect(res.ok).toBe(true); 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) { if (res.ok) {
expect(res.config.channels?.telegram?.dmPolicy).toBe("pairing"); 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 () => { it("normalizes telegram legacy streamMode aliases", async () => {
const res = validateConfigObject({ channels: { telegram: {} } }); const cases = [
expect(res.ok).toBe(true); {
if (res.ok) { name: "top-level off",
expect(res.config.channels?.telegram?.groupPolicy).toBe("allowlist"); input: { channels: { telegram: { streamMode: "off" } } },
} expectedTopLevel: "off",
}); },
it("defaults telegram.streaming to off when telegram section exists", async () => { {
const res = validateConfigObject({ channels: { telegram: {} } }); name: "top-level block",
expect(res.ok).toBe(true); input: { channels: { telegram: { streamMode: "block" } } },
if (res.ok) { expectedTopLevel: "block",
expect(res.config.channels?.telegram?.streaming).toBe("off"); },
expect(res.config.channels?.telegram?.streamMode).toBeUndefined(); {
} name: "per-account off",
}); input: {
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: { channels: {
telegram: { telegram: {
accounts: { accounts: {
@@ -413,71 +462,97 @@ describe("legacy config detection", () => {
}, },
}, },
}, },
}); },
expect(res.ok).toBe(true); expectedAccountStreaming: "off",
},
] as const;
for (const testCase of cases) {
const res = validateConfigObject(testCase.input);
expect(res.ok, testCase.name).toBe(true);
if (res.ok) { if (res.ok) {
expect(res.config.channels?.telegram?.accounts?.ops?.streaming).toBe("off"); if (testCase.expectedTopLevel !== undefined) {
expect(res.config.channels?.telegram?.accounts?.ops?.streamMode).toBeUndefined(); 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({ it("normalizes discord streaming fields during legacy migration", async () => {
channels: { const cases = [
discord: { {
streaming: true, name: "boolean streaming=true",
input: { channels: { discord: { streaming: true } } },
expectedChanges: ["Normalized channels.discord.streaming boolean → enum (partial)."],
expectedStreaming: "partial",
}, },
}, {
}); name: "streamMode with streaming boolean",
expect(res.changes).toContain( input: { channels: { discord: { streaming: false, streamMode: "block" } } },
"Normalized channels.discord.streaming boolean → enum (partial).", expectedChanges: [
);
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",
},
},
});
expect(res.changes).toContain(
"Moved channels.discord.streamMode → channels.discord.streaming (block).", "Moved channels.discord.streamMode → channels.discord.streaming (block).",
"Normalized channels.discord.streaming boolean → enum (block).",
],
expectedStreaming: "block",
},
] 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.changes).toContain("Normalized channels.discord.streaming boolean → enum (block)."); expect(res.config?.channels?.discord?.streamMode, testCase.name).toBeUndefined();
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();
} }
}); });
it("migrates discord.streaming=false to streaming=off", async () => {
const res = validateConfigObject({ channels: { discord: { streaming: false } } }); it("normalizes discord streaming fields during validation", async () => {
expect(res.ok).toBe(true); 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) { if (res.ok) {
expect(res.config.channels?.discord?.streaming).toBe("off"); expect(res.config.channels?.discord?.streaming, testCase.name).toBe(
expect(res.config.channels?.discord?.streamMode).toBeUndefined(); testCase.expectedStreaming,
);
expect(res.config.channels?.discord?.streamMode, testCase.name).toBeUndefined();
}
} }
}); });
it("keeps explicit discord.streamMode and normalizes to streaming", async () => { it("normalizes account-level discord and slack streaming aliases", async () => {
const res = validateConfigObject({ const cases = [
channels: { discord: { streamMode: "block", streaming: false } }, {
}); name: "discord account streaming boolean",
expect(res.ok).toBe(true); input: {
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: { channels: {
discord: { discord: {
accounts: { accounts: {
@@ -487,106 +562,48 @@ describe("legacy config detection", () => {
}, },
}, },
}, },
}); },
expect(res.ok).toBe(true); assert: (config: NonNullable<OpenClawConfig>) => {
if (res.ok) { expect(config.channels?.discord?.accounts?.work?.streaming).toBe("partial");
expect(res.config.channels?.discord?.accounts?.work?.streaming).toBe("partial"); expect(config.channels?.discord?.accounts?.work?.streamMode).toBeUndefined();
expect(res.config.channels?.discord?.accounts?.work?.streamMode).toBeUndefined(); },
} },
}); {
it("migrates slack.streamMode values to slack.streaming enum", async () => { name: "slack streamMode alias",
const res = validateConfigObject({ input: {
channels: { channels: {
slack: { slack: {
streamMode: "status_final", streamMode: "status_final",
}, },
}, },
}); },
expect(res.ok).toBe(true); assert: (config: NonNullable<OpenClawConfig>) => {
if (res.ok) { expect(config.channels?.slack?.streaming).toBe("progress");
expect(res.config.channels?.slack?.streaming).toBe("progress"); expect(config.channels?.slack?.streamMode).toBeUndefined();
expect(res.config.channels?.slack?.streamMode).toBeUndefined(); expect(config.channels?.slack?.nativeStreaming).toBe(true);
expect(res.config.channels?.slack?.nativeStreaming).toBe(true); },
} },
}); {
it("migrates legacy slack.streaming boolean to nativeStreaming", async () => { name: "slack streaming boolean legacy",
const res = validateConfigObject({ input: {
channels: { channels: {
slack: { slack: {
streaming: false, streaming: false,
}, },
}, },
});
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"] },
}, },
}); assert: (config: NonNullable<OpenClawConfig>) => {
expect(res.ok).toBe(false); expect(config.channels?.slack?.streaming).toBe("partial");
if (!res.ok) { expect(config.channels?.slack?.nativeStreaming).toBe(false);
expect(res.issues[0]?.path).toBe("channels.whatsapp.allowFrom"); },
} },
}); ] as const;
it('accepts whatsapp.dmPolicy="open" with allowFrom "*"', async () => { for (const testCase of cases) {
const res = validateConfigObject({ const res = validateConfigObject(testCase.input);
channels: { whatsapp: { dmPolicy: "open", allowFrom: ["*"] } }, expect(res.ok, testCase.name).toBe(true);
});
expect(res.ok).toBe(true);
if (res.ok) { if (res.ok) {
expect(res.config.channels?.whatsapp?.dmPolicy).toBe("open"); testCase.assert(res.config);
} }
});
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");
} }
}); });
it("accepts historyLimit overrides per provider and account", async () => { 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); 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");
}
});
}); });

View File

@@ -1212,20 +1212,22 @@ describe("QmdMemoryManager", () => {
readFileSpy.mockRestore(); readFileSpy.mockRestore();
}); });
it("returns empty text when a qmd workspace file does not exist", async () => { it("returns empty text when qmd files are missing before or during read", 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 () => {
const relPath = "qmd-window.md"; const relPath = "qmd-window.md";
const absPath = path.join(workspaceDir, relPath); const absPath = path.join(workspaceDir, relPath);
await fs.writeFile(absPath, "one\ntwo\nthree", "utf-8"); 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; const realOpen = fs.open;
let injected = false; let injected = false;
const openSpy = vi const openSpy = vi
@@ -1240,12 +1242,22 @@ describe("QmdMemoryManager", () => {
} }
return realOpen(target, options); return realOpen(target, options);
}); });
return () => openSpy.mockRestore();
},
},
] as const;
const result = await manager.readFile({ relPath, from: 2, lines: 1 }); for (const testCase of cases) {
expect(result).toEqual({ text: "", path: relPath }); const { manager } = await createManager();
const restoreOpen = testCase.installOpenSpy?.();
openSpy.mockRestore(); try {
const result = await manager.readFile(testCase.request);
expect(result, testCase.name).toEqual({ text: "", path: testCase.expectedPath });
} finally {
restoreOpen?.();
await manager.close(); await manager.close();
}
}
}); });
it("reuses exported session markdown files when inputs are unchanged", async () => { it("reuses exported session markdown files when inputs are unchanged", async () => {
@@ -1295,8 +1307,11 @@ describe("QmdMemoryManager", () => {
writeFileSpy.mockRestore(); writeFileSpy.mockRestore();
}); });
it("throws when sqlite index is busy", async () => { it("fails closed when sqlite index is busy during doc lookup or search", async () => {
const { manager } = await createManager(); const cases = [
{
name: "resolveDocLocation",
run: async (manager: QmdMemoryManager) => {
const inner = manager as unknown as { const inner = manager as unknown as {
db: { db: {
prepare: () => { prepare: () => {
@@ -1315,7 +1330,6 @@ describe("QmdMemoryManager", () => {
throw new Error("SQLITE_BUSY: database is locked"); throw new Error("SQLITE_BUSY: database is locked");
}, },
}; };
inner.db = { inner.db = {
prepare: () => busyStmt, prepare: () => busyStmt,
close: () => {}, close: () => {},
@@ -1323,10 +1337,11 @@ describe("QmdMemoryManager", () => {
await expect(inner.resolveDocLocation("abc123")).rejects.toThrow( await expect(inner.resolveDocLocation("abc123")).rejects.toThrow(
"qmd index busy while reading results", "qmd index busy while reading results",
); );
await manager.close(); },
}); },
{
it("fails search when sqlite index is busy so caller can fallback", async () => { name: "search",
run: async (manager: QmdMemoryManager) => {
spawnMock.mockImplementation((_cmd: string, args: string[]) => { spawnMock.mockImplementation((_cmd: string, args: string[]) => {
if (args[0] === "search") { if (args[0] === "search") {
const child = createMockChild({ autoClose: false }); const child = createMockChild({ autoClose: false });
@@ -1339,8 +1354,6 @@ describe("QmdMemoryManager", () => {
} }
return createMockChild(); return createMockChild();
}); });
const { manager } = await createManager();
const inner = manager as unknown as { const inner = manager as unknown as {
db: { prepare: () => { all: () => never }; close: () => void } | null; db: { prepare: () => { all: () => never }; close: () => void } | null;
}; };
@@ -1355,7 +1368,25 @@ describe("QmdMemoryManager", () => {
await expect( await expect(
manager.search("busy lookup", { sessionKey: "agent:main:slack:dm:u123" }), manager.search("busy lookup", { sessionKey: "agent:main:slack:dm:u123" }),
).rejects.toThrow("qmd index busy while reading results"); ).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(); await manager.close();
}
}
}); });
it("prefers exact docid match before prefix fallback for qmd document lookups", async () => { 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 () => { it("handles first-run symlink, existing dir preservation, and missing default cache", async () => {
const { manager } = await createManager({ mode: "full" }); const cases: Array<{
expect(manager).toBeTruthy(); name: string;
setup?: () => Promise<void>;
assert: () => Promise<void>;
}> = [
{
name: "symlinks default cache on first run",
assert: async () => {
const stat = await fs.lstat(customModelsDir); const stat = await fs.lstat(customModelsDir);
expect(stat.isSymbolicLink()).toBe(true); expect(stat.isSymbolicLink()).toBe(true);
const target = await fs.readlink(customModelsDir); const target = await fs.readlink(customModelsDir);
expect(target).toBe(defaultModelsDir); expect(target).toBe(defaultModelsDir);
// Models are accessible through the symlink.
const content = await fs.readFile(path.join(customModelsDir, "model.bin"), "utf-8"); const content = await fs.readFile(path.join(customModelsDir, "model.bin"), "utf-8");
expect(content).toBe("fake-model"); expect(content).toBe("fake-model");
},
await manager.close(); },
}); {
name: "does not overwrite existing models directory",
it("does not overwrite existing models directory", async () => { setup: async () => {
// Pre-create the custom models dir with different content.
await fs.mkdir(customModelsDir, { recursive: true }); await fs.mkdir(customModelsDir, { recursive: true });
await fs.writeFile(path.join(customModelsDir, "custom-model.bin"), "custom"); await fs.writeFile(path.join(customModelsDir, "custom-model.bin"), "custom");
},
const { manager } = await createManager({ mode: "full" }); assert: async () => {
expect(manager).toBeTruthy();
// Should still be a real directory, not a symlink.
const stat = await fs.lstat(customModelsDir); const stat = await fs.lstat(customModelsDir);
expect(stat.isSymbolicLink()).toBe(false); expect(stat.isSymbolicLink()).toBe(false);
expect(stat.isDirectory()).toBe(true); expect(stat.isDirectory()).toBe(true);
const content = await fs.readFile(
// Custom content should be preserved. path.join(customModelsDir, "custom-model.bin"),
const content = await fs.readFile(path.join(customModelsDir, "custom-model.bin"), "utf-8"); "utf-8",
);
expect(content).toBe("custom"); expect(content).toBe("custom");
},
await manager.close(); },
}); {
name: "skips symlink when default models are absent",
it("skips symlink when no default models exist", async () => { setup: async () => {
// Remove the default models dir.
await fs.rm(defaultModelsDir, { recursive: true, force: true }); await fs.rm(defaultModelsDir, { recursive: true, force: true });
},
const { manager } = await createManager({ mode: "full" }); assert: async () => {
expect(manager).toBeTruthy();
// Custom models dir should not exist (no symlink created).
await expect(fs.lstat(customModelsDir)).rejects.toThrow(); await expect(fs.lstat(customModelsDir)).rejects.toThrow();
expect(logWarnMock).not.toHaveBeenCalledWith( expect(logWarnMock).not.toHaveBeenCalledWith(
expect.stringContaining("failed to symlink qmd models directory"), expect.stringContaining("failed to symlink qmd models directory"),
); );
},
},
];
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(); await manager.close();
}
}
}); });
}); });
}); });

View File

@@ -182,32 +182,42 @@ describe("security audit", () => {
expect(hasFinding(res, "gateway.auth_no_rate_limit", "warn")).toBe(true); 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 () => { it("scores dangerous gateway.tools.allow over HTTP by exposure", async () => {
const cfg: OpenClawConfig = { const cases: Array<{
name: string;
cfg: OpenClawConfig;
expectedSeverity: "warn" | "critical";
}> = [
{
name: "loopback bind",
cfg: {
gateway: { gateway: {
bind: "loopback", bind: "loopback",
auth: { token: "secret" }, auth: { token: "secret" },
tools: { allow: ["sessions_spawn"] }, tools: { allow: ["sessions_spawn"] },
}, },
}; },
expectedSeverity: "warn",
const res = await audit(cfg, { env: {} }); },
{
expect(hasFinding(res, "gateway.tools_invoke_http.dangerous_allow", "warn")).toBe(true); name: "non-loopback bind",
}); cfg: {
it("flags dangerous gateway.tools.allow over HTTP as critical when gateway binds beyond loopback", async () => {
const cfg: OpenClawConfig = {
gateway: { gateway: {
bind: "lan", bind: "lan",
auth: { token: "secret" }, auth: { token: "secret" },
tools: { allow: ["sessions_spawn", "gateway"] }, 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 () => { it("does not warn for auth rate limiting when configured", async () => {
@@ -572,50 +582,55 @@ describe("security audit", () => {
expect(res.findings.some((f) => f.checkId === "fs.config.perms_group_readable")).toBe(false); 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 () => { it("scores small-model risk by tool/sandbox exposure", async () => {
const cfg: OpenClawConfig = { 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" } } }, agents: { defaults: { model: { primary: "ollama/mistral-8b" } } },
tools: { tools: { web: { search: { enabled: true }, fetch: { enabled: true } } },
web: {
search: { enabled: true },
fetch: { enabled: true },
},
},
browser: { enabled: true }, 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");
});
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 },
}, },
expectedSeverity: "critical",
detailIncludes: ["mistral-8b", "web_search", "web_fetch", "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 }, browser: { enabled: false },
}; },
expectedSeverity: "info",
const res = await audit(cfg); 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"); const finding = res.findings.find((f) => f.checkId === "models.small_params");
expect(finding?.severity).toBe("info"); expect(finding?.severity, testCase.name).toBe(testCase.expectedSeverity);
expect(finding?.detail).toContain("mistral-8b"); for (const text of testCase.detailIncludes) {
expect(finding?.detail).toContain("sandbox=all"); expect(finding?.detail, `${testCase.name}:${text}`).toContain(text);
}
}
}); });
it("flags sandbox docker config when sandbox mode is off", async () => { it("checks sandbox docker mode-off findings with/without agent override", async () => {
const cfg: OpenClawConfig = { const cases: Array<{
name: string;
cfg: OpenClawConfig;
expectedPresent: boolean;
}> = [
{
name: "mode off with docker config only",
cfg: {
agents: { agents: {
defaults: { defaults: {
sandbox: { sandbox: {
@@ -624,22 +639,12 @@ describe("security audit", () => {
}, },
}, },
}, },
}; },
expectedPresent: true,
const res = await audit(cfg); },
{
expect(res.findings).toEqual( name: "agent enables sandbox mode",
expect.arrayContaining([ cfg: {
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: { agents: {
defaults: { defaults: {
sandbox: { sandbox: {
@@ -649,11 +654,16 @@ describe("security audit", () => {
}, },
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 () => { 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 () => { it("checks sandbox browser bridge-network restrictions", async () => {
const cfg: OpenClawConfig = { const cases: Array<{
name: string;
cfg: OpenClawConfig;
expectedPresent: boolean;
expectedSeverity?: "warn";
detailIncludes?: string;
}> = [
{
name: "bridge without cdpSourceRange",
cfg: {
agents: { agents: {
defaults: { defaults: {
sandbox: { sandbox: {
mode: "all", mode: "all",
browser: { browser: { enabled: true, network: "bridge" },
enabled: true,
network: "bridge",
}, },
}, },
}, },
}, },
}; expectedPresent: true,
expectedSeverity: "warn",
const res = await audit(cfg); detailIncludes: "agents.defaults.sandbox.browser",
},
{
name: "dedicated default network",
cfg: {
agents: {
defaults: {
sandbox: {
mode: "all",
browser: { enabled: true },
},
},
},
},
expectedPresent: false,
},
];
for (const testCase of cases) {
const res = await audit(testCase.cfg);
const finding = res.findings.find( const finding = res.findings.find(
(f) => f.checkId === "sandbox.browser_cdp_bridge_unrestricted", (f) => f.checkId === "sandbox.browser_cdp_bridge_unrestricted",
); );
expect(finding?.severity).toBe("warn"); expect(Boolean(finding), testCase.name).toBe(testCase.expectedPresent);
expect(finding?.detail).toContain("agents.defaults.sandbox.browser"); if (testCase.expectedPresent) {
}); expect(finding?.severity, testCase.name).toBe(testCase.expectedSeverity);
if (testCase.detailIncludes) {
it("does not warn when sandbox browser uses dedicated default network", async () => { expect(finding?.detail, testCase.name).toContain(testCase.detailIncludes);
const cfg: OpenClawConfig = { }
agents: { }
defaults: { }
sandbox: {
mode: "all",
browser: {
enabled: true,
},
},
},
},
};
const res = await audit(cfg);
expect(hasFinding(res, "sandbox.browser_cdp_bridge_unrestricted")).toBe(false);
}); });
it("flags ineffective gateway.nodes.denyCommands entries", async () => { it("flags ineffective gateway.nodes.denyCommands entries", async () => {
@@ -929,62 +952,48 @@ describe("security audit", () => {
expect(finding?.detail).toContain("tools.exec.applyPatch.workspaceOnly=false"); expect(finding?.detail).toContain("tools.exec.applyPatch.workspaceOnly=false");
}); });
it("flags trusted-proxy auth mode without generic shared-secret findings", async () => { it("evaluates trusted-proxy auth guardrails", async () => {
const cfg: OpenClawConfig = { const cases: Array<{
name: string;
cfg: OpenClawConfig;
expectedCheckId: string;
expectedSeverity: "warn" | "critical";
suppressesGenericSharedSecretFindings?: boolean;
}> = [
{
name: "trusted-proxy base mode",
cfg: {
gateway: { gateway: {
bind: "lan", bind: "lan",
trustedProxies: ["10.0.0.1"], trustedProxies: ["10.0.0.1"],
auth: { auth: {
mode: "trusted-proxy", mode: "trusted-proxy",
trustedProxy: { trustedProxy: { userHeader: "x-forwarded-user" },
userHeader: "x-forwarded-user",
}, },
}, },
}, },
}; expectedCheckId: "gateway.trusted_proxy_auth",
expectedSeverity: "critical",
const res = await audit(cfg); suppressesGenericSharedSecretFindings: true,
},
expect(res.findings).toEqual( {
expect.arrayContaining([ name: "missing trusted proxies",
expect.objectContaining({ cfg: {
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: { gateway: {
bind: "lan", bind: "lan",
trustedProxies: [], trustedProxies: [],
auth: { auth: {
mode: "trusted-proxy", mode: "trusted-proxy",
trustedProxy: { trustedProxy: { userHeader: "x-forwarded-user" },
userHeader: "x-forwarded-user",
}, },
}, },
}, },
}; expectedCheckId: "gateway.trusted_proxy_no_proxies",
expectedSeverity: "critical",
const res = await audit(cfg); },
{
expect(res.findings).toEqual( name: "missing user header",
expect.arrayContaining([ cfg: {
expect.objectContaining({
checkId: "gateway.trusted_proxy_no_proxies",
severity: "critical",
}),
]),
);
});
it("flags trusted-proxy auth without userHeader configured", async () => {
const cfg: OpenClawConfig = {
gateway: { gateway: {
bind: "lan", bind: "lan",
trustedProxies: ["10.0.0.1"], trustedProxies: ["10.0.0.1"],
@@ -993,22 +1002,13 @@ describe("security audit", () => {
trustedProxy: {} as never, trustedProxy: {} as never,
}, },
}, },
}; },
expectedCheckId: "gateway.trusted_proxy_no_user_header",
const res = await audit(cfg); expectedSeverity: "critical",
},
expect(res.findings).toEqual( {
expect.arrayContaining([ name: "missing user allowlist",
expect.objectContaining({ cfg: {
checkId: "gateway.trusted_proxy_no_user_header",
severity: "critical",
}),
]),
);
});
it("warns when trusted-proxy auth allows all users", async () => {
const cfg: OpenClawConfig = {
gateway: { gateway: {
bind: "lan", bind: "lan",
trustedProxies: ["10.0.0.1"], trustedProxies: ["10.0.0.1"],
@@ -1020,18 +1020,23 @@ describe("security audit", () => {
}, },
}, },
}, },
}; },
expectedCheckId: "gateway.trusted_proxy_no_allowlist",
expectedSeverity: "warn",
},
];
const res = await audit(cfg); for (const testCase of cases) {
const res = await audit(testCase.cfg);
expect(res.findings).toEqual( expect(
expect.arrayContaining([ hasFinding(res, testCase.expectedCheckId, testCase.expectedSeverity),
expect.objectContaining({ testCase.name,
checkId: "gateway.trusted_proxy_no_allowlist", ).toBe(true);
severity: "warn", 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 () => { it("warns when multiple DM senders share the main session", async () => {
@@ -1416,12 +1421,15 @@ 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 cfg: OpenClawConfig = { gateway: { mode: "local" } };
const cases: Array<{
const res = await audit(cfg, { name: string;
deep: true, probeGatewayFn: NonNullable<SecurityAuditOptions["probeGatewayFn"]>;
deepTimeoutMs: 50, assertDeep?: (res: SecurityAuditReport) => void;
}> = [
{
name: "probe returns failed result",
probeGatewayFn: async () => ({ probeGatewayFn: async () => ({
ok: false, ok: false,
url: "ws://127.0.0.1:18789", url: "ws://127.0.0.1:18789",
@@ -1433,74 +1441,64 @@ describe("security audit", () => {
presence: null, presence: null,
configSnapshot: null, configSnapshot: null,
}), }),
}); },
{
expect(res.findings).toEqual( name: "probe throws",
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 () => { probeGatewayFn: async () => {
throw new Error("probe boom"); throw new Error("probe boom");
}, },
}); assertDeep: (res) => {
expect(res.deep?.gateway?.ok).toBe(false); expect(res.deep?.gateway?.ok).toBe(false);
expect(res.deep?.gateway?.error).toContain("probe boom"); expect(res.deep?.gateway?.error).toContain("probe boom");
expect(res.findings).toEqual( },
expect.arrayContaining([ },
expect.objectContaining({ checkId: "gateway.probe_failed", severity: "warn" }), ];
]), 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 () => { it("classifies legacy and weak-tier model identifiers", async () => {
const cfg: OpenClawConfig = { const cases: Array<{
agents: { defaults: { model: { primary: "openai/gpt-3.5-turbo" } } }, name: string;
}; model: string;
expectedFindings?: Array<{ checkId: string; severity: "warn" }>;
const res = await audit(cfg); expectedAbsentCheckId?: string;
}> = [
expect(res.findings).toEqual( {
expect.arrayContaining([ name: "legacy model",
expect.objectContaining({ checkId: "models.legacy", severity: "warn" }), 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 ?? []) {
it("warns on weak model tiers", async () => { expect(hasFinding(res, expected.checkId, expected.severity), testCase.name).toBe(true);
const cfg: OpenClawConfig = { }
agents: { defaults: { model: { primary: "anthropic/claude-haiku-4-5" } } }, if (testCase.expectedAbsentCheckId) {
}; expect(hasFinding(res, testCase.expectedAbsentCheckId), testCase.name).toBe(false);
}
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("warns when hooks token looks short", async () => { it("warns when hooks token looks short", async () => {
@@ -1558,107 +1556,93 @@ describe("security audit", () => {
); );
}); });
it("flags hooks request sessionKey override when enabled", async () => { it("scores hooks request sessionKey override by gateway exposure", async () => {
const cfg: OpenClawConfig = { const baseHooks = {
hooks: {
enabled: true, enabled: true,
token: "shared-gateway-token-1234567890", token: "shared-gateway-token-1234567890",
defaultSessionKey: "hook:ingress", defaultSessionKey: "hook:ingress",
allowRequestSessionKey: true, allowRequestSessionKey: true,
} satisfies NonNullable<OpenClawConfig["hooks"]>;
const cases: Array<{
name: string;
cfg: OpenClawConfig;
expectedSeverity: "warn" | "critical";
expectsPrefixesMissing?: boolean;
}> = [
{
name: "local exposure",
cfg: { hooks: baseHooks },
expectedSeverity: "warn",
expectsPrefixesMissing: true,
}, },
}; {
name: "remote exposure",
const res = await audit(cfg); cfg: { gateway: { bind: "lan" }, hooks: baseHooks },
expectedSeverity: "critical",
expect(res.findings).toEqual( },
expect.arrayContaining([ ];
expect.objectContaining({ checkId: "hooks.request_session_key_enabled", severity: "warn" }), for (const testCase of cases) {
expect.objectContaining({ const res = await audit(testCase.cfg);
checkId: "hooks.request_session_key_prefixes_missing", expect(
severity: "warn", 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 () => { it("scores gateway HTTP no-auth findings by exposure", async () => {
const cfg: OpenClawConfig = { const cases: Array<{
gateway: { bind: "lan" }, name: string;
hooks: { cfg: OpenClawConfig;
enabled: true, expectedSeverity: "warn" | "critical";
token: "shared-gateway-token-1234567890", detailIncludes?: string[];
defaultSessionKey: "hook:ingress", }> = [
allowRequestSessionKey: true, {
}, name: "loopback no-auth",
}; cfg: {
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: { gateway: {
bind: "loopback", bind: "loopback",
auth: { mode: "none" }, auth: { mode: "none" },
http: { http: { endpoints: { chatCompletions: { enabled: true } } },
endpoints: {
chatCompletions: { enabled: true },
}, },
}, },
expectedSeverity: "warn",
detailIncludes: ["/tools/invoke", "/v1/chat/completions"],
}, },
}; {
name: "remote no-auth",
const res = await runSecurityAudit({ cfg: {
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: { gateway: {
bind: "lan", bind: "lan",
auth: { mode: "none" }, auth: { mode: "none" },
http: { http: { endpoints: { responses: { enabled: true } } },
endpoints: {
responses: { enabled: true },
}, },
}, },
expectedSeverity: "critical",
}, },
}; ];
for (const testCase of cases) {
const res = await runSecurityAudit({ const res = await runSecurityAudit({
config: cfg, config: testCase.cfg,
env: {}, env: {},
includeFilesystem: false, includeFilesystem: false,
includeChannelSecurity: false, includeChannelSecurity: false,
}); });
expect(
expect(res.findings).toEqual( hasFinding(res, "gateway.http.no_auth", testCase.expectedSeverity),
expect.arrayContaining([ testCase.name,
expect.objectContaining({ checkId: "gateway.http.no_auth", severity: "critical" }), ).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 () => { 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 setProbeEnv = (env?: { token?: string; password?: string }) => {
const { probeGatewayFn, getAuth } = makeProbeCapture(); delete process.env.OPENCLAW_GATEWAY_TOKEN;
const cfg: OpenClawConfig = { delete process.env.OPENCLAW_GATEWAY_PASSWORD;
gateway: { if (env?.token !== undefined) {
mode: "local", process.env.OPENCLAW_GATEWAY_TOKEN = env.token;
auth: { token: "local-token-abc123" }, }
}, if (env?.password !== undefined) {
process.env.OPENCLAW_GATEWAY_PASSWORD = env.password;
}
}; };
await audit(cfg, { deep: true, deepTimeoutMs: 50, probeGatewayFn }); it("applies token precedence across local/remote gateway modes", async () => {
const cases: Array<{
expect(getAuth()?.token).toBe("local-token-abc123"); name: string;
}); cfg: OpenClawConfig;
env?: { token?: string };
it("prefers env token over local config token", async () => { expectedToken: string;
process.env.OPENCLAW_GATEWAY_TOKEN = "env-token"; }> = [
const { probeGatewayFn, getAuth } = makeProbeCapture(); {
const cfg: OpenClawConfig = { name: "uses local auth when gateway.mode is local",
gateway: { cfg: { gateway: { mode: "local", auth: { token: "local-token-abc123" } } },
mode: "local", expectedToken: "local-token-abc123",
auth: { token: "local-token" },
}, },
}; {
name: "prefers env token over local config token",
await audit(cfg, { deep: true, deepTimeoutMs: 50, probeGatewayFn }); cfg: { gateway: { mode: "local", auth: { token: "local-token" } } },
env: { token: "env-token" },
expect(getAuth()?.token).toBe("env-token"); expectedToken: "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)",
await audit(cfg, { deep: true, deepTimeoutMs: 50, probeGatewayFn }); cfg: { gateway: { auth: { token: "default-local-token" } } },
expectedToken: "default-local-token",
expect(getAuth()?.token).toBe("default-local-token"); },
}); {
name: "uses remote auth when gateway.mode is remote with URL",
it("uses remote auth when gateway.mode is remote with URL", async () => { cfg: {
const { probeGatewayFn, getAuth } = makeProbeCapture();
const cfg: OpenClawConfig = {
gateway: { gateway: {
mode: "remote", mode: "remote",
auth: { token: "local-token-should-not-use" }, auth: { token: "local-token-should-not-use" },
remote: { remote: { url: "wss://remote.example.com:18789", token: "remote-token-xyz789" },
url: "wss://remote.example.com:18789",
token: "remote-token-xyz789",
}, },
}, },
}; expectedToken: "remote-token-xyz789",
},
await audit(cfg, { deep: true, deepTimeoutMs: 50, probeGatewayFn }); {
name: "ignores env token when gateway.mode is remote",
expect(getAuth()?.token).toBe("remote-token-xyz789"); cfg: {
});
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: { gateway: {
mode: "remote", mode: "remote",
auth: { token: "local-token-should-not-use" }, auth: { token: "local-token-should-not-use" },
remote: { remote: { url: "wss://remote.example.com:18789", token: "remote-token" },
url: "wss://remote.example.com:18789",
token: "remote-token",
}, },
}, },
}; env: { token: "env-token" },
expectedToken: "remote-token",
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: "falls back to local auth when gateway.mode is remote but URL is missing",
cfg: {
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: { gateway: {
mode: "remote", mode: "remote",
auth: { token: "fallback-local-token" }, auth: { token: "fallback-local-token" },
remote: { remote: { token: "remote-token-should-not-use" },
token: "remote-token-should-not-use",
}, },
}, },
}; expectedToken: "fallback-local-token",
},
];
await audit(cfg, { deep: true, deepTimeoutMs: 50, probeGatewayFn }); 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);
}
});
expect(getAuth()?.token).toBe("fallback-local-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",
},
{
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",
},
];
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);
}
}); });
}); });
}); });