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#[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 Err(_) => true,
183 }
184}
185
186#[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
221fn detect_shell(config: &crate::config::ShellConfig) -> String {
224 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 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 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 #[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 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 }
506
507 let mut child = {
508 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 .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 #[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 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 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 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(¬e);
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#[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 unsafe {
711 libc::kill(-pgid, libc::SIGTERM);
712 }
713
714 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 }
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 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 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 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 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 #[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}