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