# exec-server · 在另一台机器上执行

> Codex 把“想”和“做”拆开：模型调用、上下文与审批留在 app-server 一侧，起进程、读写文件、发 HTTP 请求经 Environment 抽象落到本机或远端的 exec-server。本篇讲 Environment 三件套、exec-server 的 JSON-RPC 协议与断线续接、注册表加 Noise 加密中继的远程模式，以及路径与沙箱怎么跨操作系统。

- 作者：David（道雾轩）
- 专栏：Codex 源码解读（https://daiw.net/manual/codex-source.md）
- 最后更新：2026-09-29
- 原文：https://daiw.net/manual/codex-source/exec-server
- 转载与引用：请注明出处并附原文链接（https://daiw.net/about/copyright）

# exec-server · 在另一台机器上执行

前面几部分默认了一件事：命令就在你这台电脑上跑。[执行命令](https://daiw.net/manual/codex-source/unified-exec)起进程、[apply_patch](https://daiw.net/manual/codex-source/apply-patch) 改文件，最后都落到本机的操作系统上。exec-server 把这个假设拿掉：agent 的“脑”——模型调用、历史、审批——留在 app-server；“手”——起进程、读写文件、发 HTTP 请求——可以是另一台机器上的一个小服务。`codex-exec-server` 去掉测试约 2.7 万行，在 153 个成员里排第四，可见这不是边角功能。

## 怎么用

- **起服务**：`codex exec-server`（帮助里标着 `[EXPERIMENTAL]`）默认监听 `ws://127.0.0.1:0`，由系统分配端口，并把实际地址打印到标准输出；`--listen stdio` 改走标准输入输出。每个连接默认逐个处理请求，`--concurrent-requests N` 放开并发。
- **让 Codex 用它**：设环境变量 `CODEX_EXEC_SERVER_URL=ws://…`，唯一的执行环境变成 `remote`，本机环境不再提供；设成 `none` 则不给模型任何 shell 与文件工具。要同时挂多个环境，就写 `$CODEX_HOME/environments.toml`，这个文件存在时不再读 `CODEX_EXEC_SERVER_URL`。
- **远程注册**：`codex exec-server --remote URL --environment-id ID` 不监听端口，而是把本机注册到一个环境注册表，经加密中继接受连接；`forward --connect ws://…` 把一台已有的 exec-server 桥接进去。
- **前端接口**：app-server v2 有实验性的 `environment/add`、`environment/info`、`environment/status`；`thread/start` 与 `turn/start` 的 `environments` 参数选定本线程、本轮用哪些环境，列表第一项是主环境，传空列表则关闭环境访问。

命令行与环境变量的细节见手册的 [SDK 与 app-server](https://daiw.net/manual/codex/sdk-app-server) 和[环境变量](https://daiw.net/manual/codex/environment-variables)，下面只看实现。

## Environment：执行能力的三件套

`EnvironmentManager` 是按 ID 索引的环境表，记着默认环境；`local` 这个 ID 保留给本机。环境从哪来有固定的优先级（`EnvironmentManager::prepare_from_codex_home`）：`CODEX_EXEC_SERVER_NOISE_REGISTRY_URL`、`CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID`、`CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN` 三个变量齐全时，直接生成一个经中继连接的默认远端环境（只设一部分会报错）；否则看 `environments.toml`；再否则才是 `CODEX_EXEC_SERVER_URL`。`environments.toml` 里每个 `[[environments]]` 必须在 `url`（`ws://` 或 `wss://`，可带 `auth_bearer_token`）与 `program`（加 `args`、`env`、`cwd`，由 Codex 拉起子进程走 stdio）之间二选一；`id` 最长 64 个字符，只能用字母、数字、`-` 和 `_`，`local` 与 `none` 是保留字；`include_local` 默认为 `true`。

具体的环境是 `Environment`，内部就三样能力：

- `exec_backend: Arc<dyn ExecBackend>`：起进程，返回 `ExecProcess` 句柄，可以按序号读输出、订阅推送事件、写 stdin、发信号、终止；
- `filesystem: Arc<dyn ExecutorFileSystem>`：读写文件、列目录、遍历、复制与删除，trait 定义在 `codex-file-system`；
- `http_client: Arc<dyn HttpClient>`：以这个环境的身份发 HTTP 请求。

本机环境用 `LocalProcess`、`LocalFileSystem` 和 `RouteAwareHttpClient` 填这三个槽；远端环境则让三者共用同一个客户端：

```rust
    pub(crate) fn remote_with_client(
        client: LazyRemoteExecServerClient,
        local_runtime_paths: Option<ExecServerRuntimePaths>,
    ) -> Self {
        let exec_backend: Arc<dyn ExecBackend> = Arc::new(RemoteProcess::new(client.clone()));
        let filesystem: Arc<dyn ExecutorFileSystem> =
            Arc::new(RemoteFileSystem::new(client.clone()));

        Self {
            remote_client: Some(client.clone()),
            ready_info: Arc::new(ArcSwapOption::empty()),
            provisioning_status_tx: None,
            startup_task: Arc::new(Mutex::new(None)),
            exec_backend,
            filesystem,
            http_client: Arc::new(client),
            local_runtime_paths,
        }
    }
```

（`codex-rs/exec-server/src/environment.rs:788`）

`LazyRemoteExecServerClient` 把“连接”藏在调用后面：并发调用方共享同一次连接尝试，断线后下一次调用触发重连，同一场故障也只重连一次。普通远端环境一加入管理器就在后台开始连（`start_connecting`），以 `program` 定义的 stdio 环境要起子进程，所以等真正用到才启动，而且断了不重连（`can_reconnect` 只认 WebSocket、Noise 中继和延迟就绪三种传输）。

内核这边，`codex-rs/core/src/environment_selection.rs` 的 `ThreadEnvironments` 管着线程选中的环境列表，每启动一个任务就取一份 `TurnEnvironmentSnapshot`。解析远端环境时先等连接就绪，再用 `environment/info` 的返回构造 shell、确定用户主目录与临时目录；本机环境直接用本地探测的结果。工具处理器拿到的是 `TurnEnvironment`：读 AGENTS.md、`apply_patch`、`view_image` 都经 `get_filesystem()` 访问文件；执行命令时，unified exec 在 `environment.is_remote()`（或要用执行端 shell 快照）时把请求转成 `ExecParams` 交给 `get_exec_backend()`，本机环境仍走原来的直接 spawn（`codex-rs/core/src/unified_exec/process_manager.rs:1327`）。

```mermaid
flowchart TB
  T[工具处理器<br/>unified exec · apply_patch · view_image] --> TE[TurnEnvironment<br/>本轮选中的环境]
  TE --> E[Environment<br/>ExecBackend + ExecutorFileSystem + HttpClient]
  E -->|local| L[LocalProcess<br/>LocalFileSystem]
  E -->|远端| R[RemoteProcess<br/>RemoteFileSystem]
  R --> C[LazyRemoteExecServerClient<br/>懒连接 · 断线重连]
  C -->|WebSocket| S[exec-server<br/>ConnectionProcessor]
  C -->|stdio 子进程| S
  C -->|Noise 中继| S
  S --> H[ExecServerHandler]
  H --> P[ProcessHandler<br/>内部就是 LocalProcess]
  H --> F[FileSystemHandler]
  L --> OS1[本机操作系统]
  P --> OS2[远端操作系统]
  F --> OS2
```

## 协议：进程、文件与 HTTP

线上格式是 JSON-RPC，类型定义在独立的 `codex-exec-server-protocol` crate：WebSocket 上一条消息装一个 JSON-RPC 消息，stdio 上一行一个。连接先发 `initialize`（可带 `resumeSessionId`），收到响应后发 `initialized` 通知，之后才能调用：

| 组 | 方法 |
| --- | --- |
| 进程 | `process/start`、`process/read`、`process/write`、`process/signal`、`process/terminate`；通知 `process/output`、`process/exited`、`process/closed` |
| 文件 | `fs/readFile`，流式读的 `fs/open`、`fs/readBlock`、`fs/close`，以及 `fs/writeFile`、`fs/createDirectory`、`fs/getMetadata`、`fs/canonicalize`、`fs/readDirectory`、`fs/walk`、`fs/remove`、`fs/copy` |
| 其它 | `environment/info`、`environment/status`、`http/request`（响应体经 `http/request/bodyDelta` 通知流式回传）、`capabilityRoots/discoverV1`（在执行端发现插件与 skill） |

`process/start` 的参数把“在哪、怎么跑”说全了。注意 `cwd` 不是本机路径，沙箱也只是“意图”：

```rust
pub struct ExecParams {
    /// Client-chosen logical process handle scoped to this connection/session.
    /// This is a protocol key, not an OS pid.
    pub process_id: ProcessId,
    // ...
    pub argv: Vec<String>,
    /// Working directory URI, interpreted using the exec-server host's path rules at launch time.
    pub cwd: PathUri,
    // ...
    pub env: HashMap<String, String>,
    pub tty: bool,
    // ...
    /// Portable sandbox intent. Concrete wrapper argv is resolved by the exec-server.
    #[serde(default)]
    pub sandbox: Option<FileSystemSandboxContext>,
    // ...
}
```

（`codex-rs/exec-server-protocol/src/protocol.rs:297`）

服务端没有另写一套执行逻辑。每条连接由 `ConnectionProcessor` 跑一个循环，请求经路由表交给 `ExecServerHandler`；进程类请求落到 `ProcessHandler`，而它只是把本机环境用的那个 `LocalProcess` 包了一层：

```rust
#[derive(Clone)]
pub(crate) struct ProcessHandler {
    process: LocalProcess,
}

impl ProcessHandler {
    pub(crate) fn new(
        notifications: RpcNotificationSender,
        telemetry: ExecServerTelemetry,
        runtime_paths: ExecServerRuntimePaths,
    ) -> Self {
        Self {
            process: LocalProcess::new(notifications, telemetry, runtime_paths),
        }
    }
```

（`codex-rs/exec-server/src/server/process_handler.rs:19`）

所以“远端执行”等于“别人机器上的本地执行”，两边共用同一套 spawn、沙箱与输出缓冲代码。输出按进程编号：每个输出块、退出、关闭各占一个递增的 `seq`，服务端每个进程最多保留 1 MiB、5 万块输出（`RETAINED_OUTPUT_BYTES_PER_PROCESS`、`RETAINED_OUTPUT_CHUNKS_PER_PROCESS`），进程退出且输出流关闭后，记录再保留 30 秒才清掉。客户端两种消费方式都支持：`read` 按 `afterSeq` 翻页，`subscribe_events` 收推送；输出、退出、关闭通知出自服务端不同的任务，可能乱序到达，客户端的 `OrderedSessionEvents` 按 `seq` 排好再交出去。

网络是个反向调用的例子。进程带着受管网络代理启动时，执行端代理遇到新的目标主机，会发 `network/policyRequest` 反过来问控制端；超时、断线或回复不合规都按 `not_allowed` 拒绝（`codex-rs/exec-server/src/network_policy_decisions.rs:24`）。审批的决定权始终在控制端，执行端只负责执行与拦截。

## 断线之后：会话续接

远程链路会断，长命令不能跟着死。`initialize` 不带 `resumeSessionId` 时，服务端新建一个会话（UUID）；带上则重新挂到原会话。连接断开只是“脱离”：会话及其进程再保留 30 秒（`DETACHED_SESSION_TTL`），过期没人接回才关掉进程。客户端的恢复窗口 `SESSION_RECOVERY_TIMEOUT` 特意设成 25 秒，注释解释说两端各自开始计时，要在服务端的 30 秒里留出余量（`codex-rs/exec-server/src/client_recovery.rs:61`）。

重连后，客户端用 `process/read` 从最后收到的 `seq` 往后补齐输出；序号出现缺口（服务端的保留已经滚掉）就报错，而不是悄悄丢数据。写 stdin 也要防重复：`process/write` 带客户端生成的 `writeId`，服务端每个进程记住最近 4096 个已接受的 ID，重试同一个 ID 只回 `accepted`，不会把同样的字节写两次。stdio 模式一个进程只服务一条连接，断了就收尾，谈不上续接。

<Callout type="warn">

  crate 自带的 README 有几处落后于代码：它说连接关闭即终止该连接的进程，实际有上面 30 秒的保留期；它的 `process/write` 示例没有 `writeId`，而 v0.158.0 的 `WriteParams` 里这是必填字段，写未知进程或未开 stdin 的进程时返回的也是 `WriteStatus` 里的 `UnknownProcess`、`StdinClosed` 状态，而不是 JSON-RPC 错误。以代码为准。

</Callout>

## 远程模式：注册表与 Noise 中继

直连 WebSocket 适合同一内网。跨网络时，执行端往往在 NAT 后面、没有公网端口，于是有了 `--remote`：执行端主动连出去，双方在中继（代码里叫 rendezvous）碰头。代码把发起方（跑 app-server 的一侧）叫 harness，执行端叫 executor；注册表负责发放地址和授权。

```mermaid
sequenceDiagram
  participant X as exec-server 执行端
  participant G as 环境注册表
  participant V as 中继 rendezvous
  participant H as Codex harness 端
  X->>G: POST /cloud/environment/ID/register · 执行端公钥
  G-->>X: 中继 URL 与 registration id
  X->>V: 建立 WebSocket 并保持
  H->>G: POST /cloud/environment/ID/connect · harness 公钥
  G-->>H: 中继 URL、执行端公钥、短时效授权
  H->>V: Handshake 帧 · Noise 第一条消息内含授权
  V->>X: 按 stream_id 转发
  X->>G: POST /cloud/environment/ID/validate
  G-->>X: valid
  X-->>H: Handshake 应答 · 握手完成
  H->>X: Data 帧 · 加密的 JSON-RPC
```

中继只看得到明文的路由信息：外层是 protobuf 的 `RelayMessageFrame`（`stream_id`、序号、确认位图、`handshake`/`data`/`resume`/`reset` 等帧体），载荷是加密记录。一条执行端 WebSocket 上可以复用多条虚拟流，每条 `stream_id` 都当作一条独立的 JSON-RPC 连接来处理（`run_registered_connection`），同时最多 128 条；同一条物理连接上握手失败累计 8 次，执行端就暂停接受新握手 10 秒，已建立的流不受影响。分段、确认、重传、去重都由两端自己做，中继只转发。加密层写在模块注释里：

```rust
//! Noise channel used by the remote exec-server relay.
//!
//! The harness initiates hybrid IK and pins the exec-server static key returned
//! by the registry. The first handshake message lets the exec-server authenticate
//! the harness static key; the exec-server then asks the registry whether that
//! key is authorized before completing the handshake.
//!
//! "Hybrid" means the session keys include both X25519 and ML-KEM-768 key
//! agreement. Once the two-message handshake finishes, AES-GCM protects the
//! ordered transport records carrying JSON-RPC.
// ...
pub(crate) const NOISE_CHANNEL_SUITE: &str = "Noise_hybridIK_X25519+MLKEM768_AESGCM_SHA256";
```

（`codex-rs/exec-server/src/noise_channel.rs:1`）

职责分得很清楚：Noise 证明“对方确实握着这把私钥”，注册表决定“这把钥匙能不能用这台执行端”。harness 钉住注册表给的执行端公钥，中继或别人冒充不了执行端；会话密钥同时来自 X25519 与后量子的 ML-KEM-768 两种密钥协商。执行端进程的密钥对只在启动时生成一次，注册信息在重连时复用，中继拒绝旧地址才重新注册；重连间隔从 1 秒起翻倍，封顶 30 秒。

另有一种 `--remote-transport direct`，面向 AWS 托管的注册表：用 SigV4 给注册请求和 WebSocket 握手签名，连上之后直接跑不加 Noise 的 JSON-RPC，不支持 `forward`。注册认证默认用 `codex login` 的 ChatGPT 登录；用 `CODEX_API_KEY` 时，注册地址只能是 HTTPS 的 openai.com、openai.org 及其子域或回环地址（`validate_api_key_remote_host`）。

## 跨操作系统

控制端和执行端可以是不同的系统。仓库里有一条只在 Bazel 下跑的集成测试专门验证这点：在 Linux 上用 Wine 起一个 Windows 版 exec-server，让模拟的模型经 unified exec 在 `C:\windows` 下跑 PowerShell 命令、用 apply_patch 写文件（`codex-rs/core/tests/remote_env_windows/`）。能这样做，靠的是几条约定：

- **路径一律是 URI**。协议里的路径都是 `PathUri`，即 `file:` URI：拼接、取父目录按 `/` 做纯字面运算，不用本机规则解释；Windows 路径比较忽略 ASCII 大小写，POSIX 区分大小写。执行端在启动那一刻才转成本机路径，转不了就报 “is not valid on this exec-server host”。
- **环境信息由执行端报告**。`initialize` 的响应里直接带着 `EnvironmentInfo`：shell、`platformOs`、用户主目录、临时目录、执行端版本；内核据此构造 shell、展开 `~` 与 `:tmpdir`。
- **沙箱在执行端落地**。控制端只发可移植的 `FileSystemSandboxContext`（权限配置与工作区根，路径同样是 URI），执行端用 `select_sandbox` 选本机的实现——Seatbelt、Linux 沙箱、Windows 受限令牌或 MXC——再把实际用的类型放进 `ExecResponse` 回报。带沙箱的文件操作由顶层 `codex` 可执行文件以 `--codex-run-as-fs-helper` 隐藏参数再起一个辅助进程，在沙箱里执行，请求与结果经 stdin、stdout 传 JSON；执行端找不到可用的沙箱实现时直接拒绝，不会退回无沙箱执行。
- **能力协商**。`EnvironmentCapabilities` 列出执行端支持的新特性，例如带沙箱的能力发现、shell 快照 v2（本机实现只在 Unix 上打开）、MXC。注释写明客户端必须先看这些开关，再决定发不发新字段，新旧版本才能混用。

几处小防护也值得一提：WebSocket 监听器拒绝任何带 `Origin` 头的请求（返回 403），网页里的脚本连不上本机的 exec-server；可选的 `--ws-auth` 系列参数在握手时校验令牌；`CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN` 在 `NON_INHERITABLE_ENV_VARS` 名单里，模型跑的子进程看不到它。另有一个独立的隐藏命令 `codex tcp-tunnel`（`codex-tcp-tunnel`），把回环地址上的 TCP 监听经 HTTP/3 CONNECT 代理转发到指定主机，令牌只从 stdin 读，仓库里没有别的代码调用它。

## 和《从 LLM 到 Coding Agent》对照

[工具系统的抽象设计](https://daiw.net/manual/llm-to-agent/tool-abstraction)把工具比作 agent 的“设备驱动”，工具直接调用操作系统。Codex 在驱动下面又垫了一层“总线”：工具只认 `Environment`，本机还是远端对它透明，换一台机器执行不用改任何工具代码。

这和 Grok Build 的做法方向相同、切分点不同。[Grok Build 的 workspace 抽象](https://daiw.net/manual/grok-build/workspace-abstraction)在远程时经 hub 把整个工具调用代理给远端的 workspace 服务器；Codex 只把最底层的原语——进程、文件、HTTP——搬过去，工具逻辑、审批与网络策略判断都留在控制端，执行端只负责在本机落实沙箱。

---

上一篇：[其它界面 · 登录引导、恢复选择、代理总览与用量面板](https://daiw.net/manual/codex-source/tui-surfaces) · 下一篇：[登录与认证 · ChatGPT、API key 与网关](https://daiw.net/manual/codex-source/login-and-auth)
