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