# 守护进程与传输 · 一个服务端，多种连法

> 同一个 app-server 可以经 stdio、Unix socket、TCP WebSocket 接客户端，还能主动连到 ChatGPT 的中转服务接受远程控制，四种入口最后都变成同一种 TransportEvent。codex-app-server-daemon 把它托管成后台进程：pid 记录、生命周期锁、独立安装包、自动更新与重启后恢复线程；在 Unix 上，控制 socket 放在沙箱会屏蔽的固定目录里。

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

# 守护进程与传输 · 一个服务端，多种连法

前两篇默认客户端已经连上了 app-server。这一篇看“连上”本身：`codex-app-server-transport` 提供各种入口，`codex-app-server-daemon` 把 app-server 托管成后台进程，`codex-uds` 与 `codex-stdio-to-uds` 补上跨平台的 Unix socket 和 stdio 转接。

## 用户看到的样子

`codex app-server --listen <URL>` 决定服务端怎么监听；`codex app-server daemon start|restart|stop|version|update|bootstrap` 管理一个共享的后台服务，每条命令都向 stdout 输出一个 JSON 对象；`codex remote-control` 开启远程控制；`codex app-server proxy` 把 stdio 接到本机守护进程的 socket 上。各参数、`settings.json` 的字段与自动更新的行为，手册[SDK 与 App Server](https://daiw.net/manual/codex/sdk-app-server)里都有，这里只看实现。

## 四种入口，一条事件通道

`--listen` 由 `AppServerTransport::from_listen_url`（`codex-rs/app-server-transport/src/transport/mod.rs:122`）解析：`stdio://`（默认）、`unix://` 或 `unix://PATH`、`ws://IP:PORT`、`off`。远程控制不是 `--listen` 的取值，而是在这之外另起的一条出站连接。不管从哪个入口进来，传输层都只向处理循环报三件事：连接打开（附带这条连接的写队列）、收到一条 JSON-RPC 消息、连接关闭；守护进程的关停请求是第四种事件。

```mermaid
flowchart LR
  S[stdio<br/>每行一条 JSON] --> Q[TransportEvent 通道<br/>容量 128]
  U[Unix socket<br/>socket 上跑 WebSocket] --> Q
  W[TCP WebSocket<br/>需要认证才可对外] --> Q
  R[remote control<br/>主动连 ChatGPT 中转] --> Q
  Q --> P[处理循环<br/>MessageProcessor]
  D[daemon start] -->|拉起一个监听 unix 的 app-server| U
  T[TUI、codex agents] -->|RemoteAppServerClient| U
```

事件通道容量是 128（`CHANNEL_CAPACITY`）。通道满时，请求不会排队干等，传输层直接回错误码 `-32001`、消息 `Server overloaded; retry later.`，其它消息则等待空位。连接的来源记在 `ConnectionOrigin` 里，只有 `Stdio`、`InProcess`、`WebSocket`、`RemoteControl` 四种：Unix socket 上跑的也是 WebSocket，所以归入 `WebSocket`。

**stdio**：一行一条消息，全程只有一个连接，连接一断进程就收尾。读 stdin、写 stdout 各用一个独立线程，注释解释说这样被阻塞的管道不会拖住 Tokio 运行时；Unix 上另有一个线程专门等 SIGTERM，收到后启动关停看门狗。

**TCP WebSocket**：基于 axum，除了升级成 WebSocket，只提供 `/readyz`、`/healthz` 两个健康检查。凡是带 `Origin` 请求头的请求一律 403，浏览器里的网页因此连不上本机的 app-server；认证方式由 `--ws-auth` 选择 `capability-token` 或 `signed-bearer-token`。监听非回环地址而不配置认证时，服务端直接拒绝启动：

```rust
    if is_unauthenticated_non_loopback_listener(bind_address, &auth_policy) {
        return Err(std::io::Error::new(
            std::io::ErrorKind::InvalidInput,
            format!(
                "refusing to start non-loopback websocket listener {bind_address} without auth; configure `--ws-auth capability-token` or `--ws-auth signed-bearer-token`"
            ),
        ));
    }
```

（`codex-rs/app-server-transport/src/transport/websocket.rs:135`）

**Unix socket**：`unix://` 的默认路径是 `CODEX_HOME/app-server-control/app-server-control.sock`。在 Unix 上这只是个“会合点”符号链接，真正的 socket 放在一个固定目录里，文件名是会合点规范路径的 SHA-256：

```rust
/// Returns the fixed executor-local directory that every sandbox must hide.
pub fn shared_daemon_socket_directory() -> io::Result<PathBuf> {
    // Resolve the system alias /tmp -> /private/tmp on macOS.
    let temporary_root = fs::canonicalize("/tmp")?;
    let uid = unsafe { libc::geteuid() };
    Ok(temporary_root.join(format!("codex-daemon-{uid}")))
}
```

（`codex-rs/uds/src/daemon_directory.rs:12`）

这个目录不依赖 `HOME`、`TMPDIR` 或 `CODEX_HOME`，权限必须是 0700、属于当前用户，socket 本身是 0600。固定下来是为了让沙箱在守护进程启动之前就能把它藏起来：Linux 的 bubblewrap 参数里会屏蔽这个目录，macOS 的 Seatbelt 策略也专门加了一段：

```rust
    // Network grants and Unix-socket allowlists must never reopen the
    // privileged app-server RPC transport to filesystem-restricted commands.
    if !file_system_sandbox_policy.has_full_disk_write_access() {
        let directory = codex_uds::shared_daemon_socket_directory()
            .map_err(|error| SeatbeltPreparationError::FileSystem(error.to_string()))?;
        policy_sections.push(daemon::protection_policy(&directory)?);
    }
```

（`codex-rs/sandboxing/src/seatbelt.rs:1056`）

道理很直接：app-server 的 RPC 能开线程、批审批、改配置，模型在沙箱里跑的命令要是能连上它，沙箱就形同虚设。控制 socket 上的连接先做 WebSocket 握手（响应头里通告单帧消息的上限），再走和 TCP WebSocket 相同的连接处理；握手路径是 `/daemon/shutdown` 时则是托管方发来的关停请求，只有受托管的进程才接受，Windows 上的守护进程靠它代替 SIGTERM。

**remote control**：app-server 主动连到 ChatGPT 后端的 `wham/remote/control/server`，先调同一前缀下的 enroll、refresh 接口登记这台机器，配对用 pair 接口；后端地址只接受 `chatgpt.com`、`chatgpt-staging.com` 及其子域，或本机地址。多个远程客户端复用这一条 WebSocket：每条消息套一层带 `client_id`、`stream_id`、`seq_id` 的信封，大消息切成 base64 分片，后端按 `seq_id` 回确认，重连时只重发还没确认的部分。某个客户端发来 `initialize`，传输层就为它开一个来源为 `RemoteControl` 的虚拟连接，之后与本地连接无异。远程控制还要求 SQLite 状态库可用，否则启动时记一条错误日志，不启用它。

## 守护进程：把 app-server 托管到后台

`codex-app-server-daemon` 的状态都在 `CODEX_HOME/app-server-daemon/` 下：`settings.json`、pid 文件、生命周期锁 `daemon.lock`，以及重启恢复用的 `loaded-threads.json`。所有会改变状态的命令先拿这把锁，同一个 `CODEX_HOME` 上的 `start`、`stop`、`restart` 不会互相踩踏。`start` 的逻辑：

```rust
    async fn start(&self, feature_overrides: &BTreeMap<String, bool>) -> Result<LifecycleOutput> {
        let mut managed = self.clone();
        let mut settings = self.load_settings().await?;
        let (status, backend, pid, info) = if let Ok(info) = client::probe(&self.socket_path).await
        {
            (
                LifecycleStatus::AlreadyRunning,
                self.running_backend(&settings).await?,
                None,
                info,
            )
        } else if self.running_backend_instance(&settings).await?.is_some() {
// ...
        } else {
            // A fresh start must ignore snapshots left by older stop clients.
            if let Err(err) = thread_recovery::discard_pending(self) {
// ...
            prepare_install::prepare(self, &settings).await?;
// ...
            let pid = managed.start_managed_backend(&settings).await?;
```

（`codex-rs/app-server-daemon/src/lib.rs:405`）

- **探活**就是一次真正的 `initialize` 握手：经 Unix socket 做 WebSocket 升级，以客户端名 `codex_app_server_daemon` 初始化，2 秒超时，从返回的 `userAgent` 解析出服务端版本。能握手即视为已在运行，所以 `start` 是幂等的。
- **拉起**的命令行是 `codex app-server --listen unix://`，开了远程控制时加 `--remote-control`，另把共享的功能开关逐个写成 `-c features.<名字>=<值>`；受托管的二进制支持时再加隐藏参数 `--managed-daemon`。子进程的 stdin、stdout 接空设备，stderr 写日志文件；Unix 上先 `setsid()` 脱离终端会话，Windows 上用 `DETACHED_PROCESS` 与 `CREATE_BREAKAWAY_FROM_JOB`。pid 文件以 `create_new` 预留，记录里除了 pid 还有进程启动时间与可执行文件身份，防止 pid 被别的进程复用时误判。拉起之后每 50 毫秒探活一次，10 秒内不就绪就带上 stderr 日志的末尾报错。
- **停止**先发 SIGTERM（Windows 走上面的 `/daemon/shutdown`），等 `shutdownGraceSeconds`（默认 60，上限 300）后 SIGKILL。app-server 这一侧，收到信号就进入[第 6 篇](https://daiw.net/manual/codex-source/app-server-architecture)说的排空：不再接新轮次，等在跑的轮次结束。
- **重启恢复**：带 `--managed-daemon` 的进程在关停排空时，挑出已加载的、非临时的根线程（子代理与内部线程不算），确认能落盘后把它们的 id 连同被打断的轮次写进 `loaded-threads.json`；下次托管启动时先读走并删除这个文件，再在后台按普通的冷恢复流程逐个 `thread/resume`。显式的 `stop` 与全新的 `start` 都会先丢弃这个文件，所以只有“重启”才恢复。

**安装包与更新**：新装的守护进程用自己的安装包 `CODEX_HOME/packages/app-server-daemon/current/bin/codex`，不跟随你手上 `codex` 命令的版本；此前启动过的旧守护进程继续用 `packages/standalone/current`。pid 文件名也随之区分：独立安装包用 `daemon.pid`、`daemon-updater.pid`，只有旧布局才用 `app-server.pid`、`app-server-updater.pid`（守护进程的 README 只列了后一组旧名字）。满足条件时，`start` 还会拉起一个脱离的更新进程 `codex app-server daemon pid-update-loop`：首次检查在 5 分钟后（`INITIAL_UPDATE_DELAY`），之后按 `updateIntervalMinutes`（默认 60）重复，运行官方安装脚本；发现受托管的二进制变了，先用新二进制重启 app-server，再替换自己。

## TUI 怎么决定连谁

功能开关 `daemon_auto_start`（`codex-rs/features/src/lib.rs:935`，阶段 Stable，默认开启）打开时，交互式 TUI 启动先调 `codex_app_server_daemon::start_with_features`，再以 `RemoteAppServerClient` 经 Unix socket 连上去，连不上就报错，不悄悄退回内嵌。`daemon_startup::exclusion`（`codex-rs/tui/src/daemon_startup.rs:25`）列出改走内嵌服务的情况：`--no-daemon`、`--oss`、workload identity、设置了 `CODEX_EXEC_SERVER_URL`、`--profile`、除少数 `features.*` 之外的 `-c` 覆盖、自定义配置加载器、`--strict-config`、`--dangerously-bypass-hook-trust`。守护进程是多个客户端共享的，这些参数只属于这一次调用；`app_server_target_for_launch` 的注释就写着，共享的守护进程无法采纳这次调用自己的执行器选择。

几个影响共享服务的功能开关（`api_key_model_discovery`、`code_mode_host`、`auth_elicitation`、`mcp_oauth_refresh_coordination`）要与正在运行的守护进程一致，TUI 会先经 RPC 读取对方的实际取值来比对。关掉 `daemon_auto_start` 时，TUI 只花 50 毫秒试连默认 socket，连得上才用，失败则退回内嵌。

`codex app-server proxy` 与隐藏命令 `codex stdio-to-uds` 共用 `codex-stdio-to-uds` 里的同一个函数：把 stdin 原样写进 socket、把 socket 原样写到 stdout，一个字节都不解析。它的 README 以“让 MCP 服务器走 Unix socket”为例说明用途；接到 app-server 的控制 socket 上时，socket 那头讲 WebSocket，所以经它连接的客户端要自己完成 WebSocket 握手。Windows 的 Rust 标准库没有 Unix socket，`codex-uds` 在那里改用 `uds_windows` 实现，并把 socket 目录限制为仅当前用户可访问。

## 和其它 agent 对照

[Grok Build 的 ACP](https://daiw.net/manual/grok-build/acp) 一篇里，stdio、WebSocket、中继与 leader 四种传输汇入同一个 `MvpAgent`，leader 模式让多个客户端经 Unix socket 共享一个后端进程，与 Codex 的守护进程思路相同。[OpenCode 的新 CLI](https://daiw.net/manual/opencode/cli-daemon) 也会在后台拉起服务、每 50 毫秒探活，但遇到版本不一致就停掉旧服务重来；Codex 的守护进程有独立的安装包和更新进程，TUI 版本变了并不会替换它，只在两边的共享功能开关不一致时让你选择。

---

上一篇：[v2 协议 · thread、turn 与 item](https://daiw.net/manual/codex-source/app-server-protocol) · 下一篇：[ThreadManager 与 CodexThread · 线程的生与死](https://daiw.net/manual/codex-source/thread-manager)
