This is the image every Rainier session runs. It is built from the Dockerfile
at the root of this repository, and on the hosted product it is the only
image a Dedicated runner can pull: rainier-cloud's runner-artifacts workflow
checks this repository out at the OSS ref, builds that file, pushes it, reads
the digest back from the registry, and prints it as the session_image an
operator pastes into the Dedicated root's runner_artifacts. A runner holds a
short-lived registry credential only long enough for that pull, so a session
created against any other image fails with an unauthenticated pull.
That is the reason the toolchain is in the image rather than in an environment's setup script. Baked in, it costs one pull. Installed by a setup script, it costs an egress allowlist entry per installer, a build container with the read-only rootfs deliberately dropped, a snapshot per environment, and a first-session build every developer waits through.
Pinned by version in the Dockerfile, and by SHA-256 in
images/session/toolchain.sh for everything downloaded.
| Agents | Claude Code (npm, exact version), Codex (upstream codex-package archive, kept whole under /usr/local/lib/codex) |
| Source control | git, gh (GitHub CLI), OpenSSH client |
| C/C++ | build-essential (gcc, g++, libc6-dev), make, pkg-config, zlib1g-dev and buildpack-deps' standard header set |
| Go | the official toolchain tarball, behind a wrapper that creates its caches |
| Node | the base image's LTS line, with npm |
| 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 |
| 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 |
| Network | curl, wget, CA certificates, openssl, nc |
| Archives | tar, gzip, bzip2, xz-utils, zip, unzip |
| Diagnostics | ps (procps), ss (iproute2), lsof, dig (dnsutils), file |
| Editing and transfer | less, nano, vim-tiny, rsync |
Three manifests are written into the image at build time, because a target list
is not evidence and a digest on its own answers nothing:
/usr/local/share/rainier-os-packages.txt (every Debian package and version
actually installed), /usr/local/share/rainier-npm-global.json, and
/usr/local/share/rainier-apt-sources.txt (every apt archive the build
resolved against — there are two of them; see Local services).
What "version-pinned" does and does not cover. Every base image is a
digest; every downloaded artifact is a version in the URL and a SHA-256 checked
before extraction; Claude Code is an exact npm version, which for that package
is one immutable tarball with no dependency tree under it. The Debian package
set is pinned by the base image digest and recorded by the manifest, not
pinned by version — apt resolves against a moving archive, and that is now true
of two archives rather than one: Debian's, and the PostgreSQL project's own
(PGDG), whose signing key is pinned here by full fingerprint. Pinning that too
means sourcing from snapshot.debian.org, which is a real option and a real
cost (a slow, frequently unavailable host in the build path); it has not been
taken. Rebuilds of one commit are therefore reproducible in every layer except
the apt one, and the manifest is how you find out what the apt layer was.
An integration test needs a database, and a session has no way to get one after
the fact: sudo is not installed, the rootfs is read-only, and the egress
allowlist does not carry a package archive. So PostgreSQL 17 — the server,
not only psql — SQLite and Redis are in the image, and nothing starts
them. There is no service in the entrypoint, no cluster in any layer, and no
port bound until somebody runs one of the commands below. Each session
container has its own network namespace, so the loopback these servers bind is
reachable from that session and from nothing else: not the runner, not another
session, not the host.
Durable state lives on /workspace, under /workspace/.services, because
that is the one writable, persistent path a session has — $HOME is on the
read-only rootfs and /tmp is a per-container tmpfs a suspend and resume does
not carry. That also means a database on this path is inside what checkpoints,
archives and rainier pull carry off the runner. Put test data there, not
anything you would not want in an archive.
Runtime state — the unix sockets and the pidfiles — deliberately goes the
other way, under /tmp/rainier-services. protocol/workspace.TarGz refuses
a socket rather than skipping it, on the principle that silently shipping a
tree that is not the tree you named is the worse failure. So a socket under
/workspace turns rainier push or rainier pull of any tree containing it
into an error — and an unclean container exit leaves the socket file behind on
the volume to keep doing so. A socket is per-container state anyway; the server
recreates it on every start.
rainier-pg init # initdb a cluster in $PGDATA (UTF8, C.UTF-8, trust auth)
rainier-pg start # pg_ctl start on 127.0.0.1:$PGPORT and $PGHOST
rainier-pg status # exit 0 only while it is accepting connections
createdb myapp_test # ordinary client tools; PGHOST/PGPORT are already set
psql -d myapp_test
rainier-pg url myapp_test # postgres://rainier@127.0.0.1:5432/myapp_test?sslmode=disable
rainier-pg restart
rainier-pg stoprainier-pg is a root-owned shell script in /usr/local/bin; it installs
nothing, downloads nothing and needs no privilege. The raw tools are on PATH
and the equivalent commands are:
mkdir -p "$(dirname "$PGDATA")" "$PGHOST"
initdb --pgdata="$PGDATA" --username="$(id -un)" \
--encoding=UTF8 --locale=C.UTF-8 --auth-local=trust --auth-host=trust
printf "unix_socket_directories = '%s'\nlisten_addresses = 'localhost'\n" \
"$PGHOST" >> "$PGDATA/postgresql.conf"
pg_ctl --pgdata="$PGDATA" --log="$RAINIER_SERVICES_DIR/postgresql/server.log" \
--options="-p $PGPORT" -w start
pg_ctl --pgdata="$PGDATA" --mode=fast -W stop # then wait for postmaster.pid to goThree of those lines are the reason the helper exists.
unix_socket_directories. PostgreSQL's compiled-in default is/var/run/postgresql, which is on the read-only rootfs. Without the override the postmaster cannot create its socket and a plainpg_ctl startfails on a correct image.--locale=C.UTF-8. This image generates no locales, so aninitdbthat inherited an unsetLANGproduces an SQL_ASCII database that mangles the first non-ASCII row a test inserts.-Wand then waiting forpostmaster.pid, and never trusting that file on its own.pg_ctl -w stoppollskill(pid, 0), which cannot tell a shut-down postmaster from an unreaped zombie. A real session's PID 1 is sessiond, which reaps orphans (internal/reap), but a baredocker run --entrypoint …PID 1 does not, and a stop that hangs for a minute in one shape and not the other is not worth debugging twice. The server removing its own pidfile is its own completion signal and is true under either. The same file is also the ordinary state after the container this cluster last ran in went away —$PGDATAis on the volume and survives, the postmaster does not — sorainier-pg stopasks the server before it signals anything. A pid with nothing behind it may since have been reused by something unrelated in the new container. The stale file is left in place rather than deleted:pg_ctl startalready clears one, and refuses when the pid really is in use, which is PostgreSQL's call to make.
RAINIER_PG_TIMEOUT (seconds, default 60) bounds a start and a stop alike.
trust authentication is safe here and only here: the server listens on
localhost inside the session's own network namespace and its data directory
is the session user's. Do not copy this configuration anywhere a second party
can reach the port.
Environment already set by the image: RAINIER_SERVICES_DIR
(/workspace/.services, durable) and RAINIER_SERVICES_RUNTIME_DIR
(/tmp/rainier-services, sockets and pidfiles), and from those PGDATA,
PGHOST (a socket directory, so bare psql connects with no flags), PGPORT
and PGDATABASE.
For PostgreSQL, PGDATA and PGPORT are the knobs — a second cluster is a
second PGDATA on a second PGPORT. They are PostgreSQL's own variables and
every client honours them, which is why the image does not add a second,
overlapping one. RAINIER_SERVICES_DIR and RAINIER_SERVICES_RUNTIME_DIR are
where those defaults come from, and are what rainier-redis reads.
rainier-pg up # init if needed, then start
createdb rainier_test
export RAINIER_TEST_DATABASE_URL="$(rainier-pg url rainier_test)"
go test ./... # in a rainier-cloud checkoutThe suite migrates the database itself. rainier-pg url prints exactly the DSN
shape those tests parse; make canary in that repository takes the same
variable.
sqlite3 app.db "select sqlite_version()"
rainier-redis start # 127.0.0.1:$RAINIER_REDIS_PORT (default 6379), protected mode
rainier-redis ping # PONG
rainier-redis url # redis://127.0.0.1:6379/0
rainier-redis stopSQLite needs nothing started — it is a library and a shell, and Python's
sqlite3 module is the same library.
Named, because "not supported" and "nobody thought about it" look identical from inside a session:
- No MySQL/MariaDB, no MongoDB, no Kafka, no Elasticsearch, no MinIO/S3 stand-in, no NATS, no RabbitMQ. Each is a real size and maintenance cost and none of them is on the path of the tests this platform actually runs. A project that needs one should say so; adding it is a bounded change to this file.
- No Docker, no Docker-in-Docker, no
docker compose, notestcontainerssubstrate. A session gets no docker socket, no privileged container and no nested daemon, so a test suite that reaches for testcontainers will fail — the answer is the local server above, not a relaxed container. - No
gcloud,terraform,kubectlor other cloud-ops tooling. - No PostgreSQL extension outside the standard
contribset that ships withpostgresql-17, and no PostGIS, TimescaleDB orpgvector. - A 64 MiB
/dev/shm. That is docker's default and the driver does not change it. It is ample for ordinary work; a deliberately parallel query over a large table can exhaust it and reportcould not resize shared memory segment. Setmax_parallel_workers_per_gather = 0in that session'spostgresql.confrather than asking for a wider container.
Debian bookworm ships PostgreSQL 15. rainier-cloud's cell is 17 and its store
tests are written against 17, so a session with a 15 cannot run them. There is
no PostgreSQL 17 for bookworm that does not come, directly or transitively,
from PGDG — the PostgreSQL project's own Debian archive, and the archive
the official postgres image installs from.
Adding a second apt archive to the platform's own image is a real trust
decision, and it is pinned the strongest way an apt archive admits: the signing
key is fetched over HTTPS and its full 40-hex fingerprint is checked, as an
ARG in the Dockerfile a reviewer can read, before the archive is
configured at all. A substituted key is a failed build, never a silent install.
What that does not pin is package versions, which resolve against a moving
archive exactly as Debian's own do — rainier-apt-sources.txt records the
archives and rainier-os-packages.txt records the versions that were realized.
Two alternatives were considered and not taken. Copying /usr/lib/postgresql/17
out of a digest-pinned postgres:17-bookworm stage pins harder, but hand-carries
the dependency set — libpq5, the ICU and LLVM JIT libraries — with a missing
one showing up as a runtime failure in somebody's test rather than a failed
build. A Dev Container Feature would install from the same archive while adding
the devcontainer CLI to the build path, which this image already rejected for
the reasons below.
Measured, not estimated. scripts/session-image-smoke.sh reports both numbers
on every qualification run — as workflow notices when it runs in Actions, so
the figure lands on the pull request being approved rather than only in a job
log — and the
rollout runbook
step 2 is where the pull cost is reviewed against it.
On the qualified candidate (linux/amd64, the default pinned base; run 34330830439):
| Whole image | ≈2.42 GB (2,416,401,380 bytes), 22 layers |
| The services layer's installed payload | 14 packages, 244,512 KiB (≈239 MiB) |
That second figure is measured by the build, not estimated: it diffs its own
package set across the install and writes the total and a per-package
breakdown to /usr/local/share/rainier-services-size.txt, which the smoke then
reports. Read it off the run rather than off this table, which is one build old
the moment it is written.
Most of that 239 MiB is not PostgreSQL. The five largest of the fourteen:
126303 KiB libllvm19
57395 KiB postgresql-17
22767 KiB libz3-4
15847 KiB locales
10427 KiB postgresql-client-17
libllvm19 and its libz3-4 are 149 MiB — 61% of the layer — and they are
there for JIT compilation of queries, which postgresql-17 hard-depends
on. A session that never runs a query expensive enough to JIT still pays for
them, because a distribution package's dependencies are not optional.
Two things could remove that and neither was done here. Building PostgreSQL
from source with --without-llvm trades a checksum-pinned distribution archive
for a build this repository would then own and have to keep patched — a real
cost, on the security-relevant path, for 149 MiB of a 2.4 GB image. Shipping no
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.
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.
| 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 |
# 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 readsrainier-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.
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.
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.
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.
"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.
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-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.
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):
| 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
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.
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.
The choice was between a pinned Debian/Ubuntu Dev Containers base image and
a plain Debian/Ubuntu base with selected Dev Container Features. The
recommendation, and what is implemented, is neither: a plain pinned Debian
bookworm — which is what node:22-bookworm is — with distro packages and
checksum-pinned upstream releases on top.
- Dev Container Features are applied by the devcontainer CLI, not by
docker build. The publish path here is a plaindocker build --file oss/Dockerfileinside rainier-cloud's workflow. Adopting Features means putting Node, the devcontainer CLI, and a set of third-party OCI artifacts into the build path of the platform's own session image — a new build-time dependency and a new trust surface, in front of the one image a Dedicated runner is allowed to pull. Most Features are also consumed by mutable tag, which is the opposite of what asession_imagedigest is for. - The Dev Containers base images assume a user model this platform does
not have. They ship a
vscodeaccount at uid 1000 with passwordless sudo. Rainier runs every container as uid 1000 withno-new-privilegesand every capability dropped, and/usr/local/binmust stay root-owned because sessiond and the agents live there. Starting from those images means undoing their user setup before doing any of the actual work — more moving parts than starting from Debian, for a base whose value is conventions this image does not follow. node:22-bookwormis a plain Debian bookworm with the Node LTS line, npm, and buildpack-deps' compiler and header set already on it. It is an official image, it is the established way to get a supported Node without a third-party apt repository, and its digest is one this organization has already resolved and qualified for rainier-cloud's own environment image. Everything else is an explicitapt-get installline and a checksum inimages/session/toolchain.sh, which is where a reviewer can see it.- Follow-up, not a blocker:
node:22-bookworminherits buildpack-deps' full set, which includes things this image has no use for. Moving tonode:22-bookworm-slimand installing the compiler set explicitly is a size reduction worth taking once that digest is qualified; the explicit apt list makes it a mechanical change.
The base is an ARG, so a developer on an arm64 machine can pass the tag and
build natively (make session-image BUILD_ARGS='--build-arg BASE_IMAGE=node:22-bookworm'). The toolchain script carries checksums for both
architectures and refuses to install one architecture's binaries into the
other's userland. CI never overrides the ARG: what ships is the digest.
For the local fleet on ARM, use
BUILD_ARGS='--build-arg BASE_IMAGE=node:22-bookworm' make e2e.
Fleet startup delegates to the same image-build target and forwards that
explicit development override. It never silently substitutes an unpinned base.
This adds no general devcontainer.json support and is not a step toward it.
The driver (internal/driver.runArgs) runs every session container as
1000:1000, with no-new-privileges, every capability dropped, a read-only
rootfs, a noexec tmpfs on /tmp, the session's volume at /workspace, and the
agent-home volume at /rainier/agents. The image exists to make that
survivable:
- Non-root.
USER 1000:1000is the image's own default, not only a flag at the call site. The base image's uid-1000 account is renamedrainierrather than joined by a second one, so there is exactly one. - sessiond is protected. It lives in root-owned
/usr/local/binand the entrypoint names it by absolute path./opt/rainier-env/binis writable by the session user and first onPATH— that is what lets an environment's setup script install anything — so aPATH-resolved entrypoint would let a setup script drop its ownsessiondthere and havedocker commitbake it into the image every later session of that environment boots as PID 1. The agents are in/usr/local/binfor the same reason. /workspaceownership. The image's/workspaceis owned by 1000:1000, which is also what docker copies onto a freshly created volume at that mount point. The driver's init job does the same job independently; the two agree.- Writable caches.
$HOMEis on the read-only rootfs and/tmpis noexec, soGOCACHE,GOMODCACHE,GOPATH,GOTMPDIR,XDG_CACHE_HOME,npm_config_cache,PIP_CACHE_DIRandUV_CACHE_DIRall point onto the workspace volume. Go additionally needsGOTMPDIRto already exist and needs to execute what it builds there, which is why/usr/local/bin/gois a small root-owned wrapper that creates the directories and then execs the real toolchain — ago teston a noexec/tmpotherwise dies at the last step of a green build withfork/exec /tmp/...: permission denied. TMPDIRis deliberately left alone, for the same reasonXDG_CONFIG_HOMEis./tmpis a per-container tmpfs: agent scratch written there is writable, is not in a checkpoint, is not in an archive, and does not followrainier pulloff the runner. Exactly one build temp is moved off it —GOTMPDIR, becausego testhas to execute what it builds. SettingTMPDIRglobally would move every tool's scratch, Claude Code's and Codex's included, onto the volume that leaves the runner, and would quietly change the sandbox each agent believes it has. Neither agent is wrapped here and neither is given a rewritten temp;internal/driver.TestSessionImageLeavesScratchOnTheTmpfsfails the ordinary suite if that changes.- A service's state is on the volume too.
PGDATA,PGHOSTandRAINIER_SERVICES_DIRpoint under/workspace/.servicesfor the same reason the caches do. See Local services. XDG_CONFIG_HOMEis deliberately left alone. Configuration is where tools write credentials, and/workspaceis what checkpoints, archives andrainier pullcarry off the runner. A tool that wants to persist a token gets a read-only$HOMEand fails loudly, which is the wanted outcome.- Fresh agent homes reconstruct only required non-secret state. Credential
custody intentionally carries Claude's
.credentials.json, not its whole.claude.json, because that application file can contain workspace-local project and MCP state. After a positive credential restore, sessiond creates a missing.claude.jsonwith onlyhasCompletedOnboarding: true; an existing file always wins. This prevents Claude's interactive first-run login wizard after runner replacement without copying one workspace's mutable state into another. No seed is created when custody has no credential. - Credential-sync rollout is versioned. The agent manifest and upward credential RPCs carry their own protocol version. A new session process refuses to boot from an old manifest, and a new control plane refuses custody traffic from an old session process. An old session process can still read a home it already mounted, so deployments must stop old control planes, publish the matching session image, and replace existing sessions before accepting agent login/logout traffic.
- No credential, no privilege. Nothing token-shaped is baked in,
sudois not installed, no host path is mounted, and no docker socket is anywhere near a session.
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, twiceinternal/driver/image_contract_test.go reads the Dockerfile and the
toolchain script as text and fails the ordinary go test ./... if the
entrypoint stops being absolute, a base stops being a digest, a cache moves off
the workspace, sudo appears, a download stops being checksum-verified, or a
promised tool leaves the install list. That 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 is the other half, and it is deliberately
functional rather than a version parade. It compiles and runs C and C++, builds
a Go program and runs its tests (the noexec /tmp case), runs Node and
Python, drives make, creates venvs and installs a wheel offline, installs an
npm dependency offline and checks the cache it used, serves HTTP from Python
and HTTPS from Node against a certificate curl verifies, makes a real git
commit, starts Claude Code and Codex with a read-only $HOME, asks Codex where
it resolved its own package and runs the tool host it names, and brings the
real entrypoint up as PID 1.
It also runs the local services end to end: it checks that a fresh session has
nothing listening and no cluster in the image, then initdbs a cluster on
the workspace volume, starts it with pg_ctl, creates a database, commits one
transaction and rolls another back, connects again over the DSN rainier-pg url prints, asserts the listener is on loopback and not on 0.0.0.0, stops
and restarts the server with its data intact, writes and reads a SQLite
database from both the shell and Python, and starts Redis, PINGs it, round-trips
a key and stops it — all as uid 1000, with a read-only rootfs and no network at
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. 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 cis 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.
The upstream codex-package archive is a manifest, bin/codex, the
bin/codex-code-mode-host tool host, and bundled ripgrep, bubblewrap and zsh
under codex-path and codex-resources. Codex finds every companion by walking
up from the resolved path of its own executable until it finds
codex-package.json.
The image used to install codex-${TRIPLE}.tar.gz, which is that one executable
and nothing else. codex --version was green and codex --search failed closed
on a missing /usr/local/bin/codex-code-mode-host. Measured against the real
rust-v0.153.4 archive, with codex doctor --json reporting Codex's own
resolution rather than ours:
| Install shape | install context |
runtime.search |
|---|---|---|
| Complete package | names package, bin, resources, path |
bundled, the package's codex-path/rg |
bin/codex alone |
bare other |
system |
bin/codex alone, tool host added to PATH |
byte-for-byte identical to the row above | system |
| Complete package minus the tool host | still names the package — doctor does not notice | bundled |
Symlink on PATH → package bin/codex |
identical to the complete package | bundled |
So: the tool host goes neither on PATH nor beside sessiond in /usr/local/bin
(row 3 is row 2 exactly, so PATH placement is inert); the PATH entry is a
symlink, because Codex resolves symlinks before it looks for the package and a
copy would leave the companions unreachable; and the layout is asserted
separately, because row 4 passes every doctor check with the host deleted.
images/session/toolchain.sh extracts the archive to /opt/toolchain/lib/codex
and links $BIN/codex -> ../lib/codex/bin/codex. The link is relative so
that it resolves in the toolchain stage and again after the final image copies
both directories under /usr/local. The script rejects an incomplete layout —
require_codex_package checks the manifest's version, target and layout fields
and every member — so a build that lost a piece fails rather than shipping.
The package is root-owned under /usr/local, like sessiond and the other
agents: a session user who could rewrite the tool host could rewrite what the
agent spawns. /usr/local/lib/codex is deliberately not the user-writable
/opt/rainier-env prefix.
Packaging is not the whole of Codex readiness. If bundled bubblewrap cannot
create a namespace on a particular runner, that is a security boundary to
diagnose and review separately; do not reach for
--dangerously-bypass-approvals-and-sandbox, a privileged container,
seccomp=unconfined, added capabilities or host sysctl changes to get past it.
/usr/local/bin/gh is a root-owned wrapper. It invokes sessiond's
github-cli subcommand, which asks the existing session credential socket for
a GitHub credential in a managed session and then execs the root-owned upstream
binary at /usr/local/libexec/rainier/gh. The credential is inserted only as
GH_TOKEN in that child environment; it is not written to the shell,
configuration, image, or workspace. The wrapper passes normal gh commands
through unchanged, including gh auth token.
This narrows accidental persistence, not process isolation: gh descendants
inherit GH_TOKEN, same-UID workload processes may be able to inspect it, and
workload code can deliberately copy or print it. A managed invocation whose
credential retrieval fails refuses to start gh rather than falling back to an
inherited token or an interactive/config login. Top-level help and version
forms can run offline without retrieval.
Configuration-write limitations remain unchanged. $HOME is read-only and
credential configuration is intentionally not moved to /workspace, so gh
configuration writes or extension installation may fail under the normal
session restrictions.
API reachability remains a separate control-plane concern:
- API egress. A session's allowlist is the union of the environment's
hosts, the providers' hosts, and the git hosts the clone needs
(
github.com,codeload.github.com,objects.githubusercontent.com).api.github.comis in none of them, and everyghcommand that is not a git operation goes there. Verified from inside a live hosted session: aCONNECT api.github.comgets403from the egress proxy whilegithub.comanswers200. An environment can add the host today with--egress api.github.com; making it part of what a cloning session gets by default is a control-plane change. The launcher deliberately does not make an API host reachable. Hosted launch material must allowapi.github.combefore authenticated gh API or PR commands can succeed; explicit environment egress can supply it until that independent control-plane change lands.
The image pre-seeds /workspace/.rainier before giving the directory to UID
1000. Docker copies both to a fresh volume. This keeps compatibility with the
deployed initializer, which has only CAP_CHOWN and cannot create a directory
in an empty UID-1000-owned 0755 mount. Broadening initializer privileges or
changing workspace ownership would weaken or alter the existing contract;
seeding avoids both. A Docker-gated test calls the real initializer against the
built candidate and then checks repeated mounts as the session user.
The smoke assertion requires both exit success and the expected output; timeout, signal, and marker-then-failure regressions guard its trustworthiness. Claude/Codex version output must match the Dockerfile pins, and for Codex the version alone is explicitly not enough — see below. The gh smoke checks offline version behavior and a synthetic socket response; it never uses a real credential.
The OSS Session image qualification workflow builds the exact default base
on native linux/amd64 and runs both the driver regression and functional smoke.
It does not publish images or deploy anything. Keep its successful commit/run
with the artifact being published. ARM builds using BASE_IMAGE overrides are
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.
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.