mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-12 22:20:19 +08:00
feat(codex): enroll agent identity from session token
This commit is contained in:
@@ -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 Token(JWT)不能为空",
|
||||
));
|
||||
};
|
||||
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(®istration_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);
|
||||
|
||||
@@ -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 Token(JWT)')"
|
||||
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('格式错误'))
|
||||
|
||||
+49
@@ -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()
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user