Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
fa3895b
test: cover SSH ProxyCommand child environment
austinywang Oct 7, 2026
0ba5fbb
fix: preserve environment for SSH ProxyCommand children
austinywang Oct 7, 2026
cebdfbc
test: update SSH environment expectation
austinywang Oct 7, 2026
179e370
Merge main (efb680adc206) into codex-17560-ssh-proxy
austinywang Oct 7, 2026
9e3e8bc
test: keep SSH environment assertions private
austinywang Oct 7, 2026
2143957
fix: preserve disabled SSH agent semantics
austinywang Oct 7, 2026
e91c211
Merge main (6b02ff8fd332) into codex-17560-ssh-proxy
austinywang Oct 7, 2026
6a58c56
Merge branch 'main' of https://github.com/manaflow-ai/cmux into codex…
austinywang Oct 7, 2026
c203403
fix: preserve SSH agent override semantics
austinywang Oct 7, 2026
ff585fb
Merge main (5d12d7b4087f) into codex-17560-ssh-proxy
austinywang Oct 7, 2026
73ccd79
fix: compile SSH route identity digest
austinywang Oct 7, 2026
da10c53
fix: bind SSH route identity to captured environment
austinywang Oct 7, 2026
8221f0c
fix: prevent SSH agent routes sharing custom masters
austinywang Oct 7, 2026
e6db0c8
fix: preserve inherited command environments
austinywang Oct 7, 2026
76b271e
fix: keep preflight agent selection connection-owned
austinywang Oct 7, 2026
2ef5d13
fix: honor explicit disabled SSH agent option
austinywang Oct 7, 2026
6d1401a
test: cover OpenSSH agent option precedence across SSH launches
austinywang Oct 7, 2026
10c98f0
fix: keep SSH config agent precedence during restore
austinywang Oct 7, 2026
79da3b5
test: preserve saved SSH route socket on restore
austinywang Oct 7, 2026
504431b
test: isolate ProxyCommand from runner shell startup
austinywang Oct 7, 2026
98ed64f
fix: preserve SSH route identity across TUI handoff
austinywang Oct 7, 2026
b839a9b
Merge main (8aae7c01b0d9) into codex-17560-ssh-proxy
austinywang Oct 7, 2026
bc35186
test: retain silent cloud proxy connections
austinywang Oct 7, 2026
f6a9713
fix: preserve restored SSH carrier socket
austinywang Oct 7, 2026
7dce358
Revert "test: retain silent cloud proxy connections"
austinywang Oct 7, 2026
e8b1f1e
fix: make desktop probe timeout deterministic
austinywang Oct 7, 2026
102f4a6
fix: preserve validated SSH agent during restore
austinywang Oct 7, 2026
fd80de2
test: use a live socket for restored SSH agent fallback
austinywang Oct 7, 2026
9f04418
Merge main (c5abdafd61c) into codex-17560-ssh-proxy
austinywang Oct 7, 2026
807e573
Merge main (b9c0111a67bf) into codex-17560-ssh-proxy
austinywang Oct 7, 2026
49464a5
test: cover stale SSH agent restore state
austinywang Oct 7, 2026
90b8793
fix: allow SSH agent restore to recover later
austinywang Oct 7, 2026
fee672c
test: exercise stale SSH agent persistence
austinywang Oct 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions CLI/CMUXCLI+SSHTui.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
]
Expand Down
10 changes: 6 additions & 4 deletions CLI/cmux.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
Expand All @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} else if configuration.agentSocketPathOverrideIsSet {
components.append("<disabled-agent>")
} else {
components.append("<no-agent>")
}
}
return SHA256.hash(data: Data(components.joined(separator: "\0").utf8))
.map { String(format: "%02x", $0) }.joined()
Expand Down Expand Up @@ -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")
}
Comment thread
cursor[bot] marked this conversation as resolved.
}
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
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,24 @@ 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
}

public let connection: SSHTuiConnection
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()
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
Original file line number Diff line number Diff line change
@@ -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)) }
}
}
Loading
Loading