refactor(openai): remove agent identity support

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-08-27 13:27:10 +08:00
co-authored by Codex
parent 3920624862
commit 5ea3740de3
26 changed files with 199 additions and 1941 deletions
+2 -2
View File
@@ -27,7 +27,7 @@
| Provider | 固定 `openai`、`xai` 两个 Provider,各自拥有 OAuth、credential、额度、目录与 transport |
| 路由 | Client Key 限定账号分组;按模型能力编译 Provider 候选;支持全局精确模型映射、会话亲和和安全 fallback |
| 透明边界 | OpenAI 保留未知请求字段及字段顺序,并原样转发 SSE、WebSocket 与 Images 业务字节;xAI 在 Provider 内转换协议 |
| 账号 | OAuth、OpenAI AT/RT、账号文件与 Agent Identity 导入,credential/额度刷新、账号恢复及主动额度重置卡 |
| 账号 | OAuth、OpenAI AT/RT 与账号文件导入,credential/额度刷新、账号恢复及主动额度重置卡 |
| 管理 | 账号与分组、Client Key、运行设置、用量/错误观测、S3/R2 备份、系统更新与回滚 |
| 计量 | Token、图片、费用、延迟、账号与 Provider 归因,并记录上游响应确认的实际 `service_tier` |
@@ -90,7 +90,7 @@ curl -i http://127.0.0.1:8080/healthz
### 3. 初始化网关
1. 使用 `admin@cpr.local` 与初始密码登录。
2. 导入或授权 Provider 账号:OpenAI 支持 OAuth、逐行 AT/RT、OAuth JSON 与 Agent Identity;
2. 导入或授权 Provider 账号:OpenAI 支持 OAuth、逐行 AT/RT 与 OAuth JSON;
xAI 支持 OAuth 和账号 JSON。
3. 创建账号分组并按需配置全局模型映射。未命中的模型名原样交给候选 Provider。
4. 创建 `sk_...` Client Key,选择可用账号分组,并设置速率和并发限制;空分组范围表示全部账号。
-179
View File
@@ -8,16 +8,6 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aead"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
dependencies = [
"crypto-common 0.1.7",
"generic-array",
]
[[package]]
name = "aho-corasick"
version = "1.1.4"
@@ -721,17 +711,6 @@ dependencies = [
"phf 0.12.1",
]
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common 0.1.7",
"inout",
"zeroize",
]
[[package]]
name = "cmake"
version = "0.1.58"
@@ -961,7 +940,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"rand_core 0.6.4",
"typenum",
]
@@ -976,36 +954,6 @@ dependencies = [
"rand_core 0.10.1",
]
[[package]]
name = "crypto_box"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16182b4f39a82ec8a6851155cc4c0cda3065bb1db33651726a29e1951de0f009"
dependencies = [
"aead",
"blake2",
"crypto_secretbox",
"curve25519-dalek 4.1.3",
"salsa20",
"subtle",
"zeroize",
]
[[package]]
name = "crypto_secretbox"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d6cf87adf719ddf43a805e92c6870a531aedda35ff640442cbaf8674e141e1"
dependencies = [
"aead",
"cipher",
"generic-array",
"poly1305",
"salsa20",
"subtle",
"zeroize",
]
[[package]]
name = "ctutils"
version = "0.4.2"
@@ -1016,48 +964,6 @@ dependencies = [
"subtle",
]
[[package]]
name = "curve25519-dalek"
version = "4.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"curve25519-dalek-derive",
"fiat-crypto 0.2.9",
"rustc_version",
"subtle",
"zeroize",
]
[[package]]
name = "curve25519-dalek"
version = "5.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
"curve25519-dalek-derive",
"digest 0.11.3",
"fiat-crypto 0.3.0",
"rustc_version",
"subtle",
"zeroize",
]
[[package]]
name = "curve25519-dalek-derive"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "data-encoding"
version = "2.11.0"
@@ -1169,31 +1075,6 @@ dependencies = [
"zeroize",
]
[[package]]
name = "ed25519"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a"
dependencies = [
"pkcs8",
"signature 3.0.0",
]
[[package]]
name = "ed25519-dalek"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de"
dependencies = [
"curve25519-dalek 5.0.0",
"ed25519",
"serde",
"sha2 0.11.0",
"signature 3.0.0",
"subtle",
"zeroize",
]
[[package]]
name = "either"
version = "1.16.0"
@@ -1295,18 +1176,6 @@ dependencies = [
"subtle",
]
[[package]]
name = "fiat-crypto"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
[[package]]
name = "fiat-crypto"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24"
[[package]]
name = "filetime"
version = "0.2.29"
@@ -1631,7 +1500,6 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
"zeroize",
]
[[package]]
@@ -2073,15 +1941,6 @@ dependencies = [
"hashbrown 0.17.1",
]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"generic-array",
]
[[package]]
name = "ipnet"
version = "2.12.0"
@@ -2318,12 +2177,6 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "opaque-debug"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "openssl-probe"
version = "0.2.1"
@@ -2498,17 +2351,6 @@ version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
[[package]]
name = "poly1305"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
dependencies = [
"cpufeatures 0.2.17",
"opaque-debug",
"universal-hash",
]
[[package]]
name = "potential_utf"
version = "0.1.5"
@@ -2579,8 +2421,6 @@ dependencies = [
"bytes",
"chrono",
"cookie",
"crypto_box",
"ed25519-dalek",
"flate2",
"futures",
"gateway-admin",
@@ -3049,15 +2889,6 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "salsa20"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213"
dependencies = [
"cipher",
]
[[package]]
name = "schannel"
version = "0.1.29"
@@ -4046,16 +3877,6 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
[[package]]
name = "universal-hash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
dependencies = [
"crypto-common 0.1.7",
"subtle",
]
[[package]]
name = "untrusted"
version = "0.7.1"
@@ -14,8 +14,6 @@ base64 = "0.23.0"
bytes = "1.11.1"
chrono = { version = "0.4.45", features = ["clock", "serde"] }
cookie = "0.18.1"
crypto_box = { version = "0.9.1", features = ["seal"] }
ed25519-dalek = { version = "3.0.0", features = ["alloc", "pkcs8"] }
futures = "0.3.32"
flate2 = "1.1.9"
gateway-admin = { path = "../../gateway-admin" }
@@ -12,7 +12,7 @@ use chrono::{DateTime, FixedOffset, Utc};
use gateway_core::engine::credential::{
AccountErrorReason, CredentialCasUpdate, CredentialRevision, CredentialState, LoadedCredential,
NewProviderAccount, ProviderAccount, ProviderAccountId, ProviderAccountIdentity,
ProviderAccountUpdate, QuotaEvidence, QuotaState,
ProviderAccountUpdate, QuotaState,
};
use gateway_core::provider_ports::{
ProviderLeaseAcquisition, ProviderLeaseGuard, ProviderLeasePort, ProviderLeaseRequest,
@@ -28,14 +28,12 @@ use super::recovery_log::{CodexOAuthRecoveryOperation, record_oauth_recovery};
use super::security::CodexCredentialCodec;
use super::token_client::{RefreshFailure, TokenRefresher};
use super::types::{
CODEX_AUTHENTICATION_KIND_AGENT_IDENTITY, CODEX_AUTHENTICATION_KIND_OAUTH, CodexAccountProfile,
CodexAgentIdentityAuthMode, CodexAgentIdentityCredentialData, CodexCredentialData,
CODEX_AUTHENTICATION_KIND_OAUTH, CodexAccountProfile, CodexCredentialData,
CodexCredentialPrincipal, CodexOAuthMetadata, CodexOAuthSecret, parse_access_token_expiration,
parse_chatgpt_jwt_claims,
};
const PROVIDER_NAME: &str = "openai";
const CODEX_CREDENTIAL_SCHEMA_VERSION: u32 = 1;
const MAX_BATCH: usize = 200;
const MAX_IMPORT_DOCUMENT_BYTES: usize = 64 * 1024 * 1024;
@@ -121,56 +119,31 @@ impl fmt::Debug for PreparedCodexAccountImport {
}
struct ParsedCodexImportAccount {
id: Option<String>,
name: Option<String>,
email: Option<String>,
plan_type: Option<String>,
chatgpt_account_id: Option<String>,
chatgpt_user_id: Option<String>,
authentication: ParsedCodexAuthentication,
status: Option<String>,
}
enum ParsedCodexAuthentication {
OAuth {
access_token: Option<String>,
refresh_token: Option<String>,
id_token: Option<String>,
},
AgentIdentity {
runtime_id: String,
private_key: String,
task_id: Option<String>,
},
struct ParsedCodexAuthentication {
access_token: Option<String>,
refresh_token: Option<String>,
id_token: Option<String>,
}
impl fmt::Debug for ParsedCodexAuthentication {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::OAuth {
access_token,
refresh_token,
id_token,
} => formatter
.debug_struct("OAuth")
.field("access_token", &access_token.as_ref().map(|_| "<redacted>"))
.field(
"refresh_token",
&refresh_token.as_ref().map(|_| "<redacted>"),
)
.field("id_token", &id_token.as_ref().map(|_| "<redacted>"))
.finish(),
Self::AgentIdentity {
runtime_id: _,
private_key: _,
task_id,
} => formatter
.debug_struct("AgentIdentity")
.field("runtime_id", &"<redacted>")
.field("private_key", &"<redacted>")
.field("task_id", &task_id.as_ref().map(|_| "<redacted>"))
.finish(),
}
formatter
.debug_struct("OAuth")
.field(
"access_token",
&self.access_token.as_ref().map(|_| "<redacted>"),
)
.field(
"refresh_token",
&self.refresh_token.as_ref().map(|_| "<redacted>"),
)
.field("id_token", &self.id_token.as_ref().map(|_| "<redacted>"))
.finish()
}
}
@@ -178,20 +151,9 @@ impl fmt::Debug for ParsedCodexImportAccount {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("ParsedCodexImportAccount")
.field("id", &self.id)
.field("name", &self.name)
.field("email", &self.email.as_ref().map(|_| "<redacted>"))
.field("plan_type", &self.plan_type)
.field(
"chatgpt_account_id",
&self.chatgpt_account_id.as_ref().map(|_| "<redacted>"),
)
.field(
"chatgpt_user_id",
&self.chatgpt_user_id.as_ref().map(|_| "<redacted>"),
)
.field("authentication", &self.authentication)
.field("status", &self.status)
.finish()
}
}
@@ -222,7 +184,7 @@ impl fmt::Debug for ExportManagedCodexCredential {
#[serde(rename_all = "camelCase")]
pub struct CodexCprExportDocument {
source_format: &'static str,
accounts: Vec<CodexCprExportAccount>,
accounts: Vec<CodexCprOAuthExportAccount>,
}
impl CodexCprExportDocument {
@@ -277,25 +239,6 @@ struct CodexCprOAuthExportAccount {
access_token_expires_at: Option<String>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct CodexCprAgentIdentityExportAccount {
#[serde(flatten)]
common: CodexCprExportCommon,
auth_mode: &'static str,
agent_runtime_id: String,
agent_private_key: String,
#[serde(skip_serializing_if = "Option::is_none")]
task_id: Option<String>,
}
#[derive(Serialize)]
#[serde(untagged)]
enum CodexCprExportAccount {
OAuth(CodexCprOAuthExportAccount),
AgentIdentity(CodexCprAgentIdentityExportAccount),
}
/// App 已从 Store 读取的当前账号、revision 与明文 Provider JSON。
pub struct RotateManagedCodexCredential {
pub current: LoadedCredential,
@@ -567,7 +510,7 @@ impl CodexCredentialAdmin {
{
return Err(CodexCredentialAdminError::InvalidCredential);
}
CodexCprExportAccount::OAuth(CodexCprOAuthExportAccount {
CodexCprOAuthExportAccount {
common,
access_token: data.access_token,
refresh_token: data.refresh_token,
@@ -576,22 +519,7 @@ impl CodexCredentialAdmin {
.access_token_expires_at()
.map(DateTime::<Utc>::from)
.map(|value| value.to_rfc3339()),
})
}
CodexCredentialData::AgentIdentity(data) => {
if account.authentication_kind() != CODEX_AUTHENTICATION_KIND_AGENT_IDENTITY
|| account.has_refresh_token()
|| account.access_token_expires_at().is_some()
{
return Err(CodexCredentialAdminError::InvalidCredential);
}
CodexCprExportAccount::AgentIdentity(CodexCprAgentIdentityExportAccount {
common,
auth_mode: "agentIdentity",
agent_runtime_id: data.agent_runtime_id,
agent_private_key: data.agent_private_key,
task_id: data.task_id,
})
}
};
accounts.push(exported);
@@ -861,7 +789,7 @@ impl CodexCredentialAdminService {
Ok(prepared)
}
/// 归一导入 OAuth/agent-identity 凭据到唯一 `NewProviderAccount` 写入路径。
/// 归一导入 OAuth 凭据到唯一 `NewProviderAccount` 写入路径。
///
/// OAuth 导入先取得 access token(直接提供或 RT exchange),再按官方
/// `parse_chatgpt_jwt_claims` 从 ID token/access token 本地投影账号资料。
@@ -880,101 +808,57 @@ impl CodexCredentialAdminService {
if candidates.is_empty() || candidates.len() > MAX_BATCH {
return Err(CodexCredentialAdminError::InvalidInput);
}
let mut account_ids = BTreeSet::new();
let mut upstream_identities = BTreeSet::new();
let mut accounts = Vec::with_capacity(candidates.len());
for candidate in candidates {
let prepared = match &candidate.authentication {
ParsedCodexAuthentication::OAuth {
access_token,
refresh_token,
id_token,
} => {
let account_id = format!("acct_{}", uuid::Uuid::now_v7().simple());
if !account_ids.insert(account_id.clone()) {
return Err(CodexCredentialAdminError::InvalidInput);
}
let typed_account_id = ProviderAccountId::new(account_id.clone())
.map_err(|_| CodexCredentialAdminError::InvalidInput)?;
let (secret, access_token_expires_at) = self
.resolve_import_tokens(
&typed_account_id,
access_token.clone(),
refresh_token.clone(),
id_token.clone(),
)
.await?;
// 与官方 `parse_chatgpt_jwt_claims` 一致:先读 ID token,缺失字段
// 再由 access token 补齐。解析失败不阻塞 token 入库。
let id_metadata = secret
.id_token
.as_ref()
.and_then(|token| parse_chatgpt_jwt_claims(token.expose_secret()).ok())
.unwrap_or_default();
let access_metadata =
parse_chatgpt_jwt_claims(secret.access_token.expose_secret())
.unwrap_or_default();
let metadata = CodexOAuthMetadata {
email: id_metadata.email.or(access_metadata.email),
chatgpt_plan_type: id_metadata
.chatgpt_plan_type
.or(access_metadata.chatgpt_plan_type),
chatgpt_user_id: id_metadata
.chatgpt_user_id
.or(access_metadata.chatgpt_user_id),
chatgpt_account_id: id_metadata
.chatgpt_account_id
.or(access_metadata.chatgpt_account_id),
};
CodexCredentialAdmin.prepare_unresolved_oauth(
UnresolvedCodexOAuthCredential {
account_id,
name: candidate
.name
.clone()
.or_else(|| candidate.email.clone())
.or_else(|| metadata.email.clone())
.filter(|name| !name.trim().is_empty())
.unwrap_or_else(|| "Codex OAuth".to_owned()),
installation_id: uuid::Uuid::new_v4().to_string(),
secret,
metadata,
access_token_expires_at,
next_refresh_at: None,
enabled: true,
},
)?
}
ParsedCodexAuthentication::AgentIdentity { .. } => {
let account_id = candidate
.id
.clone()
.filter(|id| ProviderAccountId::new(id.clone()).is_ok())
.unwrap_or_else(|| format!("acct_{}", uuid::Uuid::now_v7().simple()));
if !account_ids.insert(account_id.clone()) {
return Err(CodexCredentialAdminError::InvalidInput);
}
let facts =
parse_cpr_account_facts(candidate.status.as_deref(), SystemTime::now())?;
let mut account =
self.prepare_agent_identity_import(&candidate, account_id, facts.enabled)?;
let upstream_user_id = account
.account
.upstream_user_id()
.ok_or(CodexCredentialAdminError::InvalidInput)?
.to_owned();
let upstream_account_id = account
.account
.upstream_account_id()
.ok_or(CodexCredentialAdminError::InvalidInput)?
.to_owned();
if !upstream_identities.insert((upstream_user_id, upstream_account_id)) {
return Err(CodexCredentialAdminError::InvalidInput);
}
account.account = facts.apply(account.account);
account
}
let account_id = format!("acct_{}", uuid::Uuid::now_v7().simple());
let typed_account_id = ProviderAccountId::new(account_id.clone())
.map_err(|_| CodexCredentialAdminError::InvalidInput)?;
let (secret, access_token_expires_at) = self
.resolve_import_tokens(
&typed_account_id,
candidate.authentication.access_token.clone(),
candidate.authentication.refresh_token.clone(),
candidate.authentication.id_token.clone(),
)
.await?;
// 与官方 `parse_chatgpt_jwt_claims` 一致:先读 ID token,缺失字段
// 再由 access token 补齐。解析失败不阻塞 token 入库。
let id_metadata = secret
.id_token
.as_ref()
.and_then(|token| parse_chatgpt_jwt_claims(token.expose_secret()).ok())
.unwrap_or_default();
let access_metadata =
parse_chatgpt_jwt_claims(secret.access_token.expose_secret()).unwrap_or_default();
let metadata = CodexOAuthMetadata {
email: id_metadata.email.or(access_metadata.email),
chatgpt_plan_type: id_metadata
.chatgpt_plan_type
.or(access_metadata.chatgpt_plan_type),
chatgpt_user_id: id_metadata
.chatgpt_user_id
.or(access_metadata.chatgpt_user_id),
chatgpt_account_id: id_metadata
.chatgpt_account_id
.or(access_metadata.chatgpt_account_id),
};
let prepared =
CodexCredentialAdmin.prepare_unresolved_oauth(UnresolvedCodexOAuthCredential {
account_id,
name: candidate
.name
.clone()
.or_else(|| candidate.email.clone())
.or_else(|| metadata.email.clone())
.filter(|name| !name.trim().is_empty())
.unwrap_or_else(|| "Codex OAuth".to_owned()),
installation_id: uuid::Uuid::new_v4().to_string(),
secret,
metadata,
access_token_expires_at,
next_refresh_at: None,
enabled: true,
})?;
accounts.push(prepared);
}
Ok(PreparedCodexAccountImport { accounts })
@@ -1044,79 +928,6 @@ impl CodexCredentialAdminService {
.map(ExposeSecret::expose_secret),
);
}
fn prepare_agent_identity_import(
&self,
candidate: &ParsedCodexImportAccount,
account_id: String,
enabled: bool,
) -> Result<NewProviderAccount, CodexCredentialAdminError> {
let ParsedCodexAuthentication::AgentIdentity {
runtime_id,
private_key,
task_id,
} = &candidate.authentication
else {
return Err(CodexCredentialAdminError::InvalidCredential);
};
let upstream_account_id = candidate
.chatgpt_account_id
.clone()
.ok_or(CodexCredentialAdminError::InvalidInput)?;
let upstream_user_id = candidate
.chatgpt_user_id
.clone()
.ok_or(CodexCredentialAdminError::InvalidInput)?;
let account_id = ProviderAccountId::new(account_id)
.map_err(|_| CodexCredentialAdminError::InvalidInput)?;
let provider = ProviderKind::new(PROVIDER_NAME)
.map_err(|_| CodexCredentialAdminError::InvalidInput)?;
let revision =
CredentialRevision::new(1).map_err(|_| CodexCredentialAdminError::InvalidCredential)?;
let credential =
CodexCredentialCodec::encode_agent_identity(CodexAgentIdentityCredentialData {
schema_version: CODEX_CREDENTIAL_SCHEMA_VERSION,
auth_mode: CodexAgentIdentityAuthMode::AgentIdentity,
installation_id: uuid::Uuid::new_v4().to_string(),
agent_runtime_id: runtime_id.clone(),
agent_private_key: private_key.clone(),
task_id: task_id.clone(),
cookies: Vec::new(),
})
.map_err(|_| CodexCredentialAdminError::InvalidCredential)?;
let name = candidate
.name
.clone()
.or_else(|| candidate.email.clone())
.filter(|name| !name.trim().is_empty())
.unwrap_or_else(|| "OpenAI Agent Identity".to_owned());
let account = ProviderAccount::new(
account_id,
provider,
name,
Some(upstream_user_id),
CODEX_AUTHENTICATION_KIND_AGENT_IDENTITY.to_owned(),
revision,
None,
)
.with_profile(
candidate.email.clone(),
Some(upstream_account_id),
candidate.plan_type.clone(),
)
.with_account_facts(
enabled,
CredentialState::Ready,
QuotaState::unknown(),
None,
None,
)
.with_refresh_schedule(false, None);
Ok(NewProviderAccount {
account,
credential,
})
}
}
fn optional_time(value: Option<chrono::DateTime<chrono::Utc>>) -> Option<SystemTime> {
@@ -1177,10 +988,6 @@ fn parse_import_document(
) -> Result<Vec<ParsedCodexImportAccount>, CodexCredentialAdminError> {
let mut accounts = Vec::new();
for value in import_account_values(payload)? {
if looks_like_agent_identity_account(value) {
accounts.push(parse_agent_identity_account(value)?);
continue;
}
if !is_openai_oauth_candidate(value) {
continue;
}
@@ -1194,72 +1001,9 @@ fn parse_oauth_import_account(
) -> Result<ParsedCodexImportAccount, CodexCredentialAdminError> {
let credentials = value.get("credentials").unwrap_or(value);
Ok(ParsedCodexImportAccount {
id: first_string(value, &["id"]),
name: first_string(value, &["name", "label"]),
email: first_string(value, &["email"]).or_else(|| first_string(credentials, &["email"])),
plan_type: first_string(value, &["plan_type", "planType"])
.or_else(|| first_string(credentials, &["plan_type", "planType"])),
chatgpt_account_id: None,
chatgpt_user_id: None,
authentication: parse_oauth_import_tokens(value)?,
status: first_string(value, &["status"]),
})
}
fn parse_agent_identity_account(
value: &Value,
) -> Result<ParsedCodexImportAccount, CodexCredentialAdminError> {
let account = value
.as_object()
.ok_or(CodexCredentialAdminError::InvalidInput)?;
if account
.get("platform")
.and_then(Value::as_str)
.is_some_and(|platform| !platform.eq_ignore_ascii_case("openai"))
|| account
.get("type")
.and_then(Value::as_str)
.is_some_and(|kind| !kind.eq_ignore_ascii_case("oauth"))
{
return Err(CodexCredentialAdminError::InvalidInput);
}
let identity = value
.get("agent_identity")
.or_else(|| value.get("credentials"))
.unwrap_or(value);
let auth_mode = first_string(value, &["auth_mode", "authMode"])
.or_else(|| first_string(identity, &["auth_mode", "authMode"]));
if auth_mode.as_deref() != Some("agentIdentity") {
return Err(CodexCredentialAdminError::InvalidCredential);
}
let runtime_id = first_string(identity, &["agent_runtime_id", "agentRuntimeId"])
.ok_or(CodexCredentialAdminError::InvalidCredential)?;
let private_key = first_string(identity, &["agent_private_key", "agentPrivateKey"])
.ok_or(CodexCredentialAdminError::InvalidCredential)?;
Ok(ParsedCodexImportAccount {
id: first_string(value, &["id"]),
name: first_string(value, &["name", "label"]),
email: first_string(identity, &["email"]),
plan_type: first_string(identity, &["plan_type", "planType"]),
chatgpt_account_id: first_string(
identity,
&[
"chatgpt_account_id",
"chatgptAccountId",
"account_id",
"accountId",
],
),
chatgpt_user_id: first_string(
identity,
&["chatgpt_user_id", "chatgptUserId", "user_id", "userId"],
),
authentication: ParsedCodexAuthentication::AgentIdentity {
runtime_id,
private_key,
task_id: first_string(identity, &["task_id", "taskId"]),
},
status: first_string(value, &["status"]),
})
}
@@ -1308,24 +1052,13 @@ fn parse_oauth_import_tokens(
if access_token.is_none() && refresh_token.is_none() {
return Err(CodexCredentialAdminError::InvalidCredential);
}
Ok(ParsedCodexAuthentication::OAuth {
Ok(ParsedCodexAuthentication {
access_token,
refresh_token,
id_token,
})
}
fn looks_like_agent_identity_account(value: &Value) -> bool {
let identity = value
.get("agent_identity")
.or_else(|| value.get("credentials"))
.unwrap_or(value);
first_string(value, &["auth_mode", "authMode"])
.or_else(|| first_string(identity, &["auth_mode", "authMode"]))
.is_some_and(|mode| mode == "agentIdentity")
|| first_string(identity, &["agent_runtime_id", "agentRuntimeId"]).is_some()
}
fn is_openai_oauth_candidate(value: &Value) -> bool {
let Some(account) = value.as_object() else {
return false;
@@ -1354,66 +1087,3 @@ fn first_string(value: &Value, keys: &[&str]) -> Option<String> {
.filter(|value| !value.is_empty())
.map(str::to_owned)
}
struct ImportedCprAccountFacts {
enabled: bool,
credential_state: CredentialState,
quota: QuotaState,
error_reason: Option<AccountErrorReason>,
}
impl ImportedCprAccountFacts {
fn apply(self, account: ProviderAccount) -> ProviderAccount {
account.with_account_facts(
self.enabled,
self.credential_state,
self.quota,
self.error_reason,
None,
)
}
}
fn parse_cpr_account_facts(
status: Option<&str>,
observed_at: SystemTime,
) -> Result<ImportedCprAccountFacts, CodexCredentialAdminError> {
match status
.map(str::trim)
.unwrap_or("active")
.to_ascii_lowercase()
.as_str()
{
"active" => Ok(ImportedCprAccountFacts {
enabled: true,
credential_state: CredentialState::Ready,
quota: QuotaState::unknown(),
error_reason: None,
}),
"disabled" => Ok(ImportedCprAccountFacts {
enabled: false,
credential_state: CredentialState::Ready,
quota: QuotaState::unknown(),
error_reason: None,
}),
"expired" => Ok(ImportedCprAccountFacts {
enabled: true,
credential_state: CredentialState::Expired,
quota: QuotaState::unknown(),
error_reason: Some(AccountErrorReason::CredentialExpired),
}),
"quota_exhausted" => Ok(ImportedCprAccountFacts {
enabled: true,
credential_state: CredentialState::Ready,
quota: QuotaState::exhausted(QuotaEvidence::ProviderDenied, observed_at, None),
error_reason: None,
}),
"banned" => Ok(ImportedCprAccountFacts {
enabled: true,
credential_state: CredentialState::Banned,
quota: QuotaState::unknown(),
error_reason: Some(AccountErrorReason::AccountBanned),
}),
_ => Err(CodexCredentialAdminError::InvalidInput),
}
}
@@ -1,500 +0,0 @@
//! OpenAI Agent Identity 签名、task 注册与 revision-fenced 恢复。
use std::collections::HashMap;
use std::fmt;
use std::sync::Arc;
use async_trait::async_trait;
use base64::Engine as _;
use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
use chrono::{DateTime, SecondsFormat, Utc};
use crypto_box::SecretKey as BoxSecretKey;
use ed25519_dalek::pkcs8::DecodePrivateKey as _;
use ed25519_dalek::{Signer as _, SigningKey};
use gateway_core::engine::credential::{ProviderAccount, ProviderAccountId};
use reqwest::StatusCode;
use secrecy::SecretString;
use serde::{Deserialize, Serialize};
use sha2::{Digest as _, Sha512};
use thiserror::Error;
use tokio::sync::Mutex;
use url::Url;
use super::repository::{CodexCredentialRepository, CredentialRepositoryError};
use super::security::{CodexRuntimeAuthentication, CodexRuntimeCredential};
use crate::transport::headers::build_codex_profile_headers;
use crate::transport::profile::CodexWireProfileState;
use crate::transport::websocket::CodexWebSocketExchangeError;
use crate::transport::{CodexClientError, CodexWebSocketPool};
const OFFICIAL_AGENT_IDENTITY_AUTH_BASE_URL: &str = "https://auth.openai.com/api/accounts/";
const MAX_TASK_RESPONSE_BYTES: usize = 64 * 1024;
/// 已验证且只存在于 OpenAI Provider 运行时的 Agent Identity 密钥。
pub struct CodexAgentIdentitySecret {
runtime_id: String,
signing_key: SigningKey,
task_id: Option<String>,
}
impl CodexAgentIdentitySecret {
pub fn from_pkcs8(
runtime_id: String,
encoded_private_key: &str,
task_id: Option<String>,
) -> Result<Self, CodexAgentIdentityError> {
if !valid_identifier(&runtime_id)
|| task_id
.as_deref()
.is_some_and(|value| !valid_identifier(value))
{
return Err(CodexAgentIdentityError::InvalidCredential);
}
let der = STANDARD
.decode(encoded_private_key.trim())
.map_err(|_| CodexAgentIdentityError::InvalidCredential)?;
let signing_key = SigningKey::from_pkcs8_der(&der)
.map_err(|_| CodexAgentIdentityError::InvalidCredential)?;
Ok(Self {
runtime_id,
signing_key,
task_id,
})
}
#[must_use]
pub fn runtime_id(&self) -> &str {
&self.runtime_id
}
#[must_use]
pub fn task_id(&self) -> Option<&str> {
self.task_id.as_deref()
}
pub fn authorization_header(
&self,
now: DateTime<Utc>,
) -> Result<SecretString, CodexAgentIdentityError> {
let task_id = self.task_id().ok_or(CodexAgentIdentityError::MissingTask)?;
let timestamp = timestamp(now);
let payload = format!("{}:{task_id}:{timestamp}", self.runtime_id);
let signature = self.signing_key.sign(payload.as_bytes());
let envelope = AgentAssertionEnvelope {
agent_runtime_id: &self.runtime_id,
task_id,
timestamp: &timestamp,
signature: STANDARD.encode(signature.to_bytes()),
};
let encoded = serde_json::to_vec(&envelope)
.map_err(|_| CodexAgentIdentityError::InvalidCredential)?;
Ok(SecretString::from(format!(
"AgentAssertion {}",
URL_SAFE_NO_PAD.encode(encoded)
)))
}
fn registration(&self, now: DateTime<Utc>) -> AgentTaskRegistration {
let timestamp = timestamp(now);
let payload = format!("{}:{timestamp}", self.runtime_id);
let signature = self.signing_key.sign(payload.as_bytes());
AgentTaskRegistration {
timestamp,
signature: STANDARD.encode(signature.to_bytes()),
}
}
fn decrypt_task_id(&self, encoded: &str) -> Result<String, CodexAgentIdentityError> {
let ciphertext = STANDARD
.decode(encoded.trim())
.map_err(|_| CodexAgentIdentityError::InvalidTaskResponse)?;
let digest = Sha512::digest(self.signing_key.to_bytes());
let mut curve_key = [0_u8; 32];
curve_key.copy_from_slice(&digest[..32]);
let secret = BoxSecretKey::from(curve_key);
let plaintext = secret
.unseal(&ciphertext)
.map_err(|_| CodexAgentIdentityError::InvalidTaskResponse)?;
let task_id = String::from_utf8(plaintext)
.map_err(|_| CodexAgentIdentityError::InvalidTaskResponse)?;
let task_id = task_id.trim().to_owned();
if !valid_identifier(&task_id) {
return Err(CodexAgentIdentityError::InvalidTaskResponse);
}
Ok(task_id)
}
}
impl fmt::Debug for CodexAgentIdentitySecret {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("CodexAgentIdentitySecret")
.field("runtime_id", &"<redacted>")
.field("signing_key", &"<redacted>")
.field("task_id", &self.task_id.as_ref().map(|_| "<redacted>"))
.finish()
}
}
#[derive(Serialize)]
struct AgentAssertionEnvelope<'a> {
agent_runtime_id: &'a str,
task_id: &'a str,
timestamp: &'a str,
signature: String,
}
#[derive(Serialize)]
struct AgentTaskRegistration {
timestamp: String,
signature: String,
}
#[derive(Deserialize)]
struct AgentTaskRegistrationResponse {
#[serde(default, alias = "taskId")]
task_id: Option<String>,
#[serde(default, alias = "encryptedTaskId")]
encrypted_task_id: Option<String>,
}
#[async_trait]
pub trait CodexAgentIdentityTaskRegistrar: Send + Sync + 'static {
async fn register(
&self,
credential: &CodexAgentIdentitySecret,
) -> Result<String, CodexAgentIdentityError>;
}
pub struct OfficialCodexAgentIdentityTaskRegistrar {
client: reqwest::Client,
base_url: Url,
profile: CodexWireProfileState,
}
impl OfficialCodexAgentIdentityTaskRegistrar {
pub fn new(
client: reqwest::Client,
profile: CodexWireProfileState,
) -> Result<Self, CodexAgentIdentityError> {
let base_url = Url::parse(OFFICIAL_AGENT_IDENTITY_AUTH_BASE_URL)
.map_err(|_| CodexAgentIdentityError::InvalidConfiguration)?;
Ok(Self {
client,
base_url,
profile,
})
}
}
impl fmt::Debug for OfficialCodexAgentIdentityTaskRegistrar {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("OfficialCodexAgentIdentityTaskRegistrar")
.field("base_url", &self.base_url)
.finish_non_exhaustive()
}
}
#[async_trait]
impl CodexAgentIdentityTaskRegistrar for OfficialCodexAgentIdentityTaskRegistrar {
async fn register(
&self,
credential: &CodexAgentIdentitySecret,
) -> Result<String, CodexAgentIdentityError> {
let mut endpoint = self.base_url.clone();
endpoint
.path_segments_mut()
.map_err(|_| CodexAgentIdentityError::InvalidConfiguration)?
.extend(["v1", "agent", credential.runtime_id(), "task", "register"]);
let profile = self.profile.snapshot();
let headers = build_codex_profile_headers(&profile)
.map_err(|_| CodexAgentIdentityError::InvalidConfiguration)?;
let response = self
.client
.post(endpoint)
.headers(headers)
.header(reqwest::header::ACCEPT, "application/json")
.json(&credential.registration(Utc::now()))
.send()
.await
.map_err(|_| CodexAgentIdentityError::TaskRegistrationUnavailable)?;
if !response.status().is_success() {
return Err(CodexAgentIdentityError::TaskRegistrationRejected);
}
if response
.content_length()
.is_some_and(|length| length > MAX_TASK_RESPONSE_BYTES as u64)
{
return Err(CodexAgentIdentityError::InvalidTaskResponse);
}
let body = response
.bytes()
.await
.map_err(|_| CodexAgentIdentityError::TaskRegistrationUnavailable)?;
if body.len() > MAX_TASK_RESPONSE_BYTES {
return Err(CodexAgentIdentityError::InvalidTaskResponse);
}
let result = serde_json::from_slice::<AgentTaskRegistrationResponse>(&body)
.map_err(|_| CodexAgentIdentityError::InvalidTaskResponse)?;
if let Some(task_id) = result.task_id.map(|value| value.trim().to_owned())
&& valid_identifier(&task_id)
{
return Ok(task_id);
}
let encrypted = result
.encrypted_task_id
.as_deref()
.ok_or(CodexAgentIdentityError::InvalidTaskResponse)?;
credential.decrypt_task_id(encrypted)
}
}
pub struct PreparedCodexRuntimeCredential {
pub account: ProviderAccount,
pub credential: CodexRuntimeCredential,
}
/// Agent task 的唯一注册与恢复 owner。
pub struct CodexAgentIdentityTaskService {
repository: CodexCredentialRepository,
registrar: Arc<dyn CodexAgentIdentityTaskRegistrar>,
websocket_pool: Arc<CodexWebSocketPool>,
account_locks: Mutex<HashMap<ProviderAccountId, Arc<Mutex<()>>>>,
}
impl CodexAgentIdentityTaskService {
#[must_use]
pub fn new(
repository: CodexCredentialRepository,
registrar: Arc<dyn CodexAgentIdentityTaskRegistrar>,
websocket_pool: Arc<CodexWebSocketPool>,
) -> Self {
Self {
repository,
registrar,
websocket_pool,
account_locks: Mutex::new(HashMap::new()),
}
}
pub async fn prepare(
&self,
account: &ProviderAccount,
) -> Result<PreparedCodexRuntimeCredential, CodexAgentIdentityError> {
let prepared = self.load_current(account.id()).await?;
if !matches!(
&prepared.credential.authentication,
CodexRuntimeAuthentication::AgentIdentity(secret) if secret.task_id().is_none()
) {
return Ok(prepared);
}
self.ensure_task(account.id(), None).await
}
pub async fn recover(
&self,
account_id: &ProviderAccountId,
expected_task_id: &str,
) -> Result<PreparedCodexRuntimeCredential, CodexAgentIdentityError> {
self.ensure_task(account_id, Some(expected_task_id)).await
}
pub async fn recover_after_rejected_task(
&self,
account_id: &ProviderAccountId,
authentication: &CodexRuntimeAuthentication,
error: &CodexClientError,
) -> Result<Option<PreparedCodexRuntimeCredential>, CodexAgentIdentityError> {
let CodexRuntimeAuthentication::AgentIdentity(secret) = authentication else {
return Ok(None);
};
let Some(task_id) = secret.task_id() else {
return Ok(None);
};
if !is_agent_identity_task_invalid_client_error(error) {
return Ok(None);
}
self.recover(account_id, task_id).await.map(Some)
}
async fn ensure_task(
&self,
account_id: &ProviderAccountId,
expected_task_id: Option<&str>,
) -> Result<PreparedCodexRuntimeCredential, CodexAgentIdentityError> {
let lock = {
let mut locks = self.account_locks.lock().await;
Arc::clone(
locks
.entry(account_id.clone())
.or_insert_with(|| Arc::new(Mutex::new(()))),
)
};
let _guard = lock.lock().await;
let current = self.load_current(account_id).await?;
let CodexRuntimeAuthentication::AgentIdentity(secret) = &current.credential.authentication
else {
return Ok(current);
};
match (expected_task_id, secret.task_id()) {
(None, Some(_)) => return Ok(current),
(Some(expected), Some(actual)) if actual != expected => return Ok(current),
_ => {}
}
let task_id = self.registrar.register(secret).await?;
let mut data = self.repository.load_complete_data(&current.account).await?;
let agent = data
.agent_identity_mut()
.ok_or(CodexAgentIdentityError::InvalidCredential)?;
match (expected_task_id, agent.task_id.as_deref()) {
(None, Some(_)) => return self.load_current(account_id).await,
(Some(expected), Some(actual)) if actual != expected => {
return self.load_current(account_id).await;
}
_ => agent.task_id = Some(task_id),
}
self.repository
.compare_and_swap_data(&current.account, data)
.await?;
self.websocket_pool.evict_account(account_id.as_str()).await;
self.load_current(account_id).await
}
pub(crate) async fn load_current(
&self,
account_id: &ProviderAccountId,
) -> Result<PreparedCodexRuntimeCredential, CodexAgentIdentityError> {
let account = self
.repository
.store()
.get_account(account_id)
.await?
.filter(|account| account.provider().as_str() == "openai")
.ok_or(CodexAgentIdentityError::NotFound)?;
let credential = self.repository.load_runtime_credential(&account).await?;
Ok(PreparedCodexRuntimeCredential {
account,
credential,
})
}
}
impl fmt::Debug for CodexAgentIdentityTaskService {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("CodexAgentIdentityTaskService")
.field("repository", &"CodexCredentialRepository")
.field("registrar", &"CodexAgentIdentityTaskRegistrar")
.finish_non_exhaustive()
}
}
#[derive(Debug, Error)]
pub enum CodexAgentIdentityError {
#[error("OpenAI Agent Identity credential is invalid")]
InvalidCredential,
#[error("OpenAI Agent Identity task is unavailable")]
MissingTask,
#[error("OpenAI Agent Identity task registration is unavailable")]
TaskRegistrationUnavailable,
#[error("OpenAI Agent Identity task registration was rejected")]
TaskRegistrationRejected,
#[error("OpenAI Agent Identity task registration response is invalid")]
InvalidTaskResponse,
#[error("OpenAI Agent Identity configuration is invalid")]
InvalidConfiguration,
#[error("OpenAI Agent Identity account was not found")]
NotFound,
#[error("OpenAI Agent Identity credential store is unavailable")]
Repository,
}
impl From<CredentialRepositoryError> for CodexAgentIdentityError {
fn from(_: CredentialRepositoryError) -> Self {
Self::Repository
}
}
impl From<gateway_core::error::StoreError> for CodexAgentIdentityError {
fn from(_: gateway_core::error::StoreError) -> Self {
Self::Repository
}
}
#[must_use]
pub fn is_agent_identity_task_invalid_response(status: StatusCode, body: &str) -> bool {
if status != StatusCode::UNAUTHORIZED {
return false;
}
let lower = body.to_ascii_lowercase();
let compact = lower
.chars()
.filter(|character| !character.is_ascii_whitespace())
.collect::<String>();
[
"\"code\":\"invalid_task_id\"",
"\"code\":\"task_not_found\"",
"\"code\":\"task_expired\"",
"\"error\":\"invalid_task_id\"",
]
.iter()
.any(|marker| compact.contains(marker))
|| [
"invalid task_id",
"invalid task id",
"task_id is invalid",
"task id is invalid",
"task not found",
"task expired",
"unknown task_id",
"unknown task id",
]
.iter()
.any(|marker| lower.contains(marker))
}
fn is_agent_identity_task_invalid_client_error(error: &CodexClientError) -> bool {
match error {
CodexClientError::Upstream { status, body, .. } => {
is_agent_identity_task_invalid_response(*status, body)
}
CodexClientError::WebSocket(CodexWebSocketExchangeError::Upstream(upstream)) => {
StatusCode::from_u16(upstream.status_code)
.ok()
.is_some_and(|status| {
is_agent_identity_task_invalid_response(status, &upstream.body)
})
}
CodexClientError::WebSocket(CodexWebSocketExchangeError::PostSendAmbiguous {
source: Some(source),
..
}) => is_agent_identity_task_invalid_websocket_error(source),
_ => false,
}
}
fn is_agent_identity_task_invalid_websocket_error(error: &CodexWebSocketExchangeError) -> bool {
match error {
CodexWebSocketExchangeError::Upstream(upstream) => {
StatusCode::from_u16(upstream.status_code)
.ok()
.is_some_and(|status| {
is_agent_identity_task_invalid_response(status, &upstream.body)
})
}
CodexWebSocketExchangeError::PostSendAmbiguous {
source: Some(source),
..
} => is_agent_identity_task_invalid_websocket_error(source),
_ => false,
}
}
fn timestamp(now: DateTime<Utc>) -> String {
now.to_rfc3339_opts(SecondsFormat::Secs, true)
}
fn valid_identifier(value: &str) -> bool {
!value.is_empty() && value.len() <= 2_048 && !value.chars().any(char::is_control)
}
@@ -19,7 +19,6 @@ use thiserror::Error;
use tokio::sync::Notify;
use uuid::Uuid;
use super::agent_identity::CodexAgentIdentityTaskService;
use super::repository::{CodexCredentialRepository, CredentialRepositoryError};
use crate::transport::profile::CodexWireProfileState;
use crate::transport::{CodexBackendClient, CodexCatalogModel, CodexRequestContext};
@@ -182,7 +181,6 @@ pub struct CodexCredentialCatalogService {
profile: CodexWireProfileState,
http: reqwest::Client,
base_url: String,
agent_identity: Arc<CodexAgentIdentityTaskService>,
plan_catalog_cache: Arc<dyn ProviderCatalogCachePort>,
cache: Arc<RwLock<CatalogCacheState>>,
etags: Arc<Mutex<CatalogEtagState>>,
@@ -195,7 +193,6 @@ impl CodexCredentialCatalogService {
profile: CodexWireProfileState,
http: reqwest::Client,
base_url: String,
agent_identity: Arc<CodexAgentIdentityTaskService>,
plan_catalog_cache: Arc<dyn ProviderCatalogCachePort>,
) -> Self {
Self {
@@ -203,7 +200,6 @@ impl CodexCredentialCatalogService {
profile,
http,
base_url,
agent_identity,
plan_catalog_cache,
cache: Arc::new(RwLock::new(CatalogCacheState::default())),
etags: Arc::new(Mutex::new(CatalogEtagState::default())),
@@ -429,52 +425,24 @@ impl CodexCredentialCatalogService {
client: &CodexBackendClient,
account: &ProviderAccount,
) -> Result<FetchedAccountModels, CodexCredentialCatalogError> {
let mut prepared = self
.agent_identity
.prepare(account)
let credential = self
.repository
.load_runtime_credential(account)
.await
.map_err(|_| CodexCredentialCatalogError::InvalidCredentialData)?;
let mut request_id = format!("catalog_{}", Uuid::now_v7().simple());
let mut authorization = prepared
.credential
let request_id = format!("catalog_{}", Uuid::now_v7().simple());
let authorization = credential
.authentication
.authorization_header(chrono::Utc::now())
.authorization_header()
.map_err(|_| CodexCredentialCatalogError::InvalidCredentialData)?;
let mut result = client
let result = client
.fetch_models_with_context(CodexRequestContext::auxiliary(
authorization.expose_secret(),
prepared.account.upstream_account_id(),
account.upstream_account_id(),
&request_id,
None,
))
.await;
if let Err(error) = &result
&& let Some(recovered) = self
.agent_identity
.recover_after_rejected_task(
prepared.account.id(),
&prepared.credential.authentication,
error,
)
.await
.map_err(|_| CodexCredentialCatalogError::InvalidCredentialData)?
{
prepared = recovered;
request_id = format!("catalog_{}", Uuid::now_v7().simple());
authorization = prepared
.credential
.authentication
.authorization_header(chrono::Utc::now())
.map_err(|_| CodexCredentialCatalogError::InvalidCredentialData)?;
result = client
.fetch_models_with_context(CodexRequestContext::auxiliary(
authorization.expose_secret(),
prepared.account.upstream_account_id(),
&request_id,
None,
))
.await;
}
let snapshot = result.map_err(|error| CodexCredentialCatalogError::Upstream {
detail: error.to_string(),
})?;
@@ -2,7 +2,6 @@
mod admin;
mod affinity;
mod agent_identity;
mod catalog;
mod cookie;
mod oauth;
@@ -21,12 +20,6 @@ pub(crate) use affinity::{
pub(crate) use oauth::oauth_owner_ref;
pub(crate) use types::parse_access_token_expiration;
pub use agent_identity::{
CodexAgentIdentityError, CodexAgentIdentitySecret, CodexAgentIdentityTaskRegistrar,
CodexAgentIdentityTaskService, OfficialCodexAgentIdentityTaskRegistrar,
PreparedCodexRuntimeCredential, is_agent_identity_task_invalid_response,
};
pub use admin::{
CodexCprExportDocument, CodexCredentialAdmin, CodexCredentialAdminError,
CodexCredentialAdminService, ExportManagedCodexCredential, ImportCodexOAuthCredential,
@@ -67,9 +60,7 @@ pub use selector::{
};
pub(crate) use selector::{CodexCyberPolicyScope, SelectCodexProviderEndpointCredential};
pub use types::{
CODEX_AUTHENTICATION_KIND_AGENT_IDENTITY, CODEX_AUTHENTICATION_KIND_OAUTH, CodexAccountProfile,
CodexAgentIdentityAuthMode, CodexAgentIdentityCredentialData, CodexCookie,
CodexCookieCaptureOutcome, CodexCredentialData, CodexCredentialPrincipal,
CodexOAuthCredentialData, CodexOAuthSecret, RotateCodexCredential, RuntimeCodexCookie,
UpsertCodexCookie,
CODEX_AUTHENTICATION_KIND_OAUTH, CodexAccountProfile, CodexCookie, CodexCookieCaptureOutcome,
CodexCredentialData, CodexCredentialPrincipal, CodexOAuthCredentialData, CodexOAuthSecret,
RotateCodexCredential, RuntimeCodexCookie, UpsertCodexCookie,
};
@@ -48,9 +48,8 @@ use crate::transport::{
CodexRateLimitResetCreditsConsumeResult, CodexRequestContext,
};
use super::agent_identity::{CodexAgentIdentityTaskService, PreparedCodexRuntimeCredential};
use super::repository::{CodexCredentialRepository, CredentialRepositoryError};
use super::types::CODEX_AUTHENTICATION_KIND_OAUTH;
use super::security::CodexRuntimeCredential;
use document::{
DEFAULT_CODEX_LIMIT_ID, RATE_LIMITS_BY_LIMIT_ID, RateLimitSnapshotsByLimitId,
canonicalize_rate_limit_document,
@@ -242,7 +241,6 @@ pub struct CodexCredentialQuotaService {
profile: CodexWireProfileState,
http: Client,
base_url: String,
agent_identity: Arc<CodexAgentIdentityTaskService>,
cooldowns: Arc<dyn ProviderCooldownPort>,
scheduling: CodexQuotaSchedulingProjection,
reset_consume_locks: Mutex<HashMap<ProviderAccountId, Arc<Mutex<()>>>>,
@@ -286,6 +284,11 @@ struct FetchedCodexQuota {
value: Value,
}
struct PreparedCodexRuntimeCredential {
account: ProviderAccount,
credential: CodexRuntimeCredential,
}
enum CodexQuotaFetchAttemptError {
InvalidCredential,
Upstream(CodexClientError),
@@ -293,7 +296,6 @@ enum CodexQuotaFetchAttemptError {
enum CodexQuotaFetchError {
InvalidCredential,
Recovery,
Upstream {
account: Box<ProviderAccount>,
error: CodexClientError,
@@ -312,11 +314,7 @@ enum DailyUsageAttemptError {
enum DailyUsageFetchError {
InvalidCredential,
Recovery,
Upstream {
prepared: Box<PreparedCodexRuntimeCredential>,
error: CodexClientError,
},
Upstream(CodexClientError),
}
struct FetchedDailyUsage {
@@ -536,7 +534,6 @@ impl CodexCredentialQuotaService {
profile: CodexWireProfileState,
http: Client,
base_url: String,
agent_identity: Arc<CodexAgentIdentityTaskService>,
cooldowns: Arc<dyn ProviderCooldownPort>,
) -> Self {
Self {
@@ -545,7 +542,6 @@ impl CodexCredentialQuotaService {
profile,
http,
base_url,
agent_identity,
cooldowns,
scheduling: CodexQuotaSchedulingProjection::default(),
reset_consume_locks: Mutex::new(HashMap::new()),
@@ -563,28 +559,19 @@ impl CodexCredentialQuotaService {
self.base_url.clone(),
self.profile.clone(),
);
let mut prepared = self
.agent_identity
.prepare(&account)
let credential = self
.repository
.load_runtime_credential(&account)
.await
.map_err(|_| CodexResetCreditsError::InvalidCredentialData)?;
let prepared = PreparedCodexRuntimeCredential {
account,
credential,
};
let request_id = format!("reset_credits_{}", Uuid::now_v7().simple());
let mut result = list_reset_credits_once(&client, &prepared, &request_id).await;
if let Err(ResetCreditAttemptError::Upstream(error)) = &result
&& let Some(recovered) = self
.agent_identity
.recover_after_rejected_task(
prepared.account.id(),
&prepared.credential.authentication,
error,
)
.await
.map_err(|_| CodexResetCreditsError::InvalidCredentialData)?
{
prepared = recovered;
result = list_reset_credits_once(&client, &prepared, &request_id).await;
}
result.map_err(|error| map_reset_credit_attempt_error(error, &prepared, false))
list_reset_credits_once(&client, &prepared, &request_id)
.await
.map_err(|error| map_reset_credit_attempt_error(error, false))
}
/// 查询当前账号的官方每日用量报表,并把报表范围锚定到最接近七天的额度窗口。
@@ -624,7 +611,7 @@ impl CodexCredentialQuotaService {
self.profile.clone(),
);
let FetchedCodexQuota { account, value } = self
.fetch_usage_with_recovery(&client, &account)
.fetch_usage(&client, &account)
.await
.map_err(map_statistics_quota_fetch_error)?;
let snapshot = parse_account_quota_snapshot(
@@ -672,7 +659,7 @@ impl CodexCredentialQuotaService {
)
.ok_or(CodexUsageStatisticsError::InvalidRequest)?;
let fetched = self
.fetch_daily_usage_with_recovery(
.fetch_daily_usage(
&client,
&account,
period.query_start_date,
@@ -716,45 +703,25 @@ impl CodexCredentialQuotaService {
self.base_url.clone(),
self.profile.clone(),
);
let mut prepared = self
.agent_identity
.prepare(&account)
let credential = self
.repository
.load_runtime_credential(&account)
.await
.map_err(|_| CodexResetCreditsError::InvalidCredentialData)?;
let prepared = PreparedCodexRuntimeCredential {
account,
credential,
};
let request_id = format!("reset_credit_consume_{}", Uuid::now_v7().simple());
let mut result = consume_reset_credit_once(
consume_reset_credit_once(
&client,
&prepared,
&request_id,
credit_id,
redeem_request_id,
)
.await;
// Agent Identity task recovery is the only automatic retry below. It keeps the exact
// official credit selection and redeem_request_id; generic HTTP/transport failures are
// never retried because the debit result may already be committed upstream.
if let Err(ResetCreditAttemptError::Upstream(error)) = &result
&& let Some(recovered) = self
.agent_identity
.recover_after_rejected_task(
prepared.account.id(),
&prepared.credential.authentication,
error,
)
.await
.map_err(|_| CodexResetCreditsError::InvalidCredentialData)?
{
prepared = recovered;
result = consume_reset_credit_once(
&client,
&prepared,
&request_id,
credit_id,
redeem_request_id,
)
.await;
}
result.map_err(|error| map_reset_credit_attempt_error(error, &prepared, true))
.await
.map_err(|error| map_reset_credit_attempt_error(error, true))
}
async fn reset_credit_account(
@@ -903,7 +870,7 @@ impl CodexCredentialQuotaService {
);
for account in accounts {
let observed_at = SystemTime::now();
match self.fetch_usage_with_recovery(&client, &account).await {
match self.fetch_usage(&client, &account).await {
Ok(FetchedCodexQuota { account, value }) => {
// 单账号解析或落库失败只影响该账号;其余账号继续同步。
if let Err(error) = self
@@ -921,13 +888,6 @@ impl CodexCredentialQuotaService {
Err(CodexQuotaFetchError::InvalidCredential) => {
summary.stale += 1;
}
Err(CodexQuotaFetchError::Recovery) => {
summary.transient += 1;
tracing::warn!(
account_id = %account.id(),
"OpenAI quota credential recovery failed; refresh cycle will retry later"
);
}
Err(CodexQuotaFetchError::Upstream { account, error }) => {
match classify_quota_endpoint_failure(&error) {
Some(QuotaEndpointFailure::Exhausted(evidence)) => {
@@ -1286,34 +1246,28 @@ impl CodexCredentialQuotaService {
self.base_url.clone(),
self.profile.clone(),
);
let FetchedCodexQuota { account, value } =
match self.fetch_usage_with_recovery(&client, &account).await {
Ok(fetched) => fetched,
Err(CodexQuotaFetchError::InvalidCredential) => {
return Err(CodexCredentialQuotaError::InvalidCredentialData);
}
Err(CodexQuotaFetchError::Recovery) => {
return Err(CodexCredentialQuotaError::Upstream {
detail: "credential recovery failed".to_owned(),
});
}
Err(CodexQuotaFetchError::Upstream { account, error }) => {
match classify_quota_endpoint_failure(&error) {
Some(QuotaEndpointFailure::Exhausted(evidence)) => {
self.record_confirmed_exhaustion(&account, evidence, None, observed_at)
.await?;
}
Some(QuotaEndpointFailure::Credential { state, reason }) => {
self.persist_credential_failure(&account, state, reason, observed_at)
.await;
}
None => {}
let FetchedCodexQuota { account, value } = match self.fetch_usage(&client, &account).await {
Ok(fetched) => fetched,
Err(CodexQuotaFetchError::InvalidCredential) => {
return Err(CodexCredentialQuotaError::InvalidCredentialData);
}
Err(CodexQuotaFetchError::Upstream { account, error }) => {
match classify_quota_endpoint_failure(&error) {
Some(QuotaEndpointFailure::Exhausted(evidence)) => {
self.record_confirmed_exhaustion(&account, evidence, None, observed_at)
.await?;
}
return Err(CodexCredentialQuotaError::Upstream {
detail: error.to_string(),
});
Some(QuotaEndpointFailure::Credential { state, reason }) => {
self.persist_credential_failure(&account, state, reason, observed_at)
.await;
}
None => {}
}
};
return Err(CodexCredentialQuotaError::Upstream {
detail: error.to_string(),
});
}
};
let object = normalize_quota_window_placeholders(
value
.as_object()
@@ -1406,31 +1360,21 @@ impl CodexCredentialQuotaService {
}
}
async fn fetch_usage_with_recovery(
async fn fetch_usage(
&self,
client: &CodexBackendClient,
account: &ProviderAccount,
) -> Result<FetchedCodexQuota, CodexQuotaFetchError> {
let mut prepared = self
.agent_identity
.prepare(account)
let credential = self
.repository
.load_runtime_credential(account)
.await
.map_err(|_| CodexQuotaFetchError::InvalidCredential)?;
let mut result = fetch_usage_with_5xx_retry(client, &prepared).await;
if let Err(CodexQuotaFetchAttemptError::Upstream(error)) = &result
&& let Some(recovered) = self
.agent_identity
.recover_after_rejected_task(
prepared.account.id(),
&prepared.credential.authentication,
error,
)
.await
.map_err(|_| CodexQuotaFetchError::Recovery)?
{
prepared = recovered;
result = fetch_usage_with_5xx_retry(client, &prepared).await;
}
let prepared = PreparedCodexRuntimeCredential {
account: account.clone(),
credential,
};
let result = fetch_usage_with_5xx_retry(client, &prepared).await;
match result {
Ok(value) => Ok(FetchedCodexQuota {
account: prepared.account,
@@ -1448,42 +1392,31 @@ impl CodexCredentialQuotaService {
}
}
async fn fetch_daily_usage_with_recovery(
async fn fetch_daily_usage(
&self,
client: &CodexBackendClient,
account: &ProviderAccount,
start_date: chrono::NaiveDate,
end_date: chrono::NaiveDate,
) -> Result<FetchedDailyUsage, DailyUsageFetchError> {
let mut prepared = self
.agent_identity
.prepare(account)
let credential = self
.repository
.load_runtime_credential(account)
.await
.map_err(|_| DailyUsageFetchError::InvalidCredential)?;
let mut result = fetch_daily_usage_once(client, &prepared, start_date, end_date).await;
if let Err(DailyUsageAttemptError::Upstream(error)) = &result
&& let Some(recovered) = self
.agent_identity
.recover_after_rejected_task(
prepared.account.id(),
&prepared.credential.authentication,
error,
)
.await
.map_err(|_| DailyUsageFetchError::Recovery)?
{
prepared = recovered;
result = fetch_daily_usage_once(client, &prepared, start_date, end_date).await;
}
let prepared = PreparedCodexRuntimeCredential {
account: account.clone(),
credential,
};
let result = fetch_daily_usage_once(client, &prepared, start_date, end_date).await;
match result {
Ok(value) => Ok(value),
Err(DailyUsageAttemptError::InvalidCredential) => {
Err(DailyUsageFetchError::InvalidCredential)
}
Err(DailyUsageAttemptError::Upstream(error)) => Err(DailyUsageFetchError::Upstream {
prepared: Box::new(prepared),
error,
}),
Err(DailyUsageAttemptError::Upstream(error)) => {
Err(DailyUsageFetchError::Upstream(error))
}
}
}
}
@@ -1497,7 +1430,7 @@ async fn fetch_daily_usage_once(
let authorization = prepared
.credential
.authentication
.authorization_header(Utc::now())
.authorization_header()
.map_err(|_| DailyUsageAttemptError::InvalidCredential)?;
let model_request_id = format!("usage_statistics_{}", Uuid::now_v7().simple());
let totals_request_id = format!("usage_statistics_{}", Uuid::now_v7().simple());
@@ -1534,31 +1467,21 @@ async fn fetch_daily_usage_once(
fn map_statistics_quota_fetch_error(error: CodexQuotaFetchError) -> CodexUsageStatisticsError {
match error {
CodexQuotaFetchError::InvalidCredential => CodexUsageStatisticsError::InvalidCredentialData,
CodexQuotaFetchError::Recovery => CodexUsageStatisticsError::TransportUnavailable,
CodexQuotaFetchError::Upstream { account, error } => map_statistics_client_error(
error,
account.authentication_kind() == CODEX_AUTHENTICATION_KIND_OAUTH,
),
CodexQuotaFetchError::Upstream { error, .. } => map_statistics_client_error(error),
}
}
fn map_daily_usage_fetch_error(error: DailyUsageFetchError) -> CodexUsageStatisticsError {
match error {
DailyUsageFetchError::InvalidCredential => CodexUsageStatisticsError::InvalidCredentialData,
DailyUsageFetchError::Recovery => CodexUsageStatisticsError::TransportUnavailable,
DailyUsageFetchError::Upstream { prepared, error } => {
map_statistics_client_error(error, prepared.credential.authentication.oauth().is_some())
}
DailyUsageFetchError::Upstream(error) => map_statistics_client_error(error),
}
}
fn map_statistics_client_error(
error: CodexClientError,
credential_can_refresh: bool,
) -> CodexUsageStatisticsError {
fn map_statistics_client_error(error: CodexClientError) -> CodexUsageStatisticsError {
match error {
CodexClientError::Upstream { status, body, .. }
if status == reqwest::StatusCode::UNAUTHORIZED && credential_can_refresh =>
if status == reqwest::StatusCode::UNAUTHORIZED =>
{
CodexUsageStatisticsError::CredentialRefreshRequired {
upstream_body: Some(body),
@@ -1586,7 +1509,7 @@ async fn list_reset_credits_once(
let authorization = prepared
.credential
.authentication
.authorization_header(Utc::now())
.authorization_header()
.map_err(|_| ResetCreditAttemptError::InvalidCredential)?;
client
.list_rate_limit_reset_credits(CodexRequestContext::auxiliary(
@@ -1609,7 +1532,7 @@ async fn consume_reset_credit_once(
let authorization = prepared
.credential
.authentication
.authorization_header(Utc::now())
.authorization_header()
.map_err(|_| ResetCreditAttemptError::InvalidCredential)?;
client
.consume_rate_limit_reset_credit(
@@ -1628,22 +1551,15 @@ async fn consume_reset_credit_once(
fn map_reset_credit_attempt_error(
error: ResetCreditAttemptError,
prepared: &PreparedCodexRuntimeCredential,
consume: bool,
) -> CodexResetCreditsError {
match error {
ResetCreditAttemptError::InvalidCredential => CodexResetCreditsError::InvalidCredentialData,
ResetCreditAttemptError::Upstream(error) => {
map_reset_credit_client_error(error, prepared, consume)
}
ResetCreditAttemptError::Upstream(error) => map_reset_credit_client_error(error, consume),
}
}
fn map_reset_credit_client_error(
error: CodexClientError,
prepared: &PreparedCodexRuntimeCredential,
consume: bool,
) -> CodexResetCreditsError {
fn map_reset_credit_client_error(error: CodexClientError, consume: bool) -> CodexResetCreditsError {
match error {
CodexClientError::Upstream {
status,
@@ -1651,8 +1567,7 @@ fn map_reset_credit_client_error(
diagnostics,
..
} if status == reqwest::StatusCode::UNAUTHORIZED
&& reset_credit_response_was_explicit_rejection(status, &diagnostics)
&& prepared.credential.authentication.oauth().is_some() =>
&& reset_credit_response_was_explicit_rejection(status, &diagnostics) =>
{
CodexResetCreditsError::CredentialRefreshRequired {
upstream_body: Some(body),
@@ -1735,7 +1650,7 @@ async fn fetch_usage_once(
let authorization = prepared
.credential
.authentication
.authorization_header(Utc::now())
.authorization_header()
.map_err(|_| CodexQuotaFetchAttemptError::InvalidCredential)?;
let request_id = format!("quota_{}", Uuid::now_v7().simple());
client
@@ -5,15 +5,13 @@ use secrecy::{ExposeSecret, SecretString};
use serde_json::Value;
use thiserror::Error;
use super::agent_identity::{CodexAgentIdentityError, CodexAgentIdentitySecret};
use super::types::{
CodexAccountProfile, CodexAgentIdentityCredentialData, CodexCookie, CodexCredentialData,
CodexCredentialPrincipal, CodexOAuthCredentialData, CodexOAuthSecret, RuntimeCodexCookie,
CodexAccountProfile, CodexCookie, CodexCredentialData, CodexCredentialPrincipal,
CodexOAuthCredentialData, CodexOAuthSecret, RuntimeCodexCookie,
};
const CODEX_CREDENTIAL_SCHEMA_VERSION: u32 = 1;
const MAX_CREDENTIAL_BYTES: usize = 256 * 1024;
const MAX_TOKEN_BYTES: usize = 128 * 1024;
const MAX_COOKIES: usize = 128;
/// 已解析且只在 Provider 内可见的认证材料。
@@ -42,27 +40,15 @@ impl std::fmt::Debug for CodexRuntimeCredential {
pub enum CodexRuntimeAuthentication {
OAuth(CodexOAuthSecret),
AgentIdentity(Box<CodexAgentIdentitySecret>),
}
impl CodexRuntimeAuthentication {
#[must_use]
pub const fn is_agent_identity(&self) -> bool {
matches!(self, Self::AgentIdentity(_))
}
pub fn authorization_header(
&self,
now: chrono::DateTime<chrono::Utc>,
) -> Result<SecretString, CodexCredentialDataError> {
pub fn authorization_header(&self) -> Result<SecretString, CodexCredentialDataError> {
match self {
Self::OAuth(secret) => Ok(SecretString::from(format!(
"Bearer {}",
secret.access_token.expose_secret()
))),
Self::AgentIdentity(secret) => secret
.authorization_header(now)
.map_err(|_| CodexCredentialDataError::Invalid),
}
}
@@ -70,7 +56,6 @@ impl CodexRuntimeAuthentication {
pub const fn oauth(&self) -> Option<&CodexOAuthSecret> {
match self {
Self::OAuth(secret) => Some(secret),
Self::AgentIdentity(_) => None,
}
}
}
@@ -79,7 +64,6 @@ impl std::fmt::Debug for CodexRuntimeAuthentication {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::OAuth(secret) => secret.fmt(formatter),
Self::AgentIdentity(secret) => secret.fmt(formatter),
}
}
}
@@ -147,12 +131,6 @@ impl CodexCredentialCodec {
}))
}
pub fn encode_agent_identity(
data: CodexAgentIdentityCredentialData,
) -> Result<PlaintextCredential, CodexCredentialDataError> {
Self::encode_complete(CodexCredentialData::AgentIdentity(data))
}
pub fn encode_complete(
data: CodexCredentialData,
) -> Result<PlaintextCredential, CodexCredentialDataError> {
@@ -200,21 +178,6 @@ impl CodexCredentialCodec {
data.oauth_client_id,
data.oauth_scope,
),
CodexCredentialData::AgentIdentity(data) => (
CodexRuntimeAuthentication::AgentIdentity(Box::new(
CodexAgentIdentitySecret::from_pkcs8(
data.agent_runtime_id,
&data.agent_private_key,
data.task_id,
)
.map_err(map_agent_identity_error)?,
)),
None,
data.installation_id,
data.cookies,
None,
None,
),
};
Ok(CodexRuntimeCredential {
authentication,
@@ -257,21 +220,6 @@ impl CodexCredentialCodec {
(CodexCredentialData::OAuth(incoming), CodexCredentialData::OAuth(existing)) => {
incoming.installation_id = existing.installation_id;
}
(
CodexCredentialData::AgentIdentity(incoming),
CodexCredentialData::AgentIdentity(existing),
) => {
if incoming.agent_runtime_id != existing.agent_runtime_id {
return Err(CodexCredentialDataError::Invalid);
}
incoming.installation_id = existing.installation_id;
if incoming.task_id.is_none()
&& incoming.agent_private_key == existing.agent_private_key
{
incoming.task_id = existing.task_id;
}
}
_ => return Err(CodexCredentialDataError::Invalid),
}
Self::encode_complete(incoming)
}
@@ -285,25 +233,6 @@ fn validate(data: &CodexCredentialData) -> Result<(), CodexCredentialDataError>
}
(&data.installation_id, &data.cookies)
}
CodexCredentialData::AgentIdentity(data) => {
if data.schema_version != CODEX_CREDENTIAL_SCHEMA_VERSION
|| !valid_identity(&data.agent_runtime_id)
|| !valid_secret(&data.agent_private_key)
|| data
.task_id
.as_deref()
.is_some_and(|value| !valid_identity(value))
|| CodexAgentIdentitySecret::from_pkcs8(
data.agent_runtime_id.clone(),
&data.agent_private_key,
data.task_id.clone(),
)
.is_err()
{
return Err(CodexCredentialDataError::Invalid);
}
(&data.installation_id, &data.cookies)
}
};
if !valid_installation_id(installation_id)
|| cookies.len() > MAX_COOKIES
@@ -323,20 +252,8 @@ fn validate(data: &CodexCredentialData) -> Result<(), CodexCredentialDataError>
Ok(())
}
const fn map_agent_identity_error(_: CodexAgentIdentityError) -> CodexCredentialDataError {
CodexCredentialDataError::Invalid
}
fn valid_identity(value: &str) -> bool {
!value.is_empty() && value.len() <= 2_048 && !value.chars().any(char::is_control)
}
fn valid_installation_id(value: &str) -> bool {
uuid::Uuid::parse_str(value)
.ok()
.is_some_and(|uuid| uuid.get_version_num() == 4)
}
fn valid_secret(value: &str) -> bool {
!value.is_empty() && value.len() <= MAX_TOKEN_BYTES && !value.chars().any(char::is_control)
}
@@ -22,7 +22,6 @@ use secrecy::ExposeSecret;
use thiserror::Error;
use url::Url;
use super::agent_identity::CodexAgentIdentityTaskService;
use super::catalog::CodexCredentialCatalogService;
use super::cookie::CodexCookiePolicy;
use super::quota::CodexCredentialQuotaService;
@@ -30,8 +29,7 @@ use super::refresh::refresh_recovery_deadline;
use super::repository::{CodexCredentialRepository, CredentialRepositoryError};
use super::security::CodexRuntimeAuthentication;
use super::types::{
CODEX_AUTHENTICATION_KIND_AGENT_IDENTITY, CODEX_AUTHENTICATION_KIND_OAUTH, CodexCookie,
CodexCookieCaptureOutcome, RuntimeCodexCookie,
CODEX_AUTHENTICATION_KIND_OAUTH, CodexCookie, CodexCookieCaptureOutcome, RuntimeCodexCookie,
};
const CLOUDFLARE_RECOVERY_STALE_AFTER: Duration = Duration::from_secs(60 * 60);
@@ -120,7 +118,6 @@ pub struct CodexCredentialSelector {
session_exclusions: Arc<dyn ProviderSessionExclusionPort>,
catalog: Arc<CodexCredentialCatalogService>,
quota: Arc<CodexCredentialQuotaService>,
agent_identity: Arc<CodexAgentIdentityTaskService>,
cookie_policy: CodexCookiePolicy,
risk_recovery: Mutex<HashMap<String, RiskRecoveryState>>,
account_feedback: Arc<AccountFeedbackStats>,
@@ -269,7 +266,6 @@ impl CodexCredentialSelector {
session_exclusions: Arc<dyn ProviderSessionExclusionPort>,
catalog: Arc<CodexCredentialCatalogService>,
quota: Arc<CodexCredentialQuotaService>,
agent_identity: Arc<CodexAgentIdentityTaskService>,
account_feedback: Arc<AccountFeedbackStats>,
cookie_policy: CodexCookiePolicy,
) -> Self {
@@ -281,7 +277,6 @@ impl CodexCredentialSelector {
session_exclusions,
catalog,
quota,
agent_identity,
cookie_policy,
risk_recovery: Mutex::new(HashMap::new()),
account_feedback,
@@ -580,20 +575,7 @@ impl CodexCredentialSelector {
account_switch = affinity_telemetry.account_switch,
"OpenAI account selected"
);
let (account, runtime) = if account.authentication_kind()
== CODEX_AUTHENTICATION_KIND_AGENT_IDENTITY
{
let prepared = if allows_account_state_mutation {
self.agent_identity.prepare(&account).await
} else {
self.agent_identity.load_current(account.id()).await
}
.map_err(|_| CredentialSelectionError::InvalidCredential)?;
(prepared.account, prepared.credential)
} else {
let runtime = self.repository.load_runtime_credential(&account).await?;
(account, runtime)
};
let runtime = self.repository.load_runtime_credential(&account).await?;
let cookies = runtime
.cookies
.into_iter()
@@ -280,7 +280,6 @@ impl fmt::Debug for CodexCookie {
}
pub const CODEX_AUTHENTICATION_KIND_OAUTH: &str = "oauth";
pub const CODEX_AUTHENTICATION_KIND_AGENT_IDENTITY: &str = "agent_identity";
/// Codex OAuth 对 `provider_credentials_json` 的完整明文 schema。
#[derive(Clone, Serialize, Deserialize)]
@@ -323,49 +322,11 @@ impl fmt::Debug for CodexOAuthCredentialData {
}
}
/// Agent Identity 文档的固定认证模式。
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub enum CodexAgentIdentityAuthMode {
#[serde(rename = "agentIdentity")]
AgentIdentity,
}
/// OpenAI Agent Identity 对 `provider_credentials_json` 的完整明文 schema。
#[derive(Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CodexAgentIdentityCredentialData {
pub schema_version: u32,
pub auth_mode: CodexAgentIdentityAuthMode,
pub installation_id: String,
pub agent_runtime_id: String,
pub agent_private_key: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub task_id: Option<String>,
#[serde(default)]
pub cookies: Vec<CodexCookie>,
}
impl fmt::Debug for CodexAgentIdentityCredentialData {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("CodexAgentIdentityCredentialData")
.field("schema_version", &self.schema_version)
.field("auth_mode", &self.auth_mode)
.field("installation_id", &"<pseudonymous>")
.field("agent_runtime_id", &"<redacted>")
.field("agent_private_key", &"<redacted>")
.field("task_id", &self.task_id.as_ref().map(|_| "<redacted>"))
.field("cookies", &self.cookies)
.finish()
}
}
/// OpenAI Provider 支持的两种规范化凭据形态。
/// OpenAI Provider 的规范化 OAuth 凭据形态。
#[derive(Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum CodexCredentialData {
OAuth(CodexOAuthCredentialData),
AgentIdentity(CodexAgentIdentityCredentialData),
}
impl CodexCredentialData {
@@ -373,7 +334,6 @@ impl CodexCredentialData {
pub const fn authentication_kind(&self) -> &'static str {
match self {
Self::OAuth(_) => CODEX_AUTHENTICATION_KIND_OAUTH,
Self::AgentIdentity(_) => CODEX_AUTHENTICATION_KIND_AGENT_IDENTITY,
}
}
@@ -381,14 +341,12 @@ impl CodexCredentialData {
pub fn installation_id(&self) -> &str {
match self {
Self::OAuth(data) => &data.installation_id,
Self::AgentIdentity(data) => &data.installation_id,
}
}
pub fn set_installation_id(&mut self, installation_id: String) {
match self {
Self::OAuth(data) => data.installation_id = installation_id,
Self::AgentIdentity(data) => data.installation_id = installation_id,
}
}
@@ -396,14 +354,12 @@ impl CodexCredentialData {
pub fn cookies(&self) -> &[CodexCookie] {
match self {
Self::OAuth(data) => &data.cookies,
Self::AgentIdentity(data) => &data.cookies,
}
}
pub fn cookies_mut(&mut self) -> &mut Vec<CodexCookie> {
match self {
Self::OAuth(data) => &mut data.cookies,
Self::AgentIdentity(data) => &mut data.cookies,
}
}
@@ -411,29 +367,12 @@ impl CodexCredentialData {
pub fn oauth(&self) -> Option<&CodexOAuthCredentialData> {
match self {
Self::OAuth(data) => Some(data),
Self::AgentIdentity(_) => None,
}
}
pub fn oauth_mut(&mut self) -> Option<&mut CodexOAuthCredentialData> {
match self {
Self::OAuth(data) => Some(data),
Self::AgentIdentity(_) => None,
}
}
#[must_use]
pub fn agent_identity(&self) -> Option<&CodexAgentIdentityCredentialData> {
match self {
Self::OAuth(_) => None,
Self::AgentIdentity(data) => Some(data),
}
}
pub fn agent_identity_mut(&mut self) -> Option<&mut CodexAgentIdentityCredentialData> {
match self {
Self::OAuth(_) => None,
Self::AgentIdentity(data) => Some(data),
}
}
@@ -448,7 +387,6 @@ impl fmt::Debug for CodexCredentialData {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::OAuth(data) => data.fmt(formatter),
Self::AgentIdentity(data) => data.fmt(formatter),
}
}
}
+3 -18
View File
@@ -16,10 +16,9 @@ use gateway_core::task::WorkerContribution;
use crate::admin::{OpenAiAdminProvider, OpenAiAdminServices, OpenAiOAuthPendingStore};
use crate::credential::token_client::{AuthorizationCodeExchanger, TokenRefresher};
use crate::credential::{
CodexAgentIdentityTaskService, CodexCookiePolicy, CodexCredentialAdmin,
CodexCredentialAdminService, CodexCredentialCatalogService, CodexCredentialQuotaService,
CodexCredentialRefreshService, CodexCredentialRepository, CodexCredentialSelector,
CodexOAuthAdmin, CodexOAuthAdminService, OfficialCodexAgentIdentityTaskRegistrar,
CodexCookiePolicy, CodexCredentialAdmin, CodexCredentialAdminService,
CodexCredentialCatalogService, CodexCredentialQuotaService, CodexCredentialRefreshService,
CodexCredentialRepository, CodexCredentialSelector, CodexOAuthAdmin, CodexOAuthAdminService,
};
use crate::transport::profile::{
CodexArtifactProfileCache, CodexDesktopReleaseService, OfficialCodexDesktopReleaseTransport,
@@ -97,20 +96,11 @@ pub async fn initialize(
let websocket_pool = Arc::new(CodexWebSocketPool::with_config(
config.websocket_pool_config(),
));
let agent_identity = Arc::new(CodexAgentIdentityTaskService::new(
repository.clone(),
Arc::new(
OfficialCodexAgentIdentityTaskRegistrar::new(http.clone(), profile.clone())
.map_err(|_| OpenAiInitializeError::AgentIdentity)?,
),
Arc::clone(&websocket_pool),
));
let catalog = Arc::new(CodexCredentialCatalogService::new(
repository.clone(),
profile.clone(),
http.clone(),
config.base_url().to_owned(),
Arc::clone(&agent_identity),
ports.catalog_cache(),
));
let quota = Arc::new(CodexCredentialQuotaService::new(
@@ -118,7 +108,6 @@ pub async fn initialize(
profile.clone(),
http.clone(),
config.base_url().to_owned(),
Arc::clone(&agent_identity),
ports.cooldowns(),
));
let selector = Arc::new(CodexCredentialSelector::new(
@@ -129,7 +118,6 @@ pub async fn initialize(
session_exclusions,
Arc::clone(&catalog),
Arc::clone(&quota),
Arc::clone(&agent_identity),
Arc::clone(&account_feedback),
CodexCookiePolicy::official().map_err(|_| OpenAiInitializeError::CookiePolicy)?,
));
@@ -138,7 +126,6 @@ pub async fn initialize(
selector,
Arc::clone(&catalog),
Arc::clone(&quota),
agent_identity,
account_feedback,
http,
profile.clone(),
@@ -246,8 +233,6 @@ pub enum OpenAiInitializeError {
CookiePolicy,
#[error("OpenAI token client could not initialize")]
TokenClient,
#[error("OpenAI Agent Identity service could not initialize")]
AgentIdentity,
#[error("OpenAI credential administration could not initialize")]
CredentialAdmin,
#[error("OpenAI credential refresh could not initialize")]
+13 -108
View File
@@ -49,12 +49,12 @@ use serde_json::{Map, Value, json};
use url::Url;
use crate::credential::{
CodexAccountFailure, CodexAgentIdentityTaskService, CodexCredentialCatalogError,
CodexCredentialCatalogService, CodexCredentialLease, CodexCredentialQuotaService,
CodexCredentialRefreshOutcome, CodexCredentialRefreshService, CodexCredentialSelector,
CodexCyberPolicyScope, CodexQuotaRefreshPolicy, CredentialSelectionError, RuntimeCodexCookie,
SelectCodexCredential, SelectCodexProviderEndpointCredential,
derive_codex_cyber_policy_session_key, derive_codex_session_affinity_key,
CodexAccountFailure, CodexCredentialCatalogError, CodexCredentialCatalogService,
CodexCredentialLease, CodexCredentialQuotaService, CodexCredentialRefreshOutcome,
CodexCredentialRefreshService, CodexCredentialSelector, CodexCyberPolicyScope,
CodexQuotaRefreshPolicy, CredentialSelectionError, RuntimeCodexCookie, SelectCodexCredential,
SelectCodexProviderEndpointCredential, derive_codex_cyber_policy_session_key,
derive_codex_session_affinity_key,
};
use crate::transport::canonical::{
CodexCanonicalDecoder, CodexCanonicalError, CodexCanonicalOutcome,
@@ -121,7 +121,6 @@ pub struct CodexProvider {
selector: Arc<CodexCredentialSelector>,
catalog: Arc<CodexCredentialCatalogService>,
quota: Arc<CodexCredentialQuotaService>,
agent_identity: Arc<CodexAgentIdentityTaskService>,
account_feedback: Arc<AccountFeedbackStats>,
client: CodexBackendClient,
responses_url: Url,
@@ -137,7 +136,6 @@ impl CodexProvider {
selector: Arc<CodexCredentialSelector>,
catalog: Arc<CodexCredentialCatalogService>,
quota: Arc<CodexCredentialQuotaService>,
agent_identity: Arc<CodexAgentIdentityTaskService>,
account_feedback: Arc<AccountFeedbackStats>,
http: Client,
profile: CodexWireProfileState,
@@ -157,7 +155,6 @@ impl CodexProvider {
selector,
catalog,
quota,
agent_identity,
account_feedback,
client,
responses_url,
@@ -417,7 +414,6 @@ impl Provider for CodexProvider {
selector: Arc::clone(&self.selector),
quota: Arc::clone(&self.quota),
catalog: Arc::clone(&self.catalog),
agent_identity: Arc::clone(&self.agent_identity),
lease: Arc::clone(&lease),
output_started_at,
session_affinity_key,
@@ -489,7 +485,6 @@ impl CodexProvider {
context,
selector: Arc::clone(&self.selector),
quota: Arc::clone(&self.quota),
agent_identity: Arc::clone(&self.agent_identity),
lease: Arc::clone(&lease),
output_started_at: Instant::now(),
});
@@ -512,7 +507,6 @@ struct ColdResponse {
selector: Arc<CodexCredentialSelector>,
quota: Arc<CodexCredentialQuotaService>,
catalog: Arc<CodexCredentialCatalogService>,
agent_identity: Arc<CodexAgentIdentityTaskService>,
lease: Arc<CodexCredentialLease>,
output_started_at: Instant,
session_affinity_key: Option<ProviderSessionAffinityKey>,
@@ -528,7 +522,6 @@ struct ColdJsonResponse {
context: AttemptContext,
selector: Arc<CodexCredentialSelector>,
quota: Arc<CodexCredentialQuotaService>,
agent_identity: Arc<CodexAgentIdentityTaskService>,
lease: Arc<CodexCredentialLease>,
output_started_at: Instant,
}
@@ -641,7 +634,6 @@ enum CodexHandshakeAttemptError {
Client(CodexClientError),
Cancelled,
Timeout,
AgentIdentityRecovery,
}
async fn create_response_attempt(
@@ -678,9 +670,6 @@ fn map_handshake_attempt_error(error: CodexHandshakeAttemptError) -> MappedProvi
ProviderErrorKind::Timeout,
UpstreamSendState::Ambiguous,
)),
CodexHandshakeAttemptError::AgentIdentityRecovery => MappedProviderFailure::plain(
provider_error(ProviderErrorKind::Unavailable, UpstreamSendState::Sent),
),
}
}
@@ -728,12 +717,12 @@ fn cold_json_response_stream(request: ColdJsonResponse) -> EventStream {
cyber_policy_scope: None,
allows_account_state_mutation,
};
let mut active_account = request.lease.account().clone();
let active_account = request.lease.account().clone();
let cookie_header = build_cookie_header(request.lease.cookies())?;
let mut authorization = request
let authorization = request
.lease
.authentication()
.authorization_header(chrono::Utc::now())
.authorization_header()
.map_err(|_| {
provider_error(
ProviderErrorKind::Unauthorized,
@@ -745,7 +734,7 @@ fn cold_json_response_stream(request: ColdJsonResponse) -> EventStream {
request.lease.escape_reason(),
request.lease.account_switch(),
);
let mut response = create_json_attempt(
let response = create_json_attempt(
&request,
&active_account,
request.lease.installation_id(),
@@ -754,44 +743,6 @@ fn cold_json_response_stream(request: ColdJsonResponse) -> EventStream {
account_selection,
)
.await;
if allows_account_state_mutation
&& let Err(CodexHandshakeAttemptError::Client(error)) = &response
{
match request
.agent_identity
.recover_after_rejected_task(
active_account.id(),
request.lease.authentication(),
error,
)
.await
{
Ok(Some(recovered)) => {
authorization = recovered
.credential
.authentication
.authorization_header(chrono::Utc::now())
.map_err(|_| {
provider_error(
ProviderErrorKind::Unauthorized,
UpstreamSendState::NotSent,
)
})?;
active_account = recovered.account;
response = create_json_attempt(
&request,
&active_account,
&recovered.credential.installation_id,
&authorization,
cookie_header.as_ref(),
account_selection,
)
.await;
}
Ok(None) => {}
Err(_) => response = Err(CodexHandshakeAttemptError::AgentIdentityRecovery),
}
}
let response = match response.map_err(map_handshake_attempt_error) {
Ok(response) => response,
Err(mut failure) => {
@@ -867,7 +818,6 @@ fn cold_response_stream(response: ColdResponse) -> EventStream {
selector,
quota,
catalog,
agent_identity,
lease,
output_started_at,
session_affinity_key,
@@ -886,9 +836,9 @@ fn cold_response_stream(response: ColdResponse) -> EventStream {
};
let mut active_account = lease.account().clone();
let cookie_header = build_cookie_header(lease.cookies())?;
let mut authorization = lease
let authorization = lease
.authentication()
.authorization_header(chrono::Utc::now())
.authorization_header()
.map_err(|_| {
provider_error(
ProviderErrorKind::Unauthorized,
@@ -902,7 +852,7 @@ fn cold_response_stream(response: ColdResponse) -> EventStream {
lease.escape_reason(),
lease.account_switch(),
);
let mut response = create_response_attempt(
let response = create_response_attempt(
&client,
&request,
codex_request_context(
@@ -919,51 +869,6 @@ fn cold_response_stream(response: ColdResponse) -> EventStream {
&cancellation,
)
.await;
if allows_account_state_mutation
&& let Err(CodexHandshakeAttemptError::Client(error)) = &response
{
match agent_identity
.recover_after_rejected_task(
active_account.id(),
lease.authentication(),
error,
)
.await
{
Ok(Some(recovered)) => {
authorization = recovered
.credential
.authentication
.authorization_header(chrono::Utc::now())
.map_err(|_| {
provider_error(
ProviderErrorKind::Unauthorized,
UpstreamSendState::NotSent,
)
})?;
active_account = recovered.account;
response = create_response_attempt(
&client,
&request,
codex_request_context(
&request,
&request_id,
&active_account,
&recovered.credential.installation_id,
&authorization,
cookie_header.as_ref(),
account_selection,
),
active_account.id().as_str(),
context.deadline(),
&cancellation,
)
.await;
}
Ok(None) => {}
Err(_) => response = Err(CodexHandshakeAttemptError::AgentIdentityRecovery),
}
}
let response = response.map_err(map_handshake_attempt_error);
let response = match response {
Ok(response) => response,
@@ -1,256 +0,0 @@
use std::collections::VecDeque;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use base64::Engine as _;
use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
use chrono::{TimeZone, Utc};
use ed25519_dalek::pkcs8::EncodePrivateKey as _;
use ed25519_dalek::{Signature, SigningKey, Verifier as _};
use gateway_core::engine::credential::{
CredentialRevision, CredentialState, NewProviderAccount, ProviderAccount, ProviderAccountId,
ProviderAccountStore, QuotaState,
};
use gateway_core::routing::ProviderKind;
use provider_openai::OpenAiConfig;
use provider_openai::credential::{
CODEX_AUTHENTICATION_KIND_AGENT_IDENTITY, CodexAgentIdentityAuthMode,
CodexAgentIdentityCredentialData, CodexAgentIdentityError, CodexAgentIdentitySecret,
CodexAgentIdentityTaskRegistrar, CodexAgentIdentityTaskService, CodexCredentialCodec,
CodexRuntimeAuthentication, is_agent_identity_task_invalid_response,
};
use provider_openai::transport::CodexWebSocketPool;
use provider_openai::transport::headers::build_codex_profile_headers;
use reqwest::StatusCode;
use reqwest::header::USER_AGENT;
use secrecy::ExposeSecret as _;
use serde_json::Value;
use crate::support::MemoryAccountStore;
fn signing_fixture(seed: u8) -> (SigningKey, String) {
let signing_key = SigningKey::from_bytes(&[seed; 32]);
let der = signing_key.to_pkcs8_der().expect("encode test PKCS#8 key");
(signing_key, STANDARD.encode(der.as_bytes()))
}
fn agent_account(
account_id: &str,
runtime_id: &str,
private_key: &str,
task_id: Option<&str>,
) -> NewProviderAccount {
let account_id = ProviderAccountId::new(account_id.to_owned()).expect("account id");
let provider = ProviderKind::new("openai").expect("provider");
let credential =
CodexCredentialCodec::encode_agent_identity(CodexAgentIdentityCredentialData {
schema_version: 1,
auth_mode: CodexAgentIdentityAuthMode::AgentIdentity,
installation_id: "00000000-0000-4000-8000-000000000001".to_owned(),
agent_runtime_id: runtime_id.to_owned(),
agent_private_key: private_key.to_owned(),
task_id: task_id.map(str::to_owned),
cookies: Vec::new(),
})
.expect("encode Agent Identity credential");
let account = ProviderAccount::new(
account_id,
provider,
"Agent Identity test".to_owned(),
Some("agent-user".to_owned()),
CODEX_AUTHENTICATION_KIND_AGENT_IDENTITY.to_owned(),
CredentialRevision::new(1).expect("revision"),
None,
)
.with_profile(
Some("agent@example.com".to_owned()),
Some("agent-account".to_owned()),
Some("pro".to_owned()),
)
.with_account_facts(
true,
CredentialState::Ready,
QuotaState::unknown(),
None,
None,
)
.with_refresh_schedule(false, None);
NewProviderAccount {
account,
credential,
}
}
#[test]
fn agent_identity_secret_builds_a_verifiable_assertion_without_leaking_material() {
let (signing_key, private_key) = signing_fixture(7);
let secret = CodexAgentIdentitySecret::from_pkcs8(
"runtime-test".to_owned(),
&private_key,
Some("task-test".to_owned()),
)
.expect("parse Agent Identity secret");
let now = Utc
.with_ymd_and_hms(2026, 7, 23, 1, 2, 3)
.single()
.expect("timestamp");
let header = secret
.authorization_header(now)
.expect("build AgentAssertion");
let encoded = header
.expose_secret()
.strip_prefix("AgentAssertion ")
.expect("AgentAssertion scheme");
let envelope: Value = serde_json::from_slice(
&URL_SAFE_NO_PAD
.decode(encoded)
.expect("decode assertion envelope"),
)
.expect("parse assertion envelope");
assert_eq!(envelope["agent_runtime_id"], "runtime-test");
assert_eq!(envelope["task_id"], "task-test");
assert_eq!(envelope["timestamp"], "2026-07-23T01:02:03Z");
let signature = Signature::from_slice(
&STANDARD
.decode(envelope["signature"].as_str().expect("signature"))
.expect("decode signature"),
)
.expect("signature shape");
signing_key
.verifying_key()
.verify(b"runtime-test:task-test:2026-07-23T01:02:03Z", &signature)
.expect("verify AgentAssertion");
let debug = format!("{secret:?}");
assert!(!debug.contains(&private_key));
assert!(!debug.contains("runtime-test"));
assert!(!debug.contains("task-test"));
}
#[test]
fn agent_identity_task_error_classifier_requires_unauthorized_task_error() {
assert!(is_agent_identity_task_invalid_response(
StatusCode::UNAUTHORIZED,
r#"{"error":{"code":"task_not_found"}}"#,
));
assert!(is_agent_identity_task_invalid_response(
StatusCode::UNAUTHORIZED,
"task expired",
));
assert!(!is_agent_identity_task_invalid_response(
StatusCode::FORBIDDEN,
r#"{"error":{"code":"task_not_found"}}"#,
));
assert!(!is_agent_identity_task_invalid_response(
StatusCode::UNAUTHORIZED,
"invalid access token",
));
}
#[test]
fn agent_identity_task_registration_should_use_the_shared_desktop_profile_headers() {
let profile = OpenAiConfig::default().wire_profile_state().snapshot();
let headers = build_codex_profile_headers(&profile).expect("Desktop profile headers");
assert_eq!(headers.len(), 3);
assert_eq!(headers["originator"], "Codex Desktop");
assert_eq!(headers["x-openai-internal-codex-residency"], "us");
assert_eq!(
headers[USER_AGENT],
"Codex Desktop/0.147.0-alpha.6.6 (Mac OS 15.7.1; arm64) unknown (Codex Desktop; 26.803.81509)"
);
}
struct SequenceTaskRegistrar {
task_ids: Mutex<VecDeque<String>>,
calls: AtomicUsize,
}
#[async_trait]
impl CodexAgentIdentityTaskRegistrar for SequenceTaskRegistrar {
async fn register(
&self,
_credential: &CodexAgentIdentitySecret,
) -> Result<String, CodexAgentIdentityError> {
self.calls.fetch_add(1, Ordering::SeqCst);
self.task_ids
.lock()
.expect("task registrar lock")
.pop_front()
.ok_or(CodexAgentIdentityError::TaskRegistrationUnavailable)
}
}
fn task_service(
store: &Arc<MemoryAccountStore>,
registrar: Arc<SequenceTaskRegistrar>,
) -> CodexAgentIdentityTaskService {
CodexAgentIdentityTaskService::new(
store.repository(),
registrar,
Arc::new(CodexWebSocketPool::default()),
)
}
#[tokio::test]
async fn task_registration_and_recovery_are_revision_fenced() {
let (signing_key, private_key) = signing_fixture(8);
let runtime_id = format!(
"runtime-{}",
hex::encode(signing_key.verifying_key().as_bytes())
);
let store = Arc::new(MemoryAccountStore::default());
store
.create_account(agent_account(
"acct_agent_task",
&runtime_id,
&private_key,
None,
))
.await
.expect("seed Agent Identity account");
let registrar = Arc::new(SequenceTaskRegistrar {
task_ids: Mutex::new(VecDeque::from([
"task-first".to_owned(),
"task-recovered".to_owned(),
])),
calls: AtomicUsize::new(0),
});
let service = task_service(&store, Arc::clone(&registrar));
let current = store.account("acct_agent_task").expect("current account");
let prepared = service.prepare(&current).await.expect("register task");
assert_eq!(prepared.account.revision().get(), 2);
let CodexRuntimeAuthentication::AgentIdentity(secret) = prepared.credential.authentication
else {
panic!("expected Agent Identity runtime credential");
};
assert_eq!(secret.task_id(), Some("task-first"));
assert_eq!(registrar.calls.load(Ordering::SeqCst), 1);
let current = store.account("acct_agent_task").expect("updated account");
let prepared = service
.prepare(&current)
.await
.expect("reuse registered task");
assert_eq!(prepared.account.revision().get(), 2);
assert_eq!(registrar.calls.load(Ordering::SeqCst), 1);
let recovered = service
.recover(prepared.account.id(), "task-first")
.await
.expect("rejected task must register a replacement");
assert_eq!(recovered.account.revision().get(), 3);
assert_eq!(registrar.calls.load(Ordering::SeqCst), 2);
let CodexRuntimeAuthentication::AgentIdentity(secret) = recovered.credential.authentication
else {
panic!("expected Agent Identity runtime credential");
};
assert_eq!(secret.task_id(), Some("task-recovered"));
let fenced = service
.recover(recovered.account.id(), "task-first")
.await
.expect("stale task recovery must keep the newer task");
assert_eq!(fenced.account.revision().get(), 3);
assert_eq!(registrar.calls.load(Ordering::SeqCst), 2);
}
@@ -13,7 +13,7 @@ use provider_openai::transport::profile::{CodexWireProfile, CodexWireProfileStat
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
use crate::support::{MemoryAccountStore, agent_identity_service, catalog_cache, profile, secret};
use crate::support::{MemoryAccountStore, catalog_cache, profile, secret};
const OFFICIAL_FIXTURE: &[u8] =
include_bytes!("../transport/fixtures/official_models_snapshot.json");
@@ -84,7 +84,6 @@ fn service_with_catalog_cache(
.build()
.expect("client"),
base_url,
agent_identity_service(store),
catalog_cache,
)
}
@@ -37,7 +37,7 @@ use url::Url;
use crate::support::{
MemoryAccountStore, MemoryCooldownPort, MemorySessionAffinity, MemorySessionExclusions,
TestLeaseCoordinator, account_policy, agent_identity_service, catalog_cache, profile, secret,
TestLeaseCoordinator, account_policy, catalog_cache, profile, secret,
};
fn create_account(store: &Arc<MemoryAccountStore>, id: &str, token: &str) {
@@ -162,13 +162,11 @@ fn selector_with_runtime(
verified_at: chrono::Utc::now(),
});
let http = reqwest::Client::builder().build().expect("HTTP client");
let agent_identity = agent_identity_service(store);
let catalog = Arc::new(CodexCredentialCatalogService::new(
store.repository(),
profile.clone(),
http.clone(),
OFFICIAL_CODEX_BASE_URL.to_owned(),
Arc::clone(&agent_identity),
catalog_cache(),
));
let quota = Arc::new(CodexCredentialQuotaService::new(
@@ -176,7 +174,6 @@ fn selector_with_runtime(
profile,
http,
OFFICIAL_CODEX_BASE_URL.to_owned(),
Arc::clone(&agent_identity),
cooldowns,
));
CodexCredentialSelector::new(
@@ -187,7 +184,6 @@ fn selector_with_runtime(
Arc::new(MemorySessionExclusions::default()),
catalog,
quota,
agent_identity,
account_feedback,
CodexCookiePolicy::official().expect("official cookie policy"),
)
@@ -1,5 +1,4 @@
mod admin;
mod agent_identity;
mod catalog;
mod contract;
mod cookie;
@@ -62,7 +62,6 @@ fn quota_service_with_base_url(
wire_profile(),
http,
base_url,
crate::support::agent_identity_service(store),
Arc::new(crate::support::MemoryCooldownPort::new()),
)
}
@@ -53,8 +53,7 @@ use wiremock::{Mock, MockServer, ResponseTemplate};
use crate::support::{
MemoryAccountStore, MemoryCooldownPort, MemorySessionAffinity, MemorySessionExclusions,
TestLeaseCoordinator, account_policy, agent_identity_service_with_pool, catalog_cache, profile,
secret,
TestLeaseCoordinator, account_policy, catalog_cache, profile, secret,
};
use crate::transport::accept_codex_test_websocket;
@@ -146,13 +145,11 @@ fn provider_and_quota_with_affinity_and_base_url_and_leases(
.build()
.expect("client");
let websocket_pool = Arc::new(CodexWebSocketPool::default());
let agent_identity = agent_identity_service_with_pool(store, Arc::clone(&websocket_pool));
let catalog = Arc::new(CodexCredentialCatalogService::new(
store.repository(),
profile.clone(),
http.clone(),
base_url.clone(),
Arc::clone(&agent_identity),
catalog_cache(),
));
let quota = Arc::new(CodexCredentialQuotaService::new(
@@ -160,7 +157,6 @@ fn provider_and_quota_with_affinity_and_base_url_and_leases(
profile.clone(),
http.clone(),
base_url.clone(),
Arc::clone(&agent_identity),
Arc::new(MemoryCooldownPort::new()),
));
let account_feedback = Arc::new(AccountFeedbackStats::default());
@@ -172,7 +168,6 @@ fn provider_and_quota_with_affinity_and_base_url_and_leases(
Arc::new(MemorySessionExclusions::default()),
Arc::clone(&catalog),
Arc::clone(&quota),
Arc::clone(&agent_identity),
Arc::clone(&account_feedback),
CodexCookiePolicy::official().expect("cookie policy"),
));
@@ -181,7 +176,6 @@ fn provider_and_quota_with_affinity_and_base_url_and_leases(
selector,
catalog,
Arc::clone(&quota),
agent_identity,
account_feedback,
http,
profile,
@@ -27,11 +27,9 @@ use gateway_core::provider_ports::{
};
use gateway_core::routing::ProviderKind;
use provider_openai::credential::{
CodexAccountProfile, CodexAgentIdentityTaskService, CodexCredentialAdmin,
CodexCredentialRepository, CodexOAuthSecret, ImportCodexOAuthCredential,
OfficialCodexAgentIdentityTaskRegistrar,
CodexAccountProfile, CodexCredentialAdmin, CodexCredentialRepository, CodexOAuthSecret,
ImportCodexOAuthCredential,
};
use provider_openai::transport::CodexWebSocketPool;
use secrecy::SecretString;
#[derive(Clone)]
@@ -510,32 +508,6 @@ fn rebuild_account(current: &ProviderAccount, rebuild: AccountRebuild) -> Provid
.with_refresh_schedule(rebuild.has_refresh_token, rebuild.next_refresh_at)
}
pub(crate) fn agent_identity_service(
store: &Arc<MemoryAccountStore>,
) -> Arc<CodexAgentIdentityTaskService> {
agent_identity_service_with_pool(store, Arc::new(CodexWebSocketPool::default()))
}
pub(crate) fn agent_identity_service_with_pool(
store: &Arc<MemoryAccountStore>,
websocket_pool: Arc<CodexWebSocketPool>,
) -> Arc<CodexAgentIdentityTaskService> {
let client = reqwest::Client::builder()
.no_proxy()
.build()
.expect("agent task client");
let registrar = OfficialCodexAgentIdentityTaskRegistrar::new(
client,
provider_openai::OpenAiConfig::default().wire_profile_state(),
)
.expect("agent task registrar");
Arc::new(CodexAgentIdentityTaskService::new(
store.repository(),
Arc::new(registrar),
websocket_pool,
))
}
#[derive(Default)]
pub(crate) struct TestLeaseCoordinator {
pub(crate) requests: Mutex<Vec<ProviderSchedulingLeaseRequest>>,
+2 -20
View File
@@ -131,7 +131,7 @@ Grok wire 与 Responses wire 之间的协议转换层,转换只在 xAI Provide
导入的 `data` 必须是 JSON object,Admin API 请求上限为 64 MiB;Provider 可以收紧限制,
当前 xAI 导入上限为 16 MiB。内部 schema 由目标 Provider 独占解释:
- OpenAI 接受单账号 OAuth 文档、`accounts` 数组(最多 200 项)、CPR 账号 bundle 和 Agent Identity;
- OpenAI 接受单账号 OAuth 文档、`accounts` 数组(最多 200 项)和 CPR 账号 bundle;
- OpenAI OAuth token 字段只识别 `accessToken`、`refreshToken`、`idToken`,可以嵌套在账号 object 内;
每项至少包含 AT 或 RT。RT-only 会在导入时换取 AT,AT-only 不具备自动续期能力;
- xAI 从单账号 object 或 `accounts` 数组中提取 OAuth token;包装中的代理、并发、优先级等字段不参与认证;
@@ -160,25 +160,7 @@ RT-only 使用同一形状,只提交 `refreshToken`。不得把真实 token
重新授权以及普通 credential refresh/rotation 均保留已有分组。分组关系只通过账号编辑维护。
账号列表的每个 item 返回轻量 `groups: [{ id, name, enabled }]`。
OpenAI 的 CPR 导出保持 OAuth 账号的既有字段;Agent Identity 账号则输出
`authMode: "agentIdentity"`、`agentRuntimeId`、`agentPrivateKey` 和可选的
`taskId`,不伪造 OAuth token 或过期时间。一个 `sourceFormat: "cpr"` 文档可以包含两种
认证类型,导入时按每个账号的认证模式分别解析。
```json
{
"sourceFormat": "cpr",
"accounts": [{
"id": "acct_...",
"accountId": "...",
"userId": "...",
"authMode": "agentIdentity",
"agentRuntimeId": "...",
"agentPrivateKey": "...",
"taskId": "..."
}]
}
```
OpenAI 的 CPR 导出保持 OAuth 账号的既有 token 与过期时间字段。
OpenAI rotation 请求字段为:
+2 -2
View File
@@ -59,7 +59,7 @@ flowchart LR
| `gateway-api` | HTTP/WS/SSE 解码与交付、Admin wire、静态管理端;不直接访问 Store 或具体 Provider |
| `gateway-store` | PostgreSQL、Redis、S3/R2、`pg_dump` 适配器;不拥有业务策略 |
| `gateway-host` | 配置加载、日志、HTTP 生命周期、Worker 监督和系统更新 |
| `providers/openai` | OpenAI OAuth/Agent Identity、账号选择、目录、额度、Responses/Images transport |
| `providers/openai` | OpenAI OAuth、账号选择、目录、额度、Responses/Images transport |
| `providers/xai` | xAI OAuth session、账号选择、目录、额度和 Grok/Responses 转换 |
| `frontend` | Vue 管理端,仅通过 Admin API 读写状态 |
@@ -189,7 +189,7 @@ PostgreSQL 周期对账才是正确性基础。
credential 与 quota 是两组独立事实:credential refresh 不等于 quota refresh,额度接口的 401/403
也不能单独证明 refresh token 永久失效。
- OpenAI 支持 OAuth、AT/RT、OAuth JSON 与 Agent Identity;RT-only 导入先换取 AT,AT-only 导入没有
- OpenAI 支持 OAuth、AT/RT 与 OAuth JSON;RT-only 导入先换取 AT,AT-only 导入没有
自动续期能力。OAuth 身份只从官方 JWT claims 投影,不信任导入文档顶层身份字段。
- xAI 使用 OAuth session;API Key 不是受支持的账号 credential。
- 新账号导入和首次 OAuth 在 credential 提交后尽力读取一次额度;失败只留下观测,不回滚账号事务。
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { Key, Openai, Robot, Xai } from '@boxicons/vue'
import { Key, Openai, Xai } from '@boxicons/vue'
import { computed } from 'vue'
import { formatProviderLabel } from '@/utils/providers'
@@ -28,8 +28,6 @@ const iconClass = computed(() => (props.size === 'sm' ? 'size-3' : 'size-4'))
const providerLabel = computed(() => formatProviderLabel(props.provider, '未知平台'))
const authenticationLabel = computed(() => {
if (normalizedAuthenticationKind.value === 'agent_identity')
return 'Agent Identity'
if (normalizedAuthenticationKind.value === 'oauth')
return 'OAuth'
return props.authenticationKind?.trim() || '未知认证类型'
@@ -59,11 +57,6 @@ const authenticationLabel = computed(() => {
:class="iconClass"
aria-hidden="true"
/>
<Robot
v-else-if="normalizedAuthenticationKind === 'agent_identity'"
:class="iconClass"
aria-hidden="true"
/>
<span v-else class="text-[10px] font-heavy text-cp-text-quaternary">?</span>
</span>
@@ -17,7 +17,6 @@ const modeOptions = {
{ label: 'AT', value: 'access_token' },
{ label: 'RT', value: 'refresh_token' },
{ label: '账号文件', value: 'json' },
{ label: 'Agent 身份', value: 'agent_identity' },
],
xai: [
{ label: 'OAuth', value: 'oauth' },
@@ -89,8 +88,6 @@ function resolveModal(
description = '逐行粘贴 Access Token;未包含 Refresh Token 时无法自动续期'
else if (input.form.mode === 'refresh_token')
description = '逐行粘贴 Refresh Token,导入时将自动换取 Access Token'
else if (input.form.mode === 'agent_identity')
description = '粘贴或上传 Agent 身份文件,匹配已有账号时更新凭据'
return {
title: '导入账号',
@@ -150,13 +147,6 @@ function resolveImportInput(
uploadable: false,
}
}
if (form.mode === 'agent_identity') {
return {
label: 'Agent 身份文件',
placeholder: '粘贴 Agent 身份文件内容',
uploadable: true,
}
}
if (provider === 'batch') {
return {
label: '批量账号文件',
+1 -1
View File
@@ -47,7 +47,7 @@ README 保持用户导向;不要把上游 URL、重试常量、数据库字段
- 数据面公开 Responses JSON/SSE/WS、review、Images generation/edit 和模型目录;不提供 Chat Completions。
- OpenAI Responses/Images 保持业务 wire 透明;xAI 在 Provider 内完成 Grok/Responses 转换。
- Images 固定走 OpenAI Provider 自有端点,不要求模型,也不参与文本模型映射。
- OpenAI 账号支持 OAuth、AT/RT、OAuth JSON 和 Agent Identity;xAI 只接受 OAuth session/账号 JSON,
- OpenAI 账号支持 OAuth、AT/RT 和 OAuth JSON;xAI 只接受 OAuth session/账号 JSON,
不接受 API Key。
- 主动额度重置卡只由 OpenAI 上游持有。查询由用户显式触发,消费使用 UUIDv4 幂等键;不得写本地卡库存
或直接改 quota reset 时间。
@@ -90,7 +90,7 @@ OpenAI continuation 按 native/replay-owner/replay-any 推进,xAI 使用客户
### 导入与授权
- 公共导入合同始终是 `{ provider, data }`,其中 `data` 为 Provider-owned JSON object。
- OpenAI 支持 OAuth JSON、`accounts` 数组、AT-only、RT-only、AT+RT+可选 ID Token 和 Agent Identity;
- OpenAI 支持 OAuth JSON、`accounts` 数组、AT-only、RT-only 和 AT+RT+可选 ID Token;
最多 200 项。RT-only 先交换 AT,AT-only 不会凭空获得刷新能力。
- OpenAI OAuth 身份来自 `parse_chatgpt_jwt_claims`,不信任导入 object 顶层的 user/account ID。
- 前端 AT/RT 标签只把“每行一个 token”转换成 `accounts` JSON,没有第二条导入 API。