Files
clawdbot/src/config/config.test.ts
2026-01-13 05:55:30 +00:00

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,
});
});
});
});