From b3abc8edd11a2869e3361c66c73e6464714a6c0f Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 1 Nov 2026 20:09:08 +0800 Subject: [PATCH] fix(remote): browse remote directories without an open project (#691) Browse in the Remote Development dialog failed with "no project is open in this window" on the welcome screen, and in untrusted project windows: remote_bootstrap_list was not classified in the restricted-mode allowlist, so IPC authorization required a trusted project before the command reached SSH. The listing itself already used the selected SSH configuration and never depended on a project. remote_bootstrap_list and deploy_test_connection are now allowed alongside the other application-level remote commands; both only connect or list remote paths and run no project code. Every other command used by the dialog and the welcome screen's remote entries was checked and is already classified. Fixes #672 --- crates/ide-host/Cargo.toml | 1 + crates/ide-host/src/ops/dependencies.rs | 1 + crates/ide-host/src/trust.rs | 2 + crates/ide-host/tests/trust.rs | 60 +++++++++ .../remote-dev/ConnectDialog.test.tsx | 118 ++++++++++++++++++ 5 files changed, 182 insertions(+) create mode 100644 src/features/remote-dev/ConnectDialog.test.tsx diff --git a/crates/ide-host/Cargo.toml b/crates/ide-host/Cargo.toml index 399228527..a6e21c88d 100644 --- a/crates/ide-host/Cargo.toml +++ b/crates/ide-host/Cargo.toml @@ -51,6 +51,7 @@ uuid.workspace = true tempfile.workspace = true [dev-dependencies] +ide-ssh = { workspace = true, features = ["testing"] } tokio = { workspace = true, features = ["rt-multi-thread", "macros", "io-util", "test-util"] } [target.'cfg(windows)'.dependencies] diff --git a/crates/ide-host/src/ops/dependencies.rs b/crates/ide-host/src/ops/dependencies.rs index 96dfad9b3..0dc62207b 100644 --- a/crates/ide-host/src/ops/dependencies.rs +++ b/crates/ide-host/src/ops/dependencies.rs @@ -91,6 +91,7 @@ mod tests { let host = Host::new(HostConfig { config_dir: base.join("config"), data_dir: base.join("data"), + local_data_dir: base.join("data"), languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), syntax: Arc::default(), sink_for: Box::new(|_| Arc::new(|_| {})), diff --git a/crates/ide-host/src/trust.rs b/crates/ide-host/src/trust.rs index b829bd636..7e8522e10 100644 --- a/crates/ide-host/src/trust.rs +++ b/crates/ide-host/src/trust.rs @@ -222,6 +222,7 @@ pub fn restricted_command_allowed(cmd: &str) -> bool { | "index_status" | "jvm_file_presentations" | "deploy_coverage" + | "deploy_test_connection" | "find_files" | "find_symbols" | "mark_recent" @@ -243,6 +244,7 @@ pub fn restricted_command_allowed(cmd: &str) -> bool { | "remote_current" | "remote_disconnect" | "remote_connect" + | "remote_bootstrap_list" | "remote_list" | "remote_open" | "remote_of" diff --git a/crates/ide-host/tests/trust.rs b/crates/ide-host/tests/trust.rs index aa41a3158..eb2868adf 100644 --- a/crates/ide-host/tests/trust.rs +++ b/crates/ide-host/tests/trust.rs @@ -56,6 +56,8 @@ async fn application_commands_work_in_restricted_projects_and_without_a_project( "db_console_path", "db_export_file", "vcs_write_patch", + "remote_bootstrap_list", + "deploy_test_connection", ] { assert!(ide_host::trust::restricted_command_allowed(command), "{command}"); for window in ["main", "welcome"] { @@ -75,6 +77,15 @@ async fn application_commands_work_in_restricted_projects_and_without_a_project( "vcs_git_version", "docker_compose_op", "db_execute", + "remote_delete", + "remote_mkdir", + "remote_rename", + "remote_import_local", + "remote_bootstrap_exec", + "deploy_upload", + "deploy_download", + "deploy_delete_remote", + "deploy_sync", "unknown_command", ] { assert!(!ide_host::trust::restricted_command_allowed(command), "{command}"); @@ -109,6 +120,55 @@ async fn application_commands_work_in_restricted_projects_and_without_a_project( host.shutdown().await; } +#[tokio::test] +async fn welcome_window_browses_selected_ssh_host_without_executing_commands() { + use ide_core::remote_settings::{SshAuth, SshConfiguration}; + use ide_ssh::{FsConnection, SshFiles, SshManager}; + + let tmp = tempfile::tempdir().unwrap(); + let server_root = tmp.path().join("server"); + std::fs::create_dir_all(server_root.join("projects/demo")).unwrap(); + std::fs::write(server_root.join("projects/README.md"), "remote file").unwrap(); + let port = ide_ssh::testing::sshd(server_root, Arc::new(|cmd| panic!("unexpected SSH exec: {cmd}"))).await; + let ssh = Arc::new(SshManager::with_files( + tmp.path().join("secrets.json"), + SshFiles { known_hosts: tmp.path().join("known_hosts"), config: None }, + )); + ssh.set_configs(vec![SshConfiguration { + id: "selected-host".into(), + host: "127.0.0.1".into(), + port: Some(port), + user: "dev".into(), + auth: SshAuth::Password, + ..Default::default() + }]); + ssh.set_secret("selected-host", Some("secret".into()), false); + assert!(ssh.session("selected-host").await.is_err()); + ssh.trust_host("selected-host").await.unwrap(); + + let mut host = host(&tmp.path().join("app"), false); + let state = Arc::get_mut(&mut host).unwrap(); + state.ssh = ssh; + state.remote = Arc::new(ide_remote::RemoteService::new(state.ssh.clone(), tmp.path().join("cache"))); + assert!(host.project("welcome").is_err()); + host.authorize_command("welcome", "remote_bootstrap_list").unwrap(); + let connection = FsConnection::Sftp { ssh: "selected-host".into() }; + let entries = host.remote.list(&connection, "/projects").await.unwrap(); + assert!(entries.iter().any(|e| e.path == "/projects/demo" && e.is_dir)); + assert!(entries.iter().any(|e| e.name == "README.md" && !e.is_dir)); + assert!(host.remote.list(&connection, "/projects/demo").await.unwrap().is_empty()); + host.authorize_command("welcome", "deploy_test_connection").unwrap(); + host.remote.test_connection(&connection, "/projects").await.unwrap(); + let local = tmp.path().join("local-deployment"); + std::fs::create_dir(&local).unwrap(); + ide_host::ops::deploy::deploy_test_connection(&host, FsConnection::Local, local.to_str().unwrap().into()) + .await + .unwrap(); + assert_eq!(std::fs::read_dir(local).unwrap().count(), 0, "write probe is cleaned up"); + assert!(host.project("welcome").is_err()); + host.shutdown().await; +} + #[test] fn persists_user_decisions_locations_revocation_and_one_time_migration() { let tmp = tempfile::tempdir().unwrap(); diff --git a/src/features/remote-dev/ConnectDialog.test.tsx b/src/features/remote-dev/ConnectDialog.test.tsx new file mode 100644 index 000000000..b80ed3e8f --- /dev/null +++ b/src/features/remote-dev/ConnectDialog.test.tsx @@ -0,0 +1,118 @@ +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import type { RemoteEntry, Settings } from "@/platform/ipc"; +import { useProject } from "@/platform/project"; +import { useRemoteProgress } from "@/platform/remote"; +import { ConnectHost } from "./ConnectDialog"; +import { closeConnectDialog, openConnectDialog, useConnectDialog } from "./model"; + +const { list, openRemote, executeAction } = vi.hoisted(() => ({ + list: vi.fn(), + openRemote: vi.fn(), + executeAction: vi.fn(), +})); + +vi.mock("@/platform/ipc", async (importOriginal) => { + const real = await importOriginal(); + return { ...real, commands: { ...real.commands, remoteBootstrapList: list } }; +}); +vi.mock("@/workbench/projectWindow", () => ({ openRemoteProject: openRemote })); +vi.mock("@/platform/actions", async (importOriginal) => ({ + ...(await importOriginal()), + executeAction, +})); + +const entry = (path: string, isDir = true): RemoteEntry => ({ + name: path.slice(path.lastIndexOf("/") + 1), + path, + isDir, + isSymlink: false, + size: 0, + mtime: null, + permissions: "rwxr-xr-x", +}); + +beforeEach(() => { + vi.resetAllMocks(); + useProject.setState({ + project: null, + opening: false, + settings: { + ssh: { + configs: [ + { id: "first", name: "First host", host: "first.example", user: "dev" }, + { id: "selected", name: "Selected host", host: "selected.example", user: "dev" }, + ], + }, + } as Settings, + }); + useRemoteProgress.setState({ progress: null, location: null }); + openConnectDialog(); +}); + +afterEach(() => closeConnectDialog()); + +it("browses the selected SSH host, navigates folders and opens one without a current project", async () => { + let finish!: (result: { status: "ok"; data: RemoteEntry[] }) => void; + list.mockImplementationOnce(() => new Promise((resolve) => (finish = resolve))); + openRemote.mockResolvedValue(true); + render(); + fireEvent.click(screen.getByRole("button", { name: "SSH configuration" })); + fireEvent.click(screen.getByRole("option", { name: /Selected host/ })); + fireEvent.click(screen.getByRole("button", { name: "Browse" })); + expect(list).toHaveBeenCalledWith("selected", "~"); + expect(screen.getByRole("button", { name: "Browse" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Open" })).toBeDisabled(); + finish({ + status: "ok", + data: [entry("/home/dev/.cache"), entry("/home/dev/projects"), entry("/home/dev/README.md", false)], + }); + const folders = await screen.findByRole("list", { name: "Remote folders" }); + expect( + within(folders) + .getAllByRole("button") + .map((button) => button.textContent), + ).toEqual(["..", "projects", ".cache"]); + expect(screen.getByLabelText("Project directory:")).toHaveValue("/home/dev"); + list.mockResolvedValueOnce({ status: "ok", data: [entry("/home/dev/projects/demo")] }); + fireEvent.click(within(folders).getByRole("button", { name: "projects" })); + await screen.findByRole("button", { name: "demo" }); + expect(list).toHaveBeenLastCalledWith("selected", "/home/dev/projects"); + list.mockResolvedValueOnce({ status: "ok", data: [entry("/home/dev/projects")] }); + fireEvent.click(within(folders).getByRole("button", { name: ".." })); + await waitFor(() => expect(screen.getByLabelText("Project directory:")).toHaveValue("/home/dev")); + expect(list).toHaveBeenLastCalledWith("selected", "/home/dev"); + list.mockResolvedValueOnce({ status: "ok", data: [] }); + fireEvent.click(within(folders).getByRole("button", { name: "projects" })); + await waitFor(() => expect(screen.getByLabelText("Project directory:")).toHaveValue("/home/dev/projects")); + expect(within(folders).getAllByRole("button")).toHaveLength(1); + fireEvent.click(screen.getByRole("button", { name: "Open" })); + await waitFor(() => expect(openRemote).toHaveBeenCalledWith("selected", "/home/dev/projects")); + expect(useConnectDialog.getState().open).toBe(false); + expect(useProject.getState().project).toBeNull(); +}); + +it("shows a connection error and lets the welcome window retry Browse", async () => { + list.mockRejectedValueOnce(new Error("Connection refused")); + render(); + fireEvent.change(screen.getByLabelText("Project directory:"), { target: { value: " /srv/projects " } }); + fireEvent.click(screen.getByRole("button", { name: "Browse" })); + expect(await screen.findByRole("alert")).toHaveTextContent("Connection refused"); + expect(list).toHaveBeenCalledWith("first", "/srv/projects"); + expect(screen.getByRole("button", { name: "Browse" })).toBeEnabled(); + list.mockResolvedValueOnce({ status: "ok", data: [] }); + fireEvent.click(screen.getByRole("button", { name: "Browse" })); + await screen.findByRole("list", { name: "Remote folders" }); + expect(screen.queryByRole("alert")).toBeNull(); + expect(openRemote).not.toHaveBeenCalled(); +}); + +it("offers SSH configuration from the welcome window and disables Browse until one exists", () => { + useProject.setState({ settings: { ssh: { configs: [] } } as unknown as Settings }); + render(); + expect(screen.getByRole("button", { name: "Browse" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Open" })).toBeDisabled(); + fireEvent.click(screen.getByRole("button", { name: "Configure…" })); + expect(executeAction).toHaveBeenCalledWith("file.settings", "tools.sshConfigurations"); + expect(list).not.toHaveBeenCalled(); +});