diff --git a/src/agents/auth-profiles/oauth.fallback-to-main-agent.test.ts b/src/agents/auth-profiles/oauth.fallback-to-main-agent.test.ts new file mode 100644 index 000000000..f00046338 --- /dev/null +++ b/src/agents/auth-profiles/oauth.fallback-to-main-agent.test.ts @@ -0,0 +1,93 @@ +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { resolveApiKeyForProfile } from "./oauth.js"; +import type { AuthProfileStore } from "./types.js"; + +describe("resolveApiKeyForProfile", () => { + let tmpDir: string; + let mainAgentDir: string; + let secondaryAgentDir: string; + + beforeEach(async () => { + tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "oauth-test-")); + mainAgentDir = path.join(tmpDir, "agents", "main", "agent"); + secondaryAgentDir = path.join(tmpDir, "agents", "kids", "agent"); + await fs.promises.mkdir(mainAgentDir, { recursive: true }); + await fs.promises.mkdir(secondaryAgentDir, { recursive: true }); + + // Set env to use our temp dir + process.env.CLAWDBOT_STATE_DIR = tmpDir; + }); + + afterEach(async () => { + delete process.env.CLAWDBOT_STATE_DIR; + await fs.promises.rm(tmpDir, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + it("falls back to main agent credentials when secondary agent token is expired and refresh fails", async () => { + const profileId = "anthropic:claude-cli"; + const now = Date.now(); + const expiredTime = now - 60 * 60 * 1000; // 1 hour ago + const freshTime = now + 60 * 60 * 1000; // 1 hour from now + + // Write expired credentials for secondary agent + const secondaryStore: AuthProfileStore = { + version: 1, + profiles: { + [profileId]: { + type: "oauth", + provider: "anthropic", + access: "expired-access-token", + refresh: "expired-refresh-token", + expires: expiredTime, + }, + }, + }; + await fs.promises.writeFile( + path.join(secondaryAgentDir, "auth-profiles.json"), + JSON.stringify(secondaryStore), + ); + + // Write fresh credentials for main agent + const mainStore: AuthProfileStore = { + version: 1, + profiles: { + [profileId]: { + type: "oauth", + provider: "anthropic", + access: "fresh-access-token", + refresh: "fresh-refresh-token", + expires: freshTime, + }, + }, + }; + await fs.promises.writeFile( + path.join(mainAgentDir, "auth-profiles.json"), + JSON.stringify(mainStore), + ); + + // The secondary agent should fall back to main agent's credentials + // when its own token refresh fails + const result = await resolveApiKeyForProfile({ + store: secondaryStore, + profileId, + agentDir: secondaryAgentDir, + }); + + expect(result).not.toBeNull(); + expect(result?.apiKey).toBe("fresh-access-token"); + expect(result?.provider).toBe("anthropic"); + + // Verify the credentials were copied to the secondary agent + const updatedSecondaryStore = JSON.parse( + await fs.promises.readFile(path.join(secondaryAgentDir, "auth-profiles.json"), "utf8"), + ) as AuthProfileStore; + expect(updatedSecondaryStore.profiles[profileId]).toMatchObject({ + access: "fresh-access-token", + expires: freshTime, + }); + }); +}); diff --git a/src/agents/auth-profiles/oauth.ts b/src/agents/auth-profiles/oauth.ts index 4138cda94..d7b3360de 100644 --- a/src/agents/auth-profiles/oauth.ts +++ b/src/agents/auth-profiles/oauth.ts @@ -4,7 +4,7 @@ import lockfile from "proper-lockfile"; import type { ClawdbotConfig } from "../../config/config.js"; import { refreshChutesTokens } from "../chutes-oauth.js"; import { refreshQwenPortalCredentials } from "../../providers/qwen-portal-oauth.js"; -import { AUTH_STORE_LOCK_OPTIONS } from "./constants.js"; +import { AUTH_STORE_LOCK_OPTIONS, log } from "./constants.js"; import { formatAuthDoctorHint } from "./doctor.js"; import { ensureAuthStoreFile, resolveAuthStorePath } from "./paths.js"; import { suggestOAuthProfileIdForLegacyDefault } from "./repair.js"; @@ -196,6 +196,32 @@ export async function resolveApiKeyForProfile(params: { // keep original error } } + + // Fallback: if this is a secondary agent, try using the main agent's credentials + if (params.agentDir) { + try { + const mainStore = ensureAuthProfileStore(undefined); // main agent (no agentDir) + const mainCred = mainStore.profiles[profileId]; + if (mainCred?.type === "oauth" && Date.now() < mainCred.expires) { + // Main agent has fresh credentials - copy them to this agent and use them + refreshedStore.profiles[profileId] = { ...mainCred }; + saveAuthProfileStore(refreshedStore, params.agentDir); + log.info("inherited fresh OAuth credentials from main agent", { + profileId, + agentDir: params.agentDir, + expires: new Date(mainCred.expires).toISOString(), + }); + return { + apiKey: buildOAuthApiKey(mainCred.provider, mainCred), + provider: mainCred.provider, + email: mainCred.email, + }; + } + } catch { + // keep original error if main agent fallback also fails + } + } + const message = error instanceof Error ? error.message : String(error); const hint = formatAuthDoctorHint({ cfg,