From aaf2ef4c3af370b0a15633ff7151ddf569ea8524 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 20 Feb 2026 23:32:48 -0800 Subject: [PATCH] feat: add ssh reuse defaults and remote daemon scaffold --- CLI/cmux.swift | 26 +++ daemon/remote/README.md | 14 ++ daemon/remote/cmd/cmuxd-remote/main.go | 170 ++++++++++++++++++++ daemon/remote/cmd/cmuxd-remote/main_test.go | 52 ++++++ daemon/remote/go.mod | 3 + docs/remote-daemon-spec.md | 14 +- tests_v2/test_ssh_remote_cli_metadata.py | 18 +++ 7 files changed, 291 insertions(+), 6 deletions(-) create mode 100644 daemon/remote/README.md create mode 100644 daemon/remote/cmd/cmuxd-remote/main.go create mode 100644 daemon/remote/cmd/cmuxd-remote/main_test.go create mode 100644 daemon/remote/go.mod diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 21e8e022..ded8043c 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -1885,6 +1885,15 @@ struct CMUXCLI { private func buildSSHCommandText(_ options: SSHCommandOptions) -> String { var parts: [String] = ["ssh", "-o", "StrictHostKeyChecking=accept-new"] + if !hasSSHOptionKey(options.sshOptions, key: "ControlMaster") { + parts += ["-o", "ControlMaster=auto"] + } + if !hasSSHOptionKey(options.sshOptions, key: "ControlPersist") { + parts += ["-o", "ControlPersist=600"] + } + if !hasSSHOptionKey(options.sshOptions, key: "ControlPath") { + parts += ["-o", "ControlPath=\(defaultSSHControlPathTemplate())"] + } if let port = options.port { parts += ["-p", String(port)] } @@ -1905,6 +1914,23 @@ struct CMUXCLI { return shellFeatures + " " + sshCommand } + private func hasSSHOptionKey(_ options: [String], key: String) -> Bool { + let loweredKey = key.lowercased() + for option in options { + let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } + let token = trimmed.split(whereSeparator: { $0 == "=" || $0.isWhitespace }).first.map(String.init)?.lowercased() + if token == loweredKey { + return true + } + } + return false + } + + private func defaultSSHControlPathTemplate() -> String { + "/tmp/cmux-ssh-\(getuid())-%C" + } + private func shellQuote(_ value: String) -> String { let safePattern = "^[A-Za-z0-9_@%+=:,./-]+$" if value.range(of: safePattern, options: .regularExpression) != nil { diff --git a/daemon/remote/README.md b/daemon/remote/README.md new file mode 100644 index 00000000..9d6f7d7f --- /dev/null +++ b/daemon/remote/README.md @@ -0,0 +1,14 @@ +# cmuxd-remote (Go) + +Minimal remote daemon scaffold for `cmux ssh`. + +Current commands: +1. `cmuxd-remote version` +2. `cmuxd-remote serve --stdio` + +Current RPC methods (newline-delimited JSON): +1. `hello` +2. `ping` + +This scaffold is intentionally small so `cmux` can start integrating daemon bootstrap, +capability negotiation, and protocol evolution without coupling to the Swift app runtime. diff --git a/daemon/remote/cmd/cmuxd-remote/main.go b/daemon/remote/cmd/cmuxd-remote/main.go new file mode 100644 index 00000000..0e299c8c --- /dev/null +++ b/daemon/remote/cmd/cmuxd-remote/main.go @@ -0,0 +1,170 @@ +package main + +import ( + "bufio" + "encoding/json" + "flag" + "fmt" + "io" + "os" +) + +var version = "dev" + +type rpcRequest struct { + ID any `json:"id"` + Method string `json:"method"` + Params map[string]any `json:"params"` +} + +type rpcError struct { + Code string `json:"code"` + Message string `json:"message"` +} + +type rpcResponse struct { + ID any `json:"id,omitempty"` + OK bool `json:"ok"` + Result any `json:"result,omitempty"` + Error *rpcError `json:"error,omitempty"` +} + +func main() { + os.Exit(run(os.Args[1:], os.Stdin, os.Stdout, os.Stderr)) +} + +func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { + if len(args) == 0 { + usage(stderr) + return 2 + } + + switch args[0] { + case "version": + _, _ = fmt.Fprintln(stdout, version) + return 0 + case "serve": + fs := flag.NewFlagSet("serve", flag.ContinueOnError) + fs.SetOutput(stderr) + stdio := fs.Bool("stdio", false, "serve over stdin/stdout") + if err := fs.Parse(args[1:]); err != nil { + return 2 + } + if !*stdio { + _, _ = fmt.Fprintln(stderr, "serve requires --stdio") + return 2 + } + if err := runStdioServer(stdin, stdout); err != nil { + _, _ = fmt.Fprintf(stderr, "serve failed: %v\n", err) + return 1 + } + return 0 + default: + usage(stderr) + return 2 + } +} + +func usage(w io.Writer) { + _, _ = fmt.Fprintln(w, "Usage:") + _, _ = fmt.Fprintln(w, " cmuxd-remote version") + _, _ = fmt.Fprintln(w, " cmuxd-remote serve --stdio") +} + +func runStdioServer(stdin io.Reader, stdout io.Writer) error { + scanner := bufio.NewScanner(stdin) + writer := bufio.NewWriter(stdout) + defer writer.Flush() + + for scanner.Scan() { + line := scanner.Bytes() + if len(line) == 0 { + continue + } + + var req rpcRequest + if err := json.Unmarshal(line, &req); err != nil { + if err := writeResponse(writer, rpcResponse{ + OK: false, + Error: &rpcError{ + Code: "invalid_request", + Message: "invalid JSON request", + }, + }); err != nil { + return err + } + continue + } + + resp := handleRequest(req) + if err := writeResponse(writer, resp); err != nil { + return err + } + } + + if err := scanner.Err(); err != nil { + return err + } + return nil +} + +func writeResponse(w *bufio.Writer, resp rpcResponse) error { + payload, err := json.Marshal(resp) + if err != nil { + return err + } + if _, err := w.Write(payload); err != nil { + return err + } + if err := w.WriteByte('\n'); err != nil { + return err + } + return w.Flush() +} + +func handleRequest(req rpcRequest) rpcResponse { + if req.Method == "" { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "invalid_request", + Message: "method is required", + }, + } + } + + switch req.Method { + case "hello": + return rpcResponse{ + ID: req.ID, + OK: true, + Result: map[string]any{ + "name": "cmuxd-remote", + "version": version, + "capabilities": []string{ + "session.basic", + "proxy.http_connect", + "proxy.socks5", + }, + }, + } + case "ping": + return rpcResponse{ + ID: req.ID, + OK: true, + Result: map[string]any{ + "pong": true, + }, + } + default: + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "method_not_found", + Message: fmt.Sprintf("unknown method %q", req.Method), + }, + } + } +} diff --git a/daemon/remote/cmd/cmuxd-remote/main_test.go b/daemon/remote/cmd/cmuxd-remote/main_test.go new file mode 100644 index 00000000..b6693f12 --- /dev/null +++ b/daemon/remote/cmd/cmuxd-remote/main_test.go @@ -0,0 +1,52 @@ +package main + +import ( + "bytes" + "encoding/json" + "strings" + "testing" +) + +func TestRunVersion(t *testing.T) { + var out bytes.Buffer + code := run([]string{"version"}, strings.NewReader(""), &out, &bytes.Buffer{}) + if code != 0 { + t.Fatalf("run version exit code = %d, want 0", code) + } + if strings.TrimSpace(out.String()) == "" { + t.Fatalf("version output should not be empty") + } +} + +func TestRunStdioHelloAndPing(t *testing.T) { + input := strings.NewReader( + `{"id":1,"method":"hello","params":{}}` + "\n" + + `{"id":2,"method":"ping","params":{}}` + "\n", + ) + var out bytes.Buffer + code := run([]string{"serve", "--stdio"}, input, &out, &bytes.Buffer{}) + if code != 0 { + t.Fatalf("run serve exit code = %d, want 0", code) + } + + lines := strings.Split(strings.TrimSpace(out.String()), "\n") + if len(lines) != 2 { + t.Fatalf("got %d response lines, want 2: %q", len(lines), out.String()) + } + + var first map[string]any + if err := json.Unmarshal([]byte(lines[0]), &first); err != nil { + t.Fatalf("failed to decode first response: %v", err) + } + if ok, _ := first["ok"].(bool); !ok { + t.Fatalf("first response should be ok=true: %v", first) + } + + var second map[string]any + if err := json.Unmarshal([]byte(lines[1]), &second); err != nil { + t.Fatalf("failed to decode second response: %v", err) + } + if ok, _ := second["ok"].(bool); !ok { + t.Fatalf("second response should be ok=true: %v", second) + } +} diff --git a/daemon/remote/go.mod b/daemon/remote/go.mod new file mode 100644 index 00000000..f4b93baa --- /dev/null +++ b/daemon/remote/go.mod @@ -0,0 +1,3 @@ +module github.com/manaflow-ai/cmux/daemon/remote + +go 1.22 diff --git a/docs/remote-daemon-spec.md b/docs/remote-daemon-spec.md index e4dd8aad..852b6692 100644 --- a/docs/remote-daemon-spec.md +++ b/docs/remote-daemon-spec.md @@ -52,6 +52,7 @@ Bootstrap: 2. checksum-verify before exec 3. run `cmuxd-remote serve --stdio` 4. negotiate version/capabilities +5. if bootstrap fails, fail `cmux ssh` with actionable error (no silent fallback to plain ssh mode) Minimum RPC surface: 1. `hello` @@ -86,8 +87,9 @@ States: Rules: 1. transport loss moves all attached sessions to `reconnecting` 2. successful reattach must keep same `session_id` (no duplicate shells) -3. persistent sessions survive app restart/disconnect -4. ephemeral sessions can be GC'd after TTL +3. `cmux ssh` defaults to persistent sessions +4. persistent sessions survive app restart/disconnect +5. ephemeral sessions can be GC'd after TTL when explicitly requested ## 8. Test Matrix @@ -114,6 +116,7 @@ All cases require deterministic `MUST` assertions. | W-004 | websocket via SOCKS5 | echo integrity | | W-005 | port conflict | structured conflict error + fallback behavior | | W-006 | concurrent PTY + proxy load | no PTY stall; proxy latency/error budget met | +| W-007 | browser auto wiring | browser workflow uses daemon-backed proxy automatically when remote session is active | ### 8.3 Reconnect @@ -137,12 +140,11 @@ All cases require deterministic `MUST` assertions. ## 9. CI Gates 1. `remote-terminal-core`: T-001..T-005 -2. `remote-proxy-core`: W-001..W-004 +2. `remote-proxy-core`: W-001..W-004, W-007 3. `remote-reconnect-core`: R-001..R-003 4. `remote-multidaemon-core`: M-001..M-002 ## 10. Open Decisions -1. default session policy for `cmux ssh`: `ephemeral` vs `persistent` -2. proxy endpoint scope: per daemon transport vs per workspace -3. reconnect retry budget and backoff profile +1. proxy endpoint scope: per daemon transport vs per workspace +2. reconnect retry budget and backoff profile diff --git a/tests_v2/test_ssh_remote_cli_metadata.py b/tests_v2/test_ssh_remote_cli_metadata.py index a5c81c87..3281e862 100644 --- a/tests_v2/test_ssh_remote_cli_metadata.py +++ b/tests_v2/test_ssh_remote_cli_metadata.py @@ -4,6 +4,7 @@ import glob import json import os +import re import subprocess import sys import time @@ -64,6 +65,11 @@ def _run_cli_json(cli: str, args: list[str]) -> dict: raise cmuxError(f"Invalid JSON output for {' '.join(args)}: {output!r} ({exc})") +def _extract_control_path(ssh_command: str) -> str: + match = re.search(r"ControlPath=([^\s]+)", ssh_command) + return match.group(1) if match else "" + + def main() -> int: cli = _find_cli_binary() help_text = _run_cli(cli, ["ssh", "--help"], json_output=False) @@ -94,6 +100,9 @@ def main() -> int: f"cmux ssh should scope ssh niceties to this command: {ssh_command!r}", ) _must("ssh -o StrictHostKeyChecking=accept-new" in ssh_command, f"ssh command prefix mismatch: {ssh_command!r}") + _must("-o ControlMaster=auto" in ssh_command, f"ssh command should opt into connection reuse: {ssh_command!r}") + _must("-o ControlPersist=600" in ssh_command, f"ssh command should keep master alive for reuse: {ssh_command!r}") + _must("ControlPath=/tmp/cmux-ssh-" in ssh_command, f"ssh command should use shared control path template: {ssh_command!r}") listed_row = None deadline = time.time() + 8.0 @@ -127,6 +136,7 @@ def main() -> int: ["ssh", "127.0.0.1", "--port", "1"], ) workspace_id_without_name = str(payload2.get("workspace_id") or "") + ssh_command_without_name = str(payload2.get("ssh_command") or "") workspace_ref_without_name = str(payload2.get("workspace_ref") or "") if not workspace_id_without_name and workspace_ref_without_name.startswith("workspace:"): listed2 = client._call("workspace.list", {}) or {} @@ -136,6 +146,14 @@ def main() -> int: break _must(bool(workspace_id_without_name), f"cmux ssh without --name should still create workspace: {payload2}") + _must( + "ControlPath=/tmp/cmux-ssh-" in ssh_command_without_name, + f"cmux ssh without --name should still include shared control path: {ssh_command_without_name!r}", + ) + _must( + _extract_control_path(ssh_command) == _extract_control_path(ssh_command_without_name), + f"identical hosts should resolve to same control path template: {ssh_command!r} vs {ssh_command_without_name!r}", + ) row2 = None listed2 = client._call("workspace.list", {}) or {} for row in listed2.get("workspaces") or []: