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

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

作者 David更新于 第 8 篇(共 57 篇)

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

前两篇默认客户端已经连上了 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里都有,这里只看实现。

四种入口,一条事件通道

--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 消息、连接关闭;守护进程的关停请求是第四种事件。

图表加载中…

事件通道容量是 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。监听非回环地址而不配置认证时,服务端直接拒绝启动:

    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:

/// 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 策略也专门加了一段:

    // 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 的逻辑:

    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 篇说的排空:不再接新轮次,等在跑的轮次结束。
  • 重启恢复:带 --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 一篇里,stdio、WebSocket、中继与 leader 四种传输汇入同一个 MvpAgent,leader 模式让多个客户端经 Unix socket 共享一个后端进程,与 Codex 的守护进程思路相同。OpenCode 的新 CLI 也会在后台拉起服务、每 50 毫秒探活,但遇到版本不一致就停掉旧服务重来;Codex 的守护进程有独立的安装包和更新进程,TUI 版本变了并不会替换它,只在两边的共享功能开关不一致时让你选择。


上一篇:v2 协议 · thread、turn 与 item · 下一篇:ThreadManager 与 CodexThread · 线程的生与死

本页目录