anthropics / anthropics/claude-code
[macOS] Claude Desktop's embedded Claude Code rotates the CLI's OAuth refresh token but can only persist to `.credentials.json` — Keychain stays stale, terminal CLI gets `Login expired` daily
- Ngôn ngữ chính
- Python
- Star
- 145k
- Fork
- 23.1k
- Chỉ số merge pull request
- Chỉ số pull request đang chờ
Mô tả
**Related (does not duplicate):** #78020 (store split-brain from `/login` vs daemon refresh), #80085 (concurrent instances racing on one Keychain item). Neither covers the Claude Desktop → embedded engine path below, which is a deterministic split-brain, not a race.
## Environment
- macOS (Apple Silicon), login keychain unlocked, `security show-keychain-info` → `no-timeout`
- Claude Code CLI 2.1.270 (npm install), authenticated via `/login` (Claude Max subscription, no API key)
- Claude Desktop with its embedded Claude Code engine 2.1.270 (`~/Library/Application Support/Claude/claude-code/2.1.270/claude`, spawned through the `disclaimer` helper, `entrypoint=claude-desktop`, no TTY, env has `CLAUDE_CODE_OAUTH_TOKEN` + `CLAUDE_CODE_SDK_HAS_HOST_AUTH_REFRESH` + `CLAUDE_CODE_SDK_HAS_OAUTH_REFRESH`)
- Same user, same `~/.claude` config dir for both. Windows machine with the same workflow (file store only) never shows the symptom.
## Symptom
Terminal CLI prints `Login expired · Please run /login` roughly once per access-token lifetime (≈ daily; on 2026-09-14 22:47 and 2026-09-15 15:12 local), even though nothing in the terminal ever logged out. Headless `claude -p` runs launched from other tools fail with `authentication_error` in the same windows.
## Evidence (one full cycle, local time, all from disk)
| time | fact | source |
|---|---|---|
| 09-14 22:48 | `/login` in the terminal → Keychain item `Claude Code-credentials` updated (new access/refresh pair) | Keychain `mdat` |
| 22:50 → 13:25 | A Claude Desktop engine session stays alive all night (process started the day before) | `ps`, session transcript |
| ~06:48 | Keychain access token reaches its 8 h expiry | `expiresAt` |
| 08:35:36 | That Desktop session ends a turn (`stop_hook_summary`) | transcript |
| **08:36:05** | `~/.claude/.credentials.json` is **created** (birth time). Its `mcpOAuth` section is byte-identical to the Keychain's for 26 of 27 entries (so it was populated from a Keychain read), and its `claudeAiOauth` is a **new** pair (only `accessToken/refreshToken/expiresAt/scopes`, i.e. the shape of a refresh response). `subscriptionType`/`rateLimitTier` are absent. | `stat -f %SB`, per-entry sha256 comparison |
| 08:30–15:03 | No other Claude Code process on the machine except Desktop-spawned sessions (`entrypoint=claude-desktop`) | scan of all `~/.claude/projects/*/*.jsonl` timestamps |
| 15:03 | A headless `claude -p` (no env token, reads Keychain) → `authentication_error` in 9 s → the Keychain refresh token is already dead | its transcript |
| 15:12 | Terminal CLI → `Login expired`; `/login` at 15:15 writes a fresh pair to Keychain. The file keeps its diverged pair. | transcript, Keychain `mdat` |
| 15:15 → | Keychain `claudeAiOauth` fp `b9cfbce7/ddf6f730`, file fp `8f2b5874/70e3e546` (sha256[:8] of access/refresh). Two live token families, one account. | comparison script |
`claude auth status` from a non-TTY shell still reports the Keychain record (`subscriptionType: max`), confirming readers are Keychain-first.
## What the 2.1.270 binary does (read from the bundled JS)
The credential store is a `keychain-with-file-fallback` composite:
- **read**: Keychain first; falls back to the file only when `security find-generic-password` returns 44 (not found), 36 (locked / interaction not allowed), or times out.
- **update(o)**: `i = keychain.read(); c = keychain.update(o)`;
- if `c.success`: `if (i === null) file.delete()` (migration) — this is the only code path that deletes `.credentials.json`;
- else if not transient: `file.update(o)`; if that succeeds: `if (i !== null) keychain.delete()` — i.e. it *tries* to remove the now-stale Keychain item to avoid split-brain.
## Failure mechanism
Inside the Desktop-spawned engine, Keychain **read succeeds** (the file got the Keychain's `mcpOAuth`), but **`add-generic-password` fails and the subsequent `delete-generic-password` also fails** (the Keychain item survives: `cdat` unchanged since 2026-08-18). Most likely the `disclaimer` sandbox permits `security` reads but not writes/deletes. Result:
1. The engine consumed the shared refresh token (rotation → old refresh token revoked), persisted the successor **only** to `.credentials.json`.
2. The Keychain item — still primary for every other process — now holds a revoked refresh token.
3. The next process that needs a refresh (terminal CLI, headless `claude -p`) fails → `Login expired` / `authentication_error`.
4. `/login` writes Keychain, the file keeps the other family; the cycle repeats at the next expiry.
Why the engine used the shared store's token at all: its `CLAUDE_CODE_OAUTH_TOKEN` env value is identical across engines spawned 29 h apart (same fp), i.e. it is a stale access token, so the engine falls back to the store for a live one.
## Expected
Either (a) the Desktop engine should not touch the shared store's refresh token when the host provides auth, or (b) when the fallback write is used, the failed `keychain.delete()` should be surfaced/retried, or (c) the reader should prefer whichever store holds the newer `expiresAt`. Any of these prevents the split-brain. Windows is immune because it has a single store.
## Workaround that works (verified)
Give the terminal CLI its own long-lived subscription token: `claude setup-token` → `export CLAUDE_CODE_OAUTH_TOKEN=…` in a 600 file sourced from `~/.zshrc`. `claude auth status` then reports `authMethod: oauth_token`, and the terminal no longer depends on the Keychain refresh chain the Desktop engine keeps rotating. (Closing Desktop sessions before using the CLI also avoids it, but is impractical.)
## Diagnostics anyone can run
```bash
# both stores present?
ls -la ~/.claude/.credentials.json; security find-generic-password -s "Claude Code-credentials" | grep -E 'cdat|mdat'
# do they hold the same claudeAiOauth family? (prints sha256 prefixes only)
security find-generic-password -s "Claude Code-credentials" -w | python3 -c 'import json,sys,hashlib;o=json.load(sys.stdin)["claudeAiOauth"];print("keychain",hashlib.sha256(o["refreshToken"].encode()).hexdigest()[:8])'
python3 -c 'import json,hashlib,os;o=json.load(open(os.path.expanduser("~/.claude/.credentials.json")))["claudeAiOauth"];print("file",hashlib.sha256(o["refreshToken"].encode()).hexdigest()[:8])'
```
Different prefixes = split-brain.
---
_Diagnosed with Claude Code; evidence gathered from local transcripts, Keychain metadata and the 2.1.270 bundle. Happy to provide more detail._
Hướng dẫn đóng góp
Chưa lập chỉ mục được hướng dẫn đóng góp cho kho mã nguồn này
Đánh giá
Issue này chưa được đánh giá.