Read ~/.claude/.credentials.json so a logged-in Claude Code means the harness starts with no arguments. The wire format was already there; what was missing was finding a token. Two questions the design deliberately leaves open, each gated behind a cheap probe rather than a guess: whether the token endpoint rotates refresh tokens (if it does, in-memory refresh would break Claude Code's own login), and whether OAuth requests need the Claude Code identity block first in the system prompt. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Showing 1 changed file with 184 additions and 0 deletions.
| @@ -0,0 +1,184 @@ | ||
| 1 | + # Claude Code OAuth credentials | |
| 2 | + | |
| 3 | + The harness already speaks OAuth on the wire: `-auth-token` sends | |
| 4 | + `Authorization: Bearer` and the `oauth-2025-04-20` beta header, and has since | |
| 5 | + Phase 1. What it cannot do is find a token. You either paste one on the command | |
| 6 | + line or export it, and either way it expires in a few hours and you paste again. | |
| 7 | + | |
| 8 | + Read Claude Code's credentials instead. If you are logged into Claude Code, the | |
| 9 | + harness starts with no arguments. | |
| 10 | + | |
| 11 | + ## What we are reading, and why that is a liability | |
| 12 | + | |
| 13 | + Claude Code stores a subscription OAuth credential at | |
| 14 | + `~/.claude/.credentials.json`: | |
| 15 | + | |
| 16 | + ```json | |
| 17 | + { "claudeAiOauth": { | |
| 18 | + "accessToken": "sk-ant-oat01…", "refreshToken": "sk-ant-ort01…", | |
| 19 | + "expiresAt": 1789668269460, "refreshTokenExpiresAt": 1791404074460, | |
| 20 | + "scopes": ["user:inference", "user:profile", …], | |
| 21 | + "subscriptionType": "team", "rateLimitTier": "…" } } | |
| 22 | + ``` | |
| 23 | + | |
| 24 | + Both timestamps are epoch **milliseconds**. `user:inference` is the scope that | |
| 25 | + matters; the others are for surfaces we do not call. | |
| 26 | + | |
| 27 | + Two caveats that shape everything below. | |
| 28 | + | |
| 29 | + This is Claude Code's private storage, not a documented interface. The | |
| 30 | + first-party OAuth path is `ant auth login`, which writes profiles under | |
| 31 | + `~/.config/anthropic/` that every Anthropic SDK reads automatically. That path | |
| 32 | + is stable and supported. It is also not available here — no `ant` binary, no | |
| 33 | + `~/.config/anthropic` — so we read the file that exists. The schema and the | |
| 34 | + refresh endpoint can change under us without warning, which is the argument for | |
| 35 | + putting all of it behind one small package whose tests fail loudly rather than | |
| 36 | + one that degrades quietly. | |
| 37 | + | |
| 38 | + And the credential is issued to Claude Code. Using it from another client is | |
| 39 | + outside Anthropic's terms for that credential, which is a reason to keep this | |
| 40 | + strictly opt-out-able and clearly announced, and never the silent default when | |
| 41 | + another credential was offered. API keys and gateway bearer tokens carry no such | |
| 42 | + condition and keep working exactly as they do today. | |
| 43 | + | |
| 44 | + ## Precedence | |
| 45 | + | |
| 46 | + First match wins: | |
| 47 | + | |
| 48 | + | Source | Notes | | |
| 49 | + | --- | --- | | |
| 50 | + | `-api-key` / `-auth-token` | explicit, so it beats everything | | |
| 51 | + | `ANTHROPIC_API_KEY` / `ANTHROPIC_AUTH_TOKEN` | today's behaviour, unchanged | | |
| 52 | + | `~/.claude/.credentials.json` | new, and only under the conditions below | | |
| 53 | + | — | nothing: `errNoCredentials`, as now | | |
| 54 | + | |
| 55 | + The file is consulted only when every earlier source came up empty **and** | |
| 56 | + `client.IsDefaultEndpoint()` is true. That second condition is the one worth | |
| 57 | + being stubborn about: a discovered credential must never be shipped to an | |
| 58 | + endpoint the user pointed us at. Otherwise `-base-url https://someone-elses-proxy` | |
| 59 | + silently forwards your subscription token to a third party because a file | |
| 60 | + happened to exist on disk. A credential you typed is a credential you chose to | |
| 61 | + send; a credential we found is not. | |
| 62 | + | |
| 63 | + When the file is the source, say so through `notice()` — the same channel that | |
| 64 | + already announces a non-default endpoint, and for the same reason. `-no-claude-code-auth` | |
| 65 | + skips discovery entirely. | |
| 66 | + | |
| 67 | + ## `internal/claudecode` | |
| 68 | + | |
| 69 | + One package, three jobs, no ambient state: | |
| 70 | + | |
| 71 | + ```go | |
| 72 | + type Credentials struct { | |
| 73 | + AccessToken, RefreshToken string | |
| 74 | + ExpiresAt time.Time | |
| 75 | + Scopes []string | |
| 76 | + SubscriptionType string | |
| 77 | + } | |
| 78 | + | |
| 79 | + func Load(home string) (*Credentials, error) // parse. no network | |
| 80 | + func (c *Credentials) Expired(now time.Time) bool // with skew | |
| 81 | + func Refresh(context.Context, *http.Client, *Credentials) (*Credentials, error) | |
| 82 | + ``` | |
| 83 | + | |
| 84 | + `Load` takes `home` rather than calling `os.UserHomeDir` so tests point it at a | |
| 85 | + fixture directory, matching how `skills.Discover` already takes its roots. | |
| 86 | + `Refresh` returns new credentials and writes nothing — see below. | |
| 87 | + | |
| 88 | + Missing file is not an error worth decorating: it means "not logged in", and the | |
| 89 | + caller falls through to `errNoCredentials`. A file that exists but does not parse | |
| 90 | + *is* an error, reported with the path, because that is a broken install rather | |
| 91 | + than an absent one. | |
| 92 | + | |
| 93 | + `Expired` subtracts a 60-second skew margin, so a token expiring during the | |
| 94 | + request we are about to send counts as expired now. | |
| 95 | + | |
| 96 | + On macOS Claude Code keeps this in the Keychain and the file does not exist. | |
| 97 | + That degrades to "no credential found", which is correct, and is documented | |
| 98 | + rather than worked around. | |
| 99 | + | |
| 100 | + ## Dynamic credentials in `llm.Client` | |
| 101 | + | |
| 102 | + `authToken` is a fixed string read once at construction, used by both `attempt` | |
| 103 | + and the streaming path. A token that expires mid-session needs to change, so: | |
| 104 | + | |
| 105 | + ```go | |
| 106 | + func WithTokenSource(f func(context.Context) (string, error)) Option | |
| 107 | + ``` | |
| 108 | + | |
| 109 | + `WithAuthToken` becomes the constant case of it. Both request paths call the | |
| 110 | + source and set the bearer from the result; the existing header logic is | |
| 111 | + untouched, because the header was never the problem. | |
| 112 | + | |
| 113 | + Resolving a credential can now fail at request time rather than at startup. | |
| 114 | + That error returns as-is — it is not an `*APIError`, so `Retryable` is false and | |
| 115 | + the loop surfaces it instead of hammering a dead endpoint. | |
| 116 | + | |
| 117 | + The token source refreshes at most once per expiry, caches in memory for the | |
| 118 | + process lifetime, and is mutex-guarded: streaming and non-streaming requests can | |
| 119 | + be in flight together, and two concurrent refreshes of the same token is exactly | |
| 120 | + the scenario the next section is about. | |
| 121 | + | |
| 122 | + ## Refresh, and an open question that gates it | |
| 123 | + | |
| 124 | + Refreshing is `POST /v1/oauth/token` with the refresh token. If the endpoint | |
| 125 | + returns a *new* refresh token, then refreshing without writing it back consumes | |
| 126 | + the one Claude Code still has on disk — its copy goes dead and `claude` asks you | |
| 127 | + to log in again. We would have broken a tool outside this repo to save a paste. | |
| 128 | + | |
| 129 | + We do not yet know whether that endpoint rotates. So the first implementation | |
| 130 | + task is a probe, not code: refresh once, compare the returned refresh token to | |
| 131 | + the stored one, report. | |
| 132 | + | |
| 133 | + - **No rotation** — in-memory-only refresh is safe. Build it as described. | |
| 134 | + - **Rotation** — stop. Bring the result back before writing refresh code; the | |
| 135 | + choice then is write-back (with atomic rename and 0600) or no refresh at all, | |
| 136 | + and it is not a choice to make silently inside an implementation task. | |
| 137 | + | |
| 138 | + Until that resolves, the fallback is the honest one: use the stored token, and | |
| 139 | + when it is expired fail with `your Claude Code login has expired, run claude to | |
| 140 | + refresh it`. That is a working feature on its own. Refresh is an improvement on | |
| 141 | + top of it, not a prerequisite. | |
| 142 | + | |
| 143 | + ## The system prompt | |
| 144 | + | |
| 145 | + OAuth requests are expected to require the Claude Code identity string as the | |
| 146 | + first system block. If that holds, `defaultSystem` at `loop.go:26` is currently | |
| 147 | + the first block and these requests will be rejected. | |
| 148 | + | |
| 149 | + This is also a question rather than a fact, and a cheap one: one live request | |
| 150 | + with the token and no identity block. If it fails, the agent prepends a second | |
| 151 | + `SystemBlock` carrying the identity string when — and only when — the credential | |
| 152 | + came from OAuth. Both blocks stay static, so the cached prefix survives intact, | |
| 153 | + which is the property `defaultSystem`'s comment exists to protect. If the | |
| 154 | + request succeeds, nothing changes and this section is deleted. | |
| 155 | + | |
| 156 | + Ordering matters: this probe uses the *access* token and touches no refresh | |
| 157 | + token, so it is free of the rotation risk above and can run first. | |
| 158 | + | |
| 159 | + ## Testing | |
| 160 | + | |
| 161 | + `Load`: missing file, unparseable JSON, the millisecond epoch converted | |
| 162 | + correctly, an expired access token, an expired *refresh* token (which is | |
| 163 | + terminal — no amount of refreshing fixes it). | |
| 164 | + | |
| 165 | + `Refresh` against `httptest`: success, `400 invalid_grant`, transport error. | |
| 166 | + No test makes a real network call. These land only if the probe clears refresh | |
| 167 | + to be built; if it does not, the expired-token error path is what gets tested | |
| 168 | + instead. | |
| 169 | + | |
| 170 | + `llm.Client`: the token source is called per request and its value reaches the | |
| 171 | + `Authorization` header; a source error propagates without retries; `WithAuthToken` | |
| 172 | + still behaves as the constant source. | |
| 173 | + | |
| 174 | + Precedence, which is where the security property lives and therefore gets the | |
| 175 | + most tests: flag beats env beats file; a non-default base URL means the file is | |
| 176 | + never read; `-no-claude-code-auth` means the file is never read. That third one | |
| 177 | + is a test rather than a comment because "we did not send the credential" is not | |
| 178 | + observable from the outside. | |
| 179 | + | |
| 180 | + ## Not doing | |
| 181 | + | |
| 182 | + No login flow — no PKCE, no browser, no callback server. You log in with Claude | |
| 183 | + Code; the harness reads what that produced. If `ant` shows up on this machine | |
| 184 | + later, its profile is a better source than this file and worth revisiting then. | |