Skip to main content

atman_runtime/tools/
bash_bg.rs

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(&registry, &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(&registry, 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(&registry, 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(&registry, 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(&registry), 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(&registry, 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(&registry, 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(&registry, 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}