mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 18:56:55 -06:00
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:
@@ -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()))
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user