From a4a516e82fc4b91248d0ae7a56f96d519b722dc2 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 11:03:38 -0800 Subject: [PATCH 01/10] Plug-ins: only a person can approve a plug-in's permissions and heavy budget `plugin.install` accepted `grant` and `allowHeavy` from any client, so the program installing a plug-in could answer its own permission prompt. The desktop app now shows an approval dialog that names the module, its hash and what it asks for. Confirming it gives the engine a one-use approval tied to that module id, hash and permission list. A control or MCP request and a frame of synthetic input cannot confirm the dialog, and Enter does not confirm it. Without such an approval, `grant` and `allowHeavy` are refused. The new `--allow-plugin-grants` flag on `mcp` and `run` lets a person who starts a headless session accept them from requests. The flag cannot be combined with `--connect`. A module already approved in the desktop app still installs through its stored grant. --- apps/vectorcraft-cli/src/main.rs | 33 ++++- apps/vectorcraft-cli/tests/it/cli.rs | 25 ++++ crates/engine/src/cmd/plugin.rs | 92 +++++++++++--- crates/engine/src/lib.rs | 6 + crates/engine/src/session/documents.rs | 2 + crates/engine/src/tests/plugins.rs | 92 ++++++++++++++ crates/engine/tests/plugin_fuzz.rs | 1 + crates/ui-egui/src/app.rs | 1 + crates/ui-egui/src/app/links.rs | 8 +- crates/ui-egui/src/dialogs.rs | 4 + crates/ui-egui/src/dialogs/plugin.rs | 75 ++++++++++- .../src/dialogs/tests_plugin_fields.rs | 6 + crates/ui-egui/src/i18n/bs.tsv | 6 +- crates/ui-egui/src/i18n/ca.tsv | 6 +- crates/ui-egui/src/i18n/cs.tsv | 6 +- crates/ui-egui/src/i18n/de.tsv | 6 +- crates/ui-egui/src/i18n/el.tsv | 6 +- crates/ui-egui/src/i18n/es.tsv | 6 +- crates/ui-egui/src/i18n/fi.tsv | 6 +- crates/ui-egui/src/i18n/fr.tsv | 6 +- crates/ui-egui/src/i18n/hi.tsv | 6 +- crates/ui-egui/src/i18n/hu.tsv | 6 +- crates/ui-egui/src/i18n/it.tsv | 6 +- crates/ui-egui/src/i18n/ja.tsv | 6 +- crates/ui-egui/src/i18n/ka.tsv | 6 +- crates/ui-egui/src/i18n/ko.tsv | 6 +- crates/ui-egui/src/i18n/pl.tsv | 6 +- crates/ui-egui/src/i18n/pt-br.tsv | 6 +- crates/ui-egui/src/i18n/ru.tsv | 6 +- crates/ui-egui/src/i18n/sv.tsv | 6 +- crates/ui-egui/src/i18n/tr.tsv | 6 +- crates/ui-egui/src/i18n/uk.tsv | 6 +- crates/ui-egui/src/i18n/vi.tsv | 6 +- crates/ui-egui/src/i18n/zh-hans.tsv | 6 +- crates/ui-egui/src/i18n/zh-hant.tsv | 6 +- crates/ui-egui/src/io.rs | 1 + crates/ui-egui/src/io/open.rs | 116 ++++++++++-------- crates/ui-egui/src/lib.rs | 3 + crates/ui-egui/src/menus/ui_commands.rs | 2 +- crates/ui-egui/src/tests/automation.rs | 38 ++++++ crates/ui-egui/src/tests/plugin_runs.rs | 11 +- crates/ui-egui/src/tests/plugins.rs | 27 ++-- docs/control-protocol.md | 3 +- docs/mcp.md | 18 +-- docs/plugins.md | 21 ++-- docs/target-app-parity.md | 1 + 46 files changed, 565 insertions(+), 159 deletions(-) diff --git a/apps/vectorcraft-cli/src/main.rs b/apps/vectorcraft-cli/src/main.rs index 2d8df4592..6f41d65ee 100644 --- a/apps/vectorcraft-cli/src/main.rs +++ b/apps/vectorcraft-cli/src/main.rs @@ -72,7 +72,7 @@ const USAGE: &str = "\ vectorcraft-cli — VectorCraft automation USAGE: - vectorcraft-cli mcp [--connect ADDR | --headless] [--automation-read-root DIR]... [--automation-write-root DIR]... + vectorcraft-cli mcp [--connect ADDR | --headless] [--automation-read-root DIR]... [--automation-write-root DIR]... [--allow-plugin-grants] Run the MCP server on stdio. Default: connect to a running app at 127.0.0.1:7979 (vectorcraft --control 7979), falling back to a headless in-process session. --automation-read-root / --automation-write-root confine the files the agent's commands read @@ -80,8 +80,12 @@ USAGE: DIR, links followed; each may be given more than once, a path inside any of its folders being allowed, and a kind left out grants none of its access. They imply --headless: a running app enforces its own (vectorcraft --control PORT --automation-read-root DIR …). + --allow-plugin-grants lets plugin.install's grant and allowHeavy take effect in this session, + so whoever sends the commands can give a plug-in the permissions it lists, and a heavy budget, + with nobody approving them. Without it they are refused, and a plug-in installs only when a + person already approved its hash in the desktop app. It implies --headless. - vectorcraft-cli run [--in FILE] [--cmd ID [--params JSON] | --commands FILE|-]... [--export FILE]... [--scale N] [--ppi N] + vectorcraft-cli run [--in FILE] [--cmd ID [--params JSON] | --commands FILE|-]... [--export FILE]... [--scale N] [--ppi N] [--allow-plugin-grants] Headless batch: open FILE (any readable format) or start a new document, run commands in order, export each FILE in the format its extension picks (see Writable formats; a .ait FILE is a template, written as file.saveAsTemplate writes it). Raster exports use the document's @@ -91,6 +95,8 @@ USAGE: --cmd and --export steps: a JSON array of {\"command\": ID, \"params\": {...}} (or \"id\" for \"command\"), or one such object per line (JSON Lines), at most 64 MiB and 100000 steps. Errors name the step, counting from 0 as \"$N\" references do. + --allow-plugin-grants lets plugin.install's grant and allowHeavy take effect in this run, as + for mcp. vectorcraft-cli commands Print the command catalogue as JSON. @@ -213,12 +219,21 @@ fn with_stores(mut h: Headless) -> Headless { h } +/// Makes `h`'s `plugin.install` accept `grant` and `allowHeavy` when `allow` (`--allow-plugin-grants`). +fn accepting_grants(mut h: Headless, allow: bool) -> Headless { + if allow { + h.session.allow_plugin_grants_from_requests(); + } + h +} + fn mcp(args: &[String]) -> Result<(), String> { if help_requested(args, &["--connect", "--automation-read-root", "--automation-write-root"]) { return help(); } let mut connect: Option = None; let mut headless = false; + let mut allow_grants = false; let (mut read_roots, mut write_roots): (Vec, Vec) = (vec![], vec![]); let mut it = args.iter(); while let Some(a) = it.next() { @@ -230,6 +245,7 @@ fn mcp(args: &[String]) -> Result<(), String> { match flag { "--connect" => connect = Some(it.next().cloned().ok_or("--connect needs an address")?), "--headless" => headless = true, + "--allow-plugin-grants" => allow_grants = true, "--automation-read-root" | "--automation-write-root" => { let dir = inline.or_else(|| it.next().cloned()).ok_or_else(|| format!("{flag} needs a folder"))?; if flag == "--automation-read-root" { read_roots.push(dir) } else { write_roots.push(dir) } @@ -240,6 +256,9 @@ fn mcp(args: &[String]) -> Result<(), String> { if headless && connect.is_some() { return Err("use either --connect or --headless".into()); } + if allow_grants && connect.is_some() { + return Err("--allow-plugin-grants applies to the headless server; in a running app a person approves each plug-in".into()); + } let read: Vec<&Path> = read_roots.iter().map(Path::new).collect(); let write: Vec<&Path> = write_roots.iter().map(Path::new).collect(); let roots = AutomationRoots::new(&read, &write)?; @@ -251,9 +270,9 @@ fn mcp(args: &[String]) -> Result<(), String> { // Every thread of this process, not only the server's: nothing reaches past the roots. file_access::confine_process(roots.clone()); eprintln!("vectorcraft-cli: files confined: {}", roots.describe()); - Box::new(Headless::with_document().with_automation_roots(Some(roots))) - } else if headless { - Box::new(with_stores(Headless::with_document())) + Box::new(accepting_grants(Headless::with_document().with_automation_roots(Some(roots)), allow_grants)) + } else if headless || allow_grants { + Box::new(accepting_grants(with_stores(Headless::with_document()), allow_grants)) } else if let Some(addr) = connect { // Explicit address: fail loudly if the app isn't there. Box::new(Remote::connect(&addr).map_err(|e| format!("cannot connect to {addr}: {e}"))?) @@ -512,6 +531,7 @@ fn run(args: &[String]) -> Result<(), String> { let mut stdin_read = false; let mut scale = None::; let mut ppi = None::; + let mut allow_grants = false; let mut it = args.iter(); while let Some(a) = it.next() { let mut val = |what: &str| it.next().cloned().ok_or_else(|| format!("{a} needs {what}")); @@ -546,6 +566,7 @@ fn run(args: &[String]) -> Result<(), String> { steps.push(Step::Export(val("a file")?)); params_for = None; } + "--allow-plugin-grants" => allow_grants = true, "--scale" | "-s" => { let raw = val("a number")?; scale = Some(raw.parse::().map_err(|_| format!("--scale {raw}: not a number"))?); @@ -563,7 +584,7 @@ fn run(args: &[String]) -> Result<(), String> { } } - let mut h = with_stores(Headless::new()); + let mut h = accepting_grants(with_stores(Headless::new()), allow_grants); let mut out = std::io::stdout().lock(); // A reader that stopped early (a closed pipe) isn't an error, and the batch goes on: its later // steps (an export…) still run, unprinted. diff --git a/apps/vectorcraft-cli/tests/it/cli.rs b/apps/vectorcraft-cli/tests/it/cli.rs index 9caf4bc92..8053737b9 100644 --- a/apps/vectorcraft-cli/tests/it/cli.rs +++ b/apps/vectorcraft-cli/tests/it/cli.rs @@ -19,6 +19,31 @@ fn empty_dir(dir: std::path::PathBuf) -> std::path::PathBuf { dir } +/// `run` refuses `plugin.install`'s `grant` unless the process was started with +/// `--allow-plugin-grants`, which `mcp` won't take with `--connect`. +#[test] +fn plugin_grants_need_the_start_up_flag() { + let module = vectorcraft_format::base64_encode(include_bytes!("../../../../crates/plugins/tests/fixtures/desaturate.wasm")); + let steps = tmp("grants.json"); + std::fs::write(&steps, json!([{"command": "plugin.install", "params": {"dataBase64": module, "grant": ["doc.read"]}}]).to_string()).unwrap(); + let config = empty_dir(std::env::temp_dir().join(format!("vectorcraft-cli-test-grants-{}", std::process::id()))); + let run = |extra: &[&str]| { + Command::new(BIN).env("VECTORCRAFT_CONFIG_DIR", &config).args(["run", "--commands", steps.to_str().unwrap()]).args(extra).output().unwrap() + }; + let out = run(&[]); + assert!(!out.status.success()); + let shown = String::from_utf8_lossy(&out.stderr).into_owned() + &String::from_utf8_lossy(&out.stdout); + assert!(shown.contains("--allow-plugin-grants"), "{shown}"); + let out = run(&["--allow-plugin-grants"]); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + + let out = Command::new(BIN).args(["mcp", "--allow-plugin-grants", "--connect", "127.0.0.1:1"]).output().unwrap(); + assert!(!out.status.success()); + assert!(String::from_utf8_lossy(&out.stderr).contains("--allow-plugin-grants applies to the headless server")); + let usage = Command::new(BIN).arg("help").output().unwrap(); + assert!(String::from_utf8_lossy(&usage.stdout).contains("--allow-plugin-grants lets plugin.install's grant and allowHeavy take effect")); +} + #[test] fn commands_prints_catalogue() { let out = Command::new(BIN).arg("commands").output().unwrap(); diff --git a/crates/engine/src/cmd/plugin.rs b/crates/engine/src/cmd/plugin.rs index 6457e2cc9..61ad9e8cb 100644 --- a/crates/engine/src/cmd/plugin.rs +++ b/crates/engine/src/cmd/plugin.rs @@ -10,15 +10,18 @@ //! exporters add a file format that `document.export` writes (`fileio::plugin_formats`). //! //! A filter may ask for a heavy budget (`Budget::Heavy`: minutes rather than a minute, for routing, -//! packing or simulation). It installs only once the user agrees (`plugin.install {allowHeavy}`), -//! which loading a folder doesn't ask, so folders skip it; the app runs it in the background with -//! Cancel ([`FilterRun`]). +//! packing or simulation). It installs only once a person agrees, which loading a folder doesn't +//! ask, so folders skip it; the app runs it in the background with Cancel ([`FilterRun`]). //! -//! A plug-in whose manifest lists permissions installs once the user grants them -//! (`plugin.install {grant}`, recorded with the module's hash and kept in the plug-in store), and -//! a filter does only what they allow: without `doc.read` it is given no objects, without -//! `doc.write` an output that changes the document is refused, and without `state` it neither -//! reads nor writes the data it keeps with the document. +//! A plug-in whose manifest lists permissions installs once a person grants them (recorded with +//! the module's hash and kept in the plug-in store), and a filter does only what they allow: +//! without `doc.read` it is given no objects, without `doc.write` an output that changes the +//! document is refused, and without `state` it neither reads nor writes the data it keeps with +//! the document. +//! +//! `plugin.install {grant, allowHeavy}` takes effect only with an [`InstallApproval`], which no +//! command can set, or in a session started to accept them from requests +//! ([`Session::allow_plugin_grants_from_requests`]). //! //! A plug-in of API level 2 that lists `progress` may import the host's `vc_progress`, //! `vc_cancelled` and `vc_log` ([`vectorcraft_plugins::host`]): run inside a @@ -332,8 +335,48 @@ fn grant_param(c: &str, p: &Value) -> Result>> { Ok(Some(out)) } -/// `plugin.install`: loads and checks the module, refuses one that asks for a heavy budget unless -/// `allowHeavy` says the user agreed, and one that lists permissions the user hasn't granted this +/// What a person approved for one install: the module they were shown (its id, SHA-256 and the +/// permissions it lists) and whether they agreed to its heavy budget. Only the code that asked the +/// person sets it ([`Session::approve_plugin_install`]); no command can. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct InstallApproval { + pub id: String, + pub hash: String, + pub permissions: Vec, + pub heavy: bool, +} + +impl InstallApproval { + /// Is `plugin` the module this approval was given for? + fn covers(&self, plugin: &Plugin) -> bool { + let m = plugin.manifest(); + self.id == m.id && self.hash == plugin.hash() && self.permissions.iter().map(String::as_str).eq(permissions::names(&m.permissions())) + } +} + +impl Session { + /// Lets the next `plugin.install` take its `grant` and `allowHeavy`, for the module `approval` + /// describes. Call it only with what a person approved, just before that install. + pub fn approve_plugin_install(&mut self, approval: InstallApproval) { + self.plugin_install_approval = Some(approval); + } + + /// Drops an approval that no install has spent. + pub fn clear_plugin_install_approval(&mut self) { + self.plugin_install_approval = None; + } + + /// Makes `plugin.install` accept `grant` and `allowHeavy` from any request in this session. + /// For the person starting the process (`vectorcraft-cli --allow-plugin-grants`), never for + /// a request. + pub fn allow_plugin_grants_from_requests(&mut self) { + self.plugin_grants_from_requests = true; + } +} + +/// `plugin.install`: loads and checks the module, refuses `grant` and `allowHeavy` that neither an +/// [`InstallApproval`] for this module nor the session allows, one that asks for a heavy budget +/// unless `allowHeavy` says a person agreed, one that lists permissions no person has granted this /// very module unless `grant` lists them all, and one whose SHA-256 isn't the `hash` given; records /// the grant with the module's hash, keeps it for the next start and installs it → its /// `plugin.list` entry. @@ -341,6 +384,8 @@ fn install(s: &mut Session, p: &Value) -> Result { const C: &str = "plugin.install"; let (replace, allow_heavy) = (switch_or(p, "replace", true, C)?, switch_or(p, "allowHeavy", false, C)?); let grant = grant_param(C, p)?; + // One use: spent whatever this install does. + let approval = s.plugin_install_approval.take(); let (plugin, bytes) = load_given(C, p)?; // `hash` pins the module the user was shown: one with other bytes is refused. if let Some(h) = p.get("hash").filter(|v| !v.is_null()) @@ -351,25 +396,38 @@ fn install(s: &mut Session, p: &Value) -> Result { if !replace && registry::get(plugin.id()).is_some() { return Err(bad(C, format!("a plug-in with id {:?} is already installed", plugin.id()))); } - // A heavy plug-in is installed once the user agrees: whoever installs it asks them first. + // `grant` and `allowHeavy` say that a person agreed, and a request alone can't say that. + let approval = approval.filter(|a| a.covers(&plugin)); + if !s.plugin_grants_from_requests && ((grant.is_some() && approval.is_none()) || (allow_heavy && !approval.is_some_and(|a| a.heavy))) { + return Err(bad( + C, + "`grant` and `allowHeavy` take effect only in a session started with --allow-plugin-grants: approve the plug-in in the desktop app", + )); + } + // A heavy plug-in is installed once a person agrees. if plugin.manifest().budget == Budget::Heavy && !allow_heavy { let (minutes, times) = vectorcraft_plugins::heavy_terms(); return Err(bad( C, format!( - "“{}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees", + "“{}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants", plugin.manifest().name ), )); } - // A plug-in that lists permissions installs once the user grants them: whoever installs it - // asks them first, unless they already granted this very module all of them. + // A plug-in that lists permissions installs once a person grants them, unless they already + // granted this very module all of them. let m = plugin.manifest(); let wanted = m.permissions(); if !s.plugin_approved(&plugin) && !grant.as_ref().is_some_and(|g| wanted.iter().all(|w| g.contains(w))) { let name = permissions::display_text(&m.name, permissions::MAX_SHOWN_NAME); let asked = permissions::names(&wanted).join(", "); - return Err(bad(C, format!("“{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees"))); + return Err(bad( + C, + format!( + "“{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants" + ), + )); } if m.asks_permissions() { let g = Grant { id: m.id.clone(), hash: plugin.hash().to_string(), permissions: wanted }; @@ -527,7 +585,7 @@ pub fn specs() -> Vec { "Install Plug-in", [], None, - "{path: a .wasm file (desktop) | dataBase64: the module (+ name?: shown as its source), replace?: bool (true: a plug-in with the same id is replaced), name?: file name (with dataBase64), allowHeavy?: bool (true once the user agreed to a heavy budget: a filter whose manifest asks for one, budget \"heavy\" in plugin.inspect, installs only with it), grant?: [permission] (the permissions the user granted: a plug-in whose manifest lists permissions, needsApproval in plugin.inspect, installs only when grant holds every one of them; the grant is recorded with the module's hash and covers only that module, so a module with other bytes needs a new grant even when it lists the same permissions; with a plug-in store attached, only the grants kept there count), hash?: string (the module's SHA-256 as plugin.inspect gave it: a module with other bytes is refused)} validate and install a WebAssembly plug-in for this session → its plugin.list entry", + "{path: a .wasm file (desktop) | dataBase64: the module (+ name?: shown as its source), replace?: bool (true: a plug-in with the same id is replaced), name?: file name (with dataBase64), allowHeavy?: bool (a filter whose manifest asks for a heavy budget, budget \"heavy\" in plugin.inspect, installs only with it), grant?: [permission] (a plug-in whose manifest lists permissions, needsApproval in plugin.inspect, installs only when grant holds every one of them; the grant is recorded with the module's hash and covers only that module, so a module with other bytes needs a new grant even when it lists the same permissions; with a plug-in store attached, only the grants kept there count; a request's allowHeavy and grant are refused unless the session was started with vectorcraft-cli --allow-plugin-grants: in the desktop app a person approves the install, see ui.installPlugin, and a module whose hash a person already approved needs no grant), hash?: string (the module's SHA-256 as plugin.inspect gave it: a module with other bytes is refused)} validate and install a WebAssembly plug-in for this session → its plugin.list entry", always, install ), @@ -536,7 +594,7 @@ pub fn specs() -> Vec { "Inspect Plug-in", [], None, - "{path: a .wasm file (desktop) | dataBase64: the module, name?: the file name shown as its source (with dataBase64)} validate a WebAssembly plug-in without installing it → the plugin.list entry it would have, plus needsApproval: bool (true when it lists permissions the user hasn't granted this very module, its bytes included: plugin.install then needs grant)", + "{path: a .wasm file (desktop) | dataBase64: the module, name?: the file name shown as its source (with dataBase64)} validate a WebAssembly plug-in without installing it → the plugin.list entry it would have, plus needsApproval: bool (true when it lists permissions no person has granted this very module, its bytes included: it then needs a person's approval, see plugin.install)", always, inspect ), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 399a4ed11..f2c12a7a0 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -132,6 +132,12 @@ pub struct Session { /// A filter's output that a run made in the background handed in for `plugin.run` to apply /// ([`cmd::plugin::FilterRun::hand_in`]); the next `plugin.run` takes it. plugin_output: Option, + /// What a person approved for the next `plugin.install` ([`Session::approve_plugin_install`]); + /// that install spends it, whatever it does. + plugin_install_approval: Option, + /// `plugin.install` takes `grant` and `allowHeavy` from any request + /// ([`Session::allow_plugin_grants_from_requests`]). + plugin_grants_from_requests: bool, /// URLs recently given in the Attributes panel (`attributes.set {url}`), newest first; not saved. pub recent_urls: Vec, /// The saved tracing presets' revision and latest changes ([`Session::trace_presets_revision`]). diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index ec9fdbac1..d46c9b9b8 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -71,6 +71,8 @@ impl Session { profile_dir: None, plugin_dir: None, plugin_output: None, + plugin_install_approval: None, + plugin_grants_from_requests: false, recent_urls: vec![], trace_preset_changes: Default::default(), ui_language: None, diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index 7d0983ef2..041a0463e 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -25,7 +25,15 @@ fn install_wat(s: &mut Session, src: &str) -> Value { install(s, &wat::parse_str(src).unwrap()) } +/// A session started to take `grant` and `allowHeavy` from requests, as most tests here install. fn session() -> Session { + let mut s = strict_session(); + s.allow_plugin_grants_from_requests(); + s +} + +/// A session as MCP and the CLI have by default: `grant` and `allowHeavy` need an approval. +fn strict_session() -> Session { let mut s = Session::new(); s.execute("file.new", &json!({"width": 400, "height": 200})).unwrap(); s @@ -1073,6 +1081,90 @@ fn a_level_2_filter_installs_with_progress_granted_and_reports_to_the_caller() { run(&mut s, "plugin.remove", json!({"id": id})); } +/// An approval of the module `wasm` with id `id`, its `permissions` and, if `heavy`, its budget. +fn approval(id: &str, wasm: &[u8], permissions: &[&str], heavy: bool) -> crate::cmd::plugin::InstallApproval { + crate::cmd::plugin::InstallApproval { + id: id.into(), + hash: vectorcraft_plugins::permissions::sha256_hex(wasm), + permissions: permissions.iter().map(|p| p.to_string()).collect(), + heavy, + } +} + +/// In a session that wasn't started to accept them, `grant` and `allowHeavy` are refused whichever +/// way the command runs, a batch included, until an approval covers the module, its id, SHA-256 and +/// permissions. An approval is spent by the first install, whatever it does. A hash already approved +/// still installs. +#[test] +fn a_request_cannot_grant_what_only_a_person_approves() { + let mut s = strict_session(); + let id = "test.engine.approval"; + let wasm = asking(id, r#"["doc.read"]"#, &json!({"objects": []})); + let data = vectorcraft_format::base64_encode(&wasm); + let refused = "take effect only in a session started with --allow-plugin-grants"; + let e = s.execute("plugin.install", &json!({"dataBase64": data, "grant": ["doc.read"]})).unwrap_err().to_string(); + assert!(e.contains(refused), "{e}"); + let e = s.execute("plugin.install", &json!({"dataBase64": data})).unwrap_err().to_string(); + assert!(e.contains("approve them in the desktop app") && e.contains("--allow-plugin-grants"), "{e}"); + let batch = json!({"commands": [{"command": "plugin.install", "params": {"dataBase64": data, "grant": ["doc.read"]}}]}); + let r = s.execute("command.batch", &batch); + assert!(r.as_ref().map_or(true, |v| v.to_string().contains(refused)), "a batch can't grant: {r:?}"); + assert!(vectorcraft_plugins::registry::get(id).is_none()); + + // Approved for other bytes, for another id or for other permissions: refused, and spent. + let other = asking(id, r#"["doc.read"]"#, &json!({"objects": [{"name": "x"}]})); + for wrong in [ + approval(id, &other, &["doc.read"], false), + approval("test.engine.approval.other", &wasm, &["doc.read"], false), + approval(id, &wasm, &["doc.read", "state"], false), + ] { + s.approve_plugin_install(wrong); + let e = s.execute("plugin.install", &json!({"dataBase64": data, "grant": ["doc.read"]})).unwrap_err().to_string(); + assert!(e.contains(refused), "{e}"); + } + assert!(vectorcraft_plugins::registry::get(id).is_none()); + + // The approval for this module installs it once. + s.approve_plugin_install(approval(id, &wasm, &["doc.read"], false)); + run(&mut s, "plugin.install", json!({"dataBase64": data, "grant": ["doc.read"]})); + assert_eq!(vectorcraft_plugins::registry::granted(id).map(|g| g.hash), Some(vectorcraft_plugins::permissions::sha256_hex(&wasm))); + let e = s.execute("plugin.install", &json!({"dataBase64": data, "grant": ["doc.read"]})).unwrap_err().to_string(); + assert!(e.contains(refused), "the approval was spent: {e}"); + // A hash already approved installs without one. + run(&mut s, "plugin.install", json!({"dataBase64": data})); + run(&mut s, "plugin.remove", json!({"id": id})); + + // A refused install spends the approval too. + s.approve_plugin_install(approval(id, &wasm, &["doc.read"], false)); + let e = s.execute("plugin.install", &json!({"dataBase64": data, "grant": ["doc.read"], "hash": "0"})).unwrap_err().to_string(); + assert!(e.contains("SHA-256"), "{e}"); + let e = s.execute("plugin.install", &json!({"dataBase64": data, "grant": ["doc.read"]})).unwrap_err().to_string(); + assert!(e.contains(refused), "{e}"); + + // The heavy budget needs its own agreement. + let heavy_id = "test.engine.approval.heavy"; + let heavy_wasm = heavy(heavy_id, 1); + let heavy_data = vectorcraft_format::base64_encode(&heavy_wasm); + let e = s.execute("plugin.install", &json!({"dataBase64": heavy_data, "allowHeavy": true})).unwrap_err().to_string(); + assert!(e.contains(refused), "{e}"); + // Without a `permissions` list it has the ones its kind implies. + let inspected = run(&mut s, "plugin.inspect", json!({"dataBase64": heavy_data})); + let implied: Vec<&str> = inspected["permissions"].as_array().unwrap().iter().filter_map(Value::as_str).collect(); + s.approve_plugin_install(approval(heavy_id, &heavy_wasm, &implied, false)); + let e = s.execute("plugin.install", &json!({"dataBase64": heavy_data, "allowHeavy": true})).unwrap_err().to_string(); + assert!(e.contains(refused), "an approval without the heavy budget doesn't cover it: {e}"); + let e = s.execute("plugin.install", &json!({"dataBase64": heavy_data})).unwrap_err().to_string(); + assert!(e.contains("approve it in the desktop app"), "{e}"); + s.approve_plugin_install(approval(heavy_id, &heavy_wasm, &implied, true)); + run(&mut s, "plugin.install", json!({"dataBase64": heavy_data, "allowHeavy": true})); + run(&mut s, "plugin.remove", json!({"id": heavy_id})); + + // A session started to accept them takes them from requests. + s.allow_plugin_grants_from_requests(); + run(&mut s, "plugin.install", json!({"dataBase64": data, "grant": ["doc.read"]})); + run(&mut s, "plugin.remove", json!({"id": id})); +} + /// A filter run the caller cancels (MCP's `notifications/cancelled`, the app's Cancel) stops between /// two slices of its budget and changes nothing. #[test] diff --git a/crates/engine/tests/plugin_fuzz.rs b/crates/engine/tests/plugin_fuzz.rs index 1791555f7..5a42300b1 100644 --- a/crates/engine/tests/plugin_fuzz.rs +++ b/crates/engine/tests/plugin_fuzz.rs @@ -308,6 +308,7 @@ proptest! { prop_assert!(shown.chars().count() <= vectorcraft_plugins::permissions::MAX_SHOWN_NAME); } let mut s = rich_session(); + s.allow_plugin_grants_from_requests(); let module = wat::parse_str(tpl::returning(&text, r#"{"objects":[]}"#)).unwrap(); let data = vectorcraft_format::base64_encode(&module); let r = catch_quiet(|| { diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 5b53073a3..625670eb8 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -165,6 +165,7 @@ impl VectorcraftApp { scrub_scope: None, automation_roots: None, synthetic_frame: false, + pending_plugin_install: None, pen_end: Default::default(), host_iteration: 0, #[cfg(test)] diff --git a/crates/ui-egui/src/app/links.rs b/crates/ui-egui/src/app/links.rs index 7d9116290..8f4ce246f 100644 --- a/crates/ui-egui/src/app/links.rs +++ b/crates/ui-egui/src/app/links.rs @@ -24,12 +24,18 @@ impl VectorcraftApp { /// control or MCP request, or a frame of synthetic input): the automation roots don't confine /// what that app does. pub(crate) fn refuse_automation(&self, id: &str) -> Result<(), String> { - if self.ui.answering_control || self.synthetic_frame { + if self.automation_is_acting() { return Err(format!("{id} opens another app: only the person at the keyboard can run it")); } Ok(()) } + /// Did automation send what is being handled now: a control or MCP request, or a frame of + /// synthetic input? + pub(crate) fn automation_is_acting(&self) -> bool { + self.ui.answering_control || self.synthetic_frame + } + /// Run a Help link command and open the URL it returns. pub fn open_link(&mut self, id: &str) { if let Ok(v) = self.run(id, serde_json::json!({})) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index a7766dd8d..bd77b4741 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -390,6 +390,7 @@ registry! { PrintPresets: [print_presets::KIND] => print_presets::SPEC, Plugin: [plugin::KIND] => plugin::SPEC, PluginExport: [plugin::EXPORT_KIND] => plugin::EXPORT_SPEC, + PluginInstall: [plugin::INSTALL_KIND] => plugin::INSTALL_SPEC, ScriptCheck: [script_check::KIND] => script_check::SPEC, VectorHalftone: [halftone::KIND] => halftone::SPEC, SimplifySlider: [simplify_slider::KIND] => simplify_slider::SPEC, @@ -546,6 +547,9 @@ pub fn cancel(app: &mut VectorcraftApp) { if d.kind == swatch_conflict::KIND { crate::placing::cancel_swatch_paste(app); } + if d.kind == plugin::INSTALL_KIND { + app.pending_plugin_install = None; + } // Toolbar-only Flare options start no preview and must not cancel a gesture // that was already active before opening them. The generic parameter dialog has a spec // without a preview, yet previews some commands (Grid Repeat Options) when asked to. diff --git a/crates/ui-egui/src/dialogs/plugin.rs b/crates/ui-egui/src/dialogs/plugin.rs index eef4787ee..77711d324 100644 --- a/crates/ui-egui/src/dialogs/plugin.rs +++ b/crates/ui-egui/src/dialogs/plugin.rs @@ -9,16 +9,22 @@ //! it runs in the background ([`crate::plugin_runs`]). Exporters are formats of File › Export › //! Export As; one with parameters opens dialog `pluginExport` once the file is picked, and its //! Export button writes the file. +//! +//! 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, and Enter doesn't. use std::sync::Arc; use serde_json::{Map, Value, json}; +use vectorcraft_engine::cmd::plugin::InstallApproval; use vectorcraft_plugins::{Budget, Kind, Manifest, Plugin, registry}; -use super::{DialogSpec, form}; +use super::{DialogAction, DialogSpec, form}; use crate::VectorcraftApp; use crate::menus::Item; use crate::state::Dialog; +use crate::widgets; /// The dialog kind of plug-in filters. pub const KIND: &str = "plugin"; @@ -197,3 +203,70 @@ fn export_confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result app.ui.dialog = None; crate::io::export(app, format.as_deref(), path, &p) } + +/// The dialog kind of the approval of a plug-in install. +pub const INSTALL_KIND: &str = "pluginInstall"; + +/// A plug-in install waiting for a person's answer. +pub(crate) struct PendingInstall { + /// The question and the lines under it, in the UI language. + pub message: String, + pub detail: String, + /// `plugin.install`'s params for OK. + pub params: Value, + /// What OK approves: the module the question is about. + pub approval: InstallApproval, +} + +/// No OK in the shared footer, so Enter does nothing. +pub(super) const INSTALL_SPEC: DialogSpec = DialogSpec { + heading: |_| tl!("Install Plug-in…").trim_end_matches('…').to_string(), + body_with_actions: Some(install_body), + confirm: install_confirm, + ok: None, + max_width: Some(420.0), + ..DialogSpec::FORM +}; + +/// Asks a person whether to install what `pending` describes. → `{pending}`. +pub(crate) fn ask_install(app: &mut VectorcraftApp, pending: PendingInstall) -> Value { + app.pending_plugin_install = Some(pending); + app.ui.dialog = Some(Dialog::new(INSTALL_KIND, json!({}))); + json!({ "pending": INSTALL_KIND }) +} + +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 }; + 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)); + ui.add_space(16.0); + 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() { + action = DialogAction::Confirm; + } + ui.add_space(8.0); + if widgets::secondary_button(ui, "Cancel").clicked() { + action = DialogAction::Cancel; + } + }); + action +} + +/// OK: installs the module the dialog showed, if a person pressed it; automation is refused and the +/// dialog stays open. +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()); + } + app.ui.dialog = None; + let Some(pending) = app.pending_plugin_install.take() else { return Ok(Value::Null) }; + app.session.approve_plugin_install(pending.approval); + let r = app.run("plugin.install", pending.params); + app.session.clear_plugin_install_approval(); + let r = r?; + crate::io::report_installed(app, &r); + Ok(r) +} diff --git a/crates/ui-egui/src/dialogs/tests_plugin_fields.rs b/crates/ui-egui/src/dialogs/tests_plugin_fields.rs index 8a66bc8a9..8f78b719d 100644 --- a/crates/ui-egui/src/dialogs/tests_plugin_fields.rs +++ b/crates/ui-egui/src/dialogs/tests_plugin_fields.rs @@ -13,6 +13,12 @@ use vectorcraft_plugins::wat as tpl; use super::*; +/// The install approval has no OK in the shared footer: Enter, real or injected, doesn't press it. +#[test] +fn enter_does_not_answer_the_install_approval() { + assert!(plugin::INSTALL_SPEC.ok.is_none() && plugin::INSTALL_SPEC.body_with_actions.is_some()); +} + /// A filter whose parameters use every presentation key. fn manifest(id: &str) -> String { format!( diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 4fd69733d..c61bb9d7e 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -5737,7 +5737,7 @@ axis Y Axis Y osa @msg Running “{name}”… Pokretanje „{name}”… @msg a live effect runs while the document is drawn, so it can't be `heavy` živi efekat radi dok se dokument iscrtava, pa ne može biti `heavy` @msg an exporter always gets an export's budget: leave `budget` out izvoznik uvijek dobija budžet izvoza: izostavite `budget` -@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees „{_1}” traži veliki budžet (svako pokretanje do {minutes} minuta i {times} puta više uobičajenih instrukcija): instalirajte ga s `allowHeavy: true` kada korisnik pristane +@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants „{_1}” traži veliki budžet (svako pokretanje do {minutes} minuta i {times} puta više uobičajenih instrukcija): osoba ga mora odobriti u desktop aplikaciji, ili proslijedite `allowHeavy: true` u sesiji pokrenutoj s --allow-plugin-grants # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? Instalirati „{name}”, koji traži ove dozvole? ID: {id} ID: {id} @@ -5762,7 +5762,9 @@ axis Y Axis Y osa @msg permission `{shown}` is listed twice dozvola `{shown}` navedena je dvaput @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it dozvola `{shown}` rezervisana je za kasniji API dodataka; ovaj domaćin je ne dodjeljuje @msg unknown permission `{shown}` nepoznata dozvola `{shown}` -@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees „{name}” traži dozvole {asked}: instalirajte ga s `grant` koji ih navodi kada korisnik pristane +@msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants „{name}” traži dozvole {asked}: osoba ih mora odobriti u desktop aplikaciji, ili proslijedite `grant` u sesiji pokrenutoj s --allow-plugin-grants +@msg `grant` and `allowHeavy` take effect only in a session started with --allow-plugin-grants: approve the plug-in in the desktop app `grant` i `allowHeavy` važe samo u sesiji pokrenutoj s --allow-plugin-grants: odobrite dodatak u desktop aplikaciji +@msg Only the person at the keyboard can approve installing a plug-in Instalaciju dodatka može odobriti samo osoba za tastaturom @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed „{name}” nije dobio `{p}`, što je potrebno njegovom izlazu: ništa nije promijenjeno @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed modul nema SHA-256 naveden u `hash`: ništa nije instalirano New Set… Novi skup… diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index f2a467fc5..15533d114 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -5855,7 +5855,7 @@ digits Malayalam Malaiàlam @msg {path}: {e} {path}: {e} @msg {status} ({e}) {status} ({e}) @msg {tool} isn't in a toolbar group of several tools {tool} no forma part d’un grup de diverses eines a la barra d’eines -@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees «{_1}» demana un pressupost elevat (cada execució fins a {minutes} minuts i {times} vegades les instruccions habituals): instal·la’l amb `allowHeavy: true` quan l’usuari hi estigui d’acord +@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants «{_1}» demana un pressupost elevat (cada execució fins a {minutes} minuts i {times} vegades les instruccions habituals): una persona l’ha d’aprovar a l’aplicació d’escriptori, o passa `allowHeavy: true` en una sessió iniciada amb --allow-plugin-grants # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? Instal·la «{name}», que demana aquests permisos? ID: {id} ID: {id} @@ -5880,7 +5880,9 @@ digits Malayalam Malaiàlam @msg permission `{shown}` is listed twice el permís `{shown}` apareix dues vegades @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it el permís `{shown}` està reservat per a una API de complements posterior; aquest amfitrió no el concedeix @msg unknown permission `{shown}` permís desconegut `{shown}` -@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees «{name}» demana els permisos {asked}: instal·la’l amb un `grant` que els enumeri quan l’usuari hi estigui d’acord +@msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants «{name}» demana els permisos {asked}: una persona els ha d’aprovar a l’aplicació d’escriptori, o passa `grant` en una sessió iniciada amb --allow-plugin-grants +@msg `grant` and `allowHeavy` take effect only in a session started with --allow-plugin-grants: approve the plug-in in the desktop app `grant` i `allowHeavy` només tenen efecte en una sessió iniciada amb --allow-plugin-grants: aprova el complement a l’aplicació d’escriptori +@msg Only the person at the keyboard can approve installing a plug-in Només la persona al teclat pot aprovar la instal·lació d’un complement @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed a «{name}» no se li ha concedit `{p}`, que la seva sortida necessita: no s’ha canviat res @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed el mòdul no té el SHA-256 que indica `hash`: no s’ha instal·lat res @msg “{name}” is not a file «{name}» no és un fitxer diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 15cd33ed0..bf723ab69 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -5841,7 +5841,7 @@ axis Y Axis osa Y @msg Running “{name}”… Spouští se „{name}“… @msg a live effect runs while the document is drawn, so it can't be `heavy` živý efekt běží během vykreslování dokumentu, takže nemůže být `heavy` @msg an exporter always gets an export's budget: leave `budget` out exportní zásuvný modul dostává vždy rozpočet exportu: klíč `budget` vynechte -@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees „{_1}“ žádá o velký výpočetní rozpočet (každé spuštění až {minutes} minut a {times}krát víc instrukcí než obvykle): nainstalujte ho s `allowHeavy: true`, jakmile uživatel souhlasí +@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants „{_1}“ žádá o velký výpočetní rozpočet (každé spuštění až {minutes} minut a {times}krát víc instrukcí než obvykle): musí ho schválit člověk v desktopové aplikaci, nebo předejte `allowHeavy: true` v relaci spuštěné s --allow-plugin-grants # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? Nainstalovat „{name}“, který žádá o tato oprávnění? ID: {id} ID: {id} @@ -5866,7 +5866,9 @@ axis Y Axis osa Y @msg permission `{shown}` is listed twice oprávnění `{shown}` je uvedeno dvakrát @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it oprávnění `{shown}` je vyhrazeno pro pozdější API zásuvných modulů; tento hostitel ho neuděluje @msg unknown permission `{shown}` neznámé oprávnění `{shown}` -@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees „{name}“ žádá o oprávnění {asked}: nainstalujte ho s `grant`, který je uvádí, jakmile uživatel souhlasí +@msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants „{name}“ žádá o oprávnění {asked}: musí je schválit člověk v desktopové aplikaci, nebo předejte `grant` v relaci spuštěné s --allow-plugin-grants +@msg `grant` and `allowHeavy` take effect only in a session started with --allow-plugin-grants: approve the plug-in in the desktop app `grant` a `allowHeavy` platí jen v relaci spuštěné s --allow-plugin-grants: schvalte zásuvný modul v desktopové aplikaci +@msg Only the person at the keyboard can approve installing a plug-in Instalaci zásuvného modulu může schválit jen člověk u klávesnice @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed „{name}“ nedostal `{p}`, které jeho výstup potřebuje: nic se nezměnilo @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed modul nemá SHA-256 uvedený v `hash`: nic nebylo nainstalováno # Actions panel: action sets (#3712). diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index e61e26c81..afadc6136 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5423,7 +5423,7 @@ axis Y Axis Y-Achse @msg Running “{name}”… „{name}“ wird ausgeführt… @msg a live effect runs while the document is drawn, so it can't be `heavy` ein Live-Effekt läuft, während das Dokument gezeichnet wird, daher kann er nicht `heavy` sein @msg an exporter always gets an export's budget: leave `budget` out ein Export-Plug-in erhält immer das Budget eines Exports: Lassen Sie `budget` weg -@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees „{_1}“ verlangt ein großes Rechenbudget (jede Ausführung bis zu {minutes} Minuten und {times}-mal so viele Anweisungen wie üblich): Installieren Sie es mit `allowHeavy: true`, sobald der Benutzer zustimmt +@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants „{_1}“ verlangt ein großes Rechenbudget (jede Ausführung bis zu {minutes} Minuten und {times}-mal so viele Anweisungen wie üblich): Eine Person muss es in der Desktop-App genehmigen, oder übergeben Sie `allowHeavy: true` in einer mit --allow-plugin-grants gestarteten Sitzung # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? „{name}“ installieren, das diese Berechtigungen verlangt? ID: {id} ID: {id} @@ -5448,7 +5448,9 @@ axis Y Axis Y-Achse @msg permission `{shown}` is listed twice die Berechtigung `{shown}` ist doppelt aufgeführt @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it die Berechtigung `{shown}` ist für eine spätere Plug-in-API reserviert; dieser Host gewährt sie nicht @msg unknown permission `{shown}` unbekannte Berechtigung `{shown}` -@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees „{name}“ verlangt die Berechtigungen {asked}: Installieren Sie es mit `grant`, das sie aufführt, sobald der Benutzer zustimmt +@msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants „{name}“ verlangt die Berechtigungen {asked}: Eine Person muss sie in der Desktop-App genehmigen, oder übergeben Sie `grant` in einer mit --allow-plugin-grants gestarteten Sitzung +@msg `grant` and `allowHeavy` take effect only in a session started with --allow-plugin-grants: approve the plug-in in the desktop app `grant` und `allowHeavy` wirken nur in einer mit --allow-plugin-grants gestarteten Sitzung: Genehmigen Sie das Plug-in in der Desktop-App +@msg Only the person at the keyboard can approve installing a plug-in Nur die Person an der Tastatur kann die Installation eines Plug-ins genehmigen @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed „{name}“ wurde `{p}` nicht gewährt, das seine Ausgabe braucht: Es wurde nichts geändert @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed das Modul hat nicht den SHA-256-Wert aus `hash`: Es wurde nichts installiert # Actions panel: action sets (#3712). diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index bad5543ba..dd0090b5c 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -5792,7 +5792,7 @@ axis Y Axis Άξονας Y @msg Running “{name}”… Εκτέλεση του «{name}»… @msg a live effect runs while the document is drawn, so it can't be `heavy` ένα δυναμικό εφέ εκτελείται κατά τη σχεδίαση του εγγράφου, οπότε δεν μπορεί να είναι `heavy` @msg an exporter always gets an export's budget: leave `budget` out ένα πρόσθετο εξαγωγής παίρνει πάντα τον προϋπολογισμό μιας εξαγωγής: παραλείψτε το `budget` -@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees το «{_1}» ζητά μεγάλο υπολογιστικό προϋπολογισμό (κάθε εκτέλεση έως {minutes} λεπτά και {times} φορές τις συνήθεις εντολές): εγκαταστήστε το με `allowHeavy: true` μόλις συμφωνήσει ο χρήστης +@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants το «{_1}» ζητά μεγάλο υπολογιστικό προϋπολογισμό (κάθε εκτέλεση έως {minutes} λεπτά και {times} φορές τις συνήθεις εντολές): πρέπει να το εγκρίνει ένα άτομο στην εφαρμογή επιφάνειας εργασίας ή περάστε `allowHeavy: true` σε μια συνεδρία που ξεκίνησε με --allow-plugin-grants # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? Να εγκατασταθεί το «{name}», που ζητά αυτά τα δικαιώματα; ID: {id} Αναγνωριστικό: {id} @@ -5817,7 +5817,9 @@ axis Y Axis Άξονας Y @msg permission `{shown}` is listed twice το δικαίωμα `{shown}` αναφέρεται δύο φορές @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it το δικαίωμα `{shown}` είναι δεσμευμένο για μεταγενέστερο API προσθέτων· αυτός ο κεντρικός υπολογιστής δεν το παραχωρεί @msg unknown permission `{shown}` άγνωστο δικαίωμα `{shown}` -@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees το «{name}» ζητά τα δικαιώματα {asked}: εγκαταστήστε το με ένα `grant` που τα απαριθμεί μόλις συμφωνήσει ο χρήστης +@msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants το «{name}» ζητά τα δικαιώματα {asked}: πρέπει να τα εγκρίνει ένα άτομο στην εφαρμογή επιφάνειας εργασίας ή περάστε `grant` σε μια συνεδρία που ξεκίνησε με --allow-plugin-grants +@msg `grant` and `allowHeavy` take effect only in a session started with --allow-plugin-grants: approve the plug-in in the desktop app τα `grant` και `allowHeavy` ισχύουν μόνο σε συνεδρία που ξεκίνησε με --allow-plugin-grants: εγκρίνετε το πρόσθετο στην εφαρμογή επιφάνειας εργασίας +@msg Only the person at the keyboard can approve installing a plug-in Μόνο το άτομο στο πληκτρολόγιο μπορεί να εγκρίνει την εγκατάσταση ενός πρόσθετου @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed στο «{name}» δεν παραχωρήθηκε το `{p}`, που χρειάζεται η έξοδός του: δεν άλλαξε τίποτα @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed η λειτουργική μονάδα δεν έχει το SHA-256 που δίνει το `hash`: δεν εγκαταστάθηκε τίποτα # Actions panel: action sets (#3712). diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 651886e05..6e8e73c69 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -5782,7 +5782,7 @@ axis Y Axis Eje Y @msg Running “{name}”… Ejecutando “{name}”… @msg a live effect runs while the document is drawn, so it can't be `heavy` un efecto interactivo se ejecuta mientras se dibuja el documento, así que no puede ser `heavy` @msg an exporter always gets an export's budget: leave `budget` out un plugin de exportación siempre recibe el presupuesto de una exportación: omite `budget` -@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees “{_1}” pide un presupuesto de cálculo grande (cada ejecución hasta {minutes} minutos y {times} veces las instrucciones habituales): instálalo con `allowHeavy: true` cuando el usuario lo acepte +@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants “{_1}” pide un presupuesto de cálculo grande (cada ejecución hasta {minutes} minutos y {times} veces las instrucciones habituales): una persona debe aprobarlo en la aplicación de escritorio, o pasa `allowHeavy: true` en una sesión iniciada con --allow-plugin-grants # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? ¿Instalar “{name}”, que pide estos permisos? ID: {id} ID: {id} @@ -5807,7 +5807,9 @@ axis Y Axis Eje Y @msg permission `{shown}` is listed twice el permiso `{shown}` aparece dos veces @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it el permiso `{shown}` está reservado para una API de plugins posterior; este host no lo concede @msg unknown permission `{shown}` permiso desconocido `{shown}` -@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}” pide los permisos {asked}: instálalo con un `grant` que los incluya cuando el usuario lo acepte +@msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants “{name}” pide los permisos {asked}: una persona debe aprobarlos en la aplicación de escritorio, o pasa `grant` en una sesión iniciada con --allow-plugin-grants +@msg `grant` and `allowHeavy` take effect only in a session started with --allow-plugin-grants: approve the plug-in in the desktop app `grant` y `allowHeavy` solo tienen efecto en una sesión iniciada con --allow-plugin-grants: aprueba el complemento en la aplicación de escritorio +@msg Only the person at the keyboard can approve installing a plug-in Solo la persona que está en el teclado puede aprobar la instalación de un complemento @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed a “{name}” no se le concedió `{p}`, que su resultado necesita: no se cambió nada @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed el módulo no tiene el SHA-256 que indica `hash`: no se instaló nada # Actions panel: action sets (#3712). diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index f79d04cea..0574eca90 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -5871,7 +5871,7 @@ axis Y Axis Y-akseli @msg Running “{name}”… Suoritetaan ”{name}”… @msg a live effect runs while the document is drawn, so it can't be `heavy` live-tehoste suoritetaan asiakirjaa piirrettäessä, joten se ei voi olla `heavy` @msg an exporter always gets an export's budget: leave `budget` out vientilaajennus saa aina viennin budjetin: jätä `budget` pois -@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees ”{_1}” pyytää suurta laskentabudjettia (jokainen ajo enintään {minutes} minuuttia ja {times} kertaa tavallinen määrä käskyjä): asenna se asetuksella `allowHeavy: true`, kun käyttäjä hyväksyy +@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants ”{_1}” pyytää suurta laskentabudjettia (jokainen ajo enintään {minutes} minuuttia ja {times} kertaa tavallinen määrä käskyjä): ihmisen on hyväksyttävä se työpöytäsovelluksessa, tai anna `allowHeavy: true` istunnossa, joka on käynnistetty valitsimella --allow-plugin-grants # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? Asennetaanko ”{name}”, joka pyytää näitä käyttöoikeuksia? ID: {id} Tunnus: {id} @@ -5896,7 +5896,9 @@ axis Y Axis Y-akseli @msg permission `{shown}` is listed twice käyttöoikeus `{shown}` on lueteltu kahdesti @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it käyttöoikeus `{shown}` on varattu myöhemmälle laajennus-API:lle; tämä isäntä ei myönnä sitä @msg unknown permission `{shown}` tuntematon käyttöoikeus `{shown}` -@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees ”{name}” pyytää käyttöoikeuksia {asked}: asenna se `grant`-luettelolla, jossa ne ovat, kun käyttäjä suostuu +@msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants ”{name}” pyytää käyttöoikeuksia {asked}: ihmisen on hyväksyttävä ne työpöytäsovelluksessa, tai anna `grant` istunnossa, joka on käynnistetty valitsimella --allow-plugin-grants +@msg `grant` and `allowHeavy` take effect only in a session started with --allow-plugin-grants: approve the plug-in in the desktop app `grant` ja `allowHeavy` toimivat vain istunnossa, joka on käynnistetty valitsimella --allow-plugin-grants: hyväksy laajennus työpöytäsovelluksessa +@msg Only the person at the keyboard can approve installing a plug-in Vain näppäimistön ääressä oleva ihminen voi hyväksyä laajennuksen asennuksen @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed ”{name}” ei saanut käyttöoikeutta `{p}`, jota sen tulos tarvitsee: mitään ei muutettu @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed moduulilla ei ole `hash`-kentän SHA-256-arvoa: mitään ei asennettu # Actions panel: action sets (#3712). diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index b9c9c1165..f31dcd9cf 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -5836,7 +5836,7 @@ axis Y Axis Axe Y @msg Running “{name}”… Exécution de « {name} »… @msg a live effect runs while the document is drawn, so it can't be `heavy` un effet dynamique s’exécute pendant le tracé du document, il ne peut donc pas être `heavy` @msg an exporter always gets an export's budget: leave `budget` out un module externe d’exportation reçoit toujours le budget d’une exportation : omettez `budget` -@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees « {_1} » demande un budget de calcul important (chaque exécution jusqu’à {minutes} minutes et {times} fois les instructions habituelles) : installez-le avec `allowHeavy: true` une fois que l’utilisateur l’accepte +@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants « {_1} » demande un budget de calcul important (chaque exécution jusqu’à {minutes} minutes et {times} fois les instructions habituelles) : une personne doit l’approuver dans l’application de bureau, ou passez `allowHeavy: true` dans une session démarrée avec --allow-plugin-grants # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? Installer « {name} », qui demande ces autorisations ? ID: {id} ID : {id} @@ -5861,7 +5861,9 @@ axis Y Axis Axe Y @msg permission `{shown}` is listed twice l’autorisation `{shown}` figure deux fois @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it l’autorisation `{shown}` est réservée à une future API de module externe ; cet hôte ne l’accorde pas @msg unknown permission `{shown}` autorisation inconnue `{shown}` -@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees « {name} » demande les autorisations {asked} : installez-le avec `grant` qui les liste une fois que l’utilisateur l’accepte +@msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants « {name} » demande les autorisations {asked} : une personne doit les approuver dans l’application de bureau, ou passez `grant` dans une session démarrée avec --allow-plugin-grants +@msg `grant` and `allowHeavy` take effect only in a session started with --allow-plugin-grants: approve the plug-in in the desktop app `grant` et `allowHeavy` ne prennent effet que dans une session démarrée avec --allow-plugin-grants : approuvez le module externe dans l’application de bureau +@msg Only the person at the keyboard can approve installing a plug-in Seule la personne au clavier peut approuver l’installation d’un module externe @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed « {name} » n’a pas reçu `{p}`, dont sa sortie a besoin : rien n’a été modifié @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed le module n’a pas le SHA-256 indiqué par `hash` : rien n’a été installé # Actions panel: action sets (#3712). diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 9f6c6807f..d63c6c3b6 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -5212,7 +5212,7 @@ Zoom with Mouse Wheel माउस व्हील के साथ ज़ूम @id file.close बंद करें @id pathfinder.add मिलाएँ -@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees “{_1}” अधिक संसाधन माँगता है (हर रन में {minutes} मिनट और सामान्य से {times} गुना निर्देश): उपयोगकर्ता की सहमति मिलने पर इसे `allowHeavy: true` से इंस्टॉल करें +@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants “{_1}” अधिक संसाधन माँगता है (हर रन में {minutes} मिनट और सामान्य से {times} गुना निर्देश): इसे डेस्कटॉप ऐप में किसी व्यक्ति को स्वीकृत करना होगा, या --allow-plugin-grants से शुरू किए गए सत्र में `allowHeavy: true` दें @msg "{0}" is not a colour: write it as #rrggbb "{0}" एक रंग नहीं है: इसे #rrggbb के रूप में लिखें # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? "{name}" स्थापित करें, जो ये अनुमतियां मांगता है? @@ -5238,7 +5238,9 @@ @msg permission `{shown}` is listed twice अनुमति `{shown}` दो बार सूचीबद्ध है @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it अनुमति `{shown}` बाद के प्लग-इन API के लिए आरक्षित है; यह होस्ट इसे नहीं देता @msg unknown permission `{shown}` अज्ञात अनुमति `{shown}` -@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}” अनुमतियां {asked} मांगता है: उपयोगकर्ता की सहमति मिलने पर इसे उन्हें सूचीबद्ध करने वाले `grant` के साथ इंस्टॉल करें +@msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants “{name}” अनुमतियां {asked} मांगता है: इन्हें डेस्कटॉप ऐप में किसी व्यक्ति को स्वीकृत करना होगा, या --allow-plugin-grants से शुरू किए गए सत्र में `grant` दें +@msg `grant` and `allowHeavy` take effect only in a session started with --allow-plugin-grants: approve the plug-in in the desktop app `grant` और `allowHeavy` केवल --allow-plugin-grants से शुरू किए गए सत्र में प्रभावी होते हैं: डेस्कटॉप ऐप में प्लग-इन स्वीकृत करें +@msg Only the person at the keyboard can approve installing a plug-in प्लग-इन इंस्टॉल करने की स्वीकृति केवल कीबोर्ड पर मौजूद व्यक्ति दे सकता है @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed “{name}” को `{p}` नहीं दी गई, जो उसके आउटपुट को चाहिए: कुछ नहीं बदला @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed मॉड्यूल का SHA-256 वह नहीं है जो `hash` बताता है: कुछ भी इंस्टॉल नहीं हुआ @msg “{name}” is not a file "{name}" एक फ़ाइल नहीं है diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 2de1183cc..1aa771a42 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -5824,7 +5824,7 @@ axis Y Axis Y tengely @msg Running “{name}”… Futtatás: „{name}”… @msg a live effect runs while the document is drawn, so it can't be `heavy` az élő effektus a dokumentum rajzolása közben fut, ezért nem lehet `heavy` @msg an exporter always gets an export's budget: leave `budget` out az exportáló bővítmény mindig az exportálás keretét kapja: hagyd ki a `budget` kulcsot -@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees a(z) „{_1}” nagy számítási keretet kér (futásonként legfeljebb {minutes} perc és a szokásos utasításszám {times}-szorosa): telepítsd az `allowHeavy: true` megadásával, miután a felhasználó beleegyezett +@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants a(z) „{_1}” nagy számítási keretet kér (futásonként legfeljebb {minutes} perc és a szokásos utasításszám {times}-szorosa): egy személynek jóvá kell hagynia az asztali alkalmazásban, vagy add meg az `allowHeavy: true` értéket egy --allow-plugin-grants kapcsolóval indított munkamenetben # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? Telepíted a(z) „{name}” bővítményt, amely ezeket az engedélyeket kéri? ID: {id} Azonosító: {id} @@ -5849,7 +5849,9 @@ axis Y Axis Y tengely @msg permission `{shown}` is listed twice a(z) `{shown}` engedély kétszer szerepel @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it a(z) `{shown}` engedély egy későbbi bővítmény-API számára van fenntartva; ez a gazdaprogram nem adja meg @msg unknown permission `{shown}` ismeretlen engedély: `{shown}` -@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees a(z) „{name}” a következő engedélyeket kéri: {asked}; telepítsd egy őket felsoroló `grant` értékkel, ha a felhasználó beleegyezik +@msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants a(z) „{name}” a következő engedélyeket kéri: {asked}; egy személynek jóvá kell hagynia őket az asztali alkalmazásban, vagy add meg a `grant` értéket egy --allow-plugin-grants kapcsolóval indított munkamenetben +@msg `grant` and `allowHeavy` take effect only in a session started with --allow-plugin-grants: approve the plug-in in the desktop app a `grant` és az `allowHeavy` csak --allow-plugin-grants kapcsolóval indított munkamenetben érvényes: hagyd jóvá a bővítményt az asztali alkalmazásban +@msg Only the person at the keyboard can approve installing a plug-in Bővítmény telepítését csak a billentyűzetnél ülő személy hagyhatja jóvá @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed a(z) „{name}” nem kapta meg a(z) `{p}` engedélyt, amelyre a kimenetének szüksége van: semmi sem változott @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed a modul SHA-256 értéke nem az, amit a `hash` megad: semmi sem lett telepítve # Actions panel: action sets (#3712). diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 49f8130ae..3fe227ec7 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5457,7 +5457,7 @@ axis Y Axis Asse Y @msg Running “{name}”… Esecuzione di “{name}”… @msg a live effect runs while the document is drawn, so it can't be `heavy` un effetto dinamico viene eseguito mentre il documento viene disegnato, quindi non può essere `heavy` @msg an exporter always gets an export's budget: leave `budget` out un plug-in di esportazione riceve sempre il budget di un’esportazione: ometti `budget` -@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees “{_1}” chiede un budget di calcolo elevato (ogni esecuzione fino a {minutes} minuti e {times} volte le istruzioni consuete): installalo con `allowHeavy: true` quando l’utente acconsente +@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants “{_1}” chiede un budget di calcolo elevato (ogni esecuzione fino a {minutes} minuti e {times} volte le istruzioni consuete): una persona deve approvarlo nell’app desktop, oppure passa `allowHeavy: true` in una sessione avviata con --allow-plugin-grants # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? Installare “{name}”, che chiede queste autorizzazioni? ID: {id} ID: {id} @@ -5482,7 +5482,9 @@ axis Y Axis Asse Y @msg permission `{shown}` is listed twice l’autorizzazione `{shown}` è elencata due volte @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it l’autorizzazione `{shown}` è riservata a un’API dei plug-in successiva; questo host non la concede @msg unknown permission `{shown}` autorizzazione sconosciuta `{shown}` -@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}” chiede le autorizzazioni {asked}: installalo con un `grant` che le elenca quando l’utente acconsente +@msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants “{name}” chiede le autorizzazioni {asked}: una persona deve approvarle nell’app desktop, oppure passa `grant` in una sessione avviata con --allow-plugin-grants +@msg `grant` and `allowHeavy` take effect only in a session started with --allow-plugin-grants: approve the plug-in in the desktop app `grant` e `allowHeavy` hanno effetto solo in una sessione avviata con --allow-plugin-grants: approva il plug-in nell’app desktop +@msg Only the person at the keyboard can approve installing a plug-in Solo la persona alla tastiera può approvare l’installazione di un plug-in @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed a “{name}” non è stata concessa `{p}`, che serve al suo risultato: non è stato modificato nulla @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed il modulo non ha lo SHA-256 indicato da `hash`: non è stato installato nulla # Actions panel: action sets (#3712). diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 94b2e408a..6cfd36910 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5522,7 +5522,7 @@ axis Y Axis Y 軸 @msg Running “{name}”… 「{name}」を実行中… @msg a live effect runs while the document is drawn, so it can't be `heavy` ライブ効果はドキュメントの描画中に実行されるため、`heavy` にはできません @msg an exporter always gets an export's budget: leave `budget` out 書き出しプラグインには常に書き出しの予算が割り当てられます。`budget` は省略してください -@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees 「{_1}」は大きな計算予算を求めています (1 回の実行につき最大 {minutes} 分、通常の {times} 倍の命令)。ユーザーが同意したら `allowHeavy: true` を指定してインストールしてください +@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants 「{_1}」は大きな計算予算を求めています (1 回の実行につき最大 {minutes} 分、通常の {times} 倍の命令)。デスクトップ アプリで人が承認するか、--allow-plugin-grants を付けて開始したセッションで `allowHeavy: true` を指定してください # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? これらの権限を求める「{name}」をインストールしますか? ID: {id} ID: {id} @@ -5547,7 +5547,9 @@ axis Y Axis Y 軸 @msg permission `{shown}` is listed twice 権限 `{shown}` が 2 回指定されています @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it 権限 `{shown}` は今後のプラグイン API 用に予約されています。このホストはこの権限を付与しません @msg unknown permission `{shown}` 不明な権限 `{shown}` -@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees 「{name}」は権限 {asked} を求めています。ユーザーが同意したら、それらを列挙した `grant` を指定してインストールしてください +@msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants 「{name}」は権限 {asked} を求めています。デスクトップ アプリで人が承認するか、--allow-plugin-grants を付けて開始したセッションで `grant` を指定してください +@msg `grant` and `allowHeavy` take effect only in a session started with --allow-plugin-grants: approve the plug-in in the desktop app `grant` と `allowHeavy` は --allow-plugin-grants を付けて開始したセッションでのみ有効です。デスクトップ アプリでプラグインを承認してください +@msg Only the person at the keyboard can approve installing a plug-in プラグインのインストールを承認できるのはキーボードの前にいる人だけです @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed 「{name}」には出力に必要な `{p}` が付与されていません。何も変更されませんでした @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed モジュールの SHA-256 が `hash` の値と一致しません。何もインストールされませんでした # Actions panel: action sets (#3712). diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 77b99070f..b47e27cbd 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -5764,7 +5764,7 @@ axis Y Axis Y ღერძი @msg Running “{name}”… მიმდინარეობს „{name}“-ის გაშვება… @msg a live effect runs while the document is drawn, so it can't be `heavy` ცოცხალი ეფექტი დოკუმენტის დახატვისას ეშვება, ამიტომ ის ვერ იქნება `heavy` @msg an exporter always gets an export's budget: leave `budget` out ექსპორტის პლაგინი ყოველთვის ექსპორტის ბიუჯეტს იღებს: გამოტოვეთ `budget` -@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees „{_1}“ დიდ გამოთვლით ბიუჯეტს ითხოვს (თითო გაშვება {minutes} წუთამდე და ჩვეულებრივზე {times}-ჯერ მეტი ინსტრუქცია): მომხმარებლის თანხმობის შემდეგ დააყენეთ ის `allowHeavy: true` პარამეტრით +@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants „{_1}“ დიდ გამოთვლით ბიუჯეტს ითხოვს (თითო გაშვება {minutes} წუთამდე და ჩვეულებრივზე {times}-ჯერ მეტი ინსტრუქცია): ის დესკტოპის აპში ადამიანმა უნდა დაამტკიცოს, ან გადაეცით `allowHeavy: true` --allow-plugin-grants-ით გაშვებულ სესიაში # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? დავაყენოთ „{name}“, რომელიც ამ ნებართვებს ითხოვს? ID: {id} ID: {id} @@ -5789,7 +5789,9 @@ axis Y Axis Y ღერძი @msg permission `{shown}` is listed twice ნებართვა `{shown}` ორჯერაა ჩამოთვლილი @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it ნებართვა `{shown}` დაჯავშნილია პლაგინების მომავალი API-სთვის; ეს ჰოსტი მას არ გასცემს @msg unknown permission `{shown}` უცნობი ნებართვა `{shown}` -@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees „{name}“ ითხოვს ნებართვებს {asked}: მომხმარებლის თანხმობის შემდეგ დააყენეთ ის `grant` პარამეტრით, რომელშიც ისინი ჩამოთვლილია +@msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants „{name}“ ითხოვს ნებართვებს {asked}: ისინი დესკტოპის აპში ადამიანმა უნდა დაამტკიცოს, ან გადაეცით `grant` --allow-plugin-grants-ით გაშვებულ სესიაში +@msg `grant` and `allowHeavy` take effect only in a session started with --allow-plugin-grants: approve the plug-in in the desktop app `grant` და `allowHeavy` მოქმედებს მხოლოდ --allow-plugin-grants-ით გაშვებულ სესიაში: დაამტკიცეთ დამატება დესკტოპის აპში +@msg Only the person at the keyboard can approve installing a plug-in დამატების დაყენებას მხოლოდ კლავიატურასთან მყოფი ადამიანი ამტკიცებს @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed „{name}“-ს არ მიეცა `{p}`, რომელიც მის შედეგს სჭირდება: არაფერი შეცვლილა @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed მოდულს არ აქვს `hash`-ში მითითებული SHA-256: არაფერი დაყენებულა # Actions panel: action sets (#3712). diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index ca570d3cc..667e93783 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5418,7 +5418,7 @@ axis Y Axis Y축 @msg Running “{name}”… “{name}” 실행 중… @msg a live effect runs while the document is drawn, so it can't be `heavy` 라이브 효과는 문서를 그리는 동안 실행되므로 `heavy`일 수 없습니다 @msg an exporter always gets an export's budget: leave `budget` out 내보내기 플러그인은 항상 내보내기 예산을 받습니다. `budget`은 생략하세요 -@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees “{_1}”은(는) 큰 계산 예산을 요청합니다(실행당 최대 {minutes}분, 평소의 {times}배 명령어). 사용자가 동의하면 `allowHeavy: true`로 설치하세요 +@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants “{_1}”은(는) 큰 계산 예산을 요청합니다(실행당 최대 {minutes}분, 평소의 {times}배 명령어). 데스크톱 앱에서 사람이 승인하거나 --allow-plugin-grants로 시작한 세션에서 `allowHeavy: true`를 전달하세요 # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? 이 권한을 요청하는 “{name}”을(를) 설치하시겠습니까? ID: {id} ID: {id} @@ -5443,7 +5443,9 @@ axis Y Axis Y축 @msg permission `{shown}` is listed twice `{shown}` 권한이 두 번 나열되어 있습니다 @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it `{shown}` 권한은 이후 플러그인 API용으로 예약되어 있습니다. 이 호스트는 이 권한을 부여하지 않습니다 @msg unknown permission `{shown}` 알 수 없는 권한 `{shown}` -@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}”은(는) {asked} 권한을 요청합니다. 사용자가 동의하면 이를 나열한 `grant`로 설치하세요 +@msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants “{name}”은(는) {asked} 권한을 요청합니다. 데스크톱 앱에서 사람이 승인하거나 --allow-plugin-grants로 시작한 세션에서 `grant`를 전달하세요 +@msg `grant` and `allowHeavy` take effect only in a session started with --allow-plugin-grants: approve the plug-in in the desktop app `grant`와 `allowHeavy`는 --allow-plugin-grants로 시작한 세션에서만 적용됩니다. 데스크톱 앱에서 플러그인을 승인하세요 +@msg Only the person at the keyboard can approve installing a plug-in 플러그인 설치는 키보드 앞에 있는 사람만 승인할 수 있습니다 @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed “{name}”에 출력에 필요한 `{p}` 권한이 부여되지 않았습니다. 아무것도 변경되지 않았습니다 @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed 모듈의 SHA-256이 `hash`에 지정된 값과 다릅니다. 아무것도 설치되지 않았습니다 # Actions panel: action sets (#3712). diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 668a55ed7..7727b0c87 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -5694,7 +5694,7 @@ axis Y Axis Oś Y @msg Running “{name}”… Uruchamianie „{name}”… @msg a live effect runs while the document is drawn, so it can't be `heavy` efekt na żywo działa podczas rysowania dokumentu, więc nie może być `heavy` @msg an exporter always gets an export's budget: leave `budget` out wtyczka eksportu zawsze dostaje budżet eksportu: pomiń `budget` -@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees „{_1}” prosi o duży budżet obliczeń (każde uruchomienie do {minutes} minut i {times} razy więcej instrukcji niż zwykle): zainstaluj wtyczkę z `allowHeavy: true`, gdy użytkownik się zgodzi +@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants „{_1}” prosi o duży budżet obliczeń (każde uruchomienie do {minutes} minut i {times} razy więcej instrukcji niż zwykle): musi ją zatwierdzić osoba w aplikacji desktopowej albo przekaż `allowHeavy: true` w sesji uruchomionej z --allow-plugin-grants # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? Zainstalować „{name}”, wtyczkę, która prosi o te uprawnienia? ID: {id} ID: {id} @@ -5719,7 +5719,9 @@ axis Y Axis Oś Y @msg permission `{shown}` is listed twice uprawnienie `{shown}` występuje dwa razy @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it uprawnienie `{shown}` jest zarezerwowane dla późniejszego API wtyczek; ten host go nie przyznaje @msg unknown permission `{shown}` nieznane uprawnienie `{shown}` -@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees „{name}” prosi o uprawnienia {asked}: zainstaluj ją z `grant`, który je wymienia, gdy użytkownik się zgodzi +@msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants „{name}” prosi o uprawnienia {asked}: musi je zatwierdzić osoba w aplikacji desktopowej albo przekaż `grant` w sesji uruchomionej z --allow-plugin-grants +@msg `grant` and `allowHeavy` take effect only in a session started with --allow-plugin-grants: approve the plug-in in the desktop app `grant` i `allowHeavy` działają tylko w sesji uruchomionej z --allow-plugin-grants: zatwierdź wtyczkę w aplikacji desktopowej +@msg Only the person at the keyboard can approve installing a plug-in Instalację wtyczki może zatwierdzić tylko osoba przy klawiaturze @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed „{name}” nie otrzymała `{p}`, którego potrzebuje jej wynik: nic nie zmieniono @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed moduł nie ma skrótu SHA-256 podanego w `hash`: nic nie zainstalowano # Actions panel: action sets (#3712). diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 73cf2c8ee..df74f0a86 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -5756,7 +5756,7 @@ axis Y Axis Eixo Y @msg Running “{name}”… Executando “{name}”… @msg a live effect runs while the document is drawn, so it can't be `heavy` um efeito dinâmico é executado enquanto o documento é desenhado, então não pode ser `heavy` @msg an exporter always gets an export's budget: leave `budget` out um plug-in de exportação sempre recebe o orçamento de uma exportação: omita `budget` -@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees “{_1}” pede um orçamento de processamento grande (cada execução até {minutes} minutos e {times} vezes as instruções habituais): instale-o com `allowHeavy: true` depois que o usuário concordar +@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants “{_1}” pede um orçamento de processamento grande (cada execução até {minutes} minutos e {times} vezes as instruções habituais): uma pessoa precisa aprová-lo no aplicativo de desktop, ou passe `allowHeavy: true` em uma sessão iniciada com --allow-plugin-grants # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? Instalar “{name}”, que pede estas permissões? ID: {id} ID: {id} @@ -5781,7 +5781,9 @@ axis Y Axis Eixo Y @msg permission `{shown}` is listed twice a permissão `{shown}` aparece duas vezes @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it a permissão `{shown}` está reservada para uma API de plug-in futura; este host não a concede @msg unknown permission `{shown}` permissão desconhecida `{shown}` -@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}” pede as permissões {asked}: instale-o com `grant` listando-as depois que o usuário concordar +@msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants “{name}” pede as permissões {asked}: uma pessoa precisa aprová-las no aplicativo de desktop, ou passe `grant` em uma sessão iniciada com --allow-plugin-grants +@msg `grant` and `allowHeavy` take effect only in a session started with --allow-plugin-grants: approve the plug-in in the desktop app `grant` e `allowHeavy` só têm efeito em uma sessão iniciada com --allow-plugin-grants: aprove o plug-in no aplicativo de desktop +@msg Only the person at the keyboard can approve installing a plug-in Somente a pessoa ao teclado pode aprovar a instalação de um plug-in @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed “{name}” não recebeu `{p}`, de que sua saída precisa: nada foi alterado @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed o módulo não tem o SHA-256 indicado em `hash`: nada foi instalado # Actions panel: action sets (#3712). diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 9b462f1b9..307113d4a 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -5452,7 +5452,7 @@ axis Y Axis Ось Y @msg Running “{name}”… Выполняется «{name}»… @msg a live effect runs while the document is drawn, so it can't be `heavy` динамический эффект выполняется во время отрисовки документа, поэтому он не может быть `heavy` @msg an exporter always gets an export's budget: leave `budget` out модуль экспорта всегда получает бюджет экспорта: не указывайте `budget` -@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees «{_1}» запрашивает большой вычислительный бюджет (каждый запуск до {minutes} минут и в {times} раз больше инструкций, чем обычно): установите его с `allowHeavy: true`, когда пользователь согласится +@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants «{_1}» запрашивает большой вычислительный бюджет (каждый запуск до {minutes} минут и в {times} раз больше инструкций, чем обычно): его должен одобрить человек в настольном приложении, либо передайте `allowHeavy: true` в сеансе, запущенном с --allow-plugin-grants # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? Установить «{name}», которому нужны эти разрешения? ID: {id} ID: {id} @@ -5477,7 +5477,9 @@ axis Y Axis Ось Y @msg permission `{shown}` is listed twice разрешение `{shown}` указано дважды @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it разрешение `{shown}` зарезервировано для будущего API модулей; этот хост его не выдаёт @msg unknown permission `{shown}` неизвестное разрешение `{shown}` -@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees «{name}» запрашивает разрешения {asked}: установите его с `grant`, где они перечислены, когда пользователь согласится +@msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants «{name}» запрашивает разрешения {asked}: их должен одобрить человек в настольном приложении, либо передайте `grant` в сеансе, запущенном с --allow-plugin-grants +@msg `grant` and `allowHeavy` take effect only in a session started with --allow-plugin-grants: approve the plug-in in the desktop app `grant` и `allowHeavy` действуют только в сеансе, запущенном с --allow-plugin-grants: одобрите плагин в настольном приложении +@msg Only the person at the keyboard can approve installing a plug-in Одобрить установку плагина может только человек за клавиатурой @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed «{name}» не получил разрешение `{p}`, нужное для его результата: ничего не изменено @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed у модуля не тот SHA-256, что указан в `hash`: ничего не установлено # Actions panel: action sets (#3712). diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 44dfd2563..a05088165 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -5864,7 +5864,7 @@ axis Y Axis Y-axel @msg Running “{name}”… Kör ”{name}”… @msg a live effect runs while the document is drawn, so it can't be `heavy` en live-effekt körs medan dokumentet ritas, så den kan inte vara `heavy` @msg an exporter always gets an export's budget: leave `budget` out ett exportinsticksprogram får alltid en exports budget: utelämna `budget` -@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees ”{_1}” begär en stor beräkningsbudget (varje körning upp till {minutes} minuter och {times} gånger de vanliga instruktionerna): installera det med `allowHeavy: true` när användaren har godkänt +@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants ”{_1}” begär en stor beräkningsbudget (varje körning upp till {minutes} minuter och {times} gånger de vanliga instruktionerna): en person måste godkänna det i skrivbordsappen, eller skicka `allowHeavy: true` i en session som startats med --allow-plugin-grants # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? Installera ”{name}”, som begär dessa behörigheter? ID: {id} ID: {id} @@ -5889,7 +5889,9 @@ axis Y Axis Y-axel @msg permission `{shown}` is listed twice behörigheten `{shown}` står två gånger @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it behörigheten `{shown}` är reserverad för ett senare plugin-API; den här värden beviljar den inte @msg unknown permission `{shown}` okänd behörighet `{shown}` -@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees ”{name}” begär behörigheterna {asked}: installera det med en `grant` som listar dem när användaren har godkänt +@msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants ”{name}” begär behörigheterna {asked}: en person måste godkänna dem i skrivbordsappen, eller skicka `grant` i en session som startats med --allow-plugin-grants +@msg `grant` and `allowHeavy` take effect only in a session started with --allow-plugin-grants: approve the plug-in in the desktop app `grant` och `allowHeavy` gäller bara i en session som startats med --allow-plugin-grants: godkänn insticksprogrammet i skrivbordsappen +@msg Only the person at the keyboard can approve installing a plug-in Bara personen vid tangentbordet kan godkänna installationen av ett insticksprogram @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed ”{name}” beviljades inte `{p}`, som dess utdata behöver: ingenting ändrades @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed modulen har inte den SHA-256 som `hash` anger: ingenting installerades # Actions panel: action sets (#3712). diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 6ce1191c8..f43fe910d 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -5798,7 +5798,7 @@ axis Y Axis Y ekseni @msg Running “{name}”… “{name}” çalıştırılıyor… @msg a live effect runs while the document is drawn, so it can't be `heavy` canlı efekt belge çizilirken çalışır, bu yüzden `heavy` olamaz @msg an exporter always gets an export's budget: leave `budget` out dışa aktarma eklentisi her zaman bir dışa aktarmanın bütçesini alır: `budget` anahtarını çıkarın -@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees “{_1}” büyük bir hesaplama bütçesi istiyor (her çalıştırma en fazla {minutes} dakika ve olağan komutların {times} katı): kullanıcı onayladıktan sonra `allowHeavy: true` ile yükleyin +@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants “{_1}” büyük bir hesaplama bütçesi istiyor (her çalıştırma en fazla {minutes} dakika ve olağan komutların {times} katı): masaüstü uygulamasında bir kişinin onaylaması gerekir veya --allow-plugin-grants ile başlatılan bir oturumda `allowHeavy: true` geçirin # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? Bu izinleri isteyen “{name}” yüklensin mi? ID: {id} Kimlik: {id} @@ -5823,7 +5823,9 @@ axis Y Axis Y ekseni @msg permission `{shown}` is listed twice `{shown}` izni iki kez listelenmiş @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it `{shown}` izni sonraki bir eklenti API’si için ayrılmıştır; bu ana bilgisayar bu izni vermez @msg unknown permission `{shown}` bilinmeyen izin `{shown}` -@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}” {asked} izinlerini istiyor: kullanıcı onayladıktan sonra bunları listeleyen bir `grant` ile yükleyin +@msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants “{name}” {asked} izinlerini istiyor: masaüstü uygulamasında bir kişinin onaylaması gerekir veya --allow-plugin-grants ile başlatılan bir oturumda `grant` geçirin +@msg `grant` and `allowHeavy` take effect only in a session started with --allow-plugin-grants: approve the plug-in in the desktop app `grant` ve `allowHeavy` yalnızca --allow-plugin-grants ile başlatılan bir oturumda etkili olur: eklentiyi masaüstü uygulamasında onaylayın +@msg Only the person at the keyboard can approve installing a plug-in Bir eklentinin yüklenmesini yalnızca klavye başındaki kişi onaylayabilir @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed “{name}” öğesine çıktısının ihtiyaç duyduğu `{p}` izni verilmedi: hiçbir şey değiştirilmedi @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed modül `hash` içinde verilen SHA-256 değerine sahip değil: hiçbir şey yüklenmedi # Actions panel: action sets (#3712). diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 81d168096..d2b31c943 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -5437,7 +5437,7 @@ axis Y Axis Вісь Y @msg Running “{name}”… Виконується «{name}»… @msg a live effect runs while the document is drawn, so it can't be `heavy` динамічний ефект виконується під час відмальовування документа, тому він не може бути `heavy` @msg an exporter always gets an export's budget: leave `budget` out модуль експорту завжди отримує бюджет експорту: не вказуйте `budget` -@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees «{_1}» запитує великий обчислювальний бюджет (кожен запуск до {minutes} хвилин і в {times} разів більше інструкцій, ніж зазвичай): встановіть його з `allowHeavy: true`, коли користувач погодиться +@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants «{_1}» запитує великий обчислювальний бюджет (кожен запуск до {minutes} хвилин і в {times} разів більше інструкцій, ніж зазвичай): його має схвалити людина в настільному застосунку, або передайте `allowHeavy: true` у сеансі, запущеному з --allow-plugin-grants # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? Встановити «{name}», якому потрібні ці дозволи? ID: {id} ID: {id} @@ -5462,7 +5462,9 @@ axis Y Axis Вісь Y @msg permission `{shown}` is listed twice дозвіл `{shown}` указано двічі @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it дозвіл `{shown}` зарезервовано для пізнішого API модулів; цей хост його не надає @msg unknown permission `{shown}` невідомий дозвіл `{shown}` -@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees «{name}» запитує дозволи {asked}: установіть його з `grant`, де їх перелічено, коли користувач погодиться +@msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants «{name}» запитує дозволи {asked}: їх має схвалити людина в настільному застосунку, або передайте `grant` у сеансі, запущеному з --allow-plugin-grants +@msg `grant` and `allowHeavy` take effect only in a session started with --allow-plugin-grants: approve the plug-in in the desktop app `grant` і `allowHeavy` діють лише в сеансі, запущеному з --allow-plugin-grants: схваліть плагін у настільному застосунку +@msg Only the person at the keyboard can approve installing a plug-in Схвалити встановлення плагіна може лише людина за клавіатурою @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed «{name}» не отримав дозволу `{p}`, потрібного для його результату: нічого не змінено @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed модуль має не той SHA-256, що вказаний у `hash`: нічого не встановлено # Actions panel: action sets (#3712). diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 3b1169439..0863e3c97 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5438,7 +5438,7 @@ axis Y Axis Trục Y @msg Running “{name}”… Đang chạy “{name}”… @msg a live effect runs while the document is drawn, so it can't be `heavy` hiệu ứng trực tiếp chạy trong lúc tài liệu được vẽ nên không thể là `heavy` @msg an exporter always gets an export's budget: leave `budget` out plug-in xuất luôn nhận ngân sách của một lần xuất: hãy bỏ `budget` -@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees “{_1}” yêu cầu ngân sách tính toán lớn (mỗi lần chạy tới {minutes} phút và gấp {times} lần số lệnh thông thường): hãy cài đặt với `allowHeavy: true` khi người dùng đồng ý +@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants “{_1}” yêu cầu ngân sách tính toán lớn (mỗi lần chạy tới {minutes} phút và gấp {times} lần số lệnh thông thường): cần một người phê duyệt trong ứng dụng máy tính, hoặc truyền `allowHeavy: true` trong phiên khởi động bằng --allow-plugin-grants # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? Cài đặt “{name}”, plug-in yêu cầu các quyền này? ID: {id} ID: {id} @@ -5463,7 +5463,9 @@ axis Y Axis Trục Y @msg permission `{shown}` is listed twice quyền `{shown}` được liệt kê hai lần @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it quyền `{shown}` được dành cho một API plug-in sau này; máy chủ này không cấp quyền đó @msg unknown permission `{shown}` quyền không xác định `{shown}` -@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}” yêu cầu các quyền {asked}: hãy cài đặt với `grant` liệt kê chúng khi người dùng đồng ý +@msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants “{name}” yêu cầu các quyền {asked}: cần một người phê duyệt trong ứng dụng máy tính, hoặc truyền `grant` trong phiên khởi động bằng --allow-plugin-grants +@msg `grant` and `allowHeavy` take effect only in a session started with --allow-plugin-grants: approve the plug-in in the desktop app `grant` và `allowHeavy` chỉ có hiệu lực trong phiên khởi động bằng --allow-plugin-grants: hãy phê duyệt trình cắm trong ứng dụng máy tính +@msg Only the person at the keyboard can approve installing a plug-in Chỉ người ngồi tại bàn phím mới có thể phê duyệt việc cài đặt trình cắm @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed “{name}” chưa được cấp `{p}`, quyền mà đầu ra của nó cần: không có gì thay đổi @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed mô-đun không có SHA-256 mà `hash` cho biết: không có gì được cài đặt # Actions panel: action sets (#3712). diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index d9fa2f182..920a637a3 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -5679,7 +5679,7 @@ axis Y Axis Y 轴 @msg Running “{name}”… 正在运行“{name}”… @msg a live effect runs while the document is drawn, so it can't be `heavy` 实时效果在绘制文档时运行,因此不能为 `heavy` @msg an exporter always gets an export's budget: leave `budget` out 导出增效工具始终使用导出的预算:请省略 `budget` -@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees “{_1}”需要大量计算预算(每次运行最多 {minutes} 分钟,指令数为通常的 {times} 倍):请在用户同意后使用 `allowHeavy: true` 安装 +@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants “{_1}”需要大量计算预算(每次运行最多 {minutes} 分钟,指令数为通常的 {times} 倍):需要有人在桌面应用中批准,或在以 --allow-plugin-grants 启动的会话中传入 `allowHeavy: true` # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? 要安装需要这些权限的“{name}”吗? ID: {id} ID:{id} @@ -5704,7 +5704,9 @@ axis Y Axis Y 轴 @msg permission `{shown}` is listed twice 权限 `{shown}` 列出了两次 @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it 权限 `{shown}` 保留给以后的增效工具 API;此主机不授予该权限 @msg unknown permission `{shown}` 未知权限 `{shown}` -@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}”需要权限 {asked}:请在用户同意后使用列出这些权限的 `grant` 安装 +@msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants “{name}”需要权限 {asked}:需要有人在桌面应用中批准,或在以 --allow-plugin-grants 启动的会话中传入 `grant` +@msg `grant` and `allowHeavy` take effect only in a session started with --allow-plugin-grants: approve the plug-in in the desktop app `grant` 和 `allowHeavy` 仅在以 --allow-plugin-grants 启动的会话中生效:请在桌面应用中批准此插件 +@msg Only the person at the keyboard can approve installing a plug-in 只有键盘前的人才能批准安装插件 @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed “{name}”未获授予其输出所需的 `{p}`:未作任何更改 @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed 模块的 SHA-256 与 `hash` 给出的不符:未安装任何内容 # Actions panel: action sets (#3712). diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 6a34a843a..112de5957 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -5475,7 +5475,7 @@ axis Y Axis Y 軸 @msg Running “{name}”… 正在執行「{name}」… @msg a live effect runs while the document is drawn, so it can't be `heavy` 即時效果會在繪製文件時執行,因此不能是 `heavy` @msg an exporter always gets an export's budget: leave `budget` out 匯出增效模組一律使用匯出的預算:請移除 `budget` -@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees 「{_1}」需要大量運算預算(每次執行最多 {minutes} 分鐘,指令數為一般的 {times} 倍):請在使用者同意後以 `allowHeavy: true` 安裝 +@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants 「{_1}」需要大量運算預算(每次執行最多 {minutes} 分鐘,指令數為一般的 {times} 倍):需要有人在桌面應用程式中核准,或在以 --allow-plugin-grants 啟動的工作階段中傳入 `allowHeavy: true` # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? 要安裝需要這些權限的「{name}」嗎? ID: {id} ID:{id} @@ -5500,7 +5500,9 @@ axis Y Axis Y 軸 @msg permission `{shown}` is listed twice 權限 `{shown}` 列出了兩次 @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it 權限 `{shown}` 保留給日後的增效模組 API;此主機不授予該權限 @msg unknown permission `{shown}` 未知權限 `{shown}` -@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees 「{name}」需要權限 {asked}:請在使用者同意後以列出這些權限的 `grant` 安裝 +@msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants 「{name}」需要權限 {asked}:需要有人在桌面應用程式中核准,或在以 --allow-plugin-grants 啟動的工作階段中傳入 `grant` +@msg `grant` and `allowHeavy` take effect only in a session started with --allow-plugin-grants: approve the plug-in in the desktop app `grant` 和 `allowHeavy` 僅在以 --allow-plugin-grants 啟動的工作階段中生效:請在桌面應用程式中核准此外掛 +@msg Only the person at the keyboard can approve installing a plug-in 只有鍵盤前的人才能核准安裝外掛 @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed 「{name}」未獲授予其輸出所需的 `{p}`:未做任何變更 @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed 模組的 SHA-256 與 `hash` 提供的不符:未安裝任何內容 # Actions panel: action sets (#3712). diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index d1e34e38b..21bb176b8 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -21,6 +21,7 @@ pub use export::{EXPORT_CANCELLED, cancel_exports, export, export_for_screens, e pub(crate) use export::{ export_files, exported_with_notes, file_url, open_in_app, open_original, reveal_path, run_to_file, save_command_output_named, }; +pub(crate) use open::report_installed; pub(crate) use open::{EXPORT_PRESETS, import_presets, opened}; pub use open::{ MAX_RECENT_FILES, OpensAs, ask_revert, install_module, install_plugin, new_from_template, note_recent, open_bytes, open_dialog, open_document, diff --git a/crates/ui-egui/src/io/open.rs b/crates/ui-egui/src/io/open.rs index 4f507fe30..b0bcf98e8 100644 --- a/crates/ui-egui/src/io/open.rs +++ b/crates/ui-egui/src/io/open.rs @@ -1,3 +1,4 @@ +use vectorcraft_engine::cmd::plugin::InstallApproval; use vectorcraft_plugins::permissions; use super::save::suggested; @@ -181,63 +182,72 @@ fn permission_words(name: &str) -> String { } /// Installs the plug-in module that `module` names (`plugin.install`'s `path`, or `dataBase64` -/// with `name`) → `plugin.install`'s result. A plug-in that lists permissions the user hasn't -/// granted it yet (`grant`) asks them first, showing its name, id, version, file hash and what each -/// permission lets it do, and so does a filter that asks for a heavy budget the user hasn't allowed -/// yet (`allowHeavy`); OK installs it if the module still has the hash the prompt showed, Cancel -/// installs nothing → `{pending}` while it asks. Every -/// text from the manifest is shown with its hidden characters escaped and its length capped +/// with `name`) → `plugin.install`'s result. A plug-in that lists permissions no person has granted +/// it yet asks first, showing its name, id, version, file hash and what each permission lets it +/// do, and so does a filter that asks for a heavy budget; OK installs it if the module still has +/// the hash the prompt showed, Cancel installs nothing → `{pending}` while it asks. The `grant` +/// and `allowHeavy` of a request are ignored: only the dialog's OK approves. Every text from the +/// manifest is shown with its hidden characters escaped and its length capped /// ([`permissions::display_text`]). pub fn install_module(app: &mut VectorcraftApp, module: Value) -> Result { - let heavy_allowed = module.get("allowHeavy").and_then(Value::as_bool) == Some(true); - let granted = module.get("grant").is_some_and(|g| !g.is_null()); - if !heavy_allowed || !granted { - let given = |k: &str| module.get(k).map(|v| (k.to_string(), v.clone())); - let source = Value::Object(["path", "dataBase64", "name"].into_iter().filter_map(given).collect()); - // What OK installs: the same module, with what the user has agreed to so far, pinned to - // the bytes the prompt shows (`hash`). - let mut answered = - Value::Object(["path", "dataBase64", "name", "replace", "grant", "allowHeavy", "hash"].into_iter().filter_map(given).collect()); - let info = app.run("plugin.inspect", source)?; - if answered.get("hash").is_none() { - answered["hash"] = info["hash"].clone(); - } - let shown = |k: &str, max: usize| permissions::display_text(info[k].as_str().unwrap_or_default(), max); - let name = shown("name", permissions::MAX_SHOWN_NAME); - if !granted && info["needsApproval"] == true { - let message = crate::i18n::fmt(tl!("Install “{name}”, which asks for these permissions?"), &[("name", &name)]); - let mut lines = vec![crate::i18n::fmt(tl!("ID: {id}"), &[("id", &shown("id", permissions::MAX_SHOWN_FIELD))])]; - let version = shown("version", permissions::MAX_SHOWN_FIELD); - if !version.is_empty() { - lines.push(crate::i18n::fmt(tl!("Version: {version}"), &[("version", &version)])); - } - lines.push(crate::i18n::fmt(tl!("File SHA-256: {hash}"), &[("hash", &shown("hash", 64))])); - lines.push(String::new()); - let asked = info["permissions"].as_array().cloned().unwrap_or_default(); - lines.extend(asked.iter().filter_map(Value::as_str).map(|p| format!("• {}", permission_words(p)))); - let mut again = answered; - again["grant"] = Value::Array(asked); - dialogs::confirm::ask(app, &message, &lines.join("\n"), "ui.installPlugin", again); - return Ok(json!({ "pending": dialogs::confirm::KIND })); - } - if !heavy_allowed && info["budget"] == "heavy" { - let (minutes, times) = vectorcraft_plugins::heavy_terms(); - let message = crate::i18n::fmt(tl!("Install “{name}”, which asks for a heavy budget?"), &[("name", &name)]); - let detail = crate::i18n::fmt( - tl!( - "Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it." - ), - &[("minutes", &minutes.to_string()), ("times", ×.to_string())], - ); - let mut allowed = answered; - allowed["allowHeavy"] = json!(true); - dialogs::confirm::ask(app, &message, &detail, "ui.installPlugin", allowed); - return Ok(json!({ "pending": dialogs::confirm::KIND })); - } + let given = |k: &str| module.get(k).map(|v| (k.to_string(), v.clone())); + let source = Value::Object(["path", "dataBase64", "name"].into_iter().filter_map(given).collect()); + let mut install = Value::Object(["path", "dataBase64", "name", "replace", "hash"].into_iter().filter_map(given).collect()); + let info = app.run("plugin.inspect", source)?; + let (needs_grant, heavy) = (info["needsApproval"] == true, info["budget"] == "heavy"); + if !needs_grant && !heavy { + let r = app.run("plugin.install", install)?; + report_installed(app, &r); + return Ok(r); } - let r = app.run("plugin.install", module)?; + // Pinned to the bytes the prompt shows. + if install.get("hash").is_none() { + install["hash"] = info["hash"].clone(); + } + let shown = |k: &str, max: usize| permissions::display_text(info[k].as_str().unwrap_or_default(), max); + let name = shown("name", permissions::MAX_SHOWN_NAME); + let asked = info["permissions"].as_array().cloned().unwrap_or_default(); + let mut paragraphs = vec![]; + if needs_grant { + let mut lines = vec![crate::i18n::fmt(tl!("ID: {id}"), &[("id", &shown("id", permissions::MAX_SHOWN_FIELD))])]; + let version = shown("version", permissions::MAX_SHOWN_FIELD); + if !version.is_empty() { + lines.push(crate::i18n::fmt(tl!("Version: {version}"), &[("version", &version)])); + } + lines.push(crate::i18n::fmt(tl!("File SHA-256: {hash}"), &[("hash", &shown("hash", 64))])); + lines.push(String::new()); + lines.extend(asked.iter().filter_map(Value::as_str).map(|p| format!("• {}", permission_words(p)))); + paragraphs.push(lines.join("\n")); + install["grant"] = Value::Array(asked.clone()); + } + if heavy { + let (minutes, times) = vectorcraft_plugins::heavy_terms(); + paragraphs.push(crate::i18n::fmt( + tl!( + "Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it." + ), + &[("minutes", &minutes.to_string()), ("times", ×.to_string())], + )); + install["allowHeavy"] = json!(true); + } + let message = if needs_grant { + crate::i18n::fmt(tl!("Install “{name}”, which asks for these permissions?"), &[("name", &name)]) + } else { + crate::i18n::fmt(tl!("Install “{name}”, which asks for a heavy budget?"), &[("name", &name)]) + }; + let approval = InstallApproval { + id: info["id"].as_str().unwrap_or_default().to_string(), + hash: info["hash"].as_str().unwrap_or_default().to_string(), + permissions: asked.iter().filter_map(Value::as_str).map(str::to_string).collect(), + heavy, + }; + let pending = dialogs::plugin::PendingInstall { message, detail: paragraphs.join("\n\n"), params: install, approval }; + Ok(dialogs::plugin::ask_install(app, pending)) +} + +/// Says in the status bar that `plugin.install`'s result `r` was installed. +pub(crate) fn report_installed(app: &mut VectorcraftApp, r: &Value) { app.status(format!("Installed plug-in {}", permissions::display_text(r["name"].as_str().unwrap_or_default(), permissions::MAX_SHOWN_NAME))); - Ok(r) } /// Where File → Open… starts: the folder of the most recently opened file, else the active diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9b054a683..0402d6627 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -302,6 +302,9 @@ pub struct VectorcraftApp { /// This frame carries input the control channel injected ([`Self::raw_input_hook`]): it runs /// confined to [`Self::automation_roots`]. synthetic_frame: bool, + /// The plug-in install the approval dialog asks about, kept out of the dialog's fields, which + /// `ui.dialog.set` writes. + pub(crate) pending_plugin_install: Option, /// The pen end the canvas saw last and the tool its eraser end stands in for /// ([`stylus::sync_eraser_tool`]). pub(crate) pen_end: stylus::PenEnd, diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 5813da723..2dca2b16d 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -1457,7 +1457,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "ui.installPlugin", "Install Plug-in…", "", - "{path?: a .wasm file | dataBase64?: the module, name?: the file name shown as its source (with dataBase64), allowHeavy?: bool (the user agreed to its heavy budget), grant?: [permission] (the permissions the user granted it), replace?: bool, hash?: the module's SHA-256 (as plugin.install)} install a WebAssembly plug-in (plugin.install); without a path pick one (the web opens the browser's file picker; File › Open of a .wasm installs it too). A plug-in that lists permissions the user hasn't granted it asks the user first, showing its name, id, version, file SHA-256 and what each permission lets it do, and so does a filter that asks for a heavy budget: OK installs it if the file still has the SHA-256 shown, Cancel installs nothing → plugin.install's result | {pending: \"confirm\"} while it asks", + "{path?: a .wasm file | dataBase64?: the module, name?: the file name shown as its source (with dataBase64), replace?: bool, hash?: the module's SHA-256 (as plugin.install)} install a WebAssembly plug-in (plugin.install); without a path pick one (the web opens the browser's file picker; File › Open of a .wasm installs it too). A plug-in that lists permissions the user hasn't granted it asks the user first, showing its name, id, version, file SHA-256 and what each permission lets it do, and so does a filter that asks for a heavy budget: OK installs it if the file still has the SHA-256 shown, Cancel installs nothing. Only a person can press OK: ui.dialog.confirm, answer_dialog and synthetic input are refused → plugin.install's result | {pending: \"pluginInstall\"} while it asks", ), ( "plugin.cancelRuns", diff --git a/crates/ui-egui/src/tests/automation.rs b/crates/ui-egui/src/tests/automation.rs index 5139d4881..5af13624b 100644 --- a/crates/ui-egui/src/tests/automation.rs +++ b/crates/ui-egui/src/tests/automation.rs @@ -294,6 +294,44 @@ fn an_unidentifiable_stale_injected_batch_fails_closed() { let _ = std::fs::remove_dir_all(base); } +/// A plug-in's permissions are approved only by a person. A control request (or MCP, which comes +/// through it) can't pass `grant`, can't answer the approval dialog or rewrite what it installs, +/// and a frame of synthetic input can't press its OK; the person at the keyboard can. +#[test] +fn automation_cannot_approve_a_plugin_install() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut app = VectorcraftApp::new(Session::new(), Default::default()).with_control(rx); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + let id = "test.ui.approval.automation"; + let manifest = format!(r#"{{"id":"{id}","name":"Approval","version":"1","kind":"filter","permissions":["doc.read"]}}"#); + let bytes = wat::parse_str(vectorcraft_plugins::wat::returning(&manifest, r#"{"objects":[]}"#)).unwrap(); + let data = vectorcraft_format::base64_encode(&bytes); + let refused = |r: &Value, says: &str| assert!(r["error"].as_str().is_some_and(|e| e.contains(says)), "{r}"); + + let r = request(&mut app, &tx, "engine.execute", json!({"command": "plugin.install", "params": {"dataBase64": data, "grant": ["doc.read"]}})); + refused(&r, "--allow-plugin-grants"); + assert!(app.ui.dialog.is_none()); + + let r = request(&mut app, &tx, "ui.menu.invoke", json!({"command": "ui.installPlugin", "params": {"dataBase64": data}})); + assert_eq!(r["result"]["pending"], crate::dialogs::plugin::INSTALL_KIND, "{r}"); + let r = request(&mut app, &tx, "ui.dialog.confirm", json!({})); + refused(&r, "Only the person at the keyboard"); + assert!(app.ui.dialog.is_some(), "the refused answer leaves it open"); + assert!(vectorcraft_plugins::registry::get(id).is_none()); + + // What the dialog installs isn't in its fields. + let r = request(&mut app, &tx, "ui.dialog.set", json!({"field": "__params", "value": {"dataBase64": "AAAA"}})); + assert!(r.get("error").is_none(), "{r}"); + app.synthetic_frame = true; + assert!(crate::dialogs::confirm(&mut app).unwrap_err().contains("Only the person at the keyboard")); + assert!(vectorcraft_plugins::registry::get(id).is_none()); + app.synthetic_frame = false; + + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!(vectorcraft_plugins::registry::granted(id).map(|g| g.hash), Some(vectorcraft_plugins::permissions::sha256_hex(&bytes))); + app.run("plugin.remove", json!({"id": id})).unwrap(); +} + /// Edit Original and the Attributes panel's Browser button hand a file or URL to another app: a /// control request (or MCP, which comes through it) can't run them, roots or not. #[test] diff --git a/crates/ui-egui/src/tests/plugin_runs.rs b/crates/ui-egui/src/tests/plugin_runs.rs index 194ec133d..c3bd9df30 100644 --- a/crates/ui-egui/src/tests/plugin_runs.rs +++ b/crates/ui-egui/src/tests/plugin_runs.rs @@ -13,6 +13,7 @@ use crate::{VectorcraftApp, dialogs, plugin_runs, theme}; /// An app with a new 200 × 200 pt document. fn app() -> VectorcraftApp { let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.allow_plugin_grants_from_requests(); app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); app } @@ -71,10 +72,10 @@ fn installing_a_heavy_filter_asks_first() { let id = "test.ui.heavy.install"; let bytes = heavy(id, 1, "{}"); let r = app.run("ui.installPlugin", json!({"dataBase64": vectorcraft_format::base64_encode(&bytes), "name": "route.wasm"})).unwrap(); - assert_eq!(r["pending"], dialogs::confirm::KIND); - let d = app.ui.dialog.as_ref().unwrap(); - assert_eq!(d.str("message"), format!("Install “Test {id}”, which asks for a heavy budget?")); - assert!(d.str("detail").contains("up to 5 minutes and do 20 times the work"), "{}", d.str("detail")); + assert_eq!(r["pending"], dialogs::plugin::INSTALL_KIND); + let pending = app.pending_plugin_install.as_ref().unwrap(); + assert_eq!(pending.message, format!("Install “Test {id}”, which asks for a heavy budget?")); + assert!(pending.detail.contains("up to 5 minutes and do 20 times the work"), "{}", pending.detail); assert!(registry::get(id).is_none(), "not before the user agrees"); dialogs::confirm(&mut app).unwrap(); assert_eq!(registry::get(id).map(|p| p.manifest().name.clone()), Some(format!("Test {id}"))); @@ -82,7 +83,7 @@ fn installing_a_heavy_filter_asks_first() { // File › Open of the .wasm asks too, and Cancel installs nothing. registry::remove(id); crate::io::open_bytes(&mut app, "route.wasm", &bytes, None).unwrap(); - assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(dialogs::confirm::KIND)); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(dialogs::plugin::INSTALL_KIND)); dialogs::cancel(&mut app); assert!(registry::get(id).is_none()); } diff --git a/crates/ui-egui/src/tests/plugins.rs b/crates/ui-egui/src/tests/plugins.rs index 59da85c22..9420f50b9 100644 --- a/crates/ui-egui/src/tests/plugins.rs +++ b/crates/ui-egui/src/tests/plugins.rs @@ -142,25 +142,26 @@ fn installing_a_plugin_that_lists_permissions_asks_first() { let bytes = asking(id, name, r#"["doc.read","doc.write"]"#); let data = vectorcraft_format::base64_encode(&bytes); let r = app.run("ui.installPlugin", json!({"dataBase64": data, "name": "tidy.wasm"})).unwrap(); - assert_eq!(r["pending"], dialogs::confirm::KIND); - let d = app.ui.dialog.as_ref().unwrap(); - assert_eq!(d.str("message"), "Install “Tidy\\u{202E}fdp.exe\\u{2066}”, which asks for these permissions?"); + assert_eq!(r["pending"], dialogs::plugin::INSTALL_KIND); + let pending = app.pending_plugin_install.as_ref().unwrap(); + assert_eq!(pending.message, "Install “Tidy\\u{202E}fdp.exe\\u{2066}”, which asks for these permissions?"); let hash = vectorcraft_plugins::permissions::sha256_hex(&bytes); - assert_eq!(d.fields.get("__params").map(|p| p["hash"].clone()), Some(json!(hash)), "OK installs the module shown"); - let detail = d.str("detail"); + assert_eq!(pending.params["hash"], json!(hash), "OK installs the module shown"); + let detail = pending.detail.clone(); assert_eq!( detail, format!( "ID: {id}\nVersion: 3.0\\u{{202E}}\\u{{0009}}\\u{{0007}}\nFile SHA-256: {hash}\n\n• Read the objects it is given\n• Change, add and delete objects in the document" ) ); - assert!(!detail.chars().chain(d.str("message").chars()).any(|c| matches!(c, '\u{202E}' | '\u{2066}' | '\t' | '\u{0007}'))); + assert!(!detail.chars().chain(pending.message.chars()).any(|c| matches!(c, '\u{202E}' | '\u{2066}' | '\t' | '\u{0007}'))); assert!(vectorcraft_plugins::registry::get(id).is_none(), "not before the user agrees"); dialogs::cancel(&mut app); assert!(vectorcraft_plugins::registry::get(id).is_none(), "Cancel installs nothing"); + assert!(app.pending_plugin_install.is_none(), "Cancel drops what it asked about"); // 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::confirm::KIND)); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(dialogs::plugin::INSTALL_KIND)); dialogs::confirm(&mut app).unwrap(); assert!(app.ui.dialog.is_none()); assert!(vectorcraft_plugins::registry::get(id).is_some()); @@ -171,11 +172,11 @@ fn installing_a_plugin_that_lists_permissions_asks_first() { // Other bytes with the same permissions ask, from both ways in; Cancel keeps the one installed. let again = asking(id, "Tidy 2", r#"["doc.write","doc.read"]"#); let r = app.run("ui.installPlugin", json!({"dataBase64": vectorcraft_format::base64_encode(&again)})).unwrap(); - assert_eq!(r["pending"], dialogs::confirm::KIND, "other bytes ask again"); + assert_eq!(r["pending"], dialogs::plugin::INSTALL_KIND, "other bytes ask again"); dialogs::cancel(&mut app); assert_eq!(vectorcraft_plugins::registry::get(id).map(|p| p.manifest().name.clone()), Some(name.into()), "Cancel keeps the one installed"); io::open_bytes(&mut app, "tidy2.wasm", &again, None).unwrap(); - assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(dialogs::confirm::KIND), "File › Open asks too"); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(dialogs::plugin::INSTALL_KIND), "File › Open asks too"); dialogs::confirm(&mut app).unwrap(); assert_eq!(vectorcraft_plugins::registry::get(id).map(|p| p.manifest().name.clone()), Some("Tidy 2".into())); let new_hash = vectorcraft_plugins::permissions::sha256_hex(&again); @@ -183,8 +184,8 @@ fn installing_a_plugin_that_lists_permissions_asks_first() { // One that asks for another permission asks again. let more = asking(id, "Tidy 3", r#"["doc.read","doc.write","state"]"#); let r = app.run("ui.installPlugin", json!({"dataBase64": vectorcraft_format::base64_encode(&more)})).unwrap(); - assert_eq!(r["pending"], dialogs::confirm::KIND); - assert!(app.ui.dialog.as_ref().unwrap().str("detail").ends_with("• Keep data of its own with the document")); + assert_eq!(r["pending"], dialogs::plugin::INSTALL_KIND); + assert!(app.pending_plugin_install.as_ref().unwrap().detail.ends_with("• Keep data of its own with the document")); dialogs::cancel(&mut app); 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. @@ -202,8 +203,8 @@ fn installing_a_plugin_that_reports_progress_says_so() { let manifest = format!(r#"{{"id":"{id}","name":"Count","kind":"filter","api":2,"permissions":["doc.read","progress"]}}"#); let bytes = wat::parse_str(tpl::reporting(&manifest, "(call $vc_progress (i64.const 1) (i64.const 2))", r#"{"objects":[]}"#)).unwrap(); let r = app.run("ui.installPlugin", json!({"dataBase64": vectorcraft_format::base64_encode(&bytes)})).unwrap(); - assert_eq!(r["pending"], dialogs::confirm::KIND); - let detail = app.ui.dialog.as_ref().unwrap().str("detail"); + 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}"); dialogs::confirm(&mut app).unwrap(); assert_eq!(vectorcraft_plugins::registry::granted(id).map(|g| g.permissions.len()), Some(2)); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index a799f22ff..f759782a0 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -214,7 +214,8 @@ after it), `__plugin` (the plug-in id) and `preview`. It previews `plugin.run` o the `effect` dialog (`effect.dialog {effect: "plugin."}`). An exporter plug-in with parameters opens the `pluginExport` dialog once Export As has picked the file: one field per parameter, `__plugin`, and `__export` (the export's own parameters); `ui.dialog.confirm` writes the file. `ui.installPlugin {path?}` installs a `.wasm` (without -a path it asks for one); see [plugins.md](plugins.md). +a path it asks for one); one that needs approval opens the `pluginInstall` dialog, which only a person can confirm +(`ui.dialog.confirm` and injected input are refused); see [plugins.md](plugins.md). Overprint Black: the Edit → Edit Colors → Overprint Black… menu item opens a `command` parameter dialog for `edit.colors.overprintBlack` (fields `remove`, `percentage`, `fill`, `stroke`, `includeCmyBlacks`, diff --git a/docs/mcp.md b/docs/mcp.md index 71e7978bd..539cf7b72 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6168,15 +6168,19 @@ id `plugin.`: `effect.apply`, `effect.setParams` and `effect.expandAppearanc `document.export {format: "plugin.", params?, path?}` writes it, as does a `path` ending in one of its extensions that no built-in format has. A failing plug-in returns an error and leaves the document as it was (an export writes no file). A filter whose manifest asks for a heavy budget (minutes rather than a minute, `budget: "heavy"` in its -`plugin.inspect {path | dataBase64}` entry) installs only with `allowHeavy: true`: ask the user first. `plugin.run` +`plugin.inspect {path | dataBase64}` entry) needs a person's approval, and `plugin.install` refuses `allowHeavy: true` +from a request. `plugin.run` runs it where it is called, so the call may take minutes; `notifications/cancelled` stops it. A plug-in whose manifest lists permissions (`permissions` in its `plugin.inspect` entry, with `needsApproval: true`) -installs only with `grant: [permission]` listing every one of them: show the user the list and ask first, and pass -the entry's `hash` as `hash` so that a module that changed since is refused. Without `grant`, `plugin.install` returns -an error naming the permissions needed. The grant is kept with the module's hash and covers only that module: the -same bytes install again without `grant`, and a module with other bytes has `needsApproval: true` and needs a new -`grant` even when it lists the same permissions. `plugin.install` does not check that a person approved `grant`: it -trusts the client that sends it. `plugin.remove` forgets the grant. A +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` +accepts `grant` and `allowHeavy` from any request, in that session only (headless; it can't be combined with +`--connect`). Without `--allow-plugin-grants` and without a desktop approval, `plugin.install` returns +an error naming the permissions needed. The approval is kept with the module's hash and covers only that module: the +same bytes install again without one, also from a headless session that reads the same `grants.json`, and a module +with other bytes has `needsApproval: true` and needs a new one even when it lists the same permissions. `plugin.remove` +forgets the approval. A filter without `doc.write` or `state` that returns output needing them gets an error and the document is unchanged. A plug-in of API level 2 (`api: 2` in its entry) that lists `progress` may report its progress, see Cancel and write log lines: `notifications/cancelled` makes its `vc_cancelled` return 1, and its log lines go to the server's log diff --git a/docs/plugins.md b/docs/plugins.md index 80544ad2b..b5a4296b4 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -26,8 +26,8 @@ This page is the contract for plug-in authors and the reference for the host (`c | `plugin.list` | `{}` | `{"plugins": [{id, name, version, kind, author, description, params, paramsSchema, effect?, format?, extensions?, budget?, size, source, api, permissions, hash}], "folder": report}` (`api` is the manifest's [plug-in API level](#permissions); `budget: "heavy"` for a [heavy filter](#heavy-filters); `permissions` lists what it [may do](#permissions), `hash` is the module's SHA-256) | | `plugin.info` | `{"id"}` | One plug-in: the `plugin.list` entry plus `manifest`, `defaults` and `lastError` (a live effect's last failure). | | `plugin.run` | `{"id", "params"?: {…}, "ids"?: [...]}` (plug-in params may also be top-level keys) | Runs an object filter on the selection (or `ids`). One undo step; the result is selected, except the objects it locked or made guides. | -| `plugin.install` | `{"path": "/x/y.wasm"}` (desktop) or `{"dataBase64": "…", "name"?}`, `"replace": bool = true`, `"allowHeavy": bool = false`, `"grant"?: [permission]`, `"hash"?: "sha256 hex"` | Validates and installs a module; returns its `plugin.list` entry. The desktop app and `vectorcraft-cli` keep it in the `Plug-ins` folder next to the preferences, so it is installed again after a restart (#2541). A [heavy filter](#heavy-filters) installs only with `allowHeavy: true`, once the user agreed. A plug-in whose manifest lists [permissions](#permissions) installs only when `grant` lists every one of them, once the user agreed, unless the user already granted a module with the same SHA-256. With `hash`, a module whose SHA-256 differs is refused. | -| `plugin.inspect` | as `plugin.install`: `{"path"}` or `{"dataBase64", "name"?}` | Validates a module without installing it; returns the `plugin.list` entry it would have, plus `needsApproval` (true when `plugin.install` needs `grant`). | +| `plugin.install` | `{"path": "/x/y.wasm"}` (desktop) or `{"dataBase64": "…", "name"?}`, `"replace": bool = true`, `"allowHeavy": bool = false`, `"grant"?: [permission]`, `"hash"?: "sha256 hex"` | Validates and installs a module; returns its `plugin.list` entry. The desktop app and `vectorcraft-cli` keep it in the `Plug-ins` folder next to the preferences, so it is installed again after a restart (#2541). A [heavy filter](#heavy-filters) installs only with `allowHeavy: true`. A plug-in whose manifest lists [permissions](#permissions) installs only when `grant` lists every one of them, unless a person already granted a module with the same SHA-256. A request's `grant` and `allowHeavy` are refused except in a session started with `vectorcraft-cli --allow-plugin-grants`. With `hash`, a module whose SHA-256 differs is refused. | +| `plugin.inspect` | as `plugin.install`: `{"path"}` or `{"dataBase64", "name"?}` | Validates a module without installing it; returns the `plugin.list` entry it would have, plus `needsApproval` (true when installing it needs a person's approval). | | `plugin.remove` | `{"id"}` | Uninstalls, and deletes it from the `Plug-ins` folder. | | `plugin.reload` | `{"path"?: "folder"}` (default: the preference folder) | Installs every `*.wasm` in a folder; a bad module is reported and skipped, and so are a heavy filter and a plug-in whose permissions the user hasn't granted (nobody was asked). | @@ -89,8 +89,9 @@ budget). Each of its runs gets 1 G instructions (20 times a light run's) plus th much there is only once the run has ended. - **Installing asks first.** Install Plug-in… and File › Open show what the filter asks for, and install it once the - user clicks OK; `plugin.install` refuses it without `allowHeavy: true`, which an agent passes once the user agreed - (`plugin.inspect` tells it beforehand). A filter kept in the `Plug-ins` folder was agreed to and is installed again + user clicks OK (see [Permissions](#permissions) for who can). `plugin.install` refuses it without `allowHeavy: true`, + and refuses `allowHeavy` from a request except in a session started with `vectorcraft-cli --allow-plugin-grants` + (`plugin.inspect` shows the budget beforehand). A filter kept in the `Plug-ins` folder was agreed to and is installed again at startup. The Additional Plug-ins Folder skips heavy filters with a note in its report, since loading a folder asks nobody. - **It runs in the background.** Its dialog has no Preview. OK (or the menu item, without parameters) runs it on a @@ -129,10 +130,14 @@ capability model proposed for PhotoCraft plug-ins the file and each permission in plain words, and install it once the user clicks OK, if the file still has the SHA-256 shown; Cancel installs nothing. The names shown have control and bidirectional characters escaped (`\u{202E}`) and are cut to 64 characters. - `plugin.install` refuses the module, naming the permissions it needs, unless `grant` lists them all, which an agent - passes once the user agreed (`plugin.inspect`'s `needsApproval` tells it beforehand), with `hash` set to the - `plugin.inspect` entry's `hash` so that the module installed is the one the user saw. `plugin.install` does not - check that a person approved `grant`: it trusts the client that sends it. + 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, and Enter does not confirm it. `ui.dialog.set` can't change what it installs. +- `plugin.install` refuses the module, naming the permissions it needs, unless a person already granted this module + (`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` + takes `grant` and `allowHeavy` from any request; only the person starting the process can set that flag, and it + doesn't apply to a running app. Without it, MCP in headless mode and the CLI install a module that lists + permissions only when a person approved its hash in the desktop app, which they read from the shared `grants.json`. - The grant is recorded with the plug-in's id, the module's hash and the permissions, in `grants.json` in the `Plug-ins` folder, and covers only that module. Installing the same bytes again needs no new approval. A module with other bytes asks again, even when it lists the same permissions. Until it is approved, the installed module and its diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 816df8d0f..047fb5304 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1505,6 +1505,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Plug-in instruction budget at a bulk memory instruction:** a run that pauses where one slice of its instruction budget ends keeps the instructions the slice still held. A `memory.fill` or `memory.copy` that the slice can't pay for in full runs on the next slice, so a run whose budget covers its work finishes; a run ends with `Limit("instruction budget")` only when the instructions it has left can't pay for the next instruction. Regressions: `crates/plugins/tests/abi.rs`. Scores and hours unchanged. - **Plug-in permissions (G17):** a manifest may list `permissions` (`doc.read`, `doc.write`, `state`, `effect.render`, `export.write`; the first three follow storytold/photocraft#662) and an `api` level; without the list a plug-in gets what its kind implies and installs as before. Install Plug-in… and File › Open show the name, id, version, file SHA-256 and each permission in plain words, with control and bidirectional characters escaped, and install nothing on Cancel; `plugin.install` needs `grant`, and folders install only granted plug-ins. The grant is kept with the module's hash, a module asking for more asks again, and a filter run refuses output its permissions don't allow. Regressions: `crates/plugins/src/permissions.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugins.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 with 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 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 progress, Cancel and log lines (G17):** at plug-in API level 2 a plug-in with the `progress` permission imports `env.vc_progress`, `env.vc_cancelled` and `env.vc_log`. A heavy filter's and an export's progress windows show its progress as a percentage bar, their Cancel reaches it through `vc_cancelled`, and its log lines go to the app's log with its id, capped per line and per run. Installing checks every import and names the one it refuses. Regressions: `crates/plugins/src/host.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugin_runs.rs`. Scores and hours unchanged. - **Plug-in effects after a plug-in changes:** when a plug-in is installed for the first time, installed again or removed (`plugin.install`, `plugin.reload`, `plugin.remove`, File › Open of a `.wasm` file, the Additional Plug-ins Folder preference), the next frame the canvas draws uses the plug-ins then installed for objects with a plug-in effect and their raster effects, for the blends, envelopes and repeats that show such objects, and for pattern tiles and symbol instances whose art holds one. From b33b4525396e909716bdcb5dd826a81a2814fce9 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 12:12:57 -0800 Subject: [PATCH 02/10] Plug-ins: an install with invalid params spends its approval --- crates/engine/src/cmd/plugin.rs | 4 ++-- crates/engine/src/tests/plugins.rs | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/plugin.rs b/crates/engine/src/cmd/plugin.rs index 5405f02e7..0b97ad65f 100644 --- a/crates/engine/src/cmd/plugin.rs +++ b/crates/engine/src/cmd/plugin.rs @@ -390,10 +390,10 @@ impl Session { /// `plugin.list` entry. fn install(s: &mut Session, p: &Value) -> Result { const C: &str = "plugin.install"; - let (replace, allow_heavy) = (switch_or(p, "replace", true, C)?, switch_or(p, "allowHeavy", false, C)?); - let grant = grant_param(C, p)?; // One use: spent whatever this install does. let approval = s.plugin_install_approval.take(); + let (replace, allow_heavy) = (switch_or(p, "replace", true, C)?, switch_or(p, "allowHeavy", false, C)?); + let grant = grant_param(C, p)?; let (plugin, bytes) = load_given(C, p)?; // `hash` pins the module the user was shown: one with other bytes is refused. if let Some(h) = p.get("hash").filter(|v| !v.is_null()) diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index 041a0463e..b4e05bb94 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -1140,6 +1140,11 @@ fn a_request_cannot_grant_what_only_a_person_approves() { assert!(e.contains("SHA-256"), "{e}"); let e = s.execute("plugin.install", &json!({"dataBase64": data, "grant": ["doc.read"]})).unwrap_err().to_string(); assert!(e.contains(refused), "{e}"); + // So does one refused for its params. + s.approve_plugin_install(approval(id, &wasm, &["doc.read"], false)); + s.execute("plugin.install", &json!({"dataBase64": data, "grant": "doc.read"})).unwrap_err(); + let e = s.execute("plugin.install", &json!({"dataBase64": data, "grant": ["doc.read"]})).unwrap_err().to_string(); + assert!(e.contains(refused), "{e}"); // The heavy budget needs its own agreement. let heavy_id = "test.engine.approval.heavy"; From c396ad104abc16637ff498467bf81c537d03b56a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 12:24:12 -0800 Subject: [PATCH 03/10] Plug-ins: an update the store can't take keeps the stored grant --- crates/engine/src/cmd/plugin.rs | 10 +++++++--- crates/engine/src/tests/plugins.rs | 30 ++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/plugin.rs b/crates/engine/src/cmd/plugin.rs index 0b97ad65f..753c28708 100644 --- a/crates/engine/src/cmd/plugin.rs +++ b/crates/engine/src/cmd/plugin.rs @@ -437,13 +437,17 @@ fn install(s: &mut Session, p: &Value) -> Result { ), )); } - if m.asks_permissions() { - let g = Grant { id: m.id.clone(), hash: plugin.hash().to_string(), permissions: wanted }; + let new_grant = m.asks_permissions().then(|| Grant { id: m.id.clone(), hash: plugin.hash().to_string(), permissions: wanted }); + if let Some(g) = &new_grant { registry::grant(g.clone()); - s.store_grant(&m.id, Some(g)); } // Installed plug-ins stay installed after a restart (#2541). let stored = s.store_plugin(plugin.id(), &bytes); + // A stored grant names one module: when this one could not replace the stored file, the file + // keeps the grant it has. + if stored && let Some(g) = new_grant { + s.store_grant(&m.id, Some(g)); + } // Installing a disabled plug-in again (an updated version) turns it on for the next launch too, // also when it is disabled by its stored file alone (it stopped a launch while loading). When // the new version could not replace the stored file, the old one stays disabled. diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index b4e05bb94..ef9b35a98 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -801,6 +801,36 @@ fn a_filter_does_only_what_its_permissions_allow() { assert_eq!(s.doc().unwrap().doc.plugin_data(stateless), Some(&json!({"pen": 1}))); } +/// When an update can't replace the stored module, the store's grant stays the one for the module +/// it still holds, so that module loads at the next start. +#[test] +fn a_module_the_store_could_not_take_leaves_the_stored_grant() { + let folder = crate::tests_links::Folder::new("plugin-store-failed-update"); + let id = "test.engine.permissions.unwritable"; + let old = asking(id, r#"["doc.read"]"#, &json!({"objects": []})); + let new = asking(id, r#"["doc.read"]"#, &json!({"objects": [{"name": "x"}]})); + let (old_hash, new_hash) = (vectorcraft_plugins::permissions::sha256_hex(&old), vectorcraft_plugins::permissions::sha256_hex(&new)); + let mut s = session(); + s.attach_plugin_store(folder.0.clone()); + run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&old), "grant": ["doc.read"]})); + let stored = std::fs::read_dir(&folder.0).unwrap().map(|e| e.unwrap().path()).find(|p| p.extension().is_some_and(|e| e == "wasm")).unwrap(); + // A folder under the module's file name can't be replaced by a file. + std::fs::remove_file(&stored).unwrap(); + std::fs::create_dir(&stored).unwrap(); + run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&new), "grant": ["doc.read"]})); + let grants = std::fs::read_to_string(folder.0.join("grants.json")).unwrap(); + assert!(grants.contains(&old_hash) && !grants.contains(&new_hash), "{grants}"); + // With the old module back in that place, the next session loads it. + std::fs::remove_dir(&stored).unwrap(); + std::fs::write(&stored, &old).unwrap(); + vectorcraft_plugins::registry::remove(id); + vectorcraft_plugins::registry::revoke(id); + let mut next = session(); + next.attach_plugin_store(folder.0.clone()); + assert_eq!(vectorcraft_plugins::registry::get(id).map(|p| p.hash().to_string()), Some(old_hash)); + run(&mut next, "plugin.remove", json!({"id": id})); +} + /// What the user granted is kept in the plug-in store with the module: the next session installs /// the plug-in again without asking. A stored plug-in that lists permissions nobody granted it is /// skipped, even when another store granted it, and so is one in the Additional Plug-ins Folder From 0f002e7625e9b47ee00169af6f0a2d13dacd3a56 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 17:47:02 -0800 Subject: [PATCH 04/10] Plug-ins: tests cover each route that cannot approve an install Add tests that an action step running plugin.install with grant is refused, that ui.key, ui.click and ui.pointer sent through the control path at the open approval dialog approve nothing, with OK focused too, that MCP answer_dialog reports the app's refusal, that ui.installPlugin given grant or allowHeavy approves nothing, and that a module recorded in grants.json installs from a session with that store and no flag while other bytes under its id are refused. The batch assertion now checks the refusal's message. The docs and comments state that Enter confirms the dialog only when OK has the keyboard focus. --- crates/engine/src/tests/plugins.rs | 41 ++++++- crates/mcp/src/tests.rs | 23 ++++ crates/ui-egui/src/dialogs/plugin.rs | 5 +- .../src/dialogs/tests_plugin_fields.rs | 3 +- crates/ui-egui/src/tests/automation.rs | 103 ++++++++++++++++++ crates/ui-egui/src/tests/plugins.rs | 57 ++++++++++ docs/plugins.md | 3 +- 7 files changed, 229 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index 51e67766f..0e83a5365 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -1296,8 +1296,8 @@ fn a_request_cannot_grant_what_only_a_person_approves() { let e = s.execute("plugin.install", &json!({"dataBase64": data})).unwrap_err().to_string(); assert!(e.contains("approve them in the desktop app") && e.contains("--allow-plugin-grants"), "{e}"); let batch = json!({"commands": [{"command": "plugin.install", "params": {"dataBase64": data, "grant": ["doc.read"]}}]}); - let r = s.execute("command.batch", &batch); - assert!(r.as_ref().map_or(true, |v| v.to_string().contains(refused)), "a batch can't grant: {r:?}"); + let e = s.execute("command.batch", &batch).unwrap_err().to_string(); + assert!(e.contains(refused), "a batch can't grant: {e}"); assert!(vectorcraft_plugins::registry::get(id).is_none()); // Approved for other bytes, for another id or for other permissions: refused, and spent. @@ -1359,6 +1359,43 @@ fn a_request_cannot_grant_what_only_a_person_approves() { run(&mut s, "plugin.remove", json!({"id": id})); } +/// A module a person approved in the desktop app, recorded in the store's `grants.json`, installs +/// from another session with that store and no flag and no `grant`. Other bytes under its id are +/// refused there, with or without `grant`. +#[test] +fn a_module_approved_in_the_app_installs_from_a_strict_session_with_its_store() { + let folder = crate::tests_links::Folder::new("plugin-approval-store"); + let id = "test.engine.approval.store"; + let wasm = asking(id, r#"["doc.read"]"#, &json!({"objects": []})); + let data = vectorcraft_format::base64_encode(&wasm); + let mut desktop = strict_session(); + desktop.attach_plugin_store(folder.0.clone()); + desktop.approve_plugin_install(approval(id, &wasm, &["doc.read"], false)); + run(&mut desktop, "plugin.install", json!({"dataBase64": data, "grant": ["doc.read"]})); + assert!(std::fs::read_to_string(folder.0.join("grants.json")).unwrap().contains(id), "recorded in the store"); + + // Another process starts with an empty registry and the store's grants. + let forget = || { + vectorcraft_plugins::registry::remove(id); + vectorcraft_plugins::registry::revoke(id); + }; + forget(); + let mut headless = strict_session(); + headless.attach_plugin_store(folder.0.clone()); + forget(); + run(&mut headless, "plugin.install", json!({"dataBase64": data})); + assert_eq!(vectorcraft_plugins::registry::get(id).map(|p| p.hash().to_string()), Some(vectorcraft_plugins::permissions::sha256_hex(&wasm))); + + let other = asking(id, r#"["doc.read"]"#, &json!({"objects": [{"name": "x"}]})); + let other_data = vectorcraft_format::base64_encode(&other); + let e = headless.execute("plugin.install", &json!({"dataBase64": other_data, "replace": true})).unwrap_err().to_string(); + assert!(e.contains("approve them in the desktop app"), "{e}"); + let e = headless.execute("plugin.install", &json!({"dataBase64": other_data, "replace": true, "grant": ["doc.read"]})).unwrap_err().to_string(); + assert!(e.contains("take effect only in a session started with --allow-plugin-grants"), "{e}"); + assert_eq!(vectorcraft_plugins::registry::get(id).map(|p| p.hash().to_string()), Some(vectorcraft_plugins::permissions::sha256_hex(&wasm))); + run(&mut headless, "plugin.remove", json!({"id": id})); +} + /// A filter run the caller cancels (MCP's `notifications/cancelled`, the app's Cancel) stops between /// two slices of its budget and changes nothing. #[test] diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index 90df3349e..9920f1af8 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -709,6 +709,11 @@ fn errors_are_tool_results_not_crashes() { /// A fake control server: answers `document.inspect`, echoes `engine.execute`, errors otherwise. fn fake_app() -> (String, std::thread::JoinHandle>) { + fake_app_refusing_confirm(None) +} + +/// [`fake_app`], answering `ui.dialog.confirm` with the error `refusal` when one is given. +fn fake_app_refusing_confirm(refusal: Option<&'static str>) -> (String, std::thread::JoinHandle>) { let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); let addr = listener.local_addr().unwrap().to_string(); let h = std::thread::spawn(move || { @@ -724,6 +729,7 @@ fn fake_app() -> (String, std::thread::JoinHandle>) { "engine.execute" => json!({"ok": true, "result": {"echo": msg["params"]}}), "ui.inspect" => json!({"ok": true, "result": {"tool": "selection"}}), "ui.dialog.set" if !dialog_open => json!({"ok": false, "error": "no dialog open"}), + "ui.dialog.confirm" if refusal.is_some() => json!({"ok": false, "error": refusal}), m if m.starts_with("ui.dialog.") => { dialog_open &= m != "ui.dialog.cancel"; json!({"ok": true, "result": {"echo": msg["params"]}}) @@ -814,6 +820,23 @@ fn answer_dialog_does_not_confirm_when_the_app_refuses_the_set() { assert_eq!(seen[1]["params"], json!({"fields": set})); } +/// The app refuses to confirm a plug-in's install approval for automation: the call is an error +/// with the app's reason, and nothing else is sent that could install the module. +#[test] +fn answer_dialog_reports_that_the_app_refuses_to_approve_a_plugin_install() { + let refusal = "Only the person at the keyboard can approve installing a plug-in"; + let (addr, h) = fake_app_refusing_confirm(Some(refusal)); + let mut s = Server::new(Box::new(Remote::connect(&addr).unwrap())); + let set = json!([{"field": "__params", "value": {"dataBase64": "AAAA"}}]); + let r = call(&mut s, 1, "answer_dialog", json!({"set": set, "action": "confirm"})); + assert_eq!(r["isError"], true, "{r}"); + assert!(text_of(&r).contains(refusal), "{r}"); + drop(s); + let seen = h.join().unwrap(); + let methods: Vec<&str> = seen.iter().map(|m| m["method"].as_str().unwrap()).collect(); + assert_eq!(methods, ["ui.dialog.set", "ui.dialog.confirm"]); +} + #[test] fn answer_dialog_needs_the_desktop_app() { let mut s = server(); diff --git a/crates/ui-egui/src/dialogs/plugin.rs b/crates/ui-egui/src/dialogs/plugin.rs index f8457385d..46a1c4210 100644 --- a/crates/ui-egui/src/dialogs/plugin.rs +++ b/crates/ui-egui/src/dialogs/plugin.rs @@ -14,7 +14,7 @@ //! //! 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, and Enter doesn't. +//! can't confirm it. Enter confirms it only when OK has the keyboard focus. use std::sync::Arc; @@ -285,7 +285,8 @@ pub(crate) struct PendingInstall { pub approval: InstallApproval, } -/// No OK in the shared footer, so Enter does nothing. +/// No OK in the shared footer: the OK in its body is a button, which Enter clicks only when it has +/// the keyboard focus. pub(super) const INSTALL_SPEC: DialogSpec = DialogSpec { heading: |_| tl!("Install Plug-in…").trim_end_matches('…').to_string(), body_with_actions: Some(install_body), diff --git a/crates/ui-egui/src/dialogs/tests_plugin_fields.rs b/crates/ui-egui/src/dialogs/tests_plugin_fields.rs index 8f78b719d..f5f4e719a 100644 --- a/crates/ui-egui/src/dialogs/tests_plugin_fields.rs +++ b/crates/ui-egui/src/dialogs/tests_plugin_fields.rs @@ -13,7 +13,8 @@ use vectorcraft_plugins::wat as tpl; use super::*; -/// The install approval has no OK in the shared footer: Enter, real or injected, doesn't press it. +/// The install approval has no OK in the shared footer, so Enter presses OK only when OK has the +/// keyboard focus. #[test] fn enter_does_not_answer_the_install_approval() { assert!(plugin::INSTALL_SPEC.ok.is_none() && plugin::INSTALL_SPEC.body_with_actions.is_some()); diff --git a/crates/ui-egui/src/tests/automation.rs b/crates/ui-egui/src/tests/automation.rs index b61360dc8..86e6c6940 100644 --- a/crates/ui-egui/src/tests/automation.rs +++ b/crates/ui-egui/src/tests/automation.rs @@ -366,6 +366,12 @@ fn automation_cannot_approve_a_plugin_install() { // What the dialog installs isn't in its fields. let r = request(&mut app, &tx, "ui.dialog.set", json!({"field": "__params", "value": {"dataBase64": "AAAA"}})); assert!(r.get("error").is_none(), "{r}"); + // What MCP's `answer_dialog` sends: `ui.dialog.set {fields}`, then `ui.dialog.confirm`. + let r = request(&mut app, &tx, "ui.dialog.set", json!({"fields": [{"field": "__params", "value": {"dataBase64": "AAAA"}}]})); + assert!(r.get("error").is_none(), "{r}"); + let r = request(&mut app, &tx, "ui.dialog.confirm", json!({})); + refused(&r, "Only the person at the keyboard"); + assert!(app.ui.dialog.is_some() && vectorcraft_plugins::registry::get(id).is_none()); app.synthetic_frame = true; assert!(crate::dialogs::confirm(&mut app).unwrap_err().contains("Only the person at the keyboard")); assert!(vectorcraft_plugins::registry::get(id).is_none()); @@ -376,6 +382,103 @@ fn automation_cannot_approve_a_plugin_install() { app.run("plugin.remove", json!({"id": id})).unwrap(); } +type DialogHarness = egui_kittest::Harness<'static, VectorcraftApp>; + +/// Runs one UI pass of `h` for each batch of synthetic input the control path queued, each taken +/// through the host's raw-input hook, until none is left and the last batch is acknowledged. +fn take_injected(h: &mut DialogHarness) { + let ctx = h.ctx.clone(); + loop { + let mut raw = egui::RawInput::default(); + h.state_mut().raw_input_hook(&ctx, &mut raw); + if raw.events.is_empty() && h.state().synthetic.is_empty() { + break; + } + h.input_mut().events.extend(raw.events); + h.step(); + } +} + +/// The open install approval, with nothing installed or granted for `id`. +fn still_waiting(h: &DialogHarness, id: &str) -> bool { + h.state().ui.dialog.as_ref().is_some_and(|d| d.kind == crate::dialogs::plugin::INSTALL_KIND) + && vectorcraft_plugins::registry::get(id).is_none() + && vectorcraft_plugins::registry::granted(id).is_none() +} + +/// Keys and pointer input sent through the control path approve nothing at the open install +/// approval: Enter and Space press nothing with no button focused, and with OK focused they and a +/// `ui.click` on it press it and are refused. `ui.pointer` drives the canvas. A person's click on +/// the same button approves. +#[test] +fn injected_keys_and_pointer_input_cannot_approve_a_plugin_install() { + use egui_kittest::kittest::{NodeT, Queryable}; + let refusal = "Only the person at the keyboard"; + let (tx, rx) = std::sync::mpsc::channel(); + let mut app = VectorcraftApp::new(Session::new(), Default::default()).with_control(rx); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + let id = "test.ui.approval.injected"; + let manifest = format!(r#"{{"id":"{id}","name":"Approval","version":"1","kind":"filter","permissions":["doc.read"]}}"#); + let bytes = wat::parse_str(vectorcraft_plugins::wat::returning(&manifest, r#"{"objects":[]}"#)).unwrap(); + let data = vectorcraft_format::base64_encode(&bytes); + let r = request(&mut app, &tx, "ui.menu.invoke", json!({"command": "ui.installPlugin", "params": {"dataBase64": data}})); + assert_eq!(r["result"]["pending"], crate::dialogs::plugin::INSTALL_KIND, "{r}"); + let mut fonts = false; + let mut h: DialogHarness = 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) { + crate::theme::install_fonts(ui.ctx()); + return; + } + crate::dialogs::show(app, ui.ctx()); + }, + app, + ); + h.run_steps(4); + + for key in ["Enter", "Space"] { + assert!(!h.get_by_label("OK").accesskit_node().is_focused(), "no button has the focus yet"); + h.state_mut().ui.status.clear(); + let r = request(h.state_mut(), &tx, "ui.key", json!({"key": key})); + assert_eq!(r["ok"], true, "{r}"); + take_injected(&mut h); + assert!(still_waiting(&h, id), "{key}"); + assert_eq!(h.state().ui.status, "", "{key} presses nothing"); + } + + for key in ["Enter", "Space"] { + h.get_by_label("OK").focus(); + h.run_steps(2); + assert!(h.get_by_label("OK").accesskit_node().is_focused()); + h.state_mut().ui.status.clear(); + let r = request(h.state_mut(), &tx, "ui.key", json!({"key": key})); + assert_eq!(r["ok"], true, "{r}"); + take_injected(&mut h); + assert!(still_waiting(&h, id), "{key} on a focused OK"); + assert!(h.state().ui.status.contains(refusal), "{key} pressed OK and was refused: {:?}", h.state().ui.status); + } + + let at = h.get_by_label("OK").rect().center(); + h.state_mut().ui.status.clear(); + let r = request(h.state_mut(), &tx, "ui.click", json!({"x": at.x, "y": at.y})); + assert_eq!(r["ok"], true, "{r}"); + take_injected(&mut h); + assert!(still_waiting(&h, id), "ui.click"); + assert!(h.state().ui.status.contains(refusal), "ui.click pressed OK and was refused: {:?}", h.state().ui.status); + + let events = json!([{"kind": "down", "x": at.x, "y": at.y}, {"kind": "up", "x": at.x, "y": at.y}]); + let r = request(h.state_mut(), &tx, "ui.pointer", json!({"events": events})); + assert_eq!(r["ok"], true, "{r}"); + take_injected(&mut h); + assert!(still_waiting(&h, id), "ui.pointer"); + + h.get_by_label("OK").click(); + h.run_steps(2); + assert!(h.state().ui.dialog.is_none(), "{:?}", h.state().ui.status); + assert_eq!(vectorcraft_plugins::registry::granted(id).map(|g| g.hash), Some(vectorcraft_plugins::permissions::sha256_hex(&bytes))); + h.state_mut().run("plugin.remove", json!({"id": id})).unwrap(); +} + /// Edit Original and the Attributes panel's Browser button hand a file or URL to another app: a /// control request (or MCP, which comes through it) can't run them, roots or not. #[test] diff --git a/crates/ui-egui/src/tests/plugins.rs b/crates/ui-egui/src/tests/plugins.rs index d044deb17..22cd5caff 100644 --- a/crates/ui-egui/src/tests/plugins.rs +++ b/crates/ui-egui/src/tests/plugins.rs @@ -336,6 +336,63 @@ fn a_shortcut_runs_a_plugin_command() { app.run("plugin.remove", json!({"id": id})).unwrap(); } +/// The `grant` and `allowHeavy` that `ui.installPlugin` is given approve nothing: it asks all the +/// same, and what OK would approve comes from the manifest. A module with nothing to approve +/// installs without either. +#[test] +fn ui_install_plugin_takes_no_grant_or_heavy_budget_from_its_params() { + let mut app = app(); + let id = "test.ui.approval.params"; + let manifest = format!(r#"{{"id":"{id}","name":"Asks","kind":"filter","permissions":["doc.read"]}}"#); + let asks = wat::parse_str(tpl::returning(&manifest, r#"{"objects":[]}"#)).unwrap(); + let given = |wasm: &[u8]| json!({"dataBase64": vectorcraft_format::base64_encode(wasm), "grant": ["doc.read", "doc.write"], "allowHeavy": true}); + let r = app.run("ui.installPlugin", given(&asks)).unwrap(); + assert_eq!(r["pending"], dialogs::plugin::INSTALL_KIND); + assert!(vectorcraft_plugins::registry::get(id).is_none() && vectorcraft_plugins::registry::granted(id).is_none()); + let pending = app.pending_plugin_install.as_ref().unwrap(); + assert_eq!(pending.approval.permissions, ["doc.read"], "the permissions the manifest lists"); + assert_eq!(pending.params["grant"], json!(["doc.read"])); + assert!(!pending.approval.heavy && pending.params.get("allowHeavy").is_none(), "no heavy budget was asked about"); + dialogs::cancel(&mut app); + assert!(vectorcraft_plugins::registry::get(id).is_none()); + + let heavy_id = "test.ui.approval.params.heavy"; + let manifest = format!(r#"{{"id":"{heavy_id}","name":"Heavy","kind":"filter","budget":"heavy"}}"#); + let heavy = wat::parse_str(tpl::spinning(&manifest, 1, r#"{"objects":[]}"#)).unwrap(); + let r = app.run("ui.installPlugin", given(&heavy)).unwrap(); + assert_eq!(r["pending"], dialogs::plugin::INSTALL_KIND, "allowHeavy skips no question"); + assert!(vectorcraft_plugins::registry::get(heavy_id).is_none()); + dialogs::cancel(&mut app); + + let plain_id = "test.ui.approval.params.plain"; + let manifest = format!(r#"{{"id":"{plain_id}","name":"Plain","kind":"filter"}}"#); + let plain = wat::parse_str(tpl::returning(&manifest, r#"{"objects":[]}"#)).unwrap(); + let r = app.run("ui.installPlugin", given(&plain)).unwrap(); + assert!(r.get("pending").is_none() && app.ui.dialog.is_none(), "{r}"); + assert!(vectorcraft_plugins::registry::get(plain_id).is_some()); + assert!(vectorcraft_plugins::registry::granted(plain_id).is_none(), "the grant it was given isn't recorded"); + app.run("plugin.remove", json!({"id": plain_id})).unwrap(); +} + +/// An action's step can't grant what only a person approves: a played `plugin.install` with `grant` +/// is refused at Accelerated and at Step by Step, and nothing is installed. +#[test] +fn an_action_step_cannot_grant_a_plugin_its_permissions() { + use crate::panels::actions::{Action, ActionSet, PlaybackSpeed, play}; + let mut app = app(); + let id = "test.ui.approval.action"; + let manifest = format!(r#"{{"id":"{id}","name":"Asks","kind":"filter","permissions":["doc.read"]}}"#); + let wasm = wat::parse_str(tpl::returning(&manifest, r#"{"objects":[]}"#)).unwrap(); + let step = json!({"dataBase64": vectorcraft_format::base64_encode(&wasm), "grant": ["doc.read"]}); + app.ui.action_sets = vec![ActionSet::new("Test", vec![Action::new("Install", vec![("plugin.install".into(), step)])])]; + for speed in [PlaybackSpeed::Accelerated, PlaybackSpeed::Step] { + app.ui.action_playback.speed = speed; + let e = play(&mut app, 0, 0).unwrap_err(); + assert!(e.contains("take effect only in a session started with --allow-plugin-grants"), "{e}"); + assert!(vectorcraft_plugins::registry::get(id).is_none() && vectorcraft_plugins::registry::granted(id).is_none()); + } +} + /// A plug-in of API level 2 that lists `progress` names it in plain words when it asks. #[test] fn installing_a_plugin_that_reports_progress_says_so() { diff --git a/docs/plugins.md b/docs/plugins.md index 8a94546de..8df6ae39d 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -164,7 +164,8 @@ capability model proposed for PhotoCraft plug-ins SHA-256 shown; Cancel installs nothing. The 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, and Enter does not confirm it. `ui.dialog.set` can't change what it installs. + 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. - `plugin.install` refuses the module, naming the permissions it needs, unless a person already granted this module (`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` From fe7b71b857f2e7b220428dd5e3243c94bea66120 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 19:26:55 -0800 Subject: [PATCH 05/10] Plug-ins: the clear-cache test installs with request grants allowed plugin.install refuses grant from a request unless the session was started to allow it, so the test from main that installs with grant turns that on. The install dialog's comment names OK's Sense::click. --- crates/engine/tests/plugin_clear_cache.rs | 1 + crates/ui-egui/src/dialogs/plugin.rs | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/engine/tests/plugin_clear_cache.rs b/crates/engine/tests/plugin_clear_cache.rs index 74ba69d64..7a323390c 100644 --- a/crates/engine/tests/plugin_clear_cache.rs +++ b/crates/engine/tests/plugin_clear_cache.rs @@ -40,6 +40,7 @@ fn clearing_every_cache_empties_each_plugins_cache_and_keeps_its_settings() { std::fs::create_dir_all(&folder).unwrap(); let ids = ["test.clearcache.a", "test.clearcache.b"]; let mut s = Session::new(); + s.allow_plugin_grants_from_requests(); s.execute("file.new", &json!({"width": 400, "height": 200})).unwrap(); s.attach_plugin_store(folder.clone()); for id in ids { diff --git a/crates/ui-egui/src/dialogs/plugin.rs b/crates/ui-egui/src/dialogs/plugin.rs index 44ad09dc6..76f71f3b4 100644 --- a/crates/ui-egui/src/dialogs/plugin.rs +++ b/crates/ui-egui/src/dialogs/plugin.rs @@ -331,8 +331,8 @@ pub(crate) struct PendingInstall { pub approval: InstallApproval, } -/// No OK in the shared footer: the OK in its body is a button, which Enter clicks only when it has -/// the keyboard focus. +/// No OK in the shared footer: the OK in its body is a `Sense::click` button, which Enter clicks +/// only when it has the keyboard focus. pub(super) const INSTALL_SPEC: DialogSpec = DialogSpec { heading: |_| tl!("Install Plug-in…").trim_end_matches('…').to_string(), body_with_actions: Some(install_body), From 73e5715f6b0157574ff68493b21a99356304a22c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 20:23:54 -0800 Subject: [PATCH 06/10] Plug-ins: the input kinds fuzz test installs with request grants allowed The fuzz test from #6223 passes grant to plugin.install, which a plain session now refuses, so its cases stopped installing the module. The docs line for a filter that lists inputs now says installing it needs a person's approval. --- crates/engine/tests/plugin_fuzz.rs | 1 + docs/mcp.md | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/engine/tests/plugin_fuzz.rs b/crates/engine/tests/plugin_fuzz.rs index f25ae8dfc..0bc8615d6 100644 --- a/crates/engine/tests/plugin_fuzz.rs +++ b/crates/engine/tests/plugin_fuzz.rs @@ -863,6 +863,7 @@ proptest! { prop_assert!(kinds.is_empty() || (man.asks_permissions() && reads), "{}", text); } let mut s = rich_session(); + s.allow_plugin_grants_from_requests(); let module = wat::parse_str(tpl::module(&text, tpl::ECHO, "", 1)).unwrap(); let data = vectorcraft_format::base64_encode(&module); let grant = json!(["doc.read", "doc.write", "state", "effect.render", "export.write"]); diff --git a/docs/mcp.md b/docs/mcp.md index 6b0f08133..52c8ebd95 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6644,7 +6644,7 @@ them again at startup (`plugin.remove` deletes them there): schema, defaults). An object filter runs on the selected paths and compound paths with `plugin.run {id, params}` (one undo step; it may change, delete or add objects, and the result is selected). A filter whose manifest lists `inputs` (`type`, `image`, `symbolInstance`; `inputs` in its `plugin.list` entry) also lists `doc.read` in its `permissions`, so -`plugin.install` needs `grant`. It is also given the selected objects of those kinds to read, with at most 16 MiB of +installing it needs a person's approval. It is also given the selected objects of those kinds to read, with at most 16 MiB of pixels and 16 MiB of type runs over one run (later ones come with `pixelsOmitted` or `truncated`). Every object counts toward the input's half of the plug-in's memory cap, ids, bounds, transforms and names included: pixels and runs that don't fit come the same way, and an object whose other fields don't fit makes `plugin.run` return a memory-budget error From 9ae57b03c0590cc264b62e0730d9f44be4ff8e73 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 21:10:26 -0800 Subject: [PATCH 07/10] Plug-ins: the texts say a grant accepted under the flag stays recorded A grant accepted under --allow-plugin-grants is written to grants.json like a person's approval and counts as approved in later sessions and in the desktop app. The CLI usage text, the plugin.install params text and docs/plugins.md and docs/mcp.md said only a person's approval in the desktop app records one. --- apps/vectorcraft-cli/src/main.rs | 6 ++++-- crates/engine/src/cmd/plugin.rs | 6 +++--- docs/mcp.md | 5 +++-- docs/plugins.md | 8 +++++--- 4 files changed, 15 insertions(+), 10 deletions(-) diff --git a/apps/vectorcraft-cli/src/main.rs b/apps/vectorcraft-cli/src/main.rs index 7e66b0500..3c53c9d31 100644 --- a/apps/vectorcraft-cli/src/main.rs +++ b/apps/vectorcraft-cli/src/main.rs @@ -88,8 +88,10 @@ USAGE: enforces its own (vectorcraft --control PORT --automation-read-root DIR …). --allow-plugin-grants lets plugin.install's grant and allowHeavy take effect in this session, so whoever sends the commands can give a plug-in the permissions it lists, and a heavy budget, - with nobody approving them. Without it they are refused, and a plug-in installs only when a - person already approved its hash in the desktop app. It implies --headless. + with nobody approving them. Without it they are refused, and a plug-in that lists permissions + installs only when its hash is already recorded in grants.json, by a person's approval in the + desktop app or by a grant accepted under this flag. A grant accepted under this flag stays + recorded and counts as approved in later sessions. It implies --headless. vectorcraft-cli run [--in FILE] [--cmd ID [--params JSON] | --commands FILE|-]... [--export FILE]... [--scale N] [--ppi N] [--allow-plugin-grants] Headless batch: open FILE (any readable format) or start a new document, run commands in diff --git a/crates/engine/src/cmd/plugin.rs b/crates/engine/src/cmd/plugin.rs index 44375d685..1692e3bef 100644 --- a/crates/engine/src/cmd/plugin.rs +++ b/crates/engine/src/cmd/plugin.rs @@ -665,8 +665,8 @@ fn install(s: &mut Session, p: &Value) -> Result { ), )); } - // A plug-in that lists permissions installs once a person grants them, unless they already - // granted this very module all of them. + // A plug-in that lists permissions installs once they are granted, unless a grant recorded for + // this very module already covers all of them. let m = plugin.manifest(); let wanted = m.permissions(); if !s.plugin_approved(&plugin) && !grant.as_ref().is_some_and(|g| wanted.iter().all(|w| g.contains(w))) { @@ -976,7 +976,7 @@ pub fn specs() -> Vec { "Install Plug-in", [], None, - "{path: a .wasm file (desktop) | dataBase64: the module (+ name?: shown as its source), replace?: bool (true once the user agreed: a module with other bytes than the plug-in installed with its id, or with none installed, one whose id has settings or a cache in the plug-in store, installs only with it, takes that plug-in's place and gets its settings and cache; false: one whose id is installed is refused; left out, the same module installs again), name?: file name (with dataBase64), allowHeavy?: bool (a filter whose manifest asks for a heavy budget, budget \"heavy\" in plugin.inspect, installs only with it), grant?: [permission] (a plug-in whose manifest lists permissions, needsApproval in plugin.inspect, installs only when grant holds every one of them; the grant is recorded with the module's hash and covers only that module, so a module with other bytes needs a new grant even when it lists the same permissions; with a plug-in store attached, only the grants kept there count; a request's allowHeavy and grant are refused unless the session was started with vectorcraft-cli --allow-plugin-grants: in the desktop app a person approves the install, see ui.installPlugin, and a module whose hash a person already approved needs no grant), hash?: string (the module's SHA-256 as plugin.inspect gave it: a module with other bytes is refused)} validate and install a WebAssembly plug-in for this session → its plugin.list entry", + "{path: a .wasm file (desktop) | dataBase64: the module (+ name?: shown as its source), replace?: bool (true once the user agreed: a module with other bytes than the plug-in installed with its id, or with none installed, one whose id has settings or a cache in the plug-in store, installs only with it, takes that plug-in's place and gets its settings and cache; false: one whose id is installed is refused; left out, the same module installs again), name?: file name (with dataBase64), allowHeavy?: bool (a filter whose manifest asks for a heavy budget, budget \"heavy\" in plugin.inspect, installs only with it), grant?: [permission] (a plug-in whose manifest lists permissions, needsApproval in plugin.inspect, installs only when grant holds every one of them; the grant is recorded with the module's hash and covers only that module, so a module with other bytes needs a new grant even when it lists the same permissions; with a plug-in store attached, only the grants kept there count; a request's allowHeavy and grant are refused unless the session was started with vectorcraft-cli --allow-plugin-grants: in the desktop app a person approves the install, see ui.installPlugin, and a module whose hash is already recorded, by a person's approval or by a grant accepted under that flag, needs no grant), hash?: string (the module's SHA-256 as plugin.inspect gave it: a module with other bytes is refused)} validate and install a WebAssembly plug-in for this session → its plugin.list entry", always, install ), diff --git a/docs/mcp.md b/docs/mcp.md index fa154e43a..4f3dc0c77 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6709,8 +6709,9 @@ needs a person's approval too, and `plugin.install` refuses `grant: [permission] 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` accepts `grant` and `allowHeavy` from any request, in that session only (headless; it can't be combined with -`--connect`). Without `--allow-plugin-grants` and without a desktop approval, `plugin.install` returns -an error naming the permissions needed. The approval is kept with the module's hash and covers only that module: the +`--connect`). Without `--allow-plugin-grants` and without a recorded grant for the module, `plugin.install` returns +an error naming the permissions needed. The approval, or a `grant` accepted under the flag, is kept with the module's +hash, counts as approved in later sessions and in the desktop app, and covers only that module: the same bytes install again without one, also from a headless session that reads the same `grants.json`, and a module with other bytes has `needsApproval: true` and needs a new one even when it lists the same permissions. `plugin.remove` forgets the approval. A diff --git a/docs/plugins.md b/docs/plugins.md index a0332ae38..dd0cbcb16 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -30,7 +30,7 @@ This page is the contract for plug-in authors and the reference for the host (`c | `plugin.list` | `{}` | `{"plugins": [{id, name, version, kind, author, description, params, paramsSchema, effect?, format?, extensions?, budget?, commands?, size, source, api, paintOutput?, permissions, hash, cacheBytes}], "folder": report}` (`api` is the manifest's [plug-in API level](#permissions); `budget: "heavy"` for a [heavy filter](#heavy-filters); `commands` lists its [commands](#commands); `paintOutput: true` marks a [painted live effect](#painted-live-effects); `permissions` lists what it [may do](#permissions), `hash` is the module's SHA-256; `cacheBytes` is the size of its [cache](#settings-and-cache)) | | `plugin.info` | `{"id"}` | One plug-in: the `plugin.list` entry plus `manifest`, `defaults` and `lastError` (a live effect's last failure). | | `plugin.run` | `{"id", "params"?: {…}, "ids"?: [...]}` (plug-in params may also be top-level keys) | Runs an object filter on the selection (or `ids`; an `ids` that is not a list of object ids is an error). One undo step; the result is selected, except the objects it locked or made guides. | -| `plugin.install` | `{"path": "/x/y.wasm"}` (desktop) or `{"dataBase64": "…", "name"?}`, `"replace"?: bool`, `"allowHeavy": bool = false`, `"grant"?: [permission]`, `"hash"?: "sha256 hex"` | Validates and installs a module; returns its `plugin.list` entry. The desktop app and `vectorcraft-cli` keep it in the `Plug-ins` folder next to the preferences, so it is installed again after a restart (#2541). A module with other bytes than the plug-in installed with its id, or, with none installed, one whose id has [settings and a cache](#settings-and-cache) in the `Plug-ins` folder, installs only with `replace: true`, once the user agreed; it takes that plug-in's place and gets its settings and cache. The same module installs again without `replace`, and `replace: false` refuses a module whose id is installed. A [heavy filter](#heavy-filters) installs only with `allowHeavy: true`. A plug-in whose manifest lists [permissions](#permissions) installs only when `grant` lists every one of them, unless a person already granted a module with the same SHA-256. A request's `grant` and `allowHeavy` are refused except in a session started with `vectorcraft-cli --allow-plugin-grants`. With `hash`, a module whose SHA-256 differs is refused. | +| `plugin.install` | `{"path": "/x/y.wasm"}` (desktop) or `{"dataBase64": "…", "name"?}`, `"replace"?: bool`, `"allowHeavy": bool = false`, `"grant"?: [permission]`, `"hash"?: "sha256 hex"` | Validates and installs a module; returns its `plugin.list` entry. The desktop app and `vectorcraft-cli` keep it in the `Plug-ins` folder next to the preferences, so it is installed again after a restart (#2541). A module with other bytes than the plug-in installed with its id, or, with none installed, one whose id has [settings and a cache](#settings-and-cache) in the `Plug-ins` folder, installs only with `replace: true`, once the user agreed; it takes that plug-in's place and gets its settings and cache. The same module installs again without `replace`, and `replace: false` refuses a module whose id is installed. A [heavy filter](#heavy-filters) installs only with `allowHeavy: true`. A plug-in whose manifest lists [permissions](#permissions) installs only when `grant` lists every one of them, unless a grant is already recorded for a module with the same SHA-256. A request's `grant` and `allowHeavy` are refused except in a session started with `vectorcraft-cli --allow-plugin-grants`. With `hash`, a module whose SHA-256 differs is refused. | | `plugin.inspect` | as `plugin.install`: `{"path"}` or `{"dataBase64", "name"?}` | Validates a module without installing it; returns the `plugin.list` entry it would have, plus `needsApproval` (true when installing it needs a person's approval) and `needsReplace` (true when `plugin.install` needs `replace: true`). | | `plugin.remove` | `{"id"}` | Uninstalls, and deletes it from the `Plug-ins` folder with its [settings and cache](#settings-and-cache). | | `plugin.clearCache` | `{"id"?}` | Deletes every entry in the [cache](#settings-and-cache) of plug-in `id`, or of every installed plug-in; settings stay. Returns `{"cleared": [id], "bytesFreed"}`. | @@ -198,12 +198,14 @@ capability model proposed for PhotoCraft plug-ins 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. -- `plugin.install` refuses the module, naming the permissions it needs, unless a person already granted this module +- `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` takes `grant` and `allowHeavy` from any request; only the person starting the process can set that flag, and it doesn't apply to a running app. Without it, MCP in headless mode and the CLI install a module that lists - permissions only when a person approved its hash in the desktop app, which they read from the shared `grants.json`. + permissions only when its hash is recorded in the shared `grants.json`. A person's approval in the desktop app + records it, and so does a `grant` accepted under `--allow-plugin-grants`. A recorded grant counts as approved in + later sessions without the flag and in the desktop app. With `--allow-plugin-grants`, pass `hash` set to the `plugin.inspect` entry's `hash` so that the module installed is the one the user saw. - **Replacing asks first.** When the file is another module than the plug-in installed with its id, or, with none From ae52c802e417a149304a3612711f33d4c52990c7 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 21:47:33 -0800 Subject: [PATCH 08/10] Plug-ins: test that mcp --headless needs the start-up flag for grants plugin_grants_need_the_start_up_flag now drives `vectorcraft-cli mcp --headless` over stdio. plugin.install with grant is refused without --allow-plugin-grants and accepted with it. --- apps/vectorcraft-cli/tests/it/cli.rs | 35 +++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/apps/vectorcraft-cli/tests/it/cli.rs b/apps/vectorcraft-cli/tests/it/cli.rs index e7853700f..d6295e2ba 100644 --- a/apps/vectorcraft-cli/tests/it/cli.rs +++ b/apps/vectorcraft-cli/tests/it/cli.rs @@ -19,7 +19,7 @@ fn empty_dir(dir: std::path::PathBuf) -> std::path::PathBuf { dir } -/// `run` refuses `plugin.install`'s `grant` unless the process was started with +/// `run` and `mcp --headless` refuse `plugin.install`'s `grant` unless the process was started with /// `--allow-plugin-grants`, which `mcp` won't take with `--connect`. #[test] fn plugin_grants_need_the_start_up_flag() { @@ -37,6 +37,39 @@ fn plugin_grants_need_the_start_up_flag() { let out = run(&["--allow-plugin-grants"]); assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + let mcp_config = empty_dir(std::env::temp_dir().join(format!("vectorcraft-cli-test-mcp-grants-{}", std::process::id()))); + let mcp_install = |extra: &[&str]| -> Value { + let mut child = Command::new(BIN) + .env("VECTORCRAFT_CONFIG_DIR", &mcp_config) + .args(["mcp", "--headless"]) + .args(extra) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + { + let mut stdin = child.stdin.take().unwrap(); + let params = json!({"dataBase64": module, "grant": ["doc.read"]}); + for m in [ + json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"0"}}}), + json!({"jsonrpc":"2.0","method":"notifications/initialized"}), + json!({"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"run_command","arguments":{"command":"plugin.install","params":params}}}), + ] { + writeln!(stdin, "{m}").unwrap(); + } + } + let out = child.wait_with_output().unwrap(); + assert!(out.status.success()); + let replies: Vec = String::from_utf8(out.stdout).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect(); + replies.into_iter().find(|r| r["id"] == 2).unwrap()["result"].take() + }; + let refused = mcp_install(&[]); + assert_eq!(refused["isError"], true, "without the flag: {refused}"); + assert!(refused["content"][0]["text"].as_str().unwrap().contains("--allow-plugin-grants"), "{refused}"); + let accepted = mcp_install(&["--allow-plugin-grants"]); + assert_eq!(accepted["isError"], false, "with the flag: {accepted}"); + let out = Command::new(BIN).args(["mcp", "--allow-plugin-grants", "--connect", "127.0.0.1:1"]).output().unwrap(); assert!(!out.status.success()); assert!(String::from_utf8_lossy(&out.stderr).contains("--allow-plugin-grants applies to the headless server")); From 7431486afaeedaf305495521185cf78876d3e57a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 21:54:46 -0800 Subject: [PATCH 09/10] Plug-ins: mcp refuses --allow-plugin-grants with automation roots A server confined with --automation-read-root or --automation-write-root has no plug-in store, so a grant accepted under the flag could not be written to grants.json and recorded grants were not read. mcp now refuses the combination at start-up. The help text, docs/plugins.md and docs/mcp.md state that a confined server does not install a module that lists permissions or asks for a heavy budget, and that with VECTORCRAFT_NO_PREFS set a grant accepted under the flag lasts for that session only. --- apps/vectorcraft-cli/src/main.rs | 9 ++++++++- apps/vectorcraft-cli/tests/it/cli.rs | 8 +++++++- docs/mcp.md | 7 +++++-- docs/plugins.md | 11 +++++++---- 4 files changed, 27 insertions(+), 8 deletions(-) diff --git a/apps/vectorcraft-cli/src/main.rs b/apps/vectorcraft-cli/src/main.rs index 3c53c9d31..126d361c5 100644 --- a/apps/vectorcraft-cli/src/main.rs +++ b/apps/vectorcraft-cli/src/main.rs @@ -91,7 +91,11 @@ USAGE: with nobody approving them. Without it they are refused, and a plug-in that lists permissions installs only when its hash is already recorded in grants.json, by a person's approval in the desktop app or by a grant accepted under this flag. A grant accepted under this flag stays - recorded and counts as approved in later sessions. It implies --headless. + recorded and counts as approved in later sessions. It implies --headless. It can't be used + with --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 plug-in that lists permissions or asks for a + heavy budget does not install there. With VECTORCRAFT_NO_PREFS set there is no store either, + and a grant accepted under this flag lasts for that session only. vectorcraft-cli run [--in FILE] [--cmd ID [--params JSON] | --commands FILE|-]... [--export FILE]... [--scale N] [--ppi N] [--allow-plugin-grants] Headless batch: open FILE (any readable format) or start a new document, run commands in @@ -283,6 +287,9 @@ fn mcp(args: &[String], safe_mode: bool) -> Result<(), String> { if allow_grants && connect.is_some() { return Err("--allow-plugin-grants applies to the headless server; in a running app a person approves each plug-in".into()); } + if allow_grants && !(read_roots.is_empty() && write_roots.is_empty()) { + return Err("--allow-plugin-grants can't be used with --automation-read-root or --automation-write-root: a server confined to folders has no plug-in store to record a grant in".into()); + } if safe_mode && connect.is_some() { return Err("--safe-mode runs a headless session without plug-ins; a running app loads its own: start it with `vectorcraft --safe-mode --control `".into()); } diff --git a/apps/vectorcraft-cli/tests/it/cli.rs b/apps/vectorcraft-cli/tests/it/cli.rs index d6295e2ba..4b09e14ca 100644 --- a/apps/vectorcraft-cli/tests/it/cli.rs +++ b/apps/vectorcraft-cli/tests/it/cli.rs @@ -20,7 +20,7 @@ fn empty_dir(dir: std::path::PathBuf) -> std::path::PathBuf { } /// `run` and `mcp --headless` refuse `plugin.install`'s `grant` unless the process was started with -/// `--allow-plugin-grants`, which `mcp` won't take with `--connect`. +/// `--allow-plugin-grants`, which `mcp` won't take with `--connect` or the automation roots. #[test] fn plugin_grants_need_the_start_up_flag() { let module = vectorcraft_format::base64_encode(include_bytes!("../../../../crates/plugins/tests/fixtures/desaturate.wasm")); @@ -73,6 +73,12 @@ fn plugin_grants_need_the_start_up_flag() { let out = Command::new(BIN).args(["mcp", "--allow-plugin-grants", "--connect", "127.0.0.1:1"]).output().unwrap(); assert!(!out.status.success()); assert!(String::from_utf8_lossy(&out.stderr).contains("--allow-plugin-grants applies to the headless server")); + let root = std::env::temp_dir(); + for flag in ["--automation-read-root", "--automation-write-root"] { + let out = Command::new(BIN).args(["mcp", "--allow-plugin-grants", flag, root.to_str().unwrap()]).stdin(Stdio::null()).output().unwrap(); + assert!(!out.status.success(), "{flag}"); + assert!(String::from_utf8_lossy(&out.stderr).contains("--allow-plugin-grants can't be used with"), "{flag}"); + } let usage = Command::new(BIN).arg("help").output().unwrap(); assert!(String::from_utf8_lossy(&usage.stdout).contains("--allow-plugin-grants lets plugin.install's grant and allowHeavy take effect")); } diff --git a/docs/mcp.md b/docs/mcp.md index 4f3dc0c77..308b05afd 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6709,8 +6709,11 @@ needs a person's approval too, and `plugin.install` refuses `grant: [permission] 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` accepts `grant` and `allowHeavy` from any request, in that session only (headless; it can't be combined with -`--connect`). Without `--allow-plugin-grants` and without a recorded grant for the module, `plugin.install` returns -an error naming the permissions needed. The approval, or a `grant` accepted under the flag, is kept with the module's +`--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 +there. Without `--allow-plugin-grants` and without a recorded grant for the module, `plugin.install` returns an error +naming the permissions needed. With `VECTORCRAFT_NO_PREFS` set there is no store, and a `grant` accepted under the +flag lasts for that session only. In other sessions the approval, or a `grant` accepted under the flag, is kept with the module's hash, counts as approved in later sessions and in the desktop app, and covers only that module: the same bytes install again without one, also from a headless session that reads the same `grants.json`, and a module with other bytes has `needsApproval: true` and needs a new one even when it lists the same permissions. `plugin.remove` diff --git a/docs/plugins.md b/docs/plugins.md index dd0cbcb16..0783f9013 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -202,10 +202,13 @@ capability model proposed for PhotoCraft plug-ins (`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` takes `grant` and `allowHeavy` from any request; only the person starting the process can set that flag, and it - doesn't apply to a running app. Without it, MCP in headless mode and the CLI install a module that lists - permissions only when its hash is recorded in the shared `grants.json`. A person's approval in the desktop app - records it, and so does a `grant` accepted under `--allow-plugin-grants`. A recorded grant counts as approved in - later sessions without the flag and in the desktop app. + doesn't apply to a running app. `mcp` refuses the flag together with `--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 there. Without the flag, MCP in headless + mode and the CLI install a module that lists permissions only when its hash is recorded in the shared `grants.json`. + A person's approval in the desktop app records it, and so does a `grant` accepted under `--allow-plugin-grants`. A + recorded grant counts as approved in later sessions without the flag and in the desktop app. With + `VECTORCRAFT_NO_PREFS` set there is no store, and a `grant` accepted under the flag lasts for that session only. With `--allow-plugin-grants`, pass `hash` set to the `plugin.inspect` entry's `hash` so that the module installed is the one the user saw. - **Replacing asks first.** When the file is another module than the plug-in installed with its id, or, with none From 950d149e452276f6fa3ee936a9b1e545816873dd Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 22:16:35 -0800 Subject: [PATCH 10/10] Plug-ins: test that run and mcp need the start-up flag for allowHeavy --- apps/vectorcraft-cli/tests/it/cli.rs | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/apps/vectorcraft-cli/tests/it/cli.rs b/apps/vectorcraft-cli/tests/it/cli.rs index 4b09e14ca..f2600d9b4 100644 --- a/apps/vectorcraft-cli/tests/it/cli.rs +++ b/apps/vectorcraft-cli/tests/it/cli.rs @@ -19,14 +19,13 @@ fn empty_dir(dir: std::path::PathBuf) -> std::path::PathBuf { dir } -/// `run` and `mcp --headless` refuse `plugin.install`'s `grant` unless the process was started with -/// `--allow-plugin-grants`, which `mcp` won't take with `--connect` or the automation roots. -#[test] -fn plugin_grants_need_the_start_up_flag() { +/// `run` and `mcp --headless` refuse `plugin.install` with this param unless the process was started +/// with `--allow-plugin-grants`. +fn approval_param_needs_the_start_up_flag(key: &str, value: Value) { let module = vectorcraft_format::base64_encode(include_bytes!("../../../../crates/plugins/tests/fixtures/desaturate.wasm")); - let steps = tmp("grants.json"); - std::fs::write(&steps, json!([{"command": "plugin.install", "params": {"dataBase64": module, "grant": ["doc.read"]}}]).to_string()).unwrap(); - let config = empty_dir(std::env::temp_dir().join(format!("vectorcraft-cli-test-grants-{}", std::process::id()))); + let steps = tmp(&format!("{key}.json")); + std::fs::write(&steps, json!([{"command": "plugin.install", "params": {"dataBase64": module, key: value}}]).to_string()).unwrap(); + let config = empty_dir(std::env::temp_dir().join(format!("vectorcraft-cli-test-{key}-{}", std::process::id()))); let run = |extra: &[&str]| { Command::new(BIN).env("VECTORCRAFT_CONFIG_DIR", &config).args(["run", "--commands", steps.to_str().unwrap()]).args(extra).output().unwrap() }; @@ -37,7 +36,7 @@ fn plugin_grants_need_the_start_up_flag() { let out = run(&["--allow-plugin-grants"]); assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); - let mcp_config = empty_dir(std::env::temp_dir().join(format!("vectorcraft-cli-test-mcp-grants-{}", std::process::id()))); + let mcp_config = empty_dir(std::env::temp_dir().join(format!("vectorcraft-cli-test-mcp-{key}-{}", std::process::id()))); let mcp_install = |extra: &[&str]| -> Value { let mut child = Command::new(BIN) .env("VECTORCRAFT_CONFIG_DIR", &mcp_config) @@ -50,7 +49,7 @@ fn plugin_grants_need_the_start_up_flag() { .unwrap(); { let mut stdin = child.stdin.take().unwrap(); - let params = json!({"dataBase64": module, "grant": ["doc.read"]}); + let params = json!({"dataBase64": module, key: value}); for m in [ json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"0"}}}), json!({"jsonrpc":"2.0","method":"notifications/initialized"}), @@ -69,6 +68,14 @@ fn plugin_grants_need_the_start_up_flag() { assert!(refused["content"][0]["text"].as_str().unwrap().contains("--allow-plugin-grants"), "{refused}"); let accepted = mcp_install(&["--allow-plugin-grants"]); assert_eq!(accepted["isError"], false, "with the flag: {accepted}"); +} + +/// `run` and `mcp --headless` refuse `plugin.install`'s `grant` and `allowHeavy` unless the process was +/// started with `--allow-plugin-grants`, which `mcp` won't take with `--connect` or the automation roots. +#[test] +fn plugin_grants_need_the_start_up_flag() { + approval_param_needs_the_start_up_flag("grant", json!(["doc.read"])); + approval_param_needs_the_start_up_flag("allowHeavy", json!(true)); let out = Command::new(BIN).args(["mcp", "--allow-plugin-grants", "--connect", "127.0.0.1:1"]).output().unwrap(); assert!(!out.status.success());