Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ extension WorkspaceRemoteConfiguration {
sshOptions.filter {
resolver.optionKey($0) != "controlpath"
}
return WorkspaceRemoteConfiguration(
var copy = WorkspaceRemoteConfiguration(
transport: transport,
terminalTransport: terminalTransport,
terminalProfile: terminalProfile,
Expand All @@ -39,5 +39,7 @@ extension WorkspaceRemoteConfiguration {
sshControlMasterLeaseGeneration:
sshControlMasterLeaseGeneration
)
copy.restoredSSHSession = restoredSSHSession
return copy
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 &&
Expand Down Expand Up @@ -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,
Expand All @@ -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.
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -449,6 +458,8 @@ public struct WorkspaceRemoteConfiguration: Equatable, Sendable {
skipDaemonBootstrap: skipDaemonBootstrap,
sshControlMasterLeaseGeneration: generation
)
copy.restoredSSHSession = restoredSSHSession
return copy
}
}

Expand Down Expand Up @@ -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,
Expand All @@ -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
}
}
3 changes: 3 additions & 0 deletions Sources/RemoteTui/SSHTuiWorkspaceCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
6 changes: 4 additions & 2 deletions Sources/RemoteTui/SessionRemoteWorkspaceSnapshot+TuiSSH.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -15,5 +15,7 @@ extension SessionRemoteWorkspaceSnapshot {
terminalStartupCommand: nil, configuredRemoteCommand: configuredRemoteCommand,
agentSocketPath: agentSocketPath, preserveAfterTerminalExit: true
)
configuration.restoredSSHSession = self
return configuration
}
}
11 changes: 11 additions & 0 deletions Sources/SessionRemoteWorkspaceSnapshot+Restore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 0 additions & 4 deletions cmux.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -6742,7 +6741,6 @@
A5001642 /* RemoteInteractiveShellBootstrapBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteInteractiveShellBootstrapBuilder.swift; sourceTree = "<group>"; };
D0C0D0C0D0C0D0C0D0C00004 /* RemoteLoopbackRuntimeBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteLoopbackRuntimeBridge.swift; sourceTree = "<group>"; };
E30790000000000000000003 /* RemotePTYBridgeStrings+App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RemotePTYBridgeStrings+App.swift"; sourceTree = "<group>"; };
B11049000000000000000002 /* RemotePTYReconnectLifecycleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemotePTYReconnectLifecycleTests.swift; sourceTree = "<group>"; };
B11049000000000000000004 /* RemoteRelayTmuxCompatAuthorizationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteRelayTmuxCompatAuthorizationTests.swift; sourceTree = "<group>"; };
A5001641 /* RemoteRelayZshBootstrap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteRelayZshBootstrap.swift; sourceTree = "<group>"; };
7989A0027989A0027989A002 /* RemoteResumeBindingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteResumeBindingTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down
70 changes: 0 additions & 70 deletions cmuxTests/RemotePTYReconnectLifecycleTests.swift

This file was deleted.

Loading
Loading