claude-multiacc 2.0.55 → 2.0.57

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 (102) hide show
  1. package/README.md +7 -2
  2. package/bin/claude +71 -8
  3. package/bin/codex +67 -14
  4. package/docs/AUTORESUME-HANDOFF.md +82 -0
  5. package/docs/AUTORESUME.md +24 -12
  6. package/lib/__pycache__/audit.cpython-312.pyc +0 -0
  7. package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
  8. package/lib/__pycache__/autoresume_cross.cpython-312.pyc +0 -0
  9. package/lib/__pycache__/autoresume_cross_dispatch.cpython-312.pyc +0 -0
  10. package/lib/__pycache__/autoresume_cross_receipt.cpython-312.pyc +0 -0
  11. package/lib/__pycache__/autoresume_handoff.cpython-312.pyc +0 -0
  12. package/lib/__pycache__/autoresume_probe.cpython-312.pyc +0 -0
  13. package/lib/__pycache__/autoresume_receipt.cpython-312.pyc +0 -0
  14. package/lib/__pycache__/claude_client_error.cpython-312.pyc +0 -0
  15. package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
  16. package/lib/__pycache__/claude_token_identity.cpython-312.pyc +0 -0
  17. package/lib/__pycache__/codex_autoresume_mode.cpython-312.pyc +0 -0
  18. package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
  19. package/lib/__pycache__/codex_credit_controls.cpython-312.pyc +0 -0
  20. package/lib/__pycache__/codex_credit_local_scope.cpython-312.pyc +0 -0
  21. package/lib/__pycache__/codex_credit_selection.cpython-312.pyc +0 -0
  22. package/lib/__pycache__/codex_credits.cpython-312.pyc +0 -0
  23. package/lib/__pycache__/codex_limits_lock.cpython-312.pyc +0 -0
  24. package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
  25. package/lib/__pycache__/codex_usage_fetch.cpython-312.pyc +0 -0
  26. package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
  27. package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
  28. package/lib/__pycache__/report.cpython-312.pyc +0 -0
  29. package/lib/__pycache__/selector_credit_scope.cpython-312.pyc +0 -0
  30. package/lib/__pycache__/selector_credits.cpython-312.pyc +0 -0
  31. package/lib/__pycache__/selector_funding.cpython-312.pyc +0 -0
  32. package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
  33. package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
  34. package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
  35. package/lib/autoresume.py +60 -16
  36. package/lib/autoresume_cross.py +143 -0
  37. package/lib/autoresume_cross_dispatch.py +114 -0
  38. package/lib/autoresume_cross_receipt.py +138 -0
  39. package/lib/autoresume_handoff.py +191 -0
  40. package/lib/autoresume_probe.py +36 -0
  41. package/package.json +1 -1
  42. package/tests/__pycache__/autoresume_cross_receipt_support.cpython-312.pyc +0 -0
  43. package/tests/__pycache__/autoresume_cross_tmux_environment.cpython-312.pyc +0 -0
  44. package/tests/__pycache__/autoresume_cross_tmux_support.cpython-312.pyc +0 -0
  45. package/tests/__pycache__/autoresume_cross_unit_support.cpython-312.pyc +0 -0
  46. package/tests/__pycache__/autoresume_tmux_support.cpython-312.pyc +0 -0
  47. package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
  48. package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
  49. package/tests/__pycache__/test_autoresume_codex_records.cpython-312.pyc +0 -0
  50. package/tests/__pycache__/test_autoresume_cross_claude_holders.cpython-312.pyc +0 -0
  51. package/tests/__pycache__/test_autoresume_cross_guards.cpython-312.pyc +0 -0
  52. package/tests/__pycache__/test_autoresume_cross_holders.cpython-312.pyc +0 -0
  53. package/tests/__pycache__/test_autoresume_cross_policy.cpython-312.pyc +0 -0
  54. package/tests/__pycache__/test_autoresume_cross_receipt.cpython-312.pyc +0 -0
  55. package/tests/__pycache__/test_autoresume_cross_tmux.cpython-312.pyc +0 -0
  56. package/tests/__pycache__/test_autoresume_cross_tmux_races.cpython-312.pyc +0 -0
  57. package/tests/__pycache__/test_autoresume_cross_tokens.cpython-312.pyc +0 -0
  58. package/tests/__pycache__/test_autoresume_cursor.cpython-312.pyc +0 -0
  59. package/tests/__pycache__/test_autoresume_fallback.cpython-312.pyc +0 -0
  60. package/tests/__pycache__/test_autoresume_handoff_argv.cpython-312.pyc +0 -0
  61. package/tests/__pycache__/test_autoresume_handoff_history.cpython-312.pyc +0 -0
  62. package/tests/__pycache__/test_autoresume_mode.cpython-312.pyc +0 -0
  63. package/tests/__pycache__/test_autoresume_probe.cpython-312.pyc +0 -0
  64. package/tests/__pycache__/test_autoresume_receipt.cpython-312.pyc +0 -0
  65. package/tests/__pycache__/test_autoresume_tmux.cpython-312.pyc +0 -0
  66. package/tests/__pycache__/test_autoresume_tmux_modes.cpython-312.pyc +0 -0
  67. package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
  68. package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
  69. package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
  70. package/tests/__pycache__/test_codex_reset_credits.cpython-312.pyc +0 -0
  71. package/tests/__pycache__/test_codex_reset_credits_polling.cpython-312.pyc +0 -0
  72. package/tests/__pycache__/test_codex_reset_credits_report.cpython-312.pyc +0 -0
  73. package/tests/__pycache__/test_codex_reset_fetch.cpython-312.pyc +0 -0
  74. package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
  75. package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
  76. package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
  77. package/tests/__pycache__/test_selector.cpython-312.pyc +0 -0
  78. package/tests/__pycache__/test_selector_cached_credits.cpython-312.pyc +0 -0
  79. package/tests/__pycache__/test_selector_credit_scope.cpython-312.pyc +0 -0
  80. package/tests/__pycache__/test_selector_credit_shim.cpython-312.pyc +0 -0
  81. package/tests/__pycache__/test_selector_credits.cpython-312.pyc +0 -0
  82. package/tests/__pycache__/test_selector_spread.cpython-312.pyc +0 -0
  83. package/tests/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
  84. package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
  85. package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
  86. package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
  87. package/tests/autoresume_cross_receipt_support.py +58 -0
  88. package/tests/autoresume_cross_tmux_environment.py +59 -0
  89. package/tests/autoresume_cross_tmux_support.py +112 -0
  90. package/tests/autoresume_cross_unit_support.py +92 -0
  91. package/tests/fixtures/cross_provider_cli.py +121 -0
  92. package/tests/test_autoresume.py +4 -2
  93. package/tests/test_autoresume_cross_claude_holders.py +81 -0
  94. package/tests/test_autoresume_cross_guards.py +133 -0
  95. package/tests/test_autoresume_cross_holders.py +89 -0
  96. package/tests/test_autoresume_cross_policy.py +123 -0
  97. package/tests/test_autoresume_cross_receipt.py +141 -0
  98. package/tests/test_autoresume_cross_tmux.py +140 -0
  99. package/tests/test_autoresume_cross_tmux_races.py +45 -0
  100. package/tests/test_autoresume_cross_tokens.py +102 -0
  101. package/tests/test_autoresume_handoff_argv.py +127 -0
  102. package/tests/test_autoresume_handoff_history.py +115 -0
@@ -0,0 +1,92 @@
1
+ """Disposable dual-provider pools and simulated processes for cross-provider policy tests."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ import test_autoresume as fixtures
7
+
8
+
9
+ class DualRunner(fixtures.FakeRunner):
10
+ def __init__(self, case, target):
11
+ super().__init__(case.t0, case.PID, case.PPID, case.self_path)
12
+ self.ar_dir = case.ar_dir
13
+ self.target = target
14
+ self.probe_out = "pick=acct-02 tier=hard\n"
15
+ self.target_out = "pick=acct-01 tier=eligible\n"
16
+ self.source_hook = None
17
+ self.target_hook = None
18
+
19
+ def run(self, argv, timeout, env=None, cwd=None):
20
+ if argv[0] == self.self_path and self.source_hook:
21
+ callback, self.source_hook = self.source_hook, None
22
+ callback()
23
+ if argv[0] == self.target:
24
+ self.calls.append({"argv": list(argv), "env": env, "cwd": cwd, "t": self.t})
25
+ if self.target_hook:
26
+ callback, self.target_hook = self.target_hook, None
27
+ callback()
28
+ return (3 if "tier=none" in self.target_out else 0), self.target_out
29
+ return super().run(argv, timeout, env=env, cwd=cwd)
30
+
31
+ @property
32
+ def target_probes(self):
33
+ return [call for call in self.calls if call["argv"][0] == self.target]
34
+
35
+
36
+ def source_history(case, provider, error_class):
37
+ reset = int(case.t0) + 7200
38
+ if provider == "claude":
39
+ case.registry()
40
+ record = fixtures.cc_quota(case.t0, resets=reset, cwd=str(case.work))
41
+ if error_class != "quota":
42
+ codes = {"auth": "authentication_failed", "blocked": "account_on_hold",
43
+ "transient": "server_error", "model": "model_requires_usage_credits"}
44
+ record = fixtures.cc_api_error(case.t0, codes[error_class], cwd=str(case.work))
45
+ if error_class == "model":
46
+ record.update(error="rate_limit", apiError="model_requires_usage_credits")
47
+ return case.transcript([record])
48
+ code = {"quota": "usage_limit_exceeded", "auth": "unauthorized", "transient": "internal_server_error"}
49
+ return case.rollout([fixtures.cx_meta(case.launched, cwd=str(case.work)),
50
+ fixtures.cx_tokens(case.t0, primary={"used_percent": 100,
51
+ "window_minutes": 300, "resets_at": reset}),
52
+ fixtures.cx_error(case.t0, code[error_class])])
53
+
54
+
55
+ def scenario(test, provider, argv=None, error_class="quota", environment=None):
56
+ case = fixtures.PoolCase() if provider == "claude" else fixtures.CodexWatcherTests()
57
+ case.setUp()
58
+ test.addCleanup(case.tearDown)
59
+ target = "codex" if provider == "claude" else "claude"
60
+ sibling = case.tmp / "bin" / target
61
+ sibling.parent.mkdir(exist_ok=True)
62
+ sibling.write_text("#!/bin/sh\nexit 3\n")
63
+ sibling.chmod(0o700)
64
+ Path(case.self_path).write_text("#!/bin/sh\nexit 3\n")
65
+ Path(case.self_path).chmod(0o700)
66
+ target_root = case.tmp / (target + "-pool")
67
+ target_root.mkdir()
68
+ (target_root / "acct-01").mkdir()
69
+ manifest = json.dumps({"accounts": ["acct-01", "acct-02"], "threshold": 90})
70
+ (target_root / "accounts.json").write_text(manifest)
71
+ (case.root / "accounts.json").write_text(manifest)
72
+ default = ["--dangerously-skip-permissions"] if provider == "claude" else ["--yolo", "resume", fixtures.CX_SID]
73
+ state = case.write_state(default if argv is None else argv, provider=provider)
74
+ history = source_history(case, provider, error_class)
75
+ runner = DualRunner(case, str(sibling))
76
+ env = {"HOME": str(case.tmp), "PATH": str(sibling.parent) + ":/usr/bin:/bin",
77
+ provider.upper() + "_ACCOUNTS_ROOT": str(case.root),
78
+ target.upper() + "_ACCOUNTS_ROOT": str(target_root)}
79
+ env.update(environment or {})
80
+ watcher = fixtures.ar.Watcher(state, fixtures.ar.load_state(state),
81
+ fixtures.ar.load_argv(fixtures.ar.argv_path_for(state)), runner=runner, environ=env)
82
+ watcher.started = runner.now()
83
+ watcher.lstart = fixtures.LSTART
84
+ watcher.read()
85
+ runner.at(case.t0 + 15, lambda: runner.exit(case.PID))
86
+ return case, watcher, runner, history, target_root
87
+
88
+
89
+ def cancel_record(case, provider):
90
+ return fixtures.cc_user(case.t0 + 5, "I will continue manually", cwd=str(case.work)) \
91
+ if provider == "claude" else fixtures.cx_event(case.t0 + 5,
92
+ {"type": "user_message", "message": "I will continue manually"})
@@ -0,0 +1,121 @@
1
+ """Disposable native-CLI stand-in for the real tmux cross-provider acceptance tests."""
2
+
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+ import select
7
+ import signal
8
+ import sys
9
+ import termios
10
+ import time
11
+ import tty
12
+ import uuid
13
+
14
+ PROVIDER = Path(sys.argv[0]).name
15
+ HOME = Path(os.environ["FAKE_HOME"])
16
+ SIDS = {"codex": "01a0cb0f-a719-7461-bba6-a59bdf2412cc", "claude": "f8470bf4-f810-4492-b564-1712a7c4930e"}
17
+
18
+
19
+ def record(kind, **fields):
20
+ with (HOME / "provider.jsonl").open("a") as target:
21
+ target.write(json.dumps(dict(kind=kind, provider=PROVIDER, pid=os.getpid(), **fields)) + "\n")
22
+
23
+
24
+ def stop(signum, _frame):
25
+ record("signal", signal=signum)
26
+ target = os.environ.get("FAKE_PARK_TARGET_ON_TERM")
27
+ if signum == signal.SIGTERM and target in SIDS and target != PROVIDER:
28
+ for account in (HOME / (target + "-pool")).glob("acct-*"):
29
+ (account / ".limited").write_text(
30
+ f"{int(time.time()) + 3600}\nbucket=error-cooldown reason=error-cooldown\n")
31
+ raise SystemExit(0)
32
+
33
+
34
+ def timestamp():
35
+ return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
36
+
37
+
38
+ def session_identity():
39
+ arguments = sys.argv[1:]
40
+ for flag in ("--session-id", "--resume", "-r"):
41
+ if flag in arguments:
42
+ return arguments[arguments.index(flag) + 1]
43
+ if "resume" in arguments:
44
+ return next((arg for arg in arguments if len(arg) == 36 and arg.count("-") == 4), SIDS[PROVIDER])
45
+ records = HOME / "provider.jsonl"
46
+ if records.exists() and any(json.loads(line).get("provider") == PROVIDER
47
+ for line in records.read_text().splitlines()):
48
+ return str(uuid.uuid4())
49
+ return SIDS[PROVIDER]
50
+
51
+
52
+ def user_record(sid, prompt):
53
+ if PROVIDER == "codex":
54
+ return {"timestamp": timestamp(), "type": "response_item", "payload": {
55
+ "type": "message", "role": "user", "content": [{"type": "input_text", "text": prompt}]}}
56
+ return {"timestamp": timestamp(), "type": "user", "sessionId": sid, "cwd": os.getcwd(),
57
+ "isSidechain": False, "uuid": "585fd5be-e0df-4cb3-ae28-672ba54550a3",
58
+ "message": {"role": "user", "content": prompt}}
59
+
60
+
61
+ def initialize_history(account, sid):
62
+ if PROVIDER == "codex":
63
+ directory = account / "sessions/2026/10/11"
64
+ path = directory / ("rollout-test-" + sid + ".jsonl")
65
+ else:
66
+ directory = account / "projects" / os.getcwd().replace("/", "-")
67
+ path = directory / (sid + ".jsonl")
68
+ registry = account / "sessions" / (str(os.getpid()) + ".json")
69
+ registry.parent.mkdir(exist_ok=True)
70
+ registry.write_text(json.dumps({"pid": os.getpid(), "sessionId": sid, "cwd": os.getcwd(),
71
+ "startedAt": int(time.time() * 1000), "kind": "interactive", "entrypoint": "cli"}))
72
+ directory.mkdir(parents=True, exist_ok=True)
73
+ history = path.open("a+")
74
+ if history.tell() == 0:
75
+ if PROVIDER == "codex":
76
+ history.write(json.dumps({"timestamp": timestamp(), "type": "session_meta", "payload": {
77
+ "id": sid, "session_id": sid, "timestamp": timestamp(),
78
+ "cwd": os.getcwd(), "source": "cli"}}) + "\n")
79
+ history.write(json.dumps(user_record(sid, "Complete the synthetic acceptance task.")) + "\n")
80
+ prompt = sys.argv[-1] if len(sys.argv) > 1 and " " in sys.argv[-1] else ""
81
+ if prompt and os.environ.get("FAKE_DROP_PROMPT") != PROVIDER:
82
+ user = user_record(sid, prompt)
83
+ if os.environ.get("FAKE_OLD_PROMPT") == PROVIDER:
84
+ user["timestamp"] = "2000-01-01T00:00:00Z"
85
+ history.write(json.dumps(user) + "\n")
86
+ record("prompt", text=prompt, sid=sid, path=str(path.resolve()))
87
+ history.flush()
88
+ return history
89
+
90
+
91
+ def main():
92
+ if sys.argv[1:] == ["--help"]:
93
+ print("Usage: " + PROVIDER + " [OPTIONS]\n --no-daemon Run locally\n --session-id UUID")
94
+ return
95
+ if sys.argv[1:] == ["--version"]:
96
+ print(PROVIDER + " test")
97
+ return
98
+ account = Path(os.environ["CODEX_HOME" if PROVIDER == "codex" else "CLAUDE_CONFIG_DIR"])
99
+ sid = session_identity()
100
+ record("launch", argv=sys.argv[1:], cwd=os.getcwd(), account=account.name, sid=sid)
101
+ settings = termios.tcgetattr(0)
102
+ signal.signal(signal.SIGTERM, stop)
103
+ signal.signal(signal.SIGHUP, stop)
104
+ history = initialize_history(account, sid)
105
+ try:
106
+ tty.setraw(0)
107
+ record("ready", sid=sid, path=str(Path(history.name).resolve()))
108
+ print("FAKE " + PROVIDER.upper() + " READY", flush=True)
109
+ while True:
110
+ if select.select([0], [], [], 0.1)[0]:
111
+ data = os.read(0, 4096)
112
+ if not data:
113
+ return
114
+ record("keys", data=data.hex())
115
+ finally:
116
+ history.close()
117
+ termios.tcsetattr(0, termios.TCSANOW, settings)
118
+
119
+
120
+ if __name__ == "__main__":
121
+ main()
@@ -557,7 +557,7 @@ class ArgvTests(unittest.TestCase):
557
557
  "automatically on another account because the previous "
558
558
  "account hit its usage limit. Continue the task"))
559
559
  self.assertIn("the user has not sent a new message", q)
560
- self.assertIn("do not repeat work that is already done.", q)
560
+ self.assertIn("do not duplicate active or completed work.", q)
561
561
  self.assertIn("this model's usage limit", ar.resume_prompt("model"))
562
562
  self.assertIn("login failed", ar.resume_prompt("auth"))
563
563
  self.assertIn("was refused", ar.resume_prompt("blocked"))
@@ -1927,9 +1927,11 @@ class ClaudeWatcherTests(PoolCase):
1927
1927
  self.registry()
1928
1928
  self.transcript([cc_quota(self.t0, resets=int(self.t0) + 999)])
1929
1929
  other = self.root / "acct-02" / "sessions" / "6060.json"
1930
- other.write_text(json.dumps({"pid": 6060, "sessionId": SID, "cwd": str(self.work)}))
1930
+ other.write_text(json.dumps({"pid": 6060, "sessionId": SID, "cwd": str(self.work),
1931
+ "startedAt": self.launched * 1000 + 700}))
1931
1932
  w, r = self.watcher(state)
1932
1933
  r.live.add(6060)
1934
+ r.starts[6060] = time.strftime("%a %b %d %H:%M:%S %Y", time.localtime(self.launched))
1933
1935
  self.assertEqual(w.run(), 0)
1934
1936
  self.assertEqual((r.signals, r.sends, self.relaunch_files()), ([], [], []))
1935
1937
  self.assertIn("reason=holder", self.log_lines()[-1])
@@ -0,0 +1,81 @@
1
+ """Claude registry files must identify a current live holder before preventing a handoff."""
2
+
3
+ import json
4
+ import time
5
+ import unittest
6
+
7
+ from autoresume_cross_unit_support import scenario
8
+ import autoresume_cross as cross
9
+
10
+
11
+ class ClaudeHolderIdentityTests(unittest.TestCase):
12
+ def holder(self, case, watcher, runner):
13
+ pid = 6060
14
+ path = case.root / "acct-02" / "sessions" / (str(pid) + ".json")
15
+ record = {"pid": pid, "sessionId": watcher.sid, "cwd": str(case.work),
16
+ "startedAt": case.launched * 1000 + 700}
17
+ path.write_text(json.dumps(record))
18
+ runner.live.add(pid)
19
+ runner.starts[pid] = time.strftime("%a %b %d %H:%M:%S %Y", time.localtime(case.launched))
20
+ return pid, path, record
21
+
22
+ def test_genuine_live_holder_blocks_both_same_and_cross_provider_relaunch(self):
23
+ case, watcher, runner, _, _ = scenario(self, "claude")
24
+ pid, _, _ = self.holder(case, watcher, runner)
25
+ self.assertEqual(watcher.other_holder(), pid)
26
+ self.assertEqual(cross.shared_source(watcher), pid)
27
+ self.assertIsNone(cross.plan_fallback(watcher, watcher.tracker.pending, ""))
28
+ self.assertEqual((runner.target_probes, runner.signals, runner.sends), ([], [], []))
29
+
30
+ def test_dead_zombie_or_reused_pid_does_not_hold_the_source(self):
31
+ for stale in ("dead", "zombie", "reused", "invalid-process-start"):
32
+ with self.subTest(stale=stale):
33
+ case, watcher, runner, _, _ = scenario(self, "claude")
34
+ pid, _, _ = self.holder(case, watcher, runner)
35
+ if stale == "dead":
36
+ runner.exit(pid)
37
+ elif stale == "zombie":
38
+ runner.gone = lambda candidate: candidate == pid or candidate not in runner.live
39
+ else:
40
+ runner.starts[pid] = "invalid start" if stale == "invalid-process-start" else time.strftime(
41
+ "%a %b %d %H:%M:%S %Y", time.localtime(case.launched + 120))
42
+ self.assertIsNone(watcher.other_holder())
43
+ self.assertIsNotNone(cross.plan_fallback(watcher, watcher.tracker.pending, ""))
44
+
45
+ def test_malformed_registry_identity_cannot_claim_a_matching_live_pid(self):
46
+ invalid = (("pid", "6060"), ("pid", 7070), ("pid", None), ("startedAt", None),
47
+ ("startedAt", True), ("startedAt", "123"), ("startedAt", float("nan")),
48
+ ("startedAt", float("inf")), ("startedAt", 10 ** 1000))
49
+ for key, value in invalid:
50
+ with self.subTest(key=key, value=str(value)[:20]):
51
+ case, watcher, runner, _, _ = scenario(self, "claude")
52
+ _, path, record = self.holder(case, watcher, runner)
53
+ record[key] = value
54
+ path.write_text(json.dumps(record))
55
+ self.assertIsNone(watcher.other_holder())
56
+ self.assertIsNotNone(cross.plan_fallback(watcher, watcher.tracker.pending, ""))
57
+
58
+ def test_registry_of_another_session_or_invalid_document_does_not_hold(self):
59
+ for malformed in ("another-session", "list", "invalid-json", "future-start"):
60
+ with self.subTest(malformed=malformed):
61
+ case, watcher, runner, _, _ = scenario(self, "claude")
62
+ _, path, record = self.holder(case, watcher, runner)
63
+ if malformed == "another-session":
64
+ record["sessionId"] = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
65
+ elif malformed == "future-start":
66
+ record["startedAt"] = (runner.now() + 120) * 1000
67
+ path.write_text("[1]" if malformed == "list" else "{" if malformed == "invalid-json"
68
+ else json.dumps(record))
69
+ self.assertIsNone(watcher.other_holder())
70
+ self.assertIsNotNone(cross.plan_fallback(watcher, watcher.tracker.pending, ""))
71
+
72
+ def test_new_holder_after_target_probe_invalidates_the_plan(self):
73
+ case, watcher, runner, _, _ = scenario(self, "claude")
74
+ plan = cross.plan_fallback(watcher, watcher.tracker.pending, "")
75
+ self.assertIsNotNone(plan)
76
+ self.holder(case, watcher, runner)
77
+ self.assertFalse(cross.revalidate(watcher, plan))
78
+
79
+
80
+ if __name__ == "__main__":
81
+ unittest.main()
@@ -0,0 +1,133 @@
1
+ """Fallback opt-outs, pool boundaries and probe-time changes remain fail-closed."""
2
+
3
+ from pathlib import Path
4
+ import unittest
5
+ from unittest import mock
6
+
7
+ from autoresume_cross_receipt_support import TOKEN
8
+ from autoresume_cross_unit_support import scenario
9
+ import autoresume as ar
10
+ import autoresume_cross as cross
11
+ from autoresume_probe import cross_env, handoff_settings
12
+
13
+
14
+ class CrossProviderGuardsTests(unittest.TestCase):
15
+ def test_preparation_write_failure_removes_only_its_own_artifacts(self):
16
+ for provider in ("claude", "codex"):
17
+ for failed_write in range(1, 6):
18
+ with self.subTest(provider=provider, failed_write=failed_write):
19
+ case, watcher, runner, _, root = scenario(self, provider)
20
+ watcher.cross_plan = cross.plan_fallback(watcher, watcher.tracker.pending, "")
21
+ unrelated = case.ar_dir / "r-unrelated.relaunch"
22
+ unrelated.write_text("preserve another chain\n")
23
+ original, writes = ar._atomic_write, []
24
+ def fail_write(path, data):
25
+ writes.append(path)
26
+ if len(writes) == failed_write:
27
+ raise OSError("simulated preparation write failure")
28
+ return original(path, data)
29
+ with mock.patch.object(ar, "_atomic_write", side_effect=fail_write):
30
+ with self.assertRaises(OSError):
31
+ cross.prepare(watcher, watcher.tracker.pending, "", TOKEN,
32
+ watcher.build_relaunch_argv("quota"))
33
+ self.assertEqual(list(case.ar_dir.glob("r-" + TOKEN + ".*")), [])
34
+ self.assertEqual(list((root / "tmp/autoresume").glob("r-" + TOKEN + ".*")), [])
35
+ self.assertEqual(unrelated.read_text(), "preserve another chain\n")
36
+ self.assertEqual((runner.signals, runner.sends), ([], []))
37
+
38
+ def test_source_cross_provider_opt_out_does_not_probe_the_target(self):
39
+ for provider in ("claude", "codex"):
40
+ for disabled in ("0", "false", "no", "off"):
41
+ with self.subTest(provider=provider, disabled=disabled):
42
+ _, watcher, runner, _, _ = scenario(self, provider,
43
+ environment={provider.upper() + "_MULTIACC_CROSS_PROVIDER": disabled})
44
+ self.assertIsNone(cross.plan_fallback(watcher, watcher.tracker.pending, ""))
45
+ self.assertEqual((runner.target_probes, runner.signals), ([], []))
46
+
47
+ def test_either_provider_disable_or_pool_kill_switch_prevents_a_handoff(self):
48
+ for provider in ("claude", "codex"):
49
+ for stopped in ("claude", "codex"):
50
+ for mode in ("environment", "disable", "file"):
51
+ with self.subTest(provider=provider, stopped=stopped, mode=mode):
52
+ _, watcher, runner, _, _ = scenario(self, provider)
53
+ if mode == "file":
54
+ (Path(cross.pool_roots(watcher)[stopped]) / "autoresume.off").touch()
55
+ else:
56
+ suffix = "AUTORESUME" if mode == "environment" else "DISABLE"
57
+ watcher.env[stopped.upper() + "_MULTIACC_" + suffix] = "0" if mode == "environment" else "1"
58
+ self.assertIsNone(cross.plan_fallback(watcher, watcher.tracker.pending, ""))
59
+ self.assertEqual((runner.target_probes, runner.signals), ([], []))
60
+
61
+ def test_missing_target_pool_or_executable_holds_without_stopping(self):
62
+ for provider in ("claude", "codex"):
63
+ for missing in ("pool", "shim", "executable", "same-root", "alias-root"):
64
+ with self.subTest(provider=provider, missing=missing):
65
+ _, watcher, runner, _, root = scenario(self, provider)
66
+ if missing == "pool":
67
+ (root / "accounts.json").unlink()
68
+ elif missing == "shim":
69
+ Path(runner.target).unlink()
70
+ elif missing == "executable":
71
+ Path(runner.target).chmod(0o600)
72
+ else:
73
+ target = "codex" if provider == "claude" else "claude"
74
+ shared = Path(watcher.st["acc_root"])
75
+ if missing == "alias-root":
76
+ alias = root.parent / "alias-root"
77
+ alias.symlink_to(shared, target_is_directory=True)
78
+ shared = alias
79
+ watcher.env[target.upper() + "_ACCOUNTS_ROOT"] = str(shared)
80
+ self.assertIsNone(cross.plan_fallback(watcher, watcher.tracker.pending, ""))
81
+ self.assertEqual((runner.target_probes, runner.signals), ([], []))
82
+
83
+ def test_restrictive_permissions_hold_before_probing(self):
84
+ for mode in ("plan", "acceptEdits", "dontAsk", "auto"):
85
+ with self.subTest(mode=mode):
86
+ _, watcher, runner, _, _ = scenario(self, "claude", argv=["--permission-mode", mode])
87
+ self.assertIsNone(cross.plan_fallback(watcher, watcher.tracker.pending, ""))
88
+ self.assertEqual((runner.target_probes, runner.signals, runner.sends), ([], [], []))
89
+
90
+ def test_target_kill_switch_appearing_during_probe_prevents_source_signal(self):
91
+ for provider in ("claude", "codex"):
92
+ with self.subTest(provider=provider):
93
+ case, watcher, runner, _, root = scenario(self, provider)
94
+ runner.target_hook = lambda: (root / "autoresume.off").touch()
95
+ self.assertEqual(watcher.run(), 0)
96
+ self.assertEqual((runner.signals, runner.sends, case.relaunch_files()), ([], [], []))
97
+
98
+ def test_snapshot_truncation_with_same_inode_invalidates_a_prepared_plan(self):
99
+ for provider in ("claude", "codex"):
100
+ with self.subTest(provider=provider):
101
+ _, watcher, _, history, _ = scenario(self, provider)
102
+ with history.open("a") as target:
103
+ target.write('{"type":"progress","data":"later source state"}\n')
104
+ plan = cross.plan_fallback(watcher, watcher.tracker.pending, "")
105
+ self.assertIsNotNone(plan)
106
+ inode = history.stat().st_ino
107
+ lines = history.read_bytes().splitlines(keepends=True)
108
+ history.write_bytes(b"".join(lines[:-1]))
109
+ self.assertEqual(history.stat().st_ino, inode)
110
+ self.assertFalse(cross.revalidate(watcher, plan))
111
+
112
+ def test_target_environment_cannot_inherit_source_auth_pins_or_relaunch_tokens(self):
113
+ roots = {"claude": "/tmp/fixture-claude", "codex": "/tmp/fixture-codex"}
114
+ private = {key: "TEST_ONLY_SENTINEL" for key in (
115
+ "CLAUDE_CONFIG_DIR", "CODEX_HOME", "CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY", "OPENAI_API_KEY",
116
+ "ANTHROPIC_AUTH_TOKEN",
117
+ "CLAUDE_ACCOUNT", "CODEX_ACCOUNT", "CLAUDE_SHIM_ACTIVE", "CODEX_SHIM_ACTIVE",
118
+ "CLAUDE_MULTIACC_AR", "CODEX_MULTIACC_AR", "CLAUDE_MULTIACC_AR_AVOID", "CODEX_MULTIACC_AR_AVOID")}
119
+ env = dict(private, HOME="/tmp/fixture-home", CLAUDE_MULTIACC_HEADROOM_BAND="17",
120
+ CODEX_MULTIACC_CREDITS="1", CODEX_MULTIACC_AR_POLL="0.1")
121
+ cleaned = cross_env(env, roots)
122
+ self.assertFalse(set(private) & set(cleaned))
123
+ self.assertEqual(cleaned["CLAUDE_ACCOUNTS_ROOT"], roots["claude"])
124
+ self.assertEqual(cleaned["CODEX_ACCOUNTS_ROOT"], roots["codex"])
125
+ self.assertEqual(cleaned["CODEX_MULTIACC_CREDITS"], "1")
126
+ settings = handoff_settings(env, roots)
127
+ self.assertFalse(set(private) & set(settings))
128
+ self.assertNotIn("HOME", settings)
129
+ self.assertEqual(settings["CODEX_MULTIACC_AR_POLL"], "0.1")
130
+
131
+
132
+ if __name__ == "__main__":
133
+ unittest.main()
@@ -0,0 +1,89 @@
1
+ """An exact live source holder blocks a fork; stale or unrelated claims do not."""
2
+
3
+ from pathlib import Path
4
+ import time
5
+ import unittest
6
+
7
+ import test_autoresume as fixtures
8
+ from autoresume_cross_unit_support import scenario
9
+ import autoresume_cross as cross
10
+
11
+
12
+ class CrossProviderHolderTests(unittest.TestCase):
13
+ def holder(self, case, watcher, runner, history, pid=5454):
14
+ state = Path(case.write_state(["resume", fixtures.CX_SID], provider="codex", pid=pid))
15
+ claim = state.with_suffix(".rollout")
16
+ claim.write_text(str(history))
17
+ started = time.strftime("%a %b %d %H:%M:%S %Y", time.localtime(case.launched))
18
+ runner.live.add(pid)
19
+ runner.starts[pid] = started
20
+ runner.table.append((pid, case.PPID, started))
21
+ return state, claim, pid
22
+
23
+ def test_live_matching_codex_holder_prevents_a_new_provider_conversation(self):
24
+ case, watcher, runner, history, _ = scenario(self, "codex")
25
+ _, _, pid = self.holder(case, watcher, runner, history)
26
+ self.assertEqual(cross.shared_source(watcher), pid)
27
+ self.assertIsNone(cross.plan_fallback(watcher, watcher.tracker.pending, ""))
28
+ self.assertEqual((runner.target_probes, runner.signals), ([], []))
29
+
30
+ def test_dead_zombie_or_reused_pid_cannot_keep_a_stale_claim_alive(self):
31
+ for stale in ("dead", "zombie", "reused", "invalid-start"):
32
+ with self.subTest(stale=stale):
33
+ case, watcher, runner, history, _ = scenario(self, "codex")
34
+ _, _, pid = self.holder(case, watcher, runner, history)
35
+ if stale == "dead":
36
+ runner.exit(pid)
37
+ elif stale == "zombie":
38
+ runner.gone = lambda candidate: candidate == pid or candidate not in runner.live
39
+ else:
40
+ started = "invalid process time" if stale == "invalid-start" else time.strftime(
41
+ "%a %b %d %H:%M:%S %Y", time.localtime(case.launched + 120))
42
+ runner.starts[pid] = started
43
+ runner.table = [(pid, case.PPID, started)]
44
+ self.assertIsNone(cross.shared_source(watcher))
45
+ self.assertIsNotNone(cross.plan_fallback(watcher, watcher.tracker.pending, ""))
46
+
47
+ def test_claim_must_match_state_basename_provider_root_and_parent_identity(self):
48
+ for changed in ("pid", "provider", "acc_root", "ppid"):
49
+ with self.subTest(changed=changed):
50
+ case, watcher, runner, history, _ = scenario(self, "codex")
51
+ state, _, _ = self.holder(case, watcher, runner, history)
52
+ fields = dict(line.split("=", 1) for line in state.read_text().splitlines())
53
+ values = {"pid": "7878", "provider": "claude", "acc_root": str(case.tmp / "another-pool"),
54
+ "ppid": "888"}
55
+ fields[changed] = values[changed]
56
+ state.write_text("".join(key + "=" + value + "\n" for key, value in fields.items()))
57
+ self.assertIsNone(cross.shared_source(watcher))
58
+ self.assertIsNotNone(cross.plan_fallback(watcher, watcher.tracker.pending, ""))
59
+
60
+ def test_malformed_state_and_missing_claim_target_do_not_crash_or_block(self):
61
+ for invalid in ("malformed-state", "missing-rollout"):
62
+ with self.subTest(invalid=invalid):
63
+ case, watcher, runner, history, _ = scenario(self, "codex")
64
+ state, claim, _ = self.holder(case, watcher, runner, history)
65
+ if invalid == "malformed-state":
66
+ state.write_text("v=1\nprovider=codex\npid=not-a-pid\n")
67
+ else:
68
+ claim.write_text(str(case.tmp / "absent-rollout.jsonl"))
69
+ self.assertIsNone(cross.shared_source(watcher))
70
+ self.assertIsNotNone(cross.plan_fallback(watcher, watcher.tracker.pending, ""))
71
+
72
+ def test_another_live_session_is_not_a_holder_of_this_source(self):
73
+ case, watcher, runner, _, _ = scenario(self, "codex")
74
+ unrelated = case.rollout([fixtures.cx_meta(case.launched, sid=fixtures.CX_SID2, cwd=str(case.work))],
75
+ sid=fixtures.CX_SID2)
76
+ self.holder(case, watcher, runner, unrelated)
77
+ self.assertIsNone(cross.shared_source(watcher))
78
+ self.assertIsNotNone(cross.plan_fallback(watcher, watcher.tracker.pending, ""))
79
+
80
+ def test_new_holder_after_target_probe_invalidates_the_plan(self):
81
+ case, watcher, runner, history, _ = scenario(self, "codex")
82
+ plan = cross.plan_fallback(watcher, watcher.tracker.pending, "")
83
+ self.assertIsNotNone(plan)
84
+ self.holder(case, watcher, runner, history)
85
+ self.assertFalse(cross.revalidate(watcher, plan))
86
+
87
+
88
+ if __name__ == "__main__":
89
+ unittest.main()