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