cmux NEXT: Cloud machine actions from the CLI need --target and return their result

Merged
TE
16d ago
+1173-75

An agent's cmux cloud kill-machine --confirm true with no --target used to kill whatever machine the person's window showed (or the only Cloud machine). Cloud machine actions run from the CLI, MCP, a script, a page or another client now act only on the machine they name, and they answer with their result instead of opening a sheet on the person's screen.

What changes

  • No fallback machine for automation. CloudHandlers.machine falls back to the active window's machine (then the only machine) only for origin == .user. Any other run without --target machine:<id> is refused with the existing CloudStrings.noMachine ("…pass --target machine:."). This covers every machine action: kill, pause, resume, resize, snapshot, restore, delete-snapshot, hand-off, exec, files, ports, and the rest. New Cloud Workspace keeps the fallback for every caller, because a missed machine there only opens a workspace.
  • cloud new-machine returns the machine. A CLI/MCP run answers with result: {machine_id, title, status, target: "machine:<id>"} once the server returns the machine. A slow server answers {pending: true, creation_id} after 30 s, inside action.run's 40 s result deadline, so a caller never reads a slow create as a failure and starts a second machine. A declined or dismissed create is refused.
  • cloud exec returns output. For a non-person run on a machine that allows API exec, the command goes through POST /api/vm/{id}/exec (25 s cap) and returns {exit_code, stdout, stderr}. It opens no tab. A person's run still types the command into a new tab.
  • Other Cloud reads return data instead of a sheet when the run is not a person's: machine-status, machine-ports, machine-tools, hand-off-machine, snapshot-machine, promote-machine-to-template, files-list, file-read, file-stat, prepare-scp, network-list, firewall-list|get, domain-list and publication-list return text plus structured fields. new-terminal-on-machine returns workspace_id and moves the window only when the run asks for focus. A person's run is unchanged. This follows the pattern #23109 uses for diagnostics.
  • action list / action describe say why an action is unavailable. An action whose requires context is missing now carries missing_context, available_with_target (for Cloud machine actions, ["machine"]) and a localized context_reason ("Needs cloudWorkspace in the focused window, or pass --target machine:.").

The CLI already prints action.run's result, so no Rust change is needed. Actions that now answer with data are marked waitsForResult, which gives them the 40 s result deadline.

Files #23109 edits were avoided: no hunk touches cloudDiagnostics/diagnostics(), CloudPresenter.swift, AppControl+Cloud.swift or CmuxNextDaemonService/CloudStrings.swift. The one change in CloudHandlers+Account.swift is in bindCreation, away from the diagnostics region. New strings live in the app module's Cloud.xcstrings and CmuxNextControl/Localizable.xcstrings, with every locale.

Changelog

  • Changed: Cloud machine actions run from the CLI or an agent need --target machine:<id> and return their result (new machine id, command output, status) instead of opening a window.

Tests

  • CloudCLITargetTests (CmuxNextAppTests): CLI/MCP/script/remote/page runs without a target are refused, a named target resolves, a missing one is not_found, the person keeps the fallback, a run nested inside a CLI run is refused too, a CLI answer comes back as the run's result, slow work answers pending when its wait ends and quick work answers with its result, a refusal in the work reaches the caller as one, and new-machine answers with the machine, pending, declined and failed.
  • ActionContextGapTests (CmuxNextControlTests): the unavailable reason in action.list/describe.

Red/green on the fleet (cmux-ci run --class light --script scripts/ci/run-swift-testing-suites.sh --arg Packages/macOS/CmuxNext --red):

  • Red, first round: base feat-cmux-next plus only the target tests (scratch 80c30ad43e8, step 0237514e). aRunThatIsNotThePersonsNeedsAMachineTarget fails for all 5 origins (cli, mcp, script, remote, page) with "an error was expected but none was thrown and CloudMachineSession was returned". That's the fallback bug.
  • Red, review fixes: 4d9d68fb3a9 plus only the new tests (scratch 2ce8b4f13f2, step a233d043). Two tests fail:
    • aNestedRunInsideACLIRunNeedsAMachineTarget: no error is thrown, so a nested run still gets the fallback.
    • slowWorkAnswersPendingWhenItsWaitEnds: the answer took 8.53 s against a 5 s limit, with a 100 ms wait and 8 s of work. The race waited for the work.
  • Green: head e7f28eaa458 (step a9594174). CloudCLITargetTests and ActionContextGapTests pass.
  • Other suites fail in the same step on both sides: 35 fail on both the red and the green run.
    • Only on green: VideoWallpaperTests, a CoreVideo CVPixelBufferPoolCreatePixelBuffer segfault in VideoJob.frame. That's code this PR doesn't touch.
    • Only on red: three palette and space-switch timing suites.
  • Index out of range in PR CI's cmux-next swift test. This is AgentPaneImageActionsTests.showInFinderSelectsANamedCopyOfTheImage, not this PR:
    • revealed().count is 4 where the test expects 5. The test then reads revealed()[4] unchecked (AgentPaneImageActionsTests.swift:75), which crashes the whole single-process run.
    • The same crash happens on the base-plus-tests scratch run (step 0237514e, base 8697514a20e). This PR doesn't touch that file or module.
    • Base push runs skip cmux-next swift test (its if: excludes pushes to feat-cmux-next), so base has no log of its own to compare.
  • An earlier red attempt on adfb21f4d11 didn't count: base CmuxNextAppTests didn't compile until 0f6199ce7cd, so the branch now merges base.

Observed / Not run

  • python3 scripts/cmux-next/prepush-gate.py: no new violations.
  • Review fixes since the independent review of 4d9d68fb (NO-SHIP):
    • Snapshot and promote-template now answer pending at 30 s. The old withTaskGroup race awaited the work's Task.value, which ignores cancellation, so it waited for the whole snapshot. Both sides now yield into one AsyncStream (firstAnswer).
    • CloudHandlers.machine, answer, exec and new-terminal read ActionRunScope.current?.origin ?? invocation.origin, so a nested perform inside a CLI run gets no fallback.
    • A CLI or agent cloud exec on a machine without API exec is refused with cloud.cli.execUnavailable, in every locale of Cloud.xcstrings. Before, it typed into a tab whose output it couldn't return.
  • The cmux-cloud-vm skill docs say --target is required and that results come back.
  • ./scripts/localize-changes --catalog … for both catalogs: the new keys are translated in every locale the catalog carries. localization_catalog.py check shows the same base parity error count with and without this PR (41373 at this head).
  • Not run: no live app, CLI, socket or MCP calls (by rule). The CLI behavior is read from ControlRouter+ActionRun.swift (result) and cli/app.rs (prints the reply).

Not in this PR (follow-ups)

  1. cmux machine list / --catalog / machine <vm-id> show don't list Cloud machines. These are daemon methods (resource_api.rs LocalResourceMachineService), not app methods. The app only feeds --catalog through MachineCatalogPublisher → MachineRegistry.catalogRecords(), which drops Cloud machines with no machineResourceID, and MachineCatalogRecord.validID accepts only machine_<32hex>, never vm-…. A full fix needs both sides: Rust machine.rs/resource_api.rs/machine_catalog.rs accept vm- ids, a compatibility field, and remote records without --catalog; Swift MachineCatalogRecord gains those fields. The cheaper alternative is to have the Rust CLI merge the app's cloud.machines rows into machine list for the cmux surface. Until then, cmux app call cloud.machines lists them.
  2. Reading a Cloud machine's terminal from the CLI. cloud exec no longer needs it. In general, a term_… on a Cloud daemon gives selector.not_found, because the CLI talks to one daemon socket, and <session>:term_… gives socket.unreachable, because federation.rs says Cloud and SSH sessions "have no CLI transport yet". PublicIDTargetResolver.publicID also returns bare ids in created. This needs an app-proxied terminal read or a CLI transport to remote sessions.
  3. cmux workspace new-on-machine --machine <id> fails with "requires --machine": the global-flag parser in cli.rs (--machine routes a daemon) takes --machine before the action sees it, except after action run. This needs a Rust fix that passes --machine through for CLI action verbs whose schema has a machine argument.
  4. A paused machine's stage reads failed. CloudMachineStageInput.stage maps paused + idle link to .failed(machinePaused). It should be its own stage. That touches the progress view and sidebar, so it's a separate PR.
  5. workspace new-on-machine still returns created: []: its workspace is created on a remote daemon, and scope.created only resolves home-daemon objects to public ids.

No capture: CLI, MCP and agent results only (JSON answers, refusals and action describe fields), so nothing on screen changes. A person's own runs still open the same sheets and tabs.

🤖 Generated with Claude Code
Agent session: cosmic-gray