Skip to content

Codex CLI 模型协议与 multi-agent 参数加密研究

摘要

本报告分析 Codex CLI 0.146.0 在六个模型、多个 multi-agent 配置下的 真实 HTTPS/SSE 通信、rollout 会话记录及对应 Rust 源码。核心结论如下。

  1. Codex CLI 对所有本次模型都调用 POST /v1/responses,服务端通过 Server-Sent Events(SSE)返回类型化事件。模型差异不是 Chat Completions 与 Responses 的切换,而是同一 endpoint 下的“常规 Responses”和“Responses Lite”两种序列化布局。
  2. 0.146.0 的内置模型目录指定 Sol、Terra 为 MultiAgent V2,Luna 为 V1;5.5、5.4、5.2 未指定版本,交给 feature fallback。
  3. multi_agent_v2=false 不等于强制 V1。它只表示“不强制 V2”; 之后模型目录仍可选择 V2。因此当前新会话中 Sol 和 Terra 都加密, Luna/5.5/5.4 为 V1 明文。
  4. multi_agent_v2=true 是强制 V2 覆盖;实测可把 Luna 和 5.4 从 V1 切换到 V2。
  5. features.multi_agent=false 也不是全局强制禁用。它只让没有模型目录 版本的模型回退为 disabled;Sol 仍可由模型目录启用 V2。真正的全局 禁用是 [agents] enabled=false
  6. V2 只对 spawn_agent.messagesend_message.messagefollowup_task.message 标记应用层字段加密。工具名、task name、 sender/recipient、fork 参数、模型覆盖、流量长度以及父/子 thread metadata 仍是明文。
  7. 客户端没有实现该消息的加密或解密。它在 tool schema 中发送 "encrypted": true,Responses 服务返回 opaque 密文,客户端原样 封装为子会话的 encrypted_content 再发给服务端。这不是“本地 agent 之间的端到端加密”,而是服务端可解释、客户端不可解释的 opaque envelope。
  8. 实测密文严格符合 Fernet token 布局,但源码没有披露算法与密钥; 因此 Fernet 只能标为强格式推断,不能标为官方实现事实。
  9. 服务端判断“这个 tool argument 需要加密”的直接线上标记不是 multi_agent_v2,而是 tool parameter JSON Schema 中的 parameters.properties.message.encrypted: truemulti_agent_v2 仅在客户端本地决定是否生成带该标记的 V2 tool schema。
  10. 0.146.0 没有 force_v1=true 之类的单字段开关,但可以用 model_catalog_json 提供一个把 Sol 标为 V1 的完整模型目录。实测这会让 全新的 Sol 根会话和子会话均使用 V1 明文。恢复既有 V1 thread 再 切换到 Sol 也有效,但会继承旧历史,隔离性较差。

完整脱敏实验矩阵见 ../experiments/2026-07-30-model-multi-agent-matrix.md

1. 范围与证据等级

1.1 研究对象

  • Codex CLI:0.146.0
  • 精确 tag:rust-v0.146.0
  • 精确 commit:e363b08c9175ac1cbe5893615dd2cb9ddf95043b
  • 当前 main 对照 commit:6219b7c40fc9c702c0aef9964e72b492558f60e4
  • 模型:gpt-5.6-solgpt-5.6-terragpt-5.6-lunagpt-5.5gpt-5.4gpt-5.2
  • Provider:https://ai.feei.cn/v1wire_api = "responses"
  • 初始配置:
toml
[features]
multi_agent = true
multi_agent_v2 = false

1.2 证据等级

  • 强证据:Burp 中的实际请求/响应、对应 rollout JSONL、0.146.0 精确源码三者相互印证。
  • 中等证据:精确源码与 rollout 有效状态一致,但因上游错误未取得 完整成功响应,例如 5.2 的 V1 选择。
  • 推断:仅由二进制格式或当前行为推导,源码/公开文档未正式披露, 例如 Fernet 的具体服务端实现。

1.3 限制

本次请求经过第三方自定义 provider,而不是直接请求 api.openai.com。这不影响“客户端生成了什么 HTTP/JSON”这一观察, 但服务可用性、模型目录的远端覆盖以及响应内容可能受 provider 影响。 5.2 连续返回 503,是本次无法完成该模型 tool-call 实证的直接原因。

Burp 已安装受信任 CA,因此本文所称“明文”是指 TLS 解密后 JSON 中仍为 可读文本;“密文”是指 TLS 内部还存在一层应用字段密文。未配置 MITM 时, 无论 V1/V2,公网链路都仍受 TLS 保护。

2. 总体通信架构

text
用户提示 / 历史 / AGENTS.md / tools
              |
              v
      TurnContext + ModelInfo
              |
              +-- 选择 multi_agent_version
              +-- 选择 use_responses_lite
              |
              v
      ResponsesApiRequest 序列化
              |
              v
POST /v1/responses  Accept: text/event-stream
              |
              v
      SSE 类型化 ResponseEvent
              |
      +-------+--------+
      |                |
  assistant text    tool call
                       |
                       v
                 ToolRouter/handler
                       |
             spawn child Session/Thread
                       |
                       v
              新的 /v1/responses 请求

CLI 不是把一个“子 agent HTTP API”直接暴露给模型。模型首先返回普通 Responses tool call,客户端在本地执行 spawn_agent handler,创建新的 Codex thread/session;子 session 再自行向同一个 Responses endpoint 发送模型请求。

3. 模型目录决定两项独立行为

0.146.0 内置目录位于 models.json

模型multi_agent_versionuse_responses_lite基线有效版本
gpt-5.6-solV2trueV2
gpt-5.6-terraV2trueV2
gpt-5.6-lunaV1trueV1
gpt-5.5nullfalseV1 fallback
gpt-5.4nullfalseV1 fallback
gpt-5.2nullfalseV1 fallback

这里有两个互相独立的轴:

  • multi_agent_version 决定工具面与消息是否使用 V2 opaque envelope。
  • use_responses_lite 决定整个 Responses request 的字段摆放方式。

所以不能简单说“5.6 都加密”或“Responses Lite 都加密”。Luna 是最清楚 的反例:它使用 Responses Lite,但 multi-agent 是 V1 明文。

当前 main 的这六项值与 0.146.0 相同,但报告仍以安装版本为结论边界。

4. multi-agent 版本选择算法

核心实现在 config/mod.rs

rust
override =
  if features.multi_agent_v2 { V2 }
  else if !agents.enabled { Disabled }
  else { None }

effective =
  override
  ?? model_catalog.multi_agent_version
  ?? (features.multi_agent ? V1 : Disabled)

优先级从高到低:

  1. features.multi_agent_v2=true:强制 V2。
  2. [agents] enabled=false:强制 Disabled。
  3. 已存入会话的 multi-agent version(恢复/继承场景)。
  4. 当前模型目录的 multi_agent_version
  5. features.multi_agent=true 回退 V1,否则 Disabled。

4.1 为什么 multi_agent_v2=false 下 Sol 仍加密

false 只让第一条 override 不成立,不会产生 Some(V1)。随后 Sol 模型目录的 V2 被采用。这是观察到 Sol 加密的直接原因,不是 Sol 客户端有另一套隐藏加密代码。

4.2 为什么 Terra 的旧观察可能是明文

本次新会话的三条证据一致:

  • 0.146.0 内置目录:Terra = V2。
  • parent rollout:multi_agent_version = "v2"
  • spawn message 与 child encrypted_content:均为同一 opaque token。

旧观察出现 Terra 明文,合理原因包括:

  • 使用了旧 CLI 或旧模型目录;
  • provider 的远端模型目录当时覆盖了内置值;
  • 恢复了历史 V1 会话;
  • 抓到的是最终结果回传,而不是初始任务消息;
  • multi_agent_v2=false 被误解为强制 V1。

其中“历史会话粘性”有明确源码依据: session/mod.rs 会优先读取已记录版本;缺少运行时 metadata 的旧 resumed/forked 会话还会 兼容回退 V1。比较模型时必须每次创建新会话。

4.3 multi_agent=false 的陷阱

features.multi_agent 对应 Feature::Collab,其默认值本来就是 true: features/src/lib.rs

把它设成 false 只改变最后一层 fallback。实测:

  • Sol 仍从模型目录得到 V2,并成功 spawn。
  • 5.4 模型目录为 null,最终得到 Disabled。

agents_enabled 来自独立的 [agents].enabled,默认 true: config/mod.rs。 要对所有模型强制禁用应使用:

toml
[agents]
enabled = false

4.4 如何让 Sol 强制使用 V1

0.146.0 的普通 feature override 只有三种结果:

  • multi_agent_v2=true 返回 V2;
  • agents.enabled=false 返回 Disabled;
  • 其余情况返回 None,继续采用会话版本或模型目录版本。

因此不存在下面这种可直接使用的配置:

toml
# 不存在;不要据此配置
force_v1 = true

但源码支持顶层 model_catalog_json,可以加载一个完整的自定义模型目录: config/mod.rs。 将内置 models.json 复制到实验目录,只修改 Sol 条目:

json
{
  "slug": "gpt-5.6-sol",
  "multi_agent_version": "v1",
  "use_responses_lite": true
}

然后启动一个全新的 Sol thread:

bash
codex exec \
  -m gpt-5.6-sol \
  -c features.multi_agent=true \
  -c features.multi_agent_v2=false \
  -c 'model_catalog_json="/absolute/path/models-v1.json"' \
  '<prompt>'

注意文件必须是包含 models 数组的完整有效目录,不能只放上述三个 字段。2026-07-30 实测根 thread 019fb217-68a1-73c2-be0b-be82ad23bdd0 与子 thread 019fb217-a669-7573-a1f5-cd20ad3a8e82 均为 model=gpt-5.6-solmulti_agent_version=v1。父调用中出现明文:

javascript
const spawned = await tools.multi_agent_v1__spawn_agent({
  message: "Independently calculate 31*37..."
});

子会话收到普通 user input_text,没有 encrypted_content,结果为 RESULT=1147。这证明 model_catalog_json 不只是影响模型选择 UI,而是 实际进入 resolve_multi_agent_version_for_model()。它仍是研究用目录覆盖, 升级 CLI 后应重新核对目录 schema 和 rollout 的有效版本。

第二种已验证方法是会话继承:先用 Luna 建立 V1 thread,再以 Sol 恢复:

bash
codex exec -m gpt-5.6-luna \
  -c features.multi_agent=true \
  -c features.multi_agent_v2=false \
  '<seed prompt>'

codex exec resume -m gpt-5.6-sol \
  -c features.multi_agent=true \
  -c features.multi_agent_v2=false \
  <THREAD_ID> '<spawn prompt>'

实测 thread 019fb20d-eddc-7831-baca-8f7f5f35a3fd 的 Luna 首轮和 Sol 恢复轮均为 V1,其 Sol 子 thread 019fb20e-c840-71c0-a438-5420ad500c5e 也为 V1,明文任务 23*29 得到 RESULT=667。原因是会话中已记录的 V1 在模型目录的 Sol V2 之前被采用;但是 multi_agent_v2=true 仍会覆盖为 V2。

继承法会触发模型不匹配警告,并携带原 thread 历史和可能由 Luna 选定的 base instructions。它适合比较协议,不适合作为“干净的新 Sol V1”方案。 不要手工篡改 rollout metadata;该做法不受支持,也容易造成会话状态不一致。

5. 两种请求正文布局

共同 endpoint:

http
POST /v1/responses HTTP/1.1
Accept: text/event-stream
Content-Type: application/json
Authorization: Bearer <redacted>

共同语义字段包括:

  • model
  • input
  • tool_choice: "auto"
  • reasoning
  • store
  • stream: true
  • include: ["reasoning.encrypted_content"]
  • prompt_cache_key
  • text
  • client_metadata

按 0.146.0 ResponsesApiRequest 结构,完整顶层字段语义为:

字段类型/本次值含义
modelstring本 turn 请求的模型 slug;可与 thread 初建模型不同
instructionsstring,可省略常规 Responses 的主开发者指令;空字符串不序列化,Lite 改放 developer message
inputResponseItem[]按顺序回放的消息、reasoning、call、output、agent/compaction items
toolsarray,可省略常规 Responses 的工具 schema;Lite 改放 additional_tools
tool_choice本次 "auto"允许模型自行决定回答还是调用工具
parallel_tool_callsboolean是否允许并行调用;Lite 分支强制 false
reasoningobject/nulleffortsummary,GPT-5.6 Lite 另含 context="all_turns"
store本次 false不让 Responses 服务作为普通持久 response state 使用
stream本次 true使用 SSE 增量返回
stream_optionsobject,可省略可请求特定 reasoning summary delivery 行为
includestring array本次请求 reasoning.encrypted_content,用于无状态 reasoning 回放
service_tierstring,可省略可选服务等级覆盖
prompt_cache_keystring,可省略把同 thread 请求归入稳定 cache key
textobject,可省略输出 verbosity 和可选 JSON Schema format
client_metadatastring map,可省略session/turn/window/安装信息和 Code Mode 工具名映射等

HTTP/SSE 路径没有单独的顶层 multi_agent_v2multi_agent_versionencrypt_subagent_message 字段。multi-agent 选择结果体现在工具集合、工具 参数 schema 和后续 item 类型里。

官方 Responses API 同样定义 inputinstructionstoolsparallel_tool_callsreasoningstream 和 typed SSE items。参考 Create a model responseMigrate to the Responses API。 Codex 的 additional_tools、tool-parameter encryptedagent_message 以及 x-codex-* metadata 是额外扩展,不能当作普通 Responses client 的通用格式。

5.1 Responses Lite:Sol/Terra/Luna

源码分支: client.rs

脱敏结构:

json
{
  "model": "gpt-5.6-sol",
  "input": [
    {
      "type": "additional_tools",
      "role": "developer",
      "tools": [
        {"type": "custom", "name": "exec"},
        {"type": "function", "name": "wait"},
        {"type": "function", "name": "request_user_input"},
        {"type": "namespace", "name": "collaboration", "tools": ["..."]}
      ]
    },
    {
      "type": "message",
      "role": "developer",
      "content": [{"type": "input_text", "text": "<base instructions>"}]
    },
    "... history and user messages ..."
  ],
  "tool_choice": "auto",
  "parallel_tool_calls": false,
  "reasoning": {
    "effort": "high",
    "summary": "detailed",
    "context": "all_turns"
  },
  "store": false,
  "stream": true,
  "include": ["reasoning.encrypted_content"],
  "client_metadata": {"...": "..."}
}

特征:

  • HTTP header: X-Openai-Internal-Codex-Responses-Lite: true
  • tool definitions 被放进 input[0]additional_tools
  • base instructions 被改写成 developer message。
  • 顶层 instructionstools 被省略。
  • 即便模型目录称支持并行 tool calls,客户端仍把 parallel_tool_calls 强制为 false。
  • GPT-5.6 的 reasoning request 增加 context: "all_turns"

完整 query1.txt 中,input[0]additional_tools,随后依次是多个 developer message、环境/用户 message 和历史 assistant/user message。 这解释了为什么抓包看起来不像常见的 {instructions, input, tools}

5.2 常规 Responses:5.5/5.4/5.2

脱敏结构:

json
{
  "model": "gpt-5.4",
  "instructions": "<base instructions>",
  "input": ["... messages and response items ..."],
  "tools": ["... top-level tool definitions ..."],
  "tool_choice": "auto",
  "parallel_tool_calls": true,
  "reasoning": {
    "effort": "high",
    "summary": "detailed"
  },
  "store": false,
  "stream": true,
  "include": ["reasoning.encrypted_content"],
  "client_metadata": {"...": "..."}
}

特征:

  • 没有 Responses Lite header。
  • base instructions 位于顶层 instructions
  • tool definitions 位于顶层 tools
  • parallel_tool_calls 不会因布局被强制 false。
  • 非 Lite 模型省略 reasoning.context,让 Responses 使用其默认值。

5.3 input[] 到底是什么

input 不是 Chat Completions 风格的纯消息数组,而是一个有序的 ResponseItem 事件历史。它同时承载消息、模型 reasoning、工具调用、工具 结果、agent 路由消息和压缩状态。0.146.0 在 models.rs 用带 type discriminator 的 ResponseItem enum 表示这些项目。

最常见的消息形态是:

json
{
  "type": "message",
  "id": "msg_...",
  "role": "user",
  "content": [
    {"type": "input_text", "text": "<prompt>"}
  ]
}

role 在本次抓包中的含义如下。

role谁提供Codex 中的用途常见 content
developer客户端/产品base instructions、权限、模式、plugins、skills、子 agent 规则input_text
user用户或 V1 子任务注入用户问题、AGENTS.mdenvironment_context、V1 child promptinput_text/input_image/input_audio
assistant先前模型输出commentary 或 final answer 的历史回放output_text

官方 Responses schema 还允许 system;本次 Lite 抓包没有使用它,Codex 把产品级指令序列化成一个或多个 developer message。常规 Responses 则把 主要 base instructions 放在顶层 instructionsadditional_tools 上的 role="developer" 表示工具声明属于开发者上下文,不代表一个开发者说话 消息。

普通 message 的 content[] 支持:

  • input_text:输入给模型的文字;
  • input_image:图片 URL/data URL,可带 detail
  • input_audio:音频 data URL;
  • output_text:先前 assistant 的文字输出。

agent_message.content[] 是另一套更窄的 union,只允许 input_textencrypted_content。这正是 V2 child request 能把明文路由头与 opaque task token 放在同一个 item 内的原因。

0.146.0 客户端可序列化/解析的完整 ResponseItem 类型如下。后半部分 不一定出现在本次简单计算中,但属于同一 wire union。

type关键字段含义
additional_toolsrole, toolsLite 专用的内联工具声明前缀
messagerole, content, phasedeveloper/user/assistant 消息;phase 可为 commentaryfinal_answer
agent_messageauthor, recipient, contentV2 父子 agent 私有路由 envelope
reasoningsummary, content, encrypted_content可回放的 reasoning item;密文与子任务加密是不同机制
function_callname, namespace, arguments, call_idJSON-schema function 工具调用;arguments 在线上是“包含 JSON 的字符串”
function_call_outputcall_id, output本地 handler 结果,通过 call_id 对应调用
custom_tool_callname, input, call_idfreeform/custom 工具调用,例如 Lite 的 exec JavaScript
custom_tool_call_outputcall_id, outputcustom 工具结果
local_shell_callcall_id, status, actionprovider 原生 local-shell 调用表示
tool_search_callcall_id, execution, arguments延迟工具搜索/选择调用
tool_search_outputcall_id, status, tools工具搜索结果
web_search_callstatus, action服务端 web search item
image_generation_callstatus, revised_prompt, result图像生成 item
compactionencrypted_content兼容旧名称 compaction_summary 的加密压缩结果
compaction_trigger请求服务端执行压缩的控制 item,不是持久结果
context_compactionencrypted_content新的上下文压缩 item

未知 type 会被反序列化为内部 Other 兜底,而不是让整个历史解析失败。 工具 output 在线上既可为普通 string,也可为结构化 content items;结构化 结果支持 input_textinput_imageinput_audioencrypted_content

query1query4input 增长

旧 Sol V2 样本展示了完整的 stateless replay:

请求在前一请求基础上新增的关键 item客户端目的
query1工具声明、全部 developer/user/history message让父模型决定首次动作
query2reasoning、assistant commentary、加密 function_call、匹配的 function_call_output告诉父模型 spawn 已由本地完成
query3fork 的历史、child developer instructions、V2 agent_message作为新子 thread 的首次模型请求
query4父历史、spawn/wait 调用及输出、child 到 parent 的 agent_message让父模型消费子结果并生成最终回答

这里没有使用 previous_response_id。因为 store=false,Codex 把需要保留的 项目显式重新放回后续请求的 input[]include 中的 reasoning.encrypted_content 使 opaque reasoning 也能在 stateless 请求中 回放。

子 agent 首次请求实际发送什么

严格说不是“子 agent 先自行发内容”,而是父模型产生 spawn tool call 后, CLI 创建 child session,再代表它构造第一次 /v1/responses 请求。该请求 通常包含:

  1. child 可用工具的定义(Lite 为 additional_tools);
  2. base/developer instructions、权限、collaboration mode、plugins/skills;
  3. AGENTS.md 与 child 的 environment context;
  4. fork_turns/V1 fork_context 选择继承的父历史;
  5. child 专属开发者指令与父子 agent 路由规则;
  6. 最后才是这次任务本身。

任务本身在 V1/V2 的差别最清楚:

jsonc
// V1
{"type":"message","role":"user",
 "content":[{"type":"input_text","text":"<plaintext task>"}]}

// V2
{"type":"agent_message","author":"/root","recipient":"/root/task",
 "content":[
   {"type":"input_text","text":"Message Type: NEW_TASK\n...\nPayload:\n"},
   {"type":"encrypted_content","encrypted_content":"<opaque task>"}
 ]}

所以 V2 只隐藏 task payload,不隐藏 child 使用的工具、developer 指令、 路由头、继承的明文历史或父子 thread 关系。fork_turns="all" 尤其会让 父历史占据 child request 的大部分内容;none 才能显著减少这部分复制。

5.4 同一用户输入:Sol V2 与 Sol V1 逐报文对比

为避免把不同 prompt 导致的模型输出差异误认为协议差异,2026-07-30 使用 完全相同的用户输入做了一组配对实验:

text
You must create exactly one subagent to independently calculate 41*43.
Wait for it, then reply with exactly: PAIR_RESULT=<number>.
Do not run shell commands or modify files.

model_catalog_json 外,模型和 feature 参数相同:

text
共同:-m gpt-5.6-sol
共同:-c features.multi_agent=true
共同:-c features.multi_agent_v2=false

V2:不设置 model_catalog_json,Sol 目录值为 v2
V1:设置 model_catalog_json,且只把 Sol 的目录值改为 v1
根 thread子 thread根/子有效版本结果
默认 Sol019fb232-d524-77e0-9854-f381bb8dbfb3019fb232-eb7f-7482-8a02-3df5a4340068V2/V2PAIR_RESULT=1763
自定义目录 Sol019fb234-08b4-79c2-922b-0bd625de8558019fb234-3fd1-7e73-9499-8af193917288V1/V1PAIR_RESULT=1763

阶段 1:父会话首次 Query

两边请求的 endpoint、model、用户 message 和 Lite 布局相同:

jsonc
{
  "model": "gpt-5.6-sol",                  // 相同
  "input": [
    {
      "type": "additional_tools",          // 相同
      "role": "developer",                 // 相同
      "tools": [/* 这里不同 */]
    },
    /* 相同类别的 developer/environment items */,
    {
      "type": "message",
      "role": "user",
      "content": [{
        "type": "input_text",
        "text": "You must create exactly one subagent to independently calculate 41*43. Wait for it, then reply with exactly: PAIR_RESULT=<number>. Do not run shell commands or modify files."
      }]
    }
  ],
  "parallel_tool_calls": false,             // 相同
  "store": false,                           // 相同
  "stream": true,                           // 相同
  "include": ["reasoning.encrypted_content"] // 相同且与子任务加密无关
}

决定性区别只在客户端生成的工具面。默认 V2 的 additional_tools.tools 直接包含:

json
{
  "type": "namespace",
  "name": "collaboration",
  "tools": [{
    "type": "function",
    "name": "spawn_agent",
    "parameters": {
      "type": "object",
      "properties": {
        "task_name": {"type": "string"},
        "fork_turns": {"type": "string"},
        "message": {
          "type": "string",
          "encrypted": true
        }
      },
      "required": ["task_name", "message"]
    }
  }]
}

本次 V1 Sol Query 中没有直接的 collaboration namespace,也没有任何 message.encrypted=true。因为 Sol 是 Code Mode/Responses Lite,V1 agent 工具作为本地 exec 的 deferred/nested tools 提供;模型可通过 ALL_TOOLS 发现 multi_agent_v1__spawn_agent,但服务端收到的顶层可调用工具是 freeform exec

json
{
  "type": "custom",
  "name": "exec",
  "description": "Run JavaScript code to orchestrate/compose tool calls..."
}

首次 Query 的最小结构 diff 因而是:

diff
  model = "gpt-5.6-sol"                         # 相同
  X-Openai-Internal-Codex-Responses-Lite = true # 相同
  input.user.content = 同一条 41*43 prompt        # 相同

- V2: additional_tools 包含 collaboration.spawn_agent
- V2: collaboration.spawn_agent.message.encrypted = true
+ V1: 没有直接 collaboration namespace
+ V1: 通过 custom exec 调用本地 multi_agent_v1__* nested tools
+ V1: 不存在 encrypted=true marker

model_catalog_json 路径、文件内容以及本地 TurnContext.multi_agent_version 都没有出现在这个 HTTP body 中。

阶段 2:父模型的首次 spawn 返回

对于同一个用户目标,V2 返回标准/namespaced function call。密文在 SSE 到达客户端时已经生成:

json
{
  "type": "function_call",
  "name": "spawn_agent",
  "namespace": "collaboration",
  "arguments": "{\"task_name\":\"calculate_pair\",\"fork_turns\":\"all\",\"message\":\"gAAAAA...<redacted>\"}",
  "call_id": "call_<redacted>"
}

V1 返回 freeform/custom call,任务明文位于 JavaScript input

json
{
  "type": "custom_tool_call",
  "name": "exec",
  "input": "const spawned = await tools.multi_agent_v1__spawn_agent({message: \"Independently calculate 41*43. Return only the numeric result. Do not use shell commands or modify files.\"});\ntext(spawned);",
  "call_id": "call_<redacted>"
}

这是最直观的响应差异:

diff
- V2: function_call.arguments.message = "gAAAAA..."
+ V1: custom_tool_call.input = "...spawn_agent({message: \"明文\"})..."

两次生成的子任务措辞略有不同,属于模型生成的非确定性,不是协议字段差异; 协议差异是 V2 的 function_call + encrypted argument 与 V1 的 custom_tool_call + plaintext freeform input

阶段 3:父会话续 Query

工具由本地执行完毕后,Codex 将 call 和 output 回放进下一次 input[]。 V2 回放:

jsonc
[
  {
    "type": "function_call",
    "name": "spawn_agent",
    "namespace": "collaboration",
    "arguments": "{...\"message\":\"gAAAAA...<redacted>\"}",
    "call_id": "call_1"
  },
  {
    "type": "function_call_output",
    "call_id": "call_1",
    "output": "{\"task_name\":\"/root/calculate_pair\"}"
  }
]

V1 回放:

jsonc
[
  {
    "type": "custom_tool_call",
    "name": "exec",
    "input": "const spawned = await tools.multi_agent_v1__spawn_agent({message: \"Independently calculate 41*43...\"});",
    "call_id": "call_1"
  },
  {
    "type": "custom_tool_call_output",
    "call_id": "call_1",
    "output": [{
      "type": "input_text",
      "text": "{\"agent_id\":\"<redacted>\",\"nickname\":\"<redacted>\"}"
    }]
  }
]

因此 V1 task 不仅在父 SSE response 中是明文,还会在父续 Query 的历史 回放中再次以明文出现。V2 回放的是同一个 opaque token。

阶段 4:子会话首次 Query

V2 child 的任务 item 是:

json
{
  "type": "agent_message",
  "author": "/root",
  "recipient": "/root/calculate_pair",
  "content": [
    {
      "type": "input_text",
      "text": "Message Type: NEW_TASK\nTask name: /root/calculate_pair\nSender: /root\nPayload:\n"
    },
    {
      "type": "encrypted_content",
      "encrypted_content": "gAAAAA...<same redacted token>"
    }
  ]
}

V1 child 的任务 item 是:

json
{
  "type": "message",
  "role": "user",
  "content": [{
    "type": "input_text",
    "text": "Independently calculate 41*43. Return only the numeric result. Do not use shell commands or modify files."
  }]
}

本次 V2 模型另外选择了 fork_turns="all",而 V1 调用省略 fork_context,默认不 fork。这会让 V2 child 额外带上父历史,属于模型 选择/两个工具接口默认值的差异,不能归因于加密本身。只比较任务 item 时, 协议差异仍是:

diff
- V2: type = agent_message
- V2: content = 明文路由头 + encrypted_content
+ V1: type = message, role = user
+ V1: content = plaintext input_text

服务端究竟识别了什么

服务端并未识别一个显式“V1 标志”。它看到的是两套不同的可调用接口:

text
V2 Query
  collaboration.spawn_agent.message.encrypted = true
                   |
                   v
服务端将该 argument 封装为 opaque token
                   |
                   v
V2 child 用 type=encrypted_content 消费 token

V1 Query
  没有 encrypted=true;调用路径是普通 freeform exec
                   |
                   v
服务端按普通模型输出返回 JavaScript 明文
                   |
                   v
V1 child 用 role=user + input_text 消费明文

因此最准确的说法是:服务端识别的是参数级加密标记和后续 encrypted_content 类型,而不是 model_catalog_json 或一个线上 V1/V2 协商字段。

5.5 不同模型的实际 tool-call 报文差异

模型/有效版本请求布局父模型返回的调用 itemtask message
Sol/Terra 默认 V2Litenamespaced function_callcollaboration.spawn_agentarguments 中为密文
Luna V1Litecustom_tool_call(name="exec"),JavaScript 调用 multi_agent_v1__spawn_agentinput 中为明文
Sol + 自定义目录 V1Lite与 Luna V1 相同的 exec/V1 deferred tool 路径input 中为明文
5.5/5.4 V1常规直接 function_call(name="spawn_agent")arguments 中为明文
5.2 V1常规预计同 5.5/5.4;本次 503 未取得输出源码预测明文
5.4 + 强制 V2常规直接 V2 function_call(name="spawn_agent")arguments 中为密文

所以“请求格式不同”至少包含两个独立选择:Lite/常规决定工具定义放在哪里, V1/V2 决定暴露哪组 agent 工具以及 message 是否带 encrypted marker。 模型还可能选择是否先输出 commentary/reasoning,这属于生成行为,不是另一 套 endpoint。

5.6 为什么请求会很大

每个请求不是只发送用户最后一句话。CLI 会组合:

  • 模型 base instructions;
  • system/developer 指令;
  • AGENTS.md
  • environment context;
  • skills/plugin descriptions;
  • MCP 与本地工具 schema;
  • 之前的 message、reasoning、function call、function output;
  • multi-agent 路由与 thread metadata。

本地 query1 的 JSON body 为约 73 KB,其中仅第一个 base-instruction developer message 就约 17.7 KB;另一个 developer message 由多个 content part 构成,总计约 19 KB。工具说明与 schema 是请求膨胀的主要 来源之一。

6. HTTP header 与 metadata

本次父会话观察到的 Codex 特有 header 包括:

text
X-Codex-Beta-Features: remote_compaction_v2
X-Codex-Window-Id: <thread>:<window>
X-Codex-Turn-Metadata: <bounded JSON>
X-Client-Request-Id: <redacted>
Session-Id: <redacted>
Thread-Id: <redacted>
Originator: Codex Desktop
User-Agent: Codex Desktop/0.146.0 ... (codex_exec; 0.146.0)

Lite 模型另有:

text
X-Openai-Internal-Codex-Responses-Lite: true

子会话另有:

text
X-Codex-Parent-Thread-Id: <redacted>
X-Openai-Subagent: collab_spawn

同类信息还会进入 body 的 client_metadata。实现见 responses_metadata.rs: compatibility headers 与 client metadata 有意并存;大的 Code Mode tool-name mapping 只放 body,避免无界 header。

这意味着即使 task payload 加密,网络观察者仍可关联父子 thread、判断 spawn 时间、子 agent 类型和请求数量。

7. SSE 返回格式与客户端处理

典型事件序列:

text
response.created
response.in_progress
response.output_item.added
response.reasoning_summary_part.added
response.reasoning_summary_text.delta
response.reasoning_summary_text.done
response.output_item.done
response.output_item.added
response.function_call_arguments.delta
response.function_call_arguments.done
response.output_item.done
response.completed

Responses 不应按 Chat Completions 的 choices[0].delta 解析。reasoning、 message、function call 都是独立 item,function call 通过 call_id 与下一次请求中的 function_call_output 关联。 官方 Streaming API responses 也明确将 Responses streaming 定义为具有预定义 schema 的 semantic events; additional_toolsagent_message 和 encrypted tool parameter 仍是 Codex 扩展,不在该通用文档中。

每个 SSE frame 是一个 event: 行和一个 JSON data: 行,例如:

text
event: response.output_item.done
data: {"type":"response.output_item.done","output_index":1,
       "item":{"type":"function_call","name":"spawn_agent",
               "arguments":"{\"message\":\"...\"}",
               "call_id":"call_..."}}

外层 type 描述流事件,item.type 描述产生的持久 ResponseItem。两层 type 不能混为一谈。常见字段含义如下:

  • sequence_number:本次 response 内事件顺序;
  • output_indexresponse.output[] 中的 item 位置;
  • content_index/summary_index:item 内 content/summary part 的位置;
  • item_id:正在流式生成的 ResponseItem ID;
  • call_id:工具调用与下一请求工具输出的关联键;
  • delta:本 frame 新增的文字或 freeform tool input;
  • item:added/done 时的部分或完整 item;
  • response:created/completed/failed 时的 response 对象,completed 中含 usage、status、response ID 等。

0.146.0 的 SSE parser 实际处理表位于 responses.rs

SSE event客户端动作
response.created产生内部 Created
response.output_item.added尝试解析部分 ResponseItem,用于开始展示/跟踪
response.output_text.delta向 UI 增量发送 assistant 文字
response.reasoning_summary_part.added建立 reasoning summary part
response.reasoning_summary_text.delta/done增量/完成 reasoning 摘要
response.reasoning_text.delta处理可见 reasoning content(若 provider 提供)
response.custom_tool_call_input.delta累积 freeform/custom tool input,例如 exec JavaScript
response.output_item.done解析完整 ResponseItem;这是持久化和工具执行的权威 item
response.completed读取 response ID、token usage、可选 end_turn 并结束流
response.failed映射 context/quota/cyber/invalid/retryable 等错误
response.incomplete读取 incomplete_details.reason 后作为流错误结束

response.in_progress、content-part 的 added/done、 response.function_call_arguments.done 等事件虽然存在于真实流中,但本版本 parser 没有为它们产生单独的内部事件;最终完整 arguments 来自 response.output_item.done.item。因此不能笼统地说客户端总是靠 function_call_arguments.delta 拼接函数参数。custom/freeform tool input 才明确走 delta 累积路径。

三类典型输出 item 的完成形态是:

json
{"type":"message","role":"assistant",
 "content":[{"type":"output_text","text":"..."}],
 "phase":"commentary"}

{"type":"reasoning","summary":[{"type":"summary_text","text":"..."}],
 "encrypted_content":"<opaque reasoning>"}

{"type":"function_call","name":"spawn_agent","namespace":"collaboration",
 "arguments":"{\"task_name\":\"calc\",\"message\":\"...\"}",
 "call_id":"call_..."}

Lite V1 的 freeform 调用则是:

json
{"type":"custom_tool_call","name":"exec",
 "input":"const x = await tools.multi_agent_v1__spawn_agent({...});",
 "call_id":"call_..."}

客户端处理逻辑可概括为:

  1. SSE parser 发出文字/reasoning 增量供 UI 展示。
  2. output_item.done 给出完整 item,reasoning/message/call 被写入 rollout。
  3. function_call,客户端把 arguments 字符串解析成 JSON;对 custom_tool_call,保留其 freeform input
  4. ToolRouter 按 name/namespace 找到本地 handler。
  5. handler 执行后生成带同一 call_idfunction_call_outputcustom_tool_call_output
  6. 客户端把历史 item + call + output 重新发送到 /responses
  7. 重复直到服务端产生最终 assistant message 或错误。phase=final_answerend_turn 都可能缺失,客户端为旧 provider 保留兼容判断。

旧 Sol 四请求样本正好展示这个循环:

  • query1:父请求;
  • resp1:reasoning + 加密 spawn_agent
  • query2:父历史 + spawn call + spawn output;
  • query3:新子会话请求,含加密 agent_message
  • query4:父历史继续推进并接收子结果。

父会话和子会话各自有独立的 /responses SSE 流;spawn_agent handler 不会把 child token stream 直接复用为 parent stream。它创建 child session, 等待/消息工具再把 child 的完成状态或最终文本作为本地 tool output/agent message 注入父历史。

8. V1:子任务为什么是明文

V1 tool surface 由 multi_agents.rsmulti_agents_spec.rs 定义:

  • spawn_agent
  • send_input
  • resume_agent
  • wait_agent
  • close_agent

V1 的 message schema 是普通 string,没有 encrypted: true。handler 调用 parse_collab_input,将明文转换成普通 UserInput,然后创建子 session: multi_agents/spawn.rs

实际表现分两种外观:

  • Luna 以及自定义目录强制 V1 的 Sol 都使用 Responses Lite。multi-agent V1 工具被延迟暴露,模型先调用 exec,其 JavaScript input 中出现 tools.multi_agent_v1__spawn_agent({message: "..."})。Burp 与 rollout 都能直接看到 message。
  • 5.5/5.4 使用常规 Responses。模型直接返回名为 spawn_agentfunction_call,arguments 中是明文 message。

子会话最终收到普通 user message:

json
{
  "type": "message",
  "role": "user",
  "content": [{
    "type": "input_text",
    "text": "Independently calculate 17*19..."
  }]
}

因此 V1 的三个位置都可能留存明文:

  • 父请求的 tool call output stream;
  • 父 rollout;
  • 子请求与子 rollout。

9. V2:应用层字段加密的完整链路

9.1 请求 tool schema

V2 tool surface:

  • spawn_agent
  • send_message
  • followup_task
  • 可选 wait_agent
  • interrupt_agent
  • list_agents

V2 的三个 message 字段调用 .with_encrypted()

with_encrypted() 只是在 JSON Schema 中序列化:

json
{
  "type": "string",
  "description": "...",
  "encrypted": true
}

实现见 json_schema.rs

服务端识别加密要求的精确参数

父会话请求中,服务端据此加密模型产生的 message argument:

json
{
  "type": "function",
  "name": "spawn_agent",
  "parameters": {
    "type": "object",
    "properties": {
      "message": {
        "type": "string",
        "description": "Initial plain-text task for the new agent.",
        "encrypted": true
      }
    },
    "required": ["task_name", "message"]
  }
}

在 Sol 的 Responses Lite 实际抓包 query1.txt 中,完整 wire path 是:

text
input[]
  .type = "additional_tools"
  .tools[namespace = "collaboration"]
  .tools[name = "spawn_agent"]
  .parameters.properties.message.encrypted = true

同一个 collaboration namespace 中还有:

text
send_message.parameters.properties.message.encrypted = true
followup_task.parameters.properties.message.encrypted = true

强制 V2 的常规 Responses 模型(例如本次 gpt-5.4 + multi_agent_v2=true)语义相同,只是 tool definition 位于顶层 tools[],所以路径变为:

text
tools[name = "spawn_agent"]
  .parameters.properties.message.encrypted = true

当有效版本为 V1 时,message 仍是普通 {"type":"string"} schema, 没有 encrypted key;服务端因而返回明文 function arguments。V1 与 V2 不是通过一个单独的顶层 multi_agent_v2 参数协商,而是通过客户端发送 的不同 tool surface 和每个敏感参数上的 encrypted: true 表达。

以下字段都不是子 agent task message 的首次加密触发器:

  • include: ["reasoning.encrypted_content"]:只要求返回可回放的加密 reasoning,与 tool argument 加密是两套机制。
  • X-Openai-Internal-Codex-Responses-Lite: true:只选择 Lite 请求布局。
  • X-Openai-Subagent: collab_spawn:标识请求来自已创建的子 agent。
  • multi_agent_v2=true/false:本地配置,不直接出现在 Responses body。

首次加密和后续消费是两个阶段:

  1. 父请求的 message.encrypted: true 告诉 Responses 服务将模型产生的 message argument 作为 opaque 密文返回。
  2. 子请求的 content[].type = "encrypted_content" 告诉服务端该 input part 已是 opaque 密文,需要在子模型上下文中解释。它承载的是第一阶段 返回的同一 token,不会要求客户端重新加密。

9.2 模型/服务端返回

父模型内部需要理解原始任务,但发给客户端的 SSE tool arguments 是:

json
{
  "task_name": "calculate",
  "fork_turns": "all",
  "message": "gAAAAA...<opaque>"
}

同一次 response 的 function_call_arguments.done 已经是密文。也就是说 加密发生在客户端收到 SSE 之前。

9.3 客户端不解密

V2 handler 把 arguments 解析为普通 Rust String,但把它当 opaque message:

multi_agents_v2/spawn.rs 调用 communication_from_tool_message,后者直接调用 InterAgentCommunication::new_encrypted

multi_agents_v2.rs

仓库中没有这条消息的本地 encrypt/decrypt 或 key lookup。客户端只是:

  1. 收到 opaque string;
  2. 保存到 encrypted_content
  3. 创建子 thread;
  4. 将同一 string 放入子请求。

9.4 子请求封装

InterAgentCommunication::to_model_input_item 生成:

json
{
  "type": "agent_message",
  "author": "/root",
  "recipient": "/root/calculate",
  "content": [
    {
      "type": "input_text",
      "text": "Message Type: NEW_TASK\nTask name: ...\nSender: ...\nPayload:\n"
    },
    {
      "type": "encrypted_content",
      "encrypted_content": "gAAAAA...<same opaque value>"
    }
  ]
}

实现见 protocol.rsmodels.rs

服务端在子模型上下文中恢复 task payload;本地 CLI 从未获得明文。

9.5 子结果回父

本次简单计算中,子 agent 的 final answer 323 以普通 input_text 回到父 thread,并非 encrypted_content。所以不能把 V2 描述为“所有 agent 间消息全加密”。准确描述是:

  • 指定的 V2 message 输入参数被应用层加密;
  • 路由 envelope 明文;
  • 本次 final result 明文;
  • reasoning 另有独立的 reasoning.encrypted_content 机制。

10. 密文格式与能否解密

Sol 样本的 opaque token:

  • URL-safe Base64;
  • 首字节 0x80
  • 8-byte 大端 Unix timestamp;
  • 16-byte IV;
  • 密文长度为 16 的倍数;
  • 末尾 32 bytes;
  • timestamp 精确对应 tool call 产生时间。

这与标准 Fernet token 布局完全一致。证据强度为“强格式推断”,理由是:

  • 格式与时间戳全部匹配;
  • token 以典型 gAAAAA 开头;
  • 但 Codex 开源客户端没有算法或 key;
  • 公开 Responses 文档只解释 reasoning.encrypted_content 的 stateless/ZDR 回放用途,没有公开说明 Codex multi-agent tool parameter 的密码套件或密钥生命周期。

没有服务端 key,Burp、CLI 与本地 rollout 都不能恢复 message。不要尝试 把 Base64 decode 误认为解密;decode 只能看到 envelope 元数据。

10.1 泄漏面

即使 message 加密,仍泄漏:

  • token 生成时间;
  • padded ciphertext 长度,从而近似泄漏明文长度区间;
  • tool 名是 spawn/send/followup 中哪一种;
  • task name;
  • sender、recipient;
  • fork_turns;
  • 模型、reasoning effort、service tier override;
  • parent/child thread 关联;
  • 请求/响应时间、次数和 token usage。

10.2 信任边界

这是“客户端 opaque、服务端可解释”的 envelope,不是由父 CLI 与子 CLI 持有独立密钥的端到端加密。它主要减少以下明文暴露:

  • 本地 tool arguments;
  • 本地 rollout 中的 task payload;
  • TLS 解密代理看到的 task 字段;
  • 不需要理解 payload 的中间转发层。

它不防:

  • 执行模型推理和解密的服务;
  • 父模型本身;
  • full-history fork 中原本就存在的明文对话;
  • 子 agent 输出的明文结果;
  • task name 与 metadata 侧信道。

特别是 fork_turns="all":子请求会携带被 fork 的历史。即使新的 message 是密文,旧历史中的用户内容仍可能以普通 message 发送。

11. 5.2 失败与重试机制

5.2 两次独立会话都在首次模型输出前失败。第二次观察到:

  • 初始请求;
  • 5 次 Reconnecting... n/5
  • 最终第 6 个 503 使 turn failed。

源码: responses_retry.rsutil.rs

默认 backoff 基数为 200 ms、因子 2,并乘 0.9..1.1 jitter;若服务端 错误携带 retry delay 则优先使用服务端值。达到最大次数后,如果当前使用 WebSocket 且可切换,客户端还可以回退 HTTPS;本次 provider 本来就是 HTTPS/SSE,因此最终直接报错。

5.2 在发请求前生成的 turn context 已记录 multi_agent_version="v1", Burp 也确认它使用常规 Responses layout。但服务端没有返回 spawn call, 所以本报告把“5.2 会明文 spawn”列为源码/版本选择预测,而非成功流量证据。

12. 安全结论

12.1 对“参数是否加密”的精确回答

情况task message其他 spawn 参数子结果
V1明文明文明文
V2message 为 opaque 密文task_name/fork/model 等明文本次为明文
TLS 外部观察整个 HTTP 均受 TLS 保护整个 HTTP 均受 TLS 保护整个 HTTP 均受 TLS 保护
Burp MITM 后能看到 V1 message;V2 只见密文可见可见

12.2 配置建议

以下配置不能稳定固定为 V1:

toml
[features]
multi_agent = true
multi_agent_v2 = false

比较 V1/V2 时应:

  • 每次使用新 thread;
  • 记录 rollout 的有效 multi_agent_version
  • 同时记录模型目录;
  • 若必须让新 Sol thread 使用 V1,使用完整自定义 model_catalog_json 并把 Sol 条目标为 V1;
  • 继承既有 V1 thread 可用于协议对照,但不要把它当成隔离的新会话;
  • multi_agent_v2=true 强制 V2;
  • 若要全局禁用,使用 [agents] enabled=false
  • 不要只依据模型名称或 UI 中的 feature checkbox 推断有效版本。

若目标是减少本地/代理日志中的子任务明文,应强制 V2,但仍需理解它不是 全对话 E2E 加密,也不隐藏 full-history fork。

13. 可复核证据索引

抓包

  • query1.txt:Sol 父会话初始 Lite request
  • resp1.txt:reasoning + encrypted spawn call
  • query2.txt:父会话带 function call/output 的续请求
  • query3.txt:子会话 agent_message request
  • query4.txt:父会话继续请求
  • resp2.txtresp4.txt:对应 SSE

原始抓包包含完整运行上下文,只在本地研究环境保留,不随公开网站或远端 仓库发布。可公开复核的字段、脱敏结果和源码证据已写入正文。

源码

本地 rollout

2026-07-30 的新测试位于:

text
$HOME/.codex/sessions/2026/07/30/

“配置缺失”样本位于临时目录:

text
/private/tmp/codex-protocol-absent-20260730/sessions/2026/07/30/

Sol V1 的两个新增验证组:

text
# Luna V1 thread -> resume 为 Sol;随后创建 Sol child
$HOME/.codex/sessions/2026/07/30/rollout-2026-07-30T16-04-45-019fb20d-eddc-7831-baca-8f7f5f35a3fd.jsonl
$HOME/.codex/sessions/2026/07/30/rollout-2026-07-30T16-05-41-019fb20e-c840-71c0-a438-5420ad500c5e.jsonl

# model_catalog_json 强制新 Sol 根 thread 为 V1;随后创建 Sol child
$HOME/.codex/sessions/2026/07/30/rollout-2026-07-30T16-15-06-019fb217-68a1-73c2-be0b-be82ad23bdd0.jsonl
$HOME/.codex/sessions/2026/07/30/rollout-2026-07-30T16-15-22-019fb217-a669-7573-a1f5-cd20ad3a8e82.jsonl

# 同一 41*43 用户输入:默认 Sol V2 根/子
$HOME/.codex/sessions/2026/07/30/rollout-2026-07-30T16-45-03-019fb232-d524-77e0-9854-f381bb8dbfb3.jsonl
$HOME/.codex/sessions/2026/07/30/rollout-2026-07-30T16-45-09-019fb232-eb7f-7482-8a02-3df5a4340068.jsonl

# 同一 41*43 用户输入:model_catalog_json Sol V1 根/子
$HOME/.codex/sessions/2026/07/30/rollout-2026-07-30T16-46-22-019fb234-08b4-79c2-922b-0bd625de8558.jsonl
$HOME/.codex/sessions/2026/07/30/rollout-2026-07-30T16-46-36-019fb234-3fd1-7e73-9499-8af193917288.jsonl

详细根 thread ID 与结果映射见实验记录。报告没有复制 rollout 原文,以 避免把本地完整 developer instructions、环境信息或 metadata 写入研究库。

14. 后续可扩展实验

当前最有价值的后续工作不是重复简单 spawn,而是分别触发并验证:

  1. V2 send_message.messagefollowup_task.message 是否使用相同 token envelope,并确认 send_messagetrigger_turn=false 路由差异。
  2. V2 子结果在什么条件下也会加密;本次 final answer 是明文。
  3. fork_turns=noneall、数字窗口对 child request 明文历史量的影响。
  4. 直接 OpenAI endpoint 与 ai.feei.cn provider 的 header/body 差异。
  5. provider 远端 /models 目录变化是否会覆盖内置 multi_agent_version
  6. WebSocket-capable provider 下 Responses WS 与 HTTPS/SSE fallback。
  7. 不同 multi_agent_v2.tool_namespace 配置对 wire tool name 的影响。

这些实验应继续遵守:新会话、定向 Burp filter、只保存脱敏结构、不要导出 无关 Proxy History、不要提交认证信息。