claude-multiacc 2.0.56 → 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 +16 -5
  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 +58 -14
  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 +3 -1
  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,141 @@
1
+ """A destination startup token is insufficient without fresh exact user input in its own history."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+ import unittest
6
+
7
+ import test_autoresume as fixtures
8
+ from autoresume_cross_receipt_support import TARGET_PID, TOKEN, receipt_scenario
9
+ import autoresume_cross_receipt as receipt
10
+
11
+
12
+ class CrossProviderReceiptTests(unittest.TestCase):
13
+ def test_fresh_exact_prompt_confirms_the_new_destination_sid_in_both_directions(self):
14
+ for provider in ("claude", "codex"):
15
+ with self.subTest(provider=provider):
16
+ _, watcher, _, plan, _, _, _, sid = receipt_scenario(self, provider)
17
+ self.assertNotEqual(sid, watcher.sid)
18
+ self.assertEqual(receipt.confirm_cross(watcher, TOKEN, plan), sid)
19
+
20
+ def test_missing_wrong_or_replayed_startup_token_does_not_confirm(self):
21
+ for provider in ("claude", "codex"):
22
+ for bad in ("missing", "wrong-token", "wrong-state-token"):
23
+ with self.subTest(provider=provider, bad=bad):
24
+ _, watcher, _, plan, proof, state, _, _ = receipt_scenario(self, provider)
25
+ if bad == "missing":
26
+ proof.unlink()
27
+ elif bad == "wrong-token":
28
+ data = json.loads(proof.read_text())
29
+ data["token"] = "AnotherReceipt0123456789"
30
+ proof.write_text(json.dumps(data))
31
+ else:
32
+ state.write_text(state.read_text().replace("handoff_token=" + TOKEN,
33
+ "handoff_token=AnotherReceipt0123456789"))
34
+ self.assertIsNone(receipt.read_start(watcher, TOKEN, plan))
35
+ self.assertIsNone(receipt.confirm_cross(watcher, TOKEN, plan))
36
+
37
+ def test_dead_reused_or_mismatched_client_receipt_is_rejected(self):
38
+ for provider in ("claude", "codex"):
39
+ for bad in ("dead", "zombie", "reused", "absent-start", "pane", "chain", "provider", "depth"):
40
+ with self.subTest(provider=provider, bad=bad):
41
+ _, watcher, runner, plan, proof, _, _, _ = receipt_scenario(self, provider)
42
+ if bad == "dead":
43
+ runner.exit(TARGET_PID)
44
+ elif bad == "zombie":
45
+ runner.gone = lambda pid: pid == TARGET_PID or pid not in runner.live
46
+ elif bad == "reused":
47
+ runner.starts[TARGET_PID] = "a different process instance"
48
+ elif bad == "absent-start":
49
+ runner.starts[TARGET_PID] = None
50
+ data = json.loads(proof.read_text())
51
+ data.pop("started")
52
+ proof.write_text(json.dumps(data))
53
+ else:
54
+ data = json.loads(proof.read_text())
55
+ data[bad] = 999 if bad == "depth" else "mismatched-identity"
56
+ proof.write_text(json.dumps(data))
57
+ self.assertIsNone(receipt.read_start(watcher, TOKEN, plan))
58
+
59
+ def test_startup_state_cannot_redirect_history_or_shim_outside_the_target(self):
60
+ for provider in ("claude", "codex"):
61
+ for changed in ("acct_dir", "self", "cwd", "tmux"):
62
+ with self.subTest(provider=provider, changed=changed):
63
+ case, watcher, _, plan, _, state_path, _, _ = receipt_scenario(self, provider)
64
+ fields = dict(line.split("=", 1) for line in state_path.read_text().splitlines())
65
+ values = {"acct_dir": str(case.root / "acct-01"), "self": watcher.st["self"],
66
+ "cwd": str(case.root), "tmux": "/another/tmux/socket,123,1"}
67
+ fields[changed] = values[changed]
68
+ state_path.write_text("".join(key + "=" + value + "\n" for key, value in fields.items()))
69
+ self.assertIsNone(receipt.read_start(watcher, TOKEN, plan))
70
+
71
+ def test_an_old_or_wrong_user_prompt_cannot_confirm_even_with_fresh_startup(self):
72
+ for provider in ("claude", "codex"):
73
+ for bad in ("old", "wrong"):
74
+ with self.subTest(provider=provider, bad=bad):
75
+ _, watcher, runner, plan, _, _, history, sid = receipt_scenario(self, provider)
76
+ records = [json.loads(line) for line in history.read_text().splitlines()]
77
+ user = records[-1]
78
+ if bad == "old":
79
+ user["timestamp"] = fixtures.iso(runner.now() - 3600)
80
+ elif plan["provider"] == "claude":
81
+ user["message"]["content"] = "Some other prompt"
82
+ else:
83
+ user["payload"]["message"] = "Some other prompt"
84
+ history.write_text(fixtures.jl(records))
85
+ self.assertIsNone(receipt.confirm_cross(watcher, TOKEN, plan))
86
+
87
+ def test_assistant_or_subagent_echo_is_not_a_destination_user_receipt(self):
88
+ for provider in ("claude", "codex"):
89
+ for bad in ("assistant", "subagent"):
90
+ with self.subTest(provider=provider, bad=bad):
91
+ _, watcher, runner, plan, _, _, history, sid = receipt_scenario(self, provider)
92
+ records = [json.loads(line) for line in history.read_text().splitlines()]
93
+ if plan["provider"] == "claude":
94
+ records[-1] = fixtures.cc_reply(runner.now(), plan["argv"][-1], sessionId=sid,
95
+ cwd=plan["history"]["cwd"]) \
96
+ if bad == "assistant" else dict(records[-1], isSidechain=True)
97
+ elif bad == "assistant":
98
+ records[-1]["payload"]["type"] = "agent_message"
99
+ else:
100
+ records[0]["payload"]["source"] = {"subagent": "spawn"}
101
+ history.write_text(fixtures.jl(records))
102
+ self.assertIsNone(receipt.confirm_cross(watcher, TOKEN, plan))
103
+
104
+ def test_source_sid_cannot_be_reused_as_the_destination_conversation(self):
105
+ for provider in ("claude", "codex"):
106
+ with self.subTest(provider=provider):
107
+ _, watcher, _, plan, _, state_path, history, sid = receipt_scenario(self, provider)
108
+ records = [json.loads(line) for line in history.read_text().splitlines()]
109
+ destination = history.with_name(history.name.replace(sid, watcher.sid))
110
+ if plan["provider"] == "claude":
111
+ records[-1]["sessionId"] = watcher.sid
112
+ state = fixtures.ar.load_state(str(state_path))
113
+ registry = Path(state["acct_dir"]) / "sessions" / (str(TARGET_PID) + ".json")
114
+ data = json.loads(registry.read_text())
115
+ data["sessionId"] = watcher.sid
116
+ registry.write_text(json.dumps(data))
117
+ else:
118
+ records[0]["payload"].update(id=watcher.sid, session_id=watcher.sid)
119
+ state_path.with_suffix(".rollout").write_text(str(destination))
120
+ history.rename(destination)
121
+ destination.write_text(fixtures.jl(records))
122
+ self.assertIsNone(receipt.confirm_cross(watcher, TOKEN, plan))
123
+
124
+ def test_replacing_the_bound_target_history_cannot_manufacture_a_receipt(self):
125
+ for provider in ("claude", "codex"):
126
+ with self.subTest(provider=provider):
127
+ _, watcher, runner, plan, _, _, history, _ = receipt_scenario(self, provider)
128
+ replacement = history.with_suffix(".replacement")
129
+ replacement.write_bytes(history.read_bytes())
130
+ records = [json.loads(line) for line in history.read_text().splitlines()]
131
+ if plan["provider"] == "claude":
132
+ records[-1]["message"]["content"] = "Different user input"
133
+ else:
134
+ records[-1]["payload"]["message"] = "Different user input"
135
+ history.write_text(fixtures.jl(records))
136
+ runner.at(runner.now() + 0.2, lambda: replacement.replace(history))
137
+ self.assertIsNone(receipt.confirm_cross(watcher, TOKEN, plan))
138
+
139
+
140
+ if __name__ == "__main__":
141
+ unittest.main()
@@ -0,0 +1,140 @@
1
+ """Cross-provider acceptance through actual tmux, shells, production shims and watchers."""
2
+
3
+ import re
4
+ import signal
5
+ import time
6
+ import unittest
7
+
8
+ from autoresume_cross_tmux_support import CrossScenario, TMUX
9
+
10
+
11
+ @unittest.skipUnless(TMUX, "real tmux is unavailable")
12
+ class CrossProviderTmuxTests(unittest.TestCase):
13
+ def scenario(self, provider="codex", args=None, **environment):
14
+ case = CrossScenario(**environment)
15
+ self.addCleanup(case.close)
16
+ case.start(provider, args)
17
+ return case
18
+
19
+ def assert_handoff(self, case, source, target):
20
+ case.wait(lambda: len(case.records("ready")) >= 2, "target TUI ready")
21
+ case.wait(lambda: "autoresume switch" in case.logs(source), "source confirms target prompt")
22
+ first, second = case.records("launch")[:2]
23
+ prompt = case.records("prompt")[0]
24
+ self.assertEqual((first["provider"], second["provider"]), (source, target))
25
+ self.assertEqual((first["account"], second["account"]), ("acct-01", "acct-01"))
26
+ self.assertNotEqual(first["sid"], second["sid"])
27
+ self.assertEqual(first["cwd"], second["cwd"])
28
+ self.assertEqual(prompt["provider"], target)
29
+ self.assertIn(source, prompt["text"])
30
+ self.assertIn(first["sid"], prompt["text"])
31
+ self.assertIn(case.records("ready")[0]["path"], prompt["text"])
32
+ self.assertIn(str(case.work), prompt["text"])
33
+ self.assertIn("history", prompt["text"].lower())
34
+ self.assertIn("continu", prompt["text"].lower())
35
+ self.assertNotIn("--resume", second["argv"])
36
+ self.assertNotIn("resume", second["argv"][:-1])
37
+ self.assertEqual(case.records("keys"), [], "no keystroke may reach a TUI")
38
+ self.assertEqual(case.records("signal")[0]["signal"], signal.SIGTERM)
39
+ self.assertEqual(list(case.home.glob("*-pool/tmp/autoresume/r-*")), [])
40
+ return first, second
41
+
42
+ def test_codex_pool_exhausted_continues_in_claude_with_source_identity(self):
43
+ case = self.scenario()
44
+ case.park("codex")
45
+ case.quota()
46
+ self.assert_handoff(case, "codex", "claude")
47
+
48
+ def test_claude_pool_exhausted_continues_in_codex_with_source_identity(self):
49
+ case = self.scenario("claude")
50
+ case.park("claude")
51
+ case.quota()
52
+ self.assert_handoff(case, "claude", "codex")
53
+ self.assertIn("--no-daemon", case.records("launch")[1]["argv"])
54
+
55
+ def test_same_provider_has_priority_in_both_directions(self):
56
+ for provider in ("codex", "claude"):
57
+ with self.subTest(provider=provider):
58
+ case = self.scenario(provider)
59
+ case.quota()
60
+ case.wait(lambda: len(case.records("ready")) == 2, "same provider TUI ready")
61
+ case.wait(lambda: "autoresume switch" in case.logs(provider), "same provider confirmed")
62
+ first, second = case.records("launch")
63
+ self.assertEqual([first["provider"], second["provider"]], [provider, provider])
64
+ self.assertEqual((first["account"], second["account"]), ("acct-01", "acct-02"))
65
+ self.assertEqual(first["sid"], second["sid"])
66
+ self.assertEqual(case.records("keys"), [])
67
+ case.close()
68
+
69
+ def test_both_pools_unavailable_hold_then_recover_without_touching_tui(self):
70
+ case = self.scenario()
71
+ case.park("codex")
72
+ case.park("claude", ("acct-01", "acct-02"))
73
+ case.quota()
74
+ case.wait(lambda: "autoresume hold" in case.logs("codex"), "both pools unavailable")
75
+ time.sleep(0.7)
76
+ self.assertEqual((case.records("signal"), case.records("keys")), ([], []))
77
+ self.assertEqual(len(case.records("launch")), 1)
78
+ (case.pools["claude"] / "acct-01/.limited").unlink()
79
+ self.assert_handoff(case, "codex", "claude")
80
+
81
+ def test_unpersisted_target_prompt_never_logs_success(self):
82
+ case = self.scenario(FAKE_DROP_PROMPT="claude")
83
+ case.park("codex")
84
+ case.quota()
85
+ case.wait(lambda: len(case.records("ready")) == 2, "target starts without a prompt receipt")
86
+ case.wait(lambda: "autoresume giveup" in case.logs("codex"), "missing target receipt refused")
87
+ self.assertNotIn("autoresume switch", case.logs())
88
+ self.assertEqual(case.records("prompt"), [])
89
+ time.sleep(0.5)
90
+ self.assertEqual(len(case.records("launch")), 2, "failed receipt must not loop")
91
+
92
+ def test_target_kill_switch_holds_source(self):
93
+ case = self.scenario()
94
+ case.park("codex")
95
+ (case.pools["claude"] / "autoresume.off").touch()
96
+ case.quota()
97
+ case.wait(lambda: "autoresume hold" in case.logs("codex"), "target pool disabled")
98
+ self.assertEqual((case.records("signal"), case.records("keys")), ([], []))
99
+ self.assertEqual(len(case.records("launch")), 1)
100
+
101
+ def test_conservative_permissions_survive_handoff_in_both_directions(self):
102
+ for source, target in (("codex", "claude"), ("claude", "codex")):
103
+ with self.subTest(source=source):
104
+ case = self.scenario(source, args=[])
105
+ case.park(source)
106
+ case.quota()
107
+ _first, second = self.assert_handoff(case, source, target)
108
+ if target == "claude":
109
+ self.assertEqual(second["argv"][:2], ["--permission-mode", "default"])
110
+ else:
111
+ self.assertIn("--sandbox", second["argv"])
112
+ self.assertIn("read-only", second["argv"])
113
+ self.assertIn("--ask-for-approval", second["argv"])
114
+ self.assertIn("on-request", second["argv"])
115
+ self.assertFalse(any("dangerously" in arg or arg == "--yolo" for arg in second["argv"][:-1]))
116
+ case.close()
117
+
118
+ def test_provider_namespaces_preserve_chain_and_avoid_rejected_account_on_return(self):
119
+ case = self.scenario()
120
+ case.park("codex")
121
+ case.quota()
122
+ first, _second = self.assert_handoff(case, "codex", "claude")
123
+ for marker in case.pools["codex"].glob("acct-*/.limited"):
124
+ marker.unlink()
125
+ case.park("claude")
126
+ case.quota()
127
+ case.wait(lambda: len(case.records("ready")) == 3, "chain returns to recovered Codex pool")
128
+ case.wait(lambda: "autoresume switch" in case.logs("claude"), "return prompt confirmed")
129
+ third = case.records("launch")[2]
130
+ self.assertEqual((third["provider"], third["account"]), ("codex", "acct-02"))
131
+ self.assertNotEqual(third["sid"], first["sid"])
132
+ chains = re.findall(r"autoresume watch provider=\w+ chain=(\w+)", case.logs())
133
+ self.assertEqual(len(chains), 3)
134
+ self.assertEqual(len(set(chains)), 1)
135
+ self.assertIn("depth=2", case.logs("codex"))
136
+ self.assertEqual(case.records("keys"), [])
137
+
138
+
139
+ if __name__ == "__main__":
140
+ unittest.main()
@@ -0,0 +1,45 @@
1
+ """Real provider selection races after the source has been checked and stopped."""
2
+
3
+ import time
4
+ import unittest
5
+
6
+ from autoresume_cross_tmux_support import CrossScenario, TMUX
7
+
8
+
9
+ @unittest.skipUnless(TMUX, "real tmux is unavailable")
10
+ class CrossProviderRaceTmuxTests(unittest.TestCase):
11
+ def test_historical_target_prompt_is_not_a_new_handoff_receipt(self):
12
+ for source, target in (("codex", "claude"), ("claude", "codex")):
13
+ with self.subTest(source=source):
14
+ case = CrossScenario(FAKE_OLD_PROMPT=target)
15
+ self.addCleanup(case.close)
16
+ case.start(source)
17
+ case.park(source)
18
+ case.quota()
19
+ case.wait(lambda: len(case.records("ready")) == 2, "target TUI has a historical prompt")
20
+ case.wait(lambda: "autoresume giveup" in case.logs(source), "historical receipt refused")
21
+ self.assertEqual(len(case.records("prompt")), 1)
22
+ self.assertNotIn("autoresume switch", case.logs())
23
+ self.assertEqual(case.records("keys"), [])
24
+ case.close()
25
+
26
+ def test_target_capacity_lost_after_probe_never_launches_hard_fallback_or_claims_success(self):
27
+ for source, target in (("codex", "claude"), ("claude", "codex")):
28
+ with self.subTest(source=source):
29
+ case = CrossScenario(FAKE_PARK_TARGET_ON_TERM=target)
30
+ self.addCleanup(case.close)
31
+ case.start(source)
32
+ case.park(source)
33
+ case.quota()
34
+ case.wait(lambda: "autoresume giveup" in case.logs(source), "lost target capacity refused")
35
+ self.assertEqual(len(case.records("signal")), 1, "race occurs after the valid initial probe")
36
+ self.assertEqual(len(case.records("launch")), 1, "hard-fallback target must not launch")
37
+ self.assertNotIn("autoresume switch", case.logs())
38
+ self.assertEqual(case.records("keys"), [])
39
+ time.sleep(0.4)
40
+ self.assertEqual(len(case.records("launch")), 1, "the failed launch must not retry in a loop")
41
+ case.close()
42
+
43
+
44
+ if __name__ == "__main__":
45
+ unittest.main()
@@ -0,0 +1,102 @@
1
+ """Prepared cross-provider tokens preserve chain state and cannot mark or resume the wrong pool."""
2
+
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+ import unittest
7
+ from unittest import mock
8
+
9
+ import test_autoresume as fixtures
10
+ from autoresume_cross_receipt_support import TOKEN
11
+ from autoresume_cross_unit_support import scenario
12
+ import autoresume_cross as cross
13
+ import autoresume_cross_dispatch as dispatch
14
+
15
+
16
+ class CrossProviderTokenTests(unittest.TestCase):
17
+ def prepared(self, provider):
18
+ case, watcher, runner, _, root = scenario(self, provider)
19
+ target = "codex" if provider == "claude" else "claude"
20
+ watcher.st["depth"] = 3
21
+ watcher.st["avoid_" + target] = "acct-02:%d" % (case.t0 + 3600)
22
+ avoid = "acct-01:%d" % (case.t0 + 7200)
23
+ plan = cross.plan_fallback(watcher, watcher.tracker.pending, avoid)
24
+ self.assertIsNotNone(plan)
25
+ plan["shim"] = str(fixtures.REPO / "bin" / target)
26
+ watcher.cross_plan = plan
27
+ keys, paths = cross.prepare(watcher, watcher.tracker.pending, avoid, TOKEN,
28
+ watcher.build_relaunch_argv("quota"))
29
+ return case, watcher, runner, plan, keys, paths
30
+
31
+ def test_only_source_token_names_the_exhausted_account_and_target_is_fresh(self):
32
+ for provider in ("claude", "codex"):
33
+ with self.subTest(provider=provider):
34
+ case, watcher, runner, plan, keys, paths = self.prepared(provider)
35
+ source = case.ar_dir / ("r-" + TOKEN + ".relaunch")
36
+ target = Path(plan["root"]) / "tmp/autoresume" / source.name
37
+ old = fixtures.ar.parse_kv(source.read_text(), fixtures.ar.RELAUNCH_KEYS)
38
+ new = fixtures.ar.parse_kv(target.read_text(), fixtures.ar.RELAUNCH_KEYS)
39
+ self.assertEqual((old["provider"], old["acct"], old["cross"]), (provider, "acct-01", TOKEN))
40
+ self.assertEqual((new["provider"], new["acct"], new["cross"], new["handoff_token"]),
41
+ (plan["provider"], "", "", TOKEN))
42
+ self.assertEqual((new["chain"], old["chain"], new["depth"], old["depth"]),
43
+ (watcher.chain, watcher.chain, "4", "4"))
44
+ self.assertEqual(new["hist"], old["hist"])
45
+ self.assertEqual(new["avoid"], watcher.st["avoid_" + plan["provider"]])
46
+ argv = fixtures.ar.decode_argv(target.with_suffix(".argv").read_bytes())
47
+ self.assertEqual(argv, plan["argv"])
48
+ self.assertFalse({"--resume", "resume", watcher.sid} & set(argv[:-1]))
49
+ self.assertEqual(runner.signals, [])
50
+ self.assertTrue(all(Path(path).stat().st_mode & 0o077 == 0 for path in paths))
51
+ typed = next(key[-1] for key in keys if "-l" in key)
52
+ self.assertIn(watcher.st["self"], typed)
53
+ self.assertNotIn(plan["argv"][-1], typed)
54
+
55
+ def test_dispatch_payload_is_exact_installation_metadata_without_auth_or_prompt(self):
56
+ for provider in ("claude", "codex"):
57
+ with self.subTest(provider=provider):
58
+ case, watcher, _, plan, _, _ = self.prepared(provider)
59
+ path, payload = dispatch.read_dispatch(str(case.root), TOKEN)
60
+ self.assertEqual(payload["target_shim"], str(fixtures.REPO / "bin" / plan["provider"]))
61
+ self.assertEqual(payload["source_root"], str(case.root))
62
+ self.assertEqual(payload["sid"], watcher.sid)
63
+ self.assertNotIn(plan["argv"][-1], Path(path).read_text())
64
+ self.assertFalse({"OPENAI_API_KEY", "ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"}
65
+ & set(payload["settings"]))
66
+
67
+ def test_destination_dispatch_is_single_use_even_when_exec_fails(self):
68
+ case, watcher, _, plan, _, _ = self.prepared("codex")
69
+ env = {"HOME": str(case.tmp), "PATH": "/usr/bin:/bin"}
70
+ with mock.patch.dict(os.environ, env, clear=True), mock.patch.object(dispatch.os, "chdir"), \
71
+ mock.patch.object(dispatch.os, "execve", side_effect=OSError("simulated exec failure")) as execute:
72
+ with self.assertRaises(OSError):
73
+ dispatch.dispatch(str(case.root), TOKEN)
74
+ with self.assertRaises((OSError, ValueError)):
75
+ dispatch.dispatch(str(case.root), TOKEN)
76
+ self.assertEqual(execute.call_count, 1)
77
+ self.assertEqual(execute.call_args.args[1], [plan["shim"]])
78
+ self.assertEqual(execute.call_args.args[2]["CLAUDE_MULTIACC_AR"], TOKEN + ":" + watcher.sid)
79
+
80
+ def test_wrong_installation_or_expired_payload_cannot_dispatch(self):
81
+ for invalid in ("target", "expired", "permissions", "symlink"):
82
+ with self.subTest(invalid=invalid):
83
+ case, _, _, _, _, _ = self.prepared("claude")
84
+ path = case.ar_dir / ("r-" + TOKEN + ".cross.json")
85
+ if invalid == "target":
86
+ payload = json.loads(path.read_text())
87
+ payload["target_shim"] = "/tmp/unrelated/codex"
88
+ path.write_text(json.dumps(payload))
89
+ elif invalid == "expired":
90
+ os.utime(path, (0, 0))
91
+ elif invalid == "permissions":
92
+ path.chmod(0o644)
93
+ else:
94
+ destination = path.with_suffix(".backup")
95
+ path.rename(destination)
96
+ path.symlink_to(destination)
97
+ with self.assertRaises((OSError, ValueError)):
98
+ dispatch.read_dispatch(str(case.root), TOKEN)
99
+
100
+
101
+ if __name__ == "__main__":
102
+ unittest.main()
@@ -0,0 +1,127 @@
1
+ """Fresh provider conversations retain source history and conservative permission intent."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+ import tempfile
6
+ import unittest
7
+
8
+ import test_autoresume as fixtures
9
+ import autoresume_handoff as handoff
10
+
11
+
12
+ class HandoffArgvTests(unittest.TestCase):
13
+ def setUp(self):
14
+ temporary = tempfile.TemporaryDirectory(prefix="ar-handoff-argv-")
15
+ self.addCleanup(temporary.cleanup)
16
+ self.home = Path(temporary.name).resolve()
17
+ self.cwd = self.home / 'workspace "quoted" $literal'
18
+ self.cwd.mkdir()
19
+
20
+ def history(self, provider):
21
+ sid = fixtures.SID if provider == "claude" else fixtures.CX_SID
22
+ name = sid + ".jsonl" if provider == "claude" else "rollout-fixture-" + sid + ".jsonl"
23
+ path = self.home / name
24
+ record = fixtures.cc_user(1000, "PRIVATE_SOURCE_TEXT_SENTINEL", cwd=str(self.cwd)) \
25
+ if provider == "claude" else fixtures.cx_meta(1000, cwd=str(self.cwd))
26
+ path.write_text(json.dumps(record) + "\n")
27
+ return handoff.validate_source(provider, sid, str(path), str(self.cwd))
28
+
29
+ def test_fresh_target_drops_source_session_model_effort_and_original_prompt(self):
30
+ cases = (("claude", "codex", ["--model", "source-only-model", "--effort", "high",
31
+ "--resume", fixtures.SID, "Original user request"]),
32
+ ("codex", "claude", ["resume", "--no-daemon", "--model", "source-only-model",
33
+ fixtures.CX_SID, "Original user request"]))
34
+ for source, target, original in cases:
35
+ with self.subTest(source=source):
36
+ history = self.history(source)
37
+ argv = handoff.handoff_argv(source, target, original, history)
38
+ self.assertIsNotNone(argv)
39
+ for value in ("resume", "--resume", "--last", "--continue", "--model", "--effort",
40
+ "source-only-model", "Original user request", history["sid"]):
41
+ self.assertNotIn(value, argv[:-1])
42
+ self.assertIn(history["sid"], argv[-1])
43
+ self.assertNotIn("Original user request", argv[-1])
44
+
45
+ def test_default_and_allow_bypass_only_keep_conservative_target_permissions(self):
46
+ cases = (("claude", "codex", [], ["--sandbox", "read-only", "--ask-for-approval", "on-request"]),
47
+ ("claude", "codex", ["--allow-dangerously-skip-permissions"],
48
+ ["--sandbox", "read-only", "--ask-for-approval", "on-request"]),
49
+ ("claude", "codex", ["--permission-mode", "default"],
50
+ ["--sandbox", "read-only", "--ask-for-approval", "on-request"]),
51
+ ("codex", "claude", [], ["--permission-mode", "default"]))
52
+ for source, target, original, expected in cases:
53
+ with self.subTest(source=source, original=original):
54
+ argv = handoff.handoff_argv(source, target, original, self.history(source))
55
+ self.assertIsNotNone(argv)
56
+ self.assertEqual(argv[:-1], expected)
57
+
58
+ def test_explicit_bypass_maps_only_to_equivalent_target_bypass(self):
59
+ cases = (("claude", "codex", "--dangerously-skip-permissions",
60
+ "--dangerously-bypass-approvals-and-sandbox"),
61
+ ("codex", "claude", "--yolo", "--dangerously-skip-permissions"),
62
+ ("codex", "claude", "--dangerously-bypass-approvals-and-sandbox",
63
+ "--dangerously-skip-permissions"))
64
+ for source, target, option, expected in cases:
65
+ with self.subTest(source=source, option=option):
66
+ argv = handoff.handoff_argv(source, target, [option], self.history(source))
67
+ self.assertIsNotNone(argv)
68
+ self.assertEqual(argv[:-1], [expected])
69
+ argv = handoff.handoff_argv("claude", "codex", ["--permission-mode", "bypassPermissions"],
70
+ self.history("claude"))
71
+ self.assertEqual(argv[:-1], ["--dangerously-bypass-approvals-and-sandbox"])
72
+
73
+ def test_unsupported_restrictive_modes_and_ambiguous_permissions_hold(self):
74
+ for mode in ("plan", "acceptEdits", "dontAsk", "auto", "unknown"):
75
+ with self.subTest(mode=mode):
76
+ self.assertIsNone(handoff.handoff_argv("claude", "codex", ["--permission-mode", mode],
77
+ self.history("claude")))
78
+ conflicting = ["--dangerously-skip-permissions", "--permission-mode", "default"]
79
+ self.assertIsNone(handoff.handoff_argv("claude", "codex", conflicting, self.history("claude")))
80
+ for original in (["--sandbox", "read-only", "--ask-for-approval", "never"],
81
+ ["--yolo", "--sandbox", "read-only", "--ask-for-approval", "on-request"]):
82
+ self.assertIsNone(handoff.handoff_argv("codex", "claude", original, self.history("codex")))
83
+
84
+ def test_custom_prompt_cannot_remove_exact_history_and_continuation_instructions(self):
85
+ for source, target in (("claude", "codex"), ("codex", "claude")):
86
+ with self.subTest(source=source):
87
+ history = self.history(source)
88
+ original = Path(history["path"]).read_bytes()
89
+ argv = handoff.handoff_argv(source, target, [], history,
90
+ prompt_override="Preserve the operator's extra instruction.")
91
+ prompt = argv[-1]
92
+ for value in (source, history["sid"], json.dumps(history["path"]), json.dumps(history["cwd"]),
93
+ "Preserve the operator's extra instruction."):
94
+ self.assertIn(value, prompt)
95
+ self.assertRegex(prompt.lower(), r"read.*history|history.*read")
96
+ self.assertRegex(prompt.lower(), r"background|subagent")
97
+ self.assertRegex(prompt.lower(), r"inspect|check")
98
+ self.assertRegex(prompt.lower(), r"inode[^0-9]*" + str(history["inode"]))
99
+ self.assertIn(str(history["offset"]), prompt)
100
+ self.assertNotIn("PRIVATE_SOURCE_TEXT_SENTINEL", prompt)
101
+ self.assertEqual(Path(history["path"]).read_bytes(), original)
102
+
103
+ def test_build_validates_history_before_constructing_any_target_argv(self):
104
+ for source, target in (("claude", "codex"), ("codex", "claude")):
105
+ with self.subTest(source=source):
106
+ history = self.history(source)
107
+ result = handoff.build_handoff(source, target, [], history["sid"], history["path"],
108
+ history["cwd"], inode=history["inode"])
109
+ self.assertIsNotNone(result)
110
+ self.assertEqual(result["history"], history)
111
+ self.assertIn(history["sid"], result["argv"][-1])
112
+ self.assertIsNone(handoff.build_handoff(source, target, [], fixtures.SID2, history["path"],
113
+ history["cwd"]))
114
+
115
+ def test_same_provider_unknown_provider_and_unsupported_cli_modes_are_rejected(self):
116
+ history = self.history("claude")
117
+ for source, target, original in (("claude", "claude", []), ("claude", "other", []),
118
+ ("other", "codex", []), ("codex", "claude", []),
119
+ ("claude", "codex", ["--fork-session"]),
120
+ ("claude", "codex", ["-p", "Noninteractive task"]),
121
+ ("claude", "codex", None), ("claude", "codex", [None])):
122
+ with self.subTest(source=source, target=target, original=original):
123
+ self.assertIsNone(handoff.handoff_argv(source, target, original, history))
124
+
125
+
126
+ if __name__ == "__main__":
127
+ unittest.main()