Skip to main content

agent_sdk/
filesystem.rs

1//! Filesystem implementations for the Environment trait.
2//!
3//! Provides:
4//! - `LocalFileSystem` - Standard filesystem operations using `std::fs`
5//! - `InMemoryFileSystem` - In-memory filesystem for testing
6
7use crate::environment::{self, Environment, ExecResult, FileEntry, GrepMatch};
8use anyhow::{Context, Result};
9use async_trait::async_trait;
10use std::collections::HashMap;
11use std::path::{Path, PathBuf};
12use std::sync::RwLock;
13
14/// Maximum bytes captured from a single `exec` output stream before the rest is
15/// drained and discarded. Bounds in-process memory for verbose commands.
16const MAX_EXEC_OUTPUT_BYTES: usize = 1024 * 1024;
17
18/// Maximum size of a file that recursive grep will read into memory. Larger
19/// files (typically build artifacts / media) are skipped rather than loaded.
20const MAX_GREP_FILE_BYTES: u64 = 16 * 1024 * 1024;
21
22/// Read an async stream into memory, capping the retained bytes at
23/// [`MAX_EXEC_OUTPUT_BYTES`]. Bytes past the cap are drained (so the child's
24/// pipe never blocks) but discarded. Returns the captured bytes and whether
25/// truncation occurred.
26async fn read_capped<R>(mut reader: R) -> std::io::Result<(Vec<u8>, bool)>
27where
28    R: tokio::io::AsyncRead + Unpin,
29{
30    use tokio::io::AsyncReadExt;
31
32    let mut captured = Vec::new();
33    let mut chunk = [0u8; 8192];
34    let mut truncated = false;
35    loop {
36        let read = reader.read(&mut chunk).await?;
37        if read == 0 {
38            break;
39        }
40        if captured.len() < MAX_EXEC_OUTPUT_BYTES {
41            let remaining = MAX_EXEC_OUTPUT_BYTES - captured.len();
42            let take = remaining.min(read);
43            captured.extend_from_slice(&chunk[..take]);
44            if take < read {
45                truncated = true;
46            }
47        } else {
48            truncated = true;
49        }
50    }
51    Ok((captured, truncated))
52}
53
54/// Render captured exec output as lossy UTF-8, appending a truncation marker
55/// when the byte cap was hit.
56fn render_capped_output(bytes: &[u8], truncated: bool) -> String {
57    let mut text = String::from_utf8_lossy(bytes).into_owned();
58    if truncated {
59        text.push_str("\n[output truncated: exceeded 1 MiB cap]");
60    }
61    text
62}
63
64/// Local filesystem implementation using `std::fs`
65pub struct LocalFileSystem {
66    root: PathBuf,
67}
68
69impl LocalFileSystem {
70    #[must_use]
71    pub fn new(root: impl Into<PathBuf>) -> Self {
72        Self { root: root.into() }
73    }
74
75    fn resolve(&self, path: &str) -> PathBuf {
76        let joined = if Path::new(path).is_absolute() {
77            PathBuf::from(path)
78        } else {
79            self.root.join(path)
80        };
81        environment::normalize_path_buf(&joined)
82    }
83}
84
85#[async_trait]
86impl Environment for LocalFileSystem {
87    async fn read_file(&self, path: &str) -> Result<String> {
88        let path = self.resolve(path);
89        tokio::fs::read_to_string(&path)
90            .await
91            .with_context(|| format!("Failed to read file: {}", path.display()))
92    }
93
94    async fn read_file_bytes(&self, path: &str) -> Result<Vec<u8>> {
95        let path = self.resolve(path);
96        tokio::fs::read(&path)
97            .await
98            .with_context(|| format!("Failed to read file: {}", path.display()))
99    }
100
101    async fn write_file(&self, path: &str, content: &str) -> Result<()> {
102        let path = self.resolve(path);
103        if let Some(parent) = path.parent() {
104            tokio::fs::create_dir_all(parent).await?;
105        }
106        tokio::fs::write(&path, content)
107            .await
108            .with_context(|| format!("Failed to write file: {}", path.display()))
109    }
110
111    async fn write_file_bytes(&self, path: &str, content: &[u8]) -> Result<()> {
112        let path = self.resolve(path);
113        if let Some(parent) = path.parent() {
114            tokio::fs::create_dir_all(parent).await?;
115        }
116        tokio::fs::write(&path, content)
117            .await
118            .with_context(|| format!("Failed to write file: {}", path.display()))
119    }
120
121    async fn list_dir(&self, path: &str) -> Result<Vec<FileEntry>> {
122        let path = self.resolve(path);
123        let mut entries = Vec::new();
124        let mut dir = tokio::fs::read_dir(&path)
125            .await
126            .with_context(|| format!("Failed to read directory: {}", path.display()))?;
127
128        while let Some(entry) = dir.next_entry().await? {
129            let metadata = entry.metadata().await?;
130            entries.push(FileEntry {
131                name: entry.file_name().to_string_lossy().to_string(),
132                path: entry.path().to_string_lossy().to_string(),
133                is_dir: metadata.is_dir(),
134                size: if metadata.is_file() {
135                    Some(metadata.len())
136                } else {
137                    None
138                },
139            });
140        }
141
142        Ok(entries)
143    }
144
145    async fn exists(&self, path: &str) -> Result<bool> {
146        let path = self.resolve(path);
147        Ok(tokio::fs::try_exists(&path).await.unwrap_or(false))
148    }
149
150    async fn is_dir(&self, path: &str) -> Result<bool> {
151        let path = self.resolve(path);
152        Ok(tokio::fs::metadata(&path).await.is_ok_and(|m| m.is_dir()))
153    }
154
155    async fn is_file(&self, path: &str) -> Result<bool> {
156        let path = self.resolve(path);
157        Ok(tokio::fs::metadata(&path).await.is_ok_and(|m| m.is_file()))
158    }
159
160    async fn create_dir(&self, path: &str) -> Result<()> {
161        let path = self.resolve(path);
162        tokio::fs::create_dir_all(&path)
163            .await
164            .with_context(|| format!("Failed to create directory: {}", path.display()))
165    }
166
167    async fn delete_file(&self, path: &str) -> Result<()> {
168        let path = self.resolve(path);
169        tokio::fs::remove_file(&path)
170            .await
171            .with_context(|| format!("Failed to delete file: {}", path.display()))
172    }
173
174    async fn delete_dir(&self, path: &str, recursive: bool) -> Result<()> {
175        let path = self.resolve(path);
176        if recursive {
177            tokio::fs::remove_dir_all(&path)
178                .await
179                .with_context(|| format!("Failed to delete directory: {}", path.display()))
180        } else {
181            tokio::fs::remove_dir(&path)
182                .await
183                .with_context(|| format!("Failed to delete directory: {}", path.display()))
184        }
185    }
186
187    async fn grep(&self, pattern: &str, path: &str, recursive: bool) -> Result<Vec<GrepMatch>> {
188        let path = self.resolve(path);
189        let regex = regex::Regex::new(pattern).context("Invalid regex pattern")?;
190        let mut matches = Vec::new();
191
192        if path.is_file() {
193            self.grep_file(&path, &regex, &mut matches).await?;
194        } else if path.is_dir() {
195            self.grep_dir(&path, &regex, recursive, &mut matches)
196                .await?;
197        }
198
199        Ok(matches)
200    }
201
202    async fn glob(&self, pattern: &str) -> Result<Vec<String>> {
203        let pattern_path = self.resolve(pattern);
204        let pattern_str = pattern_path.to_string_lossy();
205
206        let paths: Vec<String> = glob::glob(&pattern_str)
207            .context("Invalid glob pattern")?
208            .filter_map(std::result::Result::ok)
209            .map(|p| p.to_string_lossy().to_string())
210            .collect();
211
212        Ok(paths)
213    }
214
215    async fn exec(&self, command: &str, timeout_ms: Option<u64>) -> Result<ExecResult> {
216        use std::process::Stdio;
217        use tokio::process::Command;
218
219        let timeout = std::time::Duration::from_millis(timeout_ms.unwrap_or(120_000));
220
221        // `kill_on_drop(true)` ensures the child is reclaimed if this future is
222        // dropped (the agent loop's cancel/timeout boundary) or if our own
223        // timeout fires — otherwise the spawned `sh -c <command>` would keep
224        // running and mutating the world after we report a failure.
225        let mut child = Command::new("sh")
226            .arg("-c")
227            .arg(command)
228            .current_dir(&self.root)
229            .stdout(Stdio::piped())
230            .stderr(Stdio::piped())
231            .kill_on_drop(true)
232            .spawn()
233            .context("Failed to execute command")?;
234
235        let stdout = child.stdout.take().context("missing stdout pipe")?;
236        let stderr = child.stderr.take().context("missing stderr pipe")?;
237
238        // Read stdout/stderr with a byte cap so a chatty or hostile command
239        // (e.g. `cat large.bin`, `yes`) cannot exhaust memory before the
240        // higher-level truncation runs.
241        let run = async {
242            let (out, err, status) =
243                tokio::join!(read_capped(stdout), read_capped(stderr), child.wait());
244            anyhow::Ok((out?, err?, status?))
245        };
246
247        // Bind the result first so the (child-borrowing) timeout future is
248        // dropped before we touch `child` again in the timeout arm.
249        let outcome = tokio::time::timeout(timeout, Box::pin(run)).await;
250
251        match outcome {
252            Ok(joined) => {
253                let ((stdout_bytes, stdout_truncated), (stderr_bytes, stderr_truncated), status) =
254                    joined.context("Failed to execute command")?;
255                Ok(ExecResult {
256                    stdout: render_capped_output(&stdout_bytes, stdout_truncated),
257                    stderr: render_capped_output(&stderr_bytes, stderr_truncated),
258                    exit_code: status.code().unwrap_or(-1),
259                })
260            }
261            Err(_elapsed) => {
262                // Reclaim the child eagerly (kill_on_drop is the backstop).
263                let _ = child.start_kill();
264                anyhow::bail!("Command timed out after {}ms", timeout.as_millis())
265            }
266        }
267    }
268
269    fn root(&self) -> &str {
270        self.root.to_str().unwrap_or_else(|| {
271            log::error!(
272                "LocalFileSystem root path contains invalid UTF-8: {}",
273                self.root.to_string_lossy()
274            );
275            "/"
276        })
277    }
278}
279
280impl LocalFileSystem {
281    async fn grep_file(
282        &self,
283        path: &Path,
284        regex: &regex::Regex,
285        matches: &mut Vec<GrepMatch>,
286    ) -> Result<()> {
287        // Read the file once as bytes and scan the same buffer (an earlier
288        // version read it twice: once to sniff for binary, once to grep).
289        let content = tokio::fs::read(path).await?;
290        Self::grep_bytes(path, &content, regex, matches);
291        Ok(())
292    }
293
294    /// Scan an already-loaded buffer for regex matches, skipping files that look
295    /// binary (a NUL byte in the first 1 KiB).
296    fn grep_bytes(path: &Path, content: &[u8], regex: &regex::Regex, matches: &mut Vec<GrepMatch>) {
297        if content.iter().take(1024).any(|&b| b == 0) {
298            return; // Skip binary
299        }
300        let text = String::from_utf8_lossy(content);
301        for (line_num, line) in text.lines().enumerate() {
302            if let Some(m) = regex.find(line) {
303                matches.push(GrepMatch {
304                    path: path.to_string_lossy().to_string(),
305                    line_number: line_num + 1,
306                    line_content: line.to_string(),
307                    match_start: m.start(),
308                    match_end: m.end(),
309                });
310            }
311        }
312    }
313
314    async fn grep_dir(
315        &self,
316        start_dir: &Path,
317        regex: &regex::Regex,
318        recursive: bool,
319        matches: &mut Vec<GrepMatch>,
320    ) -> Result<()> {
321        // Use an iterative approach with explicit queue to avoid stack overflow
322        let mut dirs_to_process = vec![start_dir.to_path_buf()];
323
324        while let Some(dir) = dirs_to_process.pop() {
325            let Ok(mut entries) = tokio::fs::read_dir(&dir).await else {
326                continue; // Skip directories we can't read
327            };
328
329            while let Ok(Some(entry)) = entries.next_entry().await {
330                let path = entry.path();
331                let Ok(metadata) = entry.metadata().await else {
332                    continue;
333                };
334
335                if metadata.is_file() {
336                    // Skip oversized files (likely build artifacts / media)
337                    // before loading them — bounds memory for huge files that
338                    // would otherwise be read in full only to be skipped.
339                    if metadata.len() > MAX_GREP_FILE_BYTES {
340                        continue;
341                    }
342                    // Read the file exactly once; `grep_bytes` does the binary
343                    // sniff and the scan over the same buffer.
344                    if let Ok(content) = tokio::fs::read(&path).await {
345                        Self::grep_bytes(&path, &content, regex, matches);
346                    }
347                } else if metadata.is_dir() && recursive {
348                    dirs_to_process.push(path);
349                }
350            }
351        }
352        Ok(())
353    }
354}
355
356/// In-memory filesystem for testing
357pub struct InMemoryFileSystem {
358    root: String,
359    files: RwLock<HashMap<String, Vec<u8>>>,
360    dirs: RwLock<std::collections::HashSet<String>>,
361}
362
363impl InMemoryFileSystem {
364    #[must_use]
365    pub fn new(root: impl Into<String>) -> Self {
366        let root = root.into();
367        let dirs = RwLock::new({
368            let mut set = std::collections::HashSet::new();
369            set.insert(root.clone());
370            set
371        });
372        Self {
373            root,
374            files: RwLock::new(HashMap::new()),
375            dirs,
376        }
377    }
378
379    fn normalize_path(&self, path: &str) -> String {
380        if path.starts_with('/') {
381            path.to_string()
382        } else {
383            format!("{}/{}", self.root.trim_end_matches('/'), path)
384        }
385    }
386
387    fn parent_dir(path: &str) -> Option<String> {
388        Path::new(path)
389            .parent()
390            .map(|p| p.to_string_lossy().to_string())
391    }
392}
393
394#[async_trait]
395impl Environment for InMemoryFileSystem {
396    async fn read_file(&self, path: &str) -> Result<String> {
397        let path = self.normalize_path(path);
398        self.files
399            .read()
400            .ok()
401            .context("lock poisoned")?
402            .get(&path)
403            .map(|bytes| String::from_utf8_lossy(bytes).to_string())
404            .ok_or_else(|| anyhow::anyhow!("File not found: {path}"))
405    }
406
407    async fn read_file_bytes(&self, path: &str) -> Result<Vec<u8>> {
408        let path = self.normalize_path(path);
409        self.files
410            .read()
411            .ok()
412            .context("lock poisoned")?
413            .get(&path)
414            .cloned()
415            .ok_or_else(|| anyhow::anyhow!("File not found: {path}"))
416    }
417
418    async fn write_file(&self, path: &str, content: &str) -> Result<()> {
419        self.write_file_bytes(path, content.as_bytes()).await
420    }
421
422    async fn write_file_bytes(&self, path: &str, content: &[u8]) -> Result<()> {
423        let path = self.normalize_path(path);
424
425        // Create parent directories
426        if let Some(parent) = Self::parent_dir(&path) {
427            self.create_dir(&parent).await?;
428        }
429
430        self.files
431            .write()
432            .ok()
433            .context("lock poisoned")?
434            .insert(path, content.to_vec());
435        Ok(())
436    }
437
438    async fn list_dir(&self, path: &str) -> Result<Vec<FileEntry>> {
439        let path = self.normalize_path(path);
440        let prefix = format!("{}/", path.trim_end_matches('/'));
441        let mut entries = Vec::new();
442
443        // Check if directory exists and collect file entries
444        {
445            let dirs = self.dirs.read().ok().context("lock poisoned")?;
446            if !dirs.contains(&path) {
447                anyhow::bail!("Directory not found: {path}");
448            }
449
450            // Find subdirectories
451            for dir_path in dirs.iter() {
452                if dir_path.starts_with(&prefix) && dir_path != &path {
453                    let relative = &dir_path[prefix.len()..];
454                    if !relative.contains('/') {
455                        entries.push(FileEntry {
456                            name: relative.to_string(),
457                            path: dir_path.clone(),
458                            is_dir: true,
459                            size: None,
460                        });
461                    }
462                }
463            }
464        }
465
466        // Find files in this directory
467        {
468            let files = self.files.read().ok().context("lock poisoned")?;
469            for (file_path, content) in files.iter() {
470                if file_path.starts_with(&prefix) {
471                    let relative = &file_path[prefix.len()..];
472                    if !relative.contains('/') {
473                        entries.push(FileEntry {
474                            name: relative.to_string(),
475                            path: file_path.clone(),
476                            is_dir: false,
477                            size: Some(content.len() as u64),
478                        });
479                    }
480                }
481            }
482        }
483
484        Ok(entries)
485    }
486
487    async fn exists(&self, path: &str) -> Result<bool> {
488        let path = self.normalize_path(path);
489        let in_files = self
490            .files
491            .read()
492            .ok()
493            .context("lock poisoned")?
494            .contains_key(&path);
495        let in_dirs = self
496            .dirs
497            .read()
498            .ok()
499            .context("lock poisoned")?
500            .contains(&path);
501        Ok(in_files || in_dirs)
502    }
503
504    async fn is_dir(&self, path: &str) -> Result<bool> {
505        let path = self.normalize_path(path);
506        Ok(self
507            .dirs
508            .read()
509            .ok()
510            .context("lock poisoned")?
511            .contains(&path))
512    }
513
514    async fn is_file(&self, path: &str) -> Result<bool> {
515        let path = self.normalize_path(path);
516        Ok(self
517            .files
518            .read()
519            .ok()
520            .context("lock poisoned")?
521            .contains_key(&path))
522    }
523
524    async fn create_dir(&self, path: &str) -> Result<()> {
525        let path = self.normalize_path(path);
526
527        // Collect all parent directories first
528        let mut current = String::new();
529        let dirs_to_create: Vec<String> = path
530            .split('/')
531            .filter(|p| !p.is_empty())
532            .map(|part| {
533                current = format!("{current}/{part}");
534                current.clone()
535            })
536            .collect();
537
538        // Insert all directories at once
539        for dir in dirs_to_create {
540            self.dirs.write().ok().context("lock poisoned")?.insert(dir);
541        }
542
543        Ok(())
544    }
545
546    async fn delete_file(&self, path: &str) -> Result<()> {
547        let path = self.normalize_path(path);
548        self.files
549            .write()
550            .ok()
551            .context("lock poisoned")?
552            .remove(&path)
553            .ok_or_else(|| anyhow::anyhow!("File not found: {path}"))?;
554        Ok(())
555    }
556
557    async fn delete_dir(&self, path: &str, recursive: bool) -> Result<()> {
558        let path = self.normalize_path(path);
559        let prefix = format!("{}/", path.trim_end_matches('/'));
560
561        // Check if directory exists
562        if !self
563            .dirs
564            .read()
565            .ok()
566            .context("lock poisoned")?
567            .contains(&path)
568        {
569            anyhow::bail!("Directory not found: {path}");
570        }
571
572        if recursive {
573            // Remove all files and subdirs
574            self.files
575                .write()
576                .ok()
577                .context("lock poisoned")?
578                .retain(|k, _| !k.starts_with(&prefix));
579            self.dirs
580                .write()
581                .ok()
582                .context("lock poisoned")?
583                .retain(|k| !k.starts_with(&prefix) && k != &path);
584        } else {
585            // Check if empty first
586            let has_files = self
587                .files
588                .read()
589                .ok()
590                .context("lock poisoned")?
591                .keys()
592                .any(|k| k.starts_with(&prefix));
593            let has_subdirs = self
594                .dirs
595                .read()
596                .ok()
597                .context("lock poisoned")?
598                .iter()
599                .any(|k| k.starts_with(&prefix) && k != &path);
600
601            if has_files || has_subdirs {
602                anyhow::bail!("Directory not empty: {path}");
603            }
604
605            self.dirs
606                .write()
607                .ok()
608                .context("lock poisoned")?
609                .remove(&path);
610        }
611
612        Ok(())
613    }
614
615    async fn grep(&self, pattern: &str, path: &str, recursive: bool) -> Result<Vec<GrepMatch>> {
616        let path = self.normalize_path(path);
617        let regex = regex::Regex::new(pattern).context("Invalid regex pattern")?;
618        let mut matches = Vec::new();
619
620        // Determine if path is a file or directory
621        let is_file = self
622            .files
623            .read()
624            .ok()
625            .context("lock poisoned")?
626            .contains_key(&path);
627        let is_dir = self
628            .dirs
629            .read()
630            .ok()
631            .context("lock poisoned")?
632            .contains(&path);
633
634        if is_file {
635            // Search single file - clone content to release lock early
636            let content = self
637                .files
638                .read()
639                .ok()
640                .context("lock poisoned")?
641                .get(&path)
642                .cloned();
643            if let Some(content) = content {
644                let content = String::from_utf8_lossy(&content);
645                for (line_num, line) in content.lines().enumerate() {
646                    if let Some(m) = regex.find(line) {
647                        matches.push(GrepMatch {
648                            path: path.clone(),
649                            line_number: line_num + 1,
650                            line_content: line.to_string(),
651                            match_start: m.start(),
652                            match_end: m.end(),
653                        });
654                    }
655                }
656            }
657        } else if is_dir {
658            // Search directory - collect files to search first
659            let prefix = format!("{}/", path.trim_end_matches('/'));
660            let files_to_search: Vec<_> = {
661                let files = self.files.read().ok().context("lock poisoned")?;
662                files
663                    .iter()
664                    .filter(|(file_path, _)| {
665                        if recursive {
666                            file_path.starts_with(&prefix)
667                        } else {
668                            file_path.starts_with(&prefix)
669                                && !file_path[prefix.len()..].contains('/')
670                        }
671                    })
672                    .map(|(k, v)| (k.clone(), v.clone()))
673                    .collect()
674            };
675
676            for (file_path, content) in files_to_search {
677                let content = String::from_utf8_lossy(&content);
678                for (line_num, line) in content.lines().enumerate() {
679                    if let Some(m) = regex.find(line) {
680                        matches.push(GrepMatch {
681                            path: file_path.clone(),
682                            line_number: line_num + 1,
683                            line_content: line.to_string(),
684                            match_start: m.start(),
685                            match_end: m.end(),
686                        });
687                    }
688                }
689            }
690        }
691
692        Ok(matches)
693    }
694
695    async fn glob(&self, pattern: &str) -> Result<Vec<String>> {
696        let pattern = self.normalize_path(pattern);
697
698        // Escape regex metacharacters (except the glob wildcards `*`/`?` and the
699        // char-class delimiters `[`/`]`) so a literal `.`/`(`/`+` matches
700        // literally instead of acting as a regex operator. `[`/`]` pass through
701        // so a balanced class like `[abc]` works as a glob char class, while an
702        // unbalanced `[` makes `Regex::new` fail and surfaces as an "Invalid
703        // glob pattern" error (matching real glob implementations).
704        let mut escaped = String::with_capacity(pattern.len());
705        for c in pattern.chars() {
706            match c {
707                '.' | '+' | '^' | '$' | '(' | ')' | '{' | '}' | '|' | '\\' => {
708                    escaped.push('\\');
709                    escaped.push(c);
710                }
711                _ => escaped.push(c),
712            }
713        }
714
715        // Simple glob matching
716        let regex_pattern = escaped
717            .replace("**", "\x00")
718            .replace('*', "[^/]*")
719            .replace('\x00', ".*")
720            .replace('?', ".");
721        let regex =
722            regex::Regex::new(&format!("^{regex_pattern}$")).context("Invalid glob pattern")?;
723
724        // Collect matches from files and dirs - release locks as early as possible
725        let mut matches: Vec<String> = self
726            .files
727            .read()
728            .ok()
729            .context("lock poisoned")?
730            .keys()
731            .filter(|p| regex.is_match(p))
732            .cloned()
733            .collect();
734
735        matches.extend(
736            self.dirs
737                .read()
738                .ok()
739                .context("lock poisoned")?
740                .iter()
741                .filter(|p| regex.is_match(p))
742                .cloned(),
743        );
744
745        matches.sort();
746        matches.dedup();
747        Ok(matches)
748    }
749
750    fn root(&self) -> &str {
751        &self.root
752    }
753}
754
755#[cfg(test)]
756mod tests {
757    use super::*;
758
759    #[tokio::test]
760    async fn test_in_memory_write_and_read() -> Result<()> {
761        let fs = InMemoryFileSystem::new("/workspace");
762
763        fs.write_file("test.txt", "Hello, World!").await?;
764        let content = fs.read_file("test.txt").await?;
765
766        assert_eq!(content, "Hello, World!");
767        Ok(())
768    }
769
770    #[tokio::test]
771    async fn test_in_memory_exists() -> Result<()> {
772        let fs = InMemoryFileSystem::new("/workspace");
773
774        assert!(!fs.exists("test.txt").await?);
775        fs.write_file("test.txt", "content").await?;
776        assert!(fs.exists("test.txt").await?);
777        Ok(())
778    }
779
780    #[tokio::test]
781    async fn test_in_memory_directories() -> Result<()> {
782        let fs = InMemoryFileSystem::new("/workspace");
783
784        fs.create_dir("src/lib").await?;
785        assert!(fs.is_dir("src").await?);
786        assert!(fs.is_dir("src/lib").await?);
787        assert!(!fs.is_file("src").await?);
788        Ok(())
789    }
790
791    #[tokio::test]
792    async fn test_in_memory_list_dir() -> Result<()> {
793        let fs = InMemoryFileSystem::new("/workspace");
794
795        fs.write_file("file1.txt", "content1").await?;
796        fs.write_file("file2.txt", "content2").await?;
797        fs.create_dir("subdir").await?;
798
799        let entries = fs.list_dir("/workspace").await?;
800        assert_eq!(entries.len(), 3);
801
802        let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
803        assert!(names.contains(&"file1.txt"));
804        assert!(names.contains(&"file2.txt"));
805        assert!(names.contains(&"subdir"));
806        Ok(())
807    }
808
809    #[tokio::test]
810    async fn test_in_memory_grep() -> Result<()> {
811        let fs = InMemoryFileSystem::new("/workspace");
812
813        fs.write_file("test.rs", "fn main() {\n    println!(\"Hello\");\n}")
814            .await?;
815
816        let matches = fs.grep("println", "/workspace", true).await?;
817        assert_eq!(matches.len(), 1);
818        assert_eq!(matches[0].line_number, 2);
819        assert!(matches[0].line_content.contains("println"));
820        Ok(())
821    }
822
823    #[tokio::test]
824    async fn test_in_memory_glob() -> Result<()> {
825        let fs = InMemoryFileSystem::new("/workspace");
826
827        fs.write_file("src/main.rs", "fn main() {}").await?;
828        fs.write_file("src/lib.rs", "pub mod foo;").await?;
829        fs.write_file("tests/test.rs", "// test").await?;
830
831        let matches = fs.glob("/workspace/src/*.rs").await?;
832        assert_eq!(matches.len(), 2);
833        Ok(())
834    }
835
836    #[tokio::test]
837    async fn test_in_memory_delete() -> Result<()> {
838        let fs = InMemoryFileSystem::new("/workspace");
839
840        fs.write_file("test.txt", "content").await?;
841        assert!(fs.exists("test.txt").await?);
842
843        fs.delete_file("test.txt").await?;
844        assert!(!fs.exists("test.txt").await?);
845        Ok(())
846    }
847
848    fn unique_temp_dir(tag: &str) -> PathBuf {
849        let nanos = std::time::SystemTime::now()
850            .duration_since(std::time::UNIX_EPOCH)
851            .map_or(0, |d| d.as_nanos());
852        std::env::temp_dir().join(format!("agent_sdk_fs_{tag}_{}_{nanos}", std::process::id()))
853    }
854
855    #[tokio::test]
856    async fn test_exec_timeout_kills_child_no_leak() -> Result<()> {
857        let tmp = unique_temp_dir("exec_leak");
858        tokio::fs::create_dir_all(&tmp).await?;
859        let marker = tmp.join("marker.txt");
860
861        let fs = LocalFileSystem::new(tmp.clone());
862        // The child sleeps past the timeout, then would create the marker.
863        // A leaked (un-killed) child creates it; a reclaimed one never does.
864        let command = format!("sleep 1; touch '{}'", marker.display());
865        let result = fs.exec(&command, Some(50)).await;
866
867        let Err(error) = result else {
868            anyhow::bail!("exec should have timed out");
869        };
870        let rendered = format!("{error:#}");
871        assert!(rendered.contains("timed out"), "got: {rendered}");
872
873        // Wait well past the child's sleep; the marker must never appear.
874        tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
875        assert!(
876            !tokio::fs::try_exists(&marker).await.unwrap_or(false),
877            "child process leaked: marker created after timeout"
878        );
879
880        let _ = tokio::fs::remove_dir_all(&tmp).await;
881        Ok(())
882    }
883
884    #[tokio::test]
885    async fn test_exec_caps_large_output() -> Result<()> {
886        let tmp = unique_temp_dir("exec_cap");
887        tokio::fs::create_dir_all(&tmp).await?;
888        let fs = LocalFileSystem::new(tmp.clone());
889
890        // Emit ~2 MB, well over the 1 MiB cap.
891        let result = fs
892            .exec(
893                "dd if=/dev/zero bs=1000000 count=2 2>/dev/null",
894                Some(10_000),
895            )
896            .await?;
897
898        assert!(
899            result.stdout.contains("[output truncated"),
900            "expected truncation marker in capped output"
901        );
902        // Captured bytes are bounded near the cap (not the full 2 MiB).
903        assert!(
904            result.stdout.len() <= MAX_EXEC_OUTPUT_BYTES + 64,
905            "captured output exceeded the cap: {} bytes",
906            result.stdout.len()
907        );
908
909        let _ = tokio::fs::remove_dir_all(&tmp).await;
910        Ok(())
911    }
912
913    #[tokio::test]
914    async fn test_local_grep_skips_binary_and_matches_text() -> Result<()> {
915        let tmp = unique_temp_dir("grep");
916        tokio::fs::create_dir_all(&tmp).await?;
917        tokio::fs::write(tmp.join("code.rs"), "fn TODO_here() {}\nlet x = 1;\n").await?;
918        // Binary file that also contains the search term but starts with a NUL.
919        let mut binary = vec![0u8, 1, 2, 3];
920        binary.extend_from_slice(b"TODO_here\n");
921        tokio::fs::write(tmp.join("blob.bin"), &binary).await?;
922
923        let fs = LocalFileSystem::new(tmp.clone());
924        let matches = fs.grep("TODO_here", ".", true).await?;
925
926        assert_eq!(matches.len(), 1, "binary file must be skipped: {matches:?}");
927        assert!(matches[0].path.ends_with("code.rs"));
928
929        let _ = tokio::fs::remove_dir_all(&tmp).await;
930        Ok(())
931    }
932
933    #[tokio::test]
934    async fn test_in_memory_glob_escapes_regex_metacharacters() -> Result<()> {
935        let fs = InMemoryFileSystem::new("/workspace");
936        fs.write_file("main.rs", "x").await?;
937        fs.write_file("mainXrs", "x").await?;
938
939        // `.` in the pattern must be literal: `mainXrs` must NOT match `*.rs`.
940        let matches = fs.glob("/workspace/*.rs").await?;
941        assert_eq!(matches, vec!["/workspace/main.rs".to_string()]);
942
943        // A pattern with regex metacharacters must not yield an invalid regex
944        // (previously `+`/`[` would either over-match or fail the whole call).
945        fs.write_file("a+b.txt", "x").await?;
946        let plus = fs.glob("/workspace/a+b.txt").await?;
947        assert_eq!(plus, vec!["/workspace/a+b.txt".to_string()]);
948
949        Ok(())
950    }
951}