Skip to content

Command Execution and exec-server

源码版本rust-v0.145.0

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

  1. Provide run_main to start exec-server listening on a URL, exposing telemetry spans: codex-rs/exec-server/src/server.rs:17-35
  2. Define the ExecBackend trait and ExecProcess trait, decoupling "starting a process" from "interacting with it": codex-rs/exec-server/src/process.rs:179-208
  3. LocalProcess is the default backend, calling codex_sandboxing::spawn_process to actually start the process: codex-rs/exec-server/src/local_process.rs:261-272
  4. prepare_exec_request translates the sandbox context into a PreparedExecRequest before spawning: codex-rs/exec-server/src/process_sandbox.rs:69-130
  5. core::exec::execute_exec_request is 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

The ExecProcess trait pulls out "operations after the process has started"; read / write / signal / terminate are all async, decoupled from "how to start".

rust
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:

rust
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:

rust
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

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.