fix(onboarding): preflight claude cli keychain
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
57
src/commands/auth-choice-options.test.ts
Normal file
57
src/commands/auth-choice-options.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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)" });
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
Reference in New Issue
Block a user