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