mirror of https://github.com/garrytan/gstack.git
fix(browse): poisoned-bundle self-heal removes the revision dir, probes handoff too, and throws typed
Same marker flaw as the migration: rmSync of the .app alone left INSTALLATION_COMPLETE behind, so the error message's own remediation no-opped and the user was hard-stuck. The probe is now an exported, unit-tested helper (probePoisonedChromiumBundle) that removes the whole chromium-<rev> dir, never touches GSTACK_CHROMIUM_PATH custom bundles, throws PoisonedBundleError (instanceof, not string-match), and runs on BOTH headed entry points — launchHeaded and handoff. 7 tests.
This commit is contained in:
parent
800716e9a4
commit
24cee40b36
|
|
@ -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-<rev>/chrome-mac/<name>.app), the WHOLE chromium-<rev> 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
|
||||
* (…/<name>.app/Contents/MacOS/<name>), 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.
|
||||
|
|
|
|||
|
|
@ -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-<rev>/chrome-mac/<name>.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 =
|
||||
'<plist><dict><key>CFBundleName</key><string>GStack Browser</string></dict></plist>';
|
||||
const CLEAN_PLIST =
|
||||
'<plist><dict><key>CFBundleName</key><string>Google Chrome for Testing</string></dict></plist>';
|
||||
|
||||
let tmpDir: string;
|
||||
let savedCustomPath: string | undefined;
|
||||
|
||||
/** Build <parentDir>/<name>.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-<rev> 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/);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue