Merge pull request #432 from RWDai/fix/cli-install-copy

Add admin API key CLI install sessions
This commit is contained in:
fawney19
2026-05-12 16:50:24 +08:00
committed by GitHub
21 changed files with 872 additions and 65 deletions

View File

@@ -0,0 +1,87 @@
use super::shared::{
admin_api_key_install_session_id_from_path, build_admin_api_keys_bad_request_response,
build_admin_api_keys_data_unavailable_response, build_admin_api_keys_not_found_response,
};
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
use crate::handlers::admin::shared::attach_admin_audit_response;
use crate::handlers::public::{
build_api_key_install_session_response, CreateApiKeyInstallSessionRequest,
};
use crate::GatewayError;
use axum::{
body::Body,
http,
response::{IntoResponse, Response},
};
pub(super) async fn build_admin_create_api_key_install_session_response(
state: &AdminAppState<'_>,
request_context: &AdminRequestContext<'_>,
request_headers: &http::HeaderMap,
request_body: Option<&axum::body::Bytes>,
) -> Result<Response<Body>, GatewayError> {
if !state.has_auth_api_key_data_reader() {
return Ok(build_admin_api_keys_data_unavailable_response());
}
let Some(api_key_id) = admin_api_key_install_session_id_from_path(request_context.path())
else {
return Ok(build_admin_api_keys_data_unavailable_response());
};
let Some(request_body) = request_body else {
return Ok(build_admin_api_keys_bad_request_response(
"请求数据验证失败",
));
};
let payload = match serde_json::from_slice::<CreateApiKeyInstallSessionRequest>(request_body) {
Ok(value) => value,
Err(_) => {
return Ok(build_admin_api_keys_bad_request_response(
"请求数据验证失败",
))
}
};
let Some(record) = state
.find_auth_api_key_export_standalone_record_by_id(&api_key_id)
.await?
else {
return Ok(build_admin_api_keys_not_found_response());
};
let Some(ciphertext) = record
.key_encrypted
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return Ok(build_admin_api_keys_bad_request_response(
"该密钥没有存储完整密钥信息",
));
};
let Some(api_key) = state.decrypt_catalog_secret_with_fallbacks(ciphertext) else {
return Ok((
http::StatusCode::INTERNAL_SERVER_ERROR,
axum::Json(serde_json::json!({ "detail": "解密密钥失败" })),
)
.into_response());
};
let response = build_api_key_install_session_response(
state.app(),
request_context.public(),
request_headers,
record.api_key_id.clone(),
record.name.unwrap_or_else(|| "API Key".to_string()),
api_key,
payload,
)
.await;
Ok(attach_admin_audit_response(
response,
"admin_standalone_api_key_install_session_created",
"create_standalone_api_key_install_session",
"api_key",
&api_key_id,
))
}

View File

@@ -18,11 +18,13 @@ use axum::{
};
use serde_json::json;
mod install_routes;
mod mutation_routes;
mod read_routes;
mod routes;
mod shared;
use self::install_routes::build_admin_create_api_key_install_session_response;
use self::mutation_routes::{
build_admin_create_api_key_response, build_admin_delete_api_key_response,
build_admin_toggle_api_key_response, build_admin_update_api_key_response,
@@ -39,8 +41,14 @@ use self::shared::{
pub(crate) async fn maybe_build_local_admin_api_keys_response(
state: &AdminAppState<'_>,
request_context: &AdminRequestContext<'_>,
request_headers: &http::HeaderMap,
request_body: Option<&axum::body::Bytes>,
) -> Result<Option<Response<Body>>, GatewayError> {
routes::maybe_build_local_admin_api_keys_routes_response(state, request_context, request_body)
.await
routes::maybe_build_local_admin_api_keys_routes_response(
state,
request_context,
request_headers,
request_body,
)
.await
}

View File

@@ -1,3 +1,4 @@
use super::install_routes::build_admin_create_api_key_install_session_response;
use super::mutation_routes::{
build_admin_create_api_key_response, build_admin_delete_api_key_response,
build_admin_toggle_api_key_response, build_admin_update_api_key_response,
@@ -11,6 +12,7 @@ use axum::{body::Body, http, response::Response};
pub(super) async fn maybe_build_local_admin_api_keys_routes_response(
state: &AdminAppState<'_>,
request_context: &AdminRequestContext<'_>,
request_headers: &http::HeaderMap,
request_body: Option<&axum::body::Bytes>,
) -> Result<Option<Response<Body>>, GatewayError> {
let Some(decision) = request_context.decision() else {
@@ -22,8 +24,10 @@ pub(super) async fn maybe_build_local_admin_api_keys_routes_response(
}
let path = request_context.path();
let path_no_trailing = path.trim_end_matches('/');
let is_api_keys_route = matches!(path, "/api/admin/api-keys" | "/api/admin/api-keys/")
|| (path.starts_with("/api/admin/api-keys/") && path.matches('/').count() == 4);
|| (path_no_trailing.starts_with("/api/admin/api-keys/")
&& matches!(path_no_trailing.matches('/').count(), 4 | 5));
if !is_api_keys_route {
return Ok(None);
@@ -54,6 +58,21 @@ pub(super) async fn maybe_build_local_admin_api_keys_routes_response(
build_admin_create_api_key_response(state, request_context, request_body).await?,
))
}
Some("create_api_key_install_session")
if request_context.method() == http::Method::POST
&& path_no_trailing.starts_with("/api/admin/api-keys/")
&& path_no_trailing.ends_with("/install-sessions") =>
{
Ok(Some(
build_admin_create_api_key_install_session_response(
state,
request_context,
request_headers,
request_body,
)
.await?,
))
}
Some("update_api_key")
if request_context.method() == http::Method::PUT
&& path.starts_with("/api/admin/api-keys/") =>

View File

@@ -91,6 +91,17 @@ pub(super) fn admin_api_keys_id_from_path(request_path: &str) -> Option<String>
}
}
pub(super) fn admin_api_key_install_session_id_from_path(request_path: &str) -> Option<String> {
let raw = request_path
.strip_prefix("/api/admin/api-keys/")?
.trim()
.trim_matches('/');
let mut segments = raw.split('/').map(str::trim);
let api_key_id = segments.next()?.to_string();
let suffix = segments.next()?;
(suffix == "install-sessions" && segments.next().is_none()).then_some(api_key_id)
}
pub(super) fn admin_api_keys_operator_id(
request_context: &AdminRequestContext<'_>,
) -> Option<String> {

View File

@@ -17,6 +17,7 @@ pub(crate) async fn maybe_build_local_admin_auth_response(
if let Some(response) = api_keys::maybe_build_local_admin_api_keys_response(
&request.state(),
&request.request_context(),
request.request_headers(),
request.request_body(),
)
.await?

View File

@@ -1,7 +1,7 @@
use super::{AdminAppState, AdminRequestContext};
use crate::{AppState, GatewayError};
use axum::body::{Body, Bytes};
use axum::http::Response;
use axum::http::{HeaderMap, Response};
pub(crate) enum AdminCancelVideoTaskError {
NotFound,
@@ -14,6 +14,7 @@ pub(crate) enum AdminCancelVideoTaskError {
pub(crate) struct AdminRouteRequest<'a> {
state: AdminAppState<'a>,
request_context: AdminRequestContext<'a>,
request_headers: &'a HeaderMap,
request_body: Option<&'a Bytes>,
}
@@ -21,11 +22,13 @@ impl<'a> AdminRouteRequest<'a> {
pub(crate) fn new(
state: &'a AppState,
request_context: &'a crate::control::GatewayPublicRequestContext,
request_headers: &'a HeaderMap,
request_body: Option<&'a Bytes>,
) -> Self {
Self {
state: AdminAppState::new(state),
request_context: AdminRequestContext::new(request_context),
request_headers,
request_body,
}
}
@@ -38,6 +41,10 @@ impl<'a> AdminRouteRequest<'a> {
self.request_context
}
pub(crate) fn request_headers(self) -> &'a HeaderMap {
self.request_headers
}
pub(crate) fn request_body(self) -> Option<&'a Bytes> {
self.request_body
}

View File

@@ -30,6 +30,7 @@ pub(super) async fn maybe_build_local_internal_proxy_response(
pub(super) async fn maybe_build_local_admin_proxy_response(
state: &AppState,
request_context: &GatewayPublicRequestContext,
request_headers: &http::HeaderMap,
request_body: Option<&Bytes>,
) -> Result<Option<Response<Body>>, GatewayError> {
let Some(decision) = request_context.control_decision.as_ref() else {
@@ -49,6 +50,7 @@ pub(super) async fn maybe_build_local_admin_proxy_response(
admin_api::maybe_build_local_admin_response(admin_api::AdminRouteRequest::new(
state,
request_context,
request_headers,
request_body,
))
.await

View File

@@ -874,9 +874,13 @@ pub(crate) async fn proxy_request(
request_permit.take(),
));
}
if let Some(response) =
maybe_build_local_admin_proxy_response(&state, &request_context, local_proxy_body.as_ref())
.await?
if let Some(response) = maybe_build_local_admin_proxy_response(
&state,
&request_context,
&parts.headers,
local_proxy_body.as_ref(),
)
.await?
{
let execution_path =
resolve_local_proxy_execution_path(&response, EXECUTION_PATH_PUBLIC_PROXY_PASSTHROUGH);

View File

@@ -20,6 +20,7 @@ pub(crate) use self::system_modules_helpers::{
};
pub(crate) use self::support::{
build_unhandled_public_support_response, matches_model_mapping_for_models,
maybe_build_local_admin_announcements_response, maybe_build_local_public_support_response,
build_api_key_install_session_response, build_unhandled_public_support_response,
matches_model_mapping_for_models, maybe_build_local_admin_announcements_response,
maybe_build_local_public_support_response, CreateApiKeyInstallSessionRequest,
};

View File

@@ -63,6 +63,9 @@ use self::support_auth::{
build_auth_settings_payload, extract_client_device_id, maybe_build_local_auth_response,
};
use self::support_dashboard::maybe_build_local_dashboard_response;
pub(crate) use self::support_install::{
build_api_key_install_session_response, CreateApiKeyInstallSessionRequest,
};
use self::support_install::{
handle_users_me_api_key_install_session_create, maybe_build_local_install_response,
users_me_api_key_install_sessions_path_matches,

View File

@@ -17,7 +17,7 @@ const INSTALL_SESSION_KEY_PREFIX: &str = "install:session:";
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
enum InstallTargetCli {
pub(crate) enum InstallTargetCli {
ClaudeCode,
CodexCli,
GeminiCli,
@@ -25,7 +25,7 @@ enum InstallTargetCli {
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
enum InstallTargetSystem {
pub(crate) enum InstallTargetSystem {
Macos,
Linux,
Windows,
@@ -33,9 +33,9 @@ enum InstallTargetSystem {
}
#[derive(Debug, Deserialize)]
struct UsersMeCreateInstallSessionRequest {
target_cli: InstallTargetCli,
target_system: InstallTargetSystem,
pub(crate) struct CreateApiKeyInstallSessionRequest {
pub(crate) target_cli: InstallTargetCli,
pub(crate) target_system: InstallTargetSystem,
}
#[derive(Debug, Serialize, Deserialize)]
@@ -240,20 +240,60 @@ PY
;;
codex_cli)
mkdir -p "$HOME/.codex"
cat > "$HOME/.codex/auth.json" <<EOF
{{"OPENAI_API_KEY":"$AETHER_API_KEY"}}
EOF
cat > "$HOME/.codex/config.toml" <<EOF
# Managed by Aether
model_provider = "aether"
python3 - "$HOME/.codex/config.toml" "$AETHER_BASE_URL" "$AETHER_API_KEY" <<'PY'
import pathlib, re, sys
[model_providers.aether]
name = "Aether"
base_url = "$AETHER_BASE_URL/v1"
env_key = "OPENAI_API_KEY"
wire_api = "chat"
EOF
chmod 600 "$HOME/.codex/auth.json" "$HOME/.codex/config.toml" 2>/dev/null || true
path = pathlib.Path(sys.argv[1])
base_url = sys.argv[2].rstrip('/') + '/v1'
api_key = sys.argv[3]
text = path.read_text() if path.exists() else ''
lines = text.splitlines()
def quote_toml(value: str) -> str:
return '"' + value.replace('\\', '\\\\').replace('"', '\\"') + '"'
result = []
in_aether = False
top_model_provider_set = False
seen_section = False
for line in lines:
stripped = line.strip()
if re.match(r'^\[.*\]$', stripped):
seen_section = True
in_aether = stripped == '[model_providers.aether]'
if in_aether:
continue
if in_aether:
continue
if not seen_section and re.match(r'^model_provider\s*=', stripped):
if not top_model_provider_set:
result.append('model_provider = "aether"')
top_model_provider_set = True
continue
result.append(line)
if not top_model_provider_set:
insert_at = next((idx for idx, line in enumerate(result) if line.strip().startswith('[')), len(result))
while insert_at > 0 and result[insert_at - 1].strip() == '':
insert_at -= 1
result[insert_at:insert_at] = ['model_provider = "aether"', '']
while result and result[-1].strip() == '':
result.pop()
if result:
result.append('')
result.extend([
'# Managed by Aether',
'[model_providers.aether]',
'name = "Aether"',
f'base_url = {{quote_toml(base_url)}}',
'wire_api = "responses"',
'requires_openai_auth = false',
f'experimental_bearer_token = {{quote_toml(api_key)}}',
])
path.write_text('\n'.join(result) + '\n')
PY
chmod 600 "$HOME/.codex/config.toml" 2>/dev/null || true
;;
gemini_cli)
mkdir -p "$HOME/.gemini"
@@ -329,8 +369,51 @@ if ($TargetCli -eq 'claude_code') {{
$Data | ConvertTo-Json -Depth 8 | Set-Content $Path -Encoding UTF8
}} elseif ($TargetCli -eq 'codex_cli') {{
$Dir = Join-Path $HomeDir '.codex'; New-Item -ItemType Directory -Force -Path $Dir | Out-Null
Set-Content (Join-Path $Dir 'auth.json') -Value (@{{ OPENAI_API_KEY = $AetherApiKey }} | ConvertTo-Json) -Encoding UTF8
Set-Content (Join-Path $Dir 'config.toml') -Value "# Managed by Aether`nmodel_provider = \"aether\"`n`n[model_providers.aether]`nname = \"Aether\"`nbase_url = \"$AetherBaseUrl/v1\"`nenv_key = \"OPENAI_API_KEY\"`nwire_api = \"chat\"`n" -Encoding UTF8
$Path = Join-Path $Dir 'config.toml'
$Text = if (Test-Path $Path) {{ Get-Content $Path -Raw }} else {{ '' }}
$Lines = if ($Text.Length -gt 0) {{ $Text -split "`r?`n" }} else {{ @() }}
$Result = New-Object System.Collections.Generic.List[string]
$InAether = $false
$TopModelProviderSet = $false
$SeenSection = $false
foreach ($Line in $Lines) {{
$Stripped = $Line.Trim()
if ($Stripped -match '^\[.*\]$') {{
$SeenSection = $true
$InAether = $Stripped -eq '[model_providers.aether]'
if ($InAether) {{ continue }}
}}
if ($InAether) {{ continue }}
if (-not $SeenSection -and $Stripped -match '^model_provider\s*=') {{
if (-not $TopModelProviderSet) {{
$Result.Add('model_provider = "aether"')
$TopModelProviderSet = $true
}}
continue
}}
$Result.Add($Line)
}}
if (-not $TopModelProviderSet) {{
$InsertAt = $Result.Count
for ($Index = 0; $Index -lt $Result.Count; $Index++) {{
if ($Result[$Index].Trim().StartsWith('[')) {{ $InsertAt = $Index; break }}
}}
while ($InsertAt -gt 0 -and $Result[$InsertAt - 1].Trim() -eq '') {{ $InsertAt-- }}
$Result.Insert($InsertAt, '')
$Result.Insert($InsertAt, 'model_provider = "aether"')
}}
while ($Result.Count -gt 0 -and $Result[$Result.Count - 1].Trim() -eq '') {{ $Result.RemoveAt($Result.Count - 1) }}
if ($Result.Count -gt 0) {{ $Result.Add('') }}
$EscapedBaseUrl = ($AetherBaseUrl.TrimEnd('/') + '/v1').Replace('\', '\\').Replace('"', '\"')
$EscapedApiKey = $AetherApiKey.Replace('\', '\\').Replace('"', '\"')
$Result.Add('# Managed by Aether')
$Result.Add('[model_providers.aether]')
$Result.Add('name = "Aether"')
$Result.Add("base_url = `"$EscapedBaseUrl`"")
$Result.Add('wire_api = "responses"')
$Result.Add('requires_openai_auth = false')
$Result.Add("experimental_bearer_token = `"$EscapedApiKey`"")
Set-Content -Path $Path -Value (($Result -join "`n") + "`n") -Encoding UTF8
}} elseif ($TargetCli -eq 'gemini_cli') {{
$Dir = Join-Path $HomeDir '.gemini'; New-Item -ItemType Directory -Force -Path $Dir | Out-Null
Set-Content (Join-Path $Dir '.env') -Value "GEMINI_API_KEY=$AetherApiKey`nGOOGLE_API_KEY=$AetherApiKey`nGOOGLE_GEMINI_BASE_URL=$AetherBaseUrl`nAETHER_BASE_URL=$AetherBaseUrl`n" -Encoding UTF8
@@ -375,7 +458,7 @@ pub(super) async fn handle_users_me_api_key_install_session_create(
let Some(request_body) = request_body else {
return build_auth_error_response(http::StatusCode::BAD_REQUEST, "请求数据验证失败", false);
};
let payload = match serde_json::from_slice::<UsersMeCreateInstallSessionRequest>(request_body) {
let payload = match serde_json::from_slice::<CreateApiKeyInstallSessionRequest>(request_body) {
Ok(value) => value,
Err(_) => {
return build_auth_error_response(
@@ -426,11 +509,32 @@ pub(super) async fn handle_users_me_api_key_install_session_create(
);
};
build_api_key_install_session_response(
state,
request_context,
headers,
record.api_key_id.clone(),
record.name.unwrap_or_else(|| "API Key".to_string()),
api_key,
payload,
)
.await
}
pub(crate) async fn build_api_key_install_session_response(
state: &AppState,
request_context: &GatewayPublicRequestContext,
headers: &http::HeaderMap,
api_key_id: String,
api_key_name: String,
api_key: String,
payload: CreateApiKeyInstallSessionRequest,
) -> Response<Body> {
let code = generate_install_code();
let expires_at_unix_secs = unix_secs_now().saturating_add(INSTALL_SESSION_TTL_SECS);
let session = StoredInstallSession {
api_key_id: record.api_key_id.clone(),
api_key_name: record.name.unwrap_or_else(|| "API Key".to_string()),
api_key_id,
api_key_name,
api_key,
base_url: base_url_from_request(headers, request_context),
target_cli: payload.target_cli,
@@ -559,3 +663,49 @@ pub(super) async fn maybe_build_local_install_response(
);
Some(response)
}
#[cfg(test)]
mod tests {
use super::*;
fn test_session(target_cli: InstallTargetCli) -> StoredInstallSession {
StoredInstallSession {
api_key_id: "key-1".to_string(),
api_key_name: "Key 1".to_string(),
api_key: "sk-test".to_string(),
base_url: "http://localhost:8084".to_string(),
target_cli,
target_system: InstallTargetSystem::Linux,
expires_at_unix_secs: u64::MAX,
}
}
#[test]
fn codex_unix_script_preserves_config_and_uses_responses_bearer_token() {
let script = build_unix_script(&test_session(InstallTargetCli::CodexCli));
assert!(script.contains("path.read_text() if path.exists() else ''"));
assert!(script.contains("stripped == '[model_providers.aether]'"));
assert!(script.contains("model_provider = \"aether\""));
assert!(script.contains("wire_api = \"responses\""));
assert!(script.contains("requires_openai_auth = false"));
assert!(script.contains("experimental_bearer_token ="));
assert!(!script.contains("wire_api = \"chat\""));
assert!(!script.contains("cat > \"$HOME/.codex/config.toml\""));
assert!(!script.contains("auth.json"));
}
#[test]
fn codex_powershell_script_preserves_config_and_uses_responses_bearer_token() {
let script = build_powershell_script(&test_session(InstallTargetCli::CodexCli));
assert!(script.contains("Get-Content $Path -Raw"));
assert!(script.contains("$Stripped -eq '[model_providers.aether]'"));
assert!(script.contains("model_provider = \"aether\""));
assert!(script.contains("wire_api = \"responses\""));
assert!(script.contains("requires_openai_auth = false"));
assert!(script.contains("experimental_bearer_token ="));
assert!(!script.contains("wire_api = \"chat\""));
assert!(!script.contains("auth.json"));
}
}

View File

@@ -307,7 +307,11 @@ pub(crate) fn admin_proxy_local_requires_buffered_body(
| (Some("payments_manage"), http::Method::POST, Some("credit_order"))
| (Some("payments_manage"), http::Method::POST, Some("create_redeem_code_batch"))
| (Some("payments_manage"), http::Method::POST, Some("delete_redeem_code_batch"))
| (Some("api_keys_manage"), http::Method::POST, Some("create_api_key"))
| (
Some("api_keys_manage"),
http::Method::POST,
Some("create_api_key" | "create_api_key_install_session"),
)
| (Some("api_keys_manage"), http::Method::PUT, Some("update_api_key"))
| (Some("api_keys_manage"), http::Method::PATCH, Some("toggle_api_key"))
| (Some("adaptive_manage"), http::Method::PATCH, Some("toggle_mode"))