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