Skip to main content

orch/engine/runner/
agent.rs

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