/** * Deterministic, scripted dashboard login for `rp ai-test`. * * Why scripted (not agent-driven): the dashboard TOTP is only valid ~30s. * If the slow AI agent typed it, the code would expire before it reached the * OTP field. A scripted Playwright login fetches the fresh TOTP and types it * within a couple of seconds, so the window never lapses. The completed login * sets cookies + the `login_token` localStorage entry naturally — we add the * one `${orgId}_sandbox` key the dashboard reads (mirroring root-web's * tests/global-setup.ts), snapshot the resulting session into * storageState.json, and hand that to every scenario's Playwright MCP browser * via `--storage-state`. The AI agent therefore never sees the credentials at * all, and starts each scenario already authenticated. */ import * as fs from 'node:fs'; import path from 'node:path'; import { assertSandboxModeEnabled, ProductionGuardError } from './host-guard'; export const STORAGE_STATE_FILENAME = 'storageState.json'; /** Structural subset of the Playwright API we drive — keeps the helper testable. */ export interface PageLike { goto(url: string, opts?: { waitUntil?: string; timeout?: number }): Promise; fill(selector: string, value: string, opts?: { timeout?: number }): Promise; type(selector: string, text: string, opts?: { timeout?: number; delay?: number }): Promise; click(selector: string, opts?: { timeout?: number }): Promise; waitForSelector(selector: string, opts?: { timeout?: number; state?: string }): Promise; evaluate(fn: (arg: A) => R, arg: A): Promise; } export interface ContextLike { newPage(): Promise; storageState(opts: { path: string }): Promise; } export interface BrowserLike { newContext(): Promise; close(): Promise; } export interface EstablishSessionParams { dashboardUrl: string; creds: { username: string; password: string; totp: string }; /** Org whose `${orgId}_sandbox` localStorage flag the dashboard reads. */ organizationId: string; /** * Private, disposable dir the authenticated session is written into. This is * the live login token — it must NOT be the user-facing run/output dir (which * holds PR-attachable screenshots and sits inside the module repo). Callers * pass an OS temp dir and delete it once the run ends. */ sessionDir: string; timeoutMs?: number; /** Override for tests. Production leaves undefined → headless Chromium. */ launchBrowser?: () => Promise; } const defaultLaunchBrowser = async (): Promise => { // Lazy import so the CLI doesn't pay Playwright's load cost unless ai-test runs. const { chromium } = await import('playwright'); return (await chromium.launch({ headless: true })) as unknown as BrowserLike; }; /** * Log in once and persist the session. Returns the storageState.json path. * Throws a friendly error if Playwright isn't installed or the login fails. */ export const establishSession = async (params: EstablishSessionParams): Promise => { const { dashboardUrl, creds, organizationId, sessionDir, timeoutMs = 60_000 } = params; const storageStatePath = path.join(sessionDir, STORAGE_STATE_FILENAME); const launchBrowser = params.launchBrowser ?? defaultLaunchBrowser; let browser: BrowserLike; try { browser = await launchBrowser(); } catch (error) { const msg = (error as Error).message ?? ''; if (/Cannot find module 'playwright'|MODULE_NOT_FOUND/i.test(msg)) { throw new Error( 'Playwright is required by `rp ai-test` for the dashboard login. Install browsers with `npx playwright install chromium` (Playwright itself ships with the rp CLI) and retry.', ); } throw error; } try { const context = await browser.newContext(); const page = await context.newPage(); await page.goto(dashboardUrl, { waitUntil: 'networkidle', timeout: timeoutMs }); await page.waitForSelector('form #email', { timeout: timeoutMs }); await page.fill('form #email', creds.username); await page.fill('form #password', creds.password); await page.click('#loginButton'); await page.waitForSelector('form #otp', { timeout: timeoutMs }); // Type the code character-by-character (NOT fill): the dashboard's 2FA form // is a react-hook-form whose per-keystroke onChange both flips the form to // valid and auto-submits once a complete 6-digit code is entered (root-web // two-fa.tsx). A single fill() sets the value without those keystroke events, // so the form never becomes valid and never submits — the field just sits // mounted with the value typed and aria-invalid="false". await page.type('form #otp', creds.totp); // Belt-and-braces in case auto-submit didn't fire: click the explicit submit // button. If auto-submit already detached the form, the click can't resolve — // ignore that and fall through to the detach wait (our "logged in" signal). try { await page.click('#twoFaButton', { timeout: 5_000 }); } catch { // Form already submitted/detached — nothing to click. } await page.waitForSelector('form #otp', { state: 'detached', timeout: timeoutMs }); // This callback is serialized and executed inside the browser page, where // localStorage exists. Reach it via globalThis so the Node tsconfig (no DOM // lib) still type-checks. We set the sandbox flag AND read it straight back: // app.rootplatform.com serves production data when this flag isn't "true", so // the read-back (asserted below) is the guard that keeps the run out of // production — the hostname can't tell sandbox from production. const sandboxFlag = await page.evaluate((orgId: string) => { const ls = ( globalThis as unknown as { localStorage: { setItem(k: string, v: string): void; getItem(k: string): string | null }; } ).localStorage; ls.setItem(`${orgId}_sandbox`, 'true'); return ls.getItem(`${orgId}_sandbox`); }, organizationId); // Throws ProductionGuardError (re-thrown verbatim below) if sandbox mode // didn't stick — we never persist a production-mode session. assertSandboxModeEnabled(organizationId, sandboxFlag); await context.storageState({ path: storageStatePath }); // The session token is a credential; lock it to owner read/write only. fs.chmodSync(storageStatePath, 0o600); return storageStatePath; } catch (error) { // Never mask the production guard behind a "could not log in" message — a // sandbox-mode breach is a hard, distinct abort. if (error instanceof ProductionGuardError) throw error; throw new Error( `rp ai-test could not log in to ${dashboardUrl}: ${(error as Error).message}. ` + 'Check the 1Password item credentials and that the sandbox test account exists and has TOTP configured.', ); } finally { await browser.close(); } };