From 828817c4b2be2153765c59c88bfad0ef3b0ae8fb Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 15:37:07 -0800 Subject: [PATCH] =?UTF-8?q?Scripts:=20File=20=E2=80=BA=20Scripts=20runs=20?= =?UTF-8?q?the=20text=20its=20check=20read,=20with=20includes=20from=20the?= =?UTF-8?q?=20checked=20folder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The menu item checked a listed script and then ran script.run by its path, which opened the file again; a link put in place of the file or a folder on its path in between could make it run a file the check never saw, and the include folder was looked up from the path again as well. script.run takes installed: true. With a path it reads the file once through script::folder::read, runs that text, and reads #include and $.evalFile in the Scripts folder's real path followed by the names that read found. The menu item runs script.run {path, installed: true}. --- crates/engine/src/cmd/scripts.rs | 43 +++++++++++++--- crates/engine/src/file_access.rs | 6 +++ crates/engine/src/script/include.rs | 10 ++-- crates/engine/src/script/mod.rs | 5 +- crates/engine/src/tests/script_folder.rs | 65 ++++++++++++++++++++++++ crates/mcp/src/ui_catalog.rs | 2 +- crates/ui-egui/src/scripts_menu.rs | 20 ++++---- docs/mcp.md | 3 ++ 8 files changed, 131 insertions(+), 23 deletions(-) diff --git a/crates/engine/src/cmd/scripts.rs b/crates/engine/src/cmd/scripts.rs index 385fd071c4..5b9e911b0a 100644 --- a/crates/engine/src/cmd/scripts.rs +++ b/crates/engine/src/cmd/scripts.rs @@ -11,7 +11,7 @@ use vectorcraft_scriptcheck::{Limits, Loaded, Loader, Report}; /// The ids a reply can hold, for the windows that show it. pub use vectorcraft_scriptcheck::{IncludeStatus, Need, OUTSIDE, PROBLEMS}; -use super::{CommandSpec, EngineError, Result, Session, always, bad, bool_or, cmd, str_param}; +use super::{CommandSpec, EngineError, Result, Session, always, bad, bool_or, cmd, str_param, switch_or}; use crate::script; /// The extensions of the scripts File › Open shows a check for. @@ -32,7 +32,7 @@ pub fn specs() -> Vec { "Run Script", [], None, - "{path?: a .js or .jsx file | source?: the script's text (exactly one of the two), name?: the name errors and the report use (default: the script's #script line, else the path's file name, or \"script\"), args?: {…} (at most 1 MiB as JSON; vc.args in the script), answers?: [{kind: alert|confirm|prompt, value: \"ok\" (alert) | true or false (confirm) | text or null (prompt)}] (for a run with nobody to ask: the answers to its dialogs, in order; at most 10000 and 1 MiB as JSON; refused in the app, where the person answers; an empty object is an empty list), maxFuel?: whole number from 1 to 100000000000 (instructions in all; default 1000000000)} run a JavaScript file against the open documents, through the vc object: vc.run(id, params) runs a command and returns its result, a failed command throws an error the script can catch and puts back the documents and the session state it changed, while any file it wrote stays written and is named; vc.document is a snapshot of the active document or null; vc.commands lists the commands a script may run; vc.log(value) adds to the report and the Script Console (script.console); $.write(...values) writes the values to the Script Console as String(value) gives them, and $.writeln(...values) does the same and ends the line; $.evalFile(path, timeout?) runs a file with the same globals and returns its last value (timeout, in milliseconds from 0, is taken and not waited on), $.fileName is the file of the code that reads it (the file being run for code that eval or Function compiled; empty for code compiled from a script given as text), $.includePath the #includepath list and $.engineName the engine's name (VectorCraft without #targetengine); alert(message[, title, errorIcon]), confirm(message[, noAsDflt, title]) and prompt(message, preset[, title]) open a dialog with a Stop button (default title \"Script Alert\"; message and preset as String(value) gives them; errorIcon and noAsDflt as Boolean(value) gives them; No is the default with noAsDflt): alert returns undefined, confirm true for Yes and false for No, prompt the field's text for OK and null for Cancel; in the app the run waits at the call for the person's answer and Stop ends it as script.stop does, a dialog cannot open inside a callback of a built-in, a getter, a conversion or a file $.evalFile runs and throws there (an #include file's code is the script's own and can), and a run with nobody to ask takes the next of answers, and ends when none is left or the next is for another kind of dialog, or when the script ends with answers unused; vc.args is args. Before anything runs, the script's preprocessor lines (# or //@ at the start of a line, outside strings and comments) are read: #include \"file\" and //@include put that file's text in place of the line, looked for beside the file that includes it, then in each #includepath folder in turn (separated by ;, relative to that file's folder; a later #includepath replaces the list), then in each JSINCLUDE folder; #script names the run; #target takes any value and changes nothing; #strict on makes an assignment to a read-only or getter-only property, or a new property on an object that is not extensible (preventExtensions, seal, freeze, vc, $), throw a TypeError, without the rest of ES5 strict mode, and #strict off turns that off (the last #strict line of the script and its includes sets the run; off without one; a $.evalFile file's own line holds for its code); #targetengine name runs the script in the named engine, whose globals and functions stay for the next run that names it until script.engine.drop drops it or the session ends (script.engines lists them; a name of letters, digits and _ not starting with a digit, or a quoted one of 1 to 64 bytes without control characters, matched as written; only in the script itself, once, before its code and #include lines; main, and a script without the line, get a new engine each run; a session keeps 8 named engines, a ninth name is refused, each has 64 MiB of heap, and an uncaught error, a limit or script.stop keeps the globals the run set while the documents go back; host functions kept in a global act for the run that calls them; an error in code an earlier run compiled is at that run's file, line and column, within 8 MiB of source maps kept for each engine, the oldest going first; not on the web); #engine is refused as unsupported, any other #name as unknown, and another //@name is a comment. A script run from a file reads its includes and $.evalFile's files only in that file's folder and the folders below it, refusing a path with a .. name or a link that leads out (a JSINCLUDE folder outside is passed over); a script given as text reads none. File(path) and Folder(path) name files and folders and read them in the same folders, and inside the automation read roots when they are set: exists, length, created, modified, getFiles(mask or function; at most 2048 entries), resolve, and open('r') with read, readln, readch, seek, tell and close; a path outside, a network or device path and a link that leads out give false or null with error \"Script file access denied\", a file is read whole at most 8 MiB (checked from its size first) and a run reads at most 32 MiB, and writing, the file dialogs and execute return false or null with error set. An error in another file is at that file's line and column; vc.api.version and vc.api.has(feature) describe the API. The run is one transaction: on success each changed document gets one undo step, Run Script, and each preference it changed keeps its value, and on an uncaught error, a limit or script.stop every open document and the session go back as they were; files a command wrote before that stay written and are named. While it runs, other commands that change anything end with a busy error. Commands that change anything outside the documents (script.*, except script.check, plugin.*, prefs.*, actionSet.*, shortcutSet.*, help.*, print.*, library.*, importMatches.*, edit.undoGroup.*, file.recovery.*, stroke.widthProfile.*, saving, importing or deleting a preset, text.addFontFiles, text.findFontFiles, text.rescanFonts, links.findFiles, file.place.queue, color.loadProfile, color.removeProfile, file.print, view.proofSetup, view.proofColors, view.overprintPreview, view.separationsPreview) throw. VectorCraft's own limits: 8 MiB of source a file and 32 MiB in all, 64 included or evaluated files 16 deep, 1024 paths looked up for them, 256 MiB of heap (128 MiB on the web), 2000000 host calls, 200000000 instructions without a pause, 64 levels, 16 MiB and 1048576 entries for a value crossing to or from a command, 10000 log entries and 4 MiB of log, 16 KiB of a dialog's message, 256 bytes of its title and 16 KiB of a prompt's preset (past these a RangeError), and 100000 objects and 512 MiB of new document data, past which a person is asked to continue or stop and a run with nobody to ask ends → {ok: true, name, changed: [document names], written: [paths], log: [values], fuelUsed, hostCalls, runTimeMs (the time the script ran, its commands included, waits between frames and for a person's answer left out; 0 without a clock), timingAvailable (false on the web), hostCallCounts: {vcRun, documentReads, commandListReads, vcLog, consoleWrites, fileCalls, fileExecutes}, slowestCommands: [{id, calls, totalMs, maxMs}] (at most 5, the longest in all first, then by id), console: {lines: [text], droppedLines, truncatedLines} (the run's own output, vc.log lines and error), dialogs: [{kind, message, title, errorIcon (alert), noAsDefault (confirm), preset (prompt), answer: \"ok\" | true | false | text | null | \"stop\" | \"missing\"}] (in the order they opened, at most 10000 and 1 MiB), dialogCount, dialogsTruncated}, or, in the app, {running: true, name} and the report later in ui.inspect's scriptReport; a run that fails ends with its error, and its report there also has kind, message, line, column, file (when the error is in another file than the script, or in code an earlier run of its engine compiled), fuelLeft, fuelUsed, hostCalls and the fields above", + "{path?: a .js or .jsx file | source?: the script's text (exactly one of the two), installed?: true or false (default false; true with a path that script.listInstalled gave: the file is read once, checked as File › Scripts checks it, and that text runs, its #include lines and $.evalFile reading in the folder that read found; a file that is no longer a listed script ends with \"the script is no longer in the Scripts folder\" and nothing runs), name?: the name errors and the report use (default: the script's #script line, else the path's file name, or \"script\"), args?: {…} (at most 1 MiB as JSON; vc.args in the script), answers?: [{kind: alert|confirm|prompt, value: \"ok\" (alert) | true or false (confirm) | text or null (prompt)}] (for a run with nobody to ask: the answers to its dialogs, in order; at most 10000 and 1 MiB as JSON; refused in the app, where the person answers; an empty object is an empty list), maxFuel?: whole number from 1 to 100000000000 (instructions in all; default 1000000000)} run a JavaScript file against the open documents, through the vc object: vc.run(id, params) runs a command and returns its result, a failed command throws an error the script can catch and puts back the documents and the session state it changed, while any file it wrote stays written and is named; vc.document is a snapshot of the active document or null; vc.commands lists the commands a script may run; vc.log(value) adds to the report and the Script Console (script.console); $.write(...values) writes the values to the Script Console as String(value) gives them, and $.writeln(...values) does the same and ends the line; $.evalFile(path, timeout?) runs a file with the same globals and returns its last value (timeout, in milliseconds from 0, is taken and not waited on), $.fileName is the file of the code that reads it (the file being run for code that eval or Function compiled; empty for code compiled from a script given as text), $.includePath the #includepath list and $.engineName the engine's name (VectorCraft without #targetengine); alert(message[, title, errorIcon]), confirm(message[, noAsDflt, title]) and prompt(message, preset[, title]) open a dialog with a Stop button (default title \"Script Alert\"; message and preset as String(value) gives them; errorIcon and noAsDflt as Boolean(value) gives them; No is the default with noAsDflt): alert returns undefined, confirm true for Yes and false for No, prompt the field's text for OK and null for Cancel; in the app the run waits at the call for the person's answer and Stop ends it as script.stop does, a dialog cannot open inside a callback of a built-in, a getter, a conversion or a file $.evalFile runs and throws there (an #include file's code is the script's own and can), and a run with nobody to ask takes the next of answers, and ends when none is left or the next is for another kind of dialog, or when the script ends with answers unused; vc.args is args. Before anything runs, the script's preprocessor lines (# or //@ at the start of a line, outside strings and comments) are read: #include \"file\" and //@include put that file's text in place of the line, looked for beside the file that includes it, then in each #includepath folder in turn (separated by ;, relative to that file's folder; a later #includepath replaces the list), then in each JSINCLUDE folder; #script names the run; #target takes any value and changes nothing; #strict on makes an assignment to a read-only or getter-only property, or a new property on an object that is not extensible (preventExtensions, seal, freeze, vc, $), throw a TypeError, without the rest of ES5 strict mode, and #strict off turns that off (the last #strict line of the script and its includes sets the run; off without one; a $.evalFile file's own line holds for its code); #targetengine name runs the script in the named engine, whose globals and functions stay for the next run that names it until script.engine.drop drops it or the session ends (script.engines lists them; a name of letters, digits and _ not starting with a digit, or a quoted one of 1 to 64 bytes without control characters, matched as written; only in the script itself, once, before its code and #include lines; main, and a script without the line, get a new engine each run; a session keeps 8 named engines, a ninth name is refused, each has 64 MiB of heap, and an uncaught error, a limit or script.stop keeps the globals the run set while the documents go back; host functions kept in a global act for the run that calls them; an error in code an earlier run compiled is at that run's file, line and column, within 8 MiB of source maps kept for each engine, the oldest going first; not on the web); #engine is refused as unsupported, any other #name as unknown, and another //@name is a comment. A script run from a file reads its includes and $.evalFile's files only in that file's folder and the folders below it, refusing a path with a .. name or a link that leads out (a JSINCLUDE folder outside is passed over); a script given as text reads none. File(path) and Folder(path) name files and folders and read them in the same folders, and inside the automation read roots when they are set: exists, length, created, modified, getFiles(mask or function; at most 2048 entries), resolve, and open('r') with read, readln, readch, seek, tell and close; a path outside, a network or device path and a link that leads out give false or null with error \"Script file access denied\", a file is read whole at most 8 MiB (checked from its size first) and a run reads at most 32 MiB, and writing, the file dialogs and execute return false or null with error set. An error in another file is at that file's line and column; vc.api.version and vc.api.has(feature) describe the API. The run is one transaction: on success each changed document gets one undo step, Run Script, and each preference it changed keeps its value, and on an uncaught error, a limit or script.stop every open document and the session go back as they were; files a command wrote before that stay written and are named. While it runs, other commands that change anything end with a busy error. Commands that change anything outside the documents (script.*, except script.check, plugin.*, prefs.*, actionSet.*, shortcutSet.*, help.*, print.*, library.*, importMatches.*, edit.undoGroup.*, file.recovery.*, stroke.widthProfile.*, saving, importing or deleting a preset, text.addFontFiles, text.findFontFiles, text.rescanFonts, links.findFiles, file.place.queue, color.loadProfile, color.removeProfile, file.print, view.proofSetup, view.proofColors, view.overprintPreview, view.separationsPreview) throw. VectorCraft's own limits: 8 MiB of source a file and 32 MiB in all, 64 included or evaluated files 16 deep, 1024 paths looked up for them, 256 MiB of heap (128 MiB on the web), 2000000 host calls, 200000000 instructions without a pause, 64 levels, 16 MiB and 1048576 entries for a value crossing to or from a command, 10000 log entries and 4 MiB of log, 16 KiB of a dialog's message, 256 bytes of its title and 16 KiB of a prompt's preset (past these a RangeError), and 100000 objects and 512 MiB of new document data, past which a person is asked to continue or stop and a run with nobody to ask ends → {ok: true, name, changed: [document names], written: [paths], log: [values], fuelUsed, hostCalls, runTimeMs (the time the script ran, its commands included, waits between frames and for a person's answer left out; 0 without a clock), timingAvailable (false on the web), hostCallCounts: {vcRun, documentReads, commandListReads, vcLog, consoleWrites, fileCalls, fileExecutes}, slowestCommands: [{id, calls, totalMs, maxMs}] (at most 5, the longest in all first, then by id), console: {lines: [text], droppedLines, truncatedLines} (the run's own output, vc.log lines and error), dialogs: [{kind, message, title, errorIcon (alert), noAsDefault (confirm), preset (prompt), answer: \"ok\" | true | false | text | null | \"stop\" | \"missing\"}] (in the order they opened, at most 10000 and 1 MiB), dialogCount, dialogsTruncated}, or, in the app, {running: true, name} and the report later in ui.inspect's scriptReport; a run that fails ends with its error, and its report there also has kind, message, line, column, file (when the error is in another file than the script, or in code an earlier run of its engine compiled), fuelLeft, fuelUsed, hostCalls and the fields above", always, run ), @@ -154,8 +154,17 @@ fn run(s: &mut Session, p: &Value) -> Result { return Err(param("`answers` is only for a run with nobody to ask: in the app, the person answers each dialog")); } let fuel = max_fuel(p)?; + let installed = switch_or(p, "installed", false, "script.run")?; + let mut checked_real = None; let text = match (path, source) { (Some(""), None) => return Err(param("`path` is empty")), + // The text runs as this one read checked it, and its folder is the one that read found. + (Some(path), None) if installed => { + let (text, real) = s.read_installed(path)?; + checked_real = Some(real); + text + } + (None, Some(_)) if installed => return Err(param("`installed` takes a `path`, not a `source`")), (Some(path), None) => { if path.to_ascii_lowercase().ends_with(".jsxbin") { return Err(EngineError::Other(COMPILED.to_string())); @@ -180,8 +189,17 @@ fn run(s: &mut Session, p: &Value) -> Result { }; let name = given_name.map(str::to_string).or_else(|| path.map(super::fileio::file_name)).unwrap_or_else(|| "script".to_string()); let locale = script::dollar::ui_locale(s.ui_language.as_deref().unwrap_or(&s.prefs.interface_language)); - let request = - script::Request { name, name_given: given_name.is_some(), file: path.map(str::to_string), source: text, args, fuel, locale, answers }; + let request = script::Request { + name, + name_given: given_name.is_some(), + file: path.map(str::to_string), + checked_real, + source: text, + args, + fuel, + locale, + answers, + }; let name = s.script_start(request)?; if s.script_frames { return Ok(json!({"running": true, "name": name})); @@ -273,15 +291,24 @@ impl Session { /// The text of the script at `path`, which a listing of the Scripts folder gave /// ([`script::folder::read`]), checked as `script.run` checks a file's. pub fn installed_script(&self, path: &str) -> Result { - let root = self.scripts_dir().ok_or_else(|| EngineError::Other(NOT_LISTED.to_string()))?; + self.read_installed(path).map(|(text, _)| text) + } + + /// [`Session::installed_script`] with the script's real path: the Scripts folder's, looked up + /// before the read, followed by the names under it that the read found to be no links. + fn read_installed(&self, path: &str) -> Result<(String, std::path::PathBuf)> { + let not_listed = || EngineError::Other(NOT_LISTED.to_string()); + let root = self.scripts_dir().ok_or_else(not_listed)?; crate::file_access::check_read(path).map_err(EngineError::Other)?; + let path = std::path::Path::new(path); + let mut real = std::fs::canonicalize(root).map_err(|_| not_listed())?; + real.extend(path.strip_prefix(root).map_err(|_| not_listed())?.components()); // One byte more than a script may have shows that the file is too large. - let bytes = script::folder::read(root, std::path::Path::new(path), script::MAX_SOURCE_BYTES + 1) - .ok_or_else(|| EngineError::Other(NOT_LISTED.to_string()))?; + let bytes = script::folder::read(root, path, script::MAX_SOURCE_BYTES + 1).ok_or_else(not_listed)?; if bytes.len() > script::MAX_SOURCE_BYTES { return Err(EngineError::Other(TOO_LARGE.to_string())); } - script_text(&bytes) + Ok((script_text(&bytes)?, real)) } } diff --git a/crates/engine/src/file_access.rs b/crates/engine/src/file_access.rs index 6ca724d810..53aabe5edd 100644 --- a/crates/engine/src/file_access.rs +++ b/crates/engine/src/file_access.rs @@ -399,6 +399,12 @@ impl ScriptScope { Self { roots: std::fs::canonicalize(dir).ok().into_iter().collect() } } + /// The scope of a script run from a file in the folder whose real path, with its links already + /// followed, is `real`: that folder, which is not looked up again. + pub(crate) fn of_real_folder(real: PathBuf) -> Self { + Self { roots: vec![real] } + } + /// Is `path` refused by its name alone: a `..` component, a NUL, a Windows device name or /// stream, or a computer on the network? fn refused_by_name(path: &Path) -> bool { diff --git a/crates/engine/src/script/include.rs b/crates/engine/src/script/include.rs index f1928228df..4223d829ba 100644 --- a/crates/engine/src/script/include.rs +++ b/crates/engine/src/script/include.rs @@ -295,12 +295,14 @@ fn display(path: &Path) -> Arc { impl Files { /// The files of a run of the script at `path` (`None` for a script given as text), with the - /// `JSINCLUDE` folders `jsinclude`. - pub(crate) fn new(path: Option<&str>, jsinclude: Vec) -> Self { - let real = path.and_then(|p| std::fs::canonicalize(p).ok()); + /// `JSINCLUDE` folders `jsinclude`. `checked` is the script's real path when the read of its text + /// found it, which the run's folder then comes from; without it, `path` is looked up. + pub(crate) fn new(path: Option<&str>, checked: Option, jsinclude: Vec) -> Self { + let scope_of_checked = checked.as_deref().and_then(Path::parent).map(|d| ScriptScope::of_real_folder(d.to_path_buf())); + let real = checked.or_else(|| path.and_then(|p| std::fs::canonicalize(p).ok())); let dir = real.as_deref().and_then(folder_of); Self { - scope: dir.as_deref().map(ScriptScope::of_folder).unwrap_or_default(), + scope: scope_of_checked.or_else(|| dir.as_deref().map(ScriptScope::of_folder)).unwrap_or_default(), jsinclude, include_path: Vec::new(), files: 0, diff --git a/crates/engine/src/script/mod.rs b/crates/engine/src/script/mod.rs index 1b95b5fe2a..b52fa6e66a 100644 --- a/crates/engine/src/script/mod.rs +++ b/crates/engine/src/script/mod.rs @@ -163,6 +163,9 @@ pub(crate) struct Request { pub name_given: bool, /// The file the script was read from, which its included files are read beside. pub file: Option, + /// The real path of `file` as the read of `source` found it, which the included files are then + /// read beside instead of looking `file` up again. + pub checked_real: Option, pub source: String, pub args: Map, pub fuel: u64, @@ -1814,7 +1817,7 @@ impl Session { self.script_high_water.retain(|uid, _| open.contains(uid)); self.script_stop.clear(); self.script_report = None; - let mut files = include::Files::new(request.file.as_deref(), include::Files::jsinclude_env()); + let mut files = include::Files::new(request.file.as_deref(), request.checked_real, include::Files::jsinclude_env()); let expanded = files.expand_script(&request.source); let name = match &expanded { Ok(include::Expanded { name: Some(name), .. }) if !request.name_given => name.clone(), diff --git a/crates/engine/src/tests/script_folder.rs b/crates/engine/src/tests/script_folder.rs index 312db3e0fc..8ea0482826 100644 --- a/crates/engine/src/tests/script_folder.rs +++ b/crates/engine/src/tests/script_folder.rs @@ -300,6 +300,71 @@ fn an_installed_script_is_read_as_script_run_reads_a_file() { let _ = std::fs::remove_dir_all(dir); } +#[test] +fn an_installed_run_runs_the_text_its_own_read_checked() { + let dir = folder("installed-run"); + let root = dir.join("Scripts"); + let script = touch(&root, "draw.jsx"); + std::fs::write(&script, "#include \"lib.jsxinc\"\nvc.log(x);").unwrap(); + std::fs::write(root.join("lib.jsxinc"), "var x = 1;").unwrap(); + // A folder in the Scripts folder that is a link: script.run by path alone follows it. + let elsewhere = dir.join("elsewhere"); + std::fs::create_dir_all(&elsewhere).unwrap(); + std::fs::write(elsewhere.join("other.jsx"), "vc.log(2);").unwrap(); + link_folder(&elsewhere, &root.join("linked")); + let linked = root.join("linked").join("other.jsx").to_string_lossy().into_owned(); + let mut s = Session::new(); + s.attach_scripts_dir(root.clone()); + let path = script.to_string_lossy().into_owned(); + let r = s.execute("script.run", &json!({"path": path, "installed": true})).unwrap(); + assert_eq!(r["log"], json!([1])); + assert_eq!(s.execute("script.run", &json!({"path": linked})).unwrap()["log"], json!([2])); + // With `installed`, the run's own read is the check: a script behind a link is not run. + let e = s.execute("script.run", &json!({"path": linked, "installed": true})).unwrap_err(); + assert_eq!(e.to_string(), cmd::scripts::NOT_LISTED); + let outside = touch(&dir, "outside.js"); + let e = s.execute("script.run", &json!({"path": outside.to_string_lossy(), "installed": true})).unwrap_err(); + assert_eq!(e.to_string(), cmd::scripts::NOT_LISTED); + // `installed` is true or false, and takes a path. + assert!( + s.execute("script.run", &json!({"path": path, "installed": "yes"})).unwrap_err().to_string().contains("`installed` must be true or false") + ); + assert!(s.execute("script.run", &json!({"source": "1", "installed": true})).unwrap_err().to_string().contains("`installed` takes a `path`")); + assert_eq!(s.execute("script.run", &json!({"source": "vc.log(3);", "installed": false})).unwrap()["log"], json!([3])); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn an_installed_run_reads_its_includes_in_the_folder_its_read_found() { + let dir = folder("installed-scope"); + let checked = dir.join("Scripts").join("tools"); + let swapped = dir.join("swapped"); + for (folder, x) in [(&checked, 1), (&swapped, 2)] { + std::fs::create_dir_all(folder).unwrap(); + std::fs::write(folder.join("draw.jsx"), "").unwrap(); + std::fs::write(folder.join("lib.jsxinc"), format!("var x = {x};")).unwrap(); + } + // The path now leads through a link to another folder, as after a link was put in place of the + // folder once the script's text was read. + let link = dir.join("link"); + link_folder(&swapped, &link); + let mut s = Session::new(); + let request = script::Request { + name: "draw.jsx".into(), + name_given: false, + file: Some(link.join("draw.jsx").to_string_lossy().into_owned()), + checked_real: Some(std::fs::canonicalize(checked.join("draw.jsx")).unwrap()), + source: "#include \"lib.jsxinc\"\nvc.log(x);".into(), + args: Default::default(), + fuel: script::DEFAULT_FUEL, + locale: "en_US".into(), + answers: Vec::new(), + }; + s.script_start(request).unwrap(); + assert_eq!(s.script_run_to_end().unwrap()["log"], json!([1])); + let _ = std::fs::remove_dir_all(dir); +} + #[test] fn the_automation_read_root_keeps_an_agent_out_of_the_scripts_folder() { let dir = folder("roots"); diff --git a/crates/mcp/src/ui_catalog.rs b/crates/mcp/src/ui_catalog.rs index 1bc2d14442..724cfbb62a 100644 --- a/crates/mcp/src/ui_catalog.rs +++ b/crates/mcp/src/ui_catalog.rs @@ -1725,7 +1725,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "ui.runInstalledScript", "Run Script", "", - "{path: a script's path as script.listInstalled gives it} run a script of the Scripts folder, as its File › Scripts item does (the script is checked again, then script.run runs it by its path, so its #include lines and $.evalFile read beside it), without the warning Other Script… shows; a file that is no longer a .js or .jsx file in the folder or a subfolder at most 4 levels below it, or that is or lies behind a link or is hidden, is not run, and the menu's listing is read again", + "{path: a script's path as script.listInstalled gives it} run a script of the Scripts folder, as its File › Scripts item does (script.run with installed reads the file once, checks it again and runs that text, so its #include lines and $.evalFile read in the folder that read found), without the warning Other Script… shows; a file that is no longer a .js or .jsx file in the folder or a subfolder at most 4 levels below it, or that is or lies behind a link or is hidden, is not run, and the menu's listing is read again", ), ( "ui.checkScript", diff --git a/crates/ui-egui/src/scripts_menu.rs b/crates/ui-egui/src/scripts_menu.rs index 9c890d5c39..c951aac8cb 100644 --- a/crates/ui-egui/src/scripts_menu.rs +++ b/crates/ui-egui/src/scripts_menu.rs @@ -16,6 +16,7 @@ use std::sync::mpsc::{Receiver, TryRecvError}; use std::sync::{Arc, Mutex, MutexGuard}; use serde_json::{Value, json}; +use vectorcraft_engine::cmd::scripts::NOT_LISTED; use vectorcraft_engine::script::folder::{Entry, Listing}; use crate::VectorcraftApp; @@ -241,15 +242,16 @@ pub fn readable(p: &Value) -> bool { p.get("path").and_then(Value::as_str).is_some_and(|path| vectorcraft_engine::file_access::check_read(path).is_ok()) } -/// `ui.runInstalledScript {path}`: run the listed script at `path`. A script that is no longer in -/// the folder is not run, and the folder is read again. The script runs from its file, so its -/// `#include` lines and `$.evalFile` read beside it. +/// `ui.runInstalledScript {path}`: run the listed script at `path`. `script.run` reads the file +/// once, checks it as a listed script and runs that text, so its `#include` lines and `$.evalFile` +/// read in the folder that read found. A script that is no longer in the folder is not run, and the +/// folder is read again. pub fn run(app: &mut VectorcraftApp, p: &Value) -> Result { let path = p.get("path").and_then(Value::as_str).ok_or("missing `path`")?; - app.session.installed_script(path).map_err(|e| { - let ctx = app.ui_ctx.clone(); - refresh(app, ctx.as_ref()); - e.to_string() - })?; - app.run("script.run", json!({ "path": path })) + app.run("script.run", json!({ "path": path, "installed": true })).inspect_err(|e| { + if e == NOT_LISTED { + let ctx = app.ui_ctx.clone(); + refresh(app, ctx.as_ref()); + } + }) } diff --git a/docs/mcp.md b/docs/mcp.md index fc03d070b6..169fb43b78 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2520,6 +2520,9 @@ The rest of this section is VectorCraft's own rules: `The Scripts folder cannot be read`. - A listed file is checked again when it is chosen. A file that is gone, is no longer a `.js` or `.jsx` file under the folder, or is or lies behind a link ends with `the script is no longer in the Scripts folder`, and nothing runs. + The file is read once for that check, and the text that read checked is the text that runs; its `#include` lines + and `$.evalFile` read in the folder that read found. `script.run {path, installed: true}` runs a listed script the + same way. - The web build has no Scripts folder, and neither has a run with `VECTORCRAFT_NO_PREFS` set. `script.listInstalled {}` returns the same listing: `{folder, scripts: [{path, label, folders}], truncated,