Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
695f07f
test: native SSH workspaces share a host control path (#8300)
austinywang Jul 17, 2026
3ddc45d
fix: share native SSH connections per host (#8300)
austinywang Jul 17, 2026
9e25382
Merge remote-tracking branch 'origin/main' into issue-8300-ssh-connec…
austinywang Jul 17, 2026
b558573
fix: harden native SSH sharing lifecycle (#8300)
austinywang Jul 17, 2026
62f3315
fix: require resolved SSH master ownership keys (#8300)
austinywang Jul 17, 2026
1c1275a
fix: coordinate SSH auth and master cleanup (#8300)
austinywang Jul 17, 2026
243708f
Merge remote-tracking branch 'origin/main' into issue-8300-ssh-connec…
austinywang Jul 17, 2026
f5bea46
refactor: align SSH broker tests with Swift policy (#8300)
austinywang Jul 17, 2026
f68883a
test: wire merged workspace todo regression coverage
austinywang Jul 17, 2026
410e0cb
fix: harden native SSH broker lifecycle
austinywang Jul 17, 2026
b871dca
Merge remote-tracking branch 'origin/main' into issue-8300-ssh-connec…
austinywang Jul 17, 2026
cabe3d6
test: cover burst SSH background priming
austinywang Jul 17, 2026
75de0ed
fix: keep SSH background priming stable through bursts
austinywang Jul 17, 2026
466e3fd
test: cover deferred SSH master cleanup
austinywang Jul 17, 2026
17cf204
fix: retry deferred SSH master cleanup
austinywang Jul 17, 2026
70c38cf
Merge branch 'main' of https://github.com/manaflow-ai/cmux into issue…
austinywang Jul 18, 2026
977f570
Merge branch 'main' of https://github.com/manaflow-ai/cmux into issue…
austinywang Jul 18, 2026
0bde12e
test: fix browser design mode test compilation
austinywang Jul 18, 2026
4768688
test: cover multiplexed SSH foreground auth followers
austinywang Jul 18, 2026
12222d2
fix: signal multiplexed SSH auth followers locally
austinywang Jul 18, 2026
5559ac2
Merge branch 'main' of https://github.com/manaflow-ai/cmux into issue…
austinywang Jul 18, 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
67 changes: 67 additions & 0 deletions CLI/CMUXCLI+SSHConnectionSharing.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import CmuxFoundation
import Foundation

extension CMUXCLI {
func resolvedUserSSHControlOptions(for options: SSHCommandOptions) -> [String]? {
guard let output = resolvedSSHConfigurationOutput(for: options) else { return nil }
return SSHConnectionSharingOptions()
.userConfiguredControlOptions(fromSSHConfigOutput: output)
}

func resolvedCmuxControlPathOptions(for options: SSHCommandOptions) -> [String] {
let sharingOptions = SSHConnectionSharingOptions()
guard let configuredPath = sharingOptions.cmuxOwnedControlPath(in: options.sshOptions),
configuredPath.contains("%"),
let output = resolvedSSHConfigurationOutput(for: options),
let resolvedPath = sshConfigurationValue(named: "controlpath", in: output) else {
return options.sshOptions
}
let validationOptions = ["ControlMaster=auto", "ControlPath=\(resolvedPath)"]
guard sharingOptions.cmuxOwnedControlPath(in: validationOptions) == resolvedPath else {
return options.sshOptions
}
let resolver = SSHAgentSocketResolver()
return options.sshOptions.map { option in
resolver.optionKey(option) == "controlpath"
? "ControlPath=\(resolvedPath)"
: option
}
}

func resolvedSSHConfigurationOutput(for options: SSHCommandOptions) -> String? {
var arguments = ["-G"]
if let port = options.port {
arguments += ["-p", String(port)]
}
if let rawIdentityFile = options.identityFile {
let trimmedIdentityFile = rawIdentityFile.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmedIdentityFile.isEmpty {
let identityFile = trimmedIdentityFile.hasPrefix("~")
? (trimmedIdentityFile as NSString).expandingTildeInPath
: trimmedIdentityFile
arguments += ["-i", identityFile]
}
}
for option in options.sshOptions {
arguments += ["-o", option]
}
arguments.append(options.destination)
let result = CLIProcessRunner.runProcess(
executablePath: "/usr/bin/ssh",
arguments: arguments,
timeout: 2
)
return result.status == 0 ? result.stdout : nil
}

func sshConfigurationValue(named name: String, in output: String) -> String? {
let loweredName = name.lowercased()
for line in output.split(whereSeparator: \.isNewline) {
let parts = line.split(maxSplits: 1, whereSeparator: \.isWhitespace)
guard parts.count == 2, parts[0].lowercased() == loweredName else { continue }
let value = parts[1].trimmingCharacters(in: .whitespacesAndNewlines)
return value.isEmpty ? nil : value
}
return nil
}
}
6 changes: 0 additions & 6 deletions CLI/CMUXCLI+SSHStartupScripts.swift
Original file line number Diff line number Diff line change
Expand Up @@ -332,9 +332,6 @@ extension CMUXCLI {
if let trimmedOneTimeCommand, !trimmedOneTimeCommand.isEmpty {
scriptLines.append("trap 'cmux_ssh_cleanup_password' EXIT")
scriptLines += ["cmux_ssh_foreground_auth() {", trimmedOneTimeCommand, "}"]
if let trimmedControlPathPreflight, !trimmedControlPathPreflight.isEmpty {
scriptLines.append("cmux_ssh_preflight_control_path")
}
scriptLines += ["( cmux_ssh_foreground_auth )", "cmux_ssh_auth_status=$?", "if [ \"$cmux_ssh_auth_status\" -ne 0 ]; then exit \"$cmux_ssh_auth_status\"; fi", "trap - EXIT"]
}
let reconnectConfiguration = retryPTYAttachStatus ? [
Expand Down Expand Up @@ -374,9 +371,6 @@ extension CMUXCLI {
]
if hasOneTimeCommand {
scriptLines.append(" if [ \"$cmux_ssh_reauth_required\" -eq 1 ]; then")
if let trimmedControlPathPreflight, !trimmedControlPathPreflight.isEmpty {
scriptLines.append(" cmux_ssh_preflight_control_path")
}
scriptLines += [" ( cmux_ssh_foreground_auth )", " cmux_ssh_status=$?", " if [ \"$cmux_ssh_status\" -eq 0 ]; then cmux_ssh_reauth_required=0; elif [ \"$cmux_ssh_status\" -ne 255 ]; then break; fi", " fi", " if [ \"$cmux_ssh_reauth_required\" -eq 0 ]; then"]
}
if let trimmedControlPathPreflight, !trimmedControlPathPreflight.isEmpty,
Expand Down
122 changes: 62 additions & 60 deletions CLI/cmux.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8576,7 +8576,7 @@ struct CMUXCLI {
let workspaceName: String?
let windowRaw: String?
let noFocus: Bool
let sshOptions: [String]
var sshOptions: [String]
let extraArguments: [String]
let agentSocketPath: String?
let passwordCredential: String?
Expand Down Expand Up @@ -8917,14 +8917,21 @@ struct CMUXCLI {
/// drop the user in a shell" pipeline. The inner loop of `cmux ssh`; also called from
/// `cmux vm new`/`shell`/`attach` so cloud VMs reuse the exact same bootstrap.
private func runSSHWithOptions(
_ sshOptions: SSHCommandOptions,
_ inputSSHOptions: SSHCommandOptions,
relayID: String,
relayToken: String,
client: SocketClient,
jsonOutput: Bool,
idFormat: CLIIDFormat,
vmIDForSplitAttach: String? = nil
) throws {
var sshOptions = inputSSHOptions
let sharingOptions = SSHConnectionSharingOptions()
sshOptions.sshOptions = sharingOptions.mergingDefaults(
into: inputSSHOptions.sshOptions,
userConfiguredControlOptions: resolvedUserSSHControlOptions(for: inputSSHOptions)
)
sshOptions.sshOptions = resolvedCmuxControlPathOptions(for: sshOptions)
let sshStartedAt = Date()
func logSSHTiming(_ stage: String, extra: String = "") {
let elapsedMs = Int(Date().timeIntervalSince(sshStartedAt) * 1000)
Expand Down Expand Up @@ -9765,32 +9772,11 @@ struct CMUXCLI {
options.sshOptions,
remoteRelayPort: options.remoteRelayPort
)
guard let controlMaster = sshOptionValue(named: "ControlMaster", in: effectiveOptions)?
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased(),
!["no", "false", "off"].contains(controlMaster),
let controlPath = sshOptionValue(named: "ControlPath", in: effectiveOptions)?
.trimmingCharacters(in: .whitespacesAndNewlines),
!controlPath.isEmpty,
controlPath.lowercased() != "none" else {
return nil
}

let sshPrefix = baseSSHArguments(options).map(shellQuote).joined(separator: " ")
let destination = shellQuote(options.destination)
return [
"cmux_ssh_preflight_control_path() {",
#" cmux_ssh_control_path="$(command \#(sshPrefix) -G \#(destination) 2>/dev/null | awk 'tolower($1) == "controlpath" { $1 = ""; sub(/^[[:space:]]+/, ""); print; exit }')" "#,
" case \"${cmux_ssh_control_path:-}\" in",
" /tmp/cmux-ssh-*|\"$HOME\"/.cmux/control/*)",
" if ! command \(sshPrefix) -S \"$cmux_ssh_control_path\" -O check \(destination) >/dev/null 2>&1; then",
" rm -f -- \"$cmux_ssh_control_path\" 2>/dev/null || true",
" fi",
" ;;",
" esac",
" unset cmux_ssh_control_path",
"}",
].joined(separator: "\n")
return SSHConnectionSharingOptions().controlPathPreflightShellFunction(
sshArguments: baseSSHArguments(options),
destination: options.destination,
options: effectiveOptions
)
}

func buildInteractiveRemoteShellScript(
Expand Down Expand Up @@ -10097,28 +10083,8 @@ struct CMUXCLI {
_ options: [String],
remoteRelayPort: Int? = nil
) -> [String] {
var merged: [String] = []
for option in options {
let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { continue }
merged.append(trimmed)
}
let controlMaster = sshOptionValue(named: "ControlMaster", in: merged)?
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
let controlMasterDisabled = ["no", "false", "off"].contains(controlMaster ?? "")
if controlMaster == nil {
merged.append("ControlMaster=auto")
}
if !controlMasterDisabled {
if !hasSSHOptionKey(merged, key: "ControlPersist") {
merged.append("ControlPersist=600")
}
if !hasSSHOptionKey(merged, key: "ControlPath") {
merged.append("ControlPath=\(defaultSSHControlPathTemplate(remoteRelayPort: remoteRelayPort))")
}
}
return merged
_ = remoteRelayPort
return SSHConnectionSharingOptions().mergingDefaults(into: options)
}

private func scopedGhosttyShellFeaturesValue() -> String {
Expand Down Expand Up @@ -10169,18 +10135,61 @@ struct CMUXCLI {
passwordCredential: String?,
controlPathPreflightShellFunction: String?
) -> String {
var authArguments = sshArgumentsOverridingHostRemoteCommand(baseSSHArguments(options, localCommandScript: localCommandScript))
var authArguments = sshArgumentsOverridingHostRemoteCommand(baseSSHArguments(options))
authArguments += ["-T", options.destination, "true"]
let authCommand = authArguments.map(shellQuote).joined(separator: " ")
let attachScript = buildSSHPTYAttachScriptBody(
remoteShellCommand: remoteShellCommand
)
let authScript = [
var authScriptLines: [String] = []
let authenticationLockPath = SSHConnectionSharingOptions().foregroundAuthenticationLockPath(
destination: options.destination,
port: options.port,
options: effectiveSSHOptions(options.sshOptions, remoteRelayPort: options.remoteRelayPort)
)
if let lockPath = authenticationLockPath {
let inFlightPath = lockPath + ".inflight"
authScriptLines += [
"umask 077",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
"cmux_ssh_auth_inflight_path=\(shellQuote(inFlightPath))",
"cmux_ssh_auth_lock_path=\(shellQuote(lockPath))",
"printf '%s\\n' \"$$\" > \"$cmux_ssh_auth_inflight_path\" || exit 255",
"cmux_ssh_clear_auth_inflight() { if [ \"$(/bin/cat -- \"$cmux_ssh_auth_inflight_path\" 2>/dev/null || true)\" = \"$$\" ]; then /bin/rm -f -- \"$cmux_ssh_auth_inflight_path\" 2>/dev/null || true; fi; }",
"trap 'cmux_ssh_clear_auth_inflight' EXIT",
"trap 'cmux_ssh_clear_auth_inflight; exit 129' HUP",
"trap 'cmux_ssh_clear_auth_inflight; exit 130' INT",
"trap 'cmux_ssh_clear_auth_inflight; exit 143' TERM",
": >> \"$cmux_ssh_auth_lock_path\" || exit 255",
"zmodload zsh/system || exit 255",
"zsystem flock -t 45 -e -f cmux_ssh_auth_lock_fd \"$cmux_ssh_auth_lock_path\" || exit 255",
]
if let controlPathPreflightShellFunction {
authScriptLines.append(controlPathPreflightShellFunction)
}
}
if controlPathPreflightShellFunction != nil {
authScriptLines.append("cmux_ssh_preflight_control_path")
}
authScriptLines += [
"command \(authCommand) <&0",
"cmux_auth_status=$?",
"if [ \"$cmux_auth_status\" -ne 0 ]; then exit \"$cmux_auth_status\"; fi",
]
.joined(separator: "\n")
if let localCommandScript = localCommandScript?
.trimmingCharacters(in: .whitespacesAndNewlines),
!localCommandScript.isEmpty {
authScriptLines.append(localCommandScript)
}
if authenticationLockPath != nil {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
authScriptLines += [
"zsystem flock -u \"$cmux_ssh_auth_lock_fd\" || exit 255",
"trap - EXIT HUP INT TERM",
]
}
let authScriptBody = authScriptLines.joined(separator: "\n")
let authScript = authenticationLockPath == nil
? authScriptBody
: "/bin/zsh -fc \(shellQuote(authScriptBody))"
return buildReusableSSHStartupCommand(
sshCommand: attachScript,
shellFeatures: "",
Expand Down Expand Up @@ -12913,13 +12922,6 @@ struct CMUXCLI {
return ["no", "false", "off"].contains(normalized) || (zeroIsDisabled && normalized == "0")
}

private func defaultSSHControlPathTemplate(remoteRelayPort: Int? = nil) -> String {
if let remoteRelayPort, remoteRelayPort > 0 {
return "/tmp/cmux-ssh-\(getuid())-\(remoteRelayPort)-%C"
}
return "/tmp/cmux-ssh-\(getuid())-%C"
}

private func normalizedSSHIdentityPath(_ rawPath: String?) -> String? {
guard let rawPath else { return nil }
let trimmed = rawPath.trimmingCharacters(in: .whitespacesAndNewlines)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ public struct WorkspaceRemoteConfiguration: Equatable, Sendable {
/// a `DaemonHello`. Reverse-relay still stays off, but SSH-backed VM workspaces can talk to
/// the baked daemon through an SSH local forward to `/run/cmuxd-remote.sock`.
public let skipDaemonBootstrap: Bool
/// Runtime generation assigned by the native-SSH connection owner.
///
/// This value is deliberately excluded from configuration equality: it
/// identifies one broker lease, not a user-visible connection setting.
public let sshControlMasterLeaseGeneration: UUID?

/// Creates a configuration, normalizing the agent socket path and gating
/// the persistent daemon slot on `preserveAfterTerminalExit` exactly like
Expand All @@ -71,7 +76,8 @@ public struct WorkspaceRemoteConfiguration: Equatable, Sendable {
daemonWebSocketEndpoint: WorkspaceRemoteWebSocketDaemonEndpoint? = nil,
preserveAfterTerminalExit: Bool = false,
persistentDaemonSlot: String? = nil,
skipDaemonBootstrap: Bool = false
skipDaemonBootstrap: Bool = false,
sshControlMasterLeaseGeneration: UUID? = nil
) {
self.transport = transport
self.destination = destination
Expand All @@ -94,6 +100,7 @@ public struct WorkspaceRemoteConfiguration: Equatable, Sendable {
? Self.normalizedPersistentDaemonSlot(persistentDaemonSlot)
: nil
self.skipDaemonBootstrap = skipDaemonBootstrap
self.sshControlMasterLeaseGeneration = sshControlMasterLeaseGeneration
}

public init(
Expand All @@ -114,7 +121,8 @@ public struct WorkspaceRemoteConfiguration: Equatable, Sendable {
daemonWebSocketEndpoint: WorkspaceRemoteWebSocketDaemonEndpoint? = nil,
preserveAfterTerminalExit: Bool = false,
persistentDaemonSlot: String? = nil,
skipDaemonBootstrap: Bool = false
skipDaemonBootstrap: Bool = false,
sshControlMasterLeaseGeneration: UUID? = nil
) {
self.init(
transport: transport,
Expand All @@ -135,10 +143,34 @@ public struct WorkspaceRemoteConfiguration: Equatable, Sendable {
daemonWebSocketEndpoint: daemonWebSocketEndpoint,
preserveAfterTerminalExit: preserveAfterTerminalExit,
persistentDaemonSlot: persistentDaemonSlot,
skipDaemonBootstrap: skipDaemonBootstrap
skipDaemonBootstrap: skipDaemonBootstrap,
sshControlMasterLeaseGeneration: sshControlMasterLeaseGeneration
)
}

/// Compares user-visible connection settings while ignoring the runtime lease generation.
public static func == (lhs: Self, rhs: Self) -> Bool {
lhs.transport == rhs.transport &&
lhs.destination == rhs.destination &&
lhs.port == rhs.port &&
lhs.identityFile == rhs.identityFile &&
lhs.sshOptions == rhs.sshOptions &&
lhs.localProxyPort == rhs.localProxyPort &&
lhs.relayPort == rhs.relayPort &&
lhs.relayID == rhs.relayID &&
lhs.relayToken == rhs.relayToken &&
lhs.localSocketPath == rhs.localSocketPath &&
lhs.ownerWorkspaceID == rhs.ownerWorkspaceID &&
lhs.managedCloudVMID == rhs.managedCloudVMID &&
lhs.terminalStartupCommand == rhs.terminalStartupCommand &&
lhs.foregroundAuthToken == rhs.foregroundAuthToken &&
lhs.agentSocketPath == rhs.agentSocketPath &&
lhs.daemonWebSocketEndpoint == rhs.daemonWebSocketEndpoint &&
lhs.preserveAfterTerminalExit == rhs.preserveAfterTerminalExit &&
lhs.persistentDaemonSlot == rhs.persistentDaemonSlot &&
lhs.skipDaemonBootstrap == rhs.skipDaemonBootstrap
}

/// Resolves the SSH agent socket to use for a remote configuration from an explicit socket or durable options.
public static func resolvedAgentSocketPath(
sshOptions: [String],
Expand Down Expand Up @@ -291,6 +323,32 @@ public struct WorkspaceRemoteConfiguration: Equatable, Sendable {
skipDaemonBootstrap: skipDaemonBootstrap
)
}

/// Returns a copy carrying the broker generation for one native-SSH lease.
public func withSSHControlMasterLeaseGeneration(_ generation: UUID) -> WorkspaceRemoteConfiguration {
WorkspaceRemoteConfiguration(
transport: transport,
destination: destination,
port: port,
identityFile: identityFile,
sshOptions: sshOptions,
localProxyPort: localProxyPort,
relayPort: relayPort,
relayID: relayID,
relayToken: relayToken,
localSocketPath: localSocketPath,
ownerWorkspaceID: ownerWorkspaceID,
managedCloudVMID: managedCloudVMID,
terminalStartupCommand: terminalStartupCommand,
foregroundAuthToken: foregroundAuthToken,
agentSocketPath: agentSocketPath,
daemonWebSocketEndpoint: daemonWebSocketEndpoint,
preserveAfterTerminalExit: preserveAfterTerminalExit,
persistentDaemonSlot: persistentDaemonSlot,
skipDaemonBootstrap: skipDaemonBootstrap,
sshControlMasterLeaseGeneration: generation
)
}
}

extension WorkspaceRemoteConfiguration {
Expand Down
Loading