1use async_trait::async_trait;
24use std::sync::Arc;
25
26use crate::error::{AgentLoopError, Result};
27use crate::session_file::{FileInfo, FileStat, GrepMatch, InitialFile, SessionFile};
28use crate::traits::SessionFileSystem;
29use crate::typed_id::SessionId;
30
31pub const WORKSPACE_MOUNT: &str = crate::session_path::WORKSPACE_PREFIX;
36
37#[derive(Clone)]
39struct Mount {
40 mount_point: String,
43 backend: Arc<dyn SessionFileSystem>,
45 backend_root: String,
47 primary_workspace: bool,
52}
53
54#[derive(Clone)]
55struct ResolvedMount {
56 mount_point: String,
57 backend: Arc<dyn SessionFileSystem>,
58 backend_root: String,
59 backend_path: String,
60 primary_workspace: bool,
61}
62
63pub struct MountFs {
66 mounts: Vec<Mount>,
69 primary: Arc<dyn SessionFileSystem>,
71 cwd: String,
75}
76
77impl MountFs {
78 pub fn new(workspace: Arc<dyn SessionFileSystem>) -> Self {
84 let mounts = vec![
85 Mount {
86 mount_point: "/".to_string(),
87 backend: workspace.clone(),
88 backend_root: "/".to_string(),
89 primary_workspace: true,
90 },
91 Mount {
92 mount_point: WORKSPACE_MOUNT.to_string(),
93 backend: workspace.clone(),
94 backend_root: "/".to_string(),
95 primary_workspace: true,
96 },
97 ];
98 let mut fs = Self {
99 mounts,
100 primary: workspace,
101 cwd: WORKSPACE_MOUNT.to_string(),
102 };
103 fs.sort_mounts();
104 fs
105 }
106
107 pub fn wrap(workspace: Arc<dyn SessionFileSystem>) -> Arc<dyn SessionFileSystem> {
109 Arc::new(Self::new(workspace))
110 }
111
112 pub fn with_mount(
115 mut self,
116 mount_point: impl Into<String>,
117 backend: Arc<dyn SessionFileSystem>,
118 backend_root: impl Into<String>,
119 ) -> Self {
120 self.mounts.push(Mount {
121 mount_point: normalize_virtual(&mount_point.into(), "/"),
122 backend,
123 backend_root: normalize_virtual(&backend_root.into(), "/"),
124 primary_workspace: false,
125 });
126 self.sort_mounts();
127 self
128 }
129
130 pub fn cwd(&self) -> String {
132 self.cwd.clone()
133 }
134
135 fn sort_mounts(&mut self) {
136 self.mounts
138 .sort_by_key(|m| std::cmp::Reverse(m.mount_point.len()));
139 }
140
141 fn resolve(&self, input: &str) -> Result<ResolvedMount> {
147 reject_additional_root_traversal(input, &self.cwd)?;
148 let virtual_path = normalize_virtual(input, &self.cwd());
149 for mount in &self.mounts {
150 if let Some(rest) = mount_suffix(&mount.mount_point, &virtual_path) {
151 return Ok(ResolvedMount {
152 mount_point: mount.mount_point.clone(),
153 backend: mount.backend.clone(),
154 backend_root: mount.backend_root.clone(),
155 backend_path: join_backend_path(&mount.backend_root, &rest),
156 primary_workspace: mount.primary_workspace,
157 });
158 }
159 }
160 Ok(ResolvedMount {
163 mount_point: "/".to_string(),
164 backend: self.primary.clone(),
165 backend_root: "/".to_string(),
166 backend_path: virtual_path,
167 primary_workspace: true,
168 })
169 }
170
171 fn grep_mounts(&self) -> Vec<ResolvedMount> {
172 let mut out: Vec<ResolvedMount> = Vec::new();
173 for mount in self.mounts.iter().rev() {
174 if out.iter().any(|existing| {
175 Arc::ptr_eq(&existing.backend, &mount.backend)
176 && existing.backend_root == mount.backend_root
177 }) {
178 continue;
179 }
180 out.push(ResolvedMount {
181 mount_point: mount.mount_point.clone(),
182 backend: mount.backend.clone(),
183 backend_root: mount.backend_root.clone(),
184 backend_path: mount.backend_root.clone(),
185 primary_workspace: mount.primary_workspace,
186 });
187 }
188 out
189 }
190}
191
192impl ResolvedMount {
193 fn map_session_file(&self, mut file: SessionFile) -> SessionFile {
194 file.path = self.to_virtual_output_path(&file.path);
195 file.name = FileInfo::name_from_path(&file.path);
196 file
197 }
198
199 fn map_file_info(&self, mut info: FileInfo) -> FileInfo {
200 info.path = self.to_virtual_output_path(&info.path);
201 info.name = FileInfo::name_from_path(&info.path);
202 info
203 }
204
205 fn map_file_stat(&self, mut stat: FileStat) -> FileStat {
206 stat.path = self.to_virtual_output_path(&stat.path);
207 stat.name = FileInfo::name_from_path(&stat.path);
208 stat
209 }
210
211 fn map_grep_match(&self, mut grep_match: GrepMatch) -> GrepMatch {
212 grep_match.path = self.to_virtual_output_path(&grep_match.path);
213 grep_match
214 }
215
216 fn to_virtual_output_path(&self, backend_path: &str) -> String {
217 if self.primary_workspace {
218 return normalize_virtual(backend_path, "/");
219 }
220 let normalized = normalize_virtual(backend_path, "/");
221 let rest = mount_suffix(&self.backend_root, &normalized).unwrap_or(normalized);
222 join_backend_path(&self.mount_point, &rest)
223 }
224}
225
226fn normalize_virtual(input: &str, cwd: &str) -> String {
229 let combined = if input.starts_with('/') {
230 input.to_string()
231 } else {
232 format!("{}/{}", cwd.trim_end_matches('/'), input)
233 };
234 let mut stack: Vec<&str> = Vec::new();
235 for segment in combined.split('/') {
236 match segment {
237 "" | "." => {}
238 ".." => {
239 stack.pop();
240 }
241 other => stack.push(other),
242 }
243 }
244 if stack.is_empty() {
245 "/".to_string()
246 } else {
247 format!("/{}", stack.join("/"))
248 }
249}
250
251fn reject_additional_root_traversal(input: &str, cwd: &str) -> Result<()> {
252 let combined = if input.starts_with('/') {
253 input.to_string()
254 } else {
255 format!("{}/{}", cwd.trim_end_matches('/'), input)
256 };
257 let segments: Vec<&str> = combined
258 .split('/')
259 .filter(|segment| !segment.is_empty())
260 .collect();
261 for window_start in 0..segments.len().saturating_sub(2) {
262 if segments[window_start] == "workspace" && segments[window_start + 1] == "roots" {
263 let root_name_idx = window_start + 2;
264 if segments[root_name_idx].is_empty() {
265 continue;
266 }
267 if segments
268 .iter()
269 .skip(root_name_idx + 1)
270 .any(|segment| *segment == "..")
271 {
272 return Err(AgentLoopError::tool(format!(
273 "path traversal rejected: {input}"
274 )));
275 }
276 }
277 }
278 Ok(())
279}
280
281fn mount_suffix(mount_point: &str, virtual_path: &str) -> Option<String> {
285 if mount_point == "/" {
286 return Some(virtual_path.to_string());
288 }
289 if virtual_path == mount_point {
290 return Some("/".to_string());
291 }
292 virtual_path
293 .strip_prefix(mount_point)
294 .filter(|rest| rest.starts_with('/'))
295 .map(|rest| rest.to_string())
296}
297
298fn join_backend_path(backend_root: &str, rest: &str) -> String {
300 if backend_root == "/" {
301 return rest.to_string();
302 }
303 if rest == "/" {
304 return backend_root.to_string();
305 }
306 format!("{backend_root}{rest}")
307}
308
309#[async_trait]
310impl SessionFileSystem for MountFs {
311 fn display_root(&self) -> String {
312 self.primary.display_root()
313 }
314
315 fn resolve_path(&self, input: &str) -> String {
316 let virtual_path = normalize_virtual(input, &self.cwd());
321 match self.resolve(&virtual_path) {
322 Ok(resolved) if resolved.primary_workspace => {
323 resolved.backend.display_path(&resolved.backend_path)
324 }
325 _ => virtual_path,
326 }
327 }
328
329 fn display_path(&self, path: &str) -> String {
330 let virtual_path = normalize_virtual(path, "/");
331 match self.resolve(&virtual_path) {
332 Ok(resolved) if resolved.primary_workspace => {
333 resolved.backend.display_path(&resolved.backend_path)
334 }
335 _ => virtual_path,
336 }
337 }
338
339 async fn read_file(&self, session_id: SessionId, path: &str) -> Result<Option<SessionFile>> {
340 let resolved = self.resolve(path)?;
341 Ok(resolved
342 .backend
343 .read_file(session_id, &resolved.backend_path)
344 .await?
345 .map(|file| resolved.map_session_file(file)))
346 }
347
348 async fn write_file(
349 &self,
350 session_id: SessionId,
351 path: &str,
352 content: &str,
353 encoding: &str,
354 ) -> Result<SessionFile> {
355 let resolved = self.resolve(path)?;
356 Ok(resolved.map_session_file(
357 resolved
358 .backend
359 .write_file(session_id, &resolved.backend_path, content, encoding)
360 .await?,
361 ))
362 }
363
364 async fn write_file_if_content_matches(
365 &self,
366 session_id: SessionId,
367 path: &str,
368 expected_content: &str,
369 expected_encoding: &str,
370 content: &str,
371 encoding: &str,
372 ) -> Result<Option<SessionFile>> {
373 let resolved = self.resolve(path)?;
374 Ok(resolved
375 .backend
376 .write_file_if_content_matches(
377 session_id,
378 &resolved.backend_path,
379 expected_content,
380 expected_encoding,
381 content,
382 encoding,
383 )
384 .await?
385 .map(|file| resolved.map_session_file(file)))
386 }
387
388 async fn delete_file(
389 &self,
390 session_id: SessionId,
391 path: &str,
392 recursive: bool,
393 ) -> Result<bool> {
394 let resolved = self.resolve(path)?;
395 resolved
396 .backend
397 .delete_file(session_id, &resolved.backend_path, recursive)
398 .await
399 }
400
401 async fn list_directory(&self, session_id: SessionId, path: &str) -> Result<Vec<FileInfo>> {
402 let resolved = self.resolve(path)?;
403 Ok(resolved
404 .backend
405 .list_directory(session_id, &resolved.backend_path)
406 .await?
407 .into_iter()
408 .map(|info| resolved.map_file_info(info))
409 .collect())
410 }
411
412 async fn stat_file(&self, session_id: SessionId, path: &str) -> Result<Option<FileStat>> {
413 let resolved = self.resolve(path)?;
414 Ok(resolved
415 .backend
416 .stat_file(session_id, &resolved.backend_path)
417 .await?
418 .map(|stat| resolved.map_file_stat(stat)))
419 }
420
421 async fn grep_files(
422 &self,
423 session_id: SessionId,
424 pattern: &str,
425 path_pattern: Option<&str>,
426 ) -> Result<Vec<GrepMatch>> {
427 match path_pattern {
428 Some(pp) => {
429 let resolved = self.resolve(pp)?;
430 Ok(resolved
431 .backend
432 .grep_files(session_id, pattern, Some(&resolved.backend_path))
433 .await?
434 .into_iter()
435 .map(|grep_match| resolved.map_grep_match(grep_match))
436 .collect())
437 }
438 None => {
439 let mut matches = Vec::new();
440 for resolved in self.grep_mounts() {
441 matches.extend(
442 resolved
443 .backend
444 .grep_files(session_id, pattern, Some(&resolved.backend_path))
445 .await?
446 .into_iter()
447 .map(|grep_match| resolved.map_grep_match(grep_match)),
448 );
449 }
450 matches.sort_by(|a, b| {
451 a.path
452 .cmp(&b.path)
453 .then(a.line_number.cmp(&b.line_number))
454 .then(a.line.cmp(&b.line))
455 });
456 Ok(matches)
457 }
458 }
459 }
460
461 async fn create_directory(&self, session_id: SessionId, path: &str) -> Result<FileInfo> {
462 let resolved = self.resolve(path)?;
463 Ok(resolved.map_file_info(
464 resolved
465 .backend
466 .create_directory(session_id, &resolved.backend_path)
467 .await?,
468 ))
469 }
470
471 async fn seed_initial_file(&self, session_id: SessionId, file: &InitialFile) -> Result<()> {
472 let resolved = self.resolve(&file.path)?;
473 let seeded = InitialFile {
474 path: resolved.backend_path,
475 content: file.content.clone(),
476 encoding: file.encoding.clone(),
477 is_readonly: file.is_readonly,
478 };
479 resolved
480 .backend
481 .seed_initial_file(session_id, &seeded)
482 .await
483 }
484}
485
486#[cfg(test)]
487mod tests {
488 use super::*;
489
490 fn sid() -> SessionId {
491 SessionId::from_seed(1)
492 }
493
494 #[derive(Default)]
497 struct FlatStore {
498 files: std::sync::Mutex<std::collections::HashMap<String, String>>,
499 }
500
501 #[async_trait]
502 impl SessionFileSystem for FlatStore {
503 async fn read_file(&self, sid: SessionId, path: &str) -> Result<Option<SessionFile>> {
504 let files = self.files.lock().unwrap();
505 Ok(files.get(path).map(|content| SessionFile {
506 id: uuid::Uuid::nil(),
507 session_id: sid.uuid(),
508 path: path.to_string(),
509 name: path.rsplit('/').next().unwrap_or("").to_string(),
510 content: Some(content.clone()),
511 encoding: "text".to_string(),
512 is_directory: false,
513 is_readonly: false,
514 size_bytes: content.len() as i64,
515 created_at: chrono::Utc::now(),
516 updated_at: chrono::Utc::now(),
517 }))
518 }
519 async fn write_file(
520 &self,
521 sid: SessionId,
522 path: &str,
523 content: &str,
524 encoding: &str,
525 ) -> Result<SessionFile> {
526 self.files
527 .lock()
528 .unwrap()
529 .insert(path.to_string(), content.to_string());
530 Ok(SessionFile {
531 id: uuid::Uuid::nil(),
532 session_id: sid.uuid(),
533 path: path.to_string(),
534 name: path.rsplit('/').next().unwrap_or("").to_string(),
535 content: Some(content.to_string()),
536 encoding: encoding.to_string(),
537 is_directory: false,
538 is_readonly: false,
539 size_bytes: content.len() as i64,
540 created_at: chrono::Utc::now(),
541 updated_at: chrono::Utc::now(),
542 })
543 }
544 async fn delete_file(&self, _: SessionId, path: &str, _: bool) -> Result<bool> {
545 Ok(self.files.lock().unwrap().remove(path).is_some())
546 }
547 async fn list_directory(&self, _: SessionId, _: &str) -> Result<Vec<FileInfo>> {
548 Ok(vec![])
549 }
550 async fn stat_file(&self, _: SessionId, path: &str) -> Result<Option<FileStat>> {
551 let files = self.files.lock().unwrap();
552 Ok(files.get(path).map(|content| FileStat {
553 path: path.to_string(),
554 name: path.rsplit('/').next().unwrap_or("").to_string(),
555 is_directory: false,
556 is_readonly: false,
557 size_bytes: content.len() as i64,
558 created_at: chrono::Utc::now(),
559 updated_at: chrono::Utc::now(),
560 }))
561 }
562 async fn grep_files(
563 &self,
564 _: SessionId,
565 pattern: &str,
566 path_pattern: Option<&str>,
567 ) -> Result<Vec<GrepMatch>> {
568 let files = self.files.lock().unwrap();
569 let mut matches = Vec::new();
570 for (path, content) in files.iter() {
571 if let Some(filter) = path_pattern
572 && !path.contains(filter)
573 {
574 continue;
575 }
576 for (idx, line) in content.lines().enumerate() {
577 if line.contains(pattern) {
578 matches.push(GrepMatch {
579 path: path.clone(),
580 line_number: idx + 1,
581 line: line.to_string(),
582 });
583 }
584 }
585 }
586 Ok(matches)
587 }
588 async fn create_directory(&self, sid: SessionId, path: &str) -> Result<FileInfo> {
589 Ok(FileInfo {
590 id: uuid::Uuid::nil(),
591 session_id: sid.uuid(),
592 name: path.rsplit('/').next().unwrap_or("").to_string(),
593 path: path.to_string(),
594 is_directory: true,
595 is_readonly: false,
596 size_bytes: 0,
597 created_at: chrono::Utc::now(),
598 updated_at: chrono::Utc::now(),
599 })
600 }
601 }
602
603 #[test]
604 fn normalize_resolves_relative_against_cwd() {
605 assert_eq!(
606 normalize_virtual("foo/bar", "/workspace"),
607 "/workspace/foo/bar"
608 );
609 assert_eq!(normalize_virtual("/foo", "/workspace"), "/foo");
610 assert_eq!(normalize_virtual("a/../b", "/workspace"), "/workspace/b");
611 assert_eq!(normalize_virtual("../../x", "/workspace"), "/x");
612 assert_eq!(normalize_virtual(".", "/workspace"), "/workspace");
613 assert_eq!(normalize_virtual("/", "/workspace"), "/");
614 }
615
616 #[tokio::test]
617 async fn workspace_and_root_address_the_same_file() {
618 let backend: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
619 let fs = MountFs::new(backend);
620
621 fs.write_file(sid(), "/workspace/src/lib.rs", "X", "text")
623 .await
624 .unwrap();
625 let via_root = fs.read_file(sid(), "/src/lib.rs").await.unwrap().unwrap();
626 assert_eq!(via_root.content.as_deref(), Some("X"));
627 assert_eq!(via_root.path, "/src/lib.rs");
629 }
630
631 #[tokio::test]
632 async fn relative_paths_resolve_against_cwd() {
633 let backend: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
634 let fs = MountFs::new(backend);
635 assert_eq!(fs.cwd(), "/workspace");
636
637 fs.write_file(sid(), "notes.md", "hi", "text")
638 .await
639 .unwrap();
640 let read = fs.read_file(sid(), "/notes.md").await.unwrap().unwrap();
642 assert_eq!(read.content.as_deref(), Some("hi"));
643 }
644
645 #[tokio::test]
646 async fn legacy_subtree_paths_pass_through_root_mount() {
647 let backend: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
648 let fs = MountFs::new(backend);
649 fs.write_file(sid(), "/outputs/call.stdout", "out", "text")
651 .await
652 .unwrap();
653 let read = fs
654 .read_file(sid(), "/workspace/outputs/call.stdout")
655 .await
656 .unwrap()
657 .unwrap();
658 assert_eq!(read.content.as_deref(), Some("out"));
659 }
660
661 #[test]
662 fn display_uses_the_primary_backends_identity() {
663 let backend: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
664 let fs = MountFs::new(backend);
665 assert_eq!(fs.display_root(), "/workspace");
666 assert_eq!(fs.display_path("/src/lib.rs"), "/workspace/src/lib.rs");
667 assert_eq!(fs.display_path("/"), "/workspace");
668 assert_eq!(fs.resolve_path("src/lib.rs"), "/workspace/src/lib.rs");
669 }
670
671 #[tokio::test]
672 async fn additional_mount_routes_to_its_backend() {
673 let workspace: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
674 let volume: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
675 let fs = MountFs::new(workspace).with_mount("/data", volume.clone(), "/");
676
677 fs.write_file(sid(), "/data/report.csv", "1,2,3", "text")
678 .await
679 .unwrap();
680 let from_volume = volume
682 .read_file(sid(), "/report.csv")
683 .await
684 .unwrap()
685 .unwrap();
686 assert_eq!(from_volume.content.as_deref(), Some("1,2,3"));
687 }
688
689 #[tokio::test]
690 async fn additional_mount_outputs_use_virtual_paths() {
691 let workspace: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
692 let volume: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
693 let fs = MountFs::new(workspace).with_mount("/workspace/roots/backend", volume, "/");
694
695 let written = fs
696 .write_file(
697 sid(),
698 "/workspace/roots/backend/Cargo.toml",
699 "name = \"backend\"",
700 "text",
701 )
702 .await
703 .unwrap();
704 assert_eq!(written.path, "/workspace/roots/backend/Cargo.toml");
705
706 let stat = fs
707 .stat_file(sid(), "/workspace/roots/backend/Cargo.toml")
708 .await
709 .unwrap()
710 .unwrap();
711 assert_eq!(stat.path, "/workspace/roots/backend/Cargo.toml");
712 assert_eq!(
713 fs.display_path(&stat.path),
714 "/workspace/roots/backend/Cargo.toml"
715 );
716 assert_eq!(
717 fs.resolve_path("/workspace/roots/backend/Cargo.toml"),
718 "/workspace/roots/backend/Cargo.toml"
719 );
720 }
721
722 #[tokio::test]
723 async fn grep_without_path_searches_all_mounts() {
724 let workspace: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
725 let volume: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
726 let fs = MountFs::new(workspace).with_mount("/workspace/roots/backend", volume, "/");
727
728 fs.write_file(sid(), "/workspace/README.md", "needle primary", "text")
729 .await
730 .unwrap();
731 fs.write_file(
732 sid(),
733 "/workspace/roots/backend/Cargo.toml",
734 "needle backend",
735 "text",
736 )
737 .await
738 .unwrap();
739
740 let matches = fs.grep_files(sid(), "needle", None).await.unwrap();
741 let paths: Vec<_> = matches.into_iter().map(|m| m.path).collect();
742 assert_eq!(
743 paths,
744 vec![
745 "/README.md".to_string(),
746 "/workspace/roots/backend/Cargo.toml".to_string()
747 ]
748 );
749 }
750}