Vectrum
Sign In

Plan: Claude Code OAuth in eight tasks

Two probes gate the code that depends on them: whether OAuth needs the
identity block, and whether the token endpoint rotates refresh tokens.
The second has an explicit stop gate, because getting it wrong breaks
the user's Claude Code login rather than this harness.

Task 4 ships a working feature without refresh, so refresh is additive
rather than load-bearing.

Also corrects the spec's claim about the retry loop: a non-APIError
falls through and is retried, so the credential is resolved once per
call rather than inside attempt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Jjonicommitted 1 parent 614f37ecommit806ec13

Showing 2 changed files with 1700 additions and 3 deletions.

+1690−0docs/superpowers/plans/2026-09-17-claude-code-oauth.mdaddedView File
@@ -0,0 +1,1690 @@
1+ # Claude Code OAuth Implementation Plan
2+
3+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4+
5+ **Goal:** If you are logged into Claude Code, `./harness` starts with no arguments and no environment.
6+
7+ **Architecture:** A new `internal/claudecode` package parses `~/.claude/.credentials.json` and hands out a bearer token. `llm.Client` gains `WithTokenSource` so the credential is resolved per request instead of frozen at construction, and both request paths share one `apply` instead of duplicating header logic. `main.go` consults the file only when no other credential was supplied and the endpoint is Anthropic's.
8+
9+ **Tech Stack:** Go 1.26.8, stdlib only (`encoding/json`, `net/http`, `net/http/httptest`, `sync`). No new dependencies.
10+
11+ **Spec:** `docs/superpowers/specs/2026-09-17-claude-code-oauth-design.md`
12+
13+ ## Global Constraints
14+
15+ - Go module is `harness`; import paths are `harness/internal/...`.
16+ - Working root for every command is `/home/joni/.t3/worktrees/coding-harness/t3code-8b127a46`. Do not `cd` to the original checkout.
17+ - `internal/claudecode` imports stdlib only. It must not import `internal/llm` — `llm` is the consumer, and the dependency runs that way or not at all.
18+ - No test makes a real network call. `httptest` for everything.
19+ - Discovered credentials are used **only** when `client.IsDefaultEndpoint()` is true and no flag or env var supplied one. This is a security property, not a preference.
20+ - New exported identifiers get a doc comment, matching the existing house style.
21+ - Run `gofmt -w` on every file you touch before committing.
22+ - Run `go build ./... && go test ./...` before every commit.
23+ - Tasks 1 and 6 are probes. They end in a recorded finding, not a feature.
24+ - **Task 6 has a STOP gate.** Do not implement Task 7 if Task 6 reports rotation. Report and wait.
25+
26+ ---
27+
28+ ### Task 1: Probe — does an OAuth request need the Claude Code identity block?
29+
30+ The spec leaves this open. It costs one request to close, and it touches only the
31+ access token, so it carries none of Task 6's risk.
32+
33+ **Files:**
34+ - Create: `/tmp/oauth-probe.sh` (throwaway, not committed)
35+ - Modify: `docs/superpowers/specs/2026-09-17-claude-code-oauth-design.md` (record the finding)
36+
37+ **Interfaces:**
38+ - Consumes: nothing.
39+ - Produces: a decision that gates Task 5. Either "identity block required" or "not required".
40+
41+ - [ ] **Step 1: Send a minimal request with the stored token and no identity block**
42+
43+ ```bash
44+ cd /home/joni/.t3/worktrees/coding-harness/t3code-8b127a46
45+ TOK=$(python3 -c "import json;print(json.load(open('$HOME/.claude/.credentials.json'))['claudeAiOauth']['accessToken'])")
46+ curl -sS -o /tmp/probe-plain.json -w '%{http_code}\n' https://api.anthropic.com/v1/messages \
47+ -H "authorization: Bearer $TOK" \
48+ -H "anthropic-version: 2023-06-01" \
49+ -H "anthropic-beta: oauth-2025-04-20" \
50+ -H "content-type: application/json" \
51+ -d '{"model":"claude-opus-5","max_tokens":16,"system":[{"type":"text","text":"You are a coding agent operating on a local repository through tools."}],"messages":[{"role":"user","content":"Say OK."}]}'
52+ cat /tmp/probe-plain.json
53+ ```
54+
55+ Expected: either `200` (no identity block needed) or `401`/`403` with a message about the client.
56+
57+ - [ ] **Step 2: If it failed, confirm the identity block is the difference**
58+
59+ Only run this if Step 1 was non-200. Identical request, identity string as the first system block:
60+
61+ ```bash
62+ curl -sS -o /tmp/probe-identity.json -w '%{http_code}\n' https://api.anthropic.com/v1/messages \
63+ -H "authorization: Bearer $TOK" \
64+ -H "anthropic-version: 2023-06-01" \
65+ -H "anthropic-beta: oauth-2025-04-20" \
66+ -H "content-type: application/json" \
67+ -d '{"model":"claude-opus-5","max_tokens":16,"system":[{"type":"text","text":"You are Claude Code, Anthropic'"'"'s official CLI for Claude."},{"type":"text","text":"You are a coding agent operating on a local repository through tools."}],"messages":[{"role":"user","content":"Say OK."}]}'
68+ cat /tmp/probe-identity.json
69+ ```
70+
71+ A 200 here with a non-200 in Step 1 proves the identity block is required.
72+
73+ - [ ] **Step 3: Record the finding in the spec**
74+
75+ Replace the first paragraph of the spec's `## The system prompt` section with what you measured. State the status codes observed. If the block is not required, say so and note that Task 5 is skipped.
76+
77+ - [ ] **Step 4: Commit the finding**
78+
79+ ```bash
80+ cd /home/joni/.t3/worktrees/coding-harness/t3code-8b127a46
81+ git add docs/superpowers/specs/2026-09-17-claude-code-oauth-design.md
82+ git commit -m "Design: settle whether OAuth needs the Claude Code identity block
83+
84+ Probed with one request. Recorded the status codes rather than the
85+ inference, so the next person does not have to re-run it."
86+ ```
87+
88+ ---
89+
90+ ### Task 2: `internal/claudecode` — parse and expiry
91+
92+ Pure functions over a file. No network, no refresh.
93+
94+ **Files:**
95+ - Create: `internal/claudecode/claudecode.go`
96+ - Create: `internal/claudecode/claudecode_test.go`
97+
98+ **Interfaces:**
99+ - Consumes: nothing.
100+ - Produces:
101+ - `claudecode.Credentials{AccessToken, RefreshToken string; ExpiresAt, RefreshExpiresAt time.Time; Scopes []string; SubscriptionType string}`
102+ - `claudecode.Load(home string) (*Credentials, error)`
103+ - `(*Credentials).Expired(now time.Time) bool`
104+ - `(*Credentials).RefreshExpired(now time.Time) bool`
105+ - `claudecode.ErrNotFound` — sentinel, matched with `errors.Is`
106+ - `claudecode.Filename = ".claude/.credentials.json"`
107+
108+ - [ ] **Step 1: Write the failing tests**
109+
110+ Create `internal/claudecode/claudecode_test.go`:
111+
112+ ```go
113+ package claudecode
114+
115+ import (
116+ "errors"
117+ "os"
118+ "path/filepath"
119+ "testing"
120+ "time"
121+ )
122+
123+ // write builds a fake home directory holding one credentials file.
124+ func write(t *testing.T, body string) string {
125+ t.Helper()
126+ home := t.TempDir()
127+ dir := filepath.Join(home, ".claude")
128+ if err := os.MkdirAll(dir, 0o700); err != nil {
129+ t.Fatal(err)
130+ }
131+ if err := os.WriteFile(filepath.Join(dir, ".credentials.json"), []byte(body), 0o600); err != nil {
132+ t.Fatal(err)
133+ }
134+ return home
135+ }
136+
137+ const sample = `{"claudeAiOauth":{
138+ "accessToken":"sk-ant-oat01-access",
139+ "refreshToken":"sk-ant-ort01-refresh",
140+ "expiresAt":1789668269460,
141+ "refreshTokenExpiresAt":1791404074460,
142+ "scopes":["user:inference","user:profile"],
143+ "subscriptionType":"team"}}`
144+
145+ func TestLoadReadsEveryField(t *testing.T) {
146+ c, err := Load(write(t, sample))
147+ if err != nil {
148+ t.Fatalf("Load: %v", err)
149+ }
150+ if c.AccessToken != "sk-ant-oat01-access" {
151+ t.Errorf("AccessToken = %q", c.AccessToken)
152+ }
153+ if c.RefreshToken != "sk-ant-ort01-refresh" {
154+ t.Errorf("RefreshToken = %q", c.RefreshToken)
155+ }
156+ if c.SubscriptionType != "team" {
157+ t.Errorf("SubscriptionType = %q", c.SubscriptionType)
158+ }
159+ if len(c.Scopes) != 2 || c.Scopes[0] != "user:inference" {
160+ t.Errorf("Scopes = %v", c.Scopes)
161+ }
162+ }
163+
164+ // The file stores epoch milliseconds. Reading them as seconds puts expiry
165+ // 55,000 years out and the token never looks stale.
166+ func TestLoadTreatsTimestampsAsMilliseconds(t *testing.T) {
167+ c, err := Load(write(t, sample))
168+ if err != nil {
169+ t.Fatalf("Load: %v", err)
170+ }
171+ want := time.UnixMilli(1789668269460)
172+ if !c.ExpiresAt.Equal(want) {
173+ t.Errorf("ExpiresAt = %s, want %s", c.ExpiresAt, want)
174+ }
175+ if want := time.UnixMilli(1791404074460); !c.RefreshExpiresAt.Equal(want) {
176+ t.Errorf("RefreshExpiresAt = %s, want %s", c.RefreshExpiresAt, want)
177+ }
178+ }
179+
180+ // Not logged in is not a failure. It is a fall-through to the next source.
181+ func TestLoadMissingFileIsErrNotFound(t *testing.T) {
182+ _, err := Load(t.TempDir())
183+ if !errors.Is(err, ErrNotFound) {
184+ t.Fatalf("err = %v, want ErrNotFound", err)
185+ }
186+ }
187+
188+ // A file that exists but does not parse is a broken install, and saying so
189+ // with the path beats falling through to "no credentials".
190+ func TestLoadMalformedJSONNamesThePath(t *testing.T) {
191+ home := write(t, "{not json")
192+ _, err := Load(home)
193+ if err == nil || errors.Is(err, ErrNotFound) {
194+ t.Fatalf("err = %v, want a parse error", err)
195+ }
196+ if !strings.Contains(err.Error(), ".credentials.json") {
197+ t.Errorf("error should name the file, got %q", err)
198+ }
199+ }
200+
201+ // An empty access token is a file we cannot use, and it fails at parse time
202+ // rather than as a puzzling 401 later.
203+ func TestLoadEmptyAccessTokenIsAnError(t *testing.T) {
204+ _, err := Load(write(t, `{"claudeAiOauth":{"accessToken":""}}`))
205+ if err == nil || errors.Is(err, ErrNotFound) {
206+ t.Fatalf("err = %v, want an error about the missing token", err)
207+ }
208+ }
209+
210+ func TestExpiredUsesASkewMargin(t *testing.T) {
211+ base := time.Unix(1_700_000_000, 0)
212+ c := &Credentials{ExpiresAt: base}
213+ if c.Expired(base.Add(-5 * time.Minute)) {
214+ t.Error("a token with five minutes left is not expired")
215+ }
216+ // Inside the skew margin: it would expire during the request we are about
217+ // to send, so it counts as expired now.
218+ if !c.Expired(base.Add(-30 * time.Second)) {
219+ t.Error("a token expiring in 30s should count as expired")
220+ }
221+ if !c.Expired(base.Add(time.Minute)) {
222+ t.Error("a token past its expiry is expired")
223+ }
224+ }
225+
226+ func TestRefreshExpiredIsTerminal(t *testing.T) {
227+ base := time.Unix(1_700_000_000, 0)
228+ c := &Credentials{RefreshExpiresAt: base}
229+ if c.RefreshExpired(base.Add(-24 * time.Hour)) {
230+ t.Error("a refresh token with a day left is not expired")
231+ }
232+ if !c.RefreshExpired(base.Add(time.Hour)) {
233+ t.Error("a refresh token past its expiry is expired")
234+ }
235+ }
236+ ```
237+
238+ Add `"strings"` to that file's imports.
239+
240+ - [ ] **Step 2: Run the tests to verify they fail**
241+
242+ ```bash
243+ cd /home/joni/.t3/worktrees/coding-harness/t3code-8b127a46
244+ go test ./internal/claudecode/...
245+ ```
246+
247+ Expected: FAIL — the package does not build, `Load` and `Credentials` are undefined.
248+
249+ - [ ] **Step 3: Write the implementation**
250+
251+ Create `internal/claudecode/claudecode.go`:
252+
253+ ```go
254+ // Package claudecode reads the OAuth credential Claude Code stores on disk, so
255+ // a harness run can borrow a login that already exists.
256+ //
257+ // This is Claude Code's private storage, not a documented interface: the
258+ // schema can change without notice. Everything that knows its shape lives
259+ // here, and parses strictly, so a change surfaces as a clear error rather than
260+ // as a puzzling 401 somewhere else.
261+ package claudecode
262+
263+ import (
264+ "encoding/json"
265+ "errors"
266+ "fmt"
267+ "os"
268+ "path/filepath"
269+ "time"
270+ )
271+
272+ // Filename is the credential store, relative to the home directory.
273+ const Filename = ".claude/.credentials.json"
274+
275+ // skew is how long before its stated expiry a token starts counting as
276+ // expired. A token with seconds left would expire during the request we are
277+ // about to send.
278+ const skew = 60 * time.Second
279+
280+ // ErrNotFound means there is no credentials file: not logged in, rather than
281+ // broken. Callers fall through to whatever source is next.
282+ var ErrNotFound = errors.New("claudecode: no credentials file")
283+
284+ // Credentials is the claudeAiOauth object, with its millisecond timestamps
285+ // converted to something Go can compare.
286+ type Credentials struct {
287+ AccessToken string
288+ RefreshToken string
289+ ExpiresAt time.Time
290+ RefreshExpiresAt time.Time
291+ Scopes []string
292+ SubscriptionType string
293+ }
294+
295+ // stored mirrors the on-disk JSON. Timestamps are epoch milliseconds.
296+ type stored struct {
297+ ClaudeAIOAuth struct {
298+ AccessToken string `json:"accessToken"`
299+ RefreshToken string `json:"refreshToken"`
300+ ExpiresAt int64 `json:"expiresAt"`
301+ RefreshTokenExpiresAt int64 `json:"refreshTokenExpiresAt"`
302+ Scopes []string `json:"scopes"`
303+ SubscriptionType string `json:"subscriptionType"`
304+ } `json:"claudeAiOauth"`
305+ }
306+
307+ // Load reads and parses the credential store. home is passed in rather than
308+ // looked up so tests can point it at a fixture, matching skills.Discover.
309+ func Load(home string) (*Credentials, error) {
310+ path := filepath.Join(home, Filename)
311+ raw, err := os.ReadFile(path)
312+ if errors.Is(err, os.ErrNotExist) {
313+ return nil, ErrNotFound
314+ }
315+ if err != nil {
316+ return nil, fmt.Errorf("claudecode: reading %s: %w", path, err)
317+ }
318+ var s stored
319+ if err := json.Unmarshal(raw, &s); err != nil {
320+ return nil, fmt.Errorf("claudecode: parsing %s: %w", path, err)
321+ }
322+ o := s.ClaudeAIOAuth
323+ if o.AccessToken == "" {
324+ return nil, fmt.Errorf("claudecode: %s has no access token", path)
325+ }
326+ return &Credentials{
327+ AccessToken: o.AccessToken,
328+ RefreshToken: o.RefreshToken,
329+ ExpiresAt: time.UnixMilli(o.ExpiresAt),
330+ RefreshExpiresAt: time.UnixMilli(o.RefreshTokenExpiresAt),
331+ Scopes: o.Scopes,
332+ SubscriptionType: o.SubscriptionType,
333+ }, nil
334+ }
335+
336+ // Expired reports whether the access token is past its expiry, or close enough
337+ // that it would lapse mid-request.
338+ func (c *Credentials) Expired(now time.Time) bool {
339+ return !now.Before(c.ExpiresAt.Add(-skew))
340+ }
341+
342+ // RefreshExpired reports whether the refresh token is past its expiry. That is
343+ // terminal: no amount of refreshing fixes it, only logging in again.
344+ func (c *Credentials) RefreshExpired(now time.Time) bool {
345+ return !now.Before(c.RefreshExpiresAt)
346+ }
347+ ```
348+
349+ - [ ] **Step 4: Run the tests to verify they pass**
350+
351+ ```bash
352+ cd /home/joni/.t3/worktrees/coding-harness/t3code-8b127a46
353+ gofmt -w internal/claudecode/ && go test ./internal/claudecode/...
354+ ```
355+
356+ Expected: PASS, all seven tests.
357+
358+ - [ ] **Step 5: Commit**
359+
360+ ```bash
361+ cd /home/joni/.t3/worktrees/coding-harness/t3code-8b127a46
362+ git add internal/claudecode/
363+ git commit -m "claudecode: parse Claude Code's credential store
364+
365+ Timestamps are epoch milliseconds; reading them as seconds puts expiry
366+ 55,000 years out and the token never looks stale. A missing file means
367+ not logged in and falls through; a file that will not parse names its
368+ path, because that is a broken install rather than an absent one."
369+ ```
370+
371+ ---
372+
373+ ### Task 3: `llm.Client` resolves its credential per request
374+
375+ Today `authToken` is frozen at construction and both request paths repeat the
376+ same header logic. Resolve once per call, before the retry loop, and share one
377+ `apply`.
378+
379+ **Files:**
380+ - Modify: `internal/llm/client.go:56-58` (struct fields), `:74-80` (`WithAuthToken`), `:116` (`HasCredentials`), `:150-155` (`CreateMessage`), `:190-205` (`attempt`)
381+ - Modify: `internal/llm/stream.go:81-93` (`streamAttempt`), and `StreamMessage`'s loop above it
382+ - Modify: `internal/llm/client_test.go` (add tests)
383+
384+ **Interfaces:**
385+ - Consumes: nothing from earlier tasks.
386+ - Produces:
387+ - `llm.WithTokenSource(func(context.Context) (string, error)) Option`
388+ - unexported `auth` struct with `apply(http.Header)`, and `(*Client).resolveAuth(context.Context) (auth, error)`
389+ - `WithAuthToken` and `WithAPIKey` keep today's behaviour exactly.
390+
391+ - [ ] **Step 1: Write the failing tests**
392+
393+ Append to `internal/llm/client_test.go`:
394+
395+ ```go
396+ // A token source is consulted per request, so a credential that rotates
397+ // mid-session reaches the wire without rebuilding the client.
398+ func TestTokenSourceIsCalledPerRequest(t *testing.T) {
399+ var got []string
400+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
401+ got = append(got, r.Header.Get("authorization"))
402+ io.WriteString(w, okResponse)
403+ }))
404+ defer srv.Close()
405+
406+ n := 0
407+ c := New(WithBaseURL(srv.URL), WithTokenSource(func(context.Context) (string, error) {
408+ n++
409+ return fmt.Sprintf("tok-%d", n), nil
410+ }))
411+ for i := 0; i < 2; i++ {
412+ if _, err := c.CreateMessage(context.Background(), Request{}); err != nil {
413+ t.Fatalf("CreateMessage: %v", err)
414+ }
415+ }
416+ if len(got) != 2 || got[0] != "Bearer tok-1" || got[1] != "Bearer tok-2" {
417+ t.Errorf("authorization headers = %v, want Bearer tok-1 then tok-2", got)
418+ }
419+ }
420+
421+ func TestTokenSourceGetsTheOAuthBetaHeader(t *testing.T) {
422+ var got http.Header
423+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
424+ got = r.Header.Clone()
425+ io.WriteString(w, okResponse)
426+ }))
427+ defer srv.Close()
428+
429+ c := New(WithBaseURL(srv.URL), WithTokenSource(func(context.Context) (string, error) {
430+ return "tok", nil
431+ }))
432+ if _, err := c.CreateMessage(context.Background(), Request{}); err != nil {
433+ t.Fatalf("CreateMessage: %v", err)
434+ }
435+ if got.Get("anthropic-beta") != oauthBeta {
436+ t.Errorf("anthropic-beta = %q, want %s", got.Get("anthropic-beta"), oauthBeta)
437+ }
438+ if got.Get("x-api-key") != "" {
439+ t.Errorf("x-api-key should be unset, got %q", got.Get("x-api-key"))
440+ }
441+ }
442+
443+ // A credential that cannot be resolved is permanent. Retrying it three times
444+ // with backoff just delays the error, so resolution happens before the loop.
445+ func TestTokenSourceErrorIsNotRetried(t *testing.T) {
446+ var hits int
447+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
448+ hits++
449+ io.WriteString(w, okResponse)
450+ }))
451+ defer srv.Close()
452+
453+ var calls int
454+ c := New(WithBaseURL(srv.URL), WithTokenSource(func(context.Context) (string, error) {
455+ calls++
456+ return "", errors.New("login expired")
457+ }))
458+ c.RetryDelay = time.Millisecond
459+ _, err := c.CreateMessage(context.Background(), Request{})
460+ if err == nil || !strings.Contains(err.Error(), "login expired") {
461+ t.Fatalf("err = %v, want the source's error", err)
462+ }
463+ if calls != 1 {
464+ t.Errorf("token source called %d times, want 1", calls)
465+ }
466+ if hits != 0 {
467+ t.Errorf("server hit %d times, want 0 — no request should have been sent", hits)
468+ }
469+ }
470+
471+ func TestHasCredentialsCountsATokenSource(t *testing.T) {
472+ c := New(WithBaseURL("http://127.0.0.1:1"))
473+ c.apiKey, c.authToken = "", ""
474+ if c.HasCredentials() {
475+ t.Fatal("no credentials expected before the source is set")
476+ }
477+ c.tokenSource = func(context.Context) (string, error) { return "tok", nil }
478+ if !c.HasCredentials() {
479+ t.Error("a token source is a credential")
480+ }
481+ }
482+
483+ // The streaming path must authenticate identically to the buffered one.
484+ func TestStreamUsesTheTokenSource(t *testing.T) {
485+ var got http.Header
486+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
487+ got = r.Header.Clone()
488+ w.Header().Set("content-type", "text/event-stream")
489+ io.WriteString(w, streamOK)
490+ }))
491+ defer srv.Close()
492+
493+ c := New(WithBaseURL(srv.URL), WithTokenSource(func(context.Context) (string, error) {
494+ return "stream-tok", nil
495+ }))
496+ if _, err := c.StreamMessage(context.Background(), Request{}, func(StreamEvent) {}); err != nil {
497+ t.Fatalf("StreamMessage: %v", err)
498+ }
499+ if got.Get("authorization") != "Bearer stream-tok" {
500+ t.Errorf("authorization = %q", got.Get("authorization"))
501+ }
502+ if got.Get("anthropic-beta") != oauthBeta {
503+ t.Errorf("anthropic-beta = %q, want %s", got.Get("anthropic-beta"), oauthBeta)
504+ }
505+ }
506+ ```
507+
508+ `streamOK` does not exist. `stream_test.go:15` defines `sse(events ...string) string`,
509+ which formats events the way the API does — build the payload with it. A
510+ `message_stop` event alone is enough for a stream that ends cleanly:
511+
512+ ```go
513+ streamOK := sse(`{"type":"message_stop"}`)
514+ ```
515+
516+ Put that line at the top of the test body. If `readStream` rejects a stream with
517+ no `message_start`, prepend the `message_start` and `message_delta` events that
518+ `stream_test.go`'s existing happy-path test uses — copy them verbatim from there
519+ rather than inventing a shape.
520+
521+ Add `"errors"`, `"fmt"` and `"time"` to `client_test.go`'s imports if they are
522+ not already present.
523+
524+ - [ ] **Step 2: Run the tests to verify they fail**
525+
526+ ```bash
527+ cd /home/joni/.t3/worktrees/coding-harness/t3code-8b127a46
528+ go test ./internal/llm/...
529+ ```
530+
531+ Expected: FAIL — `WithTokenSource` and the `tokenSource` field are undefined.
532+
533+ - [ ] **Step 3: Add the field, the option, and the resolver**
534+
535+ In `internal/llm/client.go`, extend the struct's credential fields:
536+
537+ ```go
538+ apiKey string
539+ authToken string
540+ tokenSource func(context.Context) (string, error)
541+ ```
542+
543+ Add the option next to `WithAuthToken`:
544+
545+ ```go
546+ // WithTokenSource sets a bearer credential resolved per request, so a token
547+ // that expires mid-session can be replaced without rebuilding the client. A
548+ // nil function is ignored.
549+ func WithTokenSource(f func(context.Context) (string, error)) Option {
550+ return func(c *Client) {
551+ if f != nil {
552+ c.tokenSource, c.apiKey, c.authToken = f, "", ""
553+ }
554+ }
555+ }
556+ ```
557+
558+ Teach `HasCredentials` about it:
559+
560+ ```go
561+ // HasCredentials reports whether the client can authenticate.
562+ func (c *Client) HasCredentials() bool {
563+ return c.apiKey != "" || c.authToken != "" || c.tokenSource != nil
564+ }
565+ ```
566+
567+ Add the resolver and the shared header logic:
568+
569+ ```go
570+ // auth is one request's resolved credential. Resolving returns a value rather
571+ // than mutating the client so concurrent requests cannot interleave.
572+ type auth struct {
573+ apiKey string
574+ bearer string
575+ }
576+
577+ // apply writes the credential headers. An OAuth bearer also needs the beta
578+ // flag; an API key does not.
579+ func (a auth) apply(h http.Header) {
580+ if a.apiKey != "" {
581+ h.Set("x-api-key", a.apiKey)
582+ return
583+ }
584+ h.Set("authorization", "Bearer "+a.bearer)
585+ h.Set("anthropic-beta", oauthBeta)
586+ }
587+
588+ // resolveAuth produces the credential for one request. It runs before the
589+ // retry loop: a credential that will not resolve is permanent, and the loop
590+ // only short-circuits on a non-retryable *APIError, so resolving inside
591+ // attempt would retry a dead credential three times with backoff.
592+ func (c *Client) resolveAuth(ctx context.Context) (auth, error) {
593+ if c.apiKey != "" {
594+ return auth{apiKey: c.apiKey}, nil
595+ }
596+ if c.tokenSource != nil {
597+ tok, err := c.tokenSource(ctx)
598+ if err != nil {
599+ return auth{}, err
600+ }
601+ return auth{bearer: tok}, nil
602+ }
603+ return auth{bearer: c.authToken}, nil
604+ }
605+ ```
606+
607+ - [ ] **Step 4: Thread it through both request paths**
608+
609+ In `CreateMessage`, resolve before the retry loop — directly after the
610+ `HasCredentials` check:
611+
612+ ```go
613+ a, err := c.resolveAuth(ctx)
614+ if err != nil {
615+ return nil, err
616+ }
617+ ```
618+
619+ Change `attempt`'s signature to `func (c *Client) attempt(ctx context.Context, body []byte, a auth) (*Response, error)`, update its call site to `c.attempt(ctx, body, a)`, and replace its `switch`/`default` credential block with:
620+
621+ ```go
622+ a.apply(httpReq.Header)
623+ ```
624+
625+ Do the same in `stream.go`: resolve once in `StreamMessage` before its retry
626+ loop, change `streamAttempt` to take an `auth`, update the call site, and
627+ replace its `if c.apiKey != "" { … } else { … }` block with `a.apply(httpReq.Header)`.
628+
629+ `StreamMessage` needs the same up-front `HasCredentials` check as `CreateMessage`
630+ if it does not already have one — check, and add it if absent.
631+
632+ - [ ] **Step 5: Run the full test suite**
633+
634+ ```bash
635+ cd /home/joni/.t3/worktrees/coding-harness/t3code-8b127a46
636+ gofmt -w internal/llm/ && go build ./... && go test ./...
637+ ```
638+
639+ Expected: PASS. `TestOAuthTokenUsesBearerAndBetaHeader` and
640+ `TestNoCredentialsIsAClearError` must still pass untouched — the old options
641+ are now the constant case of the new one, and that is the point.
642+
643+ - [ ] **Step 6: Commit**
644+
645+ ```bash
646+ cd /home/joni/.t3/worktrees/coding-harness/t3code-8b127a46
647+ git add internal/llm/
648+ git commit -m "llm: resolve the credential per request
649+
650+ A frozen authToken cannot outlive its own expiry. WithTokenSource makes
651+ the bearer a function; WithAuthToken is now its constant case.
652+
653+ Resolution happens before the retry loop because a credential error is
654+ not an *APIError, and the loop only returns early for those — resolving
655+ inside attempt would retry a dead credential three times with backoff.
656+ Both request paths now share one apply instead of repeating the headers."
657+ ```
658+
659+ ---
660+
661+ ### Task 4: Fall back to the Claude Code login
662+
663+ The feature, end to end, without refresh. An expired token fails with an
664+ instruction rather than a 401.
665+
666+ **Files:**
667+ - Modify: `internal/claudecode/claudecode.go` (add `Source`)
668+ - Modify: `internal/claudecode/claudecode_test.go` (add tests)
669+ - Modify: `cmd/harness/main.go:38-72` (flag, client construction, fallback), `:296` (`errNoCredentials`)
670+ - Create: `cmd/harness/auth_test.go`
671+
672+ **Interfaces:**
673+ - Consumes: `claudecode.Load`, `(*Credentials).Expired`, `llm.WithTokenSource`, `llm.Client.HasCredentials`, `llm.Client.IsDefaultEndpoint`.
674+ - Produces:
675+ - `claudecode.NewSource(*Credentials) *Source`
676+ - `(*Source).Token(context.Context) (string, error)`
677+ - `claudecode.ErrExpired`
678+ - `resolveClaudeCode(client *llm.Client, home string, disabled bool) (func(context.Context) (string, error), string, error)` in `package main`
679+
680+ - [ ] **Step 1: Write the failing Source tests**
681+
682+ Append to `internal/claudecode/claudecode_test.go`:
683+
684+ ```go
685+ func TestSourceReturnsTheAccessToken(t *testing.T) {
686+ s := NewSource(&Credentials{
687+ AccessToken: "tok",
688+ ExpiresAt: time.Now().Add(time.Hour),
689+ })
690+ got, err := s.Token(context.Background())
691+ if err != nil {
692+ t.Fatalf("Token: %v", err)
693+ }
694+ if got != "tok" {
695+ t.Errorf("Token = %q, want tok", got)
696+ }
697+ }
698+
699+ // Until refresh lands, an expired token is a clear instruction rather than a
700+ // 401 from the API.
701+ func TestSourceExpiredTokenSaysWhatToDo(t *testing.T) {
702+ s := NewSource(&Credentials{
703+ AccessToken: "tok",
704+ ExpiresAt: time.Now().Add(-time.Hour),
705+ })
706+ _, err := s.Token(context.Background())
707+ if !errors.Is(err, ErrExpired) {
708+ t.Fatalf("err = %v, want ErrExpired", err)
709+ }
710+ if !strings.Contains(err.Error(), "claude") {
711+ t.Errorf("the error should say to run claude, got %q", err)
712+ }
713+ }
714+ ```
715+
716+ Add `"context"` to the imports.
717+
718+ - [ ] **Step 2: Run the tests to verify they fail**
719+
720+ ```bash
721+ cd /home/joni/.t3/worktrees/coding-harness/t3code-8b127a46
722+ go test ./internal/claudecode/...
723+ ```
724+
725+ Expected: FAIL — `NewSource` and `ErrExpired` undefined.
726+
727+ - [ ] **Step 3: Implement Source**
728+
729+ Append to `internal/claudecode/claudecode.go`:
730+
731+ ```go
732+ // ErrExpired means the stored access token has lapsed. Task 7 may replace this
733+ // with a refresh; until then it is the whole story.
734+ var ErrExpired = errors.New("claudecode: your Claude Code login has expired, run `claude` to refresh it")
735+
736+ // Source hands out the access token for as long as it is valid. It satisfies
737+ // the shape llm.WithTokenSource wants.
738+ type Source struct {
739+ creds *Credentials
740+ }
741+
742+ // NewSource wraps credentials in a token source.
743+ func NewSource(c *Credentials) *Source { return &Source{creds: c} }
744+
745+ // Token returns the access token, or ErrExpired once it has lapsed.
746+ func (s *Source) Token(context.Context) (string, error) {
747+ if s.creds.Expired(time.Now()) {
748+ return "", ErrExpired
749+ }
750+ return s.creds.AccessToken, nil
751+ }
752+ ```
753+
754+ Add `"context"` to the file's imports.
755+
756+ - [ ] **Step 4: Write the failing precedence tests**
757+
758+ Create `cmd/harness/auth_test.go`:
759+
760+ ```go
761+ package main
762+
763+ import (
764+ "context"
765+ "os"
766+ "path/filepath"
767+ "testing"
768+
769+ "harness/internal/llm"
770+ )
771+
772+ // fakeHome writes a credentials file with a token valid for an hour.
773+ func fakeHome(t *testing.T) string {
774+ t.Helper()
775+ home := t.TempDir()
776+ dir := filepath.Join(home, ".claude")
777+ if err := os.MkdirAll(dir, 0o700); err != nil {
778+ t.Fatal(err)
779+ }
780+ // expiresAt is 2035, comfortably in the future.
781+ body := `{"claudeAiOauth":{"accessToken":"file-token","refreshToken":"r",
782+ "expiresAt":2058430000000,"refreshTokenExpiresAt":2058430000000,
783+ "scopes":["user:inference"],"subscriptionType":"team"}}`
784+ if err := os.WriteFile(filepath.Join(dir, ".credentials.json"), []byte(body), 0o600); err != nil {
785+ t.Fatal(err)
786+ }
787+ return home
788+ }
789+
790+ func TestClaudeCodeFillsInAMissingCredential(t *testing.T) {
791+ c := llm.New(llm.WithBaseURL(llm.DefaultBaseURL))
792+ c.ClearCredentialsForTest()
793+ src, note, err := resolveClaudeCode(c, fakeHome(t), false)
794+ if err != nil {
795+ t.Fatalf("resolveClaudeCode: %v", err)
796+ }
797+ if src == nil {
798+ t.Fatal("expected a token source")
799+ }
800+ tok, err := src(context.Background())
801+ if err != nil {
802+ t.Fatalf("token: %v", err)
803+ }
804+ if tok != "file-token" {
805+ t.Errorf("token = %q, want file-token", tok)
806+ }
807+ if note == "" {
808+ t.Error("using a discovered credential should be announced")
809+ }
810+ }
811+
812+ // An explicit credential wins. We never override what the user typed.
813+ func TestClaudeCodeIsSkippedWhenACredentialExists(t *testing.T) {
814+ c := llm.New(llm.WithBaseURL(llm.DefaultBaseURL), llm.WithAPIKey("sk-ant-explicit"))
815+ src, _, err := resolveClaudeCode(c, fakeHome(t), false)
816+ if err != nil {
817+ t.Fatalf("resolveClaudeCode: %v", err)
818+ }
819+ if src != nil {
820+ t.Error("an explicit credential must not be replaced by the file")
821+ }
822+ }
823+
824+ // The security property: a credential we found is never sent to an endpoint
825+ // the user pointed us at. "We did not send it" is not observable from outside,
826+ // so it is a test rather than a comment.
827+ func TestClaudeCodeIsNeverSentToANonDefaultEndpoint(t *testing.T) {
828+ c := llm.New(llm.WithBaseURL("http://gateway.example"))
829+ c.ClearCredentialsForTest()
830+ src, _, err := resolveClaudeCode(c, fakeHome(t), false)
831+ if err != nil {
832+ t.Fatalf("resolveClaudeCode: %v", err)
833+ }
834+ if src != nil {
835+ t.Fatal("a discovered credential must never go to a non-default endpoint")
836+ }
837+ }
838+
839+ func TestClaudeCodeCanBeDisabled(t *testing.T) {
840+ c := llm.New(llm.WithBaseURL(llm.DefaultBaseURL))
841+ c.ClearCredentialsForTest()
842+ src, _, err := resolveClaudeCode(c, fakeHome(t), true)
843+ if err != nil {
844+ t.Fatalf("resolveClaudeCode: %v", err)
845+ }
846+ if src != nil {
847+ t.Error("-no-claude-code-auth must skip discovery entirely")
848+ }
849+ }
850+
851+ // Not logged in is not an error. It falls through to errNoCredentials.
852+ func TestClaudeCodeMissingFileIsNotAnError(t *testing.T) {
853+ c := llm.New(llm.WithBaseURL(llm.DefaultBaseURL))
854+ c.ClearCredentialsForTest()
855+ src, _, err := resolveClaudeCode(c, t.TempDir(), false)
856+ if err != nil {
857+ t.Fatalf("a missing file is not an error, got %v", err)
858+ }
859+ if src != nil {
860+ t.Error("no file means no source")
861+ }
862+ }
863+ ```
864+
865+ - [ ] **Step 5: Add the test helper the tests need**
866+
867+ `llm.New` reads `ANTHROPIC_API_KEY` from the environment, so a developer with
868+ that set would see these tests pass for the wrong reason. Add to
869+ `internal/llm/client.go`:
870+
871+ ```go
872+ // ClearCredentialsForTest drops any credential read from the environment, so a
873+ // test exercising credential precedence is not at the mercy of the developer's
874+ // own shell.
875+ func (c *Client) ClearCredentialsForTest() {
876+ c.apiKey, c.authToken, c.tokenSource = "", "", nil
877+ }
878+ ```
879+
880+ - [ ] **Step 6: Run the tests to verify they fail**
881+
882+ ```bash
883+ cd /home/joni/.t3/worktrees/coding-harness/t3code-8b127a46
884+ go test ./cmd/... ./internal/claudecode/...
885+ ```
886+
887+ Expected: FAIL — `resolveClaudeCode` undefined.
888+
889+ - [ ] **Step 7: Implement the fallback in main.go**
890+
891+ Add the flag beside `-no-skills`:
892+
893+ ```go
894+ noClaudeCodeAuth := flag.Bool("no-claude-code-auth", false, "do not fall back to the OAuth login in ~/"+claudecode.Filename)
895+ ```
896+
897+ Add the resolver function to `cmd/harness/main.go`:
898+
899+ ```go
900+ // resolveClaudeCode returns a token source borrowed from Claude Code's login,
901+ // or nil when one should not be used. The second return is a line for the
902+ // user: a borrowed credential is worth announcing, the same way a non-default
903+ // endpoint is.
904+ //
905+ // Two conditions gate it. The client must have no credential already, because
906+ // we never override what the user typed. And the endpoint must be Anthropic's,
907+ // because a credential we found on disk is not one the user chose to send —
908+ // forwarding it to a -base-url someone else operates would leak it to a third
909+ // party because a file happened to exist.
910+ func resolveClaudeCode(client *llm.Client, home string, disabled bool) (func(context.Context) (string, error), string, error) {
911+ if disabled || client.HasCredentials() || !client.IsDefaultEndpoint() {
912+ return nil, "", nil
913+ }
914+ creds, err := claudecode.Load(home)
915+ if errors.Is(err, claudecode.ErrNotFound) {
916+ return nil, "", nil
917+ }
918+ if err != nil {
919+ return nil, "", err
920+ }
921+ note := fmt.Sprintf("using your Claude Code login (%s plan) from ~/%s", creds.SubscriptionType, claudecode.Filename)
922+ return claudecode.NewSource(creds).Token, note, nil
923+ }
924+ ```
925+
926+ Rework the client construction to allow a second pass:
927+
928+ ```go
929+ opts := []llm.Option{
930+ llm.WithModel(*model),
931+ llm.WithBaseURL(*baseURL),
932+ llm.WithAPIKey(*apiKey),
933+ llm.WithAuthToken(*authToken),
934+ }
935+ client := llm.New(opts...)
936+
937+ home, err := os.UserHomeDir()
938+ if err != nil {
939+ // No home directory is not fatal: an explicit credential still works.
940+ home = ""
941+ }
942+ var authNote string
943+ if home != "" {
944+ src, note, err := resolveClaudeCode(client, home, *noClaudeCodeAuth)
945+ if err != nil {
946+ return err
947+ }
948+ if src != nil {
949+ client = llm.New(append(opts, llm.WithTokenSource(src))...)
950+ authNote = note
951+ }
952+ }
953+ if !client.HasCredentials() {
954+ return errNoCredentials
955+ }
956+ ```
957+
958+ `notice` is defined further down, after `useTUI` is known, so report the note there:
959+
960+ ```go
961+ if authNote != "" {
962+ notice("%s", authNote)
963+ }
964+ ```
965+
966+ Put that next to the existing `if !client.IsDefaultEndpoint()` notice.
967+
968+ The existing skills block calls `os.UserHomeDir()` too — reuse the `home` you
969+ just computed rather than calling it twice, and delete the now-duplicate lookup.
970+
971+ Extend `errNoCredentials` with the new route:
972+
973+ ```go
974+ var errNoCredentials = errors.New(`no credentials. Supply one of:
975+ export ANTHROPIC_API_KEY=sk-ant-... (sent as x-api-key)
976+ export ANTHROPIC_AUTH_TOKEN=... (sent as Authorization: Bearer)
977+ -api-key / -auth-token (flags override the environment)
978+ log in with Claude Code (borrowed automatically, Anthropic endpoint only)
979+ To use a non-Anthropic endpoint, set ANTHROPIC_BASE_URL or -base-url as well.`)
980+ ```
981+
982+ Add `"harness/internal/claudecode"` to the imports.
983+
984+ - [ ] **Step 8: Run the full suite**
985+
986+ ```bash
987+ cd /home/joni/.t3/worktrees/coding-harness/t3code-8b127a46
988+ gofmt -w cmd/ internal/ && go build ./... && go test ./...
989+ ```
990+
991+ Expected: PASS.
992+
993+ - [ ] **Step 9: Verify by hand that it actually starts**
994+
995+ ```bash
996+ cd /home/joni/.t3/worktrees/coding-harness/t3code-8b127a46
997+ go build -o /tmp/harness ./cmd/harness
998+ env -u ANTHROPIC_API_KEY -u ANTHROPIC_AUTH_TOKEN /tmp/harness -C /tmp -no-tui -p "reply with the single word: ok"
999+ ```
1000+
1001+ Expected: stderr says `harness: using your Claude Code login (team plan) …`, and
1002+ the model replies. If Task 1 found the identity block is required, this fails
1003+ with a 401 until Task 5 lands — that is expected, and it is the point of doing
1004+ Task 5 next. Record which you saw.
1005+
1006+ Also confirm the guard holds:
1007+
1008+ ```bash
1009+ env -u ANTHROPIC_API_KEY -u ANTHROPIC_AUTH_TOKEN /tmp/harness -C /tmp -no-tui -base-url http://127.0.0.1:9 -p "hi"
1010+ ```
1011+
1012+ Expected: `no credentials` — not a connection error. The file was never read.
1013+
1014+ - [ ] **Step 10: Commit**
1015+
1016+ ```bash
1017+ cd /home/joni/.t3/worktrees/coding-harness/t3code-8b127a46
1018+ git add cmd/harness/ internal/claudecode/ internal/llm/
1019+ git commit -m "harness: borrow the Claude Code login when nothing else is set
1020+
1021+ Logged into Claude Code means ./harness starts with no arguments.
1022+
1023+ Two gates. An explicit credential is never overridden, and the file is
1024+ read only when the endpoint is Anthropic's: a credential we found on
1025+ disk is not one the user chose to send, and forwarding it to a -base-url
1026+ someone else runs would leak it because a file happened to exist.
1027+
1028+ No refresh yet — an expired token says to run claude."
1029+ ```
1030+
1031+ ---
1032+
1033+ ### Task 5: The Claude Code identity block
1034+
1035+ **Skip this task entirely if Task 1 found the identity block is not required.**
1036+ If you skip it, say so in the final report.
1037+
1038+ **Files:**
1039+ - Modify: `internal/agent/loop.go:57-77` (`Options`), `:301` and `:318` (request assembly)
1040+ - Modify: `internal/agent/loop_test.go` (add tests)
1041+ - Modify: `cmd/harness/main.go:149-157` (pass it)
1042+
1043+ **Interfaces:**
1044+ - Consumes: `resolveClaudeCode`'s non-nil source as the signal that the credential is OAuth-sourced.
1045+ - Produces: `agent.Options.SystemPrefix string` and `agent.ClaudeCodeIdentity`.
1046+
1047+ - [ ] **Step 1: Write the failing tests**
1048+
1049+ Append to `internal/agent/loop_test.go`:
1050+
1051+ ```go
1052+ func TestSystemPrefixBecomesTheFirstBlock(t *testing.T) {
1053+ a := New(nil, tools.NewRegistry(nil), NewState(), nil, Options{SystemPrefix: ClaudeCodeIdentity})
1054+ blocks := a.systemBlocks()
1055+ if len(blocks) != 2 {
1056+ t.Fatalf("got %d system blocks, want 2", len(blocks))
1057+ }
1058+ if blocks[0].Text != ClaudeCodeIdentity {
1059+ t.Errorf("first block = %q, want the identity string", blocks[0].Text)
1060+ }
1061+ if !strings.Contains(blocks[1].Text, "coding agent") {
1062+ t.Errorf("second block should be the harness prompt, got %q", blocks[1].Text)
1063+ }
1064+ }
1065+
1066+ // The cache breakpoint has to sit on the last block, or only the identity
1067+ // string gets cached and the prompt behind it is re-read every turn.
1068+ func TestCacheBreakpointIsOnTheLastSystemBlock(t *testing.T) {
1069+ a := New(nil, tools.NewRegistry(nil), NewState(), nil, Options{SystemPrefix: ClaudeCodeIdentity})
1070+ blocks := a.systemBlocks()
1071+ if blocks[0].CacheControl != nil {
1072+ t.Error("the first block must not carry the breakpoint")
1073+ }
1074+ if blocks[len(blocks)-1].CacheControl == nil {
1075+ t.Error("the last block must carry the breakpoint")
1076+ }
1077+ }
1078+
1079+ // Without OAuth nothing changes, including byte-for-byte.
1080+ func TestNoPrefixMeansOneBlockAsBefore(t *testing.T) {
1081+ a := New(nil, tools.NewRegistry(nil), NewState(), nil, Options{})
1082+ blocks := a.systemBlocks()
1083+ if len(blocks) != 1 {
1084+ t.Fatalf("got %d system blocks, want 1", len(blocks))
1085+ }
1086+ if blocks[0].Text != defaultSystem {
1087+ t.Error("the prompt must be unchanged when no prefix is set")
1088+ }
1089+ }
1090+ ```
1091+
1092+ This is the constructor shape `TestSystemPromptListsSkills` at `loop_test.go:494`
1093+ already uses, so `tools` and `skills` are already imported in that file.
1094+
1095+ - [ ] **Step 2: Run the tests to verify they fail**
1096+
1097+ ```bash
1098+ cd /home/joni/.t3/worktrees/coding-harness/t3code-8b127a46
1099+ go test ./internal/agent/...
1100+ ```
1101+
1102+ Expected: FAIL — `SystemPrefix`, `ClaudeCodeIdentity` and `systemBlocks` undefined.
1103+
1104+ - [ ] **Step 3: Implement it**
1105+
1106+ Add to `internal/agent/loop.go`, next to `defaultSystem`:
1107+
1108+ ```go
1109+ // ClaudeCodeIdentity is what an OAuth-authenticated request must present as
1110+ // its first system block. It is a fixed string, so it costs no cache hit: the
1111+ // prefix is still constant for the session.
1112+ const ClaudeCodeIdentity = "You are Claude Code, Anthropic's official CLI for Claude."
1113+ ```
1114+
1115+ Add the field to `Options`:
1116+
1117+ ```go
1118+ // SystemPrefix is prepended as a separate system block ahead of the
1119+ // harness prompt. It carries the identity string an OAuth credential
1120+ // requires, and is empty for every other credential.
1121+ SystemPrefix string
1122+ ```
1123+
1124+ Extract the block assembly out of the request builder at `loop.go:301`:
1125+
1126+ ```go
1127+ // systemBlocks builds the system prompt. The cache breakpoint goes on the last
1128+ // block so everything ahead of it — tools, identity, prompt — is cached
1129+ // together; on the first block it would cache the identity string and re-read
1130+ // the rest every turn.
1131+ func (a *Agent) systemBlocks() []llm.SystemBlock {
1132+ var blocks []llm.SystemBlock
1133+ if a.opts.SystemPrefix != "" {
1134+ blocks = append(blocks, llm.SystemBlock{Type: "text", Text: a.opts.SystemPrefix})
1135+ }
1136+ blocks = append(blocks, llm.SystemBlock{Type: "text", Text: a.opts.System})
1137+ if !a.opts.NoCache {
1138+ blocks[len(blocks)-1].CacheControl = llm.Ephemeral()
1139+ }
1140+ return blocks
1141+ }
1142+ ```
1143+
1144+ Then in the request builder, replace the `system := llm.SystemBlock{…}` line and
1145+ its `system.CacheControl = llm.Ephemeral()` inside the `if !a.opts.NoCache`
1146+ block with a call to `a.systemBlocks()`, and set `System: a.systemBlocks()` in
1147+ the `llm.Request`. Leave the messages-side breakpoint in that `if` exactly as it is.
1148+
1149+ - [ ] **Step 4: Run the tests to verify they pass**
1150+
1151+ ```bash
1152+ cd /home/joni/.t3/worktrees/coding-harness/t3code-8b127a46
1153+ gofmt -w internal/agent/ && go test ./internal/agent/...
1154+ ```
1155+
1156+ Expected: PASS, including the existing `TestSystemPromptIsStableAcrossRequests`.
1157+
1158+ - [ ] **Step 5: Pass it from main.go**
1159+
1160+ Track whether the credential came from Claude Code, and set the prefix:
1161+
1162+ ```go
1163+ a := agent.New(client, reg, state, log, agent.Options{
1164+ MaxTurns: *maxTurns,
1165+ Effort: *effort,
1166+ ContextWindow: *contextWindow,
1167+ CompactAt: *compactAt,
1168+ NoCache: *noCache,
1169+ Stream: !*noStream,
1170+ Skills: found,
1171+ SystemPrefix: systemPrefix,
1172+ })
1173+ ```
1174+
1175+ where `systemPrefix` is set to `agent.ClaudeCodeIdentity` in the same branch that
1176+ set `authNote`, and is otherwise empty. There is a second `agent.Options` literal
1177+ at `main.go:185` — set `SystemPrefix` there too, or the resumed-session path
1178+ authenticates differently from the fresh one.
1179+
1180+ - [ ] **Step 6: Verify end to end**
1181+
1182+ ```bash
1183+ cd /home/joni/.t3/worktrees/coding-harness/t3code-8b127a46
1184+ go build -o /tmp/harness ./cmd/harness
1185+ env -u ANTHROPIC_API_KEY -u ANTHROPIC_AUTH_TOKEN /tmp/harness -C /tmp -no-tui -p "reply with the single word: ok"
1186+ ```
1187+
1188+ Expected: the model replies. This is the request that 401'd at the end of Task 4.
1189+
1190+ - [ ] **Step 7: Commit**
1191+
1192+ ```bash
1193+ cd /home/joni/.t3/worktrees/coding-harness/t3code-8b127a46
1194+ gofmt -w cmd/ internal/ && go build ./... && go test ./...
1195+ git add cmd/harness/ internal/agent/
1196+ git commit -m "agent: the Claude Code identity block for OAuth requests
1197+
1198+ An OAuth credential requires the identity string as the first system
1199+ block. It is static, so the cached prefix survives — but the breakpoint
1200+ moves to the last block, or only the identity string would be cached
1201+ and the real prompt re-read every turn."
1202+ ```
1203+
1204+ ---
1205+
1206+ ### Task 6: Probe — does the token endpoint rotate refresh tokens?
1207+
1208+ **This task ends in a report, not code. It has a STOP gate.**
1209+
1210+ The risk: if the endpoint returns a *new* refresh token, then refreshing without
1211+ writing it back consumes the one Claude Code has on disk. Its copy goes dead and
1212+ `claude` asks for a fresh login. That is damage outside this repo, so it gets
1213+ measured before anything depends on it.
1214+
1215+ **Files:**
1216+ - Modify: `docs/superpowers/specs/2026-09-17-claude-code-oauth-design.md` (record the finding)
1217+
1218+ **Interfaces:**
1219+ - Consumes: nothing.
1220+ - Produces: the gate for Task 7.
1221+
1222+ - [ ] **Step 1: Warn the user before spending the refresh token**
1223+
1224+ Tell them, in these terms: this probe consumes one refresh. If the endpoint
1225+ rotates, their Claude Code login will need `claude` run once to recover. Get a
1226+ yes before Step 2. They approved the probe in principle when they chose "find
1227+ out first" — this is the confirmation that it is happening now.
1228+
1229+ - [ ] **Step 2: Refresh once and compare**
1230+
1231+ ```bash
1232+ OLD=$(python3 -c "import json;print(json.load(open('$HOME/.claude/.credentials.json'))['claudeAiOauth']['refreshToken'])")
1233+ curl -sS -o /tmp/refresh.json -w '%{http_code}\n' https://console.anthropic.com/v1/oauth/token \
1234+ -H "content-type: application/json" \
1235+ -d "{\"grant_type\":\"refresh_token\",\"refresh_token\":\"$OLD\",\"client_id\":\"9d1c250a-e61b-44d9-88ed-5944d1962f5e\"}"
1236+ python3 - <<'EOF'
1237+ import json, os
1238+ r = json.load(open('/tmp/refresh.json'))
1239+ old = json.load(open(os.path.expanduser('~/.claude/.credentials.json')))['claudeAiOauth']['refreshToken']
1240+ new = r.get('refresh_token')
1241+ print('keys:', sorted(r))
1242+ print('returned a refresh_token:', new is not None)
1243+ print('ROTATED' if new and new != old else 'NOT ROTATED')
1244+ EOF
1245+ ```
1246+
1247+ If the endpoint 404s or rejects the client id, that is itself the finding: the
1248+ refresh route is not what we assumed. Record it and stop — do not go hunting for
1249+ another endpoint without asking.
1250+
1251+ - [ ] **Step 3: Record the finding in the spec**
1252+
1253+ Replace the spec's `## Refresh, and an open question that gates it` open-question
1254+ paragraphs with what you measured: the status code, whether a `refresh_token`
1255+ came back, and whether it differed.
1256+
1257+ - [ ] **Step 4: STOP if it rotated, or if the endpoint was not what we assumed**
1258+
1259+ Report to the user. Do not start Task 7. The remaining choice — write-back with
1260+ atomic rename, or no refresh at all — is theirs, and the spec says so explicitly.
1261+
1262+ If it did **not** rotate, continue to Task 7.
1263+
1264+ - [ ] **Step 5: Commit the finding**
1265+
1266+ ```bash
1267+ cd /home/joni/.t3/worktrees/coding-harness/t3code-8b127a46
1268+ git add docs/superpowers/specs/2026-09-17-claude-code-oauth-design.md
1269+ git commit -m "Design: settle whether the token endpoint rotates refresh tokens
1270+
1271+ Measured rather than assumed, because guessing wrong here breaks the
1272+ user's Claude Code login rather than this harness."
1273+ ```
1274+
1275+ ---
1276+
1277+ ### Task 7: Refresh on expiry
1278+
1279+ **Only if Task 6 reported NOT ROTATED.** Otherwise this task does not exist yet.
1280+
1281+ **Files:**
1282+ - Modify: `internal/claudecode/claudecode.go` (add `Refresh`, make `Source` refresh)
1283+ - Modify: `internal/claudecode/claudecode_test.go`
1284+ - Modify: `cmd/harness/main.go` (give `NewSource` an HTTP client)
1285+
1286+ **Interfaces:**
1287+ - Consumes: `Credentials`, `Source`, `ErrExpired`, `ErrNotFound`.
1288+ - Produces:
1289+ - `claudecode.Refresh(ctx context.Context, h *http.Client, c *Credentials) (*Credentials, error)`
1290+ - `claudecode.TokenURL` and `claudecode.ClientID` constants
1291+ - `NewSource` gains a second parameter: `NewSource(c *Credentials, h *http.Client) *Source`
1292+
1293+ **Breaking change to fix as you go:** Task 4's `TestSourceReturnsTheAccessToken`
1294+ and `TestSourceExpiredTokenSaysWhatToDo` call `NewSource` with one argument, and
1295+ will stop compiling. Pass `nil` as the second argument in both — neither test
1296+ refreshes, and `NewSource` substitutes `http.DefaultClient` for a nil client.
1297+
1298+ - [ ] **Step 1: Write the failing tests**
1299+
1300+ Append to `internal/claudecode/claudecode_test.go`:
1301+
1302+ ```go
1303+ func TestRefreshExchangesTheToken(t *testing.T) {
1304+ var body map[string]any
1305+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1306+ json.NewDecoder(r.Body).Decode(&body)
1307+ io.WriteString(w, `{"access_token":"new-access","refresh_token":"new-refresh","expires_in":28800}`)
1308+ }))
1309+ defer srv.Close()
1310+ t.Setenv("CLAUDECODE_TOKEN_URL", srv.URL)
1311+
1312+ old := &Credentials{AccessToken: "old", RefreshToken: "old-refresh"}
1313+ got, err := Refresh(context.Background(), srv.Client(), old)
1314+ if err != nil {
1315+ t.Fatalf("Refresh: %v", err)
1316+ }
1317+ if got.AccessToken != "new-access" {
1318+ t.Errorf("AccessToken = %q", got.AccessToken)
1319+ }
1320+ if body["grant_type"] != "refresh_token" || body["refresh_token"] != "old-refresh" {
1321+ t.Errorf("request body = %v", body)
1322+ }
1323+ if !got.ExpiresAt.After(time.Now().Add(7 * time.Hour)) {
1324+ t.Errorf("expires_in should have become an absolute time, got %s", got.ExpiresAt)
1325+ }
1326+ }
1327+
1328+ // A refresh token the server rejects is terminal: only a fresh login fixes it.
1329+ func TestRefreshRejectedSaysToLogInAgain(t *testing.T) {
1330+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1331+ w.WriteHeader(http.StatusBadRequest)
1332+ io.WriteString(w, `{"error":"invalid_grant"}`)
1333+ }))
1334+ defer srv.Close()
1335+ t.Setenv("CLAUDECODE_TOKEN_URL", srv.URL)
1336+
1337+ _, err := Refresh(context.Background(), srv.Client(), &Credentials{RefreshToken: "dead"})
1338+ if err == nil {
1339+ t.Fatal("a rejected refresh must be an error")
1340+ }
1341+ if !strings.Contains(err.Error(), "claude") {
1342+ t.Errorf("the error should say to log in again, got %q", err)
1343+ }
1344+ }
1345+
1346+ func TestRefreshTransportErrorIsReported(t *testing.T) {
1347+ t.Setenv("CLAUDECODE_TOKEN_URL", "http://127.0.0.1:1")
1348+ _, err := Refresh(context.Background(), &http.Client{Timeout: time.Second}, &Credentials{RefreshToken: "r"})
1349+ if err == nil {
1350+ t.Fatal("a transport error must be reported")
1351+ }
1352+ }
1353+
1354+ // The whole point: an expired token becomes a working one without the caller
1355+ // knowing anything happened.
1356+ func TestSourceRefreshesOnExpiry(t *testing.T) {
1357+ var hits int
1358+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1359+ hits++
1360+ io.WriteString(w, `{"access_token":"fresh","refresh_token":"r2","expires_in":28800}`)
1361+ }))
1362+ defer srv.Close()
1363+ t.Setenv("CLAUDECODE_TOKEN_URL", srv.URL)
1364+
1365+ s := NewSource(&Credentials{
1366+ AccessToken: "stale",
1367+ RefreshToken: "r1",
1368+ ExpiresAt: time.Now().Add(-time.Hour),
1369+ RefreshExpiresAt: time.Now().Add(24 * time.Hour),
1370+ }, srv.Client())
1371+
1372+ for i := 0; i < 3; i++ {
1373+ tok, err := s.Token(context.Background())
1374+ if err != nil {
1375+ t.Fatalf("Token: %v", err)
1376+ }
1377+ if tok != "fresh" {
1378+ t.Fatalf("Token = %q, want fresh", tok)
1379+ }
1380+ }
1381+ if hits != 1 {
1382+ t.Errorf("refreshed %d times, want 1 — the result should be cached", hits)
1383+ }
1384+ }
1385+
1386+ // An expired refresh token cannot be refreshed. Fail with instructions rather
1387+ // than sending a request that is certain to be rejected.
1388+ func TestSourceExpiredRefreshTokenDoesNotCallOut(t *testing.T) {
1389+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1390+ t.Error("no request should be sent for a dead refresh token")
1391+ }))
1392+ defer srv.Close()
1393+ t.Setenv("CLAUDECODE_TOKEN_URL", srv.URL)
1394+
1395+ s := NewSource(&Credentials{
1396+ AccessToken: "stale",
1397+ RefreshToken: "r",
1398+ ExpiresAt: time.Now().Add(-time.Hour),
1399+ RefreshExpiresAt: time.Now().Add(-time.Hour),
1400+ }, srv.Client())
1401+ if _, err := s.Token(context.Background()); !errors.Is(err, ErrExpired) {
1402+ t.Fatalf("err = %v, want ErrExpired", err)
1403+ }
1404+ }
1405+
1406+ func TestSourceConcurrentTokensRefreshOnce(t *testing.T) {
1407+ var mu sync.Mutex
1408+ var hits int
1409+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1410+ mu.Lock()
1411+ hits++
1412+ mu.Unlock()
1413+ io.WriteString(w, `{"access_token":"fresh","refresh_token":"r2","expires_in":28800}`)
1414+ }))
1415+ defer srv.Close()
1416+ t.Setenv("CLAUDECODE_TOKEN_URL", srv.URL)
1417+
1418+ s := NewSource(&Credentials{
1419+ AccessToken: "stale",
1420+ RefreshToken: "r1",
1421+ ExpiresAt: time.Now().Add(-time.Hour),
1422+ RefreshExpiresAt: time.Now().Add(24 * time.Hour),
1423+ }, srv.Client())
1424+
1425+ var wg sync.WaitGroup
1426+ for i := 0; i < 8; i++ {
1427+ wg.Add(1)
1428+ go func() {
1429+ defer wg.Done()
1430+ if _, err := s.Token(context.Background()); err != nil {
1431+ t.Error(err)
1432+ }
1433+ }()
1434+ }
1435+ wg.Wait()
1436+ if hits != 1 {
1437+ t.Errorf("refreshed %d times under concurrency, want 1", hits)
1438+ }
1439+ }
1440+ ```
1441+
1442+ Add `"encoding/json"`, `"io"`, `"net/http"`, `"net/http/httptest"` and `"sync"`
1443+ to the imports.
1444+
1445+ - [ ] **Step 2: Run the tests to verify they fail**
1446+
1447+ ```bash
1448+ cd /home/joni/.t3/worktrees/coding-harness/t3code-8b127a46
1449+ go test ./internal/claudecode/...
1450+ ```
1451+
1452+ Expected: FAIL — `Refresh` undefined, and `NewSource` takes one argument.
1453+
1454+ - [ ] **Step 3: Implement Refresh and make Source use it**
1455+
1456+ In `internal/claudecode/claudecode.go`:
1457+
1458+ ```go
1459+ // TokenURL is the OAuth token endpoint. The environment variable exists so
1460+ // tests can point it at an httptest server; nothing else should set it.
1461+ var TokenURL = func() string {
1462+ if u := os.Getenv("CLAUDECODE_TOKEN_URL"); u != "" {
1463+ return u
1464+ }
1465+ return "https://console.anthropic.com/v1/oauth/token"
1466+ }
1467+
1468+ // ClientID identifies Claude Code to the token endpoint.
1469+ const ClientID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
1470+
1471+ // Refresh exchanges the refresh token for a new access token. It returns new
1472+ // credentials and writes nothing: the file belongs to Claude Code, and two
1473+ // writers racing over it is a worse problem than a token resolved twice.
1474+ func Refresh(ctx context.Context, h *http.Client, c *Credentials) (*Credentials, error) {
1475+ body, err := json.Marshal(map[string]string{
1476+ "grant_type": "refresh_token",
1477+ "refresh_token": c.RefreshToken,
1478+ "client_id": ClientID,
1479+ })
1480+ if err != nil {
1481+ return nil, err
1482+ }
1483+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, TokenURL(), bytes.NewReader(body))
1484+ if err != nil {
1485+ return nil, err
1486+ }
1487+ req.Header.Set("content-type", "application/json")
1488+ resp, err := h.Do(req)
1489+ if err != nil {
1490+ return nil, fmt.Errorf("claudecode: refreshing: %w", err)
1491+ }
1492+ defer resp.Body.Close()
1493+ raw, err := io.ReadAll(resp.Body)
1494+ if err != nil {
1495+ return nil, fmt.Errorf("claudecode: reading refresh response: %w", err)
1496+ }
1497+ if resp.StatusCode != http.StatusOK {
1498+ return nil, fmt.Errorf("claudecode: refresh rejected (%d): run `claude` to log in again: %s", resp.StatusCode, strings.TrimSpace(string(raw)))
1499+ }
1500+ var out struct {
1501+ AccessToken string `json:"access_token"`
1502+ RefreshToken string `json:"refresh_token"`
1503+ ExpiresIn int64 `json:"expires_in"`
1504+ }
1505+ if err := json.Unmarshal(raw, &out); err != nil {
1506+ return nil, fmt.Errorf("claudecode: decoding refresh response: %w", err)
1507+ }
1508+ if out.AccessToken == "" {
1509+ return nil, errors.New("claudecode: refresh returned no access token")
1510+ }
1511+ next := *c
1512+ next.AccessToken = out.AccessToken
1513+ if out.RefreshToken != "" {
1514+ next.RefreshToken = out.RefreshToken
1515+ }
1516+ next.ExpiresAt = time.Now().Add(time.Duration(out.ExpiresIn) * time.Second)
1517+ return &next, nil
1518+ }
1519+ ```
1520+
1521+ Replace `Source` with the refreshing version:
1522+
1523+ ```go
1524+ // Source hands out an access token, refreshing it when it lapses. The refresh
1525+ // is kept in memory for the process lifetime and never written back: Claude
1526+ // Code owns that file and stays its only writer.
1527+ type Source struct {
1528+ mu sync.Mutex
1529+ creds *Credentials
1530+ http *http.Client
1531+ }
1532+
1533+ // NewSource wraps credentials in a token source. h is the client used for
1534+ // refreshes.
1535+ func NewSource(c *Credentials, h *http.Client) *Source {
1536+ if h == nil {
1537+ h = http.DefaultClient
1538+ }
1539+ return &Source{creds: c, http: h}
1540+ }
1541+
1542+ // Token returns a valid access token, refreshing first if the current one has
1543+ // lapsed. The lock means concurrent requests — streaming and buffered can be
1544+ // in flight together — produce one refresh rather than a thundering herd.
1545+ func (s *Source) Token(ctx context.Context) (string, error) {
1546+ s.mu.Lock()
1547+ defer s.mu.Unlock()
1548+ now := time.Now()
1549+ if !s.creds.Expired(now) {
1550+ return s.creds.AccessToken, nil
1551+ }
1552+ if s.creds.RefreshToken == "" || s.creds.RefreshExpired(now) {
1553+ return "", ErrExpired
1554+ }
1555+ next, err := Refresh(ctx, s.http, s.creds)
1556+ if err != nil {
1557+ return "", err
1558+ }
1559+ s.creds = next
1560+ return s.creds.AccessToken, nil
1561+ }
1562+ ```
1563+
1564+ Add `"bytes"`, `"io"`, `"net/http"`, `"strings"` and `"sync"` to the imports.
1565+
1566+ - [ ] **Step 4: Update the call site**
1567+
1568+ In `cmd/harness/main.go`, `resolveClaudeCode` now needs an HTTP client:
1569+
1570+ ```go
1571+ return claudecode.NewSource(creds, client.HTTP).Token, note, nil
1572+ ```
1573+
1574+ `client.HTTP` is already a `*http.Client` with a 10-minute timeout. That is long
1575+ for a token exchange but harmless, and reusing it keeps one connection pool.
1576+
1577+ - [ ] **Step 5: Run the full suite**
1578+
1579+ ```bash
1580+ cd /home/joni/.t3/worktrees/coding-harness/t3code-8b127a46
1581+ gofmt -w cmd/ internal/ && go build ./... && go test ./... && go test -race ./internal/claudecode/...
1582+ ```
1583+
1584+ Expected: PASS, including under `-race` — the concurrency test is there to be
1585+ run that way.
1586+
1587+ - [ ] **Step 6: Commit**
1588+
1589+ ```bash
1590+ cd /home/joni/.t3/worktrees/coding-harness/t3code-8b127a46
1591+ git add cmd/harness/ internal/claudecode/
1592+ git commit -m "claudecode: refresh the access token in memory
1593+
1594+ Measured first: this endpoint does not rotate refresh tokens, so using
1595+ one does not invalidate the copy Claude Code holds. Nothing is written
1596+ back — Claude Code owns that file and stays its only writer.
1597+
1598+ One refresh per expiry, mutex-guarded, because streaming and buffered
1599+ requests can be in flight together."
1600+ ```
1601+
1602+ ---
1603+
1604+ ### Task 8: Document it
1605+
1606+ **Files:**
1607+ - Modify: `README.md:10-30` (Quick start and the auth paragraph), and the flag list at `:31-32`
1608+
1609+ **Interfaces:**
1610+ - Consumes: everything above.
1611+ - Produces: nothing code depends on.
1612+
1613+ - [ ] **Step 1: Rewrite the Quick start opening**
1614+
1615+ The current text opens by telling you to export an API key. Lead with the case
1616+ that now needs nothing:
1617+
1618+ ```markdown
1619+ ## Quick start
1620+
1621+ ```bash
1622+ go build -o harness ./cmd/harness
1623+
1624+ ./harness -C /path/to/a/throwaway/clone # full-screen TUI
1625+ ./harness -C /path/to/repo -p "why does TestFoo fail?" # one prompt
1626+ ```
1627+
1628+ If you are logged into Claude Code, that is all the setup there is: the harness
1629+ borrows the OAuth credential from `~/.claude/.credentials.json` when nothing
1630+ else supplied one. Otherwise, or to use a different account:
1631+
1632+ ```bash
1633+ export ANTHROPIC_API_KEY=sk-ant-...
1634+ ```
1635+ ```
1636+
1637+ - [ ] **Step 2: Extend the credentials paragraph**
1638+
1639+ After the existing `-auth-token` / `-api-key` sentences, add:
1640+
1641+ ```markdown
1642+ The Claude Code login is the last resort, tried only when no flag and no
1643+ environment variable supplied a credential, and only against Anthropic's own
1644+ endpoint — a credential found on disk is not one you chose to send, so it is
1645+ never forwarded to a `-base-url` you pointed the harness at. `-no-claude-code-auth`
1646+ skips it entirely. Note that the credential is issued to Claude Code; using it
1647+ from another client is outside Anthropic's terms for it, which API keys and
1648+ gateway tokens do not carry.
1649+
1650+ On macOS Claude Code keeps this in the Keychain rather than a file, so there is
1651+ nothing to borrow there.
1652+ ```
1653+
1654+ If Task 7 shipped, add: `An expired access token is refreshed in memory; the
1655+ file is never written.` If it did not, add: `An expired access token is not
1656+ refreshed — run `claude` and the harness picks up the new one.`
1657+
1658+ - [ ] **Step 3: Add the flag to the list**
1659+
1660+ Append `-no-claude-code-auth` to the "Useful flags" line at `README.md:31`.
1661+
1662+ - [ ] **Step 4: Verify the README matches reality**
1663+
1664+ ```bash
1665+ cd /home/joni/.t3/worktrees/coding-harness/t3code-8b127a46
1666+ go build -o /tmp/harness ./cmd/harness && /tmp/harness -h 2>&1 | grep -A1 claude-code
1667+ ```
1668+
1669+ Expected: the flag exists with the help text the README implies.
1670+
1671+ - [ ] **Step 5: Commit**
1672+
1673+ ```bash
1674+ cd /home/joni/.t3/worktrees/coding-harness/t3code-8b127a46
1675+ git add README.md
1676+ git commit -m "README: document borrowing the Claude Code login
1677+
1678+ Leads with the no-setup case, and says plainly why the credential is
1679+ never sent to a non-default endpoint and what its terms are."
1680+ ```
1681+
1682+ ---
1683+
1684+ ## Done when
1685+
1686+ - `env -u ANTHROPIC_API_KEY -u ANTHROPIC_AUTH_TOKEN ./harness -C /tmp -no-tui -p "say ok"` works on a machine logged into Claude Code.
1687+ - The same command with `-base-url http://127.0.0.1:9` reports `no credentials`, not a connection error.
1688+ - `go test ./...` and `go test -race ./internal/claudecode/...` pass.
1689+ - Both probe findings are recorded in the spec, with the status codes observed.
1690+ - If Task 6 found rotation, Tasks 7 is unstarted and the user has been told why.
+10−3docs/superpowers/specs/2026-09-17-claude-code-oauth-design.mdView File
@@ -110,9 +110,16 @@ func WithTokenSource(f func(context.Context) (string, error)) Option
110110 source and set the bearer from the result; the existing header logic is
111111 untouched, because the header was never the problem.
112112
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.
113+ Resolving a credential can now fail at request time rather than at startup, and
114+ where that resolution happens matters. `CreateMessage`'s retry loop only returns
115+ early for a non-retryable `*APIError`; anything else falls through and is tried
116+ again. A credential error is not an `*APIError`, so resolving inside `attempt`
117+ would retry a dead credential three times with backoff.
118+
119+ So the credential is resolved once per call, before the loop, and passed into
120+ `attempt` and `streamAttempt` as a value. That also means one refresh per
121+ request rather than one per attempt, and it lets both request paths share a
122+ single `apply` rather than duplicating the header logic as they do today.
116123
117124 The token source refreshes at most once per expiry, caches in memory for the
118125 process lifetime, and is mutex-guarded: streaming and non-streaming requests can