diff --git a/commands/cc_statusline_test.go b/commands/cc_statusline_test.go index bda7889..11bfe80 100644 --- a/commands/cc_statusline_test.go +++ b/commands/cc_statusline_test.go @@ -167,8 +167,8 @@ func (s *CCStatuslineTestSuite) TestFormatStatuslineOutput_AllValues() { assert.Contains(s.T(), output, "🤖 claude-opus-4") assert.Contains(s.T(), output, "$1.23") assert.Contains(s.T(), output, "$4.56") - assert.Contains(s.T(), output, "1h1m") // Session time (3661 seconds = 1h 1m 1s) - assert.Contains(s.T(), output, "75%") // Context percentage + assert.Contains(s.T(), output, "1h1m") // Session time (3661 seconds = 1h 1m 1s) + assert.Contains(s.T(), output, "75%") // Context percentage } func (s *CCStatuslineTestSuite) TestFormatStatuslineOutput_WithDirtyBranch() { @@ -389,6 +389,12 @@ func (s *CCStatuslineTestSuite) TestFormatQuotaPart_WithAPIError() { assert.Contains(s.T(), result, "🚦 err:api:403") } +func (s *CCStatuslineTestSuite) TestFormatQuotaPart_WithScopeError() { + // Token lacks the user:profile scope (e.g. a setup-token); show a clear, actionable label. + result := formatQuotaPart(nil, nil, "api:scope") + assert.Contains(s.T(), result, "🚦 err:api:scope") +} + func (s *CCStatuslineTestSuite) TestFormatQuotaPart_ErrorIgnoredWhenDataPresent() { fh := 10.0 sd := 20.0 diff --git a/daemon/anthropic_ratelimit.go b/daemon/anthropic_ratelimit.go index ea515c8..73a83b7 100644 --- a/daemon/anthropic_ratelimit.go +++ b/daemon/anthropic_ratelimit.go @@ -29,6 +29,11 @@ const claudeCodeFallbackVersion = "2.0.0" // anthropicUsageURL is the OAuth usage endpoint. It is a var (not a const) so tests can override it. var anthropicUsageURL = "https://api.anthropic.com/api/oauth/usage" +// anthropicUsageRequiredScope is the OAuth scope the usage endpoint gates on. Interactive Claude Code +// login tokens carry it; tokens minted by `claude setup-token` (e.g. CLAUDE_CODE_OAUTH_TOKEN in CI) do +// not, so the endpoint authenticates them but returns 403 "does not meet scope requirement user:profile". +const anthropicUsageRequiredScope = "user:profile" + // AnthropicRateLimitData holds the parsed rate limit utilization data type AnthropicRateLimitData struct { FiveHourUtilization float64 @@ -82,58 +87,74 @@ type claudeCodeOAuthEntry struct { RateLimitTier any `json:"rateLimitTier"` } -// fetchClaudeCodeOAuthToken reads the OAuth token from the platform-specific credential store. +// fetchClaudeCodeOAuthToken reads the OAuth token and its scopes from the platform-specific +// credential store. // macOS: reads from Keychain via `security` command. // Linux: reads from ~/.claude/.credentials.json file. -// Returns ("", nil) on unsupported platforms. -func fetchClaudeCodeOAuthToken() (string, error) { +// Returns ("", nil, nil) on unsupported platforms. +func fetchClaudeCodeOAuthToken() (string, []string, error) { switch runtime.GOOS { case "darwin": return fetchOAuthTokenFromKeychain() case "linux": return fetchOAuthTokenFromCredentialsFile() default: - return "", nil + return "", nil, nil } } -// fetchOAuthTokenFromKeychain reads the OAuth token from macOS Keychain. -func fetchOAuthTokenFromKeychain() (string, error) { +// fetchOAuthTokenFromKeychain reads the OAuth token and scopes from macOS Keychain. +func fetchOAuthTokenFromKeychain() (string, []string, error) { out, err := exec.Command("security", "find-generic-password", "-s", "Claude Code-credentials", "-w").Output() if err != nil { - return "", fmt.Errorf("keychain lookup failed: %w", err) + return "", nil, fmt.Errorf("keychain lookup failed: %w", err) } return parseOAuthTokenFromJSON(out) } -// fetchOAuthTokenFromCredentialsFile reads the OAuth token from ~/.claude/.credentials.json. -func fetchOAuthTokenFromCredentialsFile() (string, error) { +// fetchOAuthTokenFromCredentialsFile reads the OAuth token and scopes from ~/.claude/.credentials.json. +func fetchOAuthTokenFromCredentialsFile() (string, []string, error) { homeDir, err := os.UserHomeDir() if err != nil { - return "", fmt.Errorf("failed to get home directory: %w", err) + return "", nil, fmt.Errorf("failed to get home directory: %w", err) } data, err := os.ReadFile(filepath.Join(homeDir, ".claude", ".credentials.json")) if err != nil { - return "", fmt.Errorf("credentials file read failed: %w", err) + return "", nil, fmt.Errorf("credentials file read failed: %w", err) } return parseOAuthTokenFromJSON(data) } -// parseOAuthTokenFromJSON parses Claude Code credentials JSON and extracts the OAuth access token. -func parseOAuthTokenFromJSON(data []byte) (string, error) { +// parseOAuthTokenFromJSON parses Claude Code credentials JSON and extracts the OAuth access token +// and the scopes granted to it. +func parseOAuthTokenFromJSON(data []byte) (string, []string, error) { var creds claudeCodeCredentials if err := json.Unmarshal(data, &creds); err != nil { - return "", fmt.Errorf("failed to parse credentials JSON: %w", err) + return "", nil, fmt.Errorf("failed to parse credentials JSON: %w", err) } if creds.ClaudeAiOauth == nil || creds.ClaudeAiOauth.AccessToken == "" { - return "", fmt.Errorf("no OAuth access token found in credentials") + return "", nil, fmt.Errorf("no OAuth access token found in credentials") } - return creds.ClaudeAiOauth.AccessToken, nil + return creds.ClaudeAiOauth.AccessToken, creds.ClaudeAiOauth.Scopes, nil +} + +// hasUsageScope reports whether the token can read the usage endpoint. When scopes is empty +// (unknown), returns true so we still attempt the fetch and let the reactive 403 path decide. +func hasUsageScope(scopes []string) bool { + if len(scopes) == 0 { + return true + } + for _, s := range scopes { + if s == anthropicUsageRequiredScope { + return true + } + } + return false } // fetchAnthropicUsage calls the Anthropic OAuth usage API and returns rate limit data. diff --git a/daemon/anthropic_ratelimit_more_test.go b/daemon/anthropic_ratelimit_more_test.go index e6d38eb..2a68093 100644 --- a/daemon/anthropic_ratelimit_more_test.go +++ b/daemon/anthropic_ratelimit_more_test.go @@ -26,7 +26,7 @@ func TestFetchClaudeCodeOAuthToken_LinuxDispatch(t *testing.T) { content := `{"claudeAiOauth":{"accessToken":"sk-dispatch-token"}}` require.NoError(t, os.WriteFile(filepath.Join(claudeDir, ".credentials.json"), []byte(content), 0o600)) - tok, err := fetchClaudeCodeOAuthToken() + tok, _, err := fetchClaudeCodeOAuthToken() require.NoError(t, err) assert.Equal(t, "sk-dispatch-token", tok) }) @@ -35,7 +35,7 @@ func TestFetchClaudeCodeOAuthToken_LinuxDispatch(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) - tok, err := fetchClaudeCodeOAuthToken() + tok, _, err := fetchClaudeCodeOAuthToken() require.Error(t, err) assert.Empty(t, tok) assert.Contains(t, err.Error(), "credentials file read failed") diff --git a/daemon/anthropic_ratelimit_test.go b/daemon/anthropic_ratelimit_test.go index c0e66d6..46a60f1 100644 --- a/daemon/anthropic_ratelimit_test.go +++ b/daemon/anthropic_ratelimit_test.go @@ -289,14 +289,15 @@ func TestAnthropicRateLimitCache_GetCachedRateLimit_ReturnsCopy(t *testing.T) { func TestParseOAuthTokenFromJSON_Valid(t *testing.T) { raw := `{"claudeAiOauth":{"accessToken":"sk-ant-test-token-123","refreshToken":"sk-ref","expiresAt":1773399176544,"scopes":["user:inference"],"subscriptionType":"max","rateLimitTier":"default_claude_max_5x"}}` - token, err := parseOAuthTokenFromJSON([]byte(raw)) + token, scopes, err := parseOAuthTokenFromJSON([]byte(raw)) assert.NoError(t, err) assert.Equal(t, "sk-ant-test-token-123", token) + assert.Equal(t, []string{"user:inference"}, scopes) } func TestParseOAuthTokenFromJSON_MissingOAuth(t *testing.T) { raw := `{"someOtherKey":"value"}` - token, err := parseOAuthTokenFromJSON([]byte(raw)) + token, _, err := parseOAuthTokenFromJSON([]byte(raw)) assert.Error(t, err) assert.Contains(t, err.Error(), "no OAuth access token found") assert.Empty(t, token) @@ -304,7 +305,7 @@ func TestParseOAuthTokenFromJSON_MissingOAuth(t *testing.T) { func TestParseOAuthTokenFromJSON_EmptyToken(t *testing.T) { raw := `{"claudeAiOauth":{"accessToken":""}}` - token, err := parseOAuthTokenFromJSON([]byte(raw)) + token, _, err := parseOAuthTokenFromJSON([]byte(raw)) assert.Error(t, err) assert.Contains(t, err.Error(), "no OAuth access token found") assert.Empty(t, token) @@ -312,12 +313,29 @@ func TestParseOAuthTokenFromJSON_EmptyToken(t *testing.T) { func TestParseOAuthTokenFromJSON_InvalidJSON(t *testing.T) { raw := `not-json` - token, err := parseOAuthTokenFromJSON([]byte(raw)) + token, _, err := parseOAuthTokenFromJSON([]byte(raw)) assert.Error(t, err) assert.Contains(t, err.Error(), "failed to parse credentials JSON") assert.Empty(t, token) } +func TestHasUsageScope(t *testing.T) { + cases := []struct { + name string + scopes []string + want bool + }{ + {"has user:profile", []string{"user:inference", "user:profile"}, true}, + {"setup-token without profile", []string{"user:inference"}, false}, + {"empty scopes are treated as unknown", nil, true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + assert.Equal(t, c.want, hasUsageScope(c.scopes)) + }) + } +} + func TestFetchOAuthTokenFromCredentialsFile_Valid(t *testing.T) { tmpDir := t.TempDir() t.Setenv("HOME", tmpDir) @@ -326,20 +344,21 @@ func TestFetchOAuthTokenFromCredentialsFile_Valid(t *testing.T) { err := os.MkdirAll(claudeDir, 0700) assert.NoError(t, err) - content := `{"claudeAiOauth":{"accessToken":"sk-test-linux-token","refreshToken":"sk-ref","expiresAt":1773399176544}}` + content := `{"claudeAiOauth":{"accessToken":"sk-test-linux-token","refreshToken":"sk-ref","expiresAt":1773399176544,"scopes":["user:inference","user:profile"]}}` err = os.WriteFile(filepath.Join(claudeDir, ".credentials.json"), []byte(content), 0600) assert.NoError(t, err) - token, err := fetchOAuthTokenFromCredentialsFile() + token, scopes, err := fetchOAuthTokenFromCredentialsFile() assert.NoError(t, err) assert.Equal(t, "sk-test-linux-token", token) + assert.Equal(t, []string{"user:inference", "user:profile"}, scopes) } func TestFetchOAuthTokenFromCredentialsFile_MissingFile(t *testing.T) { tmpDir := t.TempDir() t.Setenv("HOME", tmpDir) - token, err := fetchOAuthTokenFromCredentialsFile() + token, _, err := fetchOAuthTokenFromCredentialsFile() assert.Error(t, err) assert.Contains(t, err.Error(), "credentials file read failed") assert.Empty(t, token) @@ -356,7 +375,7 @@ func TestFetchOAuthTokenFromCredentialsFile_InvalidJSON(t *testing.T) { err = os.WriteFile(filepath.Join(claudeDir, ".credentials.json"), []byte("not-json"), 0600) assert.NoError(t, err) - token, err := fetchOAuthTokenFromCredentialsFile() + token, _, err := fetchOAuthTokenFromCredentialsFile() assert.Error(t, err) assert.Contains(t, err.Error(), "failed to parse credentials JSON") assert.Empty(t, token) diff --git a/daemon/cc_info_timer.go b/daemon/cc_info_timer.go index b34154e..976ce3a 100644 --- a/daemon/cc_info_timer.go +++ b/daemon/cc_info_timer.go @@ -424,7 +424,7 @@ func (s *CCInfoTimerService) fetchRateLimit(ctx context.Context) { s.rateLimitCache.mu.Unlock() // Read token fresh from Keychain (not cached) - token, err := fetchClaudeCodeOAuthToken() + token, scopes, err := fetchClaudeCodeOAuthToken() if err != nil || token == "" { slog.Debug("Failed to get Claude Code OAuth token", slog.Any("err", err)) s.rateLimitCache.mu.Lock() @@ -433,20 +433,47 @@ func (s *CCInfoTimerService) fetchRateLimit(ctx context.Context) { return } + // Skip the doomed call when the token can't read the usage endpoint (e.g. a `claude setup-token` + // used in CI lacks the user:profile scope). No long backoff: re-reading local creds is cheap and + // lets us recover within the normal TTL if the user re-authenticates with a scoped token. + if !hasUsageScope(scopes) { + slog.Debug("Claude Code token lacks usage scope; skipping Anthropic usage fetch", + slog.String("required", anthropicUsageRequiredScope)) + s.rateLimitCache.mu.Lock() + s.rateLimitCache.lastError = "api:scope" + s.rateLimitCache.mu.Unlock() + return + } + usage, err := fetchAnthropicUsage(ctx, token, s.GetClaudeCodeVersion()) if err != nil { - slog.Warn("Failed to fetch Anthropic usage", slog.Any("err", err)) s.rateLimitCache.mu.Lock() - s.rateLimitCache.lastError = shortenAPIError(err) - // On rate limiting, back off longer than the normal TTL to avoid hammering the throttled - // bucket. Honor Retry-After when provided, otherwise use the default backoff. var apiErr *anthropicAPIError - if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusTooManyRequests { + switch { + case errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusTooManyRequests: + // On rate limiting, back off longer than the normal TTL to avoid hammering the throttled + // bucket. Honor Retry-After when provided, otherwise use the default backoff. + slog.Warn("Failed to fetch Anthropic usage", slog.Any("err", err)) + s.rateLimitCache.lastError = shortenAPIError(err) backoff := apiErr.RetryAfter if backoff < anthropicRateLimitBackoff { backoff = anthropicRateLimitBackoff } s.rateLimitCache.backoffUntil = time.Now().Add(backoff) + case errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusForbidden: + // 403 = token authenticated but lacks the usage scope (or org access). Expected for + // setup-tokens and non-recoverable for this token, so log quietly and back off. + slog.Debug("Anthropic usage forbidden for this token", slog.Any("err", err)) + s.rateLimitCache.lastError = "api:scope" + s.rateLimitCache.backoffUntil = time.Now().Add(anthropicRateLimitBackoff) + case errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusUnauthorized: + // 401 = expired/invalid token; won't self-heal until re-auth, so back off too. + slog.Debug("Anthropic usage unauthorized", slog.Any("err", err)) + s.rateLimitCache.lastError = "api:401" + s.rateLimitCache.backoffUntil = time.Now().Add(anthropicRateLimitBackoff) + default: + slog.Warn("Failed to fetch Anthropic usage", slog.Any("err", err)) + s.rateLimitCache.lastError = shortenAPIError(err) } s.rateLimitCache.mu.Unlock() return diff --git a/daemon/cc_info_timer_extra_test.go b/daemon/cc_info_timer_extra_test.go index 9cefa28..0b0f440 100644 --- a/daemon/cc_info_timer_extra_test.go +++ b/daemon/cc_info_timer_extra_test.go @@ -5,6 +5,8 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "os" + "path/filepath" "runtime" "sync/atomic" "testing" @@ -167,3 +169,63 @@ func TestFetchRateLimit_OAuthMissingSetsError(t *testing.T) { assert.Equal(t, "oauth", service.GetCachedRateLimitError()) assert.Nil(t, service.GetCachedRateLimit()) } + +func TestFetchRateLimit_MissingScopeSkipsFetch(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("token-from-file path is exercised on linux") + } + home := t.TempDir() + t.Setenv("HOME", home) + claudeDir := filepath.Join(home, ".claude") + require.NoError(t, os.MkdirAll(claudeDir, 0o700)) + // setup-token style creds: a valid token that lacks the user:profile scope. + content := `{"claudeAiOauth":{"accessToken":"sk-setup","scopes":["user:inference"]}}` + require.NoError(t, os.WriteFile(filepath.Join(claudeDir, ".credentials.json"), []byte(content), 0o600)) + + // Point the usage URL at a server that flags if it is ever called. + var called int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&called, 1) + w.WriteHeader(http.StatusForbidden) + })) + defer server.Close() + withTestUsageURL(t, server.URL) + + service := NewCCInfoTimerService(&model.ShellTimeConfig{Token: "tok"}) + service.fetchRateLimit(context.Background()) + + assert.Equal(t, "api:scope", service.GetCachedRateLimitError()) + assert.Nil(t, service.GetCachedRateLimit()) + assert.Equal(t, int32(0), atomic.LoadInt32(&called), "usage endpoint must not be called when scope is missing") +} + +func TestFetchRateLimit_Forbidden403SetsScopeError(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("token-from-file path is exercised on linux") + } + home := t.TempDir() + t.Setenv("HOME", home) + claudeDir := filepath.Join(home, ".claude") + require.NoError(t, os.MkdirAll(claudeDir, 0o700)) + // Token claims the required scope, so the proactive check passes and we hit the API, + // which still returns 403 (e.g. org access restriction). + content := `{"claudeAiOauth":{"accessToken":"sk-login","scopes":["user:inference","user:profile"]}}` + require.NoError(t, os.WriteFile(filepath.Join(claudeDir, ".credentials.json"), []byte(content), 0o600)) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + })) + defer server.Close() + withTestUsageURL(t, server.URL) + + service := NewCCInfoTimerService(&model.ShellTimeConfig{Token: "tok"}) + service.fetchRateLimit(context.Background()) + + assert.Equal(t, "api:scope", service.GetCachedRateLimitError()) + assert.Nil(t, service.GetCachedRateLimit()) + // A backoff window must be set so the daemon stops hammering the forbidden endpoint. + service.rateLimitCache.mu.RLock() + backoff := service.rateLimitCache.backoffUntil + service.rateLimitCache.mu.RUnlock() + assert.True(t, backoff.After(time.Now()), "403 should set a backoff window") +}