Skip to main content

everruns_core/
mount_fs.rs

1// Mount-based virtual filesystem resolver (EVE-660).
2//
3// `MountFs` is the single resolution seam for the agent's filesystem. It owns:
4//
5//   * a **mount table** — named mount points, each backed by a
6//     `SessionFileSystem`, with a per-mount root in the backend's keyspace, and
7//   * a **current working directory** — relative paths resolve against it.
8//
9// Resolution is uniform and POSIX-shaped: normalize the input against cwd,
10// collapse `.`/`..`, then dispatch to the longest matching mount. `/workspace`
11// is just a mount point (and the default cwd), not a magic prefix re-implemented
12// in every store. Adding `/outputs`, `/.agents/skills`, or volume mounts backed
13// by *different* stores later is `with_mount(...)` — the resolver does not change.
14//
15// Today there is a single workspace backend, so the table holds the root mount
16// (`/` → backend, for legacy backend-native paths like `/AGENTS.md`,
17// `/outputs/…`) and the `/workspace` view of the same backend. Both resolve to
18// the same files; `/workspace` wins by longest-prefix so `/workspace/foo`
19// ≡ `/foo`.
20//
21// See `specs/file-store.md` for the contract and the migration plan.
22
23use async_trait::async_trait;
24use std::sync::Arc;
25
26use crate::error::{AgentLoopError, Result};
27use crate::session_file::{FileInfo, FileStat, GrepMatch, InitialFile, SessionFile};
28use crate::traits::SessionFileSystem;
29use crate::typed_id::SessionId;
30
31/// The conventional mount point and default cwd for the workspace. Models
32/// trained on cloud-agent layouts address files here; it is a real mount, not a
33/// strip-prefix. Same string as [`crate::session_path::WORKSPACE_PREFIX`] (the
34/// display alias) — kept as one source of truth.
35pub const WORKSPACE_MOUNT: &str = crate::session_path::WORKSPACE_PREFIX;
36
37/// A single entry in the mount table.
38#[derive(Clone)]
39struct Mount {
40    /// Virtual mount point: normalized, absolute, no trailing slash (`/` for
41    /// root).
42    mount_point: String,
43    /// Backend serving this mount.
44    backend: Arc<dyn SessionFileSystem>,
45    /// Path inside the backend's own keyspace that `mount_point` maps to.
46    backend_root: String,
47    /// Whether returned backend paths should remain in the backend's canonical
48    /// keyspace. The root and `/workspace` mounts preserve the legacy primary
49    /// workspace contract (`/src/lib.rs`); named mounts report their mounted
50    /// virtual path (`/workspace/roots/backend/src/lib.rs`).
51    primary_workspace: bool,
52}
53
54#[derive(Clone)]
55struct ResolvedMount {
56    mount_point: String,
57    backend: Arc<dyn SessionFileSystem>,
58    backend_root: String,
59    backend_path: String,
60    primary_workspace: bool,
61}
62
63/// Mount-based resolver. Implements `SessionFileSystem`, so it drops into
64/// `ToolContext` / `SystemPromptContext` wherever the file store is wired.
65pub struct MountFs {
66    /// Sorted by mount-point length descending so the first match is the
67    /// longest (most specific) mount.
68    mounts: Vec<Mount>,
69    /// The workspace backend — used for grep, display, and host mapping.
70    primary: Arc<dyn SessionFileSystem>,
71    /// Current working directory (a normalized virtual path). Relative inputs
72    /// resolve against it; defaults to [`WORKSPACE_MOUNT`]. Fixed at
73    /// construction — persistent `cd` across tool calls is not a feature yet.
74    cwd: String,
75}
76
77impl MountFs {
78    /// Build a resolver over a single workspace backend.
79    ///
80    /// The backend is mounted at both `/` (its native keyspace, for legacy
81    /// absolute paths) and `/workspace` (the model-facing view). cwd defaults to
82    /// `/workspace`.
83    pub fn new(workspace: Arc<dyn SessionFileSystem>) -> Self {
84        let mounts = vec![
85            Mount {
86                mount_point: "/".to_string(),
87                backend: workspace.clone(),
88                backend_root: "/".to_string(),
89                primary_workspace: true,
90            },
91            Mount {
92                mount_point: WORKSPACE_MOUNT.to_string(),
93                backend: workspace.clone(),
94                backend_root: "/".to_string(),
95                primary_workspace: true,
96            },
97        ];
98        let mut fs = Self {
99            mounts,
100            primary: workspace,
101            cwd: WORKSPACE_MOUNT.to_string(),
102        };
103        fs.sort_mounts();
104        fs
105    }
106
107    /// Build a resolver and return it as a trait object.
108    pub fn wrap(workspace: Arc<dyn SessionFileSystem>) -> Arc<dyn SessionFileSystem> {
109        Arc::new(Self::new(workspace))
110    }
111
112    /// Register an additional mount (e.g. a read-only skills source or a named
113    /// volume) backed by a different store. Longest-prefix wins at resolution.
114    pub fn with_mount(
115        mut self,
116        mount_point: impl Into<String>,
117        backend: Arc<dyn SessionFileSystem>,
118        backend_root: impl Into<String>,
119    ) -> Self {
120        self.mounts.push(Mount {
121            mount_point: normalize_virtual(&mount_point.into(), "/"),
122            backend,
123            backend_root: normalize_virtual(&backend_root.into(), "/"),
124            primary_workspace: false,
125        });
126        self.sort_mounts();
127        self
128    }
129
130    /// The current working directory (normalized virtual path).
131    pub fn cwd(&self) -> String {
132        self.cwd.clone()
133    }
134
135    fn sort_mounts(&mut self) {
136        // Longest mount point first, so resolution picks the most specific mount.
137        self.mounts
138            .sort_by_key(|m| std::cmp::Reverse(m.mount_point.len()));
139    }
140
141    /// Resolve any input path to `(backend, backend_path)`.
142    ///
143    /// Relative inputs are joined to cwd; `.`/`..` are collapsed (clamped at
144    /// root); the longest matching mount is selected and the remainder is mapped
145    /// into that backend's keyspace.
146    fn resolve(&self, input: &str) -> Result<ResolvedMount> {
147        reject_additional_root_traversal(input, &self.cwd)?;
148        let virtual_path = normalize_virtual(input, &self.cwd());
149        for mount in &self.mounts {
150            if let Some(rest) = mount_suffix(&mount.mount_point, &virtual_path) {
151                return Ok(ResolvedMount {
152                    mount_point: mount.mount_point.clone(),
153                    backend: mount.backend.clone(),
154                    backend_root: mount.backend_root.clone(),
155                    backend_path: join_backend_path(&mount.backend_root, &rest),
156                    primary_workspace: mount.primary_workspace,
157                });
158            }
159        }
160        // The root mount matches every absolute path, so this is unreachable in
161        // practice; fall back to the primary backend with the literal path.
162        Ok(ResolvedMount {
163            mount_point: "/".to_string(),
164            backend: self.primary.clone(),
165            backend_root: "/".to_string(),
166            backend_path: virtual_path,
167            primary_workspace: true,
168        })
169    }
170
171    fn grep_mounts(&self) -> Vec<ResolvedMount> {
172        let mut out: Vec<ResolvedMount> = Vec::new();
173        for mount in self.mounts.iter().rev() {
174            if out.iter().any(|existing| {
175                Arc::ptr_eq(&existing.backend, &mount.backend)
176                    && existing.backend_root == mount.backend_root
177            }) {
178                continue;
179            }
180            out.push(ResolvedMount {
181                mount_point: mount.mount_point.clone(),
182                backend: mount.backend.clone(),
183                backend_root: mount.backend_root.clone(),
184                backend_path: mount.backend_root.clone(),
185                primary_workspace: mount.primary_workspace,
186            });
187        }
188        out
189    }
190}
191
192impl ResolvedMount {
193    fn map_session_file(&self, mut file: SessionFile) -> SessionFile {
194        file.path = self.to_virtual_output_path(&file.path);
195        file.name = FileInfo::name_from_path(&file.path);
196        file
197    }
198
199    fn map_file_info(&self, mut info: FileInfo) -> FileInfo {
200        info.path = self.to_virtual_output_path(&info.path);
201        info.name = FileInfo::name_from_path(&info.path);
202        info
203    }
204
205    fn map_file_stat(&self, mut stat: FileStat) -> FileStat {
206        stat.path = self.to_virtual_output_path(&stat.path);
207        stat.name = FileInfo::name_from_path(&stat.path);
208        stat
209    }
210
211    fn map_grep_match(&self, mut grep_match: GrepMatch) -> GrepMatch {
212        grep_match.path = self.to_virtual_output_path(&grep_match.path);
213        grep_match
214    }
215
216    fn to_virtual_output_path(&self, backend_path: &str) -> String {
217        if self.primary_workspace {
218            return normalize_virtual(backend_path, "/");
219        }
220        let normalized = normalize_virtual(backend_path, "/");
221        let rest = mount_suffix(&self.backend_root, &normalized).unwrap_or(normalized);
222        join_backend_path(&self.mount_point, &rest)
223    }
224}
225
226/// Normalize an input into an absolute virtual path: join cwd if relative, then
227/// collapse `.`/`..` segments (a leading `..` is clamped at root).
228fn normalize_virtual(input: &str, cwd: &str) -> String {
229    let combined = if input.starts_with('/') {
230        input.to_string()
231    } else {
232        format!("{}/{}", cwd.trim_end_matches('/'), input)
233    };
234    let mut stack: Vec<&str> = Vec::new();
235    for segment in combined.split('/') {
236        match segment {
237            "" | "." => {}
238            ".." => {
239                stack.pop();
240            }
241            other => stack.push(other),
242        }
243    }
244    if stack.is_empty() {
245        "/".to_string()
246    } else {
247        format!("/{}", stack.join("/"))
248    }
249}
250
251fn reject_additional_root_traversal(input: &str, cwd: &str) -> Result<()> {
252    let combined = if input.starts_with('/') {
253        input.to_string()
254    } else {
255        format!("{}/{}", cwd.trim_end_matches('/'), input)
256    };
257    let segments: Vec<&str> = combined
258        .split('/')
259        .filter(|segment| !segment.is_empty())
260        .collect();
261    for window_start in 0..segments.len().saturating_sub(2) {
262        if segments[window_start] == "workspace" && segments[window_start + 1] == "roots" {
263            let root_name_idx = window_start + 2;
264            if segments[root_name_idx].is_empty() {
265                continue;
266            }
267            if segments
268                .iter()
269                .skip(root_name_idx + 1)
270                .any(|segment| *segment == "..")
271            {
272                return Err(AgentLoopError::tool(format!(
273                    "path traversal rejected: {input}"
274                )));
275            }
276        }
277    }
278    Ok(())
279}
280
281/// If `virtual_path` is at or under `mount_point`, return the suffix as a
282/// `/`-rooted remainder (`/` for an exact match). Segment-aware: `/workspacefoo`
283/// is not under `/workspace`.
284fn mount_suffix(mount_point: &str, virtual_path: &str) -> Option<String> {
285    if mount_point == "/" {
286        // The root mount owns the whole path.
287        return Some(virtual_path.to_string());
288    }
289    if virtual_path == mount_point {
290        return Some("/".to_string());
291    }
292    virtual_path
293        .strip_prefix(mount_point)
294        .filter(|rest| rest.starts_with('/'))
295        .map(|rest| rest.to_string())
296}
297
298/// Join a backend root with a `/`-rooted remainder into a backend keyspace path.
299fn join_backend_path(backend_root: &str, rest: &str) -> String {
300    if backend_root == "/" {
301        return rest.to_string();
302    }
303    if rest == "/" {
304        return backend_root.to_string();
305    }
306    format!("{backend_root}{rest}")
307}
308
309#[async_trait]
310impl SessionFileSystem for MountFs {
311    fn display_root(&self) -> String {
312        self.primary.display_root()
313    }
314
315    fn resolve_path(&self, input: &str) -> String {
316        // The absolute virtual path: relative inputs resolve against cwd,
317        // `.`/`..` collapse, leading `..` clamps at root. This is the namespace
318        // the shell sees — `/workspace` is just the default cwd, and any path
319        // is reachable from the root mount.
320        let virtual_path = normalize_virtual(input, &self.cwd());
321        match self.resolve(&virtual_path) {
322            Ok(resolved) if resolved.primary_workspace => {
323                resolved.backend.display_path(&resolved.backend_path)
324            }
325            _ => virtual_path,
326        }
327    }
328
329    fn display_path(&self, path: &str) -> String {
330        let virtual_path = normalize_virtual(path, "/");
331        match self.resolve(&virtual_path) {
332            Ok(resolved) if resolved.primary_workspace => {
333                resolved.backend.display_path(&resolved.backend_path)
334            }
335            _ => virtual_path,
336        }
337    }
338
339    async fn read_file(&self, session_id: SessionId, path: &str) -> Result<Option<SessionFile>> {
340        let resolved = self.resolve(path)?;
341        Ok(resolved
342            .backend
343            .read_file(session_id, &resolved.backend_path)
344            .await?
345            .map(|file| resolved.map_session_file(file)))
346    }
347
348    async fn write_file(
349        &self,
350        session_id: SessionId,
351        path: &str,
352        content: &str,
353        encoding: &str,
354    ) -> Result<SessionFile> {
355        let resolved = self.resolve(path)?;
356        Ok(resolved.map_session_file(
357            resolved
358                .backend
359                .write_file(session_id, &resolved.backend_path, content, encoding)
360                .await?,
361        ))
362    }
363
364    async fn write_file_if_content_matches(
365        &self,
366        session_id: SessionId,
367        path: &str,
368        expected_content: &str,
369        expected_encoding: &str,
370        content: &str,
371        encoding: &str,
372    ) -> Result<Option<SessionFile>> {
373        let resolved = self.resolve(path)?;
374        Ok(resolved
375            .backend
376            .write_file_if_content_matches(
377                session_id,
378                &resolved.backend_path,
379                expected_content,
380                expected_encoding,
381                content,
382                encoding,
383            )
384            .await?
385            .map(|file| resolved.map_session_file(file)))
386    }
387
388    async fn delete_file(
389        &self,
390        session_id: SessionId,
391        path: &str,
392        recursive: bool,
393    ) -> Result<bool> {
394        let resolved = self.resolve(path)?;
395        resolved
396            .backend
397            .delete_file(session_id, &resolved.backend_path, recursive)
398            .await
399    }
400
401    async fn list_directory(&self, session_id: SessionId, path: &str) -> Result<Vec<FileInfo>> {
402        let resolved = self.resolve(path)?;
403        Ok(resolved
404            .backend
405            .list_directory(session_id, &resolved.backend_path)
406            .await?
407            .into_iter()
408            .map(|info| resolved.map_file_info(info))
409            .collect())
410    }
411
412    async fn stat_file(&self, session_id: SessionId, path: &str) -> Result<Option<FileStat>> {
413        let resolved = self.resolve(path)?;
414        Ok(resolved
415            .backend
416            .stat_file(session_id, &resolved.backend_path)
417            .await?
418            .map(|stat| resolved.map_file_stat(stat)))
419    }
420
421    async fn grep_files(
422        &self,
423        session_id: SessionId,
424        pattern: &str,
425        path_pattern: Option<&str>,
426    ) -> Result<Vec<GrepMatch>> {
427        match path_pattern {
428            Some(pp) => {
429                let resolved = self.resolve(pp)?;
430                Ok(resolved
431                    .backend
432                    .grep_files(session_id, pattern, Some(&resolved.backend_path))
433                    .await?
434                    .into_iter()
435                    .map(|grep_match| resolved.map_grep_match(grep_match))
436                    .collect())
437            }
438            None => {
439                let mut matches = Vec::new();
440                for resolved in self.grep_mounts() {
441                    matches.extend(
442                        resolved
443                            .backend
444                            .grep_files(session_id, pattern, Some(&resolved.backend_path))
445                            .await?
446                            .into_iter()
447                            .map(|grep_match| resolved.map_grep_match(grep_match)),
448                    );
449                }
450                matches.sort_by(|a, b| {
451                    a.path
452                        .cmp(&b.path)
453                        .then(a.line_number.cmp(&b.line_number))
454                        .then(a.line.cmp(&b.line))
455                });
456                Ok(matches)
457            }
458        }
459    }
460
461    async fn create_directory(&self, session_id: SessionId, path: &str) -> Result<FileInfo> {
462        let resolved = self.resolve(path)?;
463        Ok(resolved.map_file_info(
464            resolved
465                .backend
466                .create_directory(session_id, &resolved.backend_path)
467                .await?,
468        ))
469    }
470
471    async fn seed_initial_file(&self, session_id: SessionId, file: &InitialFile) -> Result<()> {
472        let resolved = self.resolve(&file.path)?;
473        let seeded = InitialFile {
474            path: resolved.backend_path,
475            content: file.content.clone(),
476            encoding: file.encoding.clone(),
477            is_readonly: file.is_readonly,
478        };
479        resolved
480            .backend
481            .seed_initial_file(session_id, &seeded)
482            .await
483    }
484}
485
486#[cfg(test)]
487mod tests {
488    use super::*;
489
490    fn sid() -> SessionId {
491        SessionId::from_seed(1)
492    }
493
494    // A minimal `/`-rooted in-memory backend for resolver tests (kept local to
495    // avoid a dependency on everruns-runtime).
496    #[derive(Default)]
497    struct FlatStore {
498        files: std::sync::Mutex<std::collections::HashMap<String, String>>,
499    }
500
501    #[async_trait]
502    impl SessionFileSystem for FlatStore {
503        async fn read_file(&self, sid: SessionId, path: &str) -> Result<Option<SessionFile>> {
504            let files = self.files.lock().unwrap();
505            Ok(files.get(path).map(|content| SessionFile {
506                id: uuid::Uuid::nil(),
507                session_id: sid.uuid(),
508                path: path.to_string(),
509                name: path.rsplit('/').next().unwrap_or("").to_string(),
510                content: Some(content.clone()),
511                encoding: "text".to_string(),
512                is_directory: false,
513                is_readonly: false,
514                size_bytes: content.len() as i64,
515                created_at: chrono::Utc::now(),
516                updated_at: chrono::Utc::now(),
517            }))
518        }
519        async fn write_file(
520            &self,
521            sid: SessionId,
522            path: &str,
523            content: &str,
524            encoding: &str,
525        ) -> Result<SessionFile> {
526            self.files
527                .lock()
528                .unwrap()
529                .insert(path.to_string(), content.to_string());
530            Ok(SessionFile {
531                id: uuid::Uuid::nil(),
532                session_id: sid.uuid(),
533                path: path.to_string(),
534                name: path.rsplit('/').next().unwrap_or("").to_string(),
535                content: Some(content.to_string()),
536                encoding: encoding.to_string(),
537                is_directory: false,
538                is_readonly: false,
539                size_bytes: content.len() as i64,
540                created_at: chrono::Utc::now(),
541                updated_at: chrono::Utc::now(),
542            })
543        }
544        async fn delete_file(&self, _: SessionId, path: &str, _: bool) -> Result<bool> {
545            Ok(self.files.lock().unwrap().remove(path).is_some())
546        }
547        async fn list_directory(&self, _: SessionId, _: &str) -> Result<Vec<FileInfo>> {
548            Ok(vec![])
549        }
550        async fn stat_file(&self, _: SessionId, path: &str) -> Result<Option<FileStat>> {
551            let files = self.files.lock().unwrap();
552            Ok(files.get(path).map(|content| FileStat {
553                path: path.to_string(),
554                name: path.rsplit('/').next().unwrap_or("").to_string(),
555                is_directory: false,
556                is_readonly: false,
557                size_bytes: content.len() as i64,
558                created_at: chrono::Utc::now(),
559                updated_at: chrono::Utc::now(),
560            }))
561        }
562        async fn grep_files(
563            &self,
564            _: SessionId,
565            pattern: &str,
566            path_pattern: Option<&str>,
567        ) -> Result<Vec<GrepMatch>> {
568            let files = self.files.lock().unwrap();
569            let mut matches = Vec::new();
570            for (path, content) in files.iter() {
571                if let Some(filter) = path_pattern
572                    && !path.contains(filter)
573                {
574                    continue;
575                }
576                for (idx, line) in content.lines().enumerate() {
577                    if line.contains(pattern) {
578                        matches.push(GrepMatch {
579                            path: path.clone(),
580                            line_number: idx + 1,
581                            line: line.to_string(),
582                        });
583                    }
584                }
585            }
586            Ok(matches)
587        }
588        async fn create_directory(&self, sid: SessionId, path: &str) -> Result<FileInfo> {
589            Ok(FileInfo {
590                id: uuid::Uuid::nil(),
591                session_id: sid.uuid(),
592                name: path.rsplit('/').next().unwrap_or("").to_string(),
593                path: path.to_string(),
594                is_directory: true,
595                is_readonly: false,
596                size_bytes: 0,
597                created_at: chrono::Utc::now(),
598                updated_at: chrono::Utc::now(),
599            })
600        }
601    }
602
603    #[test]
604    fn normalize_resolves_relative_against_cwd() {
605        assert_eq!(
606            normalize_virtual("foo/bar", "/workspace"),
607            "/workspace/foo/bar"
608        );
609        assert_eq!(normalize_virtual("/foo", "/workspace"), "/foo");
610        assert_eq!(normalize_virtual("a/../b", "/workspace"), "/workspace/b");
611        assert_eq!(normalize_virtual("../../x", "/workspace"), "/x");
612        assert_eq!(normalize_virtual(".", "/workspace"), "/workspace");
613        assert_eq!(normalize_virtual("/", "/workspace"), "/");
614    }
615
616    #[tokio::test]
617    async fn workspace_and_root_address_the_same_file() {
618        let backend: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
619        let fs = MountFs::new(backend);
620
621        // Write via the /workspace view; read back via the backend-native path.
622        fs.write_file(sid(), "/workspace/src/lib.rs", "X", "text")
623            .await
624            .unwrap();
625        let via_root = fs.read_file(sid(), "/src/lib.rs").await.unwrap().unwrap();
626        assert_eq!(via_root.content.as_deref(), Some("X"));
627        // The backend keyed it at /src/lib.rs (no /workspace in the keyspace).
628        assert_eq!(via_root.path, "/src/lib.rs");
629    }
630
631    #[tokio::test]
632    async fn relative_paths_resolve_against_cwd() {
633        let backend: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
634        let fs = MountFs::new(backend);
635        assert_eq!(fs.cwd(), "/workspace");
636
637        fs.write_file(sid(), "notes.md", "hi", "text")
638            .await
639            .unwrap();
640        // cwd is /workspace, so the relative write landed at backend /notes.md.
641        let read = fs.read_file(sid(), "/notes.md").await.unwrap().unwrap();
642        assert_eq!(read.content.as_deref(), Some("hi"));
643    }
644
645    #[tokio::test]
646    async fn legacy_subtree_paths_pass_through_root_mount() {
647        let backend: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
648        let fs = MountFs::new(backend);
649        // Internal callers write /outputs/... and /AGENTS.md directly.
650        fs.write_file(sid(), "/outputs/call.stdout", "out", "text")
651            .await
652            .unwrap();
653        let read = fs
654            .read_file(sid(), "/workspace/outputs/call.stdout")
655            .await
656            .unwrap()
657            .unwrap();
658        assert_eq!(read.content.as_deref(), Some("out"));
659    }
660
661    #[test]
662    fn display_uses_the_primary_backends_identity() {
663        let backend: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
664        let fs = MountFs::new(backend);
665        assert_eq!(fs.display_root(), "/workspace");
666        assert_eq!(fs.display_path("/src/lib.rs"), "/workspace/src/lib.rs");
667        assert_eq!(fs.display_path("/"), "/workspace");
668        assert_eq!(fs.resolve_path("src/lib.rs"), "/workspace/src/lib.rs");
669    }
670
671    #[tokio::test]
672    async fn additional_mount_routes_to_its_backend() {
673        let workspace: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
674        let volume: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
675        let fs = MountFs::new(workspace).with_mount("/data", volume.clone(), "/");
676
677        fs.write_file(sid(), "/data/report.csv", "1,2,3", "text")
678            .await
679            .unwrap();
680        // It went to the volume backend at /report.csv, not the workspace.
681        let from_volume = volume
682            .read_file(sid(), "/report.csv")
683            .await
684            .unwrap()
685            .unwrap();
686        assert_eq!(from_volume.content.as_deref(), Some("1,2,3"));
687    }
688
689    #[tokio::test]
690    async fn additional_mount_outputs_use_virtual_paths() {
691        let workspace: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
692        let volume: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
693        let fs = MountFs::new(workspace).with_mount("/workspace/roots/backend", volume, "/");
694
695        let written = fs
696            .write_file(
697                sid(),
698                "/workspace/roots/backend/Cargo.toml",
699                "name = \"backend\"",
700                "text",
701            )
702            .await
703            .unwrap();
704        assert_eq!(written.path, "/workspace/roots/backend/Cargo.toml");
705
706        let stat = fs
707            .stat_file(sid(), "/workspace/roots/backend/Cargo.toml")
708            .await
709            .unwrap()
710            .unwrap();
711        assert_eq!(stat.path, "/workspace/roots/backend/Cargo.toml");
712        assert_eq!(
713            fs.display_path(&stat.path),
714            "/workspace/roots/backend/Cargo.toml"
715        );
716        assert_eq!(
717            fs.resolve_path("/workspace/roots/backend/Cargo.toml"),
718            "/workspace/roots/backend/Cargo.toml"
719        );
720    }
721
722    #[tokio::test]
723    async fn grep_without_path_searches_all_mounts() {
724        let workspace: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
725        let volume: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
726        let fs = MountFs::new(workspace).with_mount("/workspace/roots/backend", volume, "/");
727
728        fs.write_file(sid(), "/workspace/README.md", "needle primary", "text")
729            .await
730            .unwrap();
731        fs.write_file(
732            sid(),
733            "/workspace/roots/backend/Cargo.toml",
734            "needle backend",
735            "text",
736        )
737        .await
738        .unwrap();
739
740        let matches = fs.grep_files(sid(), "needle", None).await.unwrap();
741        let paths: Vec<_> = matches.into_iter().map(|m| m.path).collect();
742        assert_eq!(
743            paths,
744            vec![
745                "/README.md".to_string(),
746                "/workspace/roots/backend/Cargo.toml".to_string()
747            ]
748        );
749    }
750}