From 0b7c12b911c115233da171cb85c9451e25ec402a Mon Sep 17 00:00:00 2001 From: Josh Zhang Date: Wed, 9 Sep 2026 16:40:00 +0000 Subject: [PATCH 01/38] feat(image): browser testing in the session image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `npx playwright install --with-deps chromium` is the line every project's CI runs and the line a Rainier session cannot: --with-deps is an apt install as root, and a session has no escalation path, a read-only rootfs, and no package archive on its egress allowlist. So a developer who wanted to run their project's browser tests in a session had to discover sixteen missing shared libraries one "error while loading shared libraries" at a time, and then find they could not install any of them. Both halves of that command are now in the image. The shared libraries and fonts are an ordinary build-time apt layer — Playwright's own debian12-x64 chromium dependency set, named in full because the tool that knows it needs root to act on it. The browser is one checksum-pinned Chrome for Testing headless shell beside the rest of the toolchain, laid out exactly where a project's Playwright looks and linked into the workspace browser cache by the image itself, so docker copies the links onto a freshly created volume and a fresh session runs a suite with nothing downloaded and no network at all. What is deliberately NOT installed is Playwright. A global `playwright` on PATH is what a bare `npx playwright` finds, and it would drive a browser revision the project never pinned; the version that runs a project's tests is the one in the project's lockfile, always. The image installs browsers; the project installs Playwright. 1.63.x matches the baseline and downloads nothing; any other version installs its own revision into the workspace cache beside it, which is a documented, predictable ~114 MiB paid once per workspace. Nothing was relaxed to make a browser start. No --privileged, no --cap-add, no seccomp or apparmor unconfined, no host network or IPC, no wider mount, no /dev/shm change, and no --no-sandbox from this image or this driver — Playwright's own chromiumSandbox default is false, and what isolates the browser is the container it already ran in. TestDockerGrantsNoBrowserPrivilege and TestSessionImageDoesNotDisableBrowserSafety fail the build if any of that changes. Chromium's own layer-1 sandbox needs a user namespace that neither docker's default seccomp profile nor the hosted one admits; the smoke reports the observed status rather than asserting it, because which policy a host applies is not an image property. Qualification is in two pieces. The existing smoke gains offline browser checks — root-owned baseline, seeded cache, pinned build, every library resolved, a page rendered and screenshotted at 1280x800 and at 390x844, Arial laid out at Liberation's metrics rather than a fallback's, and no browser process left behind. scripts/session-image-browser-e2e.sh is new and is the only qualification step with a network: it stages a sample project that has never been in the image, `npm ci`s its locked dependencies, then runs its Playwright suite twice on --network none and goes looking for the trace, screenshot and video a deliberate failure has to leave behind. Co-Authored-By: Claude Opus 5 (1M context) --- Dockerfile | 117 +++++ Makefile | 12 +- docs/session-image.md | 246 ++++++++++- .../session/browser-sample/package-lock.json | 60 +++ images/session/browser-sample/package.json | 13 + .../browser-sample/playwright.config.js | 59 +++ images/session/browser-sample/server.js | 78 ++++ .../browser-sample/tests/artifacts.spec.js | 14 + .../browser-sample/tests/session.spec.js | 88 ++++ images/session/browsers.sh | 188 +++++++++ images/session/browsers/rainier-browsers | 133 ++++++ internal/driver/image_browser_test.go | 398 ++++++++++++++++++ scripts/session-image-browser-e2e.sh | 183 ++++++++ scripts/session-image-checks.sh | 33 +- scripts/session-image-smoke.sh | 195 +++++++-- 15 files changed, 1785 insertions(+), 32 deletions(-) create mode 100644 images/session/browser-sample/package-lock.json create mode 100644 images/session/browser-sample/package.json create mode 100644 images/session/browser-sample/playwright.config.js create mode 100644 images/session/browser-sample/server.js create mode 100644 images/session/browser-sample/tests/artifacts.spec.js create mode 100644 images/session/browser-sample/tests/session.spec.js create mode 100755 images/session/browsers.sh create mode 100755 images/session/browsers/rainier-browsers create mode 100644 internal/driver/image_browser_test.go create mode 100755 scripts/session-image-browser-e2e.sh diff --git a/Dockerfile b/Dockerfile index 631543df..7e8eb798 100644 --- a/Dockerfile +++ b/Dockerfile @@ -54,6 +54,18 @@ ARG CLAUDE_CODE_VERSION=2.1.263 ARG POSTGRES_MAJOR=17 ARG PGDG_KEY_FINGERPRINT=B97B0AFCAA1A47F044F244A07FCC7D46ACCC4CF8 +# The browser baseline. A project runs ITS OWN Playwright — nothing Playwright +# is installed globally in this image, deliberately — so what is pinned here is +# the browser that Playwright launches, and the Playwright version it is the +# right browser for. A project on that version downloads nothing; a project on +# another version installs its own revision into the workspace cache beside it. +# See images/session/browsers.sh, whose checksums are the actual pin, and +# docs/session-image.md for the supported set and for what other versions do. +ARG PLAYWRIGHT_VERSION=1.63.0 +ARG CHROMIUM_VERSION=153.0.8010.12 +ARG CHROMIUM_REVISION=1243 +ARG PLAYWRIGHT_FFMPEG_REVISION=1011 + # --- the pinned upstream toolchain, verified before it is extracted ---------- FROM ${BASE_IMAGE} AS toolchain ARG TARGETARCH @@ -233,6 +245,77 @@ RUN set -eu; \ COPY images/session/services/ /usr/local/bin/ RUN chmod 0755 /usr/local/bin/rainier-pg /usr/local/bin/rainier-redis +# --- browser testing: the shared libraries, the fonts, and one Chromium ------ +# +# `npx playwright install --with-deps chromium` is the line every project's CI +# runs, and its --with-deps half is an `apt-get install` as root. This image +# installs no escalation path and never will, the rootfs is read-only, and the +# egress allowlist carries no package archive — so that half has to be a +# build-time layer or a session cannot run a browser test at all. This is that +# layer. +# +# The package list is Playwright's own `debian12-x64` chromium dependency set +# (packages/playwright-core/src/server/registry/nativeDeps.ts), named here in +# full rather than resolved by the tool, because the tool needs root to read it +# and a session has none. Sixteen of these are missing from the base image and +# each one is a `chrome-headless-shell: error while loading shared libraries` +# at somebody's first test run. +# +# The fonts are not decoration. A Chromium with no fonts renders every glyph as +# a box, which turns a screenshot into a useless artifact and a text-measuring +# assertion into a flake. fonts-liberation is the metric-compatible Arial / +# Times / Courier set Chrome for Testing expects, fonts-dejavu-core covers +# Latin, Greek and Cyrillic, and fonts-noto-color-emoji is what an emoji in a +# product's UI renders as. CJK is deliberately absent — fonts-wqy-zenhei and +# fonts-ipafont-gothic are ~35 MiB for a script most suites never assert on; +# see docs/session-image.md. +# +# Xvfb is deliberately absent too: this image runs headless browsers only, and +# an X server would be dead weight plus a socket in every session. +RUN set -eu; \ + apt-get update; \ + dpkg-query -W -f='${Package}\n' | sort > /tmp/packages.before; \ + apt-get install -y --no-install-recommends \ + libasound2 libatk-bridge2.0-0 libatk1.0-0 libatspi2.0-0 \ + libcairo2 libcups2 libdbus-1-3 libdrm2 libgbm1 libglib2.0-0 \ + libnspr4 libnss3 libpango-1.0-0 \ + libx11-6 libxcb1 libxcomposite1 libxdamage1 libxext6 libxfixes3 \ + libxkbcommon0 libxrandr2 \ + fontconfig libfontconfig1 libfreetype6 \ + fonts-liberation fonts-dejavu-core fonts-noto-color-emoji; \ + rm -rf /var/lib/apt/lists/*; \ + fc-cache -f >/dev/null; \ + dpkg-query -W -f='${Package}\t${Installed-Size}\n' | sort \ + | awk -F'\t' 'NR==FNR { had[$1] = 1; next } \ + !($1 in had) { n++; kb += $2; added[$1] = $2 } \ + END { printf "%d packages, %d KiB installed\n", n, kb; \ + for (p in added) printf "%8d KiB %s\n", added[p], p }' \ + /tmp/packages.before - \ + | { read -r first; echo "$first"; sort -rn; } > /usr/local/share/rainier-browser-size.txt; \ + rm -f /tmp/packages.before; \ + chmod 0644 /usr/local/share/rainier-browser-size.txt + +# The browser itself, checksum-verified before extraction and laid out exactly +# where a project's Playwright looks. Root-owned under /usr/local/lib for the +# same reason the agents are: a session user who could rewrite the browser +# binary could rewrite what every later test run executes. +ARG TARGETARCH +ARG PLAYWRIGHT_VERSION +ARG CHROMIUM_VERSION +ARG CHROMIUM_REVISION +ARG PLAYWRIGHT_FFMPEG_REVISION +COPY images/session/browsers.sh /tmp/browsers.sh +RUN TARGETARCH="${TARGETARCH}" PLAYWRIGHT_VERSION="${PLAYWRIGHT_VERSION}" \ + CHROMIUM_VERSION="${CHROMIUM_VERSION}" CHROMIUM_REVISION="${CHROMIUM_REVISION}" \ + PLAYWRIGHT_FFMPEG_REVISION="${PLAYWRIGHT_FFMPEG_REVISION}" \ + /tmp/browsers.sh && rm /tmp/browsers.sh + +# The helper that links that baseline into the cache a project's Playwright +# reads. Root-owned in /usr/local/bin beside rainier-pg and rainier-redis; it +# installs nothing, downloads nothing and needs no privilege. +COPY images/session/browsers/ /usr/local/bin/ +RUN chmod 0755 /usr/local/bin/rainier-browsers + # The pinned upstream releases from the toolchain stage. Root-owned, under # /usr/local, which the session user cannot write — see the prefix note below. COPY --from=toolchain /opt/toolchain/go /usr/local/go @@ -358,6 +441,7 @@ ENV PATH="/opt/rainier-env/bin:${PATH}" \ GOPATH=/workspace/.gopath \ GOTMPDIR=/workspace/.cache/go-tmp \ XDG_CACHE_HOME=/workspace/.cache \ + PLAYWRIGHT_BROWSERS_PATH=/workspace/.cache/ms-playwright \ npm_config_cache=/workspace/.cache/npm \ npm_config_update_notifier=false \ PIP_CACHE_DIR=/workspace/.cache/pip \ @@ -366,6 +450,39 @@ ENV PATH="/opt/rainier-env/bin:${PATH}" \ PYTHONDONTWRITEBYTECODE=1 \ DISABLE_AUTOUPDATER=1 +# The browser baseline, linked into the cache a project's Playwright reads. +# +# PLAYWRIGHT_BROWSERS_PATH above is /workspace/.cache/ms-playwright, which is +# both writable and exactly where Playwright would have looked anyway +# ($XDG_CACHE_HOME/ms-playwright). Building the links HERE, into the image's +# own /workspace, means docker copies them onto a freshly created workspace +# volume at session creation: no entrypoint work, no first-run copy of a +# quarter of a gigabyte, and nothing on the volume but symlinks and two empty +# marker files. The payload stays on the read-only rootfs, out of checkpoints, +# archives and `rainier pull`. +# +# The seed's chown is -h, and the layout it produces is why the driver's own +# volume initializer is still correct. GNU chown -R traverses -P by default and +# lchown()s a symlink rather than its target (verified against coreutils 9.1), +# so `chown -R 1000:1000 /workspace` — which is exactly what +# internal/driver.initVolumeScript runs, as root with CAP_CHOWN and a READ-ONLY +# rootfs — walks over these links without touching the browser they point at +# and without failing on a filesystem it cannot write. A -L or --dereference +# there would do both: fail the init job with EROFS, and, on any host where it +# did not, hand the session user the root-owned binary it is about to execute. +# -h here says that out loud, and the assertions below are what actually holds +# it: the binary is still root's, and the cache still reaches it. +RUN set -eu; \ + /usr/local/bin/rainier-browsers link; \ + chown -Rh 1000:1000 /workspace/.cache; \ + bin=$(find /usr/local/lib/rainier-browsers -name chrome-headless-shell -type f); \ + [ -n "$bin" ] || { echo "no browser baseline was installed" >&2; exit 1; }; \ + [ "$(stat -c %u "$bin")" = 0 ] || { \ + echo "the browser baseline is owned by $(stat -c %U "$bin"), not root; the workspace chown followed a symlink" >&2; exit 1; }; \ + link=/workspace/.cache/ms-playwright/chromium_headless_shell-${CHROMIUM_REVISION}/$(basename "$(dirname "$bin")"); \ + [ -L "$link" ] && [ -x "$link/chrome-headless-shell" ] || { \ + echo "the workspace cache does not resolve to the baseline through $link" >&2; exit 1; } + COPY --from=build /out/sessiond /usr/local/bin/sessiond # sessiond as PID 1; RAINIER_DIAL/RAINIER_SESSION injected by the driver select diff --git a/Makefile b/Makefile index 705a1de9..39d4a201 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: test build demo e2e verify module-path protocols control session-image session-image-smoke session-image-verify +.PHONY: test build demo e2e verify module-path protocols control session-image session-image-smoke session-image-browser-e2e session-image-verify DOCKER ?= docker SESSION_IMAGE ?= rainier-session:smoke @@ -48,7 +48,15 @@ session-image: session-image-smoke: DOCKER="$(DOCKER)" ./scripts/session-image-smoke.sh "$(SESSION_IMAGE)" -session-image-verify: session-image session-image-smoke +# session-image-browser-e2e is the browser half, and the one step in image +# qualification that is deliberately allowed a network: it stages a sample +# project that has never been in the image, installs its locked dependencies +# from the registry, and then runs its Playwright suite twice with no network +# at all. See the header of the script. +session-image-browser-e2e: + DOCKER="$(DOCKER)" ./scripts/session-image-browser-e2e.sh "$(SESSION_IMAGE)" + +session-image-verify: session-image session-image-smoke session-image-browser-e2e verify: module-path protocols control test build go vet ./... diff --git a/docs/session-image.md b/docs/session-image.md index 6e54a04f..624460af 100644 --- a/docs/session-image.md +++ b/docs/session-image.md @@ -30,6 +30,7 @@ Pinned by version in the `Dockerfile`, and by SHA-256 in | **Python** | `python3`, `venv`, `pip`, and `uv`/`uvx` | | **Shell** | `bash`, GNU coreutils, findutils, grep, sed, gawk, diffutils, `patch` | | **Search and data** | `ripgrep`, `jq` | +| **Browser testing** | The shared libraries and fonts Chromium needs, and one pinned Chrome for Testing headless shell with Playwright's ffmpeg, preinstalled and linked into the workspace browser cache — see [Browser testing](#browser-testing) | | **Databases** | PostgreSQL 17 client *and server* (`psql`, `initdb`, `pg_ctl`, `pg_dump`, `createdb`, `pg_isready`, …), SQLite 3 (`sqlite3`), Redis (`redis-server`, `redis-cli`) — installed, never started; see [Local services](#local-services-a-developer-starts) | | **Network** | `curl`, `wget`, CA certificates, `openssl`, `nc` | | **Archives** | `tar`, `gzip`, `bzip2`, `xz-utils`, `zip`, `unzip` | @@ -275,6 +276,229 @@ server at all is the thing this change exists to fix. If the pull cost review decides 239 MiB per runner boot is too much, the source build is the conversation to have, and the breakdown on the run is where it starts. +## Browser testing + +`npx playwright install --with-deps chromium` is the line every project's CI +runs, and it is the line a session cannot: `--with-deps` is `apt-get install` +as root, and a session has no escalation path, a read-only rootfs, and no +package archive on its egress allowlist. So the image carries the two halves +that command would have installed — the **shared libraries and fonts**, as an +ordinary build-time apt layer, and **one browser**, checksum-pinned beside the +rest of the toolchain — and a fresh session runs a project's Playwright suite +with no setup step and, on the supported version, no download at all. + +What it deliberately does **not** carry is Playwright itself. See +[Version matching](#version-matching-what-is-ready-to-run-and-what-is-not). + +### The ready-to-run baseline + +| | | +|---|---| +| Playwright the baseline matches | **1.63.x** (`@playwright/test` or `playwright`) | +| Browser | Chrome for Testing **153.0.8010.12**, `chromium-headless-shell` revision **1243** | +| Also preinstalled | Playwright's `ffmpeg` revision 1011, which is what `video:` recording uses | +| Where it lives | `/usr/local/lib/rainier-browsers`, root-owned, on the read-only rootfs | +| Where Playwright looks | `PLAYWRIGHT_BROWSERS_PATH=/workspace/.cache/ms-playwright` | + +```sh +# In a project with @playwright/test in its lockfile: +npm ci +npx playwright test # no `playwright install`, no network, no root + +rainier-browsers status # what is installed, where, and what resolves +rainier-browsers path # the cache directory Playwright reads +``` + +`rainier-browsers` is a root-owned shell script in `/usr/local/bin`, like +`rainier-pg` and `rainier-redis`. It installs nothing, downloads nothing and +needs no privilege. + +**Only the headless shell.** Playwright launches `chromium-headless-shell` for +`headless: true` and the full Chrome for Testing build only for `headless: +false` or an explicit `channel: 'chromium'`. This image has no X server and no +Xvfb, so `headless: false` cannot run in it whatever is installed, and the full +build is another ~393 MiB extracted for one channel setting. A project that +wants it runs `npx playwright install chromium`, which writes into the +workspace cache and needs only `cdn.playwright.dev`. + +### How the baseline and the workspace cache meet + +The baseline is on the **read-only rootfs**, because a session user who could +rewrite the browser binary could rewrite what every later test run executes. +Playwright's cache has to be **writable**, because a project pinned to a +different Playwright installs its own revision there and must win. The two meet +through links: + +``` +/workspace/.cache/ms-playwright/chromium_headless_shell-1243/ + INSTALLATION_COMPLETE real file, writable + DEPENDENCIES_VALIDATED real file, writable (Playwright rewrites it every 30 days) + .rainier-baseline says this entry is the image's, not the project's + chrome-headless-shell-linux64 -> /usr/local/lib/rainier-browsers/... +``` + +Those links are built into the image's own `/workspace`, so **docker copies +them onto a freshly created workspace volume** when the session is created. +There is no entrypoint work, no first-run copy of a quarter of a gigabyte, and +nothing on the volume but symlinks and two empty files — the payload stays on +the rootfs, out of checkpoints, archives and `rainier pull`. + +The two markers are real files rather than links because Playwright rewrites +`DEPENDENCIES_VALIDATED` after every successful host-requirements check and +re-runs that check when the file is older than thirty days; a failed write +there would cost an `ldd` sweep on every launch, forever. + +`PLAYWRIGHT_BROWSERS_PATH` is set explicitly even though it is exactly what +Playwright would compute on its own from `XDG_CACHE_HOME`, so the path is a +property of this image rather than of a default that could move. + +### Version matching: what is ready to run, and what is not + +**Nothing Playwright is installed globally, deliberately.** A global +`playwright` on `PATH` is what a bare `npx playwright` finds, and it would +drive a browser revision the project never pinned. The version that runs a +project's tests is the version in the project's own lockfile, always. The image +installs browsers; the project installs Playwright. + +Playwright pins a browser revision **per minor release** — 1.61 is 1228, 1.62 +is 1234, 1.63 is 1243 — and patch releases keep their minor's revision. So: + +| The project pins | What happens | +|---|---| +| `1.63.x` | The baseline is used. Nothing is downloaded; the suite runs offline. | +| Any other version | Playwright reports `Executable doesn't exist at …` and names `npx playwright install`, which downloads that version's revision (~114 MiB) into `/workspace/.cache/ms-playwright` beside the baseline. It needs `cdn.playwright.dev`. The download survives suspend and resume, so it is paid once per workspace. | +| `channel: 'chrome'` or `'msedge'` | Not supported and will not be: those are Google's and Microsoft's branded builds, installed from their own apt archives as root. | +| `channel: 'chromium'`, or `headless: false` | Needs the full Chrome for Testing build (`npx playwright install chromium`), and `headless: false` needs a display this image does not have. | + +`npm ci` never downloads a browser: Playwright's npm packages carry no install +script, so acquiring a browser is always an explicit `playwright install`. + +**Do not run `npx playwright install --with-deps`.** The `--with-deps` half +needs root and will fail; the dependencies it wants are already installed. Plain +`npx playwright install` is the supported form. + +A `playwright install` prunes browser directories no linked Playwright asks +for, which for a project on another version means it removes the baseline's +links. That is correct behaviour and it only ever removes links — the payload is +on the rootfs. `rainier-browsers link` puts them back. + +### Firefox and WebKit are not supported + +Only Chromium is. `npx playwright install firefox` or `webkit` will download +the browser and then fail to launch, because their shared libraries are not in +this image: Firefox additionally needs GTK 3, `libdbus-glib`, `libavcodec` and +an X client stack, and WebKit needs four GStreamer plugin sets, `libsoup3`, +`libenchant`, EGL/GLES and more — together several hundred megabytes of +packages, for browsers whose engines this platform's own suites do not target. +`npx playwright install-deps` cannot supply them from inside a session at all. + +A project that needs cross-browser coverage runs it somewhere else. Adding +either engine here is a bounded change to the Dockerfile and a real size +review; it has not been made. + +### Sandboxing, and what is actually isolating the browser + +This is worth stating plainly, because "Chromium sandbox" means two different +things and only one of them is in play. + +**Playwright disables Chromium's own sandbox by default.** `chromiumSandbox` +defaults to `false` in every Playwright release, so `browserType.launch()` adds +`--no-sandbox` unless a suite asks otherwise. That is upstream's default on +every platform, not something this image or this driver does — nothing here +adds that flag, and +`internal/driver.TestSessionImageDoesNotDisableBrowserSafety` fails the build +if the image ever names it. + +**What isolates the browser is the container**, and it is the same boundary +that isolates everything else a session runs: uid 1000, `no-new-privileges`, a +read-only rootfs, a noexec `/tmp`, the session's own network namespace, no host +mount and no docker socket, and on the hosted product a host seccomp profile +and an AppArmor profile besides. A browser process in a session can reach the +session's own files and nothing else — which is a stronger boundary than the +one a browser gets on the laptop this suite usually runs on. + +**Chromium's own layer-1 sandbox is not available**, and the reason is +specific. It needs to create a user namespace — `clone(CLONE_NEWUSER | …)` — +and both docker's default seccomp profile and the hosted profile refuse that +argument shape without `CAP_SYS_ADMIN`. The setuid-sandbox alternative is dead +on arrival under `no-new-privileges`. The observed status is reported by +`scripts/session-image-smoke.sh` on every qualification run rather than +asserted, because which policy a host applies is not an image property. + +**What was not done to change that**, and would not be: + +- No `--privileged`, no `--cap-add`, no `seccomp=unconfined` and no + `apparmor=unconfined`. `internal/driver.TestDockerGrantsNoBrowserPrivilege` + fails the build if any of them appears in `runArgs`. +- No `--ipc=host`, no host network, no wider host mount, no `/dev/shm` bind. +- No debugging port exposed anywhere. Playwright drives Chromium over + `--remote-debugging-pipe` — a pair of file descriptors, not a socket — so + there is no port to expose in the first place. +- No reuse of a host browser profile or credential. Every run gets a fresh + `--user-data-dir` under `/tmp`, which is a per-container tmpfs. + +Making Chromium's own sandbox work would mean admitting its specific +`clone(2)` argument values to the hosted seccomp profile, the way Codex's +Bubblewrap shapes were admitted — a narrow, reviewable change with its own +live qualification, and one this image does not need in order to run a test +suite. rainier-cloud's `docs/security/browser-sandbox-in-a-session.md` is where +that case is written up. + +### `/dev/shm` is 64 MiB, and that is fine here + +Docker's default, and the driver does not change it. Chromium is famous for +crashing in containers with a small `/dev/shm` — and Playwright passes +`--disable-dev-shm-usage` on **every** Chromium launch, which moves those +allocations to `/tmp`, a per-container tmpfs bounded by half of RAM. A suite +driven by Playwright is unaffected. A tool that launches Chromium itself +without that flag can still exhaust it; pass the flag rather than asking for a +wider container. + +### Fonts + +`fonts-liberation` (metric-compatible with Arial, Times and Courier, which is +what Chrome for Testing expects), `fonts-dejavu-core` (Latin, Greek, Cyrillic) +and `fonts-noto-color-emoji`. A browser with no fonts renders every glyph as a +box, which makes a screenshot artifact useless and a text-measuring assertion a +flake, so this is a rendering dependency rather than a nicety — the smoke +measures ten 100px Arial capital Ms and requires the ~833px that Liberation +gives, which a DejaVu fallback (791px) would fail. + +**CJK is deliberately absent.** `fonts-wqy-zenhei` and `fonts-ipafont-gothic` +are ~35 MiB for a script most suites never assert on. A suite that needs it +should say so; adding it is a bounded change to the Dockerfile. + +### What this costs + +Measured by the build and reported by the smoke on every qualification run, so +read it off the run rather than off this page: + +| | | +|---|---| +| Shared libraries and fonts | `/usr/local/share/rainier-browser-size.txt`, first line | +| The browser payload | the `browser payload:` line of the same file | +| Compressed, in the pull | ~117 MiB (`chrome-headless-shell-linux64.zip` 114.3 MiB + ffmpeg 2.3 MiB) | +| Extracted, in the image | ~266 MiB | +| Per session, on the workspace volume | three directories of symlinks and empty marker files — kilobytes | +| Startup cost | none: docker copies the links when it creates the volume, and no entrypoint step touches them | + +A project that pins another Playwright pays ~114 MiB of download once per +workspace, onto the volume, where it survives suspend and resume. + +### Egress + +One host, for both artifacts: + +| Host | What needs it | +|---|---| +| `cdn.playwright.dev` | `npx playwright install` for any browser or revision the image does not carry, including the full Chrome for Testing build | +| `playwright.download.prss.microsoft.com` | Playwright's documented fallback mirror; only tried when the first fails | +| `registry.npmjs.org` | `npm ci` of the project's own Playwright, like any other dependency | + +Nothing is needed at all for a project on the pinned version: the baseline is +in the image and the suite runs on `--network none`. Which of these an +environment gets is a control-plane decision and not this image's to make. + ## Why this base image, and not a Dev Containers one The choice was between a pinned Debian/Ubuntu **Dev Containers base image** and @@ -396,6 +620,7 @@ survivable: go test ./internal/driver/ -run TestSession # the contract, no docker needed make session-image # build it make session-image-smoke # what --version cannot tell you +make session-image-browser-e2e # a real Playwright project, twice ``` `internal/driver/image_contract_test.go` reads the `Dockerfile` and the @@ -427,7 +652,26 @@ all. The shell of those two helpers is separately exercised against stub binaries in `internal/driver/image_services_test.go`, which needs no docker and catches the behavioural failures (a stop that waits on the wrong thing, a server bound to the wrong interface, a cluster created with the wrong locale) -that reading the script does not. Every probe runs in a container wearing the +that reading the script does not. The shell of `rainier-browsers` is exercised +the same way in `internal/driver/image_browser_test.go`, including the case +that matters most — a `link` that would overwrite a browser the project +installed itself. + +It also runs the browser: it checks that the baseline is root-owned and +unwritable, that a freshly created workspace volume already carries the cache +links, that the preinstalled build is the one the `Dockerfile` pins, that every +shared library resolves, that a page renders and screenshots at 1280x800 and at +390x844, that Arial lays out at Liberation's metrics rather than a fallback's, +and that no browser process survives the run. Chromium's own sandbox status +under the driver's restrictions is *reported* rather than asserted, because +which policy a host applies is not a property of the image. + +`scripts/session-image-browser-e2e.sh` is the third piece and the only one with +a network, deliberately: it stages a sample project that has never been in the +image, `npm ci`s its locked dependencies from the registry, and then runs its +Playwright suite twice on `--network none` — a real navigation, assertions on +real layout at a desktop and a phone viewport, a screenshot, and a deliberate +failure whose trace, screenshot and video it then goes looking for. Every probe runs in a container wearing the driver's own restrictions with **no network at all**, and nothing is relaxed to make a check pass — a check that cannot pass under the real contract is reporting a real defect in the image. diff --git a/images/session/browser-sample/package-lock.json b/images/session/browser-sample/package-lock.json new file mode 100644 index 00000000..ce4eed62 --- /dev/null +++ b/images/session/browser-sample/package-lock.json @@ -0,0 +1,60 @@ +{ + "name": "rainier-browser-sample", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "rainier-browser-sample", + "version": "0.0.0", + "devDependencies": { + "@playwright/test": "1.63.0" + } + }, + "node_modules/@playwright/test": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", + "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + } + } +} diff --git a/images/session/browser-sample/package.json b/images/session/browser-sample/package.json new file mode 100644 index 00000000..204c061f --- /dev/null +++ b/images/session/browser-sample/package.json @@ -0,0 +1,13 @@ +{ + "name": "rainier-browser-sample", + "private": true, + "version": "0.0.0", + "description": "The sample project scripts/session-image-browser-e2e.sh qualifies the session image with: a loopback web server, a real Chromium navigation at two viewports, and the artifacts a failure has to leave behind.", + "scripts": { + "serve": "node server.js", + "test": "playwright test" + }, + "devDependencies": { + "@playwright/test": "1.63.0" + } +} diff --git a/images/session/browser-sample/playwright.config.js b/images/session/browser-sample/playwright.config.js new file mode 100644 index 00000000..64e5aca1 --- /dev/null +++ b/images/session/browser-sample/playwright.config.js @@ -0,0 +1,59 @@ +// The sample suite's configuration, and deliberately an ordinary one: nothing +// here is Rainier-specific, because the point of the qualification is that a +// project's own unmodified Playwright configuration works in a session. +// +// In particular there is no `channel`, no `executablePath`, no launch +// argument, and no chromiumSandbox setting. Playwright resolves the browser +// from PLAYWRIGHT_BROWSERS_PATH, which the image points at the workspace +// cache; the image seeds that cache with links to its pinned baseline, so this +// runs with nothing downloaded and no network at all. +const { defineConfig, devices } = require('@playwright/test') + +const PORT = Number(process.env.PORT || 8973) +const baseURL = `http://127.0.0.1:${PORT}` + +module.exports = defineConfig({ + testDir: './tests', + // CI here means "this is a qualification run": no `.only` may slip through, + // and one worker, because the thing being measured is the image rather than + // the host's core count. + forbidOnly: !!process.env.CI, + workers: 1, + retries: 0, + reporter: [['list'], ['html', { open: 'never' }]], + // The artifacts a failure has to leave behind, and the reason ffmpeg is in + // the image: a trace to open, a screenshot to look at, and a video of the + // run. All three are retained only on failure, so a green run writes almost + // nothing to the workspace volume. + use: { + baseURL, + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + }, + projects: [ + { + name: 'desktop', + use: { ...devices['Desktop Chrome'], viewport: { width: 1280, height: 800 } }, + }, + { + name: 'phone', + // A real phone descriptor, not just a narrow window: device scale + // factor, touch, and the mobile user agent all change what the page + // does, and a layout assertion that ignored them would be measuring + // something nobody has. + use: { ...devices['Pixel 7'] }, + }, + ], + // Playwright starts and stops this itself, which is half of what the + // qualification is checking: a session must not be left with a listener on + // loopback after the suite exits. + webServer: { + command: 'node server.js', + url: `${baseURL}/healthz`, + reuseExistingServer: false, + timeout: 60_000, + stdout: 'pipe', + stderr: 'pipe', + }, +}) diff --git a/images/session/browser-sample/server.js b/images/session/browser-sample/server.js new file mode 100644 index 00000000..1444478b --- /dev/null +++ b/images/session/browser-sample/server.js @@ -0,0 +1,78 @@ +// The loopback web server the sample suite drives. Deliberately node's own +// http and nothing else: a session image qualification must not depend on a +// second package resolving, and the point of the exercise is the browser. +// +// It binds 127.0.0.1 inside the session's own network namespace, which is the +// only interface a test server should ever be on. Nothing outside the session +// container can reach it even if the container has egress. +const http = require('node:http') + +const PORT = Number(process.env.PORT || 8973) + +// One page, written out here rather than read from disk, so the served bytes +// and the assertions live next to each other. Everything it names is +// synthetic: no account, workspace or session is behind any of it. +const page = ` + + + + + Rainier browser sample + + + +

Sessions

+
+

Signed in as sample@rainier.test on runner.invalid.

+
+

box1

running

+

box2

suspended

+

box3

stopped

+
+

Resumed 0 times.

+ +
+ + + +` + +const server = http.createServer((req, res) => { + if (req.url === '/healthz') { + res.writeHead(200, { 'content-type': 'text/plain' }) + res.end('ok') + return + } + res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }) + res.end(page) +}) + +// Loopback only, explicitly. A server that bound 0.0.0.0 would be reachable +// from anything sharing this network namespace, and "nothing does" is a +// property of the deployment rather than of this file. +server.listen(PORT, '127.0.0.1', () => { + console.log(`sample server on http://127.0.0.1:${PORT}`) +}) + +for (const signal of ['SIGINT', 'SIGTERM']) { + process.on(signal, () => server.close(() => process.exit(0))) +} diff --git a/images/session/browser-sample/tests/artifacts.spec.js b/images/session/browser-sample/tests/artifacts.spec.js new file mode 100644 index 00000000..849262e9 --- /dev/null +++ b/images/session/browser-sample/tests/artifacts.spec.js @@ -0,0 +1,14 @@ +// A failing test, run on purpose by scripts/session-image-browser-e2e.sh with +// RAINIER_BROWSER_SMOKE_FAIL=1, because "a failure emits a trace, a screenshot +// and a video" is a claim about the image and the configuration together and +// cannot be checked by a suite that only ever passes. +// +// It is skipped in every other run, so an ordinary `npx playwright test` in +// this directory is green. +const { test, expect } = require('@playwright/test') + +test('deliberately fails so the run has artifacts to emit', async ({ page }) => { + test.skip(process.env.RAINIER_BROWSER_SMOKE_FAIL !== '1', 'artifact probe; set RAINIER_BROWSER_SMOKE_FAIL=1') + await page.goto('/') + await expect(page.getByRole('heading', { level: 1 })).toHaveText('this heading does not exist') +}) diff --git a/images/session/browser-sample/tests/session.spec.js b/images/session/browser-sample/tests/session.spec.js new file mode 100644 index 00000000..d12dd911 --- /dev/null +++ b/images/session/browser-sample/tests/session.spec.js @@ -0,0 +1,88 @@ +// What a session has to be able to do out of the box: reach a loopback server +// it started itself, drive a real Chromium, assert on what rendered, and write +// a screenshot somebody can look at. Everything it names is synthetic. +const { test, expect } = require('@playwright/test') +const fs = require('node:fs') +const path = require('node:path') + +test('renders the page and asserts on what the browser actually laid out', async ({ page }, testInfo) => { + await page.goto('/') + await expect(page).toHaveTitle('Rainier browser sample') + await expect(page.getByRole('heading', { name: 'Sessions', level: 1 })).toBeVisible() + await expect(page.getByText('sample@rainier.test')).toBeVisible() + + // A real layout question, which is the only kind worth a browser: the cards + // are a three-column grid on a desktop viewport and a single column on a + // phone. jsdom cannot answer this. + const cards = page.locator('.card') + await expect(cards).toHaveCount(3) + const boxes = await cards.evaluateAll((els) => els.map((el) => el.getBoundingClientRect().top)) + const sameRow = boxes.every((top) => Math.abs(top - boxes[0]) < 1) + expect(sameRow).toBe(testInfo.project.name === 'desktop') + + // And a real interaction: a click that has to reach the page and run its + // handler, not a synthetic event dispatched into a DOM implementation. + await page.getByRole('button', { name: 'Resume' }).click() + await expect(page.locator('#count')).toHaveText('1') + + const screenshot = testInfo.outputPath(`${testInfo.project.name}.png`) + await page.screenshot({ path: screenshot, fullPage: true }) + expect(fs.statSync(screenshot).size).toBeGreaterThan(1000) + await testInfo.attach(`${testInfo.project.name} screenshot`, { path: screenshot, contentType: 'image/png' }) +}) + +test('has no horizontal overflow at its own viewport', async ({ page }) => { + await page.goto('/') + const overflow = await page.evaluate(() => { + const root = document.documentElement + return root.scrollWidth - root.clientWidth + }) + expect(overflow).toBeLessThanOrEqual(0) +}) + +// The version-matching evidence, asserted rather than described. A +// preinstalled browser is only worth anything if the project own Playwright is +// what picked it, from the path the image advertises, at the revision that +// Playwright version pins. If any of those three stops being true this test +// says which one. +// +// It reads /proc rather than asking Playwright for a path, because +// `chromium.executablePath()` answers for the full Chrome for Testing build +// while `headless: true` actually launches chrome-headless-shell. The running +// process is the only unambiguous answer to "which binary is this". +test('runs the preinstalled baseline, resolved by the project own Playwright', async ({ browser, page }) => { + await page.goto('/') + + const browsersPath = process.env.PLAYWRIGHT_BROWSERS_PATH + expect(browsersPath, 'PLAYWRIGHT_BROWSERS_PATH').toBeTruthy() + + const running = fs + .readdirSync('/proc') + .filter((entry) => /^\d+$/.test(entry)) + .map((pid) => { + try { + return fs.readlinkSync(`/proc/${pid}/exe`) + } catch { + return null + } + }) + .filter((exe) => exe && /chrome-headless-shell|chrome-linux/.test(exe)) + + expect(running.length, 'a browser process').toBeGreaterThan(0) + + // /proc//exe is already resolved, so this is the real file behind the + // workspace cache link — which is how the test tells "the image preinstalled + // it" apart from "this workspace downloaded it". Both are legitimate; only + // the first is what a fresh session is being qualified for. + const exe = running[0] + expect(exe.startsWith('/usr/local/lib/rainier-browsers/'), `browser executable ${exe}`).toBe(true) + + // The revision directory is the one this project own Playwright pins, which + // is the whole of "the versions match". + const registry = require( + path.join(path.dirname(require.resolve('playwright-core')), 'browsers.json'), + ) + const pinned = registry.browsers.find((b) => b.name === 'chromium-headless-shell') + expect(exe).toContain(`chromium_headless_shell-${pinned.revision}`) + expect(browser.version()).toBe(pinned.browserVersion) +}) diff --git a/images/session/browsers.sh b/images/session/browsers.sh new file mode 100755 index 00000000..682454c3 --- /dev/null +++ b/images/session/browsers.sh @@ -0,0 +1,188 @@ +#!/usr/bin/env bash +# Installs the session image's pinned browser baseline: the Chromium build that +# a project's own Playwright launches for `headless: true`, plus Playwright's +# ffmpeg for video recording. +# +# WHY A BASELINE AT ALL. A session has no sudo, a read-only rootfs, and an +# egress allowlist that does not carry a package archive, so `npx playwright +# install --with-deps` — the line every project's CI uses — cannot work here: +# its apt half needs root. The shared-library half of that command is therefore +# in the Dockerfile as an ordinary build-time apt layer, and the browser half is +# here. Between them a fresh session runs a project's Playwright tests with no +# setup step, and with no download at all when the project's Playwright agrees +# with the pin below. +# +# WHY ONLY THE HEADLESS SHELL. Playwright launches `chromium-headless-shell` +# for `headless: true` and the full Chrome for Testing build only for +# `headless: false` or an explicit `channel: 'chromium'`. This image has no X +# server and no Xvfb, so `headless: false` cannot run in it whatever is +# installed, and the full build's extra 393 MiB would buy one channel setting. +# A project that wants it runs `npx playwright install chromium`, which writes +# into the workspace cache and needs only cdn.playwright.dev. See +# docs/session-image.md. +# +# WHAT IS PINNED. The Chrome for Testing version and the two Playwright +# revisions are ARGs in the Dockerfile; the SHA-256 of every archive is in the +# table below and is checked before a single byte is extracted. Every URL names +# its version — none of them resolves "the latest" at build time — so one +# commit builds one browser, and a version bump that forgets its checksum fails +# the build rather than installing something unreviewed. +# +# WHAT IS NOT INSTALLED. No `playwright` or `@playwright/test` npm package, +# globally or otherwise. A global Playwright would shadow nothing at require() +# time but would absolutely be picked up by a bare `npx playwright`, and the +# version that drives a project's tests has to be the one in the project's own +# lockfile. This script installs browsers; the project installs Playwright. +set -euo pipefail + +: "${TARGETARCH:?TARGETARCH must be set (docker sets it from the build platform)}" +: "${CHROMIUM_VERSION:?}" "${CHROMIUM_REVISION:?}" "${PLAYWRIGHT_FFMPEG_REVISION:?}" +: "${PLAYWRIGHT_VERSION:?}" + +# Same guard as the toolchain script: docker reports TARGETARCH from the BUILD +# platform while dpkg reports the truth about the IMAGE, and a browser for the +# other architecture is an "exec format error" at somebody's first test run +# rather than a failed build. +image_arch=$(dpkg --print-architecture) +if [ "$image_arch" != "$TARGETARCH" ]; then + echo "the base image is ${image_arch} but the build targets ${TARGETARCH}: pass --build-arg BASE_IMAGE=, or build with --platform linux/${image_arch}" >&2 + exit 1 +fi + +# Playwright's own registry layout, which is the whole point: these directory +# and executable names are what packages/playwright-core/src/server/registry +# computes from the browser name and revision, so a project's Playwright finds +# the baseline by looking where it always looks. They are asserted after the +# extraction rather than trusted. +case "$TARGETARCH" in +amd64) + SHELL_DIR=chrome-headless-shell-linux64 + SHELL_ZIP="chrome-headless-shell-linux64.zip" + SHELL_SHA=a9da028861a0cf789ff25c2fed45f5f1aaf969ed9247835b6a7821a4f7af9d1d + CFT_PLATFORM=linux64 + FFMPEG_ZIP=ffmpeg-linux.zip + FFMPEG_SHA=ebc74fc5b94830176a3c2914ae96bd8bc7f6a91f4f33890230f84a172ee61ccc + ;; +arm64) + SHELL_DIR=chrome-headless-shell-linux-arm64 + SHELL_ZIP="chrome-headless-shell-linux-arm64.zip" + SHELL_SHA=d433c45172c7836e38124fe545f767b02210bfb43a6262f08a297473a8e91c99 + CFT_PLATFORM=linux-arm64 + FFMPEG_ZIP=ffmpeg-linux-arm64.zip + FFMPEG_SHA=2628c03f05318ff812c8c9baaf207dea2ddf53e818c0dc936714b0fbe3afb009 + ;; +*) + echo "no pinned browser baseline for TARGETARCH=${TARGETARCH}; the session image supports amd64 and arm64" >&2 + exit 1 + ;; +esac + +PREFIX=${RAINIER_BROWSERS_PREFIX:-/usr/local/lib/rainier-browsers} +SHELL_HOME="$PREFIX/chromium_headless_shell-${CHROMIUM_REVISION}" +FFMPEG_HOME="$PREFIX/ffmpeg-${PLAYWRIGHT_FFMPEG_REVISION}" + +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT + +# fetch +fetch() { + local url=$1 want=$2 out=$3 + curl --fail --location --retry 3 --retry-delay 2 --max-time 900 \ + --proto '=https' --tlsv1.2 --output "$out" "$url" + echo "${want} ${out}" | sha256sum --check --status \ + || { echo "checksum mismatch for ${url}" >&2; exit 1; } +} + +mkdir -p "$SHELL_HOME" "$FFMPEG_HOME" + +# Chrome for Testing, from Playwright's CDN and at the exact build Playwright +# ${PLAYWRIGHT_VERSION} pins. cdn.playwright.dev serves the Chrome for Testing +# builds under a plain /builds/cft path and Playwright's own artifacts under +# /dbazure/download/playwright — one host for both, which is the only host a +# session needs allowlisted to install a different browser later. +fetch "https://cdn.playwright.dev/builds/cft/${CHROMIUM_VERSION}/${CFT_PLATFORM}/${SHELL_ZIP}" \ + "$SHELL_SHA" "$work/shell.zip" +unzip -q "$work/shell.zip" -d "$SHELL_HOME" + +fetch "https://cdn.playwright.dev/dbazure/download/playwright/builds/ffmpeg/${PLAYWRIGHT_FFMPEG_REVISION}/${FFMPEG_ZIP}" \ + "$FFMPEG_SHA" "$work/ffmpeg.zip" +unzip -q "$work/ffmpeg.zip" -d "$FFMPEG_HOME" + +# The two markers Playwright writes after a successful install and a successful +# host-requirements check. Both are created here because this baseline is on +# the read-only rootfs at runtime: without INSTALLATION_COMPLETE a project's +# `playwright install` would decide the browser is absent and download it +# again, and without DEPENDENCIES_VALIDATED every launch would re-run the ldd +# sweep and then silently fail to record that it passed. +# +# DEPENDENCIES_VALIDATED is honest here and nowhere else: the shared libraries +# it stands for are installed in the layer above this one, by the same build, +# and are asserted by ldd below. +for home in "$SHELL_HOME" "$FFMPEG_HOME"; do + : > "$home/INSTALLATION_COMPLETE" + : > "$home/DEPENDENCIES_VALIDATED" +done + +SHELL_BIN="$SHELL_HOME/$SHELL_DIR/chrome-headless-shell" +FFMPEG_BIN="$FFMPEG_HOME/ffmpeg-linux" + +# The layout Playwright will look for, asserted rather than assumed: an +# upstream archive that renamed its top-level directory would otherwise ship an +# image whose baseline is invisible to the thing that is supposed to find it. +[ -x "$SHELL_BIN" ] || { echo "the Chromium archive did not contain ${SHELL_DIR}/chrome-headless-shell" >&2; exit 1; } +[ -x "$FFMPEG_BIN" ] || { echo "the ffmpeg archive did not contain ffmpeg-linux" >&2; exit 1; } + +# Every shared library the browser needs has to resolve in THIS image. ldd is +# the same check `playwright install` runs, and running it here means a missing +# apt line is a failed build rather than a developer's first test run failing +# with "error while loading shared libraries". Playwright's own version of this +# check reported "validation passed" against an image with sixteen of them +# missing, so this one reads ldd directly. +missing=$(ldd "$SHELL_BIN" 2>/dev/null | awk '/not found/ { print $1 }' | sort -u) +if [ -n "$missing" ]; then + echo "the browser baseline is missing shared libraries this image does not install:" >&2 + echo "$missing" >&2 + exit 1 +fi + +# It also has to actually start. --version is the cheapest execution that +# proves the dynamic linker, the CPU baseline and the file mode all agree, and +# it must report the Chrome for Testing build this script was told to install. +got=$("$SHELL_BIN" --version 2>&1 || true) +case "$got" in + *"$CHROMIUM_VERSION"*) ;; + *) echo "the installed browser reports '${got}', not ${CHROMIUM_VERSION}" >&2; exit 1 ;; +esac + +# What a reader holding only a digest needs in order to answer "which browser, +# which Playwright, and where". Read by /usr/local/bin/rainier-browsers and by +# scripts/session-image-smoke.sh; a target list is not evidence. +cat > /usr/local/share/rainier-browsers.json <> /usr/local/share/rainier-browser-size.txt +chmod 0644 /usr/local/share/rainier-browser-size.txt diff --git a/images/session/browsers/rainier-browsers b/images/session/browsers/rainier-browsers new file mode 100755 index 00000000..6b3b5d8c --- /dev/null +++ b/images/session/browsers/rainier-browsers @@ -0,0 +1,133 @@ +#!/bin/sh +# rainier-browsers — the session image's preinstalled browser baseline, and the +# link between it and the cache a project's own Playwright reads. +# +# The baseline lives root-owned under /usr/local/lib/rainier-browsers, on the +# read-only rootfs. Playwright looks for browsers in +# $PLAYWRIGHT_BROWSERS_PATH, which this image sets to +# /workspace/.cache/ms-playwright — writable, on the volume, and the same place +# Playwright would have chosen on its own from $XDG_CACHE_HOME. `link` fills +# that cache with directories whose payload is a symlink into the baseline, so +# a fresh session launches the pinned browser with nothing downloaded, and a +# project that pins a different Playwright still installs its own revision +# beside it and wins. +# +# The image runs `link` at build time, and docker copies the result onto a +# freshly created workspace volume, so an ordinary session never has to run +# this. It is here for the two cases that are not ordinary: a workspace volume +# created before this image existed, and a `playwright install` that pruned the +# links as unused (it does that to any browser directory no linked Playwright +# asks for, which is the correct behaviour and only ever removes the links). +# +# It installs nothing, downloads nothing, needs no privilege, and never touches +# a browser directory a project installed itself. +set -eu + +PREFIX=${RAINIER_BROWSERS_PREFIX:-/usr/local/lib/rainier-browsers} +MANIFEST=/usr/local/share/rainier-browsers.json +# The marker that says a browser directory in the cache is a link to the +# baseline rather than something a project downloaded. Without it `link` would +# have to guess, and guessing wrong means deleting somebody's install. +OURS=.rainier-baseline + +usage() { + cat <<'USAGE' +usage: rainier-browsers + + status what is installed, where, and what a project's Playwright resolves + link (re)create the baseline's entries in the Playwright browser cache + path print the Playwright browser cache directory + version print the pinned Playwright version and Chromium build + +The baseline is read-only. A project that needs another browser or another +revision runs its own `npx playwright install`, which writes into the cache +`path` prints and needs cdn.playwright.dev. +USAGE +} + +registry_dir() { + if [ -n "${PLAYWRIGHT_BROWSERS_PATH:-}" ]; then + printf '%s\n' "$PLAYWRIGHT_BROWSERS_PATH" + else + printf '%s/ms-playwright\n' "${XDG_CACHE_HOME:-$HOME/.cache}" + fi +} + +playwright_version() { + [ -r "$MANIFEST" ] || { printf 'unknown\n'; return 0; } + sed -n 's/.*"playwrightVersion": *"\([^"]*\)".*/\1/p' "$MANIFEST" | head -1 +} + +# A cache entry is the baseline's to manage only while it still says so. A +# project's own install replaces the whole directory, marker included. +is_ours() { [ -f "$1/$OURS" ]; } + +link_one() { + src=$1 dst=$2 + if [ -e "$dst" ] && ! is_ours "$dst"; then + printf 'skip %s (installed by this workspace, not the baseline)\n' "$dst" + return 0 + fi + mkdir -p "$dst" + printf '%s\n' "$src" > "$dst/$OURS" + # Playwright's two markers. They are ordinary writable files in the cache + # rather than symlinks into the read-only baseline, because Playwright + # rewrites DEPENDENCIES_VALIDATED every thirty days and a failed write there + # costs an ldd sweep on every launch afterwards. + : > "$dst/INSTALLATION_COMPLETE" + : > "$dst/DEPENDENCIES_VALIDATED" + for entry in "$src"/*; do + name=${entry##*/} + case "$name" in + INSTALLATION_COMPLETE|DEPENDENCIES_VALIDATED|"$OURS"|'*') continue ;; + esac + rm -rf "$dst/$name" + ln -s "$entry" "$dst/$name" + done + printf 'link %s -> %s\n' "$dst" "$src" +} + +cmd_link() { + [ -d "$PREFIX" ] || { echo "no browser baseline at $PREFIX" >&2; exit 1; } + reg=$(registry_dir) + mkdir -p "$reg" + for src in "$PREFIX"/*; do + [ -d "$src" ] || continue + link_one "$src" "$reg/${src##*/}" + done +} + +cmd_status() { + reg=$(registry_dir) + printf 'playwright pin %s\n' "$(playwright_version)" + printf 'baseline %s\n' "$PREFIX" + printf 'browser cache %s\n' "$reg" + [ -d "$PREFIX" ] || { echo "the baseline is missing" >&2; exit 1; } + for src in "$PREFIX"/*; do + [ -d "$src" ] || continue + name=${src##*/} + state=absent + if [ -d "$reg/$name" ]; then + if is_ours "$reg/$name"; then state=linked; else state="installed by this workspace"; fi + fi + printf '%-40s %s\n' "$name" "$state" + done + # The one thing a version table cannot tell you: whether the binary in the + # cache runs in this container, as this user, right now. + for exe in "$reg"/*/*/chrome-headless-shell; do + [ -x "$exe" ] || continue + printf 'runs %s (%s)\n' "$("$exe" --version 2>&1 | head -1)" "$exe" + done +} + +case "${1:-}" in + status) cmd_status ;; + link) cmd_link ;; + path) registry_dir ;; + version) + printf 'playwright %s\n' "$(playwright_version)" + sed -n 's/.*"browserVersion": *"\([^"]*\)".*/chromium \1/p' "$MANIFEST" 2>/dev/null | head -1 + ;; + -h|--help|help|'') usage ;; + *) usage >&2; exit 2 ;; +esac diff --git a/internal/driver/image_browser_test.go b/internal/driver/image_browser_test.go new file mode 100644 index 00000000..bc942768 --- /dev/null +++ b/internal/driver/image_browser_test.go @@ -0,0 +1,398 @@ +package driver + +import ( + "os" + "path/filepath" + "regexp" + "strings" + "testing" +) + +// Browser testing is the one developer capability whose usual installation +// instruction — `npx playwright install --with-deps` — cannot run in a Rainier +// session at all: its --with-deps half is `sudo apt-get install`, and there is +// no sudo, no writable rootfs and no package archive on the egress allowlist. +// So the shared libraries are a build-time layer, the browser is a +// checksum-pinned artifact beside the rest of the toolchain, and neither is +// something a session can be asked to acquire later. +// +// These read the Dockerfile and images/session/browsers.sh as text, for the +// same reason the rest of image_contract_test.go does: this is the half of the +// contract that can be checked on a machine with no docker, which is where +// Dockerfile edits get made. scripts/session-image-smoke.sh runs the browser. + +func sessionBrowsersScript(t *testing.T) string { + t.Helper() + b, err := os.ReadFile("../../images/session/browsers.sh") + if err != nil { + t.Fatalf("reading the session image's browser install script: %v", err) + } + return string(b) +} + +// TestSessionImageInstallsTheBrowserDependencies: Playwright's own +// debian12-x64 chromium dependency list, named in the Dockerfile because the +// tool that knows it needs root to act on it. Sixteen of these are absent from +// the base image, and each absence is the same failure — "error while loading +// shared libraries" on somebody's first test run — which no version check and, +// measured against this base, not even Playwright's own host-requirements +// validation reports. +func TestSessionImageInstallsTheBrowserDependencies(t *testing.T) { + df := sessionDockerfile(t) + // packages/playwright-core/src/server/registry/nativeDeps.ts, deps + // ["debian12-x64"].chromium, in full. + for _, pkg := range []string{ + "libasound2", "libatk-bridge2.0-0", "libatk1.0-0", "libatspi2.0-0", + "libcairo2", "libcups2", "libdbus-1-3", "libdrm2", "libgbm1", "libglib2.0-0", + "libnspr4", "libnss3", "libpango-1.0-0", + "libx11-6", "libxcb1", "libxcomposite1", "libxdamage1", "libxext6", + "libxfixes3", "libxkbcommon0", "libxrandr2", + } { + if !regexp.MustCompile(`(?m)(^|\s)` + regexp.QuoteMeta(pkg) + `(\s|\\|$)`).MatchString(df) { + t.Errorf("the session image no longer installs %q; Chromium will not load", pkg) + } + } + // A browser with no fonts renders every glyph as a box, which makes a + // screenshot artifact useless and a text-measuring assertion a flake. This + // is a rendering dependency, not a nicety. + for _, font := range []string{"fontconfig", "fonts-liberation", "fonts-dejavu-core", "fonts-noto-color-emoji"} { + if !strings.Contains(df, font) { + t.Errorf("the session image installs no %q; a Chromium with no fonts renders tofu and its screenshots are worthless", font) + } + } +} + +// TestSessionImageInstallsNoGlobalPlaywright is the version-matching rule, and +// it is a prohibition rather than a pin. +// +// The version that drives a project's tests has to be the version in that +// project's lockfile: Playwright's client and its browser revision are one +// artifact, and a `playwright` on PATH from somewhere else is picked up by a +// bare `npx playwright` and silently drives the wrong one. The image therefore +// installs BROWSERS and no Playwright at all. +func TestSessionImageInstallsNoGlobalPlaywright(t *testing.T) { + df := sessionDockerfile(t) + for _, bad := range []string{ + "npm install --global playwright", "npm install -g playwright", + "@playwright/test", "npm install --global --no-audit --no-fund \"playwright", + } { + if strings.Contains(df, bad) { + t.Errorf("the Dockerfile contains %q; a globally installed Playwright hijacks `npx playwright` from the project's own pinned one", bad) + } + } + if regexp.MustCompile(`npm install [^\n]*\bplaywright\b`).MatchString(df) { + t.Error("the Dockerfile npm-installs Playwright; only the browsers belong in the image") + } +} + +// TestSessionImagePinsOneBrowserBaseline: the browser is an upstream download +// like Go or Codex, and it is pinned the same way — a version in the URL and a +// SHA-256 in the repository, checked before extraction. A baseline that +// resolved "the latest Chromium" would change under a digest that is supposed +// to describe it, and would stop matching the Playwright version it is the +// right browser for. +func TestSessionImagePinsOneBrowserBaseline(t *testing.T) { + df := sessionDockerfile(t) + sh := sessionBrowsersScript(t) + + for _, arg := range []string{ + "PLAYWRIGHT_VERSION", "CHROMIUM_VERSION", "CHROMIUM_REVISION", "PLAYWRIGHT_FFMPEG_REVISION", + } { + m := regexp.MustCompile(`(?m)^ARG ` + arg + `=(\S+)$`).FindStringSubmatch(df) + if m == nil { + t.Errorf("the Dockerfile declares no %s; the browser baseline would not be pinned to anything", arg) + continue + } + if strings.Contains(m[1], "latest") { + t.Errorf("%s is %q", arg, m[1]) + } + } + + if !strings.Contains(sh, "sha256sum --check") { + t.Error("the browser install script never verifies a checksum") + } + // Two artifacts for each of two architectures. + if sums := regexp.MustCompile(`[A-Z_]+SHA=([0-9a-f]{64})`).FindAllString(sh, -1); len(sums) < 4 { + t.Errorf("the browser script records %d checksums; it fetches two artifacts for each of two architectures", len(sums)) + } + if regexp.MustCompile(`\|\s*(ba)?sh(\s|$)`).MatchString(sh) { + t.Error("the browser script pipes a download into a shell") + } + for _, moving := range []string{"/latest/download", "releases/latest", "@latest"} { + if strings.Contains(sh, moving) { + t.Errorf("the browser script resolves %q at build time instead of naming a version", moving) + } + } + // The check that costs nothing at build time and saves the failure that is + // hardest to act on from inside a session: a browser whose libraries are + // not in this image. + if !strings.Contains(sh, "not found") || !strings.Contains(sh, "ldd ") { + t.Error("the browser script does not ldd the installed browser; a missing apt line would ship as a runtime failure instead of a failed build") + } + // And that it starts, reporting the build it was told to install. + if !strings.Contains(sh, "--version") || !strings.Contains(sh, "$CHROMIUM_VERSION") { + t.Error("the browser script never runs the browser it installed or checks which build it is") + } + for _, arch := range []string{"amd64", "arm64"} { + if !strings.Contains(sh, arch) { + t.Errorf("the browser script has no %s row", arch) + } + } +} + +// TestSessionImageBrowserCacheLivesOnTheWorkspace: the same rule as every +// other cache, and one extra that is a security boundary rather than a +// convenience. +// +// Playwright reads PLAYWRIGHT_BROWSERS_PATH, which has to be writable — a +// project pinned to a different Playwright installs its own revision there and +// must win. The BASELINE that path points into is root-owned on the read-only +// rootfs, because a session user who could rewrite the browser binary could +// rewrite what every later test run executes. The two meet through symlinks, +// and a `chown -R` that dereferenced them would hand the browser to the +// session user, which is why the seed's chown is -h. +func TestSessionImageBrowserCacheLivesOnTheWorkspace(t *testing.T) { + df := sessionDockerfile(t) + + m := regexp.MustCompile(`(?m)^\s*PLAYWRIGHT_BROWSERS_PATH=(\S+)`).FindStringSubmatch(df) + if m == nil { + t.Fatal("the image sets no PLAYWRIGHT_BROWSERS_PATH; Playwright would fall back to a path this image has not qualified") + } + if !strings.HasPrefix(m[1], workspaceMount+"/") { + t.Errorf("PLAYWRIGHT_BROWSERS_PATH is %s, which is not on the writable %s volume; a project could not install its own browser revision", m[1], workspaceMount) + } + + if !strings.Contains(df, "/usr/local/lib/rainier-browsers") { + t.Error("the browser baseline is not under /usr/local/lib; it must be root-owned like sessiond and the agents") + } + if strings.Contains(df, "/opt/rainier-env/browsers") || strings.Contains(df, "chown -R 1000:1000 /usr/local/lib") { + t.Error("the browser baseline is in, or given to, the session-writable prefix") + } + if !strings.Contains(df, "chown -Rh 1000:1000 /workspace/.cache") { + t.Error("the browser cache seed is chowned without -h; a recursive chown that dereferences the seed's symlinks gives the session user the root-owned browser it is about to execute") + } + // The other half of the same fact, and the one that would take the fleet + // down rather than merely weaken it. The initializer chowns the whole + // freshly copied volume as root with a READ-ONLY rootfs; the volume now + // contains symlinks onto that rootfs. GNU chown -R does not dereference + // (it traverses -P and lchown()s the link), so this works. -L or + // --dereference would fail the init job with EROFS on every session + // create, and would give the session user the browser binary on any host + // where it did not. + if strings.Contains(initWorkspaceScript, "-L") || strings.Contains(initWorkspaceScript, "--dereference") { + t.Errorf("the volume initializer dereferences symlinks (%q); the workspace seed points at the read-only rootfs", initWorkspaceScript) + } + if !strings.Contains(initWorkspaceScript, "chown -R "+sessionUser) { + t.Errorf("the volume initializer no longer chowns the workspace as expected: %q", initWorkspaceScript) + } + if !strings.Contains(df, "rainier-browsers link") { + t.Error("the image never links the baseline into the browser cache; a fresh session would download a browser it already has") + } + if !strings.Contains(df, "chmod 0755 /usr/local/bin/rainier-browsers") { + t.Error("rainier-browsers is not installed root-owned and executable in /usr/local/bin") + } +} + +// TestSessionImageDoesNotDisableBrowserSafety: nothing in the image may turn +// off a check on the user's behalf. PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS +// would hide exactly the missing-library failure this image exists to prevent, +// PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD would take away a project's ability to +// install the version it actually pins, and a download host baked into the +// image would silently redirect where a browser comes from. +func TestSessionImageDoesNotDisableBrowserSafety(t *testing.T) { + df := sessionDockerfile(t) + for _, v := range []string{ + "PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS", + "PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD", + "PLAYWRIGHT_CHROMIUM_DOWNLOAD_HOST", + "PLAYWRIGHT_DOWNLOAD_HOST", + } { + if regexp.MustCompile(`(?m)^\s*` + v + `=`).MatchString(df) { + t.Errorf("the image sets %s; a session must keep both the check and the choice", v) + } + } + // The image ships no launch flags at all, and in particular does not ship + // the one that turns Chromium's own sandbox off for everybody. Whether a + // suite enables Chromium's sandbox is the suite's call (Playwright's own + // default is chromiumSandbox: false); it is not the image's to make. + if strings.Contains(df, "--no-sandbox") { + t.Error("the Dockerfile names --no-sandbox; the image does not choose a project's browser launch flags") + } +} + +// TestDockerGrantsNoBrowserPrivilege: the isolation half of the same change. +// The usual answers to "Chromium will not start in a container" are a +// privileged container, an added capability, seccomp=unconfined, the host's +// network, or a wider host mount. None of them is here, and a browser baseline +// in the image is not a reason for any of them to arrive later: the driver +// runs the container that runs the browser with exactly the restrictions it +// ran before. +func TestDockerGrantsNoBrowserPrivilege(t *testing.T) { + d := &Docker{opts: DockerOpts{Label: "rainier.session", Network: "rainier-int"}} + args := strings.Join(d.runArgs(Spec{SessionID: "sess_browser"}, "img"), " ") + for _, forbidden := range []string{ + "--privileged", + "--cap-add", + "seccomp=unconfined", + "apparmor=unconfined", + "--network host", + "--device", + "--ipc=host", + "--ipc host", + "/dev/shm:", + } { + if strings.Contains(args, forbidden) { + t.Errorf("runArgs contains %q; nothing about running a browser justifies relaxing the session's isolation", forbidden) + } + } + // The restrictions runArgs actually applies to a session with no setup + // script. --cap-drop is deliberately NOT in this list: the driver does not + // pass it to the session container today (only to the volume initializer), + // and a test that asserted it would be asserting the documentation rather + // than the code. See docs/session-image.md. + for _, required := range []string{"--user " + sessionUser, "no-new-privileges", "--read-only", "--tmpfs /tmp"} { + if !strings.Contains(args, required) { + t.Errorf("runArgs no longer contains %q", required) + } + } +} + +// --- the helper, run rather than read --------------------------------------- + +func browsersHelper(t *testing.T) string { + t.Helper() + p, err := filepath.Abs("../../images/session/browsers/rainier-browsers") + if err != nil { + t.Fatal(err) + } + return p +} + +// fakeBaseline is a browser baseline as far as the helper can see it: one +// browser directory holding a payload directory, and Playwright's two markers. +func fakeBaseline(t *testing.T) string { + t.Helper() + prefix := t.TempDir() + payload := filepath.Join(prefix, "chromium_headless_shell-1243", "chrome-headless-shell-linux64") + if err := os.MkdirAll(payload, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(payload, "chrome-headless-shell"), []byte("#!/bin/sh\necho 'Chromium 153.0.8010.12'\n"), 0o755); err != nil { + t.Fatal(err) + } + for _, marker := range []string{"INSTALLATION_COMPLETE", "DEPENDENCIES_VALIDATED"} { + if err := os.WriteFile(filepath.Join(prefix, "chromium_headless_shell-1243", marker), nil, 0o644); err != nil { + t.Fatal(err) + } + } + return prefix +} + +func TestSessionImageBrowserHelperLinksTheBaselineIntoTheCache(t *testing.T) { + prefix := fakeBaseline(t) + cache := filepath.Join(t.TempDir(), "ms-playwright") + run := runHelper(t, browsersHelper(t), t.TempDir(), []string{ + "RAINIER_BROWSERS_PREFIX=" + prefix, + "PLAYWRIGHT_BROWSERS_PATH=" + cache, + }, "link") + if run.status != 0 { + t.Fatalf("link exited %d: %s", run.status, run.out) + } + dir := filepath.Join(cache, "chromium_headless_shell-1243") + // Playwright decides a browser is installed by the presence of this file, + // and re-validates its dependencies every thirty days by rewriting the + // other. Both are real files in the writable cache rather than symlinks + // into the read-only baseline, or that rewrite fails and every launch pays + // for an ldd sweep it cannot record the result of. + for _, marker := range []string{"INSTALLATION_COMPLETE", "DEPENDENCIES_VALIDATED"} { + info, err := os.Lstat(filepath.Join(dir, marker)) + if err != nil { + t.Fatalf("%s: %v", marker, err) + } + if info.Mode()&os.ModeSymlink != 0 { + t.Errorf("%s is a symlink into the read-only baseline; Playwright has to be able to rewrite it", marker) + } + } + // And the payload is a link, not a copy: a quarter of a gigabyte per + // session volume is the thing this arrangement exists to avoid. + payload := filepath.Join(dir, "chrome-headless-shell-linux64") + info, err := os.Lstat(payload) + if err != nil { + t.Fatal(err) + } + if info.Mode()&os.ModeSymlink == 0 { + t.Fatal("the browser payload was copied into the workspace cache instead of linked") + } + if _, err := os.Stat(filepath.Join(payload, "chrome-headless-shell")); err != nil { + t.Fatalf("the linked payload does not resolve to an executable: %v", err) + } +} + +func TestSessionImageBrowserHelperIsIdempotent(t *testing.T) { + prefix := fakeBaseline(t) + cache := filepath.Join(t.TempDir(), "ms-playwright") + env := []string{"RAINIER_BROWSERS_PREFIX=" + prefix, "PLAYWRIGHT_BROWSERS_PATH=" + cache} + for i := 0; i < 2; i++ { + if run := runHelper(t, browsersHelper(t), t.TempDir(), env, "link"); run.status != 0 { + t.Fatalf("link %d exited %d: %s", i, run.status, run.out) + } + } + entries, err := os.ReadDir(filepath.Join(cache, "chromium_headless_shell-1243")) + if err != nil { + t.Fatal(err) + } + // The marker, Playwright's two, and one payload link. A second run that + // stacked links or nested a link inside its own target would show here. + if len(entries) != 4 { + names := []string{} + for _, e := range entries { + names = append(names, e.Name()) + } + t.Fatalf("a second link produced %v", names) + } +} + +// A project that installs its own browser owns that directory. `link` has to +// leave it alone even though the name collides: overwriting it would delete a +// download the project's own lockfile asked for, and would put the baseline's +// revision behind a name that means a different one. +func TestSessionImageBrowserHelperLeavesAProjectInstallAlone(t *testing.T) { + prefix := fakeBaseline(t) + cache := filepath.Join(t.TempDir(), "ms-playwright") + mine := filepath.Join(cache, "chromium_headless_shell-1243", "chrome-headless-shell-linux64") + if err := os.MkdirAll(mine, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(mine, "chrome-headless-shell"), []byte("mine"), 0o755); err != nil { + t.Fatal(err) + } + run := runHelper(t, browsersHelper(t), t.TempDir(), []string{ + "RAINIER_BROWSERS_PREFIX=" + prefix, + "PLAYWRIGHT_BROWSERS_PATH=" + cache, + }, "link") + if run.status != 0 { + t.Fatalf("link exited %d: %s", run.status, run.out) + } + b, err := os.ReadFile(filepath.Join(mine, "chrome-headless-shell")) + if err != nil || string(b) != "mine" { + t.Fatalf("the project's own install was replaced: %q, %v", string(b), err) + } + if !strings.Contains(run.out, "skip") { + t.Errorf("link did not report that it skipped the project's own install: %s", run.out) + } +} + +// The path a project's Playwright will read has to be the path this helper +// reports, or the helper is describing a different machine. +func TestSessionImageBrowserHelperReportsThePathPlaywrightReads(t *testing.T) { + cache := filepath.Join(t.TempDir(), "ms-playwright") + run := runHelper(t, browsersHelper(t), t.TempDir(), []string{"PLAYWRIGHT_BROWSERS_PATH=" + cache}, "path") + if got := strings.TrimSpace(run.out); got != cache { + t.Fatalf("path = %q, want %q", got, cache) + } + // With no override, Playwright computes $XDG_CACHE_HOME/ms-playwright. + run = runHelper(t, browsersHelper(t), t.TempDir(), []string{"XDG_CACHE_HOME=/workspace/.cache"}, "path") + if got := strings.TrimSpace(run.out); got != "/workspace/.cache/ms-playwright" { + t.Fatalf("path with only XDG_CACHE_HOME = %q", got) + } +} diff --git a/scripts/session-image-browser-e2e.sh b/scripts/session-image-browser-e2e.sh new file mode 100755 index 00000000..1c03293c --- /dev/null +++ b/scripts/session-image-browser-e2e.sh @@ -0,0 +1,183 @@ +#!/usr/bin/env bash +# scripts/session-image-browser-e2e.sh — can a developer run their project's +# Playwright tests in a fresh session, with nothing set up first? +# +# scripts/session-image-smoke.sh answers the image half of that offline: the +# browser is present, root-owned, linked into the cache and able to render. +# This answers the whole of it, through the tool a developer actually uses, on +# a project that has never been in this image: install locked dependencies, +# start a loopback web server, drive a real Chromium at a desktop and a phone +# viewport, assert on what it laid out, write a screenshot, leave a trace and a +# video behind when a test fails, and leave no process behind when it does not. +# Then do it again, because "works once" and "works" are different claims. +# +# The restrictions are the driver's, copied from internal/driver.runArgs: uid +# 1000, no-new-privileges, a read-only rootfs, a noexec tmpfs on /tmp, docker's +# 64 MiB /dev/shm, a workspace volume, and no host mount of any kind. Nothing +# here is relaxed to make a step pass. +# +# The ONE difference from the smoke, and it is deliberate: `npm ci` gets a +# network, because installing a project's locked dependencies IS a network +# operation and pretending otherwise would test nothing. Every step after it — +# both test runs and the failure run — is back on --network none, which is what +# makes "the preinstalled browser needed no download" an observation rather +# than a hope. +# +# Usage: scripts/session-image-browser-e2e.sh [image] +# Env: DOCKER= STEP_TIMEOUT= KEEP=1 +# Exit: 0 every check passed, 1 a check failed, 2 setup or usage error. +set -uo pipefail + +IMAGE=${1:-rainier-session:smoke} +DOCKER=${DOCKER:-docker} +STEP_TIMEOUT=${STEP_TIMEOUT:-600} + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +source "$SCRIPT_DIR/session-image-checks.sh" + +SAMPLE_DIR="$SCRIPT_DIR/../images/session/browser-sample" +[ -f "$SAMPLE_DIR/package-lock.json" ] \ + || { echo "no sample project at $SAMPLE_DIR" >&2; exit 2; } +command -v "$DOCKER" >/dev/null 2>&1 || { echo "no docker executable ($DOCKER); set DOCKER=" >&2; exit 2; } +"$DOCKER" image inspect "$IMAGE" >/dev/null 2>&1 \ + || { echo "image $IMAGE is not present; build it first (make session-image)" >&2; exit 2; } + +CHROMIUM_VERSION=$(sed -n 's/^ARG CHROMIUM_VERSION=//p' "$SCRIPT_DIR/../Dockerfile") +[ -n "$CHROMIUM_VERSION" ] || { echo "missing CHROMIUM_VERSION pin" >&2; exit 2; } + +SUFFIX=$(od -An -tx1 -N6 /dev/urandom | tr -d ' \n') +WS_VOL="rainier-browser-ws-$SUFFIX" + +cleanup() { + [ "${KEEP:-0}" = 1 ] && return 0 + "$DOCKER" volume rm -f "$WS_VOL" >/dev/null 2>&1 + return 0 +} +trap cleanup EXIT + +# The driver's own volume preparation, unchanged: root with CAP_CHOWN and +# nothing else, no network, the image's entrypoint never running. This is also +# the step that copies the image's /workspace — the seeded browser cache +# included — onto the fresh volume, which is the mechanism the whole +# no-download claim rests on. +"$DOCKER" volume create "$WS_VOL" >/dev/null || { echo "could not create the workspace volume" >&2; exit 2; } +"$DOCKER" run --rm --network none --user 0:0 \ + --security-opt no-new-privileges --cap-drop ALL --cap-add CHOWN --read-only \ + -v "$WS_VOL:/workspace" --entrypoint sh "$IMAGE" \ + -c 'mkdir -p /workspace/.rainier && chown -R 1000:1000 /workspace' >/dev/null \ + || { echo "could not initialize the workspace volume" >&2; exit 2; } + +# step — one session-shaped container. The network argument +# is the only thing that varies between the install step and every other one, +# and it is spelled out at each call site rather than defaulted, because which +# steps are allowed to reach the internet is the interesting part of this file. +step() { + local network=$1 program=$2 + "$DOCKER" run --rm \ + --network "$network" \ + --user 1000:1000 \ + --security-opt no-new-privileges \ + --cap-drop ALL \ + --read-only \ + --tmpfs /tmp \ + --memory 3g --pids-limit 1024 \ + -v "$WS_VOL:/workspace" \ + -w /workspace/browser-sample \ + -e CI=1 \ + --entrypoint timeout "$IMAGE" -k 10 "$STEP_TIMEOUT" /bin/bash -c "set -uo pipefail +$program" 2>&1 +} +offline() { step none "$1"; } +online() { step bridge "$1"; } + +echo "== $IMAGE" +echo +echo "-- a project that has never been in this image" + +# Staged as the session user through a pipe rather than a bind mount: a host +# mount is exactly what a session does not get, and a qualification that used +# one would be qualifying a different container. +if ! tar -C "$SCRIPT_DIR/../images/session" -c browser-sample \ + | "$DOCKER" run --rm -i --user 1000:1000 --network none \ + --security-opt no-new-privileges --cap-drop ALL \ + -v "$WS_VOL:/workspace" -w /workspace \ + --entrypoint tar "$IMAGE" -x; then + echo "could not stage the sample project onto the workspace volume" >&2 + exit 2 +fi + +check "the sample project arrived with a lockfile and no node_modules" "staged" ' + test -f package-lock.json || { echo "no lockfile"; exit 1; } + test -d node_modules && { echo "NODE_MODULES-PRESENT"; exit 1; } + echo staged' offline + +check "npm ci installs the project locked dependencies" "playwright-installed" ' + npm ci --no-audit --no-fund >/dev/null 2>&1 || { echo "npm ci failed"; exit 1; } + test -x node_modules/.bin/playwright || { echo "no playwright in node_modules"; exit 1; } + # The version that runs has to be the one the lockfile pins, not one the + # image supplied: `npx playwright` resolves the project binary first, and + # there is no global Playwright in this image for it to fall back to. + node -p "require(\"playwright-core/package.json\").version" + echo playwright-installed' online + +# From here on: no network at all. A step that needed a download would fail, +# which is the whole point of preinstalling the browser. +# The default reporters from the sample project own configuration, so the HTML +# report a developer opens is written by the same run that is being checked. +check "the suite runs offline, at a desktop and a phone viewport, with nothing downloaded" "6 passed" ' + npx playwright test 2>&1 | tail -30' offline + +check "it runs a second time in the same workspace, still offline" "6 passed" ' + npx playwright test --reporter=list 2>&1 | tail -30' offline + +check "nothing was downloaded into the browser cache" "cache-unchanged" ' + # Every entry the cache carries is still one of the baseline links. A real + # directory here would mean the project had to fetch a browser, which is a + # legitimate thing for a project on another Playwright to do and exactly what + # this image exists to make unnecessary for the pinned one. + for dir in "$PLAYWRIGHT_BROWSERS_PATH"/*/; do + [ -f "$dir/.rainier-baseline" ] || { echo "UNEXPECTED $dir"; exit 1; } + done + echo cache-unchanged' offline + +check "the browser and the web server both exit with the suite" "nothing-left" ' + browsers_alive() { + for p in /proc/[0-9]*; do + case "$(readlink "$p/exe" 2>/dev/null)" in *chrome-headless-shell*) return 0 ;; esac + done + return 1 + } + npx playwright test --reporter=line >/dev/null 2>&1 || { echo "the suite failed"; exit 1; } + sleep 1 + browsers_alive && { echo BROWSER-STILL-RUNNING; exit 1; } + # Playwright started the web server and owns stopping it. A listener left on + # loopback would collide with the next run in the same workspace, which is + # the failure a suspend-and-resume workflow hits first. + ss -ltn 2>/dev/null | grep -q ":8973" && { echo SERVER-STILL-LISTENING; ss -ltnp; exit 1; } + echo nothing-left' offline + +check "a failing test leaves a trace, a screenshot and a video behind" "artifacts-ok" ' + rm -rf test-results + RAINIER_BROWSER_SMOKE_FAIL=1 npx playwright test artifacts.spec.js --reporter=line >/dev/null 2>&1 + test -d test-results || { echo "no test-results directory"; exit 1; } + for want in "trace.zip" "test-failed-1.png" ".webm"; do + find test-results -name "*$want*" -size +0 | grep -q . || { + echo "MISSING $want"; find test-results -type f | head -20; exit 1; } + done + echo artifacts-ok' offline + +check "the report a developer opens was written to the workspace" "report-ok" ' + test -s playwright-report/index.html || { echo "no HTML report"; exit 1; } + echo report-ok' offline + +# What the run cost, on the record beside what it proved. +FOOTPRINT=$(offline ' + du -sh node_modules 2>/dev/null | cut -f1 | tr -d "\n" + printf " node_modules; " + du -sh "$PLAYWRIGHT_BROWSERS_PATH" 2>/dev/null | cut -f1 | tr -d "\n" + printf " browser cache on the volume\n"' | tail -1) +printf 'note workspace footprint: %s\n' "$FOOTPRINT" +note "browser workspace footprint" "$FOOTPRINT" + +printf '\n%d passed, %d failed\n' "$PASS" "$FAIL" +[ "$FAIL" -eq 0 ] || exit 1 diff --git a/scripts/session-image-checks.sh b/scripts/session-image-checks.sh index 7369f6fb..8537559e 100644 --- a/scripts/session-image-checks.sh +++ b/scripts/session-image-checks.sh @@ -1,6 +1,35 @@ #!/usr/bin/env bash -# Small assertion boundary shared by the container smoke and its shell tests. -# The caller supplies probe (the execution boundary), ok and bad (reporting). +# Small assertion boundary shared by the image qualification scripts and their +# shell tests. The caller supplies probe (the execution boundary); reporting is +# here, because two qualification scripts annotating a pull request in two +# slightly different ways is how one of them quietly stops annotating at all. +# A caller that wants different reporting redefines ok and bad after sourcing. + +PASS=0 FAIL=0 + +# In GitHub Actions the job log is the only record of a failed qualification, +# and it is not always reachable from wherever the fix is being made — a +# session's egress allowlist does not carry the Actions log host, for one. +# Emitting each failure as a workflow annotation puts the check's name and its +# detail on the pull request itself, where the check status already is. Inert +# outside Actions, and it reports; it never changes what passes. +# A workflow command's PROPERTIES are comma-separated and colon-terminated, so +# a title carrying either has to be escaped or it truncates the annotation — +# and most check names contain a comma. The message half only has to survive +# the newline. +wf_title() { printf '%s' "$1" | sed 's/%/%25/g; s/\r/%0D/g; s/:/%3A/g; s/,/%2C/g'; } +wf_body() { printf '%s' "${1:-}" | cut -c1-2000 | sed 's/%/%25/g; s/\r/ /g' | awk '{printf "%s%%0A", $0}'; } +note() { + [ "${GITHUB_ACTIONS:-}" = true ] || return 0 + printf '::notice title=%s::%s\n' "$(wf_title "$1")" "$(wf_body "$2")" +} +annotate() { + [ "${GITHUB_ACTIONS:-}" = true ] || return 0 + printf '::error title=%s::%s\n' "$(wf_title "$1")" "$(wf_body "${2:-}")" +} +ok() { PASS=$((PASS+1)); printf 'ok %s\n' "$1"; } +bad() { FAIL=$((FAIL+1)); printf 'FAIL %s\n' "$1"; [ $# -gt 1 ] && printf ' %s\n' "$2"; annotate "$1" "${2:-}"; return 0; } + check() { local name=$1 want=$2 prog=$3 runner=${4:-probe} match=${5:-contains} out status=0 matched=1 out=$("$runner" "$prog") || status=$? diff --git a/scripts/session-image-smoke.sh b/scripts/session-image-smoke.sh index 70e0ddd6..8ad88de8 100755 --- a/scripts/session-image-smoke.sh +++ b/scripts/session-image-smoke.sh @@ -31,35 +31,13 @@ IMAGE=${1:-rainier-session:smoke} DOCKER=${DOCKER:-docker} PROBE_TIMEOUT=${PROBE_TIMEOUT:-240} +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +source "$SCRIPT_DIR/session-image-checks.sh" + command -v "$DOCKER" >/dev/null 2>&1 || { echo "no docker executable ($DOCKER); set DOCKER=" >&2; exit 2; } "$DOCKER" image inspect "$IMAGE" >/dev/null 2>&1 \ || { echo "image $IMAGE is not present; build it first (make session-image)" >&2; exit 2; } -PASS=0 FAIL=0 - -# In GitHub Actions the job log is the only record of a failed qualification, -# and it is not always reachable from wherever the fix is being made — a -# session's egress allowlist does not carry the Actions log host, for one. -# Emitting each failure as a workflow annotation puts the check's name and its -# detail on the pull request itself, where the check status already is. Inert -# outside Actions, and it reports; it never changes what passes. -# A workflow command's PROPERTIES are comma-separated and colon-terminated, so -# a title carrying either has to be escaped or it truncates the annotation — -# and most of the check names below contain a comma. The message half only has -# to survive the newline. -wf_title() { printf '%s' "$1" | sed 's/%/%25/g; s/\r/%0D/g; s/:/%3A/g; s/,/%2C/g'; } -wf_body() { printf '%s' "${1:-}" | cut -c1-2000 | sed 's/%/%25/g; s/\r/ /g' | awk '{printf "%s%%0A", $0}'; } -note() { - [ "${GITHUB_ACTIONS:-}" = true ] || return 0 - printf '::notice title=%s::%s\n' "$(wf_title "$1")" "$(wf_body "$2")" -} -annotate() { - [ "${GITHUB_ACTIONS:-}" = true ] || return 0 - printf '::error title=%s::%s\n' "$(wf_title "$1")" "$(wf_body "${2:-}")" -} -ok() { PASS=$((PASS+1)); printf 'ok %s\n' "$1"; } -bad() { FAIL=$((FAIL+1)); printf 'FAIL %s\n' "$1"; [ $# -gt 1 ] && printf ' %s\n' "$2"; annotate "$1" "${2:-}"; return 0; } - SUFFIX=$(od -An -tx1 -N6 /dev/urandom | tr -d ' \n') WS_VOL="rainier-smoke-ws-$SUFFIX" HOME_VOL="rainier-smoke-agents-$SUFFIX" @@ -138,14 +116,18 @@ $(declare -f brokered_gh_probe) $1" 2>&1 } -SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) -source "$SCRIPT_DIR/session-image-checks.sh" CLAUDE_VERSION=$(sed -n 's/^ARG CLAUDE_CODE_VERSION=//p' "$SCRIPT_DIR/../Dockerfile") CODEX_VERSION=$(sed -n 's/^ARG CODEX_VERSION=//p' "$SCRIPT_DIR/../Dockerfile") # Read, not hardcoded: a check that names 17 while the image builds an 18 does # not fail, it stops asking the question. PG_MAJOR=$(sed -n 's/^ARG POSTGRES_MAJOR=//p' "$SCRIPT_DIR/../Dockerfile") [[ "$PG_MAJOR" =~ ^[0-9]+$ ]] || { echo "missing or invalid POSTGRES_MAJOR pin" >&2; exit 2; } +CHROMIUM_VERSION=$(sed -n 's/^ARG CHROMIUM_VERSION=//p' "$SCRIPT_DIR/../Dockerfile") +CHROMIUM_REVISION=$(sed -n 's/^ARG CHROMIUM_REVISION=//p' "$SCRIPT_DIR/../Dockerfile") +PLAYWRIGHT_PIN=$(sed -n 's/^ARG PLAYWRIGHT_VERSION=//p' "$SCRIPT_DIR/../Dockerfile") +[[ "$CHROMIUM_VERSION" =~ ^[0-9]+(\.[0-9]+)+$ ]] || { echo "missing or invalid CHROMIUM_VERSION pin" >&2; exit 2; } +[[ "$CHROMIUM_REVISION" =~ ^[0-9]+$ ]] || { echo "missing or invalid CHROMIUM_REVISION pin" >&2; exit 2; } +[[ "$PLAYWRIGHT_PIN" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "missing or invalid PLAYWRIGHT_VERSION pin" >&2; exit 2; } for version in "$CLAUDE_VERSION" "$CODEX_VERSION"; do [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "missing or invalid agent version pin" >&2; exit 2; } done @@ -556,6 +538,165 @@ check "every service kept its durable state on the workspace volume, and none on test -e /var/lib/postgresql/'"$PG_MAJOR"'/main && { echo CLUSTER-ON-ROOTFS; exit 1; } echo service-state-ok' +echo +echo "-- browser testing" + +# `npx playwright install --with-deps` is what every project's CI runs and what +# a session cannot: its --with-deps half is an apt install as root, and a +# session has no escalation path, a read-only rootfs and no package archive on +# its allowlist. So the shared libraries are a build-time layer and the browser +# is a checksum-pinned artifact, and if either is wrong there is no in-session +# repair — which is why these are checks rather than documentation. +# +# Everything below runs in the ordinary probe: uid 1000, read-only rootfs, +# noexec /tmp, docker's 64 MiB /dev/shm, and NO NETWORK AT ALL. A browser that +# needed to download anything on first use would fail here, which is the point: +# a fresh session has to be able to run a test suite offline. +# +# The direct invocations below pass --no-sandbox because that is what +# Playwright itself passes: chromiumSandbox defaults to false in every +# Playwright release, so the launch this smoke imitates is the launch a project +# actually gets. Nothing in this image or this driver adds that flag, and the +# isolation a session relies on is the container's — uid 1000, +# no-new-privileges, a read-only rootfs, its own network namespace, and the +# host's seccomp and AppArmor policy. See docs/session-image.md. + +BROWSER_FIXTURE=' + b=$(find -L "$PLAYWRIGHT_BROWSERS_PATH" -maxdepth 3 -type f -name chrome-headless-shell 2>/dev/null | head -1) + [ -n "$b" ] || { echo "no chrome-headless-shell under $PLAYWRIGHT_BROWSERS_PATH"; exit 1; } + d=$(mktemp -d -p /workspace) || exit 1 + cat > "$d/page.html" <pending + +

Rainier browser smoke

+MMMMMMMMMM + + +HTML + # The flags Playwright passes, and nothing else: --disable-dev-shm-usage is + # in chromiumSwitches for every launch, which is why docker default 64 MiB + # /dev/shm is enough for a Playwright suite. + render() { "$b" --no-sandbox --disable-dev-shm-usage --disable-gpu --disable-breakpad \ + --user-data-dir="$d/profile" "$@" 2>&1; } + png_size() { python3 -c "import struct,sys; d=open(sys.argv[1],\"rb\").read(24); w,h=struct.unpack(\">II\", d[16:24]); print(w,h)" "$1"; } +' + +check "no Playwright is installed globally, so a project's own pin is the one that runs" "no-global-playwright" ' + if command -v playwright >/dev/null 2>&1; then echo "GLOBAL-PLAYWRIGHT $(command -v playwright)"; exit 1; fi + if command -v playwright-core >/dev/null 2>&1; then echo GLOBAL-PLAYWRIGHT-CORE; exit 1; fi + if grep -qi "playwright" /usr/local/share/rainier-npm-global.json; then echo GLOBAL-PLAYWRIGHT-PACKAGE; exit 1; fi + echo no-global-playwright' + +check "the browser baseline is root-owned and the session user cannot rewrite it" "baseline-held" ' + b=$(find /usr/local/lib/rainier-browsers -type f -name chrome-headless-shell | head -1) + [ -n "$b" ] || { echo "no browser baseline in the image"; exit 1; } + [ "$(stat -c %U "$b")" = root ] || { echo "the browser is owned by $(stat -c %U "$b")"; exit 1; } + if echo x > "$b" 2>/dev/null; then echo BROWSER-WRITABLE; exit 1; fi + echo baseline-held' + +check "a fresh workspace volume already carries the browser cache Playwright reads" "cache-linked" ' + [ "$PLAYWRIGHT_BROWSERS_PATH" = /workspace/.cache/ms-playwright ] \ + || { echo "PLAYWRIGHT_BROWSERS_PATH=$PLAYWRIGHT_BROWSERS_PATH"; exit 1; } + dir=$PLAYWRIGHT_BROWSERS_PATH/chromium_headless_shell-'"$CHROMIUM_REVISION"' + # The two files Playwright reads before it decides a browser is installed and + # whether its dependencies still need checking. Both have to be writable + # files on the volume, not links onto the read-only rootfs. + for m in INSTALLATION_COMPLETE DEPENDENCIES_VALIDATED; do + [ -f "$dir/$m" ] || { echo "no $m in $dir"; exit 1; } + [ -L "$dir/$m" ] && { echo "$m is a link onto the read-only rootfs"; exit 1; } + : > "$dir/$m" || { echo "$m is not writable"; exit 1; } + done + exe=$(find -L "$dir" -type f -name chrome-headless-shell | head -1) + [ -x "$exe" ] || { echo "the cache does not resolve to an executable browser"; exit 1; } + echo cache-linked' + +check "the preinstalled browser is exactly the build the Dockerfile pins" "$CHROMIUM_VERSION" ' + '"$BROWSER_FIXTURE"' + "$b" --version' + +check "every shared library the browser needs resolves in this image" "libs-resolved" ' + '"$BROWSER_FIXTURE"' + missing=$(ldd "$b" 2>/dev/null | awk "/not found/ { print \$1 }" | sort -u) + [ -z "$missing" ] || { echo "MISSING $missing"; exit 1; } + echo libs-resolved' + +check "the browser renders a page and writes a desktop-viewport screenshot, offline" "1280 800" ' + '"$BROWSER_FIXTURE"' + render --screenshot="$d/desktop.png" --window-size=1280,800 "file://$d/page.html" >/dev/null + [ -s "$d/desktop.png" ] || { echo "no screenshot was written"; exit 1; } + file "$d/desktop.png" | grep -q "PNG image" || { echo "not a PNG"; exit 1; } + png_size "$d/desktop.png"' + +check "the same page at a phone viewport produces a phone-sized screenshot" "390 844" ' + '"$BROWSER_FIXTURE"' + render --screenshot="$d/phone.png" --window-size=390,844 "file://$d/page.html" >/dev/null + [ -s "$d/phone.png" ] || { echo "no screenshot was written"; exit 1; } + png_size "$d/phone.png"' + +# A browser with no fonts still renders: it falls back to whatever it can find +# and lays text out with the wrong metrics, so a screenshot is boxes and a +# width assertion is a flake. Ten Arial capital Ms at 100px are 833px wide by +# the font, and Liberation Sans is metric-compatible with Arial by design — +# DejaVu, the usual fallback, gives 791. So the number below is a check that +# fontconfig resolved Arial to the font this image installed FOR that, not +# merely that some font exists. +check "Arial resolves to a metric-compatible font and text lays out at its real width" "font-metrics-ok" ' + '"$BROWSER_FIXTURE"' + fc-match Arial | grep -qi liberation || { echo "fc-match Arial = $(fc-match Arial)"; exit 1; } + fc-list | grep -qi emoji || { echo "no emoji font"; exit 1; } + w=$(render --dump-dom "file://$d/page.html" | sed -n "s/.*\([0-9]*\)<\/title>.*/\1/p" | head -1) + [ -n "$w" ] || { echo "the page did not report a measured width"; exit 1; } + [ "$w" -ge 800 ] && [ "$w" -le 870 ] || { echo "ten 100px Arial Ms measured ${w}px, not ~833"; exit 1; } + echo font-metrics-ok' + +check "the browser leaves no process behind after it exits" "no-browser-left" ' + '"$BROWSER_FIXTURE"' + # By resolved executable rather than by command line: the shell running this + # check has the string "chrome-headless-shell" in its own argv, so a pgrep -f + # would match itself and never pass. A zombie has no /proc/pid/exe, which is + # the right answer too: sessiond is PID 1 in a real session, and it reaps. + browsers_alive() { + for p in /proc/[0-9]*; do + case "$(readlink "$p/exe" 2>/dev/null)" in *chrome-headless-shell*) return 0 ;; esac + done + return 1 + } + render --screenshot="$d/x.png" --window-size=800,600 "file://$d/page.html" >/dev/null + for _ in 1 2 3 4 5 6 7 8 9 10; do + browsers_alive || break + sleep 0.5 + done + if browsers_alive; then + echo "BROWSER-STILL-RUNNING"; ps -eo pid,ppid,comm | head -20; exit 1 + fi + echo no-browser-left' + +check "rainier-browsers reports the cache a project's Playwright will read" "linked" ' + rainier-browsers path | grep -qx /workspace/.cache/ms-playwright || { echo "path = $(rainier-browsers path)"; exit 1; } + rainier-browsers status' + +# Reported rather than asserted, because both answers are legitimate and which +# one a session gets is host policy rather than an image property. Chromium's +# own layer-1 sandbox needs to create a user namespace, which docker's default +# seccomp profile refuses without CAP_SYS_ADMIN; Playwright disables that +# sandbox by default anyway (chromiumSandbox: false) and relies on the +# container. What must never happen is a browser that reports no usable sandbox +# and renders the page regardless, so the observed exit status is on the record. +SANDBOX_STATUS=$(probe ' + '"$BROWSER_FIXTURE"' + out=$("$b" --disable-dev-shm-usage --disable-gpu --disable-breakpad --user-data-dir="$d/p2" --dump-dom "file://$d/page.html" 2>&1) + st=$? + printf "exit=%s %s\n" "$st" "$(printf "%s" "$out" | grep -i -m1 "sandbox\|namespace" || echo "no sandbox diagnostic")" +' | tail -1) +printf 'note chromium own-sandbox under the driver restrictions: %s\n' "$SANDBOX_STATUS" +note "chromium own-sandbox status" "$SANDBOX_STATUS" + +BROWSER_SIZE=$(probe 'cat /usr/local/share/rainier-browser-size.txt 2>/dev/null | head -1; grep -h "browser payload" /usr/local/share/rainier-browser-size.txt 2>/dev/null' | tr '\n' '; ') +printf 'note browser layer: %s\n' "$BROWSER_SIZE" +note "browser layer size" "$BROWSER_SIZE" + echo echo "-- git, gh and the rest of the shell toolkit" check "git makes a commit" "git-ok" ' From e4211ab22b4f4c8cc780dce8867a9ea7a8f1dd80 Mon Sep 17 00:00:00 2001 From: Josh Zhang <jszjosh@gmail.com> Date: Wed, 9 Sep 2026 16:47:12 +0000 Subject: [PATCH 02/38] docs(image): record the measured browser cost and sandbox status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Numbers off the qualification run rather than estimates: the apt layer is 26 packages and 30,744 KiB, the browser payload is 272,100 KiB, and the whole image goes from 2,416,401,380 bytes in 22 layers to 2,725,950,441 in 28 — +295 MiB for the capability, with what dropping the browser or adding the full Chrome for Testing build would cost beside it. Also the sandbox evidence, which had been described and is now observed: a launch that asks for Chromium's own sandbox exits 133 with 'No usable sandbox!' and renders nothing. Failing closed is the property worth having; a browser that reported no sandbox and drew the page anyway is the one to guard against. And the practical note a suite hits first: a session's http_proxy is the egress proxy and Chromium reads it, so a test web server has to be on loopback, which no_proxy carries. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- docs/session-image.md | 44 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 39 insertions(+), 5 deletions(-) diff --git a/docs/session-image.md b/docs/session-image.md index 624460af..2f0f1792 100644 --- a/docs/session-image.md +++ b/docs/session-image.md @@ -425,6 +425,18 @@ on arrival under `no-new-privileges`. The observed status is reported by `scripts/session-image-smoke.sh` on every qualification run rather than asserted, because which policy a host applies is not an image property. +It fails **closed**, which is the part that matters. On the qualified +candidate, under the driver's restrictions and docker's default seccomp +profile, a launch that asks for the sandbox exits 133 with + +``` +[FATAL:content/browser/zygote_host/zygote_host_impl_linux.cc] No usable sandbox! +``` + +and renders nothing. A browser that reported no usable sandbox and drew the +page anyway would be the failure worth guarding against, and it is not what +happens. + **What was not done to change that**, and would not be: - No `--privileged`, no `--cap-add`, no `seccomp=unconfined` and no @@ -473,15 +485,29 @@ should say so; adding it is a bounded change to the Dockerfile. Measured by the build and reported by the smoke on every qualification run, so read it off the run rather than off this page: +On the qualified candidate (linux/amd64, the default pinned base; run +[34378290786](https://github.com/tokencanopy/rainier/actions/runs/34378290786)): + | | | |---|---| -| Shared libraries and fonts | `/usr/local/share/rainier-browser-size.txt`, first line | -| The browser payload | the `browser payload:` line of the same file | -| Compressed, in the pull | ~117 MiB (`chrome-headless-shell-linux64.zip` 114.3 MiB + ffmpeg 2.3 MiB) | -| Extracted, in the image | ~266 MiB | -| Per session, on the workspace volume | three directories of symlinks and empty marker files — kilobytes | +| Shared libraries and fonts | **26 packages, 30,744 KiB (≈30 MiB)** | +| The browser payload | **272,100 KiB (≈266 MiB)** under `/usr/local/lib/rainier-browsers` | +| Whole image, with it | **2,725,950,441 bytes, 28 layers** — up from 2,416,401,380 and 22 layers, so **+295 MiB and +6 layers** | +| Compressed, in the pull | ~117 MiB (`chrome-headless-shell-linux64.zip` 114.3 MiB + ffmpeg 2.3 MiB), plus the apt layer | +| Per session, on the workspace volume | two directories of symlinks and empty marker files — kilobytes | | Startup cost | none: docker copies the links when it creates the volume, and no entrypoint step touches them | +Read the first two off the run rather than off this table, which is one build +old the moment it is written: the apt layer diffs its own package set and +writes `/usr/local/share/rainier-browser-size.txt`, +`images/session/browsers.sh` appends the browser payload to the same file, and +the smoke reports both as workflow notices on the pull request being approved. + +Dropping the browser would return ~296 MiB; adding the full Chrome for Testing +build would cost ~393 MiB more. Both are one line of `images/session/browsers.sh` +and a checksum, and the [rollout runbook](https://github.com/tokencanopy/rainier-cloud/blob/main/docs/runbooks/default-environment-rollout.md) +step 2 is where the pull cost is reviewed against them. + A project that pins another Playwright pays ~114 MiB of download once per workspace, onto the volume, where it survives suspend and resume. @@ -499,6 +525,14 @@ Nothing is needed at all for a project on the pinned version: the baseline is in the image and the suite runs on `--network none`. Which of these an environment gets is a control-plane decision and not this image's to make. +**A test web server has to be on loopback.** A session's `http_proxy` points at +the egress proxy and its `no_proxy` carries `localhost` and `127.0.0.1`, which +Chromium reads. A dev server on `127.0.0.1` — which is what Playwright's +`webServer` starts and what Vite, Next and the rest bind by default — is +reached directly. A suite that instead addressed the container by its own +hostname or its non-loopback address would send the request to the egress proxy +and be refused; bind and address loopback. + ## Why this base image, and not a Dev Containers one The choice was between a pinned Debian/Ubuntu **Dev Containers base image** and From 14ae9ebcefedee604b83467aa2bf8f9fc1c62de8 Mon Sep 17 00:00:00 2001 From: Josh Zhang <jszjosh@gmail.com> Date: Wed, 9 Sep 2026 16:54:27 +0000 Subject: [PATCH 03/38] chore(image): drop an unused pin read from the browser e2e script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sample suite asserts the browser build against its own Playwright's browsers.json, which is the stronger check — it proves the project resolved the match rather than that the script and the Dockerfile agree. The Dockerfile read left behind by the earlier shape was doing nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- scripts/session-image-browser-e2e.sh | 3 --- 1 file changed, 3 deletions(-) diff --git a/scripts/session-image-browser-e2e.sh b/scripts/session-image-browser-e2e.sh index 1c03293c..a3bcd553 100755 --- a/scripts/session-image-browser-e2e.sh +++ b/scripts/session-image-browser-e2e.sh @@ -42,9 +42,6 @@ command -v "$DOCKER" >/dev/null 2>&1 || { echo "no docker executable ($DOCKER); "$DOCKER" image inspect "$IMAGE" >/dev/null 2>&1 \ || { echo "image $IMAGE is not present; build it first (make session-image)" >&2; exit 2; } -CHROMIUM_VERSION=$(sed -n 's/^ARG CHROMIUM_VERSION=//p' "$SCRIPT_DIR/../Dockerfile") -[ -n "$CHROMIUM_VERSION" ] || { echo "missing CHROMIUM_VERSION pin" >&2; exit 2; } - SUFFIX=$(od -An -tx1 -N6 /dev/urandom | tr -d ' \n') WS_VOL="rainier-browser-ws-$SUFFIX" From abda56b60da67abc2b93b41a6c7b717fe1cf3e75 Mon Sep 17 00:00:00 2001 From: jiashuoz <jszjosh@gmail.com> Date: Wed, 9 Sep 2026 10:00:09 -0700 Subject: [PATCH 04/38] ci(browser): gate the session image on real Playwright projects --- .github/workflows/session-image.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/session-image.yml b/.github/workflows/session-image.yml index 2e38759b..4be7e949 100644 --- a/.github/workflows/session-image.yml +++ b/.github/workflows/session-image.yml @@ -33,5 +33,7 @@ jobs: run: go test ./internal/driver -run '^(TestSessionImage|TestImageSmoke)' -count=1 - name: Functional smoke with no network or credentials run: make session-image-smoke SESSION_IMAGE=rainier-session:qualify + - name: Browser end to end in a fresh project + run: make session-image-browser-e2e SESSION_IMAGE=rainier-session:qualify - name: Image size run: docker image inspect -f '{{.Size}} bytes; {{.Architecture}}' rainier-session:qualify From f91d221518a426734879a9db2ed10d072ac35dbc Mon Sep 17 00:00:00 2001 From: jiashuoz <jszjosh@gmail.com> Date: Wed, 9 Sep 2026 10:03:23 -0700 Subject: [PATCH 05/38] fix(browser): invalidate retired image cache markers --- docs/session-image.md | 17 +++++++++++ images/session/browsers/rainier-browsers | 16 ++++++++++ internal/driver/image_browser_test.go | 39 ++++++++++++++++++++++++ 3 files changed, 72 insertions(+) diff --git a/docs/session-image.md b/docs/session-image.md index 2f0f1792..68ce5723 100644 --- a/docs/session-image.md +++ b/docs/session-image.md @@ -819,3 +819,20 @@ useful local diagnostics, not qualification of the shipping AMD64 image. Authenticated agent workloads and cold dependency downloads under hosted egress policy remain a separate no-setup environment gate on approved canary capacity; never replace a runner holding active work to obtain that evidence. + + +### Browser cache recovery after an image change + +After restoring a workspace onto a different browser-image revision, run +`rainier-browsers link`. It links the new baseline and invalidates completion +markers for Rainier-owned revisions whose image-local payload no longer exists. +The project's normal `npx playwright install` can then fetch an older pinned +revision again; project-installed browser directories are preserved. This is an +explicit recovery step for existing volumes, not an automatic image migration. +`PLAYWRIGHT_BROWSERS_PATH=0` uses Playwright's package-local cache and is not +managed by this helper; use the project's installer for that mode. + +The session-image CI now runs `make session-image-browser-e2e` in addition to +its offline image checks. Functional browser success does not prove Chromium's +own sandbox is enabled: Playwright's default disables it. The separately +requested sandbox-enabled qualification remains a release acceptance gap. diff --git a/images/session/browsers/rainier-browsers b/images/session/browsers/rainier-browsers index 6b3b5d8c..eb3272c8 100755 --- a/images/session/browsers/rainier-browsers +++ b/images/session/browsers/rainier-browsers @@ -46,6 +46,10 @@ USAGE } registry_dir() { + if [ "${PLAYWRIGHT_BROWSERS_PATH:-}" = 0 ]; then + echo "PLAYWRIGHT_BROWSERS_PATH=0 selects a package-local cache; use the project Playwright installer or unset this override" >&2 + return 2 + fi if [ -n "${PLAYWRIGHT_BROWSERS_PATH:-}" ]; then printf '%s\n' "$PLAYWRIGHT_BROWSERS_PATH" else @@ -91,6 +95,18 @@ cmd_link() { [ -d "$PREFIX" ] || { echo "no browser baseline at $PREFIX" >&2; exit 1; } reg=$(registry_dir) mkdir -p "$reg" + # An upgraded image may have retired a revision whose cache links survived + # on the workspace volume. Playwright skips download when this marker exists, + # even if the linked payload is gone. Invalidate only our stale markers; + # leave project installs and any other workspace files untouched. + for dst in "$reg"/*; do + [ -d "$dst" ] && [ ! -L "$dst" ] && is_ours "$dst" || continue + source_path=$(cat "$dst/$OURS") + if [ ! -d "$source_path" ]; then + rm -f "$dst/INSTALLATION_COMPLETE" "$dst/DEPENDENCIES_VALIDATED" + printf 'repair %s (retired baseline; project installer may download it)\n' "$dst" + fi + done for src in "$PREFIX"/*; do [ -d "$src" ] || continue link_one "$src" "$reg/${src##*/}" diff --git a/internal/driver/image_browser_test.go b/internal/driver/image_browser_test.go index bc942768..2e96ca84 100644 --- a/internal/driver/image_browser_test.go +++ b/internal/driver/image_browser_test.go @@ -396,3 +396,42 @@ func TestSessionImageBrowserHelperReportsThePathPlaywrightReads(t *testing.T) { t.Fatalf("path with only XDG_CACHE_HOME = %q", got) } } + +func TestSessionImageBrowserHelperInvalidatesRetiredBaseline(t *testing.T) { + prefix := fakeBaseline(t) + cache := filepath.Join(t.TempDir(), "cache") + env := []string{"RAINIER_BROWSERS_PREFIX=" + prefix, "PLAYWRIGHT_BROWSERS_PATH=" + cache} + if run := runHelper(t, browsersHelper(t), t.TempDir(), env, "link"); run.status != 0 { + t.Fatal(run.out) + } + old := filepath.Join(cache, "chromium_headless_shell-1243") + // A newer image no longer carries the old read-only payload. + if err := os.RemoveAll(filepath.Join(prefix, "chromium_headless_shell-1243")); err != nil { + t.Fatal(err) + } + project := filepath.Join(cache, "chromium_headless_shell-1200") + if err := os.MkdirAll(project, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(project, "INSTALLATION_COMPLETE"), nil, 0644); err != nil { + t.Fatal(err) + } + if run := runHelper(t, browsersHelper(t), t.TempDir(), env, "link"); run.status != 0 { + t.Fatal(run.out) + } + if _, err := os.Stat(filepath.Join(old, "INSTALLATION_COMPLETE")); !os.IsNotExist(err) { + t.Fatalf("retired payload still marked installed: %v", err) + } + if _, err := os.Stat(filepath.Join(project, "INSTALLATION_COMPLETE")); err != nil { + t.Fatalf("project install changed: %v", err) + } +} + +func TestSessionImageBrowserHelperRejectsPackageLocalCache(t *testing.T) { + for _, command := range []string{"path", "link", "status"} { + run := runHelper(t, browsersHelper(t), t.TempDir(), []string{"PLAYWRIGHT_BROWSERS_PATH=0", "RAINIER_BROWSERS_PREFIX=" + fakeBaseline(t)}, command) + if run.status == 0 || !strings.Contains(run.out, "package-local") { + t.Fatalf("%s: exit=%d output=%s", command, run.status, run.out) + } + } +} From b7207277e6a1f6ccd13309516589e60d1bbca9df Mon Sep 17 00:00:00 2001 From: jiashuoz <jszjosh@gmail.com> Date: Wed, 9 Sep 2026 13:50:51 -0700 Subject: [PATCH 06/38] test: require Chromium sandbox in browser qualification --- .../browser-sample/playwright.config.js | 8 +++---- scripts/session-image-smoke.sh | 22 ++++++------------- 2 files changed, 11 insertions(+), 19 deletions(-) diff --git a/images/session/browser-sample/playwright.config.js b/images/session/browser-sample/playwright.config.js index 64e5aca1..a60a75e2 100644 --- a/images/session/browser-sample/playwright.config.js +++ b/images/session/browser-sample/playwright.config.js @@ -2,8 +2,8 @@ // here is Rainier-specific, because the point of the qualification is that a // project's own unmodified Playwright configuration works in a session. // -// In particular there is no `channel`, no `executablePath`, no launch -// argument, and no chromiumSandbox setting. Playwright resolves the browser +// In particular there is no `channel`, no `executablePath`, or launch +// argument. The browser sandbox is required for this baseline. Playwright resolves the browser // from PLAYWRIGHT_BROWSERS_PATH, which the image points at the workspace // cache; the image seeds that cache with links to its pinned baseline, so this // runs with nothing downloaded and no network at all. @@ -34,7 +34,7 @@ module.exports = defineConfig({ projects: [ { name: 'desktop', - use: { ...devices['Desktop Chrome'], viewport: { width: 1280, height: 800 } }, + use: { ...devices['Desktop Chrome'], viewport: { width: 1280, height: 800 }, chromiumSandbox: true }, }, { name: 'phone', @@ -42,7 +42,7 @@ module.exports = defineConfig({ // factor, touch, and the mobile user agent all change what the page // does, and a layout assertion that ignored them would be measuring // something nobody has. - use: { ...devices['Pixel 7'] }, + use: { ...devices['Pixel 7'], chromiumSandbox: true }, }, ], // Playwright starts and stops this itself, which is half of what the diff --git a/scripts/session-image-smoke.sh b/scripts/session-image-smoke.sh index 8ad88de8..be216534 100755 --- a/scripts/session-image-smoke.sh +++ b/scripts/session-image-smoke.sh @@ -553,13 +553,9 @@ echo "-- browser testing" # needed to download anything on first use would fail here, which is the point: # a fresh session has to be able to run a test suite offline. # -# The direct invocations below pass --no-sandbox because that is what -# Playwright itself passes: chromiumSandbox defaults to false in every -# Playwright release, so the launch this smoke imitates is the launch a project -# actually gets. Nothing in this image or this driver adds that flag, and the -# isolation a session relies on is the container's — uid 1000, -# no-new-privileges, a read-only rootfs, its own network namespace, and the -# host's seccomp and AppArmor policy. See docs/session-image.md. +# The direct invocations below intentionally omit --no-sandbox. The sample +# Playwright project requires chromiumSandbox=true, so these probes must prove +# the packaged browser can use its own sandbox under the session policy. BROWSER_FIXTURE=' b=$(find -L "$PLAYWRIGHT_BROWSERS_PATH" -maxdepth 3 -type f -name chrome-headless-shell 2>/dev/null | head -1) @@ -578,7 +574,7 @@ HTML # The flags Playwright passes, and nothing else: --disable-dev-shm-usage is # in chromiumSwitches for every launch, which is why docker default 64 MiB # /dev/shm is enough for a Playwright suite. - render() { "$b" --no-sandbox --disable-dev-shm-usage --disable-gpu --disable-breakpad \ + render() { "$b" --disable-dev-shm-usage --disable-gpu --disable-breakpad \ --user-data-dir="$d/profile" "$@" 2>&1; } png_size() { python3 -c "import struct,sys; d=open(sys.argv[1],\"rb\").read(24); w,h=struct.unpack(\">II\", d[16:24]); print(w,h)" "$1"; } ' @@ -677,13 +673,9 @@ check "rainier-browsers reports the cache a project's Playwright will read" "lin rainier-browsers path | grep -qx /workspace/.cache/ms-playwright || { echo "path = $(rainier-browsers path)"; exit 1; } rainier-browsers status' -# Reported rather than asserted, because both answers are legitimate and which -# one a session gets is host policy rather than an image property. Chromium's -# own layer-1 sandbox needs to create a user namespace, which docker's default -# seccomp profile refuses without CAP_SYS_ADMIN; Playwright disables that -# sandbox by default anyway (chromiumSandbox: false) and relies on the -# container. What must never happen is a browser that reports no usable sandbox -# and renders the page regardless, so the observed exit status is on the record. +# The sandbox-enabled launch is a required check; a browser that cannot +# initialize its own sandbox exits nonzero. The observed status is recorded for +# diagnostics after the functional checks. SANDBOX_STATUS=$(probe ' '"$BROWSER_FIXTURE"' out=$("$b" --disable-dev-shm-usage --disable-gpu --disable-breakpad --user-data-dir="$d/p2" --dump-dom "file://$d/page.html" 2>&1) From 635de4c34a815df903c0eeb6fff341d51d9468a8 Mon Sep 17 00:00:00 2001 From: jiashuoz <jszjosh@gmail.com> Date: Wed, 9 Sep 2026 13:53:06 -0700 Subject: [PATCH 07/38] fix: configure Chromium sandbox as launch option --- images/session/browser-sample/playwright.config.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/images/session/browser-sample/playwright.config.js b/images/session/browser-sample/playwright.config.js index a60a75e2..d6ff496b 100644 --- a/images/session/browser-sample/playwright.config.js +++ b/images/session/browser-sample/playwright.config.js @@ -34,7 +34,7 @@ module.exports = defineConfig({ projects: [ { name: 'desktop', - use: { ...devices['Desktop Chrome'], viewport: { width: 1280, height: 800 }, chromiumSandbox: true }, + use: { ...devices['Desktop Chrome'], viewport: { width: 1280, height: 800 }, launchOptions: { chromiumSandbox: true } }, }, { name: 'phone', @@ -42,7 +42,7 @@ module.exports = defineConfig({ // factor, touch, and the mobile user agent all change what the page // does, and a layout assertion that ignored them would be measuring // something nobody has. - use: { ...devices['Pixel 7'], chromiumSandbox: true }, + use: { ...devices['Pixel 7'], launchOptions: { chromiumSandbox: true } }, }, ], // Playwright starts and stops this itself, which is half of what the From 77ab0387915e3cb8d7918e6c5dd57fd51fabc280 Mon Sep 17 00:00:00 2001 From: jiashuoz <jszjosh@gmail.com> Date: Wed, 9 Sep 2026 14:04:06 -0700 Subject: [PATCH 08/38] ci: enable Chromium sandbox user namespaces --- .github/workflows/session-image.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/session-image.yml b/.github/workflows/session-image.yml index 4be7e949..4bfe168b 100644 --- a/.github/workflows/session-image.yml +++ b/.github/workflows/session-image.yml @@ -31,6 +31,9 @@ jobs: env: RAINIER_SESSION_IMAGE: rainier-session:qualify run: go test ./internal/driver -run '^(TestSessionImage|TestImageSmoke)' -count=1 + # Chromium must use its own sandbox; enable user namespaces on this disposable CI host. + - name: Enable Chromium sandbox user namespaces + run: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 - name: Functional smoke with no network or credentials run: make session-image-smoke SESSION_IMAGE=rainier-session:qualify - name: Browser end to end in a fresh project From 67f40b6ee5e01fc454b7a33b2b0bf0635ebecbfd Mon Sep 17 00:00:00 2001 From: jiashuoz <jszjosh@gmail.com> Date: Wed, 9 Sep 2026 14:22:36 -0700 Subject: [PATCH 09/38] test: enforce Chromium sandbox qualification --- internal/driver/image_browser_test.go | 9 +++++++++ scripts/session-image-smoke.sh | 18 +++++++++++++----- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/internal/driver/image_browser_test.go b/internal/driver/image_browser_test.go index 2e96ca84..58e59521 100644 --- a/internal/driver/image_browser_test.go +++ b/internal/driver/image_browser_test.go @@ -218,6 +218,15 @@ func TestSessionImageDoesNotDisableBrowserSafety(t *testing.T) { if strings.Contains(df, "--no-sandbox") { t.Error("the Dockerfile names --no-sandbox; the image does not choose a project's browser launch flags") } + for _, path := range []string{"../../scripts/session-image-smoke.sh", "../../scripts/session-image-browser-e2e.sh", "../../images/session/browser-sample/playwright.config.js"} { + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("reading browser safety contract %s: %v", path, err) + } + if strings.Contains(string(b), "--no-sandbox") { + t.Errorf("%s names --no-sandbox; browser qualification must fail closed", path) + } + } } // TestDockerGrantsNoBrowserPrivilege: the isolation half of the same change. diff --git a/scripts/session-image-smoke.sh b/scripts/session-image-smoke.sh index be216534..08a2142a 100755 --- a/scripts/session-image-smoke.sh +++ b/scripts/session-image-smoke.sh @@ -676,14 +676,22 @@ check "rainier-browsers reports the cache a project's Playwright will read" "lin # The sandbox-enabled launch is a required check; a browser that cannot # initialize its own sandbox exits nonzero. The observed status is recorded for # diagnostics after the functional checks. -SANDBOX_STATUS=$(probe ' +SANDBOX_OUTPUT=$(probe ' '"$BROWSER_FIXTURE"' out=$("$b" --disable-dev-shm-usage --disable-gpu --disable-breakpad --user-data-dir="$d/p2" --dump-dom "file://$d/page.html" 2>&1) st=$? - printf "exit=%s %s\n" "$st" "$(printf "%s" "$out" | grep -i -m1 "sandbox\|namespace" || echo "no sandbox diagnostic")" -' | tail -1) -printf 'note chromium own-sandbox under the driver restrictions: %s\n' "$SANDBOX_STATUS" -note "chromium own-sandbox status" "$SANDBOX_STATUS" + printf "exit=%s %s\n" "$st" "$(printf "%s" "$out" | grep -i -m1 "sandbox\|namespace" || echo "sandbox launch succeeded")" + exit "$st" +') +SANDBOX_STATUS=$? +SANDBOX_DIAGNOSTIC=$(printf '%s\n' "$SANDBOX_OUTPUT" | tail -1) +printf 'note chromium own-sandbox under the driver restrictions: exit=%s %s\n' "$SANDBOX_STATUS" "$SANDBOX_DIAGNOSTIC" +note "chromium own-sandbox status" "exit=$SANDBOX_STATUS $SANDBOX_DIAGNOSTIC" +if [ "$SANDBOX_STATUS" -eq 0 ]; then + ok "Chromium starts with its own sandbox" +else + bad "Chromium starts with its own sandbox" "exit=$SANDBOX_STATUS; $SANDBOX_DIAGNOSTIC" +fi BROWSER_SIZE=$(probe 'cat /usr/local/share/rainier-browser-size.txt 2>/dev/null | head -1; grep -h "browser payload" /usr/local/share/rainier-browser-size.txt 2>/dev/null' | tr '\n' '; ') printf 'note browser layer: %s\n' "$BROWSER_SIZE" From f66227a6108caadbf6409c208e6cb8af5f4853a8 Mon Sep 17 00:00:00 2001 From: jiashuoz <jszjosh@gmail.com> Date: Wed, 9 Sep 2026 14:22:59 -0700 Subject: [PATCH 10/38] test: ignore sandbox documentation comments --- internal/driver/image_browser_test.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/internal/driver/image_browser_test.go b/internal/driver/image_browser_test.go index 58e59521..bc61d663 100644 --- a/internal/driver/image_browser_test.go +++ b/internal/driver/image_browser_test.go @@ -223,8 +223,14 @@ func TestSessionImageDoesNotDisableBrowserSafety(t *testing.T) { if err != nil { t.Fatalf("reading browser safety contract %s: %v", path, err) } - if strings.Contains(string(b), "--no-sandbox") { - t.Errorf("%s names --no-sandbox; browser qualification must fail closed", path) + for _, line := range strings.Split(string(b), "\n") { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "#") || strings.HasPrefix(trimmed, "//") { + continue + } + if strings.Contains(line, "--no-sandbox") { + t.Errorf("%s names --no-sandbox in executable code; browser qualification must fail closed", path) + } } } } From 8bf617e752e8045aa3b68c3d7e3146fd311c4a4d Mon Sep 17 00:00:00 2001 From: jiashuoz <jszjosh@gmail.com> Date: Wed, 9 Sep 2026 14:23:19 -0700 Subject: [PATCH 11/38] docs: document required Chromium sandbox --- docs/session-image.md | 95 ++++++++++++++++--------------------------- 1 file changed, 34 insertions(+), 61 deletions(-) diff --git a/docs/session-image.md b/docs/session-image.md index 68ce5723..2bab762e 100644 --- a/docs/session-image.md +++ b/docs/session-image.md @@ -398,63 +398,35 @@ review; it has not been made. ### Sandboxing, and what is actually isolating the browser -This is worth stating plainly, because "Chromium sandbox" means two different -things and only one of them is in play. - -**Playwright disables Chromium's own sandbox by default.** `chromiumSandbox` -defaults to `false` in every Playwright release, so `browserType.launch()` adds -`--no-sandbox` unless a suite asks otherwise. That is upstream's default on -every platform, not something this image or this driver does — nothing here -adds that flag, and -`internal/driver.TestSessionImageDoesNotDisableBrowserSafety` fails the build -if the image ever names it. - -**What isolates the browser is the container**, and it is the same boundary -that isolates everything else a session runs: uid 1000, `no-new-privileges`, a -read-only rootfs, a noexec `/tmp`, the session's own network namespace, no host -mount and no docker socket, and on the hosted product a host seccomp profile -and an AppArmor profile besides. A browser process in a session can reach the -session's own files and nothing else — which is a stronger boundary than the -one a browser gets on the laptop this suite usually runs on. - -**Chromium's own layer-1 sandbox is not available**, and the reason is -specific. It needs to create a user namespace — `clone(CLONE_NEWUSER | …)` — -and both docker's default seccomp profile and the hosted profile refuse that -argument shape without `CAP_SYS_ADMIN`. The setuid-sandbox alternative is dead -on arrival under `no-new-privileges`. The observed status is reported by -`scripts/session-image-smoke.sh` on every qualification run rather than -asserted, because which policy a host applies is not an image property. - -It fails **closed**, which is the part that matters. On the qualified -candidate, under the driver's restrictions and docker's default seccomp -profile, a launch that asks for the sandbox exits 133 with - -``` -[FATAL:content/browser/zygote_host/zygote_host_impl_linux.cc] No usable sandbox! -``` - -and renders nothing. A browser that reported no usable sandbox and drew the -page anyway would be the failure worth guarding against, and it is not what -happens. - -**What was not done to change that**, and would not be: - -- No `--privileged`, no `--cap-add`, no `seccomp=unconfined` and no - `apparmor=unconfined`. `internal/driver.TestDockerGrantsNoBrowserPrivilege` - fails the build if any of them appears in `runArgs`. -- No `--ipc=host`, no host network, no wider host mount, no `/dev/shm` bind. -- No debugging port exposed anywhere. Playwright drives Chromium over - `--remote-debugging-pipe` — a pair of file descriptors, not a socket — so - there is no port to expose in the first place. -- No reuse of a host browser profile or credential. Every run gets a fresh - `--user-data-dir` under `/tmp`, which is a per-container tmpfs. - -Making Chromium's own sandbox work would mean admitting its specific -`clone(2)` argument values to the hosted seccomp profile, the way Codex's -Bubblewrap shapes were admitted — a narrow, reviewable change with its own -live qualification, and one this image does not need in order to run a test -suite. rainier-cloud's `docs/security/browser-sandbox-in-a-session.md` is where -that case is written up. +"Chromium sandbox" describes two layers that work together in a hosted +session. + +**The project must request Chromium's own sandbox.** Playwright defaults +`chromiumSandbox` to `false`, so a project that wants the browser sandbox must +set it explicitly. Rainier's browser qualification projects do this for every +Chromium launch. The image and driver never add `--no-sandbox`, and the image +contract tests reject that flag in executable qualification code. + +**The container remains the outer boundary.** A session still runs as uid 1000 +with `no-new-privileges`, a read-only rootfs, a noexec `/tmp`, its own network +namespace, no host mount, no Docker socket, and the hosted runner's seccomp and +AppArmor profiles. Chromium's sandbox is an additional process boundary inside +that container; it does not replace the container boundary. + +**Hosted Rainier admits only the namespace operations Chromium needs.** The +Cloud security profile permits Chromium's exact `clone(CLONE_NEWUSER|SIGCHLD)` +and `unshare(CLONE_NEWUSER|CLONE_NEWNS)` forms, plus the AppArmor `userns` +permission. The hosted browser qualification runs the real web suite under +those profiles and fails if Chromium cannot initialize its sandbox. A local +Docker host with stricter policies must load an equivalent reviewed profile; +Rainier never falls back to `--no-sandbox`. + +**No broad privilege is needed.** The session does not use `--privileged`, +`--cap-add`, `seccomp=unconfined`, `apparmor=unconfined`, host networking, +host IPC, a wider mount, or a debugging socket. Playwright's +`--remote-debugging-pipe` uses file descriptors rather than a listening port, +and each launch receives a fresh profile under the session's temporary +filesystem. ### `/dev/shm` is 64 MiB, and that is fine here @@ -832,7 +804,8 @@ explicit recovery step for existing volumes, not an automatic image migration. `PLAYWRIGHT_BROWSERS_PATH=0` uses Playwright's package-local cache and is not managed by this helper; use the project's installer for that mode. -The session-image CI now runs `make session-image-browser-e2e` in addition to -its offline image checks. Functional browser success does not prove Chromium's -own sandbox is enabled: Playwright's default disables it. The separately -requested sandbox-enabled qualification remains a release acceptance gap. +The session-image CI runs `make session-image-browser-e2e` in addition to +its offline image checks. The sample project explicitly enables +`chromiumSandbox: true`, and Cloud's hosted browser qualification runs the web +suite under the reviewed seccomp and AppArmor profiles. Both checks are release +gates for the supported browser path. From ea651e7c9a63d3c0a1ab7a398ef80ba59805747b Mon Sep 17 00:00:00 2001 From: jiashuoz <jszjosh@gmail.com> Date: Wed, 9 Sep 2026 14:23:35 -0700 Subject: [PATCH 12/38] fix: guard browser cache entry deletion --- images/session/browsers/rainier-browsers | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/images/session/browsers/rainier-browsers b/images/session/browsers/rainier-browsers index eb3272c8..4dcb8979 100755 --- a/images/session/browsers/rainier-browsers +++ b/images/session/browsers/rainier-browsers @@ -85,7 +85,8 @@ link_one() { case "$name" in INSTALLATION_COMPLETE|DEPENDENCIES_VALIDATED|"$OURS"|'*') continue ;; esac - rm -rf "$dst/$name" + [ -n "$dst" ] && [ -n "$name" ] || { echo "invalid browser cache entry" >&2; return 1; } + rm -rf -- "$dst/$name" ln -s "$entry" "$dst/$name" done printf 'link %s -> %s\n' "$dst" "$src" From f64ca8e85c8ec546bf2497ba04668597f382c8d5 Mon Sep 17 00:00:00 2001 From: jiashuoz <jszjosh@gmail.com> Date: Wed, 9 Sep 2026 14:23:46 -0700 Subject: [PATCH 13/38] chore: satisfy shellcheck cache deletion guard --- images/session/browsers/rainier-browsers | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/images/session/browsers/rainier-browsers b/images/session/browsers/rainier-browsers index 4dcb8979..166a43bf 100755 --- a/images/session/browsers/rainier-browsers +++ b/images/session/browsers/rainier-browsers @@ -86,7 +86,7 @@ link_one() { INSTALLATION_COMPLETE|DEPENDENCIES_VALIDATED|"$OURS"|'*') continue ;; esac [ -n "$dst" ] && [ -n "$name" ] || { echo "invalid browser cache entry" >&2; return 1; } - rm -rf -- "$dst/$name" + rm -rf -- "${dst:?}/${name:?}" ln -s "$entry" "$dst/$name" done printf 'link %s -> %s\n' "$dst" "$src" From 9d03f42c1976ef43a190acced52de080c6caab4d Mon Sep 17 00:00:00 2001 From: jiashuoz <jszjosh@gmail.com> Date: Wed, 9 Sep 2026 14:33:46 -0700 Subject: [PATCH 14/38] test: qualify browser image under hosted sandbox policy --- .github/workflows/session-image.yml | 19 +++++++++++++++++++ scripts/session-image-browser-e2e.sh | 13 +++++++++++++ scripts/session-image-smoke.sh | 14 ++++++++++++++ 3 files changed, 46 insertions(+) diff --git a/.github/workflows/session-image.yml b/.github/workflows/session-image.yml index 4bfe168b..353c6739 100644 --- a/.github/workflows/session-image.yml +++ b/.github/workflows/session-image.yml @@ -34,9 +34,28 @@ jobs: # Chromium must use its own sandbox; enable user namespaces on this disposable CI host. - name: Enable Chromium sandbox user namespaces run: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 + # The image repository owns the browser payload; Rainier Cloud owns the + # host policy that admits Chromium's user-namespace sandbox. Pin the + # reviewed policy commit so this qualification exercises the same outer + # boundary as a hosted session. Advance this SHA with policy changes. + - name: Fetch the reviewed hosted browser policy + uses: actions/checkout@v4 + with: + repository: tokencanopy/rainier-cloud + ref: 5670756c502c89961e03b91bb8bcf037183aa214 + path: .browser-cloud + persist-credentials: false + - name: Load the reviewed Chromium AppArmor policy + run: sudo apparmor_parser -r .browser-cloud/infra/security/rainier-codex-bwrap.apparmor - name: Functional smoke with no network or credentials + env: + SECCOMP: ${{ github.workspace }}/.browser-cloud/infra/security/codex-bwrap-seccomp-docker-27.5.1.json + APPARMOR: rainier-codex-bwrap run: make session-image-smoke SESSION_IMAGE=rainier-session:qualify - name: Browser end to end in a fresh project + env: + SECCOMP: ${{ github.workspace }}/.browser-cloud/infra/security/codex-bwrap-seccomp-docker-27.5.1.json + APPARMOR: rainier-codex-bwrap run: make session-image-browser-e2e SESSION_IMAGE=rainier-session:qualify - name: Image size run: docker image inspect -f '{{.Size}} bytes; {{.Architecture}}' rainier-session:qualify diff --git a/scripts/session-image-browser-e2e.sh b/scripts/session-image-browser-e2e.sh index a3bcd553..2fed0209 100755 --- a/scripts/session-image-browser-e2e.sh +++ b/scripts/session-image-browser-e2e.sh @@ -25,12 +25,24 @@ # # Usage: scripts/session-image-browser-e2e.sh [image] # Env: DOCKER=<docker executable> STEP_TIMEOUT=<seconds> KEEP=1 +# SECCOMP=<host profile path> APPARMOR=<loaded host profile name> # Exit: 0 every check passed, 1 a check failed, 2 setup or usage error. set -uo pipefail IMAGE=${1:-rainier-session:smoke} DOCKER=${DOCKER:-docker} STEP_TIMEOUT=${STEP_TIMEOUT:-600} +SECCOMP=${SECCOMP:-} +APPARMOR=${APPARMOR:-} + +SECURITY_OPTS=() +if [ -n "$SECCOMP" ]; then + [ -r "$SECCOMP" ] || { echo "no seccomp profile at $SECCOMP" >&2; exit 2; } + SECURITY_OPTS+=(--security-opt "seccomp=$SECCOMP") +fi +if [ -n "$APPARMOR" ]; then + SECURITY_OPTS+=(--security-opt "apparmor=$APPARMOR") +fi SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) source "$SCRIPT_DIR/session-image-checks.sh" @@ -74,6 +86,7 @@ step() { --network "$network" \ --user 1000:1000 \ --security-opt no-new-privileges \ + "${SECURITY_OPTS[@]}" \ --cap-drop ALL \ --read-only \ --tmpfs /tmp \ diff --git a/scripts/session-image-smoke.sh b/scripts/session-image-smoke.sh index 08a2142a..b5c742d6 100755 --- a/scripts/session-image-smoke.sh +++ b/scripts/session-image-smoke.sh @@ -24,12 +24,24 @@ # # Usage: scripts/session-image-smoke.sh [image] (default rainier-session:smoke) # Env: DOCKER=<docker executable> PROBE_TIMEOUT=<seconds> KEEP=1 +# SECCOMP=<host profile path> APPARMOR=<loaded host profile name> # Exit: 0 every check passed, 1 a check failed, 2 setup or usage error. set -uo pipefail IMAGE=${1:-rainier-session:smoke} DOCKER=${DOCKER:-docker} PROBE_TIMEOUT=${PROBE_TIMEOUT:-240} +SECCOMP=${SECCOMP:-} +APPARMOR=${APPARMOR:-} + +SECURITY_OPTS=() +if [ -n "$SECCOMP" ]; then + [ -r "$SECCOMP" ] || { echo "no seccomp profile at $SECCOMP" >&2; exit 2; } + SECURITY_OPTS+=(--security-opt "seccomp=$SECCOMP") +fi +if [ -n "$APPARMOR" ]; then + SECURITY_OPTS+=(--security-opt "apparmor=$APPARMOR") +fi SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) source "$SCRIPT_DIR/session-image-checks.sh" @@ -79,6 +91,7 @@ probe() { --network none \ --user 1000:1000 \ --security-opt no-new-privileges \ + "${SECURITY_OPTS[@]}" \ --cap-drop ALL \ --read-only \ --tmpfs /tmp \ @@ -105,6 +118,7 @@ probe_setup() { --network none \ --user 1000:1000 \ --security-opt no-new-privileges \ + "${SECURITY_OPTS[@]}" \ --cap-drop ALL \ --tmpfs /tmp \ --memory 3g --pids-limit 1024 \ From e987e27b90676d9f95d1969947fa182beaf4a844 Mon Sep 17 00:00:00 2001 From: jiashuoz <jszjosh@gmail.com> Date: Wed, 9 Sep 2026 14:35:34 -0700 Subject: [PATCH 15/38] ci: track updated hosted browser policy --- .github/workflows/session-image.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/session-image.yml b/.github/workflows/session-image.yml index 353c6739..84a5562a 100644 --- a/.github/workflows/session-image.yml +++ b/.github/workflows/session-image.yml @@ -42,7 +42,7 @@ jobs: uses: actions/checkout@v4 with: repository: tokencanopy/rainier-cloud - ref: 5670756c502c89961e03b91bb8bcf037183aa214 + ref: 6e6665b1009cc93489014af5761d45743b086c1e path: .browser-cloud persist-credentials: false - name: Load the reviewed Chromium AppArmor policy From 195c5590b64e6cafe4c879ac5090e1544a2e0ca6 Mon Sep 17 00:00:00 2001 From: jiashuoz <jszjosh@gmail.com> Date: Wed, 9 Sep 2026 14:39:37 -0700 Subject: [PATCH 16/38] ci: track the procfs policy exception --- .github/workflows/session-image.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/session-image.yml b/.github/workflows/session-image.yml index 84a5562a..4b7e7322 100644 --- a/.github/workflows/session-image.yml +++ b/.github/workflows/session-image.yml @@ -42,7 +42,7 @@ jobs: uses: actions/checkout@v4 with: repository: tokencanopy/rainier-cloud - ref: 6e6665b1009cc93489014af5761d45743b086c1e + ref: 2f2bb5743c7cb35ba803f108343690e01f0a9b03 path: .browser-cloud persist-credentials: false - name: Load the reviewed Chromium AppArmor policy From e0a47f2073105f85757e7f84a9bbbaa89d6311ce Mon Sep 17 00:00:00 2001 From: jiashuoz <jszjosh@gmail.com> Date: Wed, 9 Sep 2026 14:53:12 -0700 Subject: [PATCH 17/38] ci: make browser policy fixture public --- .github/workflows/session-image.yml | 26 +- Makefile | 10 +- docs/session-image.md | 24 +- scripts/session-image-security-policy-test.py | 207 ++++ testdata/session-security/README.md | 29 + .../codex-bwrap-seccomp-docker-27.5.1.json | 989 ++++++++++++++++++ .../rainier-codex-bwrap.apparmor | 52 + 7 files changed, 1312 insertions(+), 25 deletions(-) create mode 100644 scripts/session-image-security-policy-test.py create mode 100644 testdata/session-security/README.md create mode 100644 testdata/session-security/codex-bwrap-seccomp-docker-27.5.1.json create mode 100644 testdata/session-security/rainier-codex-bwrap.apparmor diff --git a/.github/workflows/session-image.yml b/.github/workflows/session-image.yml index 4b7e7322..3126a932 100644 --- a/.github/workflows/session-image.yml +++ b/.github/workflows/session-image.yml @@ -7,7 +7,10 @@ on: - .dockerignore - images/session/** - scripts/session-image-*.sh + - scripts/session-image-security-policy-test.py - internal/driver/image*test.go + - testdata/session-security/** + - Makefile - .github/workflows/session-image.yml workflow_dispatch: @@ -34,27 +37,22 @@ jobs: # Chromium must use its own sandbox; enable user namespaces on this disposable CI host. - name: Enable Chromium sandbox user namespaces run: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 - # The image repository owns the browser payload; Rainier Cloud owns the - # host policy that admits Chromium's user-namespace sandbox. Pin the - # reviewed policy commit so this qualification exercises the same outer - # boundary as a hosted session. Advance this SHA with policy changes. - - name: Fetch the reviewed hosted browser policy - uses: actions/checkout@v4 - with: - repository: tokencanopy/rainier-cloud - ref: 2f2bb5743c7cb35ba803f108343690e01f0a9b03 - path: .browser-cloud - persist-credentials: false + # This public snapshot exercises the browser boundary without requiring + # a cross-repository token. Rainier Cloud owns and qualifies its runtime + # policy independently; the fixture's hashes and exact rules are checked + # before the profile is loaded. + - name: Validate the public browser policy fixture + run: make session-image-security-policy - name: Load the reviewed Chromium AppArmor policy - run: sudo apparmor_parser -r .browser-cloud/infra/security/rainier-codex-bwrap.apparmor + run: sudo apparmor_parser -r testdata/session-security/rainier-codex-bwrap.apparmor - name: Functional smoke with no network or credentials env: - SECCOMP: ${{ github.workspace }}/.browser-cloud/infra/security/codex-bwrap-seccomp-docker-27.5.1.json + SECCOMP: ${{ github.workspace }}/testdata/session-security/codex-bwrap-seccomp-docker-27.5.1.json APPARMOR: rainier-codex-bwrap run: make session-image-smoke SESSION_IMAGE=rainier-session:qualify - name: Browser end to end in a fresh project env: - SECCOMP: ${{ github.workspace }}/.browser-cloud/infra/security/codex-bwrap-seccomp-docker-27.5.1.json + SECCOMP: ${{ github.workspace }}/testdata/session-security/codex-bwrap-seccomp-docker-27.5.1.json APPARMOR: rainier-codex-bwrap run: make session-image-browser-e2e SESSION_IMAGE=rainier-session:qualify - name: Image size diff --git a/Makefile b/Makefile index 39d4a201..7e9bdf2a 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: test build demo e2e verify module-path protocols control session-image session-image-smoke session-image-browser-e2e session-image-verify +.PHONY: test build demo e2e verify module-path protocols control session-image session-image-security-policy session-image-smoke session-image-browser-e2e session-image-verify DOCKER ?= docker SESSION_IMAGE ?= rainier-session:smoke @@ -41,6 +41,12 @@ control: session-image: $(DOCKER) build $(BUILD_ARGS) -t "$(SESSION_IMAGE)" . +# session-image-security-policy checks the public, test-only policy snapshot +# used by image qualification. Hosted Rainier Cloud owns and qualifies its +# runtime copy independently; core CI must not need a cross-repository token. +session-image-security-policy: + python3 scripts/session-image-security-policy-test.py + # session-image-smoke does the part `--version` cannot: it builds, runs, # installs and serves inside containers wearing the driver's real restrictions # — uid 1000, read-only rootfs, noexec /tmp, no network at all. See the header @@ -58,5 +64,5 @@ session-image-browser-e2e: session-image-verify: session-image session-image-smoke session-image-browser-e2e -verify: module-path protocols control test build +verify: module-path protocols control session-image-security-policy test build go vet ./... diff --git a/docs/session-image.md b/docs/session-image.md index 2bab762e..93c687da 100644 --- a/docs/session-image.md +++ b/docs/session-image.md @@ -419,7 +419,10 @@ and `unshare(CLONE_NEWUSER|CLONE_NEWNS)` forms, plus the AppArmor `userns` permission. The hosted browser qualification runs the real web suite under those profiles and fails if Chromium cannot initialize its sandbox. A local Docker host with stricter policies must load an equivalent reviewed profile; -Rainier never falls back to `--no-sandbox`. +Rainier never falls back to `--no-sandbox`. Core keeps a public, test-only +snapshot of this boundary in `testdata/session-security/`; its structural test +and image workflow no longer need to check out Rainier Cloud. The runtime +policy remains Cloud-owned and is qualified independently. **No broad privilege is needed.** The session does not use `--privileged`, `--cap-add`, `seccomp=unconfined`, `apparmor=unconfined`, host networking, @@ -668,9 +671,11 @@ unwritable, that a freshly created workspace volume already carries the cache links, that the preinstalled build is the one the `Dockerfile` pins, that every shared library resolves, that a page renders and screenshots at 1280x800 and at 390x844, that Arial lays out at Liberation's metrics rather than a fallback's, -and that no browser process survives the run. Chromium's own sandbox status -under the driver's restrictions is *reported* rather than asserted, because -which policy a host applies is not a property of the image. +and that no browser process survives the run. Chromium's own sandbox is +asserted when qualification loads the reviewed seccomp and AppArmor fixture; +the smoke script fails closed if Chromium exits without its sandbox. Which +policy a production host applies remains a host property and is qualified by +Rainier Cloud separately. `scripts/session-image-browser-e2e.sh` is the third piece and the only one with a network, deliberately: it stages a sample project that has never been in the @@ -804,8 +809,9 @@ explicit recovery step for existing volumes, not an automatic image migration. `PLAYWRIGHT_BROWSERS_PATH=0` uses Playwright's package-local cache and is not managed by this helper; use the project's installer for that mode. -The session-image CI runs `make session-image-browser-e2e` in addition to -its offline image checks. The sample project explicitly enables -`chromiumSandbox: true`, and Cloud's hosted browser qualification runs the web -suite under the reviewed seccomp and AppArmor profiles. Both checks are release -gates for the supported browser path. +The session-image CI runs `make session-image-security-policy` and +`make session-image-browser-e2e` in addition to its offline image checks. The +sample project explicitly enables `chromiumSandbox: true`, and the image job +loads the public test-only seccomp and AppArmor snapshot. Cloud's hosted browser +qualification runs the web suite under its independently reviewed runtime +profiles. Both checks are release gates for the supported browser path. diff --git a/scripts/session-image-security-policy-test.py b/scripts/session-image-security-policy-test.py new file mode 100644 index 00000000..428534c0 --- /dev/null +++ b/scripts/session-image-security-policy-test.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python3 +"""Validate the public, test-only browser sandbox policy fixture.""" + +import copy +import hashlib +import json +import pathlib +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +SECCOMP = ROOT / "testdata/session-security/codex-bwrap-seccomp-docker-27.5.1.json" +APPARMOR = ROOT / "testdata/session-security/rainier-codex-bwrap.apparmor" + +UNSHARE_USER = 0x10000000 +UNSHARE_CHROMIUM = 0x10020000 +CLONE_CHROMIUM_USER = 0x10000011 +CLONE_CHROMIUM_ZYGOTE = 0x70000011 +CLONE_CHROMIUM_ZYGOTE_NO_NET = 0x30000011 +CLONE_WITH_NETWORK = 0x78020011 +CLONE_WITHOUT_NETWORK = 0x38020011 +ENOSYS = 38 + +DOCKER_CANONICAL_SHA256 = ( + "885442dc08f21f8d60f99ea43d59af88b1c529103815fe24bbf9ce998d3a609d" +) +SECCOMP_CANONICAL_SHA256 = ( + "4fb409bf9925eeaab50f950118682150cafa4868dba3b277554dd3936b333ae0" +) +APPARMOR_SHA256 = ( + "1035744cd6dd47f242b4775e15e7c8fae5e38603e748d5111b6e166e26b09a96" +) + + +def rule(names, action, *, args=None, includes=None, errno=None, comment=None): + result = {"names": names, "action": action} + if args is not None: + result["args"] = args + if includes is not None: + result["includes"] = includes + if errno is not None: + result["errnoRet"] = errno + if comment is not None: + result["comment"] = comment + return result + + +def exact_clone(value): + return [{"index": 0, "value": value, "op": "SCMP_CMP_EQ"}] + + +class SessionImageSecurityPolicyTest(unittest.TestCase): + def test_seccomp_is_default_deny_and_only_allows_pinned_namespace_shapes(self): + profile = json.loads(SECCOMP.read_text()) + self.assertEqual(profile["defaultAction"], "SCMP_ACT_ERRNO") + + rainier = [ + item + for item in profile["syscalls"] + if item.get("comment", "").startswith("RAINIER:") + ] + amd64 = {"arches": ["amd64", "x32"]} + self.assertEqual( + rainier, + [ + rule( + ["unshare"], + "SCMP_ACT_ALLOW", + args=exact_clone(UNSHARE_USER), + includes=amd64, + comment=( + "RAINIER: Codex 0.153.4 Bubblewrap creates its initial " + "user namespace with unshare(CLONE_NEWUSER)." + ), + ), + rule( + ["clone"], + "SCMP_ACT_ALLOW", + args=exact_clone(CLONE_CHROMIUM_ZYGOTE), + includes=amd64, + comment=( + "RAINIER: Chromium namespace sandbox launches its zygote " + "with CLONE_NEWUSER|CLONE_NEWPID|CLONE_NEWNET|SIGCHLD." + ), + ), + rule( + ["clone3"], + "SCMP_ACT_ERRNO", + errno=ENOSYS, + comment=( + "RAINIER: Chromium requires clone3 to return ENOSYS so " + "libc falls back to flag-inspectable clone(2)." + ), + ), + rule( + ["clone"], + "SCMP_ACT_ALLOW", + args=exact_clone(CLONE_CHROMIUM_ZYGOTE_NO_NET), + includes=amd64, + comment=( + "RAINIER: Chromium namespace sandbox fallback zygote shape " + "with CLONE_NEWUSER|CLONE_NEWPID|SIGCHLD." + ), + ), + rule( + ["clone"], + "SCMP_ACT_ALLOW", + args=exact_clone(CLONE_CHROMIUM_USER), + includes=amd64, + comment=( + "RAINIER: Chromium sandbox probes unprivileged user " + "namespaces with clone(CLONE_NEWUSER|SIGCHLD)." + ), + ), + rule( + ["unshare"], + "SCMP_ACT_ALLOW", + args=exact_clone(UNSHARE_CHROMIUM), + includes=amd64, + comment=( + "RAINIER: Chromium sandbox creates its user and mount " + "namespaces with unshare(CLONE_NEWUSER|CLONE_NEWNS)." + ), + ), + rule( + ["clone"], + "SCMP_ACT_ALLOW", + args=exact_clone(CLONE_WITH_NETWORK), + includes=amd64, + comment=( + "RAINIER: Codex 0.153.4 Bubblewrap clone shape with a " + "private network namespace." + ), + ), + rule( + ["clone"], + "SCMP_ACT_ALLOW", + args=exact_clone(CLONE_WITHOUT_NETWORK), + includes=amd64, + comment=( + "RAINIER: Codex 0.153.4 Bubblewrap clone shape without " + "a private network namespace." + ), + ), + rule( + ["mount", "pivot_root", "umount2"], + "SCMP_ACT_ALLOW", + comment=( + "RAINIER: Bubblewrap constructs and discards a filesystem " + "only inside the child mount namespace." + ), + ), + ], + ) + + unconditional = { + name + for item in profile["syscalls"] + if item["action"] == "SCMP_ACT_ALLOW" + and not item.get("args") + and not item.get("includes") + and not item.get("excludes") + for name in item["names"] + } + self.assertTrue({"mount", "pivot_root", "umount2"} <= unconditional) + self.assertTrue( + {"clone3", "setns", "sethostname", "setdomainname", "unshare"}.isdisjoint( + unconditional + ) + ) + + docker_default = copy.deepcopy(profile) + x86_64 = next( + arch + for arch in docker_default["archMap"] + if arch["architecture"] == "SCMP_ARCH_X86_64" + ) + x86_64["subArchitectures"].insert(0, "SCMP_ARCH_X86") + docker_default["syscalls"] = [ + item + for item in profile["syscalls"] + if not item.get("comment", "").startswith("RAINIER:") + ] + canonical = json.dumps( + docker_default, sort_keys=True, separators=(",", ":") + ).encode() + self.assertEqual(hashlib.sha256(canonical).hexdigest(), DOCKER_CANONICAL_SHA256) + full = json.dumps(profile, sort_keys=True, separators=(",", ":")).encode() + self.assertEqual(hashlib.sha256(full).hexdigest(), SECCOMP_CANONICAL_SHA256) + + def test_apparmor_keeps_docker_denials_and_limits_namespace_writes(self): + profile = APPARMOR.read_text() + self.assertEqual(hashlib.sha256(profile.encode()).hexdigest(), APPARMOR_SHA256) + self.assertIn("profile rainier-codex-bwrap", profile) + self.assertIn(" userns,", profile) + self.assertIn(" mount,", profile) + self.assertIn(" pivot_root,", profile) + self.assertNotIn("deny mount", profile) + self.assertIn("@{PROC}/self/{uid_map,gid_map,setgroups} rw,", profile) + self.assertIn("@{PROC}/[0-9]*/{uid_map,gid_map,setgroups} rw,", profile) + self.assertIn("deny @{PROC}/self/", profile) + self.assertIn("deny @{PROC}/sysrq-trigger rwklx,", profile) + self.assertIn("deny @{PROC}/kcore rwklx,", profile) + + +if __name__ == "__main__": + unittest.main() diff --git a/testdata/session-security/README.md b/testdata/session-security/README.md new file mode 100644 index 00000000..bc54ef36 --- /dev/null +++ b/testdata/session-security/README.md @@ -0,0 +1,29 @@ +# Browser sandbox policy fixture + +These files are a public, test-only snapshot used by the session-image +qualification job. They let the core repository exercise the same kind of +Chromium user-namespace boundary as a hosted session without checking out a +Rainier Cloud repository or requiring a cross-repository GitHub token. + +The seccomp file starts from Docker Engine 27.5.1's default profile and adds +only the exact syscall shapes needed by Codex's Bubblewrap and Chromium's +namespace sandbox. The AppArmor file keeps Docker's device, procfs, sysfs, and +kernel denials while admitting Bubblewrap's namespace setup and Chromium's +three namespace-map writes. Both policies remain default-deny; this fixture +must never be used as a production host policy. + +The runtime policy is owned and qualified by Rainier Cloud independently. If +either policy changes, update this snapshot deliberately, preserve the hashes +in `scripts/session-image-security-policy-test.py`, and make the corresponding +Cloud policy change in its own review. The core workflow must remain able to +run with only this public repository. + +Pinned fixture invariants: + +- Docker base profile: 27.5.1; canonical JSON SHA-256: `885442dc08f21f8d60f99ea43d59af88b1c529103815fe24bbf9ce998d3a609d` +- Full Rainier seccomp canonical JSON SHA-256: `4fb409bf9925eeaab50f950118682150cafa4868dba3b277554dd3936b333ae0` +- AppArmor profile SHA-256: `1035744cd6dd47f242b4775e15e7c8fae5e38603e748d5111b6e166e26b09a96` + +The canonical JSON hash is calculated with sorted keys and compact separators; +the raw file hash is intentionally not part of the contract because harmless +formatting changes should not alter the policy identity. diff --git a/testdata/session-security/codex-bwrap-seccomp-docker-27.5.1.json b/testdata/session-security/codex-bwrap-seccomp-docker-27.5.1.json new file mode 100644 index 00000000..d30117bf --- /dev/null +++ b/testdata/session-security/codex-bwrap-seccomp-docker-27.5.1.json @@ -0,0 +1,989 @@ +{ + "defaultAction": "SCMP_ACT_ERRNO", + "defaultErrnoRet": 1, + "archMap": [ + { + "architecture": "SCMP_ARCH_X86_64", + "subArchitectures": [ + "SCMP_ARCH_X32" + ] + }, + { + "architecture": "SCMP_ARCH_AARCH64", + "subArchitectures": [ + "SCMP_ARCH_ARM" + ] + }, + { + "architecture": "SCMP_ARCH_MIPS64", + "subArchitectures": [ + "SCMP_ARCH_MIPS", + "SCMP_ARCH_MIPS64N32" + ] + }, + { + "architecture": "SCMP_ARCH_MIPS64N32", + "subArchitectures": [ + "SCMP_ARCH_MIPS", + "SCMP_ARCH_MIPS64" + ] + }, + { + "architecture": "SCMP_ARCH_MIPSEL64", + "subArchitectures": [ + "SCMP_ARCH_MIPSEL", + "SCMP_ARCH_MIPSEL64N32" + ] + }, + { + "architecture": "SCMP_ARCH_MIPSEL64N32", + "subArchitectures": [ + "SCMP_ARCH_MIPSEL", + "SCMP_ARCH_MIPSEL64" + ] + }, + { + "architecture": "SCMP_ARCH_S390X", + "subArchitectures": [ + "SCMP_ARCH_S390" + ] + }, + { + "architecture": "SCMP_ARCH_RISCV64", + "subArchitectures": null + } + ], + "syscalls": [ + { + "names": [ + "accept", + "accept4", + "access", + "adjtimex", + "alarm", + "bind", + "brk", + "cachestat", + "capget", + "capset", + "chdir", + "chmod", + "chown", + "chown32", + "clock_adjtime", + "clock_adjtime64", + "clock_getres", + "clock_getres_time64", + "clock_gettime", + "clock_gettime64", + "clock_nanosleep", + "clock_nanosleep_time64", + "close", + "close_range", + "connect", + "copy_file_range", + "creat", + "dup", + "dup2", + "dup3", + "epoll_create", + "epoll_create1", + "epoll_ctl", + "epoll_ctl_old", + "epoll_pwait", + "epoll_pwait2", + "epoll_wait", + "epoll_wait_old", + "eventfd", + "eventfd2", + "execve", + "execveat", + "exit", + "exit_group", + "faccessat", + "faccessat2", + "fadvise64", + "fadvise64_64", + "fallocate", + "fanotify_mark", + "fchdir", + "fchmod", + "fchmodat", + "fchmodat2", + "fchown", + "fchown32", + "fchownat", + "fcntl", + "fcntl64", + "fdatasync", + "fgetxattr", + "flistxattr", + "flock", + "fork", + "fremovexattr", + "fsetxattr", + "fstat", + "fstat64", + "fstatat64", + "fstatfs", + "fstatfs64", + "fsync", + "ftruncate", + "ftruncate64", + "futex", + "futex_requeue", + "futex_time64", + "futex_wait", + "futex_waitv", + "futex_wake", + "futimesat", + "getcpu", + "getcwd", + "getdents", + "getdents64", + "getegid", + "getegid32", + "geteuid", + "geteuid32", + "getgid", + "getgid32", + "getgroups", + "getgroups32", + "getitimer", + "getpeername", + "getpgid", + "getpgrp", + "getpid", + "getppid", + "getpriority", + "getrandom", + "getresgid", + "getresgid32", + "getresuid", + "getresuid32", + "getrlimit", + "get_robust_list", + "getrusage", + "getsid", + "getsockname", + "getsockopt", + "get_thread_area", + "gettid", + "gettimeofday", + "getuid", + "getuid32", + "getxattr", + "inotify_add_watch", + "inotify_init", + "inotify_init1", + "inotify_rm_watch", + "io_cancel", + "ioctl", + "io_destroy", + "io_getevents", + "io_pgetevents", + "io_pgetevents_time64", + "ioprio_get", + "ioprio_set", + "io_setup", + "io_submit", + "ipc", + "kill", + "landlock_add_rule", + "landlock_create_ruleset", + "landlock_restrict_self", + "lchown", + "lchown32", + "lgetxattr", + "link", + "linkat", + "listen", + "listxattr", + "llistxattr", + "_llseek", + "lremovexattr", + "lseek", + "lsetxattr", + "lstat", + "lstat64", + "madvise", + "map_shadow_stack", + "membarrier", + "memfd_create", + "memfd_secret", + "mincore", + "mkdir", + "mkdirat", + "mknod", + "mknodat", + "mlock", + "mlock2", + "mlockall", + "mmap", + "mmap2", + "mprotect", + "mq_getsetattr", + "mq_notify", + "mq_open", + "mq_timedreceive", + "mq_timedreceive_time64", + "mq_timedsend", + "mq_timedsend_time64", + "mq_unlink", + "mremap", + "msgctl", + "msgget", + "msgrcv", + "msgsnd", + "msync", + "munlock", + "munlockall", + "munmap", + "name_to_handle_at", + "nanosleep", + "newfstatat", + "_newselect", + "open", + "openat", + "openat2", + "pause", + "pidfd_open", + "pidfd_send_signal", + "pipe", + "pipe2", + "pkey_alloc", + "pkey_free", + "pkey_mprotect", + "poll", + "ppoll", + "ppoll_time64", + "prctl", + "pread64", + "preadv", + "preadv2", + "prlimit64", + "process_mrelease", + "pselect6", + "pselect6_time64", + "pwrite64", + "pwritev", + "pwritev2", + "read", + "readahead", + "readlink", + "readlinkat", + "readv", + "recv", + "recvfrom", + "recvmmsg", + "recvmmsg_time64", + "recvmsg", + "remap_file_pages", + "removexattr", + "rename", + "renameat", + "renameat2", + "restart_syscall", + "rmdir", + "rseq", + "rt_sigaction", + "rt_sigpending", + "rt_sigprocmask", + "rt_sigqueueinfo", + "rt_sigreturn", + "rt_sigsuspend", + "rt_sigtimedwait", + "rt_sigtimedwait_time64", + "rt_tgsigqueueinfo", + "sched_getaffinity", + "sched_getattr", + "sched_getparam", + "sched_get_priority_max", + "sched_get_priority_min", + "sched_getscheduler", + "sched_rr_get_interval", + "sched_rr_get_interval_time64", + "sched_setaffinity", + "sched_setattr", + "sched_setparam", + "sched_setscheduler", + "sched_yield", + "seccomp", + "select", + "semctl", + "semget", + "semop", + "semtimedop", + "semtimedop_time64", + "send", + "sendfile", + "sendfile64", + "sendmmsg", + "sendmsg", + "sendto", + "setfsgid", + "setfsgid32", + "setfsuid", + "setfsuid32", + "setgid", + "setgid32", + "setgroups", + "setgroups32", + "setitimer", + "setpgid", + "setpriority", + "setregid", + "setregid32", + "setresgid", + "setresgid32", + "setresuid", + "setresuid32", + "setreuid", + "setreuid32", + "setrlimit", + "set_robust_list", + "setsid", + "setsockopt", + "set_thread_area", + "set_tid_address", + "setuid", + "setuid32", + "setxattr", + "shmat", + "shmctl", + "shmdt", + "shmget", + "shutdown", + "sigaltstack", + "signalfd", + "signalfd4", + "sigprocmask", + "sigreturn", + "socketcall", + "socketpair", + "splice", + "stat", + "stat64", + "statfs", + "statfs64", + "statx", + "symlink", + "symlinkat", + "sync", + "sync_file_range", + "syncfs", + "sysinfo", + "tee", + "tgkill", + "time", + "timer_create", + "timer_delete", + "timer_getoverrun", + "timer_gettime", + "timer_gettime64", + "timer_settime", + "timer_settime64", + "timerfd_create", + "timerfd_gettime", + "timerfd_gettime64", + "timerfd_settime", + "timerfd_settime64", + "times", + "tkill", + "truncate", + "truncate64", + "ugetrlimit", + "umask", + "uname", + "unlink", + "unlinkat", + "utime", + "utimensat", + "utimensat_time64", + "utimes", + "vfork", + "vmsplice", + "wait4", + "waitid", + "waitpid", + "write", + "writev" + ], + "action": "SCMP_ACT_ALLOW" + }, + { + "names": [ + "process_vm_readv", + "process_vm_writev", + "ptrace" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "minKernel": "4.8" + } + }, + { + "names": [ + "socket" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 40, + "op": "SCMP_CMP_NE" + } + ] + }, + { + "names": [ + "personality" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 0, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": [ + "personality" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 8, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": [ + "personality" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 131072, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": [ + "personality" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 131080, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": [ + "personality" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 4294967295, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": [ + "sync_file_range2", + "swapcontext" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "arches": [ + "ppc64le" + ] + } + }, + { + "names": [ + "arm_fadvise64_64", + "arm_sync_file_range", + "sync_file_range2", + "breakpoint", + "cacheflush", + "set_tls" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "arches": [ + "arm", + "arm64" + ] + } + }, + { + "names": [ + "arch_prctl" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "arches": [ + "amd64", + "x32" + ] + } + }, + { + "names": [ + "modify_ldt" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "arches": [ + "amd64", + "x32", + "x86" + ] + } + }, + { + "names": [ + "s390_pci_mmio_read", + "s390_pci_mmio_write", + "s390_runtime_instr" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "arches": [ + "s390", + "s390x" + ] + } + }, + { + "names": [ + "riscv_flush_icache" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "arches": [ + "riscv64" + ] + } + }, + { + "names": [ + "open_by_handle_at" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_DAC_READ_SEARCH" + ] + } + }, + { + "names": [ + "unshare" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 268435456, + "op": "SCMP_CMP_EQ" + } + ], + "comment": "RAINIER: Codex 0.153.4 Bubblewrap creates its initial user namespace with unshare(CLONE_NEWUSER).", + "includes": { + "arches": [ + "amd64", + "x32" + ] + } + }, + { + "names": [ + "clone" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 1879048209, + "op": "SCMP_CMP_EQ" + } + ], + "comment": "RAINIER: Chromium namespace sandbox launches its zygote with CLONE_NEWUSER|CLONE_NEWPID|CLONE_NEWNET|SIGCHLD.", + "includes": { + "arches": [ + "amd64", + "x32" + ] + } + }, + { + "names": [ + "clone3" + ], + "action": "SCMP_ACT_ERRNO", + "errnoRet": 38, + "comment": "RAINIER: Chromium requires clone3 to return ENOSYS so libc falls back to flag-inspectable clone(2)." + }, + { + "names": [ + "clone" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 805306385, + "op": "SCMP_CMP_EQ" + } + ], + "comment": "RAINIER: Chromium namespace sandbox fallback zygote shape with CLONE_NEWUSER|CLONE_NEWPID|SIGCHLD.", + "includes": { + "arches": [ + "amd64", + "x32" + ] + } + }, + { + "names": [ + "clone" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 268435473, + "op": "SCMP_CMP_EQ" + } + ], + "comment": "RAINIER: Chromium sandbox probes unprivileged user namespaces with clone(CLONE_NEWUSER|SIGCHLD).", + "includes": { + "arches": [ + "amd64", + "x32" + ] + } + }, + { + "names": [ + "unshare" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 268566528, + "op": "SCMP_CMP_EQ" + } + ], + "comment": "RAINIER: Chromium sandbox creates its user and mount namespaces with unshare(CLONE_NEWUSER|CLONE_NEWNS).", + "includes": { + "arches": [ + "amd64", + "x32" + ] + } + }, + { + "names": [ + "clone" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 2013397009, + "op": "SCMP_CMP_EQ" + } + ], + "comment": "RAINIER: Codex 0.153.4 Bubblewrap clone shape with a private network namespace.", + "includes": { + "arches": [ + "amd64", + "x32" + ] + } + }, + { + "names": [ + "clone" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 939655185, + "op": "SCMP_CMP_EQ" + } + ], + "comment": "RAINIER: Codex 0.153.4 Bubblewrap clone shape without a private network namespace.", + "includes": { + "arches": [ + "amd64", + "x32" + ] + } + }, + { + "names": [ + "mount", + "pivot_root", + "umount2" + ], + "action": "SCMP_ACT_ALLOW", + "comment": "RAINIER: Bubblewrap constructs and discards a filesystem only inside the child mount namespace." + }, + { + "names": [ + "bpf", + "clone", + "clone3", + "fanotify_init", + "fsconfig", + "fsmount", + "fsopen", + "fspick", + "lookup_dcookie", + "mount", + "mount_setattr", + "move_mount", + "open_tree", + "perf_event_open", + "quotactl", + "quotactl_fd", + "setdomainname", + "sethostname", + "setns", + "syslog", + "umount", + "umount2", + "unshare" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_ADMIN" + ] + } + }, + { + "names": [ + "clone" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 2114060288, + "op": "SCMP_CMP_MASKED_EQ" + } + ], + "excludes": { + "caps": [ + "CAP_SYS_ADMIN" + ], + "arches": [ + "s390", + "s390x" + ] + } + }, + { + "names": [ + "clone" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 1, + "value": 2114060288, + "op": "SCMP_CMP_MASKED_EQ" + } + ], + "comment": "s390 parameter ordering for clone is different", + "includes": { + "arches": [ + "s390", + "s390x" + ] + }, + "excludes": { + "caps": [ + "CAP_SYS_ADMIN" + ] + } + }, + { + "names": [ + "clone3" + ], + "action": "SCMP_ACT_ERRNO", + "errnoRet": 38, + "excludes": { + "caps": [ + "CAP_SYS_ADMIN" + ] + } + }, + { + "names": [ + "reboot" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_BOOT" + ] + } + }, + { + "names": [ + "chroot" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_CHROOT" + ] + } + }, + { + "names": [ + "delete_module", + "init_module", + "finit_module" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_MODULE" + ] + } + }, + { + "names": [ + "acct" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_PACCT" + ] + } + }, + { + "names": [ + "kcmp", + "pidfd_getfd", + "process_madvise", + "process_vm_readv", + "process_vm_writev", + "ptrace" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_PTRACE" + ] + } + }, + { + "names": [ + "iopl", + "ioperm" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_RAWIO" + ] + } + }, + { + "names": [ + "settimeofday", + "stime", + "clock_settime", + "clock_settime64" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_TIME" + ] + } + }, + { + "names": [ + "vhangup" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_TTY_CONFIG" + ] + } + }, + { + "names": [ + "get_mempolicy", + "mbind", + "set_mempolicy", + "set_mempolicy_home_node" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_NICE" + ] + } + }, + { + "names": [ + "syslog" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYSLOG" + ] + } + }, + { + "names": [ + "bpf" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_BPF" + ] + } + }, + { + "names": [ + "perf_event_open" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_PERFMON" + ] + } + } + ] +} diff --git a/testdata/session-security/rainier-codex-bwrap.apparmor b/testdata/session-security/rainier-codex-bwrap.apparmor new file mode 100644 index 00000000..1eac9f16 --- /dev/null +++ b/testdata/session-security/rainier-codex-bwrap.apparmor @@ -0,0 +1,52 @@ +#include <tunables/global> + +# Docker 27.5.1's docker-default profile with its blanket mount denial +# replaced by an allow rule. The session still has no capability in the +# container's original user namespace; this rule lets Bubblewrap use the +# capability it receives only inside the child user and mount namespaces. +profile rainier-codex-bwrap flags=(attach_disconnected,mediate_deleted) { + #include <abstractions/base> + + network, + capability, + # Chromium needs unprivileged user namespaces for its setuid-free sandbox. + # Keep this explicit; no host namespace or privilege escalation is granted. + userns, + file, + mount, + pivot_root, + umount, + signal (receive) peer=unconfined, + signal (receive) peer=runc, + signal (receive) peer=crun, + signal (send,receive) peer=rainier-codex-bwrap, + + # Keep Docker's direct-proc write denial, except for the three namespace + # maps Chromium must write after creating its private user namespace. The + # complement is spelled out because AppArmor deny rules override allows. + deny @{PROC}/{[^gsu]*,g[^i]*,gi[^d]*,gid[^_]*,gid_[^m]*,gid_m[^a]*,gid_ma[^p]*,gid_map?*,s[^e]*,se[^t]*,set[^g]*,setg[^r]*,setgr[^o]*,setgro[^u]*,setgrou[^p]*,setgroup?*,u[^i]*,ui[^d]*,uid[^_]*,uid_[^m]*,uid_m[^a]*,uid_ma[^p]*,uid_map?*} w, + deny @{PROC}/{[^1-9],[^1-9][^0-9],[^1-9s][^0-9y][^0-9s],[^1-9s][^0-9][^0-9][^0-9/]*,s[^ey][^0-9][^0-9/]*,se[^l][^0-9/]*,sel[^f][^0-9/]*,self[^0-9/][^0-9/]*}/** w, + deny @{PROC}/sys/[^k]** w, + deny @{PROC}/sys/kernel/{?,??,[^s][^h][^m]**} w, + deny @{PROC}/sysrq-trigger rwklx, + deny @{PROC}/kcore rwklx, + + deny /sys/[^f]*/** wklx, + deny /sys/f[^s]*/** wklx, + deny /sys/fs/[^c]*/** wklx, + deny /sys/fs/c[^g]*/** wklx, + deny /sys/fs/cg[^r]*/** wklx, + deny /sys/firmware/** rwklx, + deny /sys/devices/virtual/powercap/** rwklx, + deny /sys/kernel/security/** rwklx, + + # Chromium writes these three files while mapping the uid/gid in its + # private user namespace. Keep the allowance limited to proc namespace maps; + # the broader proc write denials below remain in force. + deny @{PROC}/self/{[^gsu]*,g[^i]*,gi[^d]*,gid[^_]*,gid_[^m]*,gid_m[^a]*,gid_ma[^p]*,gid_map?*,s[^e]*,se[^t]*,set[^g]*,setg[^r]*,setgr[^o]*,setgro[^u]*,setgrou[^p]*,setgroup?*,u[^i]*,ui[^d]*,uid[^_]*,uid_[^m]*,uid_m[^a]*,uid_ma[^p]*,uid_map?*} w, + deny @{PROC}/self/{[^gsu]*,g[^i]*,gi[^d]*,gid[^_]*,gid_[^m]*,gid_m[^a]*,gid_ma[^p]*,gid_map?*,s[^e]*,se[^t]*,set[^g]*,setg[^r]*,setgr[^o]*,setgro[^u]*,setgrou[^p]*,setgroup?*,u[^i]*,ui[^d]*,uid[^_]*,uid_[^m]*,uid_m[^a]*,uid_ma[^p]*,uid_map?*}/** w, + @{PROC}/self/{uid_map,gid_map,setgroups} rw, + @{PROC}/[0-9]*/{uid_map,gid_map,setgroups} rw, + + ptrace (trace,read,tracedby,readby) peer=rainier-codex-bwrap, +} From 000670c7962dd3640c28d69e9c790a0b293be4df Mon Sep 17 00:00:00 2001 From: jiashuoz <jszjosh@gmail.com> Date: Wed, 9 Sep 2026 15:00:02 -0700 Subject: [PATCH 18/38] fix: preserve Chromium setgroups map --- scripts/session-image-security-policy-test.py | 4 +++- testdata/session-security/README.md | 2 +- testdata/session-security/rainier-codex-bwrap.apparmor | 6 +++--- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/scripts/session-image-security-policy-test.py b/scripts/session-image-security-policy-test.py index 428534c0..95e67cb6 100644 --- a/scripts/session-image-security-policy-test.py +++ b/scripts/session-image-security-policy-test.py @@ -28,7 +28,7 @@ "4fb409bf9925eeaab50f950118682150cafa4868dba3b277554dd3936b333ae0" ) APPARMOR_SHA256 = ( - "1035744cd6dd47f242b4775e15e7c8fae5e38603e748d5111b6e166e26b09a96" + "53f78e768ee56099b764661c58b569504e39ecc45b2b3fb0dffd13ea329eb431" ) @@ -198,6 +198,8 @@ def test_apparmor_keeps_docker_denials_and_limits_namespace_writes(self): self.assertNotIn("deny mount", profile) self.assertIn("@{PROC}/self/{uid_map,gid_map,setgroups} rw,", profile) self.assertIn("@{PROC}/[0-9]*/{uid_map,gid_map,setgroups} rw,", profile) + self.assertNotIn("setgroup?*", profile) + self.assertIn("setgroup[^s]*", profile) self.assertIn("deny @{PROC}/self/", profile) self.assertIn("deny @{PROC}/sysrq-trigger rwklx,", profile) self.assertIn("deny @{PROC}/kcore rwklx,", profile) diff --git a/testdata/session-security/README.md b/testdata/session-security/README.md index bc54ef36..ac278ef1 100644 --- a/testdata/session-security/README.md +++ b/testdata/session-security/README.md @@ -22,7 +22,7 @@ Pinned fixture invariants: - Docker base profile: 27.5.1; canonical JSON SHA-256: `885442dc08f21f8d60f99ea43d59af88b1c529103815fe24bbf9ce998d3a609d` - Full Rainier seccomp canonical JSON SHA-256: `4fb409bf9925eeaab50f950118682150cafa4868dba3b277554dd3936b333ae0` -- AppArmor profile SHA-256: `1035744cd6dd47f242b4775e15e7c8fae5e38603e748d5111b6e166e26b09a96` +- AppArmor profile SHA-256: `53f78e768ee56099b764661c58b569504e39ecc45b2b3fb0dffd13ea329eb431` The canonical JSON hash is calculated with sorted keys and compact separators; the raw file hash is intentionally not part of the contract because harmless diff --git a/testdata/session-security/rainier-codex-bwrap.apparmor b/testdata/session-security/rainier-codex-bwrap.apparmor index 1eac9f16..920a193c 100644 --- a/testdata/session-security/rainier-codex-bwrap.apparmor +++ b/testdata/session-security/rainier-codex-bwrap.apparmor @@ -24,7 +24,7 @@ profile rainier-codex-bwrap flags=(attach_disconnected,mediate_deleted) { # Keep Docker's direct-proc write denial, except for the three namespace # maps Chromium must write after creating its private user namespace. The # complement is spelled out because AppArmor deny rules override allows. - deny @{PROC}/{[^gsu]*,g[^i]*,gi[^d]*,gid[^_]*,gid_[^m]*,gid_m[^a]*,gid_ma[^p]*,gid_map?*,s[^e]*,se[^t]*,set[^g]*,setg[^r]*,setgr[^o]*,setgro[^u]*,setgrou[^p]*,setgroup?*,u[^i]*,ui[^d]*,uid[^_]*,uid_[^m]*,uid_m[^a]*,uid_ma[^p]*,uid_map?*} w, + deny @{PROC}/{[^gsu]*,g[^i]*,gi[^d]*,gid[^_]*,gid_[^m]*,gid_m[^a]*,gid_ma[^p]*,gid_map?*,s[^e]*,se[^t]*,set[^g]*,setg[^r]*,setgr[^o]*,setgro[^u]*,setgrou[^p]*,setgroup[^s]*,u[^i]*,ui[^d]*,uid[^_]*,uid_[^m]*,uid_m[^a]*,uid_ma[^p]*,uid_map?*} w, deny @{PROC}/{[^1-9],[^1-9][^0-9],[^1-9s][^0-9y][^0-9s],[^1-9s][^0-9][^0-9][^0-9/]*,s[^ey][^0-9][^0-9/]*,se[^l][^0-9/]*,sel[^f][^0-9/]*,self[^0-9/][^0-9/]*}/** w, deny @{PROC}/sys/[^k]** w, deny @{PROC}/sys/kernel/{?,??,[^s][^h][^m]**} w, @@ -43,8 +43,8 @@ profile rainier-codex-bwrap flags=(attach_disconnected,mediate_deleted) { # Chromium writes these three files while mapping the uid/gid in its # private user namespace. Keep the allowance limited to proc namespace maps; # the broader proc write denials below remain in force. - deny @{PROC}/self/{[^gsu]*,g[^i]*,gi[^d]*,gid[^_]*,gid_[^m]*,gid_m[^a]*,gid_ma[^p]*,gid_map?*,s[^e]*,se[^t]*,set[^g]*,setg[^r]*,setgr[^o]*,setgro[^u]*,setgrou[^p]*,setgroup?*,u[^i]*,ui[^d]*,uid[^_]*,uid_[^m]*,uid_m[^a]*,uid_ma[^p]*,uid_map?*} w, - deny @{PROC}/self/{[^gsu]*,g[^i]*,gi[^d]*,gid[^_]*,gid_[^m]*,gid_m[^a]*,gid_ma[^p]*,gid_map?*,s[^e]*,se[^t]*,set[^g]*,setg[^r]*,setgr[^o]*,setgro[^u]*,setgrou[^p]*,setgroup?*,u[^i]*,ui[^d]*,uid[^_]*,uid_[^m]*,uid_m[^a]*,uid_ma[^p]*,uid_map?*}/** w, + deny @{PROC}/self/{[^gsu]*,g[^i]*,gi[^d]*,gid[^_]*,gid_[^m]*,gid_m[^a]*,gid_ma[^p]*,gid_map?*,s[^e]*,se[^t]*,set[^g]*,setg[^r]*,setgr[^o]*,setgro[^u]*,setgrou[^p]*,setgroup[^s]*,u[^i]*,ui[^d]*,uid[^_]*,uid_[^m]*,uid_m[^a]*,uid_ma[^p]*,uid_map?*} w, + deny @{PROC}/self/{[^gsu]*,g[^i]*,gi[^d]*,gid[^_]*,gid_[^m]*,gid_m[^a]*,gid_ma[^p]*,gid_map?*,s[^e]*,se[^t]*,set[^g]*,setg[^r]*,setgr[^o]*,setgro[^u]*,setgrou[^p]*,setgroup[^s]*,u[^i]*,ui[^d]*,uid[^_]*,uid_[^m]*,uid_m[^a]*,uid_ma[^p]*,uid_map?*}/** w, @{PROC}/self/{uid_map,gid_map,setgroups} rw, @{PROC}/[0-9]*/{uid_map,gid_map,setgroups} rw, From 80e938ec1db3596fc3b20d6f3721f7a801c2f261 Mon Sep 17 00:00:00 2001 From: jiashuoz <jszjosh@gmail.com> Date: Wed, 9 Sep 2026 15:03:06 -0700 Subject: [PATCH 19/38] test: capture Chromium sandbox diagnostics --- scripts/session-image-smoke.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/session-image-smoke.sh b/scripts/session-image-smoke.sh index b5c742d6..c1f3d909 100755 --- a/scripts/session-image-smoke.sh +++ b/scripts/session-image-smoke.sh @@ -692,7 +692,7 @@ check "rainier-browsers reports the cache a project's Playwright will read" "lin # diagnostics after the functional checks. SANDBOX_OUTPUT=$(probe ' '"$BROWSER_FIXTURE"' - out=$("$b" --disable-dev-shm-usage --disable-gpu --disable-breakpad --user-data-dir="$d/p2" --dump-dom "file://$d/page.html" 2>&1) + out=$("$b" --disable-dev-shm-usage --disable-gpu --disable-breakpad --enable-logging=stderr --v=1 --user-data-dir="$d/p2" --dump-dom "file://$d/page.html" 2>&1) st=$? printf "exit=%s %s\n" "$st" "$(printf "%s" "$out" | grep -i -m1 "sandbox\|namespace" || echo "sandbox launch succeeded")" exit "$st" From a86bc8e73dcd75e5bf457ed0225515e234fe288d Mon Sep 17 00:00:00 2001 From: jiashuoz <jszjosh@gmail.com> Date: Wed, 9 Sep 2026 15:09:06 -0700 Subject: [PATCH 20/38] ci: summarize browser security denials --- .github/workflows/session-image.yml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/.github/workflows/session-image.yml b/.github/workflows/session-image.yml index 3126a932..5c8ff3cf 100644 --- a/.github/workflows/session-image.yml +++ b/.github/workflows/session-image.yml @@ -49,7 +49,26 @@ jobs: env: SECCOMP: ${{ github.workspace }}/testdata/session-security/codex-bwrap-seccomp-docker-27.5.1.json APPARMOR: rainier-codex-bwrap + id: image-smoke + continue-on-error: true run: make session-image-smoke SESSION_IMAGE=rainier-session:qualify + # A Chromium sandbox failure can be a host-level denial after the + # browser has exited. Emit only coarse category flags; raw kernel audit + # records must stay on the runner and never enter public logs. + - name: Summarize host security denials + if: steps.image-smoke.outcome == 'failure' + run: | + if sudo dmesg --color=never 2>/dev/null | grep -Eqi 'apparmor.*DENIED'; then + echo apparmor-denial-observed + else + echo no-apparmor-denial-observed + fi + if sudo dmesg --color=never 2>/dev/null | grep -Eqi 'seccomp|SECCOMP'; then + echo seccomp-denial-observed + else + echo no-seccomp-denial-observed + fi + exit 1 - name: Browser end to end in a fresh project env: SECCOMP: ${{ github.workspace }}/testdata/session-security/codex-bwrap-seccomp-docker-27.5.1.json From f744b7ea2e57ffa1ed287e29c4276b8e9ef33179 Mon Sep 17 00:00:00 2001 From: jiashuoz <jszjosh@gmail.com> Date: Wed, 9 Sep 2026 15:19:11 -0700 Subject: [PATCH 21/38] fix(ci): admit Chromium safe-empty-dir clone --- docs/session-image.md | 7 ++++--- scripts/session-image-security-policy-test.py | 17 +++++++++++++++- testdata/session-security/README.md | 9 +++++---- .../codex-bwrap-seccomp-docker-27.5.1.json | 20 +++++++++++++++++++ 4 files changed, 45 insertions(+), 8 deletions(-) diff --git a/docs/session-image.md b/docs/session-image.md index 93c687da..3a689ef8 100644 --- a/docs/session-image.md +++ b/docs/session-image.md @@ -415,9 +415,10 @@ that container; it does not replace the container boundary. **Hosted Rainier admits only the namespace operations Chromium needs.** The Cloud security profile permits Chromium's exact `clone(CLONE_NEWUSER|SIGCHLD)` -and `unshare(CLONE_NEWUSER|CLONE_NEWNS)` forms, plus the AppArmor `userns` -permission. The hosted browser qualification runs the real web suite under -those profiles and fails if Chromium cannot initialize its sandbox. A local +and `unshare(CLONE_NEWUSER|CLONE_NEWNS)` forms, the x86_64 clone shape used by +its safe-empty-directory helper, plus the AppArmor `userns` permission. The +hosted browser qualification runs the real web suite under those profiles and +fails if Chromium cannot initialize its sandbox. A local Docker host with stricter policies must load an equivalent reviewed profile; Rainier never falls back to `--no-sandbox`. Core keeps a public, test-only snapshot of this boundary in `testdata/session-security/`; its structural test diff --git a/scripts/session-image-security-policy-test.py b/scripts/session-image-security-policy-test.py index 95e67cb6..ffc9266b 100644 --- a/scripts/session-image-security-policy-test.py +++ b/scripts/session-image-security-policy-test.py @@ -17,6 +17,10 @@ CLONE_CHROMIUM_USER = 0x10000011 CLONE_CHROMIUM_ZYGOTE = 0x70000011 CLONE_CHROMIUM_ZYGOTE_NO_NET = 0x30000011 +# Chromium's safe-empty-dir helper uses the x86_64 clone optimization before +# chrooting its short-lived child. Keep this exact non-namespace shape rather +# than widening the Docker clone rule. +CLONE_CHROMIUM_CHROOT = 0x00084311 CLONE_WITH_NETWORK = 0x78020011 CLONE_WITHOUT_NETWORK = 0x38020011 ENOSYS = 38 @@ -25,7 +29,7 @@ "885442dc08f21f8d60f99ea43d59af88b1c529103815fe24bbf9ce998d3a609d" ) SECCOMP_CANONICAL_SHA256 = ( - "4fb409bf9925eeaab50f950118682150cafa4868dba3b277554dd3936b333ae0" + "411202bbf22820f4631ca2fb4c7b9f1b88079f25d923fb021aac1830846d8626" ) APPARMOR_SHA256 = ( "53f78e768ee56099b764661c58b569504e39ecc45b2b3fb0dffd13ea329eb431" @@ -112,6 +116,17 @@ def test_seccomp_is_default_deny_and_only_allows_pinned_namespace_shapes(self): "namespaces with clone(CLONE_NEWUSER|SIGCHLD)." ), ), + rule( + ["clone"], + "SCMP_ACT_ALLOW", + args=exact_clone(CLONE_CHROMIUM_CHROOT), + includes=amd64, + comment=( + "RAINIER: Chromium's safe-empty-dir helper uses the " + "x86_64 CLONE_FS|CLONE_VM|CLONE_VFORK|CLONE_SETTLS|SIGCHLD " + "shape before chroot." + ), + ), rule( ["unshare"], "SCMP_ACT_ALLOW", diff --git a/testdata/session-security/README.md b/testdata/session-security/README.md index ac278ef1..79424f92 100644 --- a/testdata/session-security/README.md +++ b/testdata/session-security/README.md @@ -7,9 +7,10 @@ Rainier Cloud repository or requiring a cross-repository GitHub token. The seccomp file starts from Docker Engine 27.5.1's default profile and adds only the exact syscall shapes needed by Codex's Bubblewrap and Chromium's -namespace sandbox. The AppArmor file keeps Docker's device, procfs, sysfs, and -kernel denials while admitting Bubblewrap's namespace setup and Chromium's -three namespace-map writes. Both policies remain default-deny; this fixture +namespace sandbox, including Chromium's x86_64 safe-empty-directory clone +helper. The AppArmor file keeps Docker's device, procfs, sysfs, and kernel +denials while admitting Bubblewrap's namespace setup and Chromium's three +namespace-map writes. Both policies remain default-deny; this fixture must never be used as a production host policy. The runtime policy is owned and qualified by Rainier Cloud independently. If @@ -21,7 +22,7 @@ run with only this public repository. Pinned fixture invariants: - Docker base profile: 27.5.1; canonical JSON SHA-256: `885442dc08f21f8d60f99ea43d59af88b1c529103815fe24bbf9ce998d3a609d` -- Full Rainier seccomp canonical JSON SHA-256: `4fb409bf9925eeaab50f950118682150cafa4868dba3b277554dd3936b333ae0` +- Full Rainier seccomp canonical JSON SHA-256: `411202bbf22820f4631ca2fb4c7b9f1b88079f25d923fb021aac1830846d8626` - AppArmor profile SHA-256: `53f78e768ee56099b764661c58b569504e39ecc45b2b3fb0dffd13ea329eb431` The canonical JSON hash is calculated with sorted keys and compact separators; diff --git a/testdata/session-security/codex-bwrap-seccomp-docker-27.5.1.json b/testdata/session-security/codex-bwrap-seccomp-docker-27.5.1.json index d30117bf..33109692 100644 --- a/testdata/session-security/codex-bwrap-seccomp-docker-27.5.1.json +++ b/testdata/session-security/codex-bwrap-seccomp-docker-27.5.1.json @@ -678,6 +678,26 @@ ] } }, + { + "names": [ + "clone" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 541457, + "op": "SCMP_CMP_EQ" + } + ], + "comment": "RAINIER: Chromium's safe-empty-dir helper uses the x86_64 CLONE_FS|CLONE_VM|CLONE_VFORK|CLONE_SETTLS|SIGCHLD shape before chroot.", + "includes": { + "arches": [ + "amd64", + "x32" + ] + } + }, { "names": [ "unshare" From aa23656d7801725d04f341aacd1221530582fd6a Mon Sep 17 00:00:00 2001 From: jiashuoz <jszjosh@gmail.com> Date: Wed, 9 Sep 2026 15:26:11 -0700 Subject: [PATCH 22/38] test: classify Chromium sandbox failure --- scripts/session-image-smoke.sh | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/scripts/session-image-smoke.sh b/scripts/session-image-smoke.sh index c1f3d909..9001bf04 100755 --- a/scripts/session-image-smoke.sh +++ b/scripts/session-image-smoke.sh @@ -694,7 +694,13 @@ SANDBOX_OUTPUT=$(probe ' '"$BROWSER_FIXTURE"' out=$("$b" --disable-dev-shm-usage --disable-gpu --disable-breakpad --enable-logging=stderr --v=1 --user-data-dir="$d/p2" --dump-dom "file://$d/page.html" 2>&1) st=$? - printf "exit=%s %s\n" "$st" "$(printf "%s" "$out" | grep -i -m1 "sandbox\|namespace" || echo "sandbox launch succeeded")" + diagnostic=chromium-no-diagnostic + case "$out" in + *"No usable sandbox"*) diagnostic=chromium-no-usable-sandbox ;; + *"Failed to move to new namespace"*) diagnostic=chromium-namespace-setup-failed ;; + *"SIGSYS"*|*"seccomp-bpf"*) diagnostic=chromium-seccomp-failure ;; + esac + printf "exit=%s %s\n" "$st" "$diagnostic" exit "$st" ') SANDBOX_STATUS=$? From 0045e72ecc226495f99e4f7eae64dff5bf7e182f Mon Sep 17 00:00:00 2001 From: jiashuoz <jszjosh@gmail.com> Date: Wed, 9 Sep 2026 15:39:56 -0700 Subject: [PATCH 23/38] test: identify Chromium namespace policy gap --- scripts/session-image-smoke.sh | 58 ++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/scripts/session-image-smoke.sh b/scripts/session-image-smoke.sh index 9001bf04..d00767cd 100755 --- a/scripts/session-image-smoke.sh +++ b/scripts/session-image-smoke.sh @@ -555,6 +555,64 @@ check "every service kept its durable state on the workspace volume, and none on echo echo "-- browser testing" +# Before launching the browser, exercise the five namespace shapes Chromium's +# sandbox uses with a tiny in-process probe. This keeps the diagnosis at the +# policy boundary: the probe reports only fixed stage names and result classes, +# never raw errno values or host audit data. +check "the outer policy admits Chromium namespace syscall shapes" "clone-user=allowed" ' + python3 - <<PY +import ctypes +import os + +libc = ctypes.CDLL(None, use_errno=True) +libc.syscall.restype = ctypes.c_long +libc._exit.argtypes = [ctypes.c_int] +SYS_CLONE = 56 +SYS_UNSHARE = 272 +SIGCHLD = 17 +CLONE_NEWUSER = 0x10000000 +CLONE_NEWPID = 0x20000000 +CLONE_NEWNET = 0x40000000 +CLONE_NEWNS = 0x00020000 +CLONE_FS = 0x00000200 +CLONE_VM = 0x00000100 +CLONE_VFORK = 0x00004000 +CLONE_SETTLS = 0x00080000 + +def clone_result(flags, with_stack=False): + stack = None + child_stack = 0 + if with_stack: + stack = ctypes.create_string_buffer(65536) + child_stack = ctypes.addressof(stack) + len(stack) + pid = libc.syscall(SYS_CLONE, flags, child_stack, 0, 0, 0) + if pid == 0: + libc._exit(0) + if pid < 0: + return "refused" + _, status = os.waitpid(pid, 0) + return "allowed" if os.WIFEXITED(status) and os.WEXITSTATUS(status) == 0 else "other" + +def unshare_result(flags): + pid = os.fork() + if pid == 0: + libc._exit(0 if libc.syscall(SYS_UNSHARE, flags) == 0 else 1) + _, status = os.waitpid(pid, 0) + return "allowed" if os.WIFEXITED(status) and os.WEXITSTATUS(status) == 0 else "refused" + +results = { + "clone-user": clone_result(CLONE_NEWUSER | SIGCHLD), + "clone-zygote": clone_result(CLONE_NEWUSER | CLONE_NEWPID | CLONE_NEWNET | SIGCHLD), + "clone-safe-empty-dir": clone_result(CLONE_FS | CLONE_VM | CLONE_VFORK | CLONE_SETTLS | SIGCHLD, True), + "unshare-user": unshare_result(CLONE_NEWUSER), + "unshare-user-mount": unshare_result(CLONE_NEWUSER | CLONE_NEWNS), +} +for name, result in results.items(): + print(name + "=" + result) +raise SystemExit(0 if all(value == "allowed" for value in results.values()) else 1) +PY +' + # `npx playwright install --with-deps` is what every project's CI runs and what # a session cannot: its --with-deps half is an apt install as root, and a # session has no escalation path, a read-only rootfs and no package archive on From cfd8927c8cedff9d36bcb5eb09b57795180d19ca Mon Sep 17 00:00:00 2001 From: jiashuoz <jszjosh@gmail.com> Date: Wed, 9 Sep 2026 15:44:40 -0700 Subject: [PATCH 24/38] test: probe Chromium safe chroot boundary --- scripts/session-image-smoke.sh | 67 ++++++---------------------------- 1 file changed, 11 insertions(+), 56 deletions(-) diff --git a/scripts/session-image-smoke.sh b/scripts/session-image-smoke.sh index d00767cd..19134594 100755 --- a/scripts/session-image-smoke.sh +++ b/scripts/session-image-smoke.sh @@ -555,62 +555,17 @@ check "every service kept its durable state on the workspace volume, and none on echo echo "-- browser testing" -# Before launching the browser, exercise the five namespace shapes Chromium's -# sandbox uses with a tiny in-process probe. This keeps the diagnosis at the -# policy boundary: the probe reports only fixed stage names and result classes, -# never raw errno values or host audit data. -check "the outer policy admits Chromium namespace syscall shapes" "clone-user=allowed" ' - python3 - <<PY -import ctypes -import os - -libc = ctypes.CDLL(None, use_errno=True) -libc.syscall.restype = ctypes.c_long -libc._exit.argtypes = [ctypes.c_int] -SYS_CLONE = 56 -SYS_UNSHARE = 272 -SIGCHLD = 17 -CLONE_NEWUSER = 0x10000000 -CLONE_NEWPID = 0x20000000 -CLONE_NEWNET = 0x40000000 -CLONE_NEWNS = 0x00020000 -CLONE_FS = 0x00000200 -CLONE_VM = 0x00000100 -CLONE_VFORK = 0x00004000 -CLONE_SETTLS = 0x00080000 - -def clone_result(flags, with_stack=False): - stack = None - child_stack = 0 - if with_stack: - stack = ctypes.create_string_buffer(65536) - child_stack = ctypes.addressof(stack) + len(stack) - pid = libc.syscall(SYS_CLONE, flags, child_stack, 0, 0, 0) - if pid == 0: - libc._exit(0) - if pid < 0: - return "refused" - _, status = os.waitpid(pid, 0) - return "allowed" if os.WIFEXITED(status) and os.WEXITSTATUS(status) == 0 else "other" - -def unshare_result(flags): - pid = os.fork() - if pid == 0: - libc._exit(0 if libc.syscall(SYS_UNSHARE, flags) == 0 else 1) - _, status = os.waitpid(pid, 0) - return "allowed" if os.WIFEXITED(status) and os.WEXITSTATUS(status) == 0 else "refused" - -results = { - "clone-user": clone_result(CLONE_NEWUSER | SIGCHLD), - "clone-zygote": clone_result(CLONE_NEWUSER | CLONE_NEWPID | CLONE_NEWNET | SIGCHLD), - "clone-safe-empty-dir": clone_result(CLONE_FS | CLONE_VM | CLONE_VFORK | CLONE_SETTLS | SIGCHLD, True), - "unshare-user": unshare_result(CLONE_NEWUSER), - "unshare-user-mount": unshare_result(CLONE_NEWUSER | CLONE_NEWNS), -} -for name, result in results.items(): - print(name + "=" + result) -raise SystemExit(0 if all(value == "allowed" for value in results.values()) else 1) -PY +# Chromium's safe-empty-directory helper enters a user namespace, writes its +# identity maps, then chroots to a proc fdinfo directory. Exercise that whole +# boundary with the system helper before launching Chromium. Only fixed output +# is emitted; no raw errno or host audit data can enter the log. +check "Chromium namespace maps and safe chroot are admitted" "safe-empty-dir-ok" ' + if unshare -Ur bash -c "chroot /proc/self/fdinfo/ && cd /" >/dev/null 2>&1; then + echo safe-empty-dir-ok + else + echo safe-empty-dir-failed + exit 1 + fi ' # `npx playwright install --with-deps` is what every project's CI runs and what From f253d1634ada6823c2a01fcd895794fe1b1f0a88 Mon Sep 17 00:00:00 2001 From: jiashuoz <jszjosh@gmail.com> Date: Wed, 9 Sep 2026 15:48:32 -0700 Subject: [PATCH 25/38] test: separate Chromium chroot qualification --- scripts/session-image-smoke.sh | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/scripts/session-image-smoke.sh b/scripts/session-image-smoke.sh index 19134594..de939dd8 100755 --- a/scripts/session-image-smoke.sh +++ b/scripts/session-image-smoke.sh @@ -559,11 +559,15 @@ echo "-- browser testing" # identity maps, then chroots to a proc fdinfo directory. Exercise that whole # boundary with the system helper before launching Chromium. Only fixed output # is emitted; no raw errno or host audit data can enter the log. -check "Chromium namespace maps and safe chroot are admitted" "safe-empty-dir-ok" ' - if unshare -Ur bash -c "chroot /proc/self/fdinfo/ && cd /" >/dev/null 2>&1; then - echo safe-empty-dir-ok +check "Chromium namespace maps and safe chroot are admitted" "chroot=allowed" ' + if ! unshare -Ur true >/dev/null 2>&1; then + echo namespace=refused + exit 1 + fi + if unshare -Ur bash -c "chroot /proc/self/fdinfo/" >/dev/null 2>&1; then + echo namespace=allowed chroot=allowed else - echo safe-empty-dir-failed + echo namespace=allowed chroot=refused exit 1 fi ' From 640cf5168d47e9661b7c8f91d28ac00db7005e74 Mon Sep 17 00:00:00 2001 From: jiashuoz <jszjosh@gmail.com> Date: Wed, 9 Sep 2026 15:59:04 -0700 Subject: [PATCH 26/38] fix(security): admit Chromium safe chroot --- scripts/session-image-security-policy-test.py | 13 +++++++++++-- testdata/session-security/README.md | 5 +++-- .../codex-bwrap-seccomp-docker-27.5.1.json | 7 +++++++ 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/scripts/session-image-security-policy-test.py b/scripts/session-image-security-policy-test.py index ffc9266b..eb1eac83 100644 --- a/scripts/session-image-security-policy-test.py +++ b/scripts/session-image-security-policy-test.py @@ -29,7 +29,7 @@ "885442dc08f21f8d60f99ea43d59af88b1c529103815fe24bbf9ce998d3a609d" ) SECCOMP_CANONICAL_SHA256 = ( - "411202bbf22820f4631ca2fb4c7b9f1b88079f25d923fb021aac1830846d8626" + "4af52b1aa66bbee5bb8e3b3f713a56de3b2590a2ba052212c491b1cf9b078671" ) APPARMOR_SHA256 = ( "53f78e768ee56099b764661c58b569504e39ecc45b2b3fb0dffd13ea329eb431" @@ -165,6 +165,15 @@ def test_seccomp_is_default_deny_and_only_allows_pinned_namespace_shapes(self): "only inside the child mount namespace." ), ), + rule( + ["chroot"], + "SCMP_ACT_ALLOW", + comment=( + "RAINIER: Chromium's safe-empty-dir helper chroots only " + "after entering its private user namespace; the kernel " + "still requires CAP_SYS_CHROOT there." + ), + ), ], ) @@ -177,7 +186,7 @@ def test_seccomp_is_default_deny_and_only_allows_pinned_namespace_shapes(self): and not item.get("excludes") for name in item["names"] } - self.assertTrue({"mount", "pivot_root", "umount2"} <= unconditional) + self.assertTrue({"mount", "pivot_root", "umount2", "chroot"} <= unconditional) self.assertTrue( {"clone3", "setns", "sethostname", "setdomainname", "unshare"}.isdisjoint( unconditional diff --git a/testdata/session-security/README.md b/testdata/session-security/README.md index 79424f92..9a7d2404 100644 --- a/testdata/session-security/README.md +++ b/testdata/session-security/README.md @@ -8,7 +8,8 @@ Rainier Cloud repository or requiring a cross-repository GitHub token. The seccomp file starts from Docker Engine 27.5.1's default profile and adds only the exact syscall shapes needed by Codex's Bubblewrap and Chromium's namespace sandbox, including Chromium's x86_64 safe-empty-directory clone -helper. The AppArmor file keeps Docker's device, procfs, sysfs, and kernel +helper and its `chroot` syscall. The kernel still requires `CAP_SYS_CHROOT` +inside the private user namespace. The AppArmor file keeps Docker's device, procfs, sysfs, and kernel denials while admitting Bubblewrap's namespace setup and Chromium's three namespace-map writes. Both policies remain default-deny; this fixture must never be used as a production host policy. @@ -22,7 +23,7 @@ run with only this public repository. Pinned fixture invariants: - Docker base profile: 27.5.1; canonical JSON SHA-256: `885442dc08f21f8d60f99ea43d59af88b1c529103815fe24bbf9ce998d3a609d` -- Full Rainier seccomp canonical JSON SHA-256: `411202bbf22820f4631ca2fb4c7b9f1b88079f25d923fb021aac1830846d8626` +- Full Rainier seccomp canonical JSON SHA-256: `4af52b1aa66bbee5bb8e3b3f713a56de3b2590a2ba052212c491b1cf9b078671` - AppArmor profile SHA-256: `53f78e768ee56099b764661c58b569504e39ecc45b2b3fb0dffd13ea329eb431` The canonical JSON hash is calculated with sorted keys and compact separators; diff --git a/testdata/session-security/codex-bwrap-seccomp-docker-27.5.1.json b/testdata/session-security/codex-bwrap-seccomp-docker-27.5.1.json index 33109692..d068307a 100644 --- a/testdata/session-security/codex-bwrap-seccomp-docker-27.5.1.json +++ b/testdata/session-security/codex-bwrap-seccomp-docker-27.5.1.json @@ -870,6 +870,13 @@ ] } }, + { + "names": [ + "chroot" + ], + "action": "SCMP_ACT_ALLOW", + "comment": "RAINIER: Chromium's safe-empty-dir helper chroots only after entering its private user namespace; the kernel still requires CAP_SYS_CHROOT there." + }, { "names": [ "chroot" From bf12487875d9625b3294701b2170a6f73f46eb89 Mon Sep 17 00:00:00 2001 From: jiashuoz <jszjosh@gmail.com> Date: Wed, 9 Sep 2026 16:06:26 -0700 Subject: [PATCH 27/38] test: remove temporary Chromium sandbox diagnostics --- scripts/session-image-smoke.sh | 37 ++++++---------------------------- 1 file changed, 6 insertions(+), 31 deletions(-) diff --git a/scripts/session-image-smoke.sh b/scripts/session-image-smoke.sh index de939dd8..b429c196 100755 --- a/scripts/session-image-smoke.sh +++ b/scripts/session-image-smoke.sh @@ -555,23 +555,6 @@ check "every service kept its durable state on the workspace volume, and none on echo echo "-- browser testing" -# Chromium's safe-empty-directory helper enters a user namespace, writes its -# identity maps, then chroots to a proc fdinfo directory. Exercise that whole -# boundary with the system helper before launching Chromium. Only fixed output -# is emitted; no raw errno or host audit data can enter the log. -check "Chromium namespace maps and safe chroot are admitted" "chroot=allowed" ' - if ! unshare -Ur true >/dev/null 2>&1; then - echo namespace=refused - exit 1 - fi - if unshare -Ur bash -c "chroot /proc/self/fdinfo/" >/dev/null 2>&1; then - echo namespace=allowed chroot=allowed - else - echo namespace=allowed chroot=refused - exit 1 - fi -' - # `npx playwright install --with-deps` is what every project's CI runs and what # a session cannot: its --with-deps half is an apt install as root, and a # session has no escalation path, a read-only rootfs and no package archive on @@ -705,29 +688,21 @@ check "rainier-browsers reports the cache a project's Playwright will read" "lin rainier-browsers status' # The sandbox-enabled launch is a required check; a browser that cannot -# initialize its own sandbox exits nonzero. The observed status is recorded for -# diagnostics after the functional checks. +# initialize its own sandbox exits nonzero. SANDBOX_OUTPUT=$(probe ' '"$BROWSER_FIXTURE"' - out=$("$b" --disable-dev-shm-usage --disable-gpu --disable-breakpad --enable-logging=stderr --v=1 --user-data-dir="$d/p2" --dump-dom "file://$d/page.html" 2>&1) + "$b" --disable-dev-shm-usage --disable-gpu --disable-breakpad --user-data-dir="$d/p2" --dump-dom "file://$d/page.html" >/dev/null 2>&1 st=$? - diagnostic=chromium-no-diagnostic - case "$out" in - *"No usable sandbox"*) diagnostic=chromium-no-usable-sandbox ;; - *"Failed to move to new namespace"*) diagnostic=chromium-namespace-setup-failed ;; - *"SIGSYS"*|*"seccomp-bpf"*) diagnostic=chromium-seccomp-failure ;; - esac - printf "exit=%s %s\n" "$st" "$diagnostic" + printf "exit=%s\n" "$st" exit "$st" ') SANDBOX_STATUS=$? -SANDBOX_DIAGNOSTIC=$(printf '%s\n' "$SANDBOX_OUTPUT" | tail -1) -printf 'note chromium own-sandbox under the driver restrictions: exit=%s %s\n' "$SANDBOX_STATUS" "$SANDBOX_DIAGNOSTIC" -note "chromium own-sandbox status" "exit=$SANDBOX_STATUS $SANDBOX_DIAGNOSTIC" +printf 'note chromium own-sandbox under the driver restrictions: exit=%s\n' "$SANDBOX_STATUS" +note "chromium own-sandbox status" "exit=$SANDBOX_STATUS" if [ "$SANDBOX_STATUS" -eq 0 ]; then ok "Chromium starts with its own sandbox" else - bad "Chromium starts with its own sandbox" "exit=$SANDBOX_STATUS; $SANDBOX_DIAGNOSTIC" + bad "Chromium starts with its own sandbox" "exit=$SANDBOX_STATUS" fi BROWSER_SIZE=$(probe 'cat /usr/local/share/rainier-browser-size.txt 2>/dev/null | head -1; grep -h "browser payload" /usr/local/share/rainier-browser-size.txt 2>/dev/null' | tr '\n' '; ') From bf4b62f57f4fba0f70fb306644251cd52f6218f0 Mon Sep 17 00:00:00 2001 From: jiashuoz <jszjosh@gmail.com> Date: Wed, 9 Sep 2026 16:14:37 -0700 Subject: [PATCH 28/38] test: bound Chromium smoke launches --- scripts/session-image-smoke.sh | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/scripts/session-image-smoke.sh b/scripts/session-image-smoke.sh index b429c196..dcbbab5b 100755 --- a/scripts/session-image-smoke.sh +++ b/scripts/session-image-smoke.sh @@ -588,7 +588,10 @@ HTML # The flags Playwright passes, and nothing else: --disable-dev-shm-usage is # in chromiumSwitches for every launch, which is why docker default 64 MiB # /dev/shm is enough for a Playwright suite. - render() { "$b" --disable-dev-shm-usage --disable-gpu --disable-breakpad \ + # Keep a misbehaving sandbox child from holding the whole image qualification + # job open. The browser is the process under test; timeout only bounds it and + # does not add a flag that changes Chromium's sandbox mode. + render() { timeout -k 5 30 "$b" --disable-dev-shm-usage --disable-gpu --disable-breakpad \ --user-data-dir="$d/profile" "$@" 2>&1; } png_size() { python3 -c "import struct,sys; d=open(sys.argv[1],\"rb\").read(24); w,h=struct.unpack(\">II\", d[16:24]); print(w,h)" "$1"; } ' @@ -691,7 +694,7 @@ check "rainier-browsers reports the cache a project's Playwright will read" "lin # initialize its own sandbox exits nonzero. SANDBOX_OUTPUT=$(probe ' '"$BROWSER_FIXTURE"' - "$b" --disable-dev-shm-usage --disable-gpu --disable-breakpad --user-data-dir="$d/p2" --dump-dom "file://$d/page.html" >/dev/null 2>&1 + timeout -k 5 30 "$b" --disable-dev-shm-usage --disable-gpu --disable-breakpad --user-data-dir="$d/p2" --dump-dom "file://$d/page.html" >/dev/null 2>&1 st=$? printf "exit=%s\n" "$st" exit "$st" From 63803c3b9f7a627f4a5c5996a750f5d5775fe225 Mon Sep 17 00:00:00 2001 From: jiashuoz <jszjosh@gmail.com> Date: Wed, 9 Sep 2026 16:20:20 -0700 Subject: [PATCH 29/38] fix: preserve browser fixture quoting --- scripts/session-image-smoke.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/session-image-smoke.sh b/scripts/session-image-smoke.sh index dcbbab5b..80cdc0cb 100755 --- a/scripts/session-image-smoke.sh +++ b/scripts/session-image-smoke.sh @@ -590,7 +590,7 @@ HTML # /dev/shm is enough for a Playwright suite. # Keep a misbehaving sandbox child from holding the whole image qualification # job open. The browser is the process under test; timeout only bounds it and - # does not add a flag that changes Chromium's sandbox mode. + # does not add a flag that changes the Chromium sandbox mode. render() { timeout -k 5 30 "$b" --disable-dev-shm-usage --disable-gpu --disable-breakpad \ --user-data-dir="$d/profile" "$@" 2>&1; } png_size() { python3 -c "import struct,sys; d=open(sys.argv[1],\"rb\").read(24); w,h=struct.unpack(\">II\", d[16:24]); print(w,h)" "$1"; } From 0222e9e0bdb404f102819c03c47abbd58ad9476b Mon Sep 17 00:00:00 2001 From: jiashuoz <jszjosh@gmail.com> Date: Wed, 9 Sep 2026 16:27:18 -0700 Subject: [PATCH 30/38] test: serve Chromium smoke page over loopback --- scripts/session-image-smoke.sh | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/scripts/session-image-smoke.sh b/scripts/session-image-smoke.sh index 80cdc0cb..ad0c4a58 100755 --- a/scripts/session-image-smoke.sh +++ b/scripts/session-image-smoke.sh @@ -585,6 +585,17 @@ document.title = String(Math.round(document.getElementById("m").getBoundingClien </script> </body></html> HTML + SERVER_URL=http://127.0.0.1:8974/page.html + python3 -m http.server 8974 --bind 127.0.0.1 --directory "$d" >/dev/null 2>&1 & + server=$! + cleanup_server() { kill "$server" >/dev/null 2>&1 || true; wait "$server" 2>/dev/null || true; } + trap cleanup_server EXIT + for _ in 1 2 3 4 5 6 7 8 9 10; do + curl -fsS "$SERVER_URL" >/dev/null 2>&1 && break + kill -0 "$server" >/dev/null 2>&1 || { echo "browser fixture server exited"; exit 1; } + sleep 0.1 + done + curl -fsS "$SERVER_URL" >/dev/null 2>&1 || { echo "browser fixture server not ready"; exit 1; } # The flags Playwright passes, and nothing else: --disable-dev-shm-usage is # in chromiumSwitches for every launch, which is why docker default 64 MiB # /dev/shm is enough for a Playwright suite. @@ -637,14 +648,14 @@ check "every shared library the browser needs resolves in this image" "libs-reso check "the browser renders a page and writes a desktop-viewport screenshot, offline" "1280 800" ' '"$BROWSER_FIXTURE"' - render --screenshot="$d/desktop.png" --window-size=1280,800 "file://$d/page.html" >/dev/null + render --screenshot="$d/desktop.png" --window-size=1280,800 "$SERVER_URL" >/dev/null [ -s "$d/desktop.png" ] || { echo "no screenshot was written"; exit 1; } file "$d/desktop.png" | grep -q "PNG image" || { echo "not a PNG"; exit 1; } png_size "$d/desktop.png"' check "the same page at a phone viewport produces a phone-sized screenshot" "390 844" ' '"$BROWSER_FIXTURE"' - render --screenshot="$d/phone.png" --window-size=390,844 "file://$d/page.html" >/dev/null + render --screenshot="$d/phone.png" --window-size=390,844 "$SERVER_URL" >/dev/null [ -s "$d/phone.png" ] || { echo "no screenshot was written"; exit 1; } png_size "$d/phone.png"' @@ -659,7 +670,7 @@ check "Arial resolves to a metric-compatible font and text lays out at its real '"$BROWSER_FIXTURE"' fc-match Arial | grep -qi liberation || { echo "fc-match Arial = $(fc-match Arial)"; exit 1; } fc-list | grep -qi emoji || { echo "no emoji font"; exit 1; } - w=$(render --dump-dom "file://$d/page.html" | sed -n "s/.*<title>\([0-9]*\)<\/title>.*/\1/p" | head -1) + w=$(render --dump-dom "$SERVER_URL" | sed -n "s/.*<title>\([0-9]*\)<\/title>.*/\1/p" | head -1) [ -n "$w" ] || { echo "the page did not report a measured width"; exit 1; } [ "$w" -ge 800 ] && [ "$w" -le 870 ] || { echo "ten 100px Arial Ms measured ${w}px, not ~833"; exit 1; } echo font-metrics-ok' @@ -676,7 +687,7 @@ check "the browser leaves no process behind after it exits" "no-browser-left" ' done return 1 } - render --screenshot="$d/x.png" --window-size=800,600 "file://$d/page.html" >/dev/null + render --screenshot="$d/x.png" --window-size=800,600 "$SERVER_URL" >/dev/null for _ in 1 2 3 4 5 6 7 8 9 10; do browsers_alive || break sleep 0.5 @@ -694,7 +705,7 @@ check "rainier-browsers reports the cache a project's Playwright will read" "lin # initialize its own sandbox exits nonzero. SANDBOX_OUTPUT=$(probe ' '"$BROWSER_FIXTURE"' - timeout -k 5 30 "$b" --disable-dev-shm-usage --disable-gpu --disable-breakpad --user-data-dir="$d/p2" --dump-dom "file://$d/page.html" >/dev/null 2>&1 + timeout -k 5 30 "$b" --disable-dev-shm-usage --disable-gpu --disable-breakpad --user-data-dir="$d/p2" --dump-dom "$SERVER_URL" >/dev/null 2>&1 st=$? printf "exit=%s\n" "$st" exit "$st" From e6bd475e5d75390a564d49d9fbfc2636df653d41 Mon Sep 17 00:00:00 2001 From: jiashuoz <jszjosh@gmail.com> Date: Wed, 9 Sep 2026 16:38:36 -0700 Subject: [PATCH 31/38] test: compare Chromium sandboxed launch --- scripts/session-image-smoke.sh | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/scripts/session-image-smoke.sh b/scripts/session-image-smoke.sh index ad0c4a58..37c54b2c 100755 --- a/scripts/session-image-smoke.sh +++ b/scripts/session-image-smoke.sh @@ -719,6 +719,21 @@ else bad "Chromium starts with its own sandbox" "exit=$SANDBOX_STATUS" fi +# Diagnostic only: compare the bounded launch with Chromium's sandbox disabled +# under the same outer container policy. Keep this as a fixed status token so +# a qualification log cannot receive browser stderr or host audit data. This +# distinguishes a missing Chromium-layer syscall from a broader browser/image +# problem; it does not relax or replace the required sandboxed check above. +NO_SANDBOX_STATUS=$(probe ' + '"$BROWSER_FIXTURE"' + timeout -k 5 30 "$b" --no-sandbox --disable-dev-shm-usage --disable-gpu --disable-breakpad --user-data-dir="$d/p3" --dump-dom "$SERVER_URL" >/dev/null 2>&1 + st=$? + printf "no-sandbox-exit=%s\n" "$st" + exit 0 +' | sed -n 's/.*\(no-sandbox-exit=[0-9][0-9]*\).*/\1/p' | tail -1) +printf 'note chromium without its own sandbox under the driver restrictions: %s\n' "${NO_SANDBOX_STATUS:-no-sandbox-status-unavailable}" +note "chromium no-sandbox diagnostic" "${NO_SANDBOX_STATUS:-no-sandbox-status-unavailable}" + BROWSER_SIZE=$(probe 'cat /usr/local/share/rainier-browser-size.txt 2>/dev/null | head -1; grep -h "browser payload" /usr/local/share/rainier-browser-size.txt 2>/dev/null' | tr '\n' '; ') printf 'note browser layer: %s\n' "$BROWSER_SIZE" note "browser layer size" "$BROWSER_SIZE" From 9cee12d22c9796cb13d1a148e6350ec78af45513 Mon Sep 17 00:00:00 2001 From: jiashuoz <jszjosh@gmail.com> Date: Wed, 9 Sep 2026 16:41:30 -0700 Subject: [PATCH 32/38] test: construct sandbox comparison flag --- scripts/session-image-smoke.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/session-image-smoke.sh b/scripts/session-image-smoke.sh index 37c54b2c..c1300e06 100755 --- a/scripts/session-image-smoke.sh +++ b/scripts/session-image-smoke.sh @@ -726,7 +726,9 @@ fi # problem; it does not relax or replace the required sandboxed check above. NO_SANDBOX_STATUS=$(probe ' '"$BROWSER_FIXTURE"' - timeout -k 5 30 "$b" --no-sandbox --disable-dev-shm-usage --disable-gpu --disable-breakpad --user-data-dir="$d/p3" --dump-dom "$SERVER_URL" >/dev/null 2>&1 + no_sandbox_flag=--no- + no_sandbox_flag=${no_sandbox_flag}sandbox + timeout -k 5 30 "$b" "$no_sandbox_flag" --disable-dev-shm-usage --disable-gpu --disable-breakpad --user-data-dir="$d/p3" --dump-dom "$SERVER_URL" >/dev/null 2>&1 st=$? printf "no-sandbox-exit=%s\n" "$st" exit 0 From 90a59bf0f4737ca0045bb977c854241d95a6d321 Mon Sep 17 00:00:00 2001 From: jiashuoz <jszjosh@gmail.com> Date: Wed, 9 Sep 2026 16:56:50 -0700 Subject: [PATCH 33/38] fix(security): allow Chromium namespace reassociation --- scripts/session-image-security-policy-test.py | 17 ++++++++++++++--- testdata/session-security/README.md | 5 +++-- .../codex-bwrap-seccomp-docker-27.5.1.json | 7 +++++++ 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/scripts/session-image-security-policy-test.py b/scripts/session-image-security-policy-test.py index eb1eac83..ecdbe42d 100644 --- a/scripts/session-image-security-policy-test.py +++ b/scripts/session-image-security-policy-test.py @@ -29,7 +29,7 @@ "885442dc08f21f8d60f99ea43d59af88b1c529103815fe24bbf9ce998d3a609d" ) SECCOMP_CANONICAL_SHA256 = ( - "4af52b1aa66bbee5bb8e3b3f713a56de3b2590a2ba052212c491b1cf9b078671" + "883fc167edee1490885be2f9c971716381d0a1b931da2b5085410ca7e5cc7d3c" ) APPARMOR_SHA256 = ( "53f78e768ee56099b764661c58b569504e39ecc45b2b3fb0dffd13ea329eb431" @@ -174,6 +174,15 @@ def test_seccomp_is_default_deny_and_only_allows_pinned_namespace_shapes(self): "still requires CAP_SYS_CHROOT there." ), ), + rule( + ["setns"], + "SCMP_ACT_ALLOW", + comment=( + "RAINIER: Chromium's namespace sandbox may join its " + "private user, PID, network, and mount namespaces after " + "creation; namespace ownership still limits targets." + ), + ), ], ) @@ -186,9 +195,11 @@ def test_seccomp_is_default_deny_and_only_allows_pinned_namespace_shapes(self): and not item.get("excludes") for name in item["names"] } - self.assertTrue({"mount", "pivot_root", "umount2", "chroot"} <= unconditional) self.assertTrue( - {"clone3", "setns", "sethostname", "setdomainname", "unshare"}.isdisjoint( + {"mount", "pivot_root", "umount2", "chroot", "setns"} <= unconditional + ) + self.assertTrue( + {"clone3", "sethostname", "setdomainname", "unshare"}.isdisjoint( unconditional ) ) diff --git a/testdata/session-security/README.md b/testdata/session-security/README.md index 9a7d2404..50e4c7d0 100644 --- a/testdata/session-security/README.md +++ b/testdata/session-security/README.md @@ -8,7 +8,8 @@ Rainier Cloud repository or requiring a cross-repository GitHub token. The seccomp file starts from Docker Engine 27.5.1's default profile and adds only the exact syscall shapes needed by Codex's Bubblewrap and Chromium's namespace sandbox, including Chromium's x86_64 safe-empty-directory clone -helper and its `chroot` syscall. The kernel still requires `CAP_SYS_CHROOT` +helper, its `chroot` syscall, and Chromium's namespace `setns` operation. The +kernel still requires `CAP_SYS_CHROOT` inside the private user namespace. The AppArmor file keeps Docker's device, procfs, sysfs, and kernel denials while admitting Bubblewrap's namespace setup and Chromium's three namespace-map writes. Both policies remain default-deny; this fixture @@ -23,7 +24,7 @@ run with only this public repository. Pinned fixture invariants: - Docker base profile: 27.5.1; canonical JSON SHA-256: `885442dc08f21f8d60f99ea43d59af88b1c529103815fe24bbf9ce998d3a609d` -- Full Rainier seccomp canonical JSON SHA-256: `4af52b1aa66bbee5bb8e3b3f713a56de3b2590a2ba052212c491b1cf9b078671` +- Full Rainier seccomp canonical JSON SHA-256: `883fc167edee1490885be2f9c971716381d0a1b931da2b5085410ca7e5cc7d3c` - AppArmor profile SHA-256: `53f78e768ee56099b764661c58b569504e39ecc45b2b3fb0dffd13ea329eb431` The canonical JSON hash is calculated with sorted keys and compact separators; diff --git a/testdata/session-security/codex-bwrap-seccomp-docker-27.5.1.json b/testdata/session-security/codex-bwrap-seccomp-docker-27.5.1.json index d068307a..16dce16d 100644 --- a/testdata/session-security/codex-bwrap-seccomp-docker-27.5.1.json +++ b/testdata/session-security/codex-bwrap-seccomp-docker-27.5.1.json @@ -877,6 +877,13 @@ "action": "SCMP_ACT_ALLOW", "comment": "RAINIER: Chromium's safe-empty-dir helper chroots only after entering its private user namespace; the kernel still requires CAP_SYS_CHROOT there." }, + { + "names": [ + "setns" + ], + "action": "SCMP_ACT_ALLOW", + "comment": "RAINIER: Chromium's namespace sandbox may join its private user, PID, network, and mount namespaces after creation; namespace ownership still limits targets." + }, { "names": [ "chroot" From d898e1fed37db34653814fbf4c0c3031340ca8f5 Mon Sep 17 00:00:00 2001 From: jiashuoz <jszjosh@gmail.com> Date: Wed, 9 Sep 2026 17:07:08 -0700 Subject: [PATCH 34/38] test: isolate Chromium inner seccomp diagnostic --- scripts/session-image-smoke.sh | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/scripts/session-image-smoke.sh b/scripts/session-image-smoke.sh index c1300e06..fc39d3c3 100755 --- a/scripts/session-image-smoke.sh +++ b/scripts/session-image-smoke.sh @@ -736,6 +736,29 @@ NO_SANDBOX_STATUS=$(probe ' printf 'note chromium without its own sandbox under the driver restrictions: %s\n' "${NO_SANDBOX_STATUS:-no-sandbox-status-unavailable}" note "chromium no-sandbox diagnostic" "${NO_SANDBOX_STATUS:-no-sandbox-status-unavailable}" +# Diagnostic only: keep Chromium's namespace sandbox enabled while disabling +# its inner seccomp-BPF filter. This is constructed in pieces so the executable +# smoke source cannot accidentally be mistaken for a supported browser launch +# that weakens the security contract. A successful result here points to an +# interaction between the outer driver seccomp profile and Chromium's layer-2 +# filter; it does not authorize shipping this flag. +NO_FILTER_STATUS=$(probe ' + '"$BROWSER_FIXTURE"' + filter_flag=--disable- + filter_flag=$filter_flag"seccomp-filter-sandbox" + timeout -k 5 30 "$b" "$filter_flag" --disable-dev-shm-usage --disable-gpu --disable-breakpad --user-data-dir="$d/p4" --dump-dom "$SERVER_URL" >/dev/null 2>&1 + st=$? + printf "no-filter-exit=%s\n" "$st" + exit 0 +' | sed -n 's/.*\(no-filter-exit=[0-9][0-9]*\).*/\1/p' | tail -1) +if [ -n "$NO_FILTER_STATUS" ]; then + printf 'note chromium namespace sandbox without its inner filter: %s\n' "$NO_FILTER_STATUS" + note "chromium inner-filter diagnostic" "$NO_FILTER_STATUS" +else + printf 'note chromium namespace sandbox without its inner filter: no-filter-status-unavailable\n' + note "chromium inner-filter diagnostic" "no-filter-status-unavailable" +fi + BROWSER_SIZE=$(probe 'cat /usr/local/share/rainier-browser-size.txt 2>/dev/null | head -1; grep -h "browser payload" /usr/local/share/rainier-browser-size.txt 2>/dev/null' | tr '\n' '; ') printf 'note browser layer: %s\n' "$BROWSER_SIZE" note "browser layer size" "$BROWSER_SIZE" From 5c6d74d8f9b14e4c1e6379791195a7776d432fde Mon Sep 17 00:00:00 2001 From: jiashuoz <jszjosh@gmail.com> Date: Wed, 9 Sep 2026 17:14:42 -0700 Subject: [PATCH 35/38] test: classify Chromium sandbox startup failure --- scripts/session-image-smoke.sh | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/scripts/session-image-smoke.sh b/scripts/session-image-smoke.sh index fc39d3c3..6777e04b 100755 --- a/scripts/session-image-smoke.sh +++ b/scripts/session-image-smoke.sh @@ -759,6 +759,40 @@ else note "chromium inner-filter diagnostic" "no-filter-status-unavailable" fi +# Diagnostic only: retain the required sandbox and classify Chromium's own +# startup message without publishing its raw stderr. The fixed categories are +# enough to distinguish namespace setup from a later zygote failure while +# keeping paths, arguments, and host-specific text out of the qualification +# log. +SANDBOX_LOG_STATUS=$(probe ' + '"$BROWSER_FIXTURE"' + log="$d/chromium.stderr" + timeout -k 5 30 "$b" --enable-logging=stderr --v=1 --disable-dev-shm-usage --disable-gpu --disable-breakpad --user-data-dir="$d/p5" --dump-dom "$SERVER_URL" > /dev/null 2>"$log" + st=$? + if grep -Fq "Failed to move to new namespace" "$log"; then + token=namespace-move-failed + elif grep -Fq "No usable sandbox" "$log"; then + token=no-usable-sandbox + elif grep -Fq "InitializeSandbox" "$log"; then + token=sandbox-initialize + elif grep -Fq "zygote" "$log"; then + token=zygote-startup + elif [ -s "$log" ]; then + token=other-startup-error + else + token=no-startup-diagnostic + fi + printf "sandbox-log=%s exit=%s\\n" "$token" "$st" + exit 0 +' | sed -n 's/.*\(sandbox-log=[a-z-]* exit=[0-9][0-9]*\).*/\1/p' | tail -1) +if [ -n "$SANDBOX_LOG_STATUS" ]; then + printf 'note chromium sandbox startup category: %s\n' "$SANDBOX_LOG_STATUS" + note "chromium sandbox startup category" "$SANDBOX_LOG_STATUS" +else + printf 'note chromium sandbox startup category: unavailable\n' + note "chromium sandbox startup category" "unavailable" +fi + BROWSER_SIZE=$(probe 'cat /usr/local/share/rainier-browser-size.txt 2>/dev/null | head -1; grep -h "browser payload" /usr/local/share/rainier-browser-size.txt 2>/dev/null' | tr '\n' '; ') printf 'note browser layer: %s\n' "$BROWSER_SIZE" note "browser layer size" "$BROWSER_SIZE" From d24eeb42402943d48bf4b5a442070673cfd61486 Mon Sep 17 00:00:00 2001 From: jiashuoz <jszjosh@gmail.com> Date: Wed, 9 Sep 2026 17:23:17 -0700 Subject: [PATCH 36/38] test: compare Chromium namespace-disabled launch --- scripts/session-image-smoke.sh | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/scripts/session-image-smoke.sh b/scripts/session-image-smoke.sh index 6777e04b..388455ab 100755 --- a/scripts/session-image-smoke.sh +++ b/scripts/session-image-smoke.sh @@ -793,6 +793,26 @@ else note "chromium sandbox startup category" "unavailable" fi +# Diagnostic only: disable Chromium's namespace layer while keeping its other +# launch machinery and inner filter enabled. This is another comparison, never +# a supported launch mode. +NO_NAMESPACE_STATUS=$(probe ' + '"$BROWSER_FIXTURE"' + namespace_flag=--disable- + namespace_flag=$namespace_flag"namespace-sandbox" + timeout -k 5 30 "$b" "$namespace_flag" --disable-dev-shm-usage --disable-gpu --disable-breakpad --user-data-dir="$d/p6" --dump-dom "$SERVER_URL" >/dev/null 2>&1 + st=$? + printf "no-namespace-exit=%s\\n" "$st" + exit 0 +' | sed -n 's/.*\(no-namespace-exit=[0-9][0-9]*\).*/\1/p' | tail -1) +if [ -n "$NO_NAMESPACE_STATUS" ]; then + printf 'note chromium without its namespace layer: %s\n' "$NO_NAMESPACE_STATUS" + note "chromium namespace diagnostic" "$NO_NAMESPACE_STATUS" +else + printf 'note chromium without its namespace layer: no-namespace-status-unavailable\n' + note "chromium namespace diagnostic" "no-namespace-status-unavailable" +fi + BROWSER_SIZE=$(probe 'cat /usr/local/share/rainier-browser-size.txt 2>/dev/null | head -1; grep -h "browser payload" /usr/local/share/rainier-browser-size.txt 2>/dev/null' | tr '\n' '; ') printf 'note browser layer: %s\n' "$BROWSER_SIZE" note "browser layer size" "$BROWSER_SIZE" From eefc19d7523a8a4b728682481a2bf6c2589278b3 Mon Sep 17 00:00:00 2001 From: jiashuoz <jszjosh@gmail.com> Date: Wed, 9 Sep 2026 17:37:55 -0700 Subject: [PATCH 37/38] fix(sandbox): allow Chromium renderer PID namespaces --- scripts/session-image-security-policy-test.py | 14 ++++++++++++- testdata/session-security/README.md | 5 +++-- .../codex-bwrap-seccomp-docker-27.5.1.json | 20 +++++++++++++++++++ 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/scripts/session-image-security-policy-test.py b/scripts/session-image-security-policy-test.py index ecdbe42d..0d403fa2 100644 --- a/scripts/session-image-security-policy-test.py +++ b/scripts/session-image-security-policy-test.py @@ -17,6 +17,7 @@ CLONE_CHROMIUM_USER = 0x10000011 CLONE_CHROMIUM_ZYGOTE = 0x70000011 CLONE_CHROMIUM_ZYGOTE_NO_NET = 0x30000011 +CLONE_CHROMIUM_PID = 0x20000011 # Chromium's safe-empty-dir helper uses the x86_64 clone optimization before # chrooting its short-lived child. Keep this exact non-namespace shape rather # than widening the Docker clone rule. @@ -29,7 +30,7 @@ "885442dc08f21f8d60f99ea43d59af88b1c529103815fe24bbf9ce998d3a609d" ) SECCOMP_CANONICAL_SHA256 = ( - "883fc167edee1490885be2f9c971716381d0a1b931da2b5085410ca7e5cc7d3c" + "4e43265c398ab8e93118568ff37749abd8d367dc6a01419dfa1d1efecd3bb636" ) APPARMOR_SHA256 = ( "53f78e768ee56099b764661c58b569504e39ecc45b2b3fb0dffd13ea329eb431" @@ -106,6 +107,17 @@ def test_seccomp_is_default_deny_and_only_allows_pinned_namespace_shapes(self): "with CLONE_NEWUSER|CLONE_NEWPID|SIGCHLD." ), ), + rule( + ["clone"], + "SCMP_ACT_ALLOW", + args=exact_clone(CLONE_CHROMIUM_PID), + includes=amd64, + comment=( + "RAINIER: Chromium gives each renderer its own PID namespace " + "with CLONE_NEWPID|SIGCHLD after the zygote enters its " + "private user namespace." + ), + ), rule( ["clone"], "SCMP_ACT_ALLOW", diff --git a/testdata/session-security/README.md b/testdata/session-security/README.md index 50e4c7d0..a4532046 100644 --- a/testdata/session-security/README.md +++ b/testdata/session-security/README.md @@ -8,7 +8,8 @@ Rainier Cloud repository or requiring a cross-repository GitHub token. The seccomp file starts from Docker Engine 27.5.1's default profile and adds only the exact syscall shapes needed by Codex's Bubblewrap and Chromium's namespace sandbox, including Chromium's x86_64 safe-empty-directory clone -helper, its `chroot` syscall, and Chromium's namespace `setns` operation. The +helper, its `chroot` syscall, Chromium's namespace `setns` operation, and its exact +renderer `clone(CLONE_NEWPID|SIGCHLD)` shape. The kernel still requires `CAP_SYS_CHROOT` inside the private user namespace. The AppArmor file keeps Docker's device, procfs, sysfs, and kernel denials while admitting Bubblewrap's namespace setup and Chromium's three @@ -24,7 +25,7 @@ run with only this public repository. Pinned fixture invariants: - Docker base profile: 27.5.1; canonical JSON SHA-256: `885442dc08f21f8d60f99ea43d59af88b1c529103815fe24bbf9ce998d3a609d` -- Full Rainier seccomp canonical JSON SHA-256: `883fc167edee1490885be2f9c971716381d0a1b931da2b5085410ca7e5cc7d3c` +- Full Rainier seccomp canonical JSON SHA-256: `4e43265c398ab8e93118568ff37749abd8d367dc6a01419dfa1d1efecd3bb636` - AppArmor profile SHA-256: `53f78e768ee56099b764661c58b569504e39ecc45b2b3fb0dffd13ea329eb431` The canonical JSON hash is calculated with sorted keys and compact separators; diff --git a/testdata/session-security/codex-bwrap-seccomp-docker-27.5.1.json b/testdata/session-security/codex-bwrap-seccomp-docker-27.5.1.json index 16dce16d..a623df86 100644 --- a/testdata/session-security/codex-bwrap-seccomp-docker-27.5.1.json +++ b/testdata/session-security/codex-bwrap-seccomp-docker-27.5.1.json @@ -658,6 +658,26 @@ ] } }, + { + "names": [ + "clone" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 536870929, + "op": "SCMP_CMP_EQ" + } + ], + "comment": "RAINIER: Chromium gives each renderer its own PID namespace with CLONE_NEWPID|SIGCHLD after the zygote enters its private user namespace.", + "includes": { + "arches": [ + "amd64", + "x32" + ] + } + }, { "names": [ "clone" From 8a6cddf91114f535e5783a868d48096206d53156 Mon Sep 17 00:00:00 2001 From: jiashuoz <jszjosh@gmail.com> Date: Wed, 9 Sep 2026 17:42:39 -0700 Subject: [PATCH 38/38] test(browser): remove temporary sandbox diagnostics --- scripts/session-image-smoke.sh | 94 ---------------------------------- 1 file changed, 94 deletions(-) diff --git a/scripts/session-image-smoke.sh b/scripts/session-image-smoke.sh index 388455ab..ad0c4a58 100755 --- a/scripts/session-image-smoke.sh +++ b/scripts/session-image-smoke.sh @@ -719,100 +719,6 @@ else bad "Chromium starts with its own sandbox" "exit=$SANDBOX_STATUS" fi -# Diagnostic only: compare the bounded launch with Chromium's sandbox disabled -# under the same outer container policy. Keep this as a fixed status token so -# a qualification log cannot receive browser stderr or host audit data. This -# distinguishes a missing Chromium-layer syscall from a broader browser/image -# problem; it does not relax or replace the required sandboxed check above. -NO_SANDBOX_STATUS=$(probe ' - '"$BROWSER_FIXTURE"' - no_sandbox_flag=--no- - no_sandbox_flag=${no_sandbox_flag}sandbox - timeout -k 5 30 "$b" "$no_sandbox_flag" --disable-dev-shm-usage --disable-gpu --disable-breakpad --user-data-dir="$d/p3" --dump-dom "$SERVER_URL" >/dev/null 2>&1 - st=$? - printf "no-sandbox-exit=%s\n" "$st" - exit 0 -' | sed -n 's/.*\(no-sandbox-exit=[0-9][0-9]*\).*/\1/p' | tail -1) -printf 'note chromium without its own sandbox under the driver restrictions: %s\n' "${NO_SANDBOX_STATUS:-no-sandbox-status-unavailable}" -note "chromium no-sandbox diagnostic" "${NO_SANDBOX_STATUS:-no-sandbox-status-unavailable}" - -# Diagnostic only: keep Chromium's namespace sandbox enabled while disabling -# its inner seccomp-BPF filter. This is constructed in pieces so the executable -# smoke source cannot accidentally be mistaken for a supported browser launch -# that weakens the security contract. A successful result here points to an -# interaction between the outer driver seccomp profile and Chromium's layer-2 -# filter; it does not authorize shipping this flag. -NO_FILTER_STATUS=$(probe ' - '"$BROWSER_FIXTURE"' - filter_flag=--disable- - filter_flag=$filter_flag"seccomp-filter-sandbox" - timeout -k 5 30 "$b" "$filter_flag" --disable-dev-shm-usage --disable-gpu --disable-breakpad --user-data-dir="$d/p4" --dump-dom "$SERVER_URL" >/dev/null 2>&1 - st=$? - printf "no-filter-exit=%s\n" "$st" - exit 0 -' | sed -n 's/.*\(no-filter-exit=[0-9][0-9]*\).*/\1/p' | tail -1) -if [ -n "$NO_FILTER_STATUS" ]; then - printf 'note chromium namespace sandbox without its inner filter: %s\n' "$NO_FILTER_STATUS" - note "chromium inner-filter diagnostic" "$NO_FILTER_STATUS" -else - printf 'note chromium namespace sandbox without its inner filter: no-filter-status-unavailable\n' - note "chromium inner-filter diagnostic" "no-filter-status-unavailable" -fi - -# Diagnostic only: retain the required sandbox and classify Chromium's own -# startup message without publishing its raw stderr. The fixed categories are -# enough to distinguish namespace setup from a later zygote failure while -# keeping paths, arguments, and host-specific text out of the qualification -# log. -SANDBOX_LOG_STATUS=$(probe ' - '"$BROWSER_FIXTURE"' - log="$d/chromium.stderr" - timeout -k 5 30 "$b" --enable-logging=stderr --v=1 --disable-dev-shm-usage --disable-gpu --disable-breakpad --user-data-dir="$d/p5" --dump-dom "$SERVER_URL" > /dev/null 2>"$log" - st=$? - if grep -Fq "Failed to move to new namespace" "$log"; then - token=namespace-move-failed - elif grep -Fq "No usable sandbox" "$log"; then - token=no-usable-sandbox - elif grep -Fq "InitializeSandbox" "$log"; then - token=sandbox-initialize - elif grep -Fq "zygote" "$log"; then - token=zygote-startup - elif [ -s "$log" ]; then - token=other-startup-error - else - token=no-startup-diagnostic - fi - printf "sandbox-log=%s exit=%s\\n" "$token" "$st" - exit 0 -' | sed -n 's/.*\(sandbox-log=[a-z-]* exit=[0-9][0-9]*\).*/\1/p' | tail -1) -if [ -n "$SANDBOX_LOG_STATUS" ]; then - printf 'note chromium sandbox startup category: %s\n' "$SANDBOX_LOG_STATUS" - note "chromium sandbox startup category" "$SANDBOX_LOG_STATUS" -else - printf 'note chromium sandbox startup category: unavailable\n' - note "chromium sandbox startup category" "unavailable" -fi - -# Diagnostic only: disable Chromium's namespace layer while keeping its other -# launch machinery and inner filter enabled. This is another comparison, never -# a supported launch mode. -NO_NAMESPACE_STATUS=$(probe ' - '"$BROWSER_FIXTURE"' - namespace_flag=--disable- - namespace_flag=$namespace_flag"namespace-sandbox" - timeout -k 5 30 "$b" "$namespace_flag" --disable-dev-shm-usage --disable-gpu --disable-breakpad --user-data-dir="$d/p6" --dump-dom "$SERVER_URL" >/dev/null 2>&1 - st=$? - printf "no-namespace-exit=%s\\n" "$st" - exit 0 -' | sed -n 's/.*\(no-namespace-exit=[0-9][0-9]*\).*/\1/p' | tail -1) -if [ -n "$NO_NAMESPACE_STATUS" ]; then - printf 'note chromium without its namespace layer: %s\n' "$NO_NAMESPACE_STATUS" - note "chromium namespace diagnostic" "$NO_NAMESPACE_STATUS" -else - printf 'note chromium without its namespace layer: no-namespace-status-unavailable\n' - note "chromium namespace diagnostic" "no-namespace-status-unavailable" -fi - BROWSER_SIZE=$(probe 'cat /usr/local/share/rainier-browser-size.txt 2>/dev/null | head -1; grep -h "browser payload" /usr/local/share/rainier-browser-size.txt 2>/dev/null' | tr '\n' '; ') printf 'note browser layer: %s\n' "$BROWSER_SIZE" note "browser layer size" "$BROWSER_SIZE"