Skip to content

fix(usage): read the CLAUDE_CONFIG_DIR keychain credential first on macOS - #573

Open
nguerrier wants to merge 2 commits into
sirmalloc:mainfrom
nguerrier:fix/usage-token-config-dir
Open

fix(usage): read the CLAUDE_CONFIG_DIR keychain credential first on macOS#573
nguerrier wants to merge 2 commits into
sirmalloc:mainfrom
nguerrier:fix/usage-token-config-dir

Conversation

@nguerrier

@nguerrier nguerrier commented Sep 1, 2026

Copy link
Copy Markdown

Problem

On macOS, getUsageToken() always reads the plain Claude Code-credentials keychain item first. Claude Code, however, stores each non-default profile's credential under its own service name — Claude Code-credentials-<sha256(configDir)[:8]> — whenever CLAUDE_CONFIG_DIR is set. So a session running with CLAUDE_CONFIG_DIR=~/.claude-work gets the usage of whatever account is logged in under ~/.claude.

The symptom is easy to miss: widgets fed by the rate_limits block 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 no rate_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 /usage said 37%; ccstatusline said 95%, and ~/.cache/ccstatusline/usage.json was stamped with the Team token's fingerprint.

Fix

  • New getMacKeychainConfigDirService() mirrors Claude Code's service-name builder: suffix -<sha256(dir)[:8]> whenever CLAUDE_CONFIG_DIR is set; CLAUDE_SECURESTORAGE_CONFIG_DIR replaces 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 first security call is still find-generic-password -s "Claude Code-credentials" -w, so single-profile setups are untouched.

Tests

usage-token.test.ts:

  • config-dir service is read first and the plain service is skipped on a hit;
  • fallback to the plain service when the suffixed item is missing;
  • direct tests of the service-name builder: default profile → null, hash value, NFC normalization, CLAUDE_SECURESTORAGE_CONFIG_DIR override and empty override.

bun test, bun run lint, bun run build are 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.lock are 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.

…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
@tim-fin

tim-fin commented Sep 2, 2026

Copy link
Copy Markdown

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 effectiveConfigDir normalizes internally:

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 .normalize('NFC') matches. The test isn't a tautology either — the input literal is cafe + U+0301 against an expectation built from U+00E9, so dropping the normalize fails it.

The hash input is the problem. That snippet hashes the raw env value — no path.resolve(), no existence check. getClaudeConfigDir() does both, so the two agree only for an already-absolute path with no trailing separator pointing at an existing directory:

CLAUDE_CONFIG_DIR Claude Code hashes this PR hashes
/Users/me/.claude-work/ /Users/me/.claude-work/ /Users/me/.claude-work
.claude-work .claude-work <cwd>/.claude-work
~/.claude-work (unexpanded) ~/.claude-work <cwd>/~/.claude-work
exists but isn't a directory that path ~/.claude

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 ~/.claude — a name the builder never emits. (A path that merely doesn't exist yet is fine; it returns the resolved path.)

Since the guard already returns null unless CLAUDE_CONFIG_DIR is set or the override is non-empty, mirroring the builder is also less code:

const configDir = (override ?? process.env.CLAUDE_CONFIG_DIR!).normalize('NFC');

That makes all four rows hit and removes the split where the guard reads process.env.CLAUDE_CONFIG_DIR but the hash reads getClaudeConfigDir(). getClaudeConfigDir() is still right for the .credentials.json fallback below — it's specifically as hash input that resolving hurts, since the hash has to reproduce a string Claude Code chose rather than locate a file.

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.
@nguerrier

Copy link
Copy Markdown
Author

Both points verified against the binary and taken.

Hash input — switched to the raw env value: (CLAUDE_SECURESTORAGE_CONFIG_DIR ?? CLAUDE_CONFIG_DIR).normalize('NFC'), no resolve, no existence check. Your reading matches what I extracted: var Se = Zo(() => (s() ?? join(homedir(), ".claude")).normalize("NFC"), s) with s() returning the raw CLAUDE_CONFIG_DIR. All four rows of your table now hash the string the builder hashes; there's a regression test for the trailing slash, and the NFC test now feeds a decomposed literal.

Miss behavior — with a config dir active, getUsageToken() no longer falls through to the plain service or the mtime scan; only the profile's own .credentials.json remains. A logged-in session with CLAUDE_CONFIG_DIR set always has its suffixed item (Claude Code reads and writes through the same builder), so a miss means no usable credential and [No credentials] is the honest render. I didn't add logging — nothing else in usage-fetch logs, and stderr is discarded in the status line pipeline.

bun test / lint / build green; re-verified on the two-homes machine (suffixed item picked for the non-default home, plain for the default).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants