chore: migrate to oxlint and oxfmt

Co-authored-by: Christoph Nakazawa <christoph.pojer@gmail.com>
This commit is contained in:
Peter Steinberger
2026-01-14 14:31:43 +00:00
parent 912ebffc63
commit c379191f80
1480 changed files with 28608 additions and 43547 deletions

View File

@@ -33,9 +33,7 @@ function collectReferencedAgentIds(cfg: ClawdbotConfig): string[] {
const agents = Array.isArray(cfg.agents?.list) ? cfg.agents?.list : [];
const defaultAgentId =
agents.find((agent) => agent?.default)?.id ??
agents[0]?.id ??
DEFAULT_AGENT_ID;
agents.find((agent) => agent?.default)?.id ?? agents[0]?.id ?? DEFAULT_AGENT_ID;
ids.add(normalizeAgentId(defaultAgentId));
for (const entry of agents) {
@@ -62,15 +60,11 @@ function resolveEffectiveAgentDir(
): string {
const id = normalizeAgentId(agentId);
const configured = Array.isArray(cfg.agents?.list)
? cfg.agents?.list.find((agent) => normalizeAgentId(agent.id) === id)
?.agentDir
? cfg.agents?.list.find((agent) => normalizeAgentId(agent.id) === id)?.agentDir
: undefined;
const trimmed = configured?.trim();
if (trimmed) return resolveUserPath(trimmed);
const root = resolveStateDir(
deps?.env ?? process.env,
deps?.homedir ?? os.homedir,
);
const root = resolveStateDir(deps?.env ?? process.env, deps?.homedir ?? os.homedir);
return path.join(root, "agents", id, "agent");
}
@@ -94,17 +88,13 @@ export function findDuplicateAgentDirs(
return [...byDir.values()].filter((v) => v.agentIds.length > 1);
}
export function formatDuplicateAgentDirError(
dups: DuplicateAgentDir[],
): string {
export function formatDuplicateAgentDirError(dups: DuplicateAgentDir[]): string {
const lines: string[] = [
"Duplicate agentDir detected (multi-agent config).",
"Each agent must have a unique agentDir; sharing it causes auth/session state collisions and token invalidation.",
"",
"Conflicts:",
...dups.map(
(d) => `- ${d.agentDir}: ${d.agentIds.map((id) => `"${id}"`).join(", ")}`,
),
...dups.map((d) => `- ${d.agentDir}: ${d.agentIds.map((id) => `"${id}"`).join(", ")}`),
"",
"Fix: remove the shared agents.list[].agentDir override (or give each agent its own directory).",
"If you want to share credentials, copy auth-profiles.json instead of sharing the entire agentDir.",

View File

@@ -5,12 +5,8 @@ import type { ClawdbotConfig } from "./config.js";
describe("resolveChannelCapabilities", () => {
it("returns undefined for missing inputs", () => {
expect(resolveChannelCapabilities({})).toBeUndefined();
expect(
resolveChannelCapabilities({ cfg: {} as ClawdbotConfig }),
).toBeUndefined();
expect(
resolveChannelCapabilities({ cfg: {} as ClawdbotConfig, channel: "" }),
).toBeUndefined();
expect(resolveChannelCapabilities({ cfg: {} as ClawdbotConfig })).toBeUndefined();
expect(resolveChannelCapabilities({ cfg: {} as ClawdbotConfig, channel: "" })).toBeUndefined();
});
it("normalizes and prefers per-account capabilities", () => {

View File

@@ -2,9 +2,7 @@ import { normalizeChannelId } from "../channels/registry.js";
import { normalizeAccountId } from "../routing/session-key.js";
import type { ClawdbotConfig } from "./config.js";
function normalizeCapabilities(
capabilities: string[] | undefined,
): string[] | undefined {
function normalizeCapabilities(capabilities: string[] | undefined): string[] | undefined {
if (!capabilities) return undefined;
const normalized = capabilities.map((entry) => entry.trim()).filter(Boolean);
return normalized.length > 0 ? normalized : undefined;
@@ -24,20 +22,14 @@ function resolveAccountCapabilities(params: {
if (accounts && typeof accounts === "object") {
const direct = accounts[normalizedAccountId];
if (direct) {
return (
normalizeCapabilities(direct.capabilities) ??
normalizeCapabilities(cfg.capabilities)
);
return normalizeCapabilities(direct.capabilities) ?? normalizeCapabilities(cfg.capabilities);
}
const matchKey = Object.keys(accounts).find(
(key) => key.toLowerCase() === normalizedAccountId.toLowerCase(),
);
const match = matchKey ? accounts[matchKey] : undefined;
if (match) {
return (
normalizeCapabilities(match.capabilities) ??
normalizeCapabilities(cfg.capabilities)
);
return normalizeCapabilities(match.capabilities) ?? normalizeCapabilities(cfg.capabilities);
}
}
@@ -54,8 +46,7 @@ export function resolveChannelCapabilities(params: {
if (!cfg || !channel) return undefined;
const channelsConfig = cfg.channels as Record<string, unknown> | undefined;
const channelConfig = (channelsConfig?.[channel] ??
(cfg as Record<string, unknown>)[channel]) as
const channelConfig = (channelsConfig?.[channel] ?? (cfg as Record<string, unknown>)[channel]) as
| {
accounts?: Record<string, { capabilities?: string[] }>;
capabilities?: string[];

View File

@@ -16,8 +16,7 @@ export function resolveNativeCommandsEnabled(params: {
globalSetting?: NativeCommandsSetting;
}): boolean {
const { providerId, providerSetting, globalSetting } = params;
const setting =
providerSetting === undefined ? globalSetting : providerSetting;
const setting = providerSetting === undefined ? globalSetting : providerSetting;
if (setting === true) return true;
if (setting === false) return false;
// auto or undefined -> heuristic

View File

@@ -27,11 +27,7 @@ export function parseConfigPath(raw: string): {
return { ok: true, path: parts };
}
export function setConfigValueAtPath(
root: PathNode,
path: string[],
value: unknown,
): void {
export function setConfigValueAtPath(root: PathNode, path: string[], value: unknown): void {
let cursor: PathNode = root;
for (let idx = 0; idx < path.length - 1; idx += 1) {
const key = path[idx];
@@ -44,10 +40,7 @@ export function setConfigValueAtPath(
cursor[path[path.length - 1]] = value;
}
export function unsetConfigValueAtPath(
root: PathNode,
path: string[],
): boolean {
export function unsetConfigValueAtPath(root: PathNode, path: string[]): boolean {
const stack: Array<{ node: PathNode; key: string }> = [];
let cursor: PathNode = root;
for (let idx = 0; idx < path.length - 1; idx += 1) {

View File

@@ -39,18 +39,10 @@ describe("config compaction settings", () => {
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.",
);
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.");
});
});
});

View File

@@ -57,12 +57,8 @@ describe("config discord", () => {
expect(cfg.channels?.discord?.enabled).toBe(true);
expect(cfg.channels?.discord?.dm?.groupEnabled).toBe(true);
expect(cfg.channels?.discord?.dm?.groupChannels).toEqual(["clawd-dm"]);
expect(cfg.channels?.discord?.guilds?.["123"]?.slug).toBe(
"friends-of-clawd",
);
expect(
cfg.channels?.discord?.guilds?.["123"]?.channels?.general?.allow,
).toBe(true);
expect(cfg.channels?.discord?.guilds?.["123"]?.slug).toBe("friends-of-clawd");
expect(cfg.channels?.discord?.guilds?.["123"]?.channels?.general?.allow).toBe(true);
});
});
});

View File

@@ -44,14 +44,11 @@ describe("config env vars", () => {
"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");
},
);
await withEnvOverride({ OPENROUTER_API_KEY: "existing-key" }, async () => {
const { loadConfig } = await import("./config.js");
loadConfig();
expect(process.env.OPENROUTER_API_KEY).toBe("existing-key");
});
});
});

View File

@@ -149,9 +149,7 @@ describe("config identity defaults", () => {
const cfg = loadConfig();
expect(cfg.messages?.responsePrefix).toBe("✅");
expect(cfg.agents?.list?.[0]?.groupChat?.mentionPatterns).toEqual([
"@clawd",
]);
expect(cfg.agents?.list?.[0]?.groupChat?.mentionPatterns).toEqual(["@clawd"]);
});
});
@@ -198,8 +196,7 @@ describe("config identity defaults", () => {
expect(cfg.channels?.signal?.textChunkLimit).toBe(2222);
expect(cfg.channels?.imessage?.textChunkLimit).toBe(1111);
const legacy = (cfg.messages as unknown as Record<string, unknown>)
.textChunkLimit;
const legacy = (cfg.messages as unknown as Record<string, unknown>).textChunkLimit;
expect(legacy).toBeUndefined();
});
});
@@ -249,9 +246,7 @@ describe("config identity defaults", () => {
const { loadConfig } = await import("./config.js");
const cfg = loadConfig();
expect(cfg.models?.providers?.minimax?.baseUrl).toBe(
"https://api.minimax.io/anthropic",
);
expect(cfg.models?.providers?.minimax?.baseUrl).toBe("https://api.minimax.io/anthropic");
});
});

View File

@@ -69,9 +69,7 @@ describe("legacy config detection", () => {
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(
res.issues.some((i) => i.path === "channels.imessage.cliPath"),
).toBe(true);
expect(res.issues.some((i) => i.path === "channels.imessage.cliPath")).toBe(true);
}
});
it("accepts tools audio transcription without cli", async () => {
@@ -139,9 +137,7 @@ describe("legacy config detection", () => {
expect(res.changes).toContain(
'Moved telegram.requireMention → channels.telegram.groups."*".requireMention.',
);
expect(res.config?.channels?.telegram?.groups?.["*"]?.requireMention).toBe(
false,
);
expect(res.config?.channels?.telegram?.groups?.["*"]?.requireMention).toBe(false);
expect(res.config?.channels?.telegram?.requireMention).toBeUndefined();
});
it("migrates legacy model config to agent.models + model lists", async () => {
@@ -158,24 +154,16 @@ describe("legacy config detection", () => {
},
});
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?.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?.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("auto-migrates legacy config in snapshot (no legacyIssues)", async () => {
@@ -205,9 +193,7 @@ describe("legacy config detection", () => {
expect(parsed.channels?.whatsapp?.allowFrom).toEqual(["+15555550123"]);
expect(parsed.routing).toBeUndefined();
expect(
warnSpy.mock.calls.some(([msg]) =>
String(msg).includes("Auto-migrated config"),
),
warnSpy.mock.calls.some(([msg]) => String(msg).includes("Auto-migrated config")),
).toBe(true);
} finally {
warnSpy.mockRestore();
@@ -239,9 +225,7 @@ describe("legacy config detection", () => {
expect(parsed.channels?.whatsapp?.allowFrom).toEqual(["+1555"]);
expect(parsed.whatsapp).toBeUndefined();
expect(
warnSpy.mock.calls.some(([msg]) =>
String(msg).includes("Auto-migrated config"),
),
warnSpy.mock.calls.some(([msg]) => String(msg).includes("Auto-migrated config")),
).toBe(true);
} finally {
warnSpy.mockRestore();
@@ -307,9 +291,7 @@ describe("legacy config detection", () => {
expect(parsed.bindings?.[0]?.match?.channel).toBe("slack");
expect(parsed.bindings?.[0]?.match?.provider).toBeUndefined();
expect(
warnSpy.mock.calls.some(([msg]) =>
String(msg).includes("Auto-migrated config"),
),
warnSpy.mock.calls.some(([msg]) => String(msg).includes("Auto-migrated config")),
).toBe(true);
} finally {
warnSpy.mockRestore();
@@ -341,9 +323,7 @@ describe("legacy config detection", () => {
try {
const { loadConfig } = await import("./config.js");
const cfg = loadConfig();
expect(cfg.session?.sendPolicy?.rules?.[0]?.match?.channel).toBe(
"telegram",
);
expect(cfg.session?.sendPolicy?.rules?.[0]?.match?.channel).toBe("telegram");
const raw = await fs.readFile(configPath, "utf-8");
const parsed = JSON.parse(raw) as {
@@ -355,16 +335,10 @@ describe("legacy config detection", () => {
};
};
};
expect(parsed.session?.sendPolicy?.rules?.[0]?.match?.channel).toBe(
"telegram",
);
expect(parsed.session?.sendPolicy?.rules?.[0]?.match?.channel).toBe("telegram");
expect(parsed.session?.sendPolicy?.rules?.[0]?.match?.provider).toBeUndefined();
expect(
parsed.session?.sendPolicy?.rules?.[0]?.match?.provider,
).toBeUndefined();
expect(
warnSpy.mock.calls.some(([msg]) =>
String(msg).includes("Auto-migrated config"),
),
warnSpy.mock.calls.some(([msg]) => String(msg).includes("Auto-migrated config")),
).toBe(true);
} finally {
warnSpy.mockRestore();
@@ -377,11 +351,7 @@ describe("legacy config detection", () => {
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.writeFile(
configPath,
JSON.stringify(
{ messages: { queue: { byProvider: { whatsapp: "queue" } } } },
null,
2,
),
JSON.stringify({ messages: { queue: { byProvider: { whatsapp: "queue" } } } }, null, 2),
"utf-8",
);
@@ -404,9 +374,7 @@ describe("legacy config detection", () => {
expect(parsed.messages?.queue?.byChannel?.whatsapp).toBe("queue");
expect(parsed.messages?.queue?.byProvider).toBeUndefined();
expect(
warnSpy.mock.calls.some(([msg]) =>
String(msg).includes("Auto-migrated config"),
),
warnSpy.mock.calls.some(([msg]) => String(msg).includes("Auto-migrated config")),
).toBe(true);
} finally {
warnSpy.mockRestore();

View File

@@ -29,9 +29,7 @@ describe("legacy config detection", () => {
const res = migrateLegacyConfig({
routing: { allowFrom: ["+15555550123"] },
});
expect(res.changes).toContain(
"Moved routing.allowFrom → channels.whatsapp.allowFrom.",
);
expect(res.changes).toContain("Moved routing.allowFrom → channels.whatsapp.allowFrom.");
expect(res.config?.channels?.whatsapp?.allowFrom).toEqual(["+15555550123"]);
expect(res.config?.routing?.allowFrom).toBeUndefined();
});
@@ -50,15 +48,9 @@ describe("legacy config detection", () => {
expect(res.changes).toContain(
'Moved routing.groupChat.requireMention → channels.imessage.groups."*".requireMention.',
);
expect(res.config?.channels?.whatsapp?.groups?.["*"]?.requireMention).toBe(
false,
);
expect(res.config?.channels?.telegram?.groups?.["*"]?.requireMention).toBe(
false,
);
expect(res.config?.channels?.imessage?.groups?.["*"]?.requireMention).toBe(
false,
);
expect(res.config?.channels?.whatsapp?.groups?.["*"]?.requireMention).toBe(false);
expect(res.config?.channels?.telegram?.groups?.["*"]?.requireMention).toBe(false);
expect(res.config?.channels?.imessage?.groups?.["*"]?.requireMention).toBe(false);
expect(res.config?.routing?.groupChat?.requireMention).toBeUndefined();
});
it("migrates routing.groupChat.mentionPatterns to messages.groupChat.mentionPatterns", async () => {
@@ -70,9 +62,7 @@ describe("legacy config detection", () => {
expect(res.changes).toContain(
"Moved routing.groupChat.mentionPatterns → messages.groupChat.mentionPatterns.",
);
expect(res.config?.messages?.groupChat?.mentionPatterns).toEqual([
"@clawd",
]);
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 () => {
@@ -88,13 +78,9 @@ describe("legacy config detection", () => {
},
},
});
expect(res.changes).toContain(
"Moved routing.agentToAgent → tools.agentToAgent.",
);
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.changes).toContain("Moved routing.transcribeAudio → tools.audio.transcription.");
expect(res.config?.tools?.agentToAgent).toEqual({
enabled: true,
allow: ["main"],
@@ -126,12 +112,8 @@ describe("legacy config detection", () => {
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.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",
@@ -192,9 +174,7 @@ describe("legacy config detection", () => {
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(
res.issues.some((issue) => issue.path === "telegram.requireMention"),
).toBe(true);
expect(res.issues.some((issue) => issue.path === "telegram.requireMention")).toBe(true);
}
});
it("rejects gateway.token", async () => {
@@ -226,12 +206,8 @@ describe("legacy config detection", () => {
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.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");
});
@@ -384,13 +360,9 @@ describe("legacy config detection", () => {
expect(res.ok).toBe(true);
if (res.ok) {
expect(res.config.channels?.whatsapp?.historyLimit).toBe(9);
expect(res.config.channels?.whatsapp?.accounts?.work?.historyLimit).toBe(
4,
);
expect(res.config.channels?.whatsapp?.accounts?.work?.historyLimit).toBe(4);
expect(res.config.channels?.telegram?.historyLimit).toBe(8);
expect(res.config.channels?.telegram?.accounts?.ops?.historyLimit).toBe(
3,
);
expect(res.config.channels?.telegram?.accounts?.ops?.historyLimit).toBe(3);
expect(res.config.channels?.slack?.historyLimit).toBe(7);
expect(res.config.channels?.slack?.accounts?.ops?.historyLimit).toBe(2);
expect(res.config.channels?.signal?.historyLimit).toBe(6);

View File

@@ -22,13 +22,10 @@ describe("config msteams", () => {
expect(res.ok).toBe(true);
if (res.ok) {
expect(res.config.channels?.msteams?.replyStyle).toBe("top-level");
expect(res.config.channels?.msteams?.teams?.team123?.replyStyle).toBe(
"thread",
expect(res.config.channels?.msteams?.teams?.team123?.replyStyle).toBe("thread");
expect(res.config.channels?.msteams?.teams?.team123?.channels?.chan456?.replyStyle).toBe(
"top-level",
);
expect(
res.config.channels?.msteams?.teams?.team123?.channels?.chan456
?.replyStyle,
).toBe("top-level");
}
});

View File

@@ -43,13 +43,10 @@ describe("Nix integration (U3, U5, U9)", () => {
});
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"));
},
);
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 () => {
@@ -57,36 +54,24 @@ describe("Nix integration (U3, U5, U9)", () => {
{ 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$/,
);
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"),
);
},
);
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"),
);
},
);
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"));
});
});
});
@@ -151,21 +136,13 @@ describe("Nix integration (U3, U5, U9)", () => {
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.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.agents?.list?.[0]?.sandbox?.workspaceRoot).toBe(path.join(home, "sandbox-root"));
expect(cfg.channels?.whatsapp?.accounts?.personal?.authDir).toBe(
path.join(home, ".clawdbot", "credentials", "wa-personal"),
);
@@ -176,9 +153,7 @@ describe("Nix integration (U3, U5, U9)", () => {
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"
);
const { DEFAULT_GATEWAY_PORT, resolveGatewayPort } = await import("./config.js");
expect(resolveGatewayPort({})).toBe(DEFAULT_GATEWAY_PORT);
});
});
@@ -234,9 +209,7 @@ describe("Nix integration (U3, U5, U9)", () => {
vi.resetModules();
const { loadConfig } = await import("./config.js");
const cfg = loadConfig();
expect(cfg.channels?.telegram?.tokenFile).toBe(
"/run/agenix/telegram-token",
);
expect(cfg.channels?.telegram?.tokenFile).toBe("/run/agenix/telegram-token");
expect(cfg.channels?.telegram?.botToken).toBeUndefined();
});
});
@@ -262,9 +235,7 @@ describe("Nix integration (U3, U5, U9)", () => {
const { loadConfig } = await import("./config.js");
const cfg = loadConfig();
expect(cfg.channels?.telegram?.botToken).toBe("fallback:token");
expect(cfg.channels?.telegram?.tokenFile).toBe(
"/run/agenix/telegram-token",
);
expect(cfg.channels?.telegram?.tokenFile).toBe("/run/agenix/telegram-token");
});
});
});

View File

@@ -12,9 +12,7 @@ describe("config preservation on validation failure", () => {
customUnknownField: { nested: "value" },
});
expect(res.ok).toBe(true);
expect(
(res as { config: Record<string, unknown> }).config.customUnknownField,
).toEqual({
expect((res as { config: Record<string, unknown> }).config.customUnknownField).toEqual({
nested: "value",
});
});
@@ -42,9 +40,7 @@ describe("config preservation on validation failure", () => {
expect((snap.config as Record<string, unknown>).customData).toEqual({
preserved: true,
});
expect(snap.config.channels?.whatsapp?.allowFrom).toEqual([
"+15555550123",
]);
expect(snap.config.channels?.whatsapp?.allowFrom).toEqual(["+15555550123"]);
});
});
});

View File

@@ -28,11 +28,7 @@ describe("config pruning defaults", () => {
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, "clawdbot.json"),
JSON.stringify(
{ agents: { defaults: { contextPruning: { mode: "off" } } } },
null,
2,
),
JSON.stringify({ agents: { defaults: { contextPruning: { mode: "off" } } } }, null, 2),
"utf-8",
);

View File

@@ -118,9 +118,7 @@ export function applyLoggingDefaults(cfg: ClawdbotConfig): ClawdbotConfig {
};
}
export function applyContextPruningDefaults(
cfg: ClawdbotConfig,
): ClawdbotConfig {
export function applyContextPruningDefaults(cfg: ClawdbotConfig): ClawdbotConfig {
const defaults = cfg.agents?.defaults;
if (!defaults) return cfg;
const contextPruning = defaults?.contextPruning;

View File

@@ -51,14 +51,11 @@ export function resolveChannelGroupPolicy(params: {
const normalizedId = params.groupId?.trim();
const groupConfig = normalizedId && groups ? groups[normalizedId] : undefined;
const defaultConfig = groups?.["*"];
const allowAll =
allowlistEnabled && Boolean(groups && Object.hasOwn(groups, "*"));
const allowAll = allowlistEnabled && Boolean(groups && Object.hasOwn(groups, "*"));
const allowed =
!allowlistEnabled ||
allowAll ||
(normalizedId
? Boolean(groups && Object.hasOwn(groups, normalizedId))
: false);
(normalizedId ? Boolean(groups && Object.hasOwn(groups, normalizedId)) : false);
return {
allowlistEnabled,
allowed,
@@ -84,17 +81,11 @@ export function resolveChannelGroupRequireMention(params: {
? defaultConfig.requireMention
: undefined;
if (
overrideOrder === "before-config" &&
typeof requireMentionOverride === "boolean"
) {
if (overrideOrder === "before-config" && typeof requireMentionOverride === "boolean") {
return requireMentionOverride;
}
if (typeof configMention === "boolean") return configMention;
if (
overrideOrder !== "before-config" &&
typeof requireMentionOverride === "boolean"
) {
if (overrideOrder !== "before-config" && typeof requireMentionOverride === "boolean") {
return requireMentionOverride;
}
return true;

View File

@@ -40,11 +40,7 @@ function createMockResolver(files: Record<string, unknown>): IncludeResolver {
};
}
function resolve(
obj: unknown,
files: Record<string, unknown> = {},
basePath = DEFAULT_BASE_PATH,
) {
function resolve(obj: unknown, files: Record<string, unknown> = {}, basePath = DEFAULT_BASE_PATH) {
return resolveConfigIncludes(obj, basePath, createMockResolver(files));
}
@@ -163,12 +159,12 @@ describe("resolveConfigIncludes", () => {
parseJson: JSON.parse,
};
const obj = { $include: "./bad.json" };
expect(() =>
resolveConfigIncludes(obj, DEFAULT_BASE_PATH, resolver),
).toThrow(ConfigIncludeError);
expect(() =>
resolveConfigIncludes(obj, DEFAULT_BASE_PATH, resolver),
).toThrow(/Failed to parse include file/);
expect(() => resolveConfigIncludes(obj, DEFAULT_BASE_PATH, resolver)).toThrow(
ConfigIncludeError,
);
expect(() => resolveConfigIncludes(obj, DEFAULT_BASE_PATH, resolver)).toThrow(
/Failed to parse include file/,
);
});
it("throws CircularIncludeError for circular includes", () => {
@@ -193,9 +189,7 @@ describe("resolveConfigIncludes", () => {
} catch (err) {
expect(err).toBeInstanceOf(CircularIncludeError);
const circular = err as CircularIncludeError;
expect(circular.chain).toEqual(
expect.arrayContaining([DEFAULT_BASE_PATH, aPath, bPath]),
);
expect(circular.chain).toEqual(expect.arrayContaining([DEFAULT_BASE_PATH, aPath, bPath]));
expect(circular.message).toMatch(/Circular include detected/);
expect(circular.message).toContain("a.json");
expect(circular.message).toContain("b.json");
@@ -261,12 +255,8 @@ describe("resolveConfigIncludes", () => {
};
}
failFiles[configPath("fail10.json")] = { done: true };
expect(() => resolve({ $include: "./fail0.json" }, failFiles)).toThrow(
ConfigIncludeError,
);
expect(() => resolve({ $include: "./fail0.json" }, failFiles)).toThrow(
/Maximum include depth/,
);
expect(() => resolve({ $include: "./fail0.json" }, failFiles)).toThrow(ConfigIncludeError);
expect(() => resolve({ $include: "./fail0.json" }, failFiles)).toThrow(/Maximum include depth/);
});
it("handles relative paths correctly", () => {
@@ -361,11 +351,7 @@ describe("real-world config patterns", () => {
};
const obj = {
$include: [
"./gateway.json",
"./channels/whatsapp.json",
"./agents/defaults.json",
],
$include: ["./gateway.json", "./channels/whatsapp.json", "./agents/defaults.json"],
};
expect(resolve(obj, files)).toEqual({

View File

@@ -44,10 +44,7 @@ export class ConfigIncludeError extends Error {
export class CircularIncludeError extends ConfigIncludeError {
constructor(public readonly chain: string[]) {
super(
`Circular include detected: ${chain.join(" -> ")}`,
chain[chain.length - 1],
);
super(`Circular include detected: ${chain.join(" -> ")}`, chain[chain.length - 1]);
this.name = "CircularIncludeError";
}
}
@@ -73,8 +70,7 @@ export function deepMerge(target: unknown, source: unknown): unknown {
if (isPlainObject(target) && isPlainObject(source)) {
const result: Record<string, unknown> = { ...target };
for (const key of Object.keys(source)) {
result[key] =
key in result ? deepMerge(result[key], source[key]) : source[key];
result[key] = key in result ? deepMerge(result[key], source[key]) : source[key];
}
return result;
}
@@ -213,11 +209,7 @@ class IncludeProcessor {
}
}
private parseFile(
includePath: string,
resolvedPath: string,
raw: string,
): unknown {
private parseFile(includePath: string, resolvedPath: string, raw: string): unknown {
try {
return this.resolver.parseJson(raw);
} catch (err) {

View File

@@ -10,10 +10,7 @@ import {
resolveShellEnvFallbackTimeoutMs,
shouldEnableShellEnvFallback,
} from "../infra/shell-env.js";
import {
DuplicateAgentDirError,
findDuplicateAgentDirs,
} from "./agent-dirs.js";
import { DuplicateAgentDirError, findDuplicateAgentDirs } from "./agent-dirs.js";
import {
applyContextPruningDefaults,
applyLoggingDefaults,
@@ -27,19 +24,12 @@ import { applyLegacyMigrations, findLegacyConfigIssues } from "./legacy.js";
import { normalizeConfigPaths } from "./normalize-paths.js";
import { resolveConfigPath, resolveStateDir } from "./paths.js";
import { applyConfigOverrides } from "./runtime-overrides.js";
import type {
ClawdbotConfig,
ConfigFileSnapshot,
LegacyConfigIssue,
} from "./types.js";
import type { ClawdbotConfig, ConfigFileSnapshot, LegacyConfigIssue } from "./types.js";
import { validateConfigObject } from "./validation.js";
import { ClawdbotSchema } from "./zod-schema.js";
// Re-export for backwards compatibility
export {
CircularIncludeError,
ConfigIncludeError,
} from "./includes.js";
export { CircularIncludeError, ConfigIncludeError } from "./includes.js";
const SHELL_ENV_EXPECTED_KEYS = [
"OPENAI_API_KEY",
@@ -59,9 +49,7 @@ const SHELL_ENV_EXPECTED_KEYS = [
"CLAWDBOT_GATEWAY_PASSWORD",
];
export type ParseConfigJson5Result =
| { ok: true; parsed: unknown }
| { ok: false; error: string };
export type ParseConfigJson5Result = { ok: true; parsed: unknown } | { ok: false; error: string };
export type ConfigIoDeps = {
fs?: typeof fs;
@@ -72,10 +60,7 @@ export type ConfigIoDeps = {
logger?: Pick<typeof console, "error" | "warn">;
};
function warnOnConfigMiskeys(
raw: unknown,
logger: Pick<typeof console, "warn">,
): void {
function warnOnConfigMiskeys(raw: unknown, logger: Pick<typeof console, "warn">): void {
if (!raw || typeof raw !== "object") return;
const gateway = (raw as Record<string, unknown>).gateway;
if (!gateway || typeof gateway !== "object") return;
@@ -149,9 +134,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
const writeConfigFileSync = (cfg: ClawdbotConfig) => {
const dir = path.dirname(configPath);
deps.fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
const json = JSON.stringify(applyModelDefaults(cfg), null, 2)
.trimEnd()
.concat("\n");
const json = JSON.stringify(applyModelDefaults(cfg), null, 2).trimEnd().concat("\n");
const tmp = path.join(
dir,
@@ -219,8 +202,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
const migrated = applyLegacyMigrations(resolved);
const resolvedConfig = migrated.next ?? resolved;
warnOnConfigMiskeys(resolvedConfig, deps.logger);
if (typeof resolvedConfig !== "object" || resolvedConfig === null)
return {};
if (typeof resolvedConfig !== "object" || resolvedConfig === null) return {};
const validated = ClawdbotSchema.safeParse(resolvedConfig);
if (!validated.success) {
deps.logger.error("Invalid config:");
@@ -234,17 +216,13 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
try {
writeConfigFileSync(resolvedConfig as ClawdbotConfig);
} catch (err) {
deps.logger.warn(
`Failed to write migrated config at ${configPath}: ${String(err)}`,
);
deps.logger.warn(`Failed to write migrated config at ${configPath}: ${String(err)}`);
}
}
const cfg = applyModelDefaults(
applyContextPruningDefaults(
applySessionDefaults(
applyLoggingDefaults(
applyMessageDefaults(validated.data as ClawdbotConfig),
),
applyLoggingDefaults(applyMessageDefaults(validated.data as ClawdbotConfig)),
),
),
);
@@ -260,18 +238,14 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
applyConfigEnv(cfg, deps.env);
const enabled =
shouldEnableShellEnvFallback(deps.env) ||
cfg.env?.shellEnv?.enabled === true;
const enabled = shouldEnableShellEnvFallback(deps.env) || cfg.env?.shellEnv?.enabled === true;
if (enabled) {
loadShellEnvFallback({
enabled: true,
env: deps.env,
expectedKeys: SHELL_ENV_EXPECTED_KEYS,
logger: deps.logger,
timeoutMs:
cfg.env?.shellEnv?.timeoutMs ??
resolveShellEnvFallbackTimeoutMs(deps.env),
timeoutMs: cfg.env?.shellEnv?.timeoutMs ?? resolveShellEnvFallbackTimeoutMs(deps.env),
});
}
@@ -291,9 +265,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
if (!exists) {
const config = applyTalkApiKey(
applyModelDefaults(
applyContextPruningDefaults(
applySessionDefaults(applyMessageDefaults({})),
),
applyContextPruningDefaults(applySessionDefaults(applyMessageDefaults({}))),
),
);
const legacyIssues: LegacyConfigIssue[] = [];
@@ -320,9 +292,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
parsed: {},
valid: false,
config: {},
issues: [
{ path: "", message: `JSON5 parse failed: ${parsedRes.error}` },
],
issues: [{ path: "", message: `JSON5 parse failed: ${parsedRes.error}` }],
legacyIssues: [],
};
}
@@ -376,9 +346,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
if (migrated.next && migrated.changes.length > 0) {
deps.logger.warn(formatLegacyMigrationLog(migrated.changes));
await writeConfigFile(validated.config).catch((err) => {
deps.logger.warn(
`Failed to write migrated config at ${configPath}: ${String(err)}`,
);
deps.logger.warn(`Failed to write migrated config at ${configPath}: ${String(err)}`);
});
}
@@ -391,9 +359,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
config: normalizeConfigPaths(
applyTalkApiKey(
applyModelDefaults(
applySessionDefaults(
applyLoggingDefaults(applyMessageDefaults(validated.config)),
),
applySessionDefaults(applyLoggingDefaults(applyMessageDefaults(validated.config))),
),
),
),
@@ -417,9 +383,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
async function writeConfigFile(cfg: ClawdbotConfig) {
const dir = path.dirname(configPath);
await deps.fs.promises.mkdir(dir, { recursive: true, mode: 0o700 });
const json = JSON.stringify(applyModelDefaults(cfg), null, 2)
.trimEnd()
.concat("\n");
const json = JSON.stringify(applyModelDefaults(cfg), null, 2).trimEnd().concat("\n");
const tmp = path.join(
dir,
@@ -431,11 +395,9 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
mode: 0o600,
});
await deps.fs.promises
.copyFile(configPath, `${configPath}.bak`)
.catch(() => {
// best-effort
});
await deps.fs.promises.copyFile(configPath, `${configPath}.bak`).catch(() => {
// best-effort
});
try {
await deps.fs.promises.rename(tmp, configPath);
@@ -481,7 +443,5 @@ export async function readConfigFileSnapshot(): Promise<ConfigFileSnapshot> {
}
export async function writeConfigFile(cfg: ClawdbotConfig): Promise<void> {
await createConfigIO({ configPath: resolveConfigPath() }).writeConfigFile(
cfg,
);
await createConfigIO({ configPath: resolveConfigPath() }).writeConfigFile(cfg);
}

View File

@@ -10,9 +10,7 @@ export function migrateLegacyConfig(raw: unknown): {
if (!next) return { config: null, changes: [] };
const validated = validateConfigObject(next);
if (!validated.ok) {
changes.push(
"Migration applied, but config still invalid; fix remaining issues manually.",
);
changes.push("Migration applied, but config still invalid; fix remaining issues manually.");
return { config: null, changes };
}
return { config: validated.config, changes };

View File

@@ -20,8 +20,7 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_1: LegacyConfigMigration[] = [
const match = getRecord(entry.match);
if (!match) continue;
if (typeof match.channel === "string" && match.channel.trim()) continue;
const provider =
typeof match.provider === "string" ? match.provider.trim() : "";
const provider = typeof match.provider === "string" ? match.provider.trim() : "";
if (!provider) continue;
match.channel = provider;
delete match.provider;
@@ -31,9 +30,7 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_1: LegacyConfigMigration[] = [
if (touched) {
raw.bindings = bindings;
changes.push(
"Moved bindings[].match.provider → bindings[].match.channel.",
);
changes.push("Moved bindings[].match.provider → bindings[].match.channel.");
}
},
},
@@ -54,8 +51,7 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_1: LegacyConfigMigration[] = [
const match = getRecord(rule.match);
if (!match) continue;
if (typeof match.channel === "string" && match.channel.trim()) continue;
const provider =
typeof match.provider === "string" ? match.provider.trim() : "";
const provider = typeof match.provider === "string" ? match.provider.trim() : "";
if (!provider) continue;
match.channel = provider;
delete match.provider;
@@ -67,9 +63,7 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_1: LegacyConfigMigration[] = [
sendPolicy.rules = rules;
session.sendPolicy = sendPolicy;
raw.session = session;
changes.push(
"Moved session.sendPolicy.rules[].match.provider → match.channel.",
);
changes.push("Moved session.sendPolicy.rules[].match.provider → match.channel.");
}
},
},
@@ -84,13 +78,9 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_1: LegacyConfigMigration[] = [
if (queue.byProvider === undefined) return;
if (queue.byChannel === undefined) {
queue.byChannel = queue.byProvider;
changes.push(
"Moved messages.queue.byProvider → messages.queue.byChannel.",
);
changes.push("Moved messages.queue.byProvider → messages.queue.byChannel.");
} else {
changes.push(
"Removed messages.queue.byProvider (messages.queue.byChannel already set).",
);
changes.push("Removed messages.queue.byProvider (messages.queue.byChannel already set).");
}
delete queue.byProvider;
messages.queue = queue;
@@ -123,9 +113,7 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_1: LegacyConfigMigration[] = [
channels[key] = channelEntry;
delete raw[key];
changes.push(
hadEntries
? `Merged ${key} → channels.${key}.`
: `Moved ${key} → channels.${key}.`,
hadEntries ? `Merged ${key} → channels.${key}.` : `Moved ${key} → channels.${key}.`,
);
}
raw.channels = channels;
@@ -150,9 +138,7 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_1: LegacyConfigMigration[] = [
whatsapp.allowFrom = allowFrom;
changes.push("Moved routing.allowFrom → channels.whatsapp.allowFrom.");
} else {
changes.push(
"Removed routing.allowFrom (channels.whatsapp.allowFrom already set).",
);
changes.push("Removed routing.allowFrom (channels.whatsapp.allowFrom already set).");
}
delete (routing as Record<string, unknown>).allowFrom;
@@ -165,18 +151,14 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_1: LegacyConfigMigration[] = [
},
{
id: "routing.groupChat.requireMention->groups.*.requireMention",
describe:
"Move routing.groupChat.requireMention to channels.whatsapp/telegram/imessage groups",
describe: "Move routing.groupChat.requireMention to channels.whatsapp/telegram/imessage groups",
apply: (raw, changes) => {
const routing = raw.routing;
if (!routing || typeof routing !== "object") return;
const groupChat =
(routing as Record<string, unknown>).groupChat &&
typeof (routing as Record<string, unknown>).groupChat === "object"
? ((routing as Record<string, unknown>).groupChat as Record<
string,
unknown
>)
? ((routing as Record<string, unknown>).groupChat as Record<string, unknown>)
: null;
if (!groupChat) return;
const requireMention = groupChat.requireMention;
@@ -256,23 +238,18 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_1: LegacyConfigMigration[] = [
},
{
id: "telegram.requireMention->channels.telegram.groups.*.requireMention",
describe:
"Move telegram.requireMention to channels.telegram.groups.*.requireMention",
describe: "Move telegram.requireMention to channels.telegram.groups.*.requireMention",
apply: (raw, changes) => {
const channels = ensureRecord(raw, "channels");
const telegram = channels.telegram;
if (!telegram || typeof telegram !== "object") return;
const requireMention = (telegram as Record<string, unknown>)
.requireMention;
const requireMention = (telegram as Record<string, unknown>).requireMention;
if (requireMention === undefined) return;
const groups =
(telegram as Record<string, unknown>).groups &&
typeof (telegram as Record<string, unknown>).groups === "object"
? ((telegram as Record<string, unknown>).groups as Record<
string,
unknown
>)
? ((telegram as Record<string, unknown>).groups as Record<string, unknown>)
: {};
const defaultKey = "*";
const entry =
@@ -288,9 +265,7 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_1: LegacyConfigMigration[] = [
'Moved telegram.requireMention → channels.telegram.groups."*".requireMention.',
);
} else {
changes.push(
'Removed telegram.requireMention (channels.telegram.groups."*" already set).',
);
changes.push('Removed telegram.requireMention (channels.telegram.groups."*" already set).');
}
delete (telegram as Record<string, unknown>).requireMention;

View File

@@ -21,12 +21,9 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_2: LegacyConfigMigration[] = [
if (!agent) return;
const label = agentRoot ? "agent" : "agents.defaults";
const legacyModel =
typeof agent.model === "string" ? String(agent.model) : undefined;
const legacyModel = typeof agent.model === "string" ? String(agent.model) : undefined;
const legacyImageModel =
typeof agent.imageModel === "string"
? String(agent.imageModel)
: undefined;
typeof agent.imageModel === "string" ? String(agent.imageModel) : undefined;
const legacyAllowed = Array.isArray(agent.allowedModels)
? (agent.allowedModels as unknown[]).map(String)
: [];
@@ -96,8 +93,7 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_2: LegacyConfigMigration[] = [
}
if (
legacyModelFallbacks.length > 0 &&
(!Array.isArray(currentModel.fallbacks) ||
currentModel.fallbacks.length === 0)
(!Array.isArray(currentModel.fallbacks) || currentModel.fallbacks.length === 0)
) {
currentModel.fallbacks = legacyModelFallbacks;
}
@@ -119,8 +115,7 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_2: LegacyConfigMigration[] = [
}
if (
legacyImageModelFallbacks.length > 0 &&
(!Array.isArray(currentImageModel.fallbacks) ||
currentImageModel.fallbacks.length === 0)
(!Array.isArray(currentImageModel.fallbacks) || currentImageModel.fallbacks.length === 0)
) {
currentImageModel.fallbacks = legacyImageModelFallbacks;
}
@@ -128,41 +123,29 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_2: LegacyConfigMigration[] = [
} else if (legacyImageModel || legacyImageModelFallbacks.length > 0) {
agent.imageModel = {
primary: legacyImageModel,
fallbacks: legacyImageModelFallbacks.length
? legacyImageModelFallbacks
: [],
fallbacks: legacyImageModelFallbacks.length ? legacyImageModelFallbacks : [],
};
}
agent.models = models;
if (legacyModel !== undefined) {
changes.push(
`Migrated ${label}.model string → ${label}.model.primary.`,
);
changes.push(`Migrated ${label}.model string → ${label}.model.primary.`);
}
if (legacyModelFallbacks.length > 0) {
changes.push(
`Migrated ${label}.modelFallbacks → ${label}.model.fallbacks.`,
);
changes.push(`Migrated ${label}.modelFallbacks → ${label}.model.fallbacks.`);
}
if (legacyImageModel !== undefined) {
changes.push(
`Migrated ${label}.imageModel string → ${label}.imageModel.primary.`,
);
changes.push(`Migrated ${label}.imageModel string → ${label}.imageModel.primary.`);
}
if (legacyImageModelFallbacks.length > 0) {
changes.push(
`Migrated ${label}.imageModelFallbacks → ${label}.imageModel.fallbacks.`,
);
changes.push(`Migrated ${label}.imageModelFallbacks → ${label}.imageModel.fallbacks.`);
}
if (legacyAllowed.length > 0) {
changes.push(`Migrated ${label}.allowedModels → ${label}.models.`);
}
if (Object.keys(legacyAliases).length > 0) {
changes.push(
`Migrated ${label}.modelAliases → ${label}.models.*.alias.`,
);
changes.push(`Migrated ${label}.modelAliases → ${label}.models.*.alias.`);
}
delete agent.allowedModels;
@@ -237,13 +220,10 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_2: LegacyConfigMigration[] = [
}
const defaultAgentId =
typeof routing.defaultAgentId === "string"
? routing.defaultAgentId.trim()
: "";
typeof routing.defaultAgentId === "string" ? routing.defaultAgentId.trim() : "";
if (defaultAgentId) {
const hasDefault = list.some(
(entry): entry is Record<string, unknown> =>
isRecord(entry) && entry.default === true,
(entry): entry is Record<string, unknown> => isRecord(entry) && entry.default === true,
);
if (!hasDefault) {
const entry = ensureAgentEntry(list, defaultAgentId);
@@ -252,9 +232,7 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_2: LegacyConfigMigration[] = [
`Moved routing.defaultAgentId → agents.list (id "${defaultAgentId}").default.`,
);
} else {
changes.push(
"Removed routing.defaultAgentId (agents.list default already set).",
);
changes.push("Removed routing.defaultAgentId (agents.list default already set).");
}
delete routing.defaultAgentId;
}
@@ -270,8 +248,7 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_2: LegacyConfigMigration[] = [
},
{
id: "routing.config-v2",
describe:
"Move routing bindings/groupChat/queue/agentToAgent/transcribeAudio",
describe: "Move routing bindings/groupChat/queue/agentToAgent/transcribeAudio",
apply: (raw, changes) => {
const routing = getRecord(raw.routing);
if (!routing) return;
@@ -292,9 +269,7 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_2: LegacyConfigMigration[] = [
tools.agentToAgent = routing.agentToAgent;
changes.push("Moved routing.agentToAgent → tools.agentToAgent.");
} else {
changes.push(
"Removed routing.agentToAgent (tools.agentToAgent already set).",
);
changes.push("Removed routing.agentToAgent (tools.agentToAgent already set).");
}
delete routing.agentToAgent;
}
@@ -318,9 +293,7 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_2: LegacyConfigMigration[] = [
const messagesGroup = ensureRecord(messages, "groupChat");
if (messagesGroup.historyLimit === undefined) {
messagesGroup.historyLimit = historyLimit;
changes.push(
"Moved routing.groupChat.historyLimit → messages.groupChat.historyLimit.",
);
changes.push("Moved routing.groupChat.historyLimit → messages.groupChat.historyLimit.");
} else {
changes.push(
"Removed routing.groupChat.historyLimit (messages.groupChat.historyLimit already set).",
@@ -360,18 +333,14 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_2: LegacyConfigMigration[] = [
const toolsAudio = ensureRecord(tools, "audio");
if (toolsAudio.transcription === undefined) {
toolsAudio.transcription = mapped;
changes.push(
"Moved routing.transcribeAudio → tools.audio.transcription.",
);
changes.push("Moved routing.transcribeAudio → tools.audio.transcription.");
} else {
changes.push(
"Removed routing.transcribeAudio (tools.audio.transcription already set).",
);
}
} else {
changes.push(
"Removed routing.transcribeAudio (unsupported transcription CLI).",
);
changes.push("Removed routing.transcribeAudio (unsupported transcription CLI).");
}
delete routing.transcribeAudio;
}
@@ -384,22 +353,16 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_2: LegacyConfigMigration[] = [
const toolsAudio = ensureRecord(tools, "audio");
if (toolsAudio.transcription === undefined) {
toolsAudio.transcription = mapped;
changes.push(
"Moved audio.transcription → tools.audio.transcription.",
);
changes.push("Moved audio.transcription → tools.audio.transcription.");
} else {
changes.push(
"Removed audio.transcription (tools.audio.transcription already set).",
);
changes.push("Removed audio.transcription (tools.audio.transcription already set).");
}
delete audio.transcription;
if (Object.keys(audio).length === 0) delete raw.audio;
else raw.audio = audio;
} else {
delete audio.transcription;
changes.push(
"Removed audio.transcription (unsupported transcription CLI).",
);
changes.push("Removed audio.transcription (unsupported transcription CLI).");
if (Object.keys(audio).length === 0) delete raw.audio;
else raw.audio = audio;
}

View File

@@ -106,9 +106,7 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_3: LegacyConfigMigration[] = [
const entry = ensureAgentEntry(list, defaultId);
if (entry.identity === undefined) {
entry.identity = identity;
changes.push(
`Moved identity → agents.list (id "${defaultId}").identity.`,
);
changes.push(`Moved identity → agents.list (id "${defaultId}").identity.`);
} else {
changes.push("Removed identity (agents.list identity already set).");
}
@@ -121,10 +119,7 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_3: LegacyConfigMigration[] = [
id: "bind-tailnet->auto",
describe: "Remap gateway/bridge bind 'tailnet' to 'auto'",
apply: (raw, changes) => {
const migrateBind = (
obj: Record<string, unknown> | null | undefined,
key: string,
) => {
const migrateBind = (obj: Record<string, unknown> | null | undefined, key: string) => {
if (!obj) return;
const bind = obj.bind;
if (bind === "tailnet") {

View File

@@ -3,18 +3,15 @@ import type { LegacyConfigRule } from "./legacy.shared.js";
export const LEGACY_CONFIG_RULES: LegacyConfigRule[] = [
{
path: ["whatsapp"],
message:
"whatsapp config moved to channels.whatsapp (auto-migrated on load).",
message: "whatsapp config moved to channels.whatsapp (auto-migrated on load).",
},
{
path: ["telegram"],
message:
"telegram config moved to channels.telegram (auto-migrated on load).",
message: "telegram config moved to channels.telegram (auto-migrated on load).",
},
{
path: ["discord"],
message:
"discord config moved to channels.discord (auto-migrated on load).",
message: "discord config moved to channels.discord (auto-migrated on load).",
},
{
path: ["slack"],
@@ -26,13 +23,11 @@ export const LEGACY_CONFIG_RULES: LegacyConfigRule[] = [
},
{
path: ["imessage"],
message:
"imessage config moved to channels.imessage (auto-migrated on load).",
message: "imessage config moved to channels.imessage (auto-migrated on load).",
},
{
path: ["msteams"],
message:
"msteams config moved to channels.msteams (auto-migrated on load).",
message: "msteams config moved to channels.msteams (auto-migrated on load).",
},
{
path: ["routing", "allowFrom"],
@@ -41,13 +36,11 @@ export const LEGACY_CONFIG_RULES: LegacyConfigRule[] = [
},
{
path: ["routing", "bindings"],
message:
"routing.bindings was moved; use top-level bindings instead (auto-migrated on load).",
message: "routing.bindings was moved; use top-level bindings instead (auto-migrated on load).",
},
{
path: ["routing", "agents"],
message:
"routing.agents was moved; use agents.list instead (auto-migrated on load).",
message: "routing.agents was moved; use agents.list instead (auto-migrated on load).",
},
{
path: ["routing", "defaultAgentId"],
@@ -71,8 +64,7 @@ export const LEGACY_CONFIG_RULES: LegacyConfigRule[] = [
},
{
path: ["routing", "queue"],
message:
"routing.queue was moved; use messages.queue instead (auto-migrated on load).",
message: "routing.queue was moved; use messages.queue instead (auto-migrated on load).",
},
{
path: ["routing", "transcribeAudio"],
@@ -86,8 +78,7 @@ export const LEGACY_CONFIG_RULES: LegacyConfigRule[] = [
},
{
path: ["identity"],
message:
"identity was moved; use agents.list[].identity instead (auto-migrated on load).",
message: "identity was moved; use agents.list[].identity instead (auto-migrated on load).",
},
{
path: ["agent"],
@@ -108,8 +99,7 @@ export const LEGACY_CONFIG_RULES: LegacyConfigRule[] = [
},
{
path: ["agent", "allowedModels"],
message:
"agent.allowedModels was replaced by agents.defaults.models (auto-migrated on load).",
message: "agent.allowedModels was replaced by agents.defaults.models (auto-migrated on load).",
},
{
path: ["agent", "modelAliases"],
@@ -128,7 +118,6 @@ export const LEGACY_CONFIG_RULES: LegacyConfigRule[] = [
},
{
path: ["gateway", "token"],
message:
"gateway.token is ignored; use gateway.auth.token instead (auto-migrated on load).",
message: "gateway.token is ignored; use gateway.auth.token instead (auto-migrated on load).",
},
];

View File

@@ -27,10 +27,7 @@ export const ensureRecord = (
return next;
};
export const mergeMissing = (
target: Record<string, unknown>,
source: Record<string, unknown>,
) => {
export const mergeMissing = (target: Record<string, unknown>, source: Record<string, unknown>) => {
for (const [key, value] of Object.entries(source)) {
if (value === undefined) continue;
const existing = target[key];
@@ -46,13 +43,9 @@ export const mergeMissing = (
const AUDIO_TRANSCRIPTION_CLI_ALLOWLIST = new Set(["whisper"]);
export const mapLegacyAudioTranscription = (
value: unknown,
): Record<string, unknown> | null => {
export const mapLegacyAudioTranscription = (value: unknown): Record<string, unknown> | null => {
const transcriber = getRecord(value);
const command = Array.isArray(transcriber?.command)
? transcriber?.command
: null;
const command = Array.isArray(transcriber?.command) ? transcriber?.command : null;
if (!command || command.length === 0) return null;
const rawExecutable = String(command[0] ?? "").trim();
if (!rawExecutable) return null;
@@ -61,9 +54,7 @@ export const mapLegacyAudioTranscription = (
const args = command.slice(1).map((part) => String(part));
const timeoutSeconds =
typeof transcriber?.timeoutSeconds === "number"
? transcriber?.timeoutSeconds
: undefined;
typeof transcriber?.timeoutSeconds === "number" ? transcriber?.timeoutSeconds : undefined;
const result: Record<string, unknown> = {};
if (args.length > 0) result.args = args;
@@ -89,9 +80,7 @@ export const resolveDefaultAgentIdFromRaw = (raw: Record<string, unknown>) => {
if (defaultEntry) return defaultEntry.id.trim();
const routing = getRecord(raw.routing);
const routingDefault =
typeof routing?.defaultAgentId === "string"
? routing.defaultAgentId.trim()
: "";
typeof routing?.defaultAgentId === "string" ? routing.defaultAgentId.trim() : "";
if (routingDefault) return routingDefault;
const firstEntry = list.find(
(entry): entry is { id: string } =>
@@ -101,16 +90,11 @@ export const resolveDefaultAgentIdFromRaw = (raw: Record<string, unknown>) => {
return "main";
};
export const ensureAgentEntry = (
list: unknown[],
id: string,
): Record<string, unknown> => {
export const ensureAgentEntry = (list: unknown[], id: string): Record<string, unknown> => {
const normalized = id.trim();
const existing = list.find(
(entry): entry is Record<string, unknown> =>
isRecord(entry) &&
typeof entry.id === "string" &&
entry.id.trim() === normalized,
isRecord(entry) && typeof entry.id === "string" && entry.id.trim() === normalized,
);
if (existing) return existing;
const created: Record<string, unknown> = { id: normalized };

View File

@@ -16,12 +16,8 @@ describe("applyModelDefaults", () => {
} satisfies ClawdbotConfig;
const next = applyModelDefaults(cfg);
expect(
next.agents?.defaults?.models?.["anthropic/claude-opus-4-5"]?.alias,
).toBe("opus");
expect(next.agents?.defaults?.models?.["openai/gpt-5.2"]?.alias).toBe(
"gpt",
);
expect(next.agents?.defaults?.models?.["anthropic/claude-opus-4-5"]?.alias).toBe("opus");
expect(next.agents?.defaults?.models?.["openai/gpt-5.2"]?.alias).toBe("gpt");
});
it("does not override existing aliases", () => {
@@ -37,9 +33,7 @@ describe("applyModelDefaults", () => {
const next = applyModelDefaults(cfg);
expect(
next.agents?.defaults?.models?.["anthropic/claude-opus-4-5"]?.alias,
).toBe("Opus");
expect(next.agents?.defaults?.models?.["anthropic/claude-opus-4-5"]?.alias).toBe("Opus");
});
it("respects explicit empty alias disables", () => {
@@ -56,11 +50,9 @@ describe("applyModelDefaults", () => {
const next = applyModelDefaults(cfg);
expect(
next.agents?.defaults?.models?.["google/gemini-3-pro-preview"]?.alias,
).toBe("");
expect(
next.agents?.defaults?.models?.["google/gemini-3-flash-preview"]?.alias,
).toBe("gemini-flash");
expect(next.agents?.defaults?.models?.["google/gemini-3-pro-preview"]?.alias).toBe("");
expect(next.agents?.defaults?.models?.["google/gemini-3-flash-preview"]?.alias).toBe(
"gemini-flash",
);
});
});

View File

@@ -45,12 +45,8 @@ describe("normalizeConfigPaths", () => {
},
});
expect(cfg.plugins?.load?.paths?.[0]).toBe(
path.join(home, "plugins", "a"),
);
expect(cfg.logging?.file).toBe(
path.join(home, ".clawdbot", "logs", "clawdbot.log"),
);
expect(cfg.plugins?.load?.paths?.[0]).toBe(path.join(home, "plugins", "a"));
expect(cfg.logging?.file).toBe(path.join(home, ".clawdbot", "logs", "clawdbot.log"));
expect(cfg.hooks?.path).toBe(path.join(home, ".clawdbot", "hooks.json5"));
expect(cfg.hooks?.transformsDir).toBe(path.join(home, "hooks-xform"));
expect(cfg.channels?.telegram?.accounts?.personal?.tokenFile).toBe(
@@ -59,18 +55,10 @@ describe("normalizeConfigPaths", () => {
expect(cfg.channels?.imessage?.accounts?.personal?.dbPath).toBe(
path.join(home, "Library", "Messages", "chat.db"),
);
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.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"));
// Non-path key => do not treat "~" as home expansion.
expect(cfg.agents?.list?.[0]?.identity?.name).toBe("~not-a-path");

View File

@@ -10,9 +10,7 @@ describe("oauth paths", () => {
CLAWDBOT_STATE_DIR: "/custom/state",
} as NodeJS.ProcessEnv;
expect(resolveOAuthDir(env, "/custom/state")).toBe(
path.resolve("/custom/oauth"),
);
expect(resolveOAuthDir(env, "/custom/state")).toBe(path.resolve("/custom/oauth"));
expect(resolveOAuthPath(env, "/custom/state")).toBe(
path.join(path.resolve("/custom/oauth"), "oauth.json"),
);
@@ -23,9 +21,7 @@ describe("oauth paths", () => {
CLAWDBOT_STATE_DIR: "/custom/state",
} as NodeJS.ProcessEnv;
expect(resolveOAuthDir(env, "/custom/state")).toBe(
path.join("/custom/state", "credentials"),
);
expect(resolveOAuthDir(env, "/custom/state")).toBe(path.join("/custom/state", "credentials"));
expect(resolveOAuthPath(env, "/custom/state")).toBe(
path.join("/custom/state", "credentials", "oauth.json"),
);

View File

@@ -9,9 +9,7 @@ import type { ClawdbotConfig } from "./types.js";
* - Missing dependencies should produce actionable Nix-specific error messages
* - Config is managed externally (read-only from Nix perspective)
*/
export function resolveIsNixMode(
env: NodeJS.ProcessEnv = process.env,
): boolean {
export function resolveIsNixMode(env: NodeJS.ProcessEnv = process.env): boolean {
return env.CLAWDBOT_NIX_MODE === "1";
}
@@ -26,8 +24,7 @@ export function resolveStateDir(
env: NodeJS.ProcessEnv = process.env,
homedir: () => string = os.homedir,
): string {
const override =
env.CLAWDBOT_STATE_DIR?.trim() || env.CLAWDIS_STATE_DIR?.trim();
const override = env.CLAWDBOT_STATE_DIR?.trim() || env.CLAWDIS_STATE_DIR?.trim();
if (override) return resolveUserPath(override);
return path.join(homedir(), ".clawdbot");
}

View File

@@ -30,18 +30,10 @@ export function deriveDefaultCanvasHostPort(gatewayPort: number): number {
return derivePort(gatewayPort, 4, DEFAULT_CANVAS_HOST_PORT);
}
export function deriveDefaultBrowserCdpPortRange(
browserControlPort: number,
): PortRange {
const start = derivePort(
browserControlPort,
9,
DEFAULT_BROWSER_CDP_PORT_RANGE_START,
);
export function deriveDefaultBrowserCdpPortRange(browserControlPort: number): PortRange {
const start = derivePort(browserControlPort, 9, DEFAULT_BROWSER_CDP_PORT_RANGE_START);
const end = clampPort(
start +
(DEFAULT_BROWSER_CDP_PORT_RANGE_END -
DEFAULT_BROWSER_CDP_PORT_RANGE_START),
start + (DEFAULT_BROWSER_CDP_PORT_RANGE_END - DEFAULT_BROWSER_CDP_PORT_RANGE_START),
DEFAULT_BROWSER_CDP_PORT_RANGE_END,
);
if (end < start) return { start, end: start };

View File

@@ -41,11 +41,7 @@ describe("runtime overrides", () => {
});
it("rejects prototype pollution paths", () => {
const attempts = [
"__proto__.polluted",
"constructor.polluted",
"prototype.polluted",
];
const attempts = ["__proto__.polluted", "constructor.polluted", "prototype.polluted"];
for (const path of attempts) {
const result = setConfigOverride(path, true);
expect(result.ok).toBe(false);

View File

@@ -1,8 +1,4 @@
import {
parseConfigPath,
setConfigValueAtPath,
unsetConfigValueAtPath,
} from "./config-paths.js";
import { parseConfigPath, setConfigValueAtPath, unsetConfigValueAtPath } from "./config-paths.js";
import type { ClawdbotConfig } from "./types.js";
type OverrideTree = Record<string, unknown>;

View File

@@ -30,15 +30,10 @@ describe("config schema", () => {
});
expect(res.uiHints["plugins.entries.voice-call"]?.label).toBe("Voice Call");
expect(res.uiHints["plugins.entries.voice-call.config"]?.label).toBe(
"Voice Call Config",
expect(res.uiHints["plugins.entries.voice-call.config"]?.label).toBe("Voice Call Config");
expect(res.uiHints["plugins.entries.voice-call.config.twilio.authToken"]?.label).toBe(
"Auth Token",
);
expect(
res.uiHints["plugins.entries.voice-call.config.twilio.authToken"]?.label,
).toBe("Auth Token");
expect(
res.uiHints["plugins.entries.voice-call.config.twilio.authToken"]
?.sensitive,
).toBe(true);
expect(res.uiHints["plugins.entries.voice-call.config.twilio.authToken"]?.sensitive).toBe(true);
});
});

View File

@@ -29,10 +29,7 @@ export type PluginUiMetadata = {
description?: string;
configUiHints?: Record<
string,
Pick<
ConfigUiHint,
"label" | "help" | "advanced" | "sensitive" | "placeholder"
>
Pick<ConfigUiHint, "label" | "help" | "advanced" | "sensitive" | "placeholder">
>;
};
@@ -95,15 +92,13 @@ const FIELD_LABELS: Record<string, string> = {
"gateway.auth.token": "Gateway Token",
"gateway.auth.password": "Gateway Password",
"tools.audio.transcription.args": "Audio Transcription Args",
"tools.audio.transcription.timeoutSeconds":
"Audio Transcription Timeout (sec)",
"tools.audio.transcription.timeoutSeconds": "Audio Transcription Timeout (sec)",
"tools.profile": "Tool Profile",
"agents.list[].tools.profile": "Agent Tool Profile",
"tools.exec.applyPatch.enabled": "Enable apply_patch",
"tools.exec.applyPatch.allowModels": "apply_patch Model Allowlist",
"gateway.controlUi.basePath": "Control UI Base Path",
"gateway.http.endpoints.chatCompletions.enabled":
"OpenAI Chat Completions Endpoint",
"gateway.http.endpoints.chatCompletions.enabled": "OpenAI Chat Completions Endpoint",
"gateway.reload.mode": "Config Reload Mode",
"gateway.reload.debounceMs": "Config Reload Debounce (ms)",
"agents.defaults.workspace": "Workspace",
@@ -119,13 +114,11 @@ const FIELD_LABELS: Record<string, string> = {
"agents.defaults.memorySearch.local.modelPath": "Local Embedding Model Path",
"agents.defaults.memorySearch.store.path": "Memory Search Index Path",
"agents.defaults.memorySearch.chunking.tokens": "Memory Chunk Tokens",
"agents.defaults.memorySearch.chunking.overlap":
"Memory Chunk Overlap Tokens",
"agents.defaults.memorySearch.chunking.overlap": "Memory Chunk Overlap Tokens",
"agents.defaults.memorySearch.sync.onSessionStart": "Index on Session Start",
"agents.defaults.memorySearch.sync.onSearch": "Index on Search (Lazy)",
"agents.defaults.memorySearch.sync.watch": "Watch Memory Files",
"agents.defaults.memorySearch.sync.watchDebounceMs":
"Memory Watch Debounce (ms)",
"agents.defaults.memorySearch.sync.watchDebounceMs": "Memory Watch Debounce (ms)",
"agents.defaults.memorySearch.query.maxResults": "Memory Search Max Results",
"agents.defaults.memorySearch.query.minScore": "Memory Search Min Score",
"auth.profiles": "Auth Profiles",
@@ -169,8 +162,7 @@ const FIELD_LABELS: Record<string, string> = {
"channels.telegram.streamMode": "Telegram Draft Stream Mode",
"channels.telegram.draftChunk.minChars": "Telegram Draft Chunk Min Chars",
"channels.telegram.draftChunk.maxChars": "Telegram Draft Chunk Max Chars",
"channels.telegram.draftChunk.breakPreference":
"Telegram Draft Chunk Break Preference",
"channels.telegram.draftChunk.breakPreference": "Telegram Draft Chunk Break Preference",
"channels.telegram.retry.attempts": "Telegram Retry Attempts",
"channels.telegram.retry.minDelayMs": "Telegram Retry Min Delay (ms)",
"channels.telegram.retry.maxDelayMs": "Telegram Retry Max Delay (ms)",
@@ -206,19 +198,15 @@ const FIELD_HELP: Record<string, string> = {
"gateway.remote.url": "Remote Gateway WebSocket URL (ws:// or wss://).",
"gateway.remote.sshTarget":
"Remote gateway over SSH (tunnels the gateway port to localhost). Format: user@host or user@host:port.",
"gateway.remote.sshIdentity":
"Optional SSH identity file path (passed to ssh -i).",
"gateway.auth.token":
"Recommended for all gateways; required for non-loopback binds.",
"gateway.remote.sshIdentity": "Optional SSH identity file path (passed to ssh -i).",
"gateway.auth.token": "Recommended for all gateways; required for non-loopback binds.",
"gateway.auth.password": "Required for Tailscale funnel.",
"gateway.controlUi.basePath":
"Optional URL prefix where the Control UI is served (e.g. /clawdbot).",
"gateway.http.endpoints.chatCompletions.enabled":
"Enable the OpenAI-compatible `POST /v1/chat/completions` endpoint (default: false).",
"gateway.reload.mode":
'Hot reload strategy for config changes ("hybrid" recommended).',
"gateway.reload.debounceMs":
"Debounce window (ms) before applying config changes.",
"gateway.reload.mode": 'Hot reload strategy for config changes ("hybrid" recommended).',
"gateway.reload.debounceMs": "Debounce window (ms) before applying config changes.",
"tools.exec.applyPatch.enabled":
"Experimental. Enables apply_patch for OpenAI models when allowed by tool policy.",
"tools.exec.applyPatch.allowModels":
@@ -226,28 +214,22 @@ const FIELD_HELP: Record<string, string> = {
"channels.slack.allowBots":
"Allow bot-authored messages to trigger Slack replies (default: false).",
"auth.profiles": "Named auth profiles (provider + mode + optional email).",
"auth.order":
"Ordered auth profile IDs per provider (used for automatic failover).",
"auth.order": "Ordered auth profile IDs per provider (used for automatic failover).",
"auth.cooldowns.billingBackoffHours":
"Base backoff (hours) when a profile fails due to billing/insufficient credits (default: 5).",
"auth.cooldowns.billingBackoffHoursByProvider":
"Optional per-provider overrides for billing backoff (hours).",
"auth.cooldowns.billingMaxHours":
"Cap (hours) for billing backoff (default: 24).",
"auth.cooldowns.failureWindowHours":
"Failure window (hours) for backoff counters (default: 24).",
"auth.cooldowns.billingMaxHours": "Cap (hours) for billing backoff (default: 24).",
"auth.cooldowns.failureWindowHours": "Failure window (hours) for backoff counters (default: 24).",
"agents.defaults.bootstrapMaxChars":
"Max characters of each workspace bootstrap file injected into the system prompt before truncation (default: 20000).",
"agents.defaults.models":
"Configured model catalog (keys are full provider/model IDs).",
"agents.defaults.models": "Configured model catalog (keys are full provider/model IDs).",
"agents.defaults.memorySearch":
"Vector search over MEMORY.md and memory/*.md (per-agent overrides supported).",
"agents.defaults.memorySearch.provider":
'Embedding provider ("openai" or "local").',
"agents.defaults.memorySearch.provider": 'Embedding provider ("openai" or "local").',
"agents.defaults.memorySearch.remote.baseUrl":
"Custom OpenAI-compatible base URL (e.g. for Gemini/OpenRouter proxies).",
"agents.defaults.memorySearch.remote.apiKey":
"Custom API key for the remote embedding provider.",
"agents.defaults.memorySearch.remote.apiKey": "Custom API key for the remote embedding provider.",
"agents.defaults.memorySearch.remote.headers":
"Extra headers for remote embeddings (merged; remote overrides OpenAI headers).",
"agents.defaults.memorySearch.local.modelPath":
@@ -258,34 +240,24 @@ const FIELD_HELP: Record<string, string> = {
"SQLite index path (default: ~/.clawdbot/memory/{agentId}.sqlite).",
"agents.defaults.memorySearch.sync.onSearch":
"Lazy sync: reindex on first search after a change.",
"agents.defaults.memorySearch.sync.watch":
"Watch memory files for changes (chokidar).",
"agents.defaults.memorySearch.sync.watch": "Watch memory files for changes (chokidar).",
"plugins.enabled": "Enable plugin/extension loading (default: true).",
"plugins.allow":
"Optional allowlist of plugin ids; when set, only listed plugins load.",
"plugins.allow": "Optional allowlist of plugin ids; when set, only listed plugins load.",
"plugins.deny": "Optional denylist of plugin ids; deny wins over allowlist.",
"plugins.load.paths": "Additional plugin files or directories to load.",
"plugins.entries":
"Per-plugin settings keyed by plugin id (enable/disable + config payloads).",
"plugins.entries.*.enabled":
"Overrides plugin enable/disable for this entry (restart required).",
"plugins.entries.*.config":
"Plugin-defined config payload (schema is provided by the plugin).",
"plugins.entries": "Per-plugin settings keyed by plugin id (enable/disable + config payloads).",
"plugins.entries.*.enabled": "Overrides plugin enable/disable for this entry (restart required).",
"plugins.entries.*.config": "Plugin-defined config payload (schema is provided by the plugin).",
"agents.defaults.model.primary": "Primary model (provider/model).",
"agents.defaults.model.fallbacks":
"Ordered fallback models (provider/model). Used when the primary model fails.",
"agents.defaults.imageModel.primary":
"Optional image model (provider/model) used when the primary model lacks image input.",
"agents.defaults.imageModel.fallbacks":
"Ordered fallback image models (provider/model).",
"agents.defaults.cliBackends":
"Optional CLI backends for text-only fallback (claude-cli, etc.).",
"agents.defaults.humanDelay.mode":
'Delay style for block replies ("off", "natural", "custom").',
"agents.defaults.humanDelay.minMs":
"Minimum delay in ms for custom humanDelay (default: 800).",
"agents.defaults.humanDelay.maxMs":
"Maximum delay in ms for custom humanDelay (default: 2500).",
"agents.defaults.imageModel.fallbacks": "Ordered fallback image models (provider/model).",
"agents.defaults.cliBackends": "Optional CLI backends for text-only fallback (claude-cli, etc.).",
"agents.defaults.humanDelay.mode": 'Delay style for block replies ("off", "natural", "custom").',
"agents.defaults.humanDelay.minMs": "Minimum delay in ms for custom humanDelay (default: 800).",
"agents.defaults.humanDelay.maxMs": "Maximum delay in ms for custom humanDelay (default: 2500).",
"commands.native":
"Register native commands with channels that support it (Discord/Slack/Telegram).",
"commands.text": "Allow text command parsing (slash commands only).",
@@ -293,24 +265,16 @@ const FIELD_HELP: Record<string, string> = {
"Allow bash chat command (`!`; `/bash` alias) to run host shell commands (default: false; requires tools.elevated).",
"commands.bashForegroundMs":
"How long bash waits before backgrounding (default: 2000; 0 backgrounds immediately).",
"commands.config":
"Allow /config chat command to read/write config on disk (default: false).",
"commands.debug":
"Allow /debug chat command for runtime-only overrides (default: false).",
"commands.restart":
"Allow /restart and gateway restart tool actions (default: false).",
"commands.useAccessGroups":
"Enforce access-group allowlists/policies for commands.",
"channels.discord.commands.native":
'Override native commands for Discord (bool or "auto").',
"channels.telegram.commands.native":
'Override native commands for Telegram (bool or "auto").',
"channels.slack.commands.native":
'Override native commands for Slack (bool or "auto").',
"commands.config": "Allow /config chat command to read/write config on disk (default: false).",
"commands.debug": "Allow /debug chat command for runtime-only overrides (default: false).",
"commands.restart": "Allow /restart and gateway restart tool actions (default: false).",
"commands.useAccessGroups": "Enforce access-group allowlists/policies for commands.",
"channels.discord.commands.native": 'Override native commands for Discord (bool or "auto").',
"channels.telegram.commands.native": 'Override native commands for Telegram (bool or "auto").',
"channels.slack.commands.native": 'Override native commands for Slack (bool or "auto").',
"session.agentToAgent.maxPingPongTurns":
"Max reply-back turns between requester and target (05).",
"messages.ackReaction":
"Emoji reaction used to acknowledge inbound messages (empty disables).",
"messages.ackReaction": "Emoji reaction used to acknowledge inbound messages (empty disables).",
"messages.ackReactionScope":
'When to send ack reactions ("group-mentions", "group-all", "direct", "all").',
"channels.telegram.dmPolicy":
@@ -325,18 +289,15 @@ const FIELD_HELP: Record<string, string> = {
"Preferred breakpoints for Telegram draft chunks (paragraph | newline | sentence). Default: paragraph.",
"channels.telegram.retry.attempts":
"Max retry attempts for outbound Telegram API calls (default: 3).",
"channels.telegram.retry.minDelayMs":
"Minimum retry delay in ms for Telegram outbound calls.",
"channels.telegram.retry.minDelayMs": "Minimum retry delay in ms for Telegram outbound calls.",
"channels.telegram.retry.maxDelayMs":
"Maximum retry delay cap in ms for Telegram outbound calls.",
"channels.telegram.retry.jitter":
"Jitter factor (0-1) applied to Telegram retry delays.",
"channels.telegram.retry.jitter": "Jitter factor (0-1) applied to Telegram retry delays.",
"channels.telegram.timeoutSeconds":
"Max seconds before Telegram API requests are aborted (default: 500 per grammY).",
"channels.whatsapp.dmPolicy":
'Direct message access control ("pairing" recommended). "open" requires channels.whatsapp.allowFrom=["*"].',
"channels.whatsapp.selfChatMode":
"Same-phone setup (bot uses your personal WhatsApp number).",
"channels.whatsapp.selfChatMode": "Same-phone setup (bot uses your personal WhatsApp number).",
"channels.signal.dmPolicy":
'Direct message access control ("pairing" recommended). "open" requires channels.signal.allowFrom=["*"].',
"channels.imessage.dmPolicy":
@@ -345,14 +306,10 @@ const FIELD_HELP: Record<string, string> = {
'Direct message access control ("pairing" recommended). "open" requires channels.discord.dm.allowFrom=["*"].',
"channels.discord.retry.attempts":
"Max retry attempts for outbound Discord API calls (default: 3).",
"channels.discord.retry.minDelayMs":
"Minimum retry delay in ms for Discord outbound calls.",
"channels.discord.retry.maxDelayMs":
"Maximum retry delay cap in ms for Discord outbound calls.",
"channels.discord.retry.jitter":
"Jitter factor (0-1) applied to Discord retry delays.",
"channels.discord.maxLinesPerMessage":
"Soft max line count per Discord message (default: 17).",
"channels.discord.retry.minDelayMs": "Minimum retry delay in ms for Discord outbound calls.",
"channels.discord.retry.maxDelayMs": "Maximum retry delay cap in ms for Discord outbound calls.",
"channels.discord.retry.jitter": "Jitter factor (0-1) applied to Discord retry delays.",
"channels.discord.maxLinesPerMessage": "Soft max line count per Discord message (default: 17).",
"channels.slack.dm.policy":
'Direct message access control ("pairing" recommended). "open" requires channels.slack.dm.allowFrom=["*"].',
};
@@ -403,10 +360,7 @@ function applySensitiveHints(hints: ConfigUiHints): ConfigUiHints {
return next;
}
function applyPluginHints(
hints: ConfigUiHints,
plugins: PluginUiMetadata[],
): ConfigUiHints {
function applyPluginHints(hints: ConfigUiHints, plugins: PluginUiMetadata[]): ConfigUiHints {
const next: ConfigUiHints = { ...hints };
for (const plugin of plugins) {
const id = plugin.id.trim();
@@ -465,9 +419,7 @@ function buildBaseConfigSchema(): ConfigSchemaResponse {
return next;
}
export function buildConfigSchema(params?: {
plugins?: PluginUiMetadata[];
}): ConfigSchemaResponse {
export function buildConfigSchema(params?: { plugins?: PluginUiMetadata[] }): ConfigSchemaResponse {
const base = buildBaseConfigSchema();
const plugins = params?.plugins ?? [];
if (plugins.length === 0) return base;

View File

@@ -17,9 +17,7 @@ import {
describe("sessions", () => {
it("returns normalized per-sender key", () => {
expect(deriveSessionKey("per-sender", { From: "whatsapp:+1555" })).toBe(
"+1555",
);
expect(deriveSessionKey("per-sender", { From: "whatsapp:+1555" })).toBe("+1555");
});
it("falls back to unknown when sender missing", () => {
@@ -31,9 +29,7 @@ describe("sessions", () => {
});
it("keeps group chats distinct", () => {
expect(deriveSessionKey("per-sender", { From: "12345-678@g.us" })).toBe(
"group:12345-678@g.us",
);
expect(deriveSessionKey("per-sender", { From: "12345-678@g.us" })).toBe("group:12345-678@g.us");
});
it("prefixes group keys with provider when available", () => {
@@ -48,11 +44,7 @@ describe("sessions", () => {
it("keeps explicit provider when provided in group key", () => {
expect(
resolveSessionKey(
"per-sender",
{ From: "group:discord:12345", ChatType: "group" },
"main",
),
resolveSessionKey("per-sender", { From: "group:discord:12345", ChatType: "group" }, "main"),
).toBe("agent:main:discord:group:12345");
});
@@ -69,9 +61,7 @@ describe("sessions", () => {
});
it("collapses direct chats to main by default", () => {
expect(resolveSessionKey("per-sender", { From: "+1555" })).toBe(
"agent:main:main",
);
expect(resolveSessionKey("per-sender", { From: "+1555" })).toBe("agent:main:main");
});
it("collapses direct chats to main even when sender missing", () => {
@@ -79,9 +69,9 @@ describe("sessions", () => {
});
it("maps direct chats to main key when provided", () => {
expect(
resolveSessionKey("per-sender", { From: "whatsapp:+1555" }, "main"),
).toBe("agent:main:main");
expect(resolveSessionKey("per-sender", { From: "whatsapp:+1555" }, "main")).toBe(
"agent:main:main",
);
});
it("uses custom main key when provided", () => {
@@ -95,9 +85,9 @@ describe("sessions", () => {
});
it("leaves groups untouched even with main key", () => {
expect(
resolveSessionKey("per-sender", { From: "12345-678@g.us" }, "main"),
).toBe("agent:main:group:12345-678@g.us");
expect(resolveSessionKey("per-sender", { From: "12345-678@g.us" }, "main")).toBe(
"agent:main:group:12345-678@g.us",
);
});
it("updateLastRoute persists channel and target", async () => {
@@ -169,10 +159,7 @@ describe("sessions", () => {
"utf-8",
);
const store = loadSessionStore(storePath) as unknown as Record<
string,
Record<string, unknown>
>;
const store = loadSessionStore(storePath) as unknown as Record<string, Record<string, unknown>>;
const entry = store[mainSessionKey] ?? {};
expect(entry.channel).toBe("slack");
expect(entry.provider).toBeUndefined();
@@ -185,9 +172,7 @@ describe("sessions", () => {
{ CLAWDBOT_STATE_DIR: "/custom/state" } as NodeJS.ProcessEnv,
() => "/home/ignored",
);
expect(dir).toBe(
path.join(path.resolve("/custom/state"), "agents", "main", "sessions"),
);
expect(dir).toBe(path.join(path.resolve("/custom/state"), "agents", "main", "sessions"));
});
it("falls back to CLAWDIS_STATE_DIR for session transcripts dir", () => {
@@ -195,9 +180,7 @@ describe("sessions", () => {
{ CLAWDIS_STATE_DIR: "/legacy/state" } as NodeJS.ProcessEnv,
() => "/home/ignored",
);
expect(dir).toBe(
path.join(path.resolve("/legacy/state"), "agents", "main", "sessions"),
);
expect(dir).toBe(path.join(path.resolve("/legacy/state"), "agents", "main", "sessions"));
});
it("includes topic ids in session transcript filenames", () => {
@@ -231,13 +214,7 @@ describe("sessions", () => {
agentId: "codex",
});
expect(sessionFile).toBe(
path.join(
path.resolve("/custom/state"),
"agents",
"codex",
"sessions",
"sess-2.jsonl",
),
path.join(path.resolve("/custom/state"), "agents", "codex", "sessions", "sess-2.jsonl"),
);
} finally {
if (prev === undefined) {
@@ -289,9 +266,7 @@ describe("sessions", () => {
]);
const store = loadSessionStore(storePath);
expect(store[mainSessionKey]?.modelOverride).toBe(
"anthropic/claude-opus-4-5",
);
expect(store[mainSessionKey]?.modelOverride).toBe("anthropic/claude-opus-4-5");
expect(store[mainSessionKey]?.thinkingLevel).toBe("high");
await expect(fs.stat(`${storePath}.lock`)).rejects.toThrow();
});

View File

@@ -44,20 +44,13 @@ export function buildGroupDisplayName(params: {
if (!params.room && token.startsWith("#")) {
token = token.replace(/^#+/, "");
}
if (
token &&
!/^[@#]/.test(token) &&
!token.startsWith("g-") &&
!token.includes("#")
) {
if (token && !/^[@#]/.test(token) && !token.startsWith("g-") && !token.includes("#")) {
token = `g-${token}`;
}
return token ? `${providerKey}:${token}` : providerKey;
}
export function resolveGroupSessionKey(
ctx: MsgContext,
): GroupKeyResolution | null {
export function resolveGroupSessionKey(ctx: MsgContext): GroupKeyResolution | null {
const from = typeof ctx.From === "string" ? ctx.From.trim() : "";
if (!from) return null;
const chatType = ctx.ChatType?.trim().toLowerCase();
@@ -71,9 +64,7 @@ export function resolveGroupSessionKey(
const providerHint = ctx.Provider?.trim().toLowerCase();
const hasLegacyGroupPrefix = from.startsWith("group:");
const raw = (
hasLegacyGroupPrefix ? from.slice("group:".length) : from
).trim();
const raw = (hasLegacyGroupPrefix ? from.slice("group:".length) : from).trim();
let provider: string | undefined;
let kind: "group" | "channel" | undefined;

View File

@@ -15,9 +15,7 @@ export function resolveMainSessionKey(cfg?: {
if (cfg?.session?.scope === "global") return "global";
const agents = cfg?.agents?.list ?? [];
const defaultAgentId =
agents.find((agent) => agent?.default)?.id ??
agents[0]?.id ??
DEFAULT_AGENT_ID;
agents.find((agent) => agent?.default)?.id ?? agents[0]?.id ?? DEFAULT_AGENT_ID;
const agentId = normalizeAgentId(defaultAgentId);
const mainKey = normalizeMainKey(cfg?.session?.mainKey);
return buildAgentMainSessionKey({ agentId, mainKey });
@@ -54,10 +52,7 @@ export function canonicalizeMainSessionAlias(params: {
});
const isMainAlias =
raw === "main" ||
raw === mainKey ||
raw === agentMainSessionKey ||
raw === agentMainAliasKey;
raw === "main" || raw === mainKey || raw === agentMainSessionKey || raw === agentMainAliasKey;
if (params.cfg?.session?.scope === "global" && isMainAlias) return "global";
if (isMainAlias) return agentMainSessionKey;

View File

@@ -1,9 +1,6 @@
import os from "node:os";
import path from "node:path";
import {
DEFAULT_AGENT_ID,
normalizeAgentId,
} from "../../routing/session-key.js";
import { DEFAULT_AGENT_ID, normalizeAgentId } from "../../routing/session-key.js";
import { resolveStateDir } from "../paths.js";
import type { SessionEntry } from "./types.js";
@@ -42,9 +39,7 @@ export function resolveSessionTranscriptPath(
topicId?: number,
): string {
const fileName =
topicId !== undefined
? `${sessionId}-topic-${topicId}.jsonl`
: `${sessionId}.jsonl`;
topicId !== undefined ? `${sessionId}-topic-${topicId}.jsonl` : `${sessionId}.jsonl`;
return path.join(resolveAgentSessionsDir(agentId), fileName);
}
@@ -54,9 +49,7 @@ export function resolveSessionFilePath(
opts?: { agentId?: string },
): string {
const candidate = entry?.sessionFile?.trim();
return candidate
? candidate
: resolveSessionTranscriptPath(sessionId, opts?.agentId);
return candidate ? candidate : resolveSessionTranscriptPath(sessionId, opts?.agentId);
}
export function resolveStorePath(store?: string, opts?: { agentId?: string }) {
@@ -69,7 +62,6 @@ export function resolveStorePath(store?: string, opts?: { agentId?: string }) {
}
return path.resolve(expanded);
}
if (store.startsWith("~"))
return path.resolve(store.replace(/^~(?=$|[\\/])/, os.homedir()));
if (store.startsWith("~")) return path.resolve(store.replace(/^~(?=$|[\\/])/, os.homedir()));
return path.resolve(store);
}

View File

@@ -21,11 +21,7 @@ export function deriveSessionKey(scope: SessionScope, ctx: MsgContext) {
* Resolve the session key with a canonical direct-chat bucket (default: "main").
* All non-group direct chats collapse to this bucket; groups stay isolated.
*/
export function resolveSessionKey(
scope: SessionScope,
ctx: MsgContext,
mainKey?: string,
) {
export function resolveSessionKey(scope: SessionScope, ctx: MsgContext, mainKey?: string) {
const explicit = ctx.SessionKey?.trim();
if (explicit) return explicit;
const raw = deriveSessionKey(scope, ctx);
@@ -35,10 +31,7 @@ export function resolveSessionKey(
agentId: DEFAULT_AGENT_ID,
mainKey: canonicalMainKey,
});
const isGroup =
raw.startsWith("group:") ||
raw.includes(":group:") ||
raw.includes(":channel:");
const isGroup = raw.startsWith("group:") || raw.includes(":group:") || raw.includes(":channel:");
if (!isGroup) return canonical;
return `agent:${DEFAULT_AGENT_ID}:${raw}`;
}

View File

@@ -3,11 +3,7 @@ import fs from "node:fs";
import path from "node:path";
import JSON5 from "json5";
import {
getFileMtimeMs,
isCacheEnabled,
resolveCacheTtlMs,
} from "../cache-utils.js";
import { getFileMtimeMs, isCacheEnabled, resolveCacheTtlMs } from "../cache-utils.js";
import { mergeSessionEntry, type SessionEntry } from "./types.js";
// ============================================================================
@@ -49,9 +45,7 @@ export function clearSessionStoreCacheForTest(): void {
SESSION_STORE_CACHE.clear();
}
export function loadSessionStore(
storePath: string,
): Record<string, SessionEntry> {
export function loadSessionStore(storePath: string): Record<string, SessionEntry> {
// Check cache first if enabled
if (isSessionStoreCacheEnabled()) {
const cached = SESSION_STORE_CACHE.get(storePath);
@@ -87,10 +81,7 @@ export function loadSessionStore(
rec.channel = rec.provider;
delete rec.provider;
}
if (
typeof rec.lastChannel !== "string" &&
typeof rec.lastProvider === "string"
) {
if (typeof rec.lastChannel !== "string" && typeof rec.lastProvider === "string") {
rec.lastChannel = rec.lastProvider;
delete rec.lastProvider;
}
@@ -288,9 +279,7 @@ export async function updateLastRoute(params: {
updatedAt: Math.max(existing?.updatedAt ?? 0, now),
lastChannel: channel,
lastTo: to?.trim() ? to.trim() : undefined,
lastAccountId: accountId?.trim()
? accountId.trim()
: existing?.lastAccountId,
lastAccountId: accountId?.trim() ? accountId.trim() : existing?.lastAccountId,
});
store[sessionKey] = next;
await saveSessionStoreUnlocked(storePath, store);

View File

@@ -67,13 +67,8 @@ export function mergeSessionEntry(
existing: SessionEntry | undefined,
patch: Partial<SessionEntry>,
): SessionEntry {
const sessionId =
patch.sessionId ?? existing?.sessionId ?? crypto.randomUUID();
const updatedAt = Math.max(
existing?.updatedAt ?? 0,
patch.updatedAt ?? 0,
Date.now(),
);
const sessionId = patch.sessionId ?? existing?.sessionId ?? crypto.randomUUID();
const updatedAt = Math.max(existing?.updatedAt ?? 0, patch.updatedAt ?? 0, Date.now());
if (!existing) return { ...patch, sessionId, updatedAt };
return { ...existing, ...patch, sessionId, updatedAt };
}

View File

@@ -8,16 +8,14 @@ type TalkApiKeyDeps = {
path?: typeof path;
};
export function readTalkApiKeyFromProfile(
deps: TalkApiKeyDeps = {},
): string | null {
export function readTalkApiKeyFromProfile(deps: TalkApiKeyDeps = {}): string | null {
const fsImpl = deps.fs ?? fs;
const osImpl = deps.os ?? os;
const pathImpl = deps.path ?? path;
const home = osImpl.homedir();
const candidates = [".profile", ".zprofile", ".zshrc", ".bashrc"].map(
(name) => pathImpl.join(home, name),
const candidates = [".profile", ".zprofile", ".zshrc", ".bashrc"].map((name) =>
pathImpl.join(home, name),
);
for (const candidate of candidates) {
if (!fsImpl.existsSync(candidate)) continue;

View File

@@ -2,9 +2,7 @@ import { vi } from "vitest";
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
export async function withTempHome<T>(
fn: (home: string) => Promise<T>,
): Promise<T> {
export async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
return withTempHomeBase(fn, { prefix: "clawdbot-config-" });
}

View File

@@ -71,14 +71,7 @@ export type SessionConfig = {
export type LoggingConfig = {
level?: "silent" | "fatal" | "error" | "warn" | "info" | "debug" | "trace";
file?: string;
consoleLevel?:
| "silent"
| "fatal"
| "error"
| "warn"
| "info"
| "debug"
| "trace";
consoleLevel?: "silent" | "fatal" | "error" | "warn" | "info" | "debug" | "trace";
consoleStyle?: "pretty" | "compact" | "json";
/** Redact sensitive tokens in tool summaries. Default: "tools". */
redactSensitive?: "off" | "tools";
@@ -102,9 +95,7 @@ export type WebConfig = {
};
// Provider docking: allowlists keyed by provider id (and internal "webchat").
export type AgentElevatedAllowFromConfig = Partial<
Record<string, Array<string | number>>
>;
export type AgentElevatedAllowFromConfig = Partial<Record<string, Array<string | number>>>;
export type IdentityConfig = {
name?: string;

View File

@@ -33,11 +33,7 @@ export type DiscordGuildChannelConfig = {
systemPrompt?: string;
};
export type DiscordReactionNotificationMode =
| "off"
| "own"
| "all"
| "allowlist";
export type DiscordReactionNotificationMode = "off" | "own" | "all" | "allowlist";
export type DiscordGuildEntry = {
slug?: string;

View File

@@ -1,8 +1,4 @@
import type {
BlockStreamingCoalesceConfig,
DmPolicy,
GroupPolicy,
} from "./types.base.js";
import type { BlockStreamingCoalesceConfig, DmPolicy, GroupPolicy } from "./types.base.js";
import type { DmConfig } from "./types.messages.js";
export type IMessageAccountConfig = {

View File

@@ -1,8 +1,4 @@
import type {
QueueDropPolicy,
QueueMode,
QueueModeByProvider,
} from "./types.queue.js";
import type { QueueDropPolicy, QueueMode, QueueModeByProvider } from "./types.queue.js";
export type GroupChatConfig = {
mentionPatterns?: string[];

View File

@@ -1,8 +1,4 @@
import type {
BlockStreamingCoalesceConfig,
DmPolicy,
GroupPolicy,
} from "./types.base.js";
import type { BlockStreamingCoalesceConfig, DmPolicy, GroupPolicy } from "./types.base.js";
import type { DmConfig } from "./types.messages.js";
export type MSTeamsWebhookConfig = {

View File

@@ -1,15 +1,7 @@
import type {
BlockStreamingCoalesceConfig,
DmPolicy,
GroupPolicy,
} from "./types.base.js";
import type { BlockStreamingCoalesceConfig, DmPolicy, GroupPolicy } from "./types.base.js";
import type { DmConfig } from "./types.messages.js";
export type SignalReactionNotificationMode =
| "off"
| "own"
| "all"
| "allowlist";
export type SignalReactionNotificationMode = "off" | "own" | "all" | "allowlist";
export type SignalAccountConfig = {
/** Optional display name for this account (used in CLI/UI lists). */

View File

@@ -1,8 +1,4 @@
import type {
BlockStreamingCoalesceConfig,
DmPolicy,
GroupPolicy,
} from "./types.base.js";
import type { BlockStreamingCoalesceConfig, DmPolicy, GroupPolicy } from "./types.base.js";
import type { DmConfig } from "./types.messages.js";
export type WhatsAppActionConfig = {

View File

@@ -1,7 +1,4 @@
import {
findDuplicateAgentDirs,
formatDuplicateAgentDirError,
} from "./agent-dirs.js";
import { findDuplicateAgentDirs, formatDuplicateAgentDirError } from "./agent-dirs.js";
import { applyModelDefaults, applySessionDefaults } from "./defaults.js";
import { findLegacyConfigIssues } from "./legacy.js";
import type { ClawdbotConfig, ConfigValidationIssue } from "./types.js";
@@ -9,9 +6,7 @@ import { ClawdbotSchema } from "./zod-schema.js";
export function validateConfigObject(
raw: unknown,
):
| { ok: true; config: ClawdbotConfig }
| { ok: false; issues: ConfigValidationIssue[] } {
): { ok: true; config: ClawdbotConfig } | { ok: false; issues: ConfigValidationIssue[] } {
const legacyIssues = findLegacyConfigIssues(raw);
if (legacyIssues.length > 0) {
return {
@@ -46,8 +41,6 @@ export function validateConfigObject(
}
return {
ok: true,
config: applyModelDefaults(
applySessionDefaults(validated.data as ClawdbotConfig),
),
config: applyModelDefaults(applySessionDefaults(validated.data as ClawdbotConfig)),
};
}

View File

@@ -47,11 +47,7 @@ export const AgentDefaultsSchema = z
contextPruning: z
.object({
mode: z
.union([
z.literal("off"),
z.literal("adaptive"),
z.literal("aggressive"),
])
.union([z.literal("off"), z.literal("adaptive"), z.literal("aggressive")])
.optional(),
keepLastAssistants: z.number().int().nonnegative().optional(),
softTrimRatio: z.number().min(0).max(1).optional(),
@@ -80,9 +76,7 @@ export const AgentDefaultsSchema = z
.optional(),
compaction: z
.object({
mode: z
.union([z.literal("default"), z.literal("safeguard")])
.optional(),
mode: z.union([z.literal("default"), z.literal("safeguard")]).optional(),
reserveTokensFloor: z.number().int().nonnegative().optional(),
memoryFlush: z
.object({
@@ -106,12 +100,8 @@ export const AgentDefaultsSchema = z
.optional(),
verboseDefault: z.union([z.literal("off"), z.literal("on")]).optional(),
elevatedDefault: z.union([z.literal("off"), z.literal("on")]).optional(),
blockStreamingDefault: z
.union([z.literal("off"), z.literal("on")])
.optional(),
blockStreamingBreak: z
.union([z.literal("text_end"), z.literal("message_end")])
.optional(),
blockStreamingDefault: z.union([z.literal("off"), z.literal("on")]).optional(),
blockStreamingBreak: z.union([z.literal("text_end"), z.literal("message_end")]).optional(),
blockStreamingChunk: BlockStreamingChunkSchema.optional(),
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
humanDelay: HumanDelaySchema.optional(),
@@ -145,22 +135,10 @@ export const AgentDefaultsSchema = z
.optional(),
sandbox: z
.object({
mode: z
.union([z.literal("off"), z.literal("non-main"), z.literal("all")])
.optional(),
workspaceAccess: z
.union([z.literal("none"), z.literal("ro"), z.literal("rw")])
.optional(),
sessionToolsVisibility: z
.union([z.literal("spawned"), z.literal("all")])
.optional(),
scope: z
.union([
z.literal("session"),
z.literal("agent"),
z.literal("shared"),
])
.optional(),
mode: z.union([z.literal("off"), z.literal("non-main"), z.literal("all")]).optional(),
workspaceAccess: z.union([z.literal("none"), z.literal("ro"), z.literal("rw")]).optional(),
sessionToolsVisibility: z.union([z.literal("spawned"), z.literal("all")]).optional(),
scope: z.union([z.literal("session"), z.literal("agent"), z.literal("shared")]).optional(),
perSession: z.boolean().optional(),
workspaceRoot: z.string().optional(),
docker: SandboxDockerSchema,

View File

@@ -114,12 +114,7 @@ export const ToolPolicySchema = z
.optional();
export const ToolProfileSchema = z
.union([
z.literal("minimal"),
z.literal("coding"),
z.literal("messaging"),
z.literal("full"),
])
.union([z.literal("minimal"), z.literal("coding"), z.literal("messaging"), z.literal("full")])
.optional();
// Provider docking: allowlists keyed by provider id (no schema updates when adding providers).
@@ -129,18 +124,10 @@ export const ElevatedAllowFromSchema = z
export const AgentSandboxSchema = z
.object({
mode: z
.union([z.literal("off"), z.literal("non-main"), z.literal("all")])
.optional(),
workspaceAccess: z
.union([z.literal("none"), z.literal("ro"), z.literal("rw")])
.optional(),
sessionToolsVisibility: z
.union([z.literal("spawned"), z.literal("all")])
.optional(),
scope: z
.union([z.literal("session"), z.literal("agent"), z.literal("shared")])
.optional(),
mode: z.union([z.literal("off"), z.literal("non-main"), z.literal("all")]).optional(),
workspaceAccess: z.union([z.literal("none"), z.literal("ro"), z.literal("rw")]).optional(),
sessionToolsVisibility: z.union([z.literal("spawned"), z.literal("all")]).optional(),
scope: z.union([z.literal("session"), z.literal("agent"), z.literal("shared")]).optional(),
perSession: z.boolean().optional(),
workspaceRoot: z.string().optional(),
docker: SandboxDockerSchema,

View File

@@ -19,11 +19,7 @@ export const BindingsSchema = z
accountId: z.string().optional(),
peer: z
.object({
kind: z.union([
z.literal("dm"),
z.literal("group"),
z.literal("channel"),
]),
kind: z.union([z.literal("dm"), z.literal("group"), z.literal("channel")]),
id: z.string(),
})
.optional(),

View File

@@ -88,11 +88,7 @@ export const QueueDropSchema = z.union([
z.literal("new"),
z.literal("summarize"),
]);
export const ReplyToModeSchema = z.union([
z.literal("off"),
z.literal("first"),
z.literal("all"),
]);
export const ReplyToModeSchema = z.union([z.literal("off"), z.literal("first"), z.literal("all")]);
// GroupPolicySchema: controls how group messages are handled
// Used with .default("allowlist").optional() pattern:
@@ -100,12 +96,7 @@ export const ReplyToModeSchema = z.union([
// - .default("allowlist") ensures runtime always resolves to "allowlist" if not provided
export const GroupPolicySchema = z.enum(["open", "disabled", "allowlist"]);
export const DmPolicySchema = z.enum([
"pairing",
"allowlist",
"open",
"disabled",
]);
export const DmPolicySchema = z.enum(["pairing", "allowlist", "open", "disabled"]);
export const BlockStreamingCoalesceSchema = z.object({
minChars: z.number().int().positive().optional(),
@@ -117,18 +108,12 @@ export const BlockStreamingChunkSchema = z.object({
minChars: z.number().int().positive().optional(),
maxChars: z.number().int().positive().optional(),
breakPreference: z
.union([
z.literal("paragraph"),
z.literal("newline"),
z.literal("sentence"),
])
.union([z.literal("paragraph"), z.literal("newline"), z.literal("sentence")])
.optional(),
});
export const HumanDelaySchema = z.object({
mode: z
.union([z.literal("off"), z.literal("natural"), z.literal("custom")])
.optional(),
mode: z.union([z.literal("off"), z.literal("natural"), z.literal("custom")]).optional(),
minMs: z.number().int().nonnegative().optional(),
maxMs: z.number().int().nonnegative().optional(),
});
@@ -136,12 +121,8 @@ export const HumanDelaySchema = z.object({
export const CliBackendSchema = z.object({
command: z.string(),
args: z.array(z.string()).optional(),
output: z
.union([z.literal("json"), z.literal("text"), z.literal("jsonl")])
.optional(),
resumeOutput: z
.union([z.literal("json"), z.literal("text"), z.literal("jsonl")])
.optional(),
output: z.union([z.literal("json"), z.literal("text"), z.literal("jsonl")]).optional(),
resumeOutput: z.union([z.literal("json"), z.literal("text"), z.literal("jsonl")]).optional(),
input: z.union([z.literal("arg"), z.literal("stdin")]).optional(),
maxPromptArgChars: z.number().int().positive().optional(),
env: z.record(z.string(), z.string()).optional(),
@@ -151,14 +132,10 @@ export const CliBackendSchema = z.object({
sessionArg: z.string().optional(),
sessionArgs: z.array(z.string()).optional(),
resumeArgs: z.array(z.string()).optional(),
sessionMode: z
.union([z.literal("always"), z.literal("existing"), z.literal("none")])
.optional(),
sessionMode: z.union([z.literal("always"), z.literal("existing"), z.literal("none")]).optional(),
sessionIdFields: z.array(z.string()).optional(),
systemPromptArg: z.string().optional(),
systemPromptMode: z
.union([z.literal("append"), z.literal("replace")])
.optional(),
systemPromptMode: z.union([z.literal("append"), z.literal("replace")]).optional(),
systemPromptWhen: z
.union([z.literal("first"), z.literal("always"), z.literal("never")])
.optional(),
@@ -237,9 +214,7 @@ export const TranscribeAudioSchema = z
})
.optional();
export const HexColorSchema = z
.string()
.regex(/^#?[0-9a-fA-F]{6}$/, "expected hex color (RRGGBB)");
export const HexColorSchema = z.string().regex(/^#?[0-9a-fA-F]{6}$/, "expected hex color (RRGGBB)");
export const ExecutableTokenSchema = z
.string()
@@ -252,10 +227,7 @@ export const ToolsAudioTranscriptionSchema = z
})
.optional();
export const NativeCommandsSettingSchema = z.union([
z.boolean(),
z.literal("auto"),
]);
export const NativeCommandsSettingSchema = z.union([z.boolean(), z.literal("auto")]);
export const ProviderCommandsSchema = z
.object({

View File

@@ -10,9 +10,7 @@ export const HookMappingSchema = z
})
.optional(),
action: z.union([z.literal("wake"), z.literal("agent")]).optional(),
wakeMode: z
.union([z.literal("now"), z.literal("next-heartbeat")])
.optional(),
wakeMode: z.union([z.literal("now"), z.literal("next-heartbeat")]).optional(),
name: z.string().optional(),
sessionKey: z.string().optional(),
messageTemplate: z.string().optional(),
@@ -63,9 +61,7 @@ export const HooksGmailSchema = z
.optional(),
tailscale: z
.object({
mode: z
.union([z.literal("off"), z.literal("serve"), z.literal("funnel")])
.optional(),
mode: z.union([z.literal("off"), z.literal("serve"), z.literal("funnel")]).optional(),
path: z.string().optional(),
target: z.string().optional(),
})

View File

@@ -66,18 +66,16 @@ export const TelegramAccountSchemaBase = z.object({
.optional(),
});
export const TelegramAccountSchema = TelegramAccountSchemaBase.superRefine(
(value, ctx) => {
requireOpenAllowFrom({
policy: value.dmPolicy,
allowFrom: value.allowFrom,
ctx,
path: ["allowFrom"],
message:
'channels.telegram.dmPolicy="open" requires channels.telegram.allowFrom to include "*"',
});
},
);
export const TelegramAccountSchema = TelegramAccountSchemaBase.superRefine((value, ctx) => {
requireOpenAllowFrom({
policy: value.dmPolicy,
allowFrom: value.allowFrom,
ctx,
path: ["allowFrom"],
message:
'channels.telegram.dmPolicy="open" requires channels.telegram.allowFrom to include "*"',
});
});
export const TelegramConfigSchema = TelegramAccountSchemaBase.extend({
accounts: z.record(z.string(), TelegramAccountSchema.optional()).optional(),
@@ -126,9 +124,7 @@ export const DiscordGuildSchema = z.object({
requireMention: z.boolean().optional(),
reactionNotifications: z.enum(["off", "own", "all", "allowlist"]).optional(),
users: z.array(z.union([z.string(), z.number()])).optional(),
channels: z
.record(z.string(), DiscordGuildChannelSchema.optional())
.optional(),
channels: z.record(z.string(), DiscordGuildChannelSchema.optional()).optional(),
});
export const DiscordAccountSchema = z.object({
@@ -281,18 +277,15 @@ export const SignalAccountSchemaBase = z.object({
reactionAllowlist: z.array(z.union([z.string(), z.number()])).optional(),
});
export const SignalAccountSchema = SignalAccountSchemaBase.superRefine(
(value, ctx) => {
requireOpenAllowFrom({
policy: value.dmPolicy,
allowFrom: value.allowFrom,
ctx,
path: ["allowFrom"],
message:
'channels.signal.dmPolicy="open" requires channels.signal.allowFrom to include "*"',
});
},
);
export const SignalAccountSchema = SignalAccountSchemaBase.superRefine((value, ctx) => {
requireOpenAllowFrom({
policy: value.dmPolicy,
allowFrom: value.allowFrom,
ctx,
path: ["allowFrom"],
message: 'channels.signal.dmPolicy="open" requires channels.signal.allowFrom to include "*"',
});
});
export const SignalConfigSchema = SignalAccountSchemaBase.extend({
accounts: z.record(z.string(), SignalAccountSchema.optional()).optional(),
@@ -302,8 +295,7 @@ export const SignalConfigSchema = SignalAccountSchemaBase.extend({
allowFrom: value.allowFrom,
ctx,
path: ["allowFrom"],
message:
'channels.signal.dmPolicy="open" requires channels.signal.allowFrom to include "*"',
message: 'channels.signal.dmPolicy="open" requires channels.signal.allowFrom to include "*"',
});
});
@@ -313,9 +305,7 @@ export const IMessageAccountSchemaBase = z.object({
enabled: z.boolean().optional(),
cliPath: ExecutableTokenSchema.optional(),
dbPath: z.string().optional(),
service: z
.union([z.literal("imessage"), z.literal("sms"), z.literal("auto")])
.optional(),
service: z.union([z.literal("imessage"), z.literal("sms"), z.literal("auto")]).optional(),
region: z.string().optional(),
dmPolicy: DmPolicySchema.optional().default("pairing"),
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
@@ -341,18 +331,16 @@ export const IMessageAccountSchemaBase = z.object({
.optional(),
});
export const IMessageAccountSchema = IMessageAccountSchemaBase.superRefine(
(value, ctx) => {
requireOpenAllowFrom({
policy: value.dmPolicy,
allowFrom: value.allowFrom,
ctx,
path: ["allowFrom"],
message:
'channels.imessage.dmPolicy="open" requires channels.imessage.allowFrom to include "*"',
});
},
);
export const IMessageAccountSchema = IMessageAccountSchemaBase.superRefine((value, ctx) => {
requireOpenAllowFrom({
policy: value.dmPolicy,
allowFrom: value.allowFrom,
ctx,
path: ["allowFrom"],
message:
'channels.imessage.dmPolicy="open" requires channels.imessage.allowFrom to include "*"',
});
});
export const IMessageConfigSchema = IMessageAccountSchemaBase.extend({
accounts: z.record(z.string(), IMessageAccountSchema.optional()).optional(),

View File

@@ -41,24 +41,18 @@ export const WhatsAppAccountSchema = z
.object({
emoji: z.string().optional(),
direct: z.boolean().optional().default(true),
group: z
.enum(["always", "mentions", "never"])
.optional()
.default("mentions"),
group: z.enum(["always", "mentions", "never"]).optional().default("mentions"),
})
.optional(),
})
.superRefine((value, ctx) => {
if (value.dmPolicy !== "open") return;
const allow = (value.allowFrom ?? [])
.map((v) => String(v).trim())
.filter(Boolean);
const allow = (value.allowFrom ?? []).map((v) => String(v).trim()).filter(Boolean);
if (allow.includes("*")) return;
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["allowFrom"],
message:
'channels.whatsapp.accounts.*.dmPolicy="open" requires allowFrom to include "*"',
message: 'channels.whatsapp.accounts.*.dmPolicy="open" requires allowFrom to include "*"',
});
});
@@ -100,18 +94,13 @@ export const WhatsAppConfigSchema = z
.object({
emoji: z.string().optional(),
direct: z.boolean().optional().default(true),
group: z
.enum(["always", "mentions", "never"])
.optional()
.default("mentions"),
group: z.enum(["always", "mentions", "never"]).optional().default("mentions"),
})
.optional(),
})
.superRefine((value, ctx) => {
if (value.dmPolicy !== "open") return;
const allow = (value.allowFrom ?? [])
.map((v) => String(v).trim())
.filter(Boolean);
const allow = (value.allowFrom ?? []).map((v) => String(v).trim()).filter(Boolean);
if (allow.includes("*")) return;
ctx.addIssue({
code: z.ZodIssueCode.custom,

View File

@@ -1,10 +1,6 @@
import { z } from "zod";
import {
GroupChatSchema,
NativeCommandsSettingSchema,
QueueSchema,
} from "./zod-schema.core.js";
import { GroupChatSchema, NativeCommandsSettingSchema, QueueSchema } from "./zod-schema.core.js";
export const SessionSchema = z
.object({
@@ -34,11 +30,7 @@ export const SessionSchema = z
.object({
channel: z.string().optional(),
chatType: z
.union([
z.literal("direct"),
z.literal("group"),
z.literal("room"),
])
.union([z.literal("direct"), z.literal("group"), z.literal("room")])
.optional(),
keyPrefix: z.string().optional(),
})
@@ -63,9 +55,7 @@ export const MessagesSchema = z
groupChat: GroupChatSchema,
queue: QueueSchema,
ackReaction: z.string().optional(),
ackReactionScope: z
.enum(["group-mentions", "group-all", "direct", "all"])
.optional(),
ackReactionScope: z.enum(["group-mentions", "group-all", "direct", "all"]).optional(),
removeAckAfterReply: z.boolean().optional(),
})
.optional();

View File

@@ -1,19 +1,10 @@
import { z } from "zod";
import { ToolsSchema } from "./zod-schema.agent-runtime.js";
import {
AgentsSchema,
AudioSchema,
BindingsSchema,
BroadcastSchema,
} from "./zod-schema.agents.js";
import { AgentsSchema, AudioSchema, BindingsSchema, BroadcastSchema } from "./zod-schema.agents.js";
import { HexColorSchema, ModelsConfigSchema } from "./zod-schema.core.js";
import { HookMappingSchema, HooksGmailSchema } from "./zod-schema.hooks.js";
import { ChannelsSchema } from "./zod-schema.providers.js";
import {
CommandsSchema,
MessagesSchema,
SessionSchema,
} from "./zod-schema.session.js";
import { CommandsSchema, MessagesSchema, SessionSchema } from "./zod-schema.session.js";
export const ClawdbotSchema = z
.object({
@@ -35,9 +26,7 @@ export const ClawdbotSchema = z
lastRunVersion: z.string().optional(),
lastRunCommit: z.string().optional(),
lastRunCommand: z.string().optional(),
lastRunMode: z
.union([z.literal("local"), z.literal("remote")])
.optional(),
lastRunMode: z.union([z.literal("local"), z.literal("remote")]).optional(),
})
.optional(),
logging: z
@@ -68,9 +57,7 @@ export const ClawdbotSchema = z
consoleStyle: z
.union([z.literal("pretty"), z.literal("compact"), z.literal("json")])
.optional(),
redactSensitive: z
.union([z.literal("off"), z.literal("tools")])
.optional(),
redactSensitive: z.union([z.literal("off"), z.literal("tools")]).optional(),
redactPatterns: z.array(z.string()).optional(),
})
.optional(),
@@ -89,10 +76,7 @@ export const ClawdbotSchema = z
.record(
z
.string()
.regex(
/^[a-z0-9-]+$/,
"Profile names must be alphanumeric with hyphens only",
),
.regex(/^[a-z0-9-]+$/, "Profile names must be alphanumeric with hyphens only"),
z
.object({
cdpPort: z.number().int().min(1).max(65535).optional(),
@@ -118,11 +102,7 @@ export const ClawdbotSchema = z
z.string(),
z.object({
provider: z.string(),
mode: z.union([
z.literal("api_key"),
z.literal("oauth"),
z.literal("token"),
]),
mode: z.union([z.literal("api_key"), z.literal("oauth"), z.literal("token")]),
email: z.string().optional(),
}),
)
@@ -131,9 +111,7 @@ export const ClawdbotSchema = z
cooldowns: z
.object({
billingBackoffHours: z.number().positive().optional(),
billingBackoffHoursByProvider: z
.record(z.string(), z.number().positive())
.optional(),
billingBackoffHoursByProvider: z.record(z.string(), z.number().positive()).optional(),
billingMaxHours: z.number().positive().optional(),
failureWindowHours: z.number().positive().optional(),
})
@@ -189,12 +167,7 @@ export const ClawdbotSchema = z
enabled: z.boolean().optional(),
port: z.number().int().positive().optional(),
bind: z
.union([
z.literal("auto"),
z.literal("lan"),
z.literal("tailnet"),
z.literal("loopback"),
])
.union([z.literal("auto"), z.literal("lan"), z.literal("tailnet"), z.literal("loopback")])
.optional(),
})
.optional(),
@@ -230,12 +203,7 @@ export const ClawdbotSchema = z
port: z.number().int().positive().optional(),
mode: z.union([z.literal("local"), z.literal("remote")]).optional(),
bind: z
.union([
z.literal("auto"),
z.literal("lan"),
z.literal("tailnet"),
z.literal("loopback"),
])
.union([z.literal("auto"), z.literal("lan"), z.literal("tailnet"), z.literal("loopback")])
.optional(),
controlUi: z
.object({
@@ -245,9 +213,7 @@ export const ClawdbotSchema = z
.optional(),
auth: z
.object({
mode: z
.union([z.literal("token"), z.literal("password")])
.optional(),
mode: z.union([z.literal("token"), z.literal("password")]).optional(),
token: z.string().optional(),
password: z.string().optional(),
allowTailscale: z.boolean().optional(),
@@ -255,13 +221,7 @@ export const ClawdbotSchema = z
.optional(),
tailscale: z
.object({
mode: z
.union([
z.literal("off"),
z.literal("serve"),
z.literal("funnel"),
])
.optional(),
mode: z.union([z.literal("off"), z.literal("serve"), z.literal("funnel")]).optional(),
resetOnExit: z.boolean().optional(),
})
.optional(),
@@ -314,12 +274,7 @@ export const ClawdbotSchema = z
.object({
preferBrew: z.boolean().optional(),
nodeManager: z
.union([
z.literal("npm"),
z.literal("pnpm"),
z.literal("yarn"),
z.literal("bun"),
])
.union([z.literal("npm"), z.literal("pnpm"), z.literal("yarn"), z.literal("bun")])
.optional(),
})
.optional(),