cmux/cmuxTests/WorkspaceRemoteConnectionTests.swift
Lawrence Chen c2065b135d
Fix SSH image transfer cleanup and IPv6 followups (#1907)
* Add SSH image transfer followup regressions

* Fix SSH image transfer ownership and IPv6 followups

---------

Co-authored-by: Lawrence Chen <lawrencecchen@users.noreply.github.com>
2026-03-20 22:04:52 -07:00

584 lines
23 KiB
Swift

import XCTest
#if canImport(cmux)
@testable import cmux
#elseif canImport(cmux_DEV)
@testable import cmux_DEV
#endif
final class WorkspaceRemoteConnectionTests: XCTestCase {
private struct ProcessRunResult {
let status: Int32
let stdout: String
let stderr: String
let timedOut: Bool
}
private func runProcess(
executablePath: String,
arguments: [String],
timeout: TimeInterval
) -> ProcessRunResult {
let process = Process()
let stdoutPipe = Pipe()
let stderrPipe = Pipe()
process.executableURL = URL(fileURLWithPath: executablePath)
process.arguments = arguments
process.standardInput = FileHandle.nullDevice
process.standardOutput = stdoutPipe
process.standardError = stderrPipe
do {
try process.run()
} catch {
return ProcessRunResult(
status: -1,
stdout: "",
stderr: String(describing: error),
timedOut: false
)
}
let exitSignal = DispatchSemaphore(value: 0)
DispatchQueue.global(qos: .userInitiated).async {
process.waitUntilExit()
exitSignal.signal()
}
let timedOut = exitSignal.wait(timeout: .now() + timeout) == .timedOut
if timedOut {
process.terminate()
_ = exitSignal.wait(timeout: .now() + 1)
}
let stdout = String(data: stdoutPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
let stderr = String(data: stderrPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
return ProcessRunResult(
status: process.terminationStatus,
stdout: stdout,
stderr: stderr,
timedOut: timedOut
)
}
private func writeShellFile(at url: URL, lines: [String]) throws {
try lines.joined(separator: "\n")
.appending("\n")
.write(to: url, atomically: true, encoding: .utf8)
}
private func runRelayZshHistfile(
configureUserHome: (URL) throws -> URL
) throws -> String {
let fileManager = FileManager.default
let home = fileManager.temporaryDirectory.appendingPathComponent("cmux-relay-zsh-\(UUID().uuidString)")
let relayDir = home.appendingPathComponent(".cmux/relay/64011.shell")
try fileManager.createDirectory(at: relayDir, withIntermediateDirectories: true)
defer { try? fileManager.removeItem(at: home) }
let effectiveUserZdotdir = try configureUserHome(home)
let bootstrap = RemoteRelayZshBootstrap(shellStateDir: relayDir.path)
try writeShellFile(at: relayDir.appendingPathComponent(".zshenv"), lines: bootstrap.zshEnvLines)
try writeShellFile(at: relayDir.appendingPathComponent(".zprofile"), lines: bootstrap.zshProfileLines)
try writeShellFile(at: relayDir.appendingPathComponent(".zshrc"), lines: bootstrap.zshRCLines(commonShellLines: []))
try writeShellFile(at: relayDir.appendingPathComponent(".zlogin"), lines: bootstrap.zshLoginLines)
let result = runProcess(
executablePath: "/usr/bin/env",
arguments: [
"HOME=\(home.path)",
"TERM=xterm-256color",
"SHELL=/bin/zsh",
"USER=\(NSUserName())",
"CMUX_REAL_ZDOTDIR=\(home.path)",
"ZDOTDIR=\(relayDir.path)",
"/bin/zsh",
"-ilc",
"print -r -- \"$HISTFILE\"",
],
timeout: 5
)
XCTAssertFalse(result.timedOut, result.stderr)
XCTAssertEqual(result.status, 0, result.stderr)
let histfile = result.stdout
.split(separator: "\n")
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.last(where: { !$0.isEmpty })
XCTAssertEqual(histfile, effectiveUserZdotdir.appendingPathComponent(".zsh_history").path)
return histfile ?? ""
}
func testRemoteRelayMetadataCleanupScriptRemovesMatchingSocketAddr() {
let fileManager = FileManager.default
let home = fileManager.temporaryDirectory.appendingPathComponent("cmux-relay-cleanup-\(UUID().uuidString)")
let relayDir = home.appendingPathComponent(".cmux/relay")
let socketAddrURL = home.appendingPathComponent(".cmux/socket_addr")
let authURL = relayDir.appendingPathComponent("64008.auth")
let daemonPathURL = relayDir.appendingPathComponent("64008.daemon_path")
XCTAssertNoThrow(try fileManager.createDirectory(at: relayDir, withIntermediateDirectories: true))
XCTAssertNoThrow(try "127.0.0.1:64008".write(to: socketAddrURL, atomically: true, encoding: .utf8))
XCTAssertNoThrow(try "auth".write(to: authURL, atomically: true, encoding: .utf8))
XCTAssertNoThrow(try "daemon".write(to: daemonPathURL, atomically: true, encoding: .utf8))
defer { try? fileManager.removeItem(at: home) }
let result = runProcess(
executablePath: "/usr/bin/env",
arguments: [
"HOME=\(home.path)",
"/bin/sh",
"-c",
WorkspaceRemoteSessionController.remoteRelayMetadataCleanupScript(relayPort: 64008),
],
timeout: 5
)
XCTAssertFalse(result.timedOut, result.stderr)
XCTAssertEqual(result.status, 0, result.stderr)
XCTAssertFalse(fileManager.fileExists(atPath: socketAddrURL.path))
XCTAssertFalse(fileManager.fileExists(atPath: authURL.path))
XCTAssertFalse(fileManager.fileExists(atPath: daemonPathURL.path))
}
func testRemoteRelayMetadataCleanupScriptPreservesDifferentSocketAddr() {
let fileManager = FileManager.default
let home = fileManager.temporaryDirectory.appendingPathComponent("cmux-relay-cleanup-preserve-\(UUID().uuidString)")
let relayDir = home.appendingPathComponent(".cmux/relay")
let socketAddrURL = home.appendingPathComponent(".cmux/socket_addr")
let authURL = relayDir.appendingPathComponent("64009.auth")
let daemonPathURL = relayDir.appendingPathComponent("64009.daemon_path")
XCTAssertNoThrow(try fileManager.createDirectory(at: relayDir, withIntermediateDirectories: true))
XCTAssertNoThrow(try "127.0.0.1:64010".write(to: socketAddrURL, atomically: true, encoding: .utf8))
XCTAssertNoThrow(try "auth".write(to: authURL, atomically: true, encoding: .utf8))
XCTAssertNoThrow(try "daemon".write(to: daemonPathURL, atomically: true, encoding: .utf8))
defer { try? fileManager.removeItem(at: home) }
let result = runProcess(
executablePath: "/usr/bin/env",
arguments: [
"HOME=\(home.path)",
"/bin/sh",
"-c",
WorkspaceRemoteSessionController.remoteRelayMetadataCleanupScript(relayPort: 64009),
],
timeout: 5
)
XCTAssertFalse(result.timedOut, result.stderr)
XCTAssertEqual(result.status, 0, result.stderr)
XCTAssertTrue(fileManager.fileExists(atPath: socketAddrURL.path))
XCTAssertFalse(fileManager.fileExists(atPath: authURL.path))
XCTAssertFalse(fileManager.fileExists(atPath: daemonPathURL.path))
}
func testRelayZshBootstrapUsesRealHomeHistoryByDefault() throws {
let histfile = try runRelayZshHistfile { home in
try ":\n".write(to: home.appendingPathComponent(".zshenv"), atomically: true, encoding: .utf8)
try ":\n".write(to: home.appendingPathComponent(".zshrc"), atomically: true, encoding: .utf8)
return home
}
XCTAssertTrue(histfile.hasSuffix("/.zsh_history"))
}
func testRelayZshBootstrapUsesUserUpdatedZdotdirHistory() throws {
let histfile = try runRelayZshHistfile { home in
let altZdotdir = home.appendingPathComponent("dotfiles")
try FileManager.default.createDirectory(at: altZdotdir, withIntermediateDirectories: true)
try "export ZDOTDIR=\"$HOME/dotfiles\"\n".write(
to: home.appendingPathComponent(".zshenv"),
atomically: true,
encoding: .utf8
)
try ":\n".write(to: altZdotdir.appendingPathComponent(".zshrc"), atomically: true, encoding: .utf8)
return altZdotdir
}
XCTAssertTrue(histfile.contains("/dotfiles/.zsh_history"))
}
func testReverseRelayStartupFailureDetailCapturesImmediateForwardingFailure() throws {
let process = Process()
let stderrPipe = Pipe()
process.executableURL = URL(fileURLWithPath: "/bin/sh")
process.arguments = ["-c", "echo 'remote port forwarding failed for listen port 64009' >&2; exit 1"]
process.standardInput = FileHandle.nullDevice
process.standardOutput = FileHandle.nullDevice
process.standardError = stderrPipe
try process.run()
let detail = WorkspaceRemoteSessionController.reverseRelayStartupFailureDetail(
process: process,
stderrPipe: stderrPipe,
gracePeriod: 1.0
)
XCTAssertEqual(detail, "remote port forwarding failed for listen port 64009")
}
@MainActor
func testRemoteTerminalSurfaceLookupTracksOnlyActiveSSHSurfaces() throws {
let workspace = Workspace()
let config = WorkspaceRemoteConfiguration(
destination: "cmux-macmini",
port: nil,
identityFile: nil,
sshOptions: [],
localProxyPort: nil,
relayPort: 64007,
relayID: String(repeating: "a", count: 16),
relayToken: String(repeating: "b", count: 64),
localSocketPath: "/tmp/cmux-debug-test.sock",
terminalStartupCommand: "ssh cmux-macmini"
)
workspace.configureRemoteConnection(config, autoConnect: false)
let panelID = try XCTUnwrap(workspace.focusedTerminalPanel?.id)
XCTAssertTrue(workspace.isRemoteTerminalSurface(panelID))
workspace.markRemoteTerminalSessionEnded(surfaceId: panelID, relayPort: 64007)
XCTAssertFalse(workspace.isRemoteTerminalSurface(panelID))
}
func testRemoteDropPathUsesLowercasedExtensionAndProvidedUUID() throws {
let fileURL = URL(fileURLWithPath: "/Users/test/Screen Shot.PNG")
let uuid = try XCTUnwrap(UUID(uuidString: "12345678-1234-1234-1234-1234567890AB"))
let remotePath = WorkspaceRemoteSessionController.remoteDropPath(for: fileURL, uuid: uuid)
XCTAssertEqual(remotePath, "/tmp/cmux-drop-12345678-1234-1234-1234-1234567890ab.png")
}
@MainActor
func testDetachAttachPreservesRemoteTerminalSurfaceTracking() throws {
let workspace = Workspace()
let config = WorkspaceRemoteConfiguration(
destination: "cmux-macmini",
port: nil,
identityFile: nil,
sshOptions: [],
localProxyPort: nil,
relayPort: 64007,
relayID: String(repeating: "a", count: 16),
relayToken: String(repeating: "b", count: 64),
localSocketPath: "/tmp/cmux-debug-test.sock",
terminalStartupCommand: "ssh cmux-macmini"
)
workspace.configureRemoteConnection(config, autoConnect: false)
let originalPanelID = try XCTUnwrap(workspace.focusedTerminalPanel?.id)
let originalPaneID = try XCTUnwrap(workspace.paneId(forPanelId: originalPanelID))
let movedPanel = try XCTUnwrap(
workspace.newTerminalSplit(from: originalPanelID, orientation: .horizontal)
)
XCTAssertTrue(workspace.isRemoteTerminalSurface(originalPanelID))
XCTAssertTrue(workspace.isRemoteTerminalSurface(movedPanel.id))
let detached = try XCTUnwrap(workspace.detachSurface(panelId: movedPanel.id))
XCTAssertTrue(detached.isRemoteTerminal)
XCTAssertEqual(detached.remoteRelayPort, config.relayPort)
let restoredPanelID = workspace.attachDetachedSurface(
detached,
inPane: originalPaneID,
focus: false
)
XCTAssertEqual(restoredPanelID, movedPanel.id)
XCTAssertTrue(workspace.isRemoteTerminalSurface(movedPanel.id))
}
@MainActor
func testDetachAttachPreservesSurfaceTTYMetadata() throws {
let source = Workspace()
let destination = Workspace()
let panelID = try XCTUnwrap(source.focusedTerminalPanel?.id)
let sourcePaneID = try XCTUnwrap(source.paneId(forPanelId: panelID))
let destinationPaneID = try XCTUnwrap(destination.bonsplitController.allPaneIds.first)
source.surfaceTTYNames[panelID] = "/dev/ttys004"
let detached = try XCTUnwrap(source.detachSurface(panelId: panelID))
XCTAssertEqual(source.surfaceTTYNames[panelID], nil)
let restoredPanelID = destination.attachDetachedSurface(
detached,
inPane: destinationPaneID,
focus: false
)
XCTAssertEqual(restoredPanelID, panelID)
XCTAssertEqual(destination.surfaceTTYNames[panelID], "/dev/ttys004")
XCTAssertEqual(source.bonsplitController.tabs(inPane: sourcePaneID).count, 0)
}
func testDetectedSSHUploadFailureCleansUpEarlierRemoteUploads() throws {
let fileManager = FileManager.default
let directoryURL = fileManager.temporaryDirectory.appendingPathComponent(
"cmux-detected-ssh-upload-\(UUID().uuidString)",
isDirectory: true
)
try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true)
defer { try? fileManager.removeItem(at: directoryURL) }
let firstFileURL = directoryURL.appendingPathComponent("first.png")
let secondFileURL = directoryURL.appendingPathComponent("second.png")
try Data("first".utf8).write(to: firstFileURL)
try Data("second".utf8).write(to: secondFileURL)
let session = DetectedSSHSession(
destination: "lawrence@example.com",
port: 2200,
identityFile: "/Users/test/.ssh/id_ed25519",
configFile: nil,
jumpHost: nil,
controlPath: nil,
useIPv4: false,
useIPv6: false,
forwardAgent: false,
compressionEnabled: false,
sshOptions: []
)
var invocations: [(executable: String, arguments: [String])] = []
var scpInvocationCount = 0
DetectedSSHSession.runProcessOverrideForTesting = { executable, arguments, _, _ in
invocations.append((executable, arguments))
if executable == "/usr/bin/scp" {
scpInvocationCount += 1
if scpInvocationCount == 1 {
return (status: 0, stdout: "", stderr: "")
}
return (status: 1, stdout: "", stderr: "copy failed")
}
if executable == "/usr/bin/ssh" {
return (status: 0, stdout: "", stderr: "")
}
XCTFail("unexpected executable \(executable)")
return (status: 1, stdout: "", stderr: "unexpected executable")
}
defer { DetectedSSHSession.runProcessOverrideForTesting = nil }
XCTAssertThrowsError(
try session.uploadDroppedFilesSyncForTesting([firstFileURL, secondFileURL])
)
let firstSCPDestination = try XCTUnwrap(
invocations
.first(where: { $0.executable == "/usr/bin/scp" })?
.arguments
.last
)
let uploadedRemotePath = try XCTUnwrap(firstSCPDestination.split(separator: ":", maxSplits: 1).last)
let cleanupInvocation = try XCTUnwrap(
invocations.first(where: { $0.executable == "/usr/bin/ssh" })
)
let cleanupCommand = cleanupInvocation.arguments.joined(separator: " ")
XCTAssertTrue(cleanupCommand.contains(String(uploadedRemotePath)))
}
func testDetectsForegroundSSHSessionForTTY() {
let session = TerminalSSHSessionDetector.detectForTesting(
ttyName: "/dev/ttys004",
processes: [
.init(pid: 2145, pgid: 1967, tpgid: 1967, tty: "ttys004", executableName: "ssh"),
],
argumentsByPID: [
2145: [
"ssh",
"-o", "ControlMaster=auto",
"-o", "ControlPath=/tmp/cmux-ssh-%C",
"-o", "StrictHostKeyChecking=accept-new",
"-p", "2200",
"-i", "/Users/test/.ssh/id_ed25519",
"lawrence@example.com",
],
]
)
XCTAssertEqual(
session,
DetectedSSHSession(
destination: "lawrence@example.com",
port: 2200,
identityFile: "/Users/test/.ssh/id_ed25519",
configFile: nil,
jumpHost: nil,
controlPath: "/tmp/cmux-ssh-%C",
useIPv4: false,
useIPv6: false,
forwardAgent: false,
compressionEnabled: false,
sshOptions: [
"StrictHostKeyChecking=accept-new",
]
)
)
}
func testDetectsForegroundSSHSessionWithShortControlPathFlag() {
let session = TerminalSSHSessionDetector.detectForTesting(
ttyName: "/dev/ttys004",
processes: [
.init(pid: 2145, pgid: 1967, tpgid: 1967, tty: "ttys004", executableName: "ssh"),
],
argumentsByPID: [
2145: [
"ssh",
"-S", "/tmp/cmux-ssh-%C",
"-p", "2200",
"lawrence@example.com",
],
]
)
XCTAssertEqual(session?.controlPath, "/tmp/cmux-ssh-%C")
let scpArgs = session?.scpArgumentsForTesting(
localPath: "/tmp/local.png",
remotePath: "/tmp/cmux-drop-123.png"
) ?? []
XCTAssertTrue(scpArgs.contains("ControlPath=/tmp/cmux-ssh-%C"))
XCTAssertFalse(scpArgs.contains("-S"))
}
func testDetectedSSHSessionBracketsIPv6LiteralSCPDestination() {
let session = DetectedSSHSession(
destination: "lawrence@2001:db8::1",
port: nil,
identityFile: nil,
configFile: nil,
jumpHost: nil,
controlPath: nil,
useIPv4: false,
useIPv6: false,
forwardAgent: false,
compressionEnabled: false,
sshOptions: []
)
let scpArgs = session.scpArgumentsForTesting(
localPath: "/tmp/local.png",
remotePath: "/tmp/cmux-drop-123.png"
)
XCTAssertEqual(scpArgs.last, "lawrence@[2001:db8::1]:/tmp/cmux-drop-123.png")
}
func testDetectsForegroundSSHSessionWithLowercaseAgentFlag() {
let session = TerminalSSHSessionDetector.detectForTesting(
ttyName: "/dev/ttys004",
processes: [
.init(pid: 2145, pgid: 1967, tpgid: 1967, tty: "ttys004", executableName: "ssh"),
],
argumentsByPID: [
2145: [
"ssh",
"-a",
"lawrence@example.com",
],
]
)
XCTAssertEqual(session?.destination, "lawrence@example.com")
XCTAssertFalse(session?.forwardAgent ?? true)
}
func testDetectsForegroundSSHSessionIgnoringBindInterfaceValue() {
let session = TerminalSSHSessionDetector.detectForTesting(
ttyName: "/dev/ttys004",
processes: [
.init(pid: 2145, pgid: 1967, tpgid: 1967, tty: "ttys004", executableName: "ssh"),
],
argumentsByPID: [
2145: [
"ssh",
"-B", "en0",
"lawrence@example.com",
],
]
)
XCTAssertEqual(session?.destination, "lawrence@example.com")
}
func testIgnoresBackgroundSSHProcessForTTY() {
let session = TerminalSSHSessionDetector.detectForTesting(
ttyName: "ttys004",
processes: [
.init(pid: 2145, pgid: 2145, tpgid: 1967, tty: "ttys004", executableName: "ssh"),
],
argumentsByPID: [
2145: ["ssh", "lawrence@example.com"],
]
)
XCTAssertNil(session)
}
@MainActor
func testProxyOnlyErrorsKeepSSHWorkspaceConnectedAndLoggedInSidebar() {
let workspace = Workspace()
let config = WorkspaceRemoteConfiguration(
destination: "cmux-macmini",
port: nil,
identityFile: nil,
sshOptions: [],
localProxyPort: nil,
relayPort: 64007,
relayID: String(repeating: "a", count: 16),
relayToken: String(repeating: "b", count: 64),
localSocketPath: "/tmp/cmux-debug-test.sock",
terminalStartupCommand: "ssh cmux-macmini"
)
workspace.configureRemoteConnection(config, autoConnect: false)
XCTAssertEqual(workspace.activeRemoteTerminalSessionCount, 1)
let proxyError = "Remote proxy to cmux-macmini unavailable: Failed to start local daemon proxy: daemon RPC timeout waiting for hello response (retry in 3s)"
workspace.applyRemoteConnectionStateUpdate(.error, detail: proxyError, target: "cmux-macmini")
XCTAssertEqual(workspace.remoteConnectionState, .connected)
XCTAssertEqual(workspace.remoteConnectionDetail, proxyError)
XCTAssertEqual(
workspace.statusEntries["remote.error"]?.value,
"Remote proxy unavailable (cmux-macmini): \(proxyError)"
)
XCTAssertEqual(workspace.logEntries.last?.source, "remote-proxy")
XCTAssertEqual(workspace.remoteStatusPayload()["connected"] as? Bool, true)
XCTAssertEqual(
((workspace.remoteStatusPayload()["proxy"] as? [String: Any])?["state"] as? String),
"error"
)
workspace.applyRemoteConnectionStateUpdate(.connecting, detail: "Connecting to cmux-macmini", target: "cmux-macmini")
XCTAssertEqual(workspace.remoteConnectionState, .connected)
XCTAssertEqual(
workspace.statusEntries["remote.error"]?.value,
"Remote proxy unavailable (cmux-macmini): \(proxyError)"
)
workspace.applyRemoteConnectionStateUpdate(
.connected,
detail: "Connected to cmux-macmini via shared local proxy 127.0.0.1:9999",
target: "cmux-macmini"
)
XCTAssertEqual(workspace.remoteConnectionState, .connected)
XCTAssertNil(workspace.statusEntries["remote.error"])
XCTAssertEqual(
((workspace.remoteStatusPayload()["proxy"] as? [String: Any])?["state"] as? String),
"unavailable"
)
}
}