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,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()
|
|
@@ -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()
|