Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
4c5bf40
test: keep SSH relay off shared ControlMaster (#8894)
austinywang Jul 29, 2026
54adb51
fix: give SSH reverse relay an app-owned transport (#8894)
austinywang Jul 29, 2026
13a3d1d
test: prove dedicated relay startup is reached (#8894)
austinywang Jul 29, 2026
6acd196
test: observe relay startup in regression scope (#8894)
austinywang Jul 29, 2026
448157c
Merge remote-tracking branch 'origin/main' into issue-8894-ssh-relay-…
austinywang Jul 29, 2026
f2acd12
test: capture dedicated reverse relay launch argv (#8894)
austinywang Jul 29, 2026
4a66fd0
fix: cancel inherited SSH relay forwards (#8894)
austinywang Jul 29, 2026
157240f
Merge remote-tracking branch 'origin/main' into issue-8894-ssh-relay-…
austinywang Jul 29, 2026
dd17886
test: remove reverse relay launch seam (#8894)
austinywang Jul 29, 2026
3917d9b
fix: keep relay cleanup off coordinator queue (#8894)
austinywang Jul 29, 2026
0999730
Merge remote-tracking branch 'origin/main' into issue-8894-ssh-relay-…
austinywang Jul 29, 2026
5c99979
fix: recover relay from inherited ControlMaster lease (#8894)
austinywang Jul 29, 2026
f86b152
Merge remote-tracking branch 'origin/main' into issue-8894-ssh-relay-…
austinywang Jul 29, 2026
9ddb4a9
docs: justify synchronous relay cancellation bridge (#8894)
austinywang Jul 29, 2026
0f4abba
test: isolate relay recovery and sanitize status (#8894)
austinywang Jul 29, 2026
04cb2c5
test: cover successful relay conflict recovery (#8894)
austinywang Jul 29, 2026
3272ae6
test: isolate reverse relay recovery launch (#8894)
austinywang Jul 29, 2026
5bb3f2f
Merge remote-tracking branch 'origin/main' into issue-8894-ssh-relay-…
austinywang Jul 29, 2026
946cea6
fix: recover relay through the shared SSH master (#8894)
austinywang Jul 29, 2026
dbfb2da
fix: coordinate conflicted SSH master recovery
austinywang Jul 29, 2026
93abf0b
fix: make shared master reset recoverable
austinywang Jul 29, 2026
dc3de78
fix: scope unresolved master reset events
austinywang Jul 29, 2026
3792beb
fix: resolve SSH master paths before reset
austinywang Jul 29, 2026
a656a95
fix: preserve retryable SSH reset state
austinywang Jul 29, 2026
6450e5f
fix: bound reverse relay startup lifecycle
austinywang Jul 29, 2026
0bdcb2d
fix: wait for SSH forward confirmation
austinywang Jul 29, 2026
710a641
fix: retain shared SSH reset ownership
austinywang Jul 29, 2026
46d16a7
fix: gate SSH master resets across processes
austinywang Jul 29, 2026
74d6323
fix: preserve SSH master lifecycle invariants
austinywang Jul 29, 2026
b1c1349
fix: close SSH ownership coordination gaps
austinywang Jul 29, 2026
531388f
fix: bound reverse relay termination
austinywang Jul 29, 2026
bff27d0
fix: preserve SSH master recovery identity
austinywang Jul 29, 2026
14e5ab1
fix: cancel only inherited relay forwards
austinywang Jul 29, 2026
7b253c0
Merge remote-tracking branch 'origin/main' into issue-8894-ssh-relay-…
austinywang Jul 29, 2026
009bd2a
fix: retry inherited forward recovery
austinywang Jul 29, 2026
ecdc7dd
fix: bound SSH relay recovery lifecycle
austinywang Jul 29, 2026
69cd315
refactor: isolate SSH recovery lifecycle state
austinywang Jul 29, 2026
3066bfb
Merge origin/main into issue-8894-ssh-relay-controlmaster-lease
austinywang Jul 29, 2026
7c47bdc
Address SSH relay ownership review findings
austinywang Jul 29, 2026
f2a69da
Fix app target remote session import
austinywang Jul 29, 2026
1f69281
fix: expose adopted SSH control path
austinywang Jul 29, 2026
5f4cbed
test: cover rotated relay auth recovery
austinywang Jul 29, 2026
fbc1586
fix: recover rotated persistent relay leases
austinywang Jul 29, 2026
f9f58d3
test: cover inherited SSH master reap
austinywang Jul 29, 2026
fbe642f
fix: reap inherited SSH masters after relay conflicts
austinywang Jul 29, 2026
3b6d7c2
refactor: isolate inherited master reap types
austinywang Jul 29, 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
Original file line number Diff line number Diff line change
Expand Up @@ -710,6 +710,11 @@ extension ControlCommandCoordinator {
"workspace_id": .string(workspaceID.uuidString),
"workspace_ref": ref(.workspace, workspaceID),
]))
case .unavailable(let workspaceID, let message):
return .err(code: "unavailable", message: message, data: .object([
"workspace_id": .string(workspaceID.uuidString),
"workspace_ref": ref(.workspace, workspaceID),
]))
case .resolved(let windowID, let workspaceID, let remoteStatus):
return .ok(.object([
"window_id": orNull(windowID?.uuidString),
Expand Down Expand Up @@ -793,14 +798,21 @@ extension ControlCommandCoordinator {
guard let workspaceID = resolution.workspaceID else {
return .err(code: "invalid_params", message: "Missing workspace_id", data: nil)
}
// Legacy `v2RawString(...)?.trimmingCharacters(...)`: trimmed, but an
// empty string stays "" (NOT nil), so use the raw-trim, not the
// empty-to-nil variant.
let token = rawString(params, "foreground_auth_token")?
.trimmingCharacters(in: .whitespacesAndNewlines)
guard let token = optionalTrimmedRawString(
params,
"foreground_auth_token"
) else {
return .err(
code: "invalid_params",
message: "Missing foreground_auth_token",
data: nil
)
}
let controlPath = optionalTrimmedRawString(params, "control_path")
return workspaceRemoteResult(context?.controlWorkspaceRemoteForegroundAuthReady(
workspaceID: workspaceID,
foregroundAuthToken: token
foregroundAuthToken: token,
resolvedControlPath: controlPath
))
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -230,10 +230,12 @@ public protocol ControlWorkspaceContext: AnyObject {
/// - Parameters:
/// - workspaceID: The resolved workspace id.
/// - foregroundAuthToken: The trimmed token, if any.
/// - resolvedControlPath: Exact cmux-owned socket authenticated by SSH.
/// - Returns: The remote resolution.
func controlWorkspaceRemoteForegroundAuthReady(
workspaceID: UUID,
foregroundAuthToken: String?
foregroundAuthToken: String?,
resolvedControlPath: String?
) -> ControlWorkspaceRemoteResolution

/// Reads remote status for `workspace.remote.status`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ public enum ControlWorkspaceRemoteResolution: Sendable, Equatable {
/// "Remote workspace is not configured", `reconnect` only). Carries the
/// resolved workspace id for that payload.
case notConfigured(workspaceID: UUID)
/// The requested ownership handoff could not be acquired without
/// disrupting another live cmux process.
case unavailable(workspaceID: UUID, message: String)
/// The mutation succeeded. Carries the owning window id (may be absent), the
/// resolved workspace id, and the bridged `remoteStatusPayload()`.
case resolved(windowID: UUID?, workspaceID: UUID, remoteStatus: JSONValue)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -346,7 +346,8 @@ extension ControlWorkspaceContext {

func controlWorkspaceRemoteForegroundAuthReady(
workspaceID: UUID,
foregroundAuthToken: String?
foregroundAuthToken: String?,
resolvedControlPath: String?
) -> ControlWorkspaceRemoteResolution { .notFound(workspaceID: workspaceID) }

func controlWorkspaceRemoteStatus(workspaceID: UUID) -> ControlWorkspaceRemoteResolution {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -234,4 +234,83 @@ struct ControlCommandCoordinatorWorkspaceTests {
#expect(code == "invalid_params")
#expect(context.terminalSessionEndCall == nil)
}

@Test func foregroundAuthenticationForwardsResolvedControlPath() {
let (coordinator, context) = coordinator()
let workspaceID = UUID()
context.foregroundAuthResolution = .unavailable(
workspaceID: workspaceID,
message: "localized ownership unavailable"
)

guard case .err(let code, let message, _) = coordinator.handle(request(
"workspace.remote.foreground_auth_ready",
[
"workspace_id": .string(workspaceID.uuidString),
"foreground_auth_token": .string(" auth-token "),
"control_path": .string(
" /tmp/cmux-ssh-501-0123456789abcdef "
),
]
)) else {
Issue.record("unexpected foreground-auth result")
return
}

#expect(code == "unavailable")
#expect(message == "localized ownership unavailable")
#expect(context.foregroundAuthCall?.workspaceID == workspaceID)
#expect(context.foregroundAuthCall?.token == "auth-token")
#expect(
context.foregroundAuthCall?.controlPath ==
"/tmp/cmux-ssh-501-0123456789abcdef"
)
}

@Test func foregroundAuthenticationRequiresNonemptyToken() {
let (coordinator, context) = coordinator()
let workspaceID = UUID()
let requests: [[String: JSONValue]] = [
["workspace_id": .string(workspaceID.uuidString)],
[
"workspace_id": .string(workspaceID.uuidString),
"foreground_auth_token": .string(" \n "),
],
]

for params in requests {
guard case .err(let code, let message, _) =
coordinator.handle(request(
"workspace.remote.foreground_auth_ready",
params
)) else {
Issue.record("missing foreground-auth token was accepted")
continue
}
#expect(code == "invalid_params")
#expect(message == "Missing foreground_auth_token")
}
#expect(context.foregroundAuthCall == nil)
}

@Test func foregroundAuthenticationNormalizesBlankControlPath() {
let (coordinator, context) = coordinator()
let workspaceID = UUID()
context.foregroundAuthResolution = .unavailable(
workspaceID: workspaceID,
message: "localized ownership unavailable"
)

_ = coordinator.handle(request(
"workspace.remote.foreground_auth_ready",
[
"workspace_id": .string(workspaceID.uuidString),
"foreground_auth_token": .string("auth-token"),
"control_path": .string(" \n "),
]
))

#expect(context.foregroundAuthCall?.token == "auth-token")
#expect(context.foregroundAuthCall?.controlPath == nil)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ final class FakeWorkspaceControlCommandContext: ControlCommandContext {
workspaceID: UUID, surfaceID: UUID, relayPort: Int?,
sessionID: String?, lifecycleID: String?, lifecycleOnly: Bool
)?
var foregroundAuthResolution:
ControlWorkspaceRemoteResolution = .missingWorkspaceID
var foregroundAuthCall: (
workspaceID: UUID,
token: String?,
controlPath: String?
)?

func controlWindowSummaries() -> [ControlWindowSummary] { [] }
func controlResolveCurrentWindow(routing: ControlRoutingSelectors) -> ControlCurrentWindowResolution {
Expand Down Expand Up @@ -86,4 +93,17 @@ final class FakeWorkspaceControlCommandContext: ControlCommandContext {
terminalSessionEndCall = (workspaceID, surfaceID, relayPort, sessionID, lifecycleID, lifecycleOnly)
return terminalSessionEndResolution
}

func controlWorkspaceRemoteForegroundAuthReady(
workspaceID: UUID,
foregroundAuthToken: String?,
resolvedControlPath: String?
) -> ControlWorkspaceRemoteResolution {
foregroundAuthCall = (
workspaceID,
foregroundAuthToken,
resolvedControlPath
)
return foregroundAuthResolution
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -56,42 +56,66 @@ extension WorkspaceRemoteConfiguration {

/// `ssh -O <controlCommand>` argv that drives a reverse forward on the
/// configured ControlMaster socket, or `nil` when no usable `ControlPath`
/// option is configured. Argument text is wire/process behavior; do not
/// alter.
/// option is present in the effective SSH options.
///
/// - Parameters:
/// - controlCommand: OpenSSH multiplexing command, such as `forward` or `cancel`.
/// - forwardSpec: Exact reverse-forward specification.
/// - effectiveSSHOptions: Options used by the foreground SSH connection.
/// - Returns: Arguments for `/usr/bin/ssh`, or `nil` without a usable control socket.
public func reverseRelayControlMasterArguments(
controlCommand: String,
forwardSpec: String
forwardSpec: String,
effectiveSSHOptions: [String]
) -> [String]? {
guard let controlPath = firstSSHOptionValue(named: "ControlPath")?
if let controlMaster = Self.firstSSHOptionValue(
named: "ControlMaster",
in: effectiveSSHOptions
)?.lowercased(),
["no", "false", "off", "0"].contains(controlMaster) {
return nil
}
guard let controlPath = Self.firstSSHOptionValue(
named: "ControlPath",
in: effectiveSSHOptions
)?
.trimmingCharacters(in: .whitespacesAndNewlines),
!controlPath.isEmpty,
controlPath.lowercased() != "none" else {
return nil
}

var args = batchSSHArguments()
args += ["-O", controlCommand, "-R", forwardSpec, destination]
return args
var arguments = batchSSHArguments(sshOptions: effectiveSSHOptions)
arguments += ["-O", controlCommand, "-R", forwardSpec, destination]
return arguments
}

/// ``reverseRelayControlMasterArguments(controlCommand:forwardSpec:)``
/// specialized to `-O cancel` for the relay's remote listen port, or
/// `nil` for a non-positive port. Argument text is wire/process behavior;
/// do not alter.
public func reverseRelayControlMasterCancelArguments(relayPort: Int) -> [String]? {
guard relayPort > 0 else { return nil }
return reverseRelayControlMasterArguments(
controlCommand: "cancel",
forwardSpec: "127.0.0.1:\(relayPort)"
)
/// Builds a non-interactive command that reuses the supplied exact ControlPath.
///
/// - Parameters:
/// - command: Remote shell command to execute.
/// - effectiveSSHOptions: Options carrying the authenticated ControlPath.
/// - Returns: Arguments for `/usr/bin/ssh`.
public func batchSSHCommandArguments(
command: String,
effectiveSSHOptions: [String]
) -> [String] {
["-T"]
+ SSHHostConfiguredRemoteCommand().overrideArguments
+ batchSSHArguments(sshOptions: effectiveSSHOptions)
+ ["-o", "RequestTTY=no", destination, command]
}

// Shared batch-mode `ssh` options: keepalives, BatchMode, no new
// ControlMaster (existing ControlPath sockets may be reused), port,
// identity, then the configuration's options minus
// ControlMaster/ControlPersist.
private func batchSSHArguments() -> [String] {
let effectiveSSHOptions = backgroundSSHOptions()
batchSSHArguments(sshOptions: sshOptions)
}

private func batchSSHArguments(sshOptions: [String]) -> [String] {
let effectiveSSHOptions = backgroundSSHOptions(sshOptions)
var args: [String] = [
"-o", "ConnectTimeout=6",
"-o", "ServerAliveInterval=20",
Expand All @@ -118,22 +142,21 @@ extension WorkspaceRemoteConfiguration {

// Trimmed options minus ControlMaster/ControlPersist (ControlPath is
// kept so batch helpers can reuse an existing master's socket).
private func backgroundSSHOptions() -> [String] {
private func backgroundSSHOptions(_ options: [String]) -> [String] {
let resolver = SSHAgentSocketResolver()
return Self.trimmedSSHOptions(sshOptions).filter { option in
return Self.trimmedSSHOptions(options).filter { option in
guard let key = resolver.optionKey(option) else { return false }
return !Self.batchSSHControlOptionKeys.contains(key)
}
}

// First non-empty value for an option key, scanning forward. This
// deliberately differs from SSHAgentSocketResolver.optionValue(named:in:)
// (which scans in reverse for OpenSSH last-wins semantics): the legacy
// batch builder used first-match and the reverse-relay behavior is pinned
// to it.
private func firstSSHOptionValue(named key: String) -> String? {
// OpenSSH uses the first obtained value for these command-line options.
private static func firstSSHOptionValue(
named key: String,
in options: [String]
) -> String? {
let loweredKey = key.lowercased()
for option in Self.trimmedSSHOptions(sshOptions) {
for option in trimmedSSHOptions(options) {
let parts = option.split(
maxSplits: 1,
omittingEmptySubsequences: true,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
internal import CmuxFoundation

extension WorkspaceRemoteConfiguration {
/// Returns a copy whose first `ControlPath` option is the exact authenticated socket.
///
/// - Parameter controlPath: Resolved path reported while foreground
/// authentication still owns the ControlMaster.
/// - Returns: A configuration that reuses that exact socket identity.
public func withResolvedSSHControlPath(
_ controlPath: String
) -> WorkspaceRemoteConfiguration {
let resolver = SSHAgentSocketResolver()
let resolvedOptions = ["ControlPath=\(controlPath)"] +
sshOptions.filter {
resolver.optionKey($0) != "controlpath"
}
return WorkspaceRemoteConfiguration(
transport: transport,
terminalTransport: terminalTransport,
terminalProfile: terminalProfile,
destination: destination,
port: port,
identityFile: identityFile,
sshOptions: resolvedOptions,
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:
sshControlMasterLeaseGeneration
)
}
}
Loading