fix(onboarding): preflight claude cli keychain

This commit is contained in:
Peter Steinberger
2026-01-08 23:17:08 +01:00
parent d38a8d7076
commit b01d7e39d5
12 changed files with 191 additions and 21 deletions

View File

@@ -228,7 +228,9 @@ Common options:
- `--json`: output JSON (includes usage unless `--no-usage` is set). - `--json`: output JSON (includes usage unless `--no-usage` is set).
OAuth sync sources: 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` - `~/.codex/auth.json``openai-codex:codex-cli`
More detail: [/concepts/oauth](/concepts/oauth) More detail: [/concepts/oauth](/concepts/oauth)

View File

@@ -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: 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` - 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). Sync happens when Clawdbot loads the auth store (so it stays up-to-date when the CLIs refresh tokens).

View File

@@ -150,7 +150,9 @@ Overrides:
On first use, Clawdbot imports `oauth.json` entries into `auth-profiles.json`. 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): 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` - `~/.codex/auth.json` (Codex CLI) → `openai-codex:codex-cli`
### `auth` ### `auth`

View File

@@ -70,7 +70,7 @@ Tip: `--json` does **not** imply non-interactive mode. Use `--non-interactive` (
- Full reset (also removes workspace) - Full reset (also removes workspace)
2) **Model/Auth** 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`. - **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 (Codex CLI)**: if `~/.codex/auth.json` exists, the wizard can reuse it.
- **OpenAI Codex OAuth**: browser flow; paste the `code#state`. - **OpenAI Codex OAuth**: browser flow; paste the `code#state`.

View File

@@ -1,3 +1,4 @@
import { execSync } from "node:child_process";
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
@@ -276,10 +277,23 @@ function mergeOAuthFileIntoStore(store: AuthProfileStore): boolean {
} }
/** /**
* Read Anthropic OAuth credentials from Claude CLI's credential file. * Read Anthropic OAuth credentials from Claude CLI's keychain entry (macOS)
* Claude CLI stores credentials at ~/.claude/.credentials.json * 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( const credPath = path.join(
resolveUserPath("~"), resolveUserPath("~"),
CLAUDE_CLI_CREDENTIALS_RELATIVE_PATH, 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. * Read OpenAI Codex OAuth credentials from Codex CLI's auth file.
* Codex CLI stores credentials at ~/.codex/auth.json * Codex CLI stores credentials at ~/.codex/auth.json
@@ -374,12 +423,15 @@ function shallowEqualOAuthCredentials(
* *
* Returns true if any credentials were updated. * Returns true if any credentials were updated.
*/ */
function syncExternalCliCredentials(store: AuthProfileStore): boolean { function syncExternalCliCredentials(
store: AuthProfileStore,
options?: { allowKeychainPrompt?: boolean },
): boolean {
let mutated = false; let mutated = false;
const now = Date.now(); const now = Date.now();
// Sync from Claude CLI // Sync from Claude CLI
const claudeCreds = readClaudeCliCredentials(); const claudeCreds = readClaudeCliCredentials(options);
if (claudeCreds) { if (claudeCreds) {
const existing = store.profiles[CLAUDE_CLI_PROFILE_ID]; const existing = store.profiles[CLAUDE_CLI_PROFILE_ID];
const existingOAuth = existing?.type === "oauth" ? existing : undefined; const existingOAuth = existing?.type === "oauth" ? existing : undefined;
@@ -486,13 +538,16 @@ export function loadAuthProfileStore(): AuthProfileStore {
return store; return store;
} }
export function ensureAuthProfileStore(agentDir?: string): AuthProfileStore { export function ensureAuthProfileStore(
agentDir?: string,
options?: { allowKeychainPrompt?: boolean },
): AuthProfileStore {
const authPath = resolveAuthStorePath(agentDir); const authPath = resolveAuthStorePath(agentDir);
const raw = loadJsonFile(authPath); const raw = loadJsonFile(authPath);
const asStore = coerceAuthStore(raw); const asStore = coerceAuthStore(raw);
if (asStore) { if (asStore) {
// Sync from external CLI tools on every load // Sync from external CLI tools on every load
const synced = syncExternalCliCredentials(asStore); const synced = syncExternalCliCredentials(asStore, options);
if (synced) { if (synced) {
saveJsonFile(authPath, asStore); saveJsonFile(authPath, asStore);
} }
@@ -532,7 +587,7 @@ export function ensureAuthProfileStore(agentDir?: string): AuthProfileStore {
} }
const mergedOAuth = mergeOAuthFileIntoStore(store); const mergedOAuth = mergeOAuthFileIntoStore(store);
const syncedCli = syncExternalCliCredentials(store); const syncedCli = syncExternalCliCredentials(store, options);
const shouldWrite = legacy !== null || mergedOAuth || syncedCli; const shouldWrite = legacy !== null || mergedOAuth || syncedCli;
if (shouldWrite) { if (shouldWrite) {
saveJsonFile(authPath, store); saveJsonFile(authPath, store);

View File

@@ -955,12 +955,15 @@ export async function agentsAddCommand(
initialValue: false, initialValue: false,
}); });
if (wantsAuth) { if (wantsAuth) {
const authStore = ensureAuthProfileStore(agentDir); const authStore = ensureAuthProfileStore(agentDir, {
allowKeychainPrompt: false,
});
const authChoice = (await prompter.select({ const authChoice = (await prompter.select({
message: "Model/auth choice", message: "Model/auth choice",
options: buildAuthChoiceOptions({ options: buildAuthChoiceOptions({
store: authStore, store: authStore,
includeSkip: true, includeSkip: true,
includeClaudeCliIfMissing: true,
}), }),
})) as AuthChoice; })) as AuthChoice;

View File

@@ -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");
});
});

View File

@@ -45,8 +45,11 @@ function formatOAuthHint(
export function buildAuthChoiceOptions(params: { export function buildAuthChoiceOptions(params: {
store: AuthProfileStore; store: AuthProfileStore;
includeSkip: boolean; includeSkip: boolean;
includeClaudeCliIfMissing?: boolean;
platform?: NodeJS.Platform;
}): AuthChoiceOption[] { }): AuthChoiceOption[] {
const options: AuthChoiceOption[] = []; const options: AuthChoiceOption[] = [];
const platform = params.platform ?? process.platform;
const codexCli = params.store.profiles[CODEX_CLI_PROFILE_ID]; const codexCli = params.store.profiles[CODEX_CLI_PROFILE_ID];
if (codexCli?.type === "oauth") { if (codexCli?.type === "oauth") {
@@ -64,6 +67,12 @@ export function buildAuthChoiceOptions(params: {
label: "Anthropic OAuth (Claude CLI)", label: "Anthropic OAuth (Claude CLI)",
hint: formatOAuthHint(claudeCli.expires), 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)" }); options.push({ value: "oauth", label: "Anthropic OAuth (Claude Pro/Max)" });

View File

@@ -168,10 +168,39 @@ export async function applyAuthChoice(params: {
); );
} }
} else if (params.authChoice === "claude-cli") { } else if (params.authChoice === "claude-cli") {
const store = ensureAuthProfileStore(params.agentDir); const store = ensureAuthProfileStore(params.agentDir, {
if (!store.profiles[CLAUDE_CLI_PROFILE_ID]) { allowKeychainPrompt: false,
});
const hasClaudeCli = Boolean(store.profiles[CLAUDE_CLI_PROFILE_ID]);
if (!hasClaudeCli && process.platform === "darwin") {
await params.prompter.note( 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", "Claude CLI OAuth",
); );
return { config: nextConfig, agentModelOverride }; return { config: nextConfig, agentModelOverride };

View File

@@ -286,8 +286,9 @@ async function promptAuthConfig(
await select({ await select({
message: "Model/auth choice", message: "Model/auth choice",
options: buildAuthChoiceOptions({ options: buildAuthChoiceOptions({
store: ensureAuthProfileStore(), store: ensureAuthProfileStore(undefined, { allowKeychainPrompt: false }),
includeSkip: true, includeSkip: true,
includeClaudeCliIfMissing: true,
}), }),
}), }),
runtime, runtime,

View File

@@ -120,10 +120,14 @@ export async function runNonInteractiveOnboarding(
mode: "api_key", mode: "api_key",
}); });
} else if (authChoice === "claude-cli") { } else if (authChoice === "claude-cli") {
const store = ensureAuthProfileStore(); const store = ensureAuthProfileStore(undefined, {
allowKeychainPrompt: false,
});
if (!store.profiles[CLAUDE_CLI_PROFILE_ID]) { if (!store.profiles[CLAUDE_CLI_PROFILE_ID]) {
runtime.error( 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); runtime.exit(1);
return; return;

View File

@@ -223,10 +223,16 @@ export async function runOnboardingWizard(
}, },
}; };
const authStore = ensureAuthProfileStore(); const authStore = ensureAuthProfileStore(undefined, {
allowKeychainPrompt: false,
});
const authChoice = (await prompter.select({ const authChoice = (await prompter.select({
message: "Model/auth choice", message: "Model/auth choice",
options: buildAuthChoiceOptions({ store: authStore, includeSkip: true }), options: buildAuthChoiceOptions({
store: authStore,
includeSkip: true,
includeClaudeCliIfMissing: true,
}),
})) as AuthChoice; })) as AuthChoice;
const authResult = await applyAuthChoice({ const authResult = await applyAuthChoice({