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