Read claude credential from KeyChain

This commit is contained in:
Kuranasaki@M3Max 2026-07-08 17:21:35 +07:00
parent cce7292877
commit d8154e5357
3 changed files with 186 additions and 0 deletions

109
issue.md Normal file
View file

@ -0,0 +1,109 @@
# Local-agent OAuth provider plugin auth override never applies
## Summary
Providers imported from a local CLI login (Claude Code OAuth, Codex OAuth) send
requests upstream with the wrong auth header. Instead of
`Authorization: Bearer <oauth-token>` (plus `anthropic-beta: oauth-2025-04-20`),
the request goes out with `x-api-key: ccr-local-agent-login` (the internal
placeholder credential) or, after a partial fix, `x-api-key: <real oauth token>`
— still the wrong header, since Anthropic's OAuth flow requires `Authorization`,
not `x-api-key`.
## Root cause
Importing a local-agent provider (`packages/core/src/agents/local-providers/claude-code.ts`,
`codex.ts`) creates two things:
1. A `GatewayProviderConfig` entry in `config.Providers`, with `api_key` set to
the sentinel placeholder `ccr-local-agent-login`
(`packages/core/src/agents/local-providers/shared.ts:26`).
2. One or more `providerPlugins` entries (`bearerAuthPlugin()` /
`apiKeyAuthPlugin()` in `shared.ts:78-114`) carrying the real captured OAuth
token in `auth.headers.authorization`, plus `removeHeaders: ["x-api-key"]`.
The plugin is supposed to be matched to its provider by the internal gateway
process (`@the-next-ai/ai-gateway`, config written by `writeCoreGatewayConfig()`
in `packages/core/src/gateway/service.ts:1151`) via an **exact string match**
between the plugin's `providerName` field and the provider's *runtime* name.
The runtime name is computed by `providerRuntimeId()`
(`gateway/service.ts:6671`):
```ts
function providerRuntimeId(provider) {
const explicit = sanitizeProviderHeaderId(provider.id);
if (explicit) return explicit;
// ...falls back to `provider-<slugified-name>-<sha256-hash-of-name+baseUrl>`
}
```
`provider.id` was **never set** during import
(`packages/ui/src/pages/home/App.tsx`, provider-save handler around line 1434
— no `id` field on the constructed `GatewayProviderConfig`). So the backend
always fell into the hash branch, producing an opaque name like
`provider-claude-code-api-884b99c439::anthropic_messages`.
Meanwhile, the plugin's `providerName` was set (in
`materializeProviderPluginTemplates()`, `App.tsx:60-75`) to the **human-readable
label**: `"Claude Code API::anthropic_messages"`.
These two strings never match. The gateway's plugin-resolution step
(`resolve(n, t)` in the vendor bundle) silently no-ops — no error, no log —
and the provider's raw `api_key` (the sentinel, or later the swapped-in real
token) goes out using the protocol's default header convention. For Anthropic
(`type: "anthropic"`), that default is always `x-api-key`, never `Authorization`,
regardless of `extraHeaders`.
## Fix applied
Two changes, both to make the plugin's `providerName` deterministically equal
to whatever runtime name the provider will actually get — rather than trying
to intercept/patch headers after the fact.
**1. `packages/ui/src/pages/home/App.tsx` (generator/import-time fix, kept):**
- Provider save now sets an explicit `id` on the saved `GatewayProviderConfig`:
a slug of the provider name (`providerNameSlug(providerName)`), reusing the
existing edit's `id` if editing rather than importing fresh.
- `materializeProviderPluginTemplates()` now builds the plugin's internal name
from that same `id` (`${providerId}::${protocol}`) instead of the raw human
name, so it exactly matches `providerCapabilityInternalName()` on the
backend (which now takes the explicit `id` path in `providerRuntimeId()`,
skipping the hash entirely — fully deterministic).
Verified: after wiping `~/.claude-code-router` and re-importing, generated
`gateway.config.json` shows the provider and its paired plugin sharing the
same computed name (`claude-code-api::anthropic_messages`), where previously
they diverged (`Claude Code API` vs `provider-claude-code-api-<hash>::anthropic_messages`).
**2. `packages/core/src/gateway/service.ts` (write-time defense-in-depth,
currently stashed / not applied):**
- `toCoreGatewayProvider()` swaps the sentinel `api_key` for the real resolved
credential (via `localAgentProviderAccountCredential()`, exported from
`packages/core/src/providers/account-service.ts`) before handing the
provider to the gateway.
- `writeCoreGatewayConfig()` also expands each `providerPlugins` entry's
`providerName` into every actual runtime alias (bare name, `name::protocol`)
via a new `withProviderPluginRuntimeNames()` alias-map, so plugins still
match even if a provider was imported before the fix in (1).
- Held back at the user's request in favor of the cleaner root-cause fix in
(1); re-apply from `git stash@{0}` ("Fixed claude oat") if existing
pre-fix-1 imports need to keep working without re-import.
## Known follow-up: newer macOS Claude Code CLI stores credentials in Keychain
`~/.claude/.credentials.json` is Claude Code CLI's older on-disk credential
store. Recent macOS builds instead store the OAuth token in the macOS
Keychain under generic-password service name `"Claude Code-credentials"`.
CCR's importer (`claude-code.ts`) only reads the JSON file today — if it's
absent, import silently finds nothing to import.
Fix not yet implemented: in the importer, fall back to
`security find-generic-password -s "Claude Code-credentials" -w` (triggers a
normal macOS keychain-access permission prompt) when the credentials file is
missing, parse stdout as JSON, and feed it through the same
`findOauthTokenSet()` path already used for the file-based case. Needs a
try/catch since `security` exits non-zero if the user denies the prompt or no
such keychain item exists.

39
issue2.md Normal file
View file

@ -0,0 +1,39 @@
# Claude Code CLI credentials moved to macOS Keychain — local-agent import fails
## Problem
Newer macOS builds of the Claude Code CLI store OAuth credentials in the macOS
Keychain (service name `"Claude Code-credentials"`) instead of
`~/.claude/.credentials.json`. CCR's local-agent provider importer only reads
the file path, so on these installs it finds no credentials and the
"Claude Code API" local login provider cannot be detected/imported.
## Where it's read today
`packages/core/src/agents/local-providers/claude-code.ts` uses
`readJsonRecord()` (from `packages/core/src/agents/local-providers/shared.ts`)
to read `~/.claude/.credentials.json` directly off disk. There is no
Keychain fallback anywhere in `packages/core/src/agents/local-providers/`.
## Fix direction
Add a Keychain fallback when the credentials file is missing/empty:
```bash
security find-generic-password -s "Claude Code-credentials" -w
```
- Triggers a standard macOS permission prompt (Allow / Always Allow) on
first access — no bypass, same consent flow any app goes through.
- Exits non-zero if the item doesn't exist or the user denies access —
must be wrapped in try/catch, falling back to "no local credentials
found" (existing `missingCandidate()` path) rather than throwing.
- Output is JSON on stdout, same shape expected by
`findOauthTokenSet()` in `shared.ts` — parse and feed through the same
path used for the file-based case.
- macOS-only path (`process.platform === "darwin"`); other platforms keep
using the file-based read only.
## Not yet implemented
This is a plan, not a diff — nothing has been changed for this issue yet.

View file

@ -1,3 +1,4 @@
import { execFileSync } from "node:child_process";
import os from "node:os";
import path from "node:path";
import type {
@ -9,6 +10,7 @@ import type {
import {
bearerAuthPlugin,
findOauthTokenSet,
isRecord,
missingCandidate,
providerInternalNamePlaceholder,
providerPayload,
@ -18,6 +20,8 @@ import {
type OAuthTokenSet
} from "@ccr/core/agents/local-providers/shared";
const claudeCodeKeychainService = "Claude Code-credentials";
const claudeDefaultModels = ["claude-sonnet-4-20250514"];
const percentLimitMapping = (id: string, label: string, path: string, window: string) => ({
@ -148,6 +152,19 @@ function readClaudeCodeOauth(): OAuthTokenSet | undefined {
sourceFile
};
}
const keychainRecord = readClaudeCodeKeychainRecord();
if (keychainRecord) {
const credential = findOauthTokenSet(keychainRecord);
if (credential) {
return {
accessToken: credential.accessToken,
refreshToken: credential.refreshToken,
sourceFile: `keychain:${claudeCodeKeychainService}`
};
}
}
return undefined;
}
@ -158,3 +175,24 @@ function claudeCredentialFiles(): string[] {
path.join(os.homedir(), ".config", "claude", "credentials.json")
]);
}
// Newer macOS builds of the Claude Code CLI store credentials in the
// Keychain instead of ~/.claude/.credentials.json. Reading it triggers the
// standard macOS keychain access prompt (Allow / Always Allow); the user
// declining or the item not existing both surface as a non-zero exit here.
function readClaudeCodeKeychainRecord(): Record<string, unknown> | undefined {
if (process.platform !== "darwin") {
return undefined;
}
try {
const output = execFileSync(
"security",
["find-generic-password", "-s", claudeCodeKeychainService, "-w"],
{ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }
);
const parsed = JSON.parse(output.trim()) as unknown;
return isRecord(parsed) ? parsed : undefined;
} catch {
return undefined;
}
}