feat(exec-server): add filesystem find-up RPC

(cherry picked from commit d94f2e75d55ecc217f99783118fec769ad19048f)
This commit is contained in:
Adam Perry
2026-07-10 05:59:06 +00:00
parent c9d8424bdd
commit 350369e9d2
10 changed files with 400 additions and 1 deletions
@@ -2,6 +2,10 @@ use std::collections::HashMap;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use codex_file_system::FileSystemSandboxContext;
pub use codex_file_system::FindUpErrorPolicy;
pub use codex_file_system::FindUpMatchKind;
pub use codex_file_system::FindUpOptions;
pub use codex_file_system::FindUpOutcome;
pub use codex_file_system::WalkOptions;
pub use codex_file_system::WalkOutcome;
use codex_network_proxy::ManagedNetworkSandboxContext;
@@ -33,6 +37,7 @@ pub const FS_WRITE_FILE_METHOD: &str = "fs/writeFile";
pub const FS_CREATE_DIRECTORY_METHOD: &str = "fs/createDirectory";
pub const FS_GET_METADATA_METHOD: &str = "fs/getMetadata";
pub const FS_GET_METADATA_BATCH_METHOD: &str = "fs/getMetadataBatch";
pub const FS_FIND_UP_METHOD: &str = "fs/findUp";
pub const FS_CANONICALIZE_METHOD: &str = "fs/canonicalize";
pub const FS_READ_DIRECTORY_METHOD: &str = "fs/readDirectory";
pub const FS_WALK_METHOD: &str = "fs/walk";
@@ -370,6 +375,17 @@ pub struct FsGetMetadataBatchResponse {
pub results: Vec<FsGetMetadataBatchResult>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FsFindUpParams {
pub start: PathUri,
#[serde(flatten)]
pub options: FindUpOptions,
pub sandbox: Option<FileSystemSandboxContext>,
}
pub type FsFindUpResponse = FindUpOutcome;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FsCanonicalizeParams {
@@ -599,6 +615,9 @@ mod tests {
use super::EnvironmentInfo;
use super::ExecExitedNotification;
use super::ExecParams;
use super::FindUpErrorPolicy;
use super::FindUpMatchKind;
use super::FsFindUpParams;
use super::FsGetMetadataBatchResponse;
use super::FsReadFileParams;
use super::HttpRequestParams;
@@ -703,6 +722,14 @@ mod tests {
"sandbox": null,
}))
.expect_err("native absolute path should not deserialize as a URI");
serde_json::from_value::<FsFindUpParams>(serde_json::json!({
"start": native_path.to_string_lossy(),
"candidateRelativePaths": ["marker"],
"matchKind": FindUpMatchKind::Any,
"nonNotFoundErrorPolicy": FindUpErrorPolicy::Propagate,
"sandbox": null,
}))
.expect_err("native absolute find-up start should not deserialize as a URI");
let sandbox = FileSystemSandboxContext::from_permission_profile_with_cwd(
PermissionProfile::default(),
+10
View File
@@ -53,6 +53,7 @@ use crate::protocol::FS_CANONICALIZE_METHOD;
use crate::protocol::FS_CLOSE_METHOD;
use crate::protocol::FS_COPY_METHOD;
use crate::protocol::FS_CREATE_DIRECTORY_METHOD;
use crate::protocol::FS_FIND_UP_METHOD;
use crate::protocol::FS_GET_METADATA_BATCH_METHOD;
use crate::protocol::FS_GET_METADATA_METHOD;
use crate::protocol::FS_OPEN_METHOD;
@@ -70,6 +71,8 @@ use crate::protocol::FsCopyParams;
use crate::protocol::FsCopyResponse;
use crate::protocol::FsCreateDirectoryParams;
use crate::protocol::FsCreateDirectoryResponse;
use crate::protocol::FsFindUpParams;
use crate::protocol::FsFindUpResponse;
use crate::protocol::FsGetMetadataBatchParams;
use crate::protocol::FsGetMetadataBatchResponse;
use crate::protocol::FsGetMetadataParams;
@@ -716,6 +719,13 @@ impl ExecServerClient {
self.call(FS_GET_METADATA_BATCH_METHOD, &params).await
}
pub async fn fs_find_up(
&self,
params: FsFindUpParams,
) -> Result<FsFindUpResponse, ExecServerError> {
self.call(FS_FIND_UP_METHOD, &params).await
}
pub async fn fs_canonicalize(
&self,
params: FsCanonicalizeParams,
+10
View File
@@ -53,6 +53,14 @@ pub use codex_file_system::FileMetadata;
pub use codex_file_system::FileSystemReadStream;
pub use codex_file_system::FileSystemResult;
pub use codex_file_system::FileSystemSandboxContext;
pub use codex_file_system::FindUpErrorPolicy;
pub use codex_file_system::FindUpMatch;
pub use codex_file_system::FindUpMatchKind;
pub use codex_file_system::FindUpOptions;
pub use codex_file_system::FindUpOutcome;
pub use codex_file_system::MAX_FIND_UP_CANDIDATE_BYTES;
pub use codex_file_system::MAX_FIND_UP_CANDIDATES;
pub use codex_file_system::MAX_FIND_UP_TOTAL_CANDIDATE_BYTES;
pub use codex_file_system::ReadDirectoryEntry;
pub use codex_file_system::RemoveOptions;
pub use codex_file_system::WalkEntry;
@@ -110,6 +118,8 @@ pub use protocol::FsCopyParams;
pub use protocol::FsCopyResponse;
pub use protocol::FsCreateDirectoryParams;
pub use protocol::FsCreateDirectoryResponse;
pub use protocol::FsFindUpParams;
pub use protocol::FsFindUpResponse;
pub use protocol::FsGetMetadataBatchParams;
pub use protocol::FsGetMetadataBatchResponse;
pub use protocol::FsGetMetadataBatchResult;
@@ -13,6 +13,8 @@ use crate::FileMetadata;
use crate::FileSystemReadStream;
use crate::FileSystemResult;
use crate::FileSystemSandboxContext;
use crate::FindUpOptions;
use crate::FindUpOutcome;
use crate::ReadDirectoryEntry;
use crate::RemoveOptions;
use crate::WalkOptions;
@@ -21,6 +23,7 @@ use crate::client::LazyRemoteExecServerClient;
use crate::protocol::FsCanonicalizeParams;
use crate::protocol::FsCopyParams;
use crate::protocol::FsCreateDirectoryParams;
use crate::protocol::FsFindUpParams;
use crate::protocol::FsGetMetadataBatchParams;
use crate::protocol::FsGetMetadataBatchResult;
use crate::protocol::FsGetMetadataParams;
@@ -211,6 +214,34 @@ impl RemoteFileSystem {
.collect())
}
async fn find_up(
&self,
start: &PathUri,
options: &FindUpOptions,
sandbox: Option<&FileSystemSandboxContext>,
) -> FileSystemResult<FindUpOutcome> {
trace!("remote fs find_up");
let client = self.client.get().await.map_err(map_remote_error)?;
match client
.fs_find_up(FsFindUpParams {
start: start.clone(),
options: options.clone(),
sandbox: remote_sandbox_context(sandbox),
})
.await
{
Ok(response) => Ok(response),
Err(ExecServerError::Server {
code: METHOD_NOT_FOUND_ERROR_CODE,
..
}) => {
<Self as ExecutorFileSystem>::find_up_via_metadata(self, start, options, sandbox)
.await
}
Err(error) => Err(map_remote_error(error)),
}
}
async fn read_directory(
&self,
path: &PathUri,
@@ -370,6 +401,15 @@ impl ExecutorFileSystem for RemoteFileSystem {
Box::pin(RemoteFileSystem::get_metadata_batch(self, paths, sandbox))
}
fn find_up<'a>(
&'a self,
start: &'a PathUri,
options: &'a FindUpOptions,
sandbox: Option<&'a FileSystemSandboxContext>,
) -> ExecutorFileSystemFuture<'a, FindUpOutcome> {
Box::pin(RemoteFileSystem::find_up(self, start, options, sandbox))
}
fn read_directory<'a>(
&'a self,
path: &'a PathUri,
@@ -21,6 +21,8 @@ use crate::protocol::FsCopyParams;
use crate::protocol::FsCopyResponse;
use crate::protocol::FsCreateDirectoryParams;
use crate::protocol::FsCreateDirectoryResponse;
use crate::protocol::FsFindUpParams;
use crate::protocol::FsFindUpResponse;
use crate::protocol::FsGetMetadataBatchParams;
use crate::protocol::FsGetMetadataBatchResponse;
use crate::protocol::FsGetMetadataBatchResult;
@@ -205,6 +207,16 @@ impl FileSystemHandler {
Ok(FsGetMetadataBatchResponse { results })
}
pub(crate) async fn find_up(
&self,
params: FsFindUpParams,
) -> Result<FsFindUpResponse, JSONRPCErrorError> {
self.file_system
.find_up(&params.start, &params.options, params.sandbox.as_ref())
.await
.map_err(map_fs_error)
}
pub(crate) async fn canonicalize(
&self,
params: FsCanonicalizeParams,
@@ -25,6 +25,8 @@ use crate::protocol::FsCopyParams;
use crate::protocol::FsCopyResponse;
use crate::protocol::FsCreateDirectoryParams;
use crate::protocol::FsCreateDirectoryResponse;
use crate::protocol::FsFindUpParams;
use crate::protocol::FsFindUpResponse;
use crate::protocol::FsGetMetadataBatchParams;
use crate::protocol::FsGetMetadataBatchResponse;
use crate::protocol::FsGetMetadataParams;
@@ -310,6 +312,14 @@ impl ExecServerHandler {
self.file_system.get_metadata_batch(params).await
}
pub(crate) async fn fs_find_up(
&self,
params: FsFindUpParams,
) -> Result<FsFindUpResponse, JSONRPCErrorError> {
self.require_initialized_for("filesystem")?;
self.file_system.find_up(params).await
}
pub(crate) async fn fs_canonicalize(
&self,
params: FsCanonicalizeParams,
@@ -11,6 +11,7 @@ use crate::protocol::FS_CANONICALIZE_METHOD;
use crate::protocol::FS_CLOSE_METHOD;
use crate::protocol::FS_COPY_METHOD;
use crate::protocol::FS_CREATE_DIRECTORY_METHOD;
use crate::protocol::FS_FIND_UP_METHOD;
use crate::protocol::FS_GET_METADATA_BATCH_METHOD;
use crate::protocol::FS_GET_METADATA_METHOD;
use crate::protocol::FS_OPEN_METHOD;
@@ -24,6 +25,7 @@ use crate::protocol::FsCanonicalizeParams;
use crate::protocol::FsCloseParams;
use crate::protocol::FsCopyParams;
use crate::protocol::FsCreateDirectoryParams;
use crate::protocol::FsFindUpParams;
use crate::protocol::FsGetMetadataBatchParams;
use crate::protocol::FsGetMetadataParams;
use crate::protocol::FsOpenParams;
@@ -145,6 +147,12 @@ pub(crate) fn build_router() -> RpcRouter<ExecServerHandler> {
handler.fs_get_metadata_batch(params).await
},
);
router.request(
FS_FIND_UP_METHOD,
|handler: Arc<ExecServerHandler>, params: FsFindUpParams| async move {
handler.fs_find_up(params).await
},
);
router.request(
FS_CANONICALIZE_METHOD,
|handler: Arc<ExecServerHandler>, params: FsCanonicalizeParams| async move {
@@ -4,6 +4,14 @@ use codex_exec_server::CopyOptions;
use codex_exec_server::CreateDirectoryOptions;
use codex_exec_server::FILE_READ_CHUNK_SIZE;
use codex_exec_server::FileMetadata;
use codex_exec_server::FindUpErrorPolicy;
use codex_exec_server::FindUpMatch;
use codex_exec_server::FindUpMatchKind;
use codex_exec_server::FindUpOptions;
use codex_exec_server::FindUpOutcome;
use codex_exec_server::MAX_FIND_UP_CANDIDATE_BYTES;
use codex_exec_server::MAX_FIND_UP_CANDIDATES;
use codex_exec_server::MAX_FIND_UP_TOTAL_CANDIDATE_BYTES;
use codex_exec_server::ReadDirectoryEntry;
use codex_exec_server::RemoveOptions;
use codex_exec_server::WalkEntry;
@@ -158,6 +166,115 @@ async fn file_system_get_metadata_batch_preserves_order_and_errors(
Ok(())
}
#[test_case(FileSystemImplementation::Local ; "local")]
#[test_case(FileSystemImplementation::Remote ; "remote")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn file_system_find_up_preserves_ancestor_and_candidate_order(
implementation: FileSystemImplementation,
) -> Result<()> {
let context = create_file_system_context(implementation).await?;
let file_system = context.file_system;
let tmp = TempDir::new()?;
let parent = tmp.path().join("parent");
let start = parent.join("child");
std::fs::create_dir_all(&start)?;
std::fs::write(parent.join("first.marker"), "parent")?;
std::fs::write(start.join("second.marker"), "child")?;
let options = FindUpOptions {
candidate_relative_paths: vec!["first.marker".to_string(), "second.marker".to_string()],
match_kind: FindUpMatchKind::File,
non_not_found_error_policy: FindUpErrorPolicy::Propagate,
};
let start_uri = PathUri::from_host_native_path(&start)?;
let nearest = file_system
.find_up(&start_uri, &options, /*sandbox*/ None)
.await?;
assert_eq!(
nearest,
FindUpOutcome {
matched: Some(FindUpMatch {
ancestor: start_uri.clone(),
path: PathUri::from_host_native_path(start.join("second.marker"))?,
}),
visited_ancestor_count: 1,
metadata_probe_count: 2,
ignored_error_count: 0,
}
);
std::fs::write(start.join("first.marker"), "first")?;
let first = file_system
.find_up(&start_uri, &options, /*sandbox*/ None)
.await?;
assert_eq!(
first,
FindUpOutcome {
matched: Some(FindUpMatch {
ancestor: start_uri,
path: PathUri::from_host_native_path(start.join("first.marker"))?,
}),
visited_ancestor_count: 1,
metadata_probe_count: 1,
ignored_error_count: 0,
}
);
Ok(())
}
#[test_case(FileSystemImplementation::Local ; "local")]
#[test_case(FileSystemImplementation::Remote ; "remote")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn file_system_find_up_enforces_bounds_and_sandbox(
implementation: FileSystemImplementation,
) -> Result<()> {
let context = create_file_system_context(implementation).await?;
let file_system = context.file_system;
let tmp = TempDir::new()?;
let start = tmp.path().join("allowed/nested");
std::fs::create_dir_all(&start)?;
std::fs::write(start.join("marker"), "ok")?;
let start_uri = PathUri::from_host_native_path(&start)?;
let sandbox = read_only_sandbox(tmp.path().join("allowed"));
let options = FindUpOptions {
candidate_relative_paths: vec!["marker".to_string()],
match_kind: FindUpMatchKind::Any,
non_not_found_error_policy: FindUpErrorPolicy::Propagate,
};
assert!(
file_system
.find_up(&start_uri, &options, Some(&sandbox))
.await?
.matched
.is_some()
);
for invalid_candidates in [
vec!["marker".to_string(); MAX_FIND_UP_CANDIDATES + 1],
vec!["x".repeat(MAX_FIND_UP_CANDIDATE_BYTES + 1)],
vec![
"x".repeat(MAX_FIND_UP_TOTAL_CANDIDATE_BYTES / MAX_FIND_UP_CANDIDATES + 1);
MAX_FIND_UP_CANDIDATES
],
vec!["../marker".to_string()],
] {
let error = file_system
.find_up(
&start_uri,
&FindUpOptions {
candidate_relative_paths: invalid_candidates,
..options.clone()
},
Some(&sandbox),
)
.await
.expect_err("invalid candidates should fail closed");
assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
}
Ok(())
}
#[test_case(FileSystemImplementation::Local ; "local")]
#[test_case(FileSystemImplementation::Remote ; "remote")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+137 -1
View File
@@ -7,9 +7,13 @@ use futures::StreamExt;
use std::io;
const MAX_CONCURRENT_PROBES: usize = 8;
pub const MAX_FIND_UP_CANDIDATES: usize = 16;
pub const MAX_FIND_UP_CANDIDATE_BYTES: usize = 256;
pub const MAX_FIND_UP_TOTAL_CANDIDATE_BYTES: usize = 2 * 1024;
/// Controls how an upward marker search handles metadata errors other than `NotFound`.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub enum FindUpErrorPolicy {
/// Return the first error in lexical search order.
Propagate,
@@ -17,6 +21,138 @@ pub enum FindUpErrorPolicy {
Ignore,
}
/// Filesystem entry kind that qualifies as an upward-search match.
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub enum FindUpMatchKind {
Any,
File,
Directory,
}
impl FindUpMatchKind {
fn matches(self, metadata: &crate::FileMetadata) -> bool {
match self {
Self::Any => true,
Self::File => metadata.is_file,
Self::Directory => metadata.is_directory,
}
}
}
/// Bounded options for an upward filesystem search.
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct FindUpOptions {
/// Relative paths tested in order beneath each ancestor.
pub candidate_relative_paths: Vec<String>,
pub match_kind: FindUpMatchKind,
pub non_not_found_error_policy: FindUpErrorPolicy,
}
/// First qualifying path found by an upward filesystem search.
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct FindUpMatch {
pub ancestor: PathUri,
pub path: PathUri,
}
/// Result and generic work counters from an upward filesystem search.
#[derive(Clone, Debug, Default, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct FindUpOutcome {
pub matched: Option<FindUpMatch>,
pub visited_ancestor_count: usize,
pub metadata_probe_count: usize,
pub ignored_error_count: usize,
}
pub(super) async fn find_up_via_metadata(
file_system: &(impl ExecutorFileSystem + ?Sized),
start: &PathUri,
options: &FindUpOptions,
sandbox: Option<&FileSystemSandboxContext>,
) -> FileSystemResult<FindUpOutcome> {
validate_find_up_options(start, options)?;
let mut outcome = FindUpOutcome::default();
for ancestor in start.ancestors() {
outcome.visited_ancestor_count += 1;
for candidate in &options.candidate_relative_paths {
let path = checked_candidate_path(&ancestor, candidate)?;
outcome.metadata_probe_count += 1;
match file_system.get_metadata(&path, sandbox).await {
Ok(metadata) if options.match_kind.matches(&metadata) => {
outcome.matched = Some(FindUpMatch { ancestor, path });
return Ok(outcome);
}
Ok(_) => {}
Err(err) if err.kind() == io::ErrorKind::NotFound => {}
Err(err) => match options.non_not_found_error_policy {
FindUpErrorPolicy::Propagate => return Err(err),
FindUpErrorPolicy::Ignore => outcome.ignored_error_count += 1,
},
}
}
}
Ok(outcome)
}
fn validate_find_up_options(start: &PathUri, options: &FindUpOptions) -> FileSystemResult<()> {
let candidate_count = options.candidate_relative_paths.len();
if candidate_count == 0 || candidate_count > MAX_FIND_UP_CANDIDATES {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"filesystem find-up candidate count must be between 1 and {MAX_FIND_UP_CANDIDATES}, got {candidate_count}"
),
));
}
let mut total_bytes = 0usize;
for candidate in &options.candidate_relative_paths {
let candidate_bytes = candidate.len();
if candidate_bytes == 0 || candidate_bytes > MAX_FIND_UP_CANDIDATE_BYTES {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"filesystem find-up candidates must be between 1 and {MAX_FIND_UP_CANDIDATE_BYTES} bytes"
),
));
}
total_bytes = total_bytes.checked_add(candidate_bytes).ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
"filesystem find-up candidate size overflow",
)
})?;
checked_candidate_path(start, candidate)?;
}
if total_bytes > MAX_FIND_UP_TOTAL_CANDIDATE_BYTES {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"filesystem find-up candidates must not exceed {MAX_FIND_UP_TOTAL_CANDIDATE_BYTES} bytes total"
),
));
}
Ok(())
}
fn checked_candidate_path(ancestor: &PathUri, candidate: &str) -> FileSystemResult<PathUri> {
let path = ancestor
.join(candidate)
.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?;
if path == *ancestor || !path.starts_with(ancestor) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("filesystem find-up candidate {candidate:?} escapes ancestor {ancestor}"),
));
}
Ok(path)
}
/// Finds the nearest ancestor containing one of the provided marker names.
///
/// Marker paths are probed in lexical order from `start` toward the filesystem root. A bounded
+29
View File
@@ -1,6 +1,13 @@
mod find_up;
pub use find_up::FindUpErrorPolicy;
pub use find_up::FindUpMatch;
pub use find_up::FindUpMatchKind;
pub use find_up::FindUpOptions;
pub use find_up::FindUpOutcome;
pub use find_up::MAX_FIND_UP_CANDIDATE_BYTES;
pub use find_up::MAX_FIND_UP_CANDIDATES;
pub use find_up::MAX_FIND_UP_TOTAL_CANDIDATE_BYTES;
pub use find_up::find_nearest_ancestor_with_markers;
pub use find_up::find_nearest_native_ancestor_with_markers;
@@ -324,6 +331,28 @@ pub trait ExecutorFileSystem: Send + Sync {
})
}
/// Finds the nearest ancestor containing a qualifying candidate path.
fn find_up<'a>(
&'a self,
start: &'a PathUri,
options: &'a FindUpOptions,
sandbox: Option<&'a FileSystemSandboxContext>,
) -> ExecutorFileSystemFuture<'a, FindUpOutcome> {
self.find_up_via_metadata(start, options, sandbox)
}
/// Performs an ordered upward search using primitive metadata operations.
///
/// Implementations with an optimized transport can use this as a compatibility fallback.
fn find_up_via_metadata<'a>(
&'a self,
start: &'a PathUri,
options: &'a FindUpOptions,
sandbox: Option<&'a FileSystemSandboxContext>,
) -> ExecutorFileSystemFuture<'a, FindUpOutcome> {
Box::pin(find_up::find_up_via_metadata(self, start, options, sandbox))
}
fn read_directory<'a>(
&'a self,
path: &'a PathUri,