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