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.
Files changed (151) hide show
  1. package/README.md +125 -4
  2. package/bin/claude +389 -94
  3. package/bin/claude-accounts +276 -42
  4. package/bin/codex +69 -5
  5. package/bin/codex-accounts +33 -4
  6. package/docs/ACCOUNT_OPERATIONS.md +62 -2
  7. package/docs/AUTORESUME-HANDOFF.md +28 -3
  8. package/docs/AUTORESUME.md +275 -18
  9. package/docs/CODEX.md +1 -0
  10. package/docs/INSTALLATION.md +7 -1
  11. package/docs/VERIFICATION.md +4 -0
  12. package/lib/__pycache__/audit.cpython-312.pyc +0 -0
  13. package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
  14. package/lib/__pycache__/autoresume_cross.cpython-312.pyc +0 -0
  15. package/lib/__pycache__/autoresume_cross_dispatch.cpython-312.pyc +0 -0
  16. package/lib/__pycache__/autoresume_cross_receipt.cpython-312.pyc +0 -0
  17. package/lib/__pycache__/autoresume_handoff.cpython-312.pyc +0 -0
  18. package/lib/__pycache__/autoresume_probe.cpython-312.pyc +0 -0
  19. package/lib/__pycache__/autoresume_receipt.cpython-312.pyc +0 -0
  20. package/lib/__pycache__/claude_client_error.cpython-312.pyc +0 -0
  21. package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
  22. package/lib/__pycache__/claude_token_identity.cpython-312.pyc +0 -0
  23. package/lib/__pycache__/codex_autoresume_mode.cpython-312.pyc +0 -0
  24. package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
  25. package/lib/__pycache__/codex_credit_controls.cpython-312.pyc +0 -0
  26. package/lib/__pycache__/codex_credit_local_scope.cpython-312.pyc +0 -0
  27. package/lib/__pycache__/codex_credit_selection.cpython-312.pyc +0 -0
  28. package/lib/__pycache__/codex_credits.cpython-312.pyc +0 -0
  29. package/lib/__pycache__/codex_limits_lock.cpython-312.pyc +0 -0
  30. package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
  31. package/lib/__pycache__/codex_trust.cpython-312.pyc +0 -0
  32. package/lib/__pycache__/codex_usage_fetch.cpython-312.pyc +0 -0
  33. package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
  34. package/lib/__pycache__/mcp_oauth.cpython-312.pyc +0 -0
  35. package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
  36. package/lib/__pycache__/model_catalog.cpython-312.pyc +0 -0
  37. package/lib/__pycache__/model_match.cpython-312.pyc +0 -0
  38. package/lib/__pycache__/report.cpython-312.pyc +0 -0
  39. package/lib/__pycache__/selector_credit_scope.cpython-312.pyc +0 -0
  40. package/lib/__pycache__/selector_credits.cpython-312.pyc +0 -0
  41. package/lib/__pycache__/selector_funding.cpython-312.pyc +0 -0
  42. package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
  43. package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
  44. package/lib/__pycache__/session_settings.cpython-312.pyc +0 -0
  45. package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
  46. package/lib/audit.py +88 -22
  47. package/lib/autoresume.py +1197 -82
  48. package/lib/autoresume_cross.py +3 -1
  49. package/lib/autoresume_handoff.py +81 -9
  50. package/lib/autoresume_probe.py +2 -2
  51. package/lib/claude_client_error.py +9 -4
  52. package/lib/codex_trust.py +263 -0
  53. package/lib/common.sh +146 -3
  54. package/lib/credential.py +5 -2
  55. package/lib/install_actions.sh +19 -0
  56. package/lib/keychain.py +180 -36
  57. package/lib/mcp_oauth.py +1748 -0
  58. package/lib/mcp_registry.py +230 -8
  59. package/lib/model_catalog.json +404 -0
  60. package/lib/model_catalog.py +201 -0
  61. package/lib/model_match.py +104 -0
  62. package/lib/report.py +8 -5
  63. package/lib/session_settings.py +123 -0
  64. package/package.json +2 -2
  65. package/tests/__pycache__/autoresume_cross_receipt_support.cpython-312.pyc +0 -0
  66. package/tests/__pycache__/autoresume_cross_tmux_environment.cpython-312.pyc +0 -0
  67. package/tests/__pycache__/autoresume_cross_tmux_support.cpython-312.pyc +0 -0
  68. package/tests/__pycache__/autoresume_cross_unit_support.cpython-312.pyc +0 -0
  69. package/tests/__pycache__/autoresume_tmux_support.cpython-312.pyc +0 -0
  70. package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
  71. package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
  72. package/tests/__pycache__/test_autoresume_catalog.cpython-312.pyc +0 -0
  73. package/tests/__pycache__/test_autoresume_catalog_cli.cpython-312.pyc +0 -0
  74. package/tests/__pycache__/test_autoresume_codex_records.cpython-312.pyc +0 -0
  75. package/tests/__pycache__/test_autoresume_codex_trust.cpython-312.pyc +0 -0
  76. package/tests/__pycache__/test_autoresume_cross_claude_holders.cpython-312.pyc +0 -0
  77. package/tests/__pycache__/test_autoresume_cross_guards.cpython-312.pyc +0 -0
  78. package/tests/__pycache__/test_autoresume_cross_holders.cpython-312.pyc +0 -0
  79. package/tests/__pycache__/test_autoresume_cross_origin.cpython-312.pyc +0 -0
  80. package/tests/__pycache__/test_autoresume_cross_policy.cpython-312.pyc +0 -0
  81. package/tests/__pycache__/test_autoresume_cross_receipt.cpython-312.pyc +0 -0
  82. package/tests/__pycache__/test_autoresume_cross_tmux.cpython-312.pyc +0 -0
  83. package/tests/__pycache__/test_autoresume_cross_tmux_races.cpython-312.pyc +0 -0
  84. package/tests/__pycache__/test_autoresume_cross_tokens.cpython-312.pyc +0 -0
  85. package/tests/__pycache__/test_autoresume_cursor.cpython-312.pyc +0 -0
  86. package/tests/__pycache__/test_autoresume_fallback.cpython-312.pyc +0 -0
  87. package/tests/__pycache__/test_autoresume_handoff_argv.cpython-312.pyc +0 -0
  88. package/tests/__pycache__/test_autoresume_handoff_history.cpython-312.pyc +0 -0
  89. package/tests/__pycache__/test_autoresume_handoff_origins.cpython-312.pyc +0 -0
  90. package/tests/__pycache__/test_autoresume_memory.cpython-312.pyc +0 -0
  91. package/tests/__pycache__/test_autoresume_memory_tmux.cpython-312.pyc +0 -0
  92. package/tests/__pycache__/test_autoresume_mode.cpython-312.pyc +0 -0
  93. package/tests/__pycache__/test_autoresume_probe.cpython-312.pyc +0 -0
  94. package/tests/__pycache__/test_autoresume_receipt.cpython-312.pyc +0 -0
  95. package/tests/__pycache__/test_autoresume_relaunch_settings.cpython-312.pyc +0 -0
  96. package/tests/__pycache__/test_autoresume_settings.cpython-312.pyc +0 -0
  97. package/tests/__pycache__/test_autoresume_sidechain.cpython-312.pyc +0 -0
  98. package/tests/__pycache__/test_autoresume_sidechain_tmux.cpython-312.pyc +0 -0
  99. package/tests/__pycache__/test_autoresume_tmux.cpython-312.pyc +0 -0
  100. package/tests/__pycache__/test_autoresume_tmux_modes.cpython-312.pyc +0 -0
  101. package/tests/__pycache__/test_autoresume_trust_tmux.cpython-312.pyc +0 -0
  102. package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
  103. package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
  104. package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
  105. package/tests/__pycache__/test_codex_reset_credits.cpython-312.pyc +0 -0
  106. package/tests/__pycache__/test_codex_reset_credits_polling.cpython-312.pyc +0 -0
  107. package/tests/__pycache__/test_codex_reset_credits_report.cpython-312.pyc +0 -0
  108. package/tests/__pycache__/test_codex_reset_fetch.cpython-312.pyc +0 -0
  109. package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
  110. package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
  111. package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
  112. package/tests/__pycache__/test_selector.cpython-312.pyc +0 -0
  113. package/tests/__pycache__/test_selector_cached_credits.cpython-312.pyc +0 -0
  114. package/tests/__pycache__/test_selector_credit_scope.cpython-312.pyc +0 -0
  115. package/tests/__pycache__/test_selector_credit_shim.cpython-312.pyc +0 -0
  116. package/tests/__pycache__/test_selector_credits.cpython-312.pyc +0 -0
  117. package/tests/__pycache__/test_selector_spread.cpython-312.pyc +0 -0
  118. package/tests/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
  119. package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
  120. package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
  121. package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
  122. package/tests/fake_codex_tui.py +25 -0
  123. package/tests/fake_security.sh +24 -2
  124. package/tests/fixtures/cross_provider_cli.py +109 -0
  125. package/tests/run-tests.sh +1171 -23
  126. package/tests/test_autoresume.py +215 -8
  127. package/tests/test_autoresume_catalog.py +226 -0
  128. package/tests/test_autoresume_catalog_cli.py +185 -0
  129. package/tests/test_autoresume_codex_records.py +16 -0
  130. package/tests/test_autoresume_codex_trust.py +247 -0
  131. package/tests/test_autoresume_cross_guards.py +28 -0
  132. package/tests/test_autoresume_cross_tmux.py +27 -2
  133. package/tests/test_autoresume_cross_tokens.py +32 -2
  134. package/tests/test_autoresume_handoff_argv.py +120 -16
  135. package/tests/test_autoresume_handoff_origins.py +14 -0
  136. package/tests/test_autoresume_memory.py +1141 -0
  137. package/tests/test_autoresume_memory_tmux.py +94 -0
  138. package/tests/test_autoresume_probe.py +2 -1
  139. package/tests/test_autoresume_relaunch_settings.py +317 -0
  140. package/tests/test_autoresume_settings.py +135 -0
  141. package/tests/test_autoresume_sidechain.py +1518 -0
  142. package/tests/test_autoresume_sidechain_tmux.py +79 -0
  143. package/tests/test_autoresume_trust_tmux.py +63 -0
  144. package/tests/test_claude_client_error.py +10 -7
  145. package/tests/test_claude_org_block.py +1 -0
  146. package/tests/test_claude_pool_prepare.py +4 -1
  147. package/tests/test_claude_reset.py +3 -0
  148. package/tests/test_keychain_nologin.py +340 -0
  149. package/tests/test_mcp_oauth.py +1414 -0
  150. package/tests/test_mcp_registry.py +234 -3
  151. 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 assert_handoff(self, case, source, target):
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, _, root = scenario(self, provider)
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):