From 46ce6364c5de96c106fd03ada2261c1da50639aa Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 23:35:26 -0800 Subject: [PATCH 1/2] MCP: document.inspect and document.json leave out paths outside a confined client's read roots Under --automation-read-root, document.inspect gives `path` as null when the document's file is outside the read roots. document.json names a linked image or placed file outside them by its file name alone, without its relative path, size or date, in the layers, symbols, graph designs, patterns, the pattern being edited and opacity masks. It also names a data set's image value outside them by its file name, and leaves the Export for Screens folder outside them out of export_settings. The document is not changed, and unconfined callers see the same replies as before. --- crates/engine/src/cmd/edit.rs | 8 +++- crates/engine/src/cmd/links.rs | 46 ++++++++++++++++++- crates/engine/src/inspect.rs | 3 +- crates/engine/src/tests/placed_document.rs | 52 ++++++++++++++++++++++ docs/mcp.md | 5 +++ docs/target-app-parity.md | 1 + 6 files changed, 111 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 108b8593c9..f050684cd3 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -38,7 +38,7 @@ pub fn specs() -> Vec { has_doc, doc_move ), - cmd!(query "document.inspect", "Inspect Document", [], None, "{depth?, childLimit?, ids?: [id…] (list just these nodes, of any level, in this order, under `nodes` in place of `layers`; an unknown id is an error; at most 10000), kind?: panel label such as Layer; Group; Path; Type or Image (exact, ignoring case; list every such node under `nodes`, each with `path`, its ancestor ids with the layer first, searching the subtrees of `ids` when given), limit?: at most this many entries of the list, offset?: entries skipped first (default 0; with `ids`, `kind`, `limit` or `offset` the reply adds `total`, the entries before paging)} → layer tree (each node: id, name, kind, visible, locked, bounds, opacity (0–1) and opacityPct (0–100 %, the scale object.setProps takes) when below 1, blend when not Normal, fill, stroke and strokeWidth of objects and compound paths; a compound path's members have compoundMember: true instead of paint, which is the compound's), artboards, selection, history (a sliced tree reports childCount); without the scope options the reply is the whole layer tree", has_doc, doc_inspect), + cmd!(query "document.inspect", "Inspect Document", [], None, "{depth?, childLimit?, ids?: [id…] (list just these nodes, of any level, in this order, under `nodes` in place of `layers`; an unknown id is an error; at most 10000), kind?: panel label such as Layer; Group; Path; Type or Image (exact, ignoring case; list every such node under `nodes`, each with `path`, its ancestor ids with the layer first, searching the subtrees of `ids` when given), limit?: at most this many entries of the list, offset?: entries skipped first (default 0; with `ids`, `kind`, `limit` or `offset` the reply adds `total`, the entries before paging)} → path (the document's file; null when automation read roots are in force and the file is outside them), layer tree (each node: id, name, kind, visible, locked, bounds, opacity (0–1) and opacityPct (0–100 %, the scale object.setProps takes) when below 1, blend when not Normal, fill, stroke and strokeWidth of objects and compound paths; a compound path's members have compoundMember: true instead of paint, which is the compound's), artboards, selection, history (a sliced tree reports childCount); without the scope options the reply is the whole layer tree", has_doc, doc_inspect), cmd!(query "document.node", "Inspect Object", [], None, "{id, summary?: compact summary, depth?: child levels in the summary (default all), childLimit?: children shown per node (default all; a level that shows fewer reports childCount)} → one object", has_doc, doc_node), cmd!(query "document.find", "Find Objects", [], None, "{name?: substring of the Layers panel name, kind?: panel label such as Group; Path; Type or Image (exact), text?: substring of type content, within?: [x, y, width, height] (an object's geometric bounds inside this rectangle, edges included; document points as document.inspect's bounds; layers never match this or the size filters), intersects?: [x, y, width, height] (bounds touching or overlapping it), minWidth?, maxWidth?, minHeight?, maxHeight?: pt (the bounds' size), minThickness?, maxThickness?: pt (the smaller of the bounds' width and height), minArea?, maxArea?: pt² (the bounds' width × height), minAnchors?, maxAnchors?: anchor points of a path (a compound path's members together; other objects never match), select?: bool (also select the matches, leaving out hidden and locked ones, those inside another match, and a compound path's members unless the compound path is isolated; Select > Reselect repeats it), limit?: max matches listed (default 100, 0 counts only)} → {matches: [{id, name, kind, path}], total, selected (with select: the count selected)}; at least one filter, all of them matching", has_doc, doc_find), cmd!( @@ -50,7 +50,11 @@ pub fn specs() -> Vec { has_doc, doc_compare ), - cmd!(query "document.json", "Document JSON", [], None, "{} → complete document model", has_doc, |s, _| Ok(serde_json::to_value(&*s.doc()?.doc).unwrap_or(Value::Null))), + cmd!(query "document.json", "Document JSON", [], None, "{} → complete document model (with automation read roots, a linked or placed file outside them is named by its file name alone, without its relative path, size or date, and so is a data set's image value outside them; a folder outside them is left out of export_settings)", has_doc, |s, _| { + let st = s.doc()?; + let d = super::links::with_hidden_paths_named(&st.doc, super::links::LinkBase::of(st).doc); + Ok(serde_json::to_value(&*d).unwrap_or(Value::Null)) + }), cmd!( "document.setUnits", "Units", diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index a134eb1c55..effa917d8e 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -28,7 +28,9 @@ use std::sync::Arc; use serde_json::{Value, json}; use vectorcraft_doc::links::{Align, Preserve, hash_bytes}; -use vectorcraft_doc::{Appearance, Document, ImageBlob, ImageObject, LinkInfo, Node, NodeId, NodeKind, PageBox, PlacedDocument, PlacementOptions}; +use vectorcraft_doc::{ + Appearance, DataValue, Document, ImageBlob, ImageObject, LinkInfo, Node, NodeId, NodeKind, PageBox, PlacedDocument, PlacementOptions, +}; use vectorcraft_geom::{Affine, PathData, Rect, shapes}; use super::fileio::{self, RasterImage, absolute_path, file_created, file_stamp, read_file, write_file}; @@ -1195,6 +1197,48 @@ pub(crate) fn with_found_paths(d: &mut Document, base: LinkBase) { }); } +/// `d`, a document saved at `doc_path`, as a reply to the caller asking now may show it: without +/// read roots in force, `d` itself; with them, a copy in which each link [`path_shown`] does not +/// allow names its file alone, without its relative path or the size and date kept of its file, +/// and each data set's image value it does not allow names its file alone too. The folder Export +/// for Screens last wrote to is left out of the export settings when it is outside the read roots. +/// Links in the layers, symbols, graph designs, patterns, the pattern being edited as it was +/// before and opacity masks are all checked. +pub(crate) fn with_hidden_paths_named<'a>(d: &'a Document, doc_path: Option<&str>) -> Cow<'a, Document> { + if crate::file_access::current().is_none() { + return Cow::Borrowed(d); + } + let mut d = d.clone(); + // The pattern being edited as it was before is checked as one more pattern, then put back. + let original = d.pattern_edit.as_mut().and_then(|e| e.original.take()); + let edited = original.is_some(); + d.patterns.extend(original); + d.update_links_where( + |l| !path_shown(&l.path, doc_path), + |l| { + let name = l.name().to_string(); + *l = LinkInfo { path: name, relative: None, modified: None, size: None, ..l.clone() }; + }, + ); + if edited && let (Some(def), Some(e)) = (d.patterns.pop(), d.pattern_edit.as_mut()) { + e.original = Some(def); + } + if d.export_settings.get("folder").and_then(Value::as_str).is_some_and(|f| crate::file_access::check_read(f).is_err()) { + d.export_settings.remove("folder"); + } + let hidden = |v: &DataValue| matches!(v, DataValue::Image(path) if !path_shown(path, doc_path)); + if d.variables.datasets.iter().any(|set| set.values.values().any(hidden)) { + for v in d.variables.datasets.iter_mut().flat_map(|set| set.values.values_mut()) { + if hidden(&*v) + && let DataValue::Image(path) = v + { + *path = LinkInfo::new(path.as_str()).name().to_string(); + } + } + } + Cow::Owned(d) +} + // ---------- commands ---------- fn check(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index 9bad62a6c8..05cc8fe77e 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -287,7 +287,8 @@ fn document_base(s: &Session) -> Value { "title": st.title(), // The File Info title (`file.info`), where `title` comes from the file name once the document has one. "fileInfoTitle": d.title, - "path": st.path, + // Null for a confined caller when the file is outside its read roots. + "path": st.path.as_deref().filter(|p| crate::file_access::check_read(p).is_ok()), "dirty": st.is_dirty(), "revision": st.revision, "units": d.units.label(), diff --git a/crates/engine/src/tests/placed_document.rs b/crates/engine/src/tests/placed_document.rs index e7d3c7cd7a..3ec0310430 100644 --- a/crates/engine/src/tests/placed_document.rs +++ b/crates/engine/src/tests/placed_document.rs @@ -1084,6 +1084,58 @@ fn link_info_leaves_out_a_placed_documents_path_outside_the_read_roots() { assert_eq!((&i["link"], &i["path"]), (&json!(src), &json!(src)), "unconfined: {i}"); } +/// `document.inspect` and `document.json` for a confined caller: the document's file, a placed +/// document and a linked image outside the read roots are not named by path, and the document +/// keeps its links. +#[test] +fn document_replies_leave_out_paths_outside_the_read_roots() { + let away = Folder::new("doc-lrr-away"); + let home = Folder::new("doc-lrr-home"); + let src = away.file("logo.vectorcraft"); + source(&src, 100.0, 50.0, RED, 0); + let pic = away.file("photo.png"); + write(&pic, &crate::tests_links::png(60, 30, BLUE)); + let mut s = session(); + let id = place(&mut s, &src); + let r = s.execute("file.place", &json!({"path": pic, "at": [50, 50]})).unwrap(); + assert_eq!(r["linked"], true, "{r}"); + s.execute("variable.define", &json!({"name": "Photo", "kind": "image"})).unwrap(); + s.execute("dataset.new", &json!({"name": "Row", "values": {"Photo": pic}})).unwrap(); + let image = r["ids"][0].as_u64().unwrap(); + let poster = away.file("poster.vectorcraft"); + save(&mut s, &poster); + // The folder Export for Screens wrote to, and a pattern of the linked image being edited: its + // definition before editing is kept beside the tile art. + let out = away.file("out"); + s.execute("document.exportForScreens", &json!({"folder": out, "formats": [{"format": "svg"}]})).unwrap(); + let pattern = s.execute("object.pattern.make", &json!({"ids": [image], "edit": false})).unwrap()["name"].clone(); + s.execute("object.pattern.edit", &json!({"name": pattern})).unwrap(); + assert!(s.doc().unwrap().doc.pattern_edit.as_ref().is_some_and(|e| e.original.is_some())); + let ask = |s: &mut Session, root: &std::path::Path, cmd: &str| { + let roots = crate::file_access::AutomationRoots::new(&[root], &[]).unwrap().unwrap(); + crate::file_access::confine(Some(&roots), || s.execute(cmd, &json!({}))).unwrap() + }; + let inspect = ask(&mut s, &home.0, "document.inspect"); + assert!(!inspect.to_string().contains("doc-lrr-away"), "{inspect}"); + assert!(inspect["path"].is_null() && inspect["uid"].is_u64(), "{inspect}"); + let model = ask(&mut s, &home.0, "document.json").to_string(); + assert!(!model.contains("doc-lrr-away"), "{model}"); + for name in ["logo.vectorcraft", "photo.png"] { + assert!(model.contains(&format!("\"path\":\"{name}\"")), "{name}: {model}"); + } + assert!(model.contains("{\"image\":\"photo.png\"}"), "{model}"); + // The document keeps its links, and a caller whose read roots hold the files sees every path. + assert_eq!(placed(&s, id).link.path, src); + for model in [ask(&mut s, &away.0, "document.json"), s.execute("document.json", &json!({})).unwrap()] { + let model = model.to_string(); + for path in [&src, &pic, &out] { + assert!(model.contains(&json!(path).to_string()), "{path}: {model}"); + } + } + assert_eq!(ask(&mut s, &away.0, "document.inspect")["path"], json!(poster)); + assert_eq!(s.execute("document.inspect", &json!({})).unwrap()["path"], json!(poster)); +} + /// Save As reads its defaults from the catalog; using them must retain current editable links. #[test] fn native_save_catalog_defaults_keep_placed_documents_editable() { diff --git a/docs/mcp.md b/docs/mcp.md index aeed51157a..8a5a05b9be 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -158,6 +158,11 @@ 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. +- `document.inspect` (`inspect_document`) gives `path` as null when the document's file is outside the read roots. + `document.json` names a linked image or placed file outside them by its file name alone in the link's `path`, and + leaves out the link's `relative`, `size` and `modified`. It also names a data set's image value outside them by + its file name alone, and leaves out the `folder` of `export_settings` (the settings Export for Screens last used) + when that folder is outside them. The document itself 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 diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 5e0cf03a3a..b5fdf7e26b 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -760,6 +760,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - 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`). - 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`). + - Document replies under `--automation-read-root`: `document.inspect` gives `path` as null for a document file outside the read roots, and `document.json` names a linked image or placed file outside them by its file name alone, without its relative path, size or date, and a data set's image value outside them by its file name, and leaves an Export for Screens folder outside them out of `export_settings`; the document is not changed. `crates/engine/src/tests/placed_document.rs` (`document_replies_leave_out_paths_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`. - 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`. From 2d66758ce480eec4b667e52489bf9171ef9bfd68 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 09:19:15 -0800 Subject: [PATCH 2/2] MCP: document.json checks brush art and relative link paths against the read roots Under --automation-read-root, document.json names a linked image or placed file in a stored brush's art outside the read roots by its file name alone, as it does in the layers. A stored brush entry that does not read as a brush is left out of the reply, and a stored brush library that is not a list becomes an empty one. A link whose file is inside the read roots loses its stored relative path when that path leads outside them, from the folder of the file the document was read from or from the document's own folder. --- crates/engine/src/cmd/edit.rs | 4 +- crates/engine/src/cmd/links.rs | 42 ++++++++++++++++++--- crates/engine/src/tests/placed_document.rs | 44 ++++++++++++++++++++++ docs/mcp.md | 8 ++-- docs/target-app-parity.md | 2 +- 5 files changed, 88 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 8b1e147625..8c86bbd777 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -50,9 +50,9 @@ pub fn specs() -> Vec { has_doc, doc_compare ), - cmd!(query "document.json", "Document JSON", [], None, "{} → complete document model (with automation read roots, a linked or placed file outside them is named by its file name alone, without its relative path, size or date, and so is a data set's image value outside them; a folder outside them is left out of export_settings)", has_doc, |s, _| { + cmd!(query "document.json", "Document JSON", [], None, "{} → complete document model (with automation read roots, a linked or placed file outside them is named by its file name alone, without its relative path, size or date, in brush art too, and so is a data set's image value outside them; a relative path that leads outside them is left out; a stored brush entry that does not read as a brush is left out; a folder outside them is left out of export_settings)", has_doc, |s, _| { let st = s.doc()?; - let d = super::links::with_hidden_paths_named(&st.doc, super::links::LinkBase::of(st).doc); + let d = super::links::with_hidden_paths_named(&st.doc, super::links::LinkBase::of(st)); Ok(serde_json::to_value(&*d).unwrap_or(Value::Null)) }), cmd!( diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index effa917d8e..0f2d6ea1e7 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -1202,27 +1202,57 @@ pub(crate) fn with_found_paths(d: &mut Document, base: LinkBase) { /// allow names its file alone, without its relative path or the size and date kept of its file, /// and each data set's image value it does not allow names its file alone too. The folder Export /// for Screens last wrote to is left out of the export settings when it is outside the read roots. -/// Links in the layers, symbols, graph designs, patterns, the pattern being edited as it was -/// before and opacity masks are all checked. -pub(crate) fn with_hidden_paths_named<'a>(d: &'a Document, doc_path: Option<&str>) -> Cow<'a, Document> { +/// A link whose path is allowed but whose relative path leads outside the read roots loses its +/// relative path. Links in the layers, symbols, graph designs, patterns, the pattern being edited +/// as it was before, opacity masks and the art of the stored brushes are all checked. A stored +/// brush entry that does not read as a brush is left out of the copy, since its links cannot be +/// checked, and a stored brush library that is not a list becomes an empty one. +pub(crate) fn with_hidden_paths_named<'a>(d: &'a Document, base: LinkBase) -> Cow<'a, Document> { if crate::file_access::current().is_none() { return Cow::Borrowed(d); } + let doc_path = base.doc; + // A stored relative path is followed from the folder of the file the document was read from, + // and a link is also looked for from the document's own folder: it is shown only when both + // places it names are inside the read roots. + let relative_hidden = |r: &str| [base.relative, base.doc].into_iter().any(|from| !path_shown(r, from)); let mut d = d.clone(); // The pattern being edited as it was before is checked as one more pattern, then put back. let original = d.pattern_edit.as_mut().and_then(|e| e.original.take()); let edited = original.is_some(); d.patterns.extend(original); + // The art of each stored brush is checked as more layers, then put back. + let stored = d.unknown.get(vectorcraft_brush::DOC_KEY).map(|v| v.as_array().cloned().unwrap_or_default()); + let mut brushes: Vec = + stored.iter().flatten().filter_map(|raw| vectorcraft_brush::Brush::from_json(raw.clone()).ok()).collect(); + let layers = d.layers.len(); + d.layers.extend(brushes.iter().flat_map(|b| b.art()).map(|n| Arc::new(n.clone()))); d.update_links_where( - |l| !path_shown(&l.path, doc_path), + |l| !path_shown(&l.path, base.doc) || l.relative.as_deref().is_some_and(relative_hidden), |l| { - let name = l.name().to_string(); - *l = LinkInfo { path: name, relative: None, modified: None, size: None, ..l.clone() }; + if path_shown(&l.path, base.doc) { + l.relative = None; + } else { + let name = l.name().to_string(); + *l = LinkInfo { path: name, relative: None, modified: None, size: None, ..l.clone() }; + } }, ); + let mut checked = d.layers.split_off(layers).into_iter(); if edited && let (Some(def), Some(e)) = (d.patterns.pop(), d.pattern_edit.as_mut()) { e.original = Some(def); } + if stored.is_some() { + // Each brush is written as it reads, so fields it does not read are left out too. + let mut kept = vec![]; + for b in &mut brushes { + for (slot, art) in b.art_mut().into_iter().zip(&mut checked) { + *slot = Arc::unwrap_or_clone(art); + } + kept.extend(serde_json::to_value(&*b).ok()); + } + d.unknown.insert(vectorcraft_brush::DOC_KEY.into(), Value::Array(kept)); + } if d.export_settings.get("folder").and_then(Value::as_str).is_some_and(|f| crate::file_access::check_read(f).is_err()) { d.export_settings.remove("folder"); } diff --git a/crates/engine/src/tests/placed_document.rs b/crates/engine/src/tests/placed_document.rs index 3ec0310430..1a6ee921f2 100644 --- a/crates/engine/src/tests/placed_document.rs +++ b/crates/engine/src/tests/placed_document.rs @@ -1136,6 +1136,50 @@ fn document_replies_leave_out_paths_outside_the_read_roots() { assert_eq!(s.execute("document.inspect", &json!({})).unwrap()["path"], json!(poster)); } +/// `document.json` for a confined caller: a linked image in a brush's art outside the read roots +/// is named by its file name, a stored brush entry that does not read as a brush is left out, and a +/// link inside them whose stored relative path leads outside them loses that relative path. +#[test] +fn document_json_checks_brush_art_and_relative_paths_against_the_read_roots() { + let away = Folder::new("doc-brr-away"); + let home = Folder::new("doc-brr-home"); + let pic = away.file("photo.png"); + write(&pic, &crate::tests_links::png(60, 30, BLUE)); + let near_pic = home.file("near.png"); + write(&near_pic, &crate::tests_links::png(20, 10, RED)); + let mut s = session(); + let r = s.execute("file.place", &json!({"path": pic, "at": [50, 50]})).unwrap(); + assert_eq!(r["linked"], true, "{r}"); + let image = r["ids"][0].as_u64().unwrap(); + let r = s.execute("file.place", &json!({"path": near_pic, "at": [150, 50]})).unwrap(); + assert_eq!(r["linked"], true, "{r}"); + s.execute("brush.new", &json!({"type": "art", "name": "Photo Band", "ids": [image]})).unwrap(); + save(&mut s, &home.file("poster.vectorcraft")); + { + let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let lib = d.unknown.get_mut(vectorcraft_brush::DOC_KEY).and_then(Value::as_array_mut).unwrap(); + lib.push(json!({"name": "Broken Band", "type": "unknown kind", "note": pic})); + d.update_links(|l| { + if l.name() == "near.png" { + l.relative = Some("../doc-brr-away/near.png".into()); + } + }); + } + let unconfined = s.execute("document.json", &json!({})).unwrap().to_string(); + for part in [json!(pic).to_string(), "Broken Band".into(), "../doc-brr-away/near.png".into()] { + assert!(unconfined.contains(&part), "{part}: {unconfined}"); + } + let roots = crate::file_access::AutomationRoots::new(&[&home.0], &[]).unwrap().unwrap(); + let model = crate::file_access::confine(Some(&roots), || s.execute("document.json", &json!({}))).unwrap(); + let text = model.to_string(); + assert!(!text.contains("doc-brr-away") && !text.contains("Broken Band"), "{text}"); + assert!(text.contains("\"path\":\"photo.png\"") && text.contains("Photo Band"), "{text}"); + assert!(text.contains(&json!(near_pic).to_string()), "{text}"); + // The document keeps its brushes and links. + let stored = s.doc().unwrap().doc.unknown.get(vectorcraft_brush::DOC_KEY).unwrap().to_string(); + assert!(stored.contains("Broken Band") && stored.contains(&json!(pic).to_string()), "{stored}"); +} + /// Save As reads its defaults from the catalog; using them must retain current editable links. #[test] fn native_save_catalog_defaults_keep_placed_documents_editable() { diff --git a/docs/mcp.md b/docs/mcp.md index 47f425482f..aee76d3710 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -172,9 +172,11 @@ the suite) keep the agent to the folders you give it: read roots, and the preset stays. The preferences themselves are not changed. - `document.inspect` (`inspect_document`) gives `path` as null when the document's file is outside the read roots. `document.json` names a linked image or placed file outside them by its file name alone in the link's `path`, and - leaves out the link's `relative`, `size` and `modified`. It also names a data set's image value outside them by - its file name alone, and leaves out the `folder` of `export_settings` (the settings Export for Screens last used) - when that folder is outside them. The document itself is not changed. + leaves out the link's `relative`, `size` and `modified`. This covers the art of the brushes in `unknown.brushes` + too. A link whose file is inside them keeps its `path` but leaves out a `relative` that leads outside them. A + stored brush entry that does not read as a brush is left out of `unknown.brushes`. `document.json` also names a data set's image + value outside them by its file name alone, and leaves out the `folder` of `export_settings` (the settings Export + for Screens last used) when that folder is outside them. The document itself 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 diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d4a501dca5..df8e93326c 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -764,7 +764,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Saves, Export for Screens and Package under `--automation-read-root`: a save without a path leaves the document's own file out of its reply, and a save without a path known leaves out the suggested folder, when they are outside the read roots; Export for Screens into an export preset's folder outside them lists its files relative to that folder and names that folder by its name in an error, and the export settings and saved presets leave out a folder outside them when read or exported; Package's report and warnings, and Document Info's linked images, name a file outside them by its file name. A path the client gives stays in the reply. `crates/engine/src/tests/save.rs`, `crates/engine/src/cmd/fileio/tests_screens.rs`, `crates/engine/src/tests/package.rs`, `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`). - 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`). - - Document replies under `--automation-read-root`: `document.inspect` gives `path` as null for a document file outside the read roots, and `document.json` names a linked image or placed file outside them by its file name alone, without its relative path, size or date, and a data set's image value outside them by its file name, and leaves an Export for Screens folder outside them out of `export_settings`; the document is not changed. `crates/engine/src/tests/placed_document.rs` (`document_replies_leave_out_paths_outside_the_read_roots`). + - Document replies under `--automation-read-root`: `document.inspect` gives `path` as null for a document file outside the read roots, and `document.json` names a linked image or placed file outside them by its file name alone, without its relative path, size or date, brush art included, leaves out a relative path that leads outside them and a stored brush entry that does not read as a brush, names a data set's image value outside them by its file name, and leaves an Export for Screens folder outside them out of `export_settings`; the document is not changed. `crates/engine/src/tests/placed_document.rs` (`document_replies_leave_out_paths_outside_the_read_roots`, `document_json_checks_brush_art_and_relative_paths_against_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`. - 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`.