From b495d439753ba67346c820a48e97585c8b2c0e43 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 26 May 2026 02:25:57 -0700 Subject: [PATCH 01/69] Add detachable SSH PTY daemon persistence --- .github/workflows/tmux-corpus.yml | 6 +- CLI/cmux.swift | 9 + Sources/TerminalController.swift | 20 + Sources/Workspace.swift | 35 +- Sources/WorkspaceRemoteConfiguration.swift | 49 +- ...orkspaceRemoteSSHBatchCommandBuilder.swift | 10 +- ...ifyProcessIntegrationRegressionTests.swift | 10 + .../TabManagerSessionSnapshotTests.swift | 87 +++- ...erminalControllerSocketSecurityTests.swift | 20 + daemon/remote/README.md | 38 +- daemon/remote/TMUX_CORPUS.md | 2 +- daemon/remote/cmd/cmuxd-remote/main.go | 489 +++++++++++++++++- daemon/remote/cmd/cmuxd-remote/main_test.go | 294 +++++++++++ docs/cli-contract.md | 7 + docs/remote-daemon-spec.md | 19 +- tests_v2/test_ssh_remote_detachable_pty.py | 280 ++++++++++ 16 files changed, 1340 insertions(+), 35 deletions(-) create mode 100644 tests_v2/test_ssh_remote_detachable_pty.py diff --git a/.github/workflows/tmux-corpus.yml b/.github/workflows/tmux-corpus.yml index 0934db356d07..0911fa6f816b 100644 --- a/.github/workflows/tmux-corpus.yml +++ b/.github/workflows/tmux-corpus.yml @@ -1,6 +1,10 @@ name: tmux corpus on: + pull_request: + paths: + - ".github/workflows/tmux-corpus.yml" + - "daemon/remote/**" workflow_dispatch: inputs: fuzztime: @@ -41,7 +45,7 @@ jobs: - name: Run tmux corpus fuzz targets working-directory: daemon/remote env: - FUZZTIME: ${{ github.event.inputs.fuzztime || '2m' }} + FUZZTIME: ${{ github.event_name == 'pull_request' && '30s' || github.event.inputs.fuzztime || '2m' }} run: | set -euo pipefail for target in \ diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 4a3223dbb383..35c23b64bd86 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -6495,6 +6495,9 @@ struct CMUXCLI { sshOptions.extraArguments.isEmpty && remoteTerminalBootstrapScript?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false && deferredRemoteReconnectCommandScript != nil + let persistentDaemonSlot = usesPersistentSSHPTY + ? "ssh-\(UUID().uuidString.lowercased())" + : nil let startupInitialSSHCommand = buildSSHCommandText( sshOptions, localCommandScript: combinedLocalCommandScript @@ -6648,6 +6651,9 @@ struct CMUXCLI { } if usesPersistentSSHPTY { configureParams["preserve_after_terminal_exit"] = true + if let persistentDaemonSlot { + configureParams["persistent_daemon_slot"] = persistentDaemonSlot + } } cliDebugLog( @@ -6716,6 +6722,9 @@ struct CMUXCLI { if usesPersistentSSHPTY, let workspaceInitialSurfaceId { payload["ssh_pty_session_id"] = "ssh-\(workspaceId)-\(workspaceInitialSurfaceId)" } + if let persistentDaemonSlot { + payload["persistent_daemon_slot"] = persistentDaemonSlot + } logSSHTiming("complete", extra: "workspace=\(String(workspaceId.prefix(8)))") if jsonOutput { print(jsonString(formatIDs(payload, mode: idFormat))) diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 5234d88cad24..84ee4edf7e8f 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -376,6 +376,10 @@ class TerminalController { return body() } + nonisolated func currentSocketPathForRemoteRestore() -> String { + withListenerState { socketPath } + } + private nonisolated func listenerStateSnapshot() -> ListenerStateSnapshot { withListenerState { ListenerStateSnapshot( @@ -6200,6 +6204,21 @@ class TerminalController { let localSocketPath = v2RawString(params, "local_socket_path") let terminalStartupCommand = v2RawString(params, "terminal_startup_command")? .trimmingCharacters(in: .whitespacesAndNewlines) + let persistentDaemonSlot = v2RawString(params, "persistent_daemon_slot")? + .trimmingCharacters(in: .whitespacesAndNewlines) + if v2HasNonNullParam(params, "persistent_daemon_slot") { + guard let persistentDaemonSlot, + !persistentDaemonSlot.isEmpty, + persistentDaemonSlot.range(of: "^[A-Za-z0-9._-]{1,128}$", options: .regularExpression) != nil, + persistentDaemonSlot != ".", + persistentDaemonSlot != ".." else { + return .err( + code: "invalid_params", + message: "persistent_daemon_slot must contain only letters, numbers, '.', '_' or '-'", + data: nil + ) + } + } let daemonWebSocketURL = v2RawString(params, "daemon_websocket_url")? .trimmingCharacters(in: .whitespacesAndNewlines) let daemonWebSocketToken = v2RawString(params, "daemon_websocket_token")? @@ -6287,6 +6306,7 @@ class TerminalController { foregroundAuthToken: foregroundAuthToken?.isEmpty == true ? nil : foregroundAuthToken, daemonWebSocketEndpoint: daemonWebSocketEndpoint, preserveAfterTerminalExit: preserveAfterTerminalExit, + persistentDaemonSlot: persistentDaemonSlot?.isEmpty == true ? nil : persistentDaemonSlot, skipDaemonBootstrap: skipDaemonBootstrap ) workspace.configureRemoteConnection(config, autoConnect: autoConnect) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 3748412d1a9f..b0bef606826a 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -266,7 +266,9 @@ extension Workspace { invalidatedRestoredAgentFingerprintsByPanelId.removeAll(keepingCapacity: false) surfaceResumeBindingsByPanelId.removeAll(keepingCapacity: false) - let restoredRemoteConfiguration = snapshot.remote?.workspaceConfiguration() + let restoredRemoteConfiguration = snapshot.remote?.workspaceConfiguration( + localSocketPath: TerminalController.shared.currentSocketPathForRemoteRestore() + ) if let restoredRemoteConfiguration { let shouldAutoConnect = Self.shouldAutoConnectRestoredRemote( foregroundAuthToken: restoredRemoteConfiguration.foregroundAuthToken, @@ -1148,11 +1150,16 @@ extension Workspace { restoredAgentResumeInput != nil || (restoredBindingInput != nil && resumeBinding?.isAgentHookBinding == true) ) - // Snapshot session IDs belong to the previous app run's remote daemon. - // Restored persistent SSH terminals start a fresh attach path and replay - // local scrollback until the new remote PTY is ready. - let restoredRemotePTYSessionID: String? = nil - let restoredRemotePTYAttachCommand: String? = nil + let restoredRemotePTYSessionID: String? = { + guard remoteConfiguration?.preserveAfterTerminalExit == true, + remoteConfiguration?.persistentDaemonSlot != nil else { + return nil + } + return normalizedRemotePTYSessionID(snapshot.terminal?.remotePTYSessionID) + }() + let restoredRemotePTYAttachCommand = restoredRemotePTYSessionID.map { + remotePTYAttachStartupCommand(sessionID: $0) + } #if DEBUG if let restorableAgent { let sessionPreview = String(restorableAgent.sessionId.prefix(8)) @@ -1816,7 +1823,8 @@ protocol WorkspaceRemotePTYBridgeRPCClient: AnyObject { nonisolated func remoteDaemonMissingRequiredCapabilitiesMessage(_ missingCapabilities: [String]) -> String { let missing = Set(missingCapabilities) if missing.contains(WorkspaceRemoteDaemonRPCClient.requiredPTYSessionCapability) || - missing.contains(WorkspaceRemoteDaemonRPCClient.requiredPTYSessionTokenCapability) { + missing.contains(WorkspaceRemoteDaemonRPCClient.requiredPTYSessionTokenCapability) || + missing.contains(WorkspaceRemoteDaemonRPCClient.requiredPTYPersistentDaemonCapability) { return "remote daemon does not support persistent SSH PTY sessions; reconnect the remote workspace to update cmux" } return "remote daemon is missing required functionality; reconnect the remote workspace to update cmux" @@ -1829,6 +1837,7 @@ private final class WorkspaceRemoteDaemonRPCClient { static let requiredProxyStreamCapability = "proxy.stream.push" static let requiredPTYSessionCapability = "pty.session" static let requiredPTYSessionTokenCapability = "pty.session.token" + static let requiredPTYPersistentDaemonCapability = "pty.session.persistent_daemon" enum StreamEvent { case data(Data) @@ -1970,6 +1979,9 @@ private final class WorkspaceRemoteDaemonRPCClient { capabilities.append(requiredPTYSessionCapability) capabilities.append(requiredPTYSessionTokenCapability) } + if configuration.persistentDaemonSlot != nil { + capabilities.append(requiredPTYPersistentDaemonCapability) + } return capabilities } @@ -6654,7 +6666,8 @@ final class WorkspaceRemoteSessionController { private var bakedDaemonPreflightRequiredCapabilities: [String] { requiredDaemonCapabilities.filter { $0 != WorkspaceRemoteDaemonRPCClient.requiredPTYSessionCapability && - $0 != WorkspaceRemoteDaemonRPCClient.requiredPTYSessionTokenCapability + $0 != WorkspaceRemoteDaemonRPCClient.requiredPTYSessionTokenCapability && + $0 != WorkspaceRemoteDaemonRPCClient.requiredPTYPersistentDaemonCapability } } @@ -11881,6 +11894,7 @@ final class Workspace: Identifiable, ObservableObject { payload["has_identity_file"] = remoteConfiguration.identityFile != nil payload["has_ssh_options"] = !remoteConfiguration.sshOptions.isEmpty payload["local_proxy_port"] = remoteConfiguration.localProxyPort ?? NSNull() + payload["persistent_daemon_slot"] = remoteConfiguration.persistentDaemonSlot ?? NSNull() } else { payload["transport"] = NSNull() payload["destination"] = NSNull() @@ -11888,6 +11902,7 @@ final class Workspace: Identifiable, ObservableObject { payload["has_identity_file"] = false payload["has_ssh_options"] = false payload["local_proxy_port"] = NSNull() + payload["persistent_daemon_slot"] = NSNull() } return payload } @@ -16048,7 +16063,9 @@ final class Workspace: Identifiable, ObservableObject { private func forkAgentRemoteConfigurationForNewWorkspace(fromPanelId panelId: UUID) -> WorkspaceRemoteConfiguration? { guard forkAgentRemoteStartupCommand(fromPanelId: panelId) != nil else { return nil } - return remoteConfiguration?.sessionSnapshot()?.workspaceConfiguration() ?? remoteConfiguration + return remoteConfiguration?.sessionSnapshot()?.workspaceConfiguration( + localSocketPath: TerminalController.shared.currentSocketPathForRemoteRestore() + ) ?? remoteConfiguration } private static func firstNonEmptyPath(_ candidates: [String?]) -> String? { diff --git a/Sources/WorkspaceRemoteConfiguration.swift b/Sources/WorkspaceRemoteConfiguration.swift index 2494a78708e4..921efea278fa 100644 --- a/Sources/WorkspaceRemoteConfiguration.swift +++ b/Sources/WorkspaceRemoteConfiguration.swift @@ -1,5 +1,8 @@ import Darwin import Foundation +#if canImport(Security) +import Security +#endif private enum WorkspaceRemoteSSHOptionFilter { private static let transientControlSocketKeys: Set = [ @@ -61,6 +64,8 @@ nonisolated struct SessionRemoteWorkspaceSnapshot: Codable, Equatable, Sendable var sshOptions: [String] var preserveAfterTerminalExit: Bool? var skipDaemonBootstrap: Bool? + var relayPort: Int? + var persistentDaemonSlot: String? } struct WorkspaceRemoteWebSocketDaemonEndpoint: Equatable { @@ -287,6 +292,7 @@ struct WorkspaceRemoteConfiguration: Equatable { let foregroundAuthToken: String? let daemonWebSocketEndpoint: WorkspaceRemoteWebSocketDaemonEndpoint? let preserveAfterTerminalExit: Bool + let persistentDaemonSlot: String? /// True for cloud-VM remotes (Freestyle snapshots) where cmuxd-remote is pre-baked in /// the image and started via systemd. Skip the upload+exec bootstrap entirely and synthesize /// a `DaemonHello`. Reverse-relay still stays off, but SSH-backed VM workspaces can talk to @@ -308,6 +314,7 @@ struct WorkspaceRemoteConfiguration: Equatable { foregroundAuthToken: String? = nil, daemonWebSocketEndpoint: WorkspaceRemoteWebSocketDaemonEndpoint? = nil, preserveAfterTerminalExit: Bool = false, + persistentDaemonSlot: String? = nil, skipDaemonBootstrap: Bool = false ) { self.transport = transport @@ -324,6 +331,7 @@ struct WorkspaceRemoteConfiguration: Equatable { self.foregroundAuthToken = foregroundAuthToken self.daemonWebSocketEndpoint = daemonWebSocketEndpoint self.preserveAfterTerminalExit = preserveAfterTerminalExit + self.persistentDaemonSlot = WorkspaceRemoteSSHOptionFilter.normalizedOptional(persistentDaemonSlot) self.skipDaemonBootstrap = skipDaemonBootstrap } @@ -342,6 +350,7 @@ struct WorkspaceRemoteConfiguration: Equatable { let normalizedOptions = Self.proxyBrokerSSHOptions(sshOptions).joined(separator: "\u{1f}") let normalizedWebSocketDaemon = daemonWebSocketEndpoint?.proxyBrokerKeyComponent ?? "" let normalizedRequiredCapabilities = preserveAfterTerminalExit ? "pty.session" : "" + let normalizedPersistentDaemonSlot = persistentDaemonSlot ?? "" return [ normalizedTransport, normalizedBootstrapMode, @@ -352,6 +361,7 @@ struct WorkspaceRemoteConfiguration: Equatable { normalizedLocalProxyPort, normalizedWebSocketDaemon, normalizedRequiredCapabilities, + normalizedPersistentDaemonSlot, ] .joined(separator: "\u{1e}") } @@ -362,7 +372,7 @@ struct WorkspaceRemoteConfiguration: Equatable { } extension SessionRemoteWorkspaceSnapshot { - func workspaceConfiguration() -> WorkspaceRemoteConfiguration? { + func workspaceConfiguration(localSocketPath: String? = nil) -> WorkspaceRemoteConfiguration? { guard transport == .ssh else { return nil } let normalizedDestination = destination.trimmingCharacters(in: .whitespacesAndNewlines) guard !normalizedDestination.isEmpty else { return nil } @@ -372,9 +382,15 @@ extension SessionRemoteWorkspaceSnapshot { let normalizedOptions = Self.normalizedSSHOptions(sshOptions) let optionsWithRestoreControlDefaults = SSHPTYAttachStartupCommandBuilder.sshOptionsWithRestoreControlDefaults(normalizedOptions) + let normalizedPersistentDaemonSlot = WorkspaceRemoteSSHOptionFilter.normalizedOptional(persistentDaemonSlot) + let normalizedRelayPort = relayPort.flatMap { port in + (1...65535).contains(port) ? port : nil + } let preservePTYSession = preserveAfterTerminalExit == true && skipDaemonBootstrap != true && + normalizedPersistentDaemonSlot != nil && + normalizedRelayPort != nil && SSHPTYAttachStartupCommandBuilder.sshOptionsSupportReusableForegroundAuth(optionsWithRestoreControlDefaults) let restoredSSHOptions = preservePTYSession ? optionsWithRestoreControlDefaults : normalizedOptions let foregroundAuthToken = preservePTYSession ? UUID().uuidString.lowercased() : nil @@ -387,6 +403,12 @@ extension SessionRemoteWorkspaceSnapshot { token: $0 ) } + let restoredRelayID = preservePTYSession && normalizedRelayPort != nil + ? UUID().uuidString.lowercased() + : nil + let restoredRelayToken = preservePTYSession && normalizedRelayPort != nil + ? Self.restoreRelayTokenHex() + : nil return WorkspaceRemoteConfiguration( transport: transport, destination: normalizedDestination, @@ -394,10 +416,10 @@ extension SessionRemoteWorkspaceSnapshot { identityFile: Self.normalizedIdentityPath(identityFile), sshOptions: restoredSSHOptions, localProxyPort: nil, - relayPort: nil, - relayID: nil, - relayToken: nil, - localSocketPath: nil, + relayPort: preservePTYSession ? normalizedRelayPort : nil, + relayID: restoredRelayID, + relayToken: restoredRelayToken, + localSocketPath: preservePTYSession ? WorkspaceRemoteSSHOptionFilter.normalizedOptional(localSocketPath) : nil, terminalStartupCommand: preservePTYSession ? SSHPTYAttachStartupCommandBuilder.command( foregroundAuth: foregroundAuth, @@ -410,10 +432,23 @@ extension SessionRemoteWorkspaceSnapshot { foregroundAuthToken: foregroundAuthToken, daemonWebSocketEndpoint: nil, preserveAfterTerminalExit: preservePTYSession, + persistentDaemonSlot: preservePTYSession ? normalizedPersistentDaemonSlot : nil, skipDaemonBootstrap: skipDaemonBootstrap == true ) } + private static func restoreRelayTokenHex() -> String { + var bytes = [UInt8](repeating: 0, count: 32) +#if canImport(Security) + if SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) == errSecSuccess { + return bytes.map { String(format: "%02x", $0) }.joined() + } +#endif + return (UUID().uuidString + UUID().uuidString) + .replacingOccurrences(of: "-", with: "") + .lowercased() + } + private func sshReconnectCommand( destination normalizedDestination: String, port normalizedPort: Int? @@ -470,7 +505,9 @@ extension WorkspaceRemoteConfiguration { identityFile: WorkspaceRemoteSSHOptionFilter.normalizedIdentityPath(identityFile), sshOptions: WorkspaceRemoteSSHOptionFilter.durableOptions(sshOptions), preserveAfterTerminalExit: preserveAfterTerminalExit ? true : nil, - skipDaemonBootstrap: skipDaemonBootstrap + skipDaemonBootstrap: skipDaemonBootstrap, + relayPort: preserveAfterTerminalExit ? relayPort : nil, + persistentDaemonSlot: preserveAfterTerminalExit ? persistentDaemonSlot : nil ) } } diff --git a/Sources/WorkspaceRemoteSSHBatchCommandBuilder.swift b/Sources/WorkspaceRemoteSSHBatchCommandBuilder.swift index bc06e2b665e8..47dfe1ee6148 100644 --- a/Sources/WorkspaceRemoteSSHBatchCommandBuilder.swift +++ b/Sources/WorkspaceRemoteSSHBatchCommandBuilder.swift @@ -10,7 +10,15 @@ enum WorkspaceRemoteSSHBatchCommandBuilder { configuration: WorkspaceRemoteConfiguration, remotePath: String ) -> [String] { - let script = "exec \(shellSingleQuoted(remotePath)) serve --stdio" + var serveArguments = ["serve", "--stdio"] + if let slot = configuration.persistentDaemonSlot?.trimmingCharacters(in: .whitespacesAndNewlines), + !slot.isEmpty { + serveArguments += ["--persistent", "--slot", slot] + } + let daemonCommand = ([remotePath] + serveArguments) + .map(shellSingleQuoted) + .joined(separator: " ") + let script = "exec \(daemonCommand)" let command = "sh -c \(shellSingleQuoted(script))" return ["-T"] + batchArguments(configuration: configuration) diff --git a/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift b/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift index 190c6237287f..e6e8c5e3489e 100644 --- a/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift +++ b/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift @@ -2221,18 +2221,24 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { let run = try runMockedSSH(arguments: [], jsonOutput: true) let payload = try jsonPayload(from: run.stdout) let sessionID = try XCTUnwrap(payload["ssh_pty_session_id"] as? String) + let persistentDaemonSlot = try XCTUnwrap(payload["persistent_daemon_slot"] as? String) XCTAssertEqual(sessionID, "ssh-\(run.workspaceId)-\(run.surfaceId)") XCTAssertFalse(sessionID.contains("$"), sessionID) XCTAssertFalse(sessionID.contains("{"), sessionID) + XCTAssertTrue(persistentDaemonSlot.hasPrefix("ssh-"), persistentDaemonSlot) + XCTAssertNotNil(UUID(uuidString: String(persistentDaemonSlot.dropFirst(4)))) } func testSSHPersistentPTYJSONResolvesSessionIDWhenWorkspaceCreateOmitsSurfaceID() throws { let run = try runMockedSSH(arguments: [], jsonOutput: true, omitWorkspaceCreateSurfaceID: true) let payload = try jsonPayload(from: run.stdout) let sessionID = try XCTUnwrap(payload["ssh_pty_session_id"] as? String) + let persistentDaemonSlot = try XCTUnwrap(payload["persistent_daemon_slot"] as? String) XCTAssertEqual(sessionID, "ssh-\(run.workspaceId)-\(run.surfaceId)") + XCTAssertTrue(persistentDaemonSlot.hasPrefix("ssh-"), persistentDaemonSlot) + XCTAssertNotNil(UUID(uuidString: String(persistentDaemonSlot.dropFirst(4)))) } private func assertSSHPersistentPTYUsesReusableForegroundAuthControlConnection( @@ -2305,6 +2311,9 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertEqual(configureParams["auto_connect"] as? Bool, false) XCTAssertNotNil(configureParams["foreground_auth_token"] as? String) XCTAssertEqual(configureParams["preserve_after_terminal_exit"] as? Bool, true) + let persistentDaemonSlot = try XCTUnwrap(configureParams["persistent_daemon_slot"] as? String) + XCTAssertTrue(persistentDaemonSlot.hasPrefix("ssh-"), persistentDaemonSlot) + XCTAssertNotNil(UUID(uuidString: String(persistentDaemonSlot.dropFirst(4)))) } func testSSHPersistentPTYFallsBackWhenForegroundAuthCannotBeReused() throws { @@ -2338,6 +2347,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertEqual(configureParams["auto_connect"] as? Bool, true, testCase.name) XCTAssertNil(configureParams["foreground_auth_token"], testCase.name) XCTAssertNil(configureParams["preserve_after_terminal_exit"], testCase.name) + XCTAssertNil(configureParams["persistent_daemon_slot"], testCase.name) } } diff --git a/cmuxTests/TabManagerSessionSnapshotTests.swift b/cmuxTests/TabManagerSessionSnapshotTests.swift index ef4584021970..8056fd7752cb 100644 --- a/cmuxTests/TabManagerSessionSnapshotTests.swift +++ b/cmuxTests/TabManagerSessionSnapshotTests.swift @@ -1739,10 +1739,11 @@ final class TabManagerSessionSnapshotTests: XCTestCase { ) } - func testSessionSnapshotRestoresPersistentSSHPTYWithFreshAttachAfterRelaunch() throws { + func testSessionSnapshotRestoresPersistentSSHPTYSessionAfterRelaunch() throws { 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, @@ -1756,7 +1757,8 @@ final class TabManagerSessionSnapshotTests: XCTestCase { relayToken: String(repeating: "e", count: 64), localSocketPath: "/tmp/cmux-persist-test.sock", terminalStartupCommand: SSHPTYAttachStartupCommandBuilder.command(), - preserveAfterTerminalExit: true + preserveAfterTerminalExit: true, + persistentDaemonSlot: persistentDaemonSlot ) remoteWorkspace.configureRemoteConnection(configuration, autoConnect: false) let remotePanelId = try XCTUnwrap(remoteWorkspace.focusedPanelId) @@ -1790,6 +1792,8 @@ final class TabManagerSessionSnapshotTests: XCTestCase { 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 @@ -1804,12 +1808,21 @@ final class TabManagerSessionSnapshotTests: XCTestCase { 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, TerminalController.shared.currentSocketPathForRemoteRestore()) + 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.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("254|255"), terminalStartupCommand) let restoredPanelId = try XCTUnwrap(restoredWorkspace.focusedPanelId) @@ -1819,19 +1832,16 @@ final class TabManagerSessionSnapshotTests: XCTestCase { XCTAssertTrue(restoredInitialCommand.contains("ssh-pty-attach"), restoredInitialCommand) XCTAssertTrue(restoredInitialCommand.contains("workspace.remote.foreground_auth_ready"), restoredInitialCommand) XCTAssertTrue(restoredInitialCommand.contains(restoredForegroundAuthToken), restoredInitialCommand) - XCTAssertFalse(restoredInitialCommand.contains("--require-existing"), restoredInitialCommand) + XCTAssertTrue(restoredInitialCommand.contains("--require-existing"), restoredInitialCommand) XCTAssertTrue(restoredInitialCommand.contains("254|255"), restoredInitialCommand) - XCTAssertFalse(restoredInitialCommand.contains(expectedSessionID), restoredInitialCommand) + XCTAssertTrue(restoredInitialCommand.contains(expectedSessionID), restoredInitialCommand) XCTAssertTrue(restoredInitialCommand.contains("CMUX_SURFACE_ID"), restoredInitialCommand) let roundTrip = restoredWorkspace.sessionSnapshot(includeScrollback: false) - let restoredSessionID = Workspace.defaultSSHPTYSessionID( - workspaceId: restoredWorkspace.id, - panelId: restoredPanelId - ) XCTAssertEqual(roundTrip.remote?.preserveAfterTerminalExit, true) - XCTAssertEqual(roundTrip.panels.first?.terminal?.remotePTYSessionID, restoredSessionID) - XCTAssertNotEqual(restoredSessionID, expectedSessionID) + 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 @@ -1907,6 +1917,63 @@ final class TabManagerSessionSnapshotTests: XCTestCase { XCTAssertNil(roundTrip.panels.first?.terminal?.remotePTYSessionID) } + func testSessionRemoteWorkspaceSnapshotRequiresPersistentDaemonSlotForPTYRestore() throws { + 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) + XCTAssertNil(configuration.relayPort) + XCTAssertNil(configuration.localSocketPath) + XCTAssertFalse(configuration.sshOptions.contains { $0.hasPrefix("ControlPath") }) + XCTAssertFalse(configuration.terminalStartupCommand?.contains("ssh-pty-attach") == true) + XCTAssertEqual(configuration.terminalStartupCommand, "ssh -p 2222 -o StrictHostKeyChecking=accept-new -tt dev@example.com") + } + + func testSessionRemoteWorkspaceSnapshotRequiresRelayPortForPTYRestore() throws { + 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, "ssh -p 2222 -o StrictHostKeyChecking=accept-new -tt dev@example.com") + } + func testSessionRemoteWorkspaceSnapshotDropsInvalidSSHPortFromReconnectCommand() throws { let snapshot = SessionRemoteWorkspaceSnapshot( transport: .ssh, diff --git a/cmuxTests/TerminalControllerSocketSecurityTests.swift b/cmuxTests/TerminalControllerSocketSecurityTests.swift index abf423fb2870..8f06f27889bb 100644 --- a/cmuxTests/TerminalControllerSocketSecurityTests.swift +++ b/cmuxTests/TerminalControllerSocketSecurityTests.swift @@ -202,6 +202,26 @@ final class TerminalControllerSocketSecurityTests: XCTestCase { XCTAssertEqual(payload["has_ssh_options"] as? Bool, true) } + func testRemoteConfigureRejectsInvalidPersistentDaemonSlot() throws { + let response = try handleV2Request( + method: "workspace.remote.configure", + params: [ + "workspace_id": UUID().uuidString, + "transport": "ssh", + "destination": "example.com", + "persistent_daemon_slot": "../bad", + ] + ) + + XCTAssertEqual(response["ok"] as? Bool, false, "Unexpected JSON-RPC response: \(response)") + let error = try XCTUnwrap(response["error"] as? [String: Any]) + XCTAssertEqual(error["code"] as? String, "invalid_params") + XCTAssertEqual( + error["message"] as? String, + "persistent_daemon_slot must contain only letters, numbers, '.', '_' or '-'" + ) + } + func testRemotePTYResizeRunsOnSocketWorker() async throws { let socketPath = makeSocketPath("pty-worker") let tabManager = TabManager() diff --git a/daemon/remote/README.md b/daemon/remote/README.md index 537d75bd7590..9ef4b586fb24 100644 --- a/daemon/remote/README.md +++ b/daemon/remote/README.md @@ -6,12 +6,16 @@ Go remote daemon for `cmux ssh` bootstrap, capability negotiation, and remote pr 1. `cmuxd-remote version` 2. `cmuxd-remote serve --stdio` -3. `cmuxd-remote serve --ws --auth-lease-file [--rpc-auth-lease-file ] [--listen 127.0.0.1:7777]` -4. `cmuxd-remote cli [args...]` — relay cmux commands to the local app over the reverse SSH forward +3. `cmuxd-remote serve --stdio --persistent --slot ` +4. `cmuxd-remote serve --ws --auth-lease-file [--rpc-auth-lease-file ] [--listen 127.0.0.1:7777]` +5. `cmuxd-remote cli [args...]` — relay cmux commands to the local app over the reverse SSH forward `serve --ws` is explicit opt-in for cloud VM images only. The normal `cmux ssh` -code path continues to use `serve --stdio` over an SSH exec channel and does not -open a WebSocket listener. +code path uses `serve --stdio --persistent --slot ` over an SSH exec +channel. That stdio process is only a proxy to an authenticated per-slot daemon +under `~/.cmux/daemon//`, so remote PTY sessions can survive local surface +close, local reconnect, and app relaunch. The persistent server never opens a +public listener; it accepts only the slot-local Unix socket and token. When invoked as `cmux` (via wrapper/symlink installed during bootstrap), the binary auto-dispatches to the `cli` subcommand. This is busybox-style argv[0] detection. @@ -30,12 +34,38 @@ When invoked as `cmux` (via wrapper/symlink installed during bootstrap), the bin 11. `session.resize` 12. `session.detach` 13. `session.status` +14. `pty.attach` +15. `pty.write` +16. `pty.resize` +17. `pty.detach` +18. `pty.close` +19. `pty.list` Current integration in cmux: 1. `workspace.remote.configure` now bootstraps this binary over SSH when missing. 2. Client sends `hello` before enabling remote proxy transport. 3. Local workspace proxy broker serves SOCKS5 + HTTP CONNECT and tunnels stream traffic through `proxy.*` RPC over `serve --stdio`, using daemon-pushed stream events instead of polling reads. 4. Daemon status/capabilities are exposed in `workspace.remote.status -> remote.daemon` (including `session.resize.min`). +5. Persistent SSH terminals require the `pty.session.persistent_daemon` capability before cmux will restore a saved remote PTY session ID after relaunch. + +## Persistent SSH PTY daemon + +`cmux ssh` uses one persistent daemon slot per CLI-launched SSH workspace. The +slot name is generated locally, validated as `[A-Za-z0-9._-]{1,128}`, and sent +to the remote daemon bootstrap as `--slot`. + +Remote slot files: +1. `~/.cmux/daemon//rpc.sock` authenticated Unix socket for stdio proxies. +2. `~/.cmux/daemon//auth.token` random 32-byte hex token, mode `0600`. +3. `~/.cmux/daemon//daemon.lock` single-owner lock. +4. `~/.cmux/daemon//daemon.log` startup and crash diagnostics. + +PTY lifecycle: +1. A local attach creates or reuses a named `pty.*` session in the persistent daemon. +2. If the local surface closes, the stdio proxy disconnects and its attachment detaches, but the PTY process and bounded scrollback remain in the daemon. +3. `cmux ssh-session-list` calls `pty.list`; `cmux ssh-session-attach` creates a new local terminal whose startup script calls `ssh-pty-attach --require-existing`. +4. `cmux ssh-session-cleanup` calls `pty.close` to terminate a persisted PTY session explicitly. +5. Sessions with no attachments keep their last-known size and are reaped by the daemon idle TTL. ## Cloud WebSocket PTY transport diff --git a/daemon/remote/TMUX_CORPUS.md b/daemon/remote/TMUX_CORPUS.md index 1e1bfa1ca78b..ee025cbc72a8 100644 --- a/daemon/remote/TMUX_CORPUS.md +++ b/daemon/remote/TMUX_CORPUS.md @@ -2,7 +2,7 @@ Pinned upstream tmux commit: `a9ba7b8ecbe1d107aa716f52d53c99ea1a00cf11`. -This map records how each selected upstream tmux regression or fuzz target is represented in cmux. It is documentation only; CI confidence comes from executable Go tests, Go fuzz targets, and the macOS terminal-renderer tests in `.github/workflows/tmux-corpus.yml`. +This map records how each selected upstream tmux regression or fuzz target is represented in cmux. It is documentation only; CI confidence comes from executable Go tests, Go fuzz targets, and the macOS terminal-renderer tests in `.github/workflows/tmux-corpus.yml`. The daemon and tmux-compat rows marked `pr` run on pull requests touching `daemon/remote/**`; the terminal-renderer rows remain nightly because they need the macOS app and GhosttyKit. | Upstream source | cmux layer | Status | CI lane | Port note | | --- | --- | --- | --- | --- | diff --git a/daemon/remote/cmd/cmuxd-remote/main.go b/daemon/remote/cmd/cmuxd-remote/main.go index 15a282e66e68..d57be8ae0389 100644 --- a/daemon/remote/cmd/cmuxd-remote/main.go +++ b/daemon/remote/cmd/cmuxd-remote/main.go @@ -4,7 +4,9 @@ import ( "bufio" "bytes" "context" + "crypto/rand" "encoding/base64" + "encoding/hex" "encoding/json" "errors" "flag" @@ -13,11 +15,13 @@ import ( "math" "net" "os" + "os/exec" "path/filepath" "sort" "strconv" "strings" "sync" + "syscall" "time" ) @@ -137,6 +141,9 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { fs.SetOutput(stderr) stdio := fs.Bool("stdio", false, "serve over stdin/stdout") ws := fs.Bool("ws", false, "serve terminal PTY transport over WebSocket") + persistent := fs.Bool("persistent", false, "proxy stdio to a persistent per-slot daemon") + persistentServer := fs.Bool("persistent-server", false, "run the persistent per-slot daemon") + persistentSlot := fs.String("slot", "", "persistent daemon slot") listen := fs.String("listen", "127.0.0.1:7777", "address for --ws") authLeaseFile := fs.String("auth-lease-file", "", "required lease JSON path for --ws") rpcAuthLeaseFile := fs.String("rpc-auth-lease-file", "", "optional daemon RPC lease JSON path for --ws /rpc") @@ -144,10 +151,29 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { if err := fs.Parse(args[1:]); err != nil { return 2 } + if *persistentServer { + if *stdio || *ws || *persistent { + _, _ = fmt.Fprintln(stderr, "serve --persistent-server cannot be combined with --stdio, --ws, or --persistent") + return 2 + } + if strings.TrimSpace(*persistentSlot) == "" { + _, _ = fmt.Fprintln(stderr, "serve --persistent-server requires --slot") + return 2 + } + if err := runPersistentDaemonServer(strings.TrimSpace(*persistentSlot), stderr); err != nil { + _, _ = fmt.Fprintf(stderr, "serve --persistent-server failed: %v\n", err) + return 1 + } + return 0 + } if *stdio == *ws { _, _ = fmt.Fprintln(stderr, "serve requires exactly one of --stdio or --ws") return 2 } + if (*persistent || strings.TrimSpace(*persistentSlot) != "") && !*stdio { + _, _ = fmt.Fprintln(stderr, "serve --persistent requires --stdio") + return 2 + } if *ws { if strings.TrimSpace(*authLeaseFile) == "" { _, _ = fmt.Fprintln(stderr, "serve --ws requires --auth-lease-file") @@ -164,6 +190,17 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { } return 0 } + if *persistent { + if strings.TrimSpace(*persistentSlot) == "" { + _, _ = fmt.Fprintln(stderr, "serve --persistent requires --slot") + return 2 + } + if err := runPersistentStdioProxy(stdin, stdout, stderr, strings.TrimSpace(*persistentSlot)); err != nil { + _, _ = fmt.Fprintf(stderr, "serve --stdio --persistent failed: %v\n", err) + return 1 + } + return 0 + } if err := runStdioServer(stdin, stdout); err != nil { _, _ = fmt.Fprintf(stderr, "serve failed: %v\n", err) return 1 @@ -181,11 +218,16 @@ func usage(w io.Writer) { _, _ = fmt.Fprintln(w, "Usage:") _, _ = fmt.Fprintln(w, " cmuxd-remote version") _, _ = fmt.Fprintln(w, " cmuxd-remote serve --stdio") + _, _ = fmt.Fprintln(w, " cmuxd-remote serve --stdio --persistent --slot ") _, _ = fmt.Fprintln(w, " cmuxd-remote serve --ws --auth-lease-file [--rpc-auth-lease-file ] [--listen 127.0.0.1:7777]") _, _ = fmt.Fprintln(w, " cmuxd-remote cli [args...]") } func runStdioServer(stdin io.Reader, stdout io.Writer) error { + return runRPCServer(stdin, stdout, newWebSocketPTYHub(wsPTYServerConfig{}, io.Discard), true) +} + +func runRPCServer(stdin io.Reader, stdout io.Writer, ptyHub *wsPTYHub, ownsPTYHub bool) error { writer := &stdioFrameWriter{ writer: bufio.NewWriter(stdout), } @@ -194,8 +236,8 @@ func runStdioServer(stdin io.Reader, stdout io.Writer) error { nextSessionID: 1, streams: map[string]*streamState{}, sessions: map[string]*sessionState{}, - ptyHub: newWebSocketPTYHub(wsPTYServerConfig{}, io.Discard), - ownsPTYHub: true, + ptyHub: ptyHub, + ownsPTYHub: ownsPTYHub, frameWriter: writer, } defer server.closeAll() @@ -250,6 +292,448 @@ func runStdioServer(stdin io.Reader, stdout io.Writer) error { } } +type persistentDaemonPaths struct { + slot string + root string + socket string + tokenFile string + logFile string + lockFile string +} + +const persistentDaemonAuthMethod = "daemon.auth" + +func persistentDaemonPathsForSlot(rawSlot string) (persistentDaemonPaths, error) { + slot, err := validatePersistentDaemonSlot(rawSlot) + if err != nil { + return persistentDaemonPaths{}, err + } + rootBase := strings.TrimSpace(os.Getenv("CMUX_REMOTE_DAEMON_ROOT")) + if rootBase == "" { + home, homeErr := os.UserHomeDir() + if homeErr != nil || strings.TrimSpace(home) == "" { + return persistentDaemonPaths{}, errors.New("cannot resolve remote home directory") + } + rootBase = filepath.Join(home, ".cmux", "daemon") + } + root := filepath.Join(rootBase, slot) + return persistentDaemonPaths{ + slot: slot, + root: root, + socket: filepath.Join(root, "rpc.sock"), + tokenFile: filepath.Join(root, "auth.token"), + logFile: filepath.Join(root, "daemon.log"), + lockFile: filepath.Join(root, "daemon.lock"), + }, nil +} + +func validatePersistentDaemonSlot(rawSlot string) (string, error) { + slot := strings.TrimSpace(rawSlot) + if slot == "" { + return "", errors.New("persistent daemon slot is required") + } + if slot == "." || slot == ".." || len(slot) > 128 { + return "", fmt.Errorf("invalid persistent daemon slot %q", rawSlot) + } + for _, r := range slot { + if (r >= 'a' && r <= 'z') || + (r >= 'A' && r <= 'Z') || + (r >= '0' && r <= '9') || + r == '-' || + r == '_' || + r == '.' { + continue + } + return "", fmt.Errorf("invalid persistent daemon slot %q", rawSlot) + } + return slot, nil +} + +func ensurePersistentDaemonDirectory(paths persistentDaemonPaths) error { + if err := os.MkdirAll(paths.root, 0o700); err != nil { + return err + } + return os.Chmod(paths.root, 0o700) +} + +func persistentDaemonToken(paths persistentDaemonPaths) (string, error) { + readExisting := func() (string, error) { + data, err := os.ReadFile(paths.tokenFile) + if err != nil { + return "", err + } + token := strings.TrimSpace(string(data)) + if token == "" { + return "", errors.New("persistent daemon token file is empty") + } + return token, nil + } + if token, err := readExisting(); err == nil { + return token, nil + } else if !errors.Is(err, os.ErrNotExist) { + return "", err + } + + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + return "", err + } + token := hex.EncodeToString(raw) + + file, err := os.OpenFile(paths.tokenFile, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if errors.Is(err, os.ErrExist) { + return readExisting() + } + if err != nil { + return "", err + } + writeOK := false + defer func() { + _ = file.Close() + if !writeOK { + _ = os.Remove(paths.tokenFile) + } + }() + if _, err := file.WriteString(token + "\n"); err != nil { + return "", err + } + writeOK = true + return token, nil +} + +func runPersistentStdioProxy(stdin io.Reader, stdout, stderr io.Writer, slot string) error { + paths, err := persistentDaemonPathsForSlot(slot) + if err != nil { + return err + } + if err := ensurePersistentDaemonDirectory(paths); err != nil { + return err + } + token, err := persistentDaemonToken(paths) + if err != nil { + return err + } + if err := ensurePersistentDaemonRunning(paths, token, stderr); err != nil { + return err + } + conn, err := dialPersistentDaemon(paths.socket, token) + if err != nil { + return err + } + defer conn.Close() + + errCh := make(chan error, 2) + go func() { + _, copyErr := io.Copy(conn, stdin) + if unixConn, ok := conn.(*net.UnixConn); ok { + _ = unixConn.CloseWrite() + } + errCh <- copyErr + }() + go func() { + _, copyErr := io.Copy(stdout, conn) + errCh <- copyErr + }() + + var firstErr error + for i := 0; i < 2; i++ { + if copyErr := <-errCh; copyErr != nil && !errors.Is(copyErr, net.ErrClosed) && firstErr == nil { + firstErr = copyErr + } + } + return firstErr +} + +func ensurePersistentDaemonRunning(paths persistentDaemonPaths, token string, stderr io.Writer) error { + if conn, err := dialPersistentDaemon(paths.socket, token); err == nil { + _ = conn.Close() + return nil + } + _ = os.Remove(paths.socket) + + executable, err := os.Executable() + if err != nil { + return err + } + logFile, err := os.OpenFile(paths.logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) + if err != nil { + return err + } + defer logFile.Close() + + cmd := exec.Command(executable, "serve", "--persistent-server", "--slot", paths.slot) + cmd.Stdin = nil + cmd.Stdout = logFile + cmd.Stderr = logFile + cmd.Env = os.Environ() + cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} + if err := cmd.Start(); err != nil { + return err + } + _ = cmd.Process.Release() + + deadline := time.Now().Add(5 * time.Second) + var lastErr error + for time.Now().Before(deadline) { + conn, dialErr := dialPersistentDaemon(paths.socket, token) + if dialErr == nil { + _ = conn.Close() + return nil + } + lastErr = dialErr + time.Sleep(50 * time.Millisecond) + } + if stderr != nil && lastErr != nil { + _, _ = fmt.Fprintf(stderr, "persistent daemon log: %s\n", paths.logFile) + } + if lastErr == nil { + lastErr = errors.New("persistent daemon did not become ready") + } + return lastErr +} + +func runPersistentDaemonServer(slot string, stderr io.Writer) error { + paths, err := persistentDaemonPathsForSlot(slot) + if err != nil { + return err + } + if err := ensurePersistentDaemonDirectory(paths); err != nil { + return err + } + token, err := persistentDaemonToken(paths) + if err != nil { + return err + } + lockFile, err := os.OpenFile(paths.lockFile, os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return err + } + defer lockFile.Close() + if err := syscall.Flock(int(lockFile.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { + return fmt.Errorf("persistent daemon slot %q is already running", paths.slot) + } + defer syscall.Flock(int(lockFile.Fd()), syscall.LOCK_UN) + + _ = os.Remove(paths.socket) + listener, err := net.Listen("unix", paths.socket) + if err != nil { + return err + } + defer listener.Close() + defer os.Remove(paths.socket) + _ = os.Chmod(paths.socket, 0o600) + + return servePersistentDaemon(listener, token, stderr) +} + +func servePersistentDaemon(listener net.Listener, token string, stderr io.Writer) error { + hub := newWebSocketPTYHub(wsPTYServerConfig{}, stderr) + defer hub.closeAll() + for { + conn, err := listener.Accept() + if err != nil { + if isClosedListenerError(err) { + return nil + } + return err + } + go handlePersistentDaemonConn(conn, token, hub) + } +} + +func isClosedListenerError(err error) bool { + if err == nil { + return false + } + if errors.Is(err, net.ErrClosed) { + return true + } + return strings.Contains(err.Error(), "use of closed network connection") +} + +func handlePersistentDaemonConn(conn net.Conn, token string, hub *wsPTYHub) { + defer conn.Close() + reader := bufio.NewReaderSize(conn, 64*1024) + writer := &stdioFrameWriter{writer: bufio.NewWriter(conn)} + if !authenticatePersistentDaemonConn(reader, writer, token) { + return + } + _ = runRPCServerWithReader(reader, writer, hub, false) +} + +func authenticatePersistentDaemonConn(reader *bufio.Reader, writer *stdioFrameWriter, token string) bool { + line, oversized, err := readRPCFrame(reader, maxRPCFrameBytes) + if err != nil || oversized { + _ = writer.writeResponse(rpcResponse{ + OK: false, + Error: &rpcError{ + Code: "unauthorized", + Message: "persistent daemon authentication required", + }, + }) + return false + } + line = bytes.TrimSuffix(line, []byte{'\n'}) + line = bytes.TrimSuffix(line, []byte{'\r'}) + var req rpcRequest + if err := json.Unmarshal(line, &req); err != nil { + _ = writer.writeResponse(rpcResponse{ + OK: false, + Error: &rpcError{ + Code: "invalid_request", + Message: "invalid JSON request", + }, + }) + return false + } + if req.Method != persistentDaemonAuthMethod { + _ = writer.writeResponse(rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "unauthorized", + Message: "persistent daemon authentication required", + }, + }) + return false + } + provided, _ := getStringParam(req.Params, "token") + if strings.TrimSpace(provided) == "" || strings.TrimSpace(provided) != token { + _ = writer.writeResponse(rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "unauthorized", + Message: "invalid persistent daemon token", + }, + }) + return false + } + _ = writer.writeResponse(rpcResponse{ + ID: req.ID, + OK: true, + Result: map[string]any{ + "authenticated": true, + }, + }) + return true +} + +func runRPCServerWithReader(reader *bufio.Reader, writer *stdioFrameWriter, ptyHub *wsPTYHub, ownsPTYHub bool) error { + server := &rpcServer{ + nextStreamID: 1, + nextSessionID: 1, + streams: map[string]*streamState{}, + sessions: map[string]*sessionState{}, + ptyHub: ptyHub, + ownsPTYHub: ownsPTYHub, + frameWriter: writer, + } + defer server.closeAll() + defer writer.writer.Flush() + + for { + line, oversized, readErr := readRPCFrame(reader, maxRPCFrameBytes) + if readErr != nil { + if errors.Is(readErr, io.EOF) { + return nil + } + return readErr + } + if oversized { + if err := writer.writeResponse(rpcResponse{ + OK: false, + Error: &rpcError{ + Code: "invalid_request", + Message: "request frame exceeds maximum size", + }, + }); err != nil { + return err + } + continue + } + line = bytes.TrimSuffix(line, []byte{'\n'}) + line = bytes.TrimSuffix(line, []byte{'\r'}) + if len(line) == 0 { + continue + } + + var req rpcRequest + if err := json.Unmarshal(line, &req); err != nil { + if err := writer.writeResponse(rpcResponse{ + OK: false, + Error: &rpcError{ + Code: "invalid_request", + Message: "invalid JSON request", + }, + }); err != nil { + return err + } + continue + } + + resp := server.handleRequest(req) + if err := writer.writeResponse(resp); err != nil { + return err + } + } +} + +func dialPersistentDaemon(socketPath string, token string) (net.Conn, error) { + conn, err := net.DialTimeout("unix", socketPath, 2*time.Second) + if err != nil { + return nil, err + } + if err := authenticatePersistentDaemonClient(conn, token); err != nil { + _ = conn.Close() + return nil, err + } + return conn, nil +} + +func authenticatePersistentDaemonClient(conn net.Conn, token string) error { + writer := bufio.NewWriter(conn) + request := rpcRequest{ + ID: "auth", + Method: persistentDaemonAuthMethod, + Params: map[string]any{ + "token": token, + }, + } + data, err := json.Marshal(request) + if err != nil { + return err + } + if _, err := writer.Write(data); err != nil { + return err + } + if err := writer.WriteByte('\n'); err != nil { + return err + } + if err := writer.Flush(); err != nil { + return err + } + reader := bufio.NewReaderSize(conn, 64*1024) + line, oversized, err := readRPCFrame(reader, maxRPCFrameBytes) + if err != nil { + return err + } + if oversized { + return errors.New("persistent daemon auth response exceeded maximum size") + } + var resp rpcResponse + if err := json.Unmarshal(bytes.TrimSpace(line), &resp); err != nil { + return err + } + if !resp.OK { + if resp.Error != nil && strings.TrimSpace(resp.Error.Message) != "" { + return errors.New(resp.Error.Message) + } + return errors.New("persistent daemon authentication failed") + } + return nil +} + func setTCPNoDelay(conn net.Conn) { tcpConn, ok := conn.(*net.TCPConn) if !ok { @@ -356,6 +840,7 @@ func (s *rpcServer) handleRequest(req rpcRequest) rpcResponse { "proxy.stream.push", "pty.session", "pty.session.token", + "pty.session.persistent_daemon", }, }, } diff --git a/daemon/remote/cmd/cmuxd-remote/main_test.go b/daemon/remote/cmd/cmuxd-remote/main_test.go index 3530baf3f172..9c4479157bff 100644 --- a/daemon/remote/cmd/cmuxd-remote/main_test.go +++ b/daemon/remote/cmd/cmuxd-remote/main_test.go @@ -48,6 +48,111 @@ func (b *notifyingBuffer) String() string { return b.buffer.String() } +func startPersistentDaemonForTest(t *testing.T, token string) (string, func()) { + t.Helper() + socketPath := filepath.Join(t.TempDir(), "rpc.sock") + listener, err := net.Listen("unix", socketPath) + if err != nil { + t.Fatalf("listen unix: %v", err) + } + done := make(chan error, 1) + go func() { + done <- servePersistentDaemon(listener, token, io.Discard) + }() + stop := func() { + _ = listener.Close() + select { + case err := <-done: + if err != nil { + t.Fatalf("persistent daemon exited with error: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatalf("persistent daemon did not stop") + } + } + return socketPath, stop +} + +func openPersistentTestClient(t *testing.T, socketPath string, token string) (net.Conn, *bufio.Reader, *bufio.Writer) { + t.Helper() + conn, err := net.Dial("unix", socketPath) + if err != nil { + t.Fatalf("dial persistent daemon: %v", err) + } + reader := bufio.NewReader(conn) + writer := bufio.NewWriter(conn) + writePersistentTestFrame(t, writer, rpcRequest{ + ID: "auth", + Method: persistentDaemonAuthMethod, + Params: map[string]any{"token": token}, + }) + frame := readPersistentTestFrame(t, conn, reader) + if ok, _ := frame["ok"].(bool); !ok { + _ = conn.Close() + t.Fatalf("persistent daemon auth failed: %v", frame) + } + return conn, reader, writer +} + +func persistentTestRPCCall(t *testing.T, conn net.Conn, reader *bufio.Reader, writer *bufio.Writer, req rpcRequest) map[string]any { + t.Helper() + writePersistentTestFrame(t, writer, req) + for { + frame := readPersistentTestFrame(t, conn, reader) + if _, isEvent := frame["event"]; isEvent { + continue + } + return frame + } +} + +func readPersistentTestEvent(t *testing.T, conn net.Conn, reader *bufio.Reader, matches func(map[string]any) bool) map[string]any { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + var last map[string]any + for time.Now().Before(deadline) { + frame := readPersistentTestFrame(t, conn, reader) + last = frame + if _, isEvent := frame["event"]; isEvent && matches(frame) { + return frame + } + } + t.Fatalf("timed out waiting for persistent daemon event; last=%v", last) + return nil +} + +func writePersistentTestFrame(t *testing.T, writer *bufio.Writer, payload any) { + t.Helper() + data, err := json.Marshal(payload) + if err != nil { + t.Fatalf("marshal test frame: %v", err) + } + if _, err := writer.Write(data); err != nil { + t.Fatalf("write test frame: %v", err) + } + if err := writer.WriteByte('\n'); err != nil { + t.Fatalf("write test newline: %v", err) + } + if err := writer.Flush(); err != nil { + t.Fatalf("flush test frame: %v", err) + } +} + +func readPersistentTestFrame(t *testing.T, conn net.Conn, reader *bufio.Reader) map[string]any { + t.Helper() + _ = conn.SetReadDeadline(time.Now().Add(5 * time.Second)) + line, err := reader.ReadBytes('\n') + _ = conn.SetReadDeadline(time.Time{}) + if err != nil { + t.Fatalf("read persistent daemon frame: %v", err) + } + var frame map[string]any + if err := json.Unmarshal(bytes.TrimSpace(line), &frame); err != nil { + t.Fatalf("decode persistent daemon frame %q: %v", string(line), err) + } + return frame +} + type eofWithPayloadConn struct { payload []byte readOnce bool @@ -169,6 +274,16 @@ func TestRunStdioHelloAndPing(t *testing.T) { if !sawPushCapability { t.Fatalf("hello should advertise proxy.stream.push: %v", firstResult) } + var sawPersistentPTYCapability bool + for _, capability := range capabilities { + if capability == "pty.session.persistent_daemon" { + sawPersistentPTYCapability = true + break + } + } + if !sawPersistentPTYCapability { + t.Fatalf("hello should advertise pty.session.persistent_daemon: %v", firstResult) + } var second map[string]any if err := json.Unmarshal([]byte(lines[1]), &second); err != nil { @@ -179,6 +294,185 @@ func TestRunStdioHelloAndPing(t *testing.T) { } } +func TestPersistentDaemonRejectsInvalidSlot(t *testing.T) { + for _, slot := range []string{"", ".", "..", "../nope", "bad/slot", strings.Repeat("a", 129)} { + if _, err := persistentDaemonPathsForSlot(slot); err == nil { + t.Fatalf("persistentDaemonPathsForSlot(%q) succeeded, want error", slot) + } + } +} + +func TestPersistentDaemonTokenConcurrentCreate(t *testing.T) { + root := t.TempDir() + paths := persistentDaemonPaths{ + root: root, + tokenFile: filepath.Join(root, "auth.token"), + } + if err := os.MkdirAll(filepath.Dir(paths.tokenFile), 0o700); err != nil { + t.Fatalf("mkdir token dir: %v", err) + } + + const workers = 12 + var wg sync.WaitGroup + results := make(chan string, workers) + errorsCh := make(chan error, workers) + for i := 0; i < workers; i++ { + wg.Add(1) + go func() { + defer wg.Done() + token, err := persistentDaemonToken(paths) + if err != nil { + errorsCh <- err + return + } + results <- token + }() + } + wg.Wait() + close(results) + close(errorsCh) + for err := range errorsCh { + t.Fatalf("persistentDaemonToken returned error: %v", err) + } + var first string + for token := range results { + if len(token) != 64 { + t.Fatalf("token length = %d, want 64", len(token)) + } + if first == "" { + first = token + continue + } + if token != first { + t.Fatalf("concurrent token mismatch: got %q want %q", token, first) + } + } + onDisk, err := os.ReadFile(paths.tokenFile) + if err != nil { + t.Fatalf("read token file: %v", err) + } + if strings.TrimSpace(string(onDisk)) != first { + t.Fatalf("token file = %q, want %q", strings.TrimSpace(string(onDisk)), first) + } +} + +func TestPersistentDaemonRejectsBadToken(t *testing.T) { + socketPath, stop := startPersistentDaemonForTest(t, "good-token") + defer stop() + + conn, err := net.Dial("unix", socketPath) + if err != nil { + t.Fatalf("dial persistent daemon: %v", err) + } + defer conn.Close() + + reader := bufio.NewReader(conn) + writer := bufio.NewWriter(conn) + writePersistentTestFrame(t, writer, rpcRequest{ + ID: 1, + Method: persistentDaemonAuthMethod, + Params: map[string]any{"token": "bad-token"}, + }) + frame := readPersistentTestFrame(t, conn, reader) + if ok, _ := frame["ok"].(bool); ok { + t.Fatalf("bad token auth should fail: %v", frame) + } + errObj, _ := frame["error"].(map[string]any) + if got := errObj["code"]; got != "unauthorized" { + t.Fatalf("bad token error code = %v, want unauthorized; frame=%v", got, frame) + } +} + +func TestPersistentDaemonPTYReattachSurvivesClientDisconnect(t *testing.T) { + socketPath, stop := startPersistentDaemonForTest(t, "reattach-token") + defer stop() + + conn1, reader1, writer1 := openPersistentTestClient(t, socketPath, "reattach-token") + sessionID := "persistent-rpc" + attach1 := persistentTestRPCCall(t, conn1, reader1, writer1, rpcRequest{ + ID: 1, + Method: "pty.attach", + Params: map[string]any{ + "session_id": sessionID, + "attachment_id": "a1", + "client_attachment_token": "token-a1", + "cols": 80, + "rows": 24, + "command": "printf 'persistent-rpc-data\\n'; sleep 60", + }, + }) + if ok, _ := attach1["ok"].(bool); !ok { + t.Fatalf("first pty.attach failed: %v", attach1) + } + readPersistentTestEvent(t, conn1, reader1, func(frame map[string]any) bool { + return frame["event"] == "pty.ready" && frame["attachment_id"] == "a1" + }) + readPersistentTestEvent(t, conn1, reader1, func(frame map[string]any) bool { + if frame["event"] != "pty.data" || frame["attachment_id"] != "a1" { + return false + } + payload, err := base64.StdEncoding.DecodeString(frame["data_base64"].(string)) + return err == nil && strings.Contains(string(payload), "persistent-rpc-data") + }) + _ = conn1.Close() + + conn2, reader2, writer2 := openPersistentTestClient(t, socketPath, "reattach-token") + defer conn2.Close() + attach2 := persistentTestRPCCall(t, conn2, reader2, writer2, rpcRequest{ + ID: 2, + Method: "pty.attach", + Params: map[string]any{ + "session_id": sessionID, + "attachment_id": "a2", + "client_attachment_token": "token-a2", + "cols": 100, + "rows": 30, + "command": "printf 'should-not-run\\n'", + "require_existing": true, + }, + }) + if ok, _ := attach2["ok"].(bool); !ok { + t.Fatalf("second pty.attach failed: %v", attach2) + } + readPersistentTestEvent(t, conn2, reader2, func(frame map[string]any) bool { + return frame["event"] == "pty.ready" && frame["attachment_id"] == "a2" + }) + readPersistentTestEvent(t, conn2, reader2, func(frame map[string]any) bool { + if frame["event"] != "pty.data" || frame["attachment_id"] != "a2" { + return false + } + payload, err := base64.StdEncoding.DecodeString(frame["data_base64"].(string)) + return err == nil && strings.Contains(string(payload), "persistent-rpc-data") + }) + + list := persistentTestRPCCall(t, conn2, reader2, writer2, rpcRequest{ + ID: 3, + Method: "pty.list", + Params: map[string]any{}, + }) + if ok, _ := list["ok"].(bool); !ok { + t.Fatalf("pty.list failed: %v", list) + } + result, _ := list["result"].(map[string]any) + sessions, _ := result["sessions"].([]any) + if len(sessions) != 1 { + t.Fatalf("pty.list sessions = %v, want one", result["sessions"]) + } + session, _ := sessions[0].(map[string]any) + if got := session["session_id"]; got != sessionID { + t.Fatalf("pty.list session_id = %v, want %s", got, sessionID) + } + + closeResp := persistentTestRPCCall(t, conn2, reader2, writer2, rpcRequest{ + ID: 4, + Method: "pty.close", + Params: map[string]any{"session_id": sessionID}, + }) + if ok, _ := closeResp["ok"].(bool); !ok { + t.Fatalf("pty.close failed: %v", closeResp) + } +} + func TestRunStdioInvalidJSONAndUnknownMethod(t *testing.T) { input := strings.NewReader( `{"id":1,"method":"hello","params":{}` + "\n" + diff --git a/docs/cli-contract.md b/docs/cli-contract.md index 64cb8a5dbfce..2f7c4536409e 100644 --- a/docs/cli-contract.md +++ b/docs/cli-contract.md @@ -100,6 +100,9 @@ Environment: | `new-workspace` | Create a workspace, optionally with cwd, command, description, and layout. | | `ssh` | Open an SSH-backed workspace. | | `remote-daemon-status` | Print bundled remote daemon version, asset, checksum, and cache status. | +| `ssh-session-list` | List persisted SSH PTY sessions for one remote workspace or all remote workspaces. Supports `--json`. | +| `ssh-session-attach` | Create a local terminal surface that reattaches to an existing persisted SSH PTY session. | +| `ssh-session-cleanup` | Close one or all persisted SSH PTY sessions. Supports `--json`. | | `new-split` | Split from a surface in a direction. | | `list-panes` | List panes in a workspace. | | `list-pane-surfaces` | List surfaces in a pane. | @@ -164,6 +167,7 @@ Environment: | `vm-pty-attach` | Internal VM PTY attach command. | | `vm-ssh-attach` | Hidden compatibility alias for older VM workspaces. | | `vm-pty-connect` | Internal helper that connects to a VM PTY from a config file. | +| `ssh-pty-attach` | Internal helper used by SSH terminal startup scripts to bridge a local terminal surface to a remote PTY session. | | `ssh-session-end` | Internal helper that clears remote SSH session state. | | `__tmux-compat` | Internal tmux compatibility dispatcher. | @@ -425,6 +429,9 @@ the expected text without connecting to a cmux socket. - `cmux new-workspace --help` -> `Usage: cmux new-workspace` - `cmux list-workspaces --help` -> `Usage: cmux list-workspaces` - `cmux ssh --help` -> `Usage: cmux ssh ` +- `cmux ssh-session-list --help` -> `Usage: cmux ssh-session-list` +- `cmux ssh-session-attach --help` -> `Usage: cmux ssh-session-attach --session-id ` +- `cmux ssh-session-cleanup --help` -> `Usage: cmux ssh-session-cleanup` - `cmux new-split --help` -> `Usage: cmux new-split` - `cmux list-panes --help` -> `Usage: cmux list-panes` - `cmux list-pane-surfaces --help` -> `Usage: cmux list-pane-surfaces` diff --git a/docs/remote-daemon-spec.md b/docs/remote-daemon-spec.md index 3c8bb0c8a81b..30e658fbc333 100644 --- a/docs/remote-daemon-spec.md +++ b/docs/remote-daemon-spec.md @@ -1,6 +1,6 @@ # Remote SSH Living Spec -Last updated: March 12, 2026 +Last updated: May 26, 2026 Tracking issue: https://github.com/manaflow-ai/cmux/issues/151 Primary PR: https://github.com/manaflow-ai/cmux/pull/1296 CLI relay PR: https://github.com/manaflow-ai/cmux/pull/374 @@ -40,6 +40,8 @@ This is a **living implementation spec** (also called an **execution spec**): a - `DONE` `workspace.remote.configure.local_proxy_port` exists as an internal deterministic test hook for bind-conflict regression coverage. - `DONE` bootstrap/probe failures surface actionable details. - `DONE` bootstrap installs `~/.cmux/bin/cmux` wrapper (also tries `/usr/local/bin/cmux`) so `cmux` is available in PATH on the remote. +- `DONE` normal `cmux ssh` launches `cmuxd-remote serve --stdio --persistent --slot `, where the stdio process proxies to a long-lived authenticated daemon under `~/.cmux/daemon//`. +- `DONE` persistent daemon slots advertise `pty.session.persistent_daemon`; cmux requires that capability before preserving a saved remote PTY session ID across app relaunch. ### 3.5 CLI Relay (Running cmux Commands From Remote) - `DONE` `cmuxd-remote` includes a table-driven CLI relay (`cli` subcommand) that maps CLI args to v1 text or v2 JSON-RPC messages. @@ -49,6 +51,7 @@ This is a **living implementation spec** (also called an **execution spec**): a - `DONE` relay address written to `~/.cmux/socket_addr` on the remote only after the reverse forward survives startup validation. - `DONE` Go CLI no longer polls for relay readiness. It dials the published relay once and only refreshes `~/.cmux/socket_addr` a single time to recover from a stale shared address rewrite. - `DONE` `cmux ssh` startup exports session-local `CMUX_SOCKET_PATH=127.0.0.1:` so parallel sessions pin to their own relay instead of racing on shared socket_addr. +- `DONE` session snapshots persist the relay port for persistent SSH PTYs and mint fresh relay credentials on restore, so a reattached remote shell can keep using its existing `CMUX_SOCKET_PATH=127.0.0.1:` after app relaunch. - `DONE` relay startup writes `~/.cmux/relay/.daemon_path`; remote `cmux` wrapper uses this to select the right daemon binary per session, including mixed local cmux versions. - `DONE` relay startup writes `~/.cmux/relay/.auth` with a relay ID and token; the local relay requires HMAC-SHA256 challenge-response before forwarding any command to the real local socket. - `DONE` ephemeral port range (49152-65535) filtered from probe results to exclude relay ports from other workspaces. @@ -135,6 +138,7 @@ Recompute effective size on: | M-008 | WebView proxy auto-wiring for remote workspaces | DONE | Workspace-scoped `WKWebsiteDataStore.proxyConfigurations` wiring is active | | M-009 | PTY resize coordinator (`smallest screen wins`) | DONE | Daemon session RPC now tracks attachments and applies min cols/rows semantics with unit tests | | M-010 | Resize + proxy reconnect e2e test suites | DONE | `tests_v2/test_ssh_remote_docker_forwarding.py` validates HTTP/websocket egress plus SOCKS pipelined-payload handling; `tests_v2/test_ssh_remote_docker_reconnect.py` verifies reconnect recovery and repeats SOCKS pipelined-payload checks after host restart; `tests_v2/test_ssh_remote_proxy_bind_conflict.py` validates structured `proxy_unavailable` bind-conflict surfacing and `local_proxy_port` status retention under bind conflict; `tests_v2/test_ssh_remote_daemon_resize_stdio.py` validates session resize semantics over real stdio RPC process boundaries; `tests_v2/test_ssh_remote_cli_metadata.py` validates `workspace.remote.configure` numeric-string compatibility, explicit `null` clear semantics (including `workspace.remote.status` reflection), strict `port`/`local_proxy_port` validation (bounds/type), case-insensitive SSH option override precedence for StrictHostKeyChecking/control-socket keys, and `local_proxy_port` payload echo for deterministic bind-conflict test hook behavior | +| M-011 | Detachable persistent `cmux ssh` PTY sessions | IN PROGRESS | Persistent remote daemon slots keep PTY sessions alive across local surface close and app relaunch; coverage includes Go daemon auth/reattach tests, Swift restore tests, CLI contract tests, and `tests_v2/test_ssh_remote_detachable_pty.py` | ## 7. Acceptance Test Matrix (With Status) @@ -178,6 +182,19 @@ Recompute effective size on: |---|---|---| | RZ-001 | two attachments, smallest wins | DONE | | RZ-002 | grow one attachment, PTY stays bounded by smallest | DONE | +| RZ-003 | detach all attachments, keep last-known PTY size | DONE | +| RZ-004 | reattach existing session, recompute effective size from active attachments | DONE | + +### 7.5 Detachable SSH PTY + +| ID | Scenario | Status | +|---|---|---| +| DP-001 | `cmux ssh` creates a persistent daemon slot and PTY session ID | IN PROGRESS | +| DP-002 | closing the local SSH surface detaches the attachment without killing the remote shell | IN PROGRESS | +| DP-003 | `cmux ssh-session-list` reports detached persisted sessions with bounded scrollback metadata | IN PROGRESS | +| DP-004 | `cmux ssh-session-attach` reattaches to the same remote shell PID and env | IN PROGRESS | +| DP-005 | app relaunch restores saved remote PTY session IDs only when the snapshot has a persistent daemon slot | IN PROGRESS | +| DP-006 | `cmux ssh-session-cleanup` terminates persisted PTY sessions explicitly | DONE | | RZ-003 | detach smallest, PTY expands to next smallest | DONE | | RZ-004 | reconnect preserves session + applies recomputed size | DONE | | RZ-005 | daemon stdio RPC round-trip enforces resize semantics end-to-end | DONE | diff --git a/tests_v2/test_ssh_remote_detachable_pty.py b/tests_v2/test_ssh_remote_detachable_pty.py new file mode 100644 index 000000000000..426acb949950 --- /dev/null +++ b/tests_v2/test_ssh_remote_detachable_pty.py @@ -0,0 +1,280 @@ +#!/usr/bin/env python3 +"""Integration: cmux ssh PTY sessions survive local surface detach and reattach.""" + +from __future__ import annotations + +import glob +import json +import os +import re +import secrets +import subprocess +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET_PATH", "/tmp/cmux-debug.sock") +SSH_HOST = os.environ.get("CMUX_SSH_TEST_HOST", "").strip() +SSH_PORT = os.environ.get("CMUX_SSH_TEST_PORT", "").strip() +SSH_IDENTITY = os.environ.get("CMUX_SSH_TEST_IDENTITY", "").strip() +SSH_OPTIONS_RAW = os.environ.get("CMUX_SSH_TEST_OPTIONS", "").strip() + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _run(cmd: list[str], *, env: dict[str, str] | None = None, check: bool = True) -> subprocess.CompletedProcess[str]: + proc = subprocess.run(cmd, capture_output=True, text=True, env=env, check=False) + if check and proc.returncode != 0: + merged = f"{proc.stdout}\n{proc.stderr}".strip() + raise cmuxError(f"Command failed ({' '.join(cmd)}): {merged}") + return proc + + +def _find_cli_binary() -> str: + env_cli = os.environ.get("CMUXTERM_CLI") + if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK): + return env_cli + + fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux") + if os.path.isfile(fixed) and os.access(fixed, os.X_OK): + return fixed + + candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True) + candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux") + candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)] + if not candidates: + raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI") + candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True) + return candidates[0] + + +def _run_cli_json(cli: str, args: list[str]) -> dict: + env = dict(os.environ) + env.pop("CMUX_WORKSPACE_ID", None) + env.pop("CMUX_SURFACE_ID", None) + env.pop("CMUX_TAB_ID", None) + + proc = _run([cli, "--socket", SOCKET_PATH, "--json", *args], env=env) + try: + return json.loads(proc.stdout or "{}") + except Exception as exc: # noqa: BLE001 + raise cmuxError(f"Invalid JSON output for {' '.join(args)}: {proc.stdout!r} ({exc})") + + +def _wait_for(pred, timeout_s: float = 10.0, step_s: float = 0.15) -> None: + deadline = time.time() + timeout_s + while time.time() < deadline: + if pred(): + return + time.sleep(step_s) + raise cmuxError("Timed out waiting for condition") + + +def _wait_remote_ready(client: cmux, workspace_id: str, timeout_s: float = 45.0) -> None: + deadline = time.time() + timeout_s + last_status = {} + while time.time() < deadline: + last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {} + remote = last_status.get("remote") or {} + daemon = remote.get("daemon") or {} + if str(remote.get("state") or "") == "connected" and str(daemon.get("state") or "") == "ready": + return + time.sleep(0.25) + raise cmuxError(f"Remote did not become ready for {workspace_id}: {last_status}") + + +def _resolve_workspace_id(client: cmux, payload: dict, *, before_workspace_ids: set[str]) -> str: + workspace_id = str(payload.get("workspace_id") or "") + if workspace_id: + return workspace_id + + workspace_ref = str(payload.get("workspace_ref") or "") + if workspace_ref.startswith("workspace:"): + listed = client._call("workspace.list", {}) or {} + for row in listed.get("workspaces") or []: + if str(row.get("ref") or "") == workspace_ref: + resolved = str(row.get("id") or "") + if resolved: + return resolved + + current = {wid for _index, wid, _title, _focused in client.list_workspaces()} + new_ids = sorted(current - before_workspace_ids) + if len(new_ids) == 1: + return new_ids[0] + + raise cmuxError(f"Unable to resolve workspace_id from payload: {payload}") + + +def _workspace_row(client: cmux, workspace_id: str) -> dict: + rows = (client._call("workspace.list", {}) or {}).get("workspaces") or [] + for row in rows: + if str(row.get("id") or "") == workspace_id: + return row + raise cmuxError(f"workspace.list missing {workspace_id}: {rows}") + + +def _run_surface_probe(client: cmux, surface_id: str, command: str, token_prefix: str, timeout_s: float = 18.0) -> str: + token = f"__CMUX_{token_prefix}_{secrets.token_hex(6)}__" + client.send_surface( + surface_id, + ( + f"printf '{token}:START'; echo; " + f"{command}; " + f"printf '{token}:END'; echo" + ), + ) + client.send_key_surface(surface_id, "enter") + deadline = time.time() + timeout_s + last = "" + pattern = re.compile(re.escape(token) + r":START\n(.*?)" + re.escape(token) + r":END", re.S) + while time.time() < deadline: + last = client.read_terminal_text(surface_id) + matches = pattern.findall(last) + if matches: + return matches[-1].replace("\r", "").strip() + time.sleep(0.15) + raise cmuxError(f"Timed out waiting for probe {token!r}: {last[-1200:]!r}") + + +def _open_ssh_workspace(client: cmux, cli: str) -> tuple[str, str, str, str]: + before_workspace_ids = {wid for _index, wid, _title, _focused in client.list_workspaces()} + + ssh_args = ["ssh", SSH_HOST, "--name", f"ssh-detachable-pty-{int(time.time())}"] + if SSH_PORT: + ssh_args.extend(["--port", SSH_PORT]) + if SSH_IDENTITY: + ssh_args.extend(["--identity", SSH_IDENTITY]) + if SSH_OPTIONS_RAW: + for option in SSH_OPTIONS_RAW.split(","): + trimmed = option.strip() + if trimmed: + ssh_args.extend(["--ssh-option", trimmed]) + + payload = _run_cli_json(cli, ssh_args) + workspace_id = _resolve_workspace_id(client, payload, before_workspace_ids=before_workspace_ids) + session_id = str(payload.get("ssh_pty_session_id") or "") + persistent_slot = str(payload.get("persistent_daemon_slot") or "") + _must(session_id.startswith("ssh-"), f"cmux ssh did not create a persistent PTY session: {payload}") + _must(persistent_slot.startswith("ssh-"), f"cmux ssh did not create a persistent daemon slot: {payload}") + + surface_id = str(payload.get("surface_id") or "") + if not surface_id: + surfaces = client.list_surfaces(workspace_id) + _must(len(surfaces) == 1, f"expected one initial ssh surface, got {surfaces}") + surface_id = surfaces[0][1] + + _wait_remote_ready(client, workspace_id) + client.select_workspace(workspace_id) + _wait_for(lambda: client.current_workspace() == workspace_id, timeout_s=8.0) + return workspace_id, surface_id, session_id, persistent_slot + + +def _session_list(cli: str, workspace_id: str) -> list[dict]: + payload = _run_cli_json(cli, ["ssh-session-list", "--workspace", workspace_id]) + return list(payload.get("sessions") or []) + + +def _session_row(cli: str, workspace_id: str, session_id: str) -> dict | None: + for row in _session_list(cli, workspace_id): + if str(row.get("session_id") or "") == session_id: + return row + return None + + +def main() -> int: + if not SSH_HOST: + print("SKIP: set CMUX_SSH_TEST_HOST to run ssh detachable PTY regression") + return 0 + + cli = _find_cli_binary() + workspace_id = "" + session_id = "" + + try: + with cmux(SOCKET_PATH) as client: + workspace_id, surface_id, session_id, persistent_slot = _open_ssh_workspace(client, cli) + + marker = f"detachable_{secrets.token_hex(6)}" + first_probe = _run_surface_probe( + client, + surface_id, + f"export CMUX_DETACH_MARK={marker}; printf 'pid=%s marker=%s socket=%s' \"$$\" \"$CMUX_DETACH_MARK\" \"$CMUX_SOCKET_PATH\"", + "SSH_DETACH_FIRST", + ) + match = re.search(r"pid=([0-9]+) marker=(\S+) socket=(\S+)", first_probe) + _must(match is not None, f"initial shell probe did not return expected fields: {first_probe!r}") + original_pid, original_marker, original_socket = match.groups() + _must(original_marker == marker, f"remote marker was not exported: {first_probe!r}") + _must(original_socket.startswith("127.0.0.1:"), f"remote shell should use relay socket, got {original_socket!r}") + + browser_surface = client.new_surface(panel_type="browser", url="about:blank") + _must(browser_surface, "failed to create browser surface guard") + client.close_surface(surface_id) + + def detached_session_is_listed() -> bool: + row = _session_row(cli, workspace_id, session_id) + if row is None: + return False + attachments = row.get("attachments") or [] + return len(attachments) == 0 + + _wait_for(detached_session_is_listed, timeout_s=20.0, step_s=0.25) + row_after_detach = _session_row(cli, workspace_id, session_id) + _must(row_after_detach is not None, f"detached session {session_id} disappeared") + _must( + int(row_after_detach.get("scrollback_bytes") or 0) > 0, + f"detached session should keep bounded scrollback metadata: {row_after_detach}", + ) + + attach_payload = _run_cli_json( + cli, + ["ssh-session-attach", "--workspace", workspace_id, "--session-id", session_id], + ) + reattached_surface = str(attach_payload.get("surface_id") or "") + _must(reattached_surface, f"ssh-session-attach output missing surface_id: {attach_payload}") + + second_probe = _run_surface_probe( + client, + reattached_surface, + "printf 'pid=%s marker=%s socket=%s' \"$$\" \"$CMUX_DETACH_MARK\" \"$CMUX_SOCKET_PATH\"", + "SSH_DETACH_SECOND", + ) + rematch = re.search(r"pid=([0-9]+) marker=(\S+) socket=(\S+)", second_probe) + _must(rematch is not None, f"reattached shell probe did not return expected fields: {second_probe!r}") + reattached_pid, reattached_marker, reattached_socket = rematch.groups() + _must(reattached_pid == original_pid, f"reattach should preserve shell pid {original_pid}, got {reattached_pid}") + _must(reattached_marker == marker, f"reattach should preserve remote shell env marker, got {second_probe!r}") + _must(reattached_socket == original_socket, f"reattach should preserve relay socket {original_socket}, got {reattached_socket}") + + final_row = _workspace_row(client, workspace_id) + final_remote = final_row.get("remote") or {} + _must( + str(final_remote.get("persistent_daemon_slot") or "") == persistent_slot, + f"workspace status should expose persistent daemon slot {persistent_slot}: {final_row}", + ) + finally: + if workspace_id: + try: + if session_id: + _run_cli_json(cli, ["ssh-session-cleanup", "--workspace", workspace_id, "--session-id", session_id]) + except Exception: + pass + try: + with cmux(SOCKET_PATH) as cleanup_client: + cleanup_client._call("workspace.close", {"workspace_id": workspace_id}) + except Exception: + pass + + print("PASS: cmux ssh PTY survives local detach and reattaches to the same remote shell") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 97cdaeaff1ae0f78f6af83e8cc29667aeb2f8ddb Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 26 May 2026 04:06:56 -0700 Subject: [PATCH 02/69] Fix persistent SSH PTY review findings --- CLI/cmux.swift | 6 +- Sources/Workspace.swift | 3 +- Sources/WorkspaceRemoteConfiguration.swift | 6 +- cmuxTests/WorkspaceUnitTests.swift | 59 ++++++++ daemon/remote/README.md | 9 +- daemon/remote/cmd/cmuxd-remote/main.go | 145 ++++++++++++++++---- daemon/remote/cmd/cmuxd-remote/main_test.go | 30 +++- docs/remote-daemon-spec.md | 8 +- 8 files changed, 225 insertions(+), 41 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 35c23b64bd86..9fa3b3085a51 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -6649,11 +6649,9 @@ struct CMUXCLI { if sshOptions.skipDaemonBootstrap { configureParams["skip_daemon_bootstrap"] = true } - if usesPersistentSSHPTY { + if let persistentDaemonSlot { configureParams["preserve_after_terminal_exit"] = true - if let persistentDaemonSlot { - configureParams["persistent_daemon_slot"] = persistentDaemonSlot - } + configureParams["persistent_daemon_slot"] = persistentDaemonSlot } cliDebugLog( diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index b0bef606826a..c591dd8e9087 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -16064,7 +16064,8 @@ final class Workspace: Identifiable, ObservableObject { private func forkAgentRemoteConfigurationForNewWorkspace(fromPanelId panelId: UUID) -> WorkspaceRemoteConfiguration? { guard forkAgentRemoteStartupCommand(fromPanelId: panelId) != nil else { return nil } return remoteConfiguration?.sessionSnapshot()?.workspaceConfiguration( - localSocketPath: TerminalController.shared.currentSocketPathForRemoteRestore() + localSocketPath: TerminalController.shared.currentSocketPathForRemoteRestore(), + allowPersistentPTYRestore: false ) ?? remoteConfiguration } diff --git a/Sources/WorkspaceRemoteConfiguration.swift b/Sources/WorkspaceRemoteConfiguration.swift index 921efea278fa..fafc76b43e91 100644 --- a/Sources/WorkspaceRemoteConfiguration.swift +++ b/Sources/WorkspaceRemoteConfiguration.swift @@ -372,7 +372,10 @@ struct WorkspaceRemoteConfiguration: Equatable { } extension SessionRemoteWorkspaceSnapshot { - func workspaceConfiguration(localSocketPath: String? = nil) -> WorkspaceRemoteConfiguration? { + func workspaceConfiguration( + localSocketPath: String? = nil, + allowPersistentPTYRestore: Bool = true + ) -> WorkspaceRemoteConfiguration? { guard transport == .ssh else { return nil } let normalizedDestination = destination.trimmingCharacters(in: .whitespacesAndNewlines) guard !normalizedDestination.isEmpty else { return nil } @@ -387,6 +390,7 @@ extension SessionRemoteWorkspaceSnapshot { (1...65535).contains(port) ? port : nil } let preservePTYSession = + allowPersistentPTYRestore && preserveAfterTerminalExit == true && skipDaemonBootstrap != true && normalizedPersistentDaemonSlot != nil && diff --git a/cmuxTests/WorkspaceUnitTests.swift b/cmuxTests/WorkspaceUnitTests.swift index 65fcb719c45f..8f67ef3842eb 100644 --- a/cmuxTests/WorkspaceUnitTests.swift +++ b/cmuxTests/WorkspaceUnitTests.swift @@ -5497,6 +5497,65 @@ final class WorkspacePanelGitBranchTests: XCTestCase { XCTAssertNil(launch.remoteConfiguration?.localSocketPath) } + func testForkAgentWorkspaceLaunchFromPersistentSSHPTYDoesNotReuseParentRelayOrDaemonSlot() throws { + let workspace = Workspace() + workspace.configureRemoteConnection( + WorkspaceRemoteConfiguration( + destination: "cmux-macmini", + port: 2222, + identityFile: "/Users/example/.ssh/cmux", + sshOptions: ["ControlMaster=auto", "ControlPersist=600"], + localProxyPort: nil, + relayPort: 64017, + relayID: "relay-fork-persistent", + relayToken: String(repeating: "c", count: 64), + localSocketPath: "/tmp/cmux-fork-persistent.sock", + terminalStartupCommand: SSHPTYAttachStartupCommandBuilder.command(), + preserveAfterTerminalExit: true, + persistentDaemonSlot: "ssh-parent-slot" + ), + autoConnect: false + ) + let sourcePanelId = try XCTUnwrap(workspace.focusedPanelId) + let snapshot = SessionRestorableAgentSnapshot( + kind: .codex, + sessionId: "019dad34-d218-7943-b81a-eddac5c87951", + workingDirectory: "/Users/cmux/project", + launchCommand: AgentLaunchCommandSnapshot( + launcher: "codex", + executablePath: "/Users/example/.bun/bin/codex", + arguments: ["/Users/example/.bun/bin/codex"], + workingDirectory: "/Users/cmux/project", + environment: nil, + capturedAt: 123, + source: "process" + ) + ) + + let launch = try XCTUnwrap( + workspace.forkAgentWorkspaceLaunch( + fromPanelId: sourcePanelId, + snapshot: snapshot + ) + ) + + XCTAssertTrue(launch.autoConnectRemoteConfiguration) + XCTAssertEqual(launch.remoteConfiguration?.destination, "cmux-macmini") + XCTAssertEqual(launch.remoteConfiguration?.port, 2222) + XCTAssertEqual(launch.remoteConfiguration?.preserveAfterTerminalExit, false) + XCTAssertNil(launch.remoteConfiguration?.relayPort) + XCTAssertNil(launch.remoteConfiguration?.relayID) + XCTAssertNil(launch.remoteConfiguration?.relayToken) + XCTAssertNil(launch.remoteConfiguration?.localSocketPath) + XCTAssertNil(launch.remoteConfiguration?.persistentDaemonSlot) + let startupCommand = try XCTUnwrap(launch.remoteConfiguration?.terminalStartupCommand) + XCTAssertFalse(startupCommand.contains("ssh-pty-attach"), startupCommand) + XCTAssertEqual( + startupCommand, + "ssh -p 2222 -i /Users/example/.ssh/cmux -o ControlMaster=auto -o ControlPersist=600 -tt cmux-macmini" + ) + } + func testForkAgentWorkspaceLaunchInRemoteWorkspaceUsesFallbackDirectoryInForkCommand() throws { let workspace = Workspace() workspace.configureRemoteConnection( diff --git a/daemon/remote/README.md b/daemon/remote/README.md index 9ef4b586fb24..27d21c774c76 100644 --- a/daemon/remote/README.md +++ b/daemon/remote/README.md @@ -13,9 +13,10 @@ Go remote daemon for `cmux ssh` bootstrap, capability negotiation, and remote pr `serve --ws` is explicit opt-in for cloud VM images only. The normal `cmux ssh` code path uses `serve --stdio --persistent --slot ` over an SSH exec channel. That stdio process is only a proxy to an authenticated per-slot daemon -under `~/.cmux/daemon//`, so remote PTY sessions can survive local surface -close, local reconnect, and app relaunch. The persistent server never opens a -public listener; it accepts only the slot-local Unix socket and token. +with credentials and logs under `~/.cmux/daemon//`, so remote PTY sessions +can survive local surface close, local reconnect, and app relaunch. The persistent +server never opens a public listener; it accepts only a per-user Unix socket under +`/tmp/cmuxd-remote-/` and the slot token. When invoked as `cmux` (via wrapper/symlink installed during bootstrap), the binary auto-dispatches to the `cli` subcommand. This is busybox-style argv[0] detection. @@ -55,7 +56,7 @@ slot name is generated locally, validated as `[A-Za-z0-9._-]{1,128}`, and sent to the remote daemon bootstrap as `--slot`. Remote slot files: -1. `~/.cmux/daemon//rpc.sock` authenticated Unix socket for stdio proxies. +1. `/tmp/cmuxd-remote-/cmuxd-.sock` authenticated Unix socket for stdio proxies. 2. `~/.cmux/daemon//auth.token` random 32-byte hex token, mode `0600`. 3. `~/.cmux/daemon//daemon.lock` single-owner lock. 4. `~/.cmux/daemon//daemon.log` startup and crash diagnostics. diff --git a/daemon/remote/cmd/cmuxd-remote/main.go b/daemon/remote/cmd/cmuxd-remote/main.go index d57be8ae0389..ff2c12df85dd 100644 --- a/daemon/remote/cmd/cmuxd-remote/main.go +++ b/daemon/remote/cmd/cmuxd-remote/main.go @@ -5,6 +5,8 @@ import ( "bytes" "context" "crypto/rand" + "crypto/sha256" + "crypto/subtle" "encoding/base64" "encoding/hex" "encoding/json" @@ -301,7 +303,10 @@ type persistentDaemonPaths struct { lockFile string } -const persistentDaemonAuthMethod = "daemon.auth" +const ( + persistentDaemonAuthMethod = "daemon.auth" + persistentDaemonReadyFDEnv = "CMUX_REMOTE_DAEMON_READY_FD" +) func persistentDaemonPathsForSlot(rawSlot string) (persistentDaemonPaths, error) { slot, err := validatePersistentDaemonSlot(rawSlot) @@ -317,16 +322,26 @@ func persistentDaemonPathsForSlot(rawSlot string) (persistentDaemonPaths, error) rootBase = filepath.Join(home, ".cmux", "daemon") } root := filepath.Join(rootBase, slot) + socketPath := persistentDaemonSocketPath(root, slot) return persistentDaemonPaths{ slot: slot, root: root, - socket: filepath.Join(root, "rpc.sock"), + socket: socketPath, tokenFile: filepath.Join(root, "auth.token"), logFile: filepath.Join(root, "daemon.log"), lockFile: filepath.Join(root, "daemon.lock"), }, nil } +func persistentDaemonSocketPath(root string, slot string) string { + socketBase := strings.TrimSpace(os.Getenv("CMUX_REMOTE_DAEMON_SOCKET_DIR")) + if socketBase == "" { + socketBase = filepath.Join("/tmp", fmt.Sprintf("cmuxd-remote-%d", os.Getuid())) + } + digest := sha256.Sum256([]byte(root + "\x00" + slot)) + return filepath.Join(socketBase, "cmuxd-"+hex.EncodeToString(digest[:8])+".sock") +} + func validatePersistentDaemonSlot(rawSlot string) (string, error) { slot := strings.TrimSpace(rawSlot) if slot == "" { @@ -353,7 +368,14 @@ func ensurePersistentDaemonDirectory(paths persistentDaemonPaths) error { if err := os.MkdirAll(paths.root, 0o700); err != nil { return err } - return os.Chmod(paths.root, 0o700) + if err := os.Chmod(paths.root, 0o700); err != nil { + return err + } + socketDir := filepath.Dir(paths.socket) + if err := os.MkdirAll(socketDir, 0o700); err != nil { + return err + } + return os.Chmod(socketDir, 0o700) } func persistentDaemonToken(paths persistentDaemonPaths) (string, error) { @@ -380,24 +402,31 @@ func persistentDaemonToken(paths persistentDaemonPaths) (string, error) { } token := hex.EncodeToString(raw) - file, err := os.OpenFile(paths.tokenFile, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) - if errors.Is(err, os.ErrExist) { - return readExisting() - } + tmpPath := filepath.Join(filepath.Dir(paths.tokenFile), fmt.Sprintf(".auth.token.%d.%d.tmp", os.Getpid(), time.Now().UnixNano())) + file, err := os.OpenFile(tmpPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) if err != nil { return "", err } - writeOK := false + closeOK := false defer func() { - _ = file.Close() - if !writeOK { - _ = os.Remove(paths.tokenFile) + if !closeOK { + _ = file.Close() } + _ = os.Remove(tmpPath) }() if _, err := file.WriteString(token + "\n"); err != nil { return "", err } - writeOK = true + if err := file.Close(); err != nil { + return "", err + } + closeOK = true + if err := os.Link(tmpPath, paths.tokenFile); err != nil { + if errors.Is(err, os.ErrExist) { + return readExisting() + } + return "", err + } return token, nil } @@ -448,8 +477,11 @@ func ensurePersistentDaemonRunning(paths persistentDaemonPaths, token string, st if conn, err := dialPersistentDaemon(paths.socket, token); err == nil { _ = conn.Close() return nil + } else if shouldRemovePersistentSocketAfterDialError(err) { + _ = os.Remove(paths.socket) + } else { + return err } - _ = os.Remove(paths.socket) executable, err := os.Executable() if err != nil { @@ -461,35 +493,77 @@ func ensurePersistentDaemonRunning(paths persistentDaemonPaths, token string, st } defer logFile.Close() + readyReader, readyWriter, err := os.Pipe() + if err != nil { + return err + } + defer readyReader.Close() + defer readyWriter.Close() + cmd := exec.Command(executable, "serve", "--persistent-server", "--slot", paths.slot) cmd.Stdin = nil cmd.Stdout = logFile cmd.Stderr = logFile - cmd.Env = os.Environ() + cmd.Env = append(os.Environ(), persistentDaemonReadyFDEnv+"=3") + cmd.ExtraFiles = []*os.File{readyWriter} cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} if err := cmd.Start(); err != nil { return err } + _ = readyWriter.Close() _ = cmd.Process.Release() - deadline := time.Now().Add(5 * time.Second) - var lastErr error - for time.Now().Before(deadline) { - conn, dialErr := dialPersistentDaemon(paths.socket, token) - if dialErr == nil { + if err := waitPersistentDaemonReady(readyReader, paths.logFile); err != nil { + if conn, dialErr := dialPersistentDaemon(paths.socket, token); dialErr == nil { _ = conn.Close() return nil } - lastErr = dialErr - time.Sleep(50 * time.Millisecond) + if stderr != nil { + _, _ = fmt.Fprintf(stderr, "persistent daemon log: %s\n", paths.logFile) + } + return err } - if stderr != nil && lastErr != nil { + + conn, err := dialPersistentDaemon(paths.socket, token) + if err == nil { + _ = conn.Close() + return nil + } + if stderr != nil { _, _ = fmt.Fprintf(stderr, "persistent daemon log: %s\n", paths.logFile) } - if lastErr == nil { - lastErr = errors.New("persistent daemon did not become ready") + return err +} + +func shouldRemovePersistentSocketAfterDialError(err error) bool { + return errors.Is(err, os.ErrNotExist) || + errors.Is(err, syscall.ENOENT) || + errors.Is(err, syscall.ECONNREFUSED) +} + +func waitPersistentDaemonReady(reader *os.File, logFile string) error { + done := make(chan error, 1) + go func() { + line, err := bufio.NewReader(reader).ReadString('\n') + if err != nil { + done <- fmt.Errorf("persistent daemon exited before readiness signal; log: %s: %w", logFile, err) + return + } + if strings.TrimSpace(line) != "ready" { + done <- fmt.Errorf("persistent daemon sent unexpected readiness signal %q; log: %s", strings.TrimSpace(line), logFile) + return + } + done <- nil + }() + + timer := time.NewTimer(5 * time.Second) + defer timer.Stop() + select { + case err := <-done: + return err + case <-timer.C: + return fmt.Errorf("persistent daemon did not become ready; log: %s", logFile) } - return lastErr } func runPersistentDaemonServer(slot string, stderr io.Writer) error { @@ -523,9 +597,27 @@ func runPersistentDaemonServer(slot string, stderr io.Writer) error { defer os.Remove(paths.socket) _ = os.Chmod(paths.socket, 0o600) + signalPersistentDaemonReady() return servePersistentDaemon(listener, token, stderr) } +func signalPersistentDaemonReady() { + rawFD := strings.TrimSpace(os.Getenv(persistentDaemonReadyFDEnv)) + if rawFD == "" { + return + } + fd, err := strconv.Atoi(rawFD) + if err != nil || fd < 3 { + return + } + file := os.NewFile(uintptr(fd), "cmux-persistent-daemon-ready") + if file == nil { + return + } + _, _ = file.WriteString("ready\n") + _ = file.Close() +} + func servePersistentDaemon(listener net.Listener, token string, stderr io.Writer) error { hub := newWebSocketPTYHub(wsPTYServerConfig{}, stderr) defer hub.closeAll() @@ -598,7 +690,8 @@ func authenticatePersistentDaemonConn(reader *bufio.Reader, writer *stdioFrameWr return false } provided, _ := getStringParam(req.Params, "token") - if strings.TrimSpace(provided) == "" || strings.TrimSpace(provided) != token { + provided = strings.TrimSpace(provided) + if provided == "" || subtle.ConstantTimeCompare([]byte(provided), []byte(token)) != 1 { _ = writer.writeResponse(rpcResponse{ ID: req.ID, OK: false, diff --git a/daemon/remote/cmd/cmuxd-remote/main_test.go b/daemon/remote/cmd/cmuxd-remote/main_test.go index 9c4479157bff..2e460742fc0f 100644 --- a/daemon/remote/cmd/cmuxd-remote/main_test.go +++ b/daemon/remote/cmd/cmuxd-remote/main_test.go @@ -50,7 +50,14 @@ func (b *notifyingBuffer) String() string { func startPersistentDaemonForTest(t *testing.T, token string) (string, func()) { t.Helper() - socketPath := filepath.Join(t.TempDir(), "rpc.sock") + socketDir, err := os.MkdirTemp("/tmp", "cmuxd-remote-test-*") + if err != nil { + t.Fatalf("create short socket dir: %v", err) + } + t.Cleanup(func() { + _ = os.RemoveAll(socketDir) + }) + socketPath := filepath.Join(socketDir, "rpc.sock") listener, err := net.Listen("unix", socketPath) if err != nil { t.Fatalf("listen unix: %v", err) @@ -302,6 +309,27 @@ func TestPersistentDaemonRejectsInvalidSlot(t *testing.T) { } } +func TestPersistentDaemonPathsUseShortSocketPath(t *testing.T) { + rootBase := filepath.Join( + t.TempDir(), + strings.Repeat("long-path-segment-", 4), + "daemon-root", + ) + t.Setenv("CMUX_REMOTE_DAEMON_ROOT", rootBase) + t.Setenv("CMUX_REMOTE_DAEMON_SOCKET_DIR", "") + + paths, err := persistentDaemonPathsForSlot(strings.Repeat("a", 128)) + if err != nil { + t.Fatalf("persistentDaemonPathsForSlot returned error: %v", err) + } + if strings.HasPrefix(paths.socket, paths.root) { + t.Fatalf("socket path should not live under long daemon root: socket=%q root=%q", paths.socket, paths.root) + } + if len(paths.socket) >= 100 { + t.Fatalf("socket path length = %d, want < 100: %q", len(paths.socket), paths.socket) + } +} + func TestPersistentDaemonTokenConcurrentCreate(t *testing.T) { root := t.TempDir() paths := persistentDaemonPaths{ diff --git a/docs/remote-daemon-spec.md b/docs/remote-daemon-spec.md index 30e658fbc333..45ff4d84bd86 100644 --- a/docs/remote-daemon-spec.md +++ b/docs/remote-daemon-spec.md @@ -40,7 +40,7 @@ This is a **living implementation spec** (also called an **execution spec**): a - `DONE` `workspace.remote.configure.local_proxy_port` exists as an internal deterministic test hook for bind-conflict regression coverage. - `DONE` bootstrap/probe failures surface actionable details. - `DONE` bootstrap installs `~/.cmux/bin/cmux` wrapper (also tries `/usr/local/bin/cmux`) so `cmux` is available in PATH on the remote. -- `DONE` normal `cmux ssh` launches `cmuxd-remote serve --stdio --persistent --slot `, where the stdio process proxies to a long-lived authenticated daemon under `~/.cmux/daemon//`. +- `DONE` normal `cmux ssh` launches `cmuxd-remote serve --stdio --persistent --slot `, where the stdio process proxies to a long-lived authenticated daemon with slot credentials under `~/.cmux/daemon//` and a short per-user socket path under `/tmp/cmuxd-remote-/`. - `DONE` persistent daemon slots advertise `pty.session.persistent_daemon`; cmux requires that capability before preserving a saved remote PTY session ID across app relaunch. ### 3.5 CLI Relay (Running cmux Commands From Remote) @@ -184,6 +184,9 @@ Recompute effective size on: | RZ-002 | grow one attachment, PTY stays bounded by smallest | DONE | | RZ-003 | detach all attachments, keep last-known PTY size | DONE | | RZ-004 | reattach existing session, recompute effective size from active attachments | DONE | +| RZ-005 | detach smallest, PTY expands to next smallest | DONE | +| RZ-006 | reconnect preserves session + applies recomputed size | DONE | +| RZ-007 | daemon stdio RPC round-trip enforces resize semantics end-to-end | DONE | ### 7.5 Detachable SSH PTY @@ -195,9 +198,6 @@ Recompute effective size on: | DP-004 | `cmux ssh-session-attach` reattaches to the same remote shell PID and env | IN PROGRESS | | DP-005 | app relaunch restores saved remote PTY session IDs only when the snapshot has a persistent daemon slot | IN PROGRESS | | DP-006 | `cmux ssh-session-cleanup` terminates persisted PTY sessions explicitly | DONE | -| RZ-003 | detach smallest, PTY expands to next smallest | DONE | -| RZ-004 | reconnect preserves session + applies recomputed size | DONE | -| RZ-005 | daemon stdio RPC round-trip enforces resize semantics end-to-end | DONE | ## 8. Removal Checklist (Port Mirroring) From fc5f4b01ac19e7061ee34777a2bf843a660fce14 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 26 May 2026 04:15:56 -0700 Subject: [PATCH 03/69] Bound persistent daemon auth handshake --- daemon/remote/cmd/cmuxd-remote/main.go | 16 ++++++++++-- daemon/remote/cmd/cmuxd-remote/main_test.go | 29 +++++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/daemon/remote/cmd/cmuxd-remote/main.go b/daemon/remote/cmd/cmuxd-remote/main.go index ff2c12df85dd..4b777fdf8e4e 100644 --- a/daemon/remote/cmd/cmuxd-remote/main.go +++ b/daemon/remote/cmd/cmuxd-remote/main.go @@ -304,8 +304,9 @@ type persistentDaemonPaths struct { } const ( - persistentDaemonAuthMethod = "daemon.auth" - persistentDaemonReadyFDEnv = "CMUX_REMOTE_DAEMON_READY_FD" + persistentDaemonAuthMethod = "daemon.auth" + persistentDaemonReadyFDEnv = "CMUX_REMOTE_DAEMON_READY_FD" + persistentDaemonAuthTimeout = 5 * time.Second ) func persistentDaemonPathsForSlot(rawSlot string) (persistentDaemonPaths, error) { @@ -785,6 +786,17 @@ func dialPersistentDaemon(socketPath string, token string) (net.Conn, error) { } func authenticatePersistentDaemonClient(conn net.Conn, token string) error { + return authenticatePersistentDaemonClientWithTimeout(conn, token, persistentDaemonAuthTimeout) +} + +func authenticatePersistentDaemonClientWithTimeout(conn net.Conn, token string, timeout time.Duration) error { + if timeout > 0 { + if err := conn.SetDeadline(time.Now().Add(timeout)); err != nil { + return err + } + defer conn.SetDeadline(time.Time{}) + } + writer := bufio.NewWriter(conn) request := rpcRequest{ ID: "auth", diff --git a/daemon/remote/cmd/cmuxd-remote/main_test.go b/daemon/remote/cmd/cmuxd-remote/main_test.go index 2e460742fc0f..9bea024508f4 100644 --- a/daemon/remote/cmd/cmuxd-remote/main_test.go +++ b/daemon/remote/cmd/cmuxd-remote/main_test.go @@ -411,6 +411,35 @@ func TestPersistentDaemonRejectsBadToken(t *testing.T) { } } +func TestAuthenticatePersistentDaemonClientReadDeadline(t *testing.T) { + client, server := net.Pipe() + defer client.Close() + defer server.Close() + + requestRead := make(chan error, 1) + go func() { + _, err := bufio.NewReader(server).ReadString('\n') + requestRead <- err + }() + + start := time.Now() + err := authenticatePersistentDaemonClientWithTimeout(client, "token", 50*time.Millisecond) + if err == nil { + t.Fatalf("authenticatePersistentDaemonClientWithTimeout succeeded, want timeout error") + } + if elapsed := time.Since(start); elapsed > time.Second { + t.Fatalf("authenticatePersistentDaemonClientWithTimeout took %s, want bounded deadline", elapsed) + } + select { + case readErr := <-requestRead: + if readErr != nil { + t.Fatalf("server failed to read auth request: %v", readErr) + } + case <-time.After(time.Second): + t.Fatalf("server did not receive auth request") + } +} + func TestPersistentDaemonPTYReattachSurvivesClientDisconnect(t *testing.T) { socketPath, stop := startPersistentDaemonForTest(t, "reattach-token") defer stop() From 4251df2009cdb297935641fd4830700a45700c26 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 26 May 2026 05:58:22 -0700 Subject: [PATCH 04/69] Fix persistent SSH daemon review findings --- Sources/TerminalController.swift | 11 ++ Sources/Workspace.swift | 3 +- Sources/WorkspaceRemoteConfiguration.swift | 4 +- .../TabManagerSessionSnapshotTests.swift | 28 ++++ ...erminalControllerSocketSecurityTests.swift | 20 +++ daemon/remote/cmd/cmuxd-remote/main.go | 141 ++++++++++++++++-- daemon/remote/cmd/cmuxd-remote/main_test.go | 77 ++++++++++ 7 files changed, 269 insertions(+), 15 deletions(-) diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 84ee4edf7e8f..945e803b49b4 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -6260,6 +6260,17 @@ class TerminalController { ) } let skipDaemonBootstrap = v2Bool(params, "skip_daemon_bootstrap") ?? false + if preserveAfterTerminalExit, + transport == .ssh, + !skipDaemonBootstrap, + daemonWebSocketEndpoint == nil, + persistentDaemonSlot == nil { + return .err( + code: "invalid_params", + message: "persistent_daemon_slot is required when preserve_after_terminal_exit is true for bootstrap SSH", + data: nil + ) + } if relayPort != nil { guard let relayID, !relayID.isEmpty else { return .err(code: "invalid_params", message: "relay_id is required when relay_port is set", data: nil) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index c591dd8e9087..9e2a499e9f9d 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -6666,8 +6666,7 @@ final class WorkspaceRemoteSessionController { private var bakedDaemonPreflightRequiredCapabilities: [String] { requiredDaemonCapabilities.filter { $0 != WorkspaceRemoteDaemonRPCClient.requiredPTYSessionCapability && - $0 != WorkspaceRemoteDaemonRPCClient.requiredPTYSessionTokenCapability && - $0 != WorkspaceRemoteDaemonRPCClient.requiredPTYPersistentDaemonCapability + $0 != WorkspaceRemoteDaemonRPCClient.requiredPTYSessionTokenCapability } } diff --git a/Sources/WorkspaceRemoteConfiguration.swift b/Sources/WorkspaceRemoteConfiguration.swift index fafc76b43e91..6e7750459862 100644 --- a/Sources/WorkspaceRemoteConfiguration.swift +++ b/Sources/WorkspaceRemoteConfiguration.swift @@ -386,6 +386,7 @@ extension SessionRemoteWorkspaceSnapshot { let normalizedOptions = Self.normalizedSSHOptions(sshOptions) let optionsWithRestoreControlDefaults = SSHPTYAttachStartupCommandBuilder.sshOptionsWithRestoreControlDefaults(normalizedOptions) let normalizedPersistentDaemonSlot = WorkspaceRemoteSSHOptionFilter.normalizedOptional(persistentDaemonSlot) + let normalizedLocalSocketPath = WorkspaceRemoteSSHOptionFilter.normalizedOptional(localSocketPath) let normalizedRelayPort = relayPort.flatMap { port in (1...65535).contains(port) ? port : nil } @@ -394,6 +395,7 @@ extension SessionRemoteWorkspaceSnapshot { preserveAfterTerminalExit == true && skipDaemonBootstrap != true && normalizedPersistentDaemonSlot != nil && + normalizedLocalSocketPath != nil && normalizedRelayPort != nil && SSHPTYAttachStartupCommandBuilder.sshOptionsSupportReusableForegroundAuth(optionsWithRestoreControlDefaults) let restoredSSHOptions = preservePTYSession ? optionsWithRestoreControlDefaults : normalizedOptions @@ -423,7 +425,7 @@ extension SessionRemoteWorkspaceSnapshot { relayPort: preservePTYSession ? normalizedRelayPort : nil, relayID: restoredRelayID, relayToken: restoredRelayToken, - localSocketPath: preservePTYSession ? WorkspaceRemoteSSHOptionFilter.normalizedOptional(localSocketPath) : nil, + localSocketPath: preservePTYSession ? normalizedLocalSocketPath : nil, terminalStartupCommand: preservePTYSession ? SSHPTYAttachStartupCommandBuilder.command( foregroundAuth: foregroundAuth, diff --git a/cmuxTests/TabManagerSessionSnapshotTests.swift b/cmuxTests/TabManagerSessionSnapshotTests.swift index 8056fd7752cb..4a62324e8e90 100644 --- a/cmuxTests/TabManagerSessionSnapshotTests.swift +++ b/cmuxTests/TabManagerSessionSnapshotTests.swift @@ -1974,6 +1974,34 @@ final class TabManagerSessionSnapshotTests: XCTestCase { XCTAssertEqual(configuration.terminalStartupCommand, "ssh -p 2222 -o StrictHostKeyChecking=accept-new -tt dev@example.com") } + func testSessionRemoteWorkspaceSnapshotRequiresLocalSocketPathForPTYRestore() throws { + 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, "ssh -p 2222 -o StrictHostKeyChecking=accept-new -tt dev@example.com") + } + func testSessionRemoteWorkspaceSnapshotDropsInvalidSSHPortFromReconnectCommand() throws { let snapshot = SessionRemoteWorkspaceSnapshot( transport: .ssh, diff --git a/cmuxTests/TerminalControllerSocketSecurityTests.swift b/cmuxTests/TerminalControllerSocketSecurityTests.swift index 8f06f27889bb..84ec9d858955 100644 --- a/cmuxTests/TerminalControllerSocketSecurityTests.swift +++ b/cmuxTests/TerminalControllerSocketSecurityTests.swift @@ -222,6 +222,26 @@ final class TerminalControllerSocketSecurityTests: XCTestCase { ) } + func testRemoteConfigureRejectsPreserveWithoutPersistentDaemonSlotForBootstrapSSH() throws { + let response = try handleV2Request( + method: "workspace.remote.configure", + params: [ + "workspace_id": UUID().uuidString, + "transport": "ssh", + "destination": "example.com", + "preserve_after_terminal_exit": true, + ] + ) + + XCTAssertEqual(response["ok"] as? Bool, false, "Unexpected JSON-RPC response: \(response)") + let error = try XCTUnwrap(response["error"] as? [String: Any]) + XCTAssertEqual(error["code"] as? String, "invalid_params") + XCTAssertEqual( + error["message"] as? String, + "persistent_daemon_slot is required when preserve_after_terminal_exit is true for bootstrap SSH" + ) + } + func testRemotePTYResizeRunsOnSocketWorker() async throws { let socketPath = makeSocketPath("pty-worker") let tabManager = TabManager() diff --git a/daemon/remote/cmd/cmuxd-remote/main.go b/daemon/remote/cmd/cmuxd-remote/main.go index 4b777fdf8e4e..0768b5c96c02 100644 --- a/daemon/remote/cmd/cmuxd-remote/main.go +++ b/daemon/remote/cmd/cmuxd-remote/main.go @@ -309,6 +309,8 @@ const ( persistentDaemonAuthTimeout = 5 * time.Second ) +var errPersistentDaemonAuthFailed = errors.New("persistent daemon authentication failed") + func persistentDaemonPathsForSlot(rawSlot string) (persistentDaemonPaths, error) { slot, err := validatePersistentDaemonSlot(rawSlot) if err != nil { @@ -450,28 +452,49 @@ func runPersistentStdioProxy(stdin io.Reader, stdout, stderr io.Writer, slot str if err != nil { return err } - defer conn.Close() + return proxyPersistentDaemonConn(stdin, stdout, conn) +} + +type persistentProxyCopyResult struct { + stream string + err error +} - errCh := make(chan error, 2) +func proxyPersistentDaemonConn(stdin io.Reader, stdout io.Writer, conn net.Conn) error { + defer conn.Close() + errCh := make(chan persistentProxyCopyResult, 2) go func() { _, copyErr := io.Copy(conn, stdin) if unixConn, ok := conn.(*net.UnixConn); ok { _ = unixConn.CloseWrite() } - errCh <- copyErr + errCh <- persistentProxyCopyResult{stream: "stdin", err: copyErr} }() go func() { _, copyErr := io.Copy(stdout, conn) - errCh <- copyErr + errCh <- persistentProxyCopyResult{stream: "stdout", err: copyErr} }() - var firstErr error - for i := 0; i < 2; i++ { - if copyErr := <-errCh; copyErr != nil && !errors.Is(copyErr, net.ErrClosed) && firstErr == nil { - firstErr = copyErr - } + first := <-errCh + if first.stream == "stdout" { + return persistentProxyCopyError(first.err) } - return firstErr + second := <-errCh + if firstErr := persistentProxyCopyError(first.err); firstErr != nil { + return firstErr + } + return persistentProxyCopyError(second.err) +} + +func persistentProxyCopyError(err error) error { + if err == nil || + errors.Is(err, net.ErrClosed) || + errors.Is(err, os.ErrClosed) || + errors.Is(err, io.ErrClosedPipe) || + errors.Is(err, syscall.EPIPE) { + return nil + } + return err } func ensurePersistentDaemonRunning(paths persistentDaemonPaths, token string, stderr io.Writer) error { @@ -480,6 +503,10 @@ func ensurePersistentDaemonRunning(paths persistentDaemonPaths, token string, st return nil } else if shouldRemovePersistentSocketAfterDialError(err) { _ = os.Remove(paths.socket) + } else if errors.Is(err, errPersistentDaemonAuthFailed) { + if recoverErr := recoverPersistentDaemonAuthFailure(paths, err); recoverErr != nil { + return recoverErr + } } else { return err } @@ -588,6 +615,9 @@ func runPersistentDaemonServer(slot string, stderr io.Writer) error { return fmt.Errorf("persistent daemon slot %q is already running", paths.slot) } defer syscall.Flock(int(lockFile.Fd()), syscall.LOCK_UN) + if err := writePersistentDaemonLockPID(lockFile); err != nil { + return err + } _ = os.Remove(paths.socket) listener, err := net.Listen("unix", paths.socket) @@ -619,6 +649,78 @@ func signalPersistentDaemonReady() { _ = file.Close() } +func writePersistentDaemonLockPID(file *os.File) error { + if err := file.Truncate(0); err != nil { + return err + } + if _, err := file.Seek(0, io.SeekStart); err != nil { + return err + } + _, err := fmt.Fprintf(file, "%d\n", os.Getpid()) + return err +} + +func persistentDaemonLockPID(lockFile string) (int, error) { + data, err := os.ReadFile(lockFile) + if err != nil { + return 0, err + } + raw := strings.TrimSpace(string(data)) + if raw == "" { + return 0, errors.New("persistent daemon lock file does not contain a pid") + } + pid, err := strconv.Atoi(raw) + if err != nil || pid <= 1 { + return 0, fmt.Errorf("persistent daemon lock file contains invalid pid %q", raw) + } + return pid, nil +} + +func recoverPersistentDaemonAuthFailure(paths persistentDaemonPaths, authErr error) error { + pid, err := persistentDaemonLockPID(paths.lockFile) + if err != nil { + return fmt.Errorf("%w; could not recover existing daemon: %v", authErr, err) + } + if pid == os.Getpid() { + return fmt.Errorf("%w; refusing to stop current process", authErr) + } + if err := syscall.Kill(pid, syscall.SIGTERM); err != nil && !errors.Is(err, syscall.ESRCH) { + return fmt.Errorf("%w; could not stop existing daemon pid %d: %v", authErr, pid, err) + } + _ = os.Remove(paths.socket) + if err := waitPersistentDaemonLockAvailable(paths.lockFile, 2*time.Second); err != nil { + return fmt.Errorf("%w; existing daemon pid %d did not release lock: %v", authErr, pid, err) + } + return nil +} + +func waitPersistentDaemonLockAvailable(lockFile string, timeout time.Duration) error { + file, err := os.OpenFile(lockFile, os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return err + } + defer file.Close() + + done := make(chan error, 1) + go func() { + lockErr := syscall.Flock(int(file.Fd()), syscall.LOCK_EX) + if lockErr == nil { + _ = syscall.Flock(int(file.Fd()), syscall.LOCK_UN) + } + done <- lockErr + }() + + timer := time.NewTimer(timeout) + defer timer.Stop() + select { + case err := <-done: + return err + case <-timer.C: + _ = file.Close() + return fmt.Errorf("timed out waiting for lock release") + } +} + func servePersistentDaemon(listener net.Listener, token string, stderr io.Writer) error { hub := newWebSocketPTYHub(wsPTYServerConfig{}, stderr) defer hub.closeAll() @@ -645,12 +747,26 @@ func isClosedListenerError(err error) bool { } func handlePersistentDaemonConn(conn net.Conn, token string, hub *wsPTYHub) { + handlePersistentDaemonConnWithAuthTimeout(conn, token, hub, persistentDaemonAuthTimeout) +} + +func handlePersistentDaemonConnWithAuthTimeout(conn net.Conn, token string, hub *wsPTYHub, timeout time.Duration) { defer conn.Close() + if timeout > 0 { + if err := conn.SetDeadline(time.Now().Add(timeout)); err != nil { + return + } + } reader := bufio.NewReaderSize(conn, 64*1024) writer := &stdioFrameWriter{writer: bufio.NewWriter(conn)} if !authenticatePersistentDaemonConn(reader, writer, token) { return } + if timeout > 0 { + if err := conn.SetDeadline(time.Time{}); err != nil { + return + } + } _ = runRPCServerWithReader(reader, writer, hub, false) } @@ -831,10 +947,11 @@ func authenticatePersistentDaemonClientWithTimeout(conn net.Conn, token string, return err } if !resp.OK { + message := "persistent daemon authentication failed" if resp.Error != nil && strings.TrimSpace(resp.Error.Message) != "" { - return errors.New(resp.Error.Message) + message = strings.TrimSpace(resp.Error.Message) } - return errors.New("persistent daemon authentication failed") + return fmt.Errorf("%w: %s", errPersistentDaemonAuthFailed, message) } return nil } diff --git a/daemon/remote/cmd/cmuxd-remote/main_test.go b/daemon/remote/cmd/cmuxd-remote/main_test.go index 9bea024508f4..1ca7b6d84b68 100644 --- a/daemon/remote/cmd/cmuxd-remote/main_test.go +++ b/daemon/remote/cmd/cmuxd-remote/main_test.go @@ -6,6 +6,7 @@ import ( "context" "encoding/base64" "encoding/json" + "errors" "io" "math" "net" @@ -411,6 +412,20 @@ func TestPersistentDaemonRejectsBadToken(t *testing.T) { } } +func TestDialPersistentDaemonBadTokenWrapsAuthFailure(t *testing.T) { + socketPath, stop := startPersistentDaemonForTest(t, "good-token") + defer stop() + + conn, err := dialPersistentDaemon(socketPath, "bad-token") + if err == nil { + _ = conn.Close() + t.Fatalf("dialPersistentDaemon succeeded with bad token") + } + if !errors.Is(err, errPersistentDaemonAuthFailed) { + t.Fatalf("dialPersistentDaemon error = %v, want errPersistentDaemonAuthFailed", err) + } +} + func TestAuthenticatePersistentDaemonClientReadDeadline(t *testing.T) { client, server := net.Pipe() defer client.Close() @@ -440,6 +455,68 @@ func TestAuthenticatePersistentDaemonClientReadDeadline(t *testing.T) { } } +func TestAuthenticatePersistentDaemonServerReadDeadline(t *testing.T) { + client, server := net.Pipe() + defer client.Close() + + hub := newWebSocketPTYHub(wsPTYServerConfig{}, io.Discard) + defer hub.closeAll() + + done := make(chan struct{}, 1) + go func() { + handlePersistentDaemonConnWithAuthTimeout(server, "token", hub, 50*time.Millisecond) + done <- struct{}{} + }() + + select { + case <-done: + case <-time.After(time.Second): + t.Fatalf("server auth handler did not return after deadline") + } +} + +func TestPersistentStdioProxyReturnsWhenDaemonClosesFirst(t *testing.T) { + client, server := net.Pipe() + stdinReader, stdinWriter := io.Pipe() + defer stdinWriter.Close() + + done := make(chan error, 1) + go func() { + done <- proxyPersistentDaemonConn(stdinReader, io.Discard, client) + }() + + _ = server.Close() + select { + case err := <-done: + if err != nil { + t.Fatalf("proxyPersistentDaemonConn returned error: %v", err) + } + case <-time.After(time.Second): + t.Fatalf("proxyPersistentDaemonConn did not return after daemon side closed") + } + _ = stdinWriter.Close() +} + +func TestPersistentDaemonLockPIDRoundTrip(t *testing.T) { + lockFile := filepath.Join(t.TempDir(), "daemon.lock") + file, err := os.OpenFile(lockFile, os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + t.Fatalf("open lock file: %v", err) + } + defer file.Close() + + if err := writePersistentDaemonLockPID(file); err != nil { + t.Fatalf("writePersistentDaemonLockPID: %v", err) + } + pid, err := persistentDaemonLockPID(lockFile) + if err != nil { + t.Fatalf("persistentDaemonLockPID: %v", err) + } + if pid != os.Getpid() { + t.Fatalf("persistentDaemonLockPID = %d, want %d", pid, os.Getpid()) + } +} + func TestPersistentDaemonPTYReattachSurvivesClientDisconnect(t *testing.T) { socketPath, stop := startPersistentDaemonForTest(t, "reattach-token") defer stop() From e2387ba495be024fd4f6022b2f8fe279c822f98a Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 26 May 2026 07:02:22 -0700 Subject: [PATCH 05/69] Enforce persistent daemon slot invariant --- Sources/TerminalController.swift | 7 +++++++ Sources/WorkspaceRemoteConfiguration.swift | 4 +++- ...erminalControllerSocketSecurityTests.swift | 20 +++++++++++++++++++ 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 945e803b49b4..544c9529c94d 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -6260,6 +6260,13 @@ class TerminalController { ) } let skipDaemonBootstrap = v2Bool(params, "skip_daemon_bootstrap") ?? false + if persistentDaemonSlot != nil, !preserveAfterTerminalExit { + return .err( + code: "invalid_params", + message: "preserve_after_terminal_exit is required when persistent_daemon_slot is set", + data: nil + ) + } if preserveAfterTerminalExit, transport == .ssh, !skipDaemonBootstrap, diff --git a/Sources/WorkspaceRemoteConfiguration.swift b/Sources/WorkspaceRemoteConfiguration.swift index 6e7750459862..4271f9db4b56 100644 --- a/Sources/WorkspaceRemoteConfiguration.swift +++ b/Sources/WorkspaceRemoteConfiguration.swift @@ -331,7 +331,9 @@ struct WorkspaceRemoteConfiguration: Equatable { self.foregroundAuthToken = foregroundAuthToken self.daemonWebSocketEndpoint = daemonWebSocketEndpoint self.preserveAfterTerminalExit = preserveAfterTerminalExit - self.persistentDaemonSlot = WorkspaceRemoteSSHOptionFilter.normalizedOptional(persistentDaemonSlot) + self.persistentDaemonSlot = preserveAfterTerminalExit + ? WorkspaceRemoteSSHOptionFilter.normalizedOptional(persistentDaemonSlot) + : nil self.skipDaemonBootstrap = skipDaemonBootstrap } diff --git a/cmuxTests/TerminalControllerSocketSecurityTests.swift b/cmuxTests/TerminalControllerSocketSecurityTests.swift index 84ec9d858955..dddc93608080 100644 --- a/cmuxTests/TerminalControllerSocketSecurityTests.swift +++ b/cmuxTests/TerminalControllerSocketSecurityTests.swift @@ -242,6 +242,26 @@ final class TerminalControllerSocketSecurityTests: XCTestCase { ) } + func testRemoteConfigureRejectsPersistentDaemonSlotWithoutPreserve() throws { + let response = try handleV2Request( + method: "workspace.remote.configure", + params: [ + "workspace_id": UUID().uuidString, + "transport": "ssh", + "destination": "example.com", + "persistent_daemon_slot": "ssh-test-slot", + ] + ) + + XCTAssertEqual(response["ok"] as? Bool, false, "Unexpected JSON-RPC response: \(response)") + let error = try XCTUnwrap(response["error"] as? [String: Any]) + XCTAssertEqual(error["code"] as? String, "invalid_params") + XCTAssertEqual( + error["message"] as? String, + "preserve_after_terminal_exit is required when persistent_daemon_slot is set" + ) + } + func testRemotePTYResizeRunsOnSocketWorker() async throws { let socketPath = makeSocketPath("pty-worker") let tabManager = TabManager() From 595cd7596b6d0810b8e1dd48d2edd0ea08bb8bf7 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 26 May 2026 07:11:11 -0700 Subject: [PATCH 06/69] Fix persistent SSH restore control paths --- Sources/Workspace.swift | 4 +- Sources/WorkspaceRemoteConfiguration.swift | 39 +++++++++++++++---- .../TabManagerSessionSnapshotTests.swift | 3 ++ 3 files changed, 38 insertions(+), 8 deletions(-) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 9e2a499e9f9d..171ae72f25b4 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -16062,7 +16062,9 @@ final class Workspace: Identifiable, ObservableObject { private func forkAgentRemoteConfigurationForNewWorkspace(fromPanelId panelId: UUID) -> WorkspaceRemoteConfiguration? { guard forkAgentRemoteStartupCommand(fromPanelId: panelId) != nil else { return nil } - return remoteConfiguration?.sessionSnapshot()?.workspaceConfiguration( + let forkedSSHOptions = remoteConfiguration + .map { WorkspaceRemoteConfiguration.forkedAgentSSHOptions($0.sshOptions) } + return remoteConfiguration?.sessionSnapshot(sshOptionsOverride: forkedSSHOptions)?.workspaceConfiguration( localSocketPath: TerminalController.shared.currentSocketPathForRemoteRestore(), allowPersistentPTYRestore: false ) ?? remoteConfiguration diff --git a/Sources/WorkspaceRemoteConfiguration.swift b/Sources/WorkspaceRemoteConfiguration.swift index 4271f9db4b56..d0fb2aa908ee 100644 --- a/Sources/WorkspaceRemoteConfiguration.swift +++ b/Sources/WorkspaceRemoteConfiguration.swift @@ -10,14 +10,25 @@ private enum WorkspaceRemoteSSHOptionFilter { "controlpath", "controlpersist", ] + private static let relayScopedControlSocketKeys: Set = [ + "controlpath", + ] static func durableOptions(_ options: [String]) -> [String] { + filteredOptions(options, droppingKeys: transientControlSocketKeys) + } + + static func forkedWorkspaceOptions(_ options: [String]) -> [String] { + filteredOptions(options, droppingKeys: relayScopedControlSocketKeys) + } + + private static func filteredOptions(_ options: [String], droppingKeys keys: Set) -> [String] { options.compactMap { option in let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? nil : trimmed }.filter { option in guard let key = optionKey(option) else { return true } - return !transientControlSocketKeys.contains(key) + return !keys.contains(key) } } @@ -181,7 +192,7 @@ nonisolated enum SSHPTYAttachStartupCommandBuilder { return arguments.map(shellQuote).joined(separator: " ") } - static func sshOptionsWithRestoreControlDefaults(_ options: [String]) -> [String] { + static func sshOptionsWithRestoreControlDefaults(_ options: [String], relayPort: Int? = nil) -> [String] { var merged = options.compactMap(normalized) let controlMaster = sshOptionValue(named: "ControlMaster", in: merged) let controlMasterDisabled = sshOptionValueIsDisabled(controlMaster) @@ -193,12 +204,19 @@ nonisolated enum SSHPTYAttachStartupCommandBuilder { merged.append("ControlPersist=600") } if !hasSSHOptionKey(merged, key: "ControlPath") { - merged.append("ControlPath=/tmp/cmux-ssh-\(getuid())-%C") + merged.append("ControlPath=\(restoreControlPathTemplate(relayPort: relayPort))") } } return merged } + private static func restoreControlPathTemplate(relayPort: Int?) -> String { + if let relayPort, relayPort > 0 { + return "/tmp/cmux-ssh-\(getuid())-\(relayPort)-%C" + } + return "/tmp/cmux-ssh-\(getuid())-%C" + } + static func sshOptionsSupportReusableForegroundAuth(_ options: [String]) -> Bool { guard !hasSSHOptionKey(options, key: "LocalCommand"), !hasSSHOptionKey(options, key: "PermitLocalCommand") else { @@ -385,13 +403,16 @@ extension SessionRemoteWorkspaceSnapshot { (1...65535).contains(port) ? port : nil } - let normalizedOptions = Self.normalizedSSHOptions(sshOptions) - let optionsWithRestoreControlDefaults = SSHPTYAttachStartupCommandBuilder.sshOptionsWithRestoreControlDefaults(normalizedOptions) let normalizedPersistentDaemonSlot = WorkspaceRemoteSSHOptionFilter.normalizedOptional(persistentDaemonSlot) let normalizedLocalSocketPath = WorkspaceRemoteSSHOptionFilter.normalizedOptional(localSocketPath) let normalizedRelayPort = relayPort.flatMap { port in (1...65535).contains(port) ? port : nil } + let normalizedOptions = Self.normalizedSSHOptions(sshOptions) + let optionsWithRestoreControlDefaults = SSHPTYAttachStartupCommandBuilder.sshOptionsWithRestoreControlDefaults( + normalizedOptions, + relayPort: normalizedRelayPort + ) let preservePTYSession = allowPersistentPTYRestore && preserveAfterTerminalExit == true && @@ -501,7 +522,11 @@ extension SessionRemoteWorkspaceSnapshot { } extension WorkspaceRemoteConfiguration { - func sessionSnapshot() -> SessionRemoteWorkspaceSnapshot? { + static func forkedAgentSSHOptions(_ options: [String]) -> [String] { + WorkspaceRemoteSSHOptionFilter.forkedWorkspaceOptions(options) + } + + func sessionSnapshot(sshOptionsOverride: [String]? = nil) -> SessionRemoteWorkspaceSnapshot? { guard transport == .ssh else { return nil } let normalizedDestination = destination.trimmingCharacters(in: .whitespacesAndNewlines) guard !normalizedDestination.isEmpty else { return nil } @@ -511,7 +536,7 @@ extension WorkspaceRemoteConfiguration { destination: normalizedDestination, port: port, identityFile: WorkspaceRemoteSSHOptionFilter.normalizedIdentityPath(identityFile), - sshOptions: WorkspaceRemoteSSHOptionFilter.durableOptions(sshOptions), + sshOptions: sshOptionsOverride ?? WorkspaceRemoteSSHOptionFilter.durableOptions(sshOptions), preserveAfterTerminalExit: preserveAfterTerminalExit ? true : nil, skipDaemonBootstrap: skipDaemonBootstrap, relayPort: preserveAfterTerminalExit ? relayPort : nil, diff --git a/cmuxTests/TabManagerSessionSnapshotTests.swift b/cmuxTests/TabManagerSessionSnapshotTests.swift index 4a62324e8e90..d4a7c4fd1a37 100644 --- a/cmuxTests/TabManagerSessionSnapshotTests.swift +++ b/cmuxTests/TabManagerSessionSnapshotTests.swift @@ -1811,6 +1811,9 @@ final class TabManagerSessionSnapshotTests: XCTestCase { XCTAssertEqual(restoredWorkspace.remoteConfiguration?.relayPort, 64003) XCTAssertEqual(restoredWorkspace.remoteConfiguration?.persistentDaemonSlot, persistentDaemonSlot) XCTAssertEqual(restoredWorkspace.remoteConfiguration?.localSocketPath, TerminalController.shared.currentSocketPathForRemoteRestore()) + XCTAssertTrue( + restoredWorkspace.remoteConfiguration?.sshOptions.contains("ControlPath=/tmp/cmux-ssh-\(getuid())-64003-%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) From c5af26717c9581c0dc3c41b2bd162e2d92e81076 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 26 May 2026 07:42:06 -0700 Subject: [PATCH 07/69] Preserve persistent daemon sessions on auth rotation --- Sources/WorkspaceRemoteConfiguration.swift | 14 +- .../TabManagerSessionSnapshotTests.swift | 28 ++++ daemon/remote/cmd/cmuxd-remote/main.go | 135 ++++++------------ daemon/remote/cmd/cmuxd-remote/main_test.go | 46 +++--- 4 files changed, 107 insertions(+), 116 deletions(-) diff --git a/Sources/WorkspaceRemoteConfiguration.swift b/Sources/WorkspaceRemoteConfiguration.swift index d0fb2aa908ee..cf7c1fa3b387 100644 --- a/Sources/WorkspaceRemoteConfiguration.swift +++ b/Sources/WorkspaceRemoteConfiguration.swift @@ -38,6 +38,16 @@ private enum WorkspaceRemoteSSHOptionFilter { return trimmed.isEmpty ? nil : trimmed } + static func normalizedPersistentDaemonSlot(_ value: String?) -> String? { + guard let slot = normalizedOptional(value), + slot != ".", + slot != "..", + slot.range(of: "^[A-Za-z0-9._-]{1,128}$", options: .regularExpression) != nil else { + return nil + } + return slot + } + static func normalizedIdentityPath(_ value: String?) -> String? { guard let trimmed = normalizedOptional(value) else { return nil } guard trimmed.hasPrefix("~") else { return trimmed } @@ -350,7 +360,7 @@ struct WorkspaceRemoteConfiguration: Equatable { self.daemonWebSocketEndpoint = daemonWebSocketEndpoint self.preserveAfterTerminalExit = preserveAfterTerminalExit self.persistentDaemonSlot = preserveAfterTerminalExit - ? WorkspaceRemoteSSHOptionFilter.normalizedOptional(persistentDaemonSlot) + ? WorkspaceRemoteSSHOptionFilter.normalizedPersistentDaemonSlot(persistentDaemonSlot) : nil self.skipDaemonBootstrap = skipDaemonBootstrap } @@ -403,7 +413,7 @@ extension SessionRemoteWorkspaceSnapshot { (1...65535).contains(port) ? port : nil } - let normalizedPersistentDaemonSlot = WorkspaceRemoteSSHOptionFilter.normalizedOptional(persistentDaemonSlot) + let normalizedPersistentDaemonSlot = WorkspaceRemoteSSHOptionFilter.normalizedPersistentDaemonSlot(persistentDaemonSlot) let normalizedLocalSocketPath = WorkspaceRemoteSSHOptionFilter.normalizedOptional(localSocketPath) let normalizedRelayPort = relayPort.flatMap { port in (1...65535).contains(port) ? port : nil diff --git a/cmuxTests/TabManagerSessionSnapshotTests.swift b/cmuxTests/TabManagerSessionSnapshotTests.swift index d4a7c4fd1a37..f7e0e8b61b86 100644 --- a/cmuxTests/TabManagerSessionSnapshotTests.swift +++ b/cmuxTests/TabManagerSessionSnapshotTests.swift @@ -2005,6 +2005,34 @@ final class TabManagerSessionSnapshotTests: XCTestCase { XCTAssertEqual(configuration.terminalStartupCommand, "ssh -p 2222 -o StrictHostKeyChecking=accept-new -tt dev@example.com") } + func testSessionRemoteWorkspaceSnapshotRequiresValidPersistentDaemonSlotForPTYRestore() throws { + 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) + XCTAssertNil(configuration.relayPort) + XCTAssertNil(configuration.localSocketPath) + XCTAssertFalse(configuration.terminalStartupCommand?.contains("ssh-pty-attach") == true) + XCTAssertEqual(configuration.terminalStartupCommand, "ssh -p 2222 -o StrictHostKeyChecking=accept-new -tt dev@example.com") + } + func testSessionRemoteWorkspaceSnapshotDropsInvalidSSHPortFromReconnectCommand() throws { let snapshot = SessionRemoteWorkspaceSnapshot( transport: .ssh, diff --git a/daemon/remote/cmd/cmuxd-remote/main.go b/daemon/remote/cmd/cmuxd-remote/main.go index 0768b5c96c02..b75b10f26247 100644 --- a/daemon/remote/cmd/cmuxd-remote/main.go +++ b/daemon/remote/cmd/cmuxd-remote/main.go @@ -382,18 +382,7 @@ func ensurePersistentDaemonDirectory(paths persistentDaemonPaths) error { } func persistentDaemonToken(paths persistentDaemonPaths) (string, error) { - readExisting := func() (string, error) { - data, err := os.ReadFile(paths.tokenFile) - if err != nil { - return "", err - } - token := strings.TrimSpace(string(data)) - if token == "" { - return "", errors.New("persistent daemon token file is empty") - } - return token, nil - } - if token, err := readExisting(); err == nil { + if token, err := readPersistentDaemonTokenFile(paths.tokenFile); err == nil { return token, nil } else if !errors.Is(err, os.ErrNotExist) { return "", err @@ -426,13 +415,25 @@ func persistentDaemonToken(paths persistentDaemonPaths) (string, error) { closeOK = true if err := os.Link(tmpPath, paths.tokenFile); err != nil { if errors.Is(err, os.ErrExist) { - return readExisting() + return readPersistentDaemonTokenFile(paths.tokenFile) } return "", err } return token, nil } +func readPersistentDaemonTokenFile(tokenFile string) (string, error) { + data, err := os.ReadFile(tokenFile) + if err != nil { + return "", err + } + token := strings.TrimSpace(string(data)) + if token == "" { + return "", errors.New("persistent daemon token file is empty") + } + return token, nil +} + func runPersistentStdioProxy(stdin io.Reader, stdout, stderr io.Writer, slot string) error { paths, err := persistentDaemonPathsForSlot(slot) if err != nil { @@ -503,10 +504,6 @@ func ensurePersistentDaemonRunning(paths persistentDaemonPaths, token string, st return nil } else if shouldRemovePersistentSocketAfterDialError(err) { _ = os.Remove(paths.socket) - } else if errors.Is(err, errPersistentDaemonAuthFailed) { - if recoverErr := recoverPersistentDaemonAuthFailure(paths, err); recoverErr != nil { - return recoverErr - } } else { return err } @@ -615,9 +612,6 @@ func runPersistentDaemonServer(slot string, stderr io.Writer) error { return fmt.Errorf("persistent daemon slot %q is already running", paths.slot) } defer syscall.Flock(int(lockFile.Fd()), syscall.LOCK_UN) - if err := writePersistentDaemonLockPID(lockFile); err != nil { - return err - } _ = os.Remove(paths.socket) listener, err := net.Listen("unix", paths.socket) @@ -629,7 +623,7 @@ func runPersistentDaemonServer(slot string, stderr io.Writer) error { _ = os.Chmod(paths.socket, 0o600) signalPersistentDaemonReady() - return servePersistentDaemon(listener, token, stderr) + return servePersistentDaemonWithVerifier(listener, persistentDaemonFileTokenVerifier(token, paths.tokenFile), stderr) } func signalPersistentDaemonReady() { @@ -649,79 +643,37 @@ func signalPersistentDaemonReady() { _ = file.Close() } -func writePersistentDaemonLockPID(file *os.File) error { - if err := file.Truncate(0); err != nil { - return err - } - if _, err := file.Seek(0, io.SeekStart); err != nil { - return err - } - _, err := fmt.Fprintf(file, "%d\n", os.Getpid()) - return err +func servePersistentDaemon(listener net.Listener, token string, stderr io.Writer) error { + return servePersistentDaemonWithVerifier(listener, persistentDaemonFixedTokenVerifier(token), stderr) } -func persistentDaemonLockPID(lockFile string) (int, error) { - data, err := os.ReadFile(lockFile) - if err != nil { - return 0, err - } - raw := strings.TrimSpace(string(data)) - if raw == "" { - return 0, errors.New("persistent daemon lock file does not contain a pid") - } - pid, err := strconv.Atoi(raw) - if err != nil || pid <= 1 { - return 0, fmt.Errorf("persistent daemon lock file contains invalid pid %q", raw) - } - return pid, nil -} +type persistentDaemonTokenVerifier func(string) bool -func recoverPersistentDaemonAuthFailure(paths persistentDaemonPaths, authErr error) error { - pid, err := persistentDaemonLockPID(paths.lockFile) - if err != nil { - return fmt.Errorf("%w; could not recover existing daemon: %v", authErr, err) - } - if pid == os.Getpid() { - return fmt.Errorf("%w; refusing to stop current process", authErr) - } - if err := syscall.Kill(pid, syscall.SIGTERM); err != nil && !errors.Is(err, syscall.ESRCH) { - return fmt.Errorf("%w; could not stop existing daemon pid %d: %v", authErr, pid, err) +func persistentDaemonFixedTokenVerifier(token string) persistentDaemonTokenVerifier { + return func(provided string) bool { + return persistentDaemonTokensEqual(provided, token) } - _ = os.Remove(paths.socket) - if err := waitPersistentDaemonLockAvailable(paths.lockFile, 2*time.Second); err != nil { - return fmt.Errorf("%w; existing daemon pid %d did not release lock: %v", authErr, pid, err) - } - return nil } -func waitPersistentDaemonLockAvailable(lockFile string, timeout time.Duration) error { - file, err := os.OpenFile(lockFile, os.O_CREATE|os.O_RDWR, 0o600) - if err != nil { - return err - } - defer file.Close() - - done := make(chan error, 1) - go func() { - lockErr := syscall.Flock(int(file.Fd()), syscall.LOCK_EX) - if lockErr == nil { - _ = syscall.Flock(int(file.Fd()), syscall.LOCK_UN) +func persistentDaemonFileTokenVerifier(initialToken string, tokenFile string) persistentDaemonTokenVerifier { + return func(provided string) bool { + token := initialToken + if currentToken, err := readPersistentDaemonTokenFile(tokenFile); err == nil { + token = currentToken } - done <- lockErr - }() - - timer := time.NewTimer(timeout) - defer timer.Stop() - select { - case err := <-done: - return err - case <-timer.C: - _ = file.Close() - return fmt.Errorf("timed out waiting for lock release") + return persistentDaemonTokensEqual(provided, token) } } -func servePersistentDaemon(listener net.Listener, token string, stderr io.Writer) error { +func persistentDaemonTokensEqual(provided string, token string) bool { + provided = strings.TrimSpace(provided) + token = strings.TrimSpace(token) + return provided != "" && + token != "" && + subtle.ConstantTimeCompare([]byte(provided), []byte(token)) == 1 +} + +func servePersistentDaemonWithVerifier(listener net.Listener, verifier persistentDaemonTokenVerifier, stderr io.Writer) error { hub := newWebSocketPTYHub(wsPTYServerConfig{}, stderr) defer hub.closeAll() for { @@ -732,7 +684,7 @@ func servePersistentDaemon(listener net.Listener, token string, stderr io.Writer } return err } - go handlePersistentDaemonConn(conn, token, hub) + go handlePersistentDaemonConn(conn, verifier, hub) } } @@ -746,11 +698,11 @@ func isClosedListenerError(err error) bool { return strings.Contains(err.Error(), "use of closed network connection") } -func handlePersistentDaemonConn(conn net.Conn, token string, hub *wsPTYHub) { - handlePersistentDaemonConnWithAuthTimeout(conn, token, hub, persistentDaemonAuthTimeout) +func handlePersistentDaemonConn(conn net.Conn, verifier persistentDaemonTokenVerifier, hub *wsPTYHub) { + handlePersistentDaemonConnWithAuthTimeout(conn, verifier, hub, persistentDaemonAuthTimeout) } -func handlePersistentDaemonConnWithAuthTimeout(conn net.Conn, token string, hub *wsPTYHub, timeout time.Duration) { +func handlePersistentDaemonConnWithAuthTimeout(conn net.Conn, verifier persistentDaemonTokenVerifier, hub *wsPTYHub, timeout time.Duration) { defer conn.Close() if timeout > 0 { if err := conn.SetDeadline(time.Now().Add(timeout)); err != nil { @@ -759,7 +711,7 @@ func handlePersistentDaemonConnWithAuthTimeout(conn net.Conn, token string, hub } reader := bufio.NewReaderSize(conn, 64*1024) writer := &stdioFrameWriter{writer: bufio.NewWriter(conn)} - if !authenticatePersistentDaemonConn(reader, writer, token) { + if !authenticatePersistentDaemonConn(reader, writer, verifier) { return } if timeout > 0 { @@ -770,7 +722,7 @@ func handlePersistentDaemonConnWithAuthTimeout(conn net.Conn, token string, hub _ = runRPCServerWithReader(reader, writer, hub, false) } -func authenticatePersistentDaemonConn(reader *bufio.Reader, writer *stdioFrameWriter, token string) bool { +func authenticatePersistentDaemonConn(reader *bufio.Reader, writer *stdioFrameWriter, verifier persistentDaemonTokenVerifier) bool { line, oversized, err := readRPCFrame(reader, maxRPCFrameBytes) if err != nil || oversized { _ = writer.writeResponse(rpcResponse{ @@ -807,8 +759,7 @@ func authenticatePersistentDaemonConn(reader *bufio.Reader, writer *stdioFrameWr return false } provided, _ := getStringParam(req.Params, "token") - provided = strings.TrimSpace(provided) - if provided == "" || subtle.ConstantTimeCompare([]byte(provided), []byte(token)) != 1 { + if !verifier(provided) { _ = writer.writeResponse(rpcResponse{ ID: req.ID, OK: false, diff --git a/daemon/remote/cmd/cmuxd-remote/main_test.go b/daemon/remote/cmd/cmuxd-remote/main_test.go index 1ca7b6d84b68..16c56354f240 100644 --- a/daemon/remote/cmd/cmuxd-remote/main_test.go +++ b/daemon/remote/cmd/cmuxd-remote/main_test.go @@ -50,6 +50,10 @@ func (b *notifyingBuffer) String() string { } func startPersistentDaemonForTest(t *testing.T, token string) (string, func()) { + return startPersistentDaemonWithVerifierForTest(t, persistentDaemonFixedTokenVerifier(token)) +} + +func startPersistentDaemonWithVerifierForTest(t *testing.T, verifier persistentDaemonTokenVerifier) (string, func()) { t.Helper() socketDir, err := os.MkdirTemp("/tmp", "cmuxd-remote-test-*") if err != nil { @@ -65,7 +69,7 @@ func startPersistentDaemonForTest(t *testing.T, token string) (string, func()) { } done := make(chan error, 1) go func() { - done <- servePersistentDaemon(listener, token, io.Discard) + done <- servePersistentDaemonWithVerifier(listener, verifier, io.Discard) }() stop := func() { _ = listener.Close() @@ -426,6 +430,24 @@ func TestDialPersistentDaemonBadTokenWrapsAuthFailure(t *testing.T) { } } +func TestPersistentDaemonAcceptsRotatedTokenFile(t *testing.T) { + tokenFile := filepath.Join(t.TempDir(), "auth.token") + if err := os.WriteFile(tokenFile, []byte("old-token\n"), 0o600); err != nil { + t.Fatalf("write initial token: %v", err) + } + socketPath, stop := startPersistentDaemonWithVerifierForTest( + t, + persistentDaemonFileTokenVerifier("old-token", tokenFile), + ) + defer stop() + + if err := os.WriteFile(tokenFile, []byte("new-token\n"), 0o600); err != nil { + t.Fatalf("rotate token: %v", err) + } + conn, _, _ := openPersistentTestClient(t, socketPath, "new-token") + _ = conn.Close() +} + func TestAuthenticatePersistentDaemonClientReadDeadline(t *testing.T) { client, server := net.Pipe() defer client.Close() @@ -464,7 +486,7 @@ func TestAuthenticatePersistentDaemonServerReadDeadline(t *testing.T) { done := make(chan struct{}, 1) go func() { - handlePersistentDaemonConnWithAuthTimeout(server, "token", hub, 50*time.Millisecond) + handlePersistentDaemonConnWithAuthTimeout(server, persistentDaemonFixedTokenVerifier("token"), hub, 50*time.Millisecond) done <- struct{}{} }() @@ -497,26 +519,6 @@ func TestPersistentStdioProxyReturnsWhenDaemonClosesFirst(t *testing.T) { _ = stdinWriter.Close() } -func TestPersistentDaemonLockPIDRoundTrip(t *testing.T) { - lockFile := filepath.Join(t.TempDir(), "daemon.lock") - file, err := os.OpenFile(lockFile, os.O_CREATE|os.O_RDWR, 0o600) - if err != nil { - t.Fatalf("open lock file: %v", err) - } - defer file.Close() - - if err := writePersistentDaemonLockPID(file); err != nil { - t.Fatalf("writePersistentDaemonLockPID: %v", err) - } - pid, err := persistentDaemonLockPID(lockFile) - if err != nil { - t.Fatalf("persistentDaemonLockPID: %v", err) - } - if pid != os.Getpid() { - t.Fatalf("persistentDaemonLockPID = %d, want %d", pid, os.Getpid()) - } -} - func TestPersistentDaemonPTYReattachSurvivesClientDisconnect(t *testing.T) { socketPath, stop := startPersistentDaemonForTest(t, "reattach-token") defer stop() From c3dc301d16dc4d6ecdef71fad5ebb280aaf1d479 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 26 May 2026 08:49:34 -0700 Subject: [PATCH 08/69] Fix SSH fork and daemon cleanup regressions --- Sources/Workspace.swift | 13 +++++++++++-- Sources/WorkspaceRemoteConfiguration.swift | 11 +++++++++-- cmuxTests/GhosttyConfigTests.swift | 3 ++- 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 171ae72f25b4..c65ea96d328e 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -7943,12 +7943,20 @@ final class WorkspaceRemoteSessionController { if trimmed.contains(" -N ") && trimmed.contains(" -R 127.0.0.1:") { return true } - if trimmed.contains("cmuxd-remote") && trimmed.contains(" serve --stdio") { + if isCMUXRemoteDaemonServeStdioCommand(trimmed) { return true } return false } + private static func isCMUXRemoteDaemonServeStdioCommand(_ command: String) -> Bool { + guard command.contains("cmuxd-remote") else { return false } + let normalized = command + .replacingOccurrences(of: "'", with: " ") + .replacingOccurrences(of: "\"", with: " ") + return normalized.contains(" serve ") && normalized.contains(" --stdio") + } + private static func commandContainsDestination(_ command: String, destination: String) -> Bool { guard !destination.isEmpty else { return false } let escaped = NSRegularExpression.escapedPattern(for: destination) @@ -16066,7 +16074,8 @@ final class Workspace: Identifiable, ObservableObject { .map { WorkspaceRemoteConfiguration.forkedAgentSSHOptions($0.sshOptions) } return remoteConfiguration?.sessionSnapshot(sshOptionsOverride: forkedSSHOptions)?.workspaceConfiguration( localSocketPath: TerminalController.shared.currentSocketPathForRemoteRestore(), - allowPersistentPTYRestore: false + allowPersistentPTYRestore: false, + preserveSSHOptions: true ) ?? remoteConfiguration } diff --git a/Sources/WorkspaceRemoteConfiguration.swift b/Sources/WorkspaceRemoteConfiguration.swift index cf7c1fa3b387..ca6fd42416b6 100644 --- a/Sources/WorkspaceRemoteConfiguration.swift +++ b/Sources/WorkspaceRemoteConfiguration.swift @@ -22,6 +22,10 @@ private enum WorkspaceRemoteSSHOptionFilter { filteredOptions(options, droppingKeys: relayScopedControlSocketKeys) } + static func trimmedOptions(_ options: [String]) -> [String] { + filteredOptions(options, droppingKeys: []) + } + private static func filteredOptions(_ options: [String], droppingKeys keys: Set) -> [String] { options.compactMap { option in let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) @@ -404,7 +408,8 @@ struct WorkspaceRemoteConfiguration: Equatable { extension SessionRemoteWorkspaceSnapshot { func workspaceConfiguration( localSocketPath: String? = nil, - allowPersistentPTYRestore: Bool = true + allowPersistentPTYRestore: Bool = true, + preserveSSHOptions: Bool = false ) -> WorkspaceRemoteConfiguration? { guard transport == .ssh else { return nil } let normalizedDestination = destination.trimmingCharacters(in: .whitespacesAndNewlines) @@ -418,7 +423,9 @@ extension SessionRemoteWorkspaceSnapshot { let normalizedRelayPort = relayPort.flatMap { port in (1...65535).contains(port) ? port : nil } - let normalizedOptions = Self.normalizedSSHOptions(sshOptions) + let normalizedOptions = preserveSSHOptions + ? WorkspaceRemoteSSHOptionFilter.trimmedOptions(sshOptions) + : Self.normalizedSSHOptions(sshOptions) let optionsWithRestoreControlDefaults = SSHPTYAttachStartupCommandBuilder.sshOptionsWithRestoreControlDefaults( normalizedOptions, relayPort: normalizedRelayPort diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index af735db3ced7..6f2ff129018a 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -2435,6 +2435,7 @@ final class WorkspaceRemoteSSHCleanupTests: XCTestCase { let psOutput = """ 101 1 /usr/bin/ssh -N -T -S none -o ControlPath=/tmp/cmux-ssh-501-56080-%C -R 127.0.0.1:56080:127.0.0.1:64048 cmux-macmini 102 1 /usr/bin/ssh -T -S none -o RequestTTY=no cmux-macmini sh -c 'exec .cmux/bin/cmuxd-remote/0.63.1/darwin-arm64/cmuxd-remote serve --stdio' + 107 1 /usr/bin/ssh -T -S none -o RequestTTY=no cmux-macmini sh -c 'exec .cmux/bin/cmuxd-remote/0.63.1/darwin-arm64/cmuxd-remote 'serve' '--stdio' '--persistent' '--slot' 'ssh-test'' 103 999 /usr/bin/ssh -N -T -S none -R 127.0.0.1:56081:127.0.0.1:64049 cmux-macmini 104 1 /usr/bin/ssh -tt cmux-macmini 105 1 /usr/bin/ssh -N -T -S none -R 127.0.0.1:56082:127.0.0.1:64050 other-host @@ -2446,7 +2447,7 @@ final class WorkspaceRemoteSSHCleanupTests: XCTestCase { psOutput: psOutput, destination: "cmux-macmini" ), - [101, 102] + [101, 102, 107] ) } From 693987b16de6a0dc77728907143dd0055e47b95e Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Wed, 27 May 2026 00:04:41 -0700 Subject: [PATCH 09/69] test: cover persistent SSH PTY relaunch snapshots --- .../TabManagerSessionSnapshotTests.swift | 56 +++++++++++++++++++ .../WorkspaceRemoteConnectionTests.swift | 40 ++++++++++++- 2 files changed, 95 insertions(+), 1 deletion(-) diff --git a/cmuxTests/TabManagerSessionSnapshotTests.swift b/cmuxTests/TabManagerSessionSnapshotTests.swift index f7e0e8b61b86..7970be9acfbd 100644 --- a/cmuxTests/TabManagerSessionSnapshotTests.swift +++ b/cmuxTests/TabManagerSessionSnapshotTests.swift @@ -1851,6 +1851,62 @@ final class TabManagerSessionSnapshotTests: XCTestCase { ) } + func testPersistentSSHPTYRestoreFallsBackToSnapshotPanelDefaultSessionID() throws { + 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" } + ) + let panelIndex = try XCTUnwrap( + legacySnapshot.workspaces[workspaceIndex].panels.firstIndex { $0.id == originalPanelId } + ) + legacySnapshot.workspaces[workspaceIndex].panels[panelIndex].terminal?.remotePTYSessionID = nil + + let restored = TabManager() + restored.restoreSessionSnapshot(legacySnapshot) + + let restoredWorkspace = try XCTUnwrap(restored.tabs.first { $0.customTitle == "Legacy Persistent SSH" }) + let restoredPanelId = try XCTUnwrap(restoredWorkspace.focusedPanelId) + let restoredInitialCommand = try XCTUnwrap( + restoredWorkspace.terminalPanel(for: restoredPanelId)?.surface.debugInitialCommand() + ) + 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 testSessionSnapshotFallsBackFromSkipBootstrapPersistentSSHPTYWithoutDaemonBridge() throws { let manager = TabManager() let remoteWorkspace = manager.addWorkspace(select: true) diff --git a/cmuxTests/WorkspaceRemoteConnectionTests.swift b/cmuxTests/WorkspaceRemoteConnectionTests.swift index f88984119382..09b347013ffc 100644 --- a/cmuxTests/WorkspaceRemoteConnectionTests.swift +++ b/cmuxTests/WorkspaceRemoteConnectionTests.swift @@ -779,7 +779,8 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { relayToken: String(repeating: "b", count: 64), localSocketPath: "/tmp/cmux-debug-test.sock", terminalStartupCommand: "ssh cmux-macmini", - preserveAfterTerminalExit: true + preserveAfterTerminalExit: true, + persistentDaemonSlot: "ssh-persist-end" ) let cleanupRequested = expectation(description: "control master cleanup requested") cleanupRequested.isInverted = true @@ -792,6 +793,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { workspace.configureRemoteConnection(config, autoConnect: false) let panelID = try XCTUnwrap(workspace.focusedTerminalPanel?.id) + let expectedSessionID = Workspace.defaultSSHPTYSessionID(workspaceId: workspace.id, panelId: panelID) workspace.markRemoteTerminalSessionEnded(surfaceId: panelID, relayPort: 64012) wait(for: [cleanupRequested], timeout: 0.2) @@ -799,6 +801,12 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { XCTAssertTrue(workspace.isRemoteWorkspace) XCTAssertEqual(workspace.activeRemoteTerminalSessionCount, 0) XCTAssertEqual(workspace.remoteConfiguration?.preserveAfterTerminalExit, true) + XCTAssertEqual(workspace.remoteConfiguration?.persistentDaemonSlot, "ssh-persist-end") + XCTAssertEqual( + workspace.sessionSnapshot(includeScrollback: false) + .panels.first { $0.id == panelID }?.terminal?.remotePTYSessionID, + expectedSessionID + ) workspace.teardownAllPanels() XCTAssertTrue(workspace.panels.isEmpty) @@ -1818,6 +1826,36 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { ) } + @MainActor + func testPersistentRemoteTerminalSeedsDefaultPTYSessionIDForSnapshot() throws { + let workspace = Workspace() + let config = WorkspaceRemoteConfiguration( + destination: "cmux-macmini", + port: nil, + identityFile: nil, + sshOptions: [], + localProxyPort: nil, + relayPort: 64015, + relayID: String(repeating: "a", count: 16), + relayToken: String(repeating: "b", count: 64), + localSocketPath: "/tmp/cmux-debug-test.sock", + terminalStartupCommand: "ssh-pty-attach", + preserveAfterTerminalExit: true, + persistentDaemonSlot: "ssh-seeded-default" + ) + workspace.configureRemoteConnection(config, autoConnect: false) + + let panelID = try XCTUnwrap(workspace.focusedTerminalPanel?.id) + let expectedSessionID = Workspace.defaultSSHPTYSessionID(workspaceId: workspace.id, panelId: panelID) + + XCTAssertTrue(workspace.remotePTYSessionIDMatches(panelId: panelID, sessionID: expectedSessionID)) + XCTAssertEqual( + workspace.sessionSnapshot(includeScrollback: false) + .panels.first { $0.id == panelID }?.terminal?.remotePTYSessionID, + expectedSessionID + ) + } + @MainActor func testDetachAttachPreservesSurfaceTTYMetadata() throws { let source = Workspace() From aa85f40fe9339981ee42022c389a6dc6dbaa5134 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Wed, 27 May 2026 00:05:06 -0700 Subject: [PATCH 10/69] fix: persist SSH PTY session IDs through relaunch --- Sources/Workspace.swift | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index c65ea96d328e..86a829780d3a 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -1156,6 +1156,7 @@ extension Workspace { return nil } return normalizedRemotePTYSessionID(snapshot.terminal?.remotePTYSessionID) + ?? Self.defaultSSHPTYSessionID(workspaceId: id, panelId: snapshot.id) }() let restoredRemotePTYAttachCommand = restoredRemotePTYSessionID.map { remotePTYAttachStartupCommand(sessionID: $0) @@ -12078,6 +12079,10 @@ final class Workspace: Identifiable, ObservableObject { skipControlMasterCleanupAfterDetachedRemoteTransfer = false pendingRemoteTerminalChildExitSurfaceIds.remove(panelId) transferredRemoteCleanupConfigurationsByPanelId.removeValue(forKey: panelId) + if remoteConfiguration?.preserveAfterTerminalExit == true, + normalizedRemotePTYSessionID(remotePTYSessionIDsByPanelId[panelId]) == nil { + remotePTYSessionIDsByPanelId[panelId] = Self.defaultSSHPTYSessionID(workspaceId: id, panelId: panelId) + } guard activeRemoteTerminalSurfaceIds.insert(panelId).inserted else { return } activeRemoteTerminalSessionCount = activeRemoteTerminalSurfaceIds.count applyPendingRemoteSurfaceTTYIfNeeded(to: panelId) @@ -12100,12 +12105,16 @@ final class Workspace: Identifiable, ObservableObject { } private func remotePTYSessionIDForSnapshot(panelId: UUID) -> String? { - guard remoteConfiguration?.preserveAfterTerminalExit == true, - activeRemoteTerminalSurfaceIds.contains(panelId) else { + guard remoteConfiguration?.preserveAfterTerminalExit == true else { return nil } - return normalizedRemotePTYSessionID(remotePTYSessionIDsByPanelId[panelId]) - ?? Self.defaultSSHPTYSessionID(workspaceId: id, panelId: panelId) + if let storedSessionID = normalizedRemotePTYSessionID(remotePTYSessionIDsByPanelId[panelId]) { + return storedSessionID + } + guard activeRemoteTerminalSurfaceIds.contains(panelId) else { + return nil + } + return Self.defaultSSHPTYSessionID(workspaceId: id, panelId: panelId) } nonisolated static func defaultSSHPTYSessionID(workspaceId: UUID, panelId: UUID) -> String { From 49e9ccbfef8854f9e4c5abc8d380c21cf19de9f8 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Wed, 27 May 2026 00:31:06 -0700 Subject: [PATCH 11/69] fix: preserve workspace id for SSH PTY fallback --- Sources/SessionPersistence.swift | 1 + Sources/Workspace.swift | 29 +++++++++++++++---- .../TabManagerSessionSnapshotTests.swift | 2 ++ 3 files changed, 27 insertions(+), 5 deletions(-) diff --git a/Sources/SessionPersistence.swift b/Sources/SessionPersistence.swift index 6fdb5377d292..dc92e3379f0e 100644 --- a/Sources/SessionPersistence.swift +++ b/Sources/SessionPersistence.swift @@ -1623,6 +1623,7 @@ indirect enum SessionWorkspaceLayoutSnapshot: Codable, Sendable { } struct SessionWorkspaceSnapshot: Codable, Sendable { + var id: UUID? = nil var processTitle: String var customTitle: String? var customDescription: String? diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index bc28fad5e4d0..96c2fcbab99d 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -229,6 +229,7 @@ extension Workspace { let workspaceNotificationSnapshots = notificationSnapshots(surfaceId: nil) return SessionWorkspaceSnapshot( + id: id, processTitle: processTitle, customTitle: customTitle, customDescription: customDescription, @@ -296,6 +297,7 @@ extension Workspace { entry.paneId, snapshot: entry.snapshot, panelSnapshotsById: panelSnapshotsById, + snapshotWorkspaceId: snapshot.id, oldToNewPanelIds: &oldToNewPanelIds ) } @@ -725,7 +727,11 @@ extension Workspace { @discardableResult private func restoreClosedPanel(_ entry: ClosedPanelHistoryEntry, inPane pane: PaneID) -> UUID? { - guard let panelId = createPanel(from: entry.snapshot, inPane: pane) else { return nil } + guard let panelId = createPanel( + from: entry.snapshot, + inPane: pane, + snapshotWorkspaceId: nil + ) else { return nil } let maxIndex = max(0, bonsplitController.tabs(inPane: pane).count - 1) _ = reorderSurface(panelId: panelId, toIndex: min(max(entry.tabIndex, 0), maxIndex)) @@ -759,7 +765,11 @@ extension Workspace { return nil } - guard let panelId = createPanel(from: entry.snapshot, inPane: pane) else { + guard let panelId = createPanel( + from: entry.snapshot, + inPane: pane, + snapshotWorkspaceId: nil + ) else { _ = closePanel(placeholderPanel.id, force: true) return nil } @@ -1090,6 +1100,7 @@ extension Workspace { _ paneId: PaneID, snapshot: SessionPaneLayoutSnapshot, panelSnapshotsById: [UUID: SessionPanelSnapshot], + snapshotWorkspaceId: UUID?, oldToNewPanelIds: inout [UUID: UUID] ) { let existingPanelIds = bonsplitController @@ -1100,7 +1111,11 @@ extension Workspace { var createdPanelIds: [UUID] = [] for oldPanelId in desiredOldPanelIds { guard let panelSnapshot = panelSnapshotsById[oldPanelId] else { continue } - guard let createdPanelId = createPanel(from: panelSnapshot, inPane: paneId) else { continue } + guard let createdPanelId = createPanel( + from: panelSnapshot, + inPane: paneId, + snapshotWorkspaceId: snapshotWorkspaceId + ) else { continue } createdPanelIds.append(createdPanelId) oldToNewPanelIds[oldPanelId] = createdPanelId } @@ -1171,7 +1186,11 @@ extension Workspace { return storedBinding } - private func createPanel(from snapshot: SessionPanelSnapshot, inPane paneId: PaneID) -> UUID? { + private func createPanel( + from snapshot: SessionPanelSnapshot, + inPane paneId: PaneID, + snapshotWorkspaceId: UUID? + ) -> UUID? { switch snapshot.type { case .terminal: let resumeBinding = snapshot.terminal?.resumeBinding @@ -1245,7 +1264,7 @@ extension Workspace { return nil } return normalizedRemotePTYSessionID(snapshot.terminal?.remotePTYSessionID) - ?? Self.defaultSSHPTYSessionID(workspaceId: id, panelId: snapshot.id) + ?? Self.defaultSSHPTYSessionID(workspaceId: snapshotWorkspaceId ?? id, panelId: snapshot.id) }() let restoredRemotePTYAttachCommand = restoredRemotePTYSessionID.map { remotePTYAttachStartupCommand(sessionID: $0) diff --git a/cmuxTests/TabManagerSessionSnapshotTests.swift b/cmuxTests/TabManagerSessionSnapshotTests.swift index b427caf4a032..30515b2eaa96 100644 --- a/cmuxTests/TabManagerSessionSnapshotTests.swift +++ b/cmuxTests/TabManagerSessionSnapshotTests.swift @@ -1867,6 +1867,7 @@ final class TabManagerSessionSnapshotTests: XCTestCase { let workspaceIndex = try XCTUnwrap( legacySnapshot.workspaces.firstIndex { $0.customTitle == "Legacy Persistent SSH" } ) + XCTAssertEqual(legacySnapshot.workspaces[workspaceIndex].id, remoteWorkspace.id) let panelIndex = try XCTUnwrap( legacySnapshot.workspaces[workspaceIndex].panels.firstIndex { $0.id == originalPanelId } ) @@ -1876,6 +1877,7 @@ final class TabManagerSessionSnapshotTests: XCTestCase { restored.restoreSessionSnapshot(legacySnapshot) 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() From 441e40bb5c6198cbce0fa177912c07b0cda6aa0f Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Wed, 27 May 2026 00:35:17 -0700 Subject: [PATCH 12/69] fix: preserve SSH options in reconnect restore --- Sources/WorkspaceRemoteConfiguration.swift | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/Sources/WorkspaceRemoteConfiguration.swift b/Sources/WorkspaceRemoteConfiguration.swift index ca6fd42416b6..c71bc6b8b324 100644 --- a/Sources/WorkspaceRemoteConfiguration.swift +++ b/Sources/WorkspaceRemoteConfiguration.swift @@ -89,8 +89,8 @@ nonisolated struct SessionRemoteWorkspaceSnapshot: Codable, Equatable, Sendable var sshOptions: [String] var preserveAfterTerminalExit: Bool? var skipDaemonBootstrap: Bool? - var relayPort: Int? - var persistentDaemonSlot: String? + var relayPort: Int? = nil + var persistentDaemonSlot: String? = nil } struct WorkspaceRemoteWebSocketDaemonEndpoint: Equatable { @@ -473,7 +473,8 @@ extension SessionRemoteWorkspaceSnapshot { ) : sshReconnectCommand( destination: normalizedDestination, - port: normalizedPort + port: normalizedPort, + sshOptions: restoredSSHOptions ), foregroundAuthToken: foregroundAuthToken, daemonWebSocketEndpoint: nil, @@ -497,7 +498,8 @@ extension SessionRemoteWorkspaceSnapshot { private func sshReconnectCommand( destination normalizedDestination: String, - port normalizedPort: Int? + port normalizedPort: Int?, + sshOptions reconnectSSHOptions: [String]? = nil ) -> String? { var arguments = ["ssh"] if let normalizedPort { @@ -506,7 +508,7 @@ extension SessionRemoteWorkspaceSnapshot { if let identityFile = Self.normalizedIdentityPath(identityFile) { arguments += ["-i", identityFile] } - let normalizedOptions = Self.normalizedSSHOptions(sshOptions) + let normalizedOptions = reconnectSSHOptions ?? Self.normalizedSSHOptions(sshOptions) for option in normalizedOptions { arguments += ["-o", option] } From 69714d290b91c4aa78314bda1135a0f4b6131d38 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Wed, 27 May 2026 00:56:41 -0700 Subject: [PATCH 13/69] fix: remap restored SSH relay context --- Sources/Workspace.swift | 207 +++++++++++++++++- .../TabManagerSessionSnapshotTests.swift | 73 ++++++ daemon/remote/cmd/cmuxd-remote/main.go | 12 +- daemon/remote/cmd/cmuxd-remote/main_test.go | 43 ++++ 4 files changed, 329 insertions(+), 6 deletions(-) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 96c2fcbab99d..c90c56a8d8d4 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -1322,6 +1322,13 @@ extension Workspace { ) else { return nil } + if restoredRemotePTYSessionID != nil { + registerRemoteRelayIDAliases( + snapshotWorkspaceId: snapshotWorkspaceId, + snapshotPanelId: snapshot.id, + restoredPanelId: terminalPanel.id + ) + } if let resumeBinding { surfaceResumeBindingsByPanelId[terminalPanel.id] = resumeBinding } else { @@ -4466,6 +4473,7 @@ private final class WorkspaceRemoteCLIRelayServer { private let localSocketPath: String private let relayID: String private let relayToken: Data + private let commandRewriter: (Data) -> Data private let queue: DispatchQueue private let onClose: () -> Void private let challengeProtocol = "cmux-relay-auth" @@ -4484,6 +4492,7 @@ private final class WorkspaceRemoteCLIRelayServer { localSocketPath: String, relayID: String, relayToken: Data, + commandRewriter: @escaping (Data) -> Data, queue: DispatchQueue, onClose: @escaping () -> Void ) { @@ -4491,6 +4500,7 @@ private final class WorkspaceRemoteCLIRelayServer { self.localSocketPath = localSocketPath self.relayID = relayID self.relayToken = relayToken + self.commandRewriter = commandRewriter self.queue = queue self.onClose = onClose } @@ -4611,8 +4621,11 @@ private final class WorkspaceRemoteCLIRelayServer { return } phase = .forwarding - DispatchQueue.global(qos: .utility).async { [localSocketPath, commandLine, queue] in - let result = Result { try Self.roundTripUnixSocket(socketPath: localSocketPath, request: commandLine) } + let forwardedCommandLine = commandRewriter(commandLine) + DispatchQueue.global(qos: .utility).async { [localSocketPath, forwardedCommandLine, queue] in + let result = Result { + try Self.roundTripUnixSocket(socketPath: localSocketPath, request: forwardedCommandLine) + } queue.async { [weak self] in guard let self else { return } switch result { @@ -4793,6 +4806,7 @@ private final class WorkspaceRemoteCLIRelayServer { private let localSocketPath: String private let relayID: String private let relayToken: Data + private let commandRewriter: (Data) -> Data private let queue = DispatchQueue(label: "com.cmux.remote-ssh.cli-relay.\(UUID().uuidString)", qos: .utility) private var listener: NWListener? @@ -4800,7 +4814,12 @@ private final class WorkspaceRemoteCLIRelayServer { private var isStopped = false private(set) var localPort: Int? - init(localSocketPath: String, relayID: String, relayTokenHex: String) throws { + init( + localSocketPath: String, + relayID: String, + relayTokenHex: String, + commandRewriter: @escaping (Data) -> Data = { $0 } + ) throws { guard let relayToken = Session.hexData(from: relayTokenHex), !relayToken.isEmpty else { throw NSError(domain: "cmux.remote.relay", code: 7, userInfo: [ NSLocalizedDescriptionKey: "invalid relay token", @@ -4809,6 +4828,7 @@ private final class WorkspaceRemoteCLIRelayServer { self.localSocketPath = localSocketPath self.relayID = relayID self.relayToken = relayToken + self.commandRewriter = commandRewriter } func start() throws -> Int { @@ -4915,6 +4935,7 @@ private final class WorkspaceRemoteCLIRelayServer { localSocketPath: localSocketPath, relayID: relayID, relayToken: relayToken, + commandRewriter: commandRewriter, queue: queue ) { [weak self] in self?.sessions.removeValue(forKey: sessionID) @@ -7188,12 +7209,29 @@ final class WorkspaceRemoteSessionController { let relayServer = try WorkspaceRemoteCLIRelayServer( localSocketPath: localSocketPath, relayID: relayID, - relayTokenHex: relayToken + relayTokenHex: relayToken, + commandRewriter: { [weak self] commandLine in + self?.rewriteRemoteRelayCommandLine(commandLine) ?? commandLine + } ) cliRelayServer = relayServer return relayServer } + private func rewriteRemoteRelayCommandLine(_ commandLine: Data) -> Data { + guard let workspace else { return commandLine } + if Thread.isMainThread { + return MainActor.assumeIsolated { + workspace.rewriteRemoteRelayCommandLine(commandLine) + } + } + return DispatchQueue.main.sync { + MainActor.assumeIsolated { + workspace.rewriteRemoteRelayCommandLine(commandLine) + } + } + } + private func installRemoteRelayMetadataLocked( remotePath: String, relayPort: Int, @@ -9527,6 +9565,8 @@ final class Workspace: Identifiable, ObservableObject { private var remoteDetectedSurfaceIds: Set = [] private var activeRemoteTerminalSurfaceIds: Set = [] private var remotePTYSessionIDsByPanelId: [UUID: String] = [:] + private var remoteRelayWorkspaceIDAliases: [UUID: UUID] = [:] + private var remoteRelaySurfaceIDAliases: [UUID: UUID] = [:] var pendingRemoteTerminalChildExitSurfaceIds: Set = [] /// Display target of the remote workspace that just disconnected. Set right before /// `createReplacementTerminalPanel()` so the replacement shell can print a banner @@ -11616,6 +11656,7 @@ final class Workspace: Identifiable, ObservableObject { surfaceListeningPorts = surfaceListeningPorts.filter { validSurfaceIds.contains($0.key) } surfaceTTYNames = surfaceTTYNames.filter { validSurfaceIds.contains($0.key) } remotePTYSessionIDsByPanelId = remotePTYSessionIDsByPanelId.filter { validSurfaceIds.contains($0.key) } + remoteRelaySurfaceIDAliases = remoteRelaySurfaceIDAliases.filter { validSurfaceIds.contains($0.value) } remoteDetectedSurfaceIds = remoteDetectedSurfaceIds.filter { validSurfaceIds.contains($0) } panelShellActivityStates = panelShellActivityStates.filter { validSurfaceIds.contains($0.key) } panelPullRequests = panelPullRequests.filter { validSurfaceIds.contains($0.key) } @@ -12058,6 +12099,8 @@ final class Workspace: Identifiable, ObservableObject { skipControlMasterCleanupAfterDetachedRemoteTransfer = false if previousConfiguration != nil, previousConfiguration != configuration { remotePTYSessionIDsByPanelId.removeAll() + remoteRelayWorkspaceIDAliases.removeAll() + remoteRelaySurfaceIDAliases.removeAll() } remoteConfiguration = configuration seedInitialRemoteTerminalSessionIfNeeded(configuration: configuration) @@ -12160,6 +12203,8 @@ final class Workspace: Identifiable, ObservableObject { pendingRemoteForegroundAuthToken = nil activeRemoteTerminalSurfaceIds.removeAll() remotePTYSessionIDsByPanelId.removeAll() + remoteRelayWorkspaceIDAliases.removeAll() + remoteRelaySurfaceIDAliases.removeAll() activeRemoteTerminalSessionCount = 0 pendingRemoteSurfaceTTYName = nil pendingRemoteSurfaceTTYSurfaceId = nil @@ -12241,6 +12286,153 @@ final class Workspace: Identifiable, ObservableObject { return trimmed } + private static let remoteRelayWorkspaceIDKeys: Set = [ + "workspace_id", + "preferred_workspace_id", + "selected_workspace_id", + "before_workspace_id", + "after_workspace_id", + "from_workspace_id", + "to_workspace_id", + ] + + private static let remoteRelaySurfaceIDKeys: Set = [ + "surface_id", + "preferred_surface_id", + "target_surface_id", + "created_surface_id", + ] + + private static let remoteRelayAmbiguousIDKeys: Set = [ + "tab_id", + ] + + private static let remoteRelayWorkspaceIDArrayKeys: Set = [ + "workspace_ids", + ] + + private static let remoteRelaySurfaceIDArrayKeys: Set = [ + "surface_ids", + ] + + private func registerRemoteRelayIDAliases( + snapshotWorkspaceId: UUID?, + snapshotPanelId: UUID, + restoredPanelId: UUID + ) { + if let snapshotWorkspaceId, snapshotWorkspaceId != id { + remoteRelayWorkspaceIDAliases[snapshotWorkspaceId] = id + } + if snapshotPanelId != restoredPanelId { + remoteRelaySurfaceIDAliases[snapshotPanelId] = restoredPanelId + } + } + + func rewriteRemoteRelayCommandLine(_ commandLine: Data) -> Data { + guard !remoteRelayWorkspaceIDAliases.isEmpty || !remoteRelaySurfaceIDAliases.isEmpty, + let line = String(data: commandLine, encoding: .utf8) else { + return commandLine + } + let trimmedLine = line.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmedLine.hasPrefix("{"), + let requestData = trimmedLine.data(using: .utf8), + var request = try? JSONSerialization.jsonObject(with: requestData) as? [String: Any] else { + return commandLine + } + + var didRewrite = false + if let params = request["params"] as? [String: Any] { + request["params"] = Self.remappedRemoteRelayValue( + params, + key: nil, + workspaceAliases: remoteRelayWorkspaceIDAliases, + surfaceAliases: remoteRelaySurfaceIDAliases, + didRewrite: &didRewrite + ) + } + + guard didRewrite, + JSONSerialization.isValidJSONObject(request), + let rewritten = try? JSONSerialization.data(withJSONObject: request, options: []) else { + return commandLine + } + return rewritten + Data([0x0A]) + } + + private static func remappedRemoteRelayValue( + _ value: Any, + key: String?, + workspaceAliases: [UUID: UUID], + surfaceAliases: [UUID: UUID], + didRewrite: inout Bool + ) -> Any { + if let dictionary = value as? [String: Any] { + var result = dictionary + for (childKey, childValue) in dictionary { + result[childKey] = remappedRemoteRelayValue( + childValue, + key: childKey, + workspaceAliases: workspaceAliases, + surfaceAliases: surfaceAliases, + didRewrite: &didRewrite + ) + } + return result + } + + if let array = value as? [Any] { + let elementKey: String? + if let key, remoteRelayWorkspaceIDArrayKeys.contains(key) { + elementKey = "workspace_id" + } else if let key, remoteRelaySurfaceIDArrayKeys.contains(key) { + elementKey = "surface_id" + } else { + elementKey = nil + } + return array.map { + remappedRemoteRelayValue( + $0, + key: elementKey, + workspaceAliases: workspaceAliases, + surfaceAliases: surfaceAliases, + didRewrite: &didRewrite + ) + } + } + + guard let key, let id = value as? String else { + return value + } + + let trimmedID = id.trimmingCharacters(in: .whitespacesAndNewlines) + guard let uuid = UUID(uuidString: trimmedID) else { + return value + } + + if remoteRelaySurfaceIDKeys.contains(key), + let mapped = surfaceAliases[uuid] { + didRewrite = true + return mapped.uuidString + } + if remoteRelayWorkspaceIDKeys.contains(key), + let mapped = workspaceAliases[uuid] { + didRewrite = true + return mapped.uuidString + } + if remoteRelayAmbiguousIDKeys.contains(key) { + if let mapped = surfaceAliases[uuid] { + didRewrite = true + return mapped.uuidString + } + if let mapped = workspaceAliases[uuid] { + didRewrite = true + return mapped.uuidString + } + } + + return value + } + private func remotePTYSessionIDForSnapshot(panelId: UUID) -> String? { guard remoteConfiguration?.preserveAfterTerminalExit == true else { return nil @@ -12283,6 +12475,7 @@ final class Workspace: Identifiable, ObservableObject { func discardRemotePTYSessionID(panelId: UUID) { remotePTYSessionIDsByPanelId.removeValue(forKey: panelId) + remoteRelaySurfaceIDAliases = remoteRelaySurfaceIDAliases.filter { $0.value != panelId } } func remotePTYSessionIDMatches(panelId: UUID, sessionID: String?) -> Bool { @@ -12306,6 +12499,7 @@ final class Workspace: Identifiable, ObservableObject { let wasTracked = activeRemoteTerminalSurfaceIds.contains(surfaceId) remotePTYSessionIDsByPanelId.removeValue(forKey: surfaceId) + remoteRelaySurfaceIDAliases = remoteRelaySurfaceIDAliases.filter { $0.value != surfaceId } untrackRemoteTerminalSurface(surfaceId) return (true, wasTracked) } @@ -14432,6 +14626,11 @@ final class Workspace: Identifiable, ObservableObject { remotePTYSessionIDsByPanelId.removeValue(forKey: detached.panelId) } if didAdoptWorkspaceRemoteTracking { + registerRemoteRelayIDAliases( + snapshotWorkspaceId: detached.sourceWorkspaceId, + snapshotPanelId: detached.panelId, + restoredPanelId: detached.panelId + ) trackRemoteTerminalSurface(detached.panelId) } if let cleanupConfiguration = detached.remoteCleanupConfiguration { diff --git a/cmuxTests/TabManagerSessionSnapshotTests.swift b/cmuxTests/TabManagerSessionSnapshotTests.swift index 30515b2eaa96..51f54bd42b87 100644 --- a/cmuxTests/TabManagerSessionSnapshotTests.swift +++ b/cmuxTests/TabManagerSessionSnapshotTests.swift @@ -1835,6 +1835,79 @@ final class TabManagerSessionSnapshotTests: XCTestCase { ) } + func testPersistentSSHPTYRestoreRewritesStaleRemoteRelayContextIDs() throws { + 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 restored = TabManager() + restored.restoreSessionSnapshot(snapshot) + + 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, + "tab_id": originalPanelId.uuidString, + "workspace_ids": [originalWorkspaceId.uuidString], + "surface_ids": [originalPanelId.uuidString], + "session_id": sessionID, + "caller": [ + "workspace_id": originalWorkspaceId.uuidString, + "surface_id": originalPanelId.uuidString, + "tab_id": originalWorkspaceId.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["tab_id"] as? String, restoredPanelId.uuidString) + XCTAssertEqual(params["workspace_ids"] as? [String], [restoredWorkspace.id.uuidString]) + XCTAssertEqual(params["surface_ids"] as? [String], [restoredPanelId.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["tab_id"] as? String, restoredWorkspace.id.uuidString) + } + func testPersistentSSHPTYRestoreFallsBackToSnapshotPanelDefaultSessionID() throws { let manager = TabManager() let remoteWorkspace = manager.addWorkspace(select: true) diff --git a/daemon/remote/cmd/cmuxd-remote/main.go b/daemon/remote/cmd/cmuxd-remote/main.go index b75b10f26247..7f78675f0ce8 100644 --- a/daemon/remote/cmd/cmuxd-remote/main.go +++ b/daemon/remote/cmd/cmuxd-remote/main.go @@ -337,14 +337,22 @@ func persistentDaemonPathsForSlot(rawSlot string) (persistentDaemonPaths, error) } func persistentDaemonSocketPath(root string, slot string) string { - socketBase := strings.TrimSpace(os.Getenv("CMUX_REMOTE_DAEMON_SOCKET_DIR")) - if socketBase == "" { + socketBase, overrideSet := persistentDaemonSocketBase() + if !overrideSet { socketBase = filepath.Join("/tmp", fmt.Sprintf("cmuxd-remote-%d", os.Getuid())) } digest := sha256.Sum256([]byte(root + "\x00" + slot)) return filepath.Join(socketBase, "cmuxd-"+hex.EncodeToString(digest[:8])+".sock") } +func persistentDaemonSocketBase() (string, bool) { + socketBase := strings.TrimSpace(os.Getenv("CMUX_REMOTE_DAEMON_SOCKET_DIR")) + if socketBase == "" { + return "", false + } + return filepath.Join(socketBase, fmt.Sprintf("cmuxd-remote-%d", os.Getuid())), true +} + func validatePersistentDaemonSlot(rawSlot string) (string, error) { slot := strings.TrimSpace(rawSlot) if slot == "" { diff --git a/daemon/remote/cmd/cmuxd-remote/main_test.go b/daemon/remote/cmd/cmuxd-remote/main_test.go index 16c56354f240..bc757585955e 100644 --- a/daemon/remote/cmd/cmuxd-remote/main_test.go +++ b/daemon/remote/cmd/cmuxd-remote/main_test.go @@ -335,6 +335,49 @@ func TestPersistentDaemonPathsUseShortSocketPath(t *testing.T) { } } +func TestPersistentDaemonSocketDirOverrideUsesPrivateChild(t *testing.T) { + rootBase := filepath.Join(t.TempDir(), "daemon-root") + socketParent := filepath.Join(t.TempDir(), "caller-socket-dir") + if err := os.MkdirAll(socketParent, 0o755); err != nil { + t.Fatalf("create socket parent: %v", err) + } + if err := os.Chmod(socketParent, 0o755); err != nil { + t.Fatalf("chmod socket parent: %v", err) + } + t.Setenv("CMUX_REMOTE_DAEMON_ROOT", rootBase) + t.Setenv("CMUX_REMOTE_DAEMON_SOCKET_DIR", socketParent) + + paths, err := persistentDaemonPathsForSlot("override-slot") + if err != nil { + t.Fatalf("persistentDaemonPathsForSlot returned error: %v", err) + } + socketDir := filepath.Dir(paths.socket) + if socketDir == socketParent { + t.Fatalf("socket dir should be a private child, got parent %q", socketParent) + } + if filepath.Dir(socketDir) != socketParent { + t.Fatalf("socket dir parent = %q, want %q", filepath.Dir(socketDir), socketParent) + } + + if err := ensurePersistentDaemonDirectory(paths); err != nil { + t.Fatalf("ensurePersistentDaemonDirectory returned error: %v", err) + } + parentInfo, err := os.Stat(socketParent) + if err != nil { + t.Fatalf("stat socket parent: %v", err) + } + if parentInfo.Mode().Perm() != 0o755 { + t.Fatalf("socket parent mode = %o, want 755", parentInfo.Mode().Perm()) + } + childInfo, err := os.Stat(socketDir) + if err != nil { + t.Fatalf("stat socket child: %v", err) + } + if childInfo.Mode().Perm() != 0o700 { + t.Fatalf("socket child mode = %o, want 700", childInfo.Mode().Perm()) + } +} + func TestPersistentDaemonTokenConcurrentCreate(t *testing.T) { root := t.TempDir() paths := persistentDaemonPaths{ From 81d464af292486ef3465524065f320cbfce8d8c6 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Wed, 27 May 2026 01:02:50 -0700 Subject: [PATCH 14/69] fix: use unique daemon token temp files --- daemon/remote/cmd/cmuxd-remote/main.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/daemon/remote/cmd/cmuxd-remote/main.go b/daemon/remote/cmd/cmuxd-remote/main.go index 7f78675f0ce8..c6878fab9fbd 100644 --- a/daemon/remote/cmd/cmuxd-remote/main.go +++ b/daemon/remote/cmd/cmuxd-remote/main.go @@ -402,11 +402,11 @@ func persistentDaemonToken(paths persistentDaemonPaths) (string, error) { } token := hex.EncodeToString(raw) - tmpPath := filepath.Join(filepath.Dir(paths.tokenFile), fmt.Sprintf(".auth.token.%d.%d.tmp", os.Getpid(), time.Now().UnixNano())) - file, err := os.OpenFile(tmpPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + file, err := os.CreateTemp(filepath.Dir(paths.tokenFile), ".auth.token.*.tmp") if err != nil { return "", err } + tmpPath := file.Name() closeOK := false defer func() { if !closeOK { From ca45ff5a21ff92aa33891b279baf14f23e7bbaaf Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Wed, 27 May 2026 01:31:42 -0700 Subject: [PATCH 15/69] fix: address ssh pty review findings --- Sources/Workspace.swift | 164 ++++++++++++++---- Sources/WorkspaceRemoteConfiguration.swift | 5 +- .../TabManagerSessionSnapshotTests.swift | 75 ++++++++ cmuxTests/WorkspaceUnitTests.swift | 2 +- daemon/remote/cmd/cmuxd-remote/main.go | 31 +++- daemon/remote/cmd/cmuxd-remote/main_test.go | 32 ++++ 6 files changed, 269 insertions(+), 40 deletions(-) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index c90c56a8d8d4..8cdc98ff2cc2 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -5623,6 +5623,31 @@ final class WorkspaceRemotePTYBridgeServer { } } +private final class WorkspaceRemoteRelayAliasStore { + private let lock = NSLock() + private var workspaceAliases: [UUID: UUID] = [:] + private var surfaceAliases: [UUID: UUID] = [:] + + func update(workspaceAliases: [UUID: UUID], surfaceAliases: [UUID: UUID]) { + lock.lock() + self.workspaceAliases = workspaceAliases + self.surfaceAliases = surfaceAliases + lock.unlock() + } + + func rewrite(_ commandLine: Data) -> Data { + lock.lock() + let workspaceAliases = self.workspaceAliases + let surfaceAliases = self.surfaceAliases + lock.unlock() + return Workspace.rewriteRemoteRelayCommandLine( + commandLine, + workspaceAliases: workspaceAliases, + surfaceAliases: surfaceAliases + ) + } +} + final class WorkspaceRemoteSessionController { #if DEBUG // XCTest seam: tests assign this before starting a controller and clear it @@ -5718,6 +5743,7 @@ final class WorkspaceRemoteSessionController { private weak var workspace: Workspace? private let configuration: WorkspaceRemoteConfiguration private let controllerID: UUID + private let remoteRelayAliasStore = WorkspaceRemoteRelayAliasStore() private enum RemotePortPollingMode { case hostWide @@ -5818,6 +5844,10 @@ final class WorkspaceRemoteSessionController { } } + func updateRemoteRelayIDAliases(workspaceAliases: [UUID: UUID], surfaceAliases: [UUID: UUID]) { + remoteRelayAliasStore.update(workspaceAliases: workspaceAliases, surfaceAliases: surfaceAliases) + } + func listPTYSessions(timeout: TimeInterval = 8.0) throws -> [[String: Any]] { try runOnControllerQueue(timeout: timeout) { guard self.daemonReady, self.proxyLease != nil else { @@ -7206,32 +7236,19 @@ final class WorkspaceRemoteSessionController { if let cliRelayServer { return cliRelayServer } + let aliasStore = remoteRelayAliasStore let relayServer = try WorkspaceRemoteCLIRelayServer( localSocketPath: localSocketPath, relayID: relayID, relayTokenHex: relayToken, - commandRewriter: { [weak self] commandLine in - self?.rewriteRemoteRelayCommandLine(commandLine) ?? commandLine + commandRewriter: { commandLine in + aliasStore.rewrite(commandLine) } ) cliRelayServer = relayServer return relayServer } - private func rewriteRemoteRelayCommandLine(_ commandLine: Data) -> Data { - guard let workspace else { return commandLine } - if Thread.isMainThread { - return MainActor.assumeIsolated { - workspace.rewriteRemoteRelayCommandLine(commandLine) - } - } - return DispatchQueue.main.sync { - MainActor.assumeIsolated { - workspace.rewriteRemoteRelayCommandLine(commandLine) - } - } - } - private func installRemoteRelayMetadataLocked( remotePath: String, relayPort: Int, @@ -11656,7 +11673,7 @@ final class Workspace: Identifiable, ObservableObject { surfaceListeningPorts = surfaceListeningPorts.filter { validSurfaceIds.contains($0.key) } surfaceTTYNames = surfaceTTYNames.filter { validSurfaceIds.contains($0.key) } remotePTYSessionIDsByPanelId = remotePTYSessionIDsByPanelId.filter { validSurfaceIds.contains($0.key) } - remoteRelaySurfaceIDAliases = remoteRelaySurfaceIDAliases.filter { validSurfaceIds.contains($0.value) } + pruneRemoteRelaySurfaceAliases(validSurfaceIds: validSurfaceIds) remoteDetectedSurfaceIds = remoteDetectedSurfaceIds.filter { validSurfaceIds.contains($0) } panelShellActivityStates = panelShellActivityStates.filter { validSurfaceIds.contains($0.key) } panelPullRequests = panelPullRequests.filter { validSurfaceIds.contains($0.key) } @@ -12099,8 +12116,7 @@ final class Workspace: Identifiable, ObservableObject { skipControlMasterCleanupAfterDetachedRemoteTransfer = false if previousConfiguration != nil, previousConfiguration != configuration { remotePTYSessionIDsByPanelId.removeAll() - remoteRelayWorkspaceIDAliases.removeAll() - remoteRelaySurfaceIDAliases.removeAll() + clearRemoteRelayIDAliases() } remoteConfiguration = configuration seedInitialRemoteTerminalSessionIfNeeded(configuration: configuration) @@ -12155,6 +12171,7 @@ final class Workspace: Identifiable, ObservableObject { activeRemoteSessionControllerID = controllerID remoteSessionController = controller syncRemotePortScanTTYs() + syncRemoteRelayIDAliasesToController() controller.start() } @@ -12203,8 +12220,7 @@ final class Workspace: Identifiable, ObservableObject { pendingRemoteForegroundAuthToken = nil activeRemoteTerminalSurfaceIds.removeAll() remotePTYSessionIDsByPanelId.removeAll() - remoteRelayWorkspaceIDAliases.removeAll() - remoteRelaySurfaceIDAliases.removeAll() + clearRemoteRelayIDAliases() activeRemoteTerminalSessionCount = 0 pendingRemoteSurfaceTTYName = nil pendingRemoteSurfaceTTYSurfaceId = nil @@ -12286,7 +12302,7 @@ final class Workspace: Identifiable, ObservableObject { return trimmed } - private static let remoteRelayWorkspaceIDKeys: Set = [ + private nonisolated static let remoteRelayWorkspaceIDKeys: Set = [ "workspace_id", "preferred_workspace_id", "selected_workspace_id", @@ -12296,40 +12312,99 @@ final class Workspace: Identifiable, ObservableObject { "to_workspace_id", ] - private static let remoteRelaySurfaceIDKeys: Set = [ + private nonisolated static let remoteRelaySurfaceIDKeys: Set = [ "surface_id", "preferred_surface_id", "target_surface_id", "created_surface_id", ] - private static let remoteRelayAmbiguousIDKeys: Set = [ + private nonisolated static let remoteRelayAmbiguousIDKeys: Set = [ "tab_id", ] - private static let remoteRelayWorkspaceIDArrayKeys: Set = [ + private nonisolated static let remoteRelayWorkspaceIDArrayKeys: Set = [ "workspace_ids", ] - private static let remoteRelaySurfaceIDArrayKeys: Set = [ + private nonisolated static let remoteRelaySurfaceIDArrayKeys: Set = [ "surface_ids", ] + private func syncRemoteRelayIDAliasesToController() { + remoteSessionController?.updateRemoteRelayIDAliases( + workspaceAliases: remoteRelayWorkspaceIDAliases, + surfaceAliases: remoteRelaySurfaceIDAliases + ) + } + + private func clearRemoteRelayIDAliases() { + guard !remoteRelayWorkspaceIDAliases.isEmpty || !remoteRelaySurfaceIDAliases.isEmpty else { return } + remoteRelayWorkspaceIDAliases.removeAll() + remoteRelaySurfaceIDAliases.removeAll() + syncRemoteRelayIDAliasesToController() + } + + private func pruneRemoteRelaySurfaceAliases(validSurfaceIds: Set) { + let nextAliases = remoteRelaySurfaceIDAliases.filter { validSurfaceIds.contains($0.value) } + guard nextAliases != remoteRelaySurfaceIDAliases else { return } + remoteRelaySurfaceIDAliases = nextAliases + syncRemoteRelayIDAliasesToController() + } + + private func removeRemoteRelaySurfaceAliases(targeting panelId: UUID) { + let nextAliases = remoteRelaySurfaceIDAliases.filter { $0.value != panelId } + guard nextAliases != remoteRelaySurfaceIDAliases else { return } + remoteRelaySurfaceIDAliases = nextAliases + syncRemoteRelayIDAliasesToController() + } + private func registerRemoteRelayIDAliases( snapshotWorkspaceId: UUID?, snapshotPanelId: UUID, restoredPanelId: UUID ) { + var didMutate = false if let snapshotWorkspaceId, snapshotWorkspaceId != id { - remoteRelayWorkspaceIDAliases[snapshotWorkspaceId] = id + if remoteRelayWorkspaceIDAliases[snapshotWorkspaceId] != id { + remoteRelayWorkspaceIDAliases[snapshotWorkspaceId] = id + didMutate = true + } } if snapshotPanelId != restoredPanelId { - remoteRelaySurfaceIDAliases[snapshotPanelId] = restoredPanelId + if remoteRelaySurfaceIDAliases[snapshotPanelId] != restoredPanelId { + remoteRelaySurfaceIDAliases[snapshotPanelId] = restoredPanelId + didMutate = true + } + } + if didMutate { + syncRemoteRelayIDAliasesToController() } } + private func registerRemoteRelayIDAliases(remotePTYSessionID: String, restoredPanelId: UUID) { + guard let parsed = Self.parsedDefaultSSHPTYSessionID(remotePTYSessionID) else { return } + registerRemoteRelayIDAliases( + snapshotWorkspaceId: parsed.workspaceId, + snapshotPanelId: parsed.panelId, + restoredPanelId: restoredPanelId + ) + } + func rewriteRemoteRelayCommandLine(_ commandLine: Data) -> Data { - guard !remoteRelayWorkspaceIDAliases.isEmpty || !remoteRelaySurfaceIDAliases.isEmpty, + Self.rewriteRemoteRelayCommandLine( + commandLine, + workspaceAliases: remoteRelayWorkspaceIDAliases, + surfaceAliases: remoteRelaySurfaceIDAliases + ) + } + + nonisolated static func rewriteRemoteRelayCommandLine( + _ commandLine: Data, + workspaceAliases: [UUID: UUID], + surfaceAliases: [UUID: UUID] + ) -> Data { + guard !workspaceAliases.isEmpty || !surfaceAliases.isEmpty, let line = String(data: commandLine, encoding: .utf8) else { return commandLine } @@ -12345,8 +12420,8 @@ final class Workspace: Identifiable, ObservableObject { request["params"] = Self.remappedRemoteRelayValue( params, key: nil, - workspaceAliases: remoteRelayWorkspaceIDAliases, - surfaceAliases: remoteRelaySurfaceIDAliases, + workspaceAliases: workspaceAliases, + surfaceAliases: surfaceAliases, didRewrite: &didRewrite ) } @@ -12359,7 +12434,7 @@ final class Workspace: Identifiable, ObservableObject { return rewritten + Data([0x0A]) } - private static func remappedRemoteRelayValue( + private nonisolated static func remappedRemoteRelayValue( _ value: Any, key: String?, workspaceAliases: [UUID: UUID], @@ -12450,6 +12525,23 @@ final class Workspace: Identifiable, ObservableObject { "ssh-\(workspaceId.uuidString)-\(panelId.uuidString)" } + private nonisolated static func parsedDefaultSSHPTYSessionID(_ value: String) -> (workspaceId: UUID, panelId: UUID)? { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.hasPrefix("ssh-") else { return nil } + let suffix = String(trimmed.dropFirst(4)) + guard suffix.count == 73 else { return nil } + let separatorIndex = suffix.index(suffix.startIndex, offsetBy: 36) + guard suffix[separatorIndex] == "-" else { return nil } + let panelStart = suffix.index(after: separatorIndex) + let workspacePart = String(suffix[.. String { SSHPTYAttachStartupCommandBuilder.command(sessionID: sessionID) } @@ -12475,7 +12567,7 @@ final class Workspace: Identifiable, ObservableObject { func discardRemotePTYSessionID(panelId: UUID) { remotePTYSessionIDsByPanelId.removeValue(forKey: panelId) - remoteRelaySurfaceIDAliases = remoteRelaySurfaceIDAliases.filter { $0.value != panelId } + removeRemoteRelaySurfaceAliases(targeting: panelId) } func remotePTYSessionIDMatches(panelId: UUID, sessionID: String?) -> Bool { @@ -12499,7 +12591,7 @@ final class Workspace: Identifiable, ObservableObject { let wasTracked = activeRemoteTerminalSurfaceIds.contains(surfaceId) remotePTYSessionIDsByPanelId.removeValue(forKey: surfaceId) - remoteRelaySurfaceIDAliases = remoteRelaySurfaceIDAliases.filter { $0.value != surfaceId } + removeRemoteRelaySurfaceAliases(targeting: surfaceId) untrackRemoteTerminalSurface(surfaceId) return (true, wasTracked) } @@ -13155,6 +13247,7 @@ final class Workspace: Identifiable, ObservableObject { let tracksRemoteTerminalSurface = remoteTerminalStartupCommand != nil || normalizedRemotePTYSessionID != nil if let normalizedRemotePTYSessionID { remotePTYSessionIDsByPanelId[newPanel.id] = normalizedRemotePTYSessionID + registerRemoteRelayIDAliases(remotePTYSessionID: normalizedRemotePTYSessionID, restoredPanelId: newPanel.id) } if tracksRemoteTerminalSurface { trackRemoteTerminalSurface(newPanel.id) @@ -13191,6 +13284,7 @@ final class Workspace: Identifiable, ObservableObject { panels.removeValue(forKey: newPanel.id) panelTitles.removeValue(forKey: newPanel.id) remotePTYSessionIDsByPanelId.removeValue(forKey: newPanel.id) + removeRemoteRelaySurfaceAliases(targeting: newPanel.id) surfaceIdToPanelId.removeValue(forKey: newTab.id) if tracksRemoteTerminalSurface { untrackRemoteTerminalSurface(newPanel.id) @@ -13295,6 +13389,7 @@ final class Workspace: Identifiable, ObservableObject { let tracksRemoteTerminalSurface = remoteTerminalStartupCommand != nil || normalizedRemotePTYSessionID != nil if let normalizedRemotePTYSessionID { remotePTYSessionIDsByPanelId[newPanel.id] = normalizedRemotePTYSessionID + registerRemoteRelayIDAliases(remotePTYSessionID: normalizedRemotePTYSessionID, restoredPanelId: newPanel.id) } if tracksRemoteTerminalSurface { trackRemoteTerminalSurface(newPanel.id) @@ -13313,6 +13408,7 @@ final class Workspace: Identifiable, ObservableObject { panels.removeValue(forKey: newPanel.id) panelTitles.removeValue(forKey: newPanel.id) remotePTYSessionIDsByPanelId.removeValue(forKey: newPanel.id) + removeRemoteRelaySurfaceAliases(targeting: newPanel.id) if tracksRemoteTerminalSurface { untrackRemoteTerminalSurface(newPanel.id) } diff --git a/Sources/WorkspaceRemoteConfiguration.swift b/Sources/WorkspaceRemoteConfiguration.swift index c71bc6b8b324..a0ef7127f69a 100644 --- a/Sources/WorkspaceRemoteConfiguration.swift +++ b/Sources/WorkspaceRemoteConfiguration.swift @@ -10,16 +10,13 @@ private enum WorkspaceRemoteSSHOptionFilter { "controlpath", "controlpersist", ] - private static let relayScopedControlSocketKeys: Set = [ - "controlpath", - ] static func durableOptions(_ options: [String]) -> [String] { filteredOptions(options, droppingKeys: transientControlSocketKeys) } static func forkedWorkspaceOptions(_ options: [String]) -> [String] { - filteredOptions(options, droppingKeys: relayScopedControlSocketKeys) + filteredOptions(options, droppingKeys: transientControlSocketKeys) } static func trimmedOptions(_ options: [String]) -> [String] { diff --git a/cmuxTests/TabManagerSessionSnapshotTests.swift b/cmuxTests/TabManagerSessionSnapshotTests.swift index 51f54bd42b87..b6fb034a8675 100644 --- a/cmuxTests/TabManagerSessionSnapshotTests.swift +++ b/cmuxTests/TabManagerSessionSnapshotTests.swift @@ -1908,6 +1908,81 @@ final class TabManagerSessionSnapshotTests: XCTestCase { XCTAssertEqual(caller["tab_id"] as? String, restoredWorkspace.id.uuidString) } + func testPersistentSSHPTYReattachRewritesStaleRemoteRelayContextIDs() throws { + 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 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 reattachedPanel = try XCTUnwrap( + restoredWorkspace.newTerminalSurface( + inPane: paneId, + focus: true, + initialCommand: Workspace.sshPTYAttachStartupCommand(sessionID: sessionID), + 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, + "tab_id": originalPanelId.uuidString, + "workspace_ids": [originalWorkspaceId.uuidString], + "surface_ids": [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["tab_id"] as? String, reattachedPanel.id.uuidString) + XCTAssertEqual(params["workspace_ids"] as? [String], [restoredWorkspace.id.uuidString]) + XCTAssertEqual(params["surface_ids"] as? [String], [reattachedPanel.id.uuidString]) + XCTAssertEqual(params["session_id"] as? String, sessionID) + } + func testPersistentSSHPTYRestoreFallsBackToSnapshotPanelDefaultSessionID() throws { let manager = TabManager() let remoteWorkspace = manager.addWorkspace(select: true) diff --git a/cmuxTests/WorkspaceUnitTests.swift b/cmuxTests/WorkspaceUnitTests.swift index 5e56a6618fca..6e8d5f583dc5 100644 --- a/cmuxTests/WorkspaceUnitTests.swift +++ b/cmuxTests/WorkspaceUnitTests.swift @@ -5615,7 +5615,7 @@ final class WorkspacePanelGitBranchTests: XCTestCase { XCTAssertFalse(startupCommand.contains("ssh-pty-attach"), startupCommand) XCTAssertEqual( startupCommand, - "ssh -p 2222 -i /Users/example/.ssh/cmux -o ControlMaster=auto -o ControlPersist=600 -tt cmux-macmini" + "ssh -p 2222 -i /Users/example/.ssh/cmux -tt cmux-macmini" ) } diff --git a/daemon/remote/cmd/cmuxd-remote/main.go b/daemon/remote/cmd/cmuxd-remote/main.go index c6878fab9fbd..23154c957445 100644 --- a/daemon/remote/cmd/cmuxd-remote/main.go +++ b/daemon/remote/cmd/cmuxd-remote/main.go @@ -324,7 +324,7 @@ func persistentDaemonPathsForSlot(rawSlot string) (persistentDaemonPaths, error) } rootBase = filepath.Join(home, ".cmux", "daemon") } - root := filepath.Join(rootBase, slot) + root := filepath.Join(rootBase, persistentDaemonVersionComponent(), slot) socketPath := persistentDaemonSocketPath(root, slot) return persistentDaemonPaths{ slot: slot, @@ -336,6 +336,35 @@ func persistentDaemonPathsForSlot(rawSlot string) (persistentDaemonPaths, error) }, nil } +func persistentDaemonVersionComponent() string { + trimmed := strings.TrimSpace(version) + if trimmed == "" { + trimmed = "dev" + } + var builder strings.Builder + for _, r := range trimmed { + if (r >= 'a' && r <= 'z') || + (r >= 'A' && r <= 'Z') || + (r >= '0' && r <= '9') || + r == '-' || + r == '_' || + r == '.' { + builder.WriteRune(r) + } else { + builder.WriteByte('_') + } + } + component := builder.String() + if component == "" || component == "." || component == ".." { + return "dev" + } + if len(component) <= 64 { + return component + } + digest := sha256.Sum256([]byte(trimmed)) + return component[:48] + "-" + hex.EncodeToString(digest[:4]) +} + func persistentDaemonSocketPath(root string, slot string) string { socketBase, overrideSet := persistentDaemonSocketBase() if !overrideSet { diff --git a/daemon/remote/cmd/cmuxd-remote/main_test.go b/daemon/remote/cmd/cmuxd-remote/main_test.go index bc757585955e..9e3f6e4e9e79 100644 --- a/daemon/remote/cmd/cmuxd-remote/main_test.go +++ b/daemon/remote/cmd/cmuxd-remote/main_test.go @@ -335,6 +335,38 @@ func TestPersistentDaemonPathsUseShortSocketPath(t *testing.T) { } } +func TestPersistentDaemonPathsIncludeDaemonVersion(t *testing.T) { + rootBase := filepath.Join(t.TempDir(), "daemon-root") + t.Setenv("CMUX_REMOTE_DAEMON_ROOT", rootBase) + t.Setenv("CMUX_REMOTE_DAEMON_SOCKET_DIR", "") + oldVersion := version + defer func() { version = oldVersion }() + + version = "v1.2.3" + first, err := persistentDaemonPathsForSlot("versioned-slot") + if err != nil { + t.Fatalf("persistentDaemonPathsForSlot returned error: %v", err) + } + if !strings.Contains(first.root, string(filepath.Separator)+"v1.2.3"+string(filepath.Separator)) { + t.Fatalf("root %q should include daemon version", first.root) + } + + version = "v1.2.4" + second, err := persistentDaemonPathsForSlot("versioned-slot") + if err != nil { + t.Fatalf("persistentDaemonPathsForSlot returned error: %v", err) + } + if first.root == second.root { + t.Fatalf("root should change across versions: %q", first.root) + } + if first.socket == second.socket { + t.Fatalf("socket should change across versions: %q", first.socket) + } + if first.lockFile == second.lockFile { + t.Fatalf("lock file should change across versions: %q", first.lockFile) + } +} + func TestPersistentDaemonSocketDirOverrideUsesPrivateChild(t *testing.T) { rootBase := filepath.Join(t.TempDir(), "daemon-root") socketParent := filepath.Join(t.TempDir(), "caller-socket-dir") From 1f9ed433b0f23e6f6377a75271d47d69dc87d436 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Wed, 27 May 2026 01:43:44 -0700 Subject: [PATCH 16/69] fix: tighten ssh pty review feedback --- Sources/Workspace.swift | 32 +++++++++++-------- Sources/WorkspaceRemoteConfiguration.swift | 4 ++- .../TabManagerSessionSnapshotTests.swift | 6 ++++ daemon/remote/cmd/cmuxd-remote/main.go | 4 +++ daemon/remote/cmd/cmuxd-remote/main_test.go | 11 +++++++ 5 files changed, 42 insertions(+), 15 deletions(-) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 8cdc98ff2cc2..d2262abb9e6f 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -12475,7 +12475,7 @@ final class Workspace: Identifiable, ObservableObject { } } - guard let key, let id = value as? String else { + guard let id = value as? String else { return value } @@ -12484,25 +12484,29 @@ final class Workspace: Identifiable, ObservableObject { return value } - if remoteRelaySurfaceIDKeys.contains(key), - let mapped = surfaceAliases[uuid] { - didRewrite = true - return mapped.uuidString - } - if remoteRelayWorkspaceIDKeys.contains(key), - let mapped = workspaceAliases[uuid] { - didRewrite = true - return mapped.uuidString - } - if remoteRelayAmbiguousIDKeys.contains(key) { - if let mapped = surfaceAliases[uuid] { + if let key { + if remoteRelaySurfaceIDKeys.contains(key), + let mapped = surfaceAliases[uuid] { didRewrite = true return mapped.uuidString } - if let mapped = workspaceAliases[uuid] { + if remoteRelayWorkspaceIDKeys.contains(key), + let mapped = workspaceAliases[uuid] { didRewrite = true return mapped.uuidString } + if !remoteRelayAmbiguousIDKeys.contains(key) { + return value + } + } + + if let mapped = surfaceAliases[uuid] { + didRewrite = true + return mapped.uuidString + } + if let mapped = workspaceAliases[uuid] { + didRewrite = true + return mapped.uuidString } return value diff --git a/Sources/WorkspaceRemoteConfiguration.swift b/Sources/WorkspaceRemoteConfiguration.swift index a0ef7127f69a..92eba9e0d2eb 100644 --- a/Sources/WorkspaceRemoteConfiguration.swift +++ b/Sources/WorkspaceRemoteConfiguration.swift @@ -16,7 +16,7 @@ private enum WorkspaceRemoteSSHOptionFilter { } static func forkedWorkspaceOptions(_ options: [String]) -> [String] { - filteredOptions(options, droppingKeys: transientControlSocketKeys) + durableOptions(options) } static func trimmedOptions(_ options: [String]) -> [String] { @@ -466,6 +466,8 @@ extension SessionRemoteWorkspaceSnapshot { terminalStartupCommand: preservePTYSession ? SSHPTYAttachStartupCommandBuilder.command( foregroundAuth: foregroundAuth, + // Restored panels get explicit require-existing attach commands with their + // persisted session IDs; this workspace default is for new panes. requireExisting: false ) : sshReconnectCommand( diff --git a/cmuxTests/TabManagerSessionSnapshotTests.swift b/cmuxTests/TabManagerSessionSnapshotTests.swift index b6fb034a8675..ac828d4223ee 100644 --- a/cmuxTests/TabManagerSessionSnapshotTests.swift +++ b/cmuxTests/TabManagerSessionSnapshotTests.swift @@ -1882,6 +1882,8 @@ final class TabManagerSessionSnapshotTests: XCTestCase { "tab_id": originalPanelId.uuidString, "workspace_ids": [originalWorkspaceId.uuidString], "surface_ids": [originalPanelId.uuidString], + "context_ids": [originalWorkspaceId.uuidString, originalPanelId.uuidString], + "context_id_groups": [[originalWorkspaceId.uuidString, originalPanelId.uuidString]], "session_id": sessionID, "caller": [ "workspace_id": originalWorkspaceId.uuidString, @@ -1900,6 +1902,8 @@ final class TabManagerSessionSnapshotTests: XCTestCase { XCTAssertEqual(params["tab_id"] as? String, restoredPanelId.uuidString) XCTAssertEqual(params["workspace_ids"] as? [String], [restoredWorkspace.id.uuidString]) XCTAssertEqual(params["surface_ids"] as? [String], [restoredPanelId.uuidString]) + XCTAssertEqual(params["context_ids"] as? [String], [restoredWorkspace.id.uuidString, restoredPanelId.uuidString]) + XCTAssertEqual(params["context_id_groups"] as? [[String]], [[restoredWorkspace.id.uuidString, restoredPanelId.uuidString]]) XCTAssertEqual(params["session_id"] as? String, sessionID) let caller = try XCTUnwrap(params["caller"] as? [String: Any]) @@ -1967,6 +1971,7 @@ final class TabManagerSessionSnapshotTests: XCTestCase { "tab_id": originalPanelId.uuidString, "workspace_ids": [originalWorkspaceId.uuidString], "surface_ids": [originalPanelId.uuidString], + "context_ids": [originalWorkspaceId.uuidString, originalPanelId.uuidString], "session_id": sessionID, ], ] @@ -1980,6 +1985,7 @@ final class TabManagerSessionSnapshotTests: XCTestCase { XCTAssertEqual(params["tab_id"] as? String, reattachedPanel.id.uuidString) XCTAssertEqual(params["workspace_ids"] as? [String], [restoredWorkspace.id.uuidString]) XCTAssertEqual(params["surface_ids"] as? [String], [reattachedPanel.id.uuidString]) + XCTAssertEqual(params["context_ids"] as? [String], [restoredWorkspace.id.uuidString, reattachedPanel.id.uuidString]) XCTAssertEqual(params["session_id"] as? String, sessionID) } diff --git a/daemon/remote/cmd/cmuxd-remote/main.go b/daemon/remote/cmd/cmuxd-remote/main.go index 23154c957445..ddc70a8de3e7 100644 --- a/daemon/remote/cmd/cmuxd-remote/main.go +++ b/daemon/remote/cmd/cmuxd-remote/main.go @@ -176,6 +176,10 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { _, _ = fmt.Fprintln(stderr, "serve --persistent requires --stdio") return 2 } + if strings.TrimSpace(*persistentSlot) != "" && !*persistent { + _, _ = fmt.Fprintln(stderr, "serve --slot requires --persistent") + return 2 + } if *ws { if strings.TrimSpace(*authLeaseFile) == "" { _, _ = fmt.Fprintln(stderr, "serve --ws requires --auth-lease-file") diff --git a/daemon/remote/cmd/cmuxd-remote/main_test.go b/daemon/remote/cmd/cmuxd-remote/main_test.go index 9e3f6e4e9e79..e4a7b30e2d16 100644 --- a/daemon/remote/cmd/cmuxd-remote/main_test.go +++ b/daemon/remote/cmd/cmuxd-remote/main_test.go @@ -684,6 +684,17 @@ func TestPersistentDaemonPTYReattachSurvivesClientDisconnect(t *testing.T) { } } +func TestRunStdioSlotRequiresPersistent(t *testing.T) { + var stderr bytes.Buffer + code := run([]string{"serve", "--stdio", "--slot", "slot-without-persistent"}, strings.NewReader(""), &bytes.Buffer{}, &stderr) + if code != 2 { + t.Fatalf("run serve exit code = %d, want 2", code) + } + if !strings.Contains(stderr.String(), "serve --slot requires --persistent") { + t.Fatalf("stderr = %q, want --slot validation error", stderr.String()) + } +} + func TestRunStdioInvalidJSONAndUnknownMethod(t *testing.T) { input := strings.NewReader( `{"id":1,"method":"hello","params":{}` + "\n" + From dc9388906715ff0b176cf002f7d5a6fcd4b644de Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Wed, 27 May 2026 01:55:23 -0700 Subject: [PATCH 17/69] fix: keep relay alias rewrites on relay queue --- Sources/Workspace.swift | 114 ++++++++++-------- .../TabManagerSessionSnapshotTests.swift | 12 +- 2 files changed, 69 insertions(+), 57 deletions(-) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index d2262abb9e6f..c3904c7289e2 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -4806,19 +4806,19 @@ private final class WorkspaceRemoteCLIRelayServer { private let localSocketPath: String private let relayID: String private let relayToken: Data - private let commandRewriter: (Data) -> Data private let queue = DispatchQueue(label: "com.cmux.remote-ssh.cli-relay.\(UUID().uuidString)", qos: .utility) private var listener: NWListener? private var sessions: [UUID: Session] = [:] private var isStopped = false private(set) var localPort: Int? + private var workspaceAliases: [UUID: UUID] = [:] + private var surfaceAliases: [UUID: UUID] = [:] init( localSocketPath: String, relayID: String, - relayTokenHex: String, - commandRewriter: @escaping (Data) -> Data = { $0 } + relayTokenHex: String ) throws { guard let relayToken = Session.hexData(from: relayTokenHex), !relayToken.isEmpty else { throw NSError(domain: "cmux.remote.relay", code: 7, userInfo: [ @@ -4828,7 +4828,6 @@ private final class WorkspaceRemoteCLIRelayServer { self.localSocketPath = localSocketPath self.relayID = relayID self.relayToken = relayToken - self.commandRewriter = commandRewriter } func start() throws -> Int { @@ -4924,6 +4923,13 @@ private final class WorkspaceRemoteCLIRelayServer { } } + func updateRemoteRelayIDAliases(workspaceAliases: [UUID: UUID], surfaceAliases: [UUID: UUID]) { + queue.async { [weak self] in + self?.workspaceAliases = workspaceAliases + self?.surfaceAliases = surfaceAliases + } + } + private func acceptConnectionLocked(_ connection: NWConnection) { guard !isStopped else { connection.cancel() @@ -4935,7 +4941,9 @@ private final class WorkspaceRemoteCLIRelayServer { localSocketPath: localSocketPath, relayID: relayID, relayToken: relayToken, - commandRewriter: commandRewriter, + commandRewriter: { [weak self] commandLine in + self?.rewriteCommandLineLocked(commandLine) ?? commandLine + }, queue: queue ) { [weak self] in self?.sessions.removeValue(forKey: sessionID) @@ -4944,6 +4952,14 @@ private final class WorkspaceRemoteCLIRelayServer { session.start() } + private func rewriteCommandLineLocked(_ commandLine: Data) -> Data { + Workspace.rewriteRemoteRelayCommandLine( + commandLine, + workspaceAliases: workspaceAliases, + surfaceAliases: surfaceAliases + ) + } + private static func makeLoopbackListener() throws -> NWListener { let tcpOptions = NWProtocolTCP.Options() tcpOptions.noDelay = true @@ -5623,31 +5639,6 @@ final class WorkspaceRemotePTYBridgeServer { } } -private final class WorkspaceRemoteRelayAliasStore { - private let lock = NSLock() - private var workspaceAliases: [UUID: UUID] = [:] - private var surfaceAliases: [UUID: UUID] = [:] - - func update(workspaceAliases: [UUID: UUID], surfaceAliases: [UUID: UUID]) { - lock.lock() - self.workspaceAliases = workspaceAliases - self.surfaceAliases = surfaceAliases - lock.unlock() - } - - func rewrite(_ commandLine: Data) -> Data { - lock.lock() - let workspaceAliases = self.workspaceAliases - let surfaceAliases = self.surfaceAliases - lock.unlock() - return Workspace.rewriteRemoteRelayCommandLine( - commandLine, - workspaceAliases: workspaceAliases, - surfaceAliases: surfaceAliases - ) - } -} - final class WorkspaceRemoteSessionController { #if DEBUG // XCTest seam: tests assign this before starting a controller and clear it @@ -5743,7 +5734,6 @@ final class WorkspaceRemoteSessionController { private weak var workspace: Workspace? private let configuration: WorkspaceRemoteConfiguration private let controllerID: UUID - private let remoteRelayAliasStore = WorkspaceRemoteRelayAliasStore() private enum RemotePortPollingMode { case hostWide @@ -5815,6 +5805,8 @@ final class WorkspaceRemoteSessionController { private var heartbeatCount: Int = 0 private var connectionAttemptStartedAt: Date? private var pendingPTYBridgeStarts: [UUID: PendingPTYBridgeStart] = [:] + private var remoteRelayWorkspaceAliases: [UUID: UUID] = [:] + private var remoteRelaySurfaceAliases: [UUID: UUID] = [:] private static let reverseRelayStartupGracePeriod: TimeInterval = 0.5 @@ -5845,7 +5837,15 @@ final class WorkspaceRemoteSessionController { } func updateRemoteRelayIDAliases(workspaceAliases: [UUID: UUID], surfaceAliases: [UUID: UUID]) { - remoteRelayAliasStore.update(workspaceAliases: workspaceAliases, surfaceAliases: surfaceAliases) + queue.async { [weak self] in + guard let self else { return } + self.remoteRelayWorkspaceAliases = workspaceAliases + self.remoteRelaySurfaceAliases = surfaceAliases + self.cliRelayServer?.updateRemoteRelayIDAliases( + workspaceAliases: workspaceAliases, + surfaceAliases: surfaceAliases + ) + } } func listPTYSessions(timeout: TimeInterval = 8.0) throws -> [[String: Any]] { @@ -7236,14 +7236,14 @@ final class WorkspaceRemoteSessionController { if let cliRelayServer { return cliRelayServer } - let aliasStore = remoteRelayAliasStore let relayServer = try WorkspaceRemoteCLIRelayServer( localSocketPath: localSocketPath, relayID: relayID, - relayTokenHex: relayToken, - commandRewriter: { commandLine in - aliasStore.rewrite(commandLine) - } + relayTokenHex: relayToken + ) + relayServer.updateRemoteRelayIDAliases( + workspaceAliases: remoteRelayWorkspaceAliases, + surfaceAliases: remoteRelaySurfaceAliases ) cliRelayServer = relayServer return relayServer @@ -12331,6 +12331,11 @@ final class Workspace: Identifiable, ObservableObject { "surface_ids", ] + private nonisolated static let remoteRelayAmbiguousIDArrayKeys: Set = [ + "tab_ids", + "tab_id_groups", + ] + private func syncRemoteRelayIDAliasesToController() { remoteSessionController?.updateRemoteRelayIDAliases( workspaceAliases: remoteRelayWorkspaceIDAliases, @@ -12461,6 +12466,12 @@ final class Workspace: Identifiable, ObservableObject { elementKey = "workspace_id" } else if let key, remoteRelaySurfaceIDArrayKeys.contains(key) { elementKey = "surface_id" + } else if let key, remoteRelayAmbiguousIDArrayKeys.contains(key) { + elementKey = "tab_id" + } else if let key, remoteRelayWorkspaceIDKeys.contains(key) + || remoteRelaySurfaceIDKeys.contains(key) + || remoteRelayAmbiguousIDKeys.contains(key) { + elementKey = key } else { elementKey = nil } @@ -12484,20 +12495,21 @@ final class Workspace: Identifiable, ObservableObject { return value } - if let key { - if remoteRelaySurfaceIDKeys.contains(key), - let mapped = surfaceAliases[uuid] { - didRewrite = true - return mapped.uuidString - } - if remoteRelayWorkspaceIDKeys.contains(key), - let mapped = workspaceAliases[uuid] { - didRewrite = true - return mapped.uuidString - } - if !remoteRelayAmbiguousIDKeys.contains(key) { - return value - } + guard let key else { + return value + } + if remoteRelaySurfaceIDKeys.contains(key), + let mapped = surfaceAliases[uuid] { + didRewrite = true + return mapped.uuidString + } + if remoteRelayWorkspaceIDKeys.contains(key), + let mapped = workspaceAliases[uuid] { + didRewrite = true + return mapped.uuidString + } + guard remoteRelayAmbiguousIDKeys.contains(key) else { + return value } if let mapped = surfaceAliases[uuid] { diff --git a/cmuxTests/TabManagerSessionSnapshotTests.swift b/cmuxTests/TabManagerSessionSnapshotTests.swift index ac828d4223ee..46fc3800e439 100644 --- a/cmuxTests/TabManagerSessionSnapshotTests.swift +++ b/cmuxTests/TabManagerSessionSnapshotTests.swift @@ -1882,8 +1882,8 @@ final class TabManagerSessionSnapshotTests: XCTestCase { "tab_id": originalPanelId.uuidString, "workspace_ids": [originalWorkspaceId.uuidString], "surface_ids": [originalPanelId.uuidString], - "context_ids": [originalWorkspaceId.uuidString, originalPanelId.uuidString], - "context_id_groups": [[originalWorkspaceId.uuidString, originalPanelId.uuidString]], + "tab_ids": [originalWorkspaceId.uuidString, originalPanelId.uuidString], + "tab_id_groups": [[originalWorkspaceId.uuidString, originalPanelId.uuidString]], "session_id": sessionID, "caller": [ "workspace_id": originalWorkspaceId.uuidString, @@ -1902,8 +1902,8 @@ final class TabManagerSessionSnapshotTests: XCTestCase { XCTAssertEqual(params["tab_id"] as? String, restoredPanelId.uuidString) XCTAssertEqual(params["workspace_ids"] as? [String], [restoredWorkspace.id.uuidString]) XCTAssertEqual(params["surface_ids"] as? [String], [restoredPanelId.uuidString]) - XCTAssertEqual(params["context_ids"] as? [String], [restoredWorkspace.id.uuidString, restoredPanelId.uuidString]) - XCTAssertEqual(params["context_id_groups"] as? [[String]], [[restoredWorkspace.id.uuidString, 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 caller = try XCTUnwrap(params["caller"] as? [String: Any]) @@ -1971,7 +1971,7 @@ final class TabManagerSessionSnapshotTests: XCTestCase { "tab_id": originalPanelId.uuidString, "workspace_ids": [originalWorkspaceId.uuidString], "surface_ids": [originalPanelId.uuidString], - "context_ids": [originalWorkspaceId.uuidString, originalPanelId.uuidString], + "tab_ids": [originalWorkspaceId.uuidString, originalPanelId.uuidString], "session_id": sessionID, ], ] @@ -1985,7 +1985,7 @@ final class TabManagerSessionSnapshotTests: XCTestCase { XCTAssertEqual(params["tab_id"] as? String, reattachedPanel.id.uuidString) XCTAssertEqual(params["workspace_ids"] as? [String], [restoredWorkspace.id.uuidString]) XCTAssertEqual(params["surface_ids"] as? [String], [reattachedPanel.id.uuidString]) - XCTAssertEqual(params["context_ids"] as? [String], [restoredWorkspace.id.uuidString, reattachedPanel.id.uuidString]) + XCTAssertEqual(params["tab_ids"] as? [String], [restoredWorkspace.id.uuidString, reattachedPanel.id.uuidString]) XCTAssertEqual(params["session_id"] as? String, sessionID) } From e7be94307a453901acc429f56859e12c72d366d5 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Wed, 27 May 2026 02:14:04 -0700 Subject: [PATCH 18/69] fix: clean stale persistent ssh proxies --- Sources/Workspace.swift | 15 +++++++++++++-- cmuxTests/GhosttyConfigTests.swift | 3 ++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index c3904c7289e2..83c8a59ecf51 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -8118,8 +8118,11 @@ final class WorkspaceRemoteSessionController { guard commandContainsDestination(trimmed, destination: destination) else { return false } if let relayPort { - return trimmed.contains(" -N ") - && trimmed.contains(" -R 127.0.0.1:\(relayPort):127.0.0.1:") + if trimmed.contains(" -N ") + && trimmed.contains(" -R 127.0.0.1:\(relayPort):127.0.0.1:") { + return true + } + return isCMUXRemotePersistentDaemonServeStdioCommand(trimmed) } if trimmed.contains(" -N ") && trimmed.contains(" -R 127.0.0.1:") { @@ -8139,6 +8142,14 @@ final class WorkspaceRemoteSessionController { return normalized.contains(" serve ") && normalized.contains(" --stdio") } + private static func isCMUXRemotePersistentDaemonServeStdioCommand(_ command: String) -> Bool { + guard isCMUXRemoteDaemonServeStdioCommand(command) else { return false } + let normalized = command + .replacingOccurrences(of: "'", with: " ") + .replacingOccurrences(of: "\"", with: " ") + return normalized.contains(" --persistent") + } + private static func commandContainsDestination(_ command: String, destination: String) -> Bool { guard !destination.isEmpty else { return false } let escaped = NSRegularExpression.escapedPattern(for: destination) diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index 6f2ff129018a..1273133434d7 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -2456,6 +2456,7 @@ final class WorkspaceRemoteSSHCleanupTests: XCTestCase { 201 1 /usr/bin/ssh -N -T -S none -R 127.0.0.1:56080:127.0.0.1:64048 cmux-macmini 202 1 /usr/bin/ssh -N -T -S none -R 127.0.0.1:56081:127.0.0.1:64049 cmux-macmini 203 1 /usr/bin/ssh -T -S none -o RequestTTY=no cmux-macmini sh -c 'exec .cmux/bin/cmuxd-remote/0.63.1/darwin-arm64/cmuxd-remote serve --stdio' + 204 1 /usr/bin/ssh -T -S none -o RequestTTY=no cmux-macmini sh -c 'exec .cmux/bin/cmuxd-remote/0.63.1/darwin-arm64/cmuxd-remote 'serve' '--stdio' '--persistent' '--slot' 'ssh-test'' """ XCTAssertEqual( @@ -2464,7 +2465,7 @@ final class WorkspaceRemoteSSHCleanupTests: XCTestCase { destination: "cmux-macmini", relayPort: 56081 ), - [202] + [202, 204] ) } } From f4352ceac7bb1561038e35626055c727df0d43b4 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Wed, 27 May 2026 02:35:20 -0700 Subject: [PATCH 19/69] fix: scope persistent ssh cleanup by slot --- Sources/Workspace.swift | 60 +++++++++++++++++++++++++----- cmuxTests/GhosttyConfigTests.swift | 13 ++++++- 2 files changed, 62 insertions(+), 11 deletions(-) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 83c8a59ecf51..cde312e8ee3b 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -6226,7 +6226,8 @@ final class WorkspaceRemoteSessionController { Self.killOrphanedRemoteSSHProcesses( destination: configuration.destination, - relayPort: configuration.relayPort + relayPort: configuration.relayPort, + persistentDaemonSlot: configuration.persistentDaemonSlot ) connectionAttemptStartedAt = Date() debugLog("remote.session.connect.begin retry=\(reconnectRetryCount) \(debugConfigSummary())") @@ -6377,7 +6378,8 @@ final class WorkspaceRemoteSessionController { let localRelayPort = try server.start() Self.killOrphanedRemoteSSHProcesses( destination: configuration.destination, - relayPort: relayPort + relayPort: relayPort, + persistentDaemonSlot: configuration.persistentDaemonSlot ) let forwardSpec = "127.0.0.1:\(relayPort):127.0.0.1:\(localRelayPort)" @@ -8024,10 +8026,16 @@ final class WorkspaceRemoteSessionController { static func orphanedCMUXRemoteSSHPIDs( psOutput: String, destination: String, - relayPort: Int? = nil + relayPort: Int? = nil, + persistentDaemonSlot: String? = nil ) -> [Int] { let trimmedDestination = destination.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedDestination.isEmpty else { return [] } + let trimmedPersistentDaemonSlot: String? = { + guard let persistentDaemonSlot else { return nil } + let trimmed = persistentDaemonSlot.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + }() return psOutput .split(separator: "\n", omittingEmptySubsequences: false) @@ -8037,7 +8045,8 @@ final class WorkspaceRemoteSessionController { guard isOrphanedCMUXRemoteSSHCommand( parsed.command, destination: trimmedDestination, - relayPort: relayPort + relayPort: relayPort, + persistentDaemonSlot: trimmedPersistentDaemonSlot ) else { return nil } @@ -8046,7 +8055,11 @@ final class WorkspaceRemoteSessionController { .sorted() } - private static func killOrphanedRemoteSSHProcesses(destination: String, relayPort: Int? = nil) { + private static func killOrphanedRemoteSSHProcesses( + destination: String, + relayPort: Int? = nil, + persistentDaemonSlot: String? = nil + ) { guard let output = captureCommandStandardOutput( executablePath: "/bin/ps", arguments: ["-axo", "pid=,ppid=,command="] @@ -8057,7 +8070,8 @@ final class WorkspaceRemoteSessionController { for pid in orphanedCMUXRemoteSSHPIDs( psOutput: output, destination: destination, - relayPort: relayPort + relayPort: relayPort, + persistentDaemonSlot: persistentDaemonSlot ) { _ = Darwin.kill(pid_t(pid), SIGTERM) } @@ -8110,24 +8124,40 @@ final class WorkspaceRemoteSessionController { private static func isOrphanedCMUXRemoteSSHCommand( _ command: String, destination: String, - relayPort: Int? + relayPort: Int?, + persistentDaemonSlot: String? ) -> Bool { let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return false } guard trimmed.hasPrefix("/usr/bin/ssh ") || trimmed.hasPrefix("ssh ") else { return false } guard commandContainsDestination(trimmed, destination: destination) else { return false } + let trimmedPersistentDaemonSlot: String? = { + guard let persistentDaemonSlot else { return nil } + let trimmed = persistentDaemonSlot.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + }() if let relayPort { if trimmed.contains(" -N ") && trimmed.contains(" -R 127.0.0.1:\(relayPort):127.0.0.1:") { return true } - return isCMUXRemotePersistentDaemonServeStdioCommand(trimmed) + guard let trimmedPersistentDaemonSlot else { return false } + return isCMUXRemotePersistentDaemonServeStdioCommand( + trimmed, + slot: trimmedPersistentDaemonSlot + ) } if trimmed.contains(" -N ") && trimmed.contains(" -R 127.0.0.1:") { return true } + if let trimmedPersistentDaemonSlot { + return isCMUXRemotePersistentDaemonServeStdioCommand( + trimmed, + slot: trimmedPersistentDaemonSlot + ) + } if isCMUXRemoteDaemonServeStdioCommand(trimmed) { return true } @@ -8142,12 +8172,22 @@ final class WorkspaceRemoteSessionController { return normalized.contains(" serve ") && normalized.contains(" --stdio") } - private static func isCMUXRemotePersistentDaemonServeStdioCommand(_ command: String) -> Bool { + private static func isCMUXRemotePersistentDaemonServeStdioCommand( + _ command: String, + slot: String + ) -> Bool { guard isCMUXRemoteDaemonServeStdioCommand(command) else { return false } let normalized = command .replacingOccurrences(of: "'", with: " ") .replacingOccurrences(of: "\"", with: " ") - return normalized.contains(" --persistent") + guard normalized.contains(" --persistent") else { return false } + let escapedSlot = NSRegularExpression.escapedPattern(for: slot) + let pattern = "(^|\\s)--slot(=|\\s+)\(escapedSlot)($|\\s)" + guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { + return normalized.contains(" --slot \(slot) ") || normalized.contains(" --slot=\(slot) ") + } + let range = NSRange(normalized.startIndex.. Bool { diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index 1273133434d7..38a849dd3e68 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -2457,16 +2457,27 @@ final class WorkspaceRemoteSSHCleanupTests: XCTestCase { 202 1 /usr/bin/ssh -N -T -S none -R 127.0.0.1:56081:127.0.0.1:64049 cmux-macmini 203 1 /usr/bin/ssh -T -S none -o RequestTTY=no cmux-macmini sh -c 'exec .cmux/bin/cmuxd-remote/0.63.1/darwin-arm64/cmuxd-remote serve --stdio' 204 1 /usr/bin/ssh -T -S none -o RequestTTY=no cmux-macmini sh -c 'exec .cmux/bin/cmuxd-remote/0.63.1/darwin-arm64/cmuxd-remote 'serve' '--stdio' '--persistent' '--slot' 'ssh-test'' + 205 1 /usr/bin/ssh -T -S none -o RequestTTY=no cmux-macmini sh -c 'exec .cmux/bin/cmuxd-remote/0.63.1/darwin-arm64/cmuxd-remote 'serve' '--stdio' '--persistent' '--slot' 'ssh-other'' """ XCTAssertEqual( WorkspaceRemoteSessionController.orphanedCMUXRemoteSSHPIDs( psOutput: psOutput, destination: "cmux-macmini", - relayPort: 56081 + relayPort: 56081, + persistentDaemonSlot: "ssh-test" ), [202, 204] ) + + XCTAssertEqual( + WorkspaceRemoteSessionController.orphanedCMUXRemoteSSHPIDs( + psOutput: psOutput, + destination: "cmux-macmini", + relayPort: 56081 + ), + [202] + ) } } From d09db5372f7d66cd385c824a4498e86b5cf3487b Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Wed, 27 May 2026 02:51:21 -0700 Subject: [PATCH 20/69] fix: gate persistent ssh restore on socket --- Sources/TerminalController.swift | 9 ++- .../TabManagerSessionSnapshotTests.swift | 69 ++++++++++++++++++- daemon/remote/README.md | 8 +-- docs/remote-daemon-spec.md | 2 +- 4 files changed, 80 insertions(+), 8 deletions(-) diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 9372930f6385..6ca921c1ceba 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -376,8 +376,13 @@ class TerminalController { return body() } - nonisolated func currentSocketPathForRemoteRestore() -> String { - withListenerState { socketPath } + nonisolated func currentSocketPathForRemoteRestore() -> String? { + withListenerState { + if isRunning || acceptLoopAlive || listenerStartInProgress || serverSocket >= 0 { + return socketPath + } + return reservedStartupSocketPath + } } private nonisolated func listenerStateSnapshot() -> ListenerStateSnapshot { diff --git a/cmuxTests/TabManagerSessionSnapshotTests.swift b/cmuxTests/TabManagerSessionSnapshotTests.swift index 46fc3800e439..42124e629515 100644 --- a/cmuxTests/TabManagerSessionSnapshotTests.swift +++ b/cmuxTests/TabManagerSessionSnapshotTests.swift @@ -19,6 +19,20 @@ final class TabManagerSessionSnapshotTests: XCTestCase { super.tearDown() } + private func reserveRemoteRestoreSocket() -> String { + TerminalController.shared.stop() + let requestedPath = "/tmp/cmux-restore-\(UUID().uuidString).sock" + let reservedPath = TerminalController.shared.reserveStartupSocketPath(requestedPath) + XCTAssertEqual(TerminalController.shared.currentSocketPathForRemoteRestore(), reservedPath) + return reservedPath + } + + private func cleanupRemoteRestoreSocket(_ path: String) { + TerminalController.shared.stop() + try? FileManager.default.removeItem(atPath: path) + try? FileManager.default.removeItem(atPath: path + ".lock") + } + func testSessionSnapshotSerializesWorkspacesAndRestoreRebuildsSelection() { let manager = TabManager() guard let firstWorkspace = manager.selectedWorkspace else { @@ -1787,6 +1801,9 @@ final class TabManagerSessionSnapshotTests: XCTestCase { ) XCTAssertTrue(expectedScrollback.contains("cmux perf synthetic scrollback"), expectedScrollback) + let reservedSocketPath = reserveRemoteRestoreSocket() + defer { cleanupRemoteRestoreSocket(reservedSocketPath) } + let restored = TabManager() restored.restoreSessionSnapshot(persistedTabManager) @@ -1794,7 +1811,7 @@ final class TabManagerSessionSnapshotTests: XCTestCase { XCTAssertEqual(restoredWorkspace.remoteConfiguration?.preserveAfterTerminalExit, true) XCTAssertEqual(restoredWorkspace.remoteConfiguration?.relayPort, 64003) XCTAssertEqual(restoredWorkspace.remoteConfiguration?.persistentDaemonSlot, persistentDaemonSlot) - XCTAssertEqual(restoredWorkspace.remoteConfiguration?.localSocketPath, TerminalController.shared.currentSocketPathForRemoteRestore()) + XCTAssertEqual(restoredWorkspace.remoteConfiguration?.localSocketPath, reservedSocketPath) XCTAssertTrue( restoredWorkspace.remoteConfiguration?.sshOptions.contains("ControlPath=/tmp/cmux-ssh-\(getuid())-64003-%C") == true ) @@ -1865,6 +1882,9 @@ final class TabManagerSessionSnapshotTests: XCTestCase { ) let snapshot = manager.sessionSnapshot(includeScrollback: false) + let reservedSocketPath = reserveRemoteRestoreSocket() + defer { cleanupRemoteRestoreSocket(reservedSocketPath) } + let restored = TabManager() restored.restoreSessionSnapshot(snapshot) @@ -1942,6 +1962,9 @@ final class TabManagerSessionSnapshotTests: XCTestCase { ) let snapshot = manager.sessionSnapshot(includeScrollback: false) + let reservedSocketPath = reserveRemoteRestoreSocket() + defer { cleanupRemoteRestoreSocket(reservedSocketPath) } + let restored = TabManager() restored.restoreSessionSnapshot(snapshot) @@ -2027,6 +2050,9 @@ final class TabManagerSessionSnapshotTests: XCTestCase { ) legacySnapshot.workspaces[workspaceIndex].panels[panelIndex].terminal?.remotePTYSessionID = nil + let reservedSocketPath = reserveRemoteRestoreSocket() + defer { cleanupRemoteRestoreSocket(reservedSocketPath) } + let restored = TabManager() restored.restoreSessionSnapshot(legacySnapshot) @@ -2047,6 +2073,47 @@ final class TabManagerSessionSnapshotTests: XCTestCase { ) } + func testSessionSnapshotFallsBackWhenPersistentSSHPTYRestoreHasNoSocketPath() throws { + TerminalController.shared.stop() + defer { TerminalController.shared.stop() } + + 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) diff --git a/daemon/remote/README.md b/daemon/remote/README.md index 27d21c774c76..be65ae6bc4d1 100644 --- a/daemon/remote/README.md +++ b/daemon/remote/README.md @@ -13,7 +13,7 @@ Go remote daemon for `cmux ssh` bootstrap, capability negotiation, and remote pr `serve --ws` is explicit opt-in for cloud VM images only. The normal `cmux ssh` code path uses `serve --stdio --persistent --slot ` over an SSH exec channel. That stdio process is only a proxy to an authenticated per-slot daemon -with credentials and logs under `~/.cmux/daemon//`, so remote PTY sessions +with credentials and logs under `~/.cmux/daemon///`, so remote PTY sessions can survive local surface close, local reconnect, and app relaunch. The persistent server never opens a public listener; it accepts only a per-user Unix socket under `/tmp/cmuxd-remote-/` and the slot token. @@ -57,9 +57,9 @@ to the remote daemon bootstrap as `--slot`. Remote slot files: 1. `/tmp/cmuxd-remote-/cmuxd-.sock` authenticated Unix socket for stdio proxies. -2. `~/.cmux/daemon//auth.token` random 32-byte hex token, mode `0600`. -3. `~/.cmux/daemon//daemon.lock` single-owner lock. -4. `~/.cmux/daemon//daemon.log` startup and crash diagnostics. +2. `~/.cmux/daemon///auth.token` random 32-byte hex token, mode `0600`. +3. `~/.cmux/daemon///daemon.lock` single-owner lock. +4. `~/.cmux/daemon///daemon.log` startup and crash diagnostics. PTY lifecycle: 1. A local attach creates or reuses a named `pty.*` session in the persistent daemon. diff --git a/docs/remote-daemon-spec.md b/docs/remote-daemon-spec.md index 45ff4d84bd86..55ebd626aed9 100644 --- a/docs/remote-daemon-spec.md +++ b/docs/remote-daemon-spec.md @@ -40,7 +40,7 @@ This is a **living implementation spec** (also called an **execution spec**): a - `DONE` `workspace.remote.configure.local_proxy_port` exists as an internal deterministic test hook for bind-conflict regression coverage. - `DONE` bootstrap/probe failures surface actionable details. - `DONE` bootstrap installs `~/.cmux/bin/cmux` wrapper (also tries `/usr/local/bin/cmux`) so `cmux` is available in PATH on the remote. -- `DONE` normal `cmux ssh` launches `cmuxd-remote serve --stdio --persistent --slot `, where the stdio process proxies to a long-lived authenticated daemon with slot credentials under `~/.cmux/daemon//` and a short per-user socket path under `/tmp/cmuxd-remote-/`. +- `DONE` normal `cmux ssh` launches `cmuxd-remote serve --stdio --persistent --slot `, where the stdio process proxies to a long-lived authenticated daemon with slot credentials under `~/.cmux/daemon///` and a short per-user socket path under `/tmp/cmuxd-remote-/`. - `DONE` persistent daemon slots advertise `pty.session.persistent_daemon`; cmux requires that capability before preserving a saved remote PTY session ID across app relaunch. ### 3.5 CLI Relay (Running cmux Commands From Remote) From 5e80396969e00297aa9b925ae0b13ad74a5967f4 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Wed, 27 May 2026 03:12:54 -0700 Subject: [PATCH 21/69] fix: preserve generic ssh cleanup with slots --- Sources/Workspace.swift | 15 +++++++++++++-- cmuxTests/GhosttyConfigTests.swift | 19 +++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index cde312e8ee3b..38a108a6bf8e 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -8153,10 +8153,13 @@ final class WorkspaceRemoteSessionController { return true } if let trimmedPersistentDaemonSlot { - return isCMUXRemotePersistentDaemonServeStdioCommand( + if isCMUXRemotePersistentDaemonServeStdioCommand( trimmed, slot: trimmedPersistentDaemonSlot - ) + ) { + return true + } + return isCMUXRemoteNonPersistentDaemonServeStdioCommand(trimmed) } if isCMUXRemoteDaemonServeStdioCommand(trimmed) { return true @@ -8172,6 +8175,14 @@ final class WorkspaceRemoteSessionController { return normalized.contains(" serve ") && normalized.contains(" --stdio") } + private static func isCMUXRemoteNonPersistentDaemonServeStdioCommand(_ command: String) -> Bool { + guard isCMUXRemoteDaemonServeStdioCommand(command) else { return false } + let normalized = command + .replacingOccurrences(of: "'", with: " ") + .replacingOccurrences(of: "\"", with: " ") + return !normalized.contains(" --persistent") + } + private static func isCMUXRemotePersistentDaemonServeStdioCommand( _ command: String, slot: String diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index 38a849dd3e68..af31df2e21bb 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -2479,6 +2479,25 @@ final class WorkspaceRemoteSSHCleanupTests: XCTestCase { [202] ) } + + func testOrphanedCMUXRemoteSSHPIDsWithSlotAndNoRelayKeepsGenericCleanup() { + let psOutput = """ + 301 1 /usr/bin/ssh -N -T -S none -R 127.0.0.1:56080:127.0.0.1:64048 cmux-macmini + 302 1 /usr/bin/ssh -T -S none -o RequestTTY=no cmux-macmini sh -c 'exec .cmux/bin/cmuxd-remote/0.63.1/darwin-arm64/cmuxd-remote serve --stdio' + 303 1 /usr/bin/ssh -T -S none -o RequestTTY=no cmux-macmini sh -c 'exec .cmux/bin/cmuxd-remote/0.63.1/darwin-arm64/cmuxd-remote 'serve' '--stdio' '--persistent' '--slot' 'ssh-test'' + 304 1 /usr/bin/ssh -T -S none -o RequestTTY=no cmux-macmini sh -c 'exec .cmux/bin/cmuxd-remote/0.63.1/darwin-arm64/cmuxd-remote 'serve' '--stdio' '--persistent' '--slot' 'ssh-other'' + 305 1 /usr/bin/ssh -T -S none -o RequestTTY=no other-host sh -c 'exec .cmux/bin/cmuxd-remote/0.63.1/darwin-arm64/cmuxd-remote serve --stdio' + """ + + XCTAssertEqual( + WorkspaceRemoteSessionController.orphanedCMUXRemoteSSHPIDs( + psOutput: psOutput, + destination: "cmux-macmini", + persistentDaemonSlot: "ssh-test" + ), + [301, 302, 303] + ) + } } final class TitlebarDoubleClickPreferenceTests: XCTestCase { From 7c56a1809228bbe62154cb551d446fdcc802cd44 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Wed, 27 May 2026 05:52:35 -0700 Subject: [PATCH 22/69] fix: bound persistent daemon slot lifecycle --- daemon/remote/cmd/cmuxd-remote/main.go | 114 +++++++++++++++++++- daemon/remote/cmd/cmuxd-remote/main_test.go | 111 +++++++++++++++++++ 2 files changed, 221 insertions(+), 4 deletions(-) diff --git a/daemon/remote/cmd/cmuxd-remote/main.go b/daemon/remote/cmd/cmuxd-remote/main.go index ddc70a8de3e7..383a824d3348 100644 --- a/daemon/remote/cmd/cmuxd-remote/main.go +++ b/daemon/remote/cmd/cmuxd-remote/main.go @@ -23,6 +23,7 @@ import ( "strconv" "strings" "sync" + "sync/atomic" "syscall" "time" ) @@ -315,6 +316,18 @@ const ( var errPersistentDaemonAuthFailed = errors.New("persistent daemon authentication failed") +const ( + persistentDaemonStartupTimeout = 5 * time.Second + persistentDaemonDialPollInterval = 25 * time.Millisecond + persistentDaemonEmptyIdleTimeout = 5 * time.Minute + persistentDaemonEmptyIdlePollStep = time.Second +) + +type persistentDaemonServerConfig struct { + emptyIdleTimeout time.Duration + acceptPollStep time.Duration +} + func persistentDaemonPathsForSlot(rawSlot string) (persistentDaemonPaths, error) { slot, err := validatePersistentDaemonSlot(rawSlot) if err != nil { @@ -580,7 +593,11 @@ func ensurePersistentDaemonRunning(paths persistentDaemonPaths, token string, st _ = cmd.Process.Release() if err := waitPersistentDaemonReady(readyReader, paths.logFile); err != nil { - if conn, dialErr := dialPersistentDaemon(paths.socket, token); dialErr == nil { + if conn, dialErr := waitForPersistentDaemonDial( + paths.socket, + token, + persistentDaemonStartupTimeout, + ); dialErr == nil { _ = conn.Close() return nil } @@ -590,7 +607,7 @@ func ensurePersistentDaemonRunning(paths persistentDaemonPaths, token string, st return err } - conn, err := dialPersistentDaemon(paths.socket, token) + conn, err := waitForPersistentDaemonDial(paths.socket, token, persistentDaemonStartupTimeout) if err == nil { _ = conn.Close() return nil @@ -601,6 +618,23 @@ func ensurePersistentDaemonRunning(paths persistentDaemonPaths, token string, st return err } +func waitForPersistentDaemonDial(socketPath string, token string, timeout time.Duration) (net.Conn, error) { + deadline := time.Now().Add(timeout) + var lastErr error + for { + conn, err := dialPersistentDaemon(socketPath, token) + if err == nil { + return conn, nil + } + lastErr = err + remaining := time.Until(deadline) + if remaining <= 0 { + return nil, lastErr + } + time.Sleep(minDuration(remaining, persistentDaemonDialPollInterval)) + } +} + func shouldRemovePersistentSocketAfterDialError(err error) bool { return errors.Is(err, os.ErrNotExist) || errors.Is(err, syscall.ENOENT) || @@ -664,7 +698,12 @@ func runPersistentDaemonServer(slot string, stderr io.Writer) error { _ = os.Chmod(paths.socket, 0o600) signalPersistentDaemonReady() - return servePersistentDaemonWithVerifier(listener, persistentDaemonFileTokenVerifier(token, paths.tokenFile), stderr) + return servePersistentDaemonWithVerifierConfig( + listener, + persistentDaemonFileTokenVerifier(token, paths.tokenFile), + stderr, + persistentDaemonServerConfig{emptyIdleTimeout: persistentDaemonEmptyIdleTimeout}, + ) } func signalPersistentDaemonReady() { @@ -715,20 +754,87 @@ func persistentDaemonTokensEqual(provided string, token string) bool { } func servePersistentDaemonWithVerifier(listener net.Listener, verifier persistentDaemonTokenVerifier, stderr io.Writer) error { + return servePersistentDaemonWithVerifierConfig(listener, verifier, stderr, persistentDaemonServerConfig{}) +} + +func servePersistentDaemonWithVerifierConfig( + listener net.Listener, + verifier persistentDaemonTokenVerifier, + stderr io.Writer, + config persistentDaemonServerConfig, +) error { hub := newWebSocketPTYHub(wsPTYServerConfig{}, stderr) defer hub.closeAll() + var activeConnections int64 + var idleSince time.Time for { + if config.emptyIdleTimeout > 0 { + now := time.Now() + isEmpty := atomic.LoadInt64(&activeConnections) == 0 && hub.activeSessionCount() == 0 + if isEmpty { + if idleSince.IsZero() { + idleSince = now + } + remaining := config.emptyIdleTimeout - now.Sub(idleSince) + if remaining <= 0 { + return nil + } + setPersistentDaemonAcceptDeadline(listener, now.Add(minDuration( + remaining, + persistentDaemonAcceptPollStep(config), + ))) + } else { + idleSince = time.Time{} + setPersistentDaemonAcceptDeadline(listener, now.Add(persistentDaemonAcceptPollStep(config))) + } + } conn, err := listener.Accept() if err != nil { + if isTimeoutError(err) { + continue + } if isClosedListenerError(err) { return nil } return err } - go handlePersistentDaemonConn(conn, verifier, hub) + atomic.AddInt64(&activeConnections, 1) + go func() { + defer atomic.AddInt64(&activeConnections, -1) + handlePersistentDaemonConn(conn, verifier, hub) + }() } } +func persistentDaemonAcceptPollStep(config persistentDaemonServerConfig) time.Duration { + if config.acceptPollStep > 0 { + return config.acceptPollStep + } + return persistentDaemonEmptyIdlePollStep +} + +type deadlineListener interface { + SetDeadline(time.Time) error +} + +func setPersistentDaemonAcceptDeadline(listener net.Listener, deadline time.Time) { + if deadlineListener, ok := listener.(deadlineListener); ok { + _ = deadlineListener.SetDeadline(deadline) + } +} + +func minDuration(a time.Duration, b time.Duration) time.Duration { + if a < b { + return a + } + return b +} + +func isTimeoutError(err error) bool { + var netErr net.Error + return errors.As(err, &netErr) && netErr.Timeout() +} + func isClosedListenerError(err error) bool { if err == nil { return false diff --git a/daemon/remote/cmd/cmuxd-remote/main_test.go b/daemon/remote/cmd/cmuxd-remote/main_test.go index e4a7b30e2d16..c4c41fdcce59 100644 --- a/daemon/remote/cmd/cmuxd-remote/main_test.go +++ b/daemon/remote/cmd/cmuxd-remote/main_test.go @@ -684,6 +684,117 @@ func TestPersistentDaemonPTYReattachSurvivesClientDisconnect(t *testing.T) { } } +func TestWaitForPersistentDaemonDialWaitsForPeerStartup(t *testing.T) { + socketDir, err := os.MkdirTemp("/tmp", "cmuxd-remote-race-*") + if err != nil { + t.Fatalf("create short socket dir: %v", err) + } + defer os.RemoveAll(socketDir) + socketPath := filepath.Join(socketDir, "rpc.sock") + + type listenerResult struct { + listener net.Listener + err error + } + listenerCh := make(chan listenerResult, 1) + done := make(chan error, 1) + go func() { + time.Sleep(50 * time.Millisecond) + listener, listenErr := net.Listen("unix", socketPath) + listenerCh <- listenerResult{listener: listener, err: listenErr} + if listenErr != nil { + done <- listenErr + return + } + done <- servePersistentDaemonWithVerifier(listener, persistentDaemonFixedTokenVerifier("race-token"), io.Discard) + }() + + conn, err := waitForPersistentDaemonDial(socketPath, "race-token", time.Second) + if err != nil { + t.Fatalf("waitForPersistentDaemonDial returned error: %v", err) + } + _ = conn.Close() + + gotListener := <-listenerCh + if gotListener.err != nil { + t.Fatalf("listen unix: %v", gotListener.err) + } + _ = gotListener.listener.Close() + select { + case err := <-done: + if err != nil { + t.Fatalf("persistent daemon exited with error: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatalf("persistent daemon did not stop") + } +} + +func TestPersistentDaemonServerExitsAfterEmptySlotIdleTimeout(t *testing.T) { + socketDir, err := os.MkdirTemp("/tmp", "cmuxd-remote-idle-*") + if err != nil { + t.Fatalf("create short socket dir: %v", err) + } + defer os.RemoveAll(socketDir) + socketPath := filepath.Join(socketDir, "rpc.sock") + listener, err := net.Listen("unix", socketPath) + if err != nil { + t.Fatalf("listen unix: %v", err) + } + + done := make(chan error, 1) + go func() { + done <- servePersistentDaemonWithVerifierConfig( + listener, + persistentDaemonFixedTokenVerifier("idle-token"), + io.Discard, + persistentDaemonServerConfig{ + emptyIdleTimeout: 80 * time.Millisecond, + acceptPollStep: 10 * time.Millisecond, + }, + ) + }() + + conn, reader, writer := openPersistentTestClient(t, socketPath, "idle-token") + attach := persistentTestRPCCall(t, conn, reader, writer, rpcRequest{ + ID: 1, + Method: "pty.attach", + Params: map[string]any{ + "session_id": "idle-session", + "attachment_id": "idle-attachment", + "client_attachment_token": "idle-attachment-token", + "cols": 80, + "rows": 24, + "command": "sleep 60", + }, + }) + if ok, _ := attach["ok"].(bool); !ok { + t.Fatalf("pty.attach failed: %v", attach) + } + readPersistentTestEvent(t, conn, reader, func(frame map[string]any) bool { + return frame["event"] == "pty.ready" && frame["attachment_id"] == "idle-attachment" + }) + + closeResp := persistentTestRPCCall(t, conn, reader, writer, rpcRequest{ + ID: 2, + Method: "pty.close", + Params: map[string]any{"session_id": "idle-session"}, + }) + if ok, _ := closeResp["ok"].(bool); !ok { + t.Fatalf("pty.close failed: %v", closeResp) + } + _ = conn.Close() + + select { + case err := <-done: + if err != nil { + t.Fatalf("persistent daemon exited with error: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatalf("persistent daemon did not stop after empty idle timeout") + } +} + func TestRunStdioSlotRequiresPersistent(t *testing.T) { var stderr bytes.Buffer code := run([]string{"serve", "--stdio", "--slot", "slot-without-persistent"}, strings.NewReader(""), &bytes.Buffer{}, &stderr) From 7d96b10495236f471be49c0f6b0236f268bff751 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Wed, 27 May 2026 06:24:52 -0700 Subject: [PATCH 23/69] fix: parse persistent daemon cleanup slots by token --- Sources/Workspace.swift | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 38a108a6bf8e..411f95f0d604 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -8192,13 +8192,17 @@ final class WorkspaceRemoteSessionController { .replacingOccurrences(of: "'", with: " ") .replacingOccurrences(of: "\"", with: " ") guard normalized.contains(" --persistent") else { return false } - let escapedSlot = NSRegularExpression.escapedPattern(for: slot) - let pattern = "(^|\\s)--slot(=|\\s+)\(escapedSlot)($|\\s)" - guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { - return normalized.contains(" --slot \(slot) ") || normalized.contains(" --slot=\(slot) ") + let tokens = normalized.split(whereSeparator: { $0.isWhitespace }).map(String.init) + for index in tokens.indices { + let token = tokens[index] + if token == "--slot" { + return tokens.indices.contains(index + 1) && tokens[index + 1] == slot + } + if token.hasPrefix("--slot=") { + return String(token.dropFirst("--slot=".count)) == slot + } } - let range = NSRange(normalized.startIndex.. Bool { From 4547993fdb84549278c58913b2ec5e8930d722e9 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Wed, 27 May 2026 07:07:09 -0700 Subject: [PATCH 24/69] fix: simplify restored relay token guards --- Sources/WorkspaceRemoteConfiguration.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/WorkspaceRemoteConfiguration.swift b/Sources/WorkspaceRemoteConfiguration.swift index 92eba9e0d2eb..a3dd73c446dc 100644 --- a/Sources/WorkspaceRemoteConfiguration.swift +++ b/Sources/WorkspaceRemoteConfiguration.swift @@ -446,10 +446,10 @@ extension SessionRemoteWorkspaceSnapshot { token: $0 ) } - let restoredRelayID = preservePTYSession && normalizedRelayPort != nil + let restoredRelayID = preservePTYSession ? UUID().uuidString.lowercased() : nil - let restoredRelayToken = preservePTYSession && normalizedRelayPort != nil + let restoredRelayToken = preservePTYSession ? Self.restoreRelayTokenHex() : nil return WorkspaceRemoteConfiguration( From c867b8f2d78efd51d6d636ba3397470540e8c03a Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Wed, 27 May 2026 08:49:25 -0700 Subject: [PATCH 25/69] fix: avoid reattaching ended ssh ptys --- Sources/SessionPersistence.swift | 3 + Sources/Workspace.swift | 10 ++- .../TabManagerSessionSnapshotTests.swift | 63 ++++++++++++++++++- 3 files changed, 73 insertions(+), 3 deletions(-) diff --git a/Sources/SessionPersistence.swift b/Sources/SessionPersistence.swift index dc92e3379f0e..6f309f1486ab 100644 --- a/Sources/SessionPersistence.swift +++ b/Sources/SessionPersistence.swift @@ -1318,6 +1318,7 @@ struct SessionTerminalPanelSnapshot: Codable, Sendable { var hibernation: SessionAgentHibernationSnapshot? var resumeBinding: SurfaceResumeBindingSnapshot? var textBoxDraft: SessionTextBoxInputDraftSnapshot? + var isRemoteTerminal: Bool? var remotePTYSessionID: String? /// Whether the agent process was actively running when this snapshot was captured. /// Nil means unknown (legacy snapshots); treated as true for backwards compatibility. @@ -1331,6 +1332,7 @@ struct SessionTerminalPanelSnapshot: Codable, Sendable { hibernation: SessionAgentHibernationSnapshot? = nil, resumeBinding: SurfaceResumeBindingSnapshot? = nil, textBoxDraft: SessionTextBoxInputDraftSnapshot? = nil, + isRemoteTerminal: Bool? = nil, remotePTYSessionID: String? = nil, wasAgentRunning: Bool? = nil ) { @@ -1341,6 +1343,7 @@ struct SessionTerminalPanelSnapshot: Codable, Sendable { self.hibernation = hibernation self.resumeBinding = resumeBinding self.textBoxDraft = textBoxDraft + self.isRemoteTerminal = isRemoteTerminal self.remotePTYSessionID = remotePTYSessionID self.wasAgentRunning = wasAgentRunning } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 411f95f0d604..3ae4a5426821 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -555,6 +555,7 @@ extension Workspace { }, resumeBinding: resumeBinding, textBoxDraft: terminalPanel.sessionTextBoxDraftSnapshot(), + isRemoteTerminal: activeRemoteTerminalSurfaceIds.contains(panelId), remotePTYSessionID: remotePTYSessionIDForSnapshot(panelId: panelId), wasAgentRunning: agentWasRunning ) @@ -1263,8 +1264,13 @@ extension Workspace { remoteConfiguration?.persistentDaemonSlot != nil else { return nil } - return normalizedRemotePTYSessionID(snapshot.terminal?.remotePTYSessionID) - ?? Self.defaultSSHPTYSessionID(workspaceId: snapshotWorkspaceId ?? id, panelId: snapshot.id) + if let remotePTYSessionID = normalizedRemotePTYSessionID(snapshot.terminal?.remotePTYSessionID) { + return remotePTYSessionID + } + guard snapshot.terminal?.isRemoteTerminal == true else { + return nil + } + return Self.defaultSSHPTYSessionID(workspaceId: snapshotWorkspaceId ?? id, panelId: snapshot.id) }() let restoredRemotePTYAttachCommand = restoredRemotePTYSessionID.map { remotePTYAttachStartupCommand(sessionID: $0) diff --git a/cmuxTests/TabManagerSessionSnapshotTests.swift b/cmuxTests/TabManagerSessionSnapshotTests.swift index 42124e629515..1dd6b7f352ce 100644 --- a/cmuxTests/TabManagerSessionSnapshotTests.swift +++ b/cmuxTests/TabManagerSessionSnapshotTests.swift @@ -2012,7 +2012,7 @@ final class TabManagerSessionSnapshotTests: XCTestCase { XCTAssertEqual(params["session_id"] as? String, sessionID) } - func testPersistentSSHPTYRestoreFallsBackToSnapshotPanelDefaultSessionID() throws { + func testPersistentSSHPTYRestoreFallsBackToSnapshotPanelDefaultSessionIDWhenActiveMarkerExists() throws { let manager = TabManager() let remoteWorkspace = manager.addWorkspace(select: true) remoteWorkspace.setCustomTitle("Legacy Persistent SSH") @@ -2049,6 +2049,7 @@ final class TabManagerSessionSnapshotTests: XCTestCase { 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) } @@ -2073,6 +2074,66 @@ final class TabManagerSessionSnapshotTests: XCTestCase { ) } + func testPersistentSSHPTYRestoreDoesNotReattachEndedSnapshotPanel() throws { + let manager = TabManager() + let remoteWorkspace = manager.addWorkspace(select: true) + remoteWorkspace.setCustomTitle("Ended Persistent SSH") + let configuration = WorkspaceRemoteConfiguration( + destination: "dev@example.com", + port: 2222, + identityFile: nil, + sshOptions: [ + "StrictHostKeyChecking=accept-new", + ], + localProxyPort: nil, + relayPort: 64019, + relayID: "relay-ended-persist", + relayToken: String(repeating: "a", count: 64), + localSocketPath: "/tmp/cmux-ended-persist.sock", + terminalStartupCommand: SSHPTYAttachStartupCommandBuilder.command(), + preserveAfterTerminalExit: true, + persistentDaemonSlot: "ssh-ended-persist" + ) + remoteWorkspace.configureRemoteConnection(configuration, autoConnect: false) + let originalPanelId = try XCTUnwrap(remoteWorkspace.focusedPanelId) + let endedSessionID = Workspace.defaultSSHPTYSessionID( + workspaceId: remoteWorkspace.id, + panelId: originalPanelId + ) + + let ended = remoteWorkspace.markRemotePTYAttachEnded( + surfaceId: originalPanelId, + sessionID: endedSessionID + ) + XCTAssertTrue(ended.clearedRemotePTYSession) + XCTAssertTrue(ended.untrackedRemoteTerminal) + + let snapshot = manager.sessionSnapshot(includeScrollback: false) + let persistedWorkspace = try XCTUnwrap( + snapshot.workspaces.first { $0.customTitle == "Ended Persistent SSH" } + ) + let persistedPanel = try XCTUnwrap( + persistedWorkspace.panels.first { $0.id == originalPanelId } + ) + XCTAssertEqual(persistedPanel.terminal?.isRemoteTerminal, false) + XCTAssertNil(persistedPanel.terminal?.remotePTYSessionID) + + let reservedSocketPath = reserveRemoteRestoreSocket() + defer { cleanupRemoteRestoreSocket(reservedSocketPath) } + + let restored = TabManager() + restored.restoreSessionSnapshot(snapshot) + + let restoredWorkspace = try XCTUnwrap(restored.tabs.first { $0.customTitle == "Ended Persistent SSH" }) + let restoredPanelId = try XCTUnwrap(restoredWorkspace.focusedPanelId) + let restoredInitialCommand = restoredWorkspace.terminalPanel(for: restoredPanelId)?.surface.debugInitialCommand() + XCTAssertFalse(restoredInitialCommand?.contains("ssh-pty-attach") == true, restoredInitialCommand ?? "") + XCTAssertNil( + restoredWorkspace.sessionSnapshot(includeScrollback: false) + .panels.first { $0.id == restoredPanelId }?.terminal?.remotePTYSessionID + ) + } + func testSessionSnapshotFallsBackWhenPersistentSSHPTYRestoreHasNoSocketPath() throws { TerminalController.shared.stop() defer { TerminalController.shared.stop() } From c1d9fe5460e339ab726d986fd681c76dd82d32f7 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Wed, 27 May 2026 10:30:28 -0700 Subject: [PATCH 26/69] fix: match escaped ssh daemon slot cleanup --- Sources/Workspace.swift | 18 +++++++++++++++++- cmuxTests/GhosttyConfigTests.swift | 17 +++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 3ae4a5426821..8f5d3e4c4887 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -8202,7 +8202,7 @@ final class WorkspaceRemoteSessionController { for index in tokens.indices { let token = tokens[index] if token == "--slot" { - return tokens.indices.contains(index + 1) && tokens[index + 1] == slot + return nextNonShellEscapeToken(after: index, in: tokens) == slot } if token.hasPrefix("--slot=") { return String(token.dropFirst("--slot=".count)) == slot @@ -8211,6 +8211,22 @@ final class WorkspaceRemoteSessionController { return false } + private static func nextNonShellEscapeToken(after index: Int, in tokens: [String]) -> String? { + var nextIndex = index + 1 + while tokens.indices.contains(nextIndex) { + let token = tokens[nextIndex] + if !isShellEscapeNoiseToken(token) { + return token + } + nextIndex += 1 + } + return nil + } + + private static func isShellEscapeNoiseToken(_ token: String) -> Bool { + !token.isEmpty && token.allSatisfy { $0 == "\\" } + } + private static func commandContainsDestination(_ command: String, destination: String) -> Bool { guard !destination.isEmpty else { return false } let escaped = NSRegularExpression.escapedPattern(for: destination) diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index af31df2e21bb..fd523a5cc95a 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -2480,6 +2480,23 @@ final class WorkspaceRemoteSSHCleanupTests: XCTestCase { ) } + func testOrphanedCMUXRemoteSSHPIDsMatchesBackslashEscapedPersistentDaemonSlot() { + let psOutput = """ + 211 1 /usr/bin/ssh -T -S none -o RequestTTY=no cmux-macmini sh -c 'exec '\''.cmux/bin/cmuxd-remote/0.63.1/darwin-arm64/cmuxd-remote'\'' '\''serve'\'' '\''--stdio'\'' '\''--persistent'\'' '\''--slot'\'' '\''ssh-test'\''' + 212 1 /usr/bin/ssh -T -S none -o RequestTTY=no cmux-macmini sh -c 'exec '\''.cmux/bin/cmuxd-remote/0.63.1/darwin-arm64/cmuxd-remote'\'' '\''serve'\'' '\''--stdio'\'' '\''--persistent'\'' '\''--slot'\'' '\''ssh-other'\''' + """ + + XCTAssertEqual( + WorkspaceRemoteSessionController.orphanedCMUXRemoteSSHPIDs( + psOutput: psOutput, + destination: "cmux-macmini", + relayPort: 56081, + persistentDaemonSlot: "ssh-test" + ), + [211] + ) + } + func testOrphanedCMUXRemoteSSHPIDsWithSlotAndNoRelayKeepsGenericCleanup() { let psOutput = """ 301 1 /usr/bin/ssh -N -T -S none -R 127.0.0.1:56080:127.0.0.1:64048 cmux-macmini From d7fe0a11d466fd90f9add365b928cf4811b591a3 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Wed, 27 May 2026 11:14:48 -0700 Subject: [PATCH 27/69] fix: harden persistent daemon socket dir --- daemon/remote/cmd/cmuxd-remote/main.go | 158 ++++++++++++++++++-- daemon/remote/cmd/cmuxd-remote/main_test.go | 96 +++++++++++- 2 files changed, 243 insertions(+), 11 deletions(-) diff --git a/daemon/remote/cmd/cmuxd-remote/main.go b/daemon/remote/cmd/cmuxd-remote/main.go index 383a824d3348..24fda08997df 100644 --- a/daemon/remote/cmd/cmuxd-remote/main.go +++ b/daemon/remote/cmd/cmuxd-remote/main.go @@ -309,9 +309,10 @@ type persistentDaemonPaths struct { } const ( - persistentDaemonAuthMethod = "daemon.auth" - persistentDaemonReadyFDEnv = "CMUX_REMOTE_DAEMON_READY_FD" - persistentDaemonAuthTimeout = 5 * time.Second + persistentDaemonAuthMethod = "daemon.auth" + persistentDaemonReadyFDEnv = "CMUX_REMOTE_DAEMON_READY_FD" + persistentDaemonAuthTimeout = 5 * time.Second + persistentDaemonSocketDirFile = "socket-dir" ) var errPersistentDaemonAuthFailed = errors.New("persistent daemon authentication failed") @@ -421,18 +422,153 @@ func validatePersistentDaemonSlot(rawSlot string) (string, error) { return slot, nil } -func ensurePersistentDaemonDirectory(paths persistentDaemonPaths) error { +func ensurePersistentDaemonDirectory(paths persistentDaemonPaths) (persistentDaemonPaths, error) { if err := os.MkdirAll(paths.root, 0o700); err != nil { + return paths, err + } + if err := verifyPrivateDaemonDirectory(paths.root); err != nil { + return paths, err + } + socketDir := filepath.Dir(paths.socket) + secureSocketDir, err := ensurePersistentDaemonSocketDirectory(paths.root, socketDir) + if err != nil { + return paths, err + } + paths.socket = filepath.Join(secureSocketDir, filepath.Base(paths.socket)) + return paths, nil +} + +func ensurePersistentDaemonSocketDirectory(root string, defaultSocketDir string) (string, error) { + if storedSocketDir, err := readPersistentDaemonSocketDir(root); err == nil { + if verifyErr := ensurePrivateDaemonLeafDirectory(storedSocketDir); verifyErr == nil { + return storedSocketDir, nil + } + } else if !errors.Is(err, os.ErrNotExist) { + return "", err + } + + if err := ensurePrivateDaemonLeafDirectory(defaultSocketDir); err == nil { + return defaultSocketDir, nil + } + return createPersistentDaemonFallbackSocketDir(root) +} + +func ensurePrivateDaemonLeafDirectory(path string) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return err } - if err := os.Chmod(paths.root, 0o700); err != nil { + if err := os.Mkdir(path, 0o700); err != nil && !errors.Is(err, os.ErrExist) { return err } - socketDir := filepath.Dir(paths.socket) - if err := os.MkdirAll(socketDir, 0o700); err != nil { + return verifyPrivateDaemonDirectory(path) +} + +func verifyPrivateDaemonDirectory(path string) error { + info, err := os.Lstat(path) + if err != nil { + return err + } + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("persistent daemon directory %q is a symlink", path) + } + if !info.IsDir() { + return fmt.Errorf("persistent daemon directory %q is not a directory", path) + } + if !daemonDirectoryOwnedByCurrentUser(info) { + return fmt.Errorf("persistent daemon directory %q is not owned by uid %d", path, os.Getuid()) + } + if info.Mode().Perm() != 0o700 { + if err := os.Chmod(path, 0o700); err != nil { + return err + } + info, err = os.Lstat(path) + if err != nil { + return err + } + if info.Mode()&os.ModeSymlink != 0 || + !info.IsDir() || + !daemonDirectoryOwnedByCurrentUser(info) || + info.Mode().Perm() != 0o700 { + return fmt.Errorf("persistent daemon directory %q is not private", path) + } + } + return nil +} + +func daemonDirectoryOwnedByCurrentUser(info os.FileInfo) bool { + stat, ok := info.Sys().(*syscall.Stat_t) + return !ok || int(stat.Uid) == os.Getuid() +} + +func readPersistentDaemonSocketDir(root string) (string, error) { + data, err := os.ReadFile(filepath.Join(root, persistentDaemonSocketDirFile)) + if err != nil { + return "", err + } + socketDir := strings.TrimSpace(string(data)) + if socketDir == "" { + return "", errors.New("persistent daemon socket directory file is empty") + } + return socketDir, nil +} + +func createPersistentDaemonFallbackSocketDir(root string) (string, error) { + for attempt := 0; attempt < 8; attempt++ { + raw := make([]byte, 8) + if _, err := rand.Read(raw); err != nil { + return "", err + } + socketDir := filepath.Join( + os.TempDir(), + fmt.Sprintf("cmuxd-remote-%d-%s", os.Getuid(), hex.EncodeToString(raw)), + ) + if err := os.Mkdir(socketDir, 0o700); err != nil { + if errors.Is(err, os.ErrExist) { + continue + } + return "", err + } + if err := writePersistentDaemonSocketDir(root, socketDir); err != nil { + _ = os.Remove(socketDir) + if errors.Is(err, os.ErrExist) { + if storedSocketDir, readErr := readPersistentDaemonSocketDir(root); readErr == nil { + if verifyErr := ensurePrivateDaemonLeafDirectory(storedSocketDir); verifyErr == nil { + return storedSocketDir, nil + } + } + continue + } + return "", err + } + return socketDir, nil + } + return "", errors.New("failed to create private persistent daemon socket directory") +} + +func writePersistentDaemonSocketDir(root string, socketDir string) error { + file, err := os.CreateTemp(root, ".socket-dir.*.tmp") + if err != nil { return err } - return os.Chmod(socketDir, 0o700) + tmpPath := file.Name() + closeOK := false + defer func() { + if !closeOK { + _ = file.Close() + } + _ = os.Remove(tmpPath) + }() + if err := file.Chmod(0o600); err != nil { + return err + } + if _, err := file.WriteString(socketDir + "\n"); err != nil { + return err + } + if err := file.Close(); err != nil { + return err + } + closeOK = true + return os.Link(tmpPath, filepath.Join(root, persistentDaemonSocketDirFile)) } func persistentDaemonToken(paths persistentDaemonPaths) (string, error) { @@ -493,7 +629,8 @@ func runPersistentStdioProxy(stdin io.Reader, stdout, stderr io.Writer, slot str if err != nil { return err } - if err := ensurePersistentDaemonDirectory(paths); err != nil { + paths, err = ensurePersistentDaemonDirectory(paths) + if err != nil { return err } token, err := persistentDaemonToken(paths) @@ -671,7 +808,8 @@ func runPersistentDaemonServer(slot string, stderr io.Writer) error { if err != nil { return err } - if err := ensurePersistentDaemonDirectory(paths); err != nil { + paths, err = ensurePersistentDaemonDirectory(paths) + if err != nil { return err } token, err := persistentDaemonToken(paths) diff --git a/daemon/remote/cmd/cmuxd-remote/main_test.go b/daemon/remote/cmd/cmuxd-remote/main_test.go index c4c41fdcce59..5d3dc2799ab0 100644 --- a/daemon/remote/cmd/cmuxd-remote/main_test.go +++ b/daemon/remote/cmd/cmuxd-remote/main_test.go @@ -7,6 +7,7 @@ import ( "encoding/base64" "encoding/json" "errors" + "fmt" "io" "math" "net" @@ -391,7 +392,8 @@ func TestPersistentDaemonSocketDirOverrideUsesPrivateChild(t *testing.T) { t.Fatalf("socket dir parent = %q, want %q", filepath.Dir(socketDir), socketParent) } - if err := ensurePersistentDaemonDirectory(paths); err != nil { + paths, err = ensurePersistentDaemonDirectory(paths) + if err != nil { t.Fatalf("ensurePersistentDaemonDirectory returned error: %v", err) } parentInfo, err := os.Stat(socketParent) @@ -410,6 +412,98 @@ func TestPersistentDaemonSocketDirOverrideUsesPrivateChild(t *testing.T) { } } +func TestPersistentDaemonSocketDirFallsBackFromUnsafeSymlink(t *testing.T) { + rootBase := filepath.Join(t.TempDir(), "daemon-root") + socketParent := filepath.Join(t.TempDir(), "caller-socket-dir") + if err := os.MkdirAll(socketParent, 0o755); err != nil { + t.Fatalf("create socket parent: %v", err) + } + unsafeTarget := filepath.Join(t.TempDir(), "attacker-dir") + if err := os.MkdirAll(unsafeTarget, 0o755); err != nil { + t.Fatalf("create unsafe target: %v", err) + } + unsafeChild := filepath.Join(socketParent, fmt.Sprintf("cmuxd-remote-%d", os.Getuid())) + if err := os.Symlink(unsafeTarget, unsafeChild); err != nil { + t.Fatalf("create unsafe socket child symlink: %v", err) + } + t.Setenv("CMUX_REMOTE_DAEMON_ROOT", rootBase) + t.Setenv("CMUX_REMOTE_DAEMON_SOCKET_DIR", socketParent) + + paths, err := persistentDaemonPathsForSlot("unsafe-socket-slot") + if err != nil { + t.Fatalf("persistentDaemonPathsForSlot returned error: %v", err) + } + unsafeSocketDir := filepath.Dir(paths.socket) + if unsafeSocketDir != unsafeChild { + t.Fatalf("precondition failed: socket dir = %q, want unsafe child %q", unsafeSocketDir, unsafeChild) + } + + paths, err = ensurePersistentDaemonDirectory(paths) + if err != nil { + t.Fatalf("ensurePersistentDaemonDirectory returned error: %v", err) + } + socketDir := filepath.Dir(paths.socket) + if socketDir == unsafeChild { + t.Fatalf("socket dir still points at unsafe child %q", socketDir) + } + if filepath.Clean(filepath.Dir(socketDir)) != filepath.Clean(os.TempDir()) { + t.Fatalf("fallback socket dir parent = %q, want %q", filepath.Dir(socketDir), os.TempDir()) + } + info, err := os.Lstat(socketDir) + if err != nil { + t.Fatalf("stat fallback socket dir: %v", err) + } + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + t.Fatalf("fallback socket dir should be a real directory, got mode %v", info.Mode()) + } + if info.Mode().Perm() != 0o700 { + t.Fatalf("fallback socket dir mode = %o, want 700", info.Mode().Perm()) + } + storedSocketDir, err := readPersistentDaemonSocketDir(paths.root) + if err != nil { + t.Fatalf("read stored fallback socket dir: %v", err) + } + if storedSocketDir != socketDir { + t.Fatalf("stored socket dir = %q, want %q", storedSocketDir, socketDir) + } +} + +func TestPersistentDaemonSocketDirReusesStoredFallback(t *testing.T) { + rootBase := filepath.Join(t.TempDir(), "daemon-root") + socketParent := filepath.Join(t.TempDir(), "caller-socket-dir") + if err := os.MkdirAll(socketParent, 0o755); err != nil { + t.Fatalf("create socket parent: %v", err) + } + unsafeChild := filepath.Join(socketParent, fmt.Sprintf("cmuxd-remote-%d", os.Getuid())) + if err := os.WriteFile(unsafeChild, []byte("not a directory"), 0o600); err != nil { + t.Fatalf("create unsafe socket child file: %v", err) + } + t.Setenv("CMUX_REMOTE_DAEMON_ROOT", rootBase) + t.Setenv("CMUX_REMOTE_DAEMON_SOCKET_DIR", socketParent) + + paths, err := persistentDaemonPathsForSlot("stored-fallback-slot") + if err != nil { + t.Fatalf("persistentDaemonPathsForSlot returned error: %v", err) + } + paths, err = ensurePersistentDaemonDirectory(paths) + if err != nil { + t.Fatalf("ensurePersistentDaemonDirectory returned error: %v", err) + } + firstSocketDir := filepath.Dir(paths.socket) + + nextPaths, err := persistentDaemonPathsForSlot("stored-fallback-slot") + if err != nil { + t.Fatalf("persistentDaemonPathsForSlot returned error: %v", err) + } + nextPaths, err = ensurePersistentDaemonDirectory(nextPaths) + if err != nil { + t.Fatalf("second ensurePersistentDaemonDirectory returned error: %v", err) + } + if filepath.Dir(nextPaths.socket) != firstSocketDir { + t.Fatalf("second socket dir = %q, want stored fallback %q", filepath.Dir(nextPaths.socket), firstSocketDir) + } +} + func TestPersistentDaemonTokenConcurrentCreate(t *testing.T) { root := t.TempDir() paths := persistentDaemonPaths{ From 4f7897c07018f4f24dcd55f99f8fd9c4af247749 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Wed, 27 May 2026 14:16:02 -0700 Subject: [PATCH 28/69] test: cover ssh pty reattach command restore --- ...ifyProcessIntegrationRegressionTests.swift | 1 + cmuxTests/GhosttyConfigTests.swift | 51 +++++++++++++++++++ .../TabManagerSessionSnapshotTests.swift | 7 ++- 3 files changed, 58 insertions(+), 1 deletion(-) diff --git a/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift b/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift index e6e8c5e3489e..fd6ca24dd351 100644 --- a/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift +++ b/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift @@ -3213,6 +3213,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertEqual(params["remote_pty_session_id"] as? String, sessionId) XCTAssertEqual(params["focus"] as? Bool, true) let initialCommand = params["initial_command"] as? String ?? "" + XCTAssertTrue(initialCommand.hasPrefix("/bin/sh -c "), initialCommand) XCTAssertTrue(initialCommand.contains("ssh-pty-attach"), initialCommand) XCTAssertTrue(initialCommand.contains("--require-existing"), initialCommand) XCTAssertTrue(initialCommand.contains(sessionId), initialCommand) diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index fd523a5cc95a..d7063f1d625d 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -51,6 +51,57 @@ final class GhosttyConfigTests: XCTestCase { let blue: Int } + func testLaunchGhosttyResourcesPreferCurrentBundleOverInheritedEnvironment() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cmux-ghostty-launch-resources-\(UUID().uuidString)") + try fileManager.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: root) } + + let inheritedResources = root.appendingPathComponent("inherited/ghostty", isDirectory: true) + let bundleResources = root.appendingPathComponent("BundleResources", isDirectory: true) + let bundledGhostty = bundleResources.appendingPathComponent("ghostty", isDirectory: true) + try fileManager.createDirectory( + at: inheritedResources.appendingPathComponent("themes", isDirectory: true), + withIntermediateDirectories: true + ) + try fileManager.createDirectory( + at: bundledGhostty.appendingPathComponent("themes", isDirectory: true), + withIntermediateDirectories: true + ) + + let resolved = cmuxApp.resolvedGhosttyResourcesDirectory( + currentValue: inheritedResources.path, + bundleResourceURL: bundleResources, + ghosttyAppResources: root.appendingPathComponent("missing", isDirectory: true).path, + fileManager: fileManager + ) + + XCTAssertEqual(resolved, bundledGhostty.path) + } + + func testLaunchGhosttyResourcesKeepInheritedEnvironmentWhenBundleHasNoResources() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cmux-ghostty-launch-resource-fallback-\(UUID().uuidString)") + try fileManager.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: root) } + + let inheritedResources = root.appendingPathComponent("inherited/ghostty", isDirectory: true) + let emptyBundleResources = root.appendingPathComponent("BundleResources", isDirectory: true) + try fileManager.createDirectory(at: inheritedResources, withIntermediateDirectories: true) + try fileManager.createDirectory(at: emptyBundleResources, withIntermediateDirectories: true) + + let resolved = cmuxApp.resolvedGhosttyResourcesDirectory( + currentValue: inheritedResources.path, + bundleResourceURL: emptyBundleResources, + ghosttyAppResources: root.appendingPathComponent("missing", isDirectory: true).path, + fileManager: fileManager + ) + + XCTAssertEqual(resolved, inheritedResources.path) + } + func testResolveThemeNamePrefersLightEntryForPairedTheme() { let resolved = GhosttyConfig.resolveThemeName( from: "light:Builtin Solarized Light,dark:Builtin Solarized Dark", diff --git a/cmuxTests/TabManagerSessionSnapshotTests.swift b/cmuxTests/TabManagerSessionSnapshotTests.swift index 1dd6b7f352ce..3569afaf34c6 100644 --- a/cmuxTests/TabManagerSessionSnapshotTests.swift +++ b/cmuxTests/TabManagerSessionSnapshotTests.swift @@ -1823,6 +1823,7 @@ final class TabManagerSessionSnapshotTests: XCTestCase { 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) @@ -1833,6 +1834,7 @@ final class TabManagerSessionSnapshotTests: XCTestCase { 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) @@ -1975,11 +1977,13 @@ final class TabManagerSessionSnapshotTests: XCTestCase { 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: Workspace.sshPTYAttachStartupCommand(sessionID: sessionID), + initialCommand: attachStartupCommand, remotePTYSessionID: sessionID ) ) @@ -2063,6 +2067,7 @@ final class TabManagerSessionSnapshotTests: XCTestCase { 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) From f94e6faf74229a8b0089e63b266e6c56bea51f18 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Wed, 27 May 2026 14:16:06 -0700 Subject: [PATCH 29/69] fix: execute persisted ssh pty attach scripts --- CLI/cmux.swift | 3 +- Sources/TabManager.swift | 9 +-- Sources/WorkspaceRemoteConfiguration.swift | 2 +- Sources/cmuxApp.swift | 66 +++++++++++++++------- 4 files changed, 51 insertions(+), 29 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index fe725ac6f4d2..1ae37672d087 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -9179,7 +9179,7 @@ struct CMUXCLI { let quotedSessionID = shellQuote(sessionID) let currentExecutable = shellQuote(resolvedExecutableURL()?.path ?? (args.first ?? "cmux")) let attachCommand = "\"$cmux_ssh_attach_cli\" --socket \"$CMUX_SOCKET_PATH\" ssh-pty-attach --wait --require-existing --workspace \"$CMUX_WORKSPACE_ID\" --session-id \(quotedSessionID) --attachment-id \"${CMUX_SURFACE_ID:-}\"" - return ([ + let script = ([ "cmux_ssh_attach_cli=\"${CMUX_BUNDLED_CLI_PATH:-}\"", "if [ -z \"$cmux_ssh_attach_cli\" ] || [ ! -x \"$cmux_ssh_attach_cli\" ]; then cmux_ssh_attach_cli=\(currentExecutable); fi", "if [ -z \"$cmux_ssh_attach_cli\" ] || [ ! -x \"$cmux_ssh_attach_cli\" ]; then cmux_ssh_attach_cli=\"$(command -v cmux 2>/dev/null || true)\"; fi", @@ -9187,6 +9187,7 @@ struct CMUXCLI { "if [ -z \"${CMUX_SOCKET_PATH:-}\" ]; then printf '%s\\n' '[cmux] required configuration missing for SSH PTY attach.' >&2; exit 1; fi", "if [ -z \"${CMUX_WORKSPACE_ID:-}\" ]; then printf '%s\\n' '[cmux] required workspace context missing for SSH PTY attach.' >&2; exit 1; fi", ] + sshPTYAttachRetryLoopLines(command: attachCommand)).joined(separator: "\n") + return "/bin/sh -c \(shellQuote(script))" } private func sshPTYAttachRetryLoopLines(command: String) -> [String] { diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index e485a1b7ef7e..2d8d13173061 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -6512,13 +6512,10 @@ class TabManager: ObservableObject { ) #endif - // Exiting the last SSH surface should demote the workspace back to a local one. - // Route through Workspace close handling so remote teardown and replacement-panel - // logic run before TabManager considers removing the workspace itself, including - // session-end paths where remote configuration was cleared before Ghostty delivered - // the child-exit callback. + // A persistent SSH workspace must never silently replace a failed remote attach with + // a local login shell. Keep the exited surface visible so the user can see the error + // and retry instead of making a detached remote workspace look local after relaunch. if keepsRemoteWorkspaceOpen { - closeRuntimeSurface(tabId: tabId, surfaceId: surfaceId) return } diff --git a/Sources/WorkspaceRemoteConfiguration.swift b/Sources/WorkspaceRemoteConfiguration.swift index a3dd73c446dc..a4ca6087f23b 100644 --- a/Sources/WorkspaceRemoteConfiguration.swift +++ b/Sources/WorkspaceRemoteConfiguration.swift @@ -142,7 +142,7 @@ nonisolated enum SSHPTYAttachStartupCommandBuilder { let requireExistingFlag = requireExisting ? " --require-existing" : "" let attachCommand = "\"$cmux_ssh_attach_cli\" --socket \"$CMUX_SOCKET_PATH\" ssh-pty-attach --wait\(requireExistingFlag) --workspace \"$CMUX_WORKSPACE_ID\" --session-id \"$cmux_ssh_attach_session_id\" --attachment-id \"${CMUX_SURFACE_ID:-}\"" lines += retryingAttachLines(command: attachCommand) - return lines.joined(separator: "\n") + return "/bin/sh -c \(shellQuote(lines.joined(separator: "\n")))" } private static func retryingAttachLines(command: String) -> [String] { diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 6f111508d9a5..b121ca4703f1 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -106,24 +106,18 @@ struct cmuxApp: App { private static func configureGhosttyEnvironment() { let fileManager = FileManager.default - let ghosttyAppResources = "/Applications/Ghostty.app/Contents/Resources/ghostty" - let bundledGhosttyURL = Bundle.main.resourceURL?.appendingPathComponent("ghostty") - var resolvedResourcesDir: String? - - if getenv("GHOSTTY_RESOURCES_DIR") == nil { - if let bundledGhosttyURL, - fileManager.fileExists(atPath: bundledGhosttyURL.path), - fileManager.fileExists(atPath: bundledGhosttyURL.appendingPathComponent("themes").path) { - resolvedResourcesDir = bundledGhosttyURL.path - } else if fileManager.fileExists(atPath: ghosttyAppResources) { - resolvedResourcesDir = ghosttyAppResources - } else if let bundledGhosttyURL, fileManager.fileExists(atPath: bundledGhosttyURL.path) { - resolvedResourcesDir = bundledGhosttyURL.path - } + let currentResourcesDir = getenv("GHOSTTY_RESOURCES_DIR").flatMap { String(cString: $0) } + if let resolvedResourcesDir = resolvedGhosttyResourcesDirectory( + currentValue: currentResourcesDir, + bundleResourceURL: Bundle.main.resourceURL, + fileManager: fileManager + ) { + setenv("GHOSTTY_RESOURCES_DIR", resolvedResourcesDir, 1) + } - if let resolvedResourcesDir { - setenv("GHOSTTY_RESOURCES_DIR", resolvedResourcesDir, 1) - } + if let terminfoURL = Bundle.main.resourceURL?.appendingPathComponent("terminfo"), + fileManager.fileExists(atPath: terminfoURL.path) { + setenv("TERMINFO", terminfoURL.path, 1) } if getenv("TERM") == nil { @@ -144,16 +138,46 @@ struct cmuxApp: App { let dataDir = resourcesParent.path let manDir = resourcesParent.appendingPathComponent("man").path - appendEnvPathIfMissing( + prependEnvPathIfMissing( "XDG_DATA_DIRS", path: dataDir, defaultValue: "/usr/local/share:/usr/share" ) - appendEnvPathIfMissing("MANPATH", path: manDir) + prependEnvPathIfMissing("MANPATH", path: manDir) + } + } + + static func resolvedGhosttyResourcesDirectory( + currentValue: String?, + bundleResourceURL: URL?, + ghosttyAppResources: String = "/Applications/Ghostty.app/Contents/Resources/ghostty", + fileManager: FileManager = .default + ) -> String? { + let bundledGhosttyURL = bundleResourceURL?.appendingPathComponent("ghostty") + if let bundledGhosttyURL, + fileManager.fileExists(atPath: bundledGhosttyURL.path), + fileManager.fileExists(atPath: bundledGhosttyURL.appendingPathComponent("themes").path) { + return bundledGhosttyURL.path } + if let bundledGhosttyURL, + fileManager.fileExists(atPath: bundledGhosttyURL.path) { + return bundledGhosttyURL.path + } + + if let currentValue = currentValue?.trimmingCharacters(in: .whitespacesAndNewlines), + !currentValue.isEmpty, + fileManager.fileExists(atPath: currentValue) { + return currentValue + } + + if fileManager.fileExists(atPath: ghosttyAppResources) { + return ghosttyAppResources + } + + return nil } - private static func appendEnvPathIfMissing(_ key: String, path: String, defaultValue: String? = nil) { + private static func prependEnvPathIfMissing(_ key: String, path: String, defaultValue: String? = nil) { if path.isEmpty { return } var current = getenv(key).flatMap { String(cString: $0) } ?? "" if current.isEmpty, let defaultValue { @@ -162,7 +186,7 @@ struct cmuxApp: App { if current.split(separator: ":").contains(Substring(path)) { return } - let updated = current.isEmpty ? path : "\(current):\(path)" + let updated = current.isEmpty ? path : "\(path):\(current)" setenv(key, updated, 1) } From 86f7874d06591a42aa30eaa8f590966edce35044 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Wed, 27 May 2026 14:32:14 -0700 Subject: [PATCH 30/69] fix: keep only persistent ssh pty exits visible --- Sources/TabManager.swift | 17 ++++++++++-- cmuxTests/TabManagerUnitTests.swift | 43 +++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 232155b744c3..d441f74c78c6 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -6565,21 +6565,32 @@ class TabManager: ObservableObject { func closePanelAfterChildExited(tabId: UUID, surfaceId: UUID) { guard let tab = tabs.first(where: { $0.id == tabId }) else { return } guard tab.panels[surfaceId] != nil else { return } - let keepsRemoteWorkspaceOpen = + let handlesRemoteExitThroughWorkspace = tab.panels.count <= 1 && tab.shouldDemoteWorkspaceAfterChildExit(surfaceId: surfaceId) + let keepsPersistentRemoteWorkspaceOpen = + handlesRemoteExitThroughWorkspace && tab.remoteConfiguration?.preserveAfterTerminalExit == true #if DEBUG cmuxDebugLog( "surface.close.childExited tab=\(tabId.uuidString.prefix(5)) " + "surface=\(surfaceId.uuidString.prefix(5)) panels=\(tab.panels.count) workspaces=\(tabs.count) " + - "remoteWorkspace=\(tab.isRemoteWorkspace ? 1 : 0) keepRemote=\(keepsRemoteWorkspaceOpen ? 1 : 0)" + "remoteWorkspace=\(tab.isRemoteWorkspace ? 1 : 0) keepRemote=\(handlesRemoteExitThroughWorkspace ? 1 : 0) " + + "keepPersistentRemote=\(keepsPersistentRemoteWorkspaceOpen ? 1 : 0)" ) #endif // A persistent SSH workspace must never silently replace a failed remote attach with // a local login shell. Keep the exited surface visible so the user can see the error // and retry instead of making a detached remote workspace look local after relaunch. - if keepsRemoteWorkspaceOpen { + if keepsPersistentRemoteWorkspaceOpen { + return + } + + // Exiting the last non-persistent SSH surface should demote the workspace back to a + // local one. Route through Workspace close handling so remote teardown and replacement + // panel logic run before TabManager considers removing the workspace itself. + if handlesRemoteExitThroughWorkspace { + closeRuntimeSurface(tabId: tabId, surfaceId: surfaceId) return } diff --git a/cmuxTests/TabManagerUnitTests.swift b/cmuxTests/TabManagerUnitTests.swift index 6cdb5327199b..84ccf7b86f8d 100644 --- a/cmuxTests/TabManagerUnitTests.swift +++ b/cmuxTests/TabManagerUnitTests.swift @@ -236,6 +236,49 @@ final class TabManagerChildExitCloseTests: XCTestCase { XCTAssertEqual(workspace.activeRemoteTerminalSessionCount, 0) } + func testChildExitOnLastPersistentRemotePanelKeepsExitedSurfaceVisible() throws { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let remotePanelId = workspace.focusedPanelId else { + XCTFail("Expected selected workspace with focused panel") + return + } + + workspace.configureRemoteConnection( + WorkspaceRemoteConfiguration( + destination: "cmux-macmini", + port: nil, + identityFile: nil, + sshOptions: [], + localProxyPort: nil, + relayPort: 64017, + relayID: String(repeating: "a", count: 16), + relayToken: String(repeating: "b", count: 64), + localSocketPath: "/tmp/cmux-debug-test.sock", + terminalStartupCommand: SSHPTYAttachStartupCommandBuilder.command(), + preserveAfterTerminalExit: true, + persistentDaemonSlot: "ssh-child-exit-test" + ), + autoConnect: false + ) + + XCTAssertTrue(workspace.isRemoteWorkspace) + XCTAssertTrue(workspace.isRemoteTerminalSurface(remotePanelId)) + + manager.closePanelAfterChildExited(tabId: workspace.id, surfaceId: remotePanelId) + drainMainQueue() + drainMainQueue() + + XCTAssertEqual(manager.tabs.count, 1) + XCTAssertEqual(manager.selectedTabId, workspace.id) + XCTAssertEqual(manager.tabs.first?.id, workspace.id) + XCTAssertTrue(workspace.isRemoteWorkspace) + XCTAssertNotNil(workspace.panels[remotePanelId]) + XCTAssertEqual(workspace.panels.count, 1) + XCTAssertEqual(workspace.focusedPanelId, remotePanelId) + XCTAssertEqual(workspace.activeRemoteTerminalSessionCount, 1) + } + func testChildExitAfterRemoteSessionEndKeepsWorkspaceAndDemotesToLocal() throws { let manager = TabManager() guard let workspace = manager.selectedWorkspace, From 5ea7ba73439ca0a210a0810df47052fd762e78d0 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Wed, 27 May 2026 14:38:23 -0700 Subject: [PATCH 31/69] fix: harden ssh pty restore fallbacks --- Sources/Workspace.swift | 12 ++++---- Sources/WorkspaceRemoteConfiguration.swift | 9 ++++-- Sources/cmuxApp.swift | 2 ++ cmuxTests/GhosttyConfigTests.swift | 17 +++++++++++ .../TabManagerSessionSnapshotTests.swift | 30 +++++++++++++++++++ 5 files changed, 61 insertions(+), 9 deletions(-) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 24abf31e0f8e..5384d51db3bc 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -8045,11 +8045,7 @@ final class WorkspaceRemoteSessionController { ) -> [Int] { let trimmedDestination = destination.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedDestination.isEmpty else { return [] } - let trimmedPersistentDaemonSlot: String? = { - guard let persistentDaemonSlot else { return nil } - let trimmed = persistentDaemonSlot.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? nil : trimmed - }() + let trimmedPersistentDaemonSlot = persistentDaemonSlot return psOutput .split(separator: "\n", omittingEmptySubsequences: false) @@ -8213,7 +8209,11 @@ final class WorkspaceRemoteSessionController { return nextNonShellEscapeToken(after: index, in: tokens) == slot } if token.hasPrefix("--slot=") { - return String(token.dropFirst("--slot=".count)) == slot + let slotValue = String(token.dropFirst("--slot=".count)) + if !slotValue.isEmpty { + return slotValue == slot + } + return nextNonShellEscapeToken(after: index, in: tokens) == slot } } return false diff --git a/Sources/WorkspaceRemoteConfiguration.swift b/Sources/WorkspaceRemoteConfiguration.swift index a4ca6087f23b..b443d2961701 100644 --- a/Sources/WorkspaceRemoteConfiguration.swift +++ b/Sources/WorkspaceRemoteConfiguration.swift @@ -420,13 +420,16 @@ extension SessionRemoteWorkspaceSnapshot { let normalizedRelayPort = relayPort.flatMap { port in (1...65535).contains(port) ? port : nil } - let normalizedOptions = preserveSSHOptions + let preservedOptions = preserveSSHOptions ? WorkspaceRemoteSSHOptionFilter.trimmedOptions(sshOptions) : Self.normalizedSSHOptions(sshOptions) let optionsWithRestoreControlDefaults = SSHPTYAttachStartupCommandBuilder.sshOptionsWithRestoreControlDefaults( - normalizedOptions, + preservedOptions, relayPort: normalizedRelayPort ) + let fallbackSSHOptions = preserveSSHOptions + ? Self.normalizedSSHOptions(preservedOptions) + : preservedOptions let preservePTYSession = allowPersistentPTYRestore && preserveAfterTerminalExit == true && @@ -435,7 +438,7 @@ extension SessionRemoteWorkspaceSnapshot { normalizedLocalSocketPath != nil && normalizedRelayPort != nil && SSHPTYAttachStartupCommandBuilder.sshOptionsSupportReusableForegroundAuth(optionsWithRestoreControlDefaults) - let restoredSSHOptions = preservePTYSession ? optionsWithRestoreControlDefaults : normalizedOptions + let restoredSSHOptions = preservePTYSession ? optionsWithRestoreControlDefaults : fallbackSSHOptions let foregroundAuthToken = preservePTYSession ? UUID().uuidString.lowercased() : nil let foregroundAuth = foregroundAuthToken.map { SSHPTYAttachStartupCommandBuilder.ForegroundAuth( diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 6d8630722954..35c7dc838fd1 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -154,6 +154,8 @@ struct cmuxApp: App { fileManager: FileManager = .default ) -> String? { let bundledGhosttyURL = bundleResourceURL?.appendingPathComponent("ghostty") + // Tagged cmux builds may inherit GHOSTTY_RESOURCES_DIR from another running + // cmux instance. Prefer this app's bundled resources when they are present. if let bundledGhosttyURL, fileManager.fileExists(atPath: bundledGhosttyURL.path), fileManager.fileExists(atPath: bundledGhosttyURL.appendingPathComponent("themes").path) { diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index d7063f1d625d..cacc1971a91d 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -2548,6 +2548,23 @@ final class WorkspaceRemoteSSHCleanupTests: XCTestCase { ) } + func testOrphanedCMUXRemoteSSHPIDsMatchesEqualsQuotedPersistentDaemonSlot() { + let psOutput = """ + 221 1 /usr/bin/ssh -T -S none -o RequestTTY=no cmux-macmini sh -c 'exec .cmux/bin/cmuxd-remote serve --stdio --persistent --slot='ssh-test'' + 222 1 /usr/bin/ssh -T -S none -o RequestTTY=no cmux-macmini sh -c 'exec .cmux/bin/cmuxd-remote serve --stdio --persistent --slot="ssh-other"' + """ + + XCTAssertEqual( + WorkspaceRemoteSessionController.orphanedCMUXRemoteSSHPIDs( + psOutput: psOutput, + destination: "cmux-macmini", + relayPort: 56081, + persistentDaemonSlot: "ssh-test" + ), + [221] + ) + } + func testOrphanedCMUXRemoteSSHPIDsWithSlotAndNoRelayKeepsGenericCleanup() { let psOutput = """ 301 1 /usr/bin/ssh -N -T -S none -R 127.0.0.1:56080:127.0.0.1:64048 cmux-macmini diff --git a/cmuxTests/TabManagerSessionSnapshotTests.swift b/cmuxTests/TabManagerSessionSnapshotTests.swift index 3569afaf34c6..c6abed5477ea 100644 --- a/cmuxTests/TabManagerSessionSnapshotTests.swift +++ b/cmuxTests/TabManagerSessionSnapshotTests.swift @@ -2334,6 +2334,36 @@ final class TabManagerSessionSnapshotTests: XCTestCase { XCTAssertEqual(configuration.terminalStartupCommand, "ssh -p 2222 -o StrictHostKeyChecking=accept-new -tt dev@example.com") } + func testSessionRemoteWorkspaceSnapshotStripsTransientControlOptionsWhenPreservedRestoreFallsBack() throws { + 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, + "ssh -p 2222 -o StrictHostKeyChecking=accept-new -tt dev@example.com" + ) + } + func testSessionRemoteWorkspaceSnapshotRequiresValidPersistentDaemonSlotForPTYRestore() throws { let snapshot = SessionRemoteWorkspaceSnapshot( transport: .ssh, From 64e92052aa12f085a60ecc35855e55b73cefc214 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Wed, 27 May 2026 15:07:38 -0700 Subject: [PATCH 32/69] fix: restore ssh pty pane bootstrap --- Sources/RemoteRelayZshBootstrap.swift | 267 ++++++++++++++++++ Sources/WorkspaceRemoteConfiguration.swift | 23 +- Sources/cmuxApp.swift | 9 +- cmuxTests/GhosttyConfigTests.swift | 47 +++ .../TabManagerSessionSnapshotTests.swift | 26 ++ 5 files changed, 367 insertions(+), 5 deletions(-) diff --git a/Sources/RemoteRelayZshBootstrap.swift b/Sources/RemoteRelayZshBootstrap.swift index 50dfb77f68eb..280eb82f587a 100644 --- a/Sources/RemoteRelayZshBootstrap.swift +++ b/Sources/RemoteRelayZshBootstrap.swift @@ -11,6 +11,273 @@ enum RemoteShellEnvironment { } } +enum RemoteInteractiveShellBootstrapBuilder { + static func script( + remoteRelayPort: Int, + shellFeatures: String, + terminfoSource: String? = nil, + bundledZshIntegration: String? = nil, + bundledBashIntegration: String? = nil + ) -> String { + let shellStateDir = shellStateDirForRemoteRelayPort(remoteRelayPort) + let commonShellExportLines = commonShellLines( + remoteRelayPort: remoteRelayPort, + shellStateDir: shellStateDir, + shellFeatures: shellFeatures, + terminfoSource: terminfoSource + ) + var zshShellLines = commonShellExportLines + zshShellLines.append( + #"if [ "${CMUX_SHELL_INTEGRATION:-1}" != "0" ] && [ -r "${CMUX_SHELL_INTEGRATION_DIR}/cmux-zsh-integration.zsh" ]; then . "${CMUX_SHELL_INTEGRATION_DIR}/cmux-zsh-integration.zsh"; fi"# + ) + var bashShellLines = commonShellExportLines + bashShellLines.append( + #"if [ "${CMUX_SHELL_INTEGRATION:-1}" != "0" ] && [ -r "${CMUX_SHELL_INTEGRATION_DIR}/cmux-bash-integration.bash" ]; then . "${CMUX_SHELL_INTEGRATION_DIR}/cmux-bash-integration.bash"; fi"# + ) + let zshBootstrap = RemoteRelayZshBootstrap(shellStateDir: shellStateDir) + let relayWarmupLines = relayWarmupLines(remoteRelayPort: remoteRelayPort) + + var outerLines: [String] = [ + "mkdir -p \"$HOME/.cmux/relay\"", + "cmux_shell_dir=\"\(shellStateDir)\"", + "mkdir -p \"$cmux_shell_dir\"", + ] + if let bundledZshIntegration { + outerLines += [ + "cat > \"$cmux_shell_dir/cmux-zsh-integration.zsh\" <<'CMUXCMUXZSH'", + bundledZshIntegration, + "CMUXCMUXZSH", + ] + } + if let bundledBashIntegration { + outerLines += [ + "cat > \"$cmux_shell_dir/cmux-bash-integration.bash\" <<'CMUXCMUXBASH'", + bundledBashIntegration, + "CMUXCMUXBASH", + ] + } + outerLines.append(contentsOf: commonShellExportLines) + outerLines += [ + "CMUX_LOGIN_SHELL=\"${SHELL:-/bin/zsh}\"", + "case \"${CMUX_LOGIN_SHELL##*/}\" in", + " zsh)", + " cat > \"$cmux_shell_dir/.zshenv\" <<'CMUXZSHENV'", + ] + outerLines.append(contentsOf: zshBootstrap.zshEnvLines) + outerLines += [ + "CMUXZSHENV", + " cat > \"$cmux_shell_dir/.zprofile\" <<'CMUXZSHPROFILE'", + ] + outerLines.append(contentsOf: zshBootstrap.zshProfileLines) + outerLines += [ + "CMUXZSHPROFILE", + " cat > \"$cmux_shell_dir/.zshrc\" <<'CMUXZSHRC'", + ] + outerLines.append(contentsOf: zshBootstrap.zshRCLines(commonShellLines: zshShellLines)) + outerLines += [ + "CMUXZSHRC", + " cat > \"$cmux_shell_dir/.zlogin\" <<'CMUXZSHLOGIN'", + ] + outerLines.append(contentsOf: zshBootstrap.zshLoginLines) + outerLines += [ + "CMUXZSHLOGIN", + " chmod 600 \"$cmux_shell_dir/.zshenv\" \"$cmux_shell_dir/.zprofile\" \"$cmux_shell_dir/.zshrc\" \"$cmux_shell_dir/.zlogin\" >/dev/null 2>&1 || true", + ] + outerLines.append(contentsOf: relayWarmupLines.map { " " + $0 }) + outerLines += [ + " export CMUX_REAL_ZDOTDIR=\"${ZDOTDIR:-$HOME}\"", + " export ZDOTDIR=\"$cmux_shell_dir\"", + " exec \"$CMUX_LOGIN_SHELL\" -il", + " ;;", + " bash)", + " cat > \"$cmux_shell_dir/.bashrc\" <<'CMUXBASHRC'", + ] + outerLines.append(contentsOf: [ + "if [ -f \"$HOME/.bash_profile\" ]; then . \"$HOME/.bash_profile\"; elif [ -f \"$HOME/.bash_login\" ]; then . \"$HOME/.bash_login\"; elif [ -f \"$HOME/.profile\" ]; then . \"$HOME/.profile\"; fi", + "[ -f \"$HOME/.bashrc\" ] && . \"$HOME/.bashrc\"", + ] + bashShellLines) + outerLines += [ + "CMUXBASHRC", + " chmod 600 \"$cmux_shell_dir/.bashrc\" >/dev/null 2>&1 || true", + ] + outerLines.append(contentsOf: relayWarmupLines.map { " " + $0 }) + outerLines += [ + " exec \"$CMUX_LOGIN_SHELL\" --rcfile \"$cmux_shell_dir/.bashrc\" -i", + " ;;", + " *)", + ] + outerLines.append(contentsOf: commonShellExportLines) + outerLines.append(contentsOf: relayWarmupLines) + outerLines += [ + "exec \"$CMUX_LOGIN_SHELL\" -i", + ";;", + "esac", + ] + + return outerLines.joined(separator: "\n") + } + + static func shellFeatures( + environment: [String: String] = ProcessInfo.processInfo.environment + ) -> String { + let rawExisting = environment["GHOSTTY_SHELL_FEATURES"] ?? "" + var seen: Set = [] + var merged: [String] = [] + + for token in rawExisting.split(separator: ",") { + let feature = token.trimmingCharacters(in: .whitespacesAndNewlines) + guard !feature.isEmpty else { continue } + if seen.insert(feature).inserted { + merged.append(feature) + } + } + + for required in ["ssh-env", "ssh-terminfo"] { + if seen.insert(required).inserted { + merged.append(required) + } + } + + return merged.joined(separator: ",") + } + + static func bundledShellIntegrationScript( + named fileName: String, + bundleResourceURL: URL? = Bundle.main.resourceURL, + fileManager: FileManager = .default + ) -> String? { + guard let bundleResourceURL else { return nil } + let url = bundleResourceURL + .appendingPathComponent("shell-integration", isDirectory: true) + .appendingPathComponent(fileName, isDirectory: false) + guard fileManager.fileExists(atPath: url.path), + let data = try? Data(contentsOf: url), + let contents = String(data: data, encoding: .utf8) else { + return nil + } + return contents + } + + private static func commonShellLines( + remoteRelayPort: Int, + shellStateDir: String, + shellFeatures: String, + terminfoSource: String? + ) -> [String] { + let relaySocket = remoteRelayPort > 0 ? "127.0.0.1:\(remoteRelayPort)" : nil + var lines = terminalSetupLines(terminfoSource: terminfoSource) + lines.append(contentsOf: RemoteShellEnvironment.utf8LocaleSetupLines()) + lines.append(contentsOf: shellExportLines(shellFeatures: shellFeatures)) + lines.append("export PATH=\"$HOME/.cmux/bin:$PATH\"") + lines.append("export CMUX_BUNDLED_CLI_PATH=\"$HOME/.cmux/bin/cmux\"") + lines.append("export CMUX_SHELL_INTEGRATION_DIR=\"\(shellStateDir)\"") + if let relaySocket { + lines.append("export CMUX_SOCKET_PATH=\(relaySocket)") + } + lines.append(contentsOf: [ + "if [ -n '__CMUX_WORKSPACE_ID__' ]; then export CMUX_WORKSPACE_ID='__CMUX_WORKSPACE_ID__'; fi", + "if [ -n '__CMUX_WORKSPACE_ID__' ]; then export CMUX_TAB_ID='__CMUX_WORKSPACE_ID__'; fi", + "if [ -n '__CMUX_SURFACE_ID__' ]; then export CMUX_SURFACE_ID='__CMUX_SURFACE_ID__'; export CMUX_PANEL_ID='__CMUX_SURFACE_ID__'; fi", + "hash -r >/dev/null 2>&1 || true", + "rehash >/dev/null 2>&1 || true", + ]) + return lines + } + + private static func terminalSetupLines(terminfoSource: String?) -> [String] { + var lines: [String] = [ + "cmux_term='xterm-256color'", + "if command -v infocmp >/dev/null 2>&1 && infocmp xterm-ghostty >/dev/null 2>&1; then", + " cmux_term='xterm-ghostty'", + "fi", + "export TERM=\"$cmux_term\"", + ] + guard let terminfoSource else { return lines } + let trimmedTerminfoSource = terminfoSource.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedTerminfoSource.isEmpty else { return lines } + lines += [ + "if [ \"$cmux_term\" != 'xterm-ghostty' ]; then", + " (", + " command -v tic >/dev/null 2>&1 || exit 0", + " mkdir -p \"$HOME/.terminfo\" 2>/dev/null || exit 0", + " cat <<'CMUXTERMINFO' | tic -x - >/dev/null 2>&1", + trimmedTerminfoSource, + "CMUXTERMINFO", + " ) >/dev/null 2>&1 &", + "fi", + ] + return lines + } + + private static func shellExportLines(shellFeatures: String) -> [String] { + let environment = ProcessInfo.processInfo.environment + let colorTerm = normalizedEnvValue(environment["COLORTERM"]) ?? "truecolor" + let termProgram = normalizedEnvValue(environment["TERM_PROGRAM"]) ?? "ghostty" + let termProgramVersion = normalizedEnvValue(environment["TERM_PROGRAM_VERSION"]) + ?? (Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String) + ?? "" + let trimmedShellFeatures = shellFeatures.trimmingCharacters(in: .whitespacesAndNewlines) + + var exports: [String] = [ + "export COLORTERM=\(shellQuote(colorTerm))", + "export TERM_PROGRAM=\(shellQuote(termProgram))", + ] + if !termProgramVersion.isEmpty { + exports.append("export TERM_PROGRAM_VERSION=\(shellQuote(termProgramVersion))") + } + if !trimmedShellFeatures.isEmpty { + exports.append("export GHOSTTY_SHELL_FEATURES=\(shellQuote(trimmedShellFeatures))") + } + return exports + } + + private static func relayWarmupLines(remoteRelayPort: Int) -> [String] { + guard remoteRelayPort > 0 else { + return [] + } + return [ + "cmux_relay_cli=\"${CMUX_BUNDLED_CLI_PATH:-$HOME/.cmux/bin/cmux}\"", + "if [ ! -x \"$cmux_relay_cli\" ]; then cmux_relay_cli=\"$(command -v cmux 2>/dev/null || true)\"; fi", + "cmux_relay_tty=\"${CMUX_BOOTSTRAP_TTY:-}\"", + "if [ -z \"$cmux_relay_tty\" ]; then cmux_relay_tty=\"$(tty 2>/dev/null || true)\"; fi", + "cmux_relay_tty=\"${cmux_relay_tty##*/}\"", + "if [ -n \"$cmux_relay_tty\" ] && [ \"$cmux_relay_tty\" != \"not a tty\" ]; then", + " mkdir -p \"$HOME/.cmux/relay\" >/dev/null 2>&1 || true", + " printf '%s' \"$cmux_relay_tty\" > \"$HOME/.cmux/relay/\(remoteRelayPort).tty\" 2>/dev/null || true", + "fi", + "if [ -n \"$cmux_relay_cli\" ] && [ -n \"$CMUX_WORKSPACE_ID\" ] && [ -n \"$cmux_relay_tty\" ] && [ \"$cmux_relay_tty\" != \"not a tty\" ]; then", + " cmux_relay_report_tty=\"{\\\"workspace_id\\\":\\\"$CMUX_WORKSPACE_ID\\\",\\\"tty_name\\\":\\\"$cmux_relay_tty\\\"}\"", + " cmux_relay_ports_kick=\"{\\\"workspace_id\\\":\\\"$CMUX_WORKSPACE_ID\\\",\\\"reason\\\":\\\"command\\\"}\"", + " if [ -n \"$CMUX_SURFACE_ID\" ]; then", + " cmux_relay_report_tty=\"{\\\"workspace_id\\\":\\\"$CMUX_WORKSPACE_ID\\\",\\\"surface_id\\\":\\\"$CMUX_SURFACE_ID\\\",\\\"tty_name\\\":\\\"$cmux_relay_tty\\\"}\"", + " cmux_relay_ports_kick=\"{\\\"workspace_id\\\":\\\"$CMUX_WORKSPACE_ID\\\",\\\"surface_id\\\":\\\"$CMUX_SURFACE_ID\\\",\\\"reason\\\":\\\"command\\\"}\"", + " fi", + " \"$cmux_relay_cli\" rpc surface.report_tty \"$cmux_relay_report_tty\" >/dev/null 2>&1 || true", + " \"$cmux_relay_cli\" rpc surface.ports_kick \"$cmux_relay_ports_kick\" >/dev/null 2>&1 || true", + "fi", + "unset CMUX_BOOTSTRAP_TTY cmux_relay_cli cmux_relay_tty cmux_relay_report_tty cmux_relay_ports_kick", + ] + } + + private static func shellStateDirForRemoteRelayPort(_ remoteRelayPort: Int) -> String { + "$HOME/.cmux/relay/\(max(remoteRelayPort, 0)).shell" + } + + private static func normalizedEnvValue(_ value: String?) -> String? { + guard let value else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + private static func shellQuote(_ value: String) -> String { + let safePattern = "^[A-Za-z0-9_@%+=:,./-]+$" + if value.range(of: safePattern, options: .regularExpression) != nil { + return value + } + return "'" + value.replacingOccurrences(of: "'", with: "'\"'\"'") + "'" + } +} + struct RemoteRelayZshBootstrap { let shellStateDir: String diff --git a/Sources/WorkspaceRemoteConfiguration.swift b/Sources/WorkspaceRemoteConfiguration.swift index b443d2961701..304d86095230 100644 --- a/Sources/WorkspaceRemoteConfiguration.swift +++ b/Sources/WorkspaceRemoteConfiguration.swift @@ -119,6 +119,7 @@ nonisolated enum SSHPTYAttachStartupCommandBuilder { static func command( sessionID: String? = nil, foregroundAuth: ForegroundAuth? = nil, + remoteCommand: String? = nil, requireExisting: Bool = true ) -> String { var lines = [ @@ -140,11 +141,27 @@ nonisolated enum SSHPTYAttachStartupCommandBuilder { lines += foregroundAuthLines(foregroundAuth) } let requireExistingFlag = requireExisting ? " --require-existing" : "" - let attachCommand = "\"$cmux_ssh_attach_cli\" --socket \"$CMUX_SOCKET_PATH\" ssh-pty-attach --wait\(requireExistingFlag) --workspace \"$CMUX_WORKSPACE_ID\" --session-id \"$cmux_ssh_attach_session_id\" --attachment-id \"${CMUX_SURFACE_ID:-}\"" + let commandB64Flag = normalized(remoteCommand).map { + " --command-b64 \(shellQuote(Data($0.utf8).base64EncodedString()))" + } ?? "" + let attachCommand = "\"$cmux_ssh_attach_cli\" --socket \"$CMUX_SOCKET_PATH\" ssh-pty-attach --wait\(requireExistingFlag) --workspace \"$CMUX_WORKSPACE_ID\" --session-id \"$cmux_ssh_attach_session_id\" --attachment-id \"${CMUX_SURFACE_ID:-}\"\(commandB64Flag)" lines += retryingAttachLines(command: attachCommand) return "/bin/sh -c \(shellQuote(lines.joined(separator: "\n")))" } + static func restoredRemoteShellCommand(relayPort: Int) -> String { + RemoteInteractiveShellBootstrapBuilder.script( + remoteRelayPort: relayPort, + shellFeatures: RemoteInteractiveShellBootstrapBuilder.shellFeatures(), + bundledZshIntegration: RemoteInteractiveShellBootstrapBuilder.bundledShellIntegrationScript( + named: "cmux-zsh-integration.zsh" + ), + bundledBashIntegration: RemoteInteractiveShellBootstrapBuilder.bundledShellIntegrationScript( + named: "cmux-bash-integration.bash" + ) + ) + } + private static func retryingAttachLines(command: String) -> [String] { [ "cmux_ssh_attach_reconnect_limit=\"${CMUX_SSH_RECONNECT_LIMIT:-20}\"", @@ -455,6 +472,9 @@ extension SessionRemoteWorkspaceSnapshot { let restoredRelayToken = preservePTYSession ? Self.restoreRelayTokenHex() : nil + let restoredRemoteShellCommand = preservePTYSession + ? normalizedRelayPort.map(SSHPTYAttachStartupCommandBuilder.restoredRemoteShellCommand(relayPort:)) + : nil return WorkspaceRemoteConfiguration( transport: transport, destination: normalizedDestination, @@ -469,6 +489,7 @@ extension SessionRemoteWorkspaceSnapshot { terminalStartupCommand: preservePTYSession ? SSHPTYAttachStartupCommandBuilder.command( foregroundAuth: foregroundAuth, + remoteCommand: restoredRemoteShellCommand, // Restored panels get explicit require-existing attach commands with their // persisted session IDs; this workspace default is for new panes. requireExisting: false diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 35c7dc838fd1..7ba6b53a89f0 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -161,10 +161,6 @@ struct cmuxApp: App { fileManager.fileExists(atPath: bundledGhosttyURL.appendingPathComponent("themes").path) { return bundledGhosttyURL.path } - if let bundledGhosttyURL, - fileManager.fileExists(atPath: bundledGhosttyURL.path) { - return bundledGhosttyURL.path - } if let currentValue = currentValue?.trimmingCharacters(in: .whitespacesAndNewlines), !currentValue.isEmpty, @@ -176,6 +172,11 @@ struct cmuxApp: App { return ghosttyAppResources } + if let bundledGhosttyURL, + fileManager.fileExists(atPath: bundledGhosttyURL.path) { + return bundledGhosttyURL.path + } + return nil } diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index cacc1971a91d..178f69eec2a0 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -102,6 +102,53 @@ final class GhosttyConfigTests: XCTestCase { XCTAssertEqual(resolved, inheritedResources.path) } + func testLaunchGhosttyResourcesKeepInheritedEnvironmentWhenBundleLacksThemes() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cmux-ghostty-launch-incomplete-resource-fallback-\(UUID().uuidString)") + try fileManager.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: root) } + + let inheritedResources = root.appendingPathComponent("inherited/ghostty", isDirectory: true) + let bundleResources = root.appendingPathComponent("BundleResources", isDirectory: true) + let bundledGhostty = bundleResources.appendingPathComponent("ghostty", isDirectory: true) + try fileManager.createDirectory( + at: inheritedResources.appendingPathComponent("themes", isDirectory: true), + withIntermediateDirectories: true + ) + try fileManager.createDirectory(at: bundledGhostty, withIntermediateDirectories: true) + + let resolved = cmuxApp.resolvedGhosttyResourcesDirectory( + currentValue: inheritedResources.path, + bundleResourceURL: bundleResources, + ghosttyAppResources: root.appendingPathComponent("missing", isDirectory: true).path, + fileManager: fileManager + ) + + XCTAssertEqual(resolved, inheritedResources.path) + } + + func testLaunchGhosttyResourcesUseIncompleteBundleOnlyAsLastFallback() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cmux-ghostty-launch-incomplete-resource-last-fallback-\(UUID().uuidString)") + try fileManager.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: root) } + + let bundleResources = root.appendingPathComponent("BundleResources", isDirectory: true) + let bundledGhostty = bundleResources.appendingPathComponent("ghostty", isDirectory: true) + try fileManager.createDirectory(at: bundledGhostty, withIntermediateDirectories: true) + + let resolved = cmuxApp.resolvedGhosttyResourcesDirectory( + currentValue: root.appendingPathComponent("missing-inherited", isDirectory: true).path, + bundleResourceURL: bundleResources, + ghosttyAppResources: root.appendingPathComponent("missing-app", isDirectory: true).path, + fileManager: fileManager + ) + + XCTAssertEqual(resolved, bundledGhostty.path) + } + func testResolveThemeNamePrefersLightEntryForPairedTheme() { let resolved = GhosttyConfig.resolveThemeName( from: "light:Builtin Solarized Light,dark:Builtin Solarized Dark", diff --git a/cmuxTests/TabManagerSessionSnapshotTests.swift b/cmuxTests/TabManagerSessionSnapshotTests.swift index c6abed5477ea..a9733c45a47d 100644 --- a/cmuxTests/TabManagerSessionSnapshotTests.swift +++ b/cmuxTests/TabManagerSessionSnapshotTests.swift @@ -1829,7 +1829,22 @@ final class TabManagerSessionSnapshotTests: XCTestCase { 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("254|255"), terminalStartupCommand) + let restoredDefaultRemoteCommand = try XCTUnwrap( + Self.decodedSSHPTYCommandB64(in: terminalStartupCommand) + ) + 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__"), restoredDefaultRemoteCommand) + XCTAssertTrue(restoredDefaultRemoteCommand.contains("__CMUX_SURFACE_ID__"), restoredDefaultRemoteCommand) let restoredPanelId = try XCTUnwrap(restoredWorkspace.focusedPanelId) let restoredInitialCommand = try XCTUnwrap( restoredWorkspace.terminalPanel(for: restoredPanelId)?.surface.debugInitialCommand() @@ -1842,6 +1857,7 @@ final class TabManagerSessionSnapshotTests: XCTestCase { XCTAssertTrue(restoredInitialCommand.contains("254|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) @@ -2443,6 +2459,16 @@ final class TabManagerSessionSnapshotTests: XCTestCase { ) } + private static func decodedSSHPTYCommandB64(in command: String) -> String? { + let marker = "--command-b64 " + guard let markerRange = command.range(of: marker) else { return nil } + let suffix = command[markerRange.upperBound...] + guard let token = suffix.split(whereSeparator: { $0.isWhitespace }).first else { return nil } + let encoded = String(token).trimmingCharacters(in: CharacterSet(charactersIn: "'\"")) + guard let data = Data(base64Encoded: encoded) else { return nil } + return String(data: data, encoding: .utf8) + } + private static func browserPanelSnapshot(id: UUID) -> SessionPanelSnapshot { SessionPanelSnapshot( id: id, From fb1d7d90d23c6469719d4a50c56a281e0f28c95d Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Wed, 27 May 2026 15:45:19 -0700 Subject: [PATCH 33/69] fix: remap restored ssh relay anchors --- Sources/Workspace.swift | 2 ++ cmuxTests/TabManagerSessionSnapshotTests.swift | 8 ++++++++ 2 files changed, 10 insertions(+) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 5384d51db3bc..1187bda081e9 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -12413,6 +12413,8 @@ final class Workspace: Identifiable, ObservableObject { "preferred_surface_id", "target_surface_id", "created_surface_id", + "before_surface_id", + "after_surface_id", ] private nonisolated static let remoteRelayAmbiguousIDKeys: Set = [ diff --git a/cmuxTests/TabManagerSessionSnapshotTests.swift b/cmuxTests/TabManagerSessionSnapshotTests.swift index a9733c45a47d..f00ecd6458c3 100644 --- a/cmuxTests/TabManagerSessionSnapshotTests.swift +++ b/cmuxTests/TabManagerSessionSnapshotTests.swift @@ -1918,6 +1918,8 @@ final class TabManagerSessionSnapshotTests: XCTestCase { "workspace_id": originalWorkspaceId.uuidString, "surface_id": originalPanelId.uuidString, "tab_id": originalPanelId.uuidString, + "before_surface_id": originalPanelId.uuidString, + "after_surface_id": originalPanelId.uuidString, "workspace_ids": [originalWorkspaceId.uuidString], "surface_ids": [originalPanelId.uuidString], "tab_ids": [originalWorkspaceId.uuidString, originalPanelId.uuidString], @@ -1938,6 +1940,8 @@ final class TabManagerSessionSnapshotTests: XCTestCase { XCTAssertEqual(params["workspace_id"] as? String, restoredWorkspace.id.uuidString) XCTAssertEqual(params["surface_id"] as? String, restoredPanelId.uuidString) XCTAssertEqual(params["tab_id"] as? String, restoredPanelId.uuidString) + XCTAssertEqual(params["before_surface_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["surface_ids"] as? [String], [restoredPanelId.uuidString]) XCTAssertEqual(params["tab_ids"] as? [String], [restoredWorkspace.id.uuidString, restoredPanelId.uuidString]) @@ -2012,6 +2016,8 @@ final class TabManagerSessionSnapshotTests: XCTestCase { "workspace_id": originalWorkspaceId.uuidString, "surface_id": originalPanelId.uuidString, "tab_id": originalPanelId.uuidString, + "before_surface_id": originalPanelId.uuidString, + "after_surface_id": originalPanelId.uuidString, "workspace_ids": [originalWorkspaceId.uuidString], "surface_ids": [originalPanelId.uuidString], "tab_ids": [originalWorkspaceId.uuidString, originalPanelId.uuidString], @@ -2026,6 +2032,8 @@ final class TabManagerSessionSnapshotTests: XCTestCase { XCTAssertEqual(params["workspace_id"] as? String, restoredWorkspace.id.uuidString) XCTAssertEqual(params["surface_id"] as? String, reattachedPanel.id.uuidString) XCTAssertEqual(params["tab_id"] as? String, reattachedPanel.id.uuidString) + XCTAssertEqual(params["before_surface_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["surface_ids"] as? [String], [reattachedPanel.id.uuidString]) XCTAssertEqual(params["tab_ids"] as? [String], [restoredWorkspace.id.uuidString, reattachedPanel.id.uuidString]) From 082e52e198fa0bd2452f884cb5fef701fa2bcab2 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Wed, 27 May 2026 16:30:45 -0700 Subject: [PATCH 34/69] fix: guard restored ssh shell placeholders --- Sources/RemoteRelayZshBootstrap.swift | 11 +++-- .../TabManagerSessionSnapshotTests.swift | 49 +++++++++++++++++-- 2 files changed, 54 insertions(+), 6 deletions(-) diff --git a/Sources/RemoteRelayZshBootstrap.swift b/Sources/RemoteRelayZshBootstrap.swift index 280eb82f587a..78213893e3bb 100644 --- a/Sources/RemoteRelayZshBootstrap.swift +++ b/Sources/RemoteRelayZshBootstrap.swift @@ -174,10 +174,15 @@ enum RemoteInteractiveShellBootstrapBuilder { if let relaySocket { lines.append("export CMUX_SOCKET_PATH=\(relaySocket)") } + // The assignment placeholders are replaced by `ssh-pty-attach` before + // this script runs. Split the sentinel patterns so a missed replacement + // does not export literal placeholder IDs into the remote shell. lines.append(contentsOf: [ - "if [ -n '__CMUX_WORKSPACE_ID__' ]; then export CMUX_WORKSPACE_ID='__CMUX_WORKSPACE_ID__'; fi", - "if [ -n '__CMUX_WORKSPACE_ID__' ]; then export CMUX_TAB_ID='__CMUX_WORKSPACE_ID__'; fi", - "if [ -n '__CMUX_SURFACE_ID__' ]; then export CMUX_SURFACE_ID='__CMUX_SURFACE_ID__'; export CMUX_PANEL_ID='__CMUX_SURFACE_ID__'; fi", + "cmux_workspace_id='__CMUX_WORKSPACE_ID__'", + "case \"$cmux_workspace_id\" in \"\"|'__CMUX_''WORKSPACE_ID__') ;; *) export CMUX_WORKSPACE_ID=\"$cmux_workspace_id\"; export CMUX_TAB_ID=\"$cmux_workspace_id\" ;; esac", + "cmux_surface_id='__CMUX_SURFACE_ID__'", + "case \"$cmux_surface_id\" in \"\"|'__CMUX_''SURFACE_ID__') ;; *) export CMUX_SURFACE_ID=\"$cmux_surface_id\"; export CMUX_PANEL_ID=\"$cmux_surface_id\" ;; esac", + "unset cmux_workspace_id cmux_surface_id", "hash -r >/dev/null 2>&1 || true", "rehash >/dev/null 2>&1 || true", ]) diff --git a/cmuxTests/TabManagerSessionSnapshotTests.swift b/cmuxTests/TabManagerSessionSnapshotTests.swift index f00ecd6458c3..06a56f775eab 100644 --- a/cmuxTests/TabManagerSessionSnapshotTests.swift +++ b/cmuxTests/TabManagerSessionSnapshotTests.swift @@ -1834,6 +1834,7 @@ final class TabManagerSessionSnapshotTests: XCTestCase { let restoredDefaultRemoteCommand = try XCTUnwrap( Self.decodedSSHPTYCommandB64(in: terminalStartupCommand) ) + let restoredPanelId = try XCTUnwrap(restoredWorkspace.focusedPanelId) XCTAssertTrue( restoredDefaultRemoteCommand.contains("export CMUX_SOCKET_PATH=127.0.0.1:64003"), restoredDefaultRemoteCommand @@ -1843,9 +1844,51 @@ final class TabManagerSessionSnapshotTests: XCTestCase { restoredDefaultRemoteCommand ) XCTAssertTrue(restoredDefaultRemoteCommand.contains("CMUX_SHELL_INTEGRATION_DIR"), restoredDefaultRemoteCommand) - XCTAssertTrue(restoredDefaultRemoteCommand.contains("__CMUX_WORKSPACE_ID__"), restoredDefaultRemoteCommand) - XCTAssertTrue(restoredDefaultRemoteCommand.contains("__CMUX_SURFACE_ID__"), restoredDefaultRemoteCommand) - let restoredPanelId = try XCTUnwrap(restoredWorkspace.focusedPanelId) + 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() ) From e024877474c5a5053588e1deb7b057db33f0b4b7 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Wed, 27 May 2026 17:28:33 -0700 Subject: [PATCH 35/69] fix: remap restored ssh panel aliases --- Sources/Workspace.swift | 7 +++++ .../TabManagerSessionSnapshotTests.swift | 30 +++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 1187bda081e9..c284a97e5f90 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -12409,11 +12409,17 @@ final class Workspace: Identifiable, ObservableObject { ] private nonisolated static let remoteRelaySurfaceIDKeys: Set = [ + "panel_id", "surface_id", + "preferred_panel_id", "preferred_surface_id", + "target_panel_id", "target_surface_id", + "created_panel_id", "created_surface_id", + "before_panel_id", "before_surface_id", + "after_panel_id", "after_surface_id", ] @@ -12426,6 +12432,7 @@ final class Workspace: Identifiable, ObservableObject { ] private nonisolated static let remoteRelaySurfaceIDArrayKeys: Set = [ + "panel_ids", "surface_ids", ] diff --git a/cmuxTests/TabManagerSessionSnapshotTests.swift b/cmuxTests/TabManagerSessionSnapshotTests.swift index 06a56f775eab..30303ad41bed 100644 --- a/cmuxTests/TabManagerSessionSnapshotTests.swift +++ b/cmuxTests/TabManagerSessionSnapshotTests.swift @@ -1960,10 +1960,17 @@ final class TabManagerSessionSnapshotTests: XCTestCase { "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]], @@ -1971,6 +1978,7 @@ final class TabManagerSessionSnapshotTests: XCTestCase { "caller": [ "workspace_id": originalWorkspaceId.uuidString, "surface_id": originalPanelId.uuidString, + "panel_id": originalPanelId.uuidString, "tab_id": originalWorkspaceId.uuidString, ], ], @@ -1982,10 +1990,17 @@ final class TabManagerSessionSnapshotTests: XCTestCase { 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]]) @@ -1994,6 +2009,7 @@ final class TabManagerSessionSnapshotTests: XCTestCase { 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) } @@ -2058,10 +2074,17 @@ final class TabManagerSessionSnapshotTests: XCTestCase { "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, @@ -2074,10 +2097,17 @@ final class TabManagerSessionSnapshotTests: XCTestCase { 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) From 1736207a4f16b7917e1a70563296056ae109cfc6 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Wed, 27 May 2026 17:52:57 -0700 Subject: [PATCH 36/69] fix: cancel stale ssh relay forwards --- Sources/Workspace.swift | 26 +++- ...orkspaceRemoteSSHBatchCommandBuilder.swift | 12 ++ .../WorkspaceRemoteConnectionTests.swift | 126 ++++++++++++++++++ 3 files changed, 162 insertions(+), 2 deletions(-) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index c284a97e5f90..93dd239c49a3 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -6397,7 +6397,7 @@ final class WorkspaceRemoteSessionController { ) let forwardSpec = "127.0.0.1:\(relayPort):127.0.0.1:\(localRelayPort)" - if startReverseRelayViaControlMasterLocked(forwardSpec: forwardSpec) { + if startReverseRelayViaControlMasterLocked(forwardSpec: forwardSpec, relayPort: relayPort) { cliRelayServer = relayServer reverseRelayStderrBuffer = "" do { @@ -6808,7 +6808,7 @@ final class WorkspaceRemoteSessionController { return args } - private func startReverseRelayViaControlMasterLocked(forwardSpec: String) -> Bool { + private func startReverseRelayViaControlMasterLocked(forwardSpec: String, relayPort: Int) -> Bool { guard let arguments = WorkspaceRemoteSSHBatchCommandBuilder.reverseRelayControlMasterArguments( configuration: configuration, controlCommand: "forward", @@ -6817,6 +6817,7 @@ final class WorkspaceRemoteSessionController { return false } + cancelStaleReverseRelayViaControlMasterLocked(relayPort: relayPort) do { let result = try sshExec(arguments: arguments, timeout: 6) guard result.status == 0 else { @@ -6833,6 +6834,27 @@ final class WorkspaceRemoteSessionController { } } + private func cancelStaleReverseRelayViaControlMasterLocked(relayPort: Int) { + guard let arguments = WorkspaceRemoteSSHBatchCommandBuilder.reverseRelayControlMasterCancelArguments( + configuration: configuration, + relayPort: relayPort + ) else { + return + } + do { + let result = try sshExec(arguments: arguments, timeout: 4) + guard result.status == 0 else { + let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) + ?? "ssh exited \(result.status)" + debugLog("remote.relay.controlmaster.cancelStaleIgnored \(detail) \(debugConfigSummary())") + return + } + debugLog("remote.relay.controlmaster.cancelStale relayPort=\(relayPort) \(debugConfigSummary())") + } catch { + debugLog("remote.relay.controlmaster.cancelStaleIgnored \(error.localizedDescription) \(debugConfigSummary())") + } + } + private func stopReverseRelayViaControlMasterLocked() { guard let forwardSpec = reverseRelayControlMasterForwardSpec else { return } reverseRelayControlMasterForwardSpec = nil diff --git a/Sources/WorkspaceRemoteSSHBatchCommandBuilder.swift b/Sources/WorkspaceRemoteSSHBatchCommandBuilder.swift index 47dfe1ee6148..f1e36e0f81c2 100644 --- a/Sources/WorkspaceRemoteSSHBatchCommandBuilder.swift +++ b/Sources/WorkspaceRemoteSSHBatchCommandBuilder.swift @@ -57,6 +57,18 @@ enum WorkspaceRemoteSSHBatchCommandBuilder { return args } + static func reverseRelayControlMasterCancelArguments( + configuration: WorkspaceRemoteConfiguration, + relayPort: Int + ) -> [String]? { + guard relayPort > 0 else { return nil } + return reverseRelayControlMasterArguments( + configuration: configuration, + controlCommand: "cancel", + forwardSpec: "127.0.0.1:\(relayPort)" + ) + } + private static func batchArguments(configuration: WorkspaceRemoteConfiguration) -> [String] { let effectiveSSHOptions = backgroundSSHOptions(configuration.sshOptions) var args: [String] = [ diff --git a/cmuxTests/WorkspaceRemoteConnectionTests.swift b/cmuxTests/WorkspaceRemoteConnectionTests.swift index 09b347013ffc..113ca85f09eb 100644 --- a/cmuxTests/WorkspaceRemoteConnectionTests.swift +++ b/cmuxTests/WorkspaceRemoteConnectionTests.swift @@ -1481,6 +1481,95 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { ) } + @MainActor + func testPersistentReverseRelayCancelsStaleControlMasterForwardBeforeReusingRelayPort() throws { + let forwardInvoked = DispatchSemaphore(value: 0) + let lock = NSLock() + var controlOperations: [(command: String, spec: String)] = [] + + WorkspaceRemoteSessionController.runProcessOverrideForTesting = { executable, arguments, _, _ in + guard executable == "/usr/bin/ssh" else { + XCTFail("unexpected executable \(executable)") + return (status: 1, stdout: "", stderr: "unexpected executable") + } + + if let operationIndex = arguments.firstIndex(of: "-O"), + operationIndex + 3 < arguments.count, + arguments[operationIndex + 2] == "-R" { + let operation = arguments[operationIndex + 1] + let spec = arguments[operationIndex + 3] + lock.lock() + controlOperations.append((command: operation, spec: spec)) + lock.unlock() + if operation == "forward" { + forwardInvoked.signal() + } + return (status: 0, stdout: "", stderr: "") + } + + 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 + """, + stderr: "" + ) + } + if command.contains("serve --stdio") { + return ( + status: 0, + stdout: #"{"id":1,"ok":true,"result":{"name":"cmuxd-remote","version":"dev","capabilities":["proxy.stream.push","pty.session","pty.session.token","pty.session.persistent_daemon"]}}"# + "\n", + stderr: "" + ) + } + return (status: 0, stdout: "", stderr: "") + } + defer { WorkspaceRemoteSessionController.runProcessOverrideForTesting = nil } + + let workspace = Workspace() + let config = WorkspaceRemoteConfiguration( + destination: "test@hpc.example", + port: 2222, + identityFile: nil, + sshOptions: [ + "ControlMaster=auto", + "ControlPersist=600", + "ControlPath=/tmp/cmux-ssh-\(getuid())-64044-%C", + "StrictHostKeyChecking=accept-new", + ], + localProxyPort: nil, + relayPort: 64044, + relayID: "relay-stale-forward", + relayToken: String(repeating: "c", count: 64), + localSocketPath: "/tmp/cmux-stale-forward-test.sock", + terminalStartupCommand: "ssh-pty-attach", + preserveAfterTerminalExit: true, + persistentDaemonSlot: "ssh-stale-forward-test" + ) + defer { workspace.disconnectRemoteConnection(clearConfiguration: true) } + + workspace.configureRemoteConnection(config, autoConnect: true) + + XCTAssertEqual(forwardInvoked.wait(timeout: .now() + 2), .success) + lock.lock() + let operations = controlOperations + lock.unlock() + + XCTAssertGreaterThanOrEqual(operations.count, 2) + XCTAssertEqual(operations[0].command, "cancel") + XCTAssertEqual(operations[0].spec, "127.0.0.1:64044") + XCTAssertEqual(operations[1].command, "forward") + XCTAssertTrue( + operations[1].spec.hasPrefix("127.0.0.1:64044:127.0.0.1:"), + "expected forward to reuse relay port after stale cancel, got \(operations[1].spec)" + ) + } + @MainActor func testDetachAttachPreservesRemoteTerminalSurfaceTracking() throws { let workspace = Workspace() @@ -2341,6 +2430,43 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { XCTAssertTrue(arguments.contains("cmux-macmini")) } + func testReverseRelayControlMasterCancelArgumentsUseRemoteListenPortOnly() throws { + let configuration = WorkspaceRemoteConfiguration( + destination: "cmux-macmini", + port: 2222, + identityFile: "/Users/test/.ssh/id_ed25519", + sshOptions: [ + "ControlMaster=auto", + "ControlPersist=600", + "ControlPath=/tmp/cmux-ssh-%C", + "StrictHostKeyChecking=accept-new", + ], + localProxyPort: nil, + relayPort: 64007, + relayID: nil, + relayToken: nil, + localSocketPath: nil, + terminalStartupCommand: "ssh cmux-macmini" + ) + + let arguments = try XCTUnwrap( + WorkspaceRemoteSSHBatchCommandBuilder.reverseRelayControlMasterCancelArguments( + configuration: configuration, + relayPort: 64007 + ) + ) + + XCTAssertFalse(arguments.contains("-S")) + XCTAssertTrue(arguments.contains("ControlMaster=no")) + XCTAssertTrue(arguments.contains("ControlPath=/tmp/cmux-ssh-%C")) + XCTAssertTrue(arguments.contains("-O")) + XCTAssertTrue(arguments.contains("cancel")) + XCTAssertTrue(arguments.contains("-R")) + XCTAssertTrue(arguments.contains("127.0.0.1:64007")) + XCTAssertFalse(arguments.contains(where: { $0.hasPrefix("127.0.0.1:64007:127.0.0.1:") })) + XCTAssertTrue(arguments.contains("cmux-macmini")) + } + func testReverseRelayControlMasterArgumentsReuseWhitespaceConfiguredControlSocket() throws { let configuration = WorkspaceRemoteConfiguration( destination: "cmux-macmini", From 393d91b4dd8bc8b03da10e3a438b9ca4e8b885c2 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Wed, 27 May 2026 18:22:04 -0700 Subject: [PATCH 37/69] fix: preserve ssh relay aliases across reconnect --- Sources/Workspace.swift | 4 +- .../TabManagerSessionSnapshotTests.swift | 49 ++++++++++++++++++- 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index aa29c6a9f919..87d2492fedef 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -12351,8 +12351,6 @@ final class Workspace: Identifiable, ObservableObject { previousController?.stop() pendingRemoteForegroundAuthToken = nil activeRemoteTerminalSurfaceIds.removeAll() - remotePTYSessionIDsByPanelId.removeAll() - clearRemoteRelayIDAliases() activeRemoteTerminalSessionCount = 0 pendingRemoteSurfaceTTYName = nil pendingRemoteSurfaceTTYSurfaceId = nil @@ -12374,6 +12372,8 @@ final class Workspace: Identifiable, ObservableObject { remoteLastDaemonErrorFingerprint = nil remoteLastPortConflictFingerprint = nil if clearConfiguration { + remotePTYSessionIDsByPanelId.removeAll() + clearRemoteRelayIDAliases() remoteConfiguration = nil skipControlMasterCleanupAfterDetachedRemoteTransfer = false } diff --git a/cmuxTests/TabManagerSessionSnapshotTests.swift b/cmuxTests/TabManagerSessionSnapshotTests.swift index 30303ad41bed..8b1a0cc12e67 100644 --- a/cmuxTests/TabManagerSessionSnapshotTests.swift +++ b/cmuxTests/TabManagerSessionSnapshotTests.swift @@ -1983,10 +1983,16 @@ final class TabManagerSessionSnapshotTests: XCTestCase { ], ], ] + 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 rewritten = try XCTUnwrap(JSONSerialization.jsonObject(with: rewrittenData, options: []) as? [String: Any]) - let params = try XCTUnwrap(rewritten["params"] as? [String: Any]) + let params = try decodedParams(from: rewrittenData) XCTAssertEqual(params["workspace_id"] as? String, restoredWorkspace.id.uuidString) XCTAssertEqual(params["surface_id"] as? String, restoredPanelId.uuidString) @@ -2011,6 +2017,45 @@ final class TabManagerSessionSnapshotTests: XCTestCase { 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 + ) + 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) + 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 testPersistentSSHPTYReattachRewritesStaleRemoteRelayContextIDs() throws { From b48d88f64f4492fd9f2a094621563383fdd609c5 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Wed, 27 May 2026 18:55:53 -0700 Subject: [PATCH 38/69] fix: keep ssh pty identity on relay refresh --- Sources/Workspace.swift | 4 ++- Sources/WorkspaceRemoteConfiguration.swift | 19 +++++++++++ .../TabManagerSessionSnapshotTests.swift | 34 +++++++++++++++++++ 3 files changed, 56 insertions(+), 1 deletion(-) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 87d2492fedef..cda5dbd2273b 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -12246,7 +12246,9 @@ final class Workspace: Identifiable, ObservableObject { defer { TerminalController.shared.notifyRemotePTYControllerAvailabilityChanged() } let previousConfiguration = remoteConfiguration skipControlMasterCleanupAfterDetachedRemoteTransfer = false - if previousConfiguration != nil, previousConfiguration != configuration { + if let previousConfiguration, + previousConfiguration != configuration, + !previousConfiguration.hasSamePersistentPTYIdentity(as: configuration) { remotePTYSessionIDsByPanelId.removeAll() clearRemoteRelayIDAliases() } diff --git a/Sources/WorkspaceRemoteConfiguration.swift b/Sources/WorkspaceRemoteConfiguration.swift index 304d86095230..814ba6a26e26 100644 --- a/Sources/WorkspaceRemoteConfiguration.swift +++ b/Sources/WorkspaceRemoteConfiguration.swift @@ -417,6 +417,25 @@ struct WorkspaceRemoteConfiguration: Equatable { private static func proxyBrokerSSHOptions(_ options: [String]) -> [String] { WorkspaceRemoteSSHOptionFilter.durableOptions(options) } + + func hasSamePersistentPTYIdentity(as other: WorkspaceRemoteConfiguration) -> Bool { + guard preserveAfterTerminalExit, + other.preserveAfterTerminalExit, + let persistentDaemonSlot, + persistentDaemonSlot == other.persistentDaemonSlot else { + return false + } + + return transport == other.transport + && skipDaemonBootstrap == other.skipDaemonBootstrap + && destination.trimmingCharacters(in: .whitespacesAndNewlines) + == other.destination.trimmingCharacters(in: .whitespacesAndNewlines) + && port == other.port + && WorkspaceRemoteSSHOptionFilter.normalizedIdentityPath(identityFile) + == WorkspaceRemoteSSHOptionFilter.normalizedIdentityPath(other.identityFile) + && Self.proxyBrokerSSHOptions(sshOptions) == Self.proxyBrokerSSHOptions(other.sshOptions) + && daemonWebSocketEndpoint?.proxyBrokerKeyComponent == other.daemonWebSocketEndpoint?.proxyBrokerKeyComponent + } } extension SessionRemoteWorkspaceSnapshot { diff --git a/cmuxTests/TabManagerSessionSnapshotTests.swift b/cmuxTests/TabManagerSessionSnapshotTests.swift index 8b1a0cc12e67..ed1467a7c9a8 100644 --- a/cmuxTests/TabManagerSessionSnapshotTests.swift +++ b/cmuxTests/TabManagerSessionSnapshotTests.swift @@ -2023,6 +2023,40 @@ final class TabManagerSessionSnapshotTests: XCTestCase { .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())-64106-%C", + "StrictHostKeyChecking=accept-new", + ], + localProxyPort: nil, + relayPort: 64106, + 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) From 9ea1dac6ae29aa28e2bdd22940224decba136659 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Wed, 27 May 2026 21:52:07 -0700 Subject: [PATCH 39/69] fix: restore moved ssh pty relay aliases --- Sources/Workspace.swift | 6 +- .../TabManagerSessionSnapshotTests.swift | 101 ++++++++++++++++++ 2 files changed, 106 insertions(+), 1 deletion(-) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 5a9c82a5e84d..98d0c8eee762 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -1336,7 +1336,11 @@ extension Workspace { ) else { return nil } - if restoredRemotePTYSessionID != nil { + if let restoredRemotePTYSessionID { + registerRemoteRelayIDAliases( + remotePTYSessionID: restoredRemotePTYSessionID, + restoredPanelId: terminalPanel.id + ) registerRemoteRelayIDAliases( snapshotWorkspaceId: snapshotWorkspaceId, snapshotPanelId: snapshot.id, diff --git a/cmuxTests/TabManagerSessionSnapshotTests.swift b/cmuxTests/TabManagerSessionSnapshotTests.swift index ed1467a7c9a8..a21fc3b90b19 100644 --- a/cmuxTests/TabManagerSessionSnapshotTests.swift +++ b/cmuxTests/TabManagerSessionSnapshotTests.swift @@ -2092,6 +2092,107 @@ final class TabManagerSessionSnapshotTests: XCTestCase { XCTAssertEqual(clearedParams["panel_id"] as? String, originalPanelId.uuidString) } + func testPersistentSSHPTYRestoreRewritesMovedSourceWorkspaceContextID() throws { + 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) + + 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 { let manager = TabManager() let remoteWorkspace = manager.addWorkspace(select: true) From f374f1f9ba331b9a26d702a5b5cc34eaa60060dc Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Wed, 27 May 2026 22:15:35 -0700 Subject: [PATCH 40/69] fix: preserve existing terminfo environment --- Sources/cmuxApp.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 7ba6b53a89f0..2856ad946d31 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -115,7 +115,8 @@ struct cmuxApp: App { setenv("GHOSTTY_RESOURCES_DIR", resolvedResourcesDir, 1) } - if let terminfoURL = Bundle.main.resourceURL?.appendingPathComponent("terminfo"), + if getenv("TERMINFO") == nil, + let terminfoURL = Bundle.main.resourceURL?.appendingPathComponent("terminfo"), fileManager.fileExists(atPath: terminfoURL.path) { setenv("TERMINFO", terminfoURL.path, 1) } From bf0443dc1384b682cd60432455f06524cfa3514c Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Wed, 27 May 2026 22:34:12 -0700 Subject: [PATCH 41/69] fix: clear failed persistent ssh attach state --- Sources/TabManager.swift | 1 + Sources/Workspace.swift | 15 +++++++++++++++ cmuxTests/TabManagerUnitTests.swift | 9 +++++++-- 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 2a7f1b8a7dfb..5cddbcf86705 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -6583,6 +6583,7 @@ class TabManager: ObservableObject { // a local login shell. Keep the exited surface visible so the user can see the error // and retry instead of making a detached remote workspace look local after relaunch. if keepsPersistentRemoteWorkspaceOpen { + tab.markPersistentRemotePTYAttachFailed(surfaceId: surfaceId) return } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 98d0c8eee762..243f1253195e 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -12823,6 +12823,21 @@ final class Workspace: Identifiable, ObservableObject { return (true, wasTracked) } + func markPersistentRemotePTYAttachFailed(surfaceId: UUID) { + guard remoteConfiguration?.preserveAfterTerminalExit == true else { return } + + remotePTYSessionIDsByPanelId.removeValue(forKey: surfaceId) + removeRemoteRelaySurfaceAliases(targeting: surfaceId) + pendingRemoteTerminalChildExitSurfaceIds.remove(surfaceId) + transferredRemoteCleanupConfigurationsByPanelId.removeValue(forKey: surfaceId) + surfaceTTYNames.removeValue(forKey: surfaceId) + if activeRemoteTerminalSurfaceIds.remove(surfaceId) != nil { + activeRemoteTerminalSessionCount = activeRemoteTerminalSurfaceIds.count + } + syncRemotePortScanTTYs() + applyBrowserRemoteWorkspaceStatusToPanels() + } + private func maybeDemoteRemoteWorkspaceAfterSSHSessionEnded() { guard activeRemoteTerminalSurfaceIds.isEmpty, remoteConfiguration != nil else { return } if remoteConfiguration?.preserveAfterTerminalExit == true { diff --git a/cmuxTests/TabManagerUnitTests.swift b/cmuxTests/TabManagerUnitTests.swift index 84ccf7b86f8d..362c9de33307 100644 --- a/cmuxTests/TabManagerUnitTests.swift +++ b/cmuxTests/TabManagerUnitTests.swift @@ -236,7 +236,7 @@ final class TabManagerChildExitCloseTests: XCTestCase { XCTAssertEqual(workspace.activeRemoteTerminalSessionCount, 0) } - func testChildExitOnLastPersistentRemotePanelKeepsExitedSurfaceVisible() throws { + func testChildExitOnLastPersistentRemotePanelKeepsExitedSurfaceVisibleAndClearsPTYState() throws { let manager = TabManager() guard let workspace = manager.selectedWorkspace, let remotePanelId = workspace.focusedPanelId else { @@ -276,7 +276,12 @@ final class TabManagerChildExitCloseTests: XCTestCase { XCTAssertNotNil(workspace.panels[remotePanelId]) XCTAssertEqual(workspace.panels.count, 1) XCTAssertEqual(workspace.focusedPanelId, remotePanelId) - XCTAssertEqual(workspace.activeRemoteTerminalSessionCount, 1) + XCTAssertEqual(workspace.activeRemoteTerminalSessionCount, 0) + XCTAssertFalse(workspace.isRemoteTerminalSurface(remotePanelId)) + XCTAssertNil( + workspace.sessionSnapshot(includeScrollback: false) + .panels.first { $0.id == remotePanelId }?.terminal?.remotePTYSessionID + ) } func testChildExitAfterRemoteSessionEndKeepsWorkspaceAndDemotesToLocal() throws { From b116e2ea6854538cb13f28c221e978679ced2a58 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Wed, 27 May 2026 23:55:47 -0700 Subject: [PATCH 42/69] ci: retrigger stuck PR checks From 56254dca8321304276ffd63f54e2ad25558e5463 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Thu, 28 May 2026 00:15:11 -0700 Subject: [PATCH 43/69] fix: reset persistent ssh sessions on relay changes --- Sources/WorkspaceRemoteConfiguration.swift | 1 + cmuxTests/GhosttyConfigTests.swift | 42 ++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/Sources/WorkspaceRemoteConfiguration.swift b/Sources/WorkspaceRemoteConfiguration.swift index 814ba6a26e26..0538e6210e2b 100644 --- a/Sources/WorkspaceRemoteConfiguration.swift +++ b/Sources/WorkspaceRemoteConfiguration.swift @@ -431,6 +431,7 @@ struct WorkspaceRemoteConfiguration: Equatable { && destination.trimmingCharacters(in: .whitespacesAndNewlines) == other.destination.trimmingCharacters(in: .whitespacesAndNewlines) && port == other.port + && relayPort == other.relayPort && WorkspaceRemoteSSHOptionFilter.normalizedIdentityPath(identityFile) == WorkspaceRemoteSSHOptionFilter.normalizedIdentityPath(other.identityFile) && Self.proxyBrokerSSHOptions(sshOptions) == Self.proxyBrokerSSHOptions(other.sshOptions) diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index 178f69eec2a0..df8c2149a763 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -2526,6 +2526,48 @@ final class WorkspaceRemoteConfigurationTransportKeyTests: XCTestCase { XCTAssertEqual(first.proxyBrokerTransportKey, second.proxyBrokerTransportKey) } + + func testPersistentPTYIdentityRequiresSameRelayPort() { + let first = WorkspaceRemoteConfiguration( + destination: "cmux-macmini", + port: 22, + identityFile: "~/.ssh/id_ed25519", + sshOptions: [ + "Compression=yes", + "ControlMaster=auto", + "ControlPath=/tmp/cmux-ssh-501-64000-%C", + ], + localProxyPort: nil, + relayPort: 64000, + relayID: "relay-a", + relayToken: "token-a", + localSocketPath: "/tmp/cmux-a.sock", + terminalStartupCommand: "ssh cmux-macmini", + preserveAfterTerminalExit: true, + persistentDaemonSlot: "ssh-test-slot" + ) + let second = WorkspaceRemoteConfiguration( + destination: "cmux-macmini", + port: 22, + identityFile: "~/.ssh/id_ed25519", + sshOptions: [ + "Compression=yes", + "ControlMaster=auto", + "ControlPath=/tmp/cmux-ssh-501-64001-%C", + ], + localProxyPort: nil, + relayPort: 64001, + relayID: "relay-b", + relayToken: "token-b", + localSocketPath: "/tmp/cmux-b.sock", + terminalStartupCommand: "ssh cmux-macmini", + preserveAfterTerminalExit: true, + persistentDaemonSlot: "ssh-test-slot" + ) + + XCTAssertFalse(first.hasSamePersistentPTYIdentity(as: second)) + XCTAssertFalse(second.hasSamePersistentPTYIdentity(as: first)) + } } final class WorkspaceRemoteSSHCleanupTests: XCTestCase { From 5bbb77c50f0b0eb81c9c608dc3c9e370c91cc549 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Thu, 28 May 2026 00:46:32 -0700 Subject: [PATCH 44/69] test: keep relay refresh on stable port --- cmuxTests/TabManagerSessionSnapshotTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmuxTests/TabManagerSessionSnapshotTests.swift b/cmuxTests/TabManagerSessionSnapshotTests.swift index a21fc3b90b19..00195fcabdeb 100644 --- a/cmuxTests/TabManagerSessionSnapshotTests.swift +++ b/cmuxTests/TabManagerSessionSnapshotTests.swift @@ -2030,11 +2030,11 @@ final class TabManagerSessionSnapshotTests: XCTestCase { sshOptions: [ "ControlMaster=auto", "ControlPersist=600", - "ControlPath=/tmp/cmux-ssh-\(getuid())-64106-%C", + "ControlPath=/tmp/cmux-ssh-\(getuid())-64006-%C", "StrictHostKeyChecking=accept-new", ], localProxyPort: nil, - relayPort: 64106, + relayPort: 64006, relayID: "relay-alias-test-refreshed", relayToken: String(repeating: "c", count: 64), localSocketPath: "/tmp/cmux-relay-alias-refreshed.sock", From e5e1186c0d200a67cbe449e4f9a474f7be8e23c5 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 29 May 2026 02:27:44 -0700 Subject: [PATCH 45/69] test: cover stale ssh relay listener cleanup --- .../WorkspaceRemoteConnectionTests.swift | 318 ++++++++++++++++++ 1 file changed, 318 insertions(+) diff --git a/cmuxTests/WorkspaceRemoteConnectionTests.swift b/cmuxTests/WorkspaceRemoteConnectionTests.swift index 113ca85f09eb..3fa9fb9ae639 100644 --- a/cmuxTests/WorkspaceRemoteConnectionTests.swift +++ b/cmuxTests/WorkspaceRemoteConnectionTests.swift @@ -67,6 +67,11 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { .write(to: url, atomically: true, encoding: .utf8) } + private func writeExecutableShellFile(at url: URL, body: String) throws { + try body.write(to: url, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path) + } + private func runRelayZshHistfile( configureUserHome: (URL) throws -> URL ) throws -> String { @@ -182,6 +187,198 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { XCTAssertFalse(fileManager.fileExists(atPath: ttyURL.path)) } + func testRemoteStaleRelayListenerCleanupScriptKillsMatchingPersistentRelayListener() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory.appendingPathComponent("cmux-stale-relay-cleanup-\(UUID().uuidString)") + let bin = root.appendingPathComponent("bin") + let killLog = root.appendingPathComponent("kill.log") + try fileManager.createDirectory(at: bin, withIntermediateDirectories: true) + try "".write(to: killLog, atomically: true, encoding: .utf8) + defer { try? fileManager.removeItem(at: root) } + + try writeExecutableShellFile( + at: bin.appendingPathComponent("lsof"), + body: """ + #!/bin/sh + cat <<'EOF' + p33681 + f12 + n127.0.0.1:50446 + EOF + """ + ) + try writeExecutableShellFile( + at: bin.appendingPathComponent("ps"), + body: """ + #!/bin/sh + cat <<'EOF' + 33681 1 /usr/sbin/sshd-session + 34057 33681 /Users/cmux/.cmux/bin/cmuxd-remote/current/darwin-arm64/cmuxd-remote serve --stdio --persistent --slot ssh-c4ba8ab1 + 34058 33681 /bin/zsh + EOF + """ + ) + + let script = try XCTUnwrap( + WorkspaceRemoteSessionController.remoteStaleRelayListenerCleanupScript( + relayPort: 50446, + persistentDaemonSlot: "ssh-c4ba8ab1" + ) + ) + let result = runProcess( + executablePath: "/usr/bin/env", + arguments: [ + "PATH=\(bin.path):/usr/bin:/bin", + "CMUX_KILL_LOG=\(killLog.path)", + "/bin/sh", + "-c", + """ + kill() { printf '%s\\n' "$*" >> "$CMUX_KILL_LOG"; return 0; } + \(script) + """, + ], + timeout: 5 + ) + + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + XCTAssertTrue(result.stdout.contains("cmux_stale_relay_killed pid=33681 children=34057 port=50446"), result.stdout) + + let killOutput = try String(contentsOf: killLog, encoding: .utf8) + XCTAssertTrue(killOutput.contains("-TERM 33681 34057"), killOutput) + XCTAssertTrue(killOutput.contains("-KILL 33681"), killOutput) + XCTAssertTrue(killOutput.contains("-KILL 34057"), killOutput) + } + + func testRemoteStaleRelayListenerCleanupScriptPreservesDifferentPersistentSlot() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory.appendingPathComponent("cmux-stale-relay-preserve-\(UUID().uuidString)") + let bin = root.appendingPathComponent("bin") + let killLog = root.appendingPathComponent("kill.log") + try fileManager.createDirectory(at: bin, withIntermediateDirectories: true) + try "".write(to: killLog, atomically: true, encoding: .utf8) + defer { try? fileManager.removeItem(at: root) } + + try writeExecutableShellFile( + at: bin.appendingPathComponent("lsof"), + body: """ + #!/bin/sh + cat <<'EOF' + p33681 + f12 + n127.0.0.1:50446 + EOF + """ + ) + try writeExecutableShellFile( + at: bin.appendingPathComponent("ps"), + body: """ + #!/bin/sh + cat <<'EOF' + 33681 1 /usr/sbin/sshd-session + 34057 33681 /Users/cmux/.cmux/bin/cmuxd-remote/current/darwin-arm64/cmuxd-remote serve --stdio --persistent --slot ssh-other + EOF + """ + ) + + let script = try XCTUnwrap( + WorkspaceRemoteSessionController.remoteStaleRelayListenerCleanupScript( + relayPort: 50446, + persistentDaemonSlot: "ssh-c4ba8ab1" + ) + ) + let result = runProcess( + executablePath: "/usr/bin/env", + arguments: [ + "PATH=\(bin.path):/usr/bin:/bin", + "CMUX_KILL_LOG=\(killLog.path)", + "/bin/sh", + "-c", + """ + kill() { printf '%s\\n' "$*" >> "$CMUX_KILL_LOG"; return 0; } + \(script) + """, + ], + timeout: 5 + ) + + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + XCTAssertEqual(result.stdout, "") + XCTAssertEqual(try String(contentsOf: killLog, encoding: .utf8), "") + } + + func testRemoteStaleRelayListenerCleanupScriptKillsMetadataMatchedListenerWithoutChild() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory.appendingPathComponent("cmux-stale-relay-metadata-\(UUID().uuidString)") + let bin = root.appendingPathComponent("bin") + let relayDir = root.appendingPathComponent(".cmux/relay") + let killLog = root.appendingPathComponent("kill.log") + try fileManager.createDirectory(at: bin, withIntermediateDirectories: true) + try fileManager.createDirectory(at: relayDir, withIntermediateDirectories: true) + try "/Users/cmux/.cmux/bin/cmuxd-remote/current/darwin-arm64/cmuxd-remote".write( + to: relayDir.appendingPathComponent("50446.daemon_path"), + atomically: true, + encoding: .utf8 + ) + try "".write(to: killLog, atomically: true, encoding: .utf8) + defer { try? fileManager.removeItem(at: root) } + + try writeExecutableShellFile( + at: bin.appendingPathComponent("lsof"), + body: """ + #!/bin/sh + cat <<'EOF' + p33681 + f12 + n127.0.0.1:50446 + EOF + """ + ) + try writeExecutableShellFile( + at: bin.appendingPathComponent("ps"), + body: """ + #!/bin/sh + cat <<'EOF' + 33681 1 /usr/sbin/sshd-session + EOF + """ + ) + + let script = try XCTUnwrap( + WorkspaceRemoteSessionController.remoteStaleRelayListenerCleanupScript( + relayPort: 50446, + persistentDaemonSlot: "ssh-c4ba8ab1" + ) + ) + let result = runProcess( + executablePath: "/usr/bin/env", + arguments: [ + "HOME=\(root.path)", + "PATH=\(bin.path):/usr/bin:/bin", + "CMUX_KILL_LOG=\(killLog.path)", + "/bin/sh", + "-c", + """ + kill() { printf '%s\\n' "$*" >> "$CMUX_KILL_LOG"; return 0; } + \(script) + """, + ], + timeout: 5 + ) + + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + XCTAssertTrue( + result.stdout.contains("cmux_stale_relay_killed pid=33681 children= port=50446 reason=metadata"), + result.stdout + ) + + let killOutput = try String(contentsOf: killLog, encoding: .utf8) + XCTAssertTrue(killOutput.contains("-TERM 33681"), killOutput) + XCTAssertTrue(killOutput.contains("-KILL 33681"), killOutput) + } + func testRelayZshBootstrapUsesRealHomeHistoryByDefault() throws { let histfile = try runRelayZshHistfile { home in try ":\n".write(to: home.appendingPathComponent(".zshenv"), atomically: true, encoding: .utf8) @@ -1570,6 +1767,127 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { ) } + @MainActor + func testPersistentReverseRelayCleansStaleRemoteListenerAndRetriesControlMasterForward() throws { + let retryForwardInvoked = DispatchSemaphore(value: 0) + let lock = NSLock() + var controlOperations: [(command: String, spec: String)] = [] + var forwardAttempts = 0 + var cleanupInvoked = false + var cleanupArguments: [String] = [] + + WorkspaceRemoteSessionController.runProcessOverrideForTesting = { executable, arguments, _, _ in + guard executable == "/usr/bin/ssh" else { + XCTFail("unexpected executable \(executable)") + return (status: 1, stdout: "", stderr: "unexpected executable") + } + + if let operationIndex = arguments.firstIndex(of: "-O"), + operationIndex + 3 < arguments.count, + arguments[operationIndex + 2] == "-R" { + let operation = arguments[operationIndex + 1] + let spec = arguments[operationIndex + 3] + lock.lock() + controlOperations.append((command: operation, spec: spec)) + if operation == "forward" { + forwardAttempts += 1 + let attempt = forwardAttempts + lock.unlock() + if attempt == 1 { + return ( + status: 255, + stdout: "", + stderr: "remote port forwarding failed for listen port 64045" + ) + } + retryForwardInvoked.signal() + return (status: 0, stdout: "", stderr: "") + } + lock.unlock() + return (status: 0, stdout: "", stderr: "") + } + + let command = arguments.last ?? "" + if command.contains("cmux_stale_relay_listener_cleanup=1") { + lock.lock() + cleanupInvoked = true + cleanupArguments = arguments + lock.unlock() + return ( + status: 0, + stdout: "cmux_stale_relay_killed pid=33681 children=34057 port=64045\n", + stderr: "" + ) + } + 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 + """, + stderr: "" + ) + } + if command.contains("serve --stdio") { + return ( + status: 0, + stdout: #"{"id":1,"ok":true,"result":{"name":"cmuxd-remote","version":"dev","capabilities":["proxy.stream.push","pty.session","pty.session.token","pty.session.persistent_daemon"]}}"# + "\n", + stderr: "" + ) + } + return (status: 0, stdout: "", stderr: "") + } + defer { WorkspaceRemoteSessionController.runProcessOverrideForTesting = nil } + + let workspace = Workspace() + let config = WorkspaceRemoteConfiguration( + destination: "test@hpc.example", + port: 2222, + identityFile: nil, + sshOptions: [ + "ControlMaster=auto", + "ControlPersist=600", + "ControlPath=/tmp/cmux-ssh-\(getuid())-64045-%C", + "StrictHostKeyChecking=accept-new", + ], + localProxyPort: nil, + relayPort: 64045, + relayID: "relay-stale-forward-retry", + relayToken: String(repeating: "d", count: 64), + localSocketPath: "/tmp/cmux-stale-forward-retry.sock", + terminalStartupCommand: "ssh-pty-attach", + preserveAfterTerminalExit: true, + persistentDaemonSlot: "ssh-stale-forward-retry" + ) + defer { workspace.disconnectRemoteConnection(clearConfiguration: true) } + + workspace.configureRemoteConnection(config, autoConnect: true) + + XCTAssertEqual(retryForwardInvoked.wait(timeout: .now() + 2), .success) + lock.lock() + let operations = controlOperations + let cleanupWasInvoked = cleanupInvoked + let capturedCleanupArguments = cleanupArguments + let attempts = forwardAttempts + lock.unlock() + + XCTAssertEqual(attempts, 2) + XCTAssertTrue(cleanupWasInvoked) + XCTAssertTrue(capturedCleanupArguments.contains("-S")) + XCTAssertTrue(capturedCleanupArguments.contains("none")) + XCTAssertFalse(capturedCleanupArguments.contains(where: { $0.hasPrefix("ControlPath=") })) + XCTAssertGreaterThanOrEqual(operations.count, 3) + XCTAssertEqual(operations[0].command, "cancel") + XCTAssertEqual(operations[0].spec, "127.0.0.1:64045") + XCTAssertEqual(operations[1].command, "forward") + XCTAssertEqual(operations[2].command, "forward") + XCTAssertEqual(operations[1].spec, operations[2].spec) + XCTAssertTrue(operations[2].spec.hasPrefix("127.0.0.1:64045:127.0.0.1:")) + } + @MainActor func testDetachAttachPreservesRemoteTerminalSurfaceTracking() throws { let workspace = Workspace() From 6f94d5ac8ff9079d82d1c11a5f94f8dd1923bff7 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 29 May 2026 02:27:53 -0700 Subject: [PATCH 46/69] fix: clean stale ssh relay listeners --- Sources/Workspace.swift | 173 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 164 insertions(+), 9 deletions(-) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 243f1253195e..9b3ea7431eb7 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -6480,7 +6480,12 @@ final class WorkspaceRemoteSessionController { "remote.relay.startFailed relayPort=\(relayPort) " + "error=\(startupFailure)" ) - relayServer?.stop() + if let relayServer { + relayServer.stop() + if cliRelayServer === relayServer { + cliRelayServer = nil + } + } publishDaemonStatus( .error, detail: "Remote SSH relay unavailable: \(startupFailure) (retry in \(retrySeconds)s)" @@ -6516,8 +6521,12 @@ final class WorkspaceRemoteSessionController { "remote.relay.startFailed relayPort=\(relayPort) " + "error=\(error.localizedDescription)" ) - relayServer?.stop() - cliRelayServer = nil + if let relayServer { + relayServer.stop() + if cliRelayServer === relayServer { + cliRelayServer = nil + } + } scheduleReverseRelayRestartLocked(remotePath: remotePath, delay: 2.0) } } @@ -6855,12 +6864,24 @@ final class WorkspaceRemoteSessionController { cancelStaleReverseRelayViaControlMasterLocked(relayPort: relayPort) do { - let result = try sshExec(arguments: arguments, timeout: 6) + var result = try sshExec(arguments: arguments, timeout: 6) guard result.status == 0 else { let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "ssh exited \(result.status)" debugLog("remote.relay.controlmaster.forwardFailed \(detail) \(debugConfigSummary())") - return false + guard cleanupStaleRemoteRelayListenerLocked(relayPort: relayPort) else { + return false + } + + result = try sshExec(arguments: arguments, timeout: 6) + guard result.status == 0 else { + let retryDetail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) + ?? "ssh exited \(result.status)" + debugLog("remote.relay.controlmaster.forwardRetryFailed \(retryDetail) \(debugConfigSummary())") + return false + } + reverseRelayControlMasterForwardSpec = forwardSpec + return true } reverseRelayControlMasterForwardSpec = forwardSpec return true @@ -6891,6 +6912,44 @@ final class WorkspaceRemoteSessionController { } } + private func cleanupStaleRemoteRelayListenerLocked(relayPort: Int) -> Bool { + guard let script = Self.remoteStaleRelayListenerCleanupScript( + relayPort: relayPort, + persistentDaemonSlot: configuration.persistentDaemonSlot + ) else { + debugLog("remote.relay.remoteListener.cleanupSkipped reason=no-persistent-slot relayPort=\(relayPort)") + return false + } + + let command = "sh -c \(Self.shellSingleQuoted(script))" + do { + let result = try sshExec( + arguments: ["-S", "none"] + sshCommonArguments(batchMode: true, dropControlPath: true) + [ + configuration.destination, + command, + ], + timeout: 8 + ) + guard result.status == 0 else { + let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) + ?? "ssh exited \(result.status)" + debugLog("remote.relay.remoteListener.cleanupFailed relayPort=\(relayPort) \(detail) \(debugConfigSummary())") + return false + } + + let output = result.stdout.trimmingCharacters(in: .whitespacesAndNewlines) + if output.isEmpty { + debugLog("remote.relay.remoteListener.cleanupNoop relayPort=\(relayPort) \(debugConfigSummary())") + } else { + debugLog("remote.relay.remoteListener.cleanup relayPort=\(relayPort) \(Self.debugLogSnippet(output)) \(debugConfigSummary())") + } + return true + } catch { + debugLog("remote.relay.remoteListener.cleanupFailed relayPort=\(relayPort) \(error.localizedDescription) \(debugConfigSummary())") + return false + } + } + private func stopReverseRelayViaControlMasterLocked() { guard let forwardSpec = reverseRelayControlMasterForwardSpec else { return } reverseRelayControlMasterForwardSpec = nil @@ -6939,10 +6998,10 @@ final class WorkspaceRemoteSessionController { return message.isEmpty ? "remote daemon bootstrap failed" : message } - private func sshCommonArguments(batchMode: Bool) -> [String] { + private func sshCommonArguments(batchMode: Bool, dropControlPath: Bool = false) -> [String] { let effectiveSSHOptions: [String] = { if batchMode { - return backgroundSSHOptions(configuration.sshOptions) + return backgroundSSHOptions(configuration.sshOptions, dropControlPath: dropControlPath) } return normalizedSSHOptions(configuration.sshOptions) }() @@ -6990,11 +7049,14 @@ final class WorkspaceRemoteSessionController { } } - private func backgroundSSHOptions(_ options: [String]) -> [String] { - let batchSSHControlOptionKeys: Set = [ + private func backgroundSSHOptions(_ options: [String], dropControlPath: Bool = false) -> [String] { + var batchSSHControlOptionKeys: Set = [ "controlmaster", "controlpersist", ] + if dropControlPath { + batchSSHControlOptionKeys.insert("controlpath") + } return normalizedSSHOptions(options).filter { option in guard let key = sshOptionKey(option) else { return false } return !batchSSHControlOptionKeys.contains(key) @@ -7355,6 +7417,99 @@ final class WorkspaceRemoteSessionController { """ } + static func remoteStaleRelayListenerCleanupScript( + relayPort: Int, + persistentDaemonSlot: String? + ) -> String? { + guard relayPort > 0, relayPort <= 65535 else { return nil } + guard let persistentDaemonSlot = normalizedPersistentDaemonSlotForRemoteCleanup(persistentDaemonSlot) else { + return nil + } + + return """ + cmux_stale_relay_listener_cleanup=1 + cmux_relay_port='\(relayPort)' + cmux_persistent_slot=\(shellSingleQuoted(persistentDaemonSlot)) + cmux_listener_pids='' + if command -v lsof >/dev/null 2>&1; then + cmux_listener_pids="$(lsof -nP -iTCP:"$cmux_relay_port" -sTCP:LISTEN -Fpn 2>/dev/null | awk -v port="$cmux_relay_port" ' + /^p/ { pid = substr($0, 2); next } + /^n/ { + name = substr($0, 2) + if (pid ~ /^[0-9]+$/ && name ~ ("(^|[^0-9])127[.]0[.]0[.]1:" port "$")) { + seen[pid] = 1 + } + } + END { + for (pid in seen) print pid + } + ')" + fi + [ -n "$cmux_listener_pids" ] || exit 0 + cmux_ps_output="$(ps -axo pid=,ppid=,command= 2>/dev/null || true)" + for cmux_listener_pid in $cmux_listener_pids; do + case "$cmux_listener_pid" in + ''|*[!0-9]*) continue ;; + esac + cmux_listener_command="$(printf '%s\\n' "$cmux_ps_output" | awk -v target="$cmux_listener_pid" '$1 == target { $1 = ""; $2 = ""; sub(/^[[:space:]]+/, ""); print; exit }')" + case "$cmux_listener_command" in + *sshd*|*ssh*) ;; + *) continue ;; + esac + cmux_child_pids="$(printf '%s\\n' "$cmux_ps_output" | awk -v parent="$cmux_listener_pid" -v slot="$cmux_persistent_slot" ' + $2 == parent && + index($0, "cmuxd-remote") && + index($0, "serve") && + index($0, "--stdio") && + index($0, "--persistent") && + index($0, slot) && + $1 ~ /^[0-9]+$/ { + print $1 + } + ')" + cmux_cleanup_reason=child + if [ -z "$cmux_child_pids" ]; then + cmux_cleanup_reason=metadata + cmux_metadata_ok=0 + cmux_daemon_map="$HOME/.cmux/relay/${cmux_relay_port}.daemon_path" + cmux_auth_file="$HOME/.cmux/relay/${cmux_relay_port}.auth" + if [ -r "$cmux_daemon_map" ]; then + cmux_daemon_path="$(tr -d '\\r\\n' < "$cmux_daemon_map")" + case "$cmux_daemon_path" in + *cmuxd-remote*) cmux_metadata_ok=1 ;; + esac + fi + if [ "$cmux_metadata_ok" -ne 1 ] && [ -r "$cmux_auth_file" ]; then + cmux_auth_payload="$(tr -d '\\r\\n' < "$cmux_auth_file")" + case "$cmux_auth_payload" in + *relay_id*relay_token*) cmux_metadata_ok=1 ;; + esac + fi + [ "$cmux_metadata_ok" -eq 1 ] || continue + fi + kill -TERM "$cmux_listener_pid" $cmux_child_pids 2>/dev/null || true + for cmux_child_pid in $cmux_child_pids; do + kill -0 "$cmux_child_pid" 2>/dev/null && kill -KILL "$cmux_child_pid" 2>/dev/null || true + done + kill -0 "$cmux_listener_pid" 2>/dev/null && kill -KILL "$cmux_listener_pid" 2>/dev/null || true + cmux_child_list="$(printf '%s\\n' "$cmux_child_pids" | tr '\\n' ' ' | sed 's/[[:space:]]*$//')" + printf 'cmux_stale_relay_killed pid=%s children=%s port=%s reason=%s\\n' "$cmux_listener_pid" "$cmux_child_list" "$cmux_relay_port" "$cmux_cleanup_reason" + done + """ + } + + private static func normalizedPersistentDaemonSlotForRemoteCleanup(_ value: String?) -> String? { + guard let value else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, + trimmed != ".", + trimmed != "..", + trimmed.range(of: "^[A-Za-z0-9._-]{1,128}$", options: .regularExpression) != nil else { + return nil + } + return trimmed + } + private func probeRemoteBootstrapStateLocked(version: String) throws -> RemoteBootstrapState { let script = """ cmux_uname_os="$(uname -s)" From 7ca9850a4fd3e29d55f596a2dcbb207f9c4e436a Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 29 May 2026 03:07:32 -0700 Subject: [PATCH 47/69] fix: default persistent ssh daemon slot --- Sources/TerminalController.swift | 8 ++--- ...erminalControllerSocketSecurityTests.swift | 29 ++++++++++++++----- 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 6ca921c1ceba..2c27cd046b07 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -6223,7 +6223,7 @@ class TerminalController { let localSocketPath = v2RawString(params, "local_socket_path") let terminalStartupCommand = v2RawString(params, "terminal_startup_command")? .trimmingCharacters(in: .whitespacesAndNewlines) - let persistentDaemonSlot = v2RawString(params, "persistent_daemon_slot")? + var persistentDaemonSlot = v2RawString(params, "persistent_daemon_slot")? .trimmingCharacters(in: .whitespacesAndNewlines) if v2HasNonNullParam(params, "persistent_daemon_slot") { guard let persistentDaemonSlot, @@ -6291,11 +6291,7 @@ class TerminalController { !skipDaemonBootstrap, daemonWebSocketEndpoint == nil, persistentDaemonSlot == nil { - return .err( - code: "invalid_params", - message: "persistent_daemon_slot is required when preserve_after_terminal_exit is true for bootstrap SSH", - data: nil - ) + persistentDaemonSlot = "ssh-\(workspaceId.uuidString.lowercased())" } if relayPort != nil { guard let relayID, !relayID.isEmpty else { diff --git a/cmuxTests/TerminalControllerSocketSecurityTests.swift b/cmuxTests/TerminalControllerSocketSecurityTests.swift index dddc93608080..d717f28d99fd 100644 --- a/cmuxTests/TerminalControllerSocketSecurityTests.swift +++ b/cmuxTests/TerminalControllerSocketSecurityTests.swift @@ -222,23 +222,38 @@ final class TerminalControllerSocketSecurityTests: XCTestCase { ) } - func testRemoteConfigureRejectsPreserveWithoutPersistentDaemonSlotForBootstrapSSH() throws { + func testRemoteConfigureDefaultsPersistentDaemonSlotForBootstrapSSH() throws { + let previousAppDelegate = AppDelegate.shared + let appDelegate = AppDelegate() + AppDelegate.shared = appDelegate + defer { AppDelegate.shared = previousAppDelegate } + + let manager = TabManager() + let workspace = manager.addWorkspace(select: false, eagerLoadTerminal: false) + let windowId = appDelegate.registerMainWindowContextForTesting(tabManager: manager) + defer { + appDelegate.unregisterMainWindowContextForTesting(windowId: windowId) + if manager.tabs.contains(where: { $0.id == workspace.id }) { + manager.closeWorkspace(workspace) + } + } + let response = try handleV2Request( method: "workspace.remote.configure", params: [ - "workspace_id": UUID().uuidString, + "workspace_id": workspace.id.uuidString, "transport": "ssh", "destination": "example.com", "preserve_after_terminal_exit": true, + "auto_connect": false, ] ) - XCTAssertEqual(response["ok"] as? Bool, false, "Unexpected JSON-RPC response: \(response)") - let error = try XCTUnwrap(response["error"] as? [String: Any]) - XCTAssertEqual(error["code"] as? String, "invalid_params") + XCTAssertEqual(response["ok"] as? Bool, true, "Unexpected JSON-RPC response: \(response)") + XCTAssertEqual(workspace.remoteConfiguration?.preserveAfterTerminalExit, true) XCTAssertEqual( - error["message"] as? String, - "persistent_daemon_slot is required when preserve_after_terminal_exit is true for bootstrap SSH" + workspace.remoteConfiguration?.persistentDaemonSlot, + "ssh-\(workspace.id.uuidString.lowercased())" ) } From da48e45388b7512c6706a5c6a73808c93be249f6 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 29 May 2026 04:25:57 -0700 Subject: [PATCH 48/69] fix: preserve relay command line endings --- Sources/Workspace.swift | 5 ++++- cmuxTests/TabManagerSessionSnapshotTests.swift | 4 ++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 9b3ea7431eb7..4cf61eafe201 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -12802,7 +12802,10 @@ final class Workspace: Identifiable, ObservableObject { let rewritten = try? JSONSerialization.data(withJSONObject: request, options: []) else { return commandLine } - return rewritten + Data([0x0A]) + if commandLine.last == 0x0A { + return rewritten + Data([0x0A]) + } + return rewritten } private nonisolated static func remappedRemoteRelayValue( diff --git a/cmuxTests/TabManagerSessionSnapshotTests.swift b/cmuxTests/TabManagerSessionSnapshotTests.swift index 00195fcabdeb..957b73f932f1 100644 --- a/cmuxTests/TabManagerSessionSnapshotTests.swift +++ b/cmuxTests/TabManagerSessionSnapshotTests.swift @@ -1993,6 +1993,10 @@ final class TabManagerSessionSnapshotTests: XCTestCase { 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) From abe618b3cb6a508562147321d3ed36264acba8af Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 29 May 2026 05:11:57 -0700 Subject: [PATCH 49/69] fix: harden persistent ssh relay cleanup --- Sources/Workspace.swift | 47 +++++++---- .../WorkspaceRemoteConnectionTests.swift | 81 +++++++++++++++++++ daemon/remote/cmd/cmuxd-remote/main.go | 13 +++ daemon/remote/cmd/cmuxd-remote/main_test.go | 52 ++++++++++++ 4 files changed, 178 insertions(+), 15 deletions(-) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 13b82a1c4030..512db90383f5 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -7745,7 +7745,8 @@ final class WorkspaceRemoteSessionController { daemonRemotePath: remotePath, relayPort: relayPort, relayID: relayID, - relayToken: relayToken + relayToken: relayToken, + persistentDaemonSlot: configuration.persistentDaemonSlot ) let command = "sh -c \(Self.shellSingleQuoted(script))" let result = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 8) @@ -7781,7 +7782,7 @@ final class WorkspaceRemoteSessionController { if [ -r "$socket_addr_file" ] && [ "$(tr -d '\\r\\n' < "$socket_addr_file")" = "$relay_socket" ]; then rm -f "$socket_addr_file" fi - rm -f "$HOME/.cmux/relay/\(relayPort).auth" "$HOME/.cmux/relay/\(relayPort).daemon_path" "$HOME/.cmux/relay/\(relayPort).tty" + rm -f "$HOME/.cmux/relay/\(relayPort).auth" "$HOME/.cmux/relay/\(relayPort).daemon_path" "$HOME/.cmux/relay/\(relayPort).slot" "$HOME/.cmux/relay/\(relayPort).tty" """ } @@ -7839,19 +7840,27 @@ final class WorkspaceRemoteSessionController { if [ -z "$cmux_child_pids" ]; then cmux_cleanup_reason=metadata cmux_metadata_ok=0 - cmux_daemon_map="$HOME/.cmux/relay/${cmux_relay_port}.daemon_path" - cmux_auth_file="$HOME/.cmux/relay/${cmux_relay_port}.auth" - if [ -r "$cmux_daemon_map" ]; then - cmux_daemon_path="$(tr -d '\\r\\n' < "$cmux_daemon_map")" - case "$cmux_daemon_path" in - *cmuxd-remote*) cmux_metadata_ok=1 ;; - esac + cmux_slot_file="$HOME/.cmux/relay/${cmux_relay_port}.slot" + cmux_metadata_slot_ok=0 + if [ -r "$cmux_slot_file" ]; then + cmux_stored_slot="$(tr -d '\\r\\n' < "$cmux_slot_file")" + [ "$cmux_stored_slot" = "$cmux_persistent_slot" ] && cmux_metadata_slot_ok=1 fi - if [ "$cmux_metadata_ok" -ne 1 ] && [ -r "$cmux_auth_file" ]; then - cmux_auth_payload="$(tr -d '\\r\\n' < "$cmux_auth_file")" - case "$cmux_auth_payload" in - *relay_id*relay_token*) cmux_metadata_ok=1 ;; - esac + if [ "$cmux_metadata_slot_ok" -eq 1 ]; then + cmux_daemon_map="$HOME/.cmux/relay/${cmux_relay_port}.daemon_path" + cmux_auth_file="$HOME/.cmux/relay/${cmux_relay_port}.auth" + if [ -r "$cmux_daemon_map" ]; then + cmux_daemon_path="$(tr -d '\\r\\n' < "$cmux_daemon_map")" + case "$cmux_daemon_path" in + *cmuxd-remote*) cmux_metadata_ok=1 ;; + esac + fi + if [ "$cmux_metadata_ok" -ne 1 ] && [ -r "$cmux_auth_file" ]; then + cmux_auth_payload="$(tr -d '\\r\\n' < "$cmux_auth_file")" + case "$cmux_auth_payload" in + *relay_id*relay_token*) cmux_metadata_ok=1 ;; + esac + fi fi [ "$cmux_metadata_ok" -eq 1 ] || continue fi @@ -8455,10 +8464,17 @@ final class WorkspaceRemoteSessionController { daemonRemotePath: String, relayPort: Int, relayID: String, - relayToken: String + relayToken: String, + persistentDaemonSlot: String? = nil ) -> String { let trimmedRemotePath = daemonRemotePath.trimmingCharacters(in: .whitespacesAndNewlines) let daemonPathExpression = remoteDaemonPathShellExpression(trimmedRemotePath) + let slotMetadataLine: String + if let slot = normalizedPersistentDaemonSlotForRemoteCleanup(persistentDaemonSlot) { + slotMetadataLine = "printf '%s' \(shellSingleQuoted(slot)) > \"$HOME/.cmux/relay/\(relayPort).slot\"\nchmod 600 \"$HOME/.cmux/relay/\(relayPort).slot\"" + } else { + slotMetadataLine = "rm -f \"$HOME/.cmux/relay/\(relayPort).slot\"" + } let authPayload = """ {"relay_id":"\(relayID)","relay_token":"\(relayToken)"} """ @@ -8468,6 +8484,7 @@ final class WorkspaceRemoteSessionController { chmod 700 "$HOME/.cmux/relay" \(remoteCLIWrapperInstallScript(daemonRemotePath: trimmedRemotePath)) printf '%s' \(daemonPathExpression) > "$HOME/.cmux/relay/\(relayPort).daemon_path" + \(slotMetadataLine) cat > "$HOME/.cmux/relay/\(relayPort).auth" <<'CMUXRELAYAUTH' \(authPayload) CMUXRELAYAUTH diff --git a/cmuxTests/WorkspaceRemoteConnectionTests.swift b/cmuxTests/WorkspaceRemoteConnectionTests.swift index 3fa9fb9ae639..98426c751ec8 100644 --- a/cmuxTests/WorkspaceRemoteConnectionTests.swift +++ b/cmuxTests/WorkspaceRemoteConnectionTests.swift @@ -124,12 +124,14 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { let socketAddrURL = home.appendingPathComponent(".cmux/socket_addr") let authURL = relayDir.appendingPathComponent("64008.auth") let daemonPathURL = relayDir.appendingPathComponent("64008.daemon_path") + let slotURL = relayDir.appendingPathComponent("64008.slot") let ttyURL = relayDir.appendingPathComponent("64008.tty") XCTAssertNoThrow(try fileManager.createDirectory(at: relayDir, withIntermediateDirectories: true)) XCTAssertNoThrow(try "127.0.0.1:64008".write(to: socketAddrURL, atomically: true, encoding: .utf8)) XCTAssertNoThrow(try "auth".write(to: authURL, atomically: true, encoding: .utf8)) XCTAssertNoThrow(try "daemon".write(to: daemonPathURL, atomically: true, encoding: .utf8)) + XCTAssertNoThrow(try "slot".write(to: slotURL, atomically: true, encoding: .utf8)) XCTAssertNoThrow(try "ttys001".write(to: ttyURL, atomically: true, encoding: .utf8)) defer { try? fileManager.removeItem(at: home) } @@ -149,6 +151,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { XCTAssertFalse(fileManager.fileExists(atPath: socketAddrURL.path)) XCTAssertFalse(fileManager.fileExists(atPath: authURL.path)) XCTAssertFalse(fileManager.fileExists(atPath: daemonPathURL.path)) + XCTAssertFalse(fileManager.fileExists(atPath: slotURL.path)) XCTAssertFalse(fileManager.fileExists(atPath: ttyURL.path)) } @@ -159,12 +162,14 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { let socketAddrURL = home.appendingPathComponent(".cmux/socket_addr") let authURL = relayDir.appendingPathComponent("64009.auth") let daemonPathURL = relayDir.appendingPathComponent("64009.daemon_path") + let slotURL = relayDir.appendingPathComponent("64009.slot") let ttyURL = relayDir.appendingPathComponent("64009.tty") XCTAssertNoThrow(try fileManager.createDirectory(at: relayDir, withIntermediateDirectories: true)) XCTAssertNoThrow(try "127.0.0.1:64010".write(to: socketAddrURL, atomically: true, encoding: .utf8)) XCTAssertNoThrow(try "auth".write(to: authURL, atomically: true, encoding: .utf8)) XCTAssertNoThrow(try "daemon".write(to: daemonPathURL, atomically: true, encoding: .utf8)) + XCTAssertNoThrow(try "slot".write(to: slotURL, atomically: true, encoding: .utf8)) XCTAssertNoThrow(try "ttys002".write(to: ttyURL, atomically: true, encoding: .utf8)) defer { try? fileManager.removeItem(at: home) } @@ -184,6 +189,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { XCTAssertTrue(fileManager.fileExists(atPath: socketAddrURL.path)) XCTAssertFalse(fileManager.fileExists(atPath: authURL.path)) XCTAssertFalse(fileManager.fileExists(atPath: daemonPathURL.path)) + XCTAssertFalse(fileManager.fileExists(atPath: slotURL.path)) XCTAssertFalse(fileManager.fileExists(atPath: ttyURL.path)) } @@ -321,6 +327,11 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { atomically: true, encoding: .utf8 ) + try "ssh-c4ba8ab1".write( + to: relayDir.appendingPathComponent("50446.slot"), + atomically: true, + encoding: .utf8 + ) try "".write(to: killLog, atomically: true, encoding: .utf8) defer { try? fileManager.removeItem(at: root) } @@ -379,6 +390,76 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { XCTAssertTrue(killOutput.contains("-KILL 33681"), killOutput) } + func testRemoteStaleRelayListenerCleanupScriptPreservesMetadataMatchedDifferentPersistentSlot() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory.appendingPathComponent("cmux-stale-relay-metadata-preserve-\(UUID().uuidString)") + let bin = root.appendingPathComponent("bin") + let relayDir = root.appendingPathComponent(".cmux/relay") + let killLog = root.appendingPathComponent("kill.log") + try fileManager.createDirectory(at: bin, withIntermediateDirectories: true) + try fileManager.createDirectory(at: relayDir, withIntermediateDirectories: true) + try "/Users/cmux/.cmux/bin/cmuxd-remote/current/darwin-arm64/cmuxd-remote".write( + to: relayDir.appendingPathComponent("50446.daemon_path"), + atomically: true, + encoding: .utf8 + ) + try "ssh-other-slot".write( + to: relayDir.appendingPathComponent("50446.slot"), + atomically: true, + encoding: .utf8 + ) + try "".write(to: killLog, atomically: true, encoding: .utf8) + defer { try? fileManager.removeItem(at: root) } + + try writeExecutableShellFile( + at: bin.appendingPathComponent("lsof"), + body: """ + #!/bin/sh + cat <<'EOF' + p33681 + f12 + n127.0.0.1:50446 + EOF + """ + ) + try writeExecutableShellFile( + at: bin.appendingPathComponent("ps"), + body: """ + #!/bin/sh + cat <<'EOF' + 33681 1 /usr/sbin/sshd-session + EOF + """ + ) + + let script = try XCTUnwrap( + WorkspaceRemoteSessionController.remoteStaleRelayListenerCleanupScript( + relayPort: 50446, + persistentDaemonSlot: "ssh-c4ba8ab1" + ) + ) + let result = runProcess( + executablePath: "/usr/bin/env", + arguments: [ + "HOME=\(root.path)", + "PATH=\(bin.path):/usr/bin:/bin", + "CMUX_KILL_LOG=\(killLog.path)", + "/bin/sh", + "-c", + """ + kill() { printf '%s\\n' "$*" >> "$CMUX_KILL_LOG"; return 0; } + \(script) + """, + ], + timeout: 5 + ) + + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + XCTAssertEqual(result.stdout, "") + XCTAssertEqual(try String(contentsOf: killLog, encoding: .utf8), "") + } + func testRelayZshBootstrapUsesRealHomeHistoryByDefault() throws { let histfile = try runRelayZshHistfile { home in try ":\n".write(to: home.appendingPathComponent(".zshenv"), atomically: true, encoding: .utf8) diff --git a/daemon/remote/cmd/cmuxd-remote/main.go b/daemon/remote/cmd/cmuxd-remote/main.go index 24fda08997df..85a5e0dffbcf 100644 --- a/daemon/remote/cmd/cmuxd-remote/main.go +++ b/daemon/remote/cmd/cmuxd-remote/main.go @@ -443,6 +443,9 @@ func ensurePersistentDaemonSocketDirectory(root string, defaultSocketDir string) if verifyErr := ensurePrivateDaemonLeafDirectory(storedSocketDir); verifyErr == nil { return storedSocketDir, nil } + if removeErr := removePersistentDaemonSocketDirMetadata(root); removeErr != nil { + return "", removeErr + } } else if !errors.Is(err, os.ErrNotExist) { return "", err } @@ -536,6 +539,9 @@ func createPersistentDaemonFallbackSocketDir(root string) (string, error) { return storedSocketDir, nil } } + if removeErr := removePersistentDaemonSocketDirMetadata(root); removeErr != nil { + return "", removeErr + } continue } return "", err @@ -571,6 +577,13 @@ func writePersistentDaemonSocketDir(root string, socketDir string) error { return os.Link(tmpPath, filepath.Join(root, persistentDaemonSocketDirFile)) } +func removePersistentDaemonSocketDirMetadata(root string) error { + if err := os.Remove(filepath.Join(root, persistentDaemonSocketDirFile)); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + return nil +} + func persistentDaemonToken(paths persistentDaemonPaths) (string, error) { if token, err := readPersistentDaemonTokenFile(paths.tokenFile); err == nil { return token, nil diff --git a/daemon/remote/cmd/cmuxd-remote/main_test.go b/daemon/remote/cmd/cmuxd-remote/main_test.go index 5d3dc2799ab0..7a2770353f8a 100644 --- a/daemon/remote/cmd/cmuxd-remote/main_test.go +++ b/daemon/remote/cmd/cmuxd-remote/main_test.go @@ -468,6 +468,58 @@ func TestPersistentDaemonSocketDirFallsBackFromUnsafeSymlink(t *testing.T) { } } +func TestPersistentDaemonSocketDirReplacesInvalidStoredFallback(t *testing.T) { + rootBase := filepath.Join(t.TempDir(), "daemon-root") + socketParent := filepath.Join(t.TempDir(), "caller-socket-dir") + if err := os.MkdirAll(socketParent, 0o755); err != nil { + t.Fatalf("create socket parent: %v", err) + } + unsafeTarget := filepath.Join(t.TempDir(), "attacker-dir") + if err := os.MkdirAll(unsafeTarget, 0o755); err != nil { + t.Fatalf("create unsafe target: %v", err) + } + unsafeChild := filepath.Join(socketParent, fmt.Sprintf("cmuxd-remote-%d", os.Getuid())) + if err := os.Symlink(unsafeTarget, unsafeChild); err != nil { + t.Fatalf("create unsafe socket child symlink: %v", err) + } + t.Setenv("CMUX_REMOTE_DAEMON_ROOT", rootBase) + t.Setenv("CMUX_REMOTE_DAEMON_SOCKET_DIR", socketParent) + + paths, err := persistentDaemonPathsForSlot("invalid-stored-fallback-slot") + if err != nil { + t.Fatalf("persistentDaemonPathsForSlot returned error: %v", err) + } + if err := os.MkdirAll(paths.root, 0o700); err != nil { + t.Fatalf("create daemon root: %v", err) + } + invalidStoredSocketDir := filepath.Join(t.TempDir(), "invalid-stored-socket-dir") + if err := os.WriteFile(invalidStoredSocketDir, []byte("not a directory"), 0o600); err != nil { + t.Fatalf("create invalid stored socket path: %v", err) + } + if err := os.WriteFile(filepath.Join(paths.root, persistentDaemonSocketDirFile), []byte(invalidStoredSocketDir+"\n"), 0o600); err != nil { + t.Fatalf("write invalid stored socket-dir metadata: %v", err) + } + + paths, err = ensurePersistentDaemonDirectory(paths) + if err != nil { + t.Fatalf("ensurePersistentDaemonDirectory returned error: %v", err) + } + socketDir := filepath.Dir(paths.socket) + if socketDir == unsafeChild { + t.Fatalf("socket dir still points at unsafe child %q", socketDir) + } + if filepath.Clean(filepath.Dir(socketDir)) != filepath.Clean(os.TempDir()) { + t.Fatalf("fallback socket dir parent = %q, want %q", filepath.Dir(socketDir), os.TempDir()) + } + storedSocketDir, err := readPersistentDaemonSocketDir(paths.root) + if err != nil { + t.Fatalf("read stored replacement socket dir: %v", err) + } + if storedSocketDir != socketDir { + t.Fatalf("stored socket dir = %q, want replacement %q", storedSocketDir, socketDir) + } +} + func TestPersistentDaemonSocketDirReusesStoredFallback(t *testing.T) { rootBase := filepath.Join(t.TempDir(), "daemon-root") socketParent := filepath.Join(t.TempDir(), "caller-socket-dir") From 4f7ddc3c2713a02c1ff193a7ab274fbbb6183fb2 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 29 May 2026 05:40:34 -0700 Subject: [PATCH 50/69] fix: suppress remote restore scaffold attaches --- Sources/Workspace.swift | 11 ++- .../TabManagerSessionSnapshotTests.swift | 68 +++++++++++++++++++ 2 files changed, 78 insertions(+), 1 deletion(-) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 512db90383f5..06b5330c75eb 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -295,7 +295,12 @@ extension Workspace { } let panelSnapshotsById = Dictionary(uniqueKeysWithValues: snapshot.panels.map { ($0.id, $0) }) - let leafEntries = restoreSessionLayout(snapshot.layout) + let leafEntries: [SessionPaneRestoreEntry] = { + let previousValue = suppressRemoteTerminalStartupForSessionRestoreScaffold + suppressRemoteTerminalStartupForSessionRestoreScaffold = true + defer { suppressRemoteTerminalStartupForSessionRestoreScaffold = previousValue } + return restoreSessionLayout(snapshot.layout) + }() var oldToNewPanelIds: [UUID: UUID] = [:] for entry in leafEntries { @@ -10323,6 +10328,7 @@ final class Workspace: Identifiable, ObservableObject { private var remotePTYSessionIDsByPanelId: [UUID: String] = [:] private var remoteRelayWorkspaceIDAliases: [UUID: UUID] = [:] private var remoteRelaySurfaceIDAliases: [UUID: UUID] = [:] + private var suppressRemoteTerminalStartupForSessionRestoreScaffold = false var pendingRemoteTerminalChildExitSurfaceIds: Set = [] /// Display target of the remote workspace that just disconnected. Set right before /// `createReplacementTerminalPanel()` so the replacement shell can print a banner @@ -14245,6 +14251,9 @@ final class Workspace: Identifiable, ObservableObject { } private func remoteTerminalStartupCommand() -> String? { + guard !suppressRemoteTerminalStartupForSessionRestoreScaffold else { + return nil + } guard let command = remoteConfiguration?.terminalStartupCommand? .trimmingCharacters(in: .whitespacesAndNewlines), !command.isEmpty else { diff --git a/cmuxTests/TabManagerSessionSnapshotTests.swift b/cmuxTests/TabManagerSessionSnapshotTests.swift index f085b36477b1..3b6e4a1e201a 100644 --- a/cmuxTests/TabManagerSessionSnapshotTests.swift +++ b/cmuxTests/TabManagerSessionSnapshotTests.swift @@ -1931,6 +1931,74 @@ final class TabManagerSessionSnapshotTests: XCTestCase { ) } + func testSessionSnapshotRestoresSplitPersistentSSHPTYWithoutDefaultAttachScaffold() throws { + 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 { let manager = TabManager() let remoteWorkspace = manager.addWorkspace(select: true) From c534be428f426aab60997ed0cbe4f7cdf89189e5 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 29 May 2026 06:13:47 -0700 Subject: [PATCH 51/69] test: use workspaceId in SSH PTY snapshot test --- cmuxTests/TabManagerSessionSnapshotTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmuxTests/TabManagerSessionSnapshotTests.swift b/cmuxTests/TabManagerSessionSnapshotTests.swift index e2e7f701f1ee..b5cc2229ec3e 100644 --- a/cmuxTests/TabManagerSessionSnapshotTests.swift +++ b/cmuxTests/TabManagerSessionSnapshotTests.swift @@ -2652,7 +2652,7 @@ final class TabManagerSessionSnapshotTests: XCTestCase { let workspaceIndex = try XCTUnwrap( legacySnapshot.workspaces.firstIndex { $0.customTitle == "Legacy Persistent SSH" } ) - XCTAssertEqual(legacySnapshot.workspaces[workspaceIndex].id, remoteWorkspace.id) + XCTAssertEqual(legacySnapshot.workspaces[workspaceIndex].workspaceId, remoteWorkspace.id) let panelIndex = try XCTUnwrap( legacySnapshot.workspaces[workspaceIndex].panels.firstIndex { $0.id == originalPanelId } ) From b900350d2686322387a232c5d84e97102ebb1ef4 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 29 May 2026 21:50:13 -0700 Subject: [PATCH 52/69] fix: address remote relay review feedback --- Resources/Localizable.xcstrings | 96 +++++++++++++++++++ Sources/Workspace.swift | 39 ++++++-- .../TabManagerSessionSnapshotTests.swift | 30 ++++++ 3 files changed, 156 insertions(+), 9 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index e0521100c850..4e5f68a1d6f5 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -2,6 +2,102 @@ "sourceLanguage": "en", "version": "1.0", "strings": { + "remoteDaemon.error.missingPersistentPTYCapability": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "remote daemon does not support persistent SSH PTY sessions; reconnect the remote workspace to update cmux" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リモートデーモンは永続 SSH PTY セッションをサポートしていません。cmux を更新するにはリモートワークスペースに再接続してください。" + } + } + } + }, + "remoteDaemon.error.missingRequiredFunctionality": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "remote daemon is missing required functionality; reconnect the remote workspace to update cmux" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リモートデーモンに必要な機能がありません。cmux を更新するにはリモートワークスペースに再接続してください。" + } + } + } + }, + "remotePTYAttach.error.attachFailed": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "remote PTY attach failed" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リモート PTY への接続に失敗しました" + } + } + } + }, + "remotePTYAttach.error.daemonTimeout": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "remote daemon did not respond in time" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リモートデーモンが時間内に応答しませんでした" + } + } + } + }, + "remotePTYAttach.error.inputBackedUp": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "remote PTY input is temporarily backed up" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リモート PTY 入力が一時的に滞留しています" + } + } + } + }, + "remotePTYAttach.error.sessionEnded": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "persistent SSH PTY session is no longer running" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "永続 SSH PTY セッションはもう実行されていません" + } + } + } + }, "settings.error.alert.dismiss": { "localizations": { "en": { diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index a669c0f999ef..e3af36b9bbd8 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -2403,9 +2403,15 @@ nonisolated func remoteDaemonMissingRequiredCapabilitiesMessage(_ missingCapabil if missing.contains(WorkspaceRemoteDaemonRPCClient.requiredPTYSessionCapability) || missing.contains(WorkspaceRemoteDaemonRPCClient.requiredPTYSessionTokenCapability) || missing.contains(WorkspaceRemoteDaemonRPCClient.requiredPTYPersistentDaemonCapability) { - return "remote daemon does not support persistent SSH PTY sessions; reconnect the remote workspace to update cmux" + return String( + localized: "remoteDaemon.error.missingPersistentPTYCapability", + defaultValue: "remote daemon does not support persistent SSH PTY sessions; reconnect the remote workspace to update cmux" + ) } - return "remote daemon is missing required functionality; reconnect the remote workspace to update cmux" + return String( + localized: "remoteDaemon.error.missingRequiredFunctionality", + defaultValue: "remote daemon is missing required functionality; reconnect the remote workspace to update cmux" + ) } private final class WorkspaceRemoteDaemonRPCClient { @@ -5934,20 +5940,35 @@ final class WorkspaceRemotePTYBridgeServer { let message = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines) let lowered = message.lowercased() if lowered.contains("missing required capability") || lowered.contains("pty.session") { - return "remote daemon does not support persistent SSH PTY sessions; reconnect the remote workspace to update cmux" + return String( + localized: "remoteDaemon.error.missingPersistentPTYCapability", + defaultValue: "remote daemon does not support persistent SSH PTY sessions; reconnect the remote workspace to update cmux" + ) } if lowered.contains("pty_session_not_found") || (lowered.contains("persistent ssh pty session") && lowered.contains("not running")) || (lowered.contains("persistent pty session") && lowered.contains("not running")) { - return "persistent SSH PTY session is no longer running" + return String( + localized: "remotePTYAttach.error.sessionEnded", + defaultValue: "persistent SSH PTY session is no longer running" + ) } if lowered.contains("pty_input_queue_full") || lowered.contains("pty input queue is full") { - return "remote PTY input is temporarily backed up" + return String( + localized: "remotePTYAttach.error.inputBackedUp", + defaultValue: "remote PTY input is temporarily backed up" + ) } if lowered.contains("timed out") || lowered.contains("timeout") { - return "remote daemon did not respond in time" + return String( + localized: "remotePTYAttach.error.daemonTimeout", + defaultValue: "remote daemon did not respond in time" + ) } - return "remote PTY attach failed" + return String( + localized: "remotePTYAttach.error.attachFailed", + defaultValue: "remote PTY attach failed" + ) } } @@ -13344,11 +13365,11 @@ final class Workspace: Identifiable, ObservableObject { return value } - if let mapped = surfaceAliases[uuid] { + if let mapped = workspaceAliases[uuid] { didRewrite = true return mapped.uuidString } - if let mapped = workspaceAliases[uuid] { + if let mapped = surfaceAliases[uuid] { didRewrite = true return mapped.uuidString } diff --git a/cmuxTests/TabManagerSessionSnapshotTests.swift b/cmuxTests/TabManagerSessionSnapshotTests.swift index b5cc2229ec3e..369b9469c6d5 100644 --- a/cmuxTests/TabManagerSessionSnapshotTests.swift +++ b/cmuxTests/TabManagerSessionSnapshotTests.swift @@ -2419,6 +2419,36 @@ final class TabManagerSessionSnapshotTests: XCTestCase { XCTAssertEqual(clearedParams["panel_id"] as? String, originalPanelId.uuidString) } + func testRemoteRelayAmbiguousTabIDAliasesPreferWorkspaceOnCollision() throws { + let staleID = UUID() + let restoredWorkspaceID = UUID() + let restoredPanelID = UUID() + let request: [String: Any] = [ + "id": "relay-ambiguous-alias-request", + "method": "surface.report_tty", + "params": [ + "workspace_id": staleID.uuidString, + "surface_id": staleID.uuidString, + "tab_id": staleID.uuidString, + "tab_ids": [staleID.uuidString], + ], + ] + let requestData = try JSONSerialization.data(withJSONObject: request, options: []) + + let rewrittenData = Workspace.rewriteRemoteRelayCommandLine( + requestData, + workspaceAliases: [staleID: restoredWorkspaceID], + surfaceAliases: [staleID: restoredPanelID] + ) + let rewritten = try XCTUnwrap(JSONSerialization.jsonObject(with: rewrittenData) as? [String: Any]) + let params = try XCTUnwrap(rewritten["params"] as? [String: Any]) + + XCTAssertEqual(params["workspace_id"] as? String, restoredWorkspaceID.uuidString) + XCTAssertEqual(params["surface_id"] as? String, restoredPanelID.uuidString) + XCTAssertEqual(params["tab_id"] as? String, restoredWorkspaceID.uuidString) + XCTAssertEqual(params["tab_ids"] as? [String], [restoredWorkspaceID.uuidString]) + } + func testPersistentSSHPTYRestoreRewritesMovedSourceWorkspaceContextID() throws { let manager = TabManager() let sourceWorkspace = manager.addWorkspace(select: true) From f2aaf6b26572fc47ee8ea63957f10a0727b9a99f Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 29 May 2026 22:06:58 -0700 Subject: [PATCH 53/69] refactor: split remote shell bootstrap builder --- ...moteInteractiveShellBootstrapBuilder.swift | 273 ++++++++++++++++++ Sources/RemoteRelayZshBootstrap.swift | 272 ----------------- cmux.xcodeproj/project.pbxproj | 6 + 3 files changed, 279 insertions(+), 272 deletions(-) create mode 100644 Sources/RemoteInteractiveShellBootstrapBuilder.swift diff --git a/Sources/RemoteInteractiveShellBootstrapBuilder.swift b/Sources/RemoteInteractiveShellBootstrapBuilder.swift new file mode 100644 index 000000000000..6c5e14a26ee1 --- /dev/null +++ b/Sources/RemoteInteractiveShellBootstrapBuilder.swift @@ -0,0 +1,273 @@ +import Foundation + +enum RemoteInteractiveShellBootstrapBuilder { + static func script( + remoteRelayPort: Int, + shellFeatures: String, + terminfoSource: String? = nil, + bundledZshIntegration: String? = nil, + bundledBashIntegration: String? = nil + ) -> String { + let shellStateDir = shellStateDirForRemoteRelayPort(remoteRelayPort) + let commonShellExportLines = commonShellLines( + remoteRelayPort: remoteRelayPort, + shellStateDir: shellStateDir, + shellFeatures: shellFeatures, + terminfoSource: terminfoSource + ) + var zshShellLines = commonShellExportLines + zshShellLines.append( + #"if [ "${CMUX_SHELL_INTEGRATION:-1}" != "0" ] && [ -r "${CMUX_SHELL_INTEGRATION_DIR}/cmux-zsh-integration.zsh" ]; then . "${CMUX_SHELL_INTEGRATION_DIR}/cmux-zsh-integration.zsh"; fi"# + ) + var bashShellLines = commonShellExportLines + bashShellLines.append( + #"if [ "${CMUX_SHELL_INTEGRATION:-1}" != "0" ] && [ -r "${CMUX_SHELL_INTEGRATION_DIR}/cmux-bash-integration.bash" ]; then . "${CMUX_SHELL_INTEGRATION_DIR}/cmux-bash-integration.bash"; fi"# + ) + let zshBootstrap = RemoteRelayZshBootstrap(shellStateDir: shellStateDir) + let relayWarmupLines = relayWarmupLines(remoteRelayPort: remoteRelayPort) + + var outerLines: [String] = [ + "mkdir -p \"$HOME/.cmux/relay\"", + "cmux_shell_dir=\"\(shellStateDir)\"", + "mkdir -p \"$cmux_shell_dir\"", + ] + if let bundledZshIntegration { + outerLines += [ + "cat > \"$cmux_shell_dir/cmux-zsh-integration.zsh\" <<'CMUXCMUXZSH'", + bundledZshIntegration, + "CMUXCMUXZSH", + ] + } + if let bundledBashIntegration { + outerLines += [ + "cat > \"$cmux_shell_dir/cmux-bash-integration.bash\" <<'CMUXCMUXBASH'", + bundledBashIntegration, + "CMUXCMUXBASH", + ] + } + outerLines.append(contentsOf: commonShellExportLines) + outerLines += [ + "CMUX_LOGIN_SHELL=\"${SHELL:-/bin/zsh}\"", + "case \"${CMUX_LOGIN_SHELL##*/}\" in", + " zsh)", + " cat > \"$cmux_shell_dir/.zshenv\" <<'CMUXZSHENV'", + ] + outerLines.append(contentsOf: zshBootstrap.zshEnvLines) + outerLines += [ + "CMUXZSHENV", + " cat > \"$cmux_shell_dir/.zprofile\" <<'CMUXZSHPROFILE'", + ] + outerLines.append(contentsOf: zshBootstrap.zshProfileLines) + outerLines += [ + "CMUXZSHPROFILE", + " cat > \"$cmux_shell_dir/.zshrc\" <<'CMUXZSHRC'", + ] + outerLines.append(contentsOf: zshBootstrap.zshRCLines(commonShellLines: zshShellLines)) + outerLines += [ + "CMUXZSHRC", + " cat > \"$cmux_shell_dir/.zlogin\" <<'CMUXZSHLOGIN'", + ] + outerLines.append(contentsOf: zshBootstrap.zshLoginLines) + outerLines += [ + "CMUXZSHLOGIN", + " chmod 600 \"$cmux_shell_dir/.zshenv\" \"$cmux_shell_dir/.zprofile\" \"$cmux_shell_dir/.zshrc\" \"$cmux_shell_dir/.zlogin\" >/dev/null 2>&1 || true", + ] + outerLines.append(contentsOf: relayWarmupLines.map { " " + $0 }) + outerLines += [ + " export CMUX_REAL_ZDOTDIR=\"${ZDOTDIR:-$HOME}\"", + " export ZDOTDIR=\"$cmux_shell_dir\"", + " exec \"$CMUX_LOGIN_SHELL\" -il", + " ;;", + " bash)", + " cat > \"$cmux_shell_dir/.bashrc\" <<'CMUXBASHRC'", + ] + outerLines.append(contentsOf: [ + "if [ -f \"$HOME/.bash_profile\" ]; then . \"$HOME/.bash_profile\"; elif [ -f \"$HOME/.bash_login\" ]; then . \"$HOME/.profile\"; fi", + "[ -f \"$HOME/.bashrc\" ] && . \"$HOME/.bashrc\"", + ] + bashShellLines) + outerLines += [ + "CMUXBASHRC", + " chmod 600 \"$cmux_shell_dir/.bashrc\" >/dev/null 2>&1 || true", + ] + outerLines.append(contentsOf: relayWarmupLines.map { " " + $0 }) + outerLines += [ + " exec \"$CMUX_LOGIN_SHELL\" --rcfile \"$cmux_shell_dir/.bashrc\" -i", + " ;;", + " *)", + ] + outerLines.append(contentsOf: commonShellExportLines) + outerLines.append(contentsOf: relayWarmupLines) + outerLines += [ + "exec \"$CMUX_LOGIN_SHELL\" -i", + ";;", + "esac", + ] + + return outerLines.joined(separator: "\n") + } + + static func shellFeatures( + environment: [String: String] = ProcessInfo.processInfo.environment + ) -> String { + let rawExisting = environment["GHOSTTY_SHELL_FEATURES"] ?? "" + var seen: Set = [] + var merged: [String] = [] + + for token in rawExisting.split(separator: ",") { + let feature = token.trimmingCharacters(in: .whitespacesAndNewlines) + guard !feature.isEmpty else { continue } + if seen.insert(feature).inserted { + merged.append(feature) + } + } + + for required in ["ssh-env", "ssh-terminfo"] { + if seen.insert(required).inserted { + merged.append(required) + } + } + + return merged.joined(separator: ",") + } + + static func bundledShellIntegrationScript( + named fileName: String, + bundleResourceURL: URL? = Bundle.main.resourceURL, + fileManager: FileManager = .default + ) -> String? { + guard let bundleResourceURL else { return nil } + let url = bundleResourceURL + .appendingPathComponent("shell-integration", isDirectory: true) + .appendingPathComponent(fileName, isDirectory: false) + guard fileManager.fileExists(atPath: url.path), + let data = try? Data(contentsOf: url), + let contents = String(data: data, encoding: .utf8) else { + return nil + } + return contents + } + + private static func commonShellLines( + remoteRelayPort: Int, + shellStateDir: String, + shellFeatures: String, + terminfoSource: String? + ) -> [String] { + let relaySocket = remoteRelayPort > 0 ? "127.0.0.1:\(remoteRelayPort)" : nil + var lines = terminalSetupLines(terminfoSource: terminfoSource) + lines.append(contentsOf: RemoteShellEnvironment.utf8LocaleSetupLines()) + lines.append(contentsOf: shellExportLines(shellFeatures: shellFeatures)) + lines.append("export PATH=\"$HOME/.cmux/bin:$PATH\"") + lines.append("export CMUX_BUNDLED_CLI_PATH=\"$HOME/.cmux/bin/cmux\"") + lines.append("export CMUX_SHELL_INTEGRATION_DIR=\"\(shellStateDir)\"") + if let relaySocket { + lines.append("export CMUX_SOCKET_PATH=\(relaySocket)") + } + // The assignment placeholders are replaced by `ssh-pty-attach` before + // this script runs. Split the sentinel patterns so a missed replacement + // does not export literal placeholder IDs into the remote shell. + lines.append(contentsOf: [ + "cmux_workspace_id='__CMUX_WORKSPACE_ID__'", + "case \"$cmux_workspace_id\" in \"\"|'__CMUX_''WORKSPACE_ID__') ;; *) export CMUX_WORKSPACE_ID=\"$cmux_workspace_id\"; export CMUX_TAB_ID=\"$cmux_workspace_id\" ;; esac", + "cmux_surface_id='__CMUX_SURFACE_ID__'", + "case \"$cmux_surface_id\" in \"\"|'__CMUX_''SURFACE_ID__') ;; *) export CMUX_SURFACE_ID=\"$cmux_surface_id\"; export CMUX_PANEL_ID=\"$cmux_surface_id\" ;; esac", + "unset cmux_workspace_id cmux_surface_id", + "hash -r >/dev/null 2>&1 || true", + "rehash >/dev/null 2>&1 || true", + ]) + return lines + } + + private static func terminalSetupLines(terminfoSource: String?) -> [String] { + var lines: [String] = [ + "cmux_term='xterm-256color'", + "if command -v infocmp >/dev/null 2>&1 && infocmp xterm-ghostty >/dev/null 2>&1; then", + " cmux_term='xterm-ghostty'", + "fi", + "export TERM=\"$cmux_term\"", + ] + guard let terminfoSource else { return lines } + let trimmedTerminfoSource = terminfoSource.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedTerminfoSource.isEmpty else { return lines } + lines += [ + "if [ \"$cmux_term\" != 'xterm-ghostty' ]; then", + " (", + " command -v tic >/dev/null 2>&1 || exit 0", + " mkdir -p \"$HOME/.terminfo\" 2>/dev/null || exit 0", + " cat <<'CMUXTERMINFO' | tic -x - >/dev/null 2>&1", + trimmedTerminfoSource, + "CMUXTERMINFO", + " ) >/dev/null 2>&1 &", + "fi", + ] + return lines + } + + private static func shellExportLines(shellFeatures: String) -> [String] { + let environment = ProcessInfo.processInfo.environment + let colorTerm = normalizedEnvValue(environment["COLORTERM"]) ?? "truecolor" + let termProgram = normalizedEnvValue(environment["TERM_PROGRAM"]) ?? "ghostty" + let termProgramVersion = normalizedEnvValue(environment["TERM_PROGRAM_VERSION"]) + ?? (Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String) + ?? "" + let trimmedShellFeatures = shellFeatures.trimmingCharacters(in: .whitespacesAndNewlines) + + var exports: [String] = [ + "export COLORTERM=\(shellQuote(colorTerm))", + "export TERM_PROGRAM=\(shellQuote(termProgram))", + ] + if !termProgramVersion.isEmpty { + exports.append("export TERM_PROGRAM_VERSION=\(shellQuote(termProgramVersion))") + } + if !trimmedShellFeatures.isEmpty { + exports.append("export GHOSTTY_SHELL_FEATURES=\(shellQuote(trimmedShellFeatures))") + } + return exports + } + + private static func relayWarmupLines(remoteRelayPort: Int) -> [String] { + guard remoteRelayPort > 0 else { + return [] + } + return [ + "cmux_relay_cli=\"${CMUX_BUNDLED_CLI_PATH:-$HOME/.cmux/bin/cmux}\"", + "if [ ! -x \"$cmux_relay_cli\" ]; then cmux_relay_cli=\"$(command -v cmux 2>/dev/null || true)\"; fi", + "cmux_relay_tty=\"${CMUX_BOOTSTRAP_TTY:-}\"", + "if [ -z \"$cmux_relay_tty\" ]; then cmux_relay_tty=\"$(tty 2>/dev/null || true)\"; fi", + "cmux_relay_tty=\"${cmux_relay_tty##*/}\"", + "if [ -n \"$cmux_relay_tty\" ] && [ \"$cmux_relay_tty\" != \"not a tty\" ]; then", + " mkdir -p \"$HOME/.cmux/relay\" >/dev/null 2>&1 || true", + " printf '%s' \"$cmux_relay_tty\" > \"$HOME/.cmux/relay/\(remoteRelayPort).tty\" 2>/dev/null || true", + "fi", + "if [ -n \"$cmux_relay_cli\" ] && [ -n \"$CMUX_WORKSPACE_ID\" ] && [ -n \"$cmux_relay_tty\" ] && [ \"$cmux_relay_tty\" != \"not a tty\" ]; then", + " cmux_relay_report_tty=\"{\\\"workspace_id\\\":\\\"$CMUX_WORKSPACE_ID\\\",\\\"tty_name\\\":\\\"$cmux_relay_tty\\\"}\"", + " cmux_relay_ports_kick=\"{\\\"workspace_id\\\":\\\"$CMUX_WORKSPACE_ID\\\",\\\"reason\\\":\\\"command\\\"}\"", + " if [ -n \"$CMUX_SURFACE_ID\" ]; then", + " cmux_relay_report_tty=\"{\\\"workspace_id\\\":\\\"$CMUX_WORKSPACE_ID\\\",\\\"surface_id\\\":\\\"$CMUX_SURFACE_ID\\\",\\\"tty_name\\\":\\\"$cmux_relay_tty\\\"}\"", + " cmux_relay_ports_kick=\"{\\\"workspace_id\\\":\\\"$CMUX_WORKSPACE_ID\\\",\\\"surface_id\\\":\\\"$CMUX_SURFACE_ID\\\",\\\"reason\\\":\\\"command\\\"}\"", + " fi", + " \"$cmux_relay_cli\" rpc surface.report_tty \"$cmux_relay_report_tty\" >/dev/null 2>&1 || true", + " \"$cmux_relay_cli\" rpc surface.ports_kick \"$cmux_relay_ports_kick\" >/dev/null 2>&1 || true", + "fi", + "unset CMUX_BOOTSTRAP_TTY cmux_relay_cli cmux_relay_tty cmux_relay_report_tty cmux_relay_ports_kick", + ] + } + + private static func shellStateDirForRemoteRelayPort(_ remoteRelayPort: Int) -> String { + "$HOME/.cmux/relay/\(max(remoteRelayPort, 0)).shell" + } + + private static func normalizedEnvValue(_ value: String?) -> String? { + guard let value else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + private static func shellQuote(_ value: String) -> String { + let safePattern = "^[A-Za-z0-9_@%+=:,./-]+$" + if value.range(of: safePattern, options: .regularExpression) != nil { + return value + } + return "'" + value.replacingOccurrences(of: "'", with: "'\"'\"'") + "'" + } +} diff --git a/Sources/RemoteRelayZshBootstrap.swift b/Sources/RemoteRelayZshBootstrap.swift index 78213893e3bb..50dfb77f68eb 100644 --- a/Sources/RemoteRelayZshBootstrap.swift +++ b/Sources/RemoteRelayZshBootstrap.swift @@ -11,278 +11,6 @@ enum RemoteShellEnvironment { } } -enum RemoteInteractiveShellBootstrapBuilder { - static func script( - remoteRelayPort: Int, - shellFeatures: String, - terminfoSource: String? = nil, - bundledZshIntegration: String? = nil, - bundledBashIntegration: String? = nil - ) -> String { - let shellStateDir = shellStateDirForRemoteRelayPort(remoteRelayPort) - let commonShellExportLines = commonShellLines( - remoteRelayPort: remoteRelayPort, - shellStateDir: shellStateDir, - shellFeatures: shellFeatures, - terminfoSource: terminfoSource - ) - var zshShellLines = commonShellExportLines - zshShellLines.append( - #"if [ "${CMUX_SHELL_INTEGRATION:-1}" != "0" ] && [ -r "${CMUX_SHELL_INTEGRATION_DIR}/cmux-zsh-integration.zsh" ]; then . "${CMUX_SHELL_INTEGRATION_DIR}/cmux-zsh-integration.zsh"; fi"# - ) - var bashShellLines = commonShellExportLines - bashShellLines.append( - #"if [ "${CMUX_SHELL_INTEGRATION:-1}" != "0" ] && [ -r "${CMUX_SHELL_INTEGRATION_DIR}/cmux-bash-integration.bash" ]; then . "${CMUX_SHELL_INTEGRATION_DIR}/cmux-bash-integration.bash"; fi"# - ) - let zshBootstrap = RemoteRelayZshBootstrap(shellStateDir: shellStateDir) - let relayWarmupLines = relayWarmupLines(remoteRelayPort: remoteRelayPort) - - var outerLines: [String] = [ - "mkdir -p \"$HOME/.cmux/relay\"", - "cmux_shell_dir=\"\(shellStateDir)\"", - "mkdir -p \"$cmux_shell_dir\"", - ] - if let bundledZshIntegration { - outerLines += [ - "cat > \"$cmux_shell_dir/cmux-zsh-integration.zsh\" <<'CMUXCMUXZSH'", - bundledZshIntegration, - "CMUXCMUXZSH", - ] - } - if let bundledBashIntegration { - outerLines += [ - "cat > \"$cmux_shell_dir/cmux-bash-integration.bash\" <<'CMUXCMUXBASH'", - bundledBashIntegration, - "CMUXCMUXBASH", - ] - } - outerLines.append(contentsOf: commonShellExportLines) - outerLines += [ - "CMUX_LOGIN_SHELL=\"${SHELL:-/bin/zsh}\"", - "case \"${CMUX_LOGIN_SHELL##*/}\" in", - " zsh)", - " cat > \"$cmux_shell_dir/.zshenv\" <<'CMUXZSHENV'", - ] - outerLines.append(contentsOf: zshBootstrap.zshEnvLines) - outerLines += [ - "CMUXZSHENV", - " cat > \"$cmux_shell_dir/.zprofile\" <<'CMUXZSHPROFILE'", - ] - outerLines.append(contentsOf: zshBootstrap.zshProfileLines) - outerLines += [ - "CMUXZSHPROFILE", - " cat > \"$cmux_shell_dir/.zshrc\" <<'CMUXZSHRC'", - ] - outerLines.append(contentsOf: zshBootstrap.zshRCLines(commonShellLines: zshShellLines)) - outerLines += [ - "CMUXZSHRC", - " cat > \"$cmux_shell_dir/.zlogin\" <<'CMUXZSHLOGIN'", - ] - outerLines.append(contentsOf: zshBootstrap.zshLoginLines) - outerLines += [ - "CMUXZSHLOGIN", - " chmod 600 \"$cmux_shell_dir/.zshenv\" \"$cmux_shell_dir/.zprofile\" \"$cmux_shell_dir/.zshrc\" \"$cmux_shell_dir/.zlogin\" >/dev/null 2>&1 || true", - ] - outerLines.append(contentsOf: relayWarmupLines.map { " " + $0 }) - outerLines += [ - " export CMUX_REAL_ZDOTDIR=\"${ZDOTDIR:-$HOME}\"", - " export ZDOTDIR=\"$cmux_shell_dir\"", - " exec \"$CMUX_LOGIN_SHELL\" -il", - " ;;", - " bash)", - " cat > \"$cmux_shell_dir/.bashrc\" <<'CMUXBASHRC'", - ] - outerLines.append(contentsOf: [ - "if [ -f \"$HOME/.bash_profile\" ]; then . \"$HOME/.bash_profile\"; elif [ -f \"$HOME/.bash_login\" ]; then . \"$HOME/.bash_login\"; elif [ -f \"$HOME/.profile\" ]; then . \"$HOME/.profile\"; fi", - "[ -f \"$HOME/.bashrc\" ] && . \"$HOME/.bashrc\"", - ] + bashShellLines) - outerLines += [ - "CMUXBASHRC", - " chmod 600 \"$cmux_shell_dir/.bashrc\" >/dev/null 2>&1 || true", - ] - outerLines.append(contentsOf: relayWarmupLines.map { " " + $0 }) - outerLines += [ - " exec \"$CMUX_LOGIN_SHELL\" --rcfile \"$cmux_shell_dir/.bashrc\" -i", - " ;;", - " *)", - ] - outerLines.append(contentsOf: commonShellExportLines) - outerLines.append(contentsOf: relayWarmupLines) - outerLines += [ - "exec \"$CMUX_LOGIN_SHELL\" -i", - ";;", - "esac", - ] - - return outerLines.joined(separator: "\n") - } - - static func shellFeatures( - environment: [String: String] = ProcessInfo.processInfo.environment - ) -> String { - let rawExisting = environment["GHOSTTY_SHELL_FEATURES"] ?? "" - var seen: Set = [] - var merged: [String] = [] - - for token in rawExisting.split(separator: ",") { - let feature = token.trimmingCharacters(in: .whitespacesAndNewlines) - guard !feature.isEmpty else { continue } - if seen.insert(feature).inserted { - merged.append(feature) - } - } - - for required in ["ssh-env", "ssh-terminfo"] { - if seen.insert(required).inserted { - merged.append(required) - } - } - - return merged.joined(separator: ",") - } - - static func bundledShellIntegrationScript( - named fileName: String, - bundleResourceURL: URL? = Bundle.main.resourceURL, - fileManager: FileManager = .default - ) -> String? { - guard let bundleResourceURL else { return nil } - let url = bundleResourceURL - .appendingPathComponent("shell-integration", isDirectory: true) - .appendingPathComponent(fileName, isDirectory: false) - guard fileManager.fileExists(atPath: url.path), - let data = try? Data(contentsOf: url), - let contents = String(data: data, encoding: .utf8) else { - return nil - } - return contents - } - - private static func commonShellLines( - remoteRelayPort: Int, - shellStateDir: String, - shellFeatures: String, - terminfoSource: String? - ) -> [String] { - let relaySocket = remoteRelayPort > 0 ? "127.0.0.1:\(remoteRelayPort)" : nil - var lines = terminalSetupLines(terminfoSource: terminfoSource) - lines.append(contentsOf: RemoteShellEnvironment.utf8LocaleSetupLines()) - lines.append(contentsOf: shellExportLines(shellFeatures: shellFeatures)) - lines.append("export PATH=\"$HOME/.cmux/bin:$PATH\"") - lines.append("export CMUX_BUNDLED_CLI_PATH=\"$HOME/.cmux/bin/cmux\"") - lines.append("export CMUX_SHELL_INTEGRATION_DIR=\"\(shellStateDir)\"") - if let relaySocket { - lines.append("export CMUX_SOCKET_PATH=\(relaySocket)") - } - // The assignment placeholders are replaced by `ssh-pty-attach` before - // this script runs. Split the sentinel patterns so a missed replacement - // does not export literal placeholder IDs into the remote shell. - lines.append(contentsOf: [ - "cmux_workspace_id='__CMUX_WORKSPACE_ID__'", - "case \"$cmux_workspace_id\" in \"\"|'__CMUX_''WORKSPACE_ID__') ;; *) export CMUX_WORKSPACE_ID=\"$cmux_workspace_id\"; export CMUX_TAB_ID=\"$cmux_workspace_id\" ;; esac", - "cmux_surface_id='__CMUX_SURFACE_ID__'", - "case \"$cmux_surface_id\" in \"\"|'__CMUX_''SURFACE_ID__') ;; *) export CMUX_SURFACE_ID=\"$cmux_surface_id\"; export CMUX_PANEL_ID=\"$cmux_surface_id\" ;; esac", - "unset cmux_workspace_id cmux_surface_id", - "hash -r >/dev/null 2>&1 || true", - "rehash >/dev/null 2>&1 || true", - ]) - return lines - } - - private static func terminalSetupLines(terminfoSource: String?) -> [String] { - var lines: [String] = [ - "cmux_term='xterm-256color'", - "if command -v infocmp >/dev/null 2>&1 && infocmp xterm-ghostty >/dev/null 2>&1; then", - " cmux_term='xterm-ghostty'", - "fi", - "export TERM=\"$cmux_term\"", - ] - guard let terminfoSource else { return lines } - let trimmedTerminfoSource = terminfoSource.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmedTerminfoSource.isEmpty else { return lines } - lines += [ - "if [ \"$cmux_term\" != 'xterm-ghostty' ]; then", - " (", - " command -v tic >/dev/null 2>&1 || exit 0", - " mkdir -p \"$HOME/.terminfo\" 2>/dev/null || exit 0", - " cat <<'CMUXTERMINFO' | tic -x - >/dev/null 2>&1", - trimmedTerminfoSource, - "CMUXTERMINFO", - " ) >/dev/null 2>&1 &", - "fi", - ] - return lines - } - - private static func shellExportLines(shellFeatures: String) -> [String] { - let environment = ProcessInfo.processInfo.environment - let colorTerm = normalizedEnvValue(environment["COLORTERM"]) ?? "truecolor" - let termProgram = normalizedEnvValue(environment["TERM_PROGRAM"]) ?? "ghostty" - let termProgramVersion = normalizedEnvValue(environment["TERM_PROGRAM_VERSION"]) - ?? (Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String) - ?? "" - let trimmedShellFeatures = shellFeatures.trimmingCharacters(in: .whitespacesAndNewlines) - - var exports: [String] = [ - "export COLORTERM=\(shellQuote(colorTerm))", - "export TERM_PROGRAM=\(shellQuote(termProgram))", - ] - if !termProgramVersion.isEmpty { - exports.append("export TERM_PROGRAM_VERSION=\(shellQuote(termProgramVersion))") - } - if !trimmedShellFeatures.isEmpty { - exports.append("export GHOSTTY_SHELL_FEATURES=\(shellQuote(trimmedShellFeatures))") - } - return exports - } - - private static func relayWarmupLines(remoteRelayPort: Int) -> [String] { - guard remoteRelayPort > 0 else { - return [] - } - return [ - "cmux_relay_cli=\"${CMUX_BUNDLED_CLI_PATH:-$HOME/.cmux/bin/cmux}\"", - "if [ ! -x \"$cmux_relay_cli\" ]; then cmux_relay_cli=\"$(command -v cmux 2>/dev/null || true)\"; fi", - "cmux_relay_tty=\"${CMUX_BOOTSTRAP_TTY:-}\"", - "if [ -z \"$cmux_relay_tty\" ]; then cmux_relay_tty=\"$(tty 2>/dev/null || true)\"; fi", - "cmux_relay_tty=\"${cmux_relay_tty##*/}\"", - "if [ -n \"$cmux_relay_tty\" ] && [ \"$cmux_relay_tty\" != \"not a tty\" ]; then", - " mkdir -p \"$HOME/.cmux/relay\" >/dev/null 2>&1 || true", - " printf '%s' \"$cmux_relay_tty\" > \"$HOME/.cmux/relay/\(remoteRelayPort).tty\" 2>/dev/null || true", - "fi", - "if [ -n \"$cmux_relay_cli\" ] && [ -n \"$CMUX_WORKSPACE_ID\" ] && [ -n \"$cmux_relay_tty\" ] && [ \"$cmux_relay_tty\" != \"not a tty\" ]; then", - " cmux_relay_report_tty=\"{\\\"workspace_id\\\":\\\"$CMUX_WORKSPACE_ID\\\",\\\"tty_name\\\":\\\"$cmux_relay_tty\\\"}\"", - " cmux_relay_ports_kick=\"{\\\"workspace_id\\\":\\\"$CMUX_WORKSPACE_ID\\\",\\\"reason\\\":\\\"command\\\"}\"", - " if [ -n \"$CMUX_SURFACE_ID\" ]; then", - " cmux_relay_report_tty=\"{\\\"workspace_id\\\":\\\"$CMUX_WORKSPACE_ID\\\",\\\"surface_id\\\":\\\"$CMUX_SURFACE_ID\\\",\\\"tty_name\\\":\\\"$cmux_relay_tty\\\"}\"", - " cmux_relay_ports_kick=\"{\\\"workspace_id\\\":\\\"$CMUX_WORKSPACE_ID\\\",\\\"surface_id\\\":\\\"$CMUX_SURFACE_ID\\\",\\\"reason\\\":\\\"command\\\"}\"", - " fi", - " \"$cmux_relay_cli\" rpc surface.report_tty \"$cmux_relay_report_tty\" >/dev/null 2>&1 || true", - " \"$cmux_relay_cli\" rpc surface.ports_kick \"$cmux_relay_ports_kick\" >/dev/null 2>&1 || true", - "fi", - "unset CMUX_BOOTSTRAP_TTY cmux_relay_cli cmux_relay_tty cmux_relay_report_tty cmux_relay_ports_kick", - ] - } - - private static func shellStateDirForRemoteRelayPort(_ remoteRelayPort: Int) -> String { - "$HOME/.cmux/relay/\(max(remoteRelayPort, 0)).shell" - } - - private static func normalizedEnvValue(_ value: String?) -> String? { - guard let value else { return nil } - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? nil : trimmed - } - - private static func shellQuote(_ value: String) -> String { - let safePattern = "^[A-Za-z0-9_@%+=:,./-]+$" - if value.range(of: safePattern, options: .regularExpression) != nil { - return value - } - return "'" + value.replacingOccurrences(of: "'", with: "'\"'\"'") + "'" - } -} - struct RemoteRelayZshBootstrap { let shellStateDir: String diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 051040682198..a7b28827b50a 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -343,6 +343,8 @@ A5C0DE0000000000000000BC /* ProjectSchemesTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5C0DE0000000000000000BB /* ProjectSchemesTabView.swift */; }; A5C0DE0000000000000000B8 /* ProjectTargetsTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5C0DE0000000000000000B7 /* ProjectTargetsTabView.swift */; }; A500RG01 /* ReactGrab.swift in Sources */ = {isa = PBXBuildFile; fileRef = A500RG00 /* ReactGrab.swift */; }; + A5001643 /* RemoteInteractiveShellBootstrapBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001642 /* RemoteInteractiveShellBootstrapBuilder.swift */; }; + B9000028A1B2C3D4E5F60719 /* RemoteInteractiveShellBootstrapBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001642 /* RemoteInteractiveShellBootstrapBuilder.swift */; }; D0C0D0C0D0C0D0C0D0C00001 /* RemoteLoopbackProxyAlias.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C0D0C0D0C0D0C0D0C00002 /* RemoteLoopbackProxyAlias.swift */; }; D0C0D0C0D0C0D0C0D0C00003 /* RemoteLoopbackRuntimeBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C0D0C0D0C0D0C0D0C00004 /* RemoteLoopbackRuntimeBridge.swift */; }; A5001640 /* RemoteRelayZshBootstrap.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001641 /* RemoteRelayZshBootstrap.swift */; }; @@ -956,6 +958,7 @@ A5C0DE0000000000000000BB /* ProjectSchemesTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/ProjectSchemesTabView.swift; sourceTree = ""; }; A5C0DE0000000000000000B7 /* ProjectTargetsTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/ProjectTargetsTabView.swift; sourceTree = ""; }; A500RG00 /* ReactGrab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/ReactGrab.swift; sourceTree = ""; }; + A5001642 /* RemoteInteractiveShellBootstrapBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteInteractiveShellBootstrapBuilder.swift; sourceTree = ""; }; D0C0D0C0D0C0D0C0D0C00002 /* RemoteLoopbackProxyAlias.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteLoopbackProxyAlias.swift; sourceTree = ""; }; D0C0D0C0D0C0D0C0D0C00004 /* RemoteLoopbackRuntimeBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteLoopbackRuntimeBridge.swift; sourceTree = ""; }; A5001641 /* RemoteRelayZshBootstrap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteRelayZshBootstrap.swift; sourceTree = ""; }; @@ -1628,6 +1631,7 @@ A5001661 /* RestorableAgentSession.swift */, C0DEF0C10000000000000002 /* AgentForkSupport.swift */, C0DEF0C20000000000000002 /* SemanticVersion.swift */, + A5001642 /* RemoteInteractiveShellBootstrapBuilder.swift */, A5001641 /* RemoteRelayZshBootstrap.swift */, D35110010000000000000002 /* CmuxApplicationSupportDirectories.swift */, A5001651 /* CmuxConfig.swift */, @@ -2335,6 +2339,7 @@ A5C0DE0000000000000000BC /* ProjectSchemesTabView.swift in Sources */, A5C0DE0000000000000000B8 /* ProjectTargetsTabView.swift in Sources */, A500RG01 /* ReactGrab.swift in Sources */, + A5001643 /* RemoteInteractiveShellBootstrapBuilder.swift in Sources */, D0C0D0C0D0C0D0C0D0C00001 /* RemoteLoopbackProxyAlias.swift in Sources */, D0C0D0C0D0C0D0C0D0C00003 /* RemoteLoopbackRuntimeBridge.swift in Sources */, A5001640 /* RemoteRelayZshBootstrap.swift in Sources */, @@ -2505,6 +2510,7 @@ B9000033A1B2C3D4E5F60719 /* CMUXCLI+TopRendering.swift in Sources */, C0DEF0B10000000000000003 /* JSONCParser.swift in Sources */, C47110020000000000000003 /* ProcessPipeReader.swift in Sources */, + B9000028A1B2C3D4E5F60719 /* RemoteInteractiveShellBootstrapBuilder.swift in Sources */, B9000027A1B2C3D4E5F60719 /* RemoteRelayZshBootstrap.swift in Sources */, C510C1E00000000000000002 /* SocketOperationTelemetry.swift in Sources */, ); From cb4225edb4683eed4da48a80e7540f44f68773a6 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 29 May 2026 22:24:28 -0700 Subject: [PATCH 54/69] fix: preserve bash login startup precedence --- ...moteInteractiveShellBootstrapBuilder.swift | 8 +- .../WorkspaceRemoteConnectionTests.swift | 91 +++++++++++++++++++ 2 files changed, 98 insertions(+), 1 deletion(-) diff --git a/Sources/RemoteInteractiveShellBootstrapBuilder.swift b/Sources/RemoteInteractiveShellBootstrapBuilder.swift index 6c5e14a26ee1..2aab6a604209 100644 --- a/Sources/RemoteInteractiveShellBootstrapBuilder.swift +++ b/Sources/RemoteInteractiveShellBootstrapBuilder.swift @@ -82,7 +82,13 @@ enum RemoteInteractiveShellBootstrapBuilder { " cat > \"$cmux_shell_dir/.bashrc\" <<'CMUXBASHRC'", ] outerLines.append(contentsOf: [ - "if [ -f \"$HOME/.bash_profile\" ]; then . \"$HOME/.bash_profile\"; elif [ -f \"$HOME/.bash_login\" ]; then . \"$HOME/.profile\"; fi", + "if [ -f \"$HOME/.bash_profile\" ]; then", + " . \"$HOME/.bash_profile\"", + "elif [ -f \"$HOME/.bash_login\" ]; then", + " . \"$HOME/.bash_login\"", + "elif [ -f \"$HOME/.profile\" ]; then", + " . \"$HOME/.profile\"", + "fi", "[ -f \"$HOME/.bashrc\" ] && . \"$HOME/.bashrc\"", ] + bashShellLines) outerLines += [ diff --git a/cmuxTests/WorkspaceRemoteConnectionTests.swift b/cmuxTests/WorkspaceRemoteConnectionTests.swift index 98426c751ec8..a179e9b37e46 100644 --- a/cmuxTests/WorkspaceRemoteConnectionTests.swift +++ b/cmuxTests/WorkspaceRemoteConnectionTests.swift @@ -117,6 +117,97 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { return histfile ?? "" } + private func runGeneratedBashBootstrapMarkers(startupFiles: [String: String]) throws -> [String] { + let fileManager = FileManager.default + let home = fileManager.temporaryDirectory.appendingPathComponent("cmux-relay-bash-\(UUID().uuidString)") + let bin = home.appendingPathComponent("bin") + let markerFile = home.appendingPathComponent("markers.txt") + try fileManager.createDirectory(at: bin, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: home) } + + for (fileName, marker) in startupFiles { + let startupScript = """ + printf '%s\\n' '\(marker)' >> "$CMUX_BASH_MARKERS" + """ + try startupScript.write(to: home.appendingPathComponent(fileName), atomically: true, encoding: .utf8) + } + try writeExecutableShellFile( + at: bin.appendingPathComponent("bash"), + body: """ + #!/bin/sh + rcfile= + while [ "$#" -gt 0 ]; do + case "$1" in + --rcfile) + shift + rcfile="${1:-}" + ;; + esac + shift || true + done + if [ -n "$rcfile" ]; then + . "$rcfile" + fi + """ + ) + + let script = RemoteInteractiveShellBootstrapBuilder.script( + remoteRelayPort: 0, + shellFeatures: "" + ) + let result = runProcess( + executablePath: "/usr/bin/env", + arguments: [ + "HOME=\(home.path)", + "SHELL=\(bin.appendingPathComponent("bash").path)", + "PATH=\(bin.path):/usr/bin:/bin", + "TERM=xterm-256color", + "USER=\(NSUserName())", + "CMUX_BASH_MARKERS=\(markerFile.path)", + "/bin/sh", + "-c", + script, + ], + timeout: 5 + ) + + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + + let contents = (try? String(contentsOf: markerFile, encoding: .utf8)) ?? "" + return contents + .split(separator: "\n") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + } + + func testGeneratedBashBootstrapSourcesLoginFilesInBashPrecedenceOrder() throws { + XCTAssertEqual( + try runGeneratedBashBootstrapMarkers(startupFiles: [ + ".bash_profile": "bash_profile", + ".bash_login": "bash_login", + ".profile": "profile", + ".bashrc": "bashrc", + ]), + ["bash_profile", "bashrc"] + ) + XCTAssertEqual( + try runGeneratedBashBootstrapMarkers(startupFiles: [ + ".bash_login": "bash_login", + ".profile": "profile", + ".bashrc": "bashrc", + ]), + ["bash_login", "bashrc"] + ) + XCTAssertEqual( + try runGeneratedBashBootstrapMarkers(startupFiles: [ + ".profile": "profile", + ".bashrc": "bashrc", + ]), + ["profile", "bashrc"] + ) + } + func testRemoteRelayMetadataCleanupScriptRemovesMatchingSocketAddr() { let fileManager = FileManager.default let home = fileManager.temporaryDirectory.appendingPathComponent("cmux-relay-cleanup-\(UUID().uuidString)") From bfcb6a23053c2ec5701a5875be9254b469694db5 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 29 May 2026 23:28:17 -0700 Subject: [PATCH 55/69] fix: address detachable pty review and ci --- .github/workflows/ci.yml | 2 +- Resources/Localizable.xcstrings | 696 +++++++++++++++++- ...moteInteractiveShellBootstrapBuilder.swift | 1 - .../WorkspaceRemoteConnectionTests.swift | 49 ++ scripts/build-ghostty-cli-helper.sh | 14 +- scripts/install-zig-ci.sh | 70 +- 6 files changed, 799 insertions(+), 33 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aad3d0956055..ee9da3765d8b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -699,7 +699,7 @@ jobs: ui-regressions: runs-on: ${{ vars.MACOS_RUNNER_15 || 'warp-macos-15-arm64-6x' }} - timeout-minutes: 25 + timeout-minutes: 40 steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 4e5f68a1d6f5..7faa55c0e8d2 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -15,6 +15,114 @@ "state": "translated", "value": "リモートデーモンは永続 SSH PTY セッションをサポートしていません。cmux を更新するにはリモートワークスペースに再接続してください。" } + }, + "zh-Hans": { + "stringUnit": { + "state": "new", + "value": "remote daemon does not support persistent SSH PTY sessions; reconnect the remote workspace to update cmux" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "new", + "value": "remote daemon does not support persistent SSH PTY sessions; reconnect the remote workspace to update cmux" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "remote daemon does not support persistent SSH PTY sessions; reconnect the remote workspace to update cmux" + } + }, + "de": { + "stringUnit": { + "state": "new", + "value": "remote daemon does not support persistent SSH PTY sessions; reconnect the remote workspace to update cmux" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "remote daemon does not support persistent SSH PTY sessions; reconnect the remote workspace to update cmux" + } + }, + "fr": { + "stringUnit": { + "state": "new", + "value": "remote daemon does not support persistent SSH PTY sessions; reconnect the remote workspace to update cmux" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "remote daemon does not support persistent SSH PTY sessions; reconnect the remote workspace to update cmux" + } + }, + "da": { + "stringUnit": { + "state": "new", + "value": "remote daemon does not support persistent SSH PTY sessions; reconnect the remote workspace to update cmux" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "remote daemon does not support persistent SSH PTY sessions; reconnect the remote workspace to update cmux" + } + }, + "ru": { + "stringUnit": { + "state": "new", + "value": "remote daemon does not support persistent SSH PTY sessions; reconnect the remote workspace to update cmux" + } + }, + "bs": { + "stringUnit": { + "state": "new", + "value": "remote daemon does not support persistent SSH PTY sessions; reconnect the remote workspace to update cmux" + } + }, + "ar": { + "stringUnit": { + "state": "new", + "value": "remote daemon does not support persistent SSH PTY sessions; reconnect the remote workspace to update cmux" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "remote daemon does not support persistent SSH PTY sessions; reconnect the remote workspace to update cmux" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "remote daemon does not support persistent SSH PTY sessions; reconnect the remote workspace to update cmux" + } + }, + "th": { + "stringUnit": { + "state": "new", + "value": "remote daemon does not support persistent SSH PTY sessions; reconnect the remote workspace to update cmux" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "remote daemon does not support persistent SSH PTY sessions; reconnect the remote workspace to update cmux" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "remote daemon does not support persistent SSH PTY sessions; reconnect the remote workspace to update cmux" + } + }, + "km": { + "stringUnit": { + "state": "new", + "value": "remote daemon does not support persistent SSH PTY sessions; reconnect the remote workspace to update cmux" + } } } }, @@ -26,42 +134,366 @@ "value": "remote daemon is missing required functionality; reconnect the remote workspace to update cmux" } }, - "ja": { + "ja": { + "stringUnit": { + "state": "translated", + "value": "リモートデーモンに必要な機能がありません。cmux を更新するにはリモートワークスペースに再接続してください。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "new", + "value": "remote daemon is missing required functionality; reconnect the remote workspace to update cmux" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "new", + "value": "remote daemon is missing required functionality; reconnect the remote workspace to update cmux" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "remote daemon is missing required functionality; reconnect the remote workspace to update cmux" + } + }, + "de": { + "stringUnit": { + "state": "new", + "value": "remote daemon is missing required functionality; reconnect the remote workspace to update cmux" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "remote daemon is missing required functionality; reconnect the remote workspace to update cmux" + } + }, + "fr": { + "stringUnit": { + "state": "new", + "value": "remote daemon is missing required functionality; reconnect the remote workspace to update cmux" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "remote daemon is missing required functionality; reconnect the remote workspace to update cmux" + } + }, + "da": { + "stringUnit": { + "state": "new", + "value": "remote daemon is missing required functionality; reconnect the remote workspace to update cmux" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "remote daemon is missing required functionality; reconnect the remote workspace to update cmux" + } + }, + "ru": { + "stringUnit": { + "state": "new", + "value": "remote daemon is missing required functionality; reconnect the remote workspace to update cmux" + } + }, + "bs": { + "stringUnit": { + "state": "new", + "value": "remote daemon is missing required functionality; reconnect the remote workspace to update cmux" + } + }, + "ar": { + "stringUnit": { + "state": "new", + "value": "remote daemon is missing required functionality; reconnect the remote workspace to update cmux" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "remote daemon is missing required functionality; reconnect the remote workspace to update cmux" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "remote daemon is missing required functionality; reconnect the remote workspace to update cmux" + } + }, + "th": { + "stringUnit": { + "state": "new", + "value": "remote daemon is missing required functionality; reconnect the remote workspace to update cmux" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "remote daemon is missing required functionality; reconnect the remote workspace to update cmux" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "remote daemon is missing required functionality; reconnect the remote workspace to update cmux" + } + }, + "km": { + "stringUnit": { + "state": "new", + "value": "remote daemon is missing required functionality; reconnect the remote workspace to update cmux" + } + } + } + }, + "remotePTYAttach.error.attachFailed": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "remote PTY attach failed" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リモート PTY への接続に失敗しました" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "new", + "value": "remote PTY attach failed" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "new", + "value": "remote PTY attach failed" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "remote PTY attach failed" + } + }, + "de": { + "stringUnit": { + "state": "new", + "value": "remote PTY attach failed" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "remote PTY attach failed" + } + }, + "fr": { + "stringUnit": { + "state": "new", + "value": "remote PTY attach failed" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "remote PTY attach failed" + } + }, + "da": { + "stringUnit": { + "state": "new", + "value": "remote PTY attach failed" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "remote PTY attach failed" + } + }, + "ru": { + "stringUnit": { + "state": "new", + "value": "remote PTY attach failed" + } + }, + "bs": { + "stringUnit": { + "state": "new", + "value": "remote PTY attach failed" + } + }, + "ar": { + "stringUnit": { + "state": "new", + "value": "remote PTY attach failed" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "remote PTY attach failed" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "remote PTY attach failed" + } + }, + "th": { + "stringUnit": { + "state": "new", + "value": "remote PTY attach failed" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "remote PTY attach failed" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "remote PTY attach failed" + } + }, + "km": { + "stringUnit": { + "state": "new", + "value": "remote PTY attach failed" + } + } + } + }, + "remotePTYAttach.error.daemonTimeout": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "remote daemon did not respond in time" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リモートデーモンが時間内に応答しませんでした" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "new", + "value": "remote daemon did not respond in time" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "new", + "value": "remote daemon did not respond in time" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "remote daemon did not respond in time" + } + }, + "de": { + "stringUnit": { + "state": "new", + "value": "remote daemon did not respond in time" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "remote daemon did not respond in time" + } + }, + "fr": { + "stringUnit": { + "state": "new", + "value": "remote daemon did not respond in time" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "remote daemon did not respond in time" + } + }, + "da": { + "stringUnit": { + "state": "new", + "value": "remote daemon did not respond in time" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "remote daemon did not respond in time" + } + }, + "ru": { + "stringUnit": { + "state": "new", + "value": "remote daemon did not respond in time" + } + }, + "bs": { + "stringUnit": { + "state": "new", + "value": "remote daemon did not respond in time" + } + }, + "ar": { + "stringUnit": { + "state": "new", + "value": "remote daemon did not respond in time" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "remote daemon did not respond in time" + } + }, + "pt-BR": { "stringUnit": { - "state": "translated", - "value": "リモートデーモンに必要な機能がありません。cmux を更新するにはリモートワークスペースに再接続してください。" + "state": "new", + "value": "remote daemon did not respond in time" } - } - } - }, - "remotePTYAttach.error.attachFailed": { - "localizations": { - "en": { + }, + "th": { "stringUnit": { - "state": "translated", - "value": "remote PTY attach failed" + "state": "new", + "value": "remote daemon did not respond in time" } }, - "ja": { + "tr": { "stringUnit": { - "state": "translated", - "value": "リモート PTY への接続に失敗しました" + "state": "new", + "value": "remote daemon did not respond in time" } - } - } - }, - "remotePTYAttach.error.daemonTimeout": { - "localizations": { - "en": { + }, + "uk": { "stringUnit": { - "state": "translated", + "state": "new", "value": "remote daemon did not respond in time" } }, - "ja": { + "km": { "stringUnit": { - "state": "translated", - "value": "リモートデーモンが時間内に応答しませんでした" + "state": "new", + "value": "remote daemon did not respond in time" } } } @@ -79,6 +511,114 @@ "state": "translated", "value": "リモート PTY 入力が一時的に滞留しています" } + }, + "zh-Hans": { + "stringUnit": { + "state": "new", + "value": "remote PTY input is temporarily backed up" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "new", + "value": "remote PTY input is temporarily backed up" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "remote PTY input is temporarily backed up" + } + }, + "de": { + "stringUnit": { + "state": "new", + "value": "remote PTY input is temporarily backed up" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "remote PTY input is temporarily backed up" + } + }, + "fr": { + "stringUnit": { + "state": "new", + "value": "remote PTY input is temporarily backed up" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "remote PTY input is temporarily backed up" + } + }, + "da": { + "stringUnit": { + "state": "new", + "value": "remote PTY input is temporarily backed up" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "remote PTY input is temporarily backed up" + } + }, + "ru": { + "stringUnit": { + "state": "new", + "value": "remote PTY input is temporarily backed up" + } + }, + "bs": { + "stringUnit": { + "state": "new", + "value": "remote PTY input is temporarily backed up" + } + }, + "ar": { + "stringUnit": { + "state": "new", + "value": "remote PTY input is temporarily backed up" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "remote PTY input is temporarily backed up" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "remote PTY input is temporarily backed up" + } + }, + "th": { + "stringUnit": { + "state": "new", + "value": "remote PTY input is temporarily backed up" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "remote PTY input is temporarily backed up" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "remote PTY input is temporarily backed up" + } + }, + "km": { + "stringUnit": { + "state": "new", + "value": "remote PTY input is temporarily backed up" + } } } }, @@ -95,6 +635,114 @@ "state": "translated", "value": "永続 SSH PTY セッションはもう実行されていません" } + }, + "zh-Hans": { + "stringUnit": { + "state": "new", + "value": "persistent SSH PTY session is no longer running" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "new", + "value": "persistent SSH PTY session is no longer running" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "persistent SSH PTY session is no longer running" + } + }, + "de": { + "stringUnit": { + "state": "new", + "value": "persistent SSH PTY session is no longer running" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "persistent SSH PTY session is no longer running" + } + }, + "fr": { + "stringUnit": { + "state": "new", + "value": "persistent SSH PTY session is no longer running" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "persistent SSH PTY session is no longer running" + } + }, + "da": { + "stringUnit": { + "state": "new", + "value": "persistent SSH PTY session is no longer running" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "persistent SSH PTY session is no longer running" + } + }, + "ru": { + "stringUnit": { + "state": "new", + "value": "persistent SSH PTY session is no longer running" + } + }, + "bs": { + "stringUnit": { + "state": "new", + "value": "persistent SSH PTY session is no longer running" + } + }, + "ar": { + "stringUnit": { + "state": "new", + "value": "persistent SSH PTY session is no longer running" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "persistent SSH PTY session is no longer running" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "persistent SSH PTY session is no longer running" + } + }, + "th": { + "stringUnit": { + "state": "new", + "value": "persistent SSH PTY session is no longer running" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "persistent SSH PTY session is no longer running" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "persistent SSH PTY session is no longer running" + } + }, + "km": { + "stringUnit": { + "state": "new", + "value": "persistent SSH PTY session is no longer running" + } } } }, diff --git a/Sources/RemoteInteractiveShellBootstrapBuilder.swift b/Sources/RemoteInteractiveShellBootstrapBuilder.swift index 2aab6a604209..18c1b5799fb6 100644 --- a/Sources/RemoteInteractiveShellBootstrapBuilder.swift +++ b/Sources/RemoteInteractiveShellBootstrapBuilder.swift @@ -101,7 +101,6 @@ enum RemoteInteractiveShellBootstrapBuilder { " ;;", " *)", ] - outerLines.append(contentsOf: commonShellExportLines) outerLines.append(contentsOf: relayWarmupLines) outerLines += [ "exec \"$CMUX_LOGIN_SHELL\" -i", diff --git a/cmuxTests/WorkspaceRemoteConnectionTests.swift b/cmuxTests/WorkspaceRemoteConnectionTests.swift index a179e9b37e46..6e9a3ee51f88 100644 --- a/cmuxTests/WorkspaceRemoteConnectionTests.swift +++ b/cmuxTests/WorkspaceRemoteConnectionTests.swift @@ -208,6 +208,55 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { ) } + func testGeneratedFallbackShellBootstrapPrependsCmuxBinOnce() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cmux-fallback-shell-bootstrap-\(UUID().uuidString)") + let home = root.appendingPathComponent("home") + let bin = root.appendingPathComponent("bin") + let capturedPath = root.appendingPathComponent("path.txt") + try fileManager.createDirectory(at: home, withIntermediateDirectories: true) + try fileManager.createDirectory(at: bin, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: root) } + + try writeExecutableShellFile( + at: bin.appendingPathComponent("fish"), + body: """ + #!/bin/sh + printf '%s\\n' "$PATH" > "$CMUX_CAPTURE_PATH" + """ + ) + + let script = RemoteInteractiveShellBootstrapBuilder.script( + remoteRelayPort: 0, + shellFeatures: "" + ) + let result = runProcess( + executablePath: "/usr/bin/env", + arguments: [ + "HOME=\(home.path)", + "SHELL=\(bin.appendingPathComponent("fish").path)", + "PATH=/usr/bin:/bin", + "TERM=xterm-256color", + "USER=\(NSUserName())", + "CMUX_CAPTURE_PATH=\(capturedPath.path)", + "/bin/sh", + "-c", + script, + ], + timeout: 5 + ) + + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + + let path = try String(contentsOf: capturedPath, encoding: .utf8) + .trimmingCharacters(in: .whitespacesAndNewlines) + let cmuxBinEntries = path.split(separator: ":") + .filter { $0 == "\(home.path)/.cmux/bin" } + XCTAssertEqual(cmuxBinEntries.count, 1, path) + } + func testRemoteRelayMetadataCleanupScriptRemovesMatchingSocketAddr() { let fileManager = FileManager.default let home = fileManager.temporaryDirectory.appendingPathComponent("cmux-relay-cleanup-\(UUID().uuidString)") diff --git a/scripts/build-ghostty-cli-helper.sh b/scripts/build-ghostty-cli-helper.sh index f283025364ad..82532b52b81c 100755 --- a/scripts/build-ghostty-cli-helper.sh +++ b/scripts/build-ghostty-cli-helper.sh @@ -16,6 +16,7 @@ EOF SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" GHOSTTY_DIR="$REPO_ROOT/ghostty" +ZIG_REQUIRED="${ZIG_REQUIRED:-0.15.2}" OUTPUT_PATH="" TARGET_TRIPLE="" @@ -33,6 +34,12 @@ target_arch_for_triple() { esac } +zig_has_required_version() { + local zig_path="$1" + [[ -x "$zig_path" ]] || return 1 + [[ "$("$zig_path" version 2>/dev/null || true)" == "$ZIG_REQUIRED" ]] +} + select_zig_for_target() { local target="${1:-}" local desired_arch @@ -43,6 +50,10 @@ select_zig_for_target() { echo "error: CMUX_ZIG is not executable: $CMUX_ZIG" >&2 return 1 fi + if ! zig_has_required_version "$CMUX_ZIG"; then + echo "error: CMUX_ZIG must be zig ${ZIG_REQUIRED}: $CMUX_ZIG" >&2 + return 1 + fi echo "$CMUX_ZIG" return 0 fi @@ -63,6 +74,7 @@ select_zig_for_target() { canonical="$(cd "$(dirname "$candidate")" && pwd)/$(basename "$candidate")" [[ "$seen" == *" $canonical "* ]] && continue seen="${seen}${canonical} " + zig_has_required_version "$canonical" || continue [[ -z "$fallback" ]] && fallback="$canonical" if [[ -n "$desired_arch" ]]; then arch="$(zig_binary_arch "$canonical")" @@ -78,7 +90,7 @@ select_zig_for_target() { return 0 fi - echo "error: zig is required to build the Ghostty CLI helper" >&2 + echo "error: zig ${ZIG_REQUIRED} is required to build the Ghostty CLI helper" >&2 return 1 } diff --git a/scripts/install-zig-ci.sh b/scripts/install-zig-ci.sh index c49e147181c9..63e24fc84068 100755 --- a/scripts/install-zig-ci.sh +++ b/scripts/install-zig-ci.sh @@ -9,13 +9,71 @@ export HOMEBREW_NO_AUTO_UPDATE="${HOMEBREW_NO_AUTO_UPDATE:-1}" export HOMEBREW_NO_INSTALL_CLEANUP="${HOMEBREW_NO_INSTALL_CLEANUP:-1}" export HOMEBREW_NO_ENV_HINTS="${HOMEBREW_NO_ENV_HINTS:-1}" -if command -v zig >/dev/null 2>&1; then - INSTALLED_ZIG_VERSION="$(zig version 2>/dev/null || true)" - if [ "$INSTALLED_ZIG_VERSION" = "$ZIG_REQUIRED" ]; then - echo "zig ${ZIG_REQUIRED} already installed" - exit 0 +publish_zig_for_later_steps() { + local zig_path="$1" + local zig_dir + zig_dir="$(cd "$(dirname "$zig_path")" && pwd)" + zig_path="${zig_dir}/$(basename "$zig_path")" + if [ -n "${GITHUB_PATH:-}" ]; then + echo "$zig_dir" >> "$GITHUB_PATH" fi -fi + if [ -n "${GITHUB_ENV:-}" ]; then + echo "CMUX_ZIG=$zig_path" >> "$GITHUB_ENV" + fi +} + +zig_has_required_version() { + local zig_path="$1" + [ -x "$zig_path" ] || return 1 + [ "$("$zig_path" version 2>/dev/null || true)" = "$ZIG_REQUIRED" ] +} + +use_existing_zig_if_available() { + local candidate + local seen=" " + for candidate in "$(command -v zig 2>/dev/null || true)" /opt/homebrew/bin/zig /usr/local/bin/zig; do + [ -n "$candidate" ] || continue + [ -x "$candidate" ] || continue + candidate="$(cd "$(dirname "$candidate")" && pwd)/$(basename "$candidate")" + case "$seen" in + *" $candidate "*) continue ;; + esac + seen="${seen}${candidate} " + if zig_has_required_version "$candidate"; then + echo "zig ${ZIG_REQUIRED} already installed at $candidate" + publish_zig_for_later_steps "$candidate" + exit 0 + fi + done +} + +install_homebrew_zig_if_matching() { + command -v brew >/dev/null 2>&1 || return 1 + if brew info --json=v2 zig | python3 - "$ZIG_REQUIRED" <<'PY' +import json +import sys + +required = sys.argv[1] +data = json.load(sys.stdin) +formulae = data.get("formulae") or [] +stable = ((formulae[0].get("versions") or {}).get("stable") if formulae else None) +raise SystemExit(0 if stable == required else 1) +PY + then + brew install zig + for candidate in /opt/homebrew/bin/zig /usr/local/bin/zig; do + if zig_has_required_version "$candidate"; then + echo "Using Homebrew zig ${ZIG_REQUIRED} at $candidate" + publish_zig_for_later_steps "$candidate" + exit 0 + fi + done + fi + return 1 +} + +use_existing_zig_if_available +install_homebrew_zig_if_matching || true case "$(uname -m)" in arm64 | aarch64) ZIG_ARCH="aarch64" ;; From 9f7cce5ee05602e631f65c0910d6937f8551cbdb Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 29 May 2026 23:33:31 -0700 Subject: [PATCH 56/69] test: harden websocket pty close assertion --- daemon/remote/cmd/cmuxd-remote/ws_pty_test.go | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/daemon/remote/cmd/cmuxd-remote/ws_pty_test.go b/daemon/remote/cmd/cmuxd-remote/ws_pty_test.go index 22d9b70690ab..53fcf1d41243 100644 --- a/daemon/remote/cmd/cmuxd-remote/ws_pty_test.go +++ b/daemon/remote/cmd/cmuxd-remote/ws_pty_test.go @@ -6,6 +6,7 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "errors" "net/http" "net/http/httptest" "os" @@ -639,9 +640,17 @@ func TestWebSocketPTYWriteFailureClosesConnectionAndReapsAttachment(t *testing.T } waitForHubSessionCount(t, hub, 0, 5*time.Second) - _, _, err := conn.Read(ctx) - if err == nil { - t.Fatal("client connection stayed open after server write failure") + closeCtx, cancelClose := context.WithTimeout(ctx, 5*time.Second) + defer cancelClose() + for { + _, _, err := conn.Read(closeCtx) + if err == nil { + continue + } + if errors.Is(err, context.DeadlineExceeded) { + t.Fatal("client connection stayed open after server write failure") + } + break } } From f1f6029a1adce136b69f29e57c02e90cdad0059d Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 29 May 2026 23:44:17 -0700 Subject: [PATCH 57/69] ci: prefer homebrew zig for macos release build --- scripts/install-zig-ci.sh | 30 +++++++++++------------------- 1 file changed, 11 insertions(+), 19 deletions(-) diff --git a/scripts/install-zig-ci.sh b/scripts/install-zig-ci.sh index 63e24fc84068..77131ed8af9b 100755 --- a/scripts/install-zig-ci.sh +++ b/scripts/install-zig-ci.sh @@ -49,26 +49,18 @@ use_existing_zig_if_available() { install_homebrew_zig_if_matching() { command -v brew >/dev/null 2>&1 || return 1 - if brew info --json=v2 zig | python3 - "$ZIG_REQUIRED" <<'PY' -import json -import sys - -required = sys.argv[1] -data = json.load(sys.stdin) -formulae = data.get("formulae") or [] -stable = ((formulae[0].get("versions") or {}).get("stable") if formulae else None) -raise SystemExit(0 if stable == required else 1) -PY - then - brew install zig - for candidate in /opt/homebrew/bin/zig /usr/local/bin/zig; do - if zig_has_required_version "$candidate"; then - echo "Using Homebrew zig ${ZIG_REQUIRED} at $candidate" - publish_zig_for_later_steps "$candidate" - exit 0 - fi - done + if ! brew install zig; then + echo "Homebrew zig install failed; falling back to verified Zig tarball" >&2 + return 1 fi + for candidate in /opt/homebrew/bin/zig /usr/local/bin/zig; do + if zig_has_required_version "$candidate"; then + echo "Using Homebrew zig ${ZIG_REQUIRED} at $candidate" + publish_zig_for_later_steps "$candidate" + exit 0 + fi + done + echo "Homebrew zig did not provide ${ZIG_REQUIRED}; falling back to verified Zig tarball" >&2 return 1 } From f5a0c13754fc52db4a3a91fd41de9e95980440ce Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 29 May 2026 23:52:18 -0700 Subject: [PATCH 58/69] ci: build release artifacts on macos 15 --- .github/workflows/ci.yml | 4 +++- .github/workflows/nightly.yml | 3 ++- .github/workflows/release.yml | 3 ++- scripts/install-zig-ci.sh | 18 ------------------ 4 files changed, 7 insertions(+), 21 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ee9da3765d8b..7b9c1fd24c1d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -594,7 +594,9 @@ jobs: # Compile the same unsigned universal Release app that nightly builds before # signing, notarization, and publishing. This catches DEBUG/Release boundary # mistakes before they reach main. - runs-on: ${{ vars.MACOS_RUNNER_26 || 'warp-macos-26-arm64-6x' }} + # Zig 0.15.2 cannot link the Ghostty helper on macOS 26. Keep the + # universal Release build on the macOS 15 runner until the Zig pin moves. + runs-on: ${{ vars.MACOS_RUNNER_15 || 'warp-macos-15-arm64-6x' }} timeout-minutes: 20 steps: - name: Checkout diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index ec931143cbc5..0624a667413b 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -100,7 +100,8 @@ jobs: build-sign-notarize-nightly: needs: decide if: needs.decide.outputs.should_build == 'true' - runs-on: ${{ vars.MACOS_RUNNER_26 || 'warp-macos-26-arm64-6x' }} + # Zig 0.15.2 cannot link the bundled Ghostty helper on macOS 26. + runs-on: ${{ vars.MACOS_RUNNER_15 || 'warp-macos-15-arm64-6x' }} timeout-minutes: 20 steps: - name: Checkout build ref diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 56acd94f0bb9..20ae65014e19 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,7 +16,8 @@ env: jobs: build-sign-notarize: - runs-on: ${{ vars.MACOS_RUNNER_26 || 'warp-macos-26-arm64-6x' }} + # Zig 0.15.2 cannot link the bundled Ghostty helper on macOS 26. + runs-on: ${{ vars.MACOS_RUNNER_15 || 'warp-macos-15-arm64-6x' }} timeout-minutes: 20 steps: - name: Checkout diff --git a/scripts/install-zig-ci.sh b/scripts/install-zig-ci.sh index 77131ed8af9b..ce8417fb51f8 100755 --- a/scripts/install-zig-ci.sh +++ b/scripts/install-zig-ci.sh @@ -47,25 +47,7 @@ use_existing_zig_if_available() { done } -install_homebrew_zig_if_matching() { - command -v brew >/dev/null 2>&1 || return 1 - if ! brew install zig; then - echo "Homebrew zig install failed; falling back to verified Zig tarball" >&2 - return 1 - fi - for candidate in /opt/homebrew/bin/zig /usr/local/bin/zig; do - if zig_has_required_version "$candidate"; then - echo "Using Homebrew zig ${ZIG_REQUIRED} at $candidate" - publish_zig_for_later_steps "$candidate" - exit 0 - fi - done - echo "Homebrew zig did not provide ${ZIG_REQUIRED}; falling back to verified Zig tarball" >&2 - return 1 -} - use_existing_zig_if_available -install_homebrew_zig_if_matching || true case "$(uname -m)" in arm64 | aarch64) ZIG_ARCH="aarch64" ;; From 7874b9932c4b6d19f65e6f69c174ae99f8927c21 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Sat, 30 May 2026 00:58:30 -0700 Subject: [PATCH 59/69] ci: extend lag regression cold-build timeout --- .github/workflows/ci.yml | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4716bc258244..844d37df96c1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -398,11 +398,10 @@ jobs: runs-on: ${{ vars.MACOS_RUNNER_15 || 'warp-macos-15-arm64-6x' }} # A cold DerivedData cache (any project.pbxproj or Package.resolved change # mints a new cache key with no restore-keys fallback) forces a full - # cmux build whose Swift codegen alone runs ~18-20 min, so 20 was right at - # the edge and got guillotined mid-build (the post-cache step never ran, so - # the cache stayed cold and every retry timed out the same way). 35 gives a - # cold build enough room to finish and populate the cache. - timeout-minutes: 35 + # cmux build whose Swift codegen alone can run 20+ min. Project/package + # changes from the sidebar extension kit pushed this full build plus the + # CA and lag regressions beyond 35 min before the cache could repopulate. + timeout-minutes: 55 steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 From 24542610ff1da6ba1d67ae6fc087aa5b978fbb3f Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Sat, 30 May 2026 03:01:22 -0700 Subject: [PATCH 60/69] fix: address persistent ssh restore review --- Sources/Workspace.swift | 6 ++- .../TabManagerSessionSnapshotTests.swift | 3 ++ daemon/remote/cmd/cmuxd-remote/main.go | 26 +--------- daemon/remote/cmd/cmuxd-remote/main_test.go | 49 +++++++++++-------- 4 files changed, 38 insertions(+), 46 deletions(-) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 33ed2f0971b6..a2766cb23c0c 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -1683,7 +1683,11 @@ extension Workspace { (restoredBindingLaunch != nil && resumeBinding?.isAgentHookBinding == true) // Guarded startup commands cd themselves and tolerate deleted saved directories. // Passing the same cwd to Ghostty can fail before the guarded command runs. - let localWorkingDirectory = remoteStartupCommand == nil && !startupHandlesWorkingDirectory + let restoresRemoteTerminalSurface = remoteConfiguration != nil && snapshot.terminal?.isRemoteTerminal == true + let localWorkingDirectory = remoteStartupCommand == nil && + restoredRemotePTYAttachCommand == nil && + !restoresRemoteTerminalSurface && + !startupHandlesWorkingDirectory ? workingDirectory : nil let restoredAgentWillRunStartupCommand = restorableAgent != nil && ( diff --git a/cmuxTests/TabManagerSessionSnapshotTests.swift b/cmuxTests/TabManagerSessionSnapshotTests.swift index 369b9469c6d5..73becd9e168c 100644 --- a/cmuxTests/TabManagerSessionSnapshotTests.swift +++ b/cmuxTests/TabManagerSessionSnapshotTests.swift @@ -2015,6 +2015,7 @@ final class TabManagerSessionSnapshotTests: XCTestCase { ) remoteWorkspace.configureRemoteConnection(configuration, autoConnect: false) let remotePanelId = try XCTUnwrap(remoteWorkspace.focusedPanelId) + remoteWorkspace.updatePanelDirectory(panelId: remotePanelId, directory: "/home/dev/persistent-project") let expectedSessionID = Workspace.defaultSSHPTYSessionID( workspaceId: remoteWorkspace.id, panelId: remotePanelId @@ -2090,6 +2091,8 @@ final class TabManagerSessionSnapshotTests: XCTestCase { 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 diff --git a/daemon/remote/cmd/cmuxd-remote/main.go b/daemon/remote/cmd/cmuxd-remote/main.go index 85a5e0dffbcf..b335defe7a00 100644 --- a/daemon/remote/cmd/cmuxd-remote/main.go +++ b/daemon/remote/cmd/cmuxd-remote/main.go @@ -319,7 +319,6 @@ var errPersistentDaemonAuthFailed = errors.New("persistent daemon authentication const ( persistentDaemonStartupTimeout = 5 * time.Second - persistentDaemonDialPollInterval = 25 * time.Millisecond persistentDaemonEmptyIdleTimeout = 5 * time.Minute persistentDaemonEmptyIdlePollStep = time.Second ) @@ -743,11 +742,7 @@ func ensurePersistentDaemonRunning(paths persistentDaemonPaths, token string, st _ = cmd.Process.Release() if err := waitPersistentDaemonReady(readyReader, paths.logFile); err != nil { - if conn, dialErr := waitForPersistentDaemonDial( - paths.socket, - token, - persistentDaemonStartupTimeout, - ); dialErr == nil { + if conn, dialErr := dialPersistentDaemon(paths.socket, token); dialErr == nil { _ = conn.Close() return nil } @@ -757,7 +752,7 @@ func ensurePersistentDaemonRunning(paths persistentDaemonPaths, token string, st return err } - conn, err := waitForPersistentDaemonDial(paths.socket, token, persistentDaemonStartupTimeout) + conn, err := dialPersistentDaemon(paths.socket, token) if err == nil { _ = conn.Close() return nil @@ -768,23 +763,6 @@ func ensurePersistentDaemonRunning(paths persistentDaemonPaths, token string, st return err } -func waitForPersistentDaemonDial(socketPath string, token string, timeout time.Duration) (net.Conn, error) { - deadline := time.Now().Add(timeout) - var lastErr error - for { - conn, err := dialPersistentDaemon(socketPath, token) - if err == nil { - return conn, nil - } - lastErr = err - remaining := time.Until(deadline) - if remaining <= 0 { - return nil, lastErr - } - time.Sleep(minDuration(remaining, persistentDaemonDialPollInterval)) - } -} - func shouldRemovePersistentSocketAfterDialError(err error) bool { return errors.Is(err, os.ErrNotExist) || errors.Is(err, syscall.ENOENT) || diff --git a/daemon/remote/cmd/cmuxd-remote/main_test.go b/daemon/remote/cmd/cmuxd-remote/main_test.go index 7a2770353f8a..382f310e6ffe 100644 --- a/daemon/remote/cmd/cmuxd-remote/main_test.go +++ b/daemon/remote/cmd/cmuxd-remote/main_test.go @@ -830,42 +830,49 @@ func TestPersistentDaemonPTYReattachSurvivesClientDisconnect(t *testing.T) { } } -func TestWaitForPersistentDaemonDialWaitsForPeerStartup(t *testing.T) { - socketDir, err := os.MkdirTemp("/tmp", "cmuxd-remote-race-*") +func TestPersistentDaemonReadySignalAllowsImmediateDial(t *testing.T) { + socketDir, err := os.MkdirTemp("/tmp", "cmuxd-remote-ready-*") if err != nil { t.Fatalf("create short socket dir: %v", err) } defer os.RemoveAll(socketDir) socketPath := filepath.Join(socketDir, "rpc.sock") + listener, err := net.Listen("unix", socketPath) + if err != nil { + t.Fatalf("listen unix: %v", err) + } - type listenerResult struct { - listener net.Listener - err error + readyReader, readyWriter, err := os.Pipe() + if err != nil { + _ = listener.Close() + t.Fatalf("create ready pipe: %v", err) + } + defer readyReader.Close() + t.Setenv(persistentDaemonReadyFDEnv, strconv.Itoa(int(readyWriter.Fd()))) + signalPersistentDaemonReady() + line, err := bufio.NewReader(readyReader).ReadString('\n') + if err != nil { + _ = listener.Close() + t.Fatalf("read ready signal: %v", err) + } + if strings.TrimSpace(line) != "ready" { + _ = listener.Close() + t.Fatalf("ready signal = %q, want ready", strings.TrimSpace(line)) } - listenerCh := make(chan listenerResult, 1) + done := make(chan error, 1) go func() { - time.Sleep(50 * time.Millisecond) - listener, listenErr := net.Listen("unix", socketPath) - listenerCh <- listenerResult{listener: listener, err: listenErr} - if listenErr != nil { - done <- listenErr - return - } - done <- servePersistentDaemonWithVerifier(listener, persistentDaemonFixedTokenVerifier("race-token"), io.Discard) + done <- servePersistentDaemonWithVerifier(listener, persistentDaemonFixedTokenVerifier("ready-token"), io.Discard) }() - conn, err := waitForPersistentDaemonDial(socketPath, "race-token", time.Second) + conn, err := dialPersistentDaemon(socketPath, "ready-token") if err != nil { - t.Fatalf("waitForPersistentDaemonDial returned error: %v", err) + _ = listener.Close() + t.Fatalf("dial persistent daemon after ready signal: %v", err) } _ = conn.Close() - gotListener := <-listenerCh - if gotListener.err != nil { - t.Fatalf("listen unix: %v", gotListener.err) - } - _ = gotListener.listener.Close() + _ = listener.Close() select { case err := <-done: if err != nil { From b9b5dd5e0f364244eefe7ef79455d2106847f6e4 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 30 May 2026 03:32:37 -0700 Subject: [PATCH 61/69] fix: preserve failed persistent pty split panes --- Sources/TabManager.swift | 9 ++--- cmuxTests/TabManagerUnitTests.swift | 56 +++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index aad303018403..d748fe7505d1 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -7899,24 +7899,25 @@ class TabManager: ObservableObject { func closePanelAfterChildExited(tabId: UUID, surfaceId: UUID) { guard let tab = tabs.first(where: { $0.id == tabId }) else { return } guard tab.panels[surfaceId] != nil else { return } + let keepsPersistentRemoteSurfaceOpen = + tab.remoteConfiguration?.preserveAfterTerminalExit == true && + tab.isRemoteTerminalSurface(surfaceId) let handlesRemoteExitThroughWorkspace = tab.panels.count <= 1 && tab.shouldDemoteWorkspaceAfterChildExit(surfaceId: surfaceId) - let keepsPersistentRemoteWorkspaceOpen = - handlesRemoteExitThroughWorkspace && tab.remoteConfiguration?.preserveAfterTerminalExit == true #if DEBUG cmuxDebugLog( "surface.close.childExited tab=\(tabId.uuidString.prefix(5)) " + "surface=\(surfaceId.uuidString.prefix(5)) panels=\(tab.panels.count) workspaces=\(tabs.count) " + "remoteWorkspace=\(tab.isRemoteWorkspace ? 1 : 0) keepRemote=\(handlesRemoteExitThroughWorkspace ? 1 : 0) " + - "keepPersistentRemote=\(keepsPersistentRemoteWorkspaceOpen ? 1 : 0)" + "keepPersistentRemote=\(keepsPersistentRemoteSurfaceOpen ? 1 : 0)" ) #endif // A persistent SSH workspace must never silently replace a failed remote attach with // a local login shell. Keep the exited surface visible so the user can see the error // and retry instead of making a detached remote workspace look local after relaunch. - if keepsPersistentRemoteWorkspaceOpen { + if keepsPersistentRemoteSurfaceOpen { tab.markPersistentRemotePTYAttachFailed(surfaceId: surfaceId) return } diff --git a/cmuxTests/TabManagerUnitTests.swift b/cmuxTests/TabManagerUnitTests.swift index 154173278c26..0944b1d8a73c 100644 --- a/cmuxTests/TabManagerUnitTests.swift +++ b/cmuxTests/TabManagerUnitTests.swift @@ -284,6 +284,62 @@ final class TabManagerChildExitCloseTests: XCTestCase { ) } + func testChildExitOnSplitPersistentRemotePanelKeepsExitedSurfaceVisibleAndClearsOnlyThatPTYState() throws { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let remotePanelId = workspace.focusedPanelId else { + XCTFail("Expected selected workspace with focused panel") + return + } + + workspace.configureRemoteConnection( + WorkspaceRemoteConfiguration( + destination: "cmux-macmini", + port: nil, + identityFile: nil, + sshOptions: [], + localProxyPort: nil, + relayPort: 64018, + relayID: String(repeating: "a", count: 16), + relayToken: String(repeating: "b", count: 64), + localSocketPath: "/tmp/cmux-debug-split-test.sock", + terminalStartupCommand: SSHPTYAttachStartupCommandBuilder.command(), + preserveAfterTerminalExit: true, + persistentDaemonSlot: "ssh-child-exit-split-test" + ), + autoConnect: false + ) + let siblingPanel = try XCTUnwrap( + workspace.newTerminalSplit(from: remotePanelId, orientation: .horizontal, focus: false) + ) + + XCTAssertTrue(workspace.isRemoteWorkspace) + XCTAssertTrue(workspace.isRemoteTerminalSurface(remotePanelId)) + XCTAssertTrue(workspace.isRemoteTerminalSurface(siblingPanel.id)) + + manager.closePanelAfterChildExited(tabId: workspace.id, surfaceId: remotePanelId) + drainMainQueue() + drainMainQueue() + + XCTAssertEqual(manager.tabs.count, 1) + XCTAssertEqual(manager.selectedTabId, workspace.id) + XCTAssertTrue(workspace.isRemoteWorkspace) + XCTAssertNotNil(workspace.panels[remotePanelId]) + XCTAssertNotNil(workspace.panels[siblingPanel.id]) + XCTAssertEqual(workspace.panels.count, 2) + XCTAssertEqual(workspace.activeRemoteTerminalSessionCount, 1) + XCTAssertFalse(workspace.isRemoteTerminalSurface(remotePanelId)) + XCTAssertTrue(workspace.isRemoteTerminalSurface(siblingPanel.id)) + XCTAssertNil( + workspace.sessionSnapshot(includeScrollback: false) + .panels.first { $0.id == remotePanelId }?.terminal?.remotePTYSessionID + ) + XCTAssertNotNil( + workspace.sessionSnapshot(includeScrollback: false) + .panels.first { $0.id == siblingPanel.id }?.terminal?.remotePTYSessionID + ) + } + func testChildExitAfterRemoteSessionEndKeepsWorkspaceAndDemotesToLocal() throws { let manager = TabManager() guard let workspace = manager.selectedWorkspace, From 23e6ad444cdb8fcf31c95ea1080f15eb0ab443b5 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 30 May 2026 04:06:28 -0700 Subject: [PATCH 62/69] fix: suppress ended persistent pty restore startup --- Sources/Workspace.swift | 17 ++++++++++++----- cmuxTests/TabManagerSessionSnapshotTests.swift | 3 ++- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 9da4ffceb262..f79c3e6d7926 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -1683,10 +1683,15 @@ extension Workspace { (restoredBindingLaunch != nil && resumeBinding?.isAgentHookBinding == true) // Guarded startup commands cd themselves and tolerate deleted saved directories. // Passing the same cwd to Ghostty can fail before the guarded command runs. - let restoresRemoteTerminalSurface = remoteConfiguration != nil && snapshot.terminal?.isRemoteTerminal == true + let restoresRemoteWorkspaceTerminalSnapshot = + remoteConfiguration != nil && snapshot.terminal?.isRemoteTerminal != nil + let suppressWorkspaceRemoteStartupCommand = + remoteConfiguration != nil && + snapshot.terminal?.isRemoteTerminal == false && + restoredRemotePTYAttachCommand == nil let localWorkingDirectory = remoteStartupCommand == nil && restoredRemotePTYAttachCommand == nil && - !restoresRemoteTerminalSurface && + !restoresRemoteWorkspaceTerminalSnapshot && !startupHandlesWorkingDirectory ? workingDirectory : nil @@ -1731,7 +1736,8 @@ extension Workspace { tmuxStartCommand: restoredTmuxStartCommand, initialInput: restoredStartupInput, startupEnvironment: replayEnvironment, - remotePTYSessionID: restoredRemotePTYSessionID + remotePTYSessionID: restoredRemotePTYSessionID, + suppressWorkspaceRemoteStartupCommand: suppressWorkspaceRemoteStartupCommand ) else { return nil } @@ -14248,7 +14254,8 @@ final class Workspace: Identifiable, ObservableObject { tmuxStartCommand: String? = nil, initialInput: String? = nil, startupEnvironment: [String: String] = [:], - remotePTYSessionID: String? = nil + remotePTYSessionID: String? = nil, + suppressWorkspaceRemoteStartupCommand: Bool = false ) -> TerminalPanel? { let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId) let previousFocusedPanelId = focusedPanelId @@ -14257,7 +14264,7 @@ final class Workspace: Identifiable, ObservableObject { var inheritedConfig = inheritedTerminalConfig(inPane: paneId) let requestedInitialCommand = initialCommand?.trimmingCharacters(in: .whitespacesAndNewlines) let explicitInitialCommand = (requestedInitialCommand?.isEmpty == false) ? requestedInitialCommand : nil - let remoteTerminalStartupCommand = remoteTerminalStartupCommand() + let remoteTerminalStartupCommand = suppressWorkspaceRemoteStartupCommand ? nil : remoteTerminalStartupCommand() let startupCommand = explicitInitialCommand ?? remoteTerminalStartupCommand // See the comment at the other call site: hold the PTY open after the remote // command exits so the user sees the error rather than a silently-respawned diff --git a/cmuxTests/TabManagerSessionSnapshotTests.swift b/cmuxTests/TabManagerSessionSnapshotTests.swift index 73becd9e168c..27a7f59c7663 100644 --- a/cmuxTests/TabManagerSessionSnapshotTests.swift +++ b/cmuxTests/TabManagerSessionSnapshotTests.swift @@ -2769,7 +2769,8 @@ final class TabManagerSessionSnapshotTests: XCTestCase { let restoredWorkspace = try XCTUnwrap(restored.tabs.first { $0.customTitle == "Ended Persistent SSH" }) let restoredPanelId = try XCTUnwrap(restoredWorkspace.focusedPanelId) let restoredInitialCommand = restoredWorkspace.terminalPanel(for: restoredPanelId)?.surface.debugInitialCommand() - XCTAssertFalse(restoredInitialCommand?.contains("ssh-pty-attach") == true, restoredInitialCommand ?? "") + XCTAssertNil(restoredInitialCommand) + XCTAssertNil(restoredWorkspace.terminalPanel(for: restoredPanelId)?.requestedWorkingDirectory) XCTAssertNil( restoredWorkspace.sessionSnapshot(includeScrollback: false) .panels.first { $0.id == restoredPanelId }?.terminal?.remotePTYSessionID From d895db49d723635874eeb9a4a359e26afa920ca3 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 30 May 2026 04:13:16 -0700 Subject: [PATCH 63/69] test: avoid ready fd reuse in daemon tests --- daemon/remote/cmd/cmuxd-remote/main_test.go | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/daemon/remote/cmd/cmuxd-remote/main_test.go b/daemon/remote/cmd/cmuxd-remote/main_test.go index 382f310e6ffe..f40e8880ac1b 100644 --- a/daemon/remote/cmd/cmuxd-remote/main_test.go +++ b/daemon/remote/cmd/cmuxd-remote/main_test.go @@ -17,6 +17,7 @@ import ( "strconv" "strings" "sync" + "syscall" "testing" "time" ) @@ -848,8 +849,15 @@ func TestPersistentDaemonReadySignalAllowsImmediateDial(t *testing.T) { t.Fatalf("create ready pipe: %v", err) } defer readyReader.Close() - t.Setenv(persistentDaemonReadyFDEnv, strconv.Itoa(int(readyWriter.Fd()))) + readyFD, err := syscall.Dup(int(readyWriter.Fd())) + if err != nil { + _ = listener.Close() + _ = readyWriter.Close() + t.Fatalf("duplicate ready fd: %v", err) + } + t.Setenv(persistentDaemonReadyFDEnv, strconv.Itoa(readyFD)) signalPersistentDaemonReady() + _ = readyWriter.Close() line, err := bufio.NewReader(readyReader).ReadString('\n') if err != nil { _ = listener.Close() @@ -902,8 +910,8 @@ func TestPersistentDaemonServerExitsAfterEmptySlotIdleTimeout(t *testing.T) { persistentDaemonFixedTokenVerifier("idle-token"), io.Discard, persistentDaemonServerConfig{ - emptyIdleTimeout: 80 * time.Millisecond, - acceptPollStep: 10 * time.Millisecond, + emptyIdleTimeout: 500 * time.Millisecond, + acceptPollStep: 25 * time.Millisecond, }, ) }() From 5f7148c4325695d9c403e35b4223c161669a975e Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 30 May 2026 04:28:37 -0700 Subject: [PATCH 64/69] fix: wire browser tab mute context action --- Sources/Workspace.swift | 35 +++++++++++++++++++++++--- cmuxTests/TabManagerUnitTests.swift | 39 +++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 4 deletions(-) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index f79c3e6d7926..8225dfe0e42d 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -1900,6 +1900,7 @@ extension Workspace { } browserPanel.restoreSessionSnapshot(browserSnapshot) + syncBrowserAudioMuteStateForPanel(panelId, browserPanel: browserPanel) if browserSnapshot.developerToolsVisible && BrowserAvailabilitySettings.isEnabled() { _ = browserPanel.showDeveloperTools() @@ -11355,12 +11356,15 @@ final class Workspace: Identifiable, ObservableObject { } private func installBrowserPanelSubscription(_ browserPanel: BrowserPanel) { - let subscription = Publishers.CombineLatest4( + let browserTabState = Publishers.CombineLatest4( browserPanel.$pageTitle.removeDuplicates(), browserPanel.$currentURL.removeDuplicates(), browserPanel.$isLoading.removeDuplicates(), browserPanel.$faviconPNGData.removeDuplicates(by: { $0 == $1 }) ) + let subscription = browserTabState + .combineLatest(browserPanel.$isMuted.removeDuplicates()) .receive(on: DispatchQueue.main) - .sink { [weak self, weak browserPanel] _, _, isLoading, favicon in + .sink { [weak self, weak browserPanel] output in + let ((_, _, isLoading, favicon), isMuted) = output guard let self = self, let browserPanel = browserPanel, let tabId = self.surfaceIdFromPanelId(browserPanel.id) else { return } @@ -11374,13 +11378,15 @@ final class Workspace: Identifiable, ObservableObject { let titleUpdate: String? = existing.title == resolvedTitle ? nil : resolvedTitle let faviconUpdate: Data?? = existing.iconImageData == favicon ? nil : .some(favicon) let loadingUpdate: Bool? = existing.isLoading == isLoading ? nil : isLoading - guard titleUpdate != nil || faviconUpdate != nil || loadingUpdate != nil else { return } + let mutedUpdate: Bool? = existing.isAudioMuted == isMuted ? nil : isMuted + guard titleUpdate != nil || faviconUpdate != nil || loadingUpdate != nil || mutedUpdate != nil else { return } self.bonsplitController.updateTab( tabId, title: titleUpdate, iconImageData: faviconUpdate, hasCustomTitle: self.panelCustomTitles[browserPanel.id] != nil, - isLoading: loadingUpdate + isLoading: loadingUpdate, + isAudioMuted: mutedUpdate ) } panelSubscriptions[browserPanel.id] = subscription @@ -11388,6 +11394,14 @@ final class Workspace: Identifiable, ObservableObject { setPreferredBrowserProfileID(browserPanel.profileID) } + private func syncBrowserAudioMuteStateForPanel(_ panelId: UUID, browserPanel: BrowserPanel? = nil) { + guard let browserPanel = browserPanel ?? self.browserPanel(for: panelId), + let tabId = surfaceIdFromPanelId(panelId), + let tab = bonsplitController.tab(tabId), + tab.isAudioMuted != browserPanel.isMuted else { return } + bonsplitController.updateTab(tabId, isAudioMuted: browserPanel.isMuted) + } + func setPreferredBrowserProfileID(_ profileID: UUID?) { guard let profileID else { preferredBrowserProfileID = nil @@ -14422,6 +14436,7 @@ final class Workspace: Identifiable, ObservableObject { kind: SurfaceKind.browser, isDirty: browserPanel.isDirty, isLoading: browserPanel.isLoading, + isAudioMuted: browserPanel.isMuted, isPinned: false ) surfaceIdToPanelId[newTab.id] = browserPanel.id @@ -14521,6 +14536,7 @@ final class Workspace: Identifiable, ObservableObject { kind: SurfaceKind.browser, isDirty: browserPanel.isDirty, isLoading: browserPanel.isLoading, + isAudioMuted: browserPanel.isMuted, isPinned: false, inPane: paneId ) else { @@ -15608,6 +15624,7 @@ final class Workspace: Identifiable, ObservableObject { } else { restoredUnreadPanelIndicators.removeValue(forKey: detached.panelId) } + let detachedBrowserMuted = (detached.panel as? BrowserPanel)?.isMuted ?? false guard let newTabId = bonsplitController.createTab( title: detached.title, @@ -15617,6 +15634,7 @@ final class Workspace: Identifiable, ObservableObject { kind: detached.kind, isDirty: detached.panel.isDirty, isLoading: detached.isLoading, + isAudioMuted: detachedBrowserMuted, isPinned: detached.isPinned, inPane: paneId ) else { @@ -17114,6 +17132,7 @@ final class Workspace: Identifiable, ObservableObject { bypassRemoteProxy: browser.bypassesRemoteWorkspaceProxyForTabDuplication ) else { return nil } newPanel.setMuted(browser.isMuted) + syncBrowserAudioMuteStateForPanel(newPanel.id, browserPanel: newPanel) _ = reorderSurface(panelId: newPanel.id, toIndex: targetIndex, focus: focus) return newPanel } @@ -18902,6 +18921,14 @@ extension Workspace: BonsplitDelegate { guard let panelId = panelIdFromSurfaceId(tab.id), let browser = browserPanel(for: panelId) else { return } browser.reload() + case .toggleAudioMute: + guard let panelId = panelIdFromSurfaceId(tab.id), + let browser = browserPanel(for: panelId) else { return } + guard browser.toggleMute() else { + NSSound.beep() + return + } + syncBrowserAudioMuteStateForPanel(panelId, browserPanel: browser) case .duplicate: guard let panelId = panelIdFromSurfaceId(tab.id) else { return } _ = duplicateBrowserToRight(panelId: panelId) diff --git a/cmuxTests/TabManagerUnitTests.swift b/cmuxTests/TabManagerUnitTests.swift index 49b624560852..1131a85decf3 100644 --- a/cmuxTests/TabManagerUnitTests.swift +++ b/cmuxTests/TabManagerUnitTests.swift @@ -2541,11 +2541,50 @@ final class TabManagerSurfaceCreationTests: XCTestCase { bypassRemoteProxy: true ) ) + guard browserPanel.setMuted(true) else { + throw XCTSkip("WKWebView page-audio mute selector is unavailable") + } let duplicate = try XCTUnwrap(workspace.duplicateBrowserToRight(panelId: browserPanel.id, focus: false)) + let duplicateTabId = try XCTUnwrap(workspace.surfaceIdFromPanelId(duplicate.id)) + let duplicateTab = try XCTUnwrap(workspace.bonsplitController.tab(duplicateTabId)) XCTAssertFalse(duplicate.isOmnibarVisible) XCTAssertTrue(duplicate.bypassesRemoteWorkspaceProxyForTabDuplication) + XCTAssertTrue(duplicate.isMuted) + XCTAssertTrue(duplicateTab.isAudioMuted) + } + + func testBrowserAudioMuteContextActionTogglesPanelAndTabState() throws { + let workspace = Workspace() + let paneId = try XCTUnwrap(workspace.bonsplitController.focusedPaneId) + let browserPanel = try XCTUnwrap(workspace.newBrowserSurface(inPane: paneId, focus: true)) + let tabId = try XCTUnwrap(workspace.surfaceIdFromPanelId(browserPanel.id)) + guard browserPanel.setMuted(false) else { + throw XCTSkip("WKWebView page-audio mute selector is unavailable") + } + + let initialTab = try XCTUnwrap(workspace.bonsplitController.tab(tabId)) + workspace.splitTabBar( + workspace.bonsplitController, + didRequestTabContextAction: .toggleAudioMute, + for: initialTab, + inPane: paneId + ) + + XCTAssertTrue(browserPanel.isMuted) + XCTAssertTrue(try XCTUnwrap(workspace.bonsplitController.tab(tabId)).isAudioMuted) + + let mutedTab = try XCTUnwrap(workspace.bonsplitController.tab(tabId)) + workspace.splitTabBar( + workspace.bonsplitController, + didRequestTabContextAction: .toggleAudioMute, + for: mutedTab, + inPane: paneId + ) + + XCTAssertFalse(browserPanel.isMuted) + XCTAssertFalse(try XCTUnwrap(workspace.bonsplitController.tab(tabId)).isAudioMuted) } func testOpenBrowserInWorkspaceSplitRightSelectsTargetWorkspaceAndCreatesSplit() { From 3076ff6fba80f5db2427382e2a4f28f336b46be9 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 30 May 2026 16:38:36 -0700 Subject: [PATCH 65/69] fix: preserve persistent attach failures --- Sources/TabManager.swift | 3 +- Sources/Workspace.swift | 61 +++++++++++++++++-- cmuxTests/TabManagerUnitTests.swift | 50 +++++++++++++++ .../WorkspaceRemoteConnectionTests.swift | 58 ++++++++++++++++++ 4 files changed, 166 insertions(+), 6 deletions(-) diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index d748fe7505d1..4cd66d38b923 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -7900,8 +7900,7 @@ class TabManager: ObservableObject { guard let tab = tabs.first(where: { $0.id == tabId }) else { return } guard tab.panels[surfaceId] != nil else { return } let keepsPersistentRemoteSurfaceOpen = - tab.remoteConfiguration?.preserveAfterTerminalExit == true && - tab.isRemoteTerminalSurface(surfaceId) + tab.shouldKeepPersistentRemoteSurfaceOpenAfterChildExit(surfaceId) let handlesRemoteExitThroughWorkspace = tab.panels.count <= 1 && tab.shouldDemoteWorkspaceAfterChildExit(surfaceId: surfaceId) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 8225dfe0e42d..817d0355eec5 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -7917,12 +7917,45 @@ final class WorkspaceRemoteSessionController { *) continue ;; esac cmux_child_pids="$(printf '%s\\n' "$cmux_ps_output" | awk -v parent="$cmux_listener_pid" -v slot="$cmux_persistent_slot" ' + function clean_token(value) { + gsub(/'\''/, "", value) + gsub(/"/, "", value) + gsub(/\\\\/, "", value) + return value + } + function has_token(target, i) { + for (i = 3; i <= NF; i++) { + if (clean_token($i) == target) return 1 + } + return 0 + } + function next_value(after, i, value) { + for (i = after + 1; i <= NF; i++) { + value = clean_token($i) + if (value != "") return value + } + return "" + } + function has_exact_slot(i, token, value) { + for (i = 3; i <= NF; i++) { + token = clean_token($i) + if (token == "--slot") { + return next_value(i) == slot + } + if (token ~ /^--slot=/) { + value = substr(token, 8) + if (value != "") return value == slot + return next_value(i) == slot + } + } + return 0 + } $2 == parent && index($0, "cmuxd-remote") && - index($0, "serve") && - index($0, "--stdio") && - index($0, "--persistent") && - index($0, slot) && + has_token("serve") && + has_token("--stdio") && + has_token("--persistent") && + has_exact_slot() && $1 ~ /^[0-9]+$/ { print $1 } @@ -10411,6 +10444,7 @@ final class Workspace: Identifiable, ObservableObject { private var remoteLastPortConflictFingerprint: String? private var remoteDetectedSurfaceIds: Set = [] private var activeRemoteTerminalSurfaceIds: Set = [] + private var endedPersistentRemotePTYAttachSurfaceIds: Set = [] private var remotePTYSessionIDsByPanelId: [UUID: String] = [:] private var remoteRelayWorkspaceIDAliases: [UUID: UUID] = [:] private var remoteRelaySurfaceIDAliases: [UUID: UUID] = [:] @@ -12546,6 +12580,7 @@ final class Workspace: Identifiable, ObservableObject { surfaceListeningPorts = surfaceListeningPorts.filter { validSurfaceIds.contains($0.key) } surfaceTTYNames = surfaceTTYNames.filter { validSurfaceIds.contains($0.key) } remotePTYSessionIDsByPanelId = remotePTYSessionIDsByPanelId.filter { validSurfaceIds.contains($0.key) } + endedPersistentRemotePTYAttachSurfaceIds = endedPersistentRemotePTYAttachSurfaceIds.filter { validSurfaceIds.contains($0) } pruneRemoteRelaySurfaceAliases(validSurfaceIds: validSurfaceIds) remoteDetectedSurfaceIds = remoteDetectedSurfaceIds.filter { validSurfaceIds.contains($0) } panelShellActivityStates = panelShellActivityStates.filter { validSurfaceIds.contains($0.key) } @@ -12789,6 +12824,13 @@ final class Workspace: Identifiable, ObservableObject { activeRemoteTerminalSurfaceIds.contains(panelId) } + @MainActor + func shouldKeepPersistentRemoteSurfaceOpenAfterChildExit(_ panelId: UUID) -> Bool { + guard remoteConfiguration?.preserveAfterTerminalExit == true else { return false } + return activeRemoteTerminalSurfaceIds.contains(panelId) || + endedPersistentRemotePTYAttachSurfaceIds.contains(panelId) + } + @MainActor func shouldDemoteWorkspaceAfterChildExit(surfaceId: UUID) -> Bool { isRemoteWorkspace || pendingRemoteTerminalChildExitSurfaceIds.contains(surfaceId) @@ -12991,6 +13033,7 @@ final class Workspace: Identifiable, ObservableObject { previousConfiguration != configuration, !previousConfiguration.hasSamePersistentPTYIdentity(as: configuration) { remotePTYSessionIDsByPanelId.removeAll() + endedPersistentRemotePTYAttachSurfaceIds.removeAll() clearRemoteRelayIDAliases() } remoteConfiguration = configuration @@ -13094,6 +13137,7 @@ final class Workspace: Identifiable, ObservableObject { previousController?.stop() pendingRemoteForegroundAuthToken = nil activeRemoteTerminalSurfaceIds.removeAll() + endedPersistentRemotePTYAttachSurfaceIds.removeAll() activeRemoteTerminalSessionCount = 0 pendingRemoteSurfaceTTYName = nil pendingRemoteSurfaceTTYSurfaceId = nil @@ -13116,6 +13160,7 @@ final class Workspace: Identifiable, ObservableObject { remoteLastPortConflictFingerprint = nil if clearConfiguration { remotePTYSessionIDsByPanelId.removeAll() + endedPersistentRemotePTYAttachSurfaceIds.removeAll() clearRemoteRelayIDAliases() remoteConfiguration = nil skipControlMasterCleanupAfterDetachedRemoteTransfer = false @@ -13150,6 +13195,7 @@ final class Workspace: Identifiable, ObservableObject { private func trackRemoteTerminalSurface(_ panelId: UUID) { skipControlMasterCleanupAfterDetachedRemoteTransfer = false + endedPersistentRemotePTYAttachSurfaceIds.remove(panelId) pendingRemoteTerminalChildExitSurfaceIds.remove(panelId) transferredRemoteCleanupConfigurationsByPanelId.removeValue(forKey: panelId) if remoteConfiguration?.preserveAfterTerminalExit == true, @@ -13470,6 +13516,7 @@ final class Workspace: Identifiable, ObservableObject { func discardRemotePTYSessionID(panelId: UUID) { remotePTYSessionIDsByPanelId.removeValue(forKey: panelId) + endedPersistentRemotePTYAttachSurfaceIds.remove(panelId) removeRemoteRelaySurfaceAliases(targeting: panelId) } @@ -13493,6 +13540,11 @@ final class Workspace: Identifiable, ObservableObject { } let wasTracked = activeRemoteTerminalSurfaceIds.contains(surfaceId) + if remoteConfiguration?.preserveAfterTerminalExit == true { + endedPersistentRemotePTYAttachSurfaceIds.insert(surfaceId) + } else { + endedPersistentRemotePTYAttachSurfaceIds.remove(surfaceId) + } remotePTYSessionIDsByPanelId.removeValue(forKey: surfaceId) removeRemoteRelaySurfaceAliases(targeting: surfaceId) untrackRemoteTerminalSurface(surfaceId) @@ -13503,6 +13555,7 @@ final class Workspace: Identifiable, ObservableObject { guard remoteConfiguration?.preserveAfterTerminalExit == true else { return } remotePTYSessionIDsByPanelId.removeValue(forKey: surfaceId) + endedPersistentRemotePTYAttachSurfaceIds.remove(surfaceId) removeRemoteRelaySurfaceAliases(targeting: surfaceId) pendingRemoteTerminalChildExitSurfaceIds.remove(surfaceId) transferredRemoteCleanupConfigurationsByPanelId.removeValue(forKey: surfaceId) diff --git a/cmuxTests/TabManagerUnitTests.swift b/cmuxTests/TabManagerUnitTests.swift index 1131a85decf3..3722dc58675d 100644 --- a/cmuxTests/TabManagerUnitTests.swift +++ b/cmuxTests/TabManagerUnitTests.swift @@ -284,6 +284,56 @@ final class TabManagerChildExitCloseTests: XCTestCase { ) } + func testChildExitAfterPersistentAttachEndKeepsExitedSurfaceVisible() throws { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let remotePanelId = workspace.focusedPanelId else { + XCTFail("Expected selected workspace with focused panel") + return + } + + workspace.configureRemoteConnection( + WorkspaceRemoteConfiguration( + destination: "cmux-macmini", + port: nil, + identityFile: nil, + sshOptions: [], + localProxyPort: nil, + relayPort: 64020, + relayID: String(repeating: "a", count: 16), + relayToken: String(repeating: "b", count: 64), + localSocketPath: "/tmp/cmux-debug-attach-end-test.sock", + terminalStartupCommand: SSHPTYAttachStartupCommandBuilder.command(), + preserveAfterTerminalExit: true, + persistentDaemonSlot: "ssh-child-exit-after-attach-end" + ), + autoConnect: false + ) + let sessionID = Workspace.defaultSSHPTYSessionID(workspaceId: workspace.id, panelId: remotePanelId) + + let outcome = workspace.markRemotePTYAttachEnded(surfaceId: remotePanelId, sessionID: sessionID) + + XCTAssertTrue(outcome.clearedRemotePTYSession) + XCTAssertTrue(outcome.untrackedRemoteTerminal) + XCTAssertFalse(workspace.isRemoteTerminalSurface(remotePanelId)) + XCTAssertEqual(workspace.activeRemoteTerminalSessionCount, 0) + XCTAssertTrue(workspace.shouldKeepPersistentRemoteSurfaceOpenAfterChildExit(remotePanelId)) + + manager.closePanelAfterChildExited(tabId: workspace.id, surfaceId: remotePanelId) + drainMainQueue() + drainMainQueue() + + XCTAssertTrue(workspace.isRemoteWorkspace) + XCTAssertNotNil(workspace.panels[remotePanelId]) + XCTAssertEqual(workspace.panels.count, 1) + XCTAssertEqual(workspace.focusedPanelId, remotePanelId) + XCTAssertFalse(workspace.shouldKeepPersistentRemoteSurfaceOpenAfterChildExit(remotePanelId)) + XCTAssertNil( + workspace.sessionSnapshot(includeScrollback: false) + .panels.first { $0.id == remotePanelId }?.terminal?.remotePTYSessionID + ) + } + func testChildExitOnSplitPersistentRemotePanelKeepsExitedSurfaceVisibleAndClearsOnlyThatPTYState() throws { let manager = TabManager() guard let workspace = manager.selectedWorkspace, diff --git a/cmuxTests/WorkspaceRemoteConnectionTests.swift b/cmuxTests/WorkspaceRemoteConnectionTests.swift index 6e9a3ee51f88..72c04d98190d 100644 --- a/cmuxTests/WorkspaceRemoteConnectionTests.swift +++ b/cmuxTests/WorkspaceRemoteConnectionTests.swift @@ -454,6 +454,64 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { XCTAssertEqual(try String(contentsOf: killLog, encoding: .utf8), "") } + func testRemoteStaleRelayListenerCleanupScriptMatchesPersistentSlotExactly() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory.appendingPathComponent("cmux-stale-relay-slot-prefix-\(UUID().uuidString)") + let bin = root.appendingPathComponent("bin") + let killLog = root.appendingPathComponent("kill.log") + try fileManager.createDirectory(at: bin, withIntermediateDirectories: true) + try "".write(to: killLog, atomically: true, encoding: .utf8) + defer { try? fileManager.removeItem(at: root) } + + try writeExecutableShellFile( + at: bin.appendingPathComponent("lsof"), + body: """ + #!/bin/sh + cat <<'EOF' + p33681 + f12 + n127.0.0.1:50446 + EOF + """ + ) + try writeExecutableShellFile( + at: bin.appendingPathComponent("ps"), + body: """ + #!/bin/sh + cat <<'EOF' + 33681 1 /usr/sbin/sshd-session + 34057 33681 /Users/cmux/.cmux/bin/cmuxd-remote/current/darwin-arm64/cmuxd-remote serve --stdio --persistent --slot ssh-ab + EOF + """ + ) + + let script = try XCTUnwrap( + WorkspaceRemoteSessionController.remoteStaleRelayListenerCleanupScript( + relayPort: 50446, + persistentDaemonSlot: "ssh-a" + ) + ) + let result = runProcess( + executablePath: "/usr/bin/env", + arguments: [ + "PATH=\(bin.path):/usr/bin:/bin", + "CMUX_KILL_LOG=\(killLog.path)", + "/bin/sh", + "-c", + """ + kill() { printf '%s\\n' "$*" >> "$CMUX_KILL_LOG"; return 0; } + \(script) + """, + ], + timeout: 5 + ) + + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + XCTAssertEqual(result.stdout, "") + XCTAssertEqual(try String(contentsOf: killLog, encoding: .utf8), "") + } + func testRemoteStaleRelayListenerCleanupScriptKillsMetadataMatchedListenerWithoutChild() throws { let fileManager = FileManager.default let root = fileManager.temporaryDirectory.appendingPathComponent("cmux-stale-relay-metadata-\(UUID().uuidString)") From 3a1cba31c5c12b35d605bcc3ce17e156764720cc Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 30 May 2026 16:56:49 -0700 Subject: [PATCH 66/69] fix: preserve local cwd on remote restore --- Sources/Workspace.swift | 12 ++-- .../TabManagerSessionSnapshotTests.swift | 59 +++++++++++++++++++ 2 files changed, 66 insertions(+), 5 deletions(-) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 817d0355eec5..5a9920a2f00e 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -1617,11 +1617,12 @@ extension Workspace { } } let effectiveResumeBinding = restoredBindingLaunch == nil ? nil : resumeBinding - let workingDirectory = + let savedWorkingDirectory = effectiveResumeBinding?.cwd ?? snapshot.terminal?.workingDirectory ?? restorableAgent?.workingDirectory ?? snapshot.directory + let workingDirectory = savedWorkingDirectory ?? currentDirectory let restorableTmuxStartCommand = restorableAgent == nil && restoredBindingLaunch == nil ? Self.restorableTmuxStartCommand(snapshot.terminal?.tmuxStartCommand) @@ -1683,17 +1684,18 @@ extension Workspace { (restoredBindingLaunch != nil && resumeBinding?.isAgentHookBinding == true) // Guarded startup commands cd themselves and tolerate deleted saved directories. // Passing the same cwd to Ghostty can fail before the guarded command runs. - let restoresRemoteWorkspaceTerminalSnapshot = - remoteConfiguration != nil && snapshot.terminal?.isRemoteTerminal != nil let suppressWorkspaceRemoteStartupCommand = remoteConfiguration != nil && snapshot.terminal?.isRemoteTerminal == false && restoredRemotePTYAttachCommand == nil - let localWorkingDirectory = remoteStartupCommand == nil && + let effectiveRemoteStartupCommand = suppressWorkspaceRemoteStartupCommand ? nil : remoteStartupCommand + let restoresRemoteWorkspaceTerminalSnapshot = + remoteConfiguration != nil && snapshot.terminal?.isRemoteTerminal == true + let localWorkingDirectory = effectiveRemoteStartupCommand == nil && restoredRemotePTYAttachCommand == nil && !restoresRemoteWorkspaceTerminalSnapshot && !startupHandlesWorkingDirectory - ? workingDirectory + ? (suppressWorkspaceRemoteStartupCommand ? savedWorkingDirectory : workingDirectory) : nil let restoredAgentWillRunStartupCommand = restorableAgent != nil && ( restoredAgentResumeLaunch?.initialCommand != nil || diff --git a/cmuxTests/TabManagerSessionSnapshotTests.swift b/cmuxTests/TabManagerSessionSnapshotTests.swift index 27a7f59c7663..af247d86e94e 100644 --- a/cmuxTests/TabManagerSessionSnapshotTests.swift +++ b/cmuxTests/TabManagerSessionSnapshotTests.swift @@ -2777,6 +2777,65 @@ final class TabManagerSessionSnapshotTests: XCTestCase { ) } + func testPersistentSSHPTYRestorePreservesLocalTerminalWorkingDirectory() throws { + 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 { TerminalController.shared.stop() defer { TerminalController.shared.stop() } From 9e01e2eac05d84aa63363ac64d01e43ad22ef15d Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 30 May 2026 18:09:49 -0700 Subject: [PATCH 67/69] fix: reconnect restored remote browser workspaces --- Sources/Workspace.swift | 6 +++++- cmuxTests/TabManagerSessionSnapshotTests.swift | 16 ++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 73ff7f3894f4..b94d61f985bc 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -886,7 +886,11 @@ extension Workspace { let normalizedForegroundAuthToken = foregroundAuthToken? .trimmingCharacters(in: .whitespacesAndNewlines) guard normalizedForegroundAuthToken?.isEmpty == false else { return true } - return !snapshot.panels.contains { $0.terminal != nil } + let hasTerminalThatWillAuthenticateReconnect = snapshot.panels.contains { + guard let terminal = $0.terminal else { return false } + return terminal.isRemoteTerminal != false + } + return !hasTerminalThatWillAuthenticateReconnect } nonisolated enum SurfaceResumeStartupLaunch { diff --git a/cmuxTests/TabManagerSessionSnapshotTests.swift b/cmuxTests/TabManagerSessionSnapshotTests.swift index af247d86e94e..06a2e6b6ef9f 100644 --- a/cmuxTests/TabManagerSessionSnapshotTests.swift +++ b/cmuxTests/TabManagerSessionSnapshotTests.swift @@ -1754,6 +1754,22 @@ final class TabManagerSessionSnapshotTests: XCTestCase { snapshot: terminalSnapshot, isRunningUnderAutomatedTests: false )) + + let localTerminalPanelId = UUID() + var localTerminal = Self.terminalPanelSnapshot(id: localTerminalPanelId) + localTerminal.terminal?.isRemoteTerminal = false + var browserAndLocalTerminalSnapshot = browserOnlySnapshot + browserAndLocalTerminalSnapshot.panels.append(localTerminal) + if case .pane(var pane) = browserAndLocalTerminalSnapshot.layout { + pane.panelIds.append(localTerminalPanelId) + browserAndLocalTerminalSnapshot.layout = .pane(pane) + } + XCTAssertTrue(Workspace.shouldAutoConnectRestoredRemote( + foregroundAuthToken: "token-a", + snapshot: browserAndLocalTerminalSnapshot, + isRunningUnderAutomatedTests: false + )) + XCTAssertTrue(Workspace.shouldAutoConnectRestoredRemote( foregroundAuthToken: nil, snapshot: terminalSnapshot, From 84ef321033961738582b1212d4e205445aca1c7e Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 30 May 2026 18:14:19 -0700 Subject: [PATCH 68/69] test: preserve daemon events during rpc calls --- daemon/remote/cmd/cmuxd-remote/main_test.go | 43 +++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/daemon/remote/cmd/cmuxd-remote/main_test.go b/daemon/remote/cmd/cmuxd-remote/main_test.go index f40e8880ac1b..0f4abe94aac6 100644 --- a/daemon/remote/cmd/cmuxd-remote/main_test.go +++ b/daemon/remote/cmd/cmuxd-remote/main_test.go @@ -28,6 +28,13 @@ type notifyingBuffer struct { notify chan struct{} } +type persistentTestFrameQueue struct { + mu sync.Mutex + frames []map[string]any +} + +var persistentTestPendingFrames sync.Map + func newNotifyingBuffer() *notifyingBuffer { return ¬ifyingBuffer{notify: make(chan struct{}, 1)} } @@ -114,6 +121,7 @@ func persistentTestRPCCall(t *testing.T, conn net.Conn, reader *bufio.Reader, wr for { frame := readPersistentTestFrame(t, conn, reader) if _, isEvent := frame["event"]; isEvent { + enqueuePersistentTestFrame(conn, frame) continue } return frame @@ -125,6 +133,13 @@ func readPersistentTestEvent(t *testing.T, conn net.Conn, reader *bufio.Reader, deadline := time.Now().Add(5 * time.Second) var last map[string]any for time.Now().Before(deadline) { + if frame, ok := dequeuePersistentTestFrame(conn); ok { + last = frame + if _, isEvent := frame["event"]; isEvent && matches(frame) { + return frame + } + continue + } frame := readPersistentTestFrame(t, conn, reader) last = frame if _, isEvent := frame["event"]; isEvent && matches(frame) { @@ -135,6 +150,34 @@ func readPersistentTestEvent(t *testing.T, conn net.Conn, reader *bufio.Reader, return nil } +func enqueuePersistentTestFrame(conn net.Conn, frame map[string]any) { + queue := persistentTestQueue(conn) + queue.mu.Lock() + queue.frames = append(queue.frames, frame) + queue.mu.Unlock() +} + +func dequeuePersistentTestFrame(conn net.Conn) (map[string]any, bool) { + queue := persistentTestQueue(conn) + queue.mu.Lock() + defer queue.mu.Unlock() + if len(queue.frames) == 0 { + return nil, false + } + frame := queue.frames[0] + queue.frames = queue.frames[1:] + return frame, true +} + +func persistentTestQueue(conn net.Conn) *persistentTestFrameQueue { + if queue, ok := persistentTestPendingFrames.Load(conn); ok { + return queue.(*persistentTestFrameQueue) + } + queue := &persistentTestFrameQueue{} + actual, _ := persistentTestPendingFrames.LoadOrStore(conn, queue) + return actual.(*persistentTestFrameQueue) +} + func writePersistentTestFrame(t *testing.T, writer *bufio.Writer, payload any) { t.Helper() data, err := json.Marshal(payload) From 603bb76f9948c8ce7bd887ef08aa9d5b5b5ba2b7 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 30 May 2026 18:50:05 -0700 Subject: [PATCH 69/69] fix: wait for restored pty foreground auth --- Sources/Workspace.swift | 7 ++++++- cmuxTests/TabManagerSessionSnapshotTests.swift | 16 ++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index b94d61f985bc..855b3a3b942f 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -888,7 +888,12 @@ extension Workspace { guard normalizedForegroundAuthToken?.isEmpty == false else { return true } let hasTerminalThatWillAuthenticateReconnect = snapshot.panels.contains { guard let terminal = $0.terminal else { return false } - return terminal.isRemoteTerminal != false + if terminal.isRemoteTerminal != false { + return true + } + let remotePTYSessionID = terminal.remotePTYSessionID? + .trimmingCharacters(in: .whitespacesAndNewlines) + return remotePTYSessionID?.isEmpty == false } return !hasTerminalThatWillAuthenticateReconnect } diff --git a/cmuxTests/TabManagerSessionSnapshotTests.swift b/cmuxTests/TabManagerSessionSnapshotTests.swift index 06a2e6b6ef9f..0105cc529edf 100644 --- a/cmuxTests/TabManagerSessionSnapshotTests.swift +++ b/cmuxTests/TabManagerSessionSnapshotTests.swift @@ -1770,6 +1770,22 @@ final class TabManagerSessionSnapshotTests: XCTestCase { isRunningUnderAutomatedTests: false )) + let restoredAttachPanelId = UUID() + var restoredAttachTerminal = Self.terminalPanelSnapshot(id: restoredAttachPanelId) + restoredAttachTerminal.terminal?.isRemoteTerminal = false + restoredAttachTerminal.terminal?.remotePTYSessionID = " ssh-restored-session " + var browserAndRestoredAttachSnapshot = browserOnlySnapshot + browserAndRestoredAttachSnapshot.panels.append(restoredAttachTerminal) + if case .pane(var pane) = browserAndRestoredAttachSnapshot.layout { + pane.panelIds.append(restoredAttachPanelId) + browserAndRestoredAttachSnapshot.layout = .pane(pane) + } + XCTAssertFalse(Workspace.shouldAutoConnectRestoredRemote( + foregroundAuthToken: "token-a", + snapshot: browserAndRestoredAttachSnapshot, + isRunningUnderAutomatedTests: false + )) + XCTAssertTrue(Workspace.shouldAutoConnectRestoredRemote( foregroundAuthToken: nil, snapshot: terminalSnapshot,