diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 71c842aa..a5573209 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -2180,6 +2180,9 @@ struct CMUXCLI { } configuredPayload = try client.sendV2(method: "workspace.remote.configure", params: configureParams) + _ = try client.sendV2(method: "workspace.select", params: [ + "workspace_id": workspaceId, + ]) } catch { _ = try? client.sendV2(method: "workspace.close", params: ["workspace_id": workspaceId]) throw error diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index c0f6f638..194d4a15 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -974,17 +974,31 @@ private final class WorkspaceRemoteDaemonRPCClient { return sshCommonArguments(configuration: configuration, batchMode: true) + [configuration.destination, command] } + private static let connectionSharingOptionKeys: Set = [ + "controlmaster", + "controlpersist", + "controlpath", + ] + private static func sshCommonArguments(configuration: WorkspaceRemoteConfiguration, batchMode: Bool) -> [String] { + let effectiveSSHOptions: [String] = { + if batchMode { + return backgroundSSHOptions(configuration.sshOptions) + } + return normalizedSSHOptions(configuration.sshOptions) + }() var args: [String] = [ "-o", "ConnectTimeout=6", "-o", "ServerAliveInterval=20", "-o", "ServerAliveCountMax=2", ] - if !hasSSHOptionKey(configuration.sshOptions, key: "StrictHostKeyChecking") { + if !hasSSHOptionKey(effectiveSSHOptions, key: "StrictHostKeyChecking") { args += ["-o", "StrictHostKeyChecking=accept-new"] } if batchMode { args += ["-o", "BatchMode=yes"] + // Avoid shared ControlPath lock contention with interactive ssh sessions. + args += ["-o", "ControlMaster=no"] } if let port = configuration.port { args += ["-p", String(port)] @@ -993,10 +1007,8 @@ private final class WorkspaceRemoteDaemonRPCClient { !identityFile.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { args += ["-i", identityFile] } - for option in configuration.sshOptions { - let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { continue } - args += ["-o", trimmed] + for option in effectiveSSHOptions { + args += ["-o", option] } return args } @@ -1004,9 +1016,7 @@ private final class WorkspaceRemoteDaemonRPCClient { private static func hasSSHOptionKey(_ options: [String], key: String) -> Bool { let loweredKey = key.lowercased() for option in options { - let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { continue } - let token = trimmed.split(whereSeparator: { $0 == "=" || $0.isWhitespace }).first.map(String.init)?.lowercased() + let token = sshOptionKey(option) if token == loweredKey { return true } @@ -1014,6 +1024,31 @@ private final class WorkspaceRemoteDaemonRPCClient { return false } + private static func normalizedSSHOptions(_ options: [String]) -> [String] { + options.compactMap { option in + let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + return trimmed + } + } + + private static func backgroundSSHOptions(_ options: [String]) -> [String] { + normalizedSSHOptions(options).filter { option in + guard let key = sshOptionKey(option) else { return false } + return !connectionSharingOptionKeys.contains(key) + } + } + + private static func sshOptionKey(_ option: String) -> String? { + let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + return trimmed + .split(whereSeparator: { $0 == "=" || $0.isWhitespace }) + .first + .map(String.init)? + .lowercased() + } + private static func shellSingleQuoted(_ value: String) -> String { "'" + value.replacingOccurrences(of: "'", with: "'\"'\"'") + "'" } @@ -2135,16 +2170,24 @@ private final class WorkspaceRemoteSessionController { } private func sshCommonArguments(batchMode: Bool) -> [String] { + let effectiveSSHOptions: [String] = { + if batchMode { + return backgroundSSHOptions(configuration.sshOptions) + } + return normalizedSSHOptions(configuration.sshOptions) + }() var args: [String] = [ "-o", "ConnectTimeout=6", "-o", "ServerAliveInterval=20", "-o", "ServerAliveCountMax=2", ] - if !hasSSHOptionKey(configuration.sshOptions, key: "StrictHostKeyChecking") { + if !hasSSHOptionKey(effectiveSSHOptions, key: "StrictHostKeyChecking") { args += ["-o", "StrictHostKeyChecking=accept-new"] } if batchMode { args += ["-o", "BatchMode=yes"] + // Avoid shared ControlPath lock contention with interactive ssh sessions. + args += ["-o", "ControlMaster=no"] } if let port = configuration.port { args += ["-p", String(port)] @@ -2153,10 +2196,8 @@ private final class WorkspaceRemoteSessionController { !identityFile.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { args += ["-i", identityFile] } - for option in configuration.sshOptions { - let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { continue } - args += ["-o", trimmed] + for option in effectiveSSHOptions { + args += ["-o", option] } return args } @@ -2164,9 +2205,7 @@ private final class WorkspaceRemoteSessionController { private func hasSSHOptionKey(_ options: [String], key: String) -> Bool { let loweredKey = key.lowercased() for option in options { - let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { continue } - let token = trimmed.split(whereSeparator: { $0 == "=" || $0.isWhitespace }).first.map(String.init)?.lowercased() + let token = sshOptionKey(option) if token == loweredKey { return true } @@ -2174,6 +2213,36 @@ private final class WorkspaceRemoteSessionController { return false } + private func normalizedSSHOptions(_ options: [String]) -> [String] { + options.compactMap { option in + let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + return trimmed + } + } + + private func backgroundSSHOptions(_ options: [String]) -> [String] { + let sharingKeys: Set = [ + "controlmaster", + "controlpersist", + "controlpath", + ] + return normalizedSSHOptions(options).filter { option in + guard let key = sshOptionKey(option) else { return false } + return !sharingKeys.contains(key) + } + } + + private func sshOptionKey(_ option: String) -> String? { + let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + return trimmed + .split(whereSeparator: { $0 == "=" || $0.isWhitespace }) + .first + .map(String.init)? + .lowercased() + } + private func sshExec(arguments: [String], stdin: Data? = nil, timeout: TimeInterval = 15) throws -> CommandResult { try runProcess( executable: "/usr/bin/ssh", @@ -2284,7 +2353,7 @@ private final class WorkspaceRemoteSessionController { private func resolveRemotePlatformLocked() throws -> RemotePlatform { let script = "uname -s; uname -m" let command = "sh -lc \(Self.shellSingleQuoted(script))" - let result = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 10) + let result = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 20) guard result.status == 0 else { let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "ssh exited \(result.status)" throw NSError(domain: "cmux.remote.daemon", code: 10, userInfo: [ @@ -2412,10 +2481,13 @@ private final class WorkspaceRemoteSessionController { ]) } + let scpSSHOptions = backgroundSSHOptions(configuration.sshOptions) var scpArgs: [String] = ["-q"] - if !hasSSHOptionKey(configuration.sshOptions, key: "StrictHostKeyChecking") { + if !hasSSHOptionKey(scpSSHOptions, key: "StrictHostKeyChecking") { scpArgs += ["-o", "StrictHostKeyChecking=accept-new"] } + // Keep bootstrap SCP detached from shared interactive ssh control sockets. + scpArgs += ["-o", "ControlMaster=no"] if let port = configuration.port { scpArgs += ["-P", String(port)] } @@ -2423,10 +2495,8 @@ private final class WorkspaceRemoteSessionController { !identityFile.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { scpArgs += ["-i", identityFile] } - for option in configuration.sshOptions { - let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { continue } - scpArgs += ["-o", trimmed] + for option in scpSSHOptions { + scpArgs += ["-o", option] } scpArgs += [localBinary.path, "\(configuration.destination):\(remoteTempPath)"] let scpResult = try scpExec(arguments: scpArgs, timeout: 45) diff --git a/tests_v2/test_ssh_remote_cli_metadata.py b/tests_v2/test_ssh_remote_cli_metadata.py index 784781fd..f5cb21c7 100644 --- a/tests_v2/test_ssh_remote_cli_metadata.py +++ b/tests_v2/test_ssh_remote_cli_metadata.py @@ -128,6 +128,21 @@ def main() -> int: workspace_id = str(row.get("id") or "") break _must(bool(workspace_id), f"cmux ssh output missing workspace_id: {payload}") + selected_workspace_id = "" + deadline_select = time.time() + 5.0 + while time.time() < deadline_select: + try: + selected_workspace_id = client.current_workspace() + except cmuxError: + time.sleep(0.05) + continue + if selected_workspace_id == workspace_id: + break + time.sleep(0.05) + _must( + selected_workspace_id == workspace_id, + f"cmux ssh should select the newly created workspace: expected {workspace_id}, got {selected_workspace_id}", + ) ssh_command = str(payload.get("ssh_command") or "") _must(bool(ssh_command), f"cmux ssh output missing ssh_command: {payload}") _must( diff --git a/tests_v2/test_ssh_remote_docker_forwarding.py b/tests_v2/test_ssh_remote_docker_forwarding.py index c8b954ea..2af14d95 100644 --- a/tests_v2/test_ssh_remote_docker_forwarding.py +++ b/tests_v2/test_ssh_remote_docker_forwarding.py @@ -440,6 +440,62 @@ wc -c < "$full" return int(text) +def _extract_daemon_version_platform(remote_path: str) -> tuple[str, str]: + parts = [segment for segment in remote_path.strip().split("/") if segment] + try: + marker_index = parts.index("cmuxd-remote") + except ValueError as exc: + raise cmuxError(f"remote daemon path missing cmuxd-remote marker: {remote_path!r}") from exc + + required_len = marker_index + 4 + _must( + len(parts) >= required_len, + f"remote daemon path should include version/platform/binary: {remote_path!r}", + ) + version = parts[marker_index + 1] + platform = parts[marker_index + 2] + binary_name = parts[marker_index + 3] + _must(binary_name == "cmuxd-remote", f"unexpected daemon binary name in remote path: {remote_path!r}") + _must(bool(version), f"daemon version should not be empty in remote path: {remote_path!r}") + _must(bool(platform), f"daemon platform should not be empty in remote path: {remote_path!r}") + return version, platform + + +def _local_cached_daemon_binary(version: str, platform: str) -> Path: + return Path(tempfile.gettempdir()) / "cmux-remote-daemon-build" / version / platform / "cmuxd-remote" + + +def _local_file_sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _remote_binary_sha256(host: str, host_port: int, key_path: Path, remote_path: str) -> str: + script = f""" +set -eu +p={_shell_single_quote(remote_path)} +case "$p" in + /*) full="$p" ;; + *) full="$HOME/$p" ;; +esac +test -x "$full" +if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$full" | awk '{{print $1}}' +elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$full" | awk '{{print $1}}' +else + openssl dgst -sha256 "$full" | awk '{{print $NF}}' +fi +""" + proc = _ssh_run(host, host_port, key_path, script, check=True) + digest = proc.stdout.strip().splitlines()[-1].strip().lower() + _must(len(digest) == 64 and all(ch in "0123456789abcdef" for ch in digest), f"invalid remote SHA256 digest: {digest!r}") + return digest + + def _wait_connected_proxy_port(client: cmux, workspace_id: str, timeout: float = 45.0) -> tuple[dict, int]: deadline = time.time() + timeout last_status = {} @@ -547,6 +603,28 @@ def main() -> int: binary_size_bytes <= MAX_REMOTE_DAEMON_SIZE_BYTES, f"uploaded daemon binary too large: {binary_size_bytes} bytes > {MAX_REMOTE_DAEMON_SIZE_BYTES}", ) + daemon_version, daemon_platform = _extract_daemon_version_platform(remote_path) + local_cached_binary = _local_cached_daemon_binary(daemon_version, daemon_platform) + _must( + local_cached_binary.is_file(), + f"expected local daemon cache artifact at {local_cached_binary} after bootstrap upload", + ) + _must( + os.access(local_cached_binary, os.X_OK), + f"local daemon cache artifact must be executable: {local_cached_binary}", + ) + local_version = _run([str(local_cached_binary), "version"], check=True).stdout.strip() + _must( + daemon_version in local_version, + f"local cached daemon binary version mismatch: expected {daemon_version!r}, got {local_version!r}", + ) + local_sha256 = _local_file_sha256(local_cached_binary) + remote_sha256 = _remote_binary_sha256(host, host_ssh_port, key_path, remote_path) + _must( + local_sha256 == remote_sha256, + "uploaded daemon binary hash should match local cached build artifact " + f"(local={local_sha256}, remote={remote_sha256})", + ) body = "" deadline_http = time.time() + 15.0 @@ -623,7 +701,7 @@ def main() -> int: print( "PASS: docker SSH proxy endpoint is reachable, handles HTTP + WebSocket egress over SOCKS and CONNECT through remote host, and is shared across identical transports; " - f"uploaded cmuxd-remote size={binary_size_bytes} bytes" + f"uploaded cmuxd-remote size={binary_size_bytes} bytes, version={daemon_version}, platform={daemon_platform}" ) return 0