claude-multiacc 2.0.58 → 2.0.60
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 +125 -4
- package/bin/claude +389 -94
- package/bin/claude-accounts +276 -42
- package/bin/codex +69 -5
- package/bin/codex-accounts +33 -4
- package/docs/ACCOUNT_OPERATIONS.md +62 -2
- package/docs/AUTORESUME-HANDOFF.md +28 -3
- package/docs/AUTORESUME.md +275 -18
- package/docs/CODEX.md +1 -0
- package/docs/INSTALLATION.md +7 -1
- package/docs/VERIFICATION.md +4 -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_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_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/audit.py +88 -22
- package/lib/autoresume.py +1197 -82
- package/lib/autoresume_cross.py +3 -1
- package/lib/autoresume_handoff.py +81 -9
- package/lib/autoresume_probe.py +2 -2
- package/lib/claude_client_error.py +9 -4
- package/lib/codex_trust.py +263 -0
- package/lib/common.sh +146 -3
- package/lib/credential.py +5 -2
- package/lib/install_actions.sh +19 -0
- package/lib/keychain.py +180 -36
- package/lib/mcp_oauth.py +1748 -0
- package/lib/mcp_registry.py +230 -8
- package/lib/model_catalog.json +404 -0
- package/lib/model_catalog.py +201 -0
- package/lib/model_match.py +104 -0
- package/lib/report.py +8 -5
- package/lib/session_settings.py +123 -0
- package/package.json +2 -2
- 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_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 +25 -0
- package/tests/fake_security.sh +24 -2
- package/tests/fixtures/cross_provider_cli.py +109 -0
- package/tests/run-tests.sh +1171 -23
- package/tests/test_autoresume.py +215 -8
- package/tests/test_autoresume_catalog.py +226 -0
- package/tests/test_autoresume_catalog_cli.py +185 -0
- package/tests/test_autoresume_codex_records.py +16 -0
- package/tests/test_autoresume_codex_trust.py +247 -0
- package/tests/test_autoresume_cross_guards.py +28 -0
- package/tests/test_autoresume_cross_tmux.py +27 -2
- package/tests/test_autoresume_cross_tokens.py +32 -2
- package/tests/test_autoresume_handoff_argv.py +120 -16
- package/tests/test_autoresume_handoff_origins.py +14 -0
- package/tests/test_autoresume_memory.py +1141 -0
- package/tests/test_autoresume_memory_tmux.py +94 -0
- package/tests/test_autoresume_probe.py +2 -1
- package/tests/test_autoresume_relaunch_settings.py +317 -0
- package/tests/test_autoresume_settings.py +135 -0
- package/tests/test_autoresume_sidechain.py +1518 -0
- package/tests/test_autoresume_sidechain_tmux.py +79 -0
- package/tests/test_autoresume_trust_tmux.py +63 -0
- package/tests/test_claude_client_error.py +10 -7
- package/tests/test_claude_org_block.py +1 -0
- package/tests/test_claude_pool_prepare.py +4 -1
- package/tests/test_claude_reset.py +3 -0
- package/tests/test_keychain_nologin.py +340 -0
- package/tests/test_mcp_oauth.py +1414 -0
- package/tests/test_mcp_registry.py +234 -3
- package/tests/test_packaged_commands.py +41 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""`<provider>-accounts model-catalog` and the limits-pass backstop, against temporary pools.
|
|
2
|
+
|
|
3
|
+
The pools, the CLIs' model caches and the provider executables are synthetic and live in a
|
|
4
|
+
temporary HOME. The caller's own pool variables are dropped, so no real pool root is ever
|
|
5
|
+
named, and no request is sent: the catalog reads files only (lib/model_catalog.py).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
import stat
|
|
12
|
+
import subprocess
|
|
13
|
+
import tempfile
|
|
14
|
+
import time
|
|
15
|
+
import unittest
|
|
16
|
+
|
|
17
|
+
REPO = Path(__file__).resolve().parents[1]
|
|
18
|
+
LEVELS = ["low", "medium", "high", "xhigh", "max"]
|
|
19
|
+
OVERLAY = "model-catalog.json"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ModelCatalogCliTests(unittest.TestCase):
|
|
23
|
+
def setUp(self):
|
|
24
|
+
work = tempfile.TemporaryDirectory(prefix="multiacc-catalog-cli-")
|
|
25
|
+
self.addCleanup(work.cleanup)
|
|
26
|
+
self.home = Path(work.name).resolve()
|
|
27
|
+
fakebin = self.home / "bin"
|
|
28
|
+
fakebin.mkdir()
|
|
29
|
+
for name in ("claude", "codex", "security", "curl"):
|
|
30
|
+
script = fakebin / name
|
|
31
|
+
script.write_text('#!/bin/sh\nprintf "%s\\n" "$0 $*" >> "$HOME/unexpected-calls"\nexit 79\n')
|
|
32
|
+
script.chmod(0o755)
|
|
33
|
+
self.env = {k: v for k, v in os.environ.items()
|
|
34
|
+
if not k.startswith(("CLAUDE_", "CODEX_", "MULTIACC_", "ANTHROPIC_", "OPENAI_"))
|
|
35
|
+
and k not in ("TMUX", "TMUX_PANE")}
|
|
36
|
+
missing = "file://" + str(self.home / "missing-endpoint.json")
|
|
37
|
+
self.env.update(HOME=str(self.home), PATH=f"{fakebin}:{os.environ['PATH']}", PYTHONDONTWRITEBYTECODE="1",
|
|
38
|
+
CLAUDE_MULTIACC_CLI_VERSION="2.1.280", CLAUDE_MULTIACC_NO_DISTRIBUTE="1",
|
|
39
|
+
CLAUDE_MULTIACC_MCP_OAUTH="0") # no detached MCP OAuth sync outliving a test
|
|
40
|
+
self.roots = {}
|
|
41
|
+
for provider in ("claude", "codex"):
|
|
42
|
+
root = self.home / (provider + "-pool")
|
|
43
|
+
(root / "acct-01").mkdir(parents=True)
|
|
44
|
+
(root / "accounts.json").write_text(json.dumps({"version": 1, "server": "none", "threshold": 90,
|
|
45
|
+
"accounts": []}))
|
|
46
|
+
self.roots[provider] = root
|
|
47
|
+
up = provider.upper()
|
|
48
|
+
self.env.update({up + "_ACCOUNTS_ROOT": str(root), up + "_MULTIACC_NO_SYNC": "1",
|
|
49
|
+
up + "_MULTIACC_USAGE_URL": missing, up + "_MULTIACC_TOKEN_URL": missing})
|
|
50
|
+
for key in ("KEYCHAIN", "MCP", "AUTORESUME", "AUTO_RESET", "PATH_PROBE"):
|
|
51
|
+
self.env[up + "_MULTIACC_" + key] = "0"
|
|
52
|
+
self.seed_caches()
|
|
53
|
+
|
|
54
|
+
def seed_caches(self):
|
|
55
|
+
"""One Codex models_cache.json and one Claude served catalog, newer than the shipped file."""
|
|
56
|
+
codex = {"fetched_at": "2099-01-01T00:00:00.000000Z", "etag": "fixture", "client_version": "0.162.1",
|
|
57
|
+
"models": [{"slug": "gpt-9-astra", "visibility": "list", "default_reasoning_level": "high",
|
|
58
|
+
"supported_reasoning_levels": [{"effort": e} for e in LEVELS + ["ultra"]],
|
|
59
|
+
"upgrade": None}]}
|
|
60
|
+
(self.roots["codex"] / "acct-01" / "models_cache.json").write_text(json.dumps(codex))
|
|
61
|
+
rows = [{"id": "claude-fable-9", "section": "main",
|
|
62
|
+
"thinking": {"type": "effort", "effort_options": [{"id": e} for e in LEVELS]}}]
|
|
63
|
+
state = {"thinking_by_model": [{"id": "claude-fable-9", "thinking": {"effort": "xhigh"}}]}
|
|
64
|
+
claude = {"version": 2, "fetchedAt": 4102444800000,
|
|
65
|
+
"catalog": {"surface": "cc", "config": {"id": "cc", "models": rows}, "state": state}}
|
|
66
|
+
cache = self.roots["claude"] / "acct-01" / "cache" / "model-catalog"
|
|
67
|
+
cache.mkdir(parents=True)
|
|
68
|
+
(cache / "fixture-cc.json").write_text(json.dumps(claude))
|
|
69
|
+
|
|
70
|
+
def cli(self, provider, *args, **env):
|
|
71
|
+
return subprocess.run([str(REPO / "bin" / (provider + "-accounts")), *args], env={**self.env, **env},
|
|
72
|
+
cwd=str(self.home), capture_output=True, text=True, timeout=60)
|
|
73
|
+
|
|
74
|
+
def copies(self):
|
|
75
|
+
return {provider: root / OVERLAY for provider, root in self.roots.items()}
|
|
76
|
+
|
|
77
|
+
def assert_nothing_written(self):
|
|
78
|
+
self.assertEqual([p for p in self.copies().values() if p.exists()], [])
|
|
79
|
+
|
|
80
|
+
def test_refresh_writes_both_pools_from_the_clis_own_caches(self):
|
|
81
|
+
result = self.cli("claude", "model-catalog", "refresh")
|
|
82
|
+
self.assertEqual(result.returncode, 0, result.stderr)
|
|
83
|
+
copies = sorted(str(path) for path in self.copies().values())
|
|
84
|
+
self.assertEqual(sorted(result.stdout.split()), copies)
|
|
85
|
+
for path in copies:
|
|
86
|
+
doc = json.loads(Path(path).read_text())
|
|
87
|
+
self.assertEqual(doc["schema"], 1)
|
|
88
|
+
self.assertIn("generated_at", doc)
|
|
89
|
+
self.assertEqual(doc["codex"]["fetched_at"], "2099-01-01T00:00:00Z")
|
|
90
|
+
self.assertEqual(doc["codex"]["models"]["gpt-9-astra"]["efforts"], LEVELS + ["ultra"])
|
|
91
|
+
self.assertEqual(doc["codex"]["models"]["gpt-9-astra"]["default_effort"], "high")
|
|
92
|
+
self.assertEqual(doc["claude"]["fetched_at"], "2100-01-01T00:00:00Z")
|
|
93
|
+
self.assertEqual(doc["claude"]["models"]["claude-fable-9"]["default_effort"], "xhigh")
|
|
94
|
+
self.assertEqual(doc["claude"]["families"]["fable"], 5) # structure: the shipped file's
|
|
95
|
+
self.assertEqual(stat.S_IMODE(os.stat(path).st_mode), 0o644)
|
|
96
|
+
self.assertEqual(sorted(os.listdir(str(self.roots["codex"]))), ["accounts.json", "acct-01", OVERLAY])
|
|
97
|
+
self.assertFalse((self.home / "unexpected-calls").exists())
|
|
98
|
+
|
|
99
|
+
def test_show_prints_the_merged_catalog_and_writes_nothing(self):
|
|
100
|
+
result = self.cli("codex", "model-catalog", "show")
|
|
101
|
+
self.assertEqual(result.returncode, 0, result.stderr)
|
|
102
|
+
doc = json.loads(result.stdout)
|
|
103
|
+
self.assertEqual(sorted(doc["codex"]["models"]), ["gpt-9-astra"])
|
|
104
|
+
self.assertEqual(sorted(doc["claude"]["models"]), ["claude-fable-9"])
|
|
105
|
+
self.assert_nothing_written()
|
|
106
|
+
|
|
107
|
+
def test_quiet_prints_nothing_and_if_stale_leaves_a_fresh_copy_alone(self):
|
|
108
|
+
quiet = self.cli("codex", "model-catalog", "refresh", "--quiet")
|
|
109
|
+
self.assertEqual((quiet.returncode, quiet.stdout, quiet.stderr), (0, "", ""))
|
|
110
|
+
copy = self.copies()["claude"]
|
|
111
|
+
inode = copy.stat().st_ino
|
|
112
|
+
fresh = self.cli("claude", "model-catalog", "refresh", "--if-stale")
|
|
113
|
+
self.assertEqual((fresh.returncode, fresh.stdout), (0, ""), fresh.stderr)
|
|
114
|
+
self.assertEqual(copy.stat().st_ino, inode)
|
|
115
|
+
os.utime(str(copy), (0, 0))
|
|
116
|
+
stale = self.cli("claude", "model-catalog", "--if-stale")
|
|
117
|
+
self.assertEqual((stale.returncode, stale.stdout.split()), (0, [str(copy)]), stale.stderr)
|
|
118
|
+
self.assertNotEqual(copy.stat().st_ino, inode)
|
|
119
|
+
|
|
120
|
+
def test_kill_switches_write_nothing_and_say_so(self):
|
|
121
|
+
for switches in ({"CLAUDE_MULTIACC_MODEL_CATALOG": "0", "CODEX_MULTIACC_MODEL_CATALOG": "off"},
|
|
122
|
+
{"MULTIACC_MODEL_CATALOG": "false"}):
|
|
123
|
+
for provider in ("claude", "codex"):
|
|
124
|
+
with self.subTest(provider=provider, switches=switches):
|
|
125
|
+
result = self.cli(provider, "model-catalog", "refresh", **switches)
|
|
126
|
+
self.assertEqual((result.returncode, result.stdout), (0, ""), result.stderr)
|
|
127
|
+
for pool in ("claude", "codex"):
|
|
128
|
+
self.assertIn(f"model catalog refresh is off for the {pool} pool", result.stderr)
|
|
129
|
+
self.assert_nothing_written()
|
|
130
|
+
quiet = self.cli(provider, "model-catalog", "--quiet", **switches)
|
|
131
|
+
self.assertEqual((quiet.returncode, quiet.stdout, quiet.stderr), (0, "", ""))
|
|
132
|
+
# One pool off leaves the other refreshed; the provider's own spelling wins when set.
|
|
133
|
+
result = self.cli("claude", "model-catalog", MULTIACC_MODEL_CATALOG="0", CODEX_MULTIACC_MODEL_CATALOG="1")
|
|
134
|
+
self.assertEqual((result.returncode, result.stdout.split()), (0, [str(self.copies()["codex"])]))
|
|
135
|
+
self.assertIn("claude pool (MULTIACC_MODEL_CATALOG=0)", result.stderr)
|
|
136
|
+
self.assertFalse(self.copies()["claude"].exists())
|
|
137
|
+
|
|
138
|
+
def test_help_and_bad_arguments_touch_nothing(self):
|
|
139
|
+
for provider in ("claude", "codex"):
|
|
140
|
+
for args in (["--help"], ["refresh", "--help"], ["show", "-h"]):
|
|
141
|
+
result = self.cli(provider, "model-catalog", *args)
|
|
142
|
+
self.assertEqual(result.returncode, 0, result.stderr)
|
|
143
|
+
self.assertIn(f"{provider}-accounts model-catalog [refresh|show]", result.stdout)
|
|
144
|
+
for args in (["bogus"], ["refresh", "show"], ["--force"]):
|
|
145
|
+
self.assertNotEqual(self.cli(provider, "model-catalog", *args).returncode, 0, args)
|
|
146
|
+
self.assert_nothing_written()
|
|
147
|
+
|
|
148
|
+
def test_a_refresh_with_no_pool_root_fails_unless_it_only_asked_for_stale_copies(self):
|
|
149
|
+
env = {"CLAUDE_ACCOUNTS_ROOT": str(self.home / "absent-1"), "CODEX_ACCOUNTS_ROOT": str(self.home / "absent-2")}
|
|
150
|
+
result = self.cli("codex", "model-catalog", "refresh", **env)
|
|
151
|
+
self.assertEqual(result.returncode, 1)
|
|
152
|
+
self.assertIn("no pool root could take the model catalog", result.stderr)
|
|
153
|
+
self.assertEqual(self.cli("codex", "model-catalog", "--if-stale", "--quiet", **env).returncode, 0)
|
|
154
|
+
|
|
155
|
+
def test_a_limits_pass_keeps_its_own_pool_copy_fresh_and_never_the_other(self):
|
|
156
|
+
for provider, other in (("claude", "codex"), ("codex", "claude")):
|
|
157
|
+
with self.subTest(provider=provider):
|
|
158
|
+
own, theirs = self.copies()[provider], self.copies()[other]
|
|
159
|
+
for path in (own, theirs):
|
|
160
|
+
if path.exists():
|
|
161
|
+
path.unlink()
|
|
162
|
+
self.assertEqual(self.cli(provider, "limits", "--quiet").returncode, 0)
|
|
163
|
+
self.assertTrue(own.exists()) # in line: written before the pass returned
|
|
164
|
+
self.assertFalse(theirs.exists()) # a pass never writes the other provider's pool
|
|
165
|
+
self.assertIn("gpt-9-astra" if provider == "codex" else "claude-fable-9", own.read_text())
|
|
166
|
+
inode = own.stat().st_ino
|
|
167
|
+
self.assertEqual(self.cli(provider, "limits", "--quiet").returncode, 0)
|
|
168
|
+
self.assertEqual(own.stat().st_ino, inode) # younger than an hour: left alone
|
|
169
|
+
os.utime(str(own), (0, 0))
|
|
170
|
+
self.assertEqual(self.cli(provider, "limits", "--quiet").returncode, 0)
|
|
171
|
+
self.assertNotEqual(own.stat().st_ino, inode)
|
|
172
|
+
self.assertGreater(own.stat().st_mtime, time.time() - 600)
|
|
173
|
+
self.assertFalse((self.home / "unexpected-calls").exists())
|
|
174
|
+
|
|
175
|
+
def test_a_limits_pass_honours_the_kill_switch(self):
|
|
176
|
+
for provider in ("claude", "codex"):
|
|
177
|
+
for switch in ({provider.upper() + "_MULTIACC_MODEL_CATALOG": "0"}, {"MULTIACC_MODEL_CATALOG": "no"}):
|
|
178
|
+
with self.subTest(provider=provider, switch=switch):
|
|
179
|
+
result = self.cli(provider, "limits", "--quiet", **switch)
|
|
180
|
+
self.assertEqual(result.returncode, 0, result.stderr)
|
|
181
|
+
self.assert_nothing_written()
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
if __name__ == "__main__":
|
|
185
|
+
unittest.main()
|
|
@@ -38,6 +38,22 @@ class CurrentCodexRecordsTests(unittest.TestCase):
|
|
|
38
38
|
self.assertEqual(runner.signals, [(pool.PID, signal.SIGTERM)])
|
|
39
39
|
self.assertEqual(pool.events(), ["watch", "detect", "switch"])
|
|
40
40
|
|
|
41
|
+
def test_current_turn_context_pins_the_sessions_model_and_effort(self):
|
|
42
|
+
pool = self.pool
|
|
43
|
+
records, _ = self.records()
|
|
44
|
+
context = {"timestamp": fixtures.iso(pool.t0 - 3), "ordinal": 4, "type": "turn_context", "payload": {
|
|
45
|
+
"turn_id": "01a135db-f14d-7a60-b656-d5ff72abd71d", "root_turn_id": "01a135db-f14d-7a60-b656-d5ff72abd71d",
|
|
46
|
+
"cwd": str(pool.work), "approval_policy": "never", "approvals_reviewer": "user",
|
|
47
|
+
"sandbox_policy": {"type": "danger-full-access"}, "permission_profile": {"type": "disabled"},
|
|
48
|
+
"model": "gpt-6-astra", "multi_agent_version": "v2", "realtime_active": False, "effort": "ultra",
|
|
49
|
+
"summary": "none"}}
|
|
50
|
+
rc, _, runner, _ = pool.run_codex(["resume", fixtures.CX_SID], records[:1] + [context] + records[1:])
|
|
51
|
+
self.assertEqual(rc, 0)
|
|
52
|
+
_, argv = pool.relaunch_of(runner)
|
|
53
|
+
self.assertEqual(argv, ["resume", "-m", "gpt-6-astra", "-c", "model_reasoning_effort=ultra",
|
|
54
|
+
fixtures.CX_SID, fixtures.ar.resume_prompt("quota")])
|
|
55
|
+
self.assertTrue(pool.log_lines()[-1].endswith(" model=gpt-6-astra effort=ultra"))
|
|
56
|
+
|
|
41
57
|
def test_paginated_user_turn_cancels_before_restart(self):
|
|
42
58
|
pool = self.pool
|
|
43
59
|
records, _ = self.records()
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""lib/codex_trust.py: folder trust for a Codex account's config.toml, never through its TUI."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import shutil
|
|
7
|
+
import stat
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import tempfile
|
|
11
|
+
import unittest
|
|
12
|
+
|
|
13
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
14
|
+
SCRIPT = ROOT / "lib" / "codex_trust.py"
|
|
15
|
+
sys.path.insert(0, str(ROOT / "lib"))
|
|
16
|
+
import codex_trust # noqa: E402
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
import tomllib
|
|
20
|
+
except ImportError: # pragma: no cover - python < 3.11
|
|
21
|
+
tomllib = None
|
|
22
|
+
|
|
23
|
+
OTHER = ('# operator notes\nmodel = "gpt-6"\n\n[projects."/elsewhere"]\ntrust_level = "trusted"\n\n'
|
|
24
|
+
'[mcp_servers.demo]\ncommand = "demo"\nargs = ["-I", "[x]", "serve"]\n'
|
|
25
|
+
'env = { A = "1" }\nstartup_timeout_sec = 60\n')
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def section(path):
|
|
29
|
+
return '[projects.%s]\ntrust_level = "trusted"\n' % json.dumps(str(path))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class CodexTrustTests(unittest.TestCase):
|
|
33
|
+
def setUp(self):
|
|
34
|
+
self.tmp = Path(os.path.realpath(tempfile.mkdtemp(prefix="cx-trust-")))
|
|
35
|
+
self.addCleanup(shutil.rmtree, str(self.tmp), True)
|
|
36
|
+
self.acct = self.tmp / "acct-02"
|
|
37
|
+
self.acct.mkdir()
|
|
38
|
+
self.conf = self.acct / "config.toml"
|
|
39
|
+
self.work = self.tmp / "work"
|
|
40
|
+
self.work.mkdir()
|
|
41
|
+
|
|
42
|
+
def run_trust(self, *args):
|
|
43
|
+
return subprocess.run([sys.executable, "-I", str(SCRIPT), *args], capture_output=True,
|
|
44
|
+
text=True, timeout=30)
|
|
45
|
+
|
|
46
|
+
def trust(self, cwd=None, acct=None, code=0):
|
|
47
|
+
out = self.run_trust("--acct-dir", str(acct or self.acct), "--cwd", str(cwd or self.work))
|
|
48
|
+
self.assertEqual((out.returncode, out.stdout, out.stderr), (code, "", ""))
|
|
49
|
+
|
|
50
|
+
def only_config(self):
|
|
51
|
+
self.assertEqual(sorted(p.name for p in self.acct.iterdir()), ["config.toml"])
|
|
52
|
+
|
|
53
|
+
def test_a_missing_config_is_created_holding_just_the_trust(self):
|
|
54
|
+
self.trust()
|
|
55
|
+
self.assertEqual(self.conf.read_text(), section(self.work))
|
|
56
|
+
self.assertEqual(stat.S_IMODE(self.conf.stat().st_mode), 0o600)
|
|
57
|
+
self.only_config()
|
|
58
|
+
if tomllib:
|
|
59
|
+
self.assertEqual(tomllib.loads(self.conf.read_text()),
|
|
60
|
+
{"projects": {str(self.work): {"trust_level": "trusted"}}})
|
|
61
|
+
|
|
62
|
+
def test_an_existing_config_keeps_every_other_byte_and_its_mode(self):
|
|
63
|
+
self.conf.write_text(OTHER)
|
|
64
|
+
self.conf.chmod(0o640)
|
|
65
|
+
self.trust()
|
|
66
|
+
self.assertEqual(self.conf.read_text(), OTHER + "\n" + section(self.work))
|
|
67
|
+
self.assertEqual(stat.S_IMODE(self.conf.stat().st_mode), 0o640)
|
|
68
|
+
self.only_config()
|
|
69
|
+
# no trailing newline: the new table still starts on a line of its own
|
|
70
|
+
self.conf.write_text('model = "gpt-6"')
|
|
71
|
+
self.trust()
|
|
72
|
+
self.assertEqual(self.conf.read_text(), 'model = "gpt-6"\n\n' + section(self.work))
|
|
73
|
+
|
|
74
|
+
def test_an_untrusted_or_incomplete_table_is_completed_in_place(self):
|
|
75
|
+
key = json.dumps(str(self.work))
|
|
76
|
+
self.conf.write_text('[projects.%s]\nnotes = "kept"\ntrust_level = "untrusted" # by hand\n\n'
|
|
77
|
+
'[projects."/other"]\ntrust_level = "untrusted"\n' % key)
|
|
78
|
+
self.trust()
|
|
79
|
+
self.assertEqual(self.conf.read_text(),
|
|
80
|
+
'[projects.%s]\nnotes = "kept"\ntrust_level = "trusted"\n\n'
|
|
81
|
+
'[projects."/other"]\ntrust_level = "untrusted"\n' % key)
|
|
82
|
+
# a table without the key gets it right under its header; a literal-string key counts
|
|
83
|
+
self.conf.write_text("[projects.'%s']\nnotes = \"kept\"" % self.work)
|
|
84
|
+
self.trust() # the file's own missing final newline is not the edit's to add
|
|
85
|
+
self.assertEqual(self.conf.read_text(),
|
|
86
|
+
"[projects.'%s']\ntrust_level = \"trusted\"\nnotes = \"kept\"" % self.work)
|
|
87
|
+
self.conf.write_text("[projects.%s]" % key)
|
|
88
|
+
self.trust()
|
|
89
|
+
self.assertEqual(self.conf.read_text(), section(self.work))
|
|
90
|
+
# spacing and a comment around the header are accepted
|
|
91
|
+
self.conf.write_text('[ projects . %s ] # here\ntrust_level = "untrusted"\n' % key)
|
|
92
|
+
self.trust()
|
|
93
|
+
self.assertEqual(self.conf.read_text(), '[ projects . %s ] # here\ntrust_level = "trusted"\n' % key)
|
|
94
|
+
|
|
95
|
+
def test_an_already_trusted_directory_rewrites_nothing(self):
|
|
96
|
+
for text in (section(self.work),
|
|
97
|
+
"[projects.%s]\ntrust_level = 'trusted' # single quotes\n" % json.dumps(str(self.work)),
|
|
98
|
+
OTHER + "\n" + section(self.work) + "\n[projects.\"/z\"]\ntrust_level = \"untrusted\"\n"):
|
|
99
|
+
self.conf.write_text(text)
|
|
100
|
+
before = self.conf.stat()
|
|
101
|
+
os.utime(str(self.conf), (before.st_atime, before.st_mtime - 100))
|
|
102
|
+
mtime = self.conf.stat().st_mtime_ns
|
|
103
|
+
self.trust()
|
|
104
|
+
self.assertEqual((self.conf.read_text(), self.conf.stat().st_mtime_ns), (text, mtime))
|
|
105
|
+
self.only_config()
|
|
106
|
+
|
|
107
|
+
def test_the_logical_and_the_physical_path_are_both_trusted(self):
|
|
108
|
+
link = self.tmp / "link to work"
|
|
109
|
+
os.symlink(str(self.work), str(link))
|
|
110
|
+
self.trust(cwd=link)
|
|
111
|
+
self.assertEqual(self.conf.read_text(), section(link) + "\n" + section(self.work))
|
|
112
|
+
# the physical one already trusted: only the logical one is added, after it
|
|
113
|
+
self.conf.write_text(section(self.work))
|
|
114
|
+
self.trust(cwd=link)
|
|
115
|
+
self.assertEqual(self.conf.read_text(), section(self.work) + "\n" + section(link))
|
|
116
|
+
|
|
117
|
+
def test_paths_needing_escapes_round_trip(self):
|
|
118
|
+
odd = self.tmp / 'quote " and \\ backslash'
|
|
119
|
+
odd.mkdir()
|
|
120
|
+
self.trust(cwd=odd)
|
|
121
|
+
text = self.conf.read_text()
|
|
122
|
+
self.assertEqual(text, section(odd))
|
|
123
|
+
if tomllib:
|
|
124
|
+
self.assertEqual(list(tomllib.loads(text)["projects"]), [str(odd)])
|
|
125
|
+
self.trust(cwd=odd) # read back as the same key: nothing to add
|
|
126
|
+
self.assertEqual(self.conf.read_text(), text)
|
|
127
|
+
|
|
128
|
+
def test_layouts_the_edit_could_corrupt_are_refused(self):
|
|
129
|
+
for text in ('projects = { "/x" = { trust_level = "trusted" } }\n',
|
|
130
|
+
'projects."/x".trust_level = "untrusted"\n',
|
|
131
|
+
'[projects]\n"/x" = { trust_level = "trusted" }\n',
|
|
132
|
+
'[projects."/x"]\ntrust_level = "trusted"\n' * 2,
|
|
133
|
+
'[projects."/x"]\ntrust_level = "trusted"\n[projects]\n'):
|
|
134
|
+
self.conf.write_text(text)
|
|
135
|
+
self.trust(code=1)
|
|
136
|
+
self.assertEqual(self.conf.read_text(), text)
|
|
137
|
+
self.only_config()
|
|
138
|
+
# a `projects` key inside another table is not the projects table
|
|
139
|
+
self.conf.write_text('[other]\nprojects = 1\n')
|
|
140
|
+
self.trust()
|
|
141
|
+
self.assertEqual(self.conf.read_text(), '[other]\nprojects = 1\n\n' + section(self.work))
|
|
142
|
+
|
|
143
|
+
def test_unreadable_or_invalid_configs_are_left_alone(self):
|
|
144
|
+
self.conf.write_bytes(b"\xff\xfe not utf-8\n")
|
|
145
|
+
self.trust(code=1)
|
|
146
|
+
self.assertEqual(self.conf.read_bytes(), b"\xff\xfe not utf-8\n")
|
|
147
|
+
if tomllib:
|
|
148
|
+
for text in ("model = \n", '[projects."/x"]\ntrust_level = "a" "b"\n', "[broken\n"):
|
|
149
|
+
self.conf.write_text(text)
|
|
150
|
+
self.trust(code=1)
|
|
151
|
+
self.assertEqual(self.conf.read_text(), text)
|
|
152
|
+
self.only_config()
|
|
153
|
+
|
|
154
|
+
def test_a_config_rewritten_meanwhile_is_not_overwritten(self):
|
|
155
|
+
self.conf.write_text(OTHER)
|
|
156
|
+
raced = OTHER + '\n[projects."/raced"]\ntrust_level = "trusted"\n'
|
|
157
|
+
real_mkstemp = codex_trust.tempfile.mkstemp
|
|
158
|
+
|
|
159
|
+
def sneaky(**kwargs):
|
|
160
|
+
self.conf.write_text(raced)
|
|
161
|
+
return real_mkstemp(**kwargs)
|
|
162
|
+
|
|
163
|
+
codex_trust.tempfile.mkstemp = sneaky
|
|
164
|
+
try:
|
|
165
|
+
self.assertEqual(codex_trust.trust(str(self.acct), str(self.work)), 1)
|
|
166
|
+
finally:
|
|
167
|
+
codex_trust.tempfile.mkstemp = real_mkstemp
|
|
168
|
+
self.assertEqual(self.conf.read_text(), raced)
|
|
169
|
+
self.only_config()
|
|
170
|
+
|
|
171
|
+
def test_a_symlinked_config_is_edited_through_the_link(self):
|
|
172
|
+
target = self.tmp / "shared-config.toml"
|
|
173
|
+
target.write_text(OTHER)
|
|
174
|
+
self.conf.symlink_to(target)
|
|
175
|
+
self.trust()
|
|
176
|
+
self.assertTrue(self.conf.is_symlink())
|
|
177
|
+
self.assertEqual(target.read_text(), OTHER + "\n" + section(self.work))
|
|
178
|
+
|
|
179
|
+
def test_non_ascii_paths_and_unicode_line_separators_stay_valid_toml(self):
|
|
180
|
+
odd = self.tmp / "emoji \U0001f600 dir"
|
|
181
|
+
odd.mkdir()
|
|
182
|
+
other = '[other]\nnotes = "a\u2028b\u0085c"\n' # line separators TOML does not break on
|
|
183
|
+
self.conf.write_text(other)
|
|
184
|
+
self.trust(cwd=odd)
|
|
185
|
+
text = self.conf.read_text()
|
|
186
|
+
self.assertEqual(text, other + '\n[projects."%s"]\ntrust_level = "trusted"\n' % odd)
|
|
187
|
+
if tomllib:
|
|
188
|
+
self.assertEqual(tomllib.loads(text)["projects"][str(odd)], {"trust_level": "trusted"})
|
|
189
|
+
self.trust(cwd=odd) # read back as the same key
|
|
190
|
+
self.assertEqual(self.conf.read_text(), text)
|
|
191
|
+
|
|
192
|
+
def test_the_text_edit_alone_is_valid_toml_without_a_parser(self):
|
|
193
|
+
# the stock macOS python has no tomllib: the edit must stand without the check
|
|
194
|
+
odd = self.tmp / 'emoji \U0001f600 " \\ \x7f dir'
|
|
195
|
+
odd.mkdir()
|
|
196
|
+
self.conf.write_text(OTHER)
|
|
197
|
+
real_verify = codex_trust._verify
|
|
198
|
+
codex_trust._verify = lambda *args: True
|
|
199
|
+
try:
|
|
200
|
+
self.assertEqual(codex_trust.trust(str(self.acct), str(odd)), 0)
|
|
201
|
+
finally:
|
|
202
|
+
codex_trust._verify = real_verify
|
|
203
|
+
text = self.conf.read_text()
|
|
204
|
+
self.assertTrue(text.startswith(OTHER))
|
|
205
|
+
if tomllib:
|
|
206
|
+
self.assertEqual(tomllib.loads(text)["projects"][str(odd)], {"trust_level": "trusted"})
|
|
207
|
+
|
|
208
|
+
def test_a_byte_order_mark_is_kept(self):
|
|
209
|
+
self.conf.write_text("\ufeff" + OTHER)
|
|
210
|
+
self.trust()
|
|
211
|
+
self.assertEqual(self.conf.read_text(), "\ufeff" + OTHER + "\n" + section(self.work))
|
|
212
|
+
self.trust()
|
|
213
|
+
self.assertEqual(self.conf.read_text(), "\ufeff" + OTHER + "\n" + section(self.work))
|
|
214
|
+
|
|
215
|
+
@unittest.skipUnless(hasattr(os, "mkfifo"), "needs mkfifo")
|
|
216
|
+
def test_a_fifo_named_config_does_not_hang(self):
|
|
217
|
+
os.mkfifo(str(self.conf))
|
|
218
|
+
self.trust(code=1)
|
|
219
|
+
|
|
220
|
+
def test_the_on_disk_spelling_is_trusted_too(self):
|
|
221
|
+
upper = self.tmp / "Work Dir"
|
|
222
|
+
upper.mkdir()
|
|
223
|
+
lower = Path(str(upper).lower())
|
|
224
|
+
keys = codex_trust.spellings(str(lower))
|
|
225
|
+
self.assertEqual(keys[0], str(lower))
|
|
226
|
+
if lower.exists() and str(lower) != str(upper): # a case-insensitive volume (macOS)
|
|
227
|
+
self.assertIn(str(upper), keys)
|
|
228
|
+
self.trust(cwd=lower)
|
|
229
|
+
self.assertEqual(self.conf.read_text(), section(lower) + "\n" + section(upper))
|
|
230
|
+
else:
|
|
231
|
+
self.assertEqual(keys, [str(lower)])
|
|
232
|
+
|
|
233
|
+
def test_usage_and_argument_errors(self):
|
|
234
|
+
for args in ((), ("--acct-dir", str(self.acct)), ("--cwd", str(self.work)),
|
|
235
|
+
("--acct-dir", str(self.acct), "--cwd", str(self.work), "extra"),
|
|
236
|
+
("--acct-dir", str(self.acct), "--cwd"),
|
|
237
|
+
("--acct-dir", str(self.acct), "--acct-dir", str(self.acct))):
|
|
238
|
+
out = self.run_trust(*args)
|
|
239
|
+
self.assertEqual(out.returncode, 2, args)
|
|
240
|
+
self.assertIn("usage", out.stderr)
|
|
241
|
+
self.trust(cwd="relative/dir", code=1)
|
|
242
|
+
self.trust(acct=self.tmp / "acct-09", code=1)
|
|
243
|
+
self.assertFalse(self.conf.exists())
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
if __name__ == "__main__":
|
|
247
|
+
unittest.main()
|
|
@@ -4,11 +4,20 @@ from pathlib import Path
|
|
|
4
4
|
import unittest
|
|
5
5
|
from unittest import mock
|
|
6
6
|
|
|
7
|
+
import test_autoresume as fixtures
|
|
7
8
|
from autoresume_cross_receipt_support import TOKEN
|
|
8
9
|
from autoresume_cross_unit_support import scenario
|
|
9
10
|
import autoresume as ar
|
|
10
11
|
import autoresume_cross as cross
|
|
11
12
|
from autoresume_probe import cross_env, handoff_settings
|
|
13
|
+
import model_match
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def settings_record(case, provider):
|
|
17
|
+
if provider == "codex":
|
|
18
|
+
return {"timestamp": fixtures.iso(case.t0 - 1), "type": "turn_context",
|
|
19
|
+
"payload": {"cwd": str(case.work), "model": "gpt-6-astra", "effort": "ultra"}}
|
|
20
|
+
return fixtures.cc_reply(case.t0 - 1, cwd=str(case.work), effort="xhigh")
|
|
12
21
|
|
|
13
22
|
|
|
14
23
|
class CrossProviderGuardsTests(unittest.TestCase):
|
|
@@ -109,6 +118,25 @@ class CrossProviderGuardsTests(unittest.TestCase):
|
|
|
109
118
|
self.assertEqual(history.stat().st_ino, inode)
|
|
110
119
|
self.assertFalse(cross.revalidate(watcher, plan))
|
|
111
120
|
|
|
121
|
+
def test_mapped_plan_revalidates_and_a_failed_mapping_still_plans_unpinned(self):
|
|
122
|
+
pins = {"--model", "--effort", "-m", "-c"}
|
|
123
|
+
for provider in ("claude", "codex"):
|
|
124
|
+
with self.subTest(provider=provider):
|
|
125
|
+
case, watcher, runner, history, _ = scenario(self, provider)
|
|
126
|
+
fixtures.append(str(history), [settings_record(case, provider)])
|
|
127
|
+
plan = cross.plan_fallback(watcher, watcher.tracker.pending, "")
|
|
128
|
+
self.assertTrue(pins & set(plan["argv"][:-1]))
|
|
129
|
+
self.assertEqual(runner.target_probes[-1]["argv"][1:], plan["argv"])
|
|
130
|
+
fixtures.append(str(history), [{"type": "progress", "data": "later source state"}])
|
|
131
|
+
self.assertTrue(cross.revalidate(watcher, plan))
|
|
132
|
+
with mock.patch.object(model_match, "resume_settings", side_effect=RuntimeError("catalog")):
|
|
133
|
+
unpinned = cross.plan_fallback(watcher, watcher.tracker.pending, "")
|
|
134
|
+
self.assertIsNotNone(unpinned)
|
|
135
|
+
self.assertFalse(pins & set(unpinned["argv"][:-1]))
|
|
136
|
+
self.assertEqual(unpinned["models"]["flags"], [])
|
|
137
|
+
self.assertEqual(runner.target_probes[-1]["argv"][1:], unpinned["argv"])
|
|
138
|
+
self.assertTrue(cross.revalidate(watcher, unpinned))
|
|
139
|
+
|
|
112
140
|
def test_target_environment_cannot_inherit_source_auth_pins_or_relaunch_tokens(self):
|
|
113
141
|
roots = {"claude": "/tmp/fixture-claude", "codex": "/tmp/fixture-codex"}
|
|
114
142
|
private = {key: "TEST_ONLY_SENTINEL" for key in (
|
|
@@ -7,6 +7,19 @@ import unittest
|
|
|
7
7
|
|
|
8
8
|
from autoresume_cross_tmux_support import CrossScenario, TMUX
|
|
9
9
|
|
|
10
|
+
# The fake CLIs record Codex gpt-6-astra at ultra and Claude claude-fable-5-1 at max (or the pins
|
|
11
|
+
# they were launched with); the shipped catalog maps each to the other provider's same level
|
|
12
|
+
# (the operator's map: astra -> opus, fable -> astra).
|
|
13
|
+
MAPPED = {"claude": ["--model", "claude-opus-5-5", "--effort", "max"],
|
|
14
|
+
"codex": ["-m", "gpt-6-astra", "-c", "model_reasoning_effort=max"]}
|
|
15
|
+
SENTENCE = {"claude": "Source model: gpt-6-astra (effort ultra). This conversation runs the matching claude "
|
|
16
|
+
"model claude-opus-5-5 (effort max).",
|
|
17
|
+
"codex": "Source model: claude-fable-5-1 (effort max). This conversation runs the matching codex "
|
|
18
|
+
"model gpt-6-astra (effort max)."}
|
|
19
|
+
BYPASS = {"claude": ["--dangerously-skip-permissions"], "codex": ["--dangerously-bypass-approvals-and-sandbox"]}
|
|
20
|
+
CONSERVATIVE = {"claude": ["--permission-mode", "default"],
|
|
21
|
+
"codex": ["--sandbox", "read-only", "--ask-for-approval", "on-request"]}
|
|
22
|
+
|
|
10
23
|
|
|
11
24
|
@unittest.skipUnless(TMUX, "real tmux is unavailable")
|
|
12
25
|
class CrossProviderTmuxTests(unittest.TestCase):
|
|
@@ -16,7 +29,15 @@ class CrossProviderTmuxTests(unittest.TestCase):
|
|
|
16
29
|
case.start(provider, args)
|
|
17
30
|
return case
|
|
18
31
|
|
|
19
|
-
def
|
|
32
|
+
def assert_mapped_launch(self, launch, permissions, prompt):
|
|
33
|
+
"""Permission options, then the same-level model pins, then the exact handoff prompt."""
|
|
34
|
+
argv = list(launch["argv"])
|
|
35
|
+
if launch["provider"] == "codex":
|
|
36
|
+
self.assertEqual(argv.count("--no-daemon"), 1)
|
|
37
|
+
argv.remove("--no-daemon")
|
|
38
|
+
self.assertEqual(argv, permissions + MAPPED[launch["provider"]] + [prompt])
|
|
39
|
+
|
|
40
|
+
def assert_handoff(self, case, source, target, permissions=None):
|
|
20
41
|
case.wait(lambda: len(case.records("ready")) >= 2, "target TUI ready")
|
|
21
42
|
case.wait(lambda: "autoresume switch" in case.logs(source), "source confirms target prompt")
|
|
22
43
|
first, second = case.records("launch")[:2]
|
|
@@ -37,6 +58,8 @@ class CrossProviderTmuxTests(unittest.TestCase):
|
|
|
37
58
|
self.assertEqual(case.records("keys"), [], "no keystroke may reach a TUI")
|
|
38
59
|
self.assertEqual(case.records("signal")[0]["signal"], signal.SIGTERM)
|
|
39
60
|
self.assertEqual(list(case.home.glob("*-pool/tmp/autoresume/r-*")), [])
|
|
61
|
+
self.assert_mapped_launch(second, BYPASS[target] if permissions is None else permissions, prompt["text"])
|
|
62
|
+
self.assertIn(SENTENCE[target], prompt["text"])
|
|
40
63
|
return first, second
|
|
41
64
|
|
|
42
65
|
def test_codex_pool_exhausted_continues_in_claude_with_source_identity(self):
|
|
@@ -104,7 +127,7 @@ class CrossProviderTmuxTests(unittest.TestCase):
|
|
|
104
127
|
case = self.scenario(source, args=[])
|
|
105
128
|
case.park(source)
|
|
106
129
|
case.quota()
|
|
107
|
-
_first, second = self.assert_handoff(case, source, target)
|
|
130
|
+
_first, second = self.assert_handoff(case, source, target, CONSERVATIVE[target])
|
|
108
131
|
if target == "claude":
|
|
109
132
|
self.assertEqual(second["argv"][:2], ["--permission-mode", "default"])
|
|
110
133
|
else:
|
|
@@ -129,6 +152,8 @@ class CrossProviderTmuxTests(unittest.TestCase):
|
|
|
129
152
|
third = case.records("launch")[2]
|
|
130
153
|
self.assertEqual((third["provider"], third["account"]), ("codex", "acct-02"))
|
|
131
154
|
self.assertNotEqual(third["sid"], first["sid"])
|
|
155
|
+
# The return hop maps the Claude session's recorded model and effort back to Codex.
|
|
156
|
+
self.assert_mapped_launch(third, BYPASS["codex"], case.records("prompt")[1]["text"])
|
|
132
157
|
chains = re.findall(r"autoresume watch provider=\w+ chain=(\w+)", case.logs())
|
|
133
158
|
self.assertEqual(len(chains), 3)
|
|
134
159
|
self.assertEqual(len(set(chains)), 1)
|
|
@@ -12,10 +12,24 @@ from autoresume_cross_unit_support import scenario
|
|
|
12
12
|
import autoresume_cross as cross
|
|
13
13
|
import autoresume_cross_dispatch as dispatch
|
|
14
14
|
|
|
15
|
+
# Source model records and the same-level target pins they map to through the shipped catalog.
|
|
16
|
+
MAPPED = {"claude": ("claude-opus-5-5[1m]", "xhigh", ["-m", "gpt-6-astra", "-c", "model_reasoning_effort=xhigh"]),
|
|
17
|
+
"codex": ("gpt-6-astra", "ultra", ["--model", "claude-opus-5-5", "--effort", "max"])}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def settings_record(case, provider):
|
|
21
|
+
model, effort, _ = MAPPED[provider]
|
|
22
|
+
if provider == "codex":
|
|
23
|
+
return {"timestamp": fixtures.iso(case.t0 - 1), "type": "turn_context",
|
|
24
|
+
"payload": {"cwd": str(case.work), "model": model, "effort": effort}}
|
|
25
|
+
return fixtures.cc_reply(case.t0 - 1, cwd=str(case.work), requestedModel=model, effort=effort)
|
|
26
|
+
|
|
15
27
|
|
|
16
28
|
class CrossProviderTokenTests(unittest.TestCase):
|
|
17
|
-
def prepared(self, provider):
|
|
18
|
-
case, watcher, runner,
|
|
29
|
+
def prepared(self, provider, settings=False):
|
|
30
|
+
case, watcher, runner, history, root = scenario(self, provider)
|
|
31
|
+
if settings:
|
|
32
|
+
fixtures.append(str(history), [settings_record(case, provider)])
|
|
19
33
|
target = "codex" if provider == "claude" else "claude"
|
|
20
34
|
watcher.st["depth"] = 3
|
|
21
35
|
watcher.st["avoid_" + target] = "acct-02:%d" % (case.t0 + 3600)
|
|
@@ -52,6 +66,22 @@ class CrossProviderTokenTests(unittest.TestCase):
|
|
|
52
66
|
self.assertIn(watcher.st["self"], typed)
|
|
53
67
|
self.assertNotIn(plan["argv"][-1], typed)
|
|
54
68
|
|
|
69
|
+
def test_only_the_target_argv_carries_the_mapped_model_and_effort(self):
|
|
70
|
+
for provider in ("claude", "codex"):
|
|
71
|
+
with self.subTest(provider=provider):
|
|
72
|
+
case, watcher, runner, plan, keys, paths = self.prepared(provider, settings=True)
|
|
73
|
+
model, _, flags = MAPPED[provider]
|
|
74
|
+
self.assertEqual(plan["argv"][-len(flags) - 1:-1], flags)
|
|
75
|
+
self.assertEqual(runner.target_probes[-1]["argv"], [runner.target] + plan["argv"])
|
|
76
|
+
target = Path(plan["root"]) / "tmp/autoresume" / ("r-" + TOKEN + ".argv")
|
|
77
|
+
self.assertEqual(fixtures.ar.decode_argv(target.read_bytes()), plan["argv"])
|
|
78
|
+
self.assertEqual(plan["models"]["flags"], flags)
|
|
79
|
+
typed = next(key[-1] for key in keys if "-l" in key)
|
|
80
|
+
metadata = [typed] + [Path(path).read_text() for path in paths if not path.endswith(".argv")]
|
|
81
|
+
for text in metadata:
|
|
82
|
+
for value in (model, flags[1], "--effort", "model_reasoning_effort"):
|
|
83
|
+
self.assertNotIn(value, text)
|
|
84
|
+
|
|
55
85
|
def test_dispatch_payload_is_exact_installation_metadata_without_auth_or_prompt(self):
|
|
56
86
|
for provider in ("claude", "codex"):
|
|
57
87
|
with self.subTest(provider=provider):
|