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#[cfg_attr(feature = "contract", derive(ts_rs::TS))]
33#[cfg_attr(feature = "contract", ts(rename = "FileContentInput"))]
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(untagged)]
36pub enum FileContent {
37 Text(String),
38 Bytes(
39 #[serde(with = "serde_bytes")]
40 #[cfg_attr(feature = "contract", ts(type = "Uint8Array"))]
41 Vec<u8>,
42 ),
43}
44
45impl FileContent {
46 pub fn byte_len(&self) -> usize {
47 match self {
48 Self::Text(value) => value.len(),
49 Self::Bytes(value) => value.len(),
50 }
51 }
52
53 pub fn into_bytes(self) -> Vec<u8> {
54 match self {
55 Self::Text(value) => value.into_bytes(),
56 Self::Bytes(value) => value,
57 }
58 }
59}
60
61impl From<String> for FileContent {
62 fn from(value: String) -> Self {
63 FileContent::Text(value)
64 }
65}
66
67impl From<&str> for FileContent {
68 fn from(value: &str) -> Self {
69 FileContent::Text(value.to_string())
70 }
71}
72
73impl From<Vec<u8>> for FileContent {
74 fn from(value: Vec<u8>) -> Self {
75 FileContent::Bytes(value)
76 }
77}
78
79impl From<&[u8]> for FileContent {
80 fn from(value: &[u8]) -> Self {
81 FileContent::Bytes(value.to_vec())
82 }
83}
84
85#[cfg_attr(feature = "contract", derive(ts_rs::TS))]
87#[cfg_attr(feature = "contract", ts(rename = "ActorDirectoryEntry"))]
88#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
89pub struct DirEntry {
90 pub path: String,
91 #[serde(rename = "type")]
92 pub entry_type: DirEntryType,
93 #[serde(rename = "sizeBytes")]
94 #[cfg_attr(feature = "contract", ts(rename = "sizeBytes"))]
95 pub size: u64,
96}
97
98#[cfg_attr(feature = "contract", derive(ts_rs::TS))]
100#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
101#[serde(rename_all = "lowercase")]
102pub enum DirEntryType {
103 File,
104 Directory,
105 Symlink,
106}
107
108#[derive(Debug, Clone, Default, PartialEq, Eq)]
111pub struct ReaddirRecursiveOptions {
112 pub max_depth: Option<u32>,
113 pub exclude: Vec<String>,
114}
115
116#[cfg_attr(feature = "contract", derive(ts_rs::TS))]
118#[cfg_attr(feature = "contract", ts(rename = "FilesystemWriteEntry"))]
119#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
120#[serde(rename_all = "camelCase", deny_unknown_fields)]
121pub struct BatchWriteEntry {
122 pub path: String,
123 pub content: FileContent,
124}
125
126#[cfg_attr(feature = "contract", derive(ts_rs::TS))]
128#[cfg_attr(feature = "contract", ts(rename = "FilesystemWriteResult"))]
129#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
130#[serde(rename_all = "camelCase")]
131pub struct BatchWriteResult {
132 pub path: String,
133 pub success: bool,
134 #[serde(default, skip_serializing_if = "Option::is_none")]
135 pub error: Option<String>,
136}
137
138#[derive(Debug, Clone, PartialEq, Eq)]
140pub struct BatchReadResult {
141 pub path: String,
142 pub content: Option<Vec<u8>>,
143 pub error: Option<String>,
144}
145
146#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
148pub struct MkdirOptions {
149 pub recursive: bool,
150}
151
152#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
154pub struct RemoveOptions {
155 pub recursive: bool,
156}
157
158#[cfg_attr(feature = "contract", derive(ts_rs::TS))]
159#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
160pub struct DynamicMountDescriptor {
161 pub path: String,
162 pub plugin: crate::config::MountPlugin,
163 #[serde(default)]
164 #[serde(rename = "readOnly")]
165 pub read_only: bool,
166}
167
168#[cfg_attr(feature = "contract", derive(ts_rs::TS))]
169#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
170pub struct MountInfo {
171 pub path: String,
172 pub kind: String,
173 #[serde(rename = "readOnly")]
174 pub read_only: bool,
175}
176
177#[cfg_attr(feature = "contract", derive(ts_rs::TS))]
179#[cfg_attr(feature = "contract", ts(rename = "ActorFileStat"))]
180#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
181pub struct VirtualStat {
182 pub mode: u32,
183 #[serde(rename = "sizeBytes")]
184 #[cfg_attr(feature = "contract", ts(rename = "sizeBytes"))]
185 pub size: u64,
186 pub blocks: u64,
187 pub dev: u64,
188 pub rdev: u64,
189 #[serde(rename = "isDirectory")]
190 pub is_directory: bool,
191 #[serde(rename = "isSymbolicLink")]
192 pub is_symbolic_link: bool,
193 #[serde(rename = "atimeMs")]
194 pub atime_ms: f64,
195 #[serde(rename = "mtimeMs")]
196 pub mtime_ms: f64,
197 #[serde(rename = "ctimeMs")]
198 pub ctime_ms: f64,
199 #[serde(rename = "birthtimeMs")]
200 pub birthtime_ms: f64,
201 pub ino: u64,
202 pub nlink: u64,
203 pub uid: u32,
204 pub gid: u32,
205}
206
207#[cfg_attr(feature = "contract", derive(ts_rs::TS))]
209#[cfg_attr(feature = "contract", ts(rename = "FilesystemDirectoryEntry"))]
210#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
211#[serde(rename_all = "camelCase")]
212pub struct VirtualDirEntry {
213 pub name: String,
214 pub is_directory: bool,
215 pub is_symbolic_link: bool,
216}
217
218#[cfg_attr(feature = "contract", derive(ts_rs::TS))]
224#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
225pub struct RootSnapshotExport {
226 pub kind: SnapshotExportKind,
227 pub source: FilesystemSnapshotExport,
228}
229
230#[cfg_attr(feature = "contract", derive(ts_rs::TS))]
232#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
233pub enum SnapshotExportKind {
234 #[serde(rename = "snapshot-export")]
235 SnapshotExport,
236}
237
238#[cfg_attr(feature = "contract", derive(ts_rs::TS))]
240#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
241pub struct FilesystemSnapshotExport {
242 pub format: String,
243 pub filesystem: FilesystemSnapshotEntries,
244}
245
246#[cfg_attr(feature = "contract", derive(ts_rs::TS))]
248#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
249pub struct FilesystemSnapshotEntries {
250 pub entries: Vec<FilesystemEntry>,
251}
252
253#[cfg_attr(feature = "contract", derive(ts_rs::TS))]
255#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
256pub struct FilesystemEntry {
257 pub path: String,
258 #[serde(rename = "type")]
259 pub entry_type: DirEntryType,
260 pub mode: String,
261 pub uid: u32,
262 pub gid: u32,
263 #[serde(default, skip_serializing_if = "Option::is_none")]
264 pub content: Option<String>,
265 #[serde(default, skip_serializing_if = "Option::is_none")]
266 pub encoding: Option<FilesystemEntryEncoding>,
267 #[serde(default, skip_serializing_if = "Option::is_none")]
268 pub target: Option<String>,
269}
270
271#[cfg_attr(feature = "contract", derive(ts_rs::TS))]
273#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
274#[serde(rename_all = "lowercase")]
275pub enum FilesystemEntryEncoding {
276 Utf8,
277 Base64,
278}
279
280#[async_trait]
289pub trait VirtualFileSystem: Send + Sync {
290 async fn read_file(&self, path: &str) -> Result<Vec<u8>>;
291 async fn read_text_file(&self, path: &str) -> Result<String>;
292 async fn read_dir(&self, path: &str) -> Result<Vec<String>>;
293 async fn read_dir_with_types(&self, path: &str) -> Result<Vec<VirtualDirEntry>>;
294 async fn write_file(&self, path: &str, content: &[u8]) -> Result<()>;
295 async fn create_dir(&self, path: &str) -> Result<()>;
296 async fn mkdir(&self, path: &str, recursive: bool) -> Result<()>;
297 async fn exists(&self, path: &str) -> Result<bool>;
298 async fn stat(&self, path: &str) -> Result<VirtualStat>;
299 async fn lstat(&self, path: &str) -> Result<VirtualStat>;
300 async fn remove_file(&self, path: &str) -> Result<()>;
301 async fn remove_dir(&self, path: &str) -> Result<()>;
302 async fn rename(&self, from: &str, to: &str) -> Result<()>;
303 async fn realpath(&self, path: &str) -> Result<String>;
304 async fn symlink(&self, target: &str, path: &str) -> Result<()>;
305 async fn readlink(&self, path: &str) -> Result<String>;
306 async fn link(&self, existing: &str, new_path: &str) -> Result<()>;
307 async fn chmod(&self, path: &str, mode: u32) -> Result<()>;
308 async fn chown(&self, path: &str, uid: u32, gid: u32) -> Result<()>;
309 async fn utimes(&self, path: &str, atime_ms: f64, mtime_ms: f64) -> Result<()>;
310 async fn truncate(&self, path: &str, len: u64) -> Result<()>;
311 async fn pread(&self, path: &str, offset: u64, length: u64) -> Result<Vec<u8>>;
312 async fn pwrite(&self, path: &str, offset: u64, data: &[u8]) -> Result<u64>;
313}
314
315impl AgentOs {
320 pub(crate) fn posix_normalize(path: &str) -> String {
327 if path.is_empty() {
328 return String::from(".");
329 }
330
331 let is_absolute = path.starts_with('/');
332 let trailing_slash = path.ends_with('/');
333
334 let mut segments: Vec<&str> = Vec::new();
335 for part in path.split('/') {
336 match part {
337 "" | "." => {}
338 ".." => {
339 match segments.last().copied() {
340 Some(last) if last != ".." => {
341 segments.pop();
342 }
343 Some(_) | None => {
344 if !is_absolute {
347 segments.push("..");
348 }
349 }
350 }
351 }
352 other => segments.push(other),
353 }
354 }
355
356 let mut joined = segments.join("/");
357 if joined.is_empty() {
358 if is_absolute {
359 return String::from("/");
360 }
361 return String::from(".");
362 }
363
364 if trailing_slash {
365 joined.push('/');
366 }
367 if is_absolute {
368 let mut absolute = String::from("/");
369 absolute.push_str(&joined);
370 absolute
371 } else {
372 joined
373 }
374 }
375
376 pub(crate) fn assert_safe_absolute_path(path: &str) -> std::result::Result<(), ClientError> {
378 if !path.starts_with('/') {
379 return Err(ClientError::PathNotAbsolute(path.to_string()));
380 }
381 if Self::posix_normalize(path) != path {
382 return Err(ClientError::PathNotNormalized(path.to_string()));
383 }
384 Ok(())
385 }
386
387 pub(crate) fn assert_writable_absolute_path(
389 path: &str,
390 ) -> std::result::Result<(), ClientError> {
391 Self::assert_safe_absolute_path(path)?;
392 if path == "/proc"
393 || path.starts_with("/proc/")
394 || path == "/etc/agentos"
395 || path.starts_with("/etc/agentos/")
396 {
397 return Err(ClientError::PathReadOnly(path.to_string()));
398 }
399 Ok(())
400 }
401}
402
403impl AgentOs {
408 fn batch_error_message(err: &anyhow::Error) -> String {
414 match err.downcast_ref::<ClientError>() {
415 Some(client_error) => client_error.batch_message(),
416 None => err.to_string(),
417 }
418 }
419
420 fn fs_vm_scope(&self) -> wire::OwnershipScope {
422 wire::OwnershipScope::VmOwnership(wire::VmOwnership {
423 connection_id: self.connection_id().to_string(),
424 session_id: self.wire_session_id().to_string(),
425 vm_id: self.vm_id().to_string(),
426 })
427 }
428
429 fn join_child(dir: &str, child: &str) -> String {
432 if dir == "/" {
433 format!("/{child}")
434 } else {
435 format!("{dir}/{child}")
436 }
437 }
438
439 async fn guest_fs_call(
442 &self,
443 request: GuestFilesystemCallRequest,
444 ) -> Result<GuestFilesystemResultResponse> {
445 let scope = self.fs_vm_scope();
446 let response = self
447 .transport()
448 .request_wire(
449 scope,
450 wire::RequestPayload::GuestFilesystemCallRequest(request),
451 )
452 .await
453 .context("guest filesystem call failed")?;
454 match response {
455 wire::ResponsePayload::GuestFilesystemResultResponse(result) => Ok(result),
456 wire::ResponsePayload::RejectedResponse(rejected) => {
457 Err(ClientError::from_rejection(rejected).into())
458 }
459 other => Err(anyhow::anyhow!(
460 "unexpected response to guest filesystem call: {other:?}"
461 )),
462 }
463 }
464
465 fn fs_request(
467 operation: GuestFilesystemOperation,
468 path: impl Into<String>,
469 ) -> GuestFilesystemCallRequest {
470 GuestFilesystemCallRequest {
471 operation,
472 path: path.into(),
473 destination_path: None,
474 target: None,
475 content: None,
476 encoding: None,
477 recursive: false,
478 max_depth: None,
479 mode: None,
480 uid: None,
481 gid: None,
482 atime_ms: None,
483 mtime_ms: None,
484 len: None,
485 offset: None,
486 }
487 }
488
489 fn virtual_stat_from(stat: GuestFilesystemStat) -> VirtualStat {
492 VirtualStat {
493 mode: stat.mode,
494 size: stat.size,
495 blocks: stat.blocks,
496 dev: stat.dev,
497 rdev: stat.rdev,
498 is_directory: stat.is_directory,
499 is_symbolic_link: stat.is_symbolic_link,
500 atime_ms: stat.atime_ms as f64,
501 mtime_ms: stat.mtime_ms as f64,
502 ctime_ms: stat.ctime_ms as f64,
503 birthtime_ms: stat.birthtime_ms as f64,
504 ino: stat.ino,
505 nlink: stat.nlink,
506 uid: stat.uid,
507 gid: stat.gid,
508 }
509 }
510
511 async fn kernel_read_file(&self, path: &str) -> Result<Vec<u8>> {
517 let result = self
518 .guest_fs_call(Self::fs_request(GuestFilesystemOperation::ReadFile, path))
519 .await?;
520 let content = result
521 .content
522 .with_context(|| format!("sidecar returned no file content for {path}"))?;
523 match result.encoding {
524 Some(RootFilesystemEntryEncoding::Base64) => BASE64
525 .decode(content.as_bytes())
526 .context("decoding base64 file content"),
527 Some(RootFilesystemEntryEncoding::Utf8) | None => Ok(content.into_bytes()),
528 }
529 }
530
531 async fn kernel_write_file(&self, path: &str, content: &FileContent) -> Result<()> {
535 let (encoded, encoding) = match content {
536 FileContent::Text(text) => (text.clone(), None),
537 FileContent::Bytes(bytes) => (
538 BASE64.encode(bytes),
539 Some(RootFilesystemEntryEncoding::Base64),
540 ),
541 };
542 let mut request = Self::fs_request(GuestFilesystemOperation::WriteFile, path);
543 request.content = Some(encoded);
544 request.encoding = encoding;
545 self.guest_fs_call(request).await?;
546 Ok(())
547 }
548
549 async fn kernel_mkdir(&self, path: &str) -> Result<()> {
555 self.guest_fs_call(Self::fs_request(GuestFilesystemOperation::CreateDir, path))
556 .await?;
557 Ok(())
558 }
559
560 async fn kernel_exists(&self, path: &str) -> Result<bool> {
561 let result = self
562 .guest_fs_call(Self::fs_request(GuestFilesystemOperation::Exists, path))
563 .await?;
564 Ok(result.exists.unwrap_or(false))
565 }
566
567 async fn kernel_readdir(&self, path: &str) -> Result<Vec<String>> {
568 let result = self
569 .guest_fs_call(Self::fs_request(GuestFilesystemOperation::ReadDir, path))
570 .await?;
571 Ok(result
576 .entries
577 .unwrap_or_default()
578 .into_iter()
579 .map(|entry| entry.name)
580 .collect())
581 }
582
583 async fn kernel_readdir_recursive(
584 &self,
585 path: &str,
586 max_depth: Option<u32>,
587 ) -> Result<Vec<wire::GuestDirEntry>> {
588 let mut request = Self::fs_request(GuestFilesystemOperation::ReadDirRecursive, path);
589 request.max_depth = max_depth;
590 let result = self.guest_fs_call(request).await?;
591 Ok(result.entries.unwrap_or_default())
592 }
593
594 async fn kernel_stat(&self, path: &str) -> Result<VirtualStat> {
595 let result = self
596 .guest_fs_call(Self::fs_request(GuestFilesystemOperation::Stat, path))
597 .await?;
598 let stat = result.stat.context("stat response missing stat payload")?;
599 Ok(Self::virtual_stat_from(stat))
600 }
601
602 async fn kernel_lstat(&self, path: &str) -> Result<VirtualStat> {
603 let result = self
604 .guest_fs_call(Self::fs_request(GuestFilesystemOperation::Lstat, path))
605 .await?;
606 let stat = result.stat.context("lstat response missing stat payload")?;
607 Ok(Self::virtual_stat_from(stat))
608 }
609
610 async fn kernel_remove_path(&self, path: &str, recursive: bool) -> Result<()> {
611 let mut request = Self::fs_request(GuestFilesystemOperation::Remove, path);
612 request.recursive = recursive;
613 self.guest_fs_call(request).await?;
614 Ok(())
615 }
616
617 async fn kernel_move_path(&self, from: &str, to: &str) -> Result<()> {
618 let mut request = Self::fs_request(GuestFilesystemOperation::Move, from);
619 request.destination_path = Some(to.to_string());
620 request.recursive = true;
621 self.guest_fs_call(request).await?;
622 Ok(())
623 }
624
625 async fn mkdirp(&self, path: &str) -> Result<()> {
628 Self::assert_writable_absolute_path(path)?;
629 let mut current = String::new();
630 for part in path.split('/').filter(|p| !p.is_empty()) {
631 current.push('/');
632 current.push_str(part);
633 if !self.kernel_exists(¤t).await? {
634 self.kernel_mkdir(¤t).await?;
635 }
636 }
637 Ok(())
638 }
639}
640
641impl AgentOs {
646 pub async fn read_file(&self, path: &str) -> Result<Vec<u8>> {
648 Self::assert_safe_absolute_path(path)?;
649 self.kernel_read_file(path).await
650 }
651
652 pub async fn write_file(&self, path: &str, content: impl Into<FileContent>) -> Result<()> {
654 Self::assert_writable_absolute_path(path)?;
655 let content = content.into();
656 self.kernel_write_file(path, &content).await
657 }
658
659 pub async fn write_files(&self, entries: Vec<BatchWriteEntry>) -> Vec<BatchWriteResult> {
661 let mut results = Vec::with_capacity(entries.len());
662 for entry in entries {
663 let outcome: Result<()> = async {
664 Self::assert_writable_absolute_path(&entry.path)?;
665 if let Some(idx) = entry.path.rfind('/') {
668 let parent = &entry.path[..idx];
669 if !parent.is_empty() {
670 self.mkdirp(parent).await?;
671 }
672 }
673 self.kernel_write_file(&entry.path, &entry.content).await?;
674 Ok(())
675 }
676 .await;
677 match outcome {
678 Ok(()) => results.push(BatchWriteResult {
679 path: entry.path,
680 success: true,
681 error: None,
682 }),
683 Err(err) => results.push(BatchWriteResult {
684 path: entry.path,
685 success: false,
686 error: Some(Self::batch_error_message(&err)),
687 }),
688 }
689 }
690 results
691 }
692
693 pub async fn read_files(&self, paths: Vec<String>) -> Vec<BatchReadResult> {
695 let mut results = Vec::with_capacity(paths.len());
696 for path in paths {
697 let outcome: Result<Vec<u8>> = async {
698 Self::assert_safe_absolute_path(&path)?;
699 self.kernel_read_file(&path).await
700 }
701 .await;
702 match outcome {
703 Ok(content) => results.push(BatchReadResult {
704 path,
705 content: Some(content),
706 error: None,
707 }),
708 Err(err) => results.push(BatchReadResult {
709 path,
710 content: None,
711 error: Some(Self::batch_error_message(&err)),
712 }),
713 }
714 }
715 results
716 }
717
718 pub async fn mkdir(&self, path: &str, options: MkdirOptions) -> Result<()> {
721 if options.recursive {
722 return self.mkdirp(path).await;
723 }
724 Self::assert_writable_absolute_path(path)?;
725 self.kernel_mkdir(path).await
726 }
727
728 pub async fn readdir(&self, path: &str) -> Result<Vec<String>> {
730 Self::assert_safe_absolute_path(path)?;
731 self.kernel_readdir(path).await
732 }
733
734 pub async fn read_dir_with_types(&self, path: &str) -> Result<Vec<VirtualDirEntry>> {
738 Self::assert_safe_absolute_path(path)?;
739 let names = self.kernel_readdir(path).await?;
740 let mut entries = Vec::with_capacity(names.len());
741 for name in names {
742 if name == "." || name == ".." {
743 continue;
744 }
745 let full_path = Self::join_child(path, &name);
746 let stat = self.kernel_lstat(&full_path).await?;
747 entries.push(VirtualDirEntry {
748 name,
749 is_directory: stat.is_directory,
750 is_symbolic_link: stat.is_symbolic_link,
751 });
752 }
753 Ok(entries)
754 }
755
756 pub async fn readdir_recursive(
758 &self,
759 path: &str,
760 options: ReaddirRecursiveOptions,
761 ) -> Result<Vec<DirEntry>> {
762 Self::assert_safe_absolute_path(path)?;
763 let exclude: std::collections::HashSet<&str> =
764 options.exclude.iter().map(String::as_str).collect();
765 let entries = self
766 .kernel_readdir_recursive(path, options.max_depth)
767 .await?;
768 let mut excluded_prefixes: Vec<String> = Vec::new();
769 let mut results: Vec<DirEntry> = Vec::new();
770
771 for entry in entries {
772 if excluded_prefixes.iter().any(|prefix| {
773 entry.path == *prefix || entry.path.starts_with(&format!("{prefix}/"))
774 }) {
775 continue;
776 }
777 if exclude.contains(entry.name.as_str()) {
778 if entry.is_directory && !entry.is_symbolic_link {
779 excluded_prefixes.push(entry.path);
780 }
781 continue;
782 }
783
784 let entry_type = if entry.is_symbolic_link {
785 DirEntryType::Symlink
786 } else if entry.is_directory {
787 DirEntryType::Directory
788 } else {
789 DirEntryType::File
790 };
791 results.push(DirEntry {
792 path: entry.path,
793 entry_type,
794 size: entry.size,
795 });
796 }
797
798 Ok(results)
799 }
800
801 pub async fn readdir_entries(&self, path: &str) -> Result<Vec<VirtualDirEntry>> {
803 Self::assert_safe_absolute_path(path)?;
804 Ok(self
805 .kernel_readdir_recursive(path, Some(0))
806 .await?
807 .into_iter()
808 .map(|entry| VirtualDirEntry {
809 name: entry.name,
810 is_directory: entry.is_directory,
811 is_symbolic_link: entry.is_symbolic_link,
812 })
813 .collect())
814 }
815
816 pub async fn stat(&self, path: &str) -> Result<VirtualStat> {
818 Self::assert_safe_absolute_path(path)?;
819 self.kernel_stat(path).await
820 }
821
822 pub async fn exists(&self, path: &str) -> Result<bool> {
824 Self::assert_safe_absolute_path(path)?;
825 self.kernel_exists(path).await
826 }
827
828 pub async fn export_root_filesystem(&self, max_bytes: usize) -> Result<RootSnapshotExport> {
830 if max_bytes == 0 {
831 return Err(ClientError::Sidecar("max_bytes must be greater than zero".into()).into());
832 }
833 let scope = self.fs_vm_scope();
834 let max_bytes_u64 = u64::try_from(max_bytes)
835 .map_err(|_| ClientError::Sidecar("max_bytes exceeds u64".into()))?;
836 let response = self
837 .transport()
838 .request_wire(
839 scope,
840 wire::RequestPayload::SnapshotRootFilesystemRequest(
841 wire::SnapshotRootFilesystemRequest {
842 max_bytes: max_bytes_u64,
843 },
844 ),
845 )
846 .await
847 .context("snapshot root filesystem failed")?;
848 let snapshot = match response {
849 wire::ResponsePayload::RootFilesystemSnapshotResponse(snapshot) => snapshot,
850 wire::ResponsePayload::RejectedResponse(rejected) => {
851 return Err(ClientError::from_rejection(rejected).into());
852 }
853 other => {
854 return Err(anyhow::anyhow!(
855 "unexpected response to snapshot root filesystem: {other:?}"
856 ));
857 }
858 };
859
860 let entries = snapshot
861 .entries
862 .into_iter()
863 .map(Self::snapshot_entry_from)
864 .collect::<Result<Vec<_>>>()?;
865
866 let snapshot = RootSnapshotExport {
867 kind: SnapshotExportKind::SnapshotExport,
868 source: FilesystemSnapshotExport {
869 format: String::from("agentos-filesystem-snapshot-v1"),
870 filesystem: FilesystemSnapshotEntries { entries },
871 },
872 };
873 let size = serde_json::to_vec(&snapshot)
874 .context("serializing root filesystem export for bound check")?
875 .len();
876 if size > max_bytes {
877 return Err(ClientError::Sidecar(format!(
878 "root filesystem export is {size} bytes, limit is {max_bytes}; raise max_bytes to export this filesystem"
879 )).into());
880 }
881 Ok(snapshot)
882 }
883
884 pub async fn mount_fs(&self, descriptor: DynamicMountDescriptor) -> Result<()> {
886 let _vm_configuration = self.inner().vm_configuration_operation.lock().await;
887 Self::assert_safe_absolute_path(&descriptor.path)?;
888 let config = descriptor
889 .plugin
890 .config
891 .unwrap_or_else(|| serde_json::json!({}));
892 let plugin_id = descriptor.plugin.id;
893 let mount = wire::MountDescriptor {
894 guest_path: descriptor.path,
895 guest_source: plugin_id.clone(),
896 guest_fstype: plugin_id.clone(),
897 read_only: descriptor.read_only,
898 plugin: wire::MountPluginDescriptor {
899 id: plugin_id,
900 config: serde_json::to_string(&config)
901 .context("serializing dynamic mount config")?,
902 },
903 };
904 {
905 let mut mounts = self.inner().dynamic_mounts.lock();
906 if mounts
907 .iter()
908 .any(|existing| existing.guest_path == mount.guest_path)
909 {
910 return Err(ClientError::Sidecar(format!(
911 "mount already exists: {}",
912 mount.guest_path
913 ))
914 .into());
915 }
916 mounts.push(mount);
917 }
918 if let Err(error) = self.reconfigure_dynamic_mounts().await {
919 self.inner().dynamic_mounts.lock().pop();
920 return Err(error);
921 }
922 Ok(())
923 }
924
925 pub async fn unmount_fs(&self, path: &str) -> Result<()> {
926 let _vm_configuration = self.inner().vm_configuration_operation.lock().await;
927 Self::assert_safe_absolute_path(path)?;
928 let removed = {
929 let mut mounts = self.inner().dynamic_mounts.lock();
930 mounts
931 .iter()
932 .position(|mount| mount.guest_path == path)
933 .map(|index| (index, mounts.remove(index)))
934 };
935 let Some((index, mount)) = removed else {
936 return Ok(());
937 };
938 if let Err(error) = self.reconfigure_dynamic_mounts().await {
939 self.inner().dynamic_mounts.lock().insert(index, mount);
940 return Err(error);
941 }
942 Ok(())
943 }
944
945 pub async fn list_mounts(&self) -> Result<Vec<MountInfo>> {
946 let response = self
947 .transport()
948 .request_wire(self.fs_vm_scope(), wire::RequestPayload::ListMountsRequest)
949 .await?;
950 match response {
951 wire::ResponsePayload::ListMountsResponse(response) => Ok(response
952 .mounts
953 .into_iter()
954 .map(|mount| MountInfo {
955 path: mount.path,
956 kind: mount.kind,
957 read_only: mount.read_only,
958 })
959 .collect()),
960 wire::ResponsePayload::RejectedResponse(rejected) => {
961 Err(ClientError::from_rejection(rejected).into())
962 }
963 other => Err(ClientError::Sidecar(format!(
964 "unexpected list mounts response: {other:?}"
965 ))
966 .into()),
967 }
968 }
969
970 async fn reconfigure_dynamic_mounts(&self) -> Result<()> {
971 let inner = self.inner();
972 let config = &inner.config;
973 let mounts = inner.dynamic_mounts.lock().clone();
974 let response = self
975 .transport()
976 .request_wire(
977 self.fs_vm_scope(),
978 wire::RequestPayload::ConfigureVmRequest(wire::ConfigureVmRequest {
979 mounts,
980 software: Vec::new(),
981 permissions: None,
984 module_access_cwd: None,
985 instructions: Vec::new(),
986 projected_modules: Vec::new(),
987 command_permissions: std::collections::HashMap::new(),
988 loopback_exempt_ports: config.loopback_exempt_ports.clone(),
989 packages: crate::agent_os::build_package_descriptors(config),
990 packages_mount_at: config.packages_mount_at.clone().unwrap_or_default(),
991 bootstrap_commands: Vec::new(),
992 host_function_shim_commands: Vec::new(),
993 }),
994 )
995 .await?;
996 match response {
997 wire::ResponsePayload::VmConfiguredResponse(_) => Ok(()),
998 wire::ResponsePayload::RejectedResponse(rejected) => {
999 Err(ClientError::from_rejection(rejected).into())
1000 }
1001 other => Err(ClientError::Sidecar(format!(
1002 "unexpected dynamic mount reconfigure response: {other:?}"
1003 ))
1004 .into()),
1005 }
1006 }
1007
1008 pub async fn move_path(&self, from: &str, to: &str) -> Result<()> {
1011 Self::assert_writable_absolute_path(from)?;
1012 Self::assert_writable_absolute_path(to)?;
1013 self.kernel_move_path(from, to).await
1014 }
1015
1016 pub async fn remove(&self, path: &str, options: RemoveOptions) -> Result<()> {
1019 Self::assert_writable_absolute_path(path)?;
1020 self.kernel_remove_path(path, options.recursive).await
1021 }
1022
1023 fn snapshot_entry_from(entry: RootFilesystemEntry) -> Result<FilesystemEntry> {
1033 let entry_type = match entry.kind {
1034 RootFilesystemEntryKind::File => DirEntryType::File,
1035 RootFilesystemEntryKind::Directory => DirEntryType::Directory,
1036 RootFilesystemEntryKind::Symlink => DirEntryType::Symlink,
1037 };
1038 let fallback_mode = match entry.kind {
1041 RootFilesystemEntryKind::Directory => 0o755,
1042 RootFilesystemEntryKind::Symlink => 0o777,
1043 RootFilesystemEntryKind::File => 0o644,
1044 };
1045 let mode = format!("0{:o}", entry.mode.unwrap_or(fallback_mode) & 0o7777);
1046 let uid = entry.uid.unwrap_or(0);
1047 let gid = entry.gid.unwrap_or(0);
1048
1049 match entry.kind {
1050 RootFilesystemEntryKind::File => {
1051 let encoding = match entry.encoding {
1052 Some(RootFilesystemEntryEncoding::Utf8) | None => FilesystemEntryEncoding::Utf8,
1053 Some(RootFilesystemEntryEncoding::Base64) => FilesystemEntryEncoding::Base64,
1054 };
1055 Ok(FilesystemEntry {
1056 path: entry.path,
1057 entry_type,
1058 mode,
1059 uid,
1060 gid,
1061 content: Some(entry.content.unwrap_or_default()),
1062 encoding: Some(encoding),
1063 target: None,
1064 })
1065 }
1066 RootFilesystemEntryKind::Symlink => {
1067 let target = entry.target.with_context(|| {
1068 format!(
1069 "sidecar root snapshot for {} is missing a symlink target",
1070 entry.path
1071 )
1072 })?;
1073 Ok(FilesystemEntry {
1074 path: entry.path,
1075 entry_type,
1076 mode,
1077 uid,
1078 gid,
1079 content: None,
1080 encoding: None,
1081 target: Some(target),
1082 })
1083 }
1084 RootFilesystemEntryKind::Directory => Ok(FilesystemEntry {
1085 path: entry.path,
1086 entry_type,
1087 mode,
1088 uid,
1089 gid,
1090 content: None,
1091 encoding: None,
1092 target: None,
1093 }),
1094 }
1095 }
1096}