1use anyhow::{Context, Result};
14use async_trait::async_trait;
15use base64::engine::general_purpose::STANDARD as BASE64;
16use base64::Engine as _;
17use serde::{Deserialize, Serialize};
18
19use agentos_sidecar_client::wire::{
20 self, GuestFilesystemCallRequest, GuestFilesystemOperation, GuestFilesystemResultResponse,
21 GuestFilesystemStat, RootFilesystemEntry, RootFilesystemEntryEncoding, RootFilesystemEntryKind,
22};
23
24use crate::agent_os::AgentOs;
25use crate::error::ClientError;
26
27#[derive(Debug, Clone, PartialEq, Eq)]
33pub enum FileContent {
34 Text(String),
35 Bytes(Vec<u8>),
36}
37
38impl From<String> for FileContent {
39 fn from(value: String) -> Self {
40 FileContent::Text(value)
41 }
42}
43
44impl From<&str> for FileContent {
45 fn from(value: &str) -> Self {
46 FileContent::Text(value.to_string())
47 }
48}
49
50impl From<Vec<u8>> for FileContent {
51 fn from(value: Vec<u8>) -> Self {
52 FileContent::Bytes(value)
53 }
54}
55
56impl From<&[u8]> for FileContent {
57 fn from(value: &[u8]) -> Self {
58 FileContent::Bytes(value.to_vec())
59 }
60}
61
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64pub struct DirEntry {
65 pub path: String,
66 #[serde(rename = "type")]
67 pub entry_type: DirEntryType,
68 pub size: u64,
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
73#[serde(rename_all = "lowercase")]
74pub enum DirEntryType {
75 File,
76 Directory,
77 Symlink,
78}
79
80#[derive(Debug, Clone, Default, PartialEq, Eq)]
83pub struct ReaddirRecursiveOptions {
84 pub max_depth: Option<u32>,
85 pub exclude: Vec<String>,
86}
87
88#[derive(Debug, Clone, PartialEq, Eq)]
90pub struct BatchWriteEntry {
91 pub path: String,
92 pub content: FileContent,
93}
94
95#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct BatchWriteResult {
98 pub path: String,
99 pub success: bool,
100 pub error: Option<String>,
101}
102
103#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct BatchReadResult {
106 pub path: String,
107 pub content: Option<Vec<u8>>,
108 pub error: Option<String>,
109}
110
111#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
113pub struct MkdirOptions {
114 pub recursive: bool,
115}
116
117#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
119pub struct RemoveOptions {
120 pub recursive: bool,
121}
122
123#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
124pub struct DynamicMountDescriptor {
125 pub path: String,
126 pub plugin: crate::config::MountPlugin,
127 #[serde(default)]
128 #[serde(rename = "readOnly")]
129 pub read_only: bool,
130}
131
132#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
133pub struct MountInfo {
134 pub path: String,
135 pub kind: String,
136 #[serde(rename = "readOnly")]
137 pub read_only: bool,
138}
139
140#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
142pub struct VirtualStat {
143 pub mode: u32,
144 pub size: u64,
145 pub blocks: u64,
146 pub dev: u64,
147 pub rdev: u64,
148 #[serde(rename = "isDirectory")]
149 pub is_directory: bool,
150 #[serde(rename = "isSymbolicLink")]
151 pub is_symbolic_link: bool,
152 #[serde(rename = "atimeMs")]
153 pub atime_ms: f64,
154 #[serde(rename = "mtimeMs")]
155 pub mtime_ms: f64,
156 #[serde(rename = "ctimeMs")]
157 pub ctime_ms: f64,
158 #[serde(rename = "birthtimeMs")]
159 pub birthtime_ms: f64,
160 pub ino: u64,
161 pub nlink: u64,
162 pub uid: u32,
163 pub gid: u32,
164}
165
166#[derive(Debug, Clone, PartialEq, Eq)]
168pub struct VirtualDirEntry {
169 pub name: String,
170 pub is_directory: bool,
171 pub is_symbolic_link: bool,
172}
173
174#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
180pub struct RootSnapshotExport {
181 pub kind: SnapshotExportKind,
182 pub source: FilesystemSnapshotExport,
183}
184
185#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
187pub enum SnapshotExportKind {
188 #[serde(rename = "snapshot-export")]
189 SnapshotExport,
190}
191
192#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
194pub struct FilesystemSnapshotExport {
195 pub format: String,
196 pub filesystem: FilesystemSnapshotEntries,
197}
198
199#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
201pub struct FilesystemSnapshotEntries {
202 pub entries: Vec<FilesystemEntry>,
203}
204
205#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
207pub struct FilesystemEntry {
208 pub path: String,
209 #[serde(rename = "type")]
210 pub entry_type: DirEntryType,
211 pub mode: String,
212 pub uid: u32,
213 pub gid: u32,
214 #[serde(default, skip_serializing_if = "Option::is_none")]
215 pub content: Option<String>,
216 #[serde(default, skip_serializing_if = "Option::is_none")]
217 pub encoding: Option<FilesystemEntryEncoding>,
218 #[serde(default, skip_serializing_if = "Option::is_none")]
219 pub target: Option<String>,
220}
221
222#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
224#[serde(rename_all = "lowercase")]
225pub enum FilesystemEntryEncoding {
226 Utf8,
227 Base64,
228}
229
230#[async_trait]
239pub trait VirtualFileSystem: Send + Sync {
240 async fn read_file(&self, path: &str) -> Result<Vec<u8>>;
241 async fn read_text_file(&self, path: &str) -> Result<String>;
242 async fn read_dir(&self, path: &str) -> Result<Vec<String>>;
243 async fn read_dir_with_types(&self, path: &str) -> Result<Vec<VirtualDirEntry>>;
244 async fn write_file(&self, path: &str, content: &[u8]) -> Result<()>;
245 async fn create_dir(&self, path: &str) -> Result<()>;
246 async fn mkdir(&self, path: &str, recursive: bool) -> Result<()>;
247 async fn exists(&self, path: &str) -> Result<bool>;
248 async fn stat(&self, path: &str) -> Result<VirtualStat>;
249 async fn lstat(&self, path: &str) -> Result<VirtualStat>;
250 async fn remove_file(&self, path: &str) -> Result<()>;
251 async fn remove_dir(&self, path: &str) -> Result<()>;
252 async fn rename(&self, from: &str, to: &str) -> Result<()>;
253 async fn realpath(&self, path: &str) -> Result<String>;
254 async fn symlink(&self, target: &str, path: &str) -> Result<()>;
255 async fn readlink(&self, path: &str) -> Result<String>;
256 async fn link(&self, existing: &str, new_path: &str) -> Result<()>;
257 async fn chmod(&self, path: &str, mode: u32) -> Result<()>;
258 async fn chown(&self, path: &str, uid: u32, gid: u32) -> Result<()>;
259 async fn utimes(&self, path: &str, atime_ms: f64, mtime_ms: f64) -> Result<()>;
260 async fn truncate(&self, path: &str, len: u64) -> Result<()>;
261 async fn pread(&self, path: &str, offset: u64, length: u64) -> Result<Vec<u8>>;
262 async fn pwrite(&self, path: &str, offset: u64, data: &[u8]) -> Result<u64>;
263}
264
265impl AgentOs {
270 pub(crate) fn posix_normalize(path: &str) -> String {
277 if path.is_empty() {
278 return String::from(".");
279 }
280
281 let is_absolute = path.starts_with('/');
282 let trailing_slash = path.ends_with('/');
283
284 let mut segments: Vec<&str> = Vec::new();
285 for part in path.split('/') {
286 match part {
287 "" | "." => {}
288 ".." => {
289 match segments.last().copied() {
290 Some(last) if last != ".." => {
291 segments.pop();
292 }
293 Some(_) | None => {
294 if !is_absolute {
297 segments.push("..");
298 }
299 }
300 }
301 }
302 other => segments.push(other),
303 }
304 }
305
306 let mut joined = segments.join("/");
307 if joined.is_empty() {
308 if is_absolute {
309 return String::from("/");
310 }
311 return String::from(".");
312 }
313
314 if trailing_slash {
315 joined.push('/');
316 }
317 if is_absolute {
318 let mut absolute = String::from("/");
319 absolute.push_str(&joined);
320 absolute
321 } else {
322 joined
323 }
324 }
325
326 pub(crate) fn assert_safe_absolute_path(path: &str) -> std::result::Result<(), ClientError> {
328 if !path.starts_with('/') {
329 return Err(ClientError::PathNotAbsolute(path.to_string()));
330 }
331 if Self::posix_normalize(path) != path {
332 return Err(ClientError::PathNotNormalized(path.to_string()));
333 }
334 Ok(())
335 }
336
337 pub(crate) fn assert_writable_absolute_path(
339 path: &str,
340 ) -> std::result::Result<(), ClientError> {
341 Self::assert_safe_absolute_path(path)?;
342 if path == "/proc"
343 || path.starts_with("/proc/")
344 || path == "/etc/agentos"
345 || path.starts_with("/etc/agentos/")
346 {
347 return Err(ClientError::PathReadOnly(path.to_string()));
348 }
349 Ok(())
350 }
351}
352
353impl AgentOs {
358 fn batch_error_message(err: &anyhow::Error) -> String {
364 match err.downcast_ref::<ClientError>() {
365 Some(client_error) => client_error.batch_message(),
366 None => err.to_string(),
367 }
368 }
369
370 fn fs_vm_scope(&self) -> wire::OwnershipScope {
372 wire::OwnershipScope::VmOwnership(wire::VmOwnership {
373 connection_id: self.connection_id().to_string(),
374 session_id: self.wire_session_id().to_string(),
375 vm_id: self.vm_id().to_string(),
376 })
377 }
378
379 fn join_child(dir: &str, child: &str) -> String {
382 if dir == "/" {
383 format!("/{child}")
384 } else {
385 format!("{dir}/{child}")
386 }
387 }
388
389 async fn guest_fs_call(
392 &self,
393 request: GuestFilesystemCallRequest,
394 ) -> Result<GuestFilesystemResultResponse> {
395 let scope = self.fs_vm_scope();
396 let response = self
397 .transport()
398 .request_wire(
399 scope,
400 wire::RequestPayload::GuestFilesystemCallRequest(request),
401 )
402 .await
403 .context("guest filesystem call failed")?;
404 match response {
405 wire::ResponsePayload::GuestFilesystemResultResponse(result) => Ok(result),
406 wire::ResponsePayload::RejectedResponse(rejected) => {
407 Err(ClientError::from_rejection(rejected).into())
408 }
409 other => Err(anyhow::anyhow!(
410 "unexpected response to guest filesystem call: {other:?}"
411 )),
412 }
413 }
414
415 fn fs_request(
417 operation: GuestFilesystemOperation,
418 path: impl Into<String>,
419 ) -> GuestFilesystemCallRequest {
420 GuestFilesystemCallRequest {
421 operation,
422 path: path.into(),
423 destination_path: None,
424 target: None,
425 content: None,
426 encoding: None,
427 recursive: false,
428 max_depth: None,
429 mode: None,
430 uid: None,
431 gid: None,
432 atime_ms: None,
433 mtime_ms: None,
434 len: None,
435 offset: None,
436 }
437 }
438
439 fn virtual_stat_from(stat: GuestFilesystemStat) -> VirtualStat {
442 VirtualStat {
443 mode: stat.mode,
444 size: stat.size,
445 blocks: stat.blocks,
446 dev: stat.dev,
447 rdev: stat.rdev,
448 is_directory: stat.is_directory,
449 is_symbolic_link: stat.is_symbolic_link,
450 atime_ms: stat.atime_ms as f64,
451 mtime_ms: stat.mtime_ms as f64,
452 ctime_ms: stat.ctime_ms as f64,
453 birthtime_ms: stat.birthtime_ms as f64,
454 ino: stat.ino,
455 nlink: stat.nlink,
456 uid: stat.uid,
457 gid: stat.gid,
458 }
459 }
460
461 async fn kernel_read_file(&self, path: &str) -> Result<Vec<u8>> {
467 let result = self
468 .guest_fs_call(Self::fs_request(GuestFilesystemOperation::ReadFile, path))
469 .await?;
470 let content = result
471 .content
472 .with_context(|| format!("sidecar returned no file content for {path}"))?;
473 match result.encoding {
474 Some(RootFilesystemEntryEncoding::Base64) => BASE64
475 .decode(content.as_bytes())
476 .context("decoding base64 file content"),
477 Some(RootFilesystemEntryEncoding::Utf8) | None => Ok(content.into_bytes()),
478 }
479 }
480
481 async fn kernel_write_file(&self, path: &str, content: &FileContent) -> Result<()> {
485 let (encoded, encoding) = match content {
486 FileContent::Text(text) => (text.clone(), None),
487 FileContent::Bytes(bytes) => (
488 BASE64.encode(bytes),
489 Some(RootFilesystemEntryEncoding::Base64),
490 ),
491 };
492 let mut request = Self::fs_request(GuestFilesystemOperation::WriteFile, path);
493 request.content = Some(encoded);
494 request.encoding = encoding;
495 self.guest_fs_call(request).await?;
496 Ok(())
497 }
498
499 async fn kernel_mkdir(&self, path: &str) -> Result<()> {
505 self.guest_fs_call(Self::fs_request(GuestFilesystemOperation::CreateDir, path))
506 .await?;
507 Ok(())
508 }
509
510 async fn kernel_exists(&self, path: &str) -> Result<bool> {
511 let result = self
512 .guest_fs_call(Self::fs_request(GuestFilesystemOperation::Exists, path))
513 .await?;
514 Ok(result.exists.unwrap_or(false))
515 }
516
517 async fn kernel_readdir(&self, path: &str) -> Result<Vec<String>> {
518 let result = self
519 .guest_fs_call(Self::fs_request(GuestFilesystemOperation::ReadDir, path))
520 .await?;
521 Ok(result
526 .entries
527 .unwrap_or_default()
528 .into_iter()
529 .map(|entry| entry.name)
530 .collect())
531 }
532
533 async fn kernel_readdir_recursive(
534 &self,
535 path: &str,
536 max_depth: Option<u32>,
537 ) -> Result<Vec<wire::GuestDirEntry>> {
538 let mut request = Self::fs_request(GuestFilesystemOperation::ReadDirRecursive, path);
539 request.max_depth = max_depth;
540 let result = self.guest_fs_call(request).await?;
541 Ok(result.entries.unwrap_or_default())
542 }
543
544 async fn kernel_stat(&self, path: &str) -> Result<VirtualStat> {
545 let result = self
546 .guest_fs_call(Self::fs_request(GuestFilesystemOperation::Stat, path))
547 .await?;
548 let stat = result.stat.context("stat response missing stat payload")?;
549 Ok(Self::virtual_stat_from(stat))
550 }
551
552 async fn kernel_lstat(&self, path: &str) -> Result<VirtualStat> {
553 let result = self
554 .guest_fs_call(Self::fs_request(GuestFilesystemOperation::Lstat, path))
555 .await?;
556 let stat = result.stat.context("lstat response missing stat payload")?;
557 Ok(Self::virtual_stat_from(stat))
558 }
559
560 async fn kernel_remove_path(&self, path: &str, recursive: bool) -> Result<()> {
561 let mut request = Self::fs_request(GuestFilesystemOperation::Remove, path);
562 request.recursive = recursive;
563 self.guest_fs_call(request).await?;
564 Ok(())
565 }
566
567 async fn kernel_move_path(&self, from: &str, to: &str) -> Result<()> {
568 let mut request = Self::fs_request(GuestFilesystemOperation::Move, from);
569 request.destination_path = Some(to.to_string());
570 request.recursive = true;
571 self.guest_fs_call(request).await?;
572 Ok(())
573 }
574
575 async fn mkdirp(&self, path: &str) -> Result<()> {
578 Self::assert_writable_absolute_path(path)?;
579 let mut current = String::new();
580 for part in path.split('/').filter(|p| !p.is_empty()) {
581 current.push('/');
582 current.push_str(part);
583 if !self.kernel_exists(¤t).await? {
584 self.kernel_mkdir(¤t).await?;
585 }
586 }
587 Ok(())
588 }
589}
590
591impl AgentOs {
596 pub async fn read_file(&self, path: &str) -> Result<Vec<u8>> {
598 Self::assert_safe_absolute_path(path)?;
599 self.kernel_read_file(path).await
600 }
601
602 pub async fn write_file(&self, path: &str, content: impl Into<FileContent>) -> Result<()> {
604 Self::assert_writable_absolute_path(path)?;
605 let content = content.into();
606 self.kernel_write_file(path, &content).await
607 }
608
609 pub async fn write_files(&self, entries: Vec<BatchWriteEntry>) -> Vec<BatchWriteResult> {
611 let mut results = Vec::with_capacity(entries.len());
612 for entry in entries {
613 let outcome: Result<()> = async {
614 Self::assert_writable_absolute_path(&entry.path)?;
615 if let Some(idx) = entry.path.rfind('/') {
618 let parent = &entry.path[..idx];
619 if !parent.is_empty() {
620 self.mkdirp(parent).await?;
621 }
622 }
623 self.kernel_write_file(&entry.path, &entry.content).await?;
624 Ok(())
625 }
626 .await;
627 match outcome {
628 Ok(()) => results.push(BatchWriteResult {
629 path: entry.path,
630 success: true,
631 error: None,
632 }),
633 Err(err) => results.push(BatchWriteResult {
634 path: entry.path,
635 success: false,
636 error: Some(Self::batch_error_message(&err)),
637 }),
638 }
639 }
640 results
641 }
642
643 pub async fn read_files(&self, paths: Vec<String>) -> Vec<BatchReadResult> {
645 let mut results = Vec::with_capacity(paths.len());
646 for path in paths {
647 let outcome: Result<Vec<u8>> = async {
648 Self::assert_safe_absolute_path(&path)?;
649 self.kernel_read_file(&path).await
650 }
651 .await;
652 match outcome {
653 Ok(content) => results.push(BatchReadResult {
654 path,
655 content: Some(content),
656 error: None,
657 }),
658 Err(err) => results.push(BatchReadResult {
659 path,
660 content: None,
661 error: Some(Self::batch_error_message(&err)),
662 }),
663 }
664 }
665 results
666 }
667
668 pub async fn mkdir(&self, path: &str, options: MkdirOptions) -> Result<()> {
671 if options.recursive {
672 return self.mkdirp(path).await;
673 }
674 Self::assert_writable_absolute_path(path)?;
675 self.kernel_mkdir(path).await
676 }
677
678 pub async fn readdir(&self, path: &str) -> Result<Vec<String>> {
680 Self::assert_safe_absolute_path(path)?;
681 self.kernel_readdir(path).await
682 }
683
684 pub(crate) async fn acp_read_dir_with_types(&self, path: &str) -> Result<Vec<VirtualDirEntry>> {
688 Self::assert_safe_absolute_path(path)?;
689 let names = self.kernel_readdir(path).await?;
690 let mut entries = Vec::with_capacity(names.len());
691 for name in names {
692 if name == "." || name == ".." {
693 continue;
694 }
695 let full_path = Self::join_child(path, &name);
696 let stat = self.kernel_lstat(&full_path).await?;
697 entries.push(VirtualDirEntry {
698 name,
699 is_directory: stat.is_directory,
700 is_symbolic_link: stat.is_symbolic_link,
701 });
702 }
703 Ok(entries)
704 }
705
706 pub async fn read_dir_with_types(&self, path: &str) -> Result<Vec<VirtualDirEntry>> {
711 self.acp_read_dir_with_types(path).await
712 }
713
714 pub async fn readdir_recursive(
716 &self,
717 path: &str,
718 options: ReaddirRecursiveOptions,
719 ) -> Result<Vec<DirEntry>> {
720 Self::assert_safe_absolute_path(path)?;
721 let exclude: std::collections::HashSet<&str> =
722 options.exclude.iter().map(String::as_str).collect();
723 let entries = self
724 .kernel_readdir_recursive(path, options.max_depth)
725 .await?;
726 let mut excluded_prefixes: Vec<String> = Vec::new();
727 let mut results: Vec<DirEntry> = Vec::new();
728
729 for entry in entries {
730 if excluded_prefixes.iter().any(|prefix| {
731 entry.path == *prefix || entry.path.starts_with(&format!("{prefix}/"))
732 }) {
733 continue;
734 }
735 if exclude.contains(entry.name.as_str()) {
736 if entry.is_directory && !entry.is_symbolic_link {
737 excluded_prefixes.push(entry.path);
738 }
739 continue;
740 }
741
742 let entry_type = if entry.is_symbolic_link {
743 DirEntryType::Symlink
744 } else if entry.is_directory {
745 DirEntryType::Directory
746 } else {
747 DirEntryType::File
748 };
749 results.push(DirEntry {
750 path: entry.path,
751 entry_type,
752 size: entry.size,
753 });
754 }
755
756 Ok(results)
757 }
758
759 pub async fn readdir_entries(&self, path: &str) -> Result<Vec<VirtualDirEntry>> {
761 Self::assert_safe_absolute_path(path)?;
762 Ok(self
763 .kernel_readdir_recursive(path, Some(0))
764 .await?
765 .into_iter()
766 .map(|entry| VirtualDirEntry {
767 name: entry.name,
768 is_directory: entry.is_directory,
769 is_symbolic_link: entry.is_symbolic_link,
770 })
771 .collect())
772 }
773
774 pub async fn stat(&self, path: &str) -> Result<VirtualStat> {
776 Self::assert_safe_absolute_path(path)?;
777 self.kernel_stat(path).await
778 }
779
780 pub async fn exists(&self, path: &str) -> Result<bool> {
782 Self::assert_safe_absolute_path(path)?;
783 self.kernel_exists(path).await
784 }
785
786 pub async fn export_root_filesystem(&self, max_bytes: usize) -> Result<RootSnapshotExport> {
788 if max_bytes == 0 {
789 return Err(ClientError::Sidecar("max_bytes must be greater than zero".into()).into());
790 }
791 let scope = self.fs_vm_scope();
792 let max_bytes_u64 = u64::try_from(max_bytes)
793 .map_err(|_| ClientError::Sidecar("max_bytes exceeds u64".into()))?;
794 let response = self
795 .transport()
796 .request_wire(
797 scope,
798 wire::RequestPayload::SnapshotRootFilesystemRequest(
799 wire::SnapshotRootFilesystemRequest {
800 max_bytes: max_bytes_u64,
801 },
802 ),
803 )
804 .await
805 .context("snapshot root filesystem failed")?;
806 let snapshot = match response {
807 wire::ResponsePayload::RootFilesystemSnapshotResponse(snapshot) => snapshot,
808 wire::ResponsePayload::RejectedResponse(rejected) => {
809 return Err(ClientError::from_rejection(rejected).into());
810 }
811 other => {
812 return Err(anyhow::anyhow!(
813 "unexpected response to snapshot root filesystem: {other:?}"
814 ));
815 }
816 };
817
818 let entries = snapshot
819 .entries
820 .into_iter()
821 .map(Self::snapshot_entry_from)
822 .collect::<Result<Vec<_>>>()?;
823
824 let snapshot = RootSnapshotExport {
825 kind: SnapshotExportKind::SnapshotExport,
826 source: FilesystemSnapshotExport {
827 format: String::from("agentos-filesystem-snapshot-v1"),
828 filesystem: FilesystemSnapshotEntries { entries },
829 },
830 };
831 let size = serde_json::to_vec(&snapshot)
832 .context("serializing root filesystem export for bound check")?
833 .len();
834 if size > max_bytes {
835 return Err(ClientError::Sidecar(format!(
836 "root filesystem export is {size} bytes, limit is {max_bytes}; raise max_bytes to export this filesystem"
837 )).into());
838 }
839 Ok(snapshot)
840 }
841
842 pub async fn mount_fs(&self, descriptor: DynamicMountDescriptor) -> Result<()> {
844 Self::assert_safe_absolute_path(&descriptor.path)?;
845 let config = descriptor
846 .plugin
847 .config
848 .unwrap_or_else(|| serde_json::json!({}));
849 let plugin_id = descriptor.plugin.id;
850 let mount = wire::MountDescriptor {
851 guest_path: descriptor.path,
852 guest_source: plugin_id.clone(),
853 guest_fstype: plugin_id.clone(),
854 read_only: descriptor.read_only,
855 plugin: wire::MountPluginDescriptor {
856 id: plugin_id,
857 config: serde_json::to_string(&config)
858 .context("serializing dynamic mount config")?,
859 },
860 };
861 {
862 let mut mounts = self.inner().dynamic_mounts.lock();
863 if mounts
864 .iter()
865 .any(|existing| existing.guest_path == mount.guest_path)
866 {
867 return Err(ClientError::Sidecar(format!(
868 "mount already exists: {}",
869 mount.guest_path
870 ))
871 .into());
872 }
873 mounts.push(mount);
874 }
875 if let Err(error) = self.reconfigure_dynamic_mounts().await {
876 self.inner().dynamic_mounts.lock().pop();
877 return Err(error);
878 }
879 Ok(())
880 }
881
882 pub async fn unmount_fs(&self, path: &str) -> Result<()> {
883 Self::assert_safe_absolute_path(path)?;
884 let removed = {
885 let mut mounts = self.inner().dynamic_mounts.lock();
886 mounts
887 .iter()
888 .position(|mount| mount.guest_path == path)
889 .map(|index| (index, mounts.remove(index)))
890 };
891 let Some((index, mount)) = removed else {
892 return Ok(());
893 };
894 if let Err(error) = self.reconfigure_dynamic_mounts().await {
895 self.inner().dynamic_mounts.lock().insert(index, mount);
896 return Err(error);
897 }
898 Ok(())
899 }
900
901 pub async fn list_mounts(&self) -> Result<Vec<MountInfo>> {
902 let response = self
903 .transport()
904 .request_wire(self.fs_vm_scope(), wire::RequestPayload::ListMountsRequest)
905 .await?;
906 match response {
907 wire::ResponsePayload::ListMountsResponse(response) => Ok(response
908 .mounts
909 .into_iter()
910 .map(|mount| MountInfo {
911 path: mount.path,
912 kind: mount.kind,
913 read_only: mount.read_only,
914 })
915 .collect()),
916 wire::ResponsePayload::RejectedResponse(rejected) => {
917 Err(ClientError::from_rejection(rejected).into())
918 }
919 other => Err(ClientError::Sidecar(format!(
920 "unexpected list mounts response: {other:?}"
921 ))
922 .into()),
923 }
924 }
925
926 async fn reconfigure_dynamic_mounts(&self) -> Result<()> {
927 let inner = self.inner();
928 let config = &inner.config;
929 let mounts = inner.dynamic_mounts.lock().clone();
930 let response = self
931 .transport()
932 .request_wire(
933 self.fs_vm_scope(),
934 wire::RequestPayload::ConfigureVmRequest(wire::ConfigureVmRequest {
935 mounts,
936 software: Vec::new(),
937 permissions: Some(crate::agent_os::permissions_policy(config)),
938 module_access_cwd: None,
939 instructions: config.additional_instructions.clone().into_iter().collect(),
940 projected_modules: Vec::new(),
941 command_permissions: std::collections::HashMap::new(),
942 loopback_exempt_ports: config.loopback_exempt_ports.clone(),
943 packages: crate::agent_os::build_package_descriptors(config),
944 packages_mount_at: config.packages_mount_at.clone().unwrap_or_default(),
945 bootstrap_commands: Vec::new(),
946 binding_shim_commands: Vec::new(),
947 }),
948 )
949 .await?;
950 match response {
951 wire::ResponsePayload::VmConfiguredResponse(_) => Ok(()),
952 wire::ResponsePayload::RejectedResponse(rejected) => {
953 Err(ClientError::from_rejection(rejected).into())
954 }
955 other => Err(ClientError::Sidecar(format!(
956 "unexpected dynamic mount reconfigure response: {other:?}"
957 ))
958 .into()),
959 }
960 }
961
962 pub async fn move_path(&self, from: &str, to: &str) -> Result<()> {
965 Self::assert_writable_absolute_path(from)?;
966 Self::assert_writable_absolute_path(to)?;
967 self.kernel_move_path(from, to).await
968 }
969
970 pub async fn remove(&self, path: &str, options: RemoveOptions) -> Result<()> {
973 Self::assert_writable_absolute_path(path)?;
974 self.kernel_remove_path(path, options.recursive).await
975 }
976
977 fn snapshot_entry_from(entry: RootFilesystemEntry) -> Result<FilesystemEntry> {
987 let entry_type = match entry.kind {
988 RootFilesystemEntryKind::File => DirEntryType::File,
989 RootFilesystemEntryKind::Directory => DirEntryType::Directory,
990 RootFilesystemEntryKind::Symlink => DirEntryType::Symlink,
991 };
992 let fallback_mode = match entry.kind {
995 RootFilesystemEntryKind::Directory => 0o755,
996 RootFilesystemEntryKind::Symlink => 0o777,
997 RootFilesystemEntryKind::File => 0o644,
998 };
999 let mode = format!("0{:o}", entry.mode.unwrap_or(fallback_mode) & 0o7777);
1000 let uid = entry.uid.unwrap_or(0);
1001 let gid = entry.gid.unwrap_or(0);
1002
1003 match entry.kind {
1004 RootFilesystemEntryKind::File => {
1005 let encoding = match entry.encoding {
1006 Some(RootFilesystemEntryEncoding::Utf8) | None => FilesystemEntryEncoding::Utf8,
1007 Some(RootFilesystemEntryEncoding::Base64) => FilesystemEntryEncoding::Base64,
1008 };
1009 Ok(FilesystemEntry {
1010 path: entry.path,
1011 entry_type,
1012 mode,
1013 uid,
1014 gid,
1015 content: Some(entry.content.unwrap_or_default()),
1016 encoding: Some(encoding),
1017 target: None,
1018 })
1019 }
1020 RootFilesystemEntryKind::Symlink => {
1021 let target = entry.target.with_context(|| {
1022 format!(
1023 "sidecar root snapshot for {} is missing a symlink target",
1024 entry.path
1025 )
1026 })?;
1027 Ok(FilesystemEntry {
1028 path: entry.path,
1029 entry_type,
1030 mode,
1031 uid,
1032 gid,
1033 content: None,
1034 encoding: None,
1035 target: Some(target),
1036 })
1037 }
1038 RootFilesystemEntryKind::Directory => Ok(FilesystemEntry {
1039 path: entry.path,
1040 entry_type,
1041 mode,
1042 uid,
1043 gid,
1044 content: None,
1045 encoding: None,
1046 target: None,
1047 }),
1048 }
1049 }
1050}