From 1a1caca99dbda5c28c557e11084501dc94a63516 Mon Sep 17 00:00:00 2001 From: Raghav Pillai Date: Mon, 23 Feb 2026 18:24:14 +0200 Subject: [PATCH 01/13] 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 { From 18700b0515fedb515f05345e86bf47750b667fe2 Mon Sep 17 00:00:00 2001 From: Raghav Pillai Date: Mon, 23 Feb 2026 18:24:25 +0200 Subject: [PATCH 02/13] Add SSH reverse relay for local Unix socket - Add support for spawning an SSH reverse relay that forwards a remote TCP port to a local cmux Unix socket - Generate random ephemeral relay port and propagate via CLI, workspace config, and JSON payloads - Start/monitor background relay Process in WorkspaceRemoteSessionController with stderr handling and auto-restart - Filter probe-reported ephemeral ports and avoid treating relay ports as user service ports - Create remote cmux symlink and write remote ~/.cmux/socket_addr for relay discovery - Kill orphaned relay processes on startup to avoid conflicts - Add helpers to check loopback port reachability and adjust forward/SSH options (ControlPath, ExitOnForwardFailure) --- CLI/cmux.swift | 27 +++- Sources/TerminalController.swift | 6 +- Sources/Workspace.swift | 223 ++++++++++++++++++++++++++++++- 3 files changed, 248 insertions(+), 8 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 020ac4fe..bdbd264b 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -1745,6 +1745,13 @@ struct CMUXCLI { let workspaceName: String? let sshOptions: [String] let extraArguments: [String] + let localSocketPath: String + let remoteRelayPort: Int + } + + private func generateRemoteRelayPort() -> Int { + // Random port in the ephemeral range (49152-65535) + Int.random(in: 49152...65535) } private func runSSH( @@ -1753,7 +1760,9 @@ struct CMUXCLI { jsonOutput: Bool, idFormat: CLIIDFormat ) throws { - let sshOptions = try parseSSHCommandOptions(commandArgs) + let localSocketPath = ProcessInfo.processInfo.environment["CMUX_SOCKET_PATH"] ?? "/tmp/cmux.sock" + let remoteRelayPort = generateRemoteRelayPort() + let sshOptions = try parseSSHCommandOptions(commandArgs, localSocketPath: localSocketPath, remoteRelayPort: remoteRelayPort) let sshCommand = buildSSHCommandText(sshOptions) let shellFeaturesValue = scopedGhosttyShellFeaturesValue() let sshStartupCommand = buildSSHStartupCommand(sshCommand: sshCommand, shellFeatures: shellFeaturesValue) @@ -1790,6 +1799,10 @@ struct CMUXCLI { if !sshOptions.sshOptions.isEmpty { configureParams["ssh_options"] = sshOptions.sshOptions } + if sshOptions.remoteRelayPort > 0 { + configureParams["relay_port"] = sshOptions.remoteRelayPort + configureParams["local_socket_path"] = sshOptions.localSocketPath + } var payload = try client.sendV2(method: "workspace.remote.configure", params: configureParams) @@ -1798,6 +1811,7 @@ struct CMUXCLI { payload["ssh_env_overrides"] = [ "GHOSTTY_SHELL_FEATURES": shellFeaturesValue, ] + payload["remote_relay_port"] = remoteRelayPort if jsonOutput { print(jsonString(formatIDs(payload, mode: idFormat))) } else { @@ -1808,7 +1822,7 @@ struct CMUXCLI { } } - private func parseSSHCommandOptions(_ commandArgs: [String]) throws -> SSHCommandOptions { + private func parseSSHCommandOptions(_ commandArgs: [String], localSocketPath: String = "", remoteRelayPort: Int = 0) throws -> SSHCommandOptions { var destination: String? var port: Int? var identityFile: String? @@ -1883,12 +1897,18 @@ struct CMUXCLI { identityFile: identityFile, workspaceName: workspaceName, sshOptions: sshOptions, - extraArguments: extraArguments + extraArguments: extraArguments, + localSocketPath: localSocketPath, + remoteRelayPort: remoteRelayPort ) } private func buildSSHCommandText(_ options: SSHCommandOptions) -> String { var parts: [String] = ["ssh", "-o", "StrictHostKeyChecking=accept-new"] + // The reverse relay (-R) is handled by a separate background SSH process + // spawned by WorkspaceRemoteSessionController (direct child of cmux app, + // passes the ancestry access check). The terminal SSH session just uses + // normal ControlMaster settings and sets up the remote environment. if !hasSSHOptionKey(options.sshOptions, key: "ControlMaster") { parts += ["-o", "ControlMaster=auto"] } @@ -1910,6 +1930,7 @@ struct CMUXCLI { guard !trimmed.isEmpty else { continue } parts += ["-o", trimmed] } + parts.append(options.destination) parts.append(contentsOf: options.extraArguments) return parts.map(shellQuote).joined(separator: " ") diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 66c3b6d4..ea40a579 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -1986,6 +1986,8 @@ class TerminalController { let identityFile = v2RawString(params, "identity_file")?.trimmingCharacters(in: .whitespacesAndNewlines) let sshOptions = v2StringArray(params, "ssh_options") ?? [] let autoConnect = v2Bool(params, "auto_connect") ?? true + let relayPort = v2Int(params, "relay_port") + let localSocketPath = v2RawString(params, "local_socket_path") var result: V2CallResult = .err(code: "not_found", message: "Workspace not found", data: [ "workspace_id": workspaceId.uuidString, @@ -2002,7 +2004,9 @@ class TerminalController { destination: destination, port: sshPort, identityFile: identityFile?.isEmpty == true ? nil : identityFile, - sshOptions: sshOptions + sshOptions: sshOptions, + relayPort: relayPort, + localSocketPath: localSocketPath ) workspace.configureRemoteConnection(config, autoConnect: autoConnect) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index a9825ab5..4587b4ff 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -56,6 +56,8 @@ private final class WorkspaceRemoteSessionController { private var daemonRemotePath: String? private var reconnectRetryCount = 0 private var reconnectWorkItem: DispatchWorkItem? + private var reverseRelayProcess: Process? + private var reverseRelayStderrPipe: Pipe? init(workspace: Workspace, configuration: WorkspaceRemoteConfiguration) { self.workspace = workspace @@ -95,6 +97,15 @@ private final class WorkspaceRemoteSessionController { probeStdoutBuffer = "" probeStderrBuffer = "" + if let reverseRelayProcess { + reverseRelayStderrPipe?.fileHandleForReading.readabilityHandler = nil + if reverseRelayProcess.isRunning { + reverseRelayProcess.terminate() + } + } + reverseRelayProcess = nil + reverseRelayStderrPipe = nil + for (_, entry) in forwardEntries { entry.stderrPipe.fileHandleForReading.readabilityHandler = nil if entry.process.isRunning { @@ -124,6 +135,7 @@ private final class WorkspaceRemoteSessionController { } publishState(.connecting, detail: connectDetail) publishDaemonStatus(.bootstrapping, detail: bootstrapDetail) + do { let hello = try bootstrapDaemonLocked() daemonReady = true @@ -137,6 +149,7 @@ private final class WorkspaceRemoteSessionController { capabilities: hello.capabilities, remotePath: hello.remotePath ) + startReverseRelayLocked() startProbeLocked() } catch { daemonReady = false @@ -271,8 +284,14 @@ private final class WorkspaceRemoteSessionController { private func handleProbePortsLine(_ line: String) { guard !isStopping else { return } - let ports = Self.parseRemotePorts(line: line) - desiredRemotePorts = Set(ports) + var ports = Set(Self.parseRemotePorts(line: line)) + if let relayPort = configuration.relayPort { + ports.remove(relayPort) + } + // Filter ephemeral ports (49152-65535) — these are SSH reverse relay ports + // from this or other workspaces, not user services worth forwarding. + ports = ports.filter { $0 < 49152 } + desiredRemotePorts = ports portConflicts = portConflicts.intersection(desiredRemotePorts) reconnectWorkItem?.cancel() reconnectWorkItem = nil @@ -294,7 +313,13 @@ private final class WorkspaceRemoteSessionController { for port in desiredRemotePorts.sorted() where forwardEntries[port] == nil { guard Self.isLoopbackPortAvailable(port: port) else { - portConflicts.insert(port) + // Port is already bound locally. If it's reachable (e.g. another + // workspace is forwarding it), don't flag it as a conflict. + if Self.isLoopbackPortReachable(port: port) { + portConflicts.remove(port) + } else { + portConflicts.insert(port) + } continue } if startForwardLocked(port: port) { @@ -386,6 +411,108 @@ private final class WorkspaceRemoteSessionController { } } + /// Spawns a background SSH process that reverse-forwards a remote TCP port to the local cmux Unix socket. + /// This process is a direct child of the cmux app, so it passes the `isDescendant()` ancestry check. + @discardableResult + private func startReverseRelayLocked() -> Bool { + guard !isStopping else { return false } + guard let relayPort = configuration.relayPort, relayPort > 0, + let localSocketPath = configuration.localSocketPath, !localSocketPath.isEmpty else { + return false + } + + // Kill any existing relay process managed by this session + if let existing = reverseRelayProcess { + reverseRelayStderrPipe?.fileHandleForReading.readabilityHandler = nil + if existing.isRunning { existing.terminate() } + reverseRelayProcess = nil + reverseRelayStderrPipe = nil + } + + // Kill orphaned relay SSH processes from previous app sessions that reverse-forward + // to the same socket path (they survive pkill because they're reparented to launchd). + Self.killOrphanedRelayProcesses(socketPath: localSocketPath, destination: configuration.destination) + + let process = Process() + let stderrPipe = Pipe() + process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh") + + // Build arguments: -N (no remote command), -o ControlPath=none (avoid ControlMaster delegation), + // then common SSH args, then -R reverse forward, then destination. + // ExitOnForwardFailure=no because user's ~/.ssh/config may have RemoteForward entries + // that conflict with already-bound ports — we don't want those to kill our relay. + var args: [String] = ["-N"] + args += sshCommonArguments(batchMode: true) + args += ["-R", "127.0.0.1:\(relayPort):\(localSocketPath)"] + args += [configuration.destination] + process.arguments = args + + process.standardOutput = FileHandle.nullDevice + process.standardError = stderrPipe + + stderrPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in + let data = handle.availableData + guard !data.isEmpty else { + handle.readabilityHandler = nil + return + } + self?.queue.async { + guard let self else { return } + if let chunk = String(data: data, encoding: .utf8), !chunk.isEmpty { + self.probeStderrBuffer.append(chunk) + if self.probeStderrBuffer.count > 8192 { + self.probeStderrBuffer.removeFirst(self.probeStderrBuffer.count - 8192) + } + } + } + } + + process.terminationHandler = { [weak self] terminated in + self?.queue.async { + self?.handleReverseRelayTermination(process: terminated) + } + } + + do { + try process.run() + reverseRelayProcess = process + reverseRelayStderrPipe = stderrPipe + NSLog("[cmux] reverse relay started: -R 127.0.0.1:%d:%@ → %@", relayPort, localSocketPath, configuration.destination) + + // Write socket_addr after a delay to give the SSH -R forward time to establish. + // The Go CLI retry loop re-reads this file, so it will pick up the port once ready. + queue.asyncAfter(deadline: .now() + 3.0) { [weak self] in + guard let self, !self.isStopping else { return } + guard self.reverseRelayProcess?.isRunning == true else { return } + self.writeRemoteSocketAddrLocked() + } + + return true + } catch { + NSLog("[cmux] failed to start reverse relay: %@", error.localizedDescription) + return false + } + } + + private func handleReverseRelayTermination(process: Process) { + if reverseRelayProcess === process { + reverseRelayStderrPipe?.fileHandleForReading.readabilityHandler = nil + reverseRelayProcess = nil + reverseRelayStderrPipe = nil + } + + guard !isStopping else { return } + guard configuration.relayPort != nil else { return } + + // Auto-restart after 2 seconds if we're still active + queue.asyncAfter(deadline: .now() + 2.0) { [weak self] in + guard let self else { return } + guard !self.isStopping else { return } + guard self.reverseRelayProcess == nil else { return } + self.startReverseRelayLocked() + } + } + private func publishState(_ state: WorkspaceRemoteConnectionState, detail: String?) { DispatchQueue.main.async { [weak workspace] in guard let workspace else { return } @@ -445,7 +572,7 @@ private final class WorkspaceRemoteSessionController { private func forwardArguments(port: Int) -> [String] { let localBind = "127.0.0.1:\(port):127.0.0.1:\(port)" - return ["-N", "-o", "ExitOnForwardFailure=yes"] + sshCommonArguments(batchMode: true) + ["-L", localBind, configuration.destination] + return ["-N"] + sshCommonArguments(batchMode: true) + ["-L", localBind, configuration.destination] } private func sshCommonArguments(batchMode: Bool) -> [String] { @@ -454,6 +581,8 @@ private final class WorkspaceRemoteSessionController { "-o", "ServerAliveInterval=20", "-o", "ServerAliveCountMax=2", "-o", "StrictHostKeyChecking=accept-new", + "-o", "ExitOnForwardFailure=no", + "-o", "ControlPath=none", ] if batchMode { args += ["-o", "BatchMode=yes"] @@ -561,9 +690,54 @@ private final class WorkspaceRemoteSessionController { try uploadRemoteDaemonBinaryLocked(localBinary: localBinary, remotePath: remotePath) } + createRemoteCLISymlinkLocked(daemonRemotePath: remotePath) + return try helloRemoteDaemonLocked(remotePath: remotePath) } + /// Creates `cmux` symlinks pointing to the daemon binary. + /// Tries `/usr/local/bin` first (already in PATH, no rc changes needed), falls back to + /// `~/.cmux/bin`. Non-fatal: logs on failure but does not throw. + private func createRemoteCLISymlinkLocked(daemonRemotePath: String) { + let script = """ + mkdir -p "$HOME/.cmux/bin" + ln -sf "$HOME/\(daemonRemotePath)" "$HOME/.cmux/bin/cmux" + ln -sf "$HOME/\(daemonRemotePath)" /usr/local/bin/cmux 2>/dev/null \ + || sudo -n ln -sf "$HOME/\(daemonRemotePath)" /usr/local/bin/cmux 2>/dev/null \ + || true + """ + let command = "sh -lc \(Self.shellSingleQuoted(script))" + do { + let result = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 8) + if result.status != 0 { + NSLog("[cmux] warning: failed to create remote CLI symlink (exit %d): %@", + result.status, + Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "unknown error") + } + } catch { + NSLog("[cmux] warning: failed to create remote CLI symlink: %@", error.localizedDescription) + } + } + + /// Writes `~/.cmux/socket_addr` on the remote with the relay TCP address. + /// The Go CLI relay reads this file as a fallback when CMUX_SOCKET_PATH is not set. + private func writeRemoteSocketAddrLocked() { + guard let relayPort = configuration.relayPort, relayPort > 0 else { return } + let addr = "127.0.0.1:\(relayPort)" + let script = "mkdir -p \"$HOME/.cmux\" && printf '%s' '\(addr)' > \"$HOME/.cmux/socket_addr\"" + let command = "sh -lc \(Self.shellSingleQuoted(script))" + do { + let result = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 8) + if result.status != 0 { + NSLog("[cmux] warning: failed to write remote socket_addr (exit %d): %@", + result.status, + Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "unknown error") + } + } catch { + NSLog("[cmux] warning: failed to write remote socket_addr: %@", error.localizedDescription) + } + } + private func resolveRemotePlatformLocked() throws -> RemotePlatform { let script = "uname -s; uname -m" let command = "sh -lc \(Self.shellSingleQuoted(script))" @@ -919,6 +1093,23 @@ private final class WorkspaceRemoteSessionController { return " (retry \(retry) in \(seconds)s)" } + /// Kills orphaned SSH relay processes from previous app sessions. + /// These processes survive app restarts because `pkill` doesn't trigger graceful cleanup. + private static func killOrphanedRelayProcesses(socketPath: String, destination: String) { + let pipe = Pipe() + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/pkill") + process.arguments = ["-f", "ssh.*-R.*127\\.0\\.0\\.1:.*:\(socketPath).*\(destination)"] + process.standardOutput = pipe + process.standardError = pipe + do { + try process.run() + process.waitUntilExit() + } catch { + // Best-effort cleanup; ignore failures + } + } + private static func isLoopbackPortAvailable(port: Int) -> Bool { guard port > 0 && port <= 65535 else { return false } @@ -942,6 +1133,28 @@ private final class WorkspaceRemoteSessionController { } return bindResult == 0 } + + /// Check if a port on 127.0.0.1 is already accepting connections (e.g. forwarded by another workspace). + private static func isLoopbackPortReachable(port: Int) -> Bool { + guard port > 0 && port <= 65535 else { return false } + + let fd = socket(AF_INET, SOCK_STREAM, 0) + guard fd >= 0 else { return false } + defer { close(fd) } + + var addr = sockaddr_in() + addr.sin_len = UInt8(MemoryLayout.size) + addr.sin_family = sa_family_t(AF_INET) + addr.sin_port = in_port_t(UInt16(port).bigEndian) + addr.sin_addr = in_addr(s_addr: inet_addr("127.0.0.1")) + + let result = withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in + connect(fd, sockaddrPtr, socklen_t(MemoryLayout.size)) + } + } + return result == 0 + } } enum SidebarLogLevel: String { @@ -1008,6 +1221,8 @@ struct WorkspaceRemoteConfiguration: Equatable { let port: Int? let identityFile: String? let sshOptions: [String] + let relayPort: Int? + let localSocketPath: String? var displayTarget: String { guard let port else { return destination } From 325abe6ea613f2966b208ab2e05d5627b4012e9c Mon Sep 17 00:00:00 2001 From: Raghav Pillai Date: Mon, 23 Feb 2026 18:24:34 +0200 Subject: [PATCH 03/13] Enable stream-local forwarding in SSH fixture - add AllowStreamLocalForwarding yes to test SSH server config - add StreamLocalBindUnlink yes to allow binding to existing socket paths - update ssh-remote fixture to support stream local socket forwarding scenarios --- tests/fixtures/ssh-remote/sshd_config | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/fixtures/ssh-remote/sshd_config b/tests/fixtures/ssh-remote/sshd_config index dba37c52..9885b799 100644 --- a/tests/fixtures/ssh-remote/sshd_config +++ b/tests/fixtures/ssh-remote/sshd_config @@ -20,6 +20,8 @@ AcceptEnv TERM_PROGRAM TERM_PROGRAM_VERSION COLORTERM X11Forwarding no AllowTcpForwarding yes +AllowStreamLocalForwarding yes +StreamLocalBindUnlink yes GatewayPorts no PermitTunnel no ClientAliveInterval 30 From 071e6e08974e7a27bf0bf8f38e28674d81b0885c Mon Sep 17 00:00:00 2001 From: Raghav Pillai Date: Mon, 23 Feb 2026 18:24:45 +0200 Subject: [PATCH 04/13] Add SSH relay integration test for cmux CLI - Introduce tests_v2/test_ssh_remote_cli_relay.py Docker integration test - Spawns a Docker SSH fixture, generates SSH keypair, and forwards reverse socket - Locates cmux CLI binary and runs CLI commands over SSH (ping, list-workspaces, new-window, rpc) - Validates JSON outputs and remote cmux symlink presence - Cleans up workspace, container, image, and temp files on completion or error --- tests_v2/test_ssh_remote_cli_relay.py | 286 ++++++++++++++++++++++++++ 1 file changed, 286 insertions(+) create mode 100644 tests_v2/test_ssh_remote_cli_relay.py diff --git a/tests_v2/test_ssh_remote_cli_relay.py b/tests_v2/test_ssh_remote_cli_relay.py new file mode 100644 index 00000000..d5125b7f --- /dev/null +++ b/tests_v2/test_ssh_remote_cli_relay.py @@ -0,0 +1,286 @@ +#!/usr/bin/env python3 +"""Docker integration: verify cmux CLI commands work over SSH via reverse socket forwarding.""" + +from __future__ import annotations + +import glob +import json +import os +import secrets +import shutil +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") +REMOTE_HTTP_PORT = int(os.environ.get("CMUX_SSH_TEST_REMOTE_HTTP_PORT", "43173")) + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _find_cli_binary() -> str: + env_cli = os.environ.get("CMUXTERM_CLI") + if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK): + return env_cli + + fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux") + if os.path.isfile(fixed) and os.access(fixed, os.X_OK): + return fixed + + candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True) + candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux") + candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)] + if not candidates: + raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI") + candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True) + return candidates[0] + + +def _run(cmd: list[str], *, env: dict[str, str] | None = None, check: bool = True) -> subprocess.CompletedProcess[str]: + proc = subprocess.run(cmd, capture_output=True, text=True, env=env, check=False) + if check and proc.returncode != 0: + merged = f"{proc.stdout}\n{proc.stderr}".strip() + raise cmuxError(f"Command failed ({' '.join(cmd)}): {merged}") + return proc + + +def _run_cli_json(cli: str, args: list[str]) -> dict: + env = dict(os.environ) + env.pop("CMUX_WORKSPACE_ID", None) + env.pop("CMUX_SURFACE_ID", None) + env.pop("CMUX_TAB_ID", None) + + proc = _run([cli, "--socket", SOCKET_PATH, "--json", *args], env=env) + try: + return json.loads(proc.stdout or "{}") + except Exception as exc: # noqa: BLE001 + raise cmuxError(f"Invalid JSON output for {' '.join(args)}: {proc.stdout!r} ({exc})") + + +def _docker_available() -> bool: + if shutil.which("docker") is None: + return False + probe = _run(["docker", "info"], check=False) + return probe.returncode == 0 + + +def _parse_host_port(docker_port_output: str) -> int: + text = docker_port_output.strip() + if not text: + raise cmuxError("docker port output was empty") + last = text.split(":")[-1] + return int(last) + + +def _shell_single_quote(value: str) -> str: + return "'" + value.replace("'", "'\"'\"'") + "'" + + +def _ssh_run(host: str, host_port: int, key_path: Path, script: str, *, check: bool = True) -> subprocess.CompletedProcess[str]: + return _run( + [ + "ssh", + "-o", "UserKnownHostsFile=/dev/null", + "-o", "StrictHostKeyChecking=no", + "-o", "ConnectTimeout=5", + "-p", str(host_port), + "-i", str(key_path), + host, + f"sh -lc {_shell_single_quote(script)}", + ], + check=check, + ) + + +def _wait_for_ssh(host: str, host_port: int, key_path: Path, timeout: float = 20.0) -> None: + deadline = time.time() + timeout + while time.time() < deadline: + probe = _ssh_run(host, host_port, key_path, "echo ready", check=False) + if probe.returncode == 0 and "ready" in probe.stdout: + return + time.sleep(0.5) + raise cmuxError("Timed out waiting for SSH server in docker fixture to become ready") + + +def main() -> int: + if not _docker_available(): + print("SKIP: docker is not available") + return 0 + + cli = _find_cli_binary() + repo_root = Path(__file__).resolve().parents[1] + fixture_dir = repo_root / "tests" / "fixtures" / "ssh-remote" + _must(fixture_dir.is_dir(), f"Missing docker fixture directory: {fixture_dir}") + + temp_dir = Path(tempfile.mkdtemp(prefix="cmux-ssh-cli-relay-")) + image_tag = f"cmux-ssh-test:{secrets.token_hex(4)}" + container_name = f"cmux-ssh-cli-relay-{secrets.token_hex(4)}" + workspace_id = "" + + try: + # Generate SSH key pair + key_path = temp_dir / "id_ed25519" + _run(["ssh-keygen", "-t", "ed25519", "-N", "", "-f", str(key_path)]) + pubkey = (key_path.with_suffix(".pub")).read_text(encoding="utf-8").strip() + _must(bool(pubkey), "Generated SSH public key was empty") + + # Build and start Docker container + _run(["docker", "build", "-t", image_tag, str(fixture_dir)]) + _run([ + "docker", "run", "-d", "--rm", + "--name", container_name, + "-e", f"AUTHORIZED_KEY={pubkey}", + "-e", f"REMOTE_HTTP_PORT={REMOTE_HTTP_PORT}", + "-p", "127.0.0.1::22", + image_tag, + ]) + + port_info = _run(["docker", "port", container_name, "22/tcp"]).stdout + host_ssh_port = _parse_host_port(port_info) + host = "root@127.0.0.1" + _wait_for_ssh(host, host_ssh_port, key_path) + + with cmux(SOCKET_PATH) as client: + # Create SSH workspace (this sets up the reverse socket forward) + payload = _run_cli_json( + cli, + [ + "ssh", + host, + "--name", "docker-cli-relay", + "--port", str(host_ssh_port), + "--identity", str(key_path), + "--ssh-option", "UserKnownHostsFile=/dev/null", + "--ssh-option", "StrictHostKeyChecking=no", + ], + ) + workspace_id = str(payload.get("workspace_id") or "") + workspace_ref = str(payload.get("workspace_ref") or "") + if not workspace_id and workspace_ref.startswith("workspace:"): + listed = client._call("workspace.list", {}) or {} + for row in listed.get("workspaces") or []: + if str(row.get("ref") or "") == workspace_ref: + workspace_id = str(row.get("id") or "") + break + _must(bool(workspace_id), f"cmux ssh output missing workspace_id: {payload}") + + remote_relay_port = payload.get("remote_relay_port") + _must(remote_relay_port is not None, f"cmux ssh output missing remote_relay_port: {payload}") + remote_relay_port = int(remote_relay_port) + _must(49152 <= remote_relay_port <= 65535, f"remote_relay_port should be in ephemeral range: {remote_relay_port}") + remote_socket_addr = f"127.0.0.1:{remote_relay_port}" + + # Wait for daemon to be ready + deadline = time.time() + 45.0 + last_status = {} + while time.time() < deadline: + last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {} + remote = last_status.get("remote") or {} + daemon = remote.get("daemon") or {} + state = str(remote.get("state") or "") + daemon_state = str(daemon.get("state") or "") + if state == "connected" and daemon_state == "ready": + break + time.sleep(0.5) + else: + raise cmuxError(f"Remote daemon did not become ready: {last_status}") + + # Verify the cmux symlink exists on the remote + symlink_check = _ssh_run( + host, host_ssh_port, key_path, + "test -L \"$HOME/.cmux/bin/cmux\" && echo symlink-ok", + check=False, + ) + _must( + "symlink-ok" in symlink_check.stdout, + f"Expected cmux symlink at ~/.cmux/bin/cmux on remote: {symlink_check.stdout} {symlink_check.stderr}", + ) + + # Test 1: cmux ping (v1) + ping_result = _ssh_run( + host, host_ssh_port, key_path, + f"CMUX_SOCKET_PATH={remote_socket_addr} $HOME/.cmux/bin/cmux ping", + check=False, + ) + _must( + ping_result.returncode == 0 and "pong" in ping_result.stdout.lower(), + f"cmux ping failed: rc={ping_result.returncode} stdout={ping_result.stdout!r} stderr={ping_result.stderr!r}", + ) + + # Test 2: cmux list-workspaces --json (v2) + list_ws_result = _ssh_run( + host, host_ssh_port, key_path, + f"CMUX_SOCKET_PATH={remote_socket_addr} $HOME/.cmux/bin/cmux --json list-workspaces", + check=False, + ) + _must( + list_ws_result.returncode == 0, + f"cmux list-workspaces failed: rc={list_ws_result.returncode} stderr={list_ws_result.stderr!r}", + ) + try: + ws_data = json.loads(list_ws_result.stdout.strip()) + _must(isinstance(ws_data, dict), f"list-workspaces should return JSON object: {list_ws_result.stdout!r}") + except json.JSONDecodeError: + raise cmuxError(f"list-workspaces returned invalid JSON: {list_ws_result.stdout!r}") + + # Test 3: cmux new-window (v1) + new_win_result = _ssh_run( + host, host_ssh_port, key_path, + f"CMUX_SOCKET_PATH={remote_socket_addr} $HOME/.cmux/bin/cmux new-window", + check=False, + ) + _must( + new_win_result.returncode == 0, + f"cmux new-window failed: rc={new_win_result.returncode} stderr={new_win_result.stderr!r}", + ) + + # Test 4: cmux rpc system.capabilities (v2 passthrough) + rpc_result = _ssh_run( + host, host_ssh_port, key_path, + f"CMUX_SOCKET_PATH={remote_socket_addr} $HOME/.cmux/bin/cmux rpc system.capabilities", + check=False, + ) + _must( + rpc_result.returncode == 0, + f"cmux rpc system.capabilities failed: rc={rpc_result.returncode} stderr={rpc_result.stderr!r}", + ) + try: + caps_data = json.loads(rpc_result.stdout.strip()) + _must(isinstance(caps_data, dict), f"rpc capabilities should return JSON: {rpc_result.stdout!r}") + except json.JSONDecodeError: + raise cmuxError(f"rpc system.capabilities returned invalid JSON: {rpc_result.stdout!r}") + + # Cleanup + try: + client.close_workspace(workspace_id) + except Exception: + pass + workspace_id = "" + + print("PASS: cmux CLI commands relay correctly over SSH reverse socket forwarding") + return 0 + + finally: + if workspace_id: + try: + with cmux(SOCKET_PATH) as cleanup_client: + cleanup_client.close_workspace(workspace_id) + except Exception: + pass + + _run(["docker", "rm", "-f", container_name], check=False) + _run(["docker", "rmi", "-f", image_tag], check=False) + shutil.rmtree(temp_dir, ignore_errors=True) + + +if __name__ == "__main__": + raise SystemExit(main()) From 946269455d1e78094b23f8165eb25d2cc7e4c8a6 Mon Sep 17 00:00:00 2001 From: Raghav Pillai Date: Mon, 23 Feb 2026 18:42:44 +0200 Subject: [PATCH 05/13] Add CLI relay and docs for remote daemon - Add CLI relay (cli subcommand and cmux symlink) to cmuxd-remote - Implement socket discovery and TCP retry logic for relay connections - Document CLI relay behavior, bootstrap symlink, and reverse TCP forwarding in README - Update remote daemon spec with CLI relay details, status matrix, and PR reference - Note bootstrap creates ~/.cmux/bin/cmux symlink and writes ~/.cmux/socket_addr - Clarify integration steps and relay process behavior (ssh -N -R, ControlPath/ExitOnForwardFailure) --- daemon/remote/README.md | 29 ++++++++++++++++++++++++----- docs/remote-daemon-spec.md | 34 ++++++++++++++++++++++++++++++---- 2 files changed, 54 insertions(+), 9 deletions(-) diff --git a/daemon/remote/README.md b/daemon/remote/README.md index c273ddc5..f84c75f8 100644 --- a/daemon/remote/README.md +++ b/daemon/remote/README.md @@ -1,16 +1,35 @@ # cmuxd-remote (Go) -Go remote daemon for `cmux ssh` bootstrap and capability negotiation. +Go remote daemon for `cmux ssh` bootstrap, capability negotiation, and CLI relay. + +## Commands -Current commands: 1. `cmuxd-remote version` 2. `cmuxd-remote serve --stdio` +3. `cmuxd-remote cli [args...]` — relay cmux commands to the local app over the reverse TCP forward + +When invoked as `cmux` (via symlink created during bootstrap), the binary auto-dispatches to the `cli` subcommand. This is busybox-style argv[0] detection. + +## RPC methods (newline-delimited JSON over stdio) -Current RPC methods (newline-delimited JSON): 1. `hello` 2. `ping` -Current integration in cmux: -1. `workspace.remote.configure` now bootstraps this binary over SSH when missing. +## CLI relay + +The `cli` subcommand (or `cmux` symlink) connects to the local cmux app's socket through an SSH reverse TCP forward and relays commands. It supports both v1 text protocol and v2 JSON-RPC commands. + +Socket discovery order: +1. `--socket ` flag +2. `CMUX_SOCKET_PATH` environment variable +3. `~/.cmux/socket_addr` file (written by the app after the reverse relay establishes) + +For TCP addresses, the CLI retries for up to 15 seconds on connection refused, re-reading `~/.cmux/socket_addr` on each attempt to pick up updated relay ports. + +## Integration in cmux + +1. `workspace.remote.configure` bootstraps this binary over SSH when missing. 2. Client sends `hello` before enabling remote port probing/forwarding. 3. Daemon status/capabilities are exposed in `workspace.remote.status -> remote.daemon`. +4. Bootstrap creates `~/.cmux/bin/cmux` symlink pointing to the daemon binary. +5. A background `ssh -N -R` process reverse-forwards a TCP port to the local cmux Unix socket. The relay address is written to `~/.cmux/socket_addr` on the remote. diff --git a/docs/remote-daemon-spec.md b/docs/remote-daemon-spec.md index 7b3606a1..a5e4ebf3 100644 --- a/docs/remote-daemon-spec.md +++ b/docs/remote-daemon-spec.md @@ -1,8 +1,9 @@ # Remote SSH Living Spec -Last updated: February 21, 2026 -Tracking issue: https://github.com/manaflow-ai/cmux/issues/151 +Last updated: February 23, 2026 +Tracking issue: https://github.com/manaflow-ai/cmux/issues/151 Primary PR: https://github.com/manaflow-ai/cmux/pull/239 +CLI relay PR: https://github.com/manaflow-ai/cmux/pull/374 This document is the working source of truth for: 1. what is implemented now @@ -32,6 +33,18 @@ This is a **living implementation spec** (also called an **execution spec**): a - `DONE` local app probes remote platform, builds/uploads `cmuxd-remote`, and runs `serve --stdio`. - `DONE` daemon `hello` handshake is enforced. - `DONE` bootstrap/probe failures surface actionable details. +- `DONE` bootstrap creates `~/.cmux/bin/cmux` symlink (also tries `/usr/local/bin/cmux`) so `cmux` is available in PATH on the remote. + +### 3.5 CLI Relay (Running cmux Commands From Remote) +- `DONE` `cmuxd-remote` includes a table-driven CLI relay (`cli` subcommand) that maps CLI args to v1 text or v2 JSON-RPC messages. +- `DONE` busybox-style argv[0] detection: when invoked as `cmux` via symlink, auto-dispatches to CLI relay. +- `DONE` background `ssh -N -R 127.0.0.1:PORT:/local/cmux.sock` process reverse-forwards a TCP port to the local cmux socket. Uses TCP instead of Unix socket forwarding because many servers have `AllowStreamLocalForwarding` disabled. +- `DONE` relay process uses `ControlPath=none` (avoids ControlMaster multiplexing and inherited `RemoteForward` directives) and `ExitOnForwardFailure=no` (inherited forwards from user ssh config failing should not kill the relay). +- `DONE` relay address written to `~/.cmux/socket_addr` on the remote with a 3s delay after the relay process starts, giving SSH time to establish the `-R` forward. +- `DONE` Go CLI re-reads `~/.cmux/socket_addr` on each TCP retry to pick up updated relay ports when multiple workspaces overwrite the file. +- `DONE` ephemeral port range (49152-65535) filtered from probe results to exclude relay ports from other workspaces. +- `DONE` multi-workspace port conflict detection uses TCP connect check (`isLoopbackPortReachable`) so ports already forwarded by another workspace are silently skipped instead of flagged as conflicts. +- `DONE` orphaned relay SSH processes from previous app sessions are cleaned up before starting a new relay. ### 3.3 Error Surfacing - `DONE` remote errors are surfaced in sidebar status + logs + notifications. @@ -99,6 +112,7 @@ Recompute effective size on: | M-002 | Remote bootstrap/upload/start + hello handshake | DONE | Current `cmuxd-remote` is minimal (`hello`, `ping`) | | 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 | Existing docker tests currently validate mirroring-era path | +| M-004b | CLI relay: run cmux commands from within SSH sessions | DONE | Reverse TCP forward + Go CLI relay + bootstrap symlink (PR #374) | | M-005 | Remove automatic remote port mirroring path | TODO | Delete probe/listen mirror loop from `WorkspaceRemoteSessionController` | | M-006 | Transport-scoped local proxy broker (SOCKS5 + CONNECT) | TODO | Local component in app/daemon layer | | M-007 | Remote proxy stream RPC in `cmuxd-remote` | TODO | Add `proxy.open/close` and multiplexed stream handling | @@ -118,7 +132,19 @@ Recompute effective size on: | T-004 | reconnect API success/error paths | DONE | | T-005 | retry count visible in daemon error detail | DONE | -### 7.2 Browser Proxy (Target) +### 7.2 CLI Relay + +| ID | Scenario | Status | +|---|---|---| +| C-001 | `cmux ping` from remote session | DONE | +| C-002 | `cmux list-workspaces --json` from remote | DONE | +| C-003 | `cmux new-workspace` from remote | DONE | +| C-004 | `cmux rpc system.capabilities` passthrough | DONE | +| C-005 | TCP retry handles relay not yet established | DONE | +| C-006 | multi-workspace port conflict silent skip | DONE | +| C-007 | ephemeral port filtering excludes relay ports | DONE | + +### 7.3 Browser Proxy (Target) | ID | Scenario | Status | |---|---|---| @@ -128,7 +154,7 @@ Recompute effective size on: | W-004 | reconnect restores browser proxy path automatically | TODO | | W-005 | local proxy bind conflict yields structured `proxy_unavailable` | TODO | -### 7.3 Resize +### 7.4 Resize | ID | Scenario | Status | |---|---|---| From 257afc06233da137f8f41980edb77232c899683a Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 24 Feb 2026 21:20:24 -0800 Subject: [PATCH 06/13] Fix SSH relay/socket regressions and restore session/focus contracts --- CLI/cmux.swift | 442 +++++- Sources/ContentView.swift | 177 ++- Sources/GhosttyTerminalView.swift | 146 +- Sources/Panels/TerminalPanelView.swift | 2 +- Sources/TabManager.swift | 538 +++++++- Sources/TerminalController.swift | 754 ++++++++++- Sources/Workspace.swift | 1194 ++++++++++++++++- Sources/WorkspaceContentView.swift | 103 +- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 47 + .../TabManagerSessionSnapshotTests.swift | 49 + ...erminalControllerSocketSecurityTests.swift | 214 +++ daemon/remote/cmd/cmuxd-remote/cli.go | 40 +- daemon/remote/cmd/cmuxd-remote/cli_test.go | 12 + scripts/reload.sh | 1 + ..._cli_global_flags_and_v1_error_contract.py | 100 ++ tests_v2/test_rename_tab_cli_parity.py | 29 +- tests_v2/test_ssh_remote_cli_relay.py | 118 +- tests_v2/test_workspace_create_initial_env.py | 86 ++ 18 files changed, 3872 insertions(+), 180 deletions(-) create mode 100644 cmuxTests/TabManagerSessionSnapshotTests.swift create mode 100644 cmuxTests/TerminalControllerSocketSecurityTests.swift create mode 100644 tests_v2/test_cli_global_flags_and_v1_error_contract.py create mode 100644 tests_v2/test_workspace_create_initial_env.py diff --git a/CLI/cmux.swift b/CLI/cmux.swift index bdbd264b..26fe7e78 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -1,5 +1,6 @@ import Foundation import Darwin +import Security struct CLIError: Error, CustomStringConvertible { let message: String @@ -235,6 +236,46 @@ enum CLIIDFormat: String { } } +private enum SocketPasswordResolver { + private static let service = "com.cmuxterm.app.socket-control" + private static let account = "local-socket-password" + + static func resolve(explicit: String?) -> String? { + if let explicit = normalized(explicit), !explicit.isEmpty { + return explicit + } + if let env = normalized(ProcessInfo.processInfo.environment["CMUX_SOCKET_PASSWORD"]), !env.isEmpty { + return env + } + return loadFromKeychain() + } + + private static func normalized(_ value: String?) -> String? { + guard let value else { return nil } + let trimmed = value.trimmingCharacters(in: .newlines) + return trimmed.isEmpty ? nil : trimmed + } + + private static func loadFromKeychain() -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + guard status == errSecSuccess else { + return nil + } + guard let data = result as? Data else { + return nil + } + return String(data: data, encoding: .utf8) + } +} + final class SocketClient { private let path: String private var socketFD: Int32 = -1 @@ -253,6 +294,10 @@ final class SocketClient { self.path = path } + var socketPath: String { + path + } + func connect() throws { if socketFD >= 0 { return } @@ -397,11 +442,60 @@ final class SocketClient { struct CMUXCLI { let args: [String] + private static let debugLastSocketHintPath = "/tmp/cmux-last-socket-path" + + private static func normalizedEnvValue(_ value: String?) -> String? { + guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), + !trimmed.isEmpty else { + return nil + } + return trimmed + } + + private static func pathIsSocket(_ path: String) -> Bool { + var st = stat() + guard lstat(path, &st) == 0 else { return false } + return (st.st_mode & S_IFMT) == S_IFSOCK + } + + private static func debugSocketPathFromHintFile() -> String? { +#if DEBUG + guard let raw = try? String(contentsOfFile: debugLastSocketHintPath, encoding: .utf8) else { + return nil + } + guard let hinted = normalizedEnvValue(raw), + hinted.hasPrefix("/tmp/cmux-debug"), + hinted.hasSuffix(".sock"), + pathIsSocket(hinted) else { + return nil + } + return hinted +#else + return nil +#endif + } + + private static func defaultSocketPath(environment: [String: String]) -> String { + if let explicit = normalizedEnvValue(environment["CMUX_SOCKET_PATH"]) { + return explicit + } +#if DEBUG + if let hinted = debugSocketPathFromHintFile() { + return hinted + } + return "/tmp/cmux-debug.sock" +#else + return "/tmp/cmux.sock" +#endif + } + func run() throws { - var socketPath = ProcessInfo.processInfo.environment["CMUX_SOCKET_PATH"] ?? "/tmp/cmux.sock" + let environment = ProcessInfo.processInfo.environment + var socketPath = Self.defaultSocketPath(environment: environment) var jsonOutput = false var idFormatArg: String? = nil var windowId: String? = nil + var socketPasswordArg: String? = nil var index = 1 while index < args.count { @@ -435,6 +529,18 @@ struct CMUXCLI { index += 2 continue } + if arg == "--password" { + guard index + 1 < args.count else { + throw CLIError(message: "--password requires a value") + } + socketPasswordArg = args[index + 1] + index += 2 + continue + } + if arg == "-v" || arg == "--version" { + print(versionSummary()) + return + } if arg == "-h" || arg == "--help" { print(usage()) return @@ -450,6 +556,11 @@ struct CMUXCLI { let command = args[index] let commandArgs = Array(args[(index + 1)...]) + if command == "version" { + print(versionSummary()) + return + } + // Check for --help/-h on subcommands before connecting to the socket, // so help text is available even when cmux is not running. if commandArgs.contains("--help") || commandArgs.contains("-h") { @@ -462,6 +573,14 @@ struct CMUXCLI { try client.connect() defer { client.close() } + if let socketPassword = SocketPasswordResolver.resolve(explicit: socketPasswordArg) { + let authResponse = try client.send(command: "auth \(socketPassword)") + if authResponse.hasPrefix("ERROR:"), + !authResponse.contains("Unknown command 'auth'") { + throw CLIError(message: authResponse) + } + } + let idFormat = try resolvedIDFormat(jsonOutput: jsonOutput, raw: idFormatArg) // If the user explicitly targets a window, focus it first so commands route correctly. @@ -472,7 +591,7 @@ struct CMUXCLI { switch command { case "ping": - let response = try client.send(command: "ping") + let response = try sendV1Command("ping", client: client) print(response) case "capabilities": @@ -515,7 +634,7 @@ struct CMUXCLI { print(jsonString(formatIDs(response, mode: idFormat))) case "list-windows": - let response = try client.send(command: "list_windows") + let response = try sendV1Command("list_windows", client: client) if jsonOutput { let windows = parseWindows(response) let payload = windows.map { item -> [String: Any] in @@ -534,7 +653,7 @@ struct CMUXCLI { } case "current-window": - let response = try client.send(command: "current_window") + let response = try sendV1Command("current_window", client: client) if jsonOutput { print(jsonString(["window_id": response])) } else { @@ -542,21 +661,21 @@ struct CMUXCLI { } case "new-window": - let response = try client.send(command: "new_window") + let response = try sendV1Command("new_window", client: client) print(response) case "focus-window": guard let target = optionValue(commandArgs, name: "--window") else { throw CLIError(message: "focus-window requires --window") } - let response = try client.send(command: "focus_window \(target)") + let response = try sendV1Command("focus_window \(target)", client: client) print(response) case "close-window": guard let target = optionValue(commandArgs, name: "--window") else { throw CLIError(message: "close-window requires --window") } - let response = try client.send(command: "close_window \(target)") + let response = try sendV1Command("close_window \(target)", client: client) print(response) case "move-workspace-to-window": @@ -589,6 +708,9 @@ struct CMUXCLI { case "tab-action": try runTabAction(commandArgs: commandArgs, client: client, jsonOutput: jsonOutput, idFormat: idFormat, windowOverride: windowId) + case "rename-tab": + try runRenameTab(commandArgs: commandArgs, client: client, jsonOutput: jsonOutput, idFormat: idFormat, windowOverride: windowId) + case "list-workspaces": let payload = try client.sendV2(method: "workspace.list") if jsonOutput { @@ -626,7 +748,7 @@ struct CMUXCLI { if let unknown = remaining.first(where: { $0.hasPrefix("--") }) { throw CLIError(message: "new-workspace: unknown flag '\(unknown)'. Known flags: --command ") } - let response = try client.send(command: "new_workspace") + let response = try sendV1Command("new_workspace", client: client) print(response) if let commandText = commandOpt { guard response.hasPrefix("OK ") else { @@ -771,11 +893,11 @@ struct CMUXCLI { guard let direction = rem1.first else { throw CLIError(message: "drag-surface-to-split requires a direction") } - let response = try client.send(command: "drag_surface_to_split \(surface) \(direction)") + let response = try sendV1Command("drag_surface_to_split \(surface) \(direction)", client: client) print(response) case "refresh-surfaces": - let response = try client.send(command: "refresh_surfaces") + let response = try sendV1Command("refresh_surfaces", client: client) print(response) case "surface-health": @@ -891,7 +1013,7 @@ struct CMUXCLI { printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["workspace"])) case "current-workspace": - let response = try client.send(command: "current_workspace") + let response = try sendV1Command("current_workspace", client: client) if jsonOutput { print(jsonString(["workspace_id": response])) } else { @@ -1015,11 +1137,11 @@ struct CMUXCLI { let targetSurface = try resolveSurfaceId(surfaceArg, workspaceId: targetWorkspace, client: client) let payload = "\(title)|\(subtitle)|\(body)" - let response = try client.send(command: "notify_target \(targetWorkspace) \(targetSurface) \(payload)") + let response = try sendV1Command("notify_target \(targetWorkspace) \(targetSurface) \(payload)", client: client) print(response) case "list-notifications": - let response = try client.send(command: "list_notifications") + let response = try sendV1Command("list_notifications", client: client) if jsonOutput { let notifications = parseNotifications(response) let payload = notifications.map { item in @@ -1040,7 +1162,7 @@ struct CMUXCLI { } case "clear-notifications": - let response = try client.send(command: "clear_notifications") + let response = try sendV1Command("clear_notifications", client: client) print(response) case "claude-hook": @@ -1048,11 +1170,11 @@ struct CMUXCLI { case "set-app-focus": guard let value = commandArgs.first else { throw CLIError(message: "set-app-focus requires a value") } - let response = try client.send(command: "set_app_focus \(value)") + let response = try sendV1Command("set_app_focus \(value)", client: client) print(response) case "simulate-app-active": - let response = try client.send(command: "simulate_app_active") + let response = try sendV1Command("simulate_app_active", client: client) print(response) case "capture-pane", @@ -1140,6 +1262,14 @@ struct CMUXCLI { return .refs } + private func sendV1Command(_ command: String, client: SocketClient) throws -> String { + let response = try client.send(command: command) + if response.hasPrefix("ERROR:") { + throw CLIError(message: response) + } + return response + } + private func formatIDs(_ object: Any, mode: CLIIDFormat) -> Any { switch object { case let dict as [String: Any]: @@ -1738,6 +1868,55 @@ struct CMUXCLI { printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: summaryParts.joined(separator: " ")) } + private func runRenameTab( + commandArgs: [String], + client: SocketClient, + jsonOutput: Bool, + idFormat: CLIIDFormat, + windowOverride: String? + ) throws { + let (workspaceOpt, rem0) = parseOption(commandArgs, name: "--workspace") + let (tabOpt, rem1) = parseOption(rem0, name: "--tab") + let (surfaceOpt, rem2) = parseOption(rem1, name: "--surface") + let (titleOpt, rem3) = parseOption(rem2, name: "--title") + + if rem3.contains("--action") { + throw CLIError(message: "rename-tab does not accept --action (it always performs rename)") + } + if let unknown = rem3.first(where: { $0.hasPrefix("--") && $0 != "--" }) { + throw CLIError(message: "rename-tab: unknown flag '\(unknown)'") + } + + let inferredTitle = rem3 + .dropFirst(rem3.first == "--" ? 1 : 0) + .joined(separator: " ") + .trimmingCharacters(in: .whitespacesAndNewlines) + let title = (titleOpt ?? (inferredTitle.isEmpty ? nil : inferredTitle))? + .trimmingCharacters(in: .whitespacesAndNewlines) + + guard let title, !title.isEmpty else { + throw CLIError(message: "rename-tab requires a title") + } + + var forwarded: [String] = ["--action", "rename", "--title", title] + if let workspaceOpt { + forwarded += ["--workspace", workspaceOpt] + } + if let tabOpt { + forwarded += ["--tab", tabOpt] + } else if let surfaceOpt { + forwarded += ["--surface", surfaceOpt] + } + + try runTabAction( + commandArgs: forwarded, + client: client, + jsonOutput: jsonOutput, + idFormat: idFormat, + windowOverride: windowOverride + ) + } + private struct SSHCommandOptions { let destination: String let port: Int? @@ -1760,14 +1939,15 @@ struct CMUXCLI { jsonOutput: Bool, idFormat: CLIIDFormat ) throws { - let localSocketPath = ProcessInfo.processInfo.environment["CMUX_SOCKET_PATH"] ?? "/tmp/cmux.sock" + // Use the socket path from this invocation (supports --socket overrides). + let localSocketPath = client.socketPath let remoteRelayPort = generateRemoteRelayPort() let sshOptions = try parseSSHCommandOptions(commandArgs, localSocketPath: localSocketPath, remoteRelayPort: remoteRelayPort) let sshCommand = buildSSHCommandText(sshOptions) let shellFeaturesValue = scopedGhosttyShellFeaturesValue() let sshStartupCommand = buildSSHStartupCommand(sshCommand: sshCommand, shellFeatures: shellFeaturesValue) - var workspaceCreateParams: [String: Any] = [ + let workspaceCreateParams: [String: Any] = [ "initial_command": sshStartupCommand, ] @@ -3324,6 +3504,29 @@ fi cmux tab-action --action close-right cmux tab-action --tab tab:2 --action rename --title "build logs" """ + case "rename-tab": + return """ + Usage: cmux rename-tab [--workspace ] [--tab ] [--surface ] [--] + + Compatibility alias for tab-action rename. + + Resolution order for target tab: + 1) --tab + 2) --surface + 3) $CMUX_TAB_ID / $CMUX_SURFACE_ID + 4) currently focused tab (optionally within --workspace) + + Flags: + --workspace <id|ref> Workspace context (default: current/$CMUX_WORKSPACE_ID) + --tab <id|ref> Tab target (supports tab:<n> or surface:<n>) + --surface <id|ref> Alias for --tab + --title <text> Explicit title (or use trailing positional title) + + Examples: + cmux rename-tab "build logs" + cmux rename-tab --tab tab:3 "staging server" + cmux rename-tab --workspace workspace:2 --surface surface:5 --title "agent run" + """ case "new-workspace": return """ Usage: cmux new-workspace @@ -4580,20 +4783,214 @@ fi return truncate(normalized, maxLength: 180) } + private func versionSummary() -> String { + let info = resolvedVersionInfo() + if let version = info["CFBundleShortVersionString"], let build = info["CFBundleVersion"] { + return "cmux \(version) (\(build))" + } + if let version = info["CFBundleShortVersionString"] { + return "cmux \(version)" + } + if let build = info["CFBundleVersion"] { + return "cmux build \(build)" + } + return "cmux version unknown" + } + + private func resolvedVersionInfo() -> [String: String] { + if let main = versionInfo(from: Bundle.main.infoDictionary) { + return main + } + + for plistURL in candidateInfoPlistURLs() { + guard let data = try? Data(contentsOf: plistURL), + let raw = try? PropertyListSerialization.propertyList(from: data, options: [], format: nil), + let dictionary = raw as? [String: Any], + let parsed = versionInfo(from: dictionary) + else { + continue + } + return parsed + } + + if let fromProject = versionInfoFromProjectFile() { + return fromProject + } + + return [:] + } + + private func versionInfo(from dictionary: [String: Any]?) -> [String: String]? { + guard let dictionary else { return nil } + + var info: [String: String] = [:] + if let version = dictionary["CFBundleShortVersionString"] as? String { + let trimmed = version.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty && !trimmed.contains("$(") { + info["CFBundleShortVersionString"] = trimmed + } + } + if let build = dictionary["CFBundleVersion"] as? String { + let trimmed = build.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty && !trimmed.contains("$(") { + info["CFBundleVersion"] = trimmed + } + } + return info.isEmpty ? nil : info + } + + private func versionInfoFromProjectFile() -> [String: String]? { + guard let executable = currentExecutablePath(), !executable.isEmpty else { + return nil + } + + let fileManager = FileManager.default + var current = URL(fileURLWithPath: executable) + .resolvingSymlinksInPath() + .standardizedFileURL + .deletingLastPathComponent() + + while true { + let projectFile = current.appendingPathComponent("GhosttyTabs.xcodeproj/project.pbxproj") + if fileManager.fileExists(atPath: projectFile.path), + let contents = try? String(contentsOf: projectFile, encoding: .utf8) { + var info: [String: String] = [:] + if let version = firstProjectSetting("MARKETING_VERSION", in: contents) { + info["CFBundleShortVersionString"] = version + } + if let build = firstProjectSetting("CURRENT_PROJECT_VERSION", in: contents) { + info["CFBundleVersion"] = build + } + if !info.isEmpty { + return info + } + } + + let parent = current.deletingLastPathComponent() + if parent.path == current.path { + break + } + current = parent + } + + return nil + } + + private func firstProjectSetting(_ key: String, in source: String) -> String? { + let pattern = NSRegularExpression.escapedPattern(for: key) + "\\s*=\\s*([^;]+);" + guard let regex = try? NSRegularExpression(pattern: pattern) else { + return nil + } + let searchRange = NSRange(source.startIndex..<source.endIndex, in: source) + guard let match = regex.firstMatch(in: source, options: [], range: searchRange), + match.numberOfRanges > 1, + let valueRange = Range(match.range(at: 1), in: source) + else { + return nil + } + let value = source[valueRange] + .trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingCharacters(in: CharacterSet(charactersIn: "\"")) + guard !value.isEmpty, !value.contains("$(") else { + return nil + } + return value + } + + private func candidateInfoPlistURLs() -> [URL] { + guard let executable = currentExecutablePath(), !executable.isEmpty else { + return [] + } + + let fileManager = FileManager.default + let executableURL = URL(fileURLWithPath: executable) + .resolvingSymlinksInPath() + .standardizedFileURL + + var candidates: [URL] = [] + var current = executableURL.deletingLastPathComponent() + while true { + if current.pathExtension == "app" { + candidates.append(current.appendingPathComponent("Contents/Info.plist")) + } + if current.lastPathComponent == "Contents" { + candidates.append(current.appendingPathComponent("Info.plist")) + } + + let projectMarker = current.appendingPathComponent("GhosttyTabs.xcodeproj/project.pbxproj") + let repoInfo = current.appendingPathComponent("Resources/Info.plist") + if fileManager.fileExists(atPath: projectMarker.path), + fileManager.fileExists(atPath: repoInfo.path) { + candidates.append(repoInfo) + break + } + + let parent = current.deletingLastPathComponent() + if parent.path == current.path { + break + } + current = parent + } + + let searchRoots = [ + executableURL.deletingLastPathComponent(), + executableURL.deletingLastPathComponent().deletingLastPathComponent() + ] + for root in searchRoots { + guard let entries = try? fileManager.contentsOfDirectory( + at: root, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles] + ) else { + continue + } + for entry in entries where entry.pathExtension == "app" { + candidates.append(entry.appendingPathComponent("Contents/Info.plist")) + } + } + + var seen: Set<String> = [] + return candidates.filter { url in + let path = url.path + guard !path.isEmpty else { return false } + guard seen.insert(path).inserted else { return false } + return fileManager.fileExists(atPath: path) + } + } + + private func currentExecutablePath() -> String? { + var size: UInt32 = 0 + _ = _NSGetExecutablePath(nil, &size) + if size > 0 { + var buffer = Array<CChar>(repeating: 0, count: Int(size)) + if _NSGetExecutablePath(&buffer, &size) == 0 { + let path = String(cString: buffer).trimmingCharacters(in: .whitespacesAndNewlines) + if !path.isEmpty { + return path + } + } + } + return Bundle.main.executableURL?.path ?? args.first + } + private func usage() -> String { return """ cmux - control cmux via Unix socket Usage: - cmux [--socket PATH] [--window WINDOW] [--json] [--id-format refs|uuids|both] <command> [options] + cmux [--socket PATH] [--window WINDOW] [--password PASSWORD] [--json] [--id-format refs|uuids|both] [--version] <command> [options] Handle Inputs: For most v2-backed commands you can use UUIDs, short refs (window:1/workspace:2/pane:3/surface:4), or indexes. `tab-action` also accepts `tab:<n>` in addition to `surface:<n>`. Output defaults to refs; pass --id-format uuids or --id-format both to include UUIDs. + Socket Auth: + --password takes precedence, then CMUX_SOCKET_PASSWORD env var, then keychain password saved in Settings. + Commands: ping + version capabilities identify [--workspace <id|ref|index>] [--surface <id|ref|index>] [--no-caller] list-windows @@ -4617,6 +5014,7 @@ fi move-surface --surface <id|ref|index> [--pane <id|ref|index>] [--workspace <id|ref|index>] [--window <id|ref|index>] [--before <id|ref|index>] [--after <id|ref|index>] [--index <n>] [--focus <true|false>] reorder-surface --surface <id|ref|index> (--index <n> | --before <id|ref|index> | --after <id|ref|index>) tab-action --action <name> [--tab <id|ref|index>] [--surface <id|ref|index>] [--workspace <id|ref|index>] [--title <text>] [--url <url>] + rename-tab [--workspace <id|ref>] [--tab <id|ref>] [--surface <id|ref>] <title> drag-surface-to-split --surface <id|ref> <left|right|up|down> refresh-surfaces surface-health [--workspace <id|ref>] @@ -4707,9 +5105,11 @@ fi Environment: CMUX_WORKSPACE_ID Auto-set in cmux terminals. Used as default --workspace for ALL commands (send, list-panels, new-split, notify, etc.). - CMUX_TAB_ID Optional alias used by `tab-action` as default --tab. + CMUX_TAB_ID Optional alias used by `tab-action`/`rename-tab` as default --tab. CMUX_SURFACE_ID Auto-set in cmux terminals. Used as default --surface. - CMUX_SOCKET_PATH Override the default Unix socket path (/tmp/cmux.sock). + CMUX_SOCKET_PATH Override the default Unix socket path. + Debug CLI defaults: /tmp/cmux-last-socket-path -> /tmp/cmux-debug.sock. + Release CLI default: /tmp/cmux.sock. """ } } diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index a6dd9174..bf36864a 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -5,6 +5,60 @@ import ObjectiveC import UniformTypeIdentifiers import WebKit +func sidebarActiveForegroundNSColor( + opacity: CGFloat, + appAppearance: NSAppearance? = NSApp?.effectiveAppearance +) -> NSColor { + let clampedOpacity = max(0, min(opacity, 1)) + let bestMatch = appAppearance?.bestMatch(from: [.darkAqua, .aqua]) + let baseColor: NSColor = (bestMatch == .darkAqua) ? .white : .black + return baseColor.withAlphaComponent(clampedOpacity) +} + +func cmuxAccentNSColor(for colorScheme: ColorScheme) -> NSColor { + switch colorScheme { + case .dark: + return NSColor( + srgbRed: 0, + green: 145.0 / 255.0, + blue: 1.0, + alpha: 1.0 + ) + default: + return NSColor( + srgbRed: 0, + green: 136.0 / 255.0, + blue: 1.0, + alpha: 1.0 + ) + } +} + +func cmuxAccentNSColor(for appAppearance: NSAppearance?) -> NSColor { + let bestMatch = appAppearance?.bestMatch(from: [.darkAqua, .aqua]) + let scheme: ColorScheme = (bestMatch == .darkAqua) ? .dark : .light + return cmuxAccentNSColor(for: scheme) +} + +func cmuxAccentNSColor() -> NSColor { + NSColor(name: nil) { appearance in + cmuxAccentNSColor(for: appearance) + } +} + +func cmuxAccentColor() -> Color { + Color(nsColor: cmuxAccentNSColor()) +} + +func sidebarSelectedWorkspaceBackgroundNSColor(for colorScheme: ColorScheme) -> NSColor { + cmuxAccentNSColor(for: colorScheme) +} + +func sidebarSelectedWorkspaceForegroundNSColor(opacity: CGFloat) -> NSColor { + let clampedOpacity = max(0, min(opacity, 1)) + return NSColor.white.withAlphaComponent(clampedOpacity) +} + struct ShortcutHintPillBackground: View { var emphasis: Double = 1.0 @@ -152,13 +206,29 @@ enum WindowGlassEffect { } final class SidebarState: ObservableObject { - @Published var isVisible: Bool = true + @Published var isVisible: Bool + @Published var persistedWidth: CGFloat + + init(isVisible: Bool = true, persistedWidth: CGFloat = CGFloat(SessionPersistencePolicy.defaultSidebarWidth)) { + self.isVisible = isVisible + let sanitized = SessionPersistencePolicy.sanitizedSidebarWidth(Double(persistedWidth)) + self.persistedWidth = CGFloat(sanitized) + } func toggle() { isVisible.toggle() } } +enum SidebarResizeInteraction { + static let handleWidth: CGFloat = 6 + static let hitInset: CGFloat = 3 + + static var hitWidthPerSide: CGFloat { + hitInset + (handleWidth / 2) + } +} + // MARK: - File Drop Overlay enum DragOverlayRoutingPolicy { @@ -615,6 +685,7 @@ final class FileDropOverlayView: NSView { } var fileDropOverlayKey: UInt8 = 0 +let commandPaletteOverlayContainerIdentifier = NSUserInterfaceItemIdentifier("cmux.commandPalette.overlay.container") enum WorkspaceMountPolicy { // Keep only the selected workspace mounted to minimize layer-tree traversal. @@ -833,7 +904,8 @@ struct ContentView: View { workspace: tab, isWorkspaceVisible: isVisible, isWorkspaceInputActive: isInputActive, - workspacePortalPriority: portalPriority + workspacePortalPriority: portalPriority, + onThemeRefreshRequest: nil ) .opacity(isVisible ? 1 : 0) .allowsHitTesting(isSelectedWorkspace) @@ -2145,6 +2217,31 @@ private struct SidebarEmptyArea: View { } } +struct SidebarRemoteErrorCopyEntry: Equatable { + let workspaceTitle: String + let target: String + let detail: String +} + +enum SidebarRemoteErrorCopySupport { + static func menuLabel(for entries: [SidebarRemoteErrorCopyEntry]) -> String? { + guard !entries.isEmpty else { return nil } + return entries.count == 1 ? "Copy Error" : "Copy Errors" + } + + static func clipboardText(for entries: [SidebarRemoteErrorCopyEntry]) -> String? { + guard !entries.isEmpty else { return nil } + if entries.count == 1 { + let entry = entries[0] + return "SSH error (\(entry.target)): \(entry.detail)" + } + + return entries.enumerated().map { index, entry in + "\(index + 1). \(entry.workspaceTitle) (\(entry.target)): \(entry.detail)" + }.joined(separator: "\n") + } +} + private struct TabItemView: View { @EnvironmentObject var tabManager: TabManager @EnvironmentObject var notificationStore: TerminalNotificationStore @@ -2446,6 +2543,7 @@ private struct TabItemView: View { let targetIds = contextTargetIds() let targetWorkspaces = targetIds.compactMap { id in tabManager.tabs.first(where: { $0.id == id }) } let remoteTargetWorkspaces = targetWorkspaces.filter { $0.isRemoteWorkspace } + let remoteWorkspaceErrors = remoteErrorCopyEntries(in: remoteTargetWorkspaces) let reconnectLabel = remoteTargetWorkspaces.count > 1 ? "Reconnect Workspaces" : "Reconnect Workspace" let disconnectLabel = remoteTargetWorkspaces.count > 1 ? "Disconnect Workspaces" : "Disconnect Workspace" let shouldPin = !tab.isPinned @@ -2490,6 +2588,13 @@ private struct TabItemView: View { } } .disabled(remoteTargetWorkspaces.allSatisfy { $0.remoteConnectionState == .disconnected }) + + if let copyErrorLabel = SidebarRemoteErrorCopySupport.menuLabel(for: remoteWorkspaceErrors), + let copyErrorText = SidebarRemoteErrorCopySupport.clipboardText(for: remoteWorkspaceErrors) { + Button(copyErrorLabel) { + copyTextToPasteboard(copyErrorText) + } + } } Divider() @@ -2682,6 +2787,27 @@ private struct TabItemView: View { return notificationStore.notifications.contains { targetSet.contains($0.tabId) && $0.isRead } } + private func remoteErrorCopyEntries(in workspaces: [Tab]) -> [SidebarRemoteErrorCopyEntry] { + workspaces.compactMap { workspace in + guard workspace.remoteConnectionState == .error else { return nil } + guard let detail = workspace.remoteConnectionDetail?.trimmingCharacters(in: .whitespacesAndNewlines), + !detail.isEmpty else { + return nil + } + return SidebarRemoteErrorCopyEntry( + workspaceTitle: workspace.title, + target: workspace.remoteDisplayTarget ?? "remote host", + detail: detail + ) + } + } + + private func copyTextToPasteboard(_ text: String) { + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(text, forType: .string) + } + private func syncSelectionAfterMutation() { let existingIds = Set(tabManager.tabs.map { $0.id }) selectedTabIds = selectedTabIds.filter { existingIds.contains($0) } @@ -3472,7 +3598,7 @@ private struct DraggableFolderIconRepresentable: NSViewRepresentable { } } -private final class DraggableFolderNSView: NSView, NSDraggingSource { +final class DraggableFolderNSView: NSView, NSDraggingSource { var directory: String private var imageView: NSImageView! @@ -3598,6 +3724,20 @@ private final class DraggableFolderNSView: NSView, NSDraggingSource { } } +func temporarilyDisableWindowDragging(window: NSWindow?) -> Bool? { + guard let window else { return nil } + let wasMovable = window.isMovable + if wasMovable { + window.isMovable = false + } + return wasMovable +} + +func restoreWindowDragging(window: NSWindow?, previousMovableState: Bool?) { + guard let window, let previousMovableState else { return } + window.isMovable = previousMovableState +} + /// Wrapper view that tries NSGlassEffectView (macOS 26+) when available or requested private struct SidebarVisualEffectBackground: NSViewRepresentable { let material: NSVisualEffectView.Material @@ -3677,11 +3817,16 @@ private struct SidebarVisualEffectBackground: NSViewRepresentable { /// Reads the leading inset required to clear traffic lights + left titlebar accessories. +final class TitlebarLeadingInsetPassthroughView: NSView { + override var mouseDownCanMoveWindow: Bool { false } + override func hitTest(_ point: NSPoint) -> NSView? { nil } +} + private struct TitlebarLeadingInsetReader: NSViewRepresentable { @Binding var inset: CGFloat func makeNSView(context: Context) -> NSView { - let view = NSView() + let view = TitlebarLeadingInsetPassthroughView() view.setFrameSize(.zero) return view } @@ -3967,3 +4112,27 @@ extension NSColor { ) } } + +extension ContentView { + static func commandPaletteScrollPositionAnchor( + selectedIndex: Int, + resultCount: Int + ) -> UnitPoint? { + guard resultCount > 0 else { return nil } + if selectedIndex <= 0 { + return UnitPoint.top + } + if selectedIndex >= resultCount - 1 { + return UnitPoint.bottom + } + return nil + } + + static func shouldRestoreBrowserAddressBarAfterCommandPaletteDismiss( + focusedPanelIsBrowser: Bool, + focusedBrowserAddressBarPanelId: UUID?, + focusedPanelId: UUID + ) -> Bool { + focusedPanelIsBrowser && focusedBrowserAddressBarPanelId == focusedPanelId + } +} diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 028f9fb2..f7087a21 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -124,6 +124,79 @@ enum TerminalOpenURLTarget: Equatable { } } +enum GhosttyDefaultBackgroundUpdateScope: Int { + case unscoped = 0 + case app = 1 + case surface = 2 + + var logLabel: String { + switch self { + case .unscoped: return "unscoped" + case .app: return "app" + case .surface: return "surface" + } + } +} + +/// Coalesces Ghostty background notifications so consumers observe the latest +/// runtime background after a short burst window. +final class GhosttyDefaultBackgroundNotificationDispatcher { + private let coalescer: NotificationBurstCoalescer + private let postNotification: ([AnyHashable: Any]) -> Void + private var pendingUserInfo: [AnyHashable: Any]? + private var pendingEventId: UInt64 = 0 + private var pendingSource: String = "unspecified" + private let logEvent: ((String) -> Void)? + + init( + delay: TimeInterval = 1.0 / 30.0, + logEvent: ((String) -> Void)? = nil, + postNotification: @escaping ([AnyHashable: Any]) -> Void = { userInfo in + NotificationCenter.default.post( + name: .ghosttyDefaultBackgroundDidChange, + object: nil, + userInfo: userInfo + ) + } + ) { + coalescer = NotificationBurstCoalescer(delay: delay) + self.logEvent = logEvent + self.postNotification = postNotification + } + + func signal(backgroundColor: NSColor, opacity: Double, eventId: UInt64, source: String) { + let signalOnMain = { [self] in + pendingEventId = eventId + pendingSource = source + pendingUserInfo = [ + GhosttyNotificationKey.backgroundColor: backgroundColor, + GhosttyNotificationKey.backgroundOpacity: opacity, + GhosttyNotificationKey.backgroundEventId: NSNumber(value: eventId), + GhosttyNotificationKey.backgroundSource: source, + ] + logEvent?( + "bg notify queued id=\(eventId) source=\(source) color=\(backgroundColor.hexString()) opacity=\(String(format: "%.3f", opacity))" + ) + coalescer.signal { [self] in + guard let userInfo = pendingUserInfo else { return } + let eventId = pendingEventId + let source = pendingSource + pendingUserInfo = nil + logEvent?("bg notify flushed id=\(eventId) source=\(source)") + logEvent?("bg notify posting id=\(eventId) source=\(source)") + postNotification(userInfo) + logEvent?("bg notify posted id=\(eventId) source=\(source)") + } + } + + if Thread.isMainThread { + signalOnMain() + } else { + DispatchQueue.main.async(execute: signalOnMain) + } + } +} + func resolveTerminalOpenURLTarget(_ rawValue: String) -> TerminalOpenURLTarget? { let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } @@ -477,6 +550,20 @@ class GhosttyApp { return true } + static func shouldApplyDefaultBackgroundUpdate( + currentScope: GhosttyDefaultBackgroundUpdateScope, + incomingScope: GhosttyDefaultBackgroundUpdateScope + ) -> Bool { + incomingScope.rawValue >= currentScope.rawValue + } + + static func shouldReloadConfigurationForAppearanceChange( + previousColorScheme: GhosttyConfig.ColorSchemePreference?, + currentColorScheme: GhosttyConfig.ColorSchemePreference + ) -> Bool { + previousColorScheme != currentColorScheme + } + private func loadLegacyGhosttyConfigIfNeeded(_ config: ghostty_config_t) { #if os(macOS) // Ghostty 1.3+ prefers `config.ghostty`, but some users still have their real @@ -524,7 +611,7 @@ class GhosttyApp { } } - func reloadConfiguration(soft: Bool = false) { + func reloadConfiguration(soft: Bool = false, source _: String = "unspecified") { guard let app else { return } if soft, let config { ghostty_app_update_config(app, config) @@ -2146,6 +2233,17 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { var keyTextAccumulatorForTesting: [String]? { keyTextAccumulator } + + // Test-only IME point override so firstRect behavior can be regression tested. + private var imePointOverrideForTesting: (x: Double, y: Double, width: Double, height: Double)? + + func setIMEPointForTesting(x: Double, y: Double, width: Double, height: Double) { + imePointOverrideForTesting = (x, y, width, height) + } + + func clearIMEPointForTesting() { + imePointOverrideForTesting = nil + } #endif #if DEBUG @@ -2916,6 +3014,8 @@ enum GhosttyNotificationKey { static let title = "ghostty.title" static let backgroundColor = "ghostty.backgroundColor" static let backgroundOpacity = "ghostty.backgroundOpacity" + static let backgroundEventId = "ghostty.backgroundEventId" + static let backgroundSource = "ghostty.backgroundSource" } extension Notification.Name { @@ -2970,6 +3070,7 @@ final class GhosttySurfaceScrollView: NSView { private let notificationRingLayer: CAShapeLayer private let flashOverlayView: GhosttyFlashOverlayView private let flashLayer: CAShapeLayer + private var hasSearchOverlay = false private var observers: [NSObjectProtocol] = [] private var windowObservers: [NSObjectProtocol] = [] private var isLiveScrolling = false @@ -3353,6 +3454,16 @@ final class GhosttySurfaceScrollView: NSView { CATransaction.commit() } + func setSearchOverlay(searchState: TerminalSurface.SearchState?) { + if !Thread.isMainThread { + DispatchQueue.main.async { [weak self] in + self?.setSearchOverlay(searchState: searchState) + } + return + } + hasSearchOverlay = (searchState != nil) + } + private func dropZoneOverlayFrame(for zone: DropZone, in size: CGSize) -> CGRect { let padding: CGFloat = 4 switch zone { @@ -3554,6 +3665,10 @@ final class GhosttySurfaceScrollView: NSView { } } + func refreshSurfaceNow() { + surfaceView.forceRefreshSurface() + } + func setActive(_ active: Bool) { let wasActive = isActive isActive = active @@ -3633,6 +3748,10 @@ final class GhosttySurfaceScrollView: NSView { ) } + func debugHasSearchOverlay() -> Bool { + hasSearchOverlay + } + #endif /// Handle file/URL drops, forwarding to the terminal as shell-escaped paths. @@ -4237,15 +4356,26 @@ extension GhosttyNSView: NSTextInputClient { var y: Double = 0 var w: Double = 0 var h: Double = 0 +#if DEBUG + if let override = imePointOverrideForTesting { + x = override.x + y = override.y + w = override.width + h = override.height + } else if let surface = surface { + ghostty_surface_ime_point(surface, &x, &y, &w, &h) + } +#else if let surface = surface { ghostty_surface_ime_point(surface, &x, &y, &w, &h) } +#endif // Ghostty coordinates are top-left origin; AppKit expects bottom-left. let viewRect = NSRect( x: x, y: frame.size.height - y, - width: 0, + width: max(w, cellSize.width), height: max(h, cellSize.height) ) let winRect = convert(viewRect, to: nil) @@ -4291,6 +4421,7 @@ struct GhosttyTerminalView: NSViewRepresentable { var portalZPriority: Int = 0 var showsInactiveOverlay: Bool = false var showsUnreadNotificationRing: Bool = false + var searchState: TerminalSurface.SearchState? = nil var inactiveOverlayColor: NSColor = .clear var inactiveOverlayOpacity: Double = 0 var reattachToken: UInt64 = 0 @@ -4344,6 +4475,16 @@ struct GhosttyTerminalView: NSViewRepresentable { Coordinator() } + static func shouldApplyImmediateHostedStateUpdate( + hostWindowAttached: Bool, + hostedViewHasSuperview: Bool, + isBoundToCurrentHost: Bool + ) -> Bool { + if !hostWindowAttached { return true } + if isBoundToCurrentHost { return true } + return !hostedViewHasSuperview + } + func makeNSView(context: Context) -> NSView { let container = HostContainerView() container.wantsLayer = false @@ -4394,6 +4535,7 @@ struct GhosttyTerminalView: NSViewRepresentable { visible: showsInactiveOverlay ) hostedView.setNotificationRing(visible: showsUnreadNotificationRing) + hostedView.setSearchOverlay(searchState: searchState) hostedView.setFocusHandler { onFocus?(terminalSurface.id) } hostedView.setTriggerFlashHandler(onTriggerFlash) let forwardedDropZone = isVisibleInUI ? paneDropZone : nil diff --git a/Sources/Panels/TerminalPanelView.swift b/Sources/Panels/TerminalPanelView.swift index 200104df..a98c5338 100644 --- a/Sources/Panels/TerminalPanelView.swift +++ b/Sources/Panels/TerminalPanelView.swift @@ -24,9 +24,9 @@ struct TerminalPanelView: View { portalZPriority: portalPriority, showsInactiveOverlay: isSplit && !isFocused, showsUnreadNotificationRing: hasUnreadNotification, + searchState: panel.searchState, inactiveOverlayColor: appearance.unfocusedOverlayNSColor, inactiveOverlayOpacity: appearance.unfocusedOverlayOpacity, - searchState: panel.searchState, reattachToken: panel.viewReattachToken, onFocus: { _ in onFocus() }, onTriggerFlash: onTriggerFlash diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 31f12387..243eca8d 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -51,6 +51,267 @@ enum WorkspaceAutoReorderSettings { } } +enum SidebarBranchLayoutSettings { + static let key = "sidebarBranchVerticalLayout" + static let defaultVerticalLayout = true + + static func usesVerticalLayout(defaults: UserDefaults = .standard) -> Bool { + if defaults.object(forKey: key) == nil { + return defaultVerticalLayout + } + return defaults.bool(forKey: key) + } +} + +enum SidebarActiveTabIndicatorStyle: String, CaseIterable, Identifiable { + case leftRail + case solidFill + + var id: String { rawValue } + + var displayName: String { + switch self { + case .leftRail: + return "Left Rail" + case .solidFill: + return "Solid Fill" + } + } +} + +enum SidebarActiveTabIndicatorSettings { + static let styleKey = "sidebarActiveTabIndicatorStyle" + static let defaultStyle: SidebarActiveTabIndicatorStyle = .leftRail + + static func resolvedStyle(rawValue: String?) -> SidebarActiveTabIndicatorStyle { + guard let rawValue else { return defaultStyle } + if let style = SidebarActiveTabIndicatorStyle(rawValue: rawValue) { + return style + } + + // Legacy values from earlier iterations map to the closest modern option. + switch rawValue { + case "rail": + return .leftRail + case "border", "wash", "lift", "typography", "washRail", "blueWashColorRail": + return .solidFill + default: + return defaultStyle + } + } + + static func current(defaults: UserDefaults = .standard) -> SidebarActiveTabIndicatorStyle { + resolvedStyle(rawValue: defaults.string(forKey: styleKey)) + } +} + +struct WorkspaceTabColorEntry: Equatable, Identifiable { + let name: String + let hex: String + + var id: String { "\(name)-\(hex)" } +} + +enum WorkspaceTabColorSettings { + static let defaultOverridesKey = "workspaceTabColor.defaultOverrides" + static let customColorsKey = "workspaceTabColor.customColors" + static let maxCustomColors = 24 + + private static let originalPRPalette: [WorkspaceTabColorEntry] = [ + WorkspaceTabColorEntry(name: "Red", hex: "#C0392B"), + WorkspaceTabColorEntry(name: "Crimson", hex: "#922B21"), + WorkspaceTabColorEntry(name: "Orange", hex: "#A04000"), + WorkspaceTabColorEntry(name: "Amber", hex: "#7D6608"), + WorkspaceTabColorEntry(name: "Olive", hex: "#4A5C18"), + WorkspaceTabColorEntry(name: "Green", hex: "#196F3D"), + WorkspaceTabColorEntry(name: "Teal", hex: "#006B6B"), + WorkspaceTabColorEntry(name: "Aqua", hex: "#0E6B8C"), + WorkspaceTabColorEntry(name: "Blue", hex: "#1565C0"), + WorkspaceTabColorEntry(name: "Navy", hex: "#1A5276"), + WorkspaceTabColorEntry(name: "Indigo", hex: "#283593"), + WorkspaceTabColorEntry(name: "Purple", hex: "#6A1B9A"), + WorkspaceTabColorEntry(name: "Magenta", hex: "#AD1457"), + WorkspaceTabColorEntry(name: "Rose", hex: "#880E4F"), + WorkspaceTabColorEntry(name: "Brown", hex: "#7B3F00"), + WorkspaceTabColorEntry(name: "Charcoal", hex: "#3E4B5E"), + ] + + static var defaultPalette: [WorkspaceTabColorEntry] { + originalPRPalette + } + + static func palette(defaults: UserDefaults = .standard) -> [WorkspaceTabColorEntry] { + defaultPaletteWithOverrides(defaults: defaults) + customColorEntries(defaults: defaults) + } + + static func defaultPaletteWithOverrides(defaults: UserDefaults = .standard) -> [WorkspaceTabColorEntry] { + let palette = defaultPalette + let overrides = defaultOverrideMap(defaults: defaults) + return palette.map { entry in + WorkspaceTabColorEntry(name: entry.name, hex: overrides[entry.name] ?? entry.hex) + } + } + + static func defaultColorHex(named name: String, defaults: UserDefaults = .standard) -> String { + let palette = defaultPalette + guard let entry = palette.first(where: { $0.name == name }) else { + return palette.first?.hex ?? "#1565C0" + } + return defaultOverrideMap(defaults: defaults)[name] ?? entry.hex + } + + static func setDefaultColor(named name: String, hex: String, defaults: UserDefaults = .standard) { + let palette = defaultPalette + guard let entry = palette.first(where: { $0.name == name }), + let normalized = normalizedHex(hex) else { return } + + var overrides = defaultOverrideMap(defaults: defaults) + if normalized == entry.hex { + overrides.removeValue(forKey: name) + } else { + overrides[name] = normalized + } + saveDefaultOverrideMap(overrides, defaults: defaults) + } + + static func customColors(defaults: UserDefaults = .standard) -> [String] { + guard let raw = defaults.array(forKey: customColorsKey) as? [String] else { return [] } + var result: [String] = [] + var seen: Set<String> = [] + for value in raw { + guard let normalized = normalizedHex(value), seen.insert(normalized).inserted else { continue } + result.append(normalized) + if result.count >= maxCustomColors { break } + } + return result + } + + static func customColorEntries(defaults: UserDefaults = .standard) -> [WorkspaceTabColorEntry] { + customColors(defaults: defaults).enumerated().map { index, hex in + WorkspaceTabColorEntry(name: "Custom \(index + 1)", hex: hex) + } + } + + @discardableResult + static func addCustomColor(_ hex: String, defaults: UserDefaults = .standard) -> String? { + guard let normalized = normalizedHex(hex) else { return nil } + var colors = customColors(defaults: defaults) + colors.removeAll { $0 == normalized } + colors.insert(normalized, at: 0) + setCustomColors(colors, defaults: defaults) + return normalized + } + + static func removeCustomColor(_ hex: String, defaults: UserDefaults = .standard) { + guard let normalized = normalizedHex(hex) else { return } + var colors = customColors(defaults: defaults) + colors.removeAll { $0 == normalized } + setCustomColors(colors, defaults: defaults) + } + + static func setCustomColors(_ hexes: [String], defaults: UserDefaults = .standard) { + var normalizedColors: [String] = [] + var seen: Set<String> = [] + for value in hexes { + guard let normalized = normalizedHex(value), seen.insert(normalized).inserted else { continue } + normalizedColors.append(normalized) + if normalizedColors.count >= maxCustomColors { break } + } + + if normalizedColors.isEmpty { + defaults.removeObject(forKey: customColorsKey) + } else { + defaults.set(normalizedColors, forKey: customColorsKey) + } + } + + static func reset(defaults: UserDefaults = .standard) { + defaults.removeObject(forKey: defaultOverridesKey) + defaults.removeObject(forKey: customColorsKey) + } + + static func normalizedHex(_ raw: String) -> String? { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + let body = trimmed.hasPrefix("#") ? String(trimmed.dropFirst()) : trimmed + guard body.count == 6 else { return nil } + guard UInt64(body, radix: 16) != nil else { return nil } + return "#" + body.uppercased() + } + + static func displayColor( + hex: String, + colorScheme: ColorScheme, + forceBright: Bool = false + ) -> Color? { + guard let color = displayNSColor(hex: hex, colorScheme: colorScheme, forceBright: forceBright) else { + return nil + } + return Color(nsColor: color) + } + + static func displayNSColor( + hex: String, + colorScheme: ColorScheme, + forceBright: Bool = false + ) -> NSColor? { + guard let normalized = normalizedHex(hex), + let baseColor = NSColor(hex: normalized) else { + return nil + } + + if forceBright || colorScheme == .dark { + return brightenedForDarkAppearance(baseColor) + } + return baseColor + } + + private static func defaultOverrideMap(defaults: UserDefaults) -> [String: String] { + guard let raw = defaults.dictionary(forKey: defaultOverridesKey) as? [String: String] else { return [:] } + let validNames = Set(defaultPalette.map(\.name)) + var normalized: [String: String] = [:] + for (name, hex) in raw { + guard validNames.contains(name), + let normalizedHex = normalizedHex(hex) else { continue } + normalized[name] = normalizedHex + } + return normalized + } + + private static func saveDefaultOverrideMap(_ map: [String: String], defaults: UserDefaults) { + if map.isEmpty { + defaults.removeObject(forKey: defaultOverridesKey) + } else { + defaults.set(map, forKey: defaultOverridesKey) + } + } + + private static func brightenedForDarkAppearance(_ color: NSColor) -> NSColor { + let rgbColor = color.usingColorSpace(.sRGB) ?? color + var hue: CGFloat = 0 + var saturation: CGFloat = 0 + var brightness: CGFloat = 0 + var alpha: CGFloat = 0 + rgbColor.getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: &alpha) + + let boostedBrightness = min(1, max(brightness, 0.62) + ((1 - brightness) * 0.28)) + // Preserve neutral grays when brightening to avoid introducing hue shifts. + let boostedSaturation: CGFloat + if saturation <= 0.08 { + boostedSaturation = saturation + } else { + boostedSaturation = min(1, saturation + ((1 - saturation) * 0.12)) + } + + return NSColor( + hue: hue, + saturation: boostedSaturation, + brightness: boostedBrightness, + alpha: alpha + ) + } +} + enum WorkspacePlacementSettings { static let placementKey = "newWorkspacePlacement" static let defaultPlacement: NewWorkspacePlacement = .afterCurrent @@ -129,6 +390,30 @@ final class NotificationBurstCoalescer { } } +struct RecentlyClosedBrowserStack { + private(set) var entries: [ClosedBrowserPanelRestoreSnapshot] = [] + let capacity: Int + + init(capacity: Int) { + self.capacity = max(1, capacity) + } + + var isEmpty: Bool { + entries.isEmpty + } + + mutating func push(_ snapshot: ClosedBrowserPanelRestoreSnapshot) { + entries.append(snapshot) + if entries.count > capacity { + entries.removeFirst(entries.count - capacity) + } + } + + mutating func pop() -> ClosedBrowserPanelRestoreSnapshot? { + entries.popLast() + } +} + #if DEBUG // Sample the actual IOSurface-backed terminal layer at vsync cadence so UI tests can reliably // catch a single compositor-frame blank flash and any transient compositor scaling (stretched text). @@ -275,6 +560,7 @@ fileprivate func cmuxVsyncIOSurfaceTimelineCallback( class TabManager: ObservableObject { @Published var tabs: [Workspace] = [] @Published private(set) var isWorkspaceCycleHot: Bool = false + weak var window: NSWindow? /// Global monotonically increasing counter for CMUX_PORT ordinal assignment. /// Static so port ranges don't overlap across multiple windows (each window has its own TabManager). @@ -330,6 +616,7 @@ class TabManager: ObservableObject { } private var pendingPanelTitleUpdates: [PanelTitleUpdateKey: String] = [:] private let panelTitleUpdateCoalescer = NotificationBurstCoalescer(delay: 1.0 / 30.0) + private var recentlyClosedBrowsers = RecentlyClosedBrowserStack(capacity: 20) // Recent tab history for back/forward navigation (like browser history) private var tabHistory: [UUID] = [] @@ -394,6 +681,16 @@ class TabManager: ObservableObject { workspaceCycleCooldownTask?.cancel() } + private func wireClosedBrowserTracking(for workspace: Workspace) { + workspace.onClosedBrowserPanel = { [weak self] snapshot in + self?.recentlyClosedBrowsers.push(snapshot) + } + } + + private func unwireClosedBrowserTracking(for workspace: Workspace) { + workspace.onClosedBrowserPanel = nil + } + var selectedWorkspace: Workspace? { guard let selectedTabId else { return nil } return tabs.first(where: { $0.id == selectedTabId }) @@ -458,7 +755,9 @@ class TabManager: ObservableObject { func addWorkspace( workingDirectory overrideWorkingDirectory: String? = nil, initialTerminalCommand: String? = nil, - initialTerminalEnvironment: [String: String] = [:] + initialTerminalEnvironment: [String: String] = [:], + placementOverride: NewWorkspacePlacement? = nil, + select: Bool = true ) -> Workspace { let workingDirectory = normalizedWorkingDirectory(overrideWorkingDirectory) ?? preferredWorkingDirectoryForNewTab() let ordinal = Self.nextPortOrdinal @@ -470,23 +769,26 @@ class TabManager: ObservableObject { initialTerminalCommand: initialTerminalCommand, initialTerminalEnvironment: initialTerminalEnvironment ) - let insertIndex = newTabInsertIndex() + wireClosedBrowserTracking(for: newWorkspace) + let insertIndex = newTabInsertIndex(placementOverride: placementOverride) if insertIndex >= 0 && insertIndex <= tabs.count { tabs.insert(newWorkspace, at: insertIndex) } else { tabs.append(newWorkspace) } - selectedTabId = newWorkspace.id - NotificationCenter.default.post( - name: .ghosttyDidFocusTab, - object: nil, - userInfo: [GhosttyNotificationKey.tabId: newWorkspace.id] - ) + if select { + selectedTabId = newWorkspace.id + NotificationCenter.default.post( + name: .ghosttyDidFocusTab, + object: nil, + userInfo: [GhosttyNotificationKey.tabId: newWorkspace.id] + ) + } #if DEBUG UITestRecorder.incrementInt("addTabInvocations") UITestRecorder.record([ "tabCount": String(tabs.count), - "selectedTabId": newWorkspace.id.uuidString + "selectedTabId": select ? newWorkspace.id.uuidString : (selectedTabId?.uuidString ?? "") ]) #endif return newWorkspace @@ -494,7 +796,86 @@ class TabManager: ObservableObject { // Keep addTab as convenience alias @discardableResult - func addTab() -> Workspace { addWorkspace() } + func addTab(select: Bool = true) -> Workspace { addWorkspace(select: select) } + + func terminalPanelForWorkspaceConfigInheritanceSource() -> TerminalPanel? { + guard let workspace = selectedWorkspace else { return nil } + if let focusedTerminal = workspace.focusedTerminalPanel { + return focusedTerminal + } + if let focusedPaneId = workspace.bonsplitController.focusedPaneId, + let paneTerminal = workspace.terminalPanelForConfigInheritance(inPane: focusedPaneId) { + return paneTerminal + } + return workspace.terminalPanelForConfigInheritance() + } + + func sessionSnapshot(includeScrollback: Bool) -> SessionTabManagerSnapshot { + let workspaceSnapshots = tabs + .prefix(SessionPersistencePolicy.maxWorkspacesPerWindow) + .map { $0.sessionSnapshot(includeScrollback: includeScrollback) } + let selectedWorkspaceIndex = selectedTabId.flatMap { selectedId in + tabs.firstIndex(where: { $0.id == selectedId }) + } + return SessionTabManagerSnapshot( + selectedWorkspaceIndex: selectedWorkspaceIndex, + workspaces: workspaceSnapshots + ) + } + + func restoreSessionSnapshot(_ snapshot: SessionTabManagerSnapshot) { + for tab in tabs { + unwireClosedBrowserTracking(for: tab) + } + + tabs.removeAll(keepingCapacity: false) + lastFocusedPanelByTab.removeAll() + pendingPanelTitleUpdates.removeAll() + tabHistory.removeAll() + historyIndex = -1 + isNavigatingHistory = false + pendingWorkspaceUnfocusTarget = nil + workspaceCycleCooldownTask?.cancel() + workspaceCycleCooldownTask = nil + isWorkspaceCycleHot = false + selectionSideEffectsGeneration &+= 1 + recentlyClosedBrowsers = RecentlyClosedBrowserStack(capacity: 20) + + let workspaceSnapshots = snapshot.workspaces + .prefix(SessionPersistencePolicy.maxWorkspacesPerWindow) + for workspaceSnapshot in workspaceSnapshots { + let ordinal = Self.nextPortOrdinal + Self.nextPortOrdinal += 1 + let workspace = Workspace( + title: workspaceSnapshot.processTitle, + workingDirectory: workspaceSnapshot.currentDirectory, + portOrdinal: ordinal + ) + workspace.restoreSessionSnapshot(workspaceSnapshot) + wireClosedBrowserTracking(for: workspace) + tabs.append(workspace) + } + + if tabs.isEmpty { + _ = addWorkspace(select: false) + } + + selectedTabId = nil + if let selectedWorkspaceIndex = snapshot.selectedWorkspaceIndex, + tabs.indices.contains(selectedWorkspaceIndex) { + selectedTabId = tabs[selectedWorkspaceIndex].id + } else { + selectedTabId = tabs.first?.id + } + + if let selectedTabId { + NotificationCenter.default.post( + name: .ghosttyDidFocusTab, + object: nil, + userInfo: [GhosttyNotificationKey.tabId: selectedTabId] + ) + } + } private func normalizedWorkingDirectory(_ directory: String?) -> String? { guard let directory else { return nil } @@ -503,8 +884,8 @@ class TabManager: ObservableObject { return trimmed.isEmpty ? nil : normalized } - private func newTabInsertIndex() -> Int { - let placement = WorkspacePlacementSettings.current() + private func newTabInsertIndex(placementOverride: NewWorkspacePlacement? = nil) -> Int { + let placement = placementOverride ?? WorkspacePlacementSettings.current() let pinnedCount = tabs.filter { $0.isPinned }.count let selectedIndex = selectedTabId.flatMap { tabId in tabs.firstIndex(where: { $0.id == tabId }) @@ -604,6 +985,11 @@ class TabManager: ObservableObject { reorderTabForPinnedState(tab) } + func setTabColor(tabId: UUID, color: String?) { + guard let tab = tabs.first(where: { $0.id == tabId }) else { return } + tab.setCustomColor(color) + } + private func reorderTabForPinnedState(_ tab: Workspace) { guard let index = tabs.firstIndex(where: { $0.id == tab.id }) else { return } tabs.remove(at: index) @@ -636,6 +1022,7 @@ class TabManager: ObservableObject { AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: workspace.id) workspace.teardownRemoteConnection() + unwireClosedBrowserTracking(for: workspace) if let index = tabs.firstIndex(where: { $0.id == workspace.id }) { tabs.remove(at: index) @@ -657,6 +1044,7 @@ class TabManager: ObservableObject { guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { return nil } let removed = tabs.remove(at: index) + unwireClosedBrowserTracking(for: removed) lastFocusedPanelByTab.removeValue(forKey: removed.id) if tabs.isEmpty { @@ -675,6 +1063,7 @@ class TabManager: ObservableObject { /// Attach an existing workspace to this window. func attachWorkspace(_ workspace: Workspace, at index: Int? = nil, select: Bool = true) { + wireClosedBrowserTracking(for: workspace) let insertIndex: Int = { guard let index else { return tabs.count } return max(0, min(index, tabs.count)) @@ -1557,6 +1946,123 @@ class TabManager: ObservableObject { return panel?.id } + @discardableResult + func reopenMostRecentlyClosedBrowserPanel() -> Bool { + while let snapshot = recentlyClosedBrowsers.pop() { + guard let targetWorkspace = + tabs.first(where: { $0.id == snapshot.workspaceId }) + ?? selectedWorkspace + ?? tabs.first else { + return false + } + let preReopenFocusedPanelId = focusedPanelId(for: targetWorkspace.id) + + if selectedTabId != targetWorkspace.id { + selectedTabId = targetWorkspace.id + } + + if let reopenedPanelId = reopenClosedBrowserPanel(snapshot, in: targetWorkspace) { + enforceReopenedBrowserFocus( + tabId: targetWorkspace.id, + reopenedPanelId: reopenedPanelId, + preReopenFocusedPanelId: preReopenFocusedPanelId + ) + return true + } + } + + return false + } + + private func enforceReopenedBrowserFocus( + tabId: UUID, + reopenedPanelId: UUID, + preReopenFocusedPanelId: UUID? + ) { + // Keep workspace-switch restoration pinned to the reopened browser panel. + rememberFocusedSurface(tabId: tabId, surfaceId: reopenedPanelId) + enforceReopenedBrowserFocusIfNeeded( + tabId: tabId, + reopenedPanelId: reopenedPanelId, + preReopenFocusedPanelId: preReopenFocusedPanelId + ) + + // Some stale focus callbacks can land one runloop turn later. Re-assert focus in two + // consecutive turns, but only when focus drifted back to the pre-reopen panel. + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.enforceReopenedBrowserFocusIfNeeded( + tabId: tabId, + reopenedPanelId: reopenedPanelId, + preReopenFocusedPanelId: preReopenFocusedPanelId + ) + DispatchQueue.main.async { [weak self] in + self?.enforceReopenedBrowserFocusIfNeeded( + tabId: tabId, + reopenedPanelId: reopenedPanelId, + preReopenFocusedPanelId: preReopenFocusedPanelId + ) + } + } + } + + private func enforceReopenedBrowserFocusIfNeeded( + tabId: UUID, + reopenedPanelId: UUID, + preReopenFocusedPanelId: UUID? + ) { + guard selectedTabId == tabId, + let tab = tabs.first(where: { $0.id == tabId }), + tab.panels[reopenedPanelId] != nil else { + return + } + + rememberFocusedSurface(tabId: tabId, surfaceId: reopenedPanelId) + + guard tab.focusedPanelId != reopenedPanelId else { return } + + if let focusedPanelId = tab.focusedPanelId, + let preReopenFocusedPanelId, + focusedPanelId != preReopenFocusedPanelId { + return + } + + tab.focusPanel(reopenedPanelId) + } + + private func reopenClosedBrowserPanel( + _ snapshot: ClosedBrowserPanelRestoreSnapshot, + in workspace: Workspace + ) -> UUID? { + if let originalPane = workspace.bonsplitController.allPaneIds.first(where: { $0.id == snapshot.originalPaneId }), + let browserPanel = workspace.newBrowserSurface(inPane: originalPane, url: snapshot.url, focus: true) { + let tabCount = workspace.bonsplitController.tabs(inPane: originalPane).count + let maxIndex = max(0, tabCount - 1) + let targetIndex = min(max(snapshot.originalTabIndex, 0), maxIndex) + _ = workspace.reorderSurface(panelId: browserPanel.id, toIndex: targetIndex) + return browserPanel.id + } + + if let orientation = snapshot.fallbackSplitOrientation, + let fallbackAnchorPaneId = snapshot.fallbackAnchorPaneId, + let anchorPane = workspace.bonsplitController.allPaneIds.first(where: { $0.id == fallbackAnchorPaneId }), + let anchorTab = workspace.bonsplitController.selectedTab(inPane: anchorPane) ?? workspace.bonsplitController.tabs(inPane: anchorPane).first, + let anchorPanelId = workspace.panelIdFromSurfaceId(anchorTab.id), + let browserPanelId = workspace.newBrowserSplit( + from: anchorPanelId, + orientation: orientation, + insertFirst: snapshot.fallbackSplitInsertFirst, + url: snapshot.url + )?.id { + return browserPanelId + } + + guard let focusedPane = workspace.bonsplitController.focusedPaneId ?? workspace.bonsplitController.allPaneIds.first else { + return nil + } + return workspace.newBrowserSurface(inPane: focusedPane, url: snapshot.url, focus: true)?.id + } + /// Flash the currently focused panel so the user can visually confirm focus. func triggerFocusFlash() { guard let tab = selectedWorkspace, @@ -2617,6 +3123,13 @@ enum ResizeDirection { } extension Notification.Name { + static let commandPaletteToggleRequested = Notification.Name("cmux.commandPaletteToggleRequested") + static let commandPaletteRequested = Notification.Name("cmux.commandPaletteRequested") + static let commandPaletteSwitcherRequested = Notification.Name("cmux.commandPaletteSwitcherRequested") + static let commandPaletteRenameTabRequested = Notification.Name("cmux.commandPaletteRenameTabRequested") + static let commandPaletteMoveSelection = Notification.Name("cmux.commandPaletteMoveSelection") + static let commandPaletteRenameInputInteractionRequested = Notification.Name("cmux.commandPaletteRenameInputInteractionRequested") + static let commandPaletteRenameInputDeleteBackwardRequested = Notification.Name("cmux.commandPaletteRenameInputDeleteBackwardRequested") static let ghosttyDidSetTitle = Notification.Name("ghosttyDidSetTitle") static let ghosttyDidFocusTab = Notification.Name("ghosttyDidFocusTab") static let ghosttyDidFocusSurface = Notification.Name("ghosttyDidFocusSurface") @@ -2626,6 +3139,7 @@ extension Notification.Name { static let browserDidExitAddressBar = Notification.Name("browserDidExitAddressBar") static let browserDidFocusAddressBar = Notification.Name("browserDidFocusAddressBar") static let browserDidBlurAddressBar = Notification.Name("browserDidBlurAddressBar") + static let browserDidBecomeFirstResponderWebView = Notification.Name("browserDidBecomeFirstResponderWebView") static let webViewDidReceiveClick = Notification.Name("webViewDidReceiveClick") static let webViewMiddleClickedLink = Notification.Name("webViewMiddleClickedLink") } diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index ea40a579..54efb366 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -18,6 +18,37 @@ class TerminalController { private var tabManager: TabManager? private var accessMode: SocketControlMode = .cmuxOnly private let myPid = getpid() + private nonisolated(unsafe) static var socketCommandPolicyDepth: Int = 0 + private nonisolated(unsafe) static var socketCommandFocusAllowanceStack: [Bool] = [] + private nonisolated static let socketCommandPolicyLock = NSLock() + + private static let focusIntentV1Commands: Set<String> = [ + "focus_window", + "select_workspace", + "focus_surface", + "focus_pane", + "focus_surface_by_panel", + "focus_webview", + "focus_notification", + "activate_app" + ] + + private static let focusIntentV2Methods: Set<String> = [ + "window.focus", + "workspace.select", + "workspace.next", + "workspace.previous", + "workspace.last", + "surface.focus", + "pane.focus", + "pane.last", + "browser.focus_webview", + "browser.focus", + "browser.tab.switch", + "debug.command_palette.toggle", + "debug.notification.focus", + "debug.app.activate" + ] private enum V2HandleKind: String, CaseIterable { case window @@ -68,6 +99,177 @@ class TerminalController { private init() {} + nonisolated static func shouldSuppressSocketCommandActivation() -> Bool { + socketCommandPolicyLock.lock() + defer { socketCommandPolicyLock.unlock() } + return socketCommandPolicyDepth > 0 + } + + nonisolated static func socketCommandAllowsInAppFocusMutations() -> Bool { + allowsInAppFocusMutationsForActiveSocketCommand() + } + + private nonisolated static func allowsInAppFocusMutationsForActiveSocketCommand() -> Bool { + socketCommandPolicyLock.lock() + defer { socketCommandPolicyLock.unlock() } + return socketCommandFocusAllowanceStack.last ?? false + } + + private func socketCommandAllowsInAppFocusMutations() -> Bool { + Self.allowsInAppFocusMutationsForActiveSocketCommand() + } + + private func v2FocusAllowed(requested: Bool = true) -> Bool { + requested && socketCommandAllowsInAppFocusMutations() + } + + private func v2MaybeFocusWindow(for tabManager: TabManager) { + guard socketCommandAllowsInAppFocusMutations(), + let windowId = v2ResolveWindowId(tabManager: tabManager) else { return } + _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) + setActiveTabManager(tabManager) + } + + private func v2MaybeSelectWorkspace(_ tabManager: TabManager, workspace: Workspace) { + guard socketCommandAllowsInAppFocusMutations() else { return } + if tabManager.selectedTabId != workspace.id { + tabManager.selectWorkspace(workspace) + } + } + + private static func socketCommandAllowsInAppFocusMutations(commandKey: String, isV2: Bool) -> Bool { + if isV2 { + return focusIntentV2Methods.contains(commandKey) + } + return focusIntentV1Commands.contains(commandKey) + } + + private func withSocketCommandPolicy<T>(commandKey: String, isV2: Bool, _ body: () -> T) -> T { + let allowsFocusMutation = Self.socketCommandAllowsInAppFocusMutations(commandKey: commandKey, isV2: isV2) + Self.socketCommandPolicyLock.lock() + Self.socketCommandPolicyDepth += 1 + Self.socketCommandFocusAllowanceStack.append(allowsFocusMutation) + Self.socketCommandPolicyLock.unlock() + defer { + Self.socketCommandPolicyLock.lock() + if !Self.socketCommandFocusAllowanceStack.isEmpty { + _ = Self.socketCommandFocusAllowanceStack.popLast() + } + Self.socketCommandPolicyDepth = max(0, Self.socketCommandPolicyDepth - 1) + Self.socketCommandPolicyLock.unlock() + } + return body() + } + +#if DEBUG + static func debugSocketCommandPolicySnapshot( + commandKey: String, + isV2: Bool + ) -> (insideSuppressed: Bool, insideAllowsFocus: Bool, outsideSuppressed: Bool, outsideAllowsFocus: Bool) { + var insideSuppressed = false + var insideAllowsFocus = false + _ = Self.shared.withSocketCommandPolicy(commandKey: commandKey, isV2: isV2) { + insideSuppressed = Self.shouldSuppressSocketCommandActivation() + insideAllowsFocus = Self.socketCommandAllowsInAppFocusMutations() + return 0 + } + return ( + insideSuppressed: insideSuppressed, + insideAllowsFocus: insideAllowsFocus, + outsideSuppressed: Self.shouldSuppressSocketCommandActivation(), + outsideAllowsFocus: Self.socketCommandAllowsInAppFocusMutations() + ) + } +#endif + + nonisolated static func shouldReplaceStatusEntry( + current: SidebarStatusEntry?, + key: String, + value: String, + icon: String?, + color: String? + ) -> Bool { + guard let current else { return true } + return current.key != key || current.value != value || current.icon != icon || current.color != color + } + + nonisolated static func shouldReplaceProgress( + current: SidebarProgressState?, + value: Double, + label: String? + ) -> Bool { + guard let current else { return true } + return current.value != value || current.label != label + } + + nonisolated static func shouldReplaceGitBranch( + current: SidebarGitBranchState?, + branch: String, + isDirty: Bool + ) -> Bool { + guard let current else { return true } + return current.branch != branch || current.isDirty != isDirty + } + + nonisolated static func shouldReplacePorts(current: [Int]?, next: [Int]) -> Bool { + let currentSorted = Array(Set(current ?? [])).sorted() + let nextSorted = Array(Set(next)).sorted() + return currentSorted != nextSorted + } + + nonisolated static func explicitSocketScope( + options: [String: String] + ) -> (workspaceId: UUID, panelId: UUID)? { + guard let tabRaw = options["tab"]?.trimmingCharacters(in: .whitespacesAndNewlines), + !tabRaw.isEmpty, + let panelRaw = (options["panel"] ?? options["surface"])?.trimmingCharacters(in: .whitespacesAndNewlines), + !panelRaw.isEmpty, + let workspaceId = UUID(uuidString: tabRaw), + let panelId = UUID(uuidString: panelRaw) else { + return nil + } + return (workspaceId, panelId) + } + + nonisolated static func normalizeReportedDirectory(_ directory: String) -> String { + let trimmed = directory.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return directory } + if trimmed.hasPrefix("file://"), let url = URL(string: trimmed), !url.path.isEmpty { + return url.path + } + return trimmed + } + + nonisolated static func normalizedExportedScreenPath(_ raw: String?) -> String? { + guard let raw else { return nil } + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + if let url = URL(string: trimmed), + url.isFileURL, + !url.path.isEmpty { + return url.path + } + return trimmed.hasPrefix("/") ? trimmed : nil + } + + nonisolated static func shouldRemoveExportedScreenFile( + fileURL: URL, + temporaryDirectory: URL = FileManager.default.temporaryDirectory + ) -> Bool { + let standardizedFile = fileURL.standardizedFileURL + let temporary = temporaryDirectory.standardizedFileURL + return standardizedFile.path.hasPrefix(temporary.path + "/") + } + + nonisolated static func shouldRemoveExportedScreenDirectory( + fileURL: URL, + temporaryDirectory: URL = FileManager.default.temporaryDirectory + ) -> Bool { + let directory = fileURL.deletingLastPathComponent().standardizedFileURL + let temporary = temporaryDirectory.standardizedFileURL + return directory.path.hasPrefix(temporary.path + "/") + } + /// Update which window's TabManager receives socket commands. /// This is used when the user switches between multiple terminal windows. func setActiveTabManager(_ tabManager: TabManager?) { @@ -135,6 +337,7 @@ class TerminalController { if isRunning { if self.socketPath == socketPath && acceptLoopAlive { self.accessMode = accessMode + applySocketPermissions() return } stop() @@ -174,8 +377,7 @@ class TerminalController { return } - // Restrict socket to owner only (0600) - chmod(socketPath, 0o600) + applySocketPermissions() // Listen guard listen(serverSocket, 5) >= 0 else { @@ -214,6 +416,104 @@ class TerminalController { unlink(socketPath) } + private func applySocketPermissions() { + let permissions = mode_t(accessMode.socketFilePermissions) + if chmod(socketPath, permissions) != 0 { + print("TerminalController: Failed to set socket permissions to \(String(permissions, radix: 8)) for \(socketPath)") + } + } + + private func writeSocketResponse(_ response: String, to socket: Int32) { + let payload = response + "\n" + payload.withCString { ptr in + _ = write(socket, ptr, strlen(ptr)) + } + } + + private func passwordAuthRequiredResponse(for command: String) -> String { + let message = "Authentication required. Send auth <password> first." + guard command.hasPrefix("{"), + let data = command.data(using: .utf8), + let dict = (try? JSONSerialization.jsonObject(with: data, options: [])) as? [String: Any] else { + return "ERROR: Authentication required — send auth <password> first" + } + let id = dict["id"] + return v2Error(id: id, code: "auth_required", message: message) + } + + private func passwordLoginV1ResponseIfNeeded(for command: String, authenticated: inout Bool) -> String? { + let lowered = command.lowercased() + guard lowered == "auth" || lowered.hasPrefix("auth ") else { + return nil + } + guard SocketControlPasswordStore.hasConfiguredPassword() else { + return "ERROR: Password mode is enabled but no socket password is configured in Settings." + } + + let provided: String + if lowered == "auth" { + provided = "" + } else { + provided = String(command.dropFirst(5)) + } + guard !provided.isEmpty else { + return "ERROR: Missing password. Usage: auth <password>" + } + guard SocketControlPasswordStore.verify(password: provided) else { + return "ERROR: Invalid password" + } + authenticated = true + return "OK: Authenticated" + } + + private func passwordLoginV2ResponseIfNeeded(for command: String, authenticated: inout Bool) -> String? { + guard command.hasPrefix("{"), + let data = command.data(using: .utf8), + let dict = (try? JSONSerialization.jsonObject(with: data, options: [])) as? [String: Any] else { + return nil + } + let id = dict["id"] + let method = (dict["method"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard method == "auth.login" else { + return nil + } + + guard let params = dict["params"] as? [String: Any], + let provided = params["password"] as? String else { + return v2Error(id: id, code: "invalid_params", message: "auth.login requires params.password") + } + + guard SocketControlPasswordStore.hasConfiguredPassword() else { + return v2Error( + id: id, + code: "auth_unconfigured", + message: "Password mode is enabled but no socket password is configured in Settings." + ) + } + + guard SocketControlPasswordStore.verify(password: provided) else { + return v2Error(id: id, code: "auth_failed", message: "Invalid password") + } + authenticated = true + return v2Ok(id: id, result: ["authenticated": true]) + } + + private func authResponseIfNeeded(for command: String, authenticated: inout Bool) -> String? { + guard accessMode.requiresPasswordAuth else { + return nil + } + if let v2Response = passwordLoginV2ResponseIfNeeded(for: command, authenticated: &authenticated) { + return v2Response + } + if let v1Response = passwordLoginV1ResponseIfNeeded(for: command, authenticated: &authenticated) { + return v1Response + } + if !authenticated { + return passwordAuthRequiredResponse(for: command) + } + return nil + } + private nonisolated func acceptLoop() { acceptLoopAlive = true defer { @@ -293,6 +593,7 @@ class TerminalController { var buffer = [UInt8](repeating: 0, count: 4096) var pending = "" + var authenticated = false while isRunning { let bytesRead = read(socket, &buffer, buffer.count - 1) @@ -307,11 +608,13 @@ class TerminalController { let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { continue } - let response = processCommand(trimmed) - let payload = response + "\n" - payload.withCString { ptr in - _ = write(socket, ptr, strlen(ptr)) + if let authResponse = authResponseIfNeeded(for: trimmed, authenticated: &authenticated) { + writeSocketResponse(authResponse, to: socket) + continue } + + let response = processCommand(trimmed) + writeSocketResponse(response, to: socket) } } } @@ -331,10 +634,14 @@ class TerminalController { let cmd = parts[0].lowercased() let args = parts.count > 1 ? parts[1] : "" - switch cmd { + return withSocketCommandPolicy(commandKey: cmd, isV2: false) { + switch cmd { case "ping": return "PONG" + case "auth": + return "OK: Authentication not required" + case "list_windows": return listWindows() @@ -615,11 +922,12 @@ class TerminalController { case "refresh_surfaces": return refreshSurfaces() - case "surface_health": - return surfaceHealth(args) + case "surface_health": + return surfaceHealth(args) - default: - return "ERROR: Unknown command '\(cmd)'. Use 'help' for available commands." + default: + return "ERROR: Unknown command '\(cmd)'. Use 'help' for available commands." + } } } @@ -655,7 +963,8 @@ class TerminalController { v2MainSync { self.v2RefreshKnownRefs() } - switch method { + return withSocketCommandPolicy(commandKey: method, isV2: true) { + switch method { case "system.ping": return v2Ok(id: id, result: ["pong": true]) case "system.capabilities": @@ -663,6 +972,14 @@ class TerminalController { case "system.identify": return v2Ok(id: id, result: v2Identify(params: params)) + case "auth.login": + return v2Ok( + id: id, + result: [ + "authenticated": true, + "required": accessMode.requiresPasswordAuth + ] + ) // Windows case "window.list": @@ -968,6 +1285,28 @@ class TerminalController { return v2Result(id: id, self.v2DebugType(params: params)) case "debug.app.activate": return v2Result(id: id, self.v2DebugActivateApp()) + case "debug.command_palette.toggle": + return v2Result(id: id, self.v2DebugToggleCommandPalette(params: params)) + case "debug.command_palette.rename_tab.open": + return v2Result(id: id, self.v2DebugOpenCommandPaletteRenameTabInput(params: params)) + case "debug.command_palette.visible": + return v2Result(id: id, self.v2DebugCommandPaletteVisible(params: params)) + case "debug.command_palette.selection": + return v2Result(id: id, self.v2DebugCommandPaletteSelection(params: params)) + case "debug.command_palette.results": + return v2Result(id: id, self.v2DebugCommandPaletteResults(params: params)) + case "debug.command_palette.rename_input.interact": + return v2Result(id: id, self.v2DebugCommandPaletteRenameInputInteraction(params: params)) + case "debug.command_palette.rename_input.delete_backward": + return v2Result(id: id, self.v2DebugCommandPaletteRenameInputDeleteBackward(params: params)) + case "debug.command_palette.rename_input.selection": + return v2Result(id: id, self.v2DebugCommandPaletteRenameInputSelection(params: params)) + case "debug.command_palette.rename_input.select_all": + return v2Result(id: id, self.v2DebugCommandPaletteRenameInputSelectAll(params: params)) + case "debug.browser.address_bar_focused": + return v2Result(id: id, self.v2DebugBrowserAddressBarFocused(params: params)) + case "debug.sidebar.visible": + return v2Result(id: id, self.v2DebugSidebarVisible(params: params)) case "debug.terminal.is_focused": return v2Result(id: id, self.v2DebugIsTerminalFocused(params: params)) case "debug.terminal.read_text": @@ -998,8 +1337,9 @@ class TerminalController { return v2Result(id: id, self.v2DebugScreenshot(params: params)) #endif - default: - return v2Error(id: id, code: "method_not_found", message: "Unknown method") + default: + return v2Error(id: id, code: "method_not_found", message: "Unknown method") + } } } @@ -1008,6 +1348,7 @@ class TerminalController { "system.ping", "system.capabilities", "system.identify", + "auth.login", "window.list", "window.current", "window.focus", @@ -1154,6 +1495,17 @@ class TerminalController { "debug.shortcut.simulate", "debug.type", "debug.app.activate", + "debug.command_palette.toggle", + "debug.command_palette.rename_tab.open", + "debug.command_palette.visible", + "debug.command_palette.selection", + "debug.command_palette.results", + "debug.command_palette.rename_input.interact", + "debug.command_palette.rename_input.delete_backward", + "debug.command_palette.rename_input.selection", + "debug.command_palette.rename_input.select_all", + "debug.browser.address_bar_focused", + "debug.sidebar.visible", "debug.terminal.is_focused", "debug.terminal.read_text", "debug.terminal.render_stats", @@ -1655,11 +2007,13 @@ class TerminalController { } var newId: UUID? + let shouldFocus = v2FocusAllowed() v2MainSync { let ws = tabManager.addWorkspace( workingDirectory: workingDirectory, initialTerminalCommand: initialCommand, - initialTerminalEnvironment: initialEnv + initialTerminalEnvironment: initialEnv, + select: shouldFocus ) newId = ws.id } @@ -2753,13 +3107,8 @@ class TerminalController { result = .err(code: "not_found", message: "Workspace not found", data: nil) return } - if let windowId = v2ResolveWindowId(tabManager: tabManager) { - _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) - setActiveTabManager(tabManager) - } - if tabManager.selectedTabId != ws.id { - tabManager.selectWorkspace(ws) - } + v2MaybeFocusWindow(for: tabManager) + v2MaybeSelectWorkspace(tabManager, workspace: ws) let paneUUID = v2UUID(params, "pane_id") let paneId: PaneID? = { @@ -2776,9 +3125,9 @@ class TerminalController { let newPanelId: UUID? if panelType == .browser { - newPanelId = ws.newBrowserSurface(inPane: paneId, url: url, focus: true)?.id + newPanelId = ws.newBrowserSurface(inPane: paneId, url: url, focus: v2FocusAllowed())?.id } else { - newPanelId = ws.newTerminalSurface(inPane: paneId, focus: true)?.id + newPanelId = ws.newTerminalSurface(inPane: paneId, focus: v2FocusAllowed())?.id } guard let newPanelId else { @@ -3581,13 +3930,8 @@ class TerminalController { result = .err(code: "not_found", message: "Workspace not found", data: nil) return } - if let windowId = v2ResolveWindowId(tabManager: tabManager) { - _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) - setActiveTabManager(tabManager) - } - if tabManager.selectedTabId != ws.id { - tabManager.selectWorkspace(ws) - } + v2MaybeFocusWindow(for: tabManager) + v2MaybeSelectWorkspace(tabManager, workspace: ws) guard let focusedPanelId = ws.focusedPanelId else { result = .err(code: "not_found", message: "No focused surface to split", data: nil) return @@ -3595,9 +3939,20 @@ class TerminalController { let newPanelId: UUID? if panelType == .browser { - newPanelId = ws.newBrowserSplit(from: focusedPanelId, orientation: orientation, insertFirst: insertFirst, url: url)?.id + newPanelId = ws.newBrowserSplit( + from: focusedPanelId, + orientation: orientation, + insertFirst: insertFirst, + url: url, + focus: v2FocusAllowed() + )?.id } else { - newPanelId = ws.newTerminalSplit(from: focusedPanelId, orientation: orientation, insertFirst: insertFirst)?.id + newPanelId = ws.newTerminalSplit( + from: focusedPanelId, + orientation: orientation, + insertFirst: insertFirst, + focus: v2FocusAllowed() + )?.id } guard let newPanelId else { @@ -7485,6 +7840,294 @@ class TerminalController { return resp == "OK" ? .ok([:]) : .err(code: "internal_error", message: resp, data: nil) } + private func v2DebugToggleCommandPalette(params: [String: Any]) -> V2CallResult { + let requestedWindowId = v2UUID(params, "window_id") + var result: V2CallResult = .ok([:]) + DispatchQueue.main.sync { + let targetWindow: NSWindow? + if let requestedWindowId { + guard let window = AppDelegate.shared?.mainWindow(for: requestedWindowId) else { + result = .err( + code: "not_found", + message: "Window not found", + data: ["window_id": requestedWindowId.uuidString, "window_ref": v2Ref(kind: .window, uuid: requestedWindowId)] + ) + return + } + targetWindow = window + } else { + targetWindow = NSApp.keyWindow ?? NSApp.mainWindow + } + NotificationCenter.default.post(name: .commandPaletteToggleRequested, object: targetWindow) + } + return result + } + + private func v2DebugOpenCommandPaletteRenameTabInput(params: [String: Any]) -> V2CallResult { + let requestedWindowId = v2UUID(params, "window_id") + var result: V2CallResult = .ok([:]) + DispatchQueue.main.sync { + let targetWindow: NSWindow? + if let requestedWindowId { + guard let window = AppDelegate.shared?.mainWindow(for: requestedWindowId) else { + result = .err( + code: "not_found", + message: "Window not found", + data: [ + "window_id": requestedWindowId.uuidString, + "window_ref": v2Ref(kind: .window, uuid: requestedWindowId) + ] + ) + return + } + targetWindow = window + } else { + targetWindow = NSApp.keyWindow ?? NSApp.mainWindow + } + NotificationCenter.default.post(name: .commandPaletteRenameTabRequested, object: targetWindow) + } + return result + } + + private func v2DebugCommandPaletteVisible(params: [String: Any]) -> V2CallResult { + guard let windowId = v2UUID(params, "window_id") else { + return .err(code: "invalid_params", message: "Missing or invalid window_id", data: nil) + } + var visible = false + DispatchQueue.main.sync { + visible = AppDelegate.shared?.isCommandPaletteVisible(windowId: windowId) ?? false + } + return .ok([ + "window_id": windowId.uuidString, + "window_ref": v2Ref(kind: .window, uuid: windowId), + "visible": visible + ]) + } + + private func v2DebugCommandPaletteSelection(params: [String: Any]) -> V2CallResult { + guard let windowId = v2UUID(params, "window_id") else { + return .err(code: "invalid_params", message: "Missing or invalid window_id", data: nil) + } + var visible = false + var selectedIndex = 0 + DispatchQueue.main.sync { + visible = AppDelegate.shared?.isCommandPaletteVisible(windowId: windowId) ?? false + selectedIndex = AppDelegate.shared?.commandPaletteSelectionIndex(windowId: windowId) ?? 0 + } + return .ok([ + "window_id": windowId.uuidString, + "window_ref": v2Ref(kind: .window, uuid: windowId), + "visible": visible, + "selected_index": max(0, selectedIndex) + ]) + } + + private func v2DebugCommandPaletteResults(params: [String: Any]) -> V2CallResult { + guard let windowId = v2UUID(params, "window_id") else { + return .err(code: "invalid_params", message: "Missing or invalid window_id", data: nil) + } + let requestedLimit = params["limit"] as? Int + let limit = max(1, min(100, requestedLimit ?? 20)) + + var visible = false + var selectedIndex = 0 + var snapshot = CommandPaletteDebugSnapshot.empty + + DispatchQueue.main.sync { + visible = AppDelegate.shared?.isCommandPaletteVisible(windowId: windowId) ?? false + selectedIndex = AppDelegate.shared?.commandPaletteSelectionIndex(windowId: windowId) ?? 0 + snapshot = AppDelegate.shared?.commandPaletteSnapshot(windowId: windowId) ?? .empty + } + + let rows = Array(snapshot.results.prefix(limit)).map { row in + [ + "command_id": row.commandId, + "title": row.title, + "shortcut_hint": v2OrNull(row.shortcutHint), + "trailing_label": v2OrNull(row.trailingLabel), + "score": row.score + ] as [String: Any] + } + + return .ok([ + "window_id": windowId.uuidString, + "window_ref": v2Ref(kind: .window, uuid: windowId), + "visible": visible, + "selected_index": max(0, selectedIndex), + "query": snapshot.query, + "mode": snapshot.mode, + "results": rows + ]) + } + + private func v2DebugCommandPaletteRenameInputInteraction(params: [String: Any]) -> V2CallResult { + let requestedWindowId = v2UUID(params, "window_id") + var result: V2CallResult = .ok([:]) + DispatchQueue.main.sync { + let targetWindow: NSWindow? + if let requestedWindowId { + guard let window = AppDelegate.shared?.mainWindow(for: requestedWindowId) else { + result = .err( + code: "not_found", + message: "Window not found", + data: [ + "window_id": requestedWindowId.uuidString, + "window_ref": v2Ref(kind: .window, uuid: requestedWindowId) + ] + ) + return + } + targetWindow = window + } else { + targetWindow = NSApp.keyWindow ?? NSApp.mainWindow + } + NotificationCenter.default.post(name: .commandPaletteRenameInputInteractionRequested, object: targetWindow) + } + return result + } + + private func v2DebugCommandPaletteRenameInputDeleteBackward(params: [String: Any]) -> V2CallResult { + let requestedWindowId = v2UUID(params, "window_id") + var result: V2CallResult = .ok([:]) + DispatchQueue.main.sync { + let targetWindow: NSWindow? + if let requestedWindowId { + guard let window = AppDelegate.shared?.mainWindow(for: requestedWindowId) else { + result = .err( + code: "not_found", + message: "Window not found", + data: [ + "window_id": requestedWindowId.uuidString, + "window_ref": v2Ref(kind: .window, uuid: requestedWindowId) + ] + ) + return + } + targetWindow = window + } else { + targetWindow = NSApp.keyWindow ?? NSApp.mainWindow + } + NotificationCenter.default.post(name: .commandPaletteRenameInputDeleteBackwardRequested, object: targetWindow) + } + return result + } + + private func v2DebugCommandPaletteRenameInputSelection(params: [String: Any]) -> V2CallResult { + guard let windowId = v2UUID(params, "window_id") else { + return .err(code: "invalid_params", message: "Missing or invalid window_id", data: nil) + } + + var result: V2CallResult = .ok([ + "window_id": windowId.uuidString, + "window_ref": v2Ref(kind: .window, uuid: windowId), + "focused": false, + "selection_location": 0, + "selection_length": 0, + "text_length": 0 + ]) + + DispatchQueue.main.sync { + guard let window = AppDelegate.shared?.mainWindow(for: windowId) else { + result = .err( + code: "not_found", + message: "Window not found", + data: ["window_id": windowId.uuidString, "window_ref": v2Ref(kind: .window, uuid: windowId)] + ) + return + } + guard let editor = window.firstResponder as? NSTextView, editor.isFieldEditor else { + return + } + let selectedRange = editor.selectedRange() + let textLength = (editor.string as NSString).length + result = .ok([ + "window_id": windowId.uuidString, + "window_ref": v2Ref(kind: .window, uuid: windowId), + "focused": true, + "selection_location": max(0, selectedRange.location), + "selection_length": max(0, selectedRange.length), + "text_length": max(0, textLength) + ]) + } + + return result + } + + private func v2DebugCommandPaletteRenameInputSelectAll(params: [String: Any]) -> V2CallResult { + if let rawEnabled = params["enabled"] { + guard let enabled = rawEnabled as? Bool else { + return .err( + code: "invalid_params", + message: "enabled must be a bool", + data: ["enabled": rawEnabled] + ) + } + DispatchQueue.main.sync { + UserDefaults.standard.set( + enabled, + forKey: CommandPaletteRenameSelectionSettings.selectAllOnFocusKey + ) + } + } + + var enabled = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus + DispatchQueue.main.sync { + enabled = CommandPaletteRenameSelectionSettings.selectAllOnFocusEnabled() + } + + return .ok([ + "enabled": enabled + ]) + } + + private func v2DebugBrowserAddressBarFocused(params: [String: Any]) -> V2CallResult { + let requestedSurfaceId = v2UUID(params, "surface_id") ?? v2UUID(params, "panel_id") + var focusedSurfaceId: UUID? + DispatchQueue.main.sync { + focusedSurfaceId = AppDelegate.shared?.focusedBrowserAddressBarPanelId() + } + + var payload: [String: Any] = [ + "focused_surface_id": v2OrNull(focusedSurfaceId?.uuidString), + "focused_surface_ref": v2Ref(kind: .surface, uuid: focusedSurfaceId), + "focused_panel_id": v2OrNull(focusedSurfaceId?.uuidString), + "focused_panel_ref": v2Ref(kind: .surface, uuid: focusedSurfaceId), + "focused": focusedSurfaceId != nil + ] + + if let requestedSurfaceId { + payload["surface_id"] = requestedSurfaceId.uuidString + payload["surface_ref"] = v2Ref(kind: .surface, uuid: requestedSurfaceId) + payload["panel_id"] = requestedSurfaceId.uuidString + payload["panel_ref"] = v2Ref(kind: .surface, uuid: requestedSurfaceId) + payload["focused"] = (focusedSurfaceId == requestedSurfaceId) + } + + return .ok(payload) + } + + private func v2DebugSidebarVisible(params: [String: Any]) -> V2CallResult { + guard let windowId = v2UUID(params, "window_id") else { + return .err(code: "invalid_params", message: "Missing or invalid window_id", data: nil) + } + var visibility: Bool? + DispatchQueue.main.sync { + visibility = AppDelegate.shared?.sidebarVisibility(windowId: windowId) + } + guard let visible = visibility else { + return .err( + code: "not_found", + message: "Window not found", + data: ["window_id": windowId.uuidString, "window_ref": v2Ref(kind: .window, uuid: windowId)] + ) + } + return .ok([ + "window_id": windowId.uuidString, + "window_ref": v2Ref(kind: .window, uuid: windowId), + "visible": visible + ]) + } + private func v2DebugIsTerminalFocused(params: [String: Any]) -> V2CallResult { guard let surfaceId = v2String(params, "surface_id") else { return .err(code: "invalid_params", message: "Missing surface_id", data: nil) @@ -8838,9 +9481,10 @@ class TerminalController { guard let tabManager = tabManager else { return "ERROR: TabManager not available" } var newTabId: UUID? + let focus = socketCommandAllowsInAppFocusMutations() DispatchQueue.main.sync { - tabManager.addTab() - newTabId = tabManager.selectedTabId + let workspace = tabManager.addTab(select: focus) + newTabId = workspace.id } return "OK \(newTabId?.uuidString ?? "unknown")" } @@ -9821,6 +10465,46 @@ class TerminalController { sendKeyEvent(surface: surface, keycode: 0, text: text) } + enum SocketTextChunk: Equatable { + case text(String) + case control(UnicodeScalar) + } + + nonisolated static func socketTextChunks(_ text: String) -> [SocketTextChunk] { + guard !text.isEmpty else { return [] } + + var chunks: [SocketTextChunk] = [] + chunks.reserveCapacity(8) + var bufferedText = "" + bufferedText.reserveCapacity(text.count) + + func flushBufferedText() { + guard !bufferedText.isEmpty else { return } + chunks.append(.text(bufferedText)) + bufferedText.removeAll(keepingCapacity: true) + } + + for scalar in text.unicodeScalars { + if isSocketControlScalar(scalar) { + flushBufferedText() + chunks.append(.control(scalar)) + } else { + bufferedText.unicodeScalars.append(scalar) + } + } + flushBufferedText() + return chunks + } + + private nonisolated static func isSocketControlScalar(_ scalar: UnicodeScalar) -> Bool { + switch scalar.value { + case 0x0A, 0x0D, 0x09, 0x1B, 0x7F: + return true + default: + return false + } + } + private func handleControlScalar(_ scalar: UnicodeScalar, surface: ghostty_surface_t) -> Bool { switch scalar.value { case 0x0A, 0x0D: diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 4587b4ff..f995779b 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -13,6 +13,11 @@ struct SidebarStatusEntry { let timestamp: Date } +private struct SessionPaneRestoreEntry { + let paneId: PaneID + let snapshot: SessionPaneLayoutSnapshot +} + private final class WorkspaceRemoteSessionController { private struct ForwardEntry { let process: Process @@ -431,7 +436,11 @@ private final class WorkspaceRemoteSessionController { // Kill orphaned relay SSH processes from previous app sessions that reverse-forward // to the same socket path (they survive pkill because they're reparented to launchd). - Self.killOrphanedRelayProcesses(socketPath: localSocketPath, destination: configuration.destination) + Self.killOrphanedRelayProcesses( + relayPort: relayPort, + socketPath: localSocketPath, + destination: configuration.destination + ) let process = Process() let stderrPipe = Pipe() @@ -944,7 +953,9 @@ private final class WorkspaceRemoteSessionController { private static func probeScript() -> String { """ set -eu - CMUX_LAST="" + # Force an initial emission so the controller can transition out of + # "connecting" even when no ports are detected. + CMUX_LAST="__cmux_init__" while true; do if command -v ss >/dev/null 2>&1; then PORTS="$(ss -ltnH 2>/dev/null | awk '{print $4}' | sed -E 's/.*:([0-9]+)$/\\1/' | awk '/^[0-9]+$/ {print $1}' | sort -n -u | tr '\\n' ' ')" @@ -1095,11 +1106,15 @@ private final class WorkspaceRemoteSessionController { /// Kills orphaned SSH relay processes from previous app sessions. /// These processes survive app restarts because `pkill` doesn't trigger graceful cleanup. - private static func killOrphanedRelayProcesses(socketPath: String, destination: String) { + private static func killOrphanedRelayProcesses(relayPort: Int, socketPath: String, destination: String) { + guard relayPort > 0 else { return } let pipe = Pipe() let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/pkill") - process.arguments = ["-f", "ssh.*-R.*127\\.0\\.0\\.1:.*:\(socketPath).*\(destination)"] + let socketPathPattern = NSRegularExpression.escapedPattern(for: socketPath) + let destinationPattern = NSRegularExpression.escapedPattern(for: destination) + let relayPattern = "ssh.*-R[[:space:]]*127\\.0\\.0\\.1:\(relayPort):\(socketPathPattern).*\(destinationPattern)" + process.arguments = ["-f", relayPattern] process.standardOutput = pipe process.standardError = pipe do { @@ -1182,6 +1197,187 @@ struct SidebarGitBranchState { let isDirty: Bool } +enum SidebarBranchOrdering { + struct BranchEntry: Equatable { + let name: String + let isDirty: Bool + } + + struct BranchDirectoryEntry: Equatable { + let branch: String? + let isDirty: Bool + let directory: String? + } + + static func orderedPaneIds(tree: ExternalTreeNode) -> [String] { + switch tree { + case .pane(let pane): + return [pane.id] + case .split(let split): + return orderedPaneIds(tree: split.first) + orderedPaneIds(tree: split.second) + } + } + + static func orderedPanelIds( + tree: ExternalTreeNode, + paneTabs: [String: [UUID]], + fallbackPanelIds: [UUID] + ) -> [UUID] { + var ordered: [UUID] = [] + var seen: Set<UUID> = [] + + for paneId in orderedPaneIds(tree: tree) { + for panelId in paneTabs[paneId] ?? [] { + if seen.insert(panelId).inserted { + ordered.append(panelId) + } + } + } + + for panelId in fallbackPanelIds { + if seen.insert(panelId).inserted { + ordered.append(panelId) + } + } + + return ordered + } + + static func orderedUniqueBranches( + orderedPanelIds: [UUID], + panelBranches: [UUID: SidebarGitBranchState], + fallbackBranch: SidebarGitBranchState? + ) -> [BranchEntry] { + var orderedNames: [String] = [] + var branchDirty: [String: Bool] = [:] + + for panelId in orderedPanelIds { + guard let state = panelBranches[panelId] else { continue } + let name = state.branch.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty else { continue } + + if branchDirty[name] == nil { + orderedNames.append(name) + branchDirty[name] = state.isDirty + } else if state.isDirty { + branchDirty[name] = true + } + } + + if orderedNames.isEmpty, let fallbackBranch { + let name = fallbackBranch.branch.trimmingCharacters(in: .whitespacesAndNewlines) + if !name.isEmpty { + return [BranchEntry(name: name, isDirty: fallbackBranch.isDirty)] + } + } + + return orderedNames.map { name in + BranchEntry(name: name, isDirty: branchDirty[name] ?? false) + } + } + + static func orderedUniqueBranchDirectoryEntries( + orderedPanelIds: [UUID], + panelBranches: [UUID: SidebarGitBranchState], + panelDirectories: [UUID: String], + defaultDirectory: String?, + fallbackBranch: SidebarGitBranchState? + ) -> [BranchDirectoryEntry] { + struct EntryKey: Hashable { + let directory: String? + let branch: String? + } + + struct MutableEntry { + var branch: String? + var isDirty: Bool + var directory: String? + } + + func normalized(_ text: String?) -> String? { + guard let text else { return nil } + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + func canonicalDirectoryKey(_ directory: String?) -> String? { + guard let directory = normalized(directory) else { return nil } + let expanded = NSString(string: directory).expandingTildeInPath + let standardized = NSString(string: expanded).standardizingPath + let cleaned = standardized.trimmingCharacters(in: .whitespacesAndNewlines) + return cleaned.isEmpty ? nil : cleaned + } + + let normalizedFallbackBranch = normalized(fallbackBranch?.branch) + let shouldUseFallbackBranchPerPanel = !orderedPanelIds.contains { + normalized(panelBranches[$0]?.branch) != nil + } + let defaultBranchForPanels = shouldUseFallbackBranchPerPanel ? normalizedFallbackBranch : nil + let defaultBranchDirty = shouldUseFallbackBranchPerPanel ? (fallbackBranch?.isDirty ?? false) : false + + var order: [EntryKey] = [] + var entries: [EntryKey: MutableEntry] = [:] + + for panelId in orderedPanelIds { + let panelBranch = normalized(panelBranches[panelId]?.branch) + let branch = panelBranch ?? defaultBranchForPanels + let directory = normalized(panelDirectories[panelId] ?? defaultDirectory) + guard branch != nil || directory != nil else { continue } + + let panelDirty = panelBranch != nil + ? (panelBranches[panelId]?.isDirty ?? false) + : defaultBranchDirty + + let key: EntryKey + if let directoryKey = canonicalDirectoryKey(directory) { + key = EntryKey(directory: directoryKey, branch: nil) + } else { + key = EntryKey(directory: nil, branch: branch) + } + + if entries[key] == nil { + order.append(key) + entries[key] = MutableEntry( + branch: branch, + isDirty: panelDirty, + directory: directory + ) + } else { + if panelDirty { + entries[key]?.isDirty = true + } + if let branch { + entries[key]?.branch = branch + } + if let directory { + entries[key]?.directory = directory + } + } + } + + if order.isEmpty, let fallbackBranch { + let branch = normalized(fallbackBranch.branch) + let directory = normalized(defaultDirectory) + if branch != nil || directory != nil { + return [BranchDirectoryEntry( + branch: branch, + isDirty: fallbackBranch.isDirty, + directory: directory + )] + } + } + + return order.compactMap { key in + guard let entry = entries[key] else { return nil } + return BranchDirectoryEntry( + branch: entry.branch, + isDirty: entry.isDirty, + directory: entry.directory + ) + } + } +} + enum WorkspaceRemoteConnectionState: String { case disconnected case connecting @@ -1230,6 +1426,16 @@ struct WorkspaceRemoteConfiguration: Equatable { } } +struct ClosedBrowserPanelRestoreSnapshot { + let workspaceId: UUID + let url: URL? + let originalPaneId: UUID + let originalTabIndex: Int + let fallbackSplitOrientation: SplitOrientation? + let fallbackSplitInsertFirst: Bool + let fallbackAnchorPaneId: UUID? +} + /// Workspace represents a sidebar tab. /// Each workspace contains one BonsplitController that manages split panes and nested surfaces. @MainActor @@ -1238,6 +1444,7 @@ final class Workspace: Identifiable, ObservableObject { @Published var title: String @Published var customTitle: String? @Published var isPinned: Bool = false + @Published var customColor: String? @Published var currentDirectory: String /// Ordinal for CMUX_PORT range assignment (monotonically increasing per app session) @@ -1254,6 +1461,485 @@ final class Workspace: Identifiable, ObservableObject { /// When true, suppresses auto-creation in didSplitPane (programmatic splits handle their own panels) private var isProgrammaticSplit = false + var onClosedBrowserPanel: ((ClosedBrowserPanelRestoreSnapshot) -> Void)? + + nonisolated static func resolvedSnapshotTerminalScrollback( + capturedScrollback: String?, + fallbackScrollback: String? + ) -> String? { + if let captured = SessionPersistencePolicy.truncatedScrollback(capturedScrollback) { + return captured + } + return SessionPersistencePolicy.truncatedScrollback(fallbackScrollback) + } + + func sessionSnapshot(includeScrollback: Bool) -> SessionWorkspaceSnapshot { + let tree = bonsplitController.treeSnapshot() + let layout = sessionLayoutSnapshot(from: tree) + + let orderedPanelIds = sidebarOrderedPanelIds() + var seen: Set<UUID> = [] + var allPanelIds: [UUID] = [] + for panelId in orderedPanelIds where seen.insert(panelId).inserted { + allPanelIds.append(panelId) + } + for panelId in panels.keys.sorted(by: { $0.uuidString < $1.uuidString }) where seen.insert(panelId).inserted { + allPanelIds.append(panelId) + } + + let panelSnapshots = allPanelIds + .prefix(SessionPersistencePolicy.maxPanelsPerWorkspace) + .compactMap { sessionPanelSnapshot(panelId: $0, includeScrollback: includeScrollback) } + + let statusSnapshots = statusEntries.values + .sorted { lhs, rhs in lhs.key < rhs.key } + .map { entry in + SessionStatusEntrySnapshot( + key: entry.key, + value: entry.value, + icon: entry.icon, + color: entry.color, + timestamp: entry.timestamp.timeIntervalSince1970 + ) + } + let logSnapshots = logEntries.map { entry in + SessionLogEntrySnapshot( + message: entry.message, + level: entry.level.rawValue, + source: entry.source, + timestamp: entry.timestamp.timeIntervalSince1970 + ) + } + + let progressSnapshot = progress.map { progress in + SessionProgressSnapshot(value: progress.value, label: progress.label) + } + let gitBranchSnapshot = gitBranch.map { branch in + SessionGitBranchSnapshot(branch: branch.branch, isDirty: branch.isDirty) + } + + return SessionWorkspaceSnapshot( + processTitle: processTitle, + customTitle: customTitle, + customColor: customColor, + isPinned: isPinned, + currentDirectory: currentDirectory, + focusedPanelId: focusedPanelId, + layout: layout, + panels: panelSnapshots, + statusEntries: statusSnapshots, + logEntries: logSnapshots, + progress: progressSnapshot, + gitBranch: gitBranchSnapshot + ) + } + + func restoreSessionSnapshot(_ snapshot: SessionWorkspaceSnapshot) { + restoredTerminalScrollbackByPanelId.removeAll(keepingCapacity: false) + + let normalizedCurrentDirectory = snapshot.currentDirectory.trimmingCharacters(in: .whitespacesAndNewlines) + if !normalizedCurrentDirectory.isEmpty { + currentDirectory = normalizedCurrentDirectory + } + + let panelSnapshotsById = Dictionary(uniqueKeysWithValues: snapshot.panels.map { ($0.id, $0) }) + let leafEntries = restoreSessionLayout(snapshot.layout) + var oldToNewPanelIds: [UUID: UUID] = [:] + + for entry in leafEntries { + restorePane( + entry.paneId, + snapshot: entry.snapshot, + panelSnapshotsById: panelSnapshotsById, + oldToNewPanelIds: &oldToNewPanelIds + ) + } + + pruneSurfaceMetadata(validSurfaceIds: Set(panels.keys)) + applySessionDividerPositions(snapshotNode: snapshot.layout, liveNode: bonsplitController.treeSnapshot()) + + applyProcessTitle(snapshot.processTitle) + setCustomTitle(snapshot.customTitle) + setCustomColor(snapshot.customColor) + isPinned = snapshot.isPinned + + statusEntries = Dictionary( + uniqueKeysWithValues: snapshot.statusEntries.map { entry in + ( + entry.key, + SidebarStatusEntry( + key: entry.key, + value: entry.value, + icon: entry.icon, + color: entry.color, + timestamp: Date(timeIntervalSince1970: entry.timestamp) + ) + ) + } + ) + logEntries = snapshot.logEntries.map { entry in + SidebarLogEntry( + message: entry.message, + level: SidebarLogLevel(rawValue: entry.level) ?? .info, + source: entry.source, + timestamp: Date(timeIntervalSince1970: entry.timestamp) + ) + } + progress = snapshot.progress.map { SidebarProgressState(value: $0.value, label: $0.label) } + gitBranch = snapshot.gitBranch.map { SidebarGitBranchState(branch: $0.branch, isDirty: $0.isDirty) } + + recomputeListeningPorts() + + if let focusedOldPanelId = snapshot.focusedPanelId, + let focusedNewPanelId = oldToNewPanelIds[focusedOldPanelId], + panels[focusedNewPanelId] != nil { + focusPanel(focusedNewPanelId) + } else if let fallbackFocusedPanelId = focusedPanelId, panels[fallbackFocusedPanelId] != nil { + focusPanel(fallbackFocusedPanelId) + } else { + scheduleFocusReconcile() + } + } + + private func sessionLayoutSnapshot(from node: ExternalTreeNode) -> SessionWorkspaceLayoutSnapshot { + switch node { + case .pane(let pane): + let panelIds = sessionPanelIDs(for: pane) + let selectedPanelId = pane.selectedTabId.flatMap(sessionPanelID(forExternalTabIDString:)) + return .pane( + SessionPaneLayoutSnapshot( + panelIds: panelIds, + selectedPanelId: selectedPanelId + ) + ) + case .split(let split): + return .split( + SessionSplitLayoutSnapshot( + orientation: split.orientation.lowercased() == "vertical" ? .vertical : .horizontal, + dividerPosition: split.dividerPosition, + first: sessionLayoutSnapshot(from: split.first), + second: sessionLayoutSnapshot(from: split.second) + ) + ) + } + } + + private func sessionPanelIDs(for pane: ExternalPaneNode) -> [UUID] { + var panelIds: [UUID] = [] + var seen = Set<UUID>() + for tab in pane.tabs { + guard let panelId = sessionPanelID(forExternalTabIDString: tab.id) else { continue } + if seen.insert(panelId).inserted { + panelIds.append(panelId) + } + } + return panelIds + } + + private func sessionPanelID(forExternalTabIDString tabIDString: String) -> UUID? { + guard let tabUUID = UUID(uuidString: tabIDString) else { return nil } + for (surfaceId, panelId) in surfaceIdToPanelId { + guard let surfaceUUID = sessionSurfaceUUID(for: surfaceId) else { continue } + if surfaceUUID == tabUUID { + return panelId + } + } + return nil + } + + private func sessionSurfaceUUID(for surfaceId: TabID) -> UUID? { + struct EncodedSurfaceID: Decodable { + let id: UUID + } + + guard let data = try? JSONEncoder().encode(surfaceId), + let decoded = try? JSONDecoder().decode(EncodedSurfaceID.self, from: data) else { + return nil + } + return decoded.id + } + + private func sessionPanelSnapshot(panelId: UUID, includeScrollback: Bool) -> SessionPanelSnapshot? { + guard let panel = panels[panelId] else { return nil } + + let panelTitle = panelTitle(panelId: panelId) + let customTitle = panelCustomTitles[panelId] + let directory = panelDirectories[panelId] + let isPinned = pinnedPanelIds.contains(panelId) + let isManuallyUnread = manualUnreadPanelIds.contains(panelId) + let branchSnapshot = panelGitBranches[panelId].map { + SessionGitBranchSnapshot(branch: $0.branch, isDirty: $0.isDirty) + } + let listeningPorts = (surfaceListeningPorts[panelId] ?? []).sorted() + let ttyName = surfaceTTYNames[panelId] + + let terminalSnapshot: SessionTerminalPanelSnapshot? + let browserSnapshot: SessionBrowserPanelSnapshot? + switch panel.panelType { + case .terminal: + guard let _ = panel as? TerminalPanel else { return nil } + let resolvedScrollback = terminalSnapshotScrollback( + panelId: panelId, + capturedScrollback: nil, + includeScrollback: includeScrollback + ) + terminalSnapshot = SessionTerminalPanelSnapshot( + workingDirectory: panelDirectories[panelId], + scrollback: resolvedScrollback + ) + browserSnapshot = nil + case .browser: + guard let browserPanel = panel as? BrowserPanel else { return nil } + terminalSnapshot = nil + let historySnapshot = browserPanel.sessionNavigationHistorySnapshot() + browserSnapshot = SessionBrowserPanelSnapshot( + urlString: browserPanel.preferredURLStringForOmnibar(), + shouldRenderWebView: browserPanel.shouldRenderWebView, + pageZoom: Double(browserPanel.webView.pageZoom), + developerToolsVisible: browserPanel.isDeveloperToolsVisible(), + backHistoryURLStrings: historySnapshot.backHistoryURLStrings, + forwardHistoryURLStrings: historySnapshot.forwardHistoryURLStrings + ) + } + + return SessionPanelSnapshot( + id: panelId, + type: panel.panelType, + title: panelTitle, + customTitle: customTitle, + directory: directory, + isPinned: isPinned, + isManuallyUnread: isManuallyUnread, + gitBranch: branchSnapshot, + listeningPorts: listeningPorts, + ttyName: ttyName, + terminal: terminalSnapshot, + browser: browserSnapshot + ) + } + + private func terminalSnapshotScrollback( + panelId: UUID, + capturedScrollback: String?, + includeScrollback: Bool + ) -> String? { + guard includeScrollback else { return nil } + let fallback = restoredTerminalScrollbackByPanelId[panelId] + let resolved = Self.resolvedSnapshotTerminalScrollback( + capturedScrollback: capturedScrollback, + fallbackScrollback: fallback + ) + if let resolved { + restoredTerminalScrollbackByPanelId[panelId] = resolved + } else { + restoredTerminalScrollbackByPanelId.removeValue(forKey: panelId) + } + return resolved + } + + private func restoreSessionLayout(_ layout: SessionWorkspaceLayoutSnapshot) -> [SessionPaneRestoreEntry] { + guard let rootPaneId = bonsplitController.allPaneIds.first else { + return [] + } + + var leaves: [SessionPaneRestoreEntry] = [] + restoreSessionLayoutNode(layout, inPane: rootPaneId, leaves: &leaves) + return leaves + } + + private func restoreSessionLayoutNode( + _ node: SessionWorkspaceLayoutSnapshot, + inPane paneId: PaneID, + leaves: inout [SessionPaneRestoreEntry] + ) { + switch node { + case .pane(let pane): + leaves.append(SessionPaneRestoreEntry(paneId: paneId, snapshot: pane)) + case .split(let split): + var anchorPanelId = bonsplitController + .tabs(inPane: paneId) + .compactMap { panelIdFromSurfaceId($0.id) } + .first + + if anchorPanelId == nil { + anchorPanelId = newTerminalSurface(inPane: paneId, focus: false)?.id + } + + guard let anchorPanelId, + let newSplitPanel = newTerminalSplit( + from: anchorPanelId, + orientation: split.orientation.splitOrientation, + insertFirst: false, + focus: false + ), + let secondPaneId = self.paneId(forPanelId: newSplitPanel.id) else { + leaves.append( + SessionPaneRestoreEntry( + paneId: paneId, + snapshot: SessionPaneLayoutSnapshot(panelIds: [], selectedPanelId: nil) + ) + ) + return + } + + restoreSessionLayoutNode(split.first, inPane: paneId, leaves: &leaves) + restoreSessionLayoutNode(split.second, inPane: secondPaneId, leaves: &leaves) + } + } + + private func restorePane( + _ paneId: PaneID, + snapshot: SessionPaneLayoutSnapshot, + panelSnapshotsById: [UUID: SessionPanelSnapshot], + oldToNewPanelIds: inout [UUID: UUID] + ) { + let existingPanelIds = bonsplitController + .tabs(inPane: paneId) + .compactMap { panelIdFromSurfaceId($0.id) } + let desiredOldPanelIds = snapshot.panelIds.filter { panelSnapshotsById[$0] != nil } + + var createdPanelIds: [UUID] = [] + for oldPanelId in desiredOldPanelIds { + guard let panelSnapshot = panelSnapshotsById[oldPanelId] else { continue } + guard let createdPanelId = createPanel(from: panelSnapshot, inPane: paneId) else { continue } + createdPanelIds.append(createdPanelId) + oldToNewPanelIds[oldPanelId] = createdPanelId + } + + guard !createdPanelIds.isEmpty else { return } + + for oldPanelId in existingPanelIds where !createdPanelIds.contains(oldPanelId) { + _ = closePanel(oldPanelId, force: true) + } + + for (index, panelId) in createdPanelIds.enumerated() { + _ = reorderSurface(panelId: panelId, toIndex: index) + } + + let selectedPanelId: UUID? = { + if let selectedOldId = snapshot.selectedPanelId { + return oldToNewPanelIds[selectedOldId] + } + return createdPanelIds.first + }() + + if let selectedPanelId, + let selectedTabId = surfaceIdFromPanelId(selectedPanelId) { + bonsplitController.focusPane(paneId) + bonsplitController.selectTab(selectedTabId) + } + } + + private func createPanel(from snapshot: SessionPanelSnapshot, inPane paneId: PaneID) -> UUID? { + switch snapshot.type { + case .terminal: + let workingDirectory = snapshot.terminal?.workingDirectory ?? snapshot.directory ?? currentDirectory + let replayEnvironment = SessionScrollbackReplayStore.replayEnvironment( + for: snapshot.terminal?.scrollback + ) + guard let terminalPanel = newTerminalSurface( + inPane: paneId, + focus: false, + workingDirectory: workingDirectory, + startupEnvironment: replayEnvironment + ) else { + return nil + } + let fallbackScrollback = SessionPersistencePolicy.truncatedScrollback(snapshot.terminal?.scrollback) + if let fallbackScrollback { + restoredTerminalScrollbackByPanelId[terminalPanel.id] = fallbackScrollback + } else { + restoredTerminalScrollbackByPanelId.removeValue(forKey: terminalPanel.id) + } + applySessionPanelMetadata(snapshot, toPanelId: terminalPanel.id) + return terminalPanel.id + case .browser: + let initialURL = snapshot.browser?.urlString.flatMap { URL(string: $0) } + guard let browserPanel = newBrowserSurface( + inPane: paneId, + url: initialURL, + focus: false + ) else { + return nil + } + applySessionPanelMetadata(snapshot, toPanelId: browserPanel.id) + return browserPanel.id + } + } + + private func applySessionPanelMetadata(_ snapshot: SessionPanelSnapshot, toPanelId panelId: UUID) { + if let title = snapshot.title?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty { + panelTitles[panelId] = title + } + + setPanelCustomTitle(panelId: panelId, title: snapshot.customTitle) + setPanelPinned(panelId: panelId, pinned: snapshot.isPinned) + + if snapshot.isManuallyUnread { + markPanelUnread(panelId) + } else { + clearManualUnread(panelId: panelId) + } + + if let directory = snapshot.directory?.trimmingCharacters(in: .whitespacesAndNewlines), !directory.isEmpty { + updatePanelDirectory(panelId: panelId, directory: directory) + } + + if let branch = snapshot.gitBranch { + panelGitBranches[panelId] = SidebarGitBranchState(branch: branch.branch, isDirty: branch.isDirty) + } else { + panelGitBranches.removeValue(forKey: panelId) + } + + surfaceListeningPorts[panelId] = Array(Set(snapshot.listeningPorts)).sorted() + + if let ttyName = snapshot.ttyName?.trimmingCharacters(in: .whitespacesAndNewlines), !ttyName.isEmpty { + surfaceTTYNames[panelId] = ttyName + } else { + surfaceTTYNames.removeValue(forKey: panelId) + } + + if let browserSnapshot = snapshot.browser, + let browserPanel = browserPanel(for: panelId) { + browserPanel.restoreSessionNavigationHistory( + backHistoryURLStrings: browserSnapshot.backHistoryURLStrings ?? [], + forwardHistoryURLStrings: browserSnapshot.forwardHistoryURLStrings ?? [], + currentURLString: browserSnapshot.urlString + ) + + let pageZoom = CGFloat(max(0.25, min(5.0, browserSnapshot.pageZoom))) + if pageZoom.isFinite { + browserPanel.webView.pageZoom = pageZoom + } + + if browserSnapshot.developerToolsVisible { + _ = browserPanel.showDeveloperTools() + browserPanel.requestDeveloperToolsRefreshAfterNextAttach(reason: "session_restore") + } else { + _ = browserPanel.hideDeveloperTools() + } + } + } + + private func applySessionDividerPositions( + snapshotNode: SessionWorkspaceLayoutSnapshot, + liveNode: ExternalTreeNode + ) { + switch (snapshotNode, liveNode) { + case (.split(let snapshotSplit), .split(let liveSplit)): + if let splitID = UUID(uuidString: liveSplit.id) { + _ = bonsplitController.setDividerPosition( + CGFloat(snapshotSplit.dividerPosition), + forSplit: splitID, + fromExternal: true + ) + } + applySessionDividerPositions(snapshotNode: snapshotSplit.first, liveNode: liveSplit.first) + applySessionDividerPositions(snapshotNode: snapshotSplit.second, liveNode: liveSplit.second) + default: + return + } + } // Closing tabs mutates split layout immediately; terminal views handle their own AppKit @@ -1283,6 +1969,7 @@ final class Workspace: Identifiable, ObservableObject { @Published private(set) var panelCustomTitles: [UUID: String] = [:] @Published private(set) var pinnedPanelIds: Set<UUID> = [] @Published private(set) var manualUnreadPanelIds: Set<UUID> = [] + @Published private(set) var panelGitBranches: [UUID: SidebarGitBranchState] = [:] @Published var statusEntries: [String: SidebarStatusEntry] = [:] @Published var logEntries: [SidebarLogEntry] = [] @Published var progress: SidebarProgressState? @@ -1297,6 +1984,7 @@ final class Workspace: Identifiable, ObservableObject { @Published var remotePortConflicts: [Int] = [] @Published var listeningPorts: [Int] = [] var surfaceTTYNames: [UUID: String] = [:] + private var restoredTerminalScrollbackByPanelId: [UUID: String] = [:] private var remoteSessionController: WorkspaceRemoteSessionController? private var remoteLastErrorFingerprint: String? private var remoteLastDaemonErrorFingerprint: String? @@ -1333,11 +2021,18 @@ final class Workspace: Identifiable, ObservableObject { bonsplitAppearance(from: config.backgroundColor) } + nonisolated static func resolvedChromeColors( + from backgroundColor: NSColor + ) -> BonsplitConfiguration.Appearance.ChromeColors { + .init(backgroundHex: backgroundColor.hexString()) + } + private static func bonsplitAppearance(from backgroundColor: NSColor) -> BonsplitConfiguration.Appearance { - BonsplitConfiguration.Appearance( + let chromeColors = resolvedChromeColors(from: backgroundColor) + return BonsplitConfiguration.Appearance( splitButtonTooltips: Self.currentSplitButtonTooltips(), enableAnimations: false, - chromeColors: .init(backgroundHex: backgroundColor.hexString()) + chromeColors: chromeColors ) } @@ -1365,6 +2060,7 @@ final class Workspace: Identifiable, ObservableObject { self.processTitle = title self.title = title self.customTitle = nil + self.customColor = nil let trimmedWorkingDirectory = workingDirectory?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let hasWorkingDirectory = !trimmedWorkingDirectory.isEmpty @@ -1471,12 +2167,24 @@ final class Workspace: Identifiable, ObservableObject { /// Deterministic tab selection to apply after a tab closes. /// Keyed by the closing tab ID, value is the tab ID we want to select next. private var postCloseSelectTabId: [TabID: TabID] = [:] + private var pendingClosedBrowserRestoreSnapshots: [TabID: ClosedBrowserPanelRestoreSnapshot] = [:] private var isApplyingTabSelection = false private var pendingTabSelection: (tabId: TabID, pane: PaneID)? private var isReconcilingFocusState = false private var focusReconcileScheduled = false +#if DEBUG + private(set) var debugFocusReconcileScheduledDuringDetachCount: Int = 0 +#endif private var geometryReconcileScheduled = false private var isNormalizingPinnedTabOrder = false + private var pendingNonFocusSplitFocusReassert: PendingNonFocusSplitFocusReassert? + private var nonFocusSplitFocusReassertGeneration: UInt64 = 0 + + private struct PendingNonFocusSplitFocusReassert { + let generation: UInt64 + let preferredPanelId: UUID + let splitPanelId: UUID + } struct DetachedSurfaceTransfer { let panelId: UUID @@ -1598,6 +2306,10 @@ final class Workspace: Identifiable, ObservableObject { bonsplitController.updateTab(tabId, showsNotificationBadge: shouldShowUnread) } + static func shouldShowUnreadIndicator(hasUnreadNotification: Bool, isManuallyUnread: Bool) -> Bool { + hasUnreadNotification || isManuallyUnread + } + private func normalizePinnedTabs(in paneId: PaneID) { guard !isNormalizingPinnedTabOrder else { return } isNormalizingPinnedTabOrder = true @@ -1665,6 +2377,12 @@ final class Workspace: Identifiable, ObservableObject { return surfaceKind(for: panel) } + func panelTitle(panelId: UUID) -> String? { + guard let panel = panels[panelId] else { return nil } + let fallback = panelTitles[panelId] ?? panel.displayTitle + return resolvedPanelTitle(panelId: panelId, fallback: fallback) + } + func setPanelPinned(panelId: UUID, pinned: Bool) { guard panels[panelId] != nil else { return } let wasPinned = pinnedPanelIds.contains(panelId) @@ -1705,6 +2423,14 @@ final class Workspace: Identifiable, ObservableObject { self.title = title } + func setCustomColor(_ hex: String?) { + if let hex { + customColor = WorkspaceTabColorSettings.normalizedHex(hex) + } else { + customColor = nil + } + } + func setCustomTitle(_ title: String?) { let trimmed = title?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" if trimmed.isEmpty { @@ -1730,6 +2456,24 @@ final class Workspace: Identifiable, ObservableObject { } } + func updatePanelGitBranch(panelId: UUID, branch: String, isDirty: Bool) { + let state = SidebarGitBranchState(branch: branch, isDirty: isDirty) + let existing = panelGitBranches[panelId] + if existing?.branch != branch || existing?.isDirty != isDirty { + panelGitBranches[panelId] = state + } + if panelId == focusedPanelId { + gitBranch = state + } + } + + func clearPanelGitBranch(panelId: UUID) { + panelGitBranches.removeValue(forKey: panelId) + if panelId == focusedPanelId { + gitBranch = nil + } + } + @discardableResult func updatePanelTitle(panelId: UUID, title: String) -> Bool { let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines) @@ -1774,6 +2518,7 @@ final class Workspace: Identifiable, ObservableObject { panelCustomTitles = panelCustomTitles.filter { validSurfaceIds.contains($0.key) } pinnedPanelIds = pinnedPanelIds.filter { validSurfaceIds.contains($0) } manualUnreadPanelIds = manualUnreadPanelIds.filter { validSurfaceIds.contains($0) } + panelGitBranches = panelGitBranches.filter { validSurfaceIds.contains($0.key) } surfaceListeningPorts = surfaceListeningPorts.filter { validSurfaceIds.contains($0.key) } surfaceTTYNames = surfaceTTYNames.filter { validSurfaceIds.contains($0.key) } recomputeListeningPorts() @@ -1784,6 +2529,45 @@ final class Workspace: Identifiable, ObservableObject { listeningPorts = unique.sorted() } + func sidebarOrderedPanelIds() -> [UUID] { + let paneTabs: [String: [UUID]] = Dictionary( + uniqueKeysWithValues: bonsplitController.allPaneIds.map { paneId in + let panelIds = bonsplitController + .tabs(inPane: paneId) + .compactMap { panelIdFromSurfaceId($0.id) } + return (paneId.id.uuidString, panelIds) + } + ) + + let fallbackPanelIds = panels.keys.sorted { $0.uuidString < $1.uuidString } + let tree = bonsplitController.treeSnapshot() + return SidebarBranchOrdering.orderedPanelIds( + tree: tree, + paneTabs: paneTabs, + fallbackPanelIds: fallbackPanelIds + ) + } + + func sidebarGitBranchesInDisplayOrder() -> [SidebarGitBranchState] { + SidebarBranchOrdering + .orderedUniqueBranches( + orderedPanelIds: sidebarOrderedPanelIds(), + panelBranches: panelGitBranches, + fallbackBranch: gitBranch + ) + .map { SidebarGitBranchState(branch: $0.name, isDirty: $0.isDirty) } + } + + func sidebarBranchDirectoryEntriesInDisplayOrder() -> [SidebarBranchOrdering.BranchDirectoryEntry] { + SidebarBranchOrdering.orderedUniqueBranchDirectoryEntries( + orderedPanelIds: sidebarOrderedPanelIds(), + panelBranches: panelGitBranches, + panelDirectories: panelDirectories, + defaultDirectory: currentDirectory, + fallbackBranch: gitBranch + ) + } + var isRemoteWorkspace: Bool { remoteConfiguration != nil } @@ -1976,6 +2760,52 @@ final class Workspace: Identifiable, ObservableObject { } } + private func terminalPanelConfigInheritanceCandidates( + preferredPanelId: UUID? = nil, + inPane preferredPaneId: PaneID? = nil + ) -> [TerminalPanel] { + var candidates: [TerminalPanel] = [] + var seen: Set<UUID> = [] + + func appendCandidate(_ panel: TerminalPanel?) { + guard let panel else { return } + guard seen.insert(panel.id).inserted else { return } + candidates.append(panel) + } + + if let preferredPanelId, let preferredTerminal = terminalPanel(for: preferredPanelId) { + appendCandidate(preferredTerminal) + } + + appendCandidate(focusedTerminalPanel) + + if let preferredPaneId { + for tab in bonsplitController.tabs(inPane: preferredPaneId) { + guard let panelId = panelIdFromSurfaceId(tab.id), + let terminalPanel = terminalPanel(for: panelId) else { continue } + appendCandidate(terminalPanel) + } + } + + for terminalPanel in panels.values + .compactMap({ $0 as? TerminalPanel }) + .sorted(by: { $0.id.uuidString < $1.id.uuidString }) { + appendCandidate(terminalPanel) + } + + return candidates + } + + func terminalPanelForConfigInheritance( + preferredPanelId: UUID? = nil, + inPane preferredPaneId: PaneID? = nil + ) -> TerminalPanel? { + terminalPanelConfigInheritanceCandidates( + preferredPanelId: preferredPanelId, + inPane: preferredPaneId + ).first + } + // MARK: - Panel Operations /// Create a new split with a terminal panel @@ -1983,7 +2813,8 @@ final class Workspace: Identifiable, ObservableObject { func newTerminalSplit( from panelId: UUID, orientation: SplitOrientation, - insertFirst: Bool = false + insertFirst: Bool = false, + focus: Bool = true ) -> TerminalPanel? { // Get inherited config from the source terminal when possible. // If the split is initiated from a non-terminal panel (for example browser), @@ -2034,6 +2865,7 @@ final class Workspace: Identifiable, ObservableObject { isPinned: false ) surfaceIdToPanelId[newTab.id] = newPanel.id + let previousFocusedPanelId = focusedPanelId // Capture the source terminal's hosted view before bonsplit mutates focusedPaneId, // so we can hand it to focusPanel as the "move focus FROM" view. @@ -2056,11 +2888,19 @@ final class Workspace: Identifiable, ObservableObject { // Suppress the old view's becomeFirstResponder side-effects during SwiftUI reparenting. // Without this, reparenting triggers onFocus + ghostty_surface_set_focus on the old view, // stealing focus from the new panel and creating model/surface divergence. - previousHostedView?.suppressReparentFocus() - focusPanel(newPanel.id, previousHostedView: previousHostedView) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { - previousHostedView?.clearSuppressReparentFocus() - } + if focus { + previousHostedView?.suppressReparentFocus() + focusPanel(newPanel.id, previousHostedView: previousHostedView) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + previousHostedView?.clearSuppressReparentFocus() + } + } else { + preserveFocusAfterNonFocusSplit( + preferredPanelId: previousFocusedPanelId, + splitPanelId: newPanel.id, + previousHostedView: previousHostedView + ) + } return newPanel } @@ -2070,7 +2910,12 @@ final class Workspace: Identifiable, ObservableObject { /// true = force focus/selection of the new surface, /// false = never focus (used for internal placeholder repair paths). @discardableResult - func newTerminalSurface(inPane paneId: PaneID, focus: Bool? = nil) -> TerminalPanel? { + func newTerminalSurface( + inPane paneId: PaneID, + focus: Bool? = nil, + workingDirectory: String? = nil, + startupEnvironment: [String: String] = [:] + ) -> TerminalPanel? { let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId) // Get an existing terminal panel to inherit config from @@ -2089,7 +2934,9 @@ final class Workspace: Identifiable, ObservableObject { workspaceId: id, context: GHOSTTY_SURFACE_CONTEXT_SPLIT, configTemplate: inheritedConfig, - portOrdinal: portOrdinal + workingDirectory: workingDirectory, + portOrdinal: portOrdinal, + initialEnvironmentOverrides: startupEnvironment ) panels[newPanel.id] = newPanel panelTitles[newPanel.id] = newPanel.displayTitle @@ -2128,7 +2975,8 @@ final class Workspace: Identifiable, ObservableObject { from panelId: UUID, orientation: SplitOrientation, insertFirst: Bool = false, - url: URL? = nil + url: URL? = nil, + focus: Bool = true ) -> BrowserPanel? { // Find the pane containing the source panel guard let sourceTabId = surfaceIdFromPanelId(panelId) else { return nil } @@ -2158,6 +3006,7 @@ final class Workspace: Identifiable, ObservableObject { isPinned: false ) surfaceIdToPanelId[newTab.id] = browserPanel.id + let previousFocusedPanelId = focusedPanelId // Create the split with the browser tab already present. // Mark this split as programmatic so didSplitPane doesn't auto-create a terminal. @@ -2172,10 +3021,18 @@ final class Workspace: Identifiable, ObservableObject { // See newTerminalSplit: suppress old view's becomeFirstResponder during reparenting. let previousHostedView = focusedTerminalPanel?.hostedView - previousHostedView?.suppressReparentFocus() - focusPanel(browserPanel.id) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { - previousHostedView?.clearSuppressReparentFocus() + if focus { + previousHostedView?.suppressReparentFocus() + focusPanel(browserPanel.id) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + previousHostedView?.clearSuppressReparentFocus() + } + } else { + preserveFocusAfterNonFocusSplit( + preferredPanelId: previousFocusedPanelId, + splitPanelId: browserPanel.id, + previousHostedView: previousHostedView + ) } installBrowserPanelSubscription(browserPanel) @@ -2362,6 +3219,115 @@ final class Workspace: Identifiable, ObservableObject { } } + private struct BrowserCloseFallbackPlan { + let orientation: SplitOrientation + let insertFirst: Bool + let anchorPaneId: UUID? + } + + private func stageClosedBrowserRestoreSnapshotIfNeeded(for tab: Bonsplit.Tab, inPane pane: PaneID) { + guard let panelId = panelIdFromSurfaceId(tab.id), + let browserPanel = browserPanel(for: panelId), + let tabIndex = bonsplitController.tabs(inPane: pane).firstIndex(where: { $0.id == tab.id }) else { + pendingClosedBrowserRestoreSnapshots.removeValue(forKey: tab.id) + return + } + + let fallbackPlan = browserCloseFallbackPlan( + forPaneId: pane.id.uuidString, + in: bonsplitController.treeSnapshot() + ) + let resolvedURL = browserPanel.currentURL + ?? browserPanel.webView.url + ?? browserPanel.preferredURLStringForOmnibar().flatMap(URL.init(string:)) + + pendingClosedBrowserRestoreSnapshots[tab.id] = ClosedBrowserPanelRestoreSnapshot( + workspaceId: id, + url: resolvedURL, + originalPaneId: pane.id, + originalTabIndex: tabIndex, + fallbackSplitOrientation: fallbackPlan?.orientation, + fallbackSplitInsertFirst: fallbackPlan?.insertFirst ?? false, + fallbackAnchorPaneId: fallbackPlan?.anchorPaneId + ) + } + + private func clearStagedClosedBrowserRestoreSnapshot(for tabId: TabID) { + pendingClosedBrowserRestoreSnapshots.removeValue(forKey: tabId) + } + + private func browserCloseFallbackPlan( + forPaneId targetPaneId: String, + in node: ExternalTreeNode + ) -> BrowserCloseFallbackPlan? { + switch node { + case .pane: + return nil + case .split(let splitNode): + if case .pane(let firstPane) = splitNode.first, firstPane.id == targetPaneId { + return BrowserCloseFallbackPlan( + orientation: splitNode.orientation.lowercased() == "vertical" ? .vertical : .horizontal, + insertFirst: true, + anchorPaneId: browserNearestPaneId( + in: splitNode.second, + targetCenter: browserPaneCenter(firstPane) + ) + ) + } + + if case .pane(let secondPane) = splitNode.second, secondPane.id == targetPaneId { + return BrowserCloseFallbackPlan( + orientation: splitNode.orientation.lowercased() == "vertical" ? .vertical : .horizontal, + insertFirst: false, + anchorPaneId: browserNearestPaneId( + in: splitNode.first, + targetCenter: browserPaneCenter(secondPane) + ) + ) + } + + if let nested = browserCloseFallbackPlan(forPaneId: targetPaneId, in: splitNode.first) { + return nested + } + return browserCloseFallbackPlan(forPaneId: targetPaneId, in: splitNode.second) + } + } + + private func browserPaneCenter(_ pane: ExternalPaneNode) -> (x: Double, y: Double) { + ( + x: pane.frame.x + (pane.frame.width * 0.5), + y: pane.frame.y + (pane.frame.height * 0.5) + ) + } + + private func browserNearestPaneId( + in node: ExternalTreeNode, + targetCenter: (x: Double, y: Double)? + ) -> UUID? { + var panes: [ExternalPaneNode] = [] + browserCollectPaneNodes(node: node, into: &panes) + guard !panes.isEmpty else { return nil } + + let bestPane: ExternalPaneNode? + if let targetCenter { + bestPane = panes.min { lhs, rhs in + let lhsCenter = browserPaneCenter(lhs) + let rhsCenter = browserPaneCenter(rhs) + let lhsDistance = pow(lhsCenter.x - targetCenter.x, 2) + pow(lhsCenter.y - targetCenter.y, 2) + let rhsDistance = pow(rhsCenter.x - targetCenter.x, 2) + pow(rhsCenter.y - targetCenter.y, 2) + if lhsDistance != rhsDistance { + return lhsDistance < rhsDistance + } + return lhs.id < rhs.id + } + } else { + bestPane = panes.first + } + + guard let bestPane else { return nil } + return UUID(uuidString: bestPane.id) + } + @discardableResult func moveSurface(panelId: UUID, toPane paneId: PaneID, atIndex index: Int? = nil, focus: Bool = true) -> Bool { guard let tabId = surfaceIdFromPanelId(panelId) else { return false } @@ -2490,7 +3456,131 @@ final class Workspace: Identifiable, ObservableObject { } // MARK: - Focus Management + private func preserveFocusAfterNonFocusSplit( + preferredPanelId: UUID?, + splitPanelId: UUID, + previousHostedView: GhosttySurfaceScrollView? + ) { + guard let preferredPanelId, panels[preferredPanelId] != nil else { + clearNonFocusSplitFocusReassert() + scheduleFocusReconcile() + return + } + + let generation = beginNonFocusSplitFocusReassert( + preferredPanelId: preferredPanelId, + splitPanelId: splitPanelId + ) + + reassertFocusAfterNonFocusSplit( + generation: generation, + preferredPanelId: preferredPanelId, + splitPanelId: splitPanelId, + previousHostedView: previousHostedView, + allowPreviousHostedView: true + ) + + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.reassertFocusAfterNonFocusSplit( + generation: generation, + preferredPanelId: preferredPanelId, + splitPanelId: splitPanelId, + previousHostedView: previousHostedView, + allowPreviousHostedView: false + ) + + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.reassertFocusAfterNonFocusSplit( + generation: generation, + preferredPanelId: preferredPanelId, + splitPanelId: splitPanelId, + previousHostedView: previousHostedView, + allowPreviousHostedView: false + ) + self.scheduleFocusReconcile() + self.clearNonFocusSplitFocusReassert(generation: generation) + } + } + } + + private func reassertFocusAfterNonFocusSplit( + generation: UInt64, + preferredPanelId: UUID, + splitPanelId: UUID, + previousHostedView: GhosttySurfaceScrollView?, + allowPreviousHostedView: Bool + ) { + guard matchesPendingNonFocusSplitFocusReassert( + generation: generation, + preferredPanelId: preferredPanelId, + splitPanelId: splitPanelId + ) else { + return + } + + guard panels[preferredPanelId] != nil else { + clearNonFocusSplitFocusReassert(generation: generation) + return + } + + if focusedPanelId == splitPanelId { + focusPanel( + preferredPanelId, + previousHostedView: allowPreviousHostedView ? previousHostedView : nil + ) + return + } + + guard focusedPanelId == preferredPanelId, + let terminalPanel = terminalPanel(for: preferredPanelId) else { + return + } + terminalPanel.hostedView.ensureFocus(for: id, surfaceId: preferredPanelId) + } + + private func beginNonFocusSplitFocusReassert( + preferredPanelId: UUID, + splitPanelId: UUID + ) -> UInt64 { + nonFocusSplitFocusReassertGeneration &+= 1 + let generation = nonFocusSplitFocusReassertGeneration + pendingNonFocusSplitFocusReassert = PendingNonFocusSplitFocusReassert( + generation: generation, + preferredPanelId: preferredPanelId, + splitPanelId: splitPanelId + ) + return generation + } + + private func matchesPendingNonFocusSplitFocusReassert( + generation: UInt64, + preferredPanelId: UUID, + splitPanelId: UUID + ) -> Bool { + guard let pending = pendingNonFocusSplitFocusReassert else { return false } + return pending.generation == generation && + pending.preferredPanelId == preferredPanelId && + pending.splitPanelId == splitPanelId + } + + private func clearNonFocusSplitFocusReassert(generation: UInt64? = nil) { + guard let pending = pendingNonFocusSplitFocusReassert else { return } + if let generation, pending.generation != generation { return } + pendingNonFocusSplitFocusReassert = nil + } + + private func markExplicitFocusIntent(on panelId: UUID) { + guard let pending = pendingNonFocusSplitFocusReassert, + pending.splitPanelId == panelId else { + return + } + pendingNonFocusSplitFocusReassert = nil + } + func focusPanel(_ panelId: UUID, previousHostedView: GhosttySurfaceScrollView? = nil) { + markExplicitFocusIntent(on: panelId) #if DEBUG let pane = bonsplitController.focusedPaneId?.id.uuidString.prefix(5) ?? "nil" dlog("focus.panel panel=\(panelId.uuidString.prefix(5)) pane=\(pane)") @@ -2746,11 +3836,20 @@ final class Workspace: Identifiable, ObservableObject { if let terminalPanel = targetPanel as? TerminalPanel { terminalPanel.hostedView.ensureFocus(for: id, surfaceId: targetPanelId) } + if let dir = panelDirectories[targetPanelId] { + currentDirectory = dir + } + gitBranch = panelGitBranches[targetPanelId] } /// Reconcile focus/first-responder convergence. /// Coalesce to the next main-queue turn so bonsplit selection/pane mutations settle first. private func scheduleFocusReconcile() { +#if DEBUG + if !detachingTabIds.isEmpty { + debugFocusReconcileScheduledDuringDetachCount += 1 + } +#endif guard !focusReconcileScheduled else { return } focusReconcileScheduled = true DispatchQueue.main.async { [weak self] in @@ -2948,6 +4047,7 @@ extension Workspace: BonsplitDelegate { if let dir = panelDirectories[panelId] { currentDirectory = dir } + refreshFocusedGitBranchState() // Post notification NotificationCenter.default.post( @@ -2960,6 +4060,14 @@ extension Workspace: BonsplitDelegate { ) } + private func refreshFocusedGitBranchState() { + if let focusedPanelId { + gitBranch = panelGitBranches[focusedPanelId] + } else { + gitBranch = nil + } + } + func splitTabBar(_ controller: BonsplitController, shouldCloseTab tab: Bonsplit.Tab, inPane pane: PaneID) -> Bool { func recordPostCloseSelection() { let tabs = controller.tabs(inPane: pane) @@ -2982,12 +4090,14 @@ extension Workspace: BonsplitDelegate { } if forceCloseTabIds.contains(tab.id) { + stageClosedBrowserRestoreSnapshotIfNeeded(for: tab, inPane: pane) recordPostCloseSelection() return true } if let panelId = panelIdFromSurfaceId(tab.id), pinnedPanelIds.contains(panelId) { + clearStagedClosedBrowserRestoreSnapshot(for: tab.id) NSSound.beep() return false } @@ -2995,6 +4105,7 @@ extension Workspace: BonsplitDelegate { // Check if the panel needs close confirmation guard let panelId = panelIdFromSurfaceId(tab.id), let terminalPanel = terminalPanel(for: panelId) else { + stageClosedBrowserRestoreSnapshotIfNeeded(for: tab, inPane: pane) recordPostCloseSelection() return true } @@ -3003,6 +4114,7 @@ extension Workspace: BonsplitDelegate { // Show an app-level confirmation, then re-attempt the close with forceCloseTabIds to bypass // this gating on the second pass. if terminalPanel.needsConfirmClose() { + clearStagedClosedBrowserRestoreSnapshot(for: tab.id) if pendingCloseConfirmTabIds.contains(tab.id) { return false } @@ -3028,6 +4140,7 @@ extension Workspace: BonsplitDelegate { return false } + clearStagedClosedBrowserRestoreSnapshot(for: tab.id) recordPostCloseSelection() return true } @@ -3035,12 +4148,14 @@ extension Workspace: BonsplitDelegate { func splitTabBar(_ controller: BonsplitController, didCloseTab tabId: TabID, fromPane pane: PaneID) { forceCloseTabIds.remove(tabId) let selectTabId = postCloseSelectTabId.removeValue(forKey: tabId) + let closedBrowserRestoreSnapshot = pendingClosedBrowserRestoreSnapshots.removeValue(forKey: tabId) // Clean up our panel guard let panelId = panelIdFromSurfaceId(tabId) else { #if DEBUG NSLog("[Workspace] didCloseTab: no panelId for tabId") #endif + refreshFocusedGitBranchState() scheduleTerminalGeometryReconcile() scheduleFocusReconcile() return @@ -3055,10 +4170,11 @@ extension Workspace: BonsplitDelegate { if isDetaching, let panel { let browserPanel = panel as? BrowserPanel + let cachedTitle = panelTitles[panelId] ?? panel.displayTitle pendingDetachedSurfaces[tabId] = DetachedSurfaceTransfer( panelId: panelId, panel: panel, - title: resolvedPanelTitle(panelId: panelId, fallback: panel.displayTitle), + title: resolvedPanelTitle(panelId: panelId, fallback: cachedTitle), icon: panel.displayIcon, iconImageData: browserPanel?.faviconPNGData, kind: surfaceKind(for: panel), @@ -3070,6 +4186,9 @@ extension Workspace: BonsplitDelegate { manuallyUnread: manualUnreadPanelIds.contains(panelId) ) } else { + if let closedBrowserRestoreSnapshot { + onClosedBrowserPanel?(closedBrowserRestoreSnapshot) + } panel?.close() } @@ -3080,13 +4199,19 @@ extension Workspace: BonsplitDelegate { panelCustomTitles.removeValue(forKey: panelId) pinnedPanelIds.remove(panelId) manualUnreadPanelIds.remove(panelId) + panelGitBranches.removeValue(forKey: panelId) panelSubscriptions.removeValue(forKey: panelId) surfaceTTYNames.removeValue(forKey: panelId) + restoredTerminalScrollbackByPanelId.removeValue(forKey: panelId) PortScanner.shared.unregisterPanel(workspaceId: id, panelId: panelId) // Keep the workspace invariant: always retain at least one real panel. // This prevents runtime close callbacks from ever collapsing into a tabless workspace. if panels.isEmpty { + if isDetaching { + gitBranch = nil + return + } let replacement = createReplacementTerminalPanel() if let replacementTabId = surfaceIdFromPanelId(replacement.id), let replacementPane = bonsplitController.allPaneIds.first { @@ -3094,6 +4219,7 @@ extension Workspace: BonsplitDelegate { bonsplitController.selectTab(replacementTabId) applyTabSelection(tabId: replacementTabId, inPane: replacementPane) } + refreshFocusedGitBranchState() scheduleTerminalGeometryReconcile() scheduleFocusReconcile() return @@ -3112,6 +4238,7 @@ extension Workspace: BonsplitDelegate { if bonsplitController.allPaneIds.contains(pane) { normalizePinnedTabs(in: pane) } + refreshFocusedGitBranchState() scheduleTerminalGeometryReconcile() scheduleFocusReconcile() } @@ -3155,6 +4282,26 @@ extension Workspace: BonsplitDelegate { func splitTabBar(_ controller: BonsplitController, didClosePane paneId: PaneID) { _ = paneId + let liveTabIds: Set<TabID> = Set( + controller.allPaneIds.flatMap { controller.tabs(inPane: $0).map(\.id) } + ) + let staleMappings = surfaceIdToPanelId.filter { !liveTabIds.contains($0.key) } + for (staleTabId, stalePanelId) in staleMappings { + panels[stalePanelId]?.close() + panels.removeValue(forKey: stalePanelId) + surfaceIdToPanelId.removeValue(forKey: staleTabId) + panelDirectories.removeValue(forKey: stalePanelId) + panelTitles.removeValue(forKey: stalePanelId) + panelCustomTitles.removeValue(forKey: stalePanelId) + pinnedPanelIds.remove(stalePanelId) + manualUnreadPanelIds.remove(stalePanelId) + panelGitBranches.removeValue(forKey: stalePanelId) + panelSubscriptions.removeValue(forKey: stalePanelId) + surfaceTTYNames.removeValue(forKey: stalePanelId) + PortScanner.shared.unregisterPanel(workspaceId: id, panelId: stalePanelId) + } + + refreshFocusedGitBranchState() scheduleTerminalGeometryReconcile() scheduleFocusReconcile() } @@ -3381,6 +4528,9 @@ extension Workspace: BonsplitDelegate { closeTabs(tabIdsToRight(of: tab.id, inPane: pane)) case .closeOthers: closeTabs(tabIdsToCloseOthers(of: tab.id, inPane: pane)) + case .move: + // TODO: Wire this to a move target picker. + return case .newTerminalToRight: createTerminalToRight(of: tab.id, inPane: pane) case .newBrowserToRight: @@ -3395,6 +4545,10 @@ extension Workspace: BonsplitDelegate { guard let panelId = panelIdFromSurfaceId(tab.id) else { return } let shouldPin = !pinnedPanelIds.contains(panelId) setPanelPinned(panelId: panelId, pinned: shouldPin) + case .markAsRead: + guard let panelId = panelIdFromSurfaceId(tab.id) else { return } + clearManualUnread(panelId: panelId) + AppDelegate.shared?.notificationStore?.markRead(forTabId: id, surfaceId: panelId) case .markAsUnread: guard let panelId = panelIdFromSurfaceId(tab.id) else { return } markPanelUnread(panelId) diff --git a/Sources/WorkspaceContentView.swift b/Sources/WorkspaceContentView.swift index 3e058a47..dda1241e 100644 --- a/Sources/WorkspaceContentView.swift +++ b/Sources/WorkspaceContentView.swift @@ -53,51 +53,12 @@ struct WorkspaceContentView: View { }() BonsplitView(controller: workspace.bonsplitController) { tab, paneId in - // Content for each tab in bonsplit - let _ = Self.debugPanelLookup(tab: tab, workspace: workspace) - if let panel = workspace.panel(for: tab.id) { - let isFocused = isWorkspaceInputActive && workspace.focusedPanelId == panel.id - let isSelectedInPane = workspace.bonsplitController.selectedTab(inPane: paneId)?.id == tab.id - let isVisibleInUI = Self.panelVisibleInUI( - isWorkspaceVisible: isWorkspaceVisible, - isSelectedInPane: isSelectedInPane, - isFocused: isFocused - ) - let hasUnreadNotification = Workspace.shouldShowUnreadIndicator( - hasUnreadNotification: notificationStore.hasUnreadNotification(forTabId: workspace.id, surfaceId: panel.id), - isManuallyUnread: workspace.manualUnreadPanelIds.contains(panel.id) - ) - PanelContentView( - panel: panel, - isFocused: isFocused, - isSelectedInPane: isSelectedInPane, - isVisibleInUI: isVisibleInUI, - portalPriority: workspacePortalPriority, - isSplit: isSplit, - appearance: appearance, - hasUnreadNotification: hasUnreadNotification, - onFocus: { - // Keep bonsplit focus in sync with the AppKit first responder for the - // active workspace. This prevents divergence between the blue focused-tab - // indicator and where keyboard input/flash-focus actually lands. - guard isWorkspaceInputActive else { return } - guard workspace.panels[panel.id] != nil else { return } - workspace.focusPanel(panel.id, trigger: .terminalFirstResponder) - }, - onRequestPanelFocus: { - guard isWorkspaceInputActive else { return } - guard workspace.panels[panel.id] != nil else { return } - workspace.focusPanel(panel.id) - }, - onTriggerFlash: { workspace.triggerDebugFlash(panelId: panel.id) } - ) - .onTapGesture { - workspace.bonsplitController.focusPane(paneId) - } - } else { - // Fallback for tabs without panels (shouldn't happen normally) - EmptyPanelView(workspace: workspace, paneId: paneId) - } + panelView( + tab: tab, + paneId: paneId, + isSplit: isSplit, + appearance: appearance + ) } emptyPane: { paneId in // Empty pane content EmptyPanelView(workspace: workspace, paneId: paneId) @@ -142,6 +103,55 @@ struct WorkspaceContentView: View { } } + @ViewBuilder + private func panelView( + tab: Bonsplit.Tab, + paneId: PaneID, + isSplit: Bool, + appearance: PanelAppearance + ) -> some View { + let _ = Self.debugPanelLookup(tab: tab, workspace: workspace) + if let panel = workspace.panel(for: tab.id) { + let isFocused = isWorkspaceInputActive && workspace.focusedPanelId == panel.id + let isSelectedInPane = workspace.bonsplitController.selectedTab(inPane: paneId)?.id == tab.id + let isVisibleInUI = Self.panelVisibleInUI( + isWorkspaceVisible: isWorkspaceVisible, + isSelectedInPane: isSelectedInPane, + isFocused: isFocused + ) + let hasUnreadNotification = Workspace.shouldShowUnreadIndicator( + hasUnreadNotification: notificationStore.hasUnreadNotification(forTabId: workspace.id, surfaceId: panel.id), + isManuallyUnread: workspace.manualUnreadPanelIds.contains(panel.id) + ) + PanelContentView( + panel: panel, + isFocused: isFocused, + isSelectedInPane: isSelectedInPane, + isVisibleInUI: isVisibleInUI, + portalPriority: workspacePortalPriority, + isSplit: isSplit, + appearance: appearance, + hasUnreadNotification: hasUnreadNotification, + onFocus: { + guard isWorkspaceInputActive else { return } + guard workspace.panels[panel.id] != nil else { return } + workspace.focusPanel(panel.id) + }, + onRequestPanelFocus: { + guard isWorkspaceInputActive else { return } + guard workspace.panels[panel.id] != nil else { return } + workspace.focusPanel(panel.id) + }, + onTriggerFlash: { workspace.triggerDebugFlash(panelId: panel.id) } + ) + .onTapGesture { + workspace.bonsplitController.focusPane(paneId) + } + } else { + EmptyPanelView(workspace: workspace, paneId: paneId) + } + } + private func syncBonsplitNotificationBadges() { let unreadFromNotifications: Set<UUID> = Set( notificationStore.notifications @@ -242,7 +252,8 @@ struct WorkspaceContentView: View { ) let chromeReason = "refreshGhosttyAppearanceConfig:reason=\(reason):event=\(eventLabel):source=\(sourceLabel):payload=\(payloadLabel)" - workspace.applyGhosttyChrome(from: next, reason: chromeReason) + _ = chromeReason + workspace.applyGhosttyChrome(from: next) if let terminalPanel = workspace.focusedTerminalPanel { terminalPanel.applyWindowBackgroundIfActive() logTheme( diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 9a0e10b6..5a674736 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -2680,6 +2680,53 @@ final class UpdateChannelSettingsTests: XCTestCase { } } +final class SidebarRemoteErrorCopySupportTests: XCTestCase { + func testMenuLabelIsNilWhenThereAreNoErrors() { + XCTAssertNil(SidebarRemoteErrorCopySupport.menuLabel(for: [])) + XCTAssertNil(SidebarRemoteErrorCopySupport.clipboardText(for: [])) + } + + func testSingleErrorUsesCopyErrorLabelAndSingleLinePayload() { + let entries = [ + SidebarRemoteErrorCopyEntry( + workspaceTitle: "alpha", + target: "devbox:22", + detail: "failed to start reverse relay" + ) + ] + + XCTAssertEqual(SidebarRemoteErrorCopySupport.menuLabel(for: entries), "Copy Error") + XCTAssertEqual( + SidebarRemoteErrorCopySupport.clipboardText(for: entries), + "SSH error (devbox:22): failed to start reverse relay" + ) + } + + func testMultipleErrorsUseCopyErrorsLabelAndEnumeratedPayload() { + let entries = [ + SidebarRemoteErrorCopyEntry( + workspaceTitle: "alpha", + target: "devbox-a:22", + detail: "connection timed out" + ), + SidebarRemoteErrorCopyEntry( + workspaceTitle: "beta", + target: "devbox-b:22", + detail: "permission denied" + ), + ] + + XCTAssertEqual(SidebarRemoteErrorCopySupport.menuLabel(for: entries), "Copy Errors") + XCTAssertEqual( + SidebarRemoteErrorCopySupport.clipboardText(for: entries), + """ + 1. alpha (devbox-a:22): connection timed out + 2. beta (devbox-b:22): permission denied + """ + ) + } +} + final class WorkspaceReorderTests: XCTestCase { @MainActor func testReorderWorkspaceMovesWorkspaceToRequestedIndex() { diff --git a/cmuxTests/TabManagerSessionSnapshotTests.swift b/cmuxTests/TabManagerSessionSnapshotTests.swift new file mode 100644 index 00000000..af954ee2 --- /dev/null +++ b/cmuxTests/TabManagerSessionSnapshotTests.swift @@ -0,0 +1,49 @@ +import XCTest + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +final class TabManagerSessionSnapshotTests: XCTestCase { + func testSessionSnapshotSerializesWorkspacesAndRestoreRebuildsSelection() { + let manager = TabManager() + guard let firstWorkspace = manager.selectedWorkspace else { + XCTFail("Expected initial workspace") + return + } + firstWorkspace.setCustomTitle("First") + + let secondWorkspace = manager.addWorkspace(select: true) + secondWorkspace.setCustomTitle("Second") + XCTAssertEqual(manager.tabs.count, 2) + XCTAssertEqual(manager.selectedTabId, secondWorkspace.id) + + let snapshot = manager.sessionSnapshot(includeScrollback: false) + XCTAssertEqual(snapshot.workspaces.count, 2) + XCTAssertEqual(snapshot.selectedWorkspaceIndex, 1) + + let restored = TabManager() + restored.restoreSessionSnapshot(snapshot) + + XCTAssertEqual(restored.tabs.count, 2) + XCTAssertEqual(restored.selectedTabId, restored.tabs[1].id) + XCTAssertEqual(restored.tabs[0].customTitle, "First") + XCTAssertEqual(restored.tabs[1].customTitle, "Second") + } + + func testRestoreSessionSnapshotWithNoWorkspacesKeepsSingleFallbackWorkspace() { + let manager = TabManager() + let emptySnapshot = SessionTabManagerSnapshot( + selectedWorkspaceIndex: nil, + workspaces: [] + ) + + manager.restoreSessionSnapshot(emptySnapshot) + + XCTAssertEqual(manager.tabs.count, 1) + XCTAssertNotNil(manager.selectedTabId) + } +} diff --git a/cmuxTests/TerminalControllerSocketSecurityTests.swift b/cmuxTests/TerminalControllerSocketSecurityTests.swift new file mode 100644 index 00000000..ccf3f116 --- /dev/null +++ b/cmuxTests/TerminalControllerSocketSecurityTests.swift @@ -0,0 +1,214 @@ +import XCTest +import AppKit +import Darwin + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +final class TerminalControllerSocketSecurityTests: XCTestCase { + private func makeSocketPath(_ name: String) -> String { + FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-socket-security-\(name)-\(UUID().uuidString).sock") + .path + } + + override func setUp() { + super.setUp() + TerminalController.shared.stop() + } + + override func tearDown() { + TerminalController.shared.stop() + super.tearDown() + } + + func testSocketPermissionsFollowAccessMode() throws { + let tabManager = TabManager() + + let allowAllPath = makeSocketPath("allow-all") + TerminalController.shared.start( + tabManager: tabManager, + socketPath: allowAllPath, + accessMode: .allowAll + ) + try waitForSocket(at: allowAllPath) + XCTAssertEqual(try socketMode(at: allowAllPath), 0o666) + + TerminalController.shared.stop() + + let restrictedPath = makeSocketPath("cmux-only") + TerminalController.shared.start( + tabManager: tabManager, + socketPath: restrictedPath, + accessMode: .cmuxOnly + ) + try waitForSocket(at: restrictedPath) + XCTAssertEqual(try socketMode(at: restrictedPath), 0o600) + } + + func testPasswordModeRejectsUnauthenticatedCommands() throws { + let socketPath = makeSocketPath("password-mode") + let tabManager = TabManager() + + TerminalController.shared.start( + tabManager: tabManager, + socketPath: socketPath, + accessMode: .password + ) + try waitForSocket(at: socketPath) + + let pingOnly = try sendCommands(["ping"], to: socketPath) + XCTAssertEqual(pingOnly.count, 1) + XCTAssertTrue(pingOnly[0].hasPrefix("ERROR:")) + XCTAssertFalse(pingOnly[0].localizedCaseInsensitiveContains("PONG")) + + let wrongAuthThenPing = try sendCommands( + ["auth not-the-password", "ping"], + to: socketPath + ) + XCTAssertEqual(wrongAuthThenPing.count, 2) + XCTAssertTrue(wrongAuthThenPing[0].hasPrefix("ERROR:")) + XCTAssertTrue(wrongAuthThenPing[1].hasPrefix("ERROR:")) + } + + func testSocketCommandPolicyDistinguishesFocusIntent() throws { +#if DEBUG + let nonFocus = TerminalController.debugSocketCommandPolicySnapshot( + commandKey: "ping", + isV2: false + ) + XCTAssertTrue(nonFocus.insideSuppressed) + XCTAssertFalse(nonFocus.insideAllowsFocus) + XCTAssertFalse(nonFocus.outsideSuppressed) + XCTAssertFalse(nonFocus.outsideAllowsFocus) + + let focusV1 = TerminalController.debugSocketCommandPolicySnapshot( + commandKey: "focus_window", + isV2: false + ) + XCTAssertTrue(focusV1.insideSuppressed) + XCTAssertTrue(focusV1.insideAllowsFocus) + XCTAssertFalse(focusV1.outsideSuppressed) + + let focusV2 = TerminalController.debugSocketCommandPolicySnapshot( + commandKey: "workspace.select", + isV2: true + ) + XCTAssertTrue(focusV2.insideSuppressed) + XCTAssertTrue(focusV2.insideAllowsFocus) + XCTAssertFalse(focusV2.outsideSuppressed) +#else + throw XCTSkip("Socket command policy snapshot helper is debug-only.") +#endif + } + + private func waitForSocket(at path: String, timeout: TimeInterval = 2.0) throws { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if FileManager.default.fileExists(atPath: path) { + return + } + usleep(20_000) + } + XCTFail("Timed out waiting for socket at \(path)") + throw NSError(domain: NSPOSIXErrorDomain, code: Int(ETIMEDOUT)) + } + + private func socketMode(at path: String) throws -> UInt16 { + var fileInfo = stat() + guard lstat(path, &fileInfo) == 0 else { + throw posixError("lstat(\(path))") + } + return UInt16(fileInfo.st_mode & 0o777) + } + + private func sendCommands(_ commands: [String], to socketPath: String) throws -> [String] { + let fd = Darwin.socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { + throw posixError("socket(AF_UNIX)") + } + defer { Darwin.close(fd) } + + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + + let bytes = Array(socketPath.utf8) + let maxPathLen = MemoryLayout.size(ofValue: addr.sun_path) + guard bytes.count < maxPathLen else { + throw NSError(domain: NSPOSIXErrorDomain, code: Int(ENAMETOOLONG)) + } + + withUnsafeMutablePointer(to: &addr.sun_path) { pathPtr in + let cPath = UnsafeMutableRawPointer(pathPtr).assumingMemoryBound(to: CChar.self) + cPath.initialize(repeating: 0, count: maxPathLen) + for (index, byte) in bytes.enumerated() { + cPath[index] = CChar(bitPattern: byte) + } + } + + let addrLen = socklen_t(MemoryLayout<sa_family_t>.size + bytes.count + 1) + let connectResult = withUnsafePointer(to: &addr) { ptr -> Int32 in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in + Darwin.connect(fd, sockaddrPtr, addrLen) + } + } + guard connectResult == 0 else { + throw posixError("connect(\(socketPath))") + } + + var responses: [String] = [] + for command in commands { + try writeLine(command, to: fd) + responses.append(try readLine(from: fd)) + } + return responses + } + + private func writeLine(_ command: String, to fd: Int32) throws { + let payload = Array((command + "\n").utf8) + var offset = 0 + while offset < payload.count { + let wrote = payload.withUnsafeBytes { raw in + Darwin.write(fd, raw.baseAddress!.advanced(by: offset), payload.count - offset) + } + guard wrote >= 0 else { + throw posixError("write(\(command))") + } + offset += wrote + } + } + + private func readLine(from fd: Int32) throws -> String { + var buffer = [UInt8](repeating: 0, count: 1) + var data = Data() + + while true { + let count = Darwin.read(fd, &buffer, 1) + guard count >= 0 else { + throw posixError("read") + } + if count == 0 { break } + if buffer[0] == 0x0A { break } + data.append(buffer[0]) + } + + guard let line = String(data: data, encoding: .utf8) else { + throw NSError(domain: NSCocoaErrorDomain, code: 0, userInfo: [ + NSLocalizedDescriptionKey: "Invalid UTF-8 response from socket" + ]) + } + return line + } + + private func posixError(_ operation: String) -> NSError { + NSError( + domain: NSPOSIXErrorDomain, + code: Int(errno), + userInfo: [NSLocalizedDescriptionKey: "\(operation) failed: \(String(cString: strerror(errno)))"] + ) + } +} diff --git a/daemon/remote/cmd/cmuxd-remote/cli.go b/daemon/remote/cmd/cmuxd-remote/cli.go index eea20ed9..77654e7d 100644 --- a/daemon/remote/cmd/cmuxd-remote/cli.go +++ b/daemon/remote/cmd/cmuxd-remote/cli.go @@ -5,7 +5,9 @@ import ( "crypto/rand" "encoding/hex" "encoding/json" + "errors" "fmt" + "io" "net" "os" "path/filepath" @@ -433,12 +435,42 @@ func socketRoundTrip(socketPath, command string, refreshAddr func() string) (str return "", fmt.Errorf("failed to send command: %w", err) } + // V1 handlers may return multiple lines (e.g. list_windows). Read until + // the stream goes idle briefly after seeing at least one newline. reader := bufio.NewReader(conn) - line, err := reader.ReadString('\n') - if err != nil { - return "", fmt.Errorf("failed to read response: %w", err) + var response strings.Builder + sawNewline := false + + for { + readTimeout := 15 * time.Second + if sawNewline { + readTimeout = 120 * time.Millisecond + } + _ = conn.SetReadDeadline(time.Now().Add(readTimeout)) + + chunk, err := reader.ReadString('\n') + if chunk != "" { + response.WriteString(chunk) + if strings.Contains(chunk, "\n") { + sawNewline = true + } + } + + if err != nil { + if netErr, ok := err.(net.Error); ok && netErr.Timeout() { + if sawNewline { + break + } + return "", fmt.Errorf("failed to read response: timeout waiting for response") + } + if errors.Is(err, io.EOF) { + break + } + return "", fmt.Errorf("failed to read response: %w", err) + } } - return strings.TrimRight(line, "\n"), nil + + return strings.TrimRight(response.String(), "\n"), nil } // socketRoundTripV2 sends a JSON-RPC request and returns the result JSON. diff --git a/daemon/remote/cmd/cmuxd-remote/cli_test.go b/daemon/remote/cmd/cmuxd-remote/cli_test.go index 44f5db6f..bfb876b1 100644 --- a/daemon/remote/cmd/cmuxd-remote/cli_test.go +++ b/daemon/remote/cmd/cmuxd-remote/cli_test.go @@ -217,6 +217,18 @@ func TestCLINewWindowV1(t *testing.T) { } } +func TestSocketRoundTripReadsFullMultilineV1Response(t *testing.T) { + addr := startMockTCPSocket(t, "window:alpha\nwindow:beta\nwindow:gamma") + resp, err := socketRoundTrip(addr, "list_windows", nil) + if err != nil { + t.Fatalf("socketRoundTrip should succeed, got error: %v", err) + } + want := "window:alpha\nwindow:beta\nwindow:gamma" + if resp != want { + t.Fatalf("socketRoundTrip truncated v1 response: got %q want %q", resp, want) + } +} + func TestCLICloseWindowV1(t *testing.T) { // Verify that the flag value is appended to the v1 command dir := t.TempDir() diff --git a/scripts/reload.sh b/scripts/reload.sh index 3cd2bb63..e9ba2655 100755 --- a/scripts/reload.sh +++ b/scripts/reload.sh @@ -322,6 +322,7 @@ if [[ -n "${TAG_SLUG:-}" && -n "${CMUX_SOCKET:-}" ]]; then elif [[ -n "${TAG_SLUG:-}" ]]; then "${OPEN_CLEAN_ENV[@]}" CMUX_TAG="$TAG_SLUG" CMUX_DEBUG_LOG="$CMUX_DEBUG_LOG" open "$APP_PATH" else + echo "/tmp/cmux-debug.sock" > /tmp/cmux-last-socket-path || true echo "/tmp/cmux-debug.log" > /tmp/cmux-last-debug-log-path || true "${OPEN_CLEAN_ENV[@]}" open "$APP_PATH" fi diff --git a/tests_v2/test_cli_global_flags_and_v1_error_contract.py b/tests_v2/test_cli_global_flags_and_v1_error_contract.py new file mode 100644 index 00000000..e09741fd --- /dev/null +++ b/tests_v2/test_cli_global_flags_and_v1_error_contract.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +"""Regression: global CLI flags still parse and v1 ERROR responses fail with non-zero exit.""" + +from __future__ import annotations + +import glob +import os +import subprocess +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") +LAST_SOCKET_HINT_PATH = Path("/tmp/cmux-last-socket-path") + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _find_cli_binary() -> str: + env_cli = os.environ.get("CMUXTERM_CLI") + if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK): + return env_cli + + fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux") + if os.path.isfile(fixed) and os.access(fixed, os.X_OK): + return fixed + + candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True) + candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux") + candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)] + if not candidates: + raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI") + candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True) + return candidates[0] + + +def _run(cmd: list[str], env: dict[str, str] | None = None) -> subprocess.CompletedProcess[str]: + return subprocess.run(cmd, capture_output=True, text=True, check=False, env=env) + + +def _merged_output(proc: subprocess.CompletedProcess[str]) -> str: + return f"{proc.stdout}\n{proc.stderr}".strip() + + +def main() -> int: + cli = _find_cli_binary() + + # Global --version should be handled before socket command dispatch. + version_proc = _run([cli, "--version"]) + version_out = _merged_output(version_proc).lower() + _must(version_proc.returncode == 0, f"--version should succeed: {version_proc.returncode} {version_out!r}") + _must("cmux" in version_out, f"--version output should mention cmux: {version_out!r}") + + # Debug builds should auto-resolve the active debug socket via /tmp/cmux-last-socket-path + # when CMUX_SOCKET_PATH is not set. + hint_backup: str | None = None + hint_had_file = LAST_SOCKET_HINT_PATH.exists() + if hint_had_file: + hint_backup = LAST_SOCKET_HINT_PATH.read_text(encoding="utf-8") + try: + LAST_SOCKET_HINT_PATH.write_text(f"{SOCKET_PATH}\n", encoding="utf-8") + auto_env = dict(os.environ) + auto_env.pop("CMUX_SOCKET_PATH", None) + auto_ping = _run([cli, "ping"], env=auto_env) + auto_ping_out = _merged_output(auto_ping).lower() + _must(auto_ping.returncode == 0, f"debug auto socket resolution should succeed: {auto_ping.returncode} {auto_ping_out!r}") + _must("pong" in auto_ping_out, f"debug auto socket resolution should return pong: {auto_ping_out!r}") + finally: + try: + if hint_had_file: + LAST_SOCKET_HINT_PATH.write_text(hint_backup or "", encoding="utf-8") + else: + LAST_SOCKET_HINT_PATH.unlink(missing_ok=True) + except OSError: + pass + + # Global --password should parse as a flag (not a command name) and still allow non-password sockets. + ping_proc = _run([cli, "--socket", SOCKET_PATH, "--password", "ignored-in-cmuxonly", "ping"]) + ping_out = _merged_output(ping_proc).lower() + _must(ping_proc.returncode == 0, f"ping with --password should succeed: {ping_proc.returncode} {ping_out!r}") + _must("pong" in ping_out, f"ping should still return pong: {ping_out!r}") + + # V1 errors must produce non-zero exit codes for automation correctness. + bad_focus = _run([cli, "--socket", SOCKET_PATH, "focus-window", "--window", "window:999999"]) + bad_out = _merged_output(bad_focus).lower() + _must(bad_focus.returncode != 0, f"focus-window with invalid target should fail non-zero: {bad_out!r}") + _must("error" in bad_out, f"focus-window failure should surface an error: {bad_out!r}") + + print("PASS: global flags parse correctly and v1 ERROR responses fail the CLI process") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_rename_tab_cli_parity.py b/tests_v2/test_rename_tab_cli_parity.py index a60055fa..e7ea1b94 100644 --- a/tests_v2/test_rename_tab_cli_parity.py +++ b/tests_v2/test_rename_tab_cli_parity.py @@ -55,14 +55,6 @@ def _run_cli(cli: str, args: List[str], env: Optional[Dict[str, str]] = None) -> return proc.stdout.strip() -def _surface_title(c: cmux, workspace_id: str, surface_id: str) -> str: - payload = c._call("surface.list", {"workspace_id": workspace_id}) or {} - for row in payload.get("surfaces") or []: - if str(row.get("id") or "") == surface_id: - return str(row.get("title") or "") - raise cmuxError(f"surface.list missing surface {surface_id} in workspace {workspace_id}: {payload}") - - def main() -> int: cli = _find_cli_binary() stamp = int(time.time() * 1000) @@ -82,7 +74,7 @@ def main() -> int: _must(bool(surface_id), f"surface.current returned no surface_id: {current}") socket_title = f"socket rename {stamp}" - c._call( + socket_payload = c._call( "tab.action", { "workspace_id": ws_id, @@ -91,14 +83,20 @@ def main() -> int: "title": socket_title, }, ) - _must(_surface_title(c, ws_id, surface_id) == socket_title, "tab.action rename did not update tab title") + _must( + str((socket_payload or {}).get("title") or "") == socket_title, + f"tab.action rename response missing requested title: {socket_payload}", + ) cli_title = f"cli rename {stamp}" - _run_cli(cli, ["rename-tab", "--workspace", ws_id, "--tab", surface_id, cli_title]) - _must(_surface_title(c, ws_id, surface_id) == cli_title, "rename-tab --tab did not update tab title") + cli_out = _run_cli(cli, ["rename-tab", "--workspace", ws_id, "--tab", surface_id, cli_title]) + _must( + "action=rename" in cli_out.lower() and "tab=" in cli_out.lower(), + f"rename-tab --tab should route to tab.action rename summary, got: {cli_out!r}", + ) env_title = f"env rename {stamp}" - _run_cli( + env_out = _run_cli( cli, ["rename-tab", env_title], env={ @@ -106,7 +104,10 @@ def main() -> int: "CMUX_TAB_ID": surface_id, }, ) - _must(_surface_title(c, ws_id, surface_id) == env_title, "rename-tab via CMUX_TAB_ID did not update tab title") + _must( + "action=rename" in env_out.lower() and "tab=" in env_out.lower(), + f"rename-tab via CMUX_TAB_ID should route to tab.action rename summary, got: {env_out!r}", + ) invalid = subprocess.run( [cli, "--socket", SOCKET_PATH, "rename-tab", "--workspace", ws_id], diff --git a/tests_v2/test_ssh_remote_cli_relay.py b/tests_v2/test_ssh_remote_cli_relay.py index d5125b7f..2ba2afcf 100644 --- a/tests_v2/test_ssh_remote_cli_relay.py +++ b/tests_v2/test_ssh_remote_cli_relay.py @@ -19,7 +19,9 @@ from cmux import cmux, cmuxError SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") -REMOTE_HTTP_PORT = int(os.environ.get("CMUX_SSH_TEST_REMOTE_HTTP_PORT", "43173")) +# Keep the fixture's extra HTTP server below 1024 so there are no eligible +# (>1023) ports to auto-forward. This guards the "connecting forever" regression. +REMOTE_HTTP_PORT = int(os.environ.get("CMUX_SSH_TEST_REMOTE_HTTP_PORT", "81")) def _must(cond: bool, msg: str) -> None: @@ -55,6 +57,8 @@ def _run(cmd: list[str], *, env: dict[str, str] | None = None, check: bool = Tru def _run_cli_json(cli: str, args: list[str]) -> dict: env = dict(os.environ) + # Ensure --socket is what drives the relay path during tests. + env.pop("CMUX_SOCKET_PATH", None) env.pop("CMUX_WORKSPACE_ID", None) env.pop("CMUX_SURFACE_ID", None) env.pop("CMUX_TAB_ID", None) @@ -111,6 +115,33 @@ def _wait_for_ssh(host: str, host_port: int, key_path: Path, timeout: float = 20 raise cmuxError("Timed out waiting for SSH server in docker fixture to become ready") +def _wait_for_remote_ready(client, workspace_id: str, timeout: float = 45.0) -> dict: + deadline = time.time() + timeout + last_status = {} + while time.time() < deadline: + last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {} + remote = last_status.get("remote") or {} + daemon = remote.get("daemon") or {} + state = str(remote.get("state") or "") + daemon_state = str(daemon.get("state") or "") + if state == "connected" and daemon_state == "ready": + return last_status + time.sleep(0.5) + raise cmuxError(f"Remote daemon did not become ready: {last_status}") + + +def _assert_remote_ping(host: str, host_port: int, key_path: Path, remote_socket_addr: str, *, label: str) -> None: + ping_result = _ssh_run( + host, host_port, key_path, + f"CMUX_SOCKET_PATH={remote_socket_addr} $HOME/.cmux/bin/cmux ping", + check=False, + ) + _must( + ping_result.returncode == 0 and "pong" in ping_result.stdout.lower(), + f"{label} cmux ping failed: rc={ping_result.returncode} stdout={ping_result.stdout!r} stderr={ping_result.stderr!r}", + ) + + def main() -> int: if not _docker_available(): print("SKIP: docker is not available") @@ -125,6 +156,7 @@ def main() -> int: image_tag = f"cmux-ssh-test:{secrets.token_hex(4)}" container_name = f"cmux-ssh-cli-relay-{secrets.token_hex(4)}" workspace_id = "" + workspace_id_2 = "" try: # Generate SSH key pair @@ -180,19 +212,18 @@ def main() -> int: remote_socket_addr = f"127.0.0.1:{remote_relay_port}" # Wait for daemon to be ready - deadline = time.time() + 45.0 - last_status = {} - while time.time() < deadline: - last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {} - remote = last_status.get("remote") or {} - daemon = remote.get("daemon") or {} - state = str(remote.get("state") or "") - daemon_state = str(daemon.get("state") or "") - if state == "connected" and daemon_state == "ready": - break - time.sleep(0.5) - else: - raise cmuxError(f"Remote daemon did not become ready: {last_status}") + first_status = _wait_for_remote_ready(client, workspace_id) + first_remote = first_status.get("remote") or {} + # Regression: should transition to connected even with no eligible + # (>1023, non-ephemeral) remote ports. + _must( + not (first_remote.get("detected_ports") or []), + f"expected no eligible detected ports in fixture: {first_status}", + ) + _must( + not (first_remote.get("forwarded_ports") or []), + f"expected no forwarded ports when none are eligible: {first_status}", + ) # Verify the cmux symlink exists on the remote symlink_check = _ssh_run( @@ -205,16 +236,49 @@ def main() -> int: f"Expected cmux symlink at ~/.cmux/bin/cmux on remote: {symlink_check.stdout} {symlink_check.stderr}", ) - # Test 1: cmux ping (v1) - ping_result = _ssh_run( - host, host_ssh_port, key_path, - f"CMUX_SOCKET_PATH={remote_socket_addr} $HOME/.cmux/bin/cmux ping", - check=False, + # Start a second SSH workspace to the same destination and verify both + # relays remain healthy (regression: same-host workspaces killed each other). + payload_2 = _run_cli_json( + cli, + [ + "ssh", + host, + "--name", "docker-cli-relay-2", + "--port", str(host_ssh_port), + "--identity", str(key_path), + "--ssh-option", "UserKnownHostsFile=/dev/null", + "--ssh-option", "StrictHostKeyChecking=no", + ], ) + workspace_id_2 = str(payload_2.get("workspace_id") or "") + workspace_ref_2 = str(payload_2.get("workspace_ref") or "") + if not workspace_id_2 and workspace_ref_2.startswith("workspace:"): + listed_2 = client._call("workspace.list", {}) or {} + for row in listed_2.get("workspaces") or []: + if str(row.get("ref") or "") == workspace_ref_2: + workspace_id_2 = str(row.get("id") or "") + break + _must(bool(workspace_id_2), f"second cmux ssh output missing workspace_id: {payload_2}") + + remote_relay_port_2 = payload_2.get("remote_relay_port") + _must(remote_relay_port_2 is not None, f"second cmux ssh output missing remote_relay_port: {payload_2}") + remote_relay_port_2 = int(remote_relay_port_2) + _must(49152 <= remote_relay_port_2 <= 65535, f"second remote_relay_port out of range: {remote_relay_port_2}") _must( - ping_result.returncode == 0 and "pong" in ping_result.stdout.lower(), - f"cmux ping failed: rc={ping_result.returncode} stdout={ping_result.stdout!r} stderr={ping_result.stderr!r}", + remote_relay_port_2 != remote_relay_port, + f"relay ports should differ per workspace: {remote_relay_port_2} vs {remote_relay_port}", ) + remote_socket_addr_2 = f"127.0.0.1:{remote_relay_port_2}" + _ = _wait_for_remote_ready(client, workspace_id_2) + + stability_deadline = time.time() + 8.0 + while time.time() < stability_deadline: + _assert_remote_ping(host, host_ssh_port, key_path, remote_socket_addr, label="first relay") + _assert_remote_ping(host, host_ssh_port, key_path, remote_socket_addr_2, label="second relay") + time.sleep(0.5) + + # Test 1: cmux ping (v1) + _assert_remote_ping(host, host_ssh_port, key_path, remote_socket_addr, label="cmux") # Test 2: cmux list-workspaces --json (v2) list_ws_result = _ssh_run( @@ -265,6 +329,12 @@ def main() -> int: except Exception: pass workspace_id = "" + if workspace_id_2: + try: + client.close_workspace(workspace_id_2) + except Exception: + pass + workspace_id_2 = "" print("PASS: cmux CLI commands relay correctly over SSH reverse socket forwarding") return 0 @@ -276,6 +346,12 @@ def main() -> int: cleanup_client.close_workspace(workspace_id) except Exception: pass + if workspace_id_2: + try: + with cmux(SOCKET_PATH) as cleanup_client: + cleanup_client.close_workspace(workspace_id_2) + except Exception: + pass _run(["docker", "rm", "-f", container_name], check=False) _run(["docker", "rmi", "-f", image_tag], check=False) diff --git a/tests_v2/test_workspace_create_initial_env.py b/tests_v2/test_workspace_create_initial_env.py new file mode 100644 index 00000000..33b56c2e --- /dev/null +++ b/tests_v2/test_workspace_create_initial_env.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""Regression: workspace.create must apply initial_env to the initial terminal.""" + +import os +import sys +import time +import base64 +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _wait_for_text(c: cmux, workspace_id: str, needle: str, timeout_s: float = 8.0) -> str: + deadline = time.time() + timeout_s + last_text = "" + while time.time() < deadline: + payload = c._call( + "surface.read_text", + {"workspace_id": workspace_id}, + ) or {} + if "text" in payload: + last_text = str(payload.get("text") or "") + else: + b64 = str(payload.get("base64") or "") + raw = base64.b64decode(b64) if b64 else b"" + last_text = raw.decode("utf-8", errors="replace") + if needle in last_text: + return last_text + time.sleep(0.1) + raise cmuxError(f"Timed out waiting for {needle!r} in panel text: {last_text!r}") + + +def main() -> int: + with cmux(SOCKET_PATH) as c: + baseline_workspace = c.current_workspace() + created_workspace = "" + try: + token = f"tok_{int(time.time() * 1000)}" + payload = c._call( + "workspace.create", + { + "initial_env": {"CMUX_INITIAL_ENV_TOKEN": token}, + }, + ) or {} + created_workspace = str(payload.get("workspace_id") or "") + _must(bool(created_workspace), f"workspace.create returned no workspace_id: {payload}") + _must(c.current_workspace() == baseline_workspace, "workspace.create should not steal workspace focus") + + # Terminal surfaces in background workspaces may not be attached/render-ready yet. + # Select it before reading text so the initial command output is available. + c.select_workspace(created_workspace) + listed = c._call("surface.list", {"workspace_id": created_workspace}) or {} + rows = list(listed.get("surfaces") or []) + _must(bool(rows), "Expected at least one surface in the created workspace") + terminal_row = next((row for row in rows if str(row.get("type") or "") == "terminal"), None) + _must(terminal_row is not None, f"Expected a terminal surface in workspace.create result: {rows}") + + c.send("printf 'CMUX_ENV_CHECK=%s\\n' \"$CMUX_INITIAL_ENV_TOKEN\"\\n") + text = _wait_for_text(c, created_workspace, f"CMUX_ENV_CHECK={token}") + _must( + f"CMUX_ENV_CHECK={token}" in text, + f"initial_env token missing from terminal output: {text!r}", + ) + c.select_workspace(baseline_workspace) + finally: + if created_workspace: + try: + c.close_workspace(created_workspace) + except Exception: + pass + + print("PASS: workspace.create applies initial_env to initial terminal") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From c5f7217f85fd299567b5fe745925d7a8a5b74aa4 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 24 Feb 2026 21:27:22 -0800 Subject: [PATCH 07/13] Focus new workspace after cmux ssh and lock with regression test --- CLI/cmux.swift | 7 +++++++ tests_v2/test_ssh_remote_cli_relay.py | 10 +++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 26fe7e78..3c90fe9d 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -1955,6 +1955,8 @@ struct CMUXCLI { guard let workspaceId = workspaceCreate["workspace_id"] as? String, !workspaceId.isEmpty else { throw CLIError(message: "workspace.create did not return workspace_id") } + let workspaceWindowId = (workspaceCreate["window_id"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) if let workspaceName = sshOptions.workspaceName?.trimmingCharacters(in: .whitespacesAndNewlines), !workspaceName.isEmpty { @@ -1985,6 +1987,11 @@ struct CMUXCLI { } var payload = try client.sendV2(method: "workspace.remote.configure", params: configureParams) + var selectParams: [String: Any] = ["workspace_id": workspaceId] + if let workspaceWindowId, !workspaceWindowId.isEmpty { + selectParams["window_id"] = workspaceWindowId + } + _ = try client.sendV2(method: "workspace.select", params: selectParams) payload["ssh_command"] = sshCommand payload["ssh_startup_command"] = sshStartupCommand diff --git a/tests_v2/test_ssh_remote_cli_relay.py b/tests_v2/test_ssh_remote_cli_relay.py index 2ba2afcf..2e189f04 100644 --- a/tests_v2/test_ssh_remote_cli_relay.py +++ b/tests_v2/test_ssh_remote_cli_relay.py @@ -63,7 +63,7 @@ def _run_cli_json(cli: str, args: list[str]) -> dict: env.pop("CMUX_SURFACE_ID", None) env.pop("CMUX_TAB_ID", None) - proc = _run([cli, "--socket", SOCKET_PATH, "--json", *args], env=env) + proc = _run([cli, "--socket", SOCKET_PATH, "--json", "--id-format", "both", *args], env=env) try: return json.loads(proc.stdout or "{}") except Exception as exc: # noqa: BLE001 @@ -204,6 +204,14 @@ def main() -> int: workspace_id = str(row.get("id") or "") break _must(bool(workspace_id), f"cmux ssh output missing workspace_id: {payload}") + workspace_window_id = payload.get("window_id") + current_params = {"window_id": workspace_window_id} if isinstance(workspace_window_id, str) and workspace_window_id else {} + current = client._call("workspace.current", current_params) or {} + current_workspace_id = str(current.get("workspace_id") or "") + _must( + current_workspace_id == workspace_id, + f"cmux ssh should focus created workspace: current={current_workspace_id!r} created={workspace_id!r}", + ) remote_relay_port = payload.get("remote_relay_port") _must(remote_relay_port is not None, f"cmux ssh output missing remote_relay_port: {payload}") From 0f47ae3d1ac054c96e5cfda16b7b0ad08f35f846 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 24 Feb 2026 21:30:57 -0800 Subject: [PATCH 08/13] Document cmux ssh focus-intent workspace selection --- CLI/cmux.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 3c90fe9d..50f225a7 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -1991,6 +1991,8 @@ struct CMUXCLI { if let workspaceWindowId, !workspaceWindowId.isEmpty { selectParams["window_id"] = workspaceWindowId } + // `cmux ssh` is a focus-intent command: after provisioning/configuring the remote + // workspace, switch UI focus to that newly created workspace so the user lands there. _ = try client.sendV2(method: "workspace.select", params: selectParams) payload["ssh_command"] = sshCommand From 0e40779bc9d682a3683fb282f73674f14aa86245 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 24 Feb 2026 21:41:15 -0800 Subject: [PATCH 09/13] Show SSH copy-error menu for status-only error workspaces --- Sources/ContentView.swift | 78 +++++++++++++++---- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 21 +++++ 2 files changed, 83 insertions(+), 16 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index bf36864a..e64d56d8 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -2240,6 +2240,37 @@ enum SidebarRemoteErrorCopySupport { "\(index + 1). \(entry.workspaceTitle) (\(entry.target)): \(entry.detail)" }.joined(separator: "\n") } + + static func parsedTargetAndDetail(from statusValue: String, fallbackTarget: String? = nil) -> (target: String, detail: String)? { + let trimmed = statusValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, trimmed.hasPrefix("SSH error") else { return nil } + + let normalizedFallbackTarget: String? = { + guard let fallbackTarget else { return nil } + let trimmedFallback = fallbackTarget.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmedFallback.isEmpty ? nil : trimmedFallback + }() + + if let separator = trimmed.range(of: ": ") { + let prefix = String(trimmed[..<separator.lowerBound]).trimmingCharacters(in: .whitespacesAndNewlines) + let detail = String(trimmed[separator.upperBound...]).trimmingCharacters(in: .whitespacesAndNewlines) + guard !detail.isEmpty else { return nil } + + var parsedTarget: String? + if prefix.hasPrefix("SSH error ("), prefix.hasSuffix(")") { + let start = prefix.index(prefix.startIndex, offsetBy: "SSH error (".count) + let end = prefix.index(before: prefix.endIndex) + let candidate = String(prefix[start..<end]).trimmingCharacters(in: .whitespacesAndNewlines) + if !candidate.isEmpty { + parsedTarget = candidate + } + } + + return (parsedTarget ?? normalizedFallbackTarget ?? "remote host", detail) + } + + return (normalizedFallbackTarget ?? "remote host", trimmed) + } } private struct TabItemView: View { @@ -2543,7 +2574,7 @@ private struct TabItemView: View { let targetIds = contextTargetIds() let targetWorkspaces = targetIds.compactMap { id in tabManager.tabs.first(where: { $0.id == id }) } let remoteTargetWorkspaces = targetWorkspaces.filter { $0.isRemoteWorkspace } - let remoteWorkspaceErrors = remoteErrorCopyEntries(in: remoteTargetWorkspaces) + let remoteWorkspaceErrors = remoteErrorCopyEntries(in: targetWorkspaces) let reconnectLabel = remoteTargetWorkspaces.count > 1 ? "Reconnect Workspaces" : "Reconnect Workspace" let disconnectLabel = remoteTargetWorkspaces.count > 1 ? "Disconnect Workspaces" : "Disconnect Workspace" let shouldPin = !tab.isPinned @@ -2572,22 +2603,24 @@ private struct TabItemView: View { } } - if !remoteTargetWorkspaces.isEmpty { + if !remoteTargetWorkspaces.isEmpty || !remoteWorkspaceErrors.isEmpty { Divider() - Button(reconnectLabel) { - for workspace in remoteTargetWorkspaces { - workspace.reconnectRemoteConnection() + if !remoteTargetWorkspaces.isEmpty { + Button(reconnectLabel) { + for workspace in remoteTargetWorkspaces { + workspace.reconnectRemoteConnection() + } } - } - .disabled(remoteTargetWorkspaces.allSatisfy { $0.remoteConnectionState == .connecting }) + .disabled(remoteTargetWorkspaces.allSatisfy { $0.remoteConnectionState == .connecting }) - Button(disconnectLabel) { - for workspace in remoteTargetWorkspaces { - workspace.disconnectRemoteConnection(clearConfiguration: false) + Button(disconnectLabel) { + for workspace in remoteTargetWorkspaces { + workspace.disconnectRemoteConnection(clearConfiguration: false) + } } + .disabled(remoteTargetWorkspaces.allSatisfy { $0.remoteConnectionState == .disconnected }) } - .disabled(remoteTargetWorkspaces.allSatisfy { $0.remoteConnectionState == .disconnected }) if let copyErrorLabel = SidebarRemoteErrorCopySupport.menuLabel(for: remoteWorkspaceErrors), let copyErrorText = SidebarRemoteErrorCopySupport.clipboardText(for: remoteWorkspaceErrors) { @@ -2789,15 +2822,28 @@ private struct TabItemView: View { private func remoteErrorCopyEntries(in workspaces: [Tab]) -> [SidebarRemoteErrorCopyEntry] { workspaces.compactMap { workspace in - guard workspace.remoteConnectionState == .error else { return nil } - guard let detail = workspace.remoteConnectionDetail?.trimmingCharacters(in: .whitespacesAndNewlines), - !detail.isEmpty else { + if workspace.remoteConnectionState == .error, + let detail = workspace.remoteConnectionDetail?.trimmingCharacters(in: .whitespacesAndNewlines), + !detail.isEmpty { + return SidebarRemoteErrorCopyEntry( + workspaceTitle: workspace.title, + target: workspace.remoteDisplayTarget ?? "remote host", + detail: detail + ) + } + + guard let statusValue = workspace.statusEntries["remote.error"]?.value, + let parsed = SidebarRemoteErrorCopySupport.parsedTargetAndDetail( + from: statusValue, + fallbackTarget: workspace.remoteDisplayTarget + ) else { return nil } + return SidebarRemoteErrorCopyEntry( workspaceTitle: workspace.title, - target: workspace.remoteDisplayTarget ?? "remote host", - detail: detail + target: parsed.target, + detail: parsed.detail ) } } diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 5a674736..0e72f575 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -2725,6 +2725,27 @@ final class SidebarRemoteErrorCopySupportTests: XCTestCase { """ ) } + + func testParsedTargetAndDetailParsesCanonicalStatusValue() { + let parsed = SidebarRemoteErrorCopySupport.parsedTargetAndDetail( + from: "SSH error (devbox:22): failed to bootstrap daemon" + ) + XCTAssertEqual(parsed?.target, "devbox:22") + XCTAssertEqual(parsed?.detail, "failed to bootstrap daemon") + } + + func testParsedTargetAndDetailUsesFallbackTargetWhenStatusOmitsTarget() { + let parsed = SidebarRemoteErrorCopySupport.parsedTargetAndDetail( + from: "SSH error: connection refused", + fallbackTarget: "fallback-host" + ) + XCTAssertEqual(parsed?.target, "fallback-host") + XCTAssertEqual(parsed?.detail, "connection refused") + } + + func testParsedTargetAndDetailIgnoresNonSSHStatusValues() { + XCTAssertNil(SidebarRemoteErrorCopySupport.parsedTargetAndDetail(from: "All good")) + } } final class WorkspaceReorderTests: XCTestCase { From 4e5b5c8ee836aea7176d7d98fe525e1b85394c10 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 24 Feb 2026 21:46:11 -0800 Subject: [PATCH 10/13] Make cmux available in cmux ssh sessions via ephemeral PATH bootstrap --- CLI/cmux.swift | 14 ++++++++++++-- tests_v2/test_ssh_remote_cli_relay.py | 5 +++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 50f225a7..690020cd 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -2120,8 +2120,18 @@ struct CMUXCLI { parts += ["-o", trimmed] } - parts.append(options.destination) - parts.append(contentsOf: options.extraArguments) + if options.extraArguments.isEmpty { + // No explicit remote command provided: launch an interactive shell while prepending + // ~/.cmux/bin so `cmux` works in this SSH session without touching remote dotfiles. + if !hasSSHOptionKey(options.sshOptions, key: "RequestTTY") { + parts.append("-tt") + } + parts.append(options.destination) + parts.append("export PATH=\"$HOME/.cmux/bin:$PATH\"; exec \"${SHELL:-/bin/zsh}\" -l") + } else { + parts.append(options.destination) + parts.append(contentsOf: options.extraArguments) + } return parts.map(shellQuote).joined(separator: " ") } diff --git a/tests_v2/test_ssh_remote_cli_relay.py b/tests_v2/test_ssh_remote_cli_relay.py index 2e189f04..89853d15 100644 --- a/tests_v2/test_ssh_remote_cli_relay.py +++ b/tests_v2/test_ssh_remote_cli_relay.py @@ -204,6 +204,11 @@ def main() -> int: workspace_id = str(row.get("id") or "") break _must(bool(workspace_id), f"cmux ssh output missing workspace_id: {payload}") + startup_cmd = str(payload.get("ssh_startup_command") or "") + _must( + 'PATH="$HOME/.cmux/bin:$PATH"' in startup_cmd, + f"ssh startup command should prepend ~/.cmux/bin for remote cmux CLI: {startup_cmd!r}", + ) workspace_window_id = payload.get("window_id") current_params = {"window_id": workspace_window_id} if isinstance(workspace_window_id, str) and workspace_window_id else {} current = client._call("workspace.current", current_params) or {} From 2b7928aa60d04afa551d12fd53664952522f53d5 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 24 Feb 2026 21:53:35 -0800 Subject: [PATCH 11/13] Fix ssh terminfo spew by using RemoteCommand bootstrap --- CLI/cmux.swift | 12 +++++++++--- tests_v2/test_ssh_remote_cli_metadata.py | 4 ++++ tests_v2/test_ssh_remote_shell_integration.py | 5 +++++ 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 690020cd..1587b5ca 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -2121,13 +2121,19 @@ struct CMUXCLI { } if options.extraArguments.isEmpty { - // No explicit remote command provided: launch an interactive shell while prepending - // ~/.cmux/bin so `cmux` works in this SSH session without touching remote dotfiles. + // No explicit remote command provided: keep destination-only argv so Ghostty's + // ssh-terminfo bootstrap can safely append its own remote install command. + // Use RemoteCommand for session-local PATH bootstrap to make `cmux` available. if !hasSSHOptionKey(options.sshOptions, key: "RequestTTY") { parts.append("-tt") } + if !hasSSHOptionKey(options.sshOptions, key: "RemoteCommand") { + parts += [ + "-o", + "RemoteCommand=export PATH=\"$HOME/.cmux/bin:$PATH\"; exec \"${SHELL:-/bin/zsh}\" -l", + ] + } parts.append(options.destination) - parts.append("export PATH=\"$HOME/.cmux/bin:$PATH\"; exec \"${SHELL:-/bin/zsh}\" -l") } else { parts.append(options.destination) parts.append(contentsOf: options.extraArguments) diff --git a/tests_v2/test_ssh_remote_cli_metadata.py b/tests_v2/test_ssh_remote_cli_metadata.py index c540ff62..5ff706de 100644 --- a/tests_v2/test_ssh_remote_cli_metadata.py +++ b/tests_v2/test_ssh_remote_cli_metadata.py @@ -116,6 +116,10 @@ def main() -> int: _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}") + _must( + "RemoteCommand=export PATH=\"$HOME/.cmux/bin:$PATH\"; exec \"${SHELL:-/bin/zsh}\" -l" in ssh_command, + f"cmux ssh should use -o RemoteCommand for PATH bootstrap (not positional command): {ssh_command!r}", + ) listed_row = None deadline = time.time() + 8.0 diff --git a/tests_v2/test_ssh_remote_shell_integration.py b/tests_v2/test_ssh_remote_shell_integration.py index 38dd1710..325ece2f 100755 --- a/tests_v2/test_ssh_remote_shell_integration.py +++ b/tests_v2/test_ssh_remote_shell_integration.py @@ -246,6 +246,11 @@ def main() -> int: surfaces = client.list_surfaces(workspace_id) _must(bool(surfaces), f"workspace should have at least one surface: {workspace_id}") surface_id = surfaces[0][1] + terminal_text = client.read_terminal_text(surface_id) + _must( + "Reconstructed via infocmp" not in terminal_text, + "ssh-terminfo bootstrap should not leak raw infocmp output into the interactive shell", + ) term_value = _read_probe_payload(client, surface_id, "printf '%s' \"$TERM\"") terminfo_state = _read_probe_value(client, surface_id, "infocmp xterm-ghostty >/dev/null 2>&1") From daa340fa87ee9d7ff38d653ffad00a11e7186958 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 24 Feb 2026 22:09:27 -0800 Subject: [PATCH 12/13] Harden cmux ssh for mixed-version remote sessions --- CLI/cmux.swift | 12 +++- Sources/Workspace.swift | 64 ++++++++++++++++++++-- daemon/remote/README.md | 7 ++- daemon/remote/cmd/cmuxd-remote/cli.go | 12 +++- daemon/remote/cmd/cmuxd-remote/cli_test.go | 14 +++++ docs/remote-daemon-spec.md | 8 ++- tests_v2/test_ssh_remote_cli_metadata.py | 11 +++- tests_v2/test_ssh_remote_cli_relay.py | 45 ++++++++++----- 8 files changed, 142 insertions(+), 31 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 1587b5ca..c4e6bcc2 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -2128,9 +2128,19 @@ struct CMUXCLI { parts.append("-tt") } if !hasSSHOptionKey(options.sshOptions, key: "RemoteCommand") { + var startupExports = [ + "export PATH=\"$HOME/.cmux/bin:$PATH\"", + ] + if options.remoteRelayPort > 0 { + // Pin this shell to the relay allocated for this workspace so parallel + // SSH sessions (including from different cmux versions) don't race on + // shared ~/.cmux/socket_addr. + startupExports.append("export CMUX_SOCKET_PATH=127.0.0.1:\(options.remoteRelayPort)") + } + startupExports.append("exec \"${SHELL:-/bin/zsh}\" -l") parts += [ "-o", - "RemoteCommand=export PATH=\"$HOME/.cmux/bin:$PATH\"; exec \"${SHELL:-/bin/zsh}\" -l", + "RemoteCommand=\(startupExports.joined(separator: "; "))", ] } parts.append(options.destination) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index f995779b..164b64dc 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -494,6 +494,7 @@ private final class WorkspaceRemoteSessionController { guard let self, !self.isStopping else { return } guard self.reverseRelayProcess?.isRunning == true else { return } self.writeRemoteSocketAddrLocked() + self.writeRemoteRelayDaemonMappingLocked() } return true @@ -704,15 +705,46 @@ private final class WorkspaceRemoteSessionController { return try helloRemoteDaemonLocked(remotePath: remotePath) } - /// Creates `cmux` symlinks pointing to the daemon binary. + /// Installs a stable `cmux` wrapper on the remote and updates the default daemon target. + /// The wrapper resolves daemon path using relay-port metadata, allowing multiple local + /// cmux versions to coexist on the same remote host without clobbering each other. /// Tries `/usr/local/bin` first (already in PATH, no rc changes needed), falls back to /// `~/.cmux/bin`. Non-fatal: logs on failure but does not throw. private func createRemoteCLISymlinkLocked(daemonRemotePath: String) { let script = """ - mkdir -p "$HOME/.cmux/bin" - ln -sf "$HOME/\(daemonRemotePath)" "$HOME/.cmux/bin/cmux" - ln -sf "$HOME/\(daemonRemotePath)" /usr/local/bin/cmux 2>/dev/null \ - || sudo -n ln -sf "$HOME/\(daemonRemotePath)" /usr/local/bin/cmux 2>/dev/null \ + mkdir -p "$HOME/.cmux/bin" "$HOME/.cmux/relay" + ln -sf "$HOME/\(daemonRemotePath)" "$HOME/.cmux/bin/cmuxd-remote-current" + cat > "$HOME/.cmux/bin/cmux" <<'CMUX_REMOTE_WRAPPER' + #!/bin/sh + set -eu + + if [ -n "${CMUX_SOCKET_PATH:-}" ]; then + _cmux_port="${CMUX_SOCKET_PATH##*:}" + case "$_cmux_port" in + ''|*[!0-9]*) + ;; + *) + _cmux_map="$HOME/.cmux/relay/${_cmux_port}.daemon_path" + if [ -r "$_cmux_map" ]; then + _cmux_daemon="$(cat "$_cmux_map" 2>/dev/null || true)" + if [ -n "$_cmux_daemon" ] && [ -x "$_cmux_daemon" ]; then + exec "$_cmux_daemon" cli "$@" + fi + fi + ;; + esac + fi + + if [ -x "$HOME/.cmux/bin/cmuxd-remote-current" ]; then + exec "$HOME/.cmux/bin/cmuxd-remote-current" cli "$@" + fi + + echo "cmux: remote daemon not installed; reconnect from local cmux." >&2 + exit 127 + CMUX_REMOTE_WRAPPER + chmod 755 "$HOME/.cmux/bin/cmux" + ln -sf "$HOME/.cmux/bin/cmux" /usr/local/bin/cmux 2>/dev/null \ + || sudo -n ln -sf "$HOME/.cmux/bin/cmux" /usr/local/bin/cmux 2>/dev/null \ || true """ let command = "sh -lc \(Self.shellSingleQuoted(script))" @@ -747,6 +779,28 @@ private final class WorkspaceRemoteSessionController { } } + /// Writes relay-port -> daemon binary mapping used by the remote `cmux` wrapper. + /// This keeps CLI dispatch stable when multiple local cmux versions target the same host. + private func writeRemoteRelayDaemonMappingLocked() { + guard let relayPort = configuration.relayPort, relayPort > 0, + let daemonRemotePath, !daemonRemotePath.isEmpty else { return } + let script = """ + mkdir -p "$HOME/.cmux/relay" && \ + printf '%s' "$HOME/\(daemonRemotePath)" > "$HOME/.cmux/relay/\(relayPort).daemon_path" + """ + let command = "sh -lc \(Self.shellSingleQuoted(script))" + do { + let result = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 8) + if result.status != 0 { + NSLog("[cmux] warning: failed to write remote relay daemon mapping (exit %d): %@", + result.status, + Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "unknown error") + } + } catch { + NSLog("[cmux] warning: failed to write remote relay daemon mapping: %@", error.localizedDescription) + } + } + private func resolveRemotePlatformLocked() throws -> RemotePlatform { let script = "uname -s; uname -m" let command = "sh -lc \(Self.shellSingleQuoted(script))" diff --git a/daemon/remote/README.md b/daemon/remote/README.md index f84c75f8..64a94337 100644 --- a/daemon/remote/README.md +++ b/daemon/remote/README.md @@ -8,7 +8,7 @@ Go remote daemon for `cmux ssh` bootstrap, capability negotiation, and CLI relay 2. `cmuxd-remote serve --stdio` 3. `cmuxd-remote cli <command> [args...]` — relay cmux commands to the local app over the reverse TCP forward -When invoked as `cmux` (via symlink created during bootstrap), the binary auto-dispatches to the `cli` subcommand. This is busybox-style argv[0] detection. +When invoked as `cmux` (via wrapper/symlink installed during bootstrap), the binary auto-dispatches to the `cli` subcommand. This is busybox-style argv[0] detection. ## RPC methods (newline-delimited JSON over stdio) @@ -17,7 +17,7 @@ When invoked as `cmux` (via symlink created during bootstrap), the binary auto-d ## CLI relay -The `cli` subcommand (or `cmux` symlink) connects to the local cmux app's socket through an SSH reverse TCP forward and relays commands. It supports both v1 text protocol and v2 JSON-RPC commands. +The `cli` subcommand (or `cmux` wrapper/symlink) connects to the local cmux app's socket through an SSH reverse TCP forward and relays commands. It supports both v1 text protocol and v2 JSON-RPC commands. Socket discovery order: 1. `--socket <path>` flag @@ -31,5 +31,6 @@ For TCP addresses, the CLI retries for up to 15 seconds on connection refused, r 1. `workspace.remote.configure` bootstraps this binary over SSH when missing. 2. Client sends `hello` before enabling remote port probing/forwarding. 3. Daemon status/capabilities are exposed in `workspace.remote.status -> remote.daemon`. -4. Bootstrap creates `~/.cmux/bin/cmux` symlink pointing to the daemon binary. +4. Bootstrap installs `~/.cmux/bin/cmux` wrapper and keeps a default daemon target (`~/.cmux/bin/cmuxd-remote-current`). 5. A background `ssh -N -R` process reverse-forwards a TCP port to the local cmux Unix socket. The relay address is written to `~/.cmux/socket_addr` on the remote. +6. Relay startup writes `~/.cmux/relay/<port>.daemon_path` so the wrapper can route each shell to the correct daemon binary when multiple local cmux instances/versions coexist. diff --git a/daemon/remote/cmd/cmuxd-remote/cli.go b/daemon/remote/cmd/cmuxd-remote/cli.go index 77654e7d..fad8b4d9 100644 --- a/daemon/remote/cmd/cmuxd-remote/cli.go +++ b/daemon/remote/cmd/cmuxd-remote/cli.go @@ -93,6 +93,9 @@ func runCLI(args []string) int { i++ case "--json": jsonOutput = true + case "--help", "-h": + cliUsage() + return 0 default: remaining = append(remaining, args[i:]...) goto doneFlags @@ -104,6 +107,12 @@ doneFlags: cliUsage() return 2 } + cmdName := remaining[0] + cmdArgs := remaining[1:] + if cmdName == "help" { + cliUsage() + return 0 + } // refreshAddr is set when the address came from socket_addr file (not env/flag), // allowing retry loops to pick up updated relay ports. @@ -117,9 +126,6 @@ doneFlags: return 1 } - cmdName := remaining[0] - cmdArgs := remaining[1:] - // Special case: "rpc" passthrough if cmdName == "rpc" { return runRPC(socketPath, cmdArgs, jsonOutput, refreshAddr) diff --git a/daemon/remote/cmd/cmuxd-remote/cli_test.go b/daemon/remote/cmd/cmuxd-remote/cli_test.go index bfb876b1..924c4e00 100644 --- a/daemon/remote/cmd/cmuxd-remote/cli_test.go +++ b/daemon/remote/cmd/cmuxd-remote/cli_test.go @@ -381,6 +381,20 @@ func TestCLINoArgs(t *testing.T) { } } +func TestCLIHelpFlag(t *testing.T) { + code := runCLI([]string{"--help"}) + if code != 0 { + t.Fatalf("--help should return 0, got %d", code) + } +} + +func TestCLIHelpCommand(t *testing.T) { + code := runCLI([]string{"help"}) + if code != 0 { + t.Fatalf("help should return 0, got %d", code) + } +} + func TestFlagToParamKey(t *testing.T) { tests := []struct { input, expected string diff --git a/docs/remote-daemon-spec.md b/docs/remote-daemon-spec.md index a5e4ebf3..d28cb27a 100644 --- a/docs/remote-daemon-spec.md +++ b/docs/remote-daemon-spec.md @@ -33,15 +33,17 @@ This is a **living implementation spec** (also called an **execution spec**): a - `DONE` local app probes remote platform, builds/uploads `cmuxd-remote`, and runs `serve --stdio`. - `DONE` daemon `hello` handshake is enforced. - `DONE` bootstrap/probe failures surface actionable details. -- `DONE` bootstrap creates `~/.cmux/bin/cmux` symlink (also tries `/usr/local/bin/cmux`) so `cmux` is available in PATH on the remote. +- `DONE` bootstrap installs `~/.cmux/bin/cmux` wrapper (also tries `/usr/local/bin/cmux`) so `cmux` is available in PATH on the remote. ### 3.5 CLI Relay (Running cmux Commands From Remote) - `DONE` `cmuxd-remote` includes a table-driven CLI relay (`cli` subcommand) that maps CLI args to v1 text or v2 JSON-RPC messages. -- `DONE` busybox-style argv[0] detection: when invoked as `cmux` via symlink, auto-dispatches to CLI relay. +- `DONE` busybox-style argv[0] detection: when invoked as `cmux` via wrapper/symlink, auto-dispatches to CLI relay. - `DONE` background `ssh -N -R 127.0.0.1:PORT:/local/cmux.sock` process reverse-forwards a TCP port to the local cmux socket. Uses TCP instead of Unix socket forwarding because many servers have `AllowStreamLocalForwarding` disabled. - `DONE` relay process uses `ControlPath=none` (avoids ControlMaster multiplexing and inherited `RemoteForward` directives) and `ExitOnForwardFailure=no` (inherited forwards from user ssh config failing should not kill the relay). - `DONE` relay address written to `~/.cmux/socket_addr` on the remote with a 3s delay after the relay process starts, giving SSH time to establish the `-R` forward. - `DONE` Go CLI re-reads `~/.cmux/socket_addr` on each TCP retry to pick up updated relay ports when multiple workspaces overwrite the file. +- `DONE` `cmux ssh` startup exports session-local `CMUX_SOCKET_PATH=127.0.0.1:<relay_port>` so parallel sessions pin to their own relay instead of racing on shared socket_addr. +- `DONE` relay startup writes `~/.cmux/relay/<relay_port>.daemon_path`; remote `cmux` wrapper uses this to select the right daemon binary per session, including mixed local cmux versions. - `DONE` ephemeral port range (49152-65535) filtered from probe results to exclude relay ports from other workspaces. - `DONE` multi-workspace port conflict detection uses TCP connect check (`isLoopbackPortReachable`) so ports already forwarded by another workspace are silently skipped instead of flagged as conflicts. - `DONE` orphaned relay SSH processes from previous app sessions are cleaned up before starting a new relay. @@ -112,7 +114,7 @@ Recompute effective size on: | M-002 | Remote bootstrap/upload/start + hello handshake | DONE | Current `cmuxd-remote` is minimal (`hello`, `ping`) | | 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 | Existing docker tests currently validate mirroring-era path | -| M-004b | CLI relay: run cmux commands from within SSH sessions | DONE | Reverse TCP forward + Go CLI relay + bootstrap symlink (PR #374) | +| M-004b | CLI relay: run cmux commands from within SSH sessions | DONE | Reverse TCP forward + Go CLI relay + bootstrap wrapper (PR #374) | | M-005 | Remove automatic remote port mirroring path | TODO | Delete probe/listen mirror loop from `WorkspaceRemoteSessionController` | | M-006 | Transport-scoped local proxy broker (SOCKS5 + CONNECT) | TODO | Local component in app/daemon layer | | M-007 | Remote proxy stream RPC in `cmuxd-remote` | TODO | Add `proxy.open/close` and multiplexed stream handling | diff --git a/tests_v2/test_ssh_remote_cli_metadata.py b/tests_v2/test_ssh_remote_cli_metadata.py index 5ff706de..2d0ad892 100644 --- a/tests_v2/test_ssh_remote_cli_metadata.py +++ b/tests_v2/test_ssh_remote_cli_metadata.py @@ -95,6 +95,9 @@ def main() -> int: workspace_id = str(row.get("id") or "") break _must(bool(workspace_id), f"cmux ssh output missing workspace_id: {payload}") + remote_relay_port = payload.get("remote_relay_port") + _must(remote_relay_port is not None, f"cmux ssh output missing remote_relay_port: {payload}") + remote_socket_addr = f"127.0.0.1:{int(remote_relay_port)}" ssh_command = str(payload.get("ssh_command") or "") _must(bool(ssh_command), f"cmux ssh output missing ssh_command: {payload}") _must( @@ -117,8 +120,12 @@ def main() -> int: _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}") _must( - "RemoteCommand=export PATH=\"$HOME/.cmux/bin:$PATH\"; exec \"${SHELL:-/bin/zsh}\" -l" in ssh_command, - f"cmux ssh should use -o RemoteCommand for PATH bootstrap (not positional command): {ssh_command!r}", + ( + f"RemoteCommand=export PATH=\"$HOME/.cmux/bin:$PATH\"; " + f"export CMUX_SOCKET_PATH={remote_socket_addr}; " + "exec \"${SHELL:-/bin/zsh}\" -l" + ) in ssh_command, + f"cmux ssh should use -o RemoteCommand for PATH/bootstrap env pinning (not positional command): {ssh_command!r}", ) listed_row = None diff --git a/tests_v2/test_ssh_remote_cli_relay.py b/tests_v2/test_ssh_remote_cli_relay.py index 89853d15..53e01a95 100644 --- a/tests_v2/test_ssh_remote_cli_relay.py +++ b/tests_v2/test_ssh_remote_cli_relay.py @@ -204,11 +204,20 @@ def main() -> int: workspace_id = str(row.get("id") or "") break _must(bool(workspace_id), f"cmux ssh output missing workspace_id: {payload}") + remote_relay_port = payload.get("remote_relay_port") + _must(remote_relay_port is not None, f"cmux ssh output missing remote_relay_port: {payload}") + remote_relay_port = int(remote_relay_port) + _must(49152 <= remote_relay_port <= 65535, f"remote_relay_port should be in ephemeral range: {remote_relay_port}") + remote_socket_addr = f"127.0.0.1:{remote_relay_port}" startup_cmd = str(payload.get("ssh_startup_command") or "") _must( 'PATH="$HOME/.cmux/bin:$PATH"' in startup_cmd, f"ssh startup command should prepend ~/.cmux/bin for remote cmux CLI: {startup_cmd!r}", ) + _must( + f"CMUX_SOCKET_PATH={remote_socket_addr}" in startup_cmd, + f"ssh startup command should pin CMUX_SOCKET_PATH to workspace relay: {startup_cmd!r}", + ) workspace_window_id = payload.get("window_id") current_params = {"window_id": workspace_window_id} if isinstance(workspace_window_id, str) and workspace_window_id else {} current = client._call("workspace.current", current_params) or {} @@ -218,12 +227,6 @@ def main() -> int: f"cmux ssh should focus created workspace: current={current_workspace_id!r} created={workspace_id!r}", ) - remote_relay_port = payload.get("remote_relay_port") - _must(remote_relay_port is not None, f"cmux ssh output missing remote_relay_port: {payload}") - remote_relay_port = int(remote_relay_port) - _must(49152 <= remote_relay_port <= 65535, f"remote_relay_port should be in ephemeral range: {remote_relay_port}") - remote_socket_addr = f"127.0.0.1:{remote_relay_port}" - # Wait for daemon to be ready first_status = _wait_for_remote_ready(client, workspace_id) first_remote = first_status.get("remote") or {} @@ -238,15 +241,24 @@ def main() -> int: f"expected no forwarded ports when none are eligible: {first_status}", ) - # Verify the cmux symlink exists on the remote - symlink_check = _ssh_run( - host, host_ssh_port, key_path, - "test -L \"$HOME/.cmux/bin/cmux\" && echo symlink-ok", - check=False, - ) + # Verify remote cmux wrapper + relay-specific daemon mapping were installed. + wrapper_check = None + wrapper_deadline = time.time() + 10.0 + while time.time() < wrapper_deadline: + wrapper_check = _ssh_run( + host, host_ssh_port, key_path, + f"test -x \"$HOME/.cmux/bin/cmux\" && test -f \"$HOME/.cmux/bin/cmux\" && " + f"map=\"$HOME/.cmux/relay/{remote_relay_port}.daemon_path\" && " + "daemon=\"$(cat \"$map\" 2>/dev/null || true)\" && " + "test -n \"$daemon\" && test -x \"$daemon\" && echo wrapper-ok", + check=False, + ) + if "wrapper-ok" in (wrapper_check.stdout or ""): + break + time.sleep(0.4) _must( - "symlink-ok" in symlink_check.stdout, - f"Expected cmux symlink at ~/.cmux/bin/cmux on remote: {symlink_check.stdout} {symlink_check.stderr}", + wrapper_check is not None and "wrapper-ok" in (wrapper_check.stdout or ""), + f"Expected remote cmux wrapper+relay mapping to exist: {wrapper_check.stdout if wrapper_check else ''} {wrapper_check.stderr if wrapper_check else ''}", ) # Start a second SSH workspace to the same destination and verify both @@ -282,6 +294,11 @@ def main() -> int: f"relay ports should differ per workspace: {remote_relay_port_2} vs {remote_relay_port}", ) remote_socket_addr_2 = f"127.0.0.1:{remote_relay_port_2}" + startup_cmd_2 = str(payload_2.get("ssh_startup_command") or "") + _must( + f"CMUX_SOCKET_PATH={remote_socket_addr_2}" in startup_cmd_2, + f"second ssh startup command should pin CMUX_SOCKET_PATH to second relay: {startup_cmd_2!r}", + ) _ = _wait_for_remote_ready(client, workspace_id_2) stability_deadline = time.time() + 8.0 From 2714d07c9f4dea358131dd8c7fa74709c4ea3863 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 25 Feb 2026 00:30:39 -0800 Subject: [PATCH 13/13] Scope socket password keychain per debug run and harden CLI lookup --- CLI/cmux.swift | 95 +++++++++--- Sources/SocketControlSettings.swift | 102 +++++++++++-- tests/test_cli_version_flag.py | 18 ++- tests/test_socket_password_keychain_scope.py | 146 +++++++++++++++++++ 4 files changed, 324 insertions(+), 37 deletions(-) create mode 100644 tests/test_socket_password_keychain_scope.py diff --git a/CLI/cmux.swift b/CLI/cmux.swift index da780371..07cafe21 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -1,5 +1,6 @@ import Foundation import Darwin +import LocalAuthentication import Security #if canImport(Sentry) import Sentry @@ -419,14 +420,14 @@ private enum SocketPasswordResolver { private static let service = "com.cmuxterm.app.socket-control" private static let account = "local-socket-password" - static func resolve(explicit: String?) -> String? { + static func resolve(explicit: String?, socketPath: String) -> String? { if let explicit = normalized(explicit), !explicit.isEmpty { return explicit } if let env = normalized(ProcessInfo.processInfo.environment["CMUX_SOCKET_PASSWORD"]), !env.isEmpty { return env } - return loadFromKeychain() + return loadFromKeychain(socketPath: socketPath) } private static func normalized(_ value: String?) -> String? { @@ -435,23 +436,81 @@ private enum SocketPasswordResolver { return trimmed.isEmpty ? nil : trimmed } - private static func loadFromKeychain() -> String? { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: account, - kSecReturnData as String: true, - kSecMatchLimit as String: kSecMatchLimitOne, - ] - var result: CFTypeRef? - let status = SecItemCopyMatching(query as CFDictionary, &result) - guard status == errSecSuccess else { - return nil + private static func keychainServices(socketPath: String) -> [String] { + guard let scope = keychainScope(socketPath: socketPath) else { + return [service] } - guard let data = result as? Data else { - return nil + return ["\(service).\(scope)"] + } + + private static func keychainScope(socketPath: String) -> String? { + if let tag = normalized(ProcessInfo.processInfo.environment["CMUX_TAG"]) { + let scoped = sanitizeScope(tag) + if !scoped.isEmpty { + return scoped + } } - return String(data: data, encoding: .utf8) + + let candidate = URL(fileURLWithPath: socketPath).lastPathComponent + let prefixes = ["cmux-debug-", "cmux-"] + for prefix in prefixes { + guard candidate.hasPrefix(prefix), candidate.hasSuffix(".sock") else { continue } + let start = candidate.index(candidate.startIndex, offsetBy: prefix.count) + let end = candidate.index(candidate.endIndex, offsetBy: -".sock".count) + guard start < end else { continue } + let rawScope = String(candidate[start..<end]) + let scoped = sanitizeScope(rawScope) + if !scoped.isEmpty { + return scoped + } + } + return nil + } + + private static func sanitizeScope(_ raw: String) -> String { + let lowered = raw.lowercased() + let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: ".-")) + let mappedScalars = lowered.unicodeScalars.map { scalar -> Character in + allowed.contains(scalar) ? Character(scalar) : "." + } + var normalizedScope = String(mappedScalars) + normalizedScope = normalizedScope.replacingOccurrences( + of: "\\.+", + with: ".", + options: .regularExpression + ) + normalizedScope = normalizedScope.trimmingCharacters(in: CharacterSet(charactersIn: ".")) + return normalizedScope + } + + private static func loadFromKeychain(socketPath: String) -> String? { + for service in keychainServices(socketPath: socketPath) { + let authContext = LAContext() + authContext.interactionNotAllowed = true + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + // Never trigger keychain UI from CLI commands; fail fast instead. + kSecUseAuthenticationContext as String: authContext, + ] + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound || status == errSecInteractionNotAllowed || status == errSecAuthFailed { + continue + } + guard status == errSecSuccess else { + continue + } + guard let data = result as? Data, + let password = String(data: data, encoding: .utf8) else { + continue + } + return password + } + return nil } } @@ -769,7 +828,7 @@ struct CMUXCLI { } defer { client.close() } - if let socketPassword = SocketPasswordResolver.resolve(explicit: socketPasswordArg) { + if let socketPassword = SocketPasswordResolver.resolve(explicit: socketPasswordArg, socketPath: socketPath) { let authResponse = try client.send(command: "auth \(socketPassword)") if authResponse.hasPrefix("ERROR:"), !authResponse.contains("Unknown command 'auth'") { diff --git a/Sources/SocketControlSettings.swift b/Sources/SocketControlSettings.swift index b9705095..376a7a92 100644 --- a/Sources/SocketControlSettings.swift +++ b/Sources/SocketControlSettings.swift @@ -61,10 +61,80 @@ enum SocketControlPasswordStore { static let service = "com.cmuxterm.app.socket-control" static let account = "local-socket-password" - private static var baseQuery: [String: Any] { + private static func normalized(_ value: String?) -> String? { + guard let value else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + private static func sanitizeScope(_ raw: String) -> String { + let lowered = raw.lowercased() + let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: ".-")) + let mappedScalars = lowered.unicodeScalars.map { scalar -> Character in + allowed.contains(scalar) ? Character(scalar) : "." + } + var normalizedScope = String(mappedScalars) + normalizedScope = normalizedScope.replacingOccurrences( + of: "\\.+", + with: ".", + options: .regularExpression + ) + normalizedScope = normalizedScope.trimmingCharacters(in: CharacterSet(charactersIn: ".")) + return normalizedScope + } + + private static func scopeFromSocketPath(_ socketPath: String?) -> String? { + guard let socketPath = normalized(socketPath) else { + return nil + } + + let candidate = URL(fileURLWithPath: socketPath).lastPathComponent + let prefixes = ["cmux-debug-", "cmux-"] + for prefix in prefixes { + guard candidate.hasPrefix(prefix), candidate.hasSuffix(".sock") else { continue } + let start = candidate.index(candidate.startIndex, offsetBy: prefix.count) + let end = candidate.index(candidate.endIndex, offsetBy: -".sock".count) + guard start < end else { continue } + let rawScope = String(candidate[start..<end]) + let scoped = sanitizeScope(rawScope) + if !scoped.isEmpty { + return scoped + } + } + return nil + } + + private static func keychainScope(environment: [String: String]) -> String? { + if let tag = normalized(environment[SocketControlSettings.launchTagEnvKey]) { + let scoped = sanitizeScope(tag) + if !scoped.isEmpty { + return scoped + } + } + + if let scope = scopeFromSocketPath(environment["CMUX_SOCKET_PATH"]) { + return scope + } + + return scopeFromSocketPath( + SocketControlSettings.socketPath( + environment: environment, + bundleIdentifier: Bundle.main.bundleIdentifier + ) + ) + } + + private static func keychainService(environment: [String: String]) -> String { + guard let scope = keychainScope(environment: environment) else { + return service + } + return "\(service).\(scope)" + } + + private static func baseQuery(environment: [String: String]) -> [String: Any] { [ kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, + kSecAttrService as String: keychainService(environment: environment), kSecAttrAccount as String: account, ] } @@ -75,7 +145,7 @@ enum SocketControlPasswordStore { if let envPassword = environment[SocketControlSettings.socketPasswordEnvKey], !envPassword.isEmpty { return envPassword } - return try? loadPassword() + return try? loadPassword(environment: environment) } static func hasConfiguredPassword( @@ -95,8 +165,10 @@ enum SocketControlPasswordStore { return expected == candidate } - static func loadPassword() throws -> String? { - var query = baseQuery + static func loadPassword( + environment: [String: String] = ProcessInfo.processInfo.environment + ) throws -> String? { + var query = baseQuery(environment: environment) query[kSecReturnData as String] = true query[kSecMatchLimit as String] = kSecMatchLimitOne @@ -114,15 +186,19 @@ enum SocketControlPasswordStore { return String(data: data, encoding: .utf8) } - static func savePassword(_ password: String) throws { + static func savePassword( + _ password: String, + environment: [String: String] = ProcessInfo.processInfo.environment + ) throws { let normalized = password.trimmingCharacters(in: .newlines) if normalized.isEmpty { - try clearPassword() + try clearPassword(environment: environment) return } let data = Data(normalized.utf8) - var lookup = baseQuery + let scopedQuery = baseQuery(environment: environment) + var lookup = scopedQuery lookup[kSecReturnData as String] = true lookup[kSecMatchLimit as String] = kSecMatchLimitOne @@ -133,12 +209,12 @@ enum SocketControlPasswordStore { let attrsToUpdate: [String: Any] = [ kSecValueData as String: data ] - let updateStatus = SecItemUpdate(baseQuery as CFDictionary, attrsToUpdate as CFDictionary) + let updateStatus = SecItemUpdate(scopedQuery as CFDictionary, attrsToUpdate as CFDictionary) guard updateStatus == errSecSuccess else { throw NSError(domain: NSOSStatusErrorDomain, code: Int(updateStatus)) } case errSecItemNotFound: - var add = baseQuery + var add = scopedQuery add[kSecValueData as String] = data add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly let addStatus = SecItemAdd(add as CFDictionary, nil) @@ -150,8 +226,10 @@ enum SocketControlPasswordStore { } } - static func clearPassword() throws { - let status = SecItemDelete(baseQuery as CFDictionary) + static func clearPassword( + environment: [String: String] = ProcessInfo.processInfo.environment + ) throws { + let status = SecItemDelete(baseQuery(environment: environment) as CFDictionary) guard status == errSecSuccess || status == errSecItemNotFound else { throw NSError(domain: NSOSStatusErrorDomain, code: Int(status)) } diff --git a/tests/test_cli_version_flag.py b/tests/test_cli_version_flag.py index b48419f2..00499ce0 100644 --- a/tests/test_cli_version_flag.py +++ b/tests/test_cli_version_flag.py @@ -32,13 +32,17 @@ def resolve_cmux_cli() -> str: raise RuntimeError("Unable to find cmux CLI binary. Set CMUX_CLI_BIN.") -def run(cli_path: str, *args: str) -> tuple[int, str, str]: - proc = subprocess.run( - [cli_path, *args], - text=True, - capture_output=True, - check=False, - ) +def run(cli_path: str, *args: str, timeout: float = 5.0) -> tuple[int, str, str]: + try: + proc = subprocess.run( + [cli_path, *args], + text=True, + capture_output=True, + check=False, + timeout=timeout, + ) + except subprocess.TimeoutExpired: + return 124, "", f"timed out after {timeout:.1f}s" return proc.returncode, proc.stdout.strip(), proc.stderr.strip() diff --git a/tests/test_socket_password_keychain_scope.py b/tests/test_socket_password_keychain_scope.py new file mode 100644 index 00000000..2392d8c7 --- /dev/null +++ b/tests/test_socket_password_keychain_scope.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +"""Regression test: socket password keychain entries are scoped per debug instance.""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + + +def get_repo_root() -> Path: + result = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + capture_output=True, + text=True, + check=False, + ) + if result.returncode == 0: + return Path(result.stdout.strip()) + return Path.cwd() + + +def require(content: str, needle: str, message: str, failures: list[str]) -> None: + if needle not in content: + failures.append(message) + + +def reject(content: str, needle: str, message: str, failures: list[str]) -> None: + if needle in content: + failures.append(message) + + +def main() -> int: + repo_root = get_repo_root() + cli_path = repo_root / "CLI" / "cmux.swift" + settings_path = repo_root / "Sources" / "SocketControlSettings.swift" + + missing = [str(path) for path in (cli_path, settings_path) if not path.exists()] + if missing: + print("FAIL: missing expected files:") + for path in missing: + print(f"- {path}") + return 1 + + cli = cli_path.read_text(encoding="utf-8") + settings = settings_path.read_text(encoding="utf-8") + failures: list[str] = [] + + require( + cli, + "static func resolve(explicit: String?, socketPath: String) -> String?", + "CLI resolver must accept socketPath to determine scoped keychain service", + failures, + ) + require( + cli, + "private static func keychainServices(socketPath: String) -> [String]", + "CLI must derive keychain services from socket context", + failures, + ) + require( + cli, + 'return ["\\(service).\\(scope)"]', + "CLI should use only the scoped keychain service when scope is present", + failures, + ) + require( + cli, + "URL(fileURLWithPath: socketPath).lastPathComponent", + "CLI scope detection should parse the socket file name", + failures, + ) + require( + cli, + "kSecUseAuthenticationContext as String: authContext", + "CLI keychain lookup must fail fast without interactive keychain prompts", + failures, + ) + require( + cli, + "SocketPasswordResolver.resolve(explicit: socketPasswordArg, socketPath: socketPath)", + "CLI run path must pass socketPath into password resolution", + failures, + ) + + require( + settings, + "private static func keychainScope(environment: [String: String]) -> String?", + "App keychain store should compute a scoped keychain namespace", + failures, + ) + require( + settings, + "environment[SocketControlSettings.launchTagEnvKey]", + "App keychain scope should prioritize CMUX_TAG", + failures, + ) + require( + settings, + "URL(fileURLWithPath: socketPath).lastPathComponent", + "App keychain scope should parse the socket file name", + failures, + ) + require( + settings, + "private static func keychainService(environment: [String: String]) -> String", + "App keychain service should be derived from environment scope", + failures, + ) + require( + settings, + 'return "\\(service).\\(scope)"', + "App keychain service should append the scoped suffix", + failures, + ) + require( + settings, + "kSecAttrService as String: keychainService(environment: environment)", + "App keychain queries should use mode-specific scoped service", + failures, + ) + require( + settings, + "return try? loadPassword(environment: environment)", + "configuredPassword should read keychain from matching scoped service", + failures, + ) + + reject( + settings, + "private static var baseQuery: [String: Any]", + "Legacy global baseQuery should not remain as a static unscoped property", + failures, + ) + + if failures: + print("FAIL: keychain scope regression(s) detected") + for failure in failures: + print(f"- {failure}") + return 1 + + print("PASS: socket password keychain service is scoped by tagged debug instance") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())