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/// Run `git rev-parse <arg>` from `work_dir` and canonicalize the
21/// result. Returns `Ok(None)` when git is not available or the command fails.
22async fn resolve_git_dir_arg(
23    work_dir: &std::path::Path,
24    arg: &str,
25) -> anyhow::Result<Option<PathBuf>> {
26    let output = tokio::process::Command::new("git")
27        .args(["rev-parse", arg])
28        .current_dir(work_dir)
29        .output()
30        .await?;
31
32    if !output.status.success() {
33        return Ok(None);
34    }
35
36    let raw = String::from_utf8_lossy(&output.stdout).trim().to_string();
37    if raw.is_empty() {
38        return Ok(None);
39    }
40
41    // The path may be relative to work_dir; canonicalize to absolute.
42    let candidate = if std::path::Path::new(&raw).is_absolute() {
43        PathBuf::from(&raw)
44    } else {
45        work_dir.join(&raw)
46    };
47
48    let canonical = tokio::fs::canonicalize(&candidate)
49        .await
50        .unwrap_or(candidate);
51    Ok(Some(canonical))
52}
53
54fn render_prompt_template(template: &str, vars: HashMap<String, String>) -> String {
55    match render_template_str(template, &vars) {
56        Ok(rendered) => rendered,
57        Err(err) => {
58            tracing::error!(error = %err, "failed to render prompt template");
59            template.to_string()
60        }
61    }
62}
63
64/// Agent invocation configuration.
65pub struct AgentInvocation {
66    /// Agent name (claude, codex, opencode, grok, kimi, minimax)
67    pub agent: String,
68    /// Model override (e.g., "claude-sonnet-4-6", "o3", "gpt-4.1")
69    pub model: Option<String>,
70    /// Working directory
71    pub work_dir: PathBuf,
72    /// System prompt content
73    pub system_prompt: String,
74    /// Agent message (task prompt)
75    pub agent_message: String,
76    /// Task ID
77    pub task_id: String,
78    /// Disallowed tools pattern
79    pub disallowed_tools: Vec<String>,
80    /// Git author name
81    pub git_author_name: String,
82    /// Git author email
83    pub git_author_email: String,
84    /// Output file path for agent response
85    pub output_file: PathBuf,
86    /// Timeout in seconds (0 = no timeout)
87    pub timeout_seconds: u64,
88    /// Repository slug (owner/repo) for per-repo state isolation
89    pub repo: String,
90    /// Current attempt number (1-indexed)
91    pub attempt: u32,
92}
93
94/// Build the runner script content (saved to attempt dir), plus the small
95/// helper script it execs the agent command through under a pty. Returns
96/// `(runner_script, agent_cmd_script)`.
97fn build_tmux_runner_scripts(
98    inv: &AgentInvocation,
99    agent_cmd: &str,
100    attempt_dir: &std::path::Path,
101) -> anyhow::Result<(String, String)> {
102    let status_file = attempt_dir.join("exit.txt");
103    let stderr_file = attempt_dir.join("stderr.txt");
104    let agent_cmd_script_path = attempt_dir.join("agent_cmd.sh");
105    let sq_git_name = shell_single_quote(&inv.git_author_name);
106    let sq_git_email = shell_single_quote(&inv.git_author_email);
107    let sq_task_id = shell_single_quote(&inv.task_id);
108    let sq_output_file = shell_single_quote(&inv.output_file.display().to_string());
109    let sq_work_dir = shell_single_quote(&inv.work_dir.display().to_string());
110    let sq_status_file = shell_single_quote(&status_file.display().to_string());
111    let sq_stderr_file = shell_single_quote(&stderr_file.display().to_string());
112    let sq_agent_cmd_script = shell_single_quote(&agent_cmd_script_path.display().to_string());
113
114    // Runs the agent with stderr split off to a real file before the pty layer
115    // below merges stdout+stderr, exactly as the previous plain-pipe invocation did.
116    let agent_cmd_script = format!("#!/usr/bin/env bash\n{agent_cmd} 2>{sq_stderr_file}\n");
117
118    let runner_script = format!(
119        r#"#!/usr/bin/env bash
120set -euo pipefail
121
122status_file={sq_status_file}
123stderr_file={sq_stderr_file}
124trap 'status=$?; printf "%s\n" "$status" > "$status_file"' EXIT
125
126# Environment — ~/.path and ~/.private are loaded by orch at startup into the process env
127export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"
128export GIT_AUTHOR_NAME={sq_git_name}
129export GIT_COMMITTER_NAME={sq_git_name}
130export GIT_AUTHOR_EMAIL={sq_git_email}
131export GIT_COMMITTER_EMAIL={sq_git_email}
132# Env config overrides the operator's global commit.gpgsign, the signing agent
133# (1Password etc) is often locked in unattended sessions and blocks `git commit`
134export GIT_CONFIG_COUNT="1"
135export GIT_CONFIG_KEY_0="commit.gpgsign"
136export GIT_CONFIG_VALUE_0="false"
137export TASK_ID={sq_task_id}
138export OUTPUT_FILE={sq_output_file}
139unset CLAUDECODE  # allow nested claude invocations from orch
140
141cd {sq_work_dir} || {{
142    printf '%s\n' "worktree directory does not exist: {sq_work_dir}" > "$stderr_file"
143    exit 1
144}}
145
146# Run agent under a pty (via `script`) so its stdout is a tty, not a plain pipe —
147# many CLI runtimes silently switch to full block-buffering when stdout isn't a
148# tty, which can delay pane output long enough to trip silence detection even
149# while the agent is actively streaming. Tee to both output file and terminal
150# (tmux pane) for live streaming.
151set +e
152script -q /dev/null /bin/bash {sq_agent_cmd_script} | tee {sq_output_file}
153CMD_STATUS=${{PIPESTATUS[0]:-0}}
154set -e
155
156exit $CMD_STATUS
157"#,
158    );
159
160    Ok((runner_script, agent_cmd_script))
161}
162
163/// Spawn the agent in a tmux session.
164///
165/// Returns the tmux session name.
166pub async fn spawn_in_tmux(tmux: &TmuxManager, inv: &AgentInvocation) -> anyhow::Result<String> {
167    // Prepare attempt directory and prompt files (system + message).
168    let attempt_dir =
169        crate::home::task_attempt_dir_async(&inv.repo, &inv.task_id, inv.attempt).await?;
170
171    let sys_file = attempt_dir.join("prompt-sys.md");
172    let msg_file = attempt_dir.join("prompt-msg.md");
173    // Build unified permission rules and sys content
174    let mut permissions = super::agents::PermissionRules::from_config();
175    if !inv.disallowed_tools.is_empty() {
176        for tool in &inv.disallowed_tools {
177            if !permissions.disallowed_tools.contains(tool) {
178                permissions.disallowed_tools.push(tool.clone());
179            }
180        }
181    }
182    permissions.allowed_edit_paths.push(inv.work_dir.clone());
183
184    // For Codex running in workspace-write sandbox mode, grant write access to
185    // git metadata dirs when they live outside the worktree root. We include
186    // both --git-common-dir and --git-dir because index.lock can be created
187    // under the worktree-specific git dir.
188    if inv.agent == "codex" {
189        let mut extra_dirs = BTreeSet::new();
190        for (arg, label) in [
191            ("--git-common-dir", "git common dir"),
192            ("--git-dir", "git dir"),
193        ] {
194            match resolve_git_dir_arg(&inv.work_dir, arg).await {
195                Ok(Some(dir)) if !dir.starts_with(&inv.work_dir) => {
196                    extra_dirs.insert(dir);
197                }
198                Ok(_) => {}
199                Err(e) => {
200                    tracing::debug!(
201                        work_dir = %inv.work_dir.display(),
202                        arg,
203                        error = %e,
204                        "failed to resolve {}; skipping --add-dir",
205                        label
206                    );
207                }
208            }
209        }
210        permissions.extra_writable_dirs.extend(extra_dirs);
211    }
212
213    let sys_content = if !permissions.allowed_tools.is_empty() {
214        let tools_list = permissions
215            .allowed_tools
216            .iter()
217            .map(|t| format!("- {t}"))
218            .collect::<Vec<_>>()
219            .join("\n");
220        let mut vars = HashMap::new();
221        vars.insert("TOOLS_LIST".to_string(), tools_list);
222        let tools_prompt = render_prompt_template(ALLOWED_TOOLS_TEMPLATE, vars);
223        format!("{}\n\n{}", inv.system_prompt, tools_prompt)
224    } else {
225        inv.system_prompt.clone()
226    };
227
228    tokio::fs::write(&sys_file, &sys_content).await?;
229    tokio::fs::write(&msg_file, &inv.agent_message).await?;
230
231    // Build agent command using per-agent runner (used in non-PTY path below)
232    let runner = super::agents::get_runner(&inv.agent);
233
234    // Prepare environment map for tmux session (does not write GH token to disk)
235    let mut env = std::collections::HashMap::new();
236    env.insert("GIT_AUTHOR_NAME".to_string(), inv.git_author_name.clone());
237    env.insert(
238        "GIT_COMMITTER_NAME".to_string(),
239        inv.git_author_name.clone(),
240    );
241    env.insert("GIT_AUTHOR_EMAIL".to_string(), inv.git_author_email.clone());
242    env.insert(
243        "GIT_COMMITTER_EMAIL".to_string(),
244        inv.git_author_email.clone(),
245    );
246    env.insert("TASK_ID".to_string(), inv.task_id.clone());
247    env.insert(
248        "OUTPUT_FILE".to_string(),
249        inv.output_file.display().to_string(),
250    );
251
252    // Write runner script and execute it in a detached tmux session.
253    let timeout_cmd = if inv.timeout_seconds > 0 {
254        format!("timeout {}", inv.timeout_seconds)
255    } else {
256        String::new()
257    };
258    let agent_cmd = runner.build_command(
259        inv.model.as_deref(),
260        &timeout_cmd,
261        &sys_file.to_string_lossy(),
262        &msg_file.to_string_lossy(),
263        &permissions,
264    );
265
266    // Write runner script (and the agent-cmd helper script it execs under a pty)
267    // to the per-task attempt dir.
268    let script_path = attempt_dir.join("runner.sh");
269    let agent_cmd_script_path = attempt_dir.join("agent_cmd.sh");
270    let (script_content, agent_cmd_script_content) =
271        build_tmux_runner_scripts(inv, &agent_cmd, &attempt_dir)?;
272    tokio::fs::write(&agent_cmd_script_path, &agent_cmd_script_content).await?;
273    tokio::fs::write(&script_path, &script_content).await?;
274
275    // Make executable
276    #[cfg(unix)]
277    {
278        use std::os::unix::fs::PermissionsExt;
279        tokio::fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o755)).await?;
280        tokio::fs::set_permissions(
281            &agent_cmd_script_path,
282            std::fs::Permissions::from_mode(0o755),
283        )
284        .await?;
285    }
286
287    let command = format!("bash \"{}\"", script_path.display());
288
289    // Resolve GitHub token via the process-wide shared resolver (cached after first call)
290    let github_token = match crate::github::token::shared().get_token().await {
291        Ok(Some(token)) => {
292            tracing::debug!("Resolved GitHub token via TokenResolver for agent session");
293            Some(token)
294        }
295        Ok(None) => {
296            tracing::warn!(
297                "No GitHub token available; set GH_TOKEN, GITHUB_TOKEN, or configure github.token_mode"
298            );
299            None
300        }
301        Err(e) => {
302            tracing::warn!(error = %e, "Failed to resolve GitHub token");
303            None
304        }
305    };
306
307    // Inject GH_TOKEN into env so it's passed via `tmux new-session -e` along
308    // with everything else. The `-e` flag sets variables in the *initial* pane
309    // environment, so the agent process sees it immediately on startup.
310    //
311    // IMPORTANT: Do NOT use create_session_detached + set_env + create_window
312    // to inject GH_TOKEN. That approach creates a 2-window session (window 0 =
313    // idle shell, window 1 = agent). All tmux monitoring (is_session_active,
314    // capture_pane, batch_session_active) targets the session without a window
315    // index, which defaults to the *active* window — the idle shell. This
316    // breaks completion detection (shell never exits → pane_dead always 0),
317    // output capture (reads the shell prompt, not agent output), and silence
318    // detection. See the revert of PR #2903.
319    if let Some(ref token) = github_token {
320        env.insert("GH_TOKEN".to_string(), token.clone());
321    }
322
323    let env_vec: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
324
325    let session = tmux
326        .create_session(
327            &inv.repo,
328            &inv.task_id,
329            &inv.work_dir.to_string_lossy(),
330            &command,
331            env_vec.as_slice(),
332        )
333        .await?;
334
335    tracing::info!(
336        task_id = inv.task_id,
337        agent = inv.agent,
338        session = %session,
339        "agent spawned in tmux"
340    );
341
342    Ok(session)
343}
344
345/// Build the system prompt for the agent.
346pub fn build_system_prompt(
347    _task: &crate::backends::ExternalTask,
348    context: &super::context::TaskContext,
349    route_result: Option<&crate::engine::router::RouteResult>,
350    default_branch: &str,
351) -> String {
352    let mut vars = HashMap::new();
353
354    vars.insert("DEFAULT_BRANCH".to_string(), default_branch.to_string());
355
356    if let Some(rr) = route_result {
357        vars.insert("ROLE".to_string(), rr.profile.role.clone());
358
359        if !rr.profile.constraints.is_empty() {
360            let constraints = rr
361                .profile
362                .constraints
363                .iter()
364                .map(|c| format!("- {c}"))
365                .collect::<Vec<_>>()
366                .join("\n");
367            vars.insert("CONSTRAINTS".to_string(), constraints);
368        }
369    }
370
371    if !context.project_instructions.is_empty() {
372        vars.insert(
373            "PROJECT_INSTRUCTIONS".to_string(),
374            context.project_instructions.clone(),
375        );
376    }
377
378    if !context.skills_docs.is_empty() {
379        vars.insert("SKILLS_DOCS".to_string(), context.skills_docs.clone());
380    }
381
382    if !context.repo_tree.is_empty() {
383        vars.insert("REPO_TREE".to_string(), context.repo_tree.clone());
384    }
385
386    render_prompt_template(AGENT_SYSTEM_TEMPLATE, vars)
387}
388
389/// Build the agent message (task prompt).
390pub fn build_agent_message(
391    task: &crate::backends::ExternalTask,
392    context: &super::context::TaskContext,
393    attempts: u32,
394) -> String {
395    let mut vars = HashMap::new();
396    vars.insert("TASK_ID".to_string(), task.id.0.clone());
397    vars.insert("TASK_TITLE".to_string(), task.title.clone());
398    vars.insert("TASK_BODY".to_string(), task.body.clone());
399
400    if !context.task_context.is_empty() {
401        vars.insert("TASK_CONTEXT".to_string(), context.task_context.clone());
402    }
403
404    if !context.parent_context.is_empty() {
405        vars.insert("PARENT_CONTEXT".to_string(), context.parent_context.clone());
406    }
407
408    if !context.issue_comments.is_empty() {
409        vars.insert("ISSUE_COMMENTS".to_string(), context.issue_comments.clone());
410    }
411
412    if !context.pr_review_context.is_empty() {
413        vars.insert(
414            "PR_REVIEW_CONTEXT".to_string(),
415            context.pr_review_context.clone(),
416        );
417    }
418
419    if attempts > 0 && !context.git_diff.is_empty() {
420        vars.insert("GIT_DIFF".to_string(), context.git_diff.clone());
421    }
422
423    if attempts > 0 {
424        vars.insert("ATTEMPT_NUMBER".to_string(), (attempts + 1).to_string());
425    }
426
427    if !context.memory.is_empty() {
428        let mut sections = Vec::new();
429        for entry in &context.memory {
430            let mut entry_vars = HashMap::new();
431            entry_vars.insert("ATTEMPT".to_string(), entry.attempt.to_string());
432            entry_vars.insert("AGENT".to_string(), entry.agent.clone());
433
434            if let Some(ref model) = entry.model {
435                entry_vars.insert("MODEL".to_string(), model.clone());
436            }
437
438            if !entry.approach.is_empty() {
439                entry_vars.insert("APPROACH".to_string(), entry.approach.clone());
440            }
441
442            if !entry.learnings.is_empty() {
443                let learnings = entry
444                    .learnings
445                    .iter()
446                    .map(|learning| format!("- {}", learning))
447                    .collect::<Vec<_>>()
448                    .join("\n");
449                entry_vars.insert("LEARNINGS".to_string(), learnings);
450            }
451
452            if let Some(ref error) = entry.error {
453                entry_vars.insert("ERROR".to_string(), error.clone());
454            }
455
456            if !entry.files_modified.is_empty() {
457                entry_vars.insert(
458                    "FILES_MODIFIED".to_string(),
459                    entry.files_modified.join(", "),
460                );
461            }
462
463            let rendered = render_prompt_template(AGENT_MEMORY_ENTRY_TEMPLATE, entry_vars);
464            sections.push(rendered.trim().to_string());
465        }
466
467        let memory_section = sections.join("\n\n");
468        vars.insert("MEMORY_SECTION".to_string(), memory_section);
469    }
470
471    render_prompt_template(AGENT_MESSAGE_TEMPLATE, vars)
472}
473
474/// Build the review prompt for the review agent.
475///
476/// This is called when a task completes and the review agent needs to
477/// review the changes before auto-merge.
478pub fn build_review_prompt(
479    task: &crate::backends::ExternalTask,
480    agent_summary: &str,
481    git_diff: &str,
482    git_log: &str,
483    default_branch: &str,
484    pr_number: u64,
485) -> String {
486    let mut vars = HashMap::new();
487    vars.insert("TASK_ID".to_string(), task.id.0.clone());
488    vars.insert("TASK_TITLE".to_string(), task.title.clone());
489    vars.insert("TASK_BODY".to_string(), task.body.clone());
490    vars.insert("DEFAULT_BRANCH".to_string(), default_branch.to_string());
491    vars.insert("PR_NUMBER".to_string(), pr_number.to_string());
492
493    if !agent_summary.is_empty() {
494        vars.insert("AGENT_SUMMARY".to_string(), agent_summary.to_string());
495    }
496
497    if !git_diff.is_empty() {
498        vars.insert("GIT_DIFF".to_string(), git_diff.to_string());
499    }
500
501    if !git_log.is_empty() {
502        vars.insert("GIT_LOG".to_string(), git_log.to_string());
503    }
504
505    render_prompt_template(REVIEW_PROMPT_TEMPLATE, vars)
506}
507
508/// Build the runner shell script contents for testing and inspection.
509///
510/// IMPORTANT: this must not embed secrets (GH_TOKEN) into the script.
511#[allow(dead_code)]
512pub fn build_runner_script(inv: &AgentInvocation) -> anyhow::Result<String> {
513    // Prepare attempt directory and prompt files
514    let attempt_dir = crate::home::task_attempt_dir(&inv.repo, &inv.task_id, inv.attempt)?;
515    std::fs::create_dir_all(&attempt_dir)?;
516
517    let sys_file = attempt_dir.join("prompt-sys.md");
518    let msg_file = attempt_dir.join("prompt-msg.md");
519    std::fs::write(&sys_file, &inv.system_prompt)?;
520    std::fs::write(&msg_file, &inv.agent_message)?;
521
522    build_runner_script_in_dir(inv, &attempt_dir)
523}
524
525/// Inner script builder given a pre-created attempt directory.
526/// Separated from `build_runner_script` so tests can pass a temp dir and
527/// avoid racing with the parallel binary/lib test that shares `~/.orch/state/`.
528fn build_runner_script_in_dir(
529    inv: &AgentInvocation,
530    attempt_dir: &std::path::Path,
531) -> anyhow::Result<String> {
532    let sys_file = attempt_dir.join("prompt-sys.md");
533    let msg_file = attempt_dir.join("prompt-msg.md");
534
535    // Build unified permission rules and sys content (minimal translation)
536    let mut permissions = super::agents::PermissionRules::from_config();
537    if !inv.disallowed_tools.is_empty() {
538        for tool in &inv.disallowed_tools {
539            if !permissions.disallowed_tools.contains(tool) {
540                permissions.disallowed_tools.push(tool.clone());
541            }
542        }
543    }
544    permissions.allowed_edit_paths.push(inv.work_dir.clone());
545
546    // Build agent command using per-agent runner
547    let runner = super::agents::get_runner(&inv.agent);
548    let timeout_cmd = if inv.timeout_seconds > 0 {
549        format!("timeout {}", inv.timeout_seconds)
550    } else {
551        String::new()
552    };
553    let agent_cmd = runner.build_command(
554        inv.model.as_deref(),
555        &timeout_cmd,
556        &sys_file.to_string_lossy(),
557        &msg_file.to_string_lossy(),
558        &permissions,
559    );
560
561    let status_file = attempt_dir.join("exit.txt");
562    let stderr_file = attempt_dir.join("stderr.txt");
563
564    let sq_status = shell_single_quote(&status_file.display().to_string());
565    let sq_stderr = shell_single_quote(&stderr_file.display().to_string());
566    let sq_out = shell_single_quote(&inv.output_file.display().to_string());
567    let sq_work_dir = shell_single_quote(&inv.work_dir.to_string_lossy());
568    let script = format!(
569        "#!/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",
570        agent_cmd = agent_cmd,
571    );
572
573    Ok(script)
574}
575
576/// Build the system prompt for the review agent.
577pub fn review_system_prompt() -> String {
578    render_prompt_template(REVIEW_SYSTEM_TEMPLATE, HashMap::new())
579}
580
581#[cfg(test)]
582mod tests {
583    use super::*;
584    use std::path::PathBuf;
585
586    fn test_invocation(task_id: &str) -> AgentInvocation {
587        AgentInvocation {
588            agent: "claude".to_string(),
589            model: None,
590            work_dir: PathBuf::from("/tmp"),
591            system_prompt: "test system prompt".to_string(),
592            agent_message: "test message".to_string(),
593            task_id: task_id.to_string(),
594            disallowed_tools: vec![],
595            git_author_name: "Test Bot".to_string(),
596            git_author_email: "bot@example.com".to_string(),
597            output_file: PathBuf::from("/tmp/test-output.json"),
598            timeout_seconds: 0,
599            repo: "orch-runner-test/token-check".to_string(),
600            attempt: 1,
601        }
602    }
603
604    #[test]
605    fn env_map_does_not_contain_token_value_as_string() {
606        // Verify that the token resolution returns the raw token value,
607        // not a string that would need to be embedded in a script.
608        // The key security guarantee: we're NOT building a runner script anymore.
609        // Tokens are injected directly into tmux session environment.
610        let resolver = crate::github::token::TokenResolver::default_env();
611        let token = resolver.get_token_sync().ok().flatten().unwrap_or_default();
612        // Token can be empty (no token configured) or a valid token string.
613        // The important thing is it's passed via tmux -e flag, not written to disk.
614        assert!(
615            token.is_empty() || token.len() > 10,
616            "resolve_token should return empty string or a valid token"
617        );
618    }
619
620    #[test]
621    fn spawn_in_tmux_rejects_invalid_env() {
622        // This test verifies the function signature and basic behavior
623        // without actually spawning a tmux session.
624        // The key security guarantee: tokens are passed via tmux -e flag,
625        // not written to any file on disk.
626        let inv = test_invocation("env-test");
627        // Just verify the invocation can be created without panicking
628        assert_eq!(inv.task_id, "env-test");
629    }
630
631    #[test]
632    fn build_runner_script_persists_exit_status_on_failure() {
633        // Use a temp dir so lib and bin compilation units don't race on ~/.orch/state/.
634        let tmp = tempfile::TempDir::new().expect("temp dir");
635        let inv = test_invocation("runner-status-test");
636        let script =
637            build_runner_script_in_dir(&inv, tmp.path()).expect("runner script should build");
638
639        assert!(script
640            .contains("trap 'status=$?; printf \"%s\\n\" \"$status\" > \"$status_file\"' EXIT"));
641        assert!(script.contains("set +e"));
642    }
643
644    #[test]
645    fn build_runner_script_reports_missing_worktree() {
646        // Use a temp dir so lib and bin compilation units don't race on ~/.orch/state/.
647        let tmp = tempfile::TempDir::new().expect("temp dir");
648        let inv = test_invocation("runner-missing-worktree-test");
649        let script =
650            build_runner_script_in_dir(&inv, tmp.path()).expect("runner script should build");
651
652        assert!(script.contains("worktree directory does not exist: '/tmp'"));
653        assert!(script.contains("stderr_file='"));
654    }
655
656    #[test]
657    fn tmux_runner_script_runs_agent_under_pty_not_a_plain_pipe() {
658        // Regression test for #3554: piping the agent binary's stdout straight
659        // into `tee` leaves it non-tty, which makes many CLI runtimes switch to
660        // full block-buffering and can starve the tmux pane long enough to trip
661        // silence detection even while the agent is actively working. The agent
662        // command must run under `script` (a real pty) before hitting `tee`.
663        let tmp = tempfile::TempDir::new().expect("temp dir");
664        let inv = test_invocation("pty-wrap-test");
665        let (runner_script, agent_cmd_script) =
666            build_tmux_runner_scripts(&inv, "echo hello", tmp.path())
667                .expect("runner scripts should build");
668
669        assert!(
670            runner_script.contains("script -q /dev/null /bin/bash"),
671            "agent must be exec'd under a pty via `script`, got: {runner_script}"
672        );
673        assert!(
674            runner_script.contains("| tee"),
675            "output must still be tee'd to the output file, got: {runner_script}"
676        );
677        assert!(
678            !runner_script.contains("echo hello 2>"),
679            "the raw agent command must not be piped directly into tee anymore, got: {runner_script}"
680        );
681        assert!(
682            agent_cmd_script.contains("echo hello 2>"),
683            "the agent command should run (with stderr split to a file) inside the pty-wrapped helper script, got: {agent_cmd_script}"
684        );
685    }
686
687    #[test]
688    fn tmux_runner_script_captures_agent_exit_status_via_pipestatus() {
689        let tmp = tempfile::TempDir::new().expect("temp dir");
690        let inv = test_invocation("pty-exit-status-test");
691        let (runner_script, _) = build_tmux_runner_scripts(&inv, "echo hello", tmp.path())
692            .expect("runner scripts should build");
693
694        assert!(runner_script.contains("CMD_STATUS=${PIPESTATUS[0]:-0}"));
695        assert!(runner_script.contains("exit $CMD_STATUS"));
696    }
697
698    #[test]
699    fn tmux_runner_script_disables_commit_signing() {
700        // Locked signing agent must not fail `git commit` inside agent sessions
701        let tmp = tempfile::TempDir::new().expect("temp dir");
702        let inv = test_invocation("gpgsign-test");
703        let (runner_script, _) = build_tmux_runner_scripts(&inv, "echo hello", tmp.path())
704            .expect("runner scripts should build");
705
706        assert!(runner_script.contains("GIT_CONFIG_KEY_0=\"commit.gpgsign\""));
707        assert!(runner_script.contains("GIT_CONFIG_VALUE_0=\"false\""));
708    }
709
710    #[test]
711    fn tmux_runner_script_writes_executable_agent_cmd_helper() {
712        let tmp = tempfile::TempDir::new().expect("temp dir");
713        let inv = test_invocation("pty-helper-path-test");
714        let (runner_script, agent_cmd_script) =
715            build_tmux_runner_scripts(&inv, "echo hello", tmp.path())
716                .expect("runner scripts should build");
717
718        let agent_cmd_script_path = tmp.path().join("agent_cmd.sh");
719        assert!(
720            runner_script.contains(&agent_cmd_script_path.display().to_string()),
721            "runner script must reference the agent_cmd.sh helper path, got: {runner_script}"
722        );
723        assert!(agent_cmd_script.starts_with("#!/usr/bin/env bash\n"));
724    }
725
726    #[test]
727    fn build_runner_script_escapes_shell_injection_in_paths() {
728        let tmp = tempfile::TempDir::new().expect("temp dir");
729        let mut inv = test_invocation("injection-test");
730        inv.work_dir = std::path::PathBuf::from("/tmp/$(whoami)/workspace");
731        inv.output_file = std::path::PathBuf::from("/tmp/evil`id`.json");
732        let script =
733            build_runner_script_in_dir(&inv, tmp.path()).expect("runner script should build");
734
735        // Values must be single-quoted, neutralizing $() and backticks
736        assert!(
737            script.contains("'/tmp/$(whoami)/workspace'"),
738            "work_dir with $() must be single-quoted: {script}"
739        );
740        assert!(
741            script.contains("'/tmp/evil`id`.json'"),
742            "output_file with backticks must be single-quoted: {script}"
743        );
744    }
745
746    #[test]
747    fn review_prompt_forbids_ending_turn_on_pending_ci_status() {
748        // Regression test for #3453: a review agent must not stop with a
749        // prose "CI is still running" status update and leave the turn
750        // without JSON. The prompt must explicitly route PENDING/NOT RUN CI
751        // through the local-checks fallback and into the same-turn JSON output.
752        let task = crate::backends::ExternalTask {
753            id: crate::backends::ExternalId("155460".to_string()),
754            title: "test task".to_string(),
755            body: "test body".to_string(),
756            state: "open".to_string(),
757            labels: vec![],
758            author: "tester".to_string(),
759            created_at: "2026-01-01T00:00:00Z".to_string(),
760            updated_at: "2026-01-01T00:00:00Z".to_string(),
761            url: "https://example.com/1".to_string(),
762        };
763        let prompt = build_review_prompt(&task, "", "", "", "main", 1);
764
765        assert!(
766            prompt.contains("Pending CI is never a terminal review state"),
767            "prompt must state pending CI is not terminal: {prompt}"
768        );
769        assert!(
770            prompt.contains("Never end your turn with a CI-waiting status update"),
771            "prompt must forbid ending the turn on a CI-waiting status update: {prompt}"
772        );
773    }
774
775    #[test]
776    fn shell_single_quote_escapes_embedded_quotes() {
777        use crate::engine::runner::agents::shell_single_quote;
778
779        assert_eq!(shell_single_quote("hello"), "'hello'");
780        assert_eq!(shell_single_quote("O'Brien"), "'O'\\''Brien'");
781        assert_eq!(shell_single_quote("$(rm -rf /)"), "'$(rm -rf /)'");
782        assert_eq!(shell_single_quote("`whoami`"), "'`whoami`'");
783        assert_eq!(shell_single_quote("a\"b"), "'a\"b'");
784        assert_eq!(shell_single_quote("$HOME"), "'$HOME'");
785    }
786}