MCP: ui.inspect leaves out paths outside the read roots

Under --automation-read-root, ui.inspect returned the paths of documents
still opening, the recent files, library panels' files and the folder
preferences even when they lay outside the read roots. The reply now
leaves them out, checked with file_access::check_read as ui.menu.list
checks recent files and listed scripts:

- opening and ui.recent_files keep only the readable paths, in order.
- A library panel keeps its row without its path; a library loaded from
  outside the roots shows as loaded/<file name> in its id and panel id,
  in ui.inspect and ui.docking.
- The folder preferences in ui.engine_prefs and in an open Preferences
  dialog are left out; an empty folder and the scratch disk choices
  Startup and None stay.

The app's own state is not changed. Without roots the replies are as
before.

Docs: docs/mcp.md, docs/control-protocol.md, docs/target-app-parity.md.
This commit is contained in:
dotCooCoo
2026-11-11 06:13:33 -08:00
parent 015805322a
commit 5d7e2fa8e1
5 changed files with 178 additions and 6 deletions
+104 -5
View File
@@ -249,8 +249,9 @@ pub fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value {
// the layout.
if let Some(fields) = ui.as_object_mut() {
fields.remove("floating_panels");
leave_out_unreadable_paths(fields);
}
json!({
let mut inspected = json!({
"scriptRunning": app.session.script_running(),
"scriptReport": app.session.script_report(),
"tool": app.session.tool_id(),
@@ -294,8 +295,9 @@ pub fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value {
"nativeMenuBar": app.services.native_menu.is_some(),
// Saves and exports still being written in the background (Background Save / Export).
"background": app.background.jobs.iter().filter(|j| !j.label.is_empty()).map(|j| j.label.as_str()).collect::<Vec<_>>(),
// Documents still being read off the UI thread (File › Open, Open Recent): their paths.
"opening": app.opening.pending.iter().map(|p| p.path.as_str()).collect::<Vec<_>>(),
// Documents still being read off the UI thread (File › Open, Open Recent): their paths, without
// the ones outside the read root.
"opening": app.opening.pending.iter().map(|p| p.path.as_str()).filter(|p| readable(p)).collect::<Vec<_>>(),
// Files still being read off the UI thread for Place (the Place dialog's OK, drops): their
// names, in the order they are placed.
"placing": app.placing.pending.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(),
@@ -305,7 +307,100 @@ pub fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value {
// A native file dialog open off the UI thread (`open`, `place`, `save`, `folder`), waiting
// for the person at the computer: what asked for it runs again when it answers.
"fileDialog": app.picks.waiting_kind(),
})
});
hide_library_ids(&mut inspected, &unreadable_library_ids(app));
inspected
}
/// The ids of the loaded libraries (`loaded/<path>`) whose file the request may not read, each with
/// the id the reply shows instead: `loaded/<file name>`, as a library loaded without a path is named.
fn unreadable_library_ids(app: &VectorcraftApp) -> Vec<(String, String)> {
app.ui
.library_panels
.iter()
.filter_map(|panel| {
let path = panel.id.strip_prefix("loaded/")?;
let name = vectorcraft_engine::cmd::fileio::file_name(path);
(name != path && !readable(path)).then(|| (panel.id.clone(), format!("loaded/{name}")))
})
.collect()
}
/// Writes each id of `ids` as the id the reply shows instead, wherever `v` holds it: in a library's
/// id, in its panel's id (`library:swatches:loaded/<path>`) and in keys.
fn hide_library_ids(v: &mut Value, ids: &[(String, String)]) {
if ids.is_empty() {
return;
}
let hide = |s: &mut String| {
for (id, shown) in ids {
if s.contains(id.as_str()) {
*s = s.replace(id.as_str(), shown);
}
}
};
match v {
Value::String(s) => hide(s),
Value::Array(items) => items.iter_mut().for_each(|item| hide_library_ids(item, ids)),
Value::Object(fields) => {
for (mut key, mut item) in std::mem::take(fields) {
hide(&mut key);
hide_library_ids(&mut item, ids);
fields.insert(key, item);
}
}
_ => {}
}
}
/// The preferences that hold a folder, by their keys in `ui.engine_prefs` and the Preferences dialog.
const PREF_FOLDERS: [&str; 6] = ["fontsFolder", "pluginsFolder", "scratchPrimary", "scratchSecondary", "recoveryFolder", "templatesFolder"];
/// The scratch disk choices that are not paths.
const SCRATCH_CHOICES: [&str; 2] = ["Startup", "None"];
/// May the request being served read `path`? Under an automation read root (a control client's
/// request), `ui.inspect` leaves out a path outside the root, as `ui.menu.list` leaves out a recent
/// file or a listed script outside it.
fn readable(path: &str) -> bool {
vectorcraft_engine::file_access::check_read(path).is_ok()
}
/// Takes out of `ui.inspect`'s copy of the UI state the paths the request may not read: recent files,
/// a library panel's file and folder preferences (saved, and in an open Preferences dialog) outside
/// the read root. The UI state itself is not changed.
fn leave_out_unreadable_paths(ui: &mut serde_json::Map<String, Value>) {
if let Some(Value::Array(files)) = ui.get_mut("recent_files") {
files.retain(|f| f.as_str().is_some_and(readable));
}
if let Some(Value::Array(panels)) = ui.get_mut("library_panels") {
for panel in panels.iter_mut().filter_map(Value::as_object_mut) {
if panel.get("path").and_then(Value::as_str).is_some_and(|p| !readable(p)) {
panel.remove("path");
}
}
}
if let Some(Value::Object(prefs)) = ui.get_mut("engine_prefs") {
leave_out_unreadable_folders(prefs);
}
if let Some(Value::Object(dialog)) = ui.get_mut("dialog")
&& dialog.get("kind").and_then(Value::as_str) == Some("preferences")
&& let Some(Value::Object(fields)) = dialog.get_mut("fields")
{
leave_out_unreadable_folders(fields);
}
}
/// Takes out of `prefs` the folder preferences ([`PREF_FOLDERS`]) the request may not read. An
/// empty value (no folder) and a scratch disk choice ([`SCRATCH_CHOICES`]) stay.
fn leave_out_unreadable_folders(prefs: &mut serde_json::Map<String, Value>) {
for key in PREF_FOLDERS {
let Some(path) = prefs.get(key).and_then(Value::as_str).map(str::trim) else { continue };
if path.is_empty() || (key.starts_with("scratch") && SCRATCH_CHOICES.contains(&path)) || readable(path) {
continue;
}
prefs.remove(key);
}
}
fn key_from(name: &str) -> Option<egui::Key> {
@@ -348,7 +443,11 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques
"engine.commands" => ok(all_commands(app)),
"document.inspect" => wrap(app.run("document.inspect", p.clone())),
"ui.inspect" => ok(inspect(app, ctx)),
"ui.docking" => ok(crate::panel_docking::inspect(app, ctx)),
"ui.docking" => {
let mut docking = crate::panel_docking::inspect(app, ctx);
hide_library_ids(&mut docking, &unreadable_library_ids(app));
ok(docking)
}
"ui.menu.list" => ok(serde_json::to_value(crate::menus::menu_entries(app)).unwrap_or_default()),
"ui.contextMenu.list" => ok(serde_json::to_value(crate::menus::context_entries(app)).unwrap_or_default()),
"ui.tool.select" => wrap(app.run("tool.select", json!({"tool": s("tool").unwrap_or("")}))),
+65
View File
@@ -360,6 +360,71 @@ fn recent_files_outside_the_read_root_are_not_listed_or_opened() {
let _ = std::fs::remove_dir_all(base);
}
/// `ui.inspect` leaves out the paths outside the read root that the person's work put in it: files
/// still opening, recent files, a library panel's file and folder preferences, saved and in the
/// Preferences dialog.
#[test]
fn ui_inspect_leaves_out_paths_outside_the_read_root() {
let (mut app, tx, base) = confined("inspect");
std::fs::copy(base.join("in/a.svg"), base.join("in/c.svg")).unwrap();
let (a, b, c) = (s(base.join("in/a.svg")), s(base.join("outside/b.svg")), s(base.join("in/c.svg")));
let svg = std::fs::read(&a).unwrap();
for path in [&a, &b, &c] {
assert!(crate::opening::start(&mut app, path, svg.clone()).is_ok(), "{path} opens off the UI thread");
}
app.ui.recent_files = vec![a.clone(), b.clone(), c.clone()];
let panel = |id: &str, path: &str| -> crate::panels::library_panel::LibraryPanel {
serde_json::from_value(json!({"kind": "swatches", "id": id, "path": path})).unwrap()
};
let theirs = format!("loaded/{b}");
app.ui.library_panels = vec![panel("mine", &a), panel(&theirs, &b)];
app.ui.open_panel = Some(format!("library:swatches:{theirs}"));
let (inside, outside) = (s(base.join("in")), s(base.join("outside")));
app.ui.engine_prefs = json!({
"fontsFolder": inside, "templatesFolder": outside, "recoveryFolder": outside, "scratchPrimary": "Startup",
"scratchSecondary": "../private", "pluginsFolder": "",
});
app.ui.dialog = Some(crate::state::Dialog::new("preferences", json!({"fontsFolder": inside, "templatesFolder": outside})));
// The person sees every path.
let ctx = egui::Context::default();
let person = crate::control::inspect(&app, &ctx);
assert_eq!(person["opening"], json!([a, b, c]));
assert_eq!(person["ui"]["recent_files"], json!([a, b, c]));
assert_eq!(person["ui"]["library_panels"][1]["path"], b);
assert_eq!(person["ui"]["engine_prefs"]["templatesFolder"], outside);
// The client sees the ones inside the read root, in the same order.
let r = request(&mut app, &tx, "ui.inspect", json!({}));
let ui = &r["result"]["ui"];
assert_eq!(r["result"]["opening"], json!([a, c]), "{r}");
assert_eq!(ui["recent_files"], json!([a, c]));
assert_eq!(ui["library_panels"][0]["path"], a);
assert_eq!((ui["library_panels"][1]["id"].as_str(), ui["library_panels"][1].get("path")), (Some("loaded/b.svg"), None), "the panel stays");
assert_eq!(ui["open_panel"], "library:swatches:loaded/b.svg");
let prefs = ui["engine_prefs"].as_object().unwrap();
assert_eq!(
(prefs["fontsFolder"].as_str(), prefs["scratchPrimary"].as_str(), prefs["pluginsFolder"].as_str()),
(Some(inside.as_str()), Some("Startup"), Some(""))
);
assert!(["templatesFolder", "recoveryFolder", "scratchSecondary"].iter().all(|k| !prefs.contains_key(*k)), "{prefs:?}");
let fields = ui["dialog"]["fields"].as_object().unwrap();
assert_eq!((fields["fontsFolder"].as_str(), fields.get("templatesFolder")), (Some(inside.as_str()), None), "the Preferences dialog's copy too");
let hidden = json!(outside).to_string();
assert!(!r.to_string().contains(hidden.trim_matches('"')), "no path outside the root: {r}");
let r = request(&mut app, &tx, "ui.docking", json!({}));
assert_eq!(r["result"]["flyout"], "library:swatches:loaded/b.svg", "{r}");
assert_eq!(crate::panel_docking::inspect(&app, &ctx)["flyout"], json!(format!("library:swatches:{theirs}")), "the person's");
assert_eq!(app.ui.recent_files, [a.clone(), b.clone(), c.clone()], "the UI state is not changed");
assert_eq!(app.opening.pending.len(), 3, "and no open is canceled");
// A write root alone lets the client read none of them.
let write_only = AutomationRoots::new(&[], &[&base.join("out")]).unwrap();
let listed = vectorcraft_engine::file_access::confine(write_only.as_ref(), || crate::control::inspect(&app, &ctx));
assert_eq!((&listed["opening"], &listed["ui"]["recent_files"]), (&json!([]), &json!([])), "{listed}");
let _ = std::fs::remove_dir_all(base);
}
/// Hidden logic-only ticks between the injection and its UI pass must not drain another step:
/// one step per settled pass, not per hidden tick.
#[test]
+1 -1
View File
@@ -92,7 +92,7 @@ the two can still win that race.
| `engine.execute` | `{command, params}` | run any engine or UI command (see `engine.commands`). A key of `params` that the command's params description doesn't name is an error naming it and the nearest key the command takes, before anything runs (`unknown parameter `widht`; did you mean `width`?`); see [Parameter keys](mcp.md#parameter-keys) |
| `engine.commands` | | every command with label, shortcut, params doc, enablement |
| `document.inspect` | `{depth?, childLimit?, ids?, kind?, limit?, offset?}` | layer tree, selection, the key object (`key`, null without one; a key ruler guide as `keyGuide`), history, paint defaults; `depth` / `childLimit` slice the layer tree as `document.node {summary: true}` does; `ids`, `kind`, `limit` and `offset` list just some nodes as `nodes` (with `total`) instead of the whole tree, see [mcp.md](mcp.md). Each node reports `opacity` below 1 and `blend` when it isn't Normal; objects and compound paths report `fill`, `stroke` and `strokeWidth`, a compound path's members `compoundMember: true` instead |
| `ui.inspect` | | tool, UI state, `screenMode` (0 normal, 1 full screen with menu bar, 2 full screen, 3 Presentation Mode), `taskBar` (`pinned`, and `rect` `[x, y, w, h]` while the Contextual Task Bar shows), `freeTransformWidget` (`[x, y, w, h]` while the Free Transform widget shows), view, canvas rect, window size, perf, background saves and exports still running, `nativeMenuBar` (true when the menus are in the macOS menu bar rather than the window; `ui.menu.list` lists the in-window menus either way), `fileDialog` (`"open"`, `"place"`, `"save"` or `"folder"` while a native file dialog is open, `null` otherwise; see below) |
| `ui.inspect` | | tool, UI state, `screenMode` (0 normal, 1 full screen with menu bar, 2 full screen, 3 Presentation Mode), `taskBar` (`pinned`, and `rect` `[x, y, w, h]` while the Contextual Task Bar shows), `freeTransformWidget` (`[x, y, w, h]` while the Free Transform widget shows), view, canvas rect, window size, perf, background saves and exports still running, `nativeMenuBar` (true when the menus are in the macOS menu bar rather than the window; `ui.menu.list` lists the in-window menus either way), `fileDialog` (`"open"`, `"place"`, `"save"` or `"folder"` while a native file dialog is open, `null` otherwise; see below), `opening` (the paths of the documents still being read). Under `--automation-read-root` the reply leaves out the paths outside the read roots (see `docs/mcp.md`) |
| `ui.menu.list` / `ui.menu.invoke` | `{command, params}` | the full menu tree / invoke an item |
| `ui.contextMenu.list` | | the canvas context menu for the current selection, flattened like `ui.menu.list` (`path` holds its submenus). `ui.click {x, y, button: "right"}` on the canvas opens it, after selecting the object there unless it is already selected. With the Artboard tool it lists the artboard entries (see `docs/mcp.md`), after selecting the artboard there unless it is already selected. While a menu opened over locked objects is open, it has an `Unlock` submenu with one item for each locked object under the pointer, topmost first and at most 20, labeled with the object's name, which runs `layer.setProps {ids: [id], locked: false}`. A locked group gets one item, and the locked objects inside it get none. Hidden objects, objects on hidden, locked or template layers, and in isolation mode objects outside the isolated group get no item |
| `ui.tool.select` / `ui.tool.list` | `{tool}` | |
+7
View File
@@ -115,6 +115,13 @@ the suite) keep the agent to the folders you give it:
- `file.recentFiles` and the control channel's `ui.menu.list` leave out the recent files outside the read roots. The
other File › Open Recent Files rows keep their ids (`file.openRecent3` still opens the third file), and
`file.openRecentN` for a file outside the roots ends with `no such recent file`, as an empty slot does.
- `ui_inspect` (the control channel's `ui.inspect`) leaves out the paths outside the read roots: in `opening`, the
documents still being read; in `ui.recent_files`, the recent files; a library panel's `path` in `ui.library_panels`;
and the folder preferences in `ui.engine_prefs` and in an open Preferences dialog's `ui.dialog.fields`
(`fontsFolder`, `pluginsFolder`, `scratchPrimary`, `scratchSecondary`, `recoveryFolder`, `templatesFolder`; an
empty folder and the scratch disk choices `Startup` and `None` stay). A library loaded from a file outside the
roots shows as `loaded/<file name>` in its id and its panel's id, in `ui_inspect` and in `ui.docking`. The other
entries keep their order, and the app's own state is not changed.
- 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
+1
View File
@@ -738,6 +738,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- Scripts: `#strict on` (and `//@strict on`) runs the script with the checks the [`$` object](https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/) help page gives for `$.strict`: an assignment to a read-only property throws a `TypeError`, and so does a new property on an object that takes none. VectorCraft's own rules: a getter-only property counts as read-only, the objects that take no new properties are the non-extensible ones (`Object.preventExtensions`, `seal`, `freeze`, and the frozen `vc` and `$` objects), the last `#strict` line of the script and its includes sets the whole run, a file `$.evalFile` runs keeps its own line's checks for its code, and the rest of ES5 strict mode stays with `'use strict'`. Without the line, runs are unchanged. `crates/engine/src/tests/script_include.rs` (`strict_on_*`, `strict_mode_in_eval_file_is_scoped`), `crates/js/src/interp_tests.rs` (`strict_writes_are_separate_from_es5_strict`).
- Scripts folder: File › Scripts lists the `.js` and `.jsx` files of the `Scripts` folder next to the preferences, each named after its file without the extension, with each subfolder as a submenu, above Check Script… and Other Script… (https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/install-and-run-scripts.html). VectorCraft's own rules: the folder is read again when the window becomes active and when the File menu opens, links are not followed, and one read stops at 2,048 entries, 512 scripts, 128 subfolders, 4 levels or 250 ms. `script.listInstalled` returns the listing. `crates/engine/src/tests/script_folder.rs`, `crates/ui-egui/src/tests/scripts_menu.rs`.
- Open Recent Files under `--automation-read-root`: the control channel's `ui.menu.list` and `file.recentFiles` leave out the recent files outside the read roots, the other rows keep their ids, and `file.openRecentN` for one outside them answers `no such recent file`, as an empty slot does. `crates/ui-egui/src/tests/automation.rs`.
- `ui.inspect` under `--automation-read-root`: the documents still opening, the recent files, library panels' files and folder preferences (saved, and in an open Preferences dialog) outside the read roots are left out of the reply, a library loaded from outside them shows by its file name in `ui.inspect` and `ui.docking`, and the app's state is not changed. `crates/ui-egui/src/tests/automation.rs` (`ui_inspect_leaves_out_paths_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 25 tools (drawing, text, effects, Pathfinder, transforms, graphs, text wrap, export, screenshots, any command), attached to the running app or headless. 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).