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