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::error::Result;
27use crate::session_file::{FileInfo, FileStat, GrepMatch, InitialFile, SessionFile};
28use crate::traits::SessionFileSystem;
29use crate::typed_id::SessionId;
30
31/// The conventional mount point and default cwd for the workspace. Models
32/// trained on cloud-agent layouts address files here; it is a real mount, not a
33/// strip-prefix. Same string as [`crate::session_path::WORKSPACE_PREFIX`] (the
34/// display alias) — kept as one source of truth.
35pub const WORKSPACE_MOUNT: &str = crate::session_path::WORKSPACE_PREFIX;
36
37/// A single entry in the mount table.
38#[derive(Clone)]
39struct Mount {
40    /// Virtual mount point: normalized, absolute, no trailing slash (`/` for
41    /// root).
42    mount_point: String,
43    /// Backend serving this mount.
44    backend: Arc<dyn SessionFileSystem>,
45    /// Path inside the backend's own keyspace that `mount_point` maps to.
46    backend_root: String,
47}
48
49/// Mount-based resolver. Implements `SessionFileSystem`, so it drops into
50/// `ToolContext` / `SystemPromptContext` wherever the file store is wired.
51pub struct MountFs {
52    /// Sorted by mount-point length descending so the first match is the
53    /// longest (most specific) mount.
54    mounts: Vec<Mount>,
55    /// The workspace backend — used for grep, display, and host mapping.
56    primary: Arc<dyn SessionFileSystem>,
57    /// Current working directory (a normalized virtual path). Relative inputs
58    /// resolve against it; defaults to [`WORKSPACE_MOUNT`]. Fixed at
59    /// construction — persistent `cd` across tool calls is not a feature yet.
60    cwd: String,
61}
62
63impl MountFs {
64    /// Build a resolver over a single workspace backend.
65    ///
66    /// The backend is mounted at both `/` (its native keyspace, for legacy
67    /// absolute paths) and `/workspace` (the model-facing view). cwd defaults to
68    /// `/workspace`.
69    pub fn new(workspace: Arc<dyn SessionFileSystem>) -> Self {
70        let mounts = vec![
71            Mount {
72                mount_point: "/".to_string(),
73                backend: workspace.clone(),
74                backend_root: "/".to_string(),
75            },
76            Mount {
77                mount_point: WORKSPACE_MOUNT.to_string(),
78                backend: workspace.clone(),
79                backend_root: "/".to_string(),
80            },
81        ];
82        let mut fs = Self {
83            mounts,
84            primary: workspace,
85            cwd: WORKSPACE_MOUNT.to_string(),
86        };
87        fs.sort_mounts();
88        fs
89    }
90
91    /// Build a resolver and return it as a trait object.
92    pub fn wrap(workspace: Arc<dyn SessionFileSystem>) -> Arc<dyn SessionFileSystem> {
93        Arc::new(Self::new(workspace))
94    }
95
96    /// Register an additional mount (e.g. a read-only skills source or a named
97    /// volume) backed by a different store. Longest-prefix wins at resolution.
98    pub fn with_mount(
99        mut self,
100        mount_point: impl Into<String>,
101        backend: Arc<dyn SessionFileSystem>,
102        backend_root: impl Into<String>,
103    ) -> Self {
104        self.mounts.push(Mount {
105            mount_point: normalize_virtual(&mount_point.into(), "/"),
106            backend,
107            backend_root: normalize_virtual(&backend_root.into(), "/"),
108        });
109        self.sort_mounts();
110        self
111    }
112
113    /// The current working directory (normalized virtual path).
114    pub fn cwd(&self) -> String {
115        self.cwd.clone()
116    }
117
118    fn sort_mounts(&mut self) {
119        // Longest mount point first, so resolution picks the most specific mount.
120        self.mounts
121            .sort_by_key(|m| std::cmp::Reverse(m.mount_point.len()));
122    }
123
124    /// Resolve any input path to `(backend, backend_path)`.
125    ///
126    /// Relative inputs are joined to cwd; `.`/`..` are collapsed (clamped at
127    /// root); the longest matching mount is selected and the remainder is mapped
128    /// into that backend's keyspace.
129    fn resolve(&self, input: &str) -> (Arc<dyn SessionFileSystem>, String) {
130        let virtual_path = normalize_virtual(input, &self.cwd());
131        for mount in &self.mounts {
132            if let Some(rest) = mount_suffix(&mount.mount_point, &virtual_path) {
133                return (
134                    mount.backend.clone(),
135                    join_backend_path(&mount.backend_root, &rest),
136                );
137            }
138        }
139        // The root mount matches every absolute path, so this is unreachable in
140        // practice; fall back to the primary backend with the literal path.
141        (self.primary.clone(), virtual_path)
142    }
143}
144
145/// Normalize an input into an absolute virtual path: join cwd if relative, then
146/// collapse `.`/`..` segments (a leading `..` is clamped at root).
147fn normalize_virtual(input: &str, cwd: &str) -> String {
148    let combined = if input.starts_with('/') {
149        input.to_string()
150    } else {
151        format!("{}/{}", cwd.trim_end_matches('/'), input)
152    };
153    let mut stack: Vec<&str> = Vec::new();
154    for segment in combined.split('/') {
155        match segment {
156            "" | "." => {}
157            ".." => {
158                stack.pop();
159            }
160            other => stack.push(other),
161        }
162    }
163    if stack.is_empty() {
164        "/".to_string()
165    } else {
166        format!("/{}", stack.join("/"))
167    }
168}
169
170/// If `virtual_path` is at or under `mount_point`, return the suffix as a
171/// `/`-rooted remainder (`/` for an exact match). Segment-aware: `/workspacefoo`
172/// is not under `/workspace`.
173fn mount_suffix(mount_point: &str, virtual_path: &str) -> Option<String> {
174    if mount_point == "/" {
175        // The root mount owns the whole path.
176        return Some(virtual_path.to_string());
177    }
178    if virtual_path == mount_point {
179        return Some("/".to_string());
180    }
181    virtual_path
182        .strip_prefix(mount_point)
183        .filter(|rest| rest.starts_with('/'))
184        .map(|rest| rest.to_string())
185}
186
187/// Join a backend root with a `/`-rooted remainder into a backend keyspace path.
188fn join_backend_path(backend_root: &str, rest: &str) -> String {
189    if backend_root == "/" {
190        return rest.to_string();
191    }
192    if rest == "/" {
193        return backend_root.to_string();
194    }
195    format!("{backend_root}{rest}")
196}
197
198#[async_trait]
199impl SessionFileSystem for MountFs {
200    fn display_root(&self) -> String {
201        WORKSPACE_MOUNT.to_string()
202    }
203
204    fn resolve_path(&self, input: &str) -> String {
205        // The absolute virtual path: relative inputs resolve against cwd,
206        // `.`/`..` collapse, leading `..` clamps at root. This is the namespace
207        // the shell sees — `/workspace` is just the default cwd, and any path
208        // is reachable from the root mount.
209        normalize_virtual(input, &self.cwd())
210    }
211
212    fn display_path(&self, path: &str) -> String {
213        // `path` is a backend keyspace path (e.g. `/foo`); render it under the
214        // workspace mount.
215        crate::session_path::to_display_path(path)
216    }
217
218    async fn read_file(&self, session_id: SessionId, path: &str) -> Result<Option<SessionFile>> {
219        let (backend, backend_path) = self.resolve(path);
220        backend.read_file(session_id, &backend_path).await
221    }
222
223    async fn write_file(
224        &self,
225        session_id: SessionId,
226        path: &str,
227        content: &str,
228        encoding: &str,
229    ) -> Result<SessionFile> {
230        let (backend, backend_path) = self.resolve(path);
231        backend
232            .write_file(session_id, &backend_path, content, encoding)
233            .await
234    }
235
236    async fn write_file_if_content_matches(
237        &self,
238        session_id: SessionId,
239        path: &str,
240        expected_content: &str,
241        expected_encoding: &str,
242        content: &str,
243        encoding: &str,
244    ) -> Result<Option<SessionFile>> {
245        let (backend, backend_path) = self.resolve(path);
246        backend
247            .write_file_if_content_matches(
248                session_id,
249                &backend_path,
250                expected_content,
251                expected_encoding,
252                content,
253                encoding,
254            )
255            .await
256    }
257
258    async fn delete_file(
259        &self,
260        session_id: SessionId,
261        path: &str,
262        recursive: bool,
263    ) -> Result<bool> {
264        let (backend, backend_path) = self.resolve(path);
265        backend
266            .delete_file(session_id, &backend_path, recursive)
267            .await
268    }
269
270    async fn list_directory(&self, session_id: SessionId, path: &str) -> Result<Vec<FileInfo>> {
271        let (backend, backend_path) = self.resolve(path);
272        backend.list_directory(session_id, &backend_path).await
273    }
274
275    async fn stat_file(&self, session_id: SessionId, path: &str) -> Result<Option<FileStat>> {
276        let (backend, backend_path) = self.resolve(path);
277        backend.stat_file(session_id, &backend_path).await
278    }
279
280    async fn grep_files(
281        &self,
282        session_id: SessionId,
283        pattern: &str,
284        path_pattern: Option<&str>,
285    ) -> Result<Vec<GrepMatch>> {
286        match path_pattern {
287            Some(pp) => {
288                let (backend, backend_path) = self.resolve(pp);
289                backend
290                    .grep_files(session_id, pattern, Some(&backend_path))
291                    .await
292            }
293            None => self.primary.grep_files(session_id, pattern, None).await,
294        }
295    }
296
297    async fn create_directory(&self, session_id: SessionId, path: &str) -> Result<FileInfo> {
298        let (backend, backend_path) = self.resolve(path);
299        backend.create_directory(session_id, &backend_path).await
300    }
301
302    async fn seed_initial_file(&self, session_id: SessionId, file: &InitialFile) -> Result<()> {
303        let (backend, backend_path) = self.resolve(&file.path);
304        let seeded = InitialFile {
305            path: backend_path,
306            content: file.content.clone(),
307            encoding: file.encoding.clone(),
308            is_readonly: file.is_readonly,
309        };
310        backend.seed_initial_file(session_id, &seeded).await
311    }
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317
318    fn sid() -> SessionId {
319        SessionId::from_seed(1)
320    }
321
322    // A minimal `/`-rooted in-memory backend for resolver tests (kept local to
323    // avoid a dependency on everruns-runtime).
324    #[derive(Default)]
325    struct FlatStore {
326        files: std::sync::Mutex<std::collections::HashMap<String, String>>,
327    }
328
329    #[async_trait]
330    impl SessionFileSystem for FlatStore {
331        async fn read_file(&self, sid: SessionId, path: &str) -> Result<Option<SessionFile>> {
332            let files = self.files.lock().unwrap();
333            Ok(files.get(path).map(|content| SessionFile {
334                id: uuid::Uuid::nil(),
335                session_id: sid.uuid(),
336                path: path.to_string(),
337                name: path.rsplit('/').next().unwrap_or("").to_string(),
338                content: Some(content.clone()),
339                encoding: "text".to_string(),
340                is_directory: false,
341                is_readonly: false,
342                size_bytes: content.len() as i64,
343                created_at: chrono::Utc::now(),
344                updated_at: chrono::Utc::now(),
345            }))
346        }
347        async fn write_file(
348            &self,
349            sid: SessionId,
350            path: &str,
351            content: &str,
352            encoding: &str,
353        ) -> Result<SessionFile> {
354            self.files
355                .lock()
356                .unwrap()
357                .insert(path.to_string(), content.to_string());
358            Ok(SessionFile {
359                id: uuid::Uuid::nil(),
360                session_id: sid.uuid(),
361                path: path.to_string(),
362                name: path.rsplit('/').next().unwrap_or("").to_string(),
363                content: Some(content.to_string()),
364                encoding: encoding.to_string(),
365                is_directory: false,
366                is_readonly: false,
367                size_bytes: content.len() as i64,
368                created_at: chrono::Utc::now(),
369                updated_at: chrono::Utc::now(),
370            })
371        }
372        async fn delete_file(&self, _: SessionId, path: &str, _: bool) -> Result<bool> {
373            Ok(self.files.lock().unwrap().remove(path).is_some())
374        }
375        async fn list_directory(&self, _: SessionId, _: &str) -> Result<Vec<FileInfo>> {
376            Ok(vec![])
377        }
378        async fn stat_file(&self, _: SessionId, _: &str) -> Result<Option<FileStat>> {
379            Ok(None)
380        }
381        async fn grep_files(
382            &self,
383            _: SessionId,
384            _: &str,
385            _: Option<&str>,
386        ) -> Result<Vec<GrepMatch>> {
387            Ok(vec![])
388        }
389        async fn create_directory(&self, sid: SessionId, path: &str) -> Result<FileInfo> {
390            Ok(FileInfo {
391                id: uuid::Uuid::nil(),
392                session_id: sid.uuid(),
393                name: path.rsplit('/').next().unwrap_or("").to_string(),
394                path: path.to_string(),
395                is_directory: true,
396                is_readonly: false,
397                size_bytes: 0,
398                created_at: chrono::Utc::now(),
399                updated_at: chrono::Utc::now(),
400            })
401        }
402    }
403
404    #[test]
405    fn normalize_resolves_relative_against_cwd() {
406        assert_eq!(
407            normalize_virtual("foo/bar", "/workspace"),
408            "/workspace/foo/bar"
409        );
410        assert_eq!(normalize_virtual("/foo", "/workspace"), "/foo");
411        assert_eq!(normalize_virtual("a/../b", "/workspace"), "/workspace/b");
412        assert_eq!(normalize_virtual("../../x", "/workspace"), "/x");
413        assert_eq!(normalize_virtual(".", "/workspace"), "/workspace");
414        assert_eq!(normalize_virtual("/", "/workspace"), "/");
415    }
416
417    #[tokio::test]
418    async fn workspace_and_root_address_the_same_file() {
419        let backend: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
420        let fs = MountFs::new(backend);
421
422        // Write via the /workspace view; read back via the backend-native path.
423        fs.write_file(sid(), "/workspace/src/lib.rs", "X", "text")
424            .await
425            .unwrap();
426        let via_root = fs.read_file(sid(), "/src/lib.rs").await.unwrap().unwrap();
427        assert_eq!(via_root.content.as_deref(), Some("X"));
428        // The backend keyed it at /src/lib.rs (no /workspace in the keyspace).
429        assert_eq!(via_root.path, "/src/lib.rs");
430    }
431
432    #[tokio::test]
433    async fn relative_paths_resolve_against_cwd() {
434        let backend: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
435        let fs = MountFs::new(backend);
436        assert_eq!(fs.cwd(), "/workspace");
437
438        fs.write_file(sid(), "notes.md", "hi", "text")
439            .await
440            .unwrap();
441        // cwd is /workspace, so the relative write landed at backend /notes.md.
442        let read = fs.read_file(sid(), "/notes.md").await.unwrap().unwrap();
443        assert_eq!(read.content.as_deref(), Some("hi"));
444    }
445
446    #[tokio::test]
447    async fn legacy_subtree_paths_pass_through_root_mount() {
448        let backend: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
449        let fs = MountFs::new(backend);
450        // Internal callers write /outputs/... and /AGENTS.md directly.
451        fs.write_file(sid(), "/outputs/call.stdout", "out", "text")
452            .await
453            .unwrap();
454        let read = fs
455            .read_file(sid(), "/workspace/outputs/call.stdout")
456            .await
457            .unwrap()
458            .unwrap();
459        assert_eq!(read.content.as_deref(), Some("out"));
460    }
461
462    #[test]
463    fn display_is_the_workspace_view() {
464        let backend: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
465        let fs = MountFs::new(backend);
466        assert_eq!(fs.display_root(), "/workspace");
467        assert_eq!(fs.display_path("/src/lib.rs"), "/workspace/src/lib.rs");
468        assert_eq!(fs.display_path("/"), "/workspace");
469    }
470
471    #[tokio::test]
472    async fn additional_mount_routes_to_its_backend() {
473        let workspace: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
474        let volume: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
475        let fs = MountFs::new(workspace).with_mount("/data", volume.clone(), "/");
476
477        fs.write_file(sid(), "/data/report.csv", "1,2,3", "text")
478            .await
479            .unwrap();
480        // It went to the volume backend at /report.csv, not the workspace.
481        let from_volume = volume
482            .read_file(sid(), "/report.csv")
483            .await
484            .unwrap()
485            .unwrap();
486        assert_eq!(from_volume.content.as_deref(), Some("1,2,3"));
487    }
488}