From f9d1013bd0f6cf5d4fdca57a93ccadba8db3d673 Mon Sep 17 00:00:00 2001 From: orhanyildirim Date: Sun, 23 Aug 2026 18:40:03 -0400 Subject: [PATCH 01/14] fix(hackbrowser): add stability launch args, move sandbox flags to Linux-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 6 Chromium flags to prevent renderer throttling/death during 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 - Move --no-sandbox, --disable-setuid-sandbox, --disable-dev-shm-usage, --no-zygote to Linux-only (PLATFORM_ARGS) — these are Docker/Linux flags that can cause instability on macOS Closes part of #84 --- packages/hackbrowser/src/stealth.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/hackbrowser/src/stealth.ts b/packages/hackbrowser/src/stealth.ts index 3876fd51d..66bab946f 100644 --- a/packages/hackbrowser/src/stealth.ts +++ b/packages/hackbrowser/src/stealth.ts @@ -1,14 +1,23 @@ 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 { From 1683d27b4209e4b1f410e9aa7afec2a631a8d4a9 Mon Sep 17 00:00:00 2001 From: orhanyildirim Date: Sun, 23 Aug 2026 18:46:11 -0400 Subject: [PATCH 02/14] fix(hackbrowser): detect browser death and gracefully terminate crawl MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The browser could die mid-crawl (crash, disconnect, page close) with no detection — the agent continued issuing commands to a dead browser, wasting tokens and eventually throwing an unhandled error that lost all captured data. Changes: - Add BrowserHealth tracker with browser.on('disconnected'), page.on('close'), page.on('crash') event handlers - Check isBrowserDead() at each BFS iteration boundary (both single-cred and multi-cred paths) - Wrap explorePageWithAI in try-catch — on browser death, break the BFS loop instead of propagating the error - Graceful shutdown: drain captured requests, skip panel events and waitForTimeout on dead browser, report partial results in errors[] instead of throwing - browser.close() wrapped in .catch() to handle already-dead browser Both run() and runMultiCredential() are covered. Closes #84 --- packages/hackbrowser/src/agent.ts | 216 ++++++++++++++++++++++-------- 1 file changed, 158 insertions(+), 58 deletions(-) diff --git a/packages/hackbrowser/src/agent.ts b/packages/hackbrowser/src/agent.ts index 0328353d1..7ef8e4459 100644 --- a/packages/hackbrowser/src/agent.ts +++ b/packages/hackbrowser/src/agent.ts @@ -86,6 +86,45 @@ 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 +} + // ============================================================ // Post-Login Re-Discovery // ============================================================ @@ -1677,6 +1716,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 @@ -1705,6 +1750,16 @@ async function runMultiCredential(config: AgentConfig, credentials: CredentialCo 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) @@ -1803,6 +1858,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 +1995,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 +2013,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 +2055,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 +2070,35 @@ 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() + 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 +2156,12 @@ export async function run(config: AgentConfig): Promise { } const browser = await Stealth.connect({ cdp: config.cdp, headless: config.headless ?? false }) + const health = createBrowserHealth() const context: BrowserContext = await browser.newContext(Stealth.contextOptions(browser.version())) 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) @@ -2195,6 +2274,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 +2394,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 +2409,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,8 +2460,13 @@ export async function run(config: AgentConfig): Promise { } // Final drain - log.info("exploration complete, draining remaining requests") - await page.waitForTimeout(2000) + 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(() => {}) clearInterval(drainInterval) while (captureQueue.length > 0) { @@ -2381,29 +2479,31 @@ 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(() => {}) + } - await browser.close() + 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, } } From f04a319bd97c91b18a5340576c5c9c092073bcc3 Mon Sep 17 00:00:00 2001 From: orhanyildirim Date: Sun, 23 Aug 2026 18:58:40 -0400 Subject: [PATCH 03/14] fix(hackbrowser): dynamic viewport in headed mode so overlay follows window resize viewport: null when headless=false lets the CSS viewport track the actual window dimensions. The overlay panel (position:fixed) now repositions naturally on resize instead of staying pinned to the 1920x1080 virtual viewport. --window-size=1920,1080 launch arg keeps the initial size. --- packages/hackbrowser/src/agent.ts | 4 ++-- packages/hackbrowser/src/stealth.ts | 7 ++++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/hackbrowser/src/agent.ts b/packages/hackbrowser/src/agent.ts index 7ef8e4459..eb625fd3d 100644 --- a/packages/hackbrowser/src/agent.ts +++ b/packages/hackbrowser/src/agent.ts @@ -1746,7 +1746,7 @@ 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() @@ -2157,7 +2157,7 @@ export async function run(config: AgentConfig): Promise { const browser = await Stealth.connect({ cdp: config.cdp, headless: config.headless ?? false }) const health = createBrowserHealth() - const context: BrowserContext = await browser.newContext(Stealth.contextOptions(browser.version())) + 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() diff --git a/packages/hackbrowser/src/stealth.ts b/packages/hackbrowser/src/stealth.ts index 66bab946f..38405183d 100644 --- a/packages/hackbrowser/src/stealth.ts +++ b/packages/hackbrowser/src/stealth.ts @@ -21,7 +21,8 @@ const LAUNCH_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 { @@ -49,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", From dd92258d4791d4559d5d66b4d2294702ae716179 Mon Sep 17 00:00:00 2001 From: orhanyildirim Date: Sun, 23 Aug 2026 19:24:10 -0400 Subject: [PATCH 04/14] fix(panel): overlay card uses position:fixed + resize listener MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Card was position:absolute inside a fixed root — when the viewport diverged from the window (Playwright viewport lock) the card drifted off-screen. Now position:fixed on the card itself, max-width clamp, and a resize listener that keeps it within visible bounds. --- packages/hackbrowser/src/panel/inject.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/hackbrowser/src/panel/inject.ts b/packages/hackbrowser/src/panel/inject.ts index d2d34fe7d..d1e3dd0e8 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,18 @@ function handle(ev) { } } +// ---------- keep card inside viewport on resize ---------- +function clampCard() { + var vw = document.documentElement.clientWidth || window.innerWidth; + var vh = 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); +clampCard(); + // ---------- render restored state ---------- setHeader(); setCounters(); From 6392884552cd9f7ca22fbfd6791e568a8441d6fa Mon Sep 17 00:00:00 2001 From: orhanyildirim Date: Sun, 23 Aug 2026 19:24:17 -0400 Subject: [PATCH 05/14] fix(hackbrowser): keep browser open when crawl is cancelled by user When the user hits stop (AbortSignal), skip browser.close() so the window stays open for manual inspection. Both run() and runMultiCredential() paths affected. --- packages/hackbrowser/src/agent.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/hackbrowser/src/agent.ts b/packages/hackbrowser/src/agent.ts index eb625fd3d..6bb419049 100644 --- a/packages/hackbrowser/src/agent.ts +++ b/packages/hackbrowser/src/agent.ts @@ -2091,7 +2091,8 @@ async function runMultiCredential(config: AgentConfig, credentials: CredentialCo await contexts[0]?.page.waitForTimeout(600).catch(() => {}) } - await browser.close().catch(() => {}) + const cancelledByUser = config.signal?.aborted === true + if (!cancelledByUser) await browser.close().catch(() => {}) return { sessionID: dryRun ? "" : sessionId, @@ -2496,7 +2497,8 @@ export async function run(config: AgentConfig): Promise { await page.waitForTimeout(600).catch(() => {}) } - await browser.close().catch(() => {}) + const cancelledByUser = config.signal?.aborted === true + if (!cancelledByUser) await browser.close().catch(() => {}) return { sessionID: dryRun ? "" : sessionID!, From 9fb654e3df9210dd8d7b5bc278600f04d5bd5b44 Mon Sep 17 00:00:00 2001 From: orhanyildirim Date: Sun, 23 Aug 2026 19:30:38 -0400 Subject: [PATCH 06/14] fix(hackbrowser): wait for browser disconnect on user stop instead of killing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When user cancels the crawl (AbortSignal), the worker now waits for browser.on('disconnected') — keeping the browser alive until the user manually closes it. The worker process stays alive during this wait, so the launcher's IPC reader loop blocks naturally until the browser closes and the result is sent. Only applies in headed mode — headless crawls still close immediately. --- packages/hackbrowser/src/agent.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/hackbrowser/src/agent.ts b/packages/hackbrowser/src/agent.ts index 6bb419049..f538c8826 100644 --- a/packages/hackbrowser/src/agent.ts +++ b/packages/hackbrowser/src/agent.ts @@ -2092,7 +2092,12 @@ async function runMultiCredential(config: AgentConfig, credentials: CredentialCo } const cancelledByUser = config.signal?.aborted === true - if (!cancelledByUser) await browser.close().catch(() => {}) + if (cancelledByUser && !browserDied && !config.headless) { + log.info("crawl stopped — browser stays open for manual inspection, close the window to finish") + await new Promise((resolve) => browser.on("disconnected", resolve)) + } else if (!cancelledByUser) { + await browser.close().catch(() => {}) + } return { sessionID: dryRun ? "" : sessionId, @@ -2498,7 +2503,12 @@ export async function run(config: AgentConfig): Promise { } const cancelledByUser = config.signal?.aborted === true - if (!cancelledByUser) await browser.close().catch(() => {}) + if (cancelledByUser && !browserDied && !config.headless) { + log.info("crawl stopped — browser stays open for manual inspection, close the window to finish") + await new Promise((resolve) => browser.on("disconnected", resolve)) + } else if (!cancelledByUser) { + await browser.close().catch(() => {}) + } return { sessionID: dryRun ? "" : sessionID!, From 54ae009ef10fdaca0d38137483d8ec430f334b1f Mon Sep 17 00:00:00 2001 From: orhanyildirim Date: Sun, 23 Aug 2026 19:30:52 -0400 Subject: [PATCH 07/14] fix(panel): use visualViewport API for more reliable resize tracking --- packages/hackbrowser/src/panel/inject.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/hackbrowser/src/panel/inject.ts b/packages/hackbrowser/src/panel/inject.ts index d1e3dd0e8..656161ccc 100644 --- a/packages/hackbrowser/src/panel/inject.ts +++ b/packages/hackbrowser/src/panel/inject.ts @@ -913,14 +913,16 @@ function handle(ev) { // ---------- keep card inside viewport on resize ---------- function clampCard() { - var vw = document.documentElement.clientWidth || window.innerWidth; - var vh = document.documentElement.clientHeight || window.innerHeight; + 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 ---------- From 87ca74849c11dab5cf1ccebcf7ca5f681417d4b0 Mon Sep 17 00:00:00 2001 From: orhanyildirim Date: Sun, 23 Aug 2026 22:03:52 -0400 Subject: [PATCH 08/14] =?UTF-8?q?fix(hackbrowser):=20graceful=20worker=20s?= =?UTF-8?q?hutdown=20=E2=80=94=20close=20stdin=20instead=20of=20proc.kill?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace proc.kill() with stdin.end() in backgroundRun's finally block. The worker process exits naturally when stdin closes, allowing Chrome to shut down gracefully instead of being force-killed mid-operation. --- packages/cyberstrike/src/tool/hackbrowser-launcher.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/cyberstrike/src/tool/hackbrowser-launcher.ts b/packages/cyberstrike/src/tool/hackbrowser-launcher.ts index eac188e0f..3ce4eace7 100644 --- a/packages/cyberstrike/src/tool/hackbrowser-launcher.ts +++ b/packages/cyberstrike/src/tool/hackbrowser-launcher.ts @@ -463,7 +463,10 @@ async function backgroundRun( ) } finally { activeRuns.delete(sessionID) - proc.kill() + try { + const s = proc.stdin + if (s && typeof s !== "number") (s as any).end() + } catch {} } } From 11572d09b035f41fc57ca13bf459ae924c3c62ee Mon Sep 17 00:00:00 2001 From: orhanyildirim Date: Sun, 23 Aug 2026 22:04:00 -0400 Subject: [PATCH 09/14] fix(hackbrowser): keep browser open in headed mode after crawl ends In headed mode, always wait for user to close the browser window instead of calling browser.close(). Prevents Chrome from closing when the stop-loading button cancels a navigation mid-crawl. Also wraps initial page.goto in try-catch so navigation failure keeps the browser open for manual interaction. --- packages/hackbrowser/src/agent.ts | 37 +++++++++++++++++++++++-------- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/packages/hackbrowser/src/agent.ts b/packages/hackbrowser/src/agent.ts index f538c8826..9f259d692 100644 --- a/packages/hackbrowser/src/agent.ts +++ b/packages/hackbrowser/src/agent.ts @@ -2091,11 +2091,10 @@ async function runMultiCredential(config: AgentConfig, credentials: CredentialCo await contexts[0]?.page.waitForTimeout(600).catch(() => {}) } - const cancelledByUser = config.signal?.aborted === true - if (cancelledByUser && !browserDied && !config.headless) { - log.info("crawl stopped — browser stays open for manual inspection, close the window to finish") + if (!config.headless && browser.isConnected()) { + log.info("crawl complete — browser stays open, close the window to finish") await new Promise((resolve) => browser.on("disconnected", resolve)) - } else if (!cancelledByUser) { + } else if (!browserDied) { await browser.close().catch(() => {}) } @@ -2213,7 +2212,28 @@ export async function run(config: AgentConfig): Promise { }, 500) // 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] }) + clearInterval(drainInterval) + if (!config.headless && browser.isConnected()) { + log.info("browser stays open — navigate manually or close the window to finish") + await new Promise((resolve) => browser.on("disconnected", resolve)) + } 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, { @@ -2502,11 +2522,10 @@ export async function run(config: AgentConfig): Promise { await page.waitForTimeout(600).catch(() => {}) } - const cancelledByUser = config.signal?.aborted === true - if (cancelledByUser && !browserDied && !config.headless) { - log.info("crawl stopped — browser stays open for manual inspection, close the window to finish") + if (!config.headless && browser.isConnected()) { + log.info("crawl complete — browser stays open, close the window to finish") await new Promise((resolve) => browser.on("disconnected", resolve)) - } else if (!cancelledByUser) { + } else if (!browserDied) { await browser.close().catch(() => {}) } From 0e58964c0e1829c6ce49187f7555e9eefa2e3655 Mon Sep 17 00:00:00 2001 From: orhanyildirim Date: Sun, 23 Aug 2026 22:13:19 -0400 Subject: [PATCH 10/14] fix(hackbrowser): safe disconnect-wait with race/timeout guards Add waitForBrowserClose() helper that handles three edge cases: - Race condition: re-checks isConnected() after attaching listener - Parent crash: races disconnect against abort signal - Chrome hang: 30-minute max timeout as backstop Also wraps runMultiCredential initial goto in try-catch (same pattern as run()) to prevent browser closing on navigation failure. --- packages/hackbrowser/src/agent.ts | 52 ++++++++++++++++++++++++++++--- 1 file changed, 48 insertions(+), 4 deletions(-) diff --git a/packages/hackbrowser/src/agent.ts b/packages/hackbrowser/src/agent.ts index 9f259d692..24f16bb66 100644 --- a/packages/hackbrowser/src/agent.ts +++ b/packages/hackbrowser/src/agent.ts @@ -125,6 +125,30 @@ 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 // ============================================================ @@ -1764,7 +1788,27 @@ async function runMultiCredential(config: AgentConfig, credentials: CredentialCo 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, { @@ -2093,7 +2137,7 @@ async function runMultiCredential(config: AgentConfig, credentials: CredentialCo if (!config.headless && browser.isConnected()) { log.info("crawl complete — browser stays open, close the window to finish") - await new Promise((resolve) => browser.on("disconnected", resolve)) + await waitForBrowserClose(browser, config.signal) } else if (!browserDied) { await browser.close().catch(() => {}) } @@ -2221,7 +2265,7 @@ export async function run(config: AgentConfig): Promise { clearInterval(drainInterval) if (!config.headless && browser.isConnected()) { log.info("browser stays open — navigate manually or close the window to finish") - await new Promise((resolve) => browser.on("disconnected", resolve)) + await waitForBrowserClose(browser, config.signal) } else { await browser.close().catch(() => {}) } @@ -2524,7 +2568,7 @@ export async function run(config: AgentConfig): Promise { if (!config.headless && browser.isConnected()) { log.info("crawl complete — browser stays open, close the window to finish") - await new Promise((resolve) => browser.on("disconnected", resolve)) + await waitForBrowserClose(browser, config.signal) } else if (!browserDied) { await browser.close().catch(() => {}) } From d7458769550e3dc0e7ce8527a0896e1688bb2ed9 Mon Sep 17 00:00:00 2001 From: orhanyildirim Date: Sun, 23 Aug 2026 22:13:27 -0400 Subject: [PATCH 11/14] fix(hackbrowser): abort crawl on stdin close to prevent orphan processes When parent closes stdin (shutdown or crash), abort the crawl controller so run()'s disconnect-wait resolves and the worker exits cleanly instead of keeping Chrome alive indefinitely. --- .../src/hackbrowser-subprocess/hackbrowser-worker.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/cyberstrike/src/hackbrowser-subprocess/hackbrowser-worker.ts b/packages/cyberstrike/src/hackbrowser-subprocess/hackbrowser-worker.ts index 3475bbe11..47307693f 100644 --- a/packages/cyberstrike/src/hackbrowser-subprocess/hackbrowser-worker.ts +++ b/packages/cyberstrike/src/hackbrowser-subprocess/hackbrowser-worker.ts @@ -269,8 +269,14 @@ 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() + }), + ) } async function runWorker(opts: WorkerOptions, signal: AbortSignal): Promise { From cfce684173bc4a9553b0c41685697a0769836577 Mon Sep 17 00:00:00 2001 From: orhanyildirim Date: Sun, 23 Aug 2026 22:13:35 -0400 Subject: [PATCH 12/14] fix(hackbrowser): kill timeout + crash-path cleanup in launcher Add 10s kill timeout after stdin.end() as a backstop if the worker hangs. On the crash detection path (worker exited without result), force-kill to clean up orphaned Chrome child processes. --- packages/cyberstrike/src/tool/hackbrowser-launcher.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/cyberstrike/src/tool/hackbrowser-launcher.ts b/packages/cyberstrike/src/tool/hackbrowser-launcher.ts index 3ce4eace7..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 = @@ -467,6 +469,9 @@ async function backgroundRun( const s = proc.stdin if (s && typeof s !== "number") (s as any).end() } catch {} + setTimeout(() => { + try { proc.kill() } catch {} + }, 10_000) } } From 2e697b409731e6f388be412b1d230dea71092089 Mon Sep 17 00:00:00 2001 From: orhanyildirim Date: Sun, 23 Aug 2026 22:36:11 -0400 Subject: [PATCH 13/14] fix(hackbrowser): drain interval leak + post-crawl capture continuity Wrap crawl body in try-finally to guarantee clearInterval on any exit path (auth errors, navigation failures). Add second drain pass after csEmit to catch late captures. In headed mode, keep draining captures during manual browsing until user closes the browser. --- packages/hackbrowser/src/agent.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/packages/hackbrowser/src/agent.ts b/packages/hackbrowser/src/agent.ts index 24f16bb66..e21066a05 100644 --- a/packages/hackbrowser/src/agent.ts +++ b/packages/hackbrowser/src/agent.ts @@ -2255,6 +2255,7 @@ export async function run(config: AgentConfig): Promise { } }, 500) + try { // Navigate to target and authenticate const initNavErr = await page .goto(targetUrl, { waitUntil: "domcontentloaded", timeout: 30000 }) @@ -2262,7 +2263,6 @@ export async function run(config: AgentConfig): Promise { .catch((e: Error) => e) if (initNavErr) { log.warn("initial navigation failed", { url: targetUrl, err: initNavErr.message.split("\n")[0] }) - clearInterval(drainInterval) if (!config.headless && browser.isConnected()) { log.info("browser stays open — navigate manually or close the window to finish") await waitForBrowserClose(browser, config.signal) @@ -2537,7 +2537,6 @@ export async function run(config: AgentConfig): Promise { ...(browserDied ? { reason: health.reason } : {}), }) if (!browserDied) await page.waitForTimeout(2000).catch(() => {}) - clearInterval(drainInterval) while (captureQueue.length > 0) { const captured = captureQueue.shift()! @@ -2564,11 +2563,24 @@ export async function run(config: AgentConfig): Promise { }, }) 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) + } } 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(() => {}) } @@ -2581,4 +2593,7 @@ export async function run(config: AgentConfig): Promise { errors: browserDied ? [`Browser died: ${health.reason}. Captured ${globalState.capturedEndpoints.size} endpoints before failure.`] : [], usage: usageAcc, } + } finally { + clearInterval(drainInterval) + } } From 9a6d1ab2312a0f0c522f36e496fa1d59d92b7739 Mon Sep 17 00:00:00 2001 From: orhanyildirim Date: Sun, 23 Aug 2026 22:36:18 -0400 Subject: [PATCH 14/14] fix(hackbrowser): add 5s force-exit watchdog after stdin close If runWorker is still hanging 5s after stdin close (e.g. stuck Playwright operation that ignores abort signal), force-exit the worker process to prevent indefinite orphan. --- .../src/hackbrowser-subprocess/hackbrowser-worker.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/cyberstrike/src/hackbrowser-subprocess/hackbrowser-worker.ts b/packages/cyberstrike/src/hackbrowser-subprocess/hackbrowser-worker.ts index 47307693f..795a055a0 100644 --- a/packages/cyberstrike/src/hackbrowser-subprocess/hackbrowser-worker.ts +++ b/packages/cyberstrike/src/hackbrowser-subprocess/hackbrowser-worker.ts @@ -277,6 +277,8 @@ async function main(): Promise { 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 {