From 5ee4456c6e351b7f5e52a803970d6cf75e0aa33f Mon Sep 17 00:00:00 2001 From: Marc Date: Fri, 16 Jan 2026 15:51:50 +0100 Subject: [PATCH] fix: merge subagent auth profiles --- CHANGELOG.md | 4 +- docs/tools/subagents.md | 9 +++ ...th-profiles.ensureauthprofilestore.test.ts | 77 +++++++++++++++++++ src/agents/auth-profiles/store.ts | 58 +++++++++++++- 4 files changed, 145 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f86c534db..20365aa36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,8 +51,8 @@ ### Fixes - WhatsApp: default response prefix only for self-chat, using identity name when set. -- Auth: merge main auth profiles into per-agent stores for sub-agents and document inheritance. (#1013) — thanks @marcmarg. -- Agents: avoid JSON Schema `format` collisions in tool params by renaming snapshot format fields. (#1013) — thanks @marcmarg. + - Auth: merge main auth profiles into per-agent stores for sub-agents and document inheritance. (#1013) — thanks @marcmarg. + - Agents: avoid JSON Schema `format` collisions in tool params by renaming snapshot format fields. (#1013) — thanks @marcmarg. - Fix: make `clawdbot update` auto-update global installs when installed via a package manager. - Fix: list model picker entries as provider/model pairs for explicit selection. (#970) — thanks @mcinteerj. - Fix: align OpenAI image-gen defaults with DALL-E 3 standard quality and document output formats. (#880) — thanks @mkbehr. diff --git a/docs/tools/subagents.md b/docs/tools/subagents.md index d80bbed7a..2ac3b57d4 100644 --- a/docs/tools/subagents.md +++ b/docs/tools/subagents.md @@ -43,6 +43,15 @@ Auto-archive: - Auto-archive is best-effort; pending timers are lost if the gateway restarts. - `runTimeoutSeconds` does **not** auto-archive; it only stops the run. The session remains until auto-archive. +## Authentication + +Sub-agent auth is resolved by **agent id**, not by session type: +- The sub-agent session key is `agent::subagent:`. +- The auth store is loaded from that agent’s `agentDir`. +- The main agent’s auth profiles are merged in as a **fallback**; agent profiles override main profiles on conflicts. + +Note: the merge is additive, so main profiles are always available as fallbacks. Fully isolated auth per agent is not supported yet. + ## Announce Sub-agents report back via an announce step: diff --git a/src/agents/auth-profiles.ensureauthprofilestore.test.ts b/src/agents/auth-profiles.ensureauthprofilestore.test.ts index 5e8420c78..db7d6f031 100644 --- a/src/agents/auth-profiles.ensureauthprofilestore.test.ts +++ b/src/agents/auth-profiles.ensureauthprofilestore.test.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; import { ensureAuthProfileStore } from "./auth-profiles.js"; +import { AUTH_STORE_VERSION } from "./auth-profiles/constants.js"; describe("ensureAuthProfileStore", () => { it("migrates legacy auth.json and deletes it (PR #368)", () => { @@ -45,4 +46,80 @@ describe("ensureAuthProfileStore", () => { fs.rmSync(agentDir, { recursive: true, force: true }); } }); + + it("merges main auth profiles into agent store and keeps agent overrides", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-auth-merge-")); + const previousAgentDir = process.env.CLAWDBOT_AGENT_DIR; + const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR; + try { + const mainDir = path.join(root, "main-agent"); + const agentDir = path.join(root, "agent-x"); + fs.mkdirSync(mainDir, { recursive: true }); + fs.mkdirSync(agentDir, { recursive: true }); + + process.env.CLAWDBOT_AGENT_DIR = mainDir; + process.env.PI_CODING_AGENT_DIR = mainDir; + + const mainStore = { + version: AUTH_STORE_VERSION, + profiles: { + "openai:default": { + type: "api_key", + provider: "openai", + key: "main-key", + }, + "anthropic:default": { + type: "api_key", + provider: "anthropic", + key: "main-anthropic-key", + }, + }, + }; + fs.writeFileSync( + path.join(mainDir, "auth-profiles.json"), + `${JSON.stringify(mainStore, null, 2)}\n`, + "utf8", + ); + + const agentStore = { + version: AUTH_STORE_VERSION, + profiles: { + "openai:default": { + type: "api_key", + provider: "openai", + key: "agent-key", + }, + }, + }; + fs.writeFileSync( + path.join(agentDir, "auth-profiles.json"), + `${JSON.stringify(agentStore, null, 2)}\n`, + "utf8", + ); + + const store = ensureAuthProfileStore(agentDir); + expect(store.profiles["anthropic:default"]).toMatchObject({ + type: "api_key", + provider: "anthropic", + key: "main-anthropic-key", + }); + expect(store.profiles["openai:default"]).toMatchObject({ + type: "api_key", + provider: "openai", + key: "agent-key", + }); + } finally { + if (previousAgentDir === undefined) { + delete process.env.CLAWDBOT_AGENT_DIR; + } else { + process.env.CLAWDBOT_AGENT_DIR = previousAgentDir; + } + if (previousPiAgentDir === undefined) { + delete process.env.PI_CODING_AGENT_DIR; + } else { + process.env.PI_CODING_AGENT_DIR = previousPiAgentDir; + } + fs.rmSync(root, { recursive: true, force: true }); + } + }); }); diff --git a/src/agents/auth-profiles/store.ts b/src/agents/auth-profiles/store.ts index 062fbad6c..75fded809 100644 --- a/src/agents/auth-profiles/store.ts +++ b/src/agents/auth-profiles/store.ts @@ -111,6 +111,34 @@ function coerceAuthStore(raw: unknown): AuthProfileStore | null { }; } +function mergeRecord( + base?: Record, + override?: Record, +): Record | undefined { + if (!base && !override) return undefined; + if (!base) return { ...override }; + if (!override) return { ...base }; + return { ...base, ...override }; +} + +function mergeAuthProfileStores(base: AuthProfileStore, override: AuthProfileStore): AuthProfileStore { + if ( + Object.keys(override.profiles).length === 0 && + !override.order && + !override.lastGood && + !override.usageStats + ) { + return base; + } + return { + version: Math.max(base.version, override.version ?? base.version), + profiles: { ...base.profiles, ...override.profiles }, + order: mergeRecord(base.order, override.order), + lastGood: mergeRecord(base.lastGood, override.lastGood), + usageStats: mergeRecord(base.usageStats, override.usageStats), + }; +} + function mergeOAuthFileIntoStore(store: AuthProfileStore): boolean { const oauthPath = resolveOAuthPath(); const oauthRaw = loadJsonFile(oauthPath); @@ -191,7 +219,7 @@ export function loadAuthProfileStore(): AuthProfileStore { return store; } -export function ensureAuthProfileStore( +function loadAuthProfileStoreForAgent( agentDir?: string, options?: { allowKeychainPrompt?: boolean }, ): AuthProfileStore { @@ -207,6 +235,19 @@ export function ensureAuthProfileStore( return asStore; } + // Fallback: inherit auth-profiles from main agent if subagent has none + if (agentDir) { + const mainAuthPath = resolveAuthStorePath(); // without agentDir = main + const mainRaw = loadJsonFile(mainAuthPath); + const mainStore = coerceAuthStore(mainRaw); + if (mainStore && Object.keys(mainStore.profiles).length > 0) { + // Clone main store to subagent directory for auth inheritance + saveJsonFile(authPath, mainStore); + log.info("inherited auth-profiles from main agent", { agentDir }); + return mainStore; + } + } + const legacyRaw = loadJsonFile(resolveLegacyAuthStorePath(agentDir)); const legacy = coerceLegacyStore(legacyRaw); const store: AuthProfileStore = { @@ -274,6 +315,21 @@ export function ensureAuthProfileStore( return store; } +export function ensureAuthProfileStore( + agentDir?: string, + options?: { allowKeychainPrompt?: boolean }, +): AuthProfileStore { + const store = loadAuthProfileStoreForAgent(agentDir, options); + const authPath = resolveAuthStorePath(agentDir); + const mainAuthPath = resolveAuthStorePath(); + if (!agentDir || authPath === mainAuthPath) { + return store; + } + + const mainStore = loadAuthProfileStoreForAgent(undefined, options); + return mergeAuthProfileStores(mainStore, store); +} + export function saveAuthProfileStore(store: AuthProfileStore, agentDir?: string): void { const authPath = resolveAuthStorePath(agentDir); const payload = {