mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 16:54:31 +00:00
test: streamline config, audit, and qmd coverage
This commit is contained in:
@@ -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");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user