mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-13 06:30:20 +08:00
Merge pull request #688 from AAEE86/feat/agent-identity-support
feat(codex): support agent identity accounts
This commit is contained in:
Generated
+121
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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,
|
||||
|
||||
+68
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user