feat: add ssh reuse defaults and remote daemon scaffold

This commit is contained in:
Lawrence Chen 2026-02-20 23:32:48 -08:00
parent f46813a029
commit aaf2ef4c3a
7 changed files with 291 additions and 6 deletions

View file

@ -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 {

14
daemon/remote/README.md Normal file
View file

@ -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.

View file

@ -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),
},
}
}
}

View file

@ -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)
}
}

3
daemon/remote/go.mod Normal file
View file

@ -0,0 +1,3 @@
module github.com/manaflow-ai/cmux/daemon/remote
go 1.22

View file

@ -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

View file

@ -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 []: