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