Skip to main content

imp_core/tools/
bash.rs

1use std::process::Stdio;
2use std::sync::OnceLock;
3
4use async_trait::async_trait;
5use regex::Regex;
6use serde_json::{json, Value};
7use tokio::io::{AsyncBufReadExt, BufReader};
8use tokio::process::Command;
9
10use imp_llm::auth::AuthStore;
11
12use super::{
13    truncate_head, truncate_tail, Tool, ToolContext, ToolOutput, ToolUpdate, TruncationResult,
14};
15use crate::error::{Error, Result};
16
17const DEFAULT_TIMEOUT_SECS: u64 = 30;
18const MAX_OUTPUT_LINES: usize = 2000;
19const MAX_OUTPUT_BYTES: usize = 50 * 1024;
20
21const SECRET_REDACTION: &str = "[REDACTED_SECRET]";
22
23#[derive(Debug, Clone, PartialEq, Eq)]
24struct RequestedSecret {
25    name: String,
26}
27
28struct ResolvedSecretEnvBinding {
29    secret_name: String,
30    env: String,
31    value: String,
32}
33
34fn parse_with_secrets(value: Option<&Value>) -> Result<Vec<RequestedSecret>> {
35    let Some(value) = value else {
36        return Ok(Vec::new());
37    };
38    if value.is_null() {
39        return Ok(Vec::new());
40    }
41    let entries = value
42        .as_array()
43        .ok_or_else(|| Error::Tool("with_secrets must be an array".into()))?;
44    let mut requested = Vec::with_capacity(entries.len());
45    let mut names = std::collections::HashSet::new();
46
47    for entry in entries {
48        let name = entry
49            .as_str()
50            .map(str::trim)
51            .filter(|name| !name.is_empty())
52            .ok_or_else(|| Error::Tool("with_secrets entries must be non-empty strings".into()))?;
53        if !names.insert(name.to_string()) {
54            return Err(Error::Tool(format!("duplicate with_secrets entry: {name}")));
55        }
56        requested.push(RequestedSecret {
57            name: name.to_string(),
58        });
59    }
60
61    Ok(requested)
62}
63
64fn secret_name_allowed(ctx: &ToolContext, name: &str) -> bool {
65    let policy = &ctx.config.secrets.commands;
66    policy.enabled && policy.allowed.iter().any(|allowed| allowed.name == name)
67}
68
69fn field_env_name(secret_name: &str, field: &str) -> String {
70    let canonical_field = if field == "secrets_key" {
71        "secret_key"
72    } else {
73        field
74    };
75    format!("{secret_name}_{canonical_field}")
76        .chars()
77        .map(|ch| {
78            if ch.is_ascii_alphanumeric() {
79                ch.to_ascii_uppercase()
80            } else {
81                '_'
82            }
83        })
84        .collect()
85}
86
87fn resolve_with_secrets(
88    ctx: &ToolContext,
89    requested: Vec<RequestedSecret>,
90) -> Result<Vec<ResolvedSecretEnvBinding>> {
91    if requested.is_empty() {
92        return Ok(Vec::new());
93    }
94
95    for secret in &requested {
96        if !secret_name_allowed(ctx, &secret.name) {
97            return Err(Error::Tool(format!(
98                "with_secrets entry {} is not allowed by config policy",
99                secret.name
100            )));
101        }
102    }
103
104    let auth_path = crate::storage::existing_global_auth_path()
105        .unwrap_or_else(crate::storage::global_auth_path);
106    let auth_store = AuthStore::load(&auth_path).unwrap_or_else(|_| AuthStore::new(auth_path));
107    let mut resolved = Vec::new();
108    let mut env_names = std::collections::HashSet::new();
109
110    for secret in requested {
111        let fields = auth_store
112            .resolve_secret_fields(&secret.name)
113            .map_err(|error| Error::Tool(format!("missing secret for {}: {error}", secret.name)))?;
114        for (field, value) in fields {
115            let env = field_env_name(&secret.name, &field);
116            if !is_valid_env_name(&env) {
117                return Err(Error::Tool(format!(
118                    "derived env name '{env}' for {}.{field} is invalid",
119                    secret.name
120                )));
121            }
122            if !env_names.insert(env.clone()) {
123                return Err(Error::Tool(format!(
124                    "duplicate derived env var {env} from with_secrets"
125                )));
126            }
127            resolved.push(ResolvedSecretEnvBinding {
128                secret_name: secret.name.clone(),
129                env,
130                value,
131            });
132        }
133    }
134
135    Ok(resolved)
136}
137
138fn redact_injected_secrets(text: &str, resolved: &[ResolvedSecretEnvBinding]) -> String {
139    resolved.iter().fold(text.to_string(), |redacted, binding| {
140        if binding.value.is_empty() {
141            redacted
142        } else {
143            redacted.replace(&binding.value, SECRET_REDACTION)
144        }
145    })
146}
147
148fn injected_secret_names(resolved: &[ResolvedSecretEnvBinding]) -> Vec<String> {
149    let mut names = Vec::new();
150    for binding in resolved {
151        if !names.contains(&binding.secret_name) {
152            names.push(binding.secret_name.clone());
153        }
154    }
155    names
156}
157
158fn injected_env_names(resolved: &[ResolvedSecretEnvBinding]) -> Vec<String> {
159    resolved.iter().map(|binding| binding.env.clone()).collect()
160}
161
162fn is_valid_env_name(value: &str) -> bool {
163    let mut chars = value.chars();
164    let Some(first) = chars.next() else {
165        return false;
166    };
167    (first == '_' || first.is_ascii_uppercase())
168        && chars.all(|ch| ch == '_' || ch.is_ascii_uppercase() || ch.is_ascii_digit())
169}
170
171/// Check whether the rush backend should be used.
172///
173/// Returns true when the `rush-backend` feature is compiled in AND the env var
174/// `IMP_SHELL_BACKEND` is either unset or set to `"rush"`. Setting
175/// `IMP_SHELL_BACKEND=sh` forces the traditional `sh -c` path even when rush
176/// is available.
177#[cfg(feature = "rush-backend")]
178fn use_rush_backend() -> bool {
179    match std::env::var("IMP_SHELL_BACKEND") {
180        Ok(val) => val.eq_ignore_ascii_case("rush"),
181        // Feature compiled in → rush is the default.
182        Err(_) => true,
183    }
184}
185
186/// Execute a command via rush's in-process library API. Returns `None` if rush
187/// fails so the caller can fall back to `sh`.
188#[cfg(feature = "rush-backend")]
189fn run_via_rush(
190    command: &str,
191    timeout_secs: u64,
192    cwd: &std::path::Path,
193    json_output: bool,
194) -> Option<(String, i32, bool, bool)> {
195    let result = rush::run(
196        command,
197        &rush::RunOptions {
198            cwd: Some(cwd.to_path_buf()),
199            timeout: Some(timeout_secs),
200            json_output,
201            max_output_bytes: Some(MAX_OUTPUT_BYTES),
202            ..Default::default()
203        },
204    );
205
206    match result {
207        Ok(r) => {
208            let mut output = r.stdout;
209            if !r.stderr.is_empty() {
210                if !output.is_empty() && !output.ends_with('\n') {
211                    output.push('\n');
212                }
213                output.push_str(&r.stderr);
214            }
215            Some((output, r.exit_code, r.timed_out, r.truncated))
216        }
217        Err(_) => None,
218    }
219}
220
221/// Detect which shell to use for command execution.
222/// Defaults to bash, with IMP_SHELL and config.shell.command as overrides.
223fn detect_shell(config: &crate::config::ShellConfig) -> String {
224    // IMP_SHELL overrides everything (also used by tests to force sh)
225    if let Ok(shell) = std::env::var("IMP_SHELL") {
226        return shell;
227    }
228
229    if let Some(shell) = config
230        .command
231        .as_deref()
232        .map(str::trim)
233        .filter(|s| !s.is_empty())
234    {
235        return shell.to_string();
236    }
237
238    "bash".to_string()
239}
240
241fn sanitize_output_text(text: &str) -> String {
242    static ANSI_RE: OnceLock<Regex> = OnceLock::new();
243    let re =
244        ANSI_RE.get_or_init(|| Regex::new(r"\x1B\[[0-9;?]*[ -/]*[@-~]").expect("valid ansi regex"));
245    re.replace_all(text, "").replace('\r', "")
246}
247
248fn looks_like_search_command(command: &str) -> bool {
249    let trimmed = command.trim_start();
250    trimmed.starts_with("rg ")
251        || trimmed == "rg"
252        || trimmed.starts_with("grep ")
253        || trimmed.starts_with("grep\n")
254        || trimmed.starts_with("fd ")
255        || trimmed == "fd"
256        || trimmed.starts_with("find ")
257        || trimmed == "find"
258        || trimmed.starts_with("ls ")
259        || trimmed == "ls"
260}
261
262fn no_match_exit_is_success(command: &str, exit_code: i32, output: &str) -> bool {
263    if exit_code != 1 || !output.trim().is_empty() {
264        return false;
265    }
266
267    let trimmed = command.trim_start();
268    trimmed.starts_with("rg ")
269        || trimmed == "rg"
270        || trimmed.starts_with("grep ")
271        || trimmed.starts_with("grep\n")
272}
273
274fn command_failure_hint(command: &str, exit_code: i32, output: &str) -> Option<String> {
275    if no_match_exit_is_success(command, exit_code, output) {
276        return Some("No matches found.".to_string());
277    }
278
279    if exit_code == 127 {
280        return Some(
281            "Command not found. Check the executable name or use an installed alternative."
282                .to_string(),
283        );
284    }
285
286    None
287}
288
289#[cfg(feature = "rush-backend")]
290fn should_try_rush_json(command: &str) -> bool {
291    if command.contains("|")
292        || command.contains("&&")
293        || command.contains("||")
294        || command.contains(';')
295        || command.contains('>')
296        || command.contains('<')
297    {
298        return false;
299    }
300
301    looks_like_search_command(command)
302}
303
304#[cfg(feature = "rush-backend")]
305fn parse_json_lines_to_text(command: &str, output: &str) -> Option<String> {
306    let value: serde_json::Value = serde_json::from_str(output).ok()?;
307    let items = value.as_array()?;
308
309    let mut lines = Vec::new();
310    let is_grep = command.trim_start().starts_with("grep");
311    let is_find = command.trim_start().starts_with("find");
312    let is_ls = command.trim_start().starts_with("ls");
313
314    for item in items {
315        if is_grep {
316            let file = item.get("file").and_then(|v| v.as_str()).unwrap_or("");
317            let line = item
318                .get("line_number")
319                .and_then(|v| v.as_u64())
320                .unwrap_or(0);
321            let full_line = item
322                .get("full_line")
323                .and_then(|v| v.as_str())
324                .unwrap_or("")
325                .trim_end_matches('\n');
326            if !file.is_empty() && line > 0 {
327                lines.push(format!("{file}:{line}:{full_line}"));
328            }
329        } else if is_find {
330            if let Some(path) = item.get("path").and_then(|v| v.as_str()) {
331                lines.push(path.to_string());
332            }
333        } else if is_ls {
334            if let Some(name) = item.get("name").and_then(|v| v.as_str()) {
335                let suffix = match item.get("type").and_then(|v| v.as_str()) {
336                    Some("directory") => "/",
337                    Some("symlink") => "@",
338                    _ => "",
339                };
340                lines.push(format!("{name}{suffix}"));
341            }
342        }
343    }
344
345    if lines.is_empty() {
346        None
347    } else {
348        Some(lines.join("\n"))
349    }
350}
351
352fn truncate_command_output(command: &str, output: &str) -> TruncationResult {
353    if looks_like_search_command(command) {
354        truncate_head(output, MAX_OUTPUT_LINES, MAX_OUTPUT_BYTES)
355    } else {
356        truncate_tail(output, MAX_OUTPUT_LINES, MAX_OUTPUT_BYTES)
357    }
358}
359
360pub struct BashTool;
361
362impl BashTool {
363    pub fn canonical() -> Self {
364        Self
365    }
366}
367
368#[async_trait]
369impl Tool for BashTool {
370    fn name(&self) -> &str {
371        "bash"
372    }
373    fn label(&self) -> &str {
374        "Shell"
375    }
376    fn description(&self) -> &str {
377        "Run a shell command in the workspace or an optional workdir."
378    }
379    fn parameters(&self) -> serde_json::Value {
380        json!({
381            "type": "object",
382            "properties": {
383                "command": { "type": "string" },
384                "timeout": { "type": "number" },
385                "workdir": { "type": "string" },
386                "with_secrets": {
387                    "type": "array",
388                    "description": "Imp secret names to expose as deterministic env vars.",
389                    "items": { "type": "string" }
390                }
391            },
392            "required": ["command"]
393        })
394    }
395    fn is_readonly(&self) -> bool {
396        false
397    }
398
399    async fn execute(
400        &self,
401        _call_id: &str,
402        params: serde_json::Value,
403        ctx: ToolContext,
404    ) -> Result<ToolOutput> {
405        let command = params["command"]
406            .as_str()
407            .ok_or_else(|| crate::error::Error::Tool("missing 'command' parameter".into()))?;
408
409        let timeout_secs = params["timeout"].as_u64().unwrap_or(DEFAULT_TIMEOUT_SECS);
410
411        // Support per-command workdir override
412        let ctx = if let Some(workdir) = params["workdir"].as_str() {
413            let wd = super::resolve_path(&ctx.cwd, workdir);
414            if !wd.is_dir() {
415                return Ok(ToolOutput::error(format!(
416                    "workdir not found or not a directory: {}",
417                    wd.display()
418                )));
419            }
420            ToolContext { cwd: wd, ..ctx }
421        } else {
422            ctx
423        };
424
425        let with_secrets = parse_with_secrets(params.get("with_secrets"))?;
426
427        run_command(command, timeout_secs, &ctx, with_secrets).await
428    }
429}
430
431async fn run_command(
432    command: &str,
433    timeout_secs: u64,
434    ctx: &ToolContext,
435    with_secrets: Vec<RequestedSecret>,
436) -> Result<ToolOutput> {
437    // Check cancellation before spawning.
438    if ctx.is_cancelled() {
439        return Ok(ToolOutput {
440            content: vec![imp_llm::ContentBlock::Text {
441                text: "[Command cancelled]".to_string(),
442            }],
443            details: json!({ "exit_code": -1, "timed_out": false, "cancelled": true, "truncated": false }),
444            is_error: true,
445        });
446    }
447
448    let resolved_secret_env = resolve_with_secrets(ctx, with_secrets)?;
449
450    // Try the rush in-process backend when available.
451    #[cfg(feature = "rush-backend")]
452    if use_rush_backend() {
453        let rush_json = should_try_rush_json(command);
454        if let Some((output, exit_code, timed_out, truncated)) =
455            run_via_rush(command, timeout_secs, &ctx.cwd, rush_json)
456        {
457            let transformed = if rush_json {
458                parse_json_lines_to_text(command, &output).unwrap_or(output)
459            } else {
460                output
461            };
462            let sanitized = sanitize_output_text(&transformed);
463            let redacted = redact_injected_secrets(&sanitized, &resolved_secret_env);
464            // Stream the output lines so callers see incremental progress.
465            for line in redacted.lines() {
466                let _ = ctx
467                    .update_tx
468                    .send(ToolUpdate {
469                        content: vec![imp_llm::ContentBlock::Text {
470                            text: line.to_string(),
471                        }],
472                        details: serde_json::Value::Null,
473                    })
474                    .await;
475            }
476
477            let mut result_text = redacted;
478            if let Some(hint) = command_failure_hint(command, exit_code, &result_text) {
479                if !result_text.is_empty() {
480                    result_text.push('\n');
481                }
482                result_text.push_str(&format!("[{hint}]"));
483            }
484            if timed_out {
485                result_text.push_str(&format!("\n[Command timed out after {timeout_secs}s]"));
486            }
487
488            return Ok(ToolOutput {
489                content: vec![imp_llm::ContentBlock::Text { text: result_text }],
490                details: json!({
491                    "exit_code": exit_code,
492                    "timed_out": timed_out,
493                    "cancelled": false,
494                    "truncated": truncated,
495                    "backend": "rush",
496                    "with_secrets": injected_secret_names(&resolved_secret_env),
497                    "injected_env": injected_env_names(&resolved_secret_env),
498                }),
499                is_error: timed_out
500                    || (exit_code != 0
501                        && !no_match_exit_is_success(command, exit_code, &transformed)),
502            });
503        }
504        // rush failed — fall through to sh.
505    }
506
507    let mut child = {
508        // Use configured shell for standard command execution.
509        let shell = detect_shell(&ctx.config.shell);
510        let mut cmd = Command::new(&shell);
511        cmd.arg("-c")
512            .arg(command)
513            .current_dir(&ctx.cwd)
514            // Tool commands are non-interactive. Keep stdin disconnected so
515            // subprocesses cannot consume raw terminal input (for example SGR
516            // mouse reporting sequences) from the interactive TUI.
517            .stdin(Stdio::null())
518            .stdout(Stdio::piped())
519            .stderr(Stdio::piped());
520
521        for binding in &resolved_secret_env {
522            cmd.env(&binding.env, &binding.value);
523        }
524
525        // Create a new process group so we can kill the entire tree.
526        #[cfg(unix)]
527        unsafe {
528            cmd.pre_exec(|| {
529                libc::setsid();
530                Ok(())
531            });
532        }
533
534        cmd.spawn()
535            .map_err(|e| crate::error::Error::Tool(format!("failed to spawn command: {e}")))?
536    };
537
538    let stdout = child.stdout.take().ok_or_else(|| {
539        crate::error::Error::Tool(
540            "failed to capture child stdout despite stdout being piped".to_string(),
541        )
542    })?;
543    let stderr = child.stderr.take().ok_or_else(|| {
544        crate::error::Error::Tool(
545            "failed to capture child stderr despite stderr being piped".to_string(),
546        )
547    })?;
548
549    // Merge stdout and stderr into a single stream.
550    let mut stdout_reader = BufReader::new(stdout).lines();
551    let mut stderr_reader = BufReader::new(stderr).lines();
552
553    let mut output = String::new();
554    let mut timed_out = false;
555    let mut stdout_done = false;
556    let mut stderr_done = false;
557
558    let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
559
560    while !stdout_done || !stderr_done {
561        tokio::select! {
562            biased;
563
564            _ = tokio::time::sleep_until(deadline) => {
565                timed_out = true;
566                kill_process_group(&child).await;
567                break;
568            }
569
570            _ = wait_for_cancellation(&ctx.cancelled), if !ctx.is_cancelled() => {
571                kill_process_group(&child).await;
572                break;
573            }
574
575            line = stdout_reader.next_line(), if !stdout_done => {
576                match line {
577                    Ok(Some(line)) => {
578                        if !line.bytes().any(|b| b == 0) {
579                            let clean = sanitize_output_text(&line);
580                            let clean = redact_injected_secrets(&clean, &resolved_secret_env);
581                            if !clean.is_empty() {
582                                append_line(&mut output, &clean, &ctx.update_tx).await;
583                            }
584                        }
585                    }
586                    _ => { stdout_done = true; }
587                }
588            }
589
590            line = stderr_reader.next_line(), if !stderr_done => {
591                match line {
592                    Ok(Some(line)) => {
593                        if !line.bytes().any(|b| b == 0) {
594                            let clean = sanitize_output_text(&line);
595                            let clean = redact_injected_secrets(&clean, &resolved_secret_env);
596                            if !clean.is_empty() {
597                                append_line(&mut output, &clean, &ctx.update_tx).await;
598                            }
599                        }
600                    }
601                    _ => { stderr_done = true; }
602                }
603            }
604        }
605    }
606
607    // Wait for child with a timeout — don't hang if process won't exit
608    let status = tokio::time::timeout(std::time::Duration::from_secs(5), child.wait())
609        .await
610        .ok()
611        .and_then(|r| r.ok());
612    let exit_code = status.and_then(|s| s.code()).unwrap_or(-1);
613
614    // Truncate from the tail (end matters more for command output).
615    let TruncationResult {
616        content: truncated_output,
617        truncated,
618        output_lines,
619        total_lines,
620        temp_file,
621        ..
622    } = truncate_command_output(command, &output);
623
624    let mut result_text = truncated_output;
625
626    if truncated {
627        let note = if looks_like_search_command(command) {
628            format!(
629                "\n[Output truncated: showing first {output_lines} of {total_lines} lines{}]",
630                temp_file
631                    .as_ref()
632                    .map(|p| format!(". Full output saved to {}", p.display()))
633                    .unwrap_or_default()
634            )
635        } else {
636            format!(
637                "\n[Output truncated: showing last {output_lines} of {total_lines} lines{}]",
638                temp_file
639                    .as_ref()
640                    .map(|p| format!(". Full output saved to {}", p.display()))
641                    .unwrap_or_default()
642            )
643        };
644        result_text.push_str(&note);
645    }
646
647    if timed_out {
648        result_text.push_str(&format!("\n[Command timed out after {timeout_secs}s]"));
649    }
650
651    if let Some(hint) = command_failure_hint(command, exit_code, &output) {
652        if !result_text.is_empty() {
653            result_text.push('\n');
654        }
655        result_text.push_str(&format!("[{hint}]"));
656    }
657
658    let cancelled = ctx.is_cancelled();
659    let details = json!({
660        "exit_code": exit_code,
661        "timed_out": timed_out,
662        "cancelled": cancelled,
663        "truncated": truncated,
664        "command": command,
665        "with_secrets": injected_secret_names(&resolved_secret_env),
666        "injected_env": injected_env_names(&resolved_secret_env),
667    });
668
669    Ok(ToolOutput {
670        content: vec![imp_llm::ContentBlock::Text { text: result_text }],
671        details,
672        is_error: cancelled
673            || timed_out
674            || (exit_code != 0 && !no_match_exit_is_success(command, exit_code, &output)),
675    })
676}
677
678async fn wait_for_cancellation(cancelled: &std::sync::atomic::AtomicBool) {
679    while !cancelled.load(std::sync::atomic::Ordering::Relaxed) {
680        tokio::time::sleep(std::time::Duration::from_millis(25)).await;
681    }
682}
683
684async fn append_line(
685    output: &mut String,
686    line: &str,
687    update_tx: &tokio::sync::mpsc::Sender<ToolUpdate>,
688) {
689    if !output.is_empty() {
690        output.push('\n');
691    }
692    output.push_str(line);
693    let _ = update_tx
694        .send(ToolUpdate {
695            content: vec![imp_llm::ContentBlock::Text {
696                text: line.to_string(),
697            }],
698            details: serde_json::Value::Null,
699        })
700        .await;
701}
702
703/// Kill the entire process group. Sends SIGTERM, waits briefly, then SIGKILL.
704#[cfg(unix)]
705async fn kill_process_group(child: &tokio::process::Child) {
706    if let Some(pid) = child.id() {
707        let pgid = pid as i32;
708
709        // SIGTERM the group
710        unsafe {
711            libc::kill(-pgid, libc::SIGTERM);
712        }
713
714        // Brief wait, then force-kill
715        tokio::time::sleep(std::time::Duration::from_millis(500)).await;
716
717        unsafe {
718            libc::kill(-pgid, libc::SIGKILL);
719        }
720    }
721}
722
723#[cfg(not(unix))]
724async fn kill_process_group(_child: &tokio::process::Child) {
725    // Best-effort on non-Unix — nothing we can do portably.
726}
727
728#[cfg(test)]
729mod tests {
730    use super::*;
731    use crate::config::{AllowedCommandSecret, CommandSecretsConfig, Config, SecretsConfig};
732    use crate::ui::NullInterface;
733    use std::sync::atomic::AtomicBool;
734    use std::sync::Arc;
735
736    // Tests use sh for deterministic behavior (rush has exit code bugs: rush#8)
737    fn ensure_sh() {
738        std::env::set_var("IMP_SHELL", "sh");
739    }
740
741    fn test_ctx(dir: &std::path::Path) -> (ToolContext, tokio::sync::mpsc::Receiver<ToolUpdate>) {
742        ensure_sh();
743        let (tx, rx) = tokio::sync::mpsc::channel(1024);
744        let (cmd_tx, _cmd_rx) = tokio::sync::mpsc::channel(16);
745        let ctx = ToolContext {
746            cwd: dir.to_path_buf(),
747            cancelled: Arc::new(AtomicBool::new(false)),
748            update_tx: tx,
749            command_tx: cmd_tx,
750            ui: Arc::new(NullInterface),
751            file_cache: Arc::new(crate::tools::FileCache::new()),
752            checkpoint_state: Arc::new(crate::tools::CheckpointState::new()),
753            file_tracker: Arc::new(std::sync::Mutex::new(crate::tools::FileTracker::new())),
754            anchor_store: Arc::new(crate::tools::AnchorStore::new()),
755            lua_tool_loader: None,
756            mode: crate::config::AgentMode::Full,
757            read_max_lines: 500,
758            turn_mana_review: Arc::new(std::sync::Mutex::new(
759                crate::mana_review::TurnManaReviewAccumulator::default(),
760            )),
761            config: Arc::new(crate::config::Config::default()),
762            run_policy: Default::default(),
763            supporting_provenance: Vec::new(),
764        };
765        (ctx, rx)
766    }
767
768    fn allow_test_secret(ctx: &mut ToolContext) {
769        ctx.config = Arc::new(Config {
770            secrets: SecretsConfig {
771                commands: CommandSecretsConfig {
772                    enabled: true,
773                    allowed: vec![AllowedCommandSecret {
774                        name: "test-service".to_string(),
775                    }],
776                },
777            },
778            ..Config::default()
779        });
780    }
781
782    #[tokio::test]
783    async fn with_secrets_injects_allowed_secret_and_redacts_output() {
784        let tmp = tempfile::tempdir().unwrap();
785        std::env::set_var("TEST_SERVICE_API_KEY", "native-secret-value");
786        let (mut ctx, _rx) = test_ctx(tmp.path());
787        allow_test_secret(&mut ctx);
788
789        let result = run_command(
790            "printf '%s' \"$TEST_SERVICE_API_KEY\"",
791            DEFAULT_TIMEOUT_SECS,
792            &ctx,
793            vec![RequestedSecret {
794                name: "test-service".to_string(),
795            }],
796        )
797        .await
798        .unwrap();
799
800        assert!(!result.is_error);
801        let text = result.text_content().unwrap();
802        assert!(text.contains(SECRET_REDACTION));
803        assert!(!text.contains("native-secret-value"));
804        assert_eq!(
805            result.details["with_secrets"][0].as_str(),
806            Some("test-service")
807        );
808        assert_eq!(
809            result.details["injected_env"][0].as_str(),
810            Some("TEST_SERVICE_API_KEY")
811        );
812        assert!(!result.details.to_string().contains("native-secret-value"));
813    }
814
815    #[tokio::test]
816    async fn with_secrets_denies_unconfigured_secret() {
817        let tmp = tempfile::tempdir().unwrap();
818        std::env::set_var("TEST_SERVICE_API_KEY", "native-secret-value");
819        let (ctx, _rx) = test_ctx(tmp.path());
820
821        let err = match run_command(
822            "true",
823            DEFAULT_TIMEOUT_SECS,
824            &ctx,
825            vec![RequestedSecret {
826                name: "test-service".to_string(),
827            }],
828        )
829        .await
830        {
831            Ok(_) => panic!("expected policy denial"),
832            Err(err) => err,
833        };
834
835        let message = err.to_string();
836        assert!(message.contains("not allowed by config policy"));
837        assert!(!message.contains("native-secret-value"));
838    }
839
840    #[tokio::test]
841    async fn with_secrets_rejects_duplicate_secret_names() {
842        let err = parse_with_secrets(Some(&serde_json::json!(["test-service", "test-service"])))
843            .unwrap_err();
844
845        assert!(err.to_string().contains("duplicate with_secrets entry"));
846    }
847
848    #[tokio::test]
849    async fn with_secrets_rejects_non_string_entries() {
850        let err = parse_with_secrets(Some(&serde_json::json!([
851            {"provider":"one", "field":"api_key"}
852        ])))
853        .unwrap_err();
854
855        assert!(err.to_string().contains("non-empty strings"));
856    }
857
858    #[test]
859    fn field_env_name_is_deterministic() {
860        assert_eq!(
861            field_env_name("openrouter", "api_key"),
862            "OPENROUTER_API_KEY"
863        );
864        assert_eq!(
865            field_env_name("porkbun", "secrets_key"),
866            "PORKBUN_SECRET_KEY"
867        );
868    }
869
870    #[tokio::test]
871    async fn bash_simple_command() {
872        let tmp = tempfile::tempdir().unwrap();
873        let (ctx, _rx) = test_ctx(tmp.path());
874
875        let result = run_command("echo hello world", DEFAULT_TIMEOUT_SECS, &ctx, Vec::new())
876            .await
877            .unwrap();
878
879        assert!(!result.is_error);
880        let text = match &result.content[0] {
881            imp_llm::ContentBlock::Text { text } => text.clone(),
882            _ => panic!("expected text"),
883        };
884        assert!(text.contains("hello world"));
885        assert_eq!(result.details["exit_code"], 0);
886    }
887
888    #[tokio::test]
889    async fn bash_exit_code() {
890        let tmp = tempfile::tempdir().unwrap();
891        let (ctx, _rx) = test_ctx(tmp.path());
892
893        let result = run_command("exit 42", DEFAULT_TIMEOUT_SECS, &ctx, Vec::new())
894            .await
895            .unwrap();
896
897        assert!(result.is_error);
898        assert_eq!(result.details["exit_code"], 42);
899    }
900
901    #[tokio::test]
902    async fn bash_timeout() {
903        let tmp = tempfile::tempdir().unwrap();
904        let (ctx, _rx) = test_ctx(tmp.path());
905
906        let result = run_command("sleep 60", 1, &ctx, Vec::new()).await.unwrap();
907
908        assert!(result.details["timed_out"].as_bool().unwrap());
909        let text = match &result.content[0] {
910            imp_llm::ContentBlock::Text { text } => text.clone(),
911            _ => panic!("expected text"),
912        };
913        assert!(text.contains("timed out"));
914    }
915
916    #[tokio::test]
917    async fn bash_cancellation() {
918        let tmp = tempfile::tempdir().unwrap();
919        let (ctx, _rx) = test_ctx(tmp.path());
920
921        // Set cancelled before running — should return immediately.
922        ctx.cancelled
923            .store(true, std::sync::atomic::Ordering::Relaxed);
924
925        let result = run_command("sleep 60", DEFAULT_TIMEOUT_SECS, &ctx, Vec::new())
926            .await
927            .unwrap();
928
929        assert!(result.details["cancelled"].as_bool().unwrap());
930        let text = match &result.content[0] {
931            imp_llm::ContentBlock::Text { text } => text.clone(),
932            _ => panic!("expected text"),
933        };
934        assert!(text.contains("cancelled"));
935    }
936
937    #[tokio::test]
938    async fn bash_cancellation_during_execution() {
939        let tmp = tempfile::tempdir().unwrap();
940        let (ctx, _rx) = test_ctx(tmp.path());
941        let cancelled = Arc::clone(&ctx.cancelled);
942
943        let task = tokio::spawn(async move {
944            run_command("sleep 60", DEFAULT_TIMEOUT_SECS, &ctx, Vec::new()).await
945        });
946        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
947        cancelled.store(true, std::sync::atomic::Ordering::Relaxed);
948
949        let result = task.await.unwrap().unwrap();
950        assert!(result.details["cancelled"].as_bool().unwrap());
951    }
952
953    #[tokio::test]
954    async fn bash_streaming_output() {
955        let tmp = tempfile::tempdir().unwrap();
956        let (ctx, mut rx) = test_ctx(tmp.path());
957
958        let handle = tokio::spawn(async move {
959            run_command(
960                "echo line1; echo line2; echo line3",
961                DEFAULT_TIMEOUT_SECS,
962                &ctx,
963                Vec::new(),
964            )
965            .await
966        });
967
968        // Collect streamed updates
969        let mut updates = Vec::new();
970        while let Some(update) = rx.recv().await {
971            updates.push(update);
972        }
973
974        let result = handle.await.unwrap().unwrap();
975        assert!(!result.is_error);
976        assert!(
977            !updates.is_empty(),
978            "should have received streaming updates"
979        );
980    }
981
982    #[tokio::test]
983    async fn bash_stdout_and_stderr_merged() {
984        let tmp = tempfile::tempdir().unwrap();
985        let (ctx, _rx) = test_ctx(tmp.path());
986
987        let result = run_command(
988            "echo stdout_line; echo stderr_line >&2",
989            DEFAULT_TIMEOUT_SECS,
990            &ctx,
991            Vec::new(),
992        )
993        .await
994        .unwrap();
995
996        // exit code 0 → not an error
997        assert!(!result.is_error);
998        let text = match &result.content[0] {
999            imp_llm::ContentBlock::Text { text } => text.clone(),
1000            _ => panic!("expected text"),
1001        };
1002        assert!(text.contains("stdout_line"));
1003        assert!(text.contains("stderr_line"));
1004    }
1005
1006    #[tokio::test]
1007    async fn bash_writes_file_side_effect() {
1008        let tmp = tempfile::tempdir().unwrap();
1009        let (ctx, _rx) = test_ctx(tmp.path());
1010
1011        let result = run_command(
1012            "echo 'side effect content' > side_effect.txt",
1013            DEFAULT_TIMEOUT_SECS,
1014            &ctx,
1015            Vec::new(),
1016        )
1017        .await
1018        .unwrap();
1019
1020        assert!(!result.is_error);
1021        let written = std::fs::read_to_string(tmp.path().join("side_effect.txt")).unwrap();
1022        assert!(written.contains("side effect content"));
1023    }
1024
1025    #[tokio::test]
1026    async fn bash_uses_cwd() {
1027        let tmp = tempfile::tempdir().unwrap();
1028        std::fs::write(tmp.path().join("testfile.txt"), "content").unwrap();
1029        let (ctx, _rx) = test_ctx(tmp.path());
1030
1031        let result = run_command("ls testfile.txt", DEFAULT_TIMEOUT_SECS, &ctx, Vec::new())
1032            .await
1033            .unwrap();
1034
1035        assert!(!result.is_error);
1036        let text = match &result.content[0] {
1037            imp_llm::ContentBlock::Text { text } => text.clone(),
1038            _ => panic!("expected text"),
1039        };
1040        assert!(text.contains("testfile.txt"));
1041    }
1042
1043    #[tokio::test]
1044    async fn bash_strips_ansi_sequences() {
1045        let tmp = tempfile::tempdir().unwrap();
1046        let (ctx, _rx) = test_ctx(tmp.path());
1047
1048        let result = run_command(
1049            "printf '\\033[1;31mred\\033[0m\\n'",
1050            DEFAULT_TIMEOUT_SECS,
1051            &ctx,
1052            Vec::new(),
1053        )
1054        .await
1055        .unwrap();
1056
1057        assert!(!result.is_error);
1058        let text = match &result.content[0] {
1059            imp_llm::ContentBlock::Text { text } => text.clone(),
1060            _ => panic!("expected text"),
1061        };
1062        assert!(text.contains("red"));
1063        assert!(!text.contains("\u{1b}[1;31m"));
1064        assert!(!text.contains("\u{1b}[0m"));
1065    }
1066
1067    #[tokio::test]
1068    async fn bash_workdir_override_executes_in_target_dir() {
1069        let root = tempfile::tempdir().unwrap();
1070        let subdir = root.path().join("subdir");
1071        std::fs::create_dir(&subdir).unwrap();
1072        std::fs::write(subdir.join("inside.txt"), "ok").unwrap();
1073        let tool = BashTool;
1074        let (ctx, _rx) = test_ctx(root.path());
1075
1076        let result = tool
1077            .execute(
1078                "c-workdir",
1079                serde_json::json!({"command": "ls inside.txt", "workdir": "subdir"}),
1080                ctx,
1081            )
1082            .await
1083            .unwrap();
1084
1085        assert!(!result.is_error);
1086        let text = match &result.content[0] {
1087            imp_llm::ContentBlock::Text { text } => text.clone(),
1088            _ => panic!("expected text"),
1089        };
1090        assert!(text.contains("inside.txt"));
1091    }
1092
1093    #[tokio::test]
1094    async fn bash_invalid_workdir_returns_error() {
1095        let root = tempfile::tempdir().unwrap();
1096        let tool = BashTool;
1097        let (ctx, _rx) = test_ctx(root.path());
1098
1099        let result = tool
1100            .execute(
1101                "c-bad-workdir",
1102                serde_json::json!({"command": "pwd", "workdir": "missing-dir"}),
1103                ctx,
1104            )
1105            .await
1106            .unwrap();
1107
1108        assert!(result.is_error);
1109        let text = match &result.content[0] {
1110            imp_llm::ContentBlock::Text { text } => text.clone(),
1111            _ => panic!("expected text"),
1112        };
1113        assert!(text.contains("workdir not found"));
1114    }
1115
1116    #[tokio::test]
1117    async fn bash_treats_rg_no_matches_as_success() {
1118        let tmp = tempfile::tempdir().unwrap();
1119        std::fs::write(tmp.path().join("afile.txt"), "haystack\n").unwrap();
1120        let (ctx, _rx) = test_ctx(tmp.path());
1121
1122        let result = run_command(
1123            "rg definitely_not_present .",
1124            DEFAULT_TIMEOUT_SECS,
1125            &ctx,
1126            Vec::new(),
1127        )
1128        .await
1129        .unwrap();
1130
1131        assert!(!result.is_error);
1132        assert_eq!(result.details["exit_code"], 1);
1133        assert!(result.text_content().unwrap().contains("No matches found"));
1134    }
1135
1136    #[tokio::test]
1137    async fn bash_command_not_found_returns_actionable_hint() {
1138        let tmp = tempfile::tempdir().unwrap();
1139        let (ctx, _rx) = test_ctx(tmp.path());
1140
1141        let result = run_command(
1142            "definitely_not_a_real_command_98765",
1143            DEFAULT_TIMEOUT_SECS,
1144            &ctx,
1145            Vec::new(),
1146        )
1147        .await
1148        .unwrap();
1149
1150        assert!(result.is_error);
1151        assert_eq!(result.details["exit_code"], 127);
1152        assert!(result.text_content().unwrap().contains("Command not found"));
1153    }
1154
1155    // ── rush backend tests ──────────────────────────────────────────
1156    //
1157    // Call run_via_rush directly to avoid env-var races between
1158    // parallel test threads.
1159
1160    #[test]
1161    #[cfg(feature = "rush-backend")]
1162    fn test_rush_backend_echo() {
1163        let tmp = tempfile::tempdir().unwrap();
1164        let (output, exit_code, timed_out, _truncated) =
1165            run_via_rush("echo hello world", DEFAULT_TIMEOUT_SECS, tmp.path(), false)
1166                .expect("rush should succeed");
1167
1168        assert_eq!(exit_code, 0);
1169        assert!(!timed_out);
1170        assert!(output.contains("hello world"), "stdout missing: {output}");
1171    }
1172
1173    #[test]
1174    #[cfg(feature = "rush-backend")]
1175    fn test_rush_backend_builtin() {
1176        let tmp = tempfile::tempdir().unwrap();
1177        std::fs::write(tmp.path().join("afile.txt"), "content").unwrap();
1178
1179        let (output, exit_code, _, _) = run_via_rush("ls", DEFAULT_TIMEOUT_SECS, tmp.path(), false)
1180            .expect("rush should succeed");
1181
1182        assert_eq!(exit_code, 0);
1183        assert!(
1184            output.contains("afile.txt"),
1185            "ls should list file: {output}"
1186        );
1187    }
1188
1189    #[test]
1190    #[cfg(feature = "rush-backend")]
1191    fn test_rush_backend_ls_json_text_transform() {
1192        let tmp = tempfile::tempdir().unwrap();
1193        std::fs::write(tmp.path().join("afile.txt"), "content").unwrap();
1194
1195        let (output, exit_code, _, _) = run_via_rush("ls", DEFAULT_TIMEOUT_SECS, tmp.path(), true)
1196            .expect("rush should succeed");
1197        let text = parse_json_lines_to_text("ls", &output).expect("json should transform");
1198
1199        assert_eq!(exit_code, 0);
1200        assert!(text.contains("afile.txt"));
1201    }
1202
1203    #[test]
1204    #[cfg(feature = "rush-backend")]
1205    fn test_rush_backend_grep_json_text_transform() {
1206        let tmp = tempfile::tempdir().unwrap();
1207        std::fs::write(tmp.path().join("afile.txt"), "hello needle world\n").unwrap();
1208
1209        let (output, exit_code, _, _) =
1210            run_via_rush("grep -r needle .", DEFAULT_TIMEOUT_SECS, tmp.path(), true)
1211                .expect("rush should succeed");
1212        let text =
1213            parse_json_lines_to_text("grep -r needle .", &output).expect("json should transform");
1214
1215        assert_eq!(exit_code, 0);
1216        assert!(text.contains("needle"));
1217        assert!(
1218            text.contains("afile.txt") || text.contains(":1:"),
1219            "unexpected grep text: {text}"
1220        );
1221    }
1222
1223    #[test]
1224    #[cfg(feature = "rush-backend")]
1225    fn test_rush_backend_find_json_text_transform() {
1226        let tmp = tempfile::tempdir().unwrap();
1227        std::fs::write(tmp.path().join("afile.txt"), "content").unwrap();
1228
1229        let (output, exit_code, _, _) = run_via_rush(
1230            "find . -name afile.txt",
1231            DEFAULT_TIMEOUT_SECS,
1232            tmp.path(),
1233            true,
1234        )
1235        .expect("rush should succeed");
1236        let text = parse_json_lines_to_text("find . -name afile.txt", &output)
1237            .expect("json should transform");
1238
1239        assert_eq!(exit_code, 0);
1240        assert!(text.contains("afile.txt"));
1241    }
1242
1243    #[test]
1244    #[cfg(feature = "rush-backend")]
1245    fn test_rush_backend_pipeline() {
1246        let tmp = tempfile::tempdir().unwrap();
1247
1248        let (output, exit_code, _, _) =
1249            run_via_rush("echo foo | cat", DEFAULT_TIMEOUT_SECS, tmp.path(), false)
1250                .expect("rush should succeed");
1251
1252        assert_eq!(exit_code, 0);
1253        assert!(output.contains("foo"), "pipeline output missing: {output}");
1254    }
1255
1256    #[test]
1257    #[cfg(feature = "rush-backend")]
1258    fn test_rush_backend_exit_code() {
1259        let tmp = tempfile::tempdir().unwrap();
1260
1261        let (_, exit_code, _, _) = run_via_rush("exit 42", DEFAULT_TIMEOUT_SECS, tmp.path(), false)
1262            .expect("rush should return result even on non-zero exit");
1263
1264        assert_eq!(exit_code, 42);
1265    }
1266}