fix(browse): XProtect launch-kill self-heal — classify, quarantine-clear, bounded reinstall (P0 #2554)

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 <launched> 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 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-08-16 10:59:04 -07:00
co-authored by Claude Fable 5
parent 2851535f3b
commit 822de7d0c3
4 changed files with 854 additions and 6 deletions
+19 -6
View File
@@ -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.`;