From 47f4b5e55ad0f8c7d5ca5720e208a558a40931ef Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 28 Feb 2026 17:36:07 -0800 Subject: [PATCH] Address PR review feedback for SSH remote workspace flow --- CLI/cmux.swift | 63 +++++++---- Sources/GhosttyTerminalView.swift | 4 +- Sources/Panels/BrowserPanel.swift | 10 +- Sources/Panels/TerminalPanel.swift | 4 +- Sources/TerminalController.swift | 16 +++ Sources/Workspace.swift | 59 ++++++++-- daemon/remote/cmd/cmuxd-remote/main.go | 103 +++++++++++++++++- daemon/remote/cmd/cmuxd-remote/main_test.go | 43 +++++++- scripts/reload.sh | 7 +- tests/fixtures/ssh-remote/run.sh | 14 +++ tests_v2/test_ssh_remote_docker_forwarding.py | 4 +- tests_v2/test_ssh_remote_shell_integration.py | 2 +- 12 files changed, 277 insertions(+), 52 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index d6bd56fa..c38c47ac 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -2153,33 +2153,40 @@ struct CMUXCLI { throw CLIError(message: "workspace.create did not return workspace_id") } - if let workspaceName = sshOptions.workspaceName?.trimmingCharacters(in: .whitespacesAndNewlines), - !workspaceName.isEmpty { - _ = try client.sendV2(method: "workspace.rename", params: [ - "workspace_id": workspaceId, - "title": workspaceName, - ]) - } - let remoteSSHOptions = sshOptionsWithControlSocketDefaults(sshOptions.sshOptions) + let configuredPayload: [String: Any] + do { + if let workspaceName = sshOptions.workspaceName?.trimmingCharacters(in: .whitespacesAndNewlines), + !workspaceName.isEmpty { + _ = try client.sendV2(method: "workspace.rename", params: [ + "workspace_id": workspaceId, + "title": workspaceName, + ]) + } - var configureParams: [String: Any] = [ - "workspace_id": workspaceId, - "destination": sshOptions.destination, - "auto_connect": true, - ] - if let port = sshOptions.port { - configureParams["port"] = port - } - if let identityFile = sshOptions.identityFile?.trimmingCharacters(in: .whitespacesAndNewlines), - !identityFile.isEmpty { - configureParams["identity_file"] = identityFile - } - if !remoteSSHOptions.isEmpty { - configureParams["ssh_options"] = remoteSSHOptions + var configureParams: [String: Any] = [ + "workspace_id": workspaceId, + "destination": sshOptions.destination, + "auto_connect": true, + ] + if let port = sshOptions.port { + configureParams["port"] = port + } + if let identityFile = sshOptions.identityFile?.trimmingCharacters(in: .whitespacesAndNewlines), + !identityFile.isEmpty { + configureParams["identity_file"] = identityFile + } + if !remoteSSHOptions.isEmpty { + configureParams["ssh_options"] = remoteSSHOptions + } + + configuredPayload = try client.sendV2(method: "workspace.remote.configure", params: configureParams) + } catch { + _ = try? client.sendV2(method: "workspace.close", params: ["workspace_id": workspaceId]) + throw error } - var payload = try client.sendV2(method: "workspace.remote.configure", params: configureParams) + var payload = configuredPayload payload["ssh_command"] = sshCommand payload["ssh_startup_command"] = sshStartupCommand @@ -2253,6 +2260,11 @@ struct CMUXCLI { throw CLIError(message: "ssh: unknown flag '\(arg)'") } if destination == nil { + if arg.hasPrefix("-") { + throw CLIError( + message: "ssh: destination must be . Use --port/--identity/--ssh-option for SSH flags and `--` for remote command args." + ) + } destination = arg } else { extraArguments.append(arg) @@ -2264,6 +2276,11 @@ struct CMUXCLI { guard let destination else { throw CLIError(message: "ssh requires a destination (example: cmux ssh user@host)") } + if destination.hasPrefix("-") { + throw CLIError( + message: "ssh: destination must be . Use --port/--identity/--ssh-option for SSH flags and `--` for remote command args." + ) + } return SSHCommandOptions( destination: destination, diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 930159e2..1ff53ef5 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -1567,8 +1567,8 @@ final class TerminalSurface: Identifiable, ObservableObject { self.workingDirectory = workingDirectory?.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedCommand = initialCommand?.trimmingCharacters(in: .whitespacesAndNewlines) self.initialCommand = (trimmedCommand?.isEmpty == false) ? trimmedCommand : nil - var mergedEnvironment = initialEnvironmentOverrides - for (key, value) in additionalEnvironment { + var mergedEnvironment = additionalEnvironment + for (key, value) in initialEnvironmentOverrides { mergedEnvironment[key] = value } self.initialEnvironmentOverrides = mergedEnvironment diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 7725dffe..55422f4f 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -1548,7 +1548,13 @@ final class BrowserPanel: Panel, ObservableObject { guard #available(macOS 14.0, *) else { return } let store = webView.configuration.websiteDataStore - guard let endpoint = remoteProxyEndpoint, + guard let endpoint = remoteProxyEndpoint else { + store.proxyConfigurations = [] + return + } + + let host = endpoint.host.trimmingCharacters(in: .whitespacesAndNewlines) + guard !host.isEmpty, endpoint.port > 0 && endpoint.port <= 65535, let nwPort = NWEndpoint.Port(rawValue: UInt16(endpoint.port)) else { store.proxyConfigurations = [] @@ -1556,7 +1562,7 @@ final class BrowserPanel: Panel, ObservableObject { } let nwEndpoint = NWEndpoint.hostPort( - host: NWEndpoint.Host(endpoint.host), + host: NWEndpoint.Host(host), port: nwPort ) // Prefer SOCKSv5; keep CONNECT configured as fallback. diff --git a/Sources/Panels/TerminalPanel.swift b/Sources/Panels/TerminalPanel.swift index acc7f03a..b2507e20 100644 --- a/Sources/Panels/TerminalPanel.swift +++ b/Sources/Panels/TerminalPanel.swift @@ -88,8 +88,8 @@ final class TerminalPanel: Panel, ObservableObject { initialEnvironmentOverrides: [String: String] = [:], additionalEnvironment: [String: String] = [:] ) { - var mergedEnvironment = initialEnvironmentOverrides - for (key, value) in additionalEnvironment { + var mergedEnvironment = additionalEnvironment + for (key, value) in initialEnvironmentOverrides { mergedEnvironment[key] = value } let surface = TerminalSurface( diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 8bae020b..f4190d0a 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -2775,6 +2775,9 @@ class TerminalController { private func v2WorkspaceRemoteConfigure(params: [String: Any]) -> V2CallResult { let requestedWorkspaceId = v2UUID(params, "workspace_id") + if v2HasNonNullParam(params, "workspace_id"), requestedWorkspaceId == nil { + return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil) + } let fallbackTabManager = v2ResolveTabManager(params: params) let workspaceId = requestedWorkspaceId ?? fallbackTabManager?.selectedTabId guard let workspaceId else { @@ -2814,6 +2817,7 @@ class TerminalController { "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), ]) + // Must run on main for v2MainSync because Workspace.configureRemoteConnection mutates TabManager/UI-owned workspace state. v2MainSync { guard let owner = AppDelegate.shared?.tabManagerFor(tabId: workspaceId), let workspace = owner.tabs.first(where: { $0.id == workspaceId }) else { @@ -2844,6 +2848,9 @@ class TerminalController { private func v2WorkspaceRemoteDisconnect(params: [String: Any]) -> V2CallResult { let requestedWorkspaceId = v2UUID(params, "workspace_id") + if v2HasNonNullParam(params, "workspace_id"), requestedWorkspaceId == nil { + return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil) + } let fallbackTabManager = v2ResolveTabManager(params: params) let workspaceId = requestedWorkspaceId ?? fallbackTabManager?.selectedTabId guard let workspaceId else { @@ -2856,6 +2863,7 @@ class TerminalController { "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), ]) + // Must run on main for v2MainSync because disconnect mutates TabManager/UI-owned workspace state. v2MainSync { guard let owner = AppDelegate.shared?.tabManagerFor(tabId: workspaceId), let workspace = owner.tabs.first(where: { $0.id == workspaceId }) else { @@ -2878,6 +2886,9 @@ class TerminalController { private func v2WorkspaceRemoteReconnect(params: [String: Any]) -> V2CallResult { let requestedWorkspaceId = v2UUID(params, "workspace_id") + if v2HasNonNullParam(params, "workspace_id"), requestedWorkspaceId == nil { + return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil) + } let fallbackTabManager = v2ResolveTabManager(params: params) let workspaceId = requestedWorkspaceId ?? fallbackTabManager?.selectedTabId guard let workspaceId else { @@ -2889,6 +2900,7 @@ class TerminalController { "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), ]) + // Must run on main for v2MainSync because reconnect mutates TabManager/UI-owned workspace state. v2MainSync { guard let owner = AppDelegate.shared?.tabManagerFor(tabId: workspaceId), let workspace = owner.tabs.first(where: { $0.id == workspaceId }) else { @@ -2919,6 +2931,9 @@ class TerminalController { private func v2WorkspaceRemoteStatus(params: [String: Any]) -> V2CallResult { let requestedWorkspaceId = v2UUID(params, "workspace_id") + if v2HasNonNullParam(params, "workspace_id"), requestedWorkspaceId == nil { + return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil) + } let fallbackTabManager = v2ResolveTabManager(params: params) let workspaceId = requestedWorkspaceId ?? fallbackTabManager?.selectedTabId guard let workspaceId else { @@ -2930,6 +2945,7 @@ class TerminalController { "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), ]) + // Must run on main for v2MainSync because Workspace.remoteStatusPayload reads TabManager/UI-owned state. v2MainSync { guard let owner = AppDelegate.shared?.tabManagerFor(tabId: workspaceId), let workspace = owner.tabs.first(where: { $0.id == workspaceId }) else { diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 6bb840e6..fce24d0a 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -594,6 +594,8 @@ extension Workspace { } private final class WorkspaceRemoteDaemonRPCClient { + private static let maxStdoutBufferBytes = 256 * 1024 + private let configuration: WorkspaceRemoteConfiguration private let remotePath: String private let onUnexpectedTermination: (String) -> Void @@ -854,6 +856,12 @@ private final class WorkspaceRemoteDaemonRPCClient { } stdoutBuffer.append(data) + if stdoutBuffer.count > Self.maxStdoutBufferBytes { + stdoutBuffer.removeAll(keepingCapacity: false) + signalPendingFailureLocked("daemon transport stdout exceeded \(Self.maxStdoutBufferBytes) bytes without message framing") + process?.terminate() + return + } while let newlineIndex = stdoutBuffer.firstIndex(of: 0x0A) { var lineData = Data(stdoutBuffer[.. Self.maxHandshakeBytes { + self.close(reason: "proxy handshake exceeded \(Self.maxHandshakeBytes) bytes") + return + } self.handshakeBuffer.append(data) self.processHandshakeBuffer() } else { @@ -1315,7 +1329,7 @@ private final class WorkspaceRemoteDaemonProxyTunnel { return } if !pendingPayload.isEmpty { - self.forwardToRemote(pendingPayload) + self.forwardToRemote(pendingPayload, allowAfterEOF: true) } self.scheduleRemoteReadLoop() }) @@ -1324,9 +1338,9 @@ private final class WorkspaceRemoteDaemonProxyTunnel { } } - private func forwardToRemote(_ data: Data) { + private func forwardToRemote(_ data: Data, allowAfterEOF: Bool = false) { guard !isClosed else { return } - guard !localInputEOF else { return } + guard !localInputEOF || allowAfterEOF else { return } guard let streamID else { return } do { try rpcClient.writeStream(streamID: streamID, data: data) @@ -1852,6 +1866,7 @@ private final class WorkspaceRemoteSessionController { } private let queue = DispatchQueue(label: "com.cmux.remote-ssh.\(UUID().uuidString)", qos: .utility) + private let queueKey = DispatchSpecificKey() private weak var workspace: Workspace? private let configuration: WorkspaceRemoteConfiguration @@ -1867,6 +1882,7 @@ private final class WorkspaceRemoteSessionController { init(workspace: Workspace, configuration: WorkspaceRemoteConfiguration) { self.workspace = workspace self.configuration = configuration + queue.setSpecific(key: queueKey, value: ()) } func start() { @@ -1878,8 +1894,12 @@ private final class WorkspaceRemoteSessionController { } func stop() { - queue.async { [weak self] in - self?.stopAllLocked() + if DispatchQueue.getSpecific(key: queueKey) != nil { + stopAllLocked() + return + } + queue.sync { + stopAllLocked() } } @@ -2255,9 +2275,13 @@ private final class WorkspaceRemoteSessionController { } private func buildLocalDaemonBinary(goOS: String, goArch: String, version: String) throws -> URL { + if let bundledBinary = Self.findBundledDaemonBinary(goOS: goOS, goArch: goArch, version: version) { + return bundledBinary + } + guard let repoRoot = Self.findRepoRoot() else { throw NSError(domain: "cmux.remote.daemon", code: 20, userInfo: [ - NSLocalizedDescriptionKey: "cannot locate cmux repo root for daemon build", + NSLocalizedDescriptionKey: "cannot locate cmux repo root for daemon build and no bundled cmuxd-remote binary was found", ]) } let daemonRoot = repoRoot.appendingPathComponent("daemon/remote", isDirectory: true) @@ -2269,7 +2293,7 @@ private final class WorkspaceRemoteSessionController { } guard let goBinary = Self.which("go") else { throw NSError(domain: "cmux.remote.daemon", code: 22, userInfo: [ - NSLocalizedDescriptionKey: "go is required to build cmuxd-remote", + NSLocalizedDescriptionKey: "go is required to build cmuxd-remote when no bundled binary is available", ]) } @@ -2307,6 +2331,27 @@ private final class WorkspaceRemoteSessionController { return output } + private static func findBundledDaemonBinary(goOS: String, goArch: String, version: String) -> URL? { + let fm = FileManager.default + var candidates: [URL] = [] + let env = ProcessInfo.processInfo.environment + if let explicit = env["CMUX_REMOTE_DAEMON_BINARY"]?.trimmingCharacters(in: .whitespacesAndNewlines), + !explicit.isEmpty { + candidates.append(URL(fileURLWithPath: explicit, isDirectory: false)) + } + if let resourceRoot = Bundle.main.resourceURL { + candidates.append(resourceRoot.appendingPathComponent("bin/cmuxd-remote-\(goOS)-\(goArch)", isDirectory: false)) + candidates.append(resourceRoot.appendingPathComponent("bin/cmuxd-remote", isDirectory: false)) + candidates.append(resourceRoot.appendingPathComponent("cmuxd-remote/\(goOS)-\(goArch)/cmuxd-remote", isDirectory: false)) + candidates.append(resourceRoot.appendingPathComponent("cmuxd-remote/\(version)/\(goOS)-\(goArch)/cmuxd-remote", isDirectory: false)) + } + + for candidate in candidates.map(\.standardizedFileURL) where fm.isExecutableFile(atPath: candidate.path) { + return candidate + } + return nil + } + private func uploadRemoteDaemonBinaryLocked(localBinary: URL, remotePath: String) throws { let remoteDirectory = (remotePath as NSString).deletingLastPathComponent let remoteTempPath = "\(remotePath).tmp-\(UUID().uuidString.prefix(8))" diff --git a/daemon/remote/cmd/cmuxd-remote/main.go b/daemon/remote/cmd/cmuxd-remote/main.go index 727039d2..d5eee852 100644 --- a/daemon/remote/cmd/cmuxd-remote/main.go +++ b/daemon/remote/cmd/cmuxd-remote/main.go @@ -2,8 +2,10 @@ package main import ( "bufio" + "bytes" "encoding/base64" "encoding/json" + "errors" "flag" "fmt" "io" @@ -57,6 +59,8 @@ type sessionState struct { lastKnownRows int } +const maxRPCFrameBytes = 4 * 1024 * 1024 + func main() { os.Exit(run(os.Args[1:], os.Stdin, os.Stdout, os.Stderr)) } @@ -108,13 +112,32 @@ func runStdioServer(stdin io.Reader, stdout io.Writer) error { } defer server.closeAll() - scanner := bufio.NewScanner(stdin) - scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024) + reader := bufio.NewReaderSize(stdin, 64*1024) writer := bufio.NewWriter(stdout) defer writer.Flush() - for scanner.Scan() { - line := scanner.Bytes() + for { + line, oversized, readErr := readRPCFrame(reader, maxRPCFrameBytes) + if readErr != nil { + if errors.Is(readErr, io.EOF) { + return nil + } + return readErr + } + if oversized { + if err := writeResponse(writer, rpcResponse{ + OK: false, + Error: &rpcError{ + Code: "invalid_request", + Message: "request frame exceeds maximum size", + }, + }); err != nil { + return err + } + continue + } + line = bytes.TrimSuffix(line, []byte{'\n'}) + line = bytes.TrimSuffix(line, []byte{'\r'}) if len(line) == 0 { continue } @@ -138,11 +161,51 @@ func runStdioServer(stdin io.Reader, stdout io.Writer) error { return err } } +} - if err := scanner.Err(); err != nil { +func readRPCFrame(reader *bufio.Reader, maxBytes int) ([]byte, bool, error) { + frame := make([]byte, 0, 1024) + for { + chunk, err := reader.ReadSlice('\n') + if len(chunk) > 0 { + if len(frame)+len(chunk) > maxBytes { + if errors.Is(err, bufio.ErrBufferFull) { + if drainErr := discardUntilNewline(reader); drainErr != nil && !errors.Is(drainErr, io.EOF) { + return nil, false, drainErr + } + } + return nil, true, nil + } + frame = append(frame, chunk...) + } + + if err == nil { + return frame, false, nil + } + if errors.Is(err, bufio.ErrBufferFull) { + continue + } + if errors.Is(err, io.EOF) { + if len(frame) == 0 { + return nil, false, io.EOF + } + return frame, false, nil + } + return nil, false, err + } +} + +func discardUntilNewline(reader *bufio.Reader) error { + for { + _, err := reader.ReadSlice('\n') + if err == nil || errors.Is(err, io.EOF) { + return err + } + if errors.Is(err, bufio.ErrBufferFull) { + continue + } return err } - return nil } func writeResponse(w *bufio.Writer, resp rpcResponse) error { @@ -376,9 +439,37 @@ func (s *rpcServer) handleProxyWrite(req rpcRequest) rpcResponse { } } + timeoutMs := 8000 + if parsed, hasTimeout := getIntParam(req.Params, "timeout_ms"); hasTimeout { + timeoutMs = parsed + } + if timeoutMs > 0 { + if err := conn.SetWriteDeadline(time.Now().Add(time.Duration(timeoutMs) * time.Millisecond)); err != nil { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "stream_error", + Message: err.Error(), + }, + } + } + defer conn.SetWriteDeadline(time.Time{}) + } + total := 0 for total < len(payload) { written, writeErr := conn.Write(payload[total:]) + if written == 0 && writeErr == nil { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "stream_error", + Message: "write made no progress", + }, + } + } total += written if writeErr != nil { return rpcResponse{ diff --git a/daemon/remote/cmd/cmuxd-remote/main_test.go b/daemon/remote/cmd/cmuxd-remote/main_test.go index 663fd234..349be447 100644 --- a/daemon/remote/cmd/cmuxd-remote/main_test.go +++ b/daemon/remote/cmd/cmuxd-remote/main_test.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/base64" "encoding/json" + "io" "net" "strconv" "strings" @@ -156,12 +157,11 @@ func TestProxyStreamRoundTrip(t *testing.T) { } defer conn.Close() - buffer := make([]byte, 8) - n, readErr := conn.Read(buffer) - if readErr != nil { + buffer := make([]byte, 4) + if _, readErr := io.ReadFull(conn, buffer); readErr != nil { return } - if string(buffer[:n]) != "ping" { + if string(buffer) != "ping" { return } _, _ = conn.Write([]byte("pong")) @@ -246,6 +246,41 @@ func TestProxyStreamRoundTrip(t *testing.T) { } } +func TestRunStdioOversizedFrameContinuesServing(t *testing.T) { + oversized := `{"id":1,"method":"ping","params":{"blob":"` + strings.Repeat("a", maxRPCFrameBytes) + `"}}` + input := strings.NewReader(oversized + "\n" + `{"id":2,"method":"ping","params":{}}` + "\n") + var out bytes.Buffer + code := run([]string{"serve", "--stdio"}, input, &out, &bytes.Buffer{}) + if code != 0 { + t.Fatalf("run serve exit code = %d, want 0", code) + } + + lines := strings.Split(strings.TrimSpace(out.String()), "\n") + if len(lines) != 2 { + t.Fatalf("got %d response lines, want 2: %q", len(lines), out.String()) + } + + var first map[string]any + if err := json.Unmarshal([]byte(lines[0]), &first); err != nil { + t.Fatalf("failed to decode first response: %v", err) + } + if ok, _ := first["ok"].(bool); ok { + t.Fatalf("first response should be oversized-frame error: %v", first) + } + firstError, _ := first["error"].(map[string]any) + if got := firstError["code"]; got != "invalid_request" { + t.Fatalf("oversized frame should return invalid_request; got=%v payload=%v", got, first) + } + + var second map[string]any + if err := json.Unmarshal([]byte(lines[1]), &second); err != nil { + t.Fatalf("failed to decode second response: %v", err) + } + if ok, _ := second["ok"].(bool); !ok { + t.Fatalf("second response should still be handled after oversized frame: %v", second) + } +} + func TestProxyOpenInvalidParams(t *testing.T) { server := &rpcServer{ nextStreamID: 1, diff --git a/scripts/reload.sh b/scripts/reload.sh index 4492c954..f862610d 100755 --- a/scripts/reload.sh +++ b/scripts/reload.sh @@ -22,7 +22,8 @@ write_dev_cli_shim() { set -euo pipefail CLI_PATH_FILE="/tmp/cmux-last-cli-path" -if [[ -r "\$CLI_PATH_FILE" ]]; then +CLI_PATH_OWNER="\$(stat -f '%u' "\$CLI_PATH_FILE" 2>/dev/null || stat -c '%u' "\$CLI_PATH_FILE" 2>/dev/null || echo -1)" +if [[ -r "\$CLI_PATH_FILE" ]] && [[ ! -L "\$CLI_PATH_FILE" ]] && [[ "\$CLI_PATH_OWNER" == "\$(id -u)" ]]; then CLI_PATH="\$(cat "\$CLI_PATH_FILE")" if [[ -x "\$CLI_PATH" ]]; then exec "\$CLI_PATH" "\$@" @@ -36,7 +37,7 @@ fi echo "error: no reload-selected dev cmux CLI found. Run ./scripts/reload.sh --tag first." >&2 exit 1 EOF - chmod +x "$target" || true + chmod +x "$target" } select_cmux_shim_target() { @@ -351,7 +352,7 @@ fi CLI_PATH="$(dirname "$APP_PATH")/cmux" if [[ -x "$CLI_PATH" ]]; then - echo "$CLI_PATH" > /tmp/cmux-last-cli-path || true + (umask 077; printf '%s\n' "$CLI_PATH" > /tmp/cmux-last-cli-path) || true ln -sfn "$CLI_PATH" /tmp/cmux-cli || true # Stable shim that always follows the last reload-selected dev CLI. diff --git a/tests/fixtures/ssh-remote/run.sh b/tests/fixtures/ssh-remote/run.sh index 59251875..9089554f 100644 --- a/tests/fixtures/ssh-remote/run.sh +++ b/tests/fixtures/ssh-remote/run.sh @@ -19,6 +19,20 @@ chmod 700 /root/.ssh chmod 600 /root/.ssh/authorized_keys python3 -m http.server "$REMOTE_HTTP_PORT" --bind 127.0.0.1 --directory /srv/www >/tmp/http.log 2>&1 & +HTTP_PID=$! python3 /usr/local/bin/ws_echo.py --host 127.0.0.1 --port "$REMOTE_WS_PORT" >/tmp/ws.log 2>&1 & +WS_PID=$! + +sleep 0.2 +if ! kill -0 "$HTTP_PID" 2>/dev/null; then + echo "HTTP fixture failed to start (see /tmp/http.log)" >&2 + cat /tmp/http.log >&2 || true + exit 1 +fi +if ! kill -0 "$WS_PID" 2>/dev/null; then + echo "WebSocket fixture failed to start (see /tmp/ws.log)" >&2 + cat /tmp/ws.log >&2 || true + exit 1 +fi exec /usr/sbin/sshd -D -e diff --git a/tests_v2/test_ssh_remote_docker_forwarding.py b/tests_v2/test_ssh_remote_docker_forwarding.py index 7862c15e..c8b954ea 100644 --- a/tests_v2/test_ssh_remote_docker_forwarding.py +++ b/tests_v2/test_ssh_remote_docker_forwarding.py @@ -611,15 +611,15 @@ def main() -> int: try: client.close_workspace(workspace_id_shared) + workspace_id_shared = "" except Exception: pass - workspace_id_shared = "" try: client.close_workspace(workspace_id) + workspace_id = "" except Exception: pass - workspace_id = "" print( "PASS: docker SSH proxy endpoint is reachable, handles HTTP + WebSocket egress over SOCKS and CONNECT through remote host, and is shared across identical transports; " diff --git a/tests_v2/test_ssh_remote_shell_integration.py b/tests_v2/test_ssh_remote_shell_integration.py index 55adca6b..248ab110 100755 --- a/tests_v2/test_ssh_remote_shell_integration.py +++ b/tests_v2/test_ssh_remote_shell_integration.py @@ -310,9 +310,9 @@ def main() -> int: try: client.close_workspace(workspace_id) + workspace_id = "" except Exception: pass - workspace_id = "" print( "PASS: cmux ssh enables Ghostty shell integration niceties "