claude-multiacc 2.0.61 → 2.0.65
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/claude-accounts +4 -4
- package/bin/codex +54 -13
- package/bin/codex-accounts +28 -7
- package/docs/ACCOUNT_OPERATIONS.md +2 -2
- package/docs/AUTORESUME.md +16 -2
- package/docs/CODEX.md +58 -2
- package/docs/CODEX_MCP_AUTH.md +58 -0
- package/lib/__pycache__/audit.cpython-314.pyc +0 -0
- package/lib/__pycache__/autoresume.cpython-314.pyc +0 -0
- package/lib/__pycache__/autoresume_cross.cpython-314.pyc +0 -0
- package/lib/__pycache__/autoresume_cross_dispatch.cpython-314.pyc +0 -0
- package/lib/__pycache__/autoresume_cross_receipt.cpython-314.pyc +0 -0
- package/lib/__pycache__/autoresume_handoff.cpython-314.pyc +0 -0
- package/lib/__pycache__/autoresume_probe.cpython-314.pyc +0 -0
- package/lib/__pycache__/autoresume_receipt.cpython-314.pyc +0 -0
- package/lib/__pycache__/claude_client_error.cpython-314.pyc +0 -0
- package/lib/__pycache__/claude_reset.cpython-314.pyc +0 -0
- package/lib/__pycache__/claude_token_identity.cpython-314.pyc +0 -0
- package/lib/__pycache__/codex_autoresume_mode.cpython-314.pyc +0 -0
- package/lib/__pycache__/codex_config_edit.cpython-314.pyc +0 -0
- package/lib/__pycache__/codex_credit_controls.cpython-314.pyc +0 -0
- package/lib/__pycache__/codex_credit_local_scope.cpython-314.pyc +0 -0
- package/lib/__pycache__/codex_credit_selection.cpython-314.pyc +0 -0
- package/lib/__pycache__/codex_credits.cpython-314.pyc +0 -0
- package/lib/__pycache__/codex_limits_lock.cpython-314.pyc +0 -0
- package/lib/__pycache__/codex_mcp_oauth.cpython-314.pyc +0 -0
- package/lib/__pycache__/codex_python.cpython-314.pyc +0 -0
- package/lib/__pycache__/codex_resume_cwd.cpython-314.pyc +0 -0
- package/lib/__pycache__/codex_speed.cpython-314.pyc +0 -0
- package/lib/__pycache__/codex_trust.cpython-314.pyc +0 -0
- package/lib/__pycache__/codex_usage_fetch.cpython-314.pyc +0 -0
- package/lib/__pycache__/keychain.cpython-314.pyc +0 -0
- package/lib/__pycache__/mcp_mirror.cpython-314.pyc +0 -0
- package/lib/__pycache__/mcp_oauth.cpython-314.pyc +0 -0
- package/lib/__pycache__/mcp_registry.cpython-314.pyc +0 -0
- package/lib/__pycache__/model_catalog.cpython-314.pyc +0 -0
- package/lib/__pycache__/model_match.cpython-314.pyc +0 -0
- package/lib/__pycache__/report.cpython-314.pyc +0 -0
- package/lib/__pycache__/selector_credit_scope.cpython-314.pyc +0 -0
- package/lib/__pycache__/selector_credits.cpython-314.pyc +0 -0
- package/lib/__pycache__/selector_funding.cpython-314.pyc +0 -0
- package/lib/__pycache__/selector_policy.cpython-314.pyc +0 -0
- package/lib/__pycache__/selector_primitives.cpython-314.pyc +0 -0
- package/lib/__pycache__/session_settings.cpython-314.pyc +0 -0
- package/lib/__pycache__/shim_path.cpython-314.pyc +0 -0
- package/lib/autoresume.py +34 -13
- 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/codex_speed.py +351 -0
- package/lib/codex_speed.sh +39 -0
- package/lib/common.sh +16 -3
- package/lib/model_match.py +4 -1
- package/lib/session_settings.py +25 -6
- package/lib/shim_path.py +6 -0
- package/package.json +10 -5
- package/tests/__pycache__/autoresume_cross_receipt_support.cpython-314.pyc +0 -0
- package/tests/__pycache__/autoresume_cross_tmux_environment.cpython-314.pyc +0 -0
- package/tests/__pycache__/autoresume_cross_tmux_support.cpython-314.pyc +0 -0
- package/tests/__pycache__/autoresume_cross_unit_support.cpython-314.pyc +0 -0
- package/tests/__pycache__/autoresume_tmux_support.cpython-314.pyc +0 -0
- package/tests/__pycache__/packaged_command_support.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_catalog.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_catalog_cli.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_codex_records.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_codex_trust.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_claude_holders.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_guards.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_holders.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_origin.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_policy.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_receipt.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_tmux.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_tmux_races.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_tokens.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cursor.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cwd.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cwd_handoff.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cwd_reader.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cwd_tmux.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_fallback.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_handoff_argv.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_handoff_history.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_handoff_origins.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_memory.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_memory_tmux.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_mode.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_probe.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_receipt.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_relaunch_settings.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_settings.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_sidechain.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_sidechain_tmux.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_tmux.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_tmux_modes.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_trust_tmux.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_claude_reset.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_claude_token_identity.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_codex_exec_stdin.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_codex_mcp_oauth.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_codex_reset.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_credits.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_credits_polling.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_credits_report.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_fetch.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_polling.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_reporting.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_windows.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_mcp_mirror.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_mcp_mirror_args.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_mcp_oauth.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_mcp_registry.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_selector.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_selector_cached_credits.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_selector_credit_scope.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_selector_credit_shim.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_selector_credits.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_selector_spread.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_token_identity_ceremony.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_token_identity_integration.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_token_identity_retry.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_token_identity_sync.cpython-314.pyc +0 -0
- package/tests/fake_codex_tui.py +23 -1
- package/tests/run-tests.sh +23 -7
- 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_codex_speed.py +236 -0
- package/tests/test_mutate_lock.py +107 -0
- package/tests/test_shim_path.py +44 -4
- 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_python.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/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_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_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
|
@@ -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()
|
|
@@ -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()
|