feat: add ssh reuse defaults and remote daemon scaffold
This commit is contained in:
parent
f46813a029
commit
aaf2ef4c3a
7 changed files with 291 additions and 6 deletions
|
|
@ -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
14
daemon/remote/README.md
Normal 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.
|
||||
170
daemon/remote/cmd/cmuxd-remote/main.go
Normal file
170
daemon/remote/cmd/cmuxd-remote/main.go
Normal 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),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
52
daemon/remote/cmd/cmuxd-remote/main_test.go
Normal file
52
daemon/remote/cmd/cmuxd-remote/main_test.go
Normal 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
3
daemon/remote/go.mod
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
module github.com/manaflow-ai/cmux/daemon/remote
|
||||
|
||||
go 1.22
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 []:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue