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 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 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 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 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 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}