1750 lines
55 KiB
TypeScript
1750 lines
55 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
|
|
|
|
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
|
|
return withTempHomeBase(fn, { prefix: "clawdbot-config-" });
|
|
}
|
|
|
|
/**
|
|
* Helper to test env var overrides. Saves/restores env vars and resets modules.
|
|
*/
|
|
async function withEnvOverride<T>(
|
|
overrides: Record<string, string | undefined>,
|
|
fn: () => Promise<T>,
|
|
): Promise<T> {
|
|
const saved: Record<string, string | undefined> = {};
|
|
for (const key of Object.keys(overrides)) {
|
|
saved[key] = process.env[key];
|
|
if (overrides[key] === undefined) {
|
|
delete process.env[key];
|
|
} else {
|
|
process.env[key] = overrides[key];
|
|
}
|
|
}
|
|
vi.resetModules();
|
|
try {
|
|
return await fn();
|
|
} finally {
|
|
for (const key of Object.keys(saved)) {
|
|
if (saved[key] === undefined) {
|
|
delete process.env[key];
|
|
} else {
|
|
process.env[key] = saved[key];
|
|
}
|
|
}
|
|
vi.resetModules();
|
|
}
|
|
}
|
|
|
|
describe("config identity defaults", () => {
|
|
let previousHome: string | undefined;
|
|
|
|
beforeEach(() => {
|
|
previousHome = process.env.HOME;
|
|
});
|
|
|
|
afterEach(() => {
|
|
process.env.HOME = previousHome;
|
|
});
|
|
|
|
it("does not derive mentionPatterns when identity is set", async () => {
|
|
await withTempHome(async (home) => {
|
|
const configDir = path.join(home, ".clawdbot");
|
|
await fs.mkdir(configDir, { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(configDir, "clawdbot.json"),
|
|
JSON.stringify(
|
|
{
|
|
agents: {
|
|
list: [
|
|
{
|
|
id: "main",
|
|
identity: {
|
|
name: "Samantha",
|
|
theme: "helpful sloth",
|
|
emoji: "🦥",
|
|
},
|
|
},
|
|
],
|
|
},
|
|
messages: {},
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
"utf-8",
|
|
);
|
|
|
|
vi.resetModules();
|
|
const { loadConfig } = await import("./config.js");
|
|
const cfg = loadConfig();
|
|
|
|
expect(cfg.messages?.responsePrefix).toBeUndefined();
|
|
expect(cfg.messages?.groupChat?.mentionPatterns).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
it("defaults ackReactionScope without setting ackReaction", async () => {
|
|
await withTempHome(async (home) => {
|
|
const configDir = path.join(home, ".clawdbot");
|
|
await fs.mkdir(configDir, { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(configDir, "clawdbot.json"),
|
|
JSON.stringify(
|
|
{
|
|
agents: {
|
|
list: [
|
|
{
|
|
id: "main",
|
|
identity: {
|
|
name: "Samantha",
|
|
theme: "helpful sloth",
|
|
emoji: "🦥",
|
|
},
|
|
},
|
|
],
|
|
},
|
|
messages: {},
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
"utf-8",
|
|
);
|
|
|
|
vi.resetModules();
|
|
const { loadConfig } = await import("./config.js");
|
|
const cfg = loadConfig();
|
|
|
|
expect(cfg.messages?.ackReaction).toBeUndefined();
|
|
expect(cfg.messages?.ackReactionScope).toBe("group-mentions");
|
|
});
|
|
});
|
|
|
|
it("keeps ackReaction unset when identity is missing", async () => {
|
|
await withTempHome(async (home) => {
|
|
const configDir = path.join(home, ".clawdbot");
|
|
await fs.mkdir(configDir, { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(configDir, "clawdbot.json"),
|
|
JSON.stringify(
|
|
{
|
|
messages: {},
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
"utf-8",
|
|
);
|
|
|
|
vi.resetModules();
|
|
const { loadConfig } = await import("./config.js");
|
|
const cfg = loadConfig();
|
|
|
|
expect(cfg.messages?.ackReaction).toBeUndefined();
|
|
expect(cfg.messages?.ackReactionScope).toBe("group-mentions");
|
|
});
|
|
});
|
|
|
|
it("does not override explicit values", async () => {
|
|
await withTempHome(async (home) => {
|
|
const configDir = path.join(home, ".clawdbot");
|
|
await fs.mkdir(configDir, { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(configDir, "clawdbot.json"),
|
|
JSON.stringify(
|
|
{
|
|
agents: {
|
|
list: [
|
|
{
|
|
id: "main",
|
|
identity: {
|
|
name: "Samantha Sloth",
|
|
theme: "space lobster",
|
|
emoji: "🦞",
|
|
},
|
|
groupChat: { mentionPatterns: ["@clawd"] },
|
|
},
|
|
],
|
|
},
|
|
messages: {
|
|
responsePrefix: "✅",
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
"utf-8",
|
|
);
|
|
|
|
vi.resetModules();
|
|
const { loadConfig } = await import("./config.js");
|
|
const cfg = loadConfig();
|
|
|
|
expect(cfg.messages?.responsePrefix).toBe("✅");
|
|
expect(cfg.agents?.list?.[0]?.groupChat?.mentionPatterns).toEqual([
|
|
"@clawd",
|
|
]);
|
|
});
|
|
});
|
|
|
|
it("supports provider textChunkLimit config", async () => {
|
|
await withTempHome(async (home) => {
|
|
const configDir = path.join(home, ".clawdbot");
|
|
await fs.mkdir(configDir, { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(configDir, "clawdbot.json"),
|
|
JSON.stringify(
|
|
{
|
|
messages: {
|
|
messagePrefix: "[clawdbot]",
|
|
responsePrefix: "🦞",
|
|
// legacy field should be ignored (moved to providers)
|
|
textChunkLimit: 9999,
|
|
},
|
|
whatsapp: { allowFrom: ["+15555550123"], textChunkLimit: 4444 },
|
|
telegram: { enabled: true, textChunkLimit: 3333 },
|
|
discord: {
|
|
enabled: true,
|
|
textChunkLimit: 1999,
|
|
maxLinesPerMessage: 17,
|
|
},
|
|
signal: { enabled: true, textChunkLimit: 2222 },
|
|
imessage: { enabled: true, textChunkLimit: 1111 },
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
"utf-8",
|
|
);
|
|
|
|
vi.resetModules();
|
|
const { loadConfig } = await import("./config.js");
|
|
const cfg = loadConfig();
|
|
|
|
expect(cfg.whatsapp?.textChunkLimit).toBe(4444);
|
|
expect(cfg.telegram?.textChunkLimit).toBe(3333);
|
|
expect(cfg.discord?.textChunkLimit).toBe(1999);
|
|
expect(cfg.discord?.maxLinesPerMessage).toBe(17);
|
|
expect(cfg.signal?.textChunkLimit).toBe(2222);
|
|
expect(cfg.imessage?.textChunkLimit).toBe(1111);
|
|
|
|
const legacy = (cfg.messages as unknown as Record<string, unknown>)
|
|
.textChunkLimit;
|
|
expect(legacy).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
it("accepts blank model provider apiKey values", async () => {
|
|
await withTempHome(async (home) => {
|
|
const configDir = path.join(home, ".clawdbot");
|
|
await fs.mkdir(configDir, { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(configDir, "clawdbot.json"),
|
|
JSON.stringify(
|
|
{
|
|
models: {
|
|
mode: "merge",
|
|
providers: {
|
|
minimax: {
|
|
baseUrl: "https://api.minimax.io/anthropic",
|
|
apiKey: "",
|
|
api: "anthropic-messages",
|
|
models: [
|
|
{
|
|
id: "MiniMax-M2.1",
|
|
name: "MiniMax M2.1",
|
|
reasoning: false,
|
|
input: ["text"],
|
|
cost: {
|
|
input: 0,
|
|
output: 0,
|
|
cacheRead: 0,
|
|
cacheWrite: 0,
|
|
},
|
|
contextWindow: 200000,
|
|
maxTokens: 8192,
|
|
},
|
|
],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
"utf-8",
|
|
);
|
|
|
|
vi.resetModules();
|
|
const { loadConfig } = await import("./config.js");
|
|
const cfg = loadConfig();
|
|
|
|
expect(cfg.models?.providers?.minimax?.baseUrl).toBe(
|
|
"https://api.minimax.io/anthropic",
|
|
);
|
|
});
|
|
});
|
|
|
|
it("respects empty responsePrefix to disable identity defaults", async () => {
|
|
await withTempHome(async (home) => {
|
|
const configDir = path.join(home, ".clawdbot");
|
|
await fs.mkdir(configDir, { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(configDir, "clawdbot.json"),
|
|
JSON.stringify(
|
|
{
|
|
agents: {
|
|
list: [
|
|
{
|
|
id: "main",
|
|
identity: {
|
|
name: "Samantha",
|
|
theme: "helpful sloth",
|
|
emoji: "🦥",
|
|
},
|
|
},
|
|
],
|
|
},
|
|
messages: { responsePrefix: "" },
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
"utf-8",
|
|
);
|
|
|
|
vi.resetModules();
|
|
const { loadConfig } = await import("./config.js");
|
|
const cfg = loadConfig();
|
|
|
|
expect(cfg.messages?.responsePrefix).toBe("");
|
|
});
|
|
});
|
|
|
|
it("does not synthesize agent/session when absent", async () => {
|
|
await withTempHome(async (home) => {
|
|
const configDir = path.join(home, ".clawdbot");
|
|
await fs.mkdir(configDir, { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(configDir, "clawdbot.json"),
|
|
JSON.stringify(
|
|
{
|
|
messages: {},
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
"utf-8",
|
|
);
|
|
|
|
vi.resetModules();
|
|
const { loadConfig } = await import("./config.js");
|
|
const cfg = loadConfig();
|
|
|
|
expect(cfg.messages?.responsePrefix).toBeUndefined();
|
|
expect(cfg.messages?.groupChat?.mentionPatterns).toBeUndefined();
|
|
expect(cfg.agents).toBeUndefined();
|
|
expect(cfg.session).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
it("does not derive responsePrefix from identity emoji", async () => {
|
|
await withTempHome(async (home) => {
|
|
const configDir = path.join(home, ".clawdbot");
|
|
await fs.mkdir(configDir, { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(configDir, "clawdbot.json"),
|
|
JSON.stringify(
|
|
{
|
|
agents: {
|
|
list: [
|
|
{
|
|
id: "main",
|
|
identity: {
|
|
name: "Clawd",
|
|
theme: "space lobster",
|
|
emoji: "🦞",
|
|
},
|
|
},
|
|
],
|
|
},
|
|
messages: {},
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
"utf-8",
|
|
);
|
|
|
|
vi.resetModules();
|
|
const { loadConfig } = await import("./config.js");
|
|
const cfg = loadConfig();
|
|
|
|
expect(cfg.messages?.responsePrefix).toBeUndefined();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("config env vars", () => {
|
|
it("applies env vars from env block when missing", async () => {
|
|
await withTempHome(async (home) => {
|
|
const configDir = path.join(home, ".clawdbot");
|
|
await fs.mkdir(configDir, { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(configDir, "clawdbot.json"),
|
|
JSON.stringify(
|
|
{
|
|
env: { OPENROUTER_API_KEY: "config-key" },
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
"utf-8",
|
|
);
|
|
|
|
await withEnvOverride({ OPENROUTER_API_KEY: undefined }, async () => {
|
|
const { loadConfig } = await import("./config.js");
|
|
loadConfig();
|
|
expect(process.env.OPENROUTER_API_KEY).toBe("config-key");
|
|
});
|
|
});
|
|
});
|
|
|
|
it("does not override existing env vars", async () => {
|
|
await withTempHome(async (home) => {
|
|
const configDir = path.join(home, ".clawdbot");
|
|
await fs.mkdir(configDir, { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(configDir, "clawdbot.json"),
|
|
JSON.stringify(
|
|
{
|
|
env: { OPENROUTER_API_KEY: "config-key" },
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
"utf-8",
|
|
);
|
|
|
|
await withEnvOverride(
|
|
{ OPENROUTER_API_KEY: "existing-key" },
|
|
async () => {
|
|
const { loadConfig } = await import("./config.js");
|
|
loadConfig();
|
|
expect(process.env.OPENROUTER_API_KEY).toBe("existing-key");
|
|
},
|
|
);
|
|
});
|
|
});
|
|
|
|
it("applies env vars from env.vars when missing", async () => {
|
|
await withTempHome(async (home) => {
|
|
const configDir = path.join(home, ".clawdbot");
|
|
await fs.mkdir(configDir, { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(configDir, "clawdbot.json"),
|
|
JSON.stringify(
|
|
{
|
|
env: { vars: { GROQ_API_KEY: "gsk-config" } },
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
"utf-8",
|
|
);
|
|
|
|
await withEnvOverride({ GROQ_API_KEY: undefined }, async () => {
|
|
const { loadConfig } = await import("./config.js");
|
|
loadConfig();
|
|
expect(process.env.GROQ_API_KEY).toBe("gsk-config");
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("config pruning defaults", () => {
|
|
it("defaults contextPruning mode to adaptive", async () => {
|
|
await withTempHome(async (home) => {
|
|
const configDir = path.join(home, ".clawdbot");
|
|
await fs.mkdir(configDir, { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(configDir, "clawdbot.json"),
|
|
JSON.stringify({ agents: { defaults: {} } }, null, 2),
|
|
"utf-8",
|
|
);
|
|
|
|
vi.resetModules();
|
|
const { loadConfig } = await import("./config.js");
|
|
const cfg = loadConfig();
|
|
|
|
expect(cfg.agents?.defaults?.contextPruning?.mode).toBe("adaptive");
|
|
});
|
|
});
|
|
|
|
it("does not override explicit contextPruning mode", async () => {
|
|
await withTempHome(async (home) => {
|
|
const configDir = path.join(home, ".clawdbot");
|
|
await fs.mkdir(configDir, { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(configDir, "clawdbot.json"),
|
|
JSON.stringify(
|
|
{ agents: { defaults: { contextPruning: { mode: "off" } } } },
|
|
null,
|
|
2,
|
|
),
|
|
"utf-8",
|
|
);
|
|
|
|
vi.resetModules();
|
|
const { loadConfig } = await import("./config.js");
|
|
const cfg = loadConfig();
|
|
|
|
expect(cfg.agents?.defaults?.contextPruning?.mode).toBe("off");
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("config compaction settings", () => {
|
|
it("preserves memory flush config values", async () => {
|
|
await withTempHome(async (home) => {
|
|
const configDir = path.join(home, ".clawdbot");
|
|
await fs.mkdir(configDir, { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(configDir, "clawdbot.json"),
|
|
JSON.stringify(
|
|
{
|
|
agents: {
|
|
defaults: {
|
|
compaction: {
|
|
mode: "safeguard",
|
|
reserveTokensFloor: 12_345,
|
|
memoryFlush: {
|
|
enabled: false,
|
|
softThresholdTokens: 1234,
|
|
prompt: "Write notes.",
|
|
systemPrompt: "Flush memory now.",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
"utf-8",
|
|
);
|
|
|
|
vi.resetModules();
|
|
const { loadConfig } = await import("./config.js");
|
|
const cfg = loadConfig();
|
|
|
|
expect(cfg.agents?.defaults?.compaction?.reserveTokensFloor).toBe(12_345);
|
|
expect(cfg.agents?.defaults?.compaction?.mode).toBe("safeguard");
|
|
expect(cfg.agents?.defaults?.compaction?.memoryFlush?.enabled).toBe(
|
|
false,
|
|
);
|
|
expect(
|
|
cfg.agents?.defaults?.compaction?.memoryFlush?.softThresholdTokens,
|
|
).toBe(1234);
|
|
expect(cfg.agents?.defaults?.compaction?.memoryFlush?.prompt).toBe(
|
|
"Write notes.",
|
|
);
|
|
expect(cfg.agents?.defaults?.compaction?.memoryFlush?.systemPrompt).toBe(
|
|
"Flush memory now.",
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("config discord", () => {
|
|
let previousHome: string | undefined;
|
|
|
|
beforeEach(() => {
|
|
previousHome = process.env.HOME;
|
|
});
|
|
|
|
afterEach(() => {
|
|
process.env.HOME = previousHome;
|
|
});
|
|
|
|
it("loads discord guild map + dm group settings", async () => {
|
|
await withTempHome(async (home) => {
|
|
const configDir = path.join(home, ".clawdbot");
|
|
await fs.mkdir(configDir, { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(configDir, "clawdbot.json"),
|
|
JSON.stringify(
|
|
{
|
|
discord: {
|
|
enabled: true,
|
|
dm: {
|
|
enabled: true,
|
|
allowFrom: ["steipete"],
|
|
groupEnabled: true,
|
|
groupChannels: ["clawd-dm"],
|
|
},
|
|
guilds: {
|
|
"123": {
|
|
slug: "friends-of-clawd",
|
|
requireMention: false,
|
|
users: ["steipete"],
|
|
channels: {
|
|
general: { allow: true },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
"utf-8",
|
|
);
|
|
|
|
vi.resetModules();
|
|
const { loadConfig } = await import("./config.js");
|
|
const cfg = loadConfig();
|
|
|
|
expect(cfg.discord?.enabled).toBe(true);
|
|
expect(cfg.discord?.dm?.groupEnabled).toBe(true);
|
|
expect(cfg.discord?.dm?.groupChannels).toEqual(["clawd-dm"]);
|
|
expect(cfg.discord?.guilds?.["123"]?.slug).toBe("friends-of-clawd");
|
|
expect(cfg.discord?.guilds?.["123"]?.channels?.general?.allow).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("config msteams", () => {
|
|
it("accepts replyStyle at global/team/channel levels", async () => {
|
|
vi.resetModules();
|
|
const { validateConfigObject } = await import("./config.js");
|
|
const res = validateConfigObject({
|
|
msteams: {
|
|
replyStyle: "top-level",
|
|
teams: {
|
|
team123: {
|
|
replyStyle: "thread",
|
|
channels: {
|
|
chan456: { replyStyle: "top-level" },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
expect(res.ok).toBe(true);
|
|
if (res.ok) {
|
|
expect(res.config.msteams?.replyStyle).toBe("top-level");
|
|
expect(res.config.msteams?.teams?.team123?.replyStyle).toBe("thread");
|
|
expect(
|
|
res.config.msteams?.teams?.team123?.channels?.chan456?.replyStyle,
|
|
).toBe("top-level");
|
|
}
|
|
});
|
|
|
|
it("rejects invalid replyStyle", async () => {
|
|
vi.resetModules();
|
|
const { validateConfigObject } = await import("./config.js");
|
|
const res = validateConfigObject({
|
|
msteams: { replyStyle: "nope" },
|
|
});
|
|
expect(res.ok).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("Nix integration (U3, U5, U9)", () => {
|
|
describe("U3: isNixMode env var detection", () => {
|
|
it("isNixMode is false when CLAWDBOT_NIX_MODE is not set", async () => {
|
|
await withEnvOverride({ CLAWDBOT_NIX_MODE: undefined }, async () => {
|
|
const { isNixMode } = await import("./config.js");
|
|
expect(isNixMode).toBe(false);
|
|
});
|
|
});
|
|
|
|
it("isNixMode is false when CLAWDBOT_NIX_MODE is empty", async () => {
|
|
await withEnvOverride({ CLAWDBOT_NIX_MODE: "" }, async () => {
|
|
const { isNixMode } = await import("./config.js");
|
|
expect(isNixMode).toBe(false);
|
|
});
|
|
});
|
|
|
|
it("isNixMode is false when CLAWDBOT_NIX_MODE is not '1'", async () => {
|
|
await withEnvOverride({ CLAWDBOT_NIX_MODE: "true" }, async () => {
|
|
const { isNixMode } = await import("./config.js");
|
|
expect(isNixMode).toBe(false);
|
|
});
|
|
});
|
|
|
|
it("isNixMode is true when CLAWDBOT_NIX_MODE=1", async () => {
|
|
await withEnvOverride({ CLAWDBOT_NIX_MODE: "1" }, async () => {
|
|
const { isNixMode } = await import("./config.js");
|
|
expect(isNixMode).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("U5: CONFIG_PATH and STATE_DIR env var overrides", () => {
|
|
it("STATE_DIR_CLAWDBOT defaults to ~/.clawdbot when env not set", async () => {
|
|
await withEnvOverride({ CLAWDBOT_STATE_DIR: undefined }, async () => {
|
|
const { STATE_DIR_CLAWDBOT } = await import("./config.js");
|
|
expect(STATE_DIR_CLAWDBOT).toMatch(/\.clawdbot$/);
|
|
});
|
|
});
|
|
|
|
it("STATE_DIR_CLAWDBOT respects CLAWDBOT_STATE_DIR override", async () => {
|
|
await withEnvOverride(
|
|
{ CLAWDBOT_STATE_DIR: "/custom/state/dir" },
|
|
async () => {
|
|
const { STATE_DIR_CLAWDBOT } = await import("./config.js");
|
|
expect(STATE_DIR_CLAWDBOT).toBe(path.resolve("/custom/state/dir"));
|
|
},
|
|
);
|
|
});
|
|
|
|
it("CONFIG_PATH_CLAWDBOT defaults to ~/.clawdbot/clawdbot.json when env not set", async () => {
|
|
await withEnvOverride(
|
|
{ CLAWDBOT_CONFIG_PATH: undefined, CLAWDBOT_STATE_DIR: undefined },
|
|
async () => {
|
|
const { CONFIG_PATH_CLAWDBOT } = await import("./config.js");
|
|
expect(CONFIG_PATH_CLAWDBOT).toMatch(
|
|
/\.clawdbot[\\/]clawdbot\.json$/,
|
|
);
|
|
},
|
|
);
|
|
});
|
|
|
|
it("CONFIG_PATH_CLAWDBOT respects CLAWDBOT_CONFIG_PATH override", async () => {
|
|
await withEnvOverride(
|
|
{ CLAWDBOT_CONFIG_PATH: "/nix/store/abc/clawdbot.json" },
|
|
async () => {
|
|
const { CONFIG_PATH_CLAWDBOT } = await import("./config.js");
|
|
expect(CONFIG_PATH_CLAWDBOT).toBe(
|
|
path.resolve("/nix/store/abc/clawdbot.json"),
|
|
);
|
|
},
|
|
);
|
|
});
|
|
|
|
it("CONFIG_PATH_CLAWDBOT expands ~ in CLAWDBOT_CONFIG_PATH override", async () => {
|
|
await withTempHome(async (home) => {
|
|
await withEnvOverride(
|
|
{ CLAWDBOT_CONFIG_PATH: "~/.clawdbot/custom.json" },
|
|
async () => {
|
|
const { CONFIG_PATH_CLAWDBOT } = await import("./config.js");
|
|
expect(CONFIG_PATH_CLAWDBOT).toBe(
|
|
path.join(home, ".clawdbot", "custom.json"),
|
|
);
|
|
},
|
|
);
|
|
});
|
|
});
|
|
|
|
it("CONFIG_PATH_CLAWDBOT uses STATE_DIR_CLAWDBOT when only state dir is overridden", async () => {
|
|
await withEnvOverride(
|
|
{
|
|
CLAWDBOT_CONFIG_PATH: undefined,
|
|
CLAWDBOT_STATE_DIR: "/custom/state",
|
|
},
|
|
async () => {
|
|
const { CONFIG_PATH_CLAWDBOT } = await import("./config.js");
|
|
expect(CONFIG_PATH_CLAWDBOT).toBe(
|
|
path.join(path.resolve("/custom/state"), "clawdbot.json"),
|
|
);
|
|
},
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("U5b: tilde expansion for config paths", () => {
|
|
it("expands ~ in common path-ish config fields", async () => {
|
|
await withTempHome(async (home) => {
|
|
const configDir = path.join(home, ".clawdbot");
|
|
await fs.mkdir(configDir, { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(configDir, "clawdbot.json"),
|
|
JSON.stringify(
|
|
{
|
|
plugins: {
|
|
load: {
|
|
paths: ["~/plugins/demo-plugin"],
|
|
},
|
|
},
|
|
agents: {
|
|
defaults: { workspace: "~/ws-default" },
|
|
list: [
|
|
{
|
|
id: "main",
|
|
workspace: "~/ws-agent",
|
|
agentDir: "~/.clawdbot/agents/main",
|
|
sandbox: { workspaceRoot: "~/sandbox-root" },
|
|
},
|
|
],
|
|
},
|
|
whatsapp: {
|
|
accounts: {
|
|
personal: {
|
|
authDir: "~/.clawdbot/credentials/wa-personal",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
"utf-8",
|
|
);
|
|
|
|
vi.resetModules();
|
|
const { loadConfig } = await import("./config.js");
|
|
const cfg = loadConfig();
|
|
|
|
expect(cfg.plugins?.load?.paths?.[0]).toBe(
|
|
path.join(home, "plugins", "demo-plugin"),
|
|
);
|
|
expect(cfg.agents?.defaults?.workspace).toBe(
|
|
path.join(home, "ws-default"),
|
|
);
|
|
expect(cfg.agents?.list?.[0]?.workspace).toBe(
|
|
path.join(home, "ws-agent"),
|
|
);
|
|
expect(cfg.agents?.list?.[0]?.agentDir).toBe(
|
|
path.join(home, ".clawdbot", "agents", "main"),
|
|
);
|
|
expect(cfg.agents?.list?.[0]?.sandbox?.workspaceRoot).toBe(
|
|
path.join(home, "sandbox-root"),
|
|
);
|
|
expect(cfg.whatsapp?.accounts?.personal?.authDir).toBe(
|
|
path.join(home, ".clawdbot", "credentials", "wa-personal"),
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("U6: gateway port resolution", () => {
|
|
it("uses default when env and config are unset", async () => {
|
|
await withEnvOverride({ CLAWDBOT_GATEWAY_PORT: undefined }, async () => {
|
|
const { DEFAULT_GATEWAY_PORT, resolveGatewayPort } = await import(
|
|
"./config.js"
|
|
);
|
|
expect(resolveGatewayPort({})).toBe(DEFAULT_GATEWAY_PORT);
|
|
});
|
|
});
|
|
|
|
it("prefers CLAWDBOT_GATEWAY_PORT over config", async () => {
|
|
await withEnvOverride({ CLAWDBOT_GATEWAY_PORT: "19001" }, async () => {
|
|
const { resolveGatewayPort } = await import("./config.js");
|
|
expect(resolveGatewayPort({ gateway: { port: 19002 } })).toBe(19001);
|
|
});
|
|
});
|
|
|
|
it("falls back to config when env is invalid", async () => {
|
|
await withEnvOverride({ CLAWDBOT_GATEWAY_PORT: "nope" }, async () => {
|
|
const { resolveGatewayPort } = await import("./config.js");
|
|
expect(resolveGatewayPort({ gateway: { port: 19003 } })).toBe(19003);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("U9: telegram.tokenFile schema validation", () => {
|
|
it("accepts config with only botToken", async () => {
|
|
await withTempHome(async (home) => {
|
|
const configDir = path.join(home, ".clawdbot");
|
|
await fs.mkdir(configDir, { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(configDir, "clawdbot.json"),
|
|
JSON.stringify({
|
|
telegram: { botToken: "123:ABC" },
|
|
}),
|
|
"utf-8",
|
|
);
|
|
|
|
vi.resetModules();
|
|
const { loadConfig } = await import("./config.js");
|
|
const cfg = loadConfig();
|
|
expect(cfg.telegram?.botToken).toBe("123:ABC");
|
|
expect(cfg.telegram?.tokenFile).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
it("accepts config with only tokenFile", async () => {
|
|
await withTempHome(async (home) => {
|
|
const configDir = path.join(home, ".clawdbot");
|
|
await fs.mkdir(configDir, { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(configDir, "clawdbot.json"),
|
|
JSON.stringify({
|
|
telegram: { tokenFile: "/run/agenix/telegram-token" },
|
|
}),
|
|
"utf-8",
|
|
);
|
|
|
|
vi.resetModules();
|
|
const { loadConfig } = await import("./config.js");
|
|
const cfg = loadConfig();
|
|
expect(cfg.telegram?.tokenFile).toBe("/run/agenix/telegram-token");
|
|
expect(cfg.telegram?.botToken).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
it("accepts config with both botToken and tokenFile", async () => {
|
|
await withTempHome(async (home) => {
|
|
const configDir = path.join(home, ".clawdbot");
|
|
await fs.mkdir(configDir, { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(configDir, "clawdbot.json"),
|
|
JSON.stringify({
|
|
telegram: {
|
|
botToken: "fallback:token",
|
|
tokenFile: "/run/agenix/telegram-token",
|
|
},
|
|
}),
|
|
"utf-8",
|
|
);
|
|
|
|
vi.resetModules();
|
|
const { loadConfig } = await import("./config.js");
|
|
const cfg = loadConfig();
|
|
expect(cfg.telegram?.botToken).toBe("fallback:token");
|
|
expect(cfg.telegram?.tokenFile).toBe("/run/agenix/telegram-token");
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("talk api key fallback", () => {
|
|
let previousEnv: string | undefined;
|
|
|
|
beforeEach(() => {
|
|
previousEnv = process.env.ELEVENLABS_API_KEY;
|
|
delete process.env.ELEVENLABS_API_KEY;
|
|
});
|
|
|
|
afterEach(() => {
|
|
process.env.ELEVENLABS_API_KEY = previousEnv;
|
|
});
|
|
|
|
it("injects talk.apiKey from profile when config is missing", async () => {
|
|
await withTempHome(async (home) => {
|
|
await fs.writeFile(
|
|
path.join(home, ".profile"),
|
|
"export ELEVENLABS_API_KEY=profile-key\n",
|
|
"utf-8",
|
|
);
|
|
|
|
vi.resetModules();
|
|
const { readConfigFileSnapshot } = await import("./config.js");
|
|
const snap = await readConfigFileSnapshot();
|
|
|
|
expect(snap.config?.talk?.apiKey).toBe("profile-key");
|
|
expect(snap.exists).toBe(false);
|
|
});
|
|
});
|
|
|
|
it("prefers ELEVENLABS_API_KEY env over profile", async () => {
|
|
await withTempHome(async (home) => {
|
|
await fs.writeFile(
|
|
path.join(home, ".profile"),
|
|
"export ELEVENLABS_API_KEY=profile-key\n",
|
|
"utf-8",
|
|
);
|
|
process.env.ELEVENLABS_API_KEY = "env-key";
|
|
|
|
vi.resetModules();
|
|
const { readConfigFileSnapshot } = await import("./config.js");
|
|
const snap = await readConfigFileSnapshot();
|
|
|
|
expect(snap.config?.talk?.apiKey).toBe("env-key");
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("talk.voiceAliases", () => {
|
|
it("accepts a string map of voice aliases", async () => {
|
|
vi.resetModules();
|
|
const { validateConfigObject } = await import("./config.js");
|
|
const res = validateConfigObject({
|
|
talk: {
|
|
voiceAliases: {
|
|
Clawd: "EXAVITQu4vr4xnSDxMaL",
|
|
Roger: "CwhRBWXzGAHq8TQ4Fs17",
|
|
},
|
|
},
|
|
});
|
|
expect(res.ok).toBe(true);
|
|
});
|
|
|
|
it("rejects non-string voice alias values", async () => {
|
|
vi.resetModules();
|
|
const { validateConfigObject } = await import("./config.js");
|
|
const res = validateConfigObject({
|
|
talk: {
|
|
voiceAliases: {
|
|
Clawd: 123,
|
|
},
|
|
},
|
|
});
|
|
expect(res.ok).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("broadcast", () => {
|
|
it("accepts a broadcast peer map with strategy", async () => {
|
|
vi.resetModules();
|
|
const { validateConfigObject } = await import("./config.js");
|
|
const res = validateConfigObject({
|
|
agents: {
|
|
list: [{ id: "alfred" }, { id: "baerbel" }],
|
|
},
|
|
broadcast: {
|
|
strategy: "parallel",
|
|
"120363403215116621@g.us": ["alfred", "baerbel"],
|
|
},
|
|
});
|
|
expect(res.ok).toBe(true);
|
|
});
|
|
|
|
it("rejects invalid broadcast strategy", async () => {
|
|
vi.resetModules();
|
|
const { validateConfigObject } = await import("./config.js");
|
|
const res = validateConfigObject({
|
|
broadcast: { strategy: "nope" },
|
|
});
|
|
expect(res.ok).toBe(false);
|
|
});
|
|
|
|
it("rejects non-array broadcast entries", async () => {
|
|
vi.resetModules();
|
|
const { validateConfigObject } = await import("./config.js");
|
|
const res = validateConfigObject({
|
|
broadcast: { "120363403215116621@g.us": 123 },
|
|
});
|
|
expect(res.ok).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("legacy config detection", () => {
|
|
it("rejects routing.allowFrom", async () => {
|
|
vi.resetModules();
|
|
const { validateConfigObject } = await import("./config.js");
|
|
const res = validateConfigObject({
|
|
routing: { allowFrom: ["+15555550123"] },
|
|
});
|
|
expect(res.ok).toBe(false);
|
|
if (!res.ok) {
|
|
expect(res.issues[0]?.path).toBe("routing.allowFrom");
|
|
}
|
|
});
|
|
|
|
it("rejects routing.groupChat.requireMention", async () => {
|
|
vi.resetModules();
|
|
const { validateConfigObject } = await import("./config.js");
|
|
const res = validateConfigObject({
|
|
routing: { groupChat: { requireMention: false } },
|
|
});
|
|
expect(res.ok).toBe(false);
|
|
if (!res.ok) {
|
|
expect(res.issues[0]?.path).toBe("routing.groupChat.requireMention");
|
|
}
|
|
});
|
|
|
|
it("migrates routing.allowFrom to whatsapp.allowFrom", async () => {
|
|
vi.resetModules();
|
|
const { migrateLegacyConfig } = await import("./config.js");
|
|
const res = migrateLegacyConfig({
|
|
routing: { allowFrom: ["+15555550123"] },
|
|
});
|
|
expect(res.changes).toContain(
|
|
"Moved routing.allowFrom → whatsapp.allowFrom.",
|
|
);
|
|
expect(res.config?.whatsapp?.allowFrom).toEqual(["+15555550123"]);
|
|
expect(res.config?.routing?.allowFrom).toBeUndefined();
|
|
});
|
|
|
|
it("migrates routing.groupChat.requireMention to whatsapp/telegram/imessage groups", async () => {
|
|
vi.resetModules();
|
|
const { migrateLegacyConfig } = await import("./config.js");
|
|
const res = migrateLegacyConfig({
|
|
routing: { groupChat: { requireMention: false } },
|
|
});
|
|
expect(res.changes).toContain(
|
|
'Moved routing.groupChat.requireMention → whatsapp.groups."*".requireMention.',
|
|
);
|
|
expect(res.changes).toContain(
|
|
'Moved routing.groupChat.requireMention → telegram.groups."*".requireMention.',
|
|
);
|
|
expect(res.changes).toContain(
|
|
'Moved routing.groupChat.requireMention → imessage.groups."*".requireMention.',
|
|
);
|
|
expect(res.config?.whatsapp?.groups?.["*"]?.requireMention).toBe(false);
|
|
expect(res.config?.telegram?.groups?.["*"]?.requireMention).toBe(false);
|
|
expect(res.config?.imessage?.groups?.["*"]?.requireMention).toBe(false);
|
|
expect(res.config?.routing?.groupChat?.requireMention).toBeUndefined();
|
|
});
|
|
|
|
it("migrates routing.groupChat.mentionPatterns to messages.groupChat.mentionPatterns", async () => {
|
|
vi.resetModules();
|
|
const { migrateLegacyConfig } = await import("./config.js");
|
|
const res = migrateLegacyConfig({
|
|
routing: { groupChat: { mentionPatterns: ["@clawd"] } },
|
|
});
|
|
expect(res.changes).toContain(
|
|
"Moved routing.groupChat.mentionPatterns → messages.groupChat.mentionPatterns.",
|
|
);
|
|
expect(res.config?.messages?.groupChat?.mentionPatterns).toEqual([
|
|
"@clawd",
|
|
]);
|
|
expect(res.config?.routing?.groupChat?.mentionPatterns).toBeUndefined();
|
|
});
|
|
|
|
it("migrates routing agentToAgent/queue/transcribeAudio to tools/messages/audio", async () => {
|
|
vi.resetModules();
|
|
const { migrateLegacyConfig } = await import("./config.js");
|
|
const res = migrateLegacyConfig({
|
|
routing: {
|
|
agentToAgent: { enabled: true, allow: ["main"] },
|
|
queue: { mode: "queue", cap: 3 },
|
|
transcribeAudio: {
|
|
command: ["whisper", "--model", "base"],
|
|
timeoutSeconds: 2,
|
|
},
|
|
},
|
|
});
|
|
expect(res.changes).toContain(
|
|
"Moved routing.agentToAgent → tools.agentToAgent.",
|
|
);
|
|
expect(res.changes).toContain("Moved routing.queue → messages.queue.");
|
|
expect(res.changes).toContain(
|
|
"Moved routing.transcribeAudio → tools.audio.transcription.",
|
|
);
|
|
expect(res.config?.tools?.agentToAgent).toEqual({
|
|
enabled: true,
|
|
allow: ["main"],
|
|
});
|
|
expect(res.config?.messages?.queue).toEqual({
|
|
mode: "queue",
|
|
cap: 3,
|
|
});
|
|
expect(res.config?.tools?.audio?.transcription).toEqual({
|
|
args: ["--model", "base"],
|
|
timeoutSeconds: 2,
|
|
});
|
|
expect(res.config?.routing).toBeUndefined();
|
|
});
|
|
|
|
it("migrates agent config into agents.defaults and tools", async () => {
|
|
vi.resetModules();
|
|
const { migrateLegacyConfig } = await import("./config.js");
|
|
const res = migrateLegacyConfig({
|
|
agent: {
|
|
model: "openai/gpt-5.2",
|
|
tools: { allow: ["sessions.list"], deny: ["danger"] },
|
|
elevated: { enabled: true, allowFrom: { discord: ["user:1"] } },
|
|
bash: { timeoutSec: 12 },
|
|
sandbox: { tools: { allow: ["browser.open"] } },
|
|
subagents: { tools: { deny: ["sandbox"] } },
|
|
},
|
|
});
|
|
expect(res.changes).toContain("Moved agent.tools.allow → tools.allow.");
|
|
expect(res.changes).toContain("Moved agent.tools.deny → tools.deny.");
|
|
expect(res.changes).toContain("Moved agent.elevated → tools.elevated.");
|
|
expect(res.changes).toContain("Moved agent.bash → tools.exec.");
|
|
expect(res.changes).toContain(
|
|
"Moved agent.sandbox.tools → tools.sandbox.tools.",
|
|
);
|
|
expect(res.changes).toContain(
|
|
"Moved agent.subagents.tools → tools.subagents.tools.",
|
|
);
|
|
expect(res.changes).toContain("Moved agent → agents.defaults.");
|
|
expect(res.config?.agents?.defaults?.model).toEqual({
|
|
primary: "openai/gpt-5.2",
|
|
fallbacks: [],
|
|
});
|
|
expect(res.config?.tools?.allow).toEqual(["sessions.list"]);
|
|
expect(res.config?.tools?.deny).toEqual(["danger"]);
|
|
expect(res.config?.tools?.elevated).toEqual({
|
|
enabled: true,
|
|
allowFrom: { discord: ["user:1"] },
|
|
});
|
|
expect(res.config?.tools?.exec).toEqual({ timeoutSec: 12 });
|
|
expect(res.config?.tools?.sandbox?.tools).toEqual({
|
|
allow: ["browser.open"],
|
|
});
|
|
expect(res.config?.tools?.subagents?.tools).toEqual({
|
|
deny: ["sandbox"],
|
|
});
|
|
expect((res.config as { agent?: unknown }).agent).toBeUndefined();
|
|
});
|
|
|
|
it("accepts per-agent tools.elevated overrides", async () => {
|
|
vi.resetModules();
|
|
const { validateConfigObject } = await import("./config.js");
|
|
const res = validateConfigObject({
|
|
tools: {
|
|
elevated: {
|
|
allowFrom: { whatsapp: ["+15555550123"] },
|
|
},
|
|
},
|
|
agents: {
|
|
list: [
|
|
{
|
|
id: "work",
|
|
workspace: "~/clawd-work",
|
|
tools: {
|
|
elevated: {
|
|
enabled: false,
|
|
allowFrom: { whatsapp: ["+15555550123"] },
|
|
},
|
|
},
|
|
},
|
|
],
|
|
},
|
|
});
|
|
expect(res.ok).toBe(true);
|
|
if (res.ok) {
|
|
expect(res.config?.agents?.list?.[0]?.tools?.elevated).toEqual({
|
|
enabled: false,
|
|
allowFrom: { whatsapp: ["+15555550123"] },
|
|
});
|
|
}
|
|
});
|
|
|
|
it("rejects telegram.requireMention", async () => {
|
|
vi.resetModules();
|
|
const { validateConfigObject } = await import("./config.js");
|
|
const res = validateConfigObject({
|
|
telegram: { requireMention: true },
|
|
});
|
|
expect(res.ok).toBe(false);
|
|
if (!res.ok) {
|
|
expect(res.issues[0]?.path).toBe("telegram.requireMention");
|
|
}
|
|
});
|
|
|
|
it("rejects gateway.token", async () => {
|
|
vi.resetModules();
|
|
const { validateConfigObject } = await import("./config.js");
|
|
const res = validateConfigObject({
|
|
gateway: { token: "legacy-token" },
|
|
});
|
|
expect(res.ok).toBe(false);
|
|
if (!res.ok) {
|
|
expect(res.issues[0]?.path).toBe("gateway.token");
|
|
}
|
|
});
|
|
|
|
it("migrates gateway.token to gateway.auth.token", async () => {
|
|
vi.resetModules();
|
|
const { migrateLegacyConfig } = await import("./config.js");
|
|
const res = migrateLegacyConfig({
|
|
gateway: { token: "legacy-token" },
|
|
});
|
|
expect(res.changes).toContain("Moved gateway.token → gateway.auth.token.");
|
|
expect(res.config?.gateway?.auth?.token).toBe("legacy-token");
|
|
expect(res.config?.gateway?.auth?.mode).toBe("token");
|
|
expect((res.config?.gateway as { token?: string })?.token).toBeUndefined();
|
|
});
|
|
|
|
it("migrates gateway.bind and bridge.bind from 'tailnet' to 'auto'", async () => {
|
|
vi.resetModules();
|
|
const { migrateLegacyConfig } = await import("./config.js");
|
|
const res = migrateLegacyConfig({
|
|
gateway: { bind: "tailnet" as const },
|
|
bridge: { bind: "tailnet" as const },
|
|
});
|
|
expect(res.changes).toContain(
|
|
"Migrated gateway.bind from 'tailnet' to 'auto'.",
|
|
);
|
|
expect(res.changes).toContain(
|
|
"Migrated bridge.bind from 'tailnet' to 'auto'.",
|
|
);
|
|
expect(res.config?.gateway?.bind).toBe("auto");
|
|
expect(res.config?.bridge?.bind).toBe("auto");
|
|
});
|
|
|
|
it('rejects telegram.dmPolicy="open" without allowFrom "*"', async () => {
|
|
vi.resetModules();
|
|
const { validateConfigObject } = await import("./config.js");
|
|
const res = validateConfigObject({
|
|
telegram: { dmPolicy: "open", allowFrom: ["123456789"] },
|
|
});
|
|
expect(res.ok).toBe(false);
|
|
if (!res.ok) {
|
|
expect(res.issues[0]?.path).toBe("telegram.allowFrom");
|
|
}
|
|
});
|
|
|
|
it('accepts telegram.dmPolicy="open" with allowFrom "*"', async () => {
|
|
vi.resetModules();
|
|
const { validateConfigObject } = await import("./config.js");
|
|
const res = validateConfigObject({
|
|
telegram: { dmPolicy: "open", allowFrom: ["*"] },
|
|
});
|
|
expect(res.ok).toBe(true);
|
|
if (res.ok) {
|
|
expect(res.config.telegram?.dmPolicy).toBe("open");
|
|
}
|
|
});
|
|
|
|
it("defaults telegram.dmPolicy to pairing when telegram section exists", async () => {
|
|
vi.resetModules();
|
|
const { validateConfigObject } = await import("./config.js");
|
|
const res = validateConfigObject({ telegram: {} });
|
|
expect(res.ok).toBe(true);
|
|
if (res.ok) {
|
|
expect(res.config.telegram?.dmPolicy).toBe("pairing");
|
|
}
|
|
});
|
|
|
|
it("defaults telegram.groupPolicy to allowlist when telegram section exists", async () => {
|
|
vi.resetModules();
|
|
const { validateConfigObject } = await import("./config.js");
|
|
const res = validateConfigObject({ telegram: {} });
|
|
expect(res.ok).toBe(true);
|
|
if (res.ok) {
|
|
expect(res.config.telegram?.groupPolicy).toBe("allowlist");
|
|
}
|
|
});
|
|
|
|
it("defaults telegram.streamMode to partial when telegram section exists", async () => {
|
|
vi.resetModules();
|
|
const { validateConfigObject } = await import("./config.js");
|
|
const res = validateConfigObject({ telegram: {} });
|
|
expect(res.ok).toBe(true);
|
|
if (res.ok) {
|
|
expect(res.config.telegram?.streamMode).toBe("partial");
|
|
}
|
|
});
|
|
|
|
it('rejects whatsapp.dmPolicy="open" without allowFrom "*"', async () => {
|
|
vi.resetModules();
|
|
const { validateConfigObject } = await import("./config.js");
|
|
const res = validateConfigObject({
|
|
whatsapp: { dmPolicy: "open", allowFrom: ["+15555550123"] },
|
|
});
|
|
expect(res.ok).toBe(false);
|
|
if (!res.ok) {
|
|
expect(res.issues[0]?.path).toBe("whatsapp.allowFrom");
|
|
}
|
|
});
|
|
|
|
it('accepts whatsapp.dmPolicy="open" with allowFrom "*"', async () => {
|
|
vi.resetModules();
|
|
const { validateConfigObject } = await import("./config.js");
|
|
const res = validateConfigObject({
|
|
whatsapp: { dmPolicy: "open", allowFrom: ["*"] },
|
|
});
|
|
expect(res.ok).toBe(true);
|
|
if (res.ok) {
|
|
expect(res.config.whatsapp?.dmPolicy).toBe("open");
|
|
}
|
|
});
|
|
|
|
it("defaults whatsapp.dmPolicy to pairing when whatsapp section exists", async () => {
|
|
vi.resetModules();
|
|
const { validateConfigObject } = await import("./config.js");
|
|
const res = validateConfigObject({ whatsapp: {} });
|
|
expect(res.ok).toBe(true);
|
|
if (res.ok) {
|
|
expect(res.config.whatsapp?.dmPolicy).toBe("pairing");
|
|
}
|
|
});
|
|
|
|
it("defaults whatsapp.groupPolicy to allowlist when whatsapp section exists", async () => {
|
|
vi.resetModules();
|
|
const { validateConfigObject } = await import("./config.js");
|
|
const res = validateConfigObject({ whatsapp: {} });
|
|
expect(res.ok).toBe(true);
|
|
if (res.ok) {
|
|
expect(res.config.whatsapp?.groupPolicy).toBe("allowlist");
|
|
}
|
|
});
|
|
|
|
it('rejects signal.dmPolicy="open" without allowFrom "*"', async () => {
|
|
vi.resetModules();
|
|
const { validateConfigObject } = await import("./config.js");
|
|
const res = validateConfigObject({
|
|
signal: { dmPolicy: "open", allowFrom: ["+15555550123"] },
|
|
});
|
|
expect(res.ok).toBe(false);
|
|
if (!res.ok) {
|
|
expect(res.issues[0]?.path).toBe("signal.allowFrom");
|
|
}
|
|
});
|
|
|
|
it('accepts signal.dmPolicy="open" with allowFrom "*"', async () => {
|
|
vi.resetModules();
|
|
const { validateConfigObject } = await import("./config.js");
|
|
const res = validateConfigObject({
|
|
signal: { dmPolicy: "open", allowFrom: ["*"] },
|
|
});
|
|
expect(res.ok).toBe(true);
|
|
if (res.ok) {
|
|
expect(res.config.signal?.dmPolicy).toBe("open");
|
|
}
|
|
});
|
|
|
|
it("defaults signal.dmPolicy to pairing when signal section exists", async () => {
|
|
vi.resetModules();
|
|
const { validateConfigObject } = await import("./config.js");
|
|
const res = validateConfigObject({ signal: {} });
|
|
expect(res.ok).toBe(true);
|
|
if (res.ok) {
|
|
expect(res.config.signal?.dmPolicy).toBe("pairing");
|
|
}
|
|
});
|
|
|
|
it("defaults signal.groupPolicy to allowlist when signal section exists", async () => {
|
|
vi.resetModules();
|
|
const { validateConfigObject } = await import("./config.js");
|
|
const res = validateConfigObject({ signal: {} });
|
|
expect(res.ok).toBe(true);
|
|
if (res.ok) {
|
|
expect(res.config.signal?.groupPolicy).toBe("allowlist");
|
|
}
|
|
});
|
|
|
|
it("accepts historyLimit overrides per provider and account", async () => {
|
|
vi.resetModules();
|
|
const { validateConfigObject } = await import("./config.js");
|
|
const res = validateConfigObject({
|
|
messages: { groupChat: { historyLimit: 12 } },
|
|
whatsapp: { historyLimit: 9, accounts: { work: { historyLimit: 4 } } },
|
|
telegram: { historyLimit: 8, accounts: { ops: { historyLimit: 3 } } },
|
|
slack: { historyLimit: 7, accounts: { ops: { historyLimit: 2 } } },
|
|
signal: { historyLimit: 6 },
|
|
imessage: { historyLimit: 5 },
|
|
msteams: { historyLimit: 4 },
|
|
discord: { historyLimit: 3 },
|
|
});
|
|
expect(res.ok).toBe(true);
|
|
if (res.ok) {
|
|
expect(res.config.whatsapp?.historyLimit).toBe(9);
|
|
expect(res.config.whatsapp?.accounts?.work?.historyLimit).toBe(4);
|
|
expect(res.config.telegram?.historyLimit).toBe(8);
|
|
expect(res.config.telegram?.accounts?.ops?.historyLimit).toBe(3);
|
|
expect(res.config.slack?.historyLimit).toBe(7);
|
|
expect(res.config.slack?.accounts?.ops?.historyLimit).toBe(2);
|
|
expect(res.config.signal?.historyLimit).toBe(6);
|
|
expect(res.config.imessage?.historyLimit).toBe(5);
|
|
expect(res.config.msteams?.historyLimit).toBe(4);
|
|
expect(res.config.discord?.historyLimit).toBe(3);
|
|
}
|
|
});
|
|
|
|
it('rejects imessage.dmPolicy="open" without allowFrom "*"', async () => {
|
|
vi.resetModules();
|
|
const { validateConfigObject } = await import("./config.js");
|
|
const res = validateConfigObject({
|
|
imessage: { dmPolicy: "open", allowFrom: ["+15555550123"] },
|
|
});
|
|
expect(res.ok).toBe(false);
|
|
if (!res.ok) {
|
|
expect(res.issues[0]?.path).toBe("imessage.allowFrom");
|
|
}
|
|
});
|
|
|
|
it('accepts imessage.dmPolicy="open" with allowFrom "*"', async () => {
|
|
vi.resetModules();
|
|
const { validateConfigObject } = await import("./config.js");
|
|
const res = validateConfigObject({
|
|
imessage: { dmPolicy: "open", allowFrom: ["*"] },
|
|
});
|
|
expect(res.ok).toBe(true);
|
|
if (res.ok) {
|
|
expect(res.config.imessage?.dmPolicy).toBe("open");
|
|
}
|
|
});
|
|
|
|
it("defaults imessage.dmPolicy to pairing when imessage section exists", async () => {
|
|
vi.resetModules();
|
|
const { validateConfigObject } = await import("./config.js");
|
|
const res = validateConfigObject({ imessage: {} });
|
|
expect(res.ok).toBe(true);
|
|
if (res.ok) {
|
|
expect(res.config.imessage?.dmPolicy).toBe("pairing");
|
|
}
|
|
});
|
|
|
|
it("defaults imessage.groupPolicy to allowlist when imessage section exists", async () => {
|
|
vi.resetModules();
|
|
const { validateConfigObject } = await import("./config.js");
|
|
const res = validateConfigObject({ imessage: {} });
|
|
expect(res.ok).toBe(true);
|
|
if (res.ok) {
|
|
expect(res.config.imessage?.groupPolicy).toBe("allowlist");
|
|
}
|
|
});
|
|
|
|
it("defaults discord.groupPolicy to allowlist when discord section exists", async () => {
|
|
vi.resetModules();
|
|
const { validateConfigObject } = await import("./config.js");
|
|
const res = validateConfigObject({ discord: {} });
|
|
expect(res.ok).toBe(true);
|
|
if (res.ok) {
|
|
expect(res.config.discord?.groupPolicy).toBe("allowlist");
|
|
}
|
|
});
|
|
|
|
it("defaults slack.groupPolicy to allowlist when slack section exists", async () => {
|
|
vi.resetModules();
|
|
const { validateConfigObject } = await import("./config.js");
|
|
const res = validateConfigObject({ slack: {} });
|
|
expect(res.ok).toBe(true);
|
|
if (res.ok) {
|
|
expect(res.config.slack?.groupPolicy).toBe("allowlist");
|
|
}
|
|
});
|
|
|
|
it("defaults msteams.groupPolicy to allowlist when msteams section exists", async () => {
|
|
vi.resetModules();
|
|
const { validateConfigObject } = await import("./config.js");
|
|
const res = validateConfigObject({ msteams: {} });
|
|
expect(res.ok).toBe(true);
|
|
if (res.ok) {
|
|
expect(res.config.msteams?.groupPolicy).toBe("allowlist");
|
|
}
|
|
});
|
|
|
|
it("rejects unsafe executable config values", async () => {
|
|
vi.resetModules();
|
|
const { validateConfigObject } = await import("./config.js");
|
|
const res = validateConfigObject({
|
|
imessage: { cliPath: "imsg; rm -rf /" },
|
|
tools: { audio: { transcription: { args: ["--model", "base"] } } },
|
|
});
|
|
expect(res.ok).toBe(false);
|
|
if (!res.ok) {
|
|
expect(res.issues.some((i) => i.path === "imessage.cliPath")).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("accepts tools audio transcription without cli", async () => {
|
|
vi.resetModules();
|
|
const { validateConfigObject } = await import("./config.js");
|
|
const res = validateConfigObject({
|
|
tools: { audio: { transcription: { args: ["--model", "base"] } } },
|
|
});
|
|
expect(res.ok).toBe(true);
|
|
});
|
|
|
|
it("accepts path-like executable values with spaces", async () => {
|
|
vi.resetModules();
|
|
const { validateConfigObject } = await import("./config.js");
|
|
const res = validateConfigObject({
|
|
imessage: { cliPath: "/Applications/Imsg Tools/imsg" },
|
|
tools: {
|
|
audio: {
|
|
transcription: {
|
|
args: ["--model"],
|
|
},
|
|
},
|
|
},
|
|
});
|
|
expect(res.ok).toBe(true);
|
|
});
|
|
|
|
it('rejects discord.dm.policy="open" without allowFrom "*"', async () => {
|
|
vi.resetModules();
|
|
const { validateConfigObject } = await import("./config.js");
|
|
const res = validateConfigObject({
|
|
discord: { dm: { policy: "open", allowFrom: ["123"] } },
|
|
});
|
|
expect(res.ok).toBe(false);
|
|
if (!res.ok) {
|
|
expect(res.issues[0]?.path).toBe("discord.dm.allowFrom");
|
|
}
|
|
});
|
|
|
|
it('rejects slack.dm.policy="open" without allowFrom "*"', async () => {
|
|
vi.resetModules();
|
|
const { validateConfigObject } = await import("./config.js");
|
|
const res = validateConfigObject({
|
|
slack: { dm: { policy: "open", allowFrom: ["U123"] } },
|
|
});
|
|
expect(res.ok).toBe(false);
|
|
if (!res.ok) {
|
|
expect(res.issues[0]?.path).toBe("slack.dm.allowFrom");
|
|
}
|
|
});
|
|
|
|
it("rejects legacy agent.model string", async () => {
|
|
vi.resetModules();
|
|
const { validateConfigObject } = await import("./config.js");
|
|
const res = validateConfigObject({
|
|
agent: { model: "anthropic/claude-opus-4-5" },
|
|
});
|
|
expect(res.ok).toBe(false);
|
|
if (!res.ok) {
|
|
expect(res.issues.some((i) => i.path === "agent.model")).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("migrates telegram.requireMention to telegram.groups.*.requireMention", async () => {
|
|
vi.resetModules();
|
|
const { migrateLegacyConfig } = await import("./config.js");
|
|
const res = migrateLegacyConfig({
|
|
telegram: { requireMention: false },
|
|
});
|
|
expect(res.changes).toContain(
|
|
'Moved telegram.requireMention → telegram.groups."*".requireMention.',
|
|
);
|
|
expect(res.config?.telegram?.groups?.["*"]?.requireMention).toBe(false);
|
|
expect(res.config?.telegram?.requireMention).toBeUndefined();
|
|
});
|
|
|
|
it("migrates legacy model config to agent.models + model lists", async () => {
|
|
vi.resetModules();
|
|
const { migrateLegacyConfig } = await import("./config.js");
|
|
const res = migrateLegacyConfig({
|
|
agent: {
|
|
model: "anthropic/claude-opus-4-5",
|
|
modelFallbacks: ["openai/gpt-4.1-mini"],
|
|
imageModel: "openai/gpt-4.1-mini",
|
|
imageModelFallbacks: ["anthropic/claude-opus-4-5"],
|
|
allowedModels: ["anthropic/claude-opus-4-5", "openai/gpt-4.1-mini"],
|
|
modelAliases: { Opus: "anthropic/claude-opus-4-5" },
|
|
},
|
|
});
|
|
|
|
expect(res.config?.agents?.defaults?.model?.primary).toBe(
|
|
"anthropic/claude-opus-4-5",
|
|
);
|
|
expect(res.config?.agents?.defaults?.model?.fallbacks).toEqual([
|
|
"openai/gpt-4.1-mini",
|
|
]);
|
|
expect(res.config?.agents?.defaults?.imageModel?.primary).toBe(
|
|
"openai/gpt-4.1-mini",
|
|
);
|
|
expect(res.config?.agents?.defaults?.imageModel?.fallbacks).toEqual([
|
|
"anthropic/claude-opus-4-5",
|
|
]);
|
|
expect(
|
|
res.config?.agents?.defaults?.models?.["anthropic/claude-opus-4-5"],
|
|
).toMatchObject({ alias: "Opus" });
|
|
expect(
|
|
res.config?.agents?.defaults?.models?.["openai/gpt-4.1-mini"],
|
|
).toBeTruthy();
|
|
expect(res.config?.agent).toBeUndefined();
|
|
});
|
|
|
|
it("surfaces legacy issues in snapshot", async () => {
|
|
await withTempHome(async (home) => {
|
|
const configPath = path.join(home, ".clawdbot", "clawdbot.json");
|
|
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
|
await fs.writeFile(
|
|
configPath,
|
|
JSON.stringify({ routing: { allowFrom: ["+15555550123"] } }),
|
|
"utf-8",
|
|
);
|
|
|
|
vi.resetModules();
|
|
const { readConfigFileSnapshot } = await import("./config.js");
|
|
const snap = await readConfigFileSnapshot();
|
|
|
|
expect(snap.valid).toBe(false);
|
|
expect(snap.legacyIssues.length).toBe(1);
|
|
expect(snap.legacyIssues[0]?.path).toBe("routing.allowFrom");
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("multi-agent agentDir validation", () => {
|
|
it("rejects shared agents.list agentDir", async () => {
|
|
vi.resetModules();
|
|
const { validateConfigObject } = await import("./config.js");
|
|
const shared = path.join(tmpdir(), "clawdbot-shared-agentdir");
|
|
const res = validateConfigObject({
|
|
agents: {
|
|
list: [
|
|
{ id: "a", agentDir: shared },
|
|
{ id: "b", agentDir: shared },
|
|
],
|
|
},
|
|
});
|
|
expect(res.ok).toBe(false);
|
|
if (!res.ok) {
|
|
expect(res.issues.some((i) => i.path === "agents.list")).toBe(true);
|
|
expect(res.issues[0]?.message).toContain("Duplicate agentDir");
|
|
}
|
|
});
|
|
|
|
it("throws on shared agentDir during loadConfig()", async () => {
|
|
await withTempHome(async (home) => {
|
|
const configDir = path.join(home, ".clawdbot");
|
|
await fs.mkdir(configDir, { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(configDir, "clawdbot.json"),
|
|
JSON.stringify(
|
|
{
|
|
agents: {
|
|
list: [
|
|
{ id: "a", agentDir: "~/.clawdbot/agents/shared/agent" },
|
|
{ id: "b", agentDir: "~/.clawdbot/agents/shared/agent" },
|
|
],
|
|
},
|
|
bindings: [{ agentId: "a", match: { provider: "telegram" } }],
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
"utf-8",
|
|
);
|
|
|
|
vi.resetModules();
|
|
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
const { loadConfig } = await import("./config.js");
|
|
expect(() => loadConfig()).toThrow(/duplicate agentDir/i);
|
|
expect(spy.mock.calls.flat().join(" ")).toMatch(/Duplicate agentDir/i);
|
|
spy.mockRestore();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("config preservation on validation failure", () => {
|
|
it("preserves unknown fields via passthrough", async () => {
|
|
vi.resetModules();
|
|
const { validateConfigObject } = await import("./config.js");
|
|
const res = validateConfigObject({
|
|
agents: { list: [{ id: "pi" }] },
|
|
customUnknownField: { nested: "value" },
|
|
});
|
|
expect(res.ok).toBe(true);
|
|
expect(
|
|
(res as { config: Record<string, unknown> }).config.customUnknownField,
|
|
).toEqual({
|
|
nested: "value",
|
|
});
|
|
});
|
|
|
|
it("preserves config data when validation fails", async () => {
|
|
await withTempHome(async (home) => {
|
|
const configDir = path.join(home, ".clawdbot");
|
|
await fs.mkdir(configDir, { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(configDir, "clawdbot.json"),
|
|
JSON.stringify({
|
|
agents: { list: [{ id: "pi" }] },
|
|
routing: { allowFrom: ["+15555550123"] },
|
|
customData: { preserved: true },
|
|
}),
|
|
"utf-8",
|
|
);
|
|
|
|
vi.resetModules();
|
|
const { readConfigFileSnapshot } = await import("./config.js");
|
|
const snap = await readConfigFileSnapshot();
|
|
|
|
expect(snap.valid).toBe(false);
|
|
expect(snap.legacyIssues.length).toBeGreaterThan(0);
|
|
expect((snap.config as Record<string, unknown>).customData).toEqual({
|
|
preserved: true,
|
|
});
|
|
});
|
|
});
|
|
});
|