feat(volcengine): phone/SMS auto-login for console with MFA + identity selection

- 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
This commit is contained in:
deploy
2026-08-21 21:50:18 +08:00
committed by Markus Hartung
parent 07a378c86c
commit 8f15b79a84
12 changed files with 2648 additions and 3 deletions

View File

@@ -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<string, string>;
/** 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<boolean>;
click(options?: unknown): Promise<void>;
fill(value: string): Promise<void>;
isDisabled(): Promise<boolean>;
screenshot(options?: { type?: string }): Promise<Buffer>;
textContent(options?: { timeout?: number }): Promise<string | null>;
count(): Promise<number>;
nth(index: number): PwLocator;
}
interface PwPage {
setDefaultTimeout(timeout: number): void;
goto(url: string, options?: { waitUntil?: string; timeout?: number }): Promise<unknown>;
locator(selector: string): PwLocator;
screenshot(options?: { type?: string }): Promise<Buffer>;
url(): string;
content(): Promise<string>;
}
interface PwContext {
newPage(): Promise<PwPage>;
cookies(): Promise<Array<{ name: string; domain: string; value: string }>>;
}
interface PwBrowser {
newContext(options?: Record<string, unknown>): Promise<PwContext>;
close(): Promise<void>;
}
interface PwModule {
chromium: {
launch(options?: { headless?: boolean; args?: string[]; channel?: string }): Promise<PwBrowser>;
};
}
// ─── 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<string, string> | 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<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// ─── Service ────────────────────────────────────────────────────────────────
export class VolcengineConsoleAutoLoginService {
private sessions = new Map<string, ActiveSession>();
/** sessionId → bind promise set by the API layer to dedupe lazy binding */
private bindInFlight = new Map<string, Promise<unknown>>();
/** Injectable for tests — resolves the playwright module instead of `import("playwright")`. */
private readonly loadPlaywright: () => Promise<PwModule>;
private readonly delays: Required<ServiceDelays>;
constructor(
loadPlaywright: () => Promise<PwModule> = 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<T>(
sessionId: string,
bind: (credentials: Record<string, string>) => Promise<T>
): Promise<VolcLoginSessionView | null> {
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<VolcLoginSessionView | null> {
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<VolcLoginSessionView | null> {
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<string | null> {
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<VolcLoginSessionView> {
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<string, string> = {};
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<string | null> {
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<Array<{ index: number; label: string }>> {
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<string> {
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<string | null> {
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<VolcLoginSessionView | null> {
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<VolcLoginSessionView | null> {
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<void> {
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<string | null> {
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<PwLocator | null> {
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();

View File

@@ -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<ConnectionRowConnection | null>(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<string | undefined>();
@@ -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) */}
<VolcengineConnectModal
isOpen={showVolcengineConnectModal}
onClose={() => setShowVolcengineConnectModal(false)}
onFallbackManual={connectVolcengineAccountManually}
onConnected={fetchConnections}
notify={notify}
t={t}
/>
</div>
);
}

View File

@@ -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<void>;
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<SessionView | null>(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<ReturnType<typeof setInterval> | 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 (
<Modal
isOpen={isOpen}
onClose={handleClose}
title={providerText(t, "connectVolcengineAccount", "Connect Volcano Account")}
size="md"
>
<div className="space-y-4">
{showPhoneStep && (
<>
<p className="text-sm text-text-muted">
{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."
)}
</p>
<Input
label={providerText(t, "volcPhoneLabel", "Phone number")}
placeholder="13800000000"
value={phone}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPhone(e.target.value)}
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") void handleStart();
}}
inputMode="numeric"
/>
<div className="flex justify-end gap-2">
<Button variant="secondary" size="sm" onClick={handleClose}>
{providerText(t, "cancel", "Cancel")}
</Button>
<Button size="sm" loading={starting} disabled={!phone.trim()} onClick={handleStart}>
{providerText(t, "volcSendCode", "Send verification code")}
</Button>
</div>
</>
)}
{showCodeStep && (
<>
<p className="text-sm text-text-muted">
{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" }
)}
</p>
{phase === "identity_required" && session?.identityOptions?.length ? (
<div className="space-y-2">
{session.identityOptions.map((option) => (
<button
key={option.index}
type="button"
disabled={selectingIdentity}
onClick={() => handleSelectIdentity(option.index)}
className="w-full rounded-lg border border-border p-3 text-left text-sm transition-colors hover:bg-sidebar disabled:cursor-not-allowed disabled:opacity-60"
>
{selectingIdentity ? (
<span className="flex items-center gap-2">
<span className="inline-block h-3 w-3 animate-spin rounded-full border-2 border-primary border-t-transparent" />
{option.label}
</span>
) : (
option.label
)}
</button>
))}
</div>
) : (
<>
{phase === "captcha_required" && session?.captchaImage && (
<div className="space-y-2">
<p className="text-sm font-medium">
{providerText(
t,
"volcCaptchaLabel",
"Image captcha (required by the console)"
)}
</p>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={session.captchaImage}
alt="captcha"
className="max-h-40 rounded border border-border"
/>
<Input
placeholder={providerText(t, "volcCaptchaPlaceholder", "Captcha characters")}
value={captcha}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setCaptcha(e.target.value)
}
/>
</div>
)}
<Input
label={
mfaStep
? providerText(t, "volcMfaCodeLabel", "MFA verification code")
: providerText(t, "volcCodeLabel", "Verification code")
}
placeholder="123456"
value={code}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setCode(e.target.value)}
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") void handleSubmitCode();
}}
inputMode="numeric"
maxLength={6}
/>
{session?.error && <p className="text-sm text-red-500">{session.error}</p>}
<div className="flex items-center justify-between">
<Button
variant="secondary"
size="sm"
loading={resending}
disabled={resendCountdown > 0}
onClick={handleResend}
>
{resendCountdown > 0
? providerText(t, "volcResendIn", "Resend in {s}s", { s: resendCountdown })
: providerText(t, "volcResend", "Resend code")}
</Button>
<div className="flex gap-2">
<Button variant="secondary" size="sm" onClick={handleCancelSession}>
{providerText(t, "back", "Back")}
</Button>
<Button
size="sm"
loading={submittingCode}
disabled={code.trim().length < 4}
onClick={handleSubmitCode}
>
{providerText(t, "volcLogin", "Log in")}
</Button>
</div>
</div>
</>
)}
</>
)}
{showPolling && (
<div className="flex items-center gap-3 py-2">
<span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<p className="text-sm text-text-muted">
{phase === "submitting"
? providerText(
t,
"volcSubmitting",
"Submitting code and extracting console cookies..."
)
: providerText(t, "volcStarting", "Starting Volcano login...")}
</p>
</div>
)}
{done && phase === "success" && (
<div className="space-y-3">
<p className="text-sm font-medium text-green-600">
{providerText(t, "volcLoginSuccess", "Logged in to the Volcano Engine console")}
</p>
{bindingError ? (
<p className="text-sm text-red-500">
{providerText(t, "volcBindError", "Plan binding failed: {error}", {
error: bindingError,
})}
</p>
) : (
<div className="space-y-1 text-sm">
{connectedPlans.length > 0 ? (
connectedPlans.map((item) => (
<p key={item.plan} className="text-green-600">
{item.plan} plan connected
</p>
))
) : (
<p className="text-text-muted">
{providerText(
t,
"volcNoPlans",
"No Agent/Coding plans were detected on this account."
)}
</p>
)}
</div>
)}
<div className="flex justify-end">
<Button size="sm" onClick={handleClose}>
{providerText(t, "done", "Done")}
</Button>
</div>
</div>
)}
{done && phase !== "success" && (
<div className="space-y-3">
<p className="text-sm text-red-500">
{session?.error ||
(phase === "timeout"
? providerText(t, "volcTimeout", "Login timed out")
: phase === "cancelled"
? providerText(t, "volcCancelled", "Login cancelled")
: providerText(t, "volcFailed", "Login failed"))}
</p>
<div className="flex justify-end gap-2">
<Button variant="secondary" size="sm" onClick={reset}>
{providerText(t, "retry", "Retry")}
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => {
onClose();
onFallbackManual();
}}
>
{providerText(t, "volcManualLogin", "Manual browser login")}
</Button>
</div>
</div>
)}
</div>
</Modal>
);
}

View File

@@ -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<NextResponse> {
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 });
}
}

View File

@@ -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<NextResponse> {
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 }
);
}
}

View File

@@ -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<NextResponse> {
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 }
);
}
}

View File

@@ -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<NextResponse> {
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 });
}
}

View File

@@ -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<NextResponse> {
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 }
);
}
}

View File

@@ -10,6 +10,26 @@ export async function POST(request: Request): Promise<NextResponse> {
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 });

View File

@@ -95,7 +95,7 @@ export const LOCAL_ONLY_API_PREFIXES: ReadonlyArray<string> = [
*/
export const LOCAL_ONLY_API_PATTERNS: ReadonlyArray<RegExp> = [
/^\/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\/?$/,
];

View File

@@ -52,7 +52,7 @@ export const SPAWN_CAPABLE_PREFIXES: ReadonlyArray<string> = [
*/
export const SPAWN_CAPABLE_PATTERNS: ReadonlyArray<RegExp> = [
/^\/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).

View File

@@ -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<string>;
disabled: Set<string>;
fills: Record<string, string>;
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<string, string[]>;
}
function makeFakePlaywright() {
const state: FakeState = {
visible: new Set<string>(),
disabled: new Set<string>(),
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<typeof makeFakePlaywright>) {
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<typeof makeFakePlaywright>) {
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<void>((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");
});