From 9e27778e057b51012d5962af49c324f7590f3b77 Mon Sep 17 00:00:00 2001 From: yunyaozhou Date: Sat, 11 Jul 2026 19:31:23 +0800 Subject: [PATCH] feat: add verified profile import and export --- README.md | 20 +- gateway.mjs | 407 ++++++++++++++++++++++++++++++++ scripts/test-gateway-e2e.mjs | 185 ++++++++++++++- ui-src/src/App.tsx | 442 ++++++++++++++++++++++++++++++++++- ui-src/src/styles.css | 10 + 5 files changed, 1061 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 21f67ae..bccd84a 100644 --- a/README.md +++ b/README.md @@ -298,6 +298,7 @@ gateway 运行时只负责 API 与静态文件服务,不再把复杂 UI 硬写 - `usage` 中的 input / output / total / reasoning tokens - 分别管理文本 profiles 与图片 profiles - 各自新建 / 编辑 / 探测 / 切换 / 删除 profile env + - 各自导出 / 导入可移植 profile JSON;导出前必须再次验证该 profile 当前实际使用的 API key - 文本切换 provider `base_url` 不会改写图片分流,图片切换只影响 `/images/*` 与 `/v1/images/*` - 各自切换 `passthrough` / `manual_bearer` / `fixed_bearer` / `auth_json` 认证模式 - 改 `reasoning_equals` @@ -319,9 +320,25 @@ gateway 运行时只负责 API 与静态文件服务,不再把复杂 UI 硬写 - 请求历史只记录元数据、请求体字节数和 token usage,不保存请求正文或响应正文;默认展示最近 200 条 - gateway 日志持久化到 `~/.codex-retry-gateway/logs/gateway.log` - 请求记录持久化到 `~/.codex-retry-gateway/logs/requests.jsonl` 与 `~/.codex-retry-gateway/logs/requests.sqlite`,重启后会用于 UI 请求页以及 overview 的累计 token / reasoning 统计 -- `manual_bearer` 的手动 token/password 只写入系统 secret 文件,API/UI 不读回明文;profile env 只保存 secret 文件路径 +- `manual_bearer` 的手动 token/password 只写入系统 secret 文件,普通 profile API/UI 不读回明文;只有再次验证当前 key 的显式导出接口会把它放进下载文件,profile env 仍只保存 secret 文件路径 - 其他 profile env 不保存明文 `sk-...`;固定密钥请使用 env/file 引用,或用 `auth_json` 指向 `auth.json` 字段名 +### Profile 导入与导出 + +文本与图片 profile 都支持从 Profiles 页面导出和导入: + +- 文本接口:`POST /__codex_retry_gateway/api/profiles/export`、`POST /__codex_retry_gateway/api/profiles/import` +- 图片接口:`POST /__codex_retry_gateway/api/image-profiles/export`、`POST /__codex_retry_gateway/api/image-profiles/import` +- 导出前必须再次提交该 profile 当前实际使用的 API key;gateway 会从现有 `manual_bearer` secret 文件、`fixed_bearer` env/file 或 `auth_json` 解析当前 key,并做常量时间比对 +- `passthrough` 没有 profile-owned key,无法完成再次验证,因此拒绝导出 +- 验证成功后才返回 `codex-retry-gateway-profile` v1 JSON;文件包含明文 API key,文本 profile 若配置了管理 Access key 也会一并保留 +- 导出响应使用 `Cache-Control: no-store`,服务端不生成或保留导出副本,日志只记录 profile 名称和认证来源,不记录 key +- 导入只接受该格式的 JSON,并把包内 key 写入目标用户的默认 secret 文件,目录权限为 `0700`、文件权限为 `0600`;profile env 仍只保存 secret 文件路径 +- 为避免当前控制链路被意外改写,导入不会自动切换 profile,也不允许直接覆盖当前活跃 profile;同名非活跃 profile 需要显式确认覆盖 +- 导入后的认证统一落为可移植的 `manual_bearer`;先用 profile 探针验证,再手动热切换 + +导出文件本身就是明文 secret 载体,不应提交到 Git、上传到普通日志或长期放在共享下载目录。 + ## 如何调整拦截条件 编辑: @@ -398,6 +415,7 @@ macOS / Linux: ~/.codex-retry-gateway - `node scripts/test-gateway-e2e.mjs` - 已通过 - 验证 strict capture 下 `/responses` 生命周期不注入空事件,长流 delta 按序完整回放并以 `response.completed` 结束 + - 验证文本 / 图片 profile 导出必须重新校验 key,错误 key 不泄露 secret;导入后 key 只进入 `0600` secret 文件,不进入 profile env 或普通 API 响应 - `test-install-restore.ps1` - 已通过 - 验证安装、透传、UI 页面、热更新配置、实时日志、516 统计、恢复闭环 diff --git a/gateway.mjs b/gateway.mjs index 66cdec6..858527b 100644 --- a/gateway.mjs +++ b/gateway.mjs @@ -25,14 +25,21 @@ const THREAD_RULE_ITEM_API_PREFIX = `${THREAD_RULES_API_PATH}/`; const PROFILES_API_PATH = `${ADMIN_BASE_PATH}/api/profiles`; const PROFILE_PROBE_API_PATH = `${ADMIN_BASE_PATH}/api/profiles/probe`; const PROFILE_SWITCH_API_PATH = `${ADMIN_BASE_PATH}/api/profiles/switch`; +const PROFILE_EXPORT_API_PATH = `${ADMIN_BASE_PATH}/api/profiles/export`; +const PROFILE_IMPORT_API_PATH = `${ADMIN_BASE_PATH}/api/profiles/import`; const PROFILE_ITEM_API_PREFIX = `${ADMIN_BASE_PATH}/api/profiles/`; const IMAGE_PROFILES_API_PATH = `${ADMIN_BASE_PATH}/api/image-profiles`; const IMAGE_PROFILE_PROBE_API_PATH = `${ADMIN_BASE_PATH}/api/image-profiles/probe`; const IMAGE_PROFILE_SWITCH_API_PATH = `${ADMIN_BASE_PATH}/api/image-profiles/switch`; +const IMAGE_PROFILE_EXPORT_API_PATH = `${ADMIN_BASE_PATH}/api/image-profiles/export`; +const IMAGE_PROFILE_IMPORT_API_PATH = `${ADMIN_BASE_PATH}/api/image-profiles/import`; const IMAGE_PROFILE_ITEM_API_PREFIX = `${ADMIN_BASE_PATH}/api/image-profiles/`; const RESTORE_API_PATH = `${ADMIN_BASE_PATH}/api/restore`; const STATUS_REASONING_COUNT_LIMIT = 24; const MANAGEMENT_ACCESS_COOKIE = "codex_retry_gateway_access"; +const PROFILE_BUNDLE_FORMAT = "codex-retry-gateway-profile"; +const PROFILE_BUNDLE_VERSION = 1; +const PROFILE_TRANSFER_BODY_LIMIT_BYTES = 512 * 1024; const RESPONSES_REASONING_RETRY_PATHS = new Set(["/responses", "/v1/responses"]); const REASONING_RETRY_ABORT_CLIENT = "reasoning_retry_client_disconnected"; const REASONING_RETRY_ABORT_WINNER = "reasoning_retry_winner_selected"; @@ -265,6 +272,28 @@ function timingSafeEquals(left, right) { return mismatch === 0; } +class ProfileTransferError extends Error { + constructor(message, code, statusCode = 400) { + super(message); + this.name = "ProfileTransferError"; + this.code = code; + this.statusCode = statusCode; + } +} + +function normalizeExportableKey(value) { + return `${value || ""}`.trim().replace(/^Bearer\s+/i, "").trim(); +} + +function profileTransferErrorPayload(error, fallbackCode = "profile_transfer_failed") { + return { + error: { + message: `${error?.message || error}`, + code: `${error?.code || fallbackCode}`, + }, + }; +} + function parseCookieHeader(cookieHeader) { const cookies = {}; for (const part of `${cookieHeader || ""}`.split(";")) { @@ -2019,6 +2048,240 @@ async function writeImageProfile(runtime, payload) { }; } +async function verifyProfileExportKey(authConfig, providedKey, scopeLabel) { + const verificationKey = normalizeExportableKey(providedKey); + if (!verificationKey) { + throw new ProfileTransferError( + `导出${scopeLabel} profile 前必须再次输入当前 API key`, + "profile_export_key_required", + ); + } + + let resolvedAuth; + try { + resolvedAuth = await resolveUpstreamAuth(authConfig, `${scopeLabel}_profile_export`); + } catch { + throw new ProfileTransferError( + `${scopeLabel} profile 当前没有可解析、可验证的 API key`, + "profile_export_key_unavailable", + ); + } + + const resolvedKey = normalizeExportableKey(resolvedAuth.authorization); + if (!resolvedKey) { + throw new ProfileTransferError( + `${scopeLabel} profile 使用 passthrough 或没有 profile-owned API key,无法安全导出`, + "profile_export_key_unavailable", + ); + } + if (!timingSafeEquals(verificationKey, resolvedKey)) { + throw new ProfileTransferError( + "API key 验证失败,未导出 profile", + "profile_export_key_invalid", + 403, + ); + } + + return { + key: resolvedKey, + source_auth_mode: resolvedAuth.mode, + source_auth_source: resolvedAuth.source, + }; +} + +function imageProfileAuthConfig(config) { + return { + upstream_auth_mode: config.image_auth_mode, + upstream_auth_env: config.image_auth_env, + upstream_auth_file: config.image_auth_file, + upstream_auth_json_path: config.image_auth_json_path, + upstream_auth_json_key: config.image_auth_json_key, + }; +} + +async function buildTextProfileExportBundle(runtime, payload) { + const profileName = `${payload?.profile || ""}`.trim(); + if (!profileName) { + throw new ProfileTransferError("缺少要导出的 profile", "profile_required"); + } + + const { env, config } = await loadProfileConfigForProbe(runtime, profileName); + const credential = await verifyProfileExportKey(config, payload?.verification_key, "文本"); + return { + format: PROFILE_BUNDLE_FORMAT, + version: PROFILE_BUNDLE_VERSION, + kind: "text", + exported_at: new Date().toISOString(), + source_auth_mode: credential.source_auth_mode, + source_auth_source: credential.source_auth_source, + profile: { + name: profileName, + listen_host: config.listen_host, + listen_port: config.listen_port, + upstream_base_url: config.upstream_base_url, + auth_mode: "manual_bearer", + key: credential.key, + management_access_key: normalizeManagementAccessKey( + env.CODEX_RETRY_GATEWAY_MANAGEMENT_ACCESS_KEY, + ), + request_history_limit: config.request_history_limit, + model_remap: config.model_remap || "", + reasoning_match_mode: config.reasoning_match_mode, + reasoning_equals: config.reasoning_equals, + retryable_status_codes: config.retryable_status_codes, + retryable_error_messages: config.retryable_error_messages, + upstream_fetch_retry_attempts: config.upstream_fetch_retry_attempts, + upstream_fetch_retry_backoff_ms: config.upstream_fetch_retry_backoff_ms, + endpoints: config.endpoints, + }, + }; +} + +async function buildImageProfileExportBundle(runtime, payload) { + const profileName = `${payload?.profile || ""}`.trim(); + if (!profileName) { + throw new ProfileTransferError("缺少要导出的图片 profile", "profile_required"); + } + + const { config } = await loadImageProfileConfigForProbe(runtime, profileName); + if (!config.image_base_url) { + throw new ProfileTransferError( + `图片 profile ${profileName} 未配置 Base URL`, + "profile_export_disabled", + ); + } + const credential = await verifyProfileExportKey( + imageProfileAuthConfig(config), + payload?.verification_key, + "图片", + ); + return { + format: PROFILE_BUNDLE_FORMAT, + version: PROFILE_BUNDLE_VERSION, + kind: "image", + exported_at: new Date().toISOString(), + source_auth_mode: credential.source_auth_mode, + source_auth_source: credential.source_auth_source, + profile: { + name: profileName, + base_url: config.image_base_url, + auth_mode: "manual_bearer", + key: credential.key, + }, + }; +} + +function parseProfileImportBundle(payload, expectedKind) { + const bundle = payload?.bundle; + if (!bundle || typeof bundle !== "object" || Array.isArray(bundle)) { + throw new ProfileTransferError("导入请求缺少 profile bundle", "profile_bundle_required"); + } + if (bundle.format !== PROFILE_BUNDLE_FORMAT || bundle.version !== PROFILE_BUNDLE_VERSION) { + throw new ProfileTransferError( + "不支持的 profile 导出文件格式或版本", + "profile_bundle_unsupported", + ); + } + if (bundle.kind !== expectedKind) { + throw new ProfileTransferError( + expectedKind === "text" ? "该文件不是文本 profile" : "该文件不是图片 profile", + "profile_bundle_kind_mismatch", + ); + } + if (!bundle.profile || typeof bundle.profile !== "object" || Array.isArray(bundle.profile)) { + throw new ProfileTransferError("profile bundle 缺少 profile 配置", "profile_bundle_invalid"); + } + const key = normalizeExportableKey(bundle.profile.key); + if (!key) { + throw new ProfileTransferError("profile bundle 中缺少 API key", "profile_bundle_key_required"); + } + const name = `${payload?.name || bundle.profile.name || ""}`.trim(); + validateProfileName(name); + return { + bundle, + profile: bundle.profile, + key, + name, + overwrite: payload?.overwrite === true, + }; +} + +async function importTextProfileBundle(runtime, payload) { + const imported = parseProfileImportBundle(payload, "text"); + const activeProfile = runtime.config.profile_name || "default"; + const profilePath = path.join(runtime.paths.profilesDir, `${imported.name}.env`); + if (imported.name === activeProfile) { + throw new ProfileTransferError( + "不能直接覆盖当前运行的 profile;请改名导入,探针验证后再切换", + "profile_import_active_conflict", + 409, + ); + } + const existed = fs.existsSync(profilePath); + if (existed && !imported.overwrite) { + throw new ProfileTransferError( + `profile 已存在: ${imported.name};确认后可选择覆盖导入`, + "profile_import_exists", + 409, + ); + } + + const result = await writeProfile(runtime, { + name: imported.name, + listen_host: imported.profile.listen_host, + listen_port: imported.profile.listen_port, + upstream_base_url: imported.profile.upstream_base_url, + auth_mode: "manual_bearer", + manual_secret: imported.key, + management_access_key: imported.profile.management_access_key, + request_history_limit: imported.profile.request_history_limit, + model_remap: imported.profile.model_remap, + reasoning_match_mode: imported.profile.reasoning_match_mode, + reasoning_equals: imported.profile.reasoning_equals, + retryable_status_codes: imported.profile.retryable_status_codes, + retryable_error_messages: imported.profile.retryable_error_messages, + upstream_fetch_retry_attempts: imported.profile.upstream_fetch_retry_attempts, + upstream_fetch_retry_backoff_ms: imported.profile.upstream_fetch_retry_backoff_ms, + endpoints: imported.profile.endpoints, + }); + return { + ...result, + overwritten: existed && imported.overwrite, + }; +} + +async function importImageProfileBundle(runtime, payload) { + const imported = parseProfileImportBundle(payload, "image"); + const activeProfile = `${runtime.config.image_profile_name || ""}`.trim(); + const profilePath = path.join(runtime.paths.imageProfilesDir, `${imported.name}.env`); + if (imported.name === activeProfile) { + throw new ProfileTransferError( + "不能直接覆盖当前运行的图片 profile;请改名导入,探针验证后再切换", + "profile_import_active_conflict", + 409, + ); + } + const existed = fs.existsSync(profilePath); + if (existed && !imported.overwrite) { + throw new ProfileTransferError( + `图片 profile 已存在: ${imported.name};确认后可选择覆盖导入`, + "profile_import_exists", + 409, + ); + } + + const result = await writeImageProfile(runtime, { + name: imported.name, + base_url: imported.profile.base_url, + auth_mode: "manual_bearer", + manual_secret: imported.key, + }); + return { + ...result, + overwritten: existed && imported.overwrite, + }; +} + function buildMetricsSnapshot(monitor) { const reasoning516Count = monitor.observed_reasoning_counts["516"] || 0; const inspectedResponseCount = monitor.inspected_response_count; @@ -3686,6 +3949,78 @@ async function handleManagementRequest(runtime, req, res, requestUrl) { return true; } + if (pathname === PROFILE_EXPORT_API_PATH && req.method === "POST") { + const body = await readRequestBody( + req, + Math.min(runtime.config.request_body_limit_bytes, PROFILE_TRANSFER_BODY_LIMIT_BYTES), + ); + const payload = parseJsonSafely(body); + if (!payload) { + jsonResponse(req, res, 400, { + error: { + message: "profile 导出请求必须是有效 JSON", + code: "invalid_json", + }, + }, { "cache-control": "no-store" }); + return true; + } + + try { + const bundle = await buildTextProfileExportBundle(runtime, payload); + runtime.logger( + `[profile] exported name=${bundle.profile.name} source_auth=${bundle.source_auth_mode}/${bundle.source_auth_source}`, + ); + jsonResponse(req, res, 200, bundle, { + "cache-control": "no-store, max-age=0", + pragma: "no-cache", + "content-disposition": `attachment; filename="codex-retry-gateway-${bundle.profile.name}.profile.json"`, + "x-content-type-options": "nosniff", + }); + } catch (error) { + const statusCode = Number.isInteger(error?.statusCode) ? error.statusCode : 400; + jsonResponse(req, res, statusCode, profileTransferErrorPayload(error, "profile_export_failed"), { + "cache-control": "no-store", + }); + } + return true; + } + + if (pathname === PROFILE_IMPORT_API_PATH && req.method === "POST") { + const body = await readRequestBody( + req, + Math.min(runtime.config.request_body_limit_bytes, PROFILE_TRANSFER_BODY_LIMIT_BYTES), + ); + const payload = parseJsonSafely(body); + if (!payload) { + jsonResponse(req, res, 400, { + error: { + message: "profile 导入请求必须是有效 JSON", + code: "invalid_json", + }, + }, { "cache-control": "no-store" }); + return true; + } + + try { + const result = await importTextProfileBundle(runtime, payload); + runtime.logger(`[profile] imported name=${result.name} overwritten=${result.overwritten}`); + jsonResponse(req, res, 200, { + ok: true, + message: "profile 已导入;API key 已写入本机受限 secret 文件,尚未切换当前实例", + imported_profile: result, + profiles_dir: runtime.paths.profilesDir, + active_profile: runtime.config.profile_name || "default", + profiles: await listProfiles(runtime), + }, { "cache-control": "no-store" }); + } catch (error) { + const statusCode = Number.isInteger(error?.statusCode) ? error.statusCode : 400; + jsonResponse(req, res, statusCode, profileTransferErrorPayload(error, "profile_import_failed"), { + "cache-control": "no-store", + }); + } + return true; + } + if (pathname === PROFILES_API_PATH && req.method === "POST") { const body = await readRequestBody(req, runtime.config.request_body_limit_bytes); const payload = parseJsonSafely(body); @@ -3800,6 +4135,78 @@ async function handleManagementRequest(runtime, req, res, requestUrl) { return true; } + if (pathname === IMAGE_PROFILE_EXPORT_API_PATH && req.method === "POST") { + const body = await readRequestBody( + req, + Math.min(runtime.config.request_body_limit_bytes, PROFILE_TRANSFER_BODY_LIMIT_BYTES), + ); + const payload = parseJsonSafely(body); + if (!payload) { + jsonResponse(req, res, 400, { + error: { + message: "图片 profile 导出请求必须是有效 JSON", + code: "invalid_json", + }, + }, { "cache-control": "no-store" }); + return true; + } + + try { + const bundle = await buildImageProfileExportBundle(runtime, payload); + runtime.logger( + `[image-profile] exported name=${bundle.profile.name} source_auth=${bundle.source_auth_mode}/${bundle.source_auth_source}`, + ); + jsonResponse(req, res, 200, bundle, { + "cache-control": "no-store, max-age=0", + pragma: "no-cache", + "content-disposition": `attachment; filename="codex-retry-gateway-${bundle.profile.name}.image-profile.json"`, + "x-content-type-options": "nosniff", + }); + } catch (error) { + const statusCode = Number.isInteger(error?.statusCode) ? error.statusCode : 400; + jsonResponse(req, res, statusCode, profileTransferErrorPayload(error, "profile_export_failed"), { + "cache-control": "no-store", + }); + } + return true; + } + + if (pathname === IMAGE_PROFILE_IMPORT_API_PATH && req.method === "POST") { + const body = await readRequestBody( + req, + Math.min(runtime.config.request_body_limit_bytes, PROFILE_TRANSFER_BODY_LIMIT_BYTES), + ); + const payload = parseJsonSafely(body); + if (!payload) { + jsonResponse(req, res, 400, { + error: { + message: "图片 profile 导入请求必须是有效 JSON", + code: "invalid_json", + }, + }, { "cache-control": "no-store" }); + return true; + } + + try { + const result = await importImageProfileBundle(runtime, payload); + runtime.logger(`[image-profile] imported name=${result.name} overwritten=${result.overwritten}`); + jsonResponse(req, res, 200, { + ok: true, + message: "图片 profile 已导入;API key 已写入本机受限 secret 文件,尚未切换当前实例", + imported_image_profile: result, + image_profiles_dir: runtime.paths.imageProfilesDir, + active_image_profile: runtime.config.image_profile_name || "", + image_profiles: await listImageProfiles(runtime), + }, { "cache-control": "no-store" }); + } catch (error) { + const statusCode = Number.isInteger(error?.statusCode) ? error.statusCode : 400; + jsonResponse(req, res, statusCode, profileTransferErrorPayload(error, "profile_import_failed"), { + "cache-control": "no-store", + }); + } + return true; + } + if (pathname === IMAGE_PROFILES_API_PATH && req.method === "POST") { const body = await readRequestBody(req, runtime.config.request_body_limit_bytes); const payload = parseJsonSafely(body); diff --git a/scripts/test-gateway-e2e.mjs b/scripts/test-gateway-e2e.mjs index 93394b2..559fc73 100644 --- a/scripts/test-gateway-e2e.mjs +++ b/scripts/test-gateway-e2e.mjs @@ -4,7 +4,7 @@ import http from "node:http"; import net from "node:net"; import { once } from "node:events"; import { spawn } from "node:child_process"; -import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -801,6 +801,134 @@ async function run() { "文本 profile 保存不应写入图片配置", ); + const exportableTextProfileResponse = await fetch( + `http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/profiles`, + { + method: "POST", + headers: { ...adminHeaders, "content-type": "application/json" }, + body: JSON.stringify({ + name: "export-source", + listen_host: "127.0.0.1", + listen_port: gatewayPort, + upstream_base_url: `http://127.0.0.1:${upstreamPort}`, + auth_mode: "manual_bearer", + manual_secret: "text-export-secret", + management_access_key: "profile-admin-secret", + request_history_limit: 25, + model_remap: "gpt-test=gpt-upstream", + reasoning_match_mode: "manual", + reasoning_equals: [516], + retryable_status_codes: [429, 503], + retryable_error_messages: ["capacity test"], + upstream_fetch_retry_attempts: 3, + upstream_fetch_retry_backoff_ms: 50, + endpoints: ["/responses", "/v1/responses"], + }), + }, + ); + assert(exportableTextProfileResponse.status === 200, `可导出文本 profile 保存失败: ${exportableTextProfileResponse.status}`); + const exportableTextProfilePayload = await exportableTextProfileResponse.json(); + assert(!JSON.stringify(exportableTextProfilePayload).includes("text-export-secret"), "普通 profile API 不应返回导出 key"); + + const passthroughExportResponse = await fetch( + `http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/profiles/export`, + { + method: "POST", + headers: { ...adminHeaders, "content-type": "application/json" }, + body: JSON.stringify({ profile: "legacy-text", verification_key: "normal-upstream-key" }), + }, + ); + assert(passthroughExportResponse.status === 400, "passthrough profile 不应允许导出不可验证的 key"); + const passthroughExportPayload = await passthroughExportResponse.json(); + assert( + passthroughExportPayload?.error?.code === "profile_export_key_unavailable", + "passthrough profile 未返回 key unavailable 标识", + ); + + const wrongTextExportResponse = await fetch( + `http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/profiles/export`, + { + method: "POST", + headers: { ...adminHeaders, "content-type": "application/json" }, + body: JSON.stringify({ profile: "export-source", verification_key: "wrong-export-secret" }), + }, + ); + assert(wrongTextExportResponse.status === 403, `错误文本 key 应拒绝导出: ${wrongTextExportResponse.status}`); + const wrongTextExportPayload = await wrongTextExportResponse.json(); + assert(wrongTextExportPayload?.error?.code === "profile_export_key_invalid", "错误文本 key 未返回验证失败标识"); + assert(!JSON.stringify(wrongTextExportPayload).includes("text-export-secret"), "错误 key 响应泄露了文本 profile key"); + + const textExportResponse = await fetch( + `http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/profiles/export`, + { + method: "POST", + headers: { ...adminHeaders, "content-type": "application/json" }, + body: JSON.stringify({ profile: "export-source", verification_key: "text-export-secret" }), + }, + ); + assert(textExportResponse.status === 200, `文本 profile 导出失败: ${textExportResponse.status}`); + assert((textExportResponse.headers.get("cache-control") || "").includes("no-store"), "文本导出响应未禁用缓存"); + assert( + (textExportResponse.headers.get("content-disposition") || "").includes("export-source.profile.json"), + "文本导出响应缺少下载文件名", + ); + const textExportBundle = await textExportResponse.json(); + assert(textExportBundle?.format === "codex-retry-gateway-profile", "文本导出格式标识错误"); + assert(textExportBundle?.version === 1 && textExportBundle?.kind === "text", "文本导出版本或 kind 错误"); + assert(textExportBundle?.profile?.key === "text-export-secret", "文本导出文件未包含 API key"); + assert(textExportBundle?.profile?.management_access_key === "profile-admin-secret", "文本导出文件未保留管理 access key"); + assert(textExportBundle?.profile?.auth_mode === "manual_bearer", "文本导出文件未转换为可移植认证模式"); + assert(textExportBundle?.profile?.auth_file === undefined, "文本导出文件不应包含源机器 secret 路径"); + + const textImportResponse = await fetch( + `http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/profiles/import`, + { + method: "POST", + headers: { ...adminHeaders, "content-type": "application/json" }, + body: JSON.stringify({ bundle: textExportBundle, name: "imported-text" }), + }, + ); + assert(textImportResponse.status === 200, `文本 profile 导入失败: ${textImportResponse.status}`); + const textImportPayload = await textImportResponse.json(); + const importedTextProfile = (textImportPayload.profiles || []).find((profile) => profile?.name === "imported-text"); + assert(importedTextProfile?.form?.auth_mode === "manual_bearer", "导入文本 profile 未使用 manual_bearer"); + assert(importedTextProfile?.form?.manual_secret_configured === true, "导入文本 profile 未配置本机 secret"); + assert(!JSON.stringify(textImportPayload).includes("text-export-secret"), "文本导入响应泄露了 API key"); + const importedTextEnv = await readFile(path.join(profilesDir, "imported-text.env"), "utf8"); + assert(!importedTextEnv.includes("text-export-secret"), "导入文本 profile env 不应包含明文 API key"); + assert(importedTextEnv.includes("CODEX_RETRY_GATEWAY_MANAGEMENT_ACCESS_KEY=profile-admin-secret"), "导入文本 profile 未恢复管理 access key"); + const importedTextSecretPath = path.join(tempRoot, ".codex-retry-gateway", "secrets", "imported-text.token"); + assert( + (await readFile(importedTextSecretPath, "utf8")).trim() + === "text-export-secret", + "导入文本 profile 未把 API key 写入本机 secret 文件", + ); + assert(((await stat(importedTextSecretPath)).mode & 0o777) === 0o600, "导入文本 profile secret 权限不是 0600"); + + const duplicateTextImportResponse = await fetch( + `http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/profiles/import`, + { + method: "POST", + headers: { ...adminHeaders, "content-type": "application/json" }, + body: JSON.stringify({ bundle: textExportBundle, name: "imported-text" }), + }, + ); + assert(duplicateTextImportResponse.status === 409, "同名文本 profile 未确认覆盖时不应导入"); + const duplicateTextImportPayload = await duplicateTextImportResponse.json(); + assert(duplicateTextImportPayload?.error?.code === "profile_import_exists", "同名文本 profile 未返回冲突标识"); + + const activeTextImportResponse = await fetch( + `http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/profiles/import`, + { + method: "POST", + headers: { ...adminHeaders, "content-type": "application/json" }, + body: JSON.stringify({ bundle: textExportBundle, name: "legacy-text", overwrite: true }), + }, + ); + assert(activeTextImportResponse.status === 409, "不应允许导入覆盖当前文本 profile"); + const activeTextImportPayload = await activeTextImportResponse.json(); + assert(activeTextImportPayload?.error?.code === "profile_import_active_conflict", "当前文本 profile 冲突标识错误"); + const imageProfileResponse = await fetch( `http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/image-profiles`, { @@ -822,6 +950,61 @@ async function run() { assert(imagePrimary?.summary?.auth_source === "system secret file configured", "独立图片 profile 未返回认证来源"); assert(!JSON.stringify(imageProfilePayload).includes("test-image-profile-secret"), "图片 profile API 不应返回图片明文 secret"); + const wrongImageExportResponse = await fetch( + `http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/image-profiles/export`, + { + method: "POST", + headers: { ...adminHeaders, "content-type": "application/json" }, + body: JSON.stringify({ profile: "image-primary", verification_key: "wrong-image-secret" }), + }, + ); + assert(wrongImageExportResponse.status === 403, "错误图片 key 应拒绝导出"); + const wrongImageExportPayload = await wrongImageExportResponse.json(); + assert(wrongImageExportPayload?.error?.code === "profile_export_key_invalid", "错误图片 key 未返回验证失败标识"); + assert(!JSON.stringify(wrongImageExportPayload).includes("test-image-profile-secret"), "错误图片 key 响应泄露了真实 key"); + + const imageExportResponse = await fetch( + `http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/image-profiles/export`, + { + method: "POST", + headers: { ...adminHeaders, "content-type": "application/json" }, + body: JSON.stringify({ profile: "image-primary", verification_key: "test-image-profile-secret" }), + }, + ); + assert(imageExportResponse.status === 200, `图片 profile 导出失败: ${imageExportResponse.status}`); + assert((imageExportResponse.headers.get("cache-control") || "").includes("no-store"), "图片导出响应未禁用缓存"); + const imageExportBundle = await imageExportResponse.json(); + assert(imageExportBundle?.kind === "image", "图片导出 kind 错误"); + assert(imageExportBundle?.profile?.key === "test-image-profile-secret", "图片导出文件未包含 API key"); + assert(imageExportBundle?.profile?.auth_mode === "manual_bearer", "图片导出文件未转换为可移植认证模式"); + + const imageImportResponse = await fetch( + `http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/image-profiles/import`, + { + method: "POST", + headers: { ...adminHeaders, "content-type": "application/json" }, + body: JSON.stringify({ bundle: imageExportBundle, name: "imported-image" }), + }, + ); + assert(imageImportResponse.status === 200, `图片 profile 导入失败: ${imageImportResponse.status}`); + const imageImportPayload = await imageImportResponse.json(); + const importedImageProfile = (imageImportPayload.image_profiles || []).find((profile) => profile?.name === "imported-image"); + assert(importedImageProfile?.form?.auth_mode === "manual_bearer", "导入图片 profile 未使用 manual_bearer"); + assert(importedImageProfile?.form?.manual_secret_configured === true, "导入图片 profile 未配置本机 secret"); + assert(!JSON.stringify(imageImportPayload).includes("test-image-profile-secret"), "图片导入响应泄露了 API key"); + const importedImageEnv = await readFile(path.join(imageProfilesDir, "imported-image.env"), "utf8"); + assert(!importedImageEnv.includes("test-image-profile-secret"), "导入图片 profile env 不应包含明文 API key"); + const importedImageSecretPath = path.join(tempRoot, ".codex-retry-gateway", "secrets", "imported-image.images.token"); + assert( + (await readFile(importedImageSecretPath, "utf8")).trim() + === "test-image-profile-secret", + "导入图片 profile 未把 API key 写入本机 secret 文件", + ); + assert(((await stat(importedImageSecretPath)).mode & 0o777) === 0o600, "导入图片 profile secret 权限不是 0600"); + const profileTransferLog = await readFile(logPath, "utf8"); + assert(!profileTransferLog.includes("text-export-secret"), "gateway 日志泄露了文本 profile 导出 key"); + assert(!profileTransferLog.includes("test-image-profile-secret"), "gateway 日志泄露了图片 profile 导出 key"); + const switchImageProfileResponse = await fetch( `http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/image-profiles/switch`, { diff --git a/ui-src/src/App.tsx b/ui-src/src/App.tsx index f447934..aaeffb5 100644 --- a/ui-src/src/App.tsx +++ b/ui-src/src/App.tsx @@ -1,4 +1,4 @@ -import { FormEvent, startTransition, useEffect, useState } from "react"; +import { ChangeEvent, FormEvent, startTransition, useEffect, useState } from "react"; type PageKey = "overview" | "requests" | "profiles" | "rules" | "logs"; type Tone = "" | "success" | "error"; @@ -243,6 +243,12 @@ type ProfilesPayload = { listen?: string; upstream_base_url?: string; } | null; + imported_profile?: { + name?: string; + file_path?: string; + overwritten?: boolean; + } | null; + message?: string; profiles?: Profile[]; }; @@ -254,9 +260,29 @@ type ImageProfilesPayload = { hot_swapped?: boolean; image_base_url?: string; } | null; + imported_image_profile?: { + name?: string; + file_path?: string; + overwritten?: boolean; + } | null; + message?: string; image_profiles?: ImageProfile[]; }; +type ProfileBundle = { + format: "codex-retry-gateway-profile"; + version: number; + kind: "text" | "image"; + exported_at?: string; + source_auth_mode?: string; + source_auth_source?: string; + profile?: { + name?: string; + key?: string; + [key: string]: unknown; + }; +}; + type LogEntry = { seq: number; at?: string; @@ -332,9 +358,13 @@ const api = { profiles: "/__codex_retry_gateway/api/profiles", profileProbe: "/__codex_retry_gateway/api/profiles/probe", profileSwitch: "/__codex_retry_gateway/api/profiles/switch", + profileExport: "/__codex_retry_gateway/api/profiles/export", + profileImport: "/__codex_retry_gateway/api/profiles/import", imageProfiles: "/__codex_retry_gateway/api/image-profiles", imageProfileProbe: "/__codex_retry_gateway/api/image-profiles/probe", imageProfileSwitch: "/__codex_retry_gateway/api/image-profiles/switch", + imageProfileExport: "/__codex_retry_gateway/api/image-profiles/export", + imageProfileImport: "/__codex_retry_gateway/api/image-profiles/import", restore: "/__codex_retry_gateway/api/restore", }; @@ -689,6 +719,38 @@ async function fetchJson(url: string, options?: RequestInit): Promise { return payload as T; } +function downloadJsonFile(payload: unknown, filename: string) { + const blob = new Blob([`${JSON.stringify(payload, null, 2)}\n`], { + type: "application/json;charset=utf-8", + }); + const objectUrl = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = objectUrl; + anchor.download = filename; + anchor.rel = "noopener"; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(objectUrl); +} + +function validateProfileBundleFile(payload: unknown, expectedKind: "text" | "image"): ProfileBundle { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + throw new Error("文件内容不是有效的 profile bundle"); + } + const bundle = payload as ProfileBundle; + if (bundle.format !== "codex-retry-gateway-profile" || bundle.version !== 1) { + throw new Error("不支持的 profile 导出文件格式或版本"); + } + if (bundle.kind !== expectedKind) { + throw new Error(expectedKind === "text" ? "请选择文本 profile 导出文件" : "请选择图片 profile 导出文件"); + } + if (!bundle.profile?.name || !bundle.profile?.key) { + throw new Error("导出文件缺少 profile 名称或 API key"); + } + return bundle; +} + function profileFormFromStatus(status: StatusPayload | null): ProfileFormState { const config = status?.config || {}; return { @@ -847,8 +909,22 @@ export default function App() { const [threadRuleMessage, setThreadRuleMessage] = useState({ text: "", tone: "" }); const [profileMessage, setProfileMessage] = useState({ text: "", tone: "" }); const [profileProbeResult, setProfileProbeResult] = useState(null); + const [profileTransferMessage, setProfileTransferMessage] = useState({ text: "", tone: "" }); + const [profileExportTarget, setProfileExportTarget] = useState(null); + const [profileExportKey, setProfileExportKey] = useState(""); + const [exportingProfile, setExportingProfile] = useState(""); + const [profileImportBundle, setProfileImportBundle] = useState(null); + const [profileImportName, setProfileImportName] = useState(""); + const [profileImportOverwrite, setProfileImportOverwrite] = useState(false); const [imageProfileMessage, setImageProfileMessage] = useState({ text: "", tone: "" }); const [imageProfileProbeResult, setImageProfileProbeResult] = useState(null); + const [imageProfileTransferMessage, setImageProfileTransferMessage] = useState({ text: "", tone: "" }); + const [imageProfileExportTarget, setImageProfileExportTarget] = useState(null); + const [imageProfileExportKey, setImageProfileExportKey] = useState(""); + const [exportingImageProfile, setExportingImageProfile] = useState(""); + const [imageProfileImportBundle, setImageProfileImportBundle] = useState(null); + const [imageProfileImportName, setImageProfileImportName] = useState(""); + const [imageProfileImportOverwrite, setImageProfileImportOverwrite] = useState(false); const [probingProfile, setProbingProfile] = useState(""); const [deletingProfile, setDeletingProfile] = useState(""); const [switchingTo, setSwitchingTo] = useState(""); @@ -1297,6 +1373,103 @@ export default function App() { } } + function prepareProfileExport(profile: Profile) { + setProfileExportTarget(profile); + setProfileExportKey(""); + setProfileTransferMessage({ + text: `准备导出文本 profile ${profile.name};请再次输入它当前实际使用的上游 API key。`, + tone: "", + }); + } + + async function exportProfileBundle(event: FormEvent) { + event.preventDefault(); + if (!profileExportTarget) { + setProfileTransferMessage({ text: "请先从文本 profile 列表选择要导出的项目。", tone: "error" }); + return; + } + setExportingProfile(profileExportTarget.name); + setProfileTransferMessage({ text: `正在验证并导出 ${profileExportTarget.name}...`, tone: "" }); + try { + const bundle = await fetchJson(api.profileExport, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + profile: profileExportTarget.name, + verification_key: profileExportKey, + }), + }); + downloadJsonFile(bundle, `codex-retry-gateway-${profileExportTarget.name}.profile.json`); + setProfileTransferMessage({ + text: `文本 profile ${profileExportTarget.name} 已导出。文件包含明文 API key,请按 secret 文件保管。`, + tone: "success", + }); + } catch (error) { + setProfileTransferMessage({ text: error instanceof Error ? error.message : String(error), tone: "error" }); + } finally { + setProfileExportKey(""); + setExportingProfile(""); + } + } + + async function selectProfileImportFile(event: ChangeEvent) { + const file = event.target.files?.[0]; + if (!file) { + return; + } + try { + if (file.size > 512 * 1024) { + throw new Error("profile 导出文件不能超过 512 KiB"); + } + const parsed = JSON.parse(await file.text()); + const bundle = validateProfileBundleFile(parsed, "text"); + setProfileImportBundle(bundle); + setProfileImportName(bundle.profile?.name || ""); + setProfileImportOverwrite(false); + setProfileTransferMessage({ + text: `已载入文本 profile ${bundle.profile?.name || "-"};导入不会自动切换当前 gateway。`, + tone: "", + }); + } catch (error) { + setProfileImportBundle(null); + setProfileImportName(""); + setProfileTransferMessage({ text: error instanceof Error ? error.message : String(error), tone: "error" }); + } finally { + event.target.value = ""; + } + } + + async function importProfileBundle(event: FormEvent) { + event.preventDefault(); + if (!profileImportBundle) { + setProfileTransferMessage({ text: "请先选择文本 profile 导出文件。", tone: "error" }); + return; + } + setProfileTransferMessage({ text: `正在导入文本 profile ${profileImportName || "-"}...`, tone: "" }); + try { + const payload = await fetchJson(api.profileImport, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + bundle: profileImportBundle, + name: profileImportName, + overwrite: profileImportOverwrite, + }), + }); + setProfiles(payload.profiles || []); + setProfilesMeta(`目录:${payload.profiles_dir || "-"};当前运行:${payload.active_profile || "-"}。`); + setProfileImportBundle(null); + setProfileImportName(""); + setProfileImportOverwrite(false); + setProfileTransferMessage({ + text: payload.message || "文本 profile 已导入;请先探针验证,再手动切换。", + tone: "success", + }); + } catch (error) { + setProfileTransferMessage({ text: error instanceof Error ? error.message : String(error), tone: "error" }); + } + } + async function saveImageProfile(event: FormEvent) { event.preventDefault(); setImageProfileMessage({ text: "正在保存图片 profile...", tone: "" }); @@ -1409,6 +1582,103 @@ export default function App() { } } + function prepareImageProfileExport(profile: ImageProfile) { + setImageProfileExportTarget(profile); + setImageProfileExportKey(""); + setImageProfileTransferMessage({ + text: `准备导出图片 profile ${profile.name};请再次输入它当前实际使用的图片 API key。`, + tone: "", + }); + } + + async function exportImageProfileBundle(event: FormEvent) { + event.preventDefault(); + if (!imageProfileExportTarget) { + setImageProfileTransferMessage({ text: "请先从图片 profile 列表选择要导出的项目。", tone: "error" }); + return; + } + setExportingImageProfile(imageProfileExportTarget.name); + setImageProfileTransferMessage({ text: `正在验证并导出 ${imageProfileExportTarget.name}...`, tone: "" }); + try { + const bundle = await fetchJson(api.imageProfileExport, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + profile: imageProfileExportTarget.name, + verification_key: imageProfileExportKey, + }), + }); + downloadJsonFile(bundle, `codex-retry-gateway-${imageProfileExportTarget.name}.image-profile.json`); + setImageProfileTransferMessage({ + text: `图片 profile ${imageProfileExportTarget.name} 已导出。文件包含明文 API key,请按 secret 文件保管。`, + tone: "success", + }); + } catch (error) { + setImageProfileTransferMessage({ text: error instanceof Error ? error.message : String(error), tone: "error" }); + } finally { + setImageProfileExportKey(""); + setExportingImageProfile(""); + } + } + + async function selectImageProfileImportFile(event: ChangeEvent) { + const file = event.target.files?.[0]; + if (!file) { + return; + } + try { + if (file.size > 512 * 1024) { + throw new Error("图片 profile 导出文件不能超过 512 KiB"); + } + const parsed = JSON.parse(await file.text()); + const bundle = validateProfileBundleFile(parsed, "image"); + setImageProfileImportBundle(bundle); + setImageProfileImportName(bundle.profile?.name || ""); + setImageProfileImportOverwrite(false); + setImageProfileTransferMessage({ + text: `已载入图片 profile ${bundle.profile?.name || "-"};导入不会自动切换当前 gateway。`, + tone: "", + }); + } catch (error) { + setImageProfileImportBundle(null); + setImageProfileImportName(""); + setImageProfileTransferMessage({ text: error instanceof Error ? error.message : String(error), tone: "error" }); + } finally { + event.target.value = ""; + } + } + + async function importImageProfileBundle(event: FormEvent) { + event.preventDefault(); + if (!imageProfileImportBundle) { + setImageProfileTransferMessage({ text: "请先选择图片 profile 导出文件。", tone: "error" }); + return; + } + setImageProfileTransferMessage({ text: `正在导入图片 profile ${imageProfileImportName || "-"}...`, tone: "" }); + try { + const payload = await fetchJson(api.imageProfileImport, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + bundle: imageProfileImportBundle, + name: imageProfileImportName, + overwrite: imageProfileImportOverwrite, + }), + }); + setImageProfiles(payload.image_profiles || []); + setImageProfilesMeta(`目录:${payload.image_profiles_dir || "-"};当前运行:${payload.active_image_profile || "未配置"}。`); + setImageProfileImportBundle(null); + setImageProfileImportName(""); + setImageProfileImportOverwrite(false); + setImageProfileTransferMessage({ + text: payload.message || "图片 profile 已导入;请先探针验证,再手动切换。", + tone: "success", + }); + } catch (error) { + setImageProfileTransferMessage({ text: error instanceof Error ? error.message : String(error), tone: "error" }); + } + } + function waitForProfile(profileName: string) { const deadline = Date.now() + 12000; const tick = async () => { @@ -1901,6 +2171,9 @@ export default function App() { + @@ -1936,6 +2209,88 @@ export default function App() { + +
+
+
+
+

导出文本 Profile

+
下载文件包含明文 API key,以及该 profile 自身配置的管理 Access key。
+
+ {profileExportTarget ? {profileExportTarget.name} : null} +
+
+ + + + + setProfileExportKey(event.target.value)} + /> + +
+ + +
+
+
+ +
+
+
+

导入文本 Profile

+
API key 会写入本机 `0600` secret 文件,profile env 只保存文件路径。
+
+ {profileImportBundle ? 文件已载入 : null} +
+
+ + + + + setProfileImportName(event.target.value)} + /> + + +
+ +
+
+
+
+ +
+
@@ -2115,6 +2470,9 @@ export default function App() { + @@ -2136,6 +2494,88 @@ export default function App() { + +
+
+
+
+

导出图片 Profile

+
下载文件包含明文图片 API key,请按 secret 文件保管。
+
+ {imageProfileExportTarget ? {imageProfileExportTarget.name} : null} +
+ + + + + + setImageProfileExportKey(event.target.value)} + /> + +
+ + +
+ +
+ +
+
+
+

导入图片 Profile

+
图片 key 会写入本机 `0600` secret 文件,导入后请先探针验证。
+
+ {imageProfileImportBundle ? 文件已载入 : null} +
+
+ + + + + setImageProfileImportName(event.target.value)} + /> + + +
+ +
+
+
+
+ +
+
diff --git a/ui-src/src/styles.css b/ui-src/src/styles.css index b0529b3..0c92fe3 100644 --- a/ui-src/src/styles.css +++ b/ui-src/src/styles.css @@ -804,6 +804,15 @@ tr:last-child td { gap: 10px; } +.transfer-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.transfer-pane { + align-content: start; +} + .compact-profile-card { gap: 8px; } @@ -1042,6 +1051,7 @@ form { .token-strip, .mini-stats, .field-row, + .transfer-grid, .nav { grid-template-columns: 1fr; }