Merge pull request #688 from AAEE86/feat/agent-identity-support

feat(codex): support agent identity accounts
This commit is contained in:
ZheFox
2026-07-22 09:20:11 +08:00
committed by GitHub
20 changed files with 1763 additions and 17 deletions
Generated
+121
View File
@@ -554,6 +554,9 @@ dependencies = [
"async-trait",
"axum",
"base64 0.22.1",
"chrono",
"crypto_box",
"ed25519-dalek",
"http",
"regex",
"reqwest",
@@ -1156,6 +1159,15 @@ dependencies = [
"serde_core",
]
[[package]]
name = "blake2"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
dependencies = [
"digest",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
@@ -1337,6 +1349,7 @@ checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
"zeroize",
]
[[package]]
@@ -1621,6 +1634,36 @@ dependencies = [
"typenum",
]
[[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",
"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 = "csscolorparser"
version = "0.6.2"
@@ -1640,6 +1683,33 @@ dependencies = [
"cipher",
]
[[package]]
name = "curve25519-dalek"
version = "4.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
dependencies = [
"cfg-if",
"cpufeatures",
"curve25519-dalek-derive",
"digest",
"fiat-crypto",
"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.117",
]
[[package]]
name = "darling"
version = "0.23.0"
@@ -1800,6 +1870,30 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
[[package]]
name = "ed25519"
version = "2.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
dependencies = [
"pkcs8",
"signature",
]
[[package]]
name = "ed25519-dalek"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
dependencies = [
"curve25519-dalek",
"ed25519",
"serde",
"sha2",
"subtle",
"zeroize",
]
[[package]]
name = "either"
version = "1.15.0"
@@ -1872,6 +1966,12 @@ version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
[[package]]
name = "fiat-crypto"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
[[package]]
name = "filedescriptor"
version = "0.8.3"
@@ -2110,6 +2210,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
"zeroize",
]
[[package]]
@@ -3365,6 +3466,17 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]]
name = "poly1305"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
dependencies = [
"cpufeatures",
"opaque-debug",
"universal-hash",
]
[[package]]
name = "polyval"
version = "0.6.2"
@@ -3993,6 +4105,15 @@ 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"
+2
View File
@@ -110,6 +110,8 @@ bytes = "1"
cbc = "0.1"
chrono = { version = "0.4", features = ["serde"] }
chrono-tz = "0.10"
crypto_box = { version = "0.9", features = ["seal"] }
ed25519-dalek = { version = "2.2", features = ["pkcs8"] }
flate2 = "1"
futures-util = "0.3"
hmac = "0.12"
@@ -37,6 +37,15 @@ pub(crate) async fn refresh_oauth_plan_auth_for_retry(
}
};
if aether_provider_transport::is_codex_agent_identity_transport(&transport)
&& !aether_provider_transport::is_codex_agent_identity_invalid_task_response(
status_code,
response_text,
)
{
return false;
}
if transport.key.decrypted_auth_config.is_none()
&& !transport.key.auth_type.trim().eq_ignore_ascii_case("oauth")
{
@@ -53,6 +53,130 @@ fn sanitize_windsurf_batch_import_error(error: &OAuthError) -> String {
}
}
fn copy_codex_agent_identity_field(
auth_config: &mut Map<String, Value>,
nested: &Map<String, Value>,
canonical_key: &str,
aliases: &[&str],
) {
if auth_config.contains_key(canonical_key) {
return;
}
if let Some(value) = aliases.iter().find_map(|key| nested.get(*key)).cloned() {
auth_config.insert(canonical_key.to_string(), value);
}
}
fn remove_codex_agent_identity_oauth_tokens(auth_config: &mut Map<String, Value>) {
for key in [
"access_token",
"accessToken",
"refresh_token",
"refreshToken",
"id_token",
"idToken",
"expires_at",
"expiresAt",
"expires_in",
"expiresIn",
] {
auth_config.remove(key);
}
}
fn codex_agent_identity_auth_config_from_import(
entry: &AdminProviderOAuthBatchImportEntry,
) -> Result<Option<Map<String, Value>>, String> {
let Some(raw_credentials) = entry.raw_credentials.as_ref() else {
return Ok(None);
};
if !aether_provider_transport::is_codex_agent_identity_auth_config_value(raw_credentials) {
return Ok(None);
}
let mut auth_config = raw_credentials
.as_object()
.cloned()
.ok_or_else(|| "Agent Identity 凭据必须是 JSON 对象".to_string())?;
remove_codex_agent_identity_oauth_tokens(&mut auth_config);
for nested_key in ["agent_identity", "agentIdentity"] {
if let Some(nested) = auth_config
.get_mut(nested_key)
.and_then(Value::as_object_mut)
{
remove_codex_agent_identity_oauth_tokens(nested);
}
}
let nested = auth_config
.get("agent_identity")
.or_else(|| auth_config.get("agentIdentity"))
.and_then(Value::as_object)
.cloned();
let root = auth_config.clone();
for (canonical_key, aliases) in [
(
"agent_runtime_id",
&["agent_runtime_id", "agentRuntimeId"][..],
),
(
"agent_private_key",
&["agent_private_key", "agentPrivateKey"][..],
),
("task_id", &["task_id", "taskId"][..]),
(
"account_id",
&[
"account_id",
"accountId",
"chatgpt_account_id",
"chatgptAccountId",
][..],
),
(
"account_user_id",
&[
"account_user_id",
"accountUserId",
"chatgpt_account_user_id",
"chatgptAccountUserId",
][..],
),
(
"user_id",
&["user_id", "userId", "chatgpt_user_id", "chatgptUserId"][..],
),
("email", &["email"][..]),
(
"plan_type",
&[
"plan_type",
"planType",
"chatgpt_plan_type",
"chatgptPlanType",
][..],
),
("account_name", &["account_name", "accountName"][..]),
(
"is_fedramp",
&[
"is_fedramp",
"chatgpt_account_is_fedramp",
"chatgptAccountIsFedramp",
][..],
),
] {
if let Some(nested) = nested.as_ref() {
copy_codex_agent_identity_field(&mut auth_config, nested, canonical_key, aliases);
}
copy_codex_agent_identity_field(&mut auth_config, &root, canonical_key, aliases);
}
auth_config.insert("provider_type".to_string(), json!("codex"));
auth_config.insert("auth_mode".to_string(), json!("agentIdentity"));
aether_provider_transport::validate_codex_agent_identity_auth_config(&Value::Object(
auth_config.clone(),
))?;
Ok(Some(auth_config))
}
pub(super) fn estimate_admin_provider_oauth_batch_import_total(
provider_type: &str,
raw_credentials: &str,
@@ -103,6 +227,18 @@ async fn resolve_admin_provider_oauth_batch_import_tokens(
entry: &AdminProviderOAuthBatchImportEntry,
request_proxy: Option<ProxySnapshot>,
) -> Result<AdminProviderOAuthResolvedBatchImport, String> {
if provider_type.eq_ignore_ascii_case("codex") {
if let Some(auth_config) = codex_agent_identity_auth_config_from_import(entry)? {
return Ok(AdminProviderOAuthResolvedBatchImport {
// Agent Identity signs an assertion for every request. The existing OAuth
// record keeps a placeholder in its encrypted token column only.
access_token: "__placeholder__".to_string(),
auth_config,
expires_at: None,
});
}
}
let refresh_token = entry
.refresh_token
.as_deref()
@@ -546,8 +682,12 @@ pub(super) async fn execute_admin_provider_oauth_batch_import(
#[cfg(test)]
mod tests {
use super::sanitize_windsurf_batch_import_error;
use super::super::parse::parse_admin_provider_oauth_batch_import_entries;
use super::{
codex_agent_identity_auth_config_from_import, sanitize_windsurf_batch_import_error,
};
use aether_oauth::core::OAuthError;
use serde_json::json;
#[test]
fn windsurf_batch_import_error_redacts_http_body() {
@@ -572,4 +712,54 @@ mod tests {
assert!(!detail.contains("sk-secret"));
assert!(!detail.contains("secret-token"));
}
#[test]
fn normalizes_codex_agent_identity_import_without_access_token() {
let entries = parse_admin_provider_oauth_batch_import_entries(
"codex",
r#"{
"type":"sub2api-data",
"version":1,
"accounts":[{
"name":"agent@example.com",
"platform":"openai",
"credentials":{
"auth_mode":"agentIdentity",
"id_token":"stale-id-token",
"agent_identity":{
"agent_runtime_id":"runtime-1",
"agent_private_key":"MC4CAQAwBQYDK2VwBCIEIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"accountId":"account-1",
"chatgptUserId":"user-1",
"chatgptAccountIsFedramp":true,
"access_token":"stale-access-token"
}
}
}]
}"#,
);
let auth_config = codex_agent_identity_auth_config_from_import(&entries[0])
.expect("Agent Identity import should validate")
.expect("Agent Identity config should be recognized");
assert_eq!(auth_config.get("provider_type"), Some(&json!("codex")));
assert_eq!(auth_config.get("auth_mode"), Some(&json!("agentIdentity")));
assert_eq!(
auth_config.get("agent_runtime_id"),
Some(&json!("runtime-1"))
);
assert_eq!(auth_config.get("account_id"), Some(&json!("account-1")));
assert_eq!(auth_config.get("user_id"), Some(&json!("user-1")));
assert_eq!(auth_config.get("is_fedramp"), Some(&json!(true)));
assert_eq!(
auth_config.get("account_name"),
Some(&json!("agent@example.com"))
);
assert!(!auth_config.contains_key("id_token"));
assert!(auth_config
.get("agent_identity")
.and_then(serde_json::Value::as_object)
.is_some_and(|nested| !nested.contains_key("access_token")));
}
}
@@ -215,6 +215,34 @@ fn extract_admin_provider_oauth_batch_import_entry(
serde_json::Value::Object(object) => {
let is_grok = provider_type.trim().eq_ignore_ascii_case("grok");
let is_windsurf = provider_type.trim().eq_ignore_ascii_case("windsurf");
let is_codex_agent_identity = provider_type.trim().eq_ignore_ascii_case("codex")
&& aether_provider_transport::is_codex_agent_identity_auth_config_value(item);
if is_codex_agent_identity {
return Some(AdminProviderOAuthBatchImportEntry {
parse_error: None,
refresh_token: None,
access_token: None,
export_access_token: None,
raw_credentials: Some(item.clone()),
expires_at: None,
account_id: None,
account_user_id: None,
plan_type: None,
pool_tier: None,
user_id: None,
email: None,
account_name: None,
project_id: None,
client_version: None,
session_id: None,
sso_rw_token: None,
cf_cookies: None,
cf_clearance: None,
request_headers: None,
user_agent: None,
browser_profile: None,
});
}
let refresh_token = coerce_admin_provider_oauth_import_str(
object
.get("refresh_token")
@@ -434,6 +462,99 @@ fn extract_admin_provider_oauth_batch_import_entry(
}
}
fn parse_sub2api_export_accounts(
provider_type: &str,
object: &serde_json::Map<String, serde_json::Value>,
) -> Option<Vec<AdminProviderOAuthBatchImportEntry>> {
if !provider_type.trim().eq_ignore_ascii_case("codex") {
return None;
}
let is_sub2api_export = object
.get("type")
.and_then(serde_json::Value::as_str)
.is_some_and(|value| value.trim().eq_ignore_ascii_case("sub2api-data"));
if !is_sub2api_export {
return None;
}
let Some(accounts) = object.get("accounts").and_then(serde_json::Value::as_array) else {
return Some(vec![parse_error_entry(
"sub2api 导出缺少 accounts 数组".to_string(),
)]);
};
let mut entries = Vec::new();
for (index, account) in accounts.iter().enumerate() {
let Some(account) = account.as_object() else {
entries.push(parse_error_entry(format!(
"sub2api 第 {} 个账号必须是 JSON 对象",
index + 1
)));
continue;
};
if account
.get("platform")
.and_then(serde_json::Value::as_str)
.is_some_and(|platform| !platform.trim().eq_ignore_ascii_case("openai"))
{
continue;
}
let Some(mut credentials) = account
.get("credentials")
.and_then(serde_json::Value::as_object)
.cloned()
else {
entries.push(parse_error_entry(format!(
"sub2api 第 {} 个账号缺少 credentials 对象",
index + 1
)));
continue;
};
if let Some(name) = account
.get("name")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
credentials
.entry("account_name".to_string())
.or_insert_with(|| json!(name));
}
if let Some(extra) = account.get("extra").and_then(serde_json::Value::as_object) {
for key in [
"account_id",
"chatgpt_account_id",
"chatgpt_user_id",
"chatgpt_account_is_fedramp",
"email",
"plan_type",
"workspace_id",
] {
if let Some(value) = extra.get(key).cloned() {
credentials.entry(key.to_string()).or_insert(value);
}
}
}
let credentials = serde_json::Value::Object(credentials);
match extract_admin_provider_oauth_batch_import_entry(provider_type, &credentials) {
Some(entry) => entries.push(entry),
None => entries.push(parse_error_entry(format!(
"sub2api 第 {} 个账号没有可导入的凭据",
index + 1
))),
}
}
if entries.is_empty() {
entries.push(parse_error_entry(
"sub2api 导出中没有可导入的 OpenAI 账号".to_string(),
));
}
Some(entries)
}
pub(super) fn parse_admin_provider_oauth_batch_import_entries(
provider_type: &str,
raw_credentials: &str,
@@ -459,12 +580,15 @@ pub(super) fn parse_admin_provider_oauth_batch_import_entries(
}
if raw.starts_with('{') {
if let Ok(value @ serde_json::Value::Object(_)) =
serde_json::from_str::<serde_json::Value>(raw)
{
return extract_admin_provider_oauth_batch_import_entry(provider_type, &value)
.into_iter()
.collect();
if let Ok(value) = serde_json::from_str::<serde_json::Value>(raw) {
if let Some(object) = value.as_object() {
if let Some(entries) = parse_sub2api_export_accounts(provider_type, object) {
return entries;
}
return extract_admin_provider_oauth_batch_import_entry(provider_type, &value)
.into_iter()
.collect();
}
}
}
@@ -766,6 +890,76 @@ mod tests {
assert_eq!(entries[0].email.as_deref(), Some("u@example.com"));
}
#[test]
fn preserves_codex_agent_identity_entry_without_access_token() {
let entries = parse_admin_provider_oauth_batch_import_entries(
"codex",
r#"{"auth_mode":"agentIdentity","agent_identity":{"agent_runtime_id":"runtime-1","agent_private_key":"not-validated-until-import"}}"#,
);
assert_eq!(entries.len(), 1);
assert!(entries[0].refresh_token.is_none());
assert!(entries[0].access_token.is_none());
assert_eq!(
entries[0]
.raw_credentials
.as_ref()
.and_then(|value| value.get("auth_mode")),
Some(&json!("agentIdentity"))
);
}
#[test]
fn unwraps_sub2api_agent_identity_export_accounts() {
let entries = parse_admin_provider_oauth_batch_import_entries(
"codex",
r#"{
"type":"sub2api-data",
"version":1,
"accounts":[
{
"name":"agent@example.com",
"platform":"openai",
"type":"oauth",
"credentials":{
"auth_mode":"agentIdentity",
"agent_runtime_id":"runtime-1",
"agent_private_key":"test-key",
"task_id":"task-1",
"chatgpt_account_id":"account-1"
},
"extra":{
"email":"agent@example.com",
"chatgpt_user_id":"user-1"
}
},
{
"name":"unrelated@example.com",
"platform":"anthropic",
"credentials":{"access_token":"ignored-token"}
}
]
}"#,
);
assert_eq!(entries.len(), 1);
assert!(entries[0].parse_error.is_none());
assert!(entries[0].refresh_token.is_none());
assert!(entries[0].access_token.is_none());
let credentials = entries[0]
.raw_credentials
.as_ref()
.and_then(serde_json::Value::as_object)
.expect("Agent Identity credentials should be preserved");
assert_eq!(credentials.get("auth_mode"), Some(&json!("agentIdentity")));
assert_eq!(
credentials.get("account_name"),
Some(&json!("agent@example.com"))
);
assert_eq!(credentials.get("email"), Some(&json!("agent@example.com")));
assert_eq!(credentials.get("chatgpt_user_id"), Some(&json!("user-1")));
}
#[test]
fn parses_codex_import_header_overrides() {
let entries = parse_admin_provider_oauth_batch_import_entries(
@@ -448,7 +448,69 @@ pub(super) async fn execute_provider_quota_plan(
quota_kind: &str,
) -> Result<ProviderQuotaExecutionOutcome, GatewayError> {
match state.execute_execution_runtime_sync_plan(None, &plan).await {
Ok(result) => Ok(ProviderQuotaExecutionOutcome::Response(result)),
Ok(result) => {
if !crate::provider_transport::is_codex_agent_identity_transport(transport)
|| !crate::provider_transport::is_codex_agent_identity_invalid_task_response(
result.status_code,
extract_execution_error_message(&result).as_deref(),
)
{
return Ok(ProviderQuotaExecutionOutcome::Response(result));
}
let refreshed_entry = match state.force_local_oauth_refresh_entry(transport).await {
Ok(Some(entry)) => entry,
Ok(None) => {
return Ok(ProviderQuotaExecutionOutcome::Failure(
"Agent Identity 任务重注册未返回认证信息".to_string(),
));
}
Err(error) => {
warn!(
key_id = %transport.key.id,
endpoint_id = %transport.endpoint.id,
quota_kind = %quota_kind,
error = %error,
"gateway Agent Identity quota task recovery failed"
);
return Ok(ProviderQuotaExecutionOutcome::Failure(format!(
"Agent Identity 任务重注册失败: {error}"
)));
}
};
let header_name = refreshed_entry.auth_header_name.trim().to_ascii_lowercase();
let header_value = refreshed_entry.auth_header_value.trim();
if header_name.is_empty() || header_value.is_empty() {
return Ok(ProviderQuotaExecutionOutcome::Failure(
"Agent Identity 任务重注册未返回有效认证信息".to_string(),
));
}
let mut retry_plan = plan.clone();
retry_plan
.headers
.retain(|name, _| !name.eq_ignore_ascii_case(&header_name));
retry_plan
.headers
.insert(header_name, header_value.to_string());
match state
.execute_execution_runtime_sync_plan(None, &retry_plan)
.await
{
Ok(result) => Ok(ProviderQuotaExecutionOutcome::Response(result)),
Err(error) => {
let error = error.into_message();
warn!(
key_id = %transport.key.id,
endpoint_id = %transport.endpoint.id,
quota_kind = %quota_kind,
error = %error,
"gateway Agent Identity quota task recovery retry failed"
);
Ok(ProviderQuotaExecutionOutcome::Failure(error))
}
}
}
Err(err) => {
let error = err.into_message();
let proxy_node_id = plan
@@ -52,6 +52,19 @@ pub(crate) async fn build_admin_create_provider_key_record(
.and_then(serde_json::Value::as_object)
.cloned();
if auth_type == "oauth"
&& provider.provider_type.trim().eq_ignore_ascii_case("codex")
&& auth_config
.as_ref()
.is_some_and(aether_provider_transport::is_codex_agent_identity_auth_config_value)
{
aether_provider_transport::validate_codex_agent_identity_auth_config(
auth_config
.as_ref()
.expect("Agent Identity auth_config was checked"),
)?;
}
match auth_type.as_str() {
"service_account" if auth_config_object.is_none() => {
return Err("Service Account 认证模式下 auth_config 为必填字段".to_string());
@@ -78,6 +78,19 @@ pub(crate) fn build_admin_update_provider_key_record_with_existing_keys(
.and_then(serde_json::Value::as_object)
.cloned();
if target_auth_type == "oauth"
&& provider.provider_type.trim().eq_ignore_ascii_case("codex")
&& auth_config
.as_ref()
.is_some_and(aether_provider_transport::is_codex_agent_identity_auth_config_value)
{
aether_provider_transport::validate_codex_agent_identity_auth_config(
auth_config
.as_ref()
.expect("Agent Identity auth_config was checked"),
)?;
}
match target_auth_type.as_str() {
"api_key" | "bearer" => {
if let Some(api_key) = api_key_value
+27 -3
View File
@@ -81,8 +81,10 @@ static ACCESS_TOKEN_REGEX: LazyLock<Regex> = LazyLock::new(|| {
.expect("access token regex should compile")
});
static SECRET_KEY_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r#"(?i)\bsecret[_-]?key\s*[:=]\s*["']?[A-Za-z0-9._~+/=-]{20,}"#)
.expect("secret key regex should compile")
Regex::new(
r#"(?i)\b(?:secret|agent[_-]?private)[_-]?key\s*[:=]\s*["']?[A-Za-z0-9._~+/=-]{20,}"#,
)
.expect("secret key regex should compile")
});
static HIGH_ENTROPY_TOKEN_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\b[A-Za-z0-9_-]{32,}\b").expect("api key regex should compile"));
@@ -3528,7 +3530,13 @@ fn detect_candidates_with_probe(
is_valid_named_token,
);
}
if input.contains("secret_key") || input.contains("secret-key") || input.contains("SecretKey") {
if input.contains("secret_key")
|| input.contains("secret-key")
|| input.contains("SecretKey")
|| input.contains("agent_private_key")
|| input.contains("agent-private-key")
|| input.contains("agentPrivateKey")
{
push_regex_candidates(
input,
&SECRET_KEY_REGEX,
@@ -4414,6 +4422,22 @@ mod tests {
);
}
#[test]
fn pii_redaction_hides_agent_identity_private_keys() {
let private_key =
"agent_private_key=MC4CAQAwBQYDK2VwBCIEIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
let mut session = session_at(601);
let redacted = session.redact_text(private_key);
assert!(!redacted.text.contains(private_key));
assert!(redacted.text.contains("<AETHER:SECRET_KEY:"));
assert!(redacted
.matches
.iter()
.any(|matched| matched.kind == Some(RedactionKind::SecretKey)));
}
#[test]
fn pii_redaction_session_uses_configured_sentinel_namespace() {
let mut session = RedactionSession::new(
+69 -1
View File
@@ -1567,6 +1567,68 @@ impl AppState {
return Ok(());
};
if provider_transport::is_codex_agent_identity_cached_entry(entry) {
let metadata = entry.metadata.as_ref().ok_or_else(|| {
GatewayError::Internal(
"Agent Identity task registration produced no auth_config".to_string(),
)
})?;
provider_transport::validate_codex_agent_identity_auth_config(metadata)
.map_err(GatewayError::Internal)?;
let auth_config = serde_json::to_string(metadata)
.map_err(|err| GatewayError::Internal(err.to_string()))?;
let encrypted_auth_config =
encrypt_python_fernet_plaintext(encryption_key, &auth_config)
.map_err(|err| GatewayError::Internal(err.to_string()))?;
let Some(mut latest_key) = self
.data
.list_provider_catalog_keys_by_ids(&[key_id.to_string()])
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?
.into_iter()
.next()
else {
return Ok(());
};
latest_key.encrypted_auth_config = Some(encrypted_auth_config);
latest_key.encrypted_api_key = Some(
encrypt_python_fernet_plaintext(encryption_key, "__placeholder__")
.map_err(|err| GatewayError::Internal(err.to_string()))?,
);
latest_key.expires_at_unix_secs = None;
let (oauth_invalid_at_unix_secs, oauth_invalid_reason) =
local_oauth_refresh_success_invalid_state(&latest_key);
latest_key.oauth_invalid_at_unix_secs = oauth_invalid_at_unix_secs;
latest_key.oauth_invalid_reason = oauth_invalid_reason;
latest_key.updated_at_unix_secs = Some(
SystemTime::now()
.duration_since(UNIX_EPOCH)
.ok()
.map(|duration| duration.as_secs())
.unwrap_or(0),
);
let current_status_snapshot = latest_key.status_snapshot.take();
latest_key.status_snapshot =
sync_provider_key_oauth_status_snapshot(current_status_snapshot, &latest_key);
let updated = self
.update_provider_catalog_key(&latest_key)
.await?
.is_some();
if updated {
self.clear_provider_transport_snapshot_cache();
}
tracing::info!(
key_id = %key_id,
provider_id = %transport.provider.id,
provider_type = %transport.provider.provider_type,
updated,
"gateway Agent Identity task registration persisted"
);
return Ok(());
}
let access_token = entry
.auth_header_value
.trim()
@@ -1944,7 +2006,13 @@ impl AppState {
request_refresh_token_fingerprint = request_refresh_token_fingerprint
.as_deref()
.unwrap_or("-"),
body_excerpt = %local_oauth_log_excerpt(response_body_text.as_str()),
body_excerpt = %if request.request_id
== provider_transport::CODEX_AGENT_IDENTITY_TASK_REGISTRATION_REQUEST_ID
{
"[redacted]".to_string()
} else {
local_oauth_log_excerpt(response_body_text.as_str())
},
"gateway local oauth execution response returned error"
);
}
@@ -821,10 +821,25 @@ pub fn parse_codex_auth_identity(decrypted_auth_config_raw: Option<&str>) -> Cod
let namespaced_auth = value
.get("https://api.openai.com/auth")
.and_then(Value::as_object);
let agent_identity = value
.get("agent_identity")
.or_else(|| value.get("agentIdentity"))
.and_then(Value::as_object);
let account_id = value
.get("account_id")
.or_else(|| value.get("accountId"))
.or_else(|| value.get("chatgpt_account_id"))
.or_else(|| value.get("chatgptAccountId"))
.or_else(|| namespaced_auth.and_then(|auth| auth.get("chatgpt_account_id")))
.or_else(|| {
agent_identity.and_then(|identity| {
identity
.get("account_id")
.or_else(|| identity.get("accountId"))
.or_else(|| identity.get("chatgpt_account_id"))
.or_else(|| identity.get("chatgptAccountId"))
})
})
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
@@ -832,14 +847,28 @@ pub fn parse_codex_auth_identity(decrypted_auth_config_raw: Option<&str>) -> Cod
let is_fedramp = value
.get("is_fedramp")
.or_else(|| value.get("chatgpt_account_is_fedramp"))
.or_else(|| value.get("chatgptAccountIsFedramp"))
.or_else(|| namespaced_auth.and_then(|auth| auth.get("chatgpt_account_is_fedramp")))
.or_else(|| {
agent_identity.and_then(|identity| {
identity
.get("is_fedramp")
.or_else(|| identity.get("chatgpt_account_is_fedramp"))
.or_else(|| identity.get("chatgptAccountIsFedramp"))
})
})
.and_then(Value::as_bool)
.unwrap_or(false);
let uses_codex_backend = account_id.is_some()
|| value
.get("provider_type")
.and_then(Value::as_str)
.is_some_and(|provider_type| provider_type.trim().eq_ignore_ascii_case("codex"));
.is_some_and(|provider_type| provider_type.trim().eq_ignore_ascii_case("codex"))
|| value
.get("auth_mode")
.or_else(|| value.get("authMode"))
.and_then(Value::as_str)
.is_some_and(|auth_mode| auth_mode.trim().eq_ignore_ascii_case("agentIdentity"));
CodexAuthIdentity {
account_id,
@@ -2525,6 +2554,38 @@ mod tests {
);
}
#[test]
fn codex_identity_headers_support_nested_agent_identity_export() {
let mut headers = std::collections::BTreeMap::new();
apply_codex_openai_special_headers(
&mut headers,
&json!({"model": "gpt-5.6-sol", "input": []}),
&http::HeaderMap::new(),
"codex",
"openai:responses",
Some("request-agent-identity"),
Some(
r#"{
"auth_mode": "agentIdentity",
"agent_identity": {
"accountId": "account-agent",
"chatgptAccountIsFedramp": true
}
}"#,
),
);
assert_eq!(
headers.get("chatgpt-account-id").map(String::as_str),
Some("account-agent")
);
assert_eq!(
headers.get("x-openai-fedramp").map(String::as_str),
Some("true")
);
}
#[test]
fn codex_search_uses_identity_headers_without_responses_protocol_headers() {
let mut headers = std::collections::BTreeMap::from([
+24
View File
@@ -160,6 +160,30 @@ mod tests {
);
}
#[test]
fn codex_agent_identity_quota_request_prefers_dynamic_assertion() {
let spec = build_codex_pool_quota_request(
"key-1",
Some((
"authorization".to_string(),
"AgentAssertion signed-at-request-time".to_string(),
)),
None,
Some(&json!({
"auth_mode": "agentIdentity",
"headers": {
"authorization": "Bearer stale-imported-session"
}
})),
)
.expect("spec should build");
assert_eq!(
spec.headers.get("authorization").map(String::as_str),
Some("AgentAssertion signed-at-request-time")
);
}
#[test]
fn gemini_cli_quota_request_uses_v1internal_retrieve_user_quota() {
let spec = build_gemini_cli_pool_quota_request(
@@ -92,7 +92,22 @@ fn build_codex_wham_headers(
.map(str::trim)
.filter(|value| !value.is_empty());
if let Some(authorization) = auth_config_authorization {
let is_agent_identity = auth_config
.and_then(Value::as_object)
.and_then(|object| {
object
.get("auth_mode")
.or_else(|| object.get("authMode"))
.and_then(Value::as_str)
})
.is_some_and(|value| value.trim().eq_ignore_ascii_case("agentIdentity"));
if is_agent_identity {
let Some((name, value)) = resolved_oauth_auth else {
return Err("缺少 Agent Identity 认证信息,请先注册任务".to_string());
};
headers.insert(name.to_ascii_lowercase(), value);
} else if let Some(authorization) = auth_config_authorization {
headers.insert("authorization".to_string(), authorization.to_string());
} else if let Some((name, value)) = resolved_oauth_auth {
headers.insert(name.to_ascii_lowercase(), value);
@@ -16,6 +16,9 @@ aether-runtime-state.workspace = true
aether-video-tasks-core.workspace = true
async-trait.workspace = true
base64.workspace = true
chrono.workspace = true
crypto_box.workspace = true
ed25519-dalek.workspace = true
http.workspace = true
regex.workspace = true
reqwest.workspace = true
@@ -0,0 +1,784 @@
use std::collections::BTreeMap;
use async_trait::async_trait;
use base64::{
engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD},
Engine as _,
};
use chrono::{DateTime, SecondsFormat, Utc};
use crypto_box::SecretKey as Curve25519SecretKey;
use ed25519_dalek::{pkcs8::DecodePrivateKey, Signer, SigningKey};
use serde::Deserialize;
use serde_json::{json, Map, Value};
use sha2::{Digest, Sha512};
use url::Url;
use super::oauth_refresh::{
CachedOAuthEntry, LocalOAuthHttpExecutor, LocalOAuthHttpRequest, LocalOAuthRefreshAdapter,
LocalOAuthRefreshError, LocalResolvedOAuthRequestAuth,
};
use super::snapshot::GatewayProviderTransportSnapshot;
pub const CODEX_AGENT_IDENTITY_AUTH_MODE: &str = "agentIdentity";
pub const CODEX_AGENT_IDENTITY_PROVIDER_TYPE: &str = "codex";
pub const CODEX_AGENT_IDENTITY_CACHED_ENTRY_PROVIDER_TYPE: &str = "codex_agent_identity";
pub const CODEX_AGENT_IDENTITY_TASK_REGISTRATION_REQUEST_ID: &str =
"codex:agent-identity-task-register";
const CODEX_AGENT_IDENTITY_AUTH_API_BASE_URL: &str = "https://auth.openai.com/api/accounts";
const AUTHORIZATION_HEADER: &str = "authorization";
const ASSERTION_PREFIX: &str = "AgentAssertion ";
#[derive(Clone)]
struct AgentIdentityCredentials {
runtime_id: String,
signing_key: SigningKey,
task_id: Option<String>,
}
#[derive(Debug, Deserialize)]
struct AgentTaskRegistrationResponse {
#[serde(default)]
task_id: Option<String>,
#[serde(default, rename = "taskId")]
task_id_camel: Option<String>,
#[serde(default)]
encrypted_task_id: Option<String>,
#[serde(default, rename = "encryptedTaskId")]
encrypted_task_id_camel: Option<String>,
}
#[derive(Debug, Clone)]
pub struct CodexAgentIdentityRefreshAdapter {
auth_api_base_url: String,
}
impl Default for CodexAgentIdentityRefreshAdapter {
fn default() -> Self {
Self {
auth_api_base_url: CODEX_AGENT_IDENTITY_AUTH_API_BASE_URL.to_string(),
}
}
}
impl CodexAgentIdentityRefreshAdapter {
pub fn with_auth_api_base_url_for_tests(mut self, base_url: impl Into<String>) -> Self {
self.auth_api_base_url = base_url.into();
self
}
fn config_from_transport(transport: &GatewayProviderTransportSnapshot) -> Option<Value> {
transport
.key
.decrypted_auth_config
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.and_then(|value| serde_json::from_str::<Value>(value).ok())
}
fn config_from_entry(entry: &CachedOAuthEntry) -> Option<Value> {
entry
.provider_type
.trim()
.eq_ignore_ascii_case(CODEX_AGENT_IDENTITY_CACHED_ENTRY_PROVIDER_TYPE)
.then(|| entry.metadata.clone())
.flatten()
}
fn resolve_from_config(config: &Value) -> Option<LocalResolvedOAuthRequestAuth> {
let credentials = agent_identity_credentials(config).ok()?;
let task_id = credentials.task_id.as_deref()?;
let value = build_agent_assertion(&credentials, task_id, Utc::now()).ok()?;
Some(LocalResolvedOAuthRequestAuth::Header {
name: AUTHORIZATION_HEADER.to_string(),
value,
})
}
fn preferred_config(
transport: &GatewayProviderTransportSnapshot,
entry: Option<&CachedOAuthEntry>,
) -> Option<Value> {
Self::config_from_transport(transport).or_else(|| entry.and_then(Self::config_from_entry))
}
}
pub fn is_codex_agent_identity_auth_config_value(config: &Value) -> bool {
let Some(root) = config.as_object() else {
return false;
};
let nested = agent_identity_nested_object(root);
let mode = string_from_maps(root, nested, &["auth_mode", "authMode"]);
mode.as_deref()
.is_some_and(|value| value.eq_ignore_ascii_case(CODEX_AGENT_IDENTITY_AUTH_MODE))
|| (nested.is_some()
&& string_from_maps(root, nested, &["agent_runtime_id", "agentRuntimeId"]).is_some()
&& string_from_maps(root, nested, &["agent_private_key", "agentPrivateKey"]).is_some())
}
pub fn is_codex_agent_identity_transport(transport: &GatewayProviderTransportSnapshot) -> bool {
transport
.provider
.provider_type
.trim()
.eq_ignore_ascii_case(CODEX_AGENT_IDENTITY_PROVIDER_TYPE)
&& transport.key.auth_type.trim().eq_ignore_ascii_case("oauth")
&& CodexAgentIdentityRefreshAdapter::config_from_transport(transport)
.as_ref()
.is_some_and(is_codex_agent_identity_auth_config_value)
}
pub fn is_codex_agent_identity_cached_entry(entry: &CachedOAuthEntry) -> bool {
entry
.provider_type
.trim()
.eq_ignore_ascii_case(CODEX_AGENT_IDENTITY_CACHED_ENTRY_PROVIDER_TYPE)
}
pub fn validate_codex_agent_identity_auth_config(config: &Value) -> Result<(), String> {
agent_identity_credentials(config).map(|_| ())
}
/// Returns whether an upstream response proves that the registered Agent Identity task is no
/// longer usable. Only this condition should trigger task registration again; an arbitrary 401
/// can instead mean that the account itself has lost access.
pub fn is_codex_agent_identity_invalid_task_response(
status_code: u16,
response_text: Option<&str>,
) -> bool {
if status_code != 401 {
return false;
}
let Some(response_text) = response_text else {
return false;
};
let lower = response_text.to_ascii_lowercase();
let compact = lower
.chars()
.filter(|character| !character.is_ascii_whitespace())
.collect::<String>();
if [
r#""code":"invalid_task_id""#,
r#""code":"task_not_found""#,
r#""code":"task_expired""#,
r#""error":"invalid_task_id""#,
]
.iter()
.any(|marker| compact.contains(marker))
{
return true;
}
[
"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 agent_identity_nested_object(root: &Map<String, Value>) -> Option<&Map<String, Value>> {
root.get("agent_identity")
.or_else(|| root.get("agentIdentity"))
.and_then(Value::as_object)
}
fn string_from_map(map: &Map<String, Value>, keys: &[&str]) -> Option<String> {
keys.iter().find_map(|key| {
map.get(*key)
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
})
}
fn string_from_maps(
root: &Map<String, Value>,
nested: Option<&Map<String, Value>>,
keys: &[&str],
) -> Option<String> {
nested
.and_then(|nested| string_from_map(nested, keys))
.or_else(|| string_from_map(root, keys))
}
fn agent_identity_credentials(config: &Value) -> Result<AgentIdentityCredentials, String> {
let root = config
.as_object()
.ok_or_else(|| "Agent Identity auth_config must be a JSON object".to_string())?;
if !is_codex_agent_identity_auth_config_value(config) {
return Err("Codex Agent Identity auth_mode must be agentIdentity".to_string());
}
let nested = agent_identity_nested_object(root);
let runtime_id = string_from_maps(root, nested, &["agent_runtime_id", "agentRuntimeId"])
.ok_or_else(|| "Agent Identity agent_runtime_id is required".to_string())?;
let encoded_private_key =
string_from_maps(root, nested, &["agent_private_key", "agentPrivateKey"])
.ok_or_else(|| "Agent Identity agent_private_key is required".to_string())?;
let private_key_der = STANDARD
.decode(encoded_private_key)
.map_err(|_| "Agent Identity agent_private_key must be base64 PKCS#8".to_string())?;
let signing_key = SigningKey::from_pkcs8_der(&private_key_der).map_err(|_| {
"Agent Identity agent_private_key must be an Ed25519 PKCS#8 key".to_string()
})?;
Ok(AgentIdentityCredentials {
runtime_id,
signing_key,
task_id: string_from_maps(root, nested, &["task_id", "taskId"]),
})
}
fn agent_identity_timestamp(now: DateTime<Utc>) -> String {
now.to_rfc3339_opts(SecondsFormat::Secs, true)
}
fn build_agent_assertion(
credentials: &AgentIdentityCredentials,
task_id: &str,
now: DateTime<Utc>,
) -> Result<String, String> {
let task_id = task_id.trim();
if task_id.is_empty() {
return Err("Agent Identity task_id is required".to_string());
}
let timestamp = agent_identity_timestamp(now);
let payload = format!("{}:{task_id}:{timestamp}", credentials.runtime_id);
let signature = credentials.signing_key.sign(payload.as_bytes());
let envelope = json!({
"agent_runtime_id": credentials.runtime_id,
"task_id": task_id,
"timestamp": timestamp,
"signature": STANDARD.encode(signature.to_bytes()),
});
let encoded = serde_json::to_vec(&envelope)
.map_err(|_| "failed to serialize Agent Identity assertion".to_string())?;
Ok(format!(
"{ASSERTION_PREFIX}{}",
URL_SAFE_NO_PAD.encode(encoded)
))
}
fn build_task_registration_signature(
credentials: &AgentIdentityCredentials,
now: DateTime<Utc>,
) -> (String, String) {
let timestamp = agent_identity_timestamp(now);
let payload = format!("{}:{timestamp}", credentials.runtime_id);
let signature = credentials.signing_key.sign(payload.as_bytes());
(timestamp, STANDARD.encode(signature.to_bytes()))
}
fn task_registration_url(base_url: &str, runtime_id: &str) -> Result<String, String> {
let mut url = Url::parse(base_url.trim())
.map_err(|_| "Agent Identity auth API base URL is invalid".to_string())?;
let mut segments = url
.path_segments_mut()
.map_err(|_| "Agent Identity auth API base URL cannot be a base URL".to_string())?;
segments.pop_if_empty();
for segment in ["v1", "agent", runtime_id, "task", "register"] {
segments.push(segment);
}
drop(segments);
Ok(url.into())
}
fn task_id_from_registration_response(
credentials: &AgentIdentityCredentials,
body: &str,
) -> Result<String, String> {
let response = serde_json::from_str::<AgentTaskRegistrationResponse>(body)
.map_err(|_| "Agent Identity task registration returned invalid JSON".to_string())?;
for task_id in [response.task_id, response.task_id_camel]
.into_iter()
.flatten()
{
let task_id = task_id.trim();
if !task_id.is_empty() {
return Ok(task_id.to_string());
}
}
let encrypted_task_id = [response.encrypted_task_id, response.encrypted_task_id_camel]
.into_iter()
.flatten()
.find(|value| !value.trim().is_empty())
.ok_or_else(|| "Agent Identity task registration omitted task_id".to_string())?;
decrypt_agent_task_id(credentials, encrypted_task_id.as_str())
}
fn decrypt_agent_task_id(
credentials: &AgentIdentityCredentials,
encrypted_task_id: &str,
) -> Result<String, String> {
let ciphertext = STANDARD
.decode(encrypted_task_id.trim())
.map_err(|_| "Agent Identity encrypted_task_id must be base64".to_string())?;
let seed = credentials.signing_key.to_bytes();
let digest = Sha512::digest(seed);
let mut curve_private_key = [0u8; 32];
curve_private_key.copy_from_slice(&digest[..32]);
let secret_key = Curve25519SecretKey::from_bytes(curve_private_key);
let plaintext = secret_key
.unseal(&ciphertext)
.map_err(|_| "Agent Identity encrypted_task_id could not be decrypted".to_string())?;
let task_id = String::from_utf8(plaintext)
.map_err(|_| "Agent Identity decrypted task_id is invalid".to_string())?;
let task_id = task_id.trim();
if task_id.is_empty() {
return Err("Agent Identity decrypted task_id is empty".to_string());
}
Ok(task_id.to_string())
}
fn with_agent_identity_task_id(config: &Value, task_id: String) -> Result<Value, String> {
let mut root = config
.as_object()
.cloned()
.ok_or_else(|| "Agent Identity auth_config must be a JSON object".to_string())?;
let nested_key = ["agent_identity", "agentIdentity"]
.into_iter()
.find(|key| root.get(*key).and_then(Value::as_object).is_some());
if let Some(nested_key) = nested_key {
let nested = root
.get_mut(nested_key)
.and_then(Value::as_object_mut)
.expect("Agent Identity nested config was checked as an object");
nested.insert("task_id".to_string(), Value::String(task_id.clone()));
nested.remove("taskId");
}
root.insert("task_id".to_string(), Value::String(task_id));
root.remove("taskId");
Ok(Value::Object(root))
}
fn agent_identity_refresh_error(message: impl Into<String>) -> LocalOAuthRefreshError {
LocalOAuthRefreshError::InvalidResponse {
provider_type: CODEX_AGENT_IDENTITY_PROVIDER_TYPE,
message: message.into(),
}
}
#[async_trait]
impl LocalOAuthRefreshAdapter for CodexAgentIdentityRefreshAdapter {
fn provider_type(&self) -> &'static str {
CODEX_AGENT_IDENTITY_PROVIDER_TYPE
}
fn supports(&self, transport: &GatewayProviderTransportSnapshot) -> bool {
is_codex_agent_identity_transport(transport)
}
fn resolve_cached(
&self,
_transport: &GatewayProviderTransportSnapshot,
entry: &CachedOAuthEntry,
) -> Option<LocalResolvedOAuthRequestAuth> {
Self::config_from_entry(entry).and_then(|config| Self::resolve_from_config(&config))
}
fn resolve_without_refresh(
&self,
transport: &GatewayProviderTransportSnapshot,
) -> Option<LocalResolvedOAuthRequestAuth> {
Self::config_from_transport(transport).and_then(|config| Self::resolve_from_config(&config))
}
fn should_refresh(
&self,
transport: &GatewayProviderTransportSnapshot,
entry: Option<&CachedOAuthEntry>,
) -> bool {
if !self.supports(transport) {
return false;
}
Self::preferred_config(transport, entry)
.and_then(|config| agent_identity_credentials(&config).ok())
.is_some_and(|credentials| credentials.task_id.is_none())
}
async fn refresh(
&self,
executor: &dyn LocalOAuthHttpExecutor,
transport: &GatewayProviderTransportSnapshot,
entry: Option<&CachedOAuthEntry>,
) -> Result<Option<CachedOAuthEntry>, LocalOAuthRefreshError> {
let Some(config) = Self::preferred_config(transport, entry) else {
return Ok(None);
};
let credentials =
agent_identity_credentials(&config).map_err(agent_identity_refresh_error)?;
let (timestamp, signature) = build_task_registration_signature(&credentials, Utc::now());
let url = task_registration_url(&self.auth_api_base_url, credentials.runtime_id.as_str())
.map_err(agent_identity_refresh_error)?;
let request = LocalOAuthHttpRequest {
request_id: CODEX_AGENT_IDENTITY_TASK_REGISTRATION_REQUEST_ID,
method: reqwest::Method::POST,
url,
headers: BTreeMap::from([
("accept".to_string(), "application/json".to_string()),
("content-type".to_string(), "application/json".to_string()),
]),
json_body: Some(json!({
"timestamp": timestamp,
"signature": signature,
})),
body_bytes: None,
};
let response = executor
.execute(CODEX_AGENT_IDENTITY_PROVIDER_TYPE, transport, &request)
.await?;
if !(200..300).contains(&response.status_code) {
return Err(LocalOAuthRefreshError::HttpStatus {
provider_type: CODEX_AGENT_IDENTITY_PROVIDER_TYPE,
status_code: response.status_code,
// Registration responses can echo credential material. Keep the stored/logged
// error deliberately generic instead of forwarding an upstream body excerpt.
body_excerpt: format!(
"Agent Identity task registration returned HTTP {}",
response.status_code
),
});
}
let task_id = task_id_from_registration_response(&credentials, response.body_text.as_str())
.map_err(agent_identity_refresh_error)?;
let config = with_agent_identity_task_id(&config, task_id.clone())
.map_err(agent_identity_refresh_error)?;
let updated_credentials =
agent_identity_credentials(&config).map_err(agent_identity_refresh_error)?;
let auth_header_value = build_agent_assertion(&updated_credentials, &task_id, Utc::now())
.map_err(agent_identity_refresh_error)?;
Ok(Some(CachedOAuthEntry {
provider_type: CODEX_AGENT_IDENTITY_CACHED_ENTRY_PROVIDER_TYPE.to_string(),
auth_header_name: AUTHORIZATION_HEADER.to_string(),
auth_header_value,
expires_at_unix_secs: None,
metadata: Some(config),
}))
}
}
#[cfg(test)]
mod tests {
use std::sync::{Arc, Mutex};
use crypto_box::{aead::rand_core::OsRng, PublicKey};
use ed25519_dalek::{pkcs8::EncodePrivateKey, Signature, Verifier};
use serde_json::json;
use super::{
agent_identity_credentials, build_agent_assertion, decrypt_agent_task_id,
is_codex_agent_identity_auth_config_value, is_codex_agent_identity_invalid_task_response,
task_id_from_registration_response, with_agent_identity_task_id,
CodexAgentIdentityRefreshAdapter, CODEX_AGENT_IDENTITY_CACHED_ENTRY_PROVIDER_TYPE,
};
use crate::oauth_refresh::{
LocalOAuthHttpExecutor, LocalOAuthHttpRequest, LocalOAuthHttpResponse,
LocalOAuthRefreshAdapter, LocalOAuthRefreshError, LocalResolvedOAuthRequestAuth,
};
use crate::snapshot::{
GatewayProviderTransportEndpoint, GatewayProviderTransportKey,
GatewayProviderTransportProvider, GatewayProviderTransportSnapshot,
};
use base64::{
engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD},
Engine as _,
};
use chrono::{TimeZone, Utc};
use ed25519_dalek::SigningKey;
use sha2::Digest;
fn test_auth_config(task_id: Option<&str>) -> serde_json::Value {
let signing_key = SigningKey::from_bytes(&[7u8; 32]);
let private_key_der = signing_key
.to_pkcs8_der()
.expect("test key should encode as PKCS#8");
let mut config = serde_json::Map::from_iter([
("provider_type".to_string(), json!("codex")),
("auth_mode".to_string(), json!("agentIdentity")),
("agent_runtime_id".to_string(), json!("runtime-test")),
(
"agent_private_key".to_string(),
json!(STANDARD.encode(private_key_der.as_bytes())),
),
]);
if let Some(task_id) = task_id {
config.insert("task_id".to_string(), json!(task_id));
}
serde_json::Value::Object(config)
}
fn sample_transport(config: serde_json::Value) -> GatewayProviderTransportSnapshot {
GatewayProviderTransportSnapshot {
provider: GatewayProviderTransportProvider {
id: "provider-1".to_string(),
name: "Codex".to_string(),
provider_type: "codex".to_string(),
website: None,
is_active: true,
keep_priority_on_conversion: false,
enable_format_conversion: false,
concurrent_limit: None,
max_retries: None,
proxy: None,
request_timeout_secs: None,
stream_first_byte_timeout_secs: None,
config: None,
},
endpoint: GatewayProviderTransportEndpoint {
id: "endpoint-1".to_string(),
provider_id: "provider-1".to_string(),
api_format: "openai:responses".to_string(),
api_family: Some("openai".to_string()),
endpoint_kind: Some("cli".to_string()),
is_active: true,
base_url: "https://chatgpt.com/backend-api/codex".to_string(),
header_rules: None,
body_rules: None,
max_retries: None,
custom_path: None,
config: None,
format_acceptance_config: None,
proxy: None,
},
key: GatewayProviderTransportKey {
id: "key-1".to_string(),
provider_id: "provider-1".to_string(),
name: "Agent Identity".to_string(),
auth_type: "oauth".to_string(),
is_active: true,
api_formats: None,
auth_type_by_format: None,
allow_auth_channel_mismatch_formats: None,
allowed_models: None,
capabilities: None,
rate_multipliers: None,
global_priority_by_format: None,
expires_at_unix_secs: None,
proxy: None,
fingerprint: None,
upstream_metadata: None,
decrypted_api_key: "__placeholder__".to_string(),
decrypted_auth_config: Some(config.to_string()),
},
}
}
#[test]
fn builds_verifiable_agent_assertion() {
let config = test_auth_config(Some("task-test"));
let credentials = agent_identity_credentials(&config).expect("credentials should parse");
let now = Utc.with_ymd_and_hms(2030, 1, 2, 3, 4, 5).unwrap();
let assertion =
build_agent_assertion(&credentials, "task-test", now).expect("assertion should build");
let encoded = assertion
.strip_prefix("AgentAssertion ")
.expect("assertion should have its scheme");
let envelope: serde_json::Value = serde_json::from_slice(
&URL_SAFE_NO_PAD
.decode(encoded)
.expect("assertion should be URL-safe base64"),
)
.expect("assertion envelope should be JSON");
assert_eq!(envelope["agent_runtime_id"], "runtime-test");
assert_eq!(envelope["task_id"], "task-test");
assert_eq!(envelope["timestamp"], "2030-01-02T03:04:05Z");
let signature = Signature::from_slice(
&STANDARD
.decode(envelope["signature"].as_str().unwrap())
.expect("signature should be base64"),
)
.expect("signature should be valid length");
credentials
.signing_key
.verifying_key()
.verify(b"runtime-test:task-test:2030-01-02T03:04:05Z", &signature)
.expect("assertion signature should verify");
}
#[test]
fn decrypts_sealed_task_registration_response() {
let config = test_auth_config(None);
let credentials = agent_identity_credentials(&config).expect("credentials should parse");
let digest = sha2::Sha512::digest(credentials.signing_key.to_bytes());
let mut curve_private_key = [0u8; 32];
curve_private_key.copy_from_slice(&digest[..32]);
let secret_key = crypto_box::SecretKey::from_bytes(curve_private_key);
let public_key = PublicKey::from(&secret_key);
let encrypted = public_key
.seal(&mut OsRng, b"task-encrypted")
.expect("sealed task should encrypt");
assert_eq!(
decrypt_agent_task_id(&credentials, &STANDARD.encode(encrypted))
.expect("sealed task should decrypt"),
"task-encrypted"
);
}
#[test]
fn accepts_nested_agent_identity_export_shape() {
let nested = test_auth_config(Some("task-nested"));
let config = json!({
"provider_type": "codex",
"auth_mode": "agentIdentity",
"agent_identity": nested,
});
assert!(is_codex_agent_identity_auth_config_value(&config));
assert_eq!(
agent_identity_credentials(&config)
.expect("nested credentials should parse")
.task_id
.as_deref(),
Some("task-nested")
);
}
#[test]
fn synchronizes_replaced_task_id_across_nested_and_flat_import_fields() {
let config = json!({
"auth_mode": "agentIdentity",
"taskId": "old-root-task",
"agent_identity": {
"agent_runtime_id": "runtime-test",
"agent_private_key": "placeholder",
"taskId": "old-nested-task"
}
});
let updated =
with_agent_identity_task_id(&config, "new-task".to_string()).expect("task updates");
assert_eq!(updated["task_id"], "new-task");
assert!(updated.get("taskId").is_none());
assert_eq!(updated["agent_identity"]["task_id"], "new-task");
assert!(updated["agent_identity"].get("taskId").is_none());
}
#[derive(Clone)]
struct RecordingExecutor {
requests: Arc<Mutex<Vec<LocalOAuthHttpRequest>>>,
}
#[async_trait::async_trait]
impl LocalOAuthHttpExecutor for RecordingExecutor {
async fn execute(
&self,
_provider_type: &'static str,
_transport: &GatewayProviderTransportSnapshot,
request: &LocalOAuthHttpRequest,
) -> Result<LocalOAuthHttpResponse, LocalOAuthRefreshError> {
self.requests
.lock()
.expect("recording lock should hold")
.push(request.clone());
Ok(LocalOAuthHttpResponse {
status_code: 200,
body_text: r#"{"task_id":"task-registered"}"#.to_string(),
})
}
}
#[tokio::test]
async fn registers_missing_task_then_builds_a_fresh_assertion_from_cache() {
let config = test_auth_config(None);
let transport = sample_transport(config);
let requests = Arc::new(Mutex::new(Vec::new()));
let executor = RecordingExecutor {
requests: Arc::clone(&requests),
};
let adapter = CodexAgentIdentityRefreshAdapter::default()
.with_auth_api_base_url_for_tests("https://auth.test/api/accounts");
assert!(adapter.should_refresh(&transport, None));
let entry = adapter
.refresh(&executor, &transport, None)
.await
.expect("registration should succeed")
.expect("registration should return a cache entry");
assert_eq!(
entry.provider_type,
CODEX_AGENT_IDENTITY_CACHED_ENTRY_PROVIDER_TYPE
);
assert!(entry.auth_header_value.starts_with("AgentAssertion "));
assert_eq!(
entry
.metadata
.as_ref()
.and_then(|value| value.get("task_id")),
Some(&json!("task-registered"))
);
let cached_auth = adapter
.resolve_cached(&transport, &entry)
.expect("cached task should create a new assertion");
assert!(matches!(
cached_auth,
LocalResolvedOAuthRequestAuth::Header { ref name, ref value }
if name == "authorization" && value.starts_with("AgentAssertion ")
));
let requests = requests.lock().expect("recording lock should hold");
assert_eq!(requests.len(), 1);
assert_eq!(
requests[0].request_id,
super::CODEX_AGENT_IDENTITY_TASK_REGISTRATION_REQUEST_ID
);
assert_eq!(
requests[0].url,
"https://auth.test/api/accounts/v1/agent/runtime-test/task/register"
);
assert_eq!(
requests[0].json_body.as_ref().unwrap()["timestamp"]
.as_str()
.is_some(),
true
);
assert_eq!(
requests[0].json_body.as_ref().unwrap()["signature"]
.as_str()
.is_some(),
true
);
}
#[test]
fn registration_response_accepts_encrypted_task_aliases() {
let config = test_auth_config(Some("task-original"));
let credentials = agent_identity_credentials(&config).expect("credentials should parse");
assert_eq!(
task_id_from_registration_response(&credentials, r#"{"taskId":"task-camel"}"#)
.expect("camel task id should parse"),
"task-camel"
);
}
#[test]
fn recognizes_only_agent_task_specific_unauthorized_responses() {
assert!(is_codex_agent_identity_invalid_task_response(
401,
Some(r#"{"error": {"code": "invalid_task_id"}}"#),
));
assert!(is_codex_agent_identity_invalid_task_response(
401,
Some("Agent task expired; register a new task"),
));
assert!(!is_codex_agent_identity_invalid_task_response(
401,
Some(r#"{"error": {"code": "invalid_api_key"}}"#),
));
assert!(!is_codex_agent_identity_invalid_task_response(
403,
Some(r#"{"error": {"code": "invalid_task_id"}}"#),
));
}
}
@@ -22,6 +22,7 @@ const UNSAFE_AUTH_CONFIG_QUERY_NAMES: &[&str] = &[
];
const SENSITIVE_AUTH_CONFIG_KEYS: &[&str] = &[
"access_token",
"agent_private_key",
"api_key",
"apikey",
"authorization",
@@ -106,6 +107,18 @@ pub fn apply_local_auth_config_header_overrides(
let mut overrides = BTreeMap::new();
collect_auth_config_header_overrides(object, &mut overrides);
for (key, value) in overrides {
// Agent Identity assertions are generated for the current request. A stale
// imported Authorization header must never replace one later in planning.
if key.eq_ignore_ascii_case("authorization")
&& headers.get("authorization").is_some_and(|current| {
current
.trim_start()
.to_ascii_lowercase()
.starts_with("agentassertion ")
})
{
continue;
}
headers.insert(key, value);
}
}
@@ -606,6 +619,35 @@ mod tests {
assert!(!headers.contains_key("host"));
}
#[test]
fn preserves_dynamic_agent_assertion_over_static_imported_authorization() {
let mut headers = std::collections::BTreeMap::from([(
"authorization".to_string(),
"AgentAssertion signed-for-this-request".to_string(),
)]);
apply_local_auth_config_header_overrides(
&mut headers,
Some(
r#"{
"headers": {
"authorization": "Bearer stale-imported-session",
"chatgpt-account-id": "acct-1"
}
}"#,
),
);
assert_eq!(
headers.get("authorization"),
Some(&"AgentAssertion signed-for-this-request".to_string())
);
assert_eq!(
headers.get("chatgpt-account-id"),
Some(&"acct-1".to_string())
);
}
#[test]
fn ignores_invalid_auth_config_header_values_when_applying_overrides() {
let mut headers = std::collections::BTreeMap::new();
@@ -1,3 +1,4 @@
mod agent_identity;
pub mod antigravity;
pub mod auth;
mod auth_config;
@@ -28,6 +29,13 @@ mod video;
pub mod windsurf;
pub use aether_oauth as oauth;
pub use agent_identity::{
is_codex_agent_identity_auth_config_value, is_codex_agent_identity_cached_entry,
is_codex_agent_identity_invalid_task_response, is_codex_agent_identity_transport,
validate_codex_agent_identity_auth_config, CodexAgentIdentityRefreshAdapter,
CODEX_AGENT_IDENTITY_AUTH_MODE, CODEX_AGENT_IDENTITY_CACHED_ENTRY_PROVIDER_TYPE,
CODEX_AGENT_IDENTITY_PROVIDER_TYPE, CODEX_AGENT_IDENTITY_TASK_REGISTRATION_REQUEST_ID,
};
pub use auth::{build_passthrough_headers, ensure_upstream_auth_header};
pub use auth_config::apply_local_auth_config_header_overrides;
pub use cache::{provider_transport_snapshot_looks_refreshed, ProviderTransportSnapshotCacheKey};
@@ -13,6 +13,7 @@ use serde_json::Value;
use thiserror::Error;
use tokio::sync::Mutex;
use super::agent_identity::{is_codex_agent_identity_transport, CodexAgentIdentityRefreshAdapter};
use super::generic_oauth::supports_local_generic_oauth_request_auth_resolution;
pub use super::generic_oauth::GenericOAuthRefreshAdapter;
use super::kiro::{
@@ -341,6 +342,7 @@ impl LocalOAuthRefreshCoordinator {
pub fn new() -> Self {
Self {
adapters: vec![
Arc::new(CodexAgentIdentityRefreshAdapter::default()),
Arc::new(KiroOAuthRefreshAdapter::default()),
Arc::new(VertexServiceAccountRefreshAdapter),
Arc::new(GenericOAuthRefreshAdapter::default()),
@@ -550,7 +552,8 @@ impl LocalOAuthResolution {
pub fn supports_local_oauth_request_auth_resolution(
transport: &GatewayProviderTransportSnapshot,
) -> bool {
supports_local_kiro_request_auth_resolution(transport)
is_codex_agent_identity_transport(transport)
|| supports_local_kiro_request_auth_resolution(transport)
|| supports_local_vertex_service_account_auth_resolution(transport)
|| supports_local_generic_oauth_request_auth_resolution(transport)
}
@@ -947,6 +947,7 @@ const isOpen = computed(() => props.open)
const isKiroProvider = computed(() => (props.providerType || '').toLowerCase() === 'kiro')
const isGrokProvider = computed(() => (props.providerType || '').toLowerCase() === 'grok')
const isWindsurfProvider = computed(() => (props.providerType || '').toLowerCase() === 'windsurf')
const isCodexProvider = computed(() => (props.providerType || '').toLowerCase() === 'codex')
const isDeviceBrowserProvider = computed(() => isKiroProvider.value || isWindsurfProvider.value)
const showAuthorizationMode = computed(() => !isGrokProvider.value)
const defaultMode = computed<DialogMode>(() => (isGrokProvider.value ? 'import' : 'oauth'))
@@ -1696,6 +1697,43 @@ function handleImportInputError(payload: { message: string; title?: string }) {
showError(legacyT(payload.message), payload.title ? legacyT(payload.title) : undefined)
}
function isObjectRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function isCodexAgentIdentityObject(root: Record<string, unknown>): boolean {
const nestedValue = root.agent_identity ?? root.agentIdentity
const nested = isObjectRecord(nestedValue) ? nestedValue : null
const authMode = normalizeStringField(root.auth_mode)
?? normalizeStringField(root.authMode)
?? (nested
? normalizeStringField(nested.auth_mode) ?? normalizeStringField(nested.authMode)
: undefined)
if (authMode?.toLowerCase() === 'agentidentity') return true
return nested !== null
&& Boolean(
normalizeStringField(nested.agent_runtime_id) ?? normalizeStringField(nested.agentRuntimeId),
)
&& Boolean(
normalizeStringField(nested.agent_private_key) ?? normalizeStringField(nested.agentPrivateKey),
)
}
function requiresCodexBatchImport(credentials: string): boolean {
if (!isCodexProvider.value) return false
try {
const parsed: unknown = JSON.parse(credentials)
if (!isObjectRecord(parsed)) return false
return isCodexAgentIdentityObject(parsed)
|| normalizeStringField(parsed.type)?.toLowerCase() === 'sub2api-data'
} catch {
return false
}
}
function setWindsurfImportMethod(method: WindsurfImportMethod) {
if (!isWindsurfProvider.value || importing.value) return
windsurfImportMethod.value = method
@@ -1754,8 +1792,12 @@ async function handleImport() {
let keepImporting = false
try {
const proxyNodeId = selectedProxyNodeId.value || undefined
// Kiro JSON batch-import auth_config
if (isKiroProvider.value || normalizedCredentials.isBatch) {
// Kiro, Codex Agent Identity, and sub2api exports need their full JSON on the batch path.
if (
isKiroProvider.value
|| normalizedCredentials.isBatch
|| requiresCodexBatchImport(normalizedCredentials.credentials)
) {
const task = await startBatchImportOAuthTask(props.providerId, normalizedCredentials.credentials, proxyNodeId)
importTask.value = {
task_id: task.task_id,
@@ -388,6 +388,74 @@ describe('OAuthAccountDialog Grok import', () => {
}))
})
it('sends a single Codex Agent Identity auth JSON through batch import', async () => {
const root = mountDialog('codex')
await settle()
getButton(root, '导入授权')?.click()
await settle()
const credentials = JSON.stringify({
auth_mode: 'agentIdentity',
agent_identity: {
agent_runtime_id: 'runtime-1',
agent_private_key: 'base64-pkcs8-key',
},
})
const textarea = getImportTextarea(root)
textarea.value = credentials
textarea.dispatchEvent(new Event('input'))
await settle()
getExactButton(root, '导入')?.click()
await settle()
expect(endpointMocks.startBatchImportOAuthTask).toHaveBeenCalledWith(
'provider-1',
credentials,
undefined,
)
expect(endpointMocks.importProviderRefreshToken).not.toHaveBeenCalled()
})
it('sends a complete sub2api Agent Identity export through batch import', async () => {
const root = mountDialog('codex')
await settle()
getButton(root, '导入授权')?.click()
await settle()
const credentials = JSON.stringify({
type: 'sub2api-data',
version: 1,
accounts: [{
name: 'agent@example.com',
platform: 'openai',
type: 'oauth',
credentials: {
auth_mode: 'agentIdentity',
agent_runtime_id: 'runtime-1',
agent_private_key: 'base64-pkcs8-key',
task_id: 'task-1',
},
}],
})
const textarea = getImportTextarea(root)
textarea.value = credentials
textarea.dispatchEvent(new Event('input'))
await settle()
getExactButton(root, '导入')?.click()
await settle()
expect(endpointMocks.startBatchImportOAuthTask).toHaveBeenCalledWith(
'provider-1',
credentials,
undefined,
)
expect(endpointMocks.importProviderRefreshToken).not.toHaveBeenCalled()
})
it('keeps Grok multiline token import on the batch task path', async () => {
const root = mountDialog('grok')
await settle()