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 <lawrencecchen@users.noreply.github.com>
This commit is contained in:
Lawrence Chen 2026-03-25 17:51:56 -07:00 committed by GitHub
parent 58dc932248
commit 0a1d8c2289
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 437 additions and 8 deletions

View file

@ -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(

View file

@ -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<TabID> = []
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.

View file

@ -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()