diff --git a/crates/ide-host/src/trust.rs b/crates/ide-host/src/trust.rs index ea9b9604..0e828db4 100644 --- a/crates/ide-host/src/trust.rs +++ b/crates/ide-host/src/trust.rs @@ -274,6 +274,15 @@ pub fn restricted_command_allowed(cmd: &str) -> bool { | "plugin_set_theme_frame" | "plugin_catalog" | "plugin_assets" + | "plugin_market" + | "plugin_market_url" + | "plugin_set_market_url" + | "plugin_market_download" + | "plugin_unpack_package" + | "plugin_stage_package" + | "plugin_preview_install" + | "plugin_read_intellij_theme" + | "plugin_installed_intellij_themes" | "notebook_open" | "notebook_get" | "notebook_edit" @@ -319,12 +328,31 @@ pub fn restricted_command_allowed(cmd: &str) -> bool { ) || cmd.starts_with("plugin:") } +/// Commands that change which plugin code runs: allowed where no project is open (the +/// welcome screen), otherwise only in a trusted project. +pub fn projectless_command_allowed(cmd: &str) -> bool { + matches!( + cmd, + "plugin_install_dir" + | "plugin_uninstall" + | "plugin_set_enabled" + | "plugin_grant" + | "plugin_set_capability" + | "plugin_roll_back" + | "plugin_reload" + ) +} + impl crate::Host { pub fn authorize_command(&self, window: &str, command: &str) -> IdeResult<()> { if restricted_command_allowed(command) { return Ok(()); } - self.project(window)?.require_trust() + match self.project(window) { + Ok(project) => project.require_trust(), + Err(_) if projectless_command_allowed(command) => Ok(()), + Err(error) => Err(error), + } } } diff --git a/crates/ide-host/tests/trust.rs b/crates/ide-host/tests/trust.rs index eb2868ad..a5ec8d05 100644 --- a/crates/ide-host/tests/trust.rs +++ b/crates/ide-host/tests/trust.rs @@ -58,6 +58,11 @@ async fn application_commands_work_in_restricted_projects_and_without_a_project( "vcs_write_patch", "remote_bootstrap_list", "deploy_test_connection", + "plugin_market", + "plugin_market_url", + "plugin_set_market_url", + "plugin_market_download", + "plugin_preview_install", ] { assert!(ide_host::trust::restricted_command_allowed(command), "{command}"); for window in ["main", "welcome"] { @@ -72,8 +77,6 @@ async fn application_commands_work_in_restricted_projects_and_without_a_project( "tools_list", "tools_install", "tools_uninstall", - "plugin_install_dir", - "plugin_reload", "vcs_git_version", "docker_compose_op", "db_execute", @@ -93,6 +96,12 @@ async fn application_commands_work_in_restricted_projects_and_without_a_project( assert!(host.authorize_command(window, command).is_err(), "{window}: {command}"); } } + // Plugin installs run plugin code: from the welcome screen, but not in an untrusted project. + for command in ["plugin_install_dir", "plugin_uninstall", "plugin_set_enabled", "plugin_reload"] { + assert!(!ide_host::trust::restricted_command_allowed(command), "{command}"); + assert!(host.authorize_command("main", command).is_err(), "{command}"); + assert!(host.authorize_command("welcome", command).is_ok(), "{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( diff --git a/src/features/plugins/PluginsPage.tsx b/src/features/plugins/PluginsPage.tsx index 3f8da6c6..f2dac512 100644 --- a/src/features/plugins/PluginsPage.tsx +++ b/src/features/plugins/PluginsPage.tsx @@ -924,7 +924,7 @@ export function PluginsPage() { )} - {!marketUrl && ( + {!marketUrl && !marketError && (
  • {tx`${NO_REPOSITORY}. Showing plugin packages available on this machine.`} diff --git a/src/features/plugins/__tests__/plugins.test.tsx b/src/features/plugins/__tests__/plugins.test.tsx index b06c349a..b9747879 100644 --- a/src/features/plugins/__tests__/plugins.test.tsx +++ b/src/features/plugins/__tests__/plugins.test.tsx @@ -570,6 +570,21 @@ describe("PluginsPage", () => { expect(within(list).getAllByText("Installed").length).toBeGreaterThan(0); expect(notes.at(-1)).toMatchObject({ title: "Installed Toy Language 0.1.0" }); }); + + it("shows why the marketplace couldn't be read instead of claiming there is none", async () => { + // Commands without a result type reject with the backend's error object, not an Error. + setPluginsBackend({ + ...createMemoryPluginsBackend([], sampleCatalog()), + marketUrl: () => Promise.reject({ kind: "unavailable", message: "Index unreachable", data: null }), + }); + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("tab", { name: /Available/ })); + const list = await screen.findByRole("list", { name: "Available plugins" }); + expect(await within(list).findByText("Index unreachable")).toBeInTheDocument(); + expect(within(list).queryByText("[object Object]")).not.toBeInTheDocument(); + expect(within(list).queryByText(new RegExp(NO_REPOSITORY))).not.toBeInTheDocument(); + }); }); describe("secrets", () => { diff --git a/src/features/plugins/store.ts b/src/features/plugins/store.ts index 797b7658..047a4ce1 100644 --- a/src/features/plugins/store.ts +++ b/src/features/plugins/store.ts @@ -54,7 +54,13 @@ export const usePlugins = create(() => ({ marketError: null, })); -export const errorText = (e: unknown) => (e instanceof Error ? e.message : String(e)); +/** A rejection's message: an `Error`, or an `IdeError` object from a command without a result type. */ +export const errorText = (e: unknown): string => + e instanceof Error + ? e.message + : typeof e === "object" && e !== null && "message" in e && typeof e.message === "string" + ? e.message + : String(e); /** Statuses whose contributions are in effect. */ export const isLive = (status: PluginStatus) => status === "active" || status === "inactive" || status === "static";