claude-multiacc 2.0.58 → 2.0.60

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 (151) hide show
  1. package/README.md +125 -4
  2. package/bin/claude +389 -94
  3. package/bin/claude-accounts +276 -42
  4. package/bin/codex +69 -5
  5. package/bin/codex-accounts +33 -4
  6. package/docs/ACCOUNT_OPERATIONS.md +62 -2
  7. package/docs/AUTORESUME-HANDOFF.md +28 -3
  8. package/docs/AUTORESUME.md +275 -18
  9. package/docs/CODEX.md +1 -0
  10. package/docs/INSTALLATION.md +7 -1
  11. package/docs/VERIFICATION.md +4 -0
  12. package/lib/__pycache__/audit.cpython-312.pyc +0 -0
  13. package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
  14. package/lib/__pycache__/autoresume_cross.cpython-312.pyc +0 -0
  15. package/lib/__pycache__/autoresume_cross_dispatch.cpython-312.pyc +0 -0
  16. package/lib/__pycache__/autoresume_cross_receipt.cpython-312.pyc +0 -0
  17. package/lib/__pycache__/autoresume_handoff.cpython-312.pyc +0 -0
  18. package/lib/__pycache__/autoresume_probe.cpython-312.pyc +0 -0
  19. package/lib/__pycache__/autoresume_receipt.cpython-312.pyc +0 -0
  20. package/lib/__pycache__/claude_client_error.cpython-312.pyc +0 -0
  21. package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
  22. package/lib/__pycache__/claude_token_identity.cpython-312.pyc +0 -0
  23. package/lib/__pycache__/codex_autoresume_mode.cpython-312.pyc +0 -0
  24. package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
  25. package/lib/__pycache__/codex_credit_controls.cpython-312.pyc +0 -0
  26. package/lib/__pycache__/codex_credit_local_scope.cpython-312.pyc +0 -0
  27. package/lib/__pycache__/codex_credit_selection.cpython-312.pyc +0 -0
  28. package/lib/__pycache__/codex_credits.cpython-312.pyc +0 -0
  29. package/lib/__pycache__/codex_limits_lock.cpython-312.pyc +0 -0
  30. package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
  31. package/lib/__pycache__/codex_trust.cpython-312.pyc +0 -0
  32. package/lib/__pycache__/codex_usage_fetch.cpython-312.pyc +0 -0
  33. package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
  34. package/lib/__pycache__/mcp_oauth.cpython-312.pyc +0 -0
  35. package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
  36. package/lib/__pycache__/model_catalog.cpython-312.pyc +0 -0
  37. package/lib/__pycache__/model_match.cpython-312.pyc +0 -0
  38. package/lib/__pycache__/report.cpython-312.pyc +0 -0
  39. package/lib/__pycache__/selector_credit_scope.cpython-312.pyc +0 -0
  40. package/lib/__pycache__/selector_credits.cpython-312.pyc +0 -0
  41. package/lib/__pycache__/selector_funding.cpython-312.pyc +0 -0
  42. package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
  43. package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
  44. package/lib/__pycache__/session_settings.cpython-312.pyc +0 -0
  45. package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
  46. package/lib/audit.py +88 -22
  47. package/lib/autoresume.py +1197 -82
  48. package/lib/autoresume_cross.py +3 -1
  49. package/lib/autoresume_handoff.py +81 -9
  50. package/lib/autoresume_probe.py +2 -2
  51. package/lib/claude_client_error.py +9 -4
  52. package/lib/codex_trust.py +263 -0
  53. package/lib/common.sh +146 -3
  54. package/lib/credential.py +5 -2
  55. package/lib/install_actions.sh +19 -0
  56. package/lib/keychain.py +180 -36
  57. package/lib/mcp_oauth.py +1748 -0
  58. package/lib/mcp_registry.py +230 -8
  59. package/lib/model_catalog.json +404 -0
  60. package/lib/model_catalog.py +201 -0
  61. package/lib/model_match.py +104 -0
  62. package/lib/report.py +8 -5
  63. package/lib/session_settings.py +123 -0
  64. package/package.json +2 -2
  65. package/tests/__pycache__/autoresume_cross_receipt_support.cpython-312.pyc +0 -0
  66. package/tests/__pycache__/autoresume_cross_tmux_environment.cpython-312.pyc +0 -0
  67. package/tests/__pycache__/autoresume_cross_tmux_support.cpython-312.pyc +0 -0
  68. package/tests/__pycache__/autoresume_cross_unit_support.cpython-312.pyc +0 -0
  69. package/tests/__pycache__/autoresume_tmux_support.cpython-312.pyc +0 -0
  70. package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
  71. package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
  72. package/tests/__pycache__/test_autoresume_catalog.cpython-312.pyc +0 -0
  73. package/tests/__pycache__/test_autoresume_catalog_cli.cpython-312.pyc +0 -0
  74. package/tests/__pycache__/test_autoresume_codex_records.cpython-312.pyc +0 -0
  75. package/tests/__pycache__/test_autoresume_codex_trust.cpython-312.pyc +0 -0
  76. package/tests/__pycache__/test_autoresume_cross_claude_holders.cpython-312.pyc +0 -0
  77. package/tests/__pycache__/test_autoresume_cross_guards.cpython-312.pyc +0 -0
  78. package/tests/__pycache__/test_autoresume_cross_holders.cpython-312.pyc +0 -0
  79. package/tests/__pycache__/test_autoresume_cross_origin.cpython-312.pyc +0 -0
  80. package/tests/__pycache__/test_autoresume_cross_policy.cpython-312.pyc +0 -0
  81. package/tests/__pycache__/test_autoresume_cross_receipt.cpython-312.pyc +0 -0
  82. package/tests/__pycache__/test_autoresume_cross_tmux.cpython-312.pyc +0 -0
  83. package/tests/__pycache__/test_autoresume_cross_tmux_races.cpython-312.pyc +0 -0
  84. package/tests/__pycache__/test_autoresume_cross_tokens.cpython-312.pyc +0 -0
  85. package/tests/__pycache__/test_autoresume_cursor.cpython-312.pyc +0 -0
  86. package/tests/__pycache__/test_autoresume_fallback.cpython-312.pyc +0 -0
  87. package/tests/__pycache__/test_autoresume_handoff_argv.cpython-312.pyc +0 -0
  88. package/tests/__pycache__/test_autoresume_handoff_history.cpython-312.pyc +0 -0
  89. package/tests/__pycache__/test_autoresume_handoff_origins.cpython-312.pyc +0 -0
  90. package/tests/__pycache__/test_autoresume_memory.cpython-312.pyc +0 -0
  91. package/tests/__pycache__/test_autoresume_memory_tmux.cpython-312.pyc +0 -0
  92. package/tests/__pycache__/test_autoresume_mode.cpython-312.pyc +0 -0
  93. package/tests/__pycache__/test_autoresume_probe.cpython-312.pyc +0 -0
  94. package/tests/__pycache__/test_autoresume_receipt.cpython-312.pyc +0 -0
  95. package/tests/__pycache__/test_autoresume_relaunch_settings.cpython-312.pyc +0 -0
  96. package/tests/__pycache__/test_autoresume_settings.cpython-312.pyc +0 -0
  97. package/tests/__pycache__/test_autoresume_sidechain.cpython-312.pyc +0 -0
  98. package/tests/__pycache__/test_autoresume_sidechain_tmux.cpython-312.pyc +0 -0
  99. package/tests/__pycache__/test_autoresume_tmux.cpython-312.pyc +0 -0
  100. package/tests/__pycache__/test_autoresume_tmux_modes.cpython-312.pyc +0 -0
  101. package/tests/__pycache__/test_autoresume_trust_tmux.cpython-312.pyc +0 -0
  102. package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
  103. package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
  104. package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
  105. package/tests/__pycache__/test_codex_reset_credits.cpython-312.pyc +0 -0
  106. package/tests/__pycache__/test_codex_reset_credits_polling.cpython-312.pyc +0 -0
  107. package/tests/__pycache__/test_codex_reset_credits_report.cpython-312.pyc +0 -0
  108. package/tests/__pycache__/test_codex_reset_fetch.cpython-312.pyc +0 -0
  109. package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
  110. package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
  111. package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
  112. package/tests/__pycache__/test_selector.cpython-312.pyc +0 -0
  113. package/tests/__pycache__/test_selector_cached_credits.cpython-312.pyc +0 -0
  114. package/tests/__pycache__/test_selector_credit_scope.cpython-312.pyc +0 -0
  115. package/tests/__pycache__/test_selector_credit_shim.cpython-312.pyc +0 -0
  116. package/tests/__pycache__/test_selector_credits.cpython-312.pyc +0 -0
  117. package/tests/__pycache__/test_selector_spread.cpython-312.pyc +0 -0
  118. package/tests/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
  119. package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
  120. package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
  121. package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
  122. package/tests/fake_codex_tui.py +25 -0
  123. package/tests/fake_security.sh +24 -2
  124. package/tests/fixtures/cross_provider_cli.py +109 -0
  125. package/tests/run-tests.sh +1171 -23
  126. package/tests/test_autoresume.py +215 -8
  127. package/tests/test_autoresume_catalog.py +226 -0
  128. package/tests/test_autoresume_catalog_cli.py +185 -0
  129. package/tests/test_autoresume_codex_records.py +16 -0
  130. package/tests/test_autoresume_codex_trust.py +247 -0
  131. package/tests/test_autoresume_cross_guards.py +28 -0
  132. package/tests/test_autoresume_cross_tmux.py +27 -2
  133. package/tests/test_autoresume_cross_tokens.py +32 -2
  134. package/tests/test_autoresume_handoff_argv.py +120 -16
  135. package/tests/test_autoresume_handoff_origins.py +14 -0
  136. package/tests/test_autoresume_memory.py +1141 -0
  137. package/tests/test_autoresume_memory_tmux.py +94 -0
  138. package/tests/test_autoresume_probe.py +2 -1
  139. package/tests/test_autoresume_relaunch_settings.py +317 -0
  140. package/tests/test_autoresume_settings.py +135 -0
  141. package/tests/test_autoresume_sidechain.py +1518 -0
  142. package/tests/test_autoresume_sidechain_tmux.py +79 -0
  143. package/tests/test_autoresume_trust_tmux.py +63 -0
  144. package/tests/test_claude_client_error.py +10 -7
  145. package/tests/test_claude_org_block.py +1 -0
  146. package/tests/test_claude_pool_prepare.py +4 -1
  147. package/tests/test_claude_reset.py +3 -0
  148. package/tests/test_keychain_nologin.py +340 -0
  149. package/tests/test_mcp_oauth.py +1414 -0
  150. package/tests/test_mcp_registry.py +234 -3
  151. package/tests/test_packaged_commands.py +41 -0
package/lib/audit.py CHANGED
@@ -24,6 +24,16 @@ the same document and the same machine-local class; the file is simply gone once
24
24
  client has moved it into the Keychain, so a file-only reading calls a working login
25
25
  "missing" (that is exactly what happened to a whole pool on 2026-08-28).
26
26
 
27
+ That document is the client's whole credential store: it also keeps the OAuth state of
28
+ the MCP servers the account signed into (`mcpOAuth`, `mcpOAuthClientConfig`, each entry
29
+ with its own expiresAt/refreshToken). It holds a LOGIN only when it carries a
30
+ `claudeAiOauth` object. From a session that cannot open the Keychain (ssh/tmux) Claude
31
+ Code 2.1.303 writes a .credentials.json holding only MCP state as soon as an OAuth MCP
32
+ server is discovered; counting that file as the login picked accounts that exported no
33
+ token and could only say "Not logged in" (2026-10-19). Such a document is no login for
34
+ any decision here — the Keychain item and server.token decide — and every expiry or
35
+ refresh-token reading looks inside the login object alone (bin/claude: file_login).
36
+
27
37
  Run directly for a TSV dump: python3 lib/audit.py <acc-root> [mac|linux]
28
38
  Columns: id, email, home, state, label, reason, fix
29
39
  """
@@ -59,6 +69,44 @@ def _nonempty(path):
59
69
  return False
60
70
 
61
71
 
72
+ def _text(path):
73
+ try:
74
+ with open(path, errors='replace') as f:
75
+ return f.read()
76
+ except OSError:
77
+ return None
78
+
79
+
80
+ # The login object found in a document's TEXT, as the shim isolates it (bin/claude:
81
+ # cred_login_of): flat, so it ends at its first closing brace — or, in a truncated file,
82
+ # at the end of the text. Only for a document that does not parse.
83
+ _LOGIN_TEXT = re.compile(r'"claudeAiOauth"\s*:\s*\{[^}]*')
84
+
85
+
86
+ def login_of(doc):
87
+ """The Claude login a parsed credential document carries — its `claudeAiOauth`
88
+ object — or None: a document holding only MCP OAuth state holds no login."""
89
+ o = doc.get('claudeAiOauth') if isinstance(doc, dict) else None
90
+ return o if isinstance(o, dict) else None
91
+
92
+
93
+ def file_has_login(cpath):
94
+ """True when <cpath> (a .credentials.json) holds a Claude login, by the shim's rule
95
+ (bin/claude: file_login): a document carrying a `claudeAiOauth` object — or, for one
96
+ that does not parse (truncated, mid-write), text that still opens one. An empty file
97
+ or one with MCP state alone is no login: the Keychain and server.token decide."""
98
+ if not _nonempty(cpath):
99
+ return False
100
+ txt = _text(cpath)
101
+ if txt is None:
102
+ return False
103
+ try:
104
+ doc = json.loads(txt)
105
+ except (ValueError, RecursionError):
106
+ return bool(_LOGIN_TEXT.search(txt))
107
+ return login_of(doc) is not None
108
+
109
+
62
110
  def marker_reason(mpath):
63
111
  """Second line of a marker file, as a human string ('' when absent/unreadable)."""
64
112
  try:
@@ -109,15 +157,22 @@ def expired_marked(d, now=None):
109
157
  return False
110
158
  elif slug != 'org-blocked':
111
159
  mt = _mtime(mpath)
112
- for name in ('.credentials.json', 'server.token'):
113
- p = os.path.join(d, name)
114
- if os.path.isfile(p) and _mtime(p) > mt:
160
+ p = os.path.join(d, 'server.token')
161
+ if os.path.isfile(p) and _mtime(p) > mt:
162
+ return False
163
+ # A newer LOGIN is the evidence a credential park is about. The client rewrites
164
+ # .credentials.json with MCP state alone on every MCP token refresh; that file
165
+ # proves nothing about the login and heals nothing.
166
+ cpath = os.path.join(d, '.credentials.json')
167
+ if file_has_login(cpath):
168
+ if _mtime(cpath) > mt:
115
169
  return False
116
170
  # A login the client keeps in the Keychain is "written after the marker" in
117
171
  # exactly the same sense — its modification stamp is readable even when this
118
- # session cannot open the secret.
119
- if not os.path.isfile(os.path.join(d, '.credentials.json')) \
120
- and keychain.item_mtime(d) > mt:
172
+ # session cannot open the secret. An item this session CAN read is judged by
173
+ # what it holds: MCP state alone heals nothing there either.
174
+ elif keychain.item_mtime(d) > mt \
175
+ and keychain.probe(d)['state'] in ('present', 'locked'):
121
176
  return False
122
177
  soft = marker_soft_until(detail)
123
178
  if soft is not None and soft <= now:
@@ -136,18 +191,22 @@ def _ms(value):
136
191
 
137
192
  def _scrape(cpath):
138
193
  """The shim's rule for a credential JSON cannot parse: sed out the numbers and the
139
- presence of a refresh token (bin/claude: cred_num / creds_dead). Used so a
140
- half-written file is judged the same way on both sides — audit.py must never call
141
- an account dead that the shim is happily selecting."""
142
- try:
143
- txt = open(cpath, errors='replace').read()
144
- except OSError:
194
+ presence of a refresh token (bin/claude: cred_num / creds_dead) — from the LOGIN
195
+ object alone, since every MCP entry beside it carries an expiresAt and a
196
+ refreshToken of its own. Used so a half-written file is judged the same way on both
197
+ sides — audit.py must never call an account dead that the shim is happily
198
+ selecting. None when the text opens no login at all."""
199
+ txt = _text(cpath)
200
+ m = _LOGIN_TEXT.search(txt) if txt is not None else None
201
+ if not m:
145
202
  return None
203
+ login = m.group(0)
204
+
146
205
  def num(key):
147
- m = re.search(r'"%s"\s*:\s*(\d+)' % key, txt)
148
- return float(m.group(1)) / 1000.0 if m else 0.0
206
+ found = re.search(r'"%s"\s*:\s*(\d+)' % key, login)
207
+ return float(found.group(1)) / 1000.0 if found else 0.0
149
208
  return {'exp': num('expiresAt'), 'rexp': num('refreshTokenExpiresAt'),
150
- 'has_refresh': bool(re.search(r'"refreshToken"\s*:\s*"[^"]', txt))}
209
+ 'has_refresh': bool(re.search(r'"refreshToken"\s*:\s*"[^"]', login))}
151
210
 
152
211
 
153
212
  def creds_doc_state(doc, now):
@@ -171,7 +230,8 @@ def creds_doc_state(doc, now):
171
230
 
172
231
 
173
232
  def creds_state(cpath, now):
174
- """(state, reason) for an on-disk .credentials.json."""
233
+ """(state, reason) for an on-disk .credentials.json that holds a login
234
+ (file_has_login) — a file without one is no login to judge."""
175
235
  scraped = None
176
236
  try:
177
237
  return creds_doc_state(json.load(open(cpath)), now)
@@ -191,12 +251,13 @@ def oauth_login(d, now):
191
251
  """Where <d>'s OAuth login lives and whether it can authenticate.
192
252
 
193
253
  Returns {'store': 'file'|'keychain'|None, 'state': 'ok'|'expired'|'locked'|None,
194
- 'reason': str, 'doc': dict|None}. The FILE wins when both exist: it is what
195
- a session without keychain access (ssh) will use, and the client itself reads the
196
- keychain first only when it can open it. 'locked' means the login exists in the
197
- Keychain but this session cannot open it."""
254
+ 'reason': str, 'doc': dict|None}. The FILE wins when it holds a login: it is
255
+ what a session without keychain access (ssh) will use, and the client itself reads
256
+ the keychain first only when it can open it. A file or item holding MCP state alone
257
+ is no login (store None unless the other place has one). 'locked' means the login
258
+ exists in the Keychain but this session cannot open it."""
198
259
  cpath = os.path.join(d, '.credentials.json')
199
- if _nonempty(cpath):
260
+ if file_has_login(cpath):
200
261
  state, reason = creds_state(cpath, now)
201
262
  return {'store': 'file', 'state': state, 'reason': reason, 'doc': None}
202
263
  kc = keychain.probe(d)
@@ -215,6 +276,11 @@ def oauth_login(d, now):
215
276
  'reason': ('OAuth login is in the macOS Keychain, which this session '
216
277
  'cannot open (ssh/background) — it works from the Mac\'s own '
217
278
  'session'), 'doc': None}
279
+ if kc['state'] == 'nologin' or _nonempty(cpath):
280
+ # The client's MCP OAuth state with no login beside it (or a file that is no
281
+ # credential at all): nothing here authenticates, whatever its entries' expiry.
282
+ return {'store': None, 'state': None, 'doc': None,
283
+ 'reason': 'no Claude login here (the credential store holds no claudeAiOauth)'}
218
284
  return {'store': None, 'state': None, 'reason': '', 'doc': None}
219
285
 
220
286
 
@@ -356,7 +422,7 @@ def audit_account(root, acct, now=None, machine=None, require_verified_token=Fal
356
422
  row['reason'] = f'no auth here — the grant lives on the {home} machine'
357
423
  else:
358
424
  row['state'] = 'missing'
359
- row['reason'] = 'no credentials on this machine'
425
+ row['reason'] = login['reason'] or 'no credentials on this machine'
360
426
  return done(row)
361
427
 
362
428