Automation: --automation-read-root and --automation-write-root may each be given more than once (#3546)

Squashed from #3631 onto main.
This commit is contained in:
bflatastic
2026-10-22 21:36:15 +07:00
parent 49d69d48e5
commit 1b7cfc8bee
39 changed files with 301 additions and 111 deletions
+11 -12
View File
@@ -1,7 +1,7 @@
//! `vectorcraft-cli`: VectorCraft from the command line.
//!
//! ```text
//! vectorcraft-cli mcp [--connect 127.0.0.1:7979 | --headless] [--automation-read-root DIR] [--automation-write-root DIR]
//! vectorcraft-cli mcp [--connect 127.0.0.1:7979 | --headless] [--automation-read-root DIR]... [--automation-write-root DIR]...
//! vectorcraft-cli run [--in FILE] [--cmd id [--params '{json}'] | --commands FILE|-]... [--export out.svg]... [--scale 2]
//! vectorcraft-cli commands
//! vectorcraft-cli convert IN OUT [--scale 2] [--artboard 0 | --range 1-3,5] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data] [--ai-version 7|pdf] [--hidden-objects layer|omit|visible] [--transparent-images flatten|omit]
@@ -68,13 +68,14 @@ const USAGE: &str = "\
vectorcraft-cli — VectorCraft automation
USAGE:
vectorcraft-cli mcp [--connect ADDR | --headless] [--automation-read-root DIR] [--automation-write-root DIR]
vectorcraft-cli mcp [--connect ADDR | --headless] [--automation-read-root DIR]... [--automation-write-root DIR]...
Run the MCP server on stdio. Default: connect to a running app at 127.0.0.1:7979
(vectorcraft --control 7979), falling back to a headless in-process session.
--automation-read-root / --automation-write-root confine the files the agent's commands read
(open, place, relink, library loads…) and write (save, export, package, library saves…) to
DIR, links followed; a root left out grants none of its access. They imply --headless: a
running app enforces its own (vectorcraft --control PORT --automation-read-root DIR …).
DIR, links followed; each may be given more than once, a path inside any of its folders being
allowed, and a kind left out grants none of its access. They imply --headless: a running app
enforces its own (vectorcraft --control PORT --automation-read-root DIR …).
vectorcraft-cli run [--in FILE] [--cmd ID [--params JSON] | --commands FILE|-]... [--export FILE]... [--scale N]
Headless batch: open FILE (any readable format) or start a new document, run commands in
@@ -181,7 +182,7 @@ fn with_stores(mut h: Headless) -> Headless {
fn mcp(args: &[String]) -> Result<(), String> {
let mut connect: Option<String> = None;
let mut headless = false;
let (mut read_root, mut write_root): (Option<String>, Option<String>) = (None, None);
let (mut read_roots, mut write_roots): (Vec<String>, Vec<String>) = (vec![], vec![]);
let mut it = args.iter();
while let Some(a) = it.next() {
// The roots also come as `--flag=DIR`, as the other Craft apps take them.
@@ -194,10 +195,7 @@ fn mcp(args: &[String]) -> Result<(), String> {
"--headless" => headless = true,
"--automation-read-root" | "--automation-write-root" => {
let dir = inline.or_else(|| it.next().cloned()).ok_or_else(|| format!("{flag} needs a folder"))?;
let slot = if flag == "--automation-read-root" { &mut read_root } else { &mut write_root };
if slot.replace(dir).is_some() {
return Err(format!("{flag} is given twice: it takes one folder"));
}
if flag == "--automation-read-root" { read_roots.push(dir) } else { write_roots.push(dir) }
}
other => return Err(format!("unknown mcp option `{other}`")),
}
@@ -205,7 +203,9 @@ fn mcp(args: &[String]) -> Result<(), String> {
if headless && connect.is_some() {
return Err("use either --connect or --headless".into());
}
let roots = AutomationRoots::new(read_root.as_deref().map(Path::new), write_root.as_deref().map(Path::new))?;
let read: Vec<&Path> = read_roots.iter().map(Path::new).collect();
let write: Vec<&Path> = write_roots.iter().map(Path::new).collect();
let roots = AutomationRoots::new(&read, &write)?;
if roots.is_some() && connect.is_some() {
return Err("--automation-read-root and --automation-write-root confine the headless server; a running app enforces its own roots: start it with `vectorcraft --control <port> --automation-read-root <dir> --automation-write-root <dir>`"
.into());
@@ -213,8 +213,7 @@ fn mcp(args: &[String]) -> Result<(), String> {
let backend: Box<dyn Backend> = if let Some(roots) = roots {
// Every thread of this process, not only the server's: nothing reaches past the roots.
file_access::confine_process(roots.clone());
let show = |root: Option<&Path>| root.map_or_else(|| "none".to_string(), |r| r.display().to_string());
eprintln!("vectorcraft-cli: files confined: read root {}, write root {}", show(roots.read_root()), show(roots.write_root()));
eprintln!("vectorcraft-cli: files confined: {}", roots.describe());
Box::new(Headless::with_document().with_automation_roots(Some(roots)))
} else if headless {
Box::new(with_stores(Headless::with_document()))
+53 -1
View File
@@ -244,6 +244,58 @@ fn mcp_automation_roots_in_a_folder_an_earlier_run_left() {
automation_roots(&empty_dir(dir).join("roots"));
}
/// #3546: each root flag may be given more than once: a path inside any of the read roots opens,
/// one inside any of the write roots is written, and everything else, a write root's sibling read
/// root included, is refused.
#[test]
fn mcp_automation_roots_take_several_folders() {
let base = tmp("several-roots");
for d in ["in", "in2", "out", "out2", "outside"] {
std::fs::create_dir_all(base.join(d)).unwrap();
}
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="30"><rect width="20" height="10"/></svg>"#;
for f in ["in/a.svg", "in2/b.svg", "outside/c.svg"] {
std::fs::write(base.join(f), svg).unwrap();
}
let dir = |d: &str| base.join(d).to_string_lossy().into_owned();
let file = |d: &str, f: &str| base.join(d).join(f).to_string_lossy().into_owned();
let mut child = Command::new(BIN)
.args(["mcp", "--automation-read-root", &dir("in"), &format!("--automation-read-root={}", dir("in2"))])
.args(["--automation-write-root", &dir("out"), "--automation-write-root", &dir("out2")])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let call = |id: u64, name: &str, args: Value| json!({"jsonrpc":"2.0","id":id,"method":"tools/call","params":{"name":name,"arguments":args}});
let calls = [
(call(1, "open_file", json!({"path": file("in", "a.svg")})), false),
(call(2, "open_file", json!({"path": file("in2", "b.svg")})), false),
(call(3, "open_file", json!({"path": file("outside", "c.svg")})), true),
(call(4, "export", json!({"path": file("out", "a.png")})), false),
(call(5, "export", json!({"path": file("out2", "b.png")})), false),
(call(6, "export", json!({"path": file("outside", "c.png")})), true),
(call(7, "export", json!({"path": file("in2", "d.png")})), true),
(call(8, "open_file", json!({"path": file("out2", "b.png")})), true),
];
{
let mut stdin = child.stdin.take().unwrap();
for (m, _) in &calls {
writeln!(stdin, "{m}").unwrap();
}
}
let out = child.wait_with_output().unwrap();
assert!(out.status.success());
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("files confined: read ") && stderr.contains("; write "), "{stderr}");
let replies: Vec<Value> = String::from_utf8(out.stdout).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect();
let errors: Vec<bool> = replies.iter().map(|r| r["result"]["isError"] == true).collect();
assert_eq!(errors, calls.iter().map(|(_, e)| *e).collect::<Vec<_>>(), "{replies:?}");
assert!(replies[2]["result"]["content"][0]["text"].as_str().unwrap().contains("outside the read roots"), "{}", replies[2]);
assert!(base.join("out/a.png").is_file() && base.join("out2/b.png").is_file());
assert!(!base.join("outside/c.png").exists() && !base.join("in2/d.png").exists());
}
fn automation_roots(base: &std::path::Path) {
for d in ["in", "out", "outside"] {
std::fs::create_dir_all(base.join(d)).unwrap();
@@ -267,7 +319,7 @@ fn automation_roots(base: &std::path::Path) {
assert!(!out.status.success() && err.contains(why), "{args:?}: {err}");
};
fails(&["mcp", "--automation-read-root", &dir("missing")], "cannot open the automation read root");
fails(&["mcp", "--automation-read-root", &dir("in"), "--automation-read-root", &dir("out")], "given twice");
fails(&["mcp", "--automation-read-root", &dir("in"), "--automation-read-root", &dir("missing")], "cannot open the automation read root");
fails(&["mcp", "--automation-write-root"], "needs a folder");
fails(&["mcp", "--connect", "127.0.0.1:1", &format!("--automation-write-root={}", dir("out"))], "a running app enforces its own roots");