From 7c72411dd73b217ad31a0c3bd92bcaeb1bb5a97d Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 1 Nov 2026 13:45:25 +0800 Subject: [PATCH] fix(trust): allow application-level commands in restricted mode (#648) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Help › About showed no version, build or runtime in untrusted project windows and on the welcome screen. Root cause: the restricted-mode allowlist added with project trust missed app_build_info and other application-level commands, so they were rejected wherever the window had no trusted project; the frontend then cached the failed build info for the whole session. Fix: classify all registered commands and allow the 24 that only read application information or change the user's own application state (build info, log location, update checks, cache invalidation, file clipboard, JDK download, Docker settings, database export paths, patch export, system environment); JDK download now uses the application tool directory instead of project tool settings. Execution commands (run, build, debug, language servers, tool install from project settings) stay blocked. Build info is retried after a failure instead of being cached. Verified on macOS in an untrusted project and on the welcome screen; Windows not run. Related to #519 --- crates/ide-host/src/ops/tools.rs | 8 +-- crates/ide-host/src/trust.rs | 24 +++++++ crates/ide-host/tests/trust.rs | 88 +++++++++++++++++++++++++ src/features/app/__tests__/host.test.ts | 51 ++++++++++++++ src/features/app/host.ts | 7 +- 5 files changed, 173 insertions(+), 5 deletions(-) create mode 100644 src/features/app/__tests__/host.test.ts diff --git a/crates/ide-host/src/ops/tools.rs b/crates/ide-host/src/ops/tools.rs index f87b5799c..f8f05b1ff 100644 --- a/crates/ide-host/src/ops/tools.rs +++ b/crates/ide-host/src/ops/tools.rs @@ -100,11 +100,11 @@ pub fn jdk_download_vendors(_host: &Host, _window: &str) -> Vec { /// Java versions `vendor` publishes; cancellable with `cancel_request(request)`. pub async fn jdk_download_releases( host: &Host, - window: &str, + _window: &str, vendor: String, request: RequestId, ) -> IdeResult { - let tools = tools(host, window)?; + let tools = Tools::new(&host.data_dir); let cancel = host.begin_request(request); let result = tokio::task::spawn_blocking(move || tools.jdk_releases(&vendor, &cancel)).await.map_err(join_err); host.end_request(request); @@ -123,14 +123,14 @@ pub fn jdk_download_location(host: &Host, _window: &str) -> IdeResult, request: RequestId, on_event: Channel, ) -> IdeResult { - let tools = tools(host, window)?; + let tools = Tools::new(&host.data_dir); let location = location.filter(|p| !p.as_os_str().is_empty()); let cancel = host.begin_request(request); let result = tokio::task::spawn_blocking(move || { diff --git a/crates/ide-host/src/trust.rs b/crates/ide-host/src/trust.rs index 2f883b7b9..96c7c9740 100644 --- a/crates/ide-host/src/trust.rs +++ b/crates/ide-host/src/trust.rs @@ -175,6 +175,30 @@ pub fn restricted_command_allowed(cmd: &str) -> bool { matches!( cmd, "app_info" + | "app_build_info" + | "app_log_location" + | "app_check_update" + | "app_startup_update_check" + | "app_install_update" + | "caches_schedule_invalidation" + | "clear_caches" + | "clipboard_file_list" + | "clipboard_has_files" + | "clipboard_set_file_list" + | "jdk_download_vendors" + | "jdk_download_releases" + | "jdk_download_location" + | "jdk_download" + | "run_system_env" + | "run_gradle_jvm_args_script" + | "proxy_check_connection" + | "docker_settings" + | "docker_save_settings" + | "docker_set_registry_password" + | "docker_run_env" + | "db_console_path" + | "db_export_file" + | "vcs_write_patch" | "app_metrics" | "app_quit" | "app_cancel_quit" diff --git a/crates/ide-host/tests/trust.rs b/crates/ide-host/tests/trust.rs index bf6a0a057..4a1d0250a 100644 --- a/crates/ide-host/tests/trust.rs +++ b/crates/ide-host/tests/trust.rs @@ -20,6 +20,94 @@ fn host(base: &Path, environment: bool) -> Arc { })) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn application_commands_work_in_restricted_projects_and_without_a_project() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("project"); + std::fs::create_dir(&root).unwrap(); + let host = host(&tmp.path().join("app"), false); + assert!(host.project("welcome").is_err()); + assert!(ide_host::ops::project::roots(&host, "welcome").is_err()); + assert!(!host.open_project("main", &root).unwrap().is_trusted()); + for command in [ + "app_info", + "app_build_info", + "app_log_location", + "app_check_update", + "app_startup_update_check", + "app_install_update", + "caches_schedule_invalidation", + "clear_caches", + "clipboard_file_list", + "clipboard_has_files", + "clipboard_set_file_list", + "jdk_download_vendors", + "jdk_download_releases", + "jdk_download_location", + "jdk_download", + "run_system_env", + "run_gradle_jvm_args_script", + "proxy_check_connection", + "docker_settings", + "docker_save_settings", + "docker_set_registry_password", + "docker_run_env", + "db_console_path", + "db_export_file", + "vcs_write_patch", + ] { + assert!(ide_host::trust::restricted_command_allowed(command), "{command}"); + for window in ["main", "welcome"] { + assert!(host.authorize_command(window, command).is_ok(), "{window}: {command}"); + } + } + for command in [ + "run_start", + "build_sync", + "debug_start", + "sdk_detect", + "tools_list", + "tools_install", + "tools_uninstall", + "plugin_install_dir", + "plugin_reload", + "vcs_git_version", + "docker_compose_op", + "db_execute", + "unknown_command", + ] { + assert!(!ide_host::trust::restricted_command_allowed(command), "{command}"); + for window in ["main", "welcome"] { + assert!(host.authorize_command(window, command).is_err(), "{window}: {command}"); + } + } + // These requests validate the vendor before any network access, independently of project trust. + for window in ["main", "welcome"] { + let error = ide_host::ops::tools::jdk_download_releases( + &host, + window, + "unknown-vendor".into(), + ide_core::RequestId(901), + ) + .await + .unwrap_err(); + assert_eq!(error.kind, ide_core::error::ErrorKind::InvalidInput); + let error = ide_host::ops::tools::jdk_download( + &host, + window, + "unknown-vendor".into(), + 25, + None, + ide_core::RequestId(902), + ide_host::ops::Channel::new(|_| {}), + ) + .await + .unwrap_err(); + assert_eq!(error.kind, ide_core::error::ErrorKind::InvalidInput); + } + host.shutdown().await; +} + #[test] fn persists_user_decisions_locations_revocation_and_one_time_migration() { let tmp = tempfile::tempdir().unwrap(); diff --git a/src/features/app/__tests__/host.test.ts b/src/features/app/__tests__/host.test.ts new file mode 100644 index 000000000..27f7d38ee --- /dev/null +++ b/src/features/app/__tests__/host.test.ts @@ -0,0 +1,51 @@ +import { beforeEach, expect, it, vi } from "vitest"; +import type { BuildInfo } from "@/platform/ipc"; + +const { appBuildInfo, isTauri } = vi.hoisted(() => ({ appBuildInfo: vi.fn(), isTauri: vi.fn() })); +vi.mock("@/platform/ipc", () => ({ commands: { appBuildInfo }, isTauri })); + +const build: BuildInfo = { + version: "0.1.32", + build: "dev", + profile: "debug", + os: "windows", + arch: "x86_64", + tauri: "2.11.3", + updates: false, +}; + +beforeEach(() => { + vi.resetModules(); + appBuildInfo.mockReset(); + isTauri.mockReturnValue(true); +}); + +it("retries after a rejected request and caches the successful build info", async () => { + appBuildInfo.mockRejectedValueOnce(new Error("Project is not trusted")).mockResolvedValueOnce(build); + const { buildInfo, hostOs } = await import("../host"); + + const first = buildInfo(); + expect(buildInfo()).toBe(first); + await expect(first).resolves.toBeNull(); + await expect(hostOs()).resolves.toBe("windows"); + await expect(buildInfo()).resolves.toBe(build); + expect(appBuildInfo).toHaveBeenCalledTimes(2); +}); + +it("shares an in-flight successful request", async () => { + let resolve!: (value: BuildInfo) => void; + appBuildInfo.mockReturnValue(new Promise((done) => (resolve = done))); + const { buildInfo } = await import("../host"); + const first = buildInfo(); + expect(buildInfo()).toBe(first); + expect(appBuildInfo).toHaveBeenCalledTimes(1); + resolve(build); + await expect(first).resolves.toBe(build); +}); + +it("does not invoke the backend outside Tauri", async () => { + isTauri.mockReturnValue(false); + const { buildInfo } = await import("../host"); + await expect(buildInfo()).resolves.toBeNull(); + expect(appBuildInfo).not.toHaveBeenCalled(); +}); diff --git a/src/features/app/host.ts b/src/features/app/host.ts index 649365e98..4e1fdacdb 100644 --- a/src/features/app/host.ts +++ b/src/features/app/host.ts @@ -13,7 +13,12 @@ function normalize(os: string): HostOs { let info: Promise | null = null; export function buildInfo(): Promise { - info ??= isTauri() ? commands.appBuildInfo().catch(() => null) : Promise.resolve(null); + info ??= isTauri() + ? commands.appBuildInfo().catch(() => { + info = null; + return null; + }) + : Promise.resolve(null); return info; }