claude-multiacc 2.0.43 → 2.0.45

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +20 -7
  2. package/bin/claude +66 -19
  3. package/bin/claude-accounts +186 -54
  4. package/bin/codex +4 -3
  5. package/bin/codex-accounts +18 -8
  6. package/docs/ACCOUNT_OPERATIONS.md +5 -1
  7. package/docs/VERIFICATION.md +8 -0
  8. package/docs/claude-token-identity.md +147 -0
  9. package/lib/__pycache__/audit.cpython-312.pyc +0 -0
  10. package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
  11. package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
  12. package/lib/__pycache__/claude_token_identity.cpython-312.pyc +0 -0
  13. package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
  14. package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
  15. package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
  16. package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
  17. package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
  18. package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
  19. package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
  20. package/lib/audit.py +42 -4
  21. package/lib/claude_token_identity.py +233 -0
  22. package/lib/codex_audit.py +8 -1
  23. package/lib/common.sh +3 -1
  24. package/lib/credential.py +9 -2
  25. package/package.json +1 -1
  26. package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
  27. package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
  28. package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
  29. package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
  30. package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
  31. package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
  32. package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
  33. package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
  34. package/tests/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
  35. package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
  36. package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
  37. package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
  38. package/tests/run-tests.sh +254 -3
  39. package/tests/test_autoresume.py +36 -5
  40. package/tests/test_claude_token_identity.py +259 -0
  41. package/tests/test_token_identity_ceremony.py +167 -0
  42. package/tests/test_token_identity_integration.py +207 -0
  43. package/tests/test_token_identity_retry.py +95 -0
  44. package/tests/test_token_identity_sync.py +139 -0
@@ -0,0 +1,147 @@
1
+ # Claude setup-token identity and telemetry
2
+
3
+ A pool slot's telemetry and its execution credential must describe the same
4
+ organization. Otherwise the selector can correctly rank an empty account and launch
5
+ Claude against another account's exhausted subscription.
6
+
7
+ ## Session 23: the confirmed cause
8
+
9
+ The September 2026 investigation compared the actual `server.token` credentials with
10
+ their slots' saved OAuth profiles. Authenticated responses identified acct-14's token
11
+ as belonging to acct-18's organization, and acct-13's token as belonging to acct-15's
12
+ organization. The complete pool audit also found acct-19's token belonged to acct-16's
13
+ organization. Anthropic returned `anthropic-organization-id` even on HTTP 429, allowing
14
+ the comparison without exposing tokens or relying on an inference succeeding.
15
+
16
+ The scheduled OAuth usage response really reported 0% for acct-13 and acct-14. The
17
+ native CLI instead reported 94% weekly usage for acct-13's portable token and a weekly
18
+ rejection for acct-14's. Those measurements described different organizations. Changing
19
+ the weekly band, treating every zero as unknown, or overwriting OAuth usage with the
20
+ token's usage would hide the credential mismatch and could exclude healthy accounts.
21
+
22
+ The existing `.server-token-verified` file only proved that the exact token had once
23
+ completed an inference. It could therefore bless a working token from the wrong
24
+ organization indefinitely. A successful response of `OK` is still useful evidence of
25
+ authentication; it is not evidence that a token belongs to the configured slot.
26
+
27
+ ## The identity check
28
+
29
+ `lib/claude_token_identity.py` sends a small inference request and compares its
30
+ `anthropic-organization-id` response header with
31
+ `<account>/.claude.json` → `oauthAccount.organizationUuid`. This uses no usage GET and
32
+ does not change the telemetry polling cadence. Response bodies and credentials are
33
+ never written into the receipt or diagnostic output. Redirects are refused.
34
+
35
+ The independent `<account>/.server-token-identity.json` receipt records the exact
36
+ token digest, expected and observed organizations, account id, manifest email, verdict,
37
+ and check time. A match or mismatch remains reusable only while those bindings agree.
38
+ Replacing a token or changing its configured identity requires a new check. Updating
39
+ the same token's timestamp, refreshing OAuth, redeeming a limit reset, or clearing
40
+ ordinary auth/limit markers cannot invalidate a proven mismatch.
41
+
42
+ The shim checks identity before accepting the old `.server-token-verified` shortcut,
43
+ including retry candidates, and executes the exact token it checked. Concurrent checks
44
+ share an account lock so a delayed inconclusive result cannot replace proven identity.
45
+ A cached mismatch removes a candidate when execution would use its portable token;
46
+ healthy local OAuth may still use that slot. Explicit account pins also reject a known
47
+ wrong-organization token. Full `claude-accounts verify` performs the identity check,
48
+ and token ceremonies and imports reject a known mismatch before saving a candidate.
49
+ An inconclusive candidate check must not overwrite the current token's receipt.
50
+
51
+ Source sync and telemetry distribution check every portable token, even when local
52
+ OAuth works. Full verification likewise checks both credentials and reports them
53
+ separately. Sync distributes only current, confirmed identity receipts alongside the
54
+ token and telemetry; uncertainty cannot overwrite a peer's proven mismatch. A replica
55
+ without a saved OAuth profile can use the receipt's expected organization only when
56
+ its token digest and manifest binding still match. A changed local OAuth profile takes
57
+ precedence and invalidates an outdated receipt; an unrelated receipt cannot establish
58
+ the expectation for a newly created slot.
59
+
60
+ ## Recovery
61
+
62
+ Replace the misfiled token by running the supported ceremony on the source machine:
63
+
64
+ ```bash
65
+ claude-accounts login acct-NN --token
66
+ claude-accounts verify
67
+ claude-accounts sync
68
+ ```
69
+
70
+ Approve the ceremony in a browser signed into the named account. A different token
71
+ invalidates the previous receipt and is checked against the saved expected organization.
72
+ Do not clear a mismatch by deleting markers or touching `server.token`: neither repairs
73
+ the credential. A valid local OAuth login can keep that machine's slot usable while its
74
+ portable token is replaced. Peers that depend on the bad token remain excluded.
75
+
76
+ The plugin does not automatically reassign a token to a different slot. The observed
77
+ organization does not prove which human account minted it, and moving credentials
78
+ could silently change the intended account mapping.
79
+
80
+ ## What the check proves
81
+
82
+ The response header proves organization, not email, user identity, subscription seat,
83
+ remaining quota, or continuing authentication. Users sharing an organization cannot
84
+ be distinguished by this header. Keep the normal authentication preflight and quota
85
+ selection policy independent.
86
+
87
+ Setup tokens lack `user:profile`; `claude auth status` therefore reports no email for
88
+ them. An empty token email must not make the supported `add <email> --token` path require
89
+ `--force`, and the test CLI must not invent an email for token authentication. Name the
90
+ expected account before every ceremony.
91
+
92
+ No expected organization, a missing/malformed header, or a network failure produces an
93
+ inconclusive result. Execution retains its existing fallback behavior rather than
94
+ parking an account on uncertainty; inconclusive network checks back off for one minute.
95
+ Consequently the guard prevents a proved organization mismatch. It cannot promise
96
+ identity verification where Anthropic or the local account profile supplies no evidence.
97
+
98
+ ## Telemetry rules that remain necessary
99
+
100
+ Portable setup tokens can run inference but cannot call the usage endpoint. A polling
101
+ source needs working OAuth logins; token-only peers receive `limits.json` through sync.
102
+ Use `claude-accounts status` to find `RANKING IS BLIND` or `<< STALE`, and repair source
103
+ OAuth with `claude-accounts login acct-NN` without `--token`. The usage endpoint has
104
+ returned an approximately hourly budget with `Retry-After: 3600`; retrying its permanent
105
+ scope rejection more frequently creates rate limits rather than telemetry.
106
+
107
+ Earlier fixes addressed separate missing-data and marker-recovery cases. They must
108
+ remain intact after adding identity validation:
109
+
110
+ - A weekly 0% bucket without a reset carries no informative weekly answer. Aggregate
111
+ only informative buckets, separately for each signal. Ignore an empty model bucket
112
+ beside informative ones; retain a measured zero with a real reset.
113
+ - If the entire payload is uninformative, write `no_data: true` without percentage
114
+ fields. Ranking and BLIND detection require both weekly and session signals; a recent
115
+ timestamp alone must not hide missing data.
116
+ - An otherwise informative payload may have no open five-hour window. Both writers
117
+ synthesize session 0% with `session_inferred: true` so idle accounts can rank. This
118
+ exception never invents weekly usage or turns a wholly empty payload into data.
119
+ - An inferred session zero cannot clear a client five-hour rejection. Only a measured
120
+ low reading of that window can recover it before its reset. Never substitute the
121
+ weekly `max_percent`. Claude must enforce this in both shim and writer; Codex's
122
+ telemetry recovery exists only in its writer.
123
+ - A client weekly rejection survives later telemetry until its reset, except when the
124
+ reset protocol confirms an applicable redemption. Marker names must preserve the
125
+ provider's actual window: Claude `client:seven_day`; Codex `client:7d` or `client:5h`
126
+ according to rollout window minutes.
127
+
128
+ The earlier observations of zero OAuth usage alongside client rejections did not
129
+ establish that both credentials belonged to the same organization. Session 23 supplies
130
+ direct evidence of that missing assumption. Identity validation addresses it without
131
+ weakening the conservative recovery rules or changing the 50% session gate and
132
+ 30-point weekly peer band.
133
+
134
+ ## Verification boundaries
135
+
136
+ Regression coverage must exercise the shim and account commands, not just compare
137
+ helper return values. Include old auth proofs, explicit pins, fallback selection,
138
+ unchanged token timestamps, replacement tokens, healthy OAuth beside a bad token,
139
+ receipt persistence through telemetry/reset cleanup, missing headers, and replica
140
+ bindings. Full verification must fail a wrong-organization token even if a fake native
141
+ CLI would answer `OK`.
142
+
143
+ Real checks should compare the exact token's response organization with its configured
144
+ slot, then launch an independently selected account through the updated shim. Use
145
+ minimal prompts with tools disabled and keep tokens and raw account identifiers out
146
+ of task logs. Passing those checks establishes the tested behavior; it does not remove
147
+ the inconclusive and shared-organization limitations described above.
package/lib/audit.py CHANGED
@@ -7,7 +7,9 @@ States:
7
7
  expired has auth material that CANNOT authenticate — needs `claude-accounts relogin`
8
8
  token-invalid a portable setup-token was rejected — needs a new setup-token, not a login
9
9
  blocked authenticates, but its organization has disabled Claude Code subscription
10
- access — a re-login cannot fix it, so it needs an admin (or removal)
10
+ access — a re-login cannot fix it, so it needs an admin (or removal); a
11
+ park with a soft_until (every one written now: 6 h) lapses on its own
12
+ and the pool re-tries the account
11
13
  locked the OAuth login is in the macOS Keychain and THIS session cannot open it
12
14
  (ssh/tmux/background) — it works from the Mac's own GUI session, so it is
13
15
  neither dead nor missing; just not usable from here
@@ -39,6 +41,7 @@ import time
39
41
  # helper sits beside it either way.
40
42
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
41
43
  import keychain # noqa: E402
44
+ from claude_token_identity import cached_identity # noqa: E402
42
45
 
43
46
  VALID_ID = re.compile(r'acct-\d{2}')
44
47
 
@@ -72,12 +75,31 @@ def marker_slug(detail):
72
75
  return m.group(1) if m else ''
73
76
 
74
77
 
78
+ def marker_soft_until(detail):
79
+ """The soft_until=<epoch> field of a marker line (None when absent)."""
80
+ m = re.search(r'soft_until=(\d+)', detail or '')
81
+ return int(m.group(1)) if m else None
82
+
83
+
84
+ def org_block_in_force(body, now=None):
85
+ """True when a marker is an org-blocked park that still binds — no soft_until, or
86
+ one still ahead (expired_marked below and bin/claude's reading of the same line).
87
+ The writers use it to keep a weaker reason from overwriting a live org block; a
88
+ block whose soft window has run out no longer outranks anything."""
89
+ now = time.time() if now is None else now
90
+ if marker_slug(body) != 'org-blocked':
91
+ return False
92
+ soft = marker_soft_until(body)
93
+ return soft is None or soft > now
94
+
95
+
75
96
  def expired_marked(d, now=None):
76
97
  """True when <d>/.expired is still in force. Mirrors the shim exactly:
77
98
  a CREDENTIAL-scoped park clears as soon as a newer credential lands (re-login, or a
78
99
  refresh by another process); a POLICY park (org-blocked) does not, because a token
79
100
  refresh says nothing about whether the org re-enabled Claude Code. Either kind also
80
- expires at its own soft_until when the shim wrote it from a single failed run."""
101
+ expires at its own soft_until when it carries one — every shim park, and every
102
+ org-blocked park (the shim's and verify's alike carry six hours)."""
81
103
  now = time.time() if now is None else now
82
104
  mpath = os.path.join(d, '.expired')
83
105
  if not os.path.isfile(mpath):
@@ -104,8 +126,8 @@ def expired_marked(d, now=None):
104
126
  if not os.path.isfile(os.path.join(d, '.credentials.json')) \
105
127
  and keychain.item_mtime(d) > mt:
106
128
  return False
107
- m = re.search(r'soft_until=(\d+)', detail)
108
- if m and int(m.group(1)) <= now:
129
+ soft = marker_soft_until(detail)
130
+ if soft is not None and soft <= now:
109
131
  return False
110
132
  return True
111
133
 
@@ -249,6 +271,8 @@ def _fix_for(state, aid):
249
271
 
250
272
  def _token_verified(d, tpath):
251
273
  """Whether this exact setup-token passed an inference on this machine."""
274
+ if cached_identity(d)['state'] == 'mismatch':
275
+ return False
252
276
  try:
253
277
  digest = hashlib.sha256(open(tpath, 'rb').read().strip()).hexdigest()
254
278
  saved = open(os.path.join(d, '.server-token-verified')).read().strip()
@@ -285,6 +309,13 @@ def audit_account(root, acct, now=None, machine=None, require_verified_token=Fal
285
309
  row['state'] = 'blocked'
286
310
  row['reason'] = ('this account\'s organization has disabled Claude Code '
287
311
  'subscription access')
312
+ soft = marker_soft_until(detail)
313
+ if soft is not None:
314
+ # When the pool gives it another real call on its own (the park's
315
+ # soft_until). A passing verify, one refused only by a usage limit, or
316
+ # a re-login lifts it sooner.
317
+ row['reason'] += time.strftime(
318
+ '; re-tried by the pool after %Y-%m-%d %H:%M UTC', time.gmtime(soft))
288
319
  elif slug == 'setup-token-invalid':
289
320
  login = oauth_login(d, now)
290
321
  if login['state'] == 'ok':
@@ -305,6 +336,13 @@ def audit_account(root, acct, now=None, machine=None, require_verified_token=Fal
305
336
  return done(row)
306
337
  login = oauth_login(d, now)
307
338
  row['store'] = login['store']
339
+ if has_token and cached_identity(d)['state'] == 'mismatch':
340
+ if login['state'] != 'ok':
341
+ row['state'] = 'token-invalid'
342
+ row['reason'] = 'portable setup-token belongs to a different organization'
343
+ return done(row)
344
+ row['reason'] = f"{login['reason']}; portable setup-token identity mismatch, using the local login"
345
+ return done(row)
308
346
  if login['state'] in ('ok', 'expired'):
309
347
  state, reason = login['state'], login['reason']
310
348
  if state == 'ok' or has_token:
@@ -0,0 +1,233 @@
1
+ #!/usr/bin/env python3
2
+ """Bind a portable Claude token to its slot using authenticated response headers.
3
+
4
+ An inference may be quota-rejected and still identify the token's organization.
5
+ This receipt proves organization identity only, never remaining quota or token validity.
6
+ No credentials or response bodies are written to the receipt or diagnostic output.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import fcntl
13
+ import hashlib
14
+ import json
15
+ import os
16
+ from pathlib import Path
17
+ import sys
18
+ import tempfile
19
+ import time
20
+ import urllib.error
21
+ import urllib.parse
22
+ import urllib.request
23
+ import uuid
24
+
25
+ from claude_reset import cli_version, user_agent
26
+
27
+ RECEIPT = ".server-token-identity.json"
28
+ LOCK_FILE = ".server-token-identity.lock"
29
+ ENDPOINT = "https://api.anthropic.com/v1/messages?beta=true"
30
+ MODEL = "claude-haiku-4-5-20251001"
31
+ UNKNOWN_BACKOFF = 60
32
+ TIMEOUT = 10
33
+
34
+
35
+ class NoRedirect(urllib.request.HTTPRedirectHandler):
36
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
37
+ return None
38
+
39
+
40
+ def _org(value):
41
+ try:
42
+ return str(uuid.UUID(value)) if isinstance(value, str) else ""
43
+ except ValueError:
44
+ return ""
45
+
46
+
47
+ def _load(path):
48
+ try:
49
+ with path.open(encoding="utf-8") as handle:
50
+ value = json.load(handle)
51
+ return value if isinstance(value, dict) else {}
52
+ except (OSError, ValueError):
53
+ return {}
54
+
55
+
56
+ def _context(account_dir, token, now, expected_email=None):
57
+ account = Path(account_dir)
58
+ result = {"status": "unknown", "reason": "token_unavailable", "checked_at": now}
59
+ try:
60
+ if token is None:
61
+ with (account / "server.token").open(encoding="utf-8") as handle:
62
+ token = handle.read(4097)
63
+ token = token.strip()
64
+ except (OSError, ValueError, AttributeError):
65
+ return result, ""
66
+ if not token.startswith("sk-ant-oat") or len(token) > 4096 or not all(33 <= ord(c) <= 126 for c in token):
67
+ return dict(result, reason="invalid_token"), ""
68
+ digest = hashlib.sha256(token.encode()).hexdigest()
69
+ config = _load(account / ".claude.json")
70
+ profile = config.get("oauthAccount")
71
+ profile = profile if isinstance(profile, dict) else {}
72
+ rows = _load(account.parent / "accounts.json").get("accounts", [])
73
+ rows = rows if isinstance(rows, list) else []
74
+ row = next((r for r in rows if isinstance(r, dict) and r.get("id") == account.name), {})
75
+ email = str(row.get("email") or "").strip().lower()
76
+ profile_email = str(profile.get("emailAddress") or profile.get("email") or "").strip().lower()
77
+ result.update(token_digest=digest, account_id=account.name, account_email=email, manifest_bound=bool(row))
78
+ if expected_email is not None and not (profile_email or email):
79
+ return dict(result, reason="missing_profile_email"), ""
80
+ if ((email and profile_email and email != profile_email)
81
+ or (expected_email is not None and str(expected_email).strip().lower() != (profile_email or email))):
82
+ return dict(result, status="mismatch", reason="profile_manifest_mismatch"), ""
83
+ receipt = _load(account / RECEIPT)
84
+ bound = all(receipt.get(key) == result[key] for key in
85
+ ("token_digest", "account_id", "account_email", "manifest_bound"))
86
+ expected = _org(profile.get("organizationUuid"))
87
+ if not expected and "oauthAccount" not in config and bound and row:
88
+ expected = _org(receipt.get("expected_org"))
89
+ if not expected:
90
+ return dict(result, reason="missing_expected_organization"), ""
91
+ result.update(expected_org=expected, reason="unchecked")
92
+ actual, status = _org(receipt.get("actual_org")), receipt.get("status")
93
+ checked = receipt.get("checked_at")
94
+ reusable = ((status == "match" and actual == expected)
95
+ or (status == "mismatch" and actual and actual != expected)
96
+ or (status == "unknown" and isinstance(checked, (int, float))
97
+ and 0 <= now - checked < UNKNOWN_BACKOFF))
98
+ if bound and receipt.get("expected_org") == expected and reusable:
99
+ return dict(receipt, cached=True), ""
100
+ return result, token
101
+
102
+
103
+ def _probe(token, endpoint, model, version):
104
+ try:
105
+ url = urllib.parse.urlsplit(endpoint)
106
+ except ValueError:
107
+ return {"status": "unknown", "actual_org": "", "reason": "invalid_endpoint"}
108
+ official = url.scheme == "https" and url.netloc == "api.anthropic.com"
109
+ local = url.scheme == "http" and url.hostname in {"127.0.0.1", "::1", "localhost"}
110
+ if not (official or local) or url.username or url.password or url.fragment:
111
+ return {"status": "unknown", "actual_org": "", "reason": "invalid_endpoint"}
112
+ betas = ["oauth-2025-04-20", "prompt-caching-scope-2026-01-05"]
113
+ if "haiku" not in model.lower():
114
+ betas.append("claude-code-20250219")
115
+ body = {"model": model, "max_tokens": 1, "messages": [{"role": "user", "content": "quota"}]}
116
+ request = urllib.request.Request(endpoint, data=json.dumps(body).encode(), headers={
117
+ "Authorization": f"Bearer {token}", "anthropic-version": "2023-06-01",
118
+ "anthropic-beta": ",".join(betas), "Content-Type": "application/json",
119
+ "User-Agent": user_agent(version), "x-app": "cli"})
120
+ opener = urllib.request.build_opener(NoRedirect())
121
+ try:
122
+ try:
123
+ response = opener.open(request, timeout=TIMEOUT)
124
+ except urllib.error.HTTPError as error:
125
+ response = error
126
+ with response:
127
+ code = response.code
128
+ values = response.headers.get_all("anthropic-organization-id", [])
129
+ actual = _org(values[0]) if len(values) == 1 else ""
130
+ if 300 <= code < 400:
131
+ return {"status": "unknown", "actual_org": "", "reason": "redirect"}
132
+ reason = "organization_header" if actual else "missing_organization"
133
+ if not actual and code in {401, 403}:
134
+ reason = "auth_failed"
135
+ return {"actual_org": actual, "http_status": code, "reason": reason}
136
+ except (OSError, ValueError, urllib.error.URLError):
137
+ return {"status": "unknown", "actual_org": "", "reason": "request_failed"}
138
+
139
+
140
+ def _save(account, receipt):
141
+ path = None
142
+ try:
143
+ fd, path = tempfile.mkstemp(prefix=RECEIPT + ".", dir=account)
144
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
145
+ json.dump(receipt, handle, sort_keys=True)
146
+ handle.write("\n")
147
+ os.replace(path, account / RECEIPT)
148
+ except OSError:
149
+ pass
150
+ finally:
151
+ try:
152
+ if path:
153
+ os.unlink(path)
154
+ except OSError:
155
+ pass
156
+
157
+
158
+ def _result(receipt):
159
+ result = {"state": receipt.get("status", "unknown"), "cached": receipt.get("cached", False),
160
+ "token_digest": receipt.get("token_digest", ""), "reason": receipt.get("reason", "unchecked")}
161
+ if "http_status" in receipt:
162
+ result["http_status"] = receipt["http_status"]
163
+ return result
164
+
165
+
166
+ def cached_identity(account_dir, token=None, *, now=None):
167
+ """Return identity already proved for this token and slot, without HTTP."""
168
+ receipt, _ = _context(account_dir, token, time.time() if now is None else now)
169
+ return _result(receipt)
170
+
171
+
172
+ def check_identity(account_dir, token=None, *, endpoint=None, model=None, version=None, now=None,
173
+ persist=True, expected_email=None):
174
+ """Check this exact token, or only the requested account's server.token file.
175
+
176
+ Match/mismatch receipts survive indefinitely because a token cannot change its
177
+ organization. Changing either the token or configured organization invalidates
178
+ them; an inconclusive request backs off for one minute. Authentication preflight
179
+ remains independent, including for an identity-matched but later revoked token.
180
+ A persistent lock prevents a delayed unknown result from replacing proof written
181
+ by another launch. Read-only callers still receive independently proved mismatches.
182
+ """
183
+ lock = None
184
+ try:
185
+ lock = os.fdopen(os.open(Path(account_dir) / LOCK_FILE, os.O_CREAT | os.O_RDWR, 0o600), "w")
186
+ fcntl.flock(lock, fcntl.LOCK_EX)
187
+ except OSError:
188
+ if lock:
189
+ lock.close()
190
+ lock = None
191
+ try:
192
+ receipt, token = _context(account_dir, token, time.time() if now is None else now, expected_email)
193
+ if not token:
194
+ return _result(receipt)
195
+ endpoint = endpoint or os.environ.get("CLAUDE_MULTIACC_TOKEN_IDENTITY_URL") or ENDPOINT
196
+ receipt.update(_probe(token, endpoint, model or MODEL, version))
197
+ actual = receipt["actual_org"]
198
+ receipt["status"] = ("match" if actual == receipt["expected_org"] else "mismatch") if actual else "unknown"
199
+ if not lock and not actual:
200
+ current, _ = _context(account_dir, token, time.time() if now is None else now, expected_email)
201
+ if current["status"] in {"match", "mismatch"}:
202
+ return _result(current)
203
+ if persist and lock:
204
+ _save(Path(account_dir), receipt)
205
+ return _result(receipt)
206
+ finally:
207
+ if lock:
208
+ lock.close()
209
+
210
+
211
+ def main():
212
+ parser = argparse.ArgumentParser(description=__doc__)
213
+ parser.add_argument("command", choices=["check", "cached"])
214
+ parser.add_argument("account_dir")
215
+ parser.add_argument("--token-stdin", action="store_true", help="use the exact token read from stdin")
216
+ parser.add_argument("--real", help="installed Claude executable, used only to learn its version")
217
+ parser.add_argument("--model", default=MODEL)
218
+ parser.add_argument("--format", choices=["json", "state"], default="json")
219
+ parser.add_argument("--no-store", action="store_true", help="check a candidate without replacing the receipt")
220
+ parser.add_argument("--expected-email", help="named account email for a candidate not yet in the manifest")
221
+ args = parser.parse_args()
222
+ token = sys.stdin.read(4097) if args.token_stdin else None
223
+ if args.command == "cached":
224
+ result = cached_identity(args.account_dir, token)
225
+ else:
226
+ result = check_identity(args.account_dir, token, model=args.model,
227
+ version=cli_version(args.real), persist=not args.no_store,
228
+ expected_email=args.expected_email)
229
+ print(result["state"] if args.format == "state" else json.dumps(result, sort_keys=True))
230
+
231
+
232
+ if __name__ == "__main__":
233
+ main()
@@ -69,7 +69,8 @@ def expired_marked(d, now=None):
69
69
  a CREDENTIAL-scoped park clears as soon as a newer auth.json lands (re-login, or a
70
70
  refresh by another process); a POLICY park (org-blocked) does not, because a token
71
71
  refresh says nothing about whether the workspace re-enabled Codex. Either kind also
72
- expires at its own soft_until when the shim wrote it from a single failed run."""
72
+ expires at its own soft_until when it carries one — every shim park, and every
73
+ org-blocked park (the shim's and verify's alike carry six hours)."""
73
74
  now = time.time() if now is None else now
74
75
  mpath = os.path.join(d, '.expired')
75
76
  if not os.path.isfile(mpath):
@@ -199,6 +200,12 @@ def audit_account(root, acct, now=None, machine=None):
199
200
  row['state'] = 'blocked'
200
201
  row['reason'] = ('Codex access is disabled for this account by a '
201
202
  'workspace admin')
203
+ soft = re.search(r'soft_until=(\d+)', detail)
204
+ if soft:
205
+ # When the pool gives it another real call on its own (lib/audit.py).
206
+ row['reason'] += time.strftime(
207
+ '; re-tried by the pool after %Y-%m-%d %H:%M UTC',
208
+ time.gmtime(int(soft.group(1))))
202
209
  else:
203
210
  row['state'] = 'expired'
204
211
  row['reason'] = f'marked dead by the pool ({detail or "authentication failed"})'
package/lib/common.sh CHANGED
@@ -441,7 +441,9 @@ EOF
441
441
  fi
442
442
  local d="$ACC_ROOT/$target_id"
443
443
  seed_account_dir "$d"
444
- "$PYBIN" "$CREDENTIAL_PY" install "$MULTIACC_PROVIDER" "$stage" "$d" >/dev/null \
444
+ local identity_cli=""
445
+ [ "$MULTIACC_PROVIDER" != "claude" ] || identity_cli="$(find_real_claude "${_self:-$0}" 2>/dev/null)" || true
446
+ "$PYBIN" "$CREDENTIAL_PY" install "$MULTIACC_PROVIDER" "$stage" "$d" "$identity_cli" >/dev/null \
445
447
  || fail_locked "could not install the credential into $d — nothing registered"
446
448
  manifest_add_account "$target_id" "$bemail" "$home" "$badded" \
447
449
  || fail_locked "manifest update failed — $target_id was seeded but NOT registered (re-run import-credential)"
package/lib/credential.py CHANGED
@@ -12,7 +12,7 @@ Run (all paths absolute; the blob never passes through argv):
12
12
  class, id, email, home, added_at, cred_type. The separator is deliberately NOT
13
13
  whitespace: bash collapses runs of IFS whitespace, which would silently shift
14
14
  an empty field (a blob with no `home`) into the next one.
15
- python3 lib/credential.py install <provider> <blob-file> <dest-dir>
15
+ python3 lib/credential.py install <provider> <blob-file> <dest-dir> [real-claude]
16
16
  writes the credential material into <dest-dir> (0600, atomic); no-op for an
17
17
  identity-only blob. Prints the file it wrote (or nothing).
18
18
 
@@ -315,7 +315,7 @@ def cmd_inspect(argv):
315
315
 
316
316
  def cmd_install(argv):
317
317
  if len(argv) < 3:
318
- die('usage: credential.py install <provider> <blob-file> <dest-dir>')
318
+ die('usage: credential.py install <provider> <blob-file> <dest-dir> [real-claude]')
319
319
  provider, path, dest = argv[0], argv[1], argv[2]
320
320
  blob, cclass, _aid, _email, _home, _added, _type = _validate(provider, path)
321
321
  if cclass == 'identity':
@@ -325,6 +325,13 @@ def cmd_install(argv):
325
325
  f'write a credential through it', 2)
326
326
  if not os.path.isdir(dest):
327
327
  die(f'destination is not a directory: {dest}')
328
+ from claude_reset import cli_version
329
+ from claude_token_identity import check_identity
330
+ identity = check_identity(dest, blob['credential']['value'].strip(), persist=False,
331
+ version=cli_version(argv[3] if len(argv) > 3 else None), expected_email=_email)
332
+ if identity['state'] == 'mismatch':
333
+ die('the token organization does not match the destination account — nothing installed; '
334
+ 'mint a token for the named account', 5)
328
335
  out = os.path.join(dest, 'server.token')
329
336
  _write_secure(out, blob['credential']['value'].strip(), nofollow_dir=True)
330
337
  print(out)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-multiacc",
3
- "version": "2.0.43",
3
+ "version": "2.0.45",
4
4
  "description": "Unified Claude Code and OpenAI Codex subscription pooling with quota-aware selection.",
5
5
  "type": "module",
6
6
  "bin": {