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 fn insert_vars(self, vars: &mut HashMap<String, String>) {
53 let flags: &[&str] = match self {
54 Shipcheck::Available => &["SHIPCHECK"],
55 Shipcheck::NotInstalled => &["NO_SHIPCHECK", "SHIPCHECK_MISSING"],
56 Shipcheck::Absent | Shipcheck::Outdated => &["NO_SHIPCHECK"],
57 };
58 for flag in flags {
59 vars.insert(flag.to_string(), "1".to_string());
60 }
61 }
62}
63
64fn insert_cluster_vars(shipcheck: Shipcheck, vars: &mut HashMap<String, String>) {
67 if !crate::cluster::enabled() {
68 return;
69 }
70
71 let commit = if shipcheck == Shipcheck::Available {
72 "CLUSTER_SHIPCHECK"
73 } else {
74 "CLUSTER_GIT"
75 };
76 for flag in ["CLUSTER", commit] {
77 vars.insert(flag.to_string(), "1".to_string());
78 }
79}
80
81async fn run_shipcheck(
82 bin: &std::path::Path,
83 work_dir: &std::path::Path,
84 args: &[&str],
85) -> anyhow::Result<std::process::Output> {
86 let output = tokio::process::Command::new(bin)
87 .args(args)
88 .current_dir(work_dir)
89 .stdin(std::process::Stdio::null())
90 .kill_on_drop(true)
91 .output();
92 Ok(tokio::time::timeout(std::time::Duration::from_secs(30), output).await??)
93}
94
95async fn supports(bin: &std::path::Path, subcommand: &str) -> bool {
97 let Ok(out) = run_shipcheck(bin, &std::env::temp_dir(), &["--help"]).await else {
98 return false;
99 };
100 out.status.success()
101 && String::from_utf8_lossy(&out.stdout)
102 .split_whitespace()
103 .any(|word| word == subcommand)
104}
105
106pub async fn install_shipcheck_hooks(
109 work_dir: &std::path::Path,
110 shipcheck: Shipcheck,
111 bin: &std::path::Path,
112) -> bool {
113 if shipcheck != Shipcheck::Available {
114 return false;
115 }
116
117 if !supports(bin, "hook").await {
119 return false;
120 }
121
122 match run_shipcheck(bin, work_dir, &["install"]).await {
123 Ok(out) if out.status.success() => {}
124 Ok(out) => tracing::warn!(
125 work_dir = %work_dir.display(),
126 stderr = %String::from_utf8_lossy(&out.stderr),
127 "shipcheck install failed"
128 ),
129 Err(e) => tracing::warn!(
130 work_dir = %work_dir.display(),
131 error = %e,
132 "shipcheck install failed"
133 ),
134 }
135 true
136}
137
138const SHIPCHECK_RULE_PATH: &str = ".codex/rules/orch-shipcheck.rules";
139
140const SHIPCHECK_RULES: &str = r#"prefix_rule(pattern=["shipcheck", "run"], decision="allow")
142prefix_rule(pattern=["shipcheck", "commit"], decision="allow")
143prefix_rule(pattern=["shipcheck", "status"], decision="allow")
144prefix_rule(pattern=["shipcheck", "logs"], decision="allow")
145prefix_rule(pattern=["shipcheck", "doctor"], decision="allow")
146"#;
147
148async fn install_shipcheck_rule(work_dir: &std::path::Path) -> anyhow::Result<()> {
151 let mut dir = work_dir.to_path_buf();
153 for part in [".codex", "rules"] {
154 dir.push(part);
155 match tokio::fs::symlink_metadata(&dir).await {
156 Ok(meta) if meta.is_dir() => {}
157 Ok(_) => anyhow::bail!("{} is not a directory", dir.display()),
158 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
159 tokio::fs::create_dir(&dir).await?;
160 }
161 Err(e) => return Err(e.into()),
162 }
163 }
164
165 let rule = work_dir.join(SHIPCHECK_RULE_PATH);
167 match tokio::fs::remove_file(&rule).await {
168 Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e.into()),
169 _ => {}
170 }
171
172 let mut file = tokio::fs::OpenOptions::new()
173 .write(true)
174 .create_new(true)
175 .open(&rule)
176 .await?;
177 tokio::io::AsyncWriteExt::write_all(&mut file, SHIPCHECK_RULES.as_bytes()).await?;
178
179 let output = tokio::process::Command::new("git")
181 .args(["rev-parse", "--git-path", "info/exclude"])
182 .current_dir(work_dir)
183 .output()
184 .await?;
185 if !output.status.success() {
186 return Ok(());
187 }
188
189 let exclude = work_dir.join(String::from_utf8_lossy(&output.stdout).trim());
190 let line = format!("/{SHIPCHECK_RULE_PATH}");
191 let current = tokio::fs::read_to_string(&exclude)
192 .await
193 .unwrap_or_default();
194 if current.lines().any(|l| l == line) {
195 return Ok(());
196 }
197
198 if let Some(dir) = exclude.parent() {
199 tokio::fs::create_dir_all(dir).await?;
200 }
201
202 let sep = if current.is_empty() || current.ends_with('\n') {
203 ""
204 } else {
205 "\n"
206 };
207 tokio::fs::write(&exclude, format!("{current}{sep}{line}\n")).await?;
208 Ok(())
209}
210
211async fn resolve_git_dir_arg(
214 work_dir: &std::path::Path,
215 arg: &str,
216) -> anyhow::Result<Option<PathBuf>> {
217 let output = tokio::process::Command::new("git")
218 .args(["rev-parse", arg])
219 .current_dir(work_dir)
220 .output()
221 .await?;
222
223 if !output.status.success() {
224 return Ok(None);
225 }
226
227 let raw = String::from_utf8_lossy(&output.stdout).trim().to_string();
228 if raw.is_empty() {
229 return Ok(None);
230 }
231
232 let candidate = if std::path::Path::new(&raw).is_absolute() {
234 PathBuf::from(&raw)
235 } else {
236 work_dir.join(&raw)
237 };
238
239 let canonical = tokio::fs::canonicalize(&candidate)
240 .await
241 .unwrap_or(candidate);
242 Ok(Some(canonical))
243}
244
245fn render_prompt_template(template: &str, vars: HashMap<String, String>) -> String {
246 match render_template_str(template, &vars) {
247 Ok(rendered) => rendered,
248 Err(err) => {
249 tracing::error!(error = %err, "failed to render prompt template");
250 template.to_string()
251 }
252 }
253}
254
255pub struct AgentInvocation {
257 pub agent: String,
259 pub model: Option<String>,
261 pub work_dir: PathBuf,
263 pub system_prompt: String,
265 pub agent_message: String,
267 pub task_id: String,
269 pub disallowed_tools: Vec<String>,
271 pub git_author_name: String,
273 pub git_author_email: String,
275 pub output_file: PathBuf,
277 pub timeout_seconds: u64,
279 pub repo: String,
281 pub attempt: u32,
283}
284
285fn build_tmux_runner_scripts(
289 inv: &AgentInvocation,
290 agent_cmd: &str,
291 attempt_dir: &std::path::Path,
292) -> anyhow::Result<(String, String)> {
293 let status_file = attempt_dir.join("exit.txt");
294 let stderr_file = attempt_dir.join("stderr.txt");
295 let agent_cmd_script_path = attempt_dir.join("agent_cmd.sh");
296 let sq_git_name = shell_single_quote(&inv.git_author_name);
297 let sq_git_email = shell_single_quote(&inv.git_author_email);
298 let sq_task_id = shell_single_quote(&inv.task_id);
299 let sq_output_file = shell_single_quote(&inv.output_file.display().to_string());
300 let sq_work_dir = shell_single_quote(&inv.work_dir.display().to_string());
301 let sq_status_file = shell_single_quote(&status_file.display().to_string());
302 let sq_stderr_file = shell_single_quote(&stderr_file.display().to_string());
303 let sq_agent_cmd_script = shell_single_quote(&agent_cmd_script_path.display().to_string());
304
305 let agent_cmd_script = format!("#!/usr/bin/env bash\n{agent_cmd} 2>{sq_stderr_file}\n");
308
309 let runner_script = format!(
310 r#"#!/usr/bin/env bash
311set -euo pipefail
312
313status_file={sq_status_file}
314stderr_file={sq_stderr_file}
315trap 'status=$?; printf "%s\n" "$status" > "$status_file"' EXIT
316
317# Environment — ~/.path and ~/.private are loaded by orch at startup into the process env
318export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"
319export GIT_AUTHOR_NAME={sq_git_name}
320export GIT_COMMITTER_NAME={sq_git_name}
321export GIT_AUTHOR_EMAIL={sq_git_email}
322export GIT_COMMITTER_EMAIL={sq_git_email}
323# Env config overrides the operator's global commit.gpgsign, the signing agent
324# (1Password etc) is often locked in unattended sessions and blocks `git commit`
325export GIT_CONFIG_COUNT="1"
326export GIT_CONFIG_KEY_0="commit.gpgsign"
327export GIT_CONFIG_VALUE_0="false"
328export TASK_ID={sq_task_id}
329export OUTPUT_FILE={sq_output_file}
330unset CLAUDECODE # allow nested claude invocations from orch
331
332cd {sq_work_dir} || {{
333 printf '%s\n' "worktree directory does not exist: {sq_work_dir}" > "$stderr_file"
334 exit 1
335}}
336
337# Run agent under a pty (via `script`) so its stdout is a tty, not a plain pipe —
338# many CLI runtimes silently switch to full block-buffering when stdout isn't a
339# tty, which can delay pane output long enough to trip silence detection even
340# while the agent is actively streaming. Tee to both output file and terminal
341# (tmux pane) for live streaming.
342set +e
343script -q /dev/null /bin/bash {sq_agent_cmd_script} | tee {sq_output_file}
344CMD_STATUS=${{PIPESTATUS[0]:-0}}
345set -e
346
347exit $CMD_STATUS
348"#,
349 );
350
351 Ok((runner_script, agent_cmd_script))
352}
353
354pub async fn spawn_in_tmux(tmux: &TmuxManager, inv: &AgentInvocation) -> anyhow::Result<String> {
358 let attempt_dir =
360 crate::home::task_attempt_dir_async(&inv.repo, &inv.task_id, inv.attempt).await?;
361
362 let sys_file = attempt_dir.join("prompt-sys.md");
363 let msg_file = attempt_dir.join("prompt-msg.md");
364 let mut permissions = super::agents::PermissionRules::from_config();
366 if !inv.disallowed_tools.is_empty() {
367 for tool in &inv.disallowed_tools {
368 if !permissions.disallowed_tools.contains(tool) {
369 permissions.disallowed_tools.push(tool.clone());
370 }
371 }
372 }
373 permissions.allowed_edit_paths.push(inv.work_dir.clone());
374
375 if inv.agent == "codex" {
380 let mut extra_dirs = BTreeSet::new();
381 for (arg, label) in [
382 ("--git-common-dir", "git common dir"),
383 ("--git-dir", "git dir"),
384 ] {
385 match resolve_git_dir_arg(&inv.work_dir, arg).await {
386 Ok(Some(dir)) if !dir.starts_with(&inv.work_dir) => {
387 extra_dirs.insert(dir);
388 }
389 Ok(_) => {}
390 Err(e) => {
391 tracing::debug!(
392 work_dir = %inv.work_dir.display(),
393 arg,
394 error = %e,
395 "failed to resolve {}; skipping --add-dir",
396 label
397 );
398 }
399 }
400 }
401 permissions.extra_writable_dirs.extend(extra_dirs);
402
403 if Shipcheck::detect(&inv.work_dir).await == Shipcheck::Available {
404 if let Err(e) = install_shipcheck_rule(&inv.work_dir).await {
405 tracing::warn!(
406 work_dir = %inv.work_dir.display(),
407 error = %e,
408 "failed to install the shipcheck codex rule"
409 );
410 }
411 }
412 }
413
414 let sys_content = if !permissions.allowed_tools.is_empty() {
415 let tools_list = permissions
416 .allowed_tools
417 .iter()
418 .map(|t| format!("- {t}"))
419 .collect::<Vec<_>>()
420 .join("\n");
421 let mut vars = HashMap::new();
422 vars.insert("TOOLS_LIST".to_string(), tools_list);
423 let tools_prompt = render_prompt_template(ALLOWED_TOOLS_TEMPLATE, vars);
424 format!("{}\n\n{}", inv.system_prompt, tools_prompt)
425 } else {
426 inv.system_prompt.clone()
427 };
428
429 tokio::fs::write(&sys_file, &sys_content).await?;
430 tokio::fs::write(&msg_file, &inv.agent_message).await?;
431
432 let runner = super::agents::get_runner(&inv.agent);
434
435 let mut env = std::collections::HashMap::new();
437 env.insert("GIT_AUTHOR_NAME".to_string(), inv.git_author_name.clone());
438 env.insert(
439 "GIT_COMMITTER_NAME".to_string(),
440 inv.git_author_name.clone(),
441 );
442 env.insert("GIT_AUTHOR_EMAIL".to_string(), inv.git_author_email.clone());
443 env.insert(
444 "GIT_COMMITTER_EMAIL".to_string(),
445 inv.git_author_email.clone(),
446 );
447 env.insert("TASK_ID".to_string(), inv.task_id.clone());
448 env.insert(
449 "OUTPUT_FILE".to_string(),
450 inv.output_file.display().to_string(),
451 );
452
453 let timeout_cmd = if inv.timeout_seconds > 0 {
455 format!("timeout {}", inv.timeout_seconds)
456 } else {
457 String::new()
458 };
459 let agent_cmd = runner.build_command(
460 inv.model.as_deref(),
461 &timeout_cmd,
462 &sys_file.to_string_lossy(),
463 &msg_file.to_string_lossy(),
464 &permissions,
465 );
466
467 let script_path = attempt_dir.join("runner.sh");
470 let agent_cmd_script_path = attempt_dir.join("agent_cmd.sh");
471 let (script_content, agent_cmd_script_content) =
472 build_tmux_runner_scripts(inv, &agent_cmd, &attempt_dir)?;
473 tokio::fs::write(&agent_cmd_script_path, &agent_cmd_script_content).await?;
474 tokio::fs::write(&script_path, &script_content).await?;
475
476 #[cfg(unix)]
478 {
479 use std::os::unix::fs::PermissionsExt;
480 tokio::fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o755)).await?;
481 tokio::fs::set_permissions(
482 &agent_cmd_script_path,
483 std::fs::Permissions::from_mode(0o755),
484 )
485 .await?;
486 }
487
488 let command = format!("bash \"{}\"", script_path.display());
489
490 let github_token = match crate::github::token::shared().get_token().await {
492 Ok(Some(token)) => {
493 tracing::debug!("Resolved GitHub token via TokenResolver for agent session");
494 Some(token)
495 }
496 Ok(None) => {
497 tracing::warn!(
498 "No GitHub token available; set GH_TOKEN, GITHUB_TOKEN, or gh.auth.token, or run gh auth login"
499 );
500 None
501 }
502 Err(e) => {
503 tracing::warn!(error = %e, "Failed to resolve GitHub token");
504 None
505 }
506 };
507
508 if let Some(ref token) = github_token {
521 env.insert("GH_TOKEN".to_string(), token.clone());
522 }
523
524 let env_vec: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
525
526 let session = tmux
527 .create_session(
528 &inv.repo,
529 &inv.task_id,
530 &inv.work_dir.to_string_lossy(),
531 &command,
532 env_vec.as_slice(),
533 )
534 .await?;
535
536 tracing::info!(
537 task_id = inv.task_id,
538 agent = inv.agent,
539 session = %session,
540 "agent spawned in tmux"
541 );
542
543 Ok(session)
544}
545
546pub fn build_system_prompt(
548 _task: &crate::backends::ExternalTask,
549 context: &super::context::TaskContext,
550 route_result: Option<&crate::engine::router::RouteResult>,
551 default_branch: &str,
552 shipcheck: Shipcheck,
553) -> String {
554 let mut vars = HashMap::new();
555
556 vars.insert("DEFAULT_BRANCH".to_string(), default_branch.to_string());
557 shipcheck.insert_vars(&mut vars);
558 insert_cluster_vars(shipcheck, &mut vars);
559
560 if let Some(rr) = route_result {
561 vars.insert("ROLE".to_string(), rr.profile.role.clone());
562
563 if !rr.profile.constraints.is_empty() {
564 let constraints = rr
565 .profile
566 .constraints
567 .iter()
568 .map(|c| format!("- {c}"))
569 .collect::<Vec<_>>()
570 .join("\n");
571 vars.insert("CONSTRAINTS".to_string(), constraints);
572 }
573 }
574
575 if !context.project_instructions.is_empty() {
576 vars.insert(
577 "PROJECT_INSTRUCTIONS".to_string(),
578 context.project_instructions.clone(),
579 );
580 }
581
582 if !context.skills_docs.is_empty() {
583 vars.insert("SKILLS_DOCS".to_string(), context.skills_docs.clone());
584 }
585
586 if !context.repo_tree.is_empty() {
587 vars.insert("REPO_TREE".to_string(), context.repo_tree.clone());
588 }
589
590 render_prompt_template(AGENT_SYSTEM_TEMPLATE, vars)
591}
592
593pub fn build_agent_message(
595 task: &crate::backends::ExternalTask,
596 context: &super::context::TaskContext,
597 attempts: u32,
598) -> String {
599 let mut vars = HashMap::new();
600 vars.insert("TASK_ID".to_string(), task.id.0.clone());
601 vars.insert("TASK_TITLE".to_string(), task.title.clone());
602 vars.insert("TASK_BODY".to_string(), task.body.clone());
603
604 if !context.task_context.is_empty() {
605 vars.insert("TASK_CONTEXT".to_string(), context.task_context.clone());
606 }
607
608 if !context.parent_context.is_empty() {
609 vars.insert("PARENT_CONTEXT".to_string(), context.parent_context.clone());
610 }
611
612 if !context.issue_comments.is_empty() {
613 vars.insert("ISSUE_COMMENTS".to_string(), context.issue_comments.clone());
614 }
615
616 if !context.pr_review_context.is_empty() {
617 vars.insert(
618 "PR_REVIEW_CONTEXT".to_string(),
619 context.pr_review_context.clone(),
620 );
621 }
622
623 if attempts > 0 && !context.git_diff.is_empty() {
624 vars.insert("GIT_DIFF".to_string(), context.git_diff.clone());
625 }
626
627 if attempts > 0 {
628 vars.insert("ATTEMPT_NUMBER".to_string(), (attempts + 1).to_string());
629 }
630
631 if !context.memory.is_empty() {
632 let mut sections = Vec::new();
633 for entry in &context.memory {
634 let mut entry_vars = HashMap::new();
635 entry_vars.insert("ATTEMPT".to_string(), entry.attempt.to_string());
636 entry_vars.insert("AGENT".to_string(), entry.agent.clone());
637
638 if let Some(ref model) = entry.model {
639 entry_vars.insert("MODEL".to_string(), model.clone());
640 }
641
642 if !entry.approach.is_empty() {
643 entry_vars.insert("APPROACH".to_string(), entry.approach.clone());
644 }
645
646 if !entry.learnings.is_empty() {
647 let learnings = entry
648 .learnings
649 .iter()
650 .map(|learning| format!("- {}", learning))
651 .collect::<Vec<_>>()
652 .join("\n");
653 entry_vars.insert("LEARNINGS".to_string(), learnings);
654 }
655
656 if let Some(ref error) = entry.error {
657 entry_vars.insert("ERROR".to_string(), error.clone());
658 }
659
660 if !entry.files_modified.is_empty() {
661 entry_vars.insert(
662 "FILES_MODIFIED".to_string(),
663 entry.files_modified.join(", "),
664 );
665 }
666
667 let rendered = render_prompt_template(AGENT_MEMORY_ENTRY_TEMPLATE, entry_vars);
668 sections.push(rendered.trim().to_string());
669 }
670
671 let memory_section = sections.join("\n\n");
672 vars.insert("MEMORY_SECTION".to_string(), memory_section);
673 }
674
675 render_prompt_template(AGENT_MESSAGE_TEMPLATE, vars)
676}
677
678pub fn build_review_prompt(
683 task: &crate::backends::ExternalTask,
684 agent_summary: &str,
685 git_diff: &str,
686 git_log: &str,
687 default_branch: &str,
688 pr_number: u64,
689 shipcheck: Shipcheck,
690) -> String {
691 let mut vars = HashMap::new();
692 shipcheck.insert_vars(&mut vars);
693 insert_cluster_vars(shipcheck, &mut vars);
694 vars.insert("TASK_ID".to_string(), task.id.0.clone());
695 vars.insert("TASK_TITLE".to_string(), task.title.clone());
696 vars.insert("TASK_BODY".to_string(), task.body.clone());
697 vars.insert("DEFAULT_BRANCH".to_string(), default_branch.to_string());
698 vars.insert("PR_NUMBER".to_string(), pr_number.to_string());
699
700 if !agent_summary.is_empty() {
701 vars.insert("AGENT_SUMMARY".to_string(), agent_summary.to_string());
702 }
703
704 if !git_diff.is_empty() {
705 vars.insert("GIT_DIFF".to_string(), git_diff.to_string());
706 }
707
708 if !git_log.is_empty() {
709 vars.insert("GIT_LOG".to_string(), git_log.to_string());
710 }
711
712 render_prompt_template(REVIEW_PROMPT_TEMPLATE, vars)
713}
714
715#[allow(dead_code)]
719pub fn build_runner_script(inv: &AgentInvocation) -> anyhow::Result<String> {
720 let attempt_dir = crate::home::task_attempt_dir(&inv.repo, &inv.task_id, inv.attempt)?;
722 std::fs::create_dir_all(&attempt_dir)?;
723
724 let sys_file = attempt_dir.join("prompt-sys.md");
725 let msg_file = attempt_dir.join("prompt-msg.md");
726 std::fs::write(&sys_file, &inv.system_prompt)?;
727 std::fs::write(&msg_file, &inv.agent_message)?;
728
729 build_runner_script_in_dir(inv, &attempt_dir)
730}
731
732fn build_runner_script_in_dir(
736 inv: &AgentInvocation,
737 attempt_dir: &std::path::Path,
738) -> anyhow::Result<String> {
739 let sys_file = attempt_dir.join("prompt-sys.md");
740 let msg_file = attempt_dir.join("prompt-msg.md");
741
742 let mut permissions = super::agents::PermissionRules::from_config();
744 if !inv.disallowed_tools.is_empty() {
745 for tool in &inv.disallowed_tools {
746 if !permissions.disallowed_tools.contains(tool) {
747 permissions.disallowed_tools.push(tool.clone());
748 }
749 }
750 }
751 permissions.allowed_edit_paths.push(inv.work_dir.clone());
752
753 let runner = super::agents::get_runner(&inv.agent);
755 let timeout_cmd = if inv.timeout_seconds > 0 {
756 format!("timeout {}", inv.timeout_seconds)
757 } else {
758 String::new()
759 };
760 let agent_cmd = runner.build_command(
761 inv.model.as_deref(),
762 &timeout_cmd,
763 &sys_file.to_string_lossy(),
764 &msg_file.to_string_lossy(),
765 &permissions,
766 );
767
768 let status_file = attempt_dir.join("exit.txt");
769 let stderr_file = attempt_dir.join("stderr.txt");
770
771 let sq_status = shell_single_quote(&status_file.display().to_string());
772 let sq_stderr = shell_single_quote(&stderr_file.display().to_string());
773 let sq_out = shell_single_quote(&inv.output_file.display().to_string());
774 let sq_work_dir = shell_single_quote(&inv.work_dir.to_string_lossy());
775 let script = format!(
776 "#!/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",
777 agent_cmd = agent_cmd,
778 );
779
780 Ok(script)
781}
782
783pub fn review_system_prompt() -> String {
785 render_prompt_template(REVIEW_SYSTEM_TEMPLATE, HashMap::new())
786}
787
788#[cfg(test)]
789mod tests {
790 use super::*;
791 use std::path::PathBuf;
792
793 #[test]
794 fn review_system_prompt_checks_stray_files_and_trust_boundaries() {
795 let p = review_system_prompt();
796 assert!(p.contains("**Files**"));
797 assert!(p.contains("**Trust boundaries**"));
798 }
799
800 fn test_invocation(task_id: &str) -> AgentInvocation {
801 AgentInvocation {
802 agent: "claude".to_string(),
803 model: None,
804 work_dir: PathBuf::from("/tmp"),
805 system_prompt: "test system prompt".to_string(),
806 agent_message: "test message".to_string(),
807 task_id: task_id.to_string(),
808 disallowed_tools: vec![],
809 git_author_name: "Test Bot".to_string(),
810 git_author_email: "bot@example.com".to_string(),
811 output_file: PathBuf::from("/tmp/test-output.json"),
812 timeout_seconds: 0,
813 repo: "orch-runner-test/token-check".to_string(),
814 attempt: 1,
815 }
816 }
817
818 #[test]
819 fn spawn_in_tmux_rejects_invalid_env() {
820 let inv = test_invocation("env-test");
825 assert_eq!(inv.task_id, "env-test");
827 }
828
829 #[test]
830 fn build_runner_script_persists_exit_status_on_failure() {
831 let tmp = tempfile::TempDir::new().expect("temp dir");
833 let inv = test_invocation("runner-status-test");
834 let script =
835 build_runner_script_in_dir(&inv, tmp.path()).expect("runner script should build");
836
837 assert!(script
838 .contains("trap 'status=$?; printf \"%s\\n\" \"$status\" > \"$status_file\"' EXIT"));
839 assert!(script.contains("set +e"));
840 }
841
842 #[test]
843 fn build_runner_script_reports_missing_worktree() {
844 let tmp = tempfile::TempDir::new().expect("temp dir");
846 let inv = test_invocation("runner-missing-worktree-test");
847 let script =
848 build_runner_script_in_dir(&inv, tmp.path()).expect("runner script should build");
849
850 assert!(script.contains("worktree directory does not exist: '/tmp'"));
851 assert!(script.contains("stderr_file='"));
852 }
853
854 #[test]
855 fn tmux_runner_script_runs_agent_under_pty_not_a_plain_pipe() {
856 let tmp = tempfile::TempDir::new().expect("temp dir");
862 let inv = test_invocation("pty-wrap-test");
863 let (runner_script, agent_cmd_script) =
864 build_tmux_runner_scripts(&inv, "echo hello", tmp.path())
865 .expect("runner scripts should build");
866
867 assert!(
868 runner_script.contains("script -q /dev/null /bin/bash"),
869 "agent must be exec'd under a pty via `script`, got: {runner_script}"
870 );
871 assert!(
872 runner_script.contains("| tee"),
873 "output must still be tee'd to the output file, got: {runner_script}"
874 );
875 assert!(
876 !runner_script.contains("echo hello 2>"),
877 "the raw agent command must not be piped directly into tee anymore, got: {runner_script}"
878 );
879 assert!(
880 agent_cmd_script.contains("echo hello 2>"),
881 "the agent command should run (with stderr split to a file) inside the pty-wrapped helper script, got: {agent_cmd_script}"
882 );
883 }
884
885 #[test]
886 fn tmux_runner_script_captures_agent_exit_status_via_pipestatus() {
887 let tmp = tempfile::TempDir::new().expect("temp dir");
888 let inv = test_invocation("pty-exit-status-test");
889 let (runner_script, _) = build_tmux_runner_scripts(&inv, "echo hello", tmp.path())
890 .expect("runner scripts should build");
891
892 assert!(runner_script.contains("CMD_STATUS=${PIPESTATUS[0]:-0}"));
893 assert!(runner_script.contains("exit $CMD_STATUS"));
894 }
895
896 #[test]
897 fn tmux_runner_script_disables_commit_signing() {
898 let tmp = tempfile::TempDir::new().expect("temp dir");
900 let inv = test_invocation("gpgsign-test");
901 let (runner_script, _) = build_tmux_runner_scripts(&inv, "echo hello", tmp.path())
902 .expect("runner scripts should build");
903
904 assert!(runner_script.contains("GIT_CONFIG_KEY_0=\"commit.gpgsign\""));
905 assert!(runner_script.contains("GIT_CONFIG_VALUE_0=\"false\""));
906 }
907
908 #[test]
909 fn tmux_runner_script_writes_executable_agent_cmd_helper() {
910 let tmp = tempfile::TempDir::new().expect("temp dir");
911 let inv = test_invocation("pty-helper-path-test");
912 let (runner_script, agent_cmd_script) =
913 build_tmux_runner_scripts(&inv, "echo hello", tmp.path())
914 .expect("runner scripts should build");
915
916 let agent_cmd_script_path = tmp.path().join("agent_cmd.sh");
917 assert!(
918 runner_script.contains(&agent_cmd_script_path.display().to_string()),
919 "runner script must reference the agent_cmd.sh helper path, got: {runner_script}"
920 );
921 assert!(agent_cmd_script.starts_with("#!/usr/bin/env bash\n"));
922 }
923
924 #[test]
925 fn build_runner_script_escapes_shell_injection_in_paths() {
926 let tmp = tempfile::TempDir::new().expect("temp dir");
927 let mut inv = test_invocation("injection-test");
928 inv.work_dir = std::path::PathBuf::from("/tmp/$(whoami)/workspace");
929 inv.output_file = std::path::PathBuf::from("/tmp/evil`id`.json");
930 let script =
931 build_runner_script_in_dir(&inv, tmp.path()).expect("runner script should build");
932
933 assert!(
935 script.contains("'/tmp/$(whoami)/workspace'"),
936 "work_dir with $() must be single-quoted: {script}"
937 );
938 assert!(
939 script.contains("'/tmp/evil`id`.json'"),
940 "output_file with backticks must be single-quoted: {script}"
941 );
942 }
943
944 #[test]
945 fn review_prompt_forbids_ending_turn_on_pending_ci_status() {
946 let task = crate::backends::ExternalTask {
951 id: crate::backends::ExternalId("155460".to_string()),
952 title: "test task".to_string(),
953 body: "test body".to_string(),
954 state: "open".to_string(),
955 labels: vec![],
956 author: "tester".to_string(),
957 created_at: "2026-01-01T00:00:00Z".to_string(),
958 updated_at: "2026-01-01T00:00:00Z".to_string(),
959 url: "https://example.com/1".to_string(),
960 };
961 let prompt = build_review_prompt(&task, "", "", "", "main", 1, Shipcheck::Absent);
962
963 assert!(
964 prompt.contains("Pending CI is never a terminal review state"),
965 "prompt must state pending CI is not terminal: {prompt}"
966 );
967 assert!(
968 prompt.contains("Never end your turn with a CI-waiting status update"),
969 "prompt must forbid ending the turn on a CI-waiting status update: {prompt}"
970 );
971 }
972
973 #[test]
974 fn shell_single_quote_escapes_embedded_quotes() {
975 use crate::engine::runner::agents::shell_single_quote;
976
977 assert_eq!(shell_single_quote("hello"), "'hello'");
978 assert_eq!(shell_single_quote("O'Brien"), "'O'\\''Brien'");
979 assert_eq!(shell_single_quote("$(rm -rf /)"), "'$(rm -rf /)'");
980 assert_eq!(shell_single_quote("`whoami`"), "'`whoami`'");
981 assert_eq!(shell_single_quote("a\"b"), "'a\"b'");
982 assert_eq!(shell_single_quote("$HOME"), "'$HOME'");
983 }
984
985 fn git_init(dir: &std::path::Path) {
986 let out = std::process::Command::new("git")
987 .args(["init", "-q"])
988 .current_dir(dir)
989 .output()
990 .unwrap();
991 assert!(out.status.success());
992 }
993
994 #[tokio::test]
995 async fn install_shipcheck_rule_writes_rule_and_keeps_tree_clean() {
996 let dir = tempfile::tempdir().unwrap();
997 git_init(dir.path());
998
999 install_shipcheck_rule(dir.path()).await.unwrap();
1000 install_shipcheck_rule(dir.path()).await.unwrap();
1001
1002 let rule = std::fs::read_to_string(dir.path().join(SHIPCHECK_RULE_PATH)).unwrap();
1003 assert_eq!(rule, SHIPCHECK_RULES);
1004 for cmd in ["run", "commit", "status", "logs", "doctor"] {
1005 assert!(
1006 rule.contains(&format!(r#"["shipcheck", "{cmd}"]"#)),
1007 "{cmd}"
1008 );
1009 }
1010 assert!(!rule.contains(r#"["shipcheck"]"#));
1011 for cmd in [
1012 "clean", "post", "login", "logout", "daemon", "init", "install",
1013 ] {
1014 assert!(!rule.contains(&format!(r#""{cmd}""#)), "{cmd}");
1015 }
1016
1017 let exclude = std::fs::read_to_string(dir.path().join(".git/info/exclude")).unwrap();
1018 assert_eq!(exclude.matches("orch-shipcheck.rules").count(), 1);
1019 let status = std::process::Command::new("git")
1020 .args(["status", "--porcelain", "--untracked-files=all"])
1021 .current_dir(dir.path())
1022 .output()
1023 .unwrap();
1024 assert!(status.stdout.is_empty(), "{:?}", status);
1025 }
1026
1027 #[tokio::test]
1028 async fn install_shipcheck_rule_refuses_symlinked_dirs() {
1029 for link in [".codex", ".codex/rules"] {
1030 let dir = tempfile::tempdir().unwrap();
1031 let target = tempfile::tempdir().unwrap();
1032 git_init(dir.path());
1033 std::fs::create_dir_all(dir.path().join(".codex")).unwrap();
1034 if link == ".codex" {
1035 std::fs::remove_dir(dir.path().join(".codex")).unwrap();
1036 }
1037 std::os::unix::fs::symlink(target.path(), dir.path().join(link)).unwrap();
1038
1039 assert!(install_shipcheck_rule(dir.path()).await.is_err(), "{link}");
1040 assert_eq!(
1041 std::fs::read_dir(target.path()).unwrap().count(),
1042 0,
1043 "{link}"
1044 );
1045 }
1046 }
1047
1048 #[tokio::test]
1049 async fn install_shipcheck_rule_replaces_a_symlinked_file_without_following_it() {
1050 let dir = tempfile::tempdir().unwrap();
1051 let target = tempfile::tempdir().unwrap();
1052 let outside = target.path().join("victim");
1053 std::fs::write(&outside, "keep").unwrap();
1054 git_init(dir.path());
1055 std::fs::create_dir_all(dir.path().join(".codex/rules")).unwrap();
1056 std::os::unix::fs::symlink(&outside, dir.path().join(SHIPCHECK_RULE_PATH)).unwrap();
1057
1058 install_shipcheck_rule(dir.path()).await.unwrap();
1059
1060 assert_eq!(std::fs::read_to_string(&outside).unwrap(), "keep");
1061 let meta = std::fs::symlink_metadata(dir.path().join(SHIPCHECK_RULE_PATH)).unwrap();
1062 assert!(meta.is_file());
1063 }
1064
1065 fn prompt_task() -> crate::backends::ExternalTask {
1066 crate::backends::ExternalTask {
1067 id: crate::backends::ExternalId("1".to_string()),
1068 title: "t".to_string(),
1069 body: "b".to_string(),
1070 state: "open".to_string(),
1071 labels: vec![],
1072 author: "a".to_string(),
1073 created_at: "2026-01-01T00:00:00Z".to_string(),
1074 updated_at: "2026-01-01T00:00:00Z".to_string(),
1075 url: "https://example.com/1".to_string(),
1076 }
1077 }
1078
1079 #[test]
1080 fn system_prompt_uses_shipcheck_only_when_available() {
1081 let ctx = super::super::context::TaskContext::default();
1082 let task = prompt_task();
1083 let prompt = |s| build_system_prompt(&task, &ctx, None, "main", s);
1084
1085 let available = prompt(Shipcheck::Available);
1086 assert!(available.contains("shipcheck run"));
1087 assert!(available.contains("shipcheck commit"));
1088 assert!(!available.contains(".github/workflows/"));
1089 assert!(!available.contains("not installed"));
1090
1091 let outdated = prompt(Shipcheck::Outdated);
1092 assert!(!outdated.contains("shipcheck"));
1093 assert!(outdated.contains(".github/workflows/"));
1094
1095 let missing = prompt(Shipcheck::NotInstalled);
1096 assert!(!missing.contains("shipcheck run"));
1097 assert!(missing.contains(".github/workflows/"));
1098 assert!(missing.contains("shipcheck is not installed"));
1099
1100 let absent = prompt(Shipcheck::Absent);
1101 assert!(!absent.contains("shipcheck"));
1102 assert!(absent.contains(".github/workflows/"));
1103
1104 for p in [&available, &outdated, &missing, &absent] {
1105 assert!(!p.contains("{{"), "{p}");
1106 assert!(!p.contains("install shipcheck"), "{p}");
1107 }
1108 }
1109
1110 #[test]
1111 fn review_prompt_uses_shipcheck_only_when_available() {
1112 let task = prompt_task();
1113 let prompt = |s| build_review_prompt(&task, "", "", "", "main", 1, s);
1114 assert!(prompt(Shipcheck::Available).contains("shipcheck run"));
1115 for s in [
1116 Shipcheck::Outdated,
1117 Shipcheck::NotInstalled,
1118 Shipcheck::Absent,
1119 ] {
1120 let p = prompt(s);
1121 assert!(!p.contains("shipcheck"), "{p}");
1122 assert!(p.contains(".github/workflows/"), "{p}");
1123 }
1124 }
1125
1126 #[test]
1127 fn shipcheck_classify_needs_config_and_commit() {
1128 assert_eq!(Shipcheck::classify(false, Some(true)), Shipcheck::Absent);
1129 assert_eq!(Shipcheck::classify(false, None), Shipcheck::Absent);
1130 assert_eq!(Shipcheck::classify(true, None), Shipcheck::NotInstalled);
1131 assert_eq!(Shipcheck::classify(true, Some(false)), Shipcheck::Outdated);
1132 assert_eq!(Shipcheck::classify(true, Some(true)), Shipcheck::Available);
1133 }
1134
1135 #[tokio::test]
1136 async fn supports_reads_the_usage_line() {
1137 let new_dir = tempfile::tempdir().unwrap();
1138 let new = fake_shipcheck(new_dir.path(), true, true);
1139 assert!(supports(&new, "commit").await);
1140 assert!(supports(&new, "hook").await);
1141 let old_dir = tempfile::tempdir().unwrap();
1142 let old = fake_shipcheck(old_dir.path(), false, false);
1143 assert!(!supports(&old, "commit").await);
1144 assert!(!supports(&old, "hook").await);
1145 let missing = old_dir.path().join("missing");
1146 assert!(!supports(&missing, "commit").await);
1147 }
1148
1149 fn fake_shipcheck(dir: &std::path::Path, commit: bool, hook: bool) -> PathBuf {
1151 use std::os::unix::fs::PermissionsExt;
1152 let mut usage = "usage: shipcheck run [--sha <rev>] | status | logs".to_string();
1153 if commit {
1154 usage.push_str(" | commit -m <message>");
1155 }
1156 if hook {
1157 usage.push_str(" | hook");
1158 }
1159 usage.push_str(" | install | doctor");
1160 let bin = dir.join("shipcheck");
1161 std::fs::write(
1162 &bin,
1163 format!(
1164 "#!/bin/sh\nfor a in \"$@\"; do [ \"$a\" = --help ] && {{ echo '{usage}'; exit 0; }}; done\n[ \"$1\" = install ] && touch installed\nexit 0\n"
1165 ),
1166 )
1167 .unwrap();
1168 std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
1169 bin
1170 }
1171
1172 #[tokio::test]
1173 async fn install_shipcheck_hooks_runs_install_when_supported() {
1174 let bin_dir = tempfile::tempdir().unwrap();
1175 let work = tempfile::tempdir().unwrap();
1176 let bin = fake_shipcheck(bin_dir.path(), true, true);
1177 assert!(install_shipcheck_hooks(work.path(), Shipcheck::Available, &bin).await);
1178 assert!(work.path().join("installed").exists());
1179 }
1180
1181 #[tokio::test]
1182 async fn install_shipcheck_hooks_skips_a_shipcheck_without_hook() {
1183 let bin_dir = tempfile::tempdir().unwrap();
1184 let work = tempfile::tempdir().unwrap();
1185 let bin = fake_shipcheck(bin_dir.path(), true, false);
1186 assert!(!install_shipcheck_hooks(work.path(), Shipcheck::Available, &bin).await);
1187 assert!(!work.path().join("installed").exists());
1188 }
1189
1190 #[tokio::test]
1191 async fn install_shipcheck_hooks_skips_when_not_available() {
1192 let bin_dir = tempfile::tempdir().unwrap();
1193 let work = tempfile::tempdir().unwrap();
1194 let bin = fake_shipcheck(bin_dir.path(), true, true);
1195 for s in [
1196 Shipcheck::Outdated,
1197 Shipcheck::NotInstalled,
1198 Shipcheck::Absent,
1199 ] {
1200 assert!(!install_shipcheck_hooks(work.path(), s, &bin).await);
1201 }
1202 assert!(!work.path().join("installed").exists());
1203 }
1204
1205 #[test]
1206 fn the_progress_step_appears_only_in_cluster_mode() {
1207 let mut vars = HashMap::new();
1208 insert_cluster_vars(Shipcheck::Available, &mut vars);
1209 assert!(vars.is_empty(), "no cluster section, no cluster flags");
1210 let plain = crate::template::render_template_str(AGENT_SYSTEM_TEMPLATE, &vars).unwrap();
1211 assert!(!plain.contains("orch progress"));
1212
1213 for flag in ["CLUSTER", "CLUSTER_SHIPCHECK"] {
1214 vars.insert(flag.to_string(), "1".to_string());
1215 }
1216 let cluster = crate::template::render_template_str(AGENT_SYSTEM_TEMPLATE, &vars).unwrap();
1217 assert!(cluster.contains("orch progress"));
1218 assert!(cluster.contains("commits only through `shipcheck commit`"));
1219 assert!(!cluster.contains("It commits anything you left"));
1220 }
1221}