diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5120ccef..89d6cf82 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -634,6 +634,20 @@ retirement. Target native background termination only by observed current-Turn item/process identity, and verify Files plus retained interrupted history through the maintained native client before any production ownership or public admission. +The optional Codex file installer runs through the existing native process interface +with bounded stdin chunks, declared length and a SHA-256 commit trailer. It fills +the demonstrated native hard-link overwrite and whole-message size gaps; it is +not a second filesystem service or public admission. Reuse held-directory traversal +and existing rustix directory-relative operations for replacement. Keep preparation, caller +authorization and uncertain mutation recovery in their existing owning layers. +The caller must prevent concurrent workspace writers during installation; native +tools and background processes can otherwise interfere with staging. Public upload +admission must establish this prerequisite or provide a separately verified safe +commit mechanism. Temporary-file cleanup is best effort. +A queued stdin receipt, missing helper result or process termination is not a file +commit receipt. See the [installer contract](packages/codex-executor/README.md#scoped-file-installer) +for private limits, cleanup, metadata and concurrency semantics. + The private [retirement qualification](services/agents-api/tests/native/retirement/README.md) separates native connection/processor shutdown from already admitted filesystem work. Its hashed test-only scheduling overlay is not a production native patch. diff --git a/contracts/agents-api/README.md b/contracts/agents-api/README.md index c4bb7f63..deb7091e 100644 --- a/contracts/agents-api/README.md +++ b/contracts/agents-api/README.md @@ -77,7 +77,7 @@ the Python SDK. Vault HTTP paths start at `/vaults`, not `/agents/vaults`. | sessions.subagents.turns | retrieve, list | Missing | | sessions.subagents.turns.items | list | Missing | | environments | retrieve | Supported self-hosted profile: durable status and safe empty installation metadata; hosted/populated inventory remains missing | -| environments.files | create, list | Missing | +| environments.files | create, list | [Partial bounded Codex list](environment-files.md); create remains missing | | environments.templates | create, retrieve, update, list, delete | Missing | | vaults | create, retrieve, list, delete | Create/retrieve/list/delete with independent tenant persistence, stored status filtering, atomic Credential cascade and frozen Session attachments; archive semantics and full hosted lifecycle parity remain missing | | vaults.credentials | create, retrieve, update, list, delete | Static-bearer create/retrieve/list/token replacement/deletion with scoped encrypted storage; Session attachment and exact-URL HTTPS MCP binding; OAuth, archive semantics and full hosted lifecycle parity remain missing | diff --git a/packages/codex-executor/Cargo.lock b/packages/codex-executor/Cargo.lock index 050fa70b..dc55705d 100644 --- a/packages/codex-executor/Cargo.lock +++ b/packages/codex-executor/Cargo.lock @@ -65,6 +65,8 @@ dependencies = [ "rustix", "serde", "serde_json", + "sha2", + "tempfile", "tokio", "url", "uuid", diff --git a/packages/codex-executor/Cargo.toml b/packages/codex-executor/Cargo.toml index a168ac2a..3e28c698 100644 --- a/packages/codex-executor/Cargo.toml +++ b/packages/codex-executor/Cargo.toml @@ -9,6 +9,10 @@ publish = false name = "agents-api-codex-directory" path = "src/bin/directory.rs" +[[bin]] +name = "agents-api-codex-write" +path = "src/bin/write.rs" + [dependencies] codex-api = { git = "https://github.com/openai/codex", rev = "3d2ee51ca2d5db578f328aa75e20aa22c0197c9a" } codex-exec-server = { git = "https://github.com/openai/codex", rev = "3d2ee51ca2d5db578f328aa75e20aa22c0197c9a" } @@ -18,12 +22,13 @@ http = "1.3.1" rustix = { version = "=1.1.4", features = ["fs"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +sha2 = "=0.10.9" tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal"] } url = "2" -uuid = "1" +uuid = { version = "1", features = ["v4"] } [dev-dependencies] -uuid = { version = "1", features = ["v4"] } +tempfile = "=3.27.0" [patch.crates-io] tokio-tungstenite = { git = "https://github.com/openai-oss-forks/tokio-tungstenite", rev = "0e5b2d73aa18dd9f0a50ee9ff199d5aef7594186" } diff --git a/packages/codex-executor/README.md b/packages/codex-executor/README.md index 0f87be4b..6265bf23 100644 --- a/packages/codex-executor/README.md +++ b/packages/codex-executor/README.md @@ -66,6 +66,48 @@ not change stock native filesystem methods, create a daemon connection, or enabl public Files. The [native fixture](../../services/agents-api/tests/native/directory/README.md) qualifies the standalone helper independently of later adapter/public wiring. +## Scoped file installer + +The optional `agents-api-codex-write` helper addresses two pinned native write +limitations: hard-link targets are modified in place, and base64 encoding a +50 MiB file exceeds the native 64 MiB message bound. Install this helper outside +the writable workspace and invoke it directly through the native process API, +with restricted network and a filesystem policy limited to that workspace and +required helper/runtime reads. It does not authorize callers or enable Files.create. + +Arguments are the authorized absolute root, a nonempty relative file path and +its declared byte count (0–50 MiB). Stream those bytes in bounded native stdin +chunks, followed by their 32-byte binary SHA-256 digest. This is one private frame; +there is no second request on that process. The native process protocol has no +stdin-close method, so the digest terminates the frame without waiting for EOF. +Extra bytes after the frame are not consumed. A process/write accepted receipt +means queued input, not committed file contents. + +The helper reuses held-directory no-follow traversal, rejects existing nonregular +targets and requires an existing parent. It writes a fresh mode-0600 temporary +file with the existing rustix openat/renameat operations, checks the declared byte count and +digest, syncs the file, then replaces the destination directory entry and syncs +the parent. Existing hard links retain their original inode and contents. This +private replacement policy does not preserve destination mode/ownership metadata +or establish official overwrite semantics. The caller must prevent concurrent +workspace writers throughout installation, including native tools and background +processes that can modify the staging file or its directory entry. The digest +checks streamed input; it does not protect against another process replacing the +staging name or modifying its inode before commit. This prerequisite is not yet +established for public uploads. Later writers can change the installed file; +no snapshot or exactly-once guarantee is implied. + +One version-1 JSON response reports `outcome: completed` with `size_bytes`, +`failed` before replacement, or `unknown` if the parent sync fails after replacement. +Errors contain only a fixed safe code. Require a complete response plus observed +native exit/output close; exit zero alone is insufficient. Input errors preserve +the old destination under that concurrency prerequisite. Temporary-file cleanup +is best effort: permission or I/O errors, as well as forced termination, can leave +a `.parsar-upload-*` staging file. Never interpret it as a completed upload. +A missing receipt remains unknown and must not trigger automatic replay. This +helper does not fence a replacement owner after remote transport or service loss; +public admission still needs operation ownership and recovery handling. + ## 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 index e85007c5..983df194 100644 --- a/packages/codex-executor/src/bin/directory.rs +++ b/packages/codex-executor/src/bin/directory.rs @@ -1,9 +1,12 @@ use rustix::fs::FileType; #[path = "../directory.rs"] mod directory; -use directory::{anchor, directory, observe}; +use directory::observe; +#[path = "../workspace_path.rs"] +mod workspace_path; use serde_json::json; use std::{io, path::Path}; +use workspace_path::{anchor, directory}; fn run() -> io::Result { let args: Vec<_> = std::env::args().skip(1).collect(); diff --git a/packages/codex-executor/src/bin/write.rs b/packages/codex-executor/src/bin/write.rs new file mode 100644 index 00000000..05de5b04 --- /dev/null +++ b/packages/codex-executor/src/bin/write.rs @@ -0,0 +1,33 @@ +#[path = "../workspace_path.rs"] +mod workspace_path; +#[path = "../write_file.rs"] +mod write_file; + +use serde_json::json; +use std::io::{self, Write}; +use std::path::Path; + +fn run() -> Result { + let args: Vec<_> = std::env::args().skip(1).collect(); + if args.len() != 3 { + return Err(io::Error::from(io::ErrorKind::InvalidInput).into()); + } + let size = args[2] + .parse::() + .map_err(|_| io::Error::from(io::ErrorKind::InvalidInput))?; + write_file::install(Path::new(&args[0]), &args[1], size, io::stdin().lock())?; + Ok(size) +} + +fn main() -> std::process::ExitCode { + let response = match run() { + Ok(size) => json!({"version": 1, "outcome": "completed", "size_bytes": size}), + Err(failure) => { + json!({"version": 1, "outcome": if failure.committed { "unknown" } else { "failed" }, "error": if failure.error.kind() == io::ErrorKind::InvalidInput { "invalid_input" } else { "write_failed" }}) + } + }; + if writeln!(io::stdout().lock(), "{response}").is_err() { + return std::process::ExitCode::FAILURE; + } + std::process::ExitCode::SUCCESS +} diff --git a/packages/codex-executor/src/directory.rs b/packages/codex-executor/src/directory.rs index 5eb5dd6c..6cbd6373 100644 --- a/packages/codex-executor/src/directory.rs +++ b/packages/codex-executor/src/directory.rs @@ -1,7 +1,6 @@ -use rustix::fs::{AtFlags, Dir, FileType, Mode, OFlags, open, openat, statat}; +use rustix::fs::{AtFlags, Dir, FileType, statat}; use std::io; use std::os::fd::OwnedFd; -use std::path::{Component, Path}; #[derive(Debug, PartialEq)] pub(crate) struct Entry { @@ -18,68 +17,7 @@ pub(crate) struct Observation { } 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(), - )?) + io::ErrorKind::InvalidInput.into() } pub(crate) fn observe(fd: &OwnedFd, limit: usize) -> io::Result { diff --git a/packages/codex-executor/src/directory_tests.rs b/packages/codex-executor/src/directory_tests.rs index da31c18f..264072d3 100644 --- a/packages/codex-executor/src/directory_tests.rs +++ b/packages/codex-executor/src/directory_tests.rs @@ -1,4 +1,5 @@ use super::*; +use crate::workspace_path::{anchor, directory}; use std::fs; use std::os::unix::fs::symlink; use std::path::PathBuf; diff --git a/packages/codex-executor/src/workspace_path.rs b/packages/codex-executor/src/workspace_path.rs new file mode 100644 index 00000000..c6096989 --- /dev/null +++ b/packages/codex-executor/src/workspace_path.rs @@ -0,0 +1,69 @@ +use rustix::fs::{Mode, OFlags, open, openat}; +use std::io; +use std::os::fd::OwnedFd; +use std::path::{Component, Path}; + +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(), + )?) +} diff --git a/packages/codex-executor/src/write_file.rs b/packages/codex-executor/src/write_file.rs new file mode 100644 index 00000000..24b930a2 --- /dev/null +++ b/packages/codex-executor/src/write_file.rs @@ -0,0 +1,118 @@ +use crate::workspace_path::{anchor, directory}; +use rustix::fs::{AtFlags, FileType, Mode, OFlags, openat, renameat, statat, unlinkat}; +use sha2::{Digest, Sha256}; +use std::fs::File; +use std::io::{self, Read, Write}; +use std::os::fd::OwnedFd; +use std::path::Path; + +pub(crate) const MAX_BYTES: u64 = 50 * 1024 * 1024; + +#[derive(Debug)] +pub(crate) struct Failure { + pub committed: bool, + pub error: io::Error, +} + +impl From for Failure { + fn from(error: io::Error) -> Self { + Self { + committed: false, + error, + } + } +} + +pub(crate) fn install( + root: &Path, + relative: &str, + size: u64, + mut input: impl Read, +) -> Result<(), Failure> { + if size > MAX_BYTES + || relative.is_empty() + || relative.len() > 4096 + || relative + .split('/') + .any(|p| p.is_empty() || p == "." || p == ".." || p.contains(['\\', '\0', '\r', '\n'])) + { + return Err(io::Error::from(io::ErrorKind::InvalidInput).into()); + } + let (parent, leaf) = relative.rsplit_once('/').unwrap_or(("", relative)); + let root = anchor(root)?; + let parent = directory(&root, parent)?; + match statat(&parent, leaf, AtFlags::SYMLINK_NOFOLLOW) { + Ok(metadata) if FileType::from_raw_mode(metadata.st_mode) == FileType::RegularFile => (), + Ok(_) => return Err(io::Error::from(io::ErrorKind::InvalidInput).into()), + Err(rustix::io::Errno::NOENT) => (), + Err(error) => return Err(io::Error::from(error).into()), + } + let staging = Staging::new(&parent)?; + let mut file = &staging.file; + let mut digest = Sha256::new(); + let mut remaining = size; + let mut buffer = [0u8; 64 * 1024]; + while remaining != 0 { + let count = remaining.min(buffer.len() as u64) as usize; + input.read_exact(&mut buffer[..count])?; + file.write_all(&buffer[..count])?; + digest.update(&buffer[..count]); + remaining -= count as u64; + } + // Native process/write has no stdin-close operation; the digest ends one frame. + let mut trailer = [0u8; 32]; + input.read_exact(&mut trailer)?; + if digest.finalize().as_slice() != trailer { + return Err(io::Error::from(io::ErrorKind::InvalidData).into()); + } + staging.file.sync_all()?; + staging.persist(leaf)?; + File::from(parent).sync_all().map_err(|error| Failure { + committed: true, + error, + })?; + Ok(()) +} + +struct Staging<'a> { + parent: &'a OwnedFd, + name: String, + file: File, + committed: bool, +} + +impl<'a> Staging<'a> { + fn new(parent: &'a OwnedFd) -> io::Result { + let name = format!(".parsar-upload-{}", uuid::Uuid::new_v4()); + let file = openat( + parent, + name.as_str(), + OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::from_raw_mode(0o600), + )?; + Ok(Self { + parent, + name, + file: File::from(file), + committed: false, + }) + } + + fn persist(mut self, leaf: &str) -> io::Result<()> { + renameat(self.parent, self.name.as_str(), self.parent, leaf)?; + self.committed = true; + Ok(()) + } +} + +impl Drop for Staging<'_> { + fn drop(&mut self) { + if !self.committed { + let _ = unlinkat(self.parent, self.name.as_str(), AtFlags::empty()); + } + } +} + +#[cfg(test)] +#[path = "write_file_tests.rs"] +mod tests; diff --git a/packages/codex-executor/src/write_file_tests.rs b/packages/codex-executor/src/write_file_tests.rs new file mode 100644 index 00000000..6dcedfb8 --- /dev/null +++ b/packages/codex-executor/src/write_file_tests.rs @@ -0,0 +1,184 @@ +use super::*; +use std::fs; +use std::io::Cursor; +use std::os::unix::fs::{MetadataExt, PermissionsExt, symlink}; + +fn fixture() -> tempfile::TempDir { + let root = std::path::PathBuf::from(std::env::var_os("HOME").expect("HOME required")) + .join(".parsar/tests/scoped-write"); + fs::create_dir_all(&root).unwrap(); + tempfile::tempdir_in(root).unwrap() +} + +fn frame(data: &[u8]) -> impl Read + '_ { + Cursor::new(data).chain(Cursor::new(Sha256::digest(data).to_vec())) +} + +#[test] +fn replaces_only_destination_hard_link_and_keeps_exact_binary_bytes() { + let f = fixture(); + let root = f.path().join("workspace"); + fs::create_dir(&root).unwrap(); + let outside = f.path().join("outside"); + fs::write(&outside, b"outside").unwrap(); + fs::hard_link(&outside, root.join("file")).unwrap(); + let input: Vec<_> = (0..=255).cycle().take(256 * 1024).collect(); + install(&root, "file", input.len() as u64, frame(&input)).unwrap(); + assert_eq!(fs::read(&outside).unwrap(), b"outside"); + assert_eq!(fs::read(root.join("file")).unwrap(), input); + assert_ne!( + fs::metadata(&outside).unwrap().ino(), + fs::metadata(root.join("file")).unwrap().ino() + ); + assert_eq!( + fs::metadata(root.join("file")) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o600 + ); + install(&root, "file", 0, frame(b"")).unwrap(); + assert!(fs::read(root.join("file")).unwrap().is_empty()); +} + +#[test] +fn incomplete_excess_and_failed_input_preserve_old_file_and_remove_staging() { + let f = fixture(); + fs::write(f.path().join("file"), b"old").unwrap(); + for (size, data) in [(4, b"new".as_slice()), (2, b"new".as_slice())] { + assert!( + !install(f.path(), "file", size, Cursor::new(data)) + .unwrap_err() + .committed + ); + assert_eq!(fs::read(f.path().join("file")).unwrap(), b"old"); + assert_eq!(fs::read_dir(f.path()).unwrap().count(), 1); + } + struct Failing; + impl Read for Failing { + fn read(&mut self, _: &mut [u8]) -> io::Result { + Err(io::ErrorKind::BrokenPipe.into()) + } + } + assert!(!install(f.path(), "file", 3, Failing).unwrap_err().committed); + assert_eq!(fs::read(f.path().join("file")).unwrap(), b"old"); + assert_eq!(fs::read_dir(f.path()).unwrap().count(), 1); +} + +#[test] +fn rejects_symlinks_traversal_nonregular_targets_and_oversized_inputs() { + let f = fixture(); + let root = f.path().join("workspace"); + fs::create_dir(&root).unwrap(); + fs::create_dir(f.path().join("outside")).unwrap(); + fs::write(f.path().join("outside/file"), b"canary").unwrap(); + symlink(f.path().join("outside/file"), root.join("link")).unwrap(); + symlink(f.path().join("outside"), root.join("parent")).unwrap(); + for name in [ + "link", + "parent/file", + "../outside/file", + "/file", + "a/../file", + "a//file", + "", + ".", + "a\\b", + "x\n", + "missing/file", + ] { + assert!( + install(&root, name, 3, Cursor::new(b"new")).is_err(), + "{name:?}" + ); + } + assert!(install(&root, "parent", 3, Cursor::new(b"new")).is_err()); + fs::create_dir(root.join("directory")).unwrap(); + assert!(install(&root, "directory", 0, io::empty()).is_err()); + assert!(install(&root, "large", MAX_BYTES + 1, io::empty()).is_err()); + assert_eq!(fs::read(f.path().join("outside/file")).unwrap(), b"canary"); +} + +#[test] +fn supports_large_stream_without_whole_input_allocation() { + let f = fixture(); + let mut digest = Sha256::new(); + for _ in 0..(MAX_BYTES / 8192) { + digest.update([0x91; 8192]); + } + let input = io::repeat(0x91) + .take(MAX_BYTES) + .chain(Cursor::new(digest.finalize().to_vec())); + install(f.path(), "large", MAX_BYTES, input).unwrap(); + let file = fs::File::open(f.path().join("large")).unwrap(); + assert_eq!(file.metadata().unwrap().len(), MAX_BYTES); + let mut content = io::BufReader::new(file); + let mut chunk = [0u8; 8192]; + loop { + let n = content.read(&mut chunk).unwrap(); + if n == 0 { + break; + } + assert!(chunk[..n].iter().all(|x| *x == 0x91)); + } +} + +#[test] +fn ancestor_replacement_during_input_cannot_redirect_commit() { + let f = fixture(); + let root = f.path().join("workspace"); + fs::create_dir_all(root.join("a")).unwrap(); + fs::create_dir(f.path().join("outside")).unwrap(); + fs::write(f.path().join("outside/file"), b"outside").unwrap(); + let mut changed = false; + let mut bytes = Cursor::new(b"inside"); + let input = std::io::Read::by_ref(&mut bytes); + struct Replace<'a> { + read: &'a mut Cursor<&'static [u8; 6]>, + change: Box, + } + impl Read for Replace<'_> { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + (self.change)(); + self.read.read(buf) + } + } + let input = Replace { + read: input, + change: Box::new(|| { + if !changed { + fs::rename(root.join("a"), root.join("held")).unwrap(); + symlink(f.path().join("outside"), root.join("a")).unwrap(); + changed = true; + } + }), + }; + install( + &root, + "a/file", + 6, + input.chain(Cursor::new(Sha256::digest(b"inside").to_vec())), + ) + .unwrap(); + assert_eq!(fs::read(root.join("held/file")).unwrap(), b"inside"); + assert_eq!(fs::read(f.path().join("outside/file")).unwrap(), b"outside"); +} + +#[test] +fn requires_matching_commit_trailer_and_does_not_wait_for_eof() { + let f = fixture(); + fs::write(f.path().join("file"), b"old").unwrap(); + for data in [b"new".to_vec(), [b"new".as_slice(), &[0u8; 32]].concat()] { + assert!(install(f.path(), "file", 3, Cursor::new(data)).is_err()); + assert_eq!(fs::read(f.path().join("file")).unwrap(), b"old"); + } + struct NoMore; + impl Read for NoMore { + fn read(&mut self, _: &mut [u8]) -> io::Result { + panic!("must stop after commit trailer") + } + } + install(f.path(), "file", 3, frame(b"new").chain(NoMore)).unwrap(); + assert_eq!(fs::read(f.path().join("file")).unwrap(), b"new"); +} diff --git a/scripts/build-agents-executor.sh b/scripts/build-agents-executor.sh index 94367455..79d20c7c 100755 --- a/scripts/build-agents-executor.sh +++ b/scripts/build-agents-executor.sh @@ -35,8 +35,8 @@ cp -R "$repo_root/packages/codex-executor/src" "$build_context/src" cargo build --locked --release ) mkdir -p "$output_dir" -for binary in agents-api-codex-executor agents-api-codex-directory; do +for binary in agents-api-codex-executor agents-api-codex-directory agents-api-codex-write; 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" +printf 'Standalone Codex executor and workspace helpers: %s\n' "$output_dir"