From 822de7d0c39909d82062ce1537bdc62ed76166d0 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 16 Aug 2026 09:01:52 -0700 Subject: [PATCH] =?UTF-8?q?fix(browse):=20XProtect=20launch-kill=20self-he?= =?UTF-8?q?al=20=E2=80=94=20classify,=20quarantine-clear,=20bounded=20rein?= =?UTF-8?q?stall=20(P0=20#2554)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit macOS XProtect definition updates can start SIGKILLing the exact Chromium revision the lockfile pins (xprotectd killed revision 1208's headless shell at spawn; the failure surfaced as a generic launch timeout). New browse/src/xprotect-heal.ts heals it, once per process: - Classifier (F9): positive signatures sourced from the #2554 report + Playwright's launch-error format (signal=SIGKILL process-exit lines, and launch timeout WITH a marker), negative-checked FIRST against missing executable, spawn EACCES/EPERM, Linux sandbox denials, and plain exitCode=1 crashes. darwin-gated. - Heal (F4 one-shot, in-memory flag): clears com.apple.quarantine via `xattr -dr` on chromium* revision dirs in the Playwright cache ONLY — never a GSTACK_CHROMIUM_PATH bundle (probePoisonedChromiumBundle's scope contract, double-gated at the call sites via usesCustomExecutable). - Reinstall (E1/ENG-OV3): `bunx playwright install --force chromium` run FROM THE GSTACK INSTALL ROOT — the root whose node_modules/playwright-core/browsers.json pins the SAME chromium revision our embedded playwright-core expects (a cwd-resolved bunx would fetch latest and heal to the wrong revision). Bounded at 120s with a process-GROUP SIGKILL on timeout; on any heal failure the caller gets the ORIGINAL launch error + manual `bunx playwright install chromium` guidance — the CLI never hangs. - Verification (F9): post-install asserts the REGISTRY-derived executable path exists (the revision dir playwright-core 1.62.1 expects), not merely install exit 0. - Logging (F11): every action emits one structured stderr line ([browse:xprotect-heal] JSON). All three launch sites in browser-manager.ts (headless launch, headed launchPersistentContext, handoff relaunch) route through launchWithXProtectHeal with one post-heal retry. setup's ensure_playwright_browser failure path gains the same quarantine-clear (_clear_playwright_quarantine, Darwin-only, Playwright cache scope) before its Chromium reinstall. Tests: browse/test/xprotect-heal.test.ts — 33 pass (classifier both polarities, one-shot guard incl. failed-heal consumption, custom-executable scope, registry-revision expectation vs playwright-core browsers.json, install-root revision matching, quarantine-clear scope, wrapper retry + guidance surfacing). browser-manager unit/custom-chromium: 36 pass. bridge-chromium-e2e real-launch smoke: 3 pass. setup-windows-fallback ln-invariant: 9 pass. bash -n setup: clean. Fixes #2554. Co-Authored-By: Claude Fable 5 --- browse/src/browser-manager.ts | 25 +- browse/src/xprotect-heal.ts | 420 ++++++++++++++++++++++++++++++ browse/test/xprotect-heal.test.ts | 393 ++++++++++++++++++++++++++++ setup | 22 ++ 4 files changed, 854 insertions(+), 6 deletions(-) create mode 100644 browse/src/xprotect-heal.ts create mode 100644 browse/test/xprotect-heal.test.ts diff --git a/browse/src/browser-manager.ts b/browse/src/browser-manager.ts index a18bb92bd..4863c29a7 100644 --- a/browse/src/browser-manager.ts +++ b/browse/src/browser-manager.ts @@ -22,6 +22,7 @@ import { emitActivity } from './activity'; import { validateNavigationUrl } from './url-validation'; import { TabSession, type RefEntry } from './tab-session'; import { resolveChromiumProfile, cleanSingletonLocks } from './config'; +import { launchWithXProtectHeal } from './xprotect-heal'; import { withCdpSession } from './cdp-bridge'; import type { MemorySnapshot, MemoryStructureStats, MemoryTabSnapshot, MemoryProcess } from './memory-snapshot'; @@ -458,7 +459,12 @@ export class BrowserManager { console.log(`[browse] Extensions loaded from: ${extensionsDir}`); } - this.browser = await chromium.launch({ + // XProtect self-heal wrapper (P0 #2554): a macOS definition update can + // start SIGKILLing the pinned Chromium at spawn. On the classified + // signature, clear quarantine on the Playwright cache + force-reinstall + // once, then retry this launch once. This headless path always uses the + // Playwright cache (no executablePath), so the heal is never scoped out. + this.browser = await launchWithXProtectHeal(() => chromium.launch({ headless: useHeadless, // On Windows, Chromium's sandbox fails when the server is spawned through // the Bun→Node process chain (GitHub #276). Disable it — local daemon @@ -468,7 +474,7 @@ export class BrowserManager { chromiumSandbox: shouldEnableChromiumSandbox(), ...(launchArgs.length > 0 ? { args: launchArgs } : {}), ...(this.proxyConfig ? { proxy: this.proxyConfig } : {}), - }); + })); // Chromium disconnect → distinguish clean user-quit from crash. Both // events look identical to Playwright (one 'disconnected' fires), but @@ -651,7 +657,11 @@ export class BrowserManager { // three more (--disable-popup-blocking, --disable-component-update, // --disable-default-apps — each a documented automation tell per Patchright). const { STEALTH_IGNORE_DEFAULT_ARGS } = await import('./stealth'); - this.context = await chromium.launchPersistentContext(userDataDir, { + // XProtect self-heal wrapper (P0 #2554). usesCustomExecutable scopes the + // heal out when GSTACK_CHROMIUM_PATH supplies the bundle — that bundle + // belongs to the wrapper/embedder and is never quarantine-cleared or + // reinstalled over (probePoisonedChromiumBundle's scope contract). + this.context = await launchWithXProtectHeal(() => chromium.launchPersistentContext(userDataDir, { headless: false, // Match the sandbox policy used by launch() above. Without this, // Playwright auto-adds --no-sandbox on every headed launch and the user @@ -663,7 +673,7 @@ export class BrowserManager { ...(executablePath ? { executablePath } : {}), ...(this.proxyConfig ? { proxy: this.proxyConfig } : {}), ignoreDefaultArgs: STEALTH_IGNORE_DEFAULT_ARGS, - }); + }), { usesCustomExecutable: Boolean(executablePath) }); this.browser = this.context.browser(); this.connectionMode = 'headed'; this.intentionalDisconnect = false; @@ -1702,7 +1712,10 @@ export class BrowserManager { // The handoff path (headless → headed re-launch) takes the same // anti-detection posture. const { STEALTH_IGNORE_DEFAULT_ARGS } = await import('./stealth'); - newContext = await chromium.launchPersistentContext(userDataDir, { + // XProtect self-heal wrapper (P0 #2554): handoff always launches the + // Playwright-cache bundle (no executablePath), so the heal applies + // exactly as in launch()/launchHeaded(). + newContext = await launchWithXProtectHeal(() => chromium.launchPersistentContext(userDataDir, { headless: false, // Match the sandbox policy used by launchHeaded() / launch(). The // handoff path is the headless→headed re-launch and shares the same @@ -1713,7 +1726,7 @@ export class BrowserManager { ...(this.proxyConfig ? { proxy: this.proxyConfig } : {}), ignoreDefaultArgs: STEALTH_IGNORE_DEFAULT_ARGS, timeout: 15000, - }); + })); } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); return `ERROR: Cannot open headed browser — ${msg}. Headless browser still running.`; diff --git a/browse/src/xprotect-heal.ts b/browse/src/xprotect-heal.ts new file mode 100644 index 000000000..eaa4303aa --- /dev/null +++ b/browse/src/xprotect-heal.ts @@ -0,0 +1,420 @@ +/** + * XProtect launch-kill self-heal (P0 #2554). + * + * macOS XProtect definition updates can start killing the exact Chromium + * revision the committed bun.lock pins (observed: revision 1208 under + * playwright 1.58.2 — xprotectd SIGKILLs chrome-headless-shell at spawn, so + * the failure surfaces as a Playwright launch timeout or a "Browser closed" + * error carrying `signal=SIGKILL`, never anything naming XProtect). + * + * The heal, in order, at most ONCE per process (F4): + * 1. Classify the launch failure against the XProtect kill signature + * (positive AND negative fixtures under test, F9). + * 2. Clear com.apple.quarantine on the Playwright cache bundles ONLY — + * a GSTACK_CHROMIUM_PATH bundle belongs to the wrapper/embedder and is + * never touched (same scope contract as probePoisonedChromiumBundle). + * 3. Force-reinstall Chromium FROM THE GSTACK INSTALL ROOT (ENG-OV3: the + * root whose node_modules pins the same playwright-core our compiled + * binary embeds — a cwd-resolved `bunx playwright install` would fetch + * the LATEST playwright's revision, which the embedded playwright-core + * won't find, and the one-shot guard would then block the retry). + * The install is BOUNDED (~120s, process-GROUP kill on timeout; E1). + * 4. Verify the revision dir the embedded playwright-core EXPECTS exists + * post-heal (registry-derived expectation, not merely install exit 0). + * + * Every action emits one structured stderr line (F11). When the heal cannot + * complete (offline, timeout, no install root, one-shot spent), the caller + * surfaces the ORIGINAL launch error plus manual + * `bunx playwright install chromium` guidance — the CLI never hangs on it. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { spawn } from 'child_process'; +import { chromium } from 'playwright'; + +/** F11: one structured stderr line per self-heal action. */ +function logHeal(action: string, fields: Record = {}): void { + console.error(`[browse:xprotect-heal] ${JSON.stringify({ action, ...fields })}`); +} + +// ─── Classifier (F9: positives AND negatives) ──────────────────────────── + +/** + * Failure shapes that are definitively NOT an XProtect kill. Checked before + * the positives so an ambiguous message never triggers a pointless reinstall: + * - missing executable (browser was never installed / cache wiped) + * - spawn-level permission errors (EACCES / EPERM / ENOENT) + * - Linux sandbox denials (wrong OS anyway, but the text is distinctive) + */ +const NEGATIVE_SIGNATURES: RegExp[] = [ + /executable doesn't exist/i, + /spawn\s+\S+\s+(EACCES|EPERM|ENOENT)/i, + /\b(EACCES|EPERM)\b/, + /no usable sandbox/i, + /failed to move to new namespace/i, + /suid sandbox helper/i, +]; + +/** + * Failure shapes an OS-level kill produces (sourced from the #2554 report + * plus Playwright's launch-error format): the browser process SPAWNED, then + * died to SIGKILL, or never became ready (launch timeout with a `` + * marker — the report's visible symptom, since xprotectd kills the child + * without Playwright ever learning why). + */ +const POSITIVE_SIGNATURES: RegExp[] = [ + /]*signal=SIGKILL/i, + /signal[:=]\s*['"]?SIGKILL/i, +]; + +/** + * True when a launch failure message matches the macOS XProtect kill + * signature. Platform-gated: XProtect exists only on darwin. + */ +export function isXProtectKillSignature( + message: string, + platform: NodeJS.Platform = process.platform, +): boolean { + if (platform !== 'darwin') return false; + if (!message) return false; + for (const neg of NEGATIVE_SIGNATURES) { + if (neg.test(message)) return false; + } + for (const pos of POSITIVE_SIGNATURES) { + if (pos.test(message)) return true; + } + // XProtect kill at spawn also surfaces as a launch timeout where the + // process DID launch ( marker present) but never became ready — + // this is the exact symptom the #2554 report describes. + return /timeout \d+\s*ms exceeded/i.test(message) && //i.test(message); +} + +// ─── Playwright cache path helpers (pure) ──────────────────────────────── + +const REVISION_DIR_RE = /^chromium(?:_headless_shell)?-\d+$/; + +/** + * Walk up from a Chromium executable to its Playwright cache revision dir + * (e.g. …/ms-playwright/chromium-1234 or …/chromium_headless_shell-1234). + * Returns null when the executable is not in the standard cache layout. + */ +export function findPlaywrightRevisionDir(executablePath: string): string | null { + let dir = path.dirname(executablePath); + for (let i = 0; i < 8; i++) { + if (REVISION_DIR_RE.test(path.basename(dir))) return dir; + const parent = path.dirname(dir); + if (parent === dir) return null; + dir = parent; + } + return null; +} + +/** + * The Chromium revision the EMBEDDED playwright-core expects, derived from + * the registry-computed executable path (chromium.executablePath() embeds + * the revision from playwright-core's browsers.json — it is not read from + * disk, so it stays correct even when nothing is installed yet). + */ +export function expectedChromiumRevision(executablePath: string): string | null { + const revDir = findPlaywrightRevisionDir(executablePath); + if (!revDir) return null; + const m = path.basename(revDir).match(/-(\d+)$/); + return m ? m[1] : null; +} + +/** + * Find the gstack install root whose node_modules pins the SAME + * playwright-core revision our binary embeds (ENG-OV3). Candidates: + * the dev checkout (source runs) and the global ./setup install. A candidate + * qualifies only when its playwright-core/browsers.json chromium revision + * matches — running the reinstall anywhere else heals to the WRONG revision. + */ +export function findGstackInstallRoot( + expectedRevision: string, + candidates?: string[], +): string | null { + const roots = candidates ?? [ + // Dev checkout: browse/src/ → repo root. In the compiled binary + // __dirname points into the bunfs bundle and won't exist on disk, + // so this candidate simply fails the existsSync below. + path.resolve(__dirname, '..', '..'), + // Global install root (the ./setup target). + path.join(process.env.HOME || '', '.claude', 'skills', 'gstack'), + ]; + for (const root of roots) { + try { + const browsersJson = path.join(root, 'node_modules', 'playwright-core', 'browsers.json'); + if (!fs.existsSync(browsersJson)) continue; + const parsed = JSON.parse(fs.readFileSync(browsersJson, 'utf-8')); + const rev = parsed?.browsers?.find((b: { name?: string }) => b?.name === 'chromium')?.revision; + if (String(rev) === String(expectedRevision)) return root; + } catch { + continue; // unreadable/malformed candidate — try the next one + } + } + return null; +} + +// ─── Quarantine clear ──────────────────────────────────────────────────── + +function defaultRunXattr(target: string): number | null { + const res = Bun.spawnSync(['xattr', '-dr', 'com.apple.quarantine', target], { + stdout: 'pipe', + stderr: 'pipe', + timeout: 10_000, + }); + return res.exitCode; +} + +/** + * Clear com.apple.quarantine on every chromium* revision dir in the + * Playwright cache (the headless shell is what XProtect actually killed in + * #2554; the headed bundle rides along so a later headed launch doesn't + * re-trip). Scope contract mirrors probePoisonedChromiumBundle: NEVER act + * on a GSTACK_CHROMIUM_PATH bundle — that belongs to the wrapper/embedder. + * Best-effort: xattr failures are logged, never thrown (the forced + * reinstall below is the real heal). + */ +export function clearQuarantineOnPlaywrightCache( + executablePath: string, + runXattr: (target: string) => number | null = defaultRunXattr, +): boolean { + const customPath = process.env.GSTACK_CHROMIUM_PATH; + if (customPath && path.resolve(executablePath) === path.resolve(customPath)) { + logHeal('quarantine-clear-skipped', { reason: 'custom-chromium-path' }); + return false; + } + const revDir = findPlaywrightRevisionDir(executablePath); + if (!revDir) { + logHeal('quarantine-clear-skipped', { reason: 'not-in-playwright-cache', executablePath }); + return false; + } + const cacheRoot = path.dirname(revDir); + let cleared = 0; + let entries: string[]; + try { + entries = fs.readdirSync(cacheRoot); + } catch (err) { + logHeal('quarantine-clear-skipped', { + reason: 'cache-unreadable', + error: err instanceof Error ? err.message : String(err), + }); + return false; + } + for (const entry of entries) { + if (!REVISION_DIR_RE.test(entry)) continue; + const target = path.join(cacheRoot, entry); + try { + const exitCode = runXattr(target); + // Non-zero usually means "no such xattr" — nothing to clear, fine. + logHeal('quarantine-clear', { target, exitCode }); + cleared++; + } catch (err) { + logHeal('quarantine-clear', { + target, + error: err instanceof Error ? err.message : String(err), + }); + } + } + return cleared > 0; +} + +// ─── Bounded forced reinstall (E1) ─────────────────────────────────────── + +export const XPROTECT_REINSTALL_TIMEOUT_MS = 120_000; + +export interface ReinstallResult { + ok: boolean; + reason?: string; + exitCode?: number | null; +} + +/** + * Run `bunx playwright install --force chromium` from the gstack install + * root, bounded at ~120s. The child gets its own process group (detached) + * so a timeout kills the WHOLE tree (bunx → playwright CLI → download + * workers), never leaving a zombie download saturating the network. + */ +export function runBoundedChromiumReinstall( + installRoot: string, + timeoutMs: number = XPROTECT_REINSTALL_TIMEOUT_MS, +): Promise { + return new Promise((resolve) => { + let settled = false; + let child: ReturnType; + try { + child = spawn('bunx', ['playwright', 'install', '--force', 'chromium'], { + cwd: installRoot, + detached: true, // own process group → group-kill on timeout + stdio: ['ignore', 'ignore', 'pipe'], + windowsHide: true, + }); + } catch (err) { + resolve({ ok: false, reason: `spawn-error: ${err instanceof Error ? err.message : String(err)}` }); + return; + } + let stderrTail = ''; + child.stderr?.on('data', (d: Buffer) => { + stderrTail = (stderrTail + String(d)).slice(-2000); + }); + const timer = setTimeout(() => { + if (settled) return; + settled = true; + try { + if (child.pid) process.kill(-child.pid, 'SIGKILL'); // whole group + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException)?.code !== 'ESRCH') { + try { child.kill('SIGKILL'); } catch { /* already gone */ } + } + } + resolve({ ok: false, reason: 'timeout' }); + }, timeoutMs); + child.on('error', (err) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve({ ok: false, reason: `spawn-error: ${err.message}` }); + }); + child.on('exit', (code) => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (code === 0) { + resolve({ ok: true, exitCode: code }); + } else { + resolve({ + ok: false, + reason: `install-exit-${code}${stderrTail ? `: ${stderrTail.slice(-300)}` : ''}`, + exitCode: code, + }); + } + }); + }); +} + +// ─── One-shot orchestration (F4) ───────────────────────────────────────── + +let healAttempted = false; + +/** Test seam only — production never resets the one-shot guard. */ +export function resetXProtectHealForTests(): void { + healAttempted = false; +} + +export interface XProtectHealDeps { + platform?: NodeJS.Platform; + executablePath?: () => string; + clearQuarantine?: (execPath: string) => boolean; + installRoot?: (expectedRevision: string) => string | null; + runReinstall?: (installRoot: string) => Promise; + verifyInstalled?: (execPath: string) => boolean; +} + +/** + * Attempt the XProtect self-heal for a classified launch failure. + * + * Returns true when the heal completed AND the revision dir the embedded + * playwright-core expects exists on disk — the caller should retry the + * launch exactly once. Returns false when the error doesn't match the + * signature, the launch used a custom executable, the one-shot guard + * already fired, or any heal step failed (the caller then surfaces the + * original error + manual guidance). + */ +export async function maybeHealXProtectKill( + err: unknown, + opts: { usesCustomExecutable?: boolean } = {}, + deps: XProtectHealDeps = {}, +): Promise { + const message = err instanceof Error ? err.message : String(err); + if (!isXProtectKillSignature(message, deps.platform ?? process.platform)) return false; + if (opts.usesCustomExecutable) { + // A GSTACK_CHROMIUM_PATH bundle belongs to the wrapper/embedder — never + // quarantine-clear or reinstall over it (probePoisonedChromiumBundle's + // scope contract). + logHeal('skip', { reason: 'custom-executable' }); + return false; + } + if (healAttempted) { + logHeal('skip', { reason: 'already-attempted-this-process' }); + return false; + } + healAttempted = true; // F4: at most one heal per process, even on failure + logHeal('classified', { signature: 'xprotect-kill' }); + + const execPath = (deps.executablePath ?? (() => chromium.executablePath()))(); + (deps.clearQuarantine ?? clearQuarantineOnPlaywrightCache)(execPath); + + const revision = expectedChromiumRevision(execPath); + if (!revision) { + logHeal('reinstall-skipped', { reason: 'no-revision-in-path', execPath }); + return false; + } + const root = (deps.installRoot ?? findGstackInstallRoot)(revision); + if (!root) { + // No install root pins our revision — a cwd-resolved install would heal + // to the WRONG revision (ENG-OV3), so surface guidance instead. + logHeal('reinstall-skipped', { reason: 'no-install-root', revision }); + return false; + } + + logHeal('reinstall-start', { installRoot: root, revision, timeoutMs: XPROTECT_REINSTALL_TIMEOUT_MS }); + const result = await (deps.runReinstall ?? runBoundedChromiumReinstall)(root); + if (!result.ok) { + logHeal('reinstall-failed', { reason: result.reason }); + return false; + } + + // F9/ENG-OV3: assert the revision dir the embedded playwright-core + // EXPECTS exists post-heal — install exit 0 alone can mean "installed the + // wrong revision" when resolution went sideways. + const verify = deps.verifyInstalled ?? ((p: string) => fs.existsSync(p)); + if (!verify(execPath)) { + logHeal('verify-failed', { expected: execPath }); + return false; + } + logHeal('reinstall-ok', { installRoot: root, revision }); + return true; +} + +/** + * Original launch error + manual remediation, for classified failures the + * heal could not fix (offline, timeout, one-shot spent, no install root). + */ +export function buildXProtectGuidance(originalMessage: string): string { + return ( + `${originalMessage}\n` + + '[browse] This launch failure matches the macOS XProtect kill signature (#2554): ' + + "the OS killed Playwright's Chromium at spawn. Automatic self-heal did not complete. " + + 'Fix manually: run `bunx playwright install chromium` from your gstack install ' + + '(the directory whose node_modules pins playwright — ~/.claude/skills/gstack for ' + + 'global installs), then retry.' + ); +} + +/** + * Wrap a Playwright launch call with the XProtect self-heal: on a classified + * failure, heal once and retry the launch once. On a classified failure the + * heal could not fix, throw the ORIGINAL error text augmented with manual + * guidance. Unclassified failures pass through untouched. + */ +export async function launchWithXProtectHeal( + doLaunch: () => Promise, + opts: { usesCustomExecutable?: boolean } = {}, + deps: XProtectHealDeps = {}, +): Promise { + try { + return await doLaunch(); + } catch (err) { + const healed = await maybeHealXProtectKill(err, opts, deps); + if (healed) { + logHeal('retry-launch', {}); + return await doLaunch(); + } + const message = err instanceof Error ? err.message : String(err); + if (isXProtectKillSignature(message, deps.platform ?? process.platform)) { + throw new Error(buildXProtectGuidance(message), { cause: err }); + } + throw err; + } +} diff --git a/browse/test/xprotect-heal.test.ts b/browse/test/xprotect-heal.test.ts new file mode 100644 index 000000000..014c6125d --- /dev/null +++ b/browse/test/xprotect-heal.test.ts @@ -0,0 +1,393 @@ +/** + * XProtect launch-kill self-heal (P0 #2554) — unit tests. + * + * F9: the classifier is tested with POSITIVE signatures (sourced from the + * #2554 report + Playwright's launch-error format) AND NEGATIVES (missing + * executable, EPERM/EACCES, sandbox denial, plain crash) so a generic launch + * failure can never trigger a pointless reinstall. + * + * F4: the one-shot guard is pinned — at most one heal attempt per process, + * even when the heal fails. + * + * ENG-OV3/F9: the post-heal verification target is REGISTRY-derived (the + * revision playwright-core's browsers.json expects), not disk-derived, and + * the install-root finder rejects roots pinning a different revision. + */ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { chromium } from 'playwright'; +import { + isXProtectKillSignature, + findPlaywrightRevisionDir, + expectedChromiumRevision, + findGstackInstallRoot, + clearQuarantineOnPlaywrightCache, + maybeHealXProtectKill, + launchWithXProtectHeal, + resetXProtectHealForTests, + buildXProtectGuidance, +} from '../src/xprotect-heal'; + +const REPO_ROOT = path.resolve(import.meta.dir, '..', '..'); + +// ─── Fixtures: POSITIVE signatures (real Playwright error shapes for an +// OS-level SIGKILL at spawn — what xprotectd does per the #2554 report) ──── + +const SIGKILL_BROWSER_CLOSED = `browserType.launch: Browser closed. +==================== Browser output: ==================== + pid=48213 +[pid=48213] +[pid=48213] starting temporary directories cleanup +=========================== logs ===========================`; + +const SIGKILL_PERSISTENT_CONTEXT = `browserType.launchPersistentContext: Target page, context or browser has been closed +Browser logs: + pid=9021 +[pid=9021] `; + +// The #2554 report's visible symptom: the kill surfaces as a launch timeout +// where the process DID spawn () but never became ready. +const LAUNCH_TIMEOUT_AFTER_SPAWN = `browserType.launch: Timeout 180000ms exceeded. +=========================== logs =========================== + pid=51677 +============================================================`; + +// ─── Fixtures: NEGATIVE signatures (F9) ────────────────────────────────── + +const MISSING_EXECUTABLE = `browserType.launch: Executable doesn't exist at /Users/dev/Library/Caches/ms-playwright/chromium_headless_shell-1234/chrome-mac-arm64/headless_shell +╔═══════════════════════════════════════════════════════╗ +║ Looks like Playwright was just installed or updated. ║ +║ Please run the following command to download browsers:║ +║ bunx playwright install ║ +╚═══════════════════════════════════════════════════════╝`; + +const SPAWN_EACCES = `browserType.launch: spawn /Users/dev/Library/Caches/ms-playwright/chromium-1234/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing EACCES`; + +const EPERM_FAILURE = `browserType.launch: Browser closed. +==================== Browser output: ==================== +Error: EPERM: operation not permitted, open '/Users/dev/Library/Caches/ms-playwright/.links/lock'`; + +const SANDBOX_DENIAL = `browserType.launch: Browser closed. +==================== Browser output: ==================== + pid=7211 +[pid=7211][err] Failed to move to new namespace: PID namespaces supported, Network namespace supported, but failed: errno = Operation not permitted +[pid=7211] `; + +const PLAIN_CRASH_EXIT_1 = `browserType.launch: Browser closed. +==================== Browser output: ==================== + pid=3300 +[pid=3300] `; + +// ─── Classifier ────────────────────────────────────────────────────────── + +describe('isXProtectKillSignature — positives (darwin)', () => { + it('classifies SIGKILL in a Browser closed error', () => { + expect(isXProtectKillSignature(SIGKILL_BROWSER_CLOSED, 'darwin')).toBe(true); + }); + + it('classifies SIGKILL in a launchPersistentContext error', () => { + expect(isXProtectKillSignature(SIGKILL_PERSISTENT_CONTEXT, 'darwin')).toBe(true); + }); + + it('classifies a launch timeout where the process spawned ()', () => { + expect(isXProtectKillSignature(LAUNCH_TIMEOUT_AFTER_SPAWN, 'darwin')).toBe(true); + }); +}); + +describe('isXProtectKillSignature — negatives (F9)', () => { + it('rejects a missing executable', () => { + expect(isXProtectKillSignature(MISSING_EXECUTABLE, 'darwin')).toBe(false); + }); + + it('rejects spawn EACCES', () => { + expect(isXProtectKillSignature(SPAWN_EACCES, 'darwin')).toBe(false); + }); + + it('rejects EPERM failures', () => { + expect(isXProtectKillSignature(EPERM_FAILURE, 'darwin')).toBe(false); + }); + + it('rejects Linux sandbox denials even with a marker', () => { + expect(isXProtectKillSignature(SANDBOX_DENIAL, 'darwin')).toBe(false); + }); + + it('rejects a plain crash (exitCode=1, no signal)', () => { + expect(isXProtectKillSignature(PLAIN_CRASH_EXIT_1, 'darwin')).toBe(false); + }); + + it('rejects a bare timeout with no marker (process never spawned)', () => { + expect(isXProtectKillSignature('browserType.launch: Timeout 180000ms exceeded.', 'darwin')).toBe(false); + }); + + it('rejects empty messages', () => { + expect(isXProtectKillSignature('', 'darwin')).toBe(false); + }); + + it('is platform-gated: the SIGKILL signature on linux/win32 is NOT XProtect', () => { + expect(isXProtectKillSignature(SIGKILL_BROWSER_CLOSED, 'linux')).toBe(false); + expect(isXProtectKillSignature(SIGKILL_BROWSER_CLOSED, 'win32')).toBe(false); + }); +}); + +// ─── Cache path helpers ────────────────────────────────────────────────── + +describe('findPlaywrightRevisionDir', () => { + it('finds the revision dir for the headed bundle layout', () => { + const p = '/Users/dev/Library/Caches/ms-playwright/chromium-1234/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing'; + expect(findPlaywrightRevisionDir(p)).toBe('/Users/dev/Library/Caches/ms-playwright/chromium-1234'); + }); + + it('finds the revision dir for the headless shell layout', () => { + const p = '/Users/dev/Library/Caches/ms-playwright/chromium_headless_shell-1234/chrome-mac-arm64/headless_shell'; + expect(findPlaywrightRevisionDir(p)).toBe('/Users/dev/Library/Caches/ms-playwright/chromium_headless_shell-1234'); + }); + + it('returns null outside the Playwright cache layout', () => { + expect(findPlaywrightRevisionDir('/Applications/GStack Browser.app/Contents/MacOS/Chromium')).toBe(null); + }); +}); + +describe('expectedChromiumRevision — registry-derived expectation (F9/ENG-OV3)', () => { + it('matches the revision playwright-core browsers.json declares for chromium', () => { + const browsersJson = JSON.parse(fs.readFileSync( + path.join(REPO_ROOT, 'node_modules', 'playwright-core', 'browsers.json'), 'utf-8', + )); + const registryRevision = browsersJson.browsers.find((b: { name: string }) => b.name === 'chromium').revision; + // chromium.executablePath() is computed from the embedded registry (not + // read from disk) — the heal's post-install verification target is + // therefore the revision dir playwright-core EXPECTS, which is exactly + // what a wrong-revision heal would fail. + expect(expectedChromiumRevision(chromium.executablePath())).toBe(registryRevision); + }); +}); + +describe('findGstackInstallRoot (ENG-OV3: revision-matched roots only)', () => { + let tmpRoot: string; + + beforeEach(() => { + tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'xprotect-root-')); + const pwCore = path.join(tmpRoot, 'node_modules', 'playwright-core'); + fs.mkdirSync(pwCore, { recursive: true }); + fs.writeFileSync(path.join(pwCore, 'browsers.json'), JSON.stringify({ + browsers: [{ name: 'chromium', revision: '1234' }], + })); + }); + + afterEach(() => { + fs.rmSync(tmpRoot, { recursive: true, force: true }); + }); + + it('accepts a root whose pinned playwright-core expects the same revision', () => { + expect(findGstackInstallRoot('1234', [tmpRoot])).toBe(tmpRoot); + }); + + it('rejects a root pinning a DIFFERENT revision (wrong-revision heal guard)', () => { + expect(findGstackInstallRoot('9999', [tmpRoot])).toBe(null); + }); + + it('rejects roots without node_modules/playwright-core', () => { + const bare = fs.mkdtempSync(path.join(os.tmpdir(), 'xprotect-bare-')); + try { + expect(findGstackInstallRoot('1234', [bare])).toBe(null); + } finally { + fs.rmSync(bare, { recursive: true, force: true }); + } + }); + + it('resolves the dev checkout by default (its node_modules pins our revision)', () => { + const browsersJson = JSON.parse(fs.readFileSync( + path.join(REPO_ROOT, 'node_modules', 'playwright-core', 'browsers.json'), 'utf-8', + )); + const registryRevision = browsersJson.browsers.find((b: { name: string }) => b.name === 'chromium').revision; + const root = findGstackInstallRoot(registryRevision); + expect(root).not.toBe(null); + expect(fs.existsSync(path.join(root!, 'node_modules', 'playwright-core', 'browsers.json'))).toBe(true); + }); +}); + +// ─── Quarantine-clear scope contract ───────────────────────────────────── + +describe('clearQuarantineOnPlaywrightCache', () => { + let tmpCache: string; + let execPath: string; + const savedCustomPath = process.env.GSTACK_CHROMIUM_PATH; + + beforeEach(() => { + tmpCache = fs.mkdtempSync(path.join(os.tmpdir(), 'xprotect-cache-')); + for (const dir of ['chromium-1234', 'chromium_headless_shell-1234', 'firefox-5678', 'webkit-2222']) { + fs.mkdirSync(path.join(tmpCache, dir), { recursive: true }); + } + execPath = path.join(tmpCache, 'chromium-1234', 'chrome-mac-arm64', 'App.app', 'Contents', 'MacOS', 'chromium'); + delete process.env.GSTACK_CHROMIUM_PATH; + }); + + afterEach(() => { + fs.rmSync(tmpCache, { recursive: true, force: true }); + if (savedCustomPath === undefined) delete process.env.GSTACK_CHROMIUM_PATH; + else process.env.GSTACK_CHROMIUM_PATH = savedCustomPath; + }); + + it('clears every chromium* revision dir, never firefox/webkit', () => { + const cleared: string[] = []; + const ok = clearQuarantineOnPlaywrightCache(execPath, (target) => { + cleared.push(path.basename(target)); + return 0; + }); + expect(ok).toBe(true); + expect(cleared.sort()).toEqual(['chromium-1234', 'chromium_headless_shell-1234']); + }); + + it('NEVER touches a GSTACK_CHROMIUM_PATH bundle (embedder scope contract)', () => { + process.env.GSTACK_CHROMIUM_PATH = execPath; + const cleared: string[] = []; + const ok = clearQuarantineOnPlaywrightCache(execPath, (target) => { + cleared.push(target); + return 0; + }); + expect(ok).toBe(false); + expect(cleared).toEqual([]); + }); + + it('skips executables outside the Playwright cache layout', () => { + const cleared: string[] = []; + const ok = clearQuarantineOnPlaywrightCache('/Applications/Foo.app/Contents/MacOS/foo', (target) => { + cleared.push(target); + return 0; + }); + expect(ok).toBe(false); + expect(cleared).toEqual([]); + }); +}); + +// ─── One-shot heal orchestration (F4) ──────────────────────────────────── + +function makeDeps(counters: { installs: number; quarantines: number }, overrides: Record = {}) { + return { + platform: 'darwin' as NodeJS.Platform, + executablePath: () => '/tmp/ms-playwright/chromium-1234/chrome-mac-arm64/App.app/Contents/MacOS/chromium', + clearQuarantine: () => { counters.quarantines++; return true; }, + installRoot: () => '/tmp/fake-gstack-root', + runReinstall: async () => { counters.installs++; return { ok: true }; }, + verifyInstalled: () => true, + ...overrides, + }; +} + +describe('maybeHealXProtectKill', () => { + beforeEach(() => resetXProtectHealForTests()); + + it('heals a classified failure: quarantine-clear + reinstall + verify', async () => { + const counters = { installs: 0, quarantines: 0 }; + const healed = await maybeHealXProtectKill(new Error(SIGKILL_BROWSER_CLOSED), {}, makeDeps(counters)); + expect(healed).toBe(true); + expect(counters.quarantines).toBe(1); + expect(counters.installs).toBe(1); + }); + + it('F4: runs AT MOST ONCE per process, even across distinct errors', async () => { + const counters = { installs: 0, quarantines: 0 }; + expect(await maybeHealXProtectKill(new Error(SIGKILL_BROWSER_CLOSED), {}, makeDeps(counters))).toBe(true); + expect(await maybeHealXProtectKill(new Error(LAUNCH_TIMEOUT_AFTER_SPAWN), {}, makeDeps(counters))).toBe(false); + expect(counters.installs).toBe(1); + }); + + it('F4: a FAILED heal also consumes the one-shot (no reinstall loops)', async () => { + const counters = { installs: 0, quarantines: 0 }; + const failing = makeDeps(counters, { runReinstall: async () => { counters.installs++; return { ok: false, reason: 'timeout' }; } }); + expect(await maybeHealXProtectKill(new Error(SIGKILL_BROWSER_CLOSED), {}, failing)).toBe(false); + expect(await maybeHealXProtectKill(new Error(SIGKILL_BROWSER_CLOSED), {}, makeDeps(counters))).toBe(false); + expect(counters.installs).toBe(1); + }); + + it('an unclassified error does NOT consume the one-shot', async () => { + const counters = { installs: 0, quarantines: 0 }; + expect(await maybeHealXProtectKill(new Error(MISSING_EXECUTABLE), {}, makeDeps(counters))).toBe(false); + expect(counters.installs).toBe(0); + // Guard not consumed — a real signature afterwards still heals. + expect(await maybeHealXProtectKill(new Error(SIGKILL_BROWSER_CLOSED), {}, makeDeps(counters))).toBe(true); + }); + + it('never heals over a custom executable (GSTACK_CHROMIUM_PATH scope)', async () => { + const counters = { installs: 0, quarantines: 0 }; + const healed = await maybeHealXProtectKill( + new Error(SIGKILL_BROWSER_CLOSED), + { usesCustomExecutable: true }, + makeDeps(counters), + ); + expect(healed).toBe(false); + expect(counters.quarantines).toBe(0); + expect(counters.installs).toBe(0); + }); + + it('fails the heal when no install root pins our revision (ENG-OV3)', async () => { + const counters = { installs: 0, quarantines: 0 }; + const deps = makeDeps(counters, { installRoot: () => null }); + expect(await maybeHealXProtectKill(new Error(SIGKILL_BROWSER_CLOSED), {}, deps)).toBe(false); + expect(counters.installs).toBe(0); + }); + + it('fails the heal when post-install verification misses the expected revision dir (F9)', async () => { + const counters = { installs: 0, quarantines: 0 }; + const deps = makeDeps(counters, { verifyInstalled: () => false }); + expect(await maybeHealXProtectKill(new Error(SIGKILL_BROWSER_CLOSED), {}, deps)).toBe(false); + expect(counters.installs).toBe(1); + }); +}); + +// ─── Launch wrapper ────────────────────────────────────────────────────── + +describe('launchWithXProtectHeal', () => { + beforeEach(() => resetXProtectHealForTests()); + + it('retries the launch exactly once after a successful heal', async () => { + const counters = { installs: 0, quarantines: 0 }; + let attempts = 0; + const result = await launchWithXProtectHeal(async () => { + attempts++; + if (attempts === 1) throw new Error(SIGKILL_BROWSER_CLOSED); + return 'browser'; + }, {}, makeDeps(counters)); + expect(result).toBe('browser'); + expect(attempts).toBe(2); + expect(counters.installs).toBe(1); + }); + + it('surfaces the ORIGINAL error + manual guidance when the heal fails (E1)', async () => { + const counters = { installs: 0, quarantines: 0 }; + const deps = makeDeps(counters, { runReinstall: async () => ({ ok: false, reason: 'timeout' }) }); + let thrown: Error | null = null; + try { + await launchWithXProtectHeal(async () => { throw new Error(SIGKILL_BROWSER_CLOSED); }, {}, deps); + } catch (err) { + thrown = err as Error; + } + expect(thrown).not.toBe(null); + // Original launch error text preserved… + expect(thrown!.message).toContain('signal=SIGKILL'); + // …plus the manual remediation. + expect(thrown!.message).toContain('bunx playwright install chromium'); + }); + + it('passes unclassified failures through untouched', async () => { + const counters = { installs: 0, quarantines: 0 }; + let thrown: Error | null = null; + try { + await launchWithXProtectHeal(async () => { throw new Error(MISSING_EXECUTABLE); }, {}, makeDeps(counters)); + } catch (err) { + thrown = err as Error; + } + expect(thrown!.message).toBe(MISSING_EXECUTABLE); + expect(counters.installs).toBe(0); + }); +}); + +describe('buildXProtectGuidance', () => { + it('carries both the original message and the manual command', () => { + const out = buildXProtectGuidance('original launch error'); + expect(out).toContain('original launch error'); + expect(out).toContain('bunx playwright install chromium'); + expect(out).toContain('#2554'); + }); +}); diff --git a/setup b/setup index 638196550..1a59f30b0 100755 --- a/setup +++ b/setup @@ -360,6 +360,24 @@ ensure_playwright_browser() { _wait_with_deadline $! 90 } +# P0 #2554: a macOS XProtect definition update can start SIGKILLing the +# Chromium revision the lockfile pins, which surfaces here as a failed launch +# probe. Clear com.apple.quarantine on the Playwright cache bundles ONLY — +# never a GSTACK_CHROMIUM_PATH bundle (that belongs to the wrapper/embedder; +# same scope contract as browse's probePoisonedChromiumBundle) — so the +# reinstall below produces a launchable browser. Best-effort and macOS-only. +_clear_playwright_quarantine() { + [ "$(uname -s)" = "Darwin" ] || return 0 + local cache_root="${PLAYWRIGHT_BROWSERS_PATH:-$HOME/Library/Caches/ms-playwright}" + [ -d "$cache_root" ] || return 0 + local d + for d in "$cache_root"/chromium-* "$cache_root"/chromium_headless_shell-*; do + [ -d "$d" ] || continue + echo " clearing com.apple.quarantine on $(basename "$d") (XProtect self-heal, #2554)" >&2 + xattr -dr com.apple.quarantine "$d" 2>/dev/null || true + done +} + # Ensure a color-emoji font is installed (Linux only). # # Chromium renders emoji code points as .notdef "tofu" (▯) when no color-emoji @@ -629,6 +647,10 @@ fi if ! ensure_playwright_browser; then echo "Installing Playwright Chromium..." + # XProtect self-heal (#2554): the probe failure may be the OS killing the + # cached Chromium, not a missing install. Clear quarantine on the Playwright + # cache bundles before reinstalling so the fresh fetch launches clean. + _clear_playwright_quarantine _PW_LOCK="${TMPDIR:-/tmp}/gstack-playwright-install.lock" # Stale-lock self-heal: a SIGKILL'd prior setup leaves the lock dir behind # forever (mkdir mutexes have no owner). If the recorded holder PID is dead,