Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/codex-executor/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions packages/codex-executor/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand Down
30 changes: 30 additions & 0 deletions packages/codex-executor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
57 changes: 57 additions & 0 deletions packages/codex-executor/src/bin/directory.rs
Original file line number Diff line number Diff line change
@@ -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<serde_json::Value> {
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,
}
}
126 changes: 126 additions & 0 deletions packages/codex-executor/src/directory.rs
Original file line number Diff line number Diff line change
@@ -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<u64>,
}

#[derive(Debug)]
pub(crate) struct Observation {
pub entries: Vec<Entry>,
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<OwnedFd> {
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<OwnedFd> {
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<Observation> {
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;
Loading