/** * minimax-browser-auth.ts * * MiniMax browser authentication using a real Chrome profile. * * SECURITY RULES: * - Human owns authentication. Agent never receives credentials. * - No username, password, raw cookies, or session tokens stored. * - Real Chrome owns the login flow via a persistent profile. * - Profile is stored at ~/.pi-harness-runtime/browser-profiles/minimax/ */ import * as fs from "node:fs"; import * as path from "node:path"; import * as os from "node:os"; import * as net from "node:net"; import { fileURLToPath } from "node:url"; import { createRequire } from "node:module"; import { execFileSync, spawn, type ChildProcess } from "node:child_process"; import { createInterface } from "node:readline/promises"; import { chromium, type Browser, type BrowserContext, type Page, } from "playwright"; // ESM __dirname shim (not available in ESM without this) const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const _require = createRequire(import.meta.url); export interface MinimaxAuthStatus { provider: "minimax"; authenticated: boolean; checked_at: string; page_url: string; detected_text_sample: string | null; profile_path: string; usage_lines?: string[]; error_message?: string; } export interface MinimaxBrowserAuthConfig { profilePath?: string; statusPath?: string; targetUrl?: string; chromeExecutablePath?: string; authTimeoutMs?: number; headless?: boolean; cdpPort?: number; /** Suppress console output for background/runtime-driven scrapes. */ quiet?: boolean; /** Override live session detection — used by tests to bypass real daemon. */ forceNoLiveSession?: boolean; } const DEFAULT_USAGE_KEYWORDS = [ "Usage", "Token", "Plan", "Credits", "Reset", "Limit", "5h", "Weekly", "quota", "used", "coding_plan", "subscription", ]; export function getRuntimeDir(): string { return path.join(os.homedir(), ".pi-harness-runtime"); } export function getProfileDir(): string { return path.join(getRuntimeDir(), "browser-profiles", "minimax"); } export function getStatusPath(): string { return path.join(getRuntimeDir(), "auth", "minimax-auth-status.json"); } export interface MinimaxLiveBrowserSession { profile_path: string; target_url: string; chrome_path: string; debugging_port: number; pid: number; started_at: string; } export function getLiveSessionPath(): string { return path.join(getRuntimeDir(), "auth", "minimax-live-browser.json"); } function ensureDirs(config: MinimaxBrowserAuthConfig): void { const profileDir = config.profilePath ?? getProfileDir(); const statusPath = config.statusPath ?? getStatusPath(); fs.mkdirSync(path.dirname(profileDir), { recursive: true }); fs.mkdirSync(path.dirname(statusPath), { recursive: true }); // Also ensure the cookie drop folder exists fs.mkdirSync(path.join(getRuntimeDir(), "cookies"), { recursive: true }); } export function saveAuthStatus( status: MinimaxAuthStatus, config?: MinimaxBrowserAuthConfig, ): void { const statusPath = config?.statusPath ?? getStatusPath(); ensureDirs(config ?? {}); fs.writeFileSync(statusPath, JSON.stringify(status, null, 2)); } function loadSavedAuthStatus( config?: MinimaxBrowserAuthConfig, ): MinimaxAuthStatus | null { const statusPath = config?.statusPath ?? getStatusPath(); if (!fs.existsSync(statusPath)) { return null; } try { return JSON.parse( fs.readFileSync(statusPath, "utf-8"), ) as MinimaxAuthStatus; } catch { return null; } } function saveLiveBrowserSession( session: MinimaxLiveBrowserSession, liveSessionPath?: string, ): void { const p = liveSessionPath ?? getLiveSessionPath(); fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, JSON.stringify(session, null, 2)); } export function loadLiveBrowserSession( liveSessionPath?: string, ): MinimaxLiveBrowserSession | null { const p = liveSessionPath ?? getLiveSessionPath(); if (!fs.existsSync(p)) { return null; } try { return JSON.parse(fs.readFileSync(p, "utf-8")) as MinimaxLiveBrowserSession; } catch { return null; } } function clearLiveBrowserSession(liveSessionPath?: string): void { const p = liveSessionPath ?? getLiveSessionPath(); if (fs.existsSync(p)) { fs.rmSync(p, { force: true }); } } export function detectUsagePage(bodyText: string): { detected: boolean; sample: string | null; } { const words = DEFAULT_USAGE_KEYWORDS.map((k) => k.toLowerCase()); const lowerText = bodyText.toLowerCase(); const foundKeywords = words.filter((w) => lowerText.includes(w)); const detected = foundKeywords.length >= 2; const sample = detected ? bodyText.substring(0, 200).replace(/\s+/g, " ").trim() : null; return { detected, sample }; } const DEFAULT_AUTH_TIMEOUT_MS = 300000; function delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } function hasProfileData(profileDir: string): boolean { if (!fs.existsSync(profileDir)) { return false; } const markers = [ path.join(profileDir, "Local State"), path.join(profileDir, "First Run"), path.join(profileDir, "Default", "Preferences"), path.join(profileDir, "Default", "Cookies"), path.join(profileDir, "Default", "History"), ]; if (markers.some((marker) => fs.existsSync(marker))) { return true; } try { return fs.readdirSync(profileDir).length > 0; } catch { return false; } } function getChromeExecutablePath( config: MinimaxBrowserAuthConfig = {}, ): string { const configuredPath = config.chromeExecutablePath ?? process.env.PI_HARNESS_CHROME_PATH ?? process.env.GOOGLE_CHROME_BIN ?? process.env.CHROME_PATH; const candidates = configuredPath ? [configuredPath] : []; switch (process.platform) { case "darwin": candidates.push( "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta", ); break; case "win32": candidates.push( "C:Program FilesGoogleChromeApplicationchrome.exe", "C:Program Files (x86)GoogleChromeApplicationchrome.exe", path.join( process.env.LOCALAPPDATA ?? "", "Google", "Chrome", "Application", "chrome.exe", ), ); break; default: candidates.push( "/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/snap/bin/google-chrome", ); } for (const candidate of candidates) { if (candidate && fs.existsSync(candidate)) { return candidate; } } throw new Error( "Google Chrome executable not found. Install Google Chrome or set PI_HARNESS_CHROME_PATH.", ); } function getChromeArgs(): string[] { const args = ["--no-first-run", "--no-default-browser-check"]; if (process.platform === "linux") { args.push("--password-store=basic"); } return args; } function getAuthTimeoutMs(config: MinimaxBrowserAuthConfig): number { const configuredTimeout = config.authTimeoutMs ?? (process.env.PI_HARNESS_AUTH_TIMEOUT_MS ? Number(process.env.PI_HARNESS_AUTH_TIMEOUT_MS) : undefined); if ( typeof configuredTimeout === "number" && Number.isFinite(configuredTimeout) && configuredTimeout > 0 ) { return configuredTimeout; } return DEFAULT_AUTH_TIMEOUT_MS; } async function getFreePort(preferredPort?: number): Promise { if (preferredPort) return preferredPort; return new Promise((resolve, reject) => { const server = net.createServer(); server.unref(); server.on("error", reject); server.listen(0, "127.0.0.1", () => { const address = server.address(); if (!address || typeof address === "string") { server.close(); reject(new Error("Could not allocate a Chrome debugging port")); return; } const { port } = address; server.close((closeError) => { if (closeError) { reject(closeError); return; } resolve(port); }); }); }); } async function connectToChromeOverCdp( port: number, timeoutMs = 15000, ): Promise { const endpoint = `http://127.0.0.1:${port}`; const deadline = Date.now() + timeoutMs; let lastError: unknown; while (Date.now() < deadline) { try { return await chromium.connectOverCDP(endpoint); } catch (error) { lastError = error; await delay(500); } } throw new Error( `Could not connect to Chrome DevTools at ${endpoint}: ${String(lastError)}`, ); } function getCurrentPage(context: BrowserContext): Page | null { const pages = context.pages().filter((p) => !p.isClosed()); return pages.length > 0 ? pages[pages.length - 1] : null; } async function waitForPage( context: BrowserContext, timeoutMs = 15000, ): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const page = getCurrentPage(context); if (page) return page; await delay(250); } throw new Error("Chrome launched but no page became available"); } async function closeBrowserResources( browser: Browser | null, chromeProcess: ChildProcess | null, ): Promise { if (browser) await browser.close().catch(() => {}); if ( chromeProcess && chromeProcess.exitCode === null && !chromeProcess.killed ) { chromeProcess.kill("SIGTERM"); } } async function launchChromeForAuthentication( profileDir: string, targetUrl: string, config: MinimaxBrowserAuthConfig, ): Promise<{ browser: Awaited>; chromeProcess: ChildProcess; chromePath: string; cdpPort: number; }> { const chromePath = getChromeExecutablePath(config); const cdpPort = await getFreePort(config.cdpPort); const chromeArgs = [ `--remote-debugging-port=${cdpPort}`, `--user-data-dir=${profileDir}`, "--new-window", ...getChromeArgs(), targetUrl, ]; // nosemgrep: javascript.lang.security.detect-child-process.detect-child-process const chromeProcess = spawn(chromePath, chromeArgs, { stdio: "ignore" }); const browser = await connectToChromeOverCdp(cdpPort); const context = browser.contexts()[0] as BrowserContext; if (!context) { await closeBrowserResources(browser, chromeProcess); throw new Error( "Connected to Chrome, but no browser context was available", ); } return { browser, chromeProcess, chromePath, cdpPort }; } /** * Manual authentication flow: launches real Chrome so Google login works, * waits for user to sign in, then confirms at the TTY. */ export async function authenticateWithPersistentBrowser( config: MinimaxBrowserAuthConfig = {}, ): Promise { const profileDir = config.profilePath ?? getProfileDir(); const targetUrl = config.targetUrl ?? "https://platform.minimax.io/console/usage"; ensureDirs(config); console.log("=".repeat(60)); console.log("🔐 MiniMax Browser Authentication (Persistent Mode)"); console.log("=".repeat(60)); console.log(""); console.log("SECURITY: Human owns authentication."); console.log("- Login once, profile is saved for reuse"); console.log(""); console.log("Profile directory: " + profileDir); console.log(""); let browser: Browser | null = null; let chromeProcess: ChildProcess | null = null; try { const isFirstRun = !hasProfileData(profileDir); console.log("🚀 Launching real Google Chrome..."); if (isFirstRun) { console.log(" First run - creating new Chrome profile"); } else { console.log(" Reusing existing Chrome profile"); } const launched = await launchChromeForAuthentication( profileDir, targetUrl, config, ); browser = launched.browser; chromeProcess = launched.chromeProcess; const context = browser.contexts()[0]; console.log(" Chrome: " + launched.chromePath); console.log(" DevTools port: " + launched.cdpPort); console.log(""); console.log("🖥️ Waiting for MiniMax / Google login flow..."); let page = await waitForPage(context, 15000); await page .waitForLoadState("domcontentloaded", { timeout: 15000 }) .catch(() => {}); let loginComplete = false; let lastLoggedUrl = ""; let loginHintShown = false; const startTime = Date.now(); const authTimeoutMs = getAuthTimeoutMs(config); while (Date.now() - startTime < authTimeoutMs) { page = getCurrentPage(context) ?? page; if (!page || page.isClosed()) { console.log("Browser closed by user."); const status: MinimaxAuthStatus = { provider: "minimax", authenticated: false, checked_at: new Date().toISOString(), page_url: "", detected_text_sample: null, profile_path: profileDir, error_message: "Browser closed by user", }; saveAuthStatus(status, config); return status; } const currentUrl = page.url(); if (currentUrl && currentUrl !== lastLoggedUrl) { console.log(" 🔗 URL: " + currentUrl); lastLoggedUrl = currentUrl; } const isGoogleLogin = currentUrl.includes("accounts.google.com"); const isMiniMaxLogin = currentUrl.includes("unified-login") || currentUrl.includes("login"); if ((isGoogleLogin || isMiniMaxLogin) && !loginHintShown) { console.log(""); console.log("🔓 Sign in in the Chrome window that just opened"); console.log(" This uses real Chrome so Google login is allowed"); console.log(" Close browser or press Ctrl+C to cancel"); console.log(""); loginHintShown = true; } const bodyText = (await page.textContent("body").catch(() => "")) ?? ""; const { detected } = detectUsagePage(bodyText); if (detected) { loginComplete = true; break; } await delay(1500); } if (!loginComplete) { console.log(""); console.log("⏰ Browser stayed open after timeout. Please close it."); console.log(""); await waitForChromeToExit(chromeProcess); const status: MinimaxAuthStatus = { provider: "minimax", authenticated: false, checked_at: new Date().toISOString(), page_url: "", detected_text_sample: null, profile_path: profileDir, error_message: "Login not completed within timeout", }; saveAuthStatus(status, config); browser = null; chromeProcess = null; return status; } // Usage page detected — wait for human to close the browser console.log(""); console.log("✅ Usage page reached!"); console.log("💾 Profile is being saved automatically."); console.log(""); console.log("⚠️ Please close the Chrome window now."); console.log(""); await waitForChromeToExit(chromeProcess); const confirmed = await confirmUsagePageReached(30000); const status: MinimaxAuthStatus = { provider: "minimax", authenticated: confirmed, checked_at: new Date().toISOString(), page_url: targetUrl, detected_text_sample: confirmed ? "Usage page confirmed by user" : null, profile_path: profileDir, }; saveAuthStatus(status, config); console.log(""); if (confirmed) { console.log( "✅ Authentication saved! Future scrapes will use this profile.", ); } else { console.log("⚠️ Auth not confirmed. Run 'auth' again after login."); } browser = null; chromeProcess = null; return status; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); console.error("❌ Error: " + errorMessage); await closeBrowserResources(browser, chromeProcess); const status: MinimaxAuthStatus = { provider: "minimax", authenticated: false, checked_at: new Date().toISOString(), page_url: "", detected_text_sample: null, profile_path: profileDir, error_message: errorMessage, }; saveAuthStatus(status, config); return status; } } async function waitForChromeToExit(chromeProcess: ChildProcess): Promise { return new Promise((resolve) => { // macOS: helper processes keep chrome running even after window closes // Use process group to kill all related processes const timeout = setTimeout(() => { console.log( " (timeout waiting for Chrome exit — killing process group)", ); try { process.kill(chromeProcess.pid!, "SIGTERM"); } catch (_e) { /* ignore */ } resolve(); }, 15000); chromeProcess.once("exit", () => { clearTimeout(timeout); resolve(); }); // Also resolve on Enter (TTY fallback) if (process.stdin.isTTY) { console.log(" Press ENTER after closing Chrome..."); process.stdin.once("data", () => { clearTimeout(timeout); resolve(); }); } }); } async function confirmUsagePageReached(_timeoutMs = 30000): Promise { if (!process.stdin.isTTY) { console.log("(non-TTY: auto-confirming)"); return true; } const rl = createInterface({ input: process.stdin, output: process.stdout }); const answer = await rl.question( "✅ Did you reach the MiniMax usage page? (y/N): ", ); rl.close(); return answer.trim().toLowerCase() === "y"; } /** * Extract usage-relevant lines from page body text. * Splits on newlines AND HTML tag boundaries, then filters for keywords. */ export function extractUsageLines(bodyText: string): string[] { // Strip