# Claude Code OAuth Implementation Plan > **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. **Goal:** If you are logged into Claude Code, `./harness` starts with no arguments and no environment. **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. **Tech Stack:** Go 1.26.8, stdlib only (`encoding/json`, `net/http`, `net/http/httptest`, `sync`). No new dependencies. **Spec:** `docs/superpowers/specs/2026-09-17-claude-code-oauth-design.md` ## Global Constraints - Go module is `harness`; import paths are `harness/internal/...`. - Working root for every command is `/home/joni/.t3/worktrees/coding-harness/t3code-8b127a46`. Do not `cd` to the original checkout. - `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. - No test makes a real network call. `httptest` for everything. - 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. - New exported identifiers get a doc comment, matching the existing house style. - Run `gofmt -w` on every file you touch before committing. - Run `go build ./... && go test ./...` before every commit. - Tasks 1 and 6 are probes. They end in a recorded finding, not a feature. - **Task 6 has a STOP gate.** Do not implement Task 7 if Task 6 reports rotation. Report and wait. --- ### Task 1: Probe — does an OAuth request need the Claude Code identity block? The spec leaves this open. It costs one request to close, and it touches only the access token, so it carries none of Task 6's risk. **Files:** - Create: `/tmp/oauth-probe.sh` (throwaway, not committed) - Modify: `docs/superpowers/specs/2026-09-17-claude-code-oauth-design.md` (record the finding) **Interfaces:** - Consumes: nothing. - Produces: a decision that gates Task 5. Either "identity block required" or "not required". - [ ] **Step 1: Send a minimal request with the stored token and no identity block** ```bash cd /home/joni/.t3/worktrees/coding-harness/t3code-8b127a46 TOK=$(python3 -c "import json;print(json.load(open('$HOME/.claude/.credentials.json'))['claudeAiOauth']['accessToken'])") curl -sS -o /tmp/probe-plain.json -w '%{http_code}\n' https://api.anthropic.com/v1/messages \ -H "authorization: Bearer $TOK" \ -H "anthropic-version: 2023-06-01" \ -H "anthropic-beta: oauth-2025-04-20" \ -H "content-type: application/json" \ -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."}]}' cat /tmp/probe-plain.json ``` Expected: either `200` (no identity block needed) or `401`/`403` with a message about the client. - [ ] **Step 2: If it failed, confirm the identity block is the difference** Only run this if Step 1 was non-200. Identical request, identity string as the first system block: ```bash curl -sS -o /tmp/probe-identity.json -w '%{http_code}\n' https://api.anthropic.com/v1/messages \ -H "authorization: Bearer $TOK" \ -H "anthropic-version: 2023-06-01" \ -H "anthropic-beta: oauth-2025-04-20" \ -H "content-type: application/json" \ -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."}]}' cat /tmp/probe-identity.json ``` A 200 here with a non-200 in Step 1 proves the identity block is required. - [ ] **Step 3: Record the finding in the spec** 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. - [ ] **Step 4: Commit the finding** ```bash cd /home/joni/.t3/worktrees/coding-harness/t3code-8b127a46 git add docs/superpowers/specs/2026-09-17-claude-code-oauth-design.md git commit -m "Design: settle whether OAuth needs the Claude Code identity block Probed with one request. Recorded the status codes rather than the inference, so the next person does not have to re-run it." ``` --- ### Task 2: `internal/claudecode` — parse and expiry Pure functions over a file. No network, no refresh. **Files:** - Create: `internal/claudecode/claudecode.go` - Create: `internal/claudecode/claudecode_test.go` **Interfaces:** - Consumes: nothing. - Produces: - `claudecode.Credentials{AccessToken, RefreshToken string; ExpiresAt, RefreshExpiresAt time.Time; Scopes []string; SubscriptionType string}` - `claudecode.Load(home string) (*Credentials, error)` - `(*Credentials).Expired(now time.Time) bool` - `(*Credentials).RefreshExpired(now time.Time) bool` - `claudecode.ErrNotFound` — sentinel, matched with `errors.Is` - `claudecode.Filename = ".claude/.credentials.json"` - [ ] **Step 1: Write the failing tests** Create `internal/claudecode/claudecode_test.go`: ```go package claudecode import ( "errors" "os" "path/filepath" "testing" "time" ) // write builds a fake home directory holding one credentials file. func write(t *testing.T, body string) string { t.Helper() home := t.TempDir() dir := filepath.Join(home, ".claude") if err := os.MkdirAll(dir, 0o700); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dir, ".credentials.json"), []byte(body), 0o600); err != nil { t.Fatal(err) } return home } const sample = `{"claudeAiOauth":{ "accessToken":"sk-ant-oat01-access", "refreshToken":"sk-ant-ort01-refresh", "expiresAt":1789668269460, "refreshTokenExpiresAt":1791404074460, "scopes":["user:inference","user:profile"], "subscriptionType":"team"}}` func TestLoadReadsEveryField(t *testing.T) { c, err := Load(write(t, sample)) if err != nil { t.Fatalf("Load: %v", err) } if c.AccessToken != "sk-ant-oat01-access" { t.Errorf("AccessToken = %q", c.AccessToken) } if c.RefreshToken != "sk-ant-ort01-refresh" { t.Errorf("RefreshToken = %q", c.RefreshToken) } if c.SubscriptionType != "team" { t.Errorf("SubscriptionType = %q", c.SubscriptionType) } if len(c.Scopes) != 2 || c.Scopes[0] != "user:inference" { t.Errorf("Scopes = %v", c.Scopes) } } // The file stores epoch milliseconds. Reading them as seconds puts expiry // 55,000 years out and the token never looks stale. func TestLoadTreatsTimestampsAsMilliseconds(t *testing.T) { c, err := Load(write(t, sample)) if err != nil { t.Fatalf("Load: %v", err) } want := time.UnixMilli(1789668269460) if !c.ExpiresAt.Equal(want) { t.Errorf("ExpiresAt = %s, want %s", c.ExpiresAt, want) } if want := time.UnixMilli(1791404074460); !c.RefreshExpiresAt.Equal(want) { t.Errorf("RefreshExpiresAt = %s, want %s", c.RefreshExpiresAt, want) } } // Not logged in is not a failure. It is a fall-through to the next source. func TestLoadMissingFileIsErrNotFound(t *testing.T) { _, err := Load(t.TempDir()) if !errors.Is(err, ErrNotFound) { t.Fatalf("err = %v, want ErrNotFound", err) } } // A file that exists but does not parse is a broken install, and saying so // with the path beats falling through to "no credentials". func TestLoadMalformedJSONNamesThePath(t *testing.T) { home := write(t, "{not json") _, err := Load(home) if err == nil || errors.Is(err, ErrNotFound) { t.Fatalf("err = %v, want a parse error", err) } if !strings.Contains(err.Error(), ".credentials.json") { t.Errorf("error should name the file, got %q", err) } } // An empty access token is a file we cannot use, and it fails at parse time // rather than as a puzzling 401 later. func TestLoadEmptyAccessTokenIsAnError(t *testing.T) { _, err := Load(write(t, `{"claudeAiOauth":{"accessToken":""}}`)) if err == nil || errors.Is(err, ErrNotFound) { t.Fatalf("err = %v, want an error about the missing token", err) } } func TestExpiredUsesASkewMargin(t *testing.T) { base := time.Unix(1_700_000_000, 0) c := &Credentials{ExpiresAt: base} if c.Expired(base.Add(-5 * time.Minute)) { t.Error("a token with five minutes left is not expired") } // Inside the skew margin: it would expire during the request we are about // to send, so it counts as expired now. if !c.Expired(base.Add(-30 * time.Second)) { t.Error("a token expiring in 30s should count as expired") } if !c.Expired(base.Add(time.Minute)) { t.Error("a token past its expiry is expired") } } func TestRefreshExpiredIsTerminal(t *testing.T) { base := time.Unix(1_700_000_000, 0) c := &Credentials{RefreshExpiresAt: base} if c.RefreshExpired(base.Add(-24 * time.Hour)) { t.Error("a refresh token with a day left is not expired") } if !c.RefreshExpired(base.Add(time.Hour)) { t.Error("a refresh token past its expiry is expired") } } ``` Add `"strings"` to that file's imports. - [ ] **Step 2: Run the tests to verify they fail** ```bash cd /home/joni/.t3/worktrees/coding-harness/t3code-8b127a46 go test ./internal/claudecode/... ``` Expected: FAIL — the package does not build, `Load` and `Credentials` are undefined. - [ ] **Step 3: Write the implementation** Create `internal/claudecode/claudecode.go`: ```go // Package claudecode reads the OAuth credential Claude Code stores on disk, so // a harness run can borrow a login that already exists. // // This is Claude Code's private storage, not a documented interface: the // schema can change without notice. Everything that knows its shape lives // here, and parses strictly, so a change surfaces as a clear error rather than // as a puzzling 401 somewhere else. package claudecode import ( "encoding/json" "errors" "fmt" "os" "path/filepath" "time" ) // Filename is the credential store, relative to the home directory. const Filename = ".claude/.credentials.json" // skew is how long before its stated expiry a token starts counting as // expired. A token with seconds left would expire during the request we are // about to send. const skew = 60 * time.Second // ErrNotFound means there is no credentials file: not logged in, rather than // broken. Callers fall through to whatever source is next. var ErrNotFound = errors.New("claudecode: no credentials file") // Credentials is the claudeAiOauth object, with its millisecond timestamps // converted to something Go can compare. type Credentials struct { AccessToken string RefreshToken string ExpiresAt time.Time RefreshExpiresAt time.Time Scopes []string SubscriptionType string } // stored mirrors the on-disk JSON. Timestamps are epoch milliseconds. type stored struct { ClaudeAIOAuth struct { AccessToken string `json:"accessToken"` RefreshToken string `json:"refreshToken"` ExpiresAt int64 `json:"expiresAt"` RefreshTokenExpiresAt int64 `json:"refreshTokenExpiresAt"` Scopes []string `json:"scopes"` SubscriptionType string `json:"subscriptionType"` } `json:"claudeAiOauth"` } // Load reads and parses the credential store. home is passed in rather than // looked up so tests can point it at a fixture, matching skills.Discover. func Load(home string) (*Credentials, error) { path := filepath.Join(home, Filename) raw, err := os.ReadFile(path) if errors.Is(err, os.ErrNotExist) { return nil, ErrNotFound } if err != nil { return nil, fmt.Errorf("claudecode: reading %s: %w", path, err) } var s stored if err := json.Unmarshal(raw, &s); err != nil { return nil, fmt.Errorf("claudecode: parsing %s: %w", path, err) } o := s.ClaudeAIOAuth if o.AccessToken == "" { return nil, fmt.Errorf("claudecode: %s has no access token", path) } return &Credentials{ AccessToken: o.AccessToken, RefreshToken: o.RefreshToken, ExpiresAt: time.UnixMilli(o.ExpiresAt), RefreshExpiresAt: time.UnixMilli(o.RefreshTokenExpiresAt), Scopes: o.Scopes, SubscriptionType: o.SubscriptionType, }, nil } // Expired reports whether the access token is past its expiry, or close enough // that it would lapse mid-request. func (c *Credentials) Expired(now time.Time) bool { return !now.Before(c.ExpiresAt.Add(-skew)) } // RefreshExpired reports whether the refresh token is past its expiry. That is // terminal: no amount of refreshing fixes it, only logging in again. func (c *Credentials) RefreshExpired(now time.Time) bool { return !now.Before(c.RefreshExpiresAt) } ``` - [ ] **Step 4: Run the tests to verify they pass** ```bash cd /home/joni/.t3/worktrees/coding-harness/t3code-8b127a46 gofmt -w internal/claudecode/ && go test ./internal/claudecode/... ``` Expected: PASS, all seven tests. - [ ] **Step 5: Commit** ```bash cd /home/joni/.t3/worktrees/coding-harness/t3code-8b127a46 git add internal/claudecode/ git commit -m "claudecode: parse Claude Code's credential store Timestamps are epoch milliseconds; reading them as seconds puts expiry 55,000 years out and the token never looks stale. A missing file means not logged in and falls through; a file that will not parse names its path, because that is a broken install rather than an absent one." ``` --- ### Task 3: `llm.Client` resolves its credential per request Today `authToken` is frozen at construction and both request paths repeat the same header logic. Resolve once per call, before the retry loop, and share one `apply`. **Files:** - Modify: `internal/llm/client.go:56-58` (struct fields), `:74-80` (`WithAuthToken`), `:116` (`HasCredentials`), `:150-155` (`CreateMessage`), `:190-205` (`attempt`) - Modify: `internal/llm/stream.go:81-93` (`streamAttempt`), and `StreamMessage`'s loop above it - Modify: `internal/llm/client_test.go` (add tests) **Interfaces:** - Consumes: nothing from earlier tasks. - Produces: - `llm.WithTokenSource(func(context.Context) (string, error)) Option` - unexported `auth` struct with `apply(http.Header)`, and `(*Client).resolveAuth(context.Context) (auth, error)` - `WithAuthToken` and `WithAPIKey` keep today's behaviour exactly. - [ ] **Step 1: Write the failing tests** Append to `internal/llm/client_test.go`: ```go // A token source is consulted per request, so a credential that rotates // mid-session reaches the wire without rebuilding the client. func TestTokenSourceIsCalledPerRequest(t *testing.T) { var got []string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { got = append(got, r.Header.Get("authorization")) io.WriteString(w, okResponse) })) defer srv.Close() n := 0 c := New(WithBaseURL(srv.URL), WithTokenSource(func(context.Context) (string, error) { n++ return fmt.Sprintf("tok-%d", n), nil })) for i := 0; i < 2; i++ { if _, err := c.CreateMessage(context.Background(), Request{}); err != nil { t.Fatalf("CreateMessage: %v", err) } } if len(got) != 2 || got[0] != "Bearer tok-1" || got[1] != "Bearer tok-2" { t.Errorf("authorization headers = %v, want Bearer tok-1 then tok-2", got) } } func TestTokenSourceGetsTheOAuthBetaHeader(t *testing.T) { var got http.Header srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { got = r.Header.Clone() io.WriteString(w, okResponse) })) defer srv.Close() c := New(WithBaseURL(srv.URL), WithTokenSource(func(context.Context) (string, error) { return "tok", nil })) if _, err := c.CreateMessage(context.Background(), Request{}); err != nil { t.Fatalf("CreateMessage: %v", err) } if got.Get("anthropic-beta") != oauthBeta { t.Errorf("anthropic-beta = %q, want %s", got.Get("anthropic-beta"), oauthBeta) } if got.Get("x-api-key") != "" { t.Errorf("x-api-key should be unset, got %q", got.Get("x-api-key")) } } // A credential that cannot be resolved is permanent. Retrying it three times // with backoff just delays the error, so resolution happens before the loop. func TestTokenSourceErrorIsNotRetried(t *testing.T) { var hits int srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { hits++ io.WriteString(w, okResponse) })) defer srv.Close() var calls int c := New(WithBaseURL(srv.URL), WithTokenSource(func(context.Context) (string, error) { calls++ return "", errors.New("login expired") })) c.RetryDelay = time.Millisecond _, err := c.CreateMessage(context.Background(), Request{}) if err == nil || !strings.Contains(err.Error(), "login expired") { t.Fatalf("err = %v, want the source's error", err) } if calls != 1 { t.Errorf("token source called %d times, want 1", calls) } if hits != 0 { t.Errorf("server hit %d times, want 0 — no request should have been sent", hits) } } func TestHasCredentialsCountsATokenSource(t *testing.T) { c := New(WithBaseURL("http://127.0.0.1:1")) c.apiKey, c.authToken = "", "" if c.HasCredentials() { t.Fatal("no credentials expected before the source is set") } c.tokenSource = func(context.Context) (string, error) { return "tok", nil } if !c.HasCredentials() { t.Error("a token source is a credential") } } // The streaming path must authenticate identically to the buffered one. func TestStreamUsesTheTokenSource(t *testing.T) { var got http.Header srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { got = r.Header.Clone() w.Header().Set("content-type", "text/event-stream") io.WriteString(w, streamOK) })) defer srv.Close() c := New(WithBaseURL(srv.URL), WithTokenSource(func(context.Context) (string, error) { return "stream-tok", nil })) if _, err := c.StreamMessage(context.Background(), Request{}, func(StreamEvent) {}); err != nil { t.Fatalf("StreamMessage: %v", err) } if got.Get("authorization") != "Bearer stream-tok" { t.Errorf("authorization = %q", got.Get("authorization")) } if got.Get("anthropic-beta") != oauthBeta { t.Errorf("anthropic-beta = %q, want %s", got.Get("anthropic-beta"), oauthBeta) } } ``` `streamOK` does not exist. `stream_test.go:15` defines `sse(events ...string) string`, which formats events the way the API does — build the payload with it. A `message_stop` event alone is enough for a stream that ends cleanly: ```go streamOK := sse(`{"type":"message_stop"}`) ``` Put that line at the top of the test body. If `readStream` rejects a stream with no `message_start`, prepend the `message_start` and `message_delta` events that `stream_test.go`'s existing happy-path test uses — copy them verbatim from there rather than inventing a shape. Add `"errors"`, `"fmt"` and `"time"` to `client_test.go`'s imports if they are not already present. - [ ] **Step 2: Run the tests to verify they fail** ```bash cd /home/joni/.t3/worktrees/coding-harness/t3code-8b127a46 go test ./internal/llm/... ``` Expected: FAIL — `WithTokenSource` and the `tokenSource` field are undefined. - [ ] **Step 3: Add the field, the option, and the resolver** In `internal/llm/client.go`, extend the struct's credential fields: ```go apiKey string authToken string tokenSource func(context.Context) (string, error) ``` Add the option next to `WithAuthToken`: ```go // WithTokenSource sets a bearer credential resolved per request, so a token // that expires mid-session can be replaced without rebuilding the client. A // nil function is ignored. func WithTokenSource(f func(context.Context) (string, error)) Option { return func(c *Client) { if f != nil { c.tokenSource, c.apiKey, c.authToken = f, "", "" } } } ``` Teach `HasCredentials` about it: ```go // HasCredentials reports whether the client can authenticate. func (c *Client) HasCredentials() bool { return c.apiKey != "" || c.authToken != "" || c.tokenSource != nil } ``` Add the resolver and the shared header logic: ```go // auth is one request's resolved credential. Resolving returns a value rather // than mutating the client so concurrent requests cannot interleave. type auth struct { apiKey string bearer string } // apply writes the credential headers. An OAuth bearer also needs the beta // flag; an API key does not. func (a auth) apply(h http.Header) { if a.apiKey != "" { h.Set("x-api-key", a.apiKey) return } h.Set("authorization", "Bearer "+a.bearer) h.Set("anthropic-beta", oauthBeta) } // resolveAuth produces the credential for one request. It runs before the // retry loop: a credential that will not resolve is permanent, and the loop // only short-circuits on a non-retryable *APIError, so resolving inside // attempt would retry a dead credential three times with backoff. func (c *Client) resolveAuth(ctx context.Context) (auth, error) { if c.apiKey != "" { return auth{apiKey: c.apiKey}, nil } if c.tokenSource != nil { tok, err := c.tokenSource(ctx) if err != nil { return auth{}, err } return auth{bearer: tok}, nil } return auth{bearer: c.authToken}, nil } ``` - [ ] **Step 4: Thread it through both request paths** In `CreateMessage`, resolve before the retry loop — directly after the `HasCredentials` check: ```go a, err := c.resolveAuth(ctx) if err != nil { return nil, err } ``` 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: ```go a.apply(httpReq.Header) ``` Do the same in `stream.go`: resolve once in `StreamMessage` before its retry loop, change `streamAttempt` to take an `auth`, update the call site, and replace its `if c.apiKey != "" { … } else { … }` block with `a.apply(httpReq.Header)`. `StreamMessage` needs the same up-front `HasCredentials` check as `CreateMessage` if it does not already have one — check, and add it if absent. - [ ] **Step 5: Run the full test suite** ```bash cd /home/joni/.t3/worktrees/coding-harness/t3code-8b127a46 gofmt -w internal/llm/ && go build ./... && go test ./... ``` Expected: PASS. `TestOAuthTokenUsesBearerAndBetaHeader` and `TestNoCredentialsIsAClearError` must still pass untouched — the old options are now the constant case of the new one, and that is the point. - [ ] **Step 6: Commit** ```bash cd /home/joni/.t3/worktrees/coding-harness/t3code-8b127a46 git add internal/llm/ git commit -m "llm: resolve the credential per request A frozen authToken cannot outlive its own expiry. WithTokenSource makes the bearer a function; WithAuthToken is now its constant case. Resolution happens before the retry loop because a credential error is not an *APIError, and the loop only returns early for those — resolving inside attempt would retry a dead credential three times with backoff. Both request paths now share one apply instead of repeating the headers." ``` --- ### Task 4: Fall back to the Claude Code login The feature, end to end, without refresh. An expired token fails with an instruction rather than a 401. **Files:** - Modify: `internal/claudecode/claudecode.go` (add `Source`) - Modify: `internal/claudecode/claudecode_test.go` (add tests) - Modify: `cmd/harness/main.go:38-72` (flag, client construction, fallback), `:296` (`errNoCredentials`) - Create: `cmd/harness/auth_test.go` **Interfaces:** - Consumes: `claudecode.Load`, `(*Credentials).Expired`, `llm.WithTokenSource`, `llm.Client.HasCredentials`, `llm.Client.IsDefaultEndpoint`. - Produces: - `claudecode.NewSource(*Credentials) *Source` - `(*Source).Token(context.Context) (string, error)` - `claudecode.ErrExpired` - `resolveClaudeCode(client *llm.Client, home string, disabled bool) (func(context.Context) (string, error), string, error)` in `package main` - [ ] **Step 1: Write the failing Source tests** Append to `internal/claudecode/claudecode_test.go`: ```go func TestSourceReturnsTheAccessToken(t *testing.T) { s := NewSource(&Credentials{ AccessToken: "tok", ExpiresAt: time.Now().Add(time.Hour), }) got, err := s.Token(context.Background()) if err != nil { t.Fatalf("Token: %v", err) } if got != "tok" { t.Errorf("Token = %q, want tok", got) } } // Until refresh lands, an expired token is a clear instruction rather than a // 401 from the API. func TestSourceExpiredTokenSaysWhatToDo(t *testing.T) { s := NewSource(&Credentials{ AccessToken: "tok", ExpiresAt: time.Now().Add(-time.Hour), }) _, err := s.Token(context.Background()) if !errors.Is(err, ErrExpired) { t.Fatalf("err = %v, want ErrExpired", err) } if !strings.Contains(err.Error(), "claude") { t.Errorf("the error should say to run claude, got %q", err) } } ``` Add `"context"` to the imports. - [ ] **Step 2: Run the tests to verify they fail** ```bash cd /home/joni/.t3/worktrees/coding-harness/t3code-8b127a46 go test ./internal/claudecode/... ``` Expected: FAIL — `NewSource` and `ErrExpired` undefined. - [ ] **Step 3: Implement Source** Append to `internal/claudecode/claudecode.go`: ```go // ErrExpired means the stored access token has lapsed. Task 7 may replace this // with a refresh; until then it is the whole story. var ErrExpired = errors.New("claudecode: your Claude Code login has expired, run `claude` to refresh it") // Source hands out the access token for as long as it is valid. It satisfies // the shape llm.WithTokenSource wants. type Source struct { creds *Credentials } // NewSource wraps credentials in a token source. func NewSource(c *Credentials) *Source { return &Source{creds: c} } // Token returns the access token, or ErrExpired once it has lapsed. func (s *Source) Token(context.Context) (string, error) { if s.creds.Expired(time.Now()) { return "", ErrExpired } return s.creds.AccessToken, nil } ``` Add `"context"` to the file's imports. - [ ] **Step 4: Write the failing precedence tests** Create `cmd/harness/auth_test.go`: ```go package main import ( "context" "os" "path/filepath" "testing" "harness/internal/llm" ) // fakeHome writes a credentials file with a token valid for an hour. func fakeHome(t *testing.T) string { t.Helper() home := t.TempDir() dir := filepath.Join(home, ".claude") if err := os.MkdirAll(dir, 0o700); err != nil { t.Fatal(err) } // expiresAt is 2035, comfortably in the future. body := `{"claudeAiOauth":{"accessToken":"file-token","refreshToken":"r", "expiresAt":2058430000000,"refreshTokenExpiresAt":2058430000000, "scopes":["user:inference"],"subscriptionType":"team"}}` if err := os.WriteFile(filepath.Join(dir, ".credentials.json"), []byte(body), 0o600); err != nil { t.Fatal(err) } return home } func TestClaudeCodeFillsInAMissingCredential(t *testing.T) { c := llm.New(llm.WithBaseURL(llm.DefaultBaseURL)) c.ClearCredentialsForTest() src, note, err := resolveClaudeCode(c, fakeHome(t), false) if err != nil { t.Fatalf("resolveClaudeCode: %v", err) } if src == nil { t.Fatal("expected a token source") } tok, err := src(context.Background()) if err != nil { t.Fatalf("token: %v", err) } if tok != "file-token" { t.Errorf("token = %q, want file-token", tok) } if note == "" { t.Error("using a discovered credential should be announced") } } // An explicit credential wins. We never override what the user typed. func TestClaudeCodeIsSkippedWhenACredentialExists(t *testing.T) { c := llm.New(llm.WithBaseURL(llm.DefaultBaseURL), llm.WithAPIKey("sk-ant-explicit")) src, _, err := resolveClaudeCode(c, fakeHome(t), false) if err != nil { t.Fatalf("resolveClaudeCode: %v", err) } if src != nil { t.Error("an explicit credential must not be replaced by the file") } } // The security property: a credential we found is never sent to an endpoint // the user pointed us at. "We did not send it" is not observable from outside, // so it is a test rather than a comment. func TestClaudeCodeIsNeverSentToANonDefaultEndpoint(t *testing.T) { c := llm.New(llm.WithBaseURL("http://gateway.example")) c.ClearCredentialsForTest() src, _, err := resolveClaudeCode(c, fakeHome(t), false) if err != nil { t.Fatalf("resolveClaudeCode: %v", err) } if src != nil { t.Fatal("a discovered credential must never go to a non-default endpoint") } } func TestClaudeCodeCanBeDisabled(t *testing.T) { c := llm.New(llm.WithBaseURL(llm.DefaultBaseURL)) c.ClearCredentialsForTest() src, _, err := resolveClaudeCode(c, fakeHome(t), true) if err != nil { t.Fatalf("resolveClaudeCode: %v", err) } if src != nil { t.Error("-no-claude-code-auth must skip discovery entirely") } } // Not logged in is not an error. It falls through to errNoCredentials. func TestClaudeCodeMissingFileIsNotAnError(t *testing.T) { c := llm.New(llm.WithBaseURL(llm.DefaultBaseURL)) c.ClearCredentialsForTest() src, _, err := resolveClaudeCode(c, t.TempDir(), false) if err != nil { t.Fatalf("a missing file is not an error, got %v", err) } if src != nil { t.Error("no file means no source") } } ``` - [ ] **Step 5: Add the test helper the tests need** `llm.New` reads `ANTHROPIC_API_KEY` from the environment, so a developer with that set would see these tests pass for the wrong reason. Add to `internal/llm/client.go`: ```go // ClearCredentialsForTest drops any credential read from the environment, so a // test exercising credential precedence is not at the mercy of the developer's // own shell. func (c *Client) ClearCredentialsForTest() { c.apiKey, c.authToken, c.tokenSource = "", "", nil } ``` - [ ] **Step 6: Run the tests to verify they fail** ```bash cd /home/joni/.t3/worktrees/coding-harness/t3code-8b127a46 go test ./cmd/... ./internal/claudecode/... ``` Expected: FAIL — `resolveClaudeCode` undefined. - [ ] **Step 7: Implement the fallback in main.go** Add the flag beside `-no-skills`: ```go noClaudeCodeAuth := flag.Bool("no-claude-code-auth", false, "do not fall back to the OAuth login in ~/"+claudecode.Filename) ``` Add the resolver function to `cmd/harness/main.go`: ```go // resolveClaudeCode returns a token source borrowed from Claude Code's login, // or nil when one should not be used. The second return is a line for the // user: a borrowed credential is worth announcing, the same way a non-default // endpoint is. // // Two conditions gate it. The client must have no credential already, because // we never override what the user typed. And the endpoint must be Anthropic's, // because a credential we found on disk is not one the user chose to send — // forwarding it to a -base-url someone else operates would leak it to a third // party because a file happened to exist. func resolveClaudeCode(client *llm.Client, home string, disabled bool) (func(context.Context) (string, error), string, error) { if disabled || client.HasCredentials() || !client.IsDefaultEndpoint() { return nil, "", nil } creds, err := claudecode.Load(home) if errors.Is(err, claudecode.ErrNotFound) { return nil, "", nil } if err != nil { return nil, "", err } note := fmt.Sprintf("using your Claude Code login (%s plan) from ~/%s", creds.SubscriptionType, claudecode.Filename) return claudecode.NewSource(creds).Token, note, nil } ``` Rework the client construction to allow a second pass: ```go opts := []llm.Option{ llm.WithModel(*model), llm.WithBaseURL(*baseURL), llm.WithAPIKey(*apiKey), llm.WithAuthToken(*authToken), } client := llm.New(opts...) home, err := os.UserHomeDir() if err != nil { // No home directory is not fatal: an explicit credential still works. home = "" } var authNote string if home != "" { src, note, err := resolveClaudeCode(client, home, *noClaudeCodeAuth) if err != nil { return err } if src != nil { client = llm.New(append(opts, llm.WithTokenSource(src))...) authNote = note } } if !client.HasCredentials() { return errNoCredentials } ``` `notice` is defined further down, after `useTUI` is known, so report the note there: ```go if authNote != "" { notice("%s", authNote) } ``` Put that next to the existing `if !client.IsDefaultEndpoint()` notice. The existing skills block calls `os.UserHomeDir()` too — reuse the `home` you just computed rather than calling it twice, and delete the now-duplicate lookup. Extend `errNoCredentials` with the new route: ```go var errNoCredentials = errors.New(`no credentials. Supply one of: export ANTHROPIC_API_KEY=sk-ant-... (sent as x-api-key) export ANTHROPIC_AUTH_TOKEN=... (sent as Authorization: Bearer) -api-key / -auth-token (flags override the environment) log in with Claude Code (borrowed automatically, Anthropic endpoint only) To use a non-Anthropic endpoint, set ANTHROPIC_BASE_URL or -base-url as well.`) ``` Add `"harness/internal/claudecode"` to the imports. - [ ] **Step 8: Run the full suite** ```bash cd /home/joni/.t3/worktrees/coding-harness/t3code-8b127a46 gofmt -w cmd/ internal/ && go build ./... && go test ./... ``` Expected: PASS. - [ ] **Step 9: Verify by hand that it actually starts** ```bash cd /home/joni/.t3/worktrees/coding-harness/t3code-8b127a46 go build -o /tmp/harness ./cmd/harness env -u ANTHROPIC_API_KEY -u ANTHROPIC_AUTH_TOKEN /tmp/harness -C /tmp -no-tui -p "reply with the single word: ok" ``` Expected: stderr says `harness: using your Claude Code login (team plan) …`, and the model replies. If Task 1 found the identity block is required, this fails with a 401 until Task 5 lands — that is expected, and it is the point of doing Task 5 next. Record which you saw. Also confirm the guard holds: ```bash 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" ``` Expected: `no credentials` — not a connection error. The file was never read. - [ ] **Step 10: Commit** ```bash cd /home/joni/.t3/worktrees/coding-harness/t3code-8b127a46 git add cmd/harness/ internal/claudecode/ internal/llm/ git commit -m "harness: borrow the Claude Code login when nothing else is set Logged into Claude Code means ./harness starts with no arguments. Two gates. An explicit credential is never overridden, and the file is read only when the endpoint is Anthropic's: a credential we found on disk is not one the user chose to send, and forwarding it to a -base-url someone else runs would leak it because a file happened to exist. No refresh yet — an expired token says to run claude." ``` --- ### Task 5: The Claude Code identity block **Skip this task entirely if Task 1 found the identity block is not required.** If you skip it, say so in the final report. **Files:** - Modify: `internal/agent/loop.go:57-77` (`Options`), `:301` and `:318` (request assembly) - Modify: `internal/agent/loop_test.go` (add tests) - Modify: `cmd/harness/main.go:149-157` (pass it) **Interfaces:** - Consumes: `resolveClaudeCode`'s non-nil source as the signal that the credential is OAuth-sourced. - Produces: `agent.Options.SystemPrefix string` and `agent.ClaudeCodeIdentity`. - [ ] **Step 1: Write the failing tests** Append to `internal/agent/loop_test.go`: ```go func TestSystemPrefixBecomesTheFirstBlock(t *testing.T) { a := New(nil, tools.NewRegistry(nil), NewState(), nil, Options{SystemPrefix: ClaudeCodeIdentity}) blocks := a.systemBlocks() if len(blocks) != 2 { t.Fatalf("got %d system blocks, want 2", len(blocks)) } if blocks[0].Text != ClaudeCodeIdentity { t.Errorf("first block = %q, want the identity string", blocks[0].Text) } if !strings.Contains(blocks[1].Text, "coding agent") { t.Errorf("second block should be the harness prompt, got %q", blocks[1].Text) } } // The cache breakpoint has to sit on the last block, or only the identity // string gets cached and the prompt behind it is re-read every turn. func TestCacheBreakpointIsOnTheLastSystemBlock(t *testing.T) { a := New(nil, tools.NewRegistry(nil), NewState(), nil, Options{SystemPrefix: ClaudeCodeIdentity}) blocks := a.systemBlocks() if blocks[0].CacheControl != nil { t.Error("the first block must not carry the breakpoint") } if blocks[len(blocks)-1].CacheControl == nil { t.Error("the last block must carry the breakpoint") } } // Without OAuth nothing changes, including byte-for-byte. func TestNoPrefixMeansOneBlockAsBefore(t *testing.T) { a := New(nil, tools.NewRegistry(nil), NewState(), nil, Options{}) blocks := a.systemBlocks() if len(blocks) != 1 { t.Fatalf("got %d system blocks, want 1", len(blocks)) } if blocks[0].Text != defaultSystem { t.Error("the prompt must be unchanged when no prefix is set") } } ``` This is the constructor shape `TestSystemPromptListsSkills` at `loop_test.go:494` already uses, so `tools` and `skills` are already imported in that file. - [ ] **Step 2: Run the tests to verify they fail** ```bash cd /home/joni/.t3/worktrees/coding-harness/t3code-8b127a46 go test ./internal/agent/... ``` Expected: FAIL — `SystemPrefix`, `ClaudeCodeIdentity` and `systemBlocks` undefined. - [ ] **Step 3: Implement it** Add to `internal/agent/loop.go`, next to `defaultSystem`: ```go // ClaudeCodeIdentity is what an OAuth-authenticated request must present as // its first system block. It is a fixed string, so it costs no cache hit: the // prefix is still constant for the session. const ClaudeCodeIdentity = "You are Claude Code, Anthropic's official CLI for Claude." ``` Add the field to `Options`: ```go // SystemPrefix is prepended as a separate system block ahead of the // harness prompt. It carries the identity string an OAuth credential // requires, and is empty for every other credential. SystemPrefix string ``` Extract the block assembly out of the request builder at `loop.go:301`: ```go // systemBlocks builds the system prompt. The cache breakpoint goes on the last // block so everything ahead of it — tools, identity, prompt — is cached // together; on the first block it would cache the identity string and re-read // the rest every turn. func (a *Agent) systemBlocks() []llm.SystemBlock { var blocks []llm.SystemBlock if a.opts.SystemPrefix != "" { blocks = append(blocks, llm.SystemBlock{Type: "text", Text: a.opts.SystemPrefix}) } blocks = append(blocks, llm.SystemBlock{Type: "text", Text: a.opts.System}) if !a.opts.NoCache { blocks[len(blocks)-1].CacheControl = llm.Ephemeral() } return blocks } ``` Then in the request builder, replace the `system := llm.SystemBlock{…}` line and its `system.CacheControl = llm.Ephemeral()` inside the `if !a.opts.NoCache` block with a call to `a.systemBlocks()`, and set `System: a.systemBlocks()` in the `llm.Request`. Leave the messages-side breakpoint in that `if` exactly as it is. - [ ] **Step 4: Run the tests to verify they pass** ```bash cd /home/joni/.t3/worktrees/coding-harness/t3code-8b127a46 gofmt -w internal/agent/ && go test ./internal/agent/... ``` Expected: PASS, including the existing `TestSystemPromptIsStableAcrossRequests`. - [ ] **Step 5: Pass it from main.go** Track whether the credential came from Claude Code, and set the prefix: ```go a := agent.New(client, reg, state, log, agent.Options{ MaxTurns: *maxTurns, Effort: *effort, ContextWindow: *contextWindow, CompactAt: *compactAt, NoCache: *noCache, Stream: !*noStream, Skills: found, SystemPrefix: systemPrefix, }) ``` where `systemPrefix` is set to `agent.ClaudeCodeIdentity` in the same branch that set `authNote`, and is otherwise empty. There is a second `agent.Options` literal at `main.go:185` — set `SystemPrefix` there too, or the resumed-session path authenticates differently from the fresh one. - [ ] **Step 6: Verify end to end** ```bash cd /home/joni/.t3/worktrees/coding-harness/t3code-8b127a46 go build -o /tmp/harness ./cmd/harness env -u ANTHROPIC_API_KEY -u ANTHROPIC_AUTH_TOKEN /tmp/harness -C /tmp -no-tui -p "reply with the single word: ok" ``` Expected: the model replies. This is the request that 401'd at the end of Task 4. - [ ] **Step 7: Commit** ```bash cd /home/joni/.t3/worktrees/coding-harness/t3code-8b127a46 gofmt -w cmd/ internal/ && go build ./... && go test ./... git add cmd/harness/ internal/agent/ git commit -m "agent: the Claude Code identity block for OAuth requests An OAuth credential requires the identity string as the first system block. It is static, so the cached prefix survives — but the breakpoint moves to the last block, or only the identity string would be cached and the real prompt re-read every turn." ``` --- ### Task 6: Probe — does the token endpoint rotate refresh tokens? **This task ends in a report, not code. It has a STOP gate.** The risk: if the endpoint returns a *new* refresh token, then refreshing without writing it back consumes the one Claude Code has on disk. Its copy goes dead and `claude` asks for a fresh login. That is damage outside this repo, so it gets measured before anything depends on it. **Files:** - Modify: `docs/superpowers/specs/2026-09-17-claude-code-oauth-design.md` (record the finding) **Interfaces:** - Consumes: nothing. - Produces: the gate for Task 7. - [ ] **Step 1: Warn the user before spending the refresh token** Tell them, in these terms: this probe consumes one refresh. If the endpoint rotates, their Claude Code login will need `claude` run once to recover. Get a yes before Step 2. They approved the probe in principle when they chose "find out first" — this is the confirmation that it is happening now. - [ ] **Step 2: Refresh once and compare** ```bash OLD=$(python3 -c "import json;print(json.load(open('$HOME/.claude/.credentials.json'))['claudeAiOauth']['refreshToken'])") curl -sS -o /tmp/refresh.json -w '%{http_code}\n' https://console.anthropic.com/v1/oauth/token \ -H "content-type: application/json" \ -d "{\"grant_type\":\"refresh_token\",\"refresh_token\":\"$OLD\",\"client_id\":\"9d1c250a-e61b-44d9-88ed-5944d1962f5e\"}" python3 - <<'EOF' import json, os r = json.load(open('/tmp/refresh.json')) old = json.load(open(os.path.expanduser('~/.claude/.credentials.json')))['claudeAiOauth']['refreshToken'] new = r.get('refresh_token') print('keys:', sorted(r)) print('returned a refresh_token:', new is not None) print('ROTATED' if new and new != old else 'NOT ROTATED') EOF ``` If the endpoint 404s or rejects the client id, that is itself the finding: the refresh route is not what we assumed. Record it and stop — do not go hunting for another endpoint without asking. - [ ] **Step 3: Record the finding in the spec** Replace the spec's `## Refresh, and an open question that gates it` open-question paragraphs with what you measured: the status code, whether a `refresh_token` came back, and whether it differed. - [ ] **Step 4: STOP if it rotated, or if the endpoint was not what we assumed** Report to the user. Do not start Task 7. The remaining choice — write-back with atomic rename, or no refresh at all — is theirs, and the spec says so explicitly. If it did **not** rotate, continue to Task 7. - [ ] **Step 5: Commit the finding** ```bash cd /home/joni/.t3/worktrees/coding-harness/t3code-8b127a46 git add docs/superpowers/specs/2026-09-17-claude-code-oauth-design.md git commit -m "Design: settle whether the token endpoint rotates refresh tokens Measured rather than assumed, because guessing wrong here breaks the user's Claude Code login rather than this harness." ``` --- ### Task 7: Refresh on expiry **Only if Task 6 reported NOT ROTATED.** Otherwise this task does not exist yet. **Files:** - Modify: `internal/claudecode/claudecode.go` (add `Refresh`, make `Source` refresh) - Modify: `internal/claudecode/claudecode_test.go` - Modify: `cmd/harness/main.go` (give `NewSource` an HTTP client) **Interfaces:** - Consumes: `Credentials`, `Source`, `ErrExpired`, `ErrNotFound`. - Produces: - `claudecode.Refresh(ctx context.Context, h *http.Client, c *Credentials) (*Credentials, error)` - `claudecode.TokenURL` and `claudecode.ClientID` constants - `NewSource` gains a second parameter: `NewSource(c *Credentials, h *http.Client) *Source` **Breaking change to fix as you go:** Task 4's `TestSourceReturnsTheAccessToken` and `TestSourceExpiredTokenSaysWhatToDo` call `NewSource` with one argument, and will stop compiling. Pass `nil` as the second argument in both — neither test refreshes, and `NewSource` substitutes `http.DefaultClient` for a nil client. - [ ] **Step 1: Write the failing tests** Append to `internal/claudecode/claudecode_test.go`: ```go func TestRefreshExchangesTheToken(t *testing.T) { var body map[string]any srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { json.NewDecoder(r.Body).Decode(&body) io.WriteString(w, `{"access_token":"new-access","refresh_token":"new-refresh","expires_in":28800}`) })) defer srv.Close() t.Setenv("CLAUDECODE_TOKEN_URL", srv.URL) old := &Credentials{AccessToken: "old", RefreshToken: "old-refresh"} got, err := Refresh(context.Background(), srv.Client(), old) if err != nil { t.Fatalf("Refresh: %v", err) } if got.AccessToken != "new-access" { t.Errorf("AccessToken = %q", got.AccessToken) } if body["grant_type"] != "refresh_token" || body["refresh_token"] != "old-refresh" { t.Errorf("request body = %v", body) } if !got.ExpiresAt.After(time.Now().Add(7 * time.Hour)) { t.Errorf("expires_in should have become an absolute time, got %s", got.ExpiresAt) } } // A refresh token the server rejects is terminal: only a fresh login fixes it. func TestRefreshRejectedSaysToLogInAgain(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusBadRequest) io.WriteString(w, `{"error":"invalid_grant"}`) })) defer srv.Close() t.Setenv("CLAUDECODE_TOKEN_URL", srv.URL) _, err := Refresh(context.Background(), srv.Client(), &Credentials{RefreshToken: "dead"}) if err == nil { t.Fatal("a rejected refresh must be an error") } if !strings.Contains(err.Error(), "claude") { t.Errorf("the error should say to log in again, got %q", err) } } func TestRefreshTransportErrorIsReported(t *testing.T) { t.Setenv("CLAUDECODE_TOKEN_URL", "http://127.0.0.1:1") _, err := Refresh(context.Background(), &http.Client{Timeout: time.Second}, &Credentials{RefreshToken: "r"}) if err == nil { t.Fatal("a transport error must be reported") } } // The whole point: an expired token becomes a working one without the caller // knowing anything happened. func TestSourceRefreshesOnExpiry(t *testing.T) { var hits int srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { hits++ io.WriteString(w, `{"access_token":"fresh","refresh_token":"r2","expires_in":28800}`) })) defer srv.Close() t.Setenv("CLAUDECODE_TOKEN_URL", srv.URL) s := NewSource(&Credentials{ AccessToken: "stale", RefreshToken: "r1", ExpiresAt: time.Now().Add(-time.Hour), RefreshExpiresAt: time.Now().Add(24 * time.Hour), }, srv.Client()) for i := 0; i < 3; i++ { tok, err := s.Token(context.Background()) if err != nil { t.Fatalf("Token: %v", err) } if tok != "fresh" { t.Fatalf("Token = %q, want fresh", tok) } } if hits != 1 { t.Errorf("refreshed %d times, want 1 — the result should be cached", hits) } } // An expired refresh token cannot be refreshed. Fail with instructions rather // than sending a request that is certain to be rejected. func TestSourceExpiredRefreshTokenDoesNotCallOut(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Error("no request should be sent for a dead refresh token") })) defer srv.Close() t.Setenv("CLAUDECODE_TOKEN_URL", srv.URL) s := NewSource(&Credentials{ AccessToken: "stale", RefreshToken: "r", ExpiresAt: time.Now().Add(-time.Hour), RefreshExpiresAt: time.Now().Add(-time.Hour), }, srv.Client()) if _, err := s.Token(context.Background()); !errors.Is(err, ErrExpired) { t.Fatalf("err = %v, want ErrExpired", err) } } func TestSourceConcurrentTokensRefreshOnce(t *testing.T) { var mu sync.Mutex var hits int srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { mu.Lock() hits++ mu.Unlock() io.WriteString(w, `{"access_token":"fresh","refresh_token":"r2","expires_in":28800}`) })) defer srv.Close() t.Setenv("CLAUDECODE_TOKEN_URL", srv.URL) s := NewSource(&Credentials{ AccessToken: "stale", RefreshToken: "r1", ExpiresAt: time.Now().Add(-time.Hour), RefreshExpiresAt: time.Now().Add(24 * time.Hour), }, srv.Client()) var wg sync.WaitGroup for i := 0; i < 8; i++ { wg.Add(1) go func() { defer wg.Done() if _, err := s.Token(context.Background()); err != nil { t.Error(err) } }() } wg.Wait() if hits != 1 { t.Errorf("refreshed %d times under concurrency, want 1", hits) } } ``` Add `"encoding/json"`, `"io"`, `"net/http"`, `"net/http/httptest"` and `"sync"` to the imports. - [ ] **Step 2: Run the tests to verify they fail** ```bash cd /home/joni/.t3/worktrees/coding-harness/t3code-8b127a46 go test ./internal/claudecode/... ``` Expected: FAIL — `Refresh` undefined, and `NewSource` takes one argument. - [ ] **Step 3: Implement Refresh and make Source use it** In `internal/claudecode/claudecode.go`: ```go // TokenURL is the OAuth token endpoint. The environment variable exists so // tests can point it at an httptest server; nothing else should set it. var TokenURL = func() string { if u := os.Getenv("CLAUDECODE_TOKEN_URL"); u != "" { return u } return "https://console.anthropic.com/v1/oauth/token" } // ClientID identifies Claude Code to the token endpoint. const ClientID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e" // Refresh exchanges the refresh token for a new access token. It returns new // credentials and writes nothing: the file belongs to Claude Code, and two // writers racing over it is a worse problem than a token resolved twice. func Refresh(ctx context.Context, h *http.Client, c *Credentials) (*Credentials, error) { body, err := json.Marshal(map[string]string{ "grant_type": "refresh_token", "refresh_token": c.RefreshToken, "client_id": ClientID, }) if err != nil { return nil, err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, TokenURL(), bytes.NewReader(body)) if err != nil { return nil, err } req.Header.Set("content-type", "application/json") resp, err := h.Do(req) if err != nil { return nil, fmt.Errorf("claudecode: refreshing: %w", err) } defer resp.Body.Close() raw, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("claudecode: reading refresh response: %w", err) } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("claudecode: refresh rejected (%d): run `claude` to log in again: %s", resp.StatusCode, strings.TrimSpace(string(raw))) } var out struct { AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` ExpiresIn int64 `json:"expires_in"` } if err := json.Unmarshal(raw, &out); err != nil { return nil, fmt.Errorf("claudecode: decoding refresh response: %w", err) } if out.AccessToken == "" { return nil, errors.New("claudecode: refresh returned no access token") } next := *c next.AccessToken = out.AccessToken if out.RefreshToken != "" { next.RefreshToken = out.RefreshToken } next.ExpiresAt = time.Now().Add(time.Duration(out.ExpiresIn) * time.Second) return &next, nil } ``` Replace `Source` with the refreshing version: ```go // Source hands out an access token, refreshing it when it lapses. The refresh // is kept in memory for the process lifetime and never written back: Claude // Code owns that file and stays its only writer. type Source struct { mu sync.Mutex creds *Credentials http *http.Client } // NewSource wraps credentials in a token source. h is the client used for // refreshes. func NewSource(c *Credentials, h *http.Client) *Source { if h == nil { h = http.DefaultClient } return &Source{creds: c, http: h} } // Token returns a valid access token, refreshing first if the current one has // lapsed. The lock means concurrent requests — streaming and buffered can be // in flight together — produce one refresh rather than a thundering herd. func (s *Source) Token(ctx context.Context) (string, error) { s.mu.Lock() defer s.mu.Unlock() now := time.Now() if !s.creds.Expired(now) { return s.creds.AccessToken, nil } if s.creds.RefreshToken == "" || s.creds.RefreshExpired(now) { return "", ErrExpired } next, err := Refresh(ctx, s.http, s.creds) if err != nil { return "", err } s.creds = next return s.creds.AccessToken, nil } ``` Add `"bytes"`, `"io"`, `"net/http"`, `"strings"` and `"sync"` to the imports. - [ ] **Step 4: Update the call site** In `cmd/harness/main.go`, `resolveClaudeCode` now needs an HTTP client: ```go return claudecode.NewSource(creds, client.HTTP).Token, note, nil ``` `client.HTTP` is already a `*http.Client` with a 10-minute timeout. That is long for a token exchange but harmless, and reusing it keeps one connection pool. - [ ] **Step 5: Run the full suite** ```bash cd /home/joni/.t3/worktrees/coding-harness/t3code-8b127a46 gofmt -w cmd/ internal/ && go build ./... && go test ./... && go test -race ./internal/claudecode/... ``` Expected: PASS, including under `-race` — the concurrency test is there to be run that way. - [ ] **Step 6: Commit** ```bash cd /home/joni/.t3/worktrees/coding-harness/t3code-8b127a46 git add cmd/harness/ internal/claudecode/ git commit -m "claudecode: refresh the access token in memory Measured first: this endpoint does not rotate refresh tokens, so using one does not invalidate the copy Claude Code holds. Nothing is written back — Claude Code owns that file and stays its only writer. One refresh per expiry, mutex-guarded, because streaming and buffered requests can be in flight together." ``` --- ### Task 8: Document it **Files:** - Modify: `README.md:10-30` (Quick start and the auth paragraph), and the flag list at `:31-32` **Interfaces:** - Consumes: everything above. - Produces: nothing code depends on. - [ ] **Step 1: Rewrite the Quick start opening** The current text opens by telling you to export an API key. Lead with the case that now needs nothing: ```markdown ## Quick start ```bash go build -o harness ./cmd/harness ./harness -C /path/to/a/throwaway/clone # full-screen TUI ./harness -C /path/to/repo -p "why does TestFoo fail?" # one prompt ``` If you are logged into Claude Code, that is all the setup there is: the harness borrows the OAuth credential from `~/.claude/.credentials.json` when nothing else supplied one. Otherwise, or to use a different account: ```bash export ANTHROPIC_API_KEY=sk-ant-... ``` ``` - [ ] **Step 2: Extend the credentials paragraph** After the existing `-auth-token` / `-api-key` sentences, add: ```markdown The Claude Code login is the last resort, tried only when no flag and no environment variable supplied a credential, and only against Anthropic's own endpoint — a credential found on disk is not one you chose to send, so it is never forwarded to a `-base-url` you pointed the harness at. `-no-claude-code-auth` skips it entirely. Note that the credential is issued to Claude Code; using it from another client is outside Anthropic's terms for it, which API keys and gateway tokens do not carry. On macOS Claude Code keeps this in the Keychain rather than a file, so there is nothing to borrow there. ``` If Task 7 shipped, add: `An expired access token is refreshed in memory; the file is never written.` If it did not, add: `An expired access token is not refreshed — run `claude` and the harness picks up the new one.` - [ ] **Step 3: Add the flag to the list** Append `-no-claude-code-auth` to the "Useful flags" line at `README.md:31`. - [ ] **Step 4: Verify the README matches reality** ```bash cd /home/joni/.t3/worktrees/coding-harness/t3code-8b127a46 go build -o /tmp/harness ./cmd/harness && /tmp/harness -h 2>&1 | grep -A1 claude-code ``` Expected: the flag exists with the help text the README implies. - [ ] **Step 5: Commit** ```bash cd /home/joni/.t3/worktrees/coding-harness/t3code-8b127a46 git add README.md git commit -m "README: document borrowing the Claude Code login Leads with the no-setup case, and says plainly why the credential is never sent to a non-default endpoint and what its terms are." ``` --- ## Done when - `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. - The same command with `-base-url http://127.0.0.1:9` reports `no credentials`, not a connection error. - `go test ./...` and `go test -race ./internal/claudecode/...` pass. - Both probe findings are recorded in the spec, with the status codes observed. - If Task 6 found rotation, Tasks 7 is unstarted and the user has been told why.