diff --git a/.github/workflows/session-image.yml b/.github/workflows/session-image.yml
index 2e38759b..5c8ff3cf 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:
@@ -31,7 +34,45 @@ 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
+ # 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 testdata/session-security/rainier-codex-bwrap.apparmor
- name: Functional smoke with no network or credentials
+ 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
+ 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/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..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-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
@@ -48,7 +54,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
+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 6e54a04f..3a689ef8 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,239 @@ 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
+
+"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, 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
+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,
+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
+
+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:
+
+On the qualified candidate (linux/amd64, the default pinned base; run
+[34378290786](https://github.com/tokencanopy/rainier/actions/runs/34378290786)):
+
+| | |
+|---|---|
+| 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.
+
+### 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.
+
+**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
@@ -396,6 +630,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 +662,28 @@ 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 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
+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.
@@ -541,3 +797,22 @@ 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 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/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..d6ff496b
--- /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`, 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.
+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 }, launchOptions: { chromiumSandbox: true } },
+ },
+ {
+ 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'], launchOptions: { chromiumSandbox: true } },
+ },
+ ],
+ // 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.