ARTICLE · 1109561
Codex 源码-Agent Identity / Workload Identity 与 Attestation
Codex 源码解析系列
第 7 讲:Agent Identity / Workload Identity 与 Attestation
基于 OpenAI Codex 源码 · 2026-10-01
💡 本讲一句话:Codex 以托管 agent 身份无人值守运行时,"我是谁"不能再靠人的登录态——这一讲拆三条机器身份路径:Agent Identity 用 Ed25519 密钥对给每个请求现签断言(传输中无长效 token)、Workload Identity 拿企业 JWT assertion 换 ≤1h 的 access_token 并单飞缓存、Attestation 在 100ms 内向宿主进程要一个即时证明头。读完你会明白:机器凭证怎么出生、为什么不能重放、各自在哪里被验证。
一、为什么 Codex 需要"机器身份"
第 6 讲的登录流程解决的是人的身份:浏览器 OAuth、PKCE、keyring 存 token。但 Codex 还有另一类运行场景——托管 agent(app-server 背后跑的任务)、企业 CI/CD 里的批量执行、跨云工作负载。这些场景里没有人坐在终端前点"登录",凭证必须来自机器:要么服务端给这个 agent 发身份,要么企业的身份系统把 JWT assertion 塞进文件让 Codex 去换 token。
源码里这是三条独立但互补的路径,分布在五个位置:
🔹 agent-identity/src/lib.rs(1000 行):密钥生成、两级注册、逐请求签名、JWKS 验签——机器身份核心库🔹 login/src/auth/agent_identity.rs(601 行):bootstrap 编排 + 重试分类,把核心库接进登录体系🔹 workload-identity/src/{lib,exchange,assertion}.rs(约 500 行):assertion → token 交换 + 单飞缓存🔹 login/src/auth/workload_identity.rs(486 行):环境变量探测、主体锁定、进程级会话注册表🔹 core/src/attestation.rs(26 行)+ app-server/src/attestation.rs(220 行):attestation trait 与 app-server 实现
先看 Agent Identity 的起点——密钥从哪来。
二、一把 Ed25519 密钥对,三种用途
generate_agent_key_material()(agent-identity/src/lib.rs 第 425-446 行)是整条身份链的源头。注意它不是直接生成 32 字节种子,而是先采 64 字节随机数再经 SHA-512 派生:
📄 codex-rs/agent-identity/src/lib.rs (第 425-446 行)
pub fn generate_agent_key_material() -> Result<GeneratedAgentKeyMaterial> { // 生成 agent 身份密钥对:私钥本地保管,公钥上报服务端 let mut seed_material = [0u8; AGENT_IDENTITY_KEY_SEED_BYTES]; // 先准备 64 字节随机种子(常量=64),比 Ed25519 需要的 32 字节多一倍 OsRng.try_fill_bytes(&mut seed_material) // 用操作系统 CSPRNG 填充;身份密钥绝不允许降级到弱随机源 .context("failed to generate agent identity private key seed material")?; // 失败直接报错,错误信息写明意图便于排查 let mut digest = Sha512::new(); // SHA-512 输出 64 字节:一次派生同时喂饱 Ed25519 种子和 Curve25519 密钥 digest.update(AGENT_IDENTITY_KEY_DERIVATION_CONTEXT); // 混入固定上下文串 "codex-agent-identity-ed25519-v1"——将来换算法/版本时改串即可隔离旧派生结果 digest.update(seed_material); // 再混入随机种子,摘要即确定性派生结果 let digest = digest.finalize(); // 完成哈希计算 let mut secret_key_bytes = [0u8; 32]; // Ed25519 只取前 32 字节当签名种子 secret_key_bytes.copy_from_slice(&digest[..32]); // 截取摘要前半段作为私钥种子 let signing_key = SigningKey::from_bytes(&secret_key_bytes); // 从 32 字节种子构造 Ed25519 私钥(公钥由它确定性推出) let private_key_pkcs8 = signing_key.to_pkcs8_der() // PKCS#8 DER:标准密钥容器,任何语言/库都能解析 .context("failed to encode agent identity private key as PKCS#8")?; // 编码失败视为致命错误 Ok(GeneratedAgentKeyMaterial { // 返回两种形态:base64 私钥(落盘)+ SSH 公钥(上报服务端) private_key_pkcs8_base64: BASE64_STANDARD.encode(private_key.as_bytes()), // 私钥 base64 化后存进 auth record,随账号持久化复用 public_key_ssh: encode_ssh_ed25519_public_key(&signing_key.verifying_key()) // 公钥编码成 ssh-ed25519 格式——服务端按 SSH key 惯例存储和展示 })}为什么这样设计:三个细节值得停一下。其一,64→32 的派生而不是直接采 32:Ed25519 的种子同时被用来派生 Curve25519 解密密钥(后面解密加密 task_id 用),SHA-512 一次出 64 字节,两把"钥匙"同源但用途隔离。其二,AGENT_IDENTITY_KEY_DERIVATION_CONTEXT 这个带 v1 后缀的上下文串是版本隔离器——将来密钥格式升级时换掉这个常量,新旧派生结果立刻不兼容,不会出现"新代码解旧密钥"的静默错配。其三,公钥编码成 SSH wire format(ssh-ed25519 base64...)而不是裸 32 字节——服务端可以直接把它当 SSH key 展示、审计、比对,复用现成的运维心智模型。
🔑 一把密钥,三种用途:① Ed25519 签名——task 注册 + 每个请求的 AgentAssertion(第 3、4 节)② SHA-512 派生 Curve25519 解密——解开服务端加密返回的 task_id(第 3 节)③ 公钥转 SSH 格式上报——服务端注册后用它验签所有后续请求私钥全程不出本地;传输中出现的只有签名和密文。
三、两级注册:长效身份 + 一次性任务
身份分两层:agent runtime id(长效,跨运行复用)和 task_id(一次性,绑定单次 Codex run)。注册也分两步。第一步把公钥交给服务端:
📄 codex-rs/agent-identity/src/lib.rs (第 358-395 行,节选)
pub async fn register_agent_identity( // 把"这个 agent 运行时"注册到 OpenAI 身份服务,返回可复用的 runtime_id client: &HttpClient, // 复用调用方构造的 HTTP client(已带代理/CA 策略) agent_identity_authapi_base_url: &str, // auth API base URL:prod=auth.openai.com / staging=auth.api.openai.org access_token: &str, // ChatGPT 账号的 access token——注册动作本身要证明"这个账号授权了这个 agent" is_fedramp_account: bool, // FedRAMP 合规账号走独立路由,需要显式标记 key_material: &GeneratedAgentKeyMaterial, // 刚生成的密钥对:公钥上报、私钥留下 abom: AgentBillOfMaterials, // "物料清单":agent 版本 + harness id + 运行位置——服务端审计用 capabilities: Vec<String>, // 能力声明(如 responsesapi)——服务端据此决定给这个 agent 开哪些权限) -> Result<String> { let url = agent_registration_url(agent_identity_authapi_base_url); // 拼出 /v1/agent/register 端点 let request = RegisterAgentRequest { // 请求体:ABOM + SSH 公钥 + 能力列表,ttl=None 表示长期有效 abom, // 物料清单原样上报(build_abom 从 CARGO_PKG_VERSION + SessionSource 生成) agent_public_key: key_material.public_key_ssh.clone(), // 只上报公钥——私钥永远不出本地 capabilities, // 能力声明随注册固化,后续请求不再重复携带 ttl: None, // 不传 TTL:agent 身份是长效的,会过期的是 task 不是 identity }; let mut request_builder = client.post(&url) // POST 注册端点 .bearer_auth(access_token) // Bearer token 证明账号授权——没有有效登录态根本走不到这一步 .json(&request) // JSON 序列化请求体 .timeout(AGENT_REGISTRATION_TIMEOUT); // 15s 超时(常量)——注册在启动路径上,不能无限等 if is_fedramp_account { // FedRAMP 合规账号:加专用头走合规路由 request_builder = request_builder.header("X-OpenAI-Fedramp", "true"); // 服务端凭这个头把请求切到 FedRAMP 集群 } let response = request_builder.send().await // 发出请求 .with_context(|| format!("failed to send agent identity registration request to {url}"))? // 网络层失败带 URL 上下文,方便定位是哪个端点挂了 .error_for_status() // 非 2xx 直接转错误——注册失败没有静默降级空间 .with_context(|| format!("agent identity registration failed for {url}"))?; // 业务层失败同样带 URL Ok(response.agent_runtime_id) // 成功:返回服务端分配的 runtime_id,后续所有签名都锚定它}为什么这样设计:abom(Agent Bill of Materials)是安全审计里"物料清单"的用法——服务端拿到的是 agent_version + agent_harness_id + running_location 三元组(build_abom(),第 497-512 行:VSCode 来源标 codex-app,CLI/Exec/MCP 等一律标 codex-cli)。出了安全事件时,服务端能回答"是哪个版本、哪个宿主、什么 OS 上的 agent 干的"。ttl: None 则点明了两级模型:身份长效、任务短命——重连/重启不用重新注册身份,只需重新签 task。
第二步为本次运行注册 task。请求体只有两个字段:时间戳 + 对 "{runtime_id}:{timestamp}" 的 Ed25519 签名(sign_task_registration_payload(),第 306-313 行)——服务端凭已注册的公钥验签,就知道"持有私钥的这个 agent 在此刻发起了任务注册"。有意思的是返回路径:
📄 codex-rs/agent-identity/src/lib.rs (第 411-423 行)
pub fn decrypt_task_id_response( // 解密服务端返回的加密 task_id——只有持有私钥的 agent 自己能解开 key: AgentIdentityKey<'_>, // 密钥材料:runtime_id + PKCS#8 私钥(借用,不拷贝) encrypted_task_id: &str, // base64 密文:Curve25519 sealed box 格式) -> Result<String> { let signing_key = signing_key_from_private_key_pkcs8_base64(key.private_key_pkcs8_base64)?; // 从 PKCS#8 DER 还原 Ed25519 私钥(base64 → DER → SigningKey) let ciphertext = BASE64_STANDARD.decode(encrypted_task_id) // base64 解码出密文 .context("encrypted task id is not valid base64")?; // 不是合法 base64 直接报错,不猜 let plaintext = curve25519_secret_key_from_signing_key(&signing_key) // 从 Ed25519 私钥派生 Curve25519 解密密钥——同一把种子,两种用途(第 2 节) .unseal(&ciphertext) // sealed box 开盒:失败说明密文根本不是发给这把密钥的 .map_err(|_| anyhow::anyhow!("failed to decrypt encrypted task id"))?; // 解密失败统一成业务错误,不泄露底层细节 String::from_utf8(plaintext).context("decrypted task id is not valid UTF-8") // 明文必须是合法 UTF-8——task_id 是字符串标识符}为什么这样设计:服务端可以选择把 task_id 加密返回(task_id_from_register_task_response(),第 397-409 行:明文 taskId/encryptedTaskId 两种字段都兼容,还同时认 snake_case 和 camelCase——典型的"对服务端演进保持宽容")。加密路径的意义在于:task_id 本身是能力凭证(拿着它就能以该任务身份发请求),走明文意味着任何中间人都能截获并冒用;改成 sealed box 后,只有私钥持有者能解开,中间人看到的只是一团密文。而解密密钥由 Ed25519 私钥经 SHA-512 + clamp(curve25519_secret_key_from_signing_key(),第 542-550 行:secret[0] &= 248、secret[31] &= 127 | 64 是标准 Curve25519 clamping)派生——agent 不需要管理第二把密钥。
注册在启动路径上,网络抖动很常见。login/src/auth/agent_identity.rs 第 318-342 行的 retry_registration() 最多重试 3 次(MAX_AGENT_IDENTITY_BOOTSTRAP_ATTEMPTS = 3),但只重试可重试错误:is_retryable_registration_status()(agent-identity lib.rs 第 228-230 行)判定标准是 429 Too Many Requests 或任意 5xx,加上超时/连接失败;4xx 业务错误(比如账号没权限)重试一百次也没用,直接 fail loud。最终仍失败则包装成 BootstrapUnavailable { operation, attempts, message }——错误信息里带操作名和尝试次数,用户一眼知道卡在哪一步。
四、逐请求签名:传输中永远没有长效 token
身份注册完成后,真正的高频路径是每个 API 请求的认证头。authorization_header_for_agent_task()(第 232-245 行):
📄 codex-rs/agent-identity/src/lib.rs (第 232-245 行)
pub fn authorization_header_for_agent_task( // 为单个请求生成 Authorization 头:每次现签,不缓存任何 token key: AgentIdentityKey<'_>, // runtime_id + 私钥(借用视图) task_id: &str, // 本次运行的 task id——把"哪个 agent 的哪次运行"绑进签名) -> Result<String> { let timestamp = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true); // RFC3339 秒级时间戳:参与签名,服务端用它做重放窗口校验 let envelope = AgentAssertionEnvelope { // 断言信封:四个字段全部随请求传输,其中三个被签名覆盖 agent_runtime_id: key.agent_runtime_id.to_string(), // 哪个 agent(长效身份) task_id: task_id.to_string(), // 哪次运行(一次性任务)——身份和任务同时绑定进签名 timestamp: timestamp.clone(), // 何时发起——防重放的关键字段,服务端可拒绝窗口外的旧断言 signature: sign_agent_assertion_payload(key, task_id, ×tamp)?, // Ed25519 签 "{runtime_id}:{task_id}:{timestamp}"(第 521-529 行) }; let serialized_assertion = serialize_agent_assertion(&envelope)?; // JSON 序列化(BTreeMap 保证字段序稳定)→ base64url 无填充编码 Ok(format!("AgentAssertion {serialized_assertion}")) // 自定义 scheme "AgentAssertion" + 断言——服务端按此解析并验签}为什么这样设计:对比第 6 讲的 ChatGPT Bearer token:那条路径里,一个长效 access_token 在每次请求的 Authorization 头里原样出现——token 泄露 = 身份泄露。Agent Identity 反过来:私钥不出本地,传输中只有签名;即使某个断言被完整截获,攻击者拿到的也只是"某 agent 在某时刻对某 task 的一次签名",时间戳过期后作废,且无法用它推导出任何长效凭证。代价是每次请求多一次 Ed25519 签名——但 Ed25519 签名只要几微秒级,相对网络 RTT 完全可以忽略。
消费端在 model-provider/src/auth.rs(第 87-113 行),AgentIdentityAuthProvider::add_auth_headers() 把三个头一起塞进请求:
📄 codex-rs/model-provider/src/auth.rs (第 87-113 行)
impl AuthProvider for AgentIdentityAuthProvider { // Agent 身份走独立的 AuthProvider——与 ChatGPT Bearer token 完全平行的另一条认证路径 fn add_auth_headers(&self, headers: &mut HeaderMap) { // 每次请求发出前调用,往 HeaderMap 里塞认证头 let record = self.auth.record(); // 取出持久化的身份记录(runtime_id + 私钥 + 账号信息) let header_value = authorization_header_for_agent_task( // 现场生成 AgentAssertion——每个请求一个新鲜时间戳+签名 AgentIdentityKey { // 借用式密钥视图:不拷贝私钥字符串,零额外分配 agent_runtime_id: &record.agent_runtime_id, // 长效身份 id private_key_pkcs8_base64: &record.agent_private_key, // PKCS#8 私钥(base64) }, self.auth.run_task_id(), // 本次运行的 task id——构造时保证非空,这里直接取 ).map_err(std::io::Error::other); // 签名失败转 io error;注意下面会静默跳过而不是炸掉整个请求 if let Ok(header_value) = header_value && let Ok(header) = HeaderValue::from_str(&header_value) { // 双保险:生成成功且是合法 HTTP 头值才写入 let _ = headers.insert(http::header::AUTHORIZATION, header); // Authorization: AgentAssertion <base64url>——核心认证头 } if let Ok(header) = HeaderValue::from_str(self.auth.account_id()) { // ChatGPT-Account-ID:告诉服务端这笔请求归属哪个账号(计费/配额锚点) let _ = headers.insert("ChatGPT-Account-ID", header); // 身份断言之外再给一个粗粒度账号标识,方便服务端快速路由 } if self.auth.is_fedramp_account() { // FedRAMP 合规账号:每个请求都带标记,持续走合规集群 let _ = headers.insert("X-OpenAI-Fedramp", HeaderValue::from_static("true")); // 静态值用 from_static——避免每次请求做字符串分配 } }}为什么这样设计:三个头各司其职:Authorization 回答"你是谁、哪次运行"(密码学证明),ChatGPT-Account-ID 回答"为谁计费"(业务路由),X-OpenAI-Fedramp 回答"走哪个合规集群"(基础设施路由)。注意签名失败时的处理——静默跳过而不是 panic:认证头生成是纯本地计算,理论上不该失败;真失败了(比如私钥文件损坏)让请求裸奔比让整个进程崩掉更可控,服务端会因缺认证直接拒绝,错误反而更清晰。另外 AgentIdentityAuth::run_task_id()(login/agent_identity.rs 第 177-182 行)用 unreachable! 断言 task_id 必然存在——构造路径上已经强制注册过 task,这里只是文档化的不变量。
五、Workload Identity:企业联邦的 token 交换
第二条路径面向企业场景:身份系统(比如公司的 IdP)把 JWT assertion 写进文件,Codex 拿它去 auth.openai.com/oauth/token 换 access_token。入口是三个环境变量,login/src/auth/workload_identity.rs:
📄 codex-rs/login/src/auth/workload_identity.rs (第 123-129、252-263 行)
/// Returns whether workload identity was selected through process configuration. // 文档注释:workload identity 是否被进程级配置选中pub fn is_workload_identity_selected() -> bool { // 登录管理器启动时问这一句——决定走哪条凭证路径(fail loud,不静默回退) ProcessEnvironment::read().has_marker() // 读环境变量并检查"标记位"是否存在}impl ProcessEnvironment { // 进程环境快照:三个变量一次性读进来,避免多次 env 读取之间被外部修改导致不一致 fn read() -> Self { // 从 std::env 抓取三个 workload identity 相关变量(OsString 容忍非 UTF-8 值) Self { federation_rule_id: std::env::var_os(OPENAI_FEDERATION_RULE_ID_ENV_VAR), // OPENAI_FEDERATION_RULE_ID:联邦规则 id——服务端据此匹配信任策略 identity_token_file: std::env::var_os(OPENAI_IDENTITY_TOKEN_FILE_ENV_VAR), // OPENAI_IDENTITY_TOKEN_FILE:JWT assertion 文件路径(由外部身份系统写入/轮换) workload_identity_context: std::env::var_os(OPENAI_WORKLOAD_IDENTITY_CONTEXT_ENV_VAR), // 可选上下文串:附加审计信息,非必填 } } fn has_marker(&self) -> bool { // "任一核心标记存在"即选中——部分配置会在后续校验阶段 fail loud(第 131-168 行 resolve_config) self.federation_rule_id.is_some() || self.identity_token_file.is_some() // 两个核心变量有一个就算启用;只设 context 不算数,防止误触发 }}为什么这样设计:has_marker() 的注释写得很直白——"部分配置会在验证阶段失败,而不是回退到另一个凭证源"。这是安全系统的关键纪律:显式选择了 workload identity 就必须成功或报错,绝不能悄悄退回 API key 或浏览器登录——那会让企业以为自己在用联邦身份,实际却在用另一条(可能不合规的)路径。另外 resolve_config()(第 131-168 行)还强制要求 base_url 是白名单内的 prod/staging 域名(classify_auth_environment(),第 215-239 行),token 端点只允许 https://auth.openai.com/oauth/token——防止把 assertion 这种高价值凭证 POST 到攻击者控制的地址。
assertion 文件的读取(workload-identity/src/assertion.rs,全文 35 行)看似简单,每个校验都有动机:
📄 codex-rs/workload-identity/src/assertion.rs (第 10-35 行)
/// Reopens the assertion file for each exchange so its owner can rotate the credential. // 文档注释:每次交换都重新打开文件——让外部系统能随时轮换凭证而无需重启 Codexpub(crate) async fn read_assertion(path: &Path) -> Result<String, WorkloadIdentityError> { // 读取 JWT assertion 文件,带大小和内容双重校验 let file = tokio::fs::File::open(path).await.map_err(|source| { // 异步打开;失败时把路径和原始错误一起包进类型化错误 WorkloadIdentityError::AssertionFile { path: path.to_path_buf(), source: source.into() } // 调用方可以区分"文件不存在/不可读"和其他 IO 问题,给出准确提示 })?; let mut bytes = Vec::new(); // 缓冲读取的字节 file.take(MAX_ASSERTION_BYTES + 1) // take(16KB+1):多读 1 字节用来探测超限——比读完整个文件再判断省内存 .read_to_end(&mut bytes).await.map_err(|source| WorkloadIdentityError::AssertionFile { path: path.to_path_buf(), source: source.into() })?; // 读到上限即停;IO 错误同样类型化包装 if bytes.len() as u64 > MAX_ASSERTION_BYTES { // 超过 16KB 直接拒绝——assertion 是 JWT,正常远小于此,超限说明文件被污染或配错路径 return Err(WorkloadIdentityError::AssertionTooLarge); // 专用错误变体:只报"太大",不泄露文件内容 } let assertion = String::from_utf8(bytes).map_err(|_| WorkloadIdentityError::InvalidAssertion)?; // 必须是合法 UTF-8——JWT 是纯文本格式 let assertion = assertion.trim(); // 去掉首尾空白(文件末尾换行很常见,POST 前必须清掉) if assertion.is_empty() || assertion.as_bytes().contains(&0) { // 空内容或含 NUL 字节都拒绝——NUL 说明混进了二进制垃圾 return Err(WorkloadIdentityError::InvalidAssertion); // 统一归为 InvalidAssertion,不区分细节避免给攻击者探测面 } Ok(assertion.to_string()) // 校验通过:返回干净的 assertion 字符串交给交换流程}为什么这样设计:"每次重新打开文件"是轮换友好性的关键——企业 IdP 可以原子替换 assertion 文件(写新文件 + rename),Codex 下一次交换自动拿到新凭证,全程无重启。take(N+1) 的读法是 Rust 里防大文件的惯用法:流式读取、到上限即停,不会把几个 GB 的错误文件整个拉进内存。NUL 字节检查则是针对"配错了路径指向二进制文件"这类真实事故的防御。
交换本身(exchange.rs 第 172-221 行)是标准 OAuth JWT bearer grant:grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer + assertion + federation_rule_id POST 到 token 端点,响应体限 1MB、逐 chunk 累加防超限。真正精彩的是缓存层——CachedToken(第 269-297 行):
📄 codex-rs/workload-identity/src/exchange.rs (第 275-297 行)
impl CachedToken { // 缓存的 token + 两个时间锚点:何时必须刷新、何时彻底过期 fn new(token: WorkloadIdentityToken, valid_from: Instant) -> Self { // 用"生效时刻"为基准计算两个时间点(单调时钟,不受系统时间回拨影响) let lifetime = Duration::from_secs(token.expires_in); // 服务端声明的 token 寿命(秒) let refresh_margin = std::cmp::min(Duration::from_secs(120), lifetime / 2); // 提前刷新窗口:最多 120s,且不超过寿命的一半——短命 token 也要留足余量 Self { expires_at: valid_from + lifetime, // 硬过期点:过了这个时刻 token 一律作废,必须重新交换 refresh_at: valid_from + lifetime.saturating_sub(refresh_margin), // 软刷新点:到点就主动换新,不等下游拒绝才被动刷新 token, // token 本体(access_token / account_id / user_id / plan_type) } } fn token_at(&self, now: Instant) -> Option<WorkloadIdentityToken> { // 按"当前时刻"取可用 token——过期返回 None,未过期返回修正过 expires_in 的副本 let remaining = self.expires_at.checked_duration_since(now)?; // 已过期 → checked 运算返回 None → ? 直接短路传播(优雅失败) if remaining.is_zero() { // 边界情况:恰好到期也视为不可用——宁早勿晚,不给竞态留缝 return None; // 强制走重新交换路径 } let mut token = self.token.clone(); // 克隆一份再改——缓存本体保持原样,并发调用方拿到一致的快照 token.expires_in = remaining.as_secs().saturating_add(u64::from(remaining.subsec_nanos() != 0)); // expires_in 改成"剩余秒数(向上取整)"——下游按这个值做自己的过期判断不会超发 Some(token) // 返回修正后的 token:同一个凭证,但寿命字段反映真实剩余时间 }}为什么这样设计:token_at() 里改写 expires_in 是个容易被忽略的细节:缓存的 token 是"出生时"的快照,但下游(比如 model-provider)拿到后还要用它做自己的过期判断——如果继续报原始寿命,下游会在真实过期点之后才发现问题。改成剩余秒数后,每个消费方看到的都是同一时刻的真实余量。而 resolve()(第 77-119 行)用一把 tokio Mutex 实现单飞(single-flight):并发调用者里只有第一个真正发 HTTP,其余等锁后直接读缓存;瞬时失败时不立即重试,而是把 refresh_at 推迟 30s(TRANSIENT_FAILURE_RETRY_DELAY),期间继续用未过期的旧 token——故障期间既不打爆 token 端点,也不让请求全挂。更狠的是主体锁定:accept_subject()(workload_identity.rs 第 296-319 行)要求进程内所有 token 必须属于同一个 account/user,中途换账号直接 InvalidExchangeResponse;配合进程级注册表 WorkloadIdentitySessionRegistry(第 322-357 行)用配置指纹拒绝"同一进程里两套 workload identity 配置并存"——防止凭证混淆攻击:攻击者若能让 Codex 同时持有 A、B 两个账号的 token,就可能把 B 的请求路由到 A 的配额/权限上。
六、Attestation:向宿主进程要"即时证明"
第三条路径最轻量,也最能体现 Codex 的架构分层。core crate 里只有 26 行——一个 trait(core/src/attestation.rs):
📄 codex-rs/core/src/attestation.rs (第 20-26 行)
/// Host integration boundary for just-in-time attestation header values. // 文档注释:宿主集成边界——"要不要带 attestation、值是什么"由宿主决定,core 只留接口pub trait AttestationProvider: std::fmt::Debug + Send + Sync { // trait 要求 Debug+Send+Sync:可跨线程共享、可被日志打印(实现方负责不泄露敏感字段) fn header_for_request(&self, context: AttestationContext) -> GenerateAttestationFuture<'_>; // 每次上游请求前调用一次,返回 future——实现方自己决定同步还是异步取 token}为什么这样设计:core 是引擎层,它不知道宿主是谁(CLI?app-server?IDE 插件?)——attestation 的"值从哪来"完全是宿主的事。所以 core 只定义边界:给一个 AttestationContext { thread_id },还你一个 Option<HeaderValue>(x-oai-attestation 头的值)。返回 future 而不是同步值,是因为 app-server 的实现要跨进程 RPC。没有实现时返回 None——请求照常发出,只是少一个头。
app-server 的实现(app-server/src/attestation.rs)把"向客户端要 token"做成了一个带超时的 RPC:
📄 codex-rs/app-server/src/attestation.rs (第 44-61、151-170 行)
impl AttestationProvider for AppServerAttestationProvider { // app-server 的实现:向"有 attestation 能力的客户端连接"要一个即时 token fn header_for_request(&self, context: AttestationContext) -> GenerateAttestationFuture<'_> { // 返回 future,不阻塞请求准备路径 let Some(outgoing) = self.outgoing.upgrade() else { // Weak 引用升级失败说明发送器已销毁——直接放弃 attestation return Box::pin(async { None }); // 优雅降级:没有 attestation 头,但请求照常发出(core 的契约就是 Option) }; let thread_state_manager = self.thread_state_manager.clone(); // 克隆线程状态管理器(内部 Arc),用于查"哪个连接能出 token" Box::pin(async move { // 异步块:真正发起 RPC + 超时控制 request_attestation_header_value_with_timeout( // 带超时的 attestation 请求封装(100ms,常量 ATTESTATION_GENERATE_TIMEOUT) outgoing, thread_state_manager, context.thread_id, ATTESTATION_GENERATE_TIMEOUT, // 参数:发送器 / 线程状态 / 目标线程 id / 超时时长 ).await.and_then(|value| HeaderValue::from_bytes(value.as_bytes()).ok()) // 拿到字符串后转 HTTP header;非法字节序列 → None(静默降级,不炸请求) }) }}#[derive(Serialize)] // attestation 头的 JSON 信封——成功失败都发,让服务端能区分"没有 attestation"和"attestation 失败了"struct AppServerAttestationEnvelope<'a> { // v=协议版本 s=状态码 t=token(可选)——三个字段全部单字母,头越小越好 v: u8, // 协议版本号:当前固定 1,将来信封结构变化时递增,服务端按版本解析 s: u8, // 状态码:0=成功 1=超时 2=请求失败 3=被取消 4=响应畸形——服务端据此做风控/审计(第 139-149 行) #[serde(skip_serializing_if = "Option::is_none")] // token 为 None 时整个字段不出现——失败信封更短,也避免空串歧义 t: Option<&'a str>, // 成功时的 attestation token(opaque payload:app-server 不理解内容,服务端自己解析)}为什么这样设计:两个决策很见功力。第一,失败也发头:超时/取消/畸形响应都序列化成 {"v":1,"s":N}(无 token 字段)——服务端能区分"客户端没装 attestation 能力"和"装了但这次失败了",后者是更强的风控信号。第二,100ms 超时 + Weak 引用:attestation 在请求主路径上,绝不能因为宿主进程卡住而拖死所有 API 调用;Weak 升级失败则说明 app-server 正在关闭,此时发 RPC 毫无意义。另外第 85-86 行有个安全细节——日志里只记错误码不记 err.message,注释明说"message 可能含 token":attestation token 是高价值凭证,任何日志路径都不能碰它。
七、三条身份机制对照
| Agent Identity | ||
| Workload Identity | ||
| Attestation |
三者的关系不是竞争而是叠加:一个托管 agent 可以同时用 Agent Identity 证明"我是谁"、用 Workload Identity 的 token 访问模型 API(企业场景)、每个请求再带 Attestation 头供服务端做实时风控。第 6 讲的人用登录态是第四条路径——四条路最终都收敛到 CodexAuth 枚举,对上层完全透明。
八、数据流:两条身份链的完整走位
Agent Identity bootstrap 路径(托管 agent)
① 密钥生成:generate_agent_key_material
64B CSPRNG 种子 → SHA-512(上下文串+种子) → Ed25519 密钥对;公钥转 SSH 格式。
▼
② agent 注册:register_agent_identity
ABOM + SSH 公钥 + capabilities POST /v1/agent/register,换长效 runtime_id。
▼
③ task 注册:register_agent_task
私钥签 "runtime_id:timestamp",换一次性 task_id(可加密返回,Curve25519 unseal)。
▼
④ 逐请求断言:authorization_header_for_agent_task
每请求现签 AgentAssertion + ChatGPT-Account-ID(+FedRamp 头),传输中无长效 token。
Workload Identity 交换路径(企业联邦)
① 环境标记探测:is_workload_identity_selected
OPENAI_FEDERATION_RULE_ID / IDENTITY_TOKEN_FILE 任一存在即选中;部分配置 fail loud。
▼
② assertion 读取:read_assertion
每次交换重开文件(支持轮换);≤16KB、UTF-8、无 NUL,否则拒绝。
▼
③ token 交换 + 缓存:WorkloadIdentityExchange::resolve
JWT bearer grant POST /oauth/token;单飞 Mutex,提前 120s 刷新,瞬时失败推迟 30s。
▼
④ 主体校验:accept_subject
account/user 必须与进程内首个 token 一致,中途换账号直接拒绝——防凭证混淆。
📚 系列导航
← 第 6 讲:Login/Auth 登录与凭证管理
→ 第 8 讲:Session 管理与 TurnContext
关注公众号「AI技术推荐官」获取更多源码解析内容