Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions commands/cc_statusline_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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
Expand Down
53 changes: 37 additions & 16 deletions daemon/anthropic_ratelimit.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Comment on lines +148 to 158

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

In Go, json.Unmarshal distinguishes between a missing field (which leaves the slice as nil) and an explicitly empty array [] (which initializes the slice as a non-nil empty slice []string{}).

Currently, len(scopes) == 0 returns true for both cases. However, if scopes is explicitly empty (scopes != nil), we know for sure that the token has no scopes granted, so we can safely return false and skip the API call immediately. We should only fallback to true when scopes is nil (unknown/older credentials format).

Suggested change
func hasUsageScope(scopes []string) bool {
if len(scopes) == 0 {
return true
}
for _, s := range scopes {
if s == anthropicUsageRequiredScope {
return true
}
}
return false
}
func hasUsageScope(scopes []string) bool {
if scopes == nil {
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.
Expand Down
4 changes: 2 additions & 2 deletions daemon/anthropic_ratelimit_more_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
Expand All @@ -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")
Expand Down
35 changes: 27 additions & 8 deletions daemon/anthropic_ratelimit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -289,35 +289,53 @@ 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)
}

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)
}

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)
Expand All @@ -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)
Expand All @@ -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)
Expand Down
39 changes: 33 additions & 6 deletions daemon/cc_info_timer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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
}
Comment on lines +439 to +446

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

When the token lacks the required usage scope, we set lastError to "api:scope" but do not clear the previously cached usage data. Because formatQuotaPart prioritizes displaying non-nil usage data over the error string, the statusline will continue to show stale, outdated quota information from the previous token instead of displaying the api:scope error.

To ensure the error is visible to the user, we should clear s.rateLimitCache.usage when a scope error is detected.

Suggested change
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
}
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.usage = nil
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)
}
Comment on lines 451 to 477

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Repeatedly calling errors.As(err, &apiErr) in each switch case is redundant and less idiomatic. Instead, we can call errors.As once to extract the *anthropicAPIError and then use a standard switch on apiErr.StatusCode.

Additionally, we should clear s.rateLimitCache.usage on StatusForbidden and StatusUnauthorized errors so that the statusline correctly displays the error instead of stale cached usage data.

		var apiErr *anthropicAPIError
		if errors.As(err, &apiErr) {
			switch apiErr.StatusCode {
			case 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 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.usage = nil
				s.rateLimitCache.backoffUntil = time.Now().Add(anthropicRateLimitBackoff)
			case 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.usage = nil
				s.rateLimitCache.backoffUntil = time.Now().Add(anthropicRateLimitBackoff)
			default:
				slog.Warn("Failed to fetch Anthropic usage", slog.Any("err", err))
				s.rateLimitCache.lastError = shortenAPIError(err)
			}
		} else {
			slog.Warn("Failed to fetch Anthropic usage", slog.Any("err", err))
			s.rateLimitCache.lastError = shortenAPIError(err)
		}

s.rateLimitCache.mu.Unlock()
return
Expand Down
62 changes: 62 additions & 0 deletions daemon/cc_info_timer_extra_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"sync/atomic"
"testing"
Expand Down Expand Up @@ -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")
}
Loading