From f8b919e8839fddc740e90b7d50a82d415f1b4b3d Mon Sep 17 00:00:00 2001
From: initiatione <269353933@qq.com>
Date: Wed, 22 Apr 2026 13:57:21 +0800
Subject: [PATCH] Add codex2api as a protocol-first OAuth source
This keeps the existing CPA, SUB2API, and contribution flows intact
while introducing codex2api as a separate source that plugs into
Step 7 and Step 10 through backend APIs instead of page DOM.
The sidepanel now exposes codex2api settings, preserves the built-in
local default through placeholder-only UI, and accepts user-provided
public admin URLs. Step 7 now treats missing or invalid management
secrets as terminal config errors so the flow stops instead of retrying
needlessly.
Constraint: New source must be additive and must not break existing CPA/SUB2API/contribution paths
Rejected: Drive codex2api through admin page button clicks | too brittle against frontend DOM changes
Rejected: Reuse contribution mode as a source surface | violates existing panelMode/runtime-mode boundary
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Keep codex2api on the protocol path unless its API becomes insufficient; do not add a panel content script without proving the protocol path cannot work
Tested: npm test; targeted codex2api/auth retry loops repeated 40 times; full suite repeated 5 times
Not-tested: Real browser run against a live codex2api instance with a real Admin Secret and OpenAI authorization
---
README.md | 24 +++-
background.js | 61 ++++++++-
background/message-router.js | 2 +
background/navigation-utils.js | 37 +++++-
background/panel-bridge.js | 102 +++++++++++++++
background/steps/oauth-login.js | 21 +++
background/steps/platform-verify.js | 123 ++++++++++++++++++
sidepanel/contribution-mode.js | 2 +
sidepanel/sidepanel.css | 11 ++
sidepanel/sidepanel.html | 11 ++
sidepanel/sidepanel.js | 38 +++++-
...ackground-account-history-settings.test.js | 16 ++-
tests/background-contribution-mode.test.js | 4 +-
...background-navigation-utils-module.test.js | 20 +++
tests/background-panel-bridge-module.test.js | 50 +++++++
...ckground-platform-verify-codex2api.test.js | 81 ++++++++++++
tests/background-step6-retry-limit.test.js | 85 ++++++++++++
tests/sidepanel-contribution-mode.test.js | 8 ++
项目完整链路说明.md | 33 +++--
项目开发规范(AI协作).md | 10 +-
项目文件结构说明.md | 13 +-
21 files changed, 720 insertions(+), 32 deletions(-)
create mode 100644 tests/background-platform-verify-codex2api.test.js
diff --git a/README.md b/README.md
index 43468ac..9a2faa0 100644
--- a/README.md
+++ b/README.md
@@ -132,7 +132,15 @@
4. Step 1 会直接在 SUB2API 后台生成 OAuth 链接
5. Step 10 会把 localhost 回调提交回 SUB2API,并直接创建 OpenAI 账号
-### 方案 C:`Hotmail 账号池`
+### 方案 C:`Codex2API + QQ / 163 / 163 VIP`
+
+1. `来源` 选择 `Codex2API`
+2. 填好 `Codex2API` 后台地址、管理密钥
+3. `Mail` 与 `邮箱生成` 的配置方式同方案 A
+4. Step 7 会直接通过 Codex2API 协议 `/api/admin/oauth/generate-auth-url` 生成 OAuth 链接
+5. Step 10 会把 localhost 回调中的 `code / state` 通过 `/api/admin/oauth/exchange-code` 直接提交给 Codex2API
+
+### 方案 D:`Hotmail 账号池`
1. `Mail` 选择 `Hotmail`
2. 在 `Hotmail 账号池` 中添加 `邮箱 / Client ID / Refresh Token`
@@ -177,6 +185,20 @@ Step 1 和 Step 10 都依赖这个地址。
插件会在 Step 1 和 Step 10 自动从 `/api/v1/admin/proxies/all` 解析这个代理,并在 OAuth 链接生成、授权码交换和账号创建请求中附带 `proxy_id`。如果名称匹配到多个代理,请改填代理 ID;留空则不会发送 `proxy_id`。
+### `Codex2API`
+
+当 `来源 = Codex2API` 时,需要配置:
+
+- `Codex2API`:后台账号管理页地址,默认 `http://localhost:8080/admin/accounts`
+- `管理密钥`:Codex2API 的 `Admin Secret`
+
+插件会在:
+
+- Step 7 调用 `POST /api/admin/oauth/generate-auth-url` 生成授权链接
+- Step 10 调用 `POST /api/admin/oauth/exchange-code` 完成 localhost callback 的授权码交换并创建账号
+
+这条来源是协议直连,不依赖 Codex2API 后台页面的“添加账号 / OAuth 授权 / 生成授权链接”按钮 DOM。
+
### `Mail`
支持五种验证码来源:
diff --git a/background.js b/background.js
index 7297909..ca48d74 100644
--- a/background.js
+++ b/background.js
@@ -155,6 +155,7 @@ const OAUTH_FLOW_TIMEOUT_MS = 6 * 60 * 1000;
const SUB2API_STEP1_RESPONSE_TIMEOUT_MS = 90000;
const SUB2API_STEP9_RESPONSE_TIMEOUT_MS = 120000;
const DEFAULT_SUB2API_URL = 'https://sub2api.hisence.fun/admin/accounts';
+const DEFAULT_CODEX2API_URL = 'http://localhost:8080/admin/accounts';
const DEFAULT_SUB2API_GROUP_NAME = 'codex';
const DEFAULT_SUB2API_PROXY_NAME = '';
const DEFAULT_SUB2API_REDIRECT_URI = 'http://localhost:1455/auth/callback';
@@ -251,6 +252,8 @@ const PERSISTED_SETTING_DEFAULTS = {
sub2apiPassword: '',
sub2apiGroupName: DEFAULT_SUB2API_GROUP_NAME,
sub2apiDefaultProxyName: DEFAULT_SUB2API_PROXY_NAME,
+ codex2apiUrl: DEFAULT_CODEX2API_URL,
+ codex2apiAdminKey: '',
customPassword: '',
autoRunSkipFailures: false,
autoRunFallbackThreadIntervalMinutes: 0,
@@ -327,6 +330,8 @@ const DEFAULT_STATE = {
sub2apiGroupId: null, // SUB2API 目标分组 ID。
sub2apiDraftName: null, // SUB2API 本轮预生成的账号名称。
sub2apiProxyId: null, // SUB2API 本轮使用的代理 ID。
+ codex2apiSessionId: null, // Codex2API OAuth 会话 ID。
+ codex2apiOAuthState: null, // Codex2API OAuth state。
flowStartTime: null, // 当前流程开始时间。
tabRegistry: {}, // 程序维护的标签页注册表。
sourceLastUrls: {}, // 各来源页面最近一次打开的地址记录。
@@ -651,7 +656,14 @@ function normalizeEmailGenerator(value = '') {
}
function normalizePanelMode(value = '') {
- return String(value || '').trim().toLowerCase() === 'sub2api' ? 'sub2api' : 'cpa';
+ const normalized = String(value || '').trim().toLowerCase();
+ if (normalized === 'sub2api') {
+ return 'sub2api';
+ }
+ if (normalized === 'codex2api') {
+ return 'codex2api';
+ }
+ return 'cpa';
}
function normalizeMailProvider(value = '') {
@@ -872,6 +884,10 @@ function normalizePersistentSettingValue(key, value) {
return String(value || '').trim();
case 'sub2apiDefaultProxyName':
return String(value || '').trim();
+ case 'codex2apiUrl':
+ return normalizeCodex2ApiUrl(value);
+ case 'codex2apiAdminKey':
+ return String(value || '').trim();
case 'customPassword':
return String(value || '');
case 'autoRunSkipFailures':
@@ -3620,11 +3636,31 @@ function normalizeSub2ApiUrl(rawUrl) {
return parsed.toString();
}
+function normalizeCodex2ApiUrl(rawUrl) {
+ if (typeof navigationUtils !== 'undefined' && navigationUtils?.normalizeCodex2ApiUrl) {
+ return navigationUtils.normalizeCodex2ApiUrl(rawUrl);
+ }
+ const input = (rawUrl || '').trim() || DEFAULT_CODEX2API_URL;
+ const withProtocol = /^https?:\/\//i.test(input) ? input : `http://${input}`;
+ const parsed = new URL(withProtocol);
+ if (!parsed.pathname || parsed.pathname === '/' || parsed.pathname === '/admin') {
+ parsed.pathname = '/admin/accounts';
+ }
+ parsed.hash = '';
+ return parsed.toString();
+}
+
function getPanelMode(state = {}) {
if (typeof navigationUtils !== 'undefined' && navigationUtils?.getPanelMode) {
return navigationUtils.getPanelMode(state);
}
- return state.panelMode === 'sub2api' ? 'sub2api' : 'cpa';
+ if (state.panelMode === 'sub2api') {
+ return 'sub2api';
+ }
+ if (state.panelMode === 'codex2api') {
+ return 'codex2api';
+ }
+ return 'cpa';
}
function getPanelModeLabel(modeOrState) {
@@ -3632,7 +3668,13 @@ function getPanelModeLabel(modeOrState) {
return navigationUtils.getPanelModeLabel(modeOrState);
}
const mode = typeof modeOrState === 'string' ? modeOrState : getPanelMode(modeOrState);
- return mode === 'sub2api' ? 'SUB2API' : 'CPA';
+ if (mode === 'sub2api') {
+ return 'SUB2API';
+ }
+ if (mode === 'codex2api') {
+ return 'Codex2API';
+ }
+ return 'CPA';
}
function isSignupPageHost(hostname = '') {
@@ -3936,6 +3978,7 @@ function isRetryableContentScriptTransportError(error) {
}
const navigationUtils = self.MultiPageBackgroundNavigationUtils?.createNavigationUtils({
+ DEFAULT_CODEX2API_URL,
DEFAULT_SUB2API_URL,
normalizeLocalCpaStep9Mode,
});
@@ -4111,6 +4154,8 @@ function getDownstreamStateResets(step) {
sub2apiOAuthState: null,
sub2apiGroupId: null,
sub2apiDraftName: null,
+ codex2apiSessionId: null,
+ codex2apiOAuthState: null,
flowStartTime: null,
password: null,
lastEmailTimestamp: null,
@@ -4896,6 +4941,8 @@ async function handleStepData(step, payload) {
if (payload.sub2apiGroupId !== undefined) updates.sub2apiGroupId = payload.sub2apiGroupId || null;
if (payload.sub2apiDraftName !== undefined) updates.sub2apiDraftName = payload.sub2apiDraftName || null;
if (payload.sub2apiProxyId !== undefined) updates.sub2apiProxyId = payload.sub2apiProxyId || null;
+ if (payload.codex2apiSessionId !== undefined) updates.codex2apiSessionId = payload.codex2apiSessionId || null;
+ if (payload.codex2apiOAuthState !== undefined) updates.codex2apiOAuthState = payload.codex2apiOAuthState || null;
if (Object.keys(updates).length) {
await setState(updates);
}
@@ -6159,6 +6206,7 @@ const panelBridge = self.MultiPageBackgroundPanelBridge?.createPanelBridge({
closeConflictingTabsForSource,
ensureContentScriptReadyOnTab,
getPanelMode,
+ normalizeCodex2ApiUrl,
normalizeSub2ApiUrl,
rememberSourceLastUrl,
sendToContentScript,
@@ -6334,6 +6382,7 @@ const step10Executor = self.MultiPageBackgroundStep10?.createStep10Executor({
getTabId,
isLocalhostOAuthCallbackUrl,
isTabAlive,
+ normalizeCodex2ApiUrl,
normalizeSub2ApiUrl,
rememberSourceLastUrl,
reuseOrCreateTab,
@@ -6782,7 +6831,7 @@ async function runPreStep6CookieCleanup() {
async function refreshOAuthUrlBeforeStep6(state) {
if (state?.contributionModeExpected && !state?.contributionMode) {
- throw new Error('步骤 7:当前自动流程预期使用贡献模式,但运行态 contributionMode 已丢失,已阻止回退到普通 CPA / SUB2API 链路。请重新进入贡献模式后再点击自动。');
+ throw new Error('步骤 7:当前自动流程预期使用贡献模式,但运行态 contributionMode 已丢失,已阻止回退到普通 CPA / SUB2API / Codex2API 链路。请重新进入贡献模式后再点击自动。');
}
if (state?.contributionMode && contributionOAuthManager?.startContributionFlow) {
await addLog('步骤 7:contributionMode=true,走公开贡献接口,正在申请 OAuth 登录地址...', 'info');
@@ -6798,7 +6847,7 @@ async function refreshOAuthUrlBeforeStep6(state) {
await handleStepData(1, { oauthUrl });
return oauthUrl;
}
- await addLog(`步骤 7:contributionMode=false,走普通 CPA / SUB2API 链路(当前面板:${getPanelModeLabel(state)}),正在刷新 OAuth 登录地址...`, 'info');
+ await addLog(`步骤 7:contributionMode=false,走普通 CPA / SUB2API / Codex2API 链路(当前面板:${getPanelModeLabel(state)}),正在刷新 OAuth 登录地址...`, 'info');
console.log(LOG_PREFIX, '[refreshOAuthUrlBeforeStep6] requesting fresh OAuth directly from panel');
const refreshResult = await requestOAuthUrlFromPanel(state, { logLabel: '步骤 7' });
await handleStepData(1, refreshResult);
@@ -7524,7 +7573,7 @@ async function executeContributionStep10(state) {
async function executeStep10(state) {
if (state?.contributionModeExpected && !state?.contributionMode) {
- throw new Error('步骤 10:当前自动流程预期使用贡献模式,但运行态 contributionMode 已丢失,已阻止回退到普通 CPA / SUB2API 提交。请重新进入贡献模式后再点击自动。');
+ throw new Error('步骤 10:当前自动流程预期使用贡献模式,但运行态 contributionMode 已丢失,已阻止回退到普通 CPA / SUB2API / Codex2API 提交。请重新进入贡献模式后再点击自动。');
}
if (state?.contributionMode) {
return executeContributionStep10(state);
diff --git a/background/message-router.js b/background/message-router.js
index b8580fd..1a4a92e 100644
--- a/background/message-router.js
+++ b/background/message-router.js
@@ -121,6 +121,8 @@
if (payload.sub2apiGroupId !== undefined) updates.sub2apiGroupId = payload.sub2apiGroupId || null;
if (payload.sub2apiDraftName !== undefined) updates.sub2apiDraftName = payload.sub2apiDraftName || null;
if (payload.sub2apiProxyId !== undefined) updates.sub2apiProxyId = payload.sub2apiProxyId || null;
+ if (payload.codex2apiSessionId !== undefined) updates.codex2apiSessionId = payload.codex2apiSessionId || null;
+ if (payload.codex2apiOAuthState !== undefined) updates.codex2apiOAuthState = payload.codex2apiOAuthState || null;
if (Object.keys(updates).length) {
await setState(updates);
}
diff --git a/background/navigation-utils.js b/background/navigation-utils.js
index 2c7b793..edde364 100644
--- a/background/navigation-utils.js
+++ b/background/navigation-utils.js
@@ -3,6 +3,7 @@
})(typeof self !== 'undefined' ? self : globalThis, function createBackgroundNavigationUtilsModule() {
function createNavigationUtils(deps = {}) {
const {
+ DEFAULT_CODEX2API_URL,
DEFAULT_SUB2API_URL,
normalizeLocalCpaStep9Mode,
} = deps;
@@ -27,13 +28,36 @@
return parsed.toString();
}
+ function normalizeCodex2ApiUrl(rawUrl) {
+ const input = (rawUrl || '').trim() || DEFAULT_CODEX2API_URL;
+ const withProtocol = /^https?:\/\//i.test(input) ? input : `http://${input}`;
+ const parsed = new URL(withProtocol);
+ if (!parsed.pathname || parsed.pathname === '/' || parsed.pathname === '/admin') {
+ parsed.pathname = '/admin/accounts';
+ }
+ parsed.hash = '';
+ return parsed.toString();
+ }
+
function getPanelMode(state = {}) {
- return state.panelMode === 'sub2api' ? 'sub2api' : 'cpa';
+ if (state.panelMode === 'sub2api') {
+ return 'sub2api';
+ }
+ if (state.panelMode === 'codex2api') {
+ return 'codex2api';
+ }
+ return 'cpa';
}
function getPanelModeLabel(modeOrState) {
const mode = typeof modeOrState === 'string' ? modeOrState : getPanelMode(modeOrState);
- return mode === 'sub2api' ? 'SUB2API' : 'CPA';
+ if (mode === 'sub2api') {
+ return 'SUB2API';
+ }
+ if (mode === 'codex2api') {
+ return 'Codex2API';
+ }
+ return 'CPA';
}
function isSignupPageHost(hostname = '') {
@@ -124,6 +148,14 @@
|| candidate.pathname.startsWith('/login')
|| candidate.pathname === '/'
);
+ case 'codex2api-panel':
+ return Boolean(reference)
+ && candidate.origin === reference.origin
+ && (
+ candidate.pathname.startsWith('/admin/accounts')
+ || candidate.pathname === '/admin'
+ || candidate.pathname === '/'
+ );
default:
return false;
}
@@ -160,6 +192,7 @@
isSignupPageHost,
isSignupPasswordPageUrl,
matchesSourceUrlFamily,
+ normalizeCodex2ApiUrl,
normalizeSub2ApiUrl,
parseUrlSafely,
shouldBypassStep9ForLocalCpa,
diff --git a/background/panel-bridge.js b/background/panel-bridge.js
index ad97e6c..a667960 100644
--- a/background/panel-bridge.js
+++ b/background/panel-bridge.js
@@ -8,6 +8,7 @@
closeConflictingTabsForSource,
ensureContentScriptReadyOnTab,
getPanelMode,
+ normalizeCodex2ApiUrl,
normalizeSub2ApiUrl,
rememberSourceLastUrl,
sendToContentScript,
@@ -17,7 +18,74 @@
SUB2API_STEP1_RESPONSE_TIMEOUT_MS,
} = deps;
+ function normalizeAdminKey(value = '') {
+ return String(value || '').trim();
+ }
+
+ function extractStateFromAuthUrl(authUrl = '') {
+ try {
+ return new URL(authUrl).searchParams.get('state') || '';
+ } catch {
+ return '';
+ }
+ }
+
+ function getCodex2ApiErrorMessage(payload, responseStatus = 500) {
+ const candidates = [
+ payload?.error,
+ payload?.message,
+ payload?.detail,
+ payload?.reason,
+ ];
+ const message = candidates
+ .map((value) => String(value || '').trim())
+ .find(Boolean);
+ return message || `Codex2API 请求失败(HTTP ${responseStatus})。`;
+ }
+
+ async function fetchCodex2ApiJson(origin, path, options = {}) {
+ const timeoutMs = Math.max(1000, Math.floor(Number(options.timeoutMs) || 30000));
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
+
+ try {
+ const response = await fetch(`${origin}${path}`, {
+ method: options.method || 'POST',
+ headers: {
+ Accept: 'application/json',
+ 'Content-Type': 'application/json',
+ 'X-Admin-Key': normalizeAdminKey(options.adminKey),
+ },
+ body: options.body === undefined ? undefined : JSON.stringify(options.body),
+ signal: controller.signal,
+ });
+
+ let payload = {};
+ try {
+ payload = await response.json();
+ } catch {
+ payload = {};
+ }
+
+ if (!response.ok) {
+ throw new Error(getCodex2ApiErrorMessage(payload, response.status));
+ }
+
+ return payload;
+ } catch (error) {
+ if (error?.name === 'AbortError') {
+ throw new Error('Codex2API 请求超时,请稍后重试。');
+ }
+ throw error;
+ } finally {
+ clearTimeout(timer);
+ }
+ }
+
async function requestOAuthUrlFromPanel(state, options = {}) {
+ if (getPanelMode(state) === 'codex2api') {
+ return requestCodex2ApiOAuthUrl(state, options);
+ }
if (getPanelMode(state) === 'sub2api') {
return requestSub2ApiOAuthUrl(state, options);
}
@@ -74,6 +142,39 @@
return result || {};
}
+ async function requestCodex2ApiOAuthUrl(state, options = {}) {
+ const { logLabel = 'OAuth 刷新' } = options;
+ const codex2apiUrl = normalizeCodex2ApiUrl(state.codex2apiUrl);
+ const adminKey = normalizeAdminKey(state.codex2apiAdminKey);
+
+ if (!adminKey) {
+ throw new Error('尚未配置 Codex2API 管理密钥,请先在侧边栏填写。');
+ }
+
+ const origin = new URL(codex2apiUrl).origin;
+ await addLog(`${logLabel}:正在通过 Codex2API 协议生成 OAuth 授权链接...`);
+
+ const result = await fetchCodex2ApiJson(origin, '/api/admin/oauth/generate-auth-url', {
+ adminKey,
+ method: 'POST',
+ body: {},
+ });
+
+ const oauthUrl = String(result?.auth_url || result?.authUrl || '').trim();
+ const sessionId = String(result?.session_id || result?.sessionId || '').trim();
+ const oauthState = extractStateFromAuthUrl(oauthUrl);
+
+ if (!oauthUrl || !sessionId) {
+ throw new Error('Codex2API 未返回有效的 auth_url 或 session_id。');
+ }
+
+ return {
+ oauthUrl,
+ codex2apiSessionId: sessionId,
+ codex2apiOAuthState: oauthState || null,
+ };
+ }
+
async function requestSub2ApiOAuthUrl(state, options = {}) {
const { logLabel = 'OAuth 刷新' } = options;
const sub2apiUrl = normalizeSub2ApiUrl(state.sub2apiUrl);
@@ -135,6 +236,7 @@
return {
requestOAuthUrlFromPanel,
+ requestCodex2ApiOAuthUrl,
requestCpaOAuthUrl,
requestSub2ApiOAuthUrl,
};
diff --git a/background/steps/oauth-login.js b/background/steps/oauth-login.js
index 596ac9f..0d1c9e8 100644
--- a/background/steps/oauth-login.js
+++ b/background/steps/oauth-login.js
@@ -23,6 +23,20 @@
throwIfStopped,
} = deps;
+ function isManagementSecretConfigError(error) {
+ const message = String(typeof error === 'string' ? error : error?.message || '').trim();
+ if (!message) {
+ return false;
+ }
+
+ const mentionsSecret = /管理密钥|Admin Secret|X-Admin-Key|CPA Key/i.test(message);
+ if (!mentionsSecret) {
+ return false;
+ }
+
+ return /缺少|未配置|请输入|无效|错误|失败|401|认证失败|未授权|unauthorized|invalid/i.test(message);
+ }
+
async function executeStep7(state) {
if (!state.email) {
throw new Error('缺少邮箱地址,请先完成步骤 3。');
@@ -99,6 +113,13 @@
if (isAddPhoneAuthFailure(err)) {
throw err;
}
+ if (isManagementSecretConfigError(err)) {
+ await addLog(
+ `步骤 7:检测到来源后台管理密钥缺失或错误,不再重试,当前流程停止。原因:${getErrorMessage(err)}`,
+ 'error'
+ );
+ throw err;
+ }
lastError = err;
if (attempt >= STEP6_MAX_ATTEMPTS) {
break;
diff --git a/background/steps/platform-verify.js b/background/steps/platform-verify.js
index 7edfe5d..72fa9f5 100644
--- a/background/steps/platform-verify.js
+++ b/background/steps/platform-verify.js
@@ -12,6 +12,7 @@
getTabId,
isLocalhostOAuthCallbackUrl,
isTabAlive,
+ normalizeCodex2ApiUrl,
normalizeSub2ApiUrl,
rememberSourceLastUrl,
reuseOrCreateTab,
@@ -21,7 +22,86 @@
SUB2API_STEP9_RESPONSE_TIMEOUT_MS,
} = deps;
+ function normalizeString(value = '') {
+ return String(value || '').trim();
+ }
+
+ function parseLocalhostCallback(rawUrl) {
+ let parsed;
+ try {
+ parsed = new URL(rawUrl);
+ } catch {
+ throw new Error('步骤 10 捕获到的 localhost OAuth 回调地址格式无效,请重新执行步骤 9。');
+ }
+
+ const code = normalizeString(parsed.searchParams.get('code'));
+ const state = normalizeString(parsed.searchParams.get('state'));
+ if (!code || !state) {
+ throw new Error('步骤 10 捕获到的 localhost OAuth 回调地址缺少 code 或 state,请重新执行步骤 9。');
+ }
+
+ return {
+ url: parsed.toString(),
+ code,
+ state,
+ };
+ }
+
+ function getCodex2ApiErrorMessage(payload, responseStatus = 500) {
+ const details = [
+ payload?.error,
+ payload?.message,
+ payload?.detail,
+ payload?.reason,
+ ]
+ .map((value) => normalizeString(value))
+ .find(Boolean);
+ return details || `Codex2API 请求失败(HTTP ${responseStatus})。`;
+ }
+
+ async function fetchCodex2ApiJson(origin, path, options = {}) {
+ const controller = new AbortController();
+ const timeoutMs = Math.max(1000, Math.floor(Number(options.timeoutMs) || 30000));
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
+
+ try {
+ const response = await fetch(`${origin}${path}`, {
+ method: options.method || 'POST',
+ headers: {
+ Accept: 'application/json',
+ 'Content-Type': 'application/json',
+ 'X-Admin-Key': normalizeString(options.adminKey),
+ },
+ body: options.body === undefined ? undefined : JSON.stringify(options.body),
+ signal: controller.signal,
+ });
+
+ let payload = {};
+ try {
+ payload = await response.json();
+ } catch {
+ payload = {};
+ }
+
+ if (!response.ok) {
+ throw new Error(getCodex2ApiErrorMessage(payload, response.status));
+ }
+
+ return payload;
+ } catch (error) {
+ if (error?.name === 'AbortError') {
+ throw new Error('Codex2API 请求超时,请稍后重试。');
+ }
+ throw error;
+ } finally {
+ clearTimeout(timer);
+ }
+ }
+
async function executeStep10(state) {
+ if (getPanelMode(state) === 'codex2api') {
+ return executeCodex2ApiStep10(state);
+ }
if (getPanelMode(state) === 'sub2api') {
return executeSub2ApiStep10(state);
}
@@ -89,6 +169,48 @@
}
}
+ async function executeCodex2ApiStep10(state) {
+ if (state.localhostUrl && !isLocalhostOAuthCallbackUrl(state.localhostUrl)) {
+ throw new Error('步骤 9 捕获到的 localhost OAuth 回调地址无效,请重新执行步骤 9。');
+ }
+ if (!state.localhostUrl) {
+ throw new Error('缺少 localhost 回调地址,请先完成步骤 9。');
+ }
+ if (!state.codex2apiSessionId) {
+ throw new Error('缺少 Codex2API 会话信息,请重新执行步骤 7。');
+ }
+ if (!normalizeString(state.codex2apiAdminKey)) {
+ throw new Error('尚未配置 Codex2API 管理密钥,请先在侧边栏填写。');
+ }
+
+ const callback = parseLocalhostCallback(state.localhostUrl);
+ const expectedState = normalizeString(state.codex2apiOAuthState);
+ if (expectedState && expectedState !== callback.state) {
+ throw new Error('Codex2API 回调 state 与当前授权会话不匹配,请重新执行步骤 7。');
+ }
+
+ const codex2apiUrl = normalizeCodex2ApiUrl(state.codex2apiUrl);
+ const origin = new URL(codex2apiUrl).origin;
+
+ await addLog('步骤 10:正在向 Codex2API 提交回调并创建账号...');
+ const result = await fetchCodex2ApiJson(origin, '/api/admin/oauth/exchange-code', {
+ adminKey: state.codex2apiAdminKey,
+ method: 'POST',
+ body: {
+ session_id: state.codex2apiSessionId,
+ code: callback.code,
+ state: callback.state,
+ },
+ });
+
+ const verifiedStatus = normalizeString(result?.message) || 'Codex2API OAuth 账号添加成功';
+ await addLog(`步骤 10:${verifiedStatus}`, 'ok');
+ await completeStepFromBackground(10, {
+ localhostUrl: callback.url,
+ verifiedStatus,
+ });
+ }
+
async function executeSub2ApiStep10(state) {
if (state.localhostUrl && !isLocalhostOAuthCallbackUrl(state.localhostUrl)) {
throw new Error('步骤 9 捕获到的 localhost OAuth 回调地址无效,请重新执行步骤 9。');
@@ -160,6 +282,7 @@
return {
executeCpaStep10,
+ executeCodex2ApiStep10,
executeStep10,
executeSub2ApiStep10,
};
diff --git a/sidepanel/contribution-mode.js b/sidepanel/contribution-mode.js
index 50aedf7..c54081c 100644
--- a/sidepanel/contribution-mode.js
+++ b/sidepanel/contribution-mode.js
@@ -24,6 +24,8 @@
dom.rowSub2ApiPassword,
dom.rowSub2ApiGroup,
dom.rowSub2ApiDefaultProxy,
+ dom.rowCodex2ApiUrl,
+ dom.rowCodex2ApiAdminKey,
dom.rowCustomPassword,
dom.rowAccountRunHistoryHelperBaseUrl,
].filter(Boolean);
diff --git a/sidepanel/sidepanel.css b/sidepanel/sidepanel.css
index f40f857..b5002c9 100644
--- a/sidepanel/sidepanel.css
+++ b/sidepanel/sidepanel.css
@@ -806,6 +806,17 @@ header {
flex-shrink: 0;
}
+#row-codex2api-url .data-label,
+#row-codex2api-admin-key .data-label {
+ width: 76px;
+}
+
+#row-codex2api-url .data-label {
+ font-size: 10px;
+ letter-spacing: 0.02em;
+ text-transform: none;
+}
+
.data-value {
font-size: 13px;
color: var(--text-muted);
diff --git a/sidepanel/sidepanel.html b/sidepanel/sidepanel.html
index 6b75d35..dd1e052 100644
--- a/sidepanel/sidepanel.html
+++ b/sidepanel/sidepanel.html
@@ -112,6 +112,7 @@
@@ -192,6 +193,16 @@
+
+ Codex2API
+
+
+
+ 管理密钥
+
+
账户密码
diff --git a/sidepanel/sidepanel.js b/sidepanel/sidepanel.js
index 37a6d3e..34e6870 100644
--- a/sidepanel/sidepanel.js
+++ b/sidepanel/sidepanel.js
@@ -94,6 +94,10 @@ const rowSub2ApiGroup = document.getElementById('row-sub2api-group');
const inputSub2ApiGroup = document.getElementById('input-sub2api-group');
const rowSub2ApiDefaultProxy = document.getElementById('row-sub2api-default-proxy');
const inputSub2ApiDefaultProxy = document.getElementById('input-sub2api-default-proxy');
+const rowCodex2ApiUrl = document.getElementById('row-codex2api-url');
+const inputCodex2ApiUrl = document.getElementById('input-codex2api-url');
+const rowCodex2ApiAdminKey = document.getElementById('row-codex2api-admin-key');
+const inputCodex2ApiAdminKey = document.getElementById('input-codex2api-admin-key');
const rowCustomPassword = document.getElementById('row-custom-password');
const selectMailProvider = document.getElementById('select-mail-provider');
const btnMailLogin = document.getElementById('btn-mail-login');
@@ -1479,6 +1483,8 @@ function collectSettingsPayload() {
sub2apiPassword: inputSub2ApiPassword.value,
sub2apiGroupName: inputSub2ApiGroup.value.trim(),
sub2apiDefaultProxyName: inputSub2ApiDefaultProxy.value.trim(),
+ codex2apiUrl: inputCodex2ApiUrl.value.trim(),
+ codex2apiAdminKey: inputCodex2ApiAdminKey.value.trim(),
...(contributionModeEnabled ? {} : {
customPassword: inputPassword.value,
}),
@@ -1858,6 +1864,8 @@ function applySettingsState(state) {
inputSub2ApiPassword.value = state?.sub2apiPassword || '';
inputSub2ApiGroup.value = state?.sub2apiGroupName || '';
inputSub2ApiDefaultProxy.value = state?.sub2apiDefaultProxyName || '';
+ inputCodex2ApiUrl.value = state?.codex2apiUrl || '';
+ inputCodex2ApiAdminKey.value = state?.codex2apiAdminKey || '';
const restoredMailProvider = isCustomMailProvider(state?.mailProvider)
|| [ICLOUD_PROVIDER, 'hotmail-api', GMAIL_PROVIDER, 'luckmail-api', '163', '163-vip', 'qq', 'inbucket', '2925', 'cloudflare-temp-email'].includes(String(state?.mailProvider || '').trim())
? String(state?.mailProvider || '163').trim()
@@ -2864,18 +2872,24 @@ async function saveCloudflareTempEmailDomainSettings(domains, activeDomain, opti
function updatePanelModeUI() {
const useSub2Api = selectPanelMode.value === 'sub2api';
- rowVpsUrl.style.display = useSub2Api ? 'none' : '';
- rowVpsPassword.style.display = useSub2Api ? 'none' : '';
- rowLocalCpaStep9Mode.style.display = useSub2Api ? 'none' : '';
+ const useCodex2Api = selectPanelMode.value === 'codex2api';
+ const useCpa = !useSub2Api && !useCodex2Api;
+ rowVpsUrl.style.display = useCpa ? '' : 'none';
+ rowVpsPassword.style.display = useCpa ? '' : 'none';
+ rowLocalCpaStep9Mode.style.display = useCpa ? '' : 'none';
rowSub2ApiUrl.style.display = useSub2Api ? '' : 'none';
rowSub2ApiEmail.style.display = useSub2Api ? '' : 'none';
rowSub2ApiPassword.style.display = useSub2Api ? '' : 'none';
rowSub2ApiGroup.style.display = useSub2Api ? '' : 'none';
rowSub2ApiDefaultProxy.style.display = useSub2Api ? '' : 'none';
+ rowCodex2ApiUrl.style.display = useCodex2Api ? '' : 'none';
+ rowCodex2ApiAdminKey.style.display = useCodex2Api ? '' : 'none';
const step9Btn = document.querySelector('.step-btn[data-step-key="platform-verify"]');
if (step9Btn) {
- step9Btn.textContent = useSub2Api ? 'SUB2API 回调验证' : 'CPA 回调验证';
+ step9Btn.textContent = useSub2Api
+ ? 'SUB2API 回调验证'
+ : (useCodex2Api ? 'Codex2API 回调验证' : 'CPA 回调验证');
}
}
@@ -4270,6 +4284,22 @@ inputSub2ApiDefaultProxy.addEventListener('blur', () => {
saveSettings({ silent: true }).catch(() => { });
});
+inputCodex2ApiUrl.addEventListener('input', () => {
+ markSettingsDirty(true);
+ scheduleSettingsAutoSave();
+});
+inputCodex2ApiUrl.addEventListener('blur', () => {
+ saveSettings({ silent: true }).catch(() => { });
+});
+
+inputCodex2ApiAdminKey.addEventListener('input', () => {
+ markSettingsDirty(true);
+ scheduleSettingsAutoSave();
+});
+inputCodex2ApiAdminKey.addEventListener('blur', () => {
+ saveSettings({ silent: true }).catch(() => { });
+});
+
inputEmailPrefix.addEventListener('input', () => {
maybeClearGeneratedAliasAfterEmailPrefixChange().catch(() => { });
syncManagedAliasBaseEmailDraftFromInput();
diff --git a/tests/background-account-history-settings.test.js b/tests/background-account-history-settings.test.js
index ef54eda..5a2ebe8 100644
--- a/tests/background-account-history-settings.test.js
+++ b/tests/background-account-history-settings.test.js
@@ -50,6 +50,7 @@ function extractFunction(name) {
test('background account history settings are normalized independently from hotmail service mode', () => {
const bundle = [
+ extractFunction('normalizeCodex2ApiUrl'),
extractFunction('normalizeHotmailLocalBaseUrl'),
extractFunction('normalizeAccountRunHistoryHelperBaseUrl'),
extractFunction('normalizeVerificationResendCount'),
@@ -60,6 +61,7 @@ test('background account history settings are normalized independently from hotm
const DEFAULT_HOTMAIL_LOCAL_BASE_URL = 'http://127.0.0.1:17373';
const DEFAULT_ACCOUNT_RUN_HISTORY_HELPER_BASE_URL = DEFAULT_HOTMAIL_LOCAL_BASE_URL;
const DEFAULT_HOTMAIL_REMOTE_BASE_URL = '';
+const DEFAULT_CODEX2API_URL = 'http://localhost:8080/admin/accounts';
const DEFAULT_VERIFICATION_RESEND_COUNT = 4;
const DEFAULT_SUB2API_PROXY_NAME = '';
const HOTMAIL_SERVICE_MODE_REMOTE = 'remote';
@@ -70,7 +72,7 @@ const PERSISTED_SETTING_DEFAULTS = {
autoStepDelaySeconds: null,
mailProvider: '163',
};
-function normalizePanelMode(value) { return value === 'sub2api' ? 'sub2api' : 'cpa'; }
+function normalizePanelMode(value) { return value === 'sub2api' ? 'sub2api' : (value === 'codex2api' ? 'codex2api' : 'cpa'); }
function normalizeLocalCpaStep9Mode(value) { return value === 'bypass' ? 'bypass' : 'submit'; }
function normalizeAutoRunFallbackThreadIntervalMinutes(value) { return Number(value) || 0; }
function normalizeAutoRunDelayMinutes(value) { return Number(value) || 30; }
@@ -118,4 +120,16 @@ return {
api.normalizePersistentSettingValue('sub2apiDefaultProxyName', ' proxy-a '),
'proxy-a'
);
+ assert.equal(
+ api.normalizePersistentSettingValue('codex2apiUrl', 'localhost:8080/admin'),
+ 'http://localhost:8080/admin/accounts'
+ );
+ assert.equal(
+ api.normalizePersistentSettingValue('codex2apiUrl', 'https://codex-admin.example.com/'),
+ 'https://codex-admin.example.com/admin/accounts'
+ );
+ assert.equal(
+ api.normalizePersistentSettingValue('codex2apiAdminKey', ' secret-key '),
+ 'secret-key'
+ );
});
diff --git a/tests/background-contribution-mode.test.js b/tests/background-contribution-mode.test.js
index b3d6901..0d5682c 100644
--- a/tests/background-contribution-mode.test.js
+++ b/tests/background-contribution-mode.test.js
@@ -536,7 +536,7 @@ return { refreshOAuthUrlBeforeStep6 };
delete globalThis.LOG_PREFIX;
});
-test('refreshOAuthUrlBeforeStep6 logs the normal CPA/SUB2API path explicitly when contributionMode=false', async () => {
+test('refreshOAuthUrlBeforeStep6 logs the normal CPA/SUB2API/Codex2API path explicitly when contributionMode=false', async () => {
const bundle = extractFunction(backgroundSource, 'refreshOAuthUrlBeforeStep6');
const calls = [];
@@ -574,7 +574,7 @@ return { refreshOAuthUrlBeforeStep6 };
assert.equal(oauthUrl, 'https://panel.example.com/oauth');
assert.deepStrictEqual(calls, [
- { type: 'log', message: '步骤 7:contributionMode=false,走普通 CPA / SUB2API 链路(当前面板:SUB2API),正在刷新 OAuth 登录地址...' },
+ { type: 'log', message: '步骤 7:contributionMode=false,走普通 CPA / SUB2API / Codex2API 链路(当前面板:SUB2API),正在刷新 OAuth 登录地址...' },
{ type: 'panel' },
{
type: 'step',
diff --git a/tests/background-navigation-utils-module.test.js b/tests/background-navigation-utils-module.test.js
index cf03bf0..a501ac4 100644
--- a/tests/background-navigation-utils-module.test.js
+++ b/tests/background-navigation-utils-module.test.js
@@ -15,3 +15,23 @@ test('navigation utils module exposes a factory', () => {
assert.equal(typeof api?.createNavigationUtils, 'function');
});
+
+test('navigation utils support codex2api mode and url normalization', () => {
+ const source = fs.readFileSync('background/navigation-utils.js', 'utf8');
+ const globalScope = {};
+
+ const api = new Function('self', `${source}; return self.MultiPageBackgroundNavigationUtils;`)(globalScope);
+ const utils = api.createNavigationUtils({
+ DEFAULT_CODEX2API_URL: 'http://localhost:8080/admin/accounts',
+ DEFAULT_SUB2API_URL: 'https://sub.example.com/admin/accounts',
+ normalizeLocalCpaStep9Mode: (value) => value,
+ });
+
+ assert.equal(utils.normalizeCodex2ApiUrl('localhost:8080/admin'), 'http://localhost:8080/admin/accounts');
+ assert.equal(
+ utils.normalizeCodex2ApiUrl('https://codex-admin.example.com/'),
+ 'https://codex-admin.example.com/admin/accounts'
+ );
+ assert.equal(utils.getPanelMode({ panelMode: 'codex2api' }), 'codex2api');
+ assert.equal(utils.getPanelModeLabel('codex2api'), 'Codex2API');
+});
diff --git a/tests/background-panel-bridge-module.test.js b/tests/background-panel-bridge-module.test.js
index 8473571..353c990 100644
--- a/tests/background-panel-bridge-module.test.js
+++ b/tests/background-panel-bridge-module.test.js
@@ -21,3 +21,53 @@ test('panel bridge requests oauth url with step 7 log label payload', () => {
assert.match(source, /logStep:\s*7/);
assert.doesNotMatch(source, /logStep:\s*6/);
});
+
+test('panel bridge can request codex2api oauth url via protocol', async () => {
+ const source = fs.readFileSync('background/panel-bridge.js', 'utf8');
+ const originalFetch = globalThis.fetch;
+ globalThis.fetch = async (url, options = {}) => {
+ assert.equal(url, 'http://localhost:8080/api/admin/oauth/generate-auth-url');
+ assert.equal(options.method, 'POST');
+ assert.equal(options.headers['X-Admin-Key'], 'admin-secret');
+ return {
+ ok: true,
+ json: async () => ({
+ auth_url: 'https://auth.openai.com/authorize?state=oauth-state',
+ session_id: 'session-123',
+ }),
+ };
+ };
+
+ try {
+ const api = new Function('self', `${source}; return self.MultiPageBackgroundPanelBridge;`)({});
+ const bridge = api.createPanelBridge({
+ addLog: async () => {},
+ chrome: {},
+ closeConflictingTabsForSource: async () => {},
+ ensureContentScriptReadyOnTab: async () => {},
+ getPanelMode: () => 'codex2api',
+ normalizeCodex2ApiUrl: (value) => value ? `http://${value.replace(/^https?:\/\//, '')}`.replace(/\/admin$/, '/admin/accounts') : 'http://localhost:8080/admin/accounts',
+ normalizeSub2ApiUrl: (value) => value,
+ rememberSourceLastUrl: async () => {},
+ sendToContentScript: async () => ({}),
+ sendToContentScriptResilient: async () => ({}),
+ waitForTabUrlFamily: async () => null,
+ DEFAULT_SUB2API_GROUP_NAME: 'codex',
+ SUB2API_STEP1_RESPONSE_TIMEOUT_MS: 90000,
+ });
+
+ const result = await bridge.requestOAuthUrlFromPanel({
+ panelMode: 'codex2api',
+ codex2apiUrl: 'localhost:8080/admin',
+ codex2apiAdminKey: 'admin-secret',
+ }, { logLabel: '步骤 7' });
+
+ assert.deepStrictEqual(result, {
+ oauthUrl: 'https://auth.openai.com/authorize?state=oauth-state',
+ codex2apiSessionId: 'session-123',
+ codex2apiOAuthState: 'oauth-state',
+ });
+ } finally {
+ globalThis.fetch = originalFetch;
+ }
+});
diff --git a/tests/background-platform-verify-codex2api.test.js b/tests/background-platform-verify-codex2api.test.js
new file mode 100644
index 0000000..44c8bcc
--- /dev/null
+++ b/tests/background-platform-verify-codex2api.test.js
@@ -0,0 +1,81 @@
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const test = require('node:test');
+
+test('platform verify module supports codex2api protocol callback exchange', async () => {
+ const source = fs.readFileSync('background/steps/platform-verify.js', 'utf8');
+ const originalFetch = globalThis.fetch;
+ globalThis.fetch = async (url, options = {}) => {
+ assert.equal(url, 'http://localhost:8080/api/admin/oauth/exchange-code');
+ assert.equal(options.method, 'POST');
+ assert.equal(options.headers['X-Admin-Key'], 'admin-secret');
+ assert.deepStrictEqual(JSON.parse(options.body), {
+ session_id: 'session-123',
+ code: 'callback-code',
+ state: 'oauth-state',
+ });
+ return {
+ ok: true,
+ json: async () => ({
+ message: 'OAuth 账号 flow@example.com 添加成功',
+ id: 42,
+ email: 'flow@example.com',
+ plan_type: 'pro',
+ }),
+ };
+ };
+
+ try {
+ const api = new Function('self', `${source}; return self.MultiPageBackgroundStep10;`)({});
+ const completed = [];
+ const logs = [];
+ const executor = api.createStep10Executor({
+ addLog: async (message, level = 'info') => {
+ logs.push({ message, level });
+ },
+ chrome: {},
+ closeConflictingTabsForSource: async () => {},
+ completeStepFromBackground: async (step, payload) => {
+ completed.push({ step, payload });
+ },
+ ensureContentScriptReadyOnTab: async () => {},
+ getPanelMode: () => 'codex2api',
+ getTabId: async () => 0,
+ isLocalhostOAuthCallbackUrl: (value) => String(value || '').includes('/auth/callback?code='),
+ isTabAlive: async () => false,
+ normalizeCodex2ApiUrl: () => 'http://localhost:8080/admin/accounts',
+ normalizeSub2ApiUrl: (value) => value,
+ rememberSourceLastUrl: async () => {},
+ reuseOrCreateTab: async () => 0,
+ sendToContentScript: async () => ({}),
+ sendToContentScriptResilient: async () => ({}),
+ shouldBypassStep9ForLocalCpa: () => false,
+ SUB2API_STEP9_RESPONSE_TIMEOUT_MS: 120000,
+ });
+
+ await executor.executeStep10({
+ panelMode: 'codex2api',
+ localhostUrl: 'http://localhost:1455/auth/callback?code=callback-code&state=oauth-state',
+ codex2apiUrl: 'http://localhost:8080/admin/accounts',
+ codex2apiAdminKey: 'admin-secret',
+ codex2apiSessionId: 'session-123',
+ codex2apiOAuthState: 'oauth-state',
+ });
+
+ assert.deepStrictEqual(logs, [
+ { message: '步骤 10:正在向 Codex2API 提交回调并创建账号...', level: 'info' },
+ { message: '步骤 10:OAuth 账号 flow@example.com 添加成功', level: 'ok' },
+ ]);
+ assert.deepStrictEqual(completed, [
+ {
+ step: 10,
+ payload: {
+ localhostUrl: 'http://localhost:1455/auth/callback?code=callback-code&state=oauth-state',
+ verifiedStatus: 'OAuth 账号 flow@example.com 添加成功',
+ },
+ },
+ ]);
+ } finally {
+ globalThis.fetch = originalFetch;
+ }
+});
diff --git a/tests/background-step6-retry-limit.test.js b/tests/background-step6-retry-limit.test.js
index af1783c..091a579 100644
--- a/tests/background-step6-retry-limit.test.js
+++ b/tests/background-step6-retry-limit.test.js
@@ -177,3 +177,88 @@ test('step 7 starts a new oauth timeout window for each refreshed oauth url', as
},
]);
});
+
+test('step 7 stops immediately when management secret is missing', async () => {
+ const source = fs.readFileSync('background/steps/oauth-login.js', 'utf8');
+ const globalScope = {};
+ const api = new Function('self', `${source}; return self.MultiPageBackgroundStep7;`)(globalScope);
+
+ const events = {
+ refreshCalls: 0,
+ sendCalls: 0,
+ logs: [],
+ };
+
+ const executor = api.createStep7Executor({
+ addLog: async (message, level = 'info') => {
+ events.logs.push({ message, level });
+ },
+ completeStepFromBackground: async () => {},
+ getErrorMessage: (error) => error?.message || String(error || ''),
+ getLoginAuthStateLabel: (state) => state || 'unknown',
+ getState: async () => ({ email: 'user@example.com', password: 'secret' }),
+ isStep6RecoverableResult: (result) => result?.step6Outcome === 'recoverable',
+ isStep6SuccessResult: (result) => result?.step6Outcome === 'success',
+ refreshOAuthUrlBeforeStep6: async () => {
+ events.refreshCalls += 1;
+ throw new Error('尚未配置 Codex2API 管理密钥,请先在侧边栏填写。');
+ },
+ reuseOrCreateTab: async () => {},
+ sendToContentScriptResilient: async () => {
+ events.sendCalls += 1;
+ return { step6Outcome: 'success' };
+ },
+ STEP6_MAX_ATTEMPTS: 3,
+ throwIfStopped: () => {},
+ });
+
+ await assert.rejects(
+ () => executor.executeStep7({ email: 'user@example.com', password: 'secret' }),
+ /管理密钥/
+ );
+
+ assert.equal(events.refreshCalls, 1);
+ assert.equal(events.sendCalls, 0);
+ assert.ok(events.logs.some(({ message }) => /管理密钥缺失或错误,不再重试,当前流程停止/.test(message)));
+ assert.ok(!events.logs.some(({ message }) => /准备重试/.test(message)));
+});
+
+test('step 7 stops immediately when management secret is invalid', async () => {
+ const source = fs.readFileSync('background/steps/oauth-login.js', 'utf8');
+ const globalScope = {};
+ const api = new Function('self', `${source}; return self.MultiPageBackgroundStep7;`)(globalScope);
+
+ const events = {
+ refreshCalls: 0,
+ logs: [],
+ };
+
+ const executor = api.createStep7Executor({
+ addLog: async (message, level = 'info') => {
+ events.logs.push({ message, level });
+ },
+ completeStepFromBackground: async () => {},
+ getErrorMessage: (error) => error?.message || String(error || ''),
+ getLoginAuthStateLabel: (state) => state || 'unknown',
+ getState: async () => ({ email: 'user@example.com', password: 'secret' }),
+ isStep6RecoverableResult: (result) => result?.step6Outcome === 'recoverable',
+ isStep6SuccessResult: (result) => result?.step6Outcome === 'success',
+ refreshOAuthUrlBeforeStep6: async () => {
+ events.refreshCalls += 1;
+ throw new Error('Codex2API 请求失败(HTTP 401)。X-Admin-Key 无效或未授权。');
+ },
+ reuseOrCreateTab: async () => {},
+ sendToContentScriptResilient: async () => ({ step6Outcome: 'success' }),
+ STEP6_MAX_ATTEMPTS: 3,
+ throwIfStopped: () => {},
+ });
+
+ await assert.rejects(
+ () => executor.executeStep7({ email: 'user@example.com', password: 'secret' }),
+ /401|未授权|无效/
+ );
+
+ assert.equal(events.refreshCalls, 1);
+ assert.ok(events.logs.some(({ message }) => /管理密钥缺失或错误,不再重试,当前流程停止/.test(message)));
+ assert.ok(!events.logs.some(({ message }) => /准备重试/.test(message)));
+});
diff --git a/tests/sidepanel-contribution-mode.test.js b/tests/sidepanel-contribution-mode.test.js
index fca11f2..decc8cb 100644
--- a/tests/sidepanel-contribution-mode.test.js
+++ b/tests/sidepanel-contribution-mode.test.js
@@ -135,6 +135,8 @@ const inputSub2ApiEmail = { value: 'user@example.com' };
const inputSub2ApiPassword = { value: 'sub-secret' };
const inputSub2ApiGroup = { value: ' codex ' };
const inputSub2ApiDefaultProxy = { value: ' proxy-a ' };
+const inputCodex2ApiUrl = { value: 'http://localhost:8080/admin/accounts' };
+const inputCodex2ApiAdminKey = { value: 'codex-admin-secret' };
const inputPassword = { value: 'Secret123!' };
const selectMailProvider = { value: '163' };
const selectEmailGenerator = { value: 'duck' };
@@ -196,6 +198,8 @@ return {
assert.equal(normalPayload.customPassword, 'Secret123!');
assert.equal(normalPayload.accountRunHistoryTextEnabled, true);
assert.equal(normalPayload.accountRunHistoryHelperBaseUrl, 'http://127.0.0.1:17373');
+ assert.equal(normalPayload.codex2apiUrl, 'http://localhost:8080/admin/accounts');
+ assert.equal(normalPayload.codex2apiAdminKey, 'codex-admin-secret');
});
test('contribution mode manager enters mode, starts main auto flow, polls contribution status, and exits cleanly', async () => {
@@ -264,6 +268,8 @@ test('contribution mode manager enters mode, starts main auto flow, polls contri
rowSub2ApiGroup: createElement(),
rowSub2ApiPassword: createElement(),
rowSub2ApiUrl: createElement(),
+ rowCodex2ApiUrl: createElement(),
+ rowCodex2ApiAdminKey: createElement(),
rowVpsPassword: createElement(),
rowVpsUrl: createElement(),
selectPanelMode: createElement({ value: 'sub2api' }),
@@ -414,6 +420,8 @@ test('contribution mode manager enters mode, starts main auto flow, polls contri
assert.equal(dom.contributionModeSummary.textContent.length > 0, true);
assert.equal(dom.btnContributionMode.classList.contains('is-active'), true);
assert.equal(dom.rowVpsUrl.classList.contains('is-contribution-hidden'), true);
+ assert.equal(dom.rowCodex2ApiUrl.classList.contains('is-contribution-hidden'), true);
+ assert.equal(dom.rowCodex2ApiAdminKey.classList.contains('is-contribution-hidden'), true);
assert.ok(closeConfigMenuCount >= 1);
assert.ok(closeAccountRecordsCount >= 1);
assert.ok(updatePanelModeCount >= 1);
diff --git a/项目完整链路说明.md b/项目完整链路说明.md
index 7f4f5c6..fd759a6 100644
--- a/项目完整链路说明.md
+++ b/项目完整链路说明.md
@@ -22,7 +22,7 @@
- 刷新 OAuth 链接并登录
- 轮询登录验证码
- 自动确认 OAuth 同意页
-- 把 localhost 回调提交到 CPA 或 SUB2API
+- 把 localhost 回调提交到 CPA、SUB2API 或 Codex2API
## 2. 核心运行参与者
@@ -152,6 +152,7 @@
保存持久配置与账号运行历史:
- CPA / SUB2API 配置
+- Codex2API 配置
- 邮箱 provider 配置
- Hotmail 账号池
- 2925 账号池
@@ -166,7 +167,7 @@
注意:
- `contributionMode` 不属于持久配置,也不参与导入/导出;它只存在于运行态
-- `panelMode` 仍然只表示 `cpa | sub2api` 来源,不能把贡献模式实现成新的来源枚举
+- `panelMode` 当前表示 `cpa | sub2api | codex2api` 三个来源;贡献模式仍不是新的来源枚举
当启用了独立的账号运行历史本地同步配置时,账号运行历史会通过 [scripts/hotmail_helper.py](c:/Users/projectf/Downloads/codex注册扩展/scripts/hotmail_helper.py) 整体同步写入 `data/account-run-history.json` 快照文件,便于开发者直接查看完整记录。
这条配置链路独立于 `mailProvider` 和 Hotmail 的接码模式。
@@ -189,10 +190,10 @@
1. 用户点击顶部 `贡献`
2. sidepanel 直接通过 `SET_CONTRIBUTION_MODE` 切换运行态,不再弹确认窗口
3. 进入贡献模式后会强制 `panelMode = cpa`,并临时清空运行态 `customPassword`、禁用运行态账号记录快照同步
-4. sidepanel 隐藏 CPA 管理地址、管理密钥、SUB2API 配置、自定义密码、本地同步等普通模式配置,并禁用来源选择、配置菜单和记录入口
+4. sidepanel 隐藏 CPA 管理地址、管理密钥、SUB2API 配置、Codex2API 配置、自定义密码、本地同步等普通模式配置,并禁用来源选择、配置菜单和记录入口
5. 用户点击 `开始贡献` 后,不再单独走一条旁路 OAuth 流程,而是直接复用顶部 `自动` 的同一套主自动流
6. 步骤 1~6 仍按原来的注册自动化执行
-7. 当主流程进入步骤 7 时,后台改为调用公开接口 `POST https://apikey.qzz.io/oauth/api/start` 申请贡献登录地址,而不是去 CPA / SUB2API 面板刷新 OAuth
+7. 当主流程进入步骤 7 时,后台改为调用公开接口 `POST https://apikey.qzz.io/oauth/api/start` 申请贡献登录地址,而不是去 CPA / SUB2API / Codex2API 来源刷新 OAuth
8. 步骤 7 拿到 `session_id / auth_url / state` 后,继续沿用原有登录链路进入授权页
9. 步骤 9 仍负责捕获 localhost callback;贡献模式下后台也会持续监听导航变化,必要时提前兼容处理 callback
10. 步骤 10 在贡献模式下不再打开 CPA 管理页,而是围绕公开贡献会话做 callback 提交兼容和最终状态确认;当状态进入 `auto_approved / auto_rejected / manual_review_required / expired / error` 时结束
@@ -378,7 +379,10 @@
流程:
-1. 通过 CPA / SUB2API 刷新 OAuth 地址
+1. 按当前来源刷新 OAuth 地址:
+ - CPA:打开管理页并读取 OAuth 地址
+ - SUB2API:打开后台并生成 OAuth 地址
+ - Codex2API:直接调用后台协议 `/api/admin/oauth/generate-auth-url`
2. 打开最新 OAuth 链接
3. 登录;如果进入密码页且当前有密码,则填写并提交密码;如果当前没有密码但检测到一次性验证码入口,则直接切换到一次性验证码登录
4. 确保真正进入验证码页
@@ -388,7 +392,7 @@
贡献模式补充:
-- 贡献模式下,步骤 7 不再从 CPA / SUB2API 面板刷新 OAuth,而是直接调用公开贡献接口 `/oauth/api/start`
+- 贡献模式下,步骤 7 不再从 CPA / SUB2API / Codex2API 来源刷新 OAuth,而是直接调用公开贡献接口 `/oauth/api/start`
- 贡献接口返回的 `auth_url` 会写回运行态 `oauthUrl`,后续步骤 7 / 8 / 9 继续复用现有授权链路
### Step 8
@@ -435,15 +439,22 @@
流程:
1. 校验 localhost callback 是否有效
-2. 判断是 CPA 还是 SUB2API
-3. 打开相应后台
-4. 提交回调地址
+2. 判断当前来源是 CPA、SUB2API 还是 Codex2API
+3. CPA / SUB2API 打开相应后台;Codex2API 直接走协议分支,不打开后台页面
+4. 提交回调地址或等价的授权码交换请求
5. 仅当出现精确成功徽标,且该徽标不是红色/错误态、页面上也没有同时可见的失败提示时,才判定成功
6. 识别 `认证失败:*`、`认证失败: timeout of 30000ms exceeded`、`回调 URL 提交失败: oauth flow is not pending` 等失败提示并立即报错
7. 完成平台侧验证
8. 追加账号运行历史成功记录
9. 做成功后的清理与标记
+Codex2API 补充:
+
+- 步骤 7 直接调用 `POST /api/admin/oauth/generate-auth-url` 获取 `auth_url / session_id`
+- 授权页仍沿用现有 OpenAI 登录、验证码、OAuth 同意页与 localhost callback 主链
+- 步骤 10 直接调用 `POST /api/admin/oauth/exchange-code`,用 callback 中的 `code / state` 完成账号创建
+- Codex2API 这条来源不新增 panel content script,也不依赖“添加账号 -> OAuth 授权 -> 生成授权链接”页面按钮 DOM
+
贡献模式补充:
- 贡献模式下,步骤 10 不再打开 CPA 管理页
@@ -677,6 +688,10 @@
5. Step 4 / 7 的验证码流
6. 成功收尾逻辑
+如果来源本身提供稳定协议接口,还必须额外判断:
+
+7. 是否可以不打开来源后台页面,直接把步骤 7 / 10 收敛到协议分支
+
### 新增配置项
必须同时检查:
diff --git a/项目开发规范(AI协作).md b/项目开发规范(AI协作).md
index 8b7ef6a..65f006c 100644
--- a/项目开发规范(AI协作).md
+++ b/项目开发规范(AI协作).md
@@ -82,6 +82,14 @@
5. 是否需要成功收尾逻辑
6. 是否需要 README 与完整链路文档更新
+补充约定:
+
+- 如果新增来源本身已经提供稳定的后台协议接口,可以直接走协议分支接入:
+ - 步骤 7 通过 `background/panel-bridge.js` 生成 `auth_url`
+ - 步骤 10 通过 `background/steps/platform-verify.js` 直接提交 localhost callback
+- 这类来源优先复用现有 OpenAI 授权页与 localhost callback 主链,不要为了“看起来统一”再额外新增一套页面 DOM 自动点击内容脚本。
+- 只有当目标来源没有可用协议接口、必须依赖后台页面按钮时,才新增对应的 panel content script。
+
### 3.2.1 共享别名邮箱逻辑补充
当 Gmail / 2925 这类“既影响注册邮箱生成,又影响 sidepanel 表单行为”的 provider 发生变化时,必须优先检查是否应落入共享层,而不是继续把规则分散写在:
@@ -119,7 +127,7 @@
当前约定示例:
- `contributionMode` 是 sidepanel 的运行态 UI 模式,不是新的 `panelMode`
-- `panelMode` 仍然只允许 `cpa | sub2api`
+- `panelMode` 当前允许 `cpa | sub2api | codex2api`
- 运行态模式不能混进 `PERSISTED_SETTING_DEFAULTS`
- 运行态模式不能混进配置导入/导出
- 如果运行态模式会临时覆盖某些持久配置的显示值,必须同时处理好“退出模式后恢复”和“自动保存不能误覆盖原配置”这两个问题
diff --git a/项目文件结构说明.md b/项目文件结构说明.md
index 7974da3..2d002fc 100644
--- a/项目文件结构说明.md
+++ b/项目文件结构说明.md
@@ -48,8 +48,8 @@
- `background/logging-status.js`:后台日志、步骤状态、错误信息和若干状态判断的公共工具层;当前额外承接 `add-phone / 手机号页` 这类认证 fatal 错误的共享判定。
- `background/mail-2925-session.js`:2925 会话模块,负责 2925 账号池持久化、当前账号切换、cookie 清理登出、自动登录、命中“子邮箱已达上限邮箱”后的 24 小时禁用与自动切号。
- `background/message-router.js`:后台消息路由层,负责处理 `chrome.runtime.onMessage` 进入的所有业务消息;当前额外接入 2925 账号池的新增、导入、切换、登录、禁用与删除消息。
-- `background/navigation-utils.js`:导航与 URL 判断工具层,负责 callback、入口页、CPA / SUB2API 地址、步骤跳转相关判断。
-- `background/panel-bridge.js`:CPA / SUB2API 面板桥接层,封装 OAuth 地址获取所需的页面打开、脚本注入和通信。
+- `background/navigation-utils.js`:导航与 URL 判断工具层,负责 callback、入口页、CPA / SUB2API / Codex2API 地址归一化、来源标签页家族判断与步骤跳转相关判断。
+- `background/panel-bridge.js`:来源桥接层;CPA / SUB2API 继续封装页面打开、脚本注入和通信,Codex2API 则直接通过后台协议生成 OAuth 地址。
- `background/signup-flow-helpers.js`:注册页辅助层,负责打开注册入口、等待密码页以及解析当前流程所用邮箱;在 Gmail 与 `2925 + provide` 模式下会优先复用已经存在且仍兼容的完整注册邮箱,只有不兼容或为空时才重新生成;当 provider 为 2925 且启用了 provide 模式下的号池时,会先确保账号池中已选中可用账号。
- `background/tab-runtime.js`:标签页与内容脚本运行时基础设施,封装标签注册、冲突清理、消息超时、注入重试与队列;当前等待标签完成、注入后的短暂延迟和内容脚本重试等待都已做 Stop 感知,避免用户停止后后台还继续等待并恢复执行。
- `background/verification-flow.js`:注册/登录验证码共享流程层,封装重发、轮询、提交、失败回退、自定义邮箱跳过、共享验证码自动重发次数配置以及 2925 长轮询参数;当前验证码提交重试上限为 15 次,2925 每次重发验证码之间都会固定跑完一轮 15 次邮箱刷新轮询;若 `mail2925Mode = receive`,会额外把目标注册邮箱传给 2925 内容脚本做弱匹配;若 2925 轮询命中“子邮箱已达上限邮箱”,会转入 2925 会话模块执行“记录时间、禁用 24 小时、切下一个账号并重新登录”,然后直接结束当前尝试。
@@ -64,7 +64,7 @@
- `background/steps/fill-profile.js`:步骤 5 实现,负责姓名、生日填写并把资料提交给注册页内容脚本。
- `background/steps/oauth-login.js`:步骤 7 实现,负责刷新 OAuth 链接、登录和确保进入验证码页;普通可恢复登录态失败会按上限重试,但一旦识别到认证流程进入 `add-phone / 手机号页`,会立即退出步骤 7 内部重试。
- `background/steps/open-chatgpt.js`:步骤 1 实现,负责打开 ChatGPT 官网并确认入口就绪。
-- `background/steps/platform-verify.js`:步骤 10 实现,负责 CPA / SUB2API 回调验证。
+- `background/steps/platform-verify.js`:步骤 10 实现,负责 CPA / SUB2API 回调验证,以及 Codex2API 的协议式 callback code/state 交换。
- `background/steps/registry.js`:步骤注册表工厂,负责用稳定的步骤元数据映射到执行器。
- `background/steps/submit-signup-email.js`:步骤 2 实现,负责注册入口点击、邮箱提交与提交后落地页分支判断。
@@ -119,10 +119,10 @@
- `sidepanel/mail-2925-manager.js`:侧边栏 2925 账号池管理器,负责 2925 账号的新增、导入、切换、手动登录、启停、清冷却与删除。
- `sidepanel/account-records-manager.js`:侧边栏邮箱记录面板管理器,负责“记录”按钮、覆盖层开关、分页列表、成功/失败/停止统计摘要和清理确认。
- `sidepanel/contribution-content-update-service.js`:侧边栏贡献内容更新服务,负责拉取 `https://apikey.qzz.io/api/content-summary`、缓存公开公告/教程摘要,并输出可用于提示展示的 `promptVersion` 与最新更新时间。
-- `sidepanel/contribution-mode.js`:侧边栏贡献模式管理器,负责顶部“贡献”按钮、确认弹窗、贡献模式显隐、复用主自动流程启动、侧栏内贡献状态轮询、上传页跳转,以及贡献模式下对来源选择、配置入口、记录入口和敏感配置行的禁用与隐藏。
+- `sidepanel/contribution-mode.js`:侧边栏贡献模式管理器,负责顶部“贡献”按钮、确认弹窗、贡献模式显隐、复用主自动流程启动、侧栏内贡献状态轮询、上传页跳转,以及贡献模式下对来源选择、配置入口、记录入口和敏感配置行(包括 Codex2API 配置)的禁用与隐藏。
- `sidepanel/sidepanel.css`:侧边栏样式文件;当前额外提供 Hotmail / 2925 共用的号池表单容器、操作按钮行与统一的收起态列表高度样式。
-- `sidepanel/sidepanel.html`:侧边栏页面结构;当前步骤列表已改为动态容器,日志区提供“记录”按钮并挂接邮箱记录覆盖层,顶部新增“贡献”按钮并在设置卡片中新增贡献模式主面板;贡献按钮下方额外挂接一个可关闭的轻提示气泡,用于提示公开公告 / 使用教程有更新;贡献面板内展示 `OAUTH / 回调 / 总状态` 三块真实运行态信息,同时把“本地同步”与“验证码重发”拆成独立行以避免特殊模式隐藏时互相影响;页面继续加载 `managed-alias-utils.js`,并把旧的“邮箱前缀”字段语义改为“别名基邮箱”;当 provider 为 2925 时,会额外显示 `提供邮箱 / 接收邮箱` 模式切换,并把“2925 号池”从别名基邮箱行拆成独立配置行,避免 receive 模式把账号池开关一起隐藏;Hotmail / 2925 两个账号池当前都使用统一的头部“添加账号/取消添加”按钮和共享表单容器。
-- `sidepanel/sidepanel.js`:侧边栏主入口脚本,负责 UI 状态同步、动态步骤渲染、按钮交互、独立本地同步配置、共享验证码自动重发次数配置与广播接收,并装配 Hotmail / 2925 / iCloud / LuckMail / 贡献模式 / 邮箱记录面板 / 贡献内容更新服务;当前贡献模式的“开始贡献”会直接复用主自动流程启动逻辑,而独立 manager 负责贡献运行态展示与轮询,同时把 Gmail / 2925 的基邮箱输入、完整注册邮箱输入、自动生成按钮与兼容性校验统一接到共享别名逻辑上;当 provider 为 2925 时,会根据 `mail2925Mode` 决定是否启用别名基邮箱链路,而 2925 号池开关与当前账号选择则独立显示并同时服务于 provide / receive 两种模式;Hotmail / 2925 的新增表单显隐统一接到 `sidepanel/account-pool-ui.js` 这一层共享 helper;侧边栏初始化与点击“自动”前会刷新一次贡献站公开内容摘要,并按本地关闭版本决定是否展示轻提示。
+- `sidepanel/sidepanel.html`:侧边栏页面结构;当前步骤列表已改为动态容器,日志区提供“记录”按钮并挂接邮箱记录覆盖层,顶部新增“贡献”按钮并在设置卡片中新增贡献模式主面板;贡献按钮下方额外挂接一个可关闭的轻提示气泡,用于提示公开公告 / 使用教程有更新;贡献面板内展示 `OAUTH / 回调 / 总状态` 三块真实运行态信息,同时把“本地同步”与“验证码重发”拆成独立行以避免特殊模式隐藏时互相影响;页面继续加载 `managed-alias-utils.js`,并把旧的“邮箱前缀”字段语义改为“别名基邮箱”;当 provider 为 2925 时,会额外显示 `提供邮箱 / 接收邮箱` 模式切换,并把“2925 号池”从别名基邮箱行拆成独立配置行,避免 receive 模式把账号池开关一起隐藏;来源下拉框当前支持 `CPA / SUB2API / Codex2API`,其中 Codex2API 额外提供后台地址和管理密钥配置行;Hotmail / 2925 两个账号池当前都使用统一的头部“添加账号/取消添加”按钮和共享表单容器。
+- `sidepanel/sidepanel.js`:侧边栏主入口脚本,负责 UI 状态同步、动态步骤渲染、按钮交互、独立本地同步配置、共享验证码自动重发次数配置与广播接收,并装配 Hotmail / 2925 / iCloud / LuckMail / 贡献模式 / 邮箱记录面板 / 贡献内容更新服务;当前贡献模式的“开始贡献”会直接复用主自动流程启动逻辑,而独立 manager 负责贡献运行态展示与轮询,同时把 Gmail / 2925 的基邮箱输入、完整注册邮箱输入、自动生成按钮与兼容性校验统一接到共享别名逻辑上;当 provider 为 2925 时,会根据 `mail2925Mode` 决定是否启用别名基邮箱链路,而 2925 号池开关与当前账号选择则独立显示并同时服务于 provide / receive 两种模式;新来源 Codex2API 在这里仅补充来源配置接线、表单显隐和 Step 10 按钮文案,不承接协议业务逻辑;Hotmail / 2925 的新增表单显隐统一接到 `sidepanel/account-pool-ui.js` 这一层共享 helper;侧边栏初始化与点击“自动”前会刷新一次贡献站公开内容摘要,并按本地关闭版本决定是否展示轻提示。
- `sidepanel/update-service.js`:侧边栏更新检查服务,负责 GitHub Releases 查询、`Pro` / `v` 双版本族排序、缓存读取与版本展示。
## `tests/`
@@ -150,6 +150,7 @@
- `tests/background-message-router-step2-skip.test.js`:测试步骤 2 直接落到验证码页时的跳步与状态保护逻辑。
- `tests/background-navigation-utils-module.test.js`:测试导航工具模块已接入且导出工厂。
- `tests/background-panel-bridge-module.test.js`:测试面板桥接模块已接入且导出工厂。
+- `tests/background-platform-verify-codex2api.test.js`:测试 Codex2API 新来源在步骤 10 走协议式 callback 交换,不依赖后台页面注入。
- `tests/background-signup-flow-module.test.js`:测试注册页辅助模块已接入且导出工厂。
- `tests/background-skip-step-linking.test.js`:测试手动跳过步骤 1 时,会级联跳过步骤 2~5,并仅跳过其中未完成且未运行的步骤。
- `tests/background-signup-step2-branching.test.js`:测试在 Gmail / 2925 模式下,已有兼容别名邮箱时应直接复用,不应再次重生成。