diff --git a/browse/src/browser-manager.ts b/browse/src/browser-manager.ts index 4dc9cf857..ffc8b5d7e 100644 --- a/browse/src/browser-manager.ts +++ b/browse/src/browser-manager.ts @@ -73,6 +73,82 @@ export function shouldEnableChromiumSandbox(): boolean { return !(process.env.CI || process.env.CONTAINER || isRoot); } +/** + * Thrown by probePoisonedChromiumBundle() when it finds — and removes — a + * Chromium bundle poisoned by the pre-v1.64 in-place rebrand (#2242). + * Call sites rethrow on `instanceof` (never message-string sniffing) so the + * actionable remediation reaches the user instead of being swallowed by the + * probe's fall-through-on-failure catch. + */ +export class PoisonedBundleError extends Error { + constructor(message: string) { + super(message); + this.name = 'PoisonedBundleError'; + } +} + +/** + * Self-heal probe for bundles the OLD (pre-v1.64) rebrand code already + * poisoned (#2242): the mutation lives in the SHARED Playwright cache, so + * deleting the rebrand code fixes fresh installs only, and the documented + * deploy paths never run upgrade migrations. Detect the mutated plist and + * remove the bundle so the next `playwright install chromium` (or the + * upgrade migration) re-fetches a clean one. + * + * Removal scope: when the .app sits in the standard Playwright cache layout + * (chromium-/chrome-mac/.app), the WHOLE chromium- revision + * dir is removed — Playwright's INSTALLATION_COMPLETE marker lives there, + * and `playwright install chromium` treats its presence as "is already + * downloaded", so removing only the .app would turn our own remediation + * command into a no-op that leaves the user with no browser at all. Outside + * that layout, the .app plus any sibling INSTALLATION_COMPLETE / + * DEPENDENCIES_VALIDATED markers are removed. + * + * Caller contract: pass ONLY Playwright-cache executables + * (chromium.executablePath()). A bundle supplied via GSTACK_CHROMIUM_PATH + * belongs to the wrapper/embedder — its plist legitimately says "GStack + * Browser" — and must never be deleted. Both call sites (launchHeaded and + * handoff) honor this, and as a second belt the probe refuses to act on the + * GSTACK_CHROMIUM_PATH executable itself. + * + * @param chromiumExecutablePath the Chromium binary inside the .app + * (…/.app/Contents/MacOS/), as returned by + * chromium.executablePath(). + * @throws PoisonedBundleError after removing a poisoned bundle — the + * message carries the re-fetch command for the user. + */ +export function probePoisonedChromiumBundle(chromiumExecutablePath: string): void { + const fs = require('fs'); + const path = require('path'); + + // Belt to the caller contract: never act on the custom/embedder bundle. + const customPath = process.env.GSTACK_CHROMIUM_PATH; + if (customPath && path.resolve(chromiumExecutablePath) === path.resolve(customPath)) { + return; + } + + const chromeContentsDir = path.resolve(path.dirname(chromiumExecutablePath), '..'); + const chromePlist = path.join(chromeContentsDir, 'Info.plist'); + if (!fs.existsSync(chromePlist)) return; + if (!fs.readFileSync(chromePlist, 'utf-8').includes('GStack Browser')) return; + + const appDir = path.resolve(chromeContentsDir, '..'); + const revisionDir = path.resolve(appDir, '..', '..'); + if (/^chromium-\d+$/.test(path.basename(revisionDir))) { + fs.rmSync(revisionDir, { recursive: true, force: true }); + } else { + fs.rmSync(appDir, { recursive: true, force: true }); + for (const marker of ['INSTALLATION_COMPLETE', 'DEPENDENCIES_VALIDATED']) { + fs.rmSync(path.join(path.dirname(appDir), marker), { force: true }); + } + } + throw new PoisonedBundleError( + 'Chromium bundle was mutated by a previous gstack version (broken codesign seal — ' + + 'GPU exit_code=5 on macOS 26). The poisoned bundle has been removed. ' + + 'Re-fetch a clean one with: bunx playwright install chromium — then retry.', + ); +} + /** * Resolve why the underlying Chromium ChildProcess is going away. * @@ -521,30 +597,17 @@ export class BrowserManager { // bundle here — browse/test/rebrand-signed-bundle.test.ts fails CI if // you do. // - // Self-heal for bundles the OLD code already poisoned: the mutation - // lives in the SHARED Playwright cache, so deleting the rebrand code - // fixes fresh installs only, and the documented deploy paths never run - // upgrade migrations. Detect the mutated plist and remove the bundle so - // the next `playwright install chromium` (or the upgrade migration) - // re-fetches a clean one. Scoped to the Playwright cache copy — a + // Self-heal for bundles the OLD code already poisoned: probe the + // Playwright-cache bundle and remove it when the mutated plist is + // present (see probePoisonedChromiumBundle for the removal-scope + // rationale). Scoped to the Playwright cache copy — a // GSTACK_CHROMIUM_PATH bundle belongs to the wrapper/embedder and is - // never touched. + // never probed. if (!executablePath) { try { - const chromePath = chromium.executablePath(); - const chromeContentsDir = path.resolve(path.dirname(chromePath), '..'); - const chromePlist = path.join(chromeContentsDir, 'Info.plist'); - if (fs.existsSync(chromePlist) && fs.readFileSync(chromePlist, 'utf-8').includes('GStack Browser')) { - const appDir = path.resolve(chromeContentsDir, '..'); - fs.rmSync(appDir, { recursive: true, force: true }); - throw new Error( - 'Chromium bundle was mutated by a previous gstack version (broken codesign seal — ' + - 'GPU exit_code=5 on macOS 26). The poisoned bundle has been removed. ' + - 'Re-fetch a clean one with: bunx playwright install chromium — then retry.', - ); - } - } catch (err: any) { - if (err?.message?.includes('poisoned bundle')) throw err; + probePoisonedChromiumBundle(chromium.executablePath()); + } catch (err: unknown) { + if (err instanceof PoisonedBundleError) throw err; // Probe failures (no bundle yet, EACCES) fall through to launch, // which produces its own actionable error. } @@ -1579,6 +1642,20 @@ export class BrowserManager { const userDataDir = path.join(process.env.HOME || '/tmp', '.gstack', 'chromium-profile'); fs.mkdirSync(userDataDir, { recursive: true }); + // Self-heal probe (#2242): handoff always launches the Playwright-cache + // bundle (this launchPersistentContext call passes no executablePath), + // so a bundle poisoned by the old in-place rebrand would GPU-crash here + // exactly like launchHeaded(). Same probe, same contract: a + // GSTACK_CHROMIUM_PATH bundle is never passed in. The rethrown typed + // error surfaces through the outer catch as the actionable + // "Cannot open headed browser" message, headless browser untouched. + try { + probePoisonedChromiumBundle(chromium.executablePath()); + } catch (err: unknown) { + if (err instanceof PoisonedBundleError) throw err; + // Probe failures (no bundle yet, EACCES) fall through to launch. + } + // T1: same automation-tell-stripping defaults as launchHeaded(). // The handoff path (headless → headed re-launch) takes the same // anti-detection posture. diff --git a/browse/test/poisoned-bundle-probe.test.ts b/browse/test/poisoned-bundle-probe.test.ts new file mode 100644 index 000000000..0ae4a8a5f --- /dev/null +++ b/browse/test/poisoned-bundle-probe.test.ts @@ -0,0 +1,148 @@ +/** + * Unit tests for the extracted poisoned-bundle self-heal probe (#2242). + * + * probePoisonedChromiumBundle() detects a Chromium bundle mutated by the + * pre-v1.64 in-place rebrand (Info.plist contains "GStack Browser"), + * removes it so `playwright install chromium` actually re-downloads, and + * throws a typed PoisonedBundleError with the remediation command. + * + * Contracts pinned here: + * - standard cache layout (chromium-/chrome-mac/.app): the + * WHOLE revision dir is removed, INSTALLATION_COMPLETE marker included + * (leaving the marker makes the recommended re-fetch a no-op) + * - non-cache layout: the .app + sibling install markers are removed, + * nothing else + * - clean bundle: untouched, no throw + * - GSTACK_CHROMIUM_PATH bundles (custom/embedder) are NEVER deleted: + * the probe refuses to act on that executable, and both call sites + * (launchHeaded + handoff) only pass chromium.executablePath() + * - the rethrow guard at the call sites is typed (instanceof), not a + * fragile message-string match + */ + +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { probePoisonedChromiumBundle, PoisonedBundleError } from '../src/browser-manager'; + +const SRC = fs.readFileSync( + path.join(import.meta.dir, '..', 'src', 'browser-manager.ts'), + 'utf-8', +); + +const POISONED_PLIST = + 'CFBundleNameGStack Browser'; +const CLEAN_PLIST = + 'CFBundleNameGoogle Chrome for Testing'; + +let tmpDir: string; +let savedCustomPath: string | undefined; + +/** Build /.app with a plist and executable; return the executable path. */ +function makeApp(parentDir: string, plist: string): { appDir: string; exe: string } { + const appDir = path.join(parentDir, 'Google Chrome for Testing.app'); + const macos = path.join(appDir, 'Contents', 'MacOS'); + fs.mkdirSync(macos, { recursive: true }); + fs.writeFileSync(path.join(appDir, 'Contents', 'Info.plist'), plist); + const exe = path.join(macos, 'Google Chrome for Testing'); + fs.writeFileSync(exe, '#!/bin/sh\n', { mode: 0o755 }); + return { appDir, exe }; +} + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'poison-probe-')); + savedCustomPath = process.env.GSTACK_CHROMIUM_PATH; + delete process.env.GSTACK_CHROMIUM_PATH; +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + if (savedCustomPath === undefined) delete process.env.GSTACK_CHROMIUM_PATH; + else process.env.GSTACK_CHROMIUM_PATH = savedCustomPath; +}); + +describe('probePoisonedChromiumBundle — poisoned cache bundle', () => { + test('standard cache layout: whole chromium- dir removed (markers included), typed error thrown', () => { + const revDir = path.join(tmpDir, 'ms-playwright', 'chromium-1234'); + const { exe } = makeApp(path.join(revDir, 'chrome-mac'), POISONED_PLIST); + fs.writeFileSync(path.join(revDir, 'INSTALLATION_COMPLETE'), ''); + fs.writeFileSync(path.join(revDir, 'DEPENDENCIES_VALIDATED'), ''); + + let caught: unknown; + try { + probePoisonedChromiumBundle(exe); + } catch (err) { + caught = err; + } + expect(caught).toBeInstanceOf(PoisonedBundleError); + // The message is the user's remediation — it must carry the command. + expect((caught as Error).message).toContain('playwright install chromium'); + // Whole revision dir gone: leaving INSTALLATION_COMPLETE behind makes + // `playwright install chromium` no-op ("is already downloaded") and the + // remediation we just printed would do nothing. + expect(fs.existsSync(revDir)).toBe(false); + }); + + test('non-cache layout: only the .app + sibling install markers removed, neighbors survive', () => { + const parentDir = path.join(tmpDir, 'custom-bundles'); + const { appDir, exe } = makeApp(parentDir, POISONED_PLIST); + fs.writeFileSync(path.join(parentDir, 'INSTALLATION_COMPLETE'), ''); + fs.writeFileSync(path.join(parentDir, 'DEPENDENCIES_VALIDATED'), ''); + fs.writeFileSync(path.join(parentDir, 'unrelated.txt'), 'keep me'); + + expect(() => probePoisonedChromiumBundle(exe)).toThrow(PoisonedBundleError); + expect(fs.existsSync(appDir)).toBe(false); + expect(fs.existsSync(path.join(parentDir, 'INSTALLATION_COMPLETE'))).toBe(false); + expect(fs.existsSync(path.join(parentDir, 'DEPENDENCIES_VALIDATED'))).toBe(false); + // The parent dir itself and unrelated files are NOT swept. + expect(fs.readFileSync(path.join(parentDir, 'unrelated.txt'), 'utf-8')).toBe('keep me'); + }); +}); + +describe('probePoisonedChromiumBundle — clean and missing bundles', () => { + test('clean plist: untouched, no throw', () => { + const revDir = path.join(tmpDir, 'ms-playwright', 'chromium-1234'); + const { appDir, exe } = makeApp(path.join(revDir, 'chrome-mac'), CLEAN_PLIST); + fs.writeFileSync(path.join(revDir, 'INSTALLATION_COMPLETE'), ''); + + expect(() => probePoisonedChromiumBundle(exe)).not.toThrow(); + expect(fs.existsSync(path.join(appDir, 'Contents', 'Info.plist'))).toBe(true); + expect(fs.existsSync(path.join(revDir, 'INSTALLATION_COMPLETE'))).toBe(true); + }); + + test('no plist at the probed path: no-op, no throw (bundle not installed yet)', () => { + expect(() => + probePoisonedChromiumBundle(path.join(tmpDir, 'nope.app', 'Contents', 'MacOS', 'nope')), + ).not.toThrow(); + }); +}); + +describe('probePoisonedChromiumBundle — GSTACK_CHROMIUM_PATH is never deleted', () => { + test('probe refuses to act on the GSTACK_CHROMIUM_PATH executable, even when poisoned', () => { + // A custom/embedder bundle (GStack Browser.app wrapper) legitimately + // contains "GStack Browser" in its plist — that is its branding, not + // cache poison. Deleting it would destroy the embedder's product. + const { appDir, exe } = makeApp(path.join(tmpDir, 'GStack Browser.app-parent'), POISONED_PLIST); + process.env.GSTACK_CHROMIUM_PATH = exe; + + expect(() => probePoisonedChromiumBundle(exe)).not.toThrow(); + expect(fs.existsSync(path.join(appDir, 'Contents', 'Info.plist'))).toBe(true); + expect(fs.existsSync(exe)).toBe(true); + }); + + test('caller contract: both headed launch paths probe chromium.executablePath() only', () => { + // launchHeaded + handoff each call the probe with the Playwright-cache + // path. No call site may ever pass the custom-bundle env var. + const calls = SRC.match(/probePoisonedChromiumBundle\(chromium\.executablePath\(\)\)/g) || []; + expect(calls.length).toBeGreaterThanOrEqual(2); + expect(SRC).not.toMatch(/probePoisonedChromiumBundle\([^)]*GSTACK_CHROMIUM_PATH/); + }); +}); + +describe('typed rethrow guard at the call sites', () => { + test('instanceof PoisonedBundleError, not message-string sniffing', () => { + expect(SRC).not.toContain("includes('poisoned bundle')"); + expect(SRC).toMatch(/instanceof PoisonedBundleError/); + }); +});