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