mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-17 10:25:33 +02:00
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:
co-authored by
Claude Fable 5
parent
2851535f3b
commit
822de7d0c3
@@ -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.`;
|
||||
|
||||
Reference in New Issue
Block a user