1use std::collections::HashMap;
2use std::fs::File;
3use std::process::Stdio;
4use std::sync::{Arc, Mutex};
5use std::time::Duration;
6
7use command_group::{AsyncCommandGroup, AsyncGroupChild};
8use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
9use tokio::sync::mpsc;
10use tokio_util::sync::CancellationToken;
11
12use crate::error::RuntimeError;
13use crate::task_registry::{TaskDisplay, TaskKind, TaskRegistry, TaskStatus};
14use crate::tool::{BoxFut, Tier, Tool, ToolArgs, ToolCtx, ToolResult};
15use crate::value::Value;
16
17const DEFAULT_SPAWN_TIMEOUT_MS: u64 = 1_800_000;
18const MAX_SPAWN_TIMEOUT_MS: u64 = 86_400_000;
19const DEFAULT_MAX_OUTPUT_BYTES: u64 = 10_485_760;
20const RING_BUFFER_BYTES: usize = 65_536;
21const IO_DRAIN_TIMEOUT: Duration = Duration::from_secs(2);
22const DEFAULT_OUTPUT_LIMIT: usize = 32_000;
23
24#[derive(Debug, Clone, Hash, PartialEq, Eq)]
25pub struct BgHandle {
26 session_id: String,
27 local_id: u64,
28}
29
30impl BgHandle {
31 #[allow(clippy::inherent_to_string)]
32 pub fn to_string(&self) -> String {
33 format!("bg_{}_{}", self.session_id, self.local_id)
34 }
35
36 pub fn parse(s: &str) -> Option<Self> {
37 let rest = s.strip_prefix("bg_")?;
38 let idx = rest.rfind('_')?;
39 let session_id = rest[..idx].to_string();
40 let local_id = rest[idx + 1..].parse().ok()?;
41 Some(Self {
42 session_id,
43 local_id,
44 })
45 }
46}
47
48#[derive(Debug, Clone)]
49pub enum BgStatus {
50 Running {
51 pid: u32,
52 started_at: i64,
53 },
54 Exited {
55 exit_code: i32,
56 started_at: i64,
57 ended_at: i64,
58 },
59 TimedOut {
60 started_at: i64,
61 ended_at: i64,
62 },
63 Killed {
64 started_at: i64,
65 ended_at: i64,
66 },
67 Failed {
68 error: String,
69 started_at: i64,
70 ended_at: i64,
71 },
72}
73
74impl BgStatus {
75 fn kind(&self) -> &'static str {
76 match self {
77 Self::Running { .. } => "running",
78 Self::Exited { .. } => "exited",
79 Self::TimedOut { .. } => "timed_out",
80 Self::Killed { .. } => "killed",
81 Self::Failed { .. } => "failed",
82 }
83 }
84
85 fn exit_code(&self) -> Option<i32> {
86 match self {
87 Self::Exited { exit_code, .. } => Some(*exit_code),
88 _ => None,
89 }
90 }
91
92 fn started_at(&self) -> i64 {
93 match self {
94 Self::Running { started_at, .. }
95 | Self::Exited { started_at, .. }
96 | Self::TimedOut { started_at, .. }
97 | Self::Killed { started_at, .. }
98 | Self::Failed { started_at, .. } => *started_at,
99 }
100 }
101
102 fn ended_at(&self) -> Option<i64> {
103 match self {
104 Self::Exited { ended_at, .. }
105 | Self::TimedOut { ended_at, .. }
106 | Self::Killed { ended_at, .. }
107 | Self::Failed { ended_at, .. } => Some(*ended_at),
108 _ => None,
109 }
110 }
111
112 fn is_finished(&self) -> bool {
113 !matches!(self, Self::Running { .. })
114 }
115
116 fn error(&self) -> Option<&str> {
117 match self {
118 Self::Failed { error, .. } => Some(error),
119 _ => None,
120 }
121 }
122}
123
124#[derive(Debug, Default)]
125pub struct BgOutput {
126 pub combined: Vec<u8>,
127 pub total_bytes: u64,
128 pub truncated: bool,
129 buffer_start: usize,
130}
131
132fn framed_output(kind: StreamKind, data: &[u8]) -> Vec<u8> {
133 let prefix: &[u8] = match kind {
134 StreamKind::Stdout => b"[out] ",
135 StreamKind::Stderr => b"[err] ",
136 };
137 let mut frame = Vec::with_capacity(prefix.len() + data.len() + 1);
138 frame.extend_from_slice(prefix);
139 frame.extend_from_slice(data);
140 if !data.ends_with(b"\n") {
141 frame.push(b'\n');
142 }
143 frame
144}
145
146impl BgOutput {
147 fn push(&mut self, kind: StreamKind, data: &[u8], max: u64) -> Vec<u8> {
148 let mut new_total = self.total_bytes + data.len() as u64;
149 let mut to_write = data;
150 if new_total > max {
151 let allowed = max.saturating_sub(self.total_bytes) as usize;
152 to_write = &data[..allowed.min(data.len())];
153 new_total = max;
154 self.truncated = true;
155 }
156 let frame = if to_write.is_empty() {
157 Vec::new()
158 } else {
159 framed_output(kind, to_write)
160 };
161 self.combined.extend_from_slice(&frame);
162 self.total_bytes = new_total;
163 let max_ring = RING_BUFFER_BYTES;
164 if self.combined.len() > max_ring {
165 let drop = self.combined.len() - max_ring;
166 self.combined.drain(..drop);
167 self.buffer_start += drop;
168 }
169 frame
170 }
171
172 fn read_from(&self, cursor: usize, limit: usize) -> (Vec<u8>, usize, usize, bool, bool) {
173 let end = self.buffer_start + self.combined.len();
174 let start = cursor.max(self.buffer_start).min(end);
175 let local_cursor = start - self.buffer_start;
176 let (chunk, local_next, eof) = page_bytes(&self.combined, local_cursor, limit);
177 (
178 chunk,
179 start,
180 self.buffer_start + local_next,
181 eof,
182 cursor < self.buffer_start,
183 )
184 }
185}
186
187fn page_bytes(data: &[u8], cursor: usize, limit: usize) -> (Vec<u8>, usize, bool) {
188 if cursor >= data.len() {
189 return (Vec::new(), data.len(), true);
190 }
191 let remaining = &data[cursor..];
192 let mut take = remaining.len().min(limit);
193 if take < remaining.len()
194 && let Err(error) = std::str::from_utf8(&remaining[..take])
195 && error.error_len().is_none()
196 && error.valid_up_to() > 0
197 {
198 take = error.valid_up_to();
199 }
200 let chunk = remaining[..take].to_vec();
201 let next = cursor + take;
202 let eof = next >= data.len();
203 (chunk, next, eof)
204}
205
206#[derive(Clone, Copy)]
207enum StreamKind {
208 Stdout,
209 Stderr,
210}
211
212pub(crate) enum BgControl {
213 Kill,
214}
215
216pub struct BgEntry {
217 pub session_id: String,
218 pub(crate) control_tx: mpsc::Sender<BgControl>,
219 pub status: Arc<Mutex<BgStatus>>,
220 pub output: Arc<Mutex<BgOutput>>,
221 pub log_path: std::path::PathBuf,
222 pub task_id: Option<crate::task_registry::TaskId>,
223}
224
225impl crate::watch::Watchable for BgEntry {
226 fn watch_output(
227 self: std::sync::Arc<Self>,
228 pattern: String,
229 cancel: tokio_util::sync::CancellationToken,
230 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = crate::watch::WatchResult> + Send>>
231 {
232 let output = self.output.clone();
233 let status = self.status.clone();
234 Box::pin(async move {
235 loop {
236 tokio::select! {
237 _ = cancel.cancelled() => return crate::watch::WatchResult::Cancelled,
238 _ = tokio::time::sleep(std::time::Duration::from_millis(100)) => {
239 let text = {
240 let out = output.lock().unwrap();
241 String::from_utf8_lossy(&out.combined).into_owned()
242 };
243 if let Some(pos) = text.find(&pattern) {
244 return crate::watch::WatchResult::Matched {
245 row: None,
246 col: Some(pos as u16),
247 text: pattern,
248 };
249 }
250 let st = status.lock().unwrap().clone();
251 if !matches!(st, BgStatus::Running { .. }) {
252 return crate::watch::WatchResult::SourceExited;
253 }
254 }
255 }
256 }
257 })
258 }
259}
260
261struct DirectBackgroundLauncher {
262 command: tokio::process::Command,
263}
264
265impl crate::sandbox::BackgroundLauncher for DirectBackgroundLauncher {
266 fn launch(
267 mut self: Box<Self>,
268 ) -> Result<crate::sandbox::BackgroundSpawnResult, crate::sandbox::SandboxLaunchError> {
269 self.command
270 .group()
271 .kill_on_drop(true)
272 .spawn()
273 .map(crate::sandbox::BackgroundSpawnResult::direct)
274 .map_err(|error| {
275 crate::sandbox::SandboxLaunchError::Runtime(RuntimeError::ToolFailed(format!(
276 "spawn: {error}"
277 )))
278 })
279 }
280}
281
282#[derive(Default)]
283pub struct BgRegistry {
284 entries: Mutex<HashMap<String, Arc<BgEntry>>>,
285 task_registry: Option<TaskRegistry>,
286}
287
288impl BgRegistry {
289 pub fn new() -> Self {
290 Self::default()
291 }
292
293 pub fn with_task_registry(mut self, tr: TaskRegistry) -> Self {
294 self.task_registry = Some(tr);
295 self
296 }
297
298 pub fn kill_all(&self) {
299 let entries = self.entries.lock().unwrap();
300 for (_, entry) in entries.iter() {
301 let _ = entry.control_tx.try_send(BgControl::Kill);
302 }
303 }
304
305 pub fn spawn(
306 self: &Arc<Self>,
307 launcher: Box<dyn crate::sandbox::BackgroundLauncher>,
308 cmd: String,
309 timeout_ms: Option<u64>,
310 max_output_bytes: u64,
311 ctx: &ToolCtx,
312 ) -> Result<Value, RuntimeError> {
313 let session_id = ctx.session_id.clone().unwrap_or_else(|| "anon".to_string());
314 let local_id = uuid::Uuid::now_v7().as_u64_pair().0;
315 let handle = BgHandle {
316 session_id: session_id.clone(),
317 local_id,
318 };
319 let handle_str = handle.to_string();
320
321 let dir = ctx.session_dir.clone().ok_or_else(|| {
322 RuntimeError::ToolFailed("bash.spawn: session_dir not available".into())
323 })?;
324 std::fs::create_dir_all(&dir).map_err(|e| {
325 RuntimeError::ToolFailed(format!("bash.spawn: create session_dir: {e}"))
326 })?;
327 let log_path = dir.join(format!("bg_{}.log", handle_str));
328 let log_file = open_log_file(&log_path)
329 .map_err(|error| RuntimeError::ToolFailed(format!("bash.spawn: {error}")))?;
330
331 let timeout = match timeout_ms {
332 Some(0) => None,
333 Some(ms) => Some(Duration::from_millis(ms.min(MAX_SPAWN_TIMEOUT_MS))),
334 None => Some(Duration::from_millis(DEFAULT_SPAWN_TIMEOUT_MS)),
335 };
336
337 let (control_tx, control_rx) = mpsc::channel::<BgControl>(8);
338 let spawn_result = match launcher.launch() {
339 Ok(result) => result,
340 Err(error) => {
341 drop(log_file);
342 let _ = std::fs::remove_file(&log_path);
343 return Err(error.into_runtime("bash.spawn"));
344 }
345 };
346 let crate::sandbox::BackgroundSpawnResult { child, profile } = spawn_result;
347 let pid = child.id().unwrap_or(0);
348 let status = Arc::new(Mutex::new(BgStatus::Running {
349 pid,
350 started_at: now_ms(),
351 }));
352 let output = Arc::new(Mutex::new(BgOutput::default()));
353 let cancel = ctx.cancel.clone();
354 let task_cancel = cancel.child_token();
355
356 let task_id = self.task_registry.as_ref().map(|tr| {
357 tr.register(
358 TaskKind::Bash,
359 TaskDisplay {
360 label: ctx
361 .call_intent
362 .as_ref()
363 .map(|intent| intent.as_str().to_owned())
364 .unwrap_or_else(|| cmd.clone()),
365 command: Some(cmd.clone()),
366 },
367 handle_str.clone(),
368 session_id.clone(),
369 task_cancel.clone(),
370 )
371 });
372
373 let entry = Arc::new(BgEntry {
374 session_id: session_id.clone(),
375 control_tx,
376 status: status.clone(),
377 output: output.clone(),
378 log_path: log_path.clone(),
379 task_id: task_id.clone(),
380 });
381 {
382 let mut entries = self.entries.lock().unwrap();
383 entries.insert(handle_str.clone(), entry.clone());
384 }
385
386 let status_for_task = status.clone();
387 let log_path_for_return = log_path.clone();
388 let stream_tx = ctx.stream_tx.clone();
389 let handle_for_task = handle_str.clone();
390 let task_registry = self.task_registry.clone();
391 let task_id_for_spawn = task_id.clone();
392 let flow_run_id = ctx.flow_run_id.as_ref().map(|r| r.0.to_string());
393 let call_intent = ctx.call_intent.clone();
394 tokio::spawn(async move {
395 run_bg_process(
396 child,
397 profile,
398 timeout,
399 max_output_bytes,
400 log_file,
401 status_for_task,
402 output,
403 control_rx,
404 task_cancel,
405 stream_tx,
406 handle_for_task,
407 task_registry,
408 task_id_for_spawn,
409 flow_run_id,
410 call_intent,
411 )
412 .await;
413 });
414
415 Ok(Value::Struct(vec![
416 ("handle".into(), Value::Str(handle_str)),
417 ("status".into(), Value::Str("running".into())),
418 ("pid".into(), Value::Int(pid as i64)),
419 (
420 "log_path".into(),
421 Value::Str(log_path_for_return.to_string_lossy().into_owned()),
422 ),
423 ]))
424 }
425
426 pub fn lookup(&self, handle_str: &str, session_id: &str) -> Result<Arc<BgEntry>, RuntimeError> {
427 let handle = BgHandle::parse(handle_str).ok_or_else(|| {
428 RuntimeError::ToolFailed(format!("bash: invalid handle `{handle_str}`"))
429 })?;
430 if handle.session_id != session_id {
431 return Err(RuntimeError::ToolFailed(format!(
432 "bash: handle `{handle_str}` does not belong to session `{session_id}`"
433 )));
434 }
435 let entries = self.entries.lock().unwrap();
436 entries.get(handle_str).cloned().ok_or_else(|| {
437 RuntimeError::ToolFailed(format!("bash: handle `{handle_str}` not found"))
438 })
439 }
440
441 pub fn status(&self, handle_str: &str, session_id: &str) -> Result<Value, RuntimeError> {
442 let entry = self.lookup(handle_str, session_id)?;
443 let st = entry.status.lock().unwrap().clone();
444 let out = entry.output.lock().unwrap();
445 let mut fields = vec![
446 ("handle".into(), Value::Str(handle_str.into())),
447 ("status".into(), Value::Str(st.kind().into())),
448 ("started_at".into(), Value::Int(st.started_at())),
449 (
450 "log_path".into(),
451 Value::Str(entry.log_path.to_string_lossy().into_owned()),
452 ),
453 ];
454 if let Some(ec) = st.exit_code() {
455 fields.push(("exit_code".into(), Value::Int(ec as i64)));
456 }
457 if let Some(ended) = st.ended_at() {
458 fields.push(("ended_at".into(), Value::Int(ended)));
459 }
460 if let Some(error) = st.error() {
461 fields.push(("error".into(), Value::Str(error.into())));
462 }
463 fields.push(("bytes_total".into(), Value::Int(out.total_bytes as i64)));
464 fields.push(("output_truncated".into(), Value::Bool(out.truncated)));
465 Ok(Value::Struct(fields))
466 }
467
468 pub fn output(
469 &self,
470 handle_str: &str,
471 session_id: &str,
472 session_dir: Option<&std::path::Path>,
473 cursor: usize,
474 limit: usize,
475 ) -> Result<Value, RuntimeError> {
476 if let Ok(entry) = self.lookup(handle_str, session_id) {
477 let st = entry.status.lock().unwrap().clone();
478 let out = entry.output.lock().unwrap();
479 let (chunk, actual_cursor, next, eof, fell_behind) = out.read_from(cursor, limit);
480 return Ok(Value::Struct(vec![
481 ("handle".into(), Value::Str(handle_str.into())),
482 ("status".into(), Value::Str(st.kind().into())),
483 (
484 "chunk".into(),
485 Value::Str(String::from_utf8_lossy(&chunk).into_owned()),
486 ),
487 ("cursor".into(), Value::Int(actual_cursor as i64)),
488 ("next_cursor".into(), Value::Int(next as i64)),
489 (
490 "continuation".into(),
491 Value::Struct(vec![
492 ("type".into(), Value::Str("ByteCursor".into())),
493 ("next_byte".into(), Value::Int(next as i64)),
494 ("has_more".into(), Value::Bool(!eof)),
495 ]),
496 ),
497 ("eof".into(), Value::Bool(eof)),
498 (
499 "truncated".into(),
500 Value::Bool(out.truncated || fell_behind),
501 ),
502 ("live".into(), Value::Bool(true)),
503 ]));
504 }
505
506 let Some(dir) = session_dir else {
507 return Err(RuntimeError::ToolFailed(format!(
508 "bash: handle `{handle_str}` not found"
509 )));
510 };
511 let log_path = dir.join(format!("bg_{handle_str}.log"));
512 let data = std::fs::read(&log_path).map_err(|_| {
513 RuntimeError::ToolFailed(format!("bash: handle `{handle_str}` not found"))
514 })?;
515 if cursor >= data.len() {
516 return Ok(Value::Struct(vec![
517 ("handle".into(), Value::Str(handle_str.into())),
518 ("status".into(), Value::Str("exited".into())),
519 ("chunk".into(), Value::Str(String::new())),
520 ("cursor".into(), Value::Int(data.len() as i64)),
521 ("next_cursor".into(), Value::Int(data.len() as i64)),
522 (
523 "continuation".into(),
524 Value::Struct(vec![
525 ("type".into(), Value::Str("ByteCursor".into())),
526 ("next_byte".into(), Value::Int(data.len() as i64)),
527 ("has_more".into(), Value::Bool(false)),
528 ]),
529 ),
530 ("eof".into(), Value::Bool(true)),
531 ("truncated".into(), Value::Bool(false)),
532 ("live".into(), Value::Bool(false)),
533 ]));
534 }
535 let (chunk, next, eof) = page_bytes(&data, cursor, limit);
536 Ok(Value::Struct(vec![
537 ("handle".into(), Value::Str(handle_str.into())),
538 ("status".into(), Value::Str("exited".into())),
539 (
540 "chunk".into(),
541 Value::Str(String::from_utf8_lossy(&chunk).into_owned()),
542 ),
543 ("cursor".into(), Value::Int(cursor as i64)),
544 ("next_cursor".into(), Value::Int(next as i64)),
545 (
546 "continuation".into(),
547 Value::Struct(vec![
548 ("type".into(), Value::Str("ByteCursor".into())),
549 ("next_byte".into(), Value::Int(next as i64)),
550 ("has_more".into(), Value::Bool(!eof)),
551 ]),
552 ),
553 ("eof".into(), Value::Bool(eof)),
554 ("truncated".into(), Value::Bool(false)),
555 ("live".into(), Value::Bool(false)),
556 ]))
557 }
558
559 pub fn output_for_llm(
560 &self,
561 handle_str: &str,
562 session_id: &str,
563 session_dir: Option<&std::path::Path>,
564 cursor: usize,
565 limit: usize,
566 output_store: Option<&crate::tools::tool_output::OutputStore>,
567 ) -> Result<Value, RuntimeError> {
568 let persisted = || {
569 let dir = session_dir.ok_or_else(|| {
570 RuntimeError::ToolFailed(
571 "bash.output: complete persisted output is unavailable; output is truncated and cannot be continued".into(),
572 )
573 })?;
574 let log_path = dir.join(format!("bg_{handle_str}.log"));
575 std::fs::read_to_string(log_path).map_err(|_| {
576 RuntimeError::ToolFailed(
577 "bash.output: complete persisted output is unavailable; output is truncated and cannot be continued".into(),
578 )
579 })
580 };
581 let full = if let Ok(entry) = self.lookup(handle_str, session_id) {
582 let out = entry.output.lock().unwrap();
583 if out.buffer_start == 0 && !out.truncated {
584 String::from_utf8(out.combined.clone()).map_err(|_| {
585 RuntimeError::ToolFailed(
586 "bash.output: current output is not valid UTF-8; output is truncated and cannot be continued".into(),
587 )
588 })?
589 } else {
590 drop(out);
591 persisted()?
592 }
593 } else {
594 persisted()?
595 };
596 if full.len() <= limit {
597 return self.output(handle_str, session_id, session_dir, cursor, limit);
598 }
599 let store = output_store.ok_or_else(|| {
600 RuntimeError::ToolFailed(
601 "bash.output: session output store unavailable; oversized output cannot be continued".into(),
602 )
603 })?;
604 let output_id = store.register(handle_str, &full).ok_or_else(|| {
605 RuntimeError::ToolFailed(
606 "bash.output: complete output could not be persisted; output is truncated and cannot be continued".into(),
607 )
608 })?;
609 let offset = cursor.min(full.len());
610 if !full.is_char_boundary(offset) {
611 return Err(RuntimeError::ToolFailed(
612 "bash.output: cursor is not a valid UTF-8 byte boundary".into(),
613 ));
614 }
615 let (chunk, next, eof) = page_bytes(full.as_bytes(), offset, limit);
616 Ok(Value::Struct(vec![
617 (
618 "content".into(),
619 Value::Str(String::from_utf8(chunk).map_err(|_| {
620 RuntimeError::ToolFailed(
621 "bash.output: persisted output is not valid UTF-8".into(),
622 )
623 })?),
624 ),
625 ("output_id".into(), Value::Str(output_id)),
626 ("total_bytes".into(), Value::Int(full.len() as i64)),
627 (
628 "next".into(),
629 Value::Struct(vec![
630 ("mode".into(), Value::Str("bytes".into())),
631 ("offset".into(), Value::Int(next as i64)),
632 ("has_more".into(), Value::Bool(!eof)),
633 ]),
634 ),
635 ]))
636 }
637
638 pub fn kill(&self, handle_str: &str, session_id: &str) -> Result<Value, RuntimeError> {
639 let entry = self.lookup(handle_str, session_id)?;
640 let _ = entry.control_tx.try_send(BgControl::Kill);
641 let st = entry.status.lock().unwrap().clone();
642 Ok(Value::Struct(vec![
643 ("handle".into(), Value::Str(handle_str.into())),
644 ("status".into(), Value::Str(st.kind().into())),
645 ]))
646 }
647
648 #[doc(hidden)]
649 pub fn clear_for_test(&self) {
650 self.entries.lock().unwrap().clear();
651 }
652
653 pub fn list(
654 &self,
655 session_id: &str,
656 session_dir: Option<&std::path::Path>,
657 all: bool,
658 ) -> Value {
659 let entries = self.entries.lock().unwrap();
660 let mut live_handles: std::collections::HashSet<String> = std::collections::HashSet::new();
661 let mut items: Vec<Value> = entries
662 .iter()
663 .filter(|(_, e)| e.session_id == session_id)
664 .map(|(handle, entry)| {
665 live_handles.insert(handle.clone());
666 let st = entry.status.lock().unwrap().clone();
667 let out = entry.output.lock().unwrap();
668 let mut fields = vec![
669 ("handle".into(), Value::Str(handle.clone())),
670 ("status".into(), Value::Str(st.kind().into())),
671 ("started_at".into(), Value::Int(st.started_at())),
672 ("live".into(), Value::Bool(true)),
673 ];
674 if let Some(ec) = st.exit_code() {
675 fields.push(("exit_code".into(), Value::Int(ec as i64)));
676 }
677 fields.push(("bytes_total".into(), Value::Int(out.total_bytes as i64)));
678 Value::Struct(fields)
679 })
680 .collect();
681
682 if all {
683 if let Some(dir) = session_dir {
684 if let Ok(rd) = std::fs::read_dir(dir) {
685 for entry in rd.flatten() {
686 let name = entry.file_name();
687 let name = name.to_string_lossy();
688 let Some(rest) = name
689 .strip_prefix("bg_")
690 .and_then(|s| s.strip_suffix(".log"))
691 else {
692 continue;
693 };
694 let handle: String = rest.to_string();
695 if live_handles.contains(&handle) {
696 continue;
697 }
698 let Ok(meta) = entry.metadata() else {
699 continue;
700 };
701 let modified = meta
702 .modified()
703 .ok()
704 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
705 .map(|d| d.as_millis() as i64)
706 .unwrap_or(0);
707 items.push(Value::Struct(vec![
708 ("handle".into(), Value::Str(handle)),
709 ("status".into(), Value::Str("exited".into())),
710 ("started_at".into(), Value::Int(modified)),
711 ("live".into(), Value::Bool(false)),
712 ("bytes_total".into(), Value::Int(meta.len() as i64)),
713 ]));
714 }
715 }
716 }
717 }
718
719 Value::List(items)
720 }
721}
722
723impl Drop for BgRegistry {
724 fn drop(&mut self) {
725 self.kill_all();
726 }
727}
728
729#[allow(clippy::too_many_arguments)]
730async fn run_bg_process(
731 mut child: AsyncGroupChild,
732 _profile: Option<crate::sandbox::TempProfile>,
733 timeout: Option<Duration>,
734 max_output_bytes: u64,
735 log_file: File,
736 status: Arc<Mutex<BgStatus>>,
737 output: Arc<Mutex<BgOutput>>,
738 mut control_rx: mpsc::Receiver<BgControl>,
739 cancel: CancellationToken,
740 stream_tx: Option<tokio::sync::broadcast::Sender<crate::stream::StreamFrame>>,
741 handle_for_stream: String,
742 task_registry: Option<TaskRegistry>,
743 task_id: Option<crate::task_registry::TaskId>,
744 flow_run_id: Option<String>,
745 call_intent: Option<crate::message::ToolCallIntent>,
746) {
747 let stdout = child.inner().stdout.take();
748 let stderr = child.inner().stderr.take();
749 let (log_tx, log_rx) = mpsc::unbounded_channel::<Vec<u8>>();
750 let log_writer = tokio::spawn(write_log(log_file, log_rx));
751
752 let stdout_reader = stdout.map(|s| {
753 let ctx = ReadStreamCtx {
754 output: output.clone(),
755 log_tx: log_tx.clone(),
756 kind: StreamKind::Stdout,
757 max_output_bytes,
758 stream_tx: stream_tx.clone(),
759 handle: handle_for_stream.clone(),
760 flow_run_id: flow_run_id.clone(),
761 call_intent: call_intent.clone(),
762 };
763 tokio::spawn(read_stream(BufReader::new(s), ctx))
764 });
765 let stderr_reader = stderr.map(|s| {
766 let ctx = ReadStreamCtx {
767 output: output.clone(),
768 log_tx: log_tx.clone(),
769 kind: StreamKind::Stderr,
770 max_output_bytes,
771 stream_tx: stream_tx.clone(),
772 handle: handle_for_stream.clone(),
773 flow_run_id: flow_run_id.clone(),
774 call_intent: call_intent.clone(),
775 };
776 tokio::spawn(read_stream(BufReader::new(s), ctx))
777 });
778
779 let exit_reason = tokio::select! {
780 biased;
781 _ = cancel.cancelled() => ExitReason::Cancelled,
782 ctrl = control_rx.recv() => {
783 match ctrl {
784 Some(BgControl::Kill) => ExitReason::Kill,
785 None => ExitReason::Natural,
786 }
787 }
788 _ = async {
789 if let Some(t) = timeout {
790 tokio::time::sleep(t).await;
791 } else {
792 std::future::pending::<()>().await;
793 }
794 } => ExitReason::Timeout,
795 s = child.wait() => ExitReason::Exited(s),
796 };
797
798 let started_at = status.lock().unwrap().started_at();
799 let ended_at = now_ms();
800 let mut final_status = match &exit_reason {
801 ExitReason::Exited(Ok(s)) => BgStatus::Exited {
802 exit_code: s.code().unwrap_or(-1),
803 started_at,
804 ended_at,
805 },
806 ExitReason::Timeout => {
807 let _ = child.start_kill();
808 let _ = tokio::time::timeout(Duration::from_millis(500), child.wait()).await;
809 BgStatus::TimedOut {
810 started_at,
811 ended_at,
812 }
813 }
814 ExitReason::Kill => {
815 let _ = child.start_kill();
816 let _ = tokio::time::timeout(Duration::from_millis(500), child.wait()).await;
817 BgStatus::Killed {
818 started_at,
819 ended_at,
820 }
821 }
822 ExitReason::Cancelled => {
823 let _ = child.start_kill();
824 let _ = tokio::time::timeout(Duration::from_millis(500), child.wait()).await;
825 BgStatus::Killed {
826 started_at,
827 ended_at,
828 }
829 }
830 ExitReason::Exited(Err(_)) => {
831 let _ = child.start_kill();
832 BgStatus::Failed {
833 error: "wait failed".into(),
834 started_at,
835 ended_at,
836 }
837 }
838 ExitReason::Natural => {
839 let s = child.wait().await;
840 BgStatus::Exited {
841 exit_code: s.ok().and_then(|s| s.code()).unwrap_or(-1),
842 started_at,
843 ended_at: now_ms(),
844 }
845 }
846 };
847
848 if let Some(mut r) = stdout_reader {
849 if tokio::time::timeout(IO_DRAIN_TIMEOUT, &mut r)
850 .await
851 .is_err()
852 {
853 r.abort();
854 let _ = r.await;
855 }
856 }
857 if let Some(mut r) = stderr_reader {
858 if tokio::time::timeout(IO_DRAIN_TIMEOUT, &mut r)
859 .await
860 .is_err()
861 {
862 r.abort();
863 let _ = r.await;
864 }
865 }
866 drop(log_tx);
867 let mut log_writer = log_writer;
868 let log_result = match tokio::time::timeout(IO_DRAIN_TIMEOUT, &mut log_writer).await {
869 Ok(Ok(result)) => result,
870 Ok(Err(join_error)) => Err(format!("log writer task failed: {join_error}")),
871 Err(_) => {
872 log_writer.abort();
873 let _ = log_writer.await;
874 Err("log writer timed out".into())
875 }
876 };
877 if let Err(error) = log_result {
878 final_status = BgStatus::Failed {
879 error,
880 started_at,
881 ended_at: now_ms(),
882 };
883 }
884
885 let exit_code = match &final_status {
886 BgStatus::Exited { exit_code, .. } => Some(*exit_code),
887 _ => None,
888 };
889 *status.lock().unwrap() = final_status.clone();
890
891 if let Some(tx) = &stream_tx {
892 let _ = tx.send(crate::stream::StreamFrame::BashExited {
893 handle: handle_for_stream,
894 exit_code,
895 error: final_status.error().map(str::to_owned),
896 call_intent,
897 run_id: flow_run_id,
898 });
899 }
900
901 if let (Some(tr), Some(tid)) = (task_registry, task_id) {
902 let ts = match &final_status {
903 BgStatus::Exited { exit_code, .. } if *exit_code == 0 => TaskStatus::Ok,
904 BgStatus::Killed { .. } | BgStatus::TimedOut { .. } => TaskStatus::Killed,
905 _ => TaskStatus::Err,
906 };
907 tr.finish(&tid, ts);
908 }
909}
910
911fn open_log_file(log_path: &std::path::Path) -> Result<File, String> {
912 File::options()
913 .write(true)
914 .create_new(true)
915 .open(log_path)
916 .map_err(|e| format!("open log: {e}"))
917}
918
919async fn write_log(file: File, mut log_rx: mpsc::UnboundedReceiver<Vec<u8>>) -> Result<(), String> {
920 let mut file = tokio::fs::File::from_std(file);
921 while let Some(frame) = log_rx.recv().await {
922 file.write_all(&frame)
923 .await
924 .map_err(|e| format!("write log: {e}"))?;
925 }
926 file.flush().await.map_err(|e| format!("flush log: {e}"))?;
927 Ok(())
928}
929
930struct ReadStreamCtx {
931 output: Arc<Mutex<BgOutput>>,
932 log_tx: mpsc::UnboundedSender<Vec<u8>>,
933 kind: StreamKind,
934 max_output_bytes: u64,
935 stream_tx: Option<tokio::sync::broadcast::Sender<crate::stream::StreamFrame>>,
936 handle: String,
937 flow_run_id: Option<String>,
938 call_intent: Option<crate::message::ToolCallIntent>,
939}
940
941async fn read_stream<R: tokio::io::AsyncBufRead + Unpin>(mut reader: R, ctx: ReadStreamCtx) {
942 let kind_str = match ctx.kind {
943 StreamKind::Stdout => "stdout",
944 StreamKind::Stderr => "stderr",
945 };
946 let mut buf = String::new();
947 loop {
948 buf.clear();
949 match reader.read_line(&mut buf).await {
950 Ok(0) => break,
951 Ok(_) => {
952 let data = buf.as_bytes();
953 {
954 let mut out = ctx.output.lock().unwrap();
955 let _ = ctx.log_tx.send(framed_output(ctx.kind, data));
956 let _ = out.push(ctx.kind, data, ctx.max_output_bytes);
957 }
958 if let Some(tx) = &ctx.stream_tx {
959 let _ = tx.send(crate::stream::StreamFrame::BashChunk {
960 handle: ctx.handle.clone(),
961 kind: kind_str.to_string(),
962 line: buf.clone(),
963 call_intent: ctx.call_intent.clone(),
964 run_id: ctx.flow_run_id.clone(),
965 });
966 }
967 }
968 Err(_) => break,
969 }
970 }
971}
972
973enum ExitReason {
974 Exited(std::io::Result<std::process::ExitStatus>),
975 Timeout,
976 Kill,
977 Cancelled,
978 Natural,
979}
980
981fn now_ms() -> i64 {
982 chrono::Utc::now().timestamp_millis()
983}
984
985pub struct BashSpawn;
986
987impl Tool for BashSpawn {
988 fn name(&self) -> &str {
989 "bash.spawn"
990 }
991
992 fn tier(&self) -> Tier {
993 Tier::Four
994 }
995
996 fn description(&self) -> Option<&str> {
997 Some(
998 "Run a shell command via `sh -c`.\n\n\
999block=false (default): command runs in background, returns immediately with a\n\
1000handle. The command keeps running — use bash.output to read its output later,\n\
1001bash.status to check if it finished, bash.kill to stop it. Use this for:\n\
1002- long-running commands (servers, watchers)\n\
1003- commands where you need to check output incrementally\n\
1004- when you want to do other things while the command runs\n\n\
1005block=true: waits for the command to finish, then returns stdout/stderr/exit_code.\n\
1006Use block_timeout_ms to set a max wait (default 30s). Use this for:\n\
1007- short commands where you need the result immediately (ls, git status, echo)\n\
1008- commands that finish quickly\n\n\
1009Set cwd to the narrowest directory the command needs to access. In controlled\n\
1010modes, cwd is the filesystem scope shown for approval and opened by the process\n\
1011sandbox; paths embedded only in cmd do not expand sandbox access.\n\n\
1012Do NOT use `sleep` in your command to wait — use block=true with block_timeout_ms\n\
1013instead, or use the sleep tool to pause the workflow.",
1014 )
1015 }
1016
1017 fn input_schema(&self) -> serde_json::Value {
1018 serde_json::json!({
1019 "type": "object",
1020 "properties": {
1021 "cmd": {"type": "string", "description": "Shell command line."},
1022 "cwd": {"type": "string", "description": "Working directory and sandbox filesystem scope. Set this to the narrowest directory needed for paths outside the current workspace."},
1023 "block": {"type": "boolean", "default": false, "description": "If true, wait for process to exit before returning."},
1024 "block_timeout_ms": {"type": "integer", "description": "Only with block=true. Max wait. 0 = no timeout. Default 30000."},
1025 "timeout_ms": {"type": "integer", "description": "Process kill timeout in ms. Default 1800000 (30min). 0 = no timeout."},
1026 "max_output_bytes": {"type": "integer", "description": "Max combined output bytes. Default 10485760 (10MB)."}
1027 },
1028 "required": ["cmd"]
1029 })
1030 }
1031
1032 fn invocation_provenance(
1033 &self,
1034 args: &ToolArgs,
1035 ctx: &ToolCtx,
1036 ) -> Result<crate::permission::ResourceProvenance, RuntimeError> {
1037 let explicit_cwd = extract_optional_string(args, "cwd").map(std::path::PathBuf::from);
1038 Ok(crate::permission::ResourceProvenance::for_ctx(ctx)
1039 .with_cwd(ctx, explicit_cwd.as_deref())?
1040 .with_risk(crate::trust::RiskKind::ProcessSpawn))
1041 }
1042
1043 fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1044 Box::pin(async move {
1045 let cmd = extract_string(&args, "cmd", 0)?;
1046 let block = args
1047 .named("block")
1048 .and_then(|v| {
1049 if let Value::Bool(b) = v {
1050 Some(*b)
1051 } else {
1052 None
1053 }
1054 })
1055 .unwrap_or(false);
1056 let block_timeout_ms = extract_optional_int(&args, "block_timeout_ms")
1057 .map(|v| v as u64)
1058 .unwrap_or(30_000);
1059 let timeout_ms = extract_optional_int(&args, "timeout_ms").map(|v| v as u64);
1060 let max_output = extract_optional_int(&args, "max_output_bytes")
1061 .map(|v| v as u64)
1062 .unwrap_or(DEFAULT_MAX_OUTPUT_BYTES);
1063 let registry = ctx.bg_registry.clone().ok_or_else(|| {
1064 RuntimeError::ToolFailed("bash.spawn: registry not available".into())
1065 })?;
1066 let explicit_cwd = extract_optional_string(&args, "cwd").map(std::path::PathBuf::from);
1067 let cwd = ctx.resolve_cwd(explicit_cwd.as_deref())?;
1068 let authorization = ctx.invocation_authorization_for("bash.spawn")?;
1069 let launcher: Box<dyn crate::sandbox::BackgroundLauncher> = match authorization
1070 .execution_boundary()
1071 {
1072 crate::permission::ExecutionBoundary::Sandboxed => {
1073 let sandbox = ctx.sandbox.as_ref().ok_or_else(|| {
1074 RuntimeError::ToolFailed(
1075 "bash.spawn: sandbox unavailable for controlled execution".into(),
1076 )
1077 })?;
1078 sandbox
1079 .prepare_background(&["sh", "-c", cmd.as_str()], &[], &cwd, authorization)
1080 .map_err(|error| error.into_runtime("bash.spawn"))?
1081 }
1082 crate::permission::ExecutionBoundary::Direct => {
1083 let mut command = tokio::process::Command::new("sh");
1084 command
1085 .arg("-c")
1086 .arg(&cmd)
1087 .stdin(Stdio::null())
1088 .stdout(Stdio::piped())
1089 .stderr(Stdio::piped())
1090 .current_dir(&cwd);
1091 Box::new(DirectBackgroundLauncher { command })
1092 }
1093 };
1094 let handle_str = registry.spawn(launcher, cmd, timeout_ms, max_output, ctx)?;
1095
1096 if !block {
1097 return Ok(handle_str);
1098 }
1099
1100 let handle_s = handle_str
1101 .field("handle")
1102 .and_then(|v| {
1103 if let Value::Str(s) = v {
1104 Some(s.clone())
1105 } else {
1106 None
1107 }
1108 })
1109 .ok_or_else(|| {
1110 RuntimeError::ToolFailed("bash.spawn: missing handle field".into())
1111 })?;
1112 let session_id = ctx.session_id.clone().unwrap_or_else(|| "anon".into());
1113
1114 let deadline = if block_timeout_ms == 0 {
1115 None
1116 } else {
1117 Some(tokio::time::Instant::now() + Duration::from_millis(block_timeout_ms))
1118 };
1119 loop {
1120 let entry = registry.lookup(&handle_s, &session_id)?;
1121 let finished = {
1122 let st = entry.status.lock().unwrap();
1123 st.is_finished()
1124 };
1125 if finished {
1126 break;
1127 }
1128 if let Some(d) = deadline {
1129 if tokio::time::Instant::now() >= d {
1130 break;
1131 }
1132 }
1133 tokio::time::sleep(Duration::from_millis(50)).await;
1134 }
1135
1136 let entry = registry.lookup(&handle_s, &session_id)?;
1137 let st = entry.status.lock().unwrap().clone();
1138 let out = entry.output.lock().unwrap();
1139 let combined = String::from_utf8_lossy(&out.combined).into_owned();
1140 let log_path = entry.log_path.to_string_lossy().into_owned();
1141 Ok(Value::Struct(vec![
1142 ("handle".into(), Value::Str(handle_s)),
1143 ("status".into(), Value::Str(st.kind().into())),
1144 (
1145 "exit_code".into(),
1146 st.exit_code()
1147 .map(|c| Value::Int(c as i64))
1148 .unwrap_or(Value::Unit),
1149 ),
1150 (
1151 "error".into(),
1152 st.error()
1153 .map(|e| Value::Str(e.into()))
1154 .unwrap_or(Value::Unit),
1155 ),
1156 ("output".into(), Value::Str(combined)),
1157 ("bytes_total".into(), Value::Int(out.total_bytes as i64)),
1158 ("log_path".into(), Value::Str(log_path)),
1159 ]))
1160 })
1161 }
1162}
1163
1164pub struct BashStatus;
1165
1166impl Tool for BashStatus {
1167 fn name(&self) -> &str {
1168 "bash.status"
1169 }
1170
1171 fn tier(&self) -> Tier {
1172 Tier::Four
1173 }
1174
1175 fn description(&self) -> Option<&str> {
1176 Some("Check the status of a background bash process.")
1177 }
1178
1179 fn input_schema(&self) -> serde_json::Value {
1180 serde_json::json!({
1181 "type": "object",
1182 "properties": {"handle": {"type": "string"}},
1183 "required": ["handle"]
1184 })
1185 }
1186
1187 fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1188 Box::pin(async move {
1189 let handle = extract_string(&args, "handle", 0)?;
1190 let session_id = ctx.session_id.clone().unwrap_or_else(|| "anon".to_string());
1191 let registry = ctx.bg_registry.clone().ok_or_else(|| {
1192 RuntimeError::ToolFailed("bash.status: registry not available".into())
1193 })?;
1194 registry.status(&handle, &session_id)
1195 })
1196 }
1197}
1198
1199pub struct BashOutput;
1200
1201impl Tool for BashOutput {
1202 fn name(&self) -> &str {
1203 "bash.output"
1204 }
1205
1206 fn tier(&self) -> Tier {
1207 Tier::Four
1208 }
1209
1210 fn description(&self) -> Option<&str> {
1211 Some("Read output from a background bash process by byte cursor.")
1212 }
1213
1214 fn input_schema(&self) -> serde_json::Value {
1215 serde_json::json!({
1216 "type": "object",
1217 "properties": {
1218 "handle": {"type": "string"},
1219 "cursor": {"type": "integer", "description": "Byte offset to start reading. Default 0."},
1220 "limit_bytes": {"type": "integer", "description": "Max bytes to return. Default 32000."}
1221 },
1222 "required": ["handle"]
1223 })
1224 }
1225
1226 fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1227 Box::pin(async move {
1228 let handle = extract_string(&args, "handle", 0)?;
1229 let cursor = extract_optional_int(&args, "cursor").unwrap_or(0).max(0) as usize;
1230 let limit = (extract_optional_int(&args, "limit_bytes")
1231 .unwrap_or(DEFAULT_OUTPUT_LIMIT as i64)
1232 .max(1) as usize)
1233 .min(ctx.tool_output_budget.max_bytes);
1234 let session_id = ctx.session_id.clone().unwrap_or_else(|| "anon".to_string());
1235 let registry = ctx.bg_registry.clone().ok_or_else(|| {
1236 RuntimeError::ToolFailed("bash.output: registry not available".into())
1237 })?;
1238 registry.output_for_llm(
1239 &handle,
1240 &session_id,
1241 ctx.session_dir.as_deref(),
1242 cursor,
1243 limit,
1244 ctx.output_store.as_deref(),
1245 )
1246 })
1247 }
1248}
1249
1250pub struct BashKill;
1251
1252impl Tool for BashKill {
1253 fn name(&self) -> &str {
1254 "bash.kill"
1255 }
1256
1257 fn tier(&self) -> Tier {
1258 Tier::Four
1259 }
1260
1261 fn description(&self) -> Option<&str> {
1262 Some(
1263 "Kill a background bash process. signal=term (default) sends SIGTERM, signal=kill sends SIGKILL.",
1264 )
1265 }
1266
1267 fn input_schema(&self) -> serde_json::Value {
1268 serde_json::json!({
1269 "type": "object",
1270 "properties": {
1271 "handle": {"type": "string"},
1272 "signal": {"type": "string", "enum": ["term", "kill"], "description": "Default term."}
1273 },
1274 "required": ["handle"]
1275 })
1276 }
1277
1278 fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1279 Box::pin(async move {
1280 let handle = extract_string(&args, "handle", 0)?;
1281 let _ = extract_string(&args, "signal", 1);
1282 let session_id = ctx.session_id.clone().unwrap_or_else(|| "anon".to_string());
1283 let registry = ctx.bg_registry.clone().ok_or_else(|| {
1284 RuntimeError::ToolFailed("bash.kill: registry not available".into())
1285 })?;
1286 registry.kill(&handle, &session_id)
1287 })
1288 }
1289}
1290
1291pub struct BashList;
1292
1293impl Tool for BashList {
1294 fn name(&self) -> &str {
1295 "bash.list"
1296 }
1297
1298 fn tier(&self) -> Tier {
1299 Tier::Four
1300 }
1301
1302 fn description(&self) -> Option<&str> {
1303 Some(
1304 "List background bash processes for the current session. Default: only live processes. Pass all=true to include historical processes whose log files persist in session_dir (status=exited, live=false).",
1305 )
1306 }
1307
1308 fn input_schema(&self) -> serde_json::Value {
1309 serde_json::json!({
1310 "type": "object",
1311 "properties": {
1312 "all": {"type": "boolean", "description": "Include historical processes (default false)."}
1313 }
1314 })
1315 }
1316
1317 fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1318 Box::pin(async move {
1319 let all = extract_optional_bool(&args, "all").unwrap_or(false);
1320 let session_id = ctx.session_id.clone().unwrap_or_else(|| "anon".to_string());
1321 let registry = ctx.bg_registry.clone().ok_or_else(|| {
1322 RuntimeError::ToolFailed("bash.list: registry not available".into())
1323 })?;
1324 Ok(registry.list(&session_id, ctx.session_dir.as_deref(), all))
1325 })
1326 }
1327}
1328
1329fn extract_string(args: &ToolArgs, name: &str, pos: usize) -> Result<String, RuntimeError> {
1330 let value = match args.named(name) {
1331 Some(v) => v,
1332 None => args.positional(pos)?,
1333 };
1334 match value {
1335 Value::Str(s) => Ok(s.clone()),
1336 other => Err(RuntimeError::TypeMismatch {
1337 expected: "string".into(),
1338 actual: other.kind_name().into(),
1339 }),
1340 }
1341}
1342
1343fn extract_optional_int(args: &ToolArgs, name: &str) -> Option<i64> {
1344 match args.named(name)? {
1345 Value::Int(n) => Some(*n),
1346 _ => None,
1347 }
1348}
1349
1350fn extract_optional_bool(args: &ToolArgs, name: &str) -> Option<bool> {
1351 match args.named(name)? {
1352 Value::Bool(b) => Some(*b),
1353 _ => None,
1354 }
1355}
1356
1357fn extract_optional_string(args: &ToolArgs, name: &str) -> Option<String> {
1358 match args.named(name)? {
1359 Value::Str(value) => Some(value.clone()),
1360 _ => None,
1361 }
1362}
1363
1364#[cfg(test)]
1365mod tests {
1366 use super::*;
1367 use crate::tool::{ToolArgs, ToolCtx};
1368 use crate::value::Value;
1369 use std::sync::Arc;
1370 use std::sync::atomic::{AtomicUsize, Ordering};
1371 use tempfile::TempDir;
1372
1373 #[derive(Clone, Copy)]
1374 enum StrictLaunch {
1375 Success,
1376 Denied,
1377 RuntimeError,
1378 }
1379
1380 struct TestBackgroundLauncher;
1381
1382 #[derive(Clone, Copy)]
1383 enum LauncherFailure {
1384 Denied,
1385 Runtime,
1386 }
1387
1388 struct CountingFailingLauncher {
1389 launch_calls: Arc<AtomicUsize>,
1390 provisional_logs_seen: Arc<AtomicUsize>,
1391 session_dir: std::path::PathBuf,
1392 failure: LauncherFailure,
1393 }
1394
1395 impl crate::sandbox::BackgroundLauncher for CountingFailingLauncher {
1396 fn launch(
1397 self: Box<Self>,
1398 ) -> Result<crate::sandbox::BackgroundSpawnResult, crate::sandbox::SandboxLaunchError>
1399 {
1400 self.launch_calls.fetch_add(1, Ordering::SeqCst);
1401 self.provisional_logs_seen
1402 .store(background_log_count(&self.session_dir), Ordering::SeqCst);
1403 match self.failure {
1404 LauncherFailure::Denied => Err(crate::sandbox::SandboxLaunchError::Denied(
1405 Box::new(crate::sandbox::SandboxDenial {
1406 operation: crate::sandbox::SandboxOperation::BackgroundSpawn,
1407 reason: "launcher denied sentinel".into(),
1408 provenance: crate::permission::ResourceProvenance::none(),
1409 }),
1410 )),
1411 LauncherFailure::Runtime => Err(crate::sandbox::SandboxLaunchError::Runtime(
1412 RuntimeError::ToolFailed("launcher runtime sentinel".into()),
1413 )),
1414 }
1415 }
1416 }
1417
1418 impl crate::sandbox::BackgroundLauncher for TestBackgroundLauncher {
1419 fn launch(
1420 self: Box<Self>,
1421 ) -> Result<crate::sandbox::BackgroundSpawnResult, crate::sandbox::SandboxLaunchError>
1422 {
1423 let mut command = tokio::process::Command::new("sh");
1424 command
1425 .arg("-c")
1426 .arg("exit 0")
1427 .stdin(Stdio::null())
1428 .stdout(Stdio::piped())
1429 .stderr(Stdio::piped());
1430 let child = command
1431 .group()
1432 .kill_on_drop(true)
1433 .spawn()
1434 .map_err(|error| {
1435 crate::sandbox::SandboxLaunchError::Runtime(RuntimeError::ToolFailed(format!(
1436 "test spawn: {error}"
1437 )))
1438 })?;
1439 Ok(crate::sandbox::BackgroundSpawnResult::direct(child))
1440 }
1441 }
1442
1443 struct RecordingBackgroundSandbox {
1444 strict: StrictLaunch,
1445 strict_calls: AtomicUsize,
1446 cwd: Mutex<Option<std::path::PathBuf>>,
1447 }
1448
1449 impl crate::sandbox::Sandbox for RecordingBackgroundSandbox {
1450 fn spawn<'a>(
1451 &'a self,
1452 _cmd: &'a [&'a str],
1453 _env: &'a [(String, String)],
1454 _cwd: &'a std::path::Path,
1455 _authorization: &'a crate::permission::InvocationAuthorization,
1456 ) -> crate::tool::BoxFut<'a, Result<std::process::Output, RuntimeError>> {
1457 Box::pin(async { Err(RuntimeError::ToolFailed("unsupported".into())) })
1458 }
1459
1460 fn prepare_background(
1461 &self,
1462 _cmd: &[&str],
1463 _env: &[(String, String)],
1464 cwd: &std::path::Path,
1465 authorization: &crate::permission::InvocationAuthorization,
1466 ) -> Result<Box<dyn crate::sandbox::BackgroundLauncher>, crate::sandbox::SandboxLaunchError>
1467 {
1468 self.strict_calls.fetch_add(1, Ordering::SeqCst);
1469 *self.cwd.lock().unwrap() = Some(cwd.to_path_buf());
1470 match self.strict {
1471 StrictLaunch::Success => Ok(Box::new(TestBackgroundLauncher)),
1472 StrictLaunch::Denied => Err(crate::sandbox::SandboxLaunchError::Denied(Box::new(
1473 crate::sandbox::SandboxDenial {
1474 operation: crate::sandbox::SandboxOperation::BackgroundSpawn,
1475 reason: "strict denied".into(),
1476 provenance: authorization.provenance().clone(),
1477 },
1478 ))),
1479 StrictLaunch::RuntimeError => Err(crate::sandbox::SandboxLaunchError::Runtime(
1480 RuntimeError::ToolFailed("strict runtime sentinel".into()),
1481 )),
1482 }
1483 }
1484
1485 fn spawn_pty<'a>(
1486 &'a self,
1487 _cmd: &'a [&'a str],
1488 _env: &'a [(String, String)],
1489 _cwd: &'a std::path::Path,
1490 _pty_size: portable_pty::PtySize,
1491 _authorization: &'a crate::permission::InvocationAuthorization,
1492 ) -> crate::tool::BoxFut<
1493 'a,
1494 Result<crate::sandbox::PtySpawnResult, crate::sandbox::SandboxLaunchError>,
1495 > {
1496 Box::pin(async {
1497 Err(crate::sandbox::SandboxLaunchError::Runtime(
1498 RuntimeError::ToolFailed("unsupported".into()),
1499 ))
1500 })
1501 }
1502
1503 fn is_available(&self) -> bool {
1504 true
1505 }
1506
1507 fn kind(&self) -> &'static str {
1508 "test-background"
1509 }
1510 }
1511
1512 fn ctx_with_registry(registry: Arc<BgRegistry>, dir: &std::path::Path) -> ToolCtx {
1513 let mut ctx = ToolCtx::new().with_trust(crate::trust::TrustConfig {
1514 mode: crate::trust::TrustMode::Reckless,
1515 ..crate::trust::TrustConfig::default()
1516 });
1517 ctx.bg_registry = Some(registry);
1518 ctx.session_dir = Some(dir.to_path_buf());
1519 ctx.session_id = Some("test-session".to_string());
1520 ctx.for_tool_invocation(crate::tool::Tier::Four)
1521 .authorized_for(crate::permission::InvocationAuthorization::new(
1522 crate::permission::PermissionRequestId::now(),
1523 "test-call",
1524 "bash.spawn",
1525 crate::permission::ResourceProvenance::none(),
1526 crate::permission::ExecutionBoundary::Direct,
1527 ))
1528 }
1529
1530 fn brokered_spawn_ctx(
1531 registry: Arc<BgRegistry>,
1532 dir: &std::path::Path,
1533 trust: crate::trust::TrustConfig,
1534 ) -> ToolCtx {
1535 let flows = Arc::new(crate::tools::agent_ctrl::FlowRegistry::new());
1536 let broker = crate::permission::PermissionBroker::shared(Arc::clone(&flows));
1537 let run_id = crate::event::FlowRunId::now();
1538 let identity = flows
1539 .register_root(
1540 "test-session".into(),
1541 run_id.clone(),
1542 crate::flow_authority::EffectiveAuthority::root(&trust, true, None),
1543 )
1544 .unwrap();
1545 let mut ctx = ctx_with_registry(registry, dir);
1546 ctx.approval = Some(Arc::new(crate::session::ApprovalRegistry::new()));
1547 ctx.permission_broker = Some(broker);
1548 ctx.flow_registry = Some(flows);
1549 ctx.flow_identity = Some(identity);
1550 ctx.flow_run_id = Some(run_id);
1551 ctx.with_trust(trust)
1552 .for_tool_invocation(crate::tool::Tier::Four)
1553 }
1554
1555 async fn authorize_bash_spawn(ctx: ToolCtx, args: &ToolArgs) -> ToolCtx {
1556 match crate::approval::request_approval(
1557 &ctx,
1558 "bash.spawn",
1559 "bash.spawn",
1560 args,
1561 crate::tool::ApprovalLevel::Dangerous,
1562 Some(&BashSpawn),
1563 )
1564 .await
1565 {
1566 crate::approval::ApprovalOutcome::Approve { authorization } => {
1567 ctx.authorized_for(*authorization)
1568 }
1569 crate::approval::ApprovalOutcome::Deny { reason } => {
1570 panic!("strict bash spawn authorization denied: {reason}")
1571 }
1572 }
1573 }
1574
1575 fn eager_sandbox_policy() -> crate::trust::TrustConfig {
1576 crate::trust::TrustConfig {
1577 mode: crate::trust::TrustMode::Eager,
1578 escalation: crate::trust::EscalationPolicy::Deny,
1579 ..crate::trust::TrustConfig::default()
1580 }
1581 }
1582
1583 fn background_entries(registry: &BgRegistry, dir: &std::path::Path) -> usize {
1584 match registry.list("test-session", Some(dir), false) {
1585 Value::List(items) => items.len(),
1586 other => panic!("expected background list, got {other:?}"),
1587 }
1588 }
1589
1590 fn background_log_count(dir: &std::path::Path) -> usize {
1591 std::fs::read_dir(dir)
1592 .map(|entries| {
1593 entries
1594 .filter_map(Result::ok)
1595 .filter(|entry| {
1596 let name = entry.file_name();
1597 let name = name.to_string_lossy();
1598 name.starts_with("bg_") && name.ends_with(".log")
1599 })
1600 .count()
1601 })
1602 .unwrap_or(0)
1603 }
1604
1605 #[test]
1606 fn spawn_provenance_uses_explicit_cwd_not_cmd_as_path() {
1607 let dir = TempDir::new().unwrap();
1608 let ctx = ctx_with_registry(Arc::new(BgRegistry::new()), dir.path());
1609 let args = ToolArgs {
1610 named: vec![
1611 ("cmd".into(), Value::Str("/bin/echo hi".into())),
1612 ("cwd".into(), Value::Str(dir.path().display().to_string())),
1613 ],
1614 ..ToolArgs::default()
1615 };
1616 let provenance = BashSpawn.invocation_provenance(&args, &ctx).unwrap();
1617 assert_eq!(provenance.path, None);
1618 assert_eq!(
1619 provenance.cwd,
1620 Some(crate::fs_access::canonicalize_stable(dir.path()))
1621 );
1622 assert!(
1623 provenance
1624 .risks
1625 .contains(&crate::trust::RiskKind::ProcessSpawn)
1626 );
1627 }
1628
1629 #[test]
1630 fn pushed_frame_matches_live_output_after_budget_truncation() {
1631 let mut output = BgOutput::default();
1632 let first = output.push(StreamKind::Stdout, b"first\n", 6);
1633 let second = output.push(StreamKind::Stderr, b"second\n", 6);
1634
1635 assert_eq!(first, b"[out] first\n");
1636 assert!(second.is_empty());
1637 assert_eq!(output.combined, first);
1638 assert_eq!(output.total_bytes, 6);
1639 assert!(output.truncated);
1640 }
1641
1642 #[test]
1643 fn ring_buffer_cursors_remain_absolute_after_eviction() {
1644 let first_data = vec![b'a'; 40_000];
1645 let second_data = vec![b'b'; 40_000];
1646 let mut full = Vec::new();
1647 full.extend_from_slice(b"[out] ");
1648 full.extend_from_slice(&first_data);
1649 full.push(b'\n');
1650 full.extend_from_slice(b"[out] ");
1651 full.extend_from_slice(&second_data);
1652 full.push(b'\n');
1653
1654 let mut output = BgOutput::default();
1655 output.push(StreamKind::Stdout, &first_data, u64::MAX);
1656 let (_, _, cursor, _, fell_behind) = output.read_from(0, 32_000);
1657 assert_eq!(cursor, 32_000);
1658 assert!(!fell_behind);
1659
1660 output.push(StreamKind::Stdout, &second_data, u64::MAX);
1661 assert!(output.buffer_start > 0);
1662 let (chunk, actual_cursor, next, _, fell_behind) = output.read_from(cursor, 32_000);
1663 assert_eq!(actual_cursor, cursor);
1664 assert_eq!(chunk, full[cursor..next]);
1665 assert!(!fell_behind);
1666
1667 let (chunk, actual_cursor, next, _, fell_behind) = output.read_from(0, 32);
1668 assert_eq!(actual_cursor, output.buffer_start);
1669 assert_eq!(chunk, full[actual_cursor..next]);
1670 assert!(fell_behind);
1671
1672 let (chunk, actual_cursor, next, eof, fell_behind) = output.read_from(usize::MAX, 32);
1673 assert!(chunk.is_empty());
1674 assert_eq!(actual_cursor, full.len());
1675 assert_eq!(next, full.len());
1676 assert!(eof);
1677 assert!(!fell_behind);
1678 }
1679
1680 #[test]
1681 fn page_bytes_preserves_utf8_across_pages() {
1682 let data = "你好世界".as_bytes();
1683 let (first, next, eof) = page_bytes(data, 0, 4);
1684 assert_eq!(first, "你".as_bytes());
1685 assert_eq!(next, 3);
1686 assert!(!eof);
1687 let (second, next, eof) = page_bytes(data, next, 4);
1688 assert_eq!(second, "好".as_bytes());
1689 assert_eq!(next, 6);
1690 assert!(!eof);
1691 }
1692
1693 #[test]
1694 fn persisted_output_pages_utf8_and_returns_continuation() {
1695 let registry = BgRegistry::new();
1696 let dir = TempDir::new().unwrap();
1697 let handle = "missing";
1698 std::fs::write(dir.path().join("bg_missing.log"), "你好").unwrap();
1699
1700 let first = registry
1701 .output(handle, "test-session", Some(dir.path()), 0, 4)
1702 .unwrap();
1703 let Value::Struct(fields) = first else {
1704 panic!("expected output fields");
1705 };
1706 assert!(matches!(
1707 fields.iter().find(|(name, _)| name == "chunk"),
1708 Some((_, Value::Str(chunk))) if chunk == "你"
1709 ));
1710 assert!(matches!(
1711 fields.iter().find(|(name, _)| name == "next_cursor"),
1712 Some((_, Value::Int(3)))
1713 ));
1714
1715 let second = registry
1716 .output(handle, "test-session", Some(dir.path()), 3, 4)
1717 .unwrap();
1718 let Value::Struct(fields) = second else {
1719 panic!("expected output fields");
1720 };
1721 assert!(matches!(
1722 fields.iter().find(|(name, _)| name == "chunk"),
1723 Some((_, Value::Str(chunk))) if chunk == "好"
1724 ));
1725 let Value::Struct(cursor) = fields
1726 .iter()
1727 .find_map(|(name, value)| (name == "continuation").then_some(value))
1728 .unwrap()
1729 else {
1730 panic!("expected continuation");
1731 };
1732 assert!(matches!(
1733 cursor.iter().find(|(name, _)| name == "next_byte"),
1734 Some((_, Value::Int(6)))
1735 ));
1736 assert!(matches!(
1737 cursor.iter().find(|(name, _)| name == "has_more"),
1738 Some((_, Value::Bool(false)))
1739 ));
1740
1741 let beyond_eof = registry
1742 .output(handle, "test-session", Some(dir.path()), 99, 4)
1743 .unwrap();
1744 let Value::Struct(fields) = beyond_eof else {
1745 panic!("expected output fields");
1746 };
1747 assert!(matches!(
1748 fields.iter().find(|(name, _)| name == "cursor"),
1749 Some((_, Value::Int(6)))
1750 ));
1751 assert!(matches!(
1752 fields.iter().find(|(name, _)| name == "next_cursor"),
1753 Some((_, Value::Int(6)))
1754 ));
1755 let Value::Struct(cursor) = fields
1756 .iter()
1757 .find_map(|(name, value)| (name == "continuation").then_some(value))
1758 .unwrap()
1759 else {
1760 panic!("expected continuation");
1761 };
1762 assert!(matches!(
1763 cursor.iter().find(|(name, _)| name == "next_byte"),
1764 Some((_, Value::Int(6)))
1765 ));
1766 }
1767
1768 #[test]
1769 fn oversized_output_registers_and_reassembles_through_output_store() {
1770 let registry = BgRegistry::new();
1771 let dir = TempDir::new().unwrap();
1772 let full = format!("{}{}", "前缀🚀".repeat(104_857), "前缀");
1773 assert_eq!(full.len(), 1_048_576);
1774 std::fs::write(dir.path().join("bg_missing.log"), full.as_bytes()).unwrap();
1775 let store = crate::tools::tool_output::OutputStore::at(dir.path());
1776
1777 let value = registry
1778 .output_for_llm(
1779 "missing",
1780 "test-session",
1781 Some(dir.path()),
1782 0,
1783 1024,
1784 Some(&store),
1785 )
1786 .unwrap();
1787 let Value::Struct(fields) = value else {
1788 panic!("expected output fields");
1789 };
1790 let output_id = fields
1791 .iter()
1792 .find_map(|(name, value)| (name == "output_id").then_some(value))
1793 .and_then(|value| match value {
1794 Value::Str(id) => Some(id.clone()),
1795 _ => None,
1796 })
1797 .unwrap();
1798 let initial_content = fields
1799 .iter()
1800 .find_map(|(name, value)| (name == "content").then_some(value))
1801 .and_then(|value| match value {
1802 Value::Str(content) => Some(content.clone()),
1803 _ => None,
1804 })
1805 .unwrap();
1806 let total_bytes = fields
1807 .iter()
1808 .find_map(|(name, value)| (name == "total_bytes").then_some(value))
1809 .and_then(|value| match value {
1810 Value::Int(total_bytes) => Some(*total_bytes as usize),
1811 _ => None,
1812 })
1813 .unwrap();
1814 let Value::Struct(next_fields) = fields
1815 .iter()
1816 .find_map(|(name, value)| (name == "next").then_some(value))
1817 .unwrap()
1818 else {
1819 panic!("expected next fields");
1820 };
1821 let next_offset = next_fields
1822 .iter()
1823 .find_map(|(name, value)| (name == "offset").then_some(value))
1824 .and_then(|value| match value {
1825 Value::Int(offset) => Some(*offset as usize),
1826 _ => None,
1827 })
1828 .unwrap();
1829 let has_more = next_fields
1830 .iter()
1831 .find_map(|(name, value)| (name == "has_more").then_some(value))
1832 .and_then(|value| match value {
1833 Value::Bool(has_more) => Some(*has_more),
1834 _ => None,
1835 })
1836 .unwrap();
1837 assert!(output_id.starts_with("out_"));
1838 assert_eq!(total_bytes, 1_048_576);
1839 assert_eq!(next_offset, initial_content.len());
1840 assert!(next_offset <= 1_024);
1841 assert!(full.is_char_boundary(next_offset));
1842 assert!(has_more);
1843 assert!(!fields.iter().any(|(name, _)| name == "continuation"));
1844
1845 let budget = crate::tools::tool_output::ToolOutputBudget {
1846 max_lines: usize::MAX,
1847 max_bytes: 1024,
1848 max_line_bytes: usize::MAX,
1849 };
1850 let mut offset = next_offset;
1851 let mut assembled = initial_content;
1852 assert!(has_more);
1853 loop {
1854 let page = store.read_bytes(&output_id, offset, 1024, budget).unwrap();
1855 assembled.push_str(&page.content);
1856 if !page.has_more {
1857 break;
1858 }
1859 offset = page.next_offset;
1860 }
1861 assert_eq!(assembled.len(), total_bytes);
1862 assert_eq!(assembled, full);
1863 }
1864
1865 #[test]
1866 fn page_bytes_always_advances_for_incomplete_utf8() {
1867 let data = [0xf0, 0x9f, 0x9a, 0x80];
1868 let (chunk, next, eof) = page_bytes(&data, 0, 1);
1869 assert_eq!(chunk, vec![0xf0]);
1870 assert_eq!(next, 1);
1871 assert!(!eof);
1872 }
1873
1874 #[test]
1875 fn failed_status_preserves_error_reason() {
1876 let status = BgStatus::Failed {
1877 error: "open log: permission denied".into(),
1878 started_at: 1,
1879 ended_at: 2,
1880 };
1881 assert_eq!(status.kind(), "failed");
1882 assert_eq!(status.error(), Some("open log: permission denied"));
1883 assert_eq!(status.exit_code(), None);
1884 assert!(status.is_finished());
1885 }
1886
1887 #[test]
1888 fn handle_parse_roundtrip() {
1889 let h = BgHandle {
1890 session_id: "abc".into(),
1891 local_id: 42,
1892 };
1893 let s = h.to_string();
1894 assert_eq!(s, "bg_abc_42");
1895 let back = BgHandle::parse(&s).unwrap();
1896 assert_eq!(back, h);
1897 }
1898
1899 #[test]
1900 fn handle_parse_rejects_bad_format() {
1901 assert!(BgHandle::parse("not_bg").is_none());
1902 assert!(BgHandle::parse("bg_nosuffix").is_none());
1903 assert!(BgHandle::parse("bg_x_notnum").is_none());
1904 }
1905
1906 #[test]
1907 fn log_file_reports_open_failure_before_spawn() {
1908 let dir = TempDir::new().unwrap();
1909 let log_path = dir.path().join("log");
1910 std::fs::create_dir(&log_path).unwrap();
1911
1912 let error = open_log_file(&log_path).unwrap_err();
1913 assert!(error.contains("open log"));
1914 }
1915
1916 #[test]
1917 fn bg_registry_pre_launch_session_dir_failure_does_not_call_launcher() {
1918 let registry = Arc::new(BgRegistry::new());
1919 let dir = TempDir::new().unwrap();
1920 let session_path = dir.path().join("not-a-directory");
1921 std::fs::write(&session_path, b"occupied").unwrap();
1922 let launch_calls = Arc::new(AtomicUsize::new(0));
1923 let launcher = Box::new(CountingFailingLauncher {
1924 launch_calls: launch_calls.clone(),
1925 provisional_logs_seen: Arc::new(AtomicUsize::new(0)),
1926 session_dir: session_path.clone(),
1927 failure: LauncherFailure::Runtime,
1928 });
1929 let ctx = ctx_with_registry(registry.clone(), &session_path);
1930
1931 let error = registry
1932 .spawn(launcher, "ignored".into(), None, 1024, &ctx)
1933 .unwrap_err();
1934
1935 assert!(error.to_string().contains("create session_dir"));
1936 assert_eq!(launch_calls.load(Ordering::SeqCst), 0);
1937 assert_eq!(background_entries(®istry, &session_path), 0);
1938 }
1939
1940 #[test]
1941 fn bg_registry_launcher_denied_removes_provisional_log_and_registry_entry() {
1942 let task_registry = crate::task_registry::TaskRegistry::new();
1943 let registry = Arc::new(BgRegistry::new().with_task_registry(task_registry.clone()));
1944 let dir = TempDir::new().unwrap();
1945 let launch_calls = Arc::new(AtomicUsize::new(0));
1946 let provisional_logs_seen = Arc::new(AtomicUsize::new(0));
1947 let launcher = Box::new(CountingFailingLauncher {
1948 launch_calls: launch_calls.clone(),
1949 provisional_logs_seen: provisional_logs_seen.clone(),
1950 session_dir: dir.path().to_path_buf(),
1951 failure: LauncherFailure::Denied,
1952 });
1953 let ctx = ctx_with_registry(registry.clone(), dir.path());
1954
1955 let error = registry
1956 .spawn(launcher, "ignored".into(), None, 1024, &ctx)
1957 .unwrap_err();
1958
1959 assert!(error.to_string().contains("launcher denied sentinel"));
1960 assert_eq!(launch_calls.load(Ordering::SeqCst), 1);
1961 assert_eq!(provisional_logs_seen.load(Ordering::SeqCst), 1);
1962 assert_eq!(background_log_count(dir.path()), 0);
1963 assert_eq!(background_entries(®istry, dir.path()), 0);
1964 assert_eq!(task_registry.running_count(), 0);
1965 assert!(
1966 task_registry
1967 .list(&crate::task_registry::TaskFilter::all())
1968 .is_empty()
1969 );
1970 }
1971
1972 #[test]
1973 fn bg_registry_launcher_runtime_error_removes_provisional_log_and_registry_entry() {
1974 let task_registry = crate::task_registry::TaskRegistry::new();
1975 let registry = Arc::new(BgRegistry::new().with_task_registry(task_registry.clone()));
1976 let dir = TempDir::new().unwrap();
1977 let launch_calls = Arc::new(AtomicUsize::new(0));
1978 let provisional_logs_seen = Arc::new(AtomicUsize::new(0));
1979 let launcher = Box::new(CountingFailingLauncher {
1980 launch_calls: launch_calls.clone(),
1981 provisional_logs_seen: provisional_logs_seen.clone(),
1982 session_dir: dir.path().to_path_buf(),
1983 failure: LauncherFailure::Runtime,
1984 });
1985 let ctx = ctx_with_registry(registry.clone(), dir.path());
1986
1987 let error = registry
1988 .spawn(launcher, "ignored".into(), None, 1024, &ctx)
1989 .unwrap_err();
1990
1991 assert!(error.to_string().contains("launcher runtime sentinel"));
1992 assert_eq!(launch_calls.load(Ordering::SeqCst), 1);
1993 assert_eq!(provisional_logs_seen.load(Ordering::SeqCst), 1);
1994 assert_eq!(background_log_count(dir.path()), 0);
1995 assert_eq!(background_entries(®istry, dir.path()), 0);
1996 assert_eq!(task_registry.running_count(), 0);
1997 assert!(
1998 task_registry
1999 .list(&crate::task_registry::TaskFilter::all())
2000 .is_empty()
2001 );
2002 }
2003
2004 #[tokio::test]
2005 async fn sandbox_background_strict_success_registers_process() {
2006 let registry = Arc::new(BgRegistry::new());
2007 let dir = TempDir::new().unwrap();
2008 let sandbox = Arc::new(RecordingBackgroundSandbox {
2009 strict: StrictLaunch::Success,
2010 strict_calls: AtomicUsize::new(0),
2011 cwd: Mutex::new(None),
2012 });
2013 let args = ToolArgs {
2014 positional: vec![Value::Str("ignored".into())],
2015 named: vec![("cwd".into(), Value::Str(dir.path().display().to_string()))],
2016 };
2017 let ctx = brokered_spawn_ctx(registry.clone(), dir.path(), eager_sandbox_policy())
2018 .with_sandbox(sandbox.clone());
2019 let ctx = authorize_bash_spawn(ctx, &args).await;
2020
2021 BashSpawn.call(args, &ctx).await.unwrap();
2022
2023 assert_eq!(sandbox.strict_calls.load(Ordering::SeqCst), 1);
2024 assert_eq!(
2025 *sandbox.cwd.lock().unwrap(),
2026 Some(crate::fs_access::canonicalize_stable(dir.path()))
2027 );
2028 assert_eq!(background_entries(®istry, dir.path()), 1);
2029 registry.kill_all();
2030 }
2031
2032 #[tokio::test]
2033 async fn direct_authorization_skips_available_sandbox() {
2034 let registry = Arc::new(BgRegistry::new());
2035 let dir = TempDir::new().unwrap();
2036 let sandbox = Arc::new(RecordingBackgroundSandbox {
2037 strict: StrictLaunch::Denied,
2038 strict_calls: AtomicUsize::new(0),
2039 cwd: Mutex::new(None),
2040 });
2041 let args = ToolArgs {
2042 positional: vec![Value::Str("exit 0".into())],
2043 named: vec![("block".into(), Value::Bool(true))],
2044 };
2045 let ctx =
2046 ctx_with_registry(Arc::clone(®istry), dir.path()).with_sandbox(sandbox.clone());
2047
2048 let result = BashSpawn.call(args, &ctx).await.unwrap();
2049
2050 assert_eq!(sandbox.strict_calls.load(Ordering::SeqCst), 0);
2051 assert!(matches!(result.field("exit_code"), Some(Value::Int(0))));
2052 registry.kill_all();
2053 }
2054
2055 #[tokio::test]
2056 async fn sandbox_background_typed_denial_never_relaunches() {
2057 let registry = Arc::new(BgRegistry::new());
2058 let dir = TempDir::new().unwrap();
2059 let sandbox = Arc::new(RecordingBackgroundSandbox {
2060 strict: StrictLaunch::Denied,
2061 strict_calls: AtomicUsize::new(0),
2062 cwd: Mutex::new(None),
2063 });
2064 let args = ToolArgs {
2065 positional: vec![Value::Str("ignored".into())],
2066 named: vec![],
2067 };
2068 let ctx = brokered_spawn_ctx(registry.clone(), dir.path(), eager_sandbox_policy())
2069 .with_sandbox(sandbox.clone());
2070 let ctx = authorize_bash_spawn(ctx, &args).await;
2071
2072 let error = BashSpawn.call(args, &ctx).await.unwrap_err();
2073
2074 assert!(error.to_string().contains("strict denied"));
2075 assert_eq!(sandbox.strict_calls.load(Ordering::SeqCst), 1);
2076 assert_eq!(background_entries(®istry, dir.path()), 0);
2077 }
2078
2079 #[tokio::test]
2080 async fn sandbox_background_runtime_error_never_falls_back_or_registers() {
2081 let registry = Arc::new(BgRegistry::new());
2082 let dir = TempDir::new().unwrap();
2083 let sandbox = Arc::new(RecordingBackgroundSandbox {
2084 strict: StrictLaunch::RuntimeError,
2085 strict_calls: AtomicUsize::new(0),
2086 cwd: Mutex::new(None),
2087 });
2088 let args = ToolArgs {
2089 positional: vec![Value::Str("ignored".into())],
2090 named: vec![],
2091 };
2092 let ctx = brokered_spawn_ctx(registry.clone(), dir.path(), eager_sandbox_policy())
2093 .with_sandbox(sandbox.clone());
2094 let ctx = authorize_bash_spawn(ctx, &args).await;
2095
2096 let error = BashSpawn.call(args, &ctx).await.unwrap_err();
2097
2098 assert!(error.to_string().contains("strict runtime sentinel"));
2099 assert_eq!(sandbox.strict_calls.load(Ordering::SeqCst), 1);
2100 assert_eq!(background_entries(®istry, dir.path()), 0);
2101 }
2102
2103 #[tokio::test]
2104 async fn sandbox_background_denial_leaves_registry_unchanged() {
2105 let registry = Arc::new(BgRegistry::new());
2106 let dir = TempDir::new().unwrap();
2107 let sandbox = Arc::new(RecordingBackgroundSandbox {
2108 strict: StrictLaunch::Denied,
2109 strict_calls: AtomicUsize::new(0),
2110 cwd: Mutex::new(None),
2111 });
2112 let args = ToolArgs {
2113 positional: vec![Value::Str("ignored".into())],
2114 named: vec![],
2115 };
2116 let ctx = brokered_spawn_ctx(registry.clone(), dir.path(), eager_sandbox_policy())
2117 .with_sandbox(sandbox.clone());
2118 let ctx = authorize_bash_spawn(ctx, &args).await;
2119
2120 let error = BashSpawn.call(args, &ctx).await.unwrap_err();
2121
2122 assert!(error.to_string().contains("denied"));
2123 assert_eq!(sandbox.strict_calls.load(Ordering::SeqCst), 1);
2124 assert_eq!(background_entries(®istry, dir.path()), 0);
2125 }
2126
2127 #[tokio::test]
2128 async fn spawn_returns_immediately_with_running_status() {
2129 let registry = Arc::new(BgRegistry::new());
2130 let dir = TempDir::new().unwrap();
2131 let ctx = ctx_with_registry(registry.clone(), dir.path());
2132 let args = ToolArgs {
2133 positional: vec![Value::Str("echo hello".into())],
2134 named: vec![],
2135 };
2136 let v = BashSpawn.call(args, &ctx).await.unwrap();
2137 let Value::Struct(fields) = v else {
2138 panic!("expected struct")
2139 };
2140 let handle = fields
2141 .iter()
2142 .find(|(k, _)| k == "handle")
2143 .and_then(|(_, v)| {
2144 if let Value::Str(s) = v {
2145 Some(s.clone())
2146 } else {
2147 None
2148 }
2149 })
2150 .unwrap();
2151 assert!(handle.starts_with("bg_"));
2152 let status_val = fields.iter().find(|(k, _)| k == "status").unwrap();
2153 assert!(matches!(&status_val.1, Value::Str(s) if s == "running"));
2154 }
2155
2156 #[tokio::test]
2157 async fn spawn_then_status_reaches_exited() {
2158 let registry = Arc::new(BgRegistry::new());
2159 let dir = TempDir::new().unwrap();
2160 let ctx = ctx_with_registry(registry.clone(), dir.path());
2161 let spawn_args = ToolArgs {
2162 positional: vec![Value::Str("echo hello".into())],
2163 named: vec![],
2164 };
2165 let v = BashSpawn.call(spawn_args, &ctx).await.unwrap();
2166 let Value::Struct(fields) = v else { panic!() };
2167 let handle = fields
2168 .iter()
2169 .find(|(k, _)| k == "handle")
2170 .and_then(|(_, v)| {
2171 if let Value::Str(s) = v {
2172 Some(s.clone())
2173 } else {
2174 None
2175 }
2176 })
2177 .unwrap();
2178
2179 for _ in 0..50 {
2180 tokio::time::sleep(Duration::from_millis(50)).await;
2181 let status_args = ToolArgs {
2182 positional: vec![Value::Str(handle.clone())],
2183 named: vec![],
2184 };
2185 let s = BashStatus.call(status_args, &ctx).await.unwrap();
2186 if let Value::Struct(sf) = s {
2187 let kind = sf.iter().find(|(k, _)| k == "status").unwrap();
2188 if matches!(&kind.1, Value::Str(s) if s == "exited") {
2189 let ec = sf.iter().find(|(k, _)| k == "exit_code").unwrap();
2190 assert!(matches!(ec.1, Value::Int(0)));
2191 return;
2192 }
2193 }
2194 }
2195 panic!("process did not exit in time");
2196 }
2197
2198 #[tokio::test]
2199 async fn spawn_defaults_to_managed_workspace() {
2200 let registry = Arc::new(BgRegistry::new());
2201 let session_dir = TempDir::new().unwrap();
2202 let workspace = TempDir::new().unwrap();
2203 let ctx = ctx_with_registry(registry, session_dir.path()).with_workspace(
2204 crate::git_workspace::WorkspaceBinding {
2205 workspace_id: "test".into(),
2206 repository_root: workspace.path().to_path_buf(),
2207 path: workspace.path().to_path_buf(),
2208 branch: None,
2209 },
2210 );
2211 let value = BashSpawn
2212 .call(
2213 ToolArgs {
2214 positional: vec![Value::Str("pwd".into())],
2215 named: vec![],
2216 },
2217 &ctx,
2218 )
2219 .await
2220 .unwrap();
2221 let log_path = value
2222 .field("log_path")
2223 .and_then(|value| match value {
2224 Value::Str(path) => Some(path.clone()),
2225 _ => None,
2226 })
2227 .unwrap();
2228
2229 for _ in 0..50 {
2230 tokio::time::sleep(Duration::from_millis(50)).await;
2231 let output = std::fs::read_to_string(&log_path).unwrap_or_default();
2232 if !output.is_empty() {
2233 let canonical_workspace = crate::fs_access::canonicalize_stable(workspace.path());
2234 assert!(output.contains(&canonical_workspace.display().to_string()));
2235 return;
2236 }
2237 }
2238 panic!("pwd output did not arrive in time");
2239 }
2240
2241 #[tokio::test]
2242 async fn spawn_uses_explicit_cwd() {
2243 let registry = Arc::new(BgRegistry::new());
2244 let session_dir = TempDir::new().unwrap();
2245 let cwd = TempDir::new().unwrap();
2246 let ctx = ctx_with_registry(registry, session_dir.path());
2247 let value = BashSpawn
2248 .call(
2249 ToolArgs {
2250 positional: vec![Value::Str("pwd".into())],
2251 named: vec![("cwd".into(), Value::Str(cwd.path().display().to_string()))],
2252 },
2253 &ctx,
2254 )
2255 .await
2256 .unwrap();
2257 let log_path = value
2258 .field("log_path")
2259 .and_then(|value| match value {
2260 Value::Str(path) => Some(path.clone()),
2261 _ => None,
2262 })
2263 .unwrap();
2264
2265 for _ in 0..50 {
2266 tokio::time::sleep(Duration::from_millis(50)).await;
2267 let output = std::fs::read_to_string(&log_path).unwrap_or_default();
2268 if !output.is_empty() {
2269 let canonical_cwd = crate::fs_access::canonicalize_stable(cwd.path());
2270 assert!(output.contains(&canonical_cwd.display().to_string()));
2271 return;
2272 }
2273 }
2274 panic!("pwd output did not arrive in time");
2275 }
2276
2277 #[tokio::test]
2278 async fn spawn_output_captures_stdout() {
2279 let registry = Arc::new(BgRegistry::new());
2280 let dir = TempDir::new().unwrap();
2281 let ctx = ctx_with_registry(registry.clone(), dir.path());
2282 let spawn_args = ToolArgs {
2283 positional: vec![Value::Str("echo line1; echo line2".into())],
2284 named: vec![],
2285 };
2286 let v = BashSpawn.call(spawn_args, &ctx).await.unwrap();
2287 let Value::Struct(fields) = v else { panic!() };
2288 let handle = fields
2289 .iter()
2290 .find(|(k, _)| k == "handle")
2291 .and_then(|(_, v)| {
2292 if let Value::Str(s) = v {
2293 Some(s.clone())
2294 } else {
2295 None
2296 }
2297 })
2298 .unwrap();
2299 let log_path = fields
2300 .iter()
2301 .find(|(k, _)| k == "log_path")
2302 .and_then(|(_, v)| {
2303 if let Value::Str(s) = v {
2304 Some(s.clone())
2305 } else {
2306 None
2307 }
2308 })
2309 .unwrap();
2310
2311 tokio::time::sleep(Duration::from_millis(300)).await;
2312
2313 let out_args = ToolArgs {
2314 positional: vec![Value::Str(handle.clone())],
2315 named: vec![],
2316 };
2317 let o = BashOutput.call(out_args, &ctx).await.unwrap();
2318 let Value::Struct(of) = o else { panic!() };
2319 let chunk = of.iter().find(|(k, _)| k == "chunk").unwrap();
2320 if let Value::Str(s) = &chunk.1 {
2321 assert!(s.contains("line1"), "chunk should contain line1: {s}");
2322 assert!(s.contains("line2"), "chunk should contain line2: {s}");
2323 assert_eq!(std::fs::read_to_string(log_path).unwrap(), *s);
2324 } else {
2325 panic!("chunk not str");
2326 }
2327 }
2328
2329 #[tokio::test]
2330 async fn kill_terminates_long_running_process() {
2331 let registry = Arc::new(BgRegistry::new());
2332 let dir = TempDir::new().unwrap();
2333 let ctx = ctx_with_registry(registry.clone(), dir.path());
2334 let spawn_args = ToolArgs {
2335 positional: vec![Value::Str("sleep 100".into())],
2336 named: vec![],
2337 };
2338 let v = BashSpawn.call(spawn_args, &ctx).await.unwrap();
2339 let Value::Struct(fields) = v else { panic!() };
2340 let handle = fields
2341 .iter()
2342 .find(|(k, _)| k == "handle")
2343 .and_then(|(_, v)| {
2344 if let Value::Str(s) = v {
2345 Some(s.clone())
2346 } else {
2347 None
2348 }
2349 })
2350 .unwrap();
2351
2352 let kill_args = ToolArgs {
2353 positional: vec![Value::Str(handle.clone())],
2354 named: vec![],
2355 };
2356 BashKill.call(kill_args, &ctx).await.unwrap();
2357
2358 for _ in 0..50 {
2359 tokio::time::sleep(Duration::from_millis(50)).await;
2360 let status_args = ToolArgs {
2361 positional: vec![Value::Str(handle.clone())],
2362 named: vec![],
2363 };
2364 let s = BashStatus.call(status_args, &ctx).await.unwrap();
2365 if let Value::Struct(sf) = s {
2366 let kind = sf.iter().find(|(k, _)| k == "status").unwrap();
2367 if matches!(&kind.1, Value::Str(s) if s == "killed") {
2368 return;
2369 }
2370 }
2371 }
2372 panic!("process not killed in time");
2373 }
2374
2375 #[tokio::test]
2376 async fn cross_session_access_rejected() {
2377 let registry = Arc::new(BgRegistry::new());
2378 let dir = TempDir::new().unwrap();
2379 let ctx_a = ctx_with_registry(registry.clone(), dir.path());
2380
2381 let spawn_args = ToolArgs {
2382 positional: vec![Value::Str("sleep 10".into())],
2383 named: vec![],
2384 };
2385 let v = BashSpawn.call(spawn_args, &ctx_a).await.unwrap();
2386 let Value::Struct(fields) = v else { panic!() };
2387 let handle = fields
2388 .iter()
2389 .find(|(k, _)| k == "handle")
2390 .and_then(|(_, v)| {
2391 if let Value::Str(s) = v {
2392 Some(s.clone())
2393 } else {
2394 None
2395 }
2396 })
2397 .unwrap();
2398
2399 let mut ctx_b = ToolCtx::new();
2400 ctx_b.bg_registry = Some(registry.clone());
2401 ctx_b.session_dir = Some(dir.path().to_path_buf());
2402 ctx_b.session_id = Some("other-session".to_string());
2403 let status_args = ToolArgs {
2404 positional: vec![Value::Str(handle)],
2405 named: vec![],
2406 };
2407 let err = BashStatus.call(status_args, &ctx_b).await.err().unwrap();
2408 assert!(format!("{err}").contains("does not belong to session"));
2409 }
2410
2411 #[tokio::test]
2412 async fn read_stream_keeps_complete_log_after_memory_budget_is_exhausted() {
2413 let output = Arc::new(Mutex::new(BgOutput::default()));
2414 let (log_tx, mut log_rx) = mpsc::unbounded_channel();
2415 let (stream_tx, mut stream_rx) = tokio::sync::broadcast::channel(4);
2416 let (mut writer, reader) = tokio::io::duplex(1024);
2417 let input = b"first line\nsecond line\n";
2418 let write_task = tokio::spawn(async move {
2419 use tokio::io::AsyncWriteExt;
2420 writer.write_all(input).await.unwrap();
2421 });
2422
2423 read_stream(
2424 BufReader::new(reader),
2425 ReadStreamCtx {
2426 output: Arc::clone(&output),
2427 log_tx,
2428 kind: StreamKind::Stdout,
2429 max_output_bytes: 5,
2430 stream_tx: Some(stream_tx),
2431 handle: "test".into(),
2432 flow_run_id: None,
2433 call_intent: crate::message::ToolCallIntent::new("检查命令输出"),
2434 },
2435 )
2436 .await;
2437 write_task.await.unwrap();
2438
2439 let frames: Vec<Vec<u8>> = std::iter::from_fn(|| log_rx.try_recv().ok()).collect();
2440 assert_eq!(frames.concat(), b"[out] first line\n[out] second line\n");
2441 assert!(output.lock().unwrap().truncated);
2442 for _ in 0..2 {
2443 let frame = stream_rx.try_recv().expect("streamed bash line");
2444 assert!(matches!(
2445 frame,
2446 crate::stream::StreamFrame::BashChunk {
2447 call_intent: Some(intent),
2448 ..
2449 } if intent.as_str() == "检查命令输出"
2450 ));
2451 }
2452 }
2453}