fix(openai): preserve Agent Identity CPR round trips

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
zyycn
2026-07-25 03:26:00 +08:00
co-authored by Codex
parent 997e4ed296
commit ef57b88066
2 changed files with 238 additions and 51 deletions
@@ -215,7 +215,7 @@ impl fmt::Debug for ExportManagedCodexCredential {
}
}
/// CPR canonical 账号导出文档;只允许显式序列化,Debug 永不输出 token。
/// CPR canonical 账号导出文档;只允许显式序列化,Debug 永不输出 credential secret。
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CodexCprExportDocument {
@@ -252,21 +252,47 @@ impl fmt::Debug for CodexCprExportDocument {
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct CodexCprExportAccount {
struct CodexCprExportCommon {
id: String,
email: Option<String>,
account_id: Option<String>,
user_id: Option<String>,
label: Option<String>,
plan_type: Option<String>,
token: String,
refresh_token: Option<String>,
access_token_expires_at: Option<String>,
status: &'static str,
added_at: String,
updated_at: String,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct CodexCprOAuthExportAccount {
#[serde(flatten)]
common: CodexCprExportCommon,
token: String,
refresh_token: Option<String>,
access_token_expires_at: Option<String>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct CodexCprAgentIdentityExportAccount {
#[serde(flatten)]
common: CodexCprExportCommon,
auth_mode: &'static str,
agent_runtime_id: String,
agent_private_key: String,
#[serde(skip_serializing_if = "Option::is_none")]
task_id: Option<String>,
}
#[derive(Serialize)]
#[serde(untagged)]
enum CodexCprExportAccount {
OAuth(CodexCprOAuthExportAccount),
AgentIdentity(CodexCprAgentIdentityExportAccount),
}
/// App 已从 Store 读取的当前账号、revision 与明文 Provider JSON。
pub struct RotateManagedCodexCredential {
pub current: LoadedCredential,
@@ -444,31 +470,51 @@ impl CodexCredentialAdmin {
}
let data = CodexCredentialCodec::decode_complete(&item.current.credential)
.map_err(|_| CodexCredentialAdminError::InvalidCredential)?;
let CodexCredentialData::OAuth(data) = data else {
return Err(CodexCredentialAdminError::InvalidCredential);
};
if account.authentication_kind() != CODEX_AUTHENTICATION_KIND_OAUTH
|| account.has_refresh_token() != data.refresh_token.is_some()
{
return Err(CodexCredentialAdminError::InvalidCredential);
}
accounts.push(CodexCprExportAccount {
let common = CodexCprExportCommon {
id: account.id().as_str().to_owned(),
email: account.email().map(str::to_owned),
account_id: account.upstream_account_id().map(str::to_owned),
user_id: Some(account.upstream_user_id().to_owned()),
label: Some(account.name().to_owned()),
plan_type: account.plan_type().map(str::to_owned),
token: data.access_token,
refresh_token: data.refresh_token,
access_token_expires_at: account
.access_token_expires_at()
.map(DateTime::<Utc>::from)
.map(|value| value.to_rfc3339()),
status: cpr_status(&account),
added_at: china_rfc3339(item.added_at),
updated_at: china_rfc3339(item.updated_at),
});
};
let exported = match data {
CodexCredentialData::OAuth(data) => {
if account.authentication_kind() != CODEX_AUTHENTICATION_KIND_OAUTH
|| account.has_refresh_token() != data.refresh_token.is_some()
{
return Err(CodexCredentialAdminError::InvalidCredential);
}
CodexCprExportAccount::OAuth(CodexCprOAuthExportAccount {
common,
token: data.access_token,
refresh_token: data.refresh_token,
access_token_expires_at: account
.access_token_expires_at()
.map(DateTime::<Utc>::from)
.map(|value| value.to_rfc3339()),
})
}
CodexCredentialData::AgentIdentity(data) => {
if account.authentication_kind() != CODEX_AUTHENTICATION_KIND_AGENT_IDENTITY
|| account.has_refresh_token()
|| account.access_token_expires_at().is_some()
{
return Err(CodexCredentialAdminError::InvalidCredential);
}
CodexCprExportAccount::AgentIdentity(CodexCprAgentIdentityExportAccount {
common,
auth_mode: "agentIdentity",
agent_runtime_id: data.agent_runtime_id,
agent_private_key: data.agent_private_key,
task_id: data.task_id,
})
}
};
accounts.push(exported);
}
Ok(CodexCprExportDocument {
source_format: "cpr",
@@ -1034,7 +1080,25 @@ fn parse_import_document(
let values = import_account_values(payload)?;
let mut accounts = Vec::new();
for value in values {
let parsed = match shape {
// CPR 导出允许 OAuth 与 Agent Identity 共存;按账号选择 Provider
// 解析器,避免一个 Agent Identity 条目把整份文档误判成单一格式。
let account_shape = if looks_like_agent_identity_account(value) {
ImportDocumentShape::AgentIdentity
} else if shape == ImportDocumentShape::AgentIdentity {
if value.get("credentials").is_some() || value.get("tokens").is_some() {
ImportDocumentShape::CredentialBundle
} else if value
.as_object()
.is_some_and(auth_document_provider_is_openai)
{
ImportDocumentShape::AuthDocument
} else {
ImportDocumentShape::Native
}
} else {
shape
};
let parsed = match account_shape {
ImportDocumentShape::Native => Some(parse_native_account(value)?),
ImportDocumentShape::AgentIdentity => Some(parse_agent_identity_account(value)?),
ImportDocumentShape::CredentialBundle => parse_credential_bundle_account(value)?,
@@ -1076,26 +1140,37 @@ fn parse_agent_identity_account(
.get("agent_identity")
.or_else(|| value.get("credentials"))
.unwrap_or(value);
let auth_mode =
first_string(value, &["auth_mode"]).or_else(|| first_string(identity, &["auth_mode"]));
let auth_mode = first_string(value, &["auth_mode", "authMode"])
.or_else(|| first_string(identity, &["auth_mode", "authMode"]));
if auth_mode.as_deref() != Some("agentIdentity") {
return Err(CodexCredentialAdminError::InvalidCredential);
}
let runtime_id = first_string(identity, &["agent_runtime_id"])
let runtime_id = first_string(identity, &["agent_runtime_id", "agentRuntimeId"])
.ok_or(CodexCredentialAdminError::InvalidCredential)?;
let private_key = first_string(identity, &["agent_private_key"])
let private_key = first_string(identity, &["agent_private_key", "agentPrivateKey"])
.ok_or(CodexCredentialAdminError::InvalidCredential)?;
Ok(ParsedCodexImportAccount {
id: first_string(value, &["id"]),
name: first_string(value, &["name", "label"]),
email: first_string(identity, &["email"]),
plan_type: first_string(identity, &["plan_type"]),
chatgpt_account_id: first_string(identity, &["chatgpt_account_id", "account_id"]),
chatgpt_user_id: first_string(identity, &["chatgpt_user_id"]),
plan_type: first_string(identity, &["plan_type", "planType"]),
chatgpt_account_id: first_string(
identity,
&[
"chatgpt_account_id",
"chatgptAccountId",
"account_id",
"accountId",
],
),
chatgpt_user_id: first_string(
identity,
&["chatgpt_user_id", "chatgptUserId", "user_id", "userId"],
),
authentication: ParsedCodexAuthentication::AgentIdentity {
runtime_id,
private_key,
task_id: first_string(identity, &["task_id"]),
task_id: first_string(identity, &["task_id", "taskId"]),
},
status: first_string(value, &["status"]),
})
@@ -1379,24 +1454,23 @@ fn looks_like_credential_bundle(value: &Value) -> bool {
fn looks_like_agent_identity_document(value: &Value) -> bool {
import_account_values(value).is_ok_and(|accounts| {
accounts.iter().any(|account| {
account
.get("auth_mode")
.and_then(Value::as_str)
.is_some_and(|mode| mode == "agentIdentity")
|| account
.get("agent_identity")
.and_then(|identity| identity.get("agent_runtime_id"))
.is_some()
|| account
.get("credentials")
.and_then(|credentials| credentials.get("auth_mode"))
.and_then(Value::as_str)
.is_some_and(|mode| mode == "agentIdentity")
})
accounts
.iter()
.any(|account| looks_like_agent_identity_account(account))
})
}
fn looks_like_agent_identity_account(value: &Value) -> bool {
let identity = value
.get("agent_identity")
.or_else(|| value.get("credentials"))
.unwrap_or(value);
first_string(value, &["auth_mode", "authMode"])
.or_else(|| first_string(identity, &["auth_mode", "authMode"]))
.is_some_and(|mode| mode == "agentIdentity")
|| first_string(identity, &["agent_runtime_id", "agentRuntimeId"]).is_some()
}
fn looks_like_auth_document(value: &Value) -> bool {
import_account_values(value).is_ok_and(|accounts| {
accounts.iter().any(|account| {
@@ -3,23 +3,31 @@ use std::sync::{Arc, Mutex};
use std::time::Duration;
use async_trait::async_trait;
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD;
use chrono::{TimeZone, Utc};
use ed25519_dalek::SigningKey;
use ed25519_dalek::pkcs8::EncodePrivateKey as _;
use futures::future::BoxFuture;
use gateway_core::engine::credential::{
CredentialRevision, LoadedCredential, PlaintextCredential, ProviderAccountId,
AccountAvailability, CredentialRevision, LoadedCredential, PlaintextCredential,
ProviderAccount, ProviderAccountId,
};
use gateway_core::provider_ports::{
ProviderLeaseAcquisition, ProviderLeasePort, ProviderLeaseRequest, ProviderRefreshLeaseRequest,
ProviderStoreError,
};
use gateway_core::routing::ProviderKind;
use provider_openai::OFFICIAL_CODEX_BASE_URL;
use provider_openai::credential::token_client::{RefreshFailure, TokenPair, TokenRefresher};
use provider_openai::credential::{
CodexAccountIdentityService, CodexAccountIdentityVerifier, CodexCredentialAdmin,
CodexCredentialAdminError, CodexCredentialAdminService, CodexCredentialCodec,
CodexIdentityExpectation, CodexIdentityVerification, CodexIdentityVerificationError,
CodexJwtIdentityVerifier, ExportManagedCodexCredential, ImportCodexOAuthCredential,
ReqwestCodexAuthenticatedAccountSource, ReqwestOpenAiJwksSource, RotateManagedCodexCredential,
CODEX_AUTHENTICATION_KIND_AGENT_IDENTITY, CodexAccountIdentityService,
CodexAccountIdentityVerifier, CodexAgentIdentityAuthMode, CodexAgentIdentityCredentialData,
CodexCredentialAdmin, CodexCredentialAdminError, CodexCredentialAdminService,
CodexCredentialCodec, CodexIdentityExpectation, CodexIdentityVerification,
CodexIdentityVerificationError, CodexJwtIdentityVerifier, ExportManagedCodexCredential,
ImportCodexOAuthCredential, ReqwestCodexAuthenticatedAccountSource, ReqwestOpenAiJwksSource,
RotateManagedCodexCredential,
};
use provider_openai::transport::profile::{CodexWireProfile, CodexWireProfileState};
use secrecy::{ExposeSecret, SecretString};
@@ -137,6 +145,60 @@ fn export_item(id: &str, token: &str) -> ExportManagedCodexCredential {
}
}
fn agent_export_item(id: &str) -> (ExportManagedCodexCredential, String) {
let signing_key = SigningKey::from_bytes(&[7; 32]);
let private_key = STANDARD.encode(
signing_key
.to_pkcs8_der()
.expect("encode Agent Identity key")
.as_bytes(),
);
let credential =
CodexCredentialCodec::encode_agent_identity(CodexAgentIdentityCredentialData {
schema_version: 1,
auth_mode: CodexAgentIdentityAuthMode::AgentIdentity,
installation_id: "00000000-0000-4000-8000-000000000007".to_owned(),
agent_runtime_id: "runtime-export".to_owned(),
agent_private_key: private_key.clone(),
task_id: Some("task-export".to_owned()),
cookies: Vec::new(),
})
.expect("encode Agent Identity credential");
let account = ProviderAccount::new(
ProviderAccountId::new(id.to_owned()).expect("account id"),
ProviderKind::new("openai").expect("provider"),
format!("Agent Identity {id}"),
"agent-user-export".to_owned(),
CODEX_AUTHENTICATION_KIND_AGENT_IDENTITY.to_owned(),
CredentialRevision::new(1).expect("revision"),
None,
)
.with_profile(
Some("agent-export@example.com".to_owned()),
Some("agent-account-export".to_owned()),
Some("pro".to_owned()),
)
.with_runtime_state(true, AccountAvailability::Ready, None)
.with_refresh_schedule(false, None);
(
ExportManagedCodexCredential {
current: LoadedCredential {
account,
credential,
},
added_at: Utc
.with_ymd_and_hms(2026, 7, 18, 2, 47, 1)
.single()
.expect("added at"),
updated_at: Utc
.with_ymd_and_hms(2026, 7, 19, 3, 0, 0)
.single()
.expect("updated at"),
},
private_key,
)
}
#[test]
fn cpr_export_matches_the_canonical_real_document_shape() {
let document = CodexCredentialAdmin
@@ -211,6 +273,57 @@ fn cpr_export_batch_validation_and_debug_are_secret_safe() {
assert!(debug.contains("account_count: 1"));
}
#[test]
fn cpr_export_preserves_agent_identity_material_without_oauth_fields() {
let (item, private_key) = agent_export_item("acct_agent_export");
let value = CodexCredentialAdmin
.format_cpr_export(vec![item])
.expect("Agent Identity export")
.into_json()
.expect("serialize Agent Identity export");
let account = &value["accounts"][0];
assert_eq!(account["authMode"], "agentIdentity");
assert_eq!(account["agentRuntimeId"], "runtime-export");
assert!(
account["agentPrivateKey"]
.as_str()
.is_some_and(|value| value == private_key)
);
assert_eq!(account["taskId"], "task-export");
assert!(account.get("token").is_none());
assert!(account.get("refreshToken").is_none());
assert!(account.get("accessTokenExpiresAt").is_none());
}
#[tokio::test]
async fn cpr_export_and_import_support_mixed_oauth_and_agent_identity_accounts() {
let (agent, _) = agent_export_item("acct_mixed_agent");
let value = CodexCredentialAdmin
.format_cpr_export(vec![
export_item("acct_mixed_oauth", "token-acct_mixed_oauth"),
agent,
])
.expect("mixed export")
.into_json()
.expect("serialize mixed export");
let prepared = import_service(unused_import_refresher())
.prepare_import_document(value)
.await
.expect("mixed CPR import");
assert_eq!(prepared.accounts().len(), 2);
assert!(prepared.accounts().iter().any(|account| {
account.account.authentication_kind() == CODEX_AUTHENTICATION_KIND_AGENT_IDENTITY
}));
assert!(
prepared
.accounts()
.iter()
.any(|account| { account.account.authentication_kind() == "oauth" })
);
}
pub(super) struct ManualRefresher {
outcome: Mutex<Option<Result<TokenPair, RefreshFailure>>>,
seen: Mutex<Vec<String>>,