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        Ok(result.entries.unwrap_or_default())
531    }
532
533    async fn kernel_stat(&self, path: &str) -> Result<VirtualStat> {
534        let result = self
535            .guest_fs_call(Self::fs_request(GuestFilesystemOperation::Stat, path))
536            .await?;
537        let stat = result.stat.context("stat response missing stat payload")?;
538        Ok(Self::virtual_stat_from(stat))
539    }
540
541    async fn kernel_lstat(&self, path: &str) -> Result<VirtualStat> {
542        let result = self
543            .guest_fs_call(Self::fs_request(GuestFilesystemOperation::Lstat, path))
544            .await?;
545        let stat = result.stat.context("lstat response missing stat payload")?;
546        Ok(Self::virtual_stat_from(stat))
547    }
548
549    async fn kernel_readlink(&self, path: &str) -> Result<String> {
550        let result = self
551            .guest_fs_call(Self::fs_request(GuestFilesystemOperation::ReadLink, path))
552            .await?;
553        result.target.context("readlink response missing target")
554    }
555
556    async fn kernel_symlink(&self, target: &str, path: &str) -> Result<()> {
557        let mut request = Self::fs_request(GuestFilesystemOperation::Symlink, path);
558        request.target = Some(target.to_string());
559        self.guest_fs_call(request).await?;
560        Ok(())
561    }
562
563    async fn kernel_rename(&self, from: &str, to: &str) -> Result<()> {
564        let mut request = Self::fs_request(GuestFilesystemOperation::Rename, from);
565        request.destination_path = Some(to.to_string());
566        self.guest_fs_call(request).await?;
567        Ok(())
568    }
569
570    async fn kernel_chmod(&self, path: &str, mode: u32) -> Result<()> {
571        let mut request = Self::fs_request(GuestFilesystemOperation::Chmod, path);
572        request.mode = Some(mode);
573        self.guest_fs_call(request).await?;
574        Ok(())
575    }
576
577    async fn kernel_chown(&self, path: &str, uid: u32, gid: u32) -> Result<()> {
578        let mut request = Self::fs_request(GuestFilesystemOperation::Chown, path);
579        request.uid = Some(uid);
580        request.gid = Some(gid);
581        self.guest_fs_call(request).await?;
582        Ok(())
583    }
584
585    async fn kernel_remove_file(&self, path: &str) -> Result<()> {
586        self.guest_fs_call(Self::fs_request(GuestFilesystemOperation::RemoveFile, path))
587            .await?;
588        Ok(())
589    }
590
591    async fn kernel_remove_dir(&self, path: &str) -> Result<()> {
592        self.guest_fs_call(Self::fs_request(GuestFilesystemOperation::RemoveDir, path))
593            .await?;
594        Ok(())
595    }
596
597    /// Recursively create directories (`mkdir -p`). Uses the WRITABLE guard, then walks each path
598    /// component and creates the ones that do not yet exist (mirrors TS `_mkdirp`).
599    async fn mkdirp(&self, path: &str) -> Result<()> {
600        Self::assert_writable_absolute_path(path)?;
601        let mut current = String::new();
602        for part in path.split('/').filter(|p| !p.is_empty()) {
603            current.push('/');
604            current.push_str(part);
605            if !self.kernel_exists(&current).await? {
606                self.kernel_mkdir(&current).await?;
607            }
608        }
609        Ok(())
610    }
611
612    /// Recursive copy preserving mode/uid/gid and replicating symlinks (mirrors TS `_copyPath`).
613    /// Boxed because it recurses across an `async fn` boundary.
614    fn copy_path<'a>(
615        &'a self,
616        from: &'a str,
617        to: &'a str,
618    ) -> futures::future::BoxFuture<'a, Result<()>> {
619        Box::pin(async move {
620            Self::assert_writable_absolute_path(to)?;
621            let stat = self.kernel_lstat(from).await?;
622            if stat.is_symbolic_link {
623                let target = self.kernel_readlink(from).await?;
624                self.kernel_symlink(&target, to).await?;
625                return Ok(());
626            }
627            if stat.is_directory {
628                self.mkdirp(&Self::posix_dirname(to)).await?;
629                if !self.kernel_exists(to).await? {
630                    self.kernel_mkdir(to).await?;
631                }
632                self.kernel_chmod(to, stat.mode).await?;
633                self.kernel_chown(to, stat.uid, stat.gid).await?;
634                let entries = self.kernel_readdir(from).await?;
635                for entry in entries {
636                    if entry == "." || entry == ".." {
637                        continue;
638                    }
639                    let from_path = Self::join_child(from, &entry);
640                    let to_path = Self::join_child(to, &entry);
641                    self.copy_path(&from_path, &to_path).await?;
642                }
643                return Ok(());
644            }
645            let content = self.kernel_read_file(from).await?;
646            self.write_file(to, content).await?;
647            self.kernel_chmod(to, stat.mode).await?;
648            self.kernel_chown(to, stat.uid, stat.gid).await?;
649            Ok(())
650        })
651    }
652
653    /// `delete`, boxed so the recursive child walk can cross the `async fn` boundary.
654    fn delete_inner<'a>(
655        &'a self,
656        path: &'a str,
657        recursive: bool,
658    ) -> futures::future::BoxFuture<'a, Result<()>> {
659        Box::pin(async move {
660            let stat = self.kernel_lstat(path).await?;
661            if stat.is_directory {
662                if recursive {
663                    let entries = self.kernel_readdir(path).await?;
664                    for entry in entries {
665                        if entry == "." || entry == ".." {
666                            continue;
667                        }
668                        let child = format!("{path}/{entry}");
669                        // Mirror TS `delete` recursion, which re-runs the safe-path guard on each
670                        // child via the public `this.delete(...)` call before recursing.
671                        Self::assert_safe_absolute_path(&child)?;
672                        self.delete_inner(&child, true).await?;
673                    }
674                }
675                return self.kernel_remove_dir(path).await;
676            }
677            self.kernel_remove_file(path).await
678        })
679    }
680}
681
682// ---------------------------------------------------------------------------
683// Filesystem methods
684// ---------------------------------------------------------------------------
685
686impl AgentOs {
687    /// Read a file's raw bytes (no decode).
688    pub async fn read_file(&self, path: &str) -> Result<Vec<u8>> {
689        Self::assert_safe_absolute_path(path)?;
690        self.kernel_read_file(path).await
691    }
692
693    /// Write a file. Writable-path guard; does NOT auto-create parents; `Text` -> UTF-8.
694    pub async fn write_file(&self, path: &str, content: impl Into<FileContent>) -> Result<()> {
695        Self::assert_writable_absolute_path(path)?;
696        let content = content.into();
697        self.kernel_write_file(path, &content).await
698    }
699
700    /// Batch write. Sequential; never rejects (per-entry error); auto-creates parent dirs.
701    pub async fn write_files(&self, entries: Vec<BatchWriteEntry>) -> Vec<BatchWriteResult> {
702        let mut results = Vec::with_capacity(entries.len());
703        for entry in entries {
704            let outcome: Result<()> = async {
705                Self::assert_writable_absolute_path(&entry.path)?;
706                // Create parent directories as needed. TS slices off everything after the last `/`;
707                // for a path like `/foo` this yields an empty parent which is skipped.
708                if let Some(idx) = entry.path.rfind('/') {
709                    let parent = &entry.path[..idx];
710                    if !parent.is_empty() {
711                        self.mkdirp(parent).await?;
712                    }
713                }
714                self.kernel_write_file(&entry.path, &entry.content).await?;
715                Ok(())
716            }
717            .await;
718            match outcome {
719                Ok(()) => results.push(BatchWriteResult {
720                    path: entry.path,
721                    success: true,
722                    error: None,
723                }),
724                Err(err) => results.push(BatchWriteResult {
725                    path: entry.path,
726                    success: false,
727                    error: Some(Self::batch_error_message(&err)),
728                }),
729            }
730        }
731        results
732    }
733
734    /// Batch read. Sequential; never rejects; `content` None on failure.
735    pub async fn read_files(&self, paths: Vec<String>) -> Vec<BatchReadResult> {
736        let mut results = Vec::with_capacity(paths.len());
737        for path in paths {
738            let outcome: Result<Vec<u8>> = async {
739                Self::assert_safe_absolute_path(&path)?;
740                self.kernel_read_file(&path).await
741            }
742            .await;
743            match outcome {
744                Ok(content) => results.push(BatchReadResult {
745                    path,
746                    content: Some(content),
747                    error: None,
748                }),
749                Err(err) => results.push(BatchReadResult {
750                    path,
751                    content: None,
752                    error: Some(Self::batch_error_message(&err)),
753                }),
754            }
755        }
756        results
757    }
758
759    /// Make a directory. Recursive -> writable guard + mkdirp; non-recursive -> safe guard + single
760    /// level. The guard asymmetry is load-bearing.
761    pub async fn mkdir(&self, path: &str, options: MkdirOptions) -> Result<()> {
762        if options.recursive {
763            return self.mkdirp(path).await;
764        }
765        Self::assert_writable_absolute_path(path)?;
766        self.kernel_mkdir(path).await
767    }
768
769    /// List basenames (may include `.`/`..`).
770    pub async fn readdir(&self, path: &str) -> Result<Vec<String>> {
771        Self::assert_safe_absolute_path(path)?;
772        self.kernel_readdir(path).await
773    }
774
775    /// List directory entries with their resolved type, mirroring the TS `readDirWithTypes` used by
776    /// the ACP `fs/readDir` host request. `.`/`..` are filtered by the caller. A symlink is reported
777    /// as a symlink (lstat-style, not followed); other entries are stat'd as directory vs file.
778    pub(crate) async fn acp_read_dir_with_types(&self, path: &str) -> Result<Vec<VirtualDirEntry>> {
779        Self::assert_safe_absolute_path(path)?;
780        let names = self.kernel_readdir(path).await?;
781        let mut entries = Vec::with_capacity(names.len());
782        for name in names {
783            if name == "." || name == ".." {
784                continue;
785            }
786            let full_path = Self::join_child(path, &name);
787            let stat = self.kernel_lstat(&full_path).await?;
788            entries.push(VirtualDirEntry {
789                name,
790                is_directory: stat.is_directory,
791                is_symbolic_link: stat.is_symbolic_link,
792            });
793        }
794        Ok(entries)
795    }
796
797    /// Recursive BFS listing; symlinks recorded but NOT descended; a stat failure aborts the call.
798    pub async fn readdir_recursive(
799        &self,
800        path: &str,
801        options: ReaddirRecursiveOptions,
802    ) -> Result<Vec<DirEntry>> {
803        Self::assert_safe_absolute_path(path)?;
804        let max_depth = options.max_depth;
805        let exclude: std::collections::HashSet<&str> =
806            options.exclude.iter().map(String::as_str).collect();
807        let mut results: Vec<DirEntry> = Vec::new();
808
809        // BFS queue of `(dir_path, current_depth)`.
810        let mut queue: std::collections::VecDeque<(String, u32)> =
811            std::collections::VecDeque::new();
812        queue.push_back((path.to_string(), 0));
813
814        while let Some((dir_path, depth)) = queue.pop_front() {
815            let entries = self.kernel_readdir(&dir_path).await?;
816            for name in entries {
817                if name == "." || name == ".." {
818                    continue;
819                }
820                if exclude.contains(name.as_str()) {
821                    continue;
822                }
823                let full_path = Self::join_child(&dir_path, &name);
824                let s = self.kernel_lstat(&full_path).await?;
825                if s.is_symbolic_link {
826                    results.push(DirEntry {
827                        path: full_path,
828                        entry_type: DirEntryType::Symlink,
829                        size: s.size,
830                    });
831                } else if s.is_directory {
832                    results.push(DirEntry {
833                        path: full_path.clone(),
834                        entry_type: DirEntryType::Directory,
835                        size: s.size,
836                    });
837                    if max_depth.is_none() || depth < max_depth.unwrap() {
838                        queue.push_back((full_path, depth + 1));
839                    }
840                } else {
841                    results.push(DirEntry {
842                        path: full_path,
843                        entry_type: DirEntryType::File,
844                        size: s.size,
845                    });
846                }
847            }
848        }
849
850        Ok(results)
851    }
852
853    /// Stat (follows symlinks).
854    pub async fn stat(&self, path: &str) -> Result<VirtualStat> {
855        Self::assert_safe_absolute_path(path)?;
856        self.kernel_stat(path).await
857    }
858
859    /// Existence check. Safe-path guard still errors; missing path -> false.
860    pub async fn exists(&self, path: &str) -> Result<bool> {
861        Self::assert_safe_absolute_path(path)?;
862        self.kernel_exists(path).await
863    }
864
865    /// Export the root filesystem snapshot. Octal-string mode + utf8/base64 content verbatim.
866    pub async fn snapshot_root_filesystem(&self) -> Result<RootSnapshotExport> {
867        let scope = self.fs_vm_scope();
868        let response = self
869            .transport()
870            .request_wire(scope, wire::RequestPayload::SnapshotRootFilesystemRequest)
871            .await
872            .context("snapshot root filesystem failed")?;
873        let snapshot = match response {
874            wire::ResponsePayload::RootFilesystemSnapshotResponse(snapshot) => snapshot,
875            wire::ResponsePayload::RejectedResponse(wire::RejectedResponse { code, message }) => {
876                return Err(ClientError::Kernel { code, message }.into());
877            }
878            other => {
879                return Err(anyhow::anyhow!(
880                    "unexpected response to snapshot root filesystem: {other:?}"
881                ));
882            }
883        };
884
885        let entries = snapshot
886            .entries
887            .into_iter()
888            .map(Self::snapshot_entry_from)
889            .collect::<Result<Vec<_>>>()?;
890
891        Ok(RootSnapshotExport {
892            kind: SnapshotExportKind::SnapshotExport,
893            source: FilesystemSnapshotExport {
894                format: String::from("agentos-filesystem-snapshot-v1"),
895                filesystem: FilesystemSnapshotEntries { entries },
896            },
897        })
898    }
899
900    /// Mount an in-process [`VirtualFileSystem`] driver. SYNC. Safe-path guard. The driver is a live
901    /// trait object and cannot cross an RPC boundary, so it is registered (together with the
902    /// `read_only` flag, mirroring TS `kernel.mountFs({ readOnly })`) in the in-process mount table
903    /// keyed by its normalized guest path.
904    pub fn mount_fs(
905        &self,
906        path: &str,
907        driver: Arc<dyn VirtualFileSystem>,
908        options: MountFsOptions,
909    ) -> std::result::Result<(), ClientError> {
910        Self::assert_safe_absolute_path(path)?;
911        let _ = self.inner().in_process_mounts.insert(
912            path.to_string(),
913            MountedFs {
914                driver,
915                read_only: options.read_only,
916            },
917        );
918        Ok(())
919    }
920
921    /// Unmount a previously mounted path. SYNC.
922    pub fn unmount_fs(&self, path: &str) -> std::result::Result<(), ClientError> {
923        Self::assert_safe_absolute_path(path)?;
924        self.inner().in_process_mounts.remove(path);
925        Ok(())
926    }
927
928    /// Move a path. `lstat(from)` no-follow; symlink/non-dir -> rename; real dir -> recursive copy
929    /// (preserve mode/uid/gid/symlinks) + recursive delete. (TS `move`.)
930    pub async fn move_path(&self, from: &str, to: &str) -> Result<()> {
931        Self::assert_writable_absolute_path(from)?;
932        Self::assert_writable_absolute_path(to)?;
933        let source_stat = self.kernel_lstat(from).await?;
934        if !source_stat.is_directory || source_stat.is_symbolic_link {
935            return self.kernel_rename(from, to).await;
936        }
937        self.copy_path(from, to).await?;
938        self.delete(from, DeleteOptions { recursive: true }).await
939    }
940
941    /// Delete a path. `lstat` to discriminate; recursive manually recurses children then `remove_dir`;
942    /// non-recursive dir -> `remove_dir` (ENOTEMPTY if non-empty).
943    pub async fn delete(&self, path: &str, options: DeleteOptions) -> Result<()> {
944        Self::assert_writable_absolute_path(path)?;
945        self.delete_inner(path, options.recursive).await
946    }
947
948    /// Convert a wire [`RootFilesystemEntry`] into the public snapshot [`FilesystemEntry`],
949    /// preserving the octal-string `mode` and verbatim utf8/base64 `content`/`target`.
950    ///
951    /// Mirrors TS `convertSidecarRootSnapshotEntries` + `toSnapshotModeString` exactly:
952    /// - `mode` falls back kind-dependently when absent (directory 0o755, symlink 0o777, file 0o644).
953    /// - file entries ALWAYS carry `content` (defaulting to `""`) and `encoding` (defaulting to
954    ///   `utf8`); directory/symlink entries carry neither.
955    /// - symlink entries REQUIRE a `target`; a missing target is a hard error (fail-by-default),
956    ///   matching the TS `throw`.
957    fn snapshot_entry_from(entry: RootFilesystemEntry) -> Result<FilesystemEntry> {
958        let entry_type = match entry.kind {
959            RootFilesystemEntryKind::File => DirEntryType::File,
960            RootFilesystemEntryKind::Directory => DirEntryType::Directory,
961            RootFilesystemEntryKind::Symlink => DirEntryType::Symlink,
962        };
963        // Kind-dependent permission-bit fallback, then octal string with leading `0` masked to the
964        // permission bits, matching TS `toSnapshotModeString`.
965        let fallback_mode = match entry.kind {
966            RootFilesystemEntryKind::Directory => 0o755,
967            RootFilesystemEntryKind::Symlink => 0o777,
968            RootFilesystemEntryKind::File => 0o644,
969        };
970        let mode = format!("0{:o}", entry.mode.unwrap_or(fallback_mode) & 0o7777);
971        let uid = entry.uid.unwrap_or(0);
972        let gid = entry.gid.unwrap_or(0);
973
974        match entry.kind {
975            RootFilesystemEntryKind::File => {
976                let encoding = match entry.encoding {
977                    Some(RootFilesystemEntryEncoding::Utf8) | None => FilesystemEntryEncoding::Utf8,
978                    Some(RootFilesystemEntryEncoding::Base64) => FilesystemEntryEncoding::Base64,
979                };
980                Ok(FilesystemEntry {
981                    path: entry.path,
982                    entry_type,
983                    mode,
984                    uid,
985                    gid,
986                    content: Some(entry.content.unwrap_or_default()),
987                    encoding: Some(encoding),
988                    target: None,
989                })
990            }
991            RootFilesystemEntryKind::Symlink => {
992                let target = entry.target.with_context(|| {
993                    format!(
994                        "sidecar root snapshot for {} is missing a symlink target",
995                        entry.path
996                    )
997                })?;
998                Ok(FilesystemEntry {
999                    path: entry.path,
1000                    entry_type,
1001                    mode,
1002                    uid,
1003                    gid,
1004                    content: None,
1005                    encoding: None,
1006                    target: Some(target),
1007                })
1008            }
1009            RootFilesystemEntryKind::Directory => Ok(FilesystemEntry {
1010                path: entry.path,
1011                entry_type,
1012                mode,
1013                uid,
1014                gid,
1015                content: None,
1016                encoding: None,
1017                target: None,
1018            }),
1019        }
1020    }
1021}