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.
- package/README.md +7 -2
- package/bin/claude +71 -8
- package/bin/codex +67 -14
- package/docs/AUTORESUME-HANDOFF.md +82 -0
- package/docs/AUTORESUME.md +24 -12
- package/lib/__pycache__/audit.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_cross.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_cross_dispatch.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_cross_receipt.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_handoff.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_probe.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_receipt.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_client_error.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_token_identity.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_autoresume_mode.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credit_controls.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credit_local_scope.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credit_selection.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credits.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_limits_lock.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_usage_fetch.cpython-312.pyc +0 -0
- package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
- package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
- package/lib/__pycache__/report.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_credit_scope.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_credits.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_funding.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
- package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
- package/lib/autoresume.py +60 -16
- package/lib/autoresume_cross.py +143 -0
- package/lib/autoresume_cross_dispatch.py +114 -0
- package/lib/autoresume_cross_receipt.py +138 -0
- package/lib/autoresume_handoff.py +191 -0
- package/lib/autoresume_probe.py +36 -0
- package/package.json +1 -1
- package/tests/__pycache__/autoresume_cross_receipt_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/autoresume_cross_tmux_environment.cpython-312.pyc +0 -0
- package/tests/__pycache__/autoresume_cross_tmux_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/autoresume_cross_unit_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/autoresume_tmux_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_codex_records.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_claude_holders.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_guards.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_holders.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_policy.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_receipt.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_tmux.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_tmux_races.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_tokens.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cursor.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_fallback.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_handoff_argv.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_handoff_history.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_mode.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_probe.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_receipt.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_tmux.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_tmux_modes.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_credits.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_credits_polling.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_credits_report.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_fetch.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_cached_credits.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_credit_scope.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_credit_shim.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_credits.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_spread.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
- package/tests/autoresume_cross_receipt_support.py +58 -0
- package/tests/autoresume_cross_tmux_environment.py +59 -0
- package/tests/autoresume_cross_tmux_support.py +112 -0
- package/tests/autoresume_cross_unit_support.py +92 -0
- package/tests/fixtures/cross_provider_cli.py +121 -0
- package/tests/test_autoresume.py +4 -2
- package/tests/test_autoresume_cross_claude_holders.py +81 -0
- package/tests/test_autoresume_cross_guards.py +133 -0
- package/tests/test_autoresume_cross_holders.py +89 -0
- package/tests/test_autoresume_cross_policy.py +123 -0
- package/tests/test_autoresume_cross_receipt.py +141 -0
- package/tests/test_autoresume_cross_tmux.py +140 -0
- package/tests/test_autoresume_cross_tmux_races.py +45 -0
- package/tests/test_autoresume_cross_tokens.py +102 -0
- package/tests/test_autoresume_handoff_argv.py +127 -0
- package/tests/test_autoresume_handoff_history.py +115 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Quota fallback keeps same-provider priority and never stops a client without a safe target."""
|
|
2
|
+
|
|
3
|
+
import unittest
|
|
4
|
+
from unittest import mock
|
|
5
|
+
|
|
6
|
+
import test_autoresume as fixtures
|
|
7
|
+
from autoresume_cross_unit_support import cancel_record, scenario
|
|
8
|
+
import autoresume_cross as cross
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CrossProviderPolicyTests(unittest.TestCase):
|
|
12
|
+
def test_unknown_or_inconsistent_source_capacity_never_probes_another_provider(self):
|
|
13
|
+
replies = ((127, ""), (124, "pick=acct-02 tier=hard"), (0, "malformed reply"),
|
|
14
|
+
(3, "pick=acct-02 tier=hard"), (0, "pick= tier=none"),
|
|
15
|
+
(3, "pick=acct-02 tier=none"), (0, "pick= tier=hard"),
|
|
16
|
+
(0, "pick=acct-01 tier=eligible"), (1, "pick=acct-02 tier=eligible"))
|
|
17
|
+
for provider in ("claude", "codex"):
|
|
18
|
+
for status, reply in replies:
|
|
19
|
+
with self.subTest(provider=provider, status=status, reply=reply):
|
|
20
|
+
case, watcher, runner, _, _ = scenario(self, provider)
|
|
21
|
+
original = runner.run
|
|
22
|
+
def source_failure(argv, timeout, env=None, cwd=None):
|
|
23
|
+
if argv[0] == watcher.st["self"]:
|
|
24
|
+
return status, reply + "\n"
|
|
25
|
+
return original(argv, timeout, env=env, cwd=cwd)
|
|
26
|
+
with mock.patch.object(runner, "run", side_effect=source_failure):
|
|
27
|
+
self.assertEqual(watcher.run(), 0)
|
|
28
|
+
self.assertEqual((runner.target_probes, runner.signals, runner.sends), ([], [], []))
|
|
29
|
+
self.assertEqual(case.relaunch_files(), [])
|
|
30
|
+
self.assertIn("hold", case.events())
|
|
31
|
+
|
|
32
|
+
def test_valid_empty_source_pool_probes_another_provider(self):
|
|
33
|
+
for provider in ("claude", "codex"):
|
|
34
|
+
with self.subTest(provider=provider):
|
|
35
|
+
_, watcher, runner, _, _ = scenario(self, provider)
|
|
36
|
+
runner.probe_out = "pick= tier=none\n"
|
|
37
|
+
runner.target_out = "pick= tier=none\n"
|
|
38
|
+
self.assertEqual(watcher.run(), 0)
|
|
39
|
+
self.assertTrue(runner.target_probes)
|
|
40
|
+
self.assertEqual((runner.signals, runner.sends), ([], []))
|
|
41
|
+
|
|
42
|
+
def test_same_provider_eligible_soft_or_configured_credits_wins_without_cross_probe(self):
|
|
43
|
+
for provider in ("claude", "codex"):
|
|
44
|
+
for tier in ("eligible", "soft", "credits"):
|
|
45
|
+
with self.subTest(provider=provider, tier=tier):
|
|
46
|
+
case, watcher, runner, _, _ = scenario(self, provider)
|
|
47
|
+
runner.probe_out = "pick=acct-02 tier=" + tier + "\n"
|
|
48
|
+
if tier == "credits":
|
|
49
|
+
watcher.env[provider.upper() + "_MULTIACC_CREDITS"] = "1"
|
|
50
|
+
self.assertEqual(watcher.run(), 0)
|
|
51
|
+
self.assertEqual(len(runner.probes), 1)
|
|
52
|
+
self.assertEqual(runner.target_probes, [])
|
|
53
|
+
self.assertEqual(len(runner.signals), 1)
|
|
54
|
+
self.assertIn("switch", case.events())
|
|
55
|
+
|
|
56
|
+
def test_nonquota_errors_never_probe_the_other_provider(self):
|
|
57
|
+
cases = (("claude", "auth"), ("claude", "blocked"), ("claude", "model"),
|
|
58
|
+
("claude", "transient"), ("codex", "auth"), ("codex", "transient"))
|
|
59
|
+
for provider, error_class in cases:
|
|
60
|
+
with self.subTest(provider=provider, error_class=error_class):
|
|
61
|
+
case, watcher, runner, _, _ = scenario(self, provider, error_class=error_class)
|
|
62
|
+
self.assertIsNone(cross.plan_fallback(watcher, watcher.tracker.pending, ""))
|
|
63
|
+
self.assertEqual((runner.target_probes, runner.signals, runner.sends), ([], [], []))
|
|
64
|
+
|
|
65
|
+
def test_exhausted_or_missing_target_holds_the_source(self):
|
|
66
|
+
for provider in ("claude", "codex"):
|
|
67
|
+
for reply in ("pick=acct-01 tier=hard", "pick= tier=none", "malformed answer"):
|
|
68
|
+
with self.subTest(provider=provider, reply=reply):
|
|
69
|
+
case, watcher, runner, _, _ = scenario(self, provider)
|
|
70
|
+
runner.target_out = reply + "\n"
|
|
71
|
+
self.assertEqual(watcher.run(), 0)
|
|
72
|
+
self.assertTrue(runner.target_probes)
|
|
73
|
+
self.assertEqual((runner.signals, runner.sends, case.relaunch_files()), ([], [], []))
|
|
74
|
+
self.assertIn("hold", case.events())
|
|
75
|
+
|
|
76
|
+
def test_both_directions_accept_same_named_target_account_without_source_avoid_leak(self):
|
|
77
|
+
for provider in ("claude", "codex"):
|
|
78
|
+
with self.subTest(provider=provider):
|
|
79
|
+
case, watcher, runner, _, target_root = scenario(self, provider)
|
|
80
|
+
target = "codex" if provider == "claude" else "claude"
|
|
81
|
+
avoided = "acct-01:%d" % (case.t0 + 7200)
|
|
82
|
+
plan = cross.plan_fallback(watcher, watcher.tracker.pending, avoided)
|
|
83
|
+
self.assertIsNotNone(plan)
|
|
84
|
+
self.assertEqual((plan["provider"], plan["pick"], plan["root"]),
|
|
85
|
+
(target, "acct-01", str(target_root)))
|
|
86
|
+
self.assertEqual(runner.target_probes[-1]["env"][target.upper() + "_MULTIACC_AR_AVOID"], "")
|
|
87
|
+
fields = cross.chain_fields(watcher, avoided)
|
|
88
|
+
self.assertEqual((fields["avoid_" + provider], fields["avoid_" + target]), (avoided, ""))
|
|
89
|
+
|
|
90
|
+
def test_existing_target_avoidance_survives_independently(self):
|
|
91
|
+
for provider in ("claude", "codex"):
|
|
92
|
+
with self.subTest(provider=provider):
|
|
93
|
+
case, watcher, runner, _, _ = scenario(self, provider)
|
|
94
|
+
target = "codex" if provider == "claude" else "claude"
|
|
95
|
+
target_avoid = "acct-02:%d" % (case.t0 + 3600)
|
|
96
|
+
watcher.st["avoid_" + target] = target_avoid
|
|
97
|
+
plan = cross.plan_fallback(watcher, watcher.tracker.pending, "acct-01:%d" % (case.t0 + 7200))
|
|
98
|
+
self.assertIsNotNone(plan)
|
|
99
|
+
self.assertEqual(plan["avoid"], target_avoid)
|
|
100
|
+
self.assertEqual(runner.target_probes[-1]["env"][target.upper() + "_MULTIACC_AR_AVOID"], target_avoid)
|
|
101
|
+
|
|
102
|
+
def test_user_input_during_either_probe_cancels_before_any_signal(self):
|
|
103
|
+
for provider in ("claude", "codex"):
|
|
104
|
+
for stage in ("source", "target"):
|
|
105
|
+
with self.subTest(provider=provider, stage=stage):
|
|
106
|
+
case, watcher, runner, history, _ = scenario(self, provider)
|
|
107
|
+
setattr(runner, stage + "_hook", lambda: fixtures.append(history, [cancel_record(case, provider)]))
|
|
108
|
+
self.assertEqual(watcher.run(), 0)
|
|
109
|
+
self.assertIsNone(watcher.tracker.pending)
|
|
110
|
+
self.assertEqual((runner.signals, runner.sends, case.relaunch_files()), ([], [], []))
|
|
111
|
+
|
|
112
|
+
def test_rotation_budget_cannot_be_bypassed_by_changing_provider(self):
|
|
113
|
+
for provider in ("claude", "codex"):
|
|
114
|
+
with self.subTest(provider=provider):
|
|
115
|
+
case, watcher, runner, _, _ = scenario(self, provider)
|
|
116
|
+
watcher.st["hist"] = ",".join("quota:%d" % (case.t0 - n) for n in range(8))
|
|
117
|
+
self.assertEqual(watcher.run(), 0)
|
|
118
|
+
self.assertEqual((runner.probes, runner.target_probes, runner.signals), ([], [], []))
|
|
119
|
+
self.assertIn("reason=rotate-budget", case.log_lines()[-1])
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
if __name__ == "__main__":
|
|
123
|
+
unittest.main()
|
|
@@ -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()
|