Automation: a confined renderQueue.add without output needs a saved project (#2479)

On a project that was never saved, renderQueue.add without output queued
an item whose default output was the home folder, outside every
automation root (rendering it was refused, but the queue held a path the
agent may not write). While file access is confined, such an add is now
refused with a hint to give output inside a write root; a saved
project's default output (beside it) is checked when the queue renders,
as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-30 09:19:17 +07:00
co-authored by Claude Opus 5.5
parent 6681a3bc2a
commit ad6bc0c19b
4 changed files with 24 additions and 2 deletions
+1 -1
View File
@@ -64,7 +64,7 @@ impl Backend {
let params = if params.is_null() { json!({}) } else { params };
if let Some(c) = self.confinement.clone() {
let lookup = match c.check_command(id, &params)? {
Some(crate::confine::Lookup::ProjectFile) => Some((crate::confine::Lookup::ProjectFile, self.run("project.summary", json!({}))?)),
Some(l @ (crate::confine::Lookup::ProjectFile | crate::confine::Lookup::QueueDefault)) => Some((l, self.run("project.summary", json!({}))?)),
Some(crate::confine::Lookup::RenderOutputs) => Some((crate::confine::Lookup::RenderOutputs, self.run("renderQueue.list", json!({}))?)),
None => None,
};
+14
View File
@@ -128,6 +128,9 @@ pub enum Lookup {
ProjectFile,
/// Every output of the queued Render Queue items (`renderQueue.list`).
RenderOutputs,
/// Whether the open project has a file (`project.summary` → `path`): a Render Queue item
/// added without `output` writes beside it, or to the home folder before the first save.
QueueDefault,
}
impl Confinement {
@@ -239,6 +242,10 @@ 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")));
}
// Without `output`, an unsaved project's item would write to the home folder (#2479).
if id == "renderQueue.add" && !given {
return Ok(Some(Lookup::QueueDefault));
}
if IMPLICIT_WRITES.contains(&id) && !(given && id == "file.save") {
return Ok(Some(if id == "file.save" || id == "file.incrementAndSave" { Lookup::ProjectFile } else { Lookup::RenderOutputs }));
}
@@ -253,6 +260,13 @@ 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())),
},
// A saved project's default output sits beside it, and rendering checks it then.
Lookup::QueueDefault => match found.get("path").and_then(Value::as_str) {
Some(_) => Ok(()),
None => Err(Error::BadArgs(
"`renderQueue.add`: the project has no file yet, so the default output would go to the home folder: give `output` inside an automation write root".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)) {
+6
View File
@@ -1372,6 +1372,12 @@ fn automation_roots_confine_file_access() {
assert!(err, "{name} {args} should be refused: {c:?}");
c[0]["text"].as_str().unwrap_or_default().to_string()
};
// #2479: an unsaved project's queue item without `output` would write to the home folder.
call_json(&mut s, "execute_command", json!({"command": "comp.new", "params": {"name": "Unsaved", "width": 64, "height": 64}}));
let msg = denied(&mut s, "execute_command", json!({"command": "renderQueue.add", "params": {"comp": "Unsaved"}}));
assert!(msg.contains("home folder") && msg.contains("give `output`"), "{msg}");
call_json(&mut s, "execute_command", json!({"command": "renderQueue.add", "params": {"comp": "Unsaved", "output": p(&write, "unsaved.mp4")}}));
call_json(&mut s, "execute_command", json!({"command": "renderQueue.remove"}));
// Reading: inside a read root, not outside.
call_json(&mut s, "open_project", json!({"path": p(&read, "source.ecproj")}));
let msg = denied(&mut s, "open_project", json!({"path": p(&outside, "free.ecproj")}));