diff --git a/cmux-tui/README.md b/cmux-tui/README.md index 515dca4a62da..8a4755294f57 100644 --- a/cmux-tui/README.md +++ b/cmux-tui/README.md @@ -13,6 +13,7 @@ - [Mouse](docs/mouse.md) - [Configuration](docs/configuration.md) - [Machines and remote sessions](docs/machines.md) +- [Fly Sprites](docs/sprites.md) - [Public CLI](spec/cli.md) - [SDK contract](spec/bindings.md) - [Public resource protocol](spec/resource-api-v1.md) diff --git a/cmux-tui/crates/cmux-tui-core/src/mux.rs b/cmux-tui/crates/cmux-tui-core/src/mux.rs index 4386fe83a87e..cc500abddcf8 100644 --- a/cmux-tui/crates/cmux-tui-core/src/mux.rs +++ b/cmux-tui/crates/cmux-tui-core/src/mux.rs @@ -16213,6 +16213,29 @@ mod tests { assert_eq!(surface.test_cell_pixel_size(), (8, 16)); } + #[cfg(unix)] + #[test] + fn exited_host_cell_pixels_converge_without_a_live_host() { + let mux = test_mux(); + let surface = Surface::exited_terminal_placeholder( + mux.next_id(), + mux.surface_options.lock().unwrap().clone(), + Arc::downgrade(&mux), + TerminalHostIdentity { + terminal_id: "00112233445566778899aabbccddeeff".into(), + incarnation: "11111111111111111111111111111111".into(), + }, + ) + .unwrap(); + insert_surface_checked(&mut mux.state.lock().unwrap(), surface.clone()).unwrap(); + + let update = mux.set_cell_pixel_size(16, 32); + + assert!(update.failures.is_empty(), "{:?}", update.failures); + assert_eq!(mux.cell_pixel_size(), (16, 32)); + assert_eq!(surface.test_cell_pixel_size(), (16, 32)); + } + #[test] fn terminal_spawn_releases_cell_pixel_lifecycle_and_reconciles_before_publish() { let mux = test_mux(); diff --git a/cmux-tui/crates/cmux-tui-core/src/surface.rs b/cmux-tui/crates/cmux-tui-core/src/surface.rs index bc25c4d68192..3c997bc3f092 100644 --- a/cmux-tui/crates/cmux-tui-core/src/surface.rs +++ b/cmux-tui/crates/cmux-tui-core/src/surface.rs @@ -5060,8 +5060,9 @@ impl PtySurface { } return Ok(true); } - PtyRuntime::ExitedHosted => return Ok(false), - PtyRuntime::Local { .. } => {} + // The host is gone, but its retained terminal snapshot still + // renders for attached clients and must adopt their metrics. + PtyRuntime::ExitedHosted | PtyRuntime::Local { .. } => {} } } let mut geometry = self.geometry.lock().unwrap(); @@ -5093,7 +5094,7 @@ impl PtySurface { #[cfg(unix)] PtyRuntime::Hosted(_) => None, #[cfg(unix)] - PtyRuntime::ExitedHosted => return Ok(false), + PtyRuntime::ExitedHosted => None, }; let has_attach_taps = { let mut taps = self.taps.lock().unwrap(); diff --git a/cmux-tui/docs/README.md b/cmux-tui/docs/README.md index 8eb32ab5c649..e4c9304c4179 100644 --- a/cmux-tui/docs/README.md +++ b/cmux-tui/docs/README.md @@ -11,6 +11,7 @@ - [Configuration](configuration.md): full `cmux-tui.json` reference with defaults and a worked example. - [Remote daemon](remote.md): authenticated clients, SSH, WebSocket, Iroh, relays, RPC, and port forwarding. The exact agent RPC wire schema is in the [remote RPC contract](../spec/remote-rpc.md). - [Machines](machines.md): optional dual rails, static Unix/SSH targets, relay, `npx cmux` remote setup, and outbound `npx cmux machine-agent` registration. +- [Fly Sprites](sprites.md): credential-free base checkpoints, direct remote connections, and the management/device authentication boundary. - [Raw control protocol](protocol.md): private protocol-v10 JSON-lines commands and compatibility rules. - [Public resource protocol](../spec/resource-api-v1.md): stable opaque IDs, requests, mutations, streams, and errors. - [Public CLI](../spec/cli.md): noun-first commands and selectors. diff --git a/cmux-tui/docs/sprites.md b/cmux-tui/docs/sprites.md new file mode 100644 index 000000000000..7669a5c4f6ed --- /dev/null +++ b/cmux-tui/docs/sprites.md @@ -0,0 +1,99 @@ +# Fly Sprites + +Fly Sprites can host a persistent remote cmux session on a normal ext4 +filesystem. The base checkpoint contains the pinned `cmux` npm distribution +and a Sprite service, but deliberately excludes every daemon identity, +invitation, client key, and provider credential. + +## Build the base checkpoint + +Install the Sprite CLI, source an owner-only token file, and run: + +```sh +set -a +source ~/.secrets/fly-sprites.env +set +a +cmux-tui/scripts/build-sprite-base.sh --org manaflow +``` + +The script prints the Sprite name and checkpoint id. It leaves the `cmux-tui` +service stopped and `/home/sprite/.local/share/cmux-sprite/remote` absent. +Starting the service after a new Sprite is provisioned generates a unique +daemon identity. + +Checkpoints currently restore one Sprite. Fly's cross-Sprite drive forking is +in beta and is not exposed by the public REST API. Until that API ships, this +checkpoint is a verified base artifact rather than a generally available +create-time template. + +## Authentication boundary + +There are two independent trust planes: + +1. The cmux backend owns `SPRITE_TOKEN` and uses it for Sprite lifecycle, + service, filesystem, and exec APIs. The token never enters a Sprite, native + client, command argument, checkpoint, or database row. +2. Native clients connect directly through the public Sprite HTTPS edge, but + cmux authenticates the daemon key and device key inside an end-to-end Noise + session. The public URL is an untrusted carrier, not an authorization + boundary. + +Do not put a static `--ws-token` in the checkpoint. Do not checkpoint +`/home/sprite/.local/share/cmux-sprite/remote`; clones would share the daemon +private key and enrolled-device database. + +## Provision and enroll one client + +Start the service after provisioning: + +```sh +sprite exec -o -s -- sprite-env services start cmux-tui +``` + +Fetch the Sprite URL through the authenticated management API. Create a +single-device, short-lived invitation inside the Sprite: + +```sh +cmux enroll create \ + --session sprite \ + --state-dir /home/sprite/.local/share/cmux-sprite/remote \ + --advertise wss:///v1/link \ + --ttl 300 \ + --json +``` + +The backend returns the invitation once to the already authenticated cmux +user. The native client stores it in an owner-only file, claims it with its +persistent device key, and waits: + +```sh +cmux connect \ + --invite-file ~/.config/cmux/sprite.invite \ + --device-name +``` + +The backend then runs `cmux enroll pending` through authenticated Sprite exec. +It approves only the invitation id it issued for that user, VM, and expiry. +Never approve every pending request, and never identify a claimant only by its +display name. + +After approval, the client reconnects with its device key. The invitation is +single-device and expires after five minutes. Revocation uses `cmux enroll +revoke`; it closes live sessions and rejects the device in the future. + +For a team-owned Sprite, authorize invitation creation and approval against +the exact team and VM row before any provider call. Record the issued +invitation id, user id, VM id, expiry, and final device fingerprint. Store no +invitation URI or Sprite token. + +## Why the URL is public + +The current native WebSocket client does not attach a Fly organization token. +Keeping the Sprite URL private would require a cmux backend relay that adds the +provider credential. A public URL avoids that relay and preserves direct +interactive latency. Unauthenticated callers can reach the WebSocket edge but +cannot complete cmux's Noise handshake or access mux data. + +If direct public carriers are unacceptable, implement a machine-provider +stream that keeps `SPRITE_TOKEN` server-side and forwards opaque cmux protocol +messages. Do not send the provider token to the native client. diff --git a/cmux-tui/scripts/build-sprite-base.sh b/cmux-tui/scripts/build-sprite-base.sh new file mode 100755 index 000000000000..a40f9f9804f0 --- /dev/null +++ b/cmux-tui/scripts/build-sprite-base.sh @@ -0,0 +1,148 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + printf '%s\n' \ + 'Usage: build-sprite-base.sh [options]' \ + '' \ + 'Build a credential-free Fly Sprite checkpoint containing a pinned cmux remote' \ + 'daemon service.' \ + '' \ + 'Options:' \ + ' --org Sprite organization (default: SPRITE_ORG or manaflow)' \ + ' --name Base Sprite name (default: cmux-tui-base-)' \ + ' --cmux-version Pinned npm cmux version (default: 0.9.11)' \ + ' --keep-running Start the daemon after creating the clean checkpoint' \ + ' --keep-on-failure Do not destroy a newly created Sprite after failure' \ + ' -h, --help Show this help' \ + '' \ + 'Authentication:' \ + ' Source an owner-only file that exports SPRITE_TOKEN before running. The token' \ + ' is used only by the local Sprite CLI and is never copied into the Sprite or' \ + ' checkpoint.' +} + +org="${SPRITE_ORG:-manaflow}" +name="" +cmux_version="0.9.11" +keep_running=0 +keep_on_failure=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --org) + org="${2:?missing value for --org}" + shift 2 + ;; + --name) + name="${2:?missing value for --name}" + shift 2 + ;; + --cmux-version) + cmux_version="${2:?missing value for --cmux-version}" + shift 2 + ;; + --keep-running) + keep_running=1 + shift + ;; + --keep-on-failure) + keep_on_failure=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if [[ ! "$cmux_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([+-][0-9A-Za-z.-]+)?$ ]]; then + echo "--cmux-version must be an exact semver version" >&2 + exit 2 +fi +if [[ -z "${SPRITE_TOKEN:-}" ]]; then + echo "SPRITE_TOKEN is required" >&2 + exit 2 +fi +if ! command -v sprite >/dev/null 2>&1; then + echo "sprite CLI is required: curl -fsSL https://sprites.dev/install.sh | bash" >&2 + exit 2 +fi + +if [[ -z "$name" ]]; then + name="cmux-tui-base-$(date -u +%Y%m%d-%H%M%S)" +fi +if [[ ! "$name" =~ ^[a-z0-9][a-z0-9-]*$ ]]; then + echo "--name must contain lowercase letters, digits, and hyphens" >&2 + exit 2 +fi + +created=0 +cleanup() { + local status=$? + if [[ "$status" -ne 0 && "$created" -eq 1 && "$keep_on_failure" -eq 0 ]]; then + sprite destroy -o "$org" -s "$name" --force >/dev/null 2>&1 || true + fi + exit "$status" +} +trap cleanup EXIT + +sprite create -o "$org" --skip-console "$name" +created=1 + +# The Sprite edge is only the carrier. cmux authenticates and encrypts every +# connection with Noise and an enrolled device key. No Sprite management token +# or static cmux token is embedded in the image. +sprite url update -o "$org" -s "$name" --auth public + +sprite exec -o "$org" -s "$name" -- bash -lc " + set -euo pipefail + npm install -g --omit=dev --no-audit --fund=false 'cmux@${cmux_version}' + sudo ln -sf \"\$(npm prefix -g)/bin/cmux\" /usr/local/bin/cmux + /usr/local/bin/cmux --version + install -d -m 700 /home/sprite/.local/share/cmux-sprite + sprite-env services create cmux-tui \ + --cmd /usr/local/bin/cmux \ + --args daemon,--session,sprite,--remote-ws,0.0.0.0:8080,--remote-ws-insecure-bind,--remote-state-dir,/home/sprite/.local/share/cmux-sprite/remote \ + --dir /home/sprite \ + --http-port 8080 \ + --duration 10s + sprite-env services stop cmux-tui + rm -rf /home/sprite/.local/share/cmux-sprite/remote /tmp/cmux-tui-* + rm -f /.sprite/logs/services/cmux-tui.log + install -d -m 700 /home/sprite/.local/share/cmux-sprite + test ! -e /home/sprite/.local/share/cmux-sprite/remote +" + +checkpoint_output="$( + sprite checkpoint create -o "$org" -s "$name" \ + --comment "cmux ${cmux_version} clean base; no daemon identity, invitations, or credentials" +)" +printf '%s\n' "$checkpoint_output" +checkpoint="$( + printf '%s\n' "$checkpoint_output" | + awk '/Checkpoint v[0-9]+ created/ { for (i = 1; i <= NF; i++) if ($i ~ /^v[0-9]+$/) print $i }' | + tail -1 +)" +if [[ -z "$checkpoint" ]]; then + echo "checkpoint creation returned no checkpoint id" >&2 + exit 1 +fi + +if [[ "$keep_running" -eq 1 ]]; then + sprite exec -o "$org" -s "$name" -- sprite-env services start cmux-tui +fi + +trap - EXIT +printf '\nSPRITE_BASE_ORG=%s\n' "$org" +printf 'SPRITE_BASE_NAME=%s\n' "$name" +printf 'SPRITE_BASE_CHECKPOINT=%s\n' "$checkpoint" +printf 'SPRITE_BASE_CMUX_VERSION=%s\n' "$cmux_version" +printf 'SPRITE_BASE_DAEMON_STATE=absent\n' +printf 'SPRITE_BASE_SERVICE=%s\n' "$([[ "$keep_running" -eq 1 ]] && echo running || echo stopped)"