Fix ssh workspace focus and harden remote daemon bootstrap paths
This commit is contained in:
parent
bfe36f817d
commit
fff1cd786f
4 changed files with 189 additions and 23 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue