mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-14 01:53:57 +00:00
Merge remote-tracking branch 'upstream/main' into feat-4936-custom-dictionary
# Conflicts: # docs/gaps.md # docs/roadmap.md # docs/target-app-parity.md
This commit is contained in:
@@ -78,7 +78,7 @@ USAGE:
|
||||
Safe Mode is on. Plug-ins stay installed. `--safe-mode mcp` serves a headless session and
|
||||
does not take --connect.
|
||||
|
||||
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]... [--allow-plugin-grants]
|
||||
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
|
||||
@@ -86,8 +86,18 @@ USAGE:
|
||||
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 …).
|
||||
--allow-plugin-grants lets plugin.install's grant and allowHeavy take effect in this session,
|
||||
so whoever sends the commands can give a plug-in the permissions it lists, and a heavy budget,
|
||||
with nobody approving them. Without it they are refused, and a plug-in that lists permissions
|
||||
installs only when its hash is already recorded in grants.json, by a person's approval in the
|
||||
desktop app or by a grant accepted under this flag. A grant accepted under this flag stays
|
||||
recorded and counts as approved in later sessions. It implies --headless. It can't be used
|
||||
with --automation-read-root or --automation-write-root: a server confined to folders has no
|
||||
plug-in store, so it reads no grants.json, and a plug-in that lists permissions or asks for a
|
||||
heavy budget does not install there. With VECTORCRAFT_NO_PREFS set there is no store either,
|
||||
and a grant accepted under this flag lasts for that session only.
|
||||
|
||||
vectorcraft-cli run [--in FILE] [--cmd ID [--params JSON] | --commands FILE|-]... [--export FILE]... [--scale N] [--ppi N]
|
||||
vectorcraft-cli run [--in FILE] [--cmd ID [--params JSON] | --commands FILE|-]... [--export FILE]... [--scale N] [--ppi N] [--allow-plugin-grants]
|
||||
Headless batch: open FILE (any readable format) or start a new document, run commands in
|
||||
order, export each FILE in the format its extension picks (see Writable formats; a .ait FILE
|
||||
is a template, written as file.saveAsTemplate writes it). Raster exports use the document's
|
||||
@@ -97,6 +107,8 @@ USAGE:
|
||||
--cmd and --export steps: a JSON array of {\"command\": ID, \"params\": {...}} (or \"id\" for
|
||||
\"command\"), or one such object per line (JSON Lines), at most 64 MiB and 100000 steps.
|
||||
Errors name the step, counting from 0 as \"$N\" references do.
|
||||
--allow-plugin-grants lets plugin.install's grant and allowHeavy take effect in this run, as
|
||||
for mcp.
|
||||
|
||||
vectorcraft-cli commands
|
||||
Print the command catalogue as JSON.
|
||||
@@ -235,6 +247,14 @@ fn with_stores(mut h: Headless) -> Headless {
|
||||
h
|
||||
}
|
||||
|
||||
/// Makes `h`'s `plugin.install` accept `grant` and `allowHeavy` when `allow` (`--allow-plugin-grants`).
|
||||
fn accepting_grants(mut h: Headless, allow: bool) -> Headless {
|
||||
if allow {
|
||||
h.session.allow_plugin_grants_from_requests();
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
/// `mcp`. With `--safe-mode` (`safe_mode`) it serves a headless session: a running app loads its own
|
||||
/// plug-ins, so `--connect` is refused and the default does not connect.
|
||||
fn mcp(args: &[String], safe_mode: bool) -> Result<(), String> {
|
||||
@@ -243,6 +263,7 @@ fn mcp(args: &[String], safe_mode: bool) -> Result<(), String> {
|
||||
}
|
||||
let mut connect: Option<String> = None;
|
||||
let mut headless = false;
|
||||
let mut allow_grants = false;
|
||||
let (mut read_roots, mut write_roots): (Vec<String>, Vec<String>) = (vec![], vec![]);
|
||||
let mut it = args.iter();
|
||||
while let Some(a) = it.next() {
|
||||
@@ -254,6 +275,7 @@ fn mcp(args: &[String], safe_mode: bool) -> Result<(), String> {
|
||||
match flag {
|
||||
"--connect" => connect = Some(it.next().cloned().ok_or("--connect needs an address")?),
|
||||
"--headless" => headless = true,
|
||||
"--allow-plugin-grants" => allow_grants = true,
|
||||
"--automation-read-root" | "--automation-write-root" => {
|
||||
let dir = inline.or_else(|| it.next().cloned()).ok_or_else(|| format!("{flag} needs a folder"))?;
|
||||
if flag == "--automation-read-root" { read_roots.push(dir) } else { write_roots.push(dir) }
|
||||
@@ -264,6 +286,12 @@ fn mcp(args: &[String], safe_mode: bool) -> Result<(), String> {
|
||||
if headless && connect.is_some() {
|
||||
return Err("use either --connect or --headless".into());
|
||||
}
|
||||
if allow_grants && connect.is_some() {
|
||||
return Err("--allow-plugin-grants applies to the headless server; in a running app a person approves each plug-in".into());
|
||||
}
|
||||
if allow_grants && !(read_roots.is_empty() && write_roots.is_empty()) {
|
||||
return Err("--allow-plugin-grants can't be used with --automation-read-root or --automation-write-root: a server confined to folders has no plug-in store to record a grant in".into());
|
||||
}
|
||||
if safe_mode && connect.is_some() {
|
||||
return Err("--safe-mode runs a headless session without plug-ins; a running app loads its own: start it with `vectorcraft --safe-mode --control <port>`".into());
|
||||
}
|
||||
@@ -278,9 +306,9 @@ fn mcp(args: &[String], safe_mode: bool) -> Result<(), String> {
|
||||
// Every thread of this process, not only the server's: nothing reaches past the roots.
|
||||
file_access::confine_process(roots.clone());
|
||||
eprintln!("vectorcraft-cli: files confined: {}", roots.describe());
|
||||
Box::new(Headless::with_document().with_automation_roots(Some(roots)))
|
||||
} else if headless || safe_mode {
|
||||
Box::new(with_stores(Headless::with_document()))
|
||||
Box::new(accepting_grants(Headless::with_document().with_automation_roots(Some(roots)), allow_grants))
|
||||
} else if headless || safe_mode || allow_grants {
|
||||
Box::new(accepting_grants(with_stores(Headless::with_document()), allow_grants))
|
||||
} else if let Some(addr) = connect {
|
||||
// Explicit address: fail loudly if the app isn't there.
|
||||
Box::new(Remote::connect(&addr).map_err(|e| format!("cannot connect to {addr}: {e}"))?)
|
||||
@@ -563,6 +591,7 @@ fn run(args: &[String]) -> Result<(), String> {
|
||||
let mut stdin_read = false;
|
||||
let mut scale = None::<f64>;
|
||||
let mut ppi = None::<f64>;
|
||||
let mut allow_grants = false;
|
||||
let mut it = args.iter();
|
||||
while let Some(a) = it.next() {
|
||||
let mut val = |what: &str| it.next().cloned().ok_or_else(|| format!("{a} needs {what}"));
|
||||
@@ -597,6 +626,7 @@ fn run(args: &[String]) -> Result<(), String> {
|
||||
steps.push(Step::Export(val("a file")?));
|
||||
params_for = None;
|
||||
}
|
||||
"--allow-plugin-grants" => allow_grants = true,
|
||||
"--scale" | "-s" => {
|
||||
let raw = val("a number")?;
|
||||
scale = Some(raw.parse::<f64>().map_err(|_| format!("--scale {raw}: not a number"))?);
|
||||
@@ -614,7 +644,7 @@ fn run(args: &[String]) -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
|
||||
let mut h = with_stores(Headless::new());
|
||||
let mut h = accepting_grants(with_stores(Headless::new()), allow_grants);
|
||||
let mut out = std::io::stdout().lock();
|
||||
// A reader that stopped early (a closed pipe) isn't an error, and the batch goes on: its later
|
||||
// steps (an export…) still run, unprinted.
|
||||
|
||||
@@ -19,6 +19,77 @@ fn empty_dir(dir: std::path::PathBuf) -> std::path::PathBuf {
|
||||
dir
|
||||
}
|
||||
|
||||
/// `run` and `mcp --headless` refuse `plugin.install` with this param unless the process was started
|
||||
/// with `--allow-plugin-grants`.
|
||||
fn approval_param_needs_the_start_up_flag(key: &str, value: Value) {
|
||||
let module = vectorcraft_format::base64_encode(include_bytes!("../../../../crates/plugins/tests/fixtures/desaturate.wasm"));
|
||||
let steps = tmp(&format!("{key}.json"));
|
||||
std::fs::write(&steps, json!([{"command": "plugin.install", "params": {"dataBase64": module, key: value}}]).to_string()).unwrap();
|
||||
let config = empty_dir(std::env::temp_dir().join(format!("vectorcraft-cli-test-{key}-{}", std::process::id())));
|
||||
let run = |extra: &[&str]| {
|
||||
Command::new(BIN).env("VECTORCRAFT_CONFIG_DIR", &config).args(["run", "--commands", steps.to_str().unwrap()]).args(extra).output().unwrap()
|
||||
};
|
||||
let out = run(&[]);
|
||||
assert!(!out.status.success());
|
||||
let shown = String::from_utf8_lossy(&out.stderr).into_owned() + &String::from_utf8_lossy(&out.stdout);
|
||||
assert!(shown.contains("--allow-plugin-grants"), "{shown}");
|
||||
let out = run(&["--allow-plugin-grants"]);
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
|
||||
let mcp_config = empty_dir(std::env::temp_dir().join(format!("vectorcraft-cli-test-mcp-{key}-{}", std::process::id())));
|
||||
let mcp_install = |extra: &[&str]| -> Value {
|
||||
let mut child = Command::new(BIN)
|
||||
.env("VECTORCRAFT_CONFIG_DIR", &mcp_config)
|
||||
.args(["mcp", "--headless"])
|
||||
.args(extra)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
{
|
||||
let mut stdin = child.stdin.take().unwrap();
|
||||
let params = json!({"dataBase64": module, key: value});
|
||||
for m in [
|
||||
json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"0"}}}),
|
||||
json!({"jsonrpc":"2.0","method":"notifications/initialized"}),
|
||||
json!({"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"run_command","arguments":{"command":"plugin.install","params":params}}}),
|
||||
] {
|
||||
writeln!(stdin, "{m}").unwrap();
|
||||
}
|
||||
}
|
||||
let out = child.wait_with_output().unwrap();
|
||||
assert!(out.status.success());
|
||||
let replies: Vec<Value> = String::from_utf8(out.stdout).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect();
|
||||
replies.into_iter().find(|r| r["id"] == 2).unwrap()["result"].take()
|
||||
};
|
||||
let refused = mcp_install(&[]);
|
||||
assert_eq!(refused["isError"], true, "without the flag: {refused}");
|
||||
assert!(refused["content"][0]["text"].as_str().unwrap().contains("--allow-plugin-grants"), "{refused}");
|
||||
let accepted = mcp_install(&["--allow-plugin-grants"]);
|
||||
assert_eq!(accepted["isError"], false, "with the flag: {accepted}");
|
||||
}
|
||||
|
||||
/// `run` and `mcp --headless` refuse `plugin.install`'s `grant` and `allowHeavy` unless the process was
|
||||
/// started with `--allow-plugin-grants`, which `mcp` won't take with `--connect` or the automation roots.
|
||||
#[test]
|
||||
fn plugin_grants_need_the_start_up_flag() {
|
||||
approval_param_needs_the_start_up_flag("grant", json!(["doc.read"]));
|
||||
approval_param_needs_the_start_up_flag("allowHeavy", json!(true));
|
||||
|
||||
let out = Command::new(BIN).args(["mcp", "--allow-plugin-grants", "--connect", "127.0.0.1:1"]).output().unwrap();
|
||||
assert!(!out.status.success());
|
||||
assert!(String::from_utf8_lossy(&out.stderr).contains("--allow-plugin-grants applies to the headless server"));
|
||||
let root = std::env::temp_dir();
|
||||
for flag in ["--automation-read-root", "--automation-write-root"] {
|
||||
let out = Command::new(BIN).args(["mcp", "--allow-plugin-grants", flag, root.to_str().unwrap()]).stdin(Stdio::null()).output().unwrap();
|
||||
assert!(!out.status.success(), "{flag}");
|
||||
assert!(String::from_utf8_lossy(&out.stderr).contains("--allow-plugin-grants can't be used with"), "{flag}");
|
||||
}
|
||||
let usage = Command::new(BIN).arg("help").output().unwrap();
|
||||
assert!(String::from_utf8_lossy(&usage.stdout).contains("--allow-plugin-grants lets plugin.install's grant and allowHeavy take effect"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_prints_catalogue() {
|
||||
let out = Command::new(BIN).arg("commands").output().unwrap();
|
||||
|
||||
Reference in New Issue
Block a user