14 KiB
14 KiB
Remote SSH Living Spec
Last updated: March 12, 2026 Tracking issue: https://github.com/manaflow-ai/cmux/issues/151 Primary PR: https://github.com/manaflow-ai/cmux/pull/239 CLI relay PR: https://github.com/manaflow-ai/cmux/pull/374
This document is the working source of truth for:
- what is implemented now
- what is intentionally temporary
- what must be built next
1. Document Type
This is a living implementation spec (also called an execution spec): a spec-level document with status tracking (DONE, IN PROGRESS, TODO) and acceptance tests.
2. Objective
cmux ssh should provide:
- durable remote terminals with reconnect/reuse
- browser traffic that egresses from the remote host via proxying
- tmux-style PTY resize semantics (
smallest screen wins)
3. Current State (Implemented)
3.1 Remote Workspace + Reconnect UX
DONEcmux sshcreates remote-tagged workspaces and does not require--name.DONEscoped shell niceties are applied only forcmux sshlaunches.DONEcontext menu actions exist for remote workspaces (Reconnect Workspace(s),Disconnect Workspace(s)).DONEsocket API includesworkspace.remote.reconnect.
3.2 Bootstrap + Daemon
DONElocal app probes remote platform, verifies a release-pinnedcmuxd-remoteartifact by embedded manifest SHA-256, uploads it when missing, and runsserve --stdio.DONEdaemonhellohandshake is enforced.DONEdaemon now exposes proxy stream RPC (proxy.open,proxy.close,proxy.write,proxy.read).DONElocal proxy broker now tunnels SOCKS5/CONNECT traffic over daemon stream RPC instead ofssh -D.DONEdaemon now exposes session resize-coordinator RPC (session.open,session.attach,session.resize,session.detach,session.status,session.close).DONEtransport-level proxy failures now escalate from broker retry to full daemon re-bootstrap/reconnect in the session controller.DONESOCKS handshake parsing now preserves pipelined post-connect payload bytes instead of dropping request-prefix bytes.DONEworkspace.remote.configure.local_proxy_portexists as an internal deterministic test hook for bind-conflict regression coverage.DONEbootstrap/probe failures surface actionable details.DONEbootstrap installs~/.cmux/bin/cmuxwrapper (also tries/usr/local/bin/cmux) socmuxis available in PATH on the remote.
3.5 CLI Relay (Running cmux Commands From Remote)
DONEcmuxd-remoteincludes a table-driven CLI relay (clisubcommand) that maps CLI args to v1 text or v2 JSON-RPC messages.DONEbusybox-style argv[0] detection: when invoked ascmuxvia wrapper/symlink, auto-dispatches to CLI relay.DONEbackgroundssh -N -R 127.0.0.1:PORT:127.0.0.1:LOCAL_RELAY_PORTprocess reverse-forwards a TCP port to a dedicated authenticated local relay server. Uses TCP instead of Unix socket forwarding because many servers haveAllowStreamLocalForwardingdisabled.DONErelay process usesControlPath=none(avoids ControlMaster multiplexing and inheritedRemoteForwarddirectives) andExitOnForwardFailure=no(inherited forwards from user ssh config failing should not kill the relay).DONErelay address written to~/.cmux/socket_addron the remote with a 3s delay after the relay process starts, giving SSH time to establish the-Rforward.DONEGo CLI re-reads~/.cmux/socket_addron each TCP retry to pick up updated relay ports when multiple workspaces overwrite the file.DONEcmux sshstartup exports session-localCMUX_SOCKET_PATH=127.0.0.1:<relay_port>so parallel sessions pin to their own relay instead of racing on shared socket_addr.DONErelay startup writes~/.cmux/relay/<relay_port>.daemon_path; remotecmuxwrapper uses this to select the right daemon binary per session, including mixed local cmux versions.DONErelay startup writes~/.cmux/relay/<relay_port>.authwith a relay ID and token; the local relay requires HMAC-SHA256 challenge-response before forwarding any command to the real local socket.DONEephemeral port range (49152-65535) filtered from probe results to exclude relay ports from other workspaces.DONEmulti-workspace port conflict detection uses TCP connect check (isLoopbackPortReachable) so ports already forwarded by another workspace are silently skipped instead of flagged as conflicts.DONEorphaned relay SSH processes from previous app sessions are cleaned up before starting a new relay.
3.6 Artifact Trust
DONErelease and nightly workflows publishcmuxd-remoteassets fordarwin/linux × arm64/amd64.DONErelease and nightly apps embed a compactCMUXRemoteDaemonManifestJSONinInfo.plistwith exact asset URLs and SHA-256 digests.DONEcmux remote-daemon-statusexposes the current manifest entry, local cache verification state, release download command, and GitHub attestation verification command.
3.3 Error Surfacing
DONEremote errors are surfaced in sidebar status + logs + notifications.DONEreconnect retry count/time is included in surfaced error text (for example,retry 1 in 4s).
3.4 Removed Temporary Behavior
DONEremoved remote listening-port probe loop and per-port SSH-Lmirroring.DONEremote browser routing now uses a single shared local proxy endpoint instead of detected-port mirroring.DONEremote status now includes structured proxy metadata (remote.proxy) andproxy_unavailableerror code when proxy setup fails.
4. Target Architecture (No Port Mirroring)
4.1 Browser Networking Path
DONEone local proxy endpoint is created per SSH transport/session key (not per detected port).DONEendpoint is provided by a local broker that supports SOCKS5 + HTTP CONNECT and tunnels via daemon stream RPC.DONEbrowser panels in remote workspaces are auto-wired to the workspace proxy endpoint.DONEbrowser panels in local workspaces are not force-proxied.DONEidentical SSH transports share one endpoint via a transport-scoped broker.
4.2 WKWebView Wiring
DONEuse workspace-scopedWKWebsiteDataStore(forIdentifier:).DONEapply workspace/browser scopedproxyConfigurations.DONEprefer SOCKS5 proxy config.DONEkeep HTTP CONNECT proxy config as fallback.DONEre-apply proxy config on reconnect/state updates.
4.3 Remote Daemon + Transport
DONEcmuxd-remotenow supports proxy stream RPC (proxy.open,proxy.close,proxy.write,proxy.read).DONElocal side now runs a shared local broker that serves SOCKS5/CONNECT and tunnels each stream over persistent daemon stdio RPC.DONEremoved remote service-port discovery/probing from browser routing path.
4.4 Explicit Non-Goal
- Automatic mirroring of every remote listening port to local loopback is not a goal for browser support.
5. PTY Resize Semantics (tmux-style)
5.1 Core Rule
For each session with multiple attachments, the effective PTY size is:
cols = min(cols_i over attached clients)rows = min(rows_i over attached clients)
This is the smallest screen wins rule.
5.2 State Model
Per session track:
- set of active attachments
{attachment_id -> cols, rows, updated_at} - effective size currently applied to PTY
- last-known size when temporarily unattached
5.3 Recompute Triggers
Recompute effective size on:
- attachment create
- attachment detach
- resize event from any attachment
- reconnect reattach
5.4 Correctness Requirements
- Never shrink history because of UI relayout noise; only PTY viewport changes.
- On reconnect, reuse persisted session and recompute from active attachments.
- If no attachments remain, keep last-known PTY size (do not force 80x24 reset).
6. Milestones (Living Status)
| ID | Milestone | Status | Notes |
|---|---|---|---|
| M-001 | cmux ssh workspace creation + metadata + optional --name |
DONE | Covered by tests_v2/test_ssh_remote_cli_metadata.py |
| M-002 | Remote bootstrap/upload/start + hello handshake | DONE | Includes daemon capability handshake + status surfacing |
| M-003 | Reconnect/disconnect UX + API + improved error surfacing | DONE | Includes retry count in surfaced errors |
| M-004 | Docker e2e for bootstrap/reconnect shell niceties | DONE | Docker suites validate proxy-path bootstrap and reconnect behavior |
| M-004b | CLI relay: run cmux commands from within SSH sessions | DONE | Reverse TCP forward + Go CLI relay + bootstrap wrapper |
| M-005 | Remove automatic remote port mirroring path | DONE | WorkspaceRemoteSessionController now uses one shared daemon-backed proxy endpoint |
| M-006 | Transport-scoped local proxy broker (SOCKS5 + CONNECT) | DONE | Identical SSH transports now reuse one local proxy endpoint |
| M-007 | Remote proxy stream RPC in cmuxd-remote |
DONE | proxy.open/close/write/read implemented |
| M-008 | WebView proxy auto-wiring for remote workspaces | DONE | Workspace-scoped WKWebsiteDataStore.proxyConfigurations wiring is active |
| M-009 | PTY resize coordinator (smallest screen wins) |
DONE | Daemon session RPC now tracks attachments and applies min cols/rows semantics with unit tests |
| M-010 | Resize + proxy reconnect e2e test suites | DONE | tests_v2/test_ssh_remote_docker_forwarding.py validates HTTP/websocket egress plus SOCKS pipelined-payload handling; tests_v2/test_ssh_remote_docker_reconnect.py verifies reconnect recovery and repeats SOCKS pipelined-payload checks after host restart; tests_v2/test_ssh_remote_proxy_bind_conflict.py validates structured proxy_unavailable bind-conflict surfacing and local_proxy_port status retention under bind conflict; tests_v2/test_ssh_remote_daemon_resize_stdio.py validates session resize semantics over real stdio RPC process boundaries; tests_v2/test_ssh_remote_cli_metadata.py validates workspace.remote.configure numeric-string compatibility, explicit null clear semantics (including workspace.remote.status reflection), strict port/local_proxy_port validation (bounds/type), case-insensitive SSH option override precedence for StrictHostKeyChecking/control-socket keys, and local_proxy_port payload echo for deterministic bind-conflict test hook behavior |
7. Acceptance Test Matrix (With Status)
7.1 Terminal + Reconnect
| ID | Scenario | Status |
|---|---|---|
| T-001 | baseline remote connect | DONE |
| T-002 | identical host reuse semantics | DONE |
| T-003 | no --name |
DONE |
| T-004 | reconnect API success/error paths | DONE |
| T-005 | retry count visible in daemon error detail | DONE |
7.2 CLI Relay
| ID | Scenario | Status |
|---|---|---|
| C-001 | cmux ping from remote session |
DONE |
| C-002 | cmux list-workspaces --json from remote |
DONE |
| C-003 | cmux new-workspace from remote |
DONE |
| C-004 | cmux rpc system.capabilities passthrough |
DONE |
| C-005 | TCP retry handles relay not yet established | DONE |
| C-006 | multi-workspace port conflict silent skip | DONE |
| C-007 | ephemeral port filtering excludes relay ports | DONE |
7.3 Browser Proxy (Target)
| ID | Scenario | Status |
|---|---|---|
| W-001 | remote workspace browser auto-proxied | DONE |
| W-002 | browser egress equals remote network path | DONE |
| W-003 | websocket via SOCKS5/CONNECT through remote daemon | DONE |
| W-004 | reconnect restores browser proxy path automatically | DONE |
| W-005 | local proxy bind conflict yields structured proxy_unavailable |
DONE |
| W-006 | proxy transport failure triggers daemon re-bootstrap and recovers after host recreation | DONE |
| W-007 | SOCKS greeting/connect + immediate pipelined payload in same write remains intact | DONE |
7.4 Resize
| ID | Scenario | Status |
|---|---|---|
| RZ-001 | two attachments, smallest wins | DONE |
| RZ-002 | grow one attachment, PTY stays bounded by smallest | DONE |
| RZ-003 | detach smallest, PTY expands to next smallest | DONE |
| RZ-004 | reconnect preserves session + applies recomputed size | DONE |
| RZ-005 | daemon stdio RPC round-trip enforces resize semantics end-to-end | DONE |
8. Removal Checklist (Port Mirroring)
Before declaring browser proxying complete:
DONEremove remote port probe loop and-Lauto-forward orchestrationDONEremove mirror-specific routing behavior as default remote behaviorDONEreplace mirroring docker assertions with proxy egress assertionsDONEkeep optional explicit user-driven forwarding out of this path; no automatic mirroring remains in browser routing
9. Open Decisions
- Proxy auth policy for local broker (
nonevs optional credentials). - Reconnect backoff profile and max retry budget.
10. Socket API Contract Notes
10.1 workspace.remote.configure Port Fields
portandlocal_proxy_portaccept integer values and numeric strings.- Explicit
nullclears each field. - Out-of-range values and invalid types (for example booleans/non-numeric strings/fractional numbers) return
invalid_params. local_proxy_portis an internal deterministic test hook to force local bind conflicts in regression coverage.
10.2 SSH Option Precedence
StrictHostKeyCheckingdefault (accept-new) is only injected when no user override is present.- Control-socket defaults (
ControlMaster,ControlPersist,ControlPath) are only injected when missing. - SSH option key matching is case-insensitive for precedence checks in both CLI-built commands and remote configure payloads.
10.3 SSH Docker E2E Harness Knobs
CMUX_SSH_TEST_DOCKER_HOSTsets the SSH destination host/IP used by docker-backed SSH fixtures (default127.0.0.1).CMUX_SSH_TEST_DOCKER_BIND_ADDRsets the bind address used in fixture container publish mappings (default127.0.0.1).- Defaults preserve loopback behavior on a single host; override both when docker runs on a different host (for example VM -> host OrbStack).