Skip to content

Codex执行体系总览

沿真实源码拆解 exec_command 从工具注册、参数归一化、审批与沙箱编排,到本地或 exec-server 进程、持续会话与输出回收的完整执行链。

基于rust-v0.150.0
CodexRustExecution

Codex执行体系总览 ​

在 Codex 里执行一条命令,至少会出现四种容易混淆的身份:模型发起的工具调用、Core 分配的统一进程 ID、exec-server 使用的后端进程 ID,以及操作系统真正创建的子进程。它们的生命周期并不相同。

例如,exec_command 已经返回 session_id,只说明 Core 仍保存着一个可继续交互的统一执行会话;它不保证该 ID 等于 pid。远程执行或 shell snapshot 路径还可能为同一个统一 ID 创建带随机后缀的 exec-server ID。连接发生替换时,后端进程甚至可以继续存在,由恢复逻辑补回缺失输出。

因此,理解执行体系的关键不是寻找一个“执行命令函数”,而是回答四个问题:谁解释模型参数,谁决定审批与沙箱,谁持有进程,谁把输出和退出状态重新投影成工具结果。

本文从 exec_command 的真实调用链出发,依次分析这些所有权边界。非交互命令 codex exec、exec-server 和 OS process 也会在后文定位,但它们都不是 exec_command 的同义词。

如果还不熟悉 ToolRuntime、SandboxAttempt 与通用策略层的关系,可以先读工具运行时抽象;如果已经在追踪 shell 环境继承,则可把本文第 3、4 节与Shell快照与命令环境对照阅读。

1. Unified Exec入口 ​

1.1 注册条件 ​

Core 构造工具计划时,只有同时满足以下条件才注册命令工具:当前 Turn 至少有一个执行环境,ShellTool 与 UnifiedExec feature 均开启,并且模型的 shell 类型不是 Disabled。注册结果固定为 exec_command 与 write_stdin,旧的 shell_command handler 已不在这条分支中。

源码位置:codex-rs/core/src/tools/spec_plan.rs :: add_shell_tools

rust
fn add_shell_tools(context: &CoreToolPlanContext<'_>, registry: &mut ToolRegistry) {
    let turn_context = context.turn_context;
    let features = turn_context.config.features.get();
    let environment_mode = tool_environment_mode(context.environments);
    if !environment_mode.has_environment()
        || !features.enabled(Feature::ShellTool)
        || !features.enabled(Feature::UnifiedExec)
        || matches!(
            turn_context.model_info.shell_type,
            ConfigShellToolType::Disabled
        )
    {
        return;
    }

    let allow_login_shell = any_environment_allows_login_shell(context.environments);
    let exec_permission_approvals_enabled = features.enabled(Feature::ExecPermissionApprovals);
    let include_environment_id = matches!(environment_mode, ToolEnvironmentMode::Multiple);
    registry.add(ExecCommandHandler::new(ExecCommandHandlerOptions {
        allow_login_shell,
        exec_permission_approvals_enabled,
        include_environment_id,
        include_shell_parameter: unified_exec_should_include_shell_parameter(
            turn_context,
            context.environments,
        ),
    }));
    registry.add(WriteStdinHandler);
}

这段代码只决定模型能看到什么,不创建进程。include_environment_id 由执行环境数量决定;shell 参数是否出现,则还取决于当前 shell mode 和是否存在远程环境。远程环境可能运行不同操作系统,所以不能沿用宿主机的 shell 推断。

1.2 双工具协议 ​

exec_command 负责创建会话并执行第一次等待;如果进程在等待窗口结束后仍存活,结果会携带统一进程 ID。write_stdin 使用这个 ID 写入字符或继续轮询。因此,“命令工具”不是一次请求对应一次完整进程,而是可能跨越多次工具调用。

两者共享 UnifiedExecProcessManager,但职责不同:handler 解析模型协议,manager 拥有进程表、输出收集和退出清理。

2. Handler归一化 ​

2.1 环境与路径 ​

ExecCommandHandler::handle_call 从 ToolInvocation 取得 Session、TurnContext、StepContext、取消令牌、调用 ID 和 tracker。这里使用的是 StepContext 中冻结的环境快照,而不是临时读取全局配置。

源码位置:codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs :: ExecCommandHandler::handle_call

rust
let manager: &UnifiedExecProcessManager = &session.services.unified_exec_manager;
let context = UnifiedExecContext::new(
    session.clone(),
    step_context.clone(),
    cancellation_token,
    call_id.clone(),
);
let environment_args: ExecCommandEnvironmentArgs = parse_arguments(&arguments)?;
let Some(turn_environment) = resolve_tool_environment(
    &step_context.environments,
    environment_args.environment_id.as_deref(),
)? else {
    return Err(FunctionCallError::RespondToModel(
        "unified exec is unavailable in this session".to_string(),
    ));
};
let native_environment_cwd = turn_environment.cwd().clone();
let cwd = environment_args
    .workdir
    .as_deref()
    .filter(|workdir| !workdir.is_empty())
    .map_or_else(
        || Ok(native_environment_cwd.clone()),
        |workdir| native_environment_cwd.join(workdir),
    )
    .map_err(|err| FunctionCallError::RespondToModel(err.to_string()))?;

cwd 的类型是 PathUri。相对 workdir 必须先和所选环境的 cwd 合并,不能直接交给宿主机的 std::env::current_dir()。这一区别在远程 Windows 环境或 URI-native 文件系统上尤其重要。

只有宿主机需要亲自施加本地 sandbox 时,handler 才强制把 cwd 转成当前平台的 native path。远程 executor 自己解释 symbolic roots 和路径约定;Core 若过早把远程路径塞进宿主机的路径类型,反而会错误拒绝合法请求。

源码位置:codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs :: requires_host_native_cwd、native_cwd

rust
let requires_host_native_cwd = !environment.is_remote()
    && SandboxManager::new().select_initial(
        turn_environment.permission_profile(),
        SandboxablePreference::Auto,
        turn.windows_sandbox_level,
        turn.network.is_some(),
    ) != SandboxType::None;
let cwd_uses_native_convention =
    cwd.infer_path_convention() == Some(PathConvention::native());
let native_cwd = match cwd.to_abs_path() {
    Ok(cwd) if cwd_uses_native_convention => Some(cwd),
    _ if !requires_host_native_cwd => None,
    Err(err) => return Err(FunctionCallError::RespondToModel(err.to_string())),
    Ok(_) => {
        return Err(FunctionCallError::RespondToModel(format!(
            "path URI `{cwd}` does not use the host's native {} path convention",
            PathConvention::native()
        )));
    }
};

2.2 命令与权限 ​

handler 随后解析 sandbox permissions、合并 Turn 已授予权限、检查远程环境报告的 shell,并生成实际 argv。远程环境如果显式请求了不同 shell,目前会被拒绝,而不是在宿主机上猜测一个等价 shell。

进程 ID 也在这一阶段预留。它属于 Core 的统一执行层,因此参数校验、apply-patch 拦截或审批准备失败时必须释放;否则 process store 会留下从未对应任何进程的 reservation。

源码位置:codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs :: process_id、ExecCommandRequest

rust
let process_id = manager.allocate_process_id().await;
let resolved_command = get_command(
    &args,
    shell,
    &shell_mode,
    turn_environment.config().allow_login_shell,
)
.map_err(FunctionCallError::RespondToModel)?;
let command = resolved_command.command;
let shell_type = resolved_command.shell_type;

match manager
    .exec_command(
        ExecCommandRequest {
            command,
            shell_type,
            hook_command: hook_command.clone(),
            process_id,
            yield_time_ms,
            max_output_tokens,
            cwd,
            sandbox_cwd: native_environment_cwd,
            turn_environment: turn_environment.clone(),
            shell_mode,
            network: context.step_context.turn.network.clone(),
            tty,
            sandbox_permissions: effective_additional_permissions.sandbox_permissions,
            additional_permissions: normalized_additional_permissions,
            additional_permissions_preapproved: effective_additional_permissions
                .permissions_preapproved,
            justification,
            prefix_rule,
        },
        &context,
    )
    .await

注意这里仍没有创建 OS process。handler 的最终产物是带有环境、URI cwd、shell mode、权限意图、网络配置、TTY 和输出窗口的 ExecCommandRequest。

3. 策略编排 ​

3.1 执行环境 ​

UnifiedExecProcessManager::open_session_with_sandbox 是 handler 和通用工具编排器之间的适配层。它依据环境的 ShellEnvironmentPolicy 创建 env,注入 thread/session、apply-patch 和 permission profile 信息,再过滤不能继承到子进程的运行时变量。

源码位置:codex-rs/core/src/unified_exec/process_manager.rs :: open_session_with_sandbox

rust
let shell_environment_policy = request.turn_environment.shell_environment_policy();
let local_policy_env = create_env(shell_environment_policy, /*thread_id*/ None);
let mut env = local_policy_env.clone();
env.insert(
    CODEX_THREAD_ID_ENV_VAR.to_string(),
    context.session.thread_id.to_string(),
);
inject_session_id_env(&mut env, context.session.session_id());
inject_apply_patch_env(&mut env, &turn.config.features);
let active_permission_profile = request.turn_environment.active_permission_profile();
inject_permission_profile_env(&mut env, active_permission_profile.as_ref());
let mut env = apply_unified_exec_env(env);
strip_output_env(&mut env);
let mut explicit_env_overrides = shell_environment_policy.r#set.clone();
strip_output_env(&mut explicit_env_overrides);

这里同时准备 ExecServerEnvConfig 和 shell snapshot request。前者让 exec-server 在自己的环境中重放 inherit/exclude/set/include-only 策略;后者允许 executor 捕获 shell 状态,而不是把宿主机已经展开的环境盲目复制过去。

3.2 Executor沙箱 ​

ToolOrchestrator 统一执行“审批、选择 sandbox、第一次尝试、必要时升级重试”。但它不会假定所有 sandbox 都由 Core 宿主机实施。

源码位置:codex-rs/core/src/tools/orchestrator.rs :: ToolOrchestrator::run

rust
let environment = tool.turn_environment(req);
let owner_network_policy = environment.config().network_policy.is_some();
let workspace_roots = environment.workspace_roots();
let executor_managed_process_sandbox = tool.uses_executor_managed_process_sandbox(req);
let permission_profile = environment.permission_profile();
let permissions = if executor_managed_process_sandbox {
    // Executor-native roots remain symbolic until the executor applies its own sandbox.
    permission_profile.clone()
} else {
    environment.permission_profile_with_workspace_roots()
};
let file_system_sandbox_policy = permissions.file_system_sandbox_policy();

Unified Exec 在两种情况下声明 process sandbox 由 executor 管理:执行环境是远程环境,或者请求包含 shell snapshot。后者很容易被忽略:即使环境逻辑上是本地的,只要需要 executor-side snapshot,启动也会经过 exec-server backend。

源码位置:codex-rs/core/src/tools/runtimes/unified_exec.rs :: uses_executor_managed_process_sandbox

rust
fn uses_executor_managed_process_sandbox(&self, req: &UnifiedExecRequest) -> bool {
    req.turn_environment.environment.is_remote() || req.shell_snapshot.is_some()
}

这解释了为什么权限根在某些分支中保持 symbolic:真正施加 sandbox 的机器才知道路径约定和可用根。Orchestrator 仍负责审批语义和 retry 决策,但不能越权把 executor 路径提前解释成宿主路径。

3.3 Runtime准备 ​

UnifiedExecRuntime::run 接收已经决定好的 SandboxAttempt。它计算当前尝试的文件系统权限和 managed network,过滤 env,并区分本地代理准备与远程 executor-local proxy launch。

源码位置:codex-rs/core/src/tools/runtimes/unified_exec.rs :: UnifiedExecRuntime::run

rust
let environment_is_remote = req.turn_environment.environment.is_remote();
let shell_snapshot_location = if environment_is_remote {
    None
} else {
    let native_cwd = req
        .cwd
        .to_abs_path()
        .map_err(|err| ToolError::Rejected(err.to_string()))?;
    req.turn_environment.shell_snapshot(&native_cwd)
};
let (file_system_sandbox_policy, _) = attempt.permissions.to_runtime_permissions();
let launch_sandbox_permissions = sandbox_permissions_preserving_denied_reads(
    req.sandbox_permissions,
    &file_system_sandbox_policy,
);
let managed_network = attempt.network_proxy(managed_network_for_sandbox_permissions(
    req.network.as_ref(),
    launch_sandbox_permissions,
));
let env = exec_env_for_sandbox_permissions(&req.env, launch_sandbox_permissions);

Runtime 还会识别插件命令并创建 metrics sidecar。sidecar 将输出文件位置注入 env,长进程则把 sidecar 和 ProcessEntry 一起保存,直到退出 watcher 或后续 poll 得到最终退出码后再收尾。远程命令的插件归因不能依赖宿主机路径,而要通过 executor 文件系统异步解析。

从所有权看,Runtime 负责“这次尝试如何启动”;它不保存长期会话,也不直接生成模型可见的 session_id。

4. 启动后端 ​

4.1 本地Spawn ​

当环境不是远程环境、请求也不需要 exec-server shell snapshot 时,manager 才走宿主机直接启动。它把 PathUri 转成 native cwd,并把准备好的命令、环境、sandbox、TTY 和继承 fd 交给 codex_sandboxing::spawn_process。

源码位置:codex-rs/core/src/unified_exec/process_manager.rs :: open_session_with_prepared_exec_env

rust
let native_cwd = request
    .cwd
    .to_abs_path()
    .map_err(|_| UnifiedExecError::ForeignPath {
        path: request.cwd.clone(),
    })?;

if request.command.is_empty() {
    return Err(UnifiedExecError::MissingCommandLine);
}

let spawn_result = codex_sandboxing::spawn_process(codex_sandboxing::SpawnRequest {
    command: &request.command,
    cwd: native_cwd.as_path(),
    env: &request.env,
    arg0: &request.arg0,
    sandbox: request.sandbox,
    windows_sandbox,
    tty,
    stdin_open: tty,
    inherited_fds: &inherited_fds,
})
.await;
spawn_lifecycle.after_spawn();
let spawned = spawn_result
    .map_err(|err| UnifiedExecError::create_process(err.to_string()))?;
UnifiedExecProcess::from_spawned(spawned, request.sandbox, spawn_lifecycle).await

spawn_process 再依据 tty 和 stdin_open 选择 PTY、有 stdin 的 pipe 或无 stdin 的 pipe。由此可见,TTY 是进程传输形态,不是“是否使用 shell”的开关。

源码位置:codex-rs/sandboxing/src/spawn.rs :: spawn_process

rust
let (program, args) = request
    .command
    .split_first()
    .context("missing program for process spawn")?;
if request.tty {
    codex_utils_pty::pty::spawn_process(
        program,
        args,
        request.cwd,
        request.env,
        request.arg0,
        TerminalSize::default(),
        request.inherited_fds,
    )
    .await
} else if request.stdin_open {
    codex_utils_pty::pipe::spawn_process(
        program,
        args,
        request.cwd,
        request.env,
        request.arg0,
        request.inherited_fds,
    )
    .await
} else {
    codex_utils_pty::pipe::spawn_process_no_stdin(
        program,
        args,
        request.cwd,
        request.env,
        request.arg0,
        request.inherited_fds,
    )
    .await
}

4.2 ExecServer ​

只要环境远程,或者请求携带 exec-server shell snapshot,manager 就取得环境的 exec backend,并调用 start。远程网络策略需要 executor 参与判断时,则调用带 NetworkPolicyDecider 的启动入口。

源码位置:codex-rs/core/src/unified_exec/process_manager.rs :: open_session_with_prepared_exec_env

rust
if environment.is_remote() || request.exec_server_shell_snapshot.is_some() {
    if !inherited_fds.is_empty() {
        return Err(UnifiedExecError::create_process(
            "remote exec-server does not support inherited file descriptors".to_string(),
        ));
    }

    let backend = environment.get_exec_backend();
    let params = exec_server_params_for_request(
        process_id,
        request,
        windows_sandbox_proxy_settings_mode,
        tty,
    );
    let started = match network_policy_decider {
        Some(decider) => {
            backend
                .start_with_network_policy_decider(params, decider)
                .await
        }
        None => backend.start(params).await,
    }
    .map_err(|err| UnifiedExecError::create_process(err.to_string()))?;
    spawn_lifecycle.after_spawn();
    return UnifiedExecProcess::from_exec_server_started(started).await;
}

exec-server 参数保留 PathUri、env policy、shell snapshot、sandbox 和 managed network。发生 sandbox retry 或 snapshot 启动时,后端 ID 会追加 UUID;Core 对模型暴露的统一 ID 不变。

源码位置:codex-rs/core/src/unified_exec/process_manager.rs :: exec_server_params_for_request

rust
let exec_server_process_id =
    if request.exec_server_sandbox.is_some() || request.exec_server_shell_snapshot.is_some() {
        format!("{process_id}-{}", Uuid::new_v4())
    } else {
        process_id.to_string()
    };
codex_exec_server::ExecParams {
    process_id: exec_server_process_id.into(),
    argv: request.command.clone(),
    cwd: request.cwd.clone(),
    env_policy,
    shell_snapshot: request.exec_server_shell_snapshot.clone(),
    env,
    tty,
    pipe_stdin: false,
    arg0: request.arg0.clone(),
    sandbox,
    enforce_managed_network: request.exec_server_enforce_managed_network,
    managed_network: request.exec_server_managed_network.clone(),
    network_proxy: request.exec_server_network_proxy.clone(),
}

于是,同一条命令可能有以下标识链:

text
tool call_id
  -> Unified Exec process_id,例如 38142
    -> exec-server process_id,例如 38142-<uuid>
      -> OS pid

这些 ID 服务于不同关联范围,不能互换。call_id 连接模型工具调用和生命周期事件;统一 ID 连接 exec_command 与 write_stdin;后端 ID 连接 RPC;pid 只属于真正启动进程的操作系统。

5. 进程统一层 ​

本地与 exec-server 分支最终都包装成 UnifiedExecProcess。上层只通过统一的 write、interrupt、terminate、output handles 和 state watch 操作进程,不直接依赖某一种 transport。

源码位置:codex-rs/core/src/unified_exec/process.rs :: ProcessHandle、UnifiedExecProcess

rust
enum ProcessHandle {
    Local(Box<ExecCommandSession>),
    ExecServer(Arc<dyn ExecProcess>),
}

pub(crate) struct UnifiedExecProcess {
    process_handle: ProcessHandle,
    output_tx: broadcast::Sender<Vec<u8>>,
    output: OutputHandles,
    output_drained: Arc<Notify>,
    interaction_lock: Arc<Mutex<()>>,
    state_tx: watch::Sender<ProcessState>,
    state_rx: watch::Receiver<ProcessState>,
    output_task: Option<JoinHandle<()>>,
    sandbox_type: SandboxType,
    _spawn_lifecycle: Option<SpawnLifecycleHandle>,
}

本地分支直接向 ExecCommandSession 的 writer channel 写数据;远程分支调用 ExecProcess::write,并解释 Accepted、UnknownProcess、StdinClosed 与 Starting。这也是为什么模型看到的 write_stdin 错误不能简单映射成一次 std::io::Write 失败。

远程输出任务还承担恢复职责。若 event stream lagged、序号出现空洞,或旧 peer 的退出事件缺少 sandbox denial 字段,它会调用 process.read(last_seq, ...) 补齐 retained chunks 和最终状态。

源码位置:codex-rs/core/src/unified_exec/process.rs :: spawn_exec_server_output_task

rust
if event.is_none()
    || event_seq.is_some_and(|seq| seq > last_seq.saturating_add(1))
    || missing_sandbox_denial
{
    let response = match process
        .read(
            Some(last_seq),
            /*max_bytes*/ None,
            /*wait_ms*/ Some(0),
        )
        .await
    {
        Ok(response) => response,
        Err(err) => {
            let state = state_tx.borrow().clone();
            let _ = state_tx.send_replace(state.failed(err.to_string()));
            output_closed.store(true, Ordering::Release);
            output_closed_notify.notify_waiters();
            cancellation_token.cancel();
            break;
        }
    };
    let ExecReadResponse {
        chunks,
        next_seq,
        exited,
        exit_code,
        closed,
        failure,
        sandbox_denied,
    } = response;
}

因此 exec-server 事件流是快速路径,read 是恢复路径。只有同时理解事件序号与 retained output,才能解释“连接恢复后为什么没有重复输出”以及“为何输出流关闭不一定等于子进程刚刚退出”。

6. 会话生命周期 ​

6.1 首次等待 ​

manager 启动输出 streaming 后,会在首次 yield 等待之前把仍存活的进程放入 process store。这个顺序保证 Turn 被中断或 handler future 被取消时,后台进程不会因为最后一个 Arc 被丢弃而立刻触发 Drop::terminate。

源码位置:codex-rs/core/src/unified_exec/process_manager.rs :: UnifiedExecProcessManager::exec_command

rust
let process_started_alive = !process.has_exited() && process.exit_code().is_none();
let mut initial_exec_command_guard = if process_started_alive {
    let initial_exec_command_active = Arc::new(AtomicBool::new(true));
    self.store_process(
        Arc::clone(&process),
        context,
        &request.command,
        request.hook_command.clone(),
        cwd.clone(),
        plugin_attribution.clone(),
        start,
        request.process_id,
        request.tty,
        deferred_network_approval.clone(),
        network_denial_monitor,
        metrics_sidecar,
        Arc::clone(&transcript),
        Arc::clone(&initial_exec_command_active),
    )
    .await;
    InitialExecCommandGuard {
        active: Some(initial_exec_command_active),
        metrics_sidecar: None,
    }
} else {
    InitialExecCommandGuard {
        active: None,
        metrics_sidecar,
    }
};

短命令不会进入长期会话:manager 立即生成结束事件、完成网络审批和插件测量、检查 sandbox denial,然后释放统一 ID。长命令则返回 process_id: Some(...),由 exit watcher 和后续交互继续收尾。

6.2 串行交互 ​

write_stdin 允许不同 session 并行,但同一 session 的写入和轮询必须经过 interaction_lock。原因是它们共享一个会被 drain 的输出 buffer,也共享退出检测和 process store 清理。

源码位置:codex-rs/core/src/unified_exec/process_manager.rs :: UnifiedExecProcessManager::write_stdin

rust
let locked_process = {
    let store = self.process_store.lock().await;
    let entry = store
        .processes
        .get(&process_id)
        .ok_or(UnifiedExecError::UnknownProcessId { process_id })?;
    Arc::clone(&entry.process)
};
let _interaction_guard = locked_process.interaction_lock().lock_owned().await;

非 TTY 进程通常没有可写 stdin;唯一特殊输入是中断字符,它会被转换为 interrupt()。TTY 进程则把字符交给本地 writer 或远程 process/write。空字符串表示只轮询输出,不发送输入。

6.3 失败形态 ​

执行链至少区分以下结果:

失败位置典型原因是否有可继续的统一会话
handler 启动前参数、环境、shell、路径或权限字段非法否,释放 reservation
Orchestrator审批拒绝、策略禁止、sandbox 准备失败否
backend start本地 spawn 或 exec-server start 失败否
已启动进程非零退出码否,返回 exit code 和输出
长进程运行中网络拒绝、远程通信失败、显式 terminate通常终止并从 store 清理
首次 yield 到期进程仍正常运行是,返回统一 ID

Sandbox denial 是一个值得单独观察的边界:进程可能已经启动并产生输出,因此 handler 会把 denial 转换为终态工具输出,携带 exit code 和截断信息,但不会返回可供 write_stdin 继续使用的 process ID。

7. 产品与后端边界 ​

7.1 ExecServer职责 ​

exec-server 对外提供 process start/read/write/signal/terminate 等能力。服务端的 LocalProcess 保存实际进程、retained output、事件序号、退出码、sandbox 状态和已接受的 stdin write ID。后一个缓存用于处理重连后的写入重试,避免同一个 write 被重复执行。

Core 的 process store 与 exec-server 的 process map 是两个不同容器:前者服务模型工具会话,后者服务执行协议。Core 条目被清理,不等价于远端 map 在同一时刻消失;RPC transport 断开,也不自动等价于 OS child 已退出。

7.2 连接替换 ​

某些宿主先接受并认证 WebSocket,再把连接交给 EnvironmentManager。替换连接时,exec-server client 会关闭旧 transport,用保存的 session ID 完成 initialize/resume,并恢复既有进程与输出。

源码位置:

  • codex-rs/exec-server/src/environment/accepted.rs :: EnvironmentManager::from_accepted_websocket
  • codex-rs/exec-server/src/environment/accepted.rs :: EnvironmentManager::replace_accepted_websocket
  • codex-rs/exec-server/src/client/accepted.rs :: Inner::accept_replacement_connection
rust
let client = ExecServerClient::connect_accepted_websocket(websocket, options).await?;
let client = LazyRemoteExecServerClient::from_connected(client, http_client_factory.clone());
let environment = Arc::new(Environment::remote_with_client(
    client, /*local_runtime_paths*/ None,
));

这里恢复的是 exec-server session 和它管理的进程,不是重新执行模型工具调用。Core 的 call_id、统一进程 ID 与输出去重仍由各自层次维护。

7.3 非交互CLI ​

codex exec 从 codex-rs/exec/src/main.rs 启动,解析 CLI 参数后进入 codex_exec::run_main。它创建并驱动一个非交互 Agent 任务,任务内部仍可能由模型调用 exec_command。因此它位于执行体系上游,而不是 Unified Exec 的 backend。

源码位置:codex-rs/exec/src/main.rs :: main

rust
fn main() -> anyhow::Result<()> {
    arg0_dispatch_or_else(|arg0_paths: Arg0DispatchPaths| async move {
        let top_cli = TopCli::parse();
        let mut inner = top_cli.inner;
        inner.psp = top_cli.psp;
        inner
            .config_overrides
            .prepend_root_overrides(top_cli.config_overrides);

        run_main(inner, arg0_paths).await?;
        Ok(())
    })
}

可以用一句话区分三者:codex exec 决定如何运行一次 Agent 任务,exec_command 决定模型如何请求命令执行,exec-server 决定某个环境如何承载进程协议。

8. 验证路径 ​

8.1 环境过滤 ​

exec_env_policy_excludes_non_inheritable_and_runtime_variables 构造包含普通变量、不可继承变量和 Codex 运行时变量的 shell policy,断言转换后的 exec-server policy 会排除 permission profile、apply-patch 和 plugin metrics 等内部键。这验证的是环境策略边界,不涉及真实 spawn。

源码位置:codex-rs/core/src/unified_exec/process_manager_tests.rs :: exec_env_policy_excludes_non_inheritable_and_runtime_variables

text
cd codex-rs
cargo test -p codex-core --lib exec_env_policy_excludes_non_inheritable_and_runtime_variables -- --nocapture

8.2 启动与退出 ​

exec_process_starts_and_exits 把同一个断言分别运行在 local 与 remote backend 上:启动成功后等待事件,最终要求 exit code 为 0 且输出流关闭。它证明两种 backend 可以投影成相同的 ExecProcess 结果,不证明所有平台 shell、sandbox 或网络代理完全等价。

源码位置:codex-rs/exec-server/tests/exec_process.rs :: exec_process_starts_and_exits

text
cd codex-rs
cargo test -p codex-exec-server --test exec_process exec_process_starts_and_exits -- --nocapture --test-threads=1

8.3 中断传递 ​

exec_process_signal_interrupts_process 让 shell 先输出 ready,再等待 INT。测试调用 backend signal,断言 trap 输出、退出码和 closed 状态。它验证 signal 传递与退出回收,不代表 Windows 与 Unix 共享同一信号实现。

源码位置:codex-rs/exec-server/tests/exec_process.rs :: exec_process_signal_interrupts_process

text
cd codex-rs
cargo test -p codex-exec-server --test exec_process exec_process_signal_interrupts_process -- --nocapture --test-threads=1

8.4 连接恢复 ​

accepted_websocket_reconnect_recovers_running_process_and_output 启动一个持续输出的进程,替换 accepted WebSocket,然后继续读取恢复后的进程与输出。它验证“连接可替换、session 与进程可恢复”,不意味着任意断网时长都能无限保留历史输出;保留窗口和恢复超时仍受 exec-server 配置约束。

源码位置:codex-rs/exec-server/tests/accepted_websocket.rs :: accepted_websocket_reconnect_recovers_running_process_and_output

text
cd codex-rs
cargo test -p codex-exec-server --test accepted_websocket accepted_websocket_reconnect_recovers_running_process_and_output -- --nocapture --test-threads=1

9. 从症状反推所有权 ​

遇到执行问题时,按所有权定位比从错误字符串猜测更可靠:

现象优先检查
模型根本看不到命令工具tool plan、feature、环境数量、模型 shell type
cwd 在远程环境被拒绝PathUri 约定、是否错误要求 host-native cwd
审批后仍未创建进程Orchestrator 的 sandbox/network attempt
同一统一 ID 对应多个后端 IDsandbox retry 或 shell snapshot 启动
命令返回了 session ID首次 yield 到期,进程已进入 process store
write_stdin 报 stdin closed非 TTY 输入、远程 write status 或进程已经退出
远程输出出现序号空洞event stream lag,检查 read recovery
transport 替换后进程仍在accepted connection 恢复的是 exec-server session
codex exec 退出异常先查非交互 Agent 入口,不要直接归因于 process backend

整条主线可以压缩为:

text
Tool plan
  -> ExecCommandHandler
    -> UnifiedExecProcessManager
      -> ToolOrchestrator
        -> UnifiedExecRuntime
          -> local spawn 或 exec-server backend
            -> UnifiedExecProcess
              -> process store / write_stdin / exit watcher

这条链最重要的不是层数,而是每层只拥有一类决定:handler 解释模型协议,Orchestrator 决定批准哪一次尝试,Runtime 准备该尝试,backend 创建进程,统一包装器归一化 transport,manager 管理跨工具调用的会话。沿这些边界阅读后续执行专题,才能把一次命令的“请求、启动、交互、退出、恢复”分开分析。