Skip to main content

everruns_core/
mount_fs.rs

1// Mount-based virtual filesystem resolver (EVE-660).
2//
3// `MountFs` is the single resolution seam for the agent's filesystem. It owns:
4//
5//   * a **mount table** — named mount points, each backed by a
6//     `SessionFileSystem`, with a per-mount root in the backend's keyspace, and
7//   * a **current working directory** — relative paths resolve against it.
8//
9// Resolution is uniform and POSIX-shaped: normalize the input against cwd,
10// collapse `.`/`..`, then dispatch to the longest matching mount. `/workspace`
11// is just a mount point (and the default cwd), not a magic prefix re-implemented
12// in every store. Adding `/outputs`, `/.agents/skills`, or volume mounts backed
13// by *different* stores later is `with_mount(...)` — the resolver does not change.
14//
15// Today there is a single workspace backend, so the table holds the root mount
16// (`/` → backend, for legacy backend-native paths like `/AGENTS.md`,
17// `/outputs/…`) and the `/workspace` view of the same backend. Both resolve to
18// the same files; `/workspace` wins by longest-prefix so `/workspace/foo`
19// ≡ `/foo`.
20//
21// See `specs/file-store.md` for the contract and the migration plan.
22
23use async_trait::async_trait;
24use std::sync::Arc;
25
26use crate::session_file::{GrepOptions, GrepSearchResult};
27
28use crate::error::{AgentLoopError, Result};
29use crate::session_file::{FileInfo, FileStat, GrepMatch, InitialFile, SessionFile};
30use crate::traits::SessionFileSystem;
31use crate::typed_id::SessionId;
32
33/// The conventional mount point and default cwd for the workspace. Models
34/// trained on cloud-agent layouts address files here; it is a real mount, not a
35/// strip-prefix. Same string as [`crate::session_path::WORKSPACE_PREFIX`] (the
36/// display alias) — kept as one source of truth.
37pub const WORKSPACE_MOUNT: &str = crate::session_path::WORKSPACE_PREFIX;
38
39/// 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 `specs/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/// Normalize an input into an absolute virtual path: join cwd if relative, then
342/// collapse `.`/`..` segments (a leading `..` is clamped at root).
343fn normalize_virtual(input: &str, cwd: &str) -> String {
344    let combined = if input.starts_with('/') {
345        input.to_string()
346    } else {
347        format!("{}/{}", cwd.trim_end_matches('/'), input)
348    };
349    let mut stack: Vec<&str> = Vec::new();
350    for segment in combined.split('/') {
351        match segment {
352            "" | "." => {}
353            ".." => {
354                stack.pop();
355            }
356            other => stack.push(other),
357        }
358    }
359    if stack.is_empty() {
360        "/".to_string()
361    } else {
362        format!("/{}", stack.join("/"))
363    }
364}
365
366fn reject_additional_root_traversal(input: &str, cwd: &str) -> Result<()> {
367    let combined = if input.starts_with('/') {
368        input.to_string()
369    } else {
370        format!("{}/{}", cwd.trim_end_matches('/'), input)
371    };
372    let segments: Vec<&str> = combined
373        .split('/')
374        .filter(|segment| !segment.is_empty())
375        .collect();
376    for window_start in 0..segments.len().saturating_sub(2) {
377        if segments[window_start] == "workspace" && segments[window_start + 1] == "roots" {
378            let root_name_idx = window_start + 2;
379            if segments[root_name_idx].is_empty() {
380                continue;
381            }
382            if segments
383                .iter()
384                .skip(root_name_idx + 1)
385                .any(|segment| *segment == "..")
386            {
387                return Err(AgentLoopError::tool(format!(
388                    "path traversal rejected: {input}"
389                )));
390            }
391        }
392    }
393    Ok(())
394}
395
396/// If `virtual_path` is at or under `mount_point`, return the suffix as a
397/// `/`-rooted remainder (`/` for an exact match). Segment-aware: `/workspacefoo`
398/// is not under `/workspace`.
399fn mount_suffix(mount_point: &str, virtual_path: &str) -> Option<String> {
400    if mount_point == "/" {
401        // The root mount owns the whole path.
402        return Some(virtual_path.to_string());
403    }
404    if virtual_path == mount_point {
405        return Some("/".to_string());
406    }
407    virtual_path
408        .strip_prefix(mount_point)
409        .filter(|rest| rest.starts_with('/'))
410        .map(|rest| rest.to_string())
411}
412
413/// Join a backend root with a `/`-rooted remainder into a backend keyspace path.
414fn join_backend_path(backend_root: &str, rest: &str) -> String {
415    if backend_root == "/" {
416        return rest.to_string();
417    }
418    if rest == "/" {
419        return backend_root.to_string();
420    }
421    format!("{backend_root}{rest}")
422}
423
424/// Render a canonical backend key literally under that backend's display root.
425fn display_backend_path(display_root: &str, path: &str) -> String {
426    let normalized = normalize_virtual(path, "/");
427    if normalized == "/" {
428        display_root.to_string()
429    } else if display_root == "/" {
430        normalized
431    } else {
432        format!("{}{normalized}", display_root.trim_end_matches('/'))
433    }
434}
435
436#[async_trait]
437impl SessionFileSystem for MountFs {
438    fn display_root(&self) -> String {
439        // Routing always defaults cwd to /workspace, but the *displayed* root is
440        // policy: the alias for host-agnostic presentation, or the backend's own
441        // root (a real host directory) when the embedder opts in. See
442        // [`DisplayPolicy`].
443        match self.display_policy {
444            DisplayPolicy::WorkspaceAlias => WORKSPACE_MOUNT.to_string(),
445            DisplayPolicy::BackendNative => self.primary.display_root(),
446        }
447    }
448
449    fn is_mount_resolver(&self) -> bool {
450        true
451    }
452
453    fn resolve_path(&self, input: &str) -> String {
454        // Resolve the raw input through the mount table, then present the
455        // resolved backend key per the display policy. Presenting the resolved
456        // backend key (instead of re-stripping the mount) keeps a literal
457        // `workspace/…` backend segment distinct from the `/workspace` mount
458        // alias, so a displayed path round-trips to the same backend key.
459        // Presentation itself (alias vs. backend-native host path) is decided by
460        // [`present_primary_key`] — kept out of routing on purpose (#2776/#258).
461        let virtual_path = normalize_virtual(input, &self.cwd());
462        match self.resolve(&virtual_path) {
463            Ok(resolved) if resolved.primary_workspace => {
464                self.present_primary_key(&resolved.backend_path)
465            }
466            _ => virtual_path,
467        }
468    }
469
470    fn display_path(&self, path: &str) -> String {
471        // `path` here is an already-canonical virtual output path (a resolved
472        // `file.path`, i.e. a backend key in the primary namespace, or a named
473        // mount's virtual path). Normalize at root — NOT cwd — so we treat it as
474        // a canonical key and don't inject the `/workspace` cwd alias. Then:
475        //  - named mounts: return as-is (already in their mounted namespace),
476        //  - primary: present via the display policy so a literal `workspace/…`
477        //    backend segment stays distinct from the mount alias and round-trips
478        //    (alias mode), or renders as the backend's host path (native mode).
479        let virtual_path = normalize_virtual(path, "/");
480        match self.resolve(&virtual_path) {
481            Ok(resolved) if !resolved.primary_workspace => virtual_path,
482            _ => self.present_primary_key(&virtual_path),
483        }
484    }
485
486    async fn read_file(&self, session_id: SessionId, path: &str) -> Result<Option<SessionFile>> {
487        let resolved = self.resolve(path)?;
488        Ok(resolved
489            .backend
490            .read_file(session_id, &resolved.backend_path)
491            .await?
492            .map(|file| resolved.map_session_file(file)))
493    }
494
495    async fn write_file(
496        &self,
497        session_id: SessionId,
498        path: &str,
499        content: &str,
500        encoding: &str,
501    ) -> Result<SessionFile> {
502        let resolved = self.resolve(path)?;
503        Ok(resolved.map_session_file(
504            resolved
505                .backend
506                .write_file(session_id, &resolved.backend_path, content, encoding)
507                .await?,
508        ))
509    }
510
511    async fn write_file_if_content_matches(
512        &self,
513        session_id: SessionId,
514        path: &str,
515        expected_content: &str,
516        expected_encoding: &str,
517        content: &str,
518        encoding: &str,
519    ) -> Result<Option<SessionFile>> {
520        let resolved = self.resolve(path)?;
521        Ok(resolved
522            .backend
523            .write_file_if_content_matches(
524                session_id,
525                &resolved.backend_path,
526                expected_content,
527                expected_encoding,
528                content,
529                encoding,
530            )
531            .await?
532            .map(|file| resolved.map_session_file(file)))
533    }
534
535    async fn delete_file(
536        &self,
537        session_id: SessionId,
538        path: &str,
539        recursive: bool,
540    ) -> Result<bool> {
541        let resolved = self.resolve(path)?;
542        resolved
543            .backend
544            .delete_file(session_id, &resolved.backend_path, recursive)
545            .await
546    }
547
548    async fn list_directory(&self, session_id: SessionId, path: &str) -> Result<Vec<FileInfo>> {
549        let resolved = self.resolve(path)?;
550        Ok(resolved
551            .backend
552            .list_directory(session_id, &resolved.backend_path)
553            .await?
554            .into_iter()
555            .map(|info| resolved.map_file_info(info))
556            .collect())
557    }
558
559    async fn stat_file(&self, session_id: SessionId, path: &str) -> Result<Option<FileStat>> {
560        let resolved = self.resolve(path)?;
561        Ok(resolved
562            .backend
563            .stat_file(session_id, &resolved.backend_path)
564            .await?
565            .map(|stat| resolved.map_file_stat(stat)))
566    }
567
568    async fn grep_files(
569        &self,
570        session_id: SessionId,
571        pattern: &str,
572        path_pattern: Option<&str>,
573    ) -> Result<Vec<GrepMatch>> {
574        match path_pattern {
575            Some(pp) => {
576                let matcher = crate::session_path::GrepPathPattern::new(pp)?;
577                if matcher.is_glob() && (!pp.starts_with('/') || pp.starts_with(WORKSPACE_MOUNT)) {
578                    let mut matches = Vec::new();
579                    for resolved in self.grep_mounts() {
580                        matches.extend(
581                            resolved
582                                .backend
583                                .grep_files(session_id, pattern, Some(&resolved.backend_path))
584                                .await?
585                                .into_iter()
586                                .map(|grep_match| resolved.map_grep_match(grep_match))
587                                .filter(|grep_match| matcher.is_match(&grep_match.path)),
588                        );
589                    }
590                    matches.sort_by(|a, b| {
591                        a.path
592                            .cmp(&b.path)
593                            .then(a.line_number.cmp(&b.line_number))
594                            .then(a.line.cmp(&b.line))
595                    });
596                    return Ok(matches);
597                }
598                let resolved = self.resolve(pp)?;
599                Ok(resolved
600                    .backend
601                    .grep_files(session_id, pattern, Some(&resolved.backend_path))
602                    .await?
603                    .into_iter()
604                    .map(|grep_match| resolved.map_grep_match(grep_match))
605                    .collect())
606            }
607            None => {
608                let mut matches = Vec::new();
609                for resolved in self.grep_mounts() {
610                    matches.extend(
611                        resolved
612                            .backend
613                            .grep_files(session_id, pattern, Some(&resolved.backend_path))
614                            .await?
615                            .into_iter()
616                            .map(|grep_match| resolved.map_grep_match(grep_match)),
617                    );
618                }
619                matches.sort_by(|a, b| {
620                    a.path
621                        .cmp(&b.path)
622                        .then(a.line_number.cmp(&b.line_number))
623                        .then(a.line.cmp(&b.line))
624                });
625                Ok(matches)
626            }
627        }
628    }
629
630    async fn grep_files_with_options(
631        &self,
632        session_id: SessionId,
633        pattern: &str,
634        options: &GrepOptions,
635    ) -> Result<GrepSearchResult> {
636        if let Some(path_pattern) = options.path_pattern.as_deref()
637            && path_pattern.starts_with('/')
638            && !path_pattern.starts_with(WORKSPACE_MOUNT)
639        {
640            let resolved = self.resolve(path_pattern)?;
641            let mut backend_options = options.clone();
642            backend_options.path_pattern = Some(resolved.backend_path.clone());
643            return resolved
644                .backend
645                .grep_files_with_options(session_id, pattern, &backend_options)
646                .await
647                .map(|result| resolved.map_grep_result(result));
648        }
649
650        let mounts = self.grep_mounts();
651        if mounts.len() == 1 {
652            let resolved = &mounts[0];
653            let mut backend_options = options.clone();
654            backend_options.path_pattern = options.path_pattern.as_ref().map(|path| {
655                if path.starts_with(WORKSPACE_MOUNT) {
656                    path.strip_prefix(WORKSPACE_MOUNT)
657                        .unwrap_or(path)
658                        .to_string()
659                } else {
660                    path.clone()
661                }
662            });
663            return resolved
664                .backend
665                .grep_files_with_options(session_id, pattern, &backend_options)
666                .await
667                .map(|result| resolved.map_grep_result(result));
668        }
669
670        let mut backend_options = options.clone();
671        backend_options.offset = 0;
672        backend_options.limit = usize::MAX;
673        backend_options.max_bytes = usize::MAX;
674        let path_matcher = options
675            .path_pattern
676            .as_deref()
677            .map(crate::session_path::GrepPathPattern::new)
678            .transpose()?;
679        let mut results = Vec::new();
680        for resolved in mounts {
681            let mut mount_options = backend_options.clone();
682            mount_options.path_pattern = Some(resolved.backend_path.clone());
683            let result = resolved
684                .backend
685                .grep_files_with_options(session_id, pattern, &mount_options)
686                .await?;
687            let mut mapped = resolved.map_grep_result(result);
688            if let Some(matcher) = &path_matcher {
689                mapped.matches.retain(|item| matcher.is_match(&item.path));
690                mapped.blocks.retain(|block| matcher.is_match(&block.path));
691            }
692            results.push(mapped);
693        }
694        Ok(crate::session_file::merge_grep_search_results(
695            results, options,
696        ))
697    }
698
699    async fn create_directory(&self, session_id: SessionId, path: &str) -> Result<FileInfo> {
700        let resolved = self.resolve(path)?;
701        Ok(resolved.map_file_info(
702            resolved
703                .backend
704                .create_directory(session_id, &resolved.backend_path)
705                .await?,
706        ))
707    }
708
709    async fn seed_initial_file(&self, session_id: SessionId, file: &InitialFile) -> Result<()> {
710        let resolved = self.resolve(&file.path)?;
711        let seeded = InitialFile {
712            path: resolved.backend_path,
713            content: file.content.clone(),
714            encoding: file.encoding.clone(),
715            is_readonly: file.is_readonly,
716        };
717        resolved
718            .backend
719            .seed_initial_file(session_id, &seeded)
720            .await
721    }
722}
723
724#[cfg(test)]
725mod tests {
726    use super::*;
727    use crate::session_path::GrepPathPattern;
728
729    fn sid() -> SessionId {
730        SessionId::from_seed(1)
731    }
732
733    // A minimal `/`-rooted in-memory backend for resolver tests (kept local to
734    // avoid a dependency on everruns-runtime).
735    #[derive(Default)]
736    struct FlatStore {
737        files: std::sync::Mutex<std::collections::HashMap<String, String>>,
738        /// When set, the store presents host-style paths like a real-disk
739        /// backend, so `DisplayPolicy::BackendNative` has a host identity to
740        /// delegate to. `None` keeps the trait-default `/workspace` alias.
741        host_root: Option<String>,
742    }
743
744    #[async_trait]
745    impl SessionFileSystem for FlatStore {
746        fn is_mount_resolver(&self) -> bool {
747            false
748        }
749
750        fn display_root(&self) -> String {
751            match &self.host_root {
752                Some(root) => root.clone(),
753                None => crate::session_path::WORKSPACE_PREFIX.to_string(),
754            }
755        }
756
757        fn display_path(&self, path: &str) -> String {
758            match &self.host_root {
759                Some(root) => {
760                    let normalized = normalize_virtual(path, "/");
761                    if normalized == "/" {
762                        root.clone()
763                    } else {
764                        format!("{root}{normalized}")
765                    }
766                }
767                None => crate::session_path::to_display_path(path),
768            }
769        }
770
771        async fn read_file(&self, sid: SessionId, path: &str) -> Result<Option<SessionFile>> {
772            let files = self.files.lock().unwrap();
773            Ok(files.get(path).map(|content| SessionFile {
774                id: uuid::Uuid::nil(),
775                session_id: sid.uuid(),
776                path: path.to_string(),
777                name: path.rsplit('/').next().unwrap_or("").to_string(),
778                content: Some(content.clone()),
779                encoding: "text".to_string(),
780                is_directory: false,
781                is_readonly: false,
782                size_bytes: content.len() as i64,
783                created_at: chrono::Utc::now(),
784                updated_at: chrono::Utc::now(),
785            }))
786        }
787        async fn write_file(
788            &self,
789            sid: SessionId,
790            path: &str,
791            content: &str,
792            encoding: &str,
793        ) -> Result<SessionFile> {
794            self.files
795                .lock()
796                .unwrap()
797                .insert(path.to_string(), content.to_string());
798            Ok(SessionFile {
799                id: uuid::Uuid::nil(),
800                session_id: sid.uuid(),
801                path: path.to_string(),
802                name: path.rsplit('/').next().unwrap_or("").to_string(),
803                content: Some(content.to_string()),
804                encoding: encoding.to_string(),
805                is_directory: false,
806                is_readonly: false,
807                size_bytes: content.len() as i64,
808                created_at: chrono::Utc::now(),
809                updated_at: chrono::Utc::now(),
810            })
811        }
812        async fn delete_file(&self, _: SessionId, path: &str, _: bool) -> Result<bool> {
813            Ok(self.files.lock().unwrap().remove(path).is_some())
814        }
815        async fn list_directory(&self, _: SessionId, _: &str) -> Result<Vec<FileInfo>> {
816            Ok(vec![])
817        }
818        async fn stat_file(&self, _: SessionId, path: &str) -> Result<Option<FileStat>> {
819            let files = self.files.lock().unwrap();
820            Ok(files.get(path).map(|content| FileStat {
821                path: path.to_string(),
822                name: path.rsplit('/').next().unwrap_or("").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 grep_files(
831            &self,
832            _: SessionId,
833            pattern: &str,
834            path_pattern: Option<&str>,
835        ) -> Result<Vec<GrepMatch>> {
836            let path_pattern = path_pattern.map(GrepPathPattern::new).transpose()?;
837            let files = self.files.lock().unwrap();
838            let mut matches = Vec::new();
839            for (path, content) in files.iter() {
840                if let Some(filter) = &path_pattern
841                    && !filter.is_match(path)
842                {
843                    continue;
844                }
845                for (idx, line) in content.lines().enumerate() {
846                    if line.contains(pattern) {
847                        matches.push(GrepMatch {
848                            path: path.clone(),
849                            line_number: idx + 1,
850                            line: line.to_string(),
851                        });
852                    }
853                }
854            }
855            Ok(matches)
856        }
857        async fn create_directory(&self, sid: SessionId, path: &str) -> Result<FileInfo> {
858            Ok(FileInfo {
859                id: uuid::Uuid::nil(),
860                session_id: sid.uuid(),
861                name: path.rsplit('/').next().unwrap_or("").to_string(),
862                path: path.to_string(),
863                is_directory: true,
864                is_readonly: false,
865                size_bytes: 0,
866                created_at: chrono::Utc::now(),
867                updated_at: chrono::Utc::now(),
868            })
869        }
870    }
871
872    #[test]
873    fn normalize_resolves_relative_against_cwd() {
874        assert_eq!(
875            normalize_virtual("foo/bar", "/workspace"),
876            "/workspace/foo/bar"
877        );
878        assert_eq!(normalize_virtual("/foo", "/workspace"), "/foo");
879        assert_eq!(normalize_virtual("a/../b", "/workspace"), "/workspace/b");
880        assert_eq!(normalize_virtual("../../x", "/workspace"), "/x");
881        assert_eq!(normalize_virtual(".", "/workspace"), "/workspace");
882        assert_eq!(normalize_virtual("/", "/workspace"), "/");
883    }
884
885    #[tokio::test]
886    async fn workspace_and_root_address_the_same_file() {
887        let backend: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
888        let fs = MountFs::new(backend);
889
890        // Write via the /workspace view; read back via the backend-native path.
891        fs.write_file(sid(), "/workspace/src/lib.rs", "X", "text")
892            .await
893            .unwrap();
894        let via_root = fs.read_file(sid(), "/src/lib.rs").await.unwrap().unwrap();
895        assert_eq!(via_root.content.as_deref(), Some("X"));
896        // The backend keyed it at /src/lib.rs (no /workspace in the keyspace).
897        assert_eq!(via_root.path, "/src/lib.rs");
898    }
899
900    #[tokio::test]
901    async fn relative_paths_resolve_against_cwd() {
902        let backend: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
903        let fs = MountFs::new(backend);
904        assert_eq!(fs.cwd(), "/workspace");
905
906        fs.write_file(sid(), "notes.md", "hi", "text")
907            .await
908            .unwrap();
909        // cwd is /workspace, so the relative write landed at backend /notes.md.
910        let read = fs.read_file(sid(), "/notes.md").await.unwrap().unwrap();
911        assert_eq!(read.content.as_deref(), Some("hi"));
912    }
913
914    #[tokio::test]
915    async fn legacy_subtree_paths_pass_through_root_mount() {
916        let backend: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
917        let fs = MountFs::new(backend);
918        // Internal callers write /outputs/... and /AGENTS.md directly.
919        fs.write_file(sid(), "/outputs/call.stdout", "out", "text")
920            .await
921            .unwrap();
922        let read = fs
923            .read_file(sid(), "/workspace/outputs/call.stdout")
924            .await
925            .unwrap()
926            .unwrap();
927        assert_eq!(read.content.as_deref(), Some("out"));
928    }
929
930    #[test]
931    fn display_uses_workspace_alias_for_primary() {
932        let backend: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
933        let fs = MountFs::new(backend);
934        assert_eq!(fs.display_root(), "/workspace");
935        assert_eq!(fs.display_path("/src/lib.rs"), "/workspace/src/lib.rs");
936        assert_eq!(fs.display_path("/"), "/workspace");
937        assert_eq!(fs.resolve_path("src/lib.rs"), "/workspace/src/lib.rs");
938    }
939
940    #[test]
941    fn workspace_alias_is_the_default_even_over_a_host_backend() {
942        // The server-safe default: a real-disk-style backend's host identity is
943        // hidden behind /workspace unless the embedder opts in (#2776, TM-FS).
944        let backend: Arc<dyn SessionFileSystem> = Arc::new(FlatStore {
945            host_root: Some("/host/root".to_string()),
946            ..Default::default()
947        });
948        let fs = MountFs::new(backend);
949        assert_eq!(fs.display_root(), "/workspace");
950        assert_eq!(fs.display_path("/src/lib.rs"), "/workspace/src/lib.rs");
951        assert_eq!(fs.resolve_path("src/lib.rs"), "/workspace/src/lib.rs");
952    }
953
954    #[test]
955    fn backend_display_exposes_host_paths_while_routing_is_unchanged() {
956        // The local-embedder opt-in (yolop / #258): presentation delegates to the
957        // backend's real host path, but routing still treats /workspace as cwd and
958        // resolves to the same backend key.
959        let backend: Arc<dyn SessionFileSystem> = Arc::new(FlatStore {
960            host_root: Some("/host/root".to_string()),
961            ..Default::default()
962        });
963        let fs = MountFs::new(backend).with_backend_display();
964
965        assert_eq!(fs.display_root(), "/host/root");
966        assert_eq!(fs.display_path("/src/lib.rs"), "/host/root/src/lib.rs");
967        assert_eq!(fs.display_path("/"), "/host/root");
968        // Both the relative and the /workspace-addressed spellings present the
969        // same host path — routing is independent of presentation.
970        assert_eq!(fs.resolve_path("src/lib.rs"), "/host/root/src/lib.rs");
971        assert_eq!(
972            fs.resolve_path("/workspace/src/lib.rs"),
973            "/host/root/src/lib.rs"
974        );
975    }
976
977    #[tokio::test]
978    async fn display_preserves_literal_backend_workspace_segment() {
979        let backend: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
980        backend
981            .write_file(sid(), "/workspace/collide.txt", "literal", "text")
982            .await
983            .unwrap();
984        backend
985            .write_file(sid(), "/collide.txt", "alias", "text")
986            .await
987            .unwrap();
988        let fs = MountFs::new(backend);
989
990        let literal = fs
991            .read_file(sid(), "workspace/collide.txt")
992            .await
993            .unwrap()
994            .unwrap();
995        let display_path = fs.display_path(&literal.path);
996        assert_eq!(display_path, "/workspace/workspace/collide.txt");
997
998        let round_trip = fs.read_file(sid(), &display_path).await.unwrap().unwrap();
999        assert_eq!(round_trip.content.as_deref(), Some("literal"));
1000    }
1001
1002    #[tokio::test]
1003    async fn additional_mount_routes_to_its_backend() {
1004        let workspace: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
1005        let volume: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
1006        let fs = MountFs::new(workspace).with_mount("/data", volume.clone(), "/");
1007
1008        fs.write_file(sid(), "/data/report.csv", "1,2,3", "text")
1009            .await
1010            .unwrap();
1011        // It went to the volume backend at /report.csv, not the workspace.
1012        let from_volume = volume
1013            .read_file(sid(), "/report.csv")
1014            .await
1015            .unwrap()
1016            .unwrap();
1017        assert_eq!(from_volume.content.as_deref(), Some("1,2,3"));
1018    }
1019
1020    #[tokio::test]
1021    async fn additional_mount_outputs_use_virtual_paths() {
1022        let workspace: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
1023        let volume: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
1024        let fs = MountFs::new(workspace).with_mount("/workspace/roots/backend", volume, "/");
1025
1026        let written = fs
1027            .write_file(
1028                sid(),
1029                "/workspace/roots/backend/Cargo.toml",
1030                "name = \"backend\"",
1031                "text",
1032            )
1033            .await
1034            .unwrap();
1035        assert_eq!(written.path, "/workspace/roots/backend/Cargo.toml");
1036
1037        let stat = fs
1038            .stat_file(sid(), "/workspace/roots/backend/Cargo.toml")
1039            .await
1040            .unwrap()
1041            .unwrap();
1042        assert_eq!(stat.path, "/workspace/roots/backend/Cargo.toml");
1043        assert_eq!(
1044            fs.display_path(&stat.path),
1045            "/workspace/roots/backend/Cargo.toml"
1046        );
1047        assert_eq!(
1048            fs.resolve_path("/workspace/roots/backend/Cargo.toml"),
1049            "/workspace/roots/backend/Cargo.toml"
1050        );
1051    }
1052
1053    #[tokio::test]
1054    async fn grep_without_path_searches_all_mounts() {
1055        let workspace: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
1056        let volume: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
1057        let fs = MountFs::new(workspace).with_mount("/workspace/roots/backend", volume, "/");
1058
1059        fs.write_file(sid(), "/workspace/README.md", "needle primary", "text")
1060            .await
1061            .unwrap();
1062        fs.write_file(
1063            sid(),
1064            "/workspace/roots/backend/Cargo.toml",
1065            "needle backend",
1066            "text",
1067        )
1068        .await
1069        .unwrap();
1070
1071        let matches = fs.grep_files(sid(), "needle", None).await.unwrap();
1072        let paths: Vec<_> = matches.into_iter().map(|m| m.path).collect();
1073        assert_eq!(
1074            paths,
1075            vec![
1076                "/README.md".to_string(),
1077                "/workspace/roots/backend/Cargo.toml".to_string()
1078            ]
1079        );
1080    }
1081
1082    #[tokio::test]
1083    async fn grep_resolves_workspace_glob_to_backend_namespace() {
1084        let backend: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
1085        let fs = MountFs::new(backend);
1086        fs.write_file(sid(), "/workspace/src/lib.rs", "needle", "text")
1087            .await
1088            .unwrap();
1089        fs.write_file(sid(), "/workspace/docs/readme.md", "needle", "text")
1090            .await
1091            .unwrap();
1092
1093        let matches = fs
1094            .grep_files(sid(), "needle", Some("/workspace/src/**/*.rs"))
1095            .await
1096            .unwrap();
1097
1098        assert_eq!(matches.len(), 1);
1099        assert_eq!(matches[0].path, "/src/lib.rs");
1100    }
1101
1102    #[tokio::test]
1103    async fn grep_glob_searches_every_matching_mount() {
1104        let workspace: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
1105        let volume: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
1106        let fs = MountFs::new(workspace).with_mount("/workspace/roots/backend", volume, "/");
1107        fs.write_file(sid(), "/workspace/Cargo.toml", "needle", "text")
1108            .await
1109            .unwrap();
1110        fs.write_file(
1111            sid(),
1112            "/workspace/roots/backend/Cargo.toml",
1113            "needle",
1114            "text",
1115        )
1116        .await
1117        .unwrap();
1118
1119        let paths: Vec<_> = fs
1120            .grep_files(sid(), "needle", Some("**/*.toml"))
1121            .await
1122            .unwrap()
1123            .into_iter()
1124            .map(|hit| hit.path)
1125            .collect();
1126        assert_eq!(
1127            paths,
1128            vec![
1129                "/Cargo.toml".to_string(),
1130                "/workspace/roots/backend/Cargo.toml".to_string()
1131            ]
1132        );
1133    }
1134
1135    #[test]
1136    fn mount_fs_identifies_as_resolver() {
1137        let workspace: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
1138        let fs = MountFs::wrap(workspace);
1139        assert!(fs.is_mount_resolver());
1140        let again = MountFs::wrap_if_needed(fs.clone());
1141        assert!(Arc::ptr_eq(&fs, &again));
1142    }
1143}