From a2c455091f37fdd75797b73ccaf239000126aa09 Mon Sep 17 00:00:00 2001 From: Anubhav Rai Date: Tue, 27 Oct 2026 23:14:37 +0530 Subject: [PATCH 1/6] automation: a confined agent can't queue a render output outside the roots (#2479) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `renderQueue.add` and `render.preRender` without an `output` take the default from where the project sits, which for a project that has never been saved is the home folder — outside every `--automation-read-root` / `--automation-write-root`. The item was queued anyway, and only `renderQueue.render` refused it, so an agent learned about it one command too late and with a queue it had to clean up. Both commands now look the project up first (`Lookup::DefaultOutput`) and are refused on an unsaved project, naming `output` as the way through. A project saved inside the roots keeps its old behaviour: the default goes beside its file and what it writes is checked when the queue is rendered. Co-Authored-By: Claude Opus 5 --- crates/automation/src/backend.rs | 13 +++++----- crates/automation/src/confine.rs | 41 +++++++++++++++++++++++++------- crates/automation/src/tests.rs | 34 ++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 15 deletions(-) diff --git a/crates/automation/src/backend.rs b/crates/automation/src/backend.rs index c3947c876..f6ad65558 100644 --- a/crates/automation/src/backend.rs +++ b/crates/automation/src/backend.rs @@ -4,7 +4,7 @@ use effectcraft_engine::Session; use effectcraft_engine::time::Tick; use serde_json::{Value, json}; -use crate::confine::Confinement; +use crate::confine::{Confinement, Lookup}; use crate::{BridgeClient, Error, Result, base64, encode_png, recompress_png}; pub struct Backend { @@ -63,12 +63,11 @@ impl Backend { pub fn exec(&mut self, id: &str, params: Value) -> Result { let params = if params.is_null() { json!({}) } else { params }; if let Some(c) = self.confinement.clone() { - let lookup = match c.check_command(id, ¶ms)? { - Some(crate::confine::Lookup::ProjectFile) => Some((crate::confine::Lookup::ProjectFile, self.run("project.summary", json!({}))?)), - Some(crate::confine::Lookup::RenderOutputs) => Some((crate::confine::Lookup::RenderOutputs, self.run("renderQueue.list", json!({}))?)), - None => None, - }; - if let Some((lookup, found)) = lookup { + for lookup in c.check_command(id, ¶ms)? { + let found = match lookup { + Lookup::ProjectFile | Lookup::DefaultOutput => self.run("project.summary", json!({}))?, + Lookup::RenderOutputs => self.run("renderQueue.list", json!({}))?, + }; c.check_lookup(&lookup, &found)?; } } diff --git a/crates/automation/src/confine.rs b/crates/automation/src/confine.rs index f396b3da0..af8a69089 100644 --- a/crates/automation/src/confine.rs +++ b/crates/automation/src/confine.rs @@ -6,8 +6,10 @@ //! `engine.batch`) is checked before it runs: its file parameters must be absolute paths inside //! the roots (symbolic links resolved, no `..`), any other absolute path in its parameters too, //! and commands that write to a file they are not given (Save, Increment and Save, rendering the -//! Render Queue) are checked against where they would write. Commands that run code or open -//! other applications (scripts, Execute File, Reveal in Finder…) are refused while confined. +//! Render Queue) are checked against where they would write. Queueing a render without naming +//! its output needs a saved project, because the default output of an unsaved one is the home +//! folder. Commands that run code or open other applications (scripts, Execute File, Reveal in +//! Finder…) are refused while confined. use std::path::{Component, Path, PathBuf}; @@ -89,11 +91,17 @@ const WRITES: &[(&str, &[&str])] = &[ /// Commands that write where they are not told: the project's file, the Render Queue's outputs. const IMPLICIT_WRITES: &[&str] = &["file.save", "file.incrementAndSave", "renderQueue.render", "render.preRender"]; +/// Commands that queue a render, whose output defaults to the project's folder when not given. +const QUEUES_AN_OUTPUT: &[&str] = &["renderQueue.add", "render.preRender"]; + /// What must be looked up before an implicitly writing command may run. #[derive(Debug, PartialEq, Eq)] pub enum Lookup { /// The open project's file (`project.summary` → `path`). ProjectFile, + /// That the project has a file at all (`project.summary` → `path`), for a queued render whose + /// output the agent did not name: that default is derived from where the project sits. + DefaultOutput, /// Every output of the queued Render Queue items (`renderQueue.list`). RenderOutputs, } @@ -134,7 +142,7 @@ impl Confinement { /// Check engine command `id` with `params` before it runs. Returns what must still be looked /// up and passed to [`Confinement::check_lookup`] for commands that write implicitly. - pub fn check_command(&self, id: &str, params: &Value) -> Result> { + pub fn check_command(&self, id: &str, params: &Value) -> Result> { if REFUSED.contains(&id) { return Err(Error::BadArgs(format!("`{id}` is not available while file access is confined (--automation-read-root/--automation-write-root)"))); } @@ -147,11 +155,11 @@ impl Confinement { if named.iter().filter_map(|k| inner_params.get(*k)).any(has_reference) { return Err(Error::BadArgs(format!("`{inner}` in a batch: file paths can't be `$N` references while file access is confined"))); } - if self.check_command(inner, &inner_params)?.is_some() { + if !self.check_command(inner, &inner_params)?.is_empty() { return Err(Error::BadArgs(format!("`{inner}` writes to its project's or queue's files: run it on its own while file access is confined"))); } } - return Ok(None); + return Ok(vec![]); } let (named, access) = match (keys_of(WRITES, id), keys_of(READS, id)) { (Some(k), _) => (k, Access::Write), @@ -188,10 +196,19 @@ impl Confinement { if matches!(id, "file.openRecent" | "file.importRecent" | "anim.applyRecentPreset") && !given { return Err(Error::BadArgs(format!("`{id}`: give `path` while file access is confined"))); } - if IMPLICIT_WRITES.contains(&id) && !(given && id == "file.save") { - return Ok(Some(if id == "file.save" || id == "file.incrementAndSave" { Lookup::ProjectFile } else { Lookup::RenderOutputs })); + let mut lookups = vec![]; + // An output the agent did not name is derived from where the project sits: the Default + // Output Folder preference when that is set, beside the project file otherwise — and the + // home folder when the project has never been saved, outside every root (#2479). Only + // that last case is certainly outside, and reading the preference here would duplicate + // the engine's precedence, so an unsaved project is told to name its output instead. + if QUEUES_AN_OUTPUT.contains(&id) && !given { + lookups.push(Lookup::DefaultOutput); } - Ok(None) + if IMPLICIT_WRITES.contains(&id) && !(given && id == "file.save") { + lookups.push(if id == "file.save" || id == "file.incrementAndSave" { Lookup::ProjectFile } else { Lookup::RenderOutputs }); + } + Ok(lookups) } /// Check what a [`Lookup`] found: `project.summary` or `renderQueue.list`'s result. @@ -202,6 +219,14 @@ impl Confinement { Some(p) => self.check(p, Access::Write).map(drop), None => Err(Error::BadArgs("the project has no file yet: give `path` (Save As) inside an automation write root".into())), }, + Lookup::DefaultOutput => match found.get("path").and_then(Value::as_str) { + // Saved: the default goes beside the project file, inside the roots, and what it + // writes is checked when the queue is rendered. + Some(_) => Ok(()), + None => Err(Error::BadArgs( + "the project has no file yet, so this would queue its default output (the home folder) outside the automation write roots: give `output`, or save the project inside a write root first".into(), + )), + }, Lookup::RenderOutputs => { let items = found.get("items").or(Some(found)).and_then(Value::as_array).cloned().unwrap_or_default(); for it in items.iter().filter(|it| it.get("render").and_then(Value::as_bool) != Some(false)) { diff --git a/crates/automation/src/tests.rs b/crates/automation/src/tests.rs index 6e622b377..e3282dc71 100644 --- a/crates/automation/src/tests.rs +++ b/crates/automation/src/tests.rs @@ -1380,3 +1380,37 @@ fn automation_roots_confine_file_access() { assert_eq!(std::fs::read_dir(&outside).unwrap().count(), 1, "only the unconfined save"); let _ = std::fs::remove_dir_all(&base); } + +/// #2479: a queued output the agent didn't name is derived from where the project sits, so on a +/// project that has never been saved it lands in the home folder, outside every automation root. +/// Queueing it there is refused, rather than leaving an item that can never be rendered. +#[test] +fn automation_roots_refuse_a_queued_output_from_an_unsaved_project() { + use crate::confine::Confinement; + let base = std::env::temp_dir().join(format!("ec-mcp-default-out-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let write = base.join("out"); + std::fs::create_dir_all(&write).unwrap(); + let p = |f: &str| write.join(f).to_string_lossy().into_owned(); + let mut s = server(); + s.backend().confine(Confinement::new(&[], std::slice::from_ref(&write)).unwrap()); + call_json(&mut s, "execute_command", json!({"command": "comp.new", "params": {"name": "A", "width": 64, "height": 64}})); + // Never saved: neither way of queueing a render may fall back to the home folder. + for command in ["renderQueue.add", "render.preRender"] { + let (c, err) = call(&mut s, "execute_command", json!({"command": command, "params": {"comp": "A"}})); + assert!(err, "`{command}` without an output should be refused: {c:?}"); + let msg = c[0]["text"].as_str().unwrap_or_default(); + assert!(msg.contains("the project has no file yet"), "`{command}`: {msg}"); + } + let queue = call_json(&mut s, "execute_command", json!({"command": "renderQueue.list"})); + assert_eq!(queue["items"].as_array().map(Vec::len), Some(0), "nothing was queued: {queue}"); + // Naming an output inside a write root is how it is done. + call_json(&mut s, "execute_command", json!({"command": "renderQueue.add", "params": {"comp": "A", "output": p("named.mp4")}})); + // Once the project is saved there, the default output (beside it) is queued as before. + call_json(&mut s, "save_project", json!({"path": p("proj.ecproj")})); + call_json(&mut s, "execute_command", json!({"command": "renderQueue.add", "params": {"comp": "A"}})); + let queue = call_json(&mut s, "execute_command", json!({"command": "renderQueue.list"})); + let out = queue["items"][1]["outputPath"].as_str().unwrap_or_default(); + assert!(out.starts_with(&*write.to_string_lossy()), "{queue}"); + let _ = std::fs::remove_dir_all(&base); +} From b86bc1d2a9c60dd3a4f78c117b41ec1a1723daba Mon Sep 17 00:00:00 2001 From: Anubhav Rai Date: Tue, 27 Oct 2026 23:24:13 +0530 Subject: [PATCH 2/6] automation: Create Proxy queues an output the same way (#2479) `file.createProxy` builds a Render Queue item exactly as `renderQueue.add` does, with `[compName]_proxy.[fileExtension]` as its output, so an unsaved project sent it to the home folder too. Its `path` / `output` had to join `WRITES` for this: the confinement only knows an output was named through that table, and it was reading them as a read before, so a proxy could be written into a read-only root (part of the F-11 audit in #2131). Co-Authored-By: Claude Opus 5 --- crates/automation/src/confine.rs | 3 ++- crates/automation/src/tests.rs | 6 +++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/automation/src/confine.rs b/crates/automation/src/confine.rs index af8a69089..b646ba7ba 100644 --- a/crates/automation/src/confine.rs +++ b/crates/automation/src/confine.rs @@ -86,13 +86,14 @@ const WRITES: &[(&str, &[&str])] = &[ ("essential.exportTemplate", &["path"]), ("shortcuts.export", &["path"]), ("renderQueue.add", &["output", "path"]), + ("file.createProxy", &["path", "output"]), ]; /// Commands that write where they are not told: the project's file, the Render Queue's outputs. const IMPLICIT_WRITES: &[&str] = &["file.save", "file.incrementAndSave", "renderQueue.render", "render.preRender"]; /// Commands that queue a render, whose output defaults to the project's folder when not given. -const QUEUES_AN_OUTPUT: &[&str] = &["renderQueue.add", "render.preRender"]; +const QUEUES_AN_OUTPUT: &[&str] = &["renderQueue.add", "render.preRender", "file.createProxy"]; /// What must be looked up before an implicitly writing command may run. #[derive(Debug, PartialEq, Eq)] diff --git a/crates/automation/src/tests.rs b/crates/automation/src/tests.rs index e3282dc71..55cc3b3cd 100644 --- a/crates/automation/src/tests.rs +++ b/crates/automation/src/tests.rs @@ -1396,7 +1396,7 @@ fn automation_roots_refuse_a_queued_output_from_an_unsaved_project() { s.backend().confine(Confinement::new(&[], std::slice::from_ref(&write)).unwrap()); call_json(&mut s, "execute_command", json!({"command": "comp.new", "params": {"name": "A", "width": 64, "height": 64}})); // Never saved: neither way of queueing a render may fall back to the home folder. - for command in ["renderQueue.add", "render.preRender"] { + for command in ["renderQueue.add", "render.preRender", "file.createProxy"] { let (c, err) = call(&mut s, "execute_command", json!({"command": command, "params": {"comp": "A"}})); assert!(err, "`{command}` without an output should be refused: {c:?}"); let msg = c[0]["text"].as_str().unwrap_or_default(); @@ -1406,6 +1406,10 @@ fn automation_roots_refuse_a_queued_output_from_an_unsaved_project() { assert_eq!(queue["items"].as_array().map(Vec::len), Some(0), "nothing was queued: {queue}"); // Naming an output inside a write root is how it is done. call_json(&mut s, "execute_command", json!({"command": "renderQueue.add", "params": {"comp": "A", "output": p("named.mp4")}})); + // A proxy's own `path` is a write, not the read it used to be checked as (#2131 F-11). + let (c, err) = call(&mut s, "execute_command", json!({"command": "file.createProxy", "params": {"comp": "A", "path": "/elsewhere/proxy.mov"}})); + assert!(err, "a proxy outside the roots should be refused: {c:?}"); + assert!(c[0]["text"].as_str().unwrap_or_default().contains("cannot write"), "{c:?}"); // Once the project is saved there, the default output (beside it) is queued as before. call_json(&mut s, "save_project", json!({"path": p("proj.ecproj")})); call_json(&mut s, "execute_command", json!({"command": "renderQueue.add", "params": {"comp": "A"}})); From 9cc232096c184e5e58bd0571486b6e5961c98923 Mon Sep 17 00:00:00 2001 From: Anubhav Rai Date: Tue, 27 Oct 2026 23:39:21 +0530 Subject: [PATCH 3/6] automation: an output module template is an output nobody named (#2479) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `render.preRender` takes an Output Module template where a path goes, and the confinement skips the path check for one. That made `given` true, so the new lookup was skipped — and the template's own output is relative, so it resolved exactly like an output the agent never gave: `render.preRender {output: "Lossless"}` on an unsaved project queued `~/Movies/Lossless.mov` and `~/Movies/Lossless_RenderLog.txt`. The lookup now asks whether a *file* was named, so a template name takes the same route as no output at all. Found by CodeRabbit on the pull request. Co-Authored-By: Claude Opus 5 --- crates/automation/src/confine.rs | 6 +++++- crates/automation/src/tests.rs | 4 ++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/automation/src/confine.rs b/crates/automation/src/confine.rs index b646ba7ba..d9807ca40 100644 --- a/crates/automation/src/confine.rs +++ b/crates/automation/src/confine.rs @@ -169,6 +169,9 @@ impl Confinement { (None, None) => (&[][..], Access::Read), }; let mut given = false; + // Whether one of them is a file and not a Render Queue template, whose own output is + // relative and resolved like one the agent never gave. + let mut gave_a_file = false; for key in named { let Some(v) = params.get(*key).filter(|v| !v.is_null()) else { continue }; given = true; @@ -181,6 +184,7 @@ impl Confinement { match p.as_str() { Some(s) if id == "render.preRender" && !looks_like_path(s) => {} Some(s) => { + gave_a_file = true; self.check(s, access)?; } None => return Err(Error::BadArgs(format!("`{id}`: `{key}` must be a path"))), @@ -203,7 +207,7 @@ impl Confinement { // home folder when the project has never been saved, outside every root (#2479). Only // that last case is certainly outside, and reading the preference here would duplicate // the engine's precedence, so an unsaved project is told to name its output instead. - if QUEUES_AN_OUTPUT.contains(&id) && !given { + if QUEUES_AN_OUTPUT.contains(&id) && !gave_a_file { lookups.push(Lookup::DefaultOutput); } if IMPLICIT_WRITES.contains(&id) && !(given && id == "file.save") { diff --git a/crates/automation/src/tests.rs b/crates/automation/src/tests.rs index 55cc3b3cd..3c5620fbf 100644 --- a/crates/automation/src/tests.rs +++ b/crates/automation/src/tests.rs @@ -1402,6 +1402,10 @@ fn automation_roots_refuse_a_queued_output_from_an_unsaved_project() { let msg = c[0]["text"].as_str().unwrap_or_default(); assert!(msg.contains("the project has no file yet"), "`{command}`: {msg}"); } + // Naming an Output Module template instead of a file is the same: its own output is relative. + let (c, err) = call(&mut s, "execute_command", json!({"command": "render.preRender", "params": {"comp": "A", "output": "Lossless"}})); + assert!(err, "a template output should be refused too: {c:?}"); + assert!(c[0]["text"].as_str().unwrap_or_default().contains("the project has no file yet"), "{c:?}"); let queue = call_json(&mut s, "execute_command", json!({"command": "renderQueue.list"})); assert_eq!(queue["items"].as_array().map(Vec::len), Some(0), "nothing was queued: {queue}"); // Naming an output inside a write root is how it is done. From 80a33f0e86271a2f1f363374eccabe11388cf61f Mon Sep 17 00:00:00 2001 From: Anubhav Rai Date: Wed, 28 Oct 2026 00:03:47 +0530 Subject: [PATCH 4/6] automation: an array where the output goes is no output either (#2479) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `file.createProxy` and `renderQueue.add` read a single string (`str_p`), so `{path: ["…/a.mov"]}` leaves them on their default while the confinement saw a path it could check and let the queue go ahead: on an unsaved project that was `~/Movies/A_proxy.mov` again. Only a plain string now counts as an output the agent named. Found by CodeRabbit on the pull request, which reported it fixed by 9cc23209; it was not, and the case is in the test now. Co-Authored-By: Claude Opus 5 --- crates/automation/src/confine.rs | 7 ++++--- crates/automation/src/tests.rs | 4 ++++ 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/crates/automation/src/confine.rs b/crates/automation/src/confine.rs index d9807ca40..ce6717f30 100644 --- a/crates/automation/src/confine.rs +++ b/crates/automation/src/confine.rs @@ -169,8 +169,9 @@ impl Confinement { (None, None) => (&[][..], Access::Read), }; let mut given = false; - // Whether one of them is a file and not a Render Queue template, whose own output is - // relative and resolved like one the agent never gave. + // Whether one of them is a file: not a Render Queue template, whose own output is relative + // and resolved like one the agent never gave, and not an array, which the commands that + // take a single output read as nothing at all. let mut gave_a_file = false; for key in named { let Some(v) = params.get(*key).filter(|v| !v.is_null()) else { continue }; @@ -184,7 +185,7 @@ impl Confinement { match p.as_str() { Some(s) if id == "render.preRender" && !looks_like_path(s) => {} Some(s) => { - gave_a_file = true; + gave_a_file |= v.is_string(); self.check(s, access)?; } None => return Err(Error::BadArgs(format!("`{id}`: `{key}` must be a path"))), diff --git a/crates/automation/src/tests.rs b/crates/automation/src/tests.rs index 3c5620fbf..61a726d6b 100644 --- a/crates/automation/src/tests.rs +++ b/crates/automation/src/tests.rs @@ -1402,6 +1402,10 @@ fn automation_roots_refuse_a_queued_output_from_an_unsaved_project() { let msg = c[0]["text"].as_str().unwrap_or_default(); assert!(msg.contains("the project has no file yet"), "`{command}`: {msg}"); } + // An array where a path goes: the engine reads a scalar, so this is no output either. + let (c, err) = call(&mut s, "execute_command", json!({"command": "file.createProxy", "params": {"comp": "A", "path": [p("arr.mov")]}})); + assert!(err, "an array where the output goes should be refused: {c:?}"); + assert!(c[0]["text"].as_str().unwrap_or_default().contains("the project has no file yet"), "{c:?}"); // Naming an Output Module template instead of a file is the same: its own output is relative. let (c, err) = call(&mut s, "execute_command", json!({"command": "render.preRender", "params": {"comp": "A", "output": "Lossless"}})); assert!(err, "a template output should be refused too: {c:?}"); From 7df52fb20ec010adc459e72d12a900ac02bf88e2 Mon Sep 17 00:00:00 2001 From: Anubhav-Rai Date: Thu, 29 Oct 2026 10:09:15 +0530 Subject: [PATCH 5/6] automation: a proxy's relative name is a template, not a path (#2479) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Asked for on the PR: #1347 allows `file.createProxy` a name that is not a path, because the engine reads it as an Output Module template, and the `WRITES` entry added here refused exactly that. Dropped, so the classification is #1347's alone. Nothing is opened by it. A proxy named outside the roots is still refused — `cannot read` now rather than `cannot write`, which is #1347's to settle — so the test asserts the refusal and no longer its wording. A relative name is now accepted and resolves beside the saved project, inside the write root, which the test pins so this does not quietly come back. The lookup this PR is for is untouched: `file.createProxy` stays in `QUEUES_AN_OUTPUT`, so a proxy queued from a project with no file is still refused rather than landing in the home folder. Co-Authored-By: Claude Opus 5 --- crates/automation/src/confine.rs | 1 - crates/automation/src/tests.rs | 8 ++++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/automation/src/confine.rs b/crates/automation/src/confine.rs index ce6717f30..3a4d462e4 100644 --- a/crates/automation/src/confine.rs +++ b/crates/automation/src/confine.rs @@ -86,7 +86,6 @@ const WRITES: &[(&str, &[&str])] = &[ ("essential.exportTemplate", &["path"]), ("shortcuts.export", &["path"]), ("renderQueue.add", &["output", "path"]), - ("file.createProxy", &["path", "output"]), ]; /// Commands that write where they are not told: the project's file, the Render Queue's outputs. diff --git a/crates/automation/src/tests.rs b/crates/automation/src/tests.rs index 61a726d6b..088bca335 100644 --- a/crates/automation/src/tests.rs +++ b/crates/automation/src/tests.rs @@ -1414,12 +1414,16 @@ fn automation_roots_refuse_a_queued_output_from_an_unsaved_project() { assert_eq!(queue["items"].as_array().map(Vec::len), Some(0), "nothing was queued: {queue}"); // Naming an output inside a write root is how it is done. call_json(&mut s, "execute_command", json!({"command": "renderQueue.add", "params": {"comp": "A", "output": p("named.mp4")}})); - // A proxy's own `path` is a write, not the read it used to be checked as (#2131 F-11). + // A proxy named outside the roots is refused. Whether that reads as a write or a read is + // #1347's to classify, so this asserts the refusal and not its wording. let (c, err) = call(&mut s, "execute_command", json!({"command": "file.createProxy", "params": {"comp": "A", "path": "/elsewhere/proxy.mov"}})); assert!(err, "a proxy outside the roots should be refused: {c:?}"); - assert!(c[0]["text"].as_str().unwrap_or_default().contains("cannot write"), "{c:?}"); + assert!(c[0]["text"].as_str().unwrap_or_default().contains("outside the automation roots"), "{c:?}"); // Once the project is saved there, the default output (beside it) is queued as before. call_json(&mut s, "save_project", json!({"path": p("proj.ecproj")})); + // A name that is not a path is an Output Module template, and the output it resolves to lands + // beside the saved project: inside the write root, so it is allowed (#1347 relies on this). + call_json(&mut s, "execute_command", json!({"command": "file.createProxy", "params": {"comp": "A", "path": "ProxyTemplate"}})); call_json(&mut s, "execute_command", json!({"command": "renderQueue.add", "params": {"comp": "A"}})); let queue = call_json(&mut s, "execute_command", json!({"command": "renderQueue.list"})); let out = queue["items"][1]["outputPath"].as_str().unwrap_or_default(); From 56821949e30f3accbbd198755554a08fdf0ee6d2 Mon Sep 17 00:00:00 2001 From: Anubhav-Rai Date: Thu, 29 Oct 2026 10:30:13 +0530 Subject: [PATCH 6/6] automation: the out-of-root proxy path is absolute on Windows too (#2479) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduced by the commit before this one. Dropping the `WRITES` entry changed that refusal's wording from `cannot write` to `cannot read`, so the assertion moved to the part both share — `outside the automation roots` — but `/elsewhere/proxy.mov` is not absolute on Windows, where it is refused for that instead and the assertion fails. The gate runs ubuntu only, so it stayed green. A sibling of the write root under the temp directory is absolute on every platform, so the refusal is the one the test is about. Co-Authored-By: Claude Opus 5 --- crates/automation/src/tests.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/automation/src/tests.rs b/crates/automation/src/tests.rs index 088bca335..e2acce85f 100644 --- a/crates/automation/src/tests.rs +++ b/crates/automation/src/tests.rs @@ -1415,8 +1415,11 @@ fn automation_roots_refuse_a_queued_output_from_an_unsaved_project() { // Naming an output inside a write root is how it is done. call_json(&mut s, "execute_command", json!({"command": "renderQueue.add", "params": {"comp": "A", "output": p("named.mp4")}})); // A proxy named outside the roots is refused. Whether that reads as a write or a read is - // #1347's to classify, so this asserts the refusal and not its wording. - let (c, err) = call(&mut s, "execute_command", json!({"command": "file.createProxy", "params": {"comp": "A", "path": "/elsewhere/proxy.mov"}})); + // #1347's to classify, so this asserts the refusal and not its wording. The path is a sibling + // of the write root rather than `/elsewhere/…`, which is not absolute on Windows and would be + // refused for that instead. + let outside = base.join("elsewhere").join("proxy.mov").to_string_lossy().into_owned(); + let (c, err) = call(&mut s, "execute_command", json!({"command": "file.createProxy", "params": {"comp": "A", "path": outside}})); assert!(err, "a proxy outside the roots should be refused: {c:?}"); assert!(c[0]["text"].as_str().unwrap_or_default().contains("outside the automation roots"), "{c:?}"); // Once the project is saved there, the default output (beside it) is queued as before.