diff --git a/browse/src/browser-manager.ts b/browse/src/browser-manager.ts index d672b0ff7..dd67b248c 100644 --- a/browse/src/browser-manager.ts +++ b/browse/src/browser-manager.ts @@ -26,6 +26,7 @@ import { TabSession, type DeviceProfile, type RefEntry } from './tab-session'; import { resolveChromiumProfile, cleanSingletonLocks, resolveConfig } from './config'; import { ensureProjectProfile } from './chromium-profiles'; import { launchWithXProtectHeal } from './xprotect-heal'; +import { explainSandboxFailure, sandboxBlockedByHost, shouldEnableChromiumSandbox } from './chromium-sandbox'; import { DIRECT_PROXY_ARG, forcesDirect } from './proxy-config'; import { readPidStartTime, shouldSpawnXvfb, spawnFreeXvfb, xvfbInstallHint, type XvfbHandle } from './xvfb'; import { withCdpSession } from './cdp-bridge'; @@ -75,35 +76,7 @@ export function isCustomChromium(): boolean { return p.includes('GBrowser') || p.includes('gbrowser'); } -/** - * Decide whether Playwright should request Chromium's sandbox. - * - * Returns false on Windows (Bun→Node→Chromium chain breaks the sandbox, - * GitHub #276) and on Linux under root / CI / container (sandbox needs - * unprivileged user namespaces, which are missing for root and typically - * disabled in containers). - * - * When false, Playwright auto-adds --no-sandbox to the launch args — the - * desired behavior in those environments. When true, Playwright does NOT - * add --no-sandbox, which keeps Chromium's "unsupported command-line flag" - * yellow infobar from appearing on every headed launch. - * - * The headless launch path also pushes an explicit '--no-sandbox' into args - * when CI/CONTAINER/root is set; that push is now defensively redundant - * (Playwright will add it anyway when this returns false) and harmless. - */ -export function shouldEnableChromiumSandbox(): boolean { - if (process.platform === 'win32') return false; - // Explicit user override for Ubuntu/AppArmor and similar environments where - // unprivileged Chromium sandboxing is blocked even for normal users (the - // sandbox needs unprivileged user namespaces that the host policy denies, - // so /qa hangs without --no-sandbox). Setting GSTACK_CHROMIUM_NO_SANDBOX=1 - // forces the sandbox off without changing the default for everyone else. - // See #1562. - if (process.env.GSTACK_CHROMIUM_NO_SANDBOX === '1') return false; - const isRoot = typeof process.getuid === 'function' && process.getuid() === 0; - return !(process.env.CI || process.env.CONTAINER || isRoot); -} +export { shouldEnableChromiumSandbox } from './chromium-sandbox'; /** * Thrown by probePoisonedChromiumBundle() when it finds — and removes — a @@ -680,9 +653,8 @@ export class BrowserManager { // Docker/CI/root: Chromium sandbox requires unprivileged user namespaces which // are typically disabled in containers and are never available for the root - // user on Linux. Detect all three cases and add --no-sandbox automatically. - const isRoot = typeof process.getuid === 'function' && process.getuid() === 0; - if (process.env.CI || process.env.CONTAINER || isRoot) { + // user on Linux. Detect those cases and add --no-sandbox automatically. + if (sandboxBlockedByHost()) { launchArgs.push('--no-sandbox'); } @@ -726,7 +698,7 @@ export class BrowserManager { ...contextOptions, ...(executablePath ? { executablePath } : {}), ...(this.proxyConfig ? { proxy: this.proxyConfig } : {}), - }), { usesCustomExecutable: Boolean(executablePath) }).catch(nameCustomChromium); + }), { usesCustomExecutable: Boolean(executablePath) }).catch(nameCustomChromium).catch(explainSandboxFailure); this.extensionContext = true; this.browser = this.context.browser(); this.onBrowserDisconnected(this.browser, () => { @@ -768,7 +740,7 @@ export class BrowserManager { ...(launchArgs.length > 0 ? { args: launchArgs } : {}), ...(executablePath ? { executablePath } : {}), ...(this.proxyConfig ? { proxy: this.proxyConfig } : {}), - }), { usesCustomExecutable: Boolean(executablePath) }).catch(nameCustomChromium); + }), { usesCustomExecutable: Boolean(executablePath) }).catch(nameCustomChromium).catch(explainSandboxFailure); // Chromium disconnect → distinguish clean user-quit from crash. Both // events look identical to Playwright (one 'disconnected' fires), but @@ -976,7 +948,7 @@ export class BrowserManager { ...(executablePath ? { executablePath } : {}), ...(this.proxyConfig ? { proxy: this.proxyConfig } : {}), ignoreDefaultArgs: STEALTH_IGNORE_DEFAULT_ARGS, - }), { usesCustomExecutable: Boolean(executablePath) }); + }), { usesCustomExecutable: Boolean(executablePath) }).catch(explainSandboxFailure); this.browser = this.context.browser(); this.connectionMode = 'headed'; this.intentionalDisconnect = false; @@ -2280,7 +2252,7 @@ export class BrowserManager { ...(this.proxyConfig ? { proxy: this.proxyConfig } : {}), ignoreDefaultArgs: STEALTH_IGNORE_DEFAULT_ARGS, timeout: 15000, - }), { usesCustomExecutable: Boolean(executablePath) }); + }), { usesCustomExecutable: Boolean(executablePath) }).catch(explainSandboxFailure); if (this.closing) { await newContext.close().catch(() => {}); throw new Error('Browser is shutting down'); diff --git a/browse/src/chromium-sandbox.ts b/browse/src/chromium-sandbox.ts new file mode 100644 index 000000000..a00be9fef --- /dev/null +++ b/browse/src/chromium-sandbox.ts @@ -0,0 +1,52 @@ +/** + * Chromium sandbox policy and the one error for a sandbox that cannot start. + * + * The sandbox needs unprivileged user namespaces. gstack turns it off only + * where we already know they are missing (Windows, root, CI, a container) or + * where the user asked (GSTACK_CHROMIUM_NO_SANDBOX=1). Everywhere else it + * stays on, and when Chromium then aborts with "No usable sandbox" the launch + * fails with CHROMIUM_SANDBOX_HELP instead of retrying without the sandbox: + * turning it off is the user's call, because it changes what a hostile page + * can reach. + */ +import * as fs from 'fs'; + +export const CHROMIUM_SANDBOX_HELP = 'Chromium could not start its sandbox: this Linux host does not let unprivileged users create user namespaces (Ubuntu 23.10+ AppArmor policy, a container\'s seccomp profile, or user.max_user_namespaces=0). Fix it one of two ways: allow them (on Ubuntu, `sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0` or an AppArmor profile for Chromium, per the link in Chromium\'s message; elsewhere, raise user.max_user_namespaces), or set GSTACK_CHROMIUM_NO_SANDBOX=1 to run Chromium without its sandbox. Use that only for pages you trust: without the sandbox, a page that exploits a Chromium bug can reach your files and accounts.'; + +/** + * Hosts known to lack unprivileged user namespaces: root, CI, or a container + * (CONTAINER set, or Docker's /.dockerenv). + */ +export function sandboxBlockedByHost(exists: (p: string) => boolean = fs.existsSync): boolean { + const isRoot = typeof process.getuid === 'function' && process.getuid() === 0; + return Boolean(process.env.CI || process.env.CONTAINER || isRoot || exists('/.dockerenv')); +} + +/** + * Decide whether Playwright should request Chromium's sandbox. + * + * Returns false on Windows (Bun→Node→Chromium chain breaks the sandbox, + * GitHub #276), when GSTACK_CHROMIUM_NO_SANDBOX=1 (#1562), and on hosts + * sandboxBlockedByHost() names. + * + * When false, Playwright auto-adds --no-sandbox to the launch args. When true, + * Playwright does NOT add --no-sandbox, which keeps Chromium's "unsupported + * command-line flag" yellow infobar from appearing on every headed launch. + */ +export function shouldEnableChromiumSandbox(exists: (p: string) => boolean = fs.existsSync): boolean { + if (process.platform === 'win32') return false; + if (process.env.GSTACK_CHROMIUM_NO_SANDBOX === '1') return false; + return !sandboxBlockedByHost(exists); +} + +/** Chromium's "No usable sandbox" abort found in launch output, as CHROMIUM_SANDBOX_HELP with Chromium's own line below it; null for any other failure. */ +export function sandboxFailure(output: string): string | null { + const raw = output.split('\n').find((line) => line.includes('No usable sandbox')); + return raw ? `${CHROMIUM_SANDBOX_HELP}\n${raw.trim().replace(/^- /, '')}` : null; +} + +/** Launch-site catch: a sandbox abort becomes the actionable error; anything else is rethrown as it was. */ +export function explainSandboxFailure(err: unknown): never { + const explained = sandboxFailure(err instanceof Error ? err.message : String(err)); + throw explained ? new Error(explained, { cause: err }) : err; +} diff --git a/browse/test/browser-manager-unit.test.ts b/browse/test/browser-manager-unit.test.ts index eb944fcea..9b3308bec 100644 --- a/browse/test/browser-manager-unit.test.ts +++ b/browse/test/browser-manager-unit.test.ts @@ -54,14 +54,14 @@ describe('shouldEnableChromiumSandbox', () => { setPlatform('darwin'); process.getuid = (() => 501) as typeof process.getuid; const { shouldEnableChromiumSandbox } = await import('../src/browser-manager'); - expect(shouldEnableChromiumSandbox()).toBe(true); + expect(shouldEnableChromiumSandbox(() => false)).toBe(true); }); it('linux, no CI/CONTAINER/root → true', async () => { setPlatform('linux'); process.getuid = (() => 1000) as typeof process.getuid; const { shouldEnableChromiumSandbox } = await import('../src/browser-manager'); - expect(shouldEnableChromiumSandbox()).toBe(true); + expect(shouldEnableChromiumSandbox(() => false)).toBe(true); }); it('win32 → false (sandbox fails in Bun→Node→Chromium chain)', async () => { @@ -116,7 +116,7 @@ describe('shouldEnableChromiumSandbox', () => { process.env.GSTACK_CHROMIUM_NO_SANDBOX = '0'; process.getuid = (() => 1000) as typeof process.getuid; const { shouldEnableChromiumSandbox } = await import('../src/browser-manager'); - expect(shouldEnableChromiumSandbox()).toBe(true); + expect(shouldEnableChromiumSandbox(() => false)).toBe(true); }); }); diff --git a/browse/test/chromium-sandbox.test.ts b/browse/test/chromium-sandbox.test.ts new file mode 100644 index 000000000..8ea1a5deb --- /dev/null +++ b/browse/test/chromium-sandbox.test.ts @@ -0,0 +1,90 @@ +/** + * GST-1906: on a Linux host that denies unprivileged user namespaces (stock + * Ubuntu 23.10+ under AppArmor, a non-root Docker container) Chromium aborts + * with "No usable sandbox!". gstack treats Docker's /.dockerenv like + * CONTAINER, keeps the sandbox on everywhere else, and turns that abort into + * one actionable error. It never retries without the sandbox on its own. + */ +import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { chromium } from 'playwright'; +import { BrowserManager, shouldEnableChromiumSandbox } from '../src/browser-manager'; + +const RAW = '[pid=4242][err] [1108/102007.815666:FATAL:content/browser/zygote_host/zygote_host_impl_linux.cc:144] No usable sandbox! If you are running on Ubuntu 23.10+ or another Linux distro that has disabled unprivileged user namespaces with AppArmor, see https://chromium.googlesource.com/chromium/src/+/main/docs/security/apparmor-userns-restrictions.md'; +const ABORT = `browserType.launch: Target page, context or browser has been closed\nBrowser logs:\n\n /cache/chromium/chrome --headless\n pid=4242\n - ${RAW}\n`; +const ENV_KEYS = ['CI', 'CONTAINER', 'GSTACK_CHROMIUM_NO_SANDBOX', 'GSTACK_CHROMIUM_PATH', 'BROWSE_EXTENSIONS_DIR', 'CHROMIUM_PROFILE'] as const; +const saved: Record = {}; +const origPlatform = process.platform; +const origGetuid = process.getuid; + +beforeEach(() => { + for (const key of ENV_KEYS) { saved[key] = process.env[key]; delete process.env[key]; } + Object.defineProperty(process, 'platform', { value: 'linux' }); + process.getuid = (() => 1000) as typeof process.getuid; +}); + +afterEach(() => { + for (const key of ENV_KEYS) { if (saved[key] === undefined) delete process.env[key]; else process.env[key] = saved[key]; } + Object.defineProperty(process, 'platform', { value: origPlatform }); + process.getuid = origGetuid; +}); + +function expectSandboxError(text: string) { + expect(text).toContain('Chromium could not start its sandbox'); + expect(text).toContain('kernel.apparmor_restrict_unprivileged_userns'); + expect(text).toContain('GSTACK_CHROMIUM_NO_SANDBOX=1'); + expect(text).toContain('pages you trust'); + expect(text.indexOf(RAW)).toBeGreaterThan(text.indexOf('pages you trust')); +} + +describe('shouldEnableChromiumSandbox: Docker and hosts with no signal', () => { + test('non-root Linux with no CI or CONTAINER but a /.dockerenv → sandbox off', () => { + expect(shouldEnableChromiumSandbox((p) => p === '/.dockerenv')).toBe(false); + }); + + test('no signal at all → sandbox stays on', () => { + expect(shouldEnableChromiumSandbox(() => false)).toBe(true); + }); +}); + +describe('a "No usable sandbox" launch abort', () => { + test('launch() fails once with the actionable error and never retries without the sandbox', async () => { + const launch = spyOn(chromium, 'launch').mockRejectedValue(new Error(ABORT)); + try { + const err = await new BrowserManager().launch().then(() => null, (e: Error) => e); + expect(err).toBeInstanceOf(Error); + expect(err!.message).toStartWith('Chromium could not start its sandbox'); + expectSandboxError(err!.message); + expect(launch).toHaveBeenCalledTimes(1); + } finally { + launch.mockRestore(); + } + }); + + test('handoff() reports the same error and keeps the headless browser', async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'chromium-sandbox-')); + process.env.CHROMIUM_PROFILE = path.join(tmp, 'profile'); + const launch = spyOn(chromium, 'launchPersistentContext').mockRejectedValue(new Error(ABORT)); + const bundled = spyOn(chromium, 'executablePath').mockReturnValue(path.join(tmp, 'no-bundle', 'chrome')); + try { + const manager = new BrowserManager(); + Object.assign(manager as unknown as Record, { + browser: {}, + context: {}, + saveState: async () => ({ cookies: [], pages: [] }), + ensureHeadedDisplay: async () => {}, + findExtensionPath: () => null, + }); + const out = await manager.handoff('sandbox'); + expect(out).toStartWith('ERROR: Cannot open headed browser'); + expectSandboxError(out); + expect(launch).toHaveBeenCalledTimes(1); + } finally { + launch.mockRestore(); + bundled.mockRestore(); + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); +}); diff --git a/changelog.d/capy-browser-sandbox-error.md b/changelog.d/capy-browser-sandbox-error.md new file mode 100644 index 000000000..821942aa9 --- /dev/null +++ b/changelog.d/capy-browser-sandbox-error.md @@ -0,0 +1,4 @@ +--- +important: blocker +--- +- **gstack's browser now tells you how to fix a Linux host that blocks Chromium's sandbox, and /design-review --build checks the browser really starts.** On stock Ubuntu 23.10+ (AppArmor) and in non-root Docker, Chromium can't create the user namespaces its sandbox needs and aborts with "No usable sandbox!". browse used to exit with a wall of Playwright log after `Server failed to start:`. Now it prints one error that names both ways out. You can allow user namespaces (the Ubuntu AppArmor setting), or set `GSTACK_CHROMIUM_NO_SANDBOX=1`, which is only for pages you trust. Chromium's own line stays below it. gstack still never turns the sandbox off by itself, except that Docker's `/.dockerenv` now counts as a container, the same as `CONTAINER`. `gstack-design-build preflight` used to say `PREFLIGHT: ok` without starting the browser, so the run died later; it now opens and closes one tab first and stops with `code=BROWSER_SANDBOX` and the same error when the launch fails. (GST-1906) diff --git a/docs/howto-preview-a-frontend.md b/docs/howto-preview-a-frontend.md index d9f5546e1..2325ffe95 100644 --- a/docs/howto-preview-a-frontend.md +++ b/docs/howto-preview-a-frontend.md @@ -130,7 +130,7 @@ code: | Command | Does | |---------|------| -| `preflight --inventory [--grant-remote ]` | browser first, then target authorization (loopback, or a host named in a grant), then that each URL answers; prints `PREFLIGHT: ok …` or the run's final `DESIGN_BUILD:` line (`NO_BROWSER`, `UNAUTHORIZED_TARGET`, `NO_PREVIEW`) | +| `preflight --inventory [--grant-remote ]` | browser first (gstack's own browser is started for real: one tab opened and closed), then target authorization (loopback, or a host named in a grant), then that each URL answers; prints `PREFLIGHT: ok …` or the run's final `DESIGN_BUILD:` line (`NO_BROWSER`, [`BROWSER_SANDBOX`](troubleshooting.md#chromium-sandbox-unavailable), `UNAUTHORIZED_TARGET`, `NO_PREVIEW`) | | `admit ` | maps a refused design-review start to `DESIGN_BUILD: unrendered code=INSTALL` | | `plan --inventory --run-dir [--waivers ]` | writes the lint's state file and prints the exact `LINT_CMD:` (carrying `--waivers` when given; a `waived` receipt needs it) and one `SHOT:` path per state and viewport | | `verify-start --inventory --run-dir ` | records the tree identity right before the read-only final pass | diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 276a13813..50d0ca8cb 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1689,6 +1689,26 @@ user-namespace error needs `GSTACK_CHROMIUM_NO_SANDBOX=1`; then re-run `/bin/gstack-browser-ensure`. `--with-browser` on the installer front-loads the install on machines that want it now. + +### `Chromium could not start its sandbox: this Linux host does not let unprivileged users create user namespaces …` + +**Meaning.** Chromium aborted with `No usable sandbox!` (its own line is +printed below gstack's). The host denies unprivileged user namespaces: stock +Ubuntu 23.10+ under AppArmor, a container's seccomp profile, or +`user.max_user_namespaces=0`. browse prints it after `Server failed to +start:`, and `gstack-design-build preflight` prints it as +`DESIGN_BUILD: unrendered code=BROWSER_SANDBOX`. gstack never turns the +sandbox off on its own: it already does so for root, CI, `CONTAINER` and +Docker's `/.dockerenv`, and everywhere else that is your call. + +**Fix.** Either allow unprivileged user namespaces (on Ubuntu, +`sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0` or an +AppArmor profile for Chromium, per the link in Chromium's line; elsewhere, +raise `user.max_user_namespaces`), or set `GSTACK_CHROMIUM_NO_SANDBOX=1` in +the shell that runs gstack. Use the variable only for pages you trust: without +the sandbox, a page that exploits a Chromium bug can reach your files and +accounts. Then rerun. + ### `gstack-capy-install: preflight: not found (INSTALL_PREFLIGHT_FAILED; ...)` diff --git a/lib/aside-render.ts b/lib/aside-render.ts index 4cc47dab0..d05f7947f 100644 --- a/lib/aside-render.ts +++ b/lib/aside-render.ts @@ -526,6 +526,83 @@ function browserEnsureBin(bin: string): string | null { return ensure && fs.existsSync(ensure) ? ensure : null; } +type BrowseRunner = { deadline: number; browseEnv: Record; run: (args: string[]) => Promise }; + +/** browse CLI calls sharing one deadline. A failed call throws its most useful line, plus Chromium's own "No usable sandbox" line when the launch hit one. */ +function browseRunner(bin: string, log: string[], timeoutMs: number): BrowseRunner { + const browseEnv = { ...process.env, BROWSE_SESSION: '' }; + const b: BrowseRunner = { + deadline: Date.now() + timeoutMs, + browseEnv, + run: async (args) => { + const r = await runProc(bin, args, Math.max(1_000, Math.min(120_000, b.deadline - Date.now())), b.browseEnv); + log.push(`$ browse ${args.join(' ').slice(0, 300)}\n${r.stdout}${r.stderr}`.trim()); + if (r.error || r.code !== 0) { + const lines = (r.stderr || r.stdout || r.error || '').split('\n').map((l) => l.trim()).filter(Boolean); + const first = lines.find((l) => /Executable doesn't exist/.test(l)) ?? lines.find((l) => !/^\[browse\] (Starting server|Server failed to start)/.test(l) && !/^[\u2554\u2551\u255a]/.test(l)) ?? lines[0] ?? ''; + if (/JS execution blocked/.test(`${r.stderr}${r.stdout}`)) { + // After `$B cookie-import` the daemon refuses page JS on every other + // origin, 127.0.0.1 included; a local-HTML render cannot proceed in it. + throw new Error(`browse ${args[0]} refused: the daemon has imported cookies and blocks page JS on other origins (127.0.0.1 included) — restart it ($B stop) before rendering local HTML, or open Aside`); + } + const fatal = lines.find((l) => l.includes('No usable sandbox')); + throw new Error(`browse ${args[0]} failed: ${first}${fatal && fatal !== first ? `\n${fatal}` : ''}`); + } + return r.stdout; + }, + }; + return b; +} + +/** + * Open a tab, which starts the daemon and gstack's Chromium on first use. + * A cold daemon can answer "Unable to connect" once while it comes up, so that + * is retried once after a short pause; a missing Chromium is installed once + * with gstack-browser-ensure, and the install time does not count against the + * deadline. + */ +async function openBrowseTab(bin: string, b: BrowseRunner, log: string[]): Promise { + const openTab = async () => (await b.run(['newtab', '--json'])).match(/\{[^\n]*"tabId"[^\n]*\}/)?.[0]; + let opened: string | undefined; + try { + opened = await openTab(); + } catch (e) { + const message = (e as Error).message; + const ensure = /Executable doesn't exist/.test(message) ? browserEnsureBin(bin) : null; + if (ensure) { + log.push('newtab: Chromium is not installed — running gstack-browser-ensure once'); + const installStart = Date.now(); + const installed = await runProc('bash', [ensure, '--quiet'], 600_000, b.browseEnv); + b.deadline += Date.now() - installStart; + const last = installed.stdout.trim().split('\n').at(-1) ?? ''; + log.push(`${ensure} --quiet\n${installed.stdout}${installed.stderr}`.trim()); + if (installed.error || installed.code !== 0) throw new Error(`${message} (gstack-browser-ensure: ${last || installed.error || `exit ${installed.code}`})`); + opened = await openTab(); + } else { + if (!/Unable to connect/.test(message)) throw e; + log.push('newtab: daemon not up yet — retrying once'); + await Bun.sleep(1_500); + opened = await openTab(); + } + } + const tab = opened ? JSON.parse(opened).tabId : undefined; + if (typeof tab !== 'number') throw new Error('browse newtab --json returned no tabId'); + return tab; +} + +/** The cheapest real launch of gstack's browser, for design-build preflight: open one tab, then close it. Null when it opened, else why it did not. */ +export async function launchBrowse(bin: string): Promise { + const log: string[] = []; + const b = browseRunner(bin, log, DEFAULT_TIMEOUT_MS); + try { + const tab = await openBrowseTab(bin, b, log); + await runProc(bin, ['closetab', String(tab)], 15_000, b.browseEnv); + return null; + } catch (e) { + return (e as Error).message; + } +} + export async function renderWithBrowse(spec: RenderSpec, bin: string | null = resolveBrowseBin()): Promise { const outputs: string[] = []; const evals: Record = {}; @@ -535,23 +612,8 @@ export async function renderWithBrowse(spec: RenderSpec, bin: string | null = re const target = pageTarget(spec); if (target.kind === 'error') return fail(target.error); - let deadline = Date.now() + (spec.timeoutMs ?? DEFAULT_TIMEOUT_MS); - const browseEnv = { ...process.env, BROWSE_SESSION: '' }; - const run = async (args: string[]): Promise => { - const r = await runProc(bin, args, Math.max(1_000, Math.min(120_000, deadline - Date.now())), browseEnv); - log.push(`$ browse ${args.join(' ').slice(0, 300)}\n${r.stdout}${r.stderr}`.trim()); - if (r.error || r.code !== 0) { - const lines = (r.stderr || r.stdout || r.error || '').split('\n').map((l) => l.trim()).filter(Boolean); - const first = lines.find((l) => /Executable doesn't exist/.test(l)) ?? lines.find((l) => !/^\[browse\] (Starting server|Server failed to start)/.test(l) && !/^[\u2554\u2551\u255a]/.test(l)) ?? lines[0] ?? ''; - if (/JS execution blocked/.test(`${r.stderr}${r.stdout}`)) { - // After `$B cookie-import` the daemon refuses page JS on every other - // origin, 127.0.0.1 included; a local-HTML render cannot proceed in it. - throw new Error(`browse ${args[0]} refused: the daemon has imported cookies and blocks page JS on other origins (127.0.0.1 included) — restart it ($B stop) before rendering local HTML, or open Aside`); - } - throw new Error(`browse ${args[0]} failed: ${first}`); - } - return r.stdout; - }; + const b = browseRunner(bin, log, spec.timeoutMs ?? DEFAULT_TIMEOUT_MS); + const run = b.run; const copyOut = (src: string, out: string, i: number) => { if (!fs.existsSync(src)) throw new Error(`step ${i} produced no artifact (${src})`); fs.mkdirSync(path.dirname(path.resolve(out)), { recursive: true }); @@ -565,34 +627,7 @@ export async function renderWithBrowse(spec: RenderSpec, bin: string | null = re try { work = fs.mkdtempSync(path.join(SAFE_TMP_DIR, 'gstack-render-browse-')); srv = openTarget(target); - // The first CLI call auto-starts the daemon; on a cold start it can answer - // "Unable to connect" once while the server is still coming up. One retry - // after a short pause turns that into the wait it really is. - const openTab = async () => (await run(['newtab', '--json'])).match(/\{[^\n]*"tabId"[^\n]*\}/)?.[0]; - let opened: string | undefined; - try { - opened = await openTab(); - } catch (e) { - const message = (e as Error).message; - const ensure = /Executable doesn't exist/.test(message) ? browserEnsureBin(bin) : null; - if (ensure) { - log.push('newtab: Chromium is not installed — running gstack-browser-ensure once'); - const installStart = Date.now(); - const installed = await runProc('bash', [ensure, '--quiet'], 600_000, browseEnv); - deadline += Date.now() - installStart; - const last = installed.stdout.trim().split('\n').at(-1) ?? ''; - log.push(`${ensure} --quiet\n${installed.stdout}${installed.stderr}`.trim()); - if (installed.error || installed.code !== 0) throw new Error(`${message} (gstack-browser-ensure: ${last || installed.error || `exit ${installed.code}`})`); - opened = await openTab(); - } else { - if (!/Unable to connect/.test(message)) throw e; - log.push('newtab: daemon not up yet — retrying once'); - await Bun.sleep(1_500); - opened = await openTab(); - } - } - tab = opened ? JSON.parse(opened).tabId : undefined; - if (typeof tab !== 'number') throw new Error('browse newtab --json returned no tabId'); + tab = await openBrowseTab(bin, b, log); const T = ['--tab-id', String(tab)]; const js = async (expr: string, extra: string[] = []) => (await run(['js', expr, ...extra, ...T])).replace(/\n$/, ''); // Poll until truthy. A throw inside the page (e.g. `window.later.ok` before @@ -669,7 +704,7 @@ export async function renderWithBrowse(spec: RenderSpec, bin: string | null = re } catch (e) { return fail((e as Error).message); } finally { - if (tab !== undefined) await runProc(bin, ['closetab', String(tab)], 15_000, browseEnv); + if (tab !== undefined) await runProc(bin, ['closetab', String(tab)], 15_000, b.browseEnv); srv?.stop(); if (work) fs.rmSync(work, { recursive: true, force: true }); } diff --git a/lib/design-build.ts b/lib/design-build.ts index f1e107a00..7a95c1224 100644 --- a/lib/design-build.ts +++ b/lib/design-build.ts @@ -21,7 +21,8 @@ import { execFileSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import { pickEngine, type EngineChoice } from './aside-render'; +import { launchBrowse, pickEngine, type EngineChoice } from './aside-render'; +import { sandboxFailure } from '../browse/src/chromium-sandbox'; import { DEFAULT_VIEWPORTS, parseViewport, redactText, redactUrl, viewportLabel } from './design-lint/app-input'; export const MAX_ROUNDS = 3; @@ -44,6 +45,7 @@ export type Outcome = 'clean' | 'waived' | 'open' | 'incomplete' | 'unrendered' export const BUILD_FIXES: Record = { INSTALL: "run install.sh --for design-review from the lane's repo, then launch with its GSTACK_LAUNCH line", NO_BROWSER: 'gstack-browser-ensure, then rerun /design-review --build', + BROWSER_SANDBOX: 'allow unprivileged user namespaces, or set GSTACK_CHROMIUM_NO_SANDBOX=1 only if every page in the run is trusted, then rerun /design-review --build', NO_PREVIEW: `start the preview (${DOC}), then rerun`, UNAUTHORIZED_TARGET: 'serve the app on 127.0.0.1 with the preview template, or rerun with --grant-remote written by the person who owns it', TREE_CHANGED: 'the tree moved after the final pass began; rerun the final verification pass only', @@ -180,7 +182,10 @@ export async function servedStates(inv: Inventory): Promise EngineChoice; reach?: (url: string) => Promise; preview?: (url: string) => Promise } +export interface PreflightDeps { engine?: () => EngineChoice; launch?: (engine: Exclude) => Promise; reach?: (url: string) => Promise; preview?: (url: string) => Promise } + +/** Start the engine preflight found. Choosing Aside already ran a script in it (probeAside), so only gstack's own browser needs a launch here. */ +const launchEngine = async (e: Exclude): Promise => (e.engine === 'browse' ? launchBrowse(e.bin) : null); /** The preview template answers its probe route with 204 and x-gstack-preview: ready; a redirect, a marker in the HTML or any other answer is not the preview. */ async function previewAnswers(url: string): Promise { @@ -202,13 +207,18 @@ async function reachable(url: string): Promise { } /** - * Before any round: the browser engine first, then target authorization (a - * non-loopback target needs a grant naming its host, else the run stops - * incomplete before any page opens), then each URL state answers. + * Before any round: the browser engine first, started for real, then target + * authorization (a non-loopback target needs a grant naming its host, else the + * run stops incomplete before any page opens), then each URL state answers. */ export async function preflight(inv: Inventory, grants: string[], deps: PreflightDeps = {}): Promise<{ lines: string[]; exit: number }> { const engine = (deps.engine ?? (() => pickEngine()))(); if (!engine.engine) return { lines: [buildLine({ outcome: 'unrendered', code: 'NO_BROWSER', detail: redactText(engine.error) })], exit: 4 }; + const failed = await (deps.launch ?? launchEngine)(engine); + if (failed) { + const sandbox = sandboxFailure(failed); + return { lines: [buildLine({ outcome: 'unrendered', code: sandbox ? 'BROWSER_SANDBOX' : 'NO_BROWSER', detail: redactText(sandbox ?? failed) })], exit: 4 }; + } const withUrl = inv.states.filter((s) => s.url); const remote = withUrl.filter((s) => { const h = new URL(s.url!).hostname; return !isLoopbackHost(h) && !grants.includes(h); }); if (remote.length) return { lines: [buildLine({ outcome: 'incomplete', code: 'UNAUTHORIZED_TARGET', detail: `${remote.map((s) => `${s.name} at ${redactUrl(s.url!)}`).join(', ')} is not on loopback and has no grant; no page was opened and no action ran` })], exit: 4 }; diff --git a/test/design-build.test.ts b/test/design-build.test.ts index 9035c7124..fbcd0a044 100644 --- a/test/design-build.test.ts +++ b/test/design-build.test.ts @@ -193,8 +193,44 @@ describe('the receipt is bound to the measured tree (T7, T29, T37)', () => { }); }); +describe('preflight starts the browser it found (GST-1906)', () => { + const RAW = ' - [pid=4242][err] [1108/102007.815666:FATAL:content/browser/zygote_host/zygote_host_impl_linux.cc:144] No usable sandbox! If you are running on Ubuntu 23.10+ or another Linux distro that has disabled unprivileged user namespaces with AppArmor, see https://chromium.googlesource.com/chromium/src/+/main/docs/security/apparmor-userns-restrictions.md'; + const fakeBrowse = (body: string): string => { + const dir = tmpDir('design-build-browse-'); + const bin = path.join(dir, 'browse'); + fs.writeFileSync(bin, `#!/bin/sh\necho "$*" >> "${dir}/calls"\n${body}\n`, { mode: 0o755 }); + return bin; + }; + + test('a Chromium that cannot start its sandbox fails preflight with the actionable sandbox error before any URL is touched', async () => { + const bin = fakeBrowse(`printf '%s\\n' '[browse] Starting server...' '[browse] Server failed to start:' 'browserType.launch: Target page, context or browser has been closed' '${RAW}' >&2\nexit 1`); + const reached: string[] = []; + const r = await preflight(inventory(), [], { engine: () => ({ engine: 'browse', bin }), reach: async (u) => { reached.push(u); return null; } }); + expect(r.exit).toBe(4); + expect(r.lines).toHaveLength(1); + expect(r.lines[0]).toStartWith('DESIGN_BUILD: unrendered code=BROWSER_SANDBOX (Chromium could not start its sandbox'); + expect(r.lines[0]).toContain('GSTACK_CHROMIUM_NO_SANDBOX=1'); + expect(r.lines[0]).toContain('pages you trust'); + expect(r.lines[0]).toContain('No usable sandbox!'); + expect(reached).toEqual([]); + }); + + test('a browser that opens a tab passes preflight, and preflight closes the tab it opened', async () => { + const bin = fakeBrowse(`case "$1" in newtab) echo '{"tabId":7}' ;; closetab) ;; *) exit 9 ;; esac`); + const r = await preflight(inventory(), [], { engine: () => ({ engine: 'browse', bin }), reach: async () => null, preview: async () => false }); + expect(r).toEqual({ lines: ['PREFLIGHT: ok engine=browse states=2 targets=loopback mutations=unknown'], exit: 0 }); + expect(fs.readFileSync(path.join(path.dirname(bin), 'calls'), 'utf8')).toBe('newtab --json\nclosetab 7\n'); + }); + + test('any other launch failure is unrendered NO_BROWSER carrying the browser\'s own line', async () => { + const r = await preflight(inventory(), [], { engine: () => ({ engine: 'browse', bin: '/x/browse' }), launch: async () => 'browse newtab failed: Chromium exited with code 127', reach: async () => null }); + expect(r).toEqual({ lines: [buildLine({ outcome: 'unrendered', code: 'NO_BROWSER', detail: 'browse newtab failed: Chromium exited with code 127' })], exit: 4 }); + }); +}); + describe('preflight, authorization and install (T14, T38, T31)', () => { const engine = () => ({ engine: 'browse' as const, bin: '/x/browse' }); + const launch = async () => null; test('the browser comes first: no engine is unrendered NO_BROWSER before any URL is touched', async () => { const reached: string[] = []; const r = await preflight(inventory(), [], { engine: () => ({ engine: null, probe: { ok: false, reason: 'NEEDS_ASIDE', detail: 't' }, error: 'no browser available: run ./setup' }) as never, reach: async (u) => { reached.push(u); return null; } }); @@ -206,18 +242,18 @@ describe('preflight, authorization and install (T14, T38, T31)', () => { const inv = inventory({ states: [{ name: 'prod', url: 'https://app.example.com/settings?as=owner', mode: 'url' }, { name: 'owner', url: URL_OWNER, mode: 'url' }] as Inventory['states'] }); const reached: string[] = []; const reach = async (u: string) => { reached.push(u); return null; }; - const refused = await preflight(inv, [], { engine, reach }); + const refused = await preflight(inv, [], { engine, launch, reach }); expect(refused.exit).toBe(4); expect(refused.lines[0]).toStartWith('DESIGN_BUILD: incomplete code=UNAUTHORIZED_TARGET (prod at https://app.example.com/settings?as=owner is not on loopback and has no grant; no page was opened and no action ran)'); expect(reached).toEqual([]); - expect((await preflight(inv, ['other.example.com'], { engine, reach })).exit).toBe(4); + expect((await preflight(inv, ['other.example.com'], { engine, launch, reach })).exit).toBe(4); const probed: string[] = []; - const granted = await preflight(inv, ['app.example.com'], { engine, reach, preview: async (u) => { probed.push(u); return true; } }); + const granted = await preflight(inv, ['app.example.com'], { engine, launch, reach, preview: async (u) => { probed.push(u); return true; } }); expect(granted.lines).toEqual(['PREFLIGHT: ok engine=browse states=2 targets=loopback+granted mutations=fixtures+granted']); expect(probed).toEqual([URL_OWNER]); - expect((await preflight(inv, ['app.example.com'], { engine, reach, preview: async () => false })).lines).toEqual(['PREFLIGHT: ok engine=browse states=2 targets=loopback+granted mutations=unknown']); - expect((await preflight(inventory(), [], { engine, reach, preview: async () => true })).lines).toEqual(['PREFLIGHT: ok engine=browse states=2 targets=loopback mutations=fixtures-only']); - expect((await preflight(inventory(), [], { engine, reach, preview: async (u) => !u.includes('lastowner') })).lines).toEqual(['PREFLIGHT: ok engine=browse states=2 targets=loopback mutations=unknown']); + expect((await preflight(inv, ['app.example.com'], { engine, launch, reach, preview: async () => false })).lines).toEqual(['PREFLIGHT: ok engine=browse states=2 targets=loopback+granted mutations=unknown']); + expect((await preflight(inventory(), [], { engine, launch, reach, preview: async () => true })).lines).toEqual(['PREFLIGHT: ok engine=browse states=2 targets=loopback mutations=fixtures-only']); + expect((await preflight(inventory(), [], { engine, launch, reach, preview: async (u) => !u.includes('lastowner') })).lines).toEqual(['PREFLIGHT: ok engine=browse states=2 targets=loopback mutations=unknown']); }); test('only the preview\'s own probe answer proves fixtures-only: a marker in the HTML or a redirect does not', async () => { @@ -227,13 +263,13 @@ describe('preflight, authorization and install (T14, T38, T31)', () => { const bounce = Bun.serve({ hostname: '127.0.0.1', port: 0, fetch: (req) => Response.redirect(`http://127.0.0.1:${preview.port}${new URL(req.url).pathname}`, 302) }); try { const at = (port?: number) => inventory({ states: [{ name: 'owner', url: `http://127.0.0.1:${port}/settings?as=owner`, mode: 'url' }] as Inventory['states'] }); - for (const port of [marked.port, bounce.port]) expect((await preflight(at(port), [], { engine, reach: async () => null })).lines).toEqual(['PREFLIGHT: ok engine=browse states=1 targets=loopback mutations=unknown']); - expect((await preflight(at(preview.port), [], { engine, reach: async () => null })).lines).toEqual(['PREFLIGHT: ok engine=browse states=1 targets=loopback mutations=fixtures-only']); + for (const port of [marked.port, bounce.port]) expect((await preflight(at(port), [], { engine, launch, reach: async () => null })).lines).toEqual(['PREFLIGHT: ok engine=browse states=1 targets=loopback mutations=unknown']); + expect((await preflight(at(preview.port), [], { engine, launch, reach: async () => null })).lines).toEqual(['PREFLIGHT: ok engine=browse states=1 targets=loopback mutations=fixtures-only']); } finally { for (const s of [marked, preview, bounce]) s.stop(true); } }); test('a URL that does not answer is unrendered NO_PREVIEW, redacted', async () => { - const r = await preflight(inventory(), [], { engine, reach: async (u) => (u.includes('lastowner') ? 'connect ECONNREFUSED ' + u : null) }); + const r = await preflight(inventory(), [], { engine, launch, reach: async (u) => (u.includes('lastowner') ? 'connect ECONNREFUSED ' + u : null) }); expect(r.lines[0]).toStartWith('DESIGN_BUILD: unrendered code=NO_PREVIEW (no answer from lastowner'); expect(r.lines[0]).not.toContain('RECEIPTSENTINEL'); });