diff --git a/packages/cyberstrike/src/hackbrowser-subprocess/hackbrowser-worker.ts b/packages/cyberstrike/src/hackbrowser-subprocess/hackbrowser-worker.ts index 3475bbe11..795a055a0 100644 --- a/packages/cyberstrike/src/hackbrowser-subprocess/hackbrowser-worker.ts +++ b/packages/cyberstrike/src/hackbrowser-subprocess/hackbrowser-worker.ts @@ -269,8 +269,16 @@ async function main(): Promise { } }) - // Wait until stdin closes (parent terminates or closes the pipe) - await new Promise((resolve) => rl.once("close", resolve)) + // Wait until stdin closes (parent terminates or closes the pipe). + // Abort the crawl so run()'s disconnect-wait breaks and the worker exits cleanly. + await new Promise((resolve) => + rl.once("close", () => { + controller.abort() + resolve() + }), + ) + // Backstop: if runWorker is still hanging 5s after stdin close, force exit + setTimeout(() => process.exit(0), 5000).unref() } async function runWorker(opts: WorkerOptions, signal: AbortSignal): Promise { diff --git a/packages/cyberstrike/src/tool/hackbrowser-launcher.ts b/packages/cyberstrike/src/tool/hackbrowser-launcher.ts index eac188e0f..440229684 100644 --- a/packages/cyberstrike/src/tool/hackbrowser-launcher.ts +++ b/packages/cyberstrike/src/tool/hackbrowser-launcher.ts @@ -421,7 +421,9 @@ async function backgroundRun( } // Worker exited without sending result/error — unexpected crash. + // Force-kill to clean up any orphaned Chrome child processes. if (!receivedResult) { + try { proc.kill() } catch {} const exitCode = await proc.exited.catch(() => -1) const stderr = await Bun.readableStreamToText(proc.stderr as ReadableStream).catch(() => "") const message = @@ -463,7 +465,13 @@ async function backgroundRun( ) } finally { activeRuns.delete(sessionID) - proc.kill() + try { + const s = proc.stdin + if (s && typeof s !== "number") (s as any).end() + } catch {} + setTimeout(() => { + try { proc.kill() } catch {} + }, 10_000) } } diff --git a/packages/hackbrowser/src/agent.ts b/packages/hackbrowser/src/agent.ts index 0328353d1..e21066a05 100644 --- a/packages/hackbrowser/src/agent.ts +++ b/packages/hackbrowser/src/agent.ts @@ -86,6 +86,69 @@ const MAX_INLINE_DEPTH = 2 const LOGIN_SUCCESS_PATTERN = /POST\s+.*\/(login|signin|authenticate)\S*\s+\[200\]/i const SKIP_AUTO_DISCOVERY = /\b(logout|sign.?out|log.?out|delete.?account|reset.?data|revoke)\b/i +// ============================================================ +// Browser lifecycle detection +// ============================================================ + +interface BrowserHealth { + dead: boolean + reason: string +} + +function createBrowserHealth(): BrowserHealth { + return { dead: false, reason: "" } +} + +function attachLifecycleHandlers( + browser: import("playwright").Browser, + page: Page, + health: BrowserHealth, +): void { + browser.on("disconnected", () => { + health.dead = true + health.reason = "browser process disconnected" + log.error("browser disconnected — crawl will terminate") + }) + page.on("close", () => { + health.dead = true + health.reason = "page closed unexpectedly" + log.error("page closed — crawl will terminate") + }) + page.on("crash", () => { + health.dead = true + health.reason = "page renderer crashed" + log.error("page crashed — crawl will terminate") + }) +} + +function isBrowserDead(health: BrowserHealth): boolean { + return health.dead +} + +const BROWSER_WAIT_TIMEOUT = 30 * 60 * 1000 + +function waitForBrowserClose( + browser: import("playwright").Browser, + signal?: AbortSignal, +): Promise { + return new Promise((resolve) => { + let resolved = false + const done = () => { + if (resolved) return + resolved = true + clearTimeout(timer) + resolve() + } + const timer = setTimeout(done, BROWSER_WAIT_TIMEOUT) + browser.on("disconnected", done) + if (signal) { + if (signal.aborted) { done(); return } + signal.addEventListener("abort", done, { once: true }) + } + if (!browser.isConnected()) done() + }) +} + // ============================================================ // Post-Login Re-Discovery // ============================================================ @@ -1677,6 +1740,12 @@ async function runMultiCredential(config: AgentConfig, credentials: CredentialCo } const browser = await Stealth.connect({ cdp: config.cdp, headless: config.headless ?? false }) + const health = createBrowserHealth() + browser.on("disconnected", () => { + health.dead = true + health.reason = "browser process disconnected" + log.error("browser disconnected — multi-credential crawl will terminate") + }) // Single CyberStrike session for ALL credentials. Honor a host-provided // sessionID (cyberstrike injects this when /hackbrowser slash or the @@ -1701,15 +1770,45 @@ async function runMultiCredential(config: AgentConfig, credentials: CredentialCo const lastAuthHeaders = new Map>() for (const [credIndex, cred] of credentials.entries()) { - const browserContext = await browser.newContext(Stealth.contextOptions(browser.version())) + const browserContext = await browser.newContext(Stealth.contextOptions(browser.version(), config.headless ?? false)) await browserContext.addInitScript(Stealth.INIT_SCRIPT) if (panelOn) await browserContext.addInitScript(PANEL_INIT_SCRIPT) const page = await browserContext.newPage() + page.on("close", () => { + health.dead = true + health.reason = `page closed (credential: ${cred.id})` + log.error("page closed — multi-credential crawl will terminate", { credential: cred.id }) + }) + page.on("crash", () => { + health.dead = true + health.reason = `page crashed (credential: ${cred.id})` + log.error("page crashed — multi-credential crawl will terminate", { credential: cred.id }) + }) attachDialogAutoAccept(page) attachFileChooserAutoFill(page) // Navigate to target - await page.goto(targetUrl, { waitUntil: "domcontentloaded", timeout: 30000 }) + const mcInitNavErr = await page + .goto(targetUrl, { waitUntil: "domcontentloaded", timeout: 30000 }) + .then(() => null) + .catch((e: Error) => e) + if (mcInitNavErr) { + log.warn("initial navigation failed", { credential: cred.id, url: targetUrl, err: mcInitNavErr.message.split("\n")[0] }) + if (!config.headless && browser.isConnected()) { + log.info("browser stays open — navigate manually or close the window to finish") + await waitForBrowserClose(browser, config.signal) + } else { + await browser.close().catch(() => {}) + } + return { + sessionID: dryRun ? "" : sessionId, + capturedEndpoints: 0, + pagesExplored: 0, + totalSteps: 0, + errors: [`Initial navigation failed (credential: ${cred.id}): ${mcInitNavErr.message}`], + usage: usageAcc, + } + } // First panel event — identifies this context's credential before manual login. void csEmit(page, { @@ -1803,6 +1902,10 @@ async function runMultiCredential(config: AgentConfig, credentials: CredentialCo // BFS Loop — single loop, N contexts while (pageQueue.length > 0 && pagesExplored < maxPages) { + if (isBrowserDead(health)) { + log.error("browser died, terminating multi-credential crawl", { reason: health.reason, pagesExplored, captured: globalState.capturedEndpoints.size }) + break + } // Cancellation check at iteration boundary (Faz B.5). Multi-cred path // gets the same granularity as single-cred run() — all contexts share // the same signal so a single abort halts every in-flight context. @@ -1936,8 +2039,10 @@ async function runMultiCredential(config: AgentConfig, credentials: CredentialCo // design (§3.5.1). Even when fingerprints match, per-credential journey // state (empty-state queue, revisitCount, pageFingerprints) stays separate. for (const ctx of visitableContexts) { + if (isBrowserDead(health)) break log.info("exploring", { credential: ctx.id, url: entry.url }) const exploreInline = async (p: Page, url: string, depth: number): Promise => { + if (isBrowserDead(health)) return const found = await explorePageWithAI( p, url, @@ -1952,20 +2057,28 @@ async function runMultiCredential(config: AgentConfig, credentials: CredentialCo ) for (const u of found) enqueueWithContext(u, ctx.id, pageQueue, visitedPages, inScope, pathPatternCounts) } - const discovered = await explorePageWithAI( - ctx.page, - entry.url, - ctx.interceptor, - model, - globalState, - inScope, - ctx.id, - maxPages, - usageAcc, - { explore: exploreInline, depth: 0 }, - ) - for (const url of discovered) { - enqueueWithContext(url, ctx.id, pageQueue, visitedPages, inScope, pathPatternCounts) + try { + const discovered = await explorePageWithAI( + ctx.page, + entry.url, + ctx.interceptor, + model, + globalState, + inScope, + ctx.id, + maxPages, + usageAcc, + { explore: exploreInline, depth: 0 }, + ) + for (const url of discovered) { + enqueueWithContext(url, ctx.id, pageQueue, visitedPages, inScope, pathPatternCounts) + } + } catch (err) { + if (isBrowserDead(health)) { + log.error("exploration aborted — browser died mid-page", { credential: ctx.id, url: entry.url, reason: health.reason }) + break + } + log.warn("exploration error", { credential: ctx.id, url: entry.url, err: String(err) }) } } @@ -1986,8 +2099,13 @@ async function runMultiCredential(config: AgentConfig, credentials: CredentialCo } // Final drain — late async requests, no enrichment (credential_id fallback) - log.info("multi-credential exploration complete, draining remaining requests") - await contexts[0]?.page.waitForTimeout(2000) + const browserDied = isBrowserDead(health) + log.info(browserDied ? "browser died, draining captured requests" : "multi-credential exploration complete, draining remaining requests", { + pagesExplored, + captured: globalState.capturedEndpoints.size, + ...(browserDied ? { reason: health.reason } : {}), + }) + if (!browserDied) await contexts[0]?.page.waitForTimeout(2000).catch(() => {}) await drainPageCaptures(captureQueues, captureHandlers, new Map(), null, null) @@ -1996,32 +2114,40 @@ async function runMultiCredential(config: AgentConfig, credentials: CredentialCo credentials: contexts.map((c) => c.id), totalSteps: globalState.totalSteps, capturedEndpoints: globalState.capturedEndpoints.size, + browserDied, }) // Panel done event — one per context so each tab shows its own summary. - const mutationCount = [...globalState.capturedEndpoints].filter((e) => /^(POST|PUT|PATCH|DELETE)\s/.test(e)).length - const credentialIds = contexts.map((c) => c.id) - for (const ctx of contexts) { - void csEmit(ctx.page, { - type: "crawl-done", - summary: { - pagesExplored, - capturedEndpoints: globalState.capturedEndpoints.size, - mutations: mutationCount, - credentials: credentialIds, - }, - }) + if (!browserDied) { + const mutationCount = [...globalState.capturedEndpoints].filter((e) => /^(POST|PUT|PATCH|DELETE)\s/.test(e)).length + const credentialIds = contexts.map((c) => c.id) + for (const ctx of contexts) { + void csEmit(ctx.page, { + type: "crawl-done", + summary: { + pagesExplored, + capturedEndpoints: globalState.capturedEndpoints.size, + mutations: mutationCount, + credentials: credentialIds, + }, + }) + } + await contexts[0]?.page.waitForTimeout(600).catch(() => {}) } - await contexts[0]?.page.waitForTimeout(600).catch(() => {}) - await browser.close() + if (!config.headless && browser.isConnected()) { + log.info("crawl complete — browser stays open, close the window to finish") + await waitForBrowserClose(browser, config.signal) + } else if (!browserDied) { + await browser.close().catch(() => {}) + } return { sessionID: dryRun ? "" : sessionId, capturedEndpoints: globalState.capturedEndpoints.size, pagesExplored, totalSteps: globalState.totalSteps, - errors: [], + errors: browserDied ? [`Browser died: ${health.reason}. Captured ${globalState.capturedEndpoints.size} endpoints before failure.`] : [], usage: usageAcc, } } @@ -2079,10 +2205,12 @@ export async function run(config: AgentConfig): Promise { } const browser = await Stealth.connect({ cdp: config.cdp, headless: config.headless ?? false }) - const context: BrowserContext = await browser.newContext(Stealth.contextOptions(browser.version())) + const health = createBrowserHealth() + const context: BrowserContext = await browser.newContext(Stealth.contextOptions(browser.version(), config.headless ?? false)) await context.addInitScript(Stealth.INIT_SCRIPT) if (panelOn) await context.addInitScript(PANEL_INIT_SCRIPT) const page = await context.newPage() + attachLifecycleHandlers(browser, page, health) attachDialogAutoAccept(page) attachFileChooserAutoFill(page) @@ -2127,8 +2255,29 @@ export async function run(config: AgentConfig): Promise { } }, 500) + try { // Navigate to target and authenticate - await page.goto(targetUrl, { waitUntil: "domcontentloaded", timeout: 30000 }) + const initNavErr = await page + .goto(targetUrl, { waitUntil: "domcontentloaded", timeout: 30000 }) + .then(() => null) + .catch((e: Error) => e) + if (initNavErr) { + log.warn("initial navigation failed", { url: targetUrl, err: initNavErr.message.split("\n")[0] }) + if (!config.headless && browser.isConnected()) { + log.info("browser stays open — navigate manually or close the window to finish") + await waitForBrowserClose(browser, config.signal) + } else { + await browser.close().catch(() => {}) + } + return { + sessionID: dryRun ? "" : sessionID!, + capturedEndpoints: 0, + pagesExplored: 0, + totalSteps: 0, + errors: [`Initial navigation failed: ${initNavErr.message}`], + usage: usageAcc, + } + } // Panel init — after first goto so the host document exists. void csEmit(page, { @@ -2195,6 +2344,10 @@ export async function run(config: AgentConfig): Promise { let pagesExplored = 0 while (globalState.pageQueue.length > 0 && pagesExplored < maxPages) { + if (isBrowserDead(health)) { + log.error("browser died, terminating crawl", { reason: health.reason, pagesExplored, captured: globalState.capturedEndpoints.size }) + break + } // Cancellation check at iteration boundary (Faz B.5). Granularity is // per-page — current LLM call / page exploration completes before // we exit. Browser closes via the existing finally below. @@ -2311,6 +2464,7 @@ export async function run(config: AgentConfig): Promise { // ASP.NET postback) is explored INLINE while its state is alive (handleNavigation), // then its discoveries are enqueued the same way as top-level discoveries. const exploreInline = async (p: Page, url: string, depth: number): Promise => { + if (isBrowserDead(health)) return const found = await explorePageWithAI( p, url, @@ -2325,18 +2479,27 @@ export async function run(config: AgentConfig): Promise { ) for (const u of found) enqueueUrl(u, globalState, inScope) } - const discovered = await explorePageWithAI( - page, - currentUrl, - interceptor, - model, - globalState, - inScope, - SINGLE_CRED, - maxPages, - usageAcc, - { explore: exploreInline, depth: 0 }, - ) + let discovered: string[] = [] + try { + discovered = await explorePageWithAI( + page, + currentUrl, + interceptor, + model, + globalState, + inScope, + SINGLE_CRED, + maxPages, + usageAcc, + { explore: exploreInline, depth: 0 }, + ) + } catch (err) { + if (isBrowserDead(health)) { + log.error("exploration aborted — browser died mid-page", { url: currentUrl, reason: health.reason }) + break + } + log.warn("exploration error", { url: currentUrl, err: String(err) }) + } // Enqueue new same-host pages (auth URLs deferred during anonymous phase) let newEnqueued = 0 @@ -2367,9 +2530,13 @@ export async function run(config: AgentConfig): Promise { } // Final drain - log.info("exploration complete, draining remaining requests") - await page.waitForTimeout(2000) - clearInterval(drainInterval) + const browserDied = isBrowserDead(health) + log.info(browserDied ? "browser died, draining captured requests" : "exploration complete, draining remaining requests", { + pagesExplored, + captured: globalState.capturedEndpoints.size, + ...(browserDied ? { reason: health.reason } : {}), + }) + if (!browserDied) await page.waitForTimeout(2000).catch(() => {}) while (captureQueue.length > 0) { const captured = captureQueue.shift()! @@ -2381,29 +2548,52 @@ export async function run(config: AgentConfig): Promise { totalSteps: globalState.totalSteps, capturedEndpoints: globalState.capturedEndpoints.size, sessionID: dryRun ? undefined : sessionID, + browserDied, }) // Final panel event before teardown — gives pentester a visible "done" glow. - void csEmit(page, { - type: "crawl-done", - summary: { - pagesExplored, - capturedEndpoints: globalState.capturedEndpoints.size, - mutations: [...globalState.capturedEndpoints].filter((e) => /^(POST|PUT|PATCH|DELETE)\s/.test(e)).length, - credentials: [SINGLE_CRED], - }, - }) - // Let the done-glow render before tearing down. - await page.waitForTimeout(600).catch(() => {}) + if (!browserDied) { + void csEmit(page, { + type: "crawl-done", + summary: { + pagesExplored, + capturedEndpoints: globalState.capturedEndpoints.size, + mutations: [...globalState.capturedEndpoints].filter((e) => /^(POST|PUT|PATCH|DELETE)\s/.test(e)).length, + credentials: [SINGLE_CRED], + }, + }) + await page.waitForTimeout(600).catch(() => {}) + // Second drain pass — catch captures that arrived during csEmit/panel wait + while (captureQueue.length > 0) { + const captured = captureQueue.shift()! + await handleCapture(captured) + } + } - await browser.close() + if (!config.headless && browser.isConnected()) { + log.info("crawl complete — browser stays open, close the window to finish") + // Keep draining captures during manual browsing + const postCrawlDrain = setInterval(async () => { + while (captureQueue.length > 0) { + const captured = captureQueue.shift()! + await handleCapture(captured) + } + }, 500) + await waitForBrowserClose(browser, config.signal) + clearInterval(postCrawlDrain) + } else if (!browserDied) { + await browser.close().catch(() => {}) + } return { sessionID: dryRun ? "" : sessionID!, capturedEndpoints: globalState.capturedEndpoints.size, pagesExplored, totalSteps: globalState.totalSteps, - errors: [], + errors: browserDied ? [`Browser died: ${health.reason}. Captured ${globalState.capturedEndpoints.size} endpoints before failure.`] : [], usage: usageAcc, } + } finally { + clearInterval(drainInterval) + } } diff --git a/packages/hackbrowser/src/panel/inject.ts b/packages/hackbrowser/src/panel/inject.ts index d2d34fe7d..656161ccc 100644 --- a/packages/hackbrowser/src/panel/inject.ts +++ b/packages/hackbrowser/src/panel/inject.ts @@ -65,9 +65,10 @@ const PANEL_CSS = ` /* =========================== Card base =========================== */ .card { - position: absolute; + position: fixed; bottom: 16px; right: 16px; width: 360px; + max-width: calc(100vw - 32px); background: #0b0f14; border: 1px solid #1f2937; /* Non-blocking HUD: the agent's own observability overlay must NEVER intercept @@ -910,6 +911,20 @@ function handle(ev) { } } +// ---------- keep card inside viewport on resize ---------- +function clampCard() { + var vv = window.visualViewport; + var vw = vv ? vv.width : (document.documentElement.clientWidth || window.innerWidth); + var vh = vv ? vv.height : (document.documentElement.clientHeight || window.innerHeight); + var w = card.offsetWidth; + var h = card.offsetHeight; + card.style.right = Math.max(0, Math.min(16, vw - w - 16)) + 'px'; + card.style.bottom = Math.max(0, Math.min(16, vh - h - 16)) + 'px'; +} +window.addEventListener('resize', clampCard); +if (window.visualViewport) window.visualViewport.addEventListener('resize', clampCard); +clampCard(); + // ---------- render restored state ---------- setHeader(); setCounters(); diff --git a/packages/hackbrowser/src/stealth.ts b/packages/hackbrowser/src/stealth.ts index 3876fd51d..38405183d 100644 --- a/packages/hackbrowser/src/stealth.ts +++ b/packages/hackbrowser/src/stealth.ts @@ -1,18 +1,28 @@ import { existsSync } from "fs" import { chromium, type Browser, type LaunchOptions, type BrowserContextOptions } from "playwright" +const PLATFORM_ARGS = + process.platform === "linux" + ? ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage", "--no-zygote"] + : [] + const LAUNCH_ARGS = [ "--disable-blink-features=AutomationControlled", "--disable-features=IsolateOrigins,site-per-process", - "--no-sandbox", - "--disable-setuid-sandbox", - "--disable-dev-shm-usage", "--no-first-run", - "--no-zygote", + // Stability: prevent renderer throttling/death on window drag/resize/minimize + "--disable-backgrounding-occluded-windows", + "--disable-renderer-backgrounding", + "--disable-background-timer-throttling", + "--disable-hang-monitor", + "--disable-ipc-flooding-protection", + "--disable-component-update", + ...PLATFORM_ARGS, ] export function launchOptions(headless: boolean): LaunchOptions { - return { headless, args: LAUNCH_ARGS } + const args = headless ? LAUNCH_ARGS : [...LAUNCH_ARGS, "--window-size=1920,1080"] + return { headless, args } } export function userAgent(version: string): string { @@ -40,10 +50,10 @@ export async function connect(opts: { cdp?: string; headless: boolean }): Promis return chromium.launch(launchOptions(opts.headless)) } -export function contextOptions(version: string): BrowserContextOptions { +export function contextOptions(version: string, headless = true): BrowserContextOptions { return { userAgent: userAgent(version), - viewport: { width: 1920, height: 1080 }, + viewport: headless ? { width: 1920, height: 1080 } : null, screen: { width: 1920, height: 1080 }, locale: "en-US", timezoneId: "America/New_York",