Preserve custom arg0 for sandboxed exec-server processes (#34497)

## Why

Sandbox wrappers replaced the process launch command and did not carry an
`ExecParams.arg0` override through to the inner process.

## What changed

- Route sandboxed Unix launches with a custom `arg0` through a helper mode that
  re-execs the requested program with the override.
- Expose the helper executable to the filesystem sandbox and dispatch its mode
  from Codex and exec-server test binaries.

## Testing

Add coverage for the prepared sandbox command and an end-to-end remote process
that verifies both the custom `arg0` and filesystem restrictions.

GitOrigin-RevId: c9f8eef3906d184e670184c2eeed250d5895a9ca
This commit is contained in:
jif
2026-07-21 08:59:41 +00:00
committed by copyberry
parent 99eb575649
commit 4f1992732c
10 changed files with 243 additions and 14 deletions
+8 -3
View File
@@ -2,8 +2,8 @@
//!
//! This mirrors `//codex-rs/exec-server/testing:exec-server` so Cargo-backed
//! app-server integration tests can receive `CARGO_BIN_EXE_exec-server`. It
//! also handles the filesystem-helper argv mode because exec-server re-execs
//! `codex_self_exe` for sandboxed filesystem requests.
//! also handles the helper argv modes because exec-server re-execs
//! `codex_self_exe` for sandboxed filesystem and process requests.
use codex_exec_server::ExecServerRuntimePaths;
use std::ffi::OsStr;
@@ -13,7 +13,12 @@ const CODEX_LINUX_SANDBOX_EXE_ENV_VAR: &str = "CODEX_TEST_LINUX_SANDBOX_EXE";
fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let mut args = std::env::args_os();
let _ = args.next();
if args.next().as_deref() == Some(OsStr::new(codex_exec_server::CODEX_FS_HELPER_ARG1)) {
let argv1 = args.next();
#[cfg(unix)]
if argv1.as_deref() == Some(OsStr::new(codex_exec_server::CODEX_ARG0_EXEC_HELPER_ARG1)) {
codex_exec_server::run_arg0_exec_helper_main();
}
if argv1.as_deref() == Some(OsStr::new(codex_exec_server::CODEX_FS_HELPER_ARG1)) {
codex_exec_server::run_fs_helper_main();
}
+6
View File
@@ -5,6 +5,8 @@ use std::path::Path;
use std::path::PathBuf;
use codex_apply_patch::CODEX_CORE_APPLY_PATCH_ARG1;
#[cfg(unix)]
use codex_exec_server::CODEX_ARG0_EXEC_HELPER_ARG1;
use codex_exec_server::CODEX_FS_HELPER_ARG1;
use codex_install_context::InstallContext;
use codex_sandboxing::landlock::CODEX_LINUX_SANDBOX_ARG0;
@@ -98,6 +100,10 @@ pub fn arg0_dispatch() -> Option<Arg0PathEntryGuard> {
}
let argv1 = args.next().unwrap_or_default();
#[cfg(unix)]
if argv1 == CODEX_ARG0_EXEC_HELPER_ARG1 {
codex_exec_server::run_arg0_exec_helper_main();
}
if argv1 == CODEX_FS_HELPER_ARG1 {
codex_exec_server::run_fs_helper_main();
}
+6
View File
@@ -1,5 +1,7 @@
// Aggregates all former standalone integration tests as modules.
use codex_apply_patch::CODEX_CORE_APPLY_PATCH_ARG1;
#[cfg(unix)]
use codex_exec_server::CODEX_ARG0_EXEC_HELPER_ARG1;
use codex_exec_server::CODEX_FS_HELPER_ARG1;
use codex_sandboxing::landlock::CODEX_LINUX_SANDBOX_ARG0;
use codex_test_binary_support::TestBinaryDispatchGuard;
@@ -17,6 +19,10 @@ pub static CODEX_ALIASES_TEMP_DIR: Option<TestBinaryDispatchGuard> = {
if argv1 == Some(CODEX_CORE_APPLY_PATCH_ARG1) {
return TestBinaryDispatchMode::DispatchArg0Only;
}
#[cfg(unix)]
if argv1 == Some(CODEX_ARG0_EXEC_HELPER_ARG1) {
return TestBinaryDispatchMode::DispatchArg0Only;
}
if argv1 == Some(CODEX_FS_HELPER_ARG1) {
return TestBinaryDispatchMode::DispatchArg0Only;
}
@@ -0,0 +1,31 @@
#[cfg(unix)]
use std::process::Command;
pub const CODEX_ARG0_EXEC_HELPER_ARG1: &str = "--codex-run-as-arg0-exec-helper";
#[cfg(unix)]
pub fn main() -> ! {
use std::os::unix::process::CommandExt;
let mut args = std::env::args_os();
let _program = args.next();
let _helper_mode = args.next();
let Some(arg0) = args.next() else {
eprintln!("missing arg0 for exec helper");
std::process::exit(1);
};
let Some(program) = args.next() else {
eprintln!("missing program for exec helper");
std::process::exit(1);
};
let error = Command::new(&program).arg0(arg0).args(args).exec();
eprintln!("failed to exec {program:?}: {error}");
std::process::exit(1);
}
#[cfg(not(unix))]
pub fn main() -> ! {
eprintln!("arg0 exec helper is only supported on Unix");
std::process::exit(1);
}
+3
View File
@@ -1,3 +1,4 @@
mod arg0_exec_helper;
mod capability_discovery;
mod capability_discovery_cache;
mod client;
@@ -35,6 +36,8 @@ mod websocket_pong_watchdog;
use codex_exec_server_protocol as protocol;
pub use arg0_exec_helper::CODEX_ARG0_EXEC_HELPER_ARG1;
pub use arg0_exec_helper::main as run_arg0_exec_helper_main;
pub use capability_discovery::CapabilityDiscoveryError;
pub use capability_discovery::discover_capability_roots;
pub use capability_discovery_cache::ExecutorCapabilityDiscoveryCache;
+40 -10
View File
@@ -26,6 +26,8 @@ use codex_sandboxing::with_managed_mitm_ca_readable_root;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_path_uri::PathUri;
#[cfg(unix)]
use crate::CODEX_ARG0_EXEC_HELPER_ARG1;
use crate::ExecServerRuntimePaths;
use crate::protocol::ExecParams;
use crate::rpc::internal_error;
@@ -121,17 +123,27 @@ pub(crate) async fn prepare_exec_request(
native_sandbox_policy_cwd.as_path(),
);
let (file_system_policy, network_policy) = permissions.to_runtime_permissions();
#[cfg(unix)]
let sandbox_helper_paths = params
.arg0
.iter()
.map(|_| runtime_paths.codex_self_exe.clone())
.collect::<Vec<_>>();
// Bubblewrap launches the configured helper, which may re-enter this executable to apply
// seccomp, so the outer filesystem sandbox must expose both paths.
#[cfg(target_os = "linux")]
let sandbox_helper_paths = std::iter::once(&runtime_paths.codex_self_exe)
.chain(runtime_paths.codex_linux_sandbox_exe.as_ref())
.cloned()
.collect::<Vec<_>>();
#[cfg(target_os = "linux")]
let sandbox_helper_paths = {
let mut sandbox_helper_paths = sandbox_helper_paths;
if !sandbox_helper_paths.contains(&runtime_paths.codex_self_exe) {
sandbox_helper_paths.push(runtime_paths.codex_self_exe.clone());
}
sandbox_helper_paths.extend(runtime_paths.codex_linux_sandbox_exe.iter().cloned());
sandbox_helper_paths
};
#[cfg(unix)]
let file_system_policy = file_system_policy
.with_additional_readable_roots(native_sandbox_policy_cwd.as_path(), &sandbox_helper_paths);
#[cfg(target_os = "linux")]
#[cfg(unix)]
let permissions = PermissionProfile::from_runtime_permissions_with_enforcement(
permissions.enforcement(),
&file_system_policy,
@@ -154,16 +166,34 @@ pub(crate) async fn prepare_exec_request(
.argv
.split_first()
.ok_or_else(|| invalid_params("argv must not be empty".to_string()))?;
#[cfg(unix)]
let (program, args) = params.arg0.as_ref().map_or_else(
|| (program.into(), args.to_vec()),
|arg0| {
let mut helper_args = Vec::with_capacity(params.argv.len() + 2);
helper_args.push(CODEX_ARG0_EXEC_HELPER_ARG1.to_string());
helper_args.push(arg0.clone());
helper_args.extend(params.argv.iter().cloned());
(
runtime_paths
.codex_self_exe
.as_path()
.as_os_str()
.to_owned(),
helper_args,
)
},
);
#[cfg(not(unix))]
let (program, args) = (program.into(), args.to_vec());
let transform_request = SandboxDirectSpawnTransformRequest {
workspace_roots,
windows_sandbox_proxy_settings_mode:
codex_sandboxing::WindowsSandboxProxySettingsMode::Reconcile,
transform: SandboxTransformRequest {
// TODO(jif): Preserve params.arg0 for the inner command across the sandbox
// wrapper, or reject sandboxed requests with a custom arg0.
command: SandboxCommand {
program: program.into(),
args: args.to_vec(),
program,
args,
cwd: params.cwd.clone(),
env,
managed_network,
@@ -11,6 +11,8 @@ use codex_network_proxy::RemoteNetworkProxyLaunchConfig;
use codex_protocol::config_types::WindowsSandboxLevel;
#[cfg(any(unix, windows))]
use codex_protocol::models::PermissionProfile;
#[cfg(target_os = "linux")]
use codex_sandboxing::landlock::CODEX_LINUX_SANDBOX_ARG0;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_path_uri::PathUri;
use pretty_assertions::assert_eq;
@@ -19,6 +21,8 @@ use tokio::io::AsyncWriteExt;
use tokio::time::timeout;
use super::prepare_exec_request;
#[cfg(unix)]
use crate::CODEX_ARG0_EXEC_HELPER_ARG1;
use crate::ExecParams;
#[cfg(any(unix, windows))]
use crate::ExecServerRuntimePaths;
@@ -93,6 +97,61 @@ async fn sandbox_request_wraps_native_argv_on_executor() {
);
}
#[cfg(unix)]
#[tokio::test]
async fn sandbox_request_routes_custom_arg0_to_inner_helper() {
let cwd: AbsolutePathBuf = std::env::current_dir()
.expect("current directory")
.try_into()
.expect("absolute cwd");
let cwd_uri = PathUri::from_abs_path(&cwd);
let self_exe = std::env::current_exe().expect("current executable");
let runtime_paths =
ExecServerRuntimePaths::new(self_exe.clone(), Some(self_exe)).expect("runtime paths");
let sandbox = FileSystemSandboxContext::from_permission_profile_with_cwd(
PermissionProfile::workspace_write(),
cwd_uri.clone(),
);
let params = ExecParams {
process_id: ProcessId::from("process-custom-arg0"),
argv: vec!["/bin/sh".to_string(), "-c".to_string(), "true".to_string()],
cwd: cwd_uri,
env_policy: None,
env: HashMap::new(),
tty: false,
pipe_stdin: false,
arg0: Some("custom-arg0".to_string()),
sandbox: Some(sandbox),
enforce_managed_network: false,
managed_network: None,
network_proxy: None,
};
let prepared = prepare_exec_request(&params, HashMap::new(), Some(&runtime_paths))
.await
.expect("prepare sandboxed request");
let helper_mode = prepared
.command
.iter()
.position(|arg| arg == CODEX_ARG0_EXEC_HELPER_ARG1)
.expect("sandboxed command should invoke arg0 helper");
assert_eq!(
prepared.command[helper_mode..],
[
CODEX_ARG0_EXEC_HELPER_ARG1,
"custom-arg0",
"/bin/sh",
"-c",
"true",
]
);
#[cfg(target_os = "linux")]
assert_eq!(prepared.arg0, Some(CODEX_LINUX_SANDBOX_ARG0.to_string()));
#[cfg(target_os = "macos")]
assert_eq!(prepared.arg0, None);
}
#[cfg(target_os = "macos")]
#[tokio::test]
async fn sandbox_request_allows_prepared_managed_proxy_port() {
+17 -1
View File
@@ -1,12 +1,28 @@
//! Minimal exec-server fixture for Bazel-only integration tests.
//!
//! Linking only exec-server avoids depending on the full Codex CLI binary
//! when a test only needs a WebSocket executor endpoint.
//! when a test only needs a WebSocket executor endpoint. It handles the arg0
//! helper mode because sandboxed process requests re-exec this binary.
use codex_exec_server::ExecServerRuntimePaths;
#[cfg(unix)]
use std::ffi::OsStr;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
#[cfg(unix)]
{
let mut args = std::env::args_os();
let _ = args.next();
if args.next().as_deref()
== Some(OsStr::new(
codex_exec_server::CODEX_ARG0_EXEC_HELPER_ARG1,
))
{
codex_exec_server::run_arg0_exec_helper_main();
}
}
let current_exe = std::env::current_exe()?;
let runtime_paths =
ExecServerRuntimePaths::new(current_exe, /*codex_linux_sandbox_exe*/ None)?;
+4
View File
@@ -6,6 +6,7 @@ use std::process::Command;
use std::process::Stdio;
use std::time::Duration;
use codex_exec_server::CODEX_ARG0_EXEC_HELPER_ARG1;
use codex_exec_server::CODEX_FS_HELPER_ARG1;
use codex_exec_server::ExecServerRuntimePaths;
use codex_sandboxing::landlock::CODEX_LINUX_SANDBOX_ARG0;
@@ -25,6 +26,9 @@ const DELAYED_OUTPUT_AFTER_EXIT_CHILD_ARG: &str = "--codex-test-delayed-output-a
#[ctor]
pub static TEST_BINARY_DISPATCH_GUARD: Option<TestBinaryDispatchGuard> = {
let guard = configure_test_binary_dispatch("codex-exec-server-tests", |exe_name, argv1| {
if argv1 == Some(CODEX_ARG0_EXEC_HELPER_ARG1) {
return TestBinaryDispatchMode::DispatchArg0Only;
}
if argv1 == Some(CODEX_FS_HELPER_ARG1) {
return TestBinaryDispatchMode::DispatchArg0Only;
}
@@ -88,6 +88,75 @@ async fn create_process_context(use_remote: bool) -> Result<ProcessContext> {
}
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn remote_sandboxed_process_preserves_custom_arg0() -> Result<()> {
if let Some(warning) = codex_sandboxing::system_bwrap_warning(&PermissionProfile::read_only()) {
eprintln!("skipping bwrap test: {warning}");
return Ok(());
}
let context = create_process_context(/*use_remote*/ true).await?;
let workspace = TempDir::new()?;
let outside_workspace = TempDir::new()?;
let denied_file = outside_workspace.path().join("denied.txt");
std::fs::write(&denied_file, b"denied")?;
let cwd = PathUri::from_host_native_path(workspace.path())?;
let policy = FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Minimal,
},
access: FileSystemAccessMode::Read,
},
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
},
access: FileSystemAccessMode::Read,
},
]);
let sandbox = FileSystemSandboxContext::from_permission_profile_with_cwd(
PermissionProfile::from_runtime_permissions(&policy, NetworkSandboxPolicy::Restricted),
cwd.clone(),
);
let session = context
.backend
.start(ExecParams {
process_id: ProcessId::from("proc-custom-arg0"),
argv: vec![
"/bin/sh".to_string(),
"-c".to_string(),
"printf '%s' \"$0\"; if /bin/cat \"$CODEX_TEST_DENIED_FILE\" >/dev/null 2>&1; then exit 42; fi"
.to_string(),
],
cwd,
env_policy: None,
env: HashMap::from([
("PATH".to_string(), std::env::var("PATH")?),
(
"CODEX_TEST_DENIED_FILE".to_string(),
denied_file.to_string_lossy().into_owned(),
),
]),
tty: false,
pipe_stdin: false,
arg0: Some("custom-arg0".to_string()),
sandbox: Some(sandbox),
enforce_managed_network: false,
managed_network: None,
network_proxy: None,
})
.await?;
let output = collect_process_output_from_events(session.process).await?;
assert_eq!(
output,
("custom-arg0".to_string(), String::new(), Some(0), true)
);
Ok(())
}
async fn assert_exec_process_starts_and_exits(use_remote: bool) -> Result<()> {
let context = create_process_context(use_remote).await?;
let session = context