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    fn test_invocation(task_id: &str) -> AgentInvocation {
775        AgentInvocation {
776            agent: "claude".to_string(),
777            model: None,
778            work_dir: PathBuf::from("/tmp"),
779            system_prompt: "test system prompt".to_string(),
780            agent_message: "test message".to_string(),
781            task_id: task_id.to_string(),
782            disallowed_tools: vec![],
783            git_author_name: "Test Bot".to_string(),
784            git_author_email: "bot@example.com".to_string(),
785            output_file: PathBuf::from("/tmp/test-output.json"),
786            timeout_seconds: 0,
787            repo: "orch-runner-test/token-check".to_string(),
788            attempt: 1,
789        }
790    }
791
792    #[test]
793    fn env_map_does_not_contain_token_value_as_string() {
794        // Verify that the token resolution returns the raw token value,
795        // not a string that would need to be embedded in a script.
796        // The key security guarantee: we're NOT building a runner script anymore.
797        // Tokens are injected directly into tmux session environment.
798        let resolver = crate::github::token::TokenResolver::default_env();
799        let token = resolver.get_token_sync().ok().flatten().unwrap_or_default();
800        // Token can be empty (no token configured) or a valid token string.
801        // The important thing is it's passed via tmux -e flag, not written to disk.
802        assert!(
803            token.is_empty() || token.len() > 10,
804            "resolve_token should return empty string or a valid token"
805        );
806    }
807
808    #[test]
809    fn spawn_in_tmux_rejects_invalid_env() {
810        // This test verifies the function signature and basic behavior
811        // without actually spawning a tmux session.
812        // The key security guarantee: tokens are passed via tmux -e flag,
813        // not written to any file on disk.
814        let inv = test_invocation("env-test");
815        // Just verify the invocation can be created without panicking
816        assert_eq!(inv.task_id, "env-test");
817    }
818
819    #[test]
820    fn build_runner_script_persists_exit_status_on_failure() {
821        // Use a temp dir so lib and bin compilation units don't race on ~/.orch/state/.
822        let tmp = tempfile::TempDir::new().expect("temp dir");
823        let inv = test_invocation("runner-status-test");
824        let script =
825            build_runner_script_in_dir(&inv, tmp.path()).expect("runner script should build");
826
827        assert!(script
828            .contains("trap 'status=$?; printf \"%s\\n\" \"$status\" > \"$status_file\"' EXIT"));
829        assert!(script.contains("set +e"));
830    }
831
832    #[test]
833    fn build_runner_script_reports_missing_worktree() {
834        // Use a temp dir so lib and bin compilation units don't race on ~/.orch/state/.
835        let tmp = tempfile::TempDir::new().expect("temp dir");
836        let inv = test_invocation("runner-missing-worktree-test");
837        let script =
838            build_runner_script_in_dir(&inv, tmp.path()).expect("runner script should build");
839
840        assert!(script.contains("worktree directory does not exist: '/tmp'"));
841        assert!(script.contains("stderr_file='"));
842    }
843
844    #[test]
845    fn tmux_runner_script_runs_agent_under_pty_not_a_plain_pipe() {
846        // Regression test for #3554: piping the agent binary's stdout straight
847        // into `tee` leaves it non-tty, which makes many CLI runtimes switch to
848        // full block-buffering and can starve the tmux pane long enough to trip
849        // silence detection even while the agent is actively working. The agent
850        // command must run under `script` (a real pty) before hitting `tee`.
851        let tmp = tempfile::TempDir::new().expect("temp dir");
852        let inv = test_invocation("pty-wrap-test");
853        let (runner_script, agent_cmd_script) =
854            build_tmux_runner_scripts(&inv, "echo hello", tmp.path())
855                .expect("runner scripts should build");
856
857        assert!(
858            runner_script.contains("script -q /dev/null /bin/bash"),
859            "agent must be exec'd under a pty via `script`, got: {runner_script}"
860        );
861        assert!(
862            runner_script.contains("| tee"),
863            "output must still be tee'd to the output file, got: {runner_script}"
864        );
865        assert!(
866            !runner_script.contains("echo hello 2>"),
867            "the raw agent command must not be piped directly into tee anymore, got: {runner_script}"
868        );
869        assert!(
870            agent_cmd_script.contains("echo hello 2>"),
871            "the agent command should run (with stderr split to a file) inside the pty-wrapped helper script, got: {agent_cmd_script}"
872        );
873    }
874
875    #[test]
876    fn tmux_runner_script_captures_agent_exit_status_via_pipestatus() {
877        let tmp = tempfile::TempDir::new().expect("temp dir");
878        let inv = test_invocation("pty-exit-status-test");
879        let (runner_script, _) = build_tmux_runner_scripts(&inv, "echo hello", tmp.path())
880            .expect("runner scripts should build");
881
882        assert!(runner_script.contains("CMD_STATUS=${PIPESTATUS[0]:-0}"));
883        assert!(runner_script.contains("exit $CMD_STATUS"));
884    }
885
886    #[test]
887    fn tmux_runner_script_disables_commit_signing() {
888        // Locked signing agent must not fail `git commit` inside agent sessions
889        let tmp = tempfile::TempDir::new().expect("temp dir");
890        let inv = test_invocation("gpgsign-test");
891        let (runner_script, _) = build_tmux_runner_scripts(&inv, "echo hello", tmp.path())
892            .expect("runner scripts should build");
893
894        assert!(runner_script.contains("GIT_CONFIG_KEY_0=\"commit.gpgsign\""));
895        assert!(runner_script.contains("GIT_CONFIG_VALUE_0=\"false\""));
896    }
897
898    #[test]
899    fn tmux_runner_script_writes_executable_agent_cmd_helper() {
900        let tmp = tempfile::TempDir::new().expect("temp dir");
901        let inv = test_invocation("pty-helper-path-test");
902        let (runner_script, agent_cmd_script) =
903            build_tmux_runner_scripts(&inv, "echo hello", tmp.path())
904                .expect("runner scripts should build");
905
906        let agent_cmd_script_path = tmp.path().join("agent_cmd.sh");
907        assert!(
908            runner_script.contains(&agent_cmd_script_path.display().to_string()),
909            "runner script must reference the agent_cmd.sh helper path, got: {runner_script}"
910        );
911        assert!(agent_cmd_script.starts_with("#!/usr/bin/env bash\n"));
912    }
913
914    #[test]
915    fn build_runner_script_escapes_shell_injection_in_paths() {
916        let tmp = tempfile::TempDir::new().expect("temp dir");
917        let mut inv = test_invocation("injection-test");
918        inv.work_dir = std::path::PathBuf::from("/tmp/$(whoami)/workspace");
919        inv.output_file = std::path::PathBuf::from("/tmp/evil`id`.json");
920        let script =
921            build_runner_script_in_dir(&inv, tmp.path()).expect("runner script should build");
922
923        // Values must be single-quoted, neutralizing $() and backticks
924        assert!(
925            script.contains("'/tmp/$(whoami)/workspace'"),
926            "work_dir with $() must be single-quoted: {script}"
927        );
928        assert!(
929            script.contains("'/tmp/evil`id`.json'"),
930            "output_file with backticks must be single-quoted: {script}"
931        );
932    }
933
934    #[test]
935    fn review_prompt_forbids_ending_turn_on_pending_ci_status() {
936        // Regression test for #3453: a review agent must not stop with a
937        // prose "CI is still running" status update and leave the turn
938        // without JSON. The prompt must explicitly route PENDING/NOT RUN CI
939        // through the local-checks fallback and into the same-turn JSON output.
940        let task = crate::backends::ExternalTask {
941            id: crate::backends::ExternalId("155460".to_string()),
942            title: "test task".to_string(),
943            body: "test body".to_string(),
944            state: "open".to_string(),
945            labels: vec![],
946            author: "tester".to_string(),
947            created_at: "2026-01-01T00:00:00Z".to_string(),
948            updated_at: "2026-01-01T00:00:00Z".to_string(),
949            url: "https://example.com/1".to_string(),
950        };
951        let prompt = build_review_prompt(&task, "", "", "", "main", 1, Shipcheck::Absent);
952
953        assert!(
954            prompt.contains("Pending CI is never a terminal review state"),
955            "prompt must state pending CI is not terminal: {prompt}"
956        );
957        assert!(
958            prompt.contains("Never end your turn with a CI-waiting status update"),
959            "prompt must forbid ending the turn on a CI-waiting status update: {prompt}"
960        );
961    }
962
963    #[test]
964    fn shell_single_quote_escapes_embedded_quotes() {
965        use crate::engine::runner::agents::shell_single_quote;
966
967        assert_eq!(shell_single_quote("hello"), "'hello'");
968        assert_eq!(shell_single_quote("O'Brien"), "'O'\\''Brien'");
969        assert_eq!(shell_single_quote("$(rm -rf /)"), "'$(rm -rf /)'");
970        assert_eq!(shell_single_quote("`whoami`"), "'`whoami`'");
971        assert_eq!(shell_single_quote("a\"b"), "'a\"b'");
972        assert_eq!(shell_single_quote("$HOME"), "'$HOME'");
973    }
974
975    fn git_init(dir: &std::path::Path) {
976        let out = std::process::Command::new("git")
977            .args(["init", "-q"])
978            .current_dir(dir)
979            .output()
980            .unwrap();
981        assert!(out.status.success());
982    }
983
984    #[tokio::test]
985    async fn install_shipcheck_rule_writes_rule_and_keeps_tree_clean() {
986        let dir = tempfile::tempdir().unwrap();
987        git_init(dir.path());
988
989        install_shipcheck_rule(dir.path()).await.unwrap();
990        install_shipcheck_rule(dir.path()).await.unwrap();
991
992        let rule = std::fs::read_to_string(dir.path().join(SHIPCHECK_RULE_PATH)).unwrap();
993        assert_eq!(rule, SHIPCHECK_RULES);
994        for cmd in ["run", "commit", "status", "logs", "doctor"] {
995            assert!(
996                rule.contains(&format!(r#"["shipcheck", "{cmd}"]"#)),
997                "{cmd}"
998            );
999        }
1000        assert!(!rule.contains(r#"["shipcheck"]"#));
1001        for cmd in [
1002            "clean", "post", "login", "logout", "daemon", "init", "install",
1003        ] {
1004            assert!(!rule.contains(&format!(r#""{cmd}""#)), "{cmd}");
1005        }
1006
1007        let exclude = std::fs::read_to_string(dir.path().join(".git/info/exclude")).unwrap();
1008        assert_eq!(exclude.matches("orch-shipcheck.rules").count(), 1);
1009        let status = std::process::Command::new("git")
1010            .args(["status", "--porcelain", "--untracked-files=all"])
1011            .current_dir(dir.path())
1012            .output()
1013            .unwrap();
1014        assert!(status.stdout.is_empty(), "{:?}", status);
1015    }
1016
1017    #[tokio::test]
1018    async fn install_shipcheck_rule_refuses_symlinked_dirs() {
1019        for link in [".codex", ".codex/rules"] {
1020            let dir = tempfile::tempdir().unwrap();
1021            let target = tempfile::tempdir().unwrap();
1022            git_init(dir.path());
1023            std::fs::create_dir_all(dir.path().join(".codex")).unwrap();
1024            if link == ".codex" {
1025                std::fs::remove_dir(dir.path().join(".codex")).unwrap();
1026            }
1027            std::os::unix::fs::symlink(target.path(), dir.path().join(link)).unwrap();
1028
1029            assert!(install_shipcheck_rule(dir.path()).await.is_err(), "{link}");
1030            assert_eq!(
1031                std::fs::read_dir(target.path()).unwrap().count(),
1032                0,
1033                "{link}"
1034            );
1035        }
1036    }
1037
1038    #[tokio::test]
1039    async fn install_shipcheck_rule_replaces_a_symlinked_file_without_following_it() {
1040        let dir = tempfile::tempdir().unwrap();
1041        let target = tempfile::tempdir().unwrap();
1042        let outside = target.path().join("victim");
1043        std::fs::write(&outside, "keep").unwrap();
1044        git_init(dir.path());
1045        std::fs::create_dir_all(dir.path().join(".codex/rules")).unwrap();
1046        std::os::unix::fs::symlink(&outside, dir.path().join(SHIPCHECK_RULE_PATH)).unwrap();
1047
1048        install_shipcheck_rule(dir.path()).await.unwrap();
1049
1050        assert_eq!(std::fs::read_to_string(&outside).unwrap(), "keep");
1051        let meta = std::fs::symlink_metadata(dir.path().join(SHIPCHECK_RULE_PATH)).unwrap();
1052        assert!(meta.is_file());
1053    }
1054
1055    fn prompt_task() -> crate::backends::ExternalTask {
1056        crate::backends::ExternalTask {
1057            id: crate::backends::ExternalId("1".to_string()),
1058            title: "t".to_string(),
1059            body: "b".to_string(),
1060            state: "open".to_string(),
1061            labels: vec![],
1062            author: "a".to_string(),
1063            created_at: "2026-01-01T00:00:00Z".to_string(),
1064            updated_at: "2026-01-01T00:00:00Z".to_string(),
1065            url: "https://example.com/1".to_string(),
1066        }
1067    }
1068
1069    #[test]
1070    fn system_prompt_uses_shipcheck_only_when_available() {
1071        let ctx = super::super::context::TaskContext::default();
1072        let task = prompt_task();
1073        let prompt = |s| build_system_prompt(&task, &ctx, None, "main", s);
1074
1075        let available = prompt(Shipcheck::Available);
1076        assert!(available.contains("shipcheck run"));
1077        assert!(available.contains("shipcheck commit"));
1078        assert!(!available.contains(".github/workflows/"));
1079        assert!(!available.contains("not installed"));
1080
1081        let outdated = prompt(Shipcheck::Outdated);
1082        assert!(!outdated.contains("shipcheck"));
1083        assert!(outdated.contains(".github/workflows/"));
1084
1085        let missing = prompt(Shipcheck::NotInstalled);
1086        assert!(!missing.contains("shipcheck run"));
1087        assert!(missing.contains(".github/workflows/"));
1088        assert!(missing.contains("shipcheck is not installed"));
1089
1090        let absent = prompt(Shipcheck::Absent);
1091        assert!(!absent.contains("shipcheck"));
1092        assert!(absent.contains(".github/workflows/"));
1093
1094        for p in [&available, &outdated, &missing, &absent] {
1095            assert!(!p.contains("{{"), "{p}");
1096            assert!(!p.contains("install shipcheck"), "{p}");
1097        }
1098    }
1099
1100    #[test]
1101    fn review_prompt_uses_shipcheck_only_when_available() {
1102        let task = prompt_task();
1103        let prompt = |s| build_review_prompt(&task, "", "", "", "main", 1, s);
1104        assert!(prompt(Shipcheck::Available).contains("shipcheck run"));
1105        for s in [
1106            Shipcheck::Outdated,
1107            Shipcheck::NotInstalled,
1108            Shipcheck::Absent,
1109        ] {
1110            let p = prompt(s);
1111            assert!(!p.contains("shipcheck"), "{p}");
1112            assert!(p.contains(".github/workflows/"), "{p}");
1113        }
1114    }
1115
1116    #[test]
1117    fn shipcheck_classify_needs_config_and_commit() {
1118        assert_eq!(Shipcheck::classify(false, Some(true)), Shipcheck::Absent);
1119        assert_eq!(Shipcheck::classify(false, None), Shipcheck::Absent);
1120        assert_eq!(Shipcheck::classify(true, None), Shipcheck::NotInstalled);
1121        assert_eq!(Shipcheck::classify(true, Some(false)), Shipcheck::Outdated);
1122        assert_eq!(Shipcheck::classify(true, Some(true)), Shipcheck::Available);
1123    }
1124
1125    #[tokio::test]
1126    async fn supports_reads_the_usage_line() {
1127        let new_dir = tempfile::tempdir().unwrap();
1128        let new = fake_shipcheck(new_dir.path(), true, true);
1129        assert!(supports(&new, "commit").await);
1130        assert!(supports(&new, "hook").await);
1131        let old_dir = tempfile::tempdir().unwrap();
1132        let old = fake_shipcheck(old_dir.path(), false, false);
1133        assert!(!supports(&old, "commit").await);
1134        assert!(!supports(&old, "hook").await);
1135        let missing = old_dir.path().join("missing");
1136        assert!(!supports(&missing, "commit").await);
1137    }
1138
1139    // Mimics shipcheck, whose `--help` prints usage and exits 0 after any subcommand
1140    fn fake_shipcheck(dir: &std::path::Path, commit: bool, hook: bool) -> PathBuf {
1141        use std::os::unix::fs::PermissionsExt;
1142        let mut usage = "usage: shipcheck run [--sha <rev>] | status | logs".to_string();
1143        if commit {
1144            usage.push_str(" | commit -m <message>");
1145        }
1146        if hook {
1147            usage.push_str(" | hook");
1148        }
1149        usage.push_str(" | install | doctor");
1150        let bin = dir.join("shipcheck");
1151        std::fs::write(
1152            &bin,
1153            format!(
1154                "#!/bin/sh\nfor a in \"$@\"; do [ \"$a\" = --help ] && {{ echo '{usage}'; exit 0; }}; done\n[ \"$1\" = install ] && touch installed\nexit 0\n"
1155            ),
1156        )
1157        .unwrap();
1158        std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
1159        bin
1160    }
1161
1162    #[tokio::test]
1163    async fn install_shipcheck_hooks_runs_install_when_supported() {
1164        let bin_dir = tempfile::tempdir().unwrap();
1165        let work = tempfile::tempdir().unwrap();
1166        let bin = fake_shipcheck(bin_dir.path(), true, true);
1167        assert!(install_shipcheck_hooks(work.path(), Shipcheck::Available, &bin).await);
1168        assert!(work.path().join("installed").exists());
1169    }
1170
1171    #[tokio::test]
1172    async fn install_shipcheck_hooks_skips_a_shipcheck_without_hook() {
1173        let bin_dir = tempfile::tempdir().unwrap();
1174        let work = tempfile::tempdir().unwrap();
1175        let bin = fake_shipcheck(bin_dir.path(), true, false);
1176        assert!(!install_shipcheck_hooks(work.path(), Shipcheck::Available, &bin).await);
1177        assert!(!work.path().join("installed").exists());
1178    }
1179
1180    #[tokio::test]
1181    async fn install_shipcheck_hooks_skips_when_not_available() {
1182        let bin_dir = tempfile::tempdir().unwrap();
1183        let work = tempfile::tempdir().unwrap();
1184        let bin = fake_shipcheck(bin_dir.path(), true, true);
1185        for s in [
1186            Shipcheck::Outdated,
1187            Shipcheck::NotInstalled,
1188            Shipcheck::Absent,
1189        ] {
1190            assert!(!install_shipcheck_hooks(work.path(), s, &bin).await);
1191        }
1192        assert!(!work.path().join("installed").exists());
1193    }
1194}