v1.91.321.0 ci(free-tests): grant user namespaces to the bundled Chromium on runner image 20261026 (#3616)

* 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>

* docs(changelog): Unreleased entry for the Chromium userns grant on runner image 20261026

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* chore: stamp v1.91.321.0 for #3616

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:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-10-29 19:01:03 +00:00
1 parent 319ef500ca
commit 057bac8e23
6 files changed
+97 -3

No files matched your search

+30
View File
@@ -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 <abi/4.0>,\ninclude <tunables/global>\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 <abi/4.0>,\ninclude <tunables/global>\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
+10
View File
@@ -1,5 +1,15 @@
# Changelog
## [1.91.321.0] - 2026-10-29
**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.
+1 -1
View File
@@ -1 +1 @@
1.91.320.0
1.91.321.0
+1 -1
View File
@@ -1,4 +1,4 @@
# gstack digest v1.91.320.0 — regenerate/re-copy after upgrading gstack
# gstack digest v1.91.321.0 — regenerate/re-copy after upgrading gstack
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
for agent hosts without a full skill install. The full skills add workflows,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gstack",
"version": "1.91.320",
"version": "1.91.321",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT",
"type": "module",
+54
View File
@@ -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 <abi/4.0>,\ninclude <tunables/global>\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([]);
});
}