From 09fc0b84caf1ecc11f7cbad5c9ab4b50cf9e85aa Mon Sep 17 00:00:00 2001 From: ABCxFF <79597906+abcxff@users.noreply.github.com> Date: Wed, 1 Jul 2026 16:50:47 +0000 Subject: [PATCH] feat(agentos-actor-plugin): add readdirEntries (one-shot typed directory listing) --- .../src/actions/filesystem.rs | 57 ++++++++++++++++++- .../agentos-actor-plugin/src/actions/mod.rs | 7 +++ crates/client/src/fs.rs | 24 ++++++++ 3 files changed, 87 insertions(+), 1 deletion(-) diff --git a/crates/agentos-actor-plugin/src/actions/filesystem.rs b/crates/agentos-actor-plugin/src/actions/filesystem.rs index f708a6cdc7..c3129acef2 100644 --- a/crates/agentos-actor-plugin/src/actions/filesystem.rs +++ b/crates/agentos-actor-plugin/src/actions/filesystem.rs @@ -5,7 +5,7 @@ use agentos_client::{ AgentOs, BatchReadResult, BatchWriteEntry, BatchWriteResult, DeleteOptions, DirEntry, - FileContent, MkdirOptions, ReaddirRecursiveOptions, VirtualStat, + FileContent, MkdirOptions, ReaddirRecursiveOptions, VirtualDirEntry, VirtualStat, }; use anyhow::Result; use serde::{Deserialize, Serialize}; @@ -45,6 +45,61 @@ pub async fn readdir(vm: &AgentOs, path: &str) -> Result> { vm.readdir(path).await } +/// One typed directory entry returned by [`readdir_entries`]. Serializes as +/// `{ name, isDirectory, isSymbolicLink }`. No `size` — the fast path skips the +/// per-entry `stat`; callers that need a size `stat` the file when it is opened. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ReaddirEntryDto { + pub name: String, + pub is_directory: bool, + pub is_symbolic_link: bool, +} + +impl From for ReaddirEntryDto { + fn from(entry: VirtualDirEntry) -> Self { + Self { + name: entry.name, + is_directory: entry.is_directory, + is_symbolic_link: entry.is_symbolic_link, + } + } +} + +/// `readdirEntries(path)` — one round-trip directory listing with each child's +/// type, replacing `readdir` + a `stat` per entry (which wedges the actor on +/// large or virtual dirs). Routes through the kernel, so mounts list correctly. +/// +/// Returns `None` (serialized as `null`) when `path` is not a listable directory +/// — i.e. it does not exist (`ENOENT`) or is a file (`ENOTDIR`). Callers (the +/// inspector's editable cwd) treat `null` as "not found / not a directory", +/// distinct from `Some([])` (an empty directory). Other kernel failures still +/// propagate as errors. This avoids surfacing an opaque RivetKit +/// `internal_error` for the common typo-a-path case, since a *successful* `null` +/// is not sanitized the way a rejection's message is. +pub async fn readdir_entries(vm: &AgentOs, path: &str) -> Result>> { + match vm.read_dir_with_types(path).await { + Ok(entries) => Ok(Some( + entries.into_iter().map(ReaddirEntryDto::from).collect(), + )), + Err(error) if is_not_a_listable_dir(&error) => Ok(None), + Err(error) => Err(error), + } +} + +/// True when a `read_dir_with_types` failure means "there is no directory here": +/// the kernel embeds the POSIX errno in the message (the wire `code` is a generic +/// `kernel_error`), reporting `ENOENT` for a missing path and `ENOTDIR` for a +/// path that resolves to a file. Any other errno is a real failure. +fn is_not_a_listable_dir(error: &anyhow::Error) -> bool { + error + .chain() + .any(|cause| { + let msg = cause.to_string(); + msg.contains("ENOENT") || msg.contains("ENOTDIR") + }) +} + /// `exists(path)` — port of [`AgentOs::exists`]. pub async fn exists(vm: &AgentOs, path: &str) -> Result { vm.exists(path).await diff --git a/crates/agentos-actor-plugin/src/actions/mod.rs b/crates/agentos-actor-plugin/src/actions/mod.rs index 323affddf7..e145c24994 100644 --- a/crates/agentos-actor-plugin/src/actions/mod.rs +++ b/crates/agentos-actor-plugin/src/actions/mod.rs @@ -151,6 +151,13 @@ pub(crate) async fn dispatch( }, Err(error) => reply_err(host, token, error), }, + "readdirEntries" => match decode_as::<(String,)>(args) { + Ok((path,)) => match filesystem::readdir_entries(vm, &path).await { + Ok(entries) => reply_ok(host, token, &entries), + Err(error) => reply_err(host, token, error), + }, + Err(error) => reply_err(host, token, error), + }, "exists" => match decode_as::<(String,)>(args) { Ok((path,)) => match filesystem::exists(vm, &path).await { Ok(present) => reply_ok(host, token, &present), diff --git a/crates/client/src/fs.rs b/crates/client/src/fs.rs index a0a00a76ec..b72e2d2d69 100644 --- a/crates/client/src/fs.rs +++ b/crates/client/src/fs.rs @@ -802,6 +802,30 @@ impl AgentOs { Ok(entries) } + /// Fast typed directory listing: ONE guest filesystem round-trip that returns every child with + /// its type, instead of a `readdir` plus an `lstat` per entry (the [`Self::acp_read_dir_with_types`] + /// path, which wedges on large/virtual dirs). The sidecar returns the typed children in the + /// structured `dir_entries` field. `.`/`..` are filtered. Goes through the kernel, so mounts are + /// listed correctly. + pub async fn read_dir_with_types(&self, path: &str) -> Result> { + Self::assert_safe_absolute_path(path)?; + let result = self + .guest_fs_call(Self::fs_request(GuestFilesystemOperation::ReadDirWithTypes, path)) + .await?; + let mut entries = Vec::new(); + for entry in result.dir_entries.unwrap_or_default() { + if entry.name == "." || entry.name == ".." { + continue; + } + entries.push(VirtualDirEntry { + name: entry.name, + is_directory: entry.is_directory, + is_symbolic_link: entry.is_symbolic_link, + }); + } + Ok(entries) + } + /// Recursive BFS listing; symlinks recorded but NOT descended; a stat failure aborts the call. pub async fn readdir_recursive( &self,