From 5751f5b4cc09e9cd858c5dc556a1ccd71e212812 Mon Sep 17 00:00:00 2001 From: garrytan Date: Thu, 29 Oct 2026 17:44:52 +0000 Subject: [PATCH] ci(free-tests): grant user namespaces to the bundled Chromium on runner image 20261026 Runner image 20261026.1.0 keeps kernel.apparmor_restrict_unprivileged_userns=1 but loads only three AppArmor profiles, so Chromium's namespace sandbox aborts (credentials.cc:131 Permission denied) and the cookie-import-native pipe smoke fails on every job that lands on it. Load a scoped AppArmor profile granting userns to the resolved Playwright Chromium only, in both Linux jobs that set up the sandbox helper. The kernel restriction and the SUID helper stay as they are. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- .github/workflows/free-tests.yml | 30 ++++++++++++++++++ test/chromium-sandbox-ci.test.ts | 54 ++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+) diff --git a/.github/workflows/free-tests.yml b/.github/workflows/free-tests.yml index 948b597ee..92ec29173 100644 --- a/.github/workflows/free-tests.yml +++ b/.github/workflows/free-tests.yml @@ -288,6 +288,21 @@ jobs: installed="${chrome%/*}/chrome-sandbox" test -f "$helper" && test ! -L "$helper" sudo install -T -o root -g root -m 4755 "$helper" "$installed" + # Runner image 20261026.1.0 keeps kernel.apparmor_restrict_unprivileged_userns=1 + # but loads no AppArmor profiles, so Chromium's namespace sandbox aborts with + # "credentials.cc: Permission denied". Grant user namespaces to this one + # resolved binary, the way Ubuntu's own chrome profile does. + - name: Let the bundled Chromium create its sandbox namespaces + run: | + set -euo pipefail + chrome=$(bun -e 'import { chromium } from "playwright"; import { realpathSync } from "node:fs"; console.log(realpathSync(chromium.executablePath()))') + case "$chrome" in + "$HOME"/.cache/ms-playwright/chromium-*/chrome-linux*/chrome) ;; + *) echo "Unexpected Chromium installation path" >&2; exit 1 ;; + esac + profile=/etc/apparmor.d/gstack-playwright-chromium + printf 'abi ,\ninclude \nprofile gstack-playwright-chromium %s flags=(unconfined) {\n userns,\n}\n' "$chrome" | sudo tee "$profile" > /dev/null + sudo apparmor_parser -r "$profile" - name: Install Xvfb + X11 utilities + gate tools run: sudo apt-get install -y --no-install-recommends xvfb x11-utils poppler-utils fonts-noto-color-emoji zsh - name: Configure git identity (tests init temp repos) @@ -397,6 +412,21 @@ jobs: sudo install -T -o root -g root -m 4755 "$helper" "$installed" test "$(stat -c '%u:%a' "$installed")" = '0:4755' cmp -s "$helper" "$installed" + # Runner image 20261026.1.0 keeps kernel.apparmor_restrict_unprivileged_userns=1 + # but loads no AppArmor profiles, so Chromium's namespace sandbox aborts with + # "credentials.cc: Permission denied". Grant user namespaces to this one + # resolved binary, the way Ubuntu's own chrome profile does. + - name: Let the bundled Chromium create its sandbox namespaces + run: | + set -euo pipefail + chrome=$(bun -e 'import { chromium } from "playwright"; import { realpathSync } from "node:fs"; console.log(realpathSync(chromium.executablePath()))') + case "$chrome" in + "$HOME"/.cache/ms-playwright/chromium-*/chrome-linux*/chrome) ;; + *) echo "Unexpected Chromium installation path" >&2; exit 1 ;; + esac + profile=/etc/apparmor.d/gstack-playwright-chromium + printf 'abi ,\ninclude \nprofile gstack-playwright-chromium %s flags=(unconfined) {\n userns,\n}\n' "$chrome" | sudo tee "$profile" > /dev/null + sudo apparmor_parser -r "$profile" # Headed-browser tests (handoff, extension sidepanel DOM) need a real # DISPLAY — first Linux run failed with Playwright's "launched a headed diff --git a/test/chromium-sandbox-ci.test.ts b/test/chromium-sandbox-ci.test.ts index ec90534a2..4998b2bfc 100644 --- a/test/chromium-sandbox-ci.test.ts +++ b/test/chromium-sandbox-ci.test.ts @@ -75,3 +75,57 @@ test('the actual CI setup requires root ownership, setuid mode, and unchanged he expect(calls).not.toBeNull(); } }); + +const NAMESPACE_STEP = 'Let the bundled Chromium create its sandbox namespaces'; +const PROFILE = '/etc/apparmor.d/gstack-playwright-chromium'; + +function grantNamespaces(command: string, options: { outside?: boolean } = {}) { + const fixture = mkdtempSync(path.join(tmpdir(), 'chromium-userns-')); + fixtures.push(fixture); + const bin = path.join(fixture, 'bin'); + const home = path.join(fixture, 'home'); + const directory = options.outside ? path.join(fixture, 'outside') : path.join(home, '.cache/ms-playwright/chromium-1234/chrome-linux64'); + mkdirSync(bin, { recursive: true }); + mkdirSync(directory, { recursive: true }); + const executable = path.join(directory, 'chrome'); + const receipt = path.join(fixture, 'sudo.jsonl'); + writeFileSync(executable, 'synthetic browser'); + writeFileSync(path.join(bin, 'bun'), '#!/bin/sh\nprintf "%s\\n" "$FIXTURE_CHROME"\n', { mode: 0o755 }); + writeFileSync(path.join(bin, 'sudo'), `#!/usr/bin/env node +const fs = require('node:fs'); +const args = process.argv.slice(2); +const stdin = args[0] === 'tee' ? fs.readFileSync(0, 'utf8') : null; +fs.appendFileSync(process.env.FIXTURE_RECEIPT, JSON.stringify({ args, stdin }) + '\\n'); +`, { mode: 0o755 }); + const result = spawnSync('/bin/bash', ['-c', command], { + cwd: root, encoding: 'utf8', timeout: 10_000, + env: { ...process.env, HOME: home, PATH: bin + path.delimiter + process.env.PATH, FIXTURE_CHROME: executable, FIXTURE_RECEIPT: receipt }, + }); + const calls = existsSync(receipt) ? readFileSync(receipt, 'utf8').trim().split('\n').map(line => JSON.parse(line)) : []; + return { result, executable, calls }; +} + +for (const [job, suiteStep] of [['free-suite', 'Run free suite'], ['quick-bun-default', 'Run the quick free tier on the install-default Bun']]) { + const jobSteps = workflow.jobs[job].steps; + const at = jobSteps.findIndex((step: any) => step.name === NAMESPACE_STEP); + const grant = jobSteps[at]?.run; + + test(`${job} grants user namespaces to the resolved bundled Chromium alone, after its helper and before its tests`, () => { + expect(typeof grant).toBe('string'); + expect(at).toBeGreaterThan(jobSteps.findIndex((step: any) => step.name === 'Configure the bundled Chromium sandbox helper')); + expect(at).toBeLessThan(jobSteps.findIndex((step: any) => step.name === suiteStep)); + expect(grant).not.toMatch(/--no-sandbox|chromiumSandbox:\s*false|sysctl|apparmor_restrict/); + const { result, executable, calls } = grantNamespaces(grant); + expect(result.status).toBe(0); + expect(calls).toEqual([ + { args: ['tee', PROFILE], stdin: `abi ,\ninclude \nprofile gstack-playwright-chromium ${executable} flags=(unconfined) {\n userns,\n}\n` }, + { args: ['apparmor_parser', '-r', PROFILE], stdin: null }, + ]); + }); + + test(`${job} refuses to grant namespaces to an unexpected Chromium path`, () => { + const { result, calls } = grantNamespaces(grant, { outside: true }); + expect(result.status).not.toBe(0); + expect(calls).toEqual([]); + }); +}