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