claude-multiacc 2.0.42 → 2.0.44
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 +17 -1
- package/bin/claude +78 -18
- package/bin/claude-accounts +112 -22
- package/docs/ACCOUNT_OPERATIONS.md +5 -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 +10 -0
- package/lib/claude_token_identity.py +233 -0
- package/lib/common.sh +27 -2
- package/lib/credential.py +9 -2
- package/lib/keychain.py +274 -59
- 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/fake_security.sh +90 -0
- package/tests/run-tests.sh +254 -66
- package/tests/test_claude_token_identity.py +259 -0
- package/tests/test_keychain.py +323 -50
- 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
|
@@ -39,6 +39,7 @@ import time
|
|
|
39
39
|
# helper sits beside it either way.
|
|
40
40
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
41
41
|
import keychain # noqa: E402
|
|
42
|
+
from claude_token_identity import cached_identity # noqa: E402
|
|
42
43
|
|
|
43
44
|
VALID_ID = re.compile(r'acct-\d{2}')
|
|
44
45
|
|
|
@@ -249,6 +250,8 @@ def _fix_for(state, aid):
|
|
|
249
250
|
|
|
250
251
|
def _token_verified(d, tpath):
|
|
251
252
|
"""Whether this exact setup-token passed an inference on this machine."""
|
|
253
|
+
if cached_identity(d)['state'] == 'mismatch':
|
|
254
|
+
return False
|
|
252
255
|
try:
|
|
253
256
|
digest = hashlib.sha256(open(tpath, 'rb').read().strip()).hexdigest()
|
|
254
257
|
saved = open(os.path.join(d, '.server-token-verified')).read().strip()
|
|
@@ -305,6 +308,13 @@ def audit_account(root, acct, now=None, machine=None, require_verified_token=Fal
|
|
|
305
308
|
return done(row)
|
|
306
309
|
login = oauth_login(d, now)
|
|
307
310
|
row['store'] = login['store']
|
|
311
|
+
if has_token and cached_identity(d)['state'] == 'mismatch':
|
|
312
|
+
if login['state'] != 'ok':
|
|
313
|
+
row['state'] = 'token-invalid'
|
|
314
|
+
row['reason'] = 'portable setup-token belongs to a different organization'
|
|
315
|
+
return done(row)
|
|
316
|
+
row['reason'] = f"{login['reason']}; portable setup-token identity mismatch, using the local login"
|
|
317
|
+
return done(row)
|
|
308
318
|
if login['state'] in ('ok', 'expired'):
|
|
309
319
|
state, reason = login['state'], login['reason']
|
|
310
320
|
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/common.sh
CHANGED
|
@@ -12,6 +12,19 @@
|
|
|
12
12
|
# shellcheck disable=SC2034
|
|
13
13
|
|
|
14
14
|
MULTIACC_PROVIDER="${MULTIACC_PROVIDER:-claude}"
|
|
15
|
+
|
|
16
|
+
# Claude Code names its macOS Keychain item after $USER, and under Bun a client with no
|
|
17
|
+
# USER calls the user "unknown": `claude-accounts login` run from an env without it
|
|
18
|
+
# (app-robot's panel, 2026-09-24) filed the fresh login beside the old item, under a
|
|
19
|
+
# name the next client never reads (lib/keychain.py). So every client these CLIs start
|
|
20
|
+
# inherits a real name, whatever env the caller passed; bin/claude does the same.
|
|
21
|
+
# Harmless for codex and on Linux.
|
|
22
|
+
if [ -z "${USER:-}" ]; then
|
|
23
|
+
USER="$(id -un 2>/dev/null || true)"
|
|
24
|
+
if [ -n "$USER" ]; then export USER; else unset USER; fi
|
|
25
|
+
fi
|
|
26
|
+
if [ -z "${LOGNAME:-}" ] && [ -n "${USER:-}" ]; then export LOGNAME="$USER"; fi
|
|
27
|
+
|
|
15
28
|
PYBIN="${CLAUDE_MULTIACC_PYTHON:-python3}"
|
|
16
29
|
DEFAULT_SERVER="root@138.197.36.107"
|
|
17
30
|
DEFAULT_SERVER_REPO="/root/claude-multiacc"
|
|
@@ -428,7 +441,9 @@ EOF
|
|
|
428
441
|
fi
|
|
429
442
|
local d="$ACC_ROOT/$target_id"
|
|
430
443
|
seed_account_dir "$d"
|
|
431
|
-
|
|
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 \
|
|
432
447
|
|| fail_locked "could not install the credential into $d — nothing registered"
|
|
433
448
|
manifest_add_account "$target_id" "$bemail" "$home" "$badded" \
|
|
434
449
|
|| fail_locked "manifest update failed — $target_id was seeded but NOT registered (re-run import-credential)"
|
|
@@ -469,10 +484,20 @@ keychain_state() { # $1 = acct dir -> prints present|locked|absent|corrupt|unava
|
|
|
469
484
|
keychain_has_login() { # $1 = acct dir: an item EXISTS here (readable or locked)
|
|
470
485
|
case "$(keychain_state "$1")" in present|locked|corrupt) return 0 ;; *) return 1 ;; esac
|
|
471
486
|
}
|
|
472
|
-
keychain_forget() { # $1 = acct dir: drop
|
|
487
|
+
keychain_forget() { # $1 = acct dir: drop every item (a removed account must not leave a grant behind)
|
|
473
488
|
[ "$MULTIACC_PROVIDER" = "codex" ] && return 0
|
|
474
489
|
"$PYBIN" "$LIB_DIR/keychain.py" delete "$1" >/dev/null 2>&1 || true
|
|
475
490
|
}
|
|
491
|
+
keychain_prune() { # $1 = acct dir: drop the non-canonical items once the canonical one is a
|
|
492
|
+
# live login (lib/keychain.py prune). Quiet, never fails; logs only a change.
|
|
493
|
+
[ "$MULTIACC_PROVIDER" = "codex" ] && return 0
|
|
494
|
+
local line
|
|
495
|
+
line="$("$PYBIN" "$LIB_DIR/keychain.py" prune "$1" 2>/dev/null)" || true
|
|
496
|
+
case "$line" in
|
|
497
|
+
pruned:*|failed:*) log_to ops.log "keychain prune $(basename "$1"): $line" ;;
|
|
498
|
+
esac
|
|
499
|
+
return 0
|
|
500
|
+
}
|
|
476
501
|
|
|
477
502
|
# ---- expired-login bookkeeping -------------------------------------------------
|
|
478
503
|
# `.expired` is the persistent "this account cannot authenticate" marker the shim
|
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)
|