Repository navigation
Description
Summary
On macOS, every usage widget (Session Usage, Weekly Usage, etc.) renders [No credentials] on some machines even though a valid Claude Code OAuth token is present in the keychain. The token is simply stored under a keychain item that ccstatusline's lookup doesn't select.
Root cause: the login keychain can contain more than one generic-password item under the service name Claude Code-credentials, differing only by account. ccstatusline reads with the service name only:
security find-generic-password -s "Claude Code-credentials" -w
With multiple items sharing that service, security returns whichever it matches first — which may be an item that has no claudeAiOauth (so parseUsageAccessToken returns null) — and ccstatusline never tries the other account.
How this happens
Claude Code itself always reads/writes the credential with an explicit account (the OS username):
security ... generic-password -a <os-username> -s "Claude Code-credentials" ...
On an affected machine, the keychain ends up with (at least) two items:
| service | account | contents |
|---|---|---|
Claude Code-credentials |
<os-username> (e.g. vishnu) |
{ mcpOAuth, claudeAiOauth } ← the real token |
Claude Code-credentials |
unknown |
{ mcpOAuth } only ← no usage token |
The unknown-account item appears to be written by an MCP-OAuth code path that didn't resolve a username. security -s with no -a can return that one first → [No credentials], deterministically wrong on that machine.
(There may also be many Claude Code-credentials-<hex> suffixed services — these are per-server MCP OAuth tokens and do not contain claudeAiOauth; the existing dump-keychain fallback that scans suffixed services can't help here because it excludes the plain service name and none of the suffixed items hold the account token.)
Reproduce
Structure-only probe (does not print token values):
# Enumerate accounts under the plain service:
security dump-keychain 2>/dev/null \
| grep -B2 '"svce"<blob>="Claude Code-credentials"$' \
| grep '"acct"' | sort -u
# On an affected machine you'll see two accounts, e.g. "unknown" and "<your-username>".
# Which item has the token? (structure only — prints true/false, never the token)
has_token() { python3 -c 'import sys,json; print("has claudeAiOauth:", "claudeAiOauth" in (json.load(sys.stdin) or {}))'; }
security find-generic-password -s "Claude Code-credentials" -w | has_token # -> false (service-only match: MCP-only)
security find-generic-password -s "Claude Code-credentials" -a "$(id -un)" -w | has_token # -> true (OS-username account)Proposed fix
Make the keychain read account-aware, mirroring how Claude Code itself accesses the item:
- Keep the current service-only read (works on single-item keychains).
- If it yields no
claudeAiOauth, read-s "Claude Code-credentials" -a <os.userInfo().username>. - Existing suffixed-service and
.credentials.jsonfallbacks unchanged.
This is minimal, matches Claude Code's own access pattern (no fragile reverse-engineering), and is safe on machines with a single credential item (the new read only fires when the service-only read misses). It resolves the OS-username-account case observed here; it deliberately doesn't guess non-username account strings.
I have this fix implemented with unit tests and can open a PR if you'd like — flagging first since #219 and #266 also touch per-account/auth-scoping and I don't want to collide.
Environment
- macOS (Darwin arm64), Claude Code (native install)
- ccstatusline latest
main - Distinct from macOS: Session Usage shows 'no credentials' when OAuth token is in credentials file instead of keychain #236 (that was token-in-file-instead-of-keychain; here the token is in the keychain, just under a non-default account).
Activity
tim-fin commentedon Jul 26, 2026
Thanks for the detailed writeup — the mechanism you describe lines up with what I see reading
src/utils/usage-fetch.ts.One extra detail that might be worth folding in: the existing multi-service fallback can't rescue this case, so the fix probably has to go a little deeper than swapping the lookup.
getUsageToken()tries three things in order:The second step looks like it should catch a missed item, but I don't think it can here, for three reasons:
parseMacKeychainCredentialCandidates()skips the exact service it would need —service === MACOS_USAGE_CREDENTIALS_SERVICEhitscontinue, soClaude Code-credentialsitself is never a candidate.acctwould collapse to one entry even if step 1 didn't exclude them.readMacKeychainSecret()still shells out asfind-generic-password -s <service> -wwith no-a, so there's no way to ask for the other account.That mechanism seems designed for differently named services (the
Claude Code-credentials-hashedcase inusage-token.test.ts) rather than multiple accounts under one name — which is exactly the shape of the problem you hit. So it might need to track(service, account)pairs and read with-a <account> -s <service> -w, usingdump-keychainto enumerate the accounts.Fair warning that I haven't reproduced the duplicate-item state on my own machine, so this is from reading the code rather than from a live repro — happy to be corrected if I've misread it.
Are you planning to pick this one up? You know this area far better than I do, so I don't want to tread on your toes. If you'd rather not, I'm glad to take a run at it — and either way happy to test a patch on macOS.
Cloudmancermedia commentedon Jul 28, 2026
I have noticed something that seems to have the same root cause but slightly different symptom.
I run two Claude homes:
~/.claude, and~/.claude-workviaCLAUDE_CONFIG_DIR. The widgets don't error, but they render the wrong account's numbers. Work showedWk 54%while that account was at 97%. Both homes showed an identical 5-hour reset.The cache is also stamped with the wrong token:
getUsageToken()always asks for the plain service name. But Claude Code gives each home its own:Claude Code-credentials-<first 8 hex of sha256(CLAUDE_CONFIG_DIR)>. I checked, andsha256("/Users/<user>/.claude-work")[:8]is exactly the suffix on the deployed item. So a non-default home never reads its own credential.One minor correction: The issue says suffixed services are MCP-only and lack
claudeAiOauthbut this is not always the case. Here the suffixed one is the work home's credential:Per-config-dir credentials and per-server MCP tokens look the same from the name alone. That makes the
dump-keychainfallback a second way to land on the wrong account: it sorts bymdat(last-modified time), newest first, so it picks whichever home refreshed its token most recently.Proposed order, most specific first:
1–2 fire only in a non-default home; 3–6 are today's behavior plus your account-aware read, so single-account users are unaffected.
I can't reproduce
[No credentials]since I only have one item per service configured.#317 / #339
Already addresses a similar bug in a different widget and seem to have the same setup I do.
Seems like others have already started fixes related to this (#266) so just wanted to give some additional context on the issue. I have a local script working in my own setup that fixes this for me in the meantime.
tim-fin commentedon Jul 29, 2026
I can confirm the two mechanisms here — one from the Claude Code binary, one from ccstatusline's source.
The suffix scheme. The service-name builder in the current native binary (2.1.220), deobfuscated:
So a suffixed service can indeed be a full per-home credential, exactly as your keychain listing shows — the suffix appears whenever a non-default config dir is active. Two details for whoever implements the lookup: the path is NFC-normalized before hashing (visible on the override branch at least), and a
CLAUDE_SECURESTORAGE_CONFIG_DIRenv var takes precedence overCLAUDE_CONFIG_DIRas the hash input when set (an empty value forces the plain name).The mtime sort. Confirmed in source:
parseMacKeychainCredentialCandidates()orders candidates viasortMacKeychainCredentialCandidates(), newestmdatfirst — so the fallback scan does land on whichever home refreshed its token most recently, as you said.On implementability: step 1 of your ordering may be cheaper than it looks. ccstatusline already has
getClaudeConfigDir()(src/utils/claude-settings.ts), which respectsCLAUDE_CONFIG_DIR— the statusline process inherits Claude Code's environment, which is also what the #339 fix for #317 relies on — and the.credentials.jsonfallback (your step 6) already calls it. So the config-dir-aware read needs only the hash, no new plumbing.Also flagging in case you haven't seen it: #532 went up a few hours after your comment and implements the account-scoped read of the plain service name (your step 3, ordered after today's service-only read) — i.e. the
[No credentials]case this issue is about. The config-dir-aware steps 1–2 look closer to the ground #266 is covering.fix(usage): read the CLAUDE_CONFIG_DIR keychain credential first on m…
nguerrier commentedon Sep 1, 2026
I hit the two-homes variant described above (
~/.claude+~/.claude-persoviaCLAUDE_CONFIG_DIR): session/weekly were right (they come from the payload'srate_limits), butfable-weekly-usageshowed the other account's number, andusage.jsonwas stamped with the wrong token fingerprint — exactly what @Cloudmancermedia saw.I opened #573 for that part only:
getUsageToken()now triesClaude Code-credentials-<sha256(configDir)[:8]>first (same service-name rule @tim-fin extracted from the binary, including theCLAUDE_SECURESTORAGE_CONFIG_DIRoverride and NFC normalization), then falls through to the existing chain. No behavior change whenCLAUDE_CONFIG_DIRis unset. Cache scoping per profile is left to #266.If either of you wants to try it on macOS:
With
CLAUDE_CONFIG_DIRset, the Fable/Opus weekly widgets should now match/usagefor that profile.fix(usage): read the CLAUDE_CONFIG_DIR keychain credential first on m…
twjohnwu commentedon Sep 27, 2026
Another variant of the same root cause, with a misleading symptom: the first-match item can hold a stale but well-formed
claudeAiOauth(an old login under a different account name). ItsaccessTokenis expired, and/api/oauth/usageanswers an expired token with429+ a fixed one-hourretry-after, not401. So the widget looks permanently rate-limited rather than[No credentials], while Claude Code itself (which reads with-a <os-username>) keeps working.Evidence on one machine, same minute:
Probing did not extend the window (three hits in 10 min, same end time), and
/logout+/loginrewrote the current-account item but the service-only lookup still returned the old one.Two suggestions on top of the account-aware read: (1) check
claudeAiOauth.expiresAtbefore using a token and skip the request when it's in the past; (2) don't treat a429from an expired token as a rate limit.fix(usage): prefer current user's keychain item, skip expired tokens
nguerrier commentedon Oct 4, 2026
Thanks for the detailed repro. To clarify scope: #573 only changes the lookup when
CLAUDE_CONFIG_DIRis set. It reads the profile's own suffixed item and no longer falls back to the plain service or the candidate scan. The default-profile case you describe (several items under the plainClaude Code-credentialsservice) is untouched by it; that's what #532's account-scoped read addresses.+1 on checking
expiresAtbefore the request: it would also harden the suffixed path, which #573 still reads by service name only.