diff --git a/CLI/CMUXCLI+SSHConnectionSharing.swift b/CLI/CMUXCLI+SSHConnectionSharing.swift index 71195d5478a1..90c7611eea41 100644 --- a/CLI/CMUXCLI+SSHConnectionSharing.swift +++ b/CLI/CMUXCLI+SSHConnectionSharing.swift @@ -5,7 +5,10 @@ extension CMUXCLI { func resolvedUserSSHControlOptions(for options: SSHCommandOptions) -> [String]? { guard let output = resolvedSSHConfigurationOutput(for: options) else { return nil } return SSHConnectionSharingOptions() - .userConfiguredControlOptions(fromSSHConfigOutput: output) + .userConfiguredControlOptions( + fromSSHConfigOutput: output, + explicitOptions: options.sshOptions + ) } func resolvedCmuxControlPathOptions(for options: SSHCommandOptions) -> [String] { @@ -38,9 +41,13 @@ extension CMUXCLI { func resolvedSSHConfigurationResult( for options: SSHCommandOptions, - timeout: TimeInterval = 2 + timeout: TimeInterval = 2, + configurationFile: String? = nil ) -> CLIProcessResult { var arguments = ["-G"] + if let configurationFile { + arguments += ["-F", configurationFile] + } if let port = options.port { arguments += ["-p", String(port)] } diff --git a/CLI/cmux.swift b/CLI/cmux.swift index bf17f8ca14dd..18a5ac5f9471 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -4721,6 +4721,9 @@ struct CMUXCLI { if normalizedCommand == "surface", commandArgs.first?.lowercased() == "resume" { return false } + if Self.commandDefersSocketConnectionUntilRequest(command: command, commandArgs: commandArgs) { + return false + } return true } @@ -5244,6 +5247,10 @@ struct CMUXCLI { ) try validateWorkspaceLoadingCommandBeforeSocket(command: command, commandArgs: commandArgs) var client = SocketClient(path: resolvedSocketPath) + let defersSocketConnection = Self.commandDefersSocketConnectionUntilRequest( + command: command, + commandArgs: commandArgs + ) let cursorHookSocketTimeout: TimeInterval? = isCursorShellHookCommand ? 0.35 : nil let cursorHookDeadline: Date? = isCursorShellHookCommand ? Date.now.addingTimeInterval(3.0) @@ -5257,20 +5264,24 @@ struct CMUXCLI { ] ) } - cliTelemetry.breadcrumb( - "socket.connect.attempt", - data: [ - "command": command, - "path": resolvedSocketPath - ] - ) + if !defersSocketConnection { + cliTelemetry.breadcrumb( + "socket.connect.attempt", + data: [ + "command": command, + "path": resolvedSocketPath + ] + ) + } do { - if let cursorHookDeadline { - try client.connect(deadline: cursorHookDeadline) - } else { - try client.connect() + if !defersSocketConnection { + if let cursorHookDeadline { + try client.connect(deadline: cursorHookDeadline) + } else { + try client.connect() + } + cliTelemetry.breadcrumb("socket.connect.success", data: ["path": resolvedSocketPath]) } - cliTelemetry.breadcrumb("socket.connect.success", data: ["path": resolvedSocketPath]) } catch { cliTelemetry.breadcrumb("socket.connect.failure", data: ["path": resolvedSocketPath]) cliTelemetry.captureError(stage: "socket_connect", error: error) @@ -5312,13 +5323,21 @@ struct CMUXCLI { } defer { client.close() } - try authenticateClientIfNeeded( - client, - explicitPassword: socketPasswordArg, - socketPath: resolvedSocketPath, - responseTimeout: cursorHookSocketTimeout, - deadline: cursorHookDeadline - ) + if defersSocketConnection { + // send/sendV2 connects and authenticates immediately before the first request. + client.configureAuthentication(password: SocketPasswordResolver.resolve( + explicit: socketPasswordArg, + socketPath: resolvedSocketPath + )) + } else { + try authenticateClientIfNeeded( + client, + explicitPassword: socketPasswordArg, + socketPath: resolvedSocketPath, + responseTimeout: cursorHookSocketTimeout, + deadline: cursorHookDeadline + ) + } let idFormat = try resolvedIDFormat(jsonOutput: jsonOutput, raw: idFormatArg) // Workspace inspection JSON is a scripting boundary: keep stable UUIDs @@ -8137,6 +8156,16 @@ struct CMUXCLI { return FileManager.default.fileExists(atPath: resolvePath(arg)) } + /// These VM handlers finish local planning and validation before their first request. + private static func commandDefersSocketConnectionUntilRequest( + command: String, + commandArgs: [String] + ) -> Bool { + guard command == "vm" || command == "cloud", + let subcommand = commandArgs.first?.lowercased() else { return false } + return ["dev", "layout", "env"].contains(subcommand) + } + /// Returns whether a command can reach its own dispatch path without a live /// implicit socket. Commands that launch cmux or only touch local state must /// validate their arguments before discovery reports a transport failure. @@ -8145,7 +8174,8 @@ struct CMUXCLI { commandArgs: [String], environment: [String: String] ) -> Bool { - if commandCanLaunchAppWhenSocketUnavailable(command) { + if commandCanLaunchAppWhenSocketUnavailable(command) + || Self.commandDefersSocketConnectionUntilRequest(command: command, commandArgs: commandArgs) { return true } @@ -11512,6 +11542,19 @@ struct CMUXCLI { ) let resolvedUserSSHConfiguration = configurationResult.status == 0 ? configurationResult.stdout : nil + let resolvedOpenSSHDefaults: String? + if configurationResult.status == 0 { + let defaultConfigurationResult = resolvedSSHConfigurationResult( + for: sshOptions, + timeout: configurationTimeout, + configurationFile: "/dev/null" + ) + resolvedOpenSSHDefaults = defaultConfigurationResult.status == 0 + ? defaultConfigurationResult.stdout + : nil + } else { + resolvedOpenSSHDefaults = nil + } let fallsBackToOpenSSHInteractiveSession = usesImplicitManagedInteractiveShell && resolvedUserSSHConfiguration == nil let effectiveTerminalTransport: WorkspaceRemoteTerminalTransport = @@ -11526,7 +11569,11 @@ struct CMUXCLI { sshOptions.sshOptions = sharingOptions.mergingDefaults( into: inputSSHOptions.sshOptions, userConfiguredControlOptions: resolvedUserSSHConfiguration.flatMap { - sharingOptions.userConfiguredControlOptions(fromSSHConfigOutput: $0) + sharingOptions.userConfiguredControlOptions( + fromSSHConfigOutput: $0, + baselineSSHConfigOutput: resolvedOpenSSHDefaults, + explicitOptions: inputSSHOptions.sshOptions + ) } ) if resolvedUserSSHConfiguration != nil { @@ -13535,10 +13582,19 @@ struct CMUXCLI { retryLimit: Int, retryDelaySeconds: Double ) -> String { - let retryText = String( - localized: "cli.vm.sshInfo.retry.status", - defaultValue: "Retrying in \(Self.retryDelayLabel(retryDelaySeconds)) (\(Self.retryAttemptLabel(attempt: attempt, retryLimit: retryLimit)))." - ) + let retryAttempt = Self.retryAttemptLabel(attempt: attempt, retryLimit: retryLimit) + let retryText: String + if retryDelaySeconds <= 0 { + retryText = String( + localized: "cli.vm.sshInfo.retry.nowStatus", + defaultValue: "Retrying now (attempt \(retryAttempt))." + ) + } else { + retryText = String( + localized: "cli.vm.sshInfo.retry.status", + defaultValue: "Retrying in \(Self.retryDelayLabel(retryDelaySeconds))s (attempt \(retryAttempt))." + ) + } let errorText = String(describing: error) if Self.isLocalCloudVMServiceUnreachable(errorText), let url = Self.firstHTTPURL(in: errorText) { @@ -13564,19 +13620,16 @@ struct CMUXCLI { private static func retryAttemptLabel(attempt: Int, retryLimit: Int) -> String { if retryLimit >= 86_400 { - return "attempt \(attempt)" + return "\(attempt)" } - return "attempt \(attempt)/\(retryLimit)" + return "\(attempt)/\(retryLimit)" } private static func retryDelayLabel(_ seconds: Double) -> String { - if seconds <= 0 { - return String(localized: "cli.vm.sshInfo.retry.now", defaultValue: "now") - } if seconds.rounded(.towardZero) == seconds { - return "\(Int(seconds))s" + return "\(Int(seconds))" } - return String(format: "%.1fs", seconds) + return String(format: "%.1f", seconds) } private static func isLocalCloudVMServiceUnreachable(_ message: String) -> Bool { diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHConnectionSharingOptions.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHConnectionSharingOptions.swift index f64b5c37bf6e..7dae3c5fcfa5 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHConnectionSharingOptions.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHConnectionSharingOptions.swift @@ -65,15 +65,14 @@ public struct SSHConnectionSharingOptions: Sendable { /// Adds sharing defaults while honoring effective control settings from /// the user's SSH configuration. /// - /// Explicit caller options retain highest precedence. When the caller did - /// not provide any control option and `ssh -G` reported non-default - /// control settings, those effective values are carried forward instead - /// of installing cmux's socket. + /// Explicit caller options retain highest precedence per key. Independently + /// configured host control settings fill the remaining keys instead of + /// installing cmux's socket. /// /// - Parameters: /// - options: Explicit OpenSSH `-o` values. /// - userConfiguredControlOptions: Effective custom values parsed by - /// ``userConfiguredControlOptions(fromSSHConfigOutput:)``. + /// ``userConfiguredControlOptions(fromSSHConfigOutput:explicitOptions:)``. /// - Returns: Effective explicit options for native SSH commands. public func mergingDefaults( into options: [String], @@ -136,24 +135,57 @@ public struct SSHConnectionSharingOptions: Sendable { /// cmux control options are added. /// - Returns: Effective custom `-o` values, or `nil` for OpenSSH defaults. public func userConfiguredControlOptions(fromSSHConfigOutput output: String) -> [String]? { - var values: [String: String] = [:] - for line in output.split(whereSeparator: \.isNewline) { - let parts = line.split(maxSplits: 1, whereSeparator: \.isWhitespace) - guard parts.count == 2 else { continue } - let key = parts[0].lowercased() - guard ["controlmaster", "controlpath", "controlpersist"].contains(key) else { - continue - } - values[key] = parts[1].trimmingCharacters(in: .whitespacesAndNewlines) - } + userConfiguredControlOptions(fromSSHConfigOutput: output, explicitOptions: []) + } + + /// Parses resolved host control settings with the explicit caller options + /// that were included in the `ssh -G` invocation. + /// + /// Explicit values do not prove host customization: OpenSSH includes them in + /// its output and normalizes `ControlPersist=0` to `yes`. A custom value on + /// another control key still preserves the host's full effective settings, + /// with explicit options retaining precedence when merged. + /// + /// - Parameters: + /// - output: Effective configuration reported by OpenSSH. + /// - explicitOptions: Caller-provided `-o` values included in that output. + /// - Returns: Effective custom host control settings, or `nil` for defaults. + public func userConfiguredControlOptions( + fromSSHConfigOutput output: String, + explicitOptions: [String] + ) -> [String]? { + userConfiguredControlOptions( + fromSSHConfigOutput: output, + baselineSSHConfigOutput: nil, + explicitOptions: explicitOptions + ) + } + + /// Parses resolved host control settings against OpenSSH's built-in + /// defaults. Comparing with a `-F /dev/null` baseline distinguishes an + /// explicit host `ControlMaster=no` from the ordinary default `false`. + public func userConfiguredControlOptions( + fromSSHConfigOutput output: String, + baselineSSHConfigOutput: String?, + explicitOptions: [String] + ) -> [String]? { + let values = controlConfigurationValues(fromSSHConfigOutput: output) + let baselineValues = baselineSSHConfigOutput.map(controlConfigurationValues(fromSSHConfigOutput:)) // Keep the fallback explicitly disabled if an OpenSSH version omits default-valued keys. let controlMaster = values["controlmaster"] ?? "false" let controlPath = values["controlpath"] ?? "none" let controlPersist = values["controlpersist"] ?? "no" - let hasCustomValue = !isDisabled(controlMaster) - || controlPath.lowercased() != "none" - || !["no", "false", "off", "0"].contains(controlPersist.lowercased()) + let resolver = SSHAgentSocketResolver() + let defaultValues = baselineValues ?? [ + "controlmaster": "false", + "controlpath": "none", + "controlpersist": "no", + ] + let hasCustomValue = ["controlmaster", "controlpath", "controlpersist"].contains { key in + guard !resolver.hasOptionKey(explicitOptions, key: key) else { return false } + return values[key]?.lowercased() != defaultValues[key]?.lowercased() + } guard hasCustomValue else { return nil } return [ "ControlMaster=\(controlMaster)", @@ -162,6 +194,20 @@ public struct SSHConnectionSharingOptions: Sendable { ] } + private func controlConfigurationValues(fromSSHConfigOutput output: String) -> [String: String] { + var values: [String: String] = [:] + for line in output.split(whereSeparator: \.isNewline) { + let parts = line.split(maxSplits: 1, whereSeparator: \.isWhitespace) + guard parts.count == 2 else { continue } + let key = parts[0].lowercased() + guard ["controlmaster", "controlpath", "controlpersist"].contains(key) else { + continue + } + values[key] = parts[1].trimmingCharacters(in: .whitespacesAndNewlines) + } + return values + } + /// Returns the configured `ControlPath` when it is one of cmux's native /// SSH templates, including the older relay-port-scoped template so an /// upgraded app can still clean up a socket it created. diff --git a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHConnectionSharingOptionsTests.swift b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHConnectionSharingOptionsTests.swift index 2326b100a16c..2fc2323bdfda 100644 --- a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHConnectionSharingOptionsTests.swift +++ b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHConnectionSharingOptionsTests.swift @@ -148,6 +148,39 @@ struct SSHConnectionSharingOptionsTests { ).contains("ControlPath=/tmp/cmux-ssh-501-%C")) } + @Test("An explicit host opt-out differs from OpenSSH defaults") + func detectsExplicitHostOptOutAgainstBaseline() { + let output = """ + controlmaster false + controlpath none + controlpersist no + """ + let baseline = """ + controlmaster false + controlpath none + controlpersist no + """ + #expect(options.userConfiguredControlOptions( + fromSSHConfigOutput: output, + baselineSSHConfigOutput: baseline, + explicitOptions: [] + ) == nil) + let configured = """ + controlmaster no + controlpath none + controlpersist no + """ + #expect(options.userConfiguredControlOptions( + fromSSHConfigOutput: configured, + baselineSSHConfigOutput: baseline, + explicitOptions: [] + ) == [ + "ControlMaster=no", + "ControlPath=none", + "ControlPersist=no", + ]) + } + @Test("Explicit CLI control options win per key over resolved ssh_config settings") func explicitOptionsWinOverResolvedConfiguration() { let configured = [ diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 6fd132f536b1..02bcec481041 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -529703,6 +529703,254 @@ } } } + }, + "cli.vm.sshInfo.retry.status": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إعادة المحاولة خلال %@ (المحاولة %@)." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Erneuter Versuch in %@ s (Versuch %@)." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Retrying in %@s (attempt %@)." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Reintentando en %@ s (intento %@)." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Nouvelle tentative dans %@ s (tentative %@)." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@秒後に再試行します(試行 %@)。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@초 후에 다시 시도합니다(시도 %@)." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "将在 %@ 秒后重试(第 %@ 次)。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "將於 %@ 秒後重試(第 %@ 次)。" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ponovni pokušaj za %@ sekundi (pokušaj %@)." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Prøver igen om %@ sek. (forsøg %@)." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Nuovo tentativo tra %@ s (tentativo %@)." + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "កំពុងព្យាយាមម្តងទៀតក្នុង %@ វិនាទី (លើកទី %@)។" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Prøver igjen om %@ sek. (forsøk %@)." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Ponawianie za %@ s (próba %@)." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Tentando novamente em %@ s (tentativa %@)." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Повтор через %@ с (попытка %@)." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "กำลังลองอีกครั้งใน %@ วินาที (ครั้งที่ %@)" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "%@ sn. içinde yeniden deneniyor (deneme %@)." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Повторна спроба через %@ с (спроба %@)." + } + } } + }, + "cli.vm.sshInfo.retry.nowStatus": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إعادة المحاولة الآن (المحاولة %@)." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Jetzt erneut versuchen (Versuch %@)." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Retrying now (attempt %@)." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Reintentando ahora (intento %@)." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Nouvelle tentative maintenant (tentative %@)." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "今すぐ再試行します(試行 %@)。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "지금 다시 시도합니다(시도 %@)." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "立即重试(第 %@ 次)。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "立即重試(第 %@ 次)。" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Pokušaj ponovo sada (pokušaj %@)." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Prøver igen nu (forsøg %@)." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Nuovo tentativo ora (tentativo %@)." + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "កំពុងព្យាយាមម្តងទៀតឥឡូវនេះ (លើកទី %@)។" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Prøver igjen nå (forsøk %@)." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Ponawianie teraz (próba %@)." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Tentando novamente agora (tentativa %@)." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Повтор сейчас (попытка %@)." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "กำลังลองอีกครั้งตอนนี้ (ครั้งที่ %@)" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Şimdi yeniden deneniyor (deneme %@)." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Повторна спроба зараз (спроба %@)." + } + } } } }, "version": "1.0"