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 @@ + +
账户密码
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 模式下,已有兼容别名邮箱时应直接复用,不应再次重生成。