mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-09 18:28:15 +01:00
Give a clear error when Chromium's sandbox can't start, and make design-build preflight launch the browser (GST-1906) (#4162)
* Give a clear error when Chromium's sandbox can't start, and make design-build preflight launch the browser (GST-1906) On Linux hosts that deny unprivileged user namespaces (stock Ubuntu 23.10+ under AppArmor, non-root Docker), Chromium aborts with "No usable sandbox!". browse now fails with one error naming both fixes (allow user namespaces, or GSTACK_CHROMIUM_NO_SANDBOX=1 for trusted pages only) with Chromium's line below it, and never turns the sandbox off on its own. Docker's /.dockerenv now counts as a container, like CONTAINER. gstack-design-build preflight opens and closes one tab in the browser it found and stops with code=BROWSER_SANDBOX and the same error when that launch fails. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Reword the Chromium sandbox changelog entry (GST-1906) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
9740bfd940
commit
6042f6ad48
10 files changed
+319
-100
No files matched your search
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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<launching> /cache/chromium/chrome --headless\n<launched> 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<string, string | undefined> = {};
|
||||
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<string, unknown>, {
|
||||
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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
@@ -130,7 +130,7 @@ code:
|
||||
|
||||
| Command | Does |
|
||||
|---------|------|
|
||||
| `preflight --inventory <f> [--grant-remote <host>]` | 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 <f> [--grant-remote <host>]` | 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 <saved gstack-skill-start output>` | maps a refused design-review start to `DESIGN_BUILD: unrendered code=INSTALL` |
|
||||
| `plan --inventory <f> --run-dir <d> [--waivers <f>]` | 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 <f> --run-dir <d>` | records the tree identity right before the read-only final pass |
|
||||
|
||||
@@ -1689,6 +1689,26 @@ user-namespace error needs `GSTACK_CHROMIUM_NO_SANDBOX=1`; then re-run
|
||||
`<checkout>/bin/gstack-browser-ensure`. `--with-browser` on the installer
|
||||
front-loads the install on machines that want it now.
|
||||
|
||||
<a id="chromium-sandbox-unavailable"></a>
|
||||
### `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.
|
||||
|
||||
<a id="capy-install-preflight-failed"></a>
|
||||
### `gstack-capy-install: preflight: <tool> not found (INSTALL_PREFLIGHT_FAILED; ...)`
|
||||
|
||||
|
||||
+81
-46
@@ -526,6 +526,83 @@ function browserEnsureBin(bin: string): string | null {
|
||||
return ensure && fs.existsSync(ensure) ? ensure : null;
|
||||
}
|
||||
|
||||
type BrowseRunner = { deadline: number; browseEnv: Record<string, string | undefined>; run: (args: string[]) => Promise<string> };
|
||||
|
||||
/** 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<number> {
|
||||
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<string | null> {
|
||||
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<RenderResult> {
|
||||
const outputs: string[] = [];
|
||||
const evals: Record<number, string> = {};
|
||||
@@ -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<string> => {
|
||||
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 });
|
||||
}
|
||||
|
||||
+15
-5
@@ -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<string, string> = {
|
||||
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 <host> 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<Array<{ origin: stri
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface PreflightDeps { engine?: () => EngineChoice; reach?: (url: string) => Promise<string | null>; preview?: (url: string) => Promise<boolean> }
|
||||
export interface PreflightDeps { engine?: () => EngineChoice; launch?: (engine: Exclude<EngineChoice, { engine: null }>) => Promise<string | null>; reach?: (url: string) => Promise<string | null>; preview?: (url: string) => Promise<boolean> }
|
||||
|
||||
/** 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<EngineChoice, { engine: null }>): Promise<string | null> => (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<boolean> {
|
||||
@@ -202,13 +207,18 @@ async function reachable(url: string): Promise<string | null> {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 };
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user