Skip to main content

mdtask_core/
run.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::path::{Path, PathBuf};
3
4use crate::cancel::Cancel;
5use crate::deps::{Step, dependency_order};
6use crate::model::{Invocation, Job, RunError, TaskFile};
7
8/// The jobs a set of layered files exposes to an agent or MCP surface: one per
9/// name using the **nearest** definition (so a nearer non-allowed job shadows a
10/// farther allowed one, matching run semantics), keeping only those whose nearest
11/// definition carries `Agent: allow`. This is the enforcement point for listing;
12/// [`run_agent`] is the enforcement point for running. A surface exposing jobs to
13/// an agent should list only these.
14pub fn agent_jobs(files: &[(PathBuf, TaskFile)]) -> Vec<&Job> {
15    let mut seen = BTreeSet::new();
16    let mut out = Vec::new();
17    for (_, tf) in files {
18        for job in &tf.jobs {
19            if seen.insert(job.name.clone()) && job.agent_allow {
20                out.push(job);
21            }
22        }
23    }
24    out
25}
26
27/// The nearest definition of `name` across the layered files, plus the file that
28/// owns it and that file's directory (`None` when the path has no directory part).
29/// A nearer definition wins (the fallback layering), so this resolves both a
30/// target and each `Requires:` dependency the same way the CLI does.
31fn trusted_lookup<'a>(
32    files: &'a [(PathBuf, TaskFile)],
33    name: &str,
34) -> Option<(&'a TaskFile, &'a Job, Option<&'a Path>)> {
35    files
36        .iter()
37        .find_map(|(p, tf)| tf.job(name).map(|j| (tf, j, p.parent())))
38}
39
40/// Resolve `target` and its `Requires:` chain across the layered files (deps
41/// first, target last, each once). A name that resolves nowhere is `NotFound`.
42fn trusted_order(files: &[(PathBuf, TaskFile)], target: &str) -> Result<Vec<Step>, RunError> {
43    if trusted_lookup(files, target).is_none() {
44        return Err(RunError::NotFound(target.to_string()));
45    }
46    dependency_order(target, |n| {
47        trusted_lookup(files, n).map(|(_, j, _)| j.requires.clone())
48    })
49    .map_err(RunError::Dependency)
50}
51
52/// Build the ordered, ready-to-spawn invocations for `order`. `lookup` resolves
53/// each step to its file, job, and directory.
54///
55/// The target receives the caller's `args`. A dependency receives whatever its
56/// `Requires:` entry declared, with `{{ name }}` resolved against **the
57/// invocation's** arguments: the values bound to the task actually named on the
58/// command line.
59///
60/// One scope for the whole chain, rather than each job resolving against its own
61/// caller. That is a real choice and worth stating: the walk is post-order, so a
62/// dependency is planned before the parent that declares it, and a per-caller
63/// scope would mean binding parents before children purely to read their values
64/// back. One scope is also easier to explain, and it matches what a chain is for
65/// (`release bonus-die` should mean bonus-die throughout).
66///
67/// A dependency that declared no arguments still runs on its own defaults.
68fn plan_invocations<'a>(
69    order: &[Step],
70    target: &str,
71    args: &[String],
72    cwd: &Path,
73    lookup: impl Fn(&str) -> Option<(&'a TaskFile, &'a Job, Option<&'a Path>)>,
74) -> Result<Vec<Invocation>, RunError> {
75    // The invocation's own bindings, resolved up front so every step in the
76    // chain can be written against them.
77    let scope = {
78        let (_, job, _) = lookup(target).expect("the target resolves");
79        TaskFile::bind(job, args).map_err(RunError::MissingArg)?
80    };
81
82    // Resolve first, then dedupe. The walk could only dedupe on the templates as
83    // written, so `(dist {{ module }})` and `(dist foundry)` looked like two
84    // different steps right up until they resolved to the same one. Deduping
85    // here keeps the first occurrence, which is still ahead of everything that
86    // depends on it.
87    let mut seen = BTreeSet::new();
88    let mut resolved: Vec<(&str, Vec<String>)> = Vec::with_capacity(order.len());
89    for step in order {
90        let step_args: Vec<String> = if step.name == target {
91            args.to_vec()
92        } else {
93            step.args.iter().map(|a| substitute(a, &scope)).collect()
94        };
95        if seen.insert((step.name.clone(), step_args.clone())) {
96            resolved.push((step.name.as_str(), step_args));
97        }
98    }
99
100    let mut plan = Vec::with_capacity(resolved.len());
101    for (name, step_args) in resolved {
102        let (tf, job, dir) = lookup(name).expect("a resolved name still resolves");
103        let values = TaskFile::bind(job, &step_args).map_err(RunError::MissingArg)?;
104        let inv = tf
105            .invocation(job, &values, cwd, dir)
106            .map_err(RunError::MissingArg)?;
107        plan.push(inv);
108    }
109    Ok(plan)
110}
111
112/// Run a planned chain with captured output, aggregating stdout and stderr across
113/// steps and stopping on the first non-success step. The returned status is that
114/// step's (or the last step's on full success). The plan always holds the target,
115/// so it is never empty.
116fn run_plan_captured(
117    plan: &[Invocation],
118    cancel: Option<&Cancel>,
119) -> Result<std::process::Output, RunError> {
120    let mut stdout = Vec::new();
121    let mut stderr = Vec::new();
122    let mut status = None;
123    for inv in plan {
124        // Checked before each step as well as during one, so a cancellation
125        // arriving between two steps of a `Requires:` chain stops the chain
126        // rather than being noticed only once the next step is already running.
127        if cancel.is_some_and(Cancel::is_cancelled) {
128            return Err(RunError::Cancelled);
129        }
130        let out = inv.run_captured(cancel).map_err(|e| inv.spawn_error(e))?;
131        stdout.extend_from_slice(&out.stdout);
132        stderr.extend_from_slice(&out.stderr);
133        // A killed step exits non-zero, which is indistinguishable from a
134        // failing one by exit code alone. Ask the handle instead.
135        if cancel.is_some_and(Cancel::is_cancelled) {
136            return Err(RunError::Cancelled);
137        }
138        let failed = !out.status.success();
139        status = Some(out.status);
140        if failed {
141            break;
142        }
143    }
144    Ok(std::process::Output {
145        status: status.expect("the plan always contains the target"),
146        stdout,
147        stderr,
148    })
149}
150
151/// Run `name` and its `Requires:` chain across the layered `files`, inheriting the
152/// parent's stdio so output streams straight through (the CLI path: a job is an
153/// interactive command, not a captured subprocess). Dependencies run first, each
154/// once, and each with its own defaults; only `name` receives `args`. Returns the
155/// first failing step's exit status, or the last step's on full success. This is
156/// **trusted**: it applies no agent gate, so a caller must not hand it a name from
157/// an untrusted source.
158pub fn run(
159    files: &[(PathBuf, TaskFile)],
160    name: &str,
161    args: &[String],
162    cwd: &Path,
163) -> Result<std::process::ExitStatus, RunError> {
164    let order = trusted_order(files, name)?;
165    let plan = plan_invocations(&order, name, args, cwd, |n| trusted_lookup(files, n))?;
166    let mut last = None;
167    for inv in &plan {
168        let status = inv.run_inherit().map_err(|e| inv.spawn_error(e))?;
169        if !status.success() {
170            return Ok(status);
171        }
172        last = Some(status);
173    }
174    Ok(last.expect("the plan always contains the target"))
175}
176
177/// Like [`run`], but captured: run `name` and its `Requires:` chain across the
178/// layered `files` with output aggregated across steps into a single
179/// [`std::process::Output`] (its status is the failing step's, or the last on
180/// success). For an embedder (a TUI, an editor) that wants the text rather than a
181/// stream. Also **trusted**: no agent gate.
182pub fn run_captured(
183    files: &[(PathBuf, TaskFile)],
184    name: &str,
185    args: &[String],
186    cwd: &Path,
187) -> Result<std::process::Output, RunError> {
188    let order = trusted_order(files, name)?;
189    let plan = plan_invocations(&order, name, args, cwd, |n| trusted_lookup(files, n))?;
190    run_plan_captured(&plan, None)
191}
192
193/// The agent gate: run `name` for an MCP or agent surface, captured, failing
194/// closed. Enforced, in order:
195///
196/// - The **nearest** definition of `name` across the layered files must carry
197///   `Agent: allow`, else [`RunError::NotAllowed`]. A nearer non-allowed
198///   definition shadows a farther allowed one (still `NotAllowed`), and a name
199///   that resolves nowhere is `NotAllowed` too: the agent never learns whether a
200///   hidden job exists.
201/// - The target must not raw-template a declared arg into its script via
202///   `{{ arg }}` (the agent controls the value), else [`RunError::Injects`]. The
203///   author must read the value from the environment instead.
204/// - The `Requires:` chain is resolved **within the target's own file**, not by
205///   the cross-file nearest-wins scan [`run`] uses. The author who wrote
206///   `Agent: allow` vouched for their file's jobs; a nearer, untrusted task file in
207///   the invocation directory must not be able to shadow a dependency and run
208///   attacker-controlled code through an allowed entry point. A dependency is never
209///   independently callable and never listed.
210///
211/// Only the target receives `args`; dependencies run argless with author-controlled
212/// defaults, so the target is the sole injection surface.
213pub fn run_agent(
214    files: &[(PathBuf, TaskFile)],
215    name: &str,
216    args: &[String],
217    cwd: &Path,
218) -> Result<std::process::Output, RunError> {
219    run_agent_inner(files, name, args, cwd, None)
220}
221
222/// [`run_agent`], stoppable through a [`Cancel`] handle held by another thread.
223///
224/// The agent surface is the one that needs this. A person at a terminal has
225/// Ctrl-C; an MCP client has only `notifications/cancelled`, so unless something
226/// else can reach the running task, a task that serves, sleeps, or waits on the
227/// network runs to completion whatever the client says.
228///
229/// The gate is identical to [`run_agent`]'s: same allowlist, same injection
230/// refusal, same in-file dependency resolution. Cancelling mid-chain stops
231/// before the next step and returns [`RunError::Cancelled`], which is
232/// deliberately not a failure: nothing went wrong, someone asked it to stop.
233pub fn run_agent_cancellable(
234    files: &[(PathBuf, TaskFile)],
235    name: &str,
236    args: &[String],
237    cwd: &Path,
238    cancel: &Cancel,
239) -> Result<std::process::Output, RunError> {
240    run_agent_inner(files, name, args, cwd, Some(cancel))
241}
242
243fn run_agent_inner(
244    files: &[(PathBuf, TaskFile)],
245    name: &str,
246    args: &[String],
247    cwd: &Path,
248    cancel: Option<&Cancel>,
249) -> Result<std::process::Output, RunError> {
250    // The nearest definition wins. If it is not allowed (or the name resolves
251    // nowhere), refuse: fail closed.
252    let mut target: Option<(&Path, &TaskFile, &Job)> = None;
253    for (p, tf) in files {
254        if let Some(job) = tf.job(name) {
255            if job.agent_allow {
256                target = Some((p.as_path(), tf, job));
257            }
258            break; // the nearest definition decides, allowed or not
259        }
260    }
261    let Some((target_path, target_tf, target_job)) = target else {
262        return Err(RunError::NotAllowed(name.to_string()));
263    };
264
265    // Refuse a target that raw-templates an untrusted arg into its script.
266    let templated = target_job.script_arg_templates();
267    if !templated.is_empty() {
268        return Err(RunError::Injects {
269            task: name.to_string(),
270            args: templated.iter().map(|s| s.to_string()).collect(),
271        });
272    }
273
274    // Resolve the Requires: chain WITHIN the target's own file (the security
275    // boundary), not the cross-file scan.
276    let order = dependency_order(name, |n| target_tf.job(n).map(|j| j.requires.clone()))
277        .map_err(RunError::Dependency)?;
278    let dir = target_path.parent();
279    let plan = plan_invocations(&order, name, args, cwd, |n| {
280        target_tf.job(n).map(|j| (target_tf, j, dir))
281    })?;
282    run_plan_captured(&plan, cancel)
283}
284
285impl Invocation {
286    /// Wrap a spawn failure with which task and which program it was.
287    fn spawn_error(&self, source: std::io::Error) -> RunError {
288        RunError::Io {
289            task: self.task.clone(),
290            program: self.program.clone(),
291            cwd: self.cwd.clone(),
292            source,
293        }
294    }
295
296    /// The `std::process::Command` for this invocation (program, argv, env, cwd).
297    fn command(&self) -> std::process::Command {
298        let mut cmd = std::process::Command::new(&self.program);
299        cmd.args(&self.args)
300            .envs(self.env.iter().map(|(k, v)| (k, v)))
301            .current_dir(&self.cwd);
302        cmd
303    }
304
305    /// Run inheriting the parent's stdio so output streams straight through.
306    fn run_inherit(&self) -> std::io::Result<std::process::ExitStatus> {
307        self.command().status()
308    }
309
310    /// Run capturing stdout and stderr.
311    /// Run to completion, capturing output.
312    ///
313    /// With a [`Cancel`] handle this spawns into its own **process group** and
314    /// records it, so the signal can reach the script's children rather than
315    /// only the shell that launched them. Without one it is `output()`, which is
316    /// the same thing minus the bookkeeping.
317    fn run_captured(&self, cancel: Option<&Cancel>) -> std::io::Result<std::process::Output> {
318        let Some(cancel) = cancel else {
319            return self.command().output();
320        };
321
322        let mut cmd = self.command();
323        // What `output()` does for us, spelled out because we spawn by hand:
324        // a null stdin (so a task reading stdin gets EOF rather than blocking on
325        // a terminal that is not there) and piped output.
326        cmd.stdin(std::process::Stdio::null())
327            .stdout(std::process::Stdio::piped())
328            .stderr(std::process::Stdio::piped());
329        #[cfg(unix)]
330        {
331            use std::os::unix::process::CommandExt;
332            // Its own group, so cancelling reaches everything the script starts.
333            cmd.process_group(0);
334        }
335
336        let child = cmd.spawn()?;
337        // The child leads its own group, so the group id is its pid.
338        cancel.entered(child.id());
339        let out = child.wait_with_output();
340        cancel.left();
341        out
342    }
343}
344
345/// Map a fence language to `(program, code-flag)`. Unlabeled or unknown falls
346/// back to `sh -c`, so a plain ` ``` ` block runs as a shell script.
347/// The program, its "run this string" flag, and its strictness prelude.
348///
349/// One table, deliberately. This was three: `interpreter`, `strict_prelude` and
350/// `is_known_lang` each matched the same input separately, and two of them
351/// disagreed on the fallback arm. `interpreter` fell back to `sh` for an
352/// unrecognized language while `strict_prelude` fell back to `None`, so a block
353/// tagged ```console ran as a shell with no `set -e` and a failing step exited
354/// 0. That is exactly the failure this crate advertises that it prevents,
355/// reachable by one wrong word in a fence.
356///
357/// An unrecognized language falls back to `sh` **with** the shell prelude, and
358/// the parser warns. Forgiving is the right default for a fence tagged
359/// `shell-session` or `bash5`, and the strictness is what makes the fallback
360/// safe: a block that is not a shell script at all (a ```toml table, say) now
361/// fails on its first line instead of running halfway and exiting 0.
362pub(crate) fn interpreter(lang: &str) -> Interpreter {
363    let (program, flag, prelude, recognized) = match lang.trim().to_ascii_lowercase().as_str() {
364        "" | "sh" | "shell" => ("sh", "-c", Some("set -e"), true),
365        "bash" => ("bash", "-c", Some("set -e\nset -o pipefail"), true),
366        "zsh" => ("zsh", "-c", Some("set -e\nset -o pipefail"), true),
367        "fish" => ("fish", "-c", None, true),
368        "python" | "py" | "python3" => ("python3", "-c", None, true),
369        "ruby" => ("ruby", "-e", None, true),
370        "node" | "js" | "javascript" => ("node", "-e", None, true),
371        // Unknown: assume a shell, and give it the same failure detection a
372        // shell gets. The fallback itself was never the bug; the bug was that
373        // this arm resolved to `sh` while the prelude's matching arm resolved
374        // to `None`, so the fallback shell ran unstrict.
375        _ => ("sh", "-c", Some("set -e"), false),
376    };
377    Interpreter {
378        program,
379        flag,
380        prelude,
381        recognized,
382    }
383}
384
385/// How to run one language: the program, its flag, and its strictness prelude.
386pub(crate) struct Interpreter {
387    pub(crate) program: &'static str,
388    pub(crate) flag: &'static str,
389    /// The strictness prelude, or `None` for a language with no failure-detection
390    /// setting worth injecting.
391    ///
392    /// Only shells, and only the settings that are about *detecting failure*,
393    /// which is the task runner's job:
394    ///
395    /// - `set -e` stops at the first failing command, so a gate cannot pass
396    ///   while a step inside it fails.
397    /// - `pipefail` extends that through a pipeline, where the exit status
398    ///   would otherwise be the last stage's and a failing producer would go
399    ///   unnoticed.
400    ///
401    /// Deliberately NOT `set -u`. Catching an unset variable is a lint rather
402    /// than failure detection, and it changes the meaning of correct scripts:
403    /// reading an optional variable is ordinary in a task file, and defaulting
404    /// it to a hard error would break working tasks to catch a typo. Authors
405    /// who want it can still write it themselves.
406    ///
407    /// `pipefail` is not POSIX, so plain `sh` gets only `set -e`: dash rejects
408    /// `set -o pipefail` outright, which would break every task on a
409    /// Debian-ish `/bin/sh`. `fish` gets nothing, having neither the syntax nor
410    /// the semantics, and non-shells are left alone entirely.
411    pub(crate) prelude: Option<&'static str>,
412    /// Whether the language was named in the table, as opposed to falling
413    /// through to the `sh` assumption. Carried here so that "do we know this
414    /// language" is answered by the same table that answers "how do we run it",
415    /// rather than by a second list that can drift from it. It drifting is
416    /// exactly how the unstrict-fallback bug happened.
417    pub(crate) recognized: bool,
418}
419
420/// Whether a fence language maps to an interpreter (unlabeled counts as `sh`).
421/// Whether `lang` names an interpreter outright, as opposed to falling through
422/// to the `sh` assumption. Derived from the same table, so the two cannot drift.
423pub(crate) fn is_known_lang(lang: &str) -> bool {
424    interpreter(lang).recognized
425}
426
427/// Replace `{{ name }}` tokens (any inner whitespace) with `args[name]`. A token
428/// whose name is not in `args` is left as written, so a literal `{{x}}` that is
429/// not an argument survives.
430pub(crate) fn substitute(src: &str, args: &BTreeMap<String, String>) -> String {
431    let mut out = String::with_capacity(src.len());
432    let mut rest = src;
433    while let Some(open) = rest.find("{{") {
434        out.push_str(&rest[..open]);
435        let after = &rest[open + 2..];
436        if let Some(close) = after.find("}}") {
437            let name = after[..close].trim();
438            match args.get(name) {
439                Some(v) => out.push_str(v),
440                None => {
441                    // Not an argument: keep the token verbatim.
442                    out.push_str("{{");
443                    out.push_str(&after[..close]);
444                    out.push_str("}}");
445                }
446            }
447            rest = &after[close + 2..];
448        } else {
449            out.push_str("{{");
450            rest = after;
451        }
452    }
453    out.push_str(rest);
454    out
455}
456
457#[cfg(test)]
458mod tests {
459    use super::*;
460
461    /// The whole point of the process group. A shell task is `sh -c <script>`,
462    /// so the thing we spawn is a shell and the work is its children. Killing
463    /// only the shell leaves the work running while we report the task stopped,
464    /// which is worse than not cancelling, because it is a lie.
465    ///
466    /// The grandchild writes to a file *after* its sleep, so the file existing
467    /// afterwards proves it survived the cancellation.
468    #[cfg(unix)]
469    #[test]
470    fn cancelling_kills_the_script_s_children_not_just_the_shell() {
471        let dir = std::env::temp_dir().join(format!("mdtask-cancel-{}", std::process::id()));
472        std::fs::create_dir_all(&dir).unwrap();
473        let witness = dir.join("survived");
474        let src = format!(
475            "## t\n\nAgent: allow\n\n```sh\n(sleep 5; touch {}) &\nwait\n```\n",
476            witness.display()
477        );
478        let files = vec![(dir.join("tasks.md"), parse(&src))];
479
480        let cancel = Cancel::new();
481        let handle = {
482            let cancel = cancel.clone();
483            let dir = dir.clone();
484            std::thread::spawn(move || run_agent_cancellable(&files, "t", &[], &dir, &cancel))
485        };
486
487        std::thread::sleep(std::time::Duration::from_millis(300));
488        let started = std::time::Instant::now();
489        cancel.cancel();
490        let result = handle.join().expect("the run thread did not panic");
491        let took = started.elapsed();
492
493        assert!(
494            matches!(result, Err(RunError::Cancelled)),
495            "expected Cancelled, got {result:?}"
496        );
497        assert!(
498            took < std::time::Duration::from_secs(4),
499            "cancelling should not wait out the task: took {took:?}"
500        );
501
502        // Past when the grandchild would have fired had it survived.
503        std::thread::sleep(std::time::Duration::from_secs(6));
504        let survived = witness.exists();
505        std::fs::remove_dir_all(&dir).ok();
506        assert!(!survived, "the grandchild outlived the cancellation");
507    }
508
509    /// Cancelling between two steps of a chain stops the chain, rather than
510    /// being noticed only once the next step is already running.
511    #[cfg(unix)]
512    #[test]
513    fn cancelling_stops_the_rest_of_a_requires_chain() {
514        let dir = std::env::temp_dir().join(format!("mdtask-chain-cancel-{}", std::process::id()));
515        std::fs::create_dir_all(&dir).unwrap();
516        let witness = dir.join("second-ran");
517        let src = format!(
518            "## first\n\n```sh\nsleep 3\n```\n\n## second\n\nAgent: allow\nRequires: first\n\n```sh\ntouch {}\n```\n",
519            witness.display()
520        );
521        let files = vec![(dir.join("tasks.md"), parse(&src))];
522
523        let cancel = Cancel::new();
524        let handle = {
525            let cancel = cancel.clone();
526            let dir = dir.clone();
527            std::thread::spawn(move || run_agent_cancellable(&files, "second", &[], &dir, &cancel))
528        };
529        std::thread::sleep(std::time::Duration::from_millis(300));
530        cancel.cancel();
531        let result = handle.join().expect("the run thread did not panic");
532
533        let ran = witness.exists();
534        std::fs::remove_dir_all(&dir).ok();
535        assert!(matches!(result, Err(RunError::Cancelled)), "{result:?}");
536        assert!(!ran, "the target ran even though the chain was cancelled");
537    }
538
539    /// Cancelling before the run starts must stop it at the first step, not let
540    /// one through because nothing was running when the flag was set.
541    #[cfg(unix)]
542    #[test]
543    fn a_run_cancelled_before_it_starts_never_spawns() {
544        let dir = std::env::temp_dir().join(format!("mdtask-precancel-{}", std::process::id()));
545        std::fs::create_dir_all(&dir).unwrap();
546        let witness = dir.join("ran");
547        let src = format!(
548            "## t\n\nAgent: allow\n\n```sh\ntouch {}\n```\n",
549            witness.display()
550        );
551        let files = vec![(dir.join("tasks.md"), parse(&src))];
552
553        let cancel = Cancel::new();
554        cancel.cancel();
555        let result = run_agent_cancellable(&files, "t", &[], &dir, &cancel);
556
557        let ran = witness.exists();
558        std::fs::remove_dir_all(&dir).ok();
559        assert!(matches!(result, Err(RunError::Cancelled)), "{result:?}");
560        assert!(!ran, "the task ran despite being cancelled first");
561    }
562
563    /// Without a handle, nothing changes: the uncancellable path is still the
564    /// one the CLI and every embedder uses.
565    #[test]
566    fn a_run_with_no_handle_still_completes_normally() {
567        let f = files(&[(
568            "tasks.md",
569            "## t\n\nAgent: allow\n\n```sh\necho done\n```\n",
570        )]);
571        let out = run_agent(&f, "t", &[], Path::new(".")).unwrap();
572        assert!(out.status.success());
573        assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "done");
574    }
575    use crate::model::DepError;
576    use crate::parse::parse;
577
578    fn args(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
579        pairs
580            .iter()
581            .map(|(k, v)| (k.to_string(), v.to_string()))
582            .collect()
583    }
584
585    fn files(pairs: &[(&str, &str)]) -> Vec<(PathBuf, TaskFile)> {
586        pairs
587            .iter()
588            .map(|(path, src)| (PathBuf::from(path), parse(src)))
589            .collect()
590    }
591
592    fn plan_for(src: &str, target: &str, args: &[&str]) -> Vec<Invocation> {
593        let files = vec![(PathBuf::from("tasks.md"), parse(src))];
594        let order = trusted_order(&files, target).expect("resolves");
595        let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
596        plan_invocations(&order, target, &args, Path::new("."), |n| {
597            trusted_lookup(&files, n)
598        })
599        .expect("plans")
600    }
601
602    /// The value of an argument as the step will actually see it.
603    fn env_of<'a>(inv: &'a Invocation, key: &str) -> Option<&'a str> {
604        inv.env
605            .iter()
606            .find(|(k, _)| k == key)
607            .map(|(_, v)| v.as_str())
608    }
609
610    const PARAM: &str = "\
611## dist
612
613Args: module
614
615```sh
616true
617```
618
619## lint
620
621```sh
622true
623```
624
625## release
626
627Args: module
628Requires: lint, (dist {{ module }})
629
630```sh
631true
632```
633";
634
635    #[test]
636    fn substitutes_args_and_leaves_unknown_tokens() {
637        let out = substitute(
638            "hello {{ name }} and {{ other }}",
639            &args(&[("name", "world")]),
640        );
641        assert_eq!(out, "hello world and {{ other }}");
642    }
643
644    /// The point of the whole feature. Before this, a job taking an argument
645    /// could not be a dependency at all: it would be planned with none and fail
646    /// on a missing value, so the chain was unrunnable.
647    #[test]
648    fn a_placeholder_resolves_to_the_invocations_argument() {
649        let plan = plan_for(PARAM, "release", &["foundry"]);
650        assert_eq!(plan.len(), 3, "lint, dist, release");
651        assert_eq!(env_of(&plan[1], "module"), Some("foundry"), "dist got it");
652        assert_eq!(
653            env_of(&plan[2], "module"),
654            Some("foundry"),
655            "and so did release"
656        );
657    }
658
659    /// Deduplication keys on the arguments too. Two `dist` steps with different
660    /// modules are two different pieces of work, and collapsing them to one
661    /// would silently skip a build.
662    #[test]
663    fn the_same_task_with_different_arguments_runs_twice() {
664        let src = PARAM.replace(
665            "Requires: lint, (dist {{ module }})",
666            "Requires: (dist {{ module }}), (dist {{ module }}-docs)",
667        );
668        let plan = plan_for(&src, "release", &["foundry"]);
669        assert_eq!(plan.len(), 3);
670        assert_eq!(env_of(&plan[0], "module"), Some("foundry"));
671        assert_eq!(env_of(&plan[1], "module"), Some("foundry-docs"));
672    }
673
674    #[test]
675    fn the_same_task_with_the_same_arguments_still_runs_once() {
676        let src = PARAM.replace(
677            "Requires: lint, (dist {{ module }})",
678            "Requires: (dist {{ module }}), (dist foundry)",
679        );
680        let plan = plan_for(&src, "release", &["foundry"]);
681        assert_eq!(plan.len(), 2, "the two dist steps are the same work");
682    }
683
684    /// A placeholder naming something the invocation does not have is left as
685    /// written rather than becoming an empty argument, matching how `substitute`
686    /// treats an unknown token in a script body.
687    #[test]
688    fn an_unknown_placeholder_is_left_alone() {
689        let src = PARAM.replace("(dist {{ module }})", "(dist {{ nonesuch }})");
690        let plan = plan_for(&src, "release", &["foundry"]);
691        assert_eq!(env_of(&plan[1], "module"), Some("{{ nonesuch }}"));
692    }
693
694    /// Cycle detection keys on the name alone. Keying it on name-plus-arguments
695    /// would let `a` require `(a {{ x }}-more)` recurse forever, generating a
696    /// longer argument each time and never repeating a key.
697    #[test]
698    fn a_self_reference_with_different_arguments_is_still_a_cycle() {
699        let files = vec![(
700            PathBuf::from("tasks.md"),
701            parse("## a\n\nArgs: x\nRequires: (a {{ x }}-more)\n\n```sh\ntrue\n```\n"),
702        )];
703        assert!(matches!(
704            trusted_order(&files, "a"),
705            Err(RunError::Dependency(DepError::Cycle(_)))
706        ));
707    }
708
709    /// The bug this whole change exists for. The fallback to `sh` was never the
710    /// problem; the fallback running *without* `set -e` was, because a failing
711    /// step then exited 0 and a gate passed while it was broken.
712    #[test]
713    fn the_sh_fallback_is_strict() {
714        for lang in [
715            "console",
716            "shell-session",
717            "terminal",
718            "cmd",
719            "bash5",
720            "toml",
721            "json",
722        ] {
723            let i = interpreter(lang);
724            assert_eq!(i.program, "sh", "{lang:?} should fall back to sh");
725            assert!(!i.recognized, "{lang:?} is not a named language");
726            assert!(
727                i.prelude.is_some_and(|p| p.contains("set -e")),
728                "{lang:?} falls back to sh without failure detection"
729            );
730        }
731    }
732
733    /// The regression this whole change exists for: every language that resolves
734    /// to a shell must carry a failure-detecting prelude, and one that resolves
735    /// to nothing must not resolve to `sh` behind our backs. These were three
736    /// separate `match`es and two of them disagreed.
737    #[test]
738    fn every_language_that_runs_a_shell_detects_failure() {
739        // Anything whose program is a POSIX-ish shell must carry a prelude,
740        // named or fallen-back-to alike. This is the invariant that three
741        // separate `match`es failed to hold between them.
742        for lang in ["", "sh", "shell", "bash", "zsh", "console", "nonsense-tag"] {
743            let i = interpreter(lang);
744            if matches!(i.program, "sh" | "bash" | "zsh") {
745                assert!(
746                    i.prelude.is_some_and(|p| p.contains("set -e")),
747                    "{lang:?} runs {} with no failure detection",
748                    i.program
749                );
750            }
751        }
752    }
753
754    #[test]
755    fn agent_jobs_filters_to_the_gated_ones() {
756        let f = files(&[(
757            "tasks.md",
758            "## open\n\nAgent: allow\n\n```sh\ntrue\n```\n\n## closed\n\n```sh\ntrue\n```\n",
759        )]);
760        let names: Vec<_> = agent_jobs(&f).iter().map(|j| j.name.as_str()).collect();
761        assert_eq!(names, ["open"]);
762    }
763
764    #[test]
765    fn agent_jobs_shadows_a_farther_allowed_with_a_nearer_non_allowed() {
766        // The child redefines `deploy` WITHOUT the gate; the nearest definition
767        // wins and it is not allowed, so `deploy` is not exposed (fail closed).
768        let f = files(&[
769            ("child/tasks.md", "## deploy\n\n```sh\ntrue\n```\n"),
770            (
771                "tasks.md",
772                "## deploy\n\nAgent: allow\n\n```sh\ntrue\n```\n",
773            ),
774        ]);
775        assert!(agent_jobs(&f).is_empty());
776    }
777
778    #[test]
779    fn run_captured_returns_stdout() {
780        let f = files(&[("tasks.md", "## hello\n\n```sh\necho hello-out\n```\n")]);
781        let out = run_captured(&f, "hello", &[], Path::new(".")).unwrap();
782        assert!(out.status.success());
783        assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "hello-out");
784    }
785
786    #[test]
787    fn run_captured_runs_requires_deps_first() {
788        let f = files(&[(
789            "tasks.md",
790            "## a\n\nRequires: b\n\n```sh\necho A\n```\n\n## b\n\n```sh\necho B\n```\n",
791        )]);
792        let out = run_captured(&f, "a", &[], Path::new(".")).unwrap();
793        let text = String::from_utf8_lossy(&out.stdout);
794        // b runs before a (deps first).
795        let bpos = text.find('B').expect("B in output");
796        let apos = text.find('A').expect("A in output");
797        assert!(bpos < apos, "deps must run first: {text}");
798    }
799
800    #[test]
801    fn run_reports_an_unknown_target_as_not_found() {
802        let f = files(&[("tasks.md", "## a\n\n```sh\ntrue\n```\n")]);
803        match run_captured(&f, "ghost", &[], Path::new(".")) {
804            Err(RunError::NotFound(n)) => assert_eq!(n, "ghost"),
805            other => panic!("expected NotFound, got {other:?}"),
806        }
807    }
808
809    // The two RCE regressions, exercised through the public agent gate.
810
811    #[test]
812    fn run_agent_resolves_requires_within_the_targets_file_not_a_nearer_shadow() {
813        // A nearer, untrusted `build` must NOT run when the allowed ancestor
814        // `deploy` (which requires build) is invoked by name. The chain resolves
815        // within deploy's own file, so the ancestor's real build runs, not PWNED.
816        let f = files(&[
817            ("child/tasks.md", "## build\n\n```sh\necho PWNED\n```\n"),
818            (
819                "tasks.md",
820                "## deploy\n\nAgent: allow\nRequires: build\n\n```sh\necho real-deploy\n```\n\n## build\n\n```sh\necho real-build\n```\n",
821            ),
822        ]);
823        let out = run_agent(&f, "deploy", &[], Path::new(".")).unwrap();
824        let text = String::from_utf8_lossy(&out.stdout);
825        assert!(text.contains("real-build"), "got: {text}");
826        assert!(text.contains("real-deploy"), "got: {text}");
827        assert!(!text.contains("PWNED"), "nearer build ran: {text}");
828        assert!(out.status.success());
829    }
830
831    #[test]
832    fn run_agent_refuses_a_target_that_injects_an_arg_via_double_brace() {
833        // greet interpolates {{ name }} raw into its script; an agent-supplied
834        // value would be shell-injectable, so run_agent must refuse before running.
835        let f = files(&[(
836            "tasks.md",
837            "## greet\n\nAgent: allow\nArgs: name\n\n```sh\necho hi {{ name }}\n```\n",
838        )]);
839        match run_agent(&f, "greet", &["x; echo PWNED".into()], Path::new(".")) {
840            Err(RunError::Injects { task, args }) => {
841                assert_eq!(task, "greet");
842                assert_eq!(args, vec!["name".to_string()]);
843            }
844            other => panic!("expected Injects, got {other:?}"),
845        }
846    }
847
848    #[test]
849    fn run_agent_refuses_a_non_allowed_target() {
850        let f = files(&[("tasks.md", "## secret\n\n```sh\ntrue\n```\n")]);
851        match run_agent(&f, "secret", &[], Path::new(".")) {
852            Err(RunError::NotAllowed(n)) => assert_eq!(n, "secret"),
853            other => panic!("expected NotAllowed, got {other:?}"),
854        }
855    }
856
857    #[test]
858    fn run_agent_refuses_when_a_nearer_non_allowed_shadows_an_allowed_one() {
859        // The nearest `deploy` lacks the gate; it shadows the farther allowed one.
860        let f = files(&[
861            ("child/tasks.md", "## deploy\n\n```sh\necho PWNED\n```\n"),
862            (
863                "tasks.md",
864                "## deploy\n\nAgent: allow\n\n```sh\necho real\n```\n",
865            ),
866        ]);
867        match run_agent(&f, "deploy", &[], Path::new(".")) {
868            Err(RunError::NotAllowed(n)) => assert_eq!(n, "deploy"),
869            other => panic!("expected NotAllowed, got {other:?}"),
870        }
871    }
872
873    /// The bug this default exists to prevent: a shell runs the whole block as
874    /// one script, so without `set -e` a failing early step is swallowed and the
875    /// task exits with the status of the LAST command. A gate that cannot fail
876    /// is worse than no gate, because it is trusted.
877    #[test]
878    fn a_failing_early_step_fails_the_job() {
879        let tf = parse("## check\n\n```sh\nfalse\ntrue\n```\n");
880        let out = run_captured(
881            &[(PathBuf::from("tasks.md"), tf)],
882            "check",
883            &[],
884            Path::new("."),
885        )
886        .expect("runs");
887        assert!(
888            !out.status.success(),
889            "a job whose first command fails must not report success"
890        );
891    }
892
893    #[test]
894    fn no_strict_restores_the_old_lenient_behavior() {
895        let tf = parse("## check\n\nOpts: no-strict\n\n```sh\nfalse\ntrue\n```\n");
896        let out = run_captured(
897            &[(PathBuf::from("tasks.md"), tf)],
898            "check",
899            &[],
900            Path::new("."),
901        )
902        .expect("runs");
903        assert!(
904            out.status.success(),
905            "no-strict should exit with the last command's status"
906        );
907    }
908
909    #[test]
910    fn a_passing_job_is_unaffected() {
911        let tf = parse("## ok\n\n```sh\ntrue\necho fine\n```\n");
912        let out = run_captured(
913            &[(PathBuf::from("tasks.md"), tf)],
914            "ok",
915            &[],
916            Path::new("."),
917        )
918        .expect("runs");
919        assert!(out.status.success());
920        assert!(String::from_utf8_lossy(&out.stdout).contains("fine"));
921    }
922
923    /// Existing task files already open with `set -euo pipefail` by hand, so the
924    /// prelude has to be harmlessly redundant rather than conflicting.
925    #[test]
926    fn a_hand_written_prelude_still_works() {
927        let tf = parse("## ok\n\n```sh\nset -eu\necho fine\n```\n");
928        let out = run_captured(
929            &[(PathBuf::from("tasks.md"), tf)],
930            "ok",
931            &[],
932            Path::new("."),
933        )
934        .expect("runs");
935        assert!(out.status.success());
936    }
937
938    /// `pipefail` is not POSIX and dash rejects it outright, so plain `sh` must
939    /// not receive it or every task breaks on a Debian-ish /bin/sh.
940    #[test]
941    fn plain_sh_does_not_get_pipefail() {
942        assert_eq!(interpreter("sh").prelude, Some("set -e"));
943        assert_eq!(interpreter("").prelude, Some("set -e"));
944        assert!(interpreter("bash").prelude.unwrap().contains("pipefail"));
945        assert!(interpreter("zsh").prelude.unwrap().contains("pipefail"));
946    }
947
948    /// Injecting shell syntax into another language would be a syntax error, so
949    /// non-shells are left alone.
950    #[test]
951    fn non_shells_get_no_prelude() {
952        for lang in ["python", "ruby", "node", "fish"] {
953            assert_eq!(
954                interpreter(lang).prelude,
955                None,
956                "{lang} must not be given shell syntax"
957            );
958        }
959    }
960
961    /// A python job still runs, which is the real check that the prelude is not
962    /// being spliced into a language that cannot parse it.
963    #[test]
964    fn a_python_job_is_untouched() {
965        let tf = parse("## py\n\n```python\nprint(\"hi\")\n```\n");
966        let out = run_captured(
967            &[(PathBuf::from("tasks.md"), tf)],
968            "py",
969            &[],
970            Path::new("."),
971        )
972        .expect("runs");
973        assert!(out.status.success());
974        assert!(String::from_utf8_lossy(&out.stdout).contains("hi"));
975    }
976}