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 secure_exec_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    /// Posix `dirname` over an already-normalized absolute path (mirrors `posixPath.dirname`).
381    fn posix_dirname(path: &str) -> String {
382        match path.rfind('/') {
383            None => String::from("."),
384            Some(0) => String::from("/"),
385            Some(idx) => path[..idx].to_string(),
386        }
387    }
388
389    /// Join a parent directory with a child basename the way the TS fs code does (special-casing the
390    /// root so it does not produce a leading `//`).
391    fn join_child(dir: &str, child: &str) -> String {
392        if dir == "/" {
393            format!("/{child}")
394        } else {
395            format!("{dir}/{child}")
396        }
397    }
398
399    /// Issue a single guest filesystem RPC and return the typed result, mapping a sidecar
400    /// `Rejected` response into a [`ClientError::Kernel`] so the errno `code` survives for parity.
401    async fn guest_fs_call(
402        &self,
403        request: GuestFilesystemCallRequest,
404    ) -> Result<GuestFilesystemResultResponse> {
405        let scope = self.fs_vm_scope();
406        let response = self
407            .transport()
408            .request_wire(
409                scope,
410                wire::RequestPayload::GuestFilesystemCallRequest(request),
411            )
412            .await
413            .context("guest filesystem call failed")?;
414        match response {
415            wire::ResponsePayload::GuestFilesystemResultResponse(result) => Ok(result),
416            wire::ResponsePayload::RejectedResponse(wire::RejectedResponse { code, message }) => {
417                Err(ClientError::Kernel { code, message }.into())
418            }
419            other => Err(anyhow::anyhow!(
420                "unexpected response to guest filesystem call: {other:?}"
421            )),
422        }
423    }
424
425    /// A guest filesystem call carrying only an operation + path (the common case).
426    fn fs_request(
427        operation: GuestFilesystemOperation,
428        path: impl Into<String>,
429    ) -> GuestFilesystemCallRequest {
430        GuestFilesystemCallRequest {
431            operation,
432            path: path.into(),
433            destination_path: None,
434            target: None,
435            content: None,
436            encoding: None,
437            recursive: false,
438            mode: None,
439            uid: None,
440            gid: None,
441            atime_ms: None,
442            mtime_ms: None,
443            len: None,
444            offset: None,
445        }
446    }
447
448    /// Convert a wire [`GuestFilesystemStat`] into the public [`VirtualStat`] (`*_ms` widened to
449    /// `f64` to match JS millisecond precision).
450    fn virtual_stat_from(stat: GuestFilesystemStat) -> VirtualStat {
451        VirtualStat {
452            mode: stat.mode,
453            size: stat.size,
454            blocks: stat.blocks,
455            dev: stat.dev,
456            rdev: stat.rdev,
457            is_directory: stat.is_directory,
458            is_symbolic_link: stat.is_symbolic_link,
459            atime_ms: stat.atime_ms as f64,
460            mtime_ms: stat.mtime_ms as f64,
461            ctime_ms: stat.ctime_ms as f64,
462            birthtime_ms: stat.birthtime_ms as f64,
463            ino: stat.ino,
464            nlink: stat.nlink,
465            uid: stat.uid,
466            gid: stat.gid,
467        }
468    }
469
470    // --- low-level kernel ops (each maps to one guest filesystem RPC) ---
471
472    /// Mirrors TS `decodeGuestFilesystemContent`: a missing `content` field is a hard error
473    /// (`sidecar returned no file content for <path>`, fail-by-default), `base64` is decoded, and
474    /// any other/absent encoding is treated as utf8 bytes.
475    async fn kernel_read_file(&self, path: &str) -> Result<Vec<u8>> {
476        let result = self
477            .guest_fs_call(Self::fs_request(GuestFilesystemOperation::ReadFile, path))
478            .await?;
479        let content = result
480            .content
481            .with_context(|| format!("sidecar returned no file content for {path}"))?;
482        match result.encoding {
483            Some(RootFilesystemEntryEncoding::Base64) => BASE64
484                .decode(content.as_bytes())
485                .context("decoding base64 file content"),
486            Some(RootFilesystemEntryEncoding::Utf8) | None => Ok(content.into_bytes()),
487        }
488    }
489
490    /// Mirrors TS `encodeGuestFilesystemContent`: string content is sent verbatim with NO `encoding`
491    /// field (the sidecar defaults absent encoding to utf8); byte content is base64-encoded and
492    /// carries `encoding: "base64"`.
493    async fn kernel_write_file(&self, path: &str, content: &FileContent) -> Result<()> {
494        let (encoded, encoding) = match content {
495            FileContent::Text(text) => (text.clone(), None),
496            FileContent::Bytes(bytes) => (
497                BASE64.encode(bytes),
498                Some(RootFilesystemEntryEncoding::Base64),
499            ),
500        };
501        let mut request = Self::fs_request(GuestFilesystemOperation::WriteFile, path);
502        request.content = Some(encoded);
503        request.encoding = encoding;
504        self.guest_fs_call(request).await?;
505        Ok(())
506    }
507
508    /// Single-level directory creation. Mirrors TS `kernel.mkdir(path)` (no options), which the
509    /// native client maps to the `create_dir` guest filesystem operation. This backs BOTH
510    /// `AgentOs::mkdir` (non-recursive) and every `_mkdirp` component, so it always emits
511    /// [`GuestFilesystemOperation::CreateDir`] (never `Mkdir`, which the native client reserves for
512    /// the recursive `kernel.mkdir(path, { recursive: true })` shape that this code path never uses).
513    async fn kernel_mkdir(&self, path: &str) -> Result<()> {
514        self.guest_fs_call(Self::fs_request(GuestFilesystemOperation::CreateDir, path))
515            .await?;
516        Ok(())
517    }
518
519    async fn kernel_exists(&self, path: &str) -> Result<bool> {
520        let result = self
521            .guest_fs_call(Self::fs_request(GuestFilesystemOperation::Exists, path))
522            .await?;
523        Ok(result.exists.unwrap_or(false))
524    }
525
526    async fn kernel_readdir(&self, path: &str) -> Result<Vec<String>> {
527        let result = self
528            .guest_fs_call(Self::fs_request(GuestFilesystemOperation::ReadDir, path))
529            .await?;
530        // secure-exec's READ_DIR now returns rich entries (`entries:
531        // list<GuestDirEntry>` with name + is_directory + is_symbolic_link);
532        // this name-only accessor projects the basenames. The richer fields back
533        // the typed [`Self::read_dir_with_types`] path.
534        Ok(result
535            .entries
536            .unwrap_or_default()
537            .into_iter()
538            .map(|entry| entry.name)
539            .collect())
540    }
541
542    async fn kernel_stat(&self, path: &str) -> Result<VirtualStat> {
543        let result = self
544            .guest_fs_call(Self::fs_request(GuestFilesystemOperation::Stat, path))
545            .await?;
546        let stat = result.stat.context("stat response missing stat payload")?;
547        Ok(Self::virtual_stat_from(stat))
548    }
549
550    async fn kernel_lstat(&self, path: &str) -> Result<VirtualStat> {
551        let result = self
552            .guest_fs_call(Self::fs_request(GuestFilesystemOperation::Lstat, path))
553            .await?;
554        let stat = result.stat.context("lstat response missing stat payload")?;
555        Ok(Self::virtual_stat_from(stat))
556    }
557
558    async fn kernel_readlink(&self, path: &str) -> Result<String> {
559        let result = self
560            .guest_fs_call(Self::fs_request(GuestFilesystemOperation::ReadLink, path))
561            .await?;
562        result.target.context("readlink response missing target")
563    }
564
565    async fn kernel_symlink(&self, target: &str, path: &str) -> Result<()> {
566        let mut request = Self::fs_request(GuestFilesystemOperation::Symlink, path);
567        request.target = Some(target.to_string());
568        self.guest_fs_call(request).await?;
569        Ok(())
570    }
571
572    async fn kernel_rename(&self, from: &str, to: &str) -> Result<()> {
573        let mut request = Self::fs_request(GuestFilesystemOperation::Rename, from);
574        request.destination_path = Some(to.to_string());
575        self.guest_fs_call(request).await?;
576        Ok(())
577    }
578
579    async fn kernel_chmod(&self, path: &str, mode: u32) -> Result<()> {
580        let mut request = Self::fs_request(GuestFilesystemOperation::Chmod, path);
581        request.mode = Some(mode);
582        self.guest_fs_call(request).await?;
583        Ok(())
584    }
585
586    async fn kernel_chown(&self, path: &str, uid: u32, gid: u32) -> Result<()> {
587        let mut request = Self::fs_request(GuestFilesystemOperation::Chown, path);
588        request.uid = Some(uid);
589        request.gid = Some(gid);
590        self.guest_fs_call(request).await?;
591        Ok(())
592    }
593
594    async fn kernel_remove_file(&self, path: &str) -> Result<()> {
595        self.guest_fs_call(Self::fs_request(GuestFilesystemOperation::RemoveFile, path))
596            .await?;
597        Ok(())
598    }
599
600    async fn kernel_remove_dir(&self, path: &str) -> Result<()> {
601        self.guest_fs_call(Self::fs_request(GuestFilesystemOperation::RemoveDir, path))
602            .await?;
603        Ok(())
604    }
605
606    /// Recursively create directories (`mkdir -p`). Uses the WRITABLE guard, then walks each path
607    /// component and creates the ones that do not yet exist (mirrors TS `_mkdirp`).
608    async fn mkdirp(&self, path: &str) -> Result<()> {
609        Self::assert_writable_absolute_path(path)?;
610        let mut current = String::new();
611        for part in path.split('/').filter(|p| !p.is_empty()) {
612            current.push('/');
613            current.push_str(part);
614            if !self.kernel_exists(&current).await? {
615                self.kernel_mkdir(&current).await?;
616            }
617        }
618        Ok(())
619    }
620
621    /// Recursive copy preserving mode/uid/gid and replicating symlinks (mirrors TS `_copyPath`).
622    /// Boxed because it recurses across an `async fn` boundary.
623    fn copy_path<'a>(
624        &'a self,
625        from: &'a str,
626        to: &'a str,
627    ) -> futures::future::BoxFuture<'a, Result<()>> {
628        Box::pin(async move {
629            Self::assert_writable_absolute_path(to)?;
630            let stat = self.kernel_lstat(from).await?;
631            if stat.is_symbolic_link {
632                let target = self.kernel_readlink(from).await?;
633                self.kernel_symlink(&target, to).await?;
634                return Ok(());
635            }
636            if stat.is_directory {
637                self.mkdirp(&Self::posix_dirname(to)).await?;
638                if !self.kernel_exists(to).await? {
639                    self.kernel_mkdir(to).await?;
640                }
641                self.kernel_chmod(to, stat.mode).await?;
642                self.kernel_chown(to, stat.uid, stat.gid).await?;
643                let entries = self.kernel_readdir(from).await?;
644                for entry in entries {
645                    if entry == "." || entry == ".." {
646                        continue;
647                    }
648                    let from_path = Self::join_child(from, &entry);
649                    let to_path = Self::join_child(to, &entry);
650                    self.copy_path(&from_path, &to_path).await?;
651                }
652                return Ok(());
653            }
654            let content = self.kernel_read_file(from).await?;
655            self.write_file(to, content).await?;
656            self.kernel_chmod(to, stat.mode).await?;
657            self.kernel_chown(to, stat.uid, stat.gid).await?;
658            Ok(())
659        })
660    }
661
662    /// `delete`, boxed so the recursive child walk can cross the `async fn` boundary.
663    fn delete_inner<'a>(
664        &'a self,
665        path: &'a str,
666        recursive: bool,
667    ) -> futures::future::BoxFuture<'a, Result<()>> {
668        Box::pin(async move {
669            let stat = self.kernel_lstat(path).await?;
670            if stat.is_directory {
671                if recursive {
672                    let entries = self.kernel_readdir(path).await?;
673                    for entry in entries {
674                        if entry == "." || entry == ".." {
675                            continue;
676                        }
677                        let child = format!("{path}/{entry}");
678                        // Mirror TS `delete` recursion, which re-runs the safe-path guard on each
679                        // child via the public `this.delete(...)` call before recursing.
680                        Self::assert_safe_absolute_path(&child)?;
681                        self.delete_inner(&child, true).await?;
682                    }
683                }
684                return self.kernel_remove_dir(path).await;
685            }
686            self.kernel_remove_file(path).await
687        })
688    }
689}
690
691// ---------------------------------------------------------------------------
692// Filesystem methods
693// ---------------------------------------------------------------------------
694
695impl AgentOs {
696    /// Read a file's raw bytes (no decode).
697    pub async fn read_file(&self, path: &str) -> Result<Vec<u8>> {
698        Self::assert_safe_absolute_path(path)?;
699        self.kernel_read_file(path).await
700    }
701
702    /// Write a file. Writable-path guard; does NOT auto-create parents; `Text` -> UTF-8.
703    pub async fn write_file(&self, path: &str, content: impl Into<FileContent>) -> Result<()> {
704        Self::assert_writable_absolute_path(path)?;
705        let content = content.into();
706        self.kernel_write_file(path, &content).await
707    }
708
709    /// Batch write. Sequential; never rejects (per-entry error); auto-creates parent dirs.
710    pub async fn write_files(&self, entries: Vec<BatchWriteEntry>) -> Vec<BatchWriteResult> {
711        let mut results = Vec::with_capacity(entries.len());
712        for entry in entries {
713            let outcome: Result<()> = async {
714                Self::assert_writable_absolute_path(&entry.path)?;
715                // Create parent directories as needed. TS slices off everything after the last `/`;
716                // for a path like `/foo` this yields an empty parent which is skipped.
717                if let Some(idx) = entry.path.rfind('/') {
718                    let parent = &entry.path[..idx];
719                    if !parent.is_empty() {
720                        self.mkdirp(parent).await?;
721                    }
722                }
723                self.kernel_write_file(&entry.path, &entry.content).await?;
724                Ok(())
725            }
726            .await;
727            match outcome {
728                Ok(()) => results.push(BatchWriteResult {
729                    path: entry.path,
730                    success: true,
731                    error: None,
732                }),
733                Err(err) => results.push(BatchWriteResult {
734                    path: entry.path,
735                    success: false,
736                    error: Some(Self::batch_error_message(&err)),
737                }),
738            }
739        }
740        results
741    }
742
743    /// Batch read. Sequential; never rejects; `content` None on failure.
744    pub async fn read_files(&self, paths: Vec<String>) -> Vec<BatchReadResult> {
745        let mut results = Vec::with_capacity(paths.len());
746        for path in paths {
747            let outcome: Result<Vec<u8>> = async {
748                Self::assert_safe_absolute_path(&path)?;
749                self.kernel_read_file(&path).await
750            }
751            .await;
752            match outcome {
753                Ok(content) => results.push(BatchReadResult {
754                    path,
755                    content: Some(content),
756                    error: None,
757                }),
758                Err(err) => results.push(BatchReadResult {
759                    path,
760                    content: None,
761                    error: Some(Self::batch_error_message(&err)),
762                }),
763            }
764        }
765        results
766    }
767
768    /// Make a directory. Recursive -> writable guard + mkdirp; non-recursive -> safe guard + single
769    /// level. The guard asymmetry is load-bearing.
770    pub async fn mkdir(&self, path: &str, options: MkdirOptions) -> Result<()> {
771        if options.recursive {
772            return self.mkdirp(path).await;
773        }
774        Self::assert_writable_absolute_path(path)?;
775        self.kernel_mkdir(path).await
776    }
777
778    /// List basenames (may include `.`/`..`).
779    pub async fn readdir(&self, path: &str) -> Result<Vec<String>> {
780        Self::assert_safe_absolute_path(path)?;
781        self.kernel_readdir(path).await
782    }
783
784    /// List directory entries with their resolved type, mirroring the TS `readDirWithTypes` used by
785    /// the ACP `fs/readDir` host request. `.`/`..` are filtered by the caller. A symlink is reported
786    /// as a symlink (lstat-style, not followed); other entries are stat'd as directory vs file.
787    pub(crate) async fn acp_read_dir_with_types(&self, path: &str) -> Result<Vec<VirtualDirEntry>> {
788        Self::assert_safe_absolute_path(path)?;
789        let names = self.kernel_readdir(path).await?;
790        let mut entries = Vec::with_capacity(names.len());
791        for name in names {
792            if name == "." || name == ".." {
793                continue;
794            }
795            let full_path = Self::join_child(path, &name);
796            let stat = self.kernel_lstat(&full_path).await?;
797            entries.push(VirtualDirEntry {
798                name,
799                is_directory: stat.is_directory,
800                is_symbolic_link: stat.is_symbolic_link,
801            });
802        }
803        Ok(entries)
804    }
805
806    /// Typed directory listing: each child reported with its resolved type. secure-exec's native
807    /// `READ_DIR` returns basenames only (`entries: list<str>`), so the type of each entry is derived
808    /// with a per-child `lstat` (a symlink is reported as such, lstat-style, not followed). Goes
809    /// through the kernel, so mounts are listed correctly. `.`/`..` are filtered.
810    pub async fn read_dir_with_types(&self, path: &str) -> Result<Vec<VirtualDirEntry>> {
811        self.acp_read_dir_with_types(path).await
812    }
813
814    /// Recursive BFS listing; symlinks recorded but NOT descended; a stat failure aborts the call.
815    pub async fn readdir_recursive(
816        &self,
817        path: &str,
818        options: ReaddirRecursiveOptions,
819    ) -> Result<Vec<DirEntry>> {
820        Self::assert_safe_absolute_path(path)?;
821        let max_depth = options.max_depth;
822        let exclude: std::collections::HashSet<&str> =
823            options.exclude.iter().map(String::as_str).collect();
824        let mut results: Vec<DirEntry> = Vec::new();
825
826        // BFS queue of `(dir_path, current_depth)`.
827        let mut queue: std::collections::VecDeque<(String, u32)> =
828            std::collections::VecDeque::new();
829        queue.push_back((path.to_string(), 0));
830
831        while let Some((dir_path, depth)) = queue.pop_front() {
832            let entries = self.kernel_readdir(&dir_path).await?;
833            for name in entries {
834                if name == "." || name == ".." {
835                    continue;
836                }
837                if exclude.contains(name.as_str()) {
838                    continue;
839                }
840                let full_path = Self::join_child(&dir_path, &name);
841                let s = self.kernel_lstat(&full_path).await?;
842                if s.is_symbolic_link {
843                    results.push(DirEntry {
844                        path: full_path,
845                        entry_type: DirEntryType::Symlink,
846                        size: s.size,
847                    });
848                } else if s.is_directory {
849                    results.push(DirEntry {
850                        path: full_path.clone(),
851                        entry_type: DirEntryType::Directory,
852                        size: s.size,
853                    });
854                    if max_depth.is_none() || depth < max_depth.unwrap() {
855                        queue.push_back((full_path, depth + 1));
856                    }
857                } else {
858                    results.push(DirEntry {
859                        path: full_path,
860                        entry_type: DirEntryType::File,
861                        size: s.size,
862                    });
863                }
864            }
865        }
866
867        Ok(results)
868    }
869
870    /// Stat (follows symlinks).
871    pub async fn stat(&self, path: &str) -> Result<VirtualStat> {
872        Self::assert_safe_absolute_path(path)?;
873        self.kernel_stat(path).await
874    }
875
876    /// Existence check. Safe-path guard still errors; missing path -> false.
877    pub async fn exists(&self, path: &str) -> Result<bool> {
878        Self::assert_safe_absolute_path(path)?;
879        self.kernel_exists(path).await
880    }
881
882    /// Export the root filesystem snapshot. Octal-string mode + utf8/base64 content verbatim.
883    pub async fn snapshot_root_filesystem(&self) -> Result<RootSnapshotExport> {
884        let scope = self.fs_vm_scope();
885        let response = self
886            .transport()
887            .request_wire(scope, wire::RequestPayload::SnapshotRootFilesystemRequest)
888            .await
889            .context("snapshot root filesystem failed")?;
890        let snapshot = match response {
891            wire::ResponsePayload::RootFilesystemSnapshotResponse(snapshot) => snapshot,
892            wire::ResponsePayload::RejectedResponse(wire::RejectedResponse { code, message }) => {
893                return Err(ClientError::Kernel { code, message }.into());
894            }
895            other => {
896                return Err(anyhow::anyhow!(
897                    "unexpected response to snapshot root filesystem: {other:?}"
898                ));
899            }
900        };
901
902        let entries = snapshot
903            .entries
904            .into_iter()
905            .map(Self::snapshot_entry_from)
906            .collect::<Result<Vec<_>>>()?;
907
908        Ok(RootSnapshotExport {
909            kind: SnapshotExportKind::SnapshotExport,
910            source: FilesystemSnapshotExport {
911                format: String::from("agentos-filesystem-snapshot-v1"),
912                filesystem: FilesystemSnapshotEntries { entries },
913            },
914        })
915    }
916
917    /// Mount an in-process [`VirtualFileSystem`] driver. SYNC. Safe-path guard. The driver is a live
918    /// trait object and cannot cross an RPC boundary, so it is registered (together with the
919    /// `read_only` flag, mirroring TS `kernel.mountFs({ readOnly })`) in the in-process mount table
920    /// keyed by its normalized guest path.
921    pub fn mount_fs(
922        &self,
923        path: &str,
924        driver: Arc<dyn VirtualFileSystem>,
925        options: MountFsOptions,
926    ) -> std::result::Result<(), ClientError> {
927        Self::assert_safe_absolute_path(path)?;
928        let _ = self.inner().in_process_mounts.insert(
929            path.to_string(),
930            MountedFs {
931                driver,
932                read_only: options.read_only,
933            },
934        );
935        Ok(())
936    }
937
938    /// Unmount a previously mounted path. SYNC.
939    pub fn unmount_fs(&self, path: &str) -> std::result::Result<(), ClientError> {
940        Self::assert_safe_absolute_path(path)?;
941        self.inner().in_process_mounts.remove(path);
942        Ok(())
943    }
944
945    /// Move a path. `lstat(from)` no-follow; symlink/non-dir -> rename; real dir -> recursive copy
946    /// (preserve mode/uid/gid/symlinks) + recursive delete. (TS `move`.)
947    pub async fn move_path(&self, from: &str, to: &str) -> Result<()> {
948        Self::assert_writable_absolute_path(from)?;
949        Self::assert_writable_absolute_path(to)?;
950        let source_stat = self.kernel_lstat(from).await?;
951        if !source_stat.is_directory || source_stat.is_symbolic_link {
952            return self.kernel_rename(from, to).await;
953        }
954        self.copy_path(from, to).await?;
955        self.delete(from, DeleteOptions { recursive: true }).await
956    }
957
958    /// Delete a path. `lstat` to discriminate; recursive manually recurses children then `remove_dir`;
959    /// non-recursive dir -> `remove_dir` (ENOTEMPTY if non-empty).
960    pub async fn delete(&self, path: &str, options: DeleteOptions) -> Result<()> {
961        Self::assert_writable_absolute_path(path)?;
962        self.delete_inner(path, options.recursive).await
963    }
964
965    /// Convert a wire [`RootFilesystemEntry`] into the public snapshot [`FilesystemEntry`],
966    /// preserving the octal-string `mode` and verbatim utf8/base64 `content`/`target`.
967    ///
968    /// Mirrors TS `convertSidecarRootSnapshotEntries` + `toSnapshotModeString` exactly:
969    /// - `mode` falls back kind-dependently when absent (directory 0o755, symlink 0o777, file 0o644).
970    /// - file entries ALWAYS carry `content` (defaulting to `""`) and `encoding` (defaulting to
971    ///   `utf8`); directory/symlink entries carry neither.
972    /// - symlink entries REQUIRE a `target`; a missing target is a hard error (fail-by-default),
973    ///   matching the TS `throw`.
974    fn snapshot_entry_from(entry: RootFilesystemEntry) -> Result<FilesystemEntry> {
975        let entry_type = match entry.kind {
976            RootFilesystemEntryKind::File => DirEntryType::File,
977            RootFilesystemEntryKind::Directory => DirEntryType::Directory,
978            RootFilesystemEntryKind::Symlink => DirEntryType::Symlink,
979        };
980        // Kind-dependent permission-bit fallback, then octal string with leading `0` masked to the
981        // permission bits, matching TS `toSnapshotModeString`.
982        let fallback_mode = match entry.kind {
983            RootFilesystemEntryKind::Directory => 0o755,
984            RootFilesystemEntryKind::Symlink => 0o777,
985            RootFilesystemEntryKind::File => 0o644,
986        };
987        let mode = format!("0{:o}", entry.mode.unwrap_or(fallback_mode) & 0o7777);
988        let uid = entry.uid.unwrap_or(0);
989        let gid = entry.gid.unwrap_or(0);
990
991        match entry.kind {
992            RootFilesystemEntryKind::File => {
993                let encoding = match entry.encoding {
994                    Some(RootFilesystemEntryEncoding::Utf8) | None => FilesystemEntryEncoding::Utf8,
995                    Some(RootFilesystemEntryEncoding::Base64) => FilesystemEntryEncoding::Base64,
996                };
997                Ok(FilesystemEntry {
998                    path: entry.path,
999                    entry_type,
1000                    mode,
1001                    uid,
1002                    gid,
1003                    content: Some(entry.content.unwrap_or_default()),
1004                    encoding: Some(encoding),
1005                    target: None,
1006                })
1007            }
1008            RootFilesystemEntryKind::Symlink => {
1009                let target = entry.target.with_context(|| {
1010                    format!(
1011                        "sidecar root snapshot for {} is missing a symlink target",
1012                        entry.path
1013                    )
1014                })?;
1015                Ok(FilesystemEntry {
1016                    path: entry.path,
1017                    entry_type,
1018                    mode,
1019                    uid,
1020                    gid,
1021                    content: None,
1022                    encoding: None,
1023                    target: Some(target),
1024                })
1025            }
1026            RootFilesystemEntryKind::Directory => Ok(FilesystemEntry {
1027                path: entry.path,
1028                entry_type,
1029                mode,
1030                uid,
1031                gid,
1032                content: None,
1033                encoding: None,
1034                target: None,
1035            }),
1036        }
1037    }
1038}