diff --git a/CLI/CMUXCLI+MoshTerminalTransport.swift b/CLI/CMUXCLI+MoshTerminalTransport.swift index 0add02e056f..ef6f42a547b 100644 --- a/CLI/CMUXCLI+MoshTerminalTransport.swift +++ b/CLI/CMUXCLI+MoshTerminalTransport.swift @@ -78,6 +78,14 @@ extension CMUXCLI { remoteMoshProbeFailedMessage: String( localized: "cli.ssh.mosh.probeFailed", defaultValue: "[cmux] Could not verify remote Mosh support; continuing over SSH." + ), + remoteBootstrapInstallFailedMessage: String( + localized: "cli.ssh.mosh.bootstrapInstallFailed", + defaultValue: "[cmux] Remote bootstrap install failed; continuing over SSH." + ), + remoteMoshAddressFallbackMessage: String( + localized: "cli.ssh.mosh.addressFallback", + defaultValue: "[cmux] Remote SSH advertised an unusable address; resolving the Mosh address through the SSH connection." ) ).command() } diff --git a/Packages/macOS/CmuxFoundation/README.md b/Packages/macOS/CmuxFoundation/README.md index 415c62a931c..95d1af6aec7 100644 --- a/Packages/macOS/CmuxFoundation/README.md +++ b/Packages/macOS/CmuxFoundation/README.md @@ -16,6 +16,7 @@ so call sites read naturally (`value.javaScriptStringLiteral`, not `f(value)`). - `String.javaScriptStringLiteral` — the string encoded as a quoted JavaScript string literal. - `SSHAgentSocketResolver` — OpenSSH option parsing and SSH agent socket path normalization. - `MoshTerminalCommandBuilder` — a pure Mosh startup-command builder with explicit SSH fallback. +- `MoshRemoteIPMode` — the address-discovery mode selected for a Mosh connection. - `RemoteTmuxCommandBuilder` — shared remote `tmux` resolution and argv preservation. - `WorkspaceRemoteTerminalProfile` — durable shell-or-named-tmux terminal intent. - `WorkspaceRemoteTerminalTransport` — the persisted SSH-or-Mosh interactive terminal preference. @@ -51,7 +52,9 @@ let command = MoshTerminalCommandBuilder( localMoshMissingMessage: "Mosh is unavailable locally; using SSH.", localMoshUnsupportedMessage: "Mosh is too old for shared SSH setup; using SSH.", remoteMoshMissingMessage: "mosh-server is unavailable remotely; using SSH.", - remoteMoshProbeFailedMessage: "Mosh capability check failed; using SSH." + remoteMoshProbeFailedMessage: "Mosh capability check failed; using SSH.", + remoteBootstrapInstallFailedMessage: "Remote bootstrap install failed; using SSH.", + remoteMoshAddressFallbackMessage: "Remote SSH address is unusable; using local Mosh resolution." ).command() ``` diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/MoshRemoteIPMode.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/MoshRemoteIPMode.swift new file mode 100644 index 00000000000..c0ce552dae1 --- /dev/null +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/MoshRemoteIPMode.swift @@ -0,0 +1,15 @@ +/// Selects how Mosh discovers the address used for its UDP session. +/// +/// Production callers always start from ``remote``; the launcher downgrades +/// to ``proxy`` automatically when SSH advertises an unusable address. The +/// other cases exist so tests can pin each generated mode explicitly. +public enum MoshRemoteIPMode: String, Equatable, Sendable { + /// Derive the address from the remote SSH connection when possible. + case remote + + /// Resolve the destination locally before starting the Mosh server. + case local + + /// Resolve the address through Mosh's local proxy path. + case proxy +} diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/MoshTerminalCommandBuilder.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/MoshTerminalCommandBuilder.swift index 6de38d3e355..c6131108aa2 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/MoshTerminalCommandBuilder.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/MoshTerminalCommandBuilder.swift @@ -12,6 +12,7 @@ public struct MoshTerminalCommandBuilder: Sendable { private let destination: String private let remoteCommandArguments: [String] private let remoteRelayPort: Int? + private let remoteIPMode: MoshRemoteIPMode private let preparationShellScript: String? private let managementReadyShellScript: String? private let sshFallbackCommand: String @@ -19,6 +20,8 @@ public struct MoshTerminalCommandBuilder: Sendable { private let localMoshUnsupportedMessage: String private let remoteMoshMissingMessage: String private let remoteMoshProbeFailedMessage: String + private let remoteBootstrapInstallFailedMessage: String + private let remoteMoshAddressFallbackMessage: String /// Creates a Mosh terminal command builder. /// @@ -29,13 +32,16 @@ public struct MoshTerminalCommandBuilder: Sendable { /// - destination: SSH destination or host alias. /// - remoteCommandArguments: Optional command argv launched by `mosh-server`. /// - remoteRelayPort: Optional remote relay whose presence enables authoritative lifecycle attempt registration. - /// - preparationShellScript: Optional local preparation run after capability checks. + /// - remoteIPMode: Address-discovery mode passed to Mosh; remote mode falls back to SSH proxy resolution when SSH advertises an unusable address. + /// - preparationShellScript: Optional local preparation run before capability checks. /// - managementReadyShellScript: Optional local callback run after SSH preparation succeeds and before Mosh starts. /// - sshFallbackCommand: Complete local SSH terminal command used when Mosh is unavailable. /// - localMoshMissingMessage: User-facing message printed when no local `mosh` executable exists. /// - localMoshUnsupportedMessage: User-facing message printed when local Mosh lacks the required remote-IP mode. /// - remoteMoshMissingMessage: User-facing message printed when `mosh-server` is absent remotely. /// - remoteMoshProbeFailedMessage: User-facing message printed when the remote capability probe fails. + /// - remoteBootstrapInstallFailedMessage: User-facing message printed when bootstrap staging fails. + /// - remoteMoshAddressFallbackMessage: User-facing message printed when SSH proxy address resolution is selected automatically. public init( capabilityProbeSSHArguments: [String], sessionSSHArguments: [String], @@ -43,13 +49,16 @@ public struct MoshTerminalCommandBuilder: Sendable { destination: String, remoteCommandArguments: [String], remoteRelayPort: Int? = nil, + remoteIPMode: MoshRemoteIPMode = .remote, preparationShellScript: String? = nil, managementReadyShellScript: String? = nil, sshFallbackCommand: String, localMoshMissingMessage: String, localMoshUnsupportedMessage: String, remoteMoshMissingMessage: String, - remoteMoshProbeFailedMessage: String + remoteMoshProbeFailedMessage: String, + remoteBootstrapInstallFailedMessage: String, + remoteMoshAddressFallbackMessage: String ) { self.capabilityProbeSSHArguments = capabilityProbeSSHArguments self.sessionSSHArguments = sessionSSHArguments @@ -57,6 +66,7 @@ public struct MoshTerminalCommandBuilder: Sendable { self.destination = destination self.remoteCommandArguments = remoteCommandArguments self.remoteRelayPort = remoteRelayPort + self.remoteIPMode = remoteIPMode self.preparationShellScript = preparationShellScript self.managementReadyShellScript = managementReadyShellScript self.sshFallbackCommand = sshFallbackCommand @@ -64,6 +74,8 @@ public struct MoshTerminalCommandBuilder: Sendable { self.localMoshUnsupportedMessage = localMoshUnsupportedMessage self.remoteMoshMissingMessage = remoteMoshMissingMessage self.remoteMoshProbeFailedMessage = remoteMoshProbeFailedMessage + self.remoteBootstrapInstallFailedMessage = remoteBootstrapInstallFailedMessage + self.remoteMoshAddressFallbackMessage = remoteMoshAddressFallbackMessage } /// Returns a shell command that launches Mosh or falls back to SSH. @@ -93,11 +105,19 @@ public struct MoshTerminalCommandBuilder: Sendable { ]) .map(\.remoteCommandShellQuoted) .joined(separator: " ") + let remoteSSHConnectionScript = "printf '%s\\n' \"__CMUX_SSH_CONNECTION__${SSH_CONNECTION:-}\"" + let remoteSSHConnectionCommand = "/bin/sh -c \(remoteSSHConnectionScript.remoteCommandShellQuoted)" + let remoteSSHConnectionProbe = (capabilityProbeSSHArguments + [ + "-T", + destination, + remoteSSHConnectionCommand, + ]) + .map(\.remoteCommandShellQuoted) + .joined(separator: " ") let moshSSHCommand = sessionSSHArguments .map(\.remoteCommandShellQuoted) .joined(separator: " ") let moshArguments = ([ - "--experimental-remote-ip=remote", "--ssh=\(moshSSHCommand)", "--server=\(remoteMoshServerResolver.remoteExecPrefixShellCommand)", "--", @@ -123,17 +143,6 @@ public struct MoshTerminalCommandBuilder: Sendable { " ;;", "esac", "unset cmux_mosh_help", - capabilityProbe, - "cmux_mosh_probe_status=$?", - "if [ \"$cmux_mosh_probe_status\" -eq 127 ]; then", - " printf '%s\\n' \(remoteMoshMissingMessage.remoteCommandShellQuoted) >&2", - " cmux_mosh_fallback", - "fi", - "if [ \"$cmux_mosh_probe_status\" -ne 0 ]; then", - " printf '%s\\n' \(remoteMoshProbeFailedMessage.remoteCommandShellQuoted) >&2", - " cmux_mosh_fallback", - "fi", - "unset cmux_mosh_probe_status", ] let reportsTerminalLifecycle = remoteRelayPort.map { (1...65_535).contains($0) } ?? false if reportsTerminalLifecycle { @@ -146,12 +155,68 @@ public struct MoshTerminalCommandBuilder: Sendable { preparationShellScript, "cmux_mosh_prepare_status=$?", "if [ \"$cmux_mosh_prepare_status\" -ne 0 ]; then", - " printf '%s\\n' \(remoteMoshProbeFailedMessage.remoteCommandShellQuoted) >&2", + " printf '%s\\n' \(remoteBootstrapInstallFailedMessage.remoteCommandShellQuoted) >&2", " cmux_mosh_fallback", "fi", "unset cmux_mosh_prepare_status cmux_remote_install_status", ] } + script += [ + "cmux_mosh_remote_ip_mode=\(remoteIPMode.rawValue.remoteCommandShellQuoted)", + "cmux_mosh_address_fallback=0", + ] + script += [ + capabilityProbe, + "cmux_mosh_probe_status=$?", + "if [ \"$cmux_mosh_probe_status\" -eq 127 ]; then", + " printf '%s\\n' \(remoteMoshMissingMessage.remoteCommandShellQuoted) >&2", + " cmux_mosh_fallback", + "fi", + "if [ \"$cmux_mosh_probe_status\" -ne 0 ]; then", + " printf '%s\\n' \(remoteMoshProbeFailedMessage.remoteCommandShellQuoted) >&2", + " cmux_mosh_fallback", + "fi", + ] + if remoteIPMode == .remote { + // Mosh parses SSH_CONNECTION as four space-separated fields with + // numeric ports and uses only the server address for its UDP + // session, so validate exactly that shape and no more. When the + // advertised address is unusable (empty, malformed, wildcard, or + // loopback, which is what a port-forwarded SSH alias reports), + // fall back to Mosh's proxy resolution: unlike local mode it + // honors SSH aliases without requiring DNS on the destination. + script += [ + "cmux_mosh_ssh_connection_probe_status=0", + "cmux_mosh_ssh_connection_probe=\"$(\(remoteSSHConnectionProbe) 2>/dev/null)\" || cmux_mosh_ssh_connection_probe_status=$?", + "case \"$cmux_mosh_ssh_connection_probe\" in *__CMUX_SSH_CONNECTION__*) cmux_mosh_ssh_connection=\"${cmux_mosh_ssh_connection_probe##*__CMUX_SSH_CONNECTION__}\" ;; *) cmux_mosh_ssh_connection= ;; esac", + "if [ \"$cmux_mosh_ssh_connection_probe_status\" -ne 0 ] || [ -z \"$cmux_mosh_ssh_connection\" ]; then", + " cmux_mosh_address_fallback=1", + "else", + " case \"$cmux_mosh_ssh_connection\" in", + " *' '*' '*' '*)", + " cmux_mosh_ssh_connection_tail=\"${cmux_mosh_ssh_connection#* }\"", + " cmux_mosh_ssh_peer_port=\"${cmux_mosh_ssh_connection_tail%% *}\"", + " cmux_mosh_ssh_connection_tail=\"${cmux_mosh_ssh_connection_tail#* }\"", + " cmux_mosh_ssh_server_ip=\"${cmux_mosh_ssh_connection_tail%% *}\"", + " cmux_mosh_ssh_connection_tail=\"${cmux_mosh_ssh_connection_tail#* }\"", + " cmux_mosh_ssh_server_port=\"${cmux_mosh_ssh_connection_tail%% *}\"", + " case \"$cmux_mosh_ssh_peer_port\" in ''|*[!0-9]*) cmux_mosh_address_fallback=1 ;; esac", + " case \"$cmux_mosh_ssh_server_port\" in ''|*[!0-9]*) cmux_mosh_address_fallback=1 ;; esac", + " case \"$cmux_mosh_ssh_server_ip\" in ''|0.0.0.0|::|::0|::1|127.*) cmux_mosh_address_fallback=1 ;; *[!0-9A-Fa-f:.]*) cmux_mosh_address_fallback=1 ;; esac", + " ;;", + " *) cmux_mosh_address_fallback=1 ;;", + " esac", + "fi", + "if [ \"$cmux_mosh_address_fallback\" -eq 1 ]; then cmux_mosh_remote_ip_mode=proxy; fi", + "unset cmux_mosh_ssh_connection_probe_status cmux_mosh_ssh_connection_probe cmux_mosh_ssh_connection cmux_mosh_ssh_peer_port cmux_mosh_ssh_connection_tail cmux_mosh_ssh_server_ip cmux_mosh_ssh_server_port", + ] + } + script += [ + "if [ \"$cmux_mosh_address_fallback\" -eq 1 ]; then", + " printf '%s\\n' \(remoteMoshAddressFallbackMessage.remoteCommandShellQuoted) >&2", + "fi", + "unset cmux_mosh_probe_status cmux_mosh_address_fallback", + ] if let managementReadyShellScript = managementReadyShellScript? .trimmingCharacters(in: .whitespacesAndNewlines), !managementReadyShellScript.isEmpty { @@ -162,7 +227,7 @@ public struct MoshTerminalCommandBuilder: Sendable { } // Mosh exposes no reliable post-UDP-handshake callback, so this // pre-exec launcher must not claim authoritative connected readiness. - script.append("exec \"$cmux_mosh\" \(moshArguments)") + script.append("exec \"$cmux_mosh\" \"--experimental-remote-ip=$cmux_mosh_remote_ip_mode\" \(moshArguments)") return "/bin/sh -c \(script.joined(separator: "\n").remoteCommandShellQuoted)" } diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/RemoteBootstrapStagingCommandBuilder.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/RemoteBootstrapStagingCommandBuilder.swift index 34878d53530..d55a0a4e15c 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/RemoteBootstrapStagingCommandBuilder.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/RemoteBootstrapStagingCommandBuilder.swift @@ -36,15 +36,12 @@ public struct RemoteBootstrapStagingCommandBuilder: Sendable { } /// Local shell code that substitutes runtime IDs and streams the bootstrap over SSH. + /// + /// The SSH command is one `/bin/sh -c` remote-command string so an account's + /// configured login shell cannot parse the POSIX installer itself. public var preparationShellScript: String { let encodedBootstrapScript = Data(bootstrapScript.utf8).base64EncodedString() - let installCommand = ([ - "/bin/sh", - "-c", - remoteInstallShellScript, - ]) - .map(\.remoteCommandShellQuoted) - .joined(separator: " ") + let installCommand = "/bin/sh -c \(remoteInstallShellScript.remoteCommandShellQuoted)" let sshPrefix = installerSSHArguments .map(\.remoteCommandShellQuoted) .joined(separator: " ") @@ -61,21 +58,45 @@ public struct RemoteBootstrapStagingCommandBuilder: Sendable { "cmux_terminal_lifecycle_id_escaped=\"$(cmux_sed_escape \"$cmux_terminal_lifecycle_id\")\"", "cmux_ssh_attempt_id_escaped=\"$(cmux_sed_escape \"$cmux_ssh_attempt_id\")\"", "cmux_remote_bootstrap=\"$(printf '%s' \"$cmux_remote_bootstrap\" | sed \"s/__CMUX_WORKSPACE_ID__/$cmux_workspace_id_escaped/g; s/__CMUX_SURFACE_ID__/$cmux_surface_id_escaped/g; s/__CMUX_TERMINAL_LIFECYCLE_ID__/$cmux_terminal_lifecycle_id_escaped/g; s/__CMUX_SSH_ATTEMPT_ID__/$cmux_ssh_attempt_id_escaped/g\")\"", - "printf '%s' \"$cmux_remote_bootstrap\" | command \(sshPrefix) -T \(destination.remoteCommandShellQuoted) \(installCommand.remoteCommandShellQuoted)", - "cmux_remote_install_status=$?", - "unset cmux_remote_bootstrap cmux_remote_bootstrap_b64 cmux_workspace_id cmux_surface_id cmux_terminal_lifecycle_id cmux_ssh_attempt_id cmux_workspace_id_escaped cmux_surface_id_escaped cmux_terminal_lifecycle_id_escaped cmux_ssh_attempt_id_escaped", + "cmux_remote_install_stderr_file=\"$(mktemp \"${TMPDIR:-/tmp}/cmux-remote-bootstrap-install.XXXXXX\" 2>/dev/null || true)\"", + "if [ -n \"$cmux_remote_install_stderr_file\" ]; then", + " printf '%s' \"$cmux_remote_bootstrap\" | command \(sshPrefix) -T \(destination.remoteCommandShellQuoted) \(installCommand.remoteCommandShellQuoted) 2>\"$cmux_remote_install_stderr_file\"", + " cmux_remote_install_status=$?", + "else", + " printf '%s' \"$cmux_remote_bootstrap\" | command \(sshPrefix) -T \(destination.remoteCommandShellQuoted) \(installCommand.remoteCommandShellQuoted)", + " cmux_remote_install_status=$?", + "fi", + "if [ \"$cmux_remote_install_status\" -ne 0 ] && [ -n \"$cmux_remote_install_stderr_file\" ] && [ -s \"$cmux_remote_install_stderr_file\" ]; then", + " cat \"$cmux_remote_install_stderr_file\" >&2", + "fi", + "rm -f -- \"${cmux_remote_install_stderr_file:-}\" 2>/dev/null || true", + "unset cmux_remote_bootstrap cmux_remote_bootstrap_b64 cmux_workspace_id cmux_surface_id cmux_terminal_lifecycle_id cmux_ssh_attempt_id cmux_workspace_id_escaped cmux_surface_id_escaped cmux_terminal_lifecycle_id_escaped cmux_ssh_attempt_id_escaped cmux_remote_install_stderr_file", "(exit \"$cmux_remote_install_status\")", ].joined(separator: "\n") } /// Small remote shell command that replaces the process with the staged bootstrap. + /// + /// OpenSSH hands its remote command to the account's configured login + /// shell first. Quote the POSIX launcher as the argument to an explicit + /// `/bin/sh -c` so fish/csh/nushell never parse the staged bootstrap + /// command themselves. public var remoteExecutionShellScript: String { - "exec /bin/sh \"$HOME/.cmux/relay/\(remoteRelayPort).bootstrap.sh\"" + "/bin/sh -c \(stagedBootstrapLauncherScript.remoteCommandShellQuoted)" } - /// Remote argv that executes the staged bootstrap. + /// Remote command argv that executes the staged bootstrap. + /// + /// Mosh forwards this argv to `mosh-server`, which executes it with + /// `execvp` and no shell parsing, so the launcher must be real argv + /// elements: a single command string would be treated as a literal + /// executable pathname and fail. public var remoteExecutionCommandArguments: [String] { - ["/bin/sh", "-c", remoteExecutionShellScript] + ["/bin/sh", "-c", stagedBootstrapLauncherScript] + } + + private var stagedBootstrapLauncherScript: String { + "exec /bin/sh \"$HOME/.cmux/relay/\(remoteRelayPort).bootstrap.sh\"" } private var remoteInstallShellScript: String { diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/RemoteExecutableCommandBuilder.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/RemoteExecutableCommandBuilder.swift index 71a2fb4a224..2e8e5777e8d 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/RemoteExecutableCommandBuilder.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/RemoteExecutableCommandBuilder.swift @@ -37,11 +37,15 @@ public struct RemoteExecutableCommandBuilder: Sendable { /// Returns a shell-quoted remote command that resolves and executes argv. /// /// - Parameter arguments: Arguments forwarded to the resolved executable. + /// + /// The returned value is a single `/bin/sh -c` command string. This keeps + /// resolver assignments away from a host-configured fish/csh login shell. /// - Returns: A command suitable for an OpenSSH remote-command string. public func remoteShellCommand(arguments: [String]) -> String { - remoteCommandArguments(arguments: arguments) - .map(\.remoteCommandShellQuoted) - .joined(separator: " ") + Self.remoteShellCommand( + script: Self.executionShellScript, + arguments: ["cmux-remote-executable", executableName, notFoundSentinel] + arguments + ) } /// A shell command that prints the resolved executable path. @@ -49,16 +53,10 @@ public struct RemoteExecutableCommandBuilder: Sendable { /// The command exits 127 and emits the configured sentinel when no /// executable can be found. public var resolutionProbeShellCommand: String { - [ - "/bin/sh", - "-c", - Self.resolutionShellScript, - "cmux-remote-executable", - executableName, - notFoundSentinel, - ] - .map(\.remoteCommandShellQuoted) - .joined(separator: " ") + Self.remoteShellCommand( + script: Self.resolutionShellScript, + arguments: ["cmux-remote-executable", executableName, notFoundSentinel] + ) } /// A command prefix to which a remote launcher may append arguments. @@ -67,16 +65,10 @@ public struct RemoteExecutableCommandBuilder: Sendable { /// those arguments become the resolver's argv and reach the discovered /// server executable unchanged. public var remoteExecPrefixShellCommand: String { - [ - "/bin/sh", - "-c", - Self.executionShellScript, - "cmux-remote-executable", - executableName, - notFoundSentinel, - ] - .map(\.remoteCommandShellQuoted) - .joined(separator: " ") + Self.remoteShellCommand( + script: Self.executionShellScript, + arguments: ["cmux-remote-executable", executableName, notFoundSentinel] + ) } private static let resolutionShellScript = @@ -102,6 +94,11 @@ public struct RemoteExecutableCommandBuilder: Sendable { "eval \"$(/usr/libexec/path_helper -s 2>/dev/null)\"; " + "if command -v \"$cmux_executable_name\" >/dev/null 2>&1; then " + "cmux_executable_path=\"$(command -v \"$cmux_executable_name\")\"; fi; fi; " + + private static func remoteShellCommand(script: String, arguments: [String]) -> String { + let quotedArguments = arguments.map(\.remoteCommandShellQuoted).joined(separator: " ") + return "/bin/sh -c \(script.remoteCommandShellQuoted) \(quotedArguments)" + } } extension String { diff --git a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/MoshTerminalCommandBuilderTests.swift b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/MoshTerminalCommandBuilderTests.swift index 6e6a2d50268..d51e0be02ef 100644 --- a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/MoshTerminalCommandBuilderTests.swift +++ b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/MoshTerminalCommandBuilderTests.swift @@ -81,6 +81,43 @@ struct MoshTerminalCommandBuilderTests { } } + @Test( + "runs remote probes through POSIX sh under a fish login shell", + .enabled(if: MoshTerminalCommandBuilderTests.fishExecutablePath != nil) + ) + func remoteProbeIsShellAgnostic() throws { + let fishPath = try #require(Self.fishExecutablePath) + try withFakeCommands( + sshStatus: 0, + executeRemoteCommand: true, + installRemoteMoshServerOutsidePath: true, + remoteLoginShell: fishPath + ) { directory, environment in + let remoteHome = directory.appendingPathComponent("remote-home", isDirectory: true) + let staging = try #require(RemoteBootstrapStagingCommandBuilder( + installerSSHArguments: ["ssh", "-o", "RemoteCommand=none"], + destination: "user@example.com", + remoteRelayPort: 52_263, + bootstrapScript: "printf '%s\\n' fish-bootstrap" + )) + let result = try run( + builder( + preparationShellScript: staging.preparationShellScript, + remoteRelayPort: 52_263 + ), + environment: environment + ) + + #expect(result.status == 0) + #expect(result.stdout.isEmpty) + #expect(result.stderr.isEmpty) + #expect(FileManager.default.fileExists(atPath: directory.appendingPathComponent("mosh.args").path)) + #expect(FileManager.default.fileExists( + atPath: remoteHome.appendingPathComponent(".cmux/relay/52263.bootstrap.sh").path + )) + } + } + @Test("preserves the Mosh SSH bootstrap and remote command argv") func supportedMoshPreservesArguments() throws { try withFakeCommands(sshStatus: 0) { directory, environment in @@ -89,10 +126,18 @@ struct MoshTerminalCommandBuilderTests { decoding: try Data(contentsOf: directory.appendingPathComponent("mosh.args")), as: UTF8.self ).split(separator: "\n", omittingEmptySubsequences: false).dropLast().map(String.init) - let probeArguments = String( + let probeOutput = String( decoding: try Data(contentsOf: directory.appendingPathComponent("ssh.args")), as: UTF8.self - ).split(separator: "\n", omittingEmptySubsequences: false).dropLast().map(String.init) + ) + let probeInvocations = probeOutput + .components(separatedBy: "__CMUX_SSH_INVOCATION_END__\n") + .filter { !$0.isEmpty } + .map { $0.split(separator: "\n").map(String.init) } + let capabilityProbeArguments = try #require( + probeInvocations.first(where: { $0.last?.contains("mosh-server") == true }) + ) + let probeArguments = try #require(probeInvocations.first) #expect(result.status == 0) #expect(result.stdout.isEmpty) @@ -100,8 +145,8 @@ struct MoshTerminalCommandBuilderTests { #expect(Array(probeArguments.prefix(4)) == [ "-o", "RemoteCommand=none", "-T", "user@example.com", ]) - #expect(probeArguments.last?.contains("mosh-server") == true) - #expect(probeArguments.last?.contains("$HOME/.local/bin") == true) + #expect(capabilityProbeArguments.last?.contains("$HOME/.local/bin") == true) + #expect(probeInvocations.contains(where: { $0.last?.contains("SSH_CONNECTION") == true })) #expect(moshArguments[0] == "--experimental-remote-ip=remote") #expect(moshArguments[1] == "--ssh='ssh' '-o' 'RemoteCommand=none' '-p' '2222'") #expect(moshArguments[2].hasPrefix("--server=")) @@ -159,14 +204,177 @@ struct MoshTerminalCommandBuilderTests { let result = try run( builder( sshFallbackCommand: "printf 'ssh fallback\\n'", - preparationShellScript: "false" + preparationShellScript: "printf '%s\\n' 'bootstrap install stderr' >&2; false" ), environment: environment ) #expect(result.status == 0) #expect(result.stdout == "ssh fallback\n") - #expect(result.stderr == "remote probe failed\n") + #expect(result.stderr.contains("remote bootstrap install failed")) + #expect(result.stderr.contains("bootstrap install stderr")) + #expect(!result.stderr.contains("remote probe failed")) + } + } + + @Test("falls back to SSH proxy address resolution when SSH advertises an unusable address") + func unusableRemoteAddressUsesProxyMode() throws { + try withFakeCommands( + sshStatus: 0, + sshConnection: "0.0.0.0 0 0.0.0.0 0" + ) { directory, environment in + let result = try run(builder(), environment: environment) + let moshArguments = try String( + contentsOf: directory.appendingPathComponent("mosh.args"), + encoding: .utf8 + ) + + #expect(result.status == 0) + #expect(moshArguments.firstLine == "--experimental-remote-ip=proxy") + #expect(result.stderr.contains("mosh address fallback engaged")) + } + } + + @Test("falls back to SSH proxy address resolution when SSH_CONNECTION is empty") + func emptyRemoteAddressUsesProxyMode() throws { + try withFakeCommands(sshStatus: 0, sshConnection: "") { directory, environment in + let result = try run(builder(), environment: environment) + let moshArguments = try String( + contentsOf: directory.appendingPathComponent("mosh.args"), + encoding: .utf8 + ) + + #expect(result.status == 0) + #expect(moshArguments.firstLine == "--experimental-remote-ip=proxy") + #expect(result.stderr.contains("mosh address fallback engaged")) + } + } + + @Test("falls back to SSH proxy address resolution when SSH advertises a loopback server address") + func loopbackServerAddressUsesProxyMode() throws { + try withFakeCommands( + sshStatus: 0, + sshConnection: "127.0.0.1 51675 127.0.0.1 22" + ) { directory, environment in + let result = try run(builder(), environment: environment) + let moshArguments = try String( + contentsOf: directory.appendingPathComponent("mosh.args"), + encoding: .utf8 + ) + + #expect(result.status == 0) + #expect(moshArguments.firstLine == "--experimental-remote-ip=proxy") + #expect(result.stderr.contains("mosh address fallback engaged")) + } + } + + @Test("falls back to SSH proxy address resolution when SSH_CONNECTION is truncated") + func truncatedRemoteAddressUsesProxyMode() throws { + try withFakeCommands( + sshStatus: 0, + sshConnection: "192.0.2.10 12345" + ) { directory, environment in + let result = try run(builder(), environment: environment) + let moshArguments = try String( + contentsOf: directory.appendingPathComponent("mosh.args"), + encoding: .utf8 + ) + + #expect(result.status == 0) + #expect(moshArguments.firstLine == "--experimental-remote-ip=proxy") + #expect(result.stderr.contains("mosh address fallback engaged")) + } + } + + @Test("falls back to SSH proxy address resolution when a port is non-numeric") + func nonNumericPortUsesProxyMode() throws { + try withFakeCommands( + sshStatus: 0, + sshConnection: "192.0.2.10 12345 192.0.2.20 ssh" + ) { directory, environment in + let result = try run(builder(), environment: environment) + let moshArguments = try String( + contentsOf: directory.appendingPathComponent("mosh.args"), + encoding: .utf8 + ) + + #expect(result.status == 0) + #expect(moshArguments.firstLine == "--experimental-remote-ip=proxy") + #expect(result.stderr.contains("mosh address fallback engaged")) + } + } + + @Test("falls back to SSH proxy address resolution when the SSH address probe fails") + func addressProbeFailureUsesProxyMode() throws { + try withFakeCommands(sshStatus: 0, sshConnectionStatus: 255) { directory, environment in + let result = try run(builder(), environment: environment) + let moshArguments = try String( + contentsOf: directory.appendingPathComponent("mosh.args"), + encoding: .utf8 + ) + + #expect(result.status == 0) + #expect(moshArguments.firstLine == "--experimental-remote-ip=proxy") + #expect(result.stderr.contains("mosh address fallback engaged")) + } + } + + @Test("keeps remote address resolution when SSH advertises zero ports") + func zeroRemotePortsKeepRemoteMode() throws { + try withFakeCommands( + sshStatus: 0, + sshConnection: "192.0.2.10 0 192.0.2.20 0" + ) { directory, environment in + let result = try run(builder(), environment: environment) + let moshArguments = try String( + contentsOf: directory.appendingPathComponent("mosh.args"), + encoding: .utf8 + ) + + #expect(result.status == 0) + #expect(moshArguments.firstLine == "--experimental-remote-ip=remote") + #expect(result.stderr.isEmpty) + } + } + + @Test("keeps remote address resolution when only the peer address is unusual") + func unusualPeerAddressKeepsRemoteMode() throws { + try withFakeCommands( + sshStatus: 0, + sshConnection: "fe80::1%en0 51675 203.0.113.7 22" + ) { directory, environment in + let result = try run(builder(), environment: environment) + let moshArguments = try String( + contentsOf: directory.appendingPathComponent("mosh.args"), + encoding: .utf8 + ) + + #expect(result.status == 0) + #expect(moshArguments.firstLine == "--experimental-remote-ip=remote") + #expect(result.stderr.isEmpty) + } + } + + @Test("honors an explicit local Mosh address mode") + func explicitLocalMode() throws { + try withFakeCommands(sshStatus: 0) { directory, environment in + let result = try run( + builder(remoteIPMode: .local), + environment: environment + ) + let moshArguments = try String( + contentsOf: directory.appendingPathComponent("mosh.args"), + encoding: .utf8 + ) + + #expect(result.status == 0) + #expect(moshArguments.firstLine == "--experimental-remote-ip=local") + #expect(result.stderr.isEmpty) + let probeOutput = try String( + contentsOf: directory.appendingPathComponent("ssh.args"), + encoding: .utf8 + ) + #expect(!probeOutput.contains("SSH_CONNECTION")) } } @@ -216,6 +424,38 @@ struct MoshTerminalCommandBuilderTests { } } + @Test("runs the staged bootstrap through mosh-server execvp argv semantics") + func stagedBootstrapSurvivesMoshServerExec() throws { + try withFakeCommands( + sshStatus: 0, + executeRemoteCommand: true, + installRemoteMoshServerOutsidePath: true, + moshExecutesRemoteCommand: true + ) { directory, environment in + let remoteHome = directory.appendingPathComponent("remote-home", isDirectory: true) + let staging = try #require(RemoteBootstrapStagingCommandBuilder( + installerSSHArguments: ["ssh", "-o", "RemoteCommand=none"], + destination: "user@example.com", + remoteRelayPort: 52_264, + bootstrapScript: "printf ran > \"$HOME/bootstrap-ran\"" + )) + let result = try run( + builder( + preparationShellScript: staging.preparationShellScript, + remoteRelayPort: 52_264, + remoteCommandArguments: staging.remoteExecutionCommandArguments + ), + environment: environment + ) + + #expect(result.status == 0, "stderr: \(result.stderr)") + #expect(result.stderr.isEmpty) + #expect(FileManager.default.fileExists( + atPath: remoteHome.appendingPathComponent("bootstrap-ran").path + )) + } + } + @Test("does not report connected before the Mosh transport establishes") func failedMoshDoesNotReportConnected() throws { try withFakeCommands(sshStatus: 0, moshStatus: 71) { directory, environment in @@ -243,32 +483,49 @@ struct MoshTerminalCommandBuilderTests { preparationShellScript: String? = nil, managementReadyShellScript: String? = nil, remoteRelayPort: Int? = nil, - localMoshExecutableName: String = "mosh" + remoteIPMode: MoshRemoteIPMode = .remote, + localMoshExecutableName: String = "mosh", + remoteCommandArguments: [String] = ["command", "space arg", "quote'arg"] ) -> MoshTerminalCommandBuilder { MoshTerminalCommandBuilder( capabilityProbeSSHArguments: ["ssh", "-o", "RemoteCommand=none"], sessionSSHArguments: ["ssh", "-o", "RemoteCommand=none", "-p", "2222"], localMoshExecutableName: localMoshExecutableName, destination: "user@example.com", - remoteCommandArguments: ["command", "space arg", "quote'arg"], + remoteCommandArguments: remoteCommandArguments, remoteRelayPort: remoteRelayPort, + remoteIPMode: remoteIPMode, preparationShellScript: preparationShellScript, managementReadyShellScript: managementReadyShellScript, sshFallbackCommand: sshFallbackCommand, localMoshMissingMessage: "local mosh missing", localMoshUnsupportedMessage: "local mosh unsupported", remoteMoshMissingMessage: "remote mosh missing", - remoteMoshProbeFailedMessage: "remote probe failed" + remoteMoshProbeFailedMessage: "remote probe failed", + remoteBootstrapInstallFailedMessage: "remote bootstrap install failed", + remoteMoshAddressFallbackMessage: "mosh address fallback engaged" ) } + private static var fishExecutablePath: String? { + [ + "/opt/homebrew/bin/fish", + "/usr/local/bin/fish", + "/usr/bin/fish", + ].first(where: { FileManager.default.isExecutableFile(atPath: $0) }) + } + private func withFakeCommands( sshStatus: Int32, installMosh: Bool = true, moshSupportsRemoteIP: Bool = true, executeRemoteCommand: Bool = false, installRemoteMoshServerOutsidePath: Bool = false, + moshExecutesRemoteCommand: Bool = false, requireManagementReady: Bool = false, + sshConnection: String? = nil, + sshConnectionStatus: Int32? = nil, + remoteLoginShell: String = "/bin/sh", moshStatus: Int32 = 0, operation: (URL, [String: String]) throws -> Void ) throws { @@ -281,13 +538,22 @@ struct MoshTerminalCommandBuilderTests { named: "ssh", script: """ #!/bin/sh - printf '%s\\n' "$@" > "$SSH_ARGS_FILE" + printf '%s\\n' "$@" >> "$SSH_ARGS_FILE" + printf '%s\\n' '__CMUX_SSH_INVOCATION_END__' >> "$SSH_ARGS_FILE" + cmux_remote_command= + for cmux_arg in "$@"; do cmux_remote_command=$cmux_arg; done if [ "$FAKE_SSH_EXEC_REMOTE" = "1" ]; then - cmux_remote_command= - for cmux_arg in "$@"; do cmux_remote_command=$cmux_arg; done - HOME="$FAKE_REMOTE_HOME" PATH=/usr/bin:/bin /bin/sh -c "$cmux_remote_command" + SSH_CONNECTION="$FAKE_SSH_CONNECTION" HOME="$FAKE_REMOTE_HOME" PATH=/usr/bin:/bin "$FAKE_REMOTE_LOGIN_SHELL" -c "$cmux_remote_command" exit $? fi + case "$cmux_remote_command" in + *SSH_CONNECTION*) + if [ -n "${FAKE_SSH_CONNECTION_STATUS:-}" ]; then + exit "$FAKE_SSH_CONNECTION_STATUS" + fi + printf '%s\\n' "__CMUX_SSH_CONNECTION__${FAKE_SSH_CONNECTION:-}" + ;; + esac exit "$FAKE_SSH_STATUS" """, in: directory @@ -316,6 +582,14 @@ struct MoshTerminalCommandBuilderTests { exit 71 fi printf '%s\\n' "$@" > "$MOSH_ARGS_FILE" + if [ "$FAKE_MOSH_EXECS_REMOTE_COMMAND" = "1" ]; then + while [ "$#" -gt 0 ] && [ "$1" != "--" ]; do shift; done + if [ "$#" -gt 0 ]; then shift; fi + if [ "$#" -gt 0 ]; then shift; fi + if [ "$#" -gt 0 ]; then + HOME="$FAKE_REMOTE_HOME" exec "$@" + fi + fi exit "$FAKE_MOSH_STATUS" """, in: directory @@ -335,11 +609,15 @@ struct MoshTerminalCommandBuilderTests { ) } try operation(directory, [ - "PATH": directory.path, + "PATH": directory.path + ":/usr/bin:/bin", "FAKE_SSH_STATUS": String(sshStatus), "FAKE_SSH_EXEC_REMOTE": executeRemoteCommand ? "1" : "0", "FAKE_REMOTE_HOME": remoteHome.path, + "FAKE_REMOTE_LOGIN_SHELL": remoteLoginShell, + "FAKE_SSH_CONNECTION": sshConnection ?? "192.0.2.10 12345 192.0.2.20 22", + "FAKE_SSH_CONNECTION_STATUS": sshConnectionStatus.map(String.init) ?? "", "FAKE_MOSH_SUPPORTS_REMOTE_IP": moshSupportsRemoteIP ? "1" : "0", + "FAKE_MOSH_EXECS_REMOTE_COMMAND": moshExecutesRemoteCommand ? "1" : "0", "FAKE_REQUIRE_MANAGEMENT_READY": requireManagementReady ? "1" : "0", "FAKE_MOSH_STATUS": String(moshStatus), "MANAGEMENT_READY_FILE": directory.appendingPathComponent("management.ready").path, @@ -381,3 +659,9 @@ struct MoshTerminalCommandBuilderTests { ) } } + +private extension String { + var firstLine: String { + split(whereSeparator: \.isNewline).first.map(String.init) ?? "" + } +} diff --git a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/RemoteBootstrapStagingCommandBuilderTests.swift b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/RemoteBootstrapStagingCommandBuilderTests.swift index e25e3785710..ee599ae8681 100644 --- a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/RemoteBootstrapStagingCommandBuilderTests.swift +++ b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/RemoteBootstrapStagingCommandBuilderTests.swift @@ -72,6 +72,7 @@ struct RemoteBootstrapStagingCommandBuilderTests { .split(separator: "\n") .map(String.init) #expect(sshArguments.allSatisfy { $0.utf8.count < 4_096 }) + #expect(sshArguments.contains(where: { $0.hasPrefix("/bin/sh -c '") })) let execution = try run( executable: "/bin/sh", @@ -89,6 +90,143 @@ struct RemoteBootstrapStagingCommandBuilderTests { #expect(execution.stderr.isEmpty) } + @Test("execution argv runs the staged bootstrap under execvp semantics") + func executionArgumentsSurviveExecvp() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-bootstrap-argv-\(UUID().uuidString)", isDirectory: true) + let remoteHome = directory.appendingPathComponent("remote-home", isDirectory: true) + let relayDirectory = remoteHome.appendingPathComponent(".cmux/relay", isDirectory: true) + try FileManager.default.createDirectory(at: relayDirectory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + try "printf '%s\\n' argv-bootstrap\n".write( + to: relayDirectory.appendingPathComponent("52264.bootstrap.sh"), + atomically: true, + encoding: .utf8 + ) + let builder = try #require(RemoteBootstrapStagingCommandBuilder( + installerSSHArguments: ["ssh"], + destination: "user@example.com", + remoteRelayPort: 52_264, + bootstrapScript: "printf '%s\\n' argv-bootstrap" + )) + + // mosh-server executes the received command argv with execvp and no + // shell parsing, so the first element must be a real executable path. + let arguments = builder.remoteExecutionCommandArguments + let execution = try run( + executable: try #require(arguments.first), + arguments: Array(arguments.dropFirst()), + environment: [ + "HOME": remoteHome.path, + "PATH": "/usr/bin:/bin", + ] + ) + #expect(execution.status == 0) + #expect(execution.stdout == "argv-bootstrap\n") + #expect(execution.stderr.isEmpty) + } + + @Test( + "installs through POSIX sh when the remote login shell is fish", + .enabled(if: RemoteBootstrapStagingCommandBuilderTests.fishExecutablePath != nil) + ) + func stagesThroughFishLoginShell() throws { + let fishPath = try #require(Self.fishExecutablePath) + + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-bootstrap-fish-\(UUID().uuidString)", isDirectory: true) + let remoteHome = directory.appendingPathComponent("remote-home", isDirectory: true) + try FileManager.default.createDirectory(at: remoteHome, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let fakeSSH = directory.appendingPathComponent("ssh") + try """ + #!/bin/sh + cmux_remote_command= + for cmux_argument in "$@"; do cmux_remote_command=$cmux_argument; done + HOME="$CMUX_REMOTE_HOME" PATH=/usr/bin:/bin "$CMUX_FISH_PATH" -c "$cmux_remote_command" + """.write(to: fakeSSH, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], + ofItemAtPath: fakeSSH.path + ) + + let builder = try #require(RemoteBootstrapStagingCommandBuilder( + installerSSHArguments: [fakeSSH.path, "-o", "RemoteCommand=none"], + destination: "user@example.com", + remoteRelayPort: 52_262, + bootstrapScript: "printf '%s\\n' fish-bootstrap" + )) + let preparation = try run( + executable: "/bin/sh", + arguments: ["-c", builder.preparationShellScript], + environment: [ + "PATH": "/usr/bin:/bin", + "CMUX_REMOTE_HOME": remoteHome.path, + "CMUX_FISH_PATH": fishPath, + ] + ) + + #expect(preparation.status == 0) + #expect(preparation.stderr.isEmpty) + #expect(FileManager.default.fileExists( + atPath: remoteHome.appendingPathComponent(".cmux/relay/52262.bootstrap.sh").path + )) + + let execution = try run( + executable: fishPath, + arguments: ["-c", builder.remoteExecutionShellScript], + environment: [ + "HOME": remoteHome.path, + "PATH": "/usr/bin:/bin", + ] + ) + #expect(execution.status == 0) + #expect(execution.stdout == "fish-bootstrap\n") + #expect(execution.stderr.isEmpty) + } + + @Test("returns installer status and captured stderr") + func reportsInstallerFailure() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-bootstrap-failure-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let fakeSSH = directory.appendingPathComponent("ssh") + try """ + #!/bin/sh + cat >/dev/null + printf '%s\\n' 'remote installer stderr' >&2 + exit 23 + """.write(to: fakeSSH, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], + ofItemAtPath: fakeSSH.path + ) + + let builder = try #require(RemoteBootstrapStagingCommandBuilder( + installerSSHArguments: [fakeSSH.path, "-o", "RemoteCommand=none"], + destination: "user@example.com", + remoteRelayPort: 52_263, + bootstrapScript: "true" + )) + let preparation = try run( + executable: "/bin/sh", + arguments: ["-c", builder.preparationShellScript], + environment: [ + "PATH": "/usr/bin:/bin", + ] + ) + + #expect(preparation.status == 23) + #expect(preparation.stderr.contains("remote installer stderr")) + #expect(!FileManager.default.fileExists( + atPath: directory.appendingPathComponent("remote-home/.cmux/relay/52263.bootstrap.sh").path + )) + } + @Test("rejects an invalid relay namespace") func invalidRelayPort() { #expect(RemoteBootstrapStagingCommandBuilder( @@ -120,4 +258,12 @@ struct RemoteBootstrapStagingCommandBuilderTests { String(decoding: standardError.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self) ) } + + private static var fishExecutablePath: String? { + [ + "/opt/homebrew/bin/fish", + "/usr/local/bin/fish", + "/usr/bin/fish", + ].first(where: { FileManager.default.isExecutableFile(atPath: $0) }) + } } diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 7d71a134ac0..e98834c50c5 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -54293,6 +54293,256 @@ } } }, + "cli.ssh.mosh.bootstrapInstallFailed": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "[cmux] فشل تثبيت التمهيد البعيد؛ جارٍ المتابعة عبر SSH." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "[cmux] Instalacija udaljenog pokretanja nije uspjela; nastavak putem SSH-a." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "[cmux] Installation af fjern-bootstrap mislykkedes; fortsætter via SSH." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "[cmux] Installation des Remote-Bootstraps fehlgeschlagen; Verbindung wird über SSH fortgesetzt." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "[cmux] Remote bootstrap install failed; continuing over SSH." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "[cmux] Falló la instalación del arranque remoto; se continuará mediante SSH." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "[cmux] Échec de l’installation du bootstrap distant ; poursuite via SSH." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "[cmux] Installazione del bootstrap remoto non riuscita; continuazione tramite SSH." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "[cmux] リモートブートストラップのインストールに失敗したため、SSH で接続を続行します。" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "[cmux] ការដំឡើង bootstrap ពីចម្ងាយបានបរាជ័យ ដូច្នេះបន្តតាម SSH។" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "[cmux] 원격 부트스트랩 설치에 실패하여 SSH로 계속합니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "[cmux] Installasjon av ekstern bootstrap mislyktes; fortsetter via SSH." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "[cmux] Instalacja zdalnego bootstrapu nie powiodła się; kontynuowanie przez SSH." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "[cmux] Falha ao instalar o bootstrap remoto; continuando por SSH." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "[cmux] Не удалось установить удалённый bootstrap; продолжение через SSH." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "[cmux] ติดตั้ง bootstrap ระยะไกลไม่สำเร็จ จึงดำเนินการต่อผ่าน SSH" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "[cmux] Uzak bootstrap kurulamadı; SSH üzerinden devam ediliyor." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "[cmux] Не вдалося встановити віддалений bootstrap; продовження через SSH." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "[cmux] 远程引导安装失败;将继续使用 SSH。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "[cmux] 遠端 bootstrap 安裝失敗;將繼續使用 SSH。" + } + } + } + }, + "cli.ssh.mosh.addressFallback": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "[cmux] أعلن SSH البعيد عن عنوان غير صالح؛ سيتم تحديد عنوان Mosh عبر اتصال SSH." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "[cmux] Udaljeni SSH je prijavio neupotrebljivu adresu; Mosh adresa se određuje kroz SSH vezu." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "[cmux] Fjern-SSH annoncerede en ugyldig adresse; Mosh-adressen findes via SSH-forbindelsen." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "[cmux] Der Remote-SSH-Dienst meldete eine unbrauchbare Adresse; die Mosh-Adresse wird über die SSH-Verbindung ermittelt." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "[cmux] Remote SSH advertised an unusable address; resolving the Mosh address through the SSH connection." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "[cmux] El SSH remoto anunció una dirección no utilizable; la dirección de Mosh se resolverá a través de la conexión SSH." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "[cmux] Le SSH distant a annoncé une adresse inutilisable ; l’adresse Mosh est résolue via la connexion SSH." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "[cmux] SSH remoto ha annunciato un indirizzo inutilizzabile; l’indirizzo Mosh viene risolto tramite la connessione SSH." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "[cmux] リモート SSH が使用できないアドレスを通知したため、SSH 接続経由で Mosh のアドレスを解決します。" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "[cmux] SSH ពីចម្ងាយបានប្រកាសអាសយដ្ឋានដែលមិនអាចប្រើបាន ដូច្នេះដោះស្រាយអាសយដ្ឋាន Mosh តាមរយៈការតភ្ជាប់ SSH។" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "[cmux] 원격 SSH가 사용할 수 없는 주소를 알렸습니다. SSH 연결을 통해 Mosh 주소를 확인합니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "[cmux] Ekstern SSH oppga en ubrukelig adresse; Mosh-adressen løses via SSH-forbindelsen." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "[cmux] Zdalny SSH ogłosił nieużyteczny adres; adres Mosh jest ustalany przez połączenie SSH." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "[cmux] O SSH remoto anunciou um endereço inutilizável; resolvendo o endereço do Mosh pela conexão SSH." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "[cmux] Удалённый SSH сообщил непригодный адрес; адрес Mosh определяется через SSH-подключение." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "[cmux] SSH ระยะไกลแจ้งที่อยู่ที่ใช้ไม่ได้ จึงค้นหาที่อยู่ Mosh ผ่านการเชื่อมต่อ SSH" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "[cmux] Uzak SSH kullanılamayan bir adres bildirdi; Mosh adresi SSH bağlantısı üzerinden çözümleniyor." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "[cmux] Віддалений SSH повідомив непридатну адресу; адреса Mosh визначається через SSH-з’єднання." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "[cmux] 远程 SSH 宣布的地址不可用;将通过 SSH 连接解析 Mosh 地址。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "[cmux] 遠端 SSH 回報的位址無法使用;將透過 SSH 連線解析 Mosh 位址。" + } + } + } + }, "cli.ssh.mosh.remoteMissing": { "extractionState": "manual", "localizations": { diff --git a/Sources/SessionRemoteWorkspaceSnapshot+Restore.swift b/Sources/SessionRemoteWorkspaceSnapshot+Restore.swift index 0aef3dd1160..983debf9ec2 100644 --- a/Sources/SessionRemoteWorkspaceSnapshot+Restore.swift +++ b/Sources/SessionRemoteWorkspaceSnapshot+Restore.swift @@ -307,12 +307,17 @@ extension SessionRemoteWorkspaceSnapshot { let sshInvocation = terminalArguments .map(Self.shellQuote) .joined(separator: " ") - let remoteCommandTemplate = [ + let remoteCommandScript = [ restoredSSHTerminalConnectedShellScript( remoteRelayPort: remoteRelayPort ), staging.remoteExecutionShellScript, ].joined(separator: "\n") + // OpenSSH gives the command after the destination to the account's + // configured login shell. Keep fish/csh/nushell from parsing the + // POSIX lifecycle/relay script by making /bin/sh the command's + // outermost interpreter explicitly. + let remoteCommandTemplate = "/bin/sh -c \(Self.shellQuote(remoteCommandScript))" let script = [ "cmux_restore_fail() { \(failureScript); }", "cmux_restore_cli=\"${CMUX_BUNDLED_CLI_PATH:-}\"", @@ -431,6 +436,14 @@ extension SessionRemoteWorkspaceSnapshot { remoteMoshProbeFailedMessage: String( localized: "cli.ssh.mosh.probeFailed", defaultValue: "[cmux] Could not verify remote Mosh support; continuing over SSH." + ), + remoteBootstrapInstallFailedMessage: String( + localized: "cli.ssh.mosh.bootstrapInstallFailed", + defaultValue: "[cmux] Remote bootstrap install failed; continuing over SSH." + ), + remoteMoshAddressFallbackMessage: String( + localized: "cli.ssh.mosh.addressFallback", + defaultValue: "[cmux] Remote SSH advertised an unusable address; resolving the Mosh address through the SSH connection." ) ).command() } diff --git a/cmuxTests/SessionRemoteWorkspaceMoshRestoreTests.swift b/cmuxTests/SessionRemoteWorkspaceMoshRestoreTests.swift index 74ad543a345..df671d7f87f 100644 --- a/cmuxTests/SessionRemoteWorkspaceMoshRestoreTests.swift +++ b/cmuxTests/SessionRemoteWorkspaceMoshRestoreTests.swift @@ -49,7 +49,9 @@ struct SessionRemoteWorkspaceMoshRestoreTests { localMoshMissingMessage: "local Mosh missing", localMoshUnsupportedMessage: "local Mosh unsupported", remoteMoshMissingMessage: "remote Mosh missing", - remoteMoshProbeFailedMessage: "remote Mosh probe failed" + remoteMoshProbeFailedMessage: "remote Mosh probe failed", + remoteBootstrapInstallFailedMessage: "remote bootstrap install failed", + remoteMoshAddressFallbackMessage: "remote Mosh address fallback" ).command() let process = Process() process.executableURL = URL(fileURLWithPath: "/bin/sh") @@ -84,7 +86,7 @@ struct SessionRemoteWorkspaceMoshRestoreTests { let command = try #require(configuration.terminalStartupCommand) #expect(configuration.terminalTransport == .mosh) - #expect(command.contains("--experimental-remote-ip=remote"), "\(command)") + #expect(command.contains("cmux_mosh_remote_ip_mode"), "\(command)") #expect(command.contains("dev@example.com"), "\(command)") #expect(command.contains("2222"), "\(command)") #expect(command.contains("ProxyJump=bastion"), "\(command)") diff --git a/docs/remote-daemon-spec.md b/docs/remote-daemon-spec.md index 590af075441..99fe248e4a6 100644 --- a/docs/remote-daemon-spec.md +++ b/docs/remote-daemon-spec.md @@ -35,7 +35,7 @@ This is a **living implementation spec** (also called an **execution spec**): a - `DONE` `cmux mosh ` is a command-level alias for the same first-class remote-workspace path, with Mosh selected by default. - `DONE` `cmux mosh-tmux [--session ]` creates or attaches a terminal-hosted tmux session over Mosh while preserving remote metadata, daemon/control, relay, proxy/egress, upload, and reconnect behavior. The typed shell-or-tmux terminal profile persists across workspace reconnect and app session restore. - `DONE` terminal transport is separate from management transport. Mosh carries only the interactive PTY; SSH remains responsible for `cmuxd-remote` upload/bootstrap, daemon RPC, the reverse CLI relay, proxy/egress traffic, file uploads, capability probes, and reconnect controls. -- `DONE` cmux requires a local Mosh client with `--experimental-remote-ip=remote` support (Mosh 1.4+), then checks for remote `mosh-server`. A missing/incompatible client, missing server, or failed capability probe produces an explicit message and falls back to the existing SSH terminal command. +- `DONE` cmux requires a local Mosh client with `--experimental-remote-ip` support (Mosh 1.4+), stages the cmux bootstrap, and probes for an already-installed remote `mosh-server` through an explicit POSIX `/bin/sh` management lane (cmux does not install Mosh itself). When `SSH_CONNECTION` is empty or unusable, the launcher selects Mosh's SSH-proxy address-resolution mode and explains the fallback. A missing/incompatible client, bootstrap-install failure, missing server, or failed capability probe produces a stage-specific message and falls back to the existing SSH terminal command. - `DEFERRED` Native `ssh-tmux`-style mirroring over Mosh: tmux control mode requires a lossless byte stream, while Mosh exposes synchronized terminal screen state. `mosh-tmux` therefore provides a real roaming terminal attach, not a mislabeled native mirror. Also deferred: running the daemon/control lane over Mosh and automatic recovery from blocked UDP after a successful Mosh capability probe. ### 3.2 Bootstrap + Daemon