diff --git a/crates/ui-egui/src/dialogs/plugin.rs b/crates/ui-egui/src/dialogs/plugin.rs index 76f71f3b4..203f30e61 100644 --- a/crates/ui-egui/src/dialogs/plugin.rs +++ b/crates/ui-egui/src/dialogs/plugin.rs @@ -16,7 +16,9 @@ //! //! Dialog `pluginInstall` asks a person to approve a plug-in's permissions or heavy budget. What it //! shows and installs is held by the app, not in its fields; a control request or synthetic input -//! can't confirm it. Enter confirms it only when OK has the keyboard focus. +//! can't confirm it. Enter confirms it only when OK has the keyboard focus. OK takes no click, Enter +//! or Space until the question has been on screen in the focused window for [`APPROVAL_DELAY`], and +//! that time starts over for every new question and whenever the window loses the focus. use std::sync::Arc; @@ -329,6 +331,69 @@ pub(crate) struct PendingInstall { pub params: Value, /// What OK approves: the module the question is about. pub approval: InstallApproval, + /// How long the question has been on screen; [`ask_install`] starts it over. + pub shown: Shown, +} + +/// How long, in seconds, the approval's question stays on screen in the focused window, on every +/// frame and unchanged, before its OK takes a click, Enter or Space. +pub(crate) const APPROVAL_DELAY: f64 = 0.5; + +/// How long the approval's question has been on screen, from the frames that drew it. +#[derive(Default)] +pub(crate) struct Shown { + /// The time of the first frame of the current showing: drawn, visible, in the focused window, + /// right after a frame that drew it too. `None` while it isn't shown. + since: Option, + /// The pass number and time of the last frame that drew it. + pass: Option, + last: Option, + /// A pointer button went down before OK took input, and hasn't been let go of. + pressed_early: bool, + /// OK takes input: shown for [`APPROVAL_DELAY`], with no press from before that held. + ready: bool, +} + +impl Shown { + /// Notes a frame that draws the question in `ui` → whether OK is enabled on it. The showing + /// starts over when the frame before didn't draw it, when time went back, and on any frame + /// that is a measuring pass, is invisible or comes while the window doesn't have the focus. + fn drawn(&mut self, ui: &egui::Ui) -> bool { + let pass = ui.ctx().cumulative_pass_nr(); + let (now, focused, pressed) = ui.input(|i| (i.time, i.focused, i.pointer.any_pressed())); + let seen = focused && ui.is_visible() && !ui.is_sizing_pass() && now.is_finite(); + let follows = self.pass.is_some_and(|p| p.checked_add(1) == Some(pass)) && self.last.is_some_and(|t| t <= now); + if !seen || !follows { + self.since = None; + } + if seen && self.since.is_none() { + self.since = Some(now); + } + (self.pass, self.last) = (Some(pass), Some(now)); + let enabled = self.since.is_some_and(|s| now - s >= APPROVAL_DELAY); + if pressed { + self.pressed_early = !enabled; + } + self.ready = enabled && !self.pressed_early; + if let Some(s) = self.since.filter(|_| !enabled) { + ui.ctx().request_repaint_after_secs((s + APPROVAL_DELAY - now) as f32); + } + enabled + } + + /// After the frame's OK: a press from before OK took input stops counting once every pointer + /// button is up. + fn released(&mut self, ui: &egui::Ui) { + if !ui.input(|i| i.pointer.any_down()) { + self.pressed_early = false; + } + } + + /// Whether OK takes input: the question was shown for [`APPROVAL_DELAY`] on the last frame + /// that drew it. + pub(crate) fn ready(&self) -> bool { + self.ready + } } /// No OK in the shared footer: the OK in its body is a `Sense::click` button, which Enter clicks @@ -342,15 +407,20 @@ pub(super) const INSTALL_SPEC: DialogSpec = DialogSpec { ..DialogSpec::FORM }; -/// Asks a person whether to install what `pending` describes. → `{pending}`. -pub(crate) fn ask_install(app: &mut VectorcraftApp, pending: PendingInstall) -> Value { +/// Asks a person whether to install what `pending` describes, its question not yet shown, even when +/// it reads as the one it replaces. → `{pending}`. +pub(crate) fn ask_install(app: &mut VectorcraftApp, mut pending: PendingInstall) -> Value { + pending.shown = Shown::default(); app.pending_plugin_install = Some(pending); app.ui.dialog = Some(Dialog::new(INSTALL_KIND, json!({}))); json!({ "pending": INSTALL_KIND }) } +/// The question, the lines under it, and OK, which is disabled until the question has been shown +/// for [`APPROVAL_DELAY`] ([`Shown`]), and Cancel, which never waits. fn install_body(app: &mut VectorcraftApp, ui: &mut egui::Ui, _: &mut Dialog) -> DialogAction { - let Some(pending) = app.pending_plugin_install.as_ref() else { return DialogAction::Cancel }; + let Some(pending) = app.pending_plugin_install.as_mut() else { return DialogAction::Cancel }; + let enabled = pending.shown.drawn(ui); ui.label(egui::RichText::new(&pending.message).strong()); ui.add_space(8.0); ui.label(egui::RichText::new(&pending.detail).color(crate::theme::Tokens::get(ui.ctx()).text_dim)); @@ -358,9 +428,10 @@ fn install_body(app: &mut VectorcraftApp, ui: &mut egui::Ui, _: &mut Dialog) -> let mut action = DialogAction::None; let row = egui::vec2(ui.min_rect().width(), ui.spacing().interact_size.y); ui.allocate_ui_with_layout(row, egui::Layout::right_to_left(egui::Align::Center), |ui| { - if widgets::primary_button_enabled(ui, "OK", true).clicked() { + if widgets::primary_button_enabled(ui, "OK", enabled).clicked() && pending.shown.ready() { action = DialogAction::Confirm; } + pending.shown.released(ui); ui.add_space(8.0); if widgets::secondary_button(ui, "Cancel").clicked() { action = DialogAction::Cancel; @@ -370,11 +441,15 @@ fn install_body(app: &mut VectorcraftApp, ui: &mut egui::Ui, _: &mut Dialog) -> } /// OK: installs the module the dialog showed, if a person pressed it; automation is refused and the -/// dialog stays open. +/// dialog stays open. Before the question has been shown for [`APPROVAL_DELAY`] it installs nothing +/// and the dialog stays open. → `{pending}`. fn install_confirm(app: &mut VectorcraftApp, _: &Dialog) -> Result { if app.automation_is_acting() { return Err("Only the person at the keyboard can approve installing a plug-in".into()); } + if app.pending_plugin_install.as_ref().is_some_and(|p| !p.shown.ready()) { + return Ok(json!({ "pending": INSTALL_KIND })); + } app.ui.dialog = None; let Some(pending) = app.pending_plugin_install.take() else { return Ok(Value::Null) }; app.session.approve_plugin_install(pending.approval); diff --git a/crates/ui-egui/src/io/open.rs b/crates/ui-egui/src/io/open.rs index 50f13e695..88375f3a8 100644 --- a/crates/ui-egui/src/io/open.rs +++ b/crates/ui-egui/src/io/open.rs @@ -279,7 +279,7 @@ pub fn install_module(app: &mut VectorcraftApp, module: Value) -> Result; + +/// The dialog layer of `app` in a window, each frame drawn at the time [`at`] gives it. Building it +/// draws frames at times of its own, so a test opens the dialog after. +fn window(app: VectorcraftApp) -> Window { + let mut fonts = false; + egui_kittest::Harness::builder().with_size(egui::vec2(900.0, 800.0)).build_ui_state( + move |ui, app: &mut VectorcraftApp| { + if !std::mem::replace(&mut fonts, true) { + theme::install_fonts(ui.ctx()); + return; + } + dialogs::show(app, ui.ctx()); + }, + app, + ) +} + +/// Draws a frame of `h` at `time` seconds. +fn at(h: &mut Window, time: f64) { + h.input_mut().time = Some(time); + h.step(); +} + +/// Whether the approval's OK is enabled, as a screen reader reads it. +fn ok_enabled(h: &Window) -> bool { + use egui_kittest::kittest::{NodeT, Queryable}; + !h.get_by_label("OK").accesskit_node().is_disabled() +} + +/// Clicks the approval's OK as a person does, on a frame at `time` seconds. +fn click_ok(h: &mut Window, time: f64) { + use egui_kittest::kittest::Queryable; + h.get_by_label("OK").click(); + at(h, time); +} + +/// The approval is open and plug-in `id` neither installed nor granted. +fn waiting(app: &VectorcraftApp, id: &str) -> bool { + app.ui.dialog.as_ref().is_some_and(|d| d.kind == dialogs::plugin::INSTALL_KIND) + && vectorcraft_plugins::registry::get(id).is_none() + && vectorcraft_plugins::registry::granted(id).is_none() +} + fn red_rect(app: &mut VectorcraftApp) -> vectorcraft_doc::NodeId { let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 40, "height": 40})).unwrap()["id"].as_u64().unwrap(); app.run("paint.setFill", json!({"color": "#ff0000"})).unwrap(); @@ -163,6 +225,7 @@ fn installing_a_plugin_that_lists_permissions_asks_first() { // File › Open asks too, and OK installs it with what it lists granted. io::open_bytes(&mut app, "tidy.wasm", &bytes, None).unwrap(); assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(dialogs::plugin::INSTALL_KIND)); + show_approval(&mut app); dialogs::confirm(&mut app).unwrap(); assert!(app.ui.dialog.is_none()); assert!(vectorcraft_plugins::registry::get(id).is_some()); @@ -195,6 +258,7 @@ fn installing_a_plugin_that_lists_permissions_asks_first() { assert_eq!(vectorcraft_plugins::registry::get(id).map(|p| p.manifest().name.clone()), Some(name.into()), "Cancel keeps the one installed"); assert_ne!(vectorcraft_plugins::registry::granted(id).map(|g| g.hash), Some(again_hash), "Cancel records no grant"); app.run("ui.installPlugin", json!({"dataBase64": vectorcraft_format::base64_encode(&again)})).unwrap(); + show_approval(&mut app); dialogs::confirm(&mut app).unwrap(); assert!(app.ui.dialog.is_none(), "one question"); assert_eq!(vectorcraft_plugins::registry::get(id).map(|p| p.manifest().name.clone()), Some("Tidy 2".into())); @@ -209,11 +273,128 @@ fn installing_a_plugin_that_lists_permissions_asks_first() { assert_eq!(vectorcraft_plugins::registry::get(id).map(|p| p.manifest().name.clone()), Some("Tidy 2".into()), "Cancel keeps the one installed"); // OK keeps the `replace` it was asked with: `false` leaves the installed one in place. app.run("ui.installPlugin", json!({"dataBase64": vectorcraft_format::base64_encode(&more), "replace": false})).unwrap(); + show_approval(&mut app); let _ = dialogs::confirm(&mut app); assert_eq!(vectorcraft_plugins::registry::get(id).map(|p| p.manifest().name.clone()), Some("Tidy 2".into()), "replace: false is kept"); app.run("plugin.remove", json!({"id": id})).unwrap(); } +/// The approval's OK takes no click, Enter or Space, and a confirm installs nothing, until its +/// question has been on screen for [`dialogs::plugin::APPROVAL_DELAY`]; then a click installs it. +/// Cancel works on the first frame. +#[test] +fn the_install_approval_takes_ok_only_once_its_question_was_shown_for_a_moment() { + use egui_kittest::kittest::Queryable; + let id = "test.ui.approval.delay"; + let module = json!({"dataBase64": vectorcraft_format::base64_encode(&asking(id, "Delay", r#"["doc.read"]"#))}); + let mut h = window(app()); + h.state_mut().run("ui.installPlugin", module.clone()).unwrap(); + assert_eq!(dialogs::confirm(h.state_mut()).unwrap(), json!({"pending": dialogs::plugin::INSTALL_KIND}), "not drawn yet"); + assert!(waiting(h.state(), id)); + at(&mut h, 10.0); + at(&mut h, 10.0); + assert!(!ok_enabled(&h), "disabled when it first shows"); + click_ok(&mut h, 10.1); + assert!(waiting(h.state(), id), "a click"); + for (key, time) in [(egui::Key::Enter, 10.2), (egui::Key::Space, 10.3)] { + h.get_by_label("OK").focus(); + at(&mut h, time); + h.key_press(key); + at(&mut h, time + 0.05); + assert!(waiting(h.state(), id), "{key:?}"); + } + at(&mut h, 10.49); + assert!(!ok_enabled(&h), "still disabled just before the delay"); + assert_eq!(dialogs::confirm(h.state_mut()).unwrap()["pending"], dialogs::plugin::INSTALL_KIND, "a confirm"); + assert!(waiting(h.state(), id)); + at(&mut h, 10.5); + assert!(ok_enabled(&h), "enabled once shown for the delay"); + click_ok(&mut h, 10.6); + assert!(h.state().ui.dialog.is_none(), "{}", h.state().ui.status); + assert!(vectorcraft_plugins::registry::get(id).is_some()); + h.state_mut().run("plugin.remove", json!({"id": id})).unwrap(); + + h.state_mut().run("ui.installPlugin", module).unwrap(); + at(&mut h, 20.0); + at(&mut h, 20.0); + h.get_by_label("Cancel").click(); + at(&mut h, 20.0); + assert!(h.state().ui.dialog.is_none() && h.state().pending_plugin_install.is_none(), "Cancel doesn't wait"); + assert!(vectorcraft_plugins::registry::get(id).is_none()); +} + +/// The wait starts over for each question: one that takes the place of the question shown just +/// before a click lands, which the click doesn't approve; the same question asked again; and the +/// question shown when the window gets the focus back. +#[test] +fn the_install_approval_waits_again_for_a_new_question_and_after_the_focus_comes_back() { + let (a, b) = ("test.ui.approval.swap.a", "test.ui.approval.swap.b"); + let module = |id: &str| json!({"dataBase64": vectorcraft_format::base64_encode(&asking(id, "Same", r#"["doc.read"]"#))}); + let mut h = window(app()); + h.state_mut().run("ui.installPlugin", module(a)).unwrap(); + at(&mut h, 10.0); + at(&mut h, 10.0); + at(&mut h, 10.6); + assert!(ok_enabled(&h)); + // Between two frames, a request closes the question and asks about another module. + dialogs::cancel(h.state_mut()); + h.state_mut().run("ui.installPlugin", module(b)).unwrap(); + click_ok(&mut h, 10.7); + assert!(waiting(h.state(), b) && vectorcraft_plugins::registry::get(a).is_none(), "the click approves neither"); + assert!(!ok_enabled(&h)); + at(&mut h, 11.19); + assert!(!ok_enabled(&h)); + at(&mut h, 11.2); + assert!(ok_enabled(&h), "the new question waits its own delay"); + // The same question again, from the same bytes. + dialogs::cancel(h.state_mut()); + h.state_mut().run("ui.installPlugin", module(b)).unwrap(); + at(&mut h, 11.3); + assert!(!ok_enabled(&h), "asked again, it waits again"); + at(&mut h, 11.8); + assert!(ok_enabled(&h)); + // The window loses the focus and gets it back. + h.input_mut().focused = false; + at(&mut h, 11.9); + assert!(!ok_enabled(&h), "not while the window doesn't have the focus"); + assert_eq!(dialogs::confirm(h.state_mut()).unwrap()["pending"], dialogs::plugin::INSTALL_KIND); + h.input_mut().focused = true; + at(&mut h, 12.0); + at(&mut h, 12.49); + assert!(!ok_enabled(&h), "back in front, it waits again"); + at(&mut h, 12.5); + click_ok(&mut h, 12.6); + assert!(h.state().ui.dialog.is_none(), "{}", h.state().ui.status); + assert!(vectorcraft_plugins::registry::get(b).is_some() && vectorcraft_plugins::registry::get(a).is_none()); + h.state_mut().run("plugin.remove", json!({"id": b})).unwrap(); +} + +/// A press on OK that began before OK took input approves nothing when it is let go after; a new +/// click does. +#[test] +fn a_press_on_the_install_approval_from_before_ok_took_input_approves_nothing() { + use egui_kittest::kittest::Queryable; + let id = "test.ui.approval.held"; + let mut h = window(app()); + let module = json!({"dataBase64": vectorcraft_format::base64_encode(&asking(id, "Held", r#"["doc.read"]"#))}); + h.state_mut().run("ui.installPlugin", module).unwrap(); + at(&mut h, 10.0); + at(&mut h, 10.0); + let pos = h.get_by_label("OK").rect().center(); + let button = |pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + h.input_mut().events.extend([egui::Event::PointerMoved(pos), button(true)]); + at(&mut h, 10.1); + at(&mut h, 10.6); + assert!(ok_enabled(&h)); + h.input_mut().events.push(button(false)); + at(&mut h, 10.7); + assert!(waiting(h.state(), id), "the press began before OK took input"); + click_ok(&mut h, 10.8); + assert!(h.state().ui.dialog.is_none(), "{}", h.state().ui.status); + assert!(vectorcraft_plugins::registry::get(id).is_some()); + h.state_mut().run("plugin.remove", json!({"id": id})).unwrap(); +} + /// A module with other bytes than the one installed with its id asks before taking its place: in /// the ordinary confirm when it needs nothing that only a person can approve, and in the approval /// dialog, with its id and file hash, when it also asks for a heavy budget. @@ -246,6 +427,7 @@ fn installing_a_plugin_over_another_module_asks_first() { let pending = app.pending_plugin_install.as_ref().unwrap(); assert!(pending.detail.ends_with(&format!("\n\nID: {id}\nFile SHA-256: {heavy_hash}\n\n{takes_place}")), "{}", pending.detail); assert_eq!(pending.params["replace"], json!(true), "OK replaces the one installed"); + show_approval(&mut app); dialogs::confirm(&mut app).unwrap(); assert_eq!(vectorcraft_plugins::registry::get(id).map(|p| p.hash().to_string()), Some(heavy_hash)); app.run("plugin.remove", json!({"id": id})).unwrap(); @@ -404,6 +586,7 @@ fn installing_a_plugin_that_reports_progress_says_so() { assert_eq!(r["pending"], dialogs::plugin::INSTALL_KIND); let detail = app.pending_plugin_install.as_ref().unwrap().detail.clone(); assert!(detail.ends_with("• Read the objects it is given\n• Show its progress, see Cancel and write log lines"), "{detail}"); + show_approval(&mut app); dialogs::confirm(&mut app).unwrap(); assert_eq!(vectorcraft_plugins::registry::granted(id).map(|g| g.permissions.len()), Some(2)); app.run("plugin.remove", json!({"id": id})).unwrap(); diff --git a/docs/mcp.md b/docs/mcp.md index 5d4c5cdbd..2ba77e5b8 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -7443,7 +7443,9 @@ runs it where it is called, so the call may take minutes; `notifications/cancell A plug-in whose manifest lists permissions (`permissions` in its `plugin.inspect` entry, with `needsApproval: true`) needs a person's approval too, and `plugin.install` refuses `grant: [permission]` from a request. With a desktop app connected, `ui.installPlugin` opens the approval dialog and returns `{pending: "pluginInstall"}`; `answer_dialog`, -`ui.dialog.confirm` and injected input can't confirm it. A `vectorcraft-cli mcp` started with `--allow-plugin-grants` +`ui.dialog.confirm` and injected input can't confirm it. Its OK takes input only once the question has been on screen +in the focused window for half a second, counted again for every new question and after the window gets the focus +back. A `vectorcraft-cli mcp` started with `--allow-plugin-grants` accepts `grant` and `allowHeavy` from any request, in that session only (headless; it can't be combined with `--connect`, `--automation-read-root` or `--automation-write-root`). A server confined to folders has no plug-in store, so it reads no `grants.json`, and a module that lists permissions or asks for a heavy budget does not install diff --git a/docs/plugins.md b/docs/plugins.md index 06fd89f28..2e6e79262 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -212,7 +212,12 @@ capability model proposed for PhotoCraft plug-ins names shown have control and bidirectional characters escaped (`\u{202E}`) and are cut to 64 characters. Only a person can press its OK: `ui.dialog.confirm`, MCP's `answer_dialog` and injected input (`ui.key`, `ui.click`) are refused and the dialog stays open. Enter confirms it only when OK has the keyboard focus. `ui.dialog.set` can't - change what it installs. + change what it installs. OK stays disabled until the question has been on screen in the focused window for half a + second (VectorCraft's own value, from the delay the security guidance at + https://chromium.googlesource.com/chromium/src/+/refs/heads/main/docs/security/security-considerations-for-browser-ui.md + gives for dialog boxes), so a click, Enter or Space, or a press begun before then, installs nothing. The half second + starts over for every new question, even one that reads the same, and when the window gets the focus back. Cancel + and Escape work at once. - `plugin.install` refuses the module, naming the permissions it needs, unless this module already has a recorded grant (`plugin.inspect`'s `needsApproval` tells it beforehand). A request's `grant` is refused too: `plugin.install` takes it only with the approval the dialog gives. A `vectorcraft-cli mcp` or `run` started with `--allow-plugin-grants` diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9599a6373..80186e7bc 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1652,6 +1652,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Filter plug-ins read type, images and symbol instances (G17, #4333):** a filter at plug-in API level 2 lists `inputs` (`type`, `image`, `symbolInstance`) with `doc.read` in its `permissions`, so installing it asks for approval, and is also given the selected objects of those kinds, read only: type with its runs, character and paragraph attributes (no text past 256 KiB or 4,096 runs or paragraphs, or 16 MiB of runs over the input), images with their size, color mode and, when embedded and at most 1 MiB in base64 (16 MiB over the input), their RGBA pixels (a CMYK image's through the working CMYK profile; never a linked file's), and symbol instances with their symbol and transform. The objects come in paint order from the bottom, and an image that reaches the decoder counts toward the 16 MiB of pixels even when decoding fails. Every object counts toward the input's half of the memory cap; one whose fields besides its pixels and runs don't fit fails the run naming it and its size, a path or compound path that doesn't fit fails it too, and so do more than 100,000 of these objects. Its output returns each as `{id}` or exactly as given; any change or omission is refused before the document changes. Filters without `inputs` get the same input as before, except that compound paths that clip a clip group and paths inside a group that clips one are left out for every filter. Regressions: `crates/plugins/src/objects/tests.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged. - **Plug-in grants cover one module (G17):** a grant counts only for the module with the id and SHA-256 it records. A module that lists permissions and has other bytes under the same id needs a new approval, even with the same permissions: `plugin.inspect` reports `needsApproval`, `plugin.install` refuses it without `grant`, Install Plug-in… and File › Open ask again, and the plug-in store and plug-in folders skip it. A module with a missing or empty permissions list needs no grant; without the list, it gets its kind's implied permissions. A grant with a malformed hash covers nothing. Regressions: `crates/plugins/src/permissions.rs`, `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. - **Plug-in approval needs a person (G17):** a plug-in's permissions and heavy budget are approved in a dialog that only a person can confirm: the control channel, MCP and injected input are refused, and `plugin.install` takes `grant` or `allowHeavy` only with that approval. `vectorcraft-cli mcp` and `run` refuse them unless started with `--allow-plugin-grants`. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/tests/automation.rs`, `apps/vectorcraft-cli/tests/it/cli.rs`. Scores and hours unchanged. +- **Plug-in approval waits before OK (G17):** the approval dialog's OK stays disabled until its question has been on screen in the focused window for half a second, VectorCraft's own value; a click, Enter, Space, or a press begun before then installs nothing, and the dialog stays open. The wait starts over for every new question, even one that reads the same, and when the window gets the focus back, so a question swapped in just before a click is not approved by it. Cancel works at once. Regressions: `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. - **A plug-in with no permissions ends its id's grant (G17):** installing a module that lists no permissions under a granted id forgets the grant, in the process and in `grants.json`, so the formerly approved bytes need a new approval. Attaching a `Plug-ins` folder whose module for that id lists no permissions forgets it too, and plug-in folders forget the process's grant. A refused install leaves the grant. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_safe_mode.rs`. Scores and hours unchanged. - **Plug-in commands (G17):** a filter or live effect may declare up to 32 `commands` (`{id, label, params?}`); each is the command `plugin..` while the plug-in is installed, in a submenu named after the plug-in under Object › Plug-ins (Effect › Plug-ins for a live effect) whatever menu the manifest names, with its label escaped. Commands are recorded in actions, listed in Keyboard Shortcuts for the user to give a shortcut, and run through `run` and MCP with their parameters checked first (a command with parameters opens a dialog from its menu item or shortcut). A run uses the plug-in's permissions (a live effect with commands may also hold `doc.write` and `state`, and gets them without a `permissions` list), is one undo step named after the command, and on failure (a trap or an exhausted instruction budget) leaves the document and the undo history as they were with an error naming the plug-in and the command. For a command and for `plugin.run`, an `ids` that is not a list of object ids is an error rather than the selection. A malformed entry is refused by name when the module is installed. Regressions: `crates/plugins/src/manifest.rs`, `crates/plugins/src/permissions.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. - **Filter plug-ins call commands (G17, #2186):** a filter at plug-in API level 2 with the `commands` permission names the commands it may run in its manifest's `commandAllowlist` and runs them with `env.vc_call`, reading each result with `env.vc_call_result`. Each call sees what the calls before it changed; the calls and the filter's output are one undo step, and a run that fails, is canceled or makes a call that fails leaves every document, its selection and its history as they were. Commands that read or write files, use the clipboard, change preferences or the view, run scripts, actions or plug-ins, switch documents or undo are never called, and a manifest that lists one is refused when the module is installed. The install dialog lists the allowed commands. A run makes at most 1,000 calls, and each call costs 10,000 instructions of its budget. Regressions: `crates/plugins/tests/call.rs`, `crates/engine/tests/plugin_call.rs`, `crates/engine/src/cmd/plugin_call.rs`. Scores and hours unchanged.