Skip to main content

agentos_client/
fs.rs

1//! Filesystem methods + path guards + supporting types + the in-process [`VirtualFileSystem`] mount
2//! contract.
3//!
4//! Ported from `packages/core/src/agent-os.ts` (fs methods + `_assertSafeAbsolutePath` /
5//! `_assertWritableAbsolutePath`), `runtime-compat.ts` (`VirtualStat`, `VirtualFileSystem`), and
6//! `filesystem-snapshot.ts` (snapshot export types).
7//!
8//! Parity notes: every method runs the path guards first; `mkdir` recursive uses the WRITABLE guard,
9//! non-recursive uses the SAFE guard. `writeFile` does NOT create parents; `writeFiles` DOES. Batch
10//! methods NEVER reject (per-entry error strings). Snapshot wire format keeps octal-string `mode`
11//! and `utf8`/`base64` content verbatim.
12
13use 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// ---------------------------------------------------------------------------
28// Supporting types
29// ---------------------------------------------------------------------------
30
31/// `string | Uint8Array` file content.
32#[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/// An entry returned by `readdir_recursive`.
86#[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/// The type of a directory entry.
99#[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/// Options for `readdir_recursive`. `max_depth` None = unlimited, Some(0) = immediate children only;
109/// `exclude` matches basenames at any depth.
110#[derive(Debug, Clone, Default, PartialEq, Eq)]
111pub struct ReaddirRecursiveOptions {
112    pub max_depth: Option<u32>,
113    pub exclude: Vec<String>,
114}
115
116/// A batch write entry.
117#[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/// Result of a single batch write (never an `Err`).
127#[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/// Result of a single batch read (never an `Err`). `content` is None on failure.
139#[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/// Options for `mkdir`.
147#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
148pub struct MkdirOptions {
149    pub recursive: bool,
150}
151
152/// Options for `remove`.
153#[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/// Stat result. 16 fields; `*_ms` time fields are `f64` (JS ms, possibly fractional).
178#[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/// A directory entry with a known type, returned by `read_dir_with_types` on the mount contract.
208#[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// ---------------------------------------------------------------------------
219// Snapshot export wire types (octal-string mode, utf8/base64 content)
220// ---------------------------------------------------------------------------
221
222/// `{ kind: "snapshot-export"; source }`.
223#[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/// The literal `"snapshot-export"` tag.
231#[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/// `{ format: "agentos-filesystem-snapshot-v1"; filesystem: { entries } }`.
239#[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/// `{ entries: FilesystemEntry[] }`.
247#[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/// A single snapshot entry. `mode` is an OCTAL STRING (e.g. `"0755"`). `content` is utf8 or base64.
254#[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/// Snapshot content encoding.
272#[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// ---------------------------------------------------------------------------
281// VirtualFileSystem mount contract (in-process trait object for mount_fs)
282// ---------------------------------------------------------------------------
283
284/// The 25-method mount backend contract. A `mount_fs` driver implements this trait; it is a live
285/// in-process object and cannot cross an RPC boundary.
286///
287/// TODO(parity: confirm exact method set/signatures against runtime-compat.ts before first impl).
288#[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
315// ---------------------------------------------------------------------------
316// Path guards
317// ---------------------------------------------------------------------------
318
319impl AgentOs {
320    /// Posix-normalize a path the same way Node's `path.posix.normalize` does.
321    ///
322    /// Matches Node semantics: collapse `.`/`..` segments and duplicate separators, preserve a
323    /// trailing slash when present, keep a leading slash for absolute paths, and return `.` for an
324    /// empty result. Above-root `..` segments on an absolute path are discarded; on a relative path
325    /// they are retained.
326    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                            // Retain leading `..` only on relative paths; on absolute paths the
345                            // segment is silently discarded (cannot go above root).
346                            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    /// Throws `PathNotAbsolute` if not absolute, `PathNotNormalized` if not in normalized form.
377    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    /// Runs the safe guard, then rejects writes to read-only paths.
388    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
403// ---------------------------------------------------------------------------
404// Internal helpers (guest filesystem RPC + path joins)
405// ---------------------------------------------------------------------------
406
407impl AgentOs {
408    /// Render a batch-method error the way the TypeScript `AgentOs` surfaces `err.message` into
409    /// `BatchWriteResult.error` / `BatchReadResult.error`. The error may be a bare [`ClientError`]
410    /// (path guards) or an [`anyhow::Error`] wrapping one (kernel RPC failures via
411    /// [`Self::guest_fs_call`]), so downcast to recover the exact TS message; otherwise fall back to
412    /// the anyhow chain string.
413    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    /// Build the VM-scoped ownership for guest filesystem RPCs.
421    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    /// Join a parent directory with a child basename the way the TS fs code does (special-casing the
430    /// root so it does not produce a leading `//`).
431    fn join_child(dir: &str, child: &str) -> String {
432        if dir == "/" {
433            format!("/{child}")
434        } else {
435            format!("{dir}/{child}")
436        }
437    }
438
439    /// Issue a single guest filesystem RPC and return the typed result, mapping a sidecar
440    /// `Rejected` response into a [`ClientError::Kernel`] so the errno `code` survives for parity.
441    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    /// A guest filesystem call carrying only an operation + path (the common case).
466    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    /// Convert a wire [`GuestFilesystemStat`] into the public [`VirtualStat`] (`*_ms` widened to
490    /// `f64` to match JS millisecond precision).
491    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    // --- low-level kernel ops (each maps to one guest filesystem RPC) ---
512
513    /// Mirrors TS `decodeGuestFilesystemContent`: a missing `content` field is a hard error
514    /// (`sidecar returned no file content for <path>`, fail-by-default), `base64` is decoded, and
515    /// any other/absent encoding is treated as utf8 bytes.
516    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    /// Mirrors TS `encodeGuestFilesystemContent`: string content is sent verbatim with NO `encoding`
532    /// field (the sidecar defaults absent encoding to utf8); byte content is base64-encoded and
533    /// carries `encoding: "base64"`.
534    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    /// Single-level directory creation. Mirrors TS `kernel.mkdir(path)` (no options), which the
550    /// native client maps to the `create_dir` guest filesystem operation. This backs BOTH
551    /// `AgentOs::mkdir` (non-recursive) and every `_mkdirp` component, so it always emits
552    /// [`GuestFilesystemOperation::CreateDir`] (never `Mkdir`, which the native client reserves for
553    /// the recursive `kernel.mkdir(path, { recursive: true })` shape that this code path never uses).
554    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        // agentos's READ_DIR now returns rich entries (`entries:
572        // list<GuestDirEntry>` with name + is_directory + is_symbolic_link);
573        // this name-only accessor projects the basenames. The richer fields back
574        // the typed [`Self::read_dir_with_types`] path.
575        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    /// Recursively create directories (`mkdir -p`). Uses the WRITABLE guard, then walks each path
626    /// component and creates the ones that do not yet exist (mirrors TS `_mkdirp`).
627    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(&current).await? {
634                self.kernel_mkdir(&current).await?;
635            }
636        }
637        Ok(())
638    }
639}
640
641// ---------------------------------------------------------------------------
642// Filesystem methods
643// ---------------------------------------------------------------------------
644
645impl AgentOs {
646    /// Read a file's raw bytes (no decode).
647    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    /// Write a file. Writable-path guard; does NOT auto-create parents; `Text` -> UTF-8.
653    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    /// Batch write. Sequential; never rejects (per-entry error); auto-creates parent dirs.
660    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                // Create parent directories as needed. TS slices off everything after the last `/`;
666                // for a path like `/foo` this yields an empty parent which is skipped.
667                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    /// Batch read. Sequential; never rejects; `content` None on failure.
694    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    /// Make a directory. Recursive -> writable guard + mkdirp; non-recursive -> safe guard + single
719    /// level. The guard asymmetry is load-bearing.
720    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    /// List basenames (may include `.`/`..`).
729    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    /// List directory entries with their resolved type, mirroring the TS `readDirWithTypes` used by
735    /// a typed `fs/readDir` host request. `.`/`..` are filtered by the caller. A symlink is reported
736    /// as a symlink (lstat-style, not followed); other entries are stat'd as directory vs file.
737    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    /// Recursive BFS listing; symlinks recorded but NOT descended; a stat failure aborts the call.
757    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    /// Return typed immediate children using one sidecar filesystem operation.
802    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    /// Stat (follows symlinks).
817    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    /// Existence check. Safe-path guard still errors; missing path -> false.
823    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    /// Export the root filesystem snapshot. Octal-string mode + utf8/base64 content verbatim.
829    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    /// Mount a portable sidecar-owned filesystem descriptor.
885    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                    // Dynamic mount reconfiguration must retain the VM policy
982                    // resolved at creation, not reconstruct it in the client.
983                    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    /// Move a path through the sidecar primitive. The kernel attempts rename first, then falls back
1009    /// to recursive copy+remove on EXDEV.
1010    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    /// Delete a path through the sidecar primitive. Non-recursive directory deletes preserve
1017    /// ENOTEMPTY semantics.
1018    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    /// Convert a wire [`RootFilesystemEntry`] into the public snapshot [`FilesystemEntry`],
1024    /// preserving the octal-string `mode` and verbatim utf8/base64 `content`/`target`.
1025    ///
1026    /// Mirrors TS `convertSidecarRootSnapshotEntries` + `toSnapshotModeString` exactly:
1027    /// - `mode` falls back kind-dependently when absent (directory 0o755, symlink 0o777, file 0o644).
1028    /// - file entries ALWAYS carry `content` (defaulting to `""`) and `encoding` (defaulting to
1029    ///   `utf8`); directory/symlink entries carry neither.
1030    /// - symlink entries REQUIRE a `target`; a missing target is a hard error (fail-by-default),
1031    ///   matching the TS `throw`.
1032    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        // Kind-dependent permission-bit fallback, then octal string with leading `0` masked to the
1039        // permission bits, matching TS `toSnapshotModeString`.
1040        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}