From 8d42770ce3f62b5826b58aecb87e0a744e469733 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=88=9A=E9=91=AB=E7=A3=8A?= <11811705+qixinlei_24@user.noreply.gitee.com> Date: Wed, 22 Jul 2026 14:22:16 +0800 Subject: [PATCH] feat(sub2api): add agent identity import mode --- background.js | 12 +- background/sub2api-api.js | 157 +++++++++++++ .../steps/sub2api-session-import.js | 80 ++++++- flows/openai/index.js | 2 + flows/openai/workflow.js | 37 +++ sidepanel/sidepanel.html | 7 + sidepanel/sidepanel.js | 86 +++++++ .../background-sub2api-session-import.test.js | 212 ++++++++++++++++++ tests/sidepanel-flow-source-registry.test.js | 68 ++++++ tests/step-definitions-module.test.js | 21 ++ 10 files changed, 677 insertions(+), 5 deletions(-) diff --git a/background.js b/background.js index 374243a6..c630f750 100644 --- a/background.js +++ b/background.js @@ -928,6 +928,9 @@ function getStepDefinitionsForState(state = {}) { plusModeEnabled: Boolean(resolvedState?.plusModeEnabled), plusPaymentMethod: normalizePlusPaymentMethod(resolvedState?.plusPaymentMethod), plusAccountAccessStrategy: normalizePlusAccountAccessStrategy(resolvedState?.plusAccountAccessStrategy), + ...(String(resolvedState?.sub2apiImportMode || '').trim().toLowerCase() === 'agent_identity' + ? { sub2apiImportMode: 'agent_identity' } + : {}), signupMethod: getSignupMethodForStepDefinitions(resolvedState), phoneVerificationEnabled: Boolean(resolvedState?.phoneVerificationEnabled), phoneSignupReloginAfterBindEmailEnabled: Boolean(resolvedState?.phoneSignupReloginAfterBindEmailEnabled), @@ -1233,6 +1236,7 @@ const PERSISTED_SETTING_DEFAULTS = { sub2apiUrl: DEFAULT_SUB2API_URL, sub2apiEmail: '', sub2apiPassword: '', + sub2apiImportMode: 'oauth', sub2apiGroupName: DEFAULT_SUB2API_GROUP_NAME, sub2apiGroupNames: DEFAULT_SUB2API_GROUP_NAMES, sub2apiAccountPriority: DEFAULT_SUB2API_ACCOUNT_PRIORITY, @@ -1399,6 +1403,7 @@ const SETTINGS_SCHEMA_VIEW_KEYS = Object.freeze([ 'sub2apiUrl', 'sub2apiEmail', 'sub2apiPassword', + 'sub2apiImportMode', 'sub2apiGroupName', 'sub2apiGroupNames', 'sub2apiAccountPriority', @@ -1442,7 +1447,7 @@ const SETTINGS_SCHEMA_VIEW_KEYS = Object.freeze([ const SETTINGS_SCHEMA_VIEW_KEY_SET = new Set(SETTINGS_SCHEMA_VIEW_KEYS); const SETTINGS_EXPORT_SCHEMA_VERSION = 1; const SETTINGS_EXPORT_FILENAME_PREFIX = 'multipage-settings'; -const STEP6_REGISTRATION_SUCCESS_WAIT_MS = 20000; +const STEP6_REGISTRATION_SUCCESS_WAIT_MS = 1000; const DEFAULT_STATE = { flowId: DEFAULT_ACTIVE_FLOW_ID, @@ -3218,6 +3223,8 @@ function normalizePersistentSettingValue(key, value) { return String(value || '').trim(); case 'sub2apiPassword': return String(value || ''); + case 'sub2apiImportMode': + return String(value || '').trim().toLowerCase() === 'agent_identity' ? 'agent_identity' : 'oauth'; case 'sub2apiGroupName': return String(value || '').trim(); case 'sub2apiGroupNames': @@ -12268,7 +12275,7 @@ const AUTO_RUN_NODE_DELAYS = Object.freeze({ 'fill-password': 3000, 'fetch-signup-code': 2000, 'fill-profile': 0, - 'wait-registration-success': 3000, + 'wait-registration-success': 0, 'plus-checkout-create': 3000, 'paypal-hosted-openai-checkout': 2000, 'paypal-hosted-email': 2000, @@ -14211,6 +14218,7 @@ const stepExecutorsByKey = { 'paypal-approve': (state) => payPalApproveExecutor.executePayPalApprove(state), 'plus-checkout-return': (state) => plusReturnConfirmExecutor.executePlusReturnConfirm(state), 'sub2api-session-import': (state) => sub2ApiSessionImportExecutor.executeSub2ApiSessionImport(state), + 'sub2api-agent-identity-import': (state) => sub2ApiSessionImportExecutor.executeSub2ApiAgentIdentityImport(state), 'cpa-session-import': (state) => cpaSessionImportExecutor.executeCpaSessionImport(state), 'openai-upload-session-to-webchat': (state) => openAiWebchatPublisher.executeOpenAiUploadSessionToWebchat(state), 'openai-upload-session-to-chatgpt2api': (state) => openAiChatgpt2ApiPublisher.executeOpenAiUploadSessionToChatgpt2Api(state), diff --git a/background/sub2api-api.js b/background/sub2api-api.js index 9b975c0e..63260203 100644 --- a/background/sub2api-api.js +++ b/background/sub2api-api.js @@ -9,6 +9,7 @@ fetchImpl = (...args) => fetch(...args), setTimeoutImpl = (...args) => setTimeout(...args), clearTimeoutImpl = (...args) => clearTimeout(...args), + cryptoImpl = globalThis.crypto, } = deps; const DEFAULT_REDIRECT_URI = 'http://localhost:1455/auth/callback'; @@ -558,6 +559,99 @@ } } + function encodeBase64(bytes) { + const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); + if (typeof btoa === 'function') { + let binary = ''; + for (let index = 0; index < view.length; index += 0x8000) { + binary += String.fromCharCode(...view.subarray(index, index + 0x8000)); + } + return btoa(binary); + } + if (typeof Buffer !== 'undefined') { + return Buffer.from(view).toString('base64'); + } + throw new Error('当前环境不支持 Base64 编码。'); + } + + function encodeSshEd25519PublicKey(rawPublicKey) { + const algorithm = new TextEncoder().encode('ssh-ed25519'); + const publicKey = new Uint8Array(rawPublicKey); + const bytes = new Uint8Array(4 + algorithm.length + 4 + publicKey.length); + const view = new DataView(bytes.buffer); + view.setUint32(0, algorithm.length); + bytes.set(algorithm, 4); + view.setUint32(4 + algorithm.length, publicKey.length); + bytes.set(publicKey, 8 + algorithm.length); + return `ssh-ed25519 ${encodeBase64(bytes)}`; + } + + function buildAgentIdentityContext(state = {}, session = null, accessToken = '') { + const token = normalizeString(accessToken || session?.accessToken); + const claims = parseCodexAccessTokenClaims(token); + const auth = claims?.['https://api.openai.com/auth'] || {}; + const profile = claims?.['https://api.openai.com/profile'] || {}; + const accountId = normalizeString(auth.chatgpt_account_id); + const userId = normalizeString(auth.chatgpt_user_id || auth.user_id || claims?.sub); + if (!accountId || !userId) { + throw new Error('accessToken 缺少 OpenAI account_id 或 user_id,无法生成 Agent Identity。'); + } + return { + accessToken: token, + accountId, + userId, + email: normalizeEmailValue(profile.email) + || resolveCodexSessionImportAccountName(state, session, token), + planType: normalizeString(auth.chatgpt_plan_type) || 'free', + }; + } + + async function createOpenAiAgentIdentity(state = {}, session = null, accessToken = '', options = {}) { + if (!cryptoImpl?.subtle) { + throw new Error('当前浏览器不支持 Web Crypto,无法生成 Agent Identity。'); + } + const context = buildAgentIdentityContext(state, session, accessToken); + let keyPair; + try { + keyPair = await cryptoImpl.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']); + } catch { + throw new Error('当前浏览器不支持 Ed25519,无法生成 Agent Identity。'); + } + const [privateKey, publicKey] = await Promise.all([ + cryptoImpl.subtle.exportKey('pkcs8', keyPair.privateKey), + cryptoImpl.subtle.exportKey('raw', keyPair.publicKey), + ]); + const registered = await requestJson('https://auth.openai.com', '/api/accounts/v1/agent/register', { + method: 'POST', + token: context.accessToken, + timeoutMs: options.agentTimeoutMs || options.timeoutMs, + body: { + abom: { + agent_version: '0.138.0-alpha.6', + agent_harness_id: 'codex-cli', + running_location: 'local', + }, + agent_public_key: encodeSshEd25519PublicKey(publicKey), + }, + }); + const runtimeId = normalizeString(registered?.agent_runtime_id); + if (!runtimeId) { + throw new Error('OpenAI Agent 注册未返回 agent_runtime_id。'); + } + return { + auth_mode: 'agent_identity', + agent_identity: { + agent_runtime_id: runtimeId, + agent_private_key: encodeBase64(privateKey), + account_id: context.accountId, + chatgpt_user_id: context.userId, + email: context.email, + plan_type: context.planType, + chatgpt_account_is_fedramp: false, + }, + }; + } + function resolveCodexSessionImportAccountName(state = {}, session = null, accessToken = '') { const sessionObject = normalizeCodexSessionObject(session); const claims = parseCodexAccessTokenClaims(accessToken || sessionObject?.accessToken); @@ -960,9 +1054,71 @@ }; } + async function importCurrentChatGptSessionAsAgentIdentity(state = {}, options = {}) { + const logLabel = normalizeString(options.logLabel) || 'SUB2API Agent Identity 导入'; + const session = normalizeCodexSessionObject(state?.session); + const accessToken = normalizeString(state?.accessToken || session?.accessToken); + if (!accessToken) { + throw new Error('未读取到 ChatGPT accessToken,无法生成 Agent Identity。'); + } + + await logWithOptions(`${logLabel}:正在使用当前 ChatGPT accessToken 注册 Agent...`, 'info', options); + const authJson = await createOpenAiAgentIdentity(state, session, accessToken, options); + const accountName = normalizeString(authJson?.agent_identity?.email) + || resolveCodexSessionImportAccountName(state, session, accessToken); + + await logWithOptions(`${logLabel}:正在登录 SUB2API 并解析分组、代理...`, 'info', options); + const { origin, token } = await loginSub2Api(state, options); + const groupNames = state.sub2apiGroupName || DEFAULT_SUB2API_GROUP_NAME; + const groups = await getGroupsByNames(origin, token, groupNames, options); + const proxyPreference = resolveSub2ApiProxyPreference(state); + const proxy = proxyPreference ? await resolveSub2ApiProxy(origin, token, proxyPreference, options) : null; + const proxyId = normalizeProxyId(proxy?.id); + const accountPriority = resolveSub2ApiAccountPriority(state); + const groupIds = groups + .map((group) => Number(group?.id)) + .filter((id) => Number.isFinite(id) && id > 0); + if (!groupIds.length) { + throw new Error('SUB2API 返回的目标分组 ID 无效。'); + } + + const importPayload = { + content: JSON.stringify(authJson), + group_ids: groupIds, + ...(accountName ? { name: accountName } : {}), + priority: accountPriority, + update_existing: true, + }; + if (proxyId) { + importPayload.proxy_id = proxyId; + } + + await logWithOptions(`${logLabel}:正在导入 Agent Identity 到 SUB2API...`, 'info', options); + const importResult = normalizeCodexSessionImportResult(await requestJson(origin, '/api/v1/admin/accounts/import/codex-session', { + method: 'POST', + token, + timeoutMs: options.importTimeoutMs || options.timeoutMs, + body: importPayload, + })); + if (importResult.failed > 0 || (importResult.created <= 0 && importResult.updated <= 0)) { + throw new Error(getCodexSessionImportFailureMessage(importResult)); + } + const verifiedStatus = `SUB2API Agent Identity 导入完成:新建 ${importResult.created},更新 ${importResult.updated},跳过 ${importResult.skipped},失败 ${importResult.failed}`; + await logWithOptions(verifiedStatus, 'ok', options); + return { + verifiedStatus, + sub2apiImportTotal: importResult.total, + sub2apiImportCreated: importResult.created, + sub2apiImportUpdated: importResult.updated, + sub2apiImportSkipped: importResult.skipped, + sub2apiImportFailed: importResult.failed, + }; + } + return { buildDraftAccountName, buildCodexSessionImportContent, + buildAgentIdentityContext, buildOpenAiCredentials, buildOpenAiExtra, buildProxyDisplayName, @@ -972,6 +1128,7 @@ prepareGrokOAuth, createGrokAccountFromOAuth, importCurrentChatGptSession, + importCurrentChatGptSessionAsAgentIdentity, loginSub2Api, normalizeProxyId, normalizeRedirectUri, diff --git a/flows/openai/background/steps/sub2api-session-import.js b/flows/openai/background/steps/sub2api-session-import.js index a163022f..143122d0 100644 --- a/flows/openai/background/steps/sub2api-session-import.js +++ b/flows/openai/background/steps/sub2api-session-import.js @@ -3,6 +3,9 @@ })(typeof self !== 'undefined' ? self : globalThis, function createBackgroundSub2ApiSessionImportModule() { const PLUS_CHECKOUT_SOURCE = 'plus-checkout'; const PLUS_CHECKOUT_INJECT_FILES = ['content/utils.js', 'content/operation-delay.js', 'flows/openai/content/plus-checkout.js']; + const SESSION_READ_INITIAL_DELAY_MS = 1000; + const SESSION_READ_RETRY_DELAY_MS = 2000; + const SESSION_READ_MAX_ATTEMPTS = 11; function createSub2ApiSessionImportExecutor(deps = {}) { const { @@ -15,6 +18,9 @@ normalizeSub2ApiUrl = (value) => value, registerTab, sendTabMessageUntilStopped, + sessionReadInitialDelayMs = SESSION_READ_INITIAL_DELAY_MS, + sessionReadRetryDelayMs = SESSION_READ_RETRY_DELAY_MS, + sessionReadMaxAttempts = SESSION_READ_MAX_ATTEMPTS, sleepWithStop = async () => {}, throwIfStopped = () => {}, waitForTabCompleteUntilStopped = async () => {}, @@ -199,9 +205,7 @@ return tab; } - async function readCurrentChatGptSession(tabId, visibleStep) { - await waitForTabCompleteUntilStopped(tabId); - await sleepWithStop(1000); + async function requestCurrentChatGptSession(tabId, visibleStep, options = {}) { await ensureContentScriptReadyOnTabUntilStopped(PLUS_CHECKOUT_SOURCE, tabId, { inject: PLUS_CHECKOUT_INJECT_FILES, injectSource: PLUS_CHECKOUT_SOURCE, @@ -227,6 +231,9 @@ sessionResult?.accessToken || session?.accessToken ); + if (options.requireAccessToken && !accessToken) { + throw new Error(`步骤 ${visibleStep}:未读取到 ChatGPT accessToken,请确认当前标签页仍处于已登录状态。`); + } if (!session && !accessToken) { throw new Error(`步骤 ${visibleStep}:未读取到有效的 ChatGPT 会话或 accessToken,请确认当前标签页仍处于已登录状态。`); } @@ -237,6 +244,39 @@ }; } + async function readCurrentChatGptSession(tabId, visibleStep, options = {}) { + await waitForTabCompleteUntilStopped(tabId); + + const firstDelayMs = Math.max(0, Math.floor(Number(sessionReadInitialDelayMs) || 0)); + const retryDelayMs = Math.max(0, Math.floor(Number(sessionReadRetryDelayMs) || 0)); + const maxAttempts = Math.max(1, Math.floor(Number(sessionReadMaxAttempts) || SESSION_READ_MAX_ATTEMPTS)); + let lastError = null; + + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + throwIfStopped(); + const delayMs = attempt === 1 ? firstDelayMs : retryDelayMs; + if (delayMs > 0) { + await sleepWithStop(delayMs); + } + + try { + return await requestCurrentChatGptSession(tabId, visibleStep, options); + } catch (error) { + lastError = error; + if (attempt >= maxAttempts) { + break; + } + await addStepLog( + visibleStep, + `未读取到 ChatGPT session/accessToken,${Math.round(retryDelayMs / 1000)} 秒后重试...`, + 'warn' + ); + } + } + + throw lastError || new Error(`步骤 ${visibleStep}:未读取到有效的 ChatGPT 会话或 accessToken,请确认当前标签页仍处于已登录状态。`); + } + async function executeSub2ApiSessionImport(state = {}) { throwIfStopped(); const visibleStep = resolveVisibleStep(state); @@ -268,8 +308,42 @@ await completeNodeFromBackground(state?.nodeId || 'sub2api-session-import', result); } + async function executeSub2ApiAgentIdentityImport(state = {}) { + throwIfStopped(); + const visibleStep = resolveVisibleStep(state); + const api = getSub2ApiApi(); + const importAgentIdentity = api.importCurrentChatGptSessionAsAgentIdentity + || api.importCurrentChatGptSession; + + await addStepLog(visibleStep, '正在定位当前 ChatGPT 会话页并读取 accessToken...', 'info'); + const tabId = await resolveSessionTabId(state); + const tab = await getResolvedSessionTab(tabId, visibleStep); + if (chrome?.tabs?.update) { + await chrome.tabs.update(tab.id, { active: true }).catch(() => {}); + } + const sessionState = await readCurrentChatGptSession(tab.id, visibleStep, { + requireAccessToken: true, + }); + throwIfStopped(); + + const result = await importAgentIdentity({ + ...state, + session: sessionState.session, + accessToken: sessionState.accessToken, + }, { + visibleStep, + logLabel: `步骤 ${visibleStep}`, + logOptions: { step: visibleStep, stepKey: 'sub2api-agent-identity-import' }, + timeoutMs: 120000, + agentTimeoutMs: 60000, + importTimeoutMs: 120000, + }); + await completeNodeFromBackground(state?.nodeId || 'sub2api-agent-identity-import', result); + } + return { executeSub2ApiSessionImport, + executeSub2ApiAgentIdentityImport, isSupportedChatGptSessionUrl, }; } diff --git a/flows/openai/index.js b/flows/openai/index.js index 8d9e5456..608c1473 100644 --- a/flows/openai/index.js +++ b/flows/openai/index.js @@ -71,6 +71,7 @@ "sub2apiUrl": "", "sub2apiEmail": "", "sub2apiPassword": "", + "sub2apiImportMode": "oauth", "sub2apiGroupName": "codex", "sub2apiGroupNames": [ "codex", @@ -431,6 +432,7 @@ "row-sub2api-url", "row-sub2api-email", "row-sub2api-password", + "row-sub2api-import-mode", "row-sub2api-group", "row-sub2api-account-priority", "row-sub2api-default-proxy" diff --git a/flows/openai/workflow.js b/flows/openai/workflow.js index 3888b8aa..1dc796b5 100644 --- a/flows/openai/workflow.js +++ b/flows/openai/workflow.js @@ -16,6 +16,7 @@ const OPENAI_WEBCHAT_UPLOAD_STEP_KEY = 'openai-upload-session-to-webchat'; const OPENAI_CHATGPT2API_TARGET_ID = 'chatgpt2api'; const OPENAI_CHATGPT2API_UPLOAD_STEP_KEY = 'openai-upload-session-to-chatgpt2api'; + const OPENAI_SUB2API_AGENT_IDENTITY_IMPORT_STEP_KEY = 'sub2api-agent-identity-import'; function freezeDeep(entry) { if (!entry || typeof entry !== 'object' || Object.isFrozen(entry)) { @@ -1909,6 +1910,37 @@ }; } + function getSub2ApiAgentIdentityImportStep() { + return { + key: OPENAI_SUB2API_AGENT_IDENTITY_IMPORT_STEP_KEY, + title: '生成并导入 Agent Identity', + sourceId: 'sub2api-panel', + driverId: 'flows/openai/background/steps/sub2api-session-import', + command: OPENAI_SUB2API_AGENT_IDENTITY_IMPORT_STEP_KEY, + flowId: 'openai', + }; + } + + function isSub2ApiAgentIdentityMode(options = {}) { + return String(options?.targetId || '').trim().toLowerCase() === 'sub2api' + && String(options?.sub2apiImportMode || '').trim().toLowerCase() === 'agent_identity'; + } + + function replaceOpenAiOAuthTailWithAgentIdentity(steps = []) { + const registrationWaitIndex = steps.findIndex((step) => step.key === PLUS_REGISTRATION_WAIT_STEP_KEY); + if (registrationWaitIndex < 0) return steps; + return [ + ...steps.slice(0, registrationWaitIndex + 1), + getSub2ApiAgentIdentityImportStep(), + ]; + } + + function replaceSessionImportWithAgentIdentity(steps = []) { + return steps.map((step) => step.key === 'sub2api-session-import' + ? getSub2ApiAgentIdentityImportStep() + : step); + } + function getPlusRegistrationWaitStep() { const sourceStep = STEP_VARIANTS.normal.find((step) => step.key === PLUS_REGISTRATION_WAIT_STEP_KEY); return { @@ -2079,6 +2111,11 @@ if (!isPhoneVerificationEnabled(options)) { steps = omitPostLoginPhoneVerificationSteps(steps); } + if (isSub2ApiAgentIdentityMode(options)) { + steps = steps.some((step) => step.key === 'sub2api-session-import') + ? replaceSessionImportWithAgentIdentity(steps) + : replaceOpenAiOAuthTailWithAgentIdentity(steps); + } if (isOpenAiRemotePublicationTarget(options)) { steps = omitOpenAiWebchatPublicationSteps(steps); } diff --git a/sidepanel/sidepanel.html b/sidepanel/sidepanel.html index 342b6587..6ed9791a 100644 --- a/sidepanel/sidepanel.html +++ b/sidepanel/sidepanel.html @@ -201,6 +201,13 @@ data-hide-label="隐藏 SUB2API 密码" aria-label="显示 SUB2API 密码" title="显示 SUB2API 密码"> +