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