From f24a4626e36a21b16f6e669c9f3c8905854e292f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 8 Jan 2026 11:53:01 +0100 Subject: [PATCH] fix(config): reject shared agentDir --- src/config/agent-dirs.ts | 109 ++++++++++++++++++++++++++++++++++++++ src/config/config.test.ts | 52 ++++++++++++++++++ src/config/io.ts | 16 ++++++ src/config/validation.ts | 16 ++++++ 4 files changed, 193 insertions(+) create mode 100644 src/config/agent-dirs.ts diff --git a/src/config/agent-dirs.ts b/src/config/agent-dirs.ts new file mode 100644 index 000000000..871cbc89f --- /dev/null +++ b/src/config/agent-dirs.ts @@ -0,0 +1,109 @@ +import os from "node:os"; +import path from "node:path"; +import { DEFAULT_AGENT_ID, normalizeAgentId } from "../routing/session-key.js"; +import { resolveUserPath } from "../utils.js"; +import { resolveStateDir } from "./paths.js"; +import type { ClawdbotConfig } from "./types.js"; + +export type DuplicateAgentDir = { + agentDir: string; + agentIds: string[]; +}; + +export class DuplicateAgentDirError extends Error { + readonly duplicates: DuplicateAgentDir[]; + + constructor(duplicates: DuplicateAgentDir[]) { + super(formatDuplicateAgentDirError(duplicates)); + this.name = "DuplicateAgentDirError"; + this.duplicates = duplicates; + } +} + +function canonicalizeAgentDir(agentDir: string): string { + const resolved = path.resolve(agentDir); + if (process.platform === "darwin" || process.platform === "win32") { + return resolved.toLowerCase(); + } + return resolved; +} + +function collectReferencedAgentIds(cfg: ClawdbotConfig): string[] { + const ids = new Set(); + + const defaultAgentId = + cfg.routing?.defaultAgentId?.trim() || DEFAULT_AGENT_ID; + ids.add(normalizeAgentId(defaultAgentId)); + + const agents = cfg.routing?.agents; + if (agents && typeof agents === "object") { + for (const id of Object.keys(agents)) { + ids.add(normalizeAgentId(id)); + } + } + + const bindings = cfg.routing?.bindings; + if (Array.isArray(bindings)) { + for (const binding of bindings) { + const id = binding?.agentId; + if (typeof id === "string" && id.trim()) { + ids.add(normalizeAgentId(id)); + } + } + } + + return [...ids]; +} + +function resolveEffectiveAgentDir( + cfg: ClawdbotConfig, + agentId: string, + deps?: { env?: NodeJS.ProcessEnv; homedir?: () => string }, +): string { + const id = normalizeAgentId(agentId); + const configured = cfg.routing?.agents?.[id]?.agentDir?.trim(); + if (configured) return resolveUserPath(configured); + const root = resolveStateDir( + deps?.env ?? process.env, + deps?.homedir ?? os.homedir, + ); + return path.join(root, "agents", id, "agent"); +} + +export function findDuplicateAgentDirs( + cfg: ClawdbotConfig, + deps?: { env?: NodeJS.ProcessEnv; homedir?: () => string }, +): DuplicateAgentDir[] { + const byDir = new Map(); + + for (const agentId of collectReferencedAgentIds(cfg)) { + const agentDir = resolveEffectiveAgentDir(cfg, agentId, deps); + const key = canonicalizeAgentDir(agentDir); + const entry = byDir.get(key); + if (entry) { + entry.agentIds.push(agentId); + } else { + byDir.set(key, { agentDir, agentIds: [agentId] }); + } + } + + return [...byDir.values()].filter((v) => v.agentIds.length > 1); +} + +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(", ")}`, + ), + "", + "Fix: remove the shared routing.agents.*.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.", + ]; + return lines.join("\n"); +} diff --git a/src/config/config.test.ts b/src/config/config.test.ts index 303d9fe16..3125f9336 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -981,3 +981,55 @@ describe("legacy config detection", () => { }); }); }); + +describe("multi-agent agentDir validation", () => { + it("rejects shared routing.agents.*.agentDir", async () => { + vi.resetModules(); + const { validateConfigObject } = await import("./config.js"); + const shared = path.join(os.tmpdir(), "clawdbot-shared-agentdir"); + const res = validateConfigObject({ + routing: { + agents: { + a: { agentDir: shared }, + b: { agentDir: shared }, + }, + }, + }); + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.issues.some((i) => i.path === "routing.agents")).toBe(true); + expect(res.issues[0]?.message).toContain("Duplicate agentDir"); + } + }); + + it("throws on shared agentDir during loadConfig()", async () => { + await withTempHome(async (home) => { + const configDir = path.join(home, ".clawdbot"); + await fs.mkdir(configDir, { recursive: true }); + await fs.writeFile( + path.join(configDir, "clawdbot.json"), + JSON.stringify( + { + routing: { + agents: { + a: { agentDir: "~/.clawdbot/agents/shared/agent" }, + b: { agentDir: "~/.clawdbot/agents/shared/agent" }, + }, + bindings: [{ agentId: "a", match: { provider: "telegram" } }], + }, + }, + null, + 2, + ), + "utf-8", + ); + + vi.resetModules(); + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + const { loadConfig } = await import("./config.js"); + expect(() => loadConfig()).toThrow(/duplicate agentDir/i); + expect(spy.mock.calls.flat().join(" ")).toMatch(/Duplicate agentDir/i); + spy.mockRestore(); + }); + }); +}); diff --git a/src/config/io.ts b/src/config/io.ts index a1e97b0a1..2e2c7e275 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -8,6 +8,10 @@ import { resolveShellEnvFallbackTimeoutMs, shouldEnableShellEnvFallback, } from "../infra/shell-env.js"; +import { + DuplicateAgentDirError, + findDuplicateAgentDirs, +} from "./agent-dirs.js"; import { applyIdentityDefaults, applyLoggingDefaults, @@ -140,6 +144,14 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { ), ); + const duplicates = findDuplicateAgentDirs(cfg, { + env: deps.env, + homedir: deps.homedir, + }); + if (duplicates.length > 0) { + throw new DuplicateAgentDirError(duplicates); + } + const enabled = shouldEnableShellEnvFallback(deps.env) || cfg.env?.shellEnv?.enabled === true; @@ -157,6 +169,10 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { return cfg; } catch (err) { + if (err instanceof DuplicateAgentDirError) { + deps.logger.error(err.message); + throw err; + } deps.logger.error(`Failed to read config at ${configPath}`, err); return {}; } diff --git a/src/config/validation.ts b/src/config/validation.ts index 9c6378753..c8b49dca9 100644 --- a/src/config/validation.ts +++ b/src/config/validation.ts @@ -1,3 +1,7 @@ +import { + findDuplicateAgentDirs, + formatDuplicateAgentDirError, +} from "./agent-dirs.js"; import { applyIdentityDefaults, applyModelDefaults, @@ -32,6 +36,18 @@ export function validateConfigObject( })), }; } + const duplicates = findDuplicateAgentDirs(validated.data as ClawdbotConfig); + if (duplicates.length > 0) { + return { + ok: false, + issues: [ + { + path: "routing.agents", + message: formatDuplicateAgentDirError(duplicates), + }, + ], + }; + } return { ok: true, config: applyModelDefaults(