Forráskód Böngészése

feat: 添加手机号认证失败处理逻辑,确保在遇到手机号页面时自动停止授权流程

祁连海 4 hónapja
szülő
commit
e22c36328b

+ 16 - 1
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());

+ 26 - 1
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)) {

+ 6 - 0
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,

+ 4 - 5
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。');

+ 5 - 0
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,

+ 3 - 3
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');
   }

+ 167 - 0
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);
+});

+ 17 - 0
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,

+ 62 - 0
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)));
+});

+ 65 - 0
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 = [];