diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e2a6db10..40c1cd87 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -632,6 +632,18 @@ OpenAI login credentials. HTTPS certificate/hostname verification remains enable The separately named command does not establish stock-command compatibility or enable public Environment admission. Linux x86_64 is its initial deployment target. +The executor build also provides a small `agents-api-codex-directory` helper for +bounded, descriptor-scoped directory observations where the pinned native walk +cannot maintain path isolation during concurrent ancestor replacement. Use the +existing native process API, explicit argv and a qualified read-only sandbox; +keep the executable at a trusted operator path outside the writable workspace. +The adapter supplies its frozen workspace root. Enumerate and stat using retained +no-follow directory descriptors, bound scanning before collecting all names, and +require complete output plus native exit/close settlement. This is a private +adapter prerequisite, not a new public protocol, transport or filesystem framework. +The helper alone grants no tenant authority, public Files admission, snapshot or +workspace-replacement guarantee. Preserve the stock executor/model loop. + Native app-server placement is a prerequisite to typed dispatch. The pinned Codex app-server accepts registry configuration at startup; use explicit native Environment selections for the first thread and every Turn. Resume does not restore selections diff --git a/packages/codex-executor/Cargo.lock b/packages/codex-executor/Cargo.lock index 2a07c215..050fa70b 100644 --- a/packages/codex-executor/Cargo.lock +++ b/packages/codex-executor/Cargo.lock @@ -62,6 +62,7 @@ dependencies = [ "codex-exec-server", "codex-http-client", "http", + "rustix", "serde", "serde_json", "tokio", diff --git a/packages/codex-executor/Cargo.toml b/packages/codex-executor/Cargo.toml index 9300a611..a168ac2a 100644 --- a/packages/codex-executor/Cargo.toml +++ b/packages/codex-executor/Cargo.toml @@ -5,12 +5,17 @@ edition = "2024" license = "Apache-2.0" publish = false +[[bin]] +name = "agents-api-codex-directory" +path = "src/bin/directory.rs" + [dependencies] codex-api = { git = "https://github.com/openai/codex", rev = "3d2ee51ca2d5db578f328aa75e20aa22c0197c9a" } codex-exec-server = { git = "https://github.com/openai/codex", rev = "3d2ee51ca2d5db578f328aa75e20aa22c0197c9a" } codex-http-client = { git = "https://github.com/openai/codex", rev = "3d2ee51ca2d5db578f328aa75e20aa22c0197c9a" } clap = { version = "4", features = ["derive"] } http = "1.3.1" +rustix = { version = "=1.1.4", features = ["fs"] } serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal"] } diff --git a/packages/codex-executor/README.md b/packages/codex-executor/README.md index bad33f69..0f87be4b 100644 --- a/packages/codex-executor/README.md +++ b/packages/codex-executor/README.md @@ -36,6 +36,36 @@ argv0 and sandbox helper modes execute that official binary; none are copied int the launcher. System/container sandbox permissions must support the requested native policy. Do not interpret an unsandboxed command test as sandbox validation. +## Scoped directory helper + +The build also emits `agents-api-codex-directory` for the current directory-listing +adapter gap. Install it at an operator-controlled absolute path on the executor, +outside the writable workspace. Its three argv values are the authorized absolute +workspace root, a relative directory (empty for the root), and a limit of 1–4096. +Invoke it directly through the existing authenticated native process API with a +read-only filesystem policy and restricted network. No shell or model is involved. +The helper itself is a local program, not an authorization service: the caller +must bind the root to the exact authorized owner and validate the installation. + +It opens every directory component without following symlinks, retains directory +descriptors for enumeration and metadata, and stops after the limit plus one +entry. It returns one version-1 JSON response: `directory.entries` contains +`name`, `kind` (`file`, `directory`, `symlink`, `other`) and nullable `size_bytes`; +`directory.truncated` reports lookahead. Only regular files have sizes. Errors +use `error` with no partial entries. Invalid paths/names and oversized responses +are rejected. Descriptor cleanup precedes output; exit zero alone is not success, +since a settled error also returns JSON. Require valid complete JSON, a successful +exit and native output-close receipt before accepting an observation. Unknown +start/termination/transport results remain uncertain and must not be retried as +settled reads. + +This one-level observation has no order, paging or snapshot guarantee. Renames may +leave an operation reading the directory it already opened; workspace replacement +and cross-tenant placement remain the caller's responsibility. The helper does +not change stock native filesystem methods, create a daemon connection, or enable +public Files. The [native fixture](../../services/agents-api/tests/native/directory/README.md) +qualifies the standalone helper independently of later adapter/public wiring. + ## Connect an executor An operator creates an executor principal key with diff --git a/packages/codex-executor/src/bin/directory.rs b/packages/codex-executor/src/bin/directory.rs new file mode 100644 index 00000000..e85007c5 --- /dev/null +++ b/packages/codex-executor/src/bin/directory.rs @@ -0,0 +1,57 @@ +use rustix::fs::FileType; +#[path = "../directory.rs"] +mod directory; +use directory::{anchor, directory, observe}; +use serde_json::json; +use std::{io, path::Path}; + +fn run() -> io::Result { + let args: Vec<_> = std::env::args().skip(1).collect(); + if args.len() != 3 { + return Err(io::ErrorKind::InvalidInput.into()); + } + let limit = args[2] + .parse() + .map_err(|_| io::Error::from(io::ErrorKind::InvalidInput))?; + if !(1..=4096).contains(&limit) { + return Err(io::ErrorKind::InvalidInput.into()); + } + let root = anchor(Path::new(&args[0]))?; + let selected = directory(&root, &args[1])?; + let result = observe(&selected, limit)?; + let entries: Vec<_> = result + .entries + .into_iter() + .map(|entry| { + let kind = match entry.kind { + FileType::RegularFile => "file", + FileType::Directory => "directory", + FileType::Symlink => "symlink", + _ => "other", + }; + json!({"name": entry.name, "kind": kind, "size_bytes": entry.size}) + }) + .collect(); + Ok(json!({"version": 1, "directory": {"entries": entries, "truncated": result.truncated}})) +} + +fn main() -> std::process::ExitCode { + let response = match run() { + Ok(value) => value, + Err(error) => json!({"version": 1, "error": match error.kind() { + io::ErrorKind::NotFound => "not_found", + io::ErrorKind::PermissionDenied => "permission_denied", + io::ErrorKind::InvalidInput | io::ErrorKind::NotADirectory => "invalid_path", + _ => "native_error", + }}), + }; + let bytes = match serde_json::to_vec(&response) { + Ok(bytes) if bytes.len() <= 4 * 1024 * 1024 => bytes, + _ => b"{\"version\":1,\"error\":\"too_large\"}".to_vec(), + }; + use io::Write; + match io::stdout().lock().write_all(&bytes) { + Ok(()) => std::process::ExitCode::SUCCESS, + Err(_) => std::process::ExitCode::FAILURE, + } +} diff --git a/packages/codex-executor/src/directory.rs b/packages/codex-executor/src/directory.rs new file mode 100644 index 00000000..5eb5dd6c --- /dev/null +++ b/packages/codex-executor/src/directory.rs @@ -0,0 +1,126 @@ +use rustix::fs::{AtFlags, Dir, FileType, Mode, OFlags, open, openat, statat}; +use std::io; +use std::os::fd::OwnedFd; +use std::path::{Component, Path}; + +#[derive(Debug, PartialEq)] +pub(crate) struct Entry { + pub name: String, + pub kind: FileType, + pub size: Option, +} + +#[derive(Debug)] +pub(crate) struct Observation { + pub entries: Vec, + pub truncated: bool, + pub visited: usize, +} + +fn invalid() -> io::Error { + io::Error::new(io::ErrorKind::InvalidInput, "invalid directory request") +} + +pub(crate) fn anchor(root: &Path) -> io::Result { + if !root.is_absolute() || root.as_os_str().len() > 4096 { + return Err(invalid()); + } + let mut fd = open( + "/", + OFlags::PATH | OFlags::DIRECTORY | OFlags::CLOEXEC, + Mode::empty(), + )?; + for part in root.components() { + match part { + Component::RootDir => (), + Component::Normal(name) => { + fd = openat( + &fd, + name, + OFlags::PATH | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::empty(), + )?; + } + _ => return Err(invalid()), + } + } + Ok(fd) +} + +pub(crate) fn directory(root: &OwnedFd, relative: &str) -> io::Result { + if relative.len() > 4096 { + return Err(invalid()); + } + let mut fd = openat( + root, + ".", + OFlags::PATH | OFlags::DIRECTORY | OFlags::CLOEXEC, + Mode::empty(), + )?; + if !relative.is_empty() { + for part in relative.split('/') { + if part.is_empty() + || part == "." + || part == ".." + || part.contains(['\\', '\0', '\r', '\n']) + { + return Err(invalid()); + } + fd = openat( + &fd, + part, + OFlags::PATH | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::empty(), + )?; + } + } + Ok(openat( + &fd, + ".", + OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC, + Mode::empty(), + )?) +} + +pub(crate) fn observe(fd: &OwnedFd, limit: usize) -> io::Result { + if !(1..=4096).contains(&limit) { + return Err(invalid()); + } + let mut result = Observation { + entries: Vec::with_capacity(limit), + truncated: false, + visited: 0, + }; + for entry in Dir::read_from(fd)? { + let entry = entry?; + let name = entry.file_name().to_str().map_err(|_| invalid())?; + if name == "." || name == ".." { + continue; + } + result.visited += 1; + if result.entries.len() == limit { + result.truncated = true; + break; + } + if name.len() > 255 || name.contains(['\\', '\0', '\r', '\n']) { + return Err(invalid()); + } + let metadata = statat(fd, entry.file_name(), AtFlags::SYMLINK_NOFOLLOW)?; + let kind = FileType::from_raw_mode(metadata.st_mode); + let size = if kind == FileType::RegularFile { + Some(u64::try_from(metadata.st_size).map_err(|_| invalid())?) + } else { + None + }; + result.entries.push(Entry { + name: name.into(), + kind, + size, + }); + } + Ok(result) +} + +#[cfg(test)] +#[path = "directory_tests.rs"] +mod tests; diff --git a/packages/codex-executor/src/directory_tests.rs b/packages/codex-executor/src/directory_tests.rs new file mode 100644 index 00000000..da31c18f --- /dev/null +++ b/packages/codex-executor/src/directory_tests.rs @@ -0,0 +1,155 @@ +use super::*; +use std::fs; +use std::os::unix::fs::symlink; +use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, Ordering}; + +static TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +struct Fixture { + path: PathBuf, + _guard: std::sync::MutexGuard<'static, ()>, +} +impl Fixture { + fn new() -> Self { + static NEXT: AtomicU64 = AtomicU64::new(0); + let guard = TEST_LOCK.lock().unwrap(); + let root = PathBuf::from(std::env::var_os("HOME").expect("HOME required")) + .join(".parsar/tests/scoped-directory"); + fs::create_dir_all(&root).unwrap(); + let path = root.join(format!( + "fixture-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir(&path).unwrap(); + Self { + path, + _guard: guard, + } + } +} +impl Drop for Fixture { + fn drop(&mut self) { + fs::remove_dir_all(&self.path).unwrap(); + } +} + +#[test] +fn ancestor_replacement_after_open_stays_on_authorized_directory() { + let f = Fixture::new(); + fs::create_dir_all(f.path.join("workspace/a/sub")).unwrap(); + fs::create_dir_all(f.path.join("outside/sub")).unwrap(); + fs::write(f.path.join("workspace/a/sub/inside"), b"inside").unwrap(); + fs::write(f.path.join("workspace/a/sub/outside-secret"), b"decoy").unwrap(); + fs::write(f.path.join("outside/sub/outside-secret"), b"outside-secret").unwrap(); + let root = anchor(&f.path.join("workspace")).unwrap(); + let selected = directory(&root, "a/sub").unwrap(); + fs::rename(f.path.join("workspace/a"), f.path.join("workspace/held")).unwrap(); + symlink(f.path.join("outside"), f.path.join("workspace/a")).unwrap(); + let path_names: Vec<_> = fs::read_dir(f.path.join("workspace/a/sub")) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .collect(); + assert_eq!(path_names, ["outside-secret"]); + let mut safe = observe(&selected, 16).unwrap(); + safe.entries.sort_by(|a, b| a.name.cmp(&b.name)); + assert_eq!( + safe.entries, + [ + Entry { + name: "inside".into(), + kind: FileType::RegularFile, + size: Some(6) + }, + Entry { + name: "outside-secret".into(), + kind: FileType::RegularFile, + size: Some(5) + }, + ] + ); + assert!(!safe.truncated); + assert!(directory(&root, "a/sub").is_err()); + fs::remove_file(f.path.join("workspace/a")).unwrap(); + fs::rename(f.path.join("workspace/held"), f.path.join("workspace/a")).unwrap(); + for name in path_names { + assert!( + fs::symlink_metadata(f.path.join("workspace/a/sub").join(name)) + .unwrap() + .is_file() + ); + } +} + +#[test] +fn ancestor_replacement_between_component_opens_stays_on_original_fd() { + let f = Fixture::new(); + fs::create_dir_all(f.path.join("workspace/a/sub")).unwrap(); + fs::create_dir_all(f.path.join("outside/sub")).unwrap(); + fs::write(f.path.join("workspace/a/sub/inside"), b"inside").unwrap(); + fs::write(f.path.join("outside/sub/outside"), b"outside").unwrap(); + let root = anchor(&f.path.join("workspace")).unwrap(); + let parent = directory(&root, "a").unwrap(); + fs::rename(f.path.join("workspace/a"), f.path.join("workspace/held")).unwrap(); + symlink(f.path.join("outside"), f.path.join("workspace/a")).unwrap(); + let selected = directory(&parent, "sub").unwrap(); + assert_eq!(observe(&selected, 1).unwrap().entries[0].name, "inside"); +} + +#[test] +fn bound_applies_before_collecting_directory_names() { + let f = Fixture::new(); + for i in 0..5000 { + fs::write(f.path.join(format!("entry-{i:05}")), b"x").unwrap(); + } + let root = anchor(&f.path).unwrap(); + let selected = directory(&root, "").unwrap(); + let result = observe(&selected, 3).unwrap(); + assert_eq!(result.entries.len(), 3); + assert_eq!(result.visited, 4); + assert!(result.truncated); +} + +#[test] +fn symlink_metadata_does_not_follow_the_target_and_invalid_paths_fail() { + let f = Fixture::new(); + fs::create_dir(f.path.join("workspace")).unwrap(); + fs::write( + f.path.join("outside"), + b"must not become the returned file size", + ) + .unwrap(); + symlink(f.path.join("outside"), f.path.join("workspace/link")).unwrap(); + let root = anchor(&f.path.join("workspace")).unwrap(); + let selected = directory(&root, "").unwrap(); + let result = observe(&selected, 1).unwrap(); + assert_eq!( + result.entries, + [Entry { + name: "link".into(), + kind: FileType::Symlink, + size: None + }] + ); + assert!(!result.truncated); + for path in ["/outside", "..", "a/../b", ".", "a//b", "a/", "a\\b", "a\0"] { + assert!(directory(&root, path).is_err(), "{path:?}"); + } + assert!(observe(&selected, 0).is_err()); + assert!(observe(&selected, 4097).is_err()); +} + +#[test] +fn repeated_reads_release_directory_descriptors() { + let f = Fixture::new(); + let count = || fs::read_dir("/proc/self/fd").unwrap().count(); + let before = count(); + for _ in 0..200 { + let root = anchor(&f.path).unwrap(); + let selected = directory(&root, "").unwrap(); + let result = observe(&selected, 1).unwrap(); + assert!(result.entries.is_empty() && !result.truncated); + } + assert_eq!(count(), before); +} diff --git a/scripts/build-agents-executor.sh b/scripts/build-agents-executor.sh index a4562f65..94367455 100755 --- a/scripts/build-agents-executor.sh +++ b/scripts/build-agents-executor.sh @@ -35,6 +35,8 @@ cp -R "$repo_root/packages/codex-executor/src" "$build_context/src" cargo build --locked --release ) mkdir -p "$output_dir" -cp "$CARGO_TARGET_DIR/release/agents-api-codex-executor" "$output_dir/agents-api-codex-executor.tmp" -mv -f "$output_dir/agents-api-codex-executor.tmp" "$output_dir/agents-api-codex-executor" -printf 'Standalone Codex executor: %s\n' "$output_dir/agents-api-codex-executor" +for binary in agents-api-codex-executor agents-api-codex-directory; do + cp "$CARGO_TARGET_DIR/release/$binary" "$output_dir/$binary.tmp" + mv -f "$output_dir/$binary.tmp" "$output_dir/$binary" +done +printf 'Standalone Codex executor and directory helper: %s\n' "$output_dir" diff --git a/services/agents-api/internal/store/native_executor_directory_test.go b/services/agents-api/internal/store/native_executor_directory_test.go new file mode 100644 index 00000000..718afd72 --- /dev/null +++ b/services/agents-api/internal/store/native_executor_directory_test.go @@ -0,0 +1,108 @@ +package store_test + +import ( + "context" + "encoding/json" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/executor/codex" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +func TestNativeExecutorDirectoryHelper(t *testing.T) { + probe, helper, proof := os.Getenv("PARSAR_DIRECTORY_PROBE"), os.Getenv("PARSAR_DIRECTORY_HELPER"), os.Getenv("PARSAR_EXECUTOR_PROOF_DIR") + if probe == "" || helper == "" || proof == "" { + t.Skip("private directory qualification binaries required") + } + ctx, cancel := context.WithTimeout(t.Context(), 2*time.Minute) + defer cancel() + root, err := os.MkdirTemp(proof, "native-directory-") + if err != nil { + t.Fatal(err) + } + local, workspace := filepath.Join(root, "workspace"), "/scoped-directory-"+uuid.NewString() + for _, name := range []string{"workspace/sub", "workspace/empty", "executor/codex", "harness"} { + if err := os.MkdirAll(filepath.Join(root, name), 0700); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(local, "retained.txt"), []byte("retained"), 0600); err != nil { + t.Fatal(err) + } + if err := os.Symlink("/etc", filepath.Join(local, "a")); err != nil { + t.Fatal(err) + } + for i := 0; i < 5000; i++ { + if err := os.WriteFile(filepath.Join(local, "sub", strconv.Itoa(i)), []byte("x"), 0600); err != nil { + t.Fatal(err) + } + } + s, _ := store.NewTestStore(t) + lease, err := s.AcquireExecutionLease(ctx) + if err != nil { + t.Fatal(err) + } + defer lease.Close(context.Background()) + tenant := uuid.NewString() + principal := store.FixtureExecutorPrincipal(t, s, tenant) + configuration, _ := json.Marshal(map[string]any{"environment": map[string]any{"type": "self_hosted", "workspace_directory": workspace, "capability_directories": []string{}}}) + session, err := s.CreateSession(ctx, tenant, store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: "directory-primitive", Configuration: configuration}) + if err != nil { + t.Fatal(err) + } + environment, err := s.GetSessionEnvironment(ctx, tenant, session.ID) + if err != nil { + t.Fatal(err) + } + credential, err := s.IssueExecutorCredential(ctx, principal, environment.ID, environment.ID) + if err != nil { + t.Fatal(err) + } + server := httptest.NewUnstartedServer(nil) + registry, err := codex.New(codex.Config{Store: s, CheckOwnership: lease.Ping, ReplaceConnection: lease.Store().ReplaceEnvironmentConnection, ObserveConnection: lease.Store().ObserveEnvironmentConnection, PublicURL: "http://" + server.Listener.Addr().String()}) + if err != nil { + t.Fatal(err) + } + server.Config.Handler = registry.Handler() + server.Start() + defer func() { registry.Close(); server.Close() }() + container := startDaemonRemoteExecutor(t, ctx, root, local, workspace, os.Getenv("PARSAR_CODEX_BINARY"), os.Getenv("PARSAR_PLACEMENT_EXECUTOR_IMAGE"), server.URL, environment.ID, credential) + t.Cleanup(func() { _ = os.Remove(filepath.Join(root, "executor", "credential.json")) }) + if err := exec.CommandContext(ctx, "docker", "cp", helper, container+":/usr/local/bin/scoped-directory").Run(); err != nil { + t.Fatal("install qualification helper", err) + } + awaitDaemonRemoteCondition(t, ctx, 30*time.Second, "executor ready", func() bool { + connected, e := registry.Connected(ctx, tenant, environment.ID) + return e == nil && connected + }) + token, release, err := registry.IssueHarnessCredential(ctx, tenant, environment.ID) + if err != nil { + t.Fatal(err) + } + defer release() + command := exec.CommandContext(ctx, probe) + command.Dir = filepath.Join(root, "harness") + command.Env = []string{"PATH=" + os.Getenv("PATH"), "HOME=" + filepath.Join(root, "harness"), "PARSAR_NATIVE_ENV_PROOF=" + root, "PARSAR_DIRECTORY_WORKSPACE=" + workspace, "CODEX_EXEC_SERVER_NOISE_REGISTRY_URL=" + server.URL, "CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID=" + environment.ID, "CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN=" + token, "NO_PROXY=127.0.0.1,localhost", "RUST_LOG=off"} + output, err := command.CombinedOutput() + if err != nil { + message := strings.ReplaceAll(string(output), token, "[redacted]") + message = strings.ReplaceAll(message, credential.Token, "[redacted]") + t.Fatalf("native directory probe: %v: %s", err, message) + } + data, err := os.ReadFile(filepath.Join(root, "directory-native.json")) + if err != nil { + t.Fatal(err) + } + if !json.Valid(data) { + t.Fatal("invalid evidence") + } + t.Log("native directory evidence", root) +} diff --git a/services/agents-api/tests/native/directory/README.md b/services/agents-api/tests/native/directory/README.md new file mode 100644 index 00000000..02a24c66 --- /dev/null +++ b/services/agents-api/tests/native/directory/README.md @@ -0,0 +1,35 @@ +# Native directory helper acceptance + +`TestNativeExecutorDirectoryHelper` uses a real PostgreSQL registry, its issued +executor/harness credentials, the pinned native client and an actual Docker +executor. It directly invokes the installed helper through native process RPC, +requires the reported Linux sandbox, and waits for exit and output closure. +It checks root metadata, a 5,000-entry directory with bounded output, an empty +directory, symlink ancestry and invalid path/limit rejection. These are mechanism +tests with synthetic files and zero model calls. They do not establish public +Files compatibility, adapter lifecycle integration or real model execution. + +Build `probe.rs` as an example of `codex-exec-server` in an isolated export of +commit `3d2ee51ca2d5db578f328aa75e20aa22c0197c9a`, using the repository's pinned +native toolchain and the existing workspace-lock normalization. No native source +or protocol patch is required. Keep sources, build state and evidence below +`~/.parsar/`. Build the production helper with `make build-agents-executor`. + +Set the existing private integration-test database URL, `PARSAR_DIRECTORY_PROBE`, +`PARSAR_DIRECTORY_HELPER`, `PARSAR_EXECUTOR_PROOF_DIR`, `PARSAR_EXECUTOR_LAUNCHER`, +`PARSAR_CODEX_BINARY`, and the digest-pinned `PARSAR_PLACEMENT_EXECUTOR_IMAGE`. +The latter prerequisites follow the [native fixtures](../README.md). +Then run: + +```sh +go test ./services/agents-api/internal/store -run '^TestNativeExecutorDirectoryHelper$' -count=1 -v +``` + +The fixture removes its container and temporary credential. Evidence retains no +provider credentials. Directory-descriptor unit tests separately control ancestor +replacement between opens and before enumeration, verify the scan bound, and +check descriptor release. Production consumers must also qualify cancellation, +transport uncertainty, installation trust and their exact owner/authorization +binding before admission. Adapter changes require real model calls before/after +observations through the retained harness; this helper-only fixture is not a +replacement for that acceptance. diff --git a/services/agents-api/tests/native/directory/probe.rs b/services/agents-api/tests/native/directory/probe.rs new file mode 100644 index 00000000..7eb18794 --- /dev/null +++ b/services/agents-api/tests/native/directory/probe.rs @@ -0,0 +1,190 @@ +use std::collections::HashMap; +use std::path::PathBuf; +use std::time::Duration; + +use anyhow::{Context, Result, ensure}; +use codex_exec_server::{ + EnvironmentManager, ExecOutputStream, ExecParams, ExecProcess, FileSystemSandboxContext, + ProcessId, +}; +use codex_http_client::{HttpClientFactory, OutboundProxyPolicy}; +use codex_protocol::models::PermissionProfile; +use codex_protocol::permissions::{ + FileSystemAccessMode, FileSystemPath, FileSystemSandboxEntry, FileSystemSandboxPolicy, + FileSystemSpecialPath, NetworkSandboxPolicy, +}; +use codex_utils_path_uri::PathUri; +use tokio::time::timeout; + +const TIMEOUT: Duration = Duration::from_secs(15); + +#[tokio::main(flavor = "multi_thread", worker_threads = 4)] +async fn main() -> Result<()> { + timeout(Duration::from_secs(90), run()).await??; + Ok(()) +} + +async fn run() -> Result<()> { + let proof = PathBuf::from(std::env::var("PARSAR_NATIVE_ENV_PROOF")?); + let workspace = PathBuf::from(std::env::var("PARSAR_DIRECTORY_WORKSPACE")?); + let helper = PathBuf::from("/usr/local/bin/scoped-directory"); + let manager = EnvironmentManager::from_env( + None, + HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault), + ) + .await?; + ensure!(manager.try_local_environment().is_none(), "local fallback"); + let environment = manager + .default_environment() + .context("remote environment")?; + ensure!(environment.is_remote(), "remote backend required"); + timeout(TIMEOUT, environment.wait_until_ready()).await??; + let cwd = PathUri::from_host_native_path(&workspace)?; + let policy = FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry::new( + FileSystemPath::Path { + path: PathUri::from_host_native_path(&workspace)?, + }, + FileSystemAccessMode::Read, + ), + FileSystemSandboxEntry::new( + FileSystemPath::Path { + path: PathUri::from_host_native_path(&helper)?, + }, + FileSystemAccessMode::Read, + ), + FileSystemSandboxEntry::new( + FileSystemPath::Special { + value: FileSystemSpecialPath::Minimal, + }, + FileSystemAccessMode::Read, + ), + ]); + let sandbox = FileSystemSandboxContext::from_permission_profile_with_cwd( + PermissionProfile::from_runtime_permissions(&policy, NetworkSandboxPolicy::Restricted), + cwd.clone(), + ); + let mut observed = Vec::new(); + for (index, (relative, limit)) in [ + ("", 64), + ("sub", 1), + ("empty", 1), + ("a/sub", 16), + ("../outside", 2), + ("sub", 0), + ] + .into_iter() + .enumerate() + { + let mut request = params( + &format!("directory-{index}"), + &cwd, + vec![ + helper.to_string_lossy().into_owned(), + workspace.to_string_lossy().into_owned(), + relative.into(), + limit.to_string(), + ], + ); + request.sandbox = Some(sandbox.clone()); + let started = environment.get_exec_backend().start(request).await?; + let sandbox_type = format!("{:?}", started.sandbox_type); + ensure!( + sandbox_type == "Some(LinuxSeccomp)", + "required Linux sandbox missing: {sandbox_type}" + ); + let (stdout, stderr, exit) = read_closed(started.process.as_ref()).await?; + ensure!( + exit == Some(0) && stderr.is_empty(), + "directory helper execution failed: {:?} {}", + exit, + String::from_utf8_lossy(&stderr) + ); + let value: serde_json::Value = serde_json::from_slice(&stdout)?; + ensure!(value["version"] == 1, "helper version mismatch"); + if relative == "" { + let entries = value["directory"]["entries"] + .as_array() + .context("root entries")?; + ensure!(entries.len() == 4, "root count mismatch: {value}"); + ensure!( + entries + .iter() + .any(|e| e["name"] == "retained.txt" && e["size_bytes"] == 8), + "retained metadata mismatch" + ); + } else if relative == "sub" && limit == 1 { + ensure!( + value["directory"]["entries"] + .as_array() + .context("limited entries")? + .len() + == 1 + && value["directory"]["truncated"] == true, + "bounded scan mismatch" + ); + } else if relative == "empty" { + ensure!( + value["directory"]["entries"] == serde_json::json!([]) + && value["directory"]["truncated"] == false, + "empty mismatch" + ); + } else { + ensure!(value["error"].is_string(), "expected rejection: {value}"); + } + observed.push(serde_json::json!({"path":relative,"limit":limit,"response":value,"sandbox":sandbox_type,"exit":exit,"closed":true})); + } + std::fs::write( + proof.join("directory-native.json"), + serde_json::to_vec_pretty( + &serde_json::json!({"native_source":"3d2ee51ca2d5db578f328aa75e20aa22c0197c9a","observations":observed,"model_calls":0,"scope":"authenticated native process RPC, actual Docker executor, explicit argv, read-only sandbox, helper output/exit/close; not public API acceptance"}), + )?, + )?; + Ok(()) +} + +fn params(id: &str, cwd: &PathUri, argv: Vec) -> ExecParams { + ExecParams { + process_id: ProcessId::from(id), + argv, + cwd: cwd.clone(), + shell_snapshot: None, + env_policy: None, + env: HashMap::new(), + tty: false, + pipe_stdin: false, + arg0: None, + sandbox: None, + enforce_managed_network: false, + managed_network: None, + network_proxy: None, + } +} + +async fn read_closed(process: &dyn ExecProcess) -> Result<(Vec, Vec, Option)> { + timeout(TIMEOUT, async { + let mut after = None; + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + loop { + let result = process.read(after, Some(65536), Some(1000)).await?; + ensure!(result.failure.is_none(), "process failed"); + for chunk in result.chunks { + match chunk.stream { + ExecOutputStream::Stdout => stdout.extend(chunk.chunk.0), + ExecOutputStream::Stderr => stderr.extend(chunk.chunk.0), + ExecOutputStream::Pty => anyhow::bail!("unexpected PTY"), + } + } + ensure!( + stdout.len() + stderr.len() <= 2 * 1024 * 1024, + "output cap exceeded" + ); + if result.closed { + return Ok((stdout, stderr, result.exit_code)); + } + after = result.next_seq.checked_sub(1); + } + }) + .await? +}