Fix ssh workspace focus and harden remote daemon bootstrap paths

This commit is contained in:
Lawrence Chen 2026-02-28 19:56:11 -08:00
parent bfe36f817d
commit fff1cd786f
4 changed files with 189 additions and 23 deletions

View file

@ -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

View file

@ -974,17 +974,31 @@ private final class WorkspaceRemoteDaemonRPCClient {
return sshCommonArguments(configuration: configuration, batchMode: true) + [configuration.destination, command]
}
private static let connectionSharingOptionKeys: Set<String> = [
"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<String> = [
"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)

View file

@ -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(

View file

@ -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