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#[derive(Debug, Clone, PartialEq, Eq)]
33pub enum FileContent {
34    Text(String),
35    Bytes(Vec<u8>),
36}
37
38impl From<String> for FileContent {
39    fn from(value: String) -> Self {
40        FileContent::Text(value)
41    }
42}
43
44impl From<&str> for FileContent {
45    fn from(value: &str) -> Self {
46        FileContent::Text(value.to_string())
47    }
48}
49
50impl From<Vec<u8>> for FileContent {
51    fn from(value: Vec<u8>) -> Self {
52        FileContent::Bytes(value)
53    }
54}
55
56impl From<&[u8]> for FileContent {
57    fn from(value: &[u8]) -> Self {
58        FileContent::Bytes(value.to_vec())
59    }
60}
61
62/// An entry returned by `readdir_recursive`.
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64pub struct DirEntry {
65    pub path: String,
66    #[serde(rename = "type")]
67    pub entry_type: DirEntryType,
68    pub size: u64,
69}
70
71/// The type of a directory entry.
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
73#[serde(rename_all = "lowercase")]
74pub enum DirEntryType {
75    File,
76    Directory,
77    Symlink,
78}
79
80/// Options for `readdir_recursive`. `max_depth` None = unlimited, Some(0) = immediate children only;
81/// `exclude` matches basenames at any depth.
82#[derive(Debug, Clone, Default, PartialEq, Eq)]
83pub struct ReaddirRecursiveOptions {
84    pub max_depth: Option<u32>,
85    pub exclude: Vec<String>,
86}
87
88/// A batch write entry.
89#[derive(Debug, Clone, PartialEq, Eq)]
90pub struct BatchWriteEntry {
91    pub path: String,
92    pub content: FileContent,
93}
94
95/// Result of a single batch write (never an `Err`).
96#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct BatchWriteResult {
98    pub path: String,
99    pub success: bool,
100    pub error: Option<String>,
101}
102
103/// Result of a single batch read (never an `Err`). `content` is None on failure.
104#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct BatchReadResult {
106    pub path: String,
107    pub content: Option<Vec<u8>>,
108    pub error: Option<String>,
109}
110
111/// Options for `mkdir`.
112#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
113pub struct MkdirOptions {
114    pub recursive: bool,
115}
116
117/// Options for `remove`.
118#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
119pub struct RemoveOptions {
120    pub recursive: bool,
121}
122
123#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
124pub struct DynamicMountDescriptor {
125    pub path: String,
126    pub plugin: crate::config::MountPlugin,
127    #[serde(default)]
128    #[serde(rename = "readOnly")]
129    pub read_only: bool,
130}
131
132#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
133pub struct MountInfo {
134    pub path: String,
135    pub kind: String,
136    #[serde(rename = "readOnly")]
137    pub read_only: bool,
138}
139
140/// Stat result. 16 fields; `*_ms` time fields are `f64` (JS ms, possibly fractional).
141#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
142pub struct VirtualStat {
143    pub mode: u32,
144    pub size: u64,
145    pub blocks: u64,
146    pub dev: u64,
147    pub rdev: u64,
148    #[serde(rename = "isDirectory")]
149    pub is_directory: bool,
150    #[serde(rename = "isSymbolicLink")]
151    pub is_symbolic_link: bool,
152    #[serde(rename = "atimeMs")]
153    pub atime_ms: f64,
154    #[serde(rename = "mtimeMs")]
155    pub mtime_ms: f64,
156    #[serde(rename = "ctimeMs")]
157    pub ctime_ms: f64,
158    #[serde(rename = "birthtimeMs")]
159    pub birthtime_ms: f64,
160    pub ino: u64,
161    pub nlink: u64,
162    pub uid: u32,
163    pub gid: u32,
164}
165
166/// A directory entry with a known type, returned by `read_dir_with_types` on the mount contract.
167#[derive(Debug, Clone, PartialEq, Eq)]
168pub struct VirtualDirEntry {
169    pub name: String,
170    pub is_directory: bool,
171    pub is_symbolic_link: bool,
172}
173
174// ---------------------------------------------------------------------------
175// Snapshot export wire types (octal-string mode, utf8/base64 content)
176// ---------------------------------------------------------------------------
177
178/// `{ kind: "snapshot-export"; source }`.
179#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
180pub struct RootSnapshotExport {
181    pub kind: SnapshotExportKind,
182    pub source: FilesystemSnapshotExport,
183}
184
185/// The literal `"snapshot-export"` tag.
186#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
187pub enum SnapshotExportKind {
188    #[serde(rename = "snapshot-export")]
189    SnapshotExport,
190}
191
192/// `{ format: "agentos-filesystem-snapshot-v1"; filesystem: { entries } }`.
193#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
194pub struct FilesystemSnapshotExport {
195    pub format: String,
196    pub filesystem: FilesystemSnapshotEntries,
197}
198
199/// `{ entries: FilesystemEntry[] }`.
200#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
201pub struct FilesystemSnapshotEntries {
202    pub entries: Vec<FilesystemEntry>,
203}
204
205/// A single snapshot entry. `mode` is an OCTAL STRING (e.g. `"0755"`). `content` is utf8 or base64.
206#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
207pub struct FilesystemEntry {
208    pub path: String,
209    #[serde(rename = "type")]
210    pub entry_type: DirEntryType,
211    pub mode: String,
212    pub uid: u32,
213    pub gid: u32,
214    #[serde(default, skip_serializing_if = "Option::is_none")]
215    pub content: Option<String>,
216    #[serde(default, skip_serializing_if = "Option::is_none")]
217    pub encoding: Option<FilesystemEntryEncoding>,
218    #[serde(default, skip_serializing_if = "Option::is_none")]
219    pub target: Option<String>,
220}
221
222/// Snapshot content encoding.
223#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
224#[serde(rename_all = "lowercase")]
225pub enum FilesystemEntryEncoding {
226    Utf8,
227    Base64,
228}
229
230// ---------------------------------------------------------------------------
231// VirtualFileSystem mount contract (in-process trait object for mount_fs)
232// ---------------------------------------------------------------------------
233
234/// The 25-method mount backend contract. A `mount_fs` driver implements this trait; it is a live
235/// in-process object and cannot cross an RPC boundary.
236///
237/// TODO(parity: confirm exact method set/signatures against runtime-compat.ts before first impl).
238#[async_trait]
239pub trait VirtualFileSystem: Send + Sync {
240    async fn read_file(&self, path: &str) -> Result<Vec<u8>>;
241    async fn read_text_file(&self, path: &str) -> Result<String>;
242    async fn read_dir(&self, path: &str) -> Result<Vec<String>>;
243    async fn read_dir_with_types(&self, path: &str) -> Result<Vec<VirtualDirEntry>>;
244    async fn write_file(&self, path: &str, content: &[u8]) -> Result<()>;
245    async fn create_dir(&self, path: &str) -> Result<()>;
246    async fn mkdir(&self, path: &str, recursive: bool) -> Result<()>;
247    async fn exists(&self, path: &str) -> Result<bool>;
248    async fn stat(&self, path: &str) -> Result<VirtualStat>;
249    async fn lstat(&self, path: &str) -> Result<VirtualStat>;
250    async fn remove_file(&self, path: &str) -> Result<()>;
251    async fn remove_dir(&self, path: &str) -> Result<()>;
252    async fn rename(&self, from: &str, to: &str) -> Result<()>;
253    async fn realpath(&self, path: &str) -> Result<String>;
254    async fn symlink(&self, target: &str, path: &str) -> Result<()>;
255    async fn readlink(&self, path: &str) -> Result<String>;
256    async fn link(&self, existing: &str, new_path: &str) -> Result<()>;
257    async fn chmod(&self, path: &str, mode: u32) -> Result<()>;
258    async fn chown(&self, path: &str, uid: u32, gid: u32) -> Result<()>;
259    async fn utimes(&self, path: &str, atime_ms: f64, mtime_ms: f64) -> Result<()>;
260    async fn truncate(&self, path: &str, len: u64) -> Result<()>;
261    async fn pread(&self, path: &str, offset: u64, length: u64) -> Result<Vec<u8>>;
262    async fn pwrite(&self, path: &str, offset: u64, data: &[u8]) -> Result<u64>;
263}
264
265// ---------------------------------------------------------------------------
266// Path guards
267// ---------------------------------------------------------------------------
268
269impl AgentOs {
270    /// Posix-normalize a path the same way Node's `path.posix.normalize` does.
271    ///
272    /// Matches Node semantics: collapse `.`/`..` segments and duplicate separators, preserve a
273    /// trailing slash when present, keep a leading slash for absolute paths, and return `.` for an
274    /// empty result. Above-root `..` segments on an absolute path are discarded; on a relative path
275    /// they are retained.
276    pub(crate) fn posix_normalize(path: &str) -> String {
277        if path.is_empty() {
278            return String::from(".");
279        }
280
281        let is_absolute = path.starts_with('/');
282        let trailing_slash = path.ends_with('/');
283
284        let mut segments: Vec<&str> = Vec::new();
285        for part in path.split('/') {
286            match part {
287                "" | "." => {}
288                ".." => {
289                    match segments.last().copied() {
290                        Some(last) if last != ".." => {
291                            segments.pop();
292                        }
293                        Some(_) | None => {
294                            // Retain leading `..` only on relative paths; on absolute paths the
295                            // segment is silently discarded (cannot go above root).
296                            if !is_absolute {
297                                segments.push("..");
298                            }
299                        }
300                    }
301                }
302                other => segments.push(other),
303            }
304        }
305
306        let mut joined = segments.join("/");
307        if joined.is_empty() {
308            if is_absolute {
309                return String::from("/");
310            }
311            return String::from(".");
312        }
313
314        if trailing_slash {
315            joined.push('/');
316        }
317        if is_absolute {
318            let mut absolute = String::from("/");
319            absolute.push_str(&joined);
320            absolute
321        } else {
322            joined
323        }
324    }
325
326    /// Throws `PathNotAbsolute` if not absolute, `PathNotNormalized` if not in normalized form.
327    pub(crate) fn assert_safe_absolute_path(path: &str) -> std::result::Result<(), ClientError> {
328        if !path.starts_with('/') {
329            return Err(ClientError::PathNotAbsolute(path.to_string()));
330        }
331        if Self::posix_normalize(path) != path {
332            return Err(ClientError::PathNotNormalized(path.to_string()));
333        }
334        Ok(())
335    }
336
337    /// Runs the safe guard, then rejects writes to read-only paths.
338    pub(crate) fn assert_writable_absolute_path(
339        path: &str,
340    ) -> std::result::Result<(), ClientError> {
341        Self::assert_safe_absolute_path(path)?;
342        if path == "/proc"
343            || path.starts_with("/proc/")
344            || path == "/etc/agentos"
345            || path.starts_with("/etc/agentos/")
346        {
347            return Err(ClientError::PathReadOnly(path.to_string()));
348        }
349        Ok(())
350    }
351}
352
353// ---------------------------------------------------------------------------
354// Internal helpers (guest filesystem RPC + path joins)
355// ---------------------------------------------------------------------------
356
357impl AgentOs {
358    /// Render a batch-method error the way the TypeScript `AgentOs` surfaces `err.message` into
359    /// `BatchWriteResult.error` / `BatchReadResult.error`. The error may be a bare [`ClientError`]
360    /// (path guards) or an [`anyhow::Error`] wrapping one (kernel RPC failures via
361    /// [`Self::guest_fs_call`]), so downcast to recover the exact TS message; otherwise fall back to
362    /// the anyhow chain string.
363    fn batch_error_message(err: &anyhow::Error) -> String {
364        match err.downcast_ref::<ClientError>() {
365            Some(client_error) => client_error.batch_message(),
366            None => err.to_string(),
367        }
368    }
369
370    /// Build the VM-scoped ownership for guest filesystem RPCs.
371    fn fs_vm_scope(&self) -> wire::OwnershipScope {
372        wire::OwnershipScope::VmOwnership(wire::VmOwnership {
373            connection_id: self.connection_id().to_string(),
374            session_id: self.wire_session_id().to_string(),
375            vm_id: self.vm_id().to_string(),
376        })
377    }
378
379    /// Join a parent directory with a child basename the way the TS fs code does (special-casing the
380    /// root so it does not produce a leading `//`).
381    fn join_child(dir: &str, child: &str) -> String {
382        if dir == "/" {
383            format!("/{child}")
384        } else {
385            format!("{dir}/{child}")
386        }
387    }
388
389    /// Issue a single guest filesystem RPC and return the typed result, mapping a sidecar
390    /// `Rejected` response into a [`ClientError::Kernel`] so the errno `code` survives for parity.
391    async fn guest_fs_call(
392        &self,
393        request: GuestFilesystemCallRequest,
394    ) -> Result<GuestFilesystemResultResponse> {
395        let scope = self.fs_vm_scope();
396        let response = self
397            .transport()
398            .request_wire(
399                scope,
400                wire::RequestPayload::GuestFilesystemCallRequest(request),
401            )
402            .await
403            .context("guest filesystem call failed")?;
404        match response {
405            wire::ResponsePayload::GuestFilesystemResultResponse(result) => Ok(result),
406            wire::ResponsePayload::RejectedResponse(rejected) => {
407                Err(ClientError::from_rejection(rejected).into())
408            }
409            other => Err(anyhow::anyhow!(
410                "unexpected response to guest filesystem call: {other:?}"
411            )),
412        }
413    }
414
415    /// A guest filesystem call carrying only an operation + path (the common case).
416    fn fs_request(
417        operation: GuestFilesystemOperation,
418        path: impl Into<String>,
419    ) -> GuestFilesystemCallRequest {
420        GuestFilesystemCallRequest {
421            operation,
422            path: path.into(),
423            destination_path: None,
424            target: None,
425            content: None,
426            encoding: None,
427            recursive: false,
428            max_depth: None,
429            mode: None,
430            uid: None,
431            gid: None,
432            atime_ms: None,
433            mtime_ms: None,
434            len: None,
435            offset: None,
436        }
437    }
438
439    /// Convert a wire [`GuestFilesystemStat`] into the public [`VirtualStat`] (`*_ms` widened to
440    /// `f64` to match JS millisecond precision).
441    fn virtual_stat_from(stat: GuestFilesystemStat) -> VirtualStat {
442        VirtualStat {
443            mode: stat.mode,
444            size: stat.size,
445            blocks: stat.blocks,
446            dev: stat.dev,
447            rdev: stat.rdev,
448            is_directory: stat.is_directory,
449            is_symbolic_link: stat.is_symbolic_link,
450            atime_ms: stat.atime_ms as f64,
451            mtime_ms: stat.mtime_ms as f64,
452            ctime_ms: stat.ctime_ms as f64,
453            birthtime_ms: stat.birthtime_ms as f64,
454            ino: stat.ino,
455            nlink: stat.nlink,
456            uid: stat.uid,
457            gid: stat.gid,
458        }
459    }
460
461    // --- low-level kernel ops (each maps to one guest filesystem RPC) ---
462
463    /// Mirrors TS `decodeGuestFilesystemContent`: a missing `content` field is a hard error
464    /// (`sidecar returned no file content for <path>`, fail-by-default), `base64` is decoded, and
465    /// any other/absent encoding is treated as utf8 bytes.
466    async fn kernel_read_file(&self, path: &str) -> Result<Vec<u8>> {
467        let result = self
468            .guest_fs_call(Self::fs_request(GuestFilesystemOperation::ReadFile, path))
469            .await?;
470        let content = result
471            .content
472            .with_context(|| format!("sidecar returned no file content for {path}"))?;
473        match result.encoding {
474            Some(RootFilesystemEntryEncoding::Base64) => BASE64
475                .decode(content.as_bytes())
476                .context("decoding base64 file content"),
477            Some(RootFilesystemEntryEncoding::Utf8) | None => Ok(content.into_bytes()),
478        }
479    }
480
481    /// Mirrors TS `encodeGuestFilesystemContent`: string content is sent verbatim with NO `encoding`
482    /// field (the sidecar defaults absent encoding to utf8); byte content is base64-encoded and
483    /// carries `encoding: "base64"`.
484    async fn kernel_write_file(&self, path: &str, content: &FileContent) -> Result<()> {
485        let (encoded, encoding) = match content {
486            FileContent::Text(text) => (text.clone(), None),
487            FileContent::Bytes(bytes) => (
488                BASE64.encode(bytes),
489                Some(RootFilesystemEntryEncoding::Base64),
490            ),
491        };
492        let mut request = Self::fs_request(GuestFilesystemOperation::WriteFile, path);
493        request.content = Some(encoded);
494        request.encoding = encoding;
495        self.guest_fs_call(request).await?;
496        Ok(())
497    }
498
499    /// Single-level directory creation. Mirrors TS `kernel.mkdir(path)` (no options), which the
500    /// native client maps to the `create_dir` guest filesystem operation. This backs BOTH
501    /// `AgentOs::mkdir` (non-recursive) and every `_mkdirp` component, so it always emits
502    /// [`GuestFilesystemOperation::CreateDir`] (never `Mkdir`, which the native client reserves for
503    /// the recursive `kernel.mkdir(path, { recursive: true })` shape that this code path never uses).
504    async fn kernel_mkdir(&self, path: &str) -> Result<()> {
505        self.guest_fs_call(Self::fs_request(GuestFilesystemOperation::CreateDir, path))
506            .await?;
507        Ok(())
508    }
509
510    async fn kernel_exists(&self, path: &str) -> Result<bool> {
511        let result = self
512            .guest_fs_call(Self::fs_request(GuestFilesystemOperation::Exists, path))
513            .await?;
514        Ok(result.exists.unwrap_or(false))
515    }
516
517    async fn kernel_readdir(&self, path: &str) -> Result<Vec<String>> {
518        let result = self
519            .guest_fs_call(Self::fs_request(GuestFilesystemOperation::ReadDir, path))
520            .await?;
521        // agentos's READ_DIR now returns rich entries (`entries:
522        // list<GuestDirEntry>` with name + is_directory + is_symbolic_link);
523        // this name-only accessor projects the basenames. The richer fields back
524        // the typed [`Self::read_dir_with_types`] path.
525        Ok(result
526            .entries
527            .unwrap_or_default()
528            .into_iter()
529            .map(|entry| entry.name)
530            .collect())
531    }
532
533    async fn kernel_readdir_recursive(
534        &self,
535        path: &str,
536        max_depth: Option<u32>,
537    ) -> Result<Vec<wire::GuestDirEntry>> {
538        let mut request = Self::fs_request(GuestFilesystemOperation::ReadDirRecursive, path);
539        request.max_depth = max_depth;
540        let result = self.guest_fs_call(request).await?;
541        Ok(result.entries.unwrap_or_default())
542    }
543
544    async fn kernel_stat(&self, path: &str) -> Result<VirtualStat> {
545        let result = self
546            .guest_fs_call(Self::fs_request(GuestFilesystemOperation::Stat, path))
547            .await?;
548        let stat = result.stat.context("stat response missing stat payload")?;
549        Ok(Self::virtual_stat_from(stat))
550    }
551
552    async fn kernel_lstat(&self, path: &str) -> Result<VirtualStat> {
553        let result = self
554            .guest_fs_call(Self::fs_request(GuestFilesystemOperation::Lstat, path))
555            .await?;
556        let stat = result.stat.context("lstat response missing stat payload")?;
557        Ok(Self::virtual_stat_from(stat))
558    }
559
560    async fn kernel_remove_path(&self, path: &str, recursive: bool) -> Result<()> {
561        let mut request = Self::fs_request(GuestFilesystemOperation::Remove, path);
562        request.recursive = recursive;
563        self.guest_fs_call(request).await?;
564        Ok(())
565    }
566
567    async fn kernel_move_path(&self, from: &str, to: &str) -> Result<()> {
568        let mut request = Self::fs_request(GuestFilesystemOperation::Move, from);
569        request.destination_path = Some(to.to_string());
570        request.recursive = true;
571        self.guest_fs_call(request).await?;
572        Ok(())
573    }
574
575    /// Recursively create directories (`mkdir -p`). Uses the WRITABLE guard, then walks each path
576    /// component and creates the ones that do not yet exist (mirrors TS `_mkdirp`).
577    async fn mkdirp(&self, path: &str) -> Result<()> {
578        Self::assert_writable_absolute_path(path)?;
579        let mut current = String::new();
580        for part in path.split('/').filter(|p| !p.is_empty()) {
581            current.push('/');
582            current.push_str(part);
583            if !self.kernel_exists(&current).await? {
584                self.kernel_mkdir(&current).await?;
585            }
586        }
587        Ok(())
588    }
589}
590
591// ---------------------------------------------------------------------------
592// Filesystem methods
593// ---------------------------------------------------------------------------
594
595impl AgentOs {
596    /// Read a file's raw bytes (no decode).
597    pub async fn read_file(&self, path: &str) -> Result<Vec<u8>> {
598        Self::assert_safe_absolute_path(path)?;
599        self.kernel_read_file(path).await
600    }
601
602    /// Write a file. Writable-path guard; does NOT auto-create parents; `Text` -> UTF-8.
603    pub async fn write_file(&self, path: &str, content: impl Into<FileContent>) -> Result<()> {
604        Self::assert_writable_absolute_path(path)?;
605        let content = content.into();
606        self.kernel_write_file(path, &content).await
607    }
608
609    /// Batch write. Sequential; never rejects (per-entry error); auto-creates parent dirs.
610    pub async fn write_files(&self, entries: Vec<BatchWriteEntry>) -> Vec<BatchWriteResult> {
611        let mut results = Vec::with_capacity(entries.len());
612        for entry in entries {
613            let outcome: Result<()> = async {
614                Self::assert_writable_absolute_path(&entry.path)?;
615                // Create parent directories as needed. TS slices off everything after the last `/`;
616                // for a path like `/foo` this yields an empty parent which is skipped.
617                if let Some(idx) = entry.path.rfind('/') {
618                    let parent = &entry.path[..idx];
619                    if !parent.is_empty() {
620                        self.mkdirp(parent).await?;
621                    }
622                }
623                self.kernel_write_file(&entry.path, &entry.content).await?;
624                Ok(())
625            }
626            .await;
627            match outcome {
628                Ok(()) => results.push(BatchWriteResult {
629                    path: entry.path,
630                    success: true,
631                    error: None,
632                }),
633                Err(err) => results.push(BatchWriteResult {
634                    path: entry.path,
635                    success: false,
636                    error: Some(Self::batch_error_message(&err)),
637                }),
638            }
639        }
640        results
641    }
642
643    /// Batch read. Sequential; never rejects; `content` None on failure.
644    pub async fn read_files(&self, paths: Vec<String>) -> Vec<BatchReadResult> {
645        let mut results = Vec::with_capacity(paths.len());
646        for path in paths {
647            let outcome: Result<Vec<u8>> = async {
648                Self::assert_safe_absolute_path(&path)?;
649                self.kernel_read_file(&path).await
650            }
651            .await;
652            match outcome {
653                Ok(content) => results.push(BatchReadResult {
654                    path,
655                    content: Some(content),
656                    error: None,
657                }),
658                Err(err) => results.push(BatchReadResult {
659                    path,
660                    content: None,
661                    error: Some(Self::batch_error_message(&err)),
662                }),
663            }
664        }
665        results
666    }
667
668    /// Make a directory. Recursive -> writable guard + mkdirp; non-recursive -> safe guard + single
669    /// level. The guard asymmetry is load-bearing.
670    pub async fn mkdir(&self, path: &str, options: MkdirOptions) -> Result<()> {
671        if options.recursive {
672            return self.mkdirp(path).await;
673        }
674        Self::assert_writable_absolute_path(path)?;
675        self.kernel_mkdir(path).await
676    }
677
678    /// List basenames (may include `.`/`..`).
679    pub async fn readdir(&self, path: &str) -> Result<Vec<String>> {
680        Self::assert_safe_absolute_path(path)?;
681        self.kernel_readdir(path).await
682    }
683
684    /// List directory entries with their resolved type, mirroring the TS `readDirWithTypes` used by
685    /// the ACP `fs/readDir` host request. `.`/`..` are filtered by the caller. A symlink is reported
686    /// as a symlink (lstat-style, not followed); other entries are stat'd as directory vs file.
687    pub(crate) async fn acp_read_dir_with_types(&self, path: &str) -> Result<Vec<VirtualDirEntry>> {
688        Self::assert_safe_absolute_path(path)?;
689        let names = self.kernel_readdir(path).await?;
690        let mut entries = Vec::with_capacity(names.len());
691        for name in names {
692            if name == "." || name == ".." {
693                continue;
694            }
695            let full_path = Self::join_child(path, &name);
696            let stat = self.kernel_lstat(&full_path).await?;
697            entries.push(VirtualDirEntry {
698                name,
699                is_directory: stat.is_directory,
700                is_symbolic_link: stat.is_symbolic_link,
701            });
702        }
703        Ok(entries)
704    }
705
706    /// Typed directory listing: each child reported with its resolved type. agentos's native
707    /// `READ_DIR` returns basenames only (`entries: list<str>`), so the type of each entry is derived
708    /// with a per-child `lstat` (a symlink is reported as such, lstat-style, not followed). Goes
709    /// through the kernel, so mounts are listed correctly. `.`/`..` are filtered.
710    pub async fn read_dir_with_types(&self, path: &str) -> Result<Vec<VirtualDirEntry>> {
711        self.acp_read_dir_with_types(path).await
712    }
713
714    /// Recursive BFS listing; symlinks recorded but NOT descended; a stat failure aborts the call.
715    pub async fn readdir_recursive(
716        &self,
717        path: &str,
718        options: ReaddirRecursiveOptions,
719    ) -> Result<Vec<DirEntry>> {
720        Self::assert_safe_absolute_path(path)?;
721        let exclude: std::collections::HashSet<&str> =
722            options.exclude.iter().map(String::as_str).collect();
723        let entries = self
724            .kernel_readdir_recursive(path, options.max_depth)
725            .await?;
726        let mut excluded_prefixes: Vec<String> = Vec::new();
727        let mut results: Vec<DirEntry> = Vec::new();
728
729        for entry in entries {
730            if excluded_prefixes.iter().any(|prefix| {
731                entry.path == *prefix || entry.path.starts_with(&format!("{prefix}/"))
732            }) {
733                continue;
734            }
735            if exclude.contains(entry.name.as_str()) {
736                if entry.is_directory && !entry.is_symbolic_link {
737                    excluded_prefixes.push(entry.path);
738                }
739                continue;
740            }
741
742            let entry_type = if entry.is_symbolic_link {
743                DirEntryType::Symlink
744            } else if entry.is_directory {
745                DirEntryType::Directory
746            } else {
747                DirEntryType::File
748            };
749            results.push(DirEntry {
750                path: entry.path,
751                entry_type,
752                size: entry.size,
753            });
754        }
755
756        Ok(results)
757    }
758
759    /// Return typed immediate children using one sidecar filesystem operation.
760    pub async fn readdir_entries(&self, path: &str) -> Result<Vec<VirtualDirEntry>> {
761        Self::assert_safe_absolute_path(path)?;
762        Ok(self
763            .kernel_readdir_recursive(path, Some(0))
764            .await?
765            .into_iter()
766            .map(|entry| VirtualDirEntry {
767                name: entry.name,
768                is_directory: entry.is_directory,
769                is_symbolic_link: entry.is_symbolic_link,
770            })
771            .collect())
772    }
773
774    /// Stat (follows symlinks).
775    pub async fn stat(&self, path: &str) -> Result<VirtualStat> {
776        Self::assert_safe_absolute_path(path)?;
777        self.kernel_stat(path).await
778    }
779
780    /// Existence check. Safe-path guard still errors; missing path -> false.
781    pub async fn exists(&self, path: &str) -> Result<bool> {
782        Self::assert_safe_absolute_path(path)?;
783        self.kernel_exists(path).await
784    }
785
786    /// Export the root filesystem snapshot. Octal-string mode + utf8/base64 content verbatim.
787    pub async fn export_root_filesystem(&self, max_bytes: usize) -> Result<RootSnapshotExport> {
788        if max_bytes == 0 {
789            return Err(ClientError::Sidecar("max_bytes must be greater than zero".into()).into());
790        }
791        let scope = self.fs_vm_scope();
792        let max_bytes_u64 = u64::try_from(max_bytes)
793            .map_err(|_| ClientError::Sidecar("max_bytes exceeds u64".into()))?;
794        let response = self
795            .transport()
796            .request_wire(
797                scope,
798                wire::RequestPayload::SnapshotRootFilesystemRequest(
799                    wire::SnapshotRootFilesystemRequest {
800                        max_bytes: max_bytes_u64,
801                    },
802                ),
803            )
804            .await
805            .context("snapshot root filesystem failed")?;
806        let snapshot = match response {
807            wire::ResponsePayload::RootFilesystemSnapshotResponse(snapshot) => snapshot,
808            wire::ResponsePayload::RejectedResponse(rejected) => {
809                return Err(ClientError::from_rejection(rejected).into());
810            }
811            other => {
812                return Err(anyhow::anyhow!(
813                    "unexpected response to snapshot root filesystem: {other:?}"
814                ));
815            }
816        };
817
818        let entries = snapshot
819            .entries
820            .into_iter()
821            .map(Self::snapshot_entry_from)
822            .collect::<Result<Vec<_>>>()?;
823
824        let snapshot = RootSnapshotExport {
825            kind: SnapshotExportKind::SnapshotExport,
826            source: FilesystemSnapshotExport {
827                format: String::from("agentos-filesystem-snapshot-v1"),
828                filesystem: FilesystemSnapshotEntries { entries },
829            },
830        };
831        let size = serde_json::to_vec(&snapshot)
832            .context("serializing root filesystem export for bound check")?
833            .len();
834        if size > max_bytes {
835            return Err(ClientError::Sidecar(format!(
836				"root filesystem export is {size} bytes, limit is {max_bytes}; raise max_bytes to export this filesystem"
837			)).into());
838        }
839        Ok(snapshot)
840    }
841
842    /// Mount a portable sidecar-owned filesystem descriptor.
843    pub async fn mount_fs(&self, descriptor: DynamicMountDescriptor) -> Result<()> {
844        Self::assert_safe_absolute_path(&descriptor.path)?;
845        let config = descriptor
846            .plugin
847            .config
848            .unwrap_or_else(|| serde_json::json!({}));
849        let plugin_id = descriptor.plugin.id;
850        let mount = wire::MountDescriptor {
851            guest_path: descriptor.path,
852            guest_source: plugin_id.clone(),
853            guest_fstype: plugin_id.clone(),
854            read_only: descriptor.read_only,
855            plugin: wire::MountPluginDescriptor {
856                id: plugin_id,
857                config: serde_json::to_string(&config)
858                    .context("serializing dynamic mount config")?,
859            },
860        };
861        {
862            let mut mounts = self.inner().dynamic_mounts.lock();
863            if mounts
864                .iter()
865                .any(|existing| existing.guest_path == mount.guest_path)
866            {
867                return Err(ClientError::Sidecar(format!(
868                    "mount already exists: {}",
869                    mount.guest_path
870                ))
871                .into());
872            }
873            mounts.push(mount);
874        }
875        if let Err(error) = self.reconfigure_dynamic_mounts().await {
876            self.inner().dynamic_mounts.lock().pop();
877            return Err(error);
878        }
879        Ok(())
880    }
881
882    pub async fn unmount_fs(&self, path: &str) -> Result<()> {
883        Self::assert_safe_absolute_path(path)?;
884        let removed = {
885            let mut mounts = self.inner().dynamic_mounts.lock();
886            mounts
887                .iter()
888                .position(|mount| mount.guest_path == path)
889                .map(|index| (index, mounts.remove(index)))
890        };
891        let Some((index, mount)) = removed else {
892            return Ok(());
893        };
894        if let Err(error) = self.reconfigure_dynamic_mounts().await {
895            self.inner().dynamic_mounts.lock().insert(index, mount);
896            return Err(error);
897        }
898        Ok(())
899    }
900
901    pub async fn list_mounts(&self) -> Result<Vec<MountInfo>> {
902        let response = self
903            .transport()
904            .request_wire(self.fs_vm_scope(), wire::RequestPayload::ListMountsRequest)
905            .await?;
906        match response {
907            wire::ResponsePayload::ListMountsResponse(response) => Ok(response
908                .mounts
909                .into_iter()
910                .map(|mount| MountInfo {
911                    path: mount.path,
912                    kind: mount.kind,
913                    read_only: mount.read_only,
914                })
915                .collect()),
916            wire::ResponsePayload::RejectedResponse(rejected) => {
917                Err(ClientError::from_rejection(rejected).into())
918            }
919            other => Err(ClientError::Sidecar(format!(
920                "unexpected list mounts response: {other:?}"
921            ))
922            .into()),
923        }
924    }
925
926    async fn reconfigure_dynamic_mounts(&self) -> Result<()> {
927        let inner = self.inner();
928        let config = &inner.config;
929        let mounts = inner.dynamic_mounts.lock().clone();
930        let response = self
931            .transport()
932            .request_wire(
933                self.fs_vm_scope(),
934                wire::RequestPayload::ConfigureVmRequest(wire::ConfigureVmRequest {
935                    mounts,
936                    software: Vec::new(),
937                    permissions: Some(crate::agent_os::permissions_policy(config)),
938                    module_access_cwd: None,
939                    instructions: config.additional_instructions.clone().into_iter().collect(),
940                    projected_modules: Vec::new(),
941                    command_permissions: std::collections::HashMap::new(),
942                    loopback_exempt_ports: config.loopback_exempt_ports.clone(),
943                    packages: crate::agent_os::build_package_descriptors(config),
944                    packages_mount_at: config.packages_mount_at.clone().unwrap_or_default(),
945                    bootstrap_commands: Vec::new(),
946                    binding_shim_commands: Vec::new(),
947                }),
948            )
949            .await?;
950        match response {
951            wire::ResponsePayload::VmConfiguredResponse(_) => Ok(()),
952            wire::ResponsePayload::RejectedResponse(rejected) => {
953                Err(ClientError::from_rejection(rejected).into())
954            }
955            other => Err(ClientError::Sidecar(format!(
956                "unexpected dynamic mount reconfigure response: {other:?}"
957            ))
958            .into()),
959        }
960    }
961
962    /// Move a path through the sidecar primitive. The kernel attempts rename first, then falls back
963    /// to recursive copy+remove on EXDEV.
964    pub async fn move_path(&self, from: &str, to: &str) -> Result<()> {
965        Self::assert_writable_absolute_path(from)?;
966        Self::assert_writable_absolute_path(to)?;
967        self.kernel_move_path(from, to).await
968    }
969
970    /// Delete a path through the sidecar primitive. Non-recursive directory deletes preserve
971    /// ENOTEMPTY semantics.
972    pub async fn remove(&self, path: &str, options: RemoveOptions) -> Result<()> {
973        Self::assert_writable_absolute_path(path)?;
974        self.kernel_remove_path(path, options.recursive).await
975    }
976
977    /// Convert a wire [`RootFilesystemEntry`] into the public snapshot [`FilesystemEntry`],
978    /// preserving the octal-string `mode` and verbatim utf8/base64 `content`/`target`.
979    ///
980    /// Mirrors TS `convertSidecarRootSnapshotEntries` + `toSnapshotModeString` exactly:
981    /// - `mode` falls back kind-dependently when absent (directory 0o755, symlink 0o777, file 0o644).
982    /// - file entries ALWAYS carry `content` (defaulting to `""`) and `encoding` (defaulting to
983    ///   `utf8`); directory/symlink entries carry neither.
984    /// - symlink entries REQUIRE a `target`; a missing target is a hard error (fail-by-default),
985    ///   matching the TS `throw`.
986    fn snapshot_entry_from(entry: RootFilesystemEntry) -> Result<FilesystemEntry> {
987        let entry_type = match entry.kind {
988            RootFilesystemEntryKind::File => DirEntryType::File,
989            RootFilesystemEntryKind::Directory => DirEntryType::Directory,
990            RootFilesystemEntryKind::Symlink => DirEntryType::Symlink,
991        };
992        // Kind-dependent permission-bit fallback, then octal string with leading `0` masked to the
993        // permission bits, matching TS `toSnapshotModeString`.
994        let fallback_mode = match entry.kind {
995            RootFilesystemEntryKind::Directory => 0o755,
996            RootFilesystemEntryKind::Symlink => 0o777,
997            RootFilesystemEntryKind::File => 0o644,
998        };
999        let mode = format!("0{:o}", entry.mode.unwrap_or(fallback_mode) & 0o7777);
1000        let uid = entry.uid.unwrap_or(0);
1001        let gid = entry.gid.unwrap_or(0);
1002
1003        match entry.kind {
1004            RootFilesystemEntryKind::File => {
1005                let encoding = match entry.encoding {
1006                    Some(RootFilesystemEntryEncoding::Utf8) | None => FilesystemEntryEncoding::Utf8,
1007                    Some(RootFilesystemEntryEncoding::Base64) => FilesystemEntryEncoding::Base64,
1008                };
1009                Ok(FilesystemEntry {
1010                    path: entry.path,
1011                    entry_type,
1012                    mode,
1013                    uid,
1014                    gid,
1015                    content: Some(entry.content.unwrap_or_default()),
1016                    encoding: Some(encoding),
1017                    target: None,
1018                })
1019            }
1020            RootFilesystemEntryKind::Symlink => {
1021                let target = entry.target.with_context(|| {
1022                    format!(
1023                        "sidecar root snapshot for {} is missing a symlink target",
1024                        entry.path
1025                    )
1026                })?;
1027                Ok(FilesystemEntry {
1028                    path: entry.path,
1029                    entry_type,
1030                    mode,
1031                    uid,
1032                    gid,
1033                    content: None,
1034                    encoding: None,
1035                    target: Some(target),
1036                })
1037            }
1038            RootFilesystemEntryKind::Directory => Ok(FilesystemEntry {
1039                path: entry.path,
1040                entry_type,
1041                mode,
1042                uid,
1043                gid,
1044                content: None,
1045                encoding: None,
1046                target: None,
1047            }),
1048        }
1049    }
1050}