Command Execution and exec-server
The exec-server crate is the standalone service codex carved out for command execution: it listens on a URL (local or remote), receives JSON-RPC-style ExecParams, spawns the child process inside the sandbox, and streams stdout / stderr back to the client. core::exec is the client-side entry, responsible for computing the sandbox type, building the ExecRequest, and handing it to exec-server to actually run. The exec crate is a thin wrapper for the codex exec CLI command.
Responsibilities
- Provide
run_mainto start exec-server listening on a URL, exposing telemetry spans:codex-rs/exec-server/src/server.rs:17-35 - Define the
ExecBackendtrait andExecProcesstrait, decoupling "starting a process" from "interacting with it":codex-rs/exec-server/src/process.rs:179-208 LocalProcessis the default backend, callingcodex_sandboxing::spawn_processto actually start the process:codex-rs/exec-server/src/local_process.rs:261-272prepare_exec_requesttranslates the sandbox context into aPreparedExecRequestbefore spawning:codex-rs/exec-server/src/process_sandbox.rs:69-130core::exec::execute_exec_requestis the client-side entry, handling timeout, IO cap, and error classification:codex-rs/core/src/exec.rs:437-460
Design motivation
Why split exec into a standalone server? Because command execution has to happen inside the sandbox, and sandbox startup has platform overhead (seatbelt init, bubblewrap setup, Windows token prep). Letting exec-server live as a long-lived process reuses these prepared resources; the client just sends an RPC. At the same time, remote environments (SSH / cloud sandboxes) can use the same protocol — EnvironmentProvider abstracts local vs remote, so the client does not care which machine the process actually runs on.
Why is ExecBackend a trait rather than a concrete type? Because LocalProcess is the default impl, but tests can swap in a mock backend; under remote environments RemoteProcess goes through a noise relay. The ExecProcess trait pulls out "operations after a process has started" (read / write / signal / terminate), decoupled from "how to start".
prepare_exec_request must run on the server side, not the client side, because the cwd and workspace_roots in the sandbox context are executor-local paths that need native_path conversion. The permission profile also needs materialize_project_roots_with_workspace_roots to map remotely-sent project roots onto the executor's file system.
Key files
codex-rs/exec-server/src/lib.rs:36-100— crate entry, re-exportingExecServerClient,Environment,ExecBackend, etc.codex-rs/exec-server/src/server.rs:17-35—run_main/run_main_with_telemetrystarts listening.codex-rs/exec-server/src/process.rs:179-208— theExecProcessandExecBackendtraits.codex-rs/exec-server/src/local_process.rs:261-272—LocalProcesscallsspawn_processto start the child.codex-rs/exec-server/src/local_process.rs:610-614— theExecBackend for LocalProcessimpl.codex-rs/exec-server/src/process_sandbox.rs:34-67—PreparedExecRequestandPreparedWindowsSandboxRequest.codex-rs/exec-server/src/process_sandbox.rs:69-130—prepare_exec_requestlands the sandbox context onto the executor's local paths.codex-rs/core/src/exec.rs:117-130— core-sideselect_process_exec_tool_sandbox_type.codex-rs/core/src/exec.rs:350-413—build_exec_requestcallsSandboxManager::transformto getSandboxExecRequest.
The ExecProcess trait pulls out "operations after the process has started"; read / write / signal / terminate are all async, decoupled from "how to start".
pub trait ExecProcess: Send + Sync {
fn process_id(&self) -> &ProcessId;
fn subscribe_wake(&self) -> watch::Receiver<u64>;
fn subscribe_events(&self) -> ExecProcessEventReceiver;
fn read(&self, after_seq: Option<u64>, max_bytes: Option<usize>, wait_ms: Option<u64>) -> ExecProcessFuture<'_, ReadResponse>;
fn write(&self, chunk: Vec<u8>) -> ExecProcessFuture<'_, WriteResponse>;
fn signal(&self, signal: ProcessSignal) -> ExecProcessFuture<'_, ()>;
fn terminate(&self) -> ExecProcessFuture<'_, ()>;
}LocalProcess is the default backend, calling codex_sandboxing::spawn_process to actually start the process:
let spawned_result = codex_sandboxing::spawn_process(codex_sandboxing::SpawnRequest {
command: &prepared.command,
cwd: prepared.cwd.as_path(),
env: &prepared.env,
arg0: &prepared.arg0,
sandbox: prepared.sandbox,
windows_sandbox: prepared.windows_sandbox_spawn_request(),
tty: params.tty,
stdin_open: params.tty || params.pipe_stdin,
inherited_fds: &[],
}).await;On the core side, build_exec_request computes the SandboxType, then hands SandboxCommand to SandboxManager::transform to get a wrapped-argv SandboxExecRequest:
let sandbox_type = select_process_exec_tool_sandbox_type(
&file_system_sandbox_policy,
network_sandbox_policy,
windows_sandbox_level,
enforce_managed_network,
);
let manager = SandboxManager::new();
let exec_req = manager
.transform(SandboxTransformRequest {
command, permissions: permission_profile, sandbox: sandbox_type,
/* ... */
})
.map(|request| { /* ...ExecRequest::from_sandbox_exec_request */ })
.map_err(CodexErr::from)?;Data flow
Boundaries and failures
- In
prepare_exec_request, whensandbox_contextisNone, it degrades directly toSandboxType::None, skipping all sandbox prep:codex-rs/exec-server/src/process_sandbox.rs:80-90 - When
LocalProcessstartup fails, theStartingplaceholder must be cleaned from theprocessesmap to avoid leaks:codex-rs/exec-server/src/local_process.rs:273-285 - After exec completes, core uses
is_likely_sandbox_deniedon stderr to judge whether the sandbox blocked it, giving the model a readable error:codex-rs/core/src/exec.rs:804-815 - A single exec output has a hard cap of
EXEC_OUTPUT_MAX_BYTES, preventing a child process from OOMing via stdout flooding:codex-rs/core/src/exec.rs:72-76 - IO drain has a 2s timeout; after the child is killed, a grandchild still holding the pipe won't hang the agent forever:
codex-rs/core/src/exec.rs:82-89
Summary
exec-server separates command execution from core; the ExecBackend trait lets local and remote environments share the same RPC protocol. The core side only computes the sandbox, builds the request, and collects output; LocalProcess as the default backend goes through codex_sandboxing::spawn_process to actually start the process. To trace "why didn't the command run" or "why was output truncated", read this together with cross-platform sandbox and command classification execpolicy.