fix(usage): read the CLAUDE_CONFIG_DIR keychain credential first on macOS - #573
fix(usage): read the CLAUDE_CONFIG_DIR keychain credential first on macOS#573nguerrier wants to merge 2 commits into
Conversation
…acOS Claude Code stores each non-default profile's OAuth credential under its own keychain service, "Claude Code-credentials-<sha256(configDir)[:8]>", whenever CLAUDE_CONFIG_DIR is set. getUsageToken() only ever asked for the plain "Claude Code-credentials" item first, so a session running under another config dir reported the default profile's account in every widget that needs the usage API. Add getMacKeychainConfigDirService(), mirroring Claude Code's own service-name builder (CLAUDE_SECURESTORAGE_CONFIG_DIR override, empty value = plain name, NFC-normalized path), and try that service before the existing lookup chain. No change without CLAUDE_CONFIG_DIR. Refs sirmalloc#521
a828096 to
ae1f8cb
Compare
|
Checked this against the builder in 2.1.258. One correction to my earlier comment in your favour, and one input I think is wrong. NFC is settled — your version is right. I'd hedged with "visible on the override branch at least". That qualifier can go. The builder is unchanged: const dir = override !== undefined ? override.normalize("NFC") : effectiveConfigDir();and const rawConfigDir = () => process.env.CLAUDE_CONFIG_DIR;
const effectiveConfigDir = cached(
() => (rawConfigDir() ?? join(homedir(), ".claude")).normalize("NFC"), rawConfigDir);So NFC applies on both branches and your unconditional The hash input is the problem. That snippet hashes the raw env value — no
The last row is the odd one: the guard has already decided we're on a non-default profile, but the hash input silently becomes the default directory, so we look up a suffixed name built from Since the guard already returns const configDir = (override ?? process.env.CLAUDE_CONFIG_DIR!).normalize('NFC');That makes all four rows hit and removes the split where the guard reads Minor: a miss is silent. The description says a mismatch "falls back to today's behavior", but for the user this targets today's behavior is the bug — the chain continues to the plain service and the mtime-sorted scan, the two paths that surface the other account. Since the expected name is now computable, it may be worth logging a miss, or skipping the candidate scan when a config dir is active: if the active profile's exact service name isn't present, every remaining suffixed item belongs to another home or an MCP server. I haven't run this end to end — I only keep one authenticated home, so I can't exercise the two-homes path without adding an account. The above is from reading the branch against the 2.1.258 builder. |
…her profiles Review follow-ups on sirmalloc#573: - The service-name hash input is now the raw environment value (CLAUDE_SECURESTORAGE_CONFIG_DIR ?? CLAUDE_CONFIG_DIR), NFC-normalized but never resolved, matching Claude Code's builder exactly: its effectiveConfigDir is (raw ?? join(homedir(), ".claude")).normalize("NFC") with no path.resolve or existence check. Trailing slashes, relative paths and env values pointing at non-directories now hash to the same service name Claude Code emits. - When a config dir is active, a keychain miss no longer falls through to the plain service or the mtime-sorted candidate scan: those items all belong to other profiles or MCP servers, and surfacing them is the bug this PR fixes. Only the profile's own .credentials.json remains as a fallback. - usage-token-buffer.test.ts now sheds CLAUDE_CONFIG_DIR leaking in from the environment, since the candidate scan it exercises only runs for the default profile.
|
Both points verified against the binary and taken. Hash input — switched to the raw env value: Miss behavior — with a config dir active,
|
Problem
On macOS,
getUsageToken()always reads the plainClaude Code-credentialskeychain item first. Claude Code, however, stores each non-default profile's credential under its own service name —Claude Code-credentials-<sha256(configDir)[:8]>— wheneverCLAUDE_CONFIG_DIRis set. So a session running withCLAUDE_CONFIG_DIR=~/.claude-workgets the usage of whatever account is logged in under~/.claude.The symptom is easy to miss: widgets fed by the
rate_limitsblock of the status line payload (session, weekly, reset timers) show the right account, while anything that needs the usage API (fable-weekly-usage,weekly-opus-usage, extra-usage — or all of them when the payload carries norate_limits) shows the other account. This is the two-homes case described in #521 (comment by @Cloudmancermedia); the naming scheme below was confirmed from the Claude Code binary in the same thread (comment by @tim-fin).Repro on my machine:
~/.claude= Team account (Fable weekly 95%),~/.claude-perso= Max account (37%). Claude Code's/usagesaid 37%; ccstatusline said 95%, and~/.cache/ccstatusline/usage.jsonwas stamped with the Team token's fingerprint.Fix
getMacKeychainConfigDirService()mirrors Claude Code's service-name builder: suffix-<sha256(dir)[:8]>wheneverCLAUDE_CONFIG_DIRis set;CLAUDE_SECURESTORAGE_CONFIG_DIRreplaces the hash input when present (empty value = plain name); the hash input is the raw environment value, NFC-normalized but not resolved — it has to reproduce the string Claude Code hashes, not locate a directory.getUsageToken()tries that service first on macOS, then the existing chain unchanged (plain service → mtime-sorted candidates →.credentials.json).No behavior change without
CLAUDE_CONFIG_DIR: the firstsecuritycall is stillfind-generic-password -s "Claude Code-credentials" -w, so single-profile setups are untouched.Tests
usage-token.test.ts:null, hash value, NFC normalization,CLAUDE_SECURESTORAGE_CONFIG_DIRoverride and empty override.bun test,bun run lint,bun run buildare green. Verified on macOS with both profiles: the token fingerprint now matches the active profile's own keychain item.Scope
Deliberately limited to the token lookup.
usage.json/usage.lockare still shared across profiles (the token fingerprint keeps the data correct, but two profiles rendering at the same time will refetch and lock each other out for up to 30 s). #266 scopes the cache per config dir and is the natural follow-up — happy to rebase that part on top of this if wanted.Follow-up after review (see comments): switched the hash input to the raw env value, and with a config dir active a keychain miss no longer falls through to the plain service or the candidate scan — only to the profile's own
.credentials.json.Refs #521. Related: #266.