claude-multiacc 2.0.53 → 2.0.55

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 (74) hide show
  1. package/README.md +5 -3
  2. package/bin/codex +26 -5
  3. package/docs/AUTORESUME.md +21 -6
  4. package/lib/__pycache__/audit.cpython-312.pyc +0 -0
  5. package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
  6. package/lib/__pycache__/autoresume_probe.cpython-312.pyc +0 -0
  7. package/lib/__pycache__/autoresume_receipt.cpython-312.pyc +0 -0
  8. package/lib/__pycache__/claude_client_error.cpython-312.pyc +0 -0
  9. package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
  10. package/lib/__pycache__/claude_token_identity.cpython-312.pyc +0 -0
  11. package/lib/__pycache__/codex_autoresume_mode.cpython-312.pyc +0 -0
  12. package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
  13. package/lib/__pycache__/codex_credit_controls.cpython-312.pyc +0 -0
  14. package/lib/__pycache__/codex_credit_local_scope.cpython-312.pyc +0 -0
  15. package/lib/__pycache__/codex_credit_selection.cpython-312.pyc +0 -0
  16. package/lib/__pycache__/codex_credits.cpython-312.pyc +0 -0
  17. package/lib/__pycache__/codex_limits_lock.cpython-312.pyc +0 -0
  18. package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
  19. package/lib/__pycache__/codex_usage_fetch.cpython-312.pyc +0 -0
  20. package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
  21. package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
  22. package/lib/__pycache__/report.cpython-312.pyc +0 -0
  23. package/lib/__pycache__/selector_credit_scope.cpython-312.pyc +0 -0
  24. package/lib/__pycache__/selector_credits.cpython-312.pyc +0 -0
  25. package/lib/__pycache__/selector_funding.cpython-312.pyc +0 -0
  26. package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
  27. package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
  28. package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
  29. package/lib/autoresume.py +22 -5
  30. package/lib/autoresume_receipt.py +78 -0
  31. package/lib/claude_pool_prepare.py +2 -0
  32. package/lib/codex_autoresume_mode.py +58 -0
  33. package/package.json +1 -1
  34. package/tests/__pycache__/autoresume_tmux_support.cpython-312.pyc +0 -0
  35. package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
  36. package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
  37. package/tests/__pycache__/test_autoresume_codex_records.cpython-312.pyc +0 -0
  38. package/tests/__pycache__/test_autoresume_cursor.cpython-312.pyc +0 -0
  39. package/tests/__pycache__/test_autoresume_fallback.cpython-312.pyc +0 -0
  40. package/tests/__pycache__/test_autoresume_mode.cpython-312.pyc +0 -0
  41. package/tests/__pycache__/test_autoresume_probe.cpython-312.pyc +0 -0
  42. package/tests/__pycache__/test_autoresume_receipt.cpython-312.pyc +0 -0
  43. package/tests/__pycache__/test_autoresume_tmux.cpython-312.pyc +0 -0
  44. package/tests/__pycache__/test_autoresume_tmux_modes.cpython-312.pyc +0 -0
  45. package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
  46. package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
  47. package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
  48. package/tests/__pycache__/test_codex_reset_credits.cpython-312.pyc +0 -0
  49. package/tests/__pycache__/test_codex_reset_credits_polling.cpython-312.pyc +0 -0
  50. package/tests/__pycache__/test_codex_reset_credits_report.cpython-312.pyc +0 -0
  51. package/tests/__pycache__/test_codex_reset_fetch.cpython-312.pyc +0 -0
  52. package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
  53. package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
  54. package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
  55. package/tests/__pycache__/test_selector.cpython-312.pyc +0 -0
  56. package/tests/__pycache__/test_selector_cached_credits.cpython-312.pyc +0 -0
  57. package/tests/__pycache__/test_selector_credit_scope.cpython-312.pyc +0 -0
  58. package/tests/__pycache__/test_selector_credit_shim.cpython-312.pyc +0 -0
  59. package/tests/__pycache__/test_selector_credits.cpython-312.pyc +0 -0
  60. package/tests/__pycache__/test_selector_spread.cpython-312.pyc +0 -0
  61. package/tests/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
  62. package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
  63. package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
  64. package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
  65. package/tests/autoresume_tmux_support.py +135 -0
  66. package/tests/fake_codex_tui.py +81 -0
  67. package/tests/run-tests.sh +10 -1
  68. package/tests/test_autoresume.py +7 -0
  69. package/tests/test_autoresume_codex_records.py +74 -0
  70. package/tests/test_autoresume_cursor.py +87 -0
  71. package/tests/test_autoresume_mode.py +89 -0
  72. package/tests/test_autoresume_receipt.py +117 -0
  73. package/tests/test_autoresume_tmux.py +100 -0
  74. package/tests/test_autoresume_tmux_modes.py +80 -0
@@ -0,0 +1,135 @@
1
+ """Real, isolated tmux fixtures; no provider CLI, credentials, network or schedulers."""
2
+
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+ import shlex
7
+ import shutil
8
+ import signal
9
+ import subprocess
10
+ import sys
11
+ import tempfile
12
+ import time
13
+
14
+ ROOT = Path(__file__).resolve().parents[1]
15
+ SID = "01a0cb0f-a719-7461-bba6-a59bdf2412cc"
16
+ # The shell suite supplies fake tmux on PATH for its own tests; these need the real server.
17
+ ORIGINAL_PATH = os.environ.get("MULTIACC_TEST_ORIGINAL_PATH", os.environ["PATH"])
18
+ TMUX = shutil.which("tmux", path=ORIGINAL_PATH)
19
+
20
+
21
+ def seed_pool(home):
22
+ pool = home / "pool"
23
+ pool.mkdir()
24
+ sessions = home / ".codex" / "sessions"
25
+ sessions.mkdir(parents=True)
26
+ (pool / "accounts.json").write_text('{"accounts":[],"threshold":90}')
27
+ (pool / ".limits-kick").touch()
28
+ for name, percent in (("acct-01", 10), ("acct-02", 30)):
29
+ account = pool / name
30
+ account.mkdir()
31
+ (account / "auth.json").write_text('{"tokens":{"access_token":"test-only"}}')
32
+ (account / "sessions").symlink_to(sessions, target_is_directory=True)
33
+ (account / "limits.json").write_text(json.dumps({"fetched_at": int(time.time()),
34
+ "max_percent": percent, "weekly_percent": percent, "session_percent": 10}, indent=1))
35
+ return pool
36
+
37
+
38
+ def test_environment(home, pool, overrides):
39
+ fake = home / "fake"
40
+ fake.mkdir()
41
+ binary = fake / "codex"
42
+ binary.write_text("#!" + sys.executable + "\n" +
43
+ (ROOT / "tests/fake_codex_tui.py").read_text())
44
+ binary.chmod(0o755)
45
+ env = {"HOME": str(home), "PATH": str(fake) + os.pathsep + ORIGINAL_PATH,
46
+ "TERM": "xterm-256color", "SHELL": "/bin/bash", "LC_ALL": "C", "FAKE_HOME": str(home),
47
+ "CODEX_ACCOUNTS_ROOT": str(pool), "CODEX_MULTIACC_PYTHON": sys.executable,
48
+ "CODEX_MULTIACC_AUTORESUME": "1", "CODEX_MULTIACC_AUTORESUME_DEBUG": "1",
49
+ "CODEX_MULTIACC_MCP": "0", "CODEX_MULTIACC_AUTO_RESET": "0", "CODEX_MULTIACC_NO_SYNC": "1",
50
+ "CODEX_MULTIACC_CLIENT_LIMITS": "0", "CODEX_MULTIACC_HEADROOM_BAND": "0",
51
+ "CODEX_MULTIACC_USAGE_URL": "file:///nonexistent", "CODEX_MULTIACC_TOKEN_URL": "file:///nonexistent",
52
+ "CODEX_MULTIACC_AR_POLL": "0.05", "CODEX_MULTIACC_AR_GRACE": "0.4",
53
+ "CODEX_MULTIACC_AR_HOLD_REPROBE": "0.3", "CODEX_MULTIACC_AR_TERM_GRACE": "0.3",
54
+ "CODEX_MULTIACC_AR_PANE_WAIT": "4", "CODEX_MULTIACC_AR_RELAUNCH_WAIT": "5",
55
+ "CODEX_MULTIACC_AR_PROMPT_WAIT": "2",
56
+ "CODEX_MULTIACC_AR_TRANSIENT_LADDER": "0.4", "FAKE_SID": SID}
57
+ env.update(overrides)
58
+ return env
59
+
60
+
61
+ class TmuxScenario:
62
+ def __init__(self, **overrides):
63
+ self.temp = tempfile.TemporaryDirectory(prefix="ar-tmux-", dir="/tmp")
64
+ self.home = Path(self.temp.name).resolve()
65
+ self.pool = seed_pool(self.home)
66
+ self.work = self.home / "work"
67
+ self.work.mkdir()
68
+ self.sock = self.home / "tmux.sock"
69
+ self.env = test_environment(self.home, self.pool, overrides)
70
+ self.pane = "%0"
71
+ self.log = self.pool / "selection.log"
72
+ self.rollout = self.home / ".codex/sessions/2026/10/10" / ("rollout-test-" + SID + ".jsonl")
73
+
74
+ def tmux(self, *args, check=True):
75
+ result = subprocess.run([TMUX, "-S", str(self.sock), "-f", "/dev/null", *args],
76
+ env=self.env, capture_output=True, text=True, timeout=10)
77
+ if check and result.returncode:
78
+ raise AssertionError(result.stderr)
79
+ return result.stdout.strip()
80
+
81
+ def start(self, args=None, supervised=True):
82
+ self.pane = self.tmux("new-session", "-d", "-P", "-F", "#{pane_id}", "-s", "case",
83
+ "-c", str(self.work), "/bin/bash", "--noprofile", "--norc", "-i")
84
+ command = shlex.join([str(ROOT / "bin/codex"), *(args if args is not None else ["--yolo"])])
85
+ self.tmux("send-keys", "-t", self.pane, "-l", command)
86
+ self.tmux("send-keys", "-t", self.pane, "Enter")
87
+ self.wait(lambda: len(self.records("ready")) == 1, "first fake TUI ready")
88
+ if supervised:
89
+ self.wait(lambda: "autoresume watch" in self.log.read_text(), "watcher started")
90
+
91
+ def records(self, kind=None):
92
+ path = self.home / "provider.jsonl"
93
+ try:
94
+ rows = [json.loads(line) for line in path.read_text().splitlines()]
95
+ except (OSError, ValueError):
96
+ return []
97
+ return [row for row in rows if kind is None or row["kind"] == kind]
98
+
99
+ def append(self, payload, age=0):
100
+ timestamp = time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime(time.time() - age)) + ".999Z"
101
+ with self.rollout.open("a") as target:
102
+ target.write(json.dumps({"timestamp": timestamp, "type": "event_msg", "payload": payload}) + "\n")
103
+
104
+ def wait(self, predicate, label, timeout=12):
105
+ deadline = time.monotonic() + timeout
106
+ while time.monotonic() < deadline:
107
+ if predicate():
108
+ return
109
+ time.sleep(0.05)
110
+ logs = self.log.read_text() if self.log.exists() else "no selection log"
111
+ logs += "\n" + self.tmux("capture-pane", "-p", "-t", self.pane, check=False)
112
+ for path in (self.pool / "tmp/autoresume").glob("*.log"):
113
+ logs += "\n" + path.read_text()
114
+ raise AssertionError(label + " timed out:\n" + logs)
115
+
116
+ def close(self):
117
+ if not self.home.exists():
118
+ return
119
+ (self.pool / "autoresume.off").touch()
120
+ deadline = time.monotonic() + 3
121
+ while list((self.pool / "tmp/autoresume").glob("*.state")) and time.monotonic() < deadline:
122
+ time.sleep(0.05)
123
+ if self.sock.exists():
124
+ self.tmux("kill-server", check=False)
125
+ processes = subprocess.run(["ps", "-A", "-o", "pid=,args="], capture_output=True,
126
+ text=True, env=self.env, timeout=5).stdout
127
+ owned = "watch --state " + str(self.pool / "tmp/autoresume") + "/"
128
+ for row in processes.splitlines():
129
+ parts = row.strip().split(None, 1)
130
+ if len(parts) == 2 and owned in parts[1]:
131
+ try:
132
+ os.kill(int(parts[0]), signal.SIGTERM)
133
+ except ProcessLookupError:
134
+ pass
135
+ self.temp.cleanup()
@@ -0,0 +1,81 @@
1
+ """A fake foreground Codex that holds its rollout open and records every received key."""
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
+
13
+
14
+ def record(kind, **fields):
15
+ with (Path(os.environ["FAKE_HOME"]) / "provider.jsonl").open("a") as target:
16
+ target.write(json.dumps(dict(kind=kind, pid=os.getpid(), **fields)) + "\n")
17
+
18
+
19
+ def stop(signum, _frame):
20
+ record("signal", signal=signum)
21
+ if os.environ.get("FAKE_IGNORE_TERM") == "1" and signum == signal.SIGTERM:
22
+ return
23
+ raise SystemExit(0)
24
+
25
+
26
+ def initialize_rollout():
27
+ sid = os.environ["FAKE_SID"]
28
+ directory = Path(os.environ["CODEX_HOME"]) / "sessions/2026/10/10"
29
+ directory.mkdir(parents=True, exist_ok=True)
30
+ rollout = (directory / ("rollout-test-" + sid + ".jsonl")).open("a+")
31
+ if rollout.tell() == 0:
32
+ stamp = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
33
+ rollout.write(json.dumps({"timestamp": stamp, "type": "session_meta", "payload": {
34
+ "id": sid, "session_id": sid, "timestamp": stamp,
35
+ "cwd": os.getcwd(), "source": "cli"}}) + "\n")
36
+ local = "--no-daemon" in sys.argv or "FAKE_LEGACY" in os.environ
37
+ if "resume" in sys.argv and sid in sys.argv and local and "FAKE_DROP_PROMPT" not in os.environ:
38
+ prompt = sys.argv[-1]
39
+ if " " in prompt:
40
+ stamp = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
41
+ rollout.write(json.dumps({"timestamp": stamp, "type": "response_item", "payload": {
42
+ "type": "message", "role": "user",
43
+ "content": [{"type": "input_text", "text": prompt}]}}) + "\n")
44
+ rollout.write(json.dumps({"timestamp": stamp, "type": "event_msg",
45
+ "payload": {"type": "task_started"}}) + "\n")
46
+ record("prompt", text=prompt)
47
+ rollout.flush()
48
+ if not local:
49
+ rollout.close() # Modern daemon owns the writer; the foreground client has no rollout fd.
50
+ return rollout
51
+
52
+
53
+ def main():
54
+ if sys.argv[1:] == ["--help"]:
55
+ print("Usage: codex [OPTIONS]\n" + (" --no-daemon Run locally" if "FAKE_LEGACY" not in os.environ else ""))
56
+ return
57
+ if sys.argv[1:] == ["--version"]:
58
+ print("codex-cli test")
59
+ return
60
+ record("launch", argv=sys.argv[1:], cwd=os.getcwd(), account=Path(os.environ["CODEX_HOME"]).name)
61
+ settings = termios.tcgetattr(0)
62
+ signal.signal(signal.SIGTERM, stop)
63
+ signal.signal(signal.SIGHUP, stop)
64
+ rollout = initialize_rollout()
65
+ try:
66
+ tty.setraw(0)
67
+ record("ready")
68
+ print("FAKE CODEX READY", flush=True)
69
+ while True:
70
+ if select.select([0], [], [], 0.1)[0]:
71
+ data = os.read(0, 4096)
72
+ if not data:
73
+ return
74
+ record("keys", data=data.hex())
75
+ finally:
76
+ rollout.close()
77
+ termios.tcsetattr(0, termios.TCSANOW, settings)
78
+
79
+
80
+ if __name__ == "__main__":
81
+ main()
@@ -3,6 +3,7 @@
3
3
  # No network, no real accounts, no quota: fake `claude`/`codex` binaries + file://
4
4
  # usage/token fixtures.
5
5
  set -u
6
+ export MULTIACC_TEST_ORIGINAL_PATH="$PATH"
6
7
 
7
8
  REPO_DIR="$(cd "$(dirname "$0")/.." && pwd -P)"
8
9
  WORK="$(mktemp -d "${TMPDIR:-/tmp}/multiacc-test.XXXXXX")" || exit 1
@@ -5156,6 +5157,12 @@ exec 9>>"$ro"
5156
5157
  trap 'exit 0' TERM
5157
5158
  trap 'exit 0' HUP
5158
5159
  if [ "$resumed" = 1 ]; then
5160
+ python3 - "$ro" "${!#}" <<'PY'
5161
+ import json, sys
5162
+ with open(sys.argv[1], 'a') as stream:
5163
+ stream.write(json.dumps({'type': 'event_msg', 'payload': {
5164
+ 'type': 'user_message', 'message': sys.argv[2]}}) + '\n')
5165
+ PY
5159
5166
  [ -n "${FAKE_TUI_QUIET:-}" ] \
5160
5167
  || printf '{"timestamp":"%s","type":"event_msg","payload":{"type":"task_started","turn_id":"2"}}\n' "$(ts)" >&9
5161
5168
  else
@@ -5179,6 +5186,7 @@ EOF
5179
5186
  cat > "$FAKEBIN/codex" <<EOF
5180
5187
  #!/usr/bin/env bash
5181
5188
  # fake real codex for tests (not a shim)
5189
+ if [ "\${1:-}" = --help ]; then echo 'Usage: legacy codex [OPTIONS]'; exit 0; fi
5182
5190
  if [ "\${1:-}" = "mcp" ] && [ -n "\${CODEX_HOME:-}" ]; then
5183
5191
  python3 "$FAKE_CODEX_MCP" "\$CODEX_HOME/config.toml" "\$@"
5184
5192
  rc=\$?
@@ -9237,7 +9245,8 @@ if ar_wait 30 ar_lines 2 "$ARB/tui.log"; then
9237
9245
  && t_ok "AR codex quota: the old account gets the 10-minute error-cooldown, never client:7d" \
9238
9246
  || t_fail "AR codex quota: cooldown marker" "$m1 / $m2"
9239
9247
  ar_logged "$ARX" "autoresume relaunch chain=[A-Za-z0-9]+ from=$from class=quota depth=1\$" \
9240
- && ar_logged "$ARX" "autoresume switch provider=codex chain=[A-Za-z0-9]+ from=$from class=quota sid=$AR_CXSID depth=1 probe=$to" \
9248
+ && ar_wait 5 ar_logged "$ARX" \
9249
+ "autoresume switch provider=codex chain=[A-Za-z0-9]+ from=$from class=quota sid=$AR_CXSID depth=1 probe=$to" \
9241
9250
  && t_ok "AR codex quota: switch + relaunch logged" \
9242
9251
  || t_fail "AR codex quota: log lines" "$(grep autoresume "$ARX/selection.log")"
9243
9252
  ar_typed_ok CODEX "$AR_CXSID" "CODEX_ACCOUNTS_ROOT=$(cd "$ARX" && pwd)" "$REPO_DIR/bin/codex" \
@@ -1033,6 +1033,13 @@ class FakeRunner:
1033
1033
  except OSError:
1034
1034
  return
1035
1035
  self.consumed[tok] = (text, data)
1036
+ fields = ar.parse_kv(text, ar.RELAUNCH_KEYS)
1037
+ if fields.get("provider") == "codex":
1038
+ for path in Path(self.ar_dir).parent.parent.glob("acct-*/sessions/*/*/*/rollout-*.jsonl"):
1039
+ if fields["sid"] in path.name:
1040
+ append(path, [cx_event(self.t, {"type": "user_message",
1041
+ "message": ar.decode_argv(data)[-1]})])
1042
+ break
1036
1043
  ar.remove_quietly(base + ".relaunch", base + ".argv")
1037
1044
 
1038
1045
  def run(self, argv, timeout, env=None, cwd=None):
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env python3
2
+ """Replay current Codex rollout shapes through the complete watcher, without a real pool."""
3
+
4
+ import signal
5
+ import unittest
6
+
7
+ import test_autoresume as fixtures
8
+
9
+
10
+ class CurrentCodexRecordsTests(unittest.TestCase):
11
+ def setUp(self):
12
+ self.pool = fixtures.CodexWatcherTests(methodName="runTest")
13
+ self.pool.setUp()
14
+ self.addCleanup(self.pool.tearDown)
15
+
16
+ def records(self):
17
+ pool = self.pool
18
+ reset = int(pool.t0) + 86400
19
+ meta = fixtures.cx_meta(pool.launched - 3600, cwd=str(pool.work))
20
+ meta["payload"]["cli_version"] = "0.162.1"
21
+ error = fixtures.cx_error(pool.t0 - 1, "usage_limit_exceeded")
22
+ error["payload"].update(root_turn_id=error["payload"]["turn_id"],
23
+ time_to_first_token_ms=31)
24
+ return [meta, fixtures.cx_tokens(pool.t0 - 2, primary={
25
+ "used_percent": 100.0, "window_minutes": 10080, "resets_at": reset}),
26
+ fixtures.cx_tokens(pool.t0 - 1, limit_id="premium"), error], reset
27
+
28
+ def test_current_epoch_error_and_weekly_snapshot_resume_same_session(self):
29
+ pool = self.pool
30
+ records, reset = self.records()
31
+ rc, watcher, runner, _ = pool.run_codex(["resume", fixtures.CX_SID], records)
32
+ self.assertEqual(rc, 0)
33
+ fields, argv = pool.relaunch_of(runner)
34
+ self.assertEqual(fields["sid"], fixtures.CX_SID)
35
+ self.assertEqual(fields["reset"], str(reset))
36
+ self.assertEqual(argv, ["resume", fixtures.CX_SID, fixtures.ar.resume_prompt("quota")])
37
+ self.assertEqual(watcher.tracker.pending["rtype_window"], "7d")
38
+ self.assertEqual(runner.signals, [(pool.PID, signal.SIGTERM)])
39
+ self.assertEqual(pool.events(), ["watch", "detect", "switch"])
40
+
41
+ def test_paginated_user_turn_cancels_before_restart(self):
42
+ pool = self.pool
43
+ records, _ = self.records()
44
+
45
+ def setup(watcher, runner, path):
46
+ started = fixtures.cx_event(pool.t0 + 1, {"type": "task_started"})
47
+ user = {"timestamp": fixtures.iso(pool.t0 + 1), "type": "response_item",
48
+ "payload": {"type": "message", "role": "user", "content": [
49
+ {"type": "input_text", "text": "Resume the current task"}]}}
50
+ runner.at(pool.t0 + 1, lambda: fixtures.append(path, [started, user]))
51
+ runner.at(pool.t0 + 10, lambda: runner.exit(pool.PID))
52
+
53
+ rc, watcher, runner, _ = pool.run_codex(["resume", fixtures.CX_SID], records, setup=setup)
54
+ self.assertEqual(rc, 0)
55
+ self.assertIsNone(watcher.tracker.pending)
56
+ self.assertEqual((runner.probes, runner.signals, runner.sends), ([], [], []))
57
+
58
+ def test_old_epoch_error_in_resumed_paginated_rollout_is_ignored(self):
59
+ pool = self.pool
60
+ records, _ = self.records()
61
+ records[-1]["payload"]["completed_at"] = pool.launched - 3600
62
+
63
+ def setup(watcher, runner, path):
64
+ runner.at(pool.t0 + 10, lambda: runner.exit(pool.PID))
65
+
66
+ rc, watcher, runner, _ = pool.run_codex(["resume", fixtures.CX_SID], records, setup=setup)
67
+ self.assertEqual(rc, 0)
68
+ self.assertIsNone(watcher.tracker.pending)
69
+ self.assertEqual((runner.probes, runner.signals, runner.sends), ([], [], []))
70
+ self.assertEqual(pool.events(), ["watch"])
71
+
72
+
73
+ if __name__ == "__main__":
74
+ unittest.main()
@@ -0,0 +1,87 @@
1
+ """Relaunched watchers ignore exactly the previous client's persisted rollout bytes."""
2
+
3
+ from pathlib import Path
4
+ import signal
5
+ import unittest
6
+
7
+ import test_autoresume as fixtures
8
+ from autoresume_receipt import apply_cursor, cursor_value
9
+
10
+
11
+ class CursorTests(unittest.TestCase):
12
+ def setUp(self):
13
+ self.pool = fixtures.CodexWatcherTests(methodName="runTest")
14
+ self.pool.setUp()
15
+ self.addCleanup(self.pool.tearDown)
16
+
17
+ def resumed_watcher(self):
18
+ pool = self.pool
19
+ state = pool.write_state(["resume", fixtures.CX_SID], provider="codex", launched=int(pool.t0))
20
+ path = pool.rollout([fixtures.cx_meta(pool.t0 - 3600, cwd=str(pool.work)),
21
+ fixtures.cx_error(pool.t0, "usage_limit_exceeded")])
22
+ info = path.stat()
23
+ cursor = "%d:%d" % (info.st_ino, info.st_size)
24
+ state_path = Path(state)
25
+ state_path.write_text(state_path.read_text() + "rollout_cursor=" + cursor + "\n")
26
+ watcher, runner = pool.watcher(state, {"CODEX_MULTIACC_AR_GRACE": "0.1",
27
+ "CODEX_MULTIACC_AR_POLL": "0.05"})
28
+ runner.pane_cmd = "node"
29
+ return watcher, runner, path, cursor
30
+
31
+ def test_same_second_previous_quota_does_not_restart_new_client(self):
32
+ watcher, runner, _, cursor = self.resumed_watcher()
33
+ runner.at(self.pool.t0 + 1, lambda: runner.exit(self.pool.PID))
34
+ self.assertEqual(watcher.run(), 0)
35
+ self.assertEqual(watcher.st["rollout_cursor"], cursor)
36
+ self.assertIsNone(watcher.tracker.pending)
37
+ self.assertEqual((runner.probes, runner.signals, runner.sends), ([], [], []))
38
+ self.assertEqual(self.pool.events(), ["watch"])
39
+
40
+ def test_new_quota_after_cursor_restarts_even_with_same_timestamp(self):
41
+ watcher, runner, path, _ = self.resumed_watcher()
42
+ runner.at(self.pool.t0 + 0.2, lambda: fixtures.append(path, [
43
+ fixtures.cx_error(self.pool.t0, "usage_limit_exceeded")]))
44
+ self.assertEqual(watcher.run(), 0)
45
+ self.assertEqual(self.pool.events(), ["watch", "detect", "switch"])
46
+ self.assertEqual(runner.signals, [(self.pool.PID, signal.SIGTERM)])
47
+ fields, argv = self.pool.relaunch_of(runner)
48
+ inode, offset = cursor_value(fields["rollout_cursor"])
49
+ self.assertEqual(inode, path.stat().st_ino)
50
+ self.assertLess(offset, path.stat().st_size)
51
+ self.assertEqual(argv[-2], fixtures.CX_SID)
52
+
53
+ def test_replaced_rollout_refuses_without_stopping_client(self):
54
+ watcher, runner, path, _ = self.resumed_watcher()
55
+ replacement = path.with_suffix(".replacement")
56
+ replacement.write_bytes(path.read_bytes())
57
+ replacement.replace(path)
58
+ self.assertEqual(watcher.run(), 0)
59
+ self.assertEqual(self.pool.events(), ["watch", "giveup"])
60
+ self.assertIn("reason=rollout-replaced", self.pool.log_lines()[-1])
61
+ self.assertEqual((runner.probes, runner.signals, runner.sends), ([], [], []))
62
+
63
+ def test_truncated_rollout_refuses_without_stopping_client(self):
64
+ watcher, runner, path, _ = self.resumed_watcher()
65
+ path.write_bytes(path.read_bytes().splitlines(keepends=True)[0])
66
+ self.assertEqual(watcher.run(), 0)
67
+ self.assertIn("reason=rollout-replaced", self.pool.log_lines()[-1])
68
+ self.assertEqual((runner.probes, runner.signals, runner.sends), ([], [], []))
69
+
70
+ def test_absent_cursor_preserves_legacy_behavior(self):
71
+ _, _, path, _ = self.resumed_watcher()
72
+ for cursor in (None, ""):
73
+ tail = fixtures.ar.JsonlTail(path)
74
+ self.assertTrue(apply_cursor(tail, cursor))
75
+ self.assertEqual(tail.off, 0)
76
+ self.assertEqual(len(tail.read_lines()), 2)
77
+
78
+ def test_malformed_present_cursor_refuses_instead_of_replaying_history(self):
79
+ _, _, path, _ = self.resumed_watcher()
80
+ for cursor in ("1::2", "1:2:3", "1", "-1:2", "1:-2", "1:a", "9" * 21 + ":2"):
81
+ with self.subTest(cursor=cursor):
82
+ self.assertIsNone(cursor_value(cursor))
83
+ self.assertFalse(apply_cursor(fixtures.ar.JsonlTail(path), cursor))
84
+
85
+
86
+ if __name__ == "__main__":
87
+ unittest.main()
@@ -0,0 +1,89 @@
1
+ #!/usr/bin/env python3
2
+ """Capability detection uses disposable local CLIs, never account credentials."""
3
+
4
+ import importlib.util
5
+ from pathlib import Path
6
+ import subprocess
7
+ import sys
8
+ import tempfile
9
+ import time
10
+ import unittest
11
+
12
+
13
+ LIB = Path(__file__).resolve().parents[1] / "lib" / "codex_autoresume_mode.py"
14
+ SPEC = importlib.util.spec_from_file_location("codex_autoresume_mode", LIB)
15
+ mode = importlib.util.module_from_spec(SPEC)
16
+ SPEC.loader.exec_module(mode)
17
+
18
+
19
+ class NativeModeTests(unittest.TestCase):
20
+ def setUp(self):
21
+ self.scratch = tempfile.TemporaryDirectory(prefix="codex-ar-mode-")
22
+ self.addCleanup(self.scratch.cleanup)
23
+ self.root = Path(self.scratch.name)
24
+ self.executable = self.root / "codex"
25
+
26
+ def executable_with(self, body):
27
+ self.executable.write_text("#!" + sys.executable + "\n" + body)
28
+ self.executable.chmod(0o700)
29
+ return str(self.executable)
30
+
31
+ def test_advertised_option_is_detected_and_cli_only_outputs_flag(self):
32
+ executable = self.executable_with(
33
+ "import sys\nassert sys.argv[1:] == ['--help']\n"
34
+ "print('Options:\\n --no-daemon\\n Run without shared server')\n"
35
+ )
36
+ self.assertTrue(mode.supports_no_daemon(executable))
37
+ result = subprocess.run([sys.executable, "-I", str(LIB), executable],
38
+ capture_output=True, text=True, timeout=5)
39
+ self.assertEqual((result.returncode, result.stdout, result.stderr), (0, "--no-daemon\n", ""))
40
+
41
+ def test_old_cli_and_mentions_without_option_are_not_supported(self):
42
+ for help_text in ("Options:\n --help", "Use --no-daemon if available", " --no-daemon-other"):
43
+ with self.subTest(help_text=help_text):
44
+ executable = self.executable_with("print(%r)\n" % help_text)
45
+ self.assertFalse(mode.supports_no_daemon(executable))
46
+
47
+ def test_failed_help_and_missing_binary_fail_open(self):
48
+ executable = self.executable_with("print(' --no-daemon')\nraise SystemExit(2)\n")
49
+ self.assertFalse(mode.supports_no_daemon(executable))
50
+ self.assertFalse(mode.supports_no_daemon(str(self.root / "missing")))
51
+
52
+ def test_updates_are_checked_without_stale_capability_cache(self):
53
+ executable = self.executable_with("print(' --help')\n")
54
+ self.assertFalse(mode.supports_no_daemon(executable))
55
+ self.executable_with("print(' --no-daemon')\n")
56
+ self.assertTrue(mode.supports_no_daemon(executable))
57
+ self.executable_with("print(' --help')\n")
58
+ self.assertFalse(mode.supports_no_daemon(executable))
59
+
60
+ def test_large_output_and_stderr_do_not_advertise_option(self):
61
+ executable = self.executable_with(
62
+ "import sys\nsys.stdout.write('x' * %d + '\\n --no-daemon\\n')\n"
63
+ "sys.stderr.write(' --no-daemon\\n')\n" % mode.MAX_HELP_BYTES
64
+ )
65
+ self.assertFalse(mode.supports_no_daemon(executable))
66
+
67
+ def test_timeout_stops_probe_and_its_child_without_touching_other_processes(self):
68
+ marker = self.root / "child-finished"
69
+ executable = self.executable_with(
70
+ "import os, time\n"
71
+ "if os.fork() == 0:\n"
72
+ " time.sleep(0.5)\n"
73
+ " open(%r, 'w').write('unexpected')\n"
74
+ " os._exit(0)\n"
75
+ "time.sleep(20)\n" % str(marker)
76
+ )
77
+ other = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(20)"])
78
+ self.addCleanup(other.wait)
79
+ self.addCleanup(other.terminate)
80
+ started = time.monotonic()
81
+ self.assertFalse(mode.supports_no_daemon(executable, timeout=0.1))
82
+ self.assertLess(time.monotonic() - started, 2)
83
+ time.sleep(0.6)
84
+ self.assertFalse(marker.exists())
85
+ self.assertIsNone(other.poll())
86
+
87
+
88
+ if __name__ == "__main__":
89
+ unittest.main()
@@ -0,0 +1,117 @@
1
+ """Receipt checks require newly persisted user input in the bound conversation."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+ import signal
6
+ import tempfile
7
+ from types import SimpleNamespace
8
+ import unittest
9
+ from unittest import mock
10
+
11
+ import test_autoresume as fixtures
12
+ from autoresume_receipt import codex_prompt_record, confirm_prompt, receipt_tail
13
+
14
+
15
+ class ReceiptTests(unittest.TestCase):
16
+ def setUp(self):
17
+ temporary = tempfile.TemporaryDirectory()
18
+ self.addCleanup(temporary.cleanup)
19
+ self.path = Path(temporary.name) / "rollout.jsonl"
20
+ self.path.write_bytes(b"{}\n")
21
+ self.prompt = fixtures.ar.resume_prompt("quota")
22
+ self.record = json.dumps({"type": "event_msg", "payload": {
23
+ "type": "user_message", "message": self.prompt}}).encode() + b"\n"
24
+ self.runner = fixtures.FakeRunner(100, 4242, 777, "/fake/codex")
25
+ self.watcher = SimpleNamespace(r=self.runner, switched_off=lambda: False,
26
+ cfg=SimpleNamespace(prompt_wait=0.5, poll=0.05))
27
+
28
+ def test_new_exact_user_records_confirm(self):
29
+ response = json.dumps({"type": "response_item", "payload": {
30
+ "type": "message", "role": "user", "content": [
31
+ {"type": "input_text", "text": self.prompt}]}}).encode() + b"\n"
32
+ for raw in (self.record, response):
33
+ with self.subTest(raw=raw):
34
+ tail = receipt_tail(self.path, fixtures.ar.JsonlTail)
35
+ self.path.write_bytes(self.path.read_bytes() + raw)
36
+ self.assertTrue(confirm_prompt(self.watcher, tail, self.prompt))
37
+
38
+ def test_wrong_quoted_and_malformed_records_do_not_confirm(self):
39
+ records = [None, [], {}, {"type": "event_msg", "payload": []}]
40
+ records += [{"type": "event_msg", "payload": {"type": kind, "message": self.prompt}}
41
+ for kind in ("agent_message", "task_complete", "error")]
42
+ records += [{"type": "response_item", "payload": {
43
+ "type": "message", "role": role, "content": [
44
+ {"type": "input_text", "text": self.prompt}]}}
45
+ for role in ("assistant", "tool", "system")]
46
+ records += [{"type": "event_msg", "payload": {"type": "user_message", "message": text}}
47
+ for text in (self.prompt + " extra", "quoted: " + self.prompt, self.prompt[:40])]
48
+ for record in records:
49
+ self.assertFalse(codex_prompt_record(json.dumps(record), self.prompt), record)
50
+ for raw in (b"{", b"\xff", None):
51
+ self.assertFalse(codex_prompt_record(raw, self.prompt))
52
+
53
+ def test_historical_identical_prompt_is_not_a_receipt(self):
54
+ self.path.write_bytes(self.record)
55
+ tail = receipt_tail(self.path, fixtures.ar.JsonlTail)
56
+ self.assertFalse(confirm_prompt(self.watcher, tail, self.prompt))
57
+ self.assertGreaterEqual(self.runner.now(), 100.5)
58
+
59
+ def test_partial_json_line_waits_for_completion(self):
60
+ tail = receipt_tail(self.path, fixtures.ar.JsonlTail)
61
+ prefix = self.path.read_bytes()
62
+ self.path.write_bytes(prefix + self.record[:-1])
63
+ self.runner.at(100.2, lambda: self.path.write_bytes(prefix + self.record))
64
+ self.assertTrue(confirm_prompt(self.watcher, tail, self.prompt))
65
+ self.assertGreaterEqual(self.runner.now(), 100.2)
66
+
67
+ def test_delayed_new_prompt_is_confirmed(self):
68
+ tail = receipt_tail(self.path, fixtures.ar.JsonlTail)
69
+ self.runner.at(100.3, lambda: self.path.write_bytes(self.path.read_bytes() + self.record))
70
+ self.assertTrue(confirm_prompt(self.watcher, tail, self.prompt))
71
+ self.assertGreaterEqual(self.runner.now(), 100.3)
72
+
73
+ def test_replaced_or_truncated_rollout_is_rejected(self):
74
+ for replace in (False, True):
75
+ with self.subTest(replace=replace):
76
+ self.path.write_bytes(self.record * 3)
77
+ tail = receipt_tail(self.path, fixtures.ar.JsonlTail)
78
+ replacement = self.path.with_suffix(".replacement") if replace else self.path
79
+ replacement.write_bytes(self.record)
80
+ if replace:
81
+ replacement.replace(self.path)
82
+ self.assertFalse(confirm_prompt(self.watcher, tail, self.prompt))
83
+
84
+ def test_missing_file_and_kill_switch_stop_waiting(self):
85
+ tail = receipt_tail(self.path, fixtures.ar.JsonlTail)
86
+ self.watcher.switched_off = lambda: True
87
+ self.assertFalse(confirm_prompt(self.watcher, tail, self.prompt))
88
+ self.path.unlink()
89
+ self.assertFalse(confirm_prompt(self.watcher, tail, self.prompt))
90
+ self.assertEqual(self.runner.now(), 100)
91
+
92
+ def test_receipt_requires_an_existing_rollout(self):
93
+ self.assertIsNone(receipt_tail(None, fixtures.ar.JsonlTail))
94
+ self.assertIsNone(receipt_tail(self.path.with_name("missing"), fixtures.ar.JsonlTail))
95
+
96
+ def test_consumed_token_without_prompt_never_reports_switch(self):
97
+ pool = fixtures.CodexWatcherTests(methodName="runTest")
98
+ pool.setUp()
99
+ self.addCleanup(pool.tearDown)
100
+ records = [fixtures.cx_meta(pool.launched - 3600, cwd=str(pool.work)),
101
+ fixtures.cx_event(pool.launched - 10, {"type": "user_message", "message": self.prompt}),
102
+ fixtures.cx_error(pool.t0, "usage_limit_exceeded")]
103
+ with mock.patch.object(fixtures, "append"):
104
+ rc, _, runner, _ = pool.run_codex(["resume", fixtures.CX_SID], records,
105
+ env={"CODEX_MULTIACC_AR_PROMPT_WAIT": "0.5"})
106
+ self.assertEqual(rc, 0)
107
+ self.assertTrue(runner.consumed)
108
+ self.assertEqual(pool.events(), ["watch", "detect", "giveup"])
109
+ self.assertIn("reason=prompt", pool.log_lines()[-1])
110
+ self.assertIn('resume="codex resume ' + fixtures.CX_SID, pool.log_lines()[-1])
111
+ self.assertEqual(runner.signals, [(pool.PID, signal.SIGTERM)])
112
+ self.assertEqual((len(runner.sends), len(runner.notices)), (4, 1))
113
+ self.assertEqual(pool.relaunch_files(), [])
114
+
115
+
116
+ if __name__ == "__main__":
117
+ unittest.main()