Files
codex/codex-rs/exec-server/tests/macos_fs_spawn.rs
Charlie Marsh 595cc91e8c Avoid fork when spawning macOS filesystem helpers (#46661)
## Why

Filesystem helpers use a `pre_exec` callback to close inherited descriptors on macOS, forcing a fork before execution. Native spawning needs to preserve that isolation and support the socket used for file descriptor transfer.

## What changed

- Launch filesystem helpers through `codex_utils_pty::Command`, using `posix_spawn` with `POSIX_SPAWN_CLOEXEC_DEFAULT` on macOS and returning native launch errors without a fork fallback.
- Extend the shared command wrapper with explicit descriptor and fallback policies, socket-backed stdin, and custom `argv[0]` support.
- Add `Child::wait_with_output` to drain stdout and stderr concurrently, retain kill-on-drop behavior on cancellation, and keep output pipes open until the child exits.

## Testing

Add regression tests for fork-free sandboxed reads, writes, and file descriptor transfers; sandbox denial of outside paths and symlink escapes; descriptor isolation; bidirectional socket stdin; custom `argv[0]`; executable-format errors; and output-pipe lifetimes.

GitOrigin-RevId: d49c00787f30ec0bc196f9e066a0770fc8151152
2026-09-19 15:15:25 +00:00

171 lines
5.9 KiB
Rust

//! Exercise native filesystem-helper spawning with real sandbox and fd-transfer operations.
//! Atfork registration stays in an isolated process because its hooks cannot be removed.
#![cfg(target_os = "macos")]
mod common;
#[path = "file_system/support.rs"]
#[allow(dead_code, clippy::expect_used)]
mod support;
use std::io;
use std::os::unix::fs::symlink;
use std::os::unix::process::CommandExt;
use std::sync::atomic::AtomicUsize;
use std::sync::atomic::Ordering;
use std::time::Duration;
use anyhow::Context;
use anyhow::Result;
use codex_utils_path_uri::PathUri;
use futures::TryStreamExt;
use pretty_assertions::assert_eq;
use tokio::process::Command;
use tokio::time::timeout;
use crate::support::FileSystemImplementation;
use crate::support::create_file_system_context;
use crate::support::workspace_write_sandbox;
const CHILD_FLAG: &str = "CODEX_EXEC_SERVER_MACOS_FS_SPAWN_TEST_CHILD";
const CHILD_COMPLETED: &str = "filesystem spawn assertions completed";
static PARENT_FORKS: AtomicUsize = AtomicUsize::new(0);
extern "C" fn record_parent_fork() {
PARENT_FORKS.fetch_add(1, Ordering::Relaxed);
}
#[tokio::test]
async fn sandboxed_filesystem_operations_avoid_fork() -> Result<()> {
let mut command = Command::new(std::env::current_exe()?);
command
.args([
"--exact",
"sandboxed_filesystem_operations_avoid_fork_child",
"--ignored",
"--test-threads=1",
"--nocapture",
])
.env(CHILD_FLAG, "1")
.kill_on_drop(true);
let output = timeout(Duration::from_secs(/*secs*/ 60), command.output()).await??;
assert!(
output.status.success()
&& String::from_utf8_lossy(&output.stdout).contains(CHILD_COMPLETED),
"filesystem spawn test failed:\n{}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "isolated child for sandboxed_filesystem_operations_avoid_fork"]
async fn sandboxed_filesystem_operations_avoid_fork_child() -> Result<()> {
assert_eq!(std::env::var(CHILD_FLAG)?, "1");
let context = create_file_system_context(FileSystemImplementation::Local).await?;
let file_system = &context.file_system;
let directory = tempfile::tempdir()?;
let root = directory.path().canonicalize()?;
let allowed = root.join("allowed");
let outside = root.join("outside");
std::fs::create_dir(&allowed)?;
std::fs::create_dir(&outside)?;
let secret = outside.join("secret.txt");
std::fs::write(&secret, b"secret")?;
symlink(&outside, allowed.join("escape"))?;
let sandbox = workspace_write_sandbox(allowed.clone());
let path = allowed.join("file.txt");
let uri = PathUri::from_host_native_path(&path)?;
// SAFETY: This isolated process runs one test, and the parent hook only updates a
// lock-free atomic. The hook remains valid until this process exits.
let registered = unsafe {
libc::pthread_atfork(
/*prepare*/ None,
Some(record_parent_fork),
/*child*/ None,
)
};
assert_eq!(registered, 0);
let mut legacy = std::process::Command::new("/usr/bin/true");
// SAFETY: A no-op callback forces the legacy fork route for this positive control.
unsafe {
legacy.pre_exec(|| Ok(()));
}
assert!(legacy.status()?.success());
let baseline = PARENT_FORKS.load(Ordering::Relaxed);
assert_eq!(baseline, 1, "the positive control must detect fork");
file_system
.write_file(
&uri,
b"allowed".to_vec(),
Default::default(),
Some(&sandbox),
)
.await?;
assert_eq!(
file_system
.read_file(&uri, Default::default(), Some(&sandbox))
.await?,
b"allowed",
);
// The transferred fd must retain its file after the path is replaced.
let stream = file_system.read_file_stream(&uri, Some(&sandbox)).await?;
std::fs::rename(&path, allowed.join("opened.txt"))?;
std::fs::write(&path, b"replacement")?;
assert_eq!(stream.try_collect::<Vec<_>>().await?.concat(), b"allowed",);
for denied in [secret.clone(), allowed.join("escape/secret.txt")] {
assert_eq!(std::fs::read(&denied)?, b"secret");
let denied = PathUri::from_host_native_path(denied)?;
let read_error = file_system
.read_file(&denied, Default::default(), Some(&sandbox))
.await
.expect_err("sandboxed read must reject an outside file");
assert_sandbox_denied(&read_error);
let stream_error = file_system
.read_file_stream(&denied, Some(&sandbox))
.await
.err()
.context("sandboxed stream must reject an outside file")?;
assert_sandbox_denied(&stream_error);
let write_error = file_system
.write_file(
&denied,
b"changed".to_vec(),
Default::default(),
Some(&sandbox),
)
.await
.expect_err("sandboxed write must reject an outside file");
assert_sandbox_denied(&write_error);
}
assert_eq!(std::fs::read(&secret)?, b"secret");
assert_eq!(
PARENT_FORKS.load(Ordering::Relaxed),
baseline,
"sandboxed reads, writes, and fd transfers must avoid fork",
);
println!("{CHILD_COMPLETED}");
Ok(())
}
fn assert_sandbox_denied(error: &io::Error) {
assert!(
matches!(
error.kind(),
io::ErrorKind::PermissionDenied | io::ErrorKind::InvalidInput
),
"expected a sandbox denial, got {error:?}",
);
let message = error.to_string();
assert!(
message.contains("is not permitted")
|| message.contains("Operation not permitted")
|| message.contains("Permission denied"),
"expected a permission denial, got {message}",
);
}