mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-07-10 01:28:25 +00:00
Read claude credential from KeyChain
This commit is contained in:
parent
cce7292877
commit
d8154e5357
3 changed files with 186 additions and 0 deletions
109
issue.md
Normal file
109
issue.md
Normal 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
39
issue2.md
Normal 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.
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue