1use 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
33pub const WORKSPACE_MOUNT: &str = crate::session_path::WORKSPACE_PREFIX;
38
39#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
79pub enum DisplayPolicy {
80 #[default]
85 WorkspaceAlias,
86 BackendNative,
91}
92
93#[derive(Clone)]
95struct Mount {
96 mount_point: String,
99 backend: Arc<dyn SessionFileSystem>,
101 backend_root: String,
103 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
119pub struct MountFs {
122 mounts: Vec<Mount>,
125 primary: Arc<dyn SessionFileSystem>,
127 cwd: String,
131 display_policy: DisplayPolicy,
135}
136
137impl MountFs {
138 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 pub fn with_display_policy(mut self, policy: DisplayPolicy) -> Self {
171 self.display_policy = policy;
172 self
173 }
174
175 pub fn with_backend_display(self) -> Self {
185 self.with_display_policy(DisplayPolicy::BackendNative)
186 }
187
188 fn present_primary_key(&self, canonical_key: &str) -> String {
192 match self.display_policy {
193 DisplayPolicy::WorkspaceAlias => display_backend_path(WORKSPACE_MOUNT, canonical_key),
195 DisplayPolicy::BackendNative => self.primary.display_path(canonical_key),
197 }
198 }
199
200 pub fn wrap(workspace: Arc<dyn SessionFileSystem>) -> Arc<dyn SessionFileSystem> {
202 Arc::new(Self::new(workspace))
203 }
204
205 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 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 pub fn cwd(&self) -> String {
237 self.cwd.clone()
238 }
239
240 fn sort_mounts(&mut self) {
241 self.mounts
243 .sort_by_key(|m| std::cmp::Reverse(m.mount_point.len()));
244 }
245
246 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 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
341pub 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
376fn 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
431fn mount_suffix(mount_point: &str, virtual_path: &str) -> Option<String> {
435 if mount_point == "/" {
436 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
448fn 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
459fn 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 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 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 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 #[derive(Default)]
771 struct FlatStore {
772 files: std::sync::Mutex<std::collections::HashMap<String, String>>,
773 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 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 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 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 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 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 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 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 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 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 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 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 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}