From 8efa6d3e792ebe0348dc04d2a16544ac49978f7e Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Thu, 17 Sep 2026 22:38:48 +0800 Subject: [PATCH 1/2] feat(runtime): add bounded workspace file installer --- CONTRIBUTING.md | 10 + contracts/agents-api/README.md | 2 +- packages/codex-executor/Cargo.lock | 2 + packages/codex-executor/Cargo.toml | 9 +- packages/codex-executor/README.md | 36 ++++ packages/codex-executor/src/bin/directory.rs | 5 +- packages/codex-executor/src/bin/write.rs | 33 ++++ packages/codex-executor/src/directory.rs | 66 +------ .../codex-executor/src/directory_tests.rs | 1 + packages/codex-executor/src/workspace_path.rs | 69 +++++++ packages/codex-executor/src/write_file.rs | 118 +++++++++++ .../codex-executor/src/write_file_tests.rs | 184 ++++++++++++++++++ scripts/build-agents-executor.sh | 4 +- 13 files changed, 469 insertions(+), 70 deletions(-) create mode 100644 packages/codex-executor/src/bin/write.rs create mode 100644 packages/codex-executor/src/workspace_path.rs create mode 100644 packages/codex-executor/src/write_file.rs create mode 100644 packages/codex-executor/src/write_file_tests.rs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5120ccef..1741be0b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -634,6 +634,16 @@ 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. +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..a365fbb8 100644 --- a/packages/codex-executor/README.md +++ b/packages/codex-executor/README.md @@ -66,6 +66,42 @@ 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. Concurrent writers can change the file +after replacement; 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 and drop temporary state. Forced termination can leave a +`.parsar-upload-*` staging file; never interpret that file 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" From 07e3635cca58d2b5c6070366e754c8fda77c46a1 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Thu, 17 Sep 2026 22:55:04 +0800 Subject: [PATCH 2/2] docs: clarify installer concurrency and cleanup requirements --- CONTRIBUTING.md | 4 ++++ packages/codex-executor/README.md | 14 ++++++++++---- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1741be0b..89d6cf82 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -640,6 +640,10 @@ 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. diff --git a/packages/codex-executor/README.md b/packages/codex-executor/README.md index a365fbb8..6265bf23 100644 --- a/packages/codex-executor/README.md +++ b/packages/codex-executor/README.md @@ -89,15 +89,21 @@ file with the existing rustix openat/renameat operations, checks the declared by 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. Concurrent writers can change the file -after replacement; no snapshot or exactly-once guarantee is implied. +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 and drop temporary state. Forced termination can leave a -`.parsar-upload-*` staging file; never interpret that file as a completed upload. +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.