Skip to main content

cookie_agent_tools/
bash.rs

1use std::{
2    env,
3    path::{Path, PathBuf},
4    process::Stdio,
5    sync::Arc,
6    time::Duration,
7};
8
9#[cfg(any(unix, windows))]
10use std::ffi::OsString;
11#[cfg(windows)]
12use std::os::windows::ffi::OsStringExt;
13#[cfg(windows)]
14use std::sync::OnceLock;
15
16#[cfg(unix)]
17use std::os::unix::fs::MetadataExt;
18
19use async_trait::async_trait;
20use cookie_agent_engine::ToolProgress;
21use cookie_agent_engine::{
22    PreparedExecutor, PreparedTool, ProgressSink, SessionToolContext, ToolCall, ToolError,
23    ToolExecutionContext, ToolPreparationContext, ToolProvider, ToolSpec, ToolStdin,
24};
25use cookie_agent_protocol::PersistedToolResult as ToolResult;
26use cookie_agent_protocol::{ApprovalResourceSource, PermissionAction, PreparedBindingLifetime};
27use cookie_agent_protocol::{OutputStream, SafeDisplayText, ToolCallId};
28#[cfg(windows)]
29use process_wrap::tokio::{CommandWrap, JobObject as ProcessJobObject, KillOnDrop};
30use schemars::JsonSchema;
31use serde::{Deserialize, Serialize};
32#[cfg(unix)]
33use tokio::process::Child;
34use tokio::{
35    io::{AsyncRead, AsyncReadExt, AsyncWriteExt},
36    process::Command,
37    sync::Mutex,
38};
39use tokio_util::sync::CancellationToken;
40
41use crate::{fs_cap, parse_args, prepared_operation, prepared_resource, schema};
42
43#[derive(Debug)]
44pub struct BashTool {
45    _workspace: PathBuf,
46}
47
48#[derive(Debug, Deserialize, JsonSchema, Serialize)]
49struct BashArgs {
50    command: String,
51    #[serde(default = "default_timeout")]
52    timeout: u64,
53    #[serde(default)]
54    interactive: bool,
55}
56
57fn default_timeout() -> u64 {
58    120_000
59}
60
61struct BashExecutor {
62    tool_call_id: ToolCallId,
63    args: BashArgs,
64    cwd: fs_cap::PreparedExisting,
65    executable: fs_cap::PreparedExisting,
66}
67
68pub const OUTPUT_CHUNK_FLUSH_BYTES: usize = 4 * 1024;
69pub const OUTPUT_CHUNK_FLUSH_INTERVAL: Duration = Duration::from_millis(50);
70
71#[derive(Default)]
72struct MergedOutput {
73    text: String,
74    truncated: bool,
75}
76
77impl MergedOutput {
78    fn append(&mut self, chunk: &str) {
79        if self.truncated {
80            return;
81        }
82        let room = cookie_agent_protocol::MAX_TOOL_DISPLAY_BYTES.saturating_sub(self.text.len());
83        let mut end = chunk.len().min(room);
84        while !chunk.is_char_boundary(end) {
85            end -= 1;
86        }
87        self.text.push_str(&chunk[..end]);
88        self.truncated = end < chunk.len();
89    }
90}
91
92fn sanitized_chunks(bytes: &[u8]) -> Vec<String> {
93    let text = String::from_utf8_lossy(bytes);
94    let mut chunks = Vec::new();
95    let mut chunk = String::new();
96    for character in text.chars() {
97        let character = if character.is_control() && !matches!(character, '\n' | '\t') {
98            ' '
99        } else {
100            character
101        };
102        if chunk.len() + character.len_utf8() > SafeDisplayText::MAX_BYTES {
103            chunks.push(std::mem::take(&mut chunk));
104        }
105        chunk.push(character);
106    }
107    if !chunk.is_empty() {
108        chunks.push(chunk);
109    }
110    chunks
111}
112
113async fn emit_preview(
114    progress: &ProgressSink,
115    tool_call_id: ToolCallId,
116    stream: &OutputStream,
117    bytes: &[u8],
118) -> Result<(), ToolError> {
119    let stream_name = stream.name();
120    for chunk in sanitized_chunks(bytes) {
121        progress
122            .send(ToolProgress {
123                output: Vec::new(),
124                tool_call_id,
125                message: format!("bash {stream_name}"),
126                display: Some(chunk),
127            })
128            .await?;
129    }
130    Ok(())
131}
132
133async fn read_output<R>(
134    mut reader: R,
135    stream: OutputStream,
136    progress: ProgressSink,
137    tool_call_id: ToolCallId,
138    merged: Arc<Mutex<MergedOutput>>,
139) -> Result<(), ToolError>
140where
141    R: AsyncRead + Unpin,
142{
143    let mut pending = Vec::new();
144    let mut undecoded = Vec::new();
145    let mut read_buffer = [0_u8; OUTPUT_CHUNK_FLUSH_BYTES];
146    let flush = tokio::time::sleep(OUTPUT_CHUNK_FLUSH_INTERVAL);
147    tokio::pin!(flush);
148    loop {
149        tokio::select! {
150            read = reader.read(&mut read_buffer) => {
151                let count = read.map_err(|error| ToolError::execution(error.to_string()))?;
152                undecoded.extend_from_slice(&read_buffer[..count]);
153                let text = decode_output(&mut undecoded, count == 0);
154                {
155                    // Record read arrival, independently of the per-pipe preview batches.
156                    // Separate pipes cannot reconstruct the process's exact write order.
157                    let mut merged = merged.lock().await;
158                    for chunk in sanitized_chunks(text.as_bytes()) {
159                        merged.append(&chunk);
160                    }
161                }
162                if count == 0 {
163                    if !pending.is_empty() {
164                        flush_output(
165                            &progress,
166                            tool_call_id,
167                            &stream,
168                            (&mut pending, true),
169                        ).await?;
170                    }
171                    return Ok(());
172                }
173                let bytes = &read_buffer[..count];
174                if pending.is_empty() {
175                    flush.as_mut().reset(tokio::time::Instant::now() + OUTPUT_CHUNK_FLUSH_INTERVAL);
176                }
177                pending.extend_from_slice(bytes);
178                if pending.len() >= OUTPUT_CHUNK_FLUSH_BYTES {
179                    flush_output(
180                        &progress,
181                        tool_call_id,
182                        &stream,
183                        (&mut pending, false),
184                    ).await?;
185                }
186            }
187            () = &mut flush, if !pending.is_empty() => {
188                flush_output(
189                    &progress,
190                    tool_call_id,
191                    &stream,
192                    (&mut pending, false),
193                ).await?;
194                flush.as_mut().reset(tokio::time::Instant::now() + OUTPUT_CHUNK_FLUSH_INTERVAL);
195            }
196        }
197    }
198}
199
200fn decode_output(pending: &mut Vec<u8>, eof: bool) -> String {
201    let mut text = String::new();
202    let mut consumed = 0;
203    while consumed < pending.len() {
204        match std::str::from_utf8(&pending[consumed..]) {
205            Ok(valid) => {
206                text.push_str(valid);
207                consumed = pending.len();
208            }
209            Err(error) => {
210                let end = consumed + error.valid_up_to();
211                text.push_str(
212                    std::str::from_utf8(&pending[consumed..end]).expect("valid UTF-8 prefix"),
213                );
214                consumed = end;
215                if let Some(length) = error.error_len() {
216                    text.push('\u{fffd}');
217                    consumed += length;
218                } else if eof {
219                    text.push('\u{fffd}');
220                    consumed = pending.len();
221                } else {
222                    break;
223                }
224            }
225        }
226    }
227    pending.drain(..consumed);
228    text
229}
230
231async fn flush_output(
232    progress: &ProgressSink,
233    tool_call_id: ToolCallId,
234    stream: &OutputStream,
235    pending: (&mut Vec<u8>, bool),
236) -> Result<(), ToolError> {
237    let (pending, eof) = pending;
238    let text = decode_output(pending, eof);
239    if text.is_empty() {
240        return Ok(());
241    }
242    let name = stream.name();
243    progress
244        .send(ToolProgress {
245            tool_call_id,
246            message: String::new(),
247            display: None,
248            output: vec![cookie_agent_protocol::ToolOutputChunk {
249                stream: Some(name.into()),
250                text: text.clone(),
251            }],
252        })
253        .await?;
254    emit_preview(progress, tool_call_id, stream, text.as_bytes()).await
255}
256
257#[cfg(unix)]
258struct ProcessGroupChild {
259    child: Option<Child>,
260    process_group: i32,
261    complete: bool,
262}
263
264#[cfg(unix)]
265impl ProcessGroupChild {
266    fn kill_group(&mut self) {
267        unsafe {
268            libc::kill(-self.process_group, libc::SIGKILL);
269        }
270        if let Some(child) = &mut self.child {
271            let _ = child.start_kill();
272        }
273    }
274
275    async fn kill_and_reap(&mut self) {
276        self.kill_group();
277        if let Some(child) = &mut self.child {
278            let _ = child.wait().await;
279        }
280        self.complete = true;
281    }
282}
283
284#[cfg(unix)]
285impl Drop for ProcessGroupChild {
286    fn drop(&mut self) {
287        if !self.complete {
288            self.kill_group();
289            if let Some(mut child) = self.child.take()
290                && let Ok(runtime) = tokio::runtime::Handle::try_current()
291            {
292                runtime.spawn(async move {
293                    let _ = child.wait().await;
294                });
295            }
296        }
297    }
298}
299
300#[cfg(windows)]
301struct JobChild {
302    child: Option<Box<dyn process_wrap::tokio::ChildWrapper>>,
303    complete: bool,
304}
305
306#[cfg(windows)]
307impl JobChild {
308    fn kill_group(&mut self) {
309        if let Some(child) = &mut self.child {
310            let _ = child.start_kill();
311        }
312    }
313
314    async fn kill_and_reap(&mut self) {
315        self.kill_group();
316        if let Some(child) = &mut self.child {
317            let _ = child.wait().await;
318        }
319        self.complete = true;
320    }
321}
322
323#[cfg(windows)]
324impl Drop for JobChild {
325    fn drop(&mut self) {
326        if !self.complete {
327            self.kill_group();
328            if let Some(mut child) = self.child.take()
329                && let Ok(runtime) = tokio::runtime::Handle::try_current()
330            {
331                runtime.spawn(async move {
332                    let _ = child.wait().await;
333                });
334            }
335        }
336    }
337}
338
339impl BashTool {
340    #[must_use]
341    pub fn new(workspace: impl Into<PathBuf>) -> Self {
342        Self {
343            _workspace: workspace.into(),
344        }
345    }
346}
347impl Default for BashTool {
348    fn default() -> Self {
349        Self::new(std::env::current_dir().expect("current directory"))
350    }
351}
352
353#[async_trait]
354impl ToolProvider for BashTool {
355    fn provider_id(&self) -> &'static str {
356        "builtin.bash"
357    }
358
359    fn tools_for_session(&self, _: &SessionToolContext) -> Result<Vec<ToolSpec>, ToolError> {
360        Ok(vec![ToolSpec {
361            output: cookie_agent_protocol::ToolOutputDeclaration::Named {
362                streams: vec!["stdout".into(), "stderr".into()],
363            },
364            concurrency: cookie_agent_engine::ToolConcurrency::Parallel,
365            result_truncation: Default::default(),
366            name: "bash".into(),
367            permission_name: Self::get_permission_name("bash")?.into(),
368            description: bash_tool_description().into(),
369            parameters: schema::<BashArgs>(),
370        }])
371    }
372
373    fn get_permission_name(tool_name: &str) -> Result<&'static str, ToolError> {
374        match tool_name {
375            "bash" => Ok("bash"),
376            _ => Err(ToolError::execution("bash provider received another tool")),
377        }
378    }
379
380    fn get_permission_resource(
381        &self,
382        name: &str,
383        arguments: &serde_json::Value,
384    ) -> Result<(&'static str, Option<String>), ToolError> {
385        let permission_name = Self::get_permission_name(name)?;
386        let args: BashArgs = parse_args("bash", arguments.clone())?;
387        if args.command.trim().is_empty() {
388            return Err(ToolError::execution("command must not be empty"));
389        }
390        Ok((permission_name, Some(args.command)))
391    }
392
393    fn get_display_argument(
394        &self,
395        name: &str,
396        arguments: &serde_json::Value,
397    ) -> Result<String, ToolError> {
398        let (_, Some(command)) = self.get_permission_resource(name, arguments)? else {
399            return Err(ToolError::execution("bash permission resource is missing"));
400        };
401        Ok(compact_command_line(&command))
402    }
403
404    async fn prepare(
405        &self,
406        ctx: ToolPreparationContext,
407        call: ToolCall,
408    ) -> Result<PreparedTool, ToolError> {
409        let mut args: BashArgs = parse_args("bash", call.arguments)?;
410        if args.command.trim().is_empty() {
411            return Err(ToolError::execution("command must not be empty"));
412        }
413        if args.timeout == 0 {
414            args.timeout = default_timeout();
415        }
416        let executable_path = resolve_executable("bash")?;
417        let executable = fs_cap::prepare_existing(Path::new("/"), &executable_path)?;
418        if executable.directory || executable.identity.mode & 0o111 == 0 {
419            return Err(ToolError::unsupported_security(
420                "prepared bash executable is not an executable regular file",
421            ));
422        }
423        let cwd = fs_cap::prepare_existing(std::path::Path::new("/"), &ctx.cwd)?;
424        if !cwd.directory {
425            return Err(ToolError::unsupported_security(
426                "bash cwd is not a directory",
427            ));
428        }
429        let mut executable_binding = executable.manifest_bytes()?;
430        executable_binding.extend_from_slice(executable_path.as_os_str().as_encoded_bytes());
431        let command = args.command.clone();
432        let mut binding = command.as_bytes().to_vec();
433        binding.extend_from_slice(&executable_binding);
434        let resources = vec![prepared_resource(
435            PermissionAction::Bash,
436            "command",
437            command.as_bytes(),
438            &binding,
439            PreparedBindingLifetime::ProcessLocal,
440            ApprovalResourceSource::PrimaryOperation,
441        )?];
442        let policy_labels = vec![command.clone()];
443        let mut context = cwd.manifest_bytes()?;
444        context.extend_from_slice(&executable_binding);
445        let operation = prepared_operation(
446            "bash",
447            &args,
448            vec![(PermissionAction::Bash, "execute")],
449            resources,
450            &context,
451        )?;
452        let normalized_arguments = serde_json::json!({
453            "command": command,
454        });
455        PreparedTool::new(
456            operation,
457            normalized_arguments,
458            None,
459            Box::new(BashExecutor {
460                tool_call_id: call.id,
461                args,
462                cwd,
463                executable,
464            }),
465        )?
466        .with_policy_labels(policy_labels)
467    }
468}
469
470#[cfg(unix)]
471fn bash_tool_description() -> &'static str {
472    "Execute one prepared shell command."
473}
474
475#[cfg(windows)]
476fn bash_tool_description() -> &'static str {
477    "Execute one prepared Git Bash command. Single-quote native Windows paths (for example, 'C:\\Users\\name\\file') or use C:/ paths."
478}
479
480fn compact_command_line(command: &str) -> String {
481    command.split_whitespace().collect::<Vec<_>>().join(" ")
482}
483
484impl BashExecutor {
485    #[cfg(unix)]
486    async fn execute_process(
487        self,
488        progress: ProgressSink,
489        cancellation: CancellationToken,
490        stdin: Option<ToolStdin>,
491    ) -> Result<ToolResult, ToolError> {
492        self.cwd.revalidate()?;
493        self.executable.revalidate()?;
494        if cancellation.is_cancelled() {
495            return Err(ToolError::execution("prepared bash cancelled"));
496        }
497        let mut command = Command::new(self.executable.proc_fd_path());
498        command
499            .arg("-c")
500            .arg(&self.args.command)
501            .current_dir(self.cwd.proc_fd_path())
502            .stdin(if self.args.interactive {
503                Stdio::piped()
504            } else {
505                Stdio::null()
506            })
507            .stdout(Stdio::piped())
508            .stderr(Stdio::piped())
509            .kill_on_drop(true);
510        unsafe {
511            command.pre_exec(|| {
512                if libc::setsid() == -1 {
513                    Err(std::io::Error::last_os_error())
514                } else {
515                    Ok(())
516                }
517            });
518        }
519        let mut child = command
520            .spawn()
521            .map_err(|error| ToolError::execution(error.to_string()))?;
522        let process_group = child
523            .id()
524            .and_then(|id| i32::try_from(id).ok())
525            .ok_or_else(|| ToolError::execution("prepared bash child has no process id"))?;
526        let stdout = child
527            .stdout
528            .take()
529            .ok_or_else(|| ToolError::execution("bash stdout pipe missing"))?;
530        let stderr = child
531            .stderr
532            .take()
533            .ok_or_else(|| ToolError::execution("bash stderr pipe missing"))?;
534        let merged = Arc::new(Mutex::new(MergedOutput::default()));
535        let stdout_task = tokio::spawn(read_output(
536            stdout,
537            OutputStream::Stdout,
538            progress.clone(),
539            self.tool_call_id,
540            merged.clone(),
541        ));
542        let stderr_task = tokio::spawn(read_output(
543            stderr,
544            OutputStream::Stderr,
545            progress,
546            self.tool_call_id,
547            merged.clone(),
548        ));
549        let stdin_task = if self.args.interactive {
550            let mut child_stdin = child
551                .stdin
552                .take()
553                .ok_or_else(|| ToolError::execution("bash stdin pipe missing"))?;
554            let mut writes = stdin
555                .ok_or_else(|| ToolError::execution("interactive bash stdin channel missing"))?;
556            Some(tokio::spawn(async move {
557                while let Some(write) = writes.recv().await {
558                    child_stdin.write_all(&write.data).await?;
559                    child_stdin.flush().await?;
560                    if write.eof {
561                        child_stdin.shutdown().await?;
562                        break;
563                    }
564                }
565                Ok::<(), std::io::Error>(())
566            }))
567        } else {
568            None
569        };
570        let mut grouped = ProcessGroupChild {
571            child: Some(child),
572            process_group,
573            complete: false,
574        };
575        enum WaitOutcome {
576            Finished(std::io::Result<std::process::ExitStatus>),
577            TimedOut,
578            Cancelled,
579        }
580        let outcome = {
581            let wait = grouped
582                .child
583                .as_mut()
584                .expect("prepared child exists")
585                .wait();
586            tokio::pin!(wait);
587            tokio::select! {
588                result = tokio::time::timeout(Duration::from_millis(self.args.timeout), &mut wait) => {
589                    match result {
590                        Ok(result) => WaitOutcome::Finished(result),
591                        Err(_) => WaitOutcome::TimedOut,
592                    }
593                }
594                _ = cancellation.cancelled() => WaitOutcome::Cancelled,
595            }
596        };
597        let status = match outcome {
598            WaitOutcome::Finished(result) => {
599                grouped.complete = true;
600                result.map_err(|error| ToolError::execution(error.to_string()))?
601            }
602            WaitOutcome::TimedOut => {
603                grouped.kill_and_reap().await;
604                if let Some(task) = &stdin_task {
605                    task.abort();
606                }
607                let _ = stdout_task.await;
608                let _ = stderr_task.await;
609                return Err(ToolError::execution("bash timed out"));
610            }
611            WaitOutcome::Cancelled => {
612                grouped.kill_and_reap().await;
613                if let Some(task) = &stdin_task {
614                    task.abort();
615                }
616                let _ = stdout_task.await;
617                let _ = stderr_task.await;
618                return Err(ToolError::execution("prepared bash cancelled"));
619            }
620        };
621        if let Some(task) = &stdin_task {
622            task.abort();
623        }
624        grouped.kill_group();
625        stdout_task
626            .await
627            .map_err(|error| ToolError::execution(error.to_string()))??;
628        stderr_task
629            .await
630            .map_err(|error| ToolError::execution(error.to_string()))??;
631        if status.code().is_none() {
632            return Err(ToolError::execution("bash terminated by a signal"));
633        }
634        Ok(ToolResult {
635            display: Some(completed_display(
636                std::mem::take(&mut *merged.lock().await),
637                status.code(),
638            )),
639            retained_output: None,
640            title: crate::safe_title("Bash"),
641            output: String::new(),
642            metadata: serde_json::json!({"status":status.code(),"success":status.success()}),
643            truncation: None,
644            attachments: Vec::new(),
645            additional_messages: Vec::new(),
646        })
647    }
648
649    #[cfg(windows)]
650    async fn execute_process(
651        self,
652        progress: ProgressSink,
653        cancellation: CancellationToken,
654        stdin: Option<ToolStdin>,
655    ) -> Result<ToolResult, ToolError> {
656        self.cwd.revalidate()?;
657        self.executable.revalidate()?;
658        if cancellation.is_cancelled() {
659            return Err(ToolError::execution("prepared bash cancelled"));
660        }
661        let mut command = Command::new(self.executable.proc_fd_path());
662        command
663            .arg("-c")
664            .arg(&self.args.command)
665            .current_dir(self.cwd.proc_fd_path())
666            .stdin(if self.args.interactive {
667                Stdio::piped()
668            } else {
669                Stdio::null()
670            })
671            .stdout(Stdio::piped())
672            .stderr(Stdio::piped())
673            .kill_on_drop(true);
674        let mut wrapped = CommandWrap::from(command);
675        wrapped.wrap(ProcessJobObject).wrap(KillOnDrop);
676        // ProcessJobObject adds CREATE_SUSPENDED, assigns the process to the
677        // job, and only then resumes its threads, so descendants cannot escape.
678        let mut child = wrapped
679            .spawn()
680            .map_err(|error| ToolError::execution(error.to_string()))?;
681        let stdout = child
682            .stdout()
683            .take()
684            .ok_or_else(|| ToolError::execution("bash stdout pipe missing"))?;
685        let stderr = child
686            .stderr()
687            .take()
688            .ok_or_else(|| ToolError::execution("bash stderr pipe missing"))?;
689        let merged = Arc::new(Mutex::new(MergedOutput::default()));
690        let stdout_task = tokio::spawn(read_output(
691            stdout,
692            OutputStream::Stdout,
693            progress.clone(),
694            self.tool_call_id,
695            merged.clone(),
696        ));
697        let stderr_task = tokio::spawn(read_output(
698            stderr,
699            OutputStream::Stderr,
700            progress,
701            self.tool_call_id,
702            merged.clone(),
703        ));
704        let stdin_task = if self.args.interactive {
705            let mut child_stdin = child
706                .stdin()
707                .take()
708                .ok_or_else(|| ToolError::execution("bash stdin pipe missing"))?;
709            let mut writes = stdin
710                .ok_or_else(|| ToolError::execution("interactive bash stdin channel missing"))?;
711            Some(tokio::spawn(async move {
712                while let Some(write) = writes.recv().await {
713                    child_stdin.write_all(&write.data).await?;
714                    child_stdin.flush().await?;
715                    if write.eof {
716                        child_stdin.shutdown().await?;
717                        break;
718                    }
719                }
720                Ok::<(), std::io::Error>(())
721            }))
722        } else {
723            None
724        };
725        let mut grouped = JobChild {
726            child: Some(child),
727            complete: false,
728        };
729        enum WaitOutcome {
730            Finished(std::io::Result<std::process::ExitStatus>),
731            TimedOut,
732            Cancelled,
733        }
734        let outcome = {
735            let wrapped_child = grouped.child.as_mut().expect("prepared child exists");
736            // SAFETY: only the raw parent's wait state is observed. The wrapper
737            // remains owned by JobChild and retains all job/kill state and pipes.
738            let wait = unsafe { wrapped_child.inner_child_mut() }.wait();
739            tokio::pin!(wait);
740            tokio::select! {
741                result = tokio::time::timeout(Duration::from_millis(self.args.timeout), &mut wait) => {
742                    match result {
743                        Ok(result) => WaitOutcome::Finished(result),
744                        Err(_) => WaitOutcome::TimedOut,
745                    }
746                }
747                _ = cancellation.cancelled() => WaitOutcome::Cancelled,
748            }
749        };
750        let status = match outcome {
751            WaitOutcome::Finished(result) => {
752                result.map_err(|error| ToolError::execution(error.to_string()))?
753            }
754            WaitOutcome::TimedOut => {
755                grouped.kill_and_reap().await;
756                if let Some(task) = &stdin_task {
757                    task.abort();
758                }
759                let _ = stdout_task.await;
760                let _ = stderr_task.await;
761                return Err(ToolError::execution("bash timed out"));
762            }
763            WaitOutcome::Cancelled => {
764                grouped.kill_and_reap().await;
765                if let Some(task) = &stdin_task {
766                    task.abort();
767                }
768                let _ = stdout_task.await;
769                let _ = stderr_task.await;
770                return Err(ToolError::execution("prepared bash cancelled"));
771            }
772        };
773        if let Some(task) = &stdin_task {
774            task.abort();
775        }
776        grouped.kill_and_reap().await;
777        stdout_task
778            .await
779            .map_err(|error| ToolError::execution(error.to_string()))??;
780        stderr_task
781            .await
782            .map_err(|error| ToolError::execution(error.to_string()))??;
783        Ok(ToolResult {
784            display: Some(completed_display(
785                std::mem::take(&mut *merged.lock().await),
786                status.code(),
787            )),
788            retained_output: None,
789            title: crate::safe_title("Bash"),
790            output: String::new(),
791            metadata: serde_json::json!({"status":status.code(),"success":status.success()}),
792            truncation: None,
793            attachments: Vec::new(),
794            additional_messages: Vec::new(),
795        })
796    }
797}
798
799#[async_trait]
800impl PreparedExecutor for BashExecutor {
801    async fn revalidate(&self) -> Result<(), ToolError> {
802        self.cwd.revalidate()?;
803        self.executable.revalidate()
804    }
805
806    async fn execute(
807        self: Box<Self>,
808        context: ToolExecutionContext,
809    ) -> Result<cookie_agent_engine::ToolCompletion, ToolError> {
810        let result: Result<ToolResult, ToolError> = async move {
811            self.execute_process(context.progress, context.cancellation, context.stdin)
812                .await
813        }
814        .await;
815        result.map(cookie_agent_engine::ToolCompletion::streamed)
816    }
817}
818
819fn completed_display(merged: MergedOutput, status: Option<i32>) -> String {
820    let mut output = merged.text;
821    let suffix = if status != Some(0) {
822        format!(
823            "\nExit status: {}",
824            status.map_or_else(|| "unknown".into(), |code| code.to_string())
825        )
826    } else {
827        String::new()
828    };
829    const MARKER: &str = "\n[display truncated]";
830    let maximum = cookie_agent_protocol::MAX_TOOL_DISPLAY_BYTES - suffix.len();
831    if merged.truncated || output.len() > maximum {
832        let mut end = output.len().min(maximum - MARKER.len());
833        while !output.is_char_boundary(end) {
834            end -= 1;
835        }
836        output.truncate(end);
837        output.push_str(MARKER);
838    }
839    output.push_str(&suffix);
840    output
841}
842
843#[cfg(unix)]
844fn resolve_executable(name: &str) -> Result<PathBuf, ToolError> {
845    let path =
846        env::var_os("PATH").unwrap_or_else(|| OsString::from("/usr/local/bin:/usr/bin:/bin"));
847    resolve_executable_in_path(name, &path)
848}
849
850#[cfg(unix)]
851fn resolve_executable_in_path(name: &str, path: &std::ffi::OsStr) -> Result<PathBuf, ToolError> {
852    for directory in env::split_paths(path) {
853        let candidate = directory.join(name);
854        let Ok(metadata) = std::fs::symlink_metadata(&candidate) else {
855            continue;
856        };
857        if executable_metadata_is_supported(&metadata) {
858            return candidate
859                .canonicalize()
860                .map_err(|error| ToolError::execution(error.to_string()));
861        }
862    }
863    Err(ToolError::execution(format!(
864        "unable to resolve executable `{name}` from PATH during preparation"
865    )))
866}
867
868#[cfg(unix)]
869fn executable_metadata_is_supported(metadata: &std::fs::Metadata) -> bool {
870    !metadata.file_type().is_symlink() && metadata.is_file() && metadata.mode() & 0o111 != 0
871}
872
873#[cfg(windows)]
874fn resolve_executable(name: &str) -> Result<PathBuf, ToolError> {
875    static GIT_BASH: OnceLock<Result<PathBuf, String>> = OnceLock::new();
876
877    if name != "bash" {
878        return Err(ToolError::execution(format!(
879            "Windows executable discovery does not support `{name}`"
880        )));
881    }
882    GIT_BASH
883        .get_or_init(discover_git_bash)
884        .clone()
885        .map_err(ToolError::execution)
886}
887
888#[cfg(windows)]
889fn discover_git_bash() -> Result<PathBuf, String> {
890    for candidate in where_executable("bash") {
891        if let Some(shell) = validate_git_bash(&candidate) {
892            return Ok(shell);
893        }
894    }
895
896    for variable in ["EXEPATH", "MSYSTEM"] {
897        let Some(value) = env::var_os(variable) else {
898            continue;
899        };
900        for hint in env::split_paths(&value) {
901            for candidate in git_bash_candidates_from_hint(&hint) {
902                if let Some(shell) = validate_git_bash(&candidate) {
903                    return Ok(shell);
904                }
905            }
906        }
907    }
908
909    for candidate in [
910        PathBuf::from(r"C:\Program Files\Git\bin\bash.exe"),
911        PathBuf::from(r"C:\Program Files (x86)\Git\bin\bash.exe"),
912    ] {
913        if let Some(shell) = validate_git_bash(&candidate) {
914            return Ok(shell);
915        }
916    }
917
918    for git in where_executable("git") {
919        for candidate in git_bash_candidates_from_git(&git) {
920            if let Some(shell) = validate_git_bash(&candidate) {
921                return Ok(shell);
922            }
923        }
924    }
925
926    Err("git-bash not found; install Git for Windows and ensure its bin directory or git.exe is on PATH"
927        .to_owned())
928}
929
930#[cfg(windows)]
931fn where_executable(name: &str) -> Vec<PathBuf> {
932    let Ok(output) = std::process::Command::new("where.exe").arg(name).output() else {
933        return Vec::new();
934    };
935    if !output.status.success() {
936        return Vec::new();
937    }
938    String::from_utf8_lossy(&output.stdout)
939        .lines()
940        .map(str::trim)
941        .filter(|line| !line.is_empty())
942        .map(PathBuf::from)
943        .collect()
944}
945
946#[cfg(windows)]
947fn git_bash_candidates_from_hint(hint: &Path) -> Vec<PathBuf> {
948    let mut roots = vec![hint.to_owned()];
949    if hint
950        .file_name()
951        .is_some_and(|name| name.eq_ignore_ascii_case("bin") || name.eq_ignore_ascii_case("cmd"))
952        && let Some(parent) = hint.parent()
953    {
954        roots.push(parent.to_owned());
955    }
956    roots
957        .into_iter()
958        .flat_map(|root| {
959            [
960                root.join("bash.exe"),
961                root.join("bin").join("bash.exe"),
962                root.join("usr").join("bin").join("bash.exe"),
963            ]
964        })
965        .collect()
966}
967
968#[cfg(windows)]
969fn git_bash_candidates_from_git(git: &Path) -> Vec<PathBuf> {
970    let Some(directory) = git.parent() else {
971        return Vec::new();
972    };
973    let mut roots = vec![directory.to_owned()];
974    if directory
975        .file_name()
976        .is_some_and(|name| name.eq_ignore_ascii_case("shims"))
977        && let Some(scoop) = directory.parent()
978    {
979        roots.push(scoop.join("apps").join("git").join("current"));
980    }
981    if let Some(parent) = directory.parent() {
982        roots.push(parent.to_owned());
983        if directory
984            .file_name()
985            .is_some_and(|name| name.eq_ignore_ascii_case("bin"))
986            && let Some(grandparent) = parent.parent()
987        {
988            roots.push(grandparent.to_owned());
989        }
990    }
991    roots
992        .into_iter()
993        .flat_map(|root| {
994            [
995                root.join("bin").join("bash.exe"),
996                root.join("usr").join("bin").join("bash.exe"),
997            ]
998        })
999        .collect()
1000}
1001
1002#[cfg(windows)]
1003fn validate_git_bash(candidate: &Path) -> Option<PathBuf> {
1004    let canonical = candidate.canonicalize().ok()?;
1005    if canonical
1006        .file_name()
1007        .is_none_or(|name| !name.eq_ignore_ascii_case("bash.exe"))
1008        || is_system32_bash(&canonical)
1009        || !canonical.metadata().ok()?.is_file()
1010    {
1011        return None;
1012    }
1013
1014    let bin = canonical.parent()?;
1015    let root = if bin
1016        .parent()
1017        .and_then(Path::file_name)
1018        .is_some_and(|name| name.eq_ignore_ascii_case("usr"))
1019    {
1020        bin.parent()?.parent()?
1021    } else {
1022        bin.parent()?
1023    };
1024    [
1025        root.join("cmd").join("git.exe"),
1026        root.join("bin").join("git.exe"),
1027        root.join("mingw64").join("bin").join("git.exe"),
1028        root.join("mingw32").join("bin").join("git.exe"),
1029    ]
1030    .iter()
1031    .any(|git| git.is_file())
1032    .then_some(canonical)
1033}
1034
1035#[cfg(windows)]
1036fn is_system32_bash(path: &Path) -> bool {
1037    is_system32_bash_with_identity(path, canonical_system32_bash())
1038}
1039
1040#[cfg(windows)]
1041fn canonical_system32_bash() -> Option<&'static Path> {
1042    static SYSTEM32_BASH: OnceLock<Option<PathBuf>> = OnceLock::new();
1043
1044    SYSTEM32_BASH
1045        .get_or_init(|| canonical_system32_bash_from_windows_directory(&windows_directory()?))
1046        .as_deref()
1047}
1048
1049#[cfg(windows)]
1050fn windows_directory() -> Option<PathBuf> {
1051    use windows_sys::Win32::System::SystemInformation::GetWindowsDirectoryW;
1052
1053    let mut buffer = vec![0_u16; 260];
1054    loop {
1055        let length =
1056            unsafe { GetWindowsDirectoryW(buffer.as_mut_ptr(), u32::try_from(buffer.len()).ok()?) };
1057        if length == 0 {
1058            break;
1059        }
1060        let length = usize::try_from(length).ok()?;
1061        if length < buffer.len() {
1062            return Some(PathBuf::from(OsString::from_wide(&buffer[..length])));
1063        }
1064        if length > 32_767 {
1065            break;
1066        }
1067        buffer.resize(length + 1, 0);
1068    }
1069
1070    select_windows_directory(
1071        None,
1072        env::var_os("SYSTEMROOT").map(PathBuf::from),
1073        env::var_os("WINDIR").map(PathBuf::from),
1074    )
1075}
1076
1077#[cfg(windows)]
1078fn select_windows_directory(
1079    api: Option<PathBuf>,
1080    system_root: Option<PathBuf>,
1081    windir: Option<PathBuf>,
1082) -> Option<PathBuf> {
1083    api.or(system_root).or(windir)
1084}
1085
1086#[cfg(windows)]
1087fn canonical_system32_bash_from_windows_directory(windows: &Path) -> Option<PathBuf> {
1088    windows
1089        .join("System32")
1090        .canonicalize()
1091        .ok()
1092        .map(|system32| system32.join("bash.exe"))
1093}
1094
1095#[cfg(windows)]
1096fn is_system32_bash_with_identity(path: &Path, system32_bash: Option<&Path>) -> bool {
1097    is_system32_shaped_bash(path)
1098        || system32_bash.is_some_and(|system32| windows_paths_eq(path, system32))
1099}
1100
1101#[cfg(windows)]
1102fn is_system32_shaped_bash(path: &Path) -> bool {
1103    path.file_name()
1104        .is_some_and(|name| name.eq_ignore_ascii_case("bash.exe"))
1105        && path
1106            .parent()
1107            .and_then(Path::file_name)
1108            .is_some_and(|name| name.eq_ignore_ascii_case("System32"))
1109}
1110
1111#[cfg(windows)]
1112fn windows_paths_eq(left: &Path, right: &Path) -> bool {
1113    use std::path::{Component, Prefix};
1114
1115    fn drive_letter(prefix: Prefix<'_>) -> Option<u8> {
1116        match prefix {
1117            Prefix::Disk(letter) | Prefix::VerbatimDisk(letter) => Some(letter),
1118            _ => None,
1119        }
1120    }
1121
1122    fn components_eq(left: Component<'_>, right: Component<'_>) -> bool {
1123        match (left, right) {
1124            (Component::Prefix(left), Component::Prefix(right)) => {
1125                match (drive_letter(left.kind()), drive_letter(right.kind())) {
1126                    (Some(left), Some(right)) => left.eq_ignore_ascii_case(&right),
1127                    _ => left.as_os_str().eq_ignore_ascii_case(right.as_os_str()),
1128                }
1129            }
1130            (left, right) => left.as_os_str().eq_ignore_ascii_case(right.as_os_str()),
1131        }
1132    }
1133
1134    let mut left = left.components();
1135    let mut right = right.components();
1136    loop {
1137        match (left.next(), right.next()) {
1138            (Some(left), Some(right)) if components_eq(left, right) => {}
1139            (None, None) => return true,
1140            _ => return false,
1141        }
1142    }
1143}
1144
1145#[cfg(all(test, unix))]
1146mod tests;
1147
1148#[cfg(all(test, windows))]
1149mod windows_tests {
1150    use std::{
1151        ffi::OsString,
1152        os::windows::ffi::{OsStrExt, OsStringExt},
1153        path::{Path, PathBuf},
1154        process::Command,
1155        time::Duration,
1156    };
1157
1158    use windows_sys::Win32::{
1159        Foundation::{CloseHandle, WAIT_OBJECT_0},
1160        Storage::FileSystem::GetShortPathNameW,
1161        System::Threading::{OpenProcess, WaitForSingleObject},
1162    };
1163
1164    use super::{
1165        CommandWrap, KillOnDrop, ProcessJobObject, canonical_system32_bash,
1166        canonical_system32_bash_from_windows_directory, git_bash_candidates_from_git,
1167        is_system32_bash, is_system32_bash_with_identity, resolve_executable,
1168        select_windows_directory, windows_paths_eq,
1169    };
1170
1171    const TEST_NAME: &str = "bash::windows_tests::job_object_kills_spawned_process_tree";
1172
1173    fn single_quote_for_bash(value: &str) -> String {
1174        format!("'{}'", value.replace('\'', "'\\''"))
1175    }
1176
1177    fn short_path(path: &Path) -> Option<PathBuf> {
1178        let wide = path
1179            .as_os_str()
1180            .encode_wide()
1181            .chain(std::iter::once(0))
1182            .collect::<Vec<_>>();
1183        let required = unsafe { GetShortPathNameW(wide.as_ptr(), std::ptr::null_mut(), 0) };
1184        if required == 0 {
1185            return None;
1186        }
1187        let mut output = vec![0_u16; required as usize];
1188        let written = unsafe { GetShortPathNameW(wide.as_ptr(), output.as_mut_ptr(), required) };
1189        (written != 0 && written < required)
1190            .then(|| PathBuf::from(OsString::from_wide(&output[..written as usize])))
1191    }
1192
1193    #[test]
1194    fn discovery_rejects_wsl_and_covers_scoop_git_shims() {
1195        let system32 = Path::new(r"C:\Windows\System32\bash.exe");
1196        assert!(windows_paths_eq(
1197            Path::new(r"\\?\C:\Windows\System32\bash.exe"),
1198            system32,
1199        ));
1200        assert!(windows_paths_eq(
1201            Path::new(r"c:\WINDOWS\system32\BASH.EXE"),
1202            system32,
1203        ));
1204        assert!(!windows_paths_eq(
1205            Path::new(r"C:\Windows\System32-evil\bash.exe"),
1206            system32,
1207        ));
1208
1209        let candidates =
1210            git_bash_candidates_from_git(Path::new(r"C:\Users\name\scoop\shims\git.exe"));
1211        assert!(candidates.iter().any(|candidate| {
1212            candidate
1213                .to_string_lossy()
1214                .eq_ignore_ascii_case(r"C:\Users\name\scoop\apps\git\current\bin\bash.exe")
1215        }));
1216    }
1217
1218    #[test]
1219    fn system32_guard_fails_closed_without_trusted_identity() {
1220        for candidate in [
1221            r"C:\Windows\System32\bash.exe",
1222            r"\\?\C:\Windows\System32\bash.exe",
1223            r"c:\WINDOWS\system32\BASH.EXE",
1224        ] {
1225            assert!(is_system32_bash_with_identity(Path::new(candidate), None));
1226        }
1227        assert!(!is_system32_bash_with_identity(
1228            Path::new(r"C:\Windows\System32-evil\bash.exe"),
1229            None,
1230        ));
1231    }
1232
1233    #[test]
1234    fn system32_guard_rejects_shape_with_mismatched_identity() {
1235        let fake_identity = Path::new(r"\\?\D:\FakeWindows\System32\bash.exe");
1236        assert!(is_system32_bash_with_identity(
1237            Path::new(r"C:\Windows\System32\bash.exe"),
1238            Some(fake_identity),
1239        ));
1240        assert!(!is_system32_bash_with_identity(
1241            Path::new(r"C:\Windows\System32-evil\bash.exe"),
1242            Some(fake_identity),
1243        ));
1244    }
1245
1246    #[test]
1247    fn windows_directory_resolution_handles_missing_and_unusable_windir() {
1248        let api = PathBuf::from(r"C:\Windows");
1249        assert_eq!(
1250            select_windows_directory(Some(api.clone()), None, None),
1251            Some(api.clone())
1252        );
1253        assert_eq!(
1254            select_windows_directory(None, Some(api.clone()), None),
1255            Some(api)
1256        );
1257        assert_eq!(select_windows_directory(None, None, None), None);
1258
1259        let directory = tempfile::tempdir().expect("temporary root");
1260        let unusable_windir = directory.path().join("missing-windows");
1261        let selected = select_windows_directory(None, None, Some(unusable_windir))
1262            .expect("WINDIR fallback selected");
1263        let identity = canonical_system32_bash_from_windows_directory(&selected);
1264        assert_eq!(identity, None);
1265        assert!(is_system32_bash_with_identity(
1266            Path::new(r"C:\Windows\System32\bash.exe"),
1267            identity.as_deref(),
1268        ));
1269    }
1270
1271    #[test]
1272    fn system32_short_name_is_rejected_when_available() {
1273        let Some(system32) = canonical_system32_bash() else {
1274            return;
1275        };
1276        let Some(short) = short_path(system32) else {
1277            return;
1278        };
1279        if !windows_paths_eq(&short, system32) {
1280            let canonical_short = short.canonicalize().expect("canonicalize 8.3 alias");
1281            assert!(is_system32_bash(&canonical_short));
1282        }
1283    }
1284
1285    #[test]
1286    fn discovered_git_bash_reads_native_windows_paths() {
1287        let shell = resolve_executable("bash").expect("Git Bash on windows-latest");
1288        let directory = tempfile::tempdir().expect("temporary root");
1289        let file = directory.path().join("native path.txt");
1290        std::fs::write(&file, b"native-path-round-trip").expect("write fixture");
1291
1292        let native = file.to_string_lossy();
1293        let slash_form = native.replace('\\', "/");
1294        for path in [native.as_ref(), slash_form.as_str()] {
1295            let output = Command::new(&shell)
1296                .args(["-c", &format!("cat -- {}", single_quote_for_bash(path))])
1297                .output()
1298                .expect("invoke discovered Git Bash");
1299            assert!(
1300                output.status.success(),
1301                "Git Bash could not read {path:?}: {}",
1302                String::from_utf8_lossy(&output.stderr)
1303            );
1304            assert_eq!(output.stdout, b"native-path-round-trip");
1305        }
1306    }
1307
1308    #[tokio::test]
1309    async fn job_object_kills_spawned_process_tree() {
1310        if std::env::var_os("COOKIE_JOB_LEAF").is_some() {
1311            tokio::time::sleep(Duration::from_secs(30)).await;
1312            return;
1313        }
1314        if let Some(pid_file) = std::env::var_os("COOKIE_JOB_PARENT") {
1315            let mut leaves = Vec::new();
1316            for _ in 0..8 {
1317                leaves.push(
1318                    tokio::process::Command::new(std::env::current_exe().unwrap())
1319                        .args(["--exact", TEST_NAME, "--nocapture"])
1320                        .env("COOKIE_JOB_LEAF", "1")
1321                        .spawn()
1322                        .expect("spawn leaf"),
1323                );
1324            }
1325            std::fs::write(
1326                pid_file,
1327                leaves
1328                    .iter()
1329                    .map(|leaf| leaf.id().expect("leaf pid").to_string())
1330                    .collect::<Vec<_>>()
1331                    .join(","),
1332            )
1333            .expect("pid file");
1334            drop(leaves);
1335            return;
1336        }
1337
1338        let directory = tempfile::tempdir().expect("temporary root");
1339        let pid_file = directory.path().join("leaf.pid");
1340        let mut command = tokio::process::Command::new(std::env::current_exe().unwrap());
1341        command
1342            .args(["--exact", TEST_NAME, "--nocapture"])
1343            .env("COOKIE_JOB_PARENT", &pid_file);
1344        let mut wrapped = CommandWrap::from(command);
1345        wrapped.wrap(ProcessJobObject).wrap(KillOnDrop);
1346        let mut parent = wrapped.spawn().expect("spawn suspended job parent");
1347        let leaf_pids = tokio::time::timeout(Duration::from_secs(5), async {
1348            loop {
1349                // The writer's `File::create` is observable before its
1350                // `write_all`; retry until the pid list is fully present.
1351                let ready = std::fs::read_to_string(&pid_file).ok().and_then(|pids| {
1352                    pids.split(',')
1353                        .map(|pid| pid.parse::<u32>().ok())
1354                        .collect::<Option<Vec<_>>>()
1355                });
1356                if let Some(pids) = ready {
1357                    break pids;
1358                }
1359                tokio::time::sleep(Duration::from_millis(10)).await;
1360            }
1361        })
1362        .await
1363        .expect("leaves started");
1364        let parent_status = tokio::time::timeout(Duration::from_secs(2), async {
1365            // SAFETY: the wrapper remains alive and only parent wait state changes.
1366            unsafe { parent.inner_child_mut() }.wait().await
1367        })
1368        .await
1369        .expect("parent prompt return was delayed by descendants")
1370        .expect("parent wait");
1371        assert!(parent_status.success());
1372        parent.start_kill().expect("terminate job");
1373        tokio::time::timeout(Duration::from_secs(5), parent.wait())
1374            .await
1375            .expect("parent terminated")
1376            .expect("parent wait");
1377
1378        const SYNCHRONIZE: u32 = 0x0010_0000;
1379        for leaf_pid in leaf_pids {
1380            let leaf = unsafe { OpenProcess(SYNCHRONIZE, 0, leaf_pid) };
1381            if !leaf.is_null() {
1382                let waited = unsafe { WaitForSingleObject(leaf, 5_000) };
1383                unsafe {
1384                    CloseHandle(leaf);
1385                }
1386                assert_eq!(waited, WAIT_OBJECT_0, "leaf survived job termination");
1387            }
1388        }
1389    }
1390}