feat(codex): enroll agent identity from session token

This commit is contained in:
AAEE86
2026-07-22 10:21:11 +08:00
parent b61c590bdb
commit a4350a482a
7 changed files with 708 additions and 46 deletions
@@ -11,8 +11,8 @@ use super::super::runtime::{
};
use super::super::state::{
admin_provider_oauth_template, build_admin_provider_oauth_backend_unavailable_response,
exchange_admin_provider_oauth_refresh_token, is_fixed_provider_type_for_provider_oauth,
json_u64_value,
enrich_admin_provider_oauth_auth_config, exchange_admin_provider_oauth_refresh_token,
is_fixed_provider_type_for_provider_oauth, json_u64_value,
};
use super::helpers::admin_provider_oauth_key_name_from_auth_config;
use super::token_import::{
@@ -27,10 +27,10 @@ use crate::handlers::admin::request::{
};
use crate::GatewayError;
use aether_contracts::ProxySnapshot;
use aether_oauth::core::OAuthError;
use aether_oauth::provider::{
ProviderOAuthImportInput, ProviderOAuthService, ProviderOAuthTransportContext,
};
use aether_oauth::{core::OAuthError, network::OAuthNetworkContext};
use axum::{
body::Body,
http,
@@ -108,6 +108,76 @@ fn import_payload_string_any(
.map(ToOwned::to_owned)
}
fn import_payload_bool(payload: &serde_json::Map<String, serde_json::Value>, key: &str) -> bool {
payload
.get(key)
.and_then(serde_json::Value::as_bool)
.unwrap_or(false)
}
fn codex_session_token_identity_hints(
session_token: &str,
) -> Result<serde_json::Map<String, serde_json::Value>, &'static str> {
let mut hints = serde_json::Map::new();
enrich_admin_provider_oauth_auth_config(
"codex",
&mut hints,
&json!({ "access_token": session_token }),
);
let account_id = hints
.get("account_id")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty());
let user_id = hints
.get("user_id")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty());
if account_id.is_none() || user_id.is_none() {
return Err("ChatGPT Session Token 缺少账号身份字段");
}
Ok(hints)
}
async fn resolve_admin_provider_oauth_codex_session_agent_identity_import(
state: &AdminAppState<'_>,
session_token: &str,
identity_hints: serde_json::Map<String, serde_json::Value>,
request_proxy: Option<ProxySnapshot>,
) -> Result<AdminProviderOAuthSingleImportTokens, Response<Body>> {
let executor = crate::oauth::GatewayOAuthHttpExecutor::new(*state);
let mut auth_config =
aether_provider_transport::create_codex_agent_identity_from_session_token(
&executor,
session_token,
OAuthNetworkContext::provider_operation(request_proxy),
)
.await
.map_err(|error| {
build_internal_control_error_response(http::StatusCode::BAD_REQUEST, error.to_string())
})?;
for key in [
"access_token",
"accessToken",
"refresh_token",
"refreshToken",
"id_token",
"idToken",
] {
auth_config.remove(key);
}
for (key, value) in identity_hints {
auth_config.entry(key).or_insert(value);
}
Ok(AdminProviderOAuthSingleImportTokens {
// Agent Identity signs every request and never uses the encrypted OAuth token column.
access_token: "__placeholder__".to_string(),
auth_config,
expires_at: None,
})
}
fn import_payload_project_id_any(
payload: &serde_json::Map<String, serde_json::Value>,
keys: &[&str],
@@ -514,6 +584,21 @@ pub(super) async fn handle_admin_provider_oauth_import_refresh_token(
));
}
};
let create_agent_identity_from_session_token =
import_payload_bool(&raw_payload, "create_agent_identity_from_session_token");
let session_token_agent_identity_input = if create_agent_identity_from_session_token {
import_payload_string_any(
&raw_payload,
&[
"session_token",
"sessionToken",
"access_token",
"accessToken",
],
)
} else {
None
};
let refresh_token_input = import_payload_string(&raw_payload, "refresh_token", "refreshToken");
let access_token_input = import_payload_string_any(
&raw_payload,
@@ -559,7 +644,10 @@ pub(super) async fn handle_admin_provider_oauth_import_refresh_token(
refresh_token_input.as_deref(),
access_token_input.as_deref(),
);
if refresh_token_input.is_none() && access_token_input.is_none() {
if !create_agent_identity_from_session_token
&& refresh_token_input.is_none()
&& access_token_input.is_none()
{
return Ok(build_internal_control_error_response(
http::StatusCode::BAD_REQUEST,
"Refresh Token、Access Token 或 sso_token 不能为空",
@@ -577,6 +665,18 @@ pub(super) async fn handle_admin_provider_oauth_import_refresh_token(
"Kiro 不支持单条 Refresh Token 导入,请使用批量导入或设备授权。",
));
}
if create_agent_identity_from_session_token && provider_type != "codex" {
return Ok(build_internal_control_error_response(
http::StatusCode::BAD_REQUEST,
"仅 Codex Provider 支持使用 Session Token 创建 Agent Identity",
));
}
if create_agent_identity_from_session_token && refresh_token_input.is_some() {
return Ok(build_internal_control_error_response(
http::StatusCode::BAD_REQUEST,
"使用 Session Token 创建 Agent Identity 时不能同时提交 Refresh Token",
));
}
let template = admin_provider_oauth_template(&provider_type);
if template.is_none() && !provider_type_supports_access_token_import(&provider_type) {
return Ok(build_admin_provider_oauth_backend_unavailable_response());
@@ -598,7 +698,34 @@ pub(super) async fn handle_admin_provider_oauth_import_refresh_token(
.await;
let key_proxy = provider_oauth_key_proxy_value(proxy_node_id.as_deref());
let resolved_import = if provider_type == "windsurf" {
let resolved_import = if create_agent_identity_from_session_token {
let Some(session_token) = session_token_agent_identity_input.as_deref() else {
return Ok(build_internal_control_error_response(
http::StatusCode::BAD_REQUEST,
"ChatGPT Session TokenJWT)不能为空",
));
};
let identity_hints = match codex_session_token_identity_hints(session_token) {
Ok(hints) => hints,
Err(detail) => {
return Ok(build_internal_control_error_response(
http::StatusCode::BAD_REQUEST,
detail,
));
}
};
match resolve_admin_provider_oauth_codex_session_agent_identity_import(
state,
session_token,
identity_hints,
request_proxy.clone(),
)
.await
{
Ok(value) => value,
Err(response) => return Ok(response),
}
} else if provider_type == "windsurf" {
if !import_payload_has_windsurf_credentials(&raw_payload) {
return Ok(build_internal_control_error_response(
http::StatusCode::BAD_REQUEST,
@@ -645,18 +772,20 @@ pub(super) async fn handle_admin_provider_oauth_import_refresh_token(
mut auth_config,
mut expires_at,
} = resolved_import;
apply_single_import_hints(&provider_type, &raw_payload, &mut auth_config);
if let Some(header_access_token) =
provider_oauth_import_authorization_bearer_token_from_object(&raw_payload)
{
if let Some(header_expires_at) =
decode_access_token_expires_at(&header_access_token).or(imported_expires_at)
if !create_agent_identity_from_session_token {
apply_single_import_hints(&provider_type, &raw_payload, &mut auth_config);
if let Some(header_access_token) =
provider_oauth_import_authorization_bearer_token_from_object(&raw_payload)
{
auth_config.insert("expires_at".to_string(), json!(header_expires_at));
expires_at = Some(header_expires_at);
} else {
auth_config.remove("expires_at");
expires_at = None;
if let Some(header_expires_at) =
decode_access_token_expires_at(&header_access_token).or(imported_expires_at)
{
auth_config.insert("expires_at".to_string(), json!(header_expires_at));
expires_at = Some(header_expires_at);
} else {
auth_config.remove("expires_at");
expires_at = None;
}
}
}
let has_refresh_token = auth_config
@@ -753,12 +882,19 @@ pub(super) async fn handle_admin_provider_oauth_import_refresh_token(
#[cfg(test)]
mod tests {
use super::{
apply_single_import_hints, import_payload_string_any, import_payload_u64_any,
sanitize_windsurf_import_error,
apply_single_import_hints, codex_session_token_identity_hints, import_payload_bool,
import_payload_string_any, import_payload_u64_any, sanitize_windsurf_import_error,
};
use aether_oauth::core::OAuthError;
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use serde_json::json;
fn unsigned_jwt(payload: serde_json::Value) -> String {
let header = URL_SAFE_NO_PAD.encode(r#"{"alg":"none","typ":"JWT"}"#);
let payload = URL_SAFE_NO_PAD.encode(payload.to_string());
format!("{header}.{payload}.signature")
}
#[test]
fn single_import_accepts_session_token_alias() {
let payload = json!({
@@ -774,6 +910,69 @@ mod tests {
);
}
#[test]
fn session_token_agent_identity_hints_require_and_extract_chatgpt_identity() {
let session_token = unsigned_jwt(json!({
"https://api.openai.com/auth": {
"chatgpt_account_id": "account-1",
"chatgpt_user_id": "user-1",
"chatgpt_plan_type": "plus"
},
"https://api.openai.com/profile": {
"email": "agent@example.com"
}
}));
let hints = codex_session_token_identity_hints(&session_token)
.expect("session token identity hints should parse");
assert_eq!(hints.get("account_id"), Some(&json!("account-1")));
assert_eq!(hints.get("user_id"), Some(&json!("user-1")));
assert_eq!(hints.get("plan_type"), Some(&json!("plus")));
assert_eq!(hints.get("email"), Some(&json!("agent@example.com")));
assert!(!hints.contains_key("access_token"));
assert!(!hints.contains_key("id_token"));
}
#[test]
fn session_token_agent_identity_hints_reject_missing_identity() {
let session_token = unsigned_jwt(json!({
"https://api.openai.com/auth": {
"chatgpt_account_id": "account-1"
}
}));
assert_eq!(
codex_session_token_identity_hints(&session_token),
Err("ChatGPT Session Token 缺少账号身份字段")
);
}
#[test]
fn session_token_agent_identity_flag_is_explicit_boolean_only() {
let payload = json!({
"create_agent_identity_from_session_token": true,
})
.as_object()
.cloned()
.expect("payload should be an object");
assert!(import_payload_bool(
&payload,
"create_agent_identity_from_session_token"
));
let string_payload = json!({
"create_agent_identity_from_session_token": "true",
})
.as_object()
.cloned()
.expect("payload should be an object");
assert!(!import_payload_bool(
&string_payload,
"create_agent_identity_from_session_token"
));
}
#[test]
fn single_import_accepts_iso_expired_alias() {
let payload = json!({
@@ -1,16 +1,24 @@
use std::collections::BTreeMap;
use aether_oauth::network::{OAuthHttpExecutor, OAuthHttpRequest, OAuthNetworkContext};
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 crypto_box::{
aead::rand_core::{OsRng, RngCore},
SecretKey as Curve25519SecretKey,
};
use ed25519_dalek::{
pkcs8::{DecodePrivateKey, EncodePrivateKey},
Signer, SigningKey,
};
use serde::Deserialize;
use serde_json::{json, Map, Value};
use sha2::{Digest, Sha512};
use thiserror::Error;
use url::Url;
use super::oauth_refresh::{
@@ -22,11 +30,35 @@ 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_AGENT_REGISTRATION_REQUEST_ID: &str =
"codex:agent-identity-agent-register";
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 ";
const CODEX_AGENT_IDENTITY_AGENT_HARNESS_ID: &str = "codex-cli";
const CODEX_AGENT_IDENTITY_RUNNING_LOCATION: &str = "local";
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum CodexAgentIdentityEnrollmentError {
#[error("ChatGPT Session Token 不能为空")]
MissingSessionToken,
#[error("Agent Identity 注册请求失败")]
RegistrationRequestFailed,
#[error("Agent Identity 注册被拒绝(HTTP {status_code}")]
RegistrationRejected { status_code: u16 },
#[error("Agent Identity 注册响应无效")]
InvalidRegistrationResponse,
#[error("Agent Identity 密钥生成失败")]
KeyGenerationFailed,
#[error("Agent Identity task 初始化请求失败")]
TaskRegistrationRequestFailed,
#[error("Agent Identity task 初始化被拒绝(HTTP {status_code}")]
TaskRegistrationRejected { status_code: u16 },
#[error("Agent Identity task 初始化响应无效")]
InvalidTaskRegistrationResponse,
}
#[derive(Clone)]
struct AgentIdentityCredentials {
@@ -47,6 +79,14 @@ struct AgentTaskRegistrationResponse {
encrypted_task_id_camel: Option<String>,
}
#[derive(Debug, Deserialize)]
struct AgentRegistrationResponse {
#[serde(default)]
agent_runtime_id: Option<String>,
#[serde(default, rename = "agentRuntimeId")]
agent_runtime_id_camel: Option<String>,
}
#[derive(Debug, Clone)]
pub struct CodexAgentIdentityRefreshAdapter {
auth_api_base_url: String,
@@ -289,6 +329,182 @@ fn task_registration_url(base_url: &str, runtime_id: &str) -> Result<String, Str
Ok(url.into())
}
fn agent_registration_url(base_url: &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", "register"] {
segments.push(segment);
}
drop(segments);
Ok(url.into())
}
fn generate_agent_identity_signing_key() -> SigningKey {
let mut seed = [0_u8; 32];
let mut rng = OsRng;
rng.fill_bytes(&mut seed);
let signing_key = SigningKey::from_bytes(&seed);
seed.fill(0);
signing_key
}
fn agent_identity_ssh_public_key(signing_key: &SigningKey) -> String {
let header = b"ssh-ed25519";
let public_key = signing_key.verifying_key().to_bytes();
let mut blob = Vec::with_capacity(4 + header.len() + 4 + public_key.len());
blob.extend_from_slice(&(header.len() as u32).to_be_bytes());
blob.extend_from_slice(header);
blob.extend_from_slice(&(public_key.len() as u32).to_be_bytes());
blob.extend_from_slice(&public_key);
format!("ssh-ed25519 {}", STANDARD.encode(blob))
}
fn agent_runtime_id_from_registration_response(body: &str) -> Result<String, ()> {
let response = serde_json::from_str::<AgentRegistrationResponse>(body).map_err(|_| ())?;
[response.agent_runtime_id, response.agent_runtime_id_camel]
.into_iter()
.flatten()
.map(|value| value.trim().to_string())
.find(|value| !value.is_empty())
.ok_or(())
}
/// Uses a ChatGPT session token once to register a fresh Agent Identity. The returned config
/// contains only the generated signing credentials and is deliberately free of the session token.
pub async fn create_codex_agent_identity_from_session_token(
executor: &dyn OAuthHttpExecutor,
session_token: &str,
network: OAuthNetworkContext,
) -> Result<Map<String, Value>, CodexAgentIdentityEnrollmentError> {
create_codex_agent_identity_from_session_token_with_auth_api_base_url(
executor,
session_token,
network,
CODEX_AGENT_IDENTITY_AUTH_API_BASE_URL,
)
.await
}
async fn create_codex_agent_identity_from_session_token_with_auth_api_base_url(
executor: &dyn OAuthHttpExecutor,
session_token: &str,
network: OAuthNetworkContext,
auth_api_base_url: &str,
) -> Result<Map<String, Value>, CodexAgentIdentityEnrollmentError> {
let session_token = session_token.trim();
if session_token.is_empty() {
return Err(CodexAgentIdentityEnrollmentError::MissingSessionToken);
}
let signing_key = generate_agent_identity_signing_key();
let private_key_der = signing_key
.to_pkcs8_der()
.map_err(|_| CodexAgentIdentityEnrollmentError::KeyGenerationFailed)?;
let agent_private_key = STANDARD.encode(private_key_der.as_bytes());
let agent_public_key = agent_identity_ssh_public_key(&signing_key);
let registration_url = agent_registration_url(auth_api_base_url)
.map_err(|_| CodexAgentIdentityEnrollmentError::RegistrationRequestFailed)?;
let registration_response = executor
.execute(OAuthHttpRequest {
request_id: CODEX_AGENT_IDENTITY_AGENT_REGISTRATION_REQUEST_ID.to_string(),
method: reqwest::Method::POST,
url: registration_url,
headers: BTreeMap::from([
("accept".to_string(), "application/json".to_string()),
("content-type".to_string(), "application/json".to_string()),
(
"authorization".to_string(),
format!("Bearer {session_token}"),
),
(
"user-agent".to_string(),
aether_ai_formats::CODEX_CLIENT_USER_AGENT.to_string(),
),
(
"originator".to_string(),
aether_ai_formats::CODEX_CLIENT_ORIGINATOR.to_string(),
),
]),
content_type: Some("application/json".to_string()),
json_body: Some(json!({
"abom": {
"agent_version": aether_ai_formats::CODEX_CLIENT_VERSION,
"agent_harness_id": CODEX_AGENT_IDENTITY_AGENT_HARNESS_ID,
"running_location": CODEX_AGENT_IDENTITY_RUNNING_LOCATION,
},
"agent_public_key": agent_public_key,
})),
body_bytes: None,
network: network.clone(),
})
.await
.map_err(|_| CodexAgentIdentityEnrollmentError::RegistrationRequestFailed)?;
if !(200..300).contains(&registration_response.status_code) {
return Err(CodexAgentIdentityEnrollmentError::RegistrationRejected {
status_code: registration_response.status_code,
});
}
let agent_runtime_id =
agent_runtime_id_from_registration_response(registration_response.body_text.as_str())
.map_err(|_| CodexAgentIdentityEnrollmentError::InvalidRegistrationResponse)?;
let mut auth_config = Map::from_iter([
(
"provider_type".to_string(),
json!(CODEX_AGENT_IDENTITY_PROVIDER_TYPE),
),
(
"auth_mode".to_string(),
json!(CODEX_AGENT_IDENTITY_AUTH_MODE),
),
("agent_runtime_id".to_string(), json!(agent_runtime_id)),
("agent_private_key".to_string(), json!(agent_private_key)),
]);
let config_value = Value::Object(auth_config.clone());
let credentials = agent_identity_credentials(&config_value)
.map_err(|_| CodexAgentIdentityEnrollmentError::KeyGenerationFailed)?;
let (timestamp, signature) = build_task_registration_signature(&credentials, Utc::now());
let task_url = task_registration_url(auth_api_base_url, credentials.runtime_id.as_str())
.map_err(|_| CodexAgentIdentityEnrollmentError::TaskRegistrationRequestFailed)?;
let task_response = executor
.execute(OAuthHttpRequest {
request_id: CODEX_AGENT_IDENTITY_TASK_REGISTRATION_REQUEST_ID.to_string(),
method: reqwest::Method::POST,
url: task_url,
headers: BTreeMap::from([
("accept".to_string(), "application/json".to_string()),
("content-type".to_string(), "application/json".to_string()),
]),
content_type: Some("application/json".to_string()),
json_body: Some(json!({
"timestamp": timestamp,
"signature": signature,
})),
body_bytes: None,
network,
})
.await
.map_err(|_| CodexAgentIdentityEnrollmentError::TaskRegistrationRequestFailed)?;
if !(200..300).contains(&task_response.status_code) {
return Err(
CodexAgentIdentityEnrollmentError::TaskRegistrationRejected {
status_code: task_response.status_code,
},
);
}
let task_id =
task_id_from_registration_response(&credentials, task_response.body_text.as_str())
.map_err(|_| CodexAgentIdentityEnrollmentError::InvalidTaskRegistrationResponse)?;
auth_config.insert("task_id".to_string(), Value::String(task_id));
validate_codex_agent_identity_auth_config(&Value::Object(auth_config.clone()))
.map_err(|_| CodexAgentIdentityEnrollmentError::KeyGenerationFailed)?;
Ok(auth_config)
}
fn task_id_from_registration_response(
credentials: &AgentIdentityCredentials,
body: &str,
@@ -468,15 +684,21 @@ impl LocalOAuthRefreshAdapter for CodexAgentIdentityRefreshAdapter {
mod tests {
use std::sync::{Arc, Mutex};
use aether_oauth::network::{
OAuthHttpExecutor, OAuthHttpRequest, OAuthHttpResponse, OAuthNetworkContext,
};
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,
agent_identity_credentials, build_agent_assertion,
create_codex_agent_identity_from_session_token_with_auth_api_base_url,
decrypt_agent_task_id, is_codex_agent_identity_auth_config_value,
is_codex_agent_identity_invalid_task_response, task_id_from_registration_response,
validate_codex_agent_identity_auth_config, with_agent_identity_task_id,
CodexAgentIdentityEnrollmentError, CodexAgentIdentityRefreshAdapter,
CODEX_AGENT_IDENTITY_CACHED_ENTRY_PROVIDER_TYPE,
};
use crate::oauth_refresh::{
LocalOAuthHttpExecutor, LocalOAuthHttpRequest, LocalOAuthHttpResponse,
@@ -688,6 +910,133 @@ mod tests {
}
}
#[derive(Clone)]
struct RecordingEnrollmentExecutor {
requests: Arc<Mutex<Vec<OAuthHttpRequest>>>,
responses: Arc<Mutex<Vec<OAuthHttpResponse>>>,
}
#[async_trait::async_trait]
impl OAuthHttpExecutor for RecordingEnrollmentExecutor {
async fn execute(
&self,
request: OAuthHttpRequest,
) -> Result<OAuthHttpResponse, aether_oauth::core::OAuthError> {
self.requests
.lock()
.expect("recording lock should hold")
.push(request);
let mut responses = self.responses.lock().expect("response lock should hold");
if responses.is_empty() {
return Err(aether_oauth::core::OAuthError::transport(
"missing mock response",
));
}
Ok(responses.remove(0))
}
}
#[tokio::test]
async fn enrolls_agent_identity_from_session_token_without_storing_it() {
let requests = Arc::new(Mutex::new(Vec::new()));
let executor = RecordingEnrollmentExecutor {
requests: Arc::clone(&requests),
responses: Arc::new(Mutex::new(vec![
OAuthHttpResponse {
status_code: 200,
body_text: r#"{"agent_runtime_id":"runtime-enrolled"}"#.to_string(),
json_body: None,
},
OAuthHttpResponse {
status_code: 200,
body_text: r#"{"task_id":"task-enrolled"}"#.to_string(),
json_body: None,
},
])),
};
let config = create_codex_agent_identity_from_session_token_with_auth_api_base_url(
&executor,
"session-token-for-test-only",
OAuthNetworkContext::direct_identity(),
"https://auth.test/api/accounts",
)
.await
.expect("enrollment should succeed");
validate_codex_agent_identity_auth_config(&serde_json::Value::Object(config.clone()))
.expect("enrollment should return valid credentials");
assert_eq!(
config.get("agent_runtime_id"),
Some(&json!("runtime-enrolled"))
);
assert_eq!(config.get("task_id"), Some(&json!("task-enrolled")));
assert!(!config.contains_key("access_token"));
assert!(!config.contains_key("refresh_token"));
assert!(!config.contains_key("id_token"));
assert!(!config
.values()
.any(|value| value.as_str() == Some("session-token-for-test-only")));
let requests = requests.lock().expect("recording lock should hold");
assert_eq!(requests.len(), 2);
assert_eq!(
requests[0].url,
"https://auth.test/api/accounts/v1/agent/register"
);
assert_eq!(
requests[0].headers.get("authorization").map(String::as_str),
Some("Bearer session-token-for-test-only")
);
assert_eq!(
requests[0]
.json_body
.as_ref()
.and_then(|body| body.get("abom"))
.and_then(|abom| abom.get("agent_harness_id")),
Some(&json!("codex-cli"))
);
assert!(requests[0]
.json_body
.as_ref()
.and_then(|body| body.get("agent_public_key"))
.and_then(serde_json::Value::as_str)
.is_some_and(|key| key.starts_with("ssh-ed25519 ")));
assert_eq!(
requests[1].url,
"https://auth.test/api/accounts/v1/agent/runtime-enrolled/task/register"
);
assert!(!requests[1].headers.contains_key("authorization"));
}
#[tokio::test]
async fn enrollment_error_does_not_echo_session_token_or_response_body() {
let executor = RecordingEnrollmentExecutor {
requests: Arc::new(Mutex::new(Vec::new())),
responses: Arc::new(Mutex::new(vec![OAuthHttpResponse {
status_code: 401,
body_text: r#"{"detail":"session-token-for-test-only"}"#.to_string(),
json_body: None,
}])),
};
let error = create_codex_agent_identity_from_session_token_with_auth_api_base_url(
&executor,
"session-token-for-test-only",
OAuthNetworkContext::direct_identity(),
"https://auth.test/api/accounts",
)
.await
.expect_err("rejected enrollment should fail");
assert_eq!(
error,
CodexAgentIdentityEnrollmentError::RegistrationRejected { status_code: 401 }
);
assert!(!error.to_string().contains("session-token-for-test-only"));
assert!(!error.to_string().contains("detail"));
}
#[tokio::test]
async fn registers_missing_task_then_builds_a_fresh_assertion_from_cache() {
let config = test_auth_config(None);
+7 -5
View File
@@ -30,11 +30,13 @@ 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,
create_codex_agent_identity_from_session_token, 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,
CodexAgentIdentityEnrollmentError, CodexAgentIdentityRefreshAdapter,
CODEX_AGENT_IDENTITY_AGENT_REGISTRATION_REQUEST_ID, 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;
@@ -240,6 +240,8 @@ export async function importProviderRefreshToken(
authToken?: string
refresh_token?: string
access_token?: string
session_token?: string
create_agent_identity_from_session_token?: boolean
password?: string
expires_at?: number
name?: string
@@ -643,20 +643,53 @@
</div>
</div>
<JsonImportInput
v-else
v-model="importText"
:disabled="importing"
:reset-key="importInputResetKey"
:drop-title="importDropTitle"
:drop-hint="importDropHint"
:manual-placeholder="importManualPlaceholder"
:manual-description="importManualDescription"
:paste-toggle-text="importPasteToggleText"
:file-toggle-text="importFileToggleText"
textarea-class="min-h-[200px] text-xs font-mono break-all !rounded-xl"
@error="handleImportInputError"
/>
<template v-else>
<div
v-if="isCodexProvider"
class="rounded-lg border border-border bg-muted/20 px-3 py-2.5"
>
<div class="flex items-center justify-between gap-3">
<div class="min-w-0">
<p class="text-xs font-medium">
{{ legacyT('使用 Session Token 创建 Agent Identity(实验)') }}
</p>
<p class="mt-0.5 text-[11px] text-muted-foreground">
{{ legacyT('仅用于一次性注册,成功后不会保存 Token。') }}
</p>
</div>
<Switch
v-model="useSessionTokenAgentIdentity"
:disabled="importing"
:aria-label="legacyT('使用 Session Token 创建 Agent Identity(实验)')"
/>
</div>
</div>
<Textarea
v-if="isCodexSessionTokenAgentIdentityImport"
v-model="importText"
:disabled="importing"
:placeholder="legacyT('粘贴 ChatGPT Session TokenJWT')"
class="min-h-[200px] text-xs font-mono break-all !rounded-xl"
autocomplete="off"
spellcheck="false"
/>
<JsonImportInput
v-else
v-model="importText"
:disabled="importing"
:reset-key="importInputResetKey"
:drop-title="importDropTitle"
:drop-hint="importDropHint"
:manual-placeholder="importManualPlaceholder"
:manual-description="importManualDescription"
:paste-toggle-text="importPasteToggleText"
:file-toggle-text="importFileToggleText"
textarea-class="min-h-[200px] text-xs font-mono break-all !rounded-xl"
@error="handleImportInputError"
/>
</template>
<div
v-if="importTask && !isWindsurfEmailPasswordImport"
@@ -740,7 +773,7 @@
<script setup lang="ts">
import { ref, computed, watch, onBeforeUnmount } from 'vue'
import { Dialog, Button, Textarea, Popover, PopoverTrigger, PopoverContent } from '@/components/ui'
import { Dialog, Button, Textarea, Popover, PopoverTrigger, PopoverContent, Switch } from '@/components/ui'
import {
ComboboxAnchor,
ComboboxContent,
@@ -941,6 +974,7 @@ const windsurfImportMethod = ref<WindsurfImportMethod>('email_password')
const windsurfEmail = ref('')
const windsurfPassword = ref('')
const windsurfAccountName = ref('')
const useSessionTokenAgentIdentity = ref(false)
const isOpen = computed(() => props.open)
@@ -1039,7 +1073,7 @@ const importManualPlaceholder = computed(() => (
? legacyT('粘贴 Grok sso/session token,支持每行一个;或粘贴包含 token、sso_token、access_token、plan_type、pool_tier 的 JSON')
: isWindsurfProvider.value
? legacyT('粘贴 show-auth-token Token、API key 或 JSON 内容')
: legacyT('粘贴 Refresh Token / Access Token JSON 内容')
: legacyT('粘贴 Refresh Token / Access Token / Agent Identity JSON 内容')
))
const importManualDescription = computed(() => (
isGrokProvider.value
@@ -1065,6 +1099,9 @@ const proxyUsageDescription = computed(() => {
const isWindsurfEmailPasswordImport = computed(() =>
isWindsurfProvider.value && windsurfImportMethod.value === 'email_password'
)
const isCodexSessionTokenAgentIdentityImport = computed(() =>
isCodexProvider.value && useSessionTokenAgentIdentity.value
)
const importButtonText = computed(() => {
if (importing.value) {
@@ -1072,6 +1109,9 @@ const importButtonText = computed(() => {
? (isEnglishLocale() ? `Importing ${importTask.value.progress_percent}%` : `导入中 ${importTask.value.progress_percent}%`)
: legacyT('导入中...')
}
if (isCodexSessionTokenAgentIdentityImport.value) {
return legacyT('创建并导入 Agent Identity')
}
return isWindsurfEmailPasswordImport.value ? legacyT('登录并导入') : importButtonLabel.value
})
@@ -1328,6 +1368,7 @@ function resetForm() {
windsurfEmail.value = ''
windsurfPassword.value = ''
windsurfAccountName.value = ''
useSessionTokenAgentIdentity.value = false
proxyPopoverOpen.value = false
selectedProxyNodeId.value = ''
mode.value = defaultMode.value
@@ -1782,6 +1823,26 @@ async function handleImport() {
return
}
if (isCodexSessionTokenAgentIdentityImport.value) {
importing.value = true
try {
const result = await importProviderRefreshToken(props.providerId, {
session_token: inputText,
create_agent_identity_from_session_token: true,
proxy_node_id: selectedProxyNodeId.value || undefined,
})
success(getOAuthSuccessMessage('导入', result))
emit('saved')
handleClose()
} catch (err: unknown) {
const errorMessage = localizedApiError(err, '创建 Agent Identity 失败')
showError(errorMessage, legacyT('错误'))
} finally {
importing.value = false
}
return
}
const normalizedCredentials = normalizeBatchImportCredentials(inputText)
if (!normalizedCredentials.ok) {
showError(legacyT(normalizedCredentials.message), legacyT('格式错误'))
@@ -82,10 +82,30 @@ vi.mock('@/components/ui', async () => {
},
})
const Switch = defineComponent({
name: 'SwitchStub',
props: {
modelValue: Boolean,
disabled: Boolean,
},
emits: ['update:modelValue'],
setup(props, { attrs, emit }) {
return () => h('button', {
...attrs,
type: 'button',
role: 'switch',
'aria-checked': String(props.modelValue),
disabled: props.disabled,
onClick: () => emit('update:modelValue', !props.modelValue),
})
},
})
return {
Dialog,
Button,
Textarea,
Switch,
Popover: passthrough('PopoverStub'),
PopoverTrigger: passthrough('PopoverTriggerStub'),
PopoverContent: passthrough('PopoverContentStub'),
@@ -418,6 +438,35 @@ describe('OAuthAccountDialog Grok import', () => {
expect(endpointMocks.importProviderRefreshToken).not.toHaveBeenCalled()
})
it('creates Codex Agent Identity from a Session Token only when enabled', async () => {
const root = mountDialog('codex')
await settle()
getButton(root, '导入授权')?.click()
await settle()
const sessionTokenSwitch = root.querySelector<HTMLButtonElement>('button[role="switch"]')
expect(sessionTokenSwitch).toBeTruthy()
expect(sessionTokenSwitch?.getAttribute('aria-checked')).toBe('false')
sessionTokenSwitch?.click()
await settle()
const textarea = getImportTextarea(root)
textarea.value = 'session-token-for-test-only'
textarea.dispatchEvent(new Event('input'))
await settle()
getExactButton(root, '创建并导入 Agent Identity')?.click()
await settle()
expect(endpointMocks.importProviderRefreshToken).toHaveBeenCalledWith('provider-1', {
session_token: 'session-token-for-test-only',
create_agent_identity_from_session_token: true,
proxy_node_id: undefined,
})
expect(endpointMocks.startBatchImportOAuthTask).not.toHaveBeenCalled()
})
it('sends a complete sub2api Agent Identity export through batch import', async () => {
const root = mountDialog('codex')
await settle()
+1 -1
View File
@@ -1616,7 +1616,7 @@ const legacyExactEnglishMessages: Record<string, string> = {
'支持 .json / .txt,可多选': 'Supports .json / .txt and multiple selection',
'粘贴 Grok sso/session token,支持每行一个;或粘贴包含 token、sso_token、access_token、plan_type、pool_tier 的 JSON': 'Paste Grok sso/session tokens, one per line, or paste JSON containing token, sso_token, access_token, plan_type, and pool_tier.',
'粘贴 show-auth-token Token、API key 或 JSON 内容': 'Paste a show-auth-token token, API key, or JSON content',
'粘贴 Refresh Token / Access Token JSON 内容': 'Paste a Refresh Token / Access Token or JSON content',
'粘贴 Refresh Token / Access Token / Agent Identity JSON 内容': 'Paste a Refresh Token, Access Token, or Agent Identity JSON',
'plan_type / pool_tier 会作为账号套餐与能力特征保存,不是路由池选择。': 'plan_type / pool_tier are saved as account plan and capability traits, not as routing pool selection.',
'或手动粘贴 Grok Token': 'Or manually paste a Grok token',
'或手动粘贴 Token': 'Or manually paste a token',