diff --git a/docs/cloud-cmux-tui-daemon.md b/docs/cloud-cmux-tui-daemon.md new file mode 100644 index 000000000000..58baaecd8644 --- /dev/null +++ b/docs/cloud-cmux-tui-daemon.md @@ -0,0 +1,199 @@ +# Cloud VMs on the cmux-tui remote daemon + +Design for replacing the Go `cmuxd-remote` daemon in Cloud VMs with the +cmux-tui remote daemon, validated by a working transport spike +(`scripts/spike-cmux-tui-blaxel.sh`). North star: every cloud terminal is a +cmux-tui terminal, the macOS app renders it through the Ghostty manual-IO +surface, and any cmux-tui terminal (cloud, ssh, local) can be attached by +dragging it out of the right pane. + +## Why replace cmuxd-remote + +`daemon/remote/cmd/cmuxd-remote` speaks an ad-hoc protocol on `/terminal`: a +JSON auth frame, then raw PTY bytes, with reattach implemented as a raw-byte +scrollback replay (1 MiB cap) that can begin mid-escape-sequence and corrupt +the client grid. Auth is a lease file the web tier writes into the VM before +every attach. When the daemon restarts, `pty.attach` with +`require_existing=false` silently respawns a fresh shell, which users read as +losing their session. Each provider driver carries its own copy of the +injection and repair logic. + +The cmux-tui stack already solves each of these on `main`: + +- `cmux-remote` (Rust library, embedded in the single `cmux-tui` binary) runs + an authenticated daemon over versioned binary frames (`CMXR`, protocol 5, + 48 KiB frames, four lanes with per-lane replay cursors). +- Transport auth is an end-to-end Noise session against enrolled device keys, + not a bearer token on the socket. The direct-WebSocket listener serves one + route, `/v1/link`, and rejects any upgrade carrying an `Origin` header. +- Reattach is structured: a dropped carrier resumes within the daemon's + resume lease (default 120 s) by replaying reliable lanes from the client's + cursors; beyond the lease, clients resynchronize from terminal snapshots + (ghostty-vt state: styled rows, cursor, colors, `through_sequence`), never + from raw byte replay. A daemon restart changes the daemon generation and is + reported to the client instead of silently handing it a new shell. +- The daemon and the interactive client are the same binary, and the release + lane (`.github/workflows/cmux-tui-build-package.yml`) already produces the + needed artifact: a static `x86_64-unknown-linux-musl` build. + +## What the spike proved (2026-08-26) + +All steps are automated in `scripts/spike-cmux-tui-blaxel.sh` and were run +against a live Blaxel sandbox: + +1. A static musl `cmux-tui` (55 MB stripped, built on a Blacksmith testbox in + 1m47s warm) runs unmodified in a `blaxel/base-image` microVM. +2. Injection works through the same channel `blaxel.ts` uses for + `cmuxd-remote`: gzip+base64 through the sandbox filesystem API, then a + decode exec. The encoded payload (~30 MB) exceeds the API body cap, so the + script uploads 8 MB chunks and concatenates in the VM. +3. `cmux-tui server start --session cloud --remote-ws 0.0.0.0:1337 + --remote-ws-insecure-bind` under the sandbox process supervisor + (`keepAlive`, `restartOnFailure`) serves `/v1/link` behind Blaxel's TLS. +4. The single exposed HTTPS port works as-is: a private preview for port 1337 + plus a preview token passed as `?bl_preview_token=...`. The Blaxel gateway + accepts the token as a query parameter, and the Rust dialer + (`DirectWebSocketProvider`, plain `tokio-tungstenite` connect) passes the + URL through verbatim, so no header-injection change was needed. Requests + without the token get 401 from the gateway; requests with it reach the + daemon. +5. Enrollment over that URL: invitation created in the VM, `remote connect + --invite-file` from the Mac, approval in the VM, device enrolled. +6. Reconnect with state restored via the snapshot path: spawn a PTY bash over + workspace RPC, echo a marker, SIGKILL the client, connect fresh, and + `snapshot-process-terminal` returns the full styled grid with both the + pre-kill and post-reconnect markers and an advanced `through_sequence`. + The interactive TUI (`remote connect`) was also driven over the same URL. + +An Aug-20 client binary interoperated with a daemon built from `main` tip, +consistent with the protocol-version gate doing its job (both protocol 5). + +## Local repro without Blaxel credentials + +`scripts/spike-cmux-tui-local.sh` runs the same protocol loop with a local +`server start --remote-ws 127.0.0.1:` process standing in for the VM: +`up` (isolated daemon state, enrollment, approval), `evidence` (spawn PTY +bash over workspace RPC, write a marker, SIGKILL the client link, connect +fresh, assert the new connection's snapshot still carries the pre-kill marker +with an advanced `through_sequence`), `attach` (interactive remote TUI), +`down`. Evidence is self-checking and was verified against a debug build +(2026-08-26, `through_sequence 4 -> 7` across the kill). + +One semantic both spike scripts encode: RPC-spawned processes must use +`lifetime: "detached"`. A `workspace`-lifetime process is tied to the +client's workspace lease and is killed when that client's connection drops, +which is exactly the drop the spike (and any cloud client) must survive. +Cloud-owned terminals live in the daemon's cmux-tui session (or detached), +never on a connection-scoped lease. + +## Per-provider replacement + +One artifact replaces `cmuxd-remote-linux-amd64` everywhere: +`cmux-tui-x86_64-unknown-linux-musl` from the existing package lane, pinned by +sha256 exactly as `CMUX_VM_BLAXEL_DAEMON_URL`/`_SHA256` pin the Go binary +today. The per-provider delivery mechanisms stay what they are: + +| provider | today | change | +| --- | --- | --- | +| blaxel | gzip+base64 runtime injection at create | same path, chunked upload or URL fetch (binary is ~4x larger); start `server start --remote-ws` instead of `serve --ws` | +| e2b | baked into template by `web/scripts/build-cloud-vm-images.ts` | swap the copied binary and start command | +| daytona | baked into snapshot, entrypoint restarts it | same swap; the driver's repair exec restarts `server start` | +| freestyle | systemd unit in the VM snapshot | same swap in the unit file | + +The daemon's remote state dir must live on the persistent volume (`/root` on +blaxel, hence the default `/root/.local/state/cmux/remote` already qualifies) +so daemon identity and enrolled devices survive sandbox resurrection. Session +state (`--state`) lives there too, so workspace layout restores from the +journal checkpoint after a daemon restart; running processes do not survive a +restart, and clients see the generation change instead of a silent new shell. + +## Lease/auth integration with the attach-endpoint flow + +`POST /api/vm/[id]/attach-endpoint` today returns +`{transport:"websocket", url, headers, token, session_id, ...}` where `token` +is a single-use lease the web tier wrote into the VM. With the cmux-tui +daemon the endpoint returns `{transport:"cmux-remote", route, invitation?}`: + +- `route` is the tokenized preview URL + (`wss:///v1/link?bl_preview_token=`). The preview + token keeps its current minting and TTLs (12 h attach, 7 d open-port) and + its current role: it gates who can reach the listener at all. It is not the + session auth. Invitation route hints must be credential-free + (`credential_free_route_hints` rejects them), so the tokenized URL travels + only in the endpoint response, never inside an invitation. +- `invitation` is present only when this client device is not yet enrolled + with this VM's daemon. The endpoint execs `remote enroll create --ttl 300` + in the VM (exactly where it writes lease files today) and returns the + single-use `cmux://enroll/...` URI. The control plane then approves the + pending enrollment it just invited: poll `remote enroll pending` and + approve the matching `invitation_id`, which is what the spike script does. + A follow-up in cmux-remote makes this a non-racy single step: an + owner-created invitation with approval pre-granted (`approval_required` is + currently hardcoded `true` in `identity.rs`; the cloud control plane is the + owner, so pre-approval is the honest encoding of "the web tier already + authenticated this user"). +- After first enrollment the device key lives in the Mac's client state and + reattach needs only the fresh route. Revocation maps to the existing + ledger: revoking an attach revokes the device (`remote enroll revoke`) and + the preview token. + +Per-VM daemon identity plus per-user device keys give cloud attach the same +model as every other cmux-tui remote (ssh, iroh, relay), which is what makes +the right-pane drag UX (below) uniform. + +## macOS integration: manual IO instead of a PTY bridge + +Today the app bridges `/terminal` into a local PTY by spawning `cmux +vm-pty-connect` as the surface command. The replacement renders remote bytes +directly: the Ghostty manual-IO surface mode +(`GHOSTTY_SURFACE_IO_MANUAL`, `ghostty_surface_process_output`, +`TerminalManualIOWrite.swift`, all on `main` via the ghostty fork) lets the +app feed terminal bytes and receive keyboard/mouse writes without any local +shell. + +The `feat-tui-manual-io` branch already implements the pump for the local +daemon case: `cmux attach --terminal --pipe-io` (a renderer-less relay: +stdout carries VT bytes with a full-reset prefix on non-first replays, stdin +takes JSON `{"input"}`/`{"resize"}` lines, exit codes distinguish +terminal-ended from daemon-lost) driven by `TuiManualIOPump.swift` feeding +`TerminalRemoteOutputFeed`. Cloud reuses that contract unchanged: `cmux-tui +remote connect --headless` maintains the authenticated link (with its +own unlimited-attempt reconnect, heartbeats, lane replay, and snapshot +resync) and exposes the standard local control socket; the pump's `attach +--pipe-io` targets that socket. The app never re-implements the remote +protocol, and `cmux-terminal-client` (today iroh-only, C-ABI) can later +subsume the sidecar by adding `ws`/`wss` to its accepted schemes; the +provider machinery it needs is already shared in `cmux-remote`. + +## Drag-from-right-pane UX + +The right pane gains a "terminals" catalog: for each known daemon +(`remote known-daemons`: the local session, ssh remotes, every cloud VM the +attach endpoint enrolled) it lists live terminals from the daemon's catalog +(`cmux terminal list` over the same authenticated link the pump uses). +Dragging an entry into the split tree creates a manual-IO surface bound to +that terminal: the drag payload is a declared UTType carrying +`(daemon fingerprint, route, terminal id)`; the drop handler ensures a +headless link to that daemon exists, then starts a pump on `attach +--terminal --pipe-io`. Because the payload names a daemon and terminal +rather than a VM, the same drag works for a cloud VM, an ssh box, and another +local cmux-tui session; "arbitrary cmux TUI terminals" falls out of the +shared catalog rather than a cloud-specific feature. Multi-attach is safe: +daemon-side terminals accept multiple attachments and size to the minimum +grid, matching current cmuxd-remote semantics. + +## Rollout + +Phase 1: ship the cmux-tui daemon alongside cmuxd-remote (second port, +blaxel first since it needs no image rebake), attach-endpoint returns both +transports, macOS opts in behind a feature flag. Phase 2: default new +attaches to `cmux-remote`, keep `websocket` as fallback for one release. +Phase 3: delete the Go daemon path per provider, then the `daemon/remote` +tree. Each phase is revertible by flipping the transport default; the two +daemons share nothing in the VM but the process supervisor. + +Open items, in order: pre-approved invitations in `cmux-remote`; wire the +attach endpoint (`web/services/vms/drivers/*.ts`) to inject and start the new +daemon; land `feat-tui-manual-io`'s pump against a `remote connect +--headless` socket; the right-pane catalog. The spike deliberately excludes +all four. diff --git a/scripts/spike-cmux-tui-blaxel.sh b/scripts/spike-cmux-tui-blaxel.sh new file mode 100755 index 000000000000..534325c2b31b --- /dev/null +++ b/scripts/spike-cmux-tui-blaxel.sh @@ -0,0 +1,214 @@ +#!/usr/bin/env bash +# Transport spike: run the cmux-tui remote daemon inside a Blaxel sandbox and +# attach to it from this machine over the sandbox's private preview WSS URL. +# +# This is the cmux-tui replacement for the cmuxd-remote injection path in +# web/services/vms/drivers/blaxel.ts, driven end to end with curl so the +# mechanics are visible. See docs/cloud-cmux-tui-daemon.md for the design. +# +# scripts/spike-cmux-tui-blaxel.sh up --name --binary +# scripts/spike-cmux-tui-blaxel.sh evidence --name +# scripts/spike-cmux-tui-blaxel.sh attach --name +# scripts/spike-cmux-tui-blaxel.sh destroy --name +# +# Requires: BL_API_KEY and BL_WORKSPACE in the environment (or ~/.secrets/blaxel.env), +# python3, curl, and a local cmux-tui client binary (CMUX_TUI_CLIENT, default +# `cmux-tui` on PATH) whose REMOTE_PROTOCOL_VERSION matches the injected binary. +# +# `up` creates the sandbox, injects the static x86_64-musl cmux-tui in base64 +# chunks (the sandbox filesystem API caps request bodies well below the ~30 MB +# encoded binary), starts `server start --remote-ws` under the sandbox process +# supervisor, mints a private preview + token for the listener port, creates a +# single-use enrollment invitation in the VM, enrolls this machine, and +# approves the enrollment from the VM side. State lands in +# ~/.cache/cmux-tui-blaxel-spike//. +set -euo pipefail + +CONTROL=https://api.blaxel.ai/v0 +PORT=1337 +SESSION=cloud +REMOTE_BIN=/usr/local/bin/cmux-tui + +cmd="${1:-}"; shift || true +NAME="" BINARY="" +while (( $# )); do + case "$1" in + --name) shift; NAME="${1:?--name needs a value}" ;; + --binary) shift; BINARY="${1:?--binary needs a value}" ;; + *) echo "unknown option: $1" >&2; exit 64 ;; + esac + shift +done +[[ -n "$NAME" ]] || { echo "--name is required" >&2; exit 64; } +[[ "$NAME" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || { echo "--name must be a single path component" >&2; exit 64; } +case "$cmd" in up|evidence|attach|destroy) ;; *) sed -n '3,12p' "$0"; exit 64 ;; esac + +if [[ -z "${BL_API_KEY:-}" && -r "$HOME/.secrets/blaxel.env" ]]; then + # shellcheck disable=SC1091 + source "$HOME/.secrets/blaxel.env" +fi +: "${BL_API_KEY:?set BL_API_KEY}" "${BL_WORKSPACE:?set BL_WORKSPACE}" +CLIENT="${CMUX_TUI_CLIENT:-cmux-tui}" + +STATE_ROOT="$HOME/.cache/cmux-tui-blaxel-spike/$NAME" +mkdir -p "$STATE_ROOT" +chmod 700 "$STATE_ROOT" +CLIENT_STATE="$STATE_ROOT/client-state" + +api() { # method path [json-body-file] + local method="$1" url="$2" body="${3:-}" + if [[ -n "$body" ]]; then + curl -fsS -X "$method" "$url" \ + -H "X-Blaxel-Authorization: Bearer $BL_API_KEY" \ + -H "X-Blaxel-Workspace: $BL_WORKSPACE" \ + -H 'Content-Type: application/json' --data-binary "@$body" + else + curl -fsS -X "$method" "$url" \ + -H "X-Blaxel-Authorization: Bearer $BL_API_KEY" \ + -H "X-Blaxel-Workspace: $BL_WORKSPACE" + fi +} + +json() { python3 -c "import json,sys; d=json.load(sys.stdin); print(eval(sys.argv[1]))" "$1"; } + +sandbox_url() { api GET "$CONTROL/sandboxes/$NAME" | json 'd["metadata"]["url"]'; } + +sbx_exec() { # command [timeout-seconds] + local command="$1" timeout="${2:-60}" req="$STATE_ROOT/exec.json" + python3 -c 'import json,sys; print(json.dumps({"command":sys.argv[1],"waitForCompletion":True,"timeout":int(sys.argv[2])}))' \ + "$command" "$timeout" > "$req" + api POST "$(cat "$STATE_ROOT/sandbox-url")/process" "$req" +} + +sbx_exec_ok() { # command [timeout] -> stdout; fails on nonzero exit + local out + out="$(sbx_exec "$@")" + python3 - "$out" <<'PY' +import json, sys +d = json.loads(sys.argv[1]) +code = d.get("exitCode") or 0 +sys.stdout.write(d.get("stdout") or "") +if code != 0: + sys.stderr.write(d.get("stderr") or "") + sys.exit(code) +PY +} + +route() { + local preview token + preview="$(cat "$STATE_ROOT/preview-url")" + token="$(cat "$STATE_ROOT/preview-token")" + printf 'wss://%s/v1/link?bl_preview_token=%s' "${preview#https://}" "$token" +} + +case "$cmd" in +up) + [[ -n "$BINARY" && -r "$BINARY" ]] || { echo "--binary must point at a readable x86_64-musl cmux-tui (optionally .gz)" >&2; exit 64; } + + echo "==> create sandbox $NAME" + python3 -c 'import json,sys; print(json.dumps({"metadata":{"name":sys.argv[1]},"spec":{"runtime":{"image":"blaxel/base-image:latest","memory":4096,"ports":[{"name":"cmuxtui","protocol":"HTTP","target":int(sys.argv[2])}]}}}))' \ + "$NAME" "$PORT" > "$STATE_ROOT/create.json" + api POST "$CONTROL/sandboxes" "$STATE_ROOT/create.json" > /dev/null + sandbox_url > "$STATE_ROOT/sandbox-url" + echo " sandbox API: $(cat "$STATE_ROOT/sandbox-url")" + + echo "==> inject cmux-tui (gzip+base64, chunked)" + if [[ "$BINARY" == *.gz ]]; then + base64 -i "$BINARY" | tr -d '\n' > "$STATE_ROOT/binary.b64" + else + gzip -9 -c "$BINARY" | base64 | tr -d '\n' > "$STATE_ROOT/binary.b64" + fi + rm -f "$STATE_ROOT"/chunk-* + split -b 8000000 "$STATE_ROOT/binary.b64" "$STATE_ROOT/chunk-" + parts=() + for chunk in "$STATE_ROOT"/chunk-*; do + part="$(basename "$chunk")" + parts+=("/tmp/tui.$part.b64") + python3 -c 'import json,sys; print(json.dumps({"content":open(sys.argv[1]).read(),"permissions":"0600"}))' "$chunk" > "$chunk.json" + api PUT "$(cat "$STATE_ROOT/sandbox-url")/filesystem//tmp/tui.$part.b64" "$chunk.json" > /dev/null + echo " uploaded $part" + done + sbx_exec_ok "cat ${parts[*]} | base64 -d | gunzip > $REMOTE_BIN && chmod 755 $REMOTE_BIN && rm ${parts[*]} && $REMOTE_BIN --version" 120 + + echo "==> start remote daemon (session $SESSION, ws :$PORT)" + python3 -c 'import json,sys; print(json.dumps({"name":"cmux-tui-daemon","command":"env HOME=/root TERM=xterm-256color "+sys.argv[1]+" server start --session "+sys.argv[2]+" --remote-ws 0.0.0.0:"+sys.argv[3]+" --remote-ws-insecure-bind","waitForCompletion":False,"keepAlive":True,"restartOnFailure":True,"maxRestarts":10}))' \ + "$REMOTE_BIN" "$SESSION" "$PORT" > "$STATE_ROOT/daemon.json" + api POST "$(cat "$STATE_ROOT/sandbox-url")/process" "$STATE_ROOT/daemon.json" > /dev/null + sbx_exec_ok "sleep 2; env HOME=/root $REMOTE_BIN server status --session $SESSION" 30 + + echo "==> private preview + token for :$PORT" + python3 -c "import json; print(json.dumps({'metadata':{'name':'cmuxtui'},'spec':{'port':$PORT,'public':False}}))" > "$STATE_ROOT/preview.json" + api POST "$CONTROL/sandboxes/$NAME/previews" "$STATE_ROOT/preview.json" | json 'd["spec"]["url"]' > "$STATE_ROOT/preview-url" + python3 -c 'import json,datetime; print(json.dumps({"spec":{"expiresAt":(datetime.datetime.now(datetime.timezone.utc)+datetime.timedelta(hours=12)).strftime("%Y-%m-%dT%H:%M:%SZ")}}))' > "$STATE_ROOT/token.json" + api POST "$CONTROL/sandboxes/$NAME/previews/cmuxtui/tokens" "$STATE_ROOT/token.json" | json 'd["spec"]["token"]' > "$STATE_ROOT/preview-token" + chmod 600 "$STATE_ROOT/preview-token" + echo " $(cat "$STATE_ROOT/preview-url")" + + echo "==> enroll this machine" + sbx_exec_ok "env HOME=/root $REMOTE_BIN remote enroll create --session $SESSION --ttl 300 --json" 30 \ + | json 'd["uri"]' > "$STATE_ROOT/invitation" + chmod 600 "$STATE_ROOT/invitation" + CMUX_REMOTE_STATE_DIR="$CLIENT_STATE" "$CLIENT" remote connect "$(route)" \ + --invite-file "$STATE_ROOT/invitation" --device-name "spike-$(hostname -s)" --headless \ + > "$STATE_ROOT/connect.log" 2>&1 & + echo $! > "$STATE_ROOT/connect.pid" + for _ in $(seq 1 30); do + pending="$(sbx_exec_ok "env HOME=/root $REMOTE_BIN remote enroll pending --session $SESSION --json" 30)" + invitation_id="$(printf '%s' "$pending" | python3 -c 'import json,sys; p=json.load(sys.stdin); print(p[0]["invitation_id"] if p else "")')" + if [[ -n "$invitation_id" ]]; then + sbx_exec_ok "env HOME=/root $REMOTE_BIN remote enroll approve $invitation_id --session $SESSION --json" 30 > /dev/null + echo " approved enrollment $invitation_id" + break + fi + sleep 2 + done + [[ -n "${invitation_id:-}" ]] || { echo "enrollment was never requested; see $STATE_ROOT/connect.log" >&2; exit 1; } + + echo "==> done. attach interactively with:" + echo " $0 attach --name $NAME" + ;; + +evidence) + ROUTE="$(route)" + rpc() { CMUX_REMOTE_STATE_DIR="$CLIENT_STATE" "$CLIENT" remote rpc "$ROUTE"; } + echo "==> spawn PTY bash in a remote workspace" + ws="$(printf '%s\n' '{"type":"open-workspace","root":"/root"}' | rpc | json 'd["id"]')" + proc="$(python3 -c 'import json,sys; print(json.dumps({"type":"spawn-process","workspace":sys.argv[1],"argv":["bash"],"cwd":None,"env":{},"io":{"type":"pty","cols":100,"rows":30,"term":"xterm-256color","eof":"control-d"},"lifetime":"detached"}))' "$ws" | rpc | json 'd["process"]')" + echo " process $proc" + + echo "==> write marker, snapshot" + data="$(printf 'echo SPIKE-MARKER-42 $(uname -m)\n' | base64)" + printf '%s\n' "{\"type\":\"write-process\",\"process\":\"$proc\",\"write_id\":1,\"data\":\"$data\",\"eof\":false}" | rpc > /dev/null + sleep 1 + printf '%s\n' "{\"type\":\"snapshot-process-terminal\",\"process\":\"$proc\"}" | rpc > "$STATE_ROOT/snapshot-before.json" + + echo "==> SIGKILL the enrollment-era client link, connect fresh, write again, snapshot" + [[ -s "$STATE_ROOT/connect.pid" ]] && kill -9 "$(cat "$STATE_ROOT/connect.pid")" 2>/dev/null || true + data2="$(printf 'echo RECONNECTED-AFTER-DROP\n' | base64)" + { printf '%s\n' "{\"type\":\"write-process\",\"process\":\"$proc\",\"write_id\":2,\"data\":\"$data2\",\"eof\":false}"; sleep 1; \ + printf '%s\n' "{\"type\":\"snapshot-process-terminal\",\"process\":\"$proc\"}"; } | rpc > "$STATE_ROOT/snapshot-after.json" + python3 - "$STATE_ROOT/snapshot-after.json" <<'PY' +import json, sys +for line in open(sys.argv[1]): + d = json.loads(line) + if d["type"] != "process-terminal-snapshot": + continue + s = d["snapshot"] + print(f'through_sequence={s["through_sequence"]}') + for row in s["rows"]: + text = "".join(run.get("text", "") for run in (row.get("runs") or [])) + if text.strip(): + print(repr(text.rstrip())) +PY + echo " snapshots in $STATE_ROOT/snapshot-{before,after}.json" + ;; + +attach) + exec env CMUX_REMOTE_STATE_DIR="$CLIENT_STATE" "$CLIENT" remote connect "$(route)" + ;; + +destroy) + [[ -s "$STATE_ROOT/connect.pid" ]] && kill "$(cat "$STATE_ROOT/connect.pid")" 2>/dev/null || true + api DELETE "$CONTROL/sandboxes/$NAME" > /dev/null && echo "deleted sandbox $NAME" + ;; +esac diff --git a/scripts/spike-cmux-tui-local.sh b/scripts/spike-cmux-tui-local.sh new file mode 100755 index 000000000000..77d6a34a3916 --- /dev/null +++ b/scripts/spike-cmux-tui-local.sh @@ -0,0 +1,155 @@ +#!/usr/bin/env bash +# Local transport spike: the cmux-tui remote daemon and a client on one +# machine, no sandbox. Same protocol path as scripts/spike-cmux-tui-blaxel.sh +# (Noise over /v1/link, enrollment, snapshot resync); a local process stands +# in for the cloud VM so anyone can run the loop without Blaxel credentials. +# See docs/cloud-cmux-tui-daemon.md. +# +# scripts/spike-cmux-tui-local.sh up --name [--binary ] +# scripts/spike-cmux-tui-local.sh evidence --name +# scripts/spike-cmux-tui-local.sh attach --name # interactive remote TUI +# scripts/spike-cmux-tui-local.sh down --name +# +# `up` starts a headless `server start --remote-ws` daemon on a free loopback +# port with isolated state under ~/.cache/cmux-tui-local-spike//, enrolls +# this machine as a client device, and approves the enrollment daemon-side. +# `evidence` is the self-checking reconnect proof: spawn a PTY bash over +# workspace RPC, write a marker, snapshot, SIGKILL the client link, connect +# fresh, and assert the new connection's snapshot still carries the pre-kill +# marker with an advanced through_sequence (structured restore, not raw +# replay). Default binary: cmux-tui/target/debug/cmux-tui next to this repo +# (`cargo build -p cmux-tui` in cmux-tui/). +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" + +cmd="${1:-}"; shift || true +NAME="" BINARY="" +while (( $# )); do + case "$1" in + --name) shift; NAME="${1:?--name needs a value}" ;; + --binary) shift; BINARY="${1:?--binary needs a value}" ;; + *) echo "unknown option: $1" >&2; exit 64 ;; + esac + shift +done +[[ -n "$NAME" ]] || { echo "--name is required" >&2; exit 64; } +[[ "$NAME" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || { echo "--name must be a single path component" >&2; exit 64; } +case "$cmd" in up|evidence|attach|down) ;; *) sed -n '7,12p' "$0"; exit 64 ;; esac + +BIN="${BINARY:-${CMUX_TUI_BIN:-$REPO_ROOT/cmux-tui/target/debug/cmux-tui}}" +[[ -x "$BIN" ]] || { echo "cmux-tui binary not found at $BIN (cargo build -p cmux-tui)" >&2; exit 66; } + +SESSION="spike-$NAME" +ROOT="$HOME/.cache/cmux-tui-local-spike/$NAME" +CLIENT_STATE="$ROOT/client-state" +VM_STATE="$ROOT/vm" + +route() { printf 'ws://127.0.0.1:%s/v1/link' "$(cat "$ROOT/port")"; } +vm_enroll() { # action [args...] -> runs against the daemon's admin socket + local action="$1"; shift + "$BIN" remote enroll "$action" "$@" --session "$SESSION" --state-dir "$VM_STATE/remote-state" --json +} +rpc() { CMUX_REMOTE_STATE_DIR="$CLIENT_STATE" "$BIN" remote rpc "$(route)"; } + +case "$cmd" in +up) + mkdir -p "$ROOT" "$VM_STATE" "$ROOT/work" + chmod 700 "$ROOT" + + PORT="$(python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1",0)); print(s.getsockname()[1]); s.close()')" + echo "$PORT" > "$ROOT/port" + + echo "==> start remote daemon (session $SESSION, ws 127.0.0.1:$PORT)" + "$BIN" server start --session "$SESSION" --headless \ + --state "$VM_STATE/session-state" --socket "$VM_STATE/mux.sock" \ + --remote-ws "127.0.0.1:$PORT" --remote-state-dir "$VM_STATE/remote-state" \ + > "$ROOT/daemon.log" 2>&1 & + echo $! > "$ROOT/daemon.pid" + for _ in $(seq 1 50); do + python3 -c 'import socket,sys; s=socket.socket(); s.settimeout(0.2); s.connect(("127.0.0.1",int(sys.argv[1]))); s.close()' "$PORT" 2>/dev/null && break + kill -0 "$(cat "$ROOT/daemon.pid")" 2>/dev/null || { echo "daemon exited; see $ROOT/daemon.log" >&2; exit 1; } + sleep 0.2 + done + + echo "==> enroll this machine" + vm_enroll create --ttl 300 \ + | python3 -c 'import json,sys; print(json.load(sys.stdin)["uri"])' > "$ROOT/invitation" + chmod 600 "$ROOT/invitation" + CMUX_REMOTE_STATE_DIR="$CLIENT_STATE" "$BIN" remote connect "$(route)" \ + --invite-file "$ROOT/invitation" --device-name "spike-local-$NAME" --headless \ + > "$ROOT/connect.log" 2>&1 & + echo $! > "$ROOT/connect.pid" + for _ in $(seq 1 30); do + invitation_id="$(vm_enroll pending | python3 -c 'import json,sys; p=json.load(sys.stdin); print(p[0]["invitation_id"] if p else "")')" + if [[ -n "$invitation_id" ]]; then + vm_enroll approve "$invitation_id" > /dev/null + echo " approved enrollment $invitation_id" + break + fi + sleep 1 + done + [[ -n "${invitation_id:-}" ]] || { echo "enrollment was never requested; see $ROOT/connect.log" >&2; exit 1; } + + echo "==> up. next: $0 evidence --name $NAME" + ;; + +evidence) + echo "==> spawn PTY bash in a remote workspace, write marker, snapshot" + ws="$(printf '%s\n' "{\"type\":\"open-workspace\",\"root\":\"$ROOT/work\"}" | rpc \ + | python3 -c 'import json,sys; print(json.loads(sys.stdin.readline())["id"])')" + proc="$(python3 -c 'import json,sys; print(json.dumps({"type":"spawn-process","workspace":sys.argv[1],"argv":["bash"],"cwd":None,"env":{},"io":{"type":"pty","cols":100,"rows":30,"term":"xterm-256color","eof":"control-d"},"lifetime":"detached"}))' "$ws" \ + | rpc | python3 -c 'import json,sys; print(json.loads(sys.stdin.readline())["process"])')" + echo " workspace $ws process $proc" + data="$(printf 'echo SPIKE-BEFORE-KILL\n' | base64)" + { printf '%s\n' "{\"type\":\"write-process\",\"process\":\"$proc\",\"write_id\":1,\"data\":\"$data\",\"eof\":false}"; sleep 1; \ + printf '%s\n' "{\"type\":\"snapshot-process-terminal\",\"process\":\"$proc\"}"; } | rpc > "$ROOT/snapshot-before.json" + + echo "==> SIGKILL the client link, reconnect fresh, write again, snapshot" + kill -9 "$(cat "$ROOT/connect.pid")" 2>/dev/null || true + data2="$(printf 'echo SPIKE-AFTER-RECONNECT\n' | base64)" + { printf '%s\n' "{\"type\":\"write-process\",\"process\":\"$proc\",\"write_id\":2,\"data\":\"$data2\",\"eof\":false}"; sleep 1; \ + printf '%s\n' "{\"type\":\"snapshot-process-terminal\",\"process\":\"$proc\"}"; } | rpc > "$ROOT/snapshot-after.json" + + python3 - "$ROOT/snapshot-before.json" "$ROOT/snapshot-after.json" <<'PY' +import json, sys + +def snapshot(path): + for line in open(path): + d = json.loads(line) + if d["type"] == "process-terminal-snapshot": + return d["snapshot"] + sys.exit(f"no snapshot frame in {path}") + +def text(s): + return "\n".join( + "".join(run.get("text", "") for run in (row.get("runs") or [])) + for row in s["rows"] + ) + +before, after = snapshot(sys.argv[1]), snapshot(sys.argv[2]) +restored = text(after) +for line in restored.splitlines(): + if line.strip(): + print(f" {line.rstrip()}") +assert "SPIKE-BEFORE-KILL" in restored, "pre-kill marker missing after reconnect" +assert "SPIKE-AFTER-RECONNECT" in restored, "post-reconnect write missing" +assert after["through_sequence"] > before["through_sequence"], "through_sequence did not advance" +print(f"PASS: snapshot restored across kill (through_sequence " + f"{before['through_sequence']} -> {after['through_sequence']})") +PY + echo " snapshots in $ROOT/snapshot-{before,after}.json" + ;; + +attach) + exec env CMUX_REMOTE_STATE_DIR="$CLIENT_STATE" "$BIN" remote connect "$(route)" + ;; + +down) + for pid in connect daemon; do + [[ -s "$ROOT/$pid.pid" ]] && kill "$(cat "$ROOT/$pid.pid")" 2>/dev/null || true + done + rm -rf "$ROOT" + echo "removed $ROOT" + ;; +esac