From 56591f4118d0ae700c122391eac44be39bbb0c9b Mon Sep 17 00:00:00 2001 From: CRT Date: Tue, 13 Oct 2026 02:51:48 +0800 Subject: [PATCH] ci on Linux: --no-sandbox for the Chromium storebound starts itself as root on Linux or in a container (sandboxArgs; Chromium refuses root without it), never on macOS / Windows / a normal user; workflow caches Playwright's browsers; capture-web prints the capture's stderr when a run fails --- .forgejo/workflows/ci.yml | 11 ++++++++++- cli/console/session.ts | 10 ++++++---- cli/shots/browser.ts | 12 ++++++++++++ tests/capture-web.test.ts | 2 ++ tests/console-session.test.ts | 12 ++++++++++++ 5 files changed, 42 insertions(+), 5 deletions(-) diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index f92a44c..b686993 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -61,7 +61,16 @@ jobs: rustup component add clippy bun --version && cargo --version bun install --frozen-lockfile - - name: Headless Chromium (desktop boot check) + # Playwright's Chromium and headless shell (~280 MB), keyed by the locked playwright version; the system + # libraries and fonts (--with-deps) are installed on every run, the job container starts clean + - uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('bun.lock') }} + restore-keys: playwright-${{ runner.os }}- + # the job runs as root in a container: storebound adds --no-sandbox to the Chromium it starts itself there + # (cli/shots/browser.ts sandboxArgs), Playwright's own launches already pass it + - name: Headless Chromium (browser tests, desktop boot check) run: bunx playwright install --with-deps chromium - name: bun run ci run: bun run ci diff --git a/cli/console/session.ts b/cli/console/session.ts index 7b262aa..bd8e99a 100644 --- a/cli/console/session.ts +++ b/cli/console/session.ts @@ -42,7 +42,7 @@ import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; import { homedir, tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; -import { chromiumFor, installedBrowsers, type LaunchDeps, persistentExecutable, profileHolder } from '../shots/browser'; +import { chromiumFor, installedBrowsers, type LaunchDeps, persistentExecutable, profileHolder, sandboxArgs } from '../shots/browser'; /* eslint-disable @typescript-eslint/no-explicit-any */ @@ -250,10 +250,12 @@ export const AUTOMATION_SWITCHES = ['--enable-automation', '--remote-debugging-p /** * The command line of the Chrome storebound starts itself for the console window: its own user data dir, a DevTools * port the OS picks (written to /DevToolsActivePort, loopback only), session restore, no automation markers, no - * first-run dialogs. Nothing else: Chrome keeps its own user agent, keychain and updates. + * first-run dialogs; --no-sandbox only where Chromium cannot start without it (root on Linux, a container: + * sandboxArgs). Nothing else: Chrome keeps its own user agent, keychain and updates. */ -export function consoleChromeArgs(userDataDir: string, o: { headless?: boolean } = {}): string[] { - return [`--user-data-dir=${userDataDir}`, '--remote-debugging-port=0', '--no-first-run', '--no-default-browser-check', ...KEEP_SESSION_ARGS, ...NO_AUTOMATION_ARGS, ...(o.headless ? ['--headless=new'] : [])]; +export function consoleChromeArgs(userDataDir: string, o: { headless?: boolean; sandbox?: string[] } = {}): string[] { + return [`--user-data-dir=${userDataDir}`, '--remote-debugging-port=0', '--no-first-run', '--no-default-browser-check', ...KEEP_SESSION_ARGS, ...NO_AUTOMATION_ARGS, + ...(o.sandbox ?? sandboxArgs()), ...(o.headless ? ['--headless=new'] : [])]; } /** One new tab for storebound; the tabs the session restore brought back are closed (they would compete for "the active tab"). */ diff --git a/cli/shots/browser.ts b/cli/shots/browser.ts index 8d46c76..6ce5358 100644 --- a/cli/shots/browser.ts +++ b/cli/shots/browser.ts @@ -60,6 +60,18 @@ export interface Candidate { opts: Record; } +/** + * Switches for a Chromium storebound starts itself (Bun.spawn, not Playwright's launch, which already passes + * --no-sandbox unless chromiumSandbox is set): Chromium refuses to run as root on Linux without --no-sandbox + * ("Running as root without --no-sandbox is not supported", crbug.com/638180), and inside a Docker / Podman container + * its user-namespace sandbox is usually blocked by seccomp, so CI containers (the Forgejo runner runs jobs as root) + * need it too. Never on macOS or Windows, never for a normal user outside a container. + */ +export function sandboxArgs(platform: string = process.platform, uid: number | undefined = process.getuid?.(), inContainer: () => boolean = runsInContainer): string[] { + return platform === 'linux' && (uid === 0 || inContainer()) ? ['--no-sandbox'] : []; +} +const runsInContainer = (): boolean => existsSync('/.dockerenv') || existsSync('/run/.containerenv'); + /** Where Playwright's channel "chrome" finds Google Chrome (stable). */ export function chromeChannelPaths(platform: string = process.platform, env: Record = process.env): string[] { if (platform === 'darwin') return ['/Applications/Google Chrome.app/Contents/MacOS/Google Chrome']; diff --git a/tests/capture-web.test.ts b/tests/capture-web.test.ts index cb617c8..2376cfe 100644 --- a/tests/capture-web.test.ts +++ b/tests/capture-web.test.ts @@ -98,6 +98,8 @@ function project(cfg: ShipConfig, files: Record): { root: string return { root, setConfig, cli: async args => { const r = Bun.spawn(['bun', CLI, 'shots', 'capture', ...args], { cwd: root, stdout: 'pipe', stderr: 'pipe', env: { ...process.env, NO_COLOR: '1' } }); const [out, err, code] = await Promise.all([new Response(r.stdout).text(), new Response(r.stderr).text(), r.exited]); + // failed frames are warnings on stderr (exit 1): show them, or a CI failure only says which file is missing + if (code !== 0) console.error(`storebound shots capture ${args.join(' ')} (exit ${code}):\n${err}`); return { out, err, code }; } }; } diff --git a/tests/console-session.test.ts b/tests/console-session.test.ts index 340dc54..48e3e03 100644 --- a/tests/console-session.test.ts +++ b/tests/console-session.test.ts @@ -11,6 +11,7 @@ import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { APPLE_COPY_NOTE, AUTOMATION_SWITCHES, browserPlan, chromeDataDir, consoleChromeArgs, CONSOLE_HOSTS, cookieApplies, copySession, devToolsEndpoint, filterCookies, GOOGLE_COPY_WARNING, GOOGLE_REJECTED, googleRejected, NEVER_COPY, openConsoleBrowser, parseUseChromeSession, pickProfile, PROVIDER_HOSTS, sessionFiles } from '../cli/console/session'; +import { sandboxArgs } from '../cli/shots/browser'; import { capped, CLOSE_TIMEOUT_MS, LAUNCH_TIMEOUT_MS } from './helpers/browser'; const scratch = mkdtempSync(join(tmpdir(), 'sb-session-test-')); @@ -133,6 +134,17 @@ describe('Google session safety (device-bound cookies)', () => { expect(a).toContain('--user-data-dir=/p'); expect(a.some(x => /user-agent/i.test(x))).toBe(false); expect(consoleChromeArgs('/p', { headless: true })).toContain('--headless=new'); + expect(consoleChromeArgs('/p', { sandbox: [] })).not.toContain('--no-sandbox'); + expect(consoleChromeArgs('/p', { sandbox: ['--no-sandbox'] })).toContain('--no-sandbox'); + }); + test('--no-sandbox only for root on Linux or inside a container; never on macOS, Windows or for a normal user', () => { + const no = () => false, yes = () => true; + expect(sandboxArgs('linux', 0, no)).toEqual(['--no-sandbox']); + expect(sandboxArgs('linux', 1000, yes)).toEqual(['--no-sandbox']); + expect(sandboxArgs('linux', 1000, no)).toEqual([]); + expect(sandboxArgs('darwin', 0, yes)).toEqual([]); + expect(sandboxArgs('darwin', 501, no)).toEqual([]); + expect(sandboxArgs('win32', undefined, yes)).toEqual([]); }); });