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.
- package/README.md +20 -7
- package/bin/claude +66 -19
- package/bin/claude-accounts +186 -54
- package/bin/codex +4 -3
- package/bin/codex-accounts +18 -8
- package/docs/ACCOUNT_OPERATIONS.md +5 -1
- package/docs/VERIFICATION.md +8 -0
- package/docs/claude-token-identity.md +147 -0
- package/lib/__pycache__/audit.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_token_identity.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
- package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
- package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
- package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
- package/lib/audit.py +42 -4
- package/lib/claude_token_identity.py +233 -0
- package/lib/codex_audit.py +8 -1
- package/lib/common.sh +3 -1
- package/lib/credential.py +9 -2
- package/package.json +1 -1
- package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
- package/tests/run-tests.sh +254 -3
- package/tests/test_autoresume.py +36 -5
- package/tests/test_claude_token_identity.py +259 -0
- package/tests/test_token_identity_ceremony.py +167 -0
- package/tests/test_token_identity_integration.py +207 -0
- package/tests/test_token_identity_retry.py +95 -0
- 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.
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
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
|
|
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
|
-
|
|
108
|
-
if
|
|
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()
|
package/lib/codex_audit.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|