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/// Room for the frame envelope in one write-files request.
28const WRITE_FILES_FRAME_OVERHEAD_BYTES: usize = 64 * 1024;
29/// Per-entry length prefixes in one write-files request.
30const WRITE_FILES_ENTRY_OVERHEAD_BYTES: usize = 16;
31
32// ---------------------------------------------------------------------------
33// Supporting types
34// ---------------------------------------------------------------------------
35
36/// `string | Uint8Array` file content.
37#[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/// An entry returned by `readdir_recursive`.
91#[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/// The type of a directory entry.
104#[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/// Options for `readdir_recursive`. `max_depth` None = unlimited, Some(0) = immediate children only;
114/// `exclude` matches basenames at any depth.
115#[derive(Debug, Clone, Default, PartialEq, Eq)]
116pub struct ReaddirRecursiveOptions {
117    pub max_depth: Option<u32>,
118    pub exclude: Vec<String>,
119}
120
121/// A batch write entry.
122#[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/// Result of a single batch write (never an `Err`).
132#[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/// Result of a single batch read (never an `Err`). `content` is None on failure.
144#[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/// Options for `mkdir`.
152#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
153pub struct MkdirOptions {
154    pub recursive: bool,
155}
156
157/// Options for `remove`.
158#[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/// Stat result. 16 fields; `*_ms` time fields are `f64` (JS ms, possibly fractional).
183#[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/// A directory entry with a known type, returned by `read_dir_with_types` on the mount contract.
213#[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// ---------------------------------------------------------------------------
224// Snapshot export wire types (octal-string mode, utf8/base64 content)
225// ---------------------------------------------------------------------------
226
227/// `{ kind: "snapshot-export"; source }`.
228#[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/// The literal `"snapshot-export"` tag.
236#[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/// `{ format: "agentos-filesystem-snapshot-v1"; filesystem: { entries } }`.
244#[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/// `{ entries: FilesystemEntry[] }`.
252#[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/// A single snapshot entry. `mode` is an OCTAL STRING (e.g. `"0755"`). `content` is utf8 or base64.
259#[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/// Snapshot content encoding.
277#[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// ---------------------------------------------------------------------------
286// VirtualFileSystem mount contract (in-process trait object for mount_fs)
287// ---------------------------------------------------------------------------
288
289/// The 25-method mount backend contract. A `mount_fs` driver implements this trait; it is a live
290/// in-process object and cannot cross an RPC boundary.
291///
292/// TODO(parity: confirm exact method set/signatures against runtime-compat.ts before first impl).
293#[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
320// ---------------------------------------------------------------------------
321// Path guards
322// ---------------------------------------------------------------------------
323
324impl AgentOs {
325    /// Posix-normalize a path the same way Node's `path.posix.normalize` does.
326    ///
327    /// Matches Node semantics: collapse `.`/`..` segments and duplicate separators, preserve a
328    /// trailing slash when present, keep a leading slash for absolute paths, and return `.` for an
329    /// empty result. Above-root `..` segments on an absolute path are discarded; on a relative path
330    /// they are retained.
331    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                            // Retain leading `..` only on relative paths; on absolute paths the
350                            // segment is silently discarded (cannot go above root).
351                            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    /// Throws `PathNotAbsolute` if not absolute, `PathNotNormalized` if not in normalized form.
382    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    /// Runs the safe guard, then rejects writes to read-only paths.
393    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
408// ---------------------------------------------------------------------------
409// Internal helpers (guest filesystem RPC + path joins)
410// ---------------------------------------------------------------------------
411
412impl AgentOs {
413    /// Render a batch-method error the way the TypeScript `AgentOs` surfaces `err.message` into
414    /// `BatchWriteResult.error` / `BatchReadResult.error`. The error may be a bare [`ClientError`]
415    /// (path guards) or an [`anyhow::Error`] wrapping one (kernel RPC failures via
416    /// [`Self::guest_fs_call`]), so downcast to recover the exact TS message; otherwise fall back to
417    /// the anyhow chain string.
418    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    /// Build the VM-scoped ownership for guest filesystem RPCs.
426    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    /// Join a parent directory with a child basename the way the TS fs code does (special-casing the
435    /// root so it does not produce a leading `//`).
436    fn join_child(dir: &str, child: &str) -> String {
437        if dir == "/" {
438            format!("/{child}")
439        } else {
440            format!("{dir}/{child}")
441        }
442    }
443
444    /// Issue a single guest filesystem RPC and return the typed result, mapping a sidecar
445    /// `Rejected` response into a [`ClientError::Kernel`] so the errno `code` survives for parity.
446    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    /// A guest filesystem call carrying only an operation + path (the common case).
471    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    /// Convert a wire [`GuestFilesystemStat`] into the public [`VirtualStat`] (`*_ms` widened to
495    /// `f64` to match JS millisecond precision).
496    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    // --- low-level kernel ops (each maps to one guest filesystem RPC) ---
517
518    /// Mirrors TS `decodeGuestFilesystemContent`: a missing `content` field is a hard error
519    /// (`sidecar returned no file content for <path>`, fail-by-default), `base64` is decoded, and
520    /// any other/absent encoding is treated as utf8 bytes.
521    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    /// Mirrors TS `encodeGuestFilesystemContent`: string content is sent verbatim with NO `encoding`
537    /// field (the sidecar defaults absent encoding to utf8); byte content is base64-encoded and
538    /// carries `encoding: "base64"`.
539    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    /// One write-files request. Returns one error message (or `None`) per entry, in order.
555    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    /// Single-level directory creation. Mirrors TS `kernel.mkdir(path)` (no options), which the
598    /// native client maps to the `create_dir` guest filesystem operation. This backs BOTH
599    /// `AgentOs::mkdir` (non-recursive) and every `_mkdirp` component, so it always emits
600    /// [`GuestFilesystemOperation::CreateDir`] (never `Mkdir`, which the native client reserves for
601    /// the recursive `kernel.mkdir(path, { recursive: true })` shape that this code path never uses).
602    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        // agentos's READ_DIR now returns rich entries (`entries:
620        // list<GuestDirEntry>` with name + is_directory + is_symbolic_link);
621        // this name-only accessor projects the basenames. The richer fields back
622        // the typed [`Self::read_dir_with_types`] path.
623        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    /// Recursively create directories (`mkdir -p`). Uses the WRITABLE guard, then walks each path
674    /// component and creates the ones that do not yet exist (mirrors TS `_mkdirp`).
675    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(&current).await? {
682                self.kernel_mkdir(&current).await?;
683            }
684        }
685        Ok(())
686    }
687}
688
689// ---------------------------------------------------------------------------
690// Filesystem methods
691// ---------------------------------------------------------------------------
692
693impl AgentOs {
694    /// Read a file's raw bytes (no decode).
695    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    /// Write a file. Writable-path guard; does NOT auto-create parents; `Text` -> UTF-8.
701    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    /// Batch write. Never rejects (per-entry error); auto-creates parent dirs. Results keep input
708    /// order. Sends as few `GuestFilesystemWriteFilesRequest`s as the entry-count and frame-size
709    /// limits from the authenticate handshake allow (mirrors TS `SidecarProcess.writeFiles`).
710    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    /// Batch read. Sequential; never rejects; `content` None on failure.
778    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    /// Make a directory. Recursive -> writable guard + mkdirp; non-recursive -> safe guard + single
803    /// level. The guard asymmetry is load-bearing.
804    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    /// List basenames (may include `.`/`..`).
813    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    /// List directory entries with their resolved type, mirroring the TS `readDirWithTypes` used by
819    /// a typed `fs/readDir` host request. `.`/`..` are filtered by the caller. A symlink is reported
820    /// as a symlink (lstat-style, not followed); other entries are stat'd as directory vs file.
821    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    /// Recursive BFS listing; symlinks recorded but NOT descended; a stat failure aborts the call.
841    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    /// Return typed immediate children using one sidecar filesystem operation.
886    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    /// Stat (follows symlinks).
901    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    /// Existence check. Safe-path guard still errors; missing path -> false.
907    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    /// Export the root filesystem snapshot. Octal-string mode + utf8/base64 content verbatim.
913    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    /// Mount a portable sidecar-owned filesystem descriptor.
969    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                    // Dynamic mount reconfiguration must retain the VM policy
1066                    // resolved at creation, not reconstruct it in the client.
1067                    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    /// Move a path through the sidecar primitive. The kernel attempts rename first, then falls back
1093    /// to recursive copy+remove on EXDEV.
1094    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    /// Delete a path through the sidecar primitive. Non-recursive directory deletes preserve
1101    /// ENOTEMPTY semantics.
1102    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    /// Convert a wire [`RootFilesystemEntry`] into the public snapshot [`FilesystemEntry`],
1108    /// preserving the octal-string `mode` and verbatim utf8/base64 `content`/`target`.
1109    ///
1110    /// Mirrors TS `convertSidecarRootSnapshotEntries` + `toSnapshotModeString` exactly:
1111    /// - `mode` falls back kind-dependently when absent (directory 0o755, symlink 0o777, file 0o644).
1112    /// - file entries ALWAYS carry `content` (defaulting to `""`) and `encoding` (defaulting to
1113    ///   `utf8`); directory/symlink entries carry neither.
1114    /// - symlink entries REQUIRE a `target`; a missing target is a hard error (fail-by-default),
1115    ///   matching the TS `throw`.
1116    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        // Kind-dependent permission-bit fallback, then octal string with leading `0` masked to the
1123        // permission bits, matching TS `toSnapshotModeString`.
1124        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}