From 7e1f4ec1e3aa58afdad7848c7fd90cfad117dd7d Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:09:54 +0200 Subject: [PATCH 1/2] feat(agent-spec): add direct argv launches agent-session-id: a078daee-6f98-4916-91a8-d21291407789 agent-tool: Codex CLI agent-tool-version: 0.145.0 agent-model: unknown agent-runtime-profile: /nix/store/mnx8agbdq3wiyb6vz63lhgscgazkrn98-coding-agent-runtime-profile/share/coding-agents/profile.json agent-skills-manifest: /nix/store/5r69m9k2llmri3na81518zx0a7y0d3cn-agent-skills-corpus/share/agent-skills/manifest.json tooling-profile: dotfiles@0fb7e03 --- README.md | 8 +- crates/agent-spec/src/discovery.rs | 2 +- crates/agent-spec/src/kdl_format.rs | 33 ++++-- crates/agent-spec/src/spec.rs | 74 ++++++++++--- crates/agent-spec/tests/discovery.rs | 150 ++++++++++++++++++++++++++- examples/native/agent-claude.kdl | 2 +- examples/native/agent-codex.kdl | 2 +- src/compile_agent.rs | 56 +++++----- src/eval_run.rs | 2 + src/exec_backend.rs | 41 +++++--- src/hooks.rs | 66 ++++++++---- src/lib.rs | 2 +- src/main.rs | 27 +++-- src/reconcile.rs | 50 +++++++-- src/run.rs | 88 ++++++++++++---- src/validate.rs | 2 +- tests/compile_agent.rs | 17 ++- tests/exec_backend.rs | 91 ++++++++++++++-- tests/reconcile.rs | 1 + 19 files changed, 569 insertions(+), 145 deletions(-) diff --git a/README.md b/README.md index 7140110..8b16ea8 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,7 @@ agent "" { // role "worker" // supervisor "" env { ST_AGENT "." } - command #"exec codex --dangerously-bypass-approvals-and-sandbox --dangerously-bypass-hook-trust ''"# + argv "codex" "--dangerously-bypass-approvals-and-sandbox" "--dangerously-bypass-hook-trust" "" ding render { @@ -123,6 +123,12 @@ agent "" { } ``` +`argv` launches its first value directly with the remaining values as arguments. It resolves a bare +program such as `codex` through the task environment's `PATH`, preserves argument boundaries, and +does not introduce a shell. Use `command #"..."#` instead when the task intentionally needs shell +syntax such as pipelines, redirects, or variable expansion; `command` continues to run under +`sh -c`. A runnable task must declare exactly one of `argv` or `command`. + ### Scheduled work is coming soon, not implemented st2 does not parse or run scheduled entries today. The intended direction starts with a declarative diff --git a/crates/agent-spec/src/discovery.rs b/crates/agent-spec/src/discovery.rs index 139a083..c897db3 100644 --- a/crates/agent-spec/src/discovery.rs +++ b/crates/agent-spec/src/discovery.rs @@ -202,7 +202,7 @@ fn resolve_spec( (None, None) => None, }; - let spec = raw.into_agent_spec(identity, host, path.to_path_buf()); + let spec = raw.into_agent_spec(identity, host, path.to_path_buf())?; Ok(Some((spec, warnings))) } diff --git a/crates/agent-spec/src/kdl_format.rs b/crates/agent-spec/src/kdl_format.rs index 781d522..be2510d 100644 --- a/crates/agent-spec/src/kdl_format.rs +++ b/crates/agent-spec/src/kdl_format.rs @@ -28,6 +28,21 @@ fn arg_string(node: &KdlNode) -> Option { node.get(0).and_then(|v| v.as_string()).map(String::from) } +/// Every positional argument of a node, as strings. +fn argv(node: &KdlNode) -> anyhow::Result> { + node.entries() + .iter() + .filter(|entry| entry.name().is_none()) + .map(|entry| { + entry + .value() + .as_string() + .map(String::from) + .ok_or_else(|| anyhow::anyhow!("`argv` accepts only string arguments")) + }) + .collect() +} + /// First positional argument as a bool (`#true`/`#false`), defaulting to `false`. fn arg_bool(node: &KdlNode) -> bool { node.get(0).and_then(|v| v.as_bool()).unwrap_or(false) @@ -70,28 +85,23 @@ fn agent_node_to_raw(node: &KdlNode) -> anyhow::Result { "keep" => raw.keep = arg_bool(child), "restart" => raw.restart = Some(restart_node_to_raw(child)), "command" => raw.command = arg_string(child), + "argv" => raw.argv = Some(argv(child)?), "ding" => raw.ding = true, "env" => {} "pty" => { if let Some(name) = arg_string(child) { - raw.pty.insert(name, task_node_to_raw(child)); + raw.pty.insert(name, task_node_to_raw(child)?); } } "exec" => { if let Some(name) = arg_string(child) { - raw.exec.insert(name, task_node_to_raw(child)); + raw.exec.insert(name, task_node_to_raw(child)?); } } // meta, harness, model, persona, permissions, transport, strategy, … — ignored. _ => {} } } - if raw.command.is_some() && raw.pty.contains_key("agent") { - anyhow::bail!( - "agent '{}' declares both compact `command` and `pty \"agent\"`; choose one form", - raw.identity.as_deref().unwrap_or("") - ); - } if raw.ding && raw.exec.contains_key("ding") { anyhow::bail!( "agent '{}' declares both compact `ding` and `exec \"ding\"`; choose one form", @@ -118,16 +128,17 @@ fn restart_node_to_raw(node: &KdlNode) -> RawRestart { r } -fn task_node_to_raw(node: &KdlNode) -> RawTask { +fn task_node_to_raw(node: &KdlNode) -> anyhow::Result { let mut t = RawTask::default(); let Some(children) = node.children() else { - return t; + return Ok(t); }; for child in children.nodes() { match child.name().value() { "id" => t.id = arg_string(child), "command" => t.command = arg_string(child), + "argv" => t.argv = Some(argv(child)?), "cwd" => t.cwd = arg_string(child), "keep" => t.keep = arg_bool(child), // `tags role="agent" "st.network"="$CATALOG"` — properties on the node. @@ -145,7 +156,7 @@ fn task_node_to_raw(node: &KdlNode) -> RawTask { _ => {} } } - t + Ok(t) } fn env_node_to_raw(node: &KdlNode) -> std::collections::BTreeMap { diff --git a/crates/agent-spec/src/spec.rs b/crates/agent-spec/src/spec.rs index a34ce34..9ba1952 100644 --- a/crates/agent-spec/src/spec.rs +++ b/crates/agent-spec/src/spec.rs @@ -65,8 +65,12 @@ pub struct Task { pub name: String, /// Explicit on-disk id. `None` → `..` at spawn. pub id: Option, - /// The exec command line, run verbatim under `sh -c`. No command → not a launch target. + /// A shell program, run verbatim under `sh -c`. pub command: Option, + /// A direct program invocation. Element 0 is the program and the rest are its arguments. + /// + /// Mutually exclusive with [`Task::command`]. Neither field means this is not a launch target. + pub argv: Option>, /// Working dir; `None` → the agent's `workspace`, else the spec file's directory. pub cwd: Option, /// Arbitrary metadata (values are `$`-expanded at spawn). @@ -135,12 +139,12 @@ impl AgentSpec { self.host.as_deref().unwrap_or(this_host) } - /// True once at least one authored task carries an explicit `command` (i.e. the job was - /// rendered). A generated sidecar cannot make an otherwise-empty job runnable. + /// True once at least one authored task carries an explicit shell command or direct argv (i.e. + /// the job was rendered). A generated sidecar cannot make an otherwise-empty job runnable. pub fn is_runnable(&self) -> bool { self.tasks .iter() - .any(|task| !task.derived && task.command.is_some()) + .any(|task| !task.derived && (task.command.is_some() || task.argv.is_some())) } /// The restart policy in effect (declared, else the runner default). @@ -197,6 +201,8 @@ pub(crate) struct RawSpec { pub restart: Option, /// Compact catalog form: the agent itself is one pty carrying this command. pub command: Option, + /// Compact catalog form: the agent itself is one pty launched directly with this argv. + pub argv: Option>, /// Compact catalog form: environment inherited by the agent pty and every sidecar. #[serde(default)] pub env: BTreeMap, @@ -215,6 +221,7 @@ pub(crate) struct RawSpec { pub(crate) struct RawTask { pub id: Option, pub command: Option, + pub argv: Option>, pub cwd: Option, #[serde(default)] pub tags: BTreeMap, @@ -261,6 +268,7 @@ impl RawSpec { self.identity.is_some() || self.job_type.is_some() || self.command.is_some() + || self.argv.is_some() || self.ding || !self.pty.is_empty() || !self.exec.is_empty() @@ -272,18 +280,29 @@ impl RawSpec { identity: String, host: Option, path: PathBuf, - ) -> AgentSpec { + ) -> anyhow::Result { + validate_launch( + &identity, + self.command.as_ref(), + self.argv.as_ref(), + "compact task", + )?; + if (self.command.is_some() || self.argv.is_some()) && self.pty.contains_key("agent") { + anyhow::bail!( + "agent '{identity}' declares both a compact launch and `pty \"agent\"`; choose one form" + ); + } let bus_id = format!("{}.{}", host.as_deref().unwrap_or_default(), identity) .trim_start_matches('.') .to_string(); let mut tasks: Vec = Vec::new(); for (name, t) in self.pty { - tasks.push(t.lower(TaskKind::Pty, name, &self.env)); + tasks.push(t.lower(&identity, TaskKind::Pty, name, &self.env)?); } for (name, t) in self.exec { - tasks.push(t.lower(TaskKind::Exec, name, &self.env)); + tasks.push(t.lower(&identity, TaskKind::Exec, name, &self.env)?); } - if let Some(command) = self.command { + if self.command.is_some() || self.argv.is_some() { let mut tags = BTreeMap::new(); tags.insert("role".to_string(), "agent".to_string()); tasks.push(Task { @@ -292,7 +311,8 @@ impl RawSpec { name: "agent".to_string(), // An agent IS its pty: ding defaults its poke target to this same bus id. id: Some(bus_id.clone()), - command: Some(command), + command: self.command, + argv: self.argv, cwd: None, tags, env: self.env.clone(), @@ -306,6 +326,7 @@ impl RawSpec { name: "ding".to_string(), id: Some(format!("{bus_id}.ding")), command: Some(format!("st2 ding --identity {bus_id} --root $ST_ROOT")), + argv: None, cwd: None, tags: BTreeMap::new(), env: self.env, @@ -317,7 +338,7 @@ impl RawSpec { // `service` is the only job type; a stray `type` string is caught by validate (unknown-type). let job_type = JobType::Service; - AgentSpec { + Ok(AgentSpec { identity, host, role: self.role, @@ -329,31 +350,56 @@ impl RawSpec { restart: self.restart.map(RawRestart::lower), tasks, path, - } + }) } } impl RawTask { pub(crate) fn lower( self, + identity: &str, kind: TaskKind, name: String, inherited_env: &BTreeMap, - ) -> Task { + ) -> anyhow::Result { + validate_launch( + identity, + self.command.as_ref(), + self.argv.as_ref(), + &format!("{kind:?} task '{name}'"), + )?; let mut env = inherited_env.clone(); env.extend(self.env); - Task { + Ok(Task { kind, derived: false, name, id: self.id, command: self.command, + argv: self.argv, cwd: self.cwd, tags: self.tags, env, keep: self.keep, - } + }) + } +} + +fn validate_launch( + identity: &str, + command: Option<&String>, + argv: Option<&Vec>, + location: &str, +) -> anyhow::Result<()> { + if command.is_some() && argv.is_some() { + anyhow::bail!( + "agent '{identity}' {location} declares both `command` and `argv`; choose one launch form" + ); + } + if argv.is_some_and(Vec::is_empty) { + anyhow::bail!("agent '{identity}' {location} declares an empty `argv`"); } + Ok(()) } #[cfg(test)] diff --git a/crates/agent-spec/tests/discovery.rs b/crates/agent-spec/tests/discovery.rs index 31463b8..a7ea8de 100644 --- a/crates/agent-spec/tests/discovery.rs +++ b/crates/agent-spec/tests/discovery.rs @@ -9,7 +9,7 @@ use std::path::Path; use std::time::Duration; use agent_spec::spec::TaskKind; -use agent_spec::{AgentSpec, JobType, discover}; +use agent_spec::{AgentSpec, JobType, Task, discover}; fn write(root: &Path, rel: &str, contents: &str) { let path = root.join(rel); @@ -24,6 +24,15 @@ fn find<'a>(specs: &'a [AgentSpec], identity: &str) -> &'a AgentSpec { .unwrap_or_else(|| panic!("expected a spec with identity '{identity}'")) } +fn argv(task: &Task) -> Vec<&str> { + task.argv + .as_deref() + .unwrap() + .iter() + .map(String::as_str) + .collect() +} + const FULL_KDL: &str = r####" // The agent is the "job"; pty/exec are the tasks. agent "fabric-claude" { @@ -163,6 +172,143 @@ agent "cos" { ); } +#[test] +fn direct_argv_lowers_for_compact_and_explicit_kdl_tasks() { + let tmp = tempfile::tempdir().unwrap(); + write( + tmp.path(), + "agents/h/compact/agent.kdl", + r#"agent "compact" { + host "h" + argv "axe" "agent" "exec" "--" "claude" "--resume" "session id" +}"#, + ); + write( + tmp.path(), + "agents/h/explicit/agent.kdl", + r#"agent "explicit" { + host "h" + pty "agent" { argv "/opt/bin/codex" "--model" "gpt 5" } + exec "probe" { argv "printf" "%s" "$CATALOG" } +}"#, + ); + + let found = discover(tmp.path()); + assert!(found.errors.is_empty(), "{:?}", found.errors); + + let compact = find(&found.specs, "compact"); + assert_eq!( + argv(&compact.tasks[0]), + [ + "axe", + "agent", + "exec", + "--", + "claude", + "--resume", + "session id" + ] + ); + assert!(compact.tasks[0].command.is_none()); + + let explicit = find(&found.specs, "explicit"); + assert_eq!( + argv( + explicit + .tasks + .iter() + .find(|task| task.name == "agent") + .unwrap() + ), + ["/opt/bin/codex", "--model", "gpt 5"] + ); + assert_eq!( + argv( + explicit + .tasks + .iter() + .find(|task| task.name == "probe") + .unwrap() + ), + ["printf", "%s", "$CATALOG"] + ); +} + +#[test] +fn direct_argv_lowers_from_toml_and_json() { + let tmp = tempfile::tempdir().unwrap(); + write( + tmp.path(), + "agents/h/toml/agent.toml", + r#"identity = "toml" +host = "h" +argv = ["claude", "--resume", "session id"] +"#, + ); + write( + tmp.path(), + "agents/h/json/agent.json", + r#"{"identity":"json","host":"h","pty":{"agent":{"argv":["codex","resume","abc"]}}}"#, + ); + + let found = discover(tmp.path()); + assert!(found.errors.is_empty(), "{:?}", found.errors); + assert_eq!( + argv(&find(&found.specs, "toml").tasks[0]), + ["claude", "--resume", "session id"] + ); + assert_eq!( + argv(&find(&found.specs, "json").tasks[0]), + ["codex", "resume", "abc"] + ); +} + +#[test] +fn empty_or_ambiguous_argv_is_rejected_in_every_task_shape() { + let tmp = tempfile::tempdir().unwrap(); + for (identity, body) in [ + ("empty-compact", r#"argv"#), + ( + "both-compact", + r#"command "true" + argv "true""#, + ), + ("empty-explicit", r#"pty "agent" { argv }"#), + ( + "both-explicit", + r#"pty "agent" { command "true"; argv "true" }"#, + ), + ] { + write( + tmp.path(), + &format!("agents/h/{identity}/agent.kdl"), + &format!("agent \"{identity}\" {{ host \"h\"; {body} }}"), + ); + } + + let found = discover(tmp.path()); + assert_eq!(found.errors.len(), 4, "{:?}", found.errors); + let messages = found + .errors + .iter() + .map(|error| error.message.as_str()) + .collect::>(); + assert_eq!( + messages + .iter() + .filter(|message| message.contains("empty `argv`")) + .count(), + 2 + ); + assert_eq!( + messages + .iter() + .filter(|message| message.contains("both `command` and `argv`")) + .count(), + 2 + ); +} + #[test] fn compact_and_explicit_agent_task_forms_cannot_be_mixed() { let tmp = tempfile::tempdir().unwrap(); @@ -180,7 +326,7 @@ fn compact_and_explicit_agent_task_forms_cannot_be_mixed() { assert!( found.errors[0] .message - .contains("declares both compact `command`") + .contains("declares both a compact launch") ); } diff --git a/examples/native/agent-claude.kdl b/examples/native/agent-claude.kdl index 8859fef..1ca0cf6 100644 --- a/examples/native/agent-claude.kdl +++ b/examples/native/agent-claude.kdl @@ -13,7 +13,7 @@ agent "" { // The rendered bus contract requires agent-declared status: busy while executing work, available // only when yielding/ready, and dnd only for an explicit hold. - command #"exec claude --permission-mode bypassPermissions ''"# + argv "claude" "--permission-mode" "bypassPermissions" "" ding render { diff --git a/examples/native/agent-codex.kdl b/examples/native/agent-codex.kdl index 4432824..af2cfd9 100644 --- a/examples/native/agent-codex.kdl +++ b/examples/native/agent-codex.kdl @@ -14,7 +14,7 @@ agent "" { // st2 owns the hook declaration and installed scripts for this unattended seat. The rendered bus // contract requires agent-declared status: busy while executing work, available only when // yielding/ready, and dnd only for an explicit hold. - command #"exec codex --dangerously-bypass-approvals-and-sandbox --dangerously-bypass-hook-trust ''"# + argv "codex" "--dangerously-bypass-approvals-and-sandbox" "--dangerously-bypass-hook-trust" "" ding render { diff --git a/src/compile_agent.rs b/src/compile_agent.rs index 5644024..ed677da 100644 --- a/src/compile_agent.rs +++ b/src/compile_agent.rs @@ -25,51 +25,40 @@ impl AgentInput { format!("{}.{}", self.host, self.identity) } - fn command(&self) -> anyhow::Result { + fn argv(&self) -> anyhow::Result> { let boot = if self.role == "chief-of-staff" { "You just cold-started. Read AGENTS.md and run your st2 boot ritual now: set status available and drain your inbox. Before resuming or starting work, set status busy; set available only when yielding or ready for new work. Resume unfinished durable work immediately; stand by for work delivered via ding only when no unfinished durable work remains." } else { "You just cold-started. Run your st2 boot ritual now: set status available and drain your inbox. Before resuming or starting work, set status busy; set available only when yielding or ready for new work. Resume unfinished durable work immediately; stand by for work delivered via ding only when no unfinished durable work remains." }; - let model = self - .model - .as_deref() - .map(|model| format!(" --model {}", shell_single_quote(model))) - .unwrap_or_default(); - let extra: String = self - .extra_args - .iter() - .map(|arg| format!(" {}", shell_single_quote(arg))) - .collect(); - match self.harness.as_str() { - "claude" => Ok(format!( - "exec claude --permission-mode bypassPermissions{model}{extra} {}", - shell_single_quote(boot) - )), - "codex" => Ok(format!( - "exec codex --dangerously-bypass-approvals-and-sandbox \ - --dangerously-bypass-hook-trust{model}{extra} {}", - shell_single_quote(boot) - )), + let mut argv = match self.harness.as_str() { + "claude" => vec![ + "claude".to_string(), + "--permission-mode".to_string(), + "bypassPermissions".to_string(), + ], + "codex" => vec![ + "codex".to_string(), + "--dangerously-bypass-approvals-and-sandbox".to_string(), + "--dangerously-bypass-hook-trust".to_string(), + ], other => anyhow::bail!( "compile-agent: harness '{other}' is not supported (expected claude or codex)" ), + }; + if let Some(model) = &self.model { + argv.extend(["--model".to_string(), model.clone()]); } + argv.extend(self.extra_args.iter().cloned()); + argv.push(boot.to_string()); + Ok(argv) } } -fn shell_single_quote(value: &str) -> String { - format!("'{}'", value.replace('\'', "'\\''")) -} - fn kdl_str(value: &str) -> String { format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\"")) } -fn kdl_raw(value: &str) -> String { - format!("#\"{value}\"#") -} - fn kdl_multiline_raw(value: &str) -> String { format!("#\"\"\"\n{value}\n\"\"\"#") } @@ -170,7 +159,7 @@ pub fn compile_agent( fs::write(templates.join(&agents_name), agents)?; } - let command = input.command()?; + let argv = input.argv()?; let hooks = serde_json::to_string_pretty(&hook_json(&input.harness))?; let mut kdl = String::new(); kdl.push_str( @@ -186,7 +175,12 @@ pub fn compile_agent( kdl.push_str(" env {\n"); kdl.push_str(&format!(" ST_AGENT {}\n", kdl_str(&input.bus_id()))); kdl.push_str(" }\n"); - kdl.push_str(&format!(" command {}\n", kdl_raw(&command))); + kdl.push_str(" argv"); + for arg in argv { + kdl.push(' '); + kdl.push_str(&kdl_str(&arg)); + } + kdl.push('\n'); kdl.push_str(" ding\n\n"); kdl.push_str(" render {\n"); if input.harness == "codex" { diff --git a/src/eval_run.rs b/src/eval_run.rs index 7a61fb0..ecb0861 100644 --- a/src/eval_run.rs +++ b/src/eval_run.rs @@ -66,6 +66,7 @@ pub fn spec_to_agent_specs(agents: &[SpecAgent], host: &str, root: &Path) -> Vec name: "agent".to_string(), id: Some(a.id.clone()), // explicit id → the session is exactly the agent id (mix.sup) command: Some(a.command.clone()), + argv: None, cwd: None, // → the agent's workspace (resolved relative to `root`) tags: ptags, env: a.env.clone(), @@ -78,6 +79,7 @@ pub fn spec_to_agent_specs(agents: &[SpecAgent], host: &str, root: &Path) -> Vec name: ex.id.clone(), id: Some(ex.id.clone()), command: Some(ex.command.clone()), + argv: None, cwd: None, tags: BTreeMap::new(), env: ex.env.clone(), diff --git a/src/exec_backend.rs b/src/exec_backend.rs index 167bcc7..97eb580 100644 --- a/src/exec_backend.rs +++ b/src/exec_backend.rs @@ -2,25 +2,26 @@ //! //! `pty` tasks go to the `pty` CLI (which allocates a pseudo-terminal — an agent harness). `exec` //! tasks (the ding, daemons, a stage's script) must NOT allocate a terminal, so st2 supervises them -//! directly: `sh -c ''` spawned in its own session (`setsid` — no controlling tty, and -//! decoupled from st2 so it survives st2 exit, Nomad-style), stdout/stderr to a log file, and the pid -//! recorded in a machine-local runner-state dir. Liveness is `kill(pid, 0)`; st2 best-effort reaps -//! its own exited children so a zombie never reads as alive. The same `setsid` session doubles as the -//! teardown unit: an explicit kill targets the whole process GROUP, so a task's children die with it -//! (see [`ExecBackend::kill`]) — a plain stop of st2 never touches it. +//! directly: shell source through `sh -c`, or a structured argv with no shell. The process is spawned +//! in its own session (`setsid` — no controlling tty, and decoupled from st2 so it survives st2 exit, +//! Nomad-style), stdout/stderr go to a log file, and the pid is recorded in a machine-local +//! runner-state dir. Liveness is `kill(pid, 0)`; st2 best-effort reaps its own exited children so a +//! zombie never reads as alive. The same `setsid` session doubles as the teardown unit: an explicit +//! kill targets the whole process GROUP, so a task's children die with it (see +//! [`ExecBackend::kill`]) — a plain stop of st2 never touches it. //! //! State is machine-local (pids don't sync across hosts) and keyed by host, so a restarted st2 on the //! same host adopts the exec processes it left running. use anyhow::Context; -use std::ffi::OsStr; +use std::ffi::OsString; use std::fs; use std::os::unix::process::CommandExt; use std::path::{Path, PathBuf}; use std::process::Stdio; use crate::host_lock::process_alive; -use crate::reconcile::{Session, TaskTarget}; +use crate::reconcile::{Session, TaskLaunch, TaskTarget}; use crate::run::resolve_task_cwd; /// Supervises `exec` tasks as terminal-free processes. @@ -73,11 +74,25 @@ impl ExecBackend { // kill cannot take it; elsewhere a plain pass-through detached by the `setsid` below. Env, cwd, // and stdio set here reach the task in both modes. let unit = crate::isolate::scope_unit(&target.pty_id); - let mut cmd = crate::isolate::wrap( - &unit, - OsStr::new("sh"), - &[OsStr::new("-c"), OsStr::new(&target.command)], - ); + let (program, args): (OsString, Vec) = match &target.launch { + TaskLaunch::Shell(command) => ( + OsString::from("sh"), + vec![OsString::from("-c"), OsString::from(command)], + ), + TaskLaunch::Argv(argv) => { + debug_assert!(!argv.is_empty()); + let mut expanded = argv + .iter() + .map(|arg| { + OsString::from(crate::expand::expand_catalog(arg, &self.catalog_root)) + }) + .collect::>(); + let program = expanded.remove(0); + (program, expanded) + } + }; + let arg_refs = args.iter().map(OsString::as_os_str).collect::>(); + let mut cmd = crate::isolate::wrap(&unit, &program, &arg_refs); cmd.current_dir(&cwd) .stdin(Stdio::null()) .stdout(log.try_clone()?) diff --git a/src/hooks.rs b/src/hooks.rs index 522711b..5a406a2 100644 --- a/src/hooks.rs +++ b/src/hooks.rs @@ -135,10 +135,27 @@ pub fn required_by_codex(specs: &[agent_spec::spec::AgentSpec], this_host: &str) pub fn required_by_codex_agent(spec: &agent_spec::spec::AgentSpec, this_host: &str) -> bool { spec.host.as_deref().is_none_or(|host| host == this_host) && spec.tasks.iter().any(|task| { - task.name == "agent" && task.command.as_deref().is_some_and(command_invokes_codex) + task.name == "agent" + && (task.command.as_deref().is_some_and(command_invokes_codex) + || task.argv.as_deref().is_some_and(argv_invokes_codex)) }) } +pub(crate) fn launch_invokes_codex(launch: &crate::reconcile::TaskLaunch) -> bool { + match launch { + crate::reconcile::TaskLaunch::Shell(command) => command_invokes_codex(command), + crate::reconcile::TaskLaunch::Argv(argv) => argv_invokes_codex(argv), + } +} + +fn argv_invokes_codex(argv: &[String]) -> bool { + argv.first().is_some_and(|program| { + Path::new(program) + .file_name() + .is_some_and(|name| name == "codex") + }) +} + /// Recognize the exact command shape emitted for Codex agents while accepting an absolute binary /// path in a hand-authored declaration. Arbitrary shell pipelines and incidental arguments are not /// guessed through. @@ -395,6 +412,17 @@ mod tests { assert!(!command_invokes_codex( "exec /opt/bin/codex-wrapper --model x" )); + assert!(argv_invokes_codex(&[ + "codex".into(), + "--model".into(), + "x".into() + ])); + assert!(argv_invokes_codex(&[ + "/opt/bin/codex".into(), + "resume".into() + ])); + assert!(!argv_invokes_codex(&[])); + assert!(!argv_invokes_codex(&["codex-wrapper".into()])); } #[test] @@ -448,11 +476,7 @@ mod tests { let mut successor = expected_receipt(); successor.manifest.hookset = "sha256-successor".into(); successor.manifest.directory = "sets/sha256-successor".into(); - fs::write( - receipt_path(tmp.path()), - receipt_bytes(&successor).unwrap(), - ) - .unwrap(); + fs::write(receipt_path(tmp.path()), receipt_bytes(&successor).unwrap()).unwrap(); assert_eq!(verify_required_set_at(tmp.path()).unwrap(), required); assert!(verify_installed_at(tmp.path()).is_err()); @@ -468,26 +492,32 @@ mod tests { let mut receipt = read_receipt(stale.path()).unwrap(); receipt.manifest.hookset = "sha256-stale".into(); fs::write(receipt_path(stale.path()), receipt_bytes(&receipt).unwrap()).unwrap(); - assert!(verify_installed_at(stale.path()) - .unwrap_err() - .to_string() - .contains("requires")); + assert!( + verify_installed_at(stale.path()) + .unwrap_err() + .to_string() + .contains("requires") + ); let partial = tempfile::tempdir().unwrap(); let partial_dir = install_at(partial.path(), false).unwrap(); fs::remove_file(partial_dir.join("codex-stop.sh")).unwrap(); - assert!(verify_installed_at(partial.path()) - .unwrap_err() - .to_string() - .contains("codex-stop.sh")); + assert!( + verify_installed_at(partial.path()) + .unwrap_err() + .to_string() + .contains("codex-stop.sh") + ); let mismatch = tempfile::tempdir().unwrap(); let mismatch_dir = install_at(mismatch.path(), false).unwrap(); fs::write(mismatch_dir.join("codex-stop.sh"), b"wrong\n").unwrap(); - assert!(verify_installed_at(mismatch.path()) - .unwrap_err() - .to_string() - .contains("content mismatch")); + assert!( + verify_installed_at(mismatch.path()) + .unwrap_err() + .to_string() + .contains("content mismatch") + ); } #[test] diff --git a/src/lib.rs b/src/lib.rs index 5ee2c75..94dc5bc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -42,7 +42,7 @@ pub use exec_backend::ExecBackend; pub use expand::{expand_env, expand_vars}; pub use flapping::FlappingCap; pub use host_lock::HostLock; -pub use reconcile::{Launch, ReconcilePlan, Session, TaskTarget, Teardown, reconcile}; +pub use reconcile::{Launch, ReconcilePlan, Session, TaskLaunch, TaskTarget, Teardown, reconcile}; pub use run::{ PtyCli, Runner, SystemRunner, UpReport, detect_host, down, down_specs, exec_state_dir, execute, up_loop, up_loop_specs, up_once, up_once_specs, diff --git a/src/main.rs b/src/main.rs index 7cc8208..d716328 100644 --- a/src/main.rs +++ b/src/main.rs @@ -589,7 +589,12 @@ fn main() -> Result<()> { env_cmd(&root) } Command::Pretrust { dirs } => pretrust_cmd(&dirs), - Command::Eval { folder, host, keep, json } => eval_cmd(&folder, host, keep, json), + Command::Eval { + folder, + host, + keep, + json, + } => eval_cmd(&folder, host, keep, json), Command::Validate { root, host, @@ -728,12 +733,18 @@ fn eval_cmd(folder: &Path, host: Option, keep: bool, json: bool) -> Resu ) })?; let keep = keep || std::env::var_os("ST2_EVAL_KEEP").is_some(); - if json { unsafe { std::env::set_var("ST2_EVAL_JSON", "1"); } } + if json { + unsafe { + std::env::set_var("ST2_EVAL_JSON", "1"); + } + } let report = st2::eval_run::run_eval(&spec_file, host, keep)?; if json { println!("{}", serde_json::to_string_pretty(&report)?); - if report.passed() { return Ok(()); } + if report.passed() { + return Ok(()); + } anyhow::bail!("VERDICT: FAIL") } @@ -1914,7 +1925,7 @@ fn ls(root: &Path) -> Result<()> { let runnable = if spec.is_runnable() { "" } else { - " [UNRENDERED: no task command]" + " [UNRENDERED: no task launch]" }; let retired = if spec.retired { " [retired]" } else { "" }; println!( @@ -1929,8 +1940,12 @@ fn ls(root: &Path) -> Result<()> { st2::TaskKind::Pty => "pty", st2::TaskKind::Exec => "exec", }; - let cmd = task.command.as_deref().unwrap_or(""); - println!(" - {tk} {name}: {cmd}", name = task.name); + let launch = match (&task.command, &task.argv) { + (Some(command), _) => format!("command {command}"), + (_, Some(argv)) => format!("argv {argv:?}"), + _ => "".to_string(), + }; + println!(" - {tk} {name}: {launch}", name = task.name); } } diff --git a/src/reconcile.rs b/src/reconcile.rs index fd08594..0f4f054 100644 --- a/src/reconcile.rs +++ b/src/reconcile.rs @@ -26,7 +26,7 @@ pub struct Session { } /// A concrete task st2 should spawn — everything a backend needs, resolved from the spec. Produced -/// only for tasks that carry an explicit `command`. +/// only for tasks that carry an explicit `command` or `argv`. #[derive(Debug, Clone, PartialEq, Eq)] pub struct TaskTarget { /// `pty` (terminal) or `exec` (terminal-free) — selects the backend. @@ -37,8 +37,8 @@ pub struct TaskTarget { pub bus_id: String, /// The task name (`agent`, `ding`, …). pub name: String, - /// The exact command line to exec, run verbatim under `sh -c`. - pub command: String, + /// How to launch the task: shell source or a direct program argument vector. + pub launch: TaskLaunch, /// Declared working dir; `None` → default to `workspace`, else the spec dir (resolved at spawn). pub cwd: Option, /// The agent's workspace — the cwd default when `cwd` is unset. @@ -49,6 +49,15 @@ pub struct TaskTarget { pub keep: bool, } +/// A resolved task launch accepted by the execution backends. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TaskLaunch { + /// Shell source, preserved verbatim and passed to `sh -c`. + Shell(String), + /// A non-empty vector whose first element is the program. + Argv(Vec), +} + /// An agent to launch, with the specific tasks that are missing (not already live). #[derive(Debug, Clone, PartialEq, Eq)] pub struct Launch<'a> { @@ -74,7 +83,7 @@ pub struct ReconcilePlan<'a> { pub adopt: Vec<&'a AgentSpec>, /// host != this machine → skipped; another machine's st2 owns it. pub other_host: Vec<&'a AgentSpec>, - /// This host, active service, but no task carries a command (unrendered) → nothing to run. + /// This host, active service, but no task carries a launch (unrendered) → nothing to run. pub unrunnable: Vec<&'a AgentSpec>, /// Dead, non-`keep` sessions of declared tasks → reap (`rm`). pub gc: Vec, @@ -104,9 +113,15 @@ fn resolve_task_id(bus_id: &str, name: &str, explicit: Option<&str>) -> String { } /// Compute the reconcile plan for `specs` given observed `sessions`, filtering to `this_host`. -pub fn reconcile<'a>(specs: &'a [AgentSpec], sessions: &[Session], this_host: &str) -> ReconcilePlan<'a> { - let by_id: HashMap<&str, bool> = - sessions.iter().map(|s| (s.pty_id.as_str(), s.alive)).collect(); +pub fn reconcile<'a>( + specs: &'a [AgentSpec], + sessions: &[Session], + this_host: &str, +) -> ReconcilePlan<'a> { + let by_id: HashMap<&str, bool> = sessions + .iter() + .map(|s| (s.pty_id.as_str(), s.alive)) + .collect(); let mut plan = ReconcilePlan::default(); for spec in specs { @@ -128,7 +143,10 @@ pub fn reconcile<'a>(specs: &'a [AgentSpec], sessions: &[Session], this_host: &s } } if !teardown_ids.is_empty() { - plan.teardown.push(Teardown { spec, pty_ids: teardown_ids }); + plan.teardown.push(Teardown { + spec, + pty_ids: teardown_ids, + }); } continue; } @@ -142,7 +160,14 @@ pub fn reconcile<'a>(specs: &'a [AgentSpec], sessions: &[Session], this_host: &s .tasks .iter() .filter_map(|t| { - let command = t.command.clone()?; + let launch = match (&t.command, &t.argv) { + (Some(command), None) => TaskLaunch::Shell(command.clone()), + (None, Some(argv)) => TaskLaunch::Argv(argv.clone()), + (None, None) => return None, + (Some(_), Some(_)) => { + unreachable!("discovery rejects tasks carrying both command and argv") + } + }; // `supervisor` is the single source of truth. Hooks and harnesses consume the // derived environment variable, but catalog authors/renderers never need to // duplicate the relationship in env{} (and cannot accidentally make it disagree). @@ -157,7 +182,7 @@ pub fn reconcile<'a>(specs: &'a [AgentSpec], sessions: &[Session], this_host: &s pty_id: resolve_task_id(&bus_id, &t.name, t.id.as_deref()), bus_id: bus_id.clone(), name: t.name.clone(), - command, + launch, cwd: t.cwd.clone(), workspace: spec.workspace.clone(), tags: t.tags.clone(), @@ -185,7 +210,10 @@ pub fn reconcile<'a>(specs: &'a [AgentSpec], sessions: &[Session], this_host: &s if to_launch.is_empty() { plan.adopt.push(spec); } else { - plan.launch.push(Launch { spec, tasks: to_launch }); + plan.launch.push(Launch { + spec, + tasks: to_launch, + }); } } plan diff --git a/src/run.rs b/src/run.rs index 7a868d0..efbcf31 100644 --- a/src/run.rs +++ b/src/run.rs @@ -2,10 +2,10 @@ //! plus the supervisor loop that reconciles on a folder-watch + timer. //! //! Everything st2 does to the world goes through the [`Runner`] trait: list sessions, spawn a pty -//! from its explicit command, kill a session, remove a dead one. The production [`PtyCli`] shells out +//! from its explicit launch, kill a session, remove a dead one. The production [`PtyCli`] shells out //! to the `pty` CLI; tests swap in a fake, so plan execution is verified without spawning a single -//! real process. st2 stays harness-agnostic here too — it runs the spec's `command` verbatim under -//! `sh -c` and never inspects it. +//! real process. st2 stays harness-agnostic here too — it either runs shell source verbatim under +//! `sh -c` or passes a structured argv directly. //! //! The loop is decoupled Nomad-style: stopping st2 never tears down its agents — they are detached //! pty sessions and keep running; only a `retired` spec tears an agent down. @@ -26,7 +26,7 @@ use serde::Deserialize; use crate::exec_backend::ExecBackend; use crate::flapping::FlappingCap; use crate::message; -use crate::reconcile::{ReconcilePlan, Session, TaskTarget}; +use crate::reconcile::{ReconcilePlan, Session, TaskLaunch, TaskTarget}; use agent_spec::spec::TaskKind; const PTY_LIST_TIMEOUT: Duration = Duration::from_secs(2); @@ -104,7 +104,7 @@ pub(crate) fn resolve_task_cwd( pub trait Runner { /// ACTUAL state: every task session the runner can see (unioned across backends). fn list_sessions(&self) -> anyhow::Result>; - /// Spawn `target` in the background from its explicit command. `spec_dir` is the spec file's + /// Spawn `target` in the background from its explicit launch. `spec_dir` is the spec file's /// directory — part of the cwd fallback chain (task.cwd → workspace → spec dir). fn spawn(&self, target: &TaskTarget, spec_dir: &Path) -> anyhow::Result<()>; /// SIGTERM a running session. @@ -221,8 +221,8 @@ impl PtyCli { /// env can be unit-tested without spawning anything. /// /// `$VAR`s are expanded here for everything that does NOT pass through a shell — env values, tag - /// values, and `cwd` — because `pty` passes the child env through verbatim. The `command` is left - /// unexpanded: `sh -c` expands it at spawn from the same env (which includes `$CATALOG`). + /// values, `cwd`, and direct argv — because `pty` passes them through verbatim. Shell source is + /// left unexpanded: `sh -c` expands it at spawn from the same env (which includes `$CATALOG`). fn build_run_command(&self, target: &TaskTarget, spec_dir: &Path) -> Command { let cwd = self.resolve_cwd(target, spec_dir); let mut cmd = Command::new(&self.bin); @@ -260,8 +260,18 @@ impl PtyCli { assignment.push(value); cmd.arg("--env").arg(assignment); } - // Run the command verbatim under a shell — st2 never parses or splits it. - cmd.arg("--").arg("sh").arg("-c").arg(&target.command); + cmd.arg("--"); + match &target.launch { + // Run shell source verbatim — st2 never parses or splits it. + TaskLaunch::Shell(command) => { + cmd.arg("sh").arg("-c").arg(command); + } + // Direct mode preserves argument boundaries and introduces no shell process. + TaskLaunch::Argv(argv) => { + debug_assert!(!argv.is_empty()); + cmd.args(argv.iter().map(|arg| self.expand(arg))); + } + } cmd } } @@ -830,7 +840,7 @@ fn gate_codex_launches_on_hooks<'a, V>( let mut gated_agents = Vec::new(); for launch in &plan.launch { let Some(_) = launch.tasks.iter().find(|target| { - target.name == "agent" && crate::hooks::command_invokes_codex(&target.command) + target.name == "agent" && crate::hooks::launch_invokes_codex(&target.launch) }) else { continue; }; @@ -1225,7 +1235,7 @@ mod tests { pty_id: id.to_string(), bus_id: "hetz.demo".to_string(), name: "agent".to_string(), - command: cmd.to_string(), + launch: TaskLaunch::Shell(cmd.to_string()), cwd: None, workspace: None, tags: BTreeMap::new(), @@ -1477,11 +1487,9 @@ mod tests { }); let mut report = UpReport::default(); - gate_codex_launches_on_hooks( - &mut plan, - &mut report, - || panic!("an already-live Codex agent must not enter the hook gate"), - ); + gate_codex_launches_on_hooks(&mut plan, &mut report, || { + panic!("an already-live Codex agent must not enter the hook gate") + }); assert_eq!(plan.adopt, [&spec]); assert_eq!(plan.launch.len(), 1); @@ -1511,9 +1519,7 @@ mod tests { }); let mut report = UpReport::default(); - gate_codex_launches_on_hooks(&mut plan, &mut report, || { - anyhow::bail!("stale receipt") - }); + gate_codex_launches_on_hooks(&mut plan, &mut report, || anyhow::bail!("stale receipt")); assert_eq!( plan.launch @@ -1551,7 +1557,49 @@ mod tests { let name_pos = args.iter().position(|a| a == "--name").unwrap(); assert_eq!(args[name_pos + 1], "hetz.demo"); let sep = args.iter().position(|a| a == "--").unwrap(); - assert_eq!(&args[sep + 1..], &["sh", "-c", &t.command]); + assert_eq!( + &args[sep + 1..], + &[ + "sh", + "-c", + "exec claude --permission-mode bypassPermissions 'boot'" + ] + ); + } + + #[test] + fn build_run_command_passes_direct_argv_without_a_shell() { + let cli = PtyCli::new(PathBuf::from("/my/catalog")); + let mut t = target("hetz.demo.agent", "unused"); + t.launch = TaskLaunch::Argv(vec![ + "axe".into(), + "agent".into(), + "exec".into(), + "--".into(), + "claude".into(), + "--resume".into(), + "$CATALOG/session id".into(), + ]); + let cmd = cli.build_run_command(&t, Path::new("/cat/hetz/demo")); + let args = cmd + .get_args() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect::>(); + let sep = args.iter().position(|arg| arg == "--").unwrap(); + + assert_eq!( + &args[sep + 1..], + [ + "axe", + "agent", + "exec", + "--", + "claude", + "--resume", + "/my/catalog/session id" + ] + ); + assert!(!args[sep + 1..].iter().any(|arg| arg == "sh")); } #[test] diff --git a/src/validate.rs b/src/validate.rs index 6862432..9079719 100644 --- a/src/validate.rs +++ b/src/validate.rs @@ -247,7 +247,7 @@ fn validate_scoped(root: &Path, this_host: Option<&str>) -> Report { "not-runnable", rp.clone(), ag.clone(), - "service agent has no task with a command (unrendered, or the renderer emitted none)" + "service agent has no task with `command` or `argv` (unrendered, or the renderer emitted none)" .to_string(), )); } diff --git a/tests/compile_agent.rs b/tests/compile_agent.rs index 45e5b1a..4156db4 100644 --- a/tests/compile_agent.rs +++ b/tests/compile_agent.rs @@ -75,7 +75,9 @@ fn compile_agent_generates_claude_then_materializes_verbatim_persona() { let kdl = fs::read_to_string(&declaration).unwrap(); assert!(kdl.starts_with("// Experimental output from `st2 compile-agent`.")); assert!(kdl.contains("supervisor \"lead\"")); - assert!(kdl.contains("'--verbose'")); + assert!(kdl.contains("\"--verbose\"")); + assert!(kdl.contains("argv \"claude\"")); + assert!(!kdl.contains("command #\"exec claude")); assert!(kdl.contains("set status busy")); assert!(!kdl.contains("ST_SUPERVISOR")); assert!(!workspace.join(".st2/PERSONA.md").exists()); @@ -90,6 +92,16 @@ fn compile_agent_generates_claude_then_materializes_verbatim_persona() { assert_eq!(spec.host.as_deref(), Some("h")); assert_eq!(spec.role.as_deref(), Some("supervisor")); assert_eq!(spec.supervisor.as_deref(), Some("lead")); + assert_eq!( + spec.tasks + .iter() + .find(|task| task.name == "agent") + .unwrap() + .argv + .as_ref() + .unwrap()[0], + "claude" + ); let validate = Command::new(env!("CARGO_BIN_EXE_st2")) .arg("validate") @@ -237,7 +249,8 @@ fn compile_agent_generates_codex_then_materializes_composed_agents_md() { ); let kdl = fs::read_to_string(catalog.join("agents/h/worker/agent.kdl")).unwrap(); - assert!(kdl.contains("exec codex")); + assert!(kdl.contains("argv \"codex\"")); + assert!(!kdl.contains("exec codex")); assert!(kdl.contains("set status busy")); assert!(kdl.contains("--dangerously-bypass-hook-trust")); assert!(kdl.contains("json-upsert \".codex/hooks.json\"")); diff --git a/tests/exec_backend.rs b/tests/exec_backend.rs index c36c477..ef2d13b 100644 --- a/tests/exec_backend.rs +++ b/tests/exec_backend.rs @@ -2,13 +2,14 @@ use std::collections::BTreeMap; use std::fs; +#[cfg(target_os = "macos")] use std::process::Command; use std::thread::sleep; use std::time::Duration; use st2::exec_backend::ExecBackend; use st2::host_lock::process_alive; -use st2::reconcile::TaskTarget; +use st2::reconcile::{TaskLaunch, TaskTarget}; use st2::spec::TaskKind; fn exec_target(id: &str, command: &str) -> TaskTarget { @@ -17,7 +18,7 @@ fn exec_target(id: &str, command: &str) -> TaskTarget { pty_id: id.to_string(), bus_id: "hetz.demo".to_string(), name: "ding".to_string(), - command: command.to_string(), + launch: TaskLaunch::Shell(command.to_string()), cwd: None, workspace: None, tags: BTreeMap::new(), @@ -26,6 +27,12 @@ fn exec_target(id: &str, command: &str) -> TaskTarget { } } +fn argv_target(id: &str, argv: &[&str]) -> TaskTarget { + let mut target = exec_target(id, "unused"); + target.launch = TaskLaunch::Argv(argv.iter().map(|arg| (*arg).to_string()).collect()); + target +} + /// tty_nr from /proc//stat (0 == no controlling terminal). #[cfg(target_os = "linux")] fn tty_nr(pid: i32) -> Option { @@ -62,11 +69,18 @@ fn exec_spawns_terminal_free_process_tracks_liveness_kills_and_cleans_up() { // pid file recorded let pid_path = state.join(format!("{id}.pid")); assert!(pid_path.exists(), "pid file written"); - let pid: i32 = fs::read_to_string(&pid_path).unwrap().trim().parse().unwrap(); + let pid: i32 = fs::read_to_string(&pid_path) + .unwrap() + .trim() + .parse() + .unwrap(); // liveness: alive let sessions = backend.list().unwrap(); - assert!(sessions.iter().any(|s| s.pty_id == id && s.alive), "reported alive"); + assert!( + sessions.iter().any(|s| s.pty_id == id && s.alive), + "reported alive" + ); // R09: the process has NO controlling terminal. assert_eq!(tty_nr(pid), Some(0), "exec process must be terminal-free"); @@ -74,7 +88,11 @@ fn exec_spawns_terminal_free_process_tracks_liveness_kills_and_cleans_up() { // kill → becomes dead backend.kill(id).unwrap(); assert!( - wait_until(|| backend.list().unwrap().iter().any(|s| s.pty_id == id && !s.alive)), + wait_until(|| backend + .list() + .unwrap() + .iter() + .any(|s| s.pty_id == id && !s.alive)), "reported dead after kill" ); @@ -94,12 +112,16 @@ fn exec_expands_catalog_in_env_and_command() { let out = tmp.path().join("out.txt"); // $CATALOG in the command (sh -c expands it via the injected CATALOG env) and an env value. - let mut target = - exec_target("hetz.demo.probe", &format!("printf '%s|%s' \"$CATALOG\" \"$DATA\" > {}", out.display())); + let mut target = exec_target( + "hetz.demo.probe", + &format!("printf '%s|%s' \"$CATALOG\" \"$DATA\" > {}", out.display()), + ); target.env.insert("DATA".into(), "$CATALOG/x".into()); backend.spawn(&target, tmp.path()).unwrap(); - assert!(wait_until(|| out.exists() && !fs::read_to_string(&out).unwrap().is_empty())); + assert!(wait_until( + || out.exists() && !fs::read_to_string(&out).unwrap().is_empty() + )); let got = fs::read_to_string(&out).unwrap(); let cat = catalog.display().to_string(); assert_eq!(got, format!("{cat}|{cat}/x")); @@ -108,6 +130,44 @@ fn exec_expands_catalog_in_env_and_command() { backend.remove("hetz.demo.probe").ok(); } +#[test] +fn exec_launches_direct_argv_with_literal_boundaries_and_catalog_expansion() { + let tmp = tempfile::tempdir().unwrap(); + let state = tmp.path().join("state"); + let catalog = tmp.path().join("catalog"); + fs::create_dir_all(&catalog).unwrap(); + let backend = ExecBackend::new(state, catalog.clone()); + + let id = "hetz.demo.direct"; + backend + .spawn( + &argv_target( + id, + &[ + "printf", + "%s|%s|%s\n", + "space arg", + "$CATALOG/result", + "; echo not-shell-syntax", + ], + ), + tmp.path(), + ) + .unwrap(); + + let log = catalog.join("logs").join(format!("{id}.log")); + let expected = format!( + "space arg|{}/result|; echo not-shell-syntax\n", + catalog.display() + ); + assert!( + wait_until(|| fs::read_to_string(&log).unwrap_or_default() == expected), + "direct argv was not preserved: {:?}", + fs::read_to_string(&log).ok() + ); + backend.remove(id).unwrap(); +} + /// Auto-log observability: a detached exec's stdout AND stderr must be captured to a discoverable /// `/logs/