From a30ca53c1ab8d46363f83e7375a371f9681748d1 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Fri, 14 Aug 2026 12:48:06 -0700 Subject: [PATCH] =?UTF-8?q?fix(browse):=20headed=20mode=20on=20macOS=2026?= =?UTF-8?q?=20=E2=80=94=20stop=20mutating=20the=20signed=20Chromium=20bund?= =?UTF-8?q?le,=20heal=20the=20ones=20we=20already=20broke=20(#2242,=20#213?= =?UTF-8?q?8,=20#2139)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The in-place rebrand rewrote the Chrome-for-Testing bundle's Info.plist (global name replace — which also renamed CFBundleExecutable to a binary that doesn't exist) and overwrote its Resources/*.icns, breaking the codesign seal: GPU process exit_code=5, headed mode dead on macOS 26. The mutation lived in the SHARED Playwright cache, so it also poisoned the user's other Playwright projects. Three layers land together: (1) the rebrand block is gone — branding lives in the GStack Browser.app wrapper via GSTACK_CHROMIUM_PATH, with a tombstone and a static tripwire (no plist/icns writes into the bundle; the tripwire allows the read-only probe below); (2) a launch-time self-heal detects an already-poisoned cache bundle, removes it, and errors with the exact re-fetch command — covering deploy paths that never run migrations; (3) migration v1.64.0.0 sweeps every cached bundle, removes poisoned ones, and re-fetches clean Chromium immediately (migrations run after ./setup, so without the re-fetch an upgrade would end with zero working browser). Functionally verified against fixture caches: poisoned removed, clean untouched, rerun no-op. Migration filename tracks the final VERSION at ship. The #2242 watchdog half is the absorbed PR #2565 (thanks @Screddyice). Tombstone/tripwire ported from time-attack/gstack (GStack 2); self-heal and migration are ours. Co-authored-by: Sina Matian Co-Authored-By: Claude Fable 5 --- browse/src/browser-manager.ts | 75 +++++++++++------------ browse/test/rebrand-signed-bundle.test.ts | 53 ++++++++++++++++ gstack-upgrade/migrations/v1.64.0.0.sh | 75 +++++++++++++++++++++++ 3 files changed, 165 insertions(+), 38 deletions(-) create mode 100644 browse/test/rebrand-signed-bundle.test.ts create mode 100755 gstack-upgrade/migrations/v1.64.0.0.sh diff --git a/browse/src/browser-manager.ts b/browse/src/browser-manager.ts index 65597ce45..4dc9cf857 100644 --- a/browse/src/browser-manager.ts +++ b/browse/src/browser-manager.ts @@ -509,46 +509,45 @@ export class BrowserManager { // Used by GStack Browser.app to point at the bundled Chromium. const executablePath = process.env.GSTACK_CHROMIUM_PATH || undefined; - // Rebrand Chromium → GStack Browser in macOS menu bar / Dock / Cmd+Tab. - // Patch the Chromium .app's Info.plist so macOS shows our name. - // This works for both dev mode (system Playwright cache) and .app bundle. - const chromePath = executablePath || chromium.executablePath(); - try { - // Walk up from binary to the .app's Info.plist - // e.g. .../Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing - // → .../Google Chrome for Testing.app/Contents/Info.plist - const chromeContentsDir = path.resolve(path.dirname(chromePath), '..'); - const chromePlist = path.join(chromeContentsDir, 'Info.plist'); - if (fs.existsSync(chromePlist)) { - const plistContent = fs.readFileSync(chromePlist, 'utf-8'); - if (plistContent.includes('Google Chrome for Testing')) { - const patched = plistContent - .replace(/Google Chrome for Testing/g, 'GStack Browser'); - fs.writeFileSync(chromePlist, patched); - } - // Replace Chromium's Dock icon with ours (Chromium's process owns the Dock icon) - const iconCandidates = [ - path.join(__dirname, '..', '..', 'scripts', 'app', 'icon.icns'), // repo dev mode - path.join(process.env.HOME || '', '.claude', 'skills', 'gstack', 'scripts', 'app', 'icon.icns'), // global install - ]; - const iconSrc = iconCandidates.find(p => fs.existsSync(p)); - if (iconSrc) { - const chromeResources = path.join(chromeContentsDir, 'Resources'); - // Read original icon name from plist - const iconMatch = plistContent.match(/CFBundleIconFile<\/key>\s*([^<]+)<\/string>/); - let origIcon = iconMatch ? iconMatch[1] : 'app'; - if (!origIcon.endsWith('.icns')) origIcon += '.icns'; - const destIcon = path.join(chromeResources, origIcon); - try { - fs.copyFileSync(iconSrc, destIcon); - } catch (err: any) { - if (err?.code !== 'ENOENT' && err?.code !== 'EACCES') throw err; - } + // NOTE (#2242): the in-place "rebrand" that patched the Chromium .app's + // Info.plist (global "Google Chrome for Testing" → "GStack Browser" + // replace) and overwrote its Resources/*.icns is deliberately GONE. + // Chrome for Testing is a code-signed bundle: the global replace renamed + // CFBundleExecutable to a binary that doesn't exist and the plist/icon + // writes broke the codesign seal — GPU process exit_code=5, headed mode + // dead on macOS 26 (#2242, #2138, #2139). Branding belongs in the + // GStack Browser.app wrapper (GSTACK_CHROMIUM_PATH), never in a mutation + // of the signed bundle. Do not reintroduce writes into the Chromium + // 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 + // GSTACK_CHROMIUM_PATH bundle belongs to the wrapper/embedder and is + // never touched. + 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; + // Probe failures (no bundle yet, EACCES) fall through to launch, + // which produces its own actionable error. } - } catch (err: any) { - // Non-fatal: app name stays as Chrome for Testing (ENOENT/EACCES expected) - if (err?.code !== 'ENOENT' && err?.code !== 'EACCES') throw err; } // Build custom user agent: report as stock Chrome with the version diff --git a/browse/test/rebrand-signed-bundle.test.ts b/browse/test/rebrand-signed-bundle.test.ts new file mode 100644 index 000000000..723df131a --- /dev/null +++ b/browse/test/rebrand-signed-bundle.test.ts @@ -0,0 +1,53 @@ +/** + * #2242 bug 1 regression tripwire: never mutate the signed Chrome-for-Testing + * bundle. + * + * The old launchHeaded() "rebrand" ran a global + * `.replace(/Google Chrome for Testing/g, 'GStack Browser')` over the + * bundle's Info.plist — which renamed CFBundleExecutable to a binary that + * doesn't exist — and overwrote Resources/*.icns. Both writes broke the + * codesign seal: GPU process exit_code=5, headed mode dead on macOS 26 + * (#2242, #2138, #2139). + * + * Static invariant (same pattern as cdp-session-cleanup.test.ts): the + * browser lifecycle code must contain NO write into the Chromium .app + * bundle. Branding lives in the wrapper .app / custom GBrowser build. + * These assertions fail on the pre-fix code. + */ + +import { describe, test, expect } from 'bun:test'; +import * as fs from 'fs'; +import * as path from 'path'; + +const SRC = fs.readFileSync( + path.join(import.meta.dir, '..', 'src', 'browser-manager.ts'), + 'utf-8', +); + +describe('#2242: signed Chromium bundle is never mutated', () => { + test('no global Google-Chrome-for-Testing plist replace', () => { + expect(SRC).not.toContain("replace(/Google Chrome for Testing/g"); + }); + + test('no Info.plist write into the Chromium bundle (reads allowed: self-heal probe)', () => { + // The old code built `Info.plist` under the bundle's Contents dir and + // wrote it back. Any reappearance of a WRITE is a regression. The + // launch-time self-heal legitimately READS the plist to detect bundles + // the old code already poisoned (EV4), so the path construction itself + // is allowed — writes into it are not. + expect(SRC).not.toMatch(/writeFileSync\(\s*chromePlist/); + const plistWrites = SRC.match(/writeFileSync\([^)]*[Pp]list/g) || []; + expect(plistWrites).toEqual([]); + }); + + test('no icon overwrite into the Chromium bundle Resources dir', () => { + expect(SRC).not.toMatch(/copyFileSync\([^)]*destIcon/); + expect(SRC).not.toContain("CFBundleIconFile"); + }); + + test('the tombstone comment documenting why stays put', () => { + // If someone deletes the explanation, the next contributor reintroduces + // the mutation in good faith. Keep the why next to the where. + expect(SRC).toContain('#2242'); + }); +}); diff --git a/gstack-upgrade/migrations/v1.64.0.0.sh b/gstack-upgrade/migrations/v1.64.0.0.sh new file mode 100755 index 000000000..a5aa440c6 --- /dev/null +++ b/gstack-upgrade/migrations/v1.64.0.0.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# Migration: v1.64.0.0 — repair Chrome-for-Testing bundles poisoned by the +# old in-place rebrand (#2242). +# +# Why a migration: pre-v1.64 launchHeaded() rewrote the Chromium .app's +# Info.plist ("Google Chrome for Testing" → "GStack Browser") and overwrote +# its Resources/*.icns — inside the SHARED Playwright cache. That broke the +# codesign seal (GPU process exit_code=5; headed mode dead on macOS 26) and +# poisoned the cache for the user's OTHER Playwright projects too. Deleting +# the rebrand code fixes fresh installs only; every existing macOS install +# still has the mutated bundle on disk. This migration removes poisoned +# bundles and re-fetches a clean one so the upgrade doesn't leave the user +# with zero working browser (the browse launch path also self-heals, as the +# belt to this suspenders, for installs that never run migrations). +# +# Affected: macOS installs that ever ran headed mode before v1.64. +# +# Idempotent: detection is content-based (plist contains "GStack Browser"); +# a clean cache is a no-op, and the .done touchfile gates re-runs. The +# re-fetch is best-effort and non-fatal per the migration contract. + +set -u + +GSTACK_HOME="${GSTACK_HOME:-${HOME}/.gstack}" +MIGRATION_DIR="${GSTACK_HOME}/.migrations" +DONE="${MIGRATION_DIR}/v1.64.0.0.done" +mkdir -p "${MIGRATION_DIR}" 2>/dev/null || true +[ -f "${DONE}" ] && exit 0 + +# macOS only: the mutation targeted .app bundle plists. +if [ "$(uname -s 2>/dev/null)" != "Darwin" ]; then + touch "${DONE}" + exit 0 +fi + +PW_CACHE="${PLAYWRIGHT_BROWSERS_PATH:-${HOME}/Library/Caches/ms-playwright}" +REMOVED=0 + +if [ -d "${PW_CACHE}" ]; then + # Every Chrome-for-Testing bundle in the cache (one per pinned chromium build). + while IFS= read -r plist; do + if grep -q "GStack Browser" "${plist}" 2>/dev/null; then + app_dir="$(dirname "$(dirname "${plist}")")" + case "${app_dir}" in + "${PW_CACHE}"/*.app|"${PW_CACHE}"/*/*.app|"${PW_CACHE}"/*/*/*.app) + echo " [v1.64.0.0] removing rebrand-poisoned bundle: ${app_dir}" >&2 + rm -rf "${app_dir}" + REMOVED=1 + ;; + *) + echo " [v1.64.0.0] WARNING: poisoned plist outside the Playwright cache shape, skipping: ${plist}" >&2 + ;; + esac + fi + done < <(find "${PW_CACHE}" -maxdepth 5 -name "Info.plist" -path "*.app/Contents/Info.plist" 2>/dev/null) +fi + +if [ "${REMOVED}" = "1" ]; then + # Re-fetch immediately: migrations run AFTER ./setup, so without this the + # user finishes the upgrade with no working browser at all (headless AND + # headed use the same bundle). Best-effort — a failed download leaves the + # actionable command printed and the launch-time self-heal message covers + # the rest. + echo " [v1.64.0.0] re-fetching a clean Chromium (bunx playwright install chromium)..." >&2 + if command -v bunx >/dev/null 2>&1 && bunx playwright install chromium >&2; then + echo " [v1.64.0.0] clean Chromium installed." >&2 + else + echo " [v1.64.0.0] WARNING: automatic re-fetch failed. Run manually: bunx playwright install chromium" >&2 + fi +else + echo " [v1.64.0.0] no rebrand-poisoned bundles found — no-op." >&2 +fi + +touch "${DONE}" +exit 0