夜雨聆风学习资料网

ARTICLE · 1084492

Codex 源码-Protocol 协议层——SQ/EQ 双队列与权限模型

Codex 源码-Protocol 协议层——SQ/EQ 双队列与权限模型

Codex 源码解析系列

第 3 讲:Protocol 协议层——SQ/EQ 双队列与权限模型

基于 OpenAI Codex 源码 · 2026-09-27

💡 本讲一句话:Codex 的客户端和 agent 之间没有直接函数调用,全靠 protocol crate 定义的两条队列通信——请求收敛成 29 种 Op、响应展开成 83 种 EventMsg;沙箱策略、审批决策这些"安全语义"也长在协议层里。读完这一讲,你就拿到了后面所有讲的"词汇表"。

一、protocol crate:客户端与 agent 之间的"合同层"

第 2 讲我们看了 CLI 怎么把命令分发出去。但真正让 TUI、app-server、CLI 这些不同前端都能驱动同一个 agent 的,是中间这层"合同"——codex-protocol crate。它不执行任何逻辑,只回答一个问题:客户端能对 agent 说什么?agent 能向客户端报什么?

这个 crate 的规模本身就说明问题:protocol.rs 单文件 6335 行、models.rs 4419 行、permissions.rs 4468 行,lib.rs 里挂了 50 多个模块。它同时是 Rust 类型定义、JSON wire format(serde)、TypeScript 代码生成源(ts-rs)和 JSON Schema(schemars)——一份定义,四端消费。

📄 codex-rs/protocol/src/protocol.rs (第 1-4 行)

//! Defines the protocol for a Codex session between a client and an agent. // 定位:客户端与 agent 之间的会话协议,整个 crate 的总纲//! Uses a SQ (Submission Queue) / EQ (Event Queue) pattern to asynchronously communicate // 核心模式:SQ/EQ 双队列——请求走提交队列、响应走事件队列,异步解耦//! between user and agent. // 用户与 agent 之间不再直接函数调用,而是"发消息"

为什么这样设计:把协议独立成 crate,意味着 TUI、app-server(RPC 网关)、CLI exec 模式可以各自实现传输层,但共享同一套语义。SQ/EQ 这个命名直接来自 OpenAI 内部对 agent 会话的抽象:Submission Queue 是"用户说的话",Event Queue 是"agent 做的事"——两个方向完全解耦,agent 可以在处理一条提交时连续吐出几百个事件。

二、请求方向:Submission 与 Op 枚举(29 种变体)

客户端的每一次动作都被包进一个 Submission。注意它只有五个字段——协议层刻意保持"信封"极简,所有差异都塞进 op 载荷里:

📄 codex-rs/protocol/src/protocol.rs (第 190-205 行)

/// Submission Queue Entry - requests from user // SQE:提交队列条目,方向是"用户 → agent"#\[derive(Debug)] // 派生 Debug:提交对象可直接打印进日志,排障时看得到完整载荷pub struct Submission { // 信封结构体开始——刻意保持极简,差异全塞进 op 载荷里    /// Unique id for this Submission to correlate with Events // 唯一 ID(UUIDv7):后续事件靠它和这次请求对上号    pub id: String, // 字符串而非数字——跨进程传输不丢精度,日志里也直观    /// Payload // 操作载荷:Op 枚举的某个变体,"能做什么"全在这里表达    pub op: Op, // 不拆成多个字段——新增操作只需加变体,信封结构永远不变    /// Optional W3C trace carrier propagated across async submission handoffs. // W3C traceparent/tracestate:跨异步交接传播分布式追踪上下文    pub trace: Option<W3cTraceContext>, // 可空——本地 TUI 提交可以不带,core 会兜底补上    /// Core-provided ID of the parent turn that directly initiated this submission. // 直接发起本提交的父 turn(agent 间通信归因用)    pub parent_turn_id: Option<String>, // 普通用户提交没有父 turn,所以是 Option    /// Core-provided ID of the top-level turn that causally initiated this submission. // 因果链顶端的根 turn:多智能体场景回溯"最初是谁发起的"    pub root_turn_id: Option<String>,}

为什么这样设计:parent_turn_id/root_turn_id 这两个字段是第 37 讲多智能体系统的伏笔——子 agent 的提交要能回溯到"谁发起的、因果链顶端是谁"。而 trace 字段说明协议层原生支持 W3C Trace Context,一次用户请求跨 TUI→core→模型 API 的全链路追踪在信封里就带好了。

载荷 Op 是客户端能力的完整清单。数一下:29 个变体,从"发一轮输入"到"关机"全覆盖:

📄 codex-rs/protocol/src/protocol.rs (第 596-632 行,节选)

/// Submission operation // 提交操作:客户端能对 agent 做的所有事的封闭集合#\[derive(Debug)] // 派生 Debug:Op 变体可直接进日志#\[allow(clippy::large_enum_variant)] // 有的变体内嵌 oneshot channel,大小差异大——显式豁免该警告#\[non_exhaustive] // 非穷尽枚举:未来可加新变体而不破坏下游 match——给扩展留的钩子pub enum Op { // 客户端能力清单开始——29 个变体覆盖"能对 agent 做的所有事"    /// Abort current task without terminating background terminal processes. // 中断当前任务,但保留后台终端进程(用户可能还想看输出)    Interrupt, // 无载荷变体——"打断"这个动作本身不需要任何参数    ... // 中间还有 CleanBackgroundTerminals、6 个 RealtimeConversation* 变体,此处省略    /// Submit turn input using the requested routing behavior. // 提交一轮输入:整个协议里最核心的变体    TurnInput { // 带三个字段:请求体 + 路由模式 + 一次性回复通道        request: Box<TurnInputRequest>, // 请求体装箱——Op 是 large_enum_variant,Box 摊平内存占用        mode: TurnInputMode, // start-or-steer / idle-start / steer-only 三种路由行为之一        reply: oneshot::Sender<CodexResult<TurnInputSubmission>>, // 一次性回复通道:调用方只等"路由决策",不等整轮跑完    },}

为什么这样设计:TurnInput 里内嵌 oneshot::Sender 是最精妙的一笔——提交方只等"路由决策"(开新轮还是转向现有轮),不等整轮执行完。整轮的执行结果走 Event Queue 异步流出。这样 app-server 的 RPC 调用不会挂起几十秒,TUI 也能立刻拿到 turn id 开始渲染。

29 个变体按用途可以归成四类:

类别
代表变体
带 reply 通道?
轮次执行
TurnInput / RecoverTurn / SuspendTurnAndShutdown
是(oneshot)
审批应答
ExecApproval / PatchApproval / UserInputAnswer
否(事件驱动)
实时会话
RealtimeConversationStart / Audio / Text
否(流式事件)
会话管理
Compact / ThreadRollback / Shutdown
否(事件驱动)

注意"审批应答"类:用户点"允许执行"这个动作,在协议里也是一条 Submission(Op::ExecApproval),而不是某个回调函数。这保证了所有状态变更都走同一条有序队列——第 19 讲审批流会展开这一点。

三、响应方向:Event 与 EventMsg 事件流(83 种变体)

agent 侧的出口是 Event——信封同样极简:一个关联 id + 一个事件载荷:

📄 codex-rs/protocol/src/protocol.rs (第 1344-1351 行)

/// Event Queue Entry - events from agent // EQE:事件队列条目,方向是"agent → 用户"#\[derive(Debug, Clone, Deserialize, Serialize)] // Debug/Clone 供日志与回放;Serialize/Deserialize 让它能跨进程走 JSON wire formatpub struct Event { // 信封结构体开始——和 Submission 对称:id + 载荷,两个字段而已    /// Submission `id` that this event is correlated with. // 关联的提交 ID:事件流按请求归组的关键,多路复用时 UI 靠它配对    pub id: String, // 复用 Submission 的 id——同一个字符串贯穿"请求→响应"全链路    /// Payload // 事件载荷:EventMsg 枚举的某个变体(83 种)    pub msg: EventMsg,}

为什么这样设计:id 字段让客户端能把"我发的那条请求"和"之后涌出来的一串事件"精确配对。多路复用场景下(一个线程里同时有审批等待、实时音频流),没有这个 id,UI 根本不知道该把哪个输出挂到哪个交互上。

EventMsg 是 agent 能力的完整清单:83 个变体。看它的序列化声明和版本兼容处理:

📄 codex-rs/protocol/src/protocol.rs (第 1358-1423 行,节选)

/// Response event from the agent // agent 侧的全部响应事件——客户端 UI 渲染的唯一数据源/// NOTE: Make sure none of these values have optional types, as it will mess up the extension code-gen. // 硬约束:字段不许用 Option,否则扩展代码生成会坏#\[derive(Debug, Clone, Deserialize, Serialize, Display, JsonSchema, TS)] // 一份定义四端消费:Rust 类型 + JSON wire + JSON Schema + TypeScript 代码生成#\[serde(tag = "type", rename_all = "snake_case")] // 线上格式 {"type":"agent_message",...}:自描述 JSON,判别字段固定叫 type#\[ts(tag = "type")] // TS 代码生成同样按 type 字段判别——Rust/TS 双端一致pub enum EventMsg { // agent 能力清单开始——83 个变体是客户端 UI 渲染的唯一数据源    ... // 前面还有 Error / Warning / AuthRecovery* 等系统事件,此处省略    /// Agent has started a turn. // 一轮开始——UI 据此显示"思考中"状态    /// v1 wire format uses `task_started`; accept `turn_complete` for v2 interop. // v1 线上叫 task_started、v2 改名 turn_started——alias 让新旧客户端互通    #\[serde(rename = "task_started", alias = "turn_started")] // rename 定新名字、alias 认旧名字——双向兼容的写法    TurnStarted(TurnStartedEvent), // 携带结构化载荷:thread_id + turn_id,UI 据此建状态

为什么这样设计:tag = "type" 是典型的"自描述 JSON"——每个事件自带类型判别字段,客户端不需要维护版本化的 schema 注册表。而 rename + alias 这对组合拳解决了真实世界的痛点:v1 线上格式叫 task_started,v2 内部改名 turn_started,alias 让旧客户端读新事件、新客户端读旧 rollout 都不炸。协议层对"历史包袱"的容忍度,直接决定了产品能不能平滑升级。

83 个变体按用途归成八类(每类的代表事件):

轮次生命周期

代表事件 TurnStarted / TurnComplete / TurnAborted / TokenCount

典型用途 UI 状态机骨架:思考中 → 运行中 → 完成/中止,附带 token 用量

消息与推理流

代表事件 AgentMessage / UserMessage / AgentReasoning* / *ContentDelta

典型用途 聊天渲染:完整消息 + 流式增量(delta)双轨,打字机效果靠 delta

命令执行

代表事件 ExecCommandBegin / ExecCommandOutputDelta / ExecCommandEnd / TerminalInteraction

典型用途 shell 输出实时视图:开始 → 增量 stdout → 结束,含退出码

审批交互(human-in-the-loop)

代表事件 ExecApprovalRequest / ApplyPatchApprovalRequest / RequestPermissions / ElicitationRequest

典型用途 agent 暂停等用户拍板;用户的回答再以 Op::ExecApproval 等变体回流

补丁与 diff

代表事件 PatchApplyBegin / PatchApplyUpdated / PatchApplyEnd / TurnDiff

典型用途 代码变更进度条 + 本轮 diff 汇总,前端可实时展示"改了哪些文件"

MCP 与扩展工具

代表事件 McpStartup* / McpToolCallBegin-End / WebSearch* / ImageGeneration* / DynamicToolCall*

典型用途 外部能力生命周期:启动进度、工具调用起止、搜索/生图等扩展动作

多智能体协作

代表事件 CollabAgentSpawnBegin-End / CollabInteraction* / SubAgentActivity

典型用途 子 agent 的出生、交互、等待、关闭全过程可视化(第 37 讲展开)

系统状态与审计

代表事件 Error / Warning / AuthRecovery* / ContextCompacted / ThreadRolledBack / HookStarted-Completed

典型用途 健康度、认证恢复、压缩/回滚标记、钩子执行记录——审计与排障数据源

四、权限模型:沙箱策略定义在协议层

一个反直觉的设计:沙箱策略不是 core 的实现细节,而是协议的一部分。因为"允许 agent 写哪些目录、能不能联网"必须跨进程传递——TUI 要展示它、app-server 要转发它、sandboxing crate 要执行它。顶层枚举 SandboxPolicy:

📄 codex-rs/protocol/src/protocol.rs (第 1074-1126 行,节选)

/// Determines execution restrictions for model shell commands. // 决定模型 shell 命令的执行限制——沙箱策略的顶层枚举#\[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Display, JsonSchema, TS)] // 策略要跨进程传递:TUI 展示、app-server 转发、sandboxing 执行,所以必须可序列化+可比对#\[serde(tag = "type", rename_all = "kebab-case")] // 线上按 type 判别,值用 kebab-case(如 workspace-write)pub enum SandboxPolicy { // 沙箱策略顶层枚举开始——定义在协议层而非实现层    /// No restrictions whatsoever. Use with caution. // 完全放开——名字里就写着 danger,默认绝不是它    #\[serde(rename = "danger-full-access")] // 线上值用 kebab-case:配置里写 sandbox_mode = "danger-full-access"    DangerFullAccess, // 完全放开模式——名字自带 danger,默认绝不是它    ... // ReadOnly / ExternalSandbox 两个变体此处省略(见下方对照表)    /// Same as `ReadOnly` but additionally grants write access to the current working directory ("workspace"). // 只读 + cwd 可写:最常用的"工作区模式"    #\[serde(rename = "workspace-write")] // 线上值 workspace-write:最常用的"工作区模式"    WorkspaceWrite { // 带参数的变体:在只读基线上叠加可写根与网络开关        writable_roots: Vec<AbsolutePathBuf>, // 额外可写目录(cwd/TMPDIR 之外)        network_access: bool, // 是否放行出站网络,默认 false——沙箱内默认断网        exclude_tmpdir_env_var: bool, // true 则不把用户 TMPDIR 算进默认可写根        exclude_slash_tmp: bool, // true 则不把 /tmp 算进默认可写根(UNIX)    },}
模式
文件系统权限
网络默认
danger-full-access
完全读写,无任何限制
按配置
read-only
全盘只读
默认受限
workspace-write
cwd + TMPDIR 可写;.git/.agents/.codex 仍只读
默认受限
external-sandbox
由外部调用方负责隔离(如容器)
可配置

为什么这样设计:"workspace-write"是安全与可用的平衡点——agent 能干活(写工作区),但默认断网、且 .git/hooks 这类"写了就能提权"的目录被单独保护。这个保护逻辑就写在协议层的 WritableRoot::is_path_writable:

📄 codex-rs/protocol/src/protocol.rs (第 1146-1165 行)

impl WritableRoot { // 可写根的路径判定逻辑——沙箱"能不能写这里"的最终裁决    pub fn is_path_writable(&self, path: &Path) -> bool { // 判定某路径是否可写:三道闸全过才放行        // Check if the path is under the root. // 第一道闸:路径必须落在可写根之内,否则直接拒绝        if !path.starts_with(&self.root) { // 前缀匹配:路径必须落在可写根之内            return false; // 不在根内 → 不可写        } // 第一道闸通过,进入第二道闸        // Check if the path is under any of the read-only subpaths. // 第二道闸:根内还有只读子路径(如 .git/hooks),命中即拒        for subpath in &self.read_only_subpaths { // 逐个检查保护子路径            if path.starts_with(subpath) { // 命中某条只读子路径                return false; // 落在保护子路径内 → 拒绝写入            } // 这条子路径没命中,继续下一条        } // 所有只读子路径都没命中,进入第三道闸        if self.path_contains_protected_metadata_name(path) { // 第三道闸:.git/.agents/.codex 等元数据目录默认禁写——防 agent 自我提权            return false; // 路径里含受保护的元数据目录名 → 拒绝        } // 第三道闸通过——三道闸全过        true // 三道闸全过才放行    }}

为什么这样设计:注释里写得很直白:保护的是"files that could be modified to escalate the privileges of the agent"——.git/hooks、.codex(配置/钩子)、.agents。agent 能改你的代码,但不能改自己的"权限来源"。这是最小权限原则在路径粒度上的落地。

更细粒度的规则在 permissions.rs:deny 优先的冲突裁决——同一路径同时有 read/write/deny 三条规则时,按"否决优先"而不是能力大小排序:

📄 codex-rs/protocol/src/permissions.rs (第 92-130 行,节选)

/// Access mode for a filesystem entry. // 文件系统条目的访问模式——细粒度权限规则的基本单位/// When two equally specific entries target the same path, we compare these by conflict precedence rather than by capability breadth: `deny` beats `write`, and `write` beats `read`. // 冲突裁决:同一路径多条规则时 deny > write > read——按"否决优先"而不是能力大小pub enum FileSystemAccessMode { // 访问模式枚举开始:Read/Write/Deny 三态    Read, // 只读    Write, // 可写(隐含可读)    /// `none` is a legacy input alias retained temporarily for compatibility. // none 是旧配置里的别名,保留兼容    #\[serde(alias = "none")] // 旧配置写 none 也能解析成 Deny——兼容历史写法    Deny, // 显式拒绝——排序上最高,冲突时一票否决} // 枚举结束:三态覆盖所有访问语义impl FileSystemAccessMode { // 访问模式的判定方法——能力查询的入口    pub fn can_read(self) -> bool { // 能否读:Read/Write 都可读,只有 Deny 不行        !matches!(self, FileSystemAccessMode::Deny) // 能读 = 不是 Deny(取反判定最简洁)    } // can_read 结束    pub fn can_write(self) -> bool { // 能否写:只有 Write 可以        matches!(self, FileSystemAccessMode::Write) // 能写 = 恰好是 Write(Read/Deny 都不行)    }}

为什么这样设计:"deny beats write"是安全系统的标准姿势——白名单可以叠加,但黑名单必须一票否决。否则用户写了 /etc: deny 又不小心给了 /: write,细粒度规则就被粗粒度规则吞掉了。permissions.rs 里还有配套的 ReadDenyMatcher:对非法 glob 模式采取 fail-closed(匹配失败=拒绝),宁可误杀不可漏放。

五、core 如何消费协议:submission_loop 分发器

协议定义"能说什么",core 的 session 模块定义"怎么执行"。两条队列在 Session::new 里创建——注意容量选择的不对称:

📄 codex-rs/core/src/session/mod.rs (第 494、574-575、914-923 行,节选)

pub(crate) const SUBMISSION_CHANNEL_CAPACITY: usize = 512; // SQ 容量 512:有界队列,客户端狂发时在这里背压而不是无限堆积内存let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); // 提交通道用 bounded——写满会阻塞发送方,天然限流let (tx_event, rx_event) = async_channel::unbounded(); // 事件通道用 unbounded——agent 输出不能被反压卡住pub(crate) async fn submit_with_id(&self, mut sub: Submission) -> CodexResult<()> { // 提交入口:把一条 Submission 送进 SQ    if sub.trace.is_none() { // 检查调用方是否带了 trace 上下文        sub.trace = current_span_w3c_trace_context(); // 调用方没带 trace 就补上当前 span——保证分布式链路追踪不断    } // trace 兜底完成,信封字段齐了    self.tx_sub.send(sub).await.map_err(|_| CodexErr::InternalAgentDied)?; // 入队失败(agent 已死)直接报 InternalAgentDied,绝不静默吞掉    Ok(()) // 入队成功即返回——注意这里不等执行结果,结果走事件流}

为什么这样设计:SQ 有界、EQ 无界,方向完全相反——这是刻意的。用户输入是"可拒绝的"(限流合理),agent 输出是"不可丢弃的"(UI 依赖它渲染状态)。如果 EQ 也有界且客户端消费慢,agent 会被自己的输出卡死,形成死锁式停顿。

队列的消费端是 submission_loop——一个 while-recv-match 的朴素循环,29 个 Op 变体在这里逐一落地:

📄 codex-rs/core/src/session/handlers.rs (第 538-604 行,节选)

pub(super) async fn submission_loop(sess: Arc<Session>, config: Arc<Config>, rx_sub: Receiver<Submission>) { // SQ 消费主循环:协议里"能做什么"在这里落地成"怎么做"    // To break out of this loop, send Op::Shutdown. // 退出协议:收到 Shutdown op 才正常收尾,否则一直循环    let mut shutdown_received = false; // 标记是否收到显式 Shutdown——决定退出时走哪条清理路径    while let Ok(sub) = rx_sub.recv().await { // 从 SQ 取一条提交;通道关闭(客户端断开)时 recv 返回 Err,循环自然结束        ... // 每条提交先打 debug 日志、建 dispatch span(挂 W3C trace),再进入分发        let should_exit = async { // 每条提交的处理体:返回 true 表示该退出循环(仅 Shutdown)            match sub.op { // 29 个变体逐一分发——协议层定义"能做什么",这里定义"怎么做"                Op::Interrupt => { interrupt(&sess).await; false } // 中断当前任务;false = 循环继续跑下一条                ... // RealtimeConversation*、RecoverTurn、SuspendTurnAndShutdown 等分支同理,此处省略                Op::TurnInput { request, mode, reply } => { // 核心分支:解出请求、路由模式与一次性回复通道                    let result = turn_input::handle(&sess, *request, mode, sub.id.clone()).await; // 核心入口:只做路由决策(开新轮 or 转向现有轮)                    let _ = reply.send(result); // 把结果塞回 oneshot——发送方只等这一步,不等整轮执行完                    false // TurnInput 处理完不退出循环——继续消费下一条提交                }}

为什么这样设计:单循环串行分发是刻意的——所有状态变更(开轮、审批、回滚)共享一个顺序,天然免锁。代价是慢操作会阻塞后续提交,但 Codex 的解法是"重活 spawn 出去":turn_input::handle 只做路由决策就返回,真正的 turn 执行在独立 task 里跑。循环本身永远轻快。

事件出口侧同样讲究顺序——send_event_raw_with_persistence 把"落盘"放在"投递"之前:

📄 codex-rs/core/src/session/mod.rs (第 2517-2571 行,节选)

async fn send_event_raw_with_persistence(&self, event: Event, persist: bool) { // 事件出口:按"归约→落盘→投递"的固定顺序处理    // Keep realtime reduction, canonical append, and delivery in the same order. // 顺序契约:realtime 归约、落盘、投递必须同序,否则回放会乱    ... // 先让 realtime_history / mcp_runtime 旁路观察事件(不阻塞主路径)    if persist { // 需要持久化时(默认 true;部分内部事件可跳过落盘)        let rollout_items = vec![RolloutItem::EventMsg(event.msg.clone())]; // 事件转成 RolloutItem——会话持久化的最小单元        self.persist_rollout_items(&rollout_items).await; // 写入 rollout 存储(JSONL/SQLite),崩溃后可恢复现场    } // 落盘完成——客户端看到的事件在磁盘上一定有    self.services.rollout_thread_trace.record_protocol_event(&event.msg); // 同步记一条 trace,供审计与回放分析    ... // realtime after-event effects 在此落盘    self.deliver_event_raw(event).await; // 最后才投递给客户端——先落盘后发送:发出去的事件一定已持久化}async fn deliver_event_raw(&self, event: Event) { // 投递函数开始:更新状态 + 发通道,两步都不允许阻塞 agent    if let Some(status) = agent_status_from_event(&event.msg) { // 部分事件隐含状态变化(如 TurnComplete → idle)        self.agent_status.send_replace(status); // 顺手更新"agent 当前状态"(idle/running),供 UI 轮询    } // 状态同步完成    if let Err(e) = self.tx_event.send(event).await { // 发送失败只有一种情况:客户端通道已关闭        debug!("dropping event because channel is closed: {e}"); // 事件丢弃只记日志,不反压 agent    }}

为什么这样设计:"先落盘后投递"意味着客户端看到的事件在磁盘上一定有——崩溃恢复(resume)时回放 rollout,UI 状态不会比服务端少。反过来,客户端断开只丢内存里的通道发送,不影响持久化完整性。这是事件溯源(event sourcing)架构的典型取舍。

六、数据流图:一条用户消息穿过协议层

请求路径(Submission Queue)

① 封装:客户端构造 Submission

id(UUIDv7)+ op: TurnInput{request, mode, reply},信封极简、差异全在载荷。

▼

② 入队:tx_sub.send → bounded(512)

有界队列背压限流;缺 trace 自动补当前 span,链路追踪不断。

▼

③ 分发:submission_loop recv → match sub.op

单循环串行处理 29 个变体,共享顺序天然免锁;每条提交挂 dispatch span。

▼

④ 路由:turn_input::handle → reply.send(result)

只等"开新轮 or 转向现有轮"的决策;oneshot 回传后调用方立即返回。

响应路径(Event Queue)

⑤ 执行:turn task 跑模型/工具,逐步产出 EventMsg

83 种事件按需流出:AgentMessage、ExecCommand*、审批请求等。

▼

⑥ 落盘+投递:persist_rollout_items → tx_event(unbounded)

先写 rollout 再发客户端;客户端 next_event() 消费,UI 按 id 归组渲染。

七、本讲小结:protocol crate 关键数字

文件
规模
职责
protocol/src/protocol.rs
6335 行
Op(29)/ EventMsg(83)/ SandboxPolicy——协议主体
protocol/src/models.rs
4419 行
ResponseItem 消息模型 + PermissionProfile 权限档案
protocol/src/permissions.rs
4468 行
文件系统沙箱策略匹配,deny 优先 + fail-closed
core/src/session/handlers.rs
807 行
submission_loop——Op 的分发落地点

带走三句话:🔹 Codex 的客户端与 agent 之间只有两条队列:SQ(有界、背压)进 Op,EQ(无界、不丢)出 EventMsg;🔹 安全语义(沙箱模式、deny 优先、.git/.codex 保护)定义在协议层而非实现层,才能跨 TUI/app-server/sandboxing 三端一致传递;🔹 "先落盘后投递"让事件流成为可回放的审计日志——这是后面 rollout、resume、多智能体归因的共同地基。

下一讲进入 Config 配置系统:这些协议字段(sandbox_mode、approval_policy)是怎么从 TOML 文件一路解析成 SandboxPolicy/AskForApproval 枚举的,以及 feature flags 如何在不发版的情况下开关能力。

📚 系列导航

← 第 2 讲:CLI 入口与命令分发

→ 第 4 讲:Config 配置系统 + Feature Flags

关注公众号「AI技术推荐官」获取更多源码解析内容

相关学习资料