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