claude-multiacc 2.0.60 → 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 (144) hide show
  1. package/README.md +12 -3
  2. package/bin/claude +14 -3
  3. package/bin/codex +42 -10
  4. package/bin/codex-accounts +12 -4
  5. package/docs/ACCOUNT_OPERATIONS.md +2 -2
  6. package/docs/AUTORESUME.md +6 -1
  7. package/docs/CODEX.md +19 -2
  8. package/docs/CODEX_MCP_AUTH.md +58 -0
  9. package/lib/__pycache__/audit.cpython-312.pyc +0 -0
  10. package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
  11. package/lib/__pycache__/autoresume_cross.cpython-312.pyc +0 -0
  12. package/lib/__pycache__/autoresume_cross_dispatch.cpython-312.pyc +0 -0
  13. package/lib/__pycache__/autoresume_cross_receipt.cpython-312.pyc +0 -0
  14. package/lib/__pycache__/autoresume_handoff.cpython-312.pyc +0 -0
  15. package/lib/__pycache__/autoresume_probe.cpython-312.pyc +0 -0
  16. package/lib/__pycache__/autoresume_receipt.cpython-312.pyc +0 -0
  17. package/lib/__pycache__/claude_client_error.cpython-312.pyc +0 -0
  18. package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
  19. package/lib/__pycache__/claude_token_identity.cpython-312.pyc +0 -0
  20. package/lib/__pycache__/codex_autoresume_mode.cpython-312.pyc +0 -0
  21. package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
  22. package/lib/__pycache__/codex_credit_controls.cpython-312.pyc +0 -0
  23. package/lib/__pycache__/codex_credit_local_scope.cpython-312.pyc +0 -0
  24. package/lib/__pycache__/codex_credit_selection.cpython-312.pyc +0 -0
  25. package/lib/__pycache__/codex_credits.cpython-312.pyc +0 -0
  26. package/lib/__pycache__/codex_limits_lock.cpython-312.pyc +0 -0
  27. package/lib/__pycache__/codex_mcp_oauth.cpython-312.pyc +0 -0
  28. package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
  29. package/lib/__pycache__/codex_resume_cwd.cpython-312.pyc +0 -0
  30. package/lib/__pycache__/codex_trust.cpython-312.pyc +0 -0
  31. package/lib/__pycache__/codex_usage_fetch.cpython-312.pyc +0 -0
  32. package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
  33. package/lib/__pycache__/mcp_mirror.cpython-312.pyc +0 -0
  34. package/lib/__pycache__/mcp_oauth.cpython-312.pyc +0 -0
  35. package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
  36. package/lib/__pycache__/model_catalog.cpython-312.pyc +0 -0
  37. package/lib/__pycache__/model_match.cpython-312.pyc +0 -0
  38. package/lib/__pycache__/report.cpython-312.pyc +0 -0
  39. package/lib/__pycache__/selector_credit_scope.cpython-312.pyc +0 -0
  40. package/lib/__pycache__/selector_credits.cpython-312.pyc +0 -0
  41. package/lib/__pycache__/selector_funding.cpython-312.pyc +0 -0
  42. package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
  43. package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
  44. package/lib/__pycache__/session_settings.cpython-312.pyc +0 -0
  45. package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
  46. package/lib/autoresume.py +6 -5
  47. package/lib/autoresume_cross.py +2 -1
  48. package/lib/autoresume_handoff.py +13 -5
  49. package/lib/codex_exec_stdin.sh +45 -0
  50. package/lib/codex_mcp_oauth.py +204 -0
  51. package/lib/codex_mcp_oauth.sh +29 -0
  52. package/lib/codex_mcp_oauth_locks.py +158 -0
  53. package/lib/codex_mcp_oauth_native.py +102 -0
  54. package/lib/codex_mcp_oauth_store.py +149 -0
  55. package/lib/codex_resume_cwd.py +57 -0
  56. package/lib/codex_retry_process.sh +37 -0
  57. package/lib/common.sh +3 -0
  58. package/lib/mcp_mirror.py +49 -0
  59. package/lib/mcp_mirror.sh +47 -0
  60. package/lib/mcp_registry.py +7 -2
  61. package/package.json +9 -4
  62. package/tests/__pycache__/autoresume_cross_receipt_support.cpython-312.pyc +0 -0
  63. package/tests/__pycache__/autoresume_cross_tmux_environment.cpython-312.pyc +0 -0
  64. package/tests/__pycache__/autoresume_cross_tmux_support.cpython-312.pyc +0 -0
  65. package/tests/__pycache__/autoresume_cross_unit_support.cpython-312.pyc +0 -0
  66. package/tests/__pycache__/autoresume_tmux_support.cpython-312.pyc +0 -0
  67. package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
  68. package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
  69. package/tests/__pycache__/test_autoresume_catalog.cpython-312.pyc +0 -0
  70. package/tests/__pycache__/test_autoresume_catalog_cli.cpython-312.pyc +0 -0
  71. package/tests/__pycache__/test_autoresume_codex_records.cpython-312.pyc +0 -0
  72. package/tests/__pycache__/test_autoresume_codex_trust.cpython-312.pyc +0 -0
  73. package/tests/__pycache__/test_autoresume_cross_claude_holders.cpython-312.pyc +0 -0
  74. package/tests/__pycache__/test_autoresume_cross_guards.cpython-312.pyc +0 -0
  75. package/tests/__pycache__/test_autoresume_cross_holders.cpython-312.pyc +0 -0
  76. package/tests/__pycache__/test_autoresume_cross_origin.cpython-312.pyc +0 -0
  77. package/tests/__pycache__/test_autoresume_cross_policy.cpython-312.pyc +0 -0
  78. package/tests/__pycache__/test_autoresume_cross_receipt.cpython-312.pyc +0 -0
  79. package/tests/__pycache__/test_autoresume_cross_tmux.cpython-312.pyc +0 -0
  80. package/tests/__pycache__/test_autoresume_cross_tmux_races.cpython-312.pyc +0 -0
  81. package/tests/__pycache__/test_autoresume_cross_tokens.cpython-312.pyc +0 -0
  82. package/tests/__pycache__/test_autoresume_cursor.cpython-312.pyc +0 -0
  83. package/tests/__pycache__/test_autoresume_cwd.cpython-312.pyc +0 -0
  84. package/tests/__pycache__/test_autoresume_cwd_handoff.cpython-312.pyc +0 -0
  85. package/tests/__pycache__/test_autoresume_cwd_reader.cpython-312.pyc +0 -0
  86. package/tests/__pycache__/test_autoresume_cwd_tmux.cpython-312.pyc +0 -0
  87. package/tests/__pycache__/test_autoresume_fallback.cpython-312.pyc +0 -0
  88. package/tests/__pycache__/test_autoresume_handoff_argv.cpython-312.pyc +0 -0
  89. package/tests/__pycache__/test_autoresume_handoff_history.cpython-312.pyc +0 -0
  90. package/tests/__pycache__/test_autoresume_handoff_origins.cpython-312.pyc +0 -0
  91. package/tests/__pycache__/test_autoresume_memory.cpython-312.pyc +0 -0
  92. package/tests/__pycache__/test_autoresume_memory_tmux.cpython-312.pyc +0 -0
  93. package/tests/__pycache__/test_autoresume_mode.cpython-312.pyc +0 -0
  94. package/tests/__pycache__/test_autoresume_probe.cpython-312.pyc +0 -0
  95. package/tests/__pycache__/test_autoresume_receipt.cpython-312.pyc +0 -0
  96. package/tests/__pycache__/test_autoresume_relaunch_settings.cpython-312.pyc +0 -0
  97. package/tests/__pycache__/test_autoresume_settings.cpython-312.pyc +0 -0
  98. package/tests/__pycache__/test_autoresume_sidechain.cpython-312.pyc +0 -0
  99. package/tests/__pycache__/test_autoresume_sidechain_tmux.cpython-312.pyc +0 -0
  100. package/tests/__pycache__/test_autoresume_tmux.cpython-312.pyc +0 -0
  101. package/tests/__pycache__/test_autoresume_tmux_modes.cpython-312.pyc +0 -0
  102. package/tests/__pycache__/test_autoresume_trust_tmux.cpython-312.pyc +0 -0
  103. package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
  104. package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
  105. package/tests/__pycache__/test_codex_exec_stdin.cpython-312.pyc +0 -0
  106. package/tests/__pycache__/test_codex_mcp_oauth.cpython-312.pyc +0 -0
  107. package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
  108. package/tests/__pycache__/test_codex_reset_credits.cpython-312.pyc +0 -0
  109. package/tests/__pycache__/test_codex_reset_credits_polling.cpython-312.pyc +0 -0
  110. package/tests/__pycache__/test_codex_reset_credits_report.cpython-312.pyc +0 -0
  111. package/tests/__pycache__/test_codex_reset_fetch.cpython-312.pyc +0 -0
  112. package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
  113. package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
  114. package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
  115. package/tests/__pycache__/test_mcp_mirror.cpython-312.pyc +0 -0
  116. package/tests/__pycache__/test_mcp_mirror_args.cpython-312.pyc +0 -0
  117. package/tests/__pycache__/test_mcp_oauth.cpython-312.pyc +0 -0
  118. package/tests/__pycache__/test_mcp_registry.cpython-312.pyc +0 -0
  119. package/tests/__pycache__/test_selector.cpython-312.pyc +0 -0
  120. package/tests/__pycache__/test_selector_cached_credits.cpython-312.pyc +0 -0
  121. package/tests/__pycache__/test_selector_credit_scope.cpython-312.pyc +0 -0
  122. package/tests/__pycache__/test_selector_credit_shim.cpython-312.pyc +0 -0
  123. package/tests/__pycache__/test_selector_credits.cpython-312.pyc +0 -0
  124. package/tests/__pycache__/test_selector_spread.cpython-312.pyc +0 -0
  125. package/tests/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
  126. package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
  127. package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
  128. package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
  129. package/tests/fake_codex_tui.py +23 -1
  130. package/tests/run-tests.sh +3 -1
  131. package/tests/test_autoresume_cwd.py +71 -0
  132. package/tests/test_autoresume_cwd_handoff.py +92 -0
  133. package/tests/test_autoresume_cwd_reader.py +105 -0
  134. package/tests/test_autoresume_cwd_tmux.py +54 -0
  135. package/tests/test_codex_exec_stdin.py +178 -0
  136. package/tests/test_codex_mcp_oauth.py +146 -0
  137. package/tests/test_codex_mcp_oauth_adopt.py +87 -0
  138. package/tests/test_codex_mcp_oauth_migration.py +141 -0
  139. package/tests/test_codex_mcp_oauth_native.py +109 -0
  140. package/tests/test_codex_mcp_oauth_safety.py +130 -0
  141. package/tests/test_codex_mcp_oauth_shim.py +188 -0
  142. package/tests/test_codex_retry_process.py +143 -0
  143. package/tests/test_mcp_mirror.py +148 -0
  144. package/tests/test_mcp_mirror_args.py +129 -0
@@ -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()
@@ -0,0 +1,54 @@
1
+ """A quota restart restores the saved directory before Codex checks directory and trust."""
2
+
3
+ import json
4
+ import time
5
+ import unittest
6
+
7
+ from autoresume_tmux_support import SID, TMUX, TmuxScenario
8
+
9
+ QUOTA = {"type": "task_complete", "error": {"message": "You've hit your usage limit.",
10
+ "codex_error_info": "usage_limit_exceeded"}}
11
+
12
+
13
+ @unittest.skipUnless(TMUX, "real tmux is unavailable")
14
+ class CodexCwdTmuxTests(unittest.TestCase):
15
+ def test_quota_restart_restores_latest_directory_before_trust_and_prompt(self):
16
+ self.check_restart("turn_context")
17
+
18
+ def test_settings_change_restores_directory_before_the_next_turn(self):
19
+ self.check_restart("thread_settings_applied")
20
+
21
+ def check_restart(self, record_type):
22
+ case = TmuxScenario(FAKE_REQUIRE_RESUME_CWD="1", FAKE_REQUIRE_TRUST="1")
23
+ self.addCleanup(case.close)
24
+ saved = case.home / "saved project Ω"
25
+ saved.mkdir()
26
+ source = case.pool / "acct-01/config.toml"
27
+ target = case.pool / "acct-02/config.toml"
28
+ source.write_text("# The original account stays unchanged.\n")
29
+ case.start()
30
+ context = {"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
31
+ "type": "turn_context", "payload": {"cwd": str(saved)}}
32
+ if record_type == "thread_settings_applied":
33
+ context.update(type="event_msg", payload={"type": record_type, "thread_id": SID,
34
+ "thread_settings": {"cwd": str(saved)}})
35
+ with case.rollout.open("a") as stream:
36
+ stream.write(json.dumps(context) + "\n")
37
+ case.append(QUOTA)
38
+ case.wait(lambda: "autoresume switch" in case.log.read_text(), "restart in saved directory confirmed")
39
+ first, second = case.records("launch")
40
+ self.assertEqual([first["cwd"], second["cwd"]], [str(case.work), str(saved)])
41
+ self.assertEqual([first["account"], second["account"]], ["acct-01", "acct-02"])
42
+ self.assertIn(SID, second["argv"])
43
+ self.assertEqual([row["matches"] for row in case.records("resume-cwd")], [True])
44
+ self.assertEqual([(row["trusted"], row["cwd"]) for row in case.records("trust")], [(True, str(saved))])
45
+ self.assertIn('[projects.' + json.dumps(str(saved), ensure_ascii=False) + ']', target.read_text())
46
+ self.assertNotIn(str(case.work), target.read_text())
47
+ self.assertEqual(source.read_text(), "# The original account stays unchanged.\n")
48
+ self.assertEqual(len(case.records("prompt")), 1)
49
+ self.assertEqual(case.log.read_text().count("autoresume switch"), 1)
50
+ self.assertEqual(case.records("keys"), [], "neither TUI receives a keystroke")
51
+
52
+
53
+ if __name__ == "__main__":
54
+ unittest.main()
@@ -0,0 +1,178 @@
1
+ #!/usr/bin/env python3
2
+ """Exercise finite Node pipe prompts against the real shim and fake native Codex."""
3
+
4
+ import base64
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ import shutil
9
+ import subprocess
10
+ import sys
11
+ import tempfile
12
+ import time
13
+ import unittest
14
+
15
+ REPO = Path(__file__).resolve().parents[1]
16
+ SHIM = os.environ.get('MULTIACC_TEST_SHIM', str(REPO / 'bin/codex'))
17
+ NODE = shutil.which('node')
18
+ PROMPT = b'Original image brief\nUnicode: \xf0\x9f\x8c\xb1\nBinary fidelity: \x00\xff\n'
19
+ FAKE = '''import base64, json, os, pathlib, sys
20
+ root = pathlib.Path(os.environ['FAKE_ROOT'])
21
+ account = pathlib.Path(os.environ['CODEX_HOME']).name
22
+ args = sys.argv[1:]
23
+ data = sys.stdin.buffer.read() if args[-1:] == ['-'] and os.environ.get('FAKE_READ', '1') == '1' else b''
24
+ with (root / 'calls.jsonl').open('a') as handle:
25
+ handle.write(json.dumps({'account': account, 'argv': args,
26
+ 'selected_home': os.environ.get('CODEX_MULTIACC_SELECTED_HOME'),
27
+ 'stdin': base64.b64encode(data).decode()}) + '\\n')
28
+ mode = os.environ.get('FAKE_MODE', 'quota')
29
+ if mode == 'ordinary':
30
+ print('ordinary failure', file=sys.stderr)
31
+ sys.exit(7)
32
+ if mode == 'all-quota' or (account == 'acct-01' and mode in ('quota', 'auth')):
33
+ print(json.dumps({'type': 'turn.failed', 'partial': account}))
34
+ print('ERROR: You’ve hit your usage limit.' if mode != 'auth' else
35
+ 'Error: token expired. Please run codex login again', file=sys.stderr)
36
+ sys.exit(1)
37
+ print(json.dumps({'type': 'turn.completed', 'account': account}))
38
+ '''
39
+ LAUNCH = '''const {spawn} = require('node:child_process');
40
+ const child = spawn(process.env.TEST_SHIM, JSON.parse(process.env.TEST_ARGS),
41
+ {stdio: ['pipe', 'pipe', 'pipe']});
42
+ child.stdout.pipe(process.stdout); child.stderr.pipe(process.stderr);
43
+ child.stdin.on('error', error => { if (error.code !== 'EPIPE') throw error; });
44
+ if (process.env.TEST_KEEP_OPEN !== '1') {
45
+ child.stdin.end(Buffer.from(process.env.TEST_INPUT, 'base64'));
46
+ }
47
+ child.on('close', code => { child.stdin.destroy(); process.exitCode = code; });
48
+ '''
49
+
50
+
51
+ @unittest.skipUnless(NODE, 'Node is required for the actual child-process transport')
52
+ class CodexExecStdinTests(unittest.TestCase):
53
+ def setUp(self):
54
+ self.tmp = tempfile.TemporaryDirectory(prefix='multiacc-exec-stdin-')
55
+ self.addCleanup(self.tmp.cleanup)
56
+ self.root = Path(self.tmp.name)
57
+ self.pool = self.root / 'pool'
58
+ self.pool.mkdir()
59
+ (self.pool / 'accounts.json').write_text('{"threshold":90,"accounts":[]}')
60
+ (self.pool / '.limits-kick').touch()
61
+ for index in (1, 2):
62
+ account = self.pool / f'acct-{index:02d}'
63
+ account.mkdir()
64
+ (account / 'auth.json').write_text('{"access_token":"fake-offline-only"}')
65
+ (account / 'limits.json').write_text(json.dumps({
66
+ 'fetched_at': int(time.time()), 'weekly_percent': index * 10,
67
+ 'session_percent': 5, 'max_percent': index * 10, 'buckets': []}))
68
+ fakebin = self.root / 'bin'
69
+ fakebin.mkdir()
70
+ fake = fakebin / 'codex'
71
+ fake.write_text(f'#!{sys.executable}\n' + FAKE)
72
+ fake.chmod(0o755)
73
+ self.env = {
74
+ 'HOME': str(self.root), 'PATH': f'{fakebin}:/usr/bin:/bin:/usr/sbin:/sbin',
75
+ 'CODEX_ACCOUNTS_ROOT': str(self.pool), 'CODEX_MULTIACC_HEADROOM_BAND': '0',
76
+ 'CODEX_MULTIACC_MCP': '0', 'CODEX_MULTIACC_AUTO_RESET': '0',
77
+ 'CODEX_MULTIACC_MODEL_CATALOG': '0', 'CLAUDE_MULTIACC_PATH_PROBE': '0',
78
+ 'FAKE_ROOT': str(self.root), 'TEST_SHIM': SHIM,
79
+ 'TEST_INPUT': base64.b64encode(PROMPT).decode(), 'LANG': 'en_US.UTF-8',
80
+ }
81
+ self.args = ['exec', '-C', str(self.root), '--skip-git-repo-check', '--ephemeral',
82
+ '--ignore-user-config', '--json', '--color', 'never', '-s', 'workspace-write',
83
+ '-c', 'model_reasoning_effort=medium', '-c', 'project_doc_max_bytes=0', '-']
84
+
85
+ def run_node(self, args=None, **changes):
86
+ env = {**self.env, 'TEST_ARGS': json.dumps(args or self.args), **changes}
87
+ result = subprocess.run([NODE, '-e', LAUNCH], env=env, capture_output=True, timeout=15)
88
+ calls = self.root / 'calls.jsonl'
89
+ rows = [json.loads(line) for line in calls.read_text().splitlines()] if calls.exists() else []
90
+ return result, rows
91
+
92
+ def test_quota_retries_exact_node_image_prompt_with_identical_bytes(self):
93
+ result, calls = self.run_node()
94
+ self.assertEqual(result.returncode, 0, result.stderr)
95
+ self.assertEqual([row['account'] for row in calls], ['acct-01', 'acct-02'])
96
+ self.assertTrue(all(base64.b64decode(row['stdin']) == PROMPT for row in calls))
97
+ self.assertTrue(all(row['argv'] == self.args for row in calls))
98
+ self.assertTrue(all(row['selected_home'] == str(self.pool / row['account']) for row in calls))
99
+ self.assertNotIn(b'turn.failed', result.stdout)
100
+ self.assertNotIn(b'usage limit', result.stderr)
101
+ self.assertIn('error-cooldown', (self.pool / 'acct-01/.limited').read_text())
102
+ self.assertIn('retry from=acct-01 to=acct-02', (self.pool / 'selection.log').read_text())
103
+
104
+ def test_auth_failure_rotates_and_parks_only_rejected_account(self):
105
+ result, calls = self.run_node(FAKE_MODE='auth')
106
+ self.assertEqual(result.returncode, 0, result.stderr)
107
+ self.assertEqual(len(calls), 2)
108
+ self.assertIn('reason=auth-error', (self.pool / 'acct-01/.expired').read_text())
109
+ self.assertFalse((self.pool / 'acct-02/.expired').exists())
110
+
111
+ def test_success_and_ordinary_failure_never_replay(self):
112
+ for mode, expected in [('success', 0), ('ordinary', 7)]:
113
+ with self.subTest(mode=mode):
114
+ (self.root / 'calls.jsonl').unlink(missing_ok=True)
115
+ result, calls = self.run_node(FAKE_MODE=mode)
116
+ self.assertEqual(result.returncode, expected, result.stderr)
117
+ self.assertEqual(len(calls), 1)
118
+ self.assertFalse((self.pool / 'acct-01/.limited').exists())
119
+
120
+ def test_two_exhausted_accounts_return_actual_final_failure_once(self):
121
+ result, calls = self.run_node(FAKE_MODE='all-quota')
122
+ self.assertEqual(result.returncode, 1)
123
+ self.assertEqual(len(calls), 2)
124
+ self.assertEqual(result.stderr.count(b'usage limit'), 1)
125
+ self.assertNotIn(b'turn.completed', result.stdout)
126
+ self.assertEqual(json.loads(result.stdout)['partial'], 'acct-02')
127
+
128
+ def test_explicit_pins_and_disable_keep_the_existing_passthrough_contract(self):
129
+ for changes in [{'CODEX_HOME': str(self.pool / 'acct-01')},
130
+ {'CODEX_ACCOUNT': 'acct-01'}, {'CODEX_SHIM_RETRY': '0'}]:
131
+ with self.subTest(changes=changes):
132
+ (self.root / 'calls.jsonl').unlink(missing_ok=True)
133
+ result, calls = self.run_node(**changes)
134
+ self.assertEqual(result.returncode, 1, result.stderr)
135
+ self.assertEqual(len(calls), 1)
136
+ self.assertEqual(calls[0]['account'], 'acct-01')
137
+
138
+ def test_nonconsuming_open_pipe_is_not_buffered(self):
139
+ commands = [['exec', 'prompt supplied in argv'], ['exec', '--image', '-'],
140
+ ['exec', '--config', '--json', '-'], ['exec', '--full-auto', '-'],
141
+ ['exec', '--help', '-'], ['exec', '-o', '-'], ['exec', '--future-option', '-']]
142
+ for args in commands:
143
+ with self.subTest(args=args):
144
+ (self.root / 'calls.jsonl').unlink(missing_ok=True)
145
+ result, calls = self.run_node(args, TEST_KEEP_OPEN='1', FAKE_MODE='success', FAKE_READ='0')
146
+ self.assertEqual(result.returncode, 0, result.stderr)
147
+ self.assertEqual(len(calls), 1)
148
+
149
+ def test_auto_selection_provenance_does_not_turn_explicit_pins_into_pool_requests(self):
150
+ selected = str(self.pool / 'acct-01')
151
+ scenarios = [({}, selected), ({'CODEX_HOME': selected}, None),
152
+ ({'CODEX_ACCOUNT': 'acct-01', 'CODEX_MULTIACC_SELECTED_HOME': selected}, None),
153
+ ({'CODEX_HOME': str(self.pool / 'acct-02'),
154
+ 'CODEX_MULTIACC_SELECTED_HOME': selected}, selected)]
155
+ for changes, expected in scenarios:
156
+ with self.subTest(changes=changes):
157
+ (self.root / 'calls.jsonl').unlink(missing_ok=True)
158
+ result, calls = self.run_node(FAKE_MODE='success', **changes)
159
+ self.assertEqual(result.returncode, 0, result.stderr)
160
+ self.assertEqual(len(calls), 1)
161
+ self.assertEqual(calls[0]['selected_home'], expected)
162
+
163
+ def test_parser_only_accepts_explicit_prompt_not_option_values_or_early_exits(self):
164
+ parser = ['bash', str(REPO / 'lib/codex_exec_stdin.sh')]
165
+ accepted = [self.args, ['--no-daemon', 'e', '--image', 'one.png', 'two.png', '-'],
166
+ ['exec', '--', '-'], ['--model=gpt-6.1-sol', 'exec', '-']]
167
+ rejected = [['exec', '-o', '-'], ['exec', '--color', '-'], ['exec', '--help', '-'],
168
+ ['exec', '--future-option', '-'], ['exec', 'resume', '-'],
169
+ ['mcp', 'add', 'exec', '-'], ['exec', 'text'], ['exec', '--image', 'one.png'],
170
+ ['exec', '--image', '-'], ['exec', '--config', '--json', '-'], ['exec', '--full-auto', '-']]
171
+ for args in accepted + rejected:
172
+ with self.subTest(args=args):
173
+ result = subprocess.run(parser + args, capture_output=True)
174
+ self.assertEqual(result.returncode, 0 if args in accepted else 1)
175
+
176
+
177
+ if __name__ == '__main__':
178
+ unittest.main()
@@ -0,0 +1,146 @@
1
+ #!/usr/bin/env python3
2
+ """Native-layout synthetic tests; no real pool, credentials, keyring, or network."""
3
+ import fcntl
4
+ import json
5
+ import multiprocessing
6
+ import os
7
+ from pathlib import Path
8
+ import sys
9
+ import tempfile
10
+ import unittest
11
+ from unittest.mock import patch
12
+
13
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'lib'))
14
+ import codex_mcp_oauth as M
15
+ from codex_mcp_oauth_store import PLACEHOLDER, SENTINEL
16
+
17
+ KEY = 'motionsites|1234567890abcdef'
18
+ ENTRY = {'server_name': 'motionsites', 'server_url': 'https://example.invalid/mcp',
19
+ 'issuer': 'https://example.invalid', 'client_id': 'fake-client', 'access_token': 'fake-access',
20
+ 'refresh_token': 'fake-refresh', 'expires_at': 1900000000000, 'scopes': ['read']}
21
+
22
+
23
+ def native_update(account, key, entry):
24
+ """Mirror native O_NOFOLLOW, aggregate flock, truncate-in-place, empty unlink."""
25
+ lock = account / 'mcp-oauth-locks' / 'file-store.lock'
26
+ with lock.open('a') as handle:
27
+ fcntl.flock(handle, fcntl.LOCK_EX)
28
+ path = account / '.credentials.json'
29
+ doc = json.loads(path.read_text())
30
+ if entry is None:
31
+ doc.pop(key, None)
32
+ else:
33
+ doc[key] = entry
34
+ if not doc:
35
+ path.unlink()
36
+ return
37
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_NOFOLLOW, 0o600)
38
+ with os.fdopen(fd, 'w') as stream:
39
+ stream.truncate(0)
40
+ json.dump(doc, stream)
41
+
42
+
43
+ class SharedStoreTests(unittest.TestCase):
44
+ def setUp(self):
45
+ self.temp = tempfile.TemporaryDirectory()
46
+ self.addCleanup(self.temp.cleanup)
47
+ self.root = Path(self.temp.name)
48
+ self.accounts = [self.root / 'acct-01', self.root / 'acct-02']
49
+ for path in self.accounts:
50
+ path.mkdir()
51
+ (path / 'auth.json').write_text('subscription-login-must-stay-byte-identical')
52
+ (self.root / 'accounts.json').write_text(json.dumps({'accounts': [{'id': p.name} for p in self.accounts]}))
53
+ self.shared = self.root / M.SHARED_DIR
54
+
55
+ def seed(self, account=0, entry=None):
56
+ path = self.accounts[account] / '.credentials.json'
57
+ path.write_text(json.dumps({KEY: ENTRY if entry is None else entry}))
58
+ return path
59
+
60
+ def test_share_native_refresh_and_last_logout_without_resurrection(self):
61
+ source = self.seed()
62
+ inode = source.stat().st_ino
63
+ self.assertTrue(M.ensure(self.root))
64
+ for account in self.accounts:
65
+ self.assertEqual((account / '.credentials.json').stat().st_ino, inode)
66
+ self.assertEqual((account / 'auth.json').read_text(), 'subscription-login-must-stay-byte-identical')
67
+ native_update(self.accounts[1], KEY, dict(ENTRY, access_token='rotated-fake'))
68
+ self.assertEqual(json.loads(source.read_text())[KEY]['access_token'], 'rotated-fake')
69
+ native_update(self.accounts[1], KEY, None)
70
+ self.assertTrue(M.ensure(self.root))
71
+ for account in self.accounts:
72
+ self.assertEqual(json.loads((account / '.credentials.json').read_text()), {SENTINEL: PLACEHOLDER})
73
+ self.assertEqual(source.stat().st_mode & 0o777, 0o600)
74
+ before = source.stat().st_mtime_ns
75
+ receipt_before = (self.shared / 'managed.json').stat().st_mtime_ns
76
+ self.assertTrue(M.ensure(self.root))
77
+ self.assertEqual(source.stat().st_mtime_ns, before)
78
+ self.assertEqual((self.shared / 'managed.json').stat().st_mtime_ns, receipt_before)
79
+
80
+ def test_empty_and_disabled_pools_have_no_auth_state(self):
81
+ self.assertFalse(M.ensure(self.root))
82
+ self.assertFalse(self.shared.exists())
83
+ self.seed()
84
+ with patch.dict(os.environ, {'CODEX_MULTIACC_MCP_OAUTH': '0'}):
85
+ self.assertFalse(M.ensure(self.root))
86
+ self.assertFalse(self.shared.exists())
87
+ self.assertTrue(M.ensure(self.root, initialize=True))
88
+
89
+ def test_conflicting_grants_and_scoped_credentials_are_untouched(self):
90
+ first = self.seed()
91
+ second = self.seed(1, dict(ENTRY, refresh_token='different-grant'))
92
+ before = [first.read_bytes(), second.read_bytes()]
93
+ self.assertFalse(M.ensure(self.root))
94
+ self.assertEqual([first.read_bytes(), second.read_bytes()], before)
95
+ self.assertFalse(self.shared.exists())
96
+ second.unlink()
97
+ for update in ({'executor_owned': True}, {'server_name': 'ema-idp:private'}):
98
+ self.seed(entry=dict(ENTRY, **update))
99
+ self.assertFalse(M.ensure(self.root))
100
+ self.assertFalse(self.shared.exists())
101
+
102
+ def test_native_lock_contention_defers_and_preserves_owner_inode(self):
103
+ self.seed()
104
+ directory = self.accounts[0] / 'mcp-oauth-locks'
105
+ directory.mkdir()
106
+ file_lock = directory / 'file-store.lock'
107
+ file_lock.touch()
108
+ file_inode = file_lock.stat().st_ino
109
+ path = directory / ('a' * 64 + '.lock')
110
+ with path.open('w') as handle:
111
+ inode = path.stat().st_ino
112
+ fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
113
+ self.assertFalse(M.ensure(self.root))
114
+ self.assertFalse((self.accounts[1] / '.credentials.json').exists())
115
+ self.assertTrue(M.ensure(self.root))
116
+ self.assertEqual((self.shared / 'locks' / path.name).stat().st_ino, inode)
117
+ self.assertEqual((self.shared / 'locks' / 'file-store.lock').stat().st_ino, file_inode)
118
+ with (self.accounts[0] / 'mcp-oauth-locks' / path.name).open('a') as handle:
119
+ fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
120
+ other = os.open(self.accounts[1] / 'mcp-oauth-locks' / path.name, os.O_RDWR)
121
+ try:
122
+ with self.assertRaises(BlockingIOError):
123
+ fcntl.flock(other, fcntl.LOCK_EX | fcntl.LOCK_NB)
124
+ finally:
125
+ os.close(other)
126
+
127
+ def test_concurrent_native_writers_preserve_both_servers(self):
128
+ self.seed()
129
+ self.assertTrue(M.ensure(self.root))
130
+ processes = []
131
+ context = multiprocessing.get_context('fork')
132
+ for index, account in enumerate(self.accounts):
133
+ name = 'server' + str(index)
134
+ process = context.Process(target=native_update, args=(account, name, dict(ENTRY, server_name=name)))
135
+ process.start()
136
+ processes.append(process)
137
+ for process in processes:
138
+ process.join(5)
139
+ self.assertEqual(process.exitcode, 0)
140
+ doc = json.loads((self.shared / 'credentials.json').read_text())
141
+ self.assertIn('server0', doc)
142
+ self.assertIn('server1', doc)
143
+
144
+
145
+ if __name__ == '__main__':
146
+ unittest.main()