diff --git a/daemon/remote/cmd/cmuxd-remote/main_test.go b/daemon/remote/cmd/cmuxd-remote/main_test.go index 1f77dae3..4d90d6c0 100644 --- a/daemon/remote/cmd/cmuxd-remote/main_test.go +++ b/daemon/remote/cmd/cmuxd-remote/main_test.go @@ -58,3 +58,44 @@ func TestRunStdioHelloAndPing(t *testing.T) { t.Fatalf("second response should be ok=true: %v", second) } } + +func TestRunStdioInvalidJSONAndUnknownMethod(t *testing.T) { + input := strings.NewReader( + `{"id":1,"method":"hello","params":{}` + "\n" + + `{"id":2,"method":"unknown","params":{}}` + "\n", + ) + var out bytes.Buffer + code := run([]string{"serve", "--stdio"}, input, &out, &bytes.Buffer{}) + if code != 0 { + t.Fatalf("run serve exit code = %d, want 0", code) + } + + lines := strings.Split(strings.TrimSpace(out.String()), "\n") + if len(lines) != 2 { + t.Fatalf("got %d response lines, want 2: %q", len(lines), out.String()) + } + + var first map[string]any + if err := json.Unmarshal([]byte(lines[0]), &first); err != nil { + t.Fatalf("failed to decode first response: %v", err) + } + if ok, _ := first["ok"].(bool); ok { + t.Fatalf("first response should be ok=false for invalid JSON: %v", first) + } + firstError, _ := first["error"].(map[string]any) + if got := firstError["code"]; got != "invalid_request" { + t.Fatalf("invalid JSON should return invalid_request; got=%v payload=%v", got, first) + } + + var second map[string]any + if err := json.Unmarshal([]byte(lines[1]), &second); err != nil { + t.Fatalf("failed to decode second response: %v", err) + } + if ok, _ := second["ok"].(bool); ok { + t.Fatalf("second response should be ok=false for unknown method: %v", second) + } + secondError, _ := second["error"].(map[string]any) + if got := secondError["code"]; got != "method_not_found" { + t.Fatalf("unknown method should return method_not_found; got=%v payload=%v", got, second) + } +} diff --git a/tests_v2/test_ssh_remote_cli_metadata.py b/tests_v2/test_ssh_remote_cli_metadata.py index 2de2b9ad..4bc0e256 100644 --- a/tests_v2/test_ssh_remote_cli_metadata.py +++ b/tests_v2/test_ssh_remote_cli_metadata.py @@ -134,6 +134,34 @@ def main() -> int: str(daemon.get("state") or "") in {"unavailable", "bootstrapping", "ready", "error"}, f"workspace.remote.status should include daemon state metadata: {status_remote}", ) + # Fail-fast regression: unreachable SSH target should surface bootstrap error explicitly. + deadline_daemon = time.time() + 12.0 + last_status = status + while time.time() < deadline_daemon: + last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {} + last_remote = last_status.get("remote") or {} + last_daemon = last_remote.get("daemon") or {} + if str(last_daemon.get("state") or "") == "error": + break + time.sleep(0.2) + else: + raise cmuxError(f"unreachable host should drive daemon state to error: {last_status}") + + last_remote = last_status.get("remote") or {} + last_daemon = last_remote.get("daemon") or {} + detail = str(last_daemon.get("detail") or "") + _must("bootstrap failed" in detail.lower(), f"daemon error should mention bootstrap failure: {last_status}") + + # Lifecycle regression: disconnect with clear should reset remote/daemon metadata. + disconnected = client._call( + "workspace.remote.disconnect", + {"workspace_id": workspace_id, "clear": True}, + ) or {} + disconnected_remote = disconnected.get("remote") or {} + disconnected_daemon = disconnected_remote.get("daemon") or {} + _must(bool(disconnected_remote.get("enabled")) is False, f"remote config should be cleared: {disconnected}") + _must(str(disconnected_remote.get("state") or "") == "disconnected", f"remote state should be disconnected: {disconnected}") + _must(str(disconnected_daemon.get("state") or "") == "unavailable", f"daemon state should reset to unavailable: {disconnected}") # Regression: --name is optional. payload2 = _run_cli_json( diff --git a/tests_v2/test_ssh_remote_docker_forwarding.py b/tests_v2/test_ssh_remote_docker_forwarding.py index ac676f1b..6e52a197 100644 --- a/tests_v2/test_ssh_remote_docker_forwarding.py +++ b/tests_v2/test_ssh_remote_docker_forwarding.py @@ -21,6 +21,7 @@ from cmux import cmux, cmuxError SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") REMOTE_HTTP_PORT = int(os.environ.get("CMUX_SSH_TEST_REMOTE_HTTP_PORT", "43173")) +MAX_REMOTE_DAEMON_SIZE_BYTES = int(os.environ.get("CMUX_SSH_TEST_MAX_DAEMON_SIZE_BYTES", "15000000")) def _must(cond: bool, msg: str) -> None: @@ -88,6 +89,57 @@ def _http_get(url: str, timeout: float = 2.0) -> str: return resp.read().decode("utf-8", errors="replace") +def _shell_single_quote(value: str) -> str: + return "'" + value.replace("'", "'\"'\"'") + "'" + + +def _ssh_run(host: str, host_port: int, key_path: Path, script: str, *, check: bool = True) -> subprocess.CompletedProcess[str]: + return _run( + [ + "ssh", + "-o", + "UserKnownHostsFile=/dev/null", + "-o", + "StrictHostKeyChecking=no", + "-o", + "ConnectTimeout=5", + "-p", + str(host_port), + "-i", + str(key_path), + host, + f"sh -lc {_shell_single_quote(script)}", + ], + check=check, + ) + + +def _wait_for_ssh(host: str, host_port: int, key_path: Path, timeout: float = 20.0) -> None: + deadline = time.time() + timeout + while time.time() < deadline: + probe = _ssh_run(host, host_port, key_path, "echo ready", check=False) + if probe.returncode == 0 and "ready" in probe.stdout: + return + time.sleep(0.5) + raise cmuxError("Timed out waiting for SSH server in docker fixture to become ready") + + +def _remote_binary_size_bytes(host: str, host_port: int, key_path: Path, remote_path: str) -> int: + script = f""" +set -eu +p={_shell_single_quote(remote_path)} +case "$p" in + /*) full="$p" ;; + *) full="$HOME/$p" ;; +esac +test -x "$full" +wc -c < "$full" +""" + proc = _ssh_run(host, host_port, key_path, script, check=True) + text = proc.stdout.strip().splitlines()[-1].strip() + return int(text) + + def main() -> int: if not _docker_available(): print("SKIP: docker is not available") @@ -121,13 +173,24 @@ def main() -> int: port_info = _run(["docker", "port", container_name, "22/tcp"]).stdout host_ssh_port = _parse_host_port(port_info) + host = "root@127.0.0.1" + _wait_for_ssh(host, host_ssh_port, key_path) + + fresh_check = _ssh_run( + host, + host_ssh_port, + key_path, + "test ! -e \"$HOME/.cmux/bin/cmuxd-remote\" && echo fresh", + check=True, + ) + _must("fresh" in fresh_check.stdout, "Fresh container should not have preinstalled cmuxd-remote") with cmux(SOCKET_PATH) as client: payload = _run_cli_json( cli, [ "ssh", - "root@127.0.0.1", + host, "--name", "docker-ssh-forward", "--port", str(host_ssh_port), "--identity", str(key_path), @@ -162,6 +225,15 @@ def main() -> int: _must(str(daemon.get("state") or "") == "ready", f"daemon should be ready in connected state: {last_status}") capabilities = daemon.get("capabilities") or [] _must("session.basic" in capabilities, f"daemon hello capabilities missing session.basic: {daemon}") + remote_path = str(daemon.get("remote_path") or "").strip() + _must(bool(remote_path), f"daemon ready state should include remote_path: {daemon}") + + binary_size_bytes = _remote_binary_size_bytes(host, host_ssh_port, key_path, remote_path) + _must(binary_size_bytes > 0, f"uploaded daemon binary should be non-empty: {binary_size_bytes}") + _must( + binary_size_bytes <= MAX_REMOTE_DAEMON_SIZE_BYTES, + f"uploaded daemon binary too large: {binary_size_bytes} bytes > {MAX_REMOTE_DAEMON_SIZE_BYTES}", + ) body = "" deadline_http = time.time() + 15.0 @@ -183,7 +255,10 @@ def main() -> int: pass workspace_id = "" - print("PASS: docker SSH remote port is auto-detected and reachable through local forwarding") + print( + "PASS: docker SSH remote port is auto-detected and reachable through local forwarding; " + f"uploaded cmuxd-remote size={binary_size_bytes} bytes" + ) return 0 finally: