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::session_file::{GrepOptions, GrepSearchResult};
27
28use crate::error::{AgentLoopError, Result};
29use crate::session_file::{FileInfo, FileStat, GrepMatch, InitialFile, SessionFile};
30use crate::traits::SessionFileSystem;
31use crate::typed_id::SessionId;
32
33/// The conventional mount point and default cwd for the workspace. Models
34/// trained on cloud-agent layouts address files here; it is a real mount, not a
35/// strip-prefix. Same string as [`crate::session_path::WORKSPACE_PREFIX`] (the
36/// display alias) — kept as one source of truth.
37pub const WORKSPACE_MOUNT: &str = crate::session_path::WORKSPACE_PREFIX;
38
39/// A single entry in the mount table.
40#[derive(Clone)]
41struct Mount {
42    /// Virtual mount point: normalized, absolute, no trailing slash (`/` for
43    /// root).
44    mount_point: String,
45    /// Backend serving this mount.
46    backend: Arc<dyn SessionFileSystem>,
47    /// Path inside the backend's own keyspace that `mount_point` maps to.
48    backend_root: String,
49    /// Whether returned backend paths should remain in the backend's canonical
50    /// keyspace. The root and `/workspace` mounts preserve the legacy primary
51    /// workspace contract (`/src/lib.rs`); named mounts report their mounted
52    /// virtual path (`/workspace/roots/backend/src/lib.rs`).
53    primary_workspace: bool,
54}
55
56#[derive(Clone)]
57struct ResolvedMount {
58    mount_point: String,
59    backend: Arc<dyn SessionFileSystem>,
60    backend_root: String,
61    backend_path: String,
62    primary_workspace: bool,
63}
64
65/// Mount-based resolver. Implements `SessionFileSystem`, so it drops into
66/// `ToolContext` / `SystemPromptContext` wherever the file store is wired.
67pub struct MountFs {
68    /// Sorted by mount-point length descending so the first match is the
69    /// longest (most specific) mount.
70    mounts: Vec<Mount>,
71    /// The workspace backend — used as the unreachable resolution fallback.
72    primary: Arc<dyn SessionFileSystem>,
73    /// Current working directory (a normalized virtual path). Relative inputs
74    /// resolve against it; defaults to [`WORKSPACE_MOUNT`]. Fixed at
75    /// construction — persistent `cd` across tool calls is not a feature yet.
76    cwd: String,
77}
78
79impl MountFs {
80    /// Build a resolver over a single workspace backend.
81    ///
82    /// The backend is mounted at both `/` (its native keyspace, for legacy
83    /// absolute paths) and `/workspace` (the model-facing view). cwd defaults to
84    /// `/workspace`.
85    pub fn new(workspace: Arc<dyn SessionFileSystem>) -> Self {
86        let mounts = vec![
87            Mount {
88                mount_point: "/".to_string(),
89                backend: workspace.clone(),
90                backend_root: "/".to_string(),
91                primary_workspace: true,
92            },
93            Mount {
94                mount_point: WORKSPACE_MOUNT.to_string(),
95                backend: workspace.clone(),
96                backend_root: "/".to_string(),
97                primary_workspace: true,
98            },
99        ];
100        let mut fs = Self {
101            mounts,
102            primary: workspace,
103            cwd: WORKSPACE_MOUNT.to_string(),
104        };
105        fs.sort_mounts();
106        fs
107    }
108
109    /// Build a resolver and return it as a trait object.
110    pub fn wrap(workspace: Arc<dyn SessionFileSystem>) -> Arc<dyn SessionFileSystem> {
111        Arc::new(Self::new(workspace))
112    }
113
114    /// Wrap only when `workspace` is not already a [`MountFs`].
115    ///
116    /// Re-wrapping would collapse nested mount tables (e.g. multi-root
117    /// workspaces) into a single primary view and break named-mount display.
118    pub fn wrap_if_needed(workspace: Arc<dyn SessionFileSystem>) -> Arc<dyn SessionFileSystem> {
119        if workspace.is_mount_resolver() {
120            workspace
121        } else {
122            Self::wrap(workspace)
123        }
124    }
125
126    /// Register an additional mount (e.g. a read-only skills source or a named
127    /// volume) backed by a different store. Longest-prefix wins at resolution.
128    pub fn with_mount(
129        mut self,
130        mount_point: impl Into<String>,
131        backend: Arc<dyn SessionFileSystem>,
132        backend_root: impl Into<String>,
133    ) -> Self {
134        self.mounts.push(Mount {
135            mount_point: normalize_virtual(&mount_point.into(), "/"),
136            backend,
137            backend_root: normalize_virtual(&backend_root.into(), "/"),
138            primary_workspace: false,
139        });
140        self.sort_mounts();
141        self
142    }
143
144    /// The current working directory (normalized virtual path).
145    pub fn cwd(&self) -> String {
146        self.cwd.clone()
147    }
148
149    fn sort_mounts(&mut self) {
150        // Longest mount point first, so resolution picks the most specific mount.
151        self.mounts
152            .sort_by_key(|m| std::cmp::Reverse(m.mount_point.len()));
153    }
154
155    /// Resolve any input path to `(backend, backend_path)`.
156    ///
157    /// Relative inputs are joined to cwd; `.`/`..` are collapsed (clamped at
158    /// root); the longest matching mount is selected and the remainder is mapped
159    /// into that backend's keyspace.
160    fn resolve(&self, input: &str) -> Result<ResolvedMount> {
161        reject_additional_root_traversal(input, &self.cwd)?;
162        let virtual_path = normalize_virtual(input, &self.cwd());
163        for mount in &self.mounts {
164            if let Some(rest) = mount_suffix(&mount.mount_point, &virtual_path) {
165                return Ok(ResolvedMount {
166                    mount_point: mount.mount_point.clone(),
167                    backend: mount.backend.clone(),
168                    backend_root: mount.backend_root.clone(),
169                    backend_path: join_backend_path(&mount.backend_root, &rest),
170                    primary_workspace: mount.primary_workspace,
171                });
172            }
173        }
174        // The root mount matches every absolute path, so this is unreachable in
175        // practice; fall back to the primary backend with the literal path.
176        Ok(ResolvedMount {
177            mount_point: "/".to_string(),
178            backend: self.primary.clone(),
179            backend_root: "/".to_string(),
180            backend_path: virtual_path,
181            primary_workspace: true,
182        })
183    }
184
185    fn grep_mounts(&self) -> Vec<ResolvedMount> {
186        let mut out: Vec<ResolvedMount> = Vec::new();
187        for mount in self.mounts.iter().rev() {
188            if out.iter().any(|existing| {
189                Arc::ptr_eq(&existing.backend, &mount.backend)
190                    && existing.backend_root == mount.backend_root
191            }) {
192                continue;
193            }
194            out.push(ResolvedMount {
195                mount_point: mount.mount_point.clone(),
196                backend: mount.backend.clone(),
197                backend_root: mount.backend_root.clone(),
198                backend_path: mount.backend_root.clone(),
199                primary_workspace: mount.primary_workspace,
200            });
201        }
202        out
203    }
204}
205
206impl ResolvedMount {
207    fn map_session_file(&self, mut file: SessionFile) -> SessionFile {
208        file.path = self.to_virtual_output_path(&file.path);
209        file.name = FileInfo::name_from_path(&file.path);
210        file
211    }
212
213    fn map_file_info(&self, mut info: FileInfo) -> FileInfo {
214        info.path = self.to_virtual_output_path(&info.path);
215        info.name = FileInfo::name_from_path(&info.path);
216        info
217    }
218
219    fn map_file_stat(&self, mut stat: FileStat) -> FileStat {
220        stat.path = self.to_virtual_output_path(&stat.path);
221        stat.name = FileInfo::name_from_path(&stat.path);
222        stat
223    }
224
225    fn map_grep_match(&self, mut grep_match: GrepMatch) -> GrepMatch {
226        grep_match.path = self.to_virtual_output_path(&grep_match.path);
227        grep_match
228    }
229
230    fn map_grep_result(&self, mut result: GrepSearchResult) -> GrepSearchResult {
231        for grep_match in &mut result.matches {
232            grep_match.path = self.to_virtual_output_path(&grep_match.path);
233        }
234        for block in &mut result.blocks {
235            block.path = self.to_virtual_output_path(&block.path);
236        }
237        result
238    }
239
240    fn to_virtual_output_path(&self, backend_path: &str) -> String {
241        if self.primary_workspace {
242            return normalize_virtual(backend_path, "/");
243        }
244        let normalized = normalize_virtual(backend_path, "/");
245        let rest = mount_suffix(&self.backend_root, &normalized).unwrap_or(normalized);
246        join_backend_path(&self.mount_point, &rest)
247    }
248}
249
250/// Normalize an input into an absolute virtual path: join cwd if relative, then
251/// collapse `.`/`..` segments (a leading `..` is clamped at root).
252fn normalize_virtual(input: &str, cwd: &str) -> String {
253    let combined = if input.starts_with('/') {
254        input.to_string()
255    } else {
256        format!("{}/{}", cwd.trim_end_matches('/'), input)
257    };
258    let mut stack: Vec<&str> = Vec::new();
259    for segment in combined.split('/') {
260        match segment {
261            "" | "." => {}
262            ".." => {
263                stack.pop();
264            }
265            other => stack.push(other),
266        }
267    }
268    if stack.is_empty() {
269        "/".to_string()
270    } else {
271        format!("/{}", stack.join("/"))
272    }
273}
274
275fn reject_additional_root_traversal(input: &str, cwd: &str) -> Result<()> {
276    let combined = if input.starts_with('/') {
277        input.to_string()
278    } else {
279        format!("{}/{}", cwd.trim_end_matches('/'), input)
280    };
281    let segments: Vec<&str> = combined
282        .split('/')
283        .filter(|segment| !segment.is_empty())
284        .collect();
285    for window_start in 0..segments.len().saturating_sub(2) {
286        if segments[window_start] == "workspace" && segments[window_start + 1] == "roots" {
287            let root_name_idx = window_start + 2;
288            if segments[root_name_idx].is_empty() {
289                continue;
290            }
291            if segments
292                .iter()
293                .skip(root_name_idx + 1)
294                .any(|segment| *segment == "..")
295            {
296                return Err(AgentLoopError::tool(format!(
297                    "path traversal rejected: {input}"
298                )));
299            }
300        }
301    }
302    Ok(())
303}
304
305/// If `virtual_path` is at or under `mount_point`, return the suffix as a
306/// `/`-rooted remainder (`/` for an exact match). Segment-aware: `/workspacefoo`
307/// is not under `/workspace`.
308fn mount_suffix(mount_point: &str, virtual_path: &str) -> Option<String> {
309    if mount_point == "/" {
310        // The root mount owns the whole path.
311        return Some(virtual_path.to_string());
312    }
313    if virtual_path == mount_point {
314        return Some("/".to_string());
315    }
316    virtual_path
317        .strip_prefix(mount_point)
318        .filter(|rest| rest.starts_with('/'))
319        .map(|rest| rest.to_string())
320}
321
322/// Join a backend root with a `/`-rooted remainder into a backend keyspace path.
323fn join_backend_path(backend_root: &str, rest: &str) -> String {
324    if backend_root == "/" {
325        return rest.to_string();
326    }
327    if rest == "/" {
328        return backend_root.to_string();
329    }
330    format!("{backend_root}{rest}")
331}
332
333/// Render a canonical backend key literally under that backend's display root.
334fn display_backend_path(display_root: &str, path: &str) -> String {
335    let normalized = normalize_virtual(path, "/");
336    if normalized == "/" {
337        display_root.to_string()
338    } else if display_root == "/" {
339        normalized
340    } else {
341        format!("{}{normalized}", display_root.trim_end_matches('/'))
342    }
343}
344
345#[async_trait]
346impl SessionFileSystem for MountFs {
347    fn display_root(&self) -> String {
348        WORKSPACE_MOUNT.to_string()
349    }
350
351    fn is_mount_resolver(&self) -> bool {
352        true
353    }
354
355    fn resolve_path(&self, input: &str) -> String {
356        // Resolve the raw input through the mount table, then render the
357        // resolved backend key literally under the host-agnostic /workspace
358        // namespace. Prefixing the backend key (instead of re-stripping the
359        // mount) keeps a literal `workspace/…` backend segment distinct from
360        // the `/workspace` mount alias, so a displayed path round-trips to the
361        // same backend key. Using WORKSPACE_MOUNT — not a backend-derived
362        // display root — keeps this independent of real-disk host paths (#2776).
363        let virtual_path = normalize_virtual(input, &self.cwd());
364        match self.resolve(&virtual_path) {
365            Ok(resolved) if resolved.primary_workspace => {
366                display_backend_path(WORKSPACE_MOUNT, &resolved.backend_path)
367            }
368            _ => virtual_path,
369        }
370    }
371
372    fn display_path(&self, path: &str) -> String {
373        // `path` here is an already-canonical virtual output path (a resolved
374        // `file.path`, i.e. a backend key in the primary namespace, or a named
375        // mount's virtual path). Normalize at root — NOT cwd — so we treat it as
376        // a canonical key and don't inject the `/workspace` cwd alias. Then:
377        //  - named mounts: return as-is (already in their mounted namespace),
378        //  - primary: prefix literally under /workspace so a literal `workspace/…`
379        //    backend segment stays distinct from the mount alias and round-trips.
380        let virtual_path = normalize_virtual(path, "/");
381        match self.resolve(&virtual_path) {
382            Ok(resolved) if !resolved.primary_workspace => virtual_path,
383            _ => display_backend_path(WORKSPACE_MOUNT, &virtual_path),
384        }
385    }
386
387    async fn read_file(&self, session_id: SessionId, path: &str) -> Result<Option<SessionFile>> {
388        let resolved = self.resolve(path)?;
389        Ok(resolved
390            .backend
391            .read_file(session_id, &resolved.backend_path)
392            .await?
393            .map(|file| resolved.map_session_file(file)))
394    }
395
396    async fn write_file(
397        &self,
398        session_id: SessionId,
399        path: &str,
400        content: &str,
401        encoding: &str,
402    ) -> Result<SessionFile> {
403        let resolved = self.resolve(path)?;
404        Ok(resolved.map_session_file(
405            resolved
406                .backend
407                .write_file(session_id, &resolved.backend_path, content, encoding)
408                .await?,
409        ))
410    }
411
412    async fn write_file_if_content_matches(
413        &self,
414        session_id: SessionId,
415        path: &str,
416        expected_content: &str,
417        expected_encoding: &str,
418        content: &str,
419        encoding: &str,
420    ) -> Result<Option<SessionFile>> {
421        let resolved = self.resolve(path)?;
422        Ok(resolved
423            .backend
424            .write_file_if_content_matches(
425                session_id,
426                &resolved.backend_path,
427                expected_content,
428                expected_encoding,
429                content,
430                encoding,
431            )
432            .await?
433            .map(|file| resolved.map_session_file(file)))
434    }
435
436    async fn delete_file(
437        &self,
438        session_id: SessionId,
439        path: &str,
440        recursive: bool,
441    ) -> Result<bool> {
442        let resolved = self.resolve(path)?;
443        resolved
444            .backend
445            .delete_file(session_id, &resolved.backend_path, recursive)
446            .await
447    }
448
449    async fn list_directory(&self, session_id: SessionId, path: &str) -> Result<Vec<FileInfo>> {
450        let resolved = self.resolve(path)?;
451        Ok(resolved
452            .backend
453            .list_directory(session_id, &resolved.backend_path)
454            .await?
455            .into_iter()
456            .map(|info| resolved.map_file_info(info))
457            .collect())
458    }
459
460    async fn stat_file(&self, session_id: SessionId, path: &str) -> Result<Option<FileStat>> {
461        let resolved = self.resolve(path)?;
462        Ok(resolved
463            .backend
464            .stat_file(session_id, &resolved.backend_path)
465            .await?
466            .map(|stat| resolved.map_file_stat(stat)))
467    }
468
469    async fn grep_files(
470        &self,
471        session_id: SessionId,
472        pattern: &str,
473        path_pattern: Option<&str>,
474    ) -> Result<Vec<GrepMatch>> {
475        match path_pattern {
476            Some(pp) => {
477                let matcher = crate::session_path::GrepPathPattern::new(pp)?;
478                if matcher.is_glob() && (!pp.starts_with('/') || pp.starts_with(WORKSPACE_MOUNT)) {
479                    let mut matches = Vec::new();
480                    for resolved in self.grep_mounts() {
481                        matches.extend(
482                            resolved
483                                .backend
484                                .grep_files(session_id, pattern, Some(&resolved.backend_path))
485                                .await?
486                                .into_iter()
487                                .map(|grep_match| resolved.map_grep_match(grep_match))
488                                .filter(|grep_match| matcher.is_match(&grep_match.path)),
489                        );
490                    }
491                    matches.sort_by(|a, b| {
492                        a.path
493                            .cmp(&b.path)
494                            .then(a.line_number.cmp(&b.line_number))
495                            .then(a.line.cmp(&b.line))
496                    });
497                    return Ok(matches);
498                }
499                let resolved = self.resolve(pp)?;
500                Ok(resolved
501                    .backend
502                    .grep_files(session_id, pattern, Some(&resolved.backend_path))
503                    .await?
504                    .into_iter()
505                    .map(|grep_match| resolved.map_grep_match(grep_match))
506                    .collect())
507            }
508            None => {
509                let mut matches = Vec::new();
510                for resolved in self.grep_mounts() {
511                    matches.extend(
512                        resolved
513                            .backend
514                            .grep_files(session_id, pattern, Some(&resolved.backend_path))
515                            .await?
516                            .into_iter()
517                            .map(|grep_match| resolved.map_grep_match(grep_match)),
518                    );
519                }
520                matches.sort_by(|a, b| {
521                    a.path
522                        .cmp(&b.path)
523                        .then(a.line_number.cmp(&b.line_number))
524                        .then(a.line.cmp(&b.line))
525                });
526                Ok(matches)
527            }
528        }
529    }
530
531    async fn grep_files_with_options(
532        &self,
533        session_id: SessionId,
534        pattern: &str,
535        options: &GrepOptions,
536    ) -> Result<GrepSearchResult> {
537        if let Some(path_pattern) = options.path_pattern.as_deref()
538            && path_pattern.starts_with('/')
539            && !path_pattern.starts_with(WORKSPACE_MOUNT)
540        {
541            let resolved = self.resolve(path_pattern)?;
542            let mut backend_options = options.clone();
543            backend_options.path_pattern = Some(resolved.backend_path.clone());
544            return resolved
545                .backend
546                .grep_files_with_options(session_id, pattern, &backend_options)
547                .await
548                .map(|result| resolved.map_grep_result(result));
549        }
550
551        let mounts = self.grep_mounts();
552        if mounts.len() == 1 {
553            let resolved = &mounts[0];
554            let mut backend_options = options.clone();
555            backend_options.path_pattern = options.path_pattern.as_ref().map(|path| {
556                if path.starts_with(WORKSPACE_MOUNT) {
557                    path.strip_prefix(WORKSPACE_MOUNT)
558                        .unwrap_or(path)
559                        .to_string()
560                } else {
561                    path.clone()
562                }
563            });
564            return resolved
565                .backend
566                .grep_files_with_options(session_id, pattern, &backend_options)
567                .await
568                .map(|result| resolved.map_grep_result(result));
569        }
570
571        let mut backend_options = options.clone();
572        backend_options.offset = 0;
573        backend_options.limit = usize::MAX;
574        backend_options.max_bytes = usize::MAX;
575        let path_matcher = options
576            .path_pattern
577            .as_deref()
578            .map(crate::session_path::GrepPathPattern::new)
579            .transpose()?;
580        let mut results = Vec::new();
581        for resolved in mounts {
582            let mut mount_options = backend_options.clone();
583            mount_options.path_pattern = Some(resolved.backend_path.clone());
584            let result = resolved
585                .backend
586                .grep_files_with_options(session_id, pattern, &mount_options)
587                .await?;
588            let mut mapped = resolved.map_grep_result(result);
589            if let Some(matcher) = &path_matcher {
590                mapped.matches.retain(|item| matcher.is_match(&item.path));
591                mapped.blocks.retain(|block| matcher.is_match(&block.path));
592            }
593            results.push(mapped);
594        }
595        Ok(crate::session_file::merge_grep_search_results(
596            results, options,
597        ))
598    }
599
600    async fn create_directory(&self, session_id: SessionId, path: &str) -> Result<FileInfo> {
601        let resolved = self.resolve(path)?;
602        Ok(resolved.map_file_info(
603            resolved
604                .backend
605                .create_directory(session_id, &resolved.backend_path)
606                .await?,
607        ))
608    }
609
610    async fn seed_initial_file(&self, session_id: SessionId, file: &InitialFile) -> Result<()> {
611        let resolved = self.resolve(&file.path)?;
612        let seeded = InitialFile {
613            path: resolved.backend_path,
614            content: file.content.clone(),
615            encoding: file.encoding.clone(),
616            is_readonly: file.is_readonly,
617        };
618        resolved
619            .backend
620            .seed_initial_file(session_id, &seeded)
621            .await
622    }
623}
624
625#[cfg(test)]
626mod tests {
627    use super::*;
628    use crate::session_path::GrepPathPattern;
629
630    fn sid() -> SessionId {
631        SessionId::from_seed(1)
632    }
633
634    // A minimal `/`-rooted in-memory backend for resolver tests (kept local to
635    // avoid a dependency on everruns-runtime).
636    #[derive(Default)]
637    struct FlatStore {
638        files: std::sync::Mutex<std::collections::HashMap<String, String>>,
639    }
640
641    #[async_trait]
642    impl SessionFileSystem for FlatStore {
643        fn is_mount_resolver(&self) -> bool {
644            false
645        }
646
647        async fn read_file(&self, sid: SessionId, path: &str) -> Result<Option<SessionFile>> {
648            let files = self.files.lock().unwrap();
649            Ok(files.get(path).map(|content| SessionFile {
650                id: uuid::Uuid::nil(),
651                session_id: sid.uuid(),
652                path: path.to_string(),
653                name: path.rsplit('/').next().unwrap_or("").to_string(),
654                content: Some(content.clone()),
655                encoding: "text".to_string(),
656                is_directory: false,
657                is_readonly: false,
658                size_bytes: content.len() as i64,
659                created_at: chrono::Utc::now(),
660                updated_at: chrono::Utc::now(),
661            }))
662        }
663        async fn write_file(
664            &self,
665            sid: SessionId,
666            path: &str,
667            content: &str,
668            encoding: &str,
669        ) -> Result<SessionFile> {
670            self.files
671                .lock()
672                .unwrap()
673                .insert(path.to_string(), content.to_string());
674            Ok(SessionFile {
675                id: uuid::Uuid::nil(),
676                session_id: sid.uuid(),
677                path: path.to_string(),
678                name: path.rsplit('/').next().unwrap_or("").to_string(),
679                content: Some(content.to_string()),
680                encoding: encoding.to_string(),
681                is_directory: false,
682                is_readonly: false,
683                size_bytes: content.len() as i64,
684                created_at: chrono::Utc::now(),
685                updated_at: chrono::Utc::now(),
686            })
687        }
688        async fn delete_file(&self, _: SessionId, path: &str, _: bool) -> Result<bool> {
689            Ok(self.files.lock().unwrap().remove(path).is_some())
690        }
691        async fn list_directory(&self, _: SessionId, _: &str) -> Result<Vec<FileInfo>> {
692            Ok(vec![])
693        }
694        async fn stat_file(&self, _: SessionId, path: &str) -> Result<Option<FileStat>> {
695            let files = self.files.lock().unwrap();
696            Ok(files.get(path).map(|content| FileStat {
697                path: path.to_string(),
698                name: path.rsplit('/').next().unwrap_or("").to_string(),
699                is_directory: false,
700                is_readonly: false,
701                size_bytes: content.len() as i64,
702                created_at: chrono::Utc::now(),
703                updated_at: chrono::Utc::now(),
704            }))
705        }
706        async fn grep_files(
707            &self,
708            _: SessionId,
709            pattern: &str,
710            path_pattern: Option<&str>,
711        ) -> Result<Vec<GrepMatch>> {
712            let path_pattern = path_pattern.map(GrepPathPattern::new).transpose()?;
713            let files = self.files.lock().unwrap();
714            let mut matches = Vec::new();
715            for (path, content) in files.iter() {
716                if let Some(filter) = &path_pattern
717                    && !filter.is_match(path)
718                {
719                    continue;
720                }
721                for (idx, line) in content.lines().enumerate() {
722                    if line.contains(pattern) {
723                        matches.push(GrepMatch {
724                            path: path.clone(),
725                            line_number: idx + 1,
726                            line: line.to_string(),
727                        });
728                    }
729                }
730            }
731            Ok(matches)
732        }
733        async fn create_directory(&self, sid: SessionId, path: &str) -> Result<FileInfo> {
734            Ok(FileInfo {
735                id: uuid::Uuid::nil(),
736                session_id: sid.uuid(),
737                name: path.rsplit('/').next().unwrap_or("").to_string(),
738                path: path.to_string(),
739                is_directory: true,
740                is_readonly: false,
741                size_bytes: 0,
742                created_at: chrono::Utc::now(),
743                updated_at: chrono::Utc::now(),
744            })
745        }
746    }
747
748    #[test]
749    fn normalize_resolves_relative_against_cwd() {
750        assert_eq!(
751            normalize_virtual("foo/bar", "/workspace"),
752            "/workspace/foo/bar"
753        );
754        assert_eq!(normalize_virtual("/foo", "/workspace"), "/foo");
755        assert_eq!(normalize_virtual("a/../b", "/workspace"), "/workspace/b");
756        assert_eq!(normalize_virtual("../../x", "/workspace"), "/x");
757        assert_eq!(normalize_virtual(".", "/workspace"), "/workspace");
758        assert_eq!(normalize_virtual("/", "/workspace"), "/");
759    }
760
761    #[tokio::test]
762    async fn workspace_and_root_address_the_same_file() {
763        let backend: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
764        let fs = MountFs::new(backend);
765
766        // Write via the /workspace view; read back via the backend-native path.
767        fs.write_file(sid(), "/workspace/src/lib.rs", "X", "text")
768            .await
769            .unwrap();
770        let via_root = fs.read_file(sid(), "/src/lib.rs").await.unwrap().unwrap();
771        assert_eq!(via_root.content.as_deref(), Some("X"));
772        // The backend keyed it at /src/lib.rs (no /workspace in the keyspace).
773        assert_eq!(via_root.path, "/src/lib.rs");
774    }
775
776    #[tokio::test]
777    async fn relative_paths_resolve_against_cwd() {
778        let backend: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
779        let fs = MountFs::new(backend);
780        assert_eq!(fs.cwd(), "/workspace");
781
782        fs.write_file(sid(), "notes.md", "hi", "text")
783            .await
784            .unwrap();
785        // cwd is /workspace, so the relative write landed at backend /notes.md.
786        let read = fs.read_file(sid(), "/notes.md").await.unwrap().unwrap();
787        assert_eq!(read.content.as_deref(), Some("hi"));
788    }
789
790    #[tokio::test]
791    async fn legacy_subtree_paths_pass_through_root_mount() {
792        let backend: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
793        let fs = MountFs::new(backend);
794        // Internal callers write /outputs/... and /AGENTS.md directly.
795        fs.write_file(sid(), "/outputs/call.stdout", "out", "text")
796            .await
797            .unwrap();
798        let read = fs
799            .read_file(sid(), "/workspace/outputs/call.stdout")
800            .await
801            .unwrap()
802            .unwrap();
803        assert_eq!(read.content.as_deref(), Some("out"));
804    }
805
806    #[test]
807    fn display_uses_workspace_alias_for_primary() {
808        let backend: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
809        let fs = MountFs::new(backend);
810        assert_eq!(fs.display_root(), "/workspace");
811        assert_eq!(fs.display_path("/src/lib.rs"), "/workspace/src/lib.rs");
812        assert_eq!(fs.display_path("/"), "/workspace");
813        assert_eq!(fs.resolve_path("src/lib.rs"), "/workspace/src/lib.rs");
814    }
815
816    #[tokio::test]
817    async fn display_preserves_literal_backend_workspace_segment() {
818        let backend: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
819        backend
820            .write_file(sid(), "/workspace/collide.txt", "literal", "text")
821            .await
822            .unwrap();
823        backend
824            .write_file(sid(), "/collide.txt", "alias", "text")
825            .await
826            .unwrap();
827        let fs = MountFs::new(backend);
828
829        let literal = fs
830            .read_file(sid(), "workspace/collide.txt")
831            .await
832            .unwrap()
833            .unwrap();
834        let display_path = fs.display_path(&literal.path);
835        assert_eq!(display_path, "/workspace/workspace/collide.txt");
836
837        let round_trip = fs.read_file(sid(), &display_path).await.unwrap().unwrap();
838        assert_eq!(round_trip.content.as_deref(), Some("literal"));
839    }
840
841    #[tokio::test]
842    async fn additional_mount_routes_to_its_backend() {
843        let workspace: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
844        let volume: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
845        let fs = MountFs::new(workspace).with_mount("/data", volume.clone(), "/");
846
847        fs.write_file(sid(), "/data/report.csv", "1,2,3", "text")
848            .await
849            .unwrap();
850        // It went to the volume backend at /report.csv, not the workspace.
851        let from_volume = volume
852            .read_file(sid(), "/report.csv")
853            .await
854            .unwrap()
855            .unwrap();
856        assert_eq!(from_volume.content.as_deref(), Some("1,2,3"));
857    }
858
859    #[tokio::test]
860    async fn additional_mount_outputs_use_virtual_paths() {
861        let workspace: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
862        let volume: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
863        let fs = MountFs::new(workspace).with_mount("/workspace/roots/backend", volume, "/");
864
865        let written = fs
866            .write_file(
867                sid(),
868                "/workspace/roots/backend/Cargo.toml",
869                "name = \"backend\"",
870                "text",
871            )
872            .await
873            .unwrap();
874        assert_eq!(written.path, "/workspace/roots/backend/Cargo.toml");
875
876        let stat = fs
877            .stat_file(sid(), "/workspace/roots/backend/Cargo.toml")
878            .await
879            .unwrap()
880            .unwrap();
881        assert_eq!(stat.path, "/workspace/roots/backend/Cargo.toml");
882        assert_eq!(
883            fs.display_path(&stat.path),
884            "/workspace/roots/backend/Cargo.toml"
885        );
886        assert_eq!(
887            fs.resolve_path("/workspace/roots/backend/Cargo.toml"),
888            "/workspace/roots/backend/Cargo.toml"
889        );
890    }
891
892    #[tokio::test]
893    async fn grep_without_path_searches_all_mounts() {
894        let workspace: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
895        let volume: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
896        let fs = MountFs::new(workspace).with_mount("/workspace/roots/backend", volume, "/");
897
898        fs.write_file(sid(), "/workspace/README.md", "needle primary", "text")
899            .await
900            .unwrap();
901        fs.write_file(
902            sid(),
903            "/workspace/roots/backend/Cargo.toml",
904            "needle backend",
905            "text",
906        )
907        .await
908        .unwrap();
909
910        let matches = fs.grep_files(sid(), "needle", None).await.unwrap();
911        let paths: Vec<_> = matches.into_iter().map(|m| m.path).collect();
912        assert_eq!(
913            paths,
914            vec![
915                "/README.md".to_string(),
916                "/workspace/roots/backend/Cargo.toml".to_string()
917            ]
918        );
919    }
920
921    #[tokio::test]
922    async fn grep_resolves_workspace_glob_to_backend_namespace() {
923        let backend: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
924        let fs = MountFs::new(backend);
925        fs.write_file(sid(), "/workspace/src/lib.rs", "needle", "text")
926            .await
927            .unwrap();
928        fs.write_file(sid(), "/workspace/docs/readme.md", "needle", "text")
929            .await
930            .unwrap();
931
932        let matches = fs
933            .grep_files(sid(), "needle", Some("/workspace/src/**/*.rs"))
934            .await
935            .unwrap();
936
937        assert_eq!(matches.len(), 1);
938        assert_eq!(matches[0].path, "/src/lib.rs");
939    }
940
941    #[tokio::test]
942    async fn grep_glob_searches_every_matching_mount() {
943        let workspace: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
944        let volume: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
945        let fs = MountFs::new(workspace).with_mount("/workspace/roots/backend", volume, "/");
946        fs.write_file(sid(), "/workspace/Cargo.toml", "needle", "text")
947            .await
948            .unwrap();
949        fs.write_file(
950            sid(),
951            "/workspace/roots/backend/Cargo.toml",
952            "needle",
953            "text",
954        )
955        .await
956        .unwrap();
957
958        let paths: Vec<_> = fs
959            .grep_files(sid(), "needle", Some("**/*.toml"))
960            .await
961            .unwrap()
962            .into_iter()
963            .map(|hit| hit.path)
964            .collect();
965        assert_eq!(
966            paths,
967            vec![
968                "/Cargo.toml".to_string(),
969                "/workspace/roots/backend/Cargo.toml".to_string()
970            ]
971        );
972    }
973
974    #[test]
975    fn mount_fs_identifies_as_resolver() {
976        let workspace: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
977        let fs = MountFs::wrap(workspace);
978        assert!(fs.is_mount_resolver());
979        let again = MountFs::wrap_if_needed(fs.clone());
980        assert!(Arc::ptr_eq(&fs, &again));
981    }
982}