exec-server · 在另一台机器上执行
Codex 把“想”和“做”拆开:模型调用、上下文与审批留在 app-server 一侧,起进程、读写文件、发 HTTP 请求经 Environment 抽象落到本机或远端的 exec-server。本篇讲 Environment 三件套、exec-server 的 JSON-RPC 协议与断线续接、注册表加 Noise 加密中继的远程模式,以及路径与沙箱怎么跨操作系统。
exec-server · 在另一台机器上执行
前面几部分默认了一件事:命令就在你这台电脑上跑。执行命令起进程、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 和环境变量,下面只看实现。
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 填这三个槽;远端环境则让三者共用同一个客户端:
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)。
协议:进程、文件与 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 不是本机路径,沙箱也只是“意图”:
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 包了一层:
#[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 模式一个进程只服务一条连接,断了就收尾,谈不上续接。
crate 自带的 README 有几处落后于代码:它说连接关闭即终止该连接的进程,实际有上面 30 秒的保留期;它的 process/write 示例没有 writeId,而 v0.158.0 的 WriteParams 里这是必填字段,写未知进程或未开 stdin 的进程时返回的也是 WriteStatus 里的 UnknownProcess、StdinClosed 状态,而不是 JSON-RPC 错误。以代码为准。
远程模式:注册表与 Noise 中继
直连 WebSocket 适合同一内网。跨网络时,执行端往往在 NAT 后面、没有公网端口,于是有了 --remote:执行端主动连出去,双方在中继(代码里叫 rendezvous)碰头。代码把发起方(跑 app-server 的一侧)叫 harness,执行端叫 executor;注册表负责发放地址和授权。
中继只看得到明文的路由信息:外层是 protobuf 的 RelayMessageFrame(stream_id、序号、确认位图、handshake/data/resume/reset 等帧体),载荷是加密记录。一条执行端 WebSocket 上可以复用多条虚拟流,每条 stream_id 都当作一条独立的 JSON-RPC 连接来处理(run_registered_connection),同时最多 128 条;同一条物理连接上握手失败累计 8 次,执行端就暂停接受新握手 10 秒,已建立的流不受影响。分段、确认、重传、去重都由两端自己做,中继只转发。加密层写在模块注释里:
//! 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》对照
工具系统的抽象设计把工具比作 agent 的“设备驱动”,工具直接调用操作系统。Codex 在驱动下面又垫了一层“总线”:工具只认 Environment,本机还是远端对它透明,换一台机器执行不用改任何工具代码。
这和 Grok Build 的做法方向相同、切分点不同。Grok Build 的 workspace 抽象在远程时经 hub 把整个工具调用代理给远端的 workspace 服务器;Codex 只把最底层的原语——进程、文件、HTTP——搬过去,工具逻辑、审批与网络策略判断都留在控制端,执行端只负责在本机落实沙箱。
上一篇:其它界面 · 登录引导、恢复选择、代理总览与用量面板 · 下一篇:登录与认证 · ChatGPT、API key 与网关