From 0a1d8c228904aed9c8e40a3fcc492f4bb1621d2a Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 25 Mar 2026 17:51:56 -0700 Subject: [PATCH] Skip SSH cleanup after moving the last remote surface (#2123) * Add regression test for detached remote cleanup * Skip SSH cleanup after remote surface transfer * Add SSH lifecycle regression coverage * Add SSH detach cleanup transfer regressions * Transfer SSH cleanup ownership with detached remote terminals * Document intentional SSH workspace focus --------- Co-authored-by: Lawrence Chen --- CLI/cmux.swift | 3 + Sources/Workspace.swift | 85 ++++- .../WorkspaceRemoteConnectionTests.swift | 357 +++++++++++++++++- 3 files changed, 437 insertions(+), 8 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 72cc04de..199ccbbb 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -3746,6 +3746,9 @@ struct CMUXCLI { if let workspaceWindowId, !workspaceWindowId.isEmpty { selectParams["window_id"] = workspaceWindowId } + // `cmux ssh` is an explicit "open this remote workspace now" action, + // so we intentionally select the newly created workspace after wiring + // up the remote connection. _ = try client.sendV2(method: "workspace.select", params: selectParams) let remoteState = ((configuredPayload["remote"] as? [String: Any])?["state"] as? String) ?? "unknown" cliDebugLog( diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index b2730e21..ffa7bce9 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -5837,12 +5837,39 @@ final class Workspace: Identifiable, ObservableObject { let manuallyUnread: Bool let isRemoteTerminal: Bool let remoteRelayPort: Int? + let remoteCleanupConfiguration: WorkspaceRemoteConfiguration? + + func withRemoteCleanupConfiguration(_ configuration: WorkspaceRemoteConfiguration?) -> Self { + Self( + panelId: panelId, + panel: panel, + title: title, + icon: icon, + iconImageData: iconImageData, + kind: kind, + isLoading: isLoading, + isPinned: isPinned, + directory: directory, + ttyName: ttyName, + cachedTitle: cachedTitle, + customTitle: customTitle, + manuallyUnread: manuallyUnread, + isRemoteTerminal: isRemoteTerminal, + remoteRelayPort: remoteRelayPort, + remoteCleanupConfiguration: configuration + ) + } } private var detachingTabIds: Set = [] private var pendingDetachedSurfaces: [TabID: DetachedSurfaceTransfer] = [:] private var activeDetachCloseTransactions: Int = 0 private var isDetachingCloseTransaction: Bool { activeDetachCloseTransactions > 0 } + // When the last live remote terminal is detached out, the source workspace may be + // closed immediately after the move succeeds. That teardown must not shut down the + // shared SSH control master that is still serving the moved terminal. + private var skipControlMasterCleanupAfterDetachedRemoteTransfer = false + private var transferredRemoteCleanupConfigurationsByPanelId: [UUID: WorkspaceRemoteConfiguration] = [:] #if DEBUG private func debugElapsedMs(since start: TimeInterval) -> String { @@ -6797,6 +6824,7 @@ final class Workspace: Identifiable, ObservableObject { } func configureRemoteConnection(_ configuration: WorkspaceRemoteConfiguration, autoConnect: Bool = true) { + skipControlMasterCleanupAfterDetachedRemoteTransfer = false remoteConfiguration = configuration seedInitialRemoteTerminalSessionIfNeeded(configuration: configuration) remoteDetectedPorts = [] @@ -6847,7 +6875,10 @@ final class Workspace: Identifiable, ObservableObject { func disconnectRemoteConnection(clearConfiguration: Bool = false) { let shouldCleanupControlMaster = - clearConfiguration && !isDetachingCloseTransaction && pendingDetachedSurfaces.isEmpty + clearConfiguration + && !isDetachingCloseTransaction + && pendingDetachedSurfaces.isEmpty + && !skipControlMasterCleanupAfterDetachedRemoteTransfer let configurationForCleanup = shouldCleanupControlMaster ? remoteConfiguration : nil let previousController = remoteSessionController activeRemoteSessionControllerID = nil @@ -6871,6 +6902,7 @@ final class Workspace: Identifiable, ObservableObject { remoteLastPortConflictFingerprint = nil if clearConfiguration { remoteConfiguration = nil + skipControlMasterCleanupAfterDetachedRemoteTransfer = false } applyRemoteProxyEndpointUpdate(nil) applyBrowserRemoteWorkspaceStatusToPanels() @@ -6898,7 +6930,9 @@ final class Workspace: Identifiable, ObservableObject { } private func trackRemoteTerminalSurface(_ panelId: UUID) { + skipControlMasterCleanupAfterDetachedRemoteTransfer = false pendingRemoteTerminalChildExitSurfaceIds.remove(panelId) + transferredRemoteCleanupConfigurationsByPanelId.removeValue(forKey: panelId) guard activeRemoteTerminalSurfaceIds.insert(panelId).inserted else { return } activeRemoteTerminalSessionCount = activeRemoteTerminalSurfaceIds.count } @@ -6921,7 +6955,22 @@ final class Workspace: Identifiable, ObservableObject { } } + private func cleanupTransferredRemoteConnectionIfNeeded(surfaceId: UUID, relayPort: Int?) -> Bool { + guard let relayPort, + relayPort > 0, + let cleanupConfiguration = transferredRemoteCleanupConfigurationsByPanelId[surfaceId], + cleanupConfiguration.relayPort == relayPort else { + return false + } + transferredRemoteCleanupConfigurationsByPanelId.removeValue(forKey: surfaceId) + Self.requestSSHControlMasterCleanupIfNeeded(configuration: cleanupConfiguration) + return true + } + func markRemoteTerminalSessionEnded(surfaceId: UUID, relayPort: Int?) { + if cleanupTransferredRemoteConnectionIfNeeded(surfaceId: surfaceId, relayPort: relayPort) { + return + } guard let relayPort, relayPort > 0, remoteConfiguration?.relayPort == relayPort else { @@ -8189,6 +8238,9 @@ final class Workspace: Identifiable, ObservableObject { func detachSurface(panelId: UUID) -> DetachedSurfaceTransfer? { guard let tabId = surfaceIdFromPanelId(panelId) else { return nil } guard panels[panelId] != nil else { return nil } + let shouldSkipControlMasterCleanupAfterDetach = + activeRemoteTerminalSurfaceIds.contains(panelId) + && activeRemoteTerminalSurfaceIds.count == 1 #if DEBUG let detachStart = ProcessInfo.processInfo.systemUptime dlog( @@ -8215,7 +8267,13 @@ final class Workspace: Identifiable, ObservableObject { return nil } - let detached = pendingDetachedSurfaces.removeValue(forKey: tabId) + var detached = pendingDetachedSurfaces.removeValue(forKey: tabId) + if shouldSkipControlMasterCleanupAfterDetach, let detachedTransfer = detached, detachedTransfer.isRemoteTerminal { + skipControlMasterCleanupAfterDetachedRemoteTransfer = true + if detachedTransfer.remoteCleanupConfiguration == nil { + detached = detachedTransfer.withRemoteCleanupConfiguration(remoteConfiguration) + } + } #if DEBUG dlog( "split.detach.end ws=\(id.uuidString.prefix(5)) panel=\(panelId.uuidString.prefix(5)) " + @@ -8330,11 +8388,21 @@ final class Workspace: Identifiable, ObservableObject { } surfaceIdToPanelId[newTabId] = detached.panelId - if detached.isRemoteTerminal, - let detachedRelayPort = detached.remoteRelayPort, - detachedRelayPort == remoteConfiguration?.relayPort { + let didAdoptWorkspaceRemoteTracking = + detached.isRemoteTerminal + && detached.remoteRelayPort == remoteConfiguration?.relayPort + if didAdoptWorkspaceRemoteTracking { trackRemoteTerminalSurface(detached.panelId) } + if let cleanupConfiguration = detached.remoteCleanupConfiguration { + if didAdoptWorkspaceRemoteTracking { + transferredRemoteCleanupConfigurationsByPanelId.removeValue(forKey: detached.panelId) + } else { + transferredRemoteCleanupConfigurationsByPanelId[detached.panelId] = cleanupConfiguration + } + } else { + transferredRemoteCleanupConfigurationsByPanelId.removeValue(forKey: detached.panelId) + } if let index { _ = bonsplitController.reorderTab(newTabId, toIndex: index) } @@ -10171,6 +10239,7 @@ extension Workspace: BonsplitDelegate { #endif let panel = panels[panelId] + let transferredRemoteCleanupConfiguration = transferredRemoteCleanupConfigurationsByPanelId.removeValue(forKey: panelId) if isDetaching, let panel { let browserPanel = panel as? BrowserPanel @@ -10193,7 +10262,8 @@ extension Workspace: BonsplitDelegate { isRemoteTerminal: activeRemoteTerminalSurfaceIds.contains(panelId), remoteRelayPort: activeRemoteTerminalSurfaceIds.contains(panelId) ? remoteConfiguration?.relayPort - : nil + : nil, + remoteCleanupConfiguration: transferredRemoteCleanupConfiguration ) } else { if let closedBrowserRestoreSnapshot { @@ -10224,6 +10294,9 @@ extension Workspace: BonsplitDelegate { lastTerminalConfigInheritancePanelId = nil } clearRemoteConfigurationIfWorkspaceBecameLocal() + if !isDetaching, let transferredRemoteCleanupConfiguration { + Self.requestSSHControlMasterCleanupIfNeeded(configuration: transferredRemoteCleanupConfiguration) + } AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: id, surfaceId: panelId) // Keep the workspace invariant for normal close paths. diff --git a/cmuxTests/WorkspaceRemoteConnectionTests.swift b/cmuxTests/WorkspaceRemoteConnectionTests.swift index 6ff1dfc9..51ebaecd 100644 --- a/cmuxTests/WorkspaceRemoteConnectionTests.swift +++ b/cmuxTests/WorkspaceRemoteConnectionTests.swift @@ -399,6 +399,62 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { ) } + @MainActor + func testClosingRemoteWorkspaceRequestsControlMasterCleanup() throws { + let manager = TabManager() + let remainingWorkspace = try XCTUnwrap(manager.selectedWorkspace) + let remoteWorkspace = manager.addWorkspace() + let config = WorkspaceRemoteConfiguration( + destination: "cmux-macmini", + port: 2222, + identityFile: "/Users/test/.ssh/id_ed25519", + sshOptions: [ + "ControlMaster=auto", + "ControlPersist=600", + "ControlPath=/tmp/cmux-ssh-%C", + "StrictHostKeyChecking=accept-new", + ], + localProxyPort: nil, + relayPort: 64018, + relayID: String(repeating: "a", count: 16), + relayToken: String(repeating: "b", count: 64), + localSocketPath: "/tmp/cmux-debug-test.sock", + terminalStartupCommand: "ssh cmux-macmini" + ) + let cleanupRequested = expectation(description: "control master cleanup requested") + var capturedArguments: [String] = [] + + Workspace.runSSHControlMasterCommandOverrideForTesting = { arguments in + capturedArguments = arguments + cleanupRequested.fulfill() + } + defer { Workspace.runSSHControlMasterCommandOverrideForTesting = nil } + + remoteWorkspace.configureRemoteConnection(config, autoConnect: false) + + manager.closeWorkspace(remoteWorkspace) + + wait(for: [cleanupRequested], timeout: 1.0) + + XCTAssertEqual(manager.tabs.count, 1) + XCTAssertEqual(manager.tabs.first?.id, remainingWorkspace.id) + XCTAssertFalse(manager.tabs.contains(where: { $0.id == remoteWorkspace.id })) + XCTAssertFalse(remoteWorkspace.isRemoteWorkspace) + XCTAssertEqual( + capturedArguments, + [ + "-o", "BatchMode=yes", + "-o", "ControlMaster=no", + "-p", "2222", + "-i", "/Users/test/.ssh/id_ed25519", + "-o", "ControlPath=/tmp/cmux-ssh-%C", + "-o", "StrictHostKeyChecking=accept-new", + "-O", "exit", + "cmux-macmini", + ] + ) + } + @MainActor func testDetachLastRemoteSurfacePreservesRemoteSessionWithoutCleanup() throws { let workspace = Workspace() @@ -432,7 +488,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { let panelID = try XCTUnwrap(workspace.focusedTerminalPanel?.id) let detached = try XCTUnwrap(workspace.detachSurface(panelId: panelID)) - wait(for: [cleanupRequested], timeout: 0.2) + wait(for: [cleanupRequested], timeout: 1.0) XCTAssertTrue(detached.isRemoteTerminal) XCTAssertTrue(workspace.isRemoteWorkspace) @@ -446,6 +502,169 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { XCTAssertTrue(workspace.isRemoteTerminalSurface(detached.panelId)) } + @MainActor + func testClosingSourceWorkspaceAfterDetachingRemoteSurfaceSkipsControlMasterCleanup() throws { + let manager = TabManager() + let sourceWorkspace = try XCTUnwrap(manager.selectedWorkspace) + let destinationWorkspace = manager.addWorkspace() + let config = WorkspaceRemoteConfiguration( + destination: "cmux-macmini", + port: nil, + identityFile: nil, + sshOptions: [ + "ControlMaster=auto", + "ControlPersist=600", + "ControlPath=/tmp/cmux-ssh-%C", + ], + localProxyPort: nil, + relayPort: 64017, + relayID: String(repeating: "a", count: 16), + relayToken: String(repeating: "b", count: 64), + localSocketPath: "/tmp/cmux-debug-test.sock", + terminalStartupCommand: "ssh cmux-macmini" + ) + let cleanupRequested = expectation(description: "control master cleanup requested") + cleanupRequested.isInverted = true + + Workspace.runSSHControlMasterCommandOverrideForTesting = { _ in + cleanupRequested.fulfill() + } + defer { Workspace.runSSHControlMasterCommandOverrideForTesting = nil } + + sourceWorkspace.configureRemoteConnection(config, autoConnect: false) + + let panelID = try XCTUnwrap(sourceWorkspace.focusedTerminalPanel?.id) + let detached = try XCTUnwrap(sourceWorkspace.detachSurface(panelId: panelID)) + let destinationPaneID = try XCTUnwrap(destinationWorkspace.bonsplitController.allPaneIds.first) + + let restoredPanelID = destinationWorkspace.attachDetachedSurface( + detached, + inPane: destinationPaneID, + focus: false + ) + + XCTAssertNotNil(restoredPanelID) + XCTAssertTrue(destinationWorkspace.panels.keys.contains(detached.panelId)) + XCTAssertTrue(sourceWorkspace.panels.isEmpty) + + manager.closeWorkspace(sourceWorkspace) + + wait(for: [cleanupRequested], timeout: 1.0) + + XCTAssertFalse(manager.tabs.contains(where: { $0.id == sourceWorkspace.id })) + XCTAssertTrue(destinationWorkspace.panels.keys.contains(detached.panelId)) + } + + @MainActor + func testClosingMixedSourceWorkspaceAfterDetachingLastRemoteSurfaceSkipsControlMasterCleanup() throws { + let manager = TabManager() + let sourceWorkspace = try XCTUnwrap(manager.selectedWorkspace) + let destinationWorkspace = manager.addWorkspace() + let sourcePaneID = try XCTUnwrap(sourceWorkspace.bonsplitController.allPaneIds.first) + let config = WorkspaceRemoteConfiguration( + destination: "cmux-macmini", + port: nil, + identityFile: nil, + sshOptions: [ + "ControlMaster=auto", + "ControlPersist=600", + "ControlPath=/tmp/cmux-ssh-%C", + ], + localProxyPort: nil, + relayPort: 64018, + relayID: String(repeating: "a", count: 16), + relayToken: String(repeating: "b", count: 64), + localSocketPath: "/tmp/cmux-debug-test.sock", + terminalStartupCommand: "ssh cmux-macmini" + ) + let cleanupRequested = expectation(description: "control master cleanup requested") + cleanupRequested.isInverted = true + + Workspace.runSSHControlMasterCommandOverrideForTesting = { _ in + cleanupRequested.fulfill() + } + defer { Workspace.runSSHControlMasterCommandOverrideForTesting = nil } + + sourceWorkspace.configureRemoteConnection(config, autoConnect: false) + _ = sourceWorkspace.newBrowserSurface(inPane: sourcePaneID, url: URL(string: "https://example.com"), focus: false) + + let panelID = try XCTUnwrap(sourceWorkspace.focusedTerminalPanel?.id) + let detached = try XCTUnwrap(sourceWorkspace.detachSurface(panelId: panelID)) + let destinationPaneID = try XCTUnwrap(destinationWorkspace.bonsplitController.allPaneIds.first) + + let restoredPanelID = destinationWorkspace.attachDetachedSurface( + detached, + inPane: destinationPaneID, + focus: false + ) + + XCTAssertNotNil(restoredPanelID) + XCTAssertEqual(sourceWorkspace.panels.count, 1) + XCTAssertTrue(destinationWorkspace.panels.keys.contains(detached.panelId)) + + manager.closeWorkspace(sourceWorkspace) + + wait(for: [cleanupRequested], timeout: 1.0) + + XCTAssertFalse(manager.tabs.contains(where: { $0.id == sourceWorkspace.id })) + XCTAssertTrue(destinationWorkspace.panels.keys.contains(detached.panelId)) + } + + @MainActor + func testTransferredRemoteSurfaceCleansUpControlMasterWhenSessionEndsInLocalWorkspace() throws { + let manager = TabManager() + let sourceWorkspace = try XCTUnwrap(manager.selectedWorkspace) + let destinationWorkspace = manager.addWorkspace() + let config = WorkspaceRemoteConfiguration( + destination: "cmux-macmini", + port: nil, + identityFile: nil, + sshOptions: [ + "ControlMaster=auto", + "ControlPersist=600", + "ControlPath=/tmp/cmux-ssh-%C", + ], + localProxyPort: nil, + relayPort: 64019, + relayID: String(repeating: "a", count: 16), + relayToken: String(repeating: "b", count: 64), + localSocketPath: "/tmp/cmux-debug-test.sock", + terminalStartupCommand: "ssh cmux-macmini" + ) + let cleanupRequested = expectation(description: "control master cleanup requested") + var cleanupArguments: [[String]] = [] + + Workspace.runSSHControlMasterCommandOverrideForTesting = { arguments in + cleanupArguments.append(arguments) + cleanupRequested.fulfill() + } + defer { Workspace.runSSHControlMasterCommandOverrideForTesting = nil } + + sourceWorkspace.configureRemoteConnection(config, autoConnect: false) + + let panelID = try XCTUnwrap(sourceWorkspace.focusedTerminalPanel?.id) + let detached = try XCTUnwrap(sourceWorkspace.detachSurface(panelId: panelID)) + let destinationPaneID = try XCTUnwrap(destinationWorkspace.bonsplitController.allPaneIds.first) + + let restoredPanelID = destinationWorkspace.attachDetachedSurface( + detached, + inPane: destinationPaneID, + focus: false + ) + + XCTAssertNotNil(restoredPanelID) + XCTAssertFalse(destinationWorkspace.isRemoteWorkspace) + XCTAssertEqual(destinationWorkspace.activeRemoteTerminalSessionCount, 0) + + manager.closeWorkspace(sourceWorkspace) + destinationWorkspace.markRemoteTerminalSessionEnded(surfaceId: detached.panelId, relayPort: config.relayPort) + + wait(for: [cleanupRequested], timeout: 1.0) + + XCTAssertEqual(cleanupArguments.count, 1) + XCTAssertEqual(cleanupArguments.first?.suffix(2), ["exit", "cmux-macmini"]) + } + @MainActor func testRemoteTerminalSessionEndSkipsControlMasterCleanupWhenBrowserPanelsKeepWorkspaceRemote() throws { let workspace = Workspace() @@ -480,7 +699,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { workspace.markRemoteTerminalSessionEnded(surfaceId: initialTerminalID, relayPort: 64013) - wait(for: [cleanupRequested], timeout: 0.2) + wait(for: [cleanupRequested], timeout: 1.0) XCTAssertTrue(workspace.isRemoteWorkspace) XCTAssertEqual(workspace.activeRemoteTerminalSessionCount, 0) @@ -1246,6 +1465,140 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { ) } + @MainActor + func testSSHCommandCreatesConfiguresAndSelectsRemoteWorkspaceViaCLI() throws { + let cliPath = try bundledCLIPath() + let socketPath = makeSocketPath("ssh") + let listenerFD = try bindUnixSocket(at: socketPath) + let state = MockSocketServerState() + let workspaceID = "11111111-1111-1111-1111-111111111111" + let workspaceRef = "workspace:7" + let windowID = "22222222-2222-2222-2222-222222222222" + + defer { + Darwin.close(listenerFD) + unlink(socketPath) + } + + let serverHandled = startMockServer(listenerFD: listenerFD, state: state) { line in + guard let data = line.data(using: .utf8), + let payload = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], + let id = payload["id"] as? String, + let method = payload["method"] as? String else { + return self.v2Response( + id: "unknown", + ok: false, + error: ["code": "unexpected", "message": "Unexpected payload"] + ) + } + + switch method { + case "workspace.create": + return self.v2Response( + id: id, + ok: true, + result: [ + "workspace_id": workspaceID, + "window_id": windowID, + ] + ) + 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: [ + "ssh", + "--name", "SSH Workspace", + "--port", "2222", + "--identity", "/Users/test/.ssh/id_ed25519", + "--ssh-option", "ControlPath=/tmp/cmux-ssh-%C", + "--ssh-option", "StrictHostKeyChecking=accept-new", + "cmux-macmini", + ], + environment: environment, + timeout: 5 + ) + + wait(for: [serverHandled], timeout: 5) + + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + XCTAssertEqual(result.stdout, "OK workspace=\(workspaceRef) target=cmux-macmini state=connecting\n") + 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]) + } + XCTAssertEqual( + requests.compactMap { $0["method"] as? String }, + ["workspace.create", "workspace.rename", "workspace.remote.configure", "workspace.select"] + ) + + let createParams = try XCTUnwrap(requests[0]["params"] as? [String: Any]) + let initialCommand = try XCTUnwrap(createParams["initial_command"] as? String) + XCTAssertFalse(initialCommand.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + + let renameParams = try XCTUnwrap(requests[1]["params"] as? [String: Any]) + XCTAssertEqual(renameParams["workspace_id"] as? String, workspaceID) + XCTAssertEqual(renameParams["title"] as? String, "SSH Workspace") + + let configureParams = try XCTUnwrap(requests[2]["params"] as? [String: Any]) + XCTAssertEqual(configureParams["workspace_id"] as? String, workspaceID) + XCTAssertEqual(configureParams["destination"] as? String, "cmux-macmini") + XCTAssertEqual(configureParams["port"] as? Int, 2222) + XCTAssertEqual(configureParams["identity_file"] as? String, "/Users/test/.ssh/id_ed25519") + XCTAssertEqual(configureParams["local_socket_path"] as? String, socketPath) + XCTAssertEqual(configureParams["auto_connect"] as? Bool, true) + let relayPort = try XCTUnwrap(configureParams["relay_port"] as? Int) + XCTAssertGreaterThan(relayPort, 0) + let relayID = try XCTUnwrap(configureParams["relay_id"] as? String) + XCTAssertFalse(relayID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + let relayToken = try XCTUnwrap(configureParams["relay_token"] as? String) + XCTAssertEqual(relayToken.count, 64) + let terminalStartupCommand = try XCTUnwrap(configureParams["terminal_startup_command"] as? String) + XCTAssertFalse(terminalStartupCommand.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + let sshOptions = try XCTUnwrap(configureParams["ssh_options"] as? [String]) + XCTAssertTrue(sshOptions.contains("ControlMaster=auto")) + XCTAssertTrue(sshOptions.contains("ControlPersist=600")) + XCTAssertTrue(sshOptions.contains("ControlPath=/tmp/cmux-ssh-%C")) + XCTAssertTrue(sshOptions.contains("StrictHostKeyChecking=accept-new")) + + // `cmux ssh` should land the user in the new SSH workspace immediately. + let selectParams = try XCTUnwrap(requests[3]["params"] as? [String: Any]) + XCTAssertEqual(selectParams["workspace_id"] as? String, workspaceID) + XCTAssertEqual(selectParams["window_id"] as? String, windowID) + } + @MainActor func testNotifyPrefersCallerTTYOverFocusedSurfaceWhenCallerIDsAreStale() throws { let cliPath = try bundledCLIPath()