From 1a1caca99dbda5c28c557e11084501dc94a63516 Mon Sep 17 00:00:00 2001 From: Raghav Pillai Date: Mon, 23 Feb 2026 18:24:14 +0200 Subject: [PATCH] Add cmux CLI relay and tests - Introduce CLI client (cmux) to relay v1 text and v2 JSON-RPC commands over Unix/TCP sockets - Implement command specs, flag parsing, v1/v2 round-trips, TCP retry with address refresh - Add browser subcommand mapping and rpc passthrough support - Support busybox-style invocation when argv[0]=="cmux" and add 'cli' subcommand - Add comprehensive unit tests for socket dialing, CLI commands, flag-to-param mapping, and env defaults - Add socket_addr file fallback reader and random request id generation --- daemon/remote/cmd/cmuxd-remote/cli.go | 530 +++++++++++++++++++++ daemon/remote/cmd/cmuxd-remote/cli_test.go | 456 ++++++++++++++++++ daemon/remote/cmd/cmuxd-remote/main.go | 9 + 3 files changed, 995 insertions(+) create mode 100644 daemon/remote/cmd/cmuxd-remote/cli.go create mode 100644 daemon/remote/cmd/cmuxd-remote/cli_test.go diff --git a/daemon/remote/cmd/cmuxd-remote/cli.go b/daemon/remote/cmd/cmuxd-remote/cli.go new file mode 100644 index 00000000..eea20ed9 --- /dev/null +++ b/daemon/remote/cmd/cmuxd-remote/cli.go @@ -0,0 +1,530 @@ +package main + +import ( + "bufio" + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "net" + "os" + "path/filepath" + "strings" + "time" +) + +// protocolVersion indicates whether a command uses the v1 text or v2 JSON-RPC protocol. +type protocolVersion int + +const ( + protoV1 protocolVersion = iota + protoV2 +) + +// commandSpec describes a single CLI command and how to relay it. +type commandSpec struct { + name string // CLI command name (e.g. "ping", "new-window") + proto protocolVersion // v1 text or v2 JSON-RPC + v1Cmd string // v1: literal command string sent over the socket + v2Method string // v2: JSON-RPC method name + // flagKeys lists parameter keys this command accepts. + // They are extracted from --key flags and added to params. + flagKeys []string + // noParams means the command takes no parameters at all. + noParams bool +} + +var commands = []commandSpec{ + // V1 text protocol commands + {name: "ping", proto: protoV1, v1Cmd: "ping", noParams: true}, + {name: "new-window", proto: protoV1, v1Cmd: "new_window", noParams: true}, + {name: "current-window", proto: protoV1, v1Cmd: "current_window", noParams: true}, + {name: "close-window", proto: protoV1, v1Cmd: "close_window", flagKeys: []string{"window"}}, + {name: "focus-window", proto: protoV1, v1Cmd: "focus_window", flagKeys: []string{"window"}}, + {name: "list-windows", proto: protoV1, v1Cmd: "list_windows", noParams: true}, + + // V2 JSON-RPC commands + {name: "capabilities", proto: protoV2, v2Method: "system.capabilities", noParams: true}, + {name: "list-workspaces", proto: protoV2, v2Method: "workspace.list", noParams: true}, + {name: "new-workspace", proto: protoV2, v2Method: "workspace.create", flagKeys: []string{"command", "working-directory", "name"}}, + {name: "close-workspace", proto: protoV2, v2Method: "workspace.close", flagKeys: []string{"workspace"}}, + {name: "select-workspace", proto: protoV2, v2Method: "workspace.select", flagKeys: []string{"workspace"}}, + {name: "current-workspace", proto: protoV2, v2Method: "workspace.current", noParams: true}, + {name: "list-panels", proto: protoV2, v2Method: "panel.list", flagKeys: []string{"workspace"}}, + {name: "focus-panel", proto: protoV2, v2Method: "panel.focus", flagKeys: []string{"panel", "workspace"}}, + {name: "list-panes", proto: protoV2, v2Method: "pane.list", flagKeys: []string{"workspace"}}, + {name: "list-pane-surfaces", proto: protoV2, v2Method: "pane.surfaces", flagKeys: []string{"pane"}}, + {name: "new-pane", proto: protoV2, v2Method: "pane.create", flagKeys: []string{"workspace"}}, + {name: "new-surface", proto: protoV2, v2Method: "surface.create", flagKeys: []string{"workspace", "pane"}}, + {name: "new-split", proto: protoV2, v2Method: "surface.split", flagKeys: []string{"surface", "direction"}}, + {name: "close-surface", proto: protoV2, v2Method: "surface.close", flagKeys: []string{"surface"}}, + {name: "send", proto: protoV2, v2Method: "surface.send_text", flagKeys: []string{"surface", "text"}}, + {name: "send-key", proto: protoV2, v2Method: "surface.send_key", flagKeys: []string{"surface", "key"}}, + {name: "notify", proto: protoV2, v2Method: "notification.create", flagKeys: []string{"title", "body", "workspace"}}, + {name: "refresh-surfaces", proto: protoV2, v2Method: "surface.refresh", noParams: true}, +} + +var commandIndex map[string]*commandSpec + +func init() { + commandIndex = make(map[string]*commandSpec, len(commands)) + for i := range commands { + commandIndex[commands[i].name] = &commands[i] + } +} + +// runCLI is the entry point for the "cli" subcommand (or busybox "cmux" invocation). +func runCLI(args []string) int { + socketPath := os.Getenv("CMUX_SOCKET_PATH") + + // Parse global flags + var jsonOutput bool + var remaining []string + for i := 0; i < len(args); i++ { + switch args[i] { + case "--socket": + if i+1 >= len(args) { + fmt.Fprintln(os.Stderr, "cmux: --socket requires a path") + return 2 + } + socketPath = args[i+1] + i++ + case "--json": + jsonOutput = true + default: + remaining = append(remaining, args[i:]...) + goto doneFlags + } + } +doneFlags: + + if len(remaining) == 0 { + cliUsage() + return 2 + } + + // refreshAddr is set when the address came from socket_addr file (not env/flag), + // allowing retry loops to pick up updated relay ports. + var refreshAddr func() string + if socketPath == "" { + socketPath = readSocketAddrFile() + refreshAddr = readSocketAddrFile + } + if socketPath == "" { + fmt.Fprintln(os.Stderr, "cmux: CMUX_SOCKET_PATH not set and --socket not provided") + return 1 + } + + cmdName := remaining[0] + cmdArgs := remaining[1:] + + // Special case: "rpc" passthrough + if cmdName == "rpc" { + return runRPC(socketPath, cmdArgs, jsonOutput, refreshAddr) + } + + // Browser subcommand delegation + if cmdName == "browser" { + return runBrowserRelay(socketPath, cmdArgs, jsonOutput, refreshAddr) + } + + spec, ok := commandIndex[cmdName] + if !ok { + fmt.Fprintf(os.Stderr, "cmux: unknown command %q\n", cmdName) + return 2 + } + + switch spec.proto { + case protoV1: + return execV1(socketPath, spec, cmdArgs, refreshAddr) + case protoV2: + return execV2(socketPath, spec, cmdArgs, jsonOutput, refreshAddr) + default: + fmt.Fprintf(os.Stderr, "cmux: internal error: unknown protocol for %q\n", cmdName) + return 1 + } +} + +// execV1 sends a v1 text command over the socket. +func execV1(socketPath string, spec *commandSpec, args []string, refreshAddr func() string) int { + cmd := spec.v1Cmd + + if !spec.noParams { + parsed := parseFlags(args, spec.flagKeys) + for _, key := range spec.flagKeys { + if val, ok := parsed.flags[key]; ok { + cmd += " " + val + } + } + } + + resp, err := socketRoundTrip(socketPath, cmd, refreshAddr) + if err != nil { + fmt.Fprintf(os.Stderr, "cmux: %v\n", err) + return 1 + } + fmt.Print(resp) + if !strings.HasSuffix(resp, "\n") { + fmt.Println() + } + return 0 +} + +// execV2 sends a v2 JSON-RPC request over the socket. +func execV2(socketPath string, spec *commandSpec, args []string, jsonOutput bool, refreshAddr func() string) int { + params := make(map[string]any) + + if !spec.noParams { + parsed := parseFlags(args, spec.flagKeys) + // Map flag keys to JSON param keys (e.g. "workspace" → "workspace_id" where appropriate) + for _, key := range spec.flagKeys { + if val, ok := parsed.flags[key]; ok { + paramKey := flagToParamKey(key) + params[paramKey] = val + } + } + + // First positional arg is used as initial_command if --command wasn't given + if _, ok := params["initial_command"]; !ok && len(parsed.positional) > 0 { + params["initial_command"] = parsed.positional[0] + } + + // Fall back to env vars for common IDs + if _, ok := params["workspace_id"]; !ok { + if envWs := os.Getenv("CMUX_WORKSPACE_ID"); envWs != "" { + params["workspace_id"] = envWs + } + } + if _, ok := params["surface_id"]; !ok { + if envSf := os.Getenv("CMUX_SURFACE_ID"); envSf != "" { + params["surface_id"] = envSf + } + } + } + + resp, err := socketRoundTripV2(socketPath, spec.v2Method, params, refreshAddr) + if err != nil { + fmt.Fprintf(os.Stderr, "cmux: %v\n", err) + return 1 + } + + if jsonOutput { + fmt.Println(resp) + } else { + fmt.Println("OK") + } + return 0 +} + +// runRPC sends an arbitrary JSON-RPC method with optional JSON params. +func runRPC(socketPath string, args []string, jsonOutput bool, refreshAddr func() string) int { + if len(args) == 0 { + fmt.Fprintln(os.Stderr, "cmux rpc: requires a method name") + return 2 + } + method := args[0] + var params map[string]any + if len(args) > 1 { + if err := json.Unmarshal([]byte(args[1]), ¶ms); err != nil { + fmt.Fprintf(os.Stderr, "cmux rpc: invalid JSON params: %v\n", err) + return 2 + } + } + + resp, err := socketRoundTripV2(socketPath, method, params, refreshAddr) + if err != nil { + fmt.Fprintf(os.Stderr, "cmux: %v\n", err) + return 1 + } + fmt.Println(resp) + return 0 +} + +// runBrowserRelay handles "cmux browser " by mapping to browser.* v2 methods. +func runBrowserRelay(socketPath string, args []string, jsonOutput bool, refreshAddr func() string) int { + if len(args) == 0 { + fmt.Fprintln(os.Stderr, "cmux browser: requires a subcommand (open, navigate, back, forward, reload, get-url)") + return 2 + } + + sub := args[0] + subArgs := args[1:] + + var method string + var flagKeys []string + switch sub { + case "open", "open-split", "new": + method = "browser.open" + flagKeys = []string{"url", "workspace", "surface"} + case "navigate": + method = "browser.navigate" + flagKeys = []string{"url", "surface"} + case "back": + method = "browser.back" + flagKeys = []string{"surface"} + case "forward": + method = "browser.forward" + flagKeys = []string{"surface"} + case "reload": + method = "browser.reload" + flagKeys = []string{"surface"} + case "get-url": + method = "browser.get_url" + flagKeys = []string{"surface"} + default: + fmt.Fprintf(os.Stderr, "cmux browser: unknown subcommand %q\n", sub) + return 2 + } + + params := make(map[string]any) + parsed := parseFlags(subArgs, flagKeys) + for _, key := range flagKeys { + if val, ok := parsed.flags[key]; ok { + paramKey := flagToParamKey(key) + params[paramKey] = val + } + } + + resp, err := socketRoundTripV2(socketPath, method, params, refreshAddr) + if err != nil { + fmt.Fprintf(os.Stderr, "cmux: %v\n", err) + return 1 + } + if jsonOutput { + fmt.Println(resp) + } else { + fmt.Println("OK") + } + return 0 +} + +// flagToParamKey maps a CLI flag name to its JSON-RPC param key. +func flagToParamKey(key string) string { + switch key { + case "workspace": + return "workspace_id" + case "surface": + return "surface_id" + case "panel": + return "panel_id" + case "pane": + return "pane_id" + case "window": + return "window_id" + case "command": + return "initial_command" + case "name": + return "title" + case "working-directory": + return "working_directory" + default: + return key + } +} + +// parsedFlags holds the results of flag parsing. +type parsedFlags struct { + flags map[string]string // --key value pairs + positional []string // non-flag arguments +} + +// parseFlags extracts --key value pairs from args for the given allowed keys. +// Non-flag arguments are collected in positional. +func parseFlags(args []string, keys []string) parsedFlags { + allowed := make(map[string]bool, len(keys)) + for _, k := range keys { + allowed[k] = true + } + + result := parsedFlags{flags: make(map[string]string)} + for i := 0; i < len(args); i++ { + if !strings.HasPrefix(args[i], "--") { + result.positional = append(result.positional, args[i]) + continue + } + key := strings.TrimPrefix(args[i], "--") + if !allowed[key] { + continue + } + if i+1 < len(args) { + result.flags[key] = args[i+1] + i++ + } + } + return result +} + +// readSocketAddrFile reads the socket address from ~/.cmux/socket_addr as a fallback +// when CMUX_SOCKET_PATH is not set. Written by the cmux app after the relay establishes. +func readSocketAddrFile() string { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + data, err := os.ReadFile(filepath.Join(home, ".cmux", "socket_addr")) + if err != nil { + return "" + } + return strings.TrimSpace(string(data)) +} + +// dialSocket connects to the cmux socket. If addr contains a colon and doesn't +// start with '/', it's treated as a TCP address (host:port); otherwise Unix socket. +// For TCP connections, it retries briefly to allow the SSH reverse forward to establish. +// refreshAddr, if non-nil, is called on each retry to pick up updated socket_addr files. +func dialSocket(addr string, refreshAddr func() string) (net.Conn, error) { + if strings.Contains(addr, ":") && !strings.HasPrefix(addr, "/") { + return dialTCPRetry(addr, 15*time.Second, refreshAddr) + } + return net.Dial("unix", addr) +} + +// dialTCPRetry attempts a TCP connection, retrying on "connection refused" for up to timeout. +// This handles the case where the SSH reverse relay hasn't finished establishing yet. +// If refreshAddr is non-nil, it's called on each retry to pick up updated addresses +// (e.g. when socket_addr is rewritten by a new relay process). +func dialTCPRetry(addr string, timeout time.Duration, refreshAddr func() string) (net.Conn, error) { + deadline := time.Now().Add(timeout) + interval := 250 * time.Millisecond + printed := false + for { + conn, err := net.DialTimeout("tcp", addr, 2*time.Second) + if err == nil { + return conn, nil + } + if time.Now().After(deadline) { + return nil, err + } + // Only retry on connection refused (relay not ready yet) + if !isConnectionRefused(err) { + return nil, err + } + if !printed { + fmt.Fprintf(os.Stderr, "cmux: waiting for relay on %s...\n", addr) + printed = true + } + time.Sleep(interval) + // Re-read socket_addr in case the relay port has changed + if refreshAddr != nil { + if newAddr := refreshAddr(); newAddr != "" && newAddr != addr { + addr = newAddr + fmt.Fprintf(os.Stderr, "cmux: relay address updated to %s\n", addr) + } + } + } +} + +func isConnectionRefused(err error) bool { + if opErr, ok := err.(*net.OpError); ok { + return strings.Contains(opErr.Err.Error(), "connection refused") + } + return strings.Contains(err.Error(), "connection refused") +} + +// socketRoundTrip sends a raw text line and reads a raw text response (v1). +func socketRoundTrip(socketPath, command string, refreshAddr func() string) (string, error) { + conn, err := dialSocket(socketPath, refreshAddr) + if err != nil { + return "", fmt.Errorf("failed to connect to %s: %w", socketPath, err) + } + defer conn.Close() + + if _, err := fmt.Fprintf(conn, "%s\n", command); err != nil { + return "", fmt.Errorf("failed to send command: %w", err) + } + + reader := bufio.NewReader(conn) + line, err := reader.ReadString('\n') + if err != nil { + return "", fmt.Errorf("failed to read response: %w", err) + } + return strings.TrimRight(line, "\n"), nil +} + +// socketRoundTripV2 sends a JSON-RPC request and returns the result JSON. +func socketRoundTripV2(socketPath, method string, params map[string]any, refreshAddr func() string) (string, error) { + conn, err := dialSocket(socketPath, refreshAddr) + if err != nil { + return "", fmt.Errorf("failed to connect to %s: %w", socketPath, err) + } + defer conn.Close() + + id := randomHex(8) + req := map[string]any{ + "id": id, + "method": method, + } + if params != nil { + req["params"] = params + } else { + req["params"] = map[string]any{} + } + + payload, err := json.Marshal(req) + if err != nil { + return "", fmt.Errorf("failed to marshal request: %w", err) + } + + if _, err := conn.Write(append(payload, '\n')); err != nil { + return "", fmt.Errorf("failed to send request: %w", err) + } + + reader := bufio.NewReader(conn) + line, err := reader.ReadString('\n') + if err != nil { + return "", fmt.Errorf("failed to read response: %w", err) + } + + // Parse the response to check for errors + var resp map[string]any + if err := json.Unmarshal([]byte(line), &resp); err != nil { + return strings.TrimRight(line, "\n"), nil + } + + if ok, _ := resp["ok"].(bool); !ok { + if errObj, _ := resp["error"].(map[string]any); errObj != nil { + code, _ := errObj["code"].(string) + msg, _ := errObj["message"].(string) + return "", fmt.Errorf("server error [%s]: %s", code, msg) + } + return "", fmt.Errorf("server returned error response") + } + + // Return the result portion as JSON + if result, ok := resp["result"]; ok { + resultJSON, err := json.Marshal(result) + if err != nil { + return "", fmt.Errorf("failed to marshal result: %w", err) + } + return string(resultJSON), nil + } + + return "{}", nil +} + +func randomHex(n int) string { + b := make([]byte, n) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} + +func cliUsage() { + fmt.Fprintln(os.Stderr, "Usage: cmux [--socket ] [--json] [args...]") + fmt.Fprintln(os.Stderr, "") + fmt.Fprintln(os.Stderr, "Commands:") + fmt.Fprintln(os.Stderr, " ping Check connectivity") + fmt.Fprintln(os.Stderr, " capabilities List server capabilities") + fmt.Fprintln(os.Stderr, " list-workspaces List all workspaces") + fmt.Fprintln(os.Stderr, " new-window Create a new window") + fmt.Fprintln(os.Stderr, " new-workspace Create a new workspace") + fmt.Fprintln(os.Stderr, " new-surface Create a new surface") + fmt.Fprintln(os.Stderr, " new-split Split an existing surface") + fmt.Fprintln(os.Stderr, " close-surface Close a surface") + fmt.Fprintln(os.Stderr, " close-workspace Close a workspace") + fmt.Fprintln(os.Stderr, " select-workspace Select a workspace") + fmt.Fprintln(os.Stderr, " send Send text to a surface") + fmt.Fprintln(os.Stderr, " send-key Send a key to a surface") + fmt.Fprintln(os.Stderr, " notify Create a notification") + fmt.Fprintln(os.Stderr, " browser Browser commands (open, navigate, back, forward, reload, get-url)") + fmt.Fprintln(os.Stderr, " rpc [json-params] Send arbitrary JSON-RPC") +} diff --git a/daemon/remote/cmd/cmuxd-remote/cli_test.go b/daemon/remote/cmd/cmuxd-remote/cli_test.go new file mode 100644 index 00000000..44f5db6f --- /dev/null +++ b/daemon/remote/cmd/cmuxd-remote/cli_test.go @@ -0,0 +1,456 @@ +package main + +import ( + "encoding/json" + "net" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// startMockSocket creates a Unix socket that accepts one connection, +// reads a line, and responds with the given canned response. +func startMockSocket(t *testing.T, response string) string { + t.Helper() + dir := t.TempDir() + sockPath := filepath.Join(dir, "cmux.sock") + + ln, err := net.Listen("unix", sockPath) + if err != nil { + t.Fatalf("failed to listen: %v", err) + } + t.Cleanup(func() { ln.Close() }) + + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + buf := make([]byte, 4096) + n, _ := conn.Read(buf) + _ = n // consume request + conn.Write([]byte(response + "\n")) + conn.Close() + } + }() + + return sockPath +} + +// startMockV2Socket creates a Unix socket that echoes the received request's method +// back as a successful JSON-RPC response with the method name in the result. +func startMockV2Socket(t *testing.T) string { + t.Helper() + dir := t.TempDir() + sockPath := filepath.Join(dir, "cmux.sock") + + ln, err := net.Listen("unix", sockPath) + if err != nil { + t.Fatalf("failed to listen: %v", err) + } + t.Cleanup(func() { ln.Close() }) + + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + buf := make([]byte, 4096) + n, _ := conn.Read(buf) + if n > 0 { + var req map[string]any + if err := json.Unmarshal(buf[:n], &req); err == nil { + resp := map[string]any{ + "id": req["id"], + "ok": true, + "result": map[string]any{"method": req["method"], "params": req["params"]}, + } + payload, _ := json.Marshal(resp) + conn.Write(append(payload, '\n')) + } else { + conn.Write([]byte(`{"ok":false,"error":{"code":"parse","message":"bad json"}}` + "\n")) + } + } + conn.Close() + } + }() + + return sockPath +} + +// startMockTCPSocket creates a TCP listener that responds with a canned response. +func startMockTCPSocket(t *testing.T, response string) string { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to listen on TCP: %v", err) + } + t.Cleanup(func() { ln.Close() }) + + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + buf := make([]byte, 4096) + n, _ := conn.Read(buf) + _ = n + conn.Write([]byte(response + "\n")) + conn.Close() + } + }() + + return ln.Addr().String() +} + +func TestDialTCPRetrySuccess(t *testing.T) { + // Get a free port, then close the listener so connection is refused initially. + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + addr := ln.Addr().String() + ln.Close() + + // Start a listener after a delay so the retry logic finds it. + go func() { + time.Sleep(400 * time.Millisecond) + ln2, err := net.Listen("tcp", addr) + if err != nil { + return + } + defer ln2.Close() + conn, err := ln2.Accept() + if err != nil { + return + } + conn.Close() + }() + + conn, err := dialTCPRetry(addr, 3*time.Second, nil) + if err != nil { + t.Fatalf("dialTCPRetry should succeed after retry, got: %v", err) + } + conn.Close() +} + +func TestDialTCPRetryTimeout(t *testing.T) { + // Get a free port and close it — nothing will ever listen. + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + addr := ln.Addr().String() + ln.Close() + + start := time.Now() + _, err = dialTCPRetry(addr, 600*time.Millisecond, nil) + elapsed := time.Since(start) + if err == nil { + t.Fatal("dialTCPRetry should fail when nothing is listening") + } + if elapsed < 500*time.Millisecond { + t.Fatalf("should have retried for ~600ms, only took %v", elapsed) + } +} + +func TestCLIPingV1(t *testing.T) { + sockPath := startMockSocket(t, "pong") + code := runCLI([]string{"--socket", sockPath, "ping"}) + if code != 0 { + t.Fatalf("ping should return 0, got %d", code) + } +} + +func TestCLIPingV1OverTCP(t *testing.T) { + addr := startMockTCPSocket(t, "pong") + code := runCLI([]string{"--socket", addr, "ping"}) + if code != 0 { + t.Fatalf("ping over TCP should return 0, got %d", code) + } +} + +func TestDialSocketDetection(t *testing.T) { + // Unix socket paths should attempt Unix dial + for _, path := range []string{"/tmp/cmux-nonexistent-test-99999.sock", "/var/run/cmux-nonexistent.sock"} { + conn, err := dialSocket(path, nil) + if conn != nil { + conn.Close() + } + // We expect a connection error (not found), not a panic + if err == nil { + t.Fatalf("dialSocket(%q) should fail for non-existent path", path) + } + } + + // TCP addresses should attempt TCP dial + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + defer ln.Close() + + go func() { + conn, _ := ln.Accept() + if conn != nil { + conn.Close() + } + }() + + conn, err := dialSocket(ln.Addr().String(), nil) + if err != nil { + t.Fatalf("dialSocket(%q) should succeed for TCP: %v", ln.Addr().String(), err) + } + conn.Close() +} + +func TestCLINewWindowV1(t *testing.T) { + sockPath := startMockSocket(t, "OK window_id=abc123") + code := runCLI([]string{"--socket", sockPath, "new-window"}) + if code != 0 { + t.Fatalf("new-window should return 0, got %d", code) + } +} + +func TestCLICloseWindowV1(t *testing.T) { + // Verify that the flag value is appended to the v1 command + dir := t.TempDir() + sockPath := filepath.Join(dir, "cmux.sock") + + var received string + ln, err := net.Listen("unix", sockPath) + if err != nil { + t.Fatalf("listen: %v", err) + } + t.Cleanup(func() { ln.Close() }) + + go func() { + conn, err := ln.Accept() + if err != nil { + return + } + buf := make([]byte, 4096) + n, _ := conn.Read(buf) + received = strings.TrimSpace(string(buf[:n])) + conn.Write([]byte("OK\n")) + conn.Close() + }() + + code := runCLI([]string{"--socket", sockPath, "close-window", "--window", "win-42"}) + if code != 0 { + t.Fatalf("close-window should return 0, got %d", code) + } + if received != "close_window win-42" { + t.Fatalf("expected 'close_window win-42', got %q", received) + } +} + +func TestCLIListWorkspacesV2(t *testing.T) { + sockPath := startMockV2Socket(t) + code := runCLI([]string{"--socket", sockPath, "--json", "list-workspaces"}) + if code != 0 { + t.Fatalf("list-workspaces should return 0, got %d", code) + } +} + +func TestCLIRPCPassthrough(t *testing.T) { + sockPath := startMockV2Socket(t) + code := runCLI([]string{"--socket", sockPath, "rpc", "system.capabilities"}) + if code != 0 { + t.Fatalf("rpc should return 0, got %d", code) + } +} + +func TestCLIRPCWithParams(t *testing.T) { + sockPath := startMockV2Socket(t) + code := runCLI([]string{"--socket", sockPath, "rpc", "workspace.create", `{"title":"test"}`}) + if code != 0 { + t.Fatalf("rpc with params should return 0, got %d", code) + } +} + +func TestCLIUnknownCommand(t *testing.T) { + code := runCLI([]string{"--socket", "/dev/null", "does-not-exist"}) + if code != 2 { + t.Fatalf("unknown command should return 2, got %d", code) + } +} + +func TestCLINoSocket(t *testing.T) { + // Without CMUX_SOCKET_PATH set, should fail + os.Unsetenv("CMUX_SOCKET_PATH") + code := runCLI([]string{"ping"}) + if code != 1 { + t.Fatalf("missing socket should return 1, got %d", code) + } +} + +func TestCLISocketEnvVar(t *testing.T) { + sockPath := startMockSocket(t, "pong") + os.Setenv("CMUX_SOCKET_PATH", sockPath) + defer os.Unsetenv("CMUX_SOCKET_PATH") + + code := runCLI([]string{"ping"}) + if code != 0 { + t.Fatalf("ping with env socket should return 0, got %d", code) + } +} + +func TestCLIV2FlagMapping(t *testing.T) { + // Verify that --workspace gets mapped to workspace_id in params + dir := t.TempDir() + sockPath := filepath.Join(dir, "cmux.sock") + + var receivedParams map[string]any + ln, err := net.Listen("unix", sockPath) + if err != nil { + t.Fatalf("listen: %v", err) + } + t.Cleanup(func() { ln.Close() }) + + go func() { + conn, err := ln.Accept() + if err != nil { + return + } + buf := make([]byte, 4096) + n, _ := conn.Read(buf) + var req map[string]any + json.Unmarshal(buf[:n], &req) + receivedParams, _ = req["params"].(map[string]any) + resp := map[string]any{"id": req["id"], "ok": true, "result": map[string]any{}} + payload, _ := json.Marshal(resp) + conn.Write(append(payload, '\n')) + conn.Close() + }() + + code := runCLI([]string{"--socket", sockPath, "--json", "close-workspace", "--workspace", "ws-abc"}) + if code != 0 { + t.Fatalf("close-workspace should return 0, got %d", code) + } + if receivedParams["workspace_id"] != "ws-abc" { + t.Fatalf("expected workspace_id=ws-abc, got %v", receivedParams) + } +} + +func TestBusyboxArgv0Detection(t *testing.T) { + // Verify that when argv[0] base is "cmux", we enter CLI mode + base := filepath.Base("cmux") + if base != "cmux" { + t.Fatalf("expected base 'cmux', got %q", base) + } + base2 := filepath.Base("/home/user/.cmux/bin/cmux") + if base2 != "cmux" { + t.Fatalf("expected base 'cmux', got %q", base2) + } + base3 := filepath.Base("cmuxd-remote") + if base3 == "cmux" { + t.Fatalf("cmuxd-remote should not match cmux") + } +} + +func TestCLIBrowserSubcommand(t *testing.T) { + sockPath := startMockV2Socket(t) + code := runCLI([]string{"--socket", sockPath, "--json", "browser", "open", "--url", "https://example.com"}) + if code != 0 { + t.Fatalf("browser open should return 0, got %d", code) + } +} + +func TestCLINoArgs(t *testing.T) { + code := runCLI([]string{}) + if code != 2 { + t.Fatalf("no args should return 2, got %d", code) + } +} + +func TestFlagToParamKey(t *testing.T) { + tests := []struct { + input, expected string + }{ + {"workspace", "workspace_id"}, + {"surface", "surface_id"}, + {"panel", "panel_id"}, + {"pane", "pane_id"}, + {"window", "window_id"}, + {"command", "initial_command"}, + {"name", "title"}, + {"working-directory", "working_directory"}, + {"title", "title"}, + {"url", "url"}, + {"direction", "direction"}, + } + for _, tc := range tests { + got := flagToParamKey(tc.input) + if got != tc.expected { + t.Errorf("flagToParamKey(%q) = %q, want %q", tc.input, got, tc.expected) + } + } +} + +func TestParseFlags(t *testing.T) { + args := []string{"positional-cmd", "--workspace", "ws-1", "--surface", "sf-2", "--unknown", "val"} + result := parseFlags(args, []string{"workspace", "surface"}) + if result.flags["workspace"] != "ws-1" { + t.Errorf("expected workspace=ws-1, got %q", result.flags["workspace"]) + } + if result.flags["surface"] != "sf-2" { + t.Errorf("expected surface=sf-2, got %q", result.flags["surface"]) + } + if _, ok := result.flags["unknown"]; ok { + t.Errorf("unknown flag should not be parsed") + } + if len(result.positional) == 0 || result.positional[0] != "positional-cmd" { + t.Errorf("expected first positional=positional-cmd, got %v", result.positional) + } +} + +func TestCLIEnvVarDefaults(t *testing.T) { + // Test that CMUX_WORKSPACE_ID and CMUX_SURFACE_ID are used as defaults + dir := t.TempDir() + sockPath := filepath.Join(dir, "cmux.sock") + + var receivedParams map[string]any + ln, err := net.Listen("unix", sockPath) + if err != nil { + t.Fatalf("listen: %v", err) + } + t.Cleanup(func() { ln.Close() }) + + go func() { + conn, err := ln.Accept() + if err != nil { + return + } + buf := make([]byte, 4096) + n, _ := conn.Read(buf) + var req map[string]any + json.Unmarshal(buf[:n], &req) + receivedParams, _ = req["params"].(map[string]any) + resp := map[string]any{"id": req["id"], "ok": true, "result": map[string]any{}} + payload, _ := json.Marshal(resp) + conn.Write(append(payload, '\n')) + conn.Close() + }() + + os.Setenv("CMUX_WORKSPACE_ID", "env-ws-id") + os.Setenv("CMUX_SURFACE_ID", "env-sf-id") + defer os.Unsetenv("CMUX_WORKSPACE_ID") + defer os.Unsetenv("CMUX_SURFACE_ID") + + code := runCLI([]string{"--socket", sockPath, "--json", "close-surface"}) + if code != 0 { + t.Fatalf("close-surface should return 0, got %d", code) + } + if receivedParams["workspace_id"] != "env-ws-id" { + t.Errorf("expected workspace_id from env, got %v", receivedParams["workspace_id"]) + } + if receivedParams["surface_id"] != "env-sf-id" { + t.Errorf("expected surface_id from env, got %v", receivedParams["surface_id"]) + } +} diff --git a/daemon/remote/cmd/cmuxd-remote/main.go b/daemon/remote/cmd/cmuxd-remote/main.go index 0e299c8c..f114cb19 100644 --- a/daemon/remote/cmd/cmuxd-remote/main.go +++ b/daemon/remote/cmd/cmuxd-remote/main.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "os" + "path/filepath" ) var version = "dev" @@ -30,6 +31,11 @@ type rpcResponse struct { } func main() { + // Busybox-style: if invoked as "cmux" (via symlink), act as CLI relay. + base := filepath.Base(os.Args[0]) + if base == "cmux" { + os.Exit(runCLI(os.Args[1:])) + } os.Exit(run(os.Args[1:], os.Stdin, os.Stdout, os.Stderr)) } @@ -59,6 +65,8 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { return 1 } return 0 + case "cli": + return runCLI(args[1:]) default: usage(stderr) return 2 @@ -69,6 +77,7 @@ func usage(w io.Writer) { _, _ = fmt.Fprintln(w, "Usage:") _, _ = fmt.Fprintln(w, " cmuxd-remote version") _, _ = fmt.Fprintln(w, " cmuxd-remote serve --stdio") + _, _ = fmt.Fprintln(w, " cmuxd-remote cli [args...]") } func runStdioServer(stdin io.Reader, stdout io.Writer) error {