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 `knowledge/runtime-resources/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::session_files::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/// How `MountFs` presents primary-workspace paths to the model, narration,
40/// prompts, and persisted output pointers.
41///
42/// # Why this is a policy seam and not a hardcoded rule
43///
44/// `/workspace` plays **two independent roles** in `MountFs`, and they must not
45/// be conflated:
46///
47///  1. **Routing / cwd** — the model addresses files at `/workspace/...` and
48///     relative paths resolve there. This is a *runtime mechanism*: it is the
49///     same for every embedder (models are trained on cloud-agent layouts), so
50///     it stays hardcoded as [`WORKSPACE_MOUNT`].
51///  2. **Display presentation** — the path string shown to the model, emitted
52///     in tool narration, and persisted in output pointers. This is *policy*,
53///     and it legitimately differs per embedder. That is what this enum selects.
54///
55/// # History (do not re-collapse these two roles)
56///
57///  - PR #2776 made `MountFs` present `/workspace` unconditionally, deleting the
58///    old delegation to `backend.display_path(...)`. The motivation was real: a
59///    mounted **real-disk server** session leaked the host checkout path
60///    (`/private/var/.../checkout/src/lib.rs`) to the model and into persisted,
61///    agent-visible output — a host-disclosure issue (threat model TM-FS). In a
62///    multi-tenant server the host is infrastructure the model must not see, so
63///    `WorkspaceAlias` is the correct, safe **default**.
64///  - But #2776 baked that *policy* into the *mechanism*, in shared
65///    `everruns-core`. That broke local single-user embedders (e.g. the `yolop`
66///    coding CLI, PR #258 "expose real workspace paths"), where the "host" *is*
67///    the user's own machine. There, showing `/Users/me/proj/src/lib.rs` is not
68///    a disclosure — it is the desired behavior: paths are clickable and match
69///    what `bash pwd` prints. Such embedders still need `MountFs` for routing
70///    (relative resolution, the default cwd, extra mounts), so they cannot
71///    simply drop it; they need presentation to be overridable.
72///
73/// The resolution: keep the safe alias as the default so no server code changes
74/// and #2776's security property is preserved, but expose `BackendNative` so a
75/// local embedder can opt back into its backend's real identity. The runtime no
76/// longer *hardcodes* presentation — it *defaults* it, and lets the embedder
77/// decide. See `knowledge/runtime-resources/file-store.md` (EVE-660, "Display policy").
78#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
79pub enum DisplayPolicy {
80    /// Present primary paths under the stable, host-agnostic `/workspace`
81    /// namespace, regardless of what the backend physically is. The default;
82    /// required for multi-tenant/server hosts so host paths never reach the
83    /// model or persisted output.
84    #[default]
85    WorkspaceAlias,
86    /// Delegate presentation of primary paths to the backend, exposing its
87    /// native identity (real host paths for a real-disk store). For local,
88    /// single-user embedders where the host is the user's own machine and real
89    /// paths are the intended, useful output.
90    BackendNative,
91}
92
93/// A single entry in the mount table.
94#[derive(Clone)]
95struct Mount {
96    /// Virtual mount point: normalized, absolute, no trailing slash (`/` for
97    /// root).
98    mount_point: String,
99    /// Backend serving this mount.
100    backend: Arc<dyn SessionFileSystem>,
101    /// Path inside the backend's own keyspace that `mount_point` maps to.
102    backend_root: String,
103    /// Whether returned backend paths should remain in the backend's canonical
104    /// keyspace. The root and `/workspace` mounts preserve the legacy primary
105    /// workspace contract (`/src/lib.rs`); named mounts report their mounted
106    /// virtual path (`/workspace/roots/backend/src/lib.rs`).
107    primary_workspace: bool,
108}
109
110#[derive(Clone)]
111struct ResolvedMount {
112    mount_point: String,
113    backend: Arc<dyn SessionFileSystem>,
114    backend_root: String,
115    backend_path: String,
116    primary_workspace: bool,
117}
118
119/// Mount-based resolver. Implements `SessionFileSystem`, so it drops into
120/// `ToolContext` / `SystemPromptContext` wherever the file store is wired.
121pub struct MountFs {
122    /// Sorted by mount-point length descending so the first match is the
123    /// longest (most specific) mount.
124    mounts: Vec<Mount>,
125    /// The workspace backend — used as the unreachable resolution fallback.
126    primary: Arc<dyn SessionFileSystem>,
127    /// Current working directory (a normalized virtual path). Relative inputs
128    /// resolve against it; defaults to [`WORKSPACE_MOUNT`]. Fixed at
129    /// construction — persistent `cd` across tool calls is not a feature yet.
130    cwd: String,
131    /// How primary-workspace paths are presented to the model/narration.
132    /// Defaults to the host-agnostic alias; embedders opt into backend-native
133    /// presentation via [`MountFs::with_backend_display`]. See [`DisplayPolicy`].
134    display_policy: DisplayPolicy,
135}
136
137impl MountFs {
138    /// Build a resolver over a single workspace backend.
139    ///
140    /// The backend is mounted at both `/` (its native keyspace, for legacy
141    /// absolute paths) and `/workspace` (the model-facing view). cwd defaults to
142    /// `/workspace`.
143    pub fn new(workspace: Arc<dyn SessionFileSystem>) -> Self {
144        let mounts = vec![
145            Mount {
146                mount_point: "/".to_string(),
147                backend: workspace.clone(),
148                backend_root: "/".to_string(),
149                primary_workspace: true,
150            },
151            Mount {
152                mount_point: WORKSPACE_MOUNT.to_string(),
153                backend: workspace.clone(),
154                backend_root: "/".to_string(),
155                primary_workspace: true,
156            },
157        ];
158        let mut fs = Self {
159            mounts,
160            primary: workspace,
161            cwd: WORKSPACE_MOUNT.to_string(),
162            display_policy: DisplayPolicy::default(),
163        };
164        fs.sort_mounts();
165        fs
166    }
167
168    /// Select how primary-workspace paths are presented. See [`DisplayPolicy`]
169    /// for the rationale behind keeping presentation separate from routing.
170    pub fn with_display_policy(mut self, policy: DisplayPolicy) -> Self {
171        self.display_policy = policy;
172        self
173    }
174
175    /// Opt into backend-native presentation: primary paths are rendered by the
176    /// backend's own `display_path`/`display_root` (real host paths for a
177    /// real-disk store), instead of the host-agnostic `/workspace` alias.
178    ///
179    /// For local, single-user embedders (e.g. a coding CLI) where exposing the
180    /// real host path is the intended, useful behavior. Routing is unchanged —
181    /// only presentation. Do **not** use this on multi-tenant/server hosts; the
182    /// default [`DisplayPolicy::WorkspaceAlias`] keeps host paths out of
183    /// model-visible and persisted output (threat model TM-FS, PR #2776).
184    pub fn with_backend_display(self) -> Self {
185        self.with_display_policy(DisplayPolicy::BackendNative)
186    }
187
188    /// Present a resolved primary-workspace backend key to the model, honoring
189    /// the configured [`DisplayPolicy`]. Named (non-primary) mounts do not go
190    /// through here — they always report their own virtual path.
191    fn present_primary_key(&self, canonical_key: &str) -> String {
192        match self.display_policy {
193            // Host-agnostic: render the backend key literally under /workspace.
194            DisplayPolicy::WorkspaceAlias => display_backend_path(WORKSPACE_MOUNT, canonical_key),
195            // Backend-native: let the backend expose its own identity (host path).
196            DisplayPolicy::BackendNative => self.primary.display_path(canonical_key),
197        }
198    }
199
200    /// Build a resolver and return it as a trait object.
201    pub fn wrap(workspace: Arc<dyn SessionFileSystem>) -> Arc<dyn SessionFileSystem> {
202        Arc::new(Self::new(workspace))
203    }
204
205    /// Wrap only when `workspace` is not already a [`MountFs`].
206    ///
207    /// Re-wrapping would collapse nested mount tables (e.g. multi-root
208    /// workspaces) into a single primary view and break named-mount display.
209    pub fn wrap_if_needed(workspace: Arc<dyn SessionFileSystem>) -> Arc<dyn SessionFileSystem> {
210        if workspace.is_mount_resolver() {
211            workspace
212        } else {
213            Self::wrap(workspace)
214        }
215    }
216
217    /// Register an additional mount (e.g. a read-only skills source or a named
218    /// volume) backed by a different store. Longest-prefix wins at resolution.
219    pub fn with_mount(
220        mut self,
221        mount_point: impl Into<String>,
222        backend: Arc<dyn SessionFileSystem>,
223        backend_root: impl Into<String>,
224    ) -> Self {
225        self.mounts.push(Mount {
226            mount_point: normalize_virtual(&mount_point.into(), "/"),
227            backend,
228            backend_root: normalize_virtual(&backend_root.into(), "/"),
229            primary_workspace: false,
230        });
231        self.sort_mounts();
232        self
233    }
234
235    /// The current working directory (normalized virtual path).
236    pub fn cwd(&self) -> String {
237        self.cwd.clone()
238    }
239
240    fn sort_mounts(&mut self) {
241        // Longest mount point first, so resolution picks the most specific mount.
242        self.mounts
243            .sort_by_key(|m| std::cmp::Reverse(m.mount_point.len()));
244    }
245
246    /// Resolve any input path to `(backend, backend_path)`.
247    ///
248    /// Relative inputs are joined to cwd; `.`/`..` are collapsed (clamped at
249    /// root); the longest matching mount is selected and the remainder is mapped
250    /// into that backend's keyspace.
251    fn resolve(&self, input: &str) -> Result<ResolvedMount> {
252        reject_additional_root_traversal(input, &self.cwd)?;
253        let virtual_path = normalize_virtual(input, &self.cwd());
254        for mount in &self.mounts {
255            if let Some(rest) = mount_suffix(&mount.mount_point, &virtual_path) {
256                return Ok(ResolvedMount {
257                    mount_point: mount.mount_point.clone(),
258                    backend: mount.backend.clone(),
259                    backend_root: mount.backend_root.clone(),
260                    backend_path: join_backend_path(&mount.backend_root, &rest),
261                    primary_workspace: mount.primary_workspace,
262                });
263            }
264        }
265        // The root mount matches every absolute path, so this is unreachable in
266        // practice; fall back to the primary backend with the literal path.
267        Ok(ResolvedMount {
268            mount_point: "/".to_string(),
269            backend: self.primary.clone(),
270            backend_root: "/".to_string(),
271            backend_path: virtual_path,
272            primary_workspace: true,
273        })
274    }
275
276    fn grep_mounts(&self) -> Vec<ResolvedMount> {
277        let mut out: Vec<ResolvedMount> = Vec::new();
278        for mount in self.mounts.iter().rev() {
279            if out.iter().any(|existing| {
280                Arc::ptr_eq(&existing.backend, &mount.backend)
281                    && existing.backend_root == mount.backend_root
282            }) {
283                continue;
284            }
285            out.push(ResolvedMount {
286                mount_point: mount.mount_point.clone(),
287                backend: mount.backend.clone(),
288                backend_root: mount.backend_root.clone(),
289                backend_path: mount.backend_root.clone(),
290                primary_workspace: mount.primary_workspace,
291            });
292        }
293        out
294    }
295}
296
297impl ResolvedMount {
298    fn map_session_file(&self, mut file: SessionFile) -> SessionFile {
299        file.path = self.to_virtual_output_path(&file.path);
300        file.name = FileInfo::name_from_path(&file.path);
301        file
302    }
303
304    fn map_file_info(&self, mut info: FileInfo) -> FileInfo {
305        info.path = self.to_virtual_output_path(&info.path);
306        info.name = FileInfo::name_from_path(&info.path);
307        info
308    }
309
310    fn map_file_stat(&self, mut stat: FileStat) -> FileStat {
311        stat.path = self.to_virtual_output_path(&stat.path);
312        stat.name = FileInfo::name_from_path(&stat.path);
313        stat
314    }
315
316    fn map_grep_match(&self, mut grep_match: GrepMatch) -> GrepMatch {
317        grep_match.path = self.to_virtual_output_path(&grep_match.path);
318        grep_match
319    }
320
321    fn map_grep_result(&self, mut result: GrepSearchResult) -> GrepSearchResult {
322        for grep_match in &mut result.matches {
323            grep_match.path = self.to_virtual_output_path(&grep_match.path);
324        }
325        for block in &mut result.blocks {
326            block.path = self.to_virtual_output_path(&block.path);
327        }
328        result
329    }
330
331    fn to_virtual_output_path(&self, backend_path: &str) -> String {
332        if self.primary_workspace {
333            return normalize_virtual(backend_path, "/");
334        }
335        let normalized = normalize_virtual(backend_path, "/");
336        let rest = mount_suffix(&self.backend_root, &normalized).unwrap_or(normalized);
337        join_backend_path(&self.mount_point, &rest)
338    }
339}
340
341/// Wrap an embedder's file store for a model-facing
342/// [`SystemPromptContext`](crate::capabilities::SystemPromptContext):
343/// pin reads to the session's workspace, then guarantee a `/workspace` mount +
344/// cwd — WITHOUT discarding a display policy the embedder already configured.
345///
346/// This is the single, shared way the host-owned reason context assembler and
347/// executor capability loader build their prompt file store. Both used to
348/// inline `MountFs::wrap(WorkspaceScopedFileSystem::…)`
349/// separately; they drifted from the tool-execution (`act`) path — which wraps
350/// with [`MountFs::wrap_if_needed`] — so a local embedder's real host paths
351/// reached tool narration but were forced back to `/workspace` in the system
352/// prompt. Centralizing here keeps all three paths identical so presentation
353/// cannot drift again.
354///
355/// Why `wrap_if_needed` (not `wrap`): if `file_store` is already a [`MountFs`]
356/// (a local embedder that opted into [`DisplayPolicy::BackendNative`] via
357/// [`MountFs::with_backend_display`]), re-wrapping would bury it under a fresh
358/// default-`WorkspaceAlias` resolver and re-hide the host path — regressing
359/// EVE-660 / #258 host-path presentation. `wrap_if_needed` preserves the
360/// embedder's resolver (and its policy), and also avoids collapsing multi-root
361/// named mounts (see [`MountFs::wrap_if_needed`]).
362///
363/// Multi-tenant/server stores are unaffected: they are not mount resolvers, so
364/// they still get wrapped into the default `/workspace` alias, keeping host
365/// paths out of model-visible output (#2776, threat model TM-FS).
366pub fn scoped_prompt_file_store(
367    file_store: Arc<dyn SessionFileSystem>,
368    workspace_id: crate::typed_id::WorkspaceId,
369) -> Arc<dyn SessionFileSystem> {
370    MountFs::wrap_if_needed(crate::session_files::WorkspaceScopedFileSystem::wrap(
371        file_store,
372        workspace_id,
373    ))
374}
375
376/// Normalize an input into an absolute virtual path: join cwd if relative, then
377/// collapse `.`/`..` segments (a leading `..` is clamped at root).
378fn normalize_virtual(input: &str, cwd: &str) -> String {
379    let combined = if input.starts_with('/') {
380        input.to_string()
381    } else {
382        format!("{}/{}", cwd.trim_end_matches('/'), input)
383    };
384    let mut stack: Vec<&str> = Vec::new();
385    for segment in combined.split('/') {
386        match segment {
387            "" | "." => {}
388            ".." => {
389                stack.pop();
390            }
391            other => stack.push(other),
392        }
393    }
394    if stack.is_empty() {
395        "/".to_string()
396    } else {
397        format!("/{}", stack.join("/"))
398    }
399}
400
401fn reject_additional_root_traversal(input: &str, cwd: &str) -> Result<()> {
402    let combined = if input.starts_with('/') {
403        input.to_string()
404    } else {
405        format!("{}/{}", cwd.trim_end_matches('/'), input)
406    };
407    let segments: Vec<&str> = combined
408        .split('/')
409        .filter(|segment| !segment.is_empty())
410        .collect();
411    for window_start in 0..segments.len().saturating_sub(2) {
412        if segments[window_start] == "workspace" && segments[window_start + 1] == "roots" {
413            let root_name_idx = window_start + 2;
414            if segments[root_name_idx].is_empty() {
415                continue;
416            }
417            if segments
418                .iter()
419                .skip(root_name_idx + 1)
420                .any(|segment| *segment == "..")
421            {
422                return Err(AgentLoopError::tool(format!(
423                    "path traversal rejected: {input}"
424                )));
425            }
426        }
427    }
428    Ok(())
429}
430
431/// If `virtual_path` is at or under `mount_point`, return the suffix as a
432/// `/`-rooted remainder (`/` for an exact match). Segment-aware: `/workspacefoo`
433/// is not under `/workspace`.
434fn mount_suffix(mount_point: &str, virtual_path: &str) -> Option<String> {
435    if mount_point == "/" {
436        // The root mount owns the whole path.
437        return Some(virtual_path.to_string());
438    }
439    if virtual_path == mount_point {
440        return Some("/".to_string());
441    }
442    virtual_path
443        .strip_prefix(mount_point)
444        .filter(|rest| rest.starts_with('/'))
445        .map(|rest| rest.to_string())
446}
447
448/// Join a backend root with a `/`-rooted remainder into a backend keyspace path.
449fn join_backend_path(backend_root: &str, rest: &str) -> String {
450    if backend_root == "/" {
451        return rest.to_string();
452    }
453    if rest == "/" {
454        return backend_root.to_string();
455    }
456    format!("{backend_root}{rest}")
457}
458
459/// Render a canonical backend key literally under that backend's display root.
460fn display_backend_path(display_root: &str, path: &str) -> String {
461    let normalized = normalize_virtual(path, "/");
462    if normalized == "/" {
463        display_root.to_string()
464    } else if display_root == "/" {
465        normalized
466    } else {
467        format!("{}{normalized}", display_root.trim_end_matches('/'))
468    }
469}
470
471#[async_trait]
472impl SessionFileSystem for MountFs {
473    fn display_root(&self) -> String {
474        // Routing always defaults cwd to /workspace, but the *displayed* root is
475        // policy: the alias for host-agnostic presentation, or the backend's own
476        // root (a real host directory) when the embedder opts in. See
477        // [`DisplayPolicy`].
478        match self.display_policy {
479            DisplayPolicy::WorkspaceAlias => WORKSPACE_MOUNT.to_string(),
480            DisplayPolicy::BackendNative => self.primary.display_root(),
481        }
482    }
483
484    fn is_mount_resolver(&self) -> bool {
485        true
486    }
487
488    fn resolve_path(&self, input: &str) -> String {
489        // Resolve the raw input through the mount table, then present the
490        // resolved backend key per the display policy. Presenting the resolved
491        // backend key (instead of re-stripping the mount) keeps a literal
492        // `workspace/…` backend segment distinct from the `/workspace` mount
493        // alias, so a displayed path round-trips to the same backend key.
494        // Presentation itself (alias vs. backend-native host path) is decided by
495        // [`present_primary_key`] — kept out of routing on purpose (#2776/#258).
496        let virtual_path = normalize_virtual(input, &self.cwd());
497        match self.resolve(&virtual_path) {
498            Ok(resolved) if resolved.primary_workspace => {
499                self.present_primary_key(&resolved.backend_path)
500            }
501            _ => virtual_path,
502        }
503    }
504
505    fn display_path(&self, path: &str) -> String {
506        // `path` here is an already-canonical virtual output path (a resolved
507        // `file.path`, i.e. a backend key in the primary namespace, or a named
508        // mount's virtual path). Normalize at root — NOT cwd — so we treat it as
509        // a canonical key and don't inject the `/workspace` cwd alias. Then:
510        //  - named mounts: return as-is (already in their mounted namespace),
511        //  - primary: present via the display policy so a literal `workspace/…`
512        //    backend segment stays distinct from the mount alias and round-trips
513        //    (alias mode), or renders as the backend's host path (native mode).
514        let virtual_path = normalize_virtual(path, "/");
515        match self.resolve(&virtual_path) {
516            Ok(resolved) if !resolved.primary_workspace => virtual_path,
517            _ => self.present_primary_key(&virtual_path),
518        }
519    }
520
521    async fn read_file(&self, session_id: SessionId, path: &str) -> Result<Option<SessionFile>> {
522        let resolved = self.resolve(path)?;
523        Ok(resolved
524            .backend
525            .read_file(session_id, &resolved.backend_path)
526            .await?
527            .map(|file| resolved.map_session_file(file)))
528    }
529
530    async fn write_file(
531        &self,
532        session_id: SessionId,
533        path: &str,
534        content: &str,
535        encoding: &str,
536    ) -> Result<SessionFile> {
537        let resolved = self.resolve(path)?;
538        Ok(resolved.map_session_file(
539            resolved
540                .backend
541                .write_file(session_id, &resolved.backend_path, content, encoding)
542                .await?,
543        ))
544    }
545
546    async fn write_file_if_content_matches(
547        &self,
548        session_id: SessionId,
549        path: &str,
550        expected_content: &str,
551        expected_encoding: &str,
552        content: &str,
553        encoding: &str,
554    ) -> Result<Option<SessionFile>> {
555        let resolved = self.resolve(path)?;
556        Ok(resolved
557            .backend
558            .write_file_if_content_matches(
559                session_id,
560                &resolved.backend_path,
561                expected_content,
562                expected_encoding,
563                content,
564                encoding,
565            )
566            .await?
567            .map(|file| resolved.map_session_file(file)))
568    }
569
570    async fn delete_file(
571        &self,
572        session_id: SessionId,
573        path: &str,
574        recursive: bool,
575    ) -> Result<bool> {
576        let resolved = self.resolve(path)?;
577        resolved
578            .backend
579            .delete_file(session_id, &resolved.backend_path, recursive)
580            .await
581    }
582
583    async fn list_directory(&self, session_id: SessionId, path: &str) -> Result<Vec<FileInfo>> {
584        let resolved = self.resolve(path)?;
585        Ok(resolved
586            .backend
587            .list_directory(session_id, &resolved.backend_path)
588            .await?
589            .into_iter()
590            .map(|info| resolved.map_file_info(info))
591            .collect())
592    }
593
594    async fn stat_file(&self, session_id: SessionId, path: &str) -> Result<Option<FileStat>> {
595        let resolved = self.resolve(path)?;
596        Ok(resolved
597            .backend
598            .stat_file(session_id, &resolved.backend_path)
599            .await?
600            .map(|stat| resolved.map_file_stat(stat)))
601    }
602
603    async fn grep_files(
604        &self,
605        session_id: SessionId,
606        pattern: &str,
607        path_pattern: Option<&str>,
608    ) -> Result<Vec<GrepMatch>> {
609        match path_pattern {
610            Some(pp) => {
611                let matcher = crate::session_path::GrepPathPattern::new(pp)?;
612                if matcher.is_glob()
613                    && (!pp.starts_with('/') || mount_suffix(WORKSPACE_MOUNT, pp).is_some())
614                {
615                    let mut matches = Vec::new();
616                    for resolved in self.grep_mounts() {
617                        matches.extend(
618                            resolved
619                                .backend
620                                .grep_files(session_id, pattern, Some(&resolved.backend_path))
621                                .await?
622                                .into_iter()
623                                .map(|grep_match| resolved.map_grep_match(grep_match))
624                                .filter(|grep_match| matcher.is_match(&grep_match.path)),
625                        );
626                    }
627                    matches.sort_by(|a, b| {
628                        a.path
629                            .cmp(&b.path)
630                            .then(a.line_number.cmp(&b.line_number))
631                            .then(a.line.cmp(&b.line))
632                    });
633                    return Ok(matches);
634                }
635                let resolved = self.resolve(pp)?;
636                Ok(resolved
637                    .backend
638                    .grep_files(session_id, pattern, Some(&resolved.backend_path))
639                    .await?
640                    .into_iter()
641                    .map(|grep_match| resolved.map_grep_match(grep_match))
642                    .collect())
643            }
644            None => {
645                let mut matches = Vec::new();
646                for resolved in self.grep_mounts() {
647                    matches.extend(
648                        resolved
649                            .backend
650                            .grep_files(session_id, pattern, Some(&resolved.backend_path))
651                            .await?
652                            .into_iter()
653                            .map(|grep_match| resolved.map_grep_match(grep_match)),
654                    );
655                }
656                matches.sort_by(|a, b| {
657                    a.path
658                        .cmp(&b.path)
659                        .then(a.line_number.cmp(&b.line_number))
660                        .then(a.line.cmp(&b.line))
661                });
662                Ok(matches)
663            }
664        }
665    }
666
667    async fn grep_files_with_options(
668        &self,
669        session_id: SessionId,
670        pattern: &str,
671        options: &GrepOptions,
672    ) -> Result<GrepSearchResult> {
673        if let Some(path_pattern) = options.path_pattern.as_deref()
674            && path_pattern.starts_with('/')
675            && mount_suffix(WORKSPACE_MOUNT, path_pattern).is_none()
676        {
677            let resolved = self.resolve(path_pattern)?;
678            let mut backend_options = options.clone();
679            backend_options.path_pattern = Some(resolved.backend_path.clone());
680            return resolved
681                .backend
682                .grep_files_with_options(session_id, pattern, &backend_options)
683                .await
684                .map(|result| resolved.map_grep_result(result));
685        }
686
687        let mounts = self.grep_mounts();
688        if mounts.len() == 1 {
689            let resolved = &mounts[0];
690            let mut backend_options = options.clone();
691            // Only a complete mount segment is an alias; /workspacefoo is a
692            // distinct backend path and must not broaden the search to foo.
693            backend_options.path_pattern = options
694                .path_pattern
695                .as_ref()
696                .map(|path| mount_suffix(WORKSPACE_MOUNT, path).unwrap_or_else(|| path.clone()));
697            return resolved
698                .backend
699                .grep_files_with_options(session_id, pattern, &backend_options)
700                .await
701                .map(|result| resolved.map_grep_result(result));
702        }
703
704        let mut backend_options = options.clone();
705        backend_options.offset = 0;
706        backend_options.limit = usize::MAX;
707        backend_options.max_bytes = usize::MAX;
708        let path_matcher = options
709            .path_pattern
710            .as_deref()
711            .map(crate::session_path::GrepPathPattern::new)
712            .transpose()?;
713        let mut results = Vec::new();
714        for resolved in mounts {
715            let mut mount_options = backend_options.clone();
716            mount_options.path_pattern = Some(resolved.backend_path.clone());
717            let result = resolved
718                .backend
719                .grep_files_with_options(session_id, pattern, &mount_options)
720                .await?;
721            let mut mapped = resolved.map_grep_result(result);
722            if let Some(matcher) = &path_matcher {
723                mapped.matches.retain(|item| matcher.is_match(&item.path));
724                mapped.blocks.retain(|block| matcher.is_match(&block.path));
725            }
726            results.push(mapped);
727        }
728        Ok(crate::session_file::merge_grep_search_results(
729            results, options,
730        ))
731    }
732
733    async fn create_directory(&self, session_id: SessionId, path: &str) -> Result<FileInfo> {
734        let resolved = self.resolve(path)?;
735        Ok(resolved.map_file_info(
736            resolved
737                .backend
738                .create_directory(session_id, &resolved.backend_path)
739                .await?,
740        ))
741    }
742
743    async fn seed_initial_file(&self, session_id: SessionId, file: &InitialFile) -> Result<()> {
744        let resolved = self.resolve(&file.path)?;
745        let seeded = InitialFile {
746            path: resolved.backend_path,
747            content: file.content.clone(),
748            encoding: file.encoding.clone(),
749            is_readonly: file.is_readonly,
750        };
751        resolved
752            .backend
753            .seed_initial_file(session_id, &seeded)
754            .await
755    }
756}
757
758#[cfg(test)]
759mod tests {
760    use super::*;
761    use crate::session_path::GrepPathPattern;
762
763    fn sid() -> SessionId {
764        SessionId::from_seed(1)
765    }
766
767    // A minimal `/`-rooted in-memory backend for resolver tests (kept local to
768    // avoid a dependency on everruns-host).
769    #[derive(Default)]
770    struct FlatStore {
771        files: std::sync::Mutex<std::collections::HashMap<String, String>>,
772        /// When set, the store presents host-style paths like a real-disk
773        /// backend, so `DisplayPolicy::BackendNative` has a host identity to
774        /// delegate to. `None` keeps the trait-default `/workspace` alias.
775        host_root: Option<String>,
776    }
777
778    #[async_trait]
779    impl SessionFileSystem for FlatStore {
780        fn is_mount_resolver(&self) -> bool {
781            false
782        }
783
784        fn display_root(&self) -> String {
785            match &self.host_root {
786                Some(root) => root.clone(),
787                None => crate::session_path::WORKSPACE_PREFIX.to_string(),
788            }
789        }
790
791        fn display_path(&self, path: &str) -> String {
792            match &self.host_root {
793                Some(root) => {
794                    let normalized = normalize_virtual(path, "/");
795                    if normalized == "/" {
796                        root.clone()
797                    } else {
798                        format!("{root}{normalized}")
799                    }
800                }
801                None => crate::session_path::to_display_path(path),
802            }
803        }
804
805        async fn read_file(&self, sid: SessionId, path: &str) -> Result<Option<SessionFile>> {
806            let files = self.files.lock().unwrap();
807            Ok(files.get(path).map(|content| SessionFile {
808                id: uuid::Uuid::nil(),
809                session_id: sid.uuid(),
810                path: path.to_string(),
811                name: path.rsplit('/').next().unwrap_or("").to_string(),
812                content: Some(content.clone()),
813                encoding: "text".to_string(),
814                is_directory: false,
815                is_readonly: false,
816                size_bytes: content.len() as i64,
817                created_at: chrono::Utc::now(),
818                updated_at: chrono::Utc::now(),
819            }))
820        }
821        async fn write_file(
822            &self,
823            sid: SessionId,
824            path: &str,
825            content: &str,
826            encoding: &str,
827        ) -> Result<SessionFile> {
828            self.files
829                .lock()
830                .unwrap()
831                .insert(path.to_string(), content.to_string());
832            Ok(SessionFile {
833                id: uuid::Uuid::nil(),
834                session_id: sid.uuid(),
835                path: path.to_string(),
836                name: path.rsplit('/').next().unwrap_or("").to_string(),
837                content: Some(content.to_string()),
838                encoding: encoding.to_string(),
839                is_directory: false,
840                is_readonly: false,
841                size_bytes: content.len() as i64,
842                created_at: chrono::Utc::now(),
843                updated_at: chrono::Utc::now(),
844            })
845        }
846        async fn delete_file(&self, _: SessionId, path: &str, _: bool) -> Result<bool> {
847            Ok(self.files.lock().unwrap().remove(path).is_some())
848        }
849        async fn list_directory(&self, _: SessionId, _: &str) -> Result<Vec<FileInfo>> {
850            Ok(vec![])
851        }
852        async fn stat_file(&self, _: SessionId, path: &str) -> Result<Option<FileStat>> {
853            let files = self.files.lock().unwrap();
854            Ok(files.get(path).map(|content| FileStat {
855                path: path.to_string(),
856                name: path.rsplit('/').next().unwrap_or("").to_string(),
857                is_directory: false,
858                is_readonly: false,
859                size_bytes: content.len() as i64,
860                created_at: chrono::Utc::now(),
861                updated_at: chrono::Utc::now(),
862            }))
863        }
864        async fn grep_files(
865            &self,
866            _: SessionId,
867            pattern: &str,
868            path_pattern: Option<&str>,
869        ) -> Result<Vec<GrepMatch>> {
870            let path_pattern = path_pattern.map(GrepPathPattern::new).transpose()?;
871            let files = self.files.lock().unwrap();
872            let mut matches = Vec::new();
873            for (path, content) in files.iter() {
874                if let Some(filter) = &path_pattern
875                    && !filter.is_match(path)
876                {
877                    continue;
878                }
879                for (idx, line) in content.lines().enumerate() {
880                    if line.contains(pattern) {
881                        matches.push(GrepMatch {
882                            path: path.clone(),
883                            line_number: idx + 1,
884                            line: line.to_string(),
885                        });
886                    }
887                }
888            }
889            Ok(matches)
890        }
891        async fn create_directory(&self, sid: SessionId, path: &str) -> Result<FileInfo> {
892            Ok(FileInfo {
893                id: uuid::Uuid::nil(),
894                session_id: sid.uuid(),
895                name: path.rsplit('/').next().unwrap_or("").to_string(),
896                path: path.to_string(),
897                is_directory: true,
898                is_readonly: false,
899                size_bytes: 0,
900                created_at: chrono::Utc::now(),
901                updated_at: chrono::Utc::now(),
902            })
903        }
904    }
905
906    #[test]
907    fn normalize_resolves_relative_against_cwd() {
908        assert_eq!(
909            normalize_virtual("foo/bar", "/workspace"),
910            "/workspace/foo/bar"
911        );
912        assert_eq!(normalize_virtual("/foo", "/workspace"), "/foo");
913        assert_eq!(normalize_virtual("a/../b", "/workspace"), "/workspace/b");
914        assert_eq!(normalize_virtual("../../x", "/workspace"), "/x");
915        assert_eq!(normalize_virtual(".", "/workspace"), "/workspace");
916        assert_eq!(normalize_virtual("/", "/workspace"), "/");
917    }
918
919    #[tokio::test]
920    async fn primary_path_spellings_share_backend_keys_and_session() {
921        let backend: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
922        let fs = MountFs::new(backend.clone());
923        assert_eq!(fs.cwd(), "/workspace");
924        for (input, canonical, read_path) in [
925            ("/workspace/src/lib.rs", "/src/lib.rs", "/src/lib.rs"),
926            ("notes.md", "/notes.md", "/workspace/notes.md"),
927            (
928                "/outputs/call.stdout",
929                "/outputs/call.stdout",
930                "/workspace/outputs/call.stdout",
931            ),
932            ("./src/../other.txt", "/other.txt", "other.txt"),
933        ] {
934            let written = fs.write_file(sid(), input, input, "text").await.unwrap();
935            assert_eq!(written.path, canonical);
936            assert_eq!(written.session_id, sid().uuid());
937            let actual = fs.read_file(sid(), read_path).await.unwrap().unwrap();
938            assert_eq!(actual.path, canonical);
939            assert_eq!(actual.content.as_deref(), Some(input));
940            let stored = backend.read_file(sid(), canonical).await.unwrap().unwrap();
941            assert_eq!(stored.content.as_deref(), Some(input));
942        }
943    }
944
945    #[test]
946    fn default_display_hides_host_identity_and_preserves_workspace_alias() {
947        for host_root in [None, Some("/host/root".to_string())] {
948            let fs = MountFs::new(Arc::new(FlatStore {
949                host_root,
950                ..Default::default()
951            }));
952            assert_eq!(fs.display_root(), "/workspace");
953            assert_eq!(fs.display_path("/src/lib.rs"), "/workspace/src/lib.rs");
954            assert_eq!(fs.display_path("/"), "/workspace");
955            assert_eq!(fs.resolve_path("src/lib.rs"), "/workspace/src/lib.rs");
956            assert_eq!(
957                fs.resolve_path("/workspace/src/lib.rs"),
958                "/workspace/src/lib.rs"
959            );
960        }
961    }
962
963    #[tokio::test]
964    async fn backend_display_exposes_host_paths_while_routing_is_unchanged() {
965        // The local-embedder opt-in (yolop / #258): presentation delegates to the
966        // backend's real host path, but routing still treats /workspace as cwd and
967        // resolves to the same backend key.
968        let backend: Arc<dyn SessionFileSystem> = Arc::new(FlatStore {
969            host_root: Some("/host/root".to_string()),
970            ..Default::default()
971        });
972        let fs = MountFs::new(backend.clone()).with_backend_display();
973
974        assert_eq!(fs.display_root(), "/host/root");
975        assert_eq!(fs.display_path("/src/lib.rs"), "/host/root/src/lib.rs");
976        assert_eq!(fs.display_path("/"), "/host/root");
977        // Both the relative and the /workspace-addressed spellings present the
978        // same host path — routing is independent of presentation.
979        assert_eq!(fs.resolve_path("src/lib.rs"), "/host/root/src/lib.rs");
980        assert_eq!(
981            fs.resolve_path("/workspace/src/lib.rs"),
982            "/host/root/src/lib.rs"
983        );
984        fs.write_file(sid(), "src/lib.rs", "source", "text")
985            .await
986            .unwrap();
987        for input in ["src/lib.rs", "/workspace/src/lib.rs", "/src/lib.rs"] {
988            let file = fs.read_file(sid(), input).await.unwrap().unwrap();
989            assert_eq!(file.path, "/src/lib.rs");
990            assert_eq!(file.content.as_deref(), Some("source"));
991            assert_eq!(file.session_id, sid().uuid());
992        }
993        assert!(
994            backend
995                .read_file(sid(), "/host/root/src/lib.rs")
996                .await
997                .unwrap()
998                .is_none()
999        );
1000    }
1001
1002    #[tokio::test]
1003    async fn scoped_prompt_file_store_preserves_backend_native_policy() {
1004        // The regression guard for #258: when the embedder hands in a MountFs
1005        // that already opted into backend-native display, the shared prompt-store
1006        // wrapper must NOT bury it under a fresh `/workspace`-alias resolver.
1007        let backend: Arc<dyn SessionFileSystem> = Arc::new(FlatStore {
1008            host_root: Some("/host/root".to_string()),
1009            ..Default::default()
1010        });
1011        let embedder_store: Arc<dyn SessionFileSystem> =
1012            Arc::new(MountFs::new(backend).with_backend_display());
1013
1014        let prompt_store =
1015            scoped_prompt_file_store(embedder_store, crate::typed_id::WorkspaceId::from_seed(91));
1016
1017        // Host path survives all the way to what the system prompt would render.
1018        assert_eq!(prompt_store.display_root(), "/host/root");
1019        assert_eq!(
1020            prompt_store.display_path("/src/lib.rs"),
1021            "/host/root/src/lib.rs"
1022        );
1023        // Routing is unchanged: /workspace still resolves to the backend.
1024        assert_eq!(
1025            prompt_store.resolve_path("/workspace/src/lib.rs"),
1026            "/host/root/src/lib.rs"
1027        );
1028        let written = prompt_store
1029            .write_file(sid(), "pin.txt", "scoped", "text")
1030            .await
1031            .unwrap();
1032        assert_eq!(
1033            written.session_id,
1034            crate::typed_id::WorkspaceId::from_seed(91).uuid()
1035        );
1036        assert_ne!(written.session_id, sid().uuid());
1037        let read = prompt_store
1038            .read_file(SessionId::from_seed(999), "/workspace/pin.txt")
1039            .await
1040            .unwrap()
1041            .unwrap();
1042        assert_eq!(read.session_id, written.session_id);
1043        assert_eq!(read.content.as_deref(), Some("scoped"));
1044    }
1045
1046    #[tokio::test]
1047    async fn scoped_prompt_file_store_defaults_plain_backend_to_workspace_alias() {
1048        // A multi-tenant/server store is not a mount resolver, so the wrapper
1049        // still mounts it under the host-agnostic `/workspace` alias — host paths
1050        // must never leak into model-visible output (#2776, TM-FS).
1051        let backend: Arc<dyn SessionFileSystem> = Arc::new(FlatStore {
1052            host_root: Some("/host/root".to_string()),
1053            ..Default::default()
1054        });
1055
1056        let prompt_store =
1057            scoped_prompt_file_store(backend, crate::typed_id::WorkspaceId::from_seed(92));
1058
1059        assert_eq!(prompt_store.display_root(), "/workspace");
1060        assert_eq!(
1061            prompt_store.display_path("/src/lib.rs"),
1062            "/workspace/src/lib.rs"
1063        );
1064        assert_eq!(
1065            prompt_store.resolve_path("src/lib.rs"),
1066            "/workspace/src/lib.rs"
1067        );
1068        let written = prompt_store
1069            .write_file(sid(), "pin.txt", "scoped", "text")
1070            .await
1071            .unwrap();
1072        assert_eq!(
1073            written.session_id,
1074            crate::typed_id::WorkspaceId::from_seed(92).uuid()
1075        );
1076        assert_ne!(written.session_id, sid().uuid());
1077        let read = prompt_store
1078            .read_file(SessionId::from_seed(999), "/workspace/pin.txt")
1079            .await
1080            .unwrap()
1081            .unwrap();
1082        assert_eq!(read.session_id, written.session_id);
1083        assert_eq!(read.content.as_deref(), Some("scoped"));
1084    }
1085
1086    #[tokio::test]
1087    async fn display_preserves_literal_backend_workspace_segment() {
1088        let backend: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
1089        backend
1090            .write_file(sid(), "/workspace/collide.txt", "literal", "text")
1091            .await
1092            .unwrap();
1093        backend
1094            .write_file(sid(), "/collide.txt", "alias", "text")
1095            .await
1096            .unwrap();
1097        let fs = MountFs::new(backend);
1098
1099        let literal = fs
1100            .read_file(sid(), "workspace/collide.txt")
1101            .await
1102            .unwrap()
1103            .unwrap();
1104        let display_path = fs.display_path(&literal.path);
1105        assert_eq!(display_path, "/workspace/workspace/collide.txt");
1106
1107        let round_trip = fs.read_file(sid(), &display_path).await.unwrap().unwrap();
1108        assert_eq!(round_trip.content.as_deref(), Some("literal"));
1109    }
1110
1111    #[tokio::test]
1112    async fn additional_mount_selects_longest_segment_and_maps_backend_root() {
1113        let workspace: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
1114        let volume: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
1115        let nested: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
1116        let fs = MountFs::new(workspace.clone())
1117            .with_mount("/data/deep", nested.clone(), "/nested")
1118            .with_mount("//data/./", volume.clone(), "/prefix/./");
1119        for (input, owner, key) in [
1120            ("/data/report.csv", &volume, "/prefix/report.csv"),
1121            ("/data", &volume, "/prefix"),
1122            ("/data/deep/file.txt", &nested, "/nested/file.txt"),
1123            ("/data/deeper.txt", &volume, "/prefix/deeper.txt"),
1124            ("/database/file.txt", &workspace, "/database/file.txt"),
1125            ("/data/../primary.txt", &workspace, "/primary.txt"),
1126        ] {
1127            fs.write_file(sid(), input, input, "text").await.unwrap();
1128            let actual = owner.read_file(sid(), key).await.unwrap().unwrap();
1129            assert_eq!(actual.content.as_deref(), Some(input));
1130            assert_eq!(actual.session_id, sid().uuid());
1131            for other in [&workspace, &volume, &nested] {
1132                if !Arc::ptr_eq(other, owner) {
1133                    assert!(other.read_file(sid(), key).await.unwrap().is_none());
1134                }
1135            }
1136        }
1137    }
1138
1139    #[tokio::test]
1140    async fn additional_mount_outputs_use_virtual_paths() {
1141        let workspace: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
1142        let volume: Arc<dyn SessionFileSystem> = Arc::new(FlatStore {
1143            host_root: Some("/private/volume".into()),
1144            ..Default::default()
1145        });
1146        let fs = MountFs::new(workspace).with_backend_display().with_mount(
1147            "/workspace/roots/backend",
1148            volume.clone(),
1149            "/checkout",
1150        );
1151
1152        let written = fs
1153            .write_file(
1154                sid(),
1155                "/workspace/roots/backend/Cargo.toml",
1156                "name = \"backend\"",
1157                "text",
1158            )
1159            .await
1160            .unwrap();
1161        assert_eq!(written.path, "/workspace/roots/backend/Cargo.toml");
1162
1163        let stat = fs
1164            .stat_file(sid(), "/workspace/roots/backend/Cargo.toml")
1165            .await
1166            .unwrap()
1167            .unwrap();
1168        assert_eq!(stat.path, "/workspace/roots/backend/Cargo.toml");
1169        assert_eq!(
1170            fs.display_path(&stat.path),
1171            "/workspace/roots/backend/Cargo.toml"
1172        );
1173        assert_eq!(
1174            fs.resolve_path("/workspace/roots/backend/Cargo.toml"),
1175            "/workspace/roots/backend/Cargo.toml"
1176        );
1177        assert_eq!(written.name, "Cargo.toml");
1178        assert_eq!(stat.name, "Cargo.toml");
1179        let read = fs
1180            .read_file(sid(), "/workspace/roots/backend/Cargo.toml")
1181            .await
1182            .unwrap()
1183            .unwrap();
1184        assert_eq!(read.path, written.path);
1185        assert_eq!(read.content.as_deref(), Some("name = \"backend\""));
1186        let stored = volume
1187            .read_file(sid(), "/checkout/Cargo.toml")
1188            .await
1189            .unwrap()
1190            .unwrap();
1191        assert_eq!(stored.content, read.content);
1192        let directory = fs
1193            .create_directory(sid(), "/workspace/roots/backend/src")
1194            .await
1195            .unwrap();
1196        assert_eq!(directory.path, "/workspace/roots/backend/src");
1197        assert_eq!(directory.name, "src");
1198        assert!(directory.is_directory);
1199    }
1200
1201    #[tokio::test]
1202    async fn grep_without_path_searches_all_mounts() {
1203        let workspace: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
1204        let volume: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
1205        let fs = MountFs::new(workspace).with_mount("/workspace/roots/backend", volume, "/");
1206
1207        fs.write_file(sid(), "/workspace/README.md", "needle primary", "text")
1208            .await
1209            .unwrap();
1210        fs.write_file(
1211            sid(),
1212            "/workspace/roots/backend/Cargo.toml",
1213            "needle backend",
1214            "text",
1215        )
1216        .await
1217        .unwrap();
1218
1219        let matches = fs.grep_files(sid(), "needle", None).await.unwrap();
1220        let hits: Vec<_> = matches
1221            .iter()
1222            .map(|hit| (hit.path.as_str(), hit.line_number, hit.line.as_str()))
1223            .collect();
1224        assert_eq!(
1225            hits,
1226            [
1227                ("/README.md", 1, "needle primary"),
1228                ("/workspace/roots/backend/Cargo.toml", 1, "needle backend")
1229            ]
1230        );
1231    }
1232
1233    #[tokio::test]
1234    async fn grep_resolves_workspace_glob_to_backend_namespace() {
1235        let backend: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
1236        let fs = MountFs::new(backend);
1237        fs.write_file(sid(), "/workspace/src/lib.rs", "needle", "text")
1238            .await
1239            .unwrap();
1240        fs.write_file(sid(), "/workspace/docs/readme.md", "needle", "text")
1241            .await
1242            .unwrap();
1243
1244        let matches = fs
1245            .grep_files(sid(), "needle", Some("/workspace/src/**/*.rs"))
1246            .await
1247            .unwrap();
1248
1249        let hits: Vec<_> = matches
1250            .iter()
1251            .map(|hit| (hit.path.as_str(), hit.line_number, hit.line.as_str()))
1252            .collect();
1253        assert_eq!(hits, [("/src/lib.rs", 1, "needle")]);
1254    }
1255
1256    #[tokio::test]
1257    async fn grep_glob_searches_every_matching_mount() {
1258        let workspace: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
1259        let volume: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
1260        let fs = MountFs::new(workspace).with_mount("/workspace/roots/backend", volume, "/");
1261        fs.write_file(sid(), "/workspace/Cargo.toml", "needle", "text")
1262            .await
1263            .unwrap();
1264        fs.write_file(
1265            sid(),
1266            "/workspace/roots/backend/Cargo.toml",
1267            "needle",
1268            "text",
1269        )
1270        .await
1271        .unwrap();
1272
1273        fs.write_file(
1274            sid(),
1275            "/workspace/roots/backend/decoy.md",
1276            "needle decoy",
1277            "text",
1278        )
1279        .await
1280        .unwrap();
1281        let matches = fs
1282            .grep_files(sid(), "needle", Some("**/*.toml"))
1283            .await
1284            .unwrap();
1285        let hits: Vec<_> = matches
1286            .iter()
1287            .map(|hit| (hit.path.as_str(), hit.line_number, hit.line.as_str()))
1288            .collect();
1289        assert_eq!(
1290            hits,
1291            [
1292                ("/Cargo.toml", 1, "needle"),
1293                ("/workspace/roots/backend/Cargo.toml", 1, "needle")
1294            ]
1295        );
1296    }
1297
1298    #[test]
1299    fn mount_fs_identifies_as_resolver() {
1300        let workspace: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
1301        let fs = MountFs::wrap(workspace);
1302        assert!(fs.is_mount_resolver());
1303        let again = MountFs::wrap_if_needed(fs.clone());
1304        assert!(Arc::ptr_eq(&fs, &again));
1305    }
1306
1307    #[tokio::test]
1308    async fn grep_options_preserve_workspace_prefix_lookalike_paths() {
1309        let backend: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
1310        backend
1311            .write_file(sid(), "/workspacefoo/target.txt", "needle target", "text")
1312            .await
1313            .unwrap();
1314        backend
1315            .write_file(sid(), "/foo/decoy.txt", "needle decoy", "text")
1316            .await
1317            .unwrap();
1318        let fs = MountFs::new(backend);
1319        for (path, expected) in [
1320            ("/workspacefoo", "/workspacefoo/target.txt"),
1321            ("/workspacefoo/*.txt", "/workspacefoo/target.txt"),
1322            ("/workspace/foo/*.txt", "/foo/decoy.txt"),
1323        ] {
1324            let result = fs
1325                .grep_files_with_options(
1326                    sid(),
1327                    "needle",
1328                    &GrepOptions {
1329                        path_pattern: Some(path.into()),
1330                        ..Default::default()
1331                    },
1332                )
1333                .await
1334                .unwrap();
1335            let paths: Vec<_> = result.matches.iter().map(|hit| hit.path.as_str()).collect();
1336            assert_eq!(paths, [expected], "filter {path}");
1337            let flat = fs.grep_files(sid(), "needle", Some(path)).await.unwrap();
1338            assert_eq!(
1339                flat.iter().map(|hit| hit.path.as_str()).collect::<Vec<_>>(),
1340                [expected],
1341                "flat filter {path}"
1342            );
1343        }
1344    }
1345}