Skip to content

doctor: detect pool-account identity drift vs live ~/.claude.json#353

Merged
askalf merged 1 commit into
masterfrom
feat/doctor-identity-drift-check
May 21, 2026
Merged

doctor: detect pool-account identity drift vs live ~/.claude.json#353
askalf merged 1 commit into
masterfrom
feat/doctor-identity-drift-check

Conversation

@askalf
Copy link
Copy Markdown
Owner

@askalf askalf commented May 21, 2026

What

Add an "Identity" row to dario doctor that compares each pool account's stored {deviceId, accountUuid} snapshot against the live ~/.claude.json and warns on drift.

  [ OK  ]  Identity  2/2 pool accounts match ~/.claude.json (userID=d4f7c0a1…)
  [WARN ]  Identity  1/2 pool accounts drifted from ~/.claude.json (live userID=d4f7c0a1…): work (accountUuid) — re-run `dario accounts add <alias>` to refresh the stored snapshot, or non-Haiku requests on the drifted account(s) will 401

Why

When a user re-installs Claude Code, switches the active account inside CC, or manually edits ~/.claude.json after dario accounts add, the stored snapshot in ~/.dario/accounts/<alias>.json no longer matches what the proxy reads live from ~/.claude.json per request. Anthropic cross-validates the OAuth bearer against metadata.user_id (built from the live deviceId) and 401s with authentication_error on non-Haiku models when they disagree.

The catch: Haiku is more permissive and may succeed despite the mismatch, which makes the failure mode look intermittent and account-tier-shaped. A real incident from yesterday burned ~2 hours misdiagnosing it as rate-limit depletion before tracing it to a one-line userID drift in a host-mounted .claude.json. dario doctor would have surfaced it in one second.

How

  • New pure exported function checkIdentityDrift({live, poolAccounts}): Check[] in src/doctor.ts so each branch is unit-testable without filesystem fixtures.
  • I/O wrapper in runChecks() between Pool and Backends: reads live identity via detectClaudeIdentity(), loads pool accounts, calls the pure function, pushes the result. Defensively wrapped — a throw becomes [WARN] check failed: … like every other doctor section.
  • Promoted detectClaudeIdentity from module-private to export in src/accounts.ts so doctor can reuse it. No call-site changes.

Branches the check covers

State Status Detail
~/.claude.json missing or empty info Warns proxy will skip metadata.user_id → routes to Extra Usage billing instead of Max plan
Single-account mode (no pool) info Notes that drift only surfaces as 401 from Anthropic on non-Haiku (no stored baseline to compare against locally)
Pool aligned with live ok N/N pool accounts match ~/.claude.json (userID=…)
Pool drifted warn Names each drifted alias + which field differs (deviceId / accountUuid / both) + recommends dario accounts add <alias> to refresh

Tests

test/doctor-identity-drift.mjs — 30 assertions across all 7 branches above. Registered in test:serial and auto-picked-up by test/all.test.mjs parallel driver.

Full suite: 77/77 green locally (node --test --test-concurrency=8 test/all.test.mjs).

Out of scope (follow-up)

Single-account mode can't detect bearer-vs-.claude.json drift locally because dario stores no baseline alongside ~/.dario/credentials.json — the proxy reads identity live per-request. Two future-paths to cover it:

  1. Opt-in dario doctor --identity network probe — one Sonnet call to api.anthropic.com with bearer + metadata.user_id from live .claude.json. 401 = mismatch. Parallels existing --probe / --usage opt-ins.
  2. Snapshot identity at login timedario login could write userID / accountUuid into credentials.json alongside the bearer; doctor then compares against live just like pool mode.

Either of those would close the single-account hole. Kept out of this PR to keep scope tight to the zero-network-cost detection.

Risk

Additive only — no behavior change to login, refresh, proxy, or any existing check. Identity row is a new line in dario doctor output; no API surface changes. Worst case: the new check throws and the I/O wrapper converts it to [WARN] Identity check failed: …, indistinguishable from any other defensive doctor row.

When a user re-installs Claude Code (or switches the active account inside
CC) after `dario accounts add`, the stored deviceId/accountUuid snapshot in
`~/.dario/accounts/<alias>.json` no longer matches what the proxy reads
live from `~/.claude.json` per request. Anthropic cross-validates the OAuth
bearer against `metadata.user_id` (built from the live deviceId) and 401s
with `authentication_error` on non-Haiku models when they disagree — Haiku
is more permissive and may succeed despite the mismatch, which makes the
failure mode look intermittent and account-tier-shaped even though it's an
identity-staleness bug.

Add a new "Identity" row to `dario doctor` that compares each pool
account's stored snapshot against live `~/.claude.json`:

- pool aligned             → [ OK ]   N/N pool accounts match
- pool drifted              → [WARN]   names the alias(es) + which field
- no ~/.claude.json         → [INFO]   warns proxy will hit Extra Usage
- single-account mode       → [INFO]   notes drift only surfaces as 401
                                       (no stored baseline to compare;
                                        opt-in network probe is follow-up)

The comparison is factored into a pure exported function
`checkIdentityDrift({live, poolAccounts})` so the branches are unit-
testable without filesystem fixtures. New test/doctor-identity-drift.mjs
covers all branches; existing test/doctor-formatter.mjs unchanged.

Zero new runtime dependencies. Zero network calls in the always-on path
(pool drift is purely a file-compare). detectClaudeIdentity is promoted
from module-private to exported in src/accounts.ts so doctor can call it.
@askalf askalf merged commit 672accf into master May 21, 2026
9 checks passed
@askalf askalf deleted the feat/doctor-identity-drift-check branch May 21, 2026 20:49
@askalf askalf mentioned this pull request May 21, 2026
askalf added a commit that referenced this pull request May 21, 2026
Ships the dario doctor Identity drift check from #353. New "Identity" row
detects when a pool account's stored {deviceId, accountUuid} snapshot has
drifted from the live ~/.claude.json — the silent-401 footgun where Haiku
calls still succeed but Sonnet/Opus return authentication_error on a
mismatched bearer vs metadata.user_id.

Co-authored-by: askalf <263217947+askalf@users.noreply.github.com>
askalf added a commit that referenced this pull request May 23, 2026
Pre-fix: narrow file-list filter meant only PRs explicitly modifying
test/compat.mjs or src/proxy.ts triggered compat. Result: 5 release
PRs (v4.8.5 .. v4.8.9 maxTested bumps) shipped with zero compat
coverage despite being EXACTLY what compat-test is supposed to
validate. PR #353 (doctor identity-drift check) also missed coverage
even though it touches the same auth surface compat exercises.

Audit on 2026-05-23 against 15 merged dario PRs in the prior 7 days
showed only 2 of 15 triggered compat (#344 + #347, both touched the
compat workflow itself).

Broadens to:
- src/** — any source change validates against the live CC version
- package.json — version bumps (maxTested + dario version)
- (existing) scripts/capture-and-bake.mjs, test/compat.mjs, the
  workflow file itself.

Workflow + docs only changes still skip compat (no need to compat-
test a README change). The 80% historical failure rate was a
pre-secret artifact — ANTHROPIC_COMPAT_API_KEY secret was added
2026-05-20 and verified clean on master 2026-05-23 ~13:00 UTC. So
broadening here adds COVERAGE without adding NOISE.

Co-authored-by: askalf <263217947+askalf@users.noreply.github.com>
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.

1 participant