Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
0b7c12b
feat(image): browser testing in the session image
jiashuoz Sep 9, 2026
e4211ab
docs(image): record the measured browser cost and sandbox status
jiashuoz Sep 9, 2026
14ae9eb
chore(image): drop an unused pin read from the browser e2e script
jiashuoz Sep 9, 2026
abda56b
ci(browser): gate the session image on real Playwright projects
jiashuoz Sep 9, 2026
f91d221
fix(browser): invalidate retired image cache markers
jiashuoz Sep 9, 2026
b720727
test: require Chromium sandbox in browser qualification
jiashuoz Sep 9, 2026
635de4c
fix: configure Chromium sandbox as launch option
jiashuoz Sep 9, 2026
77ab038
ci: enable Chromium sandbox user namespaces
jiashuoz Sep 9, 2026
67f40b6
test: enforce Chromium sandbox qualification
jiashuoz Sep 9, 2026
f66227a
test: ignore sandbox documentation comments
jiashuoz Sep 9, 2026
8bf617e
docs: document required Chromium sandbox
jiashuoz Sep 9, 2026
ea651e7
fix: guard browser cache entry deletion
jiashuoz Sep 9, 2026
f64ca8e
chore: satisfy shellcheck cache deletion guard
jiashuoz Sep 9, 2026
9d03f42
test: qualify browser image under hosted sandbox policy
jiashuoz Sep 9, 2026
e987e27
ci: track updated hosted browser policy
jiashuoz Sep 9, 2026
195c559
ci: track the procfs policy exception
jiashuoz Sep 9, 2026
e0a47f2
ci: make browser policy fixture public
jiashuoz Sep 9, 2026
000670c
fix: preserve Chromium setgroups map
jiashuoz Sep 9, 2026
80e938e
test: capture Chromium sandbox diagnostics
jiashuoz Sep 9, 2026
a86bc8e
ci: summarize browser security denials
jiashuoz Sep 9, 2026
f744b7e
fix(ci): admit Chromium safe-empty-dir clone
jiashuoz Sep 9, 2026
aa23656
test: classify Chromium sandbox failure
jiashuoz Sep 9, 2026
0045e72
test: identify Chromium namespace policy gap
jiashuoz Sep 9, 2026
cfd8927
test: probe Chromium safe chroot boundary
jiashuoz Sep 9, 2026
f253d16
test: separate Chromium chroot qualification
jiashuoz Sep 9, 2026
640cf51
fix(security): admit Chromium safe chroot
jiashuoz Sep 9, 2026
bf12487
test: remove temporary Chromium sandbox diagnostics
jiashuoz Sep 9, 2026
bf4b62f
test: bound Chromium smoke launches
jiashuoz Sep 9, 2026
63803c3
fix: preserve browser fixture quoting
jiashuoz Sep 9, 2026
0222e9e
test: serve Chromium smoke page over loopback
jiashuoz Sep 9, 2026
e6bd475
test: compare Chromium sandboxed launch
jiashuoz Sep 9, 2026
9cee12d
test: construct sandbox comparison flag
jiashuoz Sep 9, 2026
90a59bf
fix(security): allow Chromium namespace reassociation
jiashuoz Sep 9, 2026
d898e1f
test: isolate Chromium inner seccomp diagnostic
jiashuoz Sep 10, 2026
5c6d74d
test: classify Chromium sandbox startup failure
jiashuoz Sep 10, 2026
d24eeb4
test: compare Chromium namespace-disabled launch
jiashuoz Sep 10, 2026
eefc19d
fix(sandbox): allow Chromium renderer PID namespaces
jiashuoz Sep 10, 2026
8a6cddf
test(browser): remove temporary sandbox diagnostics
jiashuoz Sep 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .github/workflows/session-image.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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
117 changes: 117 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 \
Expand All @@ -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
Expand Down
20 changes: 17 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -41,14 +41,28 @@ 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
# of the script.
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 ./...
Loading
Loading