cmux-tui: Cloud machine files and exec direct to Freestyle with a cached client session (cloud machine-io)

Merged
TE
5d ago
+1787-23

Leo asked: "are we properly using freestyle's client sessions? their files api stuff?" Not yet: the Mac app's Cloud machine file browse, read, write and exec all go Mac -> cmux API -> Freestyle with the team key. This PR adds the Rust side of the direct route. The app's forwarding is the companion Swift PR.

What Freestyle allows a client-session token to call

I checked this live against api.freestyle.sh and in its published OpenAPI (identityAccessToken security scheme):

  • Allowed: POST /v5/vms/{id}/exec-await and the /pty routes, with the x-freestyle-identity-access-token header.
  • Refused: every /v5/vms/{id}/fs/* route (dir, read, write, stat, mkdir, remove, uploads). Freestyle answers 403 {"code":"FORBIDDEN","message":"forbidden: identity tokens may only use VM exec and PTY routes"}. The scheme's own description says the token reaches "only their exec and terminal routes".

So the direct route runs the file operations through exec-await. It uses the same Python file program that cmux-platform's /v1/cloud/machines/{id}/fs/* routes run (MachineGuest.ts FS_PROGRAM), with the same answers and exit codes 44 to 47. A note for Freestyle: if identity tokens could call /fs/read and /fs/write, we could use raw-byte streaming and drop the base64 step.

cmux cloud machine-io

The command takes one request as JSON on stdin. The credential never goes in argv. It prints one JSON line: {"route":"direct"|"proxy"|"none","status","code","body"}. status, code and body are what the cmux API's own route would answer, so the app decodes both routes the same way.

  • Mint and cache. It calls POST /v1/cloud/machines/{id}/client-session with ttl_seconds: 900. The session is cached per (cmux API, team, machine, person) as one 0600 file in a 0700 folder, written whole and then renamed. The person is the JWT sub, so a refreshed Stack token reuses the cache. A cached session is renewed when it is within 5.5 minutes of its end, so no call (at most a 5 minute exec) starts on a token that could lapse mid-call. The end time trusts the earlier of the server's expires_at and this host's clock plus the TTL.
  • Direct route. Freestyle exec-await runs as the work user cmux with the identity token only. The cmux credential never goes to the provider. Writes up to 1 MiB, Freestyle's stdin limit, take one exec. Larger files are staged in 1 MiB chunks into a .cmux-write-* file next to the target, then renamed over the target once their sha256 matches, so a write is still atomic and has no size cap. Reads are ranged 4 MiB chunks. Exec goes through the same output-cap program the cmux API uses.
  • Fallback to the proxy (/v1/cloud/machines/{id}/{exec,fs/*}, unchanged) happens when:
    • the request asks for it;
    • the id is not a Worker machine (vm_ plus 20 characters);
    • the request is one the route itself refuses (bad path, bad base64, an empty command);
    • the cmux API refuses the mint. A 403, 404 or 501 (driver.capabilities.clientSession false shows up as cloud.provider.unsupported) holds the proxy for 10 minutes. A 429, a 5xx or no answer holds it for 1 minute. A 409 or 401 is not remembered;
    • Freestyle refuses a freshly minted token. A refused cached token is minted once more first;
    • the operation needs root. The file routes run as root and the direct route runs as cmux, so a path the work user may not touch, or an existing file someone else owns, exits 48 and goes to the proxy, which keeps root's semantics.
  • No replays. An exec, write, mkdir or remove that reached Freestyle without a usable answer may already have run. It is reported as 502 vm_unavailable and never replayed through the proxy, because a replay could undo a change made after it. A read, stat, dir or chunk staging falls back, since none of them touch the target. Only a request that never connected is always safe to fall back.
  • One version per read. Each chunk carries the open file's version (device, inode, size, mtime and ctime). A file replaced or changed between chunks is read again from the start, up to 3 times, then sent to the proxy.
  • Owner and mode kept. A write keeps the replaced file's mode and group. When the work user can't set that group, the write goes to the proxy.
  • cloud_client_session.rs: the mint is now a shared mint_session that returns the HTTP status and known code. cmux cloud client-session behaves as before.

Backend: nothing is needed. cmux-platform already mints (ClientSession.ts) and revokes. Identities are reused per (machine, user) and swept when a machine goes or a member leaves.

Tests

cargo test -p cmux-tui --lib -- cli::cloud_machine_io cli::cloud_client_session runs through cmux-ci test against a fake cmux API plus fake Freestyle on one local port:

  • mint once, reuse, renew inside the margin, cache file and folder modes, no token in the cache path;
  • the identity token is the only provider header (no authorization);
  • refused mints by status, each with its hold;
  • a refused cached token is minted again, and a refused fresh token goes to the proxy;
  • the needs-root fallback, and requests that never mint;
  • no proxy replay of a change that may have run;
  • a read that never mixes two versions of a file;
  • proxy refusals pass through without the credential.

The fake Freestyle can also run each exec locally with bash and python3, which exercises the real file program end to end:

  • a 1,000,000 byte write and read;
  • a 5 MiB + 17 byte staged write and ranged read;
  • modes for new files, existing files and explicit modes;
  • mkdir, dir, stat, remove, 404 and wrong-kind;
  • exec exit code and streams, and a read-only folder going to the proxy.

Performance

metricbeforeafterdeltaharness or run link
edge round trip from a Mac to the first hop (unauthenticated 4xx, median of 15)60.8 ms (cloud-api.cmux.dev)35.1 ms (api.freestyle.sh)-42%curl -w %{time_total}, same Mac, same minute
network hops for one file call3 (Mac -> cmux API Worker -> CloudDO -> Freestyle)1 (Mac -> Freestyle)-2 hopsrequest counts in the unit tests' fake
cmux API calls for N file calls in 9 minutes on one machineN1 (the mint)-(N-1)mints_once_then_reuses_the_cached_session_with_only_the_identity_token

The 1 MB file read and write numbers from the same harness are not in this table yet. Measuring them needs a NEXT Worker machine (vm_ plus 20 characters) and an execute-capable credential, and neither exists from a CLI today. The Worker link is debug-only (CMUX_NEXT_CLOUD_LINK=app), every machine on our account is classic (vm-<hex>), and cmux vm token create answers 404. Follow-up: #29957. The measurement is a curl -w of the proxied fs/read and fs/write against cmux cloud machine-io on one machine.

PR size: about 1,300 lines, about half of them tests. The cache, direct route, fallback and file program are one state machine, tested together. The Swift forwarding is split into its own PR.

Risk: medium. Nothing calls cloud machine-io until the Swift PR lands. When it does, every unexpected case falls back to today's proxied route.

šŸ¤– Generated with Claude Code

Agent session: agent session


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.


Summary by cubic

Adds cmux cloud machine-io, a Unix-only CLI command that runs Cloud machine file operations and exec directly to Freestyle using a cached client session, with automatic fallback to the existing proxied routes. This lets the macOS app reach Cloud machines without passing the team credential through the cmux API for every call.

Freestyle identity tokens can only call a VM's exec and PTY routes, so file operations run the same Python file program the cmux API uses, via exec-await. Operations that need root, a machine Freestyle won't accept, or a refused mint fall back to the proxy.

  • Mint and cache a 15-minute session per machine and person, renewed before expiry; no token is passed through argv or logged.
  • Writes up to 1 MiB take one exec; larger files are staged in chunks, verified, and renamed atomically.
  • A request that may have run is never re-executed through the proxy.
  • Refused mints and providers without client sessions hold the proxy for 1 or 10 minutes.