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::session_files::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::session_files::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()
613 && (!pp.starts_with('/') || mount_suffix(WORKSPACE_MOUNT, pp).is_some())
614 {
615 let mut matches = Vec::new();
616 for resolved in self.grep_mounts() {
617 matches.extend(
618 resolved
619 .backend
620 .grep_files(session_id, pattern, Some(&resolved.backend_path))
621 .await?
622 .into_iter()
623 .map(|grep_match| resolved.map_grep_match(grep_match))
624 .filter(|grep_match| matcher.is_match(&grep_match.path)),
625 );
626 }
627 matches.sort_by(|a, b| {
628 a.path
629 .cmp(&b.path)
630 .then(a.line_number.cmp(&b.line_number))
631 .then(a.line.cmp(&b.line))
632 });
633 return Ok(matches);
634 }
635 let resolved = self.resolve(pp)?;
636 Ok(resolved
637 .backend
638 .grep_files(session_id, pattern, Some(&resolved.backend_path))
639 .await?
640 .into_iter()
641 .map(|grep_match| resolved.map_grep_match(grep_match))
642 .collect())
643 }
644 None => {
645 let mut matches = Vec::new();
646 for resolved in self.grep_mounts() {
647 matches.extend(
648 resolved
649 .backend
650 .grep_files(session_id, pattern, Some(&resolved.backend_path))
651 .await?
652 .into_iter()
653 .map(|grep_match| resolved.map_grep_match(grep_match)),
654 );
655 }
656 matches.sort_by(|a, b| {
657 a.path
658 .cmp(&b.path)
659 .then(a.line_number.cmp(&b.line_number))
660 .then(a.line.cmp(&b.line))
661 });
662 Ok(matches)
663 }
664 }
665 }
666
667 async fn grep_files_with_options(
668 &self,
669 session_id: SessionId,
670 pattern: &str,
671 options: &GrepOptions,
672 ) -> Result<GrepSearchResult> {
673 if let Some(path_pattern) = options.path_pattern.as_deref()
674 && path_pattern.starts_with('/')
675 && mount_suffix(WORKSPACE_MOUNT, path_pattern).is_none()
676 {
677 let resolved = self.resolve(path_pattern)?;
678 let mut backend_options = options.clone();
679 backend_options.path_pattern = Some(resolved.backend_path.clone());
680 return resolved
681 .backend
682 .grep_files_with_options(session_id, pattern, &backend_options)
683 .await
684 .map(|result| resolved.map_grep_result(result));
685 }
686
687 let mounts = self.grep_mounts();
688 if mounts.len() == 1 {
689 let resolved = &mounts[0];
690 let mut backend_options = options.clone();
691 backend_options.path_pattern = options
694 .path_pattern
695 .as_ref()
696 .map(|path| mount_suffix(WORKSPACE_MOUNT, path).unwrap_or_else(|| path.clone()));
697 return resolved
698 .backend
699 .grep_files_with_options(session_id, pattern, &backend_options)
700 .await
701 .map(|result| resolved.map_grep_result(result));
702 }
703
704 let mut backend_options = options.clone();
705 backend_options.offset = 0;
706 backend_options.limit = usize::MAX;
707 backend_options.max_bytes = usize::MAX;
708 let path_matcher = options
709 .path_pattern
710 .as_deref()
711 .map(crate::session_path::GrepPathPattern::new)
712 .transpose()?;
713 let mut results = Vec::new();
714 for resolved in mounts {
715 let mut mount_options = backend_options.clone();
716 mount_options.path_pattern = Some(resolved.backend_path.clone());
717 let result = resolved
718 .backend
719 .grep_files_with_options(session_id, pattern, &mount_options)
720 .await?;
721 let mut mapped = resolved.map_grep_result(result);
722 if let Some(matcher) = &path_matcher {
723 mapped.matches.retain(|item| matcher.is_match(&item.path));
724 mapped.blocks.retain(|block| matcher.is_match(&block.path));
725 }
726 results.push(mapped);
727 }
728 Ok(crate::session_file::merge_grep_search_results(
729 results, options,
730 ))
731 }
732
733 async fn create_directory(&self, session_id: SessionId, path: &str) -> Result<FileInfo> {
734 let resolved = self.resolve(path)?;
735 Ok(resolved.map_file_info(
736 resolved
737 .backend
738 .create_directory(session_id, &resolved.backend_path)
739 .await?,
740 ))
741 }
742
743 async fn seed_initial_file(&self, session_id: SessionId, file: &InitialFile) -> Result<()> {
744 let resolved = self.resolve(&file.path)?;
745 let seeded = InitialFile {
746 path: resolved.backend_path,
747 content: file.content.clone(),
748 encoding: file.encoding.clone(),
749 is_readonly: file.is_readonly,
750 };
751 resolved
752 .backend
753 .seed_initial_file(session_id, &seeded)
754 .await
755 }
756}
757
758#[cfg(test)]
759mod tests {
760 use super::*;
761 use crate::session_path::GrepPathPattern;
762
763 fn sid() -> SessionId {
764 SessionId::from_seed(1)
765 }
766
767 #[derive(Default)]
770 struct FlatStore {
771 files: std::sync::Mutex<std::collections::HashMap<String, String>>,
772 host_root: Option<String>,
776 }
777
778 #[async_trait]
779 impl SessionFileSystem for FlatStore {
780 fn is_mount_resolver(&self) -> bool {
781 false
782 }
783
784 fn display_root(&self) -> String {
785 match &self.host_root {
786 Some(root) => root.clone(),
787 None => crate::session_path::WORKSPACE_PREFIX.to_string(),
788 }
789 }
790
791 fn display_path(&self, path: &str) -> String {
792 match &self.host_root {
793 Some(root) => {
794 let normalized = normalize_virtual(path, "/");
795 if normalized == "/" {
796 root.clone()
797 } else {
798 format!("{root}{normalized}")
799 }
800 }
801 None => crate::session_path::to_display_path(path),
802 }
803 }
804
805 async fn read_file(&self, sid: SessionId, path: &str) -> Result<Option<SessionFile>> {
806 let files = self.files.lock().unwrap();
807 Ok(files.get(path).map(|content| SessionFile {
808 id: uuid::Uuid::nil(),
809 session_id: sid.uuid(),
810 path: path.to_string(),
811 name: path.rsplit('/').next().unwrap_or("").to_string(),
812 content: Some(content.clone()),
813 encoding: "text".to_string(),
814 is_directory: false,
815 is_readonly: false,
816 size_bytes: content.len() as i64,
817 created_at: chrono::Utc::now(),
818 updated_at: chrono::Utc::now(),
819 }))
820 }
821 async fn write_file(
822 &self,
823 sid: SessionId,
824 path: &str,
825 content: &str,
826 encoding: &str,
827 ) -> Result<SessionFile> {
828 self.files
829 .lock()
830 .unwrap()
831 .insert(path.to_string(), content.to_string());
832 Ok(SessionFile {
833 id: uuid::Uuid::nil(),
834 session_id: sid.uuid(),
835 path: path.to_string(),
836 name: path.rsplit('/').next().unwrap_or("").to_string(),
837 content: Some(content.to_string()),
838 encoding: encoding.to_string(),
839 is_directory: false,
840 is_readonly: false,
841 size_bytes: content.len() as i64,
842 created_at: chrono::Utc::now(),
843 updated_at: chrono::Utc::now(),
844 })
845 }
846 async fn delete_file(&self, _: SessionId, path: &str, _: bool) -> Result<bool> {
847 Ok(self.files.lock().unwrap().remove(path).is_some())
848 }
849 async fn list_directory(&self, _: SessionId, _: &str) -> Result<Vec<FileInfo>> {
850 Ok(vec![])
851 }
852 async fn stat_file(&self, _: SessionId, path: &str) -> Result<Option<FileStat>> {
853 let files = self.files.lock().unwrap();
854 Ok(files.get(path).map(|content| FileStat {
855 path: path.to_string(),
856 name: path.rsplit('/').next().unwrap_or("").to_string(),
857 is_directory: false,
858 is_readonly: false,
859 size_bytes: content.len() as i64,
860 created_at: chrono::Utc::now(),
861 updated_at: chrono::Utc::now(),
862 }))
863 }
864 async fn grep_files(
865 &self,
866 _: SessionId,
867 pattern: &str,
868 path_pattern: Option<&str>,
869 ) -> Result<Vec<GrepMatch>> {
870 let path_pattern = path_pattern.map(GrepPathPattern::new).transpose()?;
871 let files = self.files.lock().unwrap();
872 let mut matches = Vec::new();
873 for (path, content) in files.iter() {
874 if let Some(filter) = &path_pattern
875 && !filter.is_match(path)
876 {
877 continue;
878 }
879 for (idx, line) in content.lines().enumerate() {
880 if line.contains(pattern) {
881 matches.push(GrepMatch {
882 path: path.clone(),
883 line_number: idx + 1,
884 line: line.to_string(),
885 });
886 }
887 }
888 }
889 Ok(matches)
890 }
891 async fn create_directory(&self, sid: SessionId, path: &str) -> Result<FileInfo> {
892 Ok(FileInfo {
893 id: uuid::Uuid::nil(),
894 session_id: sid.uuid(),
895 name: path.rsplit('/').next().unwrap_or("").to_string(),
896 path: path.to_string(),
897 is_directory: true,
898 is_readonly: false,
899 size_bytes: 0,
900 created_at: chrono::Utc::now(),
901 updated_at: chrono::Utc::now(),
902 })
903 }
904 }
905
906 #[test]
907 fn normalize_resolves_relative_against_cwd() {
908 assert_eq!(
909 normalize_virtual("foo/bar", "/workspace"),
910 "/workspace/foo/bar"
911 );
912 assert_eq!(normalize_virtual("/foo", "/workspace"), "/foo");
913 assert_eq!(normalize_virtual("a/../b", "/workspace"), "/workspace/b");
914 assert_eq!(normalize_virtual("../../x", "/workspace"), "/x");
915 assert_eq!(normalize_virtual(".", "/workspace"), "/workspace");
916 assert_eq!(normalize_virtual("/", "/workspace"), "/");
917 }
918
919 #[tokio::test]
920 async fn primary_path_spellings_share_backend_keys_and_session() {
921 let backend: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
922 let fs = MountFs::new(backend.clone());
923 assert_eq!(fs.cwd(), "/workspace");
924 for (input, canonical, read_path) in [
925 ("/workspace/src/lib.rs", "/src/lib.rs", "/src/lib.rs"),
926 ("notes.md", "/notes.md", "/workspace/notes.md"),
927 (
928 "/outputs/call.stdout",
929 "/outputs/call.stdout",
930 "/workspace/outputs/call.stdout",
931 ),
932 ("./src/../other.txt", "/other.txt", "other.txt"),
933 ] {
934 let written = fs.write_file(sid(), input, input, "text").await.unwrap();
935 assert_eq!(written.path, canonical);
936 assert_eq!(written.session_id, sid().uuid());
937 let actual = fs.read_file(sid(), read_path).await.unwrap().unwrap();
938 assert_eq!(actual.path, canonical);
939 assert_eq!(actual.content.as_deref(), Some(input));
940 let stored = backend.read_file(sid(), canonical).await.unwrap().unwrap();
941 assert_eq!(stored.content.as_deref(), Some(input));
942 }
943 }
944
945 #[test]
946 fn default_display_hides_host_identity_and_preserves_workspace_alias() {
947 for host_root in [None, Some("/host/root".to_string())] {
948 let fs = MountFs::new(Arc::new(FlatStore {
949 host_root,
950 ..Default::default()
951 }));
952 assert_eq!(fs.display_root(), "/workspace");
953 assert_eq!(fs.display_path("/src/lib.rs"), "/workspace/src/lib.rs");
954 assert_eq!(fs.display_path("/"), "/workspace");
955 assert_eq!(fs.resolve_path("src/lib.rs"), "/workspace/src/lib.rs");
956 assert_eq!(
957 fs.resolve_path("/workspace/src/lib.rs"),
958 "/workspace/src/lib.rs"
959 );
960 }
961 }
962
963 #[tokio::test]
964 async fn backend_display_exposes_host_paths_while_routing_is_unchanged() {
965 let backend: Arc<dyn SessionFileSystem> = Arc::new(FlatStore {
969 host_root: Some("/host/root".to_string()),
970 ..Default::default()
971 });
972 let fs = MountFs::new(backend.clone()).with_backend_display();
973
974 assert_eq!(fs.display_root(), "/host/root");
975 assert_eq!(fs.display_path("/src/lib.rs"), "/host/root/src/lib.rs");
976 assert_eq!(fs.display_path("/"), "/host/root");
977 assert_eq!(fs.resolve_path("src/lib.rs"), "/host/root/src/lib.rs");
980 assert_eq!(
981 fs.resolve_path("/workspace/src/lib.rs"),
982 "/host/root/src/lib.rs"
983 );
984 fs.write_file(sid(), "src/lib.rs", "source", "text")
985 .await
986 .unwrap();
987 for input in ["src/lib.rs", "/workspace/src/lib.rs", "/src/lib.rs"] {
988 let file = fs.read_file(sid(), input).await.unwrap().unwrap();
989 assert_eq!(file.path, "/src/lib.rs");
990 assert_eq!(file.content.as_deref(), Some("source"));
991 assert_eq!(file.session_id, sid().uuid());
992 }
993 assert!(
994 backend
995 .read_file(sid(), "/host/root/src/lib.rs")
996 .await
997 .unwrap()
998 .is_none()
999 );
1000 }
1001
1002 #[tokio::test]
1003 async fn scoped_prompt_file_store_preserves_backend_native_policy() {
1004 let backend: Arc<dyn SessionFileSystem> = Arc::new(FlatStore {
1008 host_root: Some("/host/root".to_string()),
1009 ..Default::default()
1010 });
1011 let embedder_store: Arc<dyn SessionFileSystem> =
1012 Arc::new(MountFs::new(backend).with_backend_display());
1013
1014 let prompt_store =
1015 scoped_prompt_file_store(embedder_store, crate::typed_id::WorkspaceId::from_seed(91));
1016
1017 assert_eq!(prompt_store.display_root(), "/host/root");
1019 assert_eq!(
1020 prompt_store.display_path("/src/lib.rs"),
1021 "/host/root/src/lib.rs"
1022 );
1023 assert_eq!(
1025 prompt_store.resolve_path("/workspace/src/lib.rs"),
1026 "/host/root/src/lib.rs"
1027 );
1028 let written = prompt_store
1029 .write_file(sid(), "pin.txt", "scoped", "text")
1030 .await
1031 .unwrap();
1032 assert_eq!(
1033 written.session_id,
1034 crate::typed_id::WorkspaceId::from_seed(91).uuid()
1035 );
1036 assert_ne!(written.session_id, sid().uuid());
1037 let read = prompt_store
1038 .read_file(SessionId::from_seed(999), "/workspace/pin.txt")
1039 .await
1040 .unwrap()
1041 .unwrap();
1042 assert_eq!(read.session_id, written.session_id);
1043 assert_eq!(read.content.as_deref(), Some("scoped"));
1044 }
1045
1046 #[tokio::test]
1047 async fn scoped_prompt_file_store_defaults_plain_backend_to_workspace_alias() {
1048 let backend: Arc<dyn SessionFileSystem> = Arc::new(FlatStore {
1052 host_root: Some("/host/root".to_string()),
1053 ..Default::default()
1054 });
1055
1056 let prompt_store =
1057 scoped_prompt_file_store(backend, crate::typed_id::WorkspaceId::from_seed(92));
1058
1059 assert_eq!(prompt_store.display_root(), "/workspace");
1060 assert_eq!(
1061 prompt_store.display_path("/src/lib.rs"),
1062 "/workspace/src/lib.rs"
1063 );
1064 assert_eq!(
1065 prompt_store.resolve_path("src/lib.rs"),
1066 "/workspace/src/lib.rs"
1067 );
1068 let written = prompt_store
1069 .write_file(sid(), "pin.txt", "scoped", "text")
1070 .await
1071 .unwrap();
1072 assert_eq!(
1073 written.session_id,
1074 crate::typed_id::WorkspaceId::from_seed(92).uuid()
1075 );
1076 assert_ne!(written.session_id, sid().uuid());
1077 let read = prompt_store
1078 .read_file(SessionId::from_seed(999), "/workspace/pin.txt")
1079 .await
1080 .unwrap()
1081 .unwrap();
1082 assert_eq!(read.session_id, written.session_id);
1083 assert_eq!(read.content.as_deref(), Some("scoped"));
1084 }
1085
1086 #[tokio::test]
1087 async fn display_preserves_literal_backend_workspace_segment() {
1088 let backend: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
1089 backend
1090 .write_file(sid(), "/workspace/collide.txt", "literal", "text")
1091 .await
1092 .unwrap();
1093 backend
1094 .write_file(sid(), "/collide.txt", "alias", "text")
1095 .await
1096 .unwrap();
1097 let fs = MountFs::new(backend);
1098
1099 let literal = fs
1100 .read_file(sid(), "workspace/collide.txt")
1101 .await
1102 .unwrap()
1103 .unwrap();
1104 let display_path = fs.display_path(&literal.path);
1105 assert_eq!(display_path, "/workspace/workspace/collide.txt");
1106
1107 let round_trip = fs.read_file(sid(), &display_path).await.unwrap().unwrap();
1108 assert_eq!(round_trip.content.as_deref(), Some("literal"));
1109 }
1110
1111 #[tokio::test]
1112 async fn additional_mount_selects_longest_segment_and_maps_backend_root() {
1113 let workspace: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
1114 let volume: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
1115 let nested: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
1116 let fs = MountFs::new(workspace.clone())
1117 .with_mount("/data/deep", nested.clone(), "/nested")
1118 .with_mount("//data/./", volume.clone(), "/prefix/./");
1119 for (input, owner, key) in [
1120 ("/data/report.csv", &volume, "/prefix/report.csv"),
1121 ("/data", &volume, "/prefix"),
1122 ("/data/deep/file.txt", &nested, "/nested/file.txt"),
1123 ("/data/deeper.txt", &volume, "/prefix/deeper.txt"),
1124 ("/database/file.txt", &workspace, "/database/file.txt"),
1125 ("/data/../primary.txt", &workspace, "/primary.txt"),
1126 ] {
1127 fs.write_file(sid(), input, input, "text").await.unwrap();
1128 let actual = owner.read_file(sid(), key).await.unwrap().unwrap();
1129 assert_eq!(actual.content.as_deref(), Some(input));
1130 assert_eq!(actual.session_id, sid().uuid());
1131 for other in [&workspace, &volume, &nested] {
1132 if !Arc::ptr_eq(other, owner) {
1133 assert!(other.read_file(sid(), key).await.unwrap().is_none());
1134 }
1135 }
1136 }
1137 }
1138
1139 #[tokio::test]
1140 async fn additional_mount_outputs_use_virtual_paths() {
1141 let workspace: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
1142 let volume: Arc<dyn SessionFileSystem> = Arc::new(FlatStore {
1143 host_root: Some("/private/volume".into()),
1144 ..Default::default()
1145 });
1146 let fs = MountFs::new(workspace).with_backend_display().with_mount(
1147 "/workspace/roots/backend",
1148 volume.clone(),
1149 "/checkout",
1150 );
1151
1152 let written = fs
1153 .write_file(
1154 sid(),
1155 "/workspace/roots/backend/Cargo.toml",
1156 "name = \"backend\"",
1157 "text",
1158 )
1159 .await
1160 .unwrap();
1161 assert_eq!(written.path, "/workspace/roots/backend/Cargo.toml");
1162
1163 let stat = fs
1164 .stat_file(sid(), "/workspace/roots/backend/Cargo.toml")
1165 .await
1166 .unwrap()
1167 .unwrap();
1168 assert_eq!(stat.path, "/workspace/roots/backend/Cargo.toml");
1169 assert_eq!(
1170 fs.display_path(&stat.path),
1171 "/workspace/roots/backend/Cargo.toml"
1172 );
1173 assert_eq!(
1174 fs.resolve_path("/workspace/roots/backend/Cargo.toml"),
1175 "/workspace/roots/backend/Cargo.toml"
1176 );
1177 assert_eq!(written.name, "Cargo.toml");
1178 assert_eq!(stat.name, "Cargo.toml");
1179 let read = fs
1180 .read_file(sid(), "/workspace/roots/backend/Cargo.toml")
1181 .await
1182 .unwrap()
1183 .unwrap();
1184 assert_eq!(read.path, written.path);
1185 assert_eq!(read.content.as_deref(), Some("name = \"backend\""));
1186 let stored = volume
1187 .read_file(sid(), "/checkout/Cargo.toml")
1188 .await
1189 .unwrap()
1190 .unwrap();
1191 assert_eq!(stored.content, read.content);
1192 let directory = fs
1193 .create_directory(sid(), "/workspace/roots/backend/src")
1194 .await
1195 .unwrap();
1196 assert_eq!(directory.path, "/workspace/roots/backend/src");
1197 assert_eq!(directory.name, "src");
1198 assert!(directory.is_directory);
1199 }
1200
1201 #[tokio::test]
1202 async fn grep_without_path_searches_all_mounts() {
1203 let workspace: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
1204 let volume: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
1205 let fs = MountFs::new(workspace).with_mount("/workspace/roots/backend", volume, "/");
1206
1207 fs.write_file(sid(), "/workspace/README.md", "needle primary", "text")
1208 .await
1209 .unwrap();
1210 fs.write_file(
1211 sid(),
1212 "/workspace/roots/backend/Cargo.toml",
1213 "needle backend",
1214 "text",
1215 )
1216 .await
1217 .unwrap();
1218
1219 let matches = fs.grep_files(sid(), "needle", None).await.unwrap();
1220 let hits: Vec<_> = matches
1221 .iter()
1222 .map(|hit| (hit.path.as_str(), hit.line_number, hit.line.as_str()))
1223 .collect();
1224 assert_eq!(
1225 hits,
1226 [
1227 ("/README.md", 1, "needle primary"),
1228 ("/workspace/roots/backend/Cargo.toml", 1, "needle backend")
1229 ]
1230 );
1231 }
1232
1233 #[tokio::test]
1234 async fn grep_resolves_workspace_glob_to_backend_namespace() {
1235 let backend: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
1236 let fs = MountFs::new(backend);
1237 fs.write_file(sid(), "/workspace/src/lib.rs", "needle", "text")
1238 .await
1239 .unwrap();
1240 fs.write_file(sid(), "/workspace/docs/readme.md", "needle", "text")
1241 .await
1242 .unwrap();
1243
1244 let matches = fs
1245 .grep_files(sid(), "needle", Some("/workspace/src/**/*.rs"))
1246 .await
1247 .unwrap();
1248
1249 let hits: Vec<_> = matches
1250 .iter()
1251 .map(|hit| (hit.path.as_str(), hit.line_number, hit.line.as_str()))
1252 .collect();
1253 assert_eq!(hits, [("/src/lib.rs", 1, "needle")]);
1254 }
1255
1256 #[tokio::test]
1257 async fn grep_glob_searches_every_matching_mount() {
1258 let workspace: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
1259 let volume: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
1260 let fs = MountFs::new(workspace).with_mount("/workspace/roots/backend", volume, "/");
1261 fs.write_file(sid(), "/workspace/Cargo.toml", "needle", "text")
1262 .await
1263 .unwrap();
1264 fs.write_file(
1265 sid(),
1266 "/workspace/roots/backend/Cargo.toml",
1267 "needle",
1268 "text",
1269 )
1270 .await
1271 .unwrap();
1272
1273 fs.write_file(
1274 sid(),
1275 "/workspace/roots/backend/decoy.md",
1276 "needle decoy",
1277 "text",
1278 )
1279 .await
1280 .unwrap();
1281 let matches = fs
1282 .grep_files(sid(), "needle", Some("**/*.toml"))
1283 .await
1284 .unwrap();
1285 let hits: Vec<_> = matches
1286 .iter()
1287 .map(|hit| (hit.path.as_str(), hit.line_number, hit.line.as_str()))
1288 .collect();
1289 assert_eq!(
1290 hits,
1291 [
1292 ("/Cargo.toml", 1, "needle"),
1293 ("/workspace/roots/backend/Cargo.toml", 1, "needle")
1294 ]
1295 );
1296 }
1297
1298 #[test]
1299 fn mount_fs_identifies_as_resolver() {
1300 let workspace: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
1301 let fs = MountFs::wrap(workspace);
1302 assert!(fs.is_mount_resolver());
1303 let again = MountFs::wrap_if_needed(fs.clone());
1304 assert!(Arc::ptr_eq(&fs, &again));
1305 }
1306
1307 #[tokio::test]
1308 async fn grep_options_preserve_workspace_prefix_lookalike_paths() {
1309 let backend: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
1310 backend
1311 .write_file(sid(), "/workspacefoo/target.txt", "needle target", "text")
1312 .await
1313 .unwrap();
1314 backend
1315 .write_file(sid(), "/foo/decoy.txt", "needle decoy", "text")
1316 .await
1317 .unwrap();
1318 let fs = MountFs::new(backend);
1319 for (path, expected) in [
1320 ("/workspacefoo", "/workspacefoo/target.txt"),
1321 ("/workspacefoo/*.txt", "/workspacefoo/target.txt"),
1322 ("/workspace/foo/*.txt", "/foo/decoy.txt"),
1323 ] {
1324 let result = fs
1325 .grep_files_with_options(
1326 sid(),
1327 "needle",
1328 &GrepOptions {
1329 path_pattern: Some(path.into()),
1330 ..Default::default()
1331 },
1332 )
1333 .await
1334 .unwrap();
1335 let paths: Vec<_> = result.matches.iter().map(|hit| hit.path.as_str()).collect();
1336 assert_eq!(paths, [expected], "filter {path}");
1337 let flat = fs.grep_files(sid(), "needle", Some(path)).await.unwrap();
1338 assert_eq!(
1339 flat.iter().map(|hit| hit.path.as_str()).collect::<Vec<_>>(),
1340 [expected],
1341 "flat filter {path}"
1342 );
1343 }
1344 }
1345}