From 9eb08811e210f48c8e644284b34388fc32a279ac Mon Sep 17 00:00:00 2001 From: euxaristia Date: Tue, 11 Aug 2026 21:09:45 -0400 Subject: [PATCH] Use an authenticated Claude probe to refresh expired tokens. The Claude refresh candidates only read local state, so neither could refresh an expired token in ~/.claude/.credentials.json. "auth status" prints the stored auth state and exits, despite a comment claiming it probes Anthropic auth servers, and "--version" never touches auth at all. When the token expired, EnsureTokenReady ran a refresh that could not succeed and the provider stayed degraded. Running each candidate against an expired access token paired with an invalid refresh token shows which ones attempt a refresh: "auth status", "--version" and "agents --json" leave the token untouched, while "doctor" and "mcp list" both try. Use "doctor" as the primary probe, since it resolves the account and completes quickly, and keep "mcp list" as a fallback. The candidate test now also rejects the three commands proven inert, so a future edit cannot quietly reintroduce a probe that never refreshes. Refs #24 --- pkg/credentials/credentials.go | 11 ++++---- pkg/credentials/credentials_test.go | 40 ++++++++++++++++++++--------- 2 files changed, 34 insertions(+), 17 deletions(-) diff --git a/pkg/credentials/credentials.go b/pkg/credentials/credentials.go index cd44226..77b719b 100644 --- a/pkg/credentials/credentials.go +++ b/pkg/credentials/credentials.go @@ -624,12 +624,13 @@ func getCliCandidates(provider models.UsageProvider) []candidate { {cli: "antigravity", args: []string{"models"}}, } case models.Claude: - // "auth status" probes Anthropic auth servers and forces Claude Code CLI to - // refresh expired OAuth tokens in ~/.claude/.credentials.json. "--version" is an - // offline fallback. + // "doctor" resolves the account, which makes Claude Code refresh an expired + // OAuth token in ~/.claude/.credentials.json as a side effect. "mcp list" + // refreshes the same way and serves as a fallback. Commands that only read + // local state ("auth status", "--version", "agents") never refresh. return []candidate{ - {cli: "claude", args: []string{"auth", "status"}}, - {cli: "claude", args: []string{"--version"}}, + {cli: "claude", args: []string{"doctor"}}, + {cli: "claude", args: []string{"mcp", "list"}}, } case models.Gemini: return []candidate{ diff --git a/pkg/credentials/credentials_test.go b/pkg/credentials/credentials_test.go index f881182..69be037 100644 --- a/pkg/credentials/credentials_test.go +++ b/pkg/credentials/credentials_test.go @@ -6,6 +6,7 @@ import ( "os/exec" "path/filepath" "runtime" + "slices" "testing" "time" @@ -18,15 +19,30 @@ func TestGetCliCandidatesClaude(t *testing.T) { t.Fatalf("expected candidates for Claude, got none") } - foundAuthStatus := false + foundDoctor := false for _, c := range candidates { - if c.cli == "claude" && len(c.args) >= 2 && c.args[0] == "auth" && c.args[1] == "status" { - foundAuthStatus = true + if c.cli == "claude" && len(c.args) == 1 && c.args[0] == "doctor" { + foundDoctor = true break } } - if !foundAuthStatus { - t.Errorf("expected candidate {cli: \"claude\", args: [\"auth\", \"status\"]}, got %v", candidates) + if !foundDoctor { + t.Errorf("expected authenticated Claude doctor probe, got %v", candidates) + } + + // These commands only read local state. Running one leaves an expired token + // expired, so a refresh candidate list containing one is silently useless. + nonRefreshing := [][]string{ + {"auth", "status"}, + {"--version"}, + {"agents", "--json"}, + } + for _, c := range candidates { + for _, bad := range nonRefreshing { + if c.cli == "claude" && slices.Equal(c.args, bad) { + t.Errorf("candidate %v cannot refresh an expired token", c) + } + } } } @@ -127,8 +143,8 @@ func TestForceRefreshViaCliHeadlessCandidateValidation(t *testing.T) { } // Create a single fake claude executable that models both candidates: - // "claude auth status" runs but does not refresh the token (first candidate), - // "claude --version" writes a fresh token (second candidate). It also + // "claude doctor" runs but does not refresh the token (first candidate), + // "claude mcp list" writes a fresh token (second candidate). It also // appends each invocation to a call log so the test can prove both ran. claudeCLI := filepath.Join(cliDir, "claude"+exeSuffix()) freshTime := time.Now().Add(24 * time.Hour).Format(time.RFC3339Nano) @@ -153,8 +169,8 @@ func main() { } } args := os.Args[1:] - if len(args) == 0 || args[0] != "--version" { - // First candidate: auth status. Run without refreshing the token. + if len(args) == 0 || args[0] != "mcp" { + // First candidate: doctor. Run without refreshing the token. os.Exit(0) } // Second candidate: write a fresh token. @@ -187,8 +203,8 @@ func main() { t.Fatal("expected IsClaudeWorking() to return false for expired token") } - // Call ForceRefreshViaCliHeadless - should try "claude auth status" first - // (token stays expired, loop continues), then "claude --version" (refreshes token). + // Call ForceRefreshViaCliHeadless - should try "claude doctor" first + // (token stays expired, loop continues), then "claude mcp list" (refreshes token). result := ForceRefreshViaCliHeadless(models.Claude) if !result { t.Fatal("expected ForceRefreshViaCliHeadless to return true after second candidate refreshes token") @@ -215,7 +231,7 @@ func main() { t.Fatalf("expected call log to be written: %v", err) } got := string(callLog) - want := "auth status\n--version\n" + want := "doctor\nmcp list\n" if got != want { t.Errorf("expected candidates checked in order %q, got %q", want, got) }