Restore legacy Subrouter Codex sessions through sr, never replaying their command (#21423 part)
Summary
Older Subrouter-routed Codex sessions whose restore record has only a persisted shell command now resume through the fixed sr codex resume <id> route when that command proves Subrouter, and are refused rather than replayed when it names Subrouter in a form that can't be read safely. Every other legacy command restores exactly as before.
Depends only on #24428 (package-side Subrouter session identity). This branch is #24428's head plus the commits below.
Why. Restore records written before cmux captured structured launch data have only a persisted shell command, for example codex -c model_provider=subrouter -c model_providers.subrouter.base_url=http://127.0.0.1:41234/v1 resume <id>. The endpoint it names belonged to a Subrouter process from the previous run, so replaying it can't reach the provider. With #24428, commands the existing literal parser (CodexLegacyRestoreCommand) accepts are already rerouted by the planner. This PR covers the commands that parser rejects (no resume positional, sr codex … / subrouter codex …, a trailing prompt), and makes an unreadable Subrouter command fail closed instead of being replayed.
What changes
- Package (
CMUXAgentLaunch):SubrouterCodexResumeRouting.providerEvidence(inShellCommand:)reads provider evidence from a persisted command, as evidence only. It reads at most 16 KiB and 256 words, walks Unicode scalars (as a shell reads bytes, so a quote followed by a combining mark is still a quote), splits literal text the way a POSIX shell would, and accepts only a plaincodex …,sr codex …orsubrouter codex …invocation. These make the resultambiguous, which never routes:- any shell operator,
$or backquote expansion or substitution; - a glob, a leading
~,!or#; - an unterminated quote, a line break or another control character;
- a prefix (including
cd … &&andVAR=value); - conflicting providers.
- any shell operator,
legacyRestoreDecision(legacyCommand:sessionID:workingDirectory:capturedLaunch:)returns one of:.routeonly when the evidence isprovider("subrouter"): the canonical capturecodex -c model_provider=subrouter, carrying the captured launch environment, then the record's saved environment, then any inlineCODEX_HOME/HOMEfrom the command, which wins (the same order as the literal-resume path), plus the captured verification home. #24428's planner resumes it assr codex resume <id>through its markerless reroute and argv[0] identity check. The command's own options and endpoint are never carried..refuseonly when the evidence is ambiguous and the command names a Subrouter provider assignment (model_provider=subrouter, ormodel_providers.subrouter.*)..fallThroughotherwise, so the existing legacy path runs unchanged. A command that merely mentions subrouter, such as aCODEX_HOMEunder~/.subrouteror a checkout of the Subrouter repo with another provider, restores as before.
- When the shell-command reader is used (the literal parser rejected the command), the session must be a UUID, and a
resumenaming a different session makes the evidence ambiguous. CodexLegacyRestoreCommandnow splits only on space and tab, as a shell does; U+00A0 and other Unicode spaces are ordinary characters.
- CLI (
CMUXCLI+AdmittedRestore.swift): for resume-agent records only, a legacy Codex restore applies that decision: route plans from the canonical capture, refuse stops with the existing "saved restore data is not compatible" error, fall-through keeps the existing path.
Trade-off. A routed legacy record's other launch options (model, approval mode and so on) aren't carried, because they live in text this PR deliberately doesn't replay. The routed capture is plain codex, so a custom Codex binary named only in the legacy command text isn't carried; one recorded in the captured launch is, through #24428's managed-wrapper routing.
Known limits (unchanged from the #24428 base):
- A readable command that names only
model_providers.subrouter.*with nomodel_provider, or one that spells Subrouter in an escaped or split form together with a conflicting provider, still falls through to the existing path, which replays its options as argv (never as shell). Realsrcaptures carry both options, so this needs hand-crafted text. - The refusal check reads the raw text; it doesn't anchor the end of
subrouter, so a contrived ambiguous command withmodel_provider=subrouter-ishis refused rather than falling through.
Follow-ups, not in this PR:
- Legacy Claude records that rely on Subrouter's marker pair. #21423 rebuilt their options from the legacy command text, which needs its own decision.
- Carrying a routed legacy record's other launch options.
Left out on purpose:
- #21423's app-side synthesis of an agent record from a binding-only snapshot. On restore the app types
cmux restore <kind> <id>and the CLI re-resolves the record, so the routing fix lives in the CLI's legacy path. - #21423's process-detected compatibility replay, which types a captured shell command.
Testing
Red commits come before each fix.
SubrouterCodexShellCommandEvidenceTests: proven, absent, other provider, ambiguous shapes, size and word bounds, POSIX quoting rules, a provider after--, a combining-mark quote, and an embedded line break.SubrouterCodexLegacyRestoreTests: route / refuse / fall-through, including commands that only mention subrouter in a path with another provider (fall through), unreadable Subrouter commands (refuse), a non-UUID session and aresumeof another session (refuse), the captured environment and verification home, and an end-to-end planner check that a routed command resumes as["sr", "codex", "resume", <id>, …]with no127.0.0.1endpoint and nomodel_providers.subrouteroption.CodexLegacyRestoreCommandTests: U+00A0 doesn't split a word.
Focused run: swift test --package-path Packages/macOS/CMUXAgentLaunch --filter "SubrouterCodexLegacyRestoreTests|SubrouterCodexShellCommandEvidenceTests|CodexLegacyRestoreCommandTests".
Observed / Not run
- Observed:
- Full
swift testinPackages/macOS/CMUXAgentLaunch: 903 tests in 103 suites pass on the head; the red test commits fail against the code before each fix. cmux-unitbuild-for-testing (Debug,CMUX_DEV_BACKEND_MODE=local, own derived data):** TEST BUILD SUCCEEDED **, so the app, the CLI andcmuxTestscompile.- An independent review re-probed the decision against the planner: the two path-only false positives fall through and restore directly; the proven forms route to
sr codex resume <id>; the unreadable Subrouter forms, a non-UUID session and another session'sresumeare refused; ordinary legacy records are unchanged.
- Full
- Not run:
cmuxTestsand the CLI's legacy branch weren't executed. The CLI path has no harness, the test host opens the real Feed store, and fork PRs get no macOS test lane.- No live restore. A tagged app's panes start through a login shell that loads the real account's profile, so a live restore could start real agents.
- Some boundaries aren't pinned by a test: the whitespace and quote handling in the refusal check, and stopping the other-session check at
--. The inline-over-saved precedence test's red step is a compile failure (it needed a new parameter), since the CLI branch has no harness.
Changelog
Fixed: restoring an older Subrouter Codex session resumes it through sr instead of a saved command whose provider route no longer exists.
🤖 Generated with Claude Code
Summary by CodeRabbit
- Bug Fixes
- Improved Codex session restore and resume behavior, including for sessions using Subrouter.
- Restore now checks that captured launch details match the session’s executable and routing settings before using them.
- Subrouter restores avoid carrying forward captured endpoint settings and private routing options.
- Legacy restore commands with unclear or incomplete information are rejected rather than resumed.
- Adjusted command parsing so nonbreaking spaces are not treated as separators.