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 #[test]
775 fn review_system_prompt_checks_stray_files_and_trust_boundaries() {
776 let p = review_system_prompt();
777 assert!(p.contains("**Files**"));
778 assert!(p.contains("**Trust boundaries**"));
779 }
780
781 fn test_invocation(task_id: &str) -> AgentInvocation {
782 AgentInvocation {
783 agent: "claude".to_string(),
784 model: None,
785 work_dir: PathBuf::from("/tmp"),
786 system_prompt: "test system prompt".to_string(),
787 agent_message: "test message".to_string(),
788 task_id: task_id.to_string(),
789 disallowed_tools: vec![],
790 git_author_name: "Test Bot".to_string(),
791 git_author_email: "bot@example.com".to_string(),
792 output_file: PathBuf::from("/tmp/test-output.json"),
793 timeout_seconds: 0,
794 repo: "orch-runner-test/token-check".to_string(),
795 attempt: 1,
796 }
797 }
798
799 #[test]
800 fn env_map_does_not_contain_token_value_as_string() {
801 let resolver = crate::github::token::TokenResolver::default_env();
806 let token = resolver.get_token_sync().ok().flatten().unwrap_or_default();
807 assert!(
810 token.is_empty() || token.len() > 10,
811 "resolve_token should return empty string or a valid token"
812 );
813 }
814
815 #[test]
816 fn spawn_in_tmux_rejects_invalid_env() {
817 let inv = test_invocation("env-test");
822 assert_eq!(inv.task_id, "env-test");
824 }
825
826 #[test]
827 fn build_runner_script_persists_exit_status_on_failure() {
828 let tmp = tempfile::TempDir::new().expect("temp dir");
830 let inv = test_invocation("runner-status-test");
831 let script =
832 build_runner_script_in_dir(&inv, tmp.path()).expect("runner script should build");
833
834 assert!(script
835 .contains("trap 'status=$?; printf \"%s\\n\" \"$status\" > \"$status_file\"' EXIT"));
836 assert!(script.contains("set +e"));
837 }
838
839 #[test]
840 fn build_runner_script_reports_missing_worktree() {
841 let tmp = tempfile::TempDir::new().expect("temp dir");
843 let inv = test_invocation("runner-missing-worktree-test");
844 let script =
845 build_runner_script_in_dir(&inv, tmp.path()).expect("runner script should build");
846
847 assert!(script.contains("worktree directory does not exist: '/tmp'"));
848 assert!(script.contains("stderr_file='"));
849 }
850
851 #[test]
852 fn tmux_runner_script_runs_agent_under_pty_not_a_plain_pipe() {
853 let tmp = tempfile::TempDir::new().expect("temp dir");
859 let inv = test_invocation("pty-wrap-test");
860 let (runner_script, agent_cmd_script) =
861 build_tmux_runner_scripts(&inv, "echo hello", tmp.path())
862 .expect("runner scripts should build");
863
864 assert!(
865 runner_script.contains("script -q /dev/null /bin/bash"),
866 "agent must be exec'd under a pty via `script`, got: {runner_script}"
867 );
868 assert!(
869 runner_script.contains("| tee"),
870 "output must still be tee'd to the output file, got: {runner_script}"
871 );
872 assert!(
873 !runner_script.contains("echo hello 2>"),
874 "the raw agent command must not be piped directly into tee anymore, got: {runner_script}"
875 );
876 assert!(
877 agent_cmd_script.contains("echo hello 2>"),
878 "the agent command should run (with stderr split to a file) inside the pty-wrapped helper script, got: {agent_cmd_script}"
879 );
880 }
881
882 #[test]
883 fn tmux_runner_script_captures_agent_exit_status_via_pipestatus() {
884 let tmp = tempfile::TempDir::new().expect("temp dir");
885 let inv = test_invocation("pty-exit-status-test");
886 let (runner_script, _) = build_tmux_runner_scripts(&inv, "echo hello", tmp.path())
887 .expect("runner scripts should build");
888
889 assert!(runner_script.contains("CMD_STATUS=${PIPESTATUS[0]:-0}"));
890 assert!(runner_script.contains("exit $CMD_STATUS"));
891 }
892
893 #[test]
894 fn tmux_runner_script_disables_commit_signing() {
895 let tmp = tempfile::TempDir::new().expect("temp dir");
897 let inv = test_invocation("gpgsign-test");
898 let (runner_script, _) = build_tmux_runner_scripts(&inv, "echo hello", tmp.path())
899 .expect("runner scripts should build");
900
901 assert!(runner_script.contains("GIT_CONFIG_KEY_0=\"commit.gpgsign\""));
902 assert!(runner_script.contains("GIT_CONFIG_VALUE_0=\"false\""));
903 }
904
905 #[test]
906 fn tmux_runner_script_writes_executable_agent_cmd_helper() {
907 let tmp = tempfile::TempDir::new().expect("temp dir");
908 let inv = test_invocation("pty-helper-path-test");
909 let (runner_script, agent_cmd_script) =
910 build_tmux_runner_scripts(&inv, "echo hello", tmp.path())
911 .expect("runner scripts should build");
912
913 let agent_cmd_script_path = tmp.path().join("agent_cmd.sh");
914 assert!(
915 runner_script.contains(&agent_cmd_script_path.display().to_string()),
916 "runner script must reference the agent_cmd.sh helper path, got: {runner_script}"
917 );
918 assert!(agent_cmd_script.starts_with("#!/usr/bin/env bash\n"));
919 }
920
921 #[test]
922 fn build_runner_script_escapes_shell_injection_in_paths() {
923 let tmp = tempfile::TempDir::new().expect("temp dir");
924 let mut inv = test_invocation("injection-test");
925 inv.work_dir = std::path::PathBuf::from("/tmp/$(whoami)/workspace");
926 inv.output_file = std::path::PathBuf::from("/tmp/evil`id`.json");
927 let script =
928 build_runner_script_in_dir(&inv, tmp.path()).expect("runner script should build");
929
930 assert!(
932 script.contains("'/tmp/$(whoami)/workspace'"),
933 "work_dir with $() must be single-quoted: {script}"
934 );
935 assert!(
936 script.contains("'/tmp/evil`id`.json'"),
937 "output_file with backticks must be single-quoted: {script}"
938 );
939 }
940
941 #[test]
942 fn review_prompt_forbids_ending_turn_on_pending_ci_status() {
943 let task = crate::backends::ExternalTask {
948 id: crate::backends::ExternalId("155460".to_string()),
949 title: "test task".to_string(),
950 body: "test body".to_string(),
951 state: "open".to_string(),
952 labels: vec![],
953 author: "tester".to_string(),
954 created_at: "2026-01-01T00:00:00Z".to_string(),
955 updated_at: "2026-01-01T00:00:00Z".to_string(),
956 url: "https://example.com/1".to_string(),
957 };
958 let prompt = build_review_prompt(&task, "", "", "", "main", 1, Shipcheck::Absent);
959
960 assert!(
961 prompt.contains("Pending CI is never a terminal review state"),
962 "prompt must state pending CI is not terminal: {prompt}"
963 );
964 assert!(
965 prompt.contains("Never end your turn with a CI-waiting status update"),
966 "prompt must forbid ending the turn on a CI-waiting status update: {prompt}"
967 );
968 }
969
970 #[test]
971 fn shell_single_quote_escapes_embedded_quotes() {
972 use crate::engine::runner::agents::shell_single_quote;
973
974 assert_eq!(shell_single_quote("hello"), "'hello'");
975 assert_eq!(shell_single_quote("O'Brien"), "'O'\\''Brien'");
976 assert_eq!(shell_single_quote("$(rm -rf /)"), "'$(rm -rf /)'");
977 assert_eq!(shell_single_quote("`whoami`"), "'`whoami`'");
978 assert_eq!(shell_single_quote("a\"b"), "'a\"b'");
979 assert_eq!(shell_single_quote("$HOME"), "'$HOME'");
980 }
981
982 fn git_init(dir: &std::path::Path) {
983 let out = std::process::Command::new("git")
984 .args(["init", "-q"])
985 .current_dir(dir)
986 .output()
987 .unwrap();
988 assert!(out.status.success());
989 }
990
991 #[tokio::test]
992 async fn install_shipcheck_rule_writes_rule_and_keeps_tree_clean() {
993 let dir = tempfile::tempdir().unwrap();
994 git_init(dir.path());
995
996 install_shipcheck_rule(dir.path()).await.unwrap();
997 install_shipcheck_rule(dir.path()).await.unwrap();
998
999 let rule = std::fs::read_to_string(dir.path().join(SHIPCHECK_RULE_PATH)).unwrap();
1000 assert_eq!(rule, SHIPCHECK_RULES);
1001 for cmd in ["run", "commit", "status", "logs", "doctor"] {
1002 assert!(
1003 rule.contains(&format!(r#"["shipcheck", "{cmd}"]"#)),
1004 "{cmd}"
1005 );
1006 }
1007 assert!(!rule.contains(r#"["shipcheck"]"#));
1008 for cmd in [
1009 "clean", "post", "login", "logout", "daemon", "init", "install",
1010 ] {
1011 assert!(!rule.contains(&format!(r#""{cmd}""#)), "{cmd}");
1012 }
1013
1014 let exclude = std::fs::read_to_string(dir.path().join(".git/info/exclude")).unwrap();
1015 assert_eq!(exclude.matches("orch-shipcheck.rules").count(), 1);
1016 let status = std::process::Command::new("git")
1017 .args(["status", "--porcelain", "--untracked-files=all"])
1018 .current_dir(dir.path())
1019 .output()
1020 .unwrap();
1021 assert!(status.stdout.is_empty(), "{:?}", status);
1022 }
1023
1024 #[tokio::test]
1025 async fn install_shipcheck_rule_refuses_symlinked_dirs() {
1026 for link in [".codex", ".codex/rules"] {
1027 let dir = tempfile::tempdir().unwrap();
1028 let target = tempfile::tempdir().unwrap();
1029 git_init(dir.path());
1030 std::fs::create_dir_all(dir.path().join(".codex")).unwrap();
1031 if link == ".codex" {
1032 std::fs::remove_dir(dir.path().join(".codex")).unwrap();
1033 }
1034 std::os::unix::fs::symlink(target.path(), dir.path().join(link)).unwrap();
1035
1036 assert!(install_shipcheck_rule(dir.path()).await.is_err(), "{link}");
1037 assert_eq!(
1038 std::fs::read_dir(target.path()).unwrap().count(),
1039 0,
1040 "{link}"
1041 );
1042 }
1043 }
1044
1045 #[tokio::test]
1046 async fn install_shipcheck_rule_replaces_a_symlinked_file_without_following_it() {
1047 let dir = tempfile::tempdir().unwrap();
1048 let target = tempfile::tempdir().unwrap();
1049 let outside = target.path().join("victim");
1050 std::fs::write(&outside, "keep").unwrap();
1051 git_init(dir.path());
1052 std::fs::create_dir_all(dir.path().join(".codex/rules")).unwrap();
1053 std::os::unix::fs::symlink(&outside, dir.path().join(SHIPCHECK_RULE_PATH)).unwrap();
1054
1055 install_shipcheck_rule(dir.path()).await.unwrap();
1056
1057 assert_eq!(std::fs::read_to_string(&outside).unwrap(), "keep");
1058 let meta = std::fs::symlink_metadata(dir.path().join(SHIPCHECK_RULE_PATH)).unwrap();
1059 assert!(meta.is_file());
1060 }
1061
1062 fn prompt_task() -> crate::backends::ExternalTask {
1063 crate::backends::ExternalTask {
1064 id: crate::backends::ExternalId("1".to_string()),
1065 title: "t".to_string(),
1066 body: "b".to_string(),
1067 state: "open".to_string(),
1068 labels: vec![],
1069 author: "a".to_string(),
1070 created_at: "2026-01-01T00:00:00Z".to_string(),
1071 updated_at: "2026-01-01T00:00:00Z".to_string(),
1072 url: "https://example.com/1".to_string(),
1073 }
1074 }
1075
1076 #[test]
1077 fn system_prompt_uses_shipcheck_only_when_available() {
1078 let ctx = super::super::context::TaskContext::default();
1079 let task = prompt_task();
1080 let prompt = |s| build_system_prompt(&task, &ctx, None, "main", s);
1081
1082 let available = prompt(Shipcheck::Available);
1083 assert!(available.contains("shipcheck run"));
1084 assert!(available.contains("shipcheck commit"));
1085 assert!(!available.contains(".github/workflows/"));
1086 assert!(!available.contains("not installed"));
1087
1088 let outdated = prompt(Shipcheck::Outdated);
1089 assert!(!outdated.contains("shipcheck"));
1090 assert!(outdated.contains(".github/workflows/"));
1091
1092 let missing = prompt(Shipcheck::NotInstalled);
1093 assert!(!missing.contains("shipcheck run"));
1094 assert!(missing.contains(".github/workflows/"));
1095 assert!(missing.contains("shipcheck is not installed"));
1096
1097 let absent = prompt(Shipcheck::Absent);
1098 assert!(!absent.contains("shipcheck"));
1099 assert!(absent.contains(".github/workflows/"));
1100
1101 for p in [&available, &outdated, &missing, &absent] {
1102 assert!(!p.contains("{{"), "{p}");
1103 assert!(!p.contains("install shipcheck"), "{p}");
1104 }
1105 }
1106
1107 #[test]
1108 fn review_prompt_uses_shipcheck_only_when_available() {
1109 let task = prompt_task();
1110 let prompt = |s| build_review_prompt(&task, "", "", "", "main", 1, s);
1111 assert!(prompt(Shipcheck::Available).contains("shipcheck run"));
1112 for s in [
1113 Shipcheck::Outdated,
1114 Shipcheck::NotInstalled,
1115 Shipcheck::Absent,
1116 ] {
1117 let p = prompt(s);
1118 assert!(!p.contains("shipcheck"), "{p}");
1119 assert!(p.contains(".github/workflows/"), "{p}");
1120 }
1121 }
1122
1123 #[test]
1124 fn shipcheck_classify_needs_config_and_commit() {
1125 assert_eq!(Shipcheck::classify(false, Some(true)), Shipcheck::Absent);
1126 assert_eq!(Shipcheck::classify(false, None), Shipcheck::Absent);
1127 assert_eq!(Shipcheck::classify(true, None), Shipcheck::NotInstalled);
1128 assert_eq!(Shipcheck::classify(true, Some(false)), Shipcheck::Outdated);
1129 assert_eq!(Shipcheck::classify(true, Some(true)), Shipcheck::Available);
1130 }
1131
1132 #[tokio::test]
1133 async fn supports_reads_the_usage_line() {
1134 let new_dir = tempfile::tempdir().unwrap();
1135 let new = fake_shipcheck(new_dir.path(), true, true);
1136 assert!(supports(&new, "commit").await);
1137 assert!(supports(&new, "hook").await);
1138 let old_dir = tempfile::tempdir().unwrap();
1139 let old = fake_shipcheck(old_dir.path(), false, false);
1140 assert!(!supports(&old, "commit").await);
1141 assert!(!supports(&old, "hook").await);
1142 let missing = old_dir.path().join("missing");
1143 assert!(!supports(&missing, "commit").await);
1144 }
1145
1146 fn fake_shipcheck(dir: &std::path::Path, commit: bool, hook: bool) -> PathBuf {
1148 use std::os::unix::fs::PermissionsExt;
1149 let mut usage = "usage: shipcheck run [--sha <rev>] | status | logs".to_string();
1150 if commit {
1151 usage.push_str(" | commit -m <message>");
1152 }
1153 if hook {
1154 usage.push_str(" | hook");
1155 }
1156 usage.push_str(" | install | doctor");
1157 let bin = dir.join("shipcheck");
1158 std::fs::write(
1159 &bin,
1160 format!(
1161 "#!/bin/sh\nfor a in \"$@\"; do [ \"$a\" = --help ] && {{ echo '{usage}'; exit 0; }}; done\n[ \"$1\" = install ] && touch installed\nexit 0\n"
1162 ),
1163 )
1164 .unwrap();
1165 std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
1166 bin
1167 }
1168
1169 #[tokio::test]
1170 async fn install_shipcheck_hooks_runs_install_when_supported() {
1171 let bin_dir = tempfile::tempdir().unwrap();
1172 let work = tempfile::tempdir().unwrap();
1173 let bin = fake_shipcheck(bin_dir.path(), true, true);
1174 assert!(install_shipcheck_hooks(work.path(), Shipcheck::Available, &bin).await);
1175 assert!(work.path().join("installed").exists());
1176 }
1177
1178 #[tokio::test]
1179 async fn install_shipcheck_hooks_skips_a_shipcheck_without_hook() {
1180 let bin_dir = tempfile::tempdir().unwrap();
1181 let work = tempfile::tempdir().unwrap();
1182 let bin = fake_shipcheck(bin_dir.path(), true, false);
1183 assert!(!install_shipcheck_hooks(work.path(), Shipcheck::Available, &bin).await);
1184 assert!(!work.path().join("installed").exists());
1185 }
1186
1187 #[tokio::test]
1188 async fn install_shipcheck_hooks_skips_when_not_available() {
1189 let bin_dir = tempfile::tempdir().unwrap();
1190 let work = tempfile::tempdir().unwrap();
1191 let bin = fake_shipcheck(bin_dir.path(), true, true);
1192 for s in [
1193 Shipcheck::Outdated,
1194 Shipcheck::NotInstalled,
1195 Shipcheck::Absent,
1196 ] {
1197 assert!(!install_shipcheck_hooks(work.path(), s, &bin).await);
1198 }
1199 assert!(!work.path().join("installed").exists());
1200 }
1201}