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.
Files changed (42) hide show
  1. package/README.md +17 -1
  2. package/bin/claude +78 -18
  3. package/bin/claude-accounts +112 -22
  4. package/docs/ACCOUNT_OPERATIONS.md +5 -0
  5. package/docs/claude-token-identity.md +147 -0
  6. package/lib/__pycache__/audit.cpython-312.pyc +0 -0
  7. package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
  8. package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
  9. package/lib/__pycache__/claude_token_identity.cpython-312.pyc +0 -0
  10. package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
  11. package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
  12. package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
  13. package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
  14. package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
  15. package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
  16. package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
  17. package/lib/audit.py +10 -0
  18. package/lib/claude_token_identity.py +233 -0
  19. package/lib/common.sh +27 -2
  20. package/lib/credential.py +9 -2
  21. package/lib/keychain.py +274 -59
  22. package/package.json +1 -1
  23. package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
  24. package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
  25. package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
  26. package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
  27. package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
  28. package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
  29. package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
  30. package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
  31. package/tests/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
  32. package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
  33. package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
  34. package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
  35. package/tests/fake_security.sh +90 -0
  36. package/tests/run-tests.sh +254 -66
  37. package/tests/test_claude_token_identity.py +259 -0
  38. package/tests/test_keychain.py +323 -50
  39. package/tests/test_token_identity_ceremony.py +167 -0
  40. package/tests/test_token_identity_integration.py +207 -0
  41. package/tests/test_token_identity_retry.py +95 -0
  42. 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
@@ -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
- "$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 \
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 the item (a removed account must not leave a grant behind)
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)