1use 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
20async 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 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
64pub struct AgentInvocation {
66 pub agent: String,
68 pub model: Option<String>,
70 pub work_dir: PathBuf,
72 pub system_prompt: String,
74 pub agent_message: String,
76 pub task_id: String,
78 pub disallowed_tools: Vec<String>,
80 pub git_author_name: String,
82 pub git_author_email: String,
84 pub output_file: PathBuf,
86 pub timeout_seconds: u64,
88 pub repo: String,
90 pub attempt: u32,
92}
93
94fn 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 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
163pub async fn spawn_in_tmux(tmux: &TmuxManager, inv: &AgentInvocation) -> anyhow::Result<String> {
167 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 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 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 let runner = super::agents::get_runner(&inv.agent);
233
234 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 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 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 #[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 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 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
345pub 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
389pub 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
474pub 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#[allow(dead_code)]
512pub fn build_runner_script(inv: &AgentInvocation) -> anyhow::Result<String> {
513 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
525fn 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 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 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
576pub 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 let resolver = crate::github::token::TokenResolver::default_env();
611 let token = resolver.get_token_sync().ok().flatten().unwrap_or_default();
612 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 let inv = test_invocation("env-test");
627 assert_eq!(inv.task_id, "env-test");
629 }
630
631 #[test]
632 fn build_runner_script_persists_exit_status_on_failure() {
633 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 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 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 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 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 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}