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,87 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""The documented default-home adoption is the only supported external account link."""
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import sys
|
|
7
|
+
import tempfile
|
|
8
|
+
import unittest
|
|
9
|
+
from unittest.mock import patch
|
|
10
|
+
|
|
11
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'lib'))
|
|
12
|
+
import codex_mcp_oauth as M
|
|
13
|
+
|
|
14
|
+
ENTRY = {'server_name': 'demo', 'server_url': 'https://example.invalid/mcp',
|
|
15
|
+
'client_id': 'fake-client', 'access_token': 'fake-access', 'refresh_token': 'fake-refresh'}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class DefaultAdoptionTests(unittest.TestCase):
|
|
19
|
+
def setUp(self):
|
|
20
|
+
self.temp = tempfile.TemporaryDirectory()
|
|
21
|
+
self.addCleanup(self.temp.cleanup)
|
|
22
|
+
self.home = Path(self.temp.name).resolve()
|
|
23
|
+
environment = patch.dict(os.environ, {'HOME': str(self.home)})
|
|
24
|
+
environment.start()
|
|
25
|
+
self.addCleanup(environment.stop)
|
|
26
|
+
self.root = self.home / '.codex-accounts'
|
|
27
|
+
self.root.mkdir()
|
|
28
|
+
self.default = self.home / '.codex'
|
|
29
|
+
self.default.mkdir()
|
|
30
|
+
self.adopted = self.root / 'acct-01'
|
|
31
|
+
self.adopted.symlink_to(self.default, target_is_directory=True)
|
|
32
|
+
self.regular = self.root / 'acct-02'
|
|
33
|
+
self.regular.mkdir()
|
|
34
|
+
(self.root / 'accounts.json').write_text(json.dumps({'accounts': [{'id': 'acct-01'}, {'id': 'acct-02'}]}))
|
|
35
|
+
(self.regular / '.credentials.json').write_text(json.dumps({'demo|1234': ENTRY}))
|
|
36
|
+
(self.default / 'auth.json').write_text('default-subscription-login')
|
|
37
|
+
(self.regular / 'auth.json').write_text('regular-subscription-login')
|
|
38
|
+
|
|
39
|
+
def test_adopted_default_joins_storage_preserving_logical_identity(self):
|
|
40
|
+
self.assertTrue(M.ensure(self.root, self.adopted))
|
|
41
|
+
authority = self.root / M.SHARED_DIR / 'credentials.json'
|
|
42
|
+
for path in (self.default, self.adopted, self.regular):
|
|
43
|
+
self.assertTrue(os.path.samefile(path / '.credentials.json', authority))
|
|
44
|
+
self.assertEqual((path / 'mcp-oauth-locks').resolve(), self.root / M.SHARED_DIR / 'locks')
|
|
45
|
+
receipt = json.loads((self.root / M.SHARED_DIR / 'managed.json').read_text())
|
|
46
|
+
self.assertEqual(receipt['accounts'], ['acct-01', 'acct-02'])
|
|
47
|
+
self.assertEqual((self.default / 'auth.json').read_text(), 'default-subscription-login')
|
|
48
|
+
self.assertEqual((self.regular / 'auth.json').read_text(), 'regular-subscription-login')
|
|
49
|
+
self.assertTrue(self.adopted.is_symlink())
|
|
50
|
+
self.assertTrue(M.ensure(self.root, self.default))
|
|
51
|
+
self.assertTrue(M.ensure(self.root, self.regular))
|
|
52
|
+
|
|
53
|
+
def test_foreign_adopted_target_and_duplicate_homes_are_rejected(self):
|
|
54
|
+
foreign = self.home / 'foreign'
|
|
55
|
+
foreign.mkdir()
|
|
56
|
+
self.adopted.unlink()
|
|
57
|
+
self.adopted.symlink_to(foreign, target_is_directory=True)
|
|
58
|
+
self.assertFalse(M.ensure(self.root, self.adopted))
|
|
59
|
+
self.assertEqual(list(foreign.iterdir()), [])
|
|
60
|
+
self.assertFalse((self.root / M.SHARED_DIR).exists())
|
|
61
|
+
self.adopted.unlink()
|
|
62
|
+
self.adopted.symlink_to(self.default, target_is_directory=True)
|
|
63
|
+
extra = self.root / 'acct-03'
|
|
64
|
+
extra.symlink_to(self.default, target_is_directory=True)
|
|
65
|
+
names = ['acct-01', 'acct-02', 'acct-03']
|
|
66
|
+
(self.root / 'accounts.json').write_text(json.dumps({'accounts': [{'id': name} for name in names]}))
|
|
67
|
+
self.assertFalse(M.ensure(self.root, self.default))
|
|
68
|
+
self.assertFalse((self.root / M.SHARED_DIR).exists())
|
|
69
|
+
self.assertFalse((self.default / '.credentials.json').exists())
|
|
70
|
+
|
|
71
|
+
def test_unlisted_default_home_and_retargeted_account_are_rejected(self):
|
|
72
|
+
(self.root / 'accounts.json').write_text(json.dumps({'accounts': [{'id': 'acct-02'}]}))
|
|
73
|
+
self.assertFalse(M.ensure(self.root, self.default))
|
|
74
|
+
self.assertFalse((self.root / M.SHARED_DIR).exists())
|
|
75
|
+
(self.root / 'accounts.json').write_text(json.dumps({'accounts': [{'id': 'acct-01'}, {'id': 'acct-02'}]}))
|
|
76
|
+
accounts = M.account_dirs(self.root)
|
|
77
|
+
foreign = self.home / 'foreign'
|
|
78
|
+
foreign.mkdir()
|
|
79
|
+
self.adopted.unlink()
|
|
80
|
+
self.adopted.symlink_to(foreign, target_is_directory=True)
|
|
81
|
+
with self.assertRaises(M.Deferred):
|
|
82
|
+
M.check_paths(self.root, accounts)
|
|
83
|
+
self.assertEqual(list(foreign.iterdir()), [])
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
if __name__ == '__main__':
|
|
87
|
+
unittest.main()
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Migration keeps preexisting native lock descriptors serialized through cutover."""
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
import time
|
|
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
|
+
import codex_mcp_oauth_locks as L
|
|
16
|
+
from codex_mcp_oauth_store import Deferred
|
|
17
|
+
|
|
18
|
+
DOCUMENT = {'demo|1234': {'server_name': 'demo', 'server_url': 'https://example.invalid',
|
|
19
|
+
'client_id': 'fake-client', 'access_token': 'fake-access'}}
|
|
20
|
+
WAITER = '''import fcntl,os,sys,time
|
|
21
|
+
fd=os.open(sys.argv[1],os.O_RDWR)
|
|
22
|
+
print('opened',flush=True)
|
|
23
|
+
time.sleep(0.3)
|
|
24
|
+
try:
|
|
25
|
+
fcntl.flock(fd,fcntl.LOCK_EX|fcntl.LOCK_NB)
|
|
26
|
+
print('unsafe',flush=True)
|
|
27
|
+
except BlockingIOError:
|
|
28
|
+
print('blocked',flush=True)
|
|
29
|
+
os.close(fd)
|
|
30
|
+
'''
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class MigrationTests(unittest.TestCase):
|
|
34
|
+
def setUp(self):
|
|
35
|
+
temporary = tempfile.TemporaryDirectory()
|
|
36
|
+
self.addCleanup(temporary.cleanup)
|
|
37
|
+
self.root = Path(temporary.name)
|
|
38
|
+
self.accounts = [self.root / 'acct-01', self.root / 'acct-02']
|
|
39
|
+
for account in self.accounts:
|
|
40
|
+
(account / 'mcp-oauth-locks').mkdir(parents=True)
|
|
41
|
+
(account / 'mcp-oauth-locks' / 'file-store.lock').touch()
|
|
42
|
+
(self.root / 'accounts.json').write_text(json.dumps({'accounts': [{'id': p.name} for p in self.accounts]}))
|
|
43
|
+
self.source = self.accounts[0] / '.credentials.json'
|
|
44
|
+
self.source.write_text(json.dumps(DOCUMENT))
|
|
45
|
+
self.shared = self.root / M.SHARED_DIR / 'locks'
|
|
46
|
+
|
|
47
|
+
def test_open_unlocked_descriptor_defers_before_any_cutover(self):
|
|
48
|
+
path = self.accounts[1] / 'mcp-oauth-locks' / 'file-store.lock'
|
|
49
|
+
fd = os.open(path, os.O_RDWR)
|
|
50
|
+
try:
|
|
51
|
+
self.assertFalse(M.ensure(self.root))
|
|
52
|
+
self.assertTrue(all(not (account / 'mcp-oauth-locks').is_symlink() for account in self.accounts))
|
|
53
|
+
self.assertEqual(json.loads(self.source.read_text()), DOCUMENT)
|
|
54
|
+
self.assertFalse((self.accounts[1] / '.credentials.json').exists())
|
|
55
|
+
self.assertEqual(path.stat().st_ino, os.fstat(fd).st_ino)
|
|
56
|
+
finally:
|
|
57
|
+
os.close(fd)
|
|
58
|
+
self.assertTrue(M.ensure(self.root))
|
|
59
|
+
|
|
60
|
+
def test_waiter_opened_after_preflight_drains_before_credentials_are_shared(self):
|
|
61
|
+
original = L.exchange
|
|
62
|
+
children = []
|
|
63
|
+
|
|
64
|
+
def exchange_with_waiter(source, target):
|
|
65
|
+
if source.parent.name == self.accounts[1].name:
|
|
66
|
+
child = subprocess.Popen([sys.executable, '-c', WAITER, str(source / 'file-store.lock')],
|
|
67
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
68
|
+
children.append(child)
|
|
69
|
+
self.assertEqual(child.stdout.readline().strip(), 'opened')
|
|
70
|
+
self.assertFalse((self.accounts[1] / '.credentials.json').exists())
|
|
71
|
+
original(source, target)
|
|
72
|
+
self.assertTrue(source.is_symlink())
|
|
73
|
+
self.assertTrue(target.is_dir() and not target.is_symlink())
|
|
74
|
+
|
|
75
|
+
started = time.monotonic()
|
|
76
|
+
with patch.object(L, 'exchange', side_effect=exchange_with_waiter):
|
|
77
|
+
self.assertTrue(M.ensure(self.root))
|
|
78
|
+
self.assertGreaterEqual(time.monotonic() - started, 0.3)
|
|
79
|
+
for child in children:
|
|
80
|
+
output, error = child.communicate(timeout=5)
|
|
81
|
+
self.assertEqual((child.returncode, output.strip(), error), (0, 'blocked', ''))
|
|
82
|
+
self.assertTrue(os.path.samefile(self.source, self.accounts[1] / '.credentials.json'))
|
|
83
|
+
self.assertEqual(list((self.shared.parent / 'retired-locks').iterdir()), [])
|
|
84
|
+
|
|
85
|
+
def test_lock_created_after_snapshot_keeps_its_inode(self):
|
|
86
|
+
original = L.exchange
|
|
87
|
+
created = []
|
|
88
|
+
name = 'a' * 64 + '.lock'
|
|
89
|
+
|
|
90
|
+
def exchange_with_new_lock(source, target):
|
|
91
|
+
if source.parent.name == self.accounts[1].name:
|
|
92
|
+
path = source / name
|
|
93
|
+
path.touch()
|
|
94
|
+
created.append(path.stat().st_ino)
|
|
95
|
+
original(source, target)
|
|
96
|
+
|
|
97
|
+
with patch.object(L, 'exchange', side_effect=exchange_with_new_lock):
|
|
98
|
+
self.assertTrue(M.ensure(self.root))
|
|
99
|
+
self.assertEqual((self.shared / name).stat().st_ino, created[0])
|
|
100
|
+
|
|
101
|
+
def test_unavailable_exchange_defers_without_mutating_credentials(self):
|
|
102
|
+
with patch.object(L, 'exchange_supported', side_effect=Deferred('unavailable')):
|
|
103
|
+
self.assertFalse(M.ensure(self.root))
|
|
104
|
+
self.assertEqual(json.loads(self.source.read_text()), DOCUMENT)
|
|
105
|
+
self.assertTrue(all(not (account / 'mcp-oauth-locks').is_symlink() for account in self.accounts))
|
|
106
|
+
|
|
107
|
+
def test_completed_migration_never_probes_process_handles_again(self):
|
|
108
|
+
self.assertTrue(M.ensure(self.root))
|
|
109
|
+
with patch.object(L, 'external_handles', side_effect=AssertionError('unnecessary process probe')):
|
|
110
|
+
self.assertTrue(M.ensure(self.root))
|
|
111
|
+
|
|
112
|
+
def test_partial_cutover_failure_drains_waiter_before_releasing_locks(self):
|
|
113
|
+
third = self.root / 'acct-03'
|
|
114
|
+
(third / 'mcp-oauth-locks').mkdir(parents=True)
|
|
115
|
+
(third / 'mcp-oauth-locks' / 'file-store.lock').touch()
|
|
116
|
+
self.accounts.append(third)
|
|
117
|
+
(self.root / 'accounts.json').write_text(json.dumps({'accounts': [{'id': p.name} for p in self.accounts]}))
|
|
118
|
+
original, children = L.exchange, []
|
|
119
|
+
|
|
120
|
+
def interrupted_exchange(source, target):
|
|
121
|
+
if source.parent.name == 'acct-03':
|
|
122
|
+
raise Deferred('injected cutover failure')
|
|
123
|
+
if source.parent.name == 'acct-02':
|
|
124
|
+
child = subprocess.Popen([sys.executable, '-c', WAITER, str(source / 'file-store.lock')],
|
|
125
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
126
|
+
children.append(child)
|
|
127
|
+
self.assertEqual(child.stdout.readline().strip(), 'opened')
|
|
128
|
+
original(source, target)
|
|
129
|
+
|
|
130
|
+
with patch.object(L, 'exchange', side_effect=interrupted_exchange):
|
|
131
|
+
self.assertFalse(M.ensure(self.root))
|
|
132
|
+
self.assertEqual(json.loads(self.source.read_text()), DOCUMENT)
|
|
133
|
+
self.assertFalse((self.accounts[1] / '.credentials.json').exists())
|
|
134
|
+
for child in children:
|
|
135
|
+
output, error = child.communicate(timeout=5)
|
|
136
|
+
self.assertEqual((child.returncode, output.strip(), error), (0, 'blocked', ''))
|
|
137
|
+
self.assertTrue(M.ensure(self.root))
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
if __name__ == '__main__':
|
|
141
|
+
unittest.main()
|
|
@@ -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()
|