mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-09 04:54:45 +00:00
Plug-ins: a malformed ids is refused, and a command out of budget changes nothing
plugin.run and plug-in commands read `ids` strictly: a value that is not
a list, or an entry that is not a non-negative integer, is an error
naming `ids` ("ids must be an array of non-negative integer object
ids"). Left out or null, `ids` is still the selection.
A new test shows that a plug-in command that runs out of its
instruction budget leaves the document and the undo history unchanged,
and that the error names the plug-in and the command.
docs/plugins.md and docs/mcp.md name MCP's `command_list` and
`list_commands` as the listings that show plug-in commands, and the
params text, docs and progress line describe the `ids` rule.
This commit is contained in:
@@ -33,7 +33,7 @@ use serde_json::{Map, Value, json};
|
||||
use vectorcraft_doc::{Effect, Node};
|
||||
use vectorcraft_plugins::{Budget, Command, Kind, Plugin, effect, export, objects, registry};
|
||||
|
||||
use super::{CommandSpec, EngineError, NodeId, Result, Session, always, bad, cmd, has_doc, ids_param, str_param, switch_or};
|
||||
use super::{CommandSpec, EngineError, NodeId, Result, Session, always, bad, cmd, has_doc, str_param, switch_or};
|
||||
|
||||
use vectorcraft_plugins::permissions::{self, Grant, Permission};
|
||||
|
||||
@@ -94,6 +94,19 @@ fn plugin_params(p: &Value) -> Value {
|
||||
}
|
||||
}
|
||||
|
||||
/// The objects command `c`'s params `p` give as `ids`, or `None` when `p` leaves it out or gives
|
||||
/// null. A value that is not a list, or an entry that is not a non-negative integer, is an error
|
||||
/// naming `ids`: a malformed list is never read as the selection.
|
||||
fn given_ids(c: &str, p: &Value) -> Result<Option<Vec<NodeId>>> {
|
||||
let key = "ids";
|
||||
let malformed = || bad(c, format!("{key} must be an array of non-negative integer object ids"));
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(Value::Array(a)) => a.iter().map(|v| v.as_u64().map(NodeId).ok_or_else(malformed)).collect::<Result<Vec<_>>>().map(Some),
|
||||
Some(_) => Err(malformed()),
|
||||
}
|
||||
}
|
||||
|
||||
fn plugin_by_id(cmd: &str, p: &Value) -> Result<Arc<Plugin>> {
|
||||
let id = str_param(p, "id").ok_or_else(|| bad(cmd, "missing `id` (see plugin.list)"))?;
|
||||
registry::get(id).ok_or_else(|| err(vectorcraft_plugins::Error::NotFound(id.into())))
|
||||
@@ -164,7 +177,7 @@ impl FilterRun {
|
||||
let mut params = Value::Object(resolved.map_err(|e| bad(c, e.to_string()))?);
|
||||
let may = m.permissions();
|
||||
let st = s.doc()?;
|
||||
let selected = ids_param(p, "ids").unwrap_or_else(|| st.selection.objects.clone());
|
||||
let selected = given_ids(c, p)?.unwrap_or_else(|| st.selection.objects.clone());
|
||||
// Without `doc.read` a filter is given no objects: it can only add new ones.
|
||||
let inputs = if may.contains(&Permission::DocRead) { objects::filter_targets(&st.doc, &selected) } else { Vec::new() };
|
||||
let parent = st.insertion_parent();
|
||||
@@ -362,7 +375,8 @@ fn spec_of(p: &Plugin, c: &Command) -> &'static CommandSpec {
|
||||
let label = c.shown_label();
|
||||
let top = if m.kind == Kind::Effect { "Effect" } else { "Object" };
|
||||
let name = permissions::display_text(m.name.trim(), permissions::MAX_SHOWN_NAME);
|
||||
let mut fields = String::from("ids?: [..] (default: the selection), params?: {…} (its parameters; or give them as top-level keys)");
|
||||
let mut fields =
|
||||
String::from("ids?: [object id] (left out or null: the selection), params?: {…} (its parameters; or give them as top-level keys)");
|
||||
let doc = m.for_command(c).params_doc();
|
||||
if let Some(own) = doc.strip_prefix('{').and_then(|d| d.strip_suffix('}')).filter(|d| !d.is_empty()) {
|
||||
fields.push_str(", ");
|
||||
@@ -692,7 +706,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Run Plug-in",
|
||||
[],
|
||||
None,
|
||||
"{id, params?: {…} (the plug-in's parameters; or give them as top-level keys; missing ones take their defaults, see plugin.info), ids?: [..] (default: the selection), …the plug-in's parameters as top-level keys} run an object filter on the selected paths and compound paths (inside selected groups and layers too; hidden, locked, guide and clipping paths are left out): it may change, delete or add objects, as one undo step; the result is selected, except the objects it locked or made guides. With nothing selected it gets no objects (generators add theirs to the current layer) → {plugin, ids}",
|
||||
"{id, params?: {…} (the plug-in's parameters; or give them as top-level keys; missing ones take their defaults, see plugin.info), ids?: [object id] (left out or null: the selection), …the plug-in's parameters as top-level keys} run an object filter on the selected paths and compound paths (inside selected groups and layers too; hidden, locked, guide and clipping paths are left out): it may change, delete or add objects, as one undo step; the result is selected, except the objects it locked or made guides. With nothing selected it gets no objects (generators add theirs to the current layer) → {plugin, ids}",
|
||||
has_doc,
|
||||
run
|
||||
),
|
||||
|
||||
@@ -966,6 +966,66 @@ fn a_failing_plugin_command_changes_nothing_and_names_itself() {
|
||||
run(&mut s, "plugin.remove", json!({"id": id}));
|
||||
}
|
||||
|
||||
/// A command that runs out of its instruction budget leaves the document and its history as they
|
||||
/// were, and the error names the plug-in and the command.
|
||||
#[test]
|
||||
fn a_plugin_command_out_of_budget_changes_nothing_and_names_itself() {
|
||||
let mut s = session();
|
||||
rect(&mut s, 10.0, "#ff0000");
|
||||
run(&mut s, "select.all", json!({}));
|
||||
let id = "test.engine.cmds.spin";
|
||||
let triangle = r#"{"objects":[{"path":{"subpaths":[{"closed":true,"anchors":[{"p":[0,0]},{"p":[5,0]},{"p":[5,5]}]}]}}]}"#;
|
||||
// More steps than a light budget pays for (as the plug-in crate's budget test counts them).
|
||||
install_wat(&mut s, &tpl::spinning(&with_commands(id, "filter", r#"[{"id":"spin","label":"Spin"}]"#), 100_000_000, triangle));
|
||||
let (before, undo) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len());
|
||||
let e = s.execute(&crate::cmd::plugin::command_id(id, "spin"), &json!({})).unwrap_err().to_string();
|
||||
assert!(e.contains("“Cmd Test” › Spin: ") && e.contains("instruction budget"), "{e}");
|
||||
assert_eq!(s.doc().unwrap().doc, before);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
|
||||
run(&mut s, "plugin.remove", json!({"id": id}));
|
||||
}
|
||||
|
||||
/// An `ids` that is not a list, or that holds an entry that is not an object id, is an error naming
|
||||
/// `ids` for a plug-in command and for `plugin.run`, and is never read as the selection; left out
|
||||
/// or null, `ids` is the selection.
|
||||
#[test]
|
||||
fn a_malformed_ids_is_refused_not_read_as_the_selection() {
|
||||
let mut s = session();
|
||||
let id = "test.engine.cmds.ids";
|
||||
let grow = crate::cmd::plugin::command_id(id, "grow");
|
||||
install_wat(&mut s, &grower(id));
|
||||
rect(&mut s, 10.0, "#ff0000");
|
||||
run(&mut s, "select.all", json!({}));
|
||||
let (before, undo) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len());
|
||||
let cases = [
|
||||
(grow.as_str(), json!(5)),
|
||||
(grow.as_str(), json!([1, "x"])),
|
||||
(grow.as_str(), json!([-1])),
|
||||
("plugin.run", json!("all")),
|
||||
("plugin.run", json!([1.5])),
|
||||
];
|
||||
for (cmd, ids) in cases {
|
||||
let mut p = json!({"ids": ids});
|
||||
if cmd == "plugin.run" {
|
||||
p["id"] = json!(id);
|
||||
}
|
||||
let e = s.execute(cmd, &p).unwrap_err();
|
||||
assert!(matches!(&e, EngineError::BadParams { cmd: c, .. } if c == cmd), "{cmd} {ids}: {e}");
|
||||
assert!(e.to_string().contains("ids must be an array of non-negative integer object ids"), "{cmd} {ids}: {e}");
|
||||
assert_eq!(s.doc().unwrap().doc, before, "{cmd} {ids}");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "{cmd} {ids}");
|
||||
}
|
||||
// Left out or null, the command runs on the selection.
|
||||
for p in [json!({}), json!({"ids": null})] {
|
||||
let r = run(&mut s, &grow, p.clone());
|
||||
assert_eq!(r["command"], "grow", "{p}");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "{p}");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc().unwrap().doc, before, "{p}");
|
||||
}
|
||||
run(&mut s, "plugin.remove", json!({"id": id}));
|
||||
}
|
||||
|
||||
/// A live effect's commands are listed under Effect › Plug-ins, run under its permissions (it may
|
||||
/// not change the document), and labels show bidirectional characters escaped. A malformed
|
||||
/// `commands` entry is refused at install, and the error names it.
|
||||
|
||||
+4
-3
@@ -5926,11 +5926,12 @@ permission names, and a `hash` that is not the module's SHA-256. `prefs.set` ret
|
||||
changes nothing when a new `pluginsFolder` is missing, is not a folder or can't be listed. It does not check an empty
|
||||
value or the folder the preference already holds, and the web build does not check folders.
|
||||
A filter or live effect may declare commands of its own ([plugins.md](plugins.md#commands)): each is the command
|
||||
`plugin.<id>.<command>` while the plug-in is installed, listed by `command.list` and in its `plugin.list` entry
|
||||
`plugin.<id>.<command>` while the plug-in is installed, listed by `command_list` and in its `plugin.list` entry
|
||||
(`commands: [{id, command, label, params, paramsSchema}]`). Run it with `{ids?, params?}` (its parameters may also
|
||||
be top-level keys): the parameters are checked before the plug-in runs, a run is one undo step named after the
|
||||
command, and a failing run returns an error that starts with the plug-in's name and the command's label and leaves
|
||||
the document as it was.
|
||||
command, and a run that traps or runs out of its instruction budget returns an error that starts with the plug-in's
|
||||
name and the command's label and leaves the document and the undo history as they were. For `plugin.run` and plug-in
|
||||
commands, `ids` left out or null means the selection, and an `ids` that is not a list of object ids is an error.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"plugin.install","params":{"path":"/plugins/desaturate.wasm"}}}
|
||||
|
||||
+7
-5
@@ -25,7 +25,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, permissions, hash}], "folder": report}` (`budget: "heavy"` for a [heavy filter](#heavy-filters); `commands` lists its [commands](#commands); `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.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 = 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. 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.remove` | `{"id"}` | Uninstalls, and deletes it from the `Plug-ins` folder. |
|
||||
@@ -64,14 +64,16 @@ replaced by a module without it.
|
||||
shortcut. A manifest cannot give a command a shortcut.
|
||||
- **Actions and agents.** A command run is recorded in an action like any other command and plays back with its
|
||||
parameters. `run`, MCP and `command.batch` call it as `plugin.<id>.<command>` with `{"ids"?: [...], "params"?: {…}}`
|
||||
(its parameters may also be top-level keys). `command.list` lists it, and `plugin.list` lists a plug-in's commands
|
||||
as `commands: [{id, command, label, params, paramsSchema}]`.
|
||||
(its parameters may also be top-level keys). MCP's `command_list` and `list_commands` list it, and `plugin.list`
|
||||
lists a plug-in's commands as `commands: [{id, command, label, params, paramsSchema}]`.
|
||||
- **Runs.** The parameters are checked against the command's declarations before the plug-in runs. The plug-in runs
|
||||
through `vc_run` on the selection (or `ids`) as a filter run does, with `_context.mode` `"command"` and
|
||||
`_context.command` set to the command's id ([Parameters](#parameters)), under the plug-in's
|
||||
[permissions](#permissions): a live effect's command has no `doc.write`, so an output that changes the document is
|
||||
refused. One run is one undo step named after the command. A run that traps or fails leaves the document as it was,
|
||||
and the error starts with the plug-in's name and the command's label (`“Cmd Test” › Grow: …`).
|
||||
refused. `ids` left out or null means the selection; an `ids` that is not a list of object ids is an error. One run
|
||||
is one undo step named after the command. A run that traps, runs out of its instruction budget or fails leaves the
|
||||
document and the undo history as they were, and the error starts with the plug-in's name and the command's label
|
||||
(`“Cmd Test” › Grow: …`).
|
||||
|
||||
**Preference folder.** Edit › Preferences › Plug-ins & Scratch Disks › *Additional Plug-ins Folder*: when set, every
|
||||
`*.wasm` in that folder (not recursive) is installed at startup and whenever the preference changes (desktop builds;
|
||||
|
||||
@@ -1429,7 +1429,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- **Plug-in data kept with the document (G17):** a filter writes JSON of at most 1 MiB with `documentData` (one undo step with its objects; `null` removes it) and reads it back in `_context.documentData`, which no other filter is given. It is the document's foreign data (`unknown.pluginData`, by plug-in id), as the perspective grid is, so the native file keeps it with no section of its own; an exporter is given the whole document, so it can read every plug-in's. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_export.rs` (the exporter), `crates/doc/src/plugin_data.rs`, `crates/plugins/src/objects/tests.rs`. Scores and hours unchanged.
|
||||
- **Heavy filter plug-ins (G17):** a filter's manifest may ask for `"budget": "heavy"`: 1 G instructions (20 times a light run's) and 300 s a run, for routing, packing or simulation. Install Plug-in… and File › Open ask the user first, `plugin.install` needs `allowHeavy: true` (`plugin.inspect` tells beforehand), the Additional Plug-ins Folder skips such filters, and the plug-in store keeps the answer. Its dialog has no Preview; the app runs it on a thread of its own with a progress window (Cancel, Continue in Background) and applies the result as `plugin.run` with the same parameters, one undo step, unless the document or the plug-in changed meanwhile. Regressions: `crates/plugins/tests/abi.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/tests/plugin_runs.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 commands (G17):** a filter or live effect may declare up to 32 `commands` (`{id, label, params?}`); each is the command `plugin.<id>.<command>` while the plug-in is installed, in a submenu named after the plug-in under Object › Plug-ins (Effect › Plug-ins for a live effect) whatever menu the manifest names, with its label escaped. Commands are recorded in actions, listed in Keyboard Shortcuts for the user to give a shortcut, and run through `run` and MCP with their parameters checked first (a command with parameters opens a dialog from its menu item or shortcut). A run uses the plug-in's permissions, is one undo step named after the command, and on failure leaves the document as it was with an error naming the plug-in and the command. A malformed entry is refused by name when the module is installed. Regressions: `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 commands (G17):** a filter or live effect may declare up to 32 `commands` (`{id, label, params?}`); each is the command `plugin.<id>.<command>` while the plug-in is installed, in a submenu named after the plug-in under Object › Plug-ins (Effect › Plug-ins for a live effect) whatever menu the manifest names, with its label escaped. Commands are recorded in actions, listed in Keyboard Shortcuts for the user to give a shortcut, and run through `run` and MCP with their parameters checked first (a command with parameters opens a dialog from its menu item or shortcut). A run uses the plug-in's permissions, is one undo step named after the command, and on failure (a trap or an exhausted instruction budget) leaves the document and the undo history as they were with an error naming the plug-in and the command. For a command and for `plugin.run`, an `ids` that is not a list of object ids is an error rather than the selection. A malformed entry is refused by name when the module is installed. Regressions: `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugins.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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user