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 host_path(&self, path: &str) -> Option<std::path::PathBuf> {
489        // Route first: a named mount may be a host directory while the primary
490        // is not, or the other way round, so the answer belongs to whichever
491        // backend actually owns the path.
492        let virtual_path = normalize_virtual(path, &self.cwd());
493        let resolved = self.resolve(&virtual_path).ok()?;
494        resolved.backend.host_path(&resolved.backend_path)
495    }
496
497    fn resolve_path(&self, input: &str) -> String {
498        // Resolve the raw input through the mount table, then present the
499        // resolved backend key per the display policy. Presenting the resolved
500        // backend key (instead of re-stripping the mount) keeps a literal
501        // `workspace/…` backend segment distinct from the `/workspace` mount
502        // alias, so a displayed path round-trips to the same backend key.
503        // Presentation itself (alias vs. backend-native host path) is decided by
504        // [`present_primary_key`] — kept out of routing on purpose (#2776/#258).
505        let virtual_path = normalize_virtual(input, &self.cwd());
506        match self.resolve(&virtual_path) {
507            Ok(resolved) if resolved.primary_workspace => {
508                self.present_primary_key(&resolved.backend_path)
509            }
510            _ => virtual_path,
511        }
512    }
513
514    fn display_path(&self, path: &str) -> String {
515        // `path` here is an already-canonical virtual output path (a resolved
516        // `file.path`, i.e. a backend key in the primary namespace, or a named
517        // mount's virtual path). Normalize at root — NOT cwd — so we treat it as
518        // a canonical key and don't inject the `/workspace` cwd alias. Then:
519        //  - named mounts: return as-is (already in their mounted namespace),
520        //  - primary: present via the display policy so a literal `workspace/…`
521        //    backend segment stays distinct from the mount alias and round-trips
522        //    (alias mode), or renders as the backend's host path (native mode).
523        let virtual_path = normalize_virtual(path, "/");
524        match self.resolve(&virtual_path) {
525            Ok(resolved) if !resolved.primary_workspace => virtual_path,
526            _ => self.present_primary_key(&virtual_path),
527        }
528    }
529
530    async fn read_file(&self, session_id: SessionId, path: &str) -> Result<Option<SessionFile>> {
531        let resolved = self.resolve(path)?;
532        Ok(resolved
533            .backend
534            .read_file(session_id, &resolved.backend_path)
535            .await?
536            .map(|file| resolved.map_session_file(file)))
537    }
538
539    async fn write_file(
540        &self,
541        session_id: SessionId,
542        path: &str,
543        content: &str,
544        encoding: &str,
545    ) -> Result<SessionFile> {
546        let resolved = self.resolve(path)?;
547        Ok(resolved.map_session_file(
548            resolved
549                .backend
550                .write_file(session_id, &resolved.backend_path, content, encoding)
551                .await?,
552        ))
553    }
554
555    async fn write_file_if_content_matches(
556        &self,
557        session_id: SessionId,
558        path: &str,
559        expected_content: &str,
560        expected_encoding: &str,
561        content: &str,
562        encoding: &str,
563    ) -> Result<Option<SessionFile>> {
564        let resolved = self.resolve(path)?;
565        Ok(resolved
566            .backend
567            .write_file_if_content_matches(
568                session_id,
569                &resolved.backend_path,
570                expected_content,
571                expected_encoding,
572                content,
573                encoding,
574            )
575            .await?
576            .map(|file| resolved.map_session_file(file)))
577    }
578
579    async fn delete_file(
580        &self,
581        session_id: SessionId,
582        path: &str,
583        recursive: bool,
584    ) -> Result<bool> {
585        let resolved = self.resolve(path)?;
586        resolved
587            .backend
588            .delete_file(session_id, &resolved.backend_path, recursive)
589            .await
590    }
591
592    async fn list_directory(&self, session_id: SessionId, path: &str) -> Result<Vec<FileInfo>> {
593        let resolved = self.resolve(path)?;
594        Ok(resolved
595            .backend
596            .list_directory(session_id, &resolved.backend_path)
597            .await?
598            .into_iter()
599            .map(|info| resolved.map_file_info(info))
600            .collect())
601    }
602
603    async fn stat_file(&self, session_id: SessionId, path: &str) -> Result<Option<FileStat>> {
604        let resolved = self.resolve(path)?;
605        Ok(resolved
606            .backend
607            .stat_file(session_id, &resolved.backend_path)
608            .await?
609            .map(|stat| resolved.map_file_stat(stat)))
610    }
611
612    async fn grep_files(
613        &self,
614        session_id: SessionId,
615        pattern: &str,
616        path_pattern: Option<&str>,
617    ) -> Result<Vec<GrepMatch>> {
618        match path_pattern {
619            Some(pp) => {
620                let matcher = crate::session_path::GrepPathPattern::new(pp)?;
621                if matcher.is_glob()
622                    && (!pp.starts_with('/') || mount_suffix(WORKSPACE_MOUNT, pp).is_some())
623                {
624                    let mut matches = Vec::new();
625                    for resolved in self.grep_mounts() {
626                        matches.extend(
627                            resolved
628                                .backend
629                                .grep_files(session_id, pattern, Some(&resolved.backend_path))
630                                .await?
631                                .into_iter()
632                                .map(|grep_match| resolved.map_grep_match(grep_match))
633                                .filter(|grep_match| matcher.is_match(&grep_match.path)),
634                        );
635                    }
636                    matches.sort_by(|a, b| {
637                        a.path
638                            .cmp(&b.path)
639                            .then(a.line_number.cmp(&b.line_number))
640                            .then(a.line.cmp(&b.line))
641                    });
642                    return Ok(matches);
643                }
644                let resolved = self.resolve(pp)?;
645                Ok(resolved
646                    .backend
647                    .grep_files(session_id, pattern, Some(&resolved.backend_path))
648                    .await?
649                    .into_iter()
650                    .map(|grep_match| resolved.map_grep_match(grep_match))
651                    .collect())
652            }
653            None => {
654                let mut matches = Vec::new();
655                for resolved in self.grep_mounts() {
656                    matches.extend(
657                        resolved
658                            .backend
659                            .grep_files(session_id, pattern, Some(&resolved.backend_path))
660                            .await?
661                            .into_iter()
662                            .map(|grep_match| resolved.map_grep_match(grep_match)),
663                    );
664                }
665                matches.sort_by(|a, b| {
666                    a.path
667                        .cmp(&b.path)
668                        .then(a.line_number.cmp(&b.line_number))
669                        .then(a.line.cmp(&b.line))
670                });
671                Ok(matches)
672            }
673        }
674    }
675
676    async fn grep_files_with_options(
677        &self,
678        session_id: SessionId,
679        pattern: &str,
680        options: &GrepOptions,
681    ) -> Result<GrepSearchResult> {
682        if let Some(path_pattern) = options.path_pattern.as_deref()
683            && path_pattern.starts_with('/')
684            && mount_suffix(WORKSPACE_MOUNT, path_pattern).is_none()
685        {
686            let resolved = self.resolve(path_pattern)?;
687            let mut backend_options = options.clone();
688            backend_options.path_pattern = Some(resolved.backend_path.clone());
689            return resolved
690                .backend
691                .grep_files_with_options(session_id, pattern, &backend_options)
692                .await
693                .map(|result| resolved.map_grep_result(result));
694        }
695
696        let mounts = self.grep_mounts();
697        if mounts.len() == 1 {
698            let resolved = &mounts[0];
699            let mut backend_options = options.clone();
700            // Only a complete mount segment is an alias; /workspacefoo is a
701            // distinct backend path and must not broaden the search to foo.
702            backend_options.path_pattern = options
703                .path_pattern
704                .as_ref()
705                .map(|path| mount_suffix(WORKSPACE_MOUNT, path).unwrap_or_else(|| path.clone()));
706            return resolved
707                .backend
708                .grep_files_with_options(session_id, pattern, &backend_options)
709                .await
710                .map(|result| resolved.map_grep_result(result));
711        }
712
713        let mut backend_options = options.clone();
714        backend_options.offset = 0;
715        backend_options.limit = usize::MAX;
716        backend_options.max_bytes = usize::MAX;
717        let path_matcher = options
718            .path_pattern
719            .as_deref()
720            .map(crate::session_path::GrepPathPattern::new)
721            .transpose()?;
722        let mut results = Vec::new();
723        for resolved in mounts {
724            let mut mount_options = backend_options.clone();
725            mount_options.path_pattern = Some(resolved.backend_path.clone());
726            let result = resolved
727                .backend
728                .grep_files_with_options(session_id, pattern, &mount_options)
729                .await?;
730            let mut mapped = resolved.map_grep_result(result);
731            if let Some(matcher) = &path_matcher {
732                mapped.matches.retain(|item| matcher.is_match(&item.path));
733                mapped.blocks.retain(|block| matcher.is_match(&block.path));
734            }
735            results.push(mapped);
736        }
737        Ok(crate::session_file::merge_grep_search_results(
738            results, options,
739        ))
740    }
741
742    async fn create_directory(&self, session_id: SessionId, path: &str) -> Result<FileInfo> {
743        let resolved = self.resolve(path)?;
744        Ok(resolved.map_file_info(
745            resolved
746                .backend
747                .create_directory(session_id, &resolved.backend_path)
748                .await?,
749        ))
750    }
751
752    async fn seed_initial_file(&self, session_id: SessionId, file: &InitialFile) -> Result<()> {
753        let resolved = self.resolve(&file.path)?;
754        let seeded = InitialFile {
755            path: resolved.backend_path,
756            content: file.content.clone(),
757            encoding: file.encoding.clone(),
758            is_readonly: file.is_readonly,
759        };
760        resolved
761            .backend
762            .seed_initial_file(session_id, &seeded)
763            .await
764    }
765}
766
767#[cfg(test)]
768mod tests {
769    use super::*;
770    use crate::session_path::GrepPathPattern;
771
772    fn sid() -> SessionId {
773        SessionId::from_seed(1)
774    }
775
776    // A minimal `/`-rooted in-memory backend for resolver tests (kept local to
777    // avoid a dependency on everruns-host).
778    #[derive(Default)]
779    struct FlatStore {
780        files: std::sync::Mutex<std::collections::HashMap<String, String>>,
781        /// When set, the store presents host-style paths like a real-disk
782        /// backend, so `DisplayPolicy::BackendNative` has a host identity to
783        /// delegate to. `None` keeps the trait-default `/workspace` alias.
784        host_root: Option<String>,
785    }
786
787    #[async_trait]
788    impl SessionFileSystem for FlatStore {
789        fn is_mount_resolver(&self) -> bool {
790            false
791        }
792
793        fn display_root(&self) -> String {
794            match &self.host_root {
795                Some(root) => root.clone(),
796                None => crate::session_path::WORKSPACE_PREFIX.to_string(),
797            }
798        }
799
800        fn display_path(&self, path: &str) -> String {
801            match &self.host_root {
802                Some(root) => {
803                    let normalized = normalize_virtual(path, "/");
804                    if normalized == "/" {
805                        root.clone()
806                    } else {
807                        format!("{root}{normalized}")
808                    }
809                }
810                None => crate::session_path::to_display_path(path),
811            }
812        }
813
814        async fn read_file(&self, sid: SessionId, path: &str) -> Result<Option<SessionFile>> {
815            let files = self.files.lock().unwrap();
816            Ok(files.get(path).map(|content| SessionFile {
817                id: uuid::Uuid::nil(),
818                session_id: sid.uuid(),
819                path: path.to_string(),
820                name: path.rsplit('/').next().unwrap_or("").to_string(),
821                content: Some(content.clone()),
822                encoding: "text".to_string(),
823                is_directory: false,
824                is_readonly: false,
825                size_bytes: content.len() as i64,
826                created_at: chrono::Utc::now(),
827                updated_at: chrono::Utc::now(),
828            }))
829        }
830        async fn write_file(
831            &self,
832            sid: SessionId,
833            path: &str,
834            content: &str,
835            encoding: &str,
836        ) -> Result<SessionFile> {
837            self.files
838                .lock()
839                .unwrap()
840                .insert(path.to_string(), content.to_string());
841            Ok(SessionFile {
842                id: uuid::Uuid::nil(),
843                session_id: sid.uuid(),
844                path: path.to_string(),
845                name: path.rsplit('/').next().unwrap_or("").to_string(),
846                content: Some(content.to_string()),
847                encoding: encoding.to_string(),
848                is_directory: false,
849                is_readonly: false,
850                size_bytes: content.len() as i64,
851                created_at: chrono::Utc::now(),
852                updated_at: chrono::Utc::now(),
853            })
854        }
855        async fn delete_file(&self, _: SessionId, path: &str, _: bool) -> Result<bool> {
856            Ok(self.files.lock().unwrap().remove(path).is_some())
857        }
858        async fn list_directory(&self, _: SessionId, _: &str) -> Result<Vec<FileInfo>> {
859            Ok(vec![])
860        }
861        async fn stat_file(&self, _: SessionId, path: &str) -> Result<Option<FileStat>> {
862            let files = self.files.lock().unwrap();
863            Ok(files.get(path).map(|content| FileStat {
864                path: path.to_string(),
865                name: path.rsplit('/').next().unwrap_or("").to_string(),
866                is_directory: false,
867                is_readonly: false,
868                size_bytes: content.len() as i64,
869                created_at: chrono::Utc::now(),
870                updated_at: chrono::Utc::now(),
871            }))
872        }
873        async fn grep_files(
874            &self,
875            _: SessionId,
876            pattern: &str,
877            path_pattern: Option<&str>,
878        ) -> Result<Vec<GrepMatch>> {
879            let path_pattern = path_pattern.map(GrepPathPattern::new).transpose()?;
880            let files = self.files.lock().unwrap();
881            let mut matches = Vec::new();
882            for (path, content) in files.iter() {
883                if let Some(filter) = &path_pattern
884                    && !filter.is_match(path)
885                {
886                    continue;
887                }
888                for (idx, line) in content.lines().enumerate() {
889                    if line.contains(pattern) {
890                        matches.push(GrepMatch {
891                            path: path.clone(),
892                            line_number: idx + 1,
893                            line: line.to_string(),
894                        });
895                    }
896                }
897            }
898            Ok(matches)
899        }
900        async fn create_directory(&self, sid: SessionId, path: &str) -> Result<FileInfo> {
901            Ok(FileInfo {
902                id: uuid::Uuid::nil(),
903                session_id: sid.uuid(),
904                name: path.rsplit('/').next().unwrap_or("").to_string(),
905                path: path.to_string(),
906                is_directory: true,
907                is_readonly: false,
908                size_bytes: 0,
909                created_at: chrono::Utc::now(),
910                updated_at: chrono::Utc::now(),
911            })
912        }
913    }
914
915    #[test]
916    fn normalize_resolves_relative_against_cwd() {
917        assert_eq!(
918            normalize_virtual("foo/bar", "/workspace"),
919            "/workspace/foo/bar"
920        );
921        assert_eq!(normalize_virtual("/foo", "/workspace"), "/foo");
922        assert_eq!(normalize_virtual("a/../b", "/workspace"), "/workspace/b");
923        assert_eq!(normalize_virtual("../../x", "/workspace"), "/x");
924        assert_eq!(normalize_virtual(".", "/workspace"), "/workspace");
925        assert_eq!(normalize_virtual("/", "/workspace"), "/");
926    }
927
928    #[tokio::test]
929    async fn primary_path_spellings_share_backend_keys_and_session() {
930        let backend: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
931        let fs = MountFs::new(backend.clone());
932        assert_eq!(fs.cwd(), "/workspace");
933        for (input, canonical, read_path) in [
934            ("/workspace/src/lib.rs", "/src/lib.rs", "/src/lib.rs"),
935            ("notes.md", "/notes.md", "/workspace/notes.md"),
936            (
937                "/outputs/call.stdout",
938                "/outputs/call.stdout",
939                "/workspace/outputs/call.stdout",
940            ),
941            ("./src/../other.txt", "/other.txt", "other.txt"),
942        ] {
943            let written = fs.write_file(sid(), input, input, "text").await.unwrap();
944            assert_eq!(written.path, canonical);
945            assert_eq!(written.session_id, sid().uuid());
946            let actual = fs.read_file(sid(), read_path).await.unwrap().unwrap();
947            assert_eq!(actual.path, canonical);
948            assert_eq!(actual.content.as_deref(), Some(input));
949            let stored = backend.read_file(sid(), canonical).await.unwrap().unwrap();
950            assert_eq!(stored.content.as_deref(), Some(input));
951        }
952    }
953
954    #[test]
955    fn default_display_hides_host_identity_and_preserves_workspace_alias() {
956        for host_root in [None, Some("/host/root".to_string())] {
957            let fs = MountFs::new(Arc::new(FlatStore {
958                host_root,
959                ..Default::default()
960            }));
961            assert_eq!(fs.display_root(), "/workspace");
962            assert_eq!(fs.display_path("/src/lib.rs"), "/workspace/src/lib.rs");
963            assert_eq!(fs.display_path("/"), "/workspace");
964            assert_eq!(fs.resolve_path("src/lib.rs"), "/workspace/src/lib.rs");
965            assert_eq!(
966                fs.resolve_path("/workspace/src/lib.rs"),
967                "/workspace/src/lib.rs"
968            );
969        }
970    }
971
972    #[tokio::test]
973    async fn backend_display_exposes_host_paths_while_routing_is_unchanged() {
974        // The local-embedder opt-in (yolop / #258): presentation delegates to the
975        // backend's real host path, but routing still treats /workspace as cwd and
976        // resolves to the same backend key.
977        let backend: Arc<dyn SessionFileSystem> = Arc::new(FlatStore {
978            host_root: Some("/host/root".to_string()),
979            ..Default::default()
980        });
981        let fs = MountFs::new(backend.clone()).with_backend_display();
982
983        assert_eq!(fs.display_root(), "/host/root");
984        assert_eq!(fs.display_path("/src/lib.rs"), "/host/root/src/lib.rs");
985        assert_eq!(fs.display_path("/"), "/host/root");
986        // Both the relative and the /workspace-addressed spellings present the
987        // same host path — routing is independent of presentation.
988        assert_eq!(fs.resolve_path("src/lib.rs"), "/host/root/src/lib.rs");
989        assert_eq!(
990            fs.resolve_path("/workspace/src/lib.rs"),
991            "/host/root/src/lib.rs"
992        );
993        fs.write_file(sid(), "src/lib.rs", "source", "text")
994            .await
995            .unwrap();
996        for input in ["src/lib.rs", "/workspace/src/lib.rs", "/src/lib.rs"] {
997            let file = fs.read_file(sid(), input).await.unwrap().unwrap();
998            assert_eq!(file.path, "/src/lib.rs");
999            assert_eq!(file.content.as_deref(), Some("source"));
1000            assert_eq!(file.session_id, sid().uuid());
1001        }
1002        assert!(
1003            backend
1004                .read_file(sid(), "/host/root/src/lib.rs")
1005                .await
1006                .unwrap()
1007                .is_none()
1008        );
1009    }
1010
1011    #[tokio::test]
1012    async fn scoped_prompt_file_store_preserves_backend_native_policy() {
1013        // The regression guard for #258: when the embedder hands in a MountFs
1014        // that already opted into backend-native display, the shared prompt-store
1015        // wrapper must NOT bury it under a fresh `/workspace`-alias resolver.
1016        let backend: Arc<dyn SessionFileSystem> = Arc::new(FlatStore {
1017            host_root: Some("/host/root".to_string()),
1018            ..Default::default()
1019        });
1020        let embedder_store: Arc<dyn SessionFileSystem> =
1021            Arc::new(MountFs::new(backend).with_backend_display());
1022
1023        let prompt_store =
1024            scoped_prompt_file_store(embedder_store, crate::typed_id::WorkspaceId::from_seed(91));
1025
1026        // Host path survives all the way to what the system prompt would render.
1027        assert_eq!(prompt_store.display_root(), "/host/root");
1028        assert_eq!(
1029            prompt_store.display_path("/src/lib.rs"),
1030            "/host/root/src/lib.rs"
1031        );
1032        // Routing is unchanged: /workspace still resolves to the backend.
1033        assert_eq!(
1034            prompt_store.resolve_path("/workspace/src/lib.rs"),
1035            "/host/root/src/lib.rs"
1036        );
1037        let written = prompt_store
1038            .write_file(sid(), "pin.txt", "scoped", "text")
1039            .await
1040            .unwrap();
1041        assert_eq!(
1042            written.session_id,
1043            crate::typed_id::WorkspaceId::from_seed(91).uuid()
1044        );
1045        assert_ne!(written.session_id, sid().uuid());
1046        let read = prompt_store
1047            .read_file(SessionId::from_seed(999), "/workspace/pin.txt")
1048            .await
1049            .unwrap()
1050            .unwrap();
1051        assert_eq!(read.session_id, written.session_id);
1052        assert_eq!(read.content.as_deref(), Some("scoped"));
1053    }
1054
1055    #[tokio::test]
1056    async fn scoped_prompt_file_store_defaults_plain_backend_to_workspace_alias() {
1057        // A multi-tenant/server store is not a mount resolver, so the wrapper
1058        // still mounts it under the host-agnostic `/workspace` alias — host paths
1059        // must never leak into model-visible output (#2776, TM-FS).
1060        let backend: Arc<dyn SessionFileSystem> = Arc::new(FlatStore {
1061            host_root: Some("/host/root".to_string()),
1062            ..Default::default()
1063        });
1064
1065        let prompt_store =
1066            scoped_prompt_file_store(backend, crate::typed_id::WorkspaceId::from_seed(92));
1067
1068        assert_eq!(prompt_store.display_root(), "/workspace");
1069        assert_eq!(
1070            prompt_store.display_path("/src/lib.rs"),
1071            "/workspace/src/lib.rs"
1072        );
1073        assert_eq!(
1074            prompt_store.resolve_path("src/lib.rs"),
1075            "/workspace/src/lib.rs"
1076        );
1077        let written = prompt_store
1078            .write_file(sid(), "pin.txt", "scoped", "text")
1079            .await
1080            .unwrap();
1081        assert_eq!(
1082            written.session_id,
1083            crate::typed_id::WorkspaceId::from_seed(92).uuid()
1084        );
1085        assert_ne!(written.session_id, sid().uuid());
1086        let read = prompt_store
1087            .read_file(SessionId::from_seed(999), "/workspace/pin.txt")
1088            .await
1089            .unwrap()
1090            .unwrap();
1091        assert_eq!(read.session_id, written.session_id);
1092        assert_eq!(read.content.as_deref(), Some("scoped"));
1093    }
1094
1095    #[tokio::test]
1096    async fn display_preserves_literal_backend_workspace_segment() {
1097        let backend: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
1098        backend
1099            .write_file(sid(), "/workspace/collide.txt", "literal", "text")
1100            .await
1101            .unwrap();
1102        backend
1103            .write_file(sid(), "/collide.txt", "alias", "text")
1104            .await
1105            .unwrap();
1106        let fs = MountFs::new(backend);
1107
1108        let literal = fs
1109            .read_file(sid(), "workspace/collide.txt")
1110            .await
1111            .unwrap()
1112            .unwrap();
1113        let display_path = fs.display_path(&literal.path);
1114        assert_eq!(display_path, "/workspace/workspace/collide.txt");
1115
1116        let round_trip = fs.read_file(sid(), &display_path).await.unwrap().unwrap();
1117        assert_eq!(round_trip.content.as_deref(), Some("literal"));
1118    }
1119
1120    #[tokio::test]
1121    async fn additional_mount_selects_longest_segment_and_maps_backend_root() {
1122        let workspace: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
1123        let volume: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
1124        let nested: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
1125        let fs = MountFs::new(workspace.clone())
1126            .with_mount("/data/deep", nested.clone(), "/nested")
1127            .with_mount("//data/./", volume.clone(), "/prefix/./");
1128        for (input, owner, key) in [
1129            ("/data/report.csv", &volume, "/prefix/report.csv"),
1130            ("/data", &volume, "/prefix"),
1131            ("/data/deep/file.txt", &nested, "/nested/file.txt"),
1132            ("/data/deeper.txt", &volume, "/prefix/deeper.txt"),
1133            ("/database/file.txt", &workspace, "/database/file.txt"),
1134            ("/data/../primary.txt", &workspace, "/primary.txt"),
1135        ] {
1136            fs.write_file(sid(), input, input, "text").await.unwrap();
1137            let actual = owner.read_file(sid(), key).await.unwrap().unwrap();
1138            assert_eq!(actual.content.as_deref(), Some(input));
1139            assert_eq!(actual.session_id, sid().uuid());
1140            for other in [&workspace, &volume, &nested] {
1141                if !Arc::ptr_eq(other, owner) {
1142                    assert!(other.read_file(sid(), key).await.unwrap().is_none());
1143                }
1144            }
1145        }
1146    }
1147
1148    #[tokio::test]
1149    async fn additional_mount_outputs_use_virtual_paths() {
1150        let workspace: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
1151        let volume: Arc<dyn SessionFileSystem> = Arc::new(FlatStore {
1152            host_root: Some("/private/volume".into()),
1153            ..Default::default()
1154        });
1155        let fs = MountFs::new(workspace).with_backend_display().with_mount(
1156            "/workspace/roots/backend",
1157            volume.clone(),
1158            "/checkout",
1159        );
1160
1161        let written = fs
1162            .write_file(
1163                sid(),
1164                "/workspace/roots/backend/Cargo.toml",
1165                "name = \"backend\"",
1166                "text",
1167            )
1168            .await
1169            .unwrap();
1170        assert_eq!(written.path, "/workspace/roots/backend/Cargo.toml");
1171
1172        let stat = fs
1173            .stat_file(sid(), "/workspace/roots/backend/Cargo.toml")
1174            .await
1175            .unwrap()
1176            .unwrap();
1177        assert_eq!(stat.path, "/workspace/roots/backend/Cargo.toml");
1178        assert_eq!(
1179            fs.display_path(&stat.path),
1180            "/workspace/roots/backend/Cargo.toml"
1181        );
1182        assert_eq!(
1183            fs.resolve_path("/workspace/roots/backend/Cargo.toml"),
1184            "/workspace/roots/backend/Cargo.toml"
1185        );
1186        assert_eq!(written.name, "Cargo.toml");
1187        assert_eq!(stat.name, "Cargo.toml");
1188        let read = fs
1189            .read_file(sid(), "/workspace/roots/backend/Cargo.toml")
1190            .await
1191            .unwrap()
1192            .unwrap();
1193        assert_eq!(read.path, written.path);
1194        assert_eq!(read.content.as_deref(), Some("name = \"backend\""));
1195        let stored = volume
1196            .read_file(sid(), "/checkout/Cargo.toml")
1197            .await
1198            .unwrap()
1199            .unwrap();
1200        assert_eq!(stored.content, read.content);
1201        let directory = fs
1202            .create_directory(sid(), "/workspace/roots/backend/src")
1203            .await
1204            .unwrap();
1205        assert_eq!(directory.path, "/workspace/roots/backend/src");
1206        assert_eq!(directory.name, "src");
1207        assert!(directory.is_directory);
1208    }
1209
1210    #[tokio::test]
1211    async fn grep_without_path_searches_all_mounts() {
1212        let workspace: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
1213        let volume: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
1214        let fs = MountFs::new(workspace).with_mount("/workspace/roots/backend", volume, "/");
1215
1216        fs.write_file(sid(), "/workspace/README.md", "needle primary", "text")
1217            .await
1218            .unwrap();
1219        fs.write_file(
1220            sid(),
1221            "/workspace/roots/backend/Cargo.toml",
1222            "needle backend",
1223            "text",
1224        )
1225        .await
1226        .unwrap();
1227
1228        let matches = fs.grep_files(sid(), "needle", None).await.unwrap();
1229        let hits: Vec<_> = matches
1230            .iter()
1231            .map(|hit| (hit.path.as_str(), hit.line_number, hit.line.as_str()))
1232            .collect();
1233        assert_eq!(
1234            hits,
1235            [
1236                ("/README.md", 1, "needle primary"),
1237                ("/workspace/roots/backend/Cargo.toml", 1, "needle backend")
1238            ]
1239        );
1240    }
1241
1242    #[tokio::test]
1243    async fn grep_resolves_workspace_glob_to_backend_namespace() {
1244        let backend: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
1245        let fs = MountFs::new(backend);
1246        fs.write_file(sid(), "/workspace/src/lib.rs", "needle", "text")
1247            .await
1248            .unwrap();
1249        fs.write_file(sid(), "/workspace/docs/readme.md", "needle", "text")
1250            .await
1251            .unwrap();
1252
1253        let matches = fs
1254            .grep_files(sid(), "needle", Some("/workspace/src/**/*.rs"))
1255            .await
1256            .unwrap();
1257
1258        let hits: Vec<_> = matches
1259            .iter()
1260            .map(|hit| (hit.path.as_str(), hit.line_number, hit.line.as_str()))
1261            .collect();
1262        assert_eq!(hits, [("/src/lib.rs", 1, "needle")]);
1263    }
1264
1265    #[tokio::test]
1266    async fn grep_glob_searches_every_matching_mount() {
1267        let workspace: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
1268        let volume: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
1269        let fs = MountFs::new(workspace).with_mount("/workspace/roots/backend", volume, "/");
1270        fs.write_file(sid(), "/workspace/Cargo.toml", "needle", "text")
1271            .await
1272            .unwrap();
1273        fs.write_file(
1274            sid(),
1275            "/workspace/roots/backend/Cargo.toml",
1276            "needle",
1277            "text",
1278        )
1279        .await
1280        .unwrap();
1281
1282        fs.write_file(
1283            sid(),
1284            "/workspace/roots/backend/decoy.md",
1285            "needle decoy",
1286            "text",
1287        )
1288        .await
1289        .unwrap();
1290        let matches = fs
1291            .grep_files(sid(), "needle", Some("**/*.toml"))
1292            .await
1293            .unwrap();
1294        let hits: Vec<_> = matches
1295            .iter()
1296            .map(|hit| (hit.path.as_str(), hit.line_number, hit.line.as_str()))
1297            .collect();
1298        assert_eq!(
1299            hits,
1300            [
1301                ("/Cargo.toml", 1, "needle"),
1302                ("/workspace/roots/backend/Cargo.toml", 1, "needle")
1303            ]
1304        );
1305    }
1306
1307    #[test]
1308    fn mount_fs_identifies_as_resolver() {
1309        let workspace: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
1310        let fs = MountFs::wrap(workspace);
1311        assert!(fs.is_mount_resolver());
1312        let again = MountFs::wrap_if_needed(fs.clone());
1313        assert!(Arc::ptr_eq(&fs, &again));
1314    }
1315
1316    #[tokio::test]
1317    async fn grep_options_preserve_workspace_prefix_lookalike_paths() {
1318        let backend: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
1319        backend
1320            .write_file(sid(), "/workspacefoo/target.txt", "needle target", "text")
1321            .await
1322            .unwrap();
1323        backend
1324            .write_file(sid(), "/foo/decoy.txt", "needle decoy", "text")
1325            .await
1326            .unwrap();
1327        let fs = MountFs::new(backend);
1328        for (path, expected) in [
1329            ("/workspacefoo", "/workspacefoo/target.txt"),
1330            ("/workspacefoo/*.txt", "/workspacefoo/target.txt"),
1331            ("/workspace/foo/*.txt", "/foo/decoy.txt"),
1332        ] {
1333            let result = fs
1334                .grep_files_with_options(
1335                    sid(),
1336                    "needle",
1337                    &GrepOptions {
1338                        path_pattern: Some(path.into()),
1339                        ..Default::default()
1340                    },
1341                )
1342                .await
1343                .unwrap();
1344            let paths: Vec<_> = result.matches.iter().map(|hit| hit.path.as_str()).collect();
1345            assert_eq!(paths, [expected], "filter {path}");
1346            let flat = fs.grep_files(sid(), "needle", Some(path)).await.unwrap();
1347            assert_eq!(
1348                flat.iter().map(|hit| hit.path.as_str()).collect::<Vec<_>>(),
1349                [expected],
1350                "flat filter {path}"
1351            );
1352        }
1353    }
1354}