Restore legacy Subrouter Codex sessions through sr, never replaying their command (#21423 part)

DA
13d ago
+1515-59

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 plain codex …, sr codex … or subrouter codex … invocation. These make the result ambiguous, 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 … && and VAR=value);
      • conflicting providers.
    • legacyRestoreDecision(legacyCommand:sessionID:workingDirectory:capturedLaunch:) returns one of:
      • .route only when the evidence is provider("subrouter"): the canonical capture codex -c model_provider=subrouter, carrying the captured launch environment, then the record's saved environment, then any inline CODEX_HOME/HOME from the command, which wins (the same order as the literal-resume path), plus the captured verification home. #24428's planner resumes it as sr codex resume <id> through its markerless reroute and argv[0] identity check. The command's own options and endpoint are never carried.
      • .refuse only when the evidence is ambiguous and the command names a Subrouter provider assignment (model_provider=subrouter, or model_providers.subrouter.*).
      • .fallThrough otherwise, so the existing legacy path runs unchanged. A command that merely mentions subrouter, such as a CODEX_HOME under ~/.subrouter or 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 resume naming a different session makes the evidence ambiguous.
    • CodexLegacyRestoreCommand now 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 no model_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). Real sr captures 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 with model_provider=subrouter-ish is 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 a resume of 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 no 127.0.0.1 endpoint and no model_providers.subrouter option.
  • 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 test in Packages/macOS/CMUXAgentLaunch: 903 tests in 103 suites pass on the head; the red test commits fail against the code before each fix.
    • cmux-unit build-for-testing (Debug, CMUX_DEV_BACKEND_MODE=local, own derived data): ** TEST BUILD SUCCEEDED **, so the app, the CLI and cmuxTests compile.
    • 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's resume are refused; ordinary legacy records are unchanged.
  • Not run:
    • cmuxTests and 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.