import type { BrowserContext, Page } from "playwright"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { browserChannel, browserProfilePath, freshBrowserPerAccount, headless, idleCloseMs, } from "../config.js"; import { AccessError, log } from "../errors.js"; import { paceHost, resetPacing } from "../net.js"; /** * Cookies that mean "signed in" on the two sites this server reads. They are * deleted before every run. * * The product's promise is that it reads what a signed-out visitor is shown. * That promise is only worth anything if it is enforced rather than intended - * a user who once opened this profile by hand and logged in would otherwise * silently turn every later read into an authenticated one. */ const SESSION_COOKIES = ["auth_token", "ct0", "twid", "li_at", "li_rm", "JSESSIONID", "liap"]; let context: BrowserContext | null = null; /** One page load at a time. Parallel tabs against one host is what gets noticed. */ let queue: Promise = Promise.resolve(); let idleTimer: NodeJS.Timeout | null = null; export async function withPage(url: string, run: (page: Page) => Promise): Promise { const task = queue.then(async () => { cancelIdleClose(); await paceHost(new URL(url).host); // SUBIO_MCP_ISOLATE=1: a throwaway browser on a throwaway profile, torn down // when the read finishes. See `freshBrowserPerAccount` for why this is not // the default and does not do what it looks like it does. if (freshBrowserPerAccount()) return runThrowaway(run); const ctx = await open(); // Deliberately the same tab every time. One window for a whole scan is both // lighter on the machine and less conspicuous than a tab per account. const page = ctx.pages()[0] ?? (await ctx.newPage()); try { return await run(page); } finally { scheduleIdleClose(); } }); // Keep the chain alive even when this call rejects, or one failure poisons // every later tool call. queue = task.then( () => undefined, () => undefined, ); return task as Promise; } async function runThrowaway(run: (page: Page) => Promise): Promise { const profile = mkdtempSync(join(tmpdir(), "subio-mcp-")); let ctx: BrowserContext | null = null; try { ctx = await launch(profile); log("browser", "opened a throwaway browser for this account (SUBIO_MCP_ISOLATE=1)"); const page = ctx.pages()[0] ?? (await ctx.newPage()); return await run(page); } finally { await ctx?.close().catch(() => undefined); rmSync(profile, { recursive: true, force: true }); } } async function open(): Promise { if (context) return context; context = await launch(browserProfilePath()); await signOut(context); return context; } async function launch(profile: string): Promise { const { chromium } = await import("playwright").catch(() => { throw new AccessError( "browser-missing", "Playwright is not installed. Run `npx playwright install chromium` once.", ); }); // Prefer a Chrome the machine already has over Playwright's own 150MB // download. Both are served happily; only headless is not. const channels = browserChannel() ? [browserChannel()] : ["chrome", "msedge", undefined]; let lastError: unknown = new Error("no browser could be started"); for (const channel of channels) { try { const opened = await chromium.launchPersistentContext(profile, { channel, headless: headless(), viewport: { width: 1280, height: 1400 }, }); log("browser", `driving ${channel ?? "bundled chromium"}${headless() ? " headless" : ""}`); return opened; } catch (error) { lastError = error; } } // Playwright's message is a multi-line ASCII box; the first line is the part // that says what went wrong. const detail = lastError instanceof Error ? lastError.message.split("\n")[0] : String(lastError); throw new AccessError( "browser-missing", `Could not start a browser: ${detail}. Install Google Chrome, or run \`npx playwright install chromium\`.`, ); } async function signOut(ctx: BrowserContext) { const before = await ctx.cookies(); const found = before.filter((cookie) => SESSION_COOKIES.includes(cookie.name)); if (found.length === 0) return; for (const cookie of found) { await ctx.clearCookies({ name: cookie.name, domain: cookie.domain }).catch(() => undefined); } log("browser", `cleared ${found.length} session cookie(s); reading signed out`); } /** * Nobody should have to remember to close a browser. After a scan the window has * no reason to exist, and a Chrome left running is the thing a user feels. */ function scheduleIdleClose() { cancelIdleClose(); const idle = idleCloseMs(); if (idle === 0 || !context) return; idleTimer = setTimeout(() => { log("browser", `idle for ${Math.round(idle / 1000)}s; closing the window`); void closeBrowser(); }, idle); // Never hold the process open just to run this timer. idleTimer.unref(); } function cancelIdleClose() { if (idleTimer) clearTimeout(idleTimer); idleTimer = null; } export async function closeBrowser() { cancelIdleClose(); await context?.close().catch(() => undefined); context = null; resetPacing(); } export function browserIsOpen() { return context !== null; }