From a39951d463b9c5377cbe7e08d6953899dca708d8 Mon Sep 17 00:00:00 2001 From: Radek Paclt Date: Sat, 10 Jan 2026 11:42:12 +0000 Subject: [PATCH] fix(auth): enable OAuth refresh for Claude CLI credentials When Claude CLI credentials (anthropic:claude-cli) expire, automatically refresh using the stored refresh token instead of failing with "No credentials found" error. Changes: - Read refreshToken from Claude CLI and store as OAuth credential type - Implement bidirectional sync: after refresh, write new tokens back to Claude Code storage (file on Linux/Windows, Keychain on macOS) - Prefer OAuth over Token credentials (enables auto-refresh capability) - Maintain backward compatibility for credentials without refreshToken This enables long-running agents to operate autonomously without manual re-authentication when OAuth tokens expire. Co-Authored-By: Claude --- CHANGELOG.md | 1 + docs/concepts/oauth.md | 19 ++- src/agents/auth-profiles.test.ts | 200 +++++++++++++++++++++++--- src/agents/auth-profiles.ts | 231 ++++++++++++++++++++++++++++--- 4 files changed, 417 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f4e8a0ccc..1d71c43bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,7 @@ - Dependencies: Pi 0.40.0 bump (#543) — thanks @mcinteerj. - Build: Docker build cache layer (#605) — thanks @zknicker. +- Auth: enable OAuth token refresh for Claude CLI credentials (`anthropic:claude-cli`) with bidirectional sync back to Claude Code storage (file on Linux/Windows, Keychain on macOS). This allows long-running agents to operate autonomously without manual re-authentication. ## 2026.1.8 diff --git a/docs/concepts/oauth.md b/docs/concepts/oauth.md index 15007bdcb..916d31659 100644 --- a/docs/concepts/oauth.md +++ b/docs/concepts/oauth.md @@ -102,7 +102,24 @@ At runtime: - if `expires` is in the future → use the stored access token - if expired → refresh (under a file lock) and overwrite the stored credentials -The refresh flow is automatic; you generally don’t need to manage tokens manually. +The refresh flow is automatic; you generally don't need to manage tokens manually. + +### Bidirectional sync with Claude Code + +When Clawdbot refreshes an Anthropic OAuth token (profile `anthropic:claude-cli`), it **writes the new credentials back** to Claude Code's storage: + +- **Linux/Windows**: updates `~/.claude/.credentials.json` +- **macOS**: updates Keychain item "Claude Code-credentials" + +This ensures both tools stay in sync and neither gets "logged out" after the other refreshes. + +**Why this matters for long-running agents:** + +Anthropic OAuth tokens expire after a few hours. Without bidirectional sync: +1. Clawdbot refreshes the token → gets new access token +2. Claude Code still has the old token → gets logged out + +With bidirectional sync, both tools always have the latest valid token, enabling autonomous operation for days or weeks without manual intervention. ## Multiple accounts (profiles) + routing diff --git a/src/agents/auth-profiles.test.ts b/src/agents/auth-profiles.test.ts index d4bdae3ff..b2ecc2b7b 100644 --- a/src/agents/auth-profiles.test.ts +++ b/src/agents/auth-profiles.test.ts @@ -574,7 +574,7 @@ describe("markAuthProfileFailure", () => { }); describe("external CLI credential sync", () => { - it("syncs Claude CLI credentials into anthropic:claude-cli", async () => { + it("syncs Claude CLI OAuth credentials into anthropic:claude-cli", async () => { const agentDir = fs.mkdtempSync( path.join(os.tmpdir(), "clawdbot-cli-sync-"), ); @@ -582,7 +582,7 @@ describe("external CLI credential sync", () => { // Create a temp home with Claude CLI credentials await withTempHome( async (tempHome) => { - // Create Claude CLI credentials + // Create Claude CLI credentials with refreshToken (OAuth) const claudeDir = path.join(tempHome, ".claude"); fs.mkdirSync(claudeDir, { recursive: true }); const claudeCreds = { @@ -613,7 +613,7 @@ describe("external CLI credential sync", () => { }), ); - // Load the store - should sync from CLI + // Load the store - should sync from CLI as OAuth credential const store = ensureAuthProfileStore(agentDir); expect(store.profiles["anthropic:default"]).toBeDefined(); @@ -621,13 +621,120 @@ describe("external CLI credential sync", () => { (store.profiles["anthropic:default"] as { key: string }).key, ).toBe("sk-default"); expect(store.profiles[CLAUDE_CLI_PROFILE_ID]).toBeDefined(); - expect( - (store.profiles[CLAUDE_CLI_PROFILE_ID] as { token: string }).token, - ).toBe("fresh-access-token"); - expect( - (store.profiles[CLAUDE_CLI_PROFILE_ID] as { expires: number }) - .expires, - ).toBeGreaterThan(Date.now()); + // Should be stored as OAuth credential (type: "oauth") for auto-refresh + const cliProfile = store.profiles[CLAUDE_CLI_PROFILE_ID]; + expect(cliProfile.type).toBe("oauth"); + expect((cliProfile as { access: string }).access).toBe( + "fresh-access-token", + ); + expect((cliProfile as { refresh: string }).refresh).toBe( + "fresh-refresh-token", + ); + expect((cliProfile as { expires: number }).expires).toBeGreaterThan( + Date.now(), + ); + }, + { prefix: "clawdbot-home-" }, + ); + } finally { + fs.rmSync(agentDir, { recursive: true, force: true }); + } + }); + + it("syncs Claude CLI credentials without refreshToken as token type", async () => { + const agentDir = fs.mkdtempSync( + path.join(os.tmpdir(), "clawdbot-cli-token-sync-"), + ); + try { + await withTempHome( + async (tempHome) => { + // Create Claude CLI credentials WITHOUT refreshToken (fallback to token type) + const claudeDir = path.join(tempHome, ".claude"); + fs.mkdirSync(claudeDir, { recursive: true }); + const claudeCreds = { + claudeAiOauth: { + accessToken: "access-only-token", + // No refreshToken - backward compatibility scenario + expiresAt: Date.now() + 60 * 60 * 1000, + }, + }; + fs.writeFileSync( + path.join(claudeDir, ".credentials.json"), + JSON.stringify(claudeCreds), + ); + + const authPath = path.join(agentDir, "auth-profiles.json"); + fs.writeFileSync( + authPath, + JSON.stringify({ version: 1, profiles: {} }), + ); + + const store = ensureAuthProfileStore(agentDir); + + expect(store.profiles[CLAUDE_CLI_PROFILE_ID]).toBeDefined(); + // Should be stored as token type (no refresh capability) + const cliProfile = store.profiles[CLAUDE_CLI_PROFILE_ID]; + expect(cliProfile.type).toBe("token"); + expect((cliProfile as { token: string }).token).toBe( + "access-only-token", + ); + }, + { prefix: "clawdbot-home-" }, + ); + } finally { + fs.rmSync(agentDir, { recursive: true, force: true }); + } + }); + + it("upgrades token to oauth when Claude CLI gets refreshToken", async () => { + const agentDir = fs.mkdtempSync( + path.join(os.tmpdir(), "clawdbot-cli-upgrade-"), + ); + try { + await withTempHome( + async (tempHome) => { + // Create Claude CLI credentials with refreshToken + const claudeDir = path.join(tempHome, ".claude"); + fs.mkdirSync(claudeDir, { recursive: true }); + fs.writeFileSync( + path.join(claudeDir, ".credentials.json"), + JSON.stringify({ + claudeAiOauth: { + accessToken: "new-oauth-access", + refreshToken: "new-refresh-token", + expiresAt: Date.now() + 60 * 60 * 1000, + }, + }), + ); + + // Create auth-profiles.json with existing token type credential + const authPath = path.join(agentDir, "auth-profiles.json"); + fs.writeFileSync( + authPath, + JSON.stringify({ + version: 1, + profiles: { + [CLAUDE_CLI_PROFILE_ID]: { + type: "token", + provider: "anthropic", + token: "old-token", + expires: Date.now() + 30 * 60 * 1000, + }, + }, + }), + ); + + const store = ensureAuthProfileStore(agentDir); + + // Should upgrade from token to oauth + const cliProfile = store.profiles[CLAUDE_CLI_PROFILE_ID]; + expect(cliProfile.type).toBe("oauth"); + expect((cliProfile as { access: string }).access).toBe( + "new-oauth-access", + ); + expect((cliProfile as { refresh: string }).refresh).toBe( + "new-refresh-token", + ); }, { prefix: "clawdbot-home-" }, ); @@ -732,20 +839,21 @@ describe("external CLI credential sync", () => { } }); - it("does not overwrite fresher store token with older Claude CLI credentials", async () => { + it("prefers oauth over token even if token has later expiry (oauth enables auto-refresh)", async () => { const agentDir = fs.mkdtempSync( - path.join(os.tmpdir(), "clawdbot-cli-no-downgrade-"), + path.join(os.tmpdir(), "clawdbot-cli-oauth-preferred-"), ); try { await withTempHome( async (tempHome) => { const claudeDir = path.join(tempHome, ".claude"); fs.mkdirSync(claudeDir, { recursive: true }); + // CLI has OAuth credentials (with refresh token) expiring in 30 min fs.writeFileSync( path.join(claudeDir, ".credentials.json"), JSON.stringify({ claudeAiOauth: { - accessToken: "cli-access", + accessToken: "cli-oauth-access", refreshToken: "cli-refresh", expiresAt: Date.now() + 30 * 60 * 1000, }, @@ -753,6 +861,7 @@ describe("external CLI credential sync", () => { ); const authPath = path.join(agentDir, "auth-profiles.json"); + // Store has token credentials expiring in 60 min (later than CLI) fs.writeFileSync( authPath, JSON.stringify({ @@ -761,7 +870,7 @@ describe("external CLI credential sync", () => { [CLAUDE_CLI_PROFILE_ID]: { type: "token", provider: "anthropic", - token: "store-access", + token: "store-token-access", expires: Date.now() + 60 * 60 * 1000, }, }, @@ -769,9 +878,66 @@ describe("external CLI credential sync", () => { ); const store = ensureAuthProfileStore(agentDir); - expect( - (store.profiles[CLAUDE_CLI_PROFILE_ID] as { token: string }).token, - ).toBe("store-access"); + // OAuth should be preferred over token because it can auto-refresh + const cliProfile = store.profiles[CLAUDE_CLI_PROFILE_ID]; + expect(cliProfile.type).toBe("oauth"); + expect((cliProfile as { access: string }).access).toBe( + "cli-oauth-access", + ); + }, + { prefix: "clawdbot-home-" }, + ); + } finally { + fs.rmSync(agentDir, { recursive: true, force: true }); + } + }); + + it("does not overwrite fresher store oauth with older CLI oauth", async () => { + const agentDir = fs.mkdtempSync( + path.join(os.tmpdir(), "clawdbot-cli-oauth-no-downgrade-"), + ); + try { + await withTempHome( + async (tempHome) => { + const claudeDir = path.join(tempHome, ".claude"); + fs.mkdirSync(claudeDir, { recursive: true }); + // CLI has OAuth credentials expiring in 30 min + fs.writeFileSync( + path.join(claudeDir, ".credentials.json"), + JSON.stringify({ + claudeAiOauth: { + accessToken: "cli-oauth-access", + refreshToken: "cli-refresh", + expiresAt: Date.now() + 30 * 60 * 1000, + }, + }), + ); + + const authPath = path.join(agentDir, "auth-profiles.json"); + // Store has OAuth credentials expiring in 60 min (later than CLI) + fs.writeFileSync( + authPath, + JSON.stringify({ + version: 1, + profiles: { + [CLAUDE_CLI_PROFILE_ID]: { + type: "oauth", + provider: "anthropic", + access: "store-oauth-access", + refresh: "store-refresh", + expires: Date.now() + 60 * 60 * 1000, + }, + }, + }), + ); + + const store = ensureAuthProfileStore(agentDir); + // Fresher store oauth should be kept + const cliProfile = store.profiles[CLAUDE_CLI_PROFILE_ID]; + expect(cliProfile.type).toBe("oauth"); + expect((cliProfile as { access: string }).access).toBe( + "store-oauth-access", + ); }, { prefix: "clawdbot-home-" }, ); diff --git a/src/agents/auth-profiles.ts b/src/agents/auth-profiles.ts index c5507e96a..6c62f5782 100644 --- a/src/agents/auth-profiles.ts +++ b/src/agents/auth-profiles.ts @@ -136,6 +136,133 @@ function saveJsonFile(pathname: string, data: unknown) { fs.chmodSync(pathname, 0o600); } +/** + * Write refreshed OAuth credentials back to Claude CLI's credential storage. + * This ensures Claude Code continues to work after ClawdBot refreshes the token. + * + * On macOS: Updates keychain entry "Claude Code-credentials" (primary storage). + * On Linux/Windows: Updates ~/.claude/.credentials.json file. + * + * Only writes if Claude CLI credentials exist (Claude Code is installed). + */ +function writeClaudeCliCredentials(newCredentials: OAuthCredentials): boolean { + // On macOS, Claude Code uses keychain as primary storage + if (process.platform === "darwin") { + return writeClaudeCliKeychainCredentials(newCredentials); + } + + // On Linux/Windows, use file storage + return writeClaudeCliFileCredentials(newCredentials); +} + +/** + * Write credentials to macOS keychain. + */ +function writeClaudeCliKeychainCredentials( + newCredentials: OAuthCredentials, +): boolean { + try { + // First read existing keychain entry to preserve other fields + const existingResult = execSync( + 'security find-generic-password -s "Claude Code-credentials" -w 2>/dev/null', + { encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] }, + ); + + const existingData = JSON.parse(existingResult.trim()); + const existingOauth = existingData?.claudeAiOauth; + if (!existingOauth || typeof existingOauth !== "object") { + return false; + } + + // Update with new tokens while preserving other fields + existingData.claudeAiOauth = { + ...existingOauth, + accessToken: newCredentials.access, + refreshToken: newCredentials.refresh, + expiresAt: newCredentials.expires, + }; + + const newValue = JSON.stringify(existingData); + + // Delete old entry and add new one (keychain doesn't support update) + try { + execSync( + 'security delete-generic-password -s "Claude Code-credentials"', + { + encoding: "utf8", + timeout: 5000, + stdio: ["pipe", "pipe", "pipe"], + }, + ); + } catch { + // Entry might not exist, continue + } + + execSync( + `security add-generic-password -s "Claude Code-credentials" -a "Claude Code" -w '${newValue.replace(/'/g, "'\"'\"'")}'`, + { encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] }, + ); + + log.info("wrote refreshed credentials to claude cli keychain", { + expires: new Date(newCredentials.expires).toISOString(), + }); + return true; + } catch (error) { + log.warn("failed to write credentials to claude cli keychain", { + error: error instanceof Error ? error.message : String(error), + }); + // Fall back to file storage on macOS + return writeClaudeCliFileCredentials(newCredentials); + } +} + +/** + * Write credentials to file storage (~/.claude/.credentials.json). + */ +function writeClaudeCliFileCredentials( + newCredentials: OAuthCredentials, +): boolean { + const credPath = path.join( + resolveUserPath("~"), + CLAUDE_CLI_CREDENTIALS_RELATIVE_PATH, + ); + + // Only update if Claude CLI credentials file exists + if (!fs.existsSync(credPath)) { + return false; + } + + try { + const raw = loadJsonFile(credPath); + if (!raw || typeof raw !== "object") return false; + + const data = raw as Record; + const existingOauth = data.claudeAiOauth as + | Record + | undefined; + if (!existingOauth || typeof existingOauth !== "object") return false; + + // Update with new tokens while preserving other fields (scopes, subscriptionType, etc.) + data.claudeAiOauth = { + ...existingOauth, + accessToken: newCredentials.access, + refreshToken: newCredentials.refresh, + expiresAt: newCredentials.expires, + }; + + saveJsonFile(credPath, data); + log.info("wrote refreshed credentials to claude cli file", { + expires: new Date(newCredentials.expires).toISOString(), + }); + return true; + } catch (error) { + log.warn("failed to write credentials to claude cli file", { + error: error instanceof Error ? error.message : String(error), + }); + return false; + } +} + function ensureAuthStoreFile(pathname: string) { if (fs.existsSync(pathname)) return; const payload: AuthProfileStore = { @@ -235,6 +362,16 @@ async function refreshOAuthTokenWithLock(params: { type: "oauth", }; saveAuthProfileStore(store, params.agentDir); + + // Sync refreshed credentials back to Claude CLI if this is the claude-cli profile + // This ensures Claude Code continues to work after ClawdBot refreshes the token + if ( + params.profileId === CLAUDE_CLI_PROFILE_ID && + cred.provider === "anthropic" + ) { + writeClaudeCliCredentials(result.newCredentials); + } + return result; } finally { if (release) { @@ -345,14 +482,19 @@ function mergeOAuthFileIntoStore(store: AuthProfileStore): boolean { * * On macOS, Claude Code stores credentials in keychain "Claude Code-credentials". * On Linux/Windows, it uses ~/.claude/.credentials.json + * + * Returns OAuthCredential when refreshToken is available (enables auto-refresh), + * or TokenCredential as fallback for backward compatibility. */ function readClaudeCliCredentials(options?: { allowKeychainPrompt?: boolean; -}): TokenCredential | null { +}): OAuthCredential | TokenCredential | null { if (process.platform === "darwin" && options?.allowKeychainPrompt !== false) { const keychainCreds = readClaudeCliKeychainCredentials(); if (keychainCreds) { - log.info("read anthropic credentials from claude cli keychain"); + log.info("read anthropic credentials from claude cli keychain", { + type: keychainCreds.type, + }); return keychainCreds; } } @@ -369,11 +511,24 @@ function readClaudeCliCredentials(options?: { if (!claudeOauth || typeof claudeOauth !== "object") return null; const accessToken = claudeOauth.accessToken; + const refreshToken = claudeOauth.refreshToken; const expiresAt = claudeOauth.expiresAt; if (typeof accessToken !== "string" || !accessToken) return null; if (typeof expiresAt !== "number" || expiresAt <= 0) return null; + // Return OAuthCredential when refreshToken is available (enables auto-refresh) + if (typeof refreshToken === "string" && refreshToken) { + return { + type: "oauth", + provider: "anthropic", + access: accessToken, + refresh: refreshToken, + expires: expiresAt, + }; + } + + // Fallback to TokenCredential for backward compatibility (no auto-refresh) return { type: "token", provider: "anthropic", @@ -385,8 +540,14 @@ function readClaudeCliCredentials(options?: { /** * Read Claude Code credentials from macOS keychain. * Uses the `security` CLI to access keychain without native dependencies. + * + * Returns OAuthCredential when refreshToken is available (enables auto-refresh), + * or TokenCredential as fallback for backward compatibility. */ -function readClaudeCliKeychainCredentials(): TokenCredential | null { +function readClaudeCliKeychainCredentials(): + | OAuthCredential + | TokenCredential + | null { try { const result = execSync( 'security find-generic-password -s "Claude Code-credentials" -w', @@ -398,11 +559,24 @@ function readClaudeCliKeychainCredentials(): TokenCredential | null { if (!claudeOauth || typeof claudeOauth !== "object") return null; const accessToken = claudeOauth.accessToken; + const refreshToken = claudeOauth.refreshToken; const expiresAt = claudeOauth.expiresAt; if (typeof accessToken !== "string" || !accessToken) return null; if (typeof expiresAt !== "number" || expiresAt <= 0) return null; + // Return OAuthCredential when refreshToken is available (enables auto-refresh) + if (typeof refreshToken === "string" && refreshToken) { + return { + type: "oauth", + provider: "anthropic", + access: accessToken, + refresh: refreshToken, + expires: expiresAt, + }; + } + + // Fallback to TokenCredential for backward compatibility (no auto-refresh) return { type: "token", provider: "anthropic", @@ -501,28 +675,53 @@ function syncExternalCliCredentials( let mutated = false; const now = Date.now(); - // Sync from Claude CLI + // Sync from Claude CLI (supports both OAuth and Token credentials) const claudeCreds = readClaudeCliCredentials(options); if (claudeCreds) { const existing = store.profiles[CLAUDE_CLI_PROFILE_ID]; - const existingToken = existing?.type === "token" ? existing : undefined; + const claudeCredsExpires = claudeCreds.expires ?? 0; - // Update if: no existing profile, existing is not oauth, or CLI has newer/valid token - const shouldUpdate = - !existingToken || - existingToken.provider !== "anthropic" || - (existingToken.expires ?? 0) <= now || - ((claudeCreds.expires ?? 0) > now && - (claudeCreds.expires ?? 0) > (existingToken.expires ?? 0)); + // Determine if we should update based on credential comparison + let shouldUpdate = false; + let isEqual = false; - if ( - shouldUpdate && - !shallowEqualTokenCredentials(existingToken, claudeCreds) - ) { + if (claudeCreds.type === "oauth") { + const existingOAuth = existing?.type === "oauth" ? existing : undefined; + isEqual = shallowEqualOAuthCredentials(existingOAuth, claudeCreds); + // Update if: no existing profile, type changed to oauth, expired, or CLI has newer token + shouldUpdate = + !existingOAuth || + existingOAuth.provider !== "anthropic" || + existingOAuth.expires <= now || + (claudeCredsExpires > now && + claudeCredsExpires > existingOAuth.expires); + } else { + const existingToken = existing?.type === "token" ? existing : undefined; + isEqual = shallowEqualTokenCredentials(existingToken, claudeCreds); + // Update if: no existing profile, expired, or CLI has newer token + shouldUpdate = + !existingToken || + existingToken.provider !== "anthropic" || + (existingToken.expires ?? 0) <= now || + (claudeCredsExpires > now && + claudeCredsExpires > (existingToken.expires ?? 0)); + } + + // Also update if credential type changed (token -> oauth upgrade) + if (existing && existing.type !== claudeCreds.type) { + // Prefer oauth over token (enables auto-refresh) + if (claudeCreds.type === "oauth") { + shouldUpdate = true; + isEqual = false; + } + } + + if (shouldUpdate && !isEqual) { store.profiles[CLAUDE_CLI_PROFILE_ID] = claudeCreds; mutated = true; log.info("synced anthropic credentials from claude cli", { profileId: CLAUDE_CLI_PROFILE_ID, + type: claudeCreds.type, expires: typeof claudeCreds.expires === "number" ? new Date(claudeCreds.expires).toISOString()