mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-25 19:06:47 -06:00
- `figcraft-cli mcp --automation-read-root DIR --automation-write-root DIR` (each repeatable, `--flag=DIR` works too). When either is given, every file an agent's tool call reads must lie inside a read root or a write root, and every file it writes inside a write root. Anything else is an ordinary tool error (`automation path rejected: … is outside the folders that may be written: …`); nothing is written and the server keeps serving. Without the flags nothing changes.
- The desktop app takes the same flags (`figcraft --control PORT --automation-read-root DIR …`) and confines every control request and every frame of every window to them, so `figcraft-cli mcp --attach` drives a confined app. `--attach` together with the flags is a usage error, because the app runs the commands and only the app can confine them.
- New engine module `crates/engine/src/file_access.rs`: `Roots` (resolution and comparison), a thread-scoped `confined(roots, f)`, and the file primitives (`read`, `read_capped`, `open`, `metadata`, `read_dir`, `write` (atomic), `create_dir_all`, `exists`, `is_dir`). Every engine file access now goes through it. `crates/engine/clippy.toml` denies `std::fs::*`, `File::open/create`, `OpenOptions::open`, `Path::exists/is_file/is_dir/metadata/read_dir/canonicalize` and `figcraft_format::{write_file_atomic, save_file, load_file}` everywhere else in the engine, so a command added later is confined without a check of its own.
- `ui.screenshot` and `ui.render` return the PNG as base64 `data` when no `path` is given (before, `ui.screenshot` wrote `figcraft-screenshot.png` in the working directory and `ui.render` required a path). The attached MCP backend uses that, so it no longer writes and deletes temporary PNGs, and a confined app needs no exemption for them.
- `initialize`'s `instructions` name the folders the agent may read and write.
- `figcraft-cli` options may be given more than once (`Args::values`); single-valued options keep the last value, as before.
- Docs: `docs/mcp.md` › Confining file access (options, semantics, coverage, resolution, limits), `docs/control-protocol.md` › Confining file access and the optional `path` of `ui.screenshot` / `ui.render`, `docs/cli.md`, `figcraft-cli --help`, and the Automation line plus a revision row in `docs/target-app-parity.md`.
Fixes #715, fixes #481.
73 lines
3.3 KiB
Rust
73 lines
3.3 KiB
Rust
//! Accepting a library update that changes only a paint style refreshes the layers linked to it.
|
|
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::disallowed_methods)]
|
|
|
|
use figcraft_doc::{NodeId, StyleKind};
|
|
use figcraft_engine::Session;
|
|
use serde_json::{Value, json};
|
|
|
|
fn run(s: &mut Session, command: &str, params: Value) -> Value {
|
|
s.execute(command, ¶ms).unwrap()
|
|
}
|
|
|
|
fn layer_paint(s: &Session, id: NodeId) -> String {
|
|
s.doc().unwrap().node(id).unwrap().fills[0].solid_color().unwrap().to_hex()
|
|
}
|
|
|
|
fn style_paint(s: &Session, id: &str) -> String {
|
|
match &s.doc().unwrap().style(id).unwrap().kind {
|
|
StyleKind::Paint { paints } => paints[0].solid_color().unwrap().to_hex(),
|
|
_ => panic!("expected paint style"),
|
|
}
|
|
}
|
|
|
|
struct Fixture(std::path::PathBuf);
|
|
|
|
impl Drop for Fixture {
|
|
fn drop(&mut self) {
|
|
let _ = std::fs::remove_dir_all(&self.0);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn accepted_library_paint_reaches_linked_layer() {
|
|
let fixture = Fixture(std::env::temp_dir().join(format!("figcraft-style-accept-{}", std::process::id())));
|
|
let _ = std::fs::remove_dir_all(&fixture.0);
|
|
std::fs::create_dir(&fixture.0).unwrap();
|
|
let path = fixture.0.join("library.figcraft");
|
|
let mut publisher = Session::new();
|
|
run(&mut publisher, "style.create", json!({"type":"paint","name":"Brand","value":"#0000ff"}));
|
|
let publication = run(&mut publisher, "library.publish", json!({}));
|
|
assert_eq!(publication["components"], 0);
|
|
assert_eq!(publication["styles"], 1);
|
|
run(&mut publisher, "file.saveAs", json!({"path":path}));
|
|
let mut consumer = Session::new();
|
|
let rectangle = run(&mut consumer, "shape.rectangle", json!({"width":40,"height":40}));
|
|
let layer = NodeId(rectangle["id"].as_u64().unwrap());
|
|
run(&mut consumer, "library.add", json!({"path":path}));
|
|
let applied = run(&mut consumer, "library.applyStyle", json!({"library":publication["id"],"style":"Brand","id":layer}));
|
|
let style = applied["style"].as_str().unwrap();
|
|
assert_eq!(layer_paint(&consumer, layer), "0000FF");
|
|
run(&mut publisher, "style.update", json!({"style":"Brand","value":"#00ff00"}));
|
|
run(&mut publisher, "library.publish", json!({}));
|
|
run(&mut publisher, "file.save", json!({}));
|
|
assert_eq!(run(&mut consumer, "library.updates", json!({}))["count"], 1);
|
|
let accepted = run(&mut consumer, "library.acceptUpdates", json!({}));
|
|
assert_eq!(accepted["styles"], 1);
|
|
assert_eq!(accepted["components"], 0);
|
|
assert_eq!(accepted["variables"], 0);
|
|
assert_eq!(style_paint(&consumer, style), "00FF00");
|
|
assert_eq!(consumer.doc().unwrap().node(layer).unwrap().styles.fill.as_deref(), Some(style));
|
|
// Captured before any reapply so a later action can't hide a missed refresh.
|
|
let after_accept = layer_paint(&consumer, layer);
|
|
assert_eq!(after_accept, "00FF00");
|
|
assert_eq!(run(&mut consumer, "library.updates", json!({}))["count"], 0);
|
|
consumer.undo().unwrap();
|
|
assert_eq!(style_paint(&consumer, style), "0000FF");
|
|
assert_eq!(layer_paint(&consumer, layer), "0000FF");
|
|
consumer.redo().unwrap();
|
|
assert_eq!(style_paint(&consumer, style), "00FF00");
|
|
assert_eq!(layer_paint(&consumer, layer), "00FF00");
|
|
run(&mut consumer, "style.apply", json!({"style":style,"id":layer}));
|
|
assert_eq!(layer_paint(&consumer, layer), "00FF00");
|
|
}
|