diff --git a/CLI/CMUXCLI+SSHTui.swift b/CLI/CMUXCLI+SSHTui.swift index e2e2f178a2ce..e6eefeac7037 100644 --- a/CLI/CMUXCLI+SSHTui.swift +++ b/CLI/CMUXCLI+SSHTui.swift @@ -8,11 +8,14 @@ extension CMUXCLI { configuredRemoteCommand: String?, client: SocketClient, jsonOutput: Bool, - idFormat: CLIIDFormat + idFormat: CLIIDFormat, + routeIdentifier: String? = nil ) throws { var params: [String: Any] = [ "destination": options.destination, - "ssh_options": options.sshOptions, + "ssh_options": SSHConnectionSharingOptions().optionsForTUIHandoff( + options.sshOptions, routeIdentifier: routeIdentifier + ), "focus": !options.noFocus, "operation_id": UUID().uuidString.lowercased(), ] diff --git a/CLI/cmux.swift b/CLI/cmux.swift index ec76f8af49b4..6762b67edc3e 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -12329,6 +12329,9 @@ struct CMUXCLI { "fallback=\(fallsBackToOpenSSHInteractiveSession ? "unmanaged" : "managed")" ) } + let resolvedRouteIdentifier = resolvedUserSSHConfiguration.flatMap { + sharingOptions.routeIdentifier(fromSSHConfigOutput: $0) + } sshOptions.sshOptions = sharingOptions.mergingDefaults( into: inputSSHOptions.sshOptions, userConfiguredControlOptions: resolvedUserSSHConfiguration.flatMap { @@ -12341,9 +12344,7 @@ struct CMUXCLI { routeSensitiveOptions: inputSSHOptions.identityFile.map { ["IdentityFile=\($0)"] } ?? [], // `%C` ignores proxy, identity and host-key options; a route that // sets them gets a master keyed by its whole resolved route. - routeIdentifier: resolvedUserSSHConfiguration.flatMap { - sharingOptions.routeIdentifier(fromSSHConfigOutput: $0) - } + routeIdentifier: resolvedRouteIdentifier ) if resolvedUserSSHConfiguration != nil { sshOptions.sshOptions = resolvedCmuxControlPathOptions(for: sshOptions) @@ -12361,7 +12362,8 @@ struct CMUXCLI { !sshOptions.remoteCommand.disablesTTY(in: sshOptions.sshOptions, hostRequestTTY: resolvedUserSSHConfiguration.flatMap { sshConfigurationValue(named: "requesttty", in: $0) }) { try runSSHTui(options: sshOptions, configuredRemoteCommand: configuredInteractiveRemoteCommand, - client: client, jsonOutput: jsonOutput, idFormat: idFormat) + client: client, jsonOutput: jsonOutput, idFormat: idFormat, + routeIdentifier: resolvedRouteIdentifier) return } let sshStartedAt = Date() diff --git a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Link/CloudMachineLink.swift b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Link/CloudMachineLink.swift index 16b4dd417ae5..8284045f7979 100644 --- a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Link/CloudMachineLink.swift +++ b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Link/CloudMachineLink.swift @@ -222,9 +222,9 @@ public actor CloudMachineLink { session: route.hasPrefix("ssh://") ? session : nil, sshArguments: sshArguments ) - var environment = ProcessInfo.processInfo.environment + var environment = ssh?.sshProcessEnvironment + ?? ProcessInfo.processInfo.environment environment["CMUX_REMOTE_STATE_DIR"] = paths.stateDir.path - if let ssh { environment = environment.merging((ssh.configuration.sshProcessEnvironment ?? [:])) { _, new in new } } process.environment = environment let stdout = Pipe() let stderr = Pipe() diff --git a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Link/SSHTuiConnection.swift b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Link/SSHTuiConnection.swift index 80f34639e05c..b376c0ca7e36 100644 --- a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Link/SSHTuiConnection.swift +++ b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Link/SSHTuiConnection.swift @@ -7,13 +7,28 @@ import Foundation /// Stable SSH identity and launch configuration for a cmux-tui session. public struct SSHTuiConnection: Sendable { + /// Creates an SSH connection description with a stable launch environment. + /// + /// - Parameters: + /// - configuration: The persisted SSH workspace configuration. + /// - environment: The local environment inherited by SSH children. public init( - configuration: WorkspaceRemoteConfiguration + configuration: WorkspaceRemoteConfiguration, + environment: [String: String] = ProcessInfo.processInfo.environment ) { self.configuration = configuration + inheritedEnvironment = environment } public let configuration: WorkspaceRemoteConfiguration + /// The complete local environment captured for this connection's children. + /// Capturing it once keeps route identity and every SSH launch consistent. + private let inheritedEnvironment: [String: String] + + /// The local environment used by the carrier, browser proxy and SSH helpers. + public var sshProcessEnvironment: [String: String] { + configuration.sshProcessEnvironment(inheriting: inheritedEnvironment) + } /// Coding agents whose cmux-tui hooks the host installs on each attach, so /// their state reaches the sidebar. Not part of the link identity. @@ -36,15 +51,29 @@ public struct SSHTuiConnection: Sendable { digest(includeAgentSocket: true) } + /// Hashes the route inputs that must share, or avoid sharing, an SSH master. private func digest(includeAgentSocket: Bool) -> String { let resolver = SSHAgentSocketResolver(environment: [:]) + let sharingOptions = SSHConnectionSharingOptions() let persistentOptions = configuration.sshOptions.filter { - !["controlmaster", "controlpersist", "controlpath"].contains(resolver.optionKey($0) ?? "") + let key = resolver.optionKey($0) ?? "" + return !["controlmaster", "controlpersist", "controlpath"].contains(key) + && sharingOptions.routeSensitiveIdentifier(in: [$0]) == nil } var components = [configuration.destination, configuration.port.map(String.init) ?? "", configuration.identityFile ?? ""] + persistentOptions + if let routeIdentifier = sharingOptions.routeSensitiveIdentifier(in: configuration.sshOptions) { + components.append(routeIdentifier) + } if includeAgentSocket { - components.append(configuration.agentSocketPath?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "") + if let agent = sshProcessEnvironment["SSH_AUTH_SOCK"]?.trimmingCharacters(in: .whitespacesAndNewlines), + !agent.isEmpty { + components.append(agent) + } else if configuration.agentSocketPathOverrideIsSet { + components.append("") + } else { + components.append("") + } } return SHA256.hash(data: Data(components.joined(separator: "\0").utf8)) .map { String(format: "%02x", $0) }.joined() @@ -76,12 +105,52 @@ public struct SSHTuiConnection: Sendable { /// cmux-owned ControlPath an open used, so these defaults restore the rest; /// without a shared master, batch mode can't log in on a password-only host. private var sshOptions: [String] { + let sharing = SSHConnectionSharingOptions() + let resolver = SSHAgentSocketResolver(environment: [:]) + let effectiveAgent = sshProcessEnvironment["SSH_AUTH_SOCK"]? + .trimmingCharacters(in: .whitespacesAndNewlines) + let agentRouteIsSensitive = configuration.agentSocketPathOverrideIsSet + || (effectiveAgent?.isEmpty == false) + var options = configuration.sshOptions + if agentRouteIsSensitive, + let suppliedControlPath = resolver.optionValue(named: "ControlPath", in: options), + suppliedControlPath.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() != "none" { + if sharing.cmuxOwnedControlPath(in: options) != nil { + // A restored cmux carrier owns the exact authenticated master + // recorded in its snapshot. Keep that socket even for legacy + // snapshots that predate the opaque route marker; dropping it + // can make a password-only host unreachable in batch mode. + let restoresAuthenticatedCmuxCarrier = + configuration.restoredSSHSession?.sshSessionOwner == "cmux-tui" + if !restoresAuthenticatedCmuxCarrier { + // New launches recompute a cmux-owned path from the + // captured agent route so different credentials cannot + // share a master. + options.removeAll { resolver.optionKey($0) == "controlpath" } + } + } else { + // A caller-owned path cannot encode the agent route. Never let + // it connect a master opened with a different credential. + options = options.filter { resolver.optionKey($0) != "controlpath" } + options.append("ControlPath=none") + } + } var routeSensitiveOptions = configuration.identityFile.map { ["IdentityFile=\($0)"] } ?? [] if let agent = configuration.agentSocketPath?.trimmingCharacters(in: .whitespacesAndNewlines), !agent.isEmpty { routeSensitiveOptions.append("IdentityAgent=\(agent)") + } else if configuration.agentSocketPathOverrideIsSet { + // Keep an explicitly disabled agent from sharing a master opened + // with the caller's inherited agent, and make the route identity + // reflect that OpenSSH behavior. + routeSensitiveOptions.append("IdentityAgent=none") + } + if agentRouteIsSensitive, configuration.agentSocketPathOverrideIsSet == false { + // An inherited socket is not an SSH option, but it is still part + // of the credentials carried by a multiplexed master. + routeSensitiveOptions.append("inherited-agent-route") } - return SSHConnectionSharingOptions().mergingDefaults( - into: configuration.sshOptions, + return sharing.mergingDefaults( + into: options, routeSensitiveOptions: routeSensitiveOptions, routeIdentifier: routeIdentityDigest ) diff --git a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Link/SSHTuiPreflight.swift b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Link/SSHTuiPreflight.swift index b64d4e437083..a9c24dfebd2f 100644 --- a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Link/SSHTuiPreflight.swift +++ b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Link/SSHTuiPreflight.swift @@ -8,9 +8,16 @@ import Foundation /// `ssh … true` returns OpenSSH's diagnostic in seconds instead. With connection /// sharing configured it also opens the master the carrier then multiplexes over. public struct SSHTuiPreflight: Sendable { - public init(connection: SSHTuiConnection, commands: any CommandRunning = CommandRunner(), timeout: TimeInterval = 30) { + /// Creates a prompt-free SSH route check. + /// + /// - Parameters: + /// - connection: The SSH route and captured child environment to check. + /// - commands: An injected command runner for tests, or `nil` to run with + /// the connection's captured environment. + /// - timeout: The maximum time allowed for the route check. + public init(connection: SSHTuiConnection, commands: (any CommandRunning)? = nil, timeout: TimeInterval = 30) { self.connection = connection - self.commands = commands + self.commands = commands ?? CommandRunner() self.timeout = timeout } @@ -18,6 +25,7 @@ public struct SSHTuiPreflight: Sendable { private let commands: any CommandRunning private let timeout: TimeInterval + /// Runs the prompt-free route check and reports OpenSSH's typed outcome. public func run() async throws { var arguments = connection.preflightArguments var executable = arguments.removeFirst() @@ -26,6 +34,15 @@ public struct SSHTuiPreflight: Sendable { if let agent = connection.configuration.agentSocketPath { arguments = ["SSH_AUTH_SOCK=" + agent, executable] + arguments executable = "/usr/bin/env" + } else if let agent = connection.sshProcessEnvironment["SSH_AUTH_SOCK"], + !agent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + // Pin an inherited agent captured with the route so preflight and + // the carrier cannot select different credentials. + arguments = ["SSH_AUTH_SOCK=" + agent, executable] + arguments + executable = "/usr/bin/env" + } else if connection.configuration.agentSocketPathOverrideIsSet { + arguments = ["-u", "SSH_AUTH_SOCK", executable] + arguments + executable = "/usr/bin/env" } let result = await commands.run( directory: NSHomeDirectory(), executable: executable, arguments: arguments, timeout: timeout diff --git a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/PortForward/CloudBrowserRouting.swift b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/PortForward/CloudBrowserRouting.swift index e669739147db..a70371a38d2c 100644 --- a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/PortForward/CloudBrowserRouting.swift +++ b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/PortForward/CloudBrowserRouting.swift @@ -68,10 +68,16 @@ public struct CloudBrowserRouting: Sendable { group.addTask { try await probe() } group.addTask { try await clock.sleep(for: timeout) - connection.cancel() return nil } - defer { group.cancelAll() } + // Let the timeout task publish `.unknown` before + // cancelling the probe. Cancelling the connection first + // can wake `responseStatus` with an empty response, which + // would otherwise race the timeout and look unreachable. + defer { + group.cancelAll() + connection.cancel() + } return try await group.next()! ?? .unknown } } onCancel: { diff --git a/Packages/macOS/CmuxCloud/Tests/CmuxCloudTests/SSHTuiAgentOptionsTests.swift b/Packages/macOS/CmuxCloud/Tests/CmuxCloudTests/SSHTuiAgentOptionsTests.swift new file mode 100644 index 000000000000..f6cecf3013de --- /dev/null +++ b/Packages/macOS/CmuxCloud/Tests/CmuxCloudTests/SSHTuiAgentOptionsTests.swift @@ -0,0 +1,127 @@ +import CmuxCloud +import CmuxCore +import CmuxFoundation +import Foundation +import Testing + +@Suite("SSH carrier agent option precedence") +struct SSHTuiAgentOptionsTests { + @Test("Auth, preflight, carrier and browser share the marked agent route") + func markedRouteUsesOneControlPathAcrossLaunches() throws { + let routeIdentifier = String(repeating: "d", count: 64) + let connection = connection( + agent: "/tmp/cmux-route-agent.sock", + options: ["ProxyCommand=/bin/sh -c true", "__cmux_route_sensitive=\(routeIdentifier)"], + environment: ["PATH": "/usr/bin", "SSH_AUTH_SOCK": "/tmp/cmux-route-agent.sock"] + ) + let paths = sshInvocations(connection).compactMap { arguments in + arguments.first { $0.hasPrefix("ControlPath=") } + } + #expect(paths.count == 4) + #expect(Set(paths).count == 1) + } + + @Test("A restored cmux carrier keeps its saved route socket with an agent") + func restoredCmuxCarrierKeepsSavedControlPath() throws { + let socketDirectory = try #require(SSHConnectionSharingOptions().controlSocketDirectoryPath) + let savedPath = socketDirectory + "/" + String(repeating: "b", count: 40) + var snapshot = SessionRemoteWorkspaceSnapshot( + transport: .ssh, + destination: "example.invalid", + sshOptions: ["ControlPath=\(savedPath)"], + agentSocketPath: "/tmp/cmux-saved-agent.sock", + preserveAfterTerminalExit: true + ) + snapshot.sshSessionOwner = "cmux-tui" + var configuration = WorkspaceRemoteConfiguration( + destination: "example.invalid", port: nil, identityFile: nil, + sshOptions: ["ControlPath=\(savedPath)"], + localProxyPort: nil, relayPort: nil, relayID: nil, relayToken: nil, + localSocketPath: nil, terminalStartupCommand: nil, + agentSocketPath: "/tmp/cmux-saved-agent.sock", + preserveAfterTerminalExit: true + ) + configuration.restoredSSHSession = snapshot + + let connection = SSHTuiConnection( + configuration: configuration, + environment: ["PATH": "/usr/bin", "SSH_AUTH_SOCK": "/tmp/cmux-saved-agent.sock"] + ) + #expect(connection.authenticationArguments.contains("ControlPath=\(savedPath)")) + } + + @Test("A forwarded socket preserves the user's IdentityAgent", arguments: [false, true]) + func forwardedSocketPreservesIdentityAgent(explicitOption: Bool) throws { + let connection = connection( + agent: "/tmp/cmux-caller-environment.sock", + options: explicitOption ? ["IdentityAgent=/tmp/cmux-option-agent.sock"] : [] + ) + let expected = explicitOption ? "/tmp/cmux-option-agent.sock" : "/tmp/cmux-config-agent.sock" + for arguments in sshInvocations(connection) { + #expect(try resolvedIdentityAgent(arguments) == expected) + } + } + + @Test("Explicit disable preserves IdentityAgent in options and SSH config", arguments: [false, true]) + func disabledSocketPreservesIdentityAgent(explicitOption: Bool) throws { + let connection = connection( + agent: "", + options: explicitOption ? ["IdentityAgent=/tmp/cmux-option-agent.sock"] : [] + ) + for arguments in sshInvocations(connection) { + let expected = explicitOption ? "/tmp/cmux-option-agent.sock" : "/tmp/cmux-config-agent.sock" + #expect(try resolvedIdentityAgent(arguments) == expected) + } + } + + /// Builds the same socket override the CLI sends to the app. + private func connection( + agent: String, + options: [String], + environment: [String: String] = [:] + ) -> SSHTuiConnection { + SSHTuiConnection(configuration: WorkspaceRemoteConfiguration( + destination: "example.invalid", port: nil, identityFile: nil, sshOptions: options, + localProxyPort: nil, relayPort: nil, relayID: nil, relayToken: nil, localSocketPath: nil, + terminalStartupCommand: nil, agentSocketPath: agent + ), environment: environment) + } + + /// Exercises interactive authentication, batch preflight, carrier and browser launches. + private func sshInvocations(_ connection: SSHTuiConnection) -> [[String]] { + let carriers = [ + connection.arguments(stateDirectory: "/tmp/cmux-test", deviceName: "test"), + connection.browserArguments(stateDirectory: "/tmp/cmux-test") + ].map { arguments in + arguments.indices.compactMap { index -> String? in + guard index > 0, arguments[index - 1] == "--ssh-arg" else { return nil } + return arguments[index] + } + [connection.configuration.destination] + } + return [Array(connection.authenticationArguments.dropFirst()), + Array(connection.preflightArguments.dropFirst())] + carriers + } + + /// Asks system OpenSSH to resolve our isolated configuration without opening a connection. + private func resolvedIdentityAgent(_ arguments: [String]) throws -> String? { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let config = directory.appendingPathComponent("config") + try "Host *\n IdentityAgent /tmp/cmux-config-agent.sock\n".write(to: config, atomically: true, encoding: .utf8) + let process = Process() + let output = Pipe() + process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh") + process.arguments = ["-G", "-F", config.path] + arguments + process.standardInput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + process.standardOutput = output + try process.run() + let data = output.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + try #require(process.terminationStatus == 0) + return String(decoding: data, as: UTF8.self).split(separator: "\n") + .first { $0.hasPrefix("identityagent ") } + .map { String($0.dropFirst("identityagent ".count)) } + } +} diff --git a/Packages/macOS/CmuxCore/Sources/CmuxCore/Remote/SessionRemoteWorkspaceSnapshot+SSHAgent.swift b/Packages/macOS/CmuxCore/Sources/CmuxCore/Remote/SessionRemoteWorkspaceSnapshot+SSHAgent.swift new file mode 100644 index 000000000000..49199aeb710e --- /dev/null +++ b/Packages/macOS/CmuxCore/Sources/CmuxCore/Remote/SessionRemoteWorkspaceSnapshot+SSHAgent.swift @@ -0,0 +1,26 @@ +import CmuxFoundation + +extension SessionRemoteWorkspaceSnapshot { + /// Selects a live saved agent, falling back to the current launch environment. + /// + /// An explicitly disabled saved agent never falls back to an inherited one. + /// The caller supplies liveness checks so this persistence policy performs no I/O. + /// + /// - Parameters: + /// - environment: The environment of the process restoring the workspace. + /// - isLiveAgent: Whether a normalized socket path accepts trusted agent connections. + /// - Returns: The first live socket in saved-then-inherited order, or `nil`. + public func restoredAgentSocketPath( + environment: [String: String], + isLiveAgent: (String) -> Bool + ) -> String? { + let resolver = SSHAgentSocketResolver(environment: [:]) + if agentSocketPathOverrideIsSet == true, resolver.normalizedAgentSocketPath(agentSocketPath) == nil { + return nil + } + return [agentSocketPath, environment["SSH_AUTH_SOCK"]] + .lazy + .compactMap { resolver.normalizedAgentSocketPath($0) } + .first(where: isLiveAgent) + } +} diff --git a/Packages/macOS/CmuxCore/Sources/CmuxCore/Remote/SessionRemoteWorkspaceSnapshot.swift b/Packages/macOS/CmuxCore/Sources/CmuxCore/Remote/SessionRemoteWorkspaceSnapshot.swift index 848303ef6e7e..2ea270cf4e37 100644 --- a/Packages/macOS/CmuxCore/Sources/CmuxCore/Remote/SessionRemoteWorkspaceSnapshot.swift +++ b/Packages/macOS/CmuxCore/Sources/CmuxCore/Remote/SessionRemoteWorkspaceSnapshot.swift @@ -29,6 +29,9 @@ public struct SessionRemoteWorkspaceSnapshot: Codable, Equatable, Sendable { /// shared SSH master by this agent, so a restore needs it to reach the /// master its open logged in; absent in snapshots written before it. public var agentSocketPath: String? = nil + /// Whether the saved agent socket was an explicit override, including an + /// explicit empty value that disables inherited agent forwarding. + public var agentSocketPathOverrideIsSet: Bool? = nil /// Whether remote PTY sessions outlive their local terminal surface. public var preserveAfterTerminalExit: Bool? /// Whether daemon bootstrap is skipped (pre-baked Cloud VM images). @@ -51,6 +54,7 @@ public struct SessionRemoteWorkspaceSnapshot: Codable, Equatable, Sendable { identityFile: String? = nil, sshOptions: [String] = [], agentSocketPath: String? = nil, + agentSocketPathOverrideIsSet: Bool? = nil, preserveAfterTerminalExit: Bool? = nil, skipDaemonBootstrap: Bool? = nil, relayPort: Int? = nil, @@ -66,6 +70,7 @@ public struct SessionRemoteWorkspaceSnapshot: Codable, Equatable, Sendable { self.identityFile = identityFile self.sshOptions = sshOptions self.agentSocketPath = agentSocketPath + self.agentSocketPathOverrideIsSet = agentSocketPathOverrideIsSet self.preserveAfterTerminalExit = preserveAfterTerminalExit self.skipDaemonBootstrap = skipDaemonBootstrap self.relayPort = relayPort diff --git a/Packages/macOS/CmuxCore/Sources/CmuxCore/Remote/WorkspaceRemoteConfiguration+SSHControlPath.swift b/Packages/macOS/CmuxCore/Sources/CmuxCore/Remote/WorkspaceRemoteConfiguration+SSHControlPath.swift index 38c93dcf86ad..51832e646559 100644 --- a/Packages/macOS/CmuxCore/Sources/CmuxCore/Remote/WorkspaceRemoteConfiguration+SSHControlPath.swift +++ b/Packages/macOS/CmuxCore/Sources/CmuxCore/Remote/WorkspaceRemoteConfiguration+SSHControlPath.swift @@ -32,6 +32,7 @@ extension WorkspaceRemoteConfiguration { terminalStartupCommand: terminalStartupCommand, foregroundAuthToken: foregroundAuthToken, agentSocketPath: agentSocketPath, + agentSocketPathOverrideIsSet: agentSocketPathOverrideIsSet, daemonWebSocketEndpoint: daemonWebSocketEndpoint, preserveAfterTerminalExit: preserveAfterTerminalExit, persistentDaemonSlot: persistentDaemonSlot, diff --git a/Packages/macOS/CmuxCore/Sources/CmuxCore/Remote/WorkspaceRemoteConfiguration.swift b/Packages/macOS/CmuxCore/Sources/CmuxCore/Remote/WorkspaceRemoteConfiguration.swift index 3247ac8c9895..73bb00b96551 100644 --- a/Packages/macOS/CmuxCore/Sources/CmuxCore/Remote/WorkspaceRemoteConfiguration.swift +++ b/Packages/macOS/CmuxCore/Sources/CmuxCore/Remote/WorkspaceRemoteConfiguration.swift @@ -48,6 +48,9 @@ public struct WorkspaceRemoteConfiguration: Equatable, Sendable { public let foregroundAuthToken: String? /// SSH agent socket injected as `SSH_AUTH_SOCK`, when usable. public let agentSocketPath: String? + /// Whether the agent socket was explicitly supplied, including an empty + /// value that disables inherited agent forwarding. + public let agentSocketPathOverrideIsSet: Bool /// Brokered WebSocket daemon endpoint for Cloud VM transports. public let daemonWebSocketEndpoint: WorkspaceRemoteWebSocketDaemonEndpoint? /// Whether remote PTY sessions outlive their local terminal surface. @@ -65,8 +68,11 @@ public struct WorkspaceRemoteConfiguration: Equatable, Sendable { /// identifies one broker lease, not a user-visible connection setting. public let sshControlMasterLeaseGeneration: UUID? - /// Creates a configuration, normalizing the agent socket path and allowing - /// persistent daemon state only for SSH-backed interactive terminals. + /// Creates a normalized configuration, allowing persistent daemon state + /// only for SSH-backed interactive terminals. + /// + /// - Parameter agentSocketPathOverrideIsSet: Preserves an explicit empty + /// agent value so children can remove an inherited `SSH_AUTH_SOCK`. public init( transport: WorkspaceRemoteTransport = .ssh, terminalTransport: WorkspaceRemoteTerminalTransport = .ssh, @@ -86,6 +92,7 @@ public struct WorkspaceRemoteConfiguration: Equatable, Sendable { configuredRemoteCommand: String? = nil, foregroundAuthToken: String? = nil, agentSocketPath: String? = nil, + agentSocketPathOverrideIsSet: Bool = false, daemonWebSocketEndpoint: WorkspaceRemoteWebSocketDaemonEndpoint? = nil, preserveAfterTerminalExit: Bool = false, persistentDaemonSlot: String? = nil, @@ -110,6 +117,7 @@ public struct WorkspaceRemoteConfiguration: Equatable, Sendable { self.configuredRemoteCommand = Self.normalizedOptionalValue(configuredRemoteCommand) self.foregroundAuthToken = foregroundAuthToken self.agentSocketPath = Self.normalizedAgentSocketPath(agentSocketPath) + self.agentSocketPathOverrideIsSet = agentSocketPathOverrideIsSet || agentSocketPath != nil self.daemonWebSocketEndpoint = daemonWebSocketEndpoint let preservesPersistentPTY = terminalTransport == .ssh && preserveAfterTerminalExit self.preserveAfterTerminalExit = preservesPersistentPTY @@ -120,6 +128,10 @@ public struct WorkspaceRemoteConfiguration: Equatable, Sendable { self.sshControlMasterLeaseGeneration = sshControlMasterLeaseGeneration } + /// Creates a configuration using the legacy initializer shape. + /// + /// - Parameter agentSocketPathOverrideIsSet: Preserves an explicit empty + /// agent value so children can remove an inherited `SSH_AUTH_SOCK`. public init( transport: WorkspaceRemoteTransport = .ssh, terminalTransport: WorkspaceRemoteTerminalTransport = .ssh, @@ -138,6 +150,7 @@ public struct WorkspaceRemoteConfiguration: Equatable, Sendable { configuredRemoteCommand: String? = nil, foregroundAuthToken: String? = nil, agentSocketPath: String? = nil, + agentSocketPathOverrideIsSet: Bool = false, daemonWebSocketEndpoint: WorkspaceRemoteWebSocketDaemonEndpoint? = nil, preserveAfterTerminalExit: Bool = false, persistentDaemonSlot: String? = nil, @@ -163,6 +176,7 @@ public struct WorkspaceRemoteConfiguration: Equatable, Sendable { configuredRemoteCommand: configuredRemoteCommand, foregroundAuthToken: foregroundAuthToken, agentSocketPath: agentSocketPath, + agentSocketPathOverrideIsSet: agentSocketPathOverrideIsSet, daemonWebSocketEndpoint: daemonWebSocketEndpoint, preserveAfterTerminalExit: preserveAfterTerminalExit, persistentDaemonSlot: persistentDaemonSlot, @@ -192,6 +206,7 @@ public struct WorkspaceRemoteConfiguration: Equatable, Sendable { lhs.terminalStartupCommand == rhs.terminalStartupCommand && lhs.foregroundAuthToken == rhs.foregroundAuthToken && lhs.agentSocketPath == rhs.agentSocketPath && + lhs.agentSocketPathOverrideIsSet == rhs.agentSocketPathOverrideIsSet && lhs.daemonWebSocketEndpoint == rhs.daemonWebSocketEndpoint && lhs.preserveAfterTerminalExit == rhs.preserveAfterTerminalExit && lhs.persistentDaemonSlot == rhs.persistentDaemonSlot && @@ -199,14 +214,33 @@ public struct WorkspaceRemoteConfiguration: Equatable, Sendable { } /// Resolves the SSH agent socket to use for a remote configuration from an explicit socket or durable options. + /// + /// - Parameters: + /// - sshOptions: Durable SSH options that may contain a `ForwardAgent` socket. + /// - explicitAgentSocketPath: A caller-selected socket path, when one exists. + /// - explicitAgentSocketPathIsSet: Whether the caller explicitly supplied an + /// agent value, including an empty value that disables inheritance. + /// - explicitAgentSocketPathAlreadyValidated: Whether the explicit path was + /// selected by a caller-owned liveness check. Restore uses this after + /// applying its injected liveness seam; ordinary control requests leave it + /// false so paths still require a live socket on disk. public static func resolvedAgentSocketPath( sshOptions: [String], explicitAgentSocketPath: String? = nil, - explicitAgentSocketPathIsSet: Bool = false + explicitAgentSocketPathIsSet: Bool = false, + explicitAgentSocketPathAlreadyValidated: Bool = false ) -> String? { if explicitAgentSocketPathIsSet { + if explicitAgentSocketPathAlreadyValidated { + return SSHAgentSocketResolver().normalizedAgentSocketPath(explicitAgentSocketPath) + } return existingAgentSocketPath(explicitAgentSocketPath) } + if explicitAgentSocketPathAlreadyValidated, + let normalizedExplicitAgentSocketPath = SSHAgentSocketResolver() + .normalizedAgentSocketPath(explicitAgentSocketPath) { + return normalizedExplicitAgentSocketPath + } return existingAgentSocketPath(explicitAgentSocketPath) ?? existingAgentSocketPath(sshAgentSocketPath(for: sshOptions)) } @@ -387,6 +421,7 @@ public struct WorkspaceRemoteConfiguration: Equatable, Sendable { configuredRemoteCommand: configuredRemoteCommand, foregroundAuthToken: foregroundAuthToken, agentSocketPath: agentSocketPath, + agentSocketPathOverrideIsSet: agentSocketPathOverrideIsSet, daemonWebSocketEndpoint: daemonWebSocketEndpoint, preserveAfterTerminalExit: preserveAfterTerminalExit, persistentDaemonSlot: persistentDaemonSlot, @@ -422,6 +457,7 @@ public struct WorkspaceRemoteConfiguration: Equatable, Sendable { configuredRemoteCommand: configuredRemoteCommand, foregroundAuthToken: foregroundAuthToken, agentSocketPath: agentSocketPath, + agentSocketPathOverrideIsSet: agentSocketPathOverrideIsSet, daemonWebSocketEndpoint: endpoint, preserveAfterTerminalExit: preserveAfterTerminalExit, persistentDaemonSlot: persistentDaemonSlot, @@ -452,6 +488,7 @@ public struct WorkspaceRemoteConfiguration: Equatable, Sendable { configuredRemoteCommand: configuredRemoteCommand, foregroundAuthToken: foregroundAuthToken, agentSocketPath: agentSocketPath, + agentSocketPathOverrideIsSet: agentSocketPathOverrideIsSet, daemonWebSocketEndpoint: daemonWebSocketEndpoint, preserveAfterTerminalExit: preserveAfterTerminalExit, persistentDaemonSlot: persistentDaemonSlot, @@ -473,14 +510,31 @@ extension WorkspaceRemoteConfiguration { return ["SSH_AUTH_SOCK": agentSocketPath] } - /// Full process environment for spawned ssh/scp processes with - /// `SSH_AUTH_SOCK` overridden, or `nil` when no agent socket is configured. + /// Full local environment for SSH/SCP children, with an optional agent override. + /// + /// Assigning `nil` to Foundation's `Process.environment` clears the child + /// environment on macOS, unlike leaving the property unset. Always supply + /// the inherited environment so local `ProxyCommand` helpers can find the + /// user's home directory, credentials, and executables. An explicitly + /// disabled override removes that inherited key; an absent override leaves + /// it intact. public var sshProcessEnvironment: [String: String]? { - guard let agentSocketPath = self.agentSocketPath else { - return nil + sshProcessEnvironment(inheriting: ProcessInfo.processInfo.environment) + } + + /// Applies the agent override to a complete local child environment. + /// + /// - Parameter inherited: The environment captured from the launching app. + /// - Returns: The inherited environment with the configured agent override applied. + public func sshProcessEnvironment(inheriting inherited: [String: String]) -> [String: String] { + var environment = inherited + if let agentSocketPath { + environment["SSH_AUTH_SOCK"] = agentSocketPath + } else if agentSocketPathOverrideIsSet { + // Keep ForwardAgent=no and an explicit empty socket from falling + // back to the app's own agent socket. + environment.removeValue(forKey: "SSH_AUTH_SOCK") } - var environment = ProcessInfo.processInfo.environment - environment["SSH_AUTH_SOCK"] = agentSocketPath return environment } @@ -527,6 +581,7 @@ extension WorkspaceRemoteConfiguration { identityFile: Self.normalizedIdentityPath(identityFile), sshOptions: sshOptionsOverride ?? Self.durableSSHOptions(sshOptions), agentSocketPath: agentSocketPath, + agentSocketPathOverrideIsSet: agentSocketPathOverrideIsSet ? true : nil, preserveAfterTerminalExit: preserveAfterTerminalExit ? true : nil, skipDaemonBootstrap: skipDaemonBootstrap, relayPort: retainsRelayNamespace ? relayPort : nil, diff --git a/Packages/macOS/CmuxCore/Tests/CmuxCoreTests/SSHProxyCommandEnvironmentTests.swift b/Packages/macOS/CmuxCore/Tests/CmuxCoreTests/SSHProxyCommandEnvironmentTests.swift new file mode 100644 index 000000000000..cfcf876135f5 --- /dev/null +++ b/Packages/macOS/CmuxCore/Tests/CmuxCoreTests/SSHProxyCommandEnvironmentTests.swift @@ -0,0 +1,124 @@ +import Foundation +import Testing +@testable import CmuxCore + +@Suite("SSH ProxyCommand local environment") +struct SSHProxyCommandEnvironmentTests { + @Test( + "SSH child environment preserves the app environment and overlays only its agent socket", + arguments: [nil, "", "/tmp/cmux-test-ssh-agent.sock"] as [String?] + ) + /// Verifies that an absent override inherits all caller variables while a + /// usable override replaces only `SSH_AUTH_SOCK`. + func preservesInheritedEnvironment(agentSocketPath: String?) { + var expected = ProcessInfo.processInfo.environment + if let agentSocketPath { + expected["SSH_AUTH_SOCK"] = agentSocketPath.isEmpty ? nil : agentSocketPath + } + // Report only equality, never dump inherited credentials on failure. + let preservesEnvironment = configuration(agentSocketPath: agentSocketPath).sshProcessEnvironment == expected + #expect(preservesEnvironment) + } + + @Test("SSH child environment removes an explicitly disabled agent socket") + /// Verifies that an explicit empty agent override suppresses the inherited + /// socket without exposing its value in test output. + func removesExplicitlyDisabledAgentSocket() { + var expected = ProcessInfo.processInfo.environment + expected.removeValue(forKey: "SSH_AUTH_SOCK") + let matches = configuration(agentSocketPath: "").sshProcessEnvironment == expected + #expect(matches) + } + + @Test( + "A real OpenSSH ProxyCommand inherits the local user context", + arguments: [nil, "", "/tmp/cmux-test-ssh-agent.sock"] as [String?] + ) + /// Runs OpenSSH with a local helper and checks only the expected inherited + /// context, keeping credentials and command details out of failures. + func proxyCommandReceivesUserContext(agentSocketPath: String?) throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-proxy-env-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let capture = directory.appendingPathComponent("context.txt") + let proxy = directory.appendingPathComponent("proxy.sh") + try """ + #!/bin/sh + printf '%s\\n' "$HOME" "$USER" "$LOGNAME" "$PATH" "${SSH_AUTH_SOCK-unset}" > \(shellQuote(capture.path)) + exit 1 + """.write(to: proxy, atomically: true, encoding: .utf8) + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh") + process.arguments = [ + "-F", "/dev/null", "-o", "BatchMode=yes", "-o", "ConnectTimeout=2", + "-o", "ProxyCommand=/bin/sh \(shellQuote(proxy.path))", + "--", "cmux-proxy-environment.invalid", + ] + // OpenSSH executes ProxyCommand through $SHELL. A runner's zshenv can + // rewrite PATH before our helper starts, so control the shell and all + // fixture values instead of asserting on the runner's private context. + let inherited = [ + "HOME": directory.path, + "USER": "cmux-proxy-test", + "LOGNAME": "cmux-proxy-test", + "PATH": "/cmux-proxy-fixture/bin:/usr/bin:/bin", + "SHELL": "/bin/sh", + "SSH_AUTH_SOCK": "/tmp/cmux-inherited-test-agent.sock", + ] + process.environment = configuration(agentSocketPath: agentSocketPath) + .sshProcessEnvironment(inheriting: inherited) + process.standardInput = FileHandle.nullDevice + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + try process.run() + process.waitUntilExit() + + // The helper intentionally ends before any SSH handshake or network connection. + #expect(process.terminationStatus == 255) + let expected = ["HOME", "USER", "LOGNAME", "PATH"].map { inherited[$0] ?? "" } + + [agentSocketPath.map { $0.isEmpty ? "unset" : $0 } ?? inherited["SSH_AUTH_SOCK"] ?? "unset"] + let proxyReceivedExpectedEnvironment = + try String(contentsOf: capture, encoding: .utf8) == expected.joined(separator: "\n") + "\n" + // Report only equality, never dump inherited credentials on failure. + #expect(proxyReceivedExpectedEnvironment) + } + + /// Copies must retain a disabled override after its raw empty string is normalized. + @Test("Configuration copies keep an explicitly disabled agent") + func copiesKeepDisabledAgent() { + let original = configuration(agentSocketPath: "") + let copies = [original.scopedToOwnerWorkspace(UUID()), original.withDaemonWebSocketEndpoint(nil), + original.withSSHControlMasterLeaseGeneration(UUID())] + for copy in copies { + let noAgent = copy.sshProcessEnvironment?["SSH_AUTH_SOCK"] == nil + #expect(noAgent) + } + } + + /// Quotes one temporary path for the shell script used by the test. + private func shellQuote(_ value: String) -> String { + "'" + value.replacingOccurrences(of: "'", with: "'\"'\"'") + "'" + } + + /// Builds the smallest remote configuration needed by the environment + /// assertions. + private func configuration( + agentSocketPath: String? + ) -> WorkspaceRemoteConfiguration { + WorkspaceRemoteConfiguration( + destination: "cmux-proxy-environment.invalid", + port: nil, + identityFile: nil, + sshOptions: [], + localProxyPort: nil, + relayPort: nil, + relayID: nil, + relayToken: nil, + localSocketPath: nil, + terminalStartupCommand: nil, + agentSocketPath: agentSocketPath + ) + } +} diff --git a/Packages/macOS/CmuxCore/Tests/CmuxCoreTests/SessionRemoteWorkspaceSnapshotTests.swift b/Packages/macOS/CmuxCore/Tests/CmuxCoreTests/SessionRemoteWorkspaceSnapshotTests.swift index 508ec53243be..4da765e9a762 100644 --- a/Packages/macOS/CmuxCore/Tests/CmuxCoreTests/SessionRemoteWorkspaceSnapshotTests.swift +++ b/Packages/macOS/CmuxCore/Tests/CmuxCoreTests/SessionRemoteWorkspaceSnapshotTests.swift @@ -5,6 +5,42 @@ import Testing @Suite("SessionRemoteWorkspaceSnapshot persistence shape") struct SessionRemoteWorkspaceSnapshotTests { + @Test("restore prefers a live saved agent and falls back after it moves") + func restoresLiveAgent() { + let saved = "/tmp/cmux-saved-agent.sock" + let current = "/tmp/cmux-current-agent.sock" + let snapshot = SessionRemoteWorkspaceSnapshot( + transport: .ssh, destination: "example.invalid", agentSocketPath: saved + ) + let environment = ["SSH_AUTH_SOCK": current] + #expect(snapshot.restoredAgentSocketPath(environment: environment, isLiveAgent: { _ in true }) == saved) + #expect(snapshot.restoredAgentSocketPath(environment: environment, isLiveAgent: { $0 == current }) == current) + #expect(snapshot.restoredAgentSocketPath(environment: environment, isLiveAgent: { _ in false }) == nil) + } + + @Test("an explicitly disabled saved agent never falls back", arguments: [nil, "", " "] as [String?]) + func disabledAgentDoesNotFallBack(saved: String?) throws { + let snapshot = SessionRemoteWorkspaceSnapshot( + transport: .ssh, destination: "example.invalid", + agentSocketPath: saved, agentSocketPathOverrideIsSet: true + ) + let restored = try JSONDecoder().decode(SessionRemoteWorkspaceSnapshot.self, from: JSONEncoder().encode(snapshot)) + #expect(restored.restoredAgentSocketPath( + environment: ["SSH_AUTH_SOCK": "/tmp/cmux-current-agent.sock"], + isLiveAgent: { _ in true } + ) == nil) + } + + @Test("legacy snapshots with no saved agent inherit a live current agent") + func legacyAgentInherits() { + let current = "/tmp/cmux-current-agent.sock" + let snapshot = SessionRemoteWorkspaceSnapshot(transport: .ssh, destination: "example.invalid") + #expect(snapshot.restoredAgentSocketPath( + environment: ["SSH_AUTH_SOCK": current], isLiveAgent: { $0 == current } + ) == current) + #expect(snapshot.restoredAgentSocketPath(environment: [:], isLiveAgent: { _ in true }) == nil) + } + @Test("codable round trip preserves every field") func codableRoundTrip() throws { let snapshot = SessionRemoteWorkspaceSnapshot( diff --git a/Packages/macOS/CmuxCore/Tests/CmuxCoreTests/WorkspaceRemoteConfigurationTests.swift b/Packages/macOS/CmuxCore/Tests/CmuxCoreTests/WorkspaceRemoteConfigurationTests.swift index a808da747741..151c35bf1a94 100644 --- a/Packages/macOS/CmuxCore/Tests/CmuxCoreTests/WorkspaceRemoteConfigurationTests.swift +++ b/Packages/macOS/CmuxCore/Tests/CmuxCoreTests/WorkspaceRemoteConfigurationTests.swift @@ -82,6 +82,20 @@ struct WorkspaceRemoteConfigurationNormalizationTests { #expect(WorkspaceRemoteConfiguration.hasSSHOptionKey(options, key: "SERVERALIVEINTERVAL")) #expect(!WorkspaceRemoteConfiguration.hasSSHOptionKey(options, key: "ControlPath")) } + + @Test("validated restore agent paths are not rechecked against the process filesystem") + func validatedRestoreAgentPathWins() { + let path = "/tmp/cmux-test-current-agent.sock" + #expect(WorkspaceRemoteConfiguration.resolvedAgentSocketPath( + sshOptions: [], + explicitAgentSocketPath: path, + explicitAgentSocketPathAlreadyValidated: true + ) == path) + #expect(WorkspaceRemoteConfiguration.resolvedAgentSocketPath( + sshOptions: [], + explicitAgentSocketPath: path + ) == nil) + } } @Suite("WorkspaceRemoteConfiguration value behavior") @@ -415,9 +429,10 @@ struct WorkspaceRemoteConfigurationValueTests { #expect(snapshot?.persistentDaemonSlot == nil) } + /// Terminal overlays stay narrow while standalone SSH children receive a complete environment. @Test("sshTerminalStartupEnvironment carries SSH_AUTH_SOCK only when an agent socket exists") func startupEnvironment() { #expect(makeConfiguration().sshTerminalStartupEnvironment == nil) - #expect(makeConfiguration().sshProcessEnvironment == nil) + #expect(makeConfiguration().sshProcessEnvironment != nil) } } diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHConnectionSharingOptions.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHConnectionSharingOptions.swift index b6cb0b3c2cba..d61b54aa606d 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHConnectionSharingOptions.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHConnectionSharingOptions.swift @@ -161,6 +161,10 @@ public struct SSHConnectionSharingOptions: Sendable { routeIdentifier: String? = nil ) -> [String] { let resolver = SSHAgentSocketResolver() + let optionRouteMarkerValue = resolver.optionValue( + named: Self.routeSensitiveMarkerKey, + in: options + ) let routeMarkerValue = resolver.optionValue( named: Self.routeSensitiveMarkerKey, in: userConfiguredControlOptions ?? [] @@ -170,10 +174,12 @@ public struct SSHConnectionSharingOptions: Sendable { guard let key = resolver.optionKey(option) else { return false } return Self.routeSensitiveKeys.contains(key) } + || optionRouteMarkerValue != nil || routeMarkerValue != nil // A caller's identity wins; otherwise use the route `ssh -G` resolved. // The `true` placeholder means no identity is known. let effectiveRouteIdentifier = routeIdentifier + ?? optionRouteMarkerValue.flatMap { Self.isRouteDigest($0) ? $0 : nil } ?? routeMarkerValue.flatMap { Self.isRouteDigest($0) ? $0 : nil } var merged = options.compactMap { option -> String? in let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) @@ -318,6 +324,52 @@ public struct SSHConnectionSharingOptions: Sendable { .joined() } + /// Returns an opaque route marker that can cross the CLI-to-app boundary. + /// + /// The marker carries only the digest of OpenSSH's resolved configuration; + /// it never serializes a proxy command, socket path, or credential. + public func routeSensitiveOption(for routeIdentifier: String) -> String? { + guard Self.isRouteDigest(routeIdentifier) else { return nil } + return "\(Self.routeSensitiveMarkerKey)=\(routeIdentifier)" + } + + /// Reads the opaque route marker from serialized SSH options. + public func routeSensitiveIdentifier(in options: [String]) -> String? { + let resolver = SSHAgentSocketResolver(environment: [:]) + guard let value = resolver.optionValue(named: Self.routeSensitiveMarkerKey, in: options), + Self.isRouteDigest(value) else { + return nil + } + return value + } + + /// Removes only the cmux-owned control socket generated before a CLI-to-app + /// TUI handoff and carries its opaque route identity when needed. Caller + /// supplied control paths remain authoritative and are forwarded unchanged. + /// + /// The returned values are durable SSH options; the private route marker is + /// consumed by ``mergingDefaults(into:userConfiguredControlOptions:routeSensitiveOptions:routeIdentifier:)`` + /// before any OpenSSH process is launched. + public func optionsForTUIHandoff( + _ options: [String], + routeIdentifier: String? = nil + ) -> [String] { + let resolver = SSHAgentSocketResolver(environment: [:]) + var tuiOptions = options + let generatedControlPath = cmuxOwnedControlPath(in: tuiOptions) + if generatedControlPath != nil { + tuiOptions.removeAll { resolver.optionKey($0) == "controlpath" } + } + // A route digest is needed only when the generated socket was already + // route-specific. The normal `%C` socket is recomputed by the app; + // carrying its digest would unnecessarily split ordinary connections. + if let routeIdentifier, generatedControlPath?.contains("%") == false, + let routeMarker = routeSensitiveOption(for: routeIdentifier) { + tuiOptions.append(routeMarker) + } + return tuiOptions + } + /// Whether `value` is an identity from ``routeIdentifier(fromSSHConfigOutput:)``. private static func isRouteDigest(_ value: String) -> Bool { value.utf8.count == 64 && value.utf8.allSatisfy { byte in diff --git a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHConnectionSharingOptionsTests.swift b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHConnectionSharingOptionsTests.swift index 625743298768..cbfc3e80ee42 100644 --- a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHConnectionSharingOptionsTests.swift +++ b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHConnectionSharingOptionsTests.swift @@ -172,6 +172,18 @@ struct SSHConnectionSharingOptionsTests { }) } + @Test("Opaque route markers survive the app boundary without reaching OpenSSH") + func routeMarkerIsConsumedDuringOptionMerging() throws { + let routeIdentifier = String(repeating: "a", count: 64) + let marker = try #require(options.routeSensitiveOption(for: routeIdentifier)) + #expect(options.routeSensitiveIdentifier(in: [marker]) == routeIdentifier) + + let merged = options.mergingDefaults(into: [marker]) + #expect(!merged.contains(marker)) + #expect(merged.contains("ControlMaster=auto")) + #expect(merged.contains { $0.hasPrefix("ControlPath=\(socketDirectory)/") }) + } + @Test("Legacy cmux control paths are isolated before route-specific reuse") func legacyControlPathGetsRouteSpecificReplacement() { let merged = options.mergingDefaults( diff --git a/Sources/RemoteTui/SSHTuiLinkManager.swift b/Sources/RemoteTui/SSHTuiLinkManager.swift index 21b7ddec6000..989e89c30064 100644 --- a/Sources/RemoteTui/SSHTuiLinkManager.swift +++ b/Sources/RemoteTui/SSHTuiLinkManager.swift @@ -104,7 +104,8 @@ actor SSHTuiLinkManager: RemoteTuiLinkManaging { func adopt(_ replacement: SSHTuiConnection) async { guard replacement.id == connection.id, (replacement.configuration.sshOptions != connection.configuration.sshOptions - || replacement.configuration.agentSocketPath != connection.configuration.agentSocketPath), + || replacement.configuration.agentSocketPath != connection.configuration.agentSocketPath + || replacement.configuration.agentSocketPathOverrideIsSet != connection.configuration.agentSocketPathOverrideIsSet), connecting == nil else { return } let observed = current if let observed, await observed.isConnected { return } @@ -138,7 +139,7 @@ actor SSHTuiLinkManager: RemoteTuiLinkManaging { browser = proxy let task = Task { try await proxy.start(client: clientURL, arguments: connection.browserArguments(stateDirectory: paths.stateDir.path), - environment: connection.configuration.sshProcessEnvironment, releaseHub: {}) + environment: connection.sshProcessEnvironment, releaseHub: {}) } browserStarting = task defer { if browserStarting == task { browserStarting = nil } } diff --git a/Sources/RemoteTui/SessionRemoteWorkspaceSnapshot+TuiSSH.swift b/Sources/RemoteTui/SessionRemoteWorkspaceSnapshot+TuiSSH.swift index acc1930d1b8c..a58d7352fd3d 100644 --- a/Sources/RemoteTui/SessionRemoteWorkspaceSnapshot+TuiSSH.swift +++ b/Sources/RemoteTui/SessionRemoteWorkspaceSnapshot+TuiSSH.swift @@ -1,4 +1,5 @@ import CmuxCore +import CmuxFoundation import Foundation extension SessionRemoteWorkspaceSnapshot { @@ -41,7 +42,14 @@ extension SessionRemoteWorkspaceSnapshot { : WorkspaceRemoteConfiguration.durableSSHOptions(sshOptions), localProxyPort: nil, relayPort: nil, relayID: nil, relayToken: nil, localSocketPath: nil, terminalStartupCommand: nil, configuredRemoteCommand: configuredRemoteCommand, - agentSocketPath: agentSocketPath, preserveAfterTerminalExit: true + agentSocketPath: agentSocketPath, + // A saved socket that is no longer live must not turn into a + // permanent explicit disable. Empty saved values still disable + // inheritance by design. + agentSocketPathOverrideIsSet: agentSocketPath != nil || + (self.agentSocketPathOverrideIsSet == true && + SSHAgentSocketResolver(environment: [:]).normalizedAgentSocketPath(self.agentSocketPath) == nil), + preserveAfterTerminalExit: true ) } } diff --git a/Sources/RemoteTui/TerminalController+SSHTui.swift b/Sources/RemoteTui/TerminalController+SSHTui.swift index dae1ff35b12c..daea97b0977b 100644 --- a/Sources/RemoteTui/TerminalController+SSHTui.swift +++ b/Sources/RemoteTui/TerminalController+SSHTui.swift @@ -26,7 +26,9 @@ extension TerminalController { destination: host.destination, port: host.port, identityFile: host.identityFile, sshOptions: options, localProxyPort: nil, relayPort: nil, relayID: nil, relayToken: nil, localSocketPath: nil, terminalStartupCommand: nil, configuredRemoteCommand: configuredCommand, - agentSocketPath: params["ssh_auth_sock"] as? String, preserveAfterTerminalExit: true + agentSocketPath: params["ssh_auth_sock"] as? String, + agentSocketPathOverrideIsSet: params["ssh_auth_sock"] is String, + preserveAfterTerminalExit: true ) let connection = SSHTuiConnection(configuration: configuration) let provider = try coordinator.provider(connection: connection) diff --git a/Sources/SessionRemoteWorkspaceSnapshot+Restore.swift b/Sources/SessionRemoteWorkspaceSnapshot+Restore.swift index 4898a4ac6a59..4bf00c0b14f8 100644 --- a/Sources/SessionRemoteWorkspaceSnapshot+Restore.swift +++ b/Sources/SessionRemoteWorkspaceSnapshot+Restore.swift @@ -6,11 +6,16 @@ import Security #endif extension SessionRemoteWorkspaceSnapshot { + /// Reconstructs a remote configuration from a persisted session descriptor. + /// The optional environment and liveness seam lets restore tests model a + /// moved agent without touching the process-wide environment. func workspaceConfiguration( localSocketPath: String? = nil, allowPersistentPTYRestore: Bool = true, preserveSSHOptions: Bool = false, - agentSocketPath overrideAgentSocketPath: String? = nil + agentSocketPath overrideAgentSocketPath: String? = nil, + environment: [String: String] = ProcessInfo.processInfo.environment, + isLiveAgent: @escaping (String) -> Bool = Self.acceptsAgentConnections(atPath:) ) -> WorkspaceRemoteConfiguration? { let normalizedDestination = destination.trimmingCharacters(in: .whitespacesAndNewlines) guard !normalizedDestination.isEmpty, @@ -44,7 +49,16 @@ extension SessionRemoteWorkspaceSnapshot { (1...65535).contains(port) ? port : nil } - let agentSocketPath = overrideAgentSocketPath ?? restorableAgentSocketPath() + let agentSocketPath = overrideAgentSocketPath + ?? restorableAgentSocketPath(environment: environment, isLiveAgent: isLiveAgent) + let agentSocketPathOverrideIsSet = overrideAgentSocketPath != nil + || agentSocketPath != nil + // A saved path is a route hint, not an explicit disable. If it + // stopped serving and no inherited agent is live, clear the bit + // so a later restore can adopt a newly available agent. A saved + // empty value remains an explicit disable. + || (self.agentSocketPathOverrideIsSet == true && + SSHAgentSocketResolver(environment: [:]).normalizedAgentSocketPath(self.agentSocketPath) == nil) if let configuration = tuiSSHConfiguration(agentSocketPath: agentSocketPath) { return configuration } if let configuration = legacyTmuxSSHConfiguration(agentSocketPath: agentSocketPath) { return configuration } if skipDaemonBootstrap != true, (terminalTransport ?? .ssh) == .ssh, @@ -54,7 +68,10 @@ extension SessionRemoteWorkspaceSnapshot { var configuration = WorkspaceRemoteConfiguration(destination: normalizedDestination, port: normalizedPort, identityFile: identityFile, sshOptions: sshOptions, localProxyPort: nil, relayPort: nil, relayID: nil, relayToken: nil, - localSocketPath: nil, terminalStartupCommand: nil, preserveAfterTerminalExit: true) + localSocketPath: nil, terminalStartupCommand: nil, + agentSocketPath: agentSocketPath, + agentSocketPathOverrideIsSet: agentSocketPathOverrideIsSet, + preserveAfterTerminalExit: true) configuration.restoredSSHSession = self return configuration } @@ -217,8 +234,16 @@ extension SessionRemoteWorkspaceSnapshot { sshOptions: restoredSSHOptions, // The agent the connection authenticated with wins over a // `ForwardAgent` path, as it does for cmux-tui carriers. - explicitAgentSocketPath: overrideAgentSocketPath ?? self.agentSocketPath + explicitAgentSocketPath: agentSocketPath, + explicitAgentSocketPathIsSet: agentSocketPathOverrideIsSet, + // `restorableAgentSocketPath` already applied the restore + // liveness policy (and accepts an injected test seam). Do not + // replace that result with a second process-wide filesystem + // check here. An explicit override is caller-supplied and + // retains the normal filesystem validation. + explicitAgentSocketPathAlreadyValidated: overrideAgentSocketPath == nil ), + agentSocketPathOverrideIsSet: agentSocketPathOverrideIsSet, daemonWebSocketEndpoint: nil, preserveAfterTerminalExit: preservePTYSession || restoreDefaultFreestyleSSHD, persistentDaemonSlot: (preservePTYSession || restoreDefaultFreestyleSSHD) ? effectivePersistentDaemonSlot : nil, @@ -598,11 +623,7 @@ extension SessionRemoteWorkspaceSnapshot { environment: [String: String] = ProcessInfo.processInfo.environment, isLiveAgent: (String) -> Bool = Self.acceptsAgentConnections(atPath:) ) -> String? { - let resolver = SSHAgentSocketResolver(environment: [:]) - return [agentSocketPath, environment["SSH_AUTH_SOCK"]] - .lazy - .compactMap { resolver.normalizedAgentSocketPath($0) } - .first(where: isLiveAgent) + restoredAgentSocketPath(environment: environment, isLiveAgent: isLiveAgent) } /// Whether an agent socket has a live listener run by this user or by diff --git a/Sources/TerminalController+ControlWorkspaceContext.swift b/Sources/TerminalController+ControlWorkspaceContext.swift index f26582046e3c..0d1a375fa3c9 100644 --- a/Sources/TerminalController+ControlWorkspaceContext.swift +++ b/Sources/TerminalController+ControlWorkspaceContext.swift @@ -679,6 +679,7 @@ extension TerminalController: ControlWorkspaceContext { explicitAgentSocketPath: agentSocketPath, explicitAgentSocketPathIsSet: hasExplicitAgentSocketPath ), + agentSocketPathOverrideIsSet: hasExplicitAgentSocketPath, daemonWebSocketEndpoint: daemonWebSocketEndpoint, preserveAfterTerminalExit: preserveAfterTerminalExit, persistentDaemonSlot: persistentDaemonSlot?.isEmpty == true ? nil : persistentDaemonSlot, diff --git a/cmuxTests/SSHRemoteCommandCLIIntegrationTests.swift b/cmuxTests/SSHRemoteCommandCLIIntegrationTests.swift index af20bcb16ba6..34f7559be092 100644 --- a/cmuxTests/SSHRemoteCommandCLIIntegrationTests.swift +++ b/cmuxTests/SSHRemoteCommandCLIIntegrationTests.swift @@ -1,4 +1,5 @@ import Darwin +import CmuxFoundation import Foundation import Testing @@ -14,6 +15,32 @@ struct SSHRemoteCommandCLIIntegrationTests { private final class RemoteCommandBundleToken {} + @Test + func cmuxGeneratedControlPathIsRecomputedByTheAppForTUI() throws { + let sharingOptions = SSHConnectionSharingOptions() + let generatedPath = try #require(sharingOptions.defaultControlPath) + let options = [ + "ControlMaster=auto", + "ControlPersist=600", + "ControlPath=\(generatedPath)", + ] + + #expect(sharingOptions.optionsForTUIHandoff(options) == ["ControlMaster=auto", "ControlPersist=600"]) + let routeIdentifier = String(repeating: "c", count: 64) + #expect(sharingOptions.optionsForTUIHandoff(options, routeIdentifier: routeIdentifier) + == ["ControlMaster=auto", "ControlPersist=600"]) + let routeSpecificPath = generatedPath.replacingOccurrences(of: "%C", with: String(repeating: "a", count: 40)) + let routeSpecificOptions = [ + "ControlMaster=auto", + "ControlPersist=600", + "ControlPath=\(routeSpecificPath)", + ] + #expect(sharingOptions.optionsForTUIHandoff(routeSpecificOptions, routeIdentifier: routeIdentifier) + == ["ControlMaster=auto", "ControlPersist=600", "__cmux_route_sensitive=\(routeIdentifier)"]) + let callerOwnedOptions = ["ControlMaster=auto", "ControlPath=/tmp/caller-owned"] + #expect(sharingOptions.optionsForTUIHandoff(callerOwnedOptions) == callerOwnedOptions) + } + private struct RemoteCommandMockedSSHRun { let requests: [[String: Any]] } diff --git a/cmuxTests/SSHTuiMigrationTests.swift b/cmuxTests/SSHTuiMigrationTests.swift index 0aade172c76c..1039f3edf347 100644 --- a/cmuxTests/SSHTuiMigrationTests.swift +++ b/cmuxTests/SSHTuiMigrationTests.swift @@ -194,11 +194,12 @@ struct SSHTuiMigrationTests { let snapshot = try #require(opened.sessionSnapshot()) let persisted = try JSONEncoder().encode(snapshot) let restored = try #require(try JSONDecoder().decode(SessionRemoteWorkspaceSnapshot.self, from: persisted).workspaceConfiguration()) - let openedCarrier = try resolvedControlSettings(SSHTuiConnection(configuration: opened)) + let environment = ["PATH": "/usr/bin"] + let openedCarrier = try resolvedControlSettings(SSHTuiConnection(configuration: opened, environment: environment)) #expect(openedCarrier["controlmaster"] == "auto") let socketDirectory = try #require(SSHConnectionSharingOptions().controlSocketDirectoryPath) #expect(openedCarrier["controlpath"]?.hasPrefix(socketDirectory + "/") == true) - #expect(try resolvedControlSettings(SSHTuiConnection(configuration: restored)) == openedCarrier) + #expect(try resolvedControlSettings(SSHTuiConnection(configuration: restored, environment: environment)) == openedCarrier) } @Test("A restored carrier finds the master an open with an SSH agent authenticated") @@ -220,24 +221,36 @@ struct SSHTuiMigrationTests { let restored = try #require(try JSONDecoder().decode(SessionRemoteWorkspaceSnapshot.self, from: persisted) .workspaceConfiguration(localSocketPath: "/tmp/cmux-test.sock")) #expect(restored.agentSocketPath == agent) - #expect(try resolvedControlSettings(SSHTuiConnection(configuration: restored)) - == resolvedControlSettings(SSHTuiConnection(configuration: opened))) + let environment = ["PATH": "/usr/bin"] + #expect(try resolvedControlSettings(SSHTuiConnection(configuration: restored, environment: environment)) + == resolvedControlSettings(SSHTuiConnection(configuration: opened, environment: environment))) } - @Test("A restored carrier dials the cmux master the CLI keyed by its resolved route") - func restoredCarrierDialsTheCLIsRouteMaster() throws { - // `cmux ssh` keys its master by the route `ssh -G` resolved and sends - // that cmux-owned ControlPath. The app cannot recompute the same key, - // so a restore that drops the path dials a master no login opened. - let socketDirectory = try #require(SSHConnectionSharingOptions().controlSocketDirectoryPath) - let cliPath = socketDirectory + "/" + String(repeating: "a", count: 40) - let opened = configuration(options: ["ControlMaster=auto", "ControlPersist=600", "ControlPath=\(cliPath)"]) + @Test("A restored carrier recomputes the app-owned route from durable options") + func restoredCarrierRecomputesTheAppRoute() throws { + // The CLI drops its own resolved ControlPath before the TUI payload. + // The app then keys both the live carrier and its snapshot restore from + // one captured agent route, so a password-only restore finds that same + // authenticated master without needing the CLI's ssh -G digest. + let listener = try AgentSocketListener() + defer { listener.remove() } + let agent = listener.path + let base = configuration(options: ["ProxyCommand=/bin/sh -c true"]) + let opened = WorkspaceRemoteConfiguration( + terminalProfile: base.terminalProfile, destination: base.destination, port: base.port, + identityFile: base.identityFile, sshOptions: base.sshOptions, localProxyPort: nil, relayPort: nil, + relayID: nil, relayToken: nil, localSocketPath: nil, terminalStartupCommand: nil, + configuredRemoteCommand: nil, agentSocketPath: agent, preserveAfterTerminalExit: true + ) let snapshot = try #require(opened.sessionSnapshot()) let persisted = try JSONEncoder().encode(snapshot) let restored = try #require(try JSONDecoder().decode(SessionRemoteWorkspaceSnapshot.self, from: persisted) .workspaceConfiguration(localSocketPath: "/tmp/cmux-test.sock")) - #expect(try resolvedControlSettings(SSHTuiConnection(configuration: restored))["controlpath"] == cliPath) - #expect(SSHTuiConnection(configuration: restored).id == SSHTuiConnection(configuration: opened).id) + let environment = ["PATH": "/usr/bin"] + let openedSettings = try resolvedControlSettings(SSHTuiConnection(configuration: opened, environment: environment)) + #expect(try resolvedControlSettings(SSHTuiConnection(configuration: restored, environment: environment)) == openedSettings) + #expect(SSHTuiConnection(configuration: restored, environment: environment).id + == SSHTuiConnection(configuration: opened, environment: environment).id) } @Test("A restored Mosh workspace keeps the agent its snapshot saved") @@ -270,6 +283,62 @@ struct SSHTuiMigrationTests { #expect(snapshot.restorableAgentSocketPath(environment: [:], isLiveAgent: live.contains) == nil) } + @Test("A restore carries the live fallback agent into SSH child environments") + func restoredConfigurationUsesLiveFallbackAgent() throws { + let listener = try AgentSocketListener() + defer { listener.remove() } + let saved = "/tmp/cmux-test-saved-agent.sock" + let current = listener.path + let snapshot = SessionRemoteWorkspaceSnapshot( + transport: .ssh, + destination: "alice@example.invalid", + agentSocketPath: saved + ) + let configuration = try #require(snapshot.workspaceConfiguration( + environment: ["SSH_AUTH_SOCK": current], + isLiveAgent: { [current].contains($0) } + )) + #expect(configuration.agentSocketPath == current) + #expect(configuration.sshProcessEnvironment?["SSH_AUTH_SOCK"] == current) + } + + @Test("A dead saved agent does not become an explicit disable during restore") + func staleSavedAgentDoesNotPoisonFutureRestore() throws { + let stale = "/tmp/cmux-stale-restore-agent.sock" + let current = "/tmp/cmux-current-restore-agent.sock" + var snapshot = SessionRemoteWorkspaceSnapshot( + transport: .ssh, + destination: "alice@example.invalid", + agentSocketPath: stale, + agentSocketPathOverrideIsSet: true, + ) + + let restored = try #require(snapshot.workspaceConfiguration( + environment: [:], + isLiveAgent: { _ in false } + )) + #expect(restored.agentSocketPath == nil) + #expect(!restored.agentSocketPathOverrideIsSet) + + let resaved = try #require(restored.sessionSnapshot()) + let resumed = try #require(resaved.workspaceConfiguration( + environment: ["SSH_AUTH_SOCK": current], + isLiveAgent: { $0 == current } + )) + #expect(resumed.agentSocketPath == current) + #expect(resumed.agentSocketPathOverrideIsSet) + + var carrierSnapshot = snapshot + carrierSnapshot.sshSessionOwner = "cmux-tui" + carrierSnapshot.preserveAfterTerminalExit = true + let restoredCarrier = try #require(carrierSnapshot.workspaceConfiguration( + environment: [:], + isLiveAgent: { _ in false } + )) + #expect(restoredCarrier.agentSocketPath == nil) + #expect(!restoredCarrier.agentSocketPathOverrideIsSet) + } + @Test("A saved agent path that no longer serves never beats a live agent") func staleAgentPathLosesToALiveAgent() throws { let listener = try AgentSocketListener() diff --git a/cmuxTests/SSHTuiPreflightTests.swift b/cmuxTests/SSHTuiPreflightTests.swift index c1c12e464320..c315e2b08978 100644 --- a/cmuxTests/SSHTuiPreflightTests.swift +++ b/cmuxTests/SSHTuiPreflightTests.swift @@ -15,7 +15,10 @@ struct SSHTuiPreflightTests { @Test("Runs a prompt-free ssh true over the carrier's route") func runsBatchSSHOverTheRoute() async throws { let commands = ScriptedPreflightCommands(exitStatus: 0) - let connection = SSHTuiConnection(configuration: configuration(options: ["ControlPath=/tmp/cm", "ConnectTimeout=5"])) + let connection = SSHTuiConnection( + configuration: configuration(options: ["ControlPath=/tmp/cm", "ConnectTimeout=5"]), + environment: ["PATH": "/usr/bin"] + ) try await SSHTuiPreflight(connection: connection, commands: commands, timeout: 7).run() let call = try #require(await commands.calls.first) #expect(call.executable == "/usr/bin/ssh") @@ -53,6 +56,71 @@ struct SSHTuiPreflightTests { #expect(firstPath != secondPath) } + @Test("Inherited agent sockets use the same value for the child and route identity") + func inheritedAgentSocketIsCapturedPerConnection() throws { + let first = SSHTuiConnection( + configuration: configuration(identityFile: nil), + environment: ["PATH": "/usr/bin", "SSH_AUTH_SOCK": "/tmp/inherited-agent-a.sock"] + ) + let second = SSHTuiConnection( + configuration: configuration(identityFile: nil), + environment: ["PATH": "/usr/bin", "SSH_AUTH_SOCK": "/tmp/inherited-agent-b.sock"] + ) + + #expect(first.sshProcessEnvironment["SSH_AUTH_SOCK"] == "/tmp/inherited-agent-a.sock") + #expect(second.sshProcessEnvironment["SSH_AUTH_SOCK"] == "/tmp/inherited-agent-b.sock") + #expect(first.id == second.id) + #expect(first.authenticationArguments.first { $0.hasPrefix("ControlPath=") } + != second.authenticationArguments.first { $0.hasPrefix("ControlPath=") }) + } + + @Test("A caller-owned ControlPath cannot cross inherited agent routes") + func callerControlPathDisablesCrossAgentReuse() throws { + let options = ["ControlMaster=auto", "ControlPersist=600", "ControlPath=/tmp/shared-cmux-agent"] + let first = SSHTuiConnection( + configuration: configuration(options: options, identityFile: nil), + environment: ["PATH": "/usr/bin", "SSH_AUTH_SOCK": "/tmp/inherited-agent-a.sock"] + ) + let second = SSHTuiConnection( + configuration: configuration(options: options, identityFile: nil), + environment: ["PATH": "/usr/bin", "SSH_AUTH_SOCK": "/tmp/inherited-agent-b.sock"] + ) + + #expect(first.authenticationArguments.contains("ControlPath=none")) + #expect(second.authenticationArguments.contains("ControlPath=none")) + #expect(!first.authenticationArguments.contains("ControlPath=/tmp/shared-cmux-agent")) + #expect(!second.authenticationArguments.contains("ControlPath=/tmp/shared-cmux-agent")) + } + + /// Explicit disable must not reuse an inherited-agent control master. + @Test("An explicitly disabled agent uses a separate route from an inherited agent") + func disabledAgentDoesNotShareInheritedRoute() throws { + let inherited = SSHTuiConnection(configuration: configuration(agent: nil, identityFile: nil)) + let disabled = SSHTuiConnection(configuration: WorkspaceRemoteConfiguration( + destination: "alice@example.invalid", port: 2222, identityFile: nil, sshOptions: [], + localProxyPort: nil, relayPort: nil, relayID: nil, relayToken: nil, localSocketPath: nil, + terminalStartupCommand: nil, agentSocketPath: "", agentSocketPathOverrideIsSet: true + )) + + let inheritedPath = inherited.authenticationArguments.first { $0.hasPrefix("ControlPath=") } + let disabledPath = disabled.authenticationArguments.first { $0.hasPrefix("ControlPath=") } + + #expect(inheritedPath != disabledPath) + } + + @Test("An explicit disabled socket preserves a caller IdentityAgent option") + func disabledAgentPreservesCallerIdentityAgent() throws { + let disabled = SSHTuiConnection(configuration: WorkspaceRemoteConfiguration( + destination: "alice@example.invalid", port: 2222, identityFile: nil, + sshOptions: ["IdentityAgent=/tmp/caller-agent.sock"], + localProxyPort: nil, relayPort: nil, relayID: nil, relayToken: nil, localSocketPath: nil, + terminalStartupCommand: nil, agentSocketPath: "", agentSocketPathOverrideIsSet: true + )) + + #expect(disabled.authenticationArguments.contains("IdentityAgent=/tmp/caller-agent.sock")) + #expect(!disabled.authenticationArguments.contains("IdentityAgent=none")) + } + @Test("Passes the configured agent socket like the carrier") func passesTheAgentSocket() async throws { let commands = ScriptedPreflightCommands(exitStatus: 0) @@ -63,6 +131,34 @@ struct SSHTuiPreflightTests { #expect(call.arguments == ["SSH_AUTH_SOCK=/tmp/agent.sock"] + connection.preflightArguments) } + @Test("Pins an inherited agent captured by the connection") + func passesCapturedInheritedAgentSocket() async throws { + let commands = ScriptedPreflightCommands(exitStatus: 0) + let connection = SSHTuiConnection( + configuration: configuration(agent: nil), + environment: ["PATH": "/usr/bin", "SSH_AUTH_SOCK": "/tmp/inherited-agent.sock"] + ) + try await SSHTuiPreflight(connection: connection, commands: commands).run() + let call = try #require(await commands.calls.first) + #expect(call.executable == "/usr/bin/env") + #expect(call.arguments == ["SSH_AUTH_SOCK=/tmp/inherited-agent.sock"] + connection.preflightArguments) + } + + /// Explicit disable removes the inherited socket before OpenSSH starts. + @Test("Removes an explicitly disabled agent socket from the preflight child") + func removesDisabledAgentSocket() async throws { + let commands = ScriptedPreflightCommands(exitStatus: 0) + let connection = SSHTuiConnection(configuration: WorkspaceRemoteConfiguration( + destination: "alice@example.invalid", port: 2222, identityFile: nil, sshOptions: [], + localProxyPort: nil, relayPort: nil, relayID: nil, relayToken: nil, localSocketPath: nil, + terminalStartupCommand: nil, agentSocketPath: "", agentSocketPathOverrideIsSet: true + )) + try await SSHTuiPreflight(connection: connection, commands: commands).run() + let call = try #require(await commands.calls.first) + #expect(call.executable == "/usr/bin/env") + #expect(call.arguments == ["-u", "SSH_AUTH_SOCK"] + connection.preflightArguments) + } + @Test("Reports OpenSSH's own failure with its diagnostic") func reportsOpenSSHFailure() async { let stderr = "Warning: Permanently added 'host'.\n\nalice@example.invalid: Permission denied (publickey,password).\n" diff --git a/cmuxTests/TabManagerSessionSnapshotTests.swift b/cmuxTests/TabManagerSessionSnapshotTests.swift index 9e7baceca1e9..1f69346f486d 100644 --- a/cmuxTests/TabManagerSessionSnapshotTests.swift +++ b/cmuxTests/TabManagerSessionSnapshotTests.swift @@ -2281,8 +2281,11 @@ final class TabManagerSessionSnapshotTests: XCTestCase { let expandedIdentityFile = (identityFile as NSString).expandingTildeInPath let originalAgentSocketPath = "/tmp/cmux-original-restore-agent.sock" let restoredAgentSocketPath = "/tmp/cmux-current-restore-agent-\(UUID().uuidString).sock" - XCTAssertTrue(FileManager.default.createFile(atPath: restoredAgentSocketPath, contents: Data())) - defer { try? FileManager.default.removeItem(atPath: restoredAgentSocketPath) } + let restoredAgentSocketFD = try SSHStartupManualReconnectTests.bindUnixSocket(at: restoredAgentSocketPath) + defer { + Darwin.close(restoredAgentSocketFD) + unlink(restoredAgentSocketPath) + } let previousAgentSocketPath = getenv("SSH_AUTH_SOCK").map { String(cString: $0) } setenv("SSH_AUTH_SOCK", restoredAgentSocketPath, 1) defer {