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
@@ -0,0 +1,79 @@
1
+ """A workflow agent's five-hour limit moves a claude session: actual tmux, shell, production
2
+ shim and watcher, with the cross-provider suite's disposable fake Claude TUI."""
3
+
4
+ import json
5
+ from pathlib import Path
6
+ import signal
7
+ import time
8
+ import unittest
9
+
10
+ from autoresume_cross_tmux_support import CrossScenario, TMUX
11
+
12
+ AGENT = "a33659a7131e55067"
13
+ AGENT2 = "ab12cd34ef56a7890"
14
+
15
+
16
+ def stamp(now):
17
+ """A transcript timestamp to the millisecond, as claude 2.1.303 writes them."""
18
+ return time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime(now)) + ".%03dZ" % int(now % 1 * 1000)
19
+
20
+
21
+ @unittest.skipUnless(TMUX, "real tmux is unavailable")
22
+ class SidechainTmuxTests(unittest.TestCase):
23
+ def test_a_workflow_agents_limit_resumes_the_idle_session_on_another_account(self):
24
+ # The suite's short grace (0.4 s): the relaunch starts a second or two after the
25
+ # rejection, and one more agent is rejected as the client is stopped (the fake TUI
26
+ # records it on SIGTERM). The relaunched watcher reads both in the same agent
27
+ # transcripts and must act on neither: they are the replaced process's.
28
+ case = CrossScenario(CLAUDE_MULTIACC_AR_SUBAGENT_SCAN="0.2",
29
+ FAKE_AGENT_LIMIT_ON_TERM=AGENT2)
30
+ self.addCleanup(case.close)
31
+ case.start("claude")
32
+ ready = case.records("ready")[0]
33
+ # this launch's own agents start a second after it at the soonest
34
+ state = case.pools["claude"] / "tmp" / "autoresume" / ("%d.state" % ready["pid"])
35
+ launched = int(dict(line.split("=", 1) for line in state.read_text().splitlines()
36
+ if "=" in line)["launched"])
37
+ time.sleep(max(0.0, launched + 1.05 - time.time()))
38
+ # the main transcript stays quiet; only the workflow's agent records the limit
39
+ agent = (Path(ready["path"]).with_suffix("") / "subagents" / "workflows" / "wf_e2e"
40
+ / ("agent-%s.jsonl" % AGENT))
41
+ agent.parent.mkdir(parents=True)
42
+ reset = int(time.time()) + 3600
43
+ agent.write_text(json.dumps({
44
+ "type": "assistant", "isSidechain": True, "agentId": AGENT, "sessionId": ready["sid"],
45
+ "timestamp": stamp(time.time()),
46
+ "isApiErrorMessage": True, "error": "rate_limit", "apiError": "usage_limit_reached",
47
+ "apiErrorStatus": 429, "quotaLimits": {"status": "rejected", "resetsAt": reset,
48
+ "rateLimitType": "five_hour",
49
+ "overageStatus": "rejected"},
50
+ "message": {"model": "<synthetic>", "role": "assistant", "content": [
51
+ {"type": "text", "text": "You've hit your session limit"}]}}, separators=(",", ":"))
52
+ + "\n")
53
+ case.wait(lambda: len(case.records("ready")) >= 2, "same session resumed on another account")
54
+ case.wait(lambda: "autoresume switch" in case.logs("claude"), "watcher confirms switch")
55
+ first, second = case.records("launch")[:2]
56
+ self.assertEqual([first["provider"], second["provider"]], ["claude", "claude"])
57
+ self.assertEqual((first["account"], second["account"]), ("acct-01", "acct-02"))
58
+ self.assertEqual(first["sid"], second["sid"])
59
+ self.assertEqual(second["argv"][second["argv"].index("--resume") + 1], first["sid"])
60
+ self.assertIn("Continue", second["argv"][-1])
61
+ self.assertIn("class=quota code=rate_limit sid=%s acct=acct-01 reset=%d src=sidechain agent=%s"
62
+ % (first["sid"], reset, AGENT), case.logs("claude"))
63
+ self.assertEqual(case.records("keys"), [], "no keystroke may reach either TUI")
64
+ self.assertEqual(case.records("signal")[0]["signal"], signal.SIGTERM)
65
+ self.assertEqual([row["agent"] for row in case.records("agent-limit")], [AGENT2])
66
+ marker = (case.pools["claude"] / "acct-01" / ".limited").read_text().split("\n")
67
+ self.assertEqual(marker[0], str(reset))
68
+ self.assertIn("bucket=client:five_hour", marker[1])
69
+ # the relaunched session's watcher reads the same agent transcripts, and lets them be
70
+ case.wait(lambda: case.logs("claude").count("autoresume watch") == 2, "second watcher")
71
+ time.sleep(1)
72
+ self.assertEqual(case.logs("claude").count("autoresume detect"), 1)
73
+ self.assertEqual(len(case.records("launch")), 2)
74
+ self.assertFalse((case.pools["claude"] / "acct-02" / ".limited").exists(),
75
+ "the account the session moved to is never parked")
76
+
77
+
78
+ if __name__ == "__main__":
79
+ unittest.main()
@@ -0,0 +1,63 @@
1
+ """Folder trust must be in place before a resumed Codex can submit its prompt."""
2
+
3
+ import json
4
+ import re
5
+ import unittest
6
+
7
+ from autoresume_tmux_support import TMUX, TmuxScenario
8
+ from autoresume_cross_tmux_support import CrossScenario
9
+
10
+ QUOTA = {"type": "task_complete", "error": {"message": "You've hit your usage limit.",
11
+ "codex_error_info": "usage_limit_exceeded"}}
12
+
13
+
14
+ @unittest.skipUnless(TMUX, "real tmux is unavailable")
15
+ class CodexTrustTmuxTests(unittest.TestCase):
16
+ def test_quota_restart_trusts_only_destination_before_prompt(self):
17
+ case = TmuxScenario(FAKE_REQUIRE_TRUST="1")
18
+ self.addCleanup(case.close)
19
+ source = case.pool / "acct-01/config.toml"
20
+ target = case.pool / "acct-02/config.toml"
21
+ source.write_text('# Source account stays unchanged.\n')
22
+ target.write_text('[projects.' + json.dumps(str(case.work)) + ']\ntrust_level = "untrusted"\n')
23
+ case.start()
24
+ self.assertEqual(source.read_text(), '# Source account stays unchanged.\n')
25
+ self.assertIn('"untrusted"', target.read_text())
26
+ case.append(QUOTA)
27
+ case.wait(lambda: "autoresume switch" in case.log.read_text(), "trusted restart confirmed")
28
+ self.assertTrue(case.records("trust")[0]["trusted"])
29
+ self.assertEqual(len(case.records("prompt")), 1)
30
+ self.assertEqual(case.records("keys"), [])
31
+ self.assertEqual(source.read_text(), '# Source account stays unchanged.\n')
32
+
33
+ def test_quota_restart_fails_open_when_the_trust_write_is_refused(self):
34
+ case = TmuxScenario(FAKE_REQUIRE_TRUST="1")
35
+ self.addCleanup(case.close)
36
+ target = case.pool / "acct-02/config.toml"
37
+ # A bare [projects] table is a layout lib/codex_trust.py refuses to edit.
38
+ target.write_text('[projects]\n' + json.dumps(str(case.work)) + ' = { trust_level = "untrusted" }\n')
39
+ before = target.read_bytes()
40
+ case.start()
41
+ case.append(QUOTA)
42
+ case.wait(lambda: case.records("trust"), "relaunched TUI on its trust screen")
43
+ self.assertEqual(re.findall(r" autoresume trust-failed chain=\w+ acct=(\S+)$", case.log.read_text(), re.M),
44
+ ["acct-02"])
45
+ self.assertEqual([row["account"] for row in case.records("launch")], ["acct-01", "acct-02"])
46
+ self.assertEqual([row["trusted"] for row in case.records("trust")], [False])
47
+ self.assertEqual(target.read_bytes(), before)
48
+
49
+ def test_cross_provider_handoff_trusts_codex_destination(self):
50
+ case = CrossScenario(FAKE_REQUIRE_TRUST="1")
51
+ self.addCleanup(case.close)
52
+ case.start("claude")
53
+ case.park("claude")
54
+ case.quota()
55
+ case.wait(lambda: "autoresume switch" in case.logs("claude"), "trusted Codex handoff confirmed")
56
+ self.assertTrue(case.records("trust")[0]["trusted"])
57
+ self.assertEqual(case.records("prompt")[0]["provider"], "codex")
58
+ self.assertEqual(case.records("keys"), [])
59
+ self.assertFalse((case.pools["codex"] / "acct-02/config.toml").exists())
60
+
61
+
62
+ if __name__ == "__main__":
63
+ unittest.main()
@@ -79,14 +79,17 @@ class ClaudeClientErrorTests(unittest.TestCase):
79
79
  del without_sidechain["isSidechain"]
80
80
  self.assertEqual(errors.newest_error([json.dumps(without_sidechain)], CLAIM), ("org-blocked", LATER))
81
81
 
82
- def test_organization_recovery_watermark_does_not_clear_authentication(self):
82
+ def test_verified_access_supersedes_every_error_recorded_before_it(self):
83
+ # A verify that reached the account proves the credential it runs on authenticates
84
+ # for an organization that admits Claude Code: an older authentication failure is
85
+ # as stale as an older org refusal, and must not re-park what that PASS lifted.
83
86
  at = errors.iso_epoch(LATER)
84
- org = json.dumps(api_record())
85
- self.assertIsNone(errors.newest_error([org], CLAIM, at))
86
- self.assertIsNone(errors.newest_error([org], CLAIM, at + 1))
87
- self.assertEqual(errors.newest_error([org], CLAIM, at - 0.001), ("org-blocked", LATER))
88
- auth = json.dumps(api_record("authentication_failed"))
89
- self.assertEqual(errors.newest_error([auth], CLAIM, at + 100), ("auth", LATER))
87
+ for code, slug in errors.ERRORS.items():
88
+ with self.subTest(code=code):
89
+ record = json.dumps(api_record(code))
90
+ self.assertIsNone(errors.newest_error([record], CLAIM, at))
91
+ self.assertIsNone(errors.newest_error([record], CLAIM, at + 100))
92
+ self.assertEqual(errors.newest_error([record], CLAIM, at - 0.001), (slug, LATER))
90
93
 
91
94
  def test_latest_in_class_wins_and_auth_cannot_downgrade_org_refusal(self):
92
95
  latest = "2026-09-30T13:30:02.000+03:00"
@@ -74,6 +74,7 @@ class ClaudeOrganizationBlockIntegrationTests(unittest.TestCase):
74
74
  CLAUDE_MULTIACC_KEYCHAIN="0", CLAUDE_MULTIACC_PATH_PROBE="0", CLAUDE_MULTIACC_MCP="0",
75
75
  CLAUDE_MULTIACC_AUTORESUME="0", CLAUDE_MULTIACC_AUTO_RESET="0", CLAUDE_MULTIACC_HEADROOM_BAND="0",
76
76
  CLAUDE_MULTIACC_CLIENT_SCAN_TTL="0", CLAUDE_MULTIACC_NO_SYNC="1", CLAUDE_MULTIACC_NO_DISTRIBUTE="1",
77
+ CLAUDE_MULTIACC_MCP_OAUTH="0", # the detached MCP OAuth sync must not outlive a test
77
78
  CLAUDE_MULTIACC_USAGE_URL=f"file://{self.home}/missing-usage.json",
78
79
  CLAUDE_MULTIACC_TOKEN_URL=f"file://{self.home}/missing-token.json",
79
80
  CLAUDE_MULTIACC_TOKEN_IDENTITY_URL=f"file://{self.home}/missing-identity.json",
@@ -28,8 +28,11 @@ class ClaudePoolPrepareTests(unittest.TestCase):
28
28
  }))
29
29
 
30
30
  def run_prepare(self, verb):
31
+ # Run from inside a pooled session, the environment names the REAL pool root; the
32
+ # repair must only ever see this test's --home.
33
+ env = {k: v for k, v in os.environ.items() if k != "CLAUDE_ACCOUNTS_ROOT"}
31
34
  return subprocess.run([sys.executable, str(SCRIPT), verb, "--home", str(self.home)],
32
- capture_output=True, text=True)
35
+ capture_output=True, text=True, env=env)
33
36
 
34
37
  def test_fresh_pool_starts_shared_and_bypassed(self):
35
38
  self.assertEqual(self.run_prepare("check").returncode, 1)
@@ -163,6 +163,9 @@ class ClaudePoolSandbox:
163
163
  env.update({
164
164
  "CLAUDE_ACCOUNTS_DIR": str(self.pool), "CLAUDE_MULTIACC_NO_SYNC": "1",
165
165
  "CLAUDE_MULTIACC_NO_DISTRIBUTE": "1", "CLAUDE_MULTIACC_MIN_FETCH": "0",
166
+ # No detached child may outlive the pass and write into the pool as tearDown
167
+ # removes it: the MCP OAuth sync (lib/mcp_oauth.py) the pass spawns, like the push.
168
+ "CLAUDE_MULTIACC_MCP_OAUTH": "0",
166
169
  "CLAUDE_MULTIACC_KEYCHAIN": "0", "CLAUDE_MULTIACC_PATH_PROBE": "0",
167
170
  "CLAUDE_MULTIACC_AUTO_RESET": "1", "CLAUDE_MULTIACC_CLI_VERSION": "2.1.280",
168
171
  "PYTHONDONTWRITEBYTECODE": "1",
@@ -0,0 +1,340 @@
1
+ #!/usr/bin/env python3
2
+ """A credentials document holding no Claude login is no login (lib/keychain.py,
3
+ lib/audit.py, lib/report.py, lib/credential.py).
4
+
5
+ Claude Code 2.1.303 keeps its MCP servers' OAuth state (``mcpOAuth``,
6
+ ``mcpOAuthClientConfig``) in the same document as the login, and from a session that
7
+ cannot open the Keychain (ssh/tmux) it writes a ``.credentials.json`` holding ONLY that.
8
+ Counted as the login, such accounts were picked with no token exported and every run said
9
+ "Not logged in" (2026-10-19). Runs everywhere: a fake ``security`` on PATH serves items
10
+ from a temp dir, and CLAUDE_MULTIACC_KEYCHAIN=1 forces the lookup on off-macOS.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import calendar
16
+ import json
17
+ import os
18
+ from pathlib import Path
19
+ import subprocess
20
+ import sys
21
+ import tempfile
22
+ import time
23
+ import unittest
24
+
25
+ REPO = Path(__file__).resolve().parents[1]
26
+ sys.path.insert(0, str(REPO / 'lib'))
27
+
28
+ FAKE_SECURITY = (REPO / 'tests' / 'fake_security.sh').read_text()
29
+ KEY = 'higgsfield|0123456789abcdef'
30
+ ENTRY = {'serverName': 'higgsfield', 'serverUrl': 'https://mcp.higgsfield.ai/mcp',
31
+ 'accessToken': 'mcp-access-fixture', 'refreshToken': 'mcp-refresh-fixture',
32
+ 'expiresAt': 9999999999999, 'scope': 'openid', 'issuer': 'https://clerk.higgsfield.ai',
33
+ 'clientId': 'https://claude.ai/oauth/claude-code-client-metadata',
34
+ 'redirectUri': 'http://localhost:1/callback',
35
+ 'discoveryState': {'authorizationServerUrl': 'https://clerk.higgsfield.ai',
36
+ 'oauthMetadataFound': True}}
37
+ MCP_ONLY = {'mcpOAuth': {KEY: ENTRY}}
38
+ LIVE = {'claudeAiOauth': {'accessToken': 'sk-ant-oat01-live', 'refreshToken': 'r',
39
+ 'expiresAt': 9999999999999, 'refreshTokenExpiresAt': 9999999999999}}
40
+ DEAD = {'claudeAiOauth': {'accessToken': 'sk-ant-oat01-dead', 'refreshToken': 'r',
41
+ 'expiresAt': 1000, 'refreshTokenExpiresAt': 1000}}
42
+ # An expired login with no refresh token, beside an MCP entry that is alive and has one:
43
+ # the shape whose greedy scrape read the entry's numbers as the login's.
44
+ DEAD_BESIDE_MCP = {'claudeAiOauth': {'accessToken': 'sk-ant-oat01-old', 'expiresAt': 1000},
45
+ 'mcpOAuth': {KEY: ENTRY}}
46
+
47
+
48
+ class Fixture(unittest.TestCase):
49
+ """A fake keychain, a pool with one account dir, and the modules reloaded."""
50
+
51
+ def setUp(self):
52
+ self.work = tempfile.TemporaryDirectory()
53
+ work = Path(self.work.name)
54
+ fakebin = work / 'bin'
55
+ fakebin.mkdir()
56
+ tool = fakebin / 'security'
57
+ tool.write_text(FAKE_SECURITY)
58
+ tool.chmod(0o755)
59
+ self.kcdir = work / 'keychain'
60
+ self.saved = {k: os.environ.get(k) for k in
61
+ ('PATH', 'CLAUDE_MULTIACC_KEYCHAIN', 'FAKE_KEYCHAIN_DIR',
62
+ 'FAKE_KEYCHAIN_LOCKED', 'FAKE_KEYCHAIN_MDAT', 'FAKE_SECURITY_LOG',
63
+ 'USER')}
64
+ os.environ['PATH'] = f"{fakebin}:{os.environ['PATH']}"
65
+ os.environ['CLAUDE_MULTIACC_KEYCHAIN'] = '1'
66
+ os.environ['FAKE_KEYCHAIN_DIR'] = str(self.kcdir)
67
+ for key in ('FAKE_KEYCHAIN_LOCKED', 'FAKE_KEYCHAIN_MDAT', 'FAKE_SECURITY_LOG'):
68
+ os.environ.pop(key, None)
69
+ os.environ['USER'] = 'gas'
70
+ self.calllog = work / 'security.log'
71
+ import importlib
72
+ import keychain
73
+ self.keychain = importlib.reload(keychain)
74
+ self.root = work / 'accounts'
75
+ self.acct = self.root / 'acct-01'
76
+ self.acct.mkdir(parents=True)
77
+ self.d = str(self.acct)
78
+ self.cpath = self.acct / '.credentials.json'
79
+
80
+ def tearDown(self):
81
+ for key, value in self.saved.items():
82
+ if value is None:
83
+ os.environ.pop(key, None)
84
+ else:
85
+ os.environ[key] = value
86
+ self.work.cleanup()
87
+
88
+ def put(self, doc, account='gas', mdat='20261019000000', d=None):
89
+ """An item under <account>, written the way a client whose FA() said so writes it."""
90
+ text = doc if isinstance(doc, str) else json.dumps(doc)
91
+ subprocess.run(['security', 'add-generic-password', '-U', '-a', account, '-s',
92
+ self.keychain.service_name(d or self.d), '-X',
93
+ text.encode('utf-8').hex()],
94
+ check=True, env=dict(os.environ, FAKE_KEYCHAIN_MDAT=mdat))
95
+
96
+ def items(self):
97
+ svc = self.kcdir / self.keychain.service_name(self.d)
98
+ return sorted(p.name for p in svc.iterdir()) if svc.is_dir() else []
99
+
100
+ def secret(self, account='gas'):
101
+ svc = self.kcdir / self.keychain.service_name(self.d)
102
+ return json.loads((svc / account / 'secret').read_text())
103
+
104
+ def calls(self, fn, *args):
105
+ self.calllog.write_text('')
106
+ os.environ['FAKE_SECURITY_LOG'] = str(self.calllog)
107
+ try:
108
+ result = fn(*args)
109
+ finally:
110
+ os.environ.pop('FAKE_SECURITY_LOG', None)
111
+ return result, self.calllog.read_text().splitlines()
112
+
113
+ def write_file(self, doc, mtime=None):
114
+ self.cpath.write_text(doc if isinstance(doc, str) else json.dumps(doc))
115
+ if mtime is not None:
116
+ os.utime(self.cpath, (mtime, mtime))
117
+
118
+ def manifest(self, home='mac'):
119
+ (self.root / 'accounts.json').write_text(json.dumps({
120
+ 'version': 1, 'server': 'root@203.0.113.1', 'threshold': 90,
121
+ 'accounts': [{'id': 'acct-01', 'email': 'mcp@test', 'home': home,
122
+ 'added_at': '2026-10-19T00:00:00Z'}]}))
123
+
124
+
125
+ class KeychainNoLoginTests(Fixture):
126
+ def test_an_mcp_only_item_is_nologin_never_present(self):
127
+ self.put(MCP_ONLY)
128
+ res, log = self.calls(self.keychain.probe, self.d)
129
+ self.assertEqual((res['state'], res['account'], res['doc']), ('nologin', 'gas', MCP_ONLY))
130
+ self.assertEqual(log, ['find-generic-password a=gas w=1']) # still ONE call
131
+ # with a null login beside it, too; a secret that is no JSON object stays corrupt
132
+ self.put({'claudeAiOauth': None, 'mcpOAuth': {KEY: ENTRY}})
133
+ self.assertEqual(self.keychain.probe(self.d)['state'], 'nologin')
134
+ self.put('[1, 2]')
135
+ self.assertEqual(self.keychain.probe(self.d)['state'], 'corrupt')
136
+ self.put(dict(LIVE, **MCP_ONLY))
137
+ self.assertEqual(self.keychain.probe(self.d)['state'], 'present')
138
+
139
+ def test_legacy_looks_past_an_mcp_only_canonical_item_for_a_stranded_login(self):
140
+ # A USER-less sign-in filed the login under "unknown"; a later client, USER set and
141
+ # logged in by a token, saved MCP state under the canonical name.
142
+ self.put(LIVE, 'unknown', '20261001000000')
143
+ self.put(MCP_ONLY, 'gas', '20261019000000')
144
+ self.assertEqual(self.keychain.probe(self.d)['state'], 'nologin')
145
+ res = self.keychain.probe(self.d, legacy=True)
146
+ self.assertEqual((res['state'], res['doc'], res['canonical']), ('present', LIVE, 'gas'))
147
+ self.assertNotEqual(res['account'], res['canonical'])
148
+ # the no -a first match is the OLDEST item: when that is the MCP-only canonical one,
149
+ # the canonical verdict stands
150
+ other = self.d + '-x'
151
+ self.put(MCP_ONLY, 'gas', '20261001000000', d=other)
152
+ self.put(LIVE, 'unknown', '20261019000000', d=other)
153
+ res = self.keychain.probe(other, legacy=True)
154
+ self.assertEqual((res['state'], res['account'], res['doc']), ('nologin', 'gas', MCP_ONLY))
155
+
156
+ def test_write_lands_into_the_item_keeping_its_mcp_state(self):
157
+ config = {KEY: {'clientId': 'cid'}}
158
+ self.put({'mcpOAuth': {KEY: ENTRY}, 'mcpOAuthClientConfig': config})
159
+ self.assertTrue(self.keychain.write(self.d, LIVE))
160
+ self.assertEqual(self.secret(), {'mcpOAuth': {KEY: ENTRY}, 'mcpOAuthClientConfig': config,
161
+ 'claudeAiOauth': LIVE['claudeAiOauth']})
162
+ rotated = {'claudeAiOauth': dict(LIVE['claudeAiOauth'], accessToken='sk-ant-oat01-rot')}
163
+ self.assertTrue(self.keychain.write(self.d, rotated))
164
+ doc = self.secret()
165
+ self.assertEqual(doc['claudeAiOauth']['accessToken'], 'sk-ant-oat01-rot')
166
+ self.assertEqual((doc['mcpOAuth'], doc['mcpOAuthClientConfig']), ({KEY: ENTRY}, config))
167
+ self.assertEqual(self.keychain.probe(self.d)['state'], 'present')
168
+
169
+ def test_write_never_lands_blind_on_an_item_it_cannot_read(self):
170
+ self.put(MCP_ONLY)
171
+ os.environ['FAKE_KEYCHAIN_LOCKED'] = '1'
172
+ self.assertFalse(self.keychain.write(self.d, LIVE))
173
+ os.environ.pop('FAKE_KEYCHAIN_LOCKED')
174
+ self.assertEqual(self.secret(), MCP_ONLY)
175
+
176
+ def test_write_never_sweeps_a_sibling_carrying_mcp_state(self):
177
+ self.put(DEAD, 'olduser', '20260901000000') # the oldest: a spent login alone
178
+ self.put(dict(DEAD, **MCP_ONLY), 'unknown', '20261001000000') # spent login + MCP
179
+ self.put(MCP_ONLY, 'claude-code-user', '20261002000000') # MCP state alone
180
+ self.assertTrue(self.keychain.write(self.d, LIVE))
181
+ self.assertEqual(self.items(), ['claude-code-user', 'gas', 'unknown'])
182
+ self.assertEqual(self.secret('claude-code-user'), MCP_ONLY)
183
+ self.assertEqual(self.secret('unknown'), dict(DEAD, **MCP_ONLY))
184
+
185
+ def test_prune_keeps_siblings_carrying_mcp_state(self):
186
+ self.put(DEAD, 'olduser', '20260901000000') # the oldest: the no -a match
187
+ self.put(LIVE, 'gas', '20261019000000')
188
+ self.put(MCP_ONLY, 'unknown', '20261001000000')
189
+ (ok, line), log = self.calls(self.keychain.prune, self.d)
190
+ self.assertTrue(ok, line)
191
+ self.assertEqual(line, 'pruned: kept gas and MCP state under unknown, removed olduser')
192
+ self.assertEqual(self.items(), ['gas', 'unknown'])
193
+ self.assertFalse([c for c in log if c.startswith('add-generic-password')], log)
194
+ self.assertEqual(self.keychain.prune(self.d),
195
+ (True, 'ok: the login under gas and MCP state under unknown'))
196
+
197
+ def test_prune_skips_an_mcp_only_canonical_item(self):
198
+ self.put(MCP_ONLY, 'gas', '20261019000000')
199
+ self.put(LIVE, 'unknown', '20261001000000')
200
+ self.assertEqual(self.keychain.prune(self.d),
201
+ (True, 'skip: the item under gas holds no login (MCP state only)'))
202
+ self.assertEqual(self.items(), ['gas', 'unknown'])
203
+
204
+ def test_cli_probe_names_nologin_and_keeps_tokens_off_stdout(self):
205
+ self.put(MCP_ONLY)
206
+ out = subprocess.run([sys.executable, str(REPO / 'lib' / 'keychain.py'), 'probe', self.d],
207
+ capture_output=True, text=True, check=True).stdout
208
+ self.assertNotIn('mcp-', out)
209
+ doc = json.loads(out)
210
+ self.assertEqual((doc['state'], doc['has_refresh_token'], doc['expires_at']),
211
+ ('nologin', False, None))
212
+
213
+
214
+ class AuditNoLoginTests(Fixture):
215
+ def setUp(self):
216
+ super().setUp()
217
+ import importlib
218
+ import audit
219
+ self.audit = importlib.reload(audit)
220
+
221
+ def test_an_mcp_only_file_is_no_login(self):
222
+ self.write_file(MCP_ONLY)
223
+ self.assertFalse(self.audit.file_has_login(str(self.cpath)))
224
+ login = self.audit.oauth_login(self.d, time.time())
225
+ self.assertEqual((login['store'], login['state'], login['doc']), (None, None, None))
226
+ self.assertIn('claudeAiOauth', login['reason'])
227
+ for doc in ({'claudeAiOauth': None, 'mcpOAuth': {}}, {'claudeAiOauth': 'x'}, {}, ''):
228
+ self.write_file(doc)
229
+ self.assertFalse(self.audit.file_has_login(str(self.cpath)), doc)
230
+
231
+ def test_the_keychain_decides_beside_an_mcp_only_file(self):
232
+ self.write_file(MCP_ONLY)
233
+ self.put(LIVE)
234
+ login = self.audit.oauth_login(self.d, time.time())
235
+ self.assertEqual((login['store'], login['state']), ('keychain', 'ok'))
236
+ os.environ['FAKE_KEYCHAIN_LOCKED'] = '1'
237
+ self.assertEqual(self.audit.oauth_login(self.d, time.time())['state'], 'locked')
238
+ os.environ.pop('FAKE_KEYCHAIN_LOCKED')
239
+ self.put(MCP_ONLY) # MCP state in both places: no login anywhere
240
+ login = self.audit.oauth_login(self.d, time.time())
241
+ self.assertEqual((login['store'], login['state']), (None, None))
242
+
243
+ def test_expiry_is_read_from_the_login_object_alone(self):
244
+ now = time.time()
245
+ self.write_file(DEAD_BESIDE_MCP)
246
+ self.assertTrue(self.audit.file_has_login(str(self.cpath)))
247
+ self.assertEqual(self.audit.creds_state(str(self.cpath), now)[0], 'expired')
248
+ # truncated mid-write, in either key order: still the login's own numbers
249
+ text = json.dumps(DEAD_BESIDE_MCP, separators=(',', ':'))
250
+ self.write_file(text[:-30])
251
+ self.assertTrue(self.audit.file_has_login(str(self.cpath)))
252
+ self.assertEqual(self.audit.creds_state(str(self.cpath), now)[0], 'expired')
253
+ mcp_first = json.dumps({'mcpOAuth': {KEY: ENTRY},
254
+ 'claudeAiOauth': DEAD_BESIDE_MCP['claudeAiOauth']},
255
+ separators=(',', ':'))
256
+ self.write_file(mcp_first[:-3])
257
+ self.assertEqual(self.audit.creds_state(str(self.cpath), now)[0], 'expired')
258
+ # a truncated live login stays in the pool (lenient, as the shim is)...
259
+ live = json.dumps(dict(LIVE, **MCP_ONLY), separators=(',', ':'))
260
+ self.write_file(live[:-20])
261
+ self.assertEqual(self.audit.creds_state(str(self.cpath), now)[0], 'ok')
262
+ # ...and a truncated document that never opens a login is no login at all
263
+ self.write_file(json.dumps(MCP_ONLY)[:-10])
264
+ self.assertFalse(self.audit.file_has_login(str(self.cpath)))
265
+
266
+ def test_audit_account_reads_mcp_state_as_no_login(self):
267
+ acct = {'id': 'acct-01', 'email': 'mcp@test', 'home': 'mac'}
268
+ self.write_file(MCP_ONLY)
269
+ row = self.audit.audit_account(str(self.root), acct, machine='mac')
270
+ self.assertEqual((row['state'], row['store']), ('missing', None))
271
+ self.assertIn('no Claude login', row['reason'])
272
+ (self.acct / 'server.token').write_text('sk-ant-oat01-' + 'T' * 60)
273
+ row = self.audit.audit_account(str(self.root), acct, machine='mac')
274
+ self.assertEqual((row['state'], row['store']), ('ok', None))
275
+ (self.acct / 'server.token').unlink()
276
+ self.write_file(DEAD_BESIDE_MCP)
277
+ row = self.audit.audit_account(str(self.root), acct, machine='mac')
278
+ self.assertEqual((row['state'], row['store']), ('expired', 'file'))
279
+
280
+ def test_a_park_heals_on_a_newer_login_never_on_newer_mcp_state(self):
281
+ marker = self.acct / '.expired'
282
+ marked = calendar.timegm((2026, 10, 1, 0, 0, 0, 0, 0, 0))
283
+ marker.write_text(f'{marked}\nreason=auth-error marked_at=t detail=test\n')
284
+ os.utime(marker, (marked, marked))
285
+ self.write_file(MCP_ONLY, mtime=marked + 3600)
286
+ self.assertTrue(self.audit.expired_marked(self.d))
287
+ self.write_file(dict(LIVE, **MCP_ONLY), mtime=marked + 7200)
288
+ self.assertFalse(self.audit.expired_marked(self.d))
289
+ # the Keychain: an item this session reads is judged by what it holds
290
+ self.cpath.unlink()
291
+ self.put(MCP_ONLY, mdat='20261002000000')
292
+ self.assertTrue(self.audit.expired_marked(self.d))
293
+ os.environ['FAKE_KEYCHAIN_LOCKED'] = '1' # cannot read it: its stamp decides
294
+ self.assertFalse(self.audit.expired_marked(self.d))
295
+ os.environ.pop('FAKE_KEYCHAIN_LOCKED')
296
+ self.put(dict(LIVE, **MCP_ONLY), mdat='20261003000000')
297
+ self.assertFalse(self.audit.expired_marked(self.d))
298
+ # an MCP-only file beside it hides nothing: the Keychain login still heals it
299
+ self.write_file(MCP_ONLY, mtime=marked + 60)
300
+ self.assertFalse(self.audit.expired_marked(self.d))
301
+
302
+
303
+ class ReportAndExportTests(Fixture):
304
+ def test_report_never_calls_mcp_state_an_oauth_login(self):
305
+ import importlib
306
+ import report
307
+ report = importlib.reload(report)
308
+ self.write_file(MCP_ONLY)
309
+ cclass, detail = report._claude_credentials(self.d)
310
+ self.assertEqual((cclass, detail['oauth'], detail['oauth_store'], detail['keychain']),
311
+ ('none', False, None, None))
312
+ (self.acct / 'server.token').write_text('sk-ant-oat01-' + 'T' * 60)
313
+ cclass, detail = report._claude_credentials(self.d)
314
+ self.assertEqual((cclass, detail['oauth'], detail['oauth_store']), ('portable', False, None))
315
+ (self.acct / 'server.token').unlink()
316
+ self.cpath.unlink()
317
+ self.put(MCP_ONLY)
318
+ cclass, detail = report._claude_credentials(self.d)
319
+ self.assertEqual((cclass, detail['oauth'], detail['keychain']), ('none', False, None))
320
+ self.write_file(dict(LIVE, **MCP_ONLY))
321
+ cclass, detail = report._claude_credentials(self.d)
322
+ self.assertEqual((cclass, detail['oauth'], detail['oauth_store'], detail['oauth_alive']),
323
+ ('machine-local', True, 'file', True))
324
+
325
+ def test_export_finds_no_credential_material_in_mcp_state(self):
326
+ self.manifest()
327
+ self.write_file(MCP_ONLY)
328
+ cli = [sys.executable, str(REPO / 'lib' / 'credential.py'), 'export', str(self.root),
329
+ 'claude', 'acct-01']
330
+ res = subprocess.run(cli, capture_output=True, text=True)
331
+ self.assertEqual(res.returncode, 4, res.stderr)
332
+ self.assertIn('no credential material', res.stderr)
333
+ self.write_file(dict(LIVE, **MCP_ONLY))
334
+ res = subprocess.run(cli, capture_output=True, text=True)
335
+ self.assertEqual(res.returncode, 3, res.stderr)
336
+ self.assertIn('.credentials.json', res.stderr)
337
+
338
+
339
+ if __name__ == '__main__':
340
+ unittest.main()