diff --git a/Packages/macOS/CmuxCore/Sources/CmuxCore/Remote/SessionRemoteWorkspaceSnapshot.swift b/Packages/macOS/CmuxCore/Sources/CmuxCore/Remote/SessionRemoteWorkspaceSnapshot.swift index a1abdc4db8dc..5ab83c2402b9 100644 --- a/Packages/macOS/CmuxCore/Sources/CmuxCore/Remote/SessionRemoteWorkspaceSnapshot.swift +++ b/Packages/macOS/CmuxCore/Sources/CmuxCore/Remote/SessionRemoteWorkspaceSnapshot.swift @@ -6,6 +6,9 @@ public import CmuxFoundation /// Wire/persistence shape: field names are encoded by `Codable`; do not rename /// stored properties without a migration. public struct SessionRemoteWorkspaceSnapshot: Codable, Equatable, Sendable { + /// Explicit persistent SSH owner; absent in legacy daemon snapshots. + public var sshSessionOwner: String? = nil + /// The transport the workspace used when the snapshot was taken. public var transport: WorkspaceRemoteTransport /// The interactive terminal protocol, absent in snapshots written before Mosh support. diff --git a/Packages/macOS/CmuxCore/Sources/CmuxCore/Remote/WorkspaceRemoteConfiguration+SSHControlPath.swift b/Packages/macOS/CmuxCore/Sources/CmuxCore/Remote/WorkspaceRemoteConfiguration+SSHControlPath.swift index 586a30e7102a..38c93dcf86ad 100644 --- a/Packages/macOS/CmuxCore/Sources/CmuxCore/Remote/WorkspaceRemoteConfiguration+SSHControlPath.swift +++ b/Packages/macOS/CmuxCore/Sources/CmuxCore/Remote/WorkspaceRemoteConfiguration+SSHControlPath.swift @@ -14,7 +14,7 @@ extension WorkspaceRemoteConfiguration { sshOptions.filter { resolver.optionKey($0) != "controlpath" } - return WorkspaceRemoteConfiguration( + var copy = WorkspaceRemoteConfiguration( transport: transport, terminalTransport: terminalTransport, terminalProfile: terminalProfile, @@ -39,5 +39,7 @@ extension WorkspaceRemoteConfiguration { sshControlMasterLeaseGeneration: sshControlMasterLeaseGeneration ) + copy.restoredSSHSession = restoredSSHSession + return copy } } diff --git a/Packages/macOS/CmuxCore/Sources/CmuxCore/Remote/WorkspaceRemoteConfiguration.swift b/Packages/macOS/CmuxCore/Sources/CmuxCore/Remote/WorkspaceRemoteConfiguration.swift index 28133b190a32..1088a96f0f76 100644 --- a/Packages/macOS/CmuxCore/Sources/CmuxCore/Remote/WorkspaceRemoteConfiguration.swift +++ b/Packages/macOS/CmuxCore/Sources/CmuxCore/Remote/WorkspaceRemoteConfiguration.swift @@ -12,6 +12,10 @@ public struct WorkspaceRemoteConfiguration: Equatable, Sendable { public let transport: WorkspaceRemoteTransport /// Protocol used by the user-facing interactive terminal. public let terminalTransport: WorkspaceRemoteTerminalTransport + /// Original durable descriptor when restoring managed SSH. A legacy owner is + /// retained for recovery, never silently converted into a new TUI session. + public var restoredSSHSession: SessionRemoteWorkspaceSnapshot? = nil + /// Durable program profile opened in the interactive terminal. public let terminalProfile: WorkspaceRemoteTerminalProfile /// Effective host-configured command chained after cmux's interactive bootstrap. @@ -169,7 +173,8 @@ public struct WorkspaceRemoteConfiguration: Equatable, Sendable { /// Compares user-visible connection settings while ignoring the runtime lease generation. public static func == (lhs: Self, rhs: Self) -> Bool { - lhs.transport == rhs.transport && + lhs.restoredSSHSession == rhs.restoredSSHSession && + lhs.transport == rhs.transport && lhs.terminalTransport == rhs.terminalTransport && lhs.terminalProfile == rhs.terminalProfile && lhs.configuredRemoteCommand == rhs.configuredRemoteCommand && @@ -363,7 +368,7 @@ public struct WorkspaceRemoteConfiguration: Equatable, Sendable { /// configuration. Remote CLI bridges use this to reject cross-workspace /// requests before they reach the app control socket. public func scopedToOwnerWorkspace(_ workspaceID: UUID) -> WorkspaceRemoteConfiguration { - WorkspaceRemoteConfiguration( + var copy = WorkspaceRemoteConfiguration( transport: transport, terminalTransport: terminalTransport, terminalProfile: terminalProfile, @@ -387,6 +392,8 @@ public struct WorkspaceRemoteConfiguration: Equatable, Sendable { persistentDaemonSlot: persistentDaemonSlot, skipDaemonBootstrap: skipDaemonBootstrap ) + copy.restoredSSHSession = restoredSSHSession + return copy } /// Returns a copy carrying the broker generation for one native-SSH lease. @@ -396,7 +403,7 @@ public struct WorkspaceRemoteConfiguration: Equatable, Sendable { public func withDaemonWebSocketEndpoint( _ endpoint: WorkspaceRemoteWebSocketDaemonEndpoint? ) -> WorkspaceRemoteConfiguration { - WorkspaceRemoteConfiguration( + var copy = WorkspaceRemoteConfiguration( transport: transport, terminalTransport: terminalTransport, terminalProfile: terminalProfile, @@ -421,10 +428,12 @@ public struct WorkspaceRemoteConfiguration: Equatable, Sendable { skipDaemonBootstrap: skipDaemonBootstrap, sshControlMasterLeaseGeneration: sshControlMasterLeaseGeneration ) + copy.restoredSSHSession = restoredSSHSession + return copy } public func withSSHControlMasterLeaseGeneration(_ generation: UUID) -> WorkspaceRemoteConfiguration { - WorkspaceRemoteConfiguration( + var copy = WorkspaceRemoteConfiguration( transport: transport, terminalTransport: terminalTransport, terminalProfile: terminalProfile, @@ -449,6 +458,8 @@ public struct WorkspaceRemoteConfiguration: Equatable, Sendable { skipDaemonBootstrap: skipDaemonBootstrap, sshControlMasterLeaseGeneration: generation ) + copy.restoredSSHSession = restoredSSHSession + return copy } } @@ -503,9 +514,10 @@ extension WorkspaceRemoteConfiguration { } guard transport == .ssh else { return nil } + if let restoredSSHSession { return restoredSSHSession } let retainsRelayNamespace = preserveAfterTerminalExit || terminalTransport == .mosh - return SessionRemoteWorkspaceSnapshot( + var snapshot = SessionRemoteWorkspaceSnapshot( transport: transport, terminalTransport: terminalTransport, terminalProfile: terminalProfile, @@ -520,5 +532,12 @@ extension WorkspaceRemoteConfiguration { persistentDaemonSlot: preserveAfterTerminalExit ? persistentDaemonSlot : nil, managedCloudVMID: managedCloudVMID ) + // Same rule as the app's routesThroughSSHTui: a configuration carrying a + // cmuxd-remote relay or daemon endpoint runs the legacy lifecycle, so its + // snapshot must not claim cmux-tui ownership. + if terminalTransport == .ssh && !skipDaemonBootstrap && relayPort == nil && daemonWebSocketEndpoint == nil { + snapshot.sshSessionOwner = "cmux-tui" + } + return snapshot } } diff --git a/Sources/RemoteTui/SSHTuiWorkspaceCoordinator.swift b/Sources/RemoteTui/SSHTuiWorkspaceCoordinator.swift index 79e866a333ce..78fd74ddf75b 100644 --- a/Sources/RemoteTui/SSHTuiWorkspaceCoordinator.swift +++ b/Sources/RemoteTui/SSHTuiWorkspaceCoordinator.swift @@ -72,6 +72,9 @@ final class SSHTuiWorkspaceCoordinator { workspace.failReservedCloudTerminalPane(reservation, error: CloudDiagnosticFailure.network) } } + if let saved = configuration.restoredSSHSession, saved.sshSessionOwner != "cmux-tui" { + throw CloudDiagnosticFailure.unsupported + } guard await provider.refreshCurrentGraph(force: false) else { throw CloudMachineLink.LinkError.spawnFailed(provider.info.linkError ?? CloudDiagnosticFailure.network.label) } diff --git a/Sources/RemoteTui/SessionRemoteWorkspaceSnapshot+TuiSSH.swift b/Sources/RemoteTui/SessionRemoteWorkspaceSnapshot+TuiSSH.swift index e5d7c9630661..35ea5772550e 100644 --- a/Sources/RemoteTui/SessionRemoteWorkspaceSnapshot+TuiSSH.swift +++ b/Sources/RemoteTui/SessionRemoteWorkspaceSnapshot+TuiSSH.swift @@ -4,9 +4,9 @@ import Foundation extension SessionRemoteWorkspaceSnapshot { /// Restore the carrier descriptor without reviving a cmuxd-remote launch script. func tuiSSHConfiguration(agentSocketPath: String?) -> WorkspaceRemoteConfiguration? { - guard transport == .ssh, skipDaemonBootstrap != true, + guard sshSessionOwner == "cmux-tui", transport == .ssh, skipDaemonBootstrap != true, (terminalTransport ?? .ssh) == .ssh, preserveAfterTerminalExit == true else { return nil } - return WorkspaceRemoteConfiguration( + var configuration = WorkspaceRemoteConfiguration( terminalProfile: terminalProfile ?? .shell, destination: destination.trimmingCharacters(in: .whitespacesAndNewlines), port: port.flatMap { (1...65535).contains($0) ? $0 : nil }, identityFile: WorkspaceRemoteConfiguration.normalizedIdentityPath(identityFile), @@ -15,5 +15,7 @@ extension SessionRemoteWorkspaceSnapshot { terminalStartupCommand: nil, configuredRemoteCommand: configuredRemoteCommand, agentSocketPath: agentSocketPath, preserveAfterTerminalExit: true ) + configuration.restoredSSHSession = self + return configuration } } diff --git a/Sources/SessionRemoteWorkspaceSnapshot+Restore.swift b/Sources/SessionRemoteWorkspaceSnapshot+Restore.swift index b4bb6ad08d30..de4cbc967f08 100644 --- a/Sources/SessionRemoteWorkspaceSnapshot+Restore.swift +++ b/Sources/SessionRemoteWorkspaceSnapshot+Restore.swift @@ -44,6 +44,17 @@ extension SessionRemoteWorkspaceSnapshot { } if let configuration = tuiSSHConfiguration(agentSocketPath: overrideAgentSocketPath) { return configuration } + if skipDaemonBootstrap != true, (terminalTransport ?? .ssh) == .ssh, + preserveAfterTerminalExit == true { + // Preserve the old descriptor for recovery, but never resume its daemon + // or start a replacement workload under a different session owner. + var configuration = WorkspaceRemoteConfiguration(destination: normalizedDestination, + port: normalizedPort, identityFile: identityFile, sshOptions: sshOptions, + localProxyPort: nil, relayPort: nil, relayID: nil, relayToken: nil, + localSocketPath: nil, terminalStartupCommand: nil, preserveAfterTerminalExit: true) + configuration.restoredSSHSession = self + return configuration + } let normalizedPersistentDaemonSlot = WorkspaceRemoteConfiguration.normalizedPersistentDaemonSlot(persistentDaemonSlot) let normalizedLocalSocketPath = WorkspaceRemoteConfiguration.normalizedOptionalValue(localSocketPath) diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 4e666102c872..c97a82bb5c49 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -2635,7 +2635,6 @@ B9000028A1B2C3D4E5F60719 /* RemoteInteractiveShellBootstrapBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001642 /* RemoteInteractiveShellBootstrapBuilder.swift */; }; D0C0D0C0D0C0D0C0D0C00003 /* RemoteLoopbackRuntimeBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C0D0C0D0C0D0C0D0C00004 /* RemoteLoopbackRuntimeBridge.swift */; }; E30790000000000000000004 /* RemotePTYBridgeStrings+App.swift in Sources */ = {isa = PBXBuildFile; fileRef = E30790000000000000000003 /* RemotePTYBridgeStrings+App.swift */; }; - B11049000000000000000001 /* RemotePTYReconnectLifecycleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B11049000000000000000002 /* RemotePTYReconnectLifecycleTests.swift */; }; B11049000000000000000003 /* RemoteRelayTmuxCompatAuthorizationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B11049000000000000000004 /* RemoteRelayTmuxCompatAuthorizationTests.swift */; }; A5001640 /* RemoteRelayZshBootstrap.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001641 /* RemoteRelayZshBootstrap.swift */; }; B9000027A1B2C3D4E5F60719 /* RemoteRelayZshBootstrap.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001641 /* RemoteRelayZshBootstrap.swift */; }; @@ -6742,7 +6741,6 @@ A5001642 /* RemoteInteractiveShellBootstrapBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteInteractiveShellBootstrapBuilder.swift; sourceTree = ""; }; D0C0D0C0D0C0D0C0D0C00004 /* RemoteLoopbackRuntimeBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteLoopbackRuntimeBridge.swift; sourceTree = ""; }; E30790000000000000000003 /* RemotePTYBridgeStrings+App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RemotePTYBridgeStrings+App.swift"; sourceTree = ""; }; - B11049000000000000000002 /* RemotePTYReconnectLifecycleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemotePTYReconnectLifecycleTests.swift; sourceTree = ""; }; B11049000000000000000004 /* RemoteRelayTmuxCompatAuthorizationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteRelayTmuxCompatAuthorizationTests.swift; sourceTree = ""; }; A5001641 /* RemoteRelayZshBootstrap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteRelayZshBootstrap.swift; sourceTree = ""; }; 7989A0027989A0027989A002 /* RemoteResumeBindingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteResumeBindingTests.swift; sourceTree = ""; }; @@ -12076,7 +12074,6 @@ C0A716940000000000000001 /* ComputerUseCommandPaletteTests.swift */, C0A716900000000000000001 /* ExternalApplicationWindowTrackerTests.swift */, C95FA2E4CBC042628EE67957 /* RemoteClaudeTeamsRespawnRoutingTests.swift */, - B11049000000000000000002 /* RemotePTYReconnectLifecycleTests.swift */, B11049000000000000000004 /* RemoteRelayTmuxCompatAuthorizationTests.swift */, 9492CAFE0000000000000001 /* LastTerminalChildExitRecoveryTests.swift */, C9D9924AC89D7C3408DBC56A /* WorkspaceRecoveryTests.swift */, @@ -16840,7 +16837,6 @@ 791100030000000000000001 /* RemoteDisconnectLifecycleTests.swift in Sources */, 835000000000000000000006 /* RemoteInitialCommandBootstrapFailureTests.swift in Sources */, 835000000000000000000004 /* RemoteInitialCommandBootstrapTests.swift in Sources */, - B11049000000000000000001 /* RemotePTYReconnectLifecycleTests.swift in Sources */, B11049000000000000000003 /* RemoteRelayTmuxCompatAuthorizationTests.swift in Sources */, 7989A0017989A0017989A001 /* RemoteResumeBindingTests.swift in Sources */, REE0CA0000000000000000B2 /* RemotesClientTests.swift in Sources */, diff --git a/cmuxTests/RemotePTYReconnectLifecycleTests.swift b/cmuxTests/RemotePTYReconnectLifecycleTests.swift deleted file mode 100644 index 929033f26d30..000000000000 --- a/cmuxTests/RemotePTYReconnectLifecycleTests.swift +++ /dev/null @@ -1,70 +0,0 @@ -import CmuxCore -import CmuxRemoteSession -import Foundation -import Testing - -#if canImport(cmux_DEV) -@testable import cmux_DEV -#elseif canImport(cmux) -@testable import cmux -#endif - -/// Covers the reconnect boundary after a persistent remote PTY wrapper has -/// already moved the presentation state into a retrying phase. -@MainActor -@Suite(.serialized) -struct RemotePTYReconnectLifecycleTests { - @Test - func reconnectRestartsControllerWhenPresentationIsConnectingButOwnerIsGone() async throws { - let workspace = Workspace() - workspace.remoteSessionProcessRunnerOverrideForTesting = ImmediateRemoteSessionFailureRunner() - // Legacy Workspace path: a relay-less SSH config that bootstraps the daemon is owned - // by cmux-tui since 5f0d2227241, so this uses a VM-baked daemon. - let configuration = WorkspaceRemoteConfiguration( - destination: "tiny@remote-only", - port: 22, - identityFile: nil, - sshOptions: [], - localProxyPort: nil, - relayPort: nil, - relayID: nil, - relayToken: nil, - localSocketPath: nil, - terminalStartupCommand: "ssh tiny@remote-only", - preserveAfterTerminalExit: true, - persistentDaemonSlot: "remote-pty-reconnect-test", - skipDaemonBootstrap: true - ) - - #expect(workspace.configureRemoteConnection(configuration, autoConnect: false)) - let initialTransition = try #require(workspace.remoteSessionTransitionTask) - await initialTransition.value - let panel = try #require(workspace.focusedTerminalPanel) - #expect(workspace.activeRemoteTerminalSurfaceIds.contains(panel.id)) - - // This is the state produced when the persistent wrapper reports its - // attachment ended after the old controller has been stopped. The - // published presentation is retrying, but no owner can make progress. - workspace.remoteSessionController = nil - workspace.remoteControllerConnectionState = .disconnected - workspace.remoteConnectionState = .connecting - - #expect(!workspace.reconnectRemoteConnection(surfaceId: panel.id)) - let transition = try #require(workspace.remoteSessionTransitionTask) - await transition.value - #expect(workspace.remoteSessionController != nil) - - workspace.disconnectRemoteConnection(clearConfiguration: true) - await workspace.remoteSessionTransitionTask?.value - workspace.teardownAllPanels() - } -} - -private struct ImmediateRemoteSessionFailureRunner: RemoteSessionProcessRunning, Sendable { - func run( - _: RemoteProcessRequest, - operation _: (any RemoteTransferCancelling)? - ) throws -> RemoteCommandResult { - RemoteCommandResult(status: 1, stdout: "", stderr: "intentional reconnect-test stop") - } -} diff --git a/cmuxTests/RemoteResumeBindingTests.swift b/cmuxTests/RemoteResumeBindingTests.swift index 111b504f7a00..d6c40fc411a0 100644 --- a/cmuxTests/RemoteResumeBindingTests.swift +++ b/cmuxTests/RemoteResumeBindingTests.swift @@ -908,155 +908,6 @@ struct RemoteResumeBindingTests { #expect(environment["ANTHROPIC_API_KEY"] == nil) } - @Test(.disabled("Preserved SSH snapshots restore through tuiSSHConfiguration since 5f0d2227241; rewrite against cmux-tui.")) - func persistentRestoreRunsRemoteResumeOnlyWhenSessionMustBeCreated() throws { - let fixture = try makeRelayedFixture() - defer { withExtendedLifetime(fixture.relayPortReservation) {} } - let persistedTerminal = try #require( - fixture.snapshot.panels.first { $0.id == fixture.surfaceID }?.terminal - ) - #expect(persistedTerminal.wasAgentRunning == true) - let suiteName = "cmux-remote-resume-binding-\(UUID().uuidString)" - let defaults = try #require(UserDefaults(suiteName: suiteName)) - defaults.set(true, forKey: AgentSessionAutoResumeSettings.autoResumeAgentSessionsKey) - defer { defaults.removePersistentDomain(forName: suiteName) } - - let socketPath = reserveRemoteRestoreSocket() - defer { cleanupRemoteRestoreSocket(socketPath) } - - let restoredWorkspace = Workspace( - agentSessionAutoResumeDefaults: defaults, - restorableAgentIndexProvider: { .empty } - ) - defer { restoredWorkspace.teardownAllPanels() } - let restoredIDs = restoredWorkspace.restoreSessionSnapshot(fixture.snapshot) - let restoredSurfaceID = try #require(restoredIDs[fixture.surfaceID]) - let restoredPanel = try #require(restoredWorkspace.terminalPanel(for: restoredSurfaceID)) - let liveFirstCommand = try #require(restoredPanel.surface.debugInitialCommand()) - - #expect(liveFirstCommand.contains("ssh-pty-attach"), "\(liveFirstCommand)") - #expect(liveFirstCommand.contains("--require-existing"), "\(liveFirstCommand)") - let liveFirstRemoteCommand = try decodedRemoteCommand(from: liveFirstCommand) - try expectRemoteResumeBootstrap( - liveFirstRemoteCommand, - relayPort: fixture.relayPort - ) - #expect(restoredPanel.surface.debugInitialInputForTesting() == nil) - - let roundTrip = restoredWorkspace.sessionSnapshot(includeScrollback: false) - let roundTripBinding = try #require( - roundTrip.panels.first { $0.id == restoredSurfaceID }?.terminal?.resumeBinding - ) - let encodedBinding = try JSONEncoder().encode(roundTripBinding) - let bindingObject = try #require( - JSONSerialization.jsonObject(with: encodedBinding) as? [String: Any] - ) - let launchFlavor = try #require(bindingObject["launchFlavor"] as? [String: Any]) - #expect(launchFlavor["kind"] as? String == "persistentSSH") - let remoteContext = try #require(launchFlavor["remoteContext"] as? [String: Any]) - #expect(remoteContext["workspaceID"] as? String == restoredWorkspace.id.uuidString) - #expect(remoteContext["surfaceID"] as? String == restoredSurfaceID.uuidString) - #expect(remoteContext["persistentPTYSessionID"] as? String == fixture.remotePTYSessionID) - - let ended = restoredWorkspace.markRemotePTYAttachEnded( - surfaceId: restoredSurfaceID, - sessionID: fixture.remotePTYSessionID - ) - #expect(ended.clearedRemotePTYSession) - restoredWorkspace.markPersistentRemotePTYAttachFailed(surfaceId: restoredSurfaceID) - let restarted = restoredWorkspace.reattachPersistentRemotePTYPanels( - requestedSurfaceId: restoredSurfaceID, - restartEndedSessions: true - ) - #expect(restarted == [restoredSurfaceID]) - - let gonePTYCommand = try #require( - restoredWorkspace.terminalPanel(for: restoredSurfaceID)?.surface.debugInitialCommand() - ) - #expect(!gonePTYCommand.contains("--require-existing"), "\(gonePTYCommand)") - let gonePTYRemoteCommand = try decodedRemoteCommand(from: gonePTYCommand) - try expectRemoteResumeBootstrap( - gonePTYRemoteCommand, - relayPort: fixture.relayPort - ) - } - - @Test(.disabled("Preserved SSH snapshots restore through tuiSSHConfiguration since 5f0d2227241; rewrite against cmux-tui.")) - func mismatchedRemoteBindingNeverFallsBackToLocalExecution() throws { - let fixture = try makeRelayedFixture() - defer { withExtendedLifetime(fixture.relayPortReservation) {} } - let mismatchedSnapshot = try snapshotByReplacingRemoteContext( - fixture.snapshot, - persistentPTYSessionID: "different-persistent-session" - ) - let suiteName = "cmux-mismatched-remote-resume-binding-\(UUID().uuidString)" - let defaults = try #require(UserDefaults(suiteName: suiteName)) - defaults.set(true, forKey: AgentSessionAutoResumeSettings.autoResumeAgentSessionsKey) - defer { defaults.removePersistentDomain(forName: suiteName) } - - let socketPath = reserveRemoteRestoreSocket() - defer { cleanupRemoteRestoreSocket(socketPath) } - - let restoredWorkspace = Workspace( - agentSessionAutoResumeDefaults: defaults, - restorableAgentIndexProvider: { .empty } - ) - defer { restoredWorkspace.teardownAllPanels() } - let restoredIDs = restoredWorkspace.restoreSessionSnapshot(mismatchedSnapshot) - let restoredSurfaceID = try #require(restoredIDs[fixture.surfaceID]) - let restoredPanel = try #require(restoredWorkspace.terminalPanel(for: restoredSurfaceID)) - let startupCommand = try #require(restoredPanel.surface.debugInitialCommand()) - - #expect(startupCommand.contains("ssh-pty-attach"), "\(startupCommand)") - #expect(startupCommand.contains("--require-existing"), "\(startupCommand)") - #expect(restoredPanel.surface.debugInitialInputForTesting() == nil) - #expect(!startupCommand.contains("--command-b64"), "\(startupCommand)") - #expect(!startupCommand.contains("session-remote-7989"), "\(startupCommand)") - #expect(!startupCommand.contains("REMOTE_FLAG"), "\(startupCommand)") - } - - @Test(.disabled("Preserved SSH snapshots restore through tuiSSHConfiguration since 5f0d2227241; rewrite against cmux-tui.")) - func legacyRemoteSnapshotWithoutWorkspaceIDMigratesBindingIntoPersistentSSHContext() throws { - let fixture = try makeRelayedFixture() - defer { withExtendedLifetime(fixture.relayPortReservation) {} } - let legacySnapshot = try snapshotWithoutLaunchFlavorOrWorkspaceID(fixture.snapshot) - let suiteName = "cmux-legacy-remote-resume-binding-\(UUID().uuidString)" - let defaults = try #require(UserDefaults(suiteName: suiteName)) - defaults.set(true, forKey: AgentSessionAutoResumeSettings.autoResumeAgentSessionsKey) - defer { defaults.removePersistentDomain(forName: suiteName) } - - let socketPath = reserveRemoteRestoreSocket() - defer { cleanupRemoteRestoreSocket(socketPath) } - - let restoredWorkspace = Workspace( - agentSessionAutoResumeDefaults: defaults, - restorableAgentIndexProvider: { .empty } - ) - defer { restoredWorkspace.teardownAllPanels() } - let restoredIDs = restoredWorkspace.restoreSessionSnapshot(legacySnapshot) - let restoredSurfaceID = try #require(restoredIDs[fixture.surfaceID]) - let startupCommand = try #require( - restoredWorkspace.terminalPanel(for: restoredSurfaceID)?.surface.debugInitialCommand() - ) - let remoteCommand = try decodedRemoteCommand(from: startupCommand) - try expectRemoteResumeBootstrap( - remoteCommand, - relayPort: fixture.relayPort - ) - - let roundTripBinding = try #require( - restoredWorkspace.sessionSnapshot(includeScrollback: false) - .panels.first { $0.id == restoredSurfaceID }?.terminal?.resumeBinding - ) - guard case .persistentSSH(let context) = roundTripBinding.launchFlavor else { - Issue.record("Legacy remote binding was not migrated to persistent SSH") - return - } - #expect(context.workspaceID == restoredWorkspace.id) - #expect(context.surfaceID == restoredSurfaceID) - #expect(context.persistentPTYSessionID == fixture.remotePTYSessionID) - } - @Test func legacyBindingMigrationStopsAtPersistentSSHOwnershipBoundaries() throws { let binding = try legacyLocalBinding() @@ -1346,64 +1197,6 @@ struct RemoteResumeBindingTests { return try #require(envelope["result"] as? [String: Any]) } - private func decodedRemoteCommand(from startupCommand: String) throws -> String { - let words = TerminalStartupWorkingDirectoryPrefix.shellWordRanges(startupCommand).map(\.value) - let script = try #require(words.dropFirst(2).first) - let scriptWords = TerminalStartupWorkingDirectoryPrefix.shellWordRanges(script).map(\.value) - let commandIndex = try #require(scriptWords.firstIndex(of: "--command-b64")) - let token = try #require(scriptWords.dropFirst(commandIndex + 1).first) - // The word parser retains the retry script's command separator. - let encoded = token.hasSuffix(";") ? String(token.dropLast()) : token - let data = try #require(Data(base64Encoded: encoded)) - return try #require(String(data: data, encoding: .utf8)) - } - - private func snapshotWithoutLaunchFlavorOrWorkspaceID( - _ snapshot: SessionWorkspaceSnapshot - ) throws -> SessionWorkspaceSnapshot { - let encoded = try JSONEncoder().encode(snapshot) - var object = try #require(JSONSerialization.jsonObject(with: encoded) as? [String: Any]) - object.removeValue(forKey: "workspaceId") - var panels = try #require(object["panels"] as? [[String: Any]]) - let panelIndex = try #require(panels.firstIndex { $0["terminal"] != nil }) - var panel = panels[panelIndex] - var terminal = try #require(panel["terminal"] as? [String: Any]) - var binding = try #require(terminal["resumeBinding"] as? [String: Any]) - binding.removeValue(forKey: "launchFlavor") - terminal["resumeBinding"] = binding - panel["terminal"] = terminal - panels[panelIndex] = panel - object["panels"] = panels - let legacyData = try JSONSerialization.data(withJSONObject: object) - return try JSONDecoder().decode(SessionWorkspaceSnapshot.self, from: legacyData) - } - - private func snapshotByReplacingRemoteContext( - _ snapshot: SessionWorkspaceSnapshot, - persistentPTYSessionID: String - ) throws -> SessionWorkspaceSnapshot { - let encoded = try JSONEncoder().encode(snapshot) - var object = try #require(JSONSerialization.jsonObject(with: encoded) as? [String: Any]) - var panels = try #require(object["panels"] as? [[String: Any]]) - let panelIndex = try #require(panels.firstIndex { $0["terminal"] != nil }) - var panel = panels[panelIndex] - var terminal = try #require(panel["terminal"] as? [String: Any]) - var binding = try #require(terminal["resumeBinding"] as? [String: Any]) - var launchFlavor = try #require(binding["launchFlavor"] as? [String: Any]) - var remoteContext = try #require(launchFlavor["remoteContext"] as? [String: Any]) - remoteContext["persistentPTYSessionID"] = persistentPTYSessionID - launchFlavor["remoteContext"] = remoteContext - binding["launchFlavor"] = launchFlavor - terminal["resumeBinding"] = binding - panel["terminal"] = terminal - panels[panelIndex] = panel - object["panels"] = panels - return try JSONDecoder().decode( - SessionWorkspaceSnapshot.self, - from: JSONSerialization.data(withJSONObject: object) - ) - } - private func legacyLocalBinding() throws -> SurfaceResumeBindingSnapshot { let object: [String: Any] = [ "name": "Codex", @@ -1421,29 +1214,6 @@ struct RemoteResumeBindingTests { ) } - private func expectRemoteResumeBootstrap(_ command: String, relayPort: Int) throws { - #expect(command.contains("export CMUX_SOCKET_PATH=127.0.0.1:\(relayPort)"), "\(command)") - #expect(command.contains("__CMUX_WORKSPACE_ID__"), "\(command)") - #expect(command.contains("__CMUX_SURFACE_ID__"), "\(command)") - let initialCommand = try decodedInitialCommand(from: command) - #expect(initialCommand.contains("/srv/remote project"), "\(initialCommand)") - #expect(initialCommand.contains("REMOTE_FLAG=value with spaces"), "\(initialCommand)") - #expect(initialCommand.contains("session-remote-7989"), "\(initialCommand)") - #expect(!initialCommand.contains("ANTHROPIC_API_KEY"), "\(initialCommand)") - } - - private func decodedInitialCommand(from bootstrap: String) throws -> String { - let payloadLine = try #require(bootstrap.split(separator: "\n").first { line in - line.contains("printf %s '") && line.contains("> \"$cmux_initial_command_tmp\"") - }) - let prefixRange = try #require(payloadLine.range(of: "printf %s '")) - let encodedSuffix = payloadLine[prefixRange.upperBound...] - let closingQuote = try #require(encodedSuffix.firstIndex(of: "'")) - let encodedCommand = String(encodedSuffix[.. String { - TerminalController.shared.stop(cleanupDiscoveryState: true) - let requestedPath = "/tmp/cmux-remote-resume-\(UUID().uuidString).sock" - return TerminalController.shared.reserveStartupSocketPath(requestedPath) - } - - private func cleanupRemoteRestoreSocket(_ path: String) { - TerminalController.shared.stop(cleanupDiscoveryState: true) - try? FileManager.default.removeItem(atPath: path) - try? FileManager.default.removeItem(atPath: path + ".lock") - } - private func makeMainWindow(id: UUID) -> NSWindow { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 500, height: 320), diff --git a/cmuxTests/SSHRemoteCommandChainingTests.swift b/cmuxTests/SSHRemoteCommandChainingTests.swift index b7a35082f0cc..85decd19654e 100644 --- a/cmuxTests/SSHRemoteCommandChainingTests.swift +++ b/cmuxTests/SSHRemoteCommandChainingTests.swift @@ -159,88 +159,6 @@ struct SSHRemoteCommandChainingTests { #expect(remainingPayloads.isEmpty, "\(remainingPayloads)") } - @Test(.disabled("Preserved SSH snapshots restore through tuiSSHConfiguration since 5f0d2227241; rewrite against cmux-tui.")) - func persistentWorkspaceRestoreKeepsConfiguredRemoteCommandInNewPaneBootstrap() throws { - let configuredRemoteCommand = #"cd "/srv/project dir" && exec fish"# - let liveConfiguration = WorkspaceRemoteConfiguration( - destination: "dev@example.com", - port: 2222, - identityFile: nil, - sshOptions: [ - "ControlMaster=auto", - "ControlPersist=600", - "ControlPath=/tmp/cmux-ssh-%C", - ], - localProxyPort: nil, - relayPort: 64_123, - relayID: "relay-id", - relayToken: String(repeating: "a", count: 64), - localSocketPath: "/tmp/cmux-live.sock", - terminalStartupCommand: "live startup command", - configuredRemoteCommand: configuredRemoteCommand, - preserveAfterTerminalExit: true, - persistentDaemonSlot: "ssh-restore-slot" - ) - let encodedSnapshot = try JSONEncoder().encode(try #require(liveConfiguration.sessionSnapshot())) - let snapshot = try JSONDecoder().decode( - SessionRemoteWorkspaceSnapshot.self, - from: encodedSnapshot - ) - let restored = try #require( - snapshot.workspaceConfiguration(localSocketPath: "/tmp/cmux-restored.sock") - ) - let startupCommand = try #require(restored.terminalStartupCommand) - let expectedBootstrap = SSHPTYAttachStartupCommandBuilder.restoredRemoteShellCommand( - relayPort: 64_123, - configuredRemoteCommand: configuredRemoteCommand - ) - let expectedBootstrapBase64 = Data(expectedBootstrap.utf8).base64EncodedString() - - #expect(snapshot.configuredRemoteCommand == configuredRemoteCommand) - #expect(restored.configuredRemoteCommand == configuredRemoteCommand) - #expect(startupCommand.contains(expectedBootstrapBase64), "\(startupCommand)") - } - - @Test - func nonPersistentRestoreWithoutRelayPreservesExplicitRemoteCommandIntent() throws { - let cases: [(options: [String], expectedCommandFragment: String?)] = [ - (["RemoteCommand=printf restored-command"], "'RemoteCommand=printf restored-command'"), - (["RemoteCommand=none"], "RemoteCommand=none"), - ([], nil), - ] - - for testCase in cases { - let snapshot = SessionRemoteWorkspaceSnapshot( - transport: .ssh, - terminalTransport: .ssh, - terminalProfile: .shell, - destination: "dev@example.com", - sshOptions: testCase.options, - preserveAfterTerminalExit: true, - // Legacy restore path: a preserved SSH snapshot that bootstraps the daemon restores - // through tuiSSHConfiguration since 5f0d2227241, so this uses a VM-baked daemon. - skipDaemonBootstrap: true, - relayPort: 64_123, - persistentDaemonSlot: "ssh-restore-slot" - ) - let restored = try #require( - snapshot.workspaceConfiguration( - localSocketPath: nil, - allowPersistentPTYRestore: false - ) - ) - let startupCommand = try #require(restored.terminalStartupCommand) - - #expect(restored.sshOptions == testCase.options) - #expect(startupCommand.hasPrefix("/usr/bin/ssh "), "\(startupCommand)") - if let expectedCommandFragment = testCase.expectedCommandFragment { - #expect(startupCommand.contains(expectedCommandFragment), "\(startupCommand)") - } else { - #expect(!startupCommand.localizedCaseInsensitiveContains("RemoteCommand"), "\(startupCommand)") - } - } - } - @Test func nonPersistentRestorePreservesConfiguredCommandAcrossOpenSSHReparsing() throws { let fileManager = FileManager.default diff --git a/cmuxTests/SSHTuiMigrationTests.swift b/cmuxTests/SSHTuiMigrationTests.swift index e6c0577ce7b2..e67d58324b33 100644 --- a/cmuxTests/SSHTuiMigrationTests.swift +++ b/cmuxTests/SSHTuiMigrationTests.swift @@ -96,6 +96,26 @@ struct SSHTuiMigrationTests { #expect(SSHTuiConnection(configuration: original).id == SSHTuiConnection(configuration: restored).id) } + @Test("Legacy persistent SSH snapshots are not claimed by the TUI owner") + func legacySnapshotDoesNotBecomeTuiSession() throws { + let legacy = SessionRemoteWorkspaceSnapshot(transport: .ssh, destination: "fixture@host", + preserveAfterTerminalExit: true, relayPort: 1234, persistentDaemonSlot: "legacy-owned") + #expect(legacy.tuiSSHConfiguration(agentSocketPath: nil) == nil) + let blocked = try #require(legacy.workspaceConfiguration()) + #expect(blocked.terminalStartupCommand == nil) + #expect(blocked.sessionSnapshot() == legacy) + #expect(blocked.scopedToOwnerWorkspace(UUID()).sessionSnapshot() == legacy) + #expect(blocked.withSSHControlMasterLeaseGeneration(UUID()).sessionSnapshot() == legacy) + + } + + @Test("Managed SSH snapshot serialization records its session owner") + func managedSnapshotRecordsOwner() throws { + let snapshot = try #require(configuration().sessionSnapshot()) + let object = try #require(JSONSerialization.jsonObject(with: JSONEncoder().encode(snapshot)) as? [String: Any]) + #expect(object["sshSessionOwner"] as? String == "cmux-tui") + } + @MainActor @Test("A legacy relay configuration keeps its relay lifecycle and startup command") func legacyRelayConfigurationIsNotClaimedByCmuxTui() { diff --git a/cmuxTests/TabManagerSessionSnapshotTests.swift b/cmuxTests/TabManagerSessionSnapshotTests.swift index 4b9e7d879406..20b71139f162 100644 --- a/cmuxTests/TabManagerSessionSnapshotTests.swift +++ b/cmuxTests/TabManagerSessionSnapshotTests.swift @@ -2387,465 +2387,6 @@ final class TabManagerSessionSnapshotTests: XCTestCase { XCTAssertNil(restoredWorkspace.remoteConfiguration?.sshProcessEnvironment?["SSH_AUTH_SOCK"]) } - func testSessionSnapshotRestoresPersistentSSHPTYSessionAfterRelaunch() throws { - try XCTSkipIf(true, "Preserved SSH snapshots restore through tuiSSHConfiguration since 5f0d2227241; rewrite against cmux-tui.") - let manager = TabManager() - let remoteWorkspace = manager.addWorkspace(select: true) - remoteWorkspace.setCustomTitle("Persistent SSH") - let persistentDaemonSlot = "ssh-persist-test" - let configuration = WorkspaceRemoteConfiguration( - destination: "dev@example.com", - port: 2222, - identityFile: nil, - sshOptions: [ - "StrictHostKeyChecking=accept-new", - ], - localProxyPort: nil, - relayPort: 64003, - relayID: "relay-persist-test", - relayToken: String(repeating: "e", count: 64), - localSocketPath: "/tmp/cmux-persist-test.sock", - terminalStartupCommand: SSHPTYAttachStartupCommandBuilder.command(), - preserveAfterTerminalExit: true, - persistentDaemonSlot: persistentDaemonSlot - ) - remoteWorkspace.configureRemoteConnection(configuration, autoConnect: false) - let remotePanelId = try XCTUnwrap(remoteWorkspace.focusedPanelId) - remoteWorkspace.updateRemotePanelDirectory(panelId: remotePanelId, directory: "/home/dev/persistent-project") - let expectedSessionID = Workspace.defaultSSHPTYSessionID( - workspaceId: remoteWorkspace.id, - panelId: remotePanelId - ) - let seededScrollback = remoteWorkspace.debugSeedSessionSnapshotScrollback(charactersPerTerminal: 160) - XCTAssertEqual(seededScrollback.terminals, 1) - - let snapshotURL = FileManager.default.temporaryDirectory - .appendingPathComponent("cmux-ssh-pty-session-restore-\(UUID().uuidString).json") - defer { try? FileManager.default.removeItem(at: snapshotURL) } - let snapshot = AppSessionSnapshot( - version: SessionSnapshotSchema.currentVersion, - createdAt: Date().timeIntervalSince1970, - windows: [ - SessionWindowSnapshot( - frame: nil, - display: nil, - tabManager: manager.sessionSnapshot(includeScrollback: true), - sidebar: SessionSidebarSnapshot(isVisible: true, selection: .tabs, width: nil) - ), - ] - ) - let store = SessionSnapshotRepository( - schemaVersion: SessionSnapshotSchema.currentVersion, - bundleIdentifier: "com.cmuxterm.tests" - ) - XCTAssertTrue(store.save(snapshot, fileURL: snapshotURL)) - let persistedTabManager = try XCTUnwrap( - store.load(fileURL: snapshotURL)?.windows.first?.tabManager - ) - let persistedWorkspace = try XCTUnwrap( - persistedTabManager.workspaces.first { $0.customTitle == "Persistent SSH" } - ) - XCTAssertEqual(persistedWorkspace.remote?.preserveAfterTerminalExit, true) - XCTAssertEqual(persistedWorkspace.remote?.relayPort, 64003) - XCTAssertEqual(persistedWorkspace.remote?.persistentDaemonSlot, persistentDaemonSlot) - XCTAssertEqual( - persistedWorkspace.panels.first { $0.id == remotePanelId }?.terminal?.remotePTYSessionID, - expectedSessionID - ) - let expectedScrollback = try XCTUnwrap( - persistedWorkspace.panels.first { $0.id == remotePanelId }?.terminal?.scrollback - ) - XCTAssertTrue(expectedScrollback.contains("cmux perf synthetic scrollback"), expectedScrollback) - - let reservedSocketPath = reserveRemoteRestoreSocket() - defer { cleanupRemoteRestoreSocket(reservedSocketPath) } - - let restored = TabManager() - restored.restoreSessionSnapshot(persistedTabManager) - - let restoredWorkspace = try XCTUnwrap(restored.tabs.first { $0.customTitle == "Persistent SSH" }) - XCTAssertEqual(restoredWorkspace.remoteConfiguration?.preserveAfterTerminalExit, true) - XCTAssertEqual(restoredWorkspace.remoteConfiguration?.relayPort, 64003) - XCTAssertEqual(restoredWorkspace.remoteConfiguration?.persistentDaemonSlot, persistentDaemonSlot) - XCTAssertEqual(restoredWorkspace.remoteConfiguration?.localSocketPath, reservedSocketPath) - XCTAssertTrue( - restoredWorkspace.remoteConfiguration?.sshOptions.contains("ControlPath=/tmp/cmux-ssh-\(getuid())-%C") == true - ) - XCTAssertNotEqual(restoredWorkspace.remoteConfiguration?.relayID, "relay-persist-test") - XCTAssertNotEqual(restoredWorkspace.remoteConfiguration?.relayToken, String(repeating: "e", count: 64)) - let restoredRelayToken = try XCTUnwrap(restoredWorkspace.remoteConfiguration?.relayToken) - XCTAssertEqual(restoredRelayToken.count, 64) - XCTAssertNotNil(restoredRelayToken.range(of: "^[0-9a-f]{64}$", options: .regularExpression)) - let restoredForegroundAuthToken = try XCTUnwrap(restoredWorkspace.remoteConfiguration?.foregroundAuthToken) - XCTAssertFalse(restoredForegroundAuthToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - let terminalStartupCommand = try XCTUnwrap(restoredWorkspace.remoteConfiguration?.terminalStartupCommand) - XCTAssertTrue(terminalStartupCommand.hasPrefix("/bin/sh -c "), terminalStartupCommand) - XCTAssertTrue(terminalStartupCommand.contains("ssh-pty-attach"), terminalStartupCommand) - XCTAssertTrue(terminalStartupCommand.contains("workspace.remote.foreground_auth_ready"), terminalStartupCommand) - XCTAssertTrue(terminalStartupCommand.contains(restoredForegroundAuthToken), terminalStartupCommand) - XCTAssertFalse(terminalStartupCommand.contains(expectedSessionID), terminalStartupCommand) - XCTAssertFalse(terminalStartupCommand.contains("--require-existing"), terminalStartupCommand) - XCTAssertTrue(terminalStartupCommand.contains("--command-b64 "), terminalStartupCommand) - XCTAssertTrue(terminalStartupCommand.contains("251)") && terminalStartupCommand.contains("254)") && terminalStartupCommand.contains("255)"), terminalStartupCommand) - let restoredDefaultRemoteCommand = try XCTUnwrap( - Self.decodedSSHPTYCommandB64(in: terminalStartupCommand) - ) - let restoredPanelId = try XCTUnwrap(restoredWorkspace.focusedPanelId) - XCTAssertEqual(restoredWorkspace.panelDirectories[restoredPanelId], "/home/dev/persistent-project") - XCTAssertNil(restoredWorkspace.terminalPanel(for: restoredPanelId)?.requestedWorkingDirectory) - XCTAssertTrue( - restoredDefaultRemoteCommand.contains("export CMUX_SOCKET_PATH=127.0.0.1:64003"), - restoredDefaultRemoteCommand - ) - XCTAssertTrue( - restoredDefaultRemoteCommand.contains("export PATH=\"$HOME/.cmux/bin:$PATH\""), - restoredDefaultRemoteCommand - ) - XCTAssertTrue(restoredDefaultRemoteCommand.contains("CMUX_SHELL_INTEGRATION_DIR"), restoredDefaultRemoteCommand) - XCTAssertTrue( - restoredDefaultRemoteCommand.contains("cmux_workspace_id='__CMUX_WORKSPACE_ID__'"), - restoredDefaultRemoteCommand - ) - XCTAssertTrue( - restoredDefaultRemoteCommand.contains("'__CMUX_''WORKSPACE_ID__'"), - restoredDefaultRemoteCommand - ) - XCTAssertFalse( - restoredDefaultRemoteCommand.contains("[ -n '__CMUX_WORKSPACE_ID__' ]"), - restoredDefaultRemoteCommand - ) - XCTAssertTrue( - restoredDefaultRemoteCommand.contains("cmux_surface_id='__CMUX_SURFACE_ID__'"), - restoredDefaultRemoteCommand - ) - XCTAssertTrue( - restoredDefaultRemoteCommand.contains("'__CMUX_''SURFACE_ID__'"), - restoredDefaultRemoteCommand - ) - XCTAssertFalse( - restoredDefaultRemoteCommand.contains("[ -n '__CMUX_SURFACE_ID__' ]"), - restoredDefaultRemoteCommand - ) - let substitutedRestoredDefaultRemoteCommand = restoredDefaultRemoteCommand - .replacingOccurrences(of: "__CMUX_WORKSPACE_ID__", with: restoredWorkspace.id.uuidString) - .replacingOccurrences(of: "__CMUX_SURFACE_ID__", with: restoredPanelId.uuidString) - XCTAssertTrue( - substitutedRestoredDefaultRemoteCommand.contains( - "cmux_workspace_id='\(restoredWorkspace.id.uuidString)'" - ), - substitutedRestoredDefaultRemoteCommand - ) - XCTAssertTrue( - substitutedRestoredDefaultRemoteCommand.contains("cmux_surface_id='\(restoredPanelId.uuidString)'"), - substitutedRestoredDefaultRemoteCommand - ) - XCTAssertFalse( - substitutedRestoredDefaultRemoteCommand.contains("CMUX_WORKSPACE_ID=__CMUX_WORKSPACE_ID__"), - substitutedRestoredDefaultRemoteCommand - ) - XCTAssertFalse( - substitutedRestoredDefaultRemoteCommand.contains("CMUX_SURFACE_ID=__CMUX_SURFACE_ID__"), - substitutedRestoredDefaultRemoteCommand - ) - let restoredInitialCommand = try XCTUnwrap( - restoredWorkspace.terminalPanel(for: restoredPanelId)?.surface.debugInitialCommand() - ) - XCTAssertTrue(restoredInitialCommand.hasPrefix("/bin/sh -c "), restoredInitialCommand) - XCTAssertTrue(restoredInitialCommand.contains("ssh-pty-attach"), restoredInitialCommand) - XCTAssertTrue(restoredInitialCommand.contains("workspace.remote.foreground_auth_ready"), restoredInitialCommand) - XCTAssertTrue(restoredInitialCommand.contains(restoredForegroundAuthToken), restoredInitialCommand) - XCTAssertTrue(restoredInitialCommand.contains("--require-existing"), restoredInitialCommand) - XCTAssertTrue(restoredInitialCommand.contains("251)") && restoredInitialCommand.contains("254)") && restoredInitialCommand.contains("255)"), restoredInitialCommand) - XCTAssertTrue(restoredInitialCommand.contains(expectedSessionID), restoredInitialCommand) - XCTAssertTrue(restoredInitialCommand.contains("CMUX_SURFACE_ID"), restoredInitialCommand) - XCTAssertFalse(restoredInitialCommand.contains("--command-b64 "), restoredInitialCommand) - - let roundTrip = restoredWorkspace.sessionSnapshot(includeScrollback: false) - XCTAssertEqual(roundTrip.remote?.preserveAfterTerminalExit, true) - XCTAssertEqual(roundTrip.remote?.relayPort, 64003) - XCTAssertEqual(roundTrip.remote?.persistentDaemonSlot, persistentDaemonSlot) - XCTAssertEqual(roundTrip.panels.first?.terminal?.remotePTYSessionID, expectedSessionID) - XCTAssertEqual( - persistedWorkspace.panels.first { $0.id == remotePanelId }?.terminal?.scrollback, - expectedScrollback - ) - } - - func testSessionSnapshotRestoresSplitPersistentSSHPTYWithoutDefaultAttachScaffold() throws { - try XCTSkipIf(true, "Preserved SSH snapshots restore through tuiSSHConfiguration since 5f0d2227241; rewrite against cmux-tui.") - let manager = TabManager() - let remoteWorkspace = manager.addWorkspace(select: true) - remoteWorkspace.setCustomTitle("Persistent SSH Split") - let persistentDaemonSlot = "ssh-persist-split" - let configuration = WorkspaceRemoteConfiguration( - destination: "dev@example.com", - port: 2222, - identityFile: "~/.ssh/id_ed25519", - sshOptions: [ - "StrictHostKeyChecking=accept-new", - ], - localProxyPort: nil, - relayPort: 64008, - relayID: "relay-persist-split", - relayToken: String(repeating: "c", count: 64), - localSocketPath: "/tmp/cmux-persist-split.sock", - terminalStartupCommand: SSHPTYAttachStartupCommandBuilder.command(), - preserveAfterTerminalExit: true, - persistentDaemonSlot: persistentDaemonSlot - ) - remoteWorkspace.configureRemoteConnection(configuration, autoConnect: false) - let firstPanelId = try XCTUnwrap(remoteWorkspace.focusedPanelId) - let secondPanel = try XCTUnwrap( - remoteWorkspace.newTerminalSplit(from: firstPanelId, orientation: .horizontal, focus: true) - ) - let expectedSessionIDs: Set = [ - Workspace.defaultSSHPTYSessionID(workspaceId: remoteWorkspace.id, panelId: firstPanelId), - Workspace.defaultSSHPTYSessionID(workspaceId: remoteWorkspace.id, panelId: secondPanel.id), - ] - - let snapshot = manager.sessionSnapshot(includeScrollback: false) - let reservedSocketPath = reserveRemoteRestoreSocket() - defer { cleanupRemoteRestoreSocket(reservedSocketPath) } - - let restored = TabManager() - restored.restoreSessionSnapshot(snapshot) - - let restoredWorkspace = try XCTUnwrap(restored.tabs.first { $0.customTitle == "Persistent SSH Split" }) - XCTAssertEqual(restoredWorkspace.remoteConfiguration?.preserveAfterTerminalExit, true) - XCTAssertEqual(restoredWorkspace.remoteConfiguration?.persistentDaemonSlot, persistentDaemonSlot) - XCTAssertEqual(restoredWorkspace.activeRemoteTerminalSessionCount, 2) - - let restoredSnapshot = restoredWorkspace.sessionSnapshot(includeScrollback: false) - let restoredTerminalPanels = restoredSnapshot.panels.filter { $0.terminal != nil } - XCTAssertEqual(restoredTerminalPanels.count, 2) - XCTAssertEqual( - Set(restoredTerminalPanels.compactMap { $0.terminal?.remotePTYSessionID }), - expectedSessionIDs - ) - - let workspaceDefaultCommand = try XCTUnwrap(restoredWorkspace.remoteConfiguration?.terminalStartupCommand) - XCTAssertTrue(workspaceDefaultCommand.contains("--command-b64 "), workspaceDefaultCommand) - XCTAssertFalse(workspaceDefaultCommand.contains("--require-existing"), workspaceDefaultCommand) - - for panelSnapshot in restoredTerminalPanels { - let panel = try XCTUnwrap(restoredWorkspace.terminalPanel(for: panelSnapshot.id)) - let command = try XCTUnwrap(panel.surface.debugInitialCommand()) - XCTAssertTrue(command.contains("ssh-pty-attach"), command) - XCTAssertTrue(command.contains("--require-existing"), command) - XCTAssertFalse(command.contains("--command-b64 "), command) - XCTAssertTrue( - expectedSessionIDs.contains { command.contains($0) }, - command - ) - } - } - - func testPersistentSSHPTYRestoreRewritesStaleRemoteRelayContextIDs() throws { - try XCTSkipIf(true, "Preserved SSH snapshots restore through tuiSSHConfiguration since 5f0d2227241; rewrite against cmux-tui.") - let manager = TabManager() - let remoteWorkspace = manager.addWorkspace(select: true) - remoteWorkspace.setCustomTitle("Relay Alias SSH") - let persistentDaemonSlot = "ssh-relay-alias" - let configuration = WorkspaceRemoteConfiguration( - destination: "dev@example.com", - port: 2222, - identityFile: nil, - sshOptions: [ - "StrictHostKeyChecking=accept-new", - ], - localProxyPort: nil, - relayPort: 64006, - relayID: "relay-alias-test", - relayToken: String(repeating: "a", count: 64), - localSocketPath: "/tmp/cmux-relay-alias.sock", - terminalStartupCommand: SSHPTYAttachStartupCommandBuilder.command(), - preserveAfterTerminalExit: true, - persistentDaemonSlot: persistentDaemonSlot - ) - remoteWorkspace.configureRemoteConnection(configuration, autoConnect: false) - let originalWorkspaceId = remoteWorkspace.id - let originalPanelId = try XCTUnwrap(remoteWorkspace.focusedPanelId) - let sessionID = Workspace.defaultSSHPTYSessionID( - workspaceId: originalWorkspaceId, - panelId: originalPanelId - ) - - let snapshot = manager.sessionSnapshot(includeScrollback: false) - let reservedSocketPath = reserveRemoteRestoreSocket() - defer { cleanupRemoteRestoreSocket(reservedSocketPath) } - - let restored = TabManager() - restored.restoreSessionSnapshot(snapshot, excludingWorkspaceIds: [originalWorkspaceId]) - - let restoredWorkspace = try XCTUnwrap(restored.tabs.first { $0.customTitle == "Relay Alias SSH" }) - let restoredPanelId = try XCTUnwrap(restoredWorkspace.focusedPanelId) - XCTAssertNotEqual(restoredWorkspace.id, originalWorkspaceId) - XCTAssertNotEqual(restoredPanelId, originalPanelId) - - let request: [String: Any] = [ - "id": "relay-alias-request", - "method": "surface.report_tty", - "params": [ - "workspace_id": originalWorkspaceId.uuidString, - "surface_id": originalPanelId.uuidString, - "panel_id": originalPanelId.uuidString, - "preferred_panel_id": originalPanelId.uuidString, - "target_panel_id": originalPanelId.uuidString, - "created_panel_id": originalPanelId.uuidString, - "tab_id": originalPanelId.uuidString, - "before_panel_id": originalPanelId.uuidString, - "before_surface_id": originalPanelId.uuidString, - "after_panel_id": originalPanelId.uuidString, - "after_surface_id": originalPanelId.uuidString, - "workspace_ids": [originalWorkspaceId.uuidString], - "panel_ids": [originalPanelId.uuidString], - "surface_ids": [originalPanelId.uuidString], - "tab_ids": [originalWorkspaceId.uuidString, originalPanelId.uuidString], - "tab_id_groups": [[originalWorkspaceId.uuidString, originalPanelId.uuidString]], - "session_id": sessionID, - "environment": [ - "CMUX_WORKSPACE_ID": originalWorkspaceId.uuidString, - "CMUX_SURFACE_ID": originalPanelId.uuidString, - ], - "caller": [ - "workspace_id": originalWorkspaceId.uuidString, - "surface_id": originalPanelId.uuidString, - "panel_id": originalPanelId.uuidString, - "tab_id": originalWorkspaceId.uuidString, - ], - ], - ] - func decodedParams(from commandLine: Data) throws -> [String: Any] { - let payload = try XCTUnwrap( - JSONSerialization.jsonObject(with: commandLine, options: []) as? [String: Any] - ) - return try XCTUnwrap(payload["params"] as? [String: Any]) - } - - let requestData = try JSONSerialization.data(withJSONObject: request, options: []) + Data([0x0A]) - let rewrittenData = restoredWorkspace.rewriteRemoteRelayCommandLine(requestData) - let params = try decodedParams(from: rewrittenData) - let requestDataWithoutNewline = try JSONSerialization.data(withJSONObject: request, options: []) - let rewrittenDataWithoutNewline = restoredWorkspace.rewriteRemoteRelayCommandLine(requestDataWithoutNewline) - XCTAssertEqual(rewrittenData.last, UInt8(0x0A)) - XCTAssertNotEqual(rewrittenDataWithoutNewline.last, UInt8(0x0A)) - - XCTAssertEqual(params["workspace_id"] as? String, restoredWorkspace.id.uuidString) - XCTAssertEqual(params["surface_id"] as? String, restoredPanelId.uuidString) - XCTAssertEqual(params["panel_id"] as? String, restoredPanelId.uuidString) - XCTAssertEqual(params["preferred_panel_id"] as? String, restoredPanelId.uuidString) - XCTAssertEqual(params["target_panel_id"] as? String, restoredPanelId.uuidString) - XCTAssertEqual(params["created_panel_id"] as? String, restoredPanelId.uuidString) - XCTAssertEqual(params["tab_id"] as? String, restoredPanelId.uuidString) - XCTAssertEqual(params["before_panel_id"] as? String, restoredPanelId.uuidString) - XCTAssertEqual(params["before_surface_id"] as? String, restoredPanelId.uuidString) - XCTAssertEqual(params["after_panel_id"] as? String, restoredPanelId.uuidString) - XCTAssertEqual(params["after_surface_id"] as? String, restoredPanelId.uuidString) - XCTAssertEqual(params["workspace_ids"] as? [String], [restoredWorkspace.id.uuidString]) - XCTAssertEqual(params["panel_ids"] as? [String], [restoredPanelId.uuidString]) - XCTAssertEqual(params["surface_ids"] as? [String], [restoredPanelId.uuidString]) - XCTAssertEqual(params["tab_ids"] as? [String], [restoredWorkspace.id.uuidString, restoredPanelId.uuidString]) - XCTAssertEqual(params["tab_id_groups"] as? [[String]], [[restoredWorkspace.id.uuidString, restoredPanelId.uuidString]]) - XCTAssertEqual(params["session_id"] as? String, sessionID) - - let environment = try XCTUnwrap(params["environment"] as? [String: String]) - // Shell environment entries are opaque payload, not scoped relay - // selectors. A TTY report cannot forward an environment dictionary. - XCTAssertEqual(environment["CMUX_WORKSPACE_ID"], originalWorkspaceId.uuidString) - XCTAssertEqual(environment["CMUX_SURFACE_ID"], originalPanelId.uuidString) - XCTAssertEqual( - RemoteRelayCommandPolicy().evaluate( - commandLine: rewrittenData, - workspaceAliases: [:], - surfaceAliases: [:] - ), - .deny(reason: "parameter 'environment' is not permitted through a remote relay") - ) - - let caller = try XCTUnwrap(params["caller"] as? [String: Any]) - XCTAssertEqual(caller["workspace_id"] as? String, restoredWorkspace.id.uuidString) - XCTAssertEqual(caller["surface_id"] as? String, restoredPanelId.uuidString) - XCTAssertEqual(caller["panel_id"] as? String, restoredPanelId.uuidString) - XCTAssertEqual(caller["tab_id"] as? String, restoredWorkspace.id.uuidString) - - XCTAssertEqual( - restoredWorkspace.sessionSnapshot(includeScrollback: false) - .panels.first { $0.id == restoredPanelId }?.terminal?.remotePTYSessionID, - sessionID - ) - let refreshedRelayConfiguration = WorkspaceRemoteConfiguration( - destination: " dev@example.com ", - port: 2222, - identityFile: nil, - sshOptions: [ - "ControlMaster=auto", - "ControlPersist=600", - "ControlPath=/tmp/cmux-ssh-\(getuid())-64006-%C", - "StrictHostKeyChecking=accept-new", - ], - localProxyPort: nil, - relayPort: 64006, - relayID: "relay-alias-test-refreshed", - relayToken: String(repeating: "c", count: 64), - localSocketPath: "/tmp/cmux-relay-alias-refreshed.sock", - terminalStartupCommand: SSHPTYAttachStartupCommandBuilder.command(), - foregroundAuthToken: "foreground-auth-refreshed", - preserveAfterTerminalExit: true, - persistentDaemonSlot: persistentDaemonSlot - ) - restoredWorkspace.configureRemoteConnection(refreshedRelayConfiguration, autoConnect: false) - XCTAssertTrue(restoredWorkspace.remotePTYSessionIDMatches(panelId: restoredPanelId, sessionID: sessionID)) - XCTAssertEqual( - restoredWorkspace.sessionSnapshot(includeScrollback: false) - .panels.first { $0.id == restoredPanelId }?.terminal?.remotePTYSessionID, - sessionID - ) - let refreshedRelayParams = try decodedParams( - from: restoredWorkspace.rewriteRemoteRelayCommandLine(requestData) - ) - XCTAssertEqual(refreshedRelayParams["workspace_id"] as? String, restoredWorkspace.id.uuidString) - XCTAssertEqual(refreshedRelayParams["surface_id"] as? String, restoredPanelId.uuidString) - XCTAssertEqual(refreshedRelayParams["panel_id"] as? String, restoredPanelId.uuidString) - - restoredWorkspace.disconnectRemoteConnection(clearConfiguration: false) - XCTAssertEqual( - restoredWorkspace.sessionSnapshot(includeScrollback: false) - .panels.first { $0.id == restoredPanelId }?.terminal?.remotePTYSessionID, - sessionID - ) - let preservedDisconnectParams = try decodedParams( - from: restoredWorkspace.rewriteRemoteRelayCommandLine(requestData) - ) - XCTAssertEqual(preservedDisconnectParams["workspace_id"] as? String, restoredWorkspace.id.uuidString) - XCTAssertEqual(preservedDisconnectParams["surface_id"] as? String, restoredPanelId.uuidString) - XCTAssertEqual(preservedDisconnectParams["panel_id"] as? String, restoredPanelId.uuidString) - let preservedCaller = try XCTUnwrap(preservedDisconnectParams["caller"] as? [String: Any]) - XCTAssertEqual(preservedCaller["workspace_id"] as? String, restoredWorkspace.id.uuidString) - XCTAssertEqual(preservedCaller["surface_id"] as? String, restoredPanelId.uuidString) - XCTAssertEqual(preservedCaller["panel_id"] as? String, restoredPanelId.uuidString) - - restoredWorkspace.configureRemoteConnection(configuration, autoConnect: false) - // Configuration alone leaves the explicitly disconnected terminal inactive. - // A new launch rejoins the preserved PTY and its restored relay aliases. - XCTAssertFalse(restoredWorkspace.remotePTYSessionIDMatches(panelId: restoredPanelId, sessionID: sessionID)) - restoredWorkspace.trackRemoteTerminalSurface(restoredPanelId) - XCTAssertTrue(restoredWorkspace.remotePTYSessionIDMatches(panelId: restoredPanelId, sessionID: sessionID)) - let reconfiguredParams = try decodedParams(from: restoredWorkspace.rewriteRemoteRelayCommandLine(requestData)) - XCTAssertEqual(reconfiguredParams["workspace_id"] as? String, restoredWorkspace.id.uuidString) - XCTAssertEqual(reconfiguredParams["surface_id"] as? String, restoredPanelId.uuidString) - XCTAssertEqual(reconfiguredParams["panel_id"] as? String, restoredPanelId.uuidString) - - restoredWorkspace.disconnectRemoteConnection(clearConfiguration: true) - XCTAssertNil( - restoredWorkspace.sessionSnapshot(includeScrollback: false) - .panels.first { $0.id == restoredPanelId }?.terminal?.remotePTYSessionID - ) - let clearedParams = try decodedParams(from: restoredWorkspace.rewriteRemoteRelayCommandLine(requestData)) - XCTAssertEqual(clearedParams["workspace_id"] as? String, originalWorkspaceId.uuidString) - XCTAssertEqual(clearedParams["surface_id"] as? String, originalPanelId.uuidString) - XCTAssertEqual(clearedParams["panel_id"] as? String, originalPanelId.uuidString) - } - func testRemoteRelayAmbiguousTabIDAliasesPreferWorkspaceOnCollision() throws { let staleID = UUID() let restoredWorkspaceID = UUID() @@ -2925,276 +2466,6 @@ final class TabManagerSessionSnapshotTests: XCTestCase { } } - func testPersistentSSHPTYRestoreRewritesMovedSourceWorkspaceContextID() throws { - try XCTSkipIf(true, "Preserved SSH snapshots restore through tuiSSHConfiguration since 5f0d2227241; rewrite against cmux-tui.") - let manager = TabManager() - let sourceWorkspace = manager.addWorkspace(select: true) - sourceWorkspace.setCustomTitle("Moved Relay Source") - let destinationWorkspace = manager.addWorkspace(select: false) - destinationWorkspace.setCustomTitle("Moved Relay Destination") - let persistentDaemonSlot = "ssh-relay-moved-alias" - let configuration = WorkspaceRemoteConfiguration( - destination: "dev@example.com", - port: 2222, - identityFile: nil, - sshOptions: [ - "StrictHostKeyChecking=accept-new", - ], - localProxyPort: nil, - relayPort: 64008, - relayID: "relay-moved-alias-test", - relayToken: String(repeating: "d", count: 64), - localSocketPath: "/tmp/cmux-relay-moved-alias.sock", - terminalStartupCommand: SSHPTYAttachStartupCommandBuilder.command(), - preserveAfterTerminalExit: true, - persistentDaemonSlot: persistentDaemonSlot - ) - sourceWorkspace.configureRemoteConnection(configuration, autoConnect: false) - destinationWorkspace.configureRemoteConnection(configuration, autoConnect: false) - - let sourceWorkspaceId = sourceWorkspace.id - let sourcePanelId = try XCTUnwrap(sourceWorkspace.focusedPanelId) - let sessionID = Workspace.defaultSSHPTYSessionID( - workspaceId: sourceWorkspaceId, - panelId: sourcePanelId - ) - let detached = try XCTUnwrap(sourceWorkspace.detachSurface(panelId: sourcePanelId)) - let destinationPaneId = try XCTUnwrap(destinationWorkspace.bonsplitController.allPaneIds.first) - let movedPanelId = try XCTUnwrap( - destinationWorkspace.attachDetachedSurface( - detached, - inPane: destinationPaneId, - focus: true - ) - ) - XCTAssertEqual(movedPanelId, sourcePanelId) - XCTAssertEqual( - destinationWorkspace.sessionSnapshot(includeScrollback: false) - .panels.first { $0.id == movedPanelId }?.terminal?.remotePTYSessionID, - sessionID - ) - - let snapshot = manager.sessionSnapshot(includeScrollback: false) - let reservedSocketPath = reserveRemoteRestoreSocket() - defer { cleanupRemoteRestoreSocket(reservedSocketPath) } - - let restored = TabManager() - restored.restoreSessionSnapshot( - snapshot, - excludingWorkspaceIds: Set(manager.tabs.map(\.id)) - ) - - let restoredWorkspace = try XCTUnwrap( - restored.tabs.first { $0.customTitle == "Moved Relay Destination" } - ) - let restoredSnapshot = restoredWorkspace.sessionSnapshot(includeScrollback: false) - let restoredPanelId = try XCTUnwrap( - restoredSnapshot.panels.first { $0.terminal?.remotePTYSessionID == sessionID }?.id - ) - XCTAssertNotEqual(restoredWorkspace.id, destinationWorkspace.id) - XCTAssertNotEqual(restoredWorkspace.id, sourceWorkspaceId) - XCTAssertNotEqual(restoredPanelId, sourcePanelId) - - let request: [String: Any] = [ - "id": "relay-moved-alias-request", - "method": "surface.report_tty", - "params": [ - "workspace_id": sourceWorkspaceId.uuidString, - "surface_id": sourcePanelId.uuidString, - "panel_id": sourcePanelId.uuidString, - "tab_id": sourceWorkspaceId.uuidString, - "session_id": sessionID, - "caller": [ - "workspace_id": sourceWorkspaceId.uuidString, - "surface_id": sourcePanelId.uuidString, - "panel_id": sourcePanelId.uuidString, - "tab_id": sourceWorkspaceId.uuidString, - ], - ], - ] - let requestData = try JSONSerialization.data(withJSONObject: request, options: []) + Data([0x0A]) - let rewrittenData = restoredWorkspace.rewriteRemoteRelayCommandLine(requestData) - let rewritten = try XCTUnwrap(JSONSerialization.jsonObject(with: rewrittenData, options: []) as? [String: Any]) - let params = try XCTUnwrap(rewritten["params"] as? [String: Any]) - - XCTAssertEqual(params["workspace_id"] as? String, restoredWorkspace.id.uuidString) - XCTAssertEqual(params["surface_id"] as? String, restoredPanelId.uuidString) - XCTAssertEqual(params["panel_id"] as? String, restoredPanelId.uuidString) - XCTAssertEqual(params["tab_id"] as? String, restoredWorkspace.id.uuidString) - XCTAssertEqual(params["session_id"] as? String, sessionID) - - let caller = try XCTUnwrap(params["caller"] as? [String: Any]) - XCTAssertEqual(caller["workspace_id"] as? String, restoredWorkspace.id.uuidString) - XCTAssertEqual(caller["surface_id"] as? String, restoredPanelId.uuidString) - XCTAssertEqual(caller["panel_id"] as? String, restoredPanelId.uuidString) - XCTAssertEqual(caller["tab_id"] as? String, restoredWorkspace.id.uuidString) - } - - func testPersistentSSHPTYReattachRewritesStaleRemoteRelayContextIDs() throws { - try XCTSkipIf(true, "Preserved SSH snapshots restore through tuiSSHConfiguration since 5f0d2227241; rewrite against cmux-tui.") - let manager = TabManager() - let remoteWorkspace = manager.addWorkspace(select: true) - remoteWorkspace.setCustomTitle("Relay Alias Reattach SSH") - let persistentDaemonSlot = "ssh-relay-reattach-alias" - let configuration = WorkspaceRemoteConfiguration( - destination: "dev@example.com", - port: 2222, - identityFile: nil, - sshOptions: [ - "StrictHostKeyChecking=accept-new", - ], - localProxyPort: nil, - relayPort: 64007, - relayID: "relay-reattach-alias-test", - relayToken: String(repeating: "b", count: 64), - localSocketPath: "/tmp/cmux-relay-reattach-alias.sock", - terminalStartupCommand: SSHPTYAttachStartupCommandBuilder.command(), - preserveAfterTerminalExit: true, - persistentDaemonSlot: persistentDaemonSlot - ) - remoteWorkspace.configureRemoteConnection(configuration, autoConnect: false) - let originalWorkspaceId = remoteWorkspace.id - let originalPanelId = try XCTUnwrap(remoteWorkspace.focusedPanelId) - let sessionID = Workspace.defaultSSHPTYSessionID( - workspaceId: originalWorkspaceId, - panelId: originalPanelId - ) - - let snapshot = manager.sessionSnapshot(includeScrollback: false) - let reservedSocketPath = reserveRemoteRestoreSocket() - defer { cleanupRemoteRestoreSocket(reservedSocketPath) } - - let restored = TabManager() - restored.restoreSessionSnapshot(snapshot) - - let restoredWorkspace = try XCTUnwrap(restored.tabs.first { $0.customTitle == "Relay Alias Reattach SSH" }) - let restoredPanelId = try XCTUnwrap(restoredWorkspace.focusedPanelId) - let ended = restoredWorkspace.markRemotePTYAttachEnded(surfaceId: restoredPanelId, sessionID: sessionID) - XCTAssertTrue(ended.clearedRemotePTYSession) - XCTAssertTrue(ended.untrackedRemoteTerminal) - - let paneId = try XCTUnwrap(restoredWorkspace.bonsplitController.allPaneIds.first) - let attachStartupCommand = Workspace.sshPTYAttachStartupCommand(sessionID: sessionID) - XCTAssertTrue(attachStartupCommand.hasPrefix("/bin/sh -c "), attachStartupCommand) - let reattachedPanel = try XCTUnwrap( - restoredWorkspace.newTerminalSurface( - inPane: paneId, - focus: true, - initialCommand: attachStartupCommand, - remotePTYSessionID: sessionID - ) - ) - XCTAssertNotEqual(reattachedPanel.id, restoredPanelId) - - let request: [String: Any] = [ - "id": "relay-reattach-alias-request", - "method": "surface.report_tty", - "params": [ - "workspace_id": originalWorkspaceId.uuidString, - "surface_id": originalPanelId.uuidString, - "panel_id": originalPanelId.uuidString, - "preferred_panel_id": originalPanelId.uuidString, - "target_panel_id": originalPanelId.uuidString, - "created_panel_id": originalPanelId.uuidString, - "tab_id": originalPanelId.uuidString, - "before_panel_id": originalPanelId.uuidString, - "before_surface_id": originalPanelId.uuidString, - "after_panel_id": originalPanelId.uuidString, - "after_surface_id": originalPanelId.uuidString, - "workspace_ids": [originalWorkspaceId.uuidString], - "panel_ids": [originalPanelId.uuidString], - "surface_ids": [originalPanelId.uuidString], - "tab_ids": [originalWorkspaceId.uuidString, originalPanelId.uuidString], - "session_id": sessionID, - ], - ] - let requestData = try JSONSerialization.data(withJSONObject: request, options: []) + Data([0x0A]) - let rewrittenData = restoredWorkspace.rewriteRemoteRelayCommandLine(requestData) - let rewritten = try XCTUnwrap(JSONSerialization.jsonObject(with: rewrittenData, options: []) as? [String: Any]) - let params = try XCTUnwrap(rewritten["params"] as? [String: Any]) - - XCTAssertEqual(params["workspace_id"] as? String, restoredWorkspace.id.uuidString) - XCTAssertEqual(params["surface_id"] as? String, reattachedPanel.id.uuidString) - XCTAssertEqual(params["panel_id"] as? String, reattachedPanel.id.uuidString) - XCTAssertEqual(params["preferred_panel_id"] as? String, reattachedPanel.id.uuidString) - XCTAssertEqual(params["target_panel_id"] as? String, reattachedPanel.id.uuidString) - XCTAssertEqual(params["created_panel_id"] as? String, reattachedPanel.id.uuidString) - XCTAssertEqual(params["tab_id"] as? String, reattachedPanel.id.uuidString) - XCTAssertEqual(params["before_panel_id"] as? String, reattachedPanel.id.uuidString) - XCTAssertEqual(params["before_surface_id"] as? String, reattachedPanel.id.uuidString) - XCTAssertEqual(params["after_panel_id"] as? String, reattachedPanel.id.uuidString) - XCTAssertEqual(params["after_surface_id"] as? String, reattachedPanel.id.uuidString) - XCTAssertEqual(params["workspace_ids"] as? [String], [restoredWorkspace.id.uuidString]) - XCTAssertEqual(params["panel_ids"] as? [String], [reattachedPanel.id.uuidString]) - XCTAssertEqual(params["surface_ids"] as? [String], [reattachedPanel.id.uuidString]) - XCTAssertEqual(params["tab_ids"] as? [String], [restoredWorkspace.id.uuidString, reattachedPanel.id.uuidString]) - XCTAssertEqual(params["session_id"] as? String, sessionID) - } - - func testPersistentSSHPTYRestoreFallsBackToSnapshotPanelDefaultSessionIDWhenActiveMarkerExists() throws { - try XCTSkipIf(true, "Preserved SSH snapshots restore through tuiSSHConfiguration since 5f0d2227241; rewrite against cmux-tui.") - let manager = TabManager() - let remoteWorkspace = manager.addWorkspace(select: true) - remoteWorkspace.setCustomTitle("Legacy Persistent SSH") - let persistentDaemonSlot = "ssh-legacy-persist" - let configuration = WorkspaceRemoteConfiguration( - destination: "dev@example.com", - port: 2222, - identityFile: nil, - sshOptions: [ - "StrictHostKeyChecking=accept-new", - ], - localProxyPort: nil, - relayPort: 64004, - relayID: "relay-legacy-persist", - relayToken: String(repeating: "f", count: 64), - localSocketPath: "/tmp/cmux-legacy-persist.sock", - terminalStartupCommand: SSHPTYAttachStartupCommandBuilder.command(), - preserveAfterTerminalExit: true, - persistentDaemonSlot: persistentDaemonSlot - ) - remoteWorkspace.configureRemoteConnection(configuration, autoConnect: false) - let originalPanelId = try XCTUnwrap(remoteWorkspace.focusedPanelId) - let expectedSessionID = Workspace.defaultSSHPTYSessionID( - workspaceId: remoteWorkspace.id, - panelId: originalPanelId - ) - - var legacySnapshot = manager.sessionSnapshot(includeScrollback: false) - let workspaceIndex = try XCTUnwrap( - legacySnapshot.workspaces.firstIndex { $0.customTitle == "Legacy Persistent SSH" } - ) - XCTAssertEqual(legacySnapshot.workspaces[workspaceIndex].workspaceId, remoteWorkspace.id) - let panelIndex = try XCTUnwrap( - legacySnapshot.workspaces[workspaceIndex].panels.firstIndex { $0.id == originalPanelId } - ) - legacySnapshot.workspaces[workspaceIndex].panels[panelIndex].terminal?.remotePTYSessionID = nil - legacySnapshot.workspaces[workspaceIndex].panels[panelIndex].terminal?.isRemoteTerminal = true - - let reservedSocketPath = reserveRemoteRestoreSocket() - defer { cleanupRemoteRestoreSocket(reservedSocketPath) } - - let restored = TabManager() - restored.restoreSessionSnapshot(legacySnapshot, excludingWorkspaceIds: [remoteWorkspace.id]) - - let restoredWorkspace = try XCTUnwrap(restored.tabs.first { $0.customTitle == "Legacy Persistent SSH" }) - XCTAssertNotEqual(restoredWorkspace.id, remoteWorkspace.id) - let restoredPanelId = try XCTUnwrap(restoredWorkspace.focusedPanelId) - let restoredInitialCommand = try XCTUnwrap( - restoredWorkspace.terminalPanel(for: restoredPanelId)?.surface.debugInitialCommand() - ) - XCTAssertTrue(restoredInitialCommand.hasPrefix("/bin/sh -c "), restoredInitialCommand) - XCTAssertTrue(restoredInitialCommand.contains("ssh-pty-attach"), restoredInitialCommand) - XCTAssertTrue(restoredInitialCommand.contains("--require-existing"), restoredInitialCommand) - XCTAssertTrue(restoredInitialCommand.contains(expectedSessionID), restoredInitialCommand) - XCTAssertTrue(restoredWorkspace.remotePTYSessionIDMatches(panelId: restoredPanelId, sessionID: expectedSessionID)) - XCTAssertEqual( - restoredWorkspace.sessionSnapshot(includeScrollback: false) - .panels.first { $0.id == restoredPanelId }?.terminal?.remotePTYSessionID, - expectedSessionID - ) - } - func testPersistentSSHPTYRestoreDoesNotReattachEndedSnapshotPanel() throws { try XCTSkipIf(true, "Preserved SSH snapshots restore through tuiSSHConfiguration since 5f0d2227241; rewrite against cmux-tui.") let manager = TabManager() @@ -3257,108 +2528,6 @@ final class TabManagerSessionSnapshotTests: XCTestCase { ) } - func testPersistentSSHPTYRestorePreservesLocalTerminalWorkingDirectory() throws { - try XCTSkipIf(true, "Possible regression since 5f0d2227241: Workspace.createPanel restores every terminal in a preserved SSH TUI workspace as a device mirror, including local shells; needs the migration owner's call.") - let manager = TabManager() - let remoteWorkspace = manager.addWorkspace(select: true) - remoteWorkspace.setCustomTitle("Remote Workspace With Local Terminal") - let configuration = WorkspaceRemoteConfiguration( - destination: "dev@example.com", - port: 2222, - identityFile: nil, - sshOptions: [ - "StrictHostKeyChecking=accept-new", - ], - localProxyPort: nil, - relayPort: 64020, - relayID: "relay-local-terminal", - relayToken: String(repeating: "a", count: 64), - localSocketPath: "/tmp/cmux-local-terminal.sock", - terminalStartupCommand: SSHPTYAttachStartupCommandBuilder.command(), - preserveAfterTerminalExit: true, - persistentDaemonSlot: "ssh-local-terminal" - ) - remoteWorkspace.configureRemoteConnection(configuration, autoConnect: false) - let paneId = try XCTUnwrap(remoteWorkspace.bonsplitController.allPaneIds.first) - let localDirectory = "/tmp/cmux-local-terminal" - let localPanel = try XCTUnwrap( - remoteWorkspace.newTerminalSurface( - inPane: paneId, - focus: true, - workingDirectory: localDirectory, - suppressWorkspaceRemoteStartupCommand: true - ) - ) - remoteWorkspace.setPanelCustomTitle(panelId: localPanel.id, title: "Local Shell") - - let snapshot = manager.sessionSnapshot(includeScrollback: false) - let persistedWorkspace = try XCTUnwrap( - snapshot.workspaces.first { $0.customTitle == "Remote Workspace With Local Terminal" } - ) - let persistedLocalPanel = try XCTUnwrap( - persistedWorkspace.panels.first { $0.customTitle == "Local Shell" } - ) - XCTAssertEqual(persistedLocalPanel.terminal?.isRemoteTerminal, false) - XCTAssertEqual(persistedLocalPanel.terminal?.workingDirectory, localDirectory) - - let reservedSocketPath = reserveRemoteRestoreSocket() - defer { cleanupRemoteRestoreSocket(reservedSocketPath) } - - let restored = TabManager() - restored.restoreSessionSnapshot(snapshot) - - let restoredWorkspace = try XCTUnwrap(restored.tabs.first { $0.customTitle == "Remote Workspace With Local Terminal" }) - let restoredLocalPanel = try XCTUnwrap( - restoredWorkspace.sessionSnapshot(includeScrollback: false) - .panels.first { $0.customTitle == "Local Shell" } - ) - let restoredPanel = try XCTUnwrap(restoredWorkspace.terminalPanel(for: restoredLocalPanel.id)) - XCTAssertNil(restoredPanel.surface.debugInitialCommand()) - XCTAssertEqual(restoredPanel.requestedWorkingDirectory, localDirectory) - } - - func testSessionSnapshotFallsBackWhenPersistentSSHPTYRestoreHasNoSocketPath() throws { - try XCTSkipIf(true, "Preserved SSH snapshots restore through tuiSSHConfiguration since 5f0d2227241; rewrite against cmux-tui.") - TerminalController.shared.stop(cleanupDiscoveryState: true) - defer { TerminalController.shared.stop(cleanupDiscoveryState: true) } - - let manager = TabManager() - let remoteWorkspace = manager.addWorkspace(select: true) - remoteWorkspace.setCustomTitle("Persistent SSH Without Socket") - remoteWorkspace.configureRemoteConnection( - WorkspaceRemoteConfiguration( - destination: "dev@example.com", - port: 2222, - identityFile: nil, - sshOptions: ["StrictHostKeyChecking=accept-new"], - localProxyPort: nil, - relayPort: 64018, - relayID: "relay-no-socket", - relayToken: String(repeating: "f", count: 64), - localSocketPath: "/tmp/cmux-no-socket.sock", - terminalStartupCommand: SSHPTYAttachStartupCommandBuilder.command(), - preserveAfterTerminalExit: true, - persistentDaemonSlot: "ssh-no-socket" - ), - autoConnect: false - ) - - let snapshot = manager.sessionSnapshot(includeScrollback: false) - XCTAssertNil(TerminalController.shared.currentSocketPathForRemoteRestore()) - - let restored = TabManager() - restored.restoreSessionSnapshot(snapshot) - - let restoredWorkspace = try XCTUnwrap(restored.tabs.first { $0.customTitle == "Persistent SSH Without Socket" }) - XCTAssertEqual(restoredWorkspace.remoteConfiguration?.preserveAfterTerminalExit, false) - XCTAssertNil(restoredWorkspace.remoteConfiguration?.relayPort) - XCTAssertNil(restoredWorkspace.remoteConfiguration?.localSocketPath) - XCTAssertNil(restoredWorkspace.remoteConfiguration?.persistentDaemonSlot) - let terminalStartupCommand = try XCTUnwrap(restoredWorkspace.remoteConfiguration?.terminalStartupCommand) - XCTAssertFalse(terminalStartupCommand.contains("ssh-pty-attach"), terminalStartupCommand) - XCTAssertTrue(terminalStartupCommand.contains("ssh -p 2222"), terminalStartupCommand) - } - func testSessionSnapshotFallsBackFromSkipBootstrapPersistentSSHPTYWithoutDaemonBridge() throws { let manager = TabManager() let remoteWorkspace = manager.addWorkspace(select: true) @@ -3570,48 +2739,6 @@ final class TabManagerSessionSnapshotTests: XCTestCase { XCTAssertFalse(restoredInitialCommand.contains("ssh -p 22"), restoredInitialCommand) } - func testSessionRemoteWorkspaceSnapshotRequiresPersistentDaemonSlotForPTYRestore() throws { - try XCTSkipIf(true, "Preserved SSH snapshots restore through tuiSSHConfiguration since 5f0d2227241; rewrite against cmux-tui.") - let snapshot = SessionRemoteWorkspaceSnapshot( - transport: .ssh, - destination: "dev@example.com", - port: 2222, - identityFile: nil, - sshOptions: [ - "StrictHostKeyChecking=accept-new", - "ControlMaster=auto", - "ControlPersist=600", - ], - preserveAfterTerminalExit: true, - skipDaemonBootstrap: nil, - relayPort: 64003, - persistentDaemonSlot: nil - ) - - let configuration = try XCTUnwrap(snapshot.workspaceConfiguration(localSocketPath: "/tmp/cmux-restore.sock")) - - XCTAssertEqual(configuration.preserveAfterTerminalExit, false) - XCTAssertNil(configuration.foregroundAuthToken) - XCTAssertNil(configuration.persistentDaemonSlot) - XCTAssertEqual(configuration.relayPort, 64003) - XCTAssertEqual(configuration.localSocketPath, "/tmp/cmux-restore.sock") - XCTAssertFalse(configuration.sshOptions.contains { $0.hasPrefix("ControlPath") }) - let startupCommand = try XCTUnwrap(configuration.terminalStartupCommand) - XCTAssertFalse(startupCommand.contains("ssh-pty-attach"), startupCommand) - XCTAssertTrue( - startupCommand.contains( - "ssh -o RemoteCommand=none -p 2222 -o StrictHostKeyChecking=accept-new" - ), - startupCommand - ) - XCTAssertTrue( - startupCommand.contains( - "rpc workspace.remote.terminal_session_connected" - ), - startupCommand - ) - } - func testSessionRemoteWorkspaceSnapshotRestoresOrdinarySSHWithLifecycleReporting() throws { let snapshot = SessionRemoteWorkspaceSnapshot( transport: .ssh, @@ -3707,136 +2834,6 @@ final class TabManagerSessionSnapshotTests: XCTestCase { ) } - func testSessionRemoteWorkspaceSnapshotRequiresRelayPortForPTYRestore() throws { - try XCTSkipIf(true, "Preserved SSH snapshots restore through tuiSSHConfiguration since 5f0d2227241; rewrite against cmux-tui.") - let snapshot = SessionRemoteWorkspaceSnapshot( - transport: .ssh, - destination: "dev@example.com", - port: 2222, - identityFile: nil, - sshOptions: [ - "StrictHostKeyChecking=accept-new", - "ControlMaster=auto", - "ControlPersist=600", - ], - preserveAfterTerminalExit: true, - skipDaemonBootstrap: nil, - relayPort: nil, - persistentDaemonSlot: "ssh-restore-slot" - ) - - let configuration = try XCTUnwrap(snapshot.workspaceConfiguration(localSocketPath: "/tmp/cmux-restore.sock")) - - XCTAssertEqual(configuration.preserveAfterTerminalExit, false) - XCTAssertNil(configuration.foregroundAuthToken) - XCTAssertNil(configuration.persistentDaemonSlot) - XCTAssertNil(configuration.relayPort) - XCTAssertNil(configuration.localSocketPath) - XCTAssertFalse(configuration.terminalStartupCommand?.contains("ssh-pty-attach") == true) - XCTAssertEqual(configuration.terminalStartupCommand, "/usr/bin/ssh -p 2222 -o StrictHostKeyChecking=accept-new -tt dev@example.com") - } - - func testSessionRemoteWorkspaceSnapshotRequiresLocalSocketPathForPTYRestore() throws { - try XCTSkipIf(true, "Preserved SSH snapshots restore through tuiSSHConfiguration since 5f0d2227241; rewrite against cmux-tui.") - let snapshot = SessionRemoteWorkspaceSnapshot( - transport: .ssh, - destination: "dev@example.com", - port: 2222, - identityFile: nil, - sshOptions: [ - "StrictHostKeyChecking=accept-new", - "ControlMaster=auto", - "ControlPersist=600", - ], - preserveAfterTerminalExit: true, - skipDaemonBootstrap: nil, - relayPort: 64003, - persistentDaemonSlot: "ssh-restore-slot" - ) - - let configuration = try XCTUnwrap(snapshot.workspaceConfiguration(localSocketPath: " ")) - - XCTAssertEqual(configuration.preserveAfterTerminalExit, false) - XCTAssertNil(configuration.foregroundAuthToken) - XCTAssertNil(configuration.persistentDaemonSlot) - XCTAssertNil(configuration.relayPort) - XCTAssertNil(configuration.localSocketPath) - XCTAssertFalse(configuration.terminalStartupCommand?.contains("ssh-pty-attach") == true) - XCTAssertEqual(configuration.terminalStartupCommand, "/usr/bin/ssh -p 2222 -o StrictHostKeyChecking=accept-new -tt dev@example.com") - } - - func testSessionRemoteWorkspaceSnapshotStripsTransientControlOptionsWhenPreservedRestoreFallsBack() throws { - try XCTSkipIf(true, "Preserved SSH snapshots restore through tuiSSHConfiguration since 5f0d2227241; rewrite against cmux-tui.") - let snapshot = SessionRemoteWorkspaceSnapshot( - transport: .ssh, - destination: "dev@example.com", - port: 2222, - identityFile: nil, - sshOptions: [ - "StrictHostKeyChecking=accept-new", - "ControlMaster=auto", - "ControlPersist=600", - "ControlPath=/tmp/cmux-ssh-501-64003-%C", - ], - preserveAfterTerminalExit: true, - skipDaemonBootstrap: nil, - relayPort: 64003, - persistentDaemonSlot: "ssh-restore-slot" - ) - - let configuration = try XCTUnwrap( - snapshot.workspaceConfiguration(localSocketPath: nil, preserveSSHOptions: true) - ) - - XCTAssertEqual(configuration.preserveAfterTerminalExit, false) - XCTAssertEqual(configuration.sshOptions, ["StrictHostKeyChecking=accept-new"]) - XCTAssertEqual( - configuration.terminalStartupCommand, - "/usr/bin/ssh -p 2222 -o StrictHostKeyChecking=accept-new -tt dev@example.com" - ) - } - - func testSessionRemoteWorkspaceSnapshotRequiresValidPersistentDaemonSlotForPTYRestore() throws { - try XCTSkipIf(true, "Preserved SSH snapshots restore through tuiSSHConfiguration since 5f0d2227241; rewrite against cmux-tui.") - let snapshot = SessionRemoteWorkspaceSnapshot( - transport: .ssh, - destination: "dev@example.com", - port: 2222, - identityFile: nil, - sshOptions: [ - "StrictHostKeyChecking=accept-new", - "ControlMaster=auto", - "ControlPersist=600", - ], - preserveAfterTerminalExit: true, - skipDaemonBootstrap: nil, - relayPort: 64003, - persistentDaemonSlot: "../bad" - ) - - let configuration = try XCTUnwrap(snapshot.workspaceConfiguration(localSocketPath: "/tmp/cmux-restore.sock")) - - XCTAssertEqual(configuration.preserveAfterTerminalExit, false) - XCTAssertNil(configuration.foregroundAuthToken) - XCTAssertNil(configuration.persistentDaemonSlot) - XCTAssertEqual(configuration.relayPort, 64003) - XCTAssertEqual(configuration.localSocketPath, "/tmp/cmux-restore.sock") - let startupCommand = try XCTUnwrap(configuration.terminalStartupCommand) - XCTAssertFalse(startupCommand.contains("ssh-pty-attach"), startupCommand) - XCTAssertTrue( - startupCommand.contains( - "ssh -o RemoteCommand=none -p 2222 -o StrictHostKeyChecking=accept-new" - ), - startupCommand - ) - XCTAssertTrue( - startupCommand.contains( - "rpc workspace.remote.terminal_session_connected" - ), - startupCommand - ) - } - func testSessionRemoteWorkspaceSnapshotDropsInvalidSSHPortFromReconnectCommand() throws { let snapshot = SessionRemoteWorkspaceSnapshot( transport: .ssh, @@ -3981,19 +2978,6 @@ final class TabManagerSessionSnapshotTests: XCTestCase { ) } - private static func decodedSSHPTYCommandB64(in command: String) -> String? { - let words = TerminalStartupWorkingDirectoryPrefix.shellWordRanges(command).map(\.value) - guard let script = words.dropFirst(2).first else { return nil } - let scriptWords = TerminalStartupWorkingDirectoryPrefix.shellWordRanges(script).map(\.value) - guard let commandIndex = scriptWords.firstIndex(of: "--command-b64"), - let token = scriptWords.dropFirst(commandIndex + 1).first else { return nil } - // The retry script terminates this invocation with a shell separator, - // which the whitespace-oriented word parser retains on the argument. - let encoded = token.hasSuffix(";") ? String(token.dropLast()) : token - guard let data = Data(base64Encoded: encoded) else { return nil } - return String(data: data, encoding: .utf8) - } - private func waitForClosedHistoryCount( _ expectedCount: Int, in store: ClosedItemHistoryStore, diff --git a/cmuxTests/TerminalStartupRestoreFailureTests.swift b/cmuxTests/TerminalStartupRestoreFailureTests.swift index d1d9c4ea41b2..5d852de0a1e0 100644 --- a/cmuxTests/TerminalStartupRestoreFailureTests.swift +++ b/cmuxTests/TerminalStartupRestoreFailureTests.swift @@ -23,83 +23,6 @@ private final class RejectingRestoreTabDelegate: BonsplitDelegate { @MainActor @Suite("Terminal startup restore failure handling", .serialized) struct TerminalStartupRestoreFailureTests { - @Test("Binding-only persistent SSH resume attaches after topology commit without local census admission", .disabled("Preserved SSH snapshots restore through tuiSSHConfiguration since 5f0d2227241; rewrite against cmux-tui.")) - func persistentSSHBindingOnlyResumeBypassesLocalCensusAdmission() throws { - let defaults = try makeAutoResumeDefaults() - defer { defaults.store.removePersistentDomain(forName: defaults.name) } - TerminalController.shared.stop(cleanupDiscoveryState: true) - let socketPath = TerminalController.shared.reserveStartupSocketPath( - "/tmp/cmux-terminal-restore-\(UUID().uuidString).sock" - ) - defer { - TerminalController.shared.stop(cleanupDiscoveryState: true) - try? FileManager.default.removeItem(atPath: socketPath) - try? FileManager.default.removeItem(atPath: socketPath + ".lock") - } - let source = Workspace(agentSessionAutoResumeDefaults: defaults.store) - defer { source.teardownAllPanels() } - source.configureRemoteConnection(remoteConfiguration(), autoConnect: false) - - let savedPanelID = try #require(source.focusedPanelId) - let remotePTYSessionID = Workspace.defaultSSHPTYSessionID( - workspaceId: source.id, - panelId: savedPanelID - ) - source.remotePTYSessionIDsByPanelId[savedPanelID] = remotePTYSessionID - source.surfaceResumeBindingsByPanelId[savedPanelID] = SurfaceResumeBindingSnapshot( - name: "Codex", - kind: "codex", - command: "cd '/srv/project' && codex resume persistent-ssh-session", - cwd: "/srv/project", - checkpointId: "persistent-ssh-session", - source: "agent-hook", - autoResume: true, - launchFlavor: .persistentSSH(SurfaceResumeRemoteContext( - workspaceID: source.id, - surfaceID: savedPanelID, - persistentPTYSessionID: remotePTYSessionID - )), - updatedAt: 1_800_000_300 - ) - source.updatePanelShellActivityState( - panelId: savedPanelID, - state: .commandRunning - ) - var snapshot = source.sessionSnapshot(includeScrollback: false) - let savedPanelIndex = try #require( - snapshot.panels.firstIndex { $0.id == savedPanelID } - ) - snapshot.panels[savedPanelIndex].terminal?.wasAgentRunning = true - #expect(snapshot.panels[savedPanelIndex].terminal?.agent == nil) - #expect(snapshot.panels[savedPanelIndex].terminal?.wasAgentRunning == true) - - let restored = Workspace(agentSessionAutoResumeDefaults: defaults.store) - defer { restored.teardownAllPanels() } - let restoredIDs = restored.restoreSessionSnapshot( - snapshot, - startupRestoreCommitOwner: .tabManagerTopology - ) - let restoredPanelID = try #require(restoredIDs[savedPanelID]) - let restoredPanel = try #require(restored.terminalPanel(for: restoredPanelID)) - let startupCommand = try #require(restoredPanel.surface.debugInitialCommand()) - let remoteCommand = try decodedRemoteCommand(from: startupCommand) - let initialCommand = try decodedInitialCommand(from: remoteCommand) - - #expect(startupCommand.contains("ssh-pty-attach")) - #expect(initialCommand.contains("/srv/project"), "\(initialCommand)") - #expect(initialCommand.contains("codex resume persistent-ssh-session"), "\(initialCommand)") - #expect(!initialCommand.contains("cmux restore"), "\(initialCommand)") - #expect(!restoredPanel.surface.canCreateRuntimeSurface) - - restored.terminalStartupRestoreCoordinator.commitPendingRestores( - panelIDs: [restoredPanelID] - ) - // The remote daemon owns persistent PTY admission. A local census - // must not prevent attaching the existing remote session. - #expect(restoredPanel.surface.canCreateRuntimeSurface) - #expect(restored.deferredAgentResumeRestoresByPanelId[restoredPanelID] == nil) - } - @Test("Transferred persistent SSH restore adopts the destination owner") func transferredPersistentSSHRestoreRetargetsRemoteOwner() throws { let panelID = UUID() @@ -425,47 +348,4 @@ struct TerminalStartupRestoreFailureTests { return (store, name) } - private func decodedRemoteCommand(from startupCommand: String) throws -> String { - let words = TerminalStartupWorkingDirectoryPrefix.shellWordRanges(startupCommand).map(\.value) - let script = try #require(words.first(where: { $0.contains("--command-b64") })) - let scriptWords = TerminalStartupWorkingDirectoryPrefix.shellWordRanges(script).map(\.value) - let commandIndex = try #require(scriptWords.firstIndex(of: "--command-b64")) - let token = try #require(scriptWords.dropFirst(commandIndex + 1).first) - let encoded = token.hasSuffix(";") ? String(token.dropLast()) : token - let data = try #require(Data(base64Encoded: encoded)) - return try #require(String(data: data, encoding: .utf8)) - } - - private func decodedInitialCommand(from bootstrap: String) throws -> String { - let payloadLine = try #require(bootstrap.split(separator: "\n").first { line in - line.contains("printf %s '") && line.contains("> \"$cmux_initial_command_tmp\"") - }) - let prefixRange = try #require(payloadLine.range(of: "printf %s '")) - let encodedSuffix = payloadLine[prefixRange.upperBound...] - let closingQuote = try #require(encodedSuffix.firstIndex(of: "'")) - let encodedCommand = String(encodedSuffix[.. WorkspaceRemoteConfiguration { - WorkspaceRemoteConfiguration( - transport: .ssh, - terminalTransport: .ssh, - destination: "dev@example.com", - port: 22, - identityFile: nil, - sshOptions: ["StrictHostKeyChecking=accept-new"], - localProxyPort: nil, - relayPort: 64_089, - relayID: "relay-terminal-startup-failure", - relayToken: String(repeating: "a", count: 64), - localSocketPath: "/tmp/cmux-terminal-startup-failure.sock", - terminalStartupCommand: SSHPTYAttachStartupCommandBuilder.command( - requireExisting: false - ), - preserveAfterTerminalExit: true, - persistentDaemonSlot: "ssh-terminal-startup-failure" - ) - } } diff --git a/cmuxTests/WorkspaceRemoteConnectionTests.swift b/cmuxTests/WorkspaceRemoteConnectionTests.swift index 6ec703028a58..a093a217d107 100644 --- a/cmuxTests/WorkspaceRemoteConnectionTests.swift +++ b/cmuxTests/WorkspaceRemoteConnectionTests.swift @@ -109,10 +109,6 @@ private struct ScriptedRemoteProcessRunner: RemoteSessionProcessRunning, @unchec } } -private func remoteDaemonServeCommand(_ command: String) -> Bool { - command.contains("serve") && command.contains("--stdio") -} - @MainActor private final class NativeSSHCleanupRecorder { var arguments: [[String]] = [] @@ -2098,7 +2094,10 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { // becoming another scripted process owner. sshOptions: ["ControlMaster=no"], localProxyPort: nil, - relayPort: 64036, + // A relay is what keeps this configuration on the cmuxd-remote + // lifecycle: the CLI's no-TTY `cmux ssh` path sends one, and a + // relay-less SSH configuration belongs to cmux-tui instead. + relayPort: 64_011, relayID: String(repeating: "a", count: 16), relayToken: String(repeating: "b", count: 64), localSocketPath: "/tmp/cmux-debug-test.sock", @@ -2128,145 +2127,6 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { XCTAssertEqual(try XCTUnwrap(capturedPayload), Data("fake daemon".utf8)) } - @MainActor - func testPersistentPTYBootstrapReinstallsOldDaemonMissingPTYCapability() async throws { - let fileManager = FileManager.default - let directoryURL = fileManager.temporaryDirectory.appendingPathComponent( - "cmux-remote-daemon-capability-reinstall-\(UUID().uuidString)", - isDirectory: true - ) - try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true) - defer { try? fileManager.removeItem(at: directoryURL) } - let fakeDaemonData = Data("fake daemon".utf8) - let fakeDaemonURL = directoryURL.appendingPathComponent("cmuxd-remote", isDirectory: false) - try fakeDaemonData.write(to: fakeDaemonURL) - try fileManager.setAttributes([.posixPermissions: 0o755], ofItemAtPath: fakeDaemonURL.path) - - let previousAllowLocalBuild = getenv("CMUX_REMOTE_DAEMON_ALLOW_LOCAL_BUILD").map { String(cString: $0) } - let previousDaemonBinary = getenv("CMUX_REMOTE_DAEMON_BINARY").map { String(cString: $0) } - setenv("CMUX_REMOTE_DAEMON_ALLOW_LOCAL_BUILD", "1", 1) - unsetenv("CMUX_REMOTE_DAEMON_BINARY") - defer { - if let previousAllowLocalBuild { - setenv("CMUX_REMOTE_DAEMON_ALLOW_LOCAL_BUILD", previousAllowLocalBuild, 1) - } else { - unsetenv("CMUX_REMOTE_DAEMON_ALLOW_LOCAL_BUILD") - } - if let previousDaemonBinary { - setenv("CMUX_REMOTE_DAEMON_BINARY", previousDaemonBinary, 1) - } else { - unsetenv("CMUX_REMOTE_DAEMON_BINARY") - } - } - - // Expectation rather than a semaphore, for the reason above: async fulfillment - // yields the main actor to the session transition this test is waiting on. - let uploadInvoked = expectation(description: "daemon upload invoked") - uploadInvoked.assertForOverFulfill = false - let lock = NSLock() - var uploadCommand: String? - var uploadPayload: Data? - var helloCountBeforeUpload = 0 - var helloCount = 0 - let remoteProcessScript: RemoteProcessScript = { executable, arguments, stdin, _ in - if executable == "/usr/bin/ssh" { - let command = arguments.last ?? "" - if command.contains("uname -s") { - return ( - status: 0, - stdout: """ - __CMUX_REMOTE_HOME__=/home/test - __CMUX_REMOTE_OS__=Linux - __CMUX_REMOTE_ARCH__=x86_64 - __CMUX_REMOTE_EXISTS__=yes - __CMUX_REMOTE_SIZE__=123 - """, - stderr: "" - ) - } - if remoteDaemonServeCommand(command) { - lock.withLock { - helloCount += 1 - } - // An override present before bootstrap forces a proactive install and would - // stop this from being a capability-reinstall test. Publish the deterministic - // binary only after the existing daemon's hello; the missing-capability branch - // then acquires this exact file instead of consulting an embedded manifest or - // whichever Go toolchain happens to be on the runner. - setenv("CMUX_REMOTE_DAEMON_BINARY", fakeDaemonURL.path, 1) - return ( - status: 0, - stdout: #"{"id":1,"ok":true,"result":{"name":"cmuxd-remote","version":"old","capabilities":["proxy.stream.push"]}}"# + "\n", - stderr: "" - ) - } - if command.contains("mkdir -p") { - return (status: 0, stdout: "", stderr: "") - } - // The upload streams over the ssh exec channel into a backgrounded `cat`, not scp. Recording how - // many hellos preceded it is what keeps this test about a *reinstall*: an upload - // before any hello would be a first install and would not exercise the - // missing-capability path this test is named for. - if stdin != nil { - lock.withLock { - uploadCommand = command - uploadPayload = stdin - helloCountBeforeUpload = helloCount - } - uploadInvoked.fulfill() - return (status: 1, stdout: "", stderr: "intentional stop after capability reinstall") - } - return (status: 0, stdout: "", stderr: "") - } - if executable == "/usr/bin/scp" { - XCTFail("daemon upload used scp; it is expected to stream over the ssh exec channel") - return (status: 1, stdout: "", stderr: "unexpected scp") - } - XCTFail("unexpected executable \(executable)") - return (status: 1, stdout: "", stderr: "unexpected executable") - } - - let workspace = Workspace() - workspace.remoteSessionProcessRunnerOverrideForTesting = - ScriptedRemoteProcessRunner(script: remoteProcessScript) - let config = WorkspaceRemoteConfiguration( - destination: "test@hpc.example", - port: nil, - identityFile: nil, - // The capability-reinstall path is the behavior under test. A standalone SSH - // transport keeps ControlMaster resolution out of this fixture's process script. - sshOptions: ["ControlMaster=no"], - localProxyPort: nil, - relayPort: 64037, - relayID: String(repeating: "a", count: 16), - relayToken: String(repeating: "b", count: 64), - localSocketPath: "/tmp/cmux-debug-test.sock", - terminalStartupCommand: "ssh-pty-attach", - preserveAfterTerminalExit: true - ) - defer { workspace.disconnectRemoteConnection(clearConfiguration: true) } - - workspace.configureRemoteConnection(config, autoConnect: true) - - await fulfillment(of: [uploadInvoked], timeout: 2.0) - let (capturedCommand, capturedPayload, capturedHelloCount) = lock.withLock { - (uploadCommand, uploadPayload, helloCountBeforeUpload) - } - let command = try XCTUnwrap(capturedCommand) - XCTAssertTrue( - command.contains("/home/test/.cmux/bin/cmuxd-remote/"), - "expected missing pty.session to reinstall the old daemon, got \(command)" - ) - XCTAssertEqual(try XCTUnwrap(capturedPayload), fakeDaemonData) - // Without this the test would also pass on a plain first install, which is not what it is - // named for: the reinstall is only meaningful once a hello has reported the old capabilities. - XCTAssertGreaterThan( - capturedHelloCount, - 0, - "expected the reinstall to follow a capability hello, not to be a first install" - ) - } - @MainActor func testDetachAttachPreservesRemoteTerminalSurfaceTracking() throws { let workspace = Workspace() @@ -3781,13 +3641,6 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { return false } - private func cliTestEnvironment() -> [String: String] { - var environment = ProcessInfo.processInfo.environment - environment["CMUX_CLI_SENTRY_DISABLED"] = "1" - environment["CMUX_CLAUDE_HOOK_SENTRY_DISABLED"] = "1" - return environment - } - private func waitForSocketCommand( state: MockSocketServerState, timeout: TimeInterval, diff --git a/scripts/ci/cmux-unit-test-timings.json b/scripts/ci/cmux-unit-test-timings.json index c0727dd37f74..552bdc037cef 100644 --- a/scripts/ci/cmux-unit-test-timings.json +++ b/scripts/ci/cmux-unit-test-timings.json @@ -553,7 +553,6 @@ "RemoteInitialCommandBootstrapFailureTests": 2371, "RemoteInitialCommandBootstrapTests": 1678, "RemoteLoopbackHTTPRequestRewriterTests": 9, - "RemotePTYReconnectLifecycleTests": 1, "RemoteRelayTmuxCompatAuthorizationTests": 7812, "RemoteResumeBindingTests": 3303, "RemoteSessionCleanupLifecycleTests": 5002,