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/CHANGELOG.md b/CHANGELOG.md index 1eea7d93a..2013cc82d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## [Unreleased] + +**Free Tests passes again on Ubicloud runner image 20261026.1.0.** Every Linux job that landed on that image failed the cookie-import Playwright pipe smoke, in-shard and again on the serial retry, and a rerun passed only when it happened to land on the older 20261021.1.0 image. Both images keep `kernel.apparmor_restrict_unprivileged_userns=1`, but 20261026 loads three AppArmor profiles instead of 123, so nothing grants the bundled Chromium a user namespace and its sandbox aborts with `credentials.cc:131 ... Permission denied`. The two Linux jobs that launch Chromium now grant user namespaces to that one binary, the way Ubuntu's own `chrome` profile does. + +### Fixed +- **`.github/workflows/free-tests.yml`**: `free-suite` and `quick-bun-default` gain a step after the SUID sandbox helper step. It resolves Playwright's Chromium, refuses any path outside `~/.cache/ms-playwright/chromium-*/chrome-linux*/chrome`, and loads an AppArmor profile that grants `userns` to that path alone. The kernel restriction stays on for every other binary. The SUID helper step is unchanged, and no step passes `--no-sandbox` or runs `sysctl`. Diagnostic dispatch 40921806393 ran 17 jobs: all 9 on 20261026 failed the smoke without the profile and passed with it, and all 8 on 20261021 passed throughout. + +### Tests +- `test/chromium-sandbox-ci.test.ts`: four cases, two per job, run the real step with stubbed `bun` and `sudo`. They check that the step sits after the helper and before the suite, the exact profile bytes and the `apparmor_parser -r` call, that no protection-lowering flag appears, and that an unexpected Chromium path is refused before any privileged call. They fail against main's workflow. + ## [1.91.320.0] - 2026-10-29 **A branch that adds its own pregate lane now learns why it still fails, and `gstack-post pr-body` no longer keeps a stale copy of a section.** Pregate reads its policy from the base branch so a branch cannot loosen its own gate. A branch that had already declared a lane in its own `.gstack/pregate.json` was still told to declare it, and went round in a circle. Separately, a PR body that held `## What changed` twice (an old description pasted under the /ship body) kept the old copy every time /ship reposted. 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([]); + }); +}