claude-multiacc 2.0.60 → 2.0.64
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -3
- package/bin/claude +14 -3
- package/bin/codex +42 -10
- package/bin/codex-accounts +12 -4
- package/docs/ACCOUNT_OPERATIONS.md +2 -2
- package/docs/AUTORESUME.md +6 -1
- package/docs/CODEX.md +19 -2
- package/docs/CODEX_MCP_AUTH.md +58 -0
- package/lib/__pycache__/audit.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_cross.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_cross_dispatch.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_cross_receipt.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_handoff.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_probe.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_receipt.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_client_error.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_token_identity.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_autoresume_mode.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credit_controls.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credit_local_scope.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credit_selection.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credits.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_limits_lock.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_mcp_oauth.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_resume_cwd.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_trust.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_usage_fetch.cpython-312.pyc +0 -0
- package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
- package/lib/__pycache__/mcp_mirror.cpython-312.pyc +0 -0
- package/lib/__pycache__/mcp_oauth.cpython-312.pyc +0 -0
- package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
- package/lib/__pycache__/model_catalog.cpython-312.pyc +0 -0
- package/lib/__pycache__/model_match.cpython-312.pyc +0 -0
- package/lib/__pycache__/report.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_credit_scope.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_credits.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_funding.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
- package/lib/__pycache__/session_settings.cpython-312.pyc +0 -0
- package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
- package/lib/autoresume.py +6 -5
- package/lib/autoresume_cross.py +2 -1
- package/lib/autoresume_handoff.py +13 -5
- package/lib/codex_exec_stdin.sh +45 -0
- package/lib/codex_mcp_oauth.py +204 -0
- package/lib/codex_mcp_oauth.sh +29 -0
- package/lib/codex_mcp_oauth_locks.py +158 -0
- package/lib/codex_mcp_oauth_native.py +102 -0
- package/lib/codex_mcp_oauth_store.py +149 -0
- package/lib/codex_resume_cwd.py +57 -0
- package/lib/codex_retry_process.sh +37 -0
- package/lib/common.sh +3 -0
- package/lib/mcp_mirror.py +49 -0
- package/lib/mcp_mirror.sh +47 -0
- package/lib/mcp_registry.py +7 -2
- package/package.json +9 -4
- package/tests/__pycache__/autoresume_cross_receipt_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/autoresume_cross_tmux_environment.cpython-312.pyc +0 -0
- package/tests/__pycache__/autoresume_cross_tmux_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/autoresume_cross_unit_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/autoresume_tmux_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_catalog.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_catalog_cli.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_codex_records.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_codex_trust.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_claude_holders.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_guards.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_holders.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_origin.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_policy.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_receipt.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_tmux.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_tmux_races.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_tokens.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cursor.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cwd.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cwd_handoff.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cwd_reader.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cwd_tmux.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_fallback.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_handoff_argv.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_handoff_history.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_handoff_origins.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_memory.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_memory_tmux.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_mode.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_probe.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_receipt.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_relaunch_settings.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_settings.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_sidechain.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_sidechain_tmux.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_tmux.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_tmux_modes.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_trust_tmux.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_exec_stdin.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_mcp_oauth.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_credits.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_credits_polling.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_credits_report.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_fetch.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_mcp_mirror.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_mcp_mirror_args.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_mcp_oauth.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_mcp_registry.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_cached_credits.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_credit_scope.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_credit_shim.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_credits.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_spread.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
- package/tests/fake_codex_tui.py +23 -1
- package/tests/run-tests.sh +3 -1
- package/tests/test_autoresume_cwd.py +71 -0
- package/tests/test_autoresume_cwd_handoff.py +92 -0
- package/tests/test_autoresume_cwd_reader.py +105 -0
- package/tests/test_autoresume_cwd_tmux.py +54 -0
- package/tests/test_codex_exec_stdin.py +178 -0
- package/tests/test_codex_mcp_oauth.py +146 -0
- package/tests/test_codex_mcp_oauth_adopt.py +87 -0
- package/tests/test_codex_mcp_oauth_migration.py +141 -0
- package/tests/test_codex_mcp_oauth_native.py +109 -0
- package/tests/test_codex_mcp_oauth_safety.py +130 -0
- package/tests/test_codex_mcp_oauth_shim.py +188 -0
- package/tests/test_codex_retry_process.py +143 -0
- package/tests/test_mcp_mirror.py +148 -0
- package/tests/test_mcp_mirror_args.py +129 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Buffered Codex retry owns and terminates only its active native child."""
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import signal
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import tempfile
|
|
11
|
+
import time
|
|
12
|
+
import unittest
|
|
13
|
+
|
|
14
|
+
import test_codex_exec_stdin as pipe_fixture
|
|
15
|
+
|
|
16
|
+
REPO = Path(__file__).resolve().parents[1]
|
|
17
|
+
HELPER = REPO / 'lib/codex_retry_process.sh'
|
|
18
|
+
RUNNER = '''source "$1"
|
|
19
|
+
exec 3< "$2/input"
|
|
20
|
+
fixture_root="$2"
|
|
21
|
+
trap 'printf cleaned > "$fixture_root/exit-cleanup"' EXIT
|
|
22
|
+
trap 'printf restored > "$fixture_root/restored-trap"' TERM
|
|
23
|
+
CODEX_HOME="$2/fake-profile" codex_retry_run "$3" "$2/native.py" "$2" "$4"
|
|
24
|
+
rc=$?
|
|
25
|
+
[ "$4" != restore ] || kill -TERM $$
|
|
26
|
+
exit "$rc"
|
|
27
|
+
'''
|
|
28
|
+
NATIVE = '''import json, os, pathlib, signal, sys, time
|
|
29
|
+
root = pathlib.Path(sys.argv[1])
|
|
30
|
+
mode = sys.argv[2]
|
|
31
|
+
def stop(number, frame):
|
|
32
|
+
(root / 'stopped').write_text(str(number))
|
|
33
|
+
sys.exit(0)
|
|
34
|
+
for number in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP):
|
|
35
|
+
signal.signal(number, stop)
|
|
36
|
+
data = sys.stdin.buffer.read()
|
|
37
|
+
(root / 'received').write_bytes(data)
|
|
38
|
+
(root / 'profile').write_text(os.environ.get('CODEX_HOME', ''))
|
|
39
|
+
(root / 'native.pid').write_text(str(os.getpid()))
|
|
40
|
+
if mode == 'wait':
|
|
41
|
+
while True:
|
|
42
|
+
time.sleep(0.05)
|
|
43
|
+
print(json.dumps({'mode': mode}))
|
|
44
|
+
print('native stderr', file=sys.stderr)
|
|
45
|
+
sys.exit(7 if mode == 'fail' else 0)
|
|
46
|
+
'''
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class CodexRetryProcessTests(unittest.TestCase):
|
|
50
|
+
def launch(self, mode):
|
|
51
|
+
temporary = tempfile.TemporaryDirectory(prefix='multiacc-retry-process-')
|
|
52
|
+
self.addCleanup(temporary.cleanup)
|
|
53
|
+
root = Path(temporary.name)
|
|
54
|
+
(root / 'input').write_bytes(b'finite stdin\n\x00\xff')
|
|
55
|
+
(root / 'native.py').write_text(NATIVE)
|
|
56
|
+
process = subprocess.Popen(
|
|
57
|
+
['bash', '-c', RUNNER, 'retry-test', str(HELPER), str(root), sys.executable, mode],
|
|
58
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
59
|
+
self.addCleanup(self.cleanup_process, process, root)
|
|
60
|
+
deadline = time.monotonic() + 5
|
|
61
|
+
while not (root / 'native.pid').exists() and process.poll() is None and time.monotonic() < deadline:
|
|
62
|
+
time.sleep(0.02)
|
|
63
|
+
self.assertTrue((root / 'native.pid').exists(), 'owned native child did not launch')
|
|
64
|
+
return process, root
|
|
65
|
+
|
|
66
|
+
def cleanup_process(self, process, root):
|
|
67
|
+
# Only this fixture's owned native PID; ordinary assertions verify it was
|
|
68
|
+
# already reaped. A regression must not leave a sleeping fake behind.
|
|
69
|
+
if process.poll() is None:
|
|
70
|
+
process.terminate()
|
|
71
|
+
pid_file = root / 'native.pid'
|
|
72
|
+
if pid_file.exists():
|
|
73
|
+
try:
|
|
74
|
+
os.kill(int(pid_file.read_text()), signal.SIGTERM)
|
|
75
|
+
except ProcessLookupError:
|
|
76
|
+
pass
|
|
77
|
+
try:
|
|
78
|
+
process.communicate(timeout=3)
|
|
79
|
+
except subprocess.TimeoutExpired:
|
|
80
|
+
process.kill()
|
|
81
|
+
process.communicate(timeout=3)
|
|
82
|
+
|
|
83
|
+
def test_termination_signals_reach_and_reap_only_the_owned_native_child(self):
|
|
84
|
+
for number in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP):
|
|
85
|
+
with self.subTest(signal=number):
|
|
86
|
+
process, root = self.launch('wait')
|
|
87
|
+
process.send_signal(number)
|
|
88
|
+
process.communicate(timeout=3)
|
|
89
|
+
self.assertEqual(process.returncode, 128 + number)
|
|
90
|
+
self.assertEqual(int((root / 'stopped').read_text()), number)
|
|
91
|
+
with self.assertRaises(ProcessLookupError):
|
|
92
|
+
os.kill(int((root / 'native.pid').read_text()), 0)
|
|
93
|
+
self.assertEqual((root / 'exit-cleanup').read_text(), 'cleaned')
|
|
94
|
+
|
|
95
|
+
def test_success_failure_and_prior_traps_are_preserved(self):
|
|
96
|
+
for mode, expected in [('success', 0), ('fail', 7), ('restore', 0)]:
|
|
97
|
+
with self.subTest(mode=mode):
|
|
98
|
+
process, root = self.launch(mode)
|
|
99
|
+
stdout, stderr = process.communicate(timeout=3)
|
|
100
|
+
self.assertEqual(process.returncode, expected)
|
|
101
|
+
self.assertEqual(json.loads(stdout), {'mode': mode})
|
|
102
|
+
self.assertEqual(stderr, b'native stderr\n')
|
|
103
|
+
self.assertEqual((root / 'received').read_bytes(), (root / 'input').read_bytes())
|
|
104
|
+
self.assertEqual((root / 'profile').read_text(), str(root / 'fake-profile'))
|
|
105
|
+
self.assertEqual((root / 'exit-cleanup').read_text(), 'cleaned')
|
|
106
|
+
if mode == 'restore':
|
|
107
|
+
self.assertEqual((root / 'restored-trap').read_text(), 'restored')
|
|
108
|
+
|
|
109
|
+
def test_actual_shim_pipe_cancellation_reaps_native_without_retry_or_scratch(self):
|
|
110
|
+
fixture = pipe_fixture.CodexExecStdinTests()
|
|
111
|
+
fixture.setUp()
|
|
112
|
+
self.addCleanup(fixture.doCleanups)
|
|
113
|
+
root = fixture.root
|
|
114
|
+
native = NATIVE.replace('root = pathlib.Path(sys.argv[1])',
|
|
115
|
+
"root = pathlib.Path(os.environ['FAKE_ROOT'])\n"
|
|
116
|
+
"with (root / 'attempts').open('a') as handle: handle.write('attempt\\n')")
|
|
117
|
+
native = native.replace('mode = sys.argv[2]', "mode = 'wait'")
|
|
118
|
+
(root / 'bin/codex').write_text(f'#!{sys.executable}\n' + native)
|
|
119
|
+
process = subprocess.Popen([str(REPO / 'bin/codex'), *fixture.args], env=fixture.env,
|
|
120
|
+
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
121
|
+
self.addCleanup(self.cleanup_process, process, root)
|
|
122
|
+
process.stdin.write(pipe_fixture.PROMPT)
|
|
123
|
+
process.stdin.close()
|
|
124
|
+
process.stdin = None
|
|
125
|
+
deadline = time.monotonic() + 5
|
|
126
|
+
while not (root / 'native.pid').exists() and process.poll() is None and time.monotonic() < deadline:
|
|
127
|
+
time.sleep(0.02)
|
|
128
|
+
self.assertTrue((root / 'native.pid').exists(), 'shim did not launch native child')
|
|
129
|
+
process.send_signal(signal.SIGTERM)
|
|
130
|
+
process.communicate(timeout=3)
|
|
131
|
+
self.assertEqual(process.returncode, 143)
|
|
132
|
+
self.assertEqual(int((root / 'stopped').read_text()), signal.SIGTERM)
|
|
133
|
+
with self.assertRaises(ProcessLookupError):
|
|
134
|
+
os.kill(int((root / 'native.pid').read_text()), 0)
|
|
135
|
+
self.assertEqual((root / 'attempts').read_text(), 'attempt\n')
|
|
136
|
+
self.assertEqual((root / 'received').read_bytes(), pipe_fixture.PROMPT)
|
|
137
|
+
self.assertEqual(list(fixture.pool.glob('tmp/shim.*')), [])
|
|
138
|
+
self.assertNotIn('retry from=', (fixture.pool / 'selection.log').read_text())
|
|
139
|
+
self.assertFalse((fixture.pool / 'acct-01/.limited').exists())
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
if __name__ == '__main__':
|
|
143
|
+
unittest.main()
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Stock mutations must not promote concurrent runner publications into the registry."""
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import unittest
|
|
7
|
+
from unittest.mock import patch
|
|
8
|
+
|
|
9
|
+
from test_mcp_registry import HAS_TOMLLIB, HTTP, R, RegistryCase, STDIO
|
|
10
|
+
|
|
11
|
+
APP = "appinspire-mcp"
|
|
12
|
+
MOTION = "motionsites"
|
|
13
|
+
V1 = {"type": "stdio", "command": "/releases/1000249/python", "args": ["launcher.pyc"], "env": {}}
|
|
14
|
+
V2 = {"type": "stdio", "command": "/releases/1000250/python", "args": ["launcher.pyc"], "env": {}}
|
|
15
|
+
V3 = {"type": "stdio", "command": "/bin/sh", "args": ["-p", "-c", "exec node server.mjs"], "env": {}}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class MirrorCases:
|
|
19
|
+
def setUp(self):
|
|
20
|
+
super().setUp()
|
|
21
|
+
if self.provider == "codex" and not HAS_TOMLLIB:
|
|
22
|
+
self.skipTest("Codex requires tomllib")
|
|
23
|
+
if self.replica:
|
|
24
|
+
(self.root / "sync-role").write_text("replica\n")
|
|
25
|
+
|
|
26
|
+
def publish(self, servers):
|
|
27
|
+
rc, _, err = self.run_cli(self.provider, "import-local", "--owner", "app-robot",
|
|
28
|
+
stdin=json.dumps({"mcpServers": servers}))
|
|
29
|
+
self.assertEqual(rc, 0, err)
|
|
30
|
+
|
|
31
|
+
def write_account(self, servers, projects=None):
|
|
32
|
+
if self.provider == "claude":
|
|
33
|
+
R._claude_save(self.acct(), {"mcpServers": servers, "projects": projects or {}})
|
|
34
|
+
else:
|
|
35
|
+
eff = R.Effective()
|
|
36
|
+
eff.servers = servers
|
|
37
|
+
text, notes = R.reconcile_codex_text("", eff)
|
|
38
|
+
self.assertFalse(notes)
|
|
39
|
+
(Path(self.acct()) / "config.toml").write_text(text)
|
|
40
|
+
|
|
41
|
+
def before(self):
|
|
42
|
+
rc, snapshot, err = self.run_cli(self.provider, "snapshot", "--account-dir", self.acct())
|
|
43
|
+
self.assertEqual(rc, 0, err)
|
|
44
|
+
path = self.root / "before.json"
|
|
45
|
+
path.write_text(snapshot)
|
|
46
|
+
return str(path)
|
|
47
|
+
|
|
48
|
+
def learn(self, before, name):
|
|
49
|
+
scope = ["--name", name] if name is not None else []
|
|
50
|
+
rc, _, err = self.run_cli(self.provider, "learn", "--account-dir", self.acct(),
|
|
51
|
+
"--before", before, *scope)
|
|
52
|
+
self.assertEqual(rc, 0, err)
|
|
53
|
+
return R._Target(str(self.root)).section
|
|
54
|
+
|
|
55
|
+
def test_named_add_ignores_delayed_runner_generation(self):
|
|
56
|
+
self.publish({APP: V1})
|
|
57
|
+
before = self.before()
|
|
58
|
+
self.publish({APP: V2})
|
|
59
|
+
# import-local commits G3 under the lock, then applies accounts outside it.
|
|
60
|
+
# Pause before apply: the client still sees G2, which differs from current G3.
|
|
61
|
+
with patch.object(R, "_apply_all", return_value=0):
|
|
62
|
+
self.publish({APP: V3})
|
|
63
|
+
self.write_account({APP: V2, MOTION: HTTP})
|
|
64
|
+
section = self.learn(before, MOTION)
|
|
65
|
+
self.assertEqual(set(section["mcpServers"]), {MOTION})
|
|
66
|
+
self.assertEqual(section["retired"] + section["retiredUser"], [])
|
|
67
|
+
self.assertEqual(self.overlay()["owners"]["app-robot"]["mcpServers"], {APP: V3})
|
|
68
|
+
for account in ("acct-01", "acct-02"):
|
|
69
|
+
snapshot = R.snapshot(self.provider, self.acct(account))
|
|
70
|
+
self.assertEqual(snapshot["user"][APP]["command"], "/bin/sh")
|
|
71
|
+
self.assertEqual(snapshot["user"][MOTION]["url"], HTTP["url"])
|
|
72
|
+
|
|
73
|
+
def test_named_remove_does_not_promote_runner_retirement(self):
|
|
74
|
+
self.publish({APP: V1})
|
|
75
|
+
self.run_cli(self.provider, "add-json", MOTION, json.dumps(HTTP))
|
|
76
|
+
before = self.before()
|
|
77
|
+
self.publish({})
|
|
78
|
+
self.write_account({})
|
|
79
|
+
section = self.learn(before, MOTION)
|
|
80
|
+
self.assertEqual(section["mcpServers"], {})
|
|
81
|
+
self.assertEqual(section["retiredUser"], [MOTION])
|
|
82
|
+
self.assertEqual(section["retired"], [])
|
|
83
|
+
self.assertEqual(self.overlay()["owners"]["app-robot"]["retired"], [APP])
|
|
84
|
+
|
|
85
|
+
def test_explicit_owner_override_and_removal_still_mirror(self):
|
|
86
|
+
self.publish({APP: V1})
|
|
87
|
+
before = self.before()
|
|
88
|
+
self.write_account({APP: HTTP})
|
|
89
|
+
section = self.learn(before, APP)
|
|
90
|
+
self.assertEqual(section["mcpServers"][APP]["url"], HTTP["url"])
|
|
91
|
+
self.assertEqual(self.overlay()["owners"]["app-robot"]["mcpServers"], {APP: V1})
|
|
92
|
+
before = self.before()
|
|
93
|
+
self.write_account({})
|
|
94
|
+
section = self.learn(before, APP)
|
|
95
|
+
self.assertEqual(section["mcpServers"], {})
|
|
96
|
+
self.assertEqual(section["retiredUser"], [APP])
|
|
97
|
+
self.assertEqual(section["retired"], [])
|
|
98
|
+
|
|
99
|
+
def test_bulk_import_excludes_external_updates_and_retirements(self):
|
|
100
|
+
self.publish({APP: V1, "old-runner-tool": STDIO})
|
|
101
|
+
self.run_cli(self.provider, "import-local", "--owner", "local",
|
|
102
|
+
stdin=json.dumps({"mcpServers": {"local-tool": V1}}))
|
|
103
|
+
before = self.before()
|
|
104
|
+
self.publish({APP: V2})
|
|
105
|
+
with patch.object(R, "_apply_all", return_value=0):
|
|
106
|
+
self.publish({APP: V3})
|
|
107
|
+
self.write_account({APP: V2, MOTION: HTTP, "local-tool": HTTP})
|
|
108
|
+
section = self.learn(before, None)
|
|
109
|
+
self.assertEqual(set(section["mcpServers"]), {MOTION, "local-tool"} if self.replica else {MOTION})
|
|
110
|
+
local = self.overlay()["owners"]["local"]["mcpServers"]["local-tool"]
|
|
111
|
+
self.assertEqual(local.get("url") if self.replica else local, HTTP["url"] if self.replica else V1)
|
|
112
|
+
self.assertEqual(section["retired"] + section["retiredUser"], [])
|
|
113
|
+
|
|
114
|
+
def test_named_project_changes_and_approvals_stay_scoped(self):
|
|
115
|
+
if self.provider != "claude":
|
|
116
|
+
self.skipTest("project MCP entries are Claude-only")
|
|
117
|
+
for name in (MOTION, "other"):
|
|
118
|
+
self.run_cli("claude", "add-json", name, json.dumps(STDIO), "--scope", "project",
|
|
119
|
+
"--project", "/project")
|
|
120
|
+
before = self.before()
|
|
121
|
+
self.write_account({}, {"/project": {"mcpServers": {MOTION: HTTP, "other": HTTP},
|
|
122
|
+
"enabledMcpjsonServers": [MOTION, "other"]}})
|
|
123
|
+
section = self.learn(before, MOTION)
|
|
124
|
+
project = section["projects"]["/project"]
|
|
125
|
+
self.assertEqual(project["mcpServers"], {MOTION: HTTP, "other": STDIO})
|
|
126
|
+
self.assertEqual(project["enabledMcpjsonServers"], [MOTION])
|
|
127
|
+
other = self.claude_doc("acct-02")["projects"]["/project"]
|
|
128
|
+
self.assertEqual(other["enabledMcpjsonServers"], [MOTION])
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class ClaudeSourceTests(MirrorCases, RegistryCase):
|
|
132
|
+
provider, replica = "claude", False
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class ClaudeReplicaTests(MirrorCases, RegistryCase):
|
|
136
|
+
provider, replica = "claude", True
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class CodexSourceTests(MirrorCases, RegistryCase):
|
|
140
|
+
provider, replica = "codex", False
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class CodexReplicaTests(MirrorCases, RegistryCase):
|
|
144
|
+
provider, replica = "codex", True
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
if __name__ == "__main__":
|
|
148
|
+
unittest.main()
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""MCP target parsing uses native verb options, without reading command payloads."""
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import subprocess
|
|
6
|
+
import tempfile
|
|
7
|
+
import unittest
|
|
8
|
+
|
|
9
|
+
from test_mcp_registry import R
|
|
10
|
+
|
|
11
|
+
HELPER = Path(__file__).resolve().parents[1] / "lib" / "mcp_mirror.sh"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class MutationNameTests(unittest.TestCase):
|
|
15
|
+
def parse(self, *args):
|
|
16
|
+
return subprocess.run(["/bin/bash", "-uc", '. "$1"; shift; mcp_mutation_name "$@"',
|
|
17
|
+
"mcp-target-test", str(HELPER), *args], capture_output=True, text=True)
|
|
18
|
+
|
|
19
|
+
def test_native_options_before_name(self):
|
|
20
|
+
cases = [
|
|
21
|
+
("claude", "add", "-s", "user", "-t", "http", "motionsites", "https://example.test"),
|
|
22
|
+
("claude", "add", "-suser", "-thttp", "motionsites", "https://example.test"),
|
|
23
|
+
("claude", "add", "--scope=user", "--callback-port", "1234", "--client-id", "cid",
|
|
24
|
+
"--client-secret", "motionsites", "https://example.test"),
|
|
25
|
+
("claude", "add", "-e", "A=1", "B=2", "-H", "X: 1", "Y: 2", "--", "motionsites", "echo"),
|
|
26
|
+
("claude", "add", "-eA=1", "B=2", "--scope=user", "motionsites", "--", "echo", "--unknown"),
|
|
27
|
+
("claude", "add-json", "-suser", "--client-secret", "motionsites", '{"command":"echo"}'),
|
|
28
|
+
("claude", "remove", "--scope=user", "motionsites"),
|
|
29
|
+
("codex", "add", "-c", "x=1", "--env", "A=1", "--url=https://example.test", "motionsites"),
|
|
30
|
+
("codex", "add", "--enable", "x", "--disable=y", "--bearer-token-env-var", "MCP_TOKEN",
|
|
31
|
+
"--oauth-client-id", "cid", "--oauth-client-secret", "dummy", "--oauth-resource", "r",
|
|
32
|
+
"--oauth-client-registration", "dynamic", "motionsites"),
|
|
33
|
+
("codex", "remove", "-cx=1", "--enable=x", "--disable", "y", "motionsites"),
|
|
34
|
+
("codex", "add", "motionsites", "--", "node", "--env", "appinspire-mcp"),
|
|
35
|
+
("claude", "add", "motionsites", "--", "node", "--help"),
|
|
36
|
+
("codex", "add", "motionsites", "--help"),
|
|
37
|
+
("claude", "remove", "motionsites", "-h"),
|
|
38
|
+
("claude", "add", "--client-id", "--help", "motionsites", "https://example.test"),
|
|
39
|
+
]
|
|
40
|
+
for args in cases:
|
|
41
|
+
with self.subTest(args=args):
|
|
42
|
+
result = self.parse(*args)
|
|
43
|
+
self.assertEqual((result.returncode, result.stdout, result.stderr), (0, "motionsites\n", ""))
|
|
44
|
+
|
|
45
|
+
def test_unknown_or_incomplete_options_never_become_a_broad_mirror(self):
|
|
46
|
+
cases = [
|
|
47
|
+
("claude", "add", "--future-value", "appinspire-mcp", "motionsites"),
|
|
48
|
+
("claude", "remove", "--scope"),
|
|
49
|
+
("claude", "add", "-e", "A=1", "motionsites", "echo"),
|
|
50
|
+
("claude", "add-json", "--client-secret=unexpected", "motionsites", "{}"),
|
|
51
|
+
("codex", "add", "--unknown=value", "motionsites"),
|
|
52
|
+
("codex", "remove", "--config"),
|
|
53
|
+
("codex", "add", "--"),
|
|
54
|
+
("claude", "add-from-claude-desktop"),
|
|
55
|
+
]
|
|
56
|
+
for args in cases:
|
|
57
|
+
with self.subTest(args=args):
|
|
58
|
+
result = self.parse(*args)
|
|
59
|
+
self.assertNotEqual(result.returncode, 0)
|
|
60
|
+
self.assertEqual(result.stdout, "")
|
|
61
|
+
|
|
62
|
+
def test_help_without_a_target_has_a_distinct_quiet_status(self):
|
|
63
|
+
for provider in ("claude", "codex"):
|
|
64
|
+
for flag in ("-h", "--help"):
|
|
65
|
+
result = self.parse(provider, "add", flag)
|
|
66
|
+
self.assertEqual((result.returncode, result.stdout, result.stderr), (2, "", ""))
|
|
67
|
+
|
|
68
|
+
def run_shim(self, provider, args, native_exit=0):
|
|
69
|
+
with tempfile.TemporaryDirectory() as work:
|
|
70
|
+
root = Path(work)
|
|
71
|
+
pool, fakebin = root / "pool", root / "bin"
|
|
72
|
+
(pool / "acct-01").mkdir(parents=True)
|
|
73
|
+
fakebin.mkdir()
|
|
74
|
+
(pool / "accounts.json").write_text('{"version":1,"accounts":[{"id":"acct-01"}]}')
|
|
75
|
+
native = fakebin / provider
|
|
76
|
+
native.write_text('#!/bin/sh\nprintf "NATIVE\\n"\nprintf "%s\\n" "$@"\nexit "$FAKE_EXIT"\n')
|
|
77
|
+
native.chmod(0o755)
|
|
78
|
+
logger = fakebin / "mcp-python"
|
|
79
|
+
logger.write_text('#!/bin/sh\nprintf "%s\\n" "$@" >> "$MCP_TEST_LOG"\n'
|
|
80
|
+
'case "$*" in *" snapshot "*) printf "{}\\n" ;; esac\n')
|
|
81
|
+
logger.chmod(0o755)
|
|
82
|
+
log = root / "mirror.log"
|
|
83
|
+
env = {"PATH": f"{fakebin}:/usr/bin:/bin", "HOME": work, "USER": "test", "LOGNAME": "test",
|
|
84
|
+
"TMPDIR": work, "FAKE_EXIT": str(native_exit), "MCP_TEST_LOG": str(log),
|
|
85
|
+
"CLAUDE_MULTIACC_PYTHON": str(logger), f"{provider.upper()}_ACCOUNTS_ROOT": str(pool),
|
|
86
|
+
f"{provider.upper()}_SHIM_ACTIVE": "1",
|
|
87
|
+
"CLAUDE_CONFIG_DIR" if provider == "claude" else "CODEX_HOME": str(pool / "acct-01")}
|
|
88
|
+
result = subprocess.run([str(HELPER.parents[1] / "bin" / provider), *args], env=env,
|
|
89
|
+
capture_output=True, text=True, timeout=10)
|
|
90
|
+
return result, log.read_text() if log.exists() else ""
|
|
91
|
+
|
|
92
|
+
def test_literal_help_global_value_still_mirrors(self):
|
|
93
|
+
args = ["--system-prompt", "--help", "mcp", "add-json", "--scope", "user", "motionsites",
|
|
94
|
+
'{"command":"echo","args":[]}']
|
|
95
|
+
result, log = self.run_shim("claude", args)
|
|
96
|
+
self.assertEqual((result.returncode, result.stderr), (0, ""))
|
|
97
|
+
self.assertEqual(result.stdout.splitlines(), ["NATIVE", *args])
|
|
98
|
+
self.assertIn("\nlearn\n", log)
|
|
99
|
+
self.assertIn("\n--name=motionsites\n", log)
|
|
100
|
+
|
|
101
|
+
def test_leading_dash_name_reaches_the_registry_parser(self):
|
|
102
|
+
args = ["mcp", "add", "--", "-probe-name", "echo"]
|
|
103
|
+
for provider in ("claude", "codex"):
|
|
104
|
+
result, log = self.run_shim(provider, args)
|
|
105
|
+
self.assertEqual((result.returncode, result.stderr), (0, ""))
|
|
106
|
+
self.assertEqual(result.stdout.splitlines(), ["NATIVE", *args])
|
|
107
|
+
words = log.splitlines()
|
|
108
|
+
tail = words[words.index("learn") + 1:]
|
|
109
|
+
parsed = R.build_parser().parse_args(["--root", "unused", "--provider", provider, "learn", *tail])
|
|
110
|
+
self.assertEqual(parsed.name, "-probe-name")
|
|
111
|
+
|
|
112
|
+
def test_help_is_quiet_native_passthrough(self):
|
|
113
|
+
for provider in ("claude", "codex"):
|
|
114
|
+
result, log = self.run_shim(provider, ["mcp", "add", "--help"])
|
|
115
|
+
self.assertEqual((result.returncode, result.stderr, log), (0, "", ""))
|
|
116
|
+
self.assertEqual(result.stdout.splitlines(), ["NATIVE", "mcp", "add", "--help"])
|
|
117
|
+
|
|
118
|
+
def test_unknown_options_keep_native_argv_and_exit_without_mirroring(self):
|
|
119
|
+
args = ["mcp", "add", "--future-value", "appinspire-mcp", "motionsites"]
|
|
120
|
+
for provider in ("claude", "codex"):
|
|
121
|
+
for native_exit in (0, 7):
|
|
122
|
+
result, log = self.run_shim(provider, args, native_exit)
|
|
123
|
+
self.assertEqual((result.returncode, log), (native_exit, ""))
|
|
124
|
+
self.assertEqual(result.stdout.splitlines(), ["NATIVE", *args])
|
|
125
|
+
self.assertEqual("change not mirrored" in result.stderr, native_exit == 0)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
if __name__ == "__main__":
|
|
129
|
+
unittest.main()
|