Cloud: exec and file routes on a Cloud machine, in classic /api/vm's shapes (cmux-platform#89 B)

Merged
TE
8d ago
+498-16

Leo, 2026-10-30, on building the app-server cloud path now instead of after the Saturday demo: "well why can't we just.... do our best now?"

Part of manaflow-ai/cmux-platform#89, piece B: exec, file and secret routes for app-server (CloudDO) machines. #27778 stays on HOLD until pieces A–C land.

Why

On the app-server link, cmux cloud deliver-secrets, apply-machine-config and credential recovery go through classic cmux.com /api/vm/{id}/fs and /exec. A CloudDO machine has no row there, so delivery fails at its first read and Claude starts signed out. Classic has no secrets route: deliver-secrets reads the machine config through fs, writes a receiver and a ticket through fs, then types the values over the daemon link. So exec plus fs is the whole gap.

What changes

New plain HTTP routes in cmux-api (cloud-machine-guest.ts), with the same requests and answers as classic, so the app reuses its classic decoding (piece C, cloud-np-c):

RouteBody / queryAnswer
POST /v1/cloud/machines/{m}/exec{command, timeoutMs?}{exitCode, stdout, stderr}. Runs as the work user; 30 s default, 5 min max (the provider's inline exec limit; classic allows 15), 64 KiB command, at most 1 MiB of each output stream
GET …/fs/dir?path={entries: [{name, kind, size, mode, modifiedAt}]}
GET …/fs/read?path={path, dataBase64, size} (8 MiB max)
GET …/fs/stat?path={path, kind, size, mode, modifiedAt}
POST …/fs/write{path, dataBase64, mode?}{ok: true, path}. Atomic temp file and rename; an existing file keeps its owner and (without mode) its mode; a new file without mode is 0600, as classic
POST …/fs/mkdir{path}{ok: true, path} (recursive)
DELETE …/fs/remove?path={ok: true, path}
  • Errors: {ok: false, error: {code, message}}, with the classic code also in the x-cmux-vm-error header. Codes: vm_not_found, vm_file_not_found, vm_invalid_path, vm_invalid_command, vm_command_too_large, vm_file_too_large, vm_invalid_file_body, vm_invalid_file_mode, vm_not_running, vm_unavailable, vm_file_operation_failed.
  • Who may call: the same caller check as client-session (callerFor, now exported with a refusal argument). That means a person's session, or a cli or mac install whose grant has execute, through team select and the SSO gate. Agent tokens and VM installs are refused.
  • Which machines: CloudDO.ownerExec. The machine must be in the selected team (another team's machine is not found), and the caller must be able to use that team's machines now: their personal team, or a member with team.resources and no departure heard during the read. Classic lets any member of the owning team do the same. cloud-np-a (piece A) agreed this rule. A's vm_kind: "owner" answer is a stricter rule of its own (the creator on their personal team), so the two never disagree.
  • Paths are trimmed and then checked by classic's rule: absolute, no NUL, no .. segment, at most 4096 bytes.
  • Exec output is capped on the machine by a small python3 wrapper. It reads both streams to the end, keeps 1 MiB of each, keeps the command's exit code and adds a note on stderr. A command that writes without end costs the machine CPU until its timeout, never memory or answer size.
  • How the files are handled: a small python3 program run as root on the machine. Classic's file API also writes as root, and the client already chowns afterwards. The path is shell-quoted as one argument, and file content goes over stdin only.
  • Audit: each call writes one owner.exec audit row with who, the machine and the exit code. No command, file content or secret is ever written to an op, an event, a log line or the audit.
  • automationExec: now goes through the same guestExec helper and stays creator-only. Its behaviour is unchanged, and a test pins it.
  • Fake provider: gains an opt-in scripted guest reply (fakeControl({guest_reply})) and records each such call. Without a reply set it refuses other commands, as before.

Why cmux-api and not cmux-platform: CloudDO and its rows live here, and cmux-platform serves nothing authenticated outside development (scripts/check-inert.ts). cmux-platform's port doc also says "a fix goes to cmux first and comes along with the port". The area owner (cloud-demo) and piece A agreed on this. Staging gets these routes as soon as cmux-api deploys.

Risk

risk:high (auth): a new way to run a command as the work user, or a file operation as root, on a machine. The scope is no wider than what the same person already has through classic /api/vm and the daemon link's terminals on their team's machine.

  • A machine can be reached only through its own team's CloudDO.
  • Machine tokens and agent tokens are refused, and the caller's membership is read fresh on every call.
  • Bodies are capped (about 11 MiB for a write) and read with a bounded reader.
  • remove / is refused.

No auto-resume: a paused machine answers 409, where classic would resume it.

Tests

  • test/cloud-machine-guest.test.ts (workerd), new:
    • Classic path, base64 and timeout rules, and the path quoting.
    • CloudDO.ownerExec: a machine still binding is refused, the audit carries no command, and a provider failure comes back as provider_failed.
    • A teammate may; a caller from another team finds nothing; a member who has left is refused, and so is a creator who has left. automationExec stays creator-only.
    • Over HTTP: classic shapes for exec (work user) and files (root, content over stdin only), and the file program's exit codes mapped to classic errors.
    • Refused before any guest call: no auth, another person's machine (404), wrong method, a blank or oversized command, a bad path, .., remove /, bad base64, a bad mode, a machine that is not running.
  • The output cap, run locally: an endless writer was cut at the cap with exit code 7 kept and the note on stderr; a quote in the command passed through; a signal gave 143. A new file written without mode came out 0600.
  • The file program itself, run locally against a temp folder: mkdir, write (mode, then an overwrite that keeps the mode), stat, read, dir, plus missing file → 44, missing parent → 44, wrong kind → 45, and remove.

Review

  • Round 1 (codex gpt-6-astra high) at 4f0df1ee: FIX. Four findings: new files 0644, no output cap, timeouts above the provider's 300 s limit, untrimmed paths. All four are fixed in 993d47bb. It found authorization, team selection, agent and VM-install rejection, departure checks and shell quoting sound.

Observed / Not run

  • Ran on a lane host: bun --bun x vitest run on cloud-machine-guest, automation-machine, platform-machines, cloud-client-session, cloud-providers and the cloud-acpmux/env/do/bind files, all passed. tsc -p ., check-file-size and prepush-gate are clean.
  • Not run: the full backend suite (CI runs it), and a live call against a real Freestyle VM on development (after merge).

Changelog

none

🤖 Generated with Claude Code

Agent session: agent session