From 8f15b79a842e43dc2010c5fa3314900df702e976 Mon Sep 17 00:00:00 2001 From: deploy Date: Fri, 21 Aug 2026 21:50:18 +0800 Subject: [PATCH] feat(volcengine): phone/SMS auto-login for console with MFA + identity selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Session-based headless login service (volcengineConsoleAutoLogin) - API: POST /connect {phone} + /code /status /cancel /resend /identity sub-routes - Dashboard modal: phone → SMS code → MFA step-up → identity selection - Falls back to the legacy headful manual flow on risk-control/TOTP-binding - Route guard: connect subtree stays LOCAL_ONLY + spawn-capable --- .../services/volcengineConsoleAutoLogin.ts | 979 ++++++++++++++++++ .../[id]/ProviderDetailPageClient.tsx | 20 +- .../components/VolcengineConnectModal.tsx | 591 +++++++++++ .../connect/[sessionId]/cancel/route.ts | 31 + .../connect/[sessionId]/code/route.ts | 63 ++ .../connect/[sessionId]/identity/route.ts | 67 ++ .../connect/[sessionId]/resend/route.ts | 31 + .../connect/[sessionId]/status/route.ts | 43 + .../volcengine-plan/connect/route.ts | 20 + src/server/authz/routeGuard.ts | 2 +- src/shared/constants/spawnCapablePrefixes.ts | 2 +- .../volcengine-console-auto-login.test.ts | 802 ++++++++++++++ 12 files changed, 2648 insertions(+), 3 deletions(-) create mode 100644 open-sse/services/volcengineConsoleAutoLogin.ts create mode 100644 src/app/(dashboard)/dashboard/providers/[id]/components/VolcengineConnectModal.tsx create mode 100644 src/app/api/providers/volcengine-plan/connect/[sessionId]/cancel/route.ts create mode 100644 src/app/api/providers/volcengine-plan/connect/[sessionId]/code/route.ts create mode 100644 src/app/api/providers/volcengine-plan/connect/[sessionId]/identity/route.ts create mode 100644 src/app/api/providers/volcengine-plan/connect/[sessionId]/resend/route.ts create mode 100644 src/app/api/providers/volcengine-plan/connect/[sessionId]/status/route.ts create mode 100644 tests/unit/services/volcengine-console-auto-login.test.ts diff --git a/open-sse/services/volcengineConsoleAutoLogin.ts b/open-sse/services/volcengineConsoleAutoLogin.ts new file mode 100644 index 0000000000..6048cbb63f --- /dev/null +++ b/open-sse/services/volcengineConsoleAutoLogin.ts @@ -0,0 +1,979 @@ +/** + * VolcengineConsoleAutoLogin — session-based phone/SMS-code login for the + * Volcano Engine console. + * + * Unlike InAppLoginService (which opens a headful browser and requires the + * operator to complete login inside a browser on the server machine), this + * service drives a headless Chromium through the console's 手机号登录 (phone + + * SMS verification code) flow: + * + * 1. startLogin(phone) — navigate to the login page, switch to the phone + * tab, fill the phone number, click 获取验证码. If the console demands an + * image captcha, a screenshot is captured for the dashboard to render. + * 2. submitCode(code, captcha?) — fill the SMS code (and image captcha when + * requested), click 登录 / 注册, then poll the browser context for the + * console session cookies (digest / AccountID / csrfToken / userInfo). + * 3. cancel() / resendCode() — lifecycle helpers. + * + * The service only extracts credentials; persisting/binding them to provider + * connections stays in the dashboard API layer (volcenginePlanBinding.ts). + * + * Selector strategy: the console login page is built with Arco Design and + * exposes stable element ids (#Tel_input, #Code_input, #VerificatonCodeInput). + * Every interaction goes through multi-candidate selector lists so a single + * frontend rename does not break the flow. When a candidate list misses or + * risk-control (slider) is detected, the session degrades to + * `fallback_manual` and the caller can fall back to the pre-existing + * headful-browser flow. + */ + +import { randomUUID } from "crypto"; + +// ─── Public types ─────────────────────────────────────────────────────────── + +export type VolcLoginPhase = + | "starting" + | "sending_code" + | "waiting_code" + | "captcha_required" + | "submitting" + | "mfa_waiting" + | "identity_required" + | "success" + | "error" + | "timeout" + | "cancelled" + | "fallback_manual"; + +export interface VolcLoginSessionView { + sessionId: string; + phase: VolcLoginPhase; + phoneMasked: string; + error: string | null; + /** data:image/png;base64 screenshot of the image captcha, when required */ + captchaImage: string | null; + /** epoch ms — earliest time a resend should be offered */ + resendAvailableAt: number; + createdAt: number; + updatedAt: number; + /** True while the console demands an MFA step-up code (second SMS code) */ + mfaRequired?: boolean; + /** Identity options scraped from /auth/login/select_identity, when required */ + identityOptions?: Array<{ index: number; label: string }>; + /** Credentials (console cookies) — only present after success */ + credentials?: Record; + /** Set by the API layer after binding plans (not part of this service) */ + binding?: unknown; +} + +export interface StartOptions { + /** Total session timeout in ms (default 300_000) */ + timeout?: number; +} + +export interface SubmitCodeOptions { + /** Extra wait for cookie polling after submit (default 90_000) */ + timeout?: number; +} + +/** Injectable delays — tests shrink these to keep the suite fast. */ +export interface ServiceDelays { + pageSettleMs?: number; + tabSwitchMs?: number; + sendCodeSettleMs?: number; + pollIntervalMs?: number; + resendCooldownMs?: number; +} + +// ─── Config ───────────────────────────────────────────────────────────────── + +const LOGIN_URL = "https://console.volcengine.com/auth/login"; +/** Landing page the manual headful flow uses — the console app issues the + * remaining session cookies (AccountID/userInfo) once it runs. */ +const ARK_CONSOLE_URL = + "https://console.volcengine.com/ark/region:cn-beijing/subscription/coding-plan"; + +/** Cookie names required for a valid console session (mirrors tokenExtractionConfig) */ +const REQUIRED_COOKIES = ["digest", "AccountID", "csrfToken", "userInfo"] as const; + +const DEFAULT_SESSION_TIMEOUT = 300_000; +const SUBMIT_COOKIE_TIMEOUT = 90_000; +const CAPTURE_POLL_INTERVAL = 1_000; +const RESEND_COOLDOWN_MS = 60_000; +const MAX_ACTIVE_SESSIONS = 2; + +/** Multi-candidate selectors — first visible candidate wins. */ +const SELECTORS = { + phoneTab: ['.arco-tabs-header-title:has-text("手机号登录")', "text=手机号登录"], + phoneInput: ["#Tel_input", 'input[name="Tel"]', 'input[placeholder*="手机号"]'], + smsCodeInput: ["#Code_input", 'input[placeholder*="请输入验证码"]'], + sendCodeButton: ['button:has-text("获取验证码")', "text=获取验证码"], + loginButton: ['button:has-text("登录 / 注册")', 'button:has-text("登录")'], + imageCaptchaInput: ["#VerificatonCodeInput", "input.verify-input"], + captchaShot: [".arco-modal", '[class*="captcha"]', '[class*="verify"]'], + /** Risk-control slider / popup heuristics */ + riskControl: [ + '[class*="secsdk-captcha"]', + "#captcha_popup", + '[class*="captcha-slider"]', + '[class*="drag"] [class*="slider"]', + ], + /** MFA step-up modal (需要额外认证): a SECOND 6-digit SMS code is required */ + mfaModal: ['.arco-modal:has-text("需要额外认证")', "text=需要额外认证"], + mfaInput: ["#VerificatonCodeInput", ".arco-modal input.verify-input", ".arco-modal input"], + mfaConfirmButton: ['button:has-text("好的")', '.arco-modal button:has-text("确定")'], + mfaResendButton: ['button:has-text("重发校验码")'], + /** TOTP binding modal (绑定MFA设备) — needs interactive Google Authenticator setup */ + mfaBindModal: ['.arco-modal:has-text("绑定MFA设备")'], + /** Identity selection page (/auth/login/select_identity) — the phone maps to + * multiple accounts; the user must pick which identity to log in as. + * Structure verified against the real auth bundle (vconsole-auth 1.0.0.2837, + * module 12173 + chunk 202): ul[class*=accountUl] > li[class*=accountLi] > + * div[class*=item] (click target) with the identity text in [class*=identity]; + * submit is button[type=submit] ("登录") inside [class*=selectPlatformIdentity]. + * .arco-list-item is kept as a fallback for future Arco-based redesigns. */ + identityList: ['ul[class*="accountUl"] li[class*="accountLi"]', ".arco-list-item"], + identityItem: ['li[class*="accountLi"] > [class*="item"]', ".arco-list-item"], + identitySubmitButton: [ + '[class*="selectPlatformIdentity"] button[type="submit"]', + 'button[type="submit"]:has-text("登录")', + 'button:has-text("登录")', + ], +} as const; + +/** URL marker for the console's identity-selection page */ +const IDENTITY_URL_PATTERN = /\/auth\/login\/select_identity/i; + +const BROWSER_CONTEXT_OPTIONS = { + locale: "zh-CN", + timezoneId: "Asia/Shanghai", + viewport: { width: 1280, height: 800 }, + userAgent: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", +}; + +// ─── Minimal playwright structural types ────────────────────────────────── +// Playwright is an optional runtime dep (dynamically imported), so we model +// only the API surface this service drives instead of importing its types. + +interface PwLocator { + first(): PwLocator; + isVisible(options?: { timeout?: number }): Promise; + click(options?: unknown): Promise; + fill(value: string): Promise; + isDisabled(): Promise; + screenshot(options?: { type?: string }): Promise; + textContent(options?: { timeout?: number }): Promise; + count(): Promise; + nth(index: number): PwLocator; +} + +interface PwPage { + setDefaultTimeout(timeout: number): void; + goto(url: string, options?: { waitUntil?: string; timeout?: number }): Promise; + locator(selector: string): PwLocator; + screenshot(options?: { type?: string }): Promise; + url(): string; + content(): Promise; +} + +interface PwContext { + newPage(): Promise; + cookies(): Promise>; +} + +interface PwBrowser { + newContext(options?: Record): Promise; + close(): Promise; +} + +interface PwModule { + chromium: { + launch(options?: { headless?: boolean; args?: string[]; channel?: string }): Promise; + }; +} + +// ─── Session record (internal) ────────────────────────────────────────────── + +interface ActiveSession { + sessionId: string; + phone: string; + phase: VolcLoginPhase; + error: string | null; + captchaImage: string | null; + resendAvailableAt: number; + createdAt: number; + updatedAt: number; + timeoutMs: number; + credentials: Record | null; + /** Binding outcome set by the API layer via withBinding() */ + binding?: unknown; + cancelled: boolean; + /** Identity options scraped from the select_identity page */ + identityOptions: Array<{ index: number; label: string }> | null; + // Playwright handles — never serialized + browser: PwBrowser | null; + context: PwContext | null; + page: PwPage | null; +} + +export function maskPhone(phone: string): string { + if (phone.length < 7) return "***"; + return `${phone.slice(0, 3)}****${phone.slice(-4)}`; +} + +/** Normalize a CN mobile number: strip +86/86 prefix, spaces, dashes. */ +export function normalizePhone(raw: string): string | null { + const trimmed = String(raw || "") + .trim() + .replace(/[\s-]/g, ""); + const bare = trimmed.replace(/^\+?86/, ""); + return /^1\d{10}$/.test(bare) ? bare : null; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +// ─── Service ──────────────────────────────────────────────────────────────── + +export class VolcengineConsoleAutoLoginService { + private sessions = new Map(); + /** sessionId → bind promise set by the API layer to dedupe lazy binding */ + private bindInFlight = new Map>(); + /** Injectable for tests — resolves the playwright module instead of `import("playwright")`. */ + private readonly loadPlaywright: () => Promise; + private readonly delays: Required; + + constructor( + loadPlaywright: () => Promise = async () => import("playwright"), + delays: ServiceDelays = {} + ) { + this.loadPlaywright = loadPlaywright; + this.delays = { + pageSettleMs: delays.pageSettleMs ?? 2_500, + tabSwitchMs: delays.tabSwitchMs ?? 1_000, + sendCodeSettleMs: delays.sendCodeSettleMs ?? 2_000, + pollIntervalMs: delays.pollIntervalMs ?? CAPTURE_POLL_INTERVAL, + resendCooldownMs: delays.resendCooldownMs ?? RESEND_COOLDOWN_MS, + }; + } + + // ─── Queries ───────────────────────────────────────────────────────────── + + getActiveSessionCount(): number { + let count = 0; + for (const session of this.sessions.values()) { + if (!isTerminal(session.phase)) count++; + } + return count; + } + + getStatus(sessionId: string): VolcLoginSessionView | null { + const session = this.sessions.get(sessionId); + if (!session) return null; + return this.toView(session); + } + + /** + * Lazy binding hook used by the API layer: the route stores a promise here + * so concurrent status polls do not double-bind the same credentials. + */ + async withBinding( + sessionId: string, + bind: (credentials: Record) => Promise + ): Promise { + const session = this.sessions.get(sessionId); + if (!session) return null; + if (session.phase !== "success" || !session.credentials) { + return this.toView(session); + } + if (session.binding !== undefined) return this.toView(session); + + let inFlight = this.bindInFlight.get(sessionId); + if (!inFlight) { + inFlight = bind(session.credentials) + .then((binding: unknown) => { + session.binding = binding; + return binding; + }) + .catch((error: unknown) => { + // Persist the failure so status polls do not retry forever. + session.binding = { error: errorMessage(error) }; + return session.binding; + }) + .finally(() => { + this.bindInFlight.delete(sessionId); + }); + this.bindInFlight.set(sessionId, inFlight); + } + await inFlight; + return this.toView(session); + } + + // ─── Lifecycle ─────────────────────────────────────────────────────────── + + async startLogin( + phone: string, + options?: StartOptions + ): Promise<{ ok: true; session: VolcLoginSessionView } | { ok: false; error: string }> { + const normalized = normalizePhone(phone); + if (!normalized) { + return { ok: false, error: "Invalid phone number (expected an 11-digit CN mobile number)" }; + } + + this.expireSessions(); + + for (const session of this.sessions.values()) { + if (session.phone === normalized && !isTerminal(session.phase)) { + await this.cancel(session.sessionId); + } + } + if (this.getActiveSessionCount() >= MAX_ACTIVE_SESSIONS) { + return { ok: false, error: "Too many concurrent Volcano login sessions" }; + } + + let playwright: PwModule; + try { + playwright = await this.loadPlaywright(); + } catch { + return { + ok: false, + error: "Playwright is not installed. Use manual browser login instead.", + }; + } + + const session: ActiveSession = { + sessionId: randomUUID(), + phone: normalized, + phase: "starting", + error: null, + captchaImage: null, + resendAvailableAt: 0, + createdAt: Date.now(), + updatedAt: Date.now(), + timeoutMs: options?.timeout || DEFAULT_SESSION_TIMEOUT, + credentials: null, + cancelled: false, + identityOptions: null, + browser: null, + context: null, + page: null, + }; + this.sessions.set(session.sessionId, session); + + try { + // Prefer the playwright-managed Chromium; fall back to the system Chrome + // channel on machines without `npx playwright install` browsers (dev laptops). + try { + session.browser = await playwright.chromium.launch({ + headless: true, + args: ["--disable-blink-features=AutomationControlled"], + }); + } catch (launchError) { + if (!/Executable doesn't exist/.test(String(launchError))) throw launchError; + session.browser = await playwright.chromium.launch({ + headless: true, + channel: "chrome", + args: ["--disable-blink-features=AutomationControlled"], + }); + } + session.context = await session.browser.newContext(BROWSER_CONTEXT_OPTIONS); + session.page = await session.context.newPage(); + session.page.setDefaultTimeout(15_000); + + await session.page.goto(LOGIN_URL, { waitUntil: "domcontentloaded", timeout: 30_000 }); + await sleep(this.delays.pageSettleMs); + + // Switch to the phone-code login tab + const tab = await this.firstVisible(session.page, SELECTORS.phoneTab); + if (!tab) throw new SelectorMissError("phone tab"); + await tab.click(); + await sleep(this.delays.tabSwitchMs); + + // Fill the phone number + const phoneInput = await this.firstVisible(session.page, SELECTORS.phoneInput); + if (!phoneInput) throw new SelectorMissError("phone input"); + await phoneInput.fill(normalized); + + // Send the SMS code + const sendBtn = await this.firstVisible(session.page, SELECTORS.sendCodeButton); + if (!sendBtn) throw new SelectorMissError("send-code button"); + await sendBtn.click(); + + session.phase = "sending_code"; + session.resendAvailableAt = Date.now() + this.delays.resendCooldownMs; + await sleep(this.delays.sendCodeSettleMs); + + // Risk-control slider → degrade to the manual headful flow + const risk = await this.firstVisible(session.page, SELECTORS.riskControl); + if (risk) { + session.captchaImage = await this.shot(session.page); + session.phase = "fallback_manual"; + session.error = + "Volcano risk control (slider captcha) was triggered in headless mode. Use manual browser login."; + await this.closeBrowser(session); + return { ok: true, session: this.toView(session) }; + } + + // Image captcha may be required before the SMS is sent + const captchaInput = await this.firstVisible(session.page, SELECTORS.imageCaptchaInput); + if (captchaInput) { + session.captchaImage = await this.shot(session.page); + session.phase = "captcha_required"; + } else { + session.phase = "waiting_code"; + } + return { ok: true, session: this.toView(session) }; + } catch (error) { + await this.closeBrowser(session); + session.phase = error instanceof SelectorMissError ? "fallback_manual" : "error"; + session.error = errorMessage(error); + if (session.phase === "fallback_manual") { + session.error = `${session.error}. The login page layout may have changed — use manual browser login.`; + } + return { ok: true, session: this.toView(session) }; + } + } + + async submitCode( + sessionId: string, + code: string, + captcha?: string, + options?: SubmitCodeOptions + ): Promise { + const session = this.sessions.get(sessionId); + if (!session) return null; + const fromMfa = session.phase === "mfa_waiting"; + if (session.phase !== "waiting_code" && session.phase !== "captcha_required" && !fromMfa) { + return this.toView(session); + } + + const smsCode = String(code || "").trim(); + if (!/^\d{4,6}$/.test(smsCode)) { + session.error = "Invalid SMS code"; + return this.toView(session); + } + if (session.phase === "captcha_required" && !String(captcha || "").trim()) { + session.error = "Image captcha is required"; + return this.toView(session); + } + + const page = session.page; + if (!page) { + session.phase = "error"; + session.error = "Browser session is gone — restart the login"; + return this.toView(session); + } + + try { + if (fromMfa) { + // MFA step-up (需要额外认证): fill the SECOND code into the modal + // input and confirm with 好的. + const mfaInput = await this.firstVisible(page, SELECTORS.mfaInput); + if (!mfaInput) throw new SelectorMissError("mfa code input"); + await mfaInput.fill(smsCode); + + const confirmBtn = await this.firstVisible(page, SELECTORS.mfaConfirmButton); + if (!confirmBtn) throw new SelectorMissError("mfa confirm button"); + await confirmBtn.click(); + } else { + const codeInput = await this.firstVisible(page, SELECTORS.smsCodeInput); + if (!codeInput) throw new SelectorMissError("sms code input"); + await codeInput.fill(smsCode); + + if (captcha) { + const captchaInput = await this.firstVisible(page, SELECTORS.imageCaptchaInput); + if (captchaInput) await captchaInput.fill(String(captcha).trim()); + } + + const loginBtn = await this.firstVisible(page, SELECTORS.loginButton); + if (!loginBtn) throw new SelectorMissError("login button"); + await loginBtn.click(); + } + + session.phase = "submitting"; + session.error = null; + session.captchaImage = null; + + return await this.pollUntilResolved(session, { + timeoutMs: options?.timeout || SUBMIT_COOKIE_TIMEOUT, + fromMfa, + detectIdentity: true, + }); + } catch (error) { + session.phase = error instanceof SelectorMissError ? "fallback_manual" : "error"; + session.error = errorMessage(error); + await this.closeBrowser(session); + return this.toView(session); + } + } + + /** + * Pick an identity on the console's /auth/login/select_identity page and + * finish the login. `index` maps to the identityOptions list previously + * returned in the session view. + */ + async selectIdentity( + sessionId: string, + index: number, + options?: SubmitCodeOptions + ): Promise { + const session = this.sessions.get(sessionId); + if (!session) return null; + if (session.phase !== "identity_required") { + return this.toView(session); + } + const page = session.page; + if (!page) { + session.phase = "error"; + session.error = "Browser session is gone — restart the login"; + return this.toView(session); + } + + try { + // Click the requested identity card (the page pre-selects the first one, + // so only non-zero indexes need an explicit click). + if (index > 0) { + const itemSelector = await this.identityItemSelector(page); + if (!itemSelector) throw new SelectorMissError("identity item"); + const items = page.locator(itemSelector); + const count = await items.count(); + if (index < 0 || index >= count) { + session.error = `Identity index ${index} is out of range (${count} options)`; + return this.toView(session); + } + await items.nth(index).click(); + await sleep(this.delays.tabSwitchMs); + } + + // Submit the selection (button[type=submit] “登录” on the identity card) + const submitBtn = await this.firstVisible(page, SELECTORS.identitySubmitButton); + if (!submitBtn) throw new SelectorMissError("identity submit button"); + await submitBtn.click(); + + session.phase = "submitting"; + session.error = null; + session.identityOptions = null; + + return await this.pollUntilResolved(session, { + timeoutMs: options?.timeout || SUBMIT_COOKIE_TIMEOUT, + fromMfa: false, + detectIdentity: false, + }); + } catch (error) { + session.phase = error instanceof SelectorMissError ? "fallback_manual" : "error"; + session.error = errorMessage(error); + await this.closeBrowser(session); + return this.toView(session); + } + } + + /** First clickable identity-item selector that matches at least one element. */ + private async identityItemSelector(page: PwPage): Promise { + for (const selector of SELECTORS.identityItem) { + try { + const count = await page.locator(selector).count(); + if (count > 0) return selector; + } catch { + // try next candidate + } + } + return null; + } + + /** + * Shared post-submit loop: waits for console cookies, watching for MFA + * step-up, identity selection, TOTP binding, and console error toasts. + */ + private async pollUntilResolved( + session: ActiveSession, + opts: { timeoutMs: number; fromMfa: boolean; detectIdentity: boolean } + ): Promise { + const page = session.page; + if (!page) { + session.phase = "error"; + session.error = "Browser session is gone — restart the login"; + return this.toView(session); + } + + const deadline = Date.now() + opts.timeoutMs; + let pollCount = 0; + let navigatedAfterLogin = false; + while (Date.now() < deadline) { + if (session.cancelled) { + session.phase = "cancelled"; + await this.closeBrowser(session); + return this.toView(session); + } + if (Date.now() - session.createdAt > session.timeoutMs) { + session.phase = "timeout"; + session.error = "Login timed out"; + await this.closeBrowser(session); + return this.toView(session); + } + + const cookies = await session.context.cookies(); + const credentials: Record = {}; + for (const cookie of cookies as Array<{ name: string; domain: string; value: string }>) { + if ( + REQUIRED_COOKIES.includes(cookie.name as (typeof REQUIRED_COOKIES)[number]) && + cookie.domain.includes("volcengine.com") + ) { + credentials[cookie.name] = cookie.value; + } + } + if (REQUIRED_COOKIES.every((name) => credentials[name])) { + session.credentials = credentials; + session.phase = "success"; + await this.closeBrowser(session); + return this.toView(session); + } + + // TOTP binding modal (绑定MFA设备) — needs interactive Google + // Authenticator setup that cannot be driven headlessly. + const bindModal = await this.firstVisible(page, SELECTORS.mfaBindModal); + if (bindModal) { + session.phase = "fallback_manual"; + session.error = + "The console requires binding an MFA device (Google Authenticator). Use manual browser login to complete the one-time setup."; + await this.closeBrowser(session); + return this.toView(session); + } + + // MFA step-up modal (需要额外认证) — a second SMS code is required; + // hand control back to the user instead of timing out. + if (!opts.fromMfa) { + const mfaModal = await this.firstVisible(page, SELECTORS.mfaModal); + if (mfaModal) { + session.phase = "mfa_waiting"; + session.error = null; + session.resendAvailableAt = Date.now() + this.delays.resendCooldownMs; + return this.toView(session); + } + } else if (pollCount >= 5) { + // Wrong MFA code → the modal stays up; after a grace window hand + // control back so the user can enter the latest code. + const mfaModal = await this.firstVisible(page, SELECTORS.mfaModal); + if (mfaModal) { + session.phase = "mfa_waiting"; + session.error = "The MFA code was not accepted — enter the latest code"; + session.resendAvailableAt = Date.now() + this.delays.resendCooldownMs; + return this.toView(session); + } + } + + // Identity selection page (/auth/login/select_identity) — the phone + // maps to multiple accounts; scrape the options and let the user pick. + if (opts.detectIdentity && IDENTITY_URL_PATTERN.test(page.url())) { + const options = await this.scrapeIdentityOptions(page); + if (options.length > 0) { + session.phase = "identity_required"; + session.error = null; + session.identityOptions = options; + return this.toView(session); + } + } + + // Login redirected away from /auth/login but cookies are incomplete → + // the console app may need to run once to issue AccountID/userInfo. + // Give it the same landing page the manual flow uses. + if (!navigatedAfterLogin && pollCount >= 2 && !page.url().includes("/auth/login")) { + navigatedAfterLogin = true; + try { + await page.goto(ARK_CONSOLE_URL, { + waitUntil: "domcontentloaded", + timeout: 30_000, + }); + } catch { + // navigation is best-effort; keep polling cookies + } + } + + // Console error toast (e.g. wrong SMS code) → surface it early + const toast = await page + .locator('.arco-message-error, [class*="message-error"]') + .first() + .textContent({ timeout: 250 }) + .catch(() => null); + if (toast && /验证码|密码|错误|失败|频繁/.test(toast)) { + session.phase = "error"; + session.error = toast.trim().slice(0, 120); + await this.closeBrowser(session); + return this.toView(session); + } + + await sleep(this.delays.pollIntervalMs); + pollCount++; + } + + session.phase = "timeout"; + session.error = await this.timeoutDiagnostics(session); + await this.closeBrowser(session); + return this.toView(session); + } + + /** First identity-list selector that matches at least one element. */ + private async identityListSelector(page: PwPage): Promise { + for (const selector of SELECTORS.identityList) { + try { + const count = await page.locator(selector).count(); + if (count > 0) return selector; + } catch { + // try next candidate + } + } + return null; + } + + /** Scrape identity options from the select_identity page, in document order. */ + private async scrapeIdentityOptions( + page: PwPage + ): Promise> { + const selector = await this.identityListSelector(page); + if (!selector) return []; + const items = page.locator(selector); + const count = await items.count(); + const options: Array<{ index: number; label: string }> = []; + for (let i = 0; i < count; i++) { + const text = + (await items + .nth(i) + .textContent() + .catch(() => "")) || ""; + const label = text.replace(/\s+/g, " ").trim(); + if (label) options.push({ index: i, label: label.slice(0, 100) }); + } + return options; + } + + /** + * Build a diagnostic message for the cookie-poll timeout: page URL, cookies + * collected so far, and any blocking modal. Keeps future debugging cheap. + * When stuck on the identity-selection page, also dumps the page HTML to + * /tmp so a selector miss can be fixed from ground truth in one shot. + */ + private async timeoutDiagnostics(session: ActiveSession): Promise { + const parts = ["Timed out waiting for the console session cookies"]; + try { + if (session.page) { + parts.push(`url=${session.page.url()}`); + const cookies = (await session.context.cookies()) as Array<{ + name: string; + domain: string; + }>; + const present = REQUIRED_COOKIES.filter((name) => + cookies.some((c) => c.name === name && c.domain.includes("volcengine.com")) + ); + parts.push( + `cookies=[${present.join(",") || "none of digest/AccountID/csrfToken/userInfo"}]` + ); + const bindModal = await this.firstVisible(session.page, SELECTORS.mfaBindModal); + if (bindModal) parts.push("blocked by 绑定MFA设备 modal"); + const mfaModal = await this.firstVisible(session.page, SELECTORS.mfaModal); + if (mfaModal) parts.push("blocked by 需要额外认证 modal"); + const risk = await this.firstVisible(session.page, SELECTORS.riskControl); + if (risk) parts.push("blocked by risk-control slider"); + if (IDENTITY_URL_PATTERN.test(session.page.url())) { + const dump = await this.dumpPageHtml(session); + if (dump) parts.push(`identityPageHtml=${dump}`); + } + } + } catch { + // diagnostics are best-effort + } + return parts.join(" · "); + } + + /** Best-effort page HTML dump for debugging selector misses. */ + private async dumpPageHtml(session: ActiveSession): Promise { + try { + const { writeFile } = await import("fs/promises"); + const path = `/tmp/omniroute-volc-select-identity-${session.sessionId.slice(0, 8)}.html`; + await writeFile(path, await session.page.content(), "utf8"); + return path; + } catch { + return null; + } + } + + async resendCode(sessionId: string): Promise { + const session = this.sessions.get(sessionId); + if (!session) return null; + const fromMfa = session.phase === "mfa_waiting"; + if (session.phase !== "waiting_code" && session.phase !== "captcha_required" && !fromMfa) { + return this.toView(session); + } + if (Date.now() < session.resendAvailableAt) { + return this.toView(session); + } + const page = session.page; + if (!page) { + session.phase = "error"; + session.error = "Browser session is gone — restart the login"; + return this.toView(session); + } + + try { + // In the MFA step-up modal the button is 重发校验码; on the login form + // it counts down ("60s后重发" etc.) — try the fresh label first, then + // any 重发/重新获取 variant. + const resendSelectors = fromMfa + ? [...SELECTORS.mfaResendButton] + : [ + 'button:has-text("获取验证码")', + 'button:has-text("重发")', + 'button:has-text("重新获取")', + 'button:has-text("重新发送")', + ]; + const btn = await this.firstVisible(page, resendSelectors); + if (!btn) throw new SelectorMissError("resend button"); + const disabled = await btn.isDisabled().catch(() => false); + if (disabled) { + session.error = "Resend is still cooling down on the login page"; + return this.toView(session); + } + await btn.click(); + session.resendAvailableAt = Date.now() + this.delays.resendCooldownMs; + await sleep(this.delays.sendCodeSettleMs); + + if (fromMfa) { + // Stay in mfa_waiting — the modal persists until a valid code lands. + session.phase = "mfa_waiting"; + session.error = null; + return this.toView(session); + } + + const captchaInput = await this.firstVisible(page, SELECTORS.imageCaptchaInput); + if (captchaInput) { + session.captchaImage = await this.shot(page); + session.phase = "captcha_required"; + } else { + session.captchaImage = null; + session.phase = "waiting_code"; + } + session.error = null; + return this.toView(session); + } catch (error) { + session.phase = "error"; + session.error = errorMessage(error); + await this.closeBrowser(session); + return this.toView(session); + } + } + + async cancel(sessionId: string): Promise { + const session = this.sessions.get(sessionId); + if (!session) return null; + if (isTerminal(session.phase)) return this.toView(session); + session.cancelled = true; + session.phase = "cancelled"; + await this.closeBrowser(session); + return this.toView(session); + } + + // ─── Internals ─────────────────────────────────────────────────────────── + + private toView(session: ActiveSession): VolcLoginSessionView { + const view: VolcLoginSessionView = { + sessionId: session.sessionId, + phase: session.phase, + phoneMasked: maskPhone(session.phone), + error: session.error, + captchaImage: session.phase === "captcha_required" ? session.captchaImage : null, + resendAvailableAt: session.resendAvailableAt, + createdAt: session.createdAt, + updatedAt: session.updatedAt, + }; + if (session.phase === "mfa_waiting") view.mfaRequired = true; + if (session.phase === "identity_required" && session.identityOptions) { + view.identityOptions = session.identityOptions; + } + if (session.phase === "success" && session.credentials) view.credentials = session.credentials; + if (session.binding !== undefined) view.binding = session.binding; + return view; + } + + private async closeBrowser(session: ActiveSession): Promise { + try { + await session.browser?.close?.(); + } catch { + // browser may already be gone + } finally { + session.browser = null; + session.context = null; + session.page = null; + } + } + + /** Screenshot for captcha rendering; null when capture fails. */ + private async shot(page: PwPage): Promise { + try { + const target = await this.firstVisible(page, SELECTORS.captchaShot); + const buffer: Buffer | null = target + ? await target.screenshot({ type: "png" }) + : await page.screenshot({ type: "png" }); + return buffer ? `data:image/png;base64,${buffer.toString("base64")}` : null; + } catch { + return null; + } + } + + private async firstVisible( + page: PwPage, + selectors: readonly string[] + ): Promise { + for (const selector of selectors) { + try { + const locator = page.locator(selector).first(); + if (await locator.isVisible({ timeout: 2_000 })) return locator; + } catch { + // try next candidate + } + } + return null; + } + + /** Close and drop sessions past their TTL; keep terminal ones briefly for status reads. */ + private expireSessions(): void { + const now = Date.now(); + for (const [id, session] of this.sessions) { + const age = now - session.createdAt; + const terminal = isTerminal(session.phase); + if (terminal && age > 10 * 60_000) { + this.sessions.delete(id); + } else if (!terminal && age > session.timeoutMs + 60_000) { + session.phase = "timeout"; + session.error = "Session expired"; + void this.closeBrowser(session); + this.sessions.delete(id); + } + } + } +} + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +class SelectorMissError extends Error { + constructor(element: string) { + super(`Login page element not found: ${element}`); + } +} + +function isTerminal(phase: VolcLoginPhase): boolean { + return ( + phase === "success" || + phase === "error" || + phase === "timeout" || + phase === "cancelled" || + phase === "fallback_manual" + ); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +// ─── Singleton ────────────────────────────────────────────────────────────── + +export const volcengineConsoleAutoLoginService = new VolcengineConsoleAutoLoginService(); diff --git a/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx b/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx index 9ada0a2df7..d5efb6bd45 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx @@ -57,6 +57,7 @@ import CustomModelsSection from "./components/CustomModelsSection"; import ConnectionsListPanel from "./components/ConnectionsListPanel"; import CoolingConnectionsPanel from "./components/CoolingConnectionsPanel"; import ConnectionsHeaderToolbar from "./components/ConnectionsHeaderToolbar"; +import VolcengineConnectModal from "./components/VolcengineConnectModal"; import ProviderAccountRoutingCard from "../../settings/components/ProviderAccountRoutingCard"; import ZedImportCard from "./components/ZedImportCard"; import CursorAgentNudge from "./components/CursorAgentNudge"; @@ -79,6 +80,7 @@ export default function ProviderDetailPageClient() { const [showOAuthModal, _setShowOAuthModal] = useState(false); const [reauthConnection, setReauthConnection] = useState(null); const [showKimiAuthMethodModal, setShowKimiAuthMethodModal] = useState(false); + const [showVolcengineConnectModal, setShowVolcengineConnectModal] = useState(false); const [showAddApiKeyModal, setShowAddApiKeyModal] = useState(false); const [showSiliconFlowEndpointModal, setShowSiliconFlowEndpointModal] = useState(false); const [siliconFlowInitialBaseUrl, setSiliconFlowInitialBaseUrl] = useState(); @@ -382,7 +384,9 @@ export default function ProviderDetailPageClient() { openApiKeyAddFlow(); }, [providerId, isOAuth, openApiKeyAddFlow]); - const connectVolcengineAccount = useCallback(async () => { + // Legacy manual flow: headful browser login on the machine running OmniRoute. + // Kept as the fallback for the phone/SMS auto-login modal. + const connectVolcengineAccountManually = useCallback(async () => { setConnectingVolcengineAccount(true); try { const response = await fetch("/api/providers/volcengine-plan/connect", { @@ -413,6 +417,10 @@ export default function ProviderDetailPageClient() { } }, [fetchConnections, notify]); + const connectVolcengineAccount = useCallback(() => { + setShowVolcengineConnectModal(true); + }, []); + const { commandCodeAuthState, handleCloseAddApiKeyModal, @@ -902,6 +910,16 @@ export default function ProviderDetailPageClient() { setShowTutorialModal={setShowTutorialModal} t={t} /> + + {/* Volcano Engine console phone/SMS auto-login (falls back to manual browser login) */} + setShowVolcengineConnectModal(false)} + onFallbackManual={connectVolcengineAccountManually} + onConnected={fetchConnections} + notify={notify} + t={t} + /> ); } diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/VolcengineConnectModal.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/VolcengineConnectModal.tsx new file mode 100644 index 0000000000..55c6d22cbb --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/VolcengineConnectModal.tsx @@ -0,0 +1,591 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { Button, Input, Modal } from "@/shared/components"; +import { providerText, type ProviderMessageTranslator } from "../providerPageHelpers"; + +/** + * VolcengineConnectModal — phone/SMS-code login for the Volcano Engine console. + * + * Drives the session-based auto login API: + * POST /api/providers/volcengine-plan/connect {phone} + * POST /api/providers/volcengine-plan/connect/{id}/code {code, captcha?} + * GET /api/providers/volcengine-plan/connect/{id}/status + * POST /api/providers/volcengine-plan/connect/{id}/resend + * POST /api/providers/volcengine-plan/connect/{id}/cancel + * + * Falls back to the legacy manual headful-browser flow (same POST /connect + * endpoint without a phone) when risk control or a layout change degrades + * the headless session. + */ + +type SessionPhase = + | "starting" + | "sending_code" + | "waiting_code" + | "captcha_required" + | "submitting" + | "mfa_waiting" + | "identity_required" + | "success" + | "error" + | "timeout" + | "cancelled" + | "fallback_manual"; + +interface SessionView { + sessionId: string; + phase: SessionPhase; + phoneMasked: string; + error: string | null; + captchaImage: string | null; + resendAvailableAt: number; + mfaRequired?: boolean; + identityOptions?: Array<{ index: number; label: string }>; + binding?: { + results?: Array<{ + plan: string; + available: boolean; + ok: boolean; + error?: string | null; + }>; + error?: string; + }; +} + +const PHONE_STORAGE_KEY = "omniroute.volcengine.phone"; +const TERMINAL_PHASES: SessionPhase[] = [ + "success", + "error", + "timeout", + "cancelled", + "fallback_manual", +]; + +function isTerminal(phase: SessionPhase | undefined): boolean { + return !!phase && TERMINAL_PHASES.includes(phase); +} + +type VolcengineConnectModalProps = { + isOpen: boolean; + onClose: () => void; + /** Legacy headful-browser login (opens on the server machine) */ + onFallbackManual: () => void; + /** Refresh connections after a successful bind */ + onConnected: () => void | Promise; + notify: { + success: (message: string, title?: string) => void; + error: (message: string, title?: string) => void; + }; + t: ProviderMessageTranslator; +}; + +export default function VolcengineConnectModal({ + isOpen, + onClose, + onFallbackManual, + onConnected, + notify, + t, +}: VolcengineConnectModalProps) { + const [phone, setPhone] = useState(""); + const [code, setCode] = useState(""); + const [captcha, setCaptcha] = useState(""); + const [session, setSession] = useState(null); + const [starting, setStarting] = useState(false); + const [submittingCode, setSubmittingCode] = useState(false); + const [resending, setResending] = useState(false); + const [selectingIdentity, setSelectingIdentity] = useState(false); + const [resendCountdown, setResendCountdown] = useState(0); + + const pollTimer = useRef | null>(null); + + // ── lifecycle ──────────────────────────────────────────────────────────── + + const stopTimers = useCallback(() => { + if (pollTimer.current) { + clearInterval(pollTimer.current); + pollTimer.current = null; + } + }, []); + + const reset = useCallback(() => { + stopTimers(); + setSession(null); + setCode(""); + setCaptcha(""); + setResendCountdown(0); + }, [stopTimers]); + + useEffect(() => { + if (!isOpen) { + // Leaving the modal cancels an in-flight session server-side. + const active = session && !isTerminal(session.phase) ? session : null; + if (active) { + void fetch(`/api/providers/volcengine-plan/connect/${active.sessionId}/cancel`, { + method: "POST", + }).catch(() => {}); + } + reset(); + return; + } + const saved = typeof window !== "undefined" ? localStorage.getItem(PHONE_STORAGE_KEY) : null; + if (saved) setPhone(saved); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isOpen]); + + useEffect(() => stopTimers, [stopTimers]); + + // resend countdown ticker + const resendAvailableAt = session?.resendAvailableAt ?? 0; + const sessionId = session?.sessionId; + const sessionPhase = session?.phase; + useEffect(() => { + if (!sessionId || isTerminal(sessionPhase)) return; + const tick = () => { + setResendCountdown(Math.max(0, Math.ceil((resendAvailableAt - Date.now()) / 1000))); + }; + tick(); + const timer = setInterval(tick, 1000); + return () => clearInterval(timer); + }, [sessionId, sessionPhase, resendAvailableAt]); + + // ── status polling ────────────────────────────────────────────────────── + + const startPolling = useCallback( + (sessionId: string) => { + stopTimers(); + pollTimer.current = setInterval(async () => { + try { + const response = await fetch( + `/api/providers/volcengine-plan/connect/${sessionId}/status` + ); + const data = await response.json().catch(() => ({})); + if (data?.session) { + setSession((prev) => (prev ? { ...prev, ...data.session } : data.session)); + if (isTerminal(data.session.phase)) { + stopTimers(); + if (data.session.phase === "success") void onConnected(); + } + } + } catch { + // transient network error — keep polling until phase resolves + } + }, 1500); + }, + [stopTimers, onConnected] + ); + + // ── actions ───────────────────────────────────────────────────────────── + + const handleStart = useCallback(async () => { + const trimmed = phone.trim(); + if (!trimmed) return; + setStarting(true); + try { + const response = await fetch("/api/providers/volcengine-plan/connect", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ phone: trimmed }), + }); + const data = await response.json().catch(() => ({})); + if (!response.ok || !data?.success || !data?.session) { + throw new Error(data?.error || "Failed to start Volcano login"); + } + setSession(data.session); + setResendCountdown( + Math.max(0, Math.ceil((data.session.resendAvailableAt - Date.now()) / 1000)) + ); + localStorage.setItem(PHONE_STORAGE_KEY, trimmed); + if (data.session.phase === "starting" || data.session.phase === "sending_code") { + startPolling(data.session.sessionId); + } + } catch (error) { + notify.error(error instanceof Error ? error.message : "Failed to start Volcano login"); + } finally { + setStarting(false); + } + }, [phone, notify, startPolling]); + + const handleSubmitCode = useCallback(async () => { + if (!session) return; + setSubmittingCode(true); + try { + const payload: { code: string; captcha?: string } = { code: code.trim() }; + if (session.phase === "captcha_required" && captcha.trim()) { + payload.captcha = captcha.trim(); + } + const response = await fetch( + `/api/providers/volcengine-plan/connect/${session.sessionId}/code`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + } + ); + const data = await response.json().catch(() => ({})); + if (data?.session) { + setSession((prev) => (prev ? { ...prev, ...data.session } : data.session)); + if (data.session.phase === "mfa_waiting") { + // A NEW code is required for the MFA step — clear the stale input. + setCode(""); + setCaptcha(""); + } + if ( + data.session.phase === "starting" || + data.session.phase === "sending_code" || + data.session.phase === "submitting" + ) { + startPolling(data.session.sessionId); + } else if (data.session.phase === "success") { + void onConnected(); + } + } else { + throw new Error(data?.error || "Failed to submit verification code"); + } + } catch (error) { + notify.error(error instanceof Error ? error.message : "Failed to submit verification code"); + } finally { + setSubmittingCode(false); + } + }, [session, code, captcha, notify, startPolling, onConnected]); + + const handleSelectIdentity = useCallback( + async (index: number) => { + if (!session) return; + setSelectingIdentity(true); + try { + const response = await fetch( + `/api/providers/volcengine-plan/connect/${session.sessionId}/identity`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ index }), + } + ); + const data = await response.json().catch(() => ({})); + if (data?.session) { + setSession((prev) => (prev ? { ...prev, ...data.session } : data.session)); + if ( + data.session.phase === "starting" || + data.session.phase === "sending_code" || + data.session.phase === "submitting" + ) { + startPolling(data.session.sessionId); + } else if (data.session.phase === "success") { + void onConnected(); + } + } else { + throw new Error(data?.error || "Failed to select identity"); + } + } catch (error) { + notify.error(error instanceof Error ? error.message : "Failed to select identity"); + } finally { + setSelectingIdentity(false); + } + }, + [session, notify, startPolling, onConnected] + ); + + const handleResend = useCallback(async () => { + if (!session || resendCountdown > 0) return; + setResending(true); + try { + const response = await fetch( + `/api/providers/volcengine-plan/connect/${session.sessionId}/resend`, + { method: "POST" } + ); + const data = await response.json().catch(() => ({})); + if (data?.session) { + setSession((prev) => (prev ? { ...prev, ...data.session } : data.session)); + setResendCountdown( + Math.max(0, Math.ceil((data.session.resendAvailableAt - Date.now()) / 1000)) + ); + setCode(""); + setCaptcha(""); + } + } catch { + notify.error("Failed to resend verification code"); + } finally { + setResending(false); + } + }, [session, resendCountdown, notify]); + + const handleCancelSession = useCallback(async () => { + if (!session) return; + try { + await fetch(`/api/providers/volcengine-plan/connect/${session.sessionId}/cancel`, { + method: "POST", + }); + } catch { + // best-effort + } + reset(); + }, [session, reset]); + + // ── derived UI state ──────────────────────────────────────────────────── + + const phase = session?.phase; + const showPhoneStep = !session; + const showCodeStep = + phase === "waiting_code" || + phase === "captcha_required" || + phase === "mfa_waiting" || + phase === "identity_required"; + const showPolling = phase === "starting" || phase === "sending_code" || phase === "submitting"; + const done = isTerminal(phase); + const mfaStep = phase === "mfa_waiting"; + + const bindingResults = session?.binding?.results || []; + const connectedPlans = bindingResults.filter((r) => r?.ok); + const bindingError = session?.binding?.error; + + const handleClose = useCallback(() => { + onClose(); + }, [onClose]); + + // ── render ────────────────────────────────────────────────────────────── + + return ( + +
+ {showPhoneStep && ( + <> +

+ {providerText( + t, + "volcAutoLoginDesc", + "Enter your phone number. OmniRoute sends a verification code via the Volcano Engine console and extracts the session cookies automatically — no browser interaction needed." + )} +

+ ) => setPhone(e.target.value)} + onKeyDown={(e: React.KeyboardEvent) => { + if (e.key === "Enter") void handleStart(); + }} + inputMode="numeric" + /> +
+ + +
+ + )} + + {showCodeStep && ( + <> +

+ {mfaStep + ? providerText( + t, + "volcMfaDesc", + "Additional verification required (MFA). A NEW 6-digit code was sent to {phone} — enter it below to finish login.", + { phone: session?.phoneMasked || "your phone" } + ) + : phase === "identity_required" + ? providerText( + t, + "volcIdentityDesc", + "Your phone number is linked to multiple Volcano Engine identities. Pick the one you want to log in with:" + ) + : providerText( + t, + "volcCodeSent", + "A verification code was sent to {phone}. Enter it below to finish login.", + { phone: session?.phoneMasked || "your phone" } + )} +

+ + {phase === "identity_required" && session?.identityOptions?.length ? ( +
+ {session.identityOptions.map((option) => ( + + ))} +
+ ) : ( + <> + {phase === "captcha_required" && session?.captchaImage && ( +
+

+ {providerText( + t, + "volcCaptchaLabel", + "Image captcha (required by the console)" + )} +

+ {/* eslint-disable-next-line @next/next/no-img-element */} + captcha + ) => + setCaptcha(e.target.value) + } + /> +
+ )} + + ) => setCode(e.target.value)} + onKeyDown={(e: React.KeyboardEvent) => { + if (e.key === "Enter") void handleSubmitCode(); + }} + inputMode="numeric" + maxLength={6} + /> + + {session?.error &&

{session.error}

} + +
+ +
+ + +
+
+ + )} + + )} + + {showPolling && ( +
+ +

+ {phase === "submitting" + ? providerText( + t, + "volcSubmitting", + "Submitting code and extracting console cookies..." + ) + : providerText(t, "volcStarting", "Starting Volcano login...")} +

+
+ )} + + {done && phase === "success" && ( +
+

+ {providerText(t, "volcLoginSuccess", "Logged in to the Volcano Engine console")} +

+ {bindingError ? ( +

+ {providerText(t, "volcBindError", "Plan binding failed: {error}", { + error: bindingError, + })} +

+ ) : ( +
+ {connectedPlans.length > 0 ? ( + connectedPlans.map((item) => ( +

+ ✓ {item.plan} plan connected +

+ )) + ) : ( +

+ {providerText( + t, + "volcNoPlans", + "No Agent/Coding plans were detected on this account." + )} +

+ )} +
+ )} +
+ +
+
+ )} + + {done && phase !== "success" && ( +
+

+ {session?.error || + (phase === "timeout" + ? providerText(t, "volcTimeout", "Login timed out") + : phase === "cancelled" + ? providerText(t, "volcCancelled", "Login cancelled") + : providerText(t, "volcFailed", "Login failed"))} +

+
+ + +
+
+ )} +
+
+ ); +} diff --git a/src/app/api/providers/volcengine-plan/connect/[sessionId]/cancel/route.ts b/src/app/api/providers/volcengine-plan/connect/[sessionId]/cancel/route.ts new file mode 100644 index 0000000000..ddf1a8b1b1 --- /dev/null +++ b/src/app/api/providers/volcengine-plan/connect/[sessionId]/cancel/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from "next/server"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; + +/** + * POST /api/providers/volcengine-plan/connect/[sessionId]/cancel + * Cancel an auto phone login session and close its headless browser. + */ +export async function POST( + request: Request, + { params }: { params: Promise<{ sessionId: string }> } +): Promise { + const auth = await requireManagementAuth(request); + if (auth) return auth; + + const { sessionId } = await params; + + try { + const { volcengineConsoleAutoLoginService } = + await import("@omniroute/open-sse/services/volcengineConsoleAutoLogin.ts"); + const session = await volcengineConsoleAutoLoginService.cancel(sessionId); + if (!session) { + return NextResponse.json( + { success: false, error: "Unknown or expired Volcano login session" }, + { status: 404 } + ); + } + return NextResponse.json({ success: true, session }); + } catch { + return NextResponse.json({ success: false, error: "Cancel failed" }, { status: 500 }); + } +} diff --git a/src/app/api/providers/volcengine-plan/connect/[sessionId]/code/route.ts b/src/app/api/providers/volcengine-plan/connect/[sessionId]/code/route.ts new file mode 100644 index 0000000000..138bd23ee9 --- /dev/null +++ b/src/app/api/providers/volcengine-plan/connect/[sessionId]/code/route.ts @@ -0,0 +1,63 @@ +import { NextResponse } from "next/server"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { bindVolcenginePlansFromConsoleCredentials } from "@/lib/providers/volcenginePlanBinding"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; + +/** + * POST /api/providers/volcengine-plan/connect/[sessionId]/code + * Submit the SMS verification code (plus image captcha when required) for an + * auto phone login session. Returns the session view; binding runs lazily on + * the next status poll once credentials are extracted. + */ +export async function POST( + request: Request, + { params }: { params: Promise<{ sessionId: string }> } +): Promise { + const auth = await requireManagementAuth(request); + if (auth) return auth; + + const { sessionId } = await params; + const body = await request.json().catch(() => ({})); + + try { + const { volcengineConsoleAutoLoginService } = + await import("@omniroute/open-sse/services/volcengineConsoleAutoLogin.ts"); + + if (!volcengineConsoleAutoLoginService.getStatus(sessionId)) { + return NextResponse.json( + { success: false, error: "Unknown or expired Volcano login session" }, + { status: 404 } + ); + } + + const timeout = typeof body.timeout === "number" ? body.timeout : undefined; + const session = await volcengineConsoleAutoLoginService.submitCode( + sessionId, + String(body.code ?? ""), + typeof body.captcha === "string" ? body.captcha : undefined, + { timeout } + ); + if (!session) { + return NextResponse.json( + { success: false, error: "Unknown or expired Volcano login session" }, + { status: 404 } + ); + } + + // Credentials ready → bind immediately so the response carries the outcome. + if (session.phase === "success") { + const bound = await volcengineConsoleAutoLoginService.withBinding(sessionId, (credentials) => + bindVolcenginePlansFromConsoleCredentials(credentials) + ); + return NextResponse.json({ success: true, session: bound ?? session }); + } + + return NextResponse.json({ success: false, session }); + } catch (error) { + const message = sanitizeErrorMessage(error instanceof Error ? error.message : error); + return NextResponse.json( + { success: false, error: `Volcano code submission failed: ${message}` }, + { status: 500 } + ); + } +} diff --git a/src/app/api/providers/volcengine-plan/connect/[sessionId]/identity/route.ts b/src/app/api/providers/volcengine-plan/connect/[sessionId]/identity/route.ts new file mode 100644 index 0000000000..e8b289d4b2 --- /dev/null +++ b/src/app/api/providers/volcengine-plan/connect/[sessionId]/identity/route.ts @@ -0,0 +1,67 @@ +import { NextResponse } from "next/server"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { bindVolcenginePlansFromConsoleCredentials } from "@/lib/providers/volcenginePlanBinding"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; + +/** + * POST /api/providers/volcengine-plan/connect/[sessionId]/identity + * Pick an identity on the console's select_identity page (the phone maps to + * multiple accounts) and finish the login + plan binding. + */ +export async function POST( + request: Request, + { params }: { params: Promise<{ sessionId: string }> } +): Promise { + const auth = await requireManagementAuth(request); + if (auth) return auth; + + const { sessionId } = await params; + const body = await request.json().catch(() => ({})); + + try { + const { volcengineConsoleAutoLoginService } = + await import("@omniroute/open-sse/services/volcengineConsoleAutoLogin.ts"); + + if (!volcengineConsoleAutoLoginService.getStatus(sessionId)) { + return NextResponse.json( + { success: false, error: "Unknown or expired Volcano login session" }, + { status: 404 } + ); + } + + const index = Number(body.index); + if (!Number.isInteger(index) || index < 0) { + return NextResponse.json( + { success: false, error: "Invalid identity index" }, + { status: 400 } + ); + } + + const timeout = typeof body.timeout === "number" ? body.timeout : undefined; + const session = await volcengineConsoleAutoLoginService.selectIdentity(sessionId, index, { + timeout, + }); + if (!session) { + return NextResponse.json( + { success: false, error: "Unknown or expired Volcano login session" }, + { status: 404 } + ); + } + + // Credentials ready → bind immediately so the response carries the outcome. + if (session.phase === "success") { + const bound = await volcengineConsoleAutoLoginService.withBinding(sessionId, (credentials) => + bindVolcenginePlansFromConsoleCredentials(credentials) + ); + return NextResponse.json({ success: true, session: bound ?? session }); + } + + return NextResponse.json({ success: false, session }); + } catch (error) { + const message = sanitizeErrorMessage(error instanceof Error ? error.message : error); + return NextResponse.json( + { success: false, error: `Volcano identity selection failed: ${message}` }, + { status: 500 } + ); + } +} diff --git a/src/app/api/providers/volcengine-plan/connect/[sessionId]/resend/route.ts b/src/app/api/providers/volcengine-plan/connect/[sessionId]/resend/route.ts new file mode 100644 index 0000000000..b839c85c7c --- /dev/null +++ b/src/app/api/providers/volcengine-plan/connect/[sessionId]/resend/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from "next/server"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; + +/** + * POST /api/providers/volcengine-plan/connect/[sessionId]/resend + * Re-trigger the SMS verification code for an active login session. + */ +export async function POST( + request: Request, + { params }: { params: Promise<{ sessionId: string }> } +): Promise { + const auth = await requireManagementAuth(request); + if (auth) return auth; + + const { sessionId } = await params; + + try { + const { volcengineConsoleAutoLoginService } = + await import("@omniroute/open-sse/services/volcengineConsoleAutoLogin.ts"); + const session = await volcengineConsoleAutoLoginService.resendCode(sessionId); + if (!session) { + return NextResponse.json( + { success: false, error: "Unknown or expired Volcano login session" }, + { status: 404 } + ); + } + return NextResponse.json({ success: true, session }); + } catch { + return NextResponse.json({ success: false, error: "Resend failed" }, { status: 500 }); + } +} diff --git a/src/app/api/providers/volcengine-plan/connect/[sessionId]/status/route.ts b/src/app/api/providers/volcengine-plan/connect/[sessionId]/status/route.ts new file mode 100644 index 0000000000..5af802111d --- /dev/null +++ b/src/app/api/providers/volcengine-plan/connect/[sessionId]/status/route.ts @@ -0,0 +1,43 @@ +import { NextResponse } from "next/server"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { bindVolcenginePlansFromConsoleCredentials } from "@/lib/providers/volcenginePlanBinding"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; + +/** + * GET /api/providers/volcengine-plan/connect/[sessionId]/status + * Poll an auto phone login session. When credentials have been extracted, the + * plan binding runs lazily (deduped) and its result is attached to the view. + */ +export async function GET( + request: Request, + { params }: { params: Promise<{ sessionId: string }> } +): Promise { + const auth = await requireManagementAuth(request); + if (auth) return auth; + + const { sessionId } = await params; + + try { + const { volcengineConsoleAutoLoginService } = + await import("@omniroute/open-sse/services/volcengineConsoleAutoLogin.ts"); + + const session = await volcengineConsoleAutoLoginService.withBinding(sessionId, (credentials) => + bindVolcenginePlansFromConsoleCredentials(credentials) + ); + + if (!session) { + return NextResponse.json( + { success: false, error: "Unknown or expired Volcano login session" }, + { status: 404 } + ); + } + + return NextResponse.json({ success: session.phase === "success", session }); + } catch (error) { + const message = sanitizeErrorMessage(error instanceof Error ? error.message : error); + return NextResponse.json( + { success: false, error: `Volcano login status failed: ${message}` }, + { status: 500 } + ); + } +} diff --git a/src/app/api/providers/volcengine-plan/connect/route.ts b/src/app/api/providers/volcengine-plan/connect/route.ts index 81a3f52fed..d57cac554e 100644 --- a/src/app/api/providers/volcengine-plan/connect/route.ts +++ b/src/app/api/providers/volcengine-plan/connect/route.ts @@ -10,6 +10,26 @@ export async function POST(request: Request): Promise { const body = await request.json().catch(() => ({})); const timeout = typeof body.timeout === "number" ? body.timeout : undefined; + // Auto flow: phone present → start a session-based headless phone/SMS login. + if (typeof body.phone === "string" && body.phone.trim()) { + try { + const { volcengineConsoleAutoLoginService } = + await import("@omniroute/open-sse/services/volcengineConsoleAutoLogin.ts"); + const started = await volcengineConsoleAutoLoginService.startLogin(body.phone, { timeout }); + if (!started.ok) { + return NextResponse.json({ success: false, error: started.error }, { status: 400 }); + } + return NextResponse.json({ success: true, session: started.session }); + } catch (error) { + const message = sanitizeErrorMessage(error instanceof Error ? error.message : error); + return NextResponse.json( + { success: false, error: `Volcano auto login failed to start: ${message}` }, + { status: 500 } + ); + } + } + + // Legacy manual flow: headful browser login on the server machine. try { const { inAppLoginService } = await import("@omniroute/open-sse/services/inAppLoginService.ts"); const login = await inAppLoginService.startLogin("volcengine-console", { timeout }); diff --git a/src/server/authz/routeGuard.ts b/src/server/authz/routeGuard.ts index b2da6f5756..cfb7fcd08f 100644 --- a/src/server/authz/routeGuard.ts +++ b/src/server/authz/routeGuard.ts @@ -95,7 +95,7 @@ export const LOCAL_ONLY_API_PREFIXES: ReadonlyArray = [ */ export const LOCAL_ONLY_API_PATTERNS: ReadonlyArray = [ /^\/api\/providers\/[^/]+\/login\/?$/, - /^\/api\/providers\/volcengine-plan\/connect\/?$/, + /^\/api\/providers\/volcengine-plan\/connect(\/.*)?$/, // manual headful flow + session-based phone/SMS auto-login (both spawn Playwright) /^\/api\/providers\/[^/]+\/refresh-cursor\/?$/, /^\/api\/providers\/[^/]+\/chatgpt-web-codex-doctor\/?$/, ]; diff --git a/src/shared/constants/spawnCapablePrefixes.ts b/src/shared/constants/spawnCapablePrefixes.ts index b03012864f..92d74812e5 100644 --- a/src/shared/constants/spawnCapablePrefixes.ts +++ b/src/shared/constants/spawnCapablePrefixes.ts @@ -52,7 +52,7 @@ export const SPAWN_CAPABLE_PREFIXES: ReadonlyArray = [ */ export const SPAWN_CAPABLE_PATTERNS: ReadonlyArray = [ /^\/api\/providers\/[^/]+\/login\/?$/, // pre-existing gap: in LOCAL_ONLY_API_PATTERNS today but never in a spawn-capable deny-list - /^\/api\/providers\/volcengine-plan\/connect\/?$/, // launches Playwright to bind a Volcano Engine console session + /^\/api\/providers\/volcengine-plan\/connect(\/.*)?$/, // launches Playwright to bind a Volcano Engine console session — covers the manual headful flow AND the session-based phone/SMS auto-login sub-routes (/code, /status, /cancel, /resend) /^\/api\/providers\/[^/]+\/refresh-cursor\/?$/, // spawns cursor-agent via renewal.ts (Hard Rules #15 + #17) /^\/api\/providers\/cursor\/agent-availability\/?$/, // static path (no dynamic segment), but kept in this array alongside its /api/providers/ siblings rather than the flat SPAWN_CAPABLE_PREFIXES array — spawns cursor-agent status via checkCursorAgentAvailability()/getCachedCursorAgentAvailability() (Hard Rules #15 + #17) /^\/api\/providers\/[^/]+\/chatgpt-web-codex-doctor\/?$/, // spawns via getTunnelRuntimeStatus() → spawnSync("...","runtimes status") (open-sse/executors/chatgpt-web-codex/tunnelClient.ts). Mirrors LOCAL_ONLY_API_PATTERNS in routeGuard.ts; keep the two in sync (GHSA-9q3h-mjm5-f4gj). diff --git a/tests/unit/services/volcengine-console-auto-login.test.ts b/tests/unit/services/volcengine-console-auto-login.test.ts new file mode 100644 index 0000000000..f58a577fd2 --- /dev/null +++ b/tests/unit/services/volcengine-console-auto-login.test.ts @@ -0,0 +1,802 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + VolcengineConsoleAutoLoginService, + maskPhone, + normalizePhone, +} from "../../../open-sse/services/volcengineConsoleAutoLogin.ts"; + +// ─── Fake playwright ──────────────────────────────────────────────────────── + +interface FakeState { + visible: Set; + disabled: Set; + fills: Record; + clicks: string[]; + /** Returns the cookie jar; tests swap this to simulate login progress */ + cookiesFn: () => Array<{ name: string; domain: string; value: string }>; + toastText: string | null; + browserClosed: boolean; + /** Current page URL — tests move it off /auth/login to simulate redirect */ + url: string; + gotoCalls: string[]; + /** selector → list of item texts (identity list etc.) */ + lists: Record; +} + +function makeFakePlaywright() { + const state: FakeState = { + visible: new Set(), + disabled: new Set(), + fills: {}, + clicks: [], + cookiesFn: () => [], + toastText: null, + browserClosed: false, + url: "https://console.volcengine.com/auth/login", + gotoCalls: [], + lists: {}, + }; + + class FakeLocator { + constructor( + private page: FakePage, + private selector: string, + private idx = -1 + ) {} + first() { + return this; + } + nth(index: number) { + return new FakeLocator(this.page, this.selector, index); + } + async count() { + return (this.page.state.lists[this.selector] || []).length; + } + async isVisible() { + return this.page.state.visible.has(this.selector); + } + async isDisabled() { + return this.page.state.disabled.has(this.selector); + } + async click() { + const suffix = this.idx >= 0 ? `[${this.idx}]` : ""; + this.page.state.clicks.push(`${this.selector}${suffix}`); + } + async fill(value: string) { + this.page.state.fills[this.selector] = value; + } + async screenshot() { + return Buffer.from("fake-png"); + } + async textContent() { + if (this.idx >= 0) return (this.page.state.lists[this.selector] || [])[this.idx] ?? null; + return this.page.state.toastText; + } + } + + class FakePage { + constructor(public state: FakeState) {} + setDefaultTimeout() {} + async goto(url: string) { + this.state.gotoCalls.push(url); + this.state.url = url; + } + url() { + return this.state.url; + } + locator(selector: string) { + return new FakeLocator(this, selector); + } + async screenshot() { + return Buffer.from("fake-page-png"); + } + } + + const page = new FakePage(state); + + const context = { + newPage: async () => page, + cookies: async () => state.cookiesFn(), + }; + + const browser = { + newContext: async () => context, + close: async () => { + state.browserClosed = true; + }, + }; + + return { + chromium: { launch: async () => browser }, + __state: state, + }; +} + +function fastService(fake: ReturnType) { + return new VolcengineConsoleAutoLoginService(async () => fake, { + pageSettleMs: 1, + tabSwitchMs: 1, + sendCodeSettleMs: 1, + pollIntervalMs: 1, + resendCooldownMs: 20, + }); +} + +const PHONE_TAB = '.arco-tabs-header-title:has-text("手机号登录")'; +const PHONE_INPUT = "#Tel_input"; +const SEND_CODE_BTN = 'button:has-text("获取验证码")'; +const SMS_CODE_INPUT = "#Code_input"; +const LOGIN_BTN = 'button:has-text("登录 / 注册")'; +const CAPTCHA_INPUT = "#VerificatonCodeInput"; +const CAPTCHA_MODAL = ".arco-modal"; +const MFA_MODAL = '.arco-modal:has-text("需要额外认证")'; +const MFA_INPUT = "#VerificatonCodeInput"; +const MFA_CONFIRM_BTN = 'button:has-text("好的")'; +const MFA_RESEND_BTN = 'button:has-text("重发校验码")'; +const MFA_BIND_MODAL = '.arco-modal:has-text("绑定MFA设备")'; +const IDENTITY_LIST = 'ul[class*="accountUl"] li[class*="accountLi"]'; +const IDENTITY_ITEM = 'li[class*="accountLi"] > [class*="item"]'; +const IDENTITY_SUBMIT = '[class*="selectPlatformIdentity"] button[type="submit"]'; + +const FULL_COOKIES = [ + { name: "digest", domain: ".volcengine.com", value: "d1" }, + { name: "AccountID", domain: ".volcengine.com", value: "a1" }, + { name: "csrfToken", domain: ".volcengine.com", value: "c1" }, + { name: "userInfo", domain: ".volcengine.com", value: "u1" }, +]; + +function happyPathVisible(fake: ReturnType) { + fake.__state.visible.add(PHONE_TAB); + fake.__state.visible.add(PHONE_INPUT); + fake.__state.visible.add(SEND_CODE_BTN); + fake.__state.visible.add(SMS_CODE_INPUT); + fake.__state.visible.add(LOGIN_BTN); +} + +// ─── Pure helpers ─────────────────────────────────────────────────────────── + +test("normalizePhone strips +86/86 prefixes, spaces and dashes", () => { + assert.equal(normalizePhone("+8613800000000"), "13800000000"); + assert.equal(normalizePhone("8613800000000"), "13800000000"); + assert.equal(normalizePhone("138-0000 0000"), "13800000000"); + assert.equal(normalizePhone(" 13800000000 "), "13800000000"); + assert.equal(normalizePhone("12345"), null); + assert.equal(normalizePhone("23800000000"), null); + assert.equal(normalizePhone(""), null); +}); + +test("maskPhone keeps only head/tail digits", () => { + assert.equal(maskPhone("13800000000"), "138****0000"); + assert.equal(maskPhone("1234567"), "123****4567"); + assert.equal(maskPhone("123"), "***"); +}); + +// ─── startLogin ───────────────────────────────────────────────────────────── + +test("startLogin rejects an invalid phone number", async () => { + const fake = makeFakePlaywright(); + const service = fastService(fake); + const result = await service.startLogin("not-a-phone"); + assert.equal(result.ok, false); + assert.match((result as { error: string }).error, /Invalid phone/i); +}); + +test("startLogin drives the phone tab and sends the SMS code", async () => { + const fake = makeFakePlaywright(); + happyPathVisible(fake); + const service = fastService(fake); + + const result = await service.startLogin("+8613800000000"); + assert.equal(result.ok, true); + const session = (result as { session: { sessionId: string; phase: string } }).session; + assert.equal(session.phase, "waiting_code"); + + assert.equal(fake.__state.fills[PHONE_INPUT], "13800000000"); + assert.ok(fake.__state.clicks.includes(PHONE_TAB)); + assert.ok(fake.__state.clicks.includes(SEND_CODE_BTN)); +}); + +test("startLogin degrades to fallback_manual when selectors miss", async () => { + const fake = makeFakePlaywright(); + // nothing visible → phone tab not found + const service = fastService(fake); + + const result = await service.startLogin("13800000000"); + assert.equal(result.ok, true); + const session = (result as { session: { phase: string } }).session; + assert.equal(session.phase, "fallback_manual"); + assert.ok(fake.__state.browserClosed, "browser must close on fallback"); +}); + +test("startLogin reports captcha_required with a screenshot when the console demands one", async () => { + const fake = makeFakePlaywright(); + happyPathVisible(fake); + fake.__state.visible.add(CAPTCHA_INPUT); + fake.__state.visible.add(CAPTCHA_MODAL); + const service = fastService(fake); + + const result = await service.startLogin("13800000000"); + assert.equal(result.ok, true); + const session = (result as { session: { phase: string; captchaImage: string | null } }).session; + assert.equal(session.phase, "captcha_required"); + assert.match(session.captchaImage || "", /^data:image\/png;base64,/); +}); + +test("startLogin degrades to fallback_manual on risk-control slider", async () => { + const fake = makeFakePlaywright(); + happyPathVisible(fake); + fake.__state.visible.add('[class*="secsdk-captcha"]'); + const service = fastService(fake); + + const result = await service.startLogin("13800000000"); + assert.equal(result.ok, true); + const session = (result as { session: { phase: string; error: string | null } }).session; + assert.equal(session.phase, "fallback_manual"); + assert.match(session.error || "", /risk control/i); + assert.ok(fake.__state.browserClosed); +}); + +test("startLogin replaces a stale session for the same phone", async () => { + const fake = makeFakePlaywright(); + happyPathVisible(fake); + const service = fastService(fake); + + const first = await service.startLogin("13800000000"); + const firstId = (first as { session: { sessionId: string } }).session.sessionId; + const second = await service.startLogin("13800000000"); + const secondId = (second as { session: { sessionId: string } }).session.sessionId; + + assert.notEqual(firstId, secondId); + assert.equal(service.getStatus(firstId)?.phase, "cancelled"); + assert.equal(service.getStatus(secondId)?.phase, "waiting_code"); +}); + +// ─── submitCode ───────────────────────────────────────────────────────────── + +test("submitCode completes login when all console cookies land", async () => { + const fake = makeFakePlaywright(); + happyPathVisible(fake); + const service = fastService(fake); + + const started = (await service.startLogin("13800000000")) as { + session: { sessionId: string }; + }; + // Cookies complete after the first poll + fake.__state.cookiesFn = () => FULL_COOKIES; + + const session = await service.submitCode(started.session.sessionId, "123456"); + assert.equal(session?.phase, "success"); + assert.deepEqual(Object.keys(session?.credentials || {}).sort(), [ + "AccountID", + "csrfToken", + "digest", + "userInfo", + ]); + assert.equal(fake.__state.fills[SMS_CODE_INPUT], "123456"); + assert.ok(fake.__state.clicks.includes(LOGIN_BTN)); + assert.ok(fake.__state.browserClosed, "browser must close after success"); +}); + +test("submitCode rejects a malformed code without touching the page", async () => { + const fake = makeFakePlaywright(); + happyPathVisible(fake); + const service = fastService(fake); + + const started = (await service.startLogin("13800000000")) as { + session: { sessionId: string }; + }; + const before = fake.__state.clicks.length; + + const session = await service.submitCode(started.session.sessionId, "abc"); + assert.equal(session?.phase, "waiting_code"); + assert.equal(session?.error, "Invalid SMS code"); + assert.equal(fake.__state.clicks.length, before, "no click on malformed code"); +}); + +test("submitCode requires the image captcha in captcha_required phase", async () => { + const fake = makeFakePlaywright(); + happyPathVisible(fake); + fake.__state.visible.add(CAPTCHA_INPUT); + fake.__state.visible.add(CAPTCHA_MODAL); + const service = fastService(fake); + + const started = (await service.startLogin("13800000000")) as { + session: { sessionId: string }; + }; + const session = await service.submitCode(started.session.sessionId, "123456"); + assert.equal(session?.phase, "captcha_required"); + assert.equal(session?.error, "Image captcha is required"); +}); + +test("submitCode surfaces console error toasts early", async () => { + const fake = makeFakePlaywright(); + happyPathVisible(fake); + const service = fastService(fake); + + const started = (await service.startLogin("13800000000")) as { + session: { sessionId: string }; + }; + fake.__state.toastText = "验证码错误,请重新输入"; + + const session = await service.submitCode(started.session.sessionId, "000000", undefined, { + timeout: 500, + }); + assert.equal(session?.phase, "error"); + assert.match(session?.error || "", /验证码错误/); + assert.ok(fake.__state.browserClosed); +}); + +test("submitCode times out when cookies never arrive", async () => { + const fake = makeFakePlaywright(); + happyPathVisible(fake); + const service = fastService(fake); + + const started = (await service.startLogin("13800000000")) as { + session: { sessionId: string }; + }; + const session = await service.submitCode(started.session.sessionId, "123456", undefined, { + timeout: 50, + }); + assert.equal(session?.phase, "timeout"); + assert.ok(fake.__state.browserClosed); +}); + +// ─── MFA step-up (需要额外认证) ────────────────────────────────────────── + +test("submitCode transitions to mfa_waiting when the console demands MFA", async () => { + const fake = makeFakePlaywright(); + happyPathVisible(fake); + // After the login click the MFA step-up modal appears (no cookies yet). + const originalClicks = fake.__state.clicks; + fake.__state.cookiesFn = () => [ + { name: "digest", domain: ".volcengine.com", value: "d1" }, + { name: "csrfToken", domain: ".volcengine.com", value: "c1" }, + ]; + const service = fastService(fake); + + const started = (await service.startLogin("13800000000")) as { + session: { sessionId: string }; + }; + // Simulate: login button clicked → MFA modal opens + const loginBtn = fake.__state.clicks; + assert.ok(loginBtn.length > 0); + fake.__state.visible.add(MFA_MODAL); + fake.__state.visible.add(MFA_INPUT); + fake.__state.visible.add(MFA_CONFIRM_BTN); + + const session = await service.submitCode(started.session.sessionId, "123456", undefined, { + timeout: 2_000, + }); + assert.equal(session?.phase, "mfa_waiting"); + assert.equal(session?.mfaRequired, true); + assert.equal(session?.error, null); + assert.ok(!fake.__state.browserClosed, "browser must stay open while MFA is pending"); +}); + +test("submitCode completes login from mfa_waiting with the second code", async () => { + const fake = makeFakePlaywright(); + happyPathVisible(fake); + const service = fastService(fake); + + const started = (await service.startLogin("13800000000")) as { + session: { sessionId: string }; + }; + // First submit → MFA modal opens + fake.__state.visible.add(MFA_MODAL); + fake.__state.visible.add(MFA_INPUT); + fake.__state.visible.add(MFA_CONFIRM_BTN); + fake.__state.cookiesFn = () => [ + { name: "digest", domain: ".volcengine.com", value: "d1" }, + { name: "csrfToken", domain: ".volcengine.com", value: "c1" }, + ]; + const mfa = await service.submitCode(started.session.sessionId, "111111", undefined, { + timeout: 2_000, + }); + assert.equal(mfa?.phase, "mfa_waiting"); + + // Second submit from mfa_waiting: modal closes, all cookies land + fake.__state.visible.delete(MFA_MODAL); + fake.__state.cookiesFn = () => FULL_COOKIES; + const done = await service.submitCode(started.session.sessionId, "222222"); + assert.equal(done?.phase, "success"); + assert.equal(fake.__state.fills[MFA_INPUT], "222222"); + assert.ok(fake.__state.clicks.includes(MFA_CONFIRM_BTN)); + assert.ok(fake.__state.browserClosed); +}); + +test("submitCode returns to mfa_waiting when the MFA code is rejected", async () => { + const fake = makeFakePlaywright(); + happyPathVisible(fake); + const service = fastService(fake); + + const started = (await service.startLogin("13800000000")) as { + session: { sessionId: string }; + }; + fake.__state.visible.add(MFA_MODAL); + fake.__state.visible.add(MFA_INPUT); + fake.__state.visible.add(MFA_CONFIRM_BTN); + fake.__state.cookiesFn = () => []; + const mfa = await service.submitCode(started.session.sessionId, "111111", undefined, { + timeout: 2_000, + }); + assert.equal(mfa?.phase, "mfa_waiting"); + + // Modal still up after submitting a wrong second code → retry state + const retry = await service.submitCode(started.session.sessionId, "222222", undefined, { + timeout: 2_000, + }); + assert.equal(retry?.phase, "mfa_waiting"); + assert.match(retry?.error || "", /not accepted/i); +}); + +test("submitCode degrades to fallback_manual for the TOTP binding modal", async () => { + const fake = makeFakePlaywright(); + happyPathVisible(fake); + const service = fastService(fake); + + const started = (await service.startLogin("13800000000")) as { + session: { sessionId: string }; + }; + fake.__state.visible.add(MFA_BIND_MODAL); + fake.__state.cookiesFn = () => []; + const session = await service.submitCode(started.session.sessionId, "123456", undefined, { + timeout: 2_000, + }); + assert.equal(session?.phase, "fallback_manual"); + assert.match(session?.error || "", /binding an MFA device/i); + assert.ok(fake.__state.browserClosed); +}); + +test("submitCode navigates to the ark console page when the redirect leaves cookies incomplete", async () => { + const fake = makeFakePlaywright(); + happyPathVisible(fake); + const service = fastService(fake); + + const started = (await service.startLogin("13800000000")) as { + session: { sessionId: string }; + }; + // Login redirected to the console home, cookies only complete AFTER the + // console app runs (simulated by completing the jar on goto). + fake.__state.url = "https://console.volcengine.com/"; + fake.__state.cookiesFn = () => [ + { name: "digest", domain: ".volcengine.com", value: "d1" }, + { name: "csrfToken", domain: ".volcengine.com", value: "c1" }, + ]; + + const submitPromise = service.submitCode(started.session.sessionId, "123456", undefined, { + timeout: 2_000, + }); + // Complete the cookies once the service navigates to the ark page + const waitNav = new Promise((resolve) => { + const iv = setInterval(() => { + if (fake.__state.gotoCalls.some((u) => u.includes("/ark/"))) { + clearInterval(iv); + fake.__state.cookiesFn = () => FULL_COOKIES; + resolve(); + } + }, 5); + }); + await waitNav; + const session = await submitPromise; + assert.equal(session?.phase, "success"); + assert.ok( + fake.__state.gotoCalls.some((u) => u.includes("/ark/")), + "must navigate to the ark console page to finish cookie issuance" + ); +}); + +test("resendCode from mfa_waiting clicks the modal resend button and stays in mfa", async () => { + const fake = makeFakePlaywright(); + happyPathVisible(fake); + const service = fastService(fake); // resendCooldownMs: 20ms + + const started = (await service.startLogin("13800000000")) as { + session: { sessionId: string }; + }; + fake.__state.visible.add(MFA_MODAL); + fake.__state.visible.add(MFA_INPUT); + fake.__state.visible.add(MFA_CONFIRM_BTN); + fake.__state.cookiesFn = () => []; + const mfa = await service.submitCode(started.session.sessionId, "111111", undefined, { + timeout: 2_000, + }); + assert.equal(mfa?.phase, "mfa_waiting"); + + // Wait out the 20ms cooldown, then resend must click 重发校验码 (not 获取验证码) + await new Promise((resolve) => setTimeout(resolve, 30)); + fake.__state.visible.add(MFA_RESEND_BTN); + const resent = await service.resendCode(started.session.sessionId); + assert.equal(resent?.phase, "mfa_waiting"); + assert.ok(fake.__state.clicks.includes(MFA_RESEND_BTN), "must click the MFA resend button"); +}); + +test("submitCode ignores unknown sessions", async () => { + const fake = makeFakePlaywright(); + const service = fastService(fake); + assert.equal(await service.submitCode("missing", "123456"), null); +}); + +// ─── Identity selection (/auth/login/select_identity) ─────────────────── + +test("submitCode transitions to identity_required on the select_identity page", async () => { + const fake = makeFakePlaywright(); + happyPathVisible(fake); + const service = fastService(fake); + + const started = (await service.startLogin("13800000000")) as { + session: { sessionId: string }; + }; + // SMS code accepted → redirected to identity selection with the REAL page + // structure: ul[class*=accountUl] > li[class*=accountLi] + fake.__state.url = "https://console.volcengine.com/auth/login/select_identity/"; + fake.__state.lists[IDENTITY_LIST] = [ + "主账号 company-main (ID:1000)", + "子账号 yangsiyuan (ID:2000)", + ]; + fake.__state.cookiesFn = () => [ + { name: "digest", domain: ".volcengine.com", value: "d1" }, + { name: "csrfToken", domain: ".volcengine.com", value: "c1" }, + ]; + + const session = await service.submitCode(started.session.sessionId, "123456", undefined, { + timeout: 2_000, + }); + assert.equal(session?.phase, "identity_required"); + assert.deepEqual(session?.identityOptions, [ + { index: 0, label: "主账号 company-main (ID:1000)" }, + { index: 1, label: "子账号 yangsiyuan (ID:2000)" }, + ]); + assert.ok(!fake.__state.browserClosed, "browser must stay open while identity is pending"); +}); + +test("selectIdentity clicks the chosen identity and the submit button, then completes login", async () => { + const fake = makeFakePlaywright(); + happyPathVisible(fake); + const service = fastService(fake); + + const started = (await service.startLogin("13800000000")) as { + session: { sessionId: string }; + }; + fake.__state.url = "https://console.volcengine.com/auth/login/select_identity/"; + fake.__state.lists[IDENTITY_LIST] = [ + "主账号 company-main (ID:1000)", + "子账号 yangsiyuan (ID:2000)", + ]; + fake.__state.lists[IDENTITY_ITEM] = ["item-0", "item-1"]; + fake.__state.visible.add(IDENTITY_SUBMIT); + const select = await service.submitCode(started.session.sessionId, "123456", undefined, { + timeout: 2_000, + }); + assert.equal(select?.phase, "identity_required"); + + // Choosing identity #1: item click + submit click fire, cookies complete + fake.__state.cookiesFn = () => FULL_COOKIES; + fake.__state.url = "https://console.volcengine.com/console/home"; + const done = await service.selectIdentity(started.session.sessionId, 1); + assert.equal(done?.phase, "success"); + assert.ok(fake.__state.clicks.includes(`${IDENTITY_ITEM}[1]`), "must click identity item 1"); + assert.ok(fake.__state.clicks.includes(IDENTITY_SUBMIT), "must click the submit button"); + assert.equal(done?.identityOptions, undefined); + assert.ok(fake.__state.browserClosed); +}); + +test("selectIdentity with index 0 skips the item click (page pre-selects the first identity)", async () => { + const fake = makeFakePlaywright(); + happyPathVisible(fake); + const service = fastService(fake); + + const started = (await service.startLogin("13800000000")) as { + session: { sessionId: string }; + }; + fake.__state.url = "https://console.volcengine.com/auth/login/select_identity/"; + fake.__state.lists[IDENTITY_LIST] = [ + "主账号 company-main (ID:1000)", + "子账号 yangsiyuan (ID:2000)", + ]; + fake.__state.lists[IDENTITY_ITEM] = ["item-0", "item-1"]; + fake.__state.visible.add(IDENTITY_SUBMIT); + await service.submitCode(started.session.sessionId, "123456", undefined, { timeout: 2_000 }); + + fake.__state.cookiesFn = () => FULL_COOKIES; + fake.__state.url = "https://console.volcengine.com/console/home"; + const done = await service.selectIdentity(started.session.sessionId, 0); + assert.equal(done?.phase, "success"); + assert.ok( + !fake.__state.clicks.some((c) => c.startsWith(IDENTITY_ITEM)), + "index 0 must not click an item — the page pre-selects it" + ); + assert.ok(fake.__state.clicks.includes(IDENTITY_SUBMIT)); +}); + +test("selectIdentity rejects an out-of-range index", async () => { + const fake = makeFakePlaywright(); + happyPathVisible(fake); + const service = fastService(fake); + + const started = (await service.startLogin("13800000000")) as { + session: { sessionId: string }; + }; + fake.__state.url = "https://console.volcengine.com/auth/login/select_identity/"; + fake.__state.lists[IDENTITY_LIST] = ["主账号 company-main (ID:1000)"]; + fake.__state.lists[IDENTITY_ITEM] = ["item-0"]; + fake.__state.visible.add(IDENTITY_SUBMIT); + const select = await service.submitCode(started.session.sessionId, "123456", undefined, { + timeout: 2_000, + }); + assert.equal(select?.phase, "identity_required"); + + const session = await service.selectIdentity(started.session.sessionId, 5); + assert.equal(session?.phase, "identity_required"); + assert.match(session?.error || "", /out of range/i); + assert.ok(!fake.__state.browserClosed, "session must survive a bad index"); +}); + +test("selectIdentity surfaces an MFA step-up triggered by the identity submit", async () => { + const fake = makeFakePlaywright(); + happyPathVisible(fake); + const service = fastService(fake); + + const started = (await service.startLogin("13800000000")) as { + session: { sessionId: string }; + }; + fake.__state.url = "https://console.volcengine.com/auth/login/select_identity/"; + fake.__state.lists[IDENTITY_LIST] = ["主账号 company-main (ID:1000)"]; + fake.__state.lists[IDENTITY_ITEM] = ["item-0"]; + fake.__state.visible.add(IDENTITY_SUBMIT); + await service.submitCode(started.session.sessionId, "123456", undefined, { timeout: 2_000 }); + + // Identity submit triggers ANOTHER MFA step-up + fake.__state.visible.add(MFA_MODAL); + fake.__state.visible.add(MFA_INPUT); + fake.__state.visible.add(MFA_CONFIRM_BTN); + fake.__state.cookiesFn = () => []; + const session = await service.selectIdentity(started.session.sessionId, 0); + assert.equal(session?.phase, "mfa_waiting"); + assert.equal(session?.mfaRequired, true); +}); + +test("selectIdentity is ignored outside the identity_required phase", async () => { + const fake = makeFakePlaywright(); + happyPathVisible(fake); + const service = fastService(fake); + + const started = (await service.startLogin("13800000000")) as { + session: { sessionId: string }; + }; + const session = await service.selectIdentity(started.session.sessionId, 0); + assert.equal(session?.phase, "waiting_code"); +}); + +// ─── cancel / resend ──────────────────────────────────────────────────────── + +test("cancel aborts an active session and closes the browser", async () => { + const fake = makeFakePlaywright(); + happyPathVisible(fake); + const service = fastService(fake); + + const started = (await service.startLogin("13800000000")) as { + session: { sessionId: string }; + }; + const session = await service.cancel(started.session.sessionId); + assert.equal(session?.phase, "cancelled"); + assert.ok(fake.__state.browserClosed); + assert.equal(service.getStatus(started.session.sessionId)?.phase, "cancelled"); +}); + +test("resendCode respects the cooldown window", async () => { + const fake = makeFakePlaywright(); + happyPathVisible(fake); + const service = fastService(fake); + + const started = (await service.startLogin("13800000000")) as { + session: { sessionId: string }; + }; + const clicksBefore = fake.__state.clicks.filter((c) => c === SEND_CODE_BTN).length; + + const session = await service.resendCode(started.session.sessionId); + assert.equal(session?.phase, "waiting_code"); + const clicksAfter = fake.__state.clicks.filter((c) => c === SEND_CODE_BTN).length; + assert.equal(clicksAfter, clicksBefore, "resend must not click during cooldown"); +}); + +test("resendCode clicks again once the cooldown passed", async () => { + const fake = makeFakePlaywright(); + happyPathVisible(fake); + const service = fastService(fake); // resendCooldownMs: 20ms + + const started = (await service.startLogin("13800000000")) as { + session: { sessionId: string }; + }; + + // Still inside the 20ms cooldown → no second click + await service.resendCode(started.session.sessionId); + let clicks = fake.__state.clicks.filter((c) => c === SEND_CODE_BTN).length; + assert.equal(clicks, 1, "resend must not click during cooldown"); + + // Cooldown elapsed → click fires and phase resets to waiting_code + await new Promise((resolve) => setTimeout(resolve, 30)); + const session = await service.resendCode(started.session.sessionId); + clicks = fake.__state.clicks.filter((c) => c === SEND_CODE_BTN).length; + assert.equal(clicks, 2, "resend clicks the send-code button after cooldown"); + assert.equal(session?.phase, "waiting_code"); + assert.equal(session?.error, null); +}); + +// ─── withBinding ──────────────────────────────────────────────────────────── + +test("withBinding binds once and reuses the result across polls", async () => { + const fake = makeFakePlaywright(); + happyPathVisible(fake); + const service = fastService(fake); + + const started = (await service.startLogin("13800000000")) as { + session: { sessionId: string }; + }; + fake.__state.cookiesFn = () => FULL_COOKIES; + const submitted = await service.submitCode(started.session.sessionId, "123456"); + assert.equal(submitted?.phase, "success"); + + let bindCalls = 0; + const bind = async () => { + bindCalls++; + return { results: [{ plan: "coding", ok: true }] }; + }; + + const [a, b] = await Promise.all([ + service.withBinding(started.session.sessionId, bind), + service.withBinding(started.session.sessionId, bind), + ]); + await service.withBinding(started.session.sessionId, bind); + + assert.equal(bindCalls, 1, "concurrent bind calls are deduped"); + assert.deepEqual((a as { binding: unknown }).binding, { + results: [{ plan: "coding", ok: true }], + }); + assert.deepEqual((b as { binding: unknown }).binding, { + results: [{ plan: "coding", ok: true }], + }); +}); + +test("withBinding records bind failures without retrying forever", async () => { + const fake = makeFakePlaywright(); + happyPathVisible(fake); + const service = fastService(fake); + + const started = (await service.startLogin("13800000000")) as { + session: { sessionId: string }; + }; + fake.__state.cookiesFn = () => FULL_COOKIES; + await service.submitCode(started.session.sessionId, "123456"); + + let bindCalls = 0; + const view = await service.withBinding(started.session.sessionId, async () => { + bindCalls++; + throw new Error("boom"); + }); + await service.withBinding(started.session.sessionId, async () => { + bindCalls++; + throw new Error("boom-2"); + }); + + assert.equal(bindCalls, 1, "failed bind is recorded, not retried"); + assert.deepEqual((view as { binding: unknown }).binding, { error: "boom" }); +}); + +test("withBinding returns the view unchanged before success", async () => { + const fake = makeFakePlaywright(); + happyPathVisible(fake); + const service = fastService(fake); + + const started = (await service.startLogin("13800000000")) as { + session: { sessionId: string }; + }; + let bindCalls = 0; + const view = await service.withBinding(started.session.sessionId, async () => { + bindCalls++; + return { results: [] }; + }); + assert.equal(bindCalls, 0); + assert.equal(view?.phase, "waiting_code"); +});