From 32f20c2db25e35df00402f11073456dd4b6b7bac Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Mon, 11 May 2026 20:14:35 -0700 Subject: [PATCH 1/4] test: cover ssh reconnect lifecycle reporting --- .../SSHStartupSignalLifecycleTests.swift | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) diff --git a/cmuxTests/SSHStartupSignalLifecycleTests.swift b/cmuxTests/SSHStartupSignalLifecycleTests.swift index 808d98bfefa4..41bd37fc01e5 100644 --- a/cmuxTests/SSHStartupSignalLifecycleTests.swift +++ b/cmuxTests/SSHStartupSignalLifecycleTests.swift @@ -124,6 +124,105 @@ extension CLINotifyProcessIntegrationRegressionTests { XCTAssertEqual(sessionEndCalls.count, 1, recordedCalls) } + func testSSHStartupReportsReconnectingAndConnectedLifecycleAroundTransientExit() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cmux-ssh-reconnect-state-\(UUID().uuidString)", isDirectory: true) + let fakeCLI = root.appendingPathComponent("cmux") + let fakeSSH = root.appendingPathComponent("ssh") + let logFile = root.appendingPathComponent("ssh-lifecycle.log") + let attemptFile = root.appendingPathComponent("ssh-attempts.txt") + + try fileManager.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: root) } + + try writeShellFile(at: fakeCLI, lines: [ + "#!/bin/sh", + "printf '%s\\n' \"$*\" >> \"${CMUX_TEST_LIFECYCLE_LOG}\"", + ]) + try writeShellFile(at: fakeSSH, lines: [ + "#!/bin/sh", + "count=0", + "if [ -r \"${CMUX_TEST_ATTEMPT_FILE}\" ]; then count=$(cat \"${CMUX_TEST_ATTEMPT_FILE}\"); fi", + "count=$((count + 1))", + "printf '%s\\n' \"$count\" > \"${CMUX_TEST_ATTEMPT_FILE}\"", + "if [ \"$count\" -eq 1 ]; then exit 255; fi", + "cmux_local_command=", + "cmux_previous=", + "for cmux_arg in \"$@\"; do", + " if [ \"$cmux_previous\" = \"-o\" ]; then", + " case \"$cmux_arg\" in LocalCommand=*) cmux_local_command=${cmux_arg#LocalCommand=} ;; esac", + " cmux_previous=", + " continue", + " fi", + " if [ \"$cmux_arg\" = \"-o\" ]; then cmux_previous=\"-o\"; fi", + "done", + "if [ -n \"$cmux_local_command\" ]; then", + " cmux_local_command=$(printf '%s' \"$cmux_local_command\" | sed 's/%%/%/g')", + " /bin/sh -c \"$cmux_local_command\"", + "fi", + "exit 0", + ]) + try fileManager.setAttributes([.posixPermissions: 0o700], ofItemAtPath: fakeCLI.path) + try fileManager.setAttributes([.posixPermissions: 0o700], ofItemAtPath: fakeSSH.path) + + let startupCommand = try generatedVMSSHInitialStartupCommand() + var environment = ProcessInfo.processInfo.environment + environment["PATH"] = "\(root.path):\(environment["PATH"] ?? "/usr/bin:/bin")" + environment["CMUX_BUNDLED_CLI_PATH"] = fakeCLI.path + environment["CMUX_SOCKET_PATH"] = "/tmp/cmux-debug-test.sock" + environment["CMUX_WORKSPACE_ID"] = "11111111-1111-1111-1111-111111111111" + environment["CMUX_SURFACE_ID"] = "22222222-2222-2222-2222-222222222222" + environment["CMUX_TEST_LIFECYCLE_LOG"] = logFile.path + environment["CMUX_TEST_ATTEMPT_FILE"] = attemptFile.path + environment["CMUX_SSH_RECONNECT_DELAY_SECONDS"] = "0" + environment["CMUX_SSH_RECONNECT_LIMIT"] = "2" + + let result = runProcess( + executablePath: "/bin/sh", + arguments: ["-c", startupCommand], + environment: environment, + timeout: 5 + ) + + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + XCTAssertEqual( + (try? String(contentsOf: attemptFile, encoding: .utf8))?.trimmingCharacters(in: .whitespacesAndNewlines), + "2" + ) + + let recordedCalls = ((try? String(contentsOf: logFile, encoding: .utf8)) ?? "") + .split(separator: "\n") + .map(String.init) + let reconnectingIndex = recordedCalls.firstIndex { $0.contains("ssh-session-reconnecting") } + let connectedIndex = recordedCalls.firstIndex { $0.contains("ssh-session-connected") } + let sessionEndIndex = recordedCalls.firstIndex { $0.contains("ssh-session-end") } + + XCTAssertNotNil( + reconnectingIndex, + "Transient SSH drops must mark the pane as reconnecting; recorded: \(recordedCalls)" + ) + XCTAssertNotNil( + connectedIndex, + "Successful SSH redial must mark the pane as connected; recorded: \(recordedCalls)" + ) + XCTAssertNotNil( + sessionEndIndex, + "The terminal still reports final session end on wrapper exit; recorded: \(recordedCalls)" + ) + if let reconnectingIndex, let connectedIndex { + XCTAssertLessThan(reconnectingIndex, connectedIndex, recordedCalls.joined(separator: "\n")) + } + if let connectedIndex, let sessionEndIndex { + XCTAssertLessThan(connectedIndex, sessionEndIndex, recordedCalls.joined(separator: "\n")) + } + if let reconnectingIndex { + XCTAssertTrue(recordedCalls[reconnectingIndex].contains("--attempt 1"), recordedCalls.joined(separator: "\n")) + XCTAssertTrue(recordedCalls[reconnectingIndex].contains("--exit-status 255"), recordedCalls.joined(separator: "\n")) + } + } + func testSSHStartupStopsAtConfiguredReconnectLimit() throws { let fileManager = FileManager.default let root = fileManager.temporaryDirectory From 0cf42fae66c6d2a2a423d24b5996006e73c52f78 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Mon, 11 May 2026 21:25:34 -0700 Subject: [PATCH 2/4] fix: surface ssh reconnect lifecycle --- CLI/cmux.swift | 172 ++++++++++++++++-- Resources/Localizable.xcstrings | 34 ++++ Sources/TerminalController.swift | 109 +++++++++-- Sources/Workspace.swift | 55 ++++++ .../WorkspaceRemoteConnectionTests.swift | 39 ++++ docs/cli-contract.md | 2 + 6 files changed, 378 insertions(+), 33 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index fee11771fb25..ee345ca3cc31 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -2788,6 +2788,10 @@ struct CMUXCLI { try runSSH(commandArgs: commandArgs, client: client, jsonOutput: jsonOutput, idFormat: idFormat) case "ssh-session-end": try runSSHSessionEnd(commandArgs: commandArgs, client: client) + case "ssh-session-reconnecting": + try runSSHSessionReconnecting(commandArgs: commandArgs, client: client) + case "ssh-session-connected": + try runSSHSessionConnected(commandArgs: commandArgs, client: client) case "vm-pty-attach": try runVMPtyAttach(commandArgs: commandArgs, client: client) case "vm-ssh-attach": @@ -4806,6 +4810,7 @@ struct CMUXCLI { sshOptions.sshOptions, remoteRelayPort: sshOptions.remoteRelayPort ) + let canInstallLocalCommand = canInstallSSHLocalCommand(in: remoteSSHOptions) let initialSSHCommand = buildSSHCommandText(sshOptions) // For VM workspaces (Freestyle), skip the interactive bootstrap script: the russh // gateway forwards shell-request PTYs but stalls on exec-channel I/O, and the bootstrap @@ -4833,12 +4838,21 @@ struct CMUXCLI { localCLIPath: resolvedExecutableURL()?.path, foregroundAuthToken: deferredRemoteReconnectToken ) - let sshConnectionTimingCommandScript = sshConnectionTimingLocalCommandScript( - target: sshOptions.displayDestination, - relayPort: sshOptions.remoteRelayPort - ) + let sshSessionConnectedCommandScript = canInstallLocalCommand + ? sshSessionConnectedLocalCommandScript( + remoteRelayPort: sshOptions.remoteRelayPort, + localCLIPath: resolvedExecutableURL()?.path + ) + : nil + let sshConnectionTimingCommandScript = canInstallLocalCommand + ? sshConnectionTimingLocalCommandScript( + target: sshOptions.displayDestination, + relayPort: sshOptions.remoteRelayPort + ) + : nil let combinedLocalCommandScript = combinedLocalShellScript([ deferredRemoteReconnectCommandScript, + sshSessionConnectedCommandScript, sshConnectionTimingCommandScript, ]) let configuredForegroundAuthToken = deferredRemoteReconnectCommandScript == nil ? nil : deferredRemoteReconnectToken @@ -5760,6 +5774,7 @@ struct CMUXCLI { ? "" : "export GHOSTTY_SHELL_FEATURES=\(shellQuote(trimmedFeatures))" let lifecycleCleanup = buildSSHSessionEndShellCommand(remoteRelayPort: remoteRelayPort) + let lifecycleReconnecting = buildSSHSessionReconnectingShellCommand(remoteRelayPort: remoteRelayPort) var scriptLines: [String] = [] if !shellFeaturesBootstrap.isEmpty { scriptLines.append(shellFeaturesBootstrap) @@ -5804,6 +5819,7 @@ struct CMUXCLI { " if [ \"$cmux_ssh_status\" -ne 255 ]; then break; fi", " if [ \"$cmux_ssh_retry\" -ge \"$cmux_ssh_reconnect_limit\" ]; then break; fi", " cmux_ssh_retry=$((cmux_ssh_retry + 1))", + " \(lifecycleReconnecting)", " cmux_ssh_note '\\n\\033[33m[cmux] ssh exited with status %s; reconnecting (attempt %s/%s).\\033[0m\\n\\033[2m[cmux] close this pane or press Ctrl-C to stop reconnecting.\\033[0m\\n' \"$cmux_ssh_status\" \"$cmux_ssh_retry\" \"$cmux_ssh_reconnect_limit\"", " if [ \"$cmux_ssh_reconnect_delay\" -gt 0 ]; then sleep \"$cmux_ssh_reconnect_delay\"; fi", " if [ -n \"${CMUX_SSH_PENDING_SIGNAL:-}\" ]; then cmux_ssh_session_end; trap - EXIT HUP INT TERM; exit \"$CMUX_SSH_PENDING_SIGNAL\"; fi", @@ -5859,17 +5875,41 @@ struct CMUXCLI { } private func buildSSHSessionEndShellCommand(remoteRelayPort: Int) -> String { - [ + buildSSHSessionLifecycleShellCommand( + subcommand: "ssh-session-end", + remoteRelayPort: remoteRelayPort + ) + } + + private func buildSSHSessionReconnectingShellCommand(remoteRelayPort: Int) -> String { + buildSSHSessionLifecycleShellCommand( + subcommand: "ssh-session-reconnecting", + remoteRelayPort: remoteRelayPort, + extraArguments: [ + "--attempt \"$cmux_ssh_retry\"", + "--limit \"$cmux_ssh_reconnect_limit\"", + "--exit-status \"$cmux_ssh_status\"", + ] + ) + } + + private func buildSSHSessionLifecycleShellCommand( + subcommand: String, + remoteRelayPort: Int, + extraArguments: [String] = [] + ) -> String { + let suffix = extraArguments.isEmpty ? "" : " " + extraArguments.joined(separator: " ") + return [ "if [ -n \"${CMUX_BUNDLED_CLI_PATH:-}\" ]", "&& [ -x \"${CMUX_BUNDLED_CLI_PATH}\" ]", "&& [ -n \"${CMUX_SOCKET_PATH:-}\" ]", "&& [ -n \"${CMUX_WORKSPACE_ID:-}\" ]", "&& [ -n \"${CMUX_SURFACE_ID:-}\" ]; then", - "\"${CMUX_BUNDLED_CLI_PATH}\" --socket \"${CMUX_SOCKET_PATH}\" ssh-session-end --relay-port \(remoteRelayPort) --workspace \"${CMUX_WORKSPACE_ID}\" --surface \"${CMUX_SURFACE_ID}\" >/dev/null 2>&1 || true;", + "\"${CMUX_BUNDLED_CLI_PATH}\" --socket \"${CMUX_SOCKET_PATH}\" \(subcommand) --relay-port \(remoteRelayPort) --workspace \"${CMUX_WORKSPACE_ID}\" --surface \"${CMUX_SURFACE_ID}\"\(suffix) >/dev/null 2>&1 || true;", "elif command -v cmux >/dev/null 2>&1", "&& [ -n \"${CMUX_WORKSPACE_ID:-}\" ]", "&& [ -n \"${CMUX_SURFACE_ID:-}\" ]; then", - "cmux ssh-session-end --relay-port \(remoteRelayPort) --workspace \"${CMUX_WORKSPACE_ID}\" --surface \"${CMUX_SURFACE_ID}\" >/dev/null 2>&1 || true;", + "cmux \(subcommand) --relay-port \(remoteRelayPort) --workspace \"${CMUX_WORKSPACE_ID}\" --surface \"${CMUX_SURFACE_ID}\"\(suffix) >/dev/null 2>&1 || true;", "fi", ].joined(separator: " ") } @@ -6564,28 +6604,103 @@ struct CMUXCLI { } private func runSSHSessionEnd(commandArgs: [String], client: SocketClient) throws { + let identity = try parseSSHSessionLifecycleIdentity( + commandArgs: commandArgs, + client: client, + commandName: "ssh-session-end" + ) + _ = try client.sendV2(method: "workspace.remote.terminal_session_end", params: [ + "workspace_id": identity.workspaceId, + "surface_id": identity.surfaceId, + "relay_port": identity.relayPort, + ]) + } + + private func runSSHSessionReconnecting(commandArgs: [String], client: SocketClient) throws { + let identity = try parseSSHSessionLifecycleIdentity( + commandArgs: commandArgs, + client: client, + commandName: "ssh-session-reconnecting" + ) + guard let attempt = positiveIntOption(commandArgs, name: "--attempt") else { + throw CLIError(message: "ssh-session-reconnecting requires --attempt ") + } + guard let limit = positiveIntOption(commandArgs, name: "--limit") else { + throw CLIError(message: "ssh-session-reconnecting requires --limit ") + } + guard let exitStatus = nonNegativeIntOption(commandArgs, name: "--exit-status") else { + throw CLIError(message: "ssh-session-reconnecting requires --exit-status ") + } + _ = try client.sendV2(method: "workspace.remote.terminal_reconnecting", params: [ + "workspace_id": identity.workspaceId, + "surface_id": identity.surfaceId, + "relay_port": identity.relayPort, + "attempt": attempt, + "limit": limit, + "exit_status": exitStatus, + ]) + } + + private func runSSHSessionConnected(commandArgs: [String], client: SocketClient) throws { + let identity = try parseSSHSessionLifecycleIdentity( + commandArgs: commandArgs, + client: client, + commandName: "ssh-session-connected" + ) + _ = try client.sendV2(method: "workspace.remote.terminal_connected", params: [ + "workspace_id": identity.workspaceId, + "surface_id": identity.surfaceId, + "relay_port": identity.relayPort, + ]) + } + + private struct SSHSessionLifecycleIdentity { + let relayPort: Int + let workspaceId: String + let surfaceId: String + } + + private func parseSSHSessionLifecycleIdentity( + commandArgs: [String], + client: SocketClient, + commandName: String + ) throws -> SSHSessionLifecycleIdentity { guard let relayPortRaw = optionValue(commandArgs, name: "--relay-port"), let relayPort = Int(relayPortRaw), relayPort > 0 else { - throw CLIError(message: "ssh-session-end requires --relay-port ") + throw CLIError(message: "\(commandName) requires --relay-port ") } let workspaceRaw = optionValue(commandArgs, name: "--workspace") ?? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] let surfaceRaw = optionValue(commandArgs, name: "--surface") ?? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] guard let workspaceRaw, let workspaceId = try normalizeWorkspaceHandle(workspaceRaw, client: client), !workspaceId.isEmpty else { - throw CLIError(message: "ssh-session-end requires --workspace or CMUX_WORKSPACE_ID") + throw CLIError(message: "\(commandName) requires --workspace or CMUX_WORKSPACE_ID") } guard let surfaceRaw, let surfaceId = try normalizeSurfaceHandle(surfaceRaw, client: client, workspaceHandle: workspaceId), !surfaceId.isEmpty else { - throw CLIError(message: "ssh-session-end requires --surface or CMUX_SURFACE_ID") + throw CLIError(message: "\(commandName) requires --surface or CMUX_SURFACE_ID") } - _ = try client.sendV2(method: "workspace.remote.terminal_session_end", params: [ - "workspace_id": workspaceId, - "surface_id": surfaceId, - "relay_port": relayPort, - ]) + return SSHSessionLifecycleIdentity(relayPort: relayPort, workspaceId: workspaceId, surfaceId: surfaceId) + } + + private func positiveIntOption(_ commandArgs: [String], name: String) -> Int? { + guard let raw = optionValue(commandArgs, name: name), + let value = Int(raw), + value > 0 else { + return nil + } + return value + } + + private func nonNegativeIntOption(_ commandArgs: [String], name: String) -> Int? { + guard let raw = optionValue(commandArgs, name: name), + let value = Int(raw), + value >= 0 else { + return nil + } + return value } private func runRemoteDaemonStatus(commandArgs: [String], jsonOutput: Bool) throws { @@ -6794,6 +6909,23 @@ struct CMUXCLI { ].joined(separator: " ") } + private func sshSessionConnectedLocalCommandScript(remoteRelayPort: Int, localCLIPath: String?) -> String { + let preferredCLIPath = localCLIPath?.trimmingCharacters(in: .whitespacesAndNewlines) + let bundledCLIPathAssignment = preferredCLIPath.map { + "cmux_connected_cli=\(shellQuote($0));" + } ?? "cmux_connected_cli=\"\";" + return [ + "cmux_connected_cli=\"${CMUX_BUNDLED_CLI_PATH:-}\";", + "if [ -z \"$cmux_connected_cli\" ] || [ ! -x \"$cmux_connected_cli\" ]; then \(bundledCLIPathAssignment) fi;", + "cmux_connected_socket=\"${CMUX_SOCKET_PATH:-${CMUX_SOCKET:-}}\";", + "if [ ! -x \"$cmux_connected_cli\" ]; then cmux_connected_cli=\"$(command -v cmux 2>/dev/null || true)\"; fi;", + "if [ -n \"${CMUX_WORKSPACE_ID:-}\" ] && [ -n \"${CMUX_SURFACE_ID:-}\" ] && [ -n \"$cmux_connected_socket\" ] && [ -n \"$cmux_connected_cli\" ] && [ -x \"$cmux_connected_cli\" ]; then", + "\"$cmux_connected_cli\" --socket \"$cmux_connected_socket\" ssh-session-connected --relay-port \(remoteRelayPort) --workspace \"$CMUX_WORKSPACE_ID\" --surface \"$CMUX_SURFACE_ID\" >/dev/null 2>&1 || true;", + "fi;", + "unset cmux_connected_socket cmux_connected_cli;", + ].joined(separator: " ") + } + private func sshConnectionTimingLocalCommandScript(target: String, relayPort: Int) -> String { let escapedTarget = target .replacingOccurrences(of: "\\", with: "\\\\") @@ -6810,10 +6942,7 @@ struct CMUXCLI { } private func shouldDeferRemoteReconnect(in options: [String]) -> Bool { - guard !hasSSHOptionKey(options, key: "LocalCommand"), - !hasSSHOptionKey(options, key: "PermitLocalCommand") else { - return false - } + guard canInstallSSHLocalCommand(in: options) else { return false } guard let controlPath = sshOptionValue(named: "ControlPath", in: options)? .trimmingCharacters(in: .whitespacesAndNewlines), @@ -6833,6 +6962,11 @@ struct CMUXCLI { } } + private func canInstallSSHLocalCommand(in options: [String]) -> Bool { + !hasSSHOptionKey(options, key: "LocalCommand") && + !hasSSHOptionKey(options, key: "PermitLocalCommand") + } + private func defaultSSHControlPathTemplate(remoteRelayPort: Int? = nil) -> String { if let remoteRelayPort, remoteRelayPort > 0 { return "/tmp/cmux-ssh-\(getuid())-\(remoteRelayPort)-%C" diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index f51105c327ad..a3fe611a3607 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -110580,6 +110580,40 @@ } } }, + "remote.state.reconnecting.terminal": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reconnecting to %@ (attempt %lld/%lld after SSH exit %lld)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ に再接続中 (試行 %lld/%lld、SSH 終了 %lld 後)" + } + } + } + }, + "remote.state.targetFallback": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "remote host" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リモートホスト" + } + } + } + }, "remote.disconnectBanner.sessionEnded": { "extractionState": "manual", "localizations": { diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index ae34bcf44827..c04e3ce3ff1c 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -2505,6 +2505,10 @@ class TerminalController { return v2Result(id: id, self.v2WorkspaceRemoteDisconnect(params: params)) case "workspace.remote.status": return v2Result(id: id, self.v2WorkspaceRemoteStatus(params: params)) + case "workspace.remote.terminal_reconnecting": + return v2Result(id: id, self.v2WorkspaceRemoteTerminalReconnecting(params: params)) + case "workspace.remote.terminal_connected": + return v2Result(id: id, self.v2WorkspaceRemoteTerminalConnected(params: params)) case "workspace.remote.terminal_session_end": return v2Result(id: id, self.v2WorkspaceRemoteTerminalSessionEnd(params: params)) case "session.restore_previous": @@ -2913,6 +2917,8 @@ class TerminalController { "workspace.remote.reconnect", "workspace.remote.disconnect", "workspace.remote.status", + "workspace.remote.terminal_reconnecting", + "workspace.remote.terminal_connected", "workspace.remote.terminal_session_end", "session.restore_previous", "settings.open", @@ -5033,42 +5039,117 @@ class TerminalController { return result } + private func v2WorkspaceRemoteTerminalReconnecting(params: [String: Any]) -> V2CallResult { + guard let identity = v2WorkspaceRemoteTerminalLifecycleIdentity(params: params) else { + return .err( + code: "invalid_params", + message: "Missing or invalid workspace_id, surface_id, or relay_port", + data: nil + ) + } + guard let attempt = v2StrictInt(params, "attempt"), + attempt > 0 else { + return .err(code: "invalid_params", message: "Missing or invalid attempt", data: nil) + } + guard let limit = v2StrictInt(params, "limit"), + limit > 0 else { + return .err(code: "invalid_params", message: "Missing or invalid limit", data: nil) + } + guard let exitStatus = v2StrictInt(params, "exit_status"), + exitStatus >= 0 else { + return .err(code: "invalid_params", message: "Missing or invalid exit_status", data: nil) + } + + return v2ApplyWorkspaceRemoteTerminalLifecycle(identity: identity) { workspace in + workspace.markRemoteTerminalSessionReconnecting( + surfaceId: identity.surfaceId, + relayPort: identity.relayPort, + attempt: attempt, + limit: limit, + exitStatus: exitStatus + ) + } + } + + private func v2WorkspaceRemoteTerminalConnected(params: [String: Any]) -> V2CallResult { + guard let identity = v2WorkspaceRemoteTerminalLifecycleIdentity(params: params) else { + return .err( + code: "invalid_params", + message: "Missing or invalid workspace_id, surface_id, or relay_port", + data: nil + ) + } + + return v2ApplyWorkspaceRemoteTerminalLifecycle(identity: identity) { workspace in + workspace.markRemoteTerminalSessionConnected( + surfaceId: identity.surfaceId, + relayPort: identity.relayPort + ) + } + } + private func v2WorkspaceRemoteTerminalSessionEnd(params: [String: Any]) -> V2CallResult { + guard let identity = v2WorkspaceRemoteTerminalLifecycleIdentity(params: params) else { + return .err( + code: "invalid_params", + message: "Missing or invalid workspace_id, surface_id, or relay_port", + data: nil + ) + } + + return v2ApplyWorkspaceRemoteTerminalLifecycle(identity: identity) { workspace in + workspace.markRemoteTerminalSessionEnded(surfaceId: identity.surfaceId, relayPort: identity.relayPort) + } + } + + private struct RemoteTerminalLifecycleIdentity { + let workspaceId: UUID + let surfaceId: UUID + let relayPort: Int + } + + private func v2WorkspaceRemoteTerminalLifecycleIdentity(params: [String: Any]) -> RemoteTerminalLifecycleIdentity? { guard let workspaceId = v2UUID(params, "workspace_id") else { - return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil) + return nil } guard let surfaceId = v2UUID(params, "surface_id") else { - return .err(code: "invalid_params", message: "Missing or invalid surface_id", data: nil) + return nil } guard let relayPort = v2StrictInt(params, "relay_port"), relayPort > 0, relayPort <= 65535 else { - return .err(code: "invalid_params", message: "Missing or invalid relay_port", data: nil) + return nil } + return RemoteTerminalLifecycleIdentity(workspaceId: workspaceId, surfaceId: surfaceId, relayPort: relayPort) + } + private func v2ApplyWorkspaceRemoteTerminalLifecycle( + identity: RemoteTerminalLifecycleIdentity, + apply: (Workspace) -> Void + ) -> V2CallResult { var result: V2CallResult = .err(code: "not_found", message: "Workspace not found", data: [ - "workspace_id": workspaceId.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), - "surface_id": surfaceId.uuidString, - "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), - "relay_port": relayPort, + "workspace_id": identity.workspaceId.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: identity.workspaceId), + "surface_id": identity.surfaceId.uuidString, + "surface_ref": v2Ref(kind: .surface, uuid: identity.surfaceId), + "relay_port": identity.relayPort, ]) v2MainSync { - guard let owner = AppDelegate.shared?.tabManagerFor(tabId: workspaceId), - let workspace = owner.tabs.first(where: { $0.id == workspaceId }) else { + guard let owner = AppDelegate.shared?.tabManagerFor(tabId: identity.workspaceId), + let workspace = owner.tabs.first(where: { $0.id == identity.workspaceId }) else { return } - workspace.markRemoteTerminalSessionEnded(surfaceId: surfaceId, relayPort: relayPort) + apply(workspace) let windowId = v2ResolveWindowId(tabManager: owner) result = .ok([ "window_id": v2OrNull(windowId?.uuidString), "window_ref": v2Ref(kind: .window, uuid: windowId), "workspace_id": workspace.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: workspace.id), - "surface_id": surfaceId.uuidString, - "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), - "relay_port": relayPort, + "surface_id": identity.surfaceId.uuidString, + "surface_ref": v2Ref(kind: .surface, uuid: identity.surfaceId), + "relay_port": identity.relayPort, "remote": workspace.remoteStatusPayload(), ]) } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 18950839e669..4b2ebe4260b3 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -9358,6 +9358,61 @@ final class Workspace: Identifiable, ObservableObject { untrackRemoteTerminalSurface(surfaceId) } + func markRemoteTerminalSessionReconnecting( + surfaceId: UUID, + relayPort: Int?, + attempt: Int, + limit: Int, + exitStatus: Int + ) { + guard remoteTerminalLifecycleMatches(surfaceId: surfaceId, relayPort: relayPort) else { return } + let target = remoteConfiguration?.displayTarget ?? String( + localized: "remote.state.targetFallback", + defaultValue: "remote host" + ) + let detailFormat = String( + localized: "remote.state.reconnecting.terminal", + defaultValue: "Reconnecting to %@ (attempt %lld/%lld after SSH exit %lld)" + ) + let detail = String(format: detailFormat, target, Int64(attempt), Int64(limit), Int64(exitStatus)) + applyRemoteConnectionStateUpdate(.reconnecting, detail: detail, target: target) + } + + func markRemoteTerminalSessionConnected(surfaceId: UUID, relayPort: Int?) { + guard remoteTerminalLifecycleMatches(surfaceId: surfaceId, relayPort: relayPort) else { return } + guard remoteConnectionState == .connecting || remoteConnectionState == .reconnecting else { return } + let target = remoteConfiguration?.displayTarget ?? String( + localized: "remote.state.targetFallback", + defaultValue: "remote host" + ) + if remoteConfiguration?.skipDaemonBootstrap == true, + remoteConfiguration?.daemonWebSocketEndpoint == nil { + let detailFormat = String( + localized: "remote.state.connected.vmNoProxy", + defaultValue: "Connected to %@ (VM, proxy disabled)" + ) + applyRemoteConnectionStateUpdate( + .connected, + detail: String(format: detailFormat, target), + target: target + ) + return + } + + guard remoteProxyEndpoint != nil || remoteDaemonStatus.state == .ready else { return } + applyRemoteConnectionStateUpdate(.connected, detail: nil, target: target) + } + + private func remoteTerminalLifecycleMatches(surfaceId: UUID, relayPort: Int?) -> Bool { + guard let relayPort, + relayPort > 0, + remoteConfiguration?.relayPort == relayPort else { + return false + } + return activeRemoteTerminalSurfaceIds.contains(surfaceId) + || pendingRemoteTerminalChildExitSurfaceIds.contains(surfaceId) + } + func teardownRemoteConnection() { disconnectRemoteConnection(clearConfiguration: true) } diff --git a/cmuxTests/WorkspaceRemoteConnectionTests.swift b/cmuxTests/WorkspaceRemoteConnectionTests.swift index 68646bc777f1..f480bcb6ec70 100644 --- a/cmuxTests/WorkspaceRemoteConnectionTests.swift +++ b/cmuxTests/WorkspaceRemoteConnectionTests.swift @@ -519,6 +519,45 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { XCTAssertFalse(workspace.isRemoteTerminalSurface(panelID)) } + @MainActor + func testRemoteTerminalLifecycleEventsDriveReconnectState() throws { + let workspace = Workspace() + let config = WorkspaceRemoteConfiguration( + destination: "cmux@gateway.freestyle.sh", + port: 2222, + identityFile: nil, + sshOptions: ["ControlMaster=no"], + localProxyPort: nil, + relayPort: 64041, + relayID: String(repeating: "a", count: 16), + relayToken: String(repeating: "b", count: 64), + localSocketPath: "/tmp/cmux-debug-test.sock", + terminalStartupCommand: "ssh cmux@gateway.freestyle.sh", + skipDaemonBootstrap: true + ) + + workspace.configureRemoteConnection(config, autoConnect: false) + let panelID = try XCTUnwrap(workspace.focusedTerminalPanel?.id) + + workspace.markRemoteTerminalSessionReconnecting( + surfaceId: panelID, + relayPort: 64041, + attempt: 1, + limit: 2, + exitStatus: 255 + ) + + XCTAssertEqual(workspace.remoteConnectionState, .reconnecting) + XCTAssertEqual(workspace.remoteStatusPayload()["state"] as? String, "reconnecting") + XCTAssertTrue(workspace.remoteConnectionDetail?.contains("attempt 1/2") == true) + + workspace.markRemoteTerminalSessionConnected(surfaceId: panelID, relayPort: 64041) + + XCTAssertEqual(workspace.remoteConnectionState, .connected) + XCTAssertEqual(workspace.remoteStatusPayload()["state"] as? String, "connected") + XCTAssertEqual(workspace.remoteConnectionDetail, "Connected to cmux@gateway.freestyle.sh:2222 (VM, proxy disabled)") + } + @MainActor func testForegroundSSHAuthReadyBeforeRemoteConfigureStartsDeferredConnect() { let workspace = Workspace() diff --git a/docs/cli-contract.md b/docs/cli-contract.md index ae170a6c84ac..c030f764f876 100644 --- a/docs/cli-contract.md +++ b/docs/cli-contract.md @@ -153,6 +153,8 @@ Environment: | `vm-ssh-attach` | Hidden compatibility alias for older VM workspaces. | | `vm-pty-connect` | Internal helper that connects to a VM PTY from a config file. | | `ssh-session-end` | Internal helper that clears remote SSH session state. | +| `ssh-session-reconnecting` | Internal helper that marks an SSH-backed workspace as reconnecting. | +| `ssh-session-connected` | Internal helper that marks an SSH-backed workspace as connected after retry. | | `__tmux-compat` | Internal tmux compatibility dispatcher. | ## Command Families From d6884b90a4566640cdba8e0d5ca98848077acc94 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 12 May 2026 01:37:50 -0700 Subject: [PATCH 3/4] fix: harden ssh lifecycle edge cases --- CLI/cmux.swift | 107 +++- Sources/TerminalController.swift | 3 +- Sources/Workspace.swift | 120 +++- .../SSHStartupSignalLifecycleTests.swift | 519 +++++++++++++++++- .../WorkspaceRemoteConnectionTests.swift | 114 ++++ docs/cli-contract.md | 2 +- 6 files changed, 830 insertions(+), 35 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index ee345ca3cc31..bda80985ba7d 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -4811,6 +4811,7 @@ struct CMUXCLI { remoteRelayPort: sshOptions.remoteRelayPort ) let canInstallLocalCommand = canInstallSSHLocalCommand(in: remoteSSHOptions) + let canReportConnectionLifecycle = canInstallLocalCommand && sshOptions.remoteRelayPort > 0 let initialSSHCommand = buildSSHCommandText(sshOptions) // For VM workspaces (Freestyle), skip the interactive bootstrap script: the russh // gateway forwards shell-request PTYs but stalls on exec-channel I/O, and the bootstrap @@ -4873,36 +4874,41 @@ struct CMUXCLI { remoteBootstrapScript: remoteTerminalBootstrapScript, shellFeatures: shellFeaturesValue, remoteRelayPort: sshOptions.remoteRelayPort, - localCommandScript: combinedLocalCommandScript + localCommandScript: combinedLocalCommandScript, + reportsConnectionLifecycle: canReportConnectionLifecycle ) remoteTerminalSSHStartupCommand = buildReusableBootstrapSSHStartupCommand( options: sshOptions, remoteBootstrapScript: remoteTerminalBootstrapScript, shellFeatures: shellFeaturesValue, remoteRelayPort: sshOptions.remoteRelayPort, - localCommandScript: combinedLocalCommandScript + localCommandScript: combinedLocalCommandScript, + reportsConnectionLifecycle: canReportConnectionLifecycle ) } else { initialSSHStartupCommand = try buildSSHStartupCommand( sshCommand: startupInitialSSHCommand, shellFeatures: "", - remoteRelayPort: sshOptions.remoteRelayPort + remoteRelayPort: sshOptions.remoteRelayPort, + reportsConnectionLifecycle: canReportConnectionLifecycle ) remoteTerminalSSHStartupCommand = buildReusableSSHStartupCommand( sshCommand: startupRemoteTerminalSSHCommand, shellFeatures: shellFeaturesValue, - remoteRelayPort: sshOptions.remoteRelayPort + remoteRelayPort: sshOptions.remoteRelayPort, + reportsConnectionLifecycle: canReportConnectionLifecycle ) } let reusableTerminalStartupCommand: String if let vmIDForSplitAttach, sshOptions.skipDaemonBootstrap { let executablePath = resolvedExecutableURL()?.path ?? (args.first ?? "cmux") - let splitAttachCommand = "\(shellQuote(executablePath)) vm ssh-attach --id \(shellQuote(vmIDForSplitAttach))" + let splitAttachCommand = "\(shellQuote(executablePath)) vm ssh-attach --id \(shellQuote(vmIDForSplitAttach)) --relay-port \(sshOptions.remoteRelayPort)" reusableTerminalStartupCommand = buildReusableSSHStartupCommand( sshCommand: splitAttachCommand, shellFeatures: shellFeaturesValue, - remoteRelayPort: 0 + remoteRelayPort: sshOptions.remoteRelayPort, + reportsConnectionLifecycle: canReportConnectionLifecycle ) } else { reusableTerminalStartupCommand = remoteTerminalSSHStartupCommand @@ -5185,7 +5191,8 @@ struct CMUXCLI { remoteBootstrapScript: String, shellFeatures: String, remoteRelayPort: Int, - localCommandScript: String? = nil + localCommandScript: String? = nil, + reportsConnectionLifecycle: Bool = true ) throws -> String { let commandSnippet = buildSSHBootstrapCommandSnippet( options: options, @@ -5196,7 +5203,8 @@ struct CMUXCLI { sshCommand: commandSnippet, shellFeatures: shellFeatures, remoteRelayPort: remoteRelayPort, - isShellSnippet: true + isShellSnippet: true, + reportsConnectionLifecycle: reportsConnectionLifecycle ) } @@ -5205,7 +5213,8 @@ struct CMUXCLI { remoteBootstrapScript: String, shellFeatures: String, remoteRelayPort: Int, - localCommandScript: String? = nil + localCommandScript: String? = nil, + reportsConnectionLifecycle: Bool = true ) -> String { let commandSnippet = buildSSHBootstrapCommandSnippet( options: options, @@ -5216,7 +5225,8 @@ struct CMUXCLI { sshCommand: commandSnippet, shellFeatures: shellFeatures, remoteRelayPort: remoteRelayPort, - isShellSnippet: true + isShellSnippet: true, + reportsConnectionLifecycle: reportsConnectionLifecycle ) } @@ -5644,7 +5654,8 @@ struct CMUXCLI { for option in effectiveSSHOptions { parts += ["-o", option] } - if let escapedLocalCommand = openSSHLocalCommandValue(shellScript: localCommandScript) { + if canInstallSSHLocalCommand(in: effectiveSSHOptions), + let escapedLocalCommand = openSSHLocalCommandValue(shellScript: localCommandScript) { parts += ["-o", "PermitLocalCommand=yes"] parts += ["-o", "LocalCommand=\(escapedLocalCommand)"] } @@ -5734,13 +5745,15 @@ struct CMUXCLI { sshCommand: String, shellFeatures: String, remoteRelayPort: Int, - isShellSnippet: Bool = false + isShellSnippet: Bool = false, + reportsConnectionLifecycle: Bool = true ) throws -> String { let script = buildSSHStartupScriptBody( sshCommand: sshCommand, shellFeatures: shellFeatures, remoteRelayPort: remoteRelayPort, - isShellSnippet: isShellSnippet + isShellSnippet: isShellSnippet, + reportsConnectionLifecycle: reportsConnectionLifecycle ) return try writeSSHStartupScript(script, remoteRelayPort: remoteRelayPort) } @@ -5749,13 +5762,15 @@ struct CMUXCLI { sshCommand: String, shellFeatures: String, remoteRelayPort: Int, - isShellSnippet: Bool = false + isShellSnippet: Bool = false, + reportsConnectionLifecycle: Bool = true ) -> String { let script = buildSSHStartupScriptBody( sshCommand: sshCommand, shellFeatures: shellFeatures, remoteRelayPort: remoteRelayPort, - isShellSnippet: isShellSnippet + isShellSnippet: isShellSnippet, + reportsConnectionLifecycle: reportsConnectionLifecycle ) return reusableShellStartupCommand( scriptBody: script, @@ -5767,14 +5782,17 @@ struct CMUXCLI { sshCommand: String, shellFeatures: String, remoteRelayPort: Int, - isShellSnippet: Bool + isShellSnippet: Bool, + reportsConnectionLifecycle: Bool ) -> String { let trimmedFeatures = shellFeatures.trimmingCharacters(in: .whitespacesAndNewlines) let shellFeaturesBootstrap: String = trimmedFeatures.isEmpty ? "" : "export GHOSTTY_SHELL_FEATURES=\(shellQuote(trimmedFeatures))" let lifecycleCleanup = buildSSHSessionEndShellCommand(remoteRelayPort: remoteRelayPort) - let lifecycleReconnecting = buildSSHSessionReconnectingShellCommand(remoteRelayPort: remoteRelayPort) + let lifecycleReconnecting = reportsConnectionLifecycle + ? buildSSHSessionReconnectingShellCommand(remoteRelayPort: remoteRelayPort) + : ":" var scriptLines: [String] = [] if !shellFeaturesBootstrap.isEmpty { scriptLines.append(shellFeaturesBootstrap) @@ -6079,7 +6097,8 @@ struct CMUXCLI { } private func runVMSSHAttach(commandArgs: [String], client: SocketClient) throws { - let (vmIDOpt, remaining) = parseOption(commandArgs, name: "--id") + let (vmIDOpt, rem0) = parseOption(commandArgs, name: "--id") + let (relayPortOpt, remaining) = parseOption(rem0, name: "--relay-port") if let unknown = remaining.first(where: { $0.hasPrefix("--") }) { throw CLIError(message: "vm ssh-attach: unknown flag '\(unknown)'") } @@ -6090,6 +6109,17 @@ struct CMUXCLI { !vmID.isEmpty else { throw CLIError(message: "Usage: cmux vm ssh-attach --id ") } + let remoteRelayPort: Int + if let relayPortOpt { + guard let parsedRelayPort = Int(relayPortOpt), + parsedRelayPort > 0, + parsedRelayPort <= 65535 else { + throw CLIError(message: "vm ssh-attach: --relay-port must be 1-65535") + } + remoteRelayPort = parsedRelayPort + } else { + remoteRelayPort = 0 + } let attachInfoStartedAt = Date() let response = try client.sendV2(method: "vm.attach_info", params: ["id": vmID], responseTimeout: Self.vmAttachResponseTimeoutSeconds) @@ -6098,9 +6128,25 @@ struct CMUXCLI { fromAttachInfo: response, workspaceName: nil, client: client, - remoteRelayPort: 0 + remoteRelayPort: remoteRelayPort ) - let sshArguments = buildSSHCommandArguments(options) + let remoteSSHOptions = effectiveSSHOptions( + options.sshOptions, + remoteRelayPort: options.remoteRelayPort + ) + let localCommandScript = remoteRelayPort > 0 && canInstallSSHLocalCommand(in: remoteSSHOptions) + ? combinedLocalShellScript([ + sshSessionConnectedLocalCommandScript( + remoteRelayPort: remoteRelayPort, + localCLIPath: resolvedExecutableURL()?.path + ), + sshConnectionTimingLocalCommandScript( + target: options.displayDestination, + relayPort: remoteRelayPort + ), + ]) + : nil + let sshArguments = buildSSHCommandArguments(options, localCommandScript: localCommandScript) guard let launchPath = sshArguments.first else { throw CLIError(message: "vm ssh-attach: failed to construct ssh command") } @@ -6628,7 +6674,7 @@ struct CMUXCLI { guard let limit = positiveIntOption(commandArgs, name: "--limit") else { throw CLIError(message: "ssh-session-reconnecting requires --limit ") } - guard let exitStatus = nonNegativeIntOption(commandArgs, name: "--exit-status") else { + guard let exitStatus = byteIntOption(commandArgs, name: "--exit-status") else { throw CLIError(message: "ssh-session-reconnecting requires --exit-status ") } _ = try client.sendV2(method: "workspace.remote.terminal_reconnecting", params: [ @@ -6694,10 +6740,11 @@ struct CMUXCLI { return value } - private func nonNegativeIntOption(_ commandArgs: [String], name: String) -> Int? { + private func byteIntOption(_ commandArgs: [String], name: String) -> Int? { guard let raw = optionValue(commandArgs, name: name), let value = Int(raw), - value >= 0 else { + value >= 0, + value <= 255 else { return nil } return value @@ -6963,8 +7010,18 @@ struct CMUXCLI { } private func canInstallSSHLocalCommand(in options: [String]) -> Bool { - !hasSSHOptionKey(options, key: "LocalCommand") && - !hasSSHOptionKey(options, key: "PermitLocalCommand") + guard !hasSSHOptionKey(options, key: "LocalCommand") else { + return false + } + + guard hasSSHOptionKey(options, key: "PermitLocalCommand") else { + return true + } + + let permitLocalCommand = sshOptionValue(named: "PermitLocalCommand", in: options)? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + return ["yes", "true", "on"].contains(permitLocalCommand ?? "") } private func defaultSSHControlPathTemplate(remoteRelayPort: Int? = nil) -> String { diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index c04e3ce3ff1c..719f411b839f 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -5056,7 +5056,8 @@ class TerminalController { return .err(code: "invalid_params", message: "Missing or invalid limit", data: nil) } guard let exitStatus = v2StrictInt(params, "exit_status"), - exitStatus >= 0 else { + exitStatus >= 0, + exitStatus <= 255 else { return .err(code: "invalid_params", message: "Missing or invalid exit_status", data: nil) } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 4b2ebe4260b3..445e8069f749 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -4132,7 +4132,7 @@ final class WorkspaceRemoteSessionController { localized: "remote.state.connected.vmNoProxy", defaultValue: "Connected to %@ (VM, proxy disabled)" ) - publishState( + publishVMShellConnectedState( .connected, detail: String(format: connectedDetailFormat, configuration.displayTarget) ) @@ -4467,6 +4467,21 @@ final class WorkspaceRemoteSessionController { } } + private func publishVMShellConnectedState(_ state: WorkspaceRemoteConnectionState, detail: String?) { + let controllerID = self.controllerID + let relayPort = configuration.relayPort + DispatchQueue.main.async { [weak workspace] in + guard let workspace else { return } + guard workspace.activeRemoteSessionControllerID == controllerID else { return } + guard workspace.hasActiveRemoteTerminalSession(relayPort: relayPort) else { return } + workspace.applyRemoteConnectionStateUpdate( + state, + detail: detail, + target: workspace.remoteDisplayTarget ?? "remote host" + ) + } + } + private func publishDaemonStatus( _ state: WorkspaceRemoteDaemonState, detail: String?, @@ -7151,6 +7166,8 @@ final class Workspace: Identifiable, ObservableObject { private var remoteDetectedSurfaceIds: Set = [] private var activeRemoteTerminalSurfaceIds: Set = [] var pendingRemoteTerminalChildExitSurfaceIds: Set = [] + private var pendingRemoteTerminalConnectedRelayPortsBySurfaceId: [UUID: Int] = [:] + private var preConfiguredRemoteTerminalEndedRelayPortsBySurfaceId: [UUID: Int] = [:] /// Display target of the remote workspace that just disconnected. Set right before /// `createReplacementTerminalPanel()` so the replacement shell can print a banner /// explaining that ssh ended (instead of the user seeing an unexplained local prompt @@ -9108,6 +9125,7 @@ final class Workspace: Identifiable, ObservableObject { remoteConnectionState = .connecting applyBrowserRemoteWorkspaceStatusToPanels() + _ = applyPendingRemoteTerminalConnectedIfNeeded() let controllerID = UUID() let controller = WorkspaceRemoteSessionController( workspace: self, @@ -9164,6 +9182,8 @@ final class Workspace: Identifiable, ObservableObject { pendingRemoteForegroundAuthToken = nil activeRemoteTerminalSurfaceIds.removeAll() activeRemoteTerminalSessionCount = 0 + pendingRemoteTerminalConnectedRelayPortsBySurfaceId.removeAll() + preConfiguredRemoteTerminalEndedRelayPortsBySurfaceId.removeAll() pendingRemoteSurfaceTTYName = nil pendingRemoteSurfaceTTYSurfaceId = nil pendingRemoteSurfacePortKickReason = nil @@ -9209,6 +9229,11 @@ final class Workspace: Identifiable, ObservableObject { panel is TerminalPanel ? panelId : nil } guard terminalIds.count == 1, let initialPanelId = terminalIds.first else { return } + if preConfiguredRemoteTerminalEndMatches(surfaceId: initialPanelId, configuration: configuration) { + pendingRemoteTerminalChildExitSurfaceIds.insert(initialPanelId) + preConfiguredRemoteTerminalEndedRelayPortsBySurfaceId.removeValue(forKey: initialPanelId) + return + } trackRemoteTerminalSurface(initialPanelId) } @@ -9220,11 +9245,14 @@ final class Workspace: Identifiable, ObservableObject { activeRemoteTerminalSessionCount = activeRemoteTerminalSurfaceIds.count applyPendingRemoteSurfaceTTYIfNeeded(to: panelId) _ = applyPendingRemoteSurfacePortKickIfNeeded(to: panelId) + _ = applyPendingRemoteTerminalConnectedIfNeeded(surfaceId: panelId) } func untrackRemoteTerminalSurface(_ panelId: UUID) { guard activeRemoteTerminalSurfaceIds.remove(panelId) != nil else { return } activeRemoteTerminalSessionCount = activeRemoteTerminalSurfaceIds.count + pendingRemoteTerminalConnectedRelayPortsBySurfaceId.removeValue(forKey: panelId) + preConfiguredRemoteTerminalEndedRelayPortsBySurfaceId.removeValue(forKey: panelId) guard !isDetachingCloseTransaction else { return } maybeDemoteRemoteWorkspaceAfterSSHSessionEnded() } @@ -9339,9 +9367,21 @@ final class Workspace: Identifiable, ObservableObject { } func markRemoteTerminalSessionEnded(surfaceId: UUID, relayPort: Int?) { + if let relayPort, + relayPort > 0, + pendingRemoteTerminalConnectedRelayPortsBySurfaceId[surfaceId] == relayPort { + pendingRemoteTerminalConnectedRelayPortsBySurfaceId.removeValue(forKey: surfaceId) + } if cleanupTransferredRemoteConnectionIfNeeded(surfaceId: surfaceId, relayPort: relayPort) { return } + if let relayPort, + relayPort > 0, + remoteConfiguration == nil, + panels[surfaceId] is TerminalPanel { + preConfiguredRemoteTerminalEndedRelayPortsBySurfaceId[surfaceId] = relayPort + return + } guard let relayPort, relayPort > 0, remoteConfiguration?.relayPort == relayPort else { @@ -9354,6 +9394,7 @@ final class Workspace: Identifiable, ObservableObject { if let displayTarget = remoteConfiguration?.displayTarget { pendingReplacementBannerRemoteTarget = displayTarget } + pendingRemoteTerminalConnectedRelayPortsBySurfaceId.removeValue(forKey: surfaceId) pendingRemoteTerminalChildExitSurfaceIds.insert(surfaceId) untrackRemoteTerminalSurface(surfaceId) } @@ -9379,8 +9420,20 @@ final class Workspace: Identifiable, ObservableObject { } func markRemoteTerminalSessionConnected(surfaceId: UUID, relayPort: Int?) { - guard remoteTerminalLifecycleMatches(surfaceId: surfaceId, relayPort: relayPort) else { return } - guard remoteConnectionState == .connecting || remoteConnectionState == .reconnecting else { return } + guard let relayPort, + relayPort > 0, + !pendingRemoteTerminalChildExitSurfaceIds.contains(surfaceId) else { + return + } + if !applyRemoteTerminalSessionConnectedIfReady(surfaceId: surfaceId, relayPort: relayPort) { + rememberPendingRemoteTerminalConnectedIfNeeded(surfaceId: surfaceId, relayPort: relayPort) + } + } + + @discardableResult + private func applyRemoteTerminalSessionConnectedIfReady(surfaceId: UUID, relayPort: Int) -> Bool { + guard remoteTerminalLifecycleMatches(surfaceId: surfaceId, relayPort: relayPort) else { return false } + guard remoteConnectionState == .connecting || remoteConnectionState == .reconnecting else { return false } let target = remoteConfiguration?.displayTarget ?? String( localized: "remote.state.targetFallback", defaultValue: "remote host" @@ -9396,11 +9449,14 @@ final class Workspace: Identifiable, ObservableObject { detail: String(format: detailFormat, target), target: target ) - return + pendingRemoteTerminalConnectedRelayPortsBySurfaceId.removeValue(forKey: surfaceId) + return true } - guard remoteProxyEndpoint != nil || remoteDaemonStatus.state == .ready else { return } + guard remoteProxyEndpoint != nil || remoteDaemonStatus.state == .ready else { return false } applyRemoteConnectionStateUpdate(.connected, detail: nil, target: target) + pendingRemoteTerminalConnectedRelayPortsBySurfaceId.removeValue(forKey: surfaceId) + return true } private func remoteTerminalLifecycleMatches(surfaceId: UUID, relayPort: Int?) -> Bool { @@ -9410,7 +9466,53 @@ final class Workspace: Identifiable, ObservableObject { return false } return activeRemoteTerminalSurfaceIds.contains(surfaceId) - || pendingRemoteTerminalChildExitSurfaceIds.contains(surfaceId) + } + + func hasActiveRemoteTerminalSession(relayPort: Int?) -> Bool { + guard let relayPort, + relayPort > 0, + remoteConfiguration?.relayPort == relayPort else { + return false + } + return activeRemoteTerminalSessionCount > 0 + } + + private func preConfiguredRemoteTerminalEndMatches( + surfaceId: UUID, + configuration: WorkspaceRemoteConfiguration + ) -> Bool { + guard let relayPort = configuration.relayPort, + relayPort > 0 else { + return false + } + return preConfiguredRemoteTerminalEndedRelayPortsBySurfaceId[surfaceId] == relayPort + } + + private func rememberPendingRemoteTerminalConnectedIfNeeded(surfaceId: UUID, relayPort: Int) { + guard !pendingRemoteTerminalChildExitSurfaceIds.contains(surfaceId) else { return } + guard panels[surfaceId] is TerminalPanel else { return } + if let configuredRelayPort = remoteConfiguration?.relayPort, + configuredRelayPort != relayPort { + return + } + guard remoteConfiguration == nil || !activeRemoteTerminalSurfaceIds.contains(surfaceId) else { return } + pendingRemoteTerminalConnectedRelayPortsBySurfaceId[surfaceId] = relayPort + } + + @discardableResult + private func applyPendingRemoteTerminalConnectedIfNeeded(surfaceId requestedSurfaceId: UUID? = nil) -> Bool { + let pending = pendingRemoteTerminalConnectedRelayPortsBySurfaceId + var applied = false + for (surfaceId, relayPort) in pending { + if let requestedSurfaceId, requestedSurfaceId != surfaceId { + continue + } + guard applyRemoteTerminalSessionConnectedIfReady(surfaceId: surfaceId, relayPort: relayPort) else { + continue + } + applied = true + } + return applied } func teardownRemoteConnection() { @@ -9557,6 +9659,9 @@ final class Workspace: Identifiable, ObservableObject { fileprivate func applyRemoteDaemonStatusUpdate(_ status: WorkspaceRemoteDaemonStatus, target: String) { remoteDaemonStatus = status applyBrowserRemoteWorkspaceStatusToPanels() + if status.state == .ready { + _ = applyPendingRemoteTerminalConnectedIfNeeded() + } guard status.state == .error else { remoteLastDaemonErrorFingerprint = nil return @@ -9579,6 +9684,9 @@ final class Workspace: Identifiable, ObservableObject { browserPanel.setRemoteProxyEndpoint(endpoint) } applyBrowserRemoteWorkspaceStatusToPanels() + if endpoint != nil { + _ = applyPendingRemoteTerminalConnectedIfNeeded() + } } fileprivate func applyRemoteHeartbeatUpdate(count: Int, lastSeenAt: Date?) { diff --git a/cmuxTests/SSHStartupSignalLifecycleTests.swift b/cmuxTests/SSHStartupSignalLifecycleTests.swift index 41bd37fc01e5..1ae250ce9000 100644 --- a/cmuxTests/SSHStartupSignalLifecycleTests.swift +++ b/cmuxTests/SSHStartupSignalLifecycleTests.swift @@ -223,6 +223,340 @@ extension CLINotifyProcessIntegrationRegressionTests { } } + func testSSHStartupWithExplicitLocalCommandDoesNotReportReconnectingLifecycle() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cmux-ssh-explicit-local-command-\(UUID().uuidString)", isDirectory: true) + let fakeCLI = root.appendingPathComponent("cmux") + let fakeSSH = root.appendingPathComponent("ssh") + let logFile = root.appendingPathComponent("ssh-lifecycle.log") + let attemptFile = root.appendingPathComponent("ssh-attempts.txt") + + try fileManager.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: root) } + + try writeShellFile(at: fakeCLI, lines: [ + "#!/bin/sh", + "printf '%s\\n' \"$*\" >> \"${CMUX_TEST_LIFECYCLE_LOG}\"", + ]) + try writeShellFile(at: fakeSSH, lines: [ + "#!/bin/sh", + "count=0", + "if [ -r \"${CMUX_TEST_ATTEMPT_FILE}\" ]; then count=$(cat \"${CMUX_TEST_ATTEMPT_FILE}\"); fi", + "count=$((count + 1))", + "printf '%s\\n' \"$count\" > \"${CMUX_TEST_ATTEMPT_FILE}\"", + "if [ \"$count\" -eq 1 ]; then exit 255; fi", + "exit 0", + ]) + try fileManager.setAttributes([.posixPermissions: 0o700], ofItemAtPath: fakeCLI.path) + try fileManager.setAttributes([.posixPermissions: 0o700], ofItemAtPath: fakeSSH.path) + + let startupCommand = try generatedSSHStartupCommand(extraSSHOptions: ["LocalCommand=/bin/true"]) + var environment = ProcessInfo.processInfo.environment + environment["PATH"] = "\(root.path):\(environment["PATH"] ?? "/usr/bin:/bin")" + environment["CMUX_BUNDLED_CLI_PATH"] = fakeCLI.path + environment["CMUX_SOCKET_PATH"] = "/tmp/cmux-debug-test.sock" + environment["CMUX_WORKSPACE_ID"] = "11111111-1111-1111-1111-111111111111" + environment["CMUX_SURFACE_ID"] = "22222222-2222-2222-2222-222222222222" + environment["CMUX_TEST_LIFECYCLE_LOG"] = logFile.path + environment["CMUX_TEST_ATTEMPT_FILE"] = attemptFile.path + environment["CMUX_SSH_RECONNECT_DELAY_SECONDS"] = "0" + environment["CMUX_SSH_RECONNECT_LIMIT"] = "2" + + let result = runProcess( + executablePath: "/bin/sh", + arguments: ["-c", startupCommand], + environment: environment, + timeout: 5 + ) + + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + XCTAssertEqual((try? String(contentsOf: attemptFile, encoding: .utf8))?.trimmingCharacters(in: .whitespacesAndNewlines), "3") + + let recordedCalls = (try? String(contentsOf: logFile, encoding: .utf8)) ?? "" + XCTAssertFalse(recordedCalls.contains("ssh-session-reconnecting"), recordedCalls) + XCTAssertFalse(recordedCalls.contains("ssh-session-connected"), recordedCalls) + XCTAssertTrue(recordedCalls.contains("ssh-session-end"), recordedCalls) + } + + func testSSHStartupWithPermitLocalCommandStillReportsConnectedLifecycle() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cmux-ssh-permit-local-command-\(UUID().uuidString)", isDirectory: true) + let fakeCLI = root.appendingPathComponent("cmux") + let fakeSSH = root.appendingPathComponent("ssh") + let logFile = root.appendingPathComponent("ssh-lifecycle.log") + + try fileManager.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: root) } + + try writeShellFile(at: fakeCLI, lines: [ + "#!/bin/sh", + "printf '%s\\n' \"$*\" >> \"${CMUX_TEST_LIFECYCLE_LOG}\"", + ]) + try writeShellFile(at: fakeSSH, lines: [ + "#!/bin/sh", + "cmux_local_command=", + "cmux_previous=", + "for cmux_arg in \"$@\"; do", + " if [ \"$cmux_previous\" = \"-o\" ]; then", + " case \"$cmux_arg\" in LocalCommand=*) cmux_local_command=${cmux_arg#LocalCommand=} ;; esac", + " cmux_previous=", + " continue", + " fi", + " if [ \"$cmux_arg\" = \"-o\" ]; then cmux_previous=\"-o\"; fi", + "done", + "if [ -n \"$cmux_local_command\" ]; then", + " cmux_local_command=$(printf '%s' \"$cmux_local_command\" | sed 's/%%/%/g')", + " /bin/sh -c \"$cmux_local_command\"", + "fi", + "exit 0", + ]) + try fileManager.setAttributes([.posixPermissions: 0o700], ofItemAtPath: fakeCLI.path) + try fileManager.setAttributes([.posixPermissions: 0o700], ofItemAtPath: fakeSSH.path) + + let startupCommand = try generatedSSHStartupCommand(extraSSHOptions: ["PermitLocalCommand=yes"]) + var environment = ProcessInfo.processInfo.environment + environment["PATH"] = "\(root.path):\(environment["PATH"] ?? "/usr/bin:/bin")" + environment["CMUX_BUNDLED_CLI_PATH"] = fakeCLI.path + environment["CMUX_SOCKET_PATH"] = "/tmp/cmux-debug-test.sock" + environment["CMUX_WORKSPACE_ID"] = "11111111-1111-1111-1111-111111111111" + environment["CMUX_SURFACE_ID"] = "22222222-2222-2222-2222-222222222222" + environment["CMUX_TEST_LIFECYCLE_LOG"] = logFile.path + environment["CMUX_SSH_RECONNECT_DELAY_SECONDS"] = "0" + + let result = runProcess( + executablePath: "/bin/sh", + arguments: ["-c", startupCommand], + environment: environment, + timeout: 5 + ) + + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + + let recordedCalls = (try? String(contentsOf: logFile, encoding: .utf8)) ?? "" + XCTAssertTrue(recordedCalls.contains("ssh-session-connected"), recordedCalls) + XCTAssertTrue(recordedCalls.contains("ssh-session-end"), recordedCalls) + } + + func testSSHStartupWithPermitLocalCommandNoDoesNotInjectLocalCommand() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cmux-ssh-permit-local-command-no-\(UUID().uuidString)", isDirectory: true) + let fakeCLI = root.appendingPathComponent("cmux") + let fakeSSH = root.appendingPathComponent("ssh") + let logFile = root.appendingPathComponent("ssh-lifecycle.log") + let sshArgsFile = root.appendingPathComponent("ssh-args.log") + + try fileManager.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: root) } + + try writeShellFile(at: fakeCLI, lines: [ + "#!/bin/sh", + "printf '%s\\n' \"$*\" >> \"${CMUX_TEST_LIFECYCLE_LOG}\"", + ]) + try writeShellFile(at: fakeSSH, lines: [ + "#!/bin/sh", + "printf '%s\\n' \"$*\" >> \"${CMUX_TEST_SSH_ARGS_LOG}\"", + "exit 0", + ]) + try fileManager.setAttributes([.posixPermissions: 0o700], ofItemAtPath: fakeCLI.path) + try fileManager.setAttributes([.posixPermissions: 0o700], ofItemAtPath: fakeSSH.path) + + let startupCommand = try generatedSSHStartupCommand(extraSSHOptions: ["PermitLocalCommand=no"]) + var environment = ProcessInfo.processInfo.environment + environment["PATH"] = "\(root.path):\(environment["PATH"] ?? "/usr/bin:/bin")" + environment["CMUX_BUNDLED_CLI_PATH"] = fakeCLI.path + environment["CMUX_SOCKET_PATH"] = "/tmp/cmux-debug-test.sock" + environment["CMUX_WORKSPACE_ID"] = "11111111-1111-1111-1111-111111111111" + environment["CMUX_SURFACE_ID"] = "22222222-2222-2222-2222-222222222222" + environment["CMUX_TEST_LIFECYCLE_LOG"] = logFile.path + environment["CMUX_TEST_SSH_ARGS_LOG"] = sshArgsFile.path + environment["CMUX_SSH_RECONNECT_DELAY_SECONDS"] = "0" + + let result = runProcess( + executablePath: "/bin/sh", + arguments: ["-c", startupCommand], + environment: environment, + timeout: 5 + ) + + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + + let sshArgs = (try? String(contentsOf: sshArgsFile, encoding: .utf8)) ?? "" + XCTAssertTrue(sshArgs.contains("PermitLocalCommand=no"), sshArgs) + XCTAssertFalse(sshArgs.contains("PermitLocalCommand=yes"), sshArgs) + XCTAssertFalse(sshArgs.contains("-o LocalCommand="), sshArgs) + + let recordedCalls = (try? String(contentsOf: logFile, encoding: .utf8)) ?? "" + XCTAssertFalse(recordedCalls.contains("ssh-session-connected"), recordedCalls) + XCTAssertTrue(recordedCalls.contains("ssh-session-end"), recordedCalls) + } + + func testVMSSHTerminalStartupCommandReportsLifecycleForSplitAttach() throws { + let generated = try generatedVMSSHTerminalStartupCommand() + let cliPath = try bundledCLIPath() + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cmux-vm-ssh-split-lifecycle-\(UUID().uuidString)", isDirectory: true) + let fakeSSH = root.appendingPathComponent("ssh") + let attemptFile = root.appendingPathComponent("ssh-attempts.txt") + let socketPath = makeSocketPath("vm-split") + let listenerFD = try bindUnixSocket(at: socketPath) + let state = MockSocketServerState() + + try fileManager.createDirectory(at: root, withIntermediateDirectories: true) + defer { + Darwin.close(listenerFD) + unlink(socketPath) + try? fileManager.removeItem(at: root) + } + + try writeShellFile(at: fakeSSH, lines: [ + "#!/bin/sh", + "count=0", + "if [ -r \"${CMUX_TEST_ATTEMPT_FILE}\" ]; then count=$(cat \"${CMUX_TEST_ATTEMPT_FILE}\"); fi", + "count=$((count + 1))", + "printf '%s\\n' \"$count\" > \"${CMUX_TEST_ATTEMPT_FILE}\"", + "if [ \"$count\" -eq 1 ]; then exit 255; fi", + "cmux_local_command=", + "cmux_previous=", + "for cmux_arg in \"$@\"; do", + " if [ \"$cmux_previous\" = \"-o\" ]; then", + " case \"$cmux_arg\" in LocalCommand=*) cmux_local_command=${cmux_arg#LocalCommand=} ;; esac", + " cmux_previous=", + " continue", + " fi", + " if [ \"$cmux_arg\" = \"-o\" ]; then cmux_previous=\"-o\"; fi", + "done", + "if [ -n \"$cmux_local_command\" ]; then", + " cmux_local_command=$(printf '%s' \"$cmux_local_command\" | sed 's/%%/%/g')", + " /bin/sh -c \"$cmux_local_command\"", + "fi", + "exit 0", + ]) + try fileManager.setAttributes([.posixPermissions: 0o700], ofItemAtPath: fakeSSH.path) + + let serverHandled = startMultiRequestMockServer( + listenerFD: listenerFD, + state: state, + expectedRequestCount: 5 + ) { line in + guard let payload = self.jsonObject(line), + let id = payload["id"] as? String, + let method = payload["method"] as? String else { + return self.malformedRequestResponse(raw: line) + } + + switch method { + case "vm.attach_info": + let params = payload["params"] as? [String: Any] ?? [:] + XCTAssertEqual(params["id"] as? String, generated.vmID) + return self.v2Response( + id: id, + ok: true, + result: [ + "transport": "ssh", + "host": "gateway.freestyle.sh", + "port": 2222, + "username": "cmux", + "credential": [ + "kind": "password", + "value": "lease-token", + ], + ] + ) + case "workspace.remote.terminal_reconnecting", + "workspace.remote.terminal_connected", + "workspace.remote.terminal_session_end": + let params = payload["params"] as? [String: Any] ?? [:] + XCTAssertEqual(params["workspace_id"] as? String, "11111111-1111-1111-1111-111111111111") + XCTAssertEqual(params["surface_id"] as? String, "22222222-2222-2222-2222-222222222222") + XCTAssertEqual(params["relay_port"] as? Int, generated.relayPort) + return self.v2Response(id: id, ok: true, result: ["ok": true]) + default: + return self.v2Response( + id: id, + ok: false, + error: ["code": "unexpected", "message": "Unexpected method \(method)"] + ) + } + } + + var environment = ProcessInfo.processInfo.environment + environment["PATH"] = "\(root.path):\(environment["PATH"] ?? "/usr/bin:/bin")" + environment["CMUX_BUNDLED_CLI_PATH"] = cliPath + environment["CMUX_SOCKET_PATH"] = socketPath + environment["CMUX_WORKSPACE_ID"] = "11111111-1111-1111-1111-111111111111" + environment["CMUX_SURFACE_ID"] = "22222222-2222-2222-2222-222222222222" + environment["CMUX_TEST_ATTEMPT_FILE"] = attemptFile.path + environment["CMUX_SSH_RECONNECT_DELAY_SECONDS"] = "0" + environment["CMUX_SSH_RECONNECT_LIMIT"] = "2" + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + environment["CMUX_CLAUDE_HOOK_SENTRY_DISABLED"] = "1" + + let result = runProcess( + executablePath: "/bin/sh", + arguments: ["-c", generated.terminalStartupCommand], + environment: environment, + timeout: 5 + ) + + wait(for: [serverHandled], timeout: 5) + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + XCTAssertEqual((try? String(contentsOf: attemptFile, encoding: .utf8))?.trimmingCharacters(in: .whitespacesAndNewlines), "2") + + let methods = state.commands.compactMap { self.jsonObject($0)?["method"] as? String } + XCTAssertEqual( + methods, + [ + "vm.attach_info", + "workspace.remote.terminal_reconnecting", + "vm.attach_info", + "workspace.remote.terminal_connected", + "workspace.remote.terminal_session_end", + ] + ) + } + + func testSSHSessionReconnectingRejectsExitStatusOutsideByteRange() throws { + let cliPath = try bundledCLIPath() + let socketPath = makeSocketPath("ssh-exit-status") + let listenerFD = try bindUnixSocket(at: socketPath) + defer { + Darwin.close(listenerFD) + unlink(socketPath) + } + + var environment = ProcessInfo.processInfo.environment + environment["CMUX_SOCKET_PATH"] = socketPath + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + environment["CMUX_CLAUDE_HOOK_SENTRY_DISABLED"] = "1" + + let result = runProcess( + executablePath: cliPath, + arguments: [ + "ssh-session-reconnecting", + "--relay-port", "64041", + "--workspace", "11111111-1111-1111-1111-111111111111", + "--surface", "22222222-2222-2222-2222-222222222222", + "--attempt", "1", + "--limit", "2", + "--exit-status", "256", + ], + environment: environment, + timeout: 5 + ) + + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertNotEqual(result.status, 0) + XCTAssertTrue(result.stderr.contains("ssh-session-reconnecting requires --exit-status "), result.stderr) + } + func testSSHStartupStopsAtConfiguredReconnectLimit() throws { let fileManager = FileManager.default let root = fileManager.temporaryDirectory @@ -487,7 +821,7 @@ extension CLINotifyProcessIntegrationRegressionTests { XCTAssertTrue(result.stderr.contains("[cmux] press Enter to close this pane."), result.stderr) } - private func generatedSSHStartupCommand() throws -> String { + private func generatedSSHStartupCommand(extraSSHOptions: [String] = []) throws -> String { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("ssh-pane-close") let listenerFD = try bindUnixSocket(at: socketPath) @@ -548,6 +882,7 @@ extension CLINotifyProcessIntegrationRegressionTests { environment["CMUX_CLI_SENTRY_DISABLED"] = "1" environment["CMUX_CLAUDE_HOOK_SENTRY_DISABLED"] = "1" + let extraSSHOptionArguments = extraSSHOptions.flatMap { ["--ssh-option", $0] } let result = runProcess( executablePath: cliPath, arguments: [ @@ -556,9 +891,116 @@ extension CLINotifyProcessIntegrationRegressionTests { "--port", "2222", "--ssh-option", "ControlMaster no", "--ssh-option", "ControlPath /tmp/cmux-ssh-%C", + ] + extraSSHOptionArguments + [ "cmux-macmini", ], environment: environment, + timeout: 10 + ) + + wait(for: [serverHandled], timeout: 10) + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + XCTAssertTrue(result.stderr.isEmpty, result.stderr) + + let requests = try state.commands.map { line -> [String: Any] in + let data = try XCTUnwrap(line.data(using: .utf8)) + return try XCTUnwrap(JSONSerialization.jsonObject(with: data, options: []) as? [String: Any]) + } + let configureRequest = try XCTUnwrap( + requests.first { ($0["method"] as? String) == "workspace.remote.configure" } + ) + let configureParams = try XCTUnwrap(configureRequest["params"] as? [String: Any]) + return try XCTUnwrap(configureParams["terminal_startup_command"] as? String) + } + + private func generatedVMSSHTerminalStartupCommand() throws -> ( + vmID: String, + relayPort: Int, + terminalStartupCommand: String + ) { + let cliPath = try bundledCLIPath() + let socketPath = makeSocketPath("vm-ssh-split") + let listenerFD = try bindUnixSocket(at: socketPath) + let state = MockSocketServerState() + let vmID = "vm-test-split-startup" + let workspaceID = "11111111-1111-1111-1111-111111111111" + let workspaceRef = "workspace:vm-split-startup" + + defer { + Darwin.close(listenerFD) + unlink(socketPath) + } + + let serverHandled = startMockServer(listenerFD: listenerFD, state: state) { line in + guard let payload = self.jsonObject(line), + let id = payload["id"] as? String, + let method = payload["method"] as? String else { + return self.malformedRequestResponse(raw: line) + } + + switch method { + case "vm.attach_info": + let params = payload["params"] as? [String: Any] ?? [:] + XCTAssertEqual(params["id"] as? String, vmID) + XCTAssertEqual(params["require_daemon"] as? Bool, true) + return self.v2Response( + id: id, + ok: true, + result: [ + "transport": "ssh", + "host": "gateway.freestyle.sh", + "port": 2222, + "username": "cmux", + "credential": [ + "kind": "password", + "value": "lease-token", + ], + ] + ) + case "workspace.create": + return self.v2Response( + id: id, + ok: true, + result: [ + "workspace_id": workspaceID, + ] + ) + case "workspace.rename": + return self.v2Response(id: id, ok: true, result: ["workspace_id": workspaceID]) + case "workspace.remote.configure": + return self.v2Response( + id: id, + ok: true, + result: [ + "workspace_id": workspaceID, + "workspace_ref": workspaceRef, + "remote": [ + "enabled": true, + "state": "connecting", + ], + ] + ) + case "workspace.select": + return self.v2Response(id: id, ok: true, result: ["workspace_id": workspaceID]) + default: + return self.v2Response( + id: id, + ok: false, + error: ["code": "unexpected", "message": "Unexpected method \(method)"] + ) + } + } + + var environment = ProcessInfo.processInfo.environment + environment["CMUX_SOCKET_PATH"] = socketPath + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + environment["CMUX_CLAUDE_HOOK_SENTRY_DISABLED"] = "1" + + let result = runProcess( + executablePath: cliPath, + arguments: ["vm", "ssh", vmID], + environment: environment, timeout: 5 ) @@ -575,7 +1017,9 @@ extension CLINotifyProcessIntegrationRegressionTests { requests.first { ($0["method"] as? String) == "workspace.remote.configure" } ) let configureParams = try XCTUnwrap(configureRequest["params"] as? [String: Any]) - return try XCTUnwrap(configureParams["terminal_startup_command"] as? String) + let relayPort = try XCTUnwrap(configureParams["relay_port"] as? Int) + let terminalStartupCommand = try XCTUnwrap(configureParams["terminal_startup_command"] as? String) + return (vmID: vmID, relayPort: relayPort, terminalStartupCommand: terminalStartupCommand) } private func generatedVMSSHInitialStartupCommand() throws -> String { @@ -685,4 +1129,75 @@ extension CLINotifyProcessIntegrationRegressionTests { .appending("\n") .write(to: url, atomically: true, encoding: .utf8) } + + private func startMultiRequestMockServer( + listenerFD: Int32, + state: MockSocketServerState, + expectedRequestCount: Int, + handler: @escaping @Sendable (String) -> String + ) -> XCTestExpectation { + let handled = expectation(description: "cli mock socket handled \(expectedRequestCount) requests") + let lock = NSLock() + var requestCount = 0 + var fulfilled = false + + func noteRequestHandled() -> Bool { + lock.lock() + defer { lock.unlock() } + requestCount += 1 + guard requestCount >= expectedRequestCount, !fulfilled else { + return false + } + fulfilled = true + return true + } + + DispatchQueue.global(qos: .userInitiated).async { + while true { + var clientAddr = sockaddr_un() + var clientAddrLen = socklen_t(MemoryLayout.size) + let clientFD = withUnsafeMutablePointer(to: &clientAddr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in + Darwin.accept(listenerFD, sockaddrPtr, &clientAddrLen) + } + } + guard clientFD >= 0 else { + return + } + var shouldStop = false + var pending = Data() + var buffer = [UInt8](repeating: 0, count: 4096) + while true { + let count = Darwin.read(clientFD, &buffer, buffer.count) + if count < 0 { + if errno == EINTR { continue } + break + } + if count == 0 { break } + pending.append(buffer, count: count) + + while let newlineRange = pending.firstRange(of: Data([0x0A])) { + let lineData = pending.subdata(in: 0.. URL ) throws -> String { @@ -558,6 +573,105 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { XCTAssertEqual(workspace.remoteConnectionDetail, "Connected to cmux@gateway.freestyle.sh:2222 (VM, proxy disabled)") } + @MainActor + func testRemoteTerminalConnectedBeforeConfigureAppliesAfterInitialSurfaceIsSeeded() throws { + let workspace = Workspace() + let panelID = try XCTUnwrap(workspace.focusedTerminalPanel?.id) + let config = WorkspaceRemoteConfiguration( + destination: "cmux@gateway.freestyle.sh", + port: 2222, + identityFile: nil, + sshOptions: ["ControlMaster=no"], + localProxyPort: nil, + relayPort: 64042, + relayID: String(repeating: "a", count: 16), + relayToken: String(repeating: "b", count: 64), + localSocketPath: "/tmp/cmux-debug-test.sock", + terminalStartupCommand: "ssh cmux@gateway.freestyle.sh", + skipDaemonBootstrap: true + ) + + workspace.markRemoteTerminalSessionConnected(surfaceId: panelID, relayPort: 64042) + XCTAssertEqual(workspace.remoteConnectionState, .disconnected) + + workspace.configureRemoteConnection(config, autoConnect: true) + + XCTAssertEqual(workspace.remoteConnectionState, .connected) + XCTAssertEqual(workspace.remoteStatusPayload()["state"] as? String, "connected") + XCTAssertEqual(workspace.remoteConnectionDetail, "Connected to cmux@gateway.freestyle.sh:2222 (VM, proxy disabled)") + workspace.disconnectRemoteConnection(clearConfiguration: true) + } + + @MainActor + func testRemoteTerminalEndBeforeConfigureClearsPendingConnectedEvent() throws { + let workspace = Workspace() + let panelID = try XCTUnwrap(workspace.focusedTerminalPanel?.id) + let config = WorkspaceRemoteConfiguration( + destination: "cmux@gateway.freestyle.sh", + port: 2222, + identityFile: nil, + sshOptions: ["ControlMaster=no"], + localProxyPort: nil, + relayPort: 64044, + relayID: String(repeating: "a", count: 16), + relayToken: String(repeating: "b", count: 64), + localSocketPath: "/tmp/cmux-debug-test.sock", + terminalStartupCommand: "ssh cmux@gateway.freestyle.sh", + skipDaemonBootstrap: true + ) + + workspace.markRemoteTerminalSessionConnected(surfaceId: panelID, relayPort: 64044) + workspace.markRemoteTerminalSessionEnded(surfaceId: panelID, relayPort: 64044) + + workspace.configureRemoteConnection(config, autoConnect: true) + waitForRemoteDaemonState(.ready, in: workspace) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + + XCTAssertEqual(workspace.remoteConnectionState, .connecting) + XCTAssertEqual(workspace.remoteStatusPayload()["state"] as? String, "connecting") + XCTAssertEqual(workspace.remoteStatusPayload()["connected"] as? Bool, false) + XCTAssertEqual(workspace.remoteConnectionDetail, "Connecting to cmux@gateway.freestyle.sh:2222") + workspace.disconnectRemoteConnection(clearConfiguration: true) + } + + @MainActor + func testRemoteTerminalLifecycleIgnoresAlreadyEndedSurface() throws { + let workspace = Workspace() + let config = WorkspaceRemoteConfiguration( + destination: "cmux@gateway.freestyle.sh", + port: 2222, + identityFile: nil, + sshOptions: ["ControlMaster=no"], + localProxyPort: nil, + relayPort: 64043, + relayID: String(repeating: "a", count: 16), + relayToken: String(repeating: "b", count: 64), + localSocketPath: "/tmp/cmux-debug-test.sock", + terminalStartupCommand: "ssh cmux@gateway.freestyle.sh", + skipDaemonBootstrap: true + ) + + workspace.configureRemoteConnection(config, autoConnect: false) + let panelID = try XCTUnwrap(workspace.focusedTerminalPanel?.id) + workspace.remoteConnectionState = .connecting + workspace.markRemoteTerminalSessionEnded(surfaceId: panelID, relayPort: 64043) + + XCTAssertEqual(workspace.remoteStatusPayload()["active_terminal_sessions"] as? Int, 0) + + workspace.markRemoteTerminalSessionReconnecting( + surfaceId: panelID, + relayPort: 64043, + attempt: 1, + limit: 2, + exitStatus: 255 + ) + workspace.markRemoteTerminalSessionConnected(surfaceId: panelID, relayPort: 64043) + + XCTAssertEqual(workspace.remoteConnectionState, .connecting) + XCTAssertNil(workspace.remoteConnectionDetail) + workspace.disconnectRemoteConnection(clearConfiguration: true) + } + @MainActor func testForegroundSSHAuthReadyBeforeRemoteConfigureStartsDeferredConnect() { let workspace = Workspace() diff --git a/docs/cli-contract.md b/docs/cli-contract.md index c030f764f876..ab29c8dee39d 100644 --- a/docs/cli-contract.md +++ b/docs/cli-contract.md @@ -154,7 +154,7 @@ Environment: | `vm-pty-connect` | Internal helper that connects to a VM PTY from a config file. | | `ssh-session-end` | Internal helper that clears remote SSH session state. | | `ssh-session-reconnecting` | Internal helper that marks an SSH-backed workspace as reconnecting. | -| `ssh-session-connected` | Internal helper that marks an SSH-backed workspace as connected after retry. | +| `ssh-session-connected` | Internal helper that marks an SSH-backed workspace as connected after OpenSSH reports a connection. | | `__tmux-compat` | Internal tmux compatibility dispatcher. | ## Command Families From 9456c492df24d762d2e880bcc8edcff40ca77f0e Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 12 May 2026 03:31:00 -0700 Subject: [PATCH 4/4] fix: replay pending ssh connected events --- Sources/TerminalController.swift | 3 ++ Sources/Workspace.swift | 1 - ...erminalControllerSocketSecurityTests.swift | 30 +++++++++++ .../WorkspaceRemoteConnectionTests.swift | 50 ++++++++++++++++++- 4 files changed, 82 insertions(+), 2 deletions(-) diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index d9aca4172804..a6287950a935 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -5075,6 +5075,9 @@ class TerminalController { limit > 0 else { return .err(code: "invalid_params", message: "Missing or invalid limit", data: nil) } + guard attempt <= limit else { + return .err(code: "invalid_params", message: "Missing or invalid attempt", data: nil) + } guard let exitStatus = v2StrictInt(params, "exit_status"), exitStatus >= 0, exitStatus <= 255 else { diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index cd2055642035..c67caf11ec6e 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -9571,7 +9571,6 @@ final class Workspace: Identifiable, ObservableObject { configuredRelayPort != relayPort { return } - guard remoteConfiguration == nil || !activeRemoteTerminalSurfaceIds.contains(surfaceId) else { return } pendingRemoteTerminalConnectedRelayPortsBySurfaceId[surfaceId] = relayPort } diff --git a/cmuxTests/TerminalControllerSocketSecurityTests.swift b/cmuxTests/TerminalControllerSocketSecurityTests.swift index 5cc13af5265b..0dee1d913a77 100644 --- a/cmuxTests/TerminalControllerSocketSecurityTests.swift +++ b/cmuxTests/TerminalControllerSocketSecurityTests.swift @@ -140,6 +140,36 @@ final class TerminalControllerSocketSecurityTests: XCTestCase { #endif } + func testRemoteTerminalReconnectingRejectsAttemptAboveLimit() async throws { + let socketPath = makeSocketPath("ssh-reconnecting-limit") + let tabManager = TabManager() + + TerminalController.shared.start( + tabManager: tabManager, + socketPath: socketPath, + accessMode: .allowAll + ) + try waitForSocket(at: socketPath) + + let response = try await sendV2RequestAsync( + method: "workspace.remote.terminal_reconnecting", + params: [ + "workspace_id": "11111111-1111-1111-1111-111111111111", + "surface_id": "22222222-2222-2222-2222-222222222222", + "relay_port": 64041, + "attempt": 3, + "limit": 2, + "exit_status": 255, + ], + to: socketPath + ) + + XCTAssertEqual(response["ok"] as? Bool, false, "Unexpected JSON-RPC response: \(response)") + let error = try XCTUnwrap(response["error"] as? [String: Any]) + XCTAssertEqual(error["code"] as? String, "invalid_params") + XCTAssertEqual(error["message"] as? String, "Missing or invalid attempt") + } + func testRemoteStatusPayloadOmitsSensitiveSSHConfiguration() { let tabManager = TabManager() let workspace = tabManager.addWorkspace(select: false, eagerLoadTerminal: false) diff --git a/cmuxTests/WorkspaceRemoteConnectionTests.swift b/cmuxTests/WorkspaceRemoteConnectionTests.swift index 2c8208b58b83..5a03649a95ca 100644 --- a/cmuxTests/WorkspaceRemoteConnectionTests.swift +++ b/cmuxTests/WorkspaceRemoteConnectionTests.swift @@ -82,6 +82,21 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { XCTAssertEqual(workspace.remoteDaemonStatus.state, expectedState, file: file, line: line) } + @MainActor + private func waitForRemoteConnectionState( + _ expectedState: WorkspaceRemoteConnectionState, + in workspace: Workspace, + timeout: TimeInterval = 1.0, + file: StaticString = #filePath, + line: UInt = #line + ) { + let deadline = Date().addingTimeInterval(timeout) + while workspace.remoteConnectionState != expectedState && Date() < deadline { + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.01)) + } + XCTAssertEqual(workspace.remoteConnectionState, expectedState, file: file, line: line) + } + private func runRelayZshHistfile( configureUserHome: (URL) throws -> URL ) throws -> String { @@ -571,6 +586,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { XCTAssertEqual(workspace.remoteConnectionState, .connected) XCTAssertEqual(workspace.remoteStatusPayload()["state"] as? String, "connected") XCTAssertEqual(workspace.remoteConnectionDetail, "Connected to cmux@gateway.freestyle.sh:2222 (VM, proxy disabled)") + workspace.disconnectRemoteConnection(clearConfiguration: true) } @MainActor @@ -625,7 +641,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { workspace.configureRemoteConnection(config, autoConnect: true) waitForRemoteDaemonState(.ready, in: workspace) - RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + waitForRemoteConnectionState(.connecting, in: workspace) XCTAssertEqual(workspace.remoteConnectionState, .connecting) XCTAssertEqual(workspace.remoteStatusPayload()["state"] as? String, "connecting") @@ -634,6 +650,38 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { workspace.disconnectRemoteConnection(clearConfiguration: true) } + @MainActor + func testTrackedRemoteTerminalConnectedEventReplaysWhenConnectionStarts() throws { + let workspace = Workspace() + let config = WorkspaceRemoteConfiguration( + destination: "cmux@gateway.freestyle.sh", + port: 2222, + identityFile: nil, + sshOptions: ["ControlMaster=no"], + localProxyPort: nil, + relayPort: 64045, + relayID: String(repeating: "a", count: 16), + relayToken: String(repeating: "b", count: 64), + localSocketPath: "/tmp/cmux-debug-test.sock", + terminalStartupCommand: "ssh cmux@gateway.freestyle.sh", + skipDaemonBootstrap: true + ) + + workspace.configureRemoteConnection(config, autoConnect: false) + let panelID = try XCTUnwrap(workspace.focusedTerminalPanel?.id) + XCTAssertTrue(workspace.isRemoteTerminalSurface(panelID)) + + workspace.markRemoteTerminalSessionConnected(surfaceId: panelID, relayPort: 64045) + XCTAssertEqual(workspace.remoteConnectionState, .disconnected) + + workspace.configureRemoteConnection(config, autoConnect: true) + + XCTAssertEqual(workspace.remoteConnectionState, .connected) + XCTAssertEqual(workspace.remoteStatusPayload()["state"] as? String, "connected") + XCTAssertEqual(workspace.remoteConnectionDetail, "Connected to cmux@gateway.freestyle.sh:2222 (VM, proxy disabled)") + workspace.disconnectRemoteConnection(clearConfiguration: true) + } + @MainActor func testRemoteTerminalLifecycleIgnoresAlreadyEndedSurface() throws { let workspace = Workspace()