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.
- package/README.md +12 -3
- package/bin/claude +14 -3
- package/bin/codex +42 -10
- package/bin/codex-accounts +12 -4
- package/docs/ACCOUNT_OPERATIONS.md +2 -2
- package/docs/AUTORESUME.md +6 -1
- package/docs/CODEX.md +19 -2
- package/docs/CODEX_MCP_AUTH.md +58 -0
- package/lib/__pycache__/audit.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_cross.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_cross_dispatch.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_cross_receipt.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_handoff.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_probe.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_receipt.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_client_error.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_token_identity.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_autoresume_mode.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credit_controls.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credit_local_scope.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credit_selection.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credits.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_limits_lock.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_mcp_oauth.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_resume_cwd.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_trust.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_usage_fetch.cpython-312.pyc +0 -0
- package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
- package/lib/__pycache__/mcp_mirror.cpython-312.pyc +0 -0
- package/lib/__pycache__/mcp_oauth.cpython-312.pyc +0 -0
- package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
- package/lib/__pycache__/model_catalog.cpython-312.pyc +0 -0
- package/lib/__pycache__/model_match.cpython-312.pyc +0 -0
- package/lib/__pycache__/report.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_credit_scope.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_credits.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_funding.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
- package/lib/__pycache__/session_settings.cpython-312.pyc +0 -0
- package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
- package/lib/autoresume.py +6 -5
- package/lib/autoresume_cross.py +2 -1
- package/lib/autoresume_handoff.py +13 -5
- package/lib/codex_exec_stdin.sh +45 -0
- package/lib/codex_mcp_oauth.py +204 -0
- package/lib/codex_mcp_oauth.sh +29 -0
- package/lib/codex_mcp_oauth_locks.py +158 -0
- package/lib/codex_mcp_oauth_native.py +102 -0
- package/lib/codex_mcp_oauth_store.py +149 -0
- package/lib/codex_resume_cwd.py +57 -0
- package/lib/codex_retry_process.sh +37 -0
- package/lib/common.sh +3 -0
- package/lib/mcp_mirror.py +49 -0
- package/lib/mcp_mirror.sh +47 -0
- package/lib/mcp_registry.py +7 -2
- package/package.json +9 -4
- package/tests/__pycache__/autoresume_cross_receipt_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/autoresume_cross_tmux_environment.cpython-312.pyc +0 -0
- package/tests/__pycache__/autoresume_cross_tmux_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/autoresume_cross_unit_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/autoresume_tmux_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_catalog.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_catalog_cli.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_codex_records.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_codex_trust.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_claude_holders.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_guards.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_holders.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_origin.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_policy.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_receipt.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_tmux.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_tmux_races.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_tokens.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cursor.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cwd.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cwd_handoff.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cwd_reader.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cwd_tmux.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_fallback.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_handoff_argv.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_handoff_history.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_handoff_origins.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_memory.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_memory_tmux.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_mode.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_probe.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_receipt.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_relaunch_settings.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_settings.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_sidechain.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_sidechain_tmux.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_tmux.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_tmux_modes.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_trust_tmux.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_exec_stdin.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_mcp_oauth.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_credits.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_credits_polling.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_credits_report.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_fetch.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_mcp_mirror.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_mcp_mirror_args.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_mcp_oauth.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_mcp_registry.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_cached_credits.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_credit_scope.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_credit_shim.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_credits.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_spread.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
- package/tests/fake_codex_tui.py +23 -1
- package/tests/run-tests.sh +3 -1
- package/tests/test_autoresume_cwd.py +71 -0
- package/tests/test_autoresume_cwd_handoff.py +92 -0
- package/tests/test_autoresume_cwd_reader.py +105 -0
- package/tests/test_autoresume_cwd_tmux.py +54 -0
- package/tests/test_codex_exec_stdin.py +178 -0
- package/tests/test_codex_mcp_oauth.py +146 -0
- package/tests/test_codex_mcp_oauth_adopt.py +87 -0
- package/tests/test_codex_mcp_oauth_migration.py +141 -0
- package/tests/test_codex_mcp_oauth_native.py +109 -0
- package/tests/test_codex_mcp_oauth_safety.py +130 -0
- package/tests/test_codex_mcp_oauth_shim.py +188 -0
- package/tests/test_codex_retry_process.py +143 -0
- package/tests/test_mcp_mirror.py +148 -0
- 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()
|