1use std::collections::BTreeMap;
12use std::process::Command;
13
14use crate::agent::{Report, tail};
15
16#[derive(Debug, Clone, PartialEq, Eq)]
18pub enum Parser {
19 ClaudeJson,
23 TextTail,
26 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 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
138fn 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
151fn 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 pub args: Vec<String>,
192 pub allow: Option<String>,
197 pub parse: Parser,
198 pub reports_cost: bool,
201 pub enforces_budget: bool,
205 pub sandbox: bool,
206 pub resumes: bool,
209 pub env: BTreeMap<String, String>,
215}
216
217pub 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
236fn 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 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 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 for (key, value) in &self.env {
311 cmd.env(key, value);
312 }
313 cmd
314 }
315
316 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 pub fn claude() -> Worker {
338 Worker {
339 name: "claude".into(),
340 command: "claude".into(),
341 args: [
342 "-p",
343 "{prompt}",
344 "--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 enforces_budget: false,
366 sandbox: false,
367 resumes: false,
368 env: BTreeMap::new(),
369 }
370 }
371
372 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 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 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 "--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 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 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 #[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 #[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 #[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 assert_eq!(Parser::parse("json:"), None);
648 }
649
650 #[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 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 let r = p.report("{\"other\":1}", true);
673 assert_eq!(r.cost_usd, None);
674 assert_eq!(r.summary, "{\"other\":1}");
675 assert!(!p.report("command not found\n", true).ok);
677 }
678
679 #[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 #[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 #[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 #[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 #[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 #[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 #[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 assert!(!w.reports_cost);
843 assert_eq!(w.parse, Parser::TextTail);
844 }
845 }
846}