use crate::engine::runner::agents::shell_single_quote;
use crate::template::render_template_str;
use crate::tmux::TmuxManager;
use std::collections::BTreeSet;
use std::collections::HashMap;
use std::path::PathBuf;
const AGENT_SYSTEM_TEMPLATE: &str = include_str!("../../../prompts/agent_system.md");
const AGENT_MESSAGE_TEMPLATE: &str = include_str!("../../../prompts/agent_message.md");
const AGENT_MEMORY_ENTRY_TEMPLATE: &str = include_str!("../../../prompts/agent_memory_entry.md");
const ALLOWED_TOOLS_TEMPLATE: &str = include_str!("../../../prompts/allowed_tools.md");
const REVIEW_PROMPT_TEMPLATE: &str = include_str!("../../../prompts/review_task.md");
const REVIEW_SYSTEM_TEMPLATE: &str = include_str!("../../../prompts/review_system.md");
async fn resolve_git_dir_arg(
work_dir: &std::path::Path,
arg: &str,
) -> anyhow::Result<Option<PathBuf>> {
let output = tokio::process::Command::new("git")
.args(["rev-parse", arg])
.current_dir(work_dir)
.output()
.await?;
if !output.status.success() {
return Ok(None);
}
let raw = String::from_utf8_lossy(&output.stdout).trim().to_string();
if raw.is_empty() {
return Ok(None);
}
let candidate = if std::path::Path::new(&raw).is_absolute() {
PathBuf::from(&raw)
} else {
work_dir.join(&raw)
};
let canonical = tokio::fs::canonicalize(&candidate)
.await
.unwrap_or(candidate);
Ok(Some(canonical))
}
fn render_prompt_template(template: &str, vars: HashMap<String, String>) -> String {
match render_template_str(template, &vars) {
Ok(rendered) => rendered,
Err(err) => {
tracing::error!(error = %err, "failed to render prompt template");
template.to_string()
}
}
}
pub struct AgentInvocation {
pub agent: String,
pub model: Option<String>,
pub work_dir: PathBuf,
pub system_prompt: String,
pub agent_message: String,
pub task_id: String,
pub disallowed_tools: Vec<String>,
pub git_author_name: String,
pub git_author_email: String,
pub output_file: PathBuf,
pub timeout_seconds: u64,
pub repo: String,
pub attempt: u32,
}
fn build_tmux_runner_scripts(
inv: &AgentInvocation,
agent_cmd: &str,
attempt_dir: &std::path::Path,
) -> anyhow::Result<(String, String)> {
let status_file = attempt_dir.join("exit.txt");
let stderr_file = attempt_dir.join("stderr.txt");
let agent_cmd_script_path = attempt_dir.join("agent_cmd.sh");
let sq_git_name = shell_single_quote(&inv.git_author_name);
let sq_git_email = shell_single_quote(&inv.git_author_email);
let sq_task_id = shell_single_quote(&inv.task_id);
let sq_output_file = shell_single_quote(&inv.output_file.display().to_string());
let sq_work_dir = shell_single_quote(&inv.work_dir.display().to_string());
let sq_status_file = shell_single_quote(&status_file.display().to_string());
let sq_stderr_file = shell_single_quote(&stderr_file.display().to_string());
let sq_agent_cmd_script = shell_single_quote(&agent_cmd_script_path.display().to_string());
let agent_cmd_script = format!("#!/usr/bin/env bash\n{agent_cmd} 2>{sq_stderr_file}\n");
let runner_script = format!(
r#"#!/usr/bin/env bash
set -euo pipefail
status_file={sq_status_file}
stderr_file={sq_stderr_file}
trap 'status=$?; printf "%s\n" "$status" > "$status_file"' EXIT
# Environment — ~/.path and ~/.private are loaded by orch at startup into the process env
export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"
export GIT_AUTHOR_NAME={sq_git_name}
export GIT_COMMITTER_NAME={sq_git_name}
export GIT_AUTHOR_EMAIL={sq_git_email}
export GIT_COMMITTER_EMAIL={sq_git_email}
# Env config overrides the operator's global commit.gpgsign, the signing agent
# (1Password etc) is often locked in unattended sessions and blocks `git commit`
export GIT_CONFIG_COUNT="1"
export GIT_CONFIG_KEY_0="commit.gpgsign"
export GIT_CONFIG_VALUE_0="false"
export TASK_ID={sq_task_id}
export OUTPUT_FILE={sq_output_file}
unset CLAUDECODE # allow nested claude invocations from orch
cd {sq_work_dir} || {{
printf '%s\n' "worktree directory does not exist: {sq_work_dir}" > "$stderr_file"
exit 1
}}
# Run agent under a pty (via `script`) so its stdout is a tty, not a plain pipe —
# many CLI runtimes silently switch to full block-buffering when stdout isn't a
# tty, which can delay pane output long enough to trip silence detection even
# while the agent is actively streaming. Tee to both output file and terminal
# (tmux pane) for live streaming.
set +e
script -q /dev/null /bin/bash {sq_agent_cmd_script} | tee {sq_output_file}
CMD_STATUS=${{PIPESTATUS[0]:-0}}
set -e
exit $CMD_STATUS
"#,
);
Ok((runner_script, agent_cmd_script))
}
pub async fn spawn_in_tmux(tmux: &TmuxManager, inv: &AgentInvocation) -> anyhow::Result<String> {
let attempt_dir =
crate::home::task_attempt_dir_async(&inv.repo, &inv.task_id, inv.attempt).await?;
let sys_file = attempt_dir.join("prompt-sys.md");
let msg_file = attempt_dir.join("prompt-msg.md");
let mut permissions = super::agents::PermissionRules::from_config();
if !inv.disallowed_tools.is_empty() {
for tool in &inv.disallowed_tools {
if !permissions.disallowed_tools.contains(tool) {
permissions.disallowed_tools.push(tool.clone());
}
}
}
permissions.allowed_edit_paths.push(inv.work_dir.clone());
if inv.agent == "codex" {
let mut extra_dirs = BTreeSet::new();
for (arg, label) in [
("--git-common-dir", "git common dir"),
("--git-dir", "git dir"),
] {
match resolve_git_dir_arg(&inv.work_dir, arg).await {
Ok(Some(dir)) if !dir.starts_with(&inv.work_dir) => {
extra_dirs.insert(dir);
}
Ok(_) => {}
Err(e) => {
tracing::debug!(
work_dir = %inv.work_dir.display(),
arg,
error = %e,
"failed to resolve {}; skipping --add-dir",
label
);
}
}
}
permissions.extra_writable_dirs.extend(extra_dirs);
}
let sys_content = if !permissions.allowed_tools.is_empty() {
let tools_list = permissions
.allowed_tools
.iter()
.map(|t| format!("- {t}"))
.collect::<Vec<_>>()
.join("\n");
let mut vars = HashMap::new();
vars.insert("TOOLS_LIST".to_string(), tools_list);
let tools_prompt = render_prompt_template(ALLOWED_TOOLS_TEMPLATE, vars);
format!("{}\n\n{}", inv.system_prompt, tools_prompt)
} else {
inv.system_prompt.clone()
};
tokio::fs::write(&sys_file, &sys_content).await?;
tokio::fs::write(&msg_file, &inv.agent_message).await?;
let runner = super::agents::get_runner(&inv.agent);
let mut env = std::collections::HashMap::new();
env.insert("GIT_AUTHOR_NAME".to_string(), inv.git_author_name.clone());
env.insert(
"GIT_COMMITTER_NAME".to_string(),
inv.git_author_name.clone(),
);
env.insert("GIT_AUTHOR_EMAIL".to_string(), inv.git_author_email.clone());
env.insert(
"GIT_COMMITTER_EMAIL".to_string(),
inv.git_author_email.clone(),
);
env.insert("TASK_ID".to_string(), inv.task_id.clone());
env.insert(
"OUTPUT_FILE".to_string(),
inv.output_file.display().to_string(),
);
let timeout_cmd = if inv.timeout_seconds > 0 {
format!("timeout {}", inv.timeout_seconds)
} else {
String::new()
};
let agent_cmd = runner.build_command(
inv.model.as_deref(),
&timeout_cmd,
&sys_file.to_string_lossy(),
&msg_file.to_string_lossy(),
&permissions,
);
let script_path = attempt_dir.join("runner.sh");
let agent_cmd_script_path = attempt_dir.join("agent_cmd.sh");
let (script_content, agent_cmd_script_content) =
build_tmux_runner_scripts(inv, &agent_cmd, &attempt_dir)?;
tokio::fs::write(&agent_cmd_script_path, &agent_cmd_script_content).await?;
tokio::fs::write(&script_path, &script_content).await?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
tokio::fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o755)).await?;
tokio::fs::set_permissions(
&agent_cmd_script_path,
std::fs::Permissions::from_mode(0o755),
)
.await?;
}
let command = format!("bash \"{}\"", script_path.display());
let github_token = match crate::github::token::shared().get_token().await {
Ok(Some(token)) => {
tracing::debug!("Resolved GitHub token via TokenResolver for agent session");
Some(token)
}
Ok(None) => {
tracing::warn!(
"No GitHub token available; set GH_TOKEN, GITHUB_TOKEN, or configure github.token_mode"
);
None
}
Err(e) => {
tracing::warn!(error = %e, "Failed to resolve GitHub token");
None
}
};
if let Some(ref token) = github_token {
env.insert("GH_TOKEN".to_string(), token.clone());
}
let env_vec: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
let session = tmux
.create_session(
&inv.repo,
&inv.task_id,
&inv.work_dir.to_string_lossy(),
&command,
env_vec.as_slice(),
)
.await?;
tracing::info!(
task_id = inv.task_id,
agent = inv.agent,
session = %session,
"agent spawned in tmux"
);
Ok(session)
}
pub fn build_system_prompt(
_task: &crate::backends::ExternalTask,
context: &super::context::TaskContext,
route_result: Option<&crate::engine::router::RouteResult>,
default_branch: &str,
) -> String {
let mut vars = HashMap::new();
vars.insert("DEFAULT_BRANCH".to_string(), default_branch.to_string());
if let Some(rr) = route_result {
vars.insert("ROLE".to_string(), rr.profile.role.clone());
if !rr.profile.constraints.is_empty() {
let constraints = rr
.profile
.constraints
.iter()
.map(|c| format!("- {c}"))
.collect::<Vec<_>>()
.join("\n");
vars.insert("CONSTRAINTS".to_string(), constraints);
}
}
if !context.project_instructions.is_empty() {
vars.insert(
"PROJECT_INSTRUCTIONS".to_string(),
context.project_instructions.clone(),
);
}
if !context.skills_docs.is_empty() {
vars.insert("SKILLS_DOCS".to_string(), context.skills_docs.clone());
}
if !context.repo_tree.is_empty() {
vars.insert("REPO_TREE".to_string(), context.repo_tree.clone());
}
render_prompt_template(AGENT_SYSTEM_TEMPLATE, vars)
}
pub fn build_agent_message(
task: &crate::backends::ExternalTask,
context: &super::context::TaskContext,
attempts: u32,
) -> String {
let mut vars = HashMap::new();
vars.insert("TASK_ID".to_string(), task.id.0.clone());
vars.insert("TASK_TITLE".to_string(), task.title.clone());
vars.insert("TASK_BODY".to_string(), task.body.clone());
if !context.task_context.is_empty() {
vars.insert("TASK_CONTEXT".to_string(), context.task_context.clone());
}
if !context.parent_context.is_empty() {
vars.insert("PARENT_CONTEXT".to_string(), context.parent_context.clone());
}
if !context.issue_comments.is_empty() {
vars.insert("ISSUE_COMMENTS".to_string(), context.issue_comments.clone());
}
if !context.pr_review_context.is_empty() {
vars.insert(
"PR_REVIEW_CONTEXT".to_string(),
context.pr_review_context.clone(),
);
}
if attempts > 0 && !context.git_diff.is_empty() {
vars.insert("GIT_DIFF".to_string(), context.git_diff.clone());
}
if attempts > 0 {
vars.insert("ATTEMPT_NUMBER".to_string(), (attempts + 1).to_string());
}
if !context.memory.is_empty() {
let mut sections = Vec::new();
for entry in &context.memory {
let mut entry_vars = HashMap::new();
entry_vars.insert("ATTEMPT".to_string(), entry.attempt.to_string());
entry_vars.insert("AGENT".to_string(), entry.agent.clone());
if let Some(ref model) = entry.model {
entry_vars.insert("MODEL".to_string(), model.clone());
}
if !entry.approach.is_empty() {
entry_vars.insert("APPROACH".to_string(), entry.approach.clone());
}
if !entry.learnings.is_empty() {
let learnings = entry
.learnings
.iter()
.map(|learning| format!("- {}", learning))
.collect::<Vec<_>>()
.join("\n");
entry_vars.insert("LEARNINGS".to_string(), learnings);
}
if let Some(ref error) = entry.error {
entry_vars.insert("ERROR".to_string(), error.clone());
}
if !entry.files_modified.is_empty() {
entry_vars.insert(
"FILES_MODIFIED".to_string(),
entry.files_modified.join(", "),
);
}
let rendered = render_prompt_template(AGENT_MEMORY_ENTRY_TEMPLATE, entry_vars);
sections.push(rendered.trim().to_string());
}
let memory_section = sections.join("\n\n");
vars.insert("MEMORY_SECTION".to_string(), memory_section);
}
render_prompt_template(AGENT_MESSAGE_TEMPLATE, vars)
}
pub fn build_review_prompt(
task: &crate::backends::ExternalTask,
agent_summary: &str,
git_diff: &str,
git_log: &str,
default_branch: &str,
pr_number: u64,
) -> String {
let mut vars = HashMap::new();
vars.insert("TASK_ID".to_string(), task.id.0.clone());
vars.insert("TASK_TITLE".to_string(), task.title.clone());
vars.insert("TASK_BODY".to_string(), task.body.clone());
vars.insert("DEFAULT_BRANCH".to_string(), default_branch.to_string());
vars.insert("PR_NUMBER".to_string(), pr_number.to_string());
if !agent_summary.is_empty() {
vars.insert("AGENT_SUMMARY".to_string(), agent_summary.to_string());
}
if !git_diff.is_empty() {
vars.insert("GIT_DIFF".to_string(), git_diff.to_string());
}
if !git_log.is_empty() {
vars.insert("GIT_LOG".to_string(), git_log.to_string());
}
render_prompt_template(REVIEW_PROMPT_TEMPLATE, vars)
}
#[allow(dead_code)]
pub fn build_runner_script(inv: &AgentInvocation) -> anyhow::Result<String> {
let attempt_dir = crate::home::task_attempt_dir(&inv.repo, &inv.task_id, inv.attempt)?;
std::fs::create_dir_all(&attempt_dir)?;
let sys_file = attempt_dir.join("prompt-sys.md");
let msg_file = attempt_dir.join("prompt-msg.md");
std::fs::write(&sys_file, &inv.system_prompt)?;
std::fs::write(&msg_file, &inv.agent_message)?;
build_runner_script_in_dir(inv, &attempt_dir)
}
fn build_runner_script_in_dir(
inv: &AgentInvocation,
attempt_dir: &std::path::Path,
) -> anyhow::Result<String> {
let sys_file = attempt_dir.join("prompt-sys.md");
let msg_file = attempt_dir.join("prompt-msg.md");
let mut permissions = super::agents::PermissionRules::from_config();
if !inv.disallowed_tools.is_empty() {
for tool in &inv.disallowed_tools {
if !permissions.disallowed_tools.contains(tool) {
permissions.disallowed_tools.push(tool.clone());
}
}
}
permissions.allowed_edit_paths.push(inv.work_dir.clone());
let runner = super::agents::get_runner(&inv.agent);
let timeout_cmd = if inv.timeout_seconds > 0 {
format!("timeout {}", inv.timeout_seconds)
} else {
String::new()
};
let agent_cmd = runner.build_command(
inv.model.as_deref(),
&timeout_cmd,
&sys_file.to_string_lossy(),
&msg_file.to_string_lossy(),
&permissions,
);
let status_file = attempt_dir.join("exit.txt");
let stderr_file = attempt_dir.join("stderr.txt");
let sq_status = shell_single_quote(&status_file.display().to_string());
let sq_stderr = shell_single_quote(&stderr_file.display().to_string());
let sq_out = shell_single_quote(&inv.output_file.display().to_string());
let sq_work_dir = shell_single_quote(&inv.work_dir.to_string_lossy());
let script = format!(
"#!/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",
agent_cmd = agent_cmd,
);
Ok(script)
}
pub fn review_system_prompt() -> String {
render_prompt_template(REVIEW_SYSTEM_TEMPLATE, HashMap::new())
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn test_invocation(task_id: &str) -> AgentInvocation {
AgentInvocation {
agent: "claude".to_string(),
model: None,
work_dir: PathBuf::from("/tmp"),
system_prompt: "test system prompt".to_string(),
agent_message: "test message".to_string(),
task_id: task_id.to_string(),
disallowed_tools: vec![],
git_author_name: "Test Bot".to_string(),
git_author_email: "bot@example.com".to_string(),
output_file: PathBuf::from("/tmp/test-output.json"),
timeout_seconds: 0,
repo: "orch-runner-test/token-check".to_string(),
attempt: 1,
}
}
#[test]
fn env_map_does_not_contain_token_value_as_string() {
let resolver = crate::github::token::TokenResolver::default_env();
let token = resolver.get_token_sync().ok().flatten().unwrap_or_default();
assert!(
token.is_empty() || token.len() > 10,
"resolve_token should return empty string or a valid token"
);
}
#[test]
fn spawn_in_tmux_rejects_invalid_env() {
let inv = test_invocation("env-test");
assert_eq!(inv.task_id, "env-test");
}
#[test]
fn build_runner_script_persists_exit_status_on_failure() {
let tmp = tempfile::TempDir::new().expect("temp dir");
let inv = test_invocation("runner-status-test");
let script =
build_runner_script_in_dir(&inv, tmp.path()).expect("runner script should build");
assert!(script
.contains("trap 'status=$?; printf \"%s\\n\" \"$status\" > \"$status_file\"' EXIT"));
assert!(script.contains("set +e"));
}
#[test]
fn build_runner_script_reports_missing_worktree() {
let tmp = tempfile::TempDir::new().expect("temp dir");
let inv = test_invocation("runner-missing-worktree-test");
let script =
build_runner_script_in_dir(&inv, tmp.path()).expect("runner script should build");
assert!(script.contains("worktree directory does not exist: '/tmp'"));
assert!(script.contains("stderr_file='"));
}
#[test]
fn tmux_runner_script_runs_agent_under_pty_not_a_plain_pipe() {
let tmp = tempfile::TempDir::new().expect("temp dir");
let inv = test_invocation("pty-wrap-test");
let (runner_script, agent_cmd_script) =
build_tmux_runner_scripts(&inv, "echo hello", tmp.path())
.expect("runner scripts should build");
assert!(
runner_script.contains("script -q /dev/null /bin/bash"),
"agent must be exec'd under a pty via `script`, got: {runner_script}"
);
assert!(
runner_script.contains("| tee"),
"output must still be tee'd to the output file, got: {runner_script}"
);
assert!(
!runner_script.contains("echo hello 2>"),
"the raw agent command must not be piped directly into tee anymore, got: {runner_script}"
);
assert!(
agent_cmd_script.contains("echo hello 2>"),
"the agent command should run (with stderr split to a file) inside the pty-wrapped helper script, got: {agent_cmd_script}"
);
}
#[test]
fn tmux_runner_script_captures_agent_exit_status_via_pipestatus() {
let tmp = tempfile::TempDir::new().expect("temp dir");
let inv = test_invocation("pty-exit-status-test");
let (runner_script, _) = build_tmux_runner_scripts(&inv, "echo hello", tmp.path())
.expect("runner scripts should build");
assert!(runner_script.contains("CMD_STATUS=${PIPESTATUS[0]:-0}"));
assert!(runner_script.contains("exit $CMD_STATUS"));
}
#[test]
fn tmux_runner_script_disables_commit_signing() {
let tmp = tempfile::TempDir::new().expect("temp dir");
let inv = test_invocation("gpgsign-test");
let (runner_script, _) = build_tmux_runner_scripts(&inv, "echo hello", tmp.path())
.expect("runner scripts should build");
assert!(runner_script.contains("GIT_CONFIG_KEY_0=\"commit.gpgsign\""));
assert!(runner_script.contains("GIT_CONFIG_VALUE_0=\"false\""));
}
#[test]
fn tmux_runner_script_writes_executable_agent_cmd_helper() {
let tmp = tempfile::TempDir::new().expect("temp dir");
let inv = test_invocation("pty-helper-path-test");
let (runner_script, agent_cmd_script) =
build_tmux_runner_scripts(&inv, "echo hello", tmp.path())
.expect("runner scripts should build");
let agent_cmd_script_path = tmp.path().join("agent_cmd.sh");
assert!(
runner_script.contains(&agent_cmd_script_path.display().to_string()),
"runner script must reference the agent_cmd.sh helper path, got: {runner_script}"
);
assert!(agent_cmd_script.starts_with("#!/usr/bin/env bash\n"));
}
#[test]
fn build_runner_script_escapes_shell_injection_in_paths() {
let tmp = tempfile::TempDir::new().expect("temp dir");
let mut inv = test_invocation("injection-test");
inv.work_dir = std::path::PathBuf::from("/tmp/$(whoami)/workspace");
inv.output_file = std::path::PathBuf::from("/tmp/evil`id`.json");
let script =
build_runner_script_in_dir(&inv, tmp.path()).expect("runner script should build");
assert!(
script.contains("'/tmp/$(whoami)/workspace'"),
"work_dir with $() must be single-quoted: {script}"
);
assert!(
script.contains("'/tmp/evil`id`.json'"),
"output_file with backticks must be single-quoted: {script}"
);
}
#[test]
fn review_prompt_forbids_ending_turn_on_pending_ci_status() {
let task = crate::backends::ExternalTask {
id: crate::backends::ExternalId("155460".to_string()),
title: "test task".to_string(),
body: "test body".to_string(),
state: "open".to_string(),
labels: vec![],
author: "tester".to_string(),
created_at: "2026-01-01T00:00:00Z".to_string(),
updated_at: "2026-01-01T00:00:00Z".to_string(),
url: "https://example.com/1".to_string(),
};
let prompt = build_review_prompt(&task, "", "", "", "main", 1);
assert!(
prompt.contains("Pending CI is never a terminal review state"),
"prompt must state pending CI is not terminal: {prompt}"
);
assert!(
prompt.contains("Never end your turn with a CI-waiting status update"),
"prompt must forbid ending the turn on a CI-waiting status update: {prompt}"
);
}
#[test]
fn shell_single_quote_escapes_embedded_quotes() {
use crate::engine::runner::agents::shell_single_quote;
assert_eq!(shell_single_quote("hello"), "'hello'");
assert_eq!(shell_single_quote("O'Brien"), "'O'\\''Brien'");
assert_eq!(shell_single_quote("$(rm -rf /)"), "'$(rm -rf /)'");
assert_eq!(shell_single_quote("`whoami`"), "'`whoami`'");
assert_eq!(shell_single_quote("a\"b"), "'a\"b'");
assert_eq!(shell_single_quote("$HOME"), "'$HOME'");
}
}