claude-multiacc 2.0.61 → 2.0.64

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 (137) hide show
  1. package/README.md +6 -2
  2. package/bin/codex +30 -7
  3. package/bin/codex-accounts +12 -4
  4. package/docs/ACCOUNT_OPERATIONS.md +2 -2
  5. package/docs/AUTORESUME.md +6 -1
  6. package/docs/CODEX.md +19 -2
  7. package/docs/CODEX_MCP_AUTH.md +58 -0
  8. package/lib/__pycache__/audit.cpython-312.pyc +0 -0
  9. package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
  10. package/lib/__pycache__/autoresume_cross.cpython-312.pyc +0 -0
  11. package/lib/__pycache__/autoresume_cross_dispatch.cpython-312.pyc +0 -0
  12. package/lib/__pycache__/autoresume_cross_receipt.cpython-312.pyc +0 -0
  13. package/lib/__pycache__/autoresume_handoff.cpython-312.pyc +0 -0
  14. package/lib/__pycache__/autoresume_probe.cpython-312.pyc +0 -0
  15. package/lib/__pycache__/autoresume_receipt.cpython-312.pyc +0 -0
  16. package/lib/__pycache__/claude_client_error.cpython-312.pyc +0 -0
  17. package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
  18. package/lib/__pycache__/claude_token_identity.cpython-312.pyc +0 -0
  19. package/lib/__pycache__/codex_autoresume_mode.cpython-312.pyc +0 -0
  20. package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
  21. package/lib/__pycache__/codex_credit_controls.cpython-312.pyc +0 -0
  22. package/lib/__pycache__/codex_credit_local_scope.cpython-312.pyc +0 -0
  23. package/lib/__pycache__/codex_credit_selection.cpython-312.pyc +0 -0
  24. package/lib/__pycache__/codex_credits.cpython-312.pyc +0 -0
  25. package/lib/__pycache__/codex_limits_lock.cpython-312.pyc +0 -0
  26. package/lib/__pycache__/codex_mcp_oauth.cpython-312.pyc +0 -0
  27. package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
  28. package/lib/__pycache__/codex_resume_cwd.cpython-312.pyc +0 -0
  29. package/lib/__pycache__/codex_trust.cpython-312.pyc +0 -0
  30. package/lib/__pycache__/codex_usage_fetch.cpython-312.pyc +0 -0
  31. package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
  32. package/lib/__pycache__/mcp_mirror.cpython-312.pyc +0 -0
  33. package/lib/__pycache__/mcp_oauth.cpython-312.pyc +0 -0
  34. package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
  35. package/lib/__pycache__/model_catalog.cpython-312.pyc +0 -0
  36. package/lib/__pycache__/model_match.cpython-312.pyc +0 -0
  37. package/lib/__pycache__/report.cpython-312.pyc +0 -0
  38. package/lib/__pycache__/selector_credit_scope.cpython-312.pyc +0 -0
  39. package/lib/__pycache__/selector_credits.cpython-312.pyc +0 -0
  40. package/lib/__pycache__/selector_funding.cpython-312.pyc +0 -0
  41. package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
  42. package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
  43. package/lib/__pycache__/session_settings.cpython-312.pyc +0 -0
  44. package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
  45. package/lib/autoresume.py +6 -5
  46. package/lib/autoresume_cross.py +2 -1
  47. package/lib/autoresume_handoff.py +13 -5
  48. package/lib/codex_exec_stdin.sh +45 -0
  49. package/lib/codex_mcp_oauth.py +204 -0
  50. package/lib/codex_mcp_oauth.sh +29 -0
  51. package/lib/codex_mcp_oauth_locks.py +158 -0
  52. package/lib/codex_mcp_oauth_native.py +102 -0
  53. package/lib/codex_mcp_oauth_store.py +149 -0
  54. package/lib/codex_resume_cwd.py +57 -0
  55. package/lib/codex_retry_process.sh +37 -0
  56. package/lib/common.sh +3 -0
  57. package/package.json +9 -4
  58. package/tests/__pycache__/autoresume_cross_receipt_support.cpython-312.pyc +0 -0
  59. package/tests/__pycache__/autoresume_cross_tmux_environment.cpython-312.pyc +0 -0
  60. package/tests/__pycache__/autoresume_cross_tmux_support.cpython-312.pyc +0 -0
  61. package/tests/__pycache__/autoresume_cross_unit_support.cpython-312.pyc +0 -0
  62. package/tests/__pycache__/autoresume_tmux_support.cpython-312.pyc +0 -0
  63. package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
  64. package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
  65. package/tests/__pycache__/test_autoresume_catalog.cpython-312.pyc +0 -0
  66. package/tests/__pycache__/test_autoresume_catalog_cli.cpython-312.pyc +0 -0
  67. package/tests/__pycache__/test_autoresume_codex_records.cpython-312.pyc +0 -0
  68. package/tests/__pycache__/test_autoresume_codex_trust.cpython-312.pyc +0 -0
  69. package/tests/__pycache__/test_autoresume_cross_claude_holders.cpython-312.pyc +0 -0
  70. package/tests/__pycache__/test_autoresume_cross_guards.cpython-312.pyc +0 -0
  71. package/tests/__pycache__/test_autoresume_cross_holders.cpython-312.pyc +0 -0
  72. package/tests/__pycache__/test_autoresume_cross_origin.cpython-312.pyc +0 -0
  73. package/tests/__pycache__/test_autoresume_cross_policy.cpython-312.pyc +0 -0
  74. package/tests/__pycache__/test_autoresume_cross_receipt.cpython-312.pyc +0 -0
  75. package/tests/__pycache__/test_autoresume_cross_tmux.cpython-312.pyc +0 -0
  76. package/tests/__pycache__/test_autoresume_cross_tmux_races.cpython-312.pyc +0 -0
  77. package/tests/__pycache__/test_autoresume_cross_tokens.cpython-312.pyc +0 -0
  78. package/tests/__pycache__/test_autoresume_cursor.cpython-312.pyc +0 -0
  79. package/tests/__pycache__/test_autoresume_cwd.cpython-312.pyc +0 -0
  80. package/tests/__pycache__/test_autoresume_cwd_handoff.cpython-312.pyc +0 -0
  81. package/tests/__pycache__/test_autoresume_cwd_reader.cpython-312.pyc +0 -0
  82. package/tests/__pycache__/test_autoresume_cwd_tmux.cpython-312.pyc +0 -0
  83. package/tests/__pycache__/test_autoresume_fallback.cpython-312.pyc +0 -0
  84. package/tests/__pycache__/test_autoresume_handoff_argv.cpython-312.pyc +0 -0
  85. package/tests/__pycache__/test_autoresume_handoff_history.cpython-312.pyc +0 -0
  86. package/tests/__pycache__/test_autoresume_handoff_origins.cpython-312.pyc +0 -0
  87. package/tests/__pycache__/test_autoresume_memory.cpython-312.pyc +0 -0
  88. package/tests/__pycache__/test_autoresume_memory_tmux.cpython-312.pyc +0 -0
  89. package/tests/__pycache__/test_autoresume_mode.cpython-312.pyc +0 -0
  90. package/tests/__pycache__/test_autoresume_probe.cpython-312.pyc +0 -0
  91. package/tests/__pycache__/test_autoresume_receipt.cpython-312.pyc +0 -0
  92. package/tests/__pycache__/test_autoresume_relaunch_settings.cpython-312.pyc +0 -0
  93. package/tests/__pycache__/test_autoresume_settings.cpython-312.pyc +0 -0
  94. package/tests/__pycache__/test_autoresume_sidechain.cpython-312.pyc +0 -0
  95. package/tests/__pycache__/test_autoresume_sidechain_tmux.cpython-312.pyc +0 -0
  96. package/tests/__pycache__/test_autoresume_tmux.cpython-312.pyc +0 -0
  97. package/tests/__pycache__/test_autoresume_tmux_modes.cpython-312.pyc +0 -0
  98. package/tests/__pycache__/test_autoresume_trust_tmux.cpython-312.pyc +0 -0
  99. package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
  100. package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
  101. package/tests/__pycache__/test_codex_exec_stdin.cpython-312.pyc +0 -0
  102. package/tests/__pycache__/test_codex_mcp_oauth.cpython-312.pyc +0 -0
  103. package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
  104. package/tests/__pycache__/test_codex_reset_credits.cpython-312.pyc +0 -0
  105. package/tests/__pycache__/test_codex_reset_credits_polling.cpython-312.pyc +0 -0
  106. package/tests/__pycache__/test_codex_reset_credits_report.cpython-312.pyc +0 -0
  107. package/tests/__pycache__/test_codex_reset_fetch.cpython-312.pyc +0 -0
  108. package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
  109. package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
  110. package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
  111. package/tests/__pycache__/test_mcp_mirror.cpython-312.pyc +0 -0
  112. package/tests/__pycache__/test_mcp_mirror_args.cpython-312.pyc +0 -0
  113. package/tests/__pycache__/test_mcp_oauth.cpython-312.pyc +0 -0
  114. package/tests/__pycache__/test_mcp_registry.cpython-312.pyc +0 -0
  115. package/tests/__pycache__/test_selector.cpython-312.pyc +0 -0
  116. package/tests/__pycache__/test_selector_cached_credits.cpython-312.pyc +0 -0
  117. package/tests/__pycache__/test_selector_credit_scope.cpython-312.pyc +0 -0
  118. package/tests/__pycache__/test_selector_credit_shim.cpython-312.pyc +0 -0
  119. package/tests/__pycache__/test_selector_credits.cpython-312.pyc +0 -0
  120. package/tests/__pycache__/test_selector_spread.cpython-312.pyc +0 -0
  121. package/tests/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
  122. package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
  123. package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
  124. package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
  125. package/tests/fake_codex_tui.py +23 -1
  126. package/tests/test_autoresume_cwd.py +71 -0
  127. package/tests/test_autoresume_cwd_handoff.py +92 -0
  128. package/tests/test_autoresume_cwd_reader.py +105 -0
  129. package/tests/test_autoresume_cwd_tmux.py +54 -0
  130. package/tests/test_codex_exec_stdin.py +178 -0
  131. package/tests/test_codex_mcp_oauth.py +146 -0
  132. package/tests/test_codex_mcp_oauth_adopt.py +87 -0
  133. package/tests/test_codex_mcp_oauth_migration.py +141 -0
  134. package/tests/test_codex_mcp_oauth_native.py +109 -0
  135. package/tests/test_codex_mcp_oauth_safety.py +130 -0
  136. package/tests/test_codex_mcp_oauth_shim.py +188 -0
  137. package/tests/test_codex_retry_process.py +143 -0
@@ -0,0 +1,57 @@
1
+ """Recover Codex's saved working directory from its bound main-thread rollout."""
2
+
3
+ import os
4
+
5
+ from session_settings import _record, _reverse_lines
6
+
7
+
8
+ def _directory(value):
9
+ if isinstance(value, str) and os.path.isabs(value) and not any(c in value for c in "\0\n\r"):
10
+ return value
11
+ return None
12
+
13
+
14
+ def _record_cwd(doc, own):
15
+ payload = doc.get("payload") if doc else None
16
+ if not isinstance(payload, dict) or payload.get("thread_id") not in (None, own):
17
+ return None
18
+ if doc.get("type") == "turn_context":
19
+ turn, root = payload.get("turn_id"), payload.get("root_turn_id")
20
+ if turn and root and turn != root:
21
+ return None
22
+ return _directory(payload.get("cwd"))
23
+ if doc.get("type") == "event_msg" and payload.get("type") == "thread_settings_applied":
24
+ settings = payload.get("thread_settings")
25
+ return _directory(settings.get("cwd")) if isinstance(settings, dict) else None
26
+ return None
27
+
28
+
29
+ def codex_resume_cwd(path, sid=None, upto=None):
30
+ """Latest saved main-thread cwd, then its initial cwd; unknown/unreadable gives None.
31
+
32
+ Match the native resume picker: a settings change can update cwd before the next
33
+ turn_context. Read independently of the watcher's quota cursor so chained restarts
34
+ still recover settings written before that cursor. Only the first metadata record
35
+ identifies this thread; a fork can contain its parent's metadata later in the file.
36
+ ``upto`` bounds history to a validated handoff snapshot's byte boundary.
37
+ """
38
+ if not path:
39
+ return None
40
+ try:
41
+ with open(path, "rb") as fh:
42
+ meta = _record(fh.readline(4 << 20))
43
+ payload = meta.get("payload") if meta and meta.get("type") == "session_meta" else None
44
+ if not isinstance(payload, dict) or not isinstance(payload.get("id"), str):
45
+ return None
46
+ own = payload["id"]
47
+ if sid is not None and sid != own:
48
+ return None
49
+ for line in _reverse_lines(path, upto):
50
+ if b'"cwd"' not in line:
51
+ continue
52
+ directory = _record_cwd(_record(line), own)
53
+ if directory is not None:
54
+ return directory
55
+ return _directory(payload.get("cwd"))
56
+ except (OSError, ValueError):
57
+ return None
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env bash
2
+ # Sourced only by the buffered exec retry path. The caller keeps prompt input on fd 3
3
+ # and owns stdout/stderr redirections and its EXIT cleanup; this helper preserves them.
4
+
5
+ codex_retry_signal() {
6
+ codex_retry_pending_signal="$1"
7
+ codex_retry_signal_status="$2"
8
+ # A signal can arrive between installing the traps and assigning the async PID.
9
+ # Remember it; codex_retry_run delivers it as soon as the owned PID is known.
10
+ [ -n "$codex_retry_child_pid" ] || return 0
11
+ trap '' TERM INT HUP
12
+ kill -s "$codex_retry_pending_signal" "$codex_retry_child_pid" 2>/dev/null || true
13
+ wait "$codex_retry_child_pid" 2>/dev/null || true
14
+ codex_retry_child_pid=""
15
+ exit "$codex_retry_signal_status"
16
+ }
17
+
18
+ codex_retry_run() {
19
+ local codex_retry_child_pid="" codex_retry_pending_signal="" codex_retry_signal_status=0
20
+ local codex_retry_previous_traps rc
21
+ codex_retry_previous_traps="$(trap -p TERM INT HUP)"
22
+ trap 'codex_retry_signal TERM 143' TERM
23
+ trap 'codex_retry_signal INT 130' INT
24
+ trap 'codex_retry_signal HUP 129' HUP
25
+ [ -z "$codex_retry_pending_signal" ] || exit "$codex_retry_signal_status"
26
+ "$@" <&3 &
27
+ codex_retry_child_pid=$!
28
+ if [ -n "$codex_retry_pending_signal" ]; then
29
+ codex_retry_signal "$codex_retry_pending_signal" "$codex_retry_signal_status"
30
+ fi
31
+ if wait "$codex_retry_child_pid"; then rc=0; else rc=$?; fi
32
+ codex_retry_child_pid=""
33
+ trap - TERM INT HUP
34
+ # trap -p emits shell-quoted definitions from this process, never fetched input.
35
+ eval "$codex_retry_previous_traps"
36
+ return "$rc"
37
+ }
package/lib/common.sh CHANGED
@@ -30,6 +30,8 @@ DEFAULT_SERVER="root@138.197.36.107"
30
30
  DEFAULT_SERVER_REPO="/root/claude-multiacc"
31
31
 
32
32
  LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd -P)"
33
+ codex_mcp_oauth_ensure() { :; }
34
+ if [ -f "$LIB_DIR/codex_mcp_oauth.sh" ]; then . "$LIB_DIR/codex_mcp_oauth.sh"; fi
33
35
 
34
36
  # INSTANCE-SCOPED POOLS: <PROVIDER>_ACCOUNTS_ROOT relocates the whole pool, so
35
37
  # several app-robot instances on one machine/user keep completely isolated account
@@ -806,6 +808,7 @@ seed_account_dir() { # $1 = acct dir
806
808
  fi
807
809
  codex_share_state_index "$d"
808
810
  seed_mcp_registry "$d"
811
+ codex_mcp_oauth_ensure "$ACC_ROOT" "$PYBIN" "$LIB_DIR/codex_mcp_oauth.py" "$d"
809
812
  return 0
810
813
  fi
811
814
  if [ ! -f "$d/.claude.json" ] && [ -f "$HOME/.claude.json" ]; then
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-multiacc",
3
- "version": "2.0.61",
3
+ "version": "2.0.64",
4
4
  "description": "Unified Claude Code and OpenAI Codex subscription pooling with quota-aware selection.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -56,11 +56,16 @@
56
56
  "scripts": {
57
57
  "postinstall": "node scripts/postinstall.mjs",
58
58
  "test": "npm run test:core && npm run test:seeding && npm run test:commands",
59
- "test:core": "bash tests/run-tests.sh && npm run test:selector && python3 tests/test_shim_path.py",
59
+ "test:core": "bash tests/run-tests.sh && npm run test:selector && npm run test:shim",
60
+ "test:shim": "python3 tests/test_shim_path.py && npm run test:codex-stdin",
61
+ "test:codex-stdin": "python3 tests/test_codex_exec_stdin.py && python3 tests/test_codex_retry_process.py",
60
62
  "test:selector": "python3 -m unittest discover -s tests -p 'test_selector*.py'",
61
- "test:seeding": "python3 tests/test_account_seeding.py && python3 tests/test_claude_pool_prepare.py && python3 -m unittest discover -s tests -p 'test_mcp_*.py' && npm run test:codex-settings",
63
+ "test:seeding": "npm run test:account-seeding && npm run test:mcp && npm run test:codex-settings",
62
64
  "test:codex-settings": "python3 tests/test_codex_config_edit.py && python3 tests/test_codex_settings.py",
63
- "test:commands": "python3 tests/test_packaged_commands.py"
65
+ "test:commands": "python3 tests/test_packaged_commands.py",
66
+ "test:codex-mcp-auth": "python3 -m unittest discover -s tests -p 'test_codex_mcp_oauth*.py'",
67
+ "test:account-seeding": "python3 tests/test_account_seeding.py && python3 tests/test_claude_pool_prepare.py",
68
+ "test:mcp": "python3 -m unittest discover -s tests -p 'test_mcp_*.py' && npm run test:codex-mcp-auth"
64
69
  },
65
70
  "dependencies": {
66
71
  "update-notifier": "^7.3.1"
@@ -51,6 +51,27 @@ def initialize_rollout():
51
51
  return rollout
52
52
 
53
53
 
54
+ def require_resume_cwd():
55
+ if os.environ.get("FAKE_REQUIRE_RESUME_CWD") != "1" or "resume" not in sys.argv:
56
+ return
57
+ path = Path(os.environ["CODEX_HOME"]) / "sessions/2026/10/10"
58
+ path /= "rollout-test-" + os.environ["FAKE_SID"] + ".jsonl"
59
+ saved = None
60
+ for line in path.read_text().splitlines():
61
+ row = json.loads(line)
62
+ payload = row.get("payload", {})
63
+ if row.get("type") in ("session_meta", "turn_context"):
64
+ saved = payload.get("cwd", saved)
65
+ elif row.get("type") == "event_msg" and payload.get("type") == "thread_settings_applied":
66
+ saved = payload.get("thread_settings", {}).get("cwd", saved)
67
+ matches = saved is None or os.path.realpath(saved) == os.getcwd()
68
+ record("resume-cwd", saved=saved, matches=matches)
69
+ if not matches:
70
+ print("Working directory · resume\n› 1. Use session directory\n 2. Use current directory", flush=True)
71
+ while True:
72
+ time.sleep(0.1)
73
+
74
+
54
75
  def require_resume_trust():
55
76
  if os.environ.get("FAKE_REQUIRE_TRUST") != "1" or "resume" not in sys.argv:
56
77
  return
@@ -67,7 +88,7 @@ def require_resume_trust():
67
88
  table = re.escape(json.dumps(os.getcwd()))
68
89
  trusted = re.search(r'^\s*\[projects\.' + table + r'\]\s*\n(?:(?!\s*\[).*\n)*?\s*trust_level\s*=\s*"trusted"',
69
90
  text, re.M) is not None
70
- record("trust", trusted=trusted)
91
+ record("trust", trusted=trusted, cwd=os.getcwd())
71
92
  if not trusted:
72
93
  print("Folder access\nTrust this folder?\n› 1. Trust and continue\n 2. Quit", flush=True)
73
94
  while True:
@@ -85,6 +106,7 @@ def main():
85
106
  settings = termios.tcgetattr(0)
86
107
  signal.signal(signal.SIGTERM, stop)
87
108
  signal.signal(signal.SIGHUP, stop)
109
+ require_resume_cwd()
88
110
  require_resume_trust()
89
111
  rollout = initialize_rollout()
90
112
  try:
@@ -0,0 +1,71 @@
1
+ """Account rotation restores the bound Codex session's last working directory."""
2
+
3
+ import unittest
4
+
5
+ import test_autoresume as fixtures
6
+
7
+
8
+ class CodexResumeDirectoryTests(unittest.TestCase):
9
+ def setUp(self):
10
+ self.pool = fixtures.CodexWatcherTests(methodName="runTest")
11
+ self.pool.setUp()
12
+ self.addCleanup(self.pool.tearDown)
13
+ self.saved = self.pool.tmp / "saved project ü"
14
+ self.saved.mkdir()
15
+
16
+ def restart(self, records, expected, setup=None):
17
+ pool = self.pool
18
+ records.append(fixtures.cx_error(pool.t0, "usage_limit_exceeded"))
19
+ result, _, runner, _ = pool.run_codex(["resume", fixtures.CX_SID], records, setup=setup)
20
+ self.assertEqual(result, 0)
21
+ fields, argv = pool.relaunch_of(runner)
22
+ self.assertEqual(fields["cwd"], str(expected))
23
+ self.assertEqual(runner.probes[0]["cwd"], str(expected))
24
+ self.assertEqual(argv[-2], fixtures.CX_SID)
25
+ self.assertEqual(pool.events(), ["watch", "detect", "switch"])
26
+
27
+ def test_recorded_session_directory_wins_over_launch_directory(self):
28
+ self.restart([fixtures.cx_meta(self.pool.launched - 100, cwd=str(self.saved))], self.saved)
29
+
30
+ def test_latest_turn_directory_wins_over_initial_session_directory(self):
31
+ self.restart([fixtures.cx_meta(self.pool.launched - 100, cwd=str(self.pool.work)),
32
+ {"type": "turn_context", "payload": {"cwd": str(self.saved)}}], self.saved)
33
+
34
+ def test_latest_own_thread_settings_win_without_a_new_turn(self):
35
+ settings = {"type": "thread_settings_applied", "thread_id": fixtures.CX_SID,
36
+ "thread_settings": {"cwd": str(self.saved)}}
37
+ self.restart([fixtures.cx_meta(self.pool.launched - 100, cwd=str(self.pool.work)),
38
+ {"type": "turn_context", "payload": {"cwd": str(self.pool.work)}},
39
+ fixtures.cx_event(self.pool.t0 - 1, settings)], self.saved)
40
+
41
+ def test_foreign_thread_settings_and_tool_workdirs_do_not_change_directory(self):
42
+ foreign = {"type": "thread_settings_applied", "thread_id": fixtures.CX_SID2,
43
+ "thread_settings": {"cwd": str(self.pool.work)}}
44
+ tool = {"type": "response_item", "payload": {"type": "function_call", "name": "exec_command",
45
+ "arguments": '{"workdir":"/tmp","cmd":"pwd"}'}}
46
+ self.restart([fixtures.cx_meta(self.pool.launched - 100, cwd=str(self.saved)),
47
+ fixtures.cx_event(self.pool.t0 - 1, foreign), tool], self.saved)
48
+
49
+ def test_invalid_saved_values_keep_last_valid_directory(self):
50
+ invalid = [None, 7, {}, "relative/project", "/tmp\nnot-a-directory", "/tmp\0bad"]
51
+ records = [fixtures.cx_meta(self.pool.launched - 100, cwd=str(self.saved))]
52
+ records.extend({"type": "turn_context", "payload": {"cwd": value}} for value in invalid)
53
+ self.restart(records, self.saved)
54
+
55
+ def test_deleted_saved_directory_falls_back_to_existing_launch_directory(self):
56
+ self.restart([fixtures.cx_meta(self.pool.launched - 100, cwd=str(self.saved / "deleted"))],
57
+ self.pool.work)
58
+
59
+ def test_relaunch_cursor_does_not_hide_the_saved_directory(self):
60
+ def setup(watcher, runner, path):
61
+ info = path.stat()
62
+ watcher.st["rollout_cursor"] = "%d:%d" % (info.st_ino, info.st_size)
63
+ runner.at(self.pool.t0 + 1, lambda: fixtures.append(path, [
64
+ fixtures.cx_error(self.pool.t0 + 1, "usage_limit_exceeded")]))
65
+
66
+ self.restart([fixtures.cx_meta(self.pool.launched - 100, cwd=str(self.pool.work)),
67
+ {"type": "turn_context", "payload": {"cwd": str(self.saved)}}], self.saved, setup=setup)
68
+
69
+
70
+ if __name__ == "__main__":
71
+ unittest.main()
@@ -0,0 +1,92 @@
1
+ """A Codex handoff binds the saved directory and history to one validated snapshot."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+ import tempfile
6
+ import unittest
7
+ from unittest import mock
8
+
9
+ import test_autoresume as fixtures
10
+ from autoresume_cross_unit_support import scenario
11
+ import autoresume_cross as cross
12
+ import autoresume_handoff as handoff
13
+
14
+
15
+ class CodexDirectoryHandoffTests(unittest.TestCase):
16
+ def setUp(self):
17
+ temporary = tempfile.TemporaryDirectory(prefix="ar-cwd-handoff-")
18
+ self.addCleanup(temporary.cleanup)
19
+ self.home = Path(temporary.name).resolve()
20
+ self.initial = self.home / "initial"
21
+ self.saved = self.home / "saved project"
22
+ self.initial.mkdir()
23
+ self.saved.mkdir()
24
+ self.path = self.home / ("rollout-test-" + fixtures.CX_SID + ".jsonl")
25
+ self.meta = fixtures.cx_meta(1000, cwd=str(self.initial))
26
+
27
+ def write(self, records):
28
+ self.path.write_text("".join(json.dumps(row) + "\n" for row in [self.meta] + records))
29
+
30
+ def validate(self, cwd=None):
31
+ return handoff.validate_source("codex", fixtures.CX_SID, str(self.path), str(cwd or self.saved))
32
+
33
+ def test_changed_directory_from_turn_or_settings_replaces_initial_directory(self):
34
+ turn = {"type": "turn_context", "payload": {"cwd": str(self.saved)}}
35
+ settings = fixtures.cx_event(1001, {"type": "thread_settings_applied", "thread_id": fixtures.CX_SID,
36
+ "thread_settings": {"cwd": str(self.saved)}})
37
+ for record in (turn, settings):
38
+ with self.subTest(kind=record["type"]):
39
+ self.write([record])
40
+ history = self.validate()
41
+ self.assertEqual(history["cwd"], str(self.saved))
42
+ self.assertEqual(history["offset"], self.path.stat().st_size)
43
+ self.assertIsNone(self.validate(self.initial))
44
+
45
+ def test_foreign_settings_cannot_authorize_unrelated_directory(self):
46
+ settings = fixtures.cx_event(1001, {"type": "thread_settings_applied", "thread_id": fixtures.CX_SID2,
47
+ "thread_settings": {"cwd": str(self.saved)}})
48
+ self.write([settings])
49
+ self.assertIsNone(self.validate())
50
+ self.assertIsNotNone(self.validate(self.initial))
51
+
52
+ def test_saved_directory_after_large_history_is_read_beyond_initial_prefix(self):
53
+ self.write([{"type": "response_item", "payload": {"text": "x" * handoff.MAX_SOURCE_BYTES}},
54
+ {"type": "turn_context", "payload": {"cwd": str(self.saved)}}])
55
+ self.assertEqual(self.validate()["cwd"], str(self.saved))
56
+
57
+ def test_appended_settings_after_snapshot_do_not_change_snapshot_directory(self):
58
+ self.write([{ "type": "turn_context", "payload": {"cwd": str(self.saved)}}])
59
+ offset = self.path.stat().st_size
60
+ original = handoff.codex_resume_cwd
61
+ later = {"type": "turn_context", "payload": {"cwd": str(self.initial)}}
62
+ with mock.patch.object(handoff, "codex_resume_cwd", side_effect=lambda *args, **kwargs: (
63
+ fixtures.append(self.path, [later]), original(*args, **kwargs))[1]):
64
+ history = self.validate()
65
+ self.assertEqual((history["cwd"], history["offset"]), (str(self.saved), offset))
66
+ self.assertIsNone(self.validate())
67
+
68
+ def test_replacement_during_directory_read_is_rejected(self):
69
+ self.write([{"type": "turn_context", "payload": {"cwd": str(self.saved)}}])
70
+ replacement = self.path.with_suffix(".replacement")
71
+ replacement.write_bytes(self.path.read_bytes())
72
+ original = handoff.codex_resume_cwd
73
+ with mock.patch.object(handoff, "codex_resume_cwd", side_effect=lambda *args, **kwargs: (
74
+ replacement.replace(self.path), original(*args, **kwargs))[1]):
75
+ self.assertIsNone(self.validate())
76
+
77
+ def test_cross_provider_plan_probes_in_latest_saved_directory(self):
78
+ case, watcher, runner, history, _ = scenario(self, "codex")
79
+ saved = case.tmp / "saved project"
80
+ saved.mkdir()
81
+ fixtures.append(history, [{"type": "turn_context", "payload": {"cwd": str(saved)}}])
82
+ plan = cross.plan_fallback(watcher, watcher.tracker.pending, "")
83
+ self.assertIsNotNone(plan)
84
+ self.assertEqual(plan["history"]["cwd"], str(saved))
85
+ self.assertEqual(runner.target_probes[-1]["cwd"], str(saved))
86
+ self.assertTrue(cross.revalidate(watcher, plan))
87
+ fixtures.append(history, [{"type": "turn_context", "payload": {"cwd": str(case.work)}}])
88
+ self.assertFalse(cross.revalidate(watcher, plan))
89
+
90
+
91
+ if __name__ == "__main__":
92
+ unittest.main()
@@ -0,0 +1,105 @@
1
+ """Read only a bound Codex thread's saved cwd, even across chunk and restart boundaries."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+ import sys
6
+ import tempfile
7
+ import unittest
8
+ from unittest import mock
9
+
10
+ ROOT = Path(__file__).resolve().parents[1]
11
+ sys.path.insert(0, str(ROOT / "lib"))
12
+ from codex_resume_cwd import codex_resume_cwd # noqa: E402
13
+ import session_settings # noqa: E402
14
+
15
+ SID = "01a13956-95c9-7d41-9a8b-b3e5ff79d3b2"
16
+ OTHER = "0aaaaaaa-0000-7000-8000-000000000000"
17
+
18
+
19
+ def line(kind, **payload):
20
+ return json.dumps({"type": kind, "payload": payload}, ensure_ascii=False)
21
+
22
+
23
+ class CodexCwdReaderTests(unittest.TestCase):
24
+ def setUp(self):
25
+ directory = tempfile.TemporaryDirectory(prefix="codex-cwd-reader-")
26
+ self.addCleanup(directory.cleanup)
27
+ self.path = Path(directory.name) / "rollout.jsonl"
28
+ self.meta = line("session_meta", id=SID, cwd="/initial project")
29
+
30
+ def write(self, records):
31
+ self.path.write_text("\n".join(records) + "\n", encoding="utf-8")
32
+ return self.path
33
+
34
+ def test_bound_id_is_checked_before_any_saved_context(self):
35
+ path = self.write([self.meta, line("turn_context", cwd="/latest")])
36
+ self.assertEqual(codex_resume_cwd(path, SID), "/latest")
37
+ self.assertEqual(codex_resume_cwd(path), "/latest")
38
+ self.assertIsNone(codex_resume_cwd(path, OTHER))
39
+ boundary = path.stat().st_size
40
+ with path.open("a", encoding="utf-8") as output:
41
+ output.write(line("turn_context", cwd="/after-snapshot") + "\n")
42
+ self.assertEqual(codex_resume_cwd(path, SID), "/after-snapshot")
43
+ self.assertEqual(codex_resume_cwd(path, SID, upto=boundary), "/latest")
44
+
45
+ def test_missing_or_unreadable_history_has_no_directory(self):
46
+ for path in (None, self.path, self.path.parent):
47
+ with self.subTest(path=path):
48
+ self.assertIsNone(codex_resume_cwd(path, SID))
49
+ self.write([self.meta])
50
+ with mock.patch("builtins.open", side_effect=PermissionError("denied")):
51
+ self.assertIsNone(codex_resume_cwd(self.path, SID))
52
+
53
+ def test_invalid_metadata_cannot_bind_a_later_context(self):
54
+ bad = ["{", "[]", "null", json.dumps({"type": "session_meta", "payload": []}),
55
+ line("session_meta", id=None, cwd="/initial"),
56
+ line("turn_context", id=SID, cwd="/initial")]
57
+ for first in bad:
58
+ with self.subTest(first=first):
59
+ path = self.write([first, line("turn_context", cwd="/latest")])
60
+ self.assertIsNone(codex_resume_cwd(path, SID))
61
+
62
+ def test_malformed_and_invalid_later_records_preserve_last_valid_cwd(self):
63
+ bad = ["{", '[]', '{"payload":{"cwd":',
64
+ json.dumps({"type": "turn_context", "payload": None}),
65
+ line("event_msg", type="thread_settings_applied", thread_settings=[])]
66
+ bad.extend(line("turn_context", cwd=value)
67
+ for value in (None, 7, {}, "relative", "/bad\npath", "/bad\rpath", "/bad\0path"))
68
+ path = self.write([self.meta, line("turn_context", cwd="/saved ü")] + bad)
69
+ self.assertEqual(codex_resume_cwd(path, SID), "/saved ü")
70
+
71
+ def test_foreign_settings_and_subagent_context_do_not_change_main_cwd(self):
72
+ path = self.write([
73
+ self.meta,
74
+ line("event_msg", type="thread_settings_applied", thread_id=OTHER,
75
+ thread_settings={"cwd": "/foreign"}),
76
+ line("turn_context", thread_id=OTHER, cwd="/foreign-context"),
77
+ line("turn_context", turn_id="child", root_turn_id="parent", cwd="/subagent"),
78
+ line("response_item", type="function_call", cwd="/tool", arguments='{"cwd":"/quoted"}'),
79
+ line("session_meta", id=OTHER, cwd="/copied-parent"),
80
+ ])
81
+ self.assertEqual(codex_resume_cwd(path, SID), "/initial project")
82
+
83
+ def test_reverse_chunks_reassemble_long_records_and_keep_latest_settings(self):
84
+ saved = "/saved " + "é" * 200
85
+ path = self.write([
86
+ self.meta, line("turn_context", cwd="/older"),
87
+ line("event_msg", type="thread_settings_applied", thread_id=SID,
88
+ thread_settings={"cwd": saved}),
89
+ line("response_item", type="message", text="padding" * 100),
90
+ ])
91
+ with mock.patch.object(session_settings, "CHUNK", 127):
92
+ self.assertEqual(codex_resume_cwd(path, SID), saved)
93
+
94
+ def test_initial_metadata_remains_fallback_beyond_reverse_scan_limit(self):
95
+ large_meta = line("session_meta", id=SID, cwd="/initial project", base_instructions="x" * (1 << 20))
96
+ path = self.write([large_meta, line("session_meta", id=OTHER, cwd="/copied-parent"),
97
+ line("response_item", type="message", text="padding" * 100)])
98
+ self.assertEqual(codex_resume_cwd(path, SID), "/initial project")
99
+ with mock.patch.object(session_settings, "CHUNK", 128), \
100
+ mock.patch.object(session_settings, "SCAN_LIMIT", 128):
101
+ self.assertEqual(codex_resume_cwd(path, SID), "/initial project")
102
+
103
+
104
+ if __name__ == "__main__":
105
+ unittest.main()