Skip to main content

pma_core/
worker.rs

1//! A coding agent as a record rather than a code path: how its prompt and
2//! directory are passed, how a model is named, whether a budget cap exists,
3//! how success and cost are read back, and whether it has a sandbox or a
4//! command allowlist.
5//!
6//! What the record cannot carry, the pipeline carries instead. The worktree,
7//! the stripped credentials, `pma` running `verify` itself and the path scope
8//! check do not depend on the worker, so a worker with no allowlist and no
9//! sandbox is admitted under the same gates as one with both.
10
11use std::collections::BTreeMap;
12use std::process::Command;
13
14use crate::agent::{Report, tail};
15
16/// How a worker's output is read back.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub enum Parser {
19    /// A `{"type":"result", ...}` line, the last that `claude -p
20    /// --output-format json` or `stream-json` writes: success, error flag
21    /// and `total_cost_usd`.
22    ClaudeJson,
23    /// No structured output. The exit status decides, the last lines are the
24    /// summary, and the cost is unknown rather than zero.
25    TextTail,
26    /// The last JSON value in the output, read by dotted field paths:
27    /// `json:<summary>:<cost>` or `json:<summary>:<cost>:<error-flag>`. A path
28    /// that resolves to nothing leaves its value unknown, and the exit status
29    /// decides unless an error flag says otherwise.
30    ///
31    /// A worker whose output shape nothing else reads is then a record rather
32    /// than another variant here.
33    Fields {
34        summary: String,
35        cost: String,
36        error: Option<String>,
37    },
38}
39
40impl Parser {
41    const ALL: [(&'static str, Parser); 2] = [
42        ("claude-json", Parser::ClaudeJson),
43        ("text-tail", Parser::TextTail),
44    ];
45
46    pub fn name(&self) -> String {
47        match self {
48            Parser::Fields {
49                summary,
50                cost,
51                error,
52            } => match error {
53                Some(e) => format!("json:{summary}:{cost}:{e}"),
54                None => format!("json:{summary}:{cost}"),
55            },
56            other => Self::ALL
57                .iter()
58                .find(|(_, p)| p == other)
59                .map_or(String::new(), |(n, _)| (*n).to_string()),
60        }
61    }
62
63    pub fn parse(s: &str) -> Option<Parser> {
64        if let Some(paths) = s.strip_prefix("json:") {
65            let mut parts = paths.split(':').map(str::trim);
66            let summary = parts.next()?.to_string();
67            let cost = parts.next().unwrap_or_default().to_string();
68            let error = parts.next().map(String::from).filter(|e| !e.is_empty());
69            if summary.is_empty() {
70                return None;
71            }
72            return Some(Parser::Fields {
73                summary,
74                cost,
75                error,
76            });
77        }
78        Self::ALL
79            .iter()
80            .find(|(n, _)| *n == s)
81            .map(|(_, p)| p.clone())
82    }
83
84    pub fn names() -> String {
85        format!(
86            "{}, json:<summary>:<cost>[:<error>]",
87            Self::ALL
88                .iter()
89                .map(|(n, _)| *n)
90                .collect::<Vec<_>>()
91                .join(", ")
92        )
93    }
94
95    /// `exit_ok` is false when the process failed or was killed. A worker
96    /// with structured output states its own result, so the status is only
97    /// consulted where there is nothing else to read.
98    pub fn report(&self, output: &str, exit_ok: bool) -> Report {
99        match self {
100            Parser::ClaudeJson => crate::agent::parse_claude(output),
101            Parser::TextTail => Report {
102                ok: exit_ok,
103                summary: tail(output, 20),
104                cost_usd: None,
105            },
106            Parser::Fields {
107                summary,
108                cost,
109                error,
110            } => {
111                let Some(value) = last_json(output) else {
112                    return Report {
113                        ok: false,
114                        summary: tail(output, 20),
115                        cost_usd: None,
116                    };
117                };
118                let flagged = error
119                    .as_deref()
120                    .and_then(|path| at(&value, path))
121                    .map(truthy);
122                Report {
123                    ok: exit_ok && flagged != Some(true),
124                    summary: at(&value, summary)
125                        .map(text)
126                        .unwrap_or_else(|| tail(output, 20)),
127                    cost_usd: at(&value, cost).and_then(|v| match v {
128                        serde_json::Value::Number(n) => n.as_f64(),
129                        serde_json::Value::String(s) => s.parse().ok(),
130                        _ => None,
131                    }),
132                }
133            }
134        }
135    }
136}
137
138/// The last JSON value in the output: the whole text where that parses, else
139/// the last line that does. A worker that streams events therefore reports from
140/// its final one.
141fn last_json(output: &str) -> Option<serde_json::Value> {
142    if let Ok(v) = serde_json::from_str(output.trim()) {
143        return Some(v);
144    }
145    output
146        .lines()
147        .rev()
148        .find_map(|l| serde_json::from_str(l.trim()).ok())
149}
150
151/// A dotted path into a JSON value. `a.b.0` reads a field, then a field, then
152/// an array index.
153fn at<'a>(value: &'a serde_json::Value, path: &str) -> Option<&'a serde_json::Value> {
154    if path.is_empty() {
155        return None;
156    }
157    let mut at = value;
158    for step in path.split('.') {
159        at = match step.parse::<usize>() {
160            Ok(i) => at.get(i)?,
161            Err(_) => at.get(step)?,
162        };
163    }
164    Some(at)
165}
166
167fn text(value: &serde_json::Value) -> String {
168    match value {
169        serde_json::Value::String(s) => s.clone(),
170        other => other.to_string(),
171    }
172}
173
174fn truthy(value: &serde_json::Value) -> bool {
175    match value {
176        serde_json::Value::Bool(b) => *b,
177        serde_json::Value::String(s) => !s.is_empty() && s != "false",
178        serde_json::Value::Number(n) => n.as_f64().unwrap_or_default() != 0.0,
179        _ => false,
180    }
181}
182
183#[derive(Debug, Clone, PartialEq)]
184pub struct Worker {
185    pub name: String,
186    pub command: String,
187    /// Arguments, with `{prompt}`, `{dir}`, `{model}`, `{budget}` and
188    /// `{timeout}` filled in at dispatch. `{extra}` stands for however many
189    /// arguments a preset adds, and sits where a worker wants them: before a
190    /// positional prompt for one that takes its message last.
191    pub args: Vec<String>,
192    /// A flag and a rule template separated by a space, such as
193    /// `--allowedTools Bash({cmd})`. The flag is emitted once, the rule once
194    /// per subcommand of the verify command. `None` means the worker has no
195    /// allowlist, which the worktree and the scope check cover instead.
196    pub allow: Option<String>,
197    pub parse: Parser,
198    /// Whether its output states what the run cost. A worker that does not
199    /// leaves the cost unknown, never zero.
200    pub reports_cost: bool,
201    /// Whether the budget argument is a bound the worker holds itself.
202    /// `claude` checks its cap between turns and can exceed it, so this is
203    /// false for `claude` too: a timeout is not a spend bound.
204    pub enforces_budget: bool,
205    pub sandbox: bool,
206    /// Whether a rework can continue the earlier session rather than
207    /// re-prompting. Unused until a worker that does arrives.
208    pub resumes: bool,
209    /// Environment the worker needs, such as a base URL for an
210    /// OpenAI-compatible endpoint or a config path. Applied to the child after
211    /// the credentials `agent::restrict` strips, so it cannot restore one.
212    /// Provider API keys are inherited from the session and belong here only
213    /// when a worker needs a different one from the shell's.
214    pub env: BTreeMap<String, String>,
215}
216
217/// The fields `pma agent set` accepts, with what each one takes.
218pub const FIELDS: [(&str, &str); 9] = [
219    ("command", "the program to run"),
220    ("args", "a JSON array of arguments"),
221    ("allow", "an allowlist flag and rule, or empty for none"),
222    (
223        "parse",
224        "claude-json, text-tail, or json:<summary>:<cost>[:<error>]",
225    ),
226    ("reports-cost", "true or false"),
227    ("enforces-budget", "true or false"),
228    ("sandbox", "true or false"),
229    ("resumes", "true or false"),
230    (
231        "env",
232        "a JSON object of environment variables, or {} for none",
233    ),
234];
235
236/// `template` with each `{name}` replaced in one pass, so a value is never
237/// read for placeholders: a task that mentions `{model}` reaches the agent as
238/// written. `None` when a placeholder it holds has no value. An unknown
239/// `{name}` is left as it is.
240fn fill(template: &str, values: &[(&str, Option<&str>)]) -> Option<String> {
241    let mut out = String::new();
242    let mut rest = template;
243    while let Some(open) = rest.find('{') {
244        out.push_str(&rest[..open]);
245        rest = &rest[open..];
246        let named = values.iter().find(|(name, _)| {
247            rest[1..]
248                .strip_prefix(name)
249                .is_some_and(|r| r.starts_with('}'))
250        });
251        match named {
252            Some((name, value)) => {
253                out.push_str((*value)?);
254                rest = &rest[name.len() + 2..];
255            }
256            None => {
257                out.push('{');
258                rest = &rest[1..];
259            }
260        }
261    }
262    out.push_str(rest);
263    Some(out)
264}
265
266impl Worker {
267    /// The command to run in `dir`. An argument whose placeholder has no
268    /// value is dropped, and with it the argument before it when that one is
269    /// a flag, so `--model {model}` disappears whole when no model is chosen.
270    pub fn build(
271        &self,
272        prompt: &str,
273        dir: &std::path::Path,
274        model: Option<&str>,
275        budget: f64,
276        extra: &[String],
277        timeout: std::time::Duration,
278    ) -> Command {
279        let dir_text = dir.to_string_lossy().into_owned();
280        let budget = budget.to_string();
281        let timeout = format!("{}s", timeout.as_secs());
282        let mut args: Vec<String> = Vec::new();
283        for arg in &self.args {
284            // Zero or more arguments in one place, so a preset's effort flag
285            // need not be a field this record knows about.
286            if arg == "{extra}" {
287                args.extend(extra.iter().cloned());
288                continue;
289            }
290            let values = [
291                ("prompt", Some(prompt)),
292                ("dir", Some(dir_text.as_str())),
293                ("budget", Some(budget.as_str())),
294                ("timeout", Some(timeout.as_str())),
295                ("model", model),
296            ];
297            match fill(arg, &values) {
298                Some(filled) => args.push(filled),
299                None => {
300                    if args.last().is_some_and(|a| a.starts_with('-')) {
301                        args.pop();
302                    }
303                }
304            }
305        }
306        let mut cmd = Command::new(&self.command);
307        cmd.args(args);
308        // Before `agent::restrict`, which the caller applies last, so a
309        // record cannot put back a credential the pipeline took away.
310        for (key, value) in &self.env {
311            cmd.env(key, value);
312        }
313        cmd
314    }
315
316    /// Appends the allowlist for `verify`, so the worker may run the check
317    /// `pma` runs afterwards. A worker without an allowlist gets nothing:
318    /// its reach is bounded by the worktree, not by this.
319    pub fn allow_verify(&self, cmd: &mut Command, verify: Option<&str>) {
320        let (Some(allow), Some(verify)) = (&self.allow, verify) else {
321            return;
322        };
323        let Some((flag, rule)) = allow.split_once(char::is_whitespace) else {
324            return;
325        };
326        let rules: Vec<String> = crate::agent::bash_parts(verify)
327            .iter()
328            .map(|part| rule.trim().replace("{cmd}", part))
329            .collect();
330        if !rules.is_empty() {
331            cmd.arg(flag.trim()).args(rules);
332        }
333    }
334
335    /// The worker `pma` shipped with. Seeded into the store on upgrade, and
336    /// editable from there like any other.
337    pub fn claude() -> Worker {
338        Worker {
339            name: "claude".into(),
340            command: "claude".into(),
341            args: [
342                "-p",
343                "{prompt}",
344                // One JSON record per event, so `pma watch` can read a run
345                // while it works. The last record is the same `result`.
346                "--output-format",
347                "stream-json",
348                "--verbose",
349                "--permission-mode",
350                "acceptEdits",
351                "--model",
352                "{model}",
353                "--max-budget-usd",
354                "{budget}",
355                "{extra}",
356            ]
357            .iter()
358            .map(|s| (*s).to_string())
359            .collect(),
360            allow: Some("--allowedTools Bash({cmd})".into()),
361            parse: Parser::ClaudeJson,
362            reports_cost: true,
363            // Checked between turns, so a run can exceed it: a one-word reply
364            // under a $0.05 cap cost $0.09 on 2026-09-15.
365            enforces_budget: false,
366            sandbox: false,
367            resumes: false,
368            env: BTreeMap::new(),
369        }
370    }
371
372    /// The two other workers `pma` ships a template for, both installed
373    /// separately. Each reaches OpenAI, OpenRouter or an OpenAI-compatible
374    /// endpoint through its own provider configuration: `{model}` is passed
375    /// verbatim as `provider/model`, and the key comes from the environment.
376    ///
377    /// Both are seeded with `text-tail`, which takes the verdict from the exit
378    /// status and leaves the cost unknown rather than guessing at a field. A
379    /// `json:` parser reads a cost once its output shape is known, and that is
380    /// a `pma agent set` away rather than a code change.
381    pub fn opencode() -> Worker {
382        Worker {
383            name: "opencode".into(),
384            command: "opencode".into(),
385            args: [
386                "run", "--dir", "{dir}", "--format", "json", "--auto", "--model", "{model}",
387                "{extra}", "{prompt}",
388            ]
389            .iter()
390            .map(|s| (*s).to_string())
391            .collect(),
392            // `--auto` approves every permission that is not explicitly
393            // denied, so there is no per-command allowlist to emit.
394            allow: None,
395            parse: Parser::TextTail,
396            reports_cost: false,
397            enforces_budget: false,
398            sandbox: false,
399            resumes: true,
400            env: BTreeMap::new(),
401        }
402    }
403
404    /// The same `claude`, in a disposable container. `sanduk` holds the API
405    /// key on the host and bind-mounts the worktree, so the container has the
406    /// files and never the credential.
407    ///
408    /// Four flags are not optional and are the reason this is a template
409    /// rather than a line in the README. `--work-at-host-path` mounts the
410    /// worktree at its own path, so a path in a diff or a stack trace resolves
411    /// for whoever reads it here. `--stream-json` passes the agent's own
412    /// output through, which is what `claude-json` reads a cost from -- without
413    /// it `sanduk` reformats the result into prose and the cost is lost.
414    /// `--no-report-instruction` keeps `REPORT.md` out of the worktree, where
415    /// it would land in the diff and in the scope check. `--timeout` bounds
416    /// the container from inside, so `sanduk` tears it down rather than
417    /// leaving it for its own sweep.
418    ///
419    /// `key-safe` over `sealed`: sealed blocks the fetch that `cargo`, `go`
420    /// and `pip` do mid-build, and a run that cannot fetch fails for a reason
421    /// that has nothing to do with the task. `pma agent set sanduk args` is
422    /// where a portfolio whose images carry their toolchains tightens it.
423    ///
424    /// No allowlist. The box is the bound, and a container that denies egress
425    /// does not also need a `Bash()` rule: shipping both means believing in
426    /// both.
427    pub fn sanduk() -> Worker {
428        Worker {
429            name: "sanduk".into(),
430            command: "sanduk".into(),
431            args: [
432                "run",
433                "{prompt}",
434                "-w",
435                "{dir}",
436                "--work-at-host-path",
437                "--agent",
438                "claude",
439                // Named beside the agent: `sanduk`'s default provider is
440                // openai, which `claude` cannot speak, so leaving it out
441                // fails every dispatch before the container starts.
442                "--provider",
443                "anthropic",
444                "--mode",
445                "key-safe",
446                "--model",
447                "{model}",
448                "--timeout",
449                "{timeout}",
450                "--stream-json",
451                "--no-report-instruction",
452                "{extra}",
453            ]
454            .iter()
455            .map(|s| (*s).to_string())
456            .collect(),
457            allow: None,
458            parse: Parser::ClaudeJson,
459            reports_cost: true,
460            // `--budget` reaches OpenRouter alone, so nothing here bounds an
461            // Anthropic run's spend. The timeout is the bound.
462            enforces_budget: false,
463            sandbox: true,
464            resumes: false,
465            env: BTreeMap::new(),
466        }
467    }
468
469    pub fn omp() -> Worker {
470        Worker {
471            name: "omp".into(),
472            command: "omp".into(),
473            args: [
474                "-p", "--mode", "json", "--cwd", "{dir}", "--model", "{model}", "{extra}",
475                "{prompt}",
476            ]
477            .iter()
478            .map(|s| (*s).to_string())
479            .collect(),
480            allow: None,
481            parse: Parser::TextTail,
482            reports_cost: false,
483            enforces_budget: false,
484            sandbox: false,
485            resumes: true,
486            env: BTreeMap::new(),
487        }
488    }
489}
490
491#[cfg(test)]
492mod tests {
493    use super::*;
494    use std::path::Path;
495    use std::time::Duration;
496
497    /// A worker's own bound, as dispatch passes it.
498    const MINUTE: Duration = Duration::from_secs(60);
499
500    fn args_of(cmd: &Command) -> Vec<String> {
501        cmd.get_args()
502            .map(|a| a.to_string_lossy().into_owned())
503            .collect()
504    }
505
506    #[test]
507    fn placeholders_are_filled_from_the_run() {
508        let w = Worker::claude();
509        let cmd = w.build("do it", Path::new("/w/a"), Some("haiku"), 0.5, &[], MINUTE);
510        assert_eq!(
511            args_of(&cmd),
512            [
513                "-p",
514                "do it",
515                "--output-format",
516                "stream-json",
517                "--verbose",
518                "--permission-mode",
519                "acceptEdits",
520                "--model",
521                "haiku",
522                "--max-budget-usd",
523                "0.5"
524            ]
525        );
526    }
527
528    /// Choosing no model must not pass the literal placeholder, nor leave a
529    /// `--model` with nothing after it.
530    #[test]
531    fn an_unset_model_takes_its_flag_with_it() {
532        let cmd = Worker::claude().build("do it", Path::new("/w/a"), None, 0.5, &[], MINUTE);
533        let args = args_of(&cmd);
534        assert!(!args.iter().any(|a| a.contains("{model}")), "{args:?}");
535        assert!(!args.contains(&"--model".to_string()), "{args:?}");
536        assert!(
537            args.ends_with(&["--max-budget-usd".into(), "0.5".into()]),
538            "{args:?}"
539        );
540    }
541
542    /// Task text is the agent's to read, not a template: placeholders in it
543    /// are neither filled nor taken as a missing value.
544    #[test]
545    fn placeholders_in_the_prompt_reach_the_agent_as_written() {
546        let prompt = "document {model}, {dir} and {budget} in the README";
547        let args =
548            args_of(&Worker::claude().build(prompt, Path::new("/w/a"), None, 0.5, &[], MINUTE));
549        assert_eq!(args[..2], ["-p".to_string(), prompt.to_string()]);
550        assert!(!args.contains(&"--model".to_string()), "{args:?}");
551        let args = args_of(&Worker::claude().build(
552            prompt,
553            Path::new("/w/a"),
554            Some("haiku"),
555            0.5,
556            &[],
557            MINUTE,
558        ));
559        assert_eq!(args[1], prompt);
560        assert!(args.ends_with(&[
561            "--model".into(),
562            "haiku".into(),
563            "--max-budget-usd".into(),
564            "0.5".into()
565        ]));
566    }
567
568    #[test]
569    fn fill_replaces_known_placeholders_once() {
570        let v = [("a", Some("{b}")), ("b", Some("x")), ("m", None)];
571        assert_eq!(fill("{a}-{b}", &v).as_deref(), Some("{b}-x"));
572        assert_eq!(fill("{unknown} {", &v).as_deref(), Some("{unknown} {"));
573        assert_eq!(fill("--m={m}", &v), None);
574        assert_eq!(fill("{ab}", &v).as_deref(), Some("{ab}"));
575    }
576
577    #[test]
578    fn the_directory_is_passed_where_a_worker_wants_it() {
579        let w = Worker {
580            args: ["exec", "-C", "{dir}", "{prompt}"]
581                .iter()
582                .map(|s| (*s).to_string())
583                .collect(),
584            ..Worker::claude()
585        };
586        assert_eq!(
587            args_of(&w.build("do it", Path::new("/w/a"), None, 1.0, &[], MINUTE)),
588            ["exec", "-C", "/w/a", "do it"]
589        );
590    }
591
592    #[test]
593    fn a_worker_without_an_allowlist_gets_no_rules() {
594        let w = Worker {
595            allow: None,
596            ..Worker::claude()
597        };
598        let mut cmd = w.build("do it", Path::new("/w/a"), None, 1.0, &[], MINUTE);
599        let before = args_of(&cmd).len();
600        w.allow_verify(&mut cmd, Some("make test"));
601        assert_eq!(args_of(&cmd).len(), before);
602    }
603
604    #[test]
605    fn the_allowlist_names_one_rule_per_subcommand() {
606        let w = Worker::claude();
607        let mut cmd = w.build("do it", Path::new("/w/a"), None, 1.0, &[], MINUTE);
608        w.allow_verify(&mut cmd, Some("make check && cargo test"));
609        let args = args_of(&cmd);
610        assert!(
611            args.ends_with(&[
612                "--allowedTools".into(),
613                "Bash(make check)".into(),
614                "Bash(cargo test)".into()
615            ]),
616            "{args:?}"
617        );
618    }
619
620    /// A worker with no structured output reports an unknown cost, never a
621    /// free run.
622    #[test]
623    fn text_tail_reads_the_exit_status_and_leaves_the_cost_unknown() {
624        let ok = Parser::TextTail.report("line one\nline two\n", true);
625        assert_eq!(
626            ok,
627            Report {
628                ok: true,
629                summary: "line one\nline two".into(),
630                cost_usd: None
631            }
632        );
633        assert!(!Parser::TextTail.report("boom", false).ok);
634    }
635
636    #[test]
637    fn parser_names_round_trip() {
638        for (name, parser) in Parser::ALL {
639            assert_eq!(Parser::parse(name), Some(parser.clone()));
640            assert_eq!(parser.name(), name);
641        }
642        assert_eq!(Parser::parse("codex-json"), None);
643        for spec in ["json:result:usage.cost", "json:a.b.0:c:failed"] {
644            assert_eq!(Parser::parse(spec).map(|p| p.name()).as_deref(), Some(spec));
645        }
646        // A summary path is the one thing a field parser cannot do without.
647        assert_eq!(Parser::parse("json:"), None);
648    }
649
650    /// A worker whose output shape nothing else reads is a record: the paths
651    /// say where the summary, the cost and the failure flag are.
652    #[test]
653    fn a_field_parser_reads_a_worker_it_was_never_told_about() {
654        let p = Parser::parse("json:info.text:usage.cost_usd:info.failed").unwrap();
655        let output = "starting\n{\"info\":{\"text\":\"did it\",\"failed\":false},\"usage\":{\"cost_usd\":0.42}}\n";
656        assert_eq!(
657            p.report(output, true),
658            Report {
659                ok: true,
660                summary: "did it".into(),
661                cost_usd: Some(0.42)
662            }
663        );
664        // The flag outranks a zero exit status, and a cost as text is read.
665        let failed =
666            "{\"info\":{\"text\":\"gave up\",\"failed\":true},\"usage\":{\"cost_usd\":\"0.1\"}}";
667        let r = p.report(failed, true);
668        assert!(!r.ok);
669        assert_eq!((r.summary.as_str(), r.cost_usd), ("gave up", Some(0.1)));
670        // A path that resolves to nothing leaves the cost unknown, never zero,
671        // and the output's tail stands in for the summary.
672        let r = p.report("{\"other\":1}", true);
673        assert_eq!(r.cost_usd, None);
674        assert_eq!(r.summary, "{\"other\":1}");
675        // No JSON at all is a failure, whatever the status said.
676        assert!(!p.report("command not found\n", true).ok);
677    }
678
679    /// The environment a record carries is applied after the pipeline has
680    /// stripped what an agent may not have, so a worker cannot restore a
681    /// credential by naming it.
682    #[test]
683    fn a_workers_environment_cannot_put_back_a_stripped_credential() {
684        let dir = std::env::temp_dir().join(format!("pma-worker-env-{}", std::process::id()));
685        std::fs::create_dir_all(&dir).unwrap();
686        let mut w = Worker::claude();
687        w.command = "sh".into();
688        w.args = vec![
689            "-c".into(),
690            "echo \"${OPENAI_BASE_URL-unset} ${GH_TOKEN-unset}\"".into(),
691        ];
692        w.env = BTreeMap::from([
693            ("OPENAI_BASE_URL".into(), "http://localhost:11434/v1".into()),
694            ("GH_TOKEN".into(), "sneaky".into()),
695        ]);
696        let mut cmd = w.build("", &dir, None, 1.0, &[], MINUTE);
697        crate::agent::restrict(&mut cmd, &dir).unwrap();
698        let out = cmd.output().unwrap();
699        assert_eq!(
700            String::from_utf8_lossy(&out.stdout).trim(),
701            "http://localhost:11434/v1 unset"
702        );
703        let _ = std::fs::remove_dir_all(dir);
704    }
705
706    /// A worker that bounds itself is told when to stop, and is told sooner
707    /// than the pipeline's own deadline so it stops itself first.
708    #[test]
709    fn a_timeout_reaches_a_worker_that_takes_one() {
710        let cmd = Worker::sanduk().build(
711            "do it",
712            Path::new("/w/a"),
713            None,
714            1.0,
715            &[],
716            Duration::from_secs(870),
717        );
718        let args = args_of(&cmd);
719        assert_eq!(
720            args[args.iter().position(|a| a == "--timeout").unwrap() + 1],
721            "870s"
722        );
723    }
724
725    /// The container run is the same task in a box: the worktree at its own
726    /// path, the agent's own stream passed through so the cost survives, and
727    /// no REPORT.md written into the tree the diff is taken from.
728    #[test]
729    fn the_container_template_mounts_the_worktree_where_the_host_has_it() {
730        let w = Worker::sanduk();
731        let cmd = w.build(
732            "fix the test",
733            Path::new("/w/a"),
734            Some("opus"),
735            1.0,
736            &[],
737            MINUTE,
738        );
739        let args = args_of(&cmd);
740
741        assert_eq!(w.command, "sanduk");
742        assert_eq!(
743            args[args.iter().position(|a| a == "-w").unwrap() + 1],
744            "/w/a"
745        );
746        for flag in [
747            "--work-at-host-path",
748            "--stream-json",
749            "--no-report-instruction",
750        ] {
751            assert!(args.contains(&flag.to_string()), "{flag} is not passed");
752        }
753        assert_eq!(
754            args[args.iter().position(|a| a == "--model").unwrap() + 1],
755            "opus"
756        );
757        assert_eq!(args[1], "fix the test", "the task is sanduk's positional");
758    }
759
760    /// The template's flags are ones the installed `sanduk` accepts, so a flag
761    /// renamed upstream fails here rather than on every dispatch. `--help`
762    /// last has clap reject an unknown flag or value without starting
763    /// anything; `--dry-run` would create the container network. Skipped where
764    /// `sanduk` is not installed: there is no binary to drift from.
765    #[test]
766    fn the_container_template_parses_against_the_installed_sanduk() {
767        let w = Worker::sanduk();
768        let cmd = w.build("do it", Path::new("/w/a"), Some("opus"), 1.0, &[], MINUTE);
769        let args = args_of(&cmd);
770        let sanduk = |args: &[String]| Command::new(&w.command).args(args).output();
771
772        let out = match sanduk(&[args.clone(), vec!["--help".into()]].concat()) {
773            Ok(out) => out,
774            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
775                eprintln!("skipped: {} is not on PATH", w.command);
776                return;
777            }
778            Err(e) => panic!("{}: {e}", w.command),
779        };
780        assert!(
781            out.status.success(),
782            "sanduk refuses the template: {}",
783            String::from_utf8_lossy(&out.stderr)
784        );
785
786        let agent = &args[args.iter().position(|a| a == "--agent").unwrap() + 1];
787        let out = sanduk(&["list".into(), "agents".into()]).expect("sanduk list agents");
788        let agents = String::from_utf8_lossy(&out.stdout);
789        assert!(
790            agents
791                .lines()
792                .any(|l| l.split_whitespace().next() == Some(agent)),
793            "sanduk has no agent {agent}: {agents}"
794        );
795    }
796
797    /// The box is the bound. A container that denies egress does not also need
798    /// a `Bash()` rule, and shipping both would mean believing in both.
799    #[test]
800    fn the_container_template_carries_no_allowlist() {
801        let w = Worker::sanduk();
802        let mut cmd = w.build("do it", Path::new("/w/a"), None, 1.0, &[], MINUTE);
803        let before = args_of(&cmd);
804        w.allow_verify(&mut cmd, Some("cargo test"));
805        assert_eq!(args_of(&cmd), before);
806    }
807
808    /// `sanduk` reformats a run's result into a prose line of its own, which
809    /// carries no cost. `--stream-json` passes the agent's records through
810    /// instead, and `claude-json` reads the last of them.
811    #[test]
812    fn the_container_template_still_reads_a_cost_from_the_agents_own_stream() {
813        let stream = concat!(
814            "sanduk: sanduk-ab12 -> /w/a\n",
815            r#"{"type":"assistant","message":{"content":[{"type":"text","text":"working"}]}}"#,
816            "\n",
817            r#"{"type":"result","subtype":"success","is_error":false,"result":"fixed","total_cost_usd":0.42}"#,
818            "\n",
819            "sanduk: 41.2s wall\n",
820        );
821        let report = Worker::sanduk().parse.report(stream, true);
822        assert!(report.ok);
823        assert_eq!(report.cost_usd, Some(0.42));
824        assert_eq!(report.summary, "fixed");
825    }
826
827    /// The templates are records too: nothing in the pipeline knows their
828    /// names, and the model string is passed through for their own provider
829    /// configuration to resolve.
830    #[test]
831    fn the_shipped_templates_pass_a_provider_qualified_model_through() {
832        for (w, expect) in [
833            (Worker::opencode(), "openai/gpt-5.2"),
834            (Worker::omp(), "openrouter/anthropic/claude-sonnet-4.5"),
835        ] {
836            let cmd = w.build("do it", Path::new("/w/a"), Some(expect), 1.0, &[], MINUTE);
837            let args = args_of(&cmd);
838            assert!(args.contains(&expect.to_string()), "{args:?}");
839            assert!(args.contains(&"/w/a".to_string()), "{args:?}");
840            assert!(args.contains(&"do it".to_string()), "{args:?}");
841            // Cost is unknown rather than zero until a `json:` path is set.
842            assert!(!w.reports_cost);
843            assert_eq!(w.parse, Parser::TextTail);
844        }
845    }
846}