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

Đang mở
#94,464 0 bình luận 0 reaction 0 người được giao Xem trên GitHub
area:auth area:desktop bug has repro platform:macos
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á.

Nhận issue mới trong hộp thư của bạn

Bản tóm tắt ngắn những issue GitHub phù hợp với người mới.