1use crate::engine::runner::agents::shell_single_quote;
7use crate::template::render_template_str;
8use crate::tmux::TmuxManager;
9use std::collections::BTreeSet;
10use std::collections::HashMap;
11use std::path::PathBuf;
12
13const AGENT_SYSTEM_TEMPLATE: &str = include_str!("../../../prompts/agent_system.md");
14const AGENT_MESSAGE_TEMPLATE: &str = include_str!("../../../prompts/agent_message.md");
15const AGENT_MEMORY_ENTRY_TEMPLATE: &str = include_str!("../../../prompts/agent_memory_entry.md");
16const ALLOWED_TOOLS_TEMPLATE: &str = include_str!("../../../prompts/allowed_tools.md");
17const REVIEW_PROMPT_TEMPLATE: &str = include_str!("../../../prompts/review_task.md");
18const REVIEW_SYSTEM_TEMPLATE: &str = include_str!("../../../prompts/review_system.md");
19
20#[derive(Clone, Copy, Debug, PartialEq, Eq)]
23pub enum Shipcheck {
24 Absent,
25 NotInstalled,
26 Outdated,
27 Available,
28}
29
30impl Shipcheck {
31 pub async fn detect(work_dir: &std::path::Path) -> Self {
32 static SUPPORTS_COMMIT: tokio::sync::OnceCell<Option<bool>> =
33 tokio::sync::OnceCell::const_new();
34 let installed = SUPPORTS_COMMIT
35 .get_or_init(|| async {
36 let bin = which::which("shipcheck").ok()?;
37 Some(supports(&bin, "commit").await)
38 })
39 .await;
40 Self::classify(work_dir.join("shipcheck.toml").exists(), *installed)
41 }
42
43 fn classify(has_config: bool, supports_commit: Option<bool>) -> Self {
44 match (has_config, supports_commit) {
45 (false, _) => Shipcheck::Absent,
46 (true, None) => Shipcheck::NotInstalled,
47 (true, Some(false)) => Shipcheck::Outdated,
48 (true, Some(true)) => Shipcheck::Available,
49 }
50 }
51
52 pub fn for_prompt(self, sandboxed: bool) -> Self {
55 if sandboxed && self == Shipcheck::Available {
56 return Shipcheck::Absent;
57 }
58
59 self
60 }
61
62 fn insert_vars(self, vars: &mut HashMap<String, String>) {
63 let flags: &[&str] = match self {
64 Shipcheck::Available => &["SHIPCHECK"],
65 Shipcheck::NotInstalled => &["NO_SHIPCHECK", "SHIPCHECK_MISSING"],
66 Shipcheck::Absent | Shipcheck::Outdated => &["NO_SHIPCHECK"],
67 };
68 for flag in flags {
69 vars.insert(flag.to_string(), "1".to_string());
70 }
71 }
72}
73
74fn insert_cluster_vars(shipcheck: Shipcheck, vars: &mut HashMap<String, String>) {
77 if !crate::cluster::enabled() {
78 return;
79 }
80
81 let commit = if shipcheck == Shipcheck::Available {
82 "CLUSTER_SHIPCHECK"
83 } else {
84 "CLUSTER_GIT"
85 };
86 for flag in ["CLUSTER", commit] {
87 vars.insert(flag.to_string(), "1".to_string());
88 }
89}
90
91async fn run_shipcheck(
92 bin: &std::path::Path,
93 work_dir: &std::path::Path,
94 args: &[&str],
95) -> anyhow::Result<std::process::Output> {
96 let output = tokio::process::Command::new(bin)
97 .args(args)
98 .current_dir(work_dir)
99 .stdin(std::process::Stdio::null())
100 .kill_on_drop(true)
101 .output();
102 Ok(tokio::time::timeout(std::time::Duration::from_secs(30), output).await??)
103}
104
105async fn supports(bin: &std::path::Path, subcommand: &str) -> bool {
107 let Ok(out) = run_shipcheck(bin, &std::env::temp_dir(), &["--help"]).await else {
108 return false;
109 };
110 out.status.success()
111 && String::from_utf8_lossy(&out.stdout)
112 .split_whitespace()
113 .any(|word| word == subcommand)
114}
115
116pub async fn install_shipcheck_hooks(
119 work_dir: &std::path::Path,
120 shipcheck: Shipcheck,
121 bin: &std::path::Path,
122) -> bool {
123 if shipcheck != Shipcheck::Available {
124 return false;
125 }
126
127 if !supports(bin, "hook").await {
129 return false;
130 }
131
132 match run_shipcheck(bin, work_dir, &["install"]).await {
133 Ok(out) if out.status.success() => {}
134 Ok(out) => tracing::warn!(
135 work_dir = %work_dir.display(),
136 stderr = %String::from_utf8_lossy(&out.stderr),
137 "shipcheck install failed"
138 ),
139 Err(e) => tracing::warn!(
140 work_dir = %work_dir.display(),
141 error = %e,
142 "shipcheck install failed"
143 ),
144 }
145 true
146}
147
148const SHIPCHECK_RULE_PATH: &str = ".codex/rules/orch-shipcheck.rules";
149
150const SHIPCHECK_RULES: &str = r#"prefix_rule(pattern=["shipcheck", "run"], decision="allow")
152prefix_rule(pattern=["shipcheck", "commit"], decision="allow")
153prefix_rule(pattern=["shipcheck", "status"], decision="allow")
154prefix_rule(pattern=["shipcheck", "logs"], decision="allow")
155prefix_rule(pattern=["shipcheck", "doctor"], decision="allow")
156"#;
157
158async fn install_shipcheck_rule(work_dir: &std::path::Path) -> anyhow::Result<()> {
161 let mut dir = work_dir.to_path_buf();
163 for part in [".codex", "rules"] {
164 dir.push(part);
165 match tokio::fs::symlink_metadata(&dir).await {
166 Ok(meta) if meta.is_dir() => {}
167 Ok(_) => anyhow::bail!("{} is not a directory", dir.display()),
168 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
169 tokio::fs::create_dir(&dir).await?;
170 }
171 Err(e) => return Err(e.into()),
172 }
173 }
174
175 let rule = work_dir.join(SHIPCHECK_RULE_PATH);
177 match tokio::fs::remove_file(&rule).await {
178 Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e.into()),
179 _ => {}
180 }
181
182 let mut file = tokio::fs::OpenOptions::new()
183 .write(true)
184 .create_new(true)
185 .open(&rule)
186 .await?;
187 tokio::io::AsyncWriteExt::write_all(&mut file, SHIPCHECK_RULES.as_bytes()).await?;
188
189 let output = tokio::process::Command::new("git")
191 .args(["rev-parse", "--git-path", "info/exclude"])
192 .current_dir(work_dir)
193 .output()
194 .await?;
195 if !output.status.success() {
196 return Ok(());
197 }
198
199 let exclude = work_dir.join(String::from_utf8_lossy(&output.stdout).trim());
200 let line = format!("/{SHIPCHECK_RULE_PATH}");
201 let current = tokio::fs::read_to_string(&exclude)
202 .await
203 .unwrap_or_default();
204 if current.lines().any(|l| l == line) {
205 return Ok(());
206 }
207
208 if let Some(dir) = exclude.parent() {
209 tokio::fs::create_dir_all(dir).await?;
210 }
211
212 let sep = if current.is_empty() || current.ends_with('\n') {
213 ""
214 } else {
215 "\n"
216 };
217 tokio::fs::write(&exclude, format!("{current}{sep}{line}\n")).await?;
218 Ok(())
219}
220
221pub(crate) async fn resolve_git_dir_arg(
224 work_dir: &std::path::Path,
225 arg: &str,
226) -> anyhow::Result<Option<PathBuf>> {
227 let output = tokio::process::Command::new("git")
228 .args(["rev-parse", arg])
229 .current_dir(work_dir)
230 .output()
231 .await?;
232
233 if !output.status.success() {
234 return Ok(None);
235 }
236
237 let raw = String::from_utf8_lossy(&output.stdout).trim().to_string();
238 if raw.is_empty() {
239 return Ok(None);
240 }
241
242 let candidate = if std::path::Path::new(&raw).is_absolute() {
244 PathBuf::from(&raw)
245 } else {
246 work_dir.join(&raw)
247 };
248
249 let canonical = tokio::fs::canonicalize(&candidate)
250 .await
251 .unwrap_or(candidate);
252 Ok(Some(canonical))
253}
254
255fn render_prompt_template(template: &str, vars: HashMap<String, String>) -> String {
256 match render_template_str(template, &vars) {
257 Ok(rendered) => rendered,
258 Err(err) => {
259 tracing::error!(error = %err, "failed to render prompt template");
260 template.to_string()
261 }
262 }
263}
264
265pub struct AgentInvocation {
267 pub agent: String,
269 pub model: Option<String>,
271 pub work_dir: PathBuf,
273 pub system_prompt: String,
275 pub agent_message: String,
277 pub task_id: String,
279 pub disallowed_tools: Vec<String>,
281 pub git_author_name: String,
283 pub git_author_email: String,
285 pub output_file: PathBuf,
287 pub timeout_seconds: u64,
289 pub repo: String,
291 pub sandbox: bool,
293 pub main_project_dir: PathBuf,
295 pub branch: String,
297 pub attempt: u32,
299}
300
301fn build_tmux_runner_scripts(
305 inv: &AgentInvocation,
306 agent_cmd: &str,
307 attempt_dir: &std::path::Path,
308 sandbox: Option<&[std::ffi::OsString]>,
309) -> anyhow::Result<(String, String)> {
310 let status_file = attempt_dir.join("exit.txt");
311 let stderr_file = attempt_dir.join("stderr.txt");
312 let agent_cmd_script_path = attempt_dir.join("agent_cmd.sh");
313 let sq_git_name = shell_single_quote(&inv.git_author_name);
314 let sq_git_email = shell_single_quote(&inv.git_author_email);
315 let sq_task_id = shell_single_quote(&inv.task_id);
316 let sq_output_file = shell_single_quote(&inv.output_file.display().to_string());
317 let sq_work_dir = shell_single_quote(&inv.work_dir.display().to_string());
318 let sq_status_file = shell_single_quote(&status_file.display().to_string());
319 let sq_stderr_file = shell_single_quote(&stderr_file.display().to_string());
320 let sq_agent_cmd_script = shell_single_quote(&agent_cmd_script_path.display().to_string());
321
322 let agent_cmd = match sandbox {
325 Some(argv) => {
326 let prefix: Vec<String> = argv
327 .iter()
328 .map(|a| shell_single_quote(&a.to_string_lossy()))
329 .collect();
330 let tmpdir = shell_single_quote(
332 &super::sandbox::run_temp_dir(attempt_dir)
333 .display()
334 .to_string(),
335 );
336 format!(
337 "env -u TMUX -u TMUX_PANE -u SSH_AUTH_SOCK TMPDIR={tmpdir} {} /bin/bash -c {}",
338 prefix.join(" "),
339 shell_single_quote(agent_cmd)
340 )
341 }
342 None => agent_cmd.to_string(),
343 };
344 let agent_cmd_script = format!("#!/usr/bin/env bash\n{agent_cmd} 2>{sq_stderr_file}\n");
345
346 let runner_script = format!(
347 r#"#!/usr/bin/env bash
348set -euo pipefail
349
350status_file={sq_status_file}
351stderr_file={sq_stderr_file}
352trap 'status=$?; printf "%s\n" "$status" > "$status_file"' EXIT
353
354# Environment — ~/.path and ~/.private are loaded by orch at startup into the process env
355export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"
356export GIT_AUTHOR_NAME={sq_git_name}
357export GIT_COMMITTER_NAME={sq_git_name}
358export GIT_AUTHOR_EMAIL={sq_git_email}
359export GIT_COMMITTER_EMAIL={sq_git_email}
360# Env config overrides the operator's git config for this process only, the remotes stay as they
361# are: agents never sign, and push over HTTPS with gh's credentials, not the operator's SSH agent
362export GIT_CONFIG_COUNT="4"
363export GIT_CONFIG_KEY_0="commit.gpgsign"
364export GIT_CONFIG_VALUE_0="false"
365export GIT_CONFIG_KEY_1="tag.gpgsign"
366export GIT_CONFIG_VALUE_1="false"
367export GIT_CONFIG_KEY_2="url.https://github.com/.pushInsteadOf"
368export GIT_CONFIG_VALUE_2="git@github.com:"
369export GIT_CONFIG_KEY_3="credential.https://github.com.helper"
370export GIT_CONFIG_VALUE_3="!gh auth git-credential"
371export TASK_ID={sq_task_id}
372export OUTPUT_FILE={sq_output_file}
373unset CLAUDECODE # allow nested claude invocations from orch
374
375cd {sq_work_dir} || {{
376 printf '%s\n' "worktree directory does not exist: {sq_work_dir}" > "$stderr_file"
377 exit 1
378}}
379
380# Run agent under a pty (via `script`) so its stdout is a tty, not a plain pipe —
381# many CLI runtimes silently switch to full block-buffering when stdout isn't a
382# tty, which can delay pane output long enough to trip silence detection even
383# while the agent is actively streaming. Tee to both output file and terminal
384# (tmux pane) for live streaming.
385set +e
386script -q /dev/null /bin/bash {sq_agent_cmd_script} | tee {sq_output_file}
387CMD_STATUS=${{PIPESTATUS[0]:-0}}
388set -e
389
390exit $CMD_STATUS
391"#,
392 );
393
394 Ok((runner_script, agent_cmd_script))
395}
396
397pub async fn spawn_in_tmux(tmux: &TmuxManager, inv: &AgentInvocation) -> anyhow::Result<String> {
401 let attempt_dir =
403 crate::home::task_attempt_dir_async(&inv.repo, &inv.task_id, inv.attempt).await?;
404
405 let sys_file = attempt_dir.join("prompt-sys.md");
406 let msg_file = attempt_dir.join("prompt-msg.md");
407 let mut permissions = super::agents::PermissionRules::from_config();
409 if !inv.disallowed_tools.is_empty() {
410 for tool in &inv.disallowed_tools {
411 if !permissions.disallowed_tools.contains(tool) {
412 permissions.disallowed_tools.push(tool.clone());
413 }
414 }
415 }
416 permissions.allowed_edit_paths.push(inv.work_dir.clone());
417
418 let sandbox = if inv.sandbox {
420 permissions.sandbox = super::agents::SandboxLevel::Os;
421 let temp = super::sandbox::run_temp_dir(&attempt_dir);
422 tokio::fs::create_dir_all(&temp).await?;
423
424 for name in [
427 "runner.sh",
428 "agent_cmd.sh",
429 "exit.txt",
430 "stderr.txt",
431 "prompt-sys.md",
432 "prompt-msg.md",
433 "result.json",
434 ] {
435 let path = attempt_dir.join(name);
436
437 if !path.exists() {
438 tokio::fs::write(&path, "").await?;
439 }
440 }
441
442 if !inv.output_file.exists() && inv.output_file.parent().is_some_and(|p| p.is_dir()) {
443 tokio::fs::write(&inv.output_file, "").await?;
444 }
445
446 Some(super::sandbox::argv(inv, &temp).await?)
447 } else {
448 None
449 };
450
451 if inv.agent == "codex" {
456 let mut extra_dirs = BTreeSet::new();
457 for (arg, label) in [
458 ("--git-common-dir", "git common dir"),
459 ("--git-dir", "git dir"),
460 ] {
461 match resolve_git_dir_arg(&inv.work_dir, arg).await {
462 Ok(Some(dir)) if !dir.starts_with(&inv.work_dir) => {
463 extra_dirs.insert(dir);
464 }
465 Ok(_) => {}
466 Err(e) => {
467 tracing::debug!(
468 work_dir = %inv.work_dir.display(),
469 arg,
470 error = %e,
471 "failed to resolve {}; skipping --add-dir",
472 label
473 );
474 }
475 }
476 }
477 permissions.extra_writable_dirs.extend(extra_dirs);
478
479 if Shipcheck::detect(&inv.work_dir).await == Shipcheck::Available {
480 if let Err(e) = install_shipcheck_rule(&inv.work_dir).await {
481 tracing::warn!(
482 work_dir = %inv.work_dir.display(),
483 error = %e,
484 "failed to install the shipcheck codex rule"
485 );
486 }
487 }
488 }
489
490 let sys_content = if !permissions.allowed_tools.is_empty() {
491 let tools_list = permissions
492 .allowed_tools
493 .iter()
494 .map(|t| format!("- {t}"))
495 .collect::<Vec<_>>()
496 .join("\n");
497 let mut vars = HashMap::new();
498 vars.insert("TOOLS_LIST".to_string(), tools_list);
499 let tools_prompt = render_prompt_template(ALLOWED_TOOLS_TEMPLATE, vars);
500 format!("{}\n\n{}", inv.system_prompt, tools_prompt)
501 } else {
502 inv.system_prompt.clone()
503 };
504
505 tokio::fs::write(&sys_file, &sys_content).await?;
506 tokio::fs::write(&msg_file, &inv.agent_message).await?;
507
508 let runner = super::agents::get_runner(&inv.agent);
510
511 let mut env = std::collections::HashMap::new();
513 env.insert("GIT_AUTHOR_NAME".to_string(), inv.git_author_name.clone());
514 env.insert(
515 "GIT_COMMITTER_NAME".to_string(),
516 inv.git_author_name.clone(),
517 );
518 env.insert("GIT_AUTHOR_EMAIL".to_string(), inv.git_author_email.clone());
519 env.insert(
520 "GIT_COMMITTER_EMAIL".to_string(),
521 inv.git_author_email.clone(),
522 );
523 env.insert("TASK_ID".to_string(), inv.task_id.clone());
524 env.insert(
525 "OUTPUT_FILE".to_string(),
526 inv.output_file.display().to_string(),
527 );
528
529 let timeout_cmd = if inv.timeout_seconds > 0 {
531 format!("timeout {}", inv.timeout_seconds)
532 } else {
533 String::new()
534 };
535 let agent_cmd = runner.build_command(
536 inv.model
537 .as_deref()
538 .map(str::trim)
539 .filter(|m| !m.is_empty()),
540 &timeout_cmd,
541 &sys_file.to_string_lossy(),
542 &msg_file.to_string_lossy(),
543 &permissions,
544 );
545
546 let script_path = attempt_dir.join("runner.sh");
549 let agent_cmd_script_path = attempt_dir.join("agent_cmd.sh");
550 let (script_content, agent_cmd_script_content) =
551 build_tmux_runner_scripts(inv, &agent_cmd, &attempt_dir, sandbox.as_deref())?;
552 tokio::fs::write(&agent_cmd_script_path, &agent_cmd_script_content).await?;
553 tokio::fs::write(&script_path, &script_content).await?;
554
555 #[cfg(unix)]
557 {
558 use std::os::unix::fs::PermissionsExt;
559 tokio::fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o755)).await?;
560 tokio::fs::set_permissions(
561 &agent_cmd_script_path,
562 std::fs::Permissions::from_mode(0o755),
563 )
564 .await?;
565 }
566
567 let command = format!("bash \"{}\"", script_path.display());
568
569 let github_token = match crate::github::token::shared().get_token().await {
571 Ok(Some(token)) => {
572 tracing::debug!("Resolved GitHub token via TokenResolver for agent session");
573 Some(token)
574 }
575 Ok(None) => {
576 tracing::warn!(
577 "No GitHub token available; set GH_TOKEN, GITHUB_TOKEN, or gh.auth.token, or run gh auth login"
578 );
579 None
580 }
581 Err(e) => {
582 tracing::warn!(error = %e, "Failed to resolve GitHub token");
583 None
584 }
585 };
586
587 if let Some(ref token) = github_token {
600 env.insert("GH_TOKEN".to_string(), token.clone());
601 }
602
603 let env_vec: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
604
605 let session = tmux
606 .create_session(
607 &inv.repo,
608 &inv.task_id,
609 &inv.work_dir.to_string_lossy(),
610 &command,
611 env_vec.as_slice(),
612 )
613 .await?;
614
615 tracing::info!(
616 task_id = inv.task_id,
617 agent = inv.agent,
618 session = %session,
619 "agent spawned in tmux"
620 );
621
622 Ok(session)
623}
624
625pub fn build_system_prompt(
627 _task: &crate::backends::ExternalTask,
628 context: &super::context::TaskContext,
629 route_result: Option<&crate::engine::router::RouteResult>,
630 default_branch: &str,
631 shipcheck: Shipcheck,
632) -> String {
633 let mut vars = HashMap::new();
634
635 vars.insert("DEFAULT_BRANCH".to_string(), default_branch.to_string());
636 shipcheck.insert_vars(&mut vars);
637 insert_cluster_vars(shipcheck, &mut vars);
638
639 if let Some(rr) = route_result {
640 vars.insert("ROLE".to_string(), rr.profile.role.clone());
641
642 if !rr.profile.constraints.is_empty() {
643 let constraints = rr
644 .profile
645 .constraints
646 .iter()
647 .map(|c| format!("- {c}"))
648 .collect::<Vec<_>>()
649 .join("\n");
650 vars.insert("CONSTRAINTS".to_string(), constraints);
651 }
652 }
653
654 if !context.project_instructions.is_empty() {
655 vars.insert(
656 "PROJECT_INSTRUCTIONS".to_string(),
657 context.project_instructions.clone(),
658 );
659 }
660
661 if !context.skills_docs.is_empty() {
662 vars.insert("SKILLS_DOCS".to_string(), context.skills_docs.clone());
663 }
664
665 if !context.repo_tree.is_empty() {
666 vars.insert("REPO_TREE".to_string(), context.repo_tree.clone());
667 }
668
669 render_prompt_template(AGENT_SYSTEM_TEMPLATE, vars)
670}
671
672pub fn build_agent_message(
674 task: &crate::backends::ExternalTask,
675 context: &super::context::TaskContext,
676 attempts: u32,
677) -> String {
678 let mut vars = HashMap::new();
679 vars.insert("TASK_ID".to_string(), task.id.0.clone());
680 vars.insert("TASK_TITLE".to_string(), task.title.clone());
681 vars.insert("TASK_BODY".to_string(), task.body.clone());
682
683 if !context.task_context.is_empty() {
684 vars.insert("TASK_CONTEXT".to_string(), context.task_context.clone());
685 }
686
687 if !context.parent_context.is_empty() {
688 vars.insert("PARENT_CONTEXT".to_string(), context.parent_context.clone());
689 }
690
691 if !context.issue_comments.is_empty() {
692 vars.insert("ISSUE_COMMENTS".to_string(), context.issue_comments.clone());
693 }
694
695 if !context.pr_review_context.is_empty() {
696 vars.insert(
697 "PR_REVIEW_CONTEXT".to_string(),
698 context.pr_review_context.clone(),
699 );
700 }
701
702 if attempts > 0 && !context.git_diff.is_empty() {
703 vars.insert("GIT_DIFF".to_string(), context.git_diff.clone());
704 }
705
706 if attempts > 0 {
707 vars.insert("ATTEMPT_NUMBER".to_string(), (attempts + 1).to_string());
708 }
709
710 if !context.memory.is_empty() {
711 let mut sections = Vec::new();
712 for entry in &context.memory {
713 let mut entry_vars = HashMap::new();
714 entry_vars.insert("ATTEMPT".to_string(), entry.attempt.to_string());
715 entry_vars.insert("AGENT".to_string(), entry.agent.clone());
716
717 if let Some(ref model) = entry.model {
718 entry_vars.insert("MODEL".to_string(), model.clone());
719 }
720
721 if !entry.approach.is_empty() {
722 entry_vars.insert("APPROACH".to_string(), entry.approach.clone());
723 }
724
725 if !entry.learnings.is_empty() {
726 let learnings = entry
727 .learnings
728 .iter()
729 .map(|learning| format!("- {}", learning))
730 .collect::<Vec<_>>()
731 .join("\n");
732 entry_vars.insert("LEARNINGS".to_string(), learnings);
733 }
734
735 if let Some(ref error) = entry.error {
736 entry_vars.insert("ERROR".to_string(), error.clone());
737 }
738
739 if !entry.files_modified.is_empty() {
740 entry_vars.insert(
741 "FILES_MODIFIED".to_string(),
742 entry.files_modified.join(", "),
743 );
744 }
745
746 let rendered = render_prompt_template(AGENT_MEMORY_ENTRY_TEMPLATE, entry_vars);
747 sections.push(rendered.trim().to_string());
748 }
749
750 let memory_section = sections.join("\n\n");
751 vars.insert("MEMORY_SECTION".to_string(), memory_section);
752 }
753
754 render_prompt_template(AGENT_MESSAGE_TEMPLATE, vars)
755}
756
757pub fn build_review_prompt(
762 task: &crate::backends::ExternalTask,
763 agent_summary: &str,
764 git_diff: &str,
765 git_log: &str,
766 default_branch: &str,
767 pr_number: u64,
768 shipcheck: Shipcheck,
769) -> String {
770 let mut vars = HashMap::new();
771 shipcheck.insert_vars(&mut vars);
772 insert_cluster_vars(shipcheck, &mut vars);
773 vars.insert("TASK_ID".to_string(), task.id.0.clone());
774 vars.insert("TASK_TITLE".to_string(), task.title.clone());
775 vars.insert("TASK_BODY".to_string(), task.body.clone());
776 vars.insert("DEFAULT_BRANCH".to_string(), default_branch.to_string());
777 vars.insert("PR_NUMBER".to_string(), pr_number.to_string());
778
779 if !agent_summary.is_empty() {
780 vars.insert("AGENT_SUMMARY".to_string(), agent_summary.to_string());
781 }
782
783 if !git_diff.is_empty() {
784 vars.insert("GIT_DIFF".to_string(), git_diff.to_string());
785 }
786
787 if !git_log.is_empty() {
788 vars.insert("GIT_LOG".to_string(), git_log.to_string());
789 }
790
791 render_prompt_template(REVIEW_PROMPT_TEMPLATE, vars)
792}
793
794#[allow(dead_code)]
798pub fn build_runner_script(inv: &AgentInvocation) -> anyhow::Result<String> {
799 let attempt_dir = crate::home::task_attempt_dir(&inv.repo, &inv.task_id, inv.attempt)?;
801 std::fs::create_dir_all(&attempt_dir)?;
802
803 let sys_file = attempt_dir.join("prompt-sys.md");
804 let msg_file = attempt_dir.join("prompt-msg.md");
805 std::fs::write(&sys_file, &inv.system_prompt)?;
806 std::fs::write(&msg_file, &inv.agent_message)?;
807
808 build_runner_script_in_dir(inv, &attempt_dir)
809}
810
811fn build_runner_script_in_dir(
815 inv: &AgentInvocation,
816 attempt_dir: &std::path::Path,
817) -> anyhow::Result<String> {
818 let sys_file = attempt_dir.join("prompt-sys.md");
819 let msg_file = attempt_dir.join("prompt-msg.md");
820
821 let mut permissions = super::agents::PermissionRules::from_config();
823 if !inv.disallowed_tools.is_empty() {
824 for tool in &inv.disallowed_tools {
825 if !permissions.disallowed_tools.contains(tool) {
826 permissions.disallowed_tools.push(tool.clone());
827 }
828 }
829 }
830 permissions.allowed_edit_paths.push(inv.work_dir.clone());
831
832 let runner = super::agents::get_runner(&inv.agent);
834 let timeout_cmd = if inv.timeout_seconds > 0 {
835 format!("timeout {}", inv.timeout_seconds)
836 } else {
837 String::new()
838 };
839 let agent_cmd = runner.build_command(
840 inv.model
841 .as_deref()
842 .map(str::trim)
843 .filter(|m| !m.is_empty()),
844 &timeout_cmd,
845 &sys_file.to_string_lossy(),
846 &msg_file.to_string_lossy(),
847 &permissions,
848 );
849
850 let status_file = attempt_dir.join("exit.txt");
851 let stderr_file = attempt_dir.join("stderr.txt");
852
853 let sq_status = shell_single_quote(&status_file.display().to_string());
854 let sq_stderr = shell_single_quote(&stderr_file.display().to_string());
855 let sq_out = shell_single_quote(&inv.output_file.display().to_string());
856 let sq_work_dir = shell_single_quote(&inv.work_dir.to_string_lossy());
857 let script = format!(
858 "#!/usr/bin/env bash\nset -euo pipefail\nstatus_file={sq_status}\nstderr_file={sq_stderr}\ntrap 'status=$?; printf \"%s\\n\" \"$status\" > \"$status_file\"' EXIT\nunset CLAUDECODE\ncd {sq_work_dir} || {{\n printf '%s\\n' \"worktree directory does not exist: {sq_work_dir}\" > \"$stderr_file\"\n exit 1\n}}\nset +e\nRESPONSE=$({agent_cmd} 2> \"$stderr_file\")\nCMD_STATUS=$?\nset -e\nprintf '%s' \"$RESPONSE\" > {sq_out}\nexit $CMD_STATUS\n",
859 agent_cmd = agent_cmd,
860 );
861
862 Ok(script)
863}
864
865pub fn review_system_prompt() -> String {
867 render_prompt_template(REVIEW_SYSTEM_TEMPLATE, HashMap::new())
868}
869
870#[cfg(test)]
871mod tests {
872 use super::*;
873 use std::path::PathBuf;
874
875 #[test]
876 fn review_system_prompt_checks_stray_files_and_trust_boundaries() {
877 let p = review_system_prompt();
878 assert!(p.contains("**Files**"));
879 assert!(p.contains("**Trust boundaries**"));
880 }
881
882 fn test_invocation(task_id: &str) -> AgentInvocation {
883 AgentInvocation {
884 agent: "claude".to_string(),
885 model: None,
886 work_dir: PathBuf::from("/tmp"),
887 system_prompt: "test system prompt".to_string(),
888 agent_message: "test message".to_string(),
889 task_id: task_id.to_string(),
890 disallowed_tools: vec![],
891 git_author_name: "Test Bot".to_string(),
892 git_author_email: "bot@example.com".to_string(),
893 output_file: PathBuf::from("/tmp/test-output.json"),
894 timeout_seconds: 0,
895 repo: "orch-runner-test/token-check".to_string(),
896 attempt: 1,
897 sandbox: false,
898 main_project_dir: std::path::PathBuf::from("/tmp"),
899 branch: String::new(),
900 }
901 }
902
903 #[test]
904 fn spawn_in_tmux_rejects_invalid_env() {
905 let inv = test_invocation("env-test");
910 assert_eq!(inv.task_id, "env-test");
912 }
913
914 #[test]
915 fn build_runner_script_persists_exit_status_on_failure() {
916 let tmp = tempfile::TempDir::new().expect("temp dir");
918 let inv = test_invocation("runner-status-test");
919 let script =
920 build_runner_script_in_dir(&inv, tmp.path()).expect("runner script should build");
921
922 assert!(script
923 .contains("trap 'status=$?; printf \"%s\\n\" \"$status\" > \"$status_file\"' EXIT"));
924 assert!(script.contains("set +e"));
925 }
926
927 #[test]
928 fn build_runner_script_reports_missing_worktree() {
929 let tmp = tempfile::TempDir::new().expect("temp dir");
931 let inv = test_invocation("runner-missing-worktree-test");
932 let script =
933 build_runner_script_in_dir(&inv, tmp.path()).expect("runner script should build");
934
935 assert!(script.contains("worktree directory does not exist: '/tmp'"));
936 assert!(script.contains("stderr_file='"));
937 }
938
939 #[test]
940 fn tmux_runner_script_runs_agent_under_pty_not_a_plain_pipe() {
941 let tmp = tempfile::TempDir::new().expect("temp dir");
947 let inv = test_invocation("pty-wrap-test");
948 let (runner_script, agent_cmd_script) =
949 build_tmux_runner_scripts(&inv, "echo hello", tmp.path(), None)
950 .expect("runner scripts should build");
951
952 assert!(
953 runner_script.contains("script -q /dev/null /bin/bash"),
954 "agent must be exec'd under a pty via `script`, got: {runner_script}"
955 );
956 assert!(
957 runner_script.contains("| tee"),
958 "output must still be tee'd to the output file, got: {runner_script}"
959 );
960 assert!(
961 !runner_script.contains("echo hello 2>"),
962 "the raw agent command must not be piped directly into tee anymore, got: {runner_script}"
963 );
964 assert!(
965 agent_cmd_script.contains("echo hello 2>"),
966 "the agent command should run (with stderr split to a file) inside the pty-wrapped helper script, got: {agent_cmd_script}"
967 );
968 }
969
970 #[test]
971 fn tmux_runner_script_captures_agent_exit_status_via_pipestatus() {
972 let tmp = tempfile::TempDir::new().expect("temp dir");
973 let inv = test_invocation("pty-exit-status-test");
974 let (runner_script, _) = build_tmux_runner_scripts(&inv, "echo hello", tmp.path(), None)
975 .expect("runner scripts should build");
976
977 assert!(runner_script.contains("CMD_STATUS=${PIPESTATUS[0]:-0}"));
978 assert!(runner_script.contains("exit $CMD_STATUS"));
979 }
980
981 #[test]
982 fn tmux_runner_script_disables_commit_signing() {
983 let tmp = tempfile::TempDir::new().expect("temp dir");
985 let inv = test_invocation("gpgsign-test");
986 let (runner_script, _) = build_tmux_runner_scripts(&inv, "echo hello", tmp.path(), None)
987 .expect("runner scripts should build");
988
989 assert!(runner_script.contains("GIT_CONFIG_KEY_0=\"commit.gpgsign\""));
990 assert!(runner_script.contains("GIT_CONFIG_VALUE_0=\"false\""));
991 assert!(runner_script.contains("GIT_CONFIG_KEY_1=\"tag.gpgsign\""));
992 assert!(
994 runner_script.contains("GIT_CONFIG_KEY_2=\"url.https://github.com/.pushInsteadOf\"")
995 );
996 assert!(runner_script.contains("GIT_CONFIG_VALUE_2=\"git@github.com:\""));
997 assert!(runner_script.contains("GIT_CONFIG_VALUE_3=\"!gh auth git-credential\""));
998 }
999
1000 #[test]
1001 fn tmux_runner_script_writes_executable_agent_cmd_helper() {
1002 let tmp = tempfile::TempDir::new().expect("temp dir");
1003 let inv = test_invocation("pty-helper-path-test");
1004 let (runner_script, agent_cmd_script) =
1005 build_tmux_runner_scripts(&inv, "echo hello", tmp.path(), None)
1006 .expect("runner scripts should build");
1007
1008 let agent_cmd_script_path = tmp.path().join("agent_cmd.sh");
1009 assert!(
1010 runner_script.contains(&agent_cmd_script_path.display().to_string()),
1011 "runner script must reference the agent_cmd.sh helper path, got: {runner_script}"
1012 );
1013 assert!(agent_cmd_script.starts_with("#!/usr/bin/env bash\n"));
1014 }
1015
1016 #[test]
1017 fn tmux_runner_script_runs_the_agent_inside_the_sandbox() {
1018 let tmp = tempfile::TempDir::new().expect("temp dir");
1019 let inv = test_invocation("sandbox-test");
1020 let argv: Vec<std::ffi::OsString> = vec![
1021 "/usr/bin/sandbox-exec".into(),
1022 "-p".into(),
1023 "(version 1)".into(),
1024 ];
1025 let (_, agent_cmd_script) =
1026 build_tmux_runner_scripts(&inv, "claude -p 'hi'", tmp.path(), Some(&argv))
1027 .expect("runner scripts should build");
1028 assert!(
1029 agent_cmd_script.contains(
1030 "env -u TMUX -u TMUX_PANE -u SSH_AUTH_SOCK TMPDIR='"
1031 ) && agent_cmd_script.contains(
1032 "' '/usr/bin/sandbox-exec' '-p' '(version 1)' /bin/bash -c 'claude -p '\\''hi'\\''' 2>"
1033 ),
1034 "the agent must run inside the sandbox with stderr split outside it, got: {agent_cmd_script}"
1035 );
1036 assert!(
1037 agent_cmd_script.contains(&format!(
1038 "TMPDIR='{}'",
1039 crate::engine::runner::sandbox::run_temp_dir(tmp.path()).display()
1040 )),
1041 "the agent must get its own TMPDIR, got: {agent_cmd_script}"
1042 );
1043 }
1044
1045 #[test]
1046 fn a_sandboxed_prompt_offers_no_shipcheck() {
1047 assert_eq!(Shipcheck::Available.for_prompt(true), Shipcheck::Absent);
1048 assert_eq!(Shipcheck::Available.for_prompt(false), Shipcheck::Available);
1049 assert_eq!(
1050 Shipcheck::NotInstalled.for_prompt(true),
1051 Shipcheck::NotInstalled
1052 );
1053 }
1054
1055 #[test]
1056 fn build_runner_script_escapes_shell_injection_in_paths() {
1057 let tmp = tempfile::TempDir::new().expect("temp dir");
1058 let mut inv = test_invocation("injection-test");
1059 inv.work_dir = std::path::PathBuf::from("/tmp/$(whoami)/workspace");
1060 inv.output_file = std::path::PathBuf::from("/tmp/evil`id`.json");
1061 let script =
1062 build_runner_script_in_dir(&inv, tmp.path()).expect("runner script should build");
1063
1064 assert!(
1066 script.contains("'/tmp/$(whoami)/workspace'"),
1067 "work_dir with $() must be single-quoted: {script}"
1068 );
1069 assert!(
1070 script.contains("'/tmp/evil`id`.json'"),
1071 "output_file with backticks must be single-quoted: {script}"
1072 );
1073 }
1074
1075 #[test]
1076 fn review_prompt_forbids_ending_turn_on_pending_ci_status() {
1077 let task = crate::backends::ExternalTask {
1082 id: crate::backends::ExternalId("155460".to_string()),
1083 title: "test task".to_string(),
1084 body: "test body".to_string(),
1085 state: "open".to_string(),
1086 labels: vec![],
1087 author: "tester".to_string(),
1088 created_at: "2026-01-01T00:00:00Z".to_string(),
1089 updated_at: "2026-01-01T00:00:00Z".to_string(),
1090 url: "https://example.com/1".to_string(),
1091 };
1092 let prompt = build_review_prompt(&task, "", "", "", "main", 1, Shipcheck::Absent);
1093
1094 assert!(
1095 prompt.contains("Pending CI is never a terminal review state"),
1096 "prompt must state pending CI is not terminal: {prompt}"
1097 );
1098 assert!(
1099 prompt.contains("Never end your turn with a CI-waiting status update"),
1100 "prompt must forbid ending the turn on a CI-waiting status update: {prompt}"
1101 );
1102 }
1103
1104 #[test]
1105 fn shell_single_quote_escapes_embedded_quotes() {
1106 use crate::engine::runner::agents::shell_single_quote;
1107
1108 assert_eq!(shell_single_quote("hello"), "'hello'");
1109 assert_eq!(shell_single_quote("O'Brien"), "'O'\\''Brien'");
1110 assert_eq!(shell_single_quote("$(rm -rf /)"), "'$(rm -rf /)'");
1111 assert_eq!(shell_single_quote("`whoami`"), "'`whoami`'");
1112 assert_eq!(shell_single_quote("a\"b"), "'a\"b'");
1113 assert_eq!(shell_single_quote("$HOME"), "'$HOME'");
1114 }
1115
1116 fn git_init(dir: &std::path::Path) {
1117 let out = std::process::Command::new("git")
1118 .args(["init", "-q"])
1119 .current_dir(dir)
1120 .output()
1121 .unwrap();
1122 assert!(out.status.success());
1123 }
1124
1125 #[tokio::test]
1126 async fn install_shipcheck_rule_writes_rule_and_keeps_tree_clean() {
1127 let dir = tempfile::tempdir().unwrap();
1128 git_init(dir.path());
1129
1130 install_shipcheck_rule(dir.path()).await.unwrap();
1131 install_shipcheck_rule(dir.path()).await.unwrap();
1132
1133 let rule = std::fs::read_to_string(dir.path().join(SHIPCHECK_RULE_PATH)).unwrap();
1134 assert_eq!(rule, SHIPCHECK_RULES);
1135 for cmd in ["run", "commit", "status", "logs", "doctor"] {
1136 assert!(
1137 rule.contains(&format!(r#"["shipcheck", "{cmd}"]"#)),
1138 "{cmd}"
1139 );
1140 }
1141 assert!(!rule.contains(r#"["shipcheck"]"#));
1142 for cmd in [
1143 "clean", "post", "login", "logout", "daemon", "init", "install",
1144 ] {
1145 assert!(!rule.contains(&format!(r#""{cmd}""#)), "{cmd}");
1146 }
1147
1148 let exclude = std::fs::read_to_string(dir.path().join(".git/info/exclude")).unwrap();
1149 assert_eq!(exclude.matches("orch-shipcheck.rules").count(), 1);
1150 let status = std::process::Command::new("git")
1151 .args(["status", "--porcelain", "--untracked-files=all"])
1152 .current_dir(dir.path())
1153 .output()
1154 .unwrap();
1155 assert!(status.stdout.is_empty(), "{:?}", status);
1156 }
1157
1158 #[tokio::test]
1159 async fn install_shipcheck_rule_refuses_symlinked_dirs() {
1160 for link in [".codex", ".codex/rules"] {
1161 let dir = tempfile::tempdir().unwrap();
1162 let target = tempfile::tempdir().unwrap();
1163 git_init(dir.path());
1164 std::fs::create_dir_all(dir.path().join(".codex")).unwrap();
1165 if link == ".codex" {
1166 std::fs::remove_dir(dir.path().join(".codex")).unwrap();
1167 }
1168 std::os::unix::fs::symlink(target.path(), dir.path().join(link)).unwrap();
1169
1170 assert!(install_shipcheck_rule(dir.path()).await.is_err(), "{link}");
1171 assert_eq!(
1172 std::fs::read_dir(target.path()).unwrap().count(),
1173 0,
1174 "{link}"
1175 );
1176 }
1177 }
1178
1179 #[tokio::test]
1180 async fn install_shipcheck_rule_replaces_a_symlinked_file_without_following_it() {
1181 let dir = tempfile::tempdir().unwrap();
1182 let target = tempfile::tempdir().unwrap();
1183 let outside = target.path().join("victim");
1184 std::fs::write(&outside, "keep").unwrap();
1185 git_init(dir.path());
1186 std::fs::create_dir_all(dir.path().join(".codex/rules")).unwrap();
1187 std::os::unix::fs::symlink(&outside, dir.path().join(SHIPCHECK_RULE_PATH)).unwrap();
1188
1189 install_shipcheck_rule(dir.path()).await.unwrap();
1190
1191 assert_eq!(std::fs::read_to_string(&outside).unwrap(), "keep");
1192 let meta = std::fs::symlink_metadata(dir.path().join(SHIPCHECK_RULE_PATH)).unwrap();
1193 assert!(meta.is_file());
1194 }
1195
1196 fn prompt_task() -> crate::backends::ExternalTask {
1197 crate::backends::ExternalTask {
1198 id: crate::backends::ExternalId("1".to_string()),
1199 title: "t".to_string(),
1200 body: "b".to_string(),
1201 state: "open".to_string(),
1202 labels: vec![],
1203 author: "a".to_string(),
1204 created_at: "2026-01-01T00:00:00Z".to_string(),
1205 updated_at: "2026-01-01T00:00:00Z".to_string(),
1206 url: "https://example.com/1".to_string(),
1207 }
1208 }
1209
1210 #[test]
1211 fn system_prompt_uses_shipcheck_only_when_available() {
1212 let ctx = super::super::context::TaskContext::default();
1213 let task = prompt_task();
1214 let prompt = |s| build_system_prompt(&task, &ctx, None, "main", s);
1215
1216 let available = prompt(Shipcheck::Available);
1217 assert!(available.contains("shipcheck run"));
1218 assert!(available.contains("shipcheck commit"));
1219 assert!(!available.contains(".github/workflows/"));
1220 assert!(!available.contains("not installed"));
1221
1222 let outdated = prompt(Shipcheck::Outdated);
1223 assert!(!outdated.contains("shipcheck"));
1224 assert!(outdated.contains(".github/workflows/"));
1225
1226 let missing = prompt(Shipcheck::NotInstalled);
1227 assert!(!missing.contains("shipcheck run"));
1228 assert!(missing.contains(".github/workflows/"));
1229 assert!(missing.contains("shipcheck is not installed"));
1230
1231 let absent = prompt(Shipcheck::Absent);
1232 assert!(!absent.contains("shipcheck"));
1233 assert!(absent.contains(".github/workflows/"));
1234
1235 for p in [&available, &outdated, &missing, &absent] {
1236 assert!(!p.contains("{{"), "{p}");
1237 assert!(!p.contains("install shipcheck"), "{p}");
1238 }
1239 }
1240
1241 #[test]
1242 fn review_prompt_uses_shipcheck_only_when_available() {
1243 let task = prompt_task();
1244 let prompt = |s| build_review_prompt(&task, "", "", "", "main", 1, s);
1245 assert!(prompt(Shipcheck::Available).contains("shipcheck run"));
1246 for s in [
1247 Shipcheck::Outdated,
1248 Shipcheck::NotInstalled,
1249 Shipcheck::Absent,
1250 ] {
1251 let p = prompt(s);
1252 assert!(!p.contains("shipcheck"), "{p}");
1253 assert!(p.contains(".github/workflows/"), "{p}");
1254 }
1255 }
1256
1257 #[test]
1258 fn shipcheck_classify_needs_config_and_commit() {
1259 assert_eq!(Shipcheck::classify(false, Some(true)), Shipcheck::Absent);
1260 assert_eq!(Shipcheck::classify(false, None), Shipcheck::Absent);
1261 assert_eq!(Shipcheck::classify(true, None), Shipcheck::NotInstalled);
1262 assert_eq!(Shipcheck::classify(true, Some(false)), Shipcheck::Outdated);
1263 assert_eq!(Shipcheck::classify(true, Some(true)), Shipcheck::Available);
1264 }
1265
1266 #[tokio::test]
1267 async fn supports_reads_the_usage_line() {
1268 let new_dir = tempfile::tempdir().unwrap();
1269 let new = fake_shipcheck(new_dir.path(), true, true);
1270 assert!(supports(&new, "commit").await);
1271 assert!(supports(&new, "hook").await);
1272 let old_dir = tempfile::tempdir().unwrap();
1273 let old = fake_shipcheck(old_dir.path(), false, false);
1274 assert!(!supports(&old, "commit").await);
1275 assert!(!supports(&old, "hook").await);
1276 let missing = old_dir.path().join("missing");
1277 assert!(!supports(&missing, "commit").await);
1278 }
1279
1280 fn fake_shipcheck(dir: &std::path::Path, commit: bool, hook: bool) -> PathBuf {
1282 use std::os::unix::fs::PermissionsExt;
1283 let mut usage = "usage: shipcheck run [--sha <rev>] | status | logs".to_string();
1284 if commit {
1285 usage.push_str(" | commit -m <message>");
1286 }
1287 if hook {
1288 usage.push_str(" | hook");
1289 }
1290 usage.push_str(" | install | doctor");
1291 let bin = dir.join("shipcheck");
1292 std::fs::write(
1293 &bin,
1294 format!(
1295 "#!/bin/sh\nfor a in \"$@\"; do [ \"$a\" = --help ] && {{ echo '{usage}'; exit 0; }}; done\n[ \"$1\" = install ] && touch installed\nexit 0\n"
1296 ),
1297 )
1298 .unwrap();
1299 std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
1300 bin
1301 }
1302
1303 #[tokio::test]
1304 async fn install_shipcheck_hooks_runs_install_when_supported() {
1305 let bin_dir = tempfile::tempdir().unwrap();
1306 let work = tempfile::tempdir().unwrap();
1307 let bin = fake_shipcheck(bin_dir.path(), true, true);
1308 assert!(install_shipcheck_hooks(work.path(), Shipcheck::Available, &bin).await);
1309 assert!(work.path().join("installed").exists());
1310 }
1311
1312 #[tokio::test]
1313 async fn install_shipcheck_hooks_skips_a_shipcheck_without_hook() {
1314 let bin_dir = tempfile::tempdir().unwrap();
1315 let work = tempfile::tempdir().unwrap();
1316 let bin = fake_shipcheck(bin_dir.path(), true, false);
1317 assert!(!install_shipcheck_hooks(work.path(), Shipcheck::Available, &bin).await);
1318 assert!(!work.path().join("installed").exists());
1319 }
1320
1321 #[tokio::test]
1322 async fn install_shipcheck_hooks_skips_when_not_available() {
1323 let bin_dir = tempfile::tempdir().unwrap();
1324 let work = tempfile::tempdir().unwrap();
1325 let bin = fake_shipcheck(bin_dir.path(), true, true);
1326 for s in [
1327 Shipcheck::Outdated,
1328 Shipcheck::NotInstalled,
1329 Shipcheck::Absent,
1330 ] {
1331 assert!(!install_shipcheck_hooks(work.path(), s, &bin).await);
1332 }
1333 assert!(!work.path().join("installed").exists());
1334 }
1335
1336 #[test]
1337 fn the_progress_step_appears_only_in_cluster_mode() {
1338 let mut vars = HashMap::new();
1339 insert_cluster_vars(Shipcheck::Available, &mut vars);
1340 assert!(vars.is_empty(), "no cluster section, no cluster flags");
1341 let plain = crate::template::render_template_str(AGENT_SYSTEM_TEMPLATE, &vars).unwrap();
1342 assert!(!plain.contains("orch progress"));
1343
1344 for flag in ["CLUSTER", "CLUSTER_SHIPCHECK"] {
1345 vars.insert(flag.to_string(), "1".to_string());
1346 }
1347 let cluster = crate::template::render_template_str(AGENT_SYSTEM_TEMPLATE, &vars).unwrap();
1348 assert!(cluster.contains("orch progress"));
1349 assert!(cluster.contains("commits only through `shipcheck commit`"));
1350 assert!(!cluster.contains("It commits anything you left"));
1351 }
1352}