10 KiB
10 KiB
Remote SSH Living Spec
Last updated: February 21, 2026
Tracking issue: https://github.com/manaflow-ai/cmux/issues/151
Primary PR: https://github.com/manaflow-ai/cmux/pull/239
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, builds/uploadscmuxd-remote, 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/proxy failures surface actionable details.
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-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 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.3 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).