From ef4c0aacfdc91c576180285380c7e666739b2321 Mon Sep 17 00:00:00 2001 From: saimskywalker <15120878+saimskywalker@users.noreply.github.com> Date: Sat, 31 Oct 2026 02:05:29 +0800 Subject: [PATCH 1/8] M8: commands confined to automation roots reach no server (#1095) The roots confined files only: a confined agent could still publish the open document to any server, host it on the network or join a session. The engine reaches the network only through the live link, which now refuses a call made while the roots confine it (hosting, joining, publishing, sharing, team libraries, plugin directories), and skips the background team library checks. The MCP and control protocol docs say so. --- crates/engine/src/assets/library_server.rs | 6 +++- crates/engine/src/automation/file_access.rs | 12 ++++++++ crates/engine/src/sharing/background.rs | 3 ++ crates/engine/src/sharing/background/tests.rs | 30 +++++++++++++++++++ crates/engine/src/sharing/live.rs | 3 ++ crates/engine/src/sharing/team.rs | 2 +- docs/control-protocol.md | 4 ++- docs/mcp.md | 5 ++++ 8 files changed, 62 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/assets/library_server.rs b/crates/engine/src/assets/library_server.rs index a55ea5858..eaa7fa490 100644 --- a/crates/engine/src/assets/library_server.rs +++ b/crates/engine/src/assets/library_server.rs @@ -213,7 +213,11 @@ impl Session { std::iter::once((l.path.clone(), LibraryReq::Fetch { have, usage: Some(usage(doc, &l.id)) })).chain(report) }) .collect(); - if let (false, Some(net)) = (jobs.is_empty(), self.live.link()) { + // Confined to automation roots, nothing reaches a server (#1095). + if jobs.is_empty() || crate::file_access::network_refused().is_some() { + return; + } + if let Some(net) = self.live.link() { net.library_background(jobs); } } diff --git a/crates/engine/src/automation/file_access.rs b/crates/engine/src/automation/file_access.rs index aeaa3c130..b40a92f79 100644 --- a/crates/engine/src/automation/file_access.rs +++ b/crates/engine/src/automation/file_access.rs @@ -218,6 +218,18 @@ pub fn current() -> Option> { ROOTS.with(|r| r.borrow().clone()) } +/// Why this thread's work may not reach the network now, if it may not (#1095): the automation +/// roots keep an agent to the folders it was given, so while they confine it nothing is published, +/// hosted, joined or fetched from a server either. The engine reaches the network only through the +/// live link (`crate::live::Lives`), which asks this first. +pub fn network_refused() -> Option { + current().map(|_| NETWORK_REFUSED.to_string()) +} + +/// What a confined command that would reach the network says. +pub const NETWORK_REFUSED: &str = + "automation roots are set: commands don't reach the network (publishing, live sessions, team libraries and plugin directories are off)"; + /// The path to use for `access` to `path`: resolved when confined, else as given. pub fn allow(path: impl AsRef, access: Access) -> Result { let path = path.as_ref(); diff --git a/crates/engine/src/sharing/background.rs b/crates/engine/src/sharing/background.rs index 1a29c2d19..5200bfd9d 100644 --- a/crates/engine/src/sharing/background.rs +++ b/crates/engine/src/sharing/background.rs @@ -128,6 +128,9 @@ impl Session { /// answered when it was an earlier one (what it carried then). pub(crate) fn net(&mut self, req: NetRequest) -> Result<(NetReply, Option)> { let Some(link) = self.live.link() else { return Err(EngineError::Other(req.unavailable().into())) }; + if let Some(why) = crate::file_access::network_refused() { + return Err(EngineError::Other(why)); + } if let Some(run) = self.background.run.as_mut() { let earlier = run.answers.iter().zip(run.used.iter_mut()).find(|((sent, _), used)| !**used && sent.stands_for(&req)); if let Some(((sent, reply), used)) = earlier { diff --git a/crates/engine/src/sharing/background/tests.rs b/crates/engine/src/sharing/background/tests.rs index f7a995cfd..34cbb7817 100644 --- a/crates/engine/src/sharing/background/tests.rs +++ b/crates/engine/src/sharing/background/tests.rs @@ -270,3 +270,33 @@ fn a_link_without_background_requests_is_waited_on() { let mut bare = Session::new(); assert!(bare.start_background("plugin.directory.list", json!({"server": SERVER})).unwrap_err().to_string().contains("desktop app")); } + +/// Confined to automation roots, commands reach no server (#1095): browsing a plugin directory, +/// hosting, joining and publishing are refused before the link is asked. Unconfined, the same +/// request reaches the server as before. +#[test] +fn commands_confined_to_automation_roots_reach_no_server() { + let dir = std::env::temp_dir().join(format!("figcraft-roots-network-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let roots = Arc::new(crate::file_access::Roots::new(&[], &[dir.to_string_lossy().into_owned()]).unwrap()); + let (mut s, wire) = session(false); + let refused = |r: Result| { + let e = r.unwrap_err().to_string(); + assert!(e.contains(crate::file_access::NETWORK_REFUSED), "{e}"); + }; + crate::file_access::confined(Some(&roots), || { + refused(s.execute("plugin.directory.list", &json!({"server": SERVER}))); + refused(s.execute("collab.host", &json!({}))); + refused(s.execute("collab.join", &json!({"link": LINK}))); + refused(s.execute("collab.publish", &json!({"server": SERVER}))); + }); + assert_eq!(wire.lock().unwrap().waited, 0, "the server was never asked"); + assert!(s.live.iter().next().is_none(), "no session was started"); + let _ = std::fs::remove_dir_all(&dir); + + let r = s.execute("plugin.directory.list", &json!({"server": SERVER})).unwrap(); + assert_eq!(r["plugins"][0]["id"], ID); + assert_eq!(wire.lock().unwrap().waited, 1); + let e = s.execute("collab.host", &json!({})).unwrap_err().to_string(); + assert!(!e.contains(crate::file_access::NETWORK_REFUSED), "unconfined, the link is asked: {e}"); +} diff --git a/crates/engine/src/sharing/live.rs b/crates/engine/src/sharing/live.rs index a0b98c631..45d56776c 100644 --- a/crates/engine/src/sharing/live.rs +++ b/crates/engine/src/sharing/live.rs @@ -274,6 +274,9 @@ impl Lives { /// session's id, which comes back with what it returned. fn start(&mut self, cmd: &str, start: impl FnOnce(&mut dyn LiveLink, SessionId) -> Result) -> Result<(SessionId, T)> { let link = self.link.as_deref_mut().ok_or_else(|| unavailable(cmd))?; + if let Some(why) = crate::file_access::network_refused() { + return Err(EngineError::Disabled(cmd.to_string(), why)); + } let id = self.last.saturating_add(1); let out = start(link, id)?; self.last = id; diff --git a/crates/engine/src/sharing/team.rs b/crates/engine/src/sharing/team.rs index 91b3891c0..2dd28eeeb 100644 --- a/crates/engine/src/sharing/team.rs +++ b/crates/engine/src/sharing/team.rs @@ -99,7 +99,7 @@ impl Session { pub(crate) fn team_refresh(&mut self) -> Result<()> { let server = plain_server(&self.prefs.publish_server); let public = option_env!("FIGCRAFT_DEFAULT_SERVER").is_none() && same_server(&server, DEFAULT_SERVER); - if server.is_empty() || public || self.live.link().is_none() { + if server.is_empty() || public || self.live.link().is_none() || crate::file_access::network_refused().is_some() { self.live.team = None; return Ok(()); } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index efd5e2f88..f4f7e968f 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -89,7 +89,9 @@ read or write root and written only inside a write root; anything else is an ord resolved as `docs/mcp.md` › Confining file access describes. A root that isn't an existing folder stops the app before its window opens, and so does any option the app doesn't know (a mistyped flag such as `--automation-write-roots` exits with `unknown option`, never starts unconfined). -Without the flags nothing changes. The flags arrived in FigCraft 0.7.0: 0.6.0 and earlier ignore +Confined work reaches no server either: hosting, joining, publishing and sharing files, team +libraries on a server and plugin directories are refused (`automation roots are set: commands +don't reach the network …`). Without the flags nothing changes. The flags arrived in FigCraft 0.7.0: 0.6.0 and earlier ignore them and confine nothing. Not confined: opening the files given on the command line when the app starts and the documents diff --git a/docs/mcp.md b/docs/mcp.md index ec46f9ef9..b0f6b7518 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -93,6 +93,11 @@ Each may be given more than once, as `--flag DIR` or `--flag=DIR`: compared by whole names: `/work2/x` is not inside `/work`. On Windows the case comes from the disk (`C:\WORK\x` is inside `C:\work`), `\\?\` and UNC (`\\server\share\…`) paths are resolved like drive-letter ones, and new names that are devices (`CON`, `NUL`, `COM1`…) or streams (`a.png:x`) are refused. +- **The network:** a confined call reaches no server, so it can't send a document anywhere + either. Hosting, joining, publishing and sharing a file (`collab.host`, `collab.join`, + `collab.publish`, `collab.share` …), team libraries on a server and plugin directories are + refused with `automation roots are set: commands don't reach the network …`. The app started with + the flags confines its own window too, so its sharing is off as well. - Not confined: the app's own folders, which no tool names (preferences, Data Recovery copies, plugins installed from a plugin directory, the system fonts). - `--attach` with either flag is a usage error: the app runs the commands, so only the app can From f4e34bac7164c5089f533a858fe422661d88f23b Mon Sep 17 00:00:00 2001 From: saimskywalker <15120878+saimskywalker@users.noreply.github.com> Date: Sat, 31 Oct 2026 02:10:55 +0800 Subject: [PATCH 2/8] M4: editing a stroke's Dash or Gap keeps the rest of its dash pattern (#1681) The stroke settings show the first dash and gap only, and editing either wrote back [dash, gap], so an imported pattern like [4, 2, 1, 2] collapsed on the first edit. Each field now changes its own value and keeps the rest of the pattern. --- crates/ui-egui/src/dash_pattern_tests.rs | 55 +++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/panels/right/stroke.rs | 19 +++++++- 3 files changed, 74 insertions(+), 2 deletions(-) create mode 100644 crates/ui-egui/src/dash_pattern_tests.rs diff --git a/crates/ui-egui/src/dash_pattern_tests.rs b/crates/ui-egui/src/dash_pattern_tests.rs new file mode 100644 index 000000000..09a2b1759 --- /dev/null +++ b/crates/ui-egui/src/dash_pattern_tests.rs @@ -0,0 +1,55 @@ +//! The stroke settings' Dash and Gap fields edit the first pair of a dash pattern and keep the +//! rest of it (#1681): an imported `[4, 2, 1, 2]` used to collapse to `[dash, gap]` on the first edit. + +use egui::{Event, Id, Key, Modifiers}; +use figcraft_doc::NodeId; +use figcraft_engine::Session; +use serde_json::json; + +use crate::layers_drag_tests::Ui; + +/// A selected rectangle with a stroke dashed `dashes`, its advanced stroke settings shown. +fn scene(dashes: serde_json::Value) -> (Ui, NodeId) { + let mut s = Session::new(); + let r = NodeId(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 200, "height": 120})).unwrap()["id"].as_u64().unwrap()); + s.execute("node.set", &json!({"ids": [r], "props": {"strokes": ["#000000"], "strokeWeight": 2, "dashes": dashes}})).unwrap(); + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + let mut u = Ui::new(s); + u.ctx.data_mut(|d| d.insert_temp(Id::unique("stroke_adv"), true)); + u.frame(vec![]); + (u, r) +} + +fn dashes(u: &Ui, r: NodeId) -> Vec { + u.app.session.doc().unwrap().node(r).unwrap().stroke.dashes.clone() +} + +/// Type `text` into the number field `id` and press Enter. +fn type_into(u: &mut Ui, id: &str, text: &str) { + let at = u.ctx.read_response(Id::unique(id).with("field")).unwrap_or_else(|| panic!("{id} shows")).rect.center(); + u.button(at, true); + u.button(at, false); + u.frame(vec![]); + u.frame(vec![Event::Text(text.into())]); + u.frame(vec![ + Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }, + Event::Key { key: Key::Enter, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }, + ]); + u.frame(vec![]); +} + +#[test] +fn editing_the_dash_or_gap_keeps_the_rest_of_the_pattern() { + let (mut u, r) = scene(json!([4, 2, 1, 2])); + type_into(&mut u, "stroke_dash", "6"); + assert_eq!(dashes(&u, r), [6.0, 2.0, 1.0, 2.0]); + type_into(&mut u, "stroke_gap", "3"); + assert_eq!(dashes(&u, r), [6.0, 3.0, 1.0, 2.0]); +} + +#[test] +fn a_single_value_pattern_is_the_dash_and_the_gap() { + let (mut u, r) = scene(json!([5])); + type_into(&mut u, "stroke_gap", "2"); + assert_eq!(dashes(&u, r), [5.0, 2.0]); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 3464de0b3..3019c146d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -90,6 +90,8 @@ mod crop_image_tests; #[cfg(test)] mod cursors_tests; #[cfg(test)] +mod dash_pattern_tests; +#[cfg(test)] mod design_paste_tests; #[cfg(test)] mod dev_mode_tests; diff --git a/crates/ui-egui/src/panels/right/stroke.rs b/crates/ui-egui/src/panels/right/stroke.rs index ef7a3648d..c790878f7 100644 --- a/crates/ui-egui/src/panels/right/stroke.rs +++ b/crates/ui-egui/src/panels/right/stroke.rs @@ -187,6 +187,21 @@ fn brush_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], brush: Option