From 2212739c5425e12430ef5632c3e14559557f0730 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 00:46:02 -0800 Subject: [PATCH] MCP: plug-in, recovery and script report replies leave out paths outside the read roots A client confined by --automation-read-root saw paths outside its read roots that the person or an earlier request had put in the session: - plugin.list: the plug-in folder last loaded and its failed files; plugin.list and plugin.info: a plug-in's source file; plugin.list, plugin.safeMode and plugin.setEnabled: the file of a plug-in turned off for the next launch. - file.recovery.list and file.recovery.restore: a document's original file; file.recovery.list and file.recovery.save: the recovery folder. - ui.inspect's scriptReport: the files a run wrote and the included file a failed run stopped in. A folder outside the read roots is now null, and a file outside them is named by its file name. plugin.setEnabled {file} takes that name. The restored documents keep their files, and unconfined clients and the app's own UI see the same replies as before. --- crates/engine/src/cmd/plugin.rs | 94 ++++++++++++++++++++--- crates/engine/src/cmd/recovery.rs | 39 ++++++++-- crates/engine/src/cmd/scripts.rs | 44 +++++++++++ crates/engine/src/script/mod.rs | 30 ++++++-- crates/engine/src/tests/plugins.rs | 86 +++++++++++++++++++++ crates/engine/src/tests/recovery.rs | 65 ++++++++++++++++ crates/engine/src/tests/script_console.rs | 28 +++++++ crates/ui-egui/src/control.rs | 2 +- crates/ui-egui/src/tests/automation.rs | 44 +++++++++++ docs/mcp.md | 11 +++ docs/target-app-parity.md | 1 + 11 files changed, 418 insertions(+), 26 deletions(-) diff --git a/crates/engine/src/cmd/plugin.rs b/crates/engine/src/cmd/plugin.rs index 2d16bbc683..dfa44a1436 100644 --- a/crates/engine/src/cmd/plugin.rs +++ b/crates/engine/src/cmd/plugin.rs @@ -62,13 +62,28 @@ fn err(e: vectorcraft_plugins::Error) -> EngineError { } } +/// Under automation read roots, a reply names a file or folder outside them by its name alone. +fn shown_file(path: &str) -> String { + super::fileio::shown_path(path).into_owned() +} + +/// A plug-in turned off for the next launch, or the one a launch stopped at, as a reply shows it: a +/// file outside the automation read roots is named by its file name. +fn shown_item(item: &safe_mode::Item) -> Value { + let mut v = item.to_json(); + if let Some(file) = item.file.as_deref() { + v["file"] = json!(shown_file(file)); + } + v +} + /// JSON description of an installed plug-in. pub fn describe(p: &Plugin) -> Value { let m = p.manifest(); let mut v = json!({ "id": m.id, "name": m.name, "version": m.version, "kind": m.kind.name(), "author": m.author, "description": m.description, "params": m.params_doc(), "paramsSchema": serde_json::to_value(m).ok().and_then(|v| v.get("params").cloned()), - "size": p.size(), "source": p.source(), + "size": p.size(), "source": p.source().map(shown_file), "permissions": permissions::names(&m.permissions()), "hash": p.hash(), "api": m.api, }); match m.kind { @@ -932,9 +947,46 @@ fn describe_installed(p: &Plugin) -> Value { v } +/// The folder load `report` as a reply shows it: under automation read roots, a folder outside them +/// is `null` and a failed file outside them is named by its file name, in the errors too. +fn shown_report(mut report: Value) -> Value { + let mut hidden: Vec<(String, String)> = vec![]; + if let Some(failed) = report.get_mut("failed").and_then(Value::as_array_mut) { + for row in failed { + let Some(file) = row.get("file").and_then(Value::as_str).map(str::to_string) else { continue }; + let shown = shown_file(&file); + if shown != file { + row["file"] = json!(shown); + hidden.push((file, shown)); + } + } + } + if let Some(folder) = report.get("folder").and_then(Value::as_str).map(str::to_string) { + let shown = shown_file(&folder); + if shown != folder { + report["folder"] = Value::Null; + hidden.push((folder, shown)); + } + } + // A failed file's path holds its folder's: the longer paths are put back first. + hidden.sort_by_key(|(path, _)| std::cmp::Reverse(path.len())); + let unnamed = |text: &str| hidden.iter().fold(text.to_string(), |t, (path, name)| t.replace(path.as_str(), name)); + if let Some(e) = report.get("error").and_then(Value::as_str).map(unnamed) { + report["error"] = json!(e); + } + if let Some(failed) = report.get_mut("failed").and_then(Value::as_array_mut) { + for row in failed { + if let Some(e) = row.get("error").and_then(Value::as_str).map(unnamed) { + row["error"] = json!(e); + } + } + } + report +} + fn list(_: &mut Session, _: &Value) -> Result { - let folder = FOLDER.lock().unwrap_or_else(|e| e.into_inner()).as_ref().map(|(_, _, r)| r.clone()); - let disabled: Vec = safe_mode::disabled().iter().map(safe_mode::Item::to_json).collect(); + let folder = FOLDER.lock().unwrap_or_else(|e| e.into_inner()).as_ref().map(|(_, _, r)| shown_report(r.clone())); + let disabled: Vec = safe_mode::disabled().iter().map(shown_item).collect(); Ok( json!({"plugins": registry::list().iter().map(|p| describe_installed(p)).collect::>(), "folder": folder, "safeMode": safe_mode::active(), "disabled": disabled}), ) @@ -946,11 +998,33 @@ fn safe_mode_state(_: &mut Session, _: &Value) -> Result { Ok(json!({ "active": safe_mode::active(), "source": safe_mode::source().map(safe_mode::Source::name), - "disabled": safe_mode::disabled().iter().map(safe_mode::Item::to_json).collect::>(), - "pendingItem": safe_mode::previous().map(|i| i.to_json()), + "disabled": safe_mode::disabled().iter().map(shown_item).collect::>(), + "pendingItem": safe_mode::previous().map(|i| shown_item(&i)), })) } +/// The file of a plug-in `plugin.safeMode` lists as `file`, or `plugin.list` as `source`: one they name +/// by its file name (outside the automation read roots) is found by that name, when no other listed +/// file has it. +fn listed_file(file: String) -> String { + let mut listed: Vec = safe_mode::disabled() + .into_iter() + .chain(safe_mode::previous()) + .filter_map(|i| i.file) + .chain(registry::list().iter().filter_map(|p| p.source().map(str::to_string))) + .collect(); + listed.sort(); + listed.dedup(); + if listed.contains(&file) { + return file; + } + let mut named = listed.into_iter().filter(|f| shown_file(f) == file); + match (named.next(), named.next()) { + (Some(f), None) => f, + _ => file, + } +} + /// `plugin.setEnabled`: turns a plug-in on or off for the next launch, by `id` or by `file` (a /// plug-in that stopped a launch while loading may be known by its file alone). It neither loads /// nor unloads anything now, and uninstalls nothing. @@ -972,7 +1046,7 @@ fn set_enabled(s: &mut Session, p: &Value) -> Result { let stored = if enabled { s.stored_plugin_file(&id).map(safe_mode::Item::file) } else { None }; (registry::get(&id).map(|p| safe_mode::Item::of(&p)).unwrap_or(safe_mode::Item { id: Some(id), ..Default::default() }), stored) } - (None, Some(file)) => (safe_mode::Item::file(file), None), + (None, Some(file)) => (safe_mode::Item::file(listed_file(file)), None), (None, None) => return Err(bad(C, "give `id` or `file` (see plugin.safeMode)")), }; let items: Vec<&safe_mode::Item> = std::iter::once(&item).chain(stored.as_ref()).collect(); @@ -986,7 +1060,7 @@ fn set_enabled(s: &mut Session, p: &Value) -> Result { // Without a plug-in folder (a session confined to automation roots), or when the list there // can't take the change, the change stays in this process and goes with the next change that saves. let saved = !changed || s.store_disabled_plugins(); - Ok(json!({"changed": changed, "saved": saved, "disabled": safe_mode::disabled().iter().map(safe_mode::Item::to_json).collect::>()})) + Ok(json!({"changed": changed, "saved": saved, "disabled": safe_mode::disabled().iter().map(shown_item).collect::>()})) } fn info(_: &mut Session, p: &Value) -> Result { @@ -1055,7 +1129,7 @@ pub fn specs() -> Vec { "List Plug-ins", [], None, - "{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\"|\"importer\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.), format? and extensions? (an exporter's or importer's format id, plugin., for document.export or document.open's importer, and its file extensions), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), inputs?: [\"type\"|\"image\"|\"symbolInstance\"] (the objects besides paths a filter reads, see plugin.run), size, source, permissions: [name] (what it may do: the ones its manifest lists, else the ones its kind implies; progress: it may report its progress, see Cancel and write log lines; settings: it may keep settings of its own; cache: it may keep a cache of its own; commands: a filter may run the commands its commandAllowlist names, see docs/plugins.md), commandAllowlist?: [command id] (the commands a filter with the commands permission may run while it runs, with vc_call), hash (the module's SHA-256), api (the plug-in API level it was built for, 1 or 2), paintOutput?: true (a live effect whose pieces have paints of their own, see docs/plugins.md), cacheBytes (the bytes its cache holds; see plugin.clearCache), commands?: [{id, command (its command id, plugin.., which runs it), label, params (doc), paramsSchema}] (the menu commands a filter or live effect adds)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null, safeMode: bool (no plug-in loads or runs, see plugin.safeMode), disabled: [{id, file, name, label}] (turned off for the next launch, see plugin.setEnabled)}", + "{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\"|\"importer\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.), format? and extensions? (an exporter's or importer's format id, plugin., for document.export or document.open's importer, and its file extensions), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), inputs?: [\"type\"|\"image\"|\"symbolInstance\"] (the objects besides paths a filter reads, see plugin.run), size, source, permissions: [name] (what it may do: the ones its manifest lists, else the ones its kind implies; progress: it may report its progress, see Cancel and write log lines; settings: it may keep settings of its own; cache: it may keep a cache of its own; commands: a filter may run the commands its commandAllowlist names, see docs/plugins.md), commandAllowlist?: [command id] (the commands a filter with the commands permission may run while it runs, with vc_call), hash (the module's SHA-256), api (the plug-in API level it was built for, 1 or 2), paintOutput?: true (a live effect whose pieces have paints of their own, see docs/plugins.md), cacheBytes (the bytes its cache holds; see plugin.clearCache), commands?: [{id, command (its command id, plugin.., which runs it), label, params (doc), paramsSchema}] (the menu commands a filter or live effect adds)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null (under automation read roots, a folder outside them is null, and a failed file and a plug-in's source outside them are named by their file name), safeMode: bool (no plug-in loads or runs, see plugin.safeMode), disabled: [{id, file, name, label}] (turned off for the next launch, see plugin.setEnabled)}", always, list ), @@ -1127,7 +1201,7 @@ pub fn specs() -> Vec { "Safe Mode", [], None, - "{} → {active: bool (no plug-in loads or runs: plugin.install, .run, .reload and exporters fail, live effects draw their object's geometry unchanged and the objects keep their effects), source: \"flag\" (started with --safe-mode) | \"startup\" (the user chose Safe Mode, or hasn't chosen yet, after a launch that stopped while a plug-in was loading) | null, disabled: [{id, file, name, label}] (turned off for the next launch; id is null for one known only by its file), pendingItem: {id, file, name, label} | null (the plug-in the last launch was loading when it stopped, until the user chooses; it may not be what stopped it)}. Safe Mode is chosen at launch, never by a command", + "{} → {active: bool (no plug-in loads or runs: plugin.install, .run, .reload and exporters fail, live effects draw their object's geometry unchanged and the objects keep their effects), source: \"flag\" (started with --safe-mode) | \"startup\" (the user chose Safe Mode, or hasn't chosen yet, after a launch that stopped while a plug-in was loading) | null, disabled: [{id, file, name, label}] (turned off for the next launch; id is null for one known only by its file; under automation read roots, a file outside them is named by its file name here and in pendingItem, and plugin.setEnabled takes that name), pendingItem: {id, file, name, label} | null (the plug-in the last launch was loading when it stopped, until the user chooses; it may not be what stopped it)}. Safe Mode is chosen at launch, never by a command", always, safe_mode_state ), @@ -1136,7 +1210,7 @@ pub fn specs() -> Vec { "Enable on Re-launch", [], None, - "{id | file: a plug-in as plugin.safeMode's disabled lists it, enabled: bool} turn a plug-in on or off for the next launch (Help › Safe Mode's Enable on Re-launch); nothing loads, unloads or is uninstalled now. Turning on by id also turns on the file the plug-in is stored in. Turning on one that isn't disabled is an error. plugin.install of a disabled plug-in turns it on → {changed, saved: false when the change isn't in the plug-in folder for the next launch (a session without one, or the list there can't be read or written or is full; it is saved with the next change that saves), disabled}", + "{id | file: a plug-in as plugin.safeMode's disabled lists it or as plugin.list's source (under automation read roots, a file outside them by the file name shown, when no other listed file has it), enabled: bool} turn a plug-in on or off for the next launch (Help › Safe Mode's Enable on Re-launch); nothing loads, unloads or is uninstalled now. Turning on by id also turns on the file the plug-in is stored in. Turning on one that isn't disabled is an error. plugin.install of a disabled plug-in turns it on → {changed, saved: false when the change isn't in the plug-in folder for the next launch (a session without one, or the list there can't be read or written or is full; it is saved with the next change that saves), disabled}", always, set_enabled ), diff --git a/crates/engine/src/cmd/recovery.rs b/crates/engine/src/cmd/recovery.rs index fce76bcc27..f0087595dd 100644 --- a/crates/engine/src/cmd/recovery.rs +++ b/crates/engine/src/cmd/recovery.rs @@ -97,6 +97,10 @@ pub trait RecoveryStore: Send + Sync { fn remove(&self, name: &str) -> std::result::Result<(), String>; /// Where the entries are, as the user knows it (a folder, "browser storage"). fn location(&self) -> String; + /// Is [`RecoveryStore::location`] a folder on this computer (default: no)? + fn in_folder(&self) -> bool { + false + } /// The areas in the store (default: those with entries). fn areas(&self) -> std::result::Result, String> { let names = self.list()?; @@ -477,6 +481,9 @@ impl RecoveryStore for FolderStore { fn location(&self) -> String { self.0.to_string_lossy().into_owned() } + fn in_folder(&self) -> bool { + true + } fn areas(&self) -> std::result::Result, String> { Self::names(&self.0, true) } @@ -938,6 +945,21 @@ fn sweep(store: &dyn RecoveryStore, area: &str) { } } +/// The document's file `path` as a reply names it: under automation read roots, only inside them. +fn shown_path(path: Option) -> Option { + path.filter(|p| crate::file_access::check_read(p).is_ok()) +} + +/// Where `store` keeps its copies, as a reply names it: a recovery folder outside the automation +/// read roots is `null`. +fn shown_location(store: &dyn RecoveryStore) -> Value { + let location = store.location(); + if store.in_folder() && crate::file_access::check_read(&location).is_err() { + return Value::Null; + } + json!(location) +} + fn read_meta(store: &dyn RecoveryStore, file: &str) -> Meta { store.read(&format!("{file}{META_EXT}")).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default() } @@ -1235,7 +1257,7 @@ fn save(s: &mut Session, _: &Value) -> Result { } forget_clean(s); heartbeat(s); - Ok(json!({ "saved": saved, "skipped": skipped, "location": store.location() })) + Ok(json!({ "saved": saved, "skipped": skipped, "location": shown_location(store.as_ref()) })) } /// Who an area of the store belongs to. @@ -1361,7 +1383,7 @@ fn list(s: &mut Session, _: &Value) -> Result { let meta = read_meta(store.as_ref(), f); let title = if meta.title.is_empty() { f.rsplit('/').next().unwrap_or(f).to_string() } else { meta.title }; rows.push(json!({ - "file": f, "title": title, "path": meta.path, "format": meta.format, "saved": meta.saved, + "file": f, "title": title, "path": shown_path(meta.path), "format": meta.format, "saved": meta.saved, "open": (who == Owner::Mine && open.contains(f)) || left.contains(f), "running": who == Owner::Running, })); } @@ -1370,7 +1392,7 @@ fn list(s: &mut Session, _: &Value) -> Result { tidy(store.as_ref(), &area, hold); } } - Ok(json!({ "copies": rows, "location": store.location() })) + Ok(json!({ "copies": rows, "location": shown_location(store.as_ref()) })) } /// A gone app's area to act on: its name, the copies to act on, and its lock. @@ -1483,7 +1505,10 @@ fn restore_one(s: &mut Session, store: &Arc, file: &str) -> R // The copy stays until the document is saved or closed. st.recovery = Some(RecoveryCopy { file: mine.clone(), doc: st.doc.clone() }); let title = st.title(); - Ok(super::fileio::merge(json!({ "index": index, "title": title, "file": mine, "path": meta.path, "warnings": warnings }), links.to_json())) + Ok(super::fileio::merge( + json!({ "index": index, "title": title, "file": mine, "path": shown_path(meta.path), "warnings": warnings }), + links.to_json(), + )) } fn restore(s: &mut Session, p: &Value) -> Result { @@ -1527,7 +1552,7 @@ pub fn specs() -> Vec { "Save Recovery Data", [], None, - "{} write a recovery copy of every modified document changed since its last copy, in this app's own area of the store (the app does this every autosaveInterval minutes while autosaveRecovery is on; documents of more than 20000 objects are skipped while recoveryOffForComplex is on). Images are stored apart, each once while unchanged. A copy goes when its document is saved, reverted or closed → {saved: [{file, title, bytes, imageBytes (images written this time)}], skipped: [{title, reason}], location} (location: the recovery folder, or browser storage on the web)", + "{} write a recovery copy of every modified document changed since its last copy, in this app's own area of the store (the app does this every autosaveInterval minutes while autosaveRecovery is on; documents of more than 20000 objects are skipped while recoveryOffForComplex is on). Images are stored apart, each once while unchanged. A copy goes when its document is saved, reverted or closed → {saved: [{file, title, bytes, imageBytes (images written this time)}], skipped: [{title, reason}], location} (location: the recovery folder, or browser storage on the web; null under automation read roots when the folder is outside them)", has_store, save ), @@ -1536,7 +1561,7 @@ pub fn specs() -> Vec { "Recovery Data", [], None, - "{} the recovery copies in the store → {copies: [{file (\"/\"), title, path (the document's file, if it had one), format, saved (Unix seconds or null), open (the copy of a document open here), running (kept by another VectorCraft that is running)}], location}; copies neither open nor running were left behind by a crash", + "{} the recovery copies in the store → {copies: [{file (\"/\"), title, path (the document's file, if it had one), format, saved (Unix seconds or null), open (the copy of a document open here), running (kept by another VectorCraft that is running)}], location}; copies neither open nor running were left behind by a crash; under automation read roots, path is null when the document's file is outside them, and location when the recovery folder is", has_store, list ), @@ -1545,7 +1570,7 @@ pub fn specs() -> Vec { "Restore Recovered Documents", [], None, - "{file?} open a copy left behind by a crash (default: every one; a running VectorCraft's copies are never taken) as a new document titled \" [Recovered]\": modified, and Save asks where to save it (suggesting its original file) → {restored: [{index, title, file, path, warnings, missingLinks, modifiedLinks, updatedLinks}], failed: [{file, error}] (damaged copies, when restoring every one; a named one fails the command)}. The copy moves to this app's area and stays until the document is saved or closed", + "{file?} open a copy left behind by a crash (default: every one; a running VectorCraft's copies are never taken) as a new document titled \" [Recovered]\": modified, and Save asks where to save it (suggesting its original file) → {restored: [{index, title, file, path, warnings, missingLinks, modifiedLinks, updatedLinks}], failed: [{file, error}] (damaged copies, when restoring every one; a named one fails the command)}; under automation read roots, path is null when the original file is outside them. The copy moves to this app's area and stays until the document is saved or closed", has_store, restore ), diff --git a/crates/engine/src/cmd/scripts.rs b/crates/engine/src/cmd/scripts.rs index 8c86f0dfe3..44a14dbe34 100644 --- a/crates/engine/src/cmd/scripts.rs +++ b/crates/engine/src/cmd/scripts.rs @@ -233,6 +233,43 @@ impl Session { self.scripts_dir.as_deref().filter(|_| !cfg!(target_arch = "wasm32")) } + /// The last run's report ([`Session::script_report`]) as a reply shows it: under automation read + /// roots, a file the run wrote outside them (`written`) and the file a failed run stopped in + /// (`file`, also in the run's error line in `console`) are named by their file name. What the + /// script wrote itself (`log`, its output lines) stays as written. + pub fn shown_script_report(&self) -> Option { + let mut report = self.script_report()?.clone(); + if let Some(written) = report.get_mut("written").and_then(Value::as_array_mut) { + for path in written.iter_mut() { + if let Some(name) = path.as_str().and_then(hidden_name) { + *path = json!(name); + } + } + } + if let Some(file) = report.get("file").and_then(Value::as_str).map(str::to_string) + && let Some(name) = hidden_name(&file) + { + // The run's error lines come after every line the script wrote. They are written again + // from the report with the file's name, which also covers a line the console cut inside + // the path. A console that dropped its oldest lines may hold only the last of them. + let before = script::error_lines(&report); + report["file"] = json!(name); + if let Some(message) = report.get("message").and_then(Value::as_str) { + report["message"] = json!(message.replace(&file, &name)); + } + let after = script::error_lines(&report); + if let Some(lines) = report.pointer_mut("/console/lines").and_then(Value::as_array_mut) { + let kept = (1..=before.len().min(lines.len())) + .rev() + .find(|&n| lines.iter().rev().take(n).map(Value::as_str).eq(before.iter().rev().take(n).map(|l| Some(l.as_str())))) + .unwrap_or(0); + lines.truncate(lines.len() - kept); + lines.extend(after.iter().skip(after.len().saturating_sub(kept)).map(|l| json!(l))); + } + } + Some(report) + } + /// The text of the script at `path`, which a listing of the Scripts folder gave /// ([`script::folder::read`]), checked as `script.run` checks a file's. pub fn installed_script(&self, path: &str) -> Result { @@ -248,6 +285,13 @@ impl Session { } } +/// The file name a reply names `path` by when the file is outside the automation read roots; `None` +/// when a reply may name it whole. +fn hidden_name(path: &str) -> Option { + let shown = super::fileio::shown_path(path); + (shown != path).then(|| shown.into_owned()) +} + fn list_installed(s: &mut Session, _: &Value) -> Result { let Some(root) = s.scripts_dir() else { return Ok(json!({"folder": null, "scripts": [], "truncated": false, "unreadable": false})); diff --git a/crates/engine/src/script/mod.rs b/crates/engine/src/script/mod.rs index 0821a11e71..0da75a42ed 100644 --- a/crates/engine/src/script/mod.rs +++ b/crates/engine/src/script/mod.rs @@ -2116,6 +2116,27 @@ fn line_to(s: &mut Session, host: &Host, entry: console::Entry, cut: bool) { host.console.borrow_mut().push(line, cut); } +/// The error of the failed run `report` describes, as its console lines give it; `name` is the +/// run's name in those lines ([`console::line_name`]). +fn error_text(name: &str, report: &Value) -> String { + let (line, column) = (report["line"].as_u64().unwrap_or(0), report["column"].as_u64().unwrap_or(0)); + let message = report["message"].as_str().unwrap_or_default(); + match report["file"].as_str() { + Some(file) if line > 0 => format!("{name}: {file}:{line}:{column}: {message}"), + _ if line > 0 => format!("{name}:{line}:{column}: {message}"), + _ => format!("{name}: location unavailable: {message}"), + } +} + +/// The console lines of the error of the failed run `report` describes, each as the console keeps +/// it, cut to its limit. +pub(crate) fn error_lines(report: &Value) -> Vec { + let name = console::line_name(report["name"].as_str().unwrap_or_default()); + let mut lines = vec![]; + each_line(&error_text(&name, report), |line, _| lines.push(line)); + lines +} + /// Ends the console lines of `host`'s run: the line `$.write` left unended, then for a failed run /// (`report["ok"]` false) its error as `name:line:column: message`, then an end line and a report /// line in the session's console. Adds the run's time, host calls by kind, slowest commands, @@ -2130,14 +2151,7 @@ fn close_run(s: &mut Session, host: &Host, report: &mut Value, ending: console:: } } if report["ok"] == false { - let (line, column) = (report["line"].as_u64().unwrap_or(0), report["column"].as_u64().unwrap_or(0)); - let message = report["message"].as_str().unwrap_or_default(); - let text = match report["file"].as_str() { - Some(file) if line > 0 => format!("{}: {file}:{line}:{column}: {message}", host.name), - _ if line > 0 => format!("{}:{line}:{column}: {message}", host.name), - _ => format!("{}: location unavailable: {message}", host.name), - }; - each_line(&text, |line, cut| line_to(s, host, console::Entry::Error(line), cut)); + each_line(&error_text(&host.name, report), |line, cut| line_to(s, host, console::Entry::Error(line), cut)); } let timing = clock().is_some(); let ms = millis(host.run_nanos.get()); diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index a1e615a08d..76df1c2194 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -1433,6 +1433,92 @@ fn plugins_folder_set_again_after_clearing_installs_its_plugins() { run(&mut s, "plugin.remove", json!({"id": id})); } +/// Under automation read roots, `plugin.list` names the plug-in folder last loaded, its failed +/// files and a plug-in's source file only inside them: a folder outside is `null`, and a file +/// outside is named by its file name. +#[test] +fn plugin_list_leaves_out_paths_outside_the_read_root() { + use crate::file_access::{AutomationRoots, confine}; + let _folder = PREFS_FOLDER.lock().unwrap_or_else(|e| e.into_inner()); + let base = crate::tests_links::Folder::new("plugin-list-read-root"); + let (inside, outside) = (base.0.join("in"), base.0.join("out")); + std::fs::create_dir_all(&inside).unwrap(); + std::fs::create_dir_all(&outside).unwrap(); + let id = "test.engine.listroots"; + let src = tpl::returning(&tpl::manifest(id, "filter", "{}"), r#"{"objects":[]}"#); + std::fs::write(outside.join("hidden.wasm"), wat::parse_str(src).unwrap()).unwrap(); + std::fs::write(outside.join("broken.wasm"), b"\0asm?").unwrap(); + let folder = outside.to_string_lossy().to_string(); + let mut s = session(); + let r = run(&mut s, "plugin.reload", json!({"path": folder})); + assert_eq!(r["loaded"], json!([id]), "{r}"); + let broken = outside.join("broken.wasm").to_string_lossy().to_string(); + let source = outside.join("hidden.wasm").to_string_lossy().to_string(); + let entry = |list: &Value| list["plugins"].as_array().unwrap().iter().find(|p| p["id"] == id).cloned().unwrap(); + + // The person sees the paths. + let person = run(&mut s, "plugin.list", json!({})); + assert_eq!((&person["folder"]["folder"], &person["folder"]["failed"][0]["file"]), (&json!(folder), &json!(broken)), "{person}"); + assert_eq!(entry(&person)["source"], json!(source)); + + // The client sees their names. + let roots = AutomationRoots::new(&[&inside], &[]).unwrap(); + let listed = confine(roots.as_ref(), || s.execute("plugin.list", &json!({}))).unwrap(); + let report = &listed["folder"]; + assert_eq!((&report["folder"], &report["loaded"], &report["failed"][0]["file"]), (&Value::Null, &json!([id]), &json!("broken.wasm")), "{listed}"); + assert_eq!(entry(&listed)["source"], "hidden.wasm"); + let info = confine(roots.as_ref(), || s.execute("plugin.info", &json!({"id": id}))).unwrap(); + assert_eq!(info["source"], "hidden.wasm"); + let outside_text = json!(folder).to_string(); + for reply in [&listed, &info] { + assert!(!reply.to_string().contains(outside_text.trim_matches('"')), "no path outside the root: {reply}"); + } + + // A read root holding the folder shows it whole. + let wider = AutomationRoots::new(&[&outside], &[]).unwrap(); + let listed = confine(wider.as_ref(), || s.execute("plugin.list", &json!({}))).unwrap(); + assert_eq!(listed["folder"]["folder"], json!(folder), "{listed}"); + run(&mut s, "plugin.remove", json!({"id": id})); +} + +/// Under automation read roots, a plug-in turned off for the next launch is listed with a file +/// outside them named by its file name, and `plugin.setEnabled` turns it on by that name. +#[test] +fn disabled_plugins_name_files_outside_the_read_root_by_their_names() { + if !in_own_process("disabled_plugins_name_files_outside_the_read_root_by_their_names") { + return; + } + use crate::file_access::{AutomationRoots, confine}; + let base = crate::tests_links::Folder::new("plugin-disabled-read-root"); + let (inside, outside) = (base.0.join("in"), base.0.join("out")); + std::fs::create_dir_all(&inside).unwrap(); + std::fs::create_dir_all(&outside).unwrap(); + let id = "test.engine.disabledroots"; + let src = tpl::returning(&tpl::manifest(id, "filter", "{}"), r#"{"objects":[]}"#); + let file = outside.join("off.wasm"); + std::fs::write(&file, wat::parse_str(src).unwrap()).unwrap(); + let file = file.to_string_lossy().to_string(); + let mut s = session(); + run(&mut s, "plugin.install", json!({"path": file})); + let roots = AutomationRoots::new(&[&inside], &[]).unwrap(); + // The client turns it off by the source plugin.list shows. + let listed = confine(roots.as_ref(), || s.execute("plugin.list", &json!({}))).unwrap(); + let source = listed["plugins"].as_array().unwrap().iter().find(|p| p["id"] == id).unwrap()["source"].clone(); + assert_eq!(source, "off.wasm", "{listed}"); + confine(roots.as_ref(), || s.execute("plugin.setEnabled", &json!({"file": source, "enabled": false}))).unwrap(); + assert_eq!(run(&mut s, "plugin.safeMode", json!({}))["disabled"][0]["file"], json!(file), "the person sees the path"); + + let outside_text = json!(outside.to_string_lossy()).to_string(); + for cmd in ["plugin.list", "plugin.safeMode"] { + let r = confine(roots.as_ref(), || s.execute(cmd, &json!({}))).unwrap(); + assert_eq!(r["disabled"][0]["file"], "off.wasm", "{cmd}: {r}"); + assert!(!r.to_string().contains(outside_text.trim_matches('"')), "{cmd}: no path outside the root: {r}"); + } + let r = confine(roots.as_ref(), || s.execute("plugin.setEnabled", &json!({"file": "off.wasm", "enabled": true}))).unwrap(); + assert_eq!((&r["changed"], &r["disabled"]), (&json!(true), &json!([])), "{r}"); + run(&mut s, "plugin.remove", json!({"id": id})); +} + /// #2541: a plug-in installed in a session with a plug-in store is installed again in the next /// session that attaches the store, and Remove Plug-in takes it out of the store. #[test] diff --git a/crates/engine/src/tests/recovery.rs b/crates/engine/src/tests/recovery.rs index 8248f12a48..1f3867a622 100644 --- a/crates/engine/src/tests/recovery.rs +++ b/crates/engine/src/tests/recovery.rs @@ -1545,6 +1545,71 @@ impl RecoveryStore for DiesAfterDocument { } } +/// Under automation read roots, `file.recovery.save`, `file.recovery.list` and +/// `file.recovery.restore` name a document's file and the recovery folder only inside them. +#[test] +fn recovery_replies_leave_out_paths_outside_the_read_root() { + use crate::file_access::{AutomationRoots, confine}; + use serde_json::Value; + let base = crate::tests_links::Folder::new("recovery-read-root"); + let (inside, outside) = (base.0.join("in"), base.0.join("out")); + std::fs::create_dir_all(&inside).unwrap(); + std::fs::create_dir_all(&outside).unwrap(); + let store: Arc = Arc::new(FolderStore::new(outside.join("recovery"))); + let location = outside.join("recovery").to_string_lossy().to_string(); + let kept = inside.join("kept.vectorcraft").to_string_lossy().to_string(); + let hidden = outside.join("hidden.vectorcraft").to_string_lossy().to_string(); + let mut s = Session::new(); + s.recovery.set_store(store.clone()); + for path in [&kept, &hidden] { + s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap(); + s.execute("file.saveAs", &json!({"path": path})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 20})).unwrap(); + } + let roots = AutomationRoots::new(&[&inside], &[]).unwrap(); + let saved = confine(roots.as_ref(), || s.execute("file.recovery.save", &json!({}))).unwrap(); + assert_eq!(saved["location"], Value::Null, "{saved}"); + s.execute("shape.ellipse", &json!({"x": 80, "y": 20, "width": 20, "height": 20})).unwrap(); + assert_eq!(s.execute("file.recovery.save", &json!({})).unwrap()["location"], json!(location), "the person sees the folder"); + + let sorted = |v: &Value, key: &str| -> Vec { + let mut out: Vec = v[key].as_array().unwrap().iter().map(|c| c["path"].to_string()).collect(); + out.sort(); + out + }; + let both = |first: Value| { + let mut out = vec![first.to_string(), json!(kept).to_string()]; + out.sort(); + out + }; + assert_eq!(sorted(&s.execute("file.recovery.list", &json!({})).unwrap(), "copies"), both(json!(hidden))); + let listed = confine(roots.as_ref(), || s.execute("file.recovery.list", &json!({}))).unwrap(); + assert_eq!((sorted(&listed, "copies"), &listed["location"]), (both(Value::Null), &Value::Null), "{listed}"); + let outside_text = json!(outside.to_string_lossy()).to_string(); + assert!(!listed.to_string().contains(outside_text.trim_matches('"')), "no path outside the root: {listed}"); + // A relative recovery folder is checked as the file system reads it; a store that is no folder + // keeps its name. + let mut relative = Session::new(); + relative.recovery.set_store(Arc::new(FolderStore::new("vc-recovery-relative-folder"))); + let l = confine(roots.as_ref(), || relative.execute("file.recovery.list", &json!({}))).unwrap(); + assert_eq!(l["location"], Value::Null, "{l}"); + let (mut memory, _) = session(); + let l = confine(roots.as_ref(), || memory.execute("file.recovery.list", &json!({}))).unwrap(); + assert_eq!(l["location"], "memory", "{l}"); + + // After a crash, the copies restored. + drop(s); + let mut s = Session::new(); + s.recovery.set_store(store.clone()); + let r = confine(roots.as_ref(), || s.execute("file.recovery.restore", &json!({}))).unwrap(); + assert_eq!(sorted(&r, "restored"), both(Value::Null), "{r}"); + let mut opened: Vec> = s.documents().iter().map(|d| d.path.clone()).collect(); + opened.sort(); + let mut files = vec![Some(hidden), Some(kept)]; + files.sort(); + assert_eq!(opened, files, "the documents keep their files"); +} + /// A crash between writing a copy's document and its details restores the new document with /// every image, the one placed since the last copy too (and the first copy likewise). #[test] diff --git a/crates/engine/src/tests/script_console.rs b/crates/engine/src/tests/script_console.rs index 27f53cef07..48d5c8ed0a 100644 --- a/crates/engine/src/tests/script_console.rs +++ b/crates/engine/src/tests/script_console.rs @@ -265,3 +265,31 @@ fn the_slowest_commands_are_the_longest_in_all_then_by_id() { assert_eq!(ids, ["b.two", "a.one", "c.three", "f", "e"]); assert_eq!((slow[0].calls, slow[0].total_ms, slow[0].max_ms), (3, 9, 4)); } + +/// Under automation read roots, the error lines of a failed run name the included file it stopped +/// in by its file name also when the console cut a line inside the file's path. +#[test] +fn a_cut_error_line_leaves_out_an_included_files_folder_outside_the_read_root() { + use crate::file_access::{AutomationRoots, confine}; + let base = crate::tests_links::Folder::new("script-report-cut-line"); + let inside = base.0.join("in"); + std::fs::create_dir_all(&inside).unwrap(); + // A path longer than a console line, as the report of a failed run names it. + let file = base.0.join("out").join("d".repeat(MAX_LINE_BYTES)).join("bad.jsxinc").to_string_lossy().to_string(); + let mut report = json!({"ok": false, "name": "t.js", "file": file, "line": 2, "column": 7, "message": format!("Error: stopped\nin {file}")}); + let error = script::error_lines(&report); + assert!(error.len() == 2 && error.iter().all(|l| l.len() <= MAX_LINE_BYTES && l.ends_with(CUT_MARK)), "both lines are cut: {error:?}"); + let written: Vec = std::iter::once("written by the script".to_string()).chain(error).collect(); + report["console"] = json!({"lines": written, "droppedLines": 0, "truncatedLines": 2}); + let mut s = Session::new(); + s.script_report = Some(report.clone()); + + let roots = AutomationRoots::new(&[&inside], &[]).unwrap(); + let shown = confine(roots.as_ref(), || s.shown_script_report()).unwrap(); + assert_eq!((&shown["file"], &shown["message"]), (&json!("bad.jsxinc"), &json!("Error: stopped\nin bad.jsxinc")), "{shown}"); + assert_eq!(shown["console"]["lines"], json!(["written by the script", "t.js: bad.jsxinc:2:7: Error: stopped", "in bad.jsxinc"]), "{shown}"); + let outside = json!(base.0.join("out").to_string_lossy()).to_string(); + assert!(!shown.to_string().contains(outside.trim_matches('"')), "no path outside the root: {shown}"); + // The person sees the report as it is. + assert_eq!(s.shown_script_report(), Some(report)); +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 11edb6de6e..ef6a6d20c8 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -253,7 +253,7 @@ pub fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value { } let mut inspected = json!({ "scriptRunning": app.session.script_running(), - "scriptReport": app.session.script_report(), + "scriptReport": app.session.shown_script_report(), "tool": app.session.tool_id(), "toolOptions": app.session.tool_options(), "ui": ui, diff --git a/crates/ui-egui/src/tests/automation.rs b/crates/ui-egui/src/tests/automation.rs index 26406a4992..3a84e0b94d 100644 --- a/crates/ui-egui/src/tests/automation.rs +++ b/crates/ui-egui/src/tests/automation.rs @@ -567,6 +567,50 @@ fn ui_inspect_keeps_text_that_only_looks_like_a_library_id() { let _ = std::fs::remove_dir_all(base); } +/// `ui.inspect`'s `scriptReport` names a file the person's script wrote outside the read root, and +/// the included file a failed run stopped in (also in its error line), by its file name; the person +/// sees the paths. +#[test] +fn ui_inspect_script_report_leaves_out_files_outside_the_read_root() { + let (mut app, tx, base) = confined("inspect-script-report"); + let (kept, hidden) = (s(base.join("in/kept.svg")), s(base.join("outside/hidden.svg"))); + app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + let source = format!( + "vc.run('document.export', {{path: {kept}, format: 'svg'}}); vc.run('document.export', {{path: {hidden}, format: 'svg'}});", + kept = json!(kept), + hidden = json!(hidden) + ); + let finish = |app: &mut VectorcraftApp, params: Value| { + app.session.execute("script.run", ¶ms).unwrap(); + for _ in 0..10_000 { + if app.session.script_pump().is_some() { + break; + } + } + }; + finish(&mut app, json!({"source": source, "name": "t.js"})); + let ctx = egui::Context::default(); + let outside = json!(s(base.join("outside"))).to_string(); + let person = crate::control::inspect(&app, &ctx); + assert_eq!(person["scriptReport"]["written"], json!([kept, hidden]), "{person}"); + let r = request(&mut app, &tx, "ui.inspect", json!({})); + assert_eq!(r["result"]["scriptReport"]["written"], json!([kept, "hidden.svg"]), "{r}"); + assert!(!r.to_string().contains(outside.trim_matches('"')), "no path outside the root: {r}"); + assert_eq!(app.session.script_report().unwrap()["written"], json!([kept, hidden]), "the report is not changed"); + + // A run that fails in a file it includes. + std::fs::write(base.join("outside/run.jsx"), "#include \"bad.jsxinc\"\n").unwrap(); + std::fs::write(base.join("outside/bad.jsxinc"), "\nthrow new Error('stopped here');\n").unwrap(); + finish(&mut app, json!({"path": s(base.join("outside/run.jsx"))})); + let person = crate::control::inspect(&app, &ctx); + assert_eq!(person["scriptReport"]["file"], json!(s(base.join("outside/bad.jsxinc"))), "{person}"); + let r = request(&mut app, &tx, "ui.inspect", json!({})); + assert_eq!(r["result"]["scriptReport"]["file"], "bad.jsxinc", "{r}"); + assert!(r["result"]["scriptReport"]["console"].to_string().contains("bad.jsxinc:2:"), "{r}"); + assert!(!r.to_string().contains(outside.trim_matches('"')), "no path outside the root: {r}"); + let _ = std::fs::remove_dir_all(base); +} + /// A client without automation roots sees every loaded library's full id in `ui.inspect`. #[test] fn ui_inspect_shows_full_library_ids_without_a_read_root() { diff --git a/docs/mcp.md b/docs/mcp.md index baa2631f7b..86b5a4af30 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -170,6 +170,17 @@ the suite) keep the agent to the folders you give it: `null`; an empty folder and the scratch disk choices `Startup` and `None` stay. In `prefs.get {}` and `prefs.reset`, an export preset in `screenExportPresets` leaves out its `folder` when the folder is outside the read roots, and the preset stays. The preferences themselves are not changed. +- `plugin.list`, `plugin.safeMode`, `file.recovery.*` and `ui_inspect`'s `scriptReport` leave out the paths + outside the read roots, also those the person or an earlier request put there. In `plugin.list`, the plug-in + folder last loaded is `null` in `folder.folder`, and a failed file in `folder.failed` and a plug-in's `source` + (also in `plugin.info`) are named by their file name, in the errors too. A plug-in turned off for the next + launch (`disabled`, and `pendingItem` in `plugin.safeMode`) has a file outside the read roots named by its file + name. `plugin.setEnabled {file}` takes that name, and a plug-in's `source` name, when no other listed file has + it. In `file.recovery.list` and `file.recovery.restore`, a + document's original file in `path` is `null`, and so is the recovery folder in `location` (`file.recovery.save` + too); the restored documents keep their files. In `scriptReport`, a file the run wrote (`written`) and the + included file a failed run stopped in (`file`, also in `message` and the run's error lines in `console`) are named + by their file name; what the script wrote itself (`log` and its output lines) stays as written. - Each flag may be given more than once (#3546): a path inside any of the read roots may be read, one inside any of the write roots written, so one session can read a project folder and a shared assets folder, or write to an output folder and a scratch folder, without widening a root to their common parent. A folder given twice diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f28078ec33..320a43ccc2 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -766,6 +766,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Link replies under `--automation-read-root`: `links.list`, `links.check`, `links.info` and `document.open`'s link rows leave out the path, found path and folder of a linked file outside the read roots, and the stored size and date of it in `links.info`; each row keeps its id, file name and status, and the document's links are not changed. `crates/engine/src/tests/linkspanel.rs` (`link_paths_outside_the_read_roots_are_left_out`), `crates/engine/src/tests/placed_document.rs` (`link_info_leaves_out_a_placed_documents_path_outside_the_read_roots`). - Swatch, brush, symbol and graphic style library lists under `--automation-read-root`: library files and the user library folder outside the read roots are left out, a library loaded from outside them is listed and found by its file name, and the app's own list is not changed. `crates/engine/src/tests/library_roots.rs`. - Preferences commands under `--automation-read-root`: `prefs.get`, `prefs.list`, `prefs.set` and `prefs.reset` show folder preferences outside the read roots as `null` and leave out an export preset's folder outside them, and the preferences are not changed. `crates/engine/src/tests/prefs_roots.rs`. + - Plug-in, recovery and script report replies under `--automation-read-root`: `plugin.list`, `plugin.info` and `plugin.safeMode` name the plug-in folder, failed files, plug-in sources and disabled plug-ins' files outside the read roots by file name or `null`, `file.recovery.*` leave out a document's original file and the recovery folder outside them, and `ui.inspect`'s `scriptReport` names files a run wrote outside them by file name. `crates/engine/src/tests/plugins.rs` (`plugin_list_leaves_out_paths_outside_the_read_root`), `crates/engine/src/tests/recovery.rs` (`recovery_replies_leave_out_paths_outside_the_read_root`), `crates/ui-egui/src/tests/automation.rs` (`ui_inspect_script_report_leaves_out_files_outside_the_read_root`). - Scripts: `alert`, `confirm` and `prompt` open dialogs during a run, each with a Stop button that ends the run and puts every document back. In the app the run waits at the call for the person's answer; a run over MCP or the command line takes its answers in order from `script.run`'s `answers`, and the report lists each dialog with its answer. `crates/engine/src/tests/script_dialogs.rs`, `crates/ui-egui/src/tests/script_dialogs.rs`. - Parameter keys: the engine refuses a key that a command's params description doesn't name before the command runs, in every build and for every caller, including the app's own calls, the commands a command runs and the steps of `command.batch` and `action.batch`. An action recorded by an older version still plays from the Actions panel and the Batch dialog (`Session::replay`). `crates/engine/tests/it/param_keys.rs`. - MCP server with 33 tools (drawing, text, effects, Pathfinder, transforms, graphs, text wrap, export, screenshots, undo/redo, UI inspect/screenshot aliases, any command), attached to the running app or headless. Headless and remote `command_list` include desktop `UI_COMMANDS` (`ui: true`); a UI-only id still needs `--connect` to run. Eight prompts (poster, icon-set, recolor, trace-and-style, export-set, check-spelling, variables-bind, path-tools). Effect ids are not command ids. Prefer a CLI built from the checkout when a release binary lags (#6460). `vectorcraft://commands/reference` serves the API reference. Tool descriptions and replies agree with the engine: format lists come from its format table, `save_file` says it keeps the document's own format, `inspect_document` reports the paint type's characters show, and selection exports report the encoder's warnings. `--automation-read-root` / `--automation-write-root` (on `vectorcraft-cli mcp` and on `vectorcraft --control`) confine the files commands read and write to chosen folders, links followed, with the flags PhotoCraft takes (#832); each flag may be given more than once, a path inside any of its folders being allowed (#3546, `file_access::tests::several_roots_of_a_kind_each_grant_their_access`, `cli::mcp_automation_roots_take_several_folders`). A long export can be cancelled (`notifications/cancelled` stops it at the next page or group and writes nothing), reports page progress to a client that sent a progress token, and does not make other requests wait: engine-free ones answer at once, the rest are refused as busy after two seconds (#2165, #2171, #2172).