青雲的博客
拆开 Codex 第一部:先建立系统地图 第 05 章

登录身份不等于 Agent 身份

从 access token 分类、auth.json 持久化,到 Agent Identity JWT、每个 task 的断言和最终请求头,画清 Codex 的认证边界。

源码版本
rust-v0.144.6
验证日期
Commit
5d1fbf26c43abc65a203928b2e31561cb039e06d

登录成功之后,最容易产生的误判是:“我已经有一个 token,所以这个进程就是某个 Agent。”先不要沿着 token 这个词往下推。Codex 至少把四层对象分开:

层次当前版本里的材料它负责什么它不代表什么
登录凭据Personal Access Token(PAT)或登录时提交的 Agent Identity JWT选择认证分支,完成验证并写入 credential store不自动等于某个 task 的身份
可复用 Agent IdentityAgentIdentityAuthRecord 中的 runtime id、私钥和账号关联字段保存客户端签名身份,供后续生成断言私钥和 runtime id 都不是 task_id
task-scoped assertion当前 task_id、timestamp 与签名组成的 AgentAssertion作为当前 Agent Identity 请求的 Authorization不负责 sticky turn 路由
路由与会话元数据x-codex-turn-stateSessionSource::SubAgent(...).agent_role维持 turn 路由,或描述 subagent 的会话来源与追踪角色不是登录 credential,也不是签名身份材料

四层可以同时出现在一次请求路径上,但不能合并成一个“Agent token”。登录凭据先把身份材料带进客户端;durable record 保存可复用的签名材料;task assertion 才把当前 task 绑定进请求;turn-state 和 agent_role 仍各自停留在路由、会话与追踪边界。

源码还有一个决定 task 生命周期的细节:raw JSON Web Token(JWT)claims 转成 AgentIdentityAuthRecord 时明确写入 task_id: NoneAgentIdentityAuth::from_jwt 每次 load 都会经过 from_record 注册新 task。只有 managed Record(AgentIdentityStorage::Record)里已有的非空 task_id,才可在 manager 持久化后跨 run 复用。最终请求按 auth snapshot 中的 task id 生成断言,再放进 Authorization

后面就按这四层往下追:登录 token 如何分类并写盘,Agent Identity 如何从 JWT 变成 durable record,task 如何注册并生成断言,以及请求 headers 如何同时承载认证与路由信息。先把证据边界画清,再谈“当前请求代表谁”。

login_with_access_token 先分类,后写入

login_with_access_token 的参数名叫 access token,但源码没有假定它总是同一种 token。classify_codex_access_token 只做一个很窄的分类:以 at- 开头的是 Personal Access Token(PAT,对应 PersonalAccessToken),其余输入一律进入 AgentIdentityJwt 分支。这个判断不是 JWT 解析,也不是远端验证;它只是决定后续调用哪套加载和持久化路径。

PAT 分支会调用 PersonalAccessTokenAuth::load,检查 workspace 允许范围,然后把 token 写进 AuthDotJson.personal_access_token。Agent Identity 分支则先确定 ChatGPT base URL,调用 verified_record_from_jwt,成功后写入 auth_mode: AgentIdentityagent_identity: AgentIdentityStorage::Jwt(jwt)。两个分支都调用同一个 save_auth,所以存储后端由 AuthCredentialsStoreMode 决定,而不是由 token 字符串决定。

这一步有一个可操作的结果:Agent Identity JWT 分支构造的 AuthDotJson 不带 tokensOPENAI_API_KEY。固定测试从空临时目录重新读取 auth.json,断言 auth_mode、JWT 原文,以及这两个字段均为空;同时 mock JSON Web Key Set(JWKS,验签公钥集合)endpoint 必须被命中一次。它验证的是这次写入的 payload 形状,不是“已有文件里的旧凭据一定被清理”的迁移测试。

固定实验命令在 disposable archive 中保留测试模块的默认 substring filter,不加 --exact

: "${ARCHIVE_CODEX_RS:?先执行第一部导读的 archive 准备脚本}"
cd "$ARCHIVE_CODEX_RS"
just test --locked -p codex-login login_with_access_token_writes_agent_identity_jwt

固定版本的 nextest 预期输出至少包含:

PASS codex-login login_with_access_token_writes_agent_identity_jwt
Summary: 1 test run, 1 passed

这个 test 证明的是本地 mock JWKS 和写入 payload 字段。它不证明真实 Auth API 的权限决策,不证明从 login 到模型服务的全链路 server authorization,也不证明“不带 JWKS 的 JWT decode”已经完成签名验证。

auth.json 里的 Jwt 与 Record 不是同一件事

AuthDotJson.agent_identity 的类型是 untagged enum:Jwt(String)Record(AgentIdentityAuthRecord)。JWT 形式保留登录时收到的 token;Record 形式则把可供运行时使用的身份材料展开出来。Record 至少包含 agent_runtime_idagent_private_keyaccount_idchatgpt_user_id、plan 和 FedRAMP(美国联邦云合规)标记,task_id 是可选字段。

这里的 durable 描述的是客户端身份材料的生命周期,不是 task 的生命周期。record 里保存 agent_runtime_id 与私钥,断言则另外带上当前 task 的 task_id 和 timestamp。固定 checkout 只证明客户端把这些字段分别放进 record 与断言;服务端如何解释、撤销或设置 time-to-live(TTL,有效期),不在本章证据范围内。把三者写成一个“Agent token”会丢掉系统最重要的范围边界。

四种 storage mode,四种故障面

File backend 从 $CODEX_HOME/auth.json 读写完整 AuthDotJson,保存时创建父目录、pretty-print JSON,并在 Unix 新建文件时请求 mode 0600;对已有文件,这次 OpenOptions 不会自动重新 chmod。Keyring backend 把序列化 payload 交给 encrypted secrets 或 direct keyring;Auto 先尝试 keyring,失败时回退文件;Ephemeral 使用进程内的全局 map,key 是 canonical codex_home 的 SHA-256 截断值。

因此,“JWT 已写入 auth.json”只描述 File mode 下的可见结果。生产运行可能从 keyring 或 ephemeral backend 读到同一份逻辑 payload;反过来,看到磁盘上没有文件,也不能直接推断当前进程未认证。调试存储故障时要先确认 mode 和 backend,再判断 auth payload 是否丢失。

四类身份输入的边界矩阵

输入 sourceauth mode / storagebootstrap / refreshrequest headerlifetime
Personal Access Token写盘 payload 是 auth_mode: None + personal_access_token: Some(...);运行时从该字段推导 PersonalAccessToken,backend 仍由 storage mode 选择不走 Agent Identity bootstrap;refresh 分支 no-opAuthorization: Bearer ...直到凭据被替换或 logout
直接登录的 Agent Identity JWTAgentIdentity;登录先写 agent_identity: Jwt,运行时从 claims 转成 record每次 raw JWT load 都从 task_id: None 进入 from_record 注册新 task;不走 ChatGPT refreshAuthorization: AgentAssertion ...JWT 与 durable key 是持久材料;task 按每次 load 注册,HTTP attempt 或 WebSocket handshake 生成 assertion
managed ChatGPTChatgpt 加 managed AgentIdentityStorage::Record按 policy、lock 和 cooldown bootstrap;ChatGPT token 可能 proactive refresh;失败可回退 bearer成功 bootstrap 后为 AgentAssertion ...,fallback 为 bearerrecord 与非空 task_id 可跨 run 复用;ChatGPT token 按过期/间隔刷新
provider API key / bearer tokenprovider 配置或普通 auth,不生成 Agent Identity record不走 Agent Identity bootstrap;按 provider auth 直接解析Authorization: Bearer ...随 provider 配置或当前 auth snapshot

这张表把“写盘字段”“运行时 auth mode”和“请求时带什么”分开了:PAT 的兼容性写法故意不把 auth_mode 写成 PersonalAccessToken,读取时才由 personal_access_token 推导出该运行时模式;同一份 AuthDotJson 还可能由 File、Keyring、Auto 或 Ephemeral backend 承载。第 6 章会从 provider API key / bearer 这一行和各行最终生成的 request header 继续追 provider 配置、模型目录与能力信息。

JWT payload decode 不等于身份验证

Agent Identity crate 把长期身份材料抽象为 AgentIdentityKey:runtime id 加 PKCS#8(私钥编码格式)的 base64 私钥。JWT claims 除了 agent_runtime_idagent_private_key,还带 issaud、时间、账号和 plan。这个结构解释了为什么 JWT 能携带 durable key,但也说明 JWT 本身不是每个 task 的授权票据。

decode_agent_identity_jwt(jwt, None) 的语义必须单独记住:它只拆 JWT 的 payload,校验三段格式、base64url 和 JSON。源码明确在 jwksNone 时走 payload decode;只有传入 JWKS,才读取 header 的 kid,从 trusted set 找 key,使用 RSA-SHA256(JWT 中记作 RS256),并要求固定 issuer 与 audience。换句话说,payload decode 可以帮助把 claims 转成 record,但不能证明签名来自受信 issuer。

这也是登录路径必须分两步的原因:AgentIdentityAuthRecord::from_agent_identity_jwt 的无 JWKS decode 只是把字段读出来;verified_record_from_jwt 随后请求 JWKS,再用 decode_agent_identity_jwt(jwt, Some(&jwks)) 做客户端侧的签名、issuer 和 audience 验证。若只做第一步,系统能得到一份结构正确却未完成这组客户端验证的身份记录。

从 durable key 到 task assertion

Agent Identity 的请求授权不把长期 JWT 原样塞给模型 API。task registration 先用 AgentIdentityKeyagent_runtime_id:timestamp 签名,再 POST 到 runtime-specific registration endpoint;只有成功响应才提供 task_id

raw Jwt storage load 会从 verified claims 构造 task_id: None 的 record。AgentIdentityAuth::from_jwt 每次都把它交给 from_record,因此每次 load 都注册新 task。managed AgentIdentityStorage::Record 不同:只要已有非空 task id,from_record 就可以复用;是否跨 run 保留这个 id,由 manager.rs 的 managed ChatGPT 分支按 should_persist 控制。

assertion 生成时才读取 auth snapshot 中的 task id。login_with_access_token 本身只做 verified record 和 raw Jwt 写入,不注册 task。

注册完成后,AgentAssertion 的附加时机取决于 transport。Responses 走 HTTP 时,每个实际发送 attempt 都会在 transport 前执行 auth.apply_auth;Agent Identity provider 因而重新运行断言生成逻辑,把带当前 timestamp 和 task id 的结果写进 Authorization。Responses 走 WebSocket 时,认证头只属于首次 connect 或断线 reconnect 的 HTTP upgrade。连接建立后,response.create 只是序列化成 text frame 发送,不会为这个 frame 重新组装或附加 Authorization

断言 envelope 包含 runtime id、task id、RFC 3339 格式的 timestamp 和对 runtime_id:task_id:timestamp 的签名,序列化为 URL-safe base64,前缀是 AgentAssertion 。源码能证明它是带当前时间戳的 task-scoped 断言;服务端是否把它当作短时证明,不在本章实验范围内。认证遥测也可能调用 add_auth_headers 探测 header 是否存在,但这个探测结果不会被装进已经建立连接上的 WebSocket frame。

断言签名的实现和 envelope 编码在同一模块:签名 payload 的三段字段顺序固定,序列化 map 也固定包含四个字段。读取 record 时只取 runtime id 与私钥构造 AgentIdentityKey,不会把 task id 当成 durable key 的一部分。

login/src/auth/agent_identity.rs 的注册函数还会把可重试错误包装出来,并从 record 的 runtime id/private key 构造 key。对 raw JWT 路径,这个位置是“每次从 claims 读取 task_id: None 并注册 task”的本地生命周期边界;对 managed Record 路径,非空 task_id 才能由后续 run 复用,是否把新 task_id 写回 managed record 由 manager.rs 决定,不应与最初的 login 写入 JWT 混为一个操作。

认证生命周期与刷新不是同一条路

AuthManager::agent_identity_auth 对已有 CodexAuth::AgentIdentity 直接返回 auth;API key、ChatGPT auth tokens、PAT、headers 和 Bedrock API key 本身不会直接返回 Agent Identity。ChatGPT auth 则可能按 policy 进入 managed bootstrap,复用匹配账号的 record,必要时注册 task 并持久化更新后的 record。上层公共方法先获取 self.agent_identity_lock,因此只在该实例内串行 bootstrap;cooldown 查询/记录另按 (account_id, authapi_base_url) 键控,用来抑制该实例内同一账号与端点在失败后的重复尝试。

AuthManager::auth 的 proactive refresh 判定只会进入带 ChatGPT token data 的分支;should_refresh_proactively 对其他 CodexAuth 直接返回 false。随后,refresh_token_from_authority_impl 只为 Chatgpt auth 取 refresh token;对 AgentIdentity、API key、PAT 等 auth 直接返回 Ok(())。Agent Identity 的断言仍在请求边界按当前 task 生成,不是由 ChatGPT refresh token 轮换出来的。

请求边界:Authorization、subagent、turn-state 各有归属

模型 provider 的 AgentIdentityAuthProvider 用当前 run 的 AgentIdentityAuth 取 record;每次 add_auth_headers 都调用 authorization_header_for_agent_task,把 AgentAssertion ... 放进 HTTP request 或 WebSocket upgrade 的 Authorization,并另外写入 ChatGPT-Account-ID 和可选 FedRAMP header。普通 BearerAuthProvider 则把 API key、PAT 或 ChatGPT token 变成 Bearer ...。两者都是 transport 边界的 auth provider,但凭据格式和生命周期完全不同。

codex-api 的 HTTP session endpoint 在执行普通 JSON 请求和流式请求时,都先把 request 交给 auth.apply_auth,再交给 transport。WebSocket 则在 connect/reconnect 时把 provider auth 加进 upgrade headers;复用同一连接的 response.create frame 不重复这一步。两种路径都说明 Authorization 属于 transport 边界,不是由 Responses body 或 turn metadata 代替。

Responses HTTP endpoint 先把 session-idthread-id 和额外 headers 合并;只有 SessionSource::SubAgent 才会额外映射成 x-openai-subagent。这个 header 只是 session source metadata,例如 reviewcompactcollab_spawn;它没有携带 runtime id、task id 或签名。

因此,SessionSource::SubAgent(...).agent_role 和 subagent path 属于会话控制面(第 32 章会展开),不是 Agent Identity。agent_roleThreadSpawn source 的可选字段,并可由 session source accessor 取出;本章不把它解释成认证材料。一个普通账号可以启动 subagent;一个带 Agent Identity 的请求也可以不属于 subagent。二者可能同时出现,但不能互相证明。

turn-state 更容易被误认成 auth。HTTP Responses 的 options 在 build_responses_options 中把 Some(&self.turn_state) 交给 build_responses_headers,所以 HTTP 请求可以带 x-codex-turn-state;WebSocket handshake 则显式传 turn_state=None,再另外加入 session/thread、attestation 和 WebSocket beta headers。两条 transport 分支都不在这段 helper 里生成 Authorization,认证仍由 endpoint 的 auth provider 负责。

一张图看完整请求链

flowchart LR
  accTitle: 登录身份到请求断言的认证链
  accDescr: 账号 token 经过 JWT 验证、durable key 和 task 注册生成 AgentAssertion;Bearer、subagent metadata 与 turn-state header 在请求边界保持分离。
  A[account access token] --> B{classify_codex_access_token}
  B -->|at- prefix| C[PAT in AuthDotJson]
  B -->|otherwise| D[Agent Identity JWT]
  D --> E[JWKS + kid + RS256 + iss/aud verify]
  E --> F[durable runtime id + private key]
  F --> G[register task]
  G --> H[task_id]
  F --> I[sign runtime_id:task_id:timestamp]
  H --> I
  I --> J[AgentAssertion Authorization]
  C --> K[Bearer Authorization]
  J --> L[model request headers]
  K --> L
  L --> M[session/thread/subagent headers]
  L --> N[x-codex-turn-state routing header]
  M -. metadata .-> O[transport]
  N -. routing state, not auth .-> O
  L --> O

图中 account access token -> JWT 不是所有登录方式都经过的转换:PAT 分支停在 Bearer auth,Agent Identity JWT 分支才进入 durable key 和 task assertion。turn-state 也被刻意画成 routing header,它不能替代 Authorizationx-openai-subagent 同样只是 session source metadata。

验证清单与证据边界

读这条链时可以按下面顺序做一次最小复核:

  1. 在固定 commit 导出的 archive 上运行 just test --locked -p codex-login login_with_access_token_writes_agent_identity_jwt,确认 nextest 实际运行且 Summary 显示 1 passed。
  2. 对照 auth_tests.rs 的 mock JWKS 命中,确认测试证明的是本地 JWKS + auth 写盘,不是生产 Auth API 的授权结果。
  3. 对照 agent-identity/src/lib.rs,确认 decode_agent_identity_jwt(jwt, None) 只有 payload decode;验证结论必须来自 JWKS、kid、RS256、issaud
  4. 对照 model-provider/src/auth.rscodex-api/src/endpoint/session.rs,确认 request-time Authorization 来自 auth provider;对照 core/src/client.rs,确认 turn-state 只参与额外 headers。

反向检查也很重要:本地 mock/JWKS+写盘测试不证明真实 Auth API、全链路 server authorization 或 JWT 在没有 JWKS 时已经验签。看到一个合法 JWT payload,看到一个 task_id,或者看到 SessionSource::SubAgent,都只能证明对应那一层的状态,不能向上推导出完整 Agent 身份。

第 6 章继续追 provider/model info:请求已经带上了谁的凭据之后,provider capability、模型信息和选择逻辑如何进入同一条运行链。下一章:Provider 与模型信息