fix(claude): fall back to macOS Keychain for credentials
lint / lint (push) Failing after 2m10s
test / unit (pull_request) Successful in 57s
test / integration (pull_request) Successful in 22s
test / coverage (pull_request) Successful in 1m6s

On macOS, Claude Code stores credentials in the Keychain under
service "Claude Code-credentials" rather than in a file. When
~/.claude/.credentials.json is absent, shell out to:
  security find-generic-password -s "Claude Code-credentials" -w
and parse the result as the same JSON schema.

~/.claude.json holds only profile/UI metadata (oauthAccount has
no token fields). expiresAt in the credentials is milliseconds.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 21:46:53 +00:00
parent bdd352570b
commit 7278ee1157
3 changed files with 166 additions and 60 deletions
@@ -68,9 +68,16 @@ Rejects in manifest validation when:
### Host auth extraction (`contrib/claude/claude_auth.py`)
Claude Code stores its OAuth credentials in `~/.claude/.credentials.json`
(not in `~/.claude.json`, which contains only UI state and profile
metadata). The relevant fields are:
Claude Code credential storage varies by platform:
- **Linux**: `~/.claude/.credentials.json`
- **macOS**: macOS Keychain, service `"Claude Code-credentials"`
(the file path is tried first; Keychain is the fallback when the file
is absent)
`~/.claude.json` contains only UI state and profile metadata — no token.
The credentials JSON schema (same whether from file or Keychain):
```json
{
@@ -83,17 +90,18 @@ metadata). The relevant fields are:
}
```
Note: `expiresAt` is in **milliseconds** (not seconds).
`expiresAt` is in **milliseconds** (not seconds).
At prepare/launch time, when `forward_host_credentials: true`:
1. Resolve `~/.claude/.credentials.json` (via `$HOME`).
2. Parse the JSON object.
3. Require a `claudeAiOauth` dict.
4. Require a non-empty `claudeAiOauth.accessToken` string.
5. If `claudeAiOauth.expiresAt` is present as a number, divide by 1000
and require the result to be in the future.
6. Return only the access token to the launch path.
1. Try `~/.claude/.credentials.json`; on macOS, if absent, run
`security find-generic-password -s "Claude Code-credentials" -w`
and parse its stdout as JSON.
2. Require a `claudeAiOauth` dict.
3. Require a non-empty `claudeAiOauth.accessToken` string.
4. If `claudeAiOauth.expiresAt` is present, divide by 1000 and require
the result to be in the future.
5. Return only the access token to the launch path.
Errors name the missing or invalid condition and point the operator at
`claude login`, without printing token values.