diff --git a/background.js b/background.js index db3aa71..559d010 100644 --- a/background.js +++ b/background.js @@ -3844,6 +3844,14 @@ function isVerificationMailPollingError(error) { return /未在 .*邮箱中找到新的匹配邮件|未在 Hotmail 收件箱中找到新的匹配验证码|邮箱轮询结束,但未获取到验证码|无法获取新的(?:注册|登录)验证码|页面未能重新就绪|页面通信异常|did not respond in \d+s/i.test(message); } +function isAddPhoneAuthFailure(error) { + if (typeof loggingStatus !== 'undefined' && loggingStatus?.isAddPhoneAuthFailure) { + return loggingStatus.isAddPhoneAuthFailure(error); + } + const message = getErrorMessage(error); + return /https:\/\/auth\.openai\.com\/add-phone(?:[/?#]|$)|\badd-phone\b|添加手机号|手机号码|手机号页|手机号页面|手机号|phone\s+number|telephone/i.test(message); +} + function getLoginAuthStateLabel(state) { if (typeof loggingStatus !== 'undefined' && loggingStatus?.getLoginAuthStateLabel) { return loggingStatus.getLoginAuthStateLabel(state); @@ -5181,6 +5189,7 @@ const autoRunController = self.MultiPageBackgroundAutoRunController?.createAutoR getState, getStopRequested: () => stopRequested, hasSavedProgress, + isAddPhoneAuthFailure, isRestartCurrentAttemptError, isStopError, launchAutoRunTimerPlan, @@ -5788,6 +5797,7 @@ const step7Executor = self.MultiPageBackgroundStep7?.createStep7Executor({ getLoginAuthStateLabel, getOAuthFlowStepTimeoutMs, getState, + isAddPhoneAuthFailure, isStep6RecoverableResult, isStep6SuccessResult, refreshOAuthUrlBeforeStep6, @@ -6366,7 +6376,7 @@ async function getPostStep6AutoRestartDecision(step, error) { }; } - if (isAddPhoneAuthUrl(errorMessage)) { + if (isAddPhoneAuthFailure(error) || isAddPhoneAuthUrl(errorMessage)) { return { shouldRestart: false, blockedByAddPhone: true, @@ -6440,6 +6450,11 @@ async function ensureStep8VerificationPageReady(options = {}) { throw new Error(`STEP8_RESTART_STEP7::步骤 8:当前认证页进入登录超时报错页,请回到步骤 7 重新开始。${urlPart}`.trim()); } + if (pageState.state === 'add_phone_page') { + const urlPart = pageState.url ? ` URL: ${pageState.url}` : ''; + throw new Error(`步骤 8:当前认证页进入手机号页面,当前流程无法继续自动授权。${urlPart}`.trim()); + } + const stateLabel = getLoginAuthStateLabel(pageState.state); const urlPart = pageState.url ? ` URL: ${pageState.url}` : ''; throw new Error(`当前未进入登录验证码页面,请先重新完成步骤 7。当前状态:${stateLabel}.${urlPart}`.trim()); diff --git a/background/auto-run-controller.js b/background/auto-run-controller.js index e7ccff8..40d905e 100644 --- a/background/auto-run-controller.js +++ b/background/auto-run-controller.js @@ -21,6 +21,7 @@ getRunningSteps, getState, hasSavedProgress, + isAddPhoneAuthFailure, isRestartCurrentAttemptError, isStopError, launchAutoRunTimerPlan, @@ -456,12 +457,36 @@ const reason = getErrorMessage(err); roundSummary.failureReasons.push(reason); - const canRetry = autoRunSkipFailures && attemptRun < maxAttemptsForRound; + const blockedByAddPhone = typeof isAddPhoneAuthFailure === 'function' && isAddPhoneAuthFailure(err); + const canRetry = !blockedByAddPhone && autoRunSkipFailures && attemptRun < maxAttemptsForRound; + + if (blockedByAddPhone) { + roundSummary.status = 'failed'; + roundSummary.finalFailureReason = reason; + } await setState({ autoRunRoundSummaries: serializeAutoRunRoundSummaries(totalRuns, roundSummaries), }); + if (blockedByAddPhone) { + await appendRoundRecordIfNeeded('failed', reason); + cancelPendingCommands('当前轮因认证流程进入 add-phone 已终止。'); + await broadcastStopToContentScripts(); + await addLog( + `第 ${targetRun}/${totalRuns} 轮触发 add-phone/手机号页,当前自动运行将立即停止,不再重试或进入下一轮。`, + 'warn' + ); + stoppedEarly = true; + await broadcastAutoRunStatus('stopped', { + currentRun: targetRun, + totalRuns, + attemptRun, + sessionId: 0, + }); + break; + } + if (canRetry) { const retryIndex = attemptRun; if (isRestartCurrentAttemptError(err)) { diff --git a/background/logging-status.js b/background/logging-status.js index 83a0ea7..f685fd3 100644 --- a/background/logging-status.js +++ b/background/logging-status.js @@ -61,6 +61,11 @@ return /未在 .*邮箱中找到新的匹配邮件|未在 Hotmail 收件箱中找到新的匹配验证码|邮箱轮询结束,但未获取到验证码|无法获取新的(?:注册|登录)验证码|页面未能重新就绪|页面通信异常|did not respond in \d+s/i.test(message); } + function isAddPhoneAuthFailure(error) { + const message = getErrorMessage(error); + return /https:\/\/auth\.openai\.com\/add-phone(?:[/?#]|$)|\badd-phone\b|添加手机号|手机号码|手机号页|手机号页面|手机号|phone\s+number|telephone/i.test(message); + } + function getLoginAuthStateLabel(state) { state = state === 'oauth_consent_page' ? 'unknown' : state; switch (state) { @@ -148,6 +153,7 @@ return { addLog, getAutoRunStatusPayload, + isAddPhoneAuthFailure, getErrorMessage, getFirstUnfinishedStep, getLoginAuthStateLabel, diff --git a/background/steps/oauth-login.js b/background/steps/oauth-login.js index 19cba8d..facf21e 100644 --- a/background/steps/oauth-login.js +++ b/background/steps/oauth-login.js @@ -9,6 +9,10 @@ getLoginAuthStateLabel, getOAuthFlowStepTimeoutMs, getState, + isAddPhoneAuthFailure = (error) => { + const message = String(typeof error === 'string' ? error : error?.message || ''); + return /https:\/\/auth\.openai\.com\/add-phone(?:[/?#]|$)|\badd-phone\b|添加手机号|手机号码|手机号页|手机号页面|手机号|phone\s+number|telephone/i.test(message); + }, isStep6RecoverableResult, isStep6SuccessResult, refreshOAuthUrlBeforeStep6, @@ -19,11 +23,6 @@ throwIfStopped, } = deps; - function isAddPhoneAuthFailure(error) { - const message = String(typeof error === 'string' ? error : error?.message || ''); - return /https:\/\/auth\.openai\.com\/add-phone(?:[/?#]|$)|add-phone|手机号页面|手机号码|手机号|phone\s+number|telephone/i.test(message); - } - async function executeStep7(state) { if (!state.email) { throw new Error('缺少邮箱地址,请先完成步骤 3。'); diff --git a/background/verification-flow.js b/background/verification-flow.js index 61b8d0a..055e2e7 100644 --- a/background/verification-flow.js +++ b/background/verification-flow.js @@ -642,6 +642,11 @@ continue; } + if (submitResult.addPhonePage) { + const urlPart = submitResult.url ? ` URL: ${submitResult.url}` : ''; + throw new Error(`步骤 ${step}:验证码提交后页面进入手机号页面,当前流程无法继续自动授权。${urlPart}`.trim()); + } + await setState({ lastEmailTimestamp: result.emailTimestamp, [stateKey]: result.code, diff --git a/content/signup-page.js b/content/signup-page.js index 3f29e75..47000f5 100644 --- a/content/signup-page.js +++ b/content/signup-page.js @@ -1442,7 +1442,7 @@ async function waitForVerificationSubmitOutcome(step, timeout) { } if (step === 8 && isAddPhonePageReady()) { - return { success: true, addPhonePage: true }; + return { success: true, addPhonePage: true, url: location.href }; } await sleep(150); @@ -1499,7 +1499,7 @@ async function fillVerificationCode(step, payload) { if (outcome.invalidCode) { log(`步骤 ${step}:验证码被拒绝:${outcome.errorText}`, 'warn'); } else if (outcome.addPhonePage) { - log(`步骤 ${step}:验证码已通过,并已跳转到手机号页面。`, 'ok'); + log(`步骤 ${step}:验证码提交后页面进入手机号页面,当前流程将停止自动授权。`, 'warn'); } else { log(`步骤 ${step}:验证码已通过${outcome.assumed ? '(按成功推定)' : ''}。`, 'ok'); } @@ -1541,7 +1541,7 @@ async function fillVerificationCode(step, payload) { if (outcome.invalidCode) { log(`步骤 ${step}:验证码被拒绝:${outcome.errorText}`, 'warn'); } else if (outcome.addPhonePage) { - log(`步骤 ${step}:验证码已通过,并已跳转到手机号页面。`, 'ok'); + log(`步骤 ${step}:验证码提交后页面进入手机号页面,当前流程将停止自动授权。`, 'warn'); } else { log(`步骤 ${step}:验证码已通过${outcome.assumed ? '(按成功推定)' : ''}。`, 'ok'); } diff --git a/tests/auto-run-add-phone-stop.test.js b/tests/auto-run-add-phone-stop.test.js new file mode 100644 index 0000000..af16ce7 --- /dev/null +++ b/tests/auto-run-add-phone-stop.test.js @@ -0,0 +1,167 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); + +const source = fs.readFileSync('background/auto-run-controller.js', 'utf8'); +const globalScope = {}; +const api = new Function('self', `${source}; return self.MultiPageBackgroundAutoRunController;`)(globalScope); + +test('auto-run controller does not retry add-phone failures even when auto retry is enabled', async () => { + const events = { + logs: [], + broadcasts: [], + accountRecords: [], + runCalls: 0, + }; + + let currentState = { + stepStatuses: {}, + vpsUrl: 'https://example.com/vps', + vpsPassword: 'secret', + customPassword: '', + autoRunSkipFailures: true, + autoRunFallbackThreadIntervalMinutes: 0, + autoRunDelayEnabled: false, + autoRunDelayMinutes: 30, + autoStepDelaySeconds: null, + mailProvider: '163', + emailGenerator: 'duck', + gmailBaseEmail: '', + mail2925BaseEmail: '', + emailPrefix: 'demo', + inbucketHost: '', + inbucketMailbox: '', + cloudflareDomain: '', + cloudflareDomains: [], + tabRegistry: {}, + sourceLastUrls: {}, + autoRunRoundSummaries: [], + }; + + const runtime = { + state: { + autoRunActive: false, + autoRunCurrentRun: 0, + autoRunTotalRuns: 1, + autoRunAttemptRun: 0, + autoRunSessionId: 0, + }, + get() { + return { ...this.state }; + }, + set(updates = {}) { + this.state = { ...this.state, ...updates }; + }, + }; + + let sessionSeed = 0; + + const controller = api.createAutoRunController({ + addLog: async (message, level = 'info') => { + events.logs.push({ message, level }); + }, + appendAccountRunRecord: async (status, _state, reason) => { + events.accountRecords.push({ status, reason }); + return { status, reason }; + }, + AUTO_RUN_MAX_RETRIES_PER_ROUND: 3, + AUTO_RUN_RETRY_DELAY_MS: 3000, + AUTO_RUN_TIMER_KIND_BEFORE_RETRY: 'before_retry', + AUTO_RUN_TIMER_KIND_BETWEEN_ROUNDS: 'between_rounds', + broadcastAutoRunStatus: async (phase, payload = {}) => { + events.broadcasts.push({ phase, ...payload }); + currentState = { + ...currentState, + autoRunning: ['scheduled', 'running', 'waiting_step', 'waiting_email', 'retrying', 'waiting_interval'].includes(phase), + autoRunPhase: phase, + autoRunCurrentRun: payload.currentRun ?? runtime.state.autoRunCurrentRun, + autoRunTotalRuns: payload.totalRuns ?? runtime.state.autoRunTotalRuns, + autoRunAttemptRun: payload.attemptRun ?? runtime.state.autoRunAttemptRun, + autoRunSessionId: payload.sessionId ?? runtime.state.autoRunSessionId, + }; + }, + broadcastStopToContentScripts: async () => {}, + cancelPendingCommands: () => {}, + clearStopRequest: () => {}, + createAutoRunSessionId: () => { + sessionSeed += 1; + return sessionSeed; + }, + getAutoRunStatusPayload: (phase, payload = {}) => ({ + autoRunning: ['scheduled', 'running', 'waiting_step', 'waiting_email', 'retrying', 'waiting_interval'].includes(phase), + autoRunPhase: phase, + autoRunCurrentRun: payload.currentRun ?? 0, + autoRunTotalRuns: payload.totalRuns ?? 1, + autoRunAttemptRun: payload.attemptRun ?? 0, + autoRunSessionId: payload.sessionId ?? 0, + }), + getErrorMessage: (error) => error?.message || String(error || ''), + getFirstUnfinishedStep: () => 1, + getPendingAutoRunTimerPlan: () => null, + getRunningSteps: () => [], + getState: async () => ({ + ...currentState, + stepStatuses: { ...(currentState.stepStatuses || {}) }, + tabRegistry: { ...(currentState.tabRegistry || {}) }, + sourceLastUrls: { ...(currentState.sourceLastUrls || {}) }, + }), + getStopRequested: () => false, + hasSavedProgress: () => false, + isAddPhoneAuthFailure: (error) => /add-phone|手机号页面|手机号页|手机号码|手机号/i.test(error?.message || String(error || '')), + isRestartCurrentAttemptError: () => false, + isStopError: (error) => (error?.message || String(error || '')) === '流程已被用户停止。', + launchAutoRunTimerPlan: async () => false, + normalizeAutoRunFallbackThreadIntervalMinutes: (value) => Math.max(0, Math.floor(Number(value) || 0)), + persistAutoRunTimerPlan: async () => ({}), + resetState: async () => { + currentState = { + ...currentState, + stepStatuses: {}, + tabRegistry: {}, + sourceLastUrls: {}, + }; + }, + runAutoSequenceFromStep: async () => { + events.runCalls += 1; + throw new Error('步骤 8:验证码提交后页面进入手机号页面,当前流程无法继续自动授权。 URL: https://auth.openai.com/add-phone'); + }, + runtime, + setState: async (updates = {}) => { + currentState = { + ...currentState, + ...updates, + stepStatuses: updates.stepStatuses ? { ...updates.stepStatuses } : currentState.stepStatuses, + tabRegistry: updates.tabRegistry ? { ...updates.tabRegistry } : currentState.tabRegistry, + sourceLastUrls: updates.sourceLastUrls ? { ...updates.sourceLastUrls } : currentState.sourceLastUrls, + }; + }, + sleepWithStop: async () => {}, + throwIfAutoRunSessionStopped: (sessionId) => { + if (sessionId && sessionId !== runtime.state.autoRunSessionId) { + throw new Error('流程已被用户停止。'); + } + }, + waitForRunningStepsToFinish: async () => currentState, + chrome: { + runtime: { + sendMessage() { + return Promise.resolve(); + }, + }, + }, + }); + + await controller.autoRunLoop(1, { + autoRunSkipFailures: true, + mode: 'restart', + }); + + assert.equal(events.runCalls, 1, 'add-phone fatal failure should stop before the next auto attempt starts'); + assert.equal(events.broadcasts.some(({ phase }) => phase === 'retrying'), false, 'add-phone fatal failure should not enter retrying phase'); + assert.equal(events.accountRecords.length, 1, 'fatal add-phone should still persist a failed round record'); + assert.equal(events.accountRecords[0].status, 'failed'); + assert.match(events.accountRecords[0].reason, /add-phone/); + assert.ok(events.logs.some(({ message }) => /add-phone\/手机号页/.test(message))); + assert.equal(runtime.state.autoRunActive, false); + assert.equal(runtime.state.autoRunSessionId, 0); +}); diff --git a/tests/auto-run-step6-restart.test.js b/tests/auto-run-step6-restart.test.js index 0cde267..b643b0f 100644 --- a/tests/auto-run-step6-restart.test.js +++ b/tests/auto-run-step6-restart.test.js @@ -53,6 +53,7 @@ function extractFunction(name) { } const bundle = [ + extractFunction('isAddPhoneAuthFailure'), extractFunction('isAddPhoneAuthUrl'), extractFunction('isAddPhoneAuthState'), extractFunction('getPostStep6AutoRestartDecision'), @@ -198,6 +199,22 @@ test('auto-run stops restarting once add-phone is detected', async () => { assert.ok(result.events.logs.some(({ message }) => /进入 add-phone/.test(message))); }); +test('auto-run stops restarting on generic phone-page failure messages even without add-phone url', async () => { + const harness = createHarness({ + failureStep: 9, + failureBudget: 1, + failureMessage: '步骤 8:当前认证页进入手机号页面,当前流程无法继续自动授权。', + authState: { state: 'password_page', url: 'https://auth.openai.com/log-in' }, + }); + + const result = await harness.runAndCaptureError(); + + assert.ok(result?.error); + assert.equal(result.events.invalidations.length, 0); + assert.deepStrictEqual(result.events.steps, [7, 8, 9]); + assert.ok(!result.events.logs.some(({ message }) => /回到步骤 7 重新开始授权流程/.test(message))); +}); + test('auto-run stop errors after step 7 are rethrown immediately instead of restarting', async () => { const harness = createHarness({ failureStep: 9, diff --git a/tests/background-step7-recovery.test.js b/tests/background-step7-recovery.test.js index 4a1c3a9..75b39fe 100644 --- a/tests/background-step7-recovery.test.js +++ b/tests/background-step7-recovery.test.js @@ -139,3 +139,65 @@ test('step 8 disables resend interval for 2925 mailbox polling', async () => { assert.equal(capturedOptions.beforeSubmit, undefined); assert.equal(typeof capturedOptions.getRemainingTimeMs, 'function'); }); + +test('step 8 does not rerun step 7 when verification submit lands on add-phone', async () => { + const calls = { + executeStep7: 0, + logs: [], + }; + + const executor = api.createStep8Executor({ + addLog: async (message, level = 'info') => { + calls.logs.push({ message, level }); + }, + chrome: { + tabs: { + update: async () => {}, + }, + }, + CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email', + confirmCustomVerificationStepBypass: async () => {}, + ensureStep8VerificationPageReady: async () => ({ state: 'verification_page' }), + executeStep7: async () => { + calls.executeStep7 += 1; + }, + getOAuthFlowRemainingMs: async () => 8000, + getOAuthFlowStepTimeoutMs: async (defaultTimeoutMs) => Math.min(defaultTimeoutMs, 8000), + getMailConfig: () => ({ + provider: 'qq', + label: 'QQ 邮箱', + source: 'mail-qq', + url: 'https://mail.qq.com', + navigateOnReuse: false, + }), + getState: async () => ({ email: 'user@example.com', password: 'secret' }), + getTabId: async (sourceName) => (sourceName === 'signup-page' ? 1 : 2), + HOTMAIL_PROVIDER: 'hotmail-api', + isTabAlive: async () => true, + isVerificationMailPollingError: () => false, + LUCKMAIL_PROVIDER: 'luckmail-api', + resolveVerificationStep: async () => { + throw new Error('步骤 8:验证码提交后页面进入手机号页面,当前流程无法继续自动授权。 URL: https://auth.openai.com/add-phone'); + }, + reuseOrCreateTab: async () => {}, + setState: async () => {}, + setStepStatus: async () => {}, + shouldUseCustomRegistrationEmail: () => false, + sleepWithStop: async () => {}, + STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS: 25000, + STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS: 8, + throwIfStopped: () => {}, + }); + + await assert.rejects( + () => executor.executeStep8({ + email: 'user@example.com', + password: 'secret', + oauthUrl: 'https://oauth.example/latest', + }), + /add-phone/ + ); + + assert.equal(calls.executeStep7, 0); + assert.ok(!calls.logs.some(({ message }) => /准备从步骤 7 重新开始/.test(message))); +}); diff --git a/tests/verification-flow-polling.test.js b/tests/verification-flow-polling.test.js index 7a279da..4769263 100644 --- a/tests/verification-flow-polling.test.js +++ b/tests/verification-flow-polling.test.js @@ -109,6 +109,71 @@ test('verification flow runs beforeSubmit hook before filling the code', async ( ]); }); +test('verification flow treats add-phone after login code submit as fatal instead of completing step 8', async () => { + const events = []; + + const helpers = api.createVerificationFlowHelpers({ + addLog: async () => {}, + chrome: { + tabs: { + update: async () => {}, + }, + }, + CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email', + completeStepFromBackground: async (_step, payload) => { + events.push(['complete', payload.code]); + }, + confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }), + getHotmailVerificationPollConfig: () => ({}), + getHotmailVerificationRequestTimestamp: () => 0, + getState: async () => ({}), + getTabId: async () => 1, + HOTMAIL_PROVIDER: 'hotmail-api', + isStopError: () => false, + LUCKMAIL_PROVIDER: 'luckmail-api', + MAIL_2925_VERIFICATION_INTERVAL_MS: 15000, + MAIL_2925_VERIFICATION_MAX_ATTEMPTS: 15, + pollCloudflareTempEmailVerificationCode: async () => ({}), + pollHotmailVerificationCode: async () => ({}), + pollLuckmailVerificationCode: async () => ({}), + sendToContentScript: async (_source, message) => { + if (message.type === 'FILL_CODE') { + events.push(['submit', message.payload.code]); + return { + addPhonePage: true, + url: 'https://auth.openai.com/add-phone', + }; + } + return {}; + }, + sendToMailContentScriptResilient: async () => ({ + code: '654321', + emailTimestamp: 123, + }), + setState: async (payload) => { + events.push(['state', payload.lastLoginCode || payload.lastSignupCode]); + }, + setStepStatus: async () => {}, + sleepWithStop: async () => {}, + throwIfStopped: () => {}, + VERIFICATION_POLL_MAX_ROUNDS: 5, + }); + + await assert.rejects( + () => helpers.resolveVerificationStep( + 8, + { email: 'user@example.com', lastLoginCode: null }, + { provider: 'qq', label: 'QQ 邮箱' }, + {} + ), + /验证码提交后页面进入手机号页面/ + ); + + assert.deepStrictEqual(events, [ + ['submit', '654321'], + ]); +}); + test('verification flow caps mail polling timeout to the remaining oauth budget', async () => { const mailPollCalls = [];