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.
- package/README.md +6 -2
- package/bin/codex +30 -7
- 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/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/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
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Linux process exit must not hide a live native OAuth lock holder."""
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
import tempfile
|
|
8
|
+
import time
|
|
9
|
+
import unittest
|
|
10
|
+
from unittest.mock import MagicMock, patch
|
|
11
|
+
|
|
12
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'lib'))
|
|
13
|
+
import codex_mcp_oauth_native as N
|
|
14
|
+
from codex_mcp_oauth_store import Deferred
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def scan_process(process):
|
|
18
|
+
"""Limit the proc fixture to this test's process, retaining real fd operations."""
|
|
19
|
+
root = MagicMock()
|
|
20
|
+
root.iterdir.return_value = [process]
|
|
21
|
+
with patch.object(N, 'Path', return_value=root):
|
|
22
|
+
return N.linux_handles(set(), set())
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class NativeProcessTests(unittest.TestCase):
|
|
26
|
+
def test_denied_live_or_unproven_process_still_defers(self):
|
|
27
|
+
statuses = [
|
|
28
|
+
'State:\tS (sleeping)\nThreads:\t1\n',
|
|
29
|
+
'State:\tZ (zombie)\nThreads:\t2\n',
|
|
30
|
+
'State:\tZ (zombie)\n',
|
|
31
|
+
'State:\tZ (zombie)\nThreads:\tinvalid\n',
|
|
32
|
+
'Threads:\t1\n',
|
|
33
|
+
]
|
|
34
|
+
process = MagicMock(spec=Path)
|
|
35
|
+
process.name = '12345'
|
|
36
|
+
process.stat.return_value.st_uid = os.getuid()
|
|
37
|
+
process.__truediv__.return_value.iterdir.side_effect = PermissionError('denied')
|
|
38
|
+
for status in statuses:
|
|
39
|
+
with self.subTest(status=status):
|
|
40
|
+
process.__truediv__.return_value.read_text.return_value = status
|
|
41
|
+
with self.assertRaises(Deferred):
|
|
42
|
+
scan_process(process)
|
|
43
|
+
|
|
44
|
+
def test_denied_single_thread_exit_does_not_block_migration(self):
|
|
45
|
+
process = MagicMock(spec=Path)
|
|
46
|
+
process.name = '12345'
|
|
47
|
+
process.stat.return_value.st_uid = os.getuid()
|
|
48
|
+
process.__truediv__.return_value.iterdir.side_effect = PermissionError('denied')
|
|
49
|
+
for state in ('Z (zombie)', 'X (dead)'):
|
|
50
|
+
with self.subTest(state=state):
|
|
51
|
+
process.__truediv__.return_value.read_text.return_value = f'State:\t{state}\nThreads:\t1\n'
|
|
52
|
+
self.assertFalse(scan_process(process))
|
|
53
|
+
|
|
54
|
+
def test_unreadable_status_defers_but_vanished_process_does_not(self):
|
|
55
|
+
process = MagicMock(spec=Path)
|
|
56
|
+
process.name = '12345'
|
|
57
|
+
process.stat.return_value.st_uid = os.getuid()
|
|
58
|
+
process.__truediv__.return_value.iterdir.side_effect = PermissionError('denied')
|
|
59
|
+
process.__truediv__.return_value.read_text.side_effect = PermissionError('denied')
|
|
60
|
+
with self.assertRaises(Deferred):
|
|
61
|
+
scan_process(process)
|
|
62
|
+
process.__truediv__.return_value.read_text.side_effect = FileNotFoundError('gone')
|
|
63
|
+
self.assertFalse(scan_process(process))
|
|
64
|
+
|
|
65
|
+
@unittest.skipUnless(sys.platform == 'linux', 'requires Linux proc metadata')
|
|
66
|
+
def test_real_unreaped_child_releases_its_file_descriptors(self):
|
|
67
|
+
child = subprocess.Popen([sys.executable, '-c', 'pass'])
|
|
68
|
+
try:
|
|
69
|
+
os.waitid(os.P_PID, child.pid, os.WEXITED | os.WNOWAIT)
|
|
70
|
+
process = Path('/proc') / str(child.pid)
|
|
71
|
+
self.assertTrue(N.exited_process(process))
|
|
72
|
+
self.assertFalse(scan_process(process))
|
|
73
|
+
finally:
|
|
74
|
+
child.wait(timeout=3)
|
|
75
|
+
|
|
76
|
+
@unittest.skipUnless(sys.platform == 'linux', 'requires Linux thread-group behavior')
|
|
77
|
+
def test_zombie_leader_with_living_thread_still_defers(self):
|
|
78
|
+
script = '''import ctypes,os,sys,threading,time
|
|
79
|
+
ready=threading.Event()
|
|
80
|
+
def owner():
|
|
81
|
+
with open(sys.argv[1], 'w'):
|
|
82
|
+
ready.set()
|
|
83
|
+
time.sleep(10)
|
|
84
|
+
threading.Thread(target=owner).start()
|
|
85
|
+
ready.wait()
|
|
86
|
+
ctypes.CDLL(None).pthread_exit(None)
|
|
87
|
+
'''
|
|
88
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
89
|
+
child = subprocess.Popen([sys.executable, '-c', script, str(Path(directory) / 'lock')])
|
|
90
|
+
try:
|
|
91
|
+
process = Path('/proc') / str(child.pid)
|
|
92
|
+
deadline = time.monotonic() + 3
|
|
93
|
+
while time.monotonic() < deadline:
|
|
94
|
+
status = (process / 'status').read_text()
|
|
95
|
+
if 'State:\tZ' in status and 'Threads:\t2' in status:
|
|
96
|
+
break
|
|
97
|
+
time.sleep(0.01)
|
|
98
|
+
else:
|
|
99
|
+
self.fail('fixture did not leave a zombie leader with a living thread')
|
|
100
|
+
self.assertFalse(N.exited_process(process))
|
|
101
|
+
with self.assertRaises(Deferred):
|
|
102
|
+
scan_process(process)
|
|
103
|
+
finally:
|
|
104
|
+
child.kill()
|
|
105
|
+
child.wait(timeout=3)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
if __name__ == '__main__':
|
|
109
|
+
unittest.main()
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Sharing boundaries and recovery use synthetic machine-local pools only."""
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import sys
|
|
7
|
+
import tempfile
|
|
8
|
+
import subprocess
|
|
9
|
+
import unittest
|
|
10
|
+
from unittest.mock import patch
|
|
11
|
+
|
|
12
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'lib'))
|
|
13
|
+
import codex_mcp_oauth as M
|
|
14
|
+
from codex_mcp_oauth_store import SENTINEL
|
|
15
|
+
|
|
16
|
+
ENTRY = {'server_name': 'demo', 'server_url': 'https://example.invalid/mcp',
|
|
17
|
+
'client_id': 'fake-client', 'access_token': 'fake-access', 'refresh_token': 'fake-refresh'}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SafetyTests(unittest.TestCase):
|
|
21
|
+
def setUp(self):
|
|
22
|
+
self.temp = tempfile.TemporaryDirectory()
|
|
23
|
+
self.addCleanup(self.temp.cleanup)
|
|
24
|
+
self.root = Path(self.temp.name)
|
|
25
|
+
self.account = self.root / 'acct-01'
|
|
26
|
+
self.account.mkdir()
|
|
27
|
+
(self.root / 'accounts.json').write_text(json.dumps({'accounts': [{'id': 'acct-01'}]}))
|
|
28
|
+
self.source = self.account / '.credentials.json'
|
|
29
|
+
self.source.write_text(json.dumps({'demo|1234': ENTRY}))
|
|
30
|
+
self.shared = self.root / M.SHARED_DIR
|
|
31
|
+
|
|
32
|
+
def test_nonmanifest_and_symlink_accounts_are_never_touched(self):
|
|
33
|
+
outside = self.root / 'unmanaged'
|
|
34
|
+
outside.mkdir()
|
|
35
|
+
self.assertFalse(M.ensure(self.root, outside))
|
|
36
|
+
self.assertFalse(self.shared.exists())
|
|
37
|
+
self.source.unlink()
|
|
38
|
+
self.account.rmdir()
|
|
39
|
+
self.account.symlink_to(outside, target_is_directory=True)
|
|
40
|
+
self.assertFalse(M.ensure(self.root, self.account, initialize=True))
|
|
41
|
+
self.assertEqual(list(outside.iterdir()), [])
|
|
42
|
+
|
|
43
|
+
def test_symlink_credentials_and_shared_state_are_refused(self):
|
|
44
|
+
other = self.root / 'private.json'
|
|
45
|
+
self.source.rename(other)
|
|
46
|
+
before = other.read_bytes()
|
|
47
|
+
self.source.symlink_to(other)
|
|
48
|
+
self.assertFalse(M.ensure(self.root))
|
|
49
|
+
self.assertEqual(other.read_bytes(), before)
|
|
50
|
+
self.source.unlink()
|
|
51
|
+
other.rename(self.source)
|
|
52
|
+
outside = self.root / 'outside'
|
|
53
|
+
outside.mkdir()
|
|
54
|
+
self.shared.symlink_to(outside, target_is_directory=True)
|
|
55
|
+
self.assertFalse(M.ensure(self.root))
|
|
56
|
+
self.assertEqual(list(outside.iterdir()), [])
|
|
57
|
+
|
|
58
|
+
def test_shared_canonical_deletion_never_reimports_old_link(self):
|
|
59
|
+
self.assertTrue(M.ensure(self.root))
|
|
60
|
+
(self.shared / 'credentials.json').unlink()
|
|
61
|
+
self.assertTrue(M.ensure(self.root))
|
|
62
|
+
self.assertEqual(set(json.loads(self.source.read_text())), {SENTINEL})
|
|
63
|
+
self.assertTrue(M.ensure(self.root))
|
|
64
|
+
self.assertEqual(set(json.loads(self.source.read_text())), {SENTINEL})
|
|
65
|
+
|
|
66
|
+
def test_removed_account_link_clears_shared_document(self):
|
|
67
|
+
self.assertTrue(M.ensure(self.root))
|
|
68
|
+
self.source.unlink()
|
|
69
|
+
self.assertTrue(M.ensure(self.root))
|
|
70
|
+
self.assertEqual(set(json.loads(self.source.read_text())), {SENTINEL})
|
|
71
|
+
|
|
72
|
+
def test_foreign_locks_and_malformed_documents_are_refused(self):
|
|
73
|
+
foreign = self.root / 'foreign-locks'
|
|
74
|
+
foreign.mkdir()
|
|
75
|
+
locks = self.account / 'mcp-oauth-locks'
|
|
76
|
+
locks.symlink_to(foreign, target_is_directory=True)
|
|
77
|
+
self.assertFalse(M.ensure(self.root))
|
|
78
|
+
self.assertEqual(list(foreign.iterdir()), [])
|
|
79
|
+
locks.unlink()
|
|
80
|
+
for doc in ([], {'bad': {'access_token': 'secret-do-not-print'}}, {'bad': dict(ENTRY, expires_at='bad')}):
|
|
81
|
+
self.source.write_text(json.dumps(doc))
|
|
82
|
+
self.assertFalse(M.ensure(self.root))
|
|
83
|
+
self.assertFalse(self.shared.exists())
|
|
84
|
+
|
|
85
|
+
def test_encrypted_backend_is_refused_unless_explicit_file_store(self):
|
|
86
|
+
config = self.account / 'config.toml'
|
|
87
|
+
config.write_text('[features]\nsecret_auth_storage = true\n')
|
|
88
|
+
self.assertFalse(M.ensure(self.root))
|
|
89
|
+
self.assertFalse(self.shared.exists())
|
|
90
|
+
config.write_text('mcp_oauth_credentials_store = "file"\n[features]\nsecret_auth_storage = true\n')
|
|
91
|
+
self.assertTrue(M.ensure(self.root))
|
|
92
|
+
|
|
93
|
+
def test_cli_refusal_reports_only_static_reason(self):
|
|
94
|
+
binary = self.root / 'codex-fake'
|
|
95
|
+
binary.write_text('#!/bin/sh\necho "codex-cli 0.163.0"\n')
|
|
96
|
+
binary.chmod(0o700)
|
|
97
|
+
self.source.write_text('{"secret-do-not-print": "private-value-do-not-print"}')
|
|
98
|
+
command = [sys.executable, M.__file__, 'ensure', '--root', str(self.root), '--codex-bin', str(binary)]
|
|
99
|
+
result = subprocess.run(command, capture_output=True, text=True, check=False)
|
|
100
|
+
self.assertEqual(result.returncode, 1)
|
|
101
|
+
self.assertIn('invalid credential entry', result.stderr)
|
|
102
|
+
self.assertNotIn('private-value', result.stdout + result.stderr)
|
|
103
|
+
self.assertNotIn('secret-do-not-print', result.stdout + result.stderr)
|
|
104
|
+
quiet = subprocess.run(command + ['--quiet'], capture_output=True, text=True, check=False)
|
|
105
|
+
self.assertEqual(quiet.returncode, 1)
|
|
106
|
+
self.assertEqual(quiet.stdout + quiet.stderr, '')
|
|
107
|
+
|
|
108
|
+
def test_failed_link_staging_preserves_original_grant_and_lock_directory(self):
|
|
109
|
+
other = self.root / 'acct-02'
|
|
110
|
+
other.mkdir()
|
|
111
|
+
(self.root / 'accounts.json').write_text(json.dumps({'accounts': [{'id': 'acct-01'}, {'id': 'acct-02'}]}))
|
|
112
|
+
before = self.source.read_bytes()
|
|
113
|
+
real_link = os.link
|
|
114
|
+
|
|
115
|
+
def reject_destination(source, destination):
|
|
116
|
+
if Path(destination).parent == other.resolve():
|
|
117
|
+
raise OSError('synthetic destination failure')
|
|
118
|
+
return real_link(source, destination)
|
|
119
|
+
|
|
120
|
+
with patch('codex_mcp_oauth_store.os.link', side_effect=reject_destination):
|
|
121
|
+
self.assertFalse(M.ensure(self.root))
|
|
122
|
+
self.assertEqual(self.source.read_bytes(), before)
|
|
123
|
+
self.assertFalse((other / '.credentials.json').exists())
|
|
124
|
+
self.assertFalse((self.account / 'mcp-oauth-locks').is_symlink())
|
|
125
|
+
self.assertEqual(list(other.glob('.codex-mcp-oauth*')), [])
|
|
126
|
+
self.assertTrue(M.ensure(self.root))
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
if __name__ == '__main__':
|
|
130
|
+
unittest.main()
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Exercise Codex OAuth launch hooks with native-shaped, entirely synthetic state."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import tempfile
|
|
11
|
+
import time
|
|
12
|
+
import unittest
|
|
13
|
+
|
|
14
|
+
REPO = Path(__file__).resolve().parents[1]
|
|
15
|
+
SHIM = REPO / 'bin/codex'
|
|
16
|
+
CLI = REPO / 'bin/codex-accounts'
|
|
17
|
+
ENTRY = {'server_name': 'motionsites', 'server_url': 'https://example.invalid/mcp',
|
|
18
|
+
'client_id': 'fake-client', 'access_token': 'fake-mcp-access', 'refresh_token': 'fake-mcp-refresh',
|
|
19
|
+
'expires_at': 2000000000000, 'scopes': []}
|
|
20
|
+
DOCUMENT = {'motionsites|0000000000000001': ENTRY}
|
|
21
|
+
REAL = '''#!/bin/sh
|
|
22
|
+
if [ "${1:-}" = --version ]; then
|
|
23
|
+
echo "codex-cli ${TEST_CODEX_VERSION:-0.163.0}"
|
|
24
|
+
exit 0
|
|
25
|
+
fi
|
|
26
|
+
printf 'exec %s\n' "${CODEX_HOME##*/}" >> "$EVENT_LOG"
|
|
27
|
+
if [ "$CODEX_HOME/.credentials.json" -ef "$CODEX_ACCOUNTS_ROOT/.codex-mcp-oauth/credentials.json" ]; then
|
|
28
|
+
echo shared
|
|
29
|
+
else
|
|
30
|
+
echo private
|
|
31
|
+
fi
|
|
32
|
+
if [ "${TEST_RETRY_ONCE:-0}" = 1 ] && [ ! -e "$RETRY_MARK" ]; then
|
|
33
|
+
: > "$RETRY_MARK"
|
|
34
|
+
echo 'usage limit exceeded' >&2
|
|
35
|
+
exit 1
|
|
36
|
+
fi
|
|
37
|
+
'''
|
|
38
|
+
PYTHON = '''#!/bin/sh
|
|
39
|
+
printf 'python %s\n' "${1##*/}" >> "$EVENT_LOG"
|
|
40
|
+
exec "$REAL_PYTHON" "$@"
|
|
41
|
+
'''
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class CodexMcpOauthShimTests(unittest.TestCase):
|
|
45
|
+
def setUp(self):
|
|
46
|
+
work = tempfile.TemporaryDirectory(prefix='codex-mcp-oauth-shim-')
|
|
47
|
+
self.addCleanup(work.cleanup)
|
|
48
|
+
self.work = Path(work.name)
|
|
49
|
+
self.pool, self.home, self.realbin = [self.work / name for name in ('pool', 'home', 'realbin')]
|
|
50
|
+
for directory in (self.pool, self.home, self.realbin):
|
|
51
|
+
directory.mkdir()
|
|
52
|
+
self.accounts = [self.pool / name for name in ('acct-01', 'acct-02')]
|
|
53
|
+
manifest = {'version': 1, 'server': 'none', 'accounts': [{'id': p.name} for p in self.accounts]}
|
|
54
|
+
(self.pool / 'accounts.json').write_text(json.dumps(manifest))
|
|
55
|
+
for account in self.accounts:
|
|
56
|
+
account.mkdir()
|
|
57
|
+
(account / 'auth.json').write_text('{"tokens":{"access_token":"fake-codex-access"}}\n')
|
|
58
|
+
(account / 'config.toml').write_text('# account settings\nmodel = "kept"\n')
|
|
59
|
+
(account / 'limits.json').write_text(json.dumps({
|
|
60
|
+
'fetched_at': int(time.time()), 'weekly_percent': 10, 'session_percent': 10, 'max_percent': 10}))
|
|
61
|
+
self.credential = self.accounts[0] / '.credentials.json'
|
|
62
|
+
self.credential.write_text(json.dumps(DOCUMENT))
|
|
63
|
+
self.events = self.work / 'events.log'
|
|
64
|
+
for name, script in (('codex', REAL), ('python-log', PYTHON)):
|
|
65
|
+
executable = self.realbin / name
|
|
66
|
+
executable.write_text(script)
|
|
67
|
+
executable.chmod(0o755)
|
|
68
|
+
self.env = {
|
|
69
|
+
'PATH': f'{self.realbin}:/usr/bin:/bin:/usr/sbin:/sbin', 'HOME': str(self.home), 'USER': 'tester',
|
|
70
|
+
'LC_ALL': 'C', 'CODEX_ACCOUNTS_ROOT': str(self.pool), 'EVENT_LOG': str(self.events),
|
|
71
|
+
'CLAUDE_MULTIACC_PYTHON': str(self.realbin / 'python-log'), 'REAL_PYTHON': sys.executable,
|
|
72
|
+
'CODEX_MULTIACC_AUTORESUME': '0', 'CODEX_MULTIACC_CLIENT_LIMITS': '0',
|
|
73
|
+
'CODEX_MULTIACC_PATH_PROBE': '0', 'CODEX_MULTIACC_MODEL_CATALOG': '0',
|
|
74
|
+
'CLAUDE_MULTIACC_MODEL_CATALOG': '0', 'CODEX_MULTIACC_MCP': '0',
|
|
75
|
+
'RETRY_MARK': str(self.work / 'retried'), 'PYTHONDONTWRITEBYTECODE': '1',
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
def run_command(self, argv, **environment):
|
|
79
|
+
self.events.write_text('')
|
|
80
|
+
result = subprocess.run([str(item) for item in argv], env={**self.env, **environment},
|
|
81
|
+
stdin=subprocess.DEVNULL, capture_output=True, text=True, timeout=60)
|
|
82
|
+
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
|
83
|
+
self.assertNotIn('fake-mcp-access', result.stdout + result.stderr)
|
|
84
|
+
self.assertNotIn('fake-mcp-refresh', result.stdout + result.stderr)
|
|
85
|
+
return result, self.events.read_text().splitlines()
|
|
86
|
+
|
|
87
|
+
def test_pinned_selected_and_nested_clients_receive_shared_state_before_exec(self):
|
|
88
|
+
modes = ({'CODEX_ACCOUNT': 'acct-02'}, {},
|
|
89
|
+
{'CODEX_HOME': str(self.accounts[1]), 'CODEX_SHIM_ACTIVE': '1'})
|
|
90
|
+
original_auth = [(account / 'auth.json').read_bytes() for account in self.accounts]
|
|
91
|
+
for environment in modes:
|
|
92
|
+
with self.subTest(environment=environment):
|
|
93
|
+
result, events = self.run_command([SHIM, '--help'], **environment)
|
|
94
|
+
self.assertEqual(result.stdout.strip(), 'shared')
|
|
95
|
+
self.assertEqual(events[0], 'python codex_mcp_oauth.py')
|
|
96
|
+
self.assertTrue(events[-1].startswith('exec acct-'))
|
|
97
|
+
authority = self.pool / '.codex-mcp-oauth/credentials.json'
|
|
98
|
+
for account, before in zip(self.accounts, original_auth):
|
|
99
|
+
self.assertTrue(os.path.samefile(account / '.credentials.json', authority))
|
|
100
|
+
self.assertEqual((account / 'mcp-oauth-locks').resolve(), (authority.parent / 'locks').resolve())
|
|
101
|
+
self.assertEqual((account / 'auth.json').read_bytes(), before)
|
|
102
|
+
|
|
103
|
+
def test_retry_prepares_the_replacement_account_before_running_it(self):
|
|
104
|
+
result, events = self.run_command([SHIM, 'exec', 'test prompt'], TEST_RETRY_ONCE='1')
|
|
105
|
+
oauth_and_exec = [line for line in events if line == 'python codex_mcp_oauth.py' or line.startswith('exec ')]
|
|
106
|
+
self.assertEqual(len(oauth_and_exec), 4, events)
|
|
107
|
+
self.assertEqual(oauth_and_exec[::2], ['python codex_mcp_oauth.py'] * 2)
|
|
108
|
+
self.assertNotEqual(oauth_and_exec[1], oauth_and_exec[3])
|
|
109
|
+
self.assertEqual(result.stdout.strip(), 'shared')
|
|
110
|
+
|
|
111
|
+
def test_empty_pool_kill_switch_and_missing_python_preserve_launch(self):
|
|
112
|
+
self.credential.unlink()
|
|
113
|
+
result, events = self.run_command([SHIM, '--help'], CODEX_ACCOUNT='acct-02')
|
|
114
|
+
self.assertEqual((result.stdout.strip(), events), ('private', ['exec acct-02']))
|
|
115
|
+
self.assertFalse((self.pool / '.codex-mcp-oauth').exists())
|
|
116
|
+
self.credential.write_text(json.dumps(DOCUMENT))
|
|
117
|
+
for environment in ({'CODEX_MULTIACC_MCP_OAUTH': '0'}, {'CODEX_MULTIACC_DISABLE': '1',
|
|
118
|
+
'CODEX_HOME': str(self.accounts[1])},
|
|
119
|
+
{'CLAUDE_MULTIACC_PYTHON': '/nonexistent/python3'}):
|
|
120
|
+
with self.subTest(environment=environment):
|
|
121
|
+
result, events = self.run_command([SHIM, '--help'], CODEX_ACCOUNT='acct-02', **environment)
|
|
122
|
+
self.assertEqual((result.stdout.strip(), events), ('private', ['exec acct-02']))
|
|
123
|
+
self.assertFalse((self.accounts[1] / '.credentials.json').exists())
|
|
124
|
+
broken = self.work / 'broken-install'
|
|
125
|
+
(broken / 'bin').mkdir(parents=True)
|
|
126
|
+
(broken / 'lib').mkdir()
|
|
127
|
+
shim = broken / 'bin/codex'
|
|
128
|
+
shim.write_bytes(SHIM.read_bytes())
|
|
129
|
+
shim.chmod(0o755)
|
|
130
|
+
(broken / 'lib/codex_mcp_oauth.sh').write_text('if then invalid shell syntax\n')
|
|
131
|
+
result, events = self.run_command([shim, '--help'], CODEX_ACCOUNT='acct-02')
|
|
132
|
+
self.assertEqual((result.stdout.strip(), events), ('private', ['exec acct-02']))
|
|
133
|
+
|
|
134
|
+
def test_nested_foreign_and_retired_homes_cannot_initialize_pool_sharing(self):
|
|
135
|
+
for home in (self.work / 'foreign', self.pool / 'acct-99'):
|
|
136
|
+
with self.subTest(home=home):
|
|
137
|
+
home.mkdir()
|
|
138
|
+
path = home / '.credentials.json'
|
|
139
|
+
path.write_text('{"outside":"untouched"}\n')
|
|
140
|
+
before = path.read_bytes()
|
|
141
|
+
result, _events = self.run_command([SHIM, '--help'], CODEX_HOME=str(home), CODEX_SHIM_ACTIVE='1')
|
|
142
|
+
self.assertEqual(result.stdout.strip(), 'private')
|
|
143
|
+
self.assertEqual(path.read_bytes(), before)
|
|
144
|
+
self.assertFalse((self.pool / '.codex-mcp-oauth').exists())
|
|
145
|
+
self.assertEqual(json.loads(self.credential.read_text()), DOCUMENT)
|
|
146
|
+
|
|
147
|
+
def test_auth_sync_dispatches_and_keeps_cli_arguments(self):
|
|
148
|
+
result, events = self.run_command([CLI, 'mcp', 'auth-sync', '--quiet'])
|
|
149
|
+
self.assertEqual(result.stdout, '')
|
|
150
|
+
self.assertIn('python codex_mcp_oauth.py', events)
|
|
151
|
+
self.assertTrue(os.path.samefile(self.credential, self.accounts[1] / '.credentials.json'))
|
|
152
|
+
self.assertTrue((self.accounts[1] / 'mcp-oauth-locks').is_symlink())
|
|
153
|
+
|
|
154
|
+
def test_account_seeding_joins_existing_oauth_state(self):
|
|
155
|
+
command = ['bash', '-c', '. "$1"; seed_account_dir "$2"', 'seed',
|
|
156
|
+
REPO / 'lib/common.sh', self.accounts[1]]
|
|
157
|
+
_result, events = self.run_command(command, MULTIACC_PROVIDER='codex')
|
|
158
|
+
self.assertIn('python codex_mcp_oauth.py', events)
|
|
159
|
+
self.assertTrue(os.path.samefile(self.credential, self.accounts[1] / '.credentials.json'))
|
|
160
|
+
self.assertEqual((self.accounts[1] / 'config.toml').read_text(), '# account settings\nmodel = "kept"\n')
|
|
161
|
+
|
|
162
|
+
def test_pool_backstop_joins_every_manifest_account(self):
|
|
163
|
+
command = ['bash', '-c', '. "$1"; codex_mcp_oauth_ensure "$2" "$3" "$4" "" "$5"', 'backstop',
|
|
164
|
+
REPO / 'lib/codex_mcp_oauth.sh', self.pool, self.realbin / 'python-log',
|
|
165
|
+
REPO / 'lib/codex_mcp_oauth.py', self.realbin / 'codex']
|
|
166
|
+
_result, events = self.run_command(command)
|
|
167
|
+
self.assertEqual(events, ['python codex_mcp_oauth.py'])
|
|
168
|
+
self.assertTrue(os.path.samefile(self.credential, self.accounts[1] / '.credentials.json'))
|
|
169
|
+
# A subsequent pass must accept already linked lock dirs, including /var aliases on macOS.
|
|
170
|
+
newcomer = self.pool / 'acct-03'
|
|
171
|
+
newcomer.mkdir()
|
|
172
|
+
manifest_path = self.pool / 'accounts.json'
|
|
173
|
+
manifest = json.loads(manifest_path.read_text())
|
|
174
|
+
manifest['accounts'].append({'id': newcomer.name})
|
|
175
|
+
manifest_path.write_text(json.dumps(manifest))
|
|
176
|
+
self.run_command(command)
|
|
177
|
+
self.assertTrue(os.path.samefile(self.credential, newcomer / '.credentials.json'))
|
|
178
|
+
|
|
179
|
+
def test_older_native_client_preserves_private_credentials(self):
|
|
180
|
+
result, events = self.run_command([SHIM, '--help'], CODEX_ACCOUNT='acct-02', TEST_CODEX_VERSION='0.162.0')
|
|
181
|
+
self.assertEqual(result.stdout.strip(), 'private')
|
|
182
|
+
self.assertEqual(events, ['python codex_mcp_oauth.py', 'exec acct-02'])
|
|
183
|
+
self.assertFalse((self.pool / '.codex-mcp-oauth').exists())
|
|
184
|
+
self.assertEqual(json.loads(self.credential.read_text()), DOCUMENT)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
if __name__ == '__main__':
|
|
188
|
+
unittest.main()
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Buffered Codex retry owns and terminates only its active native child."""
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import signal
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import tempfile
|
|
11
|
+
import time
|
|
12
|
+
import unittest
|
|
13
|
+
|
|
14
|
+
import test_codex_exec_stdin as pipe_fixture
|
|
15
|
+
|
|
16
|
+
REPO = Path(__file__).resolve().parents[1]
|
|
17
|
+
HELPER = REPO / 'lib/codex_retry_process.sh'
|
|
18
|
+
RUNNER = '''source "$1"
|
|
19
|
+
exec 3< "$2/input"
|
|
20
|
+
fixture_root="$2"
|
|
21
|
+
trap 'printf cleaned > "$fixture_root/exit-cleanup"' EXIT
|
|
22
|
+
trap 'printf restored > "$fixture_root/restored-trap"' TERM
|
|
23
|
+
CODEX_HOME="$2/fake-profile" codex_retry_run "$3" "$2/native.py" "$2" "$4"
|
|
24
|
+
rc=$?
|
|
25
|
+
[ "$4" != restore ] || kill -TERM $$
|
|
26
|
+
exit "$rc"
|
|
27
|
+
'''
|
|
28
|
+
NATIVE = '''import json, os, pathlib, signal, sys, time
|
|
29
|
+
root = pathlib.Path(sys.argv[1])
|
|
30
|
+
mode = sys.argv[2]
|
|
31
|
+
def stop(number, frame):
|
|
32
|
+
(root / 'stopped').write_text(str(number))
|
|
33
|
+
sys.exit(0)
|
|
34
|
+
for number in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP):
|
|
35
|
+
signal.signal(number, stop)
|
|
36
|
+
data = sys.stdin.buffer.read()
|
|
37
|
+
(root / 'received').write_bytes(data)
|
|
38
|
+
(root / 'profile').write_text(os.environ.get('CODEX_HOME', ''))
|
|
39
|
+
(root / 'native.pid').write_text(str(os.getpid()))
|
|
40
|
+
if mode == 'wait':
|
|
41
|
+
while True:
|
|
42
|
+
time.sleep(0.05)
|
|
43
|
+
print(json.dumps({'mode': mode}))
|
|
44
|
+
print('native stderr', file=sys.stderr)
|
|
45
|
+
sys.exit(7 if mode == 'fail' else 0)
|
|
46
|
+
'''
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class CodexRetryProcessTests(unittest.TestCase):
|
|
50
|
+
def launch(self, mode):
|
|
51
|
+
temporary = tempfile.TemporaryDirectory(prefix='multiacc-retry-process-')
|
|
52
|
+
self.addCleanup(temporary.cleanup)
|
|
53
|
+
root = Path(temporary.name)
|
|
54
|
+
(root / 'input').write_bytes(b'finite stdin\n\x00\xff')
|
|
55
|
+
(root / 'native.py').write_text(NATIVE)
|
|
56
|
+
process = subprocess.Popen(
|
|
57
|
+
['bash', '-c', RUNNER, 'retry-test', str(HELPER), str(root), sys.executable, mode],
|
|
58
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
59
|
+
self.addCleanup(self.cleanup_process, process, root)
|
|
60
|
+
deadline = time.monotonic() + 5
|
|
61
|
+
while not (root / 'native.pid').exists() and process.poll() is None and time.monotonic() < deadline:
|
|
62
|
+
time.sleep(0.02)
|
|
63
|
+
self.assertTrue((root / 'native.pid').exists(), 'owned native child did not launch')
|
|
64
|
+
return process, root
|
|
65
|
+
|
|
66
|
+
def cleanup_process(self, process, root):
|
|
67
|
+
# Only this fixture's owned native PID; ordinary assertions verify it was
|
|
68
|
+
# already reaped. A regression must not leave a sleeping fake behind.
|
|
69
|
+
if process.poll() is None:
|
|
70
|
+
process.terminate()
|
|
71
|
+
pid_file = root / 'native.pid'
|
|
72
|
+
if pid_file.exists():
|
|
73
|
+
try:
|
|
74
|
+
os.kill(int(pid_file.read_text()), signal.SIGTERM)
|
|
75
|
+
except ProcessLookupError:
|
|
76
|
+
pass
|
|
77
|
+
try:
|
|
78
|
+
process.communicate(timeout=3)
|
|
79
|
+
except subprocess.TimeoutExpired:
|
|
80
|
+
process.kill()
|
|
81
|
+
process.communicate(timeout=3)
|
|
82
|
+
|
|
83
|
+
def test_termination_signals_reach_and_reap_only_the_owned_native_child(self):
|
|
84
|
+
for number in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP):
|
|
85
|
+
with self.subTest(signal=number):
|
|
86
|
+
process, root = self.launch('wait')
|
|
87
|
+
process.send_signal(number)
|
|
88
|
+
process.communicate(timeout=3)
|
|
89
|
+
self.assertEqual(process.returncode, 128 + number)
|
|
90
|
+
self.assertEqual(int((root / 'stopped').read_text()), number)
|
|
91
|
+
with self.assertRaises(ProcessLookupError):
|
|
92
|
+
os.kill(int((root / 'native.pid').read_text()), 0)
|
|
93
|
+
self.assertEqual((root / 'exit-cleanup').read_text(), 'cleaned')
|
|
94
|
+
|
|
95
|
+
def test_success_failure_and_prior_traps_are_preserved(self):
|
|
96
|
+
for mode, expected in [('success', 0), ('fail', 7), ('restore', 0)]:
|
|
97
|
+
with self.subTest(mode=mode):
|
|
98
|
+
process, root = self.launch(mode)
|
|
99
|
+
stdout, stderr = process.communicate(timeout=3)
|
|
100
|
+
self.assertEqual(process.returncode, expected)
|
|
101
|
+
self.assertEqual(json.loads(stdout), {'mode': mode})
|
|
102
|
+
self.assertEqual(stderr, b'native stderr\n')
|
|
103
|
+
self.assertEqual((root / 'received').read_bytes(), (root / 'input').read_bytes())
|
|
104
|
+
self.assertEqual((root / 'profile').read_text(), str(root / 'fake-profile'))
|
|
105
|
+
self.assertEqual((root / 'exit-cleanup').read_text(), 'cleaned')
|
|
106
|
+
if mode == 'restore':
|
|
107
|
+
self.assertEqual((root / 'restored-trap').read_text(), 'restored')
|
|
108
|
+
|
|
109
|
+
def test_actual_shim_pipe_cancellation_reaps_native_without_retry_or_scratch(self):
|
|
110
|
+
fixture = pipe_fixture.CodexExecStdinTests()
|
|
111
|
+
fixture.setUp()
|
|
112
|
+
self.addCleanup(fixture.doCleanups)
|
|
113
|
+
root = fixture.root
|
|
114
|
+
native = NATIVE.replace('root = pathlib.Path(sys.argv[1])',
|
|
115
|
+
"root = pathlib.Path(os.environ['FAKE_ROOT'])\n"
|
|
116
|
+
"with (root / 'attempts').open('a') as handle: handle.write('attempt\\n')")
|
|
117
|
+
native = native.replace('mode = sys.argv[2]', "mode = 'wait'")
|
|
118
|
+
(root / 'bin/codex').write_text(f'#!{sys.executable}\n' + native)
|
|
119
|
+
process = subprocess.Popen([str(REPO / 'bin/codex'), *fixture.args], env=fixture.env,
|
|
120
|
+
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
121
|
+
self.addCleanup(self.cleanup_process, process, root)
|
|
122
|
+
process.stdin.write(pipe_fixture.PROMPT)
|
|
123
|
+
process.stdin.close()
|
|
124
|
+
process.stdin = None
|
|
125
|
+
deadline = time.monotonic() + 5
|
|
126
|
+
while not (root / 'native.pid').exists() and process.poll() is None and time.monotonic() < deadline:
|
|
127
|
+
time.sleep(0.02)
|
|
128
|
+
self.assertTrue((root / 'native.pid').exists(), 'shim did not launch native child')
|
|
129
|
+
process.send_signal(signal.SIGTERM)
|
|
130
|
+
process.communicate(timeout=3)
|
|
131
|
+
self.assertEqual(process.returncode, 143)
|
|
132
|
+
self.assertEqual(int((root / 'stopped').read_text()), signal.SIGTERM)
|
|
133
|
+
with self.assertRaises(ProcessLookupError):
|
|
134
|
+
os.kill(int((root / 'native.pid').read_text()), 0)
|
|
135
|
+
self.assertEqual((root / 'attempts').read_text(), 'attempt\n')
|
|
136
|
+
self.assertEqual((root / 'received').read_bytes(), pipe_fixture.PROMPT)
|
|
137
|
+
self.assertEqual(list(fixture.pool.glob('tmp/shim.*')), [])
|
|
138
|
+
self.assertNotIn('retry from=', (fixture.pool / 'selection.log').read_text())
|
|
139
|
+
self.assertFalse((fixture.pool / 'acct-01/.limited').exists())
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
if __name__ == '__main__':
|
|
143
|
+
unittest.main()
|