From 834e90a779ee3b558cbcfef277e0ddb0fca02338 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sun, 18 Oct 2026 09:01:26 +0000 Subject: [PATCH] fix(cli): check never hangs inside an agent's macOS sandbox (#5836) Inside Codex's seatbelt the GPU probe's Metal Chrome is always killed, and when it dies after puppeteer's setAutoAttach is answered, puppeteer waits forever for its initial targets. The probe now answers software in a named seatbelt without starting Chrome, and every CLI browser launch is bounded by its timeout, so a stuck launch rejects and a signal can end the command. --- .../cli/src/browser/launch.noListen.test.ts | 18 ++++++++++- packages/cli/src/browser/launch.ts | 30 +++++++++++++++---- .../cli/src/browser/sandboxDenied.test.ts | 9 ++++++ 3 files changed, 51 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/browser/launch.noListen.test.ts b/packages/cli/src/browser/launch.noListen.test.ts index dc39773e29..6086fda57d 100644 --- a/packages/cli/src/browser/launch.noListen.test.ts +++ b/packages/cli/src/browser/launch.noListen.test.ts @@ -1,6 +1,6 @@ import { EventEmitter } from "node:events"; import type { LaunchOptions, PuppeteerNode } from "puppeteer-core"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; const probe = vi.hoisted(() => ({ options: [] as LaunchOptions[] })); @@ -36,3 +36,19 @@ describe("the GPU probe where nothing may listen", () => { expect(probe.options[1]!.args).toEqual(["--headless"]); }); }); + +describe("the GPU probe inside an agent's macOS seatbelt", () => { + const platform = process.platform; + afterEach(() => { + vi.unstubAllEnvs(); + Object.defineProperty(process, "platform", { value: platform }); + }); + + it("answers software without starting a Chrome the seatbelt would crash", async () => { + vi.stubEnv("CODEX_SANDBOX", "seatbelt"); + Object.defineProperty(process, "platform", { value: "darwin" }); + probe.options.length = 0; + await expect(resolveManagedGpuMode("auto", "/chrome")).resolves.toBe("software"); + expect(probe.options).toEqual([]); + }); +}); diff --git a/packages/cli/src/browser/launch.ts b/packages/cli/src/browser/launch.ts index 4417f9b094..73d527ca23 100644 --- a/packages/cli/src/browser/launch.ts +++ b/packages/cli/src/browser/launch.ts @@ -22,6 +22,7 @@ const closing = new WeakMap>(); const pending = new Map, AbortController>(); const sessionCleanups = new Set<() => Promise>(); const CLOSE_GRACE_MS = 1_000; +const LAUNCH_TIMEOUT_MS = 30_000; const EXIT_POLL_MS = 50; let listening = false; let shutdown: Promise | undefined; @@ -57,6 +58,7 @@ export async function resolveManagedGpuMode( } = {}, ): Promise { ownSignals(); + if (requestedMode === "auto" && inAgentSandbox()) return "software"; return resolveCaptureBrowserGpuMode(requestedMode, chromePath, { launch: async (puppeteer, options) => { context.signal?.throwIfAborted(); @@ -108,12 +110,18 @@ async function launchOwnedBrowser( ownSignals(); const abort = new AbortController(); const signal = options.signal ? AbortSignal.any([options.signal, abort.signal]) : abort.signal; - const launch = puppeteer.launch({ - ...options, + const launch = untilLaunchOrAbort( + puppeteer.launch({ ...options, signal, handleSIGINT: false, handleSIGTERM: false }), signal, - handleSIGINT: false, - handleSIGTERM: false, - }); + ); + const limit = options.timeout ?? LAUNCH_TIMEOUT_MS; + const timer = + limit > 0 + ? setTimeout( + () => abort.abort(new Error(`The browser did not finish starting within ${limit} ms.`)), + limit, + ) + : undefined; pending.set(launch, abort); try { const browser = await launch; @@ -123,10 +131,22 @@ async function launchOwnedBrowser( child.once("exit", () => browsers.delete(browser)); return browser; } finally { + clearTimeout(timer); pending.delete(launch); } } +function untilLaunchOrAbort(launch: Promise, signal: AbortSignal): Promise { + const aborted = new Promise((_, reject) => { + if (signal.aborted) reject(signal.reason); + signal.addEventListener("abort", () => reject(signal.reason), { once: true }); + }); + void launch + .then((late) => (signal.aborted ? closeManagedBrowser(late) : undefined)) + .catch(() => undefined); + return Promise.race([launch, aborted]); +} + /** Resolved launches keep their signal live; only pending launches are aborted during shutdown. */ async function closeManagedBrowsers(): Promise { for (const abort of pending.values()) abort.abort(); diff --git a/packages/cli/src/browser/sandboxDenied.test.ts b/packages/cli/src/browser/sandboxDenied.test.ts index d5a63b3f32..f609d12867 100644 --- a/packages/cli/src/browser/sandboxDenied.test.ts +++ b/packages/cli/src/browser/sandboxDenied.test.ts @@ -69,3 +69,12 @@ describe("a browser inside an agent's macOS seatbelt", () => { expect(launches[1]!.args).toEqual(["--headless", "--single-process"]); }); }); + +describe("a browser launch that never settles", () => { + it("gives up at its timeout, as when Chrome dies while puppeteer waits for its first targets", async () => { + const stuck = { launch: () => new Promise(() => {}) } as unknown as PuppeteerNode; + await expect(launchManagedBrowser(stuck, { timeout: 50 })).rejects.toThrow( + "The browser did not finish starting within 50 ms.", + ); + }); +});