diff --git a/docs/cli/index.md b/docs/cli/index.md index ca805f3be..284a126e9 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -228,7 +228,9 @@ Common options: - `--json`: output JSON (includes usage unless `--no-usage` is set). OAuth sync sources: -- `~/.claude/.credentials.json` → `anthropic:claude-cli` +- Claude Code → `anthropic:claude-cli` + - macOS: Keychain item "Claude Code-credentials" (choose "Always Allow" to avoid launchd prompts) + - Linux/Windows: `~/.claude/.credentials.json` - `~/.codex/auth.json` → `openai-codex:codex-cli` More detail: [/concepts/oauth](/concepts/oauth) diff --git a/docs/concepts/oauth.md b/docs/concepts/oauth.md index e89ce1822..829984e69 100644 --- a/docs/concepts/oauth.md +++ b/docs/concepts/oauth.md @@ -43,7 +43,9 @@ All of the above also respect `$CLAWDBOT_STATE_DIR` (state dir override). Full r If you already signed in with the external CLIs *on the gateway host*, Clawdbot can reuse those tokens without starting a separate OAuth flow: -- Claude Code: reads `~/.claude/.credentials.json` → profile `anthropic:claude-cli` +- Claude Code: `anthropic:claude-cli` + - macOS: Keychain item "Claude Code-credentials" (choose "Always Allow" to avoid launchd prompts) + - Linux/Windows: `~/.claude/.credentials.json` - Codex CLI: reads `~/.codex/auth.json` → profile `openai-codex:codex-cli` Sync happens when Clawdbot loads the auth store (so it stays up-to-date when the CLIs refresh tokens). diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 3f37921a1..abad5bb7a 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -150,7 +150,9 @@ Overrides: On first use, Clawdbot imports `oauth.json` entries into `auth-profiles.json`. Clawdbot also auto-syncs OAuth tokens from external CLIs into `auth-profiles.json` (when present on the gateway host): -- `~/.claude/.credentials.json` (Claude Code) → `anthropic:claude-cli` +- Claude Code → `anthropic:claude-cli` + - macOS: Keychain item "Claude Code-credentials" (choose "Always Allow" to avoid launchd prompts) + - Linux/Windows: `~/.claude/.credentials.json` - `~/.codex/auth.json` (Codex CLI) → `openai-codex:codex-cli` ### `auth` diff --git a/docs/start/wizard.md b/docs/start/wizard.md index d3c355a1b..0e1cb6e90 100644 --- a/docs/start/wizard.md +++ b/docs/start/wizard.md @@ -70,7 +70,7 @@ Tip: `--json` does **not** imply non-interactive mode. Use `--non-interactive` ( - Full reset (also removes workspace) 2) **Model/Auth** - - **Anthropic OAuth (Claude CLI)**: if `~/.claude/.credentials.json` exists, the wizard can reuse it. + - **Anthropic OAuth (Claude CLI)**: on macOS the wizard checks Keychain item "Claude Code-credentials" (choose "Always Allow" so launchd starts don't block); on Linux/Windows it reuses `~/.claude/.credentials.json` if present. - **Anthropic OAuth (recommended)**: browser flow; paste the `code#state`. - **OpenAI Codex OAuth (Codex CLI)**: if `~/.codex/auth.json` exists, the wizard can reuse it. - **OpenAI Codex OAuth**: browser flow; paste the `code#state`. diff --git a/src/agents/auth-profiles.ts b/src/agents/auth-profiles.ts index 9a4a14b72..c0348c0e7 100644 --- a/src/agents/auth-profiles.ts +++ b/src/agents/auth-profiles.ts @@ -1,3 +1,4 @@ +import { execSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; @@ -276,10 +277,23 @@ function mergeOAuthFileIntoStore(store: AuthProfileStore): boolean { } /** - * Read Anthropic OAuth credentials from Claude CLI's credential file. - * Claude CLI stores credentials at ~/.claude/.credentials.json + * Read Anthropic OAuth credentials from Claude CLI's keychain entry (macOS) + * or credential file (Linux/Windows). + * + * On macOS, Claude Code stores credentials in keychain "Claude Code-credentials". + * On Linux/Windows, it uses ~/.claude/.credentials.json */ -function readClaudeCliCredentials(): OAuthCredential | null { +function readClaudeCliCredentials(options?: { + allowKeychainPrompt?: boolean; +}): OAuthCredential | null { + if (process.platform === "darwin" && options?.allowKeychainPrompt !== false) { + const keychainCreds = readClaudeCliKeychainCredentials(); + if (keychainCreds) { + log.info("read anthropic credentials from claude cli keychain"); + return keychainCreds; + } + } + const credPath = path.join( resolveUserPath("~"), CLAUDE_CLI_CREDENTIALS_RELATIVE_PATH, @@ -308,6 +322,41 @@ function readClaudeCliCredentials(): OAuthCredential | null { }; } +/** + * Read Claude Code credentials from macOS keychain. + * Uses the `security` CLI to access keychain without native dependencies. + */ +function readClaudeCliKeychainCredentials(): OAuthCredential | null { + try { + const result = execSync( + 'security find-generic-password -s "Claude Code-credentials" -w', + { encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] }, + ); + + const data = JSON.parse(result.trim()); + const claudeOauth = data?.claudeAiOauth; + 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 refreshToken !== "string" || !refreshToken) return null; + if (typeof expiresAt !== "number" || expiresAt <= 0) return null; + + return { + type: "oauth", + provider: "anthropic", + access: accessToken, + refresh: refreshToken, + expires: expiresAt, + }; + } catch { + return null; + } +} + /** * Read OpenAI Codex OAuth credentials from Codex CLI's auth file. * Codex CLI stores credentials at ~/.codex/auth.json @@ -374,12 +423,15 @@ function shallowEqualOAuthCredentials( * * Returns true if any credentials were updated. */ -function syncExternalCliCredentials(store: AuthProfileStore): boolean { +function syncExternalCliCredentials( + store: AuthProfileStore, + options?: { allowKeychainPrompt?: boolean }, +): boolean { let mutated = false; const now = Date.now(); // Sync from Claude CLI - const claudeCreds = readClaudeCliCredentials(); + const claudeCreds = readClaudeCliCredentials(options); if (claudeCreds) { const existing = store.profiles[CLAUDE_CLI_PROFILE_ID]; const existingOAuth = existing?.type === "oauth" ? existing : undefined; @@ -486,13 +538,16 @@ export function loadAuthProfileStore(): AuthProfileStore { return store; } -export function ensureAuthProfileStore(agentDir?: string): AuthProfileStore { +export function ensureAuthProfileStore( + agentDir?: string, + options?: { allowKeychainPrompt?: boolean }, +): AuthProfileStore { const authPath = resolveAuthStorePath(agentDir); const raw = loadJsonFile(authPath); const asStore = coerceAuthStore(raw); if (asStore) { // Sync from external CLI tools on every load - const synced = syncExternalCliCredentials(asStore); + const synced = syncExternalCliCredentials(asStore, options); if (synced) { saveJsonFile(authPath, asStore); } @@ -532,7 +587,7 @@ export function ensureAuthProfileStore(agentDir?: string): AuthProfileStore { } const mergedOAuth = mergeOAuthFileIntoStore(store); - const syncedCli = syncExternalCliCredentials(store); + const syncedCli = syncExternalCliCredentials(store, options); const shouldWrite = legacy !== null || mergedOAuth || syncedCli; if (shouldWrite) { saveJsonFile(authPath, store); diff --git a/src/commands/agents.ts b/src/commands/agents.ts index 9430bf5f8..81de133ab 100644 --- a/src/commands/agents.ts +++ b/src/commands/agents.ts @@ -955,12 +955,15 @@ export async function agentsAddCommand( initialValue: false, }); if (wantsAuth) { - const authStore = ensureAuthProfileStore(agentDir); + const authStore = ensureAuthProfileStore(agentDir, { + allowKeychainPrompt: false, + }); const authChoice = (await prompter.select({ message: "Model/auth choice", options: buildAuthChoiceOptions({ store: authStore, includeSkip: true, + includeClaudeCliIfMissing: true, }), })) as AuthChoice; diff --git a/src/commands/auth-choice-options.test.ts b/src/commands/auth-choice-options.test.ts new file mode 100644 index 000000000..492eefd3b --- /dev/null +++ b/src/commands/auth-choice-options.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; + +import { CLAUDE_CLI_PROFILE_ID, type AuthProfileStore } from "../agents/auth-profiles.js"; +import { buildAuthChoiceOptions } from "./auth-choice-options.js"; + +describe("buildAuthChoiceOptions", () => { + it("includes Claude CLI option on macOS even when missing", () => { + const store: AuthProfileStore = { version: 1, profiles: {} }; + const options = buildAuthChoiceOptions({ + store, + includeSkip: false, + includeClaudeCliIfMissing: true, + platform: "darwin", + }); + + const claudeCli = options.find((opt) => opt.value === "claude-cli"); + expect(claudeCli).toBeDefined(); + expect(claudeCli?.hint).toBe("requires Keychain access"); + }); + + it("skips missing Claude CLI option off macOS", () => { + const store: AuthProfileStore = { version: 1, profiles: {} }; + const options = buildAuthChoiceOptions({ + store, + includeSkip: false, + includeClaudeCliIfMissing: true, + platform: "linux", + }); + + expect(options.find((opt) => opt.value === "claude-cli")).toBeUndefined(); + }); + + it("uses token hint when Claude CLI credentials exist", () => { + const store: AuthProfileStore = { + version: 1, + profiles: { + [CLAUDE_CLI_PROFILE_ID]: { + type: "oauth", + provider: "anthropic", + access: "token", + refresh: "refresh", + expires: Date.now() + 60 * 60 * 1000, + }, + }, + }; + + const options = buildAuthChoiceOptions({ + store, + includeSkip: false, + includeClaudeCliIfMissing: true, + platform: "darwin", + }); + + const claudeCli = options.find((opt) => opt.value === "claude-cli"); + expect(claudeCli?.hint).toContain("token ok"); + }); +}); diff --git a/src/commands/auth-choice-options.ts b/src/commands/auth-choice-options.ts index 4feacf9f2..0355eb2c7 100644 --- a/src/commands/auth-choice-options.ts +++ b/src/commands/auth-choice-options.ts @@ -45,8 +45,11 @@ function formatOAuthHint( export function buildAuthChoiceOptions(params: { store: AuthProfileStore; includeSkip: boolean; + includeClaudeCliIfMissing?: boolean; + platform?: NodeJS.Platform; }): AuthChoiceOption[] { const options: AuthChoiceOption[] = []; + const platform = params.platform ?? process.platform; const codexCli = params.store.profiles[CODEX_CLI_PROFILE_ID]; if (codexCli?.type === "oauth") { @@ -64,6 +67,12 @@ export function buildAuthChoiceOptions(params: { label: "Anthropic OAuth (Claude CLI)", hint: formatOAuthHint(claudeCli.expires), }); + } else if (params.includeClaudeCliIfMissing && platform === "darwin") { + options.push({ + value: "claude-cli", + label: "Anthropic OAuth (Claude CLI)", + hint: "requires Keychain access", + }); } options.push({ value: "oauth", label: "Anthropic OAuth (Claude Pro/Max)" }); diff --git a/src/commands/auth-choice.ts b/src/commands/auth-choice.ts index 195bcf50b..36c4d0fe8 100644 --- a/src/commands/auth-choice.ts +++ b/src/commands/auth-choice.ts @@ -168,10 +168,39 @@ export async function applyAuthChoice(params: { ); } } else if (params.authChoice === "claude-cli") { - const store = ensureAuthProfileStore(params.agentDir); - if (!store.profiles[CLAUDE_CLI_PROFILE_ID]) { + const store = ensureAuthProfileStore(params.agentDir, { + allowKeychainPrompt: false, + }); + const hasClaudeCli = Boolean(store.profiles[CLAUDE_CLI_PROFILE_ID]); + if (!hasClaudeCli && process.platform === "darwin") { await params.prompter.note( - "No Claude CLI credentials found at ~/.claude/.credentials.json.", + [ + "macOS will show a Keychain prompt next.", + 'Choose "Always Allow" so the launchd gateway can start without prompts.', + 'If you choose "Allow" or "Deny", each restart will block on a Keychain alert.', + ].join("\n"), + "Claude CLI Keychain", + ); + const proceed = await params.prompter.confirm({ + message: "Check Keychain for Claude CLI credentials now?", + initialValue: true, + }); + if (!proceed) { + return { config: nextConfig, agentModelOverride }; + } + } + + const storeWithKeychain = hasClaudeCli + ? store + : ensureAuthProfileStore(params.agentDir, { + allowKeychainPrompt: true, + }); + + if (!storeWithKeychain.profiles[CLAUDE_CLI_PROFILE_ID]) { + await params.prompter.note( + process.platform === "darwin" + ? 'No Claude CLI credentials found in Keychain ("Claude Code-credentials") or ~/.claude/.credentials.json.' + : "No Claude CLI credentials found at ~/.claude/.credentials.json.", "Claude CLI OAuth", ); return { config: nextConfig, agentModelOverride }; diff --git a/src/commands/configure.ts b/src/commands/configure.ts index c0b263072..7a8255471 100644 --- a/src/commands/configure.ts +++ b/src/commands/configure.ts @@ -286,8 +286,9 @@ async function promptAuthConfig( await select({ message: "Model/auth choice", options: buildAuthChoiceOptions({ - store: ensureAuthProfileStore(), + store: ensureAuthProfileStore(undefined, { allowKeychainPrompt: false }), includeSkip: true, + includeClaudeCliIfMissing: true, }), }), runtime, diff --git a/src/commands/onboard-non-interactive.ts b/src/commands/onboard-non-interactive.ts index af4ebfc7f..382317506 100644 --- a/src/commands/onboard-non-interactive.ts +++ b/src/commands/onboard-non-interactive.ts @@ -120,10 +120,14 @@ export async function runNonInteractiveOnboarding( mode: "api_key", }); } else if (authChoice === "claude-cli") { - const store = ensureAuthProfileStore(); + const store = ensureAuthProfileStore(undefined, { + allowKeychainPrompt: false, + }); if (!store.profiles[CLAUDE_CLI_PROFILE_ID]) { runtime.error( - "No Claude CLI credentials found at ~/.claude/.credentials.json", + process.platform === "darwin" + ? 'No Claude CLI credentials found. Run interactive onboarding to approve Keychain access for "Claude Code-credentials".' + : "No Claude CLI credentials found at ~/.claude/.credentials.json", ); runtime.exit(1); return; diff --git a/src/wizard/onboarding.ts b/src/wizard/onboarding.ts index f787a11ba..c79d2d1ef 100644 --- a/src/wizard/onboarding.ts +++ b/src/wizard/onboarding.ts @@ -223,10 +223,16 @@ export async function runOnboardingWizard( }, }; - const authStore = ensureAuthProfileStore(); + const authStore = ensureAuthProfileStore(undefined, { + allowKeychainPrompt: false, + }); const authChoice = (await prompter.select({ message: "Model/auth choice", - options: buildAuthChoiceOptions({ store: authStore, includeSkip: true }), + options: buildAuthChoiceOptions({ + store: authStore, + includeSkip: true, + includeClaudeCliIfMissing: true, + }), })) as AuthChoice; const authResult = await applyAuthChoice({