Skip to main content

agent_workspace_adapter_local/
lib.rs

1//! Explicit local/test workspace adapter.
2//!
3//! This crate provides a concrete implementation of the [`Workspace`] trait
4//! for local filesystem access. It is the counterpart of `agent-workspace-server`
5//! for single-process deployments and testing.
6//!
7//! # Design decisions
8//!
9//! ## Local-first, portable
10//!
11//! The local workspace uses only `tokio::fs` and `tokio::process::Command`,
12//! requiring no external dependencies beyond the host filesystem. Remote
13//! providers (Daytona, Moulin, E2B) are in the `agents-runtime` crate and
14//! may be migrated here in a follow-up.
15//!
16//! ## Path safety
17//!
18//! All paths are resolved relative to the workspace root. Absolute paths are
19//! accepted but must be within the root or the operation fails.
20
21use std::path::{Path, PathBuf};
22use std::process::Stdio;
23use std::time::Duration;
24
25#[cfg(not(unix))]
26use agent_workspace_contract::WORKSPACE_MAX_FILE_BYTES;
27use agent_workspace_contract::{
28    CmdOutput, DirEntry, FileVisibilityOptions, GrepMatch, PortUrl, Workspace,
29    WORKSPACE_MAX_COMMAND_BYTES, WORKSPACE_MAX_COMMAND_OUTPUT_BYTES, WORKSPACE_MAX_RESULTS,
30    WORKSPACE_MAX_SEARCH_OUTPUT_BYTES,
31};
32use anyhow::{Context, Result};
33use async_trait::async_trait;
34#[cfg(not(unix))]
35use sha2::{Digest as _, Sha256};
36#[cfg(not(unix))]
37use tokio::io::AsyncWriteExt;
38use tokio::io::{AsyncRead, AsyncReadExt};
39
40mod executor;
41mod local_environment;
42pub use local_environment::*;
43mod path_resolver;
44mod provider;
45mod search;
46#[cfg(unix)]
47mod secure_fs;
48mod snapshot_object_store;
49mod snapshot_provider;
50pub use executor::*;
51use path_resolver::{normalize_path, WorkspacePathResolver};
52pub use provider::*;
53#[cfg(unix)]
54use secure_fs::SecureRoot;
55pub use snapshot_object_store::*;
56pub use snapshot_provider::*;
57
58const COMMAND_TIMEOUT: Duration = Duration::from_secs(60);
59
60/// A local filesystem workspace implementation.
61///
62/// All operations are performed against the host filesystem under the
63/// configured `root` directory.
64#[derive(Debug, Clone)]
65pub struct LocalWorkspace {
66    root: PathBuf,
67    allow_host_exec: bool,
68    #[cfg(unix)]
69    secure_root: SecureRoot,
70}
71
72#[cfg(test)]
73mod tests;
74
75impl LocalWorkspace {
76    pub async fn write_upload_chunk(&self, path: &str, offset: u64, bytes: &[u8]) -> Result<u64> {
77        #[cfg(unix)]
78        {
79            let secure = self.secure_root.clone();
80            let path = path.to_string();
81            let bytes = bytes.to_vec();
82            tokio::task::spawn_blocking(move || secure.write_chunk(&path, offset, &bytes))
83                .await
84                .context("joining descriptor-relative upload write")?
85        }
86        #[cfg(not(unix))]
87        {
88            let mut current = if self.exists(path).await? {
89                self.read_file_bytes(path).await?
90            } else {
91                Vec::new()
92            };
93            anyhow::ensure!(current.len() as u64 == offset, "upload offset conflict");
94            current.extend_from_slice(bytes);
95            self.write_file_bytes(path, &current).await?;
96            Ok(current.len() as u64)
97        }
98    }
99    pub fn new(root: PathBuf) -> Self {
100        Self::new_with_host_exec(root, false)
101    }
102
103    /// Explicitly opt into host command execution for trusted local/test use.
104    pub fn new_with_host_exec(root: PathBuf, allow_host_exec: bool) -> Self {
105        let root = if root.is_absolute() {
106            root
107        } else {
108            std::env::current_dir()
109                .map(|current| current.join(&root))
110                .unwrap_or(root)
111        };
112        Self {
113            root: normalize_path(&root),
114            allow_host_exec,
115            #[cfg(unix)]
116            secure_root: SecureRoot::new(normalize_path(&root)),
117        }
118    }
119
120    pub fn capabilities(&self) -> agent_workspace_contract::WorkspaceCapabilities {
121        agent_workspace_contract::WorkspaceCapabilities {
122            commands: self.allow_host_exec,
123            previews: true,
124            atomic_writes: true,
125            compare_and_swap: true,
126            ..Default::default()
127        }
128    }
129
130    /// Resolve a user-supplied path lexically against the workspace root.
131    fn resolve(&self, path: &str) -> Result<PathBuf> {
132        WorkspacePathResolver::new(&self.root).lexical(path)
133    }
134
135    #[cfg(not(unix))]
136    async fn canonical_root(&self) -> Result<PathBuf> {
137        WorkspacePathResolver::new(&self.root)
138            .canonical_root()
139            .await
140    }
141
142    async fn resolve_existing(&self, path: &str) -> Result<PathBuf> {
143        WorkspacePathResolver::new(&self.root).existing(path).await
144    }
145
146    #[cfg(not(unix))]
147    async fn resolve_for_write(&self, path: &str) -> Result<PathBuf> {
148        WorkspacePathResolver::new(&self.root).for_write(path).await
149    }
150
151    #[cfg(not(unix))]
152    async fn atomic_write_unlocked(&self, path: &str, content: &[u8]) -> Result<()> {
153        anyhow::ensure!(
154            content.len() <= WORKSPACE_MAX_FILE_BYTES,
155            "workspace file is too large"
156        );
157        let target = self.resolve_for_write(path).await?;
158        let parent = target.parent().context("workspace file has no parent")?;
159        tokio::fs::create_dir_all(parent).await?;
160        // Re-resolve after creating parents. This closes the common symlink
161        // substitution window before the same-directory atomic rename.
162        let target = self.resolve_for_write(path).await?;
163        let temp = parent.join(format!(
164            ".agent-write-{}.tmp",
165            uuid::Uuid::new_v4().simple()
166        ));
167        let result = async {
168            let mut file = tokio::fs::OpenOptions::new()
169                .create_new(true)
170                .write(true)
171                .open(&temp)
172                .await?;
173            file.write_all(content).await?;
174            file.sync_all().await?;
175            drop(file);
176            tokio::fs::rename(&temp, &target).await?;
177            Ok::<_, anyhow::Error>(())
178        }
179        .await;
180        if result.is_err() {
181            let _ = tokio::fs::remove_file(&temp).await;
182        }
183        result.with_context(|| format!("failed to atomically write {}", target.display()))
184    }
185
186    #[cfg(not(unix))]
187    async fn revision_for_existing(&self, path: &Path) -> Result<String> {
188        let mut file = tokio::fs::File::open(path).await?;
189        let mut hash = Sha256::new();
190        let mut total = 0_usize;
191        let mut buffer = [0_u8; 16 * 1024];
192        loop {
193            let read = file.read(&mut buffer).await?;
194            if read == 0 {
195                break;
196            }
197            total = total.saturating_add(read);
198            anyhow::ensure!(
199                total <= WORKSPACE_MAX_FILE_BYTES,
200                "workspace file exceeds {WORKSPACE_MAX_FILE_BYTES} bytes"
201            );
202            hash.update(&buffer[..read]);
203        }
204        Ok(format!("sha256:{:x}", hash.finalize()))
205    }
206
207    async fn list_dir_inner(
208        &self,
209        path: &str,
210        options: Option<FileVisibilityOptions>,
211    ) -> Result<Vec<DirEntry>> {
212        #[cfg(unix)]
213        {
214            let secure = self.secure_root.clone();
215            let path = path.to_string();
216            tokio::task::spawn_blocking(move || secure.list_dir(&path, options))
217                .await
218                .context("joining descriptor-relative directory listing")?
219        }
220        #[cfg(not(unix))]
221        {
222            let target = self.resolve_existing(path).await?;
223            let mut entries = Vec::new();
224            let mut read_dir = tokio::fs::read_dir(&target)
225                .await
226                .with_context(|| format!("failed to list {}", target.display()))?;
227            while let Some(entry) = read_dir.next_entry().await? {
228                anyhow::ensure!(
229                    entries.len() < WORKSPACE_MAX_RESULTS,
230                    "directory contains too many entries"
231                );
232                let name = entry.file_name().to_string_lossy().to_string();
233                if options
234                    .map(|options| !search::is_visible_name(&name, options))
235                    .unwrap_or(false)
236                {
237                    continue;
238                }
239                let metadata = tokio::fs::symlink_metadata(entry.path()).await?;
240                entries.push(DirEntry {
241                    name,
242                    is_dir: metadata.is_dir(),
243                    size: if metadata.is_file() {
244                        Some(metadata.len())
245                    } else {
246                        None
247                    },
248                });
249            }
250            entries.sort_by(|a, b| a.name.cmp(&b.name));
251            Ok(entries)
252        }
253    }
254
255    async fn walk_tree_inner(
256        &self,
257        path: &str,
258        max_depth: usize,
259        options: Option<FileVisibilityOptions>,
260    ) -> Result<Vec<String>> {
261        #[cfg(unix)]
262        {
263            let secure = self.secure_root.clone();
264            let path = path.to_string();
265            tokio::task::spawn_blocking(move || secure.walk(&path, max_depth, options))
266                .await
267                .context("joining descriptor-relative tree walk")?
268        }
269        #[cfg(not(unix))]
270        {
271            let target = self.resolve_existing(path).await?;
272            let mut results = Vec::new();
273            let mut output_bytes = 0;
274            let root = self.canonical_root().await?;
275            self.walk_dir(
276                &target,
277                max_depth.min(64),
278                &mut results,
279                &mut output_bytes,
280                &root,
281                options,
282            )?;
283            Ok(results)
284        }
285    }
286
287    #[cfg(not(unix))]
288    fn walk_dir(
289        &self,
290        dir: &Path,
291        remaining_depth: usize,
292        results: &mut Vec<String>,
293        output_bytes: &mut usize,
294        root: &Path,
295        options: Option<FileVisibilityOptions>,
296    ) -> Result<()> {
297        if remaining_depth == 0 {
298            return Ok(());
299        }
300        let read_dir =
301            std::fs::read_dir(dir).with_context(|| format!("failed to list {}", dir.display()))?;
302        for entry in read_dir {
303            let entry = entry?;
304            let name = entry.file_name().to_string_lossy().to_string();
305            if options
306                .map(|opt| !search::is_visible_name(&name, opt))
307                .unwrap_or(false)
308            {
309                continue;
310            }
311            let path = entry.path();
312            anyhow::ensure!(
313                results.len() < WORKSPACE_MAX_RESULTS,
314                "workspace tree result limit exceeded"
315            );
316            let relative = relative_path(root, &path)?;
317            *output_bytes = output_bytes.saturating_add(relative.len());
318            anyhow::ensure!(
319                *output_bytes <= WORKSPACE_MAX_SEARCH_OUTPUT_BYTES,
320                "workspace tree output limit exceeded"
321            );
322            results.push(relative);
323            if entry.file_type()?.is_dir() {
324                self.walk_dir(
325                    &path,
326                    remaining_depth - 1,
327                    results,
328                    output_bytes,
329                    root,
330                    options,
331                )?;
332            }
333        }
334        Ok(())
335    }
336}
337
338#[async_trait]
339impl agent_workspace_contract::WorkspaceUploadWriter for LocalWorkspace {
340    async fn write_upload_chunk(&self, path: &str, offset: u64, bytes: &[u8]) -> Result<u64> {
341        LocalWorkspace::write_upload_chunk(self, path, offset, bytes).await
342    }
343}
344
345#[async_trait]
346impl Workspace for LocalWorkspace {
347    fn description(&self) -> String {
348        format!("local:{}", self.root.display())
349    }
350
351    fn root(&self) -> PathBuf {
352        self.root.clone()
353    }
354
355    fn capabilities(&self) -> agent_workspace_contract::WorkspaceCapabilities {
356        LocalWorkspace::capabilities(self)
357    }
358
359    async fn read_file(&self, path: &str) -> Result<String> {
360        let bytes = self.read_file_bytes(path).await?;
361        String::from_utf8(bytes).context("workspace file is not valid UTF-8")
362    }
363
364    async fn read_file_bytes(&self, path: &str) -> Result<Vec<u8>> {
365        #[cfg(unix)]
366        {
367            let secure = self.secure_root.clone();
368            let path = path.to_string();
369            return tokio::task::spawn_blocking(move || secure.read(&path))
370                .await
371                .context("joining descriptor-relative read")?;
372        }
373        #[cfg(not(unix))]
374        {
375            let target = self.resolve_existing(path).await?;
376            let file = tokio::fs::File::open(&target)
377                .await
378                .with_context(|| format!("failed to read {}", target.display()))?;
379            let (bytes, truncated) = read_bounded(file, WORKSPACE_MAX_FILE_BYTES).await?;
380            anyhow::ensure!(
381                !truncated,
382                "workspace file exceeds {WORKSPACE_MAX_FILE_BYTES} bytes"
383            );
384            Ok(bytes)
385        }
386    }
387
388    async fn write_file(&self, path: &str, content: &str) -> Result<()> {
389        self.write_file_bytes(path, content.as_bytes()).await
390    }
391
392    async fn write_file_bytes(&self, path: &str, content: &[u8]) -> Result<()> {
393        self.write_file_if_match(path, content, None)
394            .await
395            .map(|_| ())
396    }
397
398    async fn file_revision(&self, path: &str) -> Result<Option<String>> {
399        #[cfg(unix)]
400        {
401            let secure = self.secure_root.clone();
402            let path = path.to_string();
403            return tokio::task::spawn_blocking(move || secure.revision(&path))
404                .await
405                .context("joining descriptor-relative revision")?;
406        }
407        #[cfg(not(unix))]
408        {
409            match tokio::fs::symlink_metadata(self.resolve(path)?).await {
410                Ok(_) => {
411                    let target = self.resolve_existing(path).await?;
412                    self.revision_for_existing(&target).await.map(Some)
413                }
414                Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
415                Err(error) => Err(error.into()),
416            }
417        }
418    }
419
420    async fn write_file_if_match(
421        &self,
422        path: &str,
423        content: &[u8],
424        if_match: Option<&str>,
425    ) -> Result<String> {
426        #[cfg(unix)]
427        {
428            let secure = self.secure_root.clone();
429            let path = path.to_string();
430            let content = content.to_vec();
431            let expected = if_match.map(str::to_string);
432            return tokio::task::spawn_blocking(move || {
433                secure.write_if_match(&path, &content, expected.as_deref())
434            })
435            .await
436            .context("joining descriptor-relative write")?;
437        }
438        #[cfg(not(unix))]
439        {
440            if let Some(expected) = if_match {
441                let actual = match tokio::fs::symlink_metadata(self.resolve(path)?).await {
442                    Ok(_) => {
443                        let target = self.resolve_existing(path).await?;
444                        Some(self.revision_for_existing(&target).await?)
445                    }
446                    Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
447                    Err(error) => return Err(error.into()),
448                };
449                if !agent_workspace_contract::revision_matches(expected, actual.as_deref()) {
450                    return Err(agent_workspace_contract::WorkspaceVersionConflict {
451                        expected: expected.to_string(),
452                        actual,
453                    }
454                    .into());
455                }
456            }
457            self.atomic_write_unlocked(path, content).await?;
458            Ok(agent_workspace_contract::content_revision(content))
459        }
460    }
461
462    async fn create_dir_all(&self, path: &str) -> Result<()> {
463        #[cfg(unix)]
464        {
465            let secure = self.secure_root.clone();
466            let path = path.to_string();
467            return tokio::task::spawn_blocking(move || secure.create_dir_all(&path))
468                .await
469                .context("joining descriptor-relative mkdir")?;
470        }
471        #[cfg(not(unix))]
472        {
473            let target = self.resolve_for_write(path).await?;
474            tokio::fs::create_dir_all(&target)
475                .await
476                .with_context(|| format!("failed to create directory {}", target.display()))
477        }
478    }
479
480    async fn remove_file(&self, path: &str) -> Result<()> {
481        #[cfg(unix)]
482        {
483            let secure = self.secure_root.clone();
484            let path = path.to_string();
485            return tokio::task::spawn_blocking(move || secure.remove(&path))
486                .await
487                .context("joining descriptor-relative remove")?;
488        }
489        #[cfg(not(unix))]
490        {
491            let target = self.resolve_existing(path).await?;
492            tokio::fs::remove_file(&target)
493                .await
494                .with_context(|| format!("failed to remove {}", target.display()))
495        }
496    }
497
498    async fn remove_file_if_match(&self, path: &str, if_match: &str) -> Result<()> {
499        #[cfg(unix)]
500        {
501            let secure = self.secure_root.clone();
502            let path = path.to_string();
503            let expected = if_match.to_string();
504            return tokio::task::spawn_blocking(move || {
505                secure.remove_if_match(&path, Some(&expected))
506            })
507            .await
508            .context("joining descriptor-relative conditional remove")?;
509        }
510        #[cfg(not(unix))]
511        {
512            let actual = self.file_revision(path).await?;
513            if !agent_workspace_contract::revision_matches(if_match, actual.as_deref()) {
514                return Err(agent_workspace_contract::WorkspaceVersionConflict {
515                    expected: if_match.to_string(),
516                    actual,
517                }
518                .into());
519            }
520            self.remove_file(path).await
521        }
522    }
523
524    async fn exists(&self, path: &str) -> Result<bool> {
525        let target = self.resolve(path)?;
526        match tokio::fs::symlink_metadata(&target).await {
527            Ok(_) => {
528                self.resolve_existing(path).await?;
529                Ok(true)
530            }
531            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
532            Err(error) => Err(error.into()),
533        }
534    }
535
536    async fn is_dir(&self, path: &str) -> Result<bool> {
537        if !self.exists(path).await? {
538            return Ok(false);
539        }
540        Ok(tokio::fs::metadata(self.resolve_existing(path).await?)
541            .await?
542            .is_dir())
543    }
544
545    async fn list_dir(&self, path: &str) -> Result<Vec<DirEntry>> {
546        self.list_dir_inner(path, None).await
547    }
548
549    async fn list_dir_with_options(
550        &self,
551        path: &str,
552        options: FileVisibilityOptions,
553    ) -> Result<Vec<DirEntry>> {
554        self.list_dir_inner(path, Some(options)).await
555    }
556
557    async fn walk_tree(&self, path: &str, max_depth: usize) -> Result<Vec<String>> {
558        self.walk_tree_inner(path, max_depth, None).await
559    }
560
561    async fn walk_tree_with_options(
562        &self,
563        path: &str,
564        max_depth: usize,
565        options: FileVisibilityOptions,
566    ) -> Result<Vec<String>> {
567        self.walk_tree_inner(path, max_depth, Some(options)).await
568    }
569
570    async fn find_files(&self, pattern: &str, path: &str) -> Result<Vec<String>> {
571        #[cfg(unix)]
572        {
573            let secure = self.secure_root.clone();
574            let path = path.to_string();
575            let pattern = glob::Pattern::new(pattern)?;
576            return tokio::task::spawn_blocking(move || {
577                let mut output_bytes = 0_usize;
578                let mut result = Vec::new();
579                for candidate in secure.walk(&path, 64, None)? {
580                    let name = Path::new(&candidate)
581                        .file_name()
582                        .and_then(|value| value.to_str())
583                        .unwrap_or("");
584                    if pattern.matches(name) && secure.is_regular_file(&candidate)? {
585                        output_bytes = output_bytes.saturating_add(candidate.len());
586                        anyhow::ensure!(
587                            output_bytes <= WORKSPACE_MAX_SEARCH_OUTPUT_BYTES,
588                            "find output limit exceeded"
589                        );
590                        result.push(candidate);
591                    }
592                }
593                Ok(result)
594            })
595            .await
596            .context("joining descriptor-relative file search")?;
597        }
598        #[cfg(not(unix))]
599        {
600            let target = self.resolve_existing(path).await?;
601            let pattern = glob::Pattern::new(pattern)?;
602            let mut results = Vec::new();
603            let mut output_bytes = 0;
604            let root = self.canonical_root().await?;
605            self.find_files_inner(&target, &pattern, &mut results, &mut output_bytes, &root)?;
606            Ok(results)
607        }
608    }
609
610    async fn grep(
611        &self,
612        pattern: &str,
613        path: &str,
614        include: Option<&str>,
615    ) -> Result<Vec<GrepMatch>> {
616        #[cfg(unix)]
617        {
618            let secure = self.secure_root.clone();
619            let path = path.to_string();
620            let regex = regex::Regex::new(pattern)?;
621            let include_pattern = include.map(glob::Pattern::new).transpose()?;
622            return tokio::task::spawn_blocking(move || {
623                let mut result = Vec::new();
624                let mut output_bytes = 0_usize;
625                for candidate in secure.walk(&path, 64, None)? {
626                    let name = Path::new(&candidate)
627                        .file_name()
628                        .and_then(|value| value.to_str())
629                        .unwrap_or("");
630                    if !secure.is_regular_file(&candidate)?
631                        || !include_pattern
632                            .as_ref()
633                            .is_none_or(|value| value.matches(name))
634                    {
635                        continue;
636                    }
637                    let bytes = secure.read(&candidate)?;
638                    let content = String::from_utf8_lossy(&bytes);
639                    for (line_number, line) in content.lines().enumerate() {
640                        if regex.is_match(line) {
641                            anyhow::ensure!(
642                                result.len() < WORKSPACE_MAX_RESULTS,
643                                "grep result limit exceeded"
644                            );
645                            output_bytes = output_bytes
646                                .saturating_add(candidate.len())
647                                .saturating_add(line.len());
648                            anyhow::ensure!(
649                                output_bytes <= WORKSPACE_MAX_SEARCH_OUTPUT_BYTES,
650                                "grep output limit exceeded"
651                            );
652                            result.push(GrepMatch {
653                                file: candidate.clone(),
654                                line_number: line_number + 1,
655                                line: line.into(),
656                            });
657                        }
658                    }
659                }
660                Ok(result)
661            })
662            .await
663            .context("joining descriptor-relative grep")?;
664        }
665        #[cfg(not(unix))]
666        {
667            let target = self.resolve_existing(path).await?;
668            let regex = regex::Regex::new(pattern)?;
669            let include_pattern = include.map(glob::Pattern::new).transpose()?;
670            let mut results = Vec::new();
671            let mut output_bytes = 0;
672            let root = self.canonical_root().await?;
673            self.grep_inner(
674                &target,
675                &regex,
676                include_pattern.as_ref(),
677                &mut results,
678                &mut output_bytes,
679                &root,
680            )?;
681            Ok(results)
682        }
683    }
684
685    async fn exec(&self, command: &str, cwd: Option<&str>) -> Result<CmdOutput> {
686        anyhow::ensure!(
687            self.allow_host_exec,
688            "host command execution is disabled for this local workspace"
689        );
690        anyhow::ensure!(
691            command.len() <= WORKSPACE_MAX_COMMAND_BYTES,
692            "workspace command exceeds {} bytes",
693            WORKSPACE_MAX_COMMAND_BYTES
694        );
695        let cwd = match cwd {
696            Some(c) => self.resolve_existing(c).await?,
697            None => self.resolve_existing(".").await?,
698        };
699        let mut child = tokio::process::Command::new("sh")
700            .arg("-lc")
701            .arg(command)
702            .current_dir(&cwd)
703            .stdout(Stdio::piped())
704            .stderr(Stdio::piped())
705            .kill_on_drop(true)
706            .spawn()
707            .with_context(|| format!("failed to execute: {command}"))?;
708        let stdout = child
709            .stdout
710            .take()
711            .context("command stdout was not captured")?;
712        let stderr = child
713            .stderr
714            .take()
715            .context("command stderr was not captured")?;
716        let stdout_task = tokio::spawn(read_bounded(stdout, WORKSPACE_MAX_COMMAND_OUTPUT_BYTES));
717        let stderr_task = tokio::spawn(read_bounded(stderr, WORKSPACE_MAX_COMMAND_OUTPUT_BYTES));
718
719        let status = match tokio::time::timeout(COMMAND_TIMEOUT, child.wait()).await {
720            Ok(result) => result?,
721            Err(_) => {
722                child.kill().await?;
723                let _ = child.wait().await;
724                anyhow::bail!("workspace command timed out after 60 seconds");
725            }
726        };
727        let (stdout, stdout_truncated) = stdout_task.await.context("joining stdout reader")??;
728        let (stderr, stderr_truncated) = stderr_task.await.context("joining stderr reader")??;
729
730        Ok(CmdOutput {
731            exit_code: status.code().unwrap_or(-1),
732            stdout: String::from_utf8_lossy(&stdout).to_string(),
733            stderr: String::from_utf8_lossy(&stderr).to_string(),
734            stdout_truncated,
735            stderr_truncated,
736        })
737    }
738
739    async fn port_url(&self, port: u16) -> Result<PortUrl> {
740        Ok(PortUrl {
741            port,
742            url: format!("http://localhost:{port}"),
743            host: Some("localhost".to_string()),
744            token: None,
745            signed: false,
746            expires_in_seconds: None,
747        })
748    }
749}
750
751impl LocalWorkspace {
752    #[cfg(not(unix))]
753    fn find_files_inner(
754        &self,
755        dir: &Path,
756        pattern: &glob::Pattern,
757        results: &mut Vec<String>,
758        output_bytes: &mut usize,
759        root: &Path,
760    ) -> Result<()> {
761        let read_dir =
762            std::fs::read_dir(dir).with_context(|| format!("failed to list {}", dir.display()))?;
763        for entry in read_dir {
764            let entry = entry?;
765            let path = entry.path();
766            let name = entry.file_name().to_string_lossy().to_string();
767            let file_type = entry.file_type()?;
768            if file_type.is_dir() {
769                self.find_files_inner(&path, pattern, results, output_bytes, root)?;
770            } else if file_type.is_file() && pattern.matches(&name) {
771                anyhow::ensure!(
772                    results.len() < WORKSPACE_MAX_RESULTS,
773                    "find result limit exceeded"
774                );
775                let relative = relative_path(root, &path)?;
776                *output_bytes = output_bytes.saturating_add(relative.len());
777                anyhow::ensure!(
778                    *output_bytes <= WORKSPACE_MAX_SEARCH_OUTPUT_BYTES,
779                    "find output limit exceeded"
780                );
781                results.push(relative);
782            }
783        }
784        Ok(())
785    }
786
787    #[cfg(not(unix))]
788    fn grep_inner(
789        &self,
790        dir: &Path,
791        regex: &regex::Regex,
792        include_pattern: Option<&glob::Pattern>,
793        results: &mut Vec<GrepMatch>,
794        output_bytes: &mut usize,
795        root: &Path,
796    ) -> Result<()> {
797        let read_dir =
798            std::fs::read_dir(dir).with_context(|| format!("failed to list {}", dir.display()))?;
799        for entry in read_dir {
800            let entry = entry?;
801            let path = entry.path();
802            let name = entry.file_name().to_string_lossy().to_string();
803            let file_type = entry.file_type()?;
804            if file_type.is_dir() {
805                self.grep_inner(&path, regex, include_pattern, results, output_bytes, root)?;
806            } else if file_type.is_file() && include_pattern.is_none_or(|p| p.matches(&name)) {
807                anyhow::ensure!(
808                    entry.metadata()?.len() <= WORKSPACE_MAX_FILE_BYTES as u64,
809                    "grep input file is too large: {}",
810                    path.display()
811                );
812                if let Ok(content) = std::fs::read_to_string(&path) {
813                    for (line_number, line) in content.lines().enumerate() {
814                        if regex.is_match(line) {
815                            anyhow::ensure!(
816                                results.len() < WORKSPACE_MAX_RESULTS,
817                                "grep result limit exceeded"
818                            );
819                            let file = relative_path(root, &path)?;
820                            *output_bytes = output_bytes
821                                .saturating_add(file.len())
822                                .saturating_add(line.len());
823                            anyhow::ensure!(
824                                *output_bytes <= WORKSPACE_MAX_SEARCH_OUTPUT_BYTES,
825                                "grep output limit exceeded"
826                            );
827                            results.push(GrepMatch {
828                                file,
829                                line_number: line_number + 1,
830                                line: line.to_string(),
831                            });
832                        }
833                    }
834                }
835            }
836        }
837        Ok(())
838    }
839}
840
841#[cfg(not(unix))]
842fn relative_path(root: &Path, path: &Path) -> Result<String> {
843    Ok(path
844        .strip_prefix(root)
845        .context("workspace traversal escaped the root")?
846        .to_string_lossy()
847        .to_string())
848}
849
850async fn read_bounded<Reader>(mut reader: Reader, limit: usize) -> std::io::Result<(Vec<u8>, bool)>
851where
852    Reader: AsyncRead + Unpin,
853{
854    let mut bytes = Vec::with_capacity(limit.min(64 * 1024));
855    let mut buffer = [0u8; 16 * 1024];
856    let mut truncated = false;
857    loop {
858        let read = reader.read(&mut buffer).await?;
859        if read == 0 {
860            break;
861        }
862        let remaining = limit.saturating_sub(bytes.len());
863        let retained = read.min(remaining);
864        bytes.extend_from_slice(&buffer[..retained]);
865        truncated |= retained < read;
866    }
867    Ok((bytes, truncated))
868}
869
870/// Create a local workspace at the given root.
871pub async fn create_local_workspace(root: PathBuf) -> Result<Box<dyn Workspace>> {
872    let ws = LocalWorkspace::new(root);
873    ws.init().await?;
874    Ok(Box::new(ws))
875}