Skip to content

feat(image): PostgreSQL 17, SQLite and Redis in the session image - #72

Merged
jiashuoz merged 1 commit into
mainfrom
feat/default-dev-tools
Sep 9, 2026
Merged

feat(image): PostgreSQL 17, SQLite and Redis in the session image#72
jiashuoz merged 1 commit into
mainfrom
feat/default-dev-tools

Conversation

@jiashuoz

@jiashuoz jiashuoz commented Sep 9, 2026

Copy link
Copy Markdown
Member

What this is

A newly created session could not run an integration test: no PostgreSQL, no
SQLite, no Redis, and no way to get one after the fact — sudo is not
installed, the rootfs is read-only, and no package archive is on the egress
allowlist. This adds the client and the server halves of all three at build
time, with auditable versions.

Nothing starts. There is no service in the entrypoint, no cluster in any layer,
and no port bound until a developer runs a documented command. Both servers
bind loopback inside the session's own network namespace.

rainier-pg up                                    # initdb + pg_ctl start
createdb myapp_test
export RAINIER_TEST_DATABASE_URL="$(rainier-pg url myapp_test)"
rainier-redis start && rainier-redis ping        # PONG
sqlite3 app.db "select sqlite_version()"

initdb and pg_ctl are on PATH and the raw commands are documented; the
helpers are ergonomics, not a requirement.

Ownership

The image a hosted Dedicated session actually runs is this repository's root
Dockerfile — what rainier-cloud's runner-artifacts publishes as the plane's
session_image. So the change is here, at the source. rainier-cloud's
environments/default/ is the separate nine-harness local preview and is
untouched; no coding-agent integration is added or expanded. The matching
rainier-cloud PR (readiness check + runbooks) is
tokencanopy/rainier-cloud#66
and is only what this one makes mechanically necessary.

The decisions worth reviewing

PGDG, and what the pin covers. Debian bookworm ships PostgreSQL 15; the
cell is 17 and its store tests are written against it. No 17 for bookworm
avoids PGDG directly or transitively. A second apt archive is a real trust
decision, so it is pinned the strongest way an apt archive admits: the signing
key's full 40-hex fingerprint is an ARG a reviewer can read, and the
primary-key count is asserted first and separatelygpg --dearmor
converts every key in the file and signed-by= then trusts the whole keyring,
so a check that read only the first fingerprint would accept the genuine key
with somebody else's appended. Package versions resolve against a moving
archive exactly as Debian's do; rainier-apt-sources.txt records both
archives. The alternative — copying /usr/lib/postgresql/17 out of a
digest-pinned postgres:17-bookworm stage — pins harder but hand-carries
libpq5, ICU and the JIT libraries, with a missing one surfacing as a runtime
failure in somebody's test instead of a failed build.

Nothing runs, and nothing is baked in. create_main_cluster = false stops
postgresql-common running initdb at install time into a directory the
read-only rootfs could never use; a policy-rc.d refuses every
maintainer-script service start for the duration of the install. libpq5 and
libpq-dev are named explicitly with their major floored at the server's —
apt is as free to resolve the 15→17 upgrade by removing the -dev package as
by upgrading it, and PGDG ships one libpq for every major it carries, so an
archive that has released an 18 correctly hands this image an 18.x libpq beside
the 17 server.

Durable state on the volume, sockets off it. PGDATA and the Redis data
directory are under /workspace/.services, because that is the only writable
path that survives a suspend. The sockets and pidfiles are under
/tmp/rainier-services, because protocol/workspace.TarGz refuses a socket
rather than skipping it — one under /workspace fails rainier push/pull of
any tree containing it, and an unclean exit leaves it there to keep failing.

Three things about this runtime are not the defaults, and each is why the
helpers exist: PostgreSQL's compiled-in socket directory is on the read-only
rootfs; the image generates no locales, so an initdb inheriting an unset
LANG builds an SQL_ASCII database that mangles the first non-ASCII row a
test inserts; and pg_ctl -w stop polls kill(pid, 0), which cannot tell a
shut-down postmaster from an unreaped zombie — and postmaster.pid outlives
the container it was written in, so it is never taken as proof a server is
running.

TMPDIR is still not set. /tmp is a per-container noexec tmpfs and that
is where agent scratch belongs — not in a checkpoint, an archive, or rainier pull. Exactly one build temp moves off it, GOTMPDIR, because go test
executes what it builds. Setting TMPDIR globally would move Claude Code's and
Codex's scratch onto the volume that leaves the runner and quietly change the
sandbox each believes it has; a contract test now fails if it appears.

Size

Measured, not estimated, and it is the number the rollout gate approves:

Whole image ≈2.42 GB, 22 layers, linux/amd64
Services layer 14 packages, 244,512 KiB (≈239 MiB)

libllvm19 (126 MiB) and libz3-4 (23 MiB) are 61% of that, and they are
there because postgresql-17 hard-depends on LLVM for query JIT. The build
writes the full per-package breakdown to
/usr/local/share/rainier-services-size.txt, and the smoke reports it as a
workflow notice so the figure is on the run being approved. Removing the
149 MiB means building PostgreSQL from source without LLVM; docs/session-image.md
argues against that and this change does not do it.

Verification

Native linux/amd64, this PR's Session image qualification: build, the
deployed initializer against the candidate, and the whole functional smoke —
which now checks that a fresh session has nothing listening and no cluster in
the image, initdbs a cluster on the volume, starts it with pg_ctl, commits
one transaction and rolls another back, reconnects over the DSN rainier-pg url prints, asserts the listener is loopback and not 0.0.0.0, asserts no
socket appears anywhere under /workspace while both servers run, stops and
restarts with the data intact, round-trips SQLite from shell and Python, and
starts, PINGs, round-trips a key in and stops Redis. All as uid 1000,
read-only rootfs, noexec /tmp, no network at all.

make verify passes locally.

Ten new Go tests run the two helpers against stub binaries with no docker:
the locale and auth flags initdb gets, init refusing to overwrite and
start refusing without a cluster, stop asking for a non-blocking fast
shutdown and then waiting on the server's own pidfile, stop surviving a stale
pidfile and not signalling a pid that may have been reused, stop not
mistaking a server that is still rejecting connections for a stale one, the
start deadline, the exact DSN, and the durable/runtime split for both servers.
They are named TestSessionImage* so the qualification workflow's -run
selector actually runs them.

Two independent review passes were run and every finding fixed: the
single-key fingerprint hole, stop on a stale pidfile, unescaped annotation
titles, hardcoded 17 where POSTGRES_MAJOR is an ARG, an unused timeout
knob, sockets on the volume, helpers ignoring their documented roots, and a
services-size figure that undercounted by a factor of three.

The change was authored in a session with no Docker daemon; no local
container evidence exists and none is claimed. The Actions log host is also not
on that session's egress allowlist, which is why the smoke now emits failures
as workflow annotations — that turned out to be the only way to see why the
first build failed, and it is worth keeping.

Not in scope

No deployment, publication or merge. No MySQL, MongoDB, Kafka, Elasticsearch,
object-store stand-in or message broker; no PostgreSQL extension beyond
postgresql-17's own contrib set; no Docker-in-Docker, docker socket or
testcontainers substrate; no privileged container, added capability, broadened
sudo or weakened egress. docs/session-image.md lists what is deliberately
absent, including the 64 MiB /dev/shm a parallel query can exhaust, so
"not supported" and "nobody thought about it" stop looking identical from
inside a session.

🤖 Generated with Claude Code

A session could not run an integration test. There is no way to get a database
after the fact — `sudo` is not installed, the rootfs is read-only, and no
package archive is on the egress allowlist — so the client AND the server
halves belong in the image, which is the one thing a Dedicated runner is
allowed to pull.

Nothing starts. `create_main_cluster = false` keeps postgresql-common from
running initdb into a directory the read-only rootfs could never use, and a
policy-rc.d refuses every maintainer-script service start for the duration of
the install. There is no service in the entrypoint and no port bound until a
developer runs one of the documented commands.

PGDG, and what the pin covers. Debian bookworm ships PostgreSQL 15 and the cell
is a 17 with store tests written against it; no 17 for bookworm avoids PGDG
directly or transitively. A second apt archive is a real trust decision, so the
signing key's full 40-hex fingerprint is an ARG a reviewer can read — and the
primary-key COUNT is asserted first and separately, because `gpg --dearmor`
converts every key in the file and `signed-by=` then trusts the whole keyring.
Versions resolve against a moving archive exactly as Debian's do;
rainier-apt-sources.txt records both archives. libpq5 and libpq-dev are named
explicitly with their major floored at the server's: apt is as free to resolve
the 15-to-17 upgrade by removing the -dev package as by upgrading it, and PGDG
ships one libpq for every major it carries, so an archive that has released an
18 correctly hands this image an 18.x libpq beside the 17 server.

Durable state goes on the workspace volume, because that is the only writable
path surviving a suspend. Sockets and pidfiles deliberately do not:
protocol/workspace.TarGz refuses a socket rather than skipping it, so one under
/workspace fails `rainier push`/`pull` of any tree containing it and an unclean
exit leaves it there to keep failing. They live on the per-container tmpfs,
which is where per-container state belongs.

`rainier-pg` and `rainier-redis` are root-owned helpers beside sessiond. They
install nothing and need no privilege, and the raw tools stay on PATH; they
exist because three things here are not the defaults. PostgreSQL's compiled-in
socket directory is on the read-only rootfs. This image generates no locales,
so an initdb inheriting an unset LANG builds an SQL_ASCII database that mangles
the first non-ASCII row a test inserts. And `pg_ctl -w stop` polls kill(pid, 0),
which cannot tell a shut-down postmaster from an unreaped zombie — while
postmaster.pid outlives the container it was written in, so it is never taken
as proof a server is running and a pid that may since have been reused is never
signalled.

TMPDIR is still not set, and a contract test now says so. /tmp is a
per-container noexec tmpfs and that is where agent scratch belongs — not in a
checkpoint, an archive or `rainier pull`. Exactly one build temp moves off it,
GOTMPDIR, because `go test` executes what it builds. Setting TMPDIR globally
would move Claude Code's and Codex's scratch onto the volume that leaves the
runner and quietly change the sandbox each believes it has.

Verification. Ten new tests run both helpers against stub binaries with no
docker, covering the initdb flags, the refusals, the stop paths (stale pidfile,
a server still rejecting connections, a server that never stops), the deadline,
the DSN, and the durable/runtime split. They are named TestSessionImage* so the
qualification workflow's -run selector actually runs them. The container smoke
checks that a fresh session has nothing listening and no cluster in the image,
then initdbs a cluster, starts it, commits one transaction and rolls another
back, reconnects over the DSN the helper prints, asserts the listener is
loopback and that no socket appears under /workspace while both servers run,
stops and restarts with the data intact, round-trips SQLite from shell and
Python, and starts, PINGs and stops Redis — as uid 1000, read-only rootfs, no
network at all.

The smoke also reports now. A failing check becomes a workflow annotation and
the sizes become notices, because a job log is not always reachable from where
the fix has to be made, and because size is a rollout gate rather than a
curiosity. The services layer measures 14 packages and 244,512 KiB installed —
the build diffs its own package set to get that, so it counts the
Debian-sourced dependencies too. libllvm19 and libz3-4 are 149 MiB of it, for
the query JIT postgresql-17 hard-depends on; removing them means building
PostgreSQL from source, which docs/session-image.md argues against.
@jiashuoz
jiashuoz force-pushed the feat/default-dev-tools branch from 4787759 to eab52cc Compare September 9, 2026 09:20
@jiashuoz
jiashuoz merged commit 70024ec into main Sep 9, 2026
1 check passed
@jiashuoz
jiashuoz deleted the feat/default-dev-tools branch September 9, 2026 15:44
jiashuoz added a commit that referenced this pull request Sep 9, 2026
* feat(cli): minimal hosted-first v0 command surface

The CLI had grown twenty-odd commands, and `rainier --help` listed all of
them: transfers, snapshots, secrets, environments, contexts, workspaces and
the self-hosted credential vault sat beside the four things a hosted
developer actually does. A new developer could not read the product off one
screen, which is the only test of a command surface that matters.

The public surface is now sign in, check readiness, authenticate a coding
agent, and create and manage sessions — 23 lines of default help. Everything
else still dispatches, under `rainier help all`, labeled Advanced.

A session has three independent facts and this CLI keeps them apart: the
sandbox's lifecycle (`state`), the process inside it (`child_exit_code`), and
whether the control plane can reach it (`reachable`). They fail separately, so
they are three columns. A session whose agent exited is still Running — it
holds its filesystem and it is still attachable — and a runner that dropped
its link makes a session Unavailable, not Failed. Action eligibility comes
from the raw API's own transition rules and never from the display word:
`SuspendSession` accepts `running` and nothing else, so Stop is not offered
for a queued session merely because queued and creating share the label
Starting.

docs/cli-v0-contract.md is the authority for what each command promises and
which Cloud APIs each promise depends on. Rainier Cloud 81d1ad3 shipped the
compute enrollment and `workspace_not_ready`, so `status` reads the
workspace's own compute state — both `status` and `health`, because ready
capacity nobody can reach is not ready. Two contracts are still outstanding
and both are isolated in cmd/rainier/readiness.go: a bearer-reachable
onboarding destination, and a per-environment agent launch catalog. Neither is
guessed at.

New: `logout`, `status`, `info`, `stop`, `delete`, the `current` selector,
and `--json` on everything a script reads — carrying the canonical API facts
verbatim beside the derived ones. `diff` is removed completely: git inside the
session is the source of truth. `doctor`, `suspend`, `rm` and `agent ls`
remain as hidden aliases for the commands that replaced them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q3GPB683VghXL2XA4XWK7Y

* fix(cli): finish v0 readiness, session safety, and output contracts

* fix(credentials): fence delayed session credential deletions (#71)

* feat(image): PostgreSQL 17, SQLite and Redis in the session image (#72)

A session could not run an integration test. There is no way to get a database
after the fact — `sudo` is not installed, the rootfs is read-only, and no
package archive is on the egress allowlist — so the client AND the server
halves belong in the image, which is the one thing a Dedicated runner is
allowed to pull.

Nothing starts. `create_main_cluster = false` keeps postgresql-common from
running initdb into a directory the read-only rootfs could never use, and a
policy-rc.d refuses every maintainer-script service start for the duration of
the install. There is no service in the entrypoint and no port bound until a
developer runs one of the documented commands.

PGDG, and what the pin covers. Debian bookworm ships PostgreSQL 15 and the cell
is a 17 with store tests written against it; no 17 for bookworm avoids PGDG
directly or transitively. A second apt archive is a real trust decision, so the
signing key's full 40-hex fingerprint is an ARG a reviewer can read — and the
primary-key COUNT is asserted first and separately, because `gpg --dearmor`
converts every key in the file and `signed-by=` then trusts the whole keyring.
Versions resolve against a moving archive exactly as Debian's do;
rainier-apt-sources.txt records both archives. libpq5 and libpq-dev are named
explicitly with their major floored at the server's: apt is as free to resolve
the 15-to-17 upgrade by removing the -dev package as by upgrading it, and PGDG
ships one libpq for every major it carries, so an archive that has released an
18 correctly hands this image an 18.x libpq beside the 17 server.

Durable state goes on the workspace volume, because that is the only writable
path surviving a suspend. Sockets and pidfiles deliberately do not:
protocol/workspace.TarGz refuses a socket rather than skipping it, so one under
/workspace fails `rainier push`/`pull` of any tree containing it and an unclean
exit leaves it there to keep failing. They live on the per-container tmpfs,
which is where per-container state belongs.

`rainier-pg` and `rainier-redis` are root-owned helpers beside sessiond. They
install nothing and need no privilege, and the raw tools stay on PATH; they
exist because three things here are not the defaults. PostgreSQL's compiled-in
socket directory is on the read-only rootfs. This image generates no locales,
so an initdb inheriting an unset LANG builds an SQL_ASCII database that mangles
the first non-ASCII row a test inserts. And `pg_ctl -w stop` polls kill(pid, 0),
which cannot tell a shut-down postmaster from an unreaped zombie — while
postmaster.pid outlives the container it was written in, so it is never taken
as proof a server is running and a pid that may since have been reused is never
signalled.

TMPDIR is still not set, and a contract test now says so. /tmp is a
per-container noexec tmpfs and that is where agent scratch belongs — not in a
checkpoint, an archive or `rainier pull`. Exactly one build temp moves off it,
GOTMPDIR, because `go test` executes what it builds. Setting TMPDIR globally
would move Claude Code's and Codex's scratch onto the volume that leaves the
runner and quietly change the sandbox each believes it has.

Verification. Ten new tests run both helpers against stub binaries with no
docker, covering the initdb flags, the refusals, the stop paths (stale pidfile,
a server still rejecting connections, a server that never stops), the deadline,
the DSN, and the durable/runtime split. They are named TestSessionImage* so the
qualification workflow's -run selector actually runs them. The container smoke
checks that a fresh session has nothing listening and no cluster in the image,
then initdbs a cluster, starts it, commits one transaction and rolls another
back, reconnects over the DSN the helper prints, asserts the listener is
loopback and that no socket appears under /workspace while both servers run,
stops and restarts with the data intact, round-trips SQLite from shell and
Python, and starts, PINGs and stops Redis — as uid 1000, read-only rootfs, no
network at all.

The smoke also reports now. A failing check becomes a workflow annotation and
the sizes become notices, because a job log is not always reachable from where
the fix has to be made, and because size is a rollout gate rather than a
curiosity. The services layer measures 14 packages and 244,512 KiB installed —
the build diffs its own package set to get that, so it counts the
Debian-sourced dependencies too. libllvm19 and libz3-4 are 149 MiB of it, for
the query JIT postgresql-17 hard-depends on; removing them means building
PostgreSQL from source, which docs/session-image.md argues against.

* fix(cli): close session selection and diagnostic review gaps

* docs(cli): record complete race verification result

* fix(cli): address independent v0 lifecycle and output review

* test(relay): drain buffered output before asserting connection close

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
jiashuoz added a commit that referenced this pull request Sep 9, 2026
* fix(control): wake scheduler after runner reconciliation

* chore: prepare npm CLI beta 0.0.8

* fix(runnerd): reset the redial backoff once a connection is established (#67)

* fix(runnerd): reset the redial backoff once a connection is established

RunAgent's backoff was initialized once per process and doubled on every
disconnect, and nothing anywhere put it back. A runner whose control plane
routinely ends connections — an hourly credential or lease expiry, a
rolling deploy — reached the 30s cap after six of them and redialed at
30-45s for the rest of that process's life, however healthy every
connection in between had been. For the whole of that gap every session on
the runner reads unreachable and the scheduler can place nothing on it.
The sibling loop this one is modeled on, cmd/sessiond's dialLoop, has
always reset on a successful dial; this one never grew the line.

The delay now returns to its floor whenever a connection was ESTABLISHED,
which here means exactly one thing: controld answered this connection's
announce with an accept. That is the control plane's own statement that
the fleet registered this runner, at a generation, on this socket — the
point past which its sessions are dispatchable. Establishment rather than
a completed dial, because several of controld's refusals come after the
websocket handshake and before any accept (a name the credential does not
cover, a generation its store cannot mint, a registration the fleet
refuses, a failed reconcile), and a store outage puts the whole fleet on
that path at once. Only an accept means the connection was good.

Measured at the defaults against a fake control plane, seven consecutive
clean closes each cost 1.04-1.45s of reachability, where before they cost
1.4s, 2.1s, 4.7s, 8s, 16s, 30s, 30s... The floor sits on AgentConfig
beside the ping bounds so a test can shorten it without touching package
state.

Two tests: ten established-then-dropped connections must each redial at
the floor — the old behavior overruns the harness's 5s bound on the ninth
— and four connections dropped before any accept must keep doubling.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KRpP2YdobVkHaNetLMBZaL

* fix(runnerd): require an established connection to hold before resetting

Review finding: keying the reset on the bare accept removed a brake that
the old always-growing delay had provided by accident. The control plane
evicts a runner's previous connection whenever a new one registers under
the same name (runnerplane.registerRunner), so two runnerd processes
configured with one runner name accept-and-evict each other indefinitely.
Every dial in that loop mints a generation and writes a fleet row, so
resetting on the accept alone would have held a misconfiguration that used
to decay to one exchange per 30s at one per second, forever, inflating
generations the whole time.

The reset now needs the connection to have been established AND to have
lasted RedialResetAfter (10s). Any routine lifetime is orders of magnitude
past that bound — the hourly expiry this change is about is 360 times it —
so the fix keeps working, while a flapping pair backs off to the cap as
before. Measured again at full defaults with real 10.5s holds: five
consecutive clean closes cost 1.196s, 1.288s, 1.328s, 1.466s, 1.196s.

Establishment is now a local in agentSession rather than a field on
agentSessionState: the accept is handled on the reader, which is the
goroutine that returns, so nothing else ever reads it and it needs no
atomic.

Also from review: redialBackoffMin() clamps to [1ms, 30s]. Under about 2ns
it reached mrand.Int63n with a zero bound, which panics and takes the
process down on its first redial; above the cap it put every redial after
a held connection past the ceiling RunAgent documents. The cap is a named
constant now, shared with nextBackoff. And establishAndDrop's comment had
its measurement bias backwards — a gap timed from the close includes the
agent's notice latency, so it is an upper bound on the agent's wait, not
an under-report.

Two more tests: four accepted-but-immediately-dropped connections must
keep backing off (74ms instead of the required 400ms without this change),
and the floor's clamp at both ends.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KRpP2YdobVkHaNetLMBZaL

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(image): install the complete Codex package, not just its executable (#66)

* fix(image): install the complete Codex package, not just its executable

`codex --search` failed closed in a session on a missing
/usr/local/bin/codex-code-mode-host.

Codex is a package, not a binary. 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`, and Codex
finds every companion by walking up from the RESOLVED path of its own
executable until it finds `codex-package.json`. The toolchain script fetched
`codex-${TRIPLE}.tar.gz`, which is that one executable and nothing else, and
installed it into $BIN. `codex --version` was green; the first turn needing the
tool host was not.

Measured against the real rust-v0.153.4 archive with `codex doctor --json`,
which reports Codex's own resolution rather than ours:

  complete package             install context names package/bin/resources/path
  bin/codex alone              bare "other"; ripgrep resolves to the OS one
  bin/codex + host on PATH     byte-for-byte identical to the row above
  package minus the tool host  doctor still passes; nothing notices
  symlink on PATH -> bin/codex identical to the complete package

So the tool host goes neither on PATH nor beside sessiond in /usr/local/bin, a
symlink is how the PATH entry reaches the package, and the layout has to be
asserted separately because doctor does not catch a deleted host.

toolchain.sh now fetches `codex-package-${CODEX_TRIPLE}.tar.gz` for both
architectures, pinned by the sums published in the release's own
codex-package_SHA256SUMS and checked before extraction as every other artifact
here is. The tree lands at $PREFIX/lib/codex and $BIN/codex is a RELATIVE link
into it, so the same link resolves in the toolchain stage and again after the
final image copies both directories under /usr/local. require_codex_package
rejects a manifest for another version, target or layout and any missing or
non-executable member, so a build that lost a piece fails instead of shipping.

The package is root-owned under /usr/local for the reason sessiond and the
other agents are: a session user who could rewrite the tool host could rewrite
what the agent spawns.

CODEX_VERSION is unchanged at 0.153.4. Sourcing toolchain.sh now exposes the
validator and installs nothing, which is how the Go tests drive it against
synthetic packages. scripts/session-image-smoke.sh asks the installed codex
where it resolved its own package, requires the bundled ripgrep rather than the
OS one, and runs the tool host it names — offline, with no credential.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDaM4pHz5JRixZrG1yEr85

* fix(image): escape manifest values in the Codex layout check

The check built its grep pattern from the version and target triple directly,
so a dot in either matched any character. Only the whitespace after the colon
is meant to be a pattern; the values are literals. A build-time assertion on a
checksum-pinned archive, so nothing was actually loose, but a check that reads
as approximate invites being trusted approximately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDaM4pHz5JRixZrG1yEr85

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(runner): support host session security profiles (#68)

* Restore Claude onboarding state with agent credentials (#69)

* fix: restore Claude onboarding state with credentials

* harden agent home restoration

* fix: fence agent credential restoration

* fix: preserve agent logout fences

* fix: fail closed across credential upgrades

* fix: reject empty credential manifests

* fix(credentials): fence delayed session credential deletions (#71)

* feat(image): PostgreSQL 17, SQLite and Redis in the session image (#72)

A session could not run an integration test. There is no way to get a database
after the fact — `sudo` is not installed, the rootfs is read-only, and no
package archive is on the egress allowlist — so the client AND the server
halves belong in the image, which is the one thing a Dedicated runner is
allowed to pull.

Nothing starts. `create_main_cluster = false` keeps postgresql-common from
running initdb into a directory the read-only rootfs could never use, and a
policy-rc.d refuses every maintainer-script service start for the duration of
the install. There is no service in the entrypoint and no port bound until a
developer runs one of the documented commands.

PGDG, and what the pin covers. Debian bookworm ships PostgreSQL 15 and the cell
is a 17 with store tests written against it; no 17 for bookworm avoids PGDG
directly or transitively. A second apt archive is a real trust decision, so the
signing key's full 40-hex fingerprint is an ARG a reviewer can read — and the
primary-key COUNT is asserted first and separately, because `gpg --dearmor`
converts every key in the file and `signed-by=` then trusts the whole keyring.
Versions resolve against a moving archive exactly as Debian's do;
rainier-apt-sources.txt records both archives. libpq5 and libpq-dev are named
explicitly with their major floored at the server's: apt is as free to resolve
the 15-to-17 upgrade by removing the -dev package as by upgrading it, and PGDG
ships one libpq for every major it carries, so an archive that has released an
18 correctly hands this image an 18.x libpq beside the 17 server.

Durable state goes on the workspace volume, because that is the only writable
path surviving a suspend. Sockets and pidfiles deliberately do not:
protocol/workspace.TarGz refuses a socket rather than skipping it, so one under
/workspace fails `rainier push`/`pull` of any tree containing it and an unclean
exit leaves it there to keep failing. They live on the per-container tmpfs,
which is where per-container state belongs.

`rainier-pg` and `rainier-redis` are root-owned helpers beside sessiond. They
install nothing and need no privilege, and the raw tools stay on PATH; they
exist because three things here are not the defaults. PostgreSQL's compiled-in
socket directory is on the read-only rootfs. This image generates no locales,
so an initdb inheriting an unset LANG builds an SQL_ASCII database that mangles
the first non-ASCII row a test inserts. And `pg_ctl -w stop` polls kill(pid, 0),
which cannot tell a shut-down postmaster from an unreaped zombie — while
postmaster.pid outlives the container it was written in, so it is never taken
as proof a server is running and a pid that may since have been reused is never
signalled.

TMPDIR is still not set, and a contract test now says so. /tmp is a
per-container noexec tmpfs and that is where agent scratch belongs — not in a
checkpoint, an archive or `rainier pull`. Exactly one build temp moves off it,
GOTMPDIR, because `go test` executes what it builds. Setting TMPDIR globally
would move Claude Code's and Codex's scratch onto the volume that leaves the
runner and quietly change the sandbox each believes it has.

Verification. Ten new tests run both helpers against stub binaries with no
docker, covering the initdb flags, the refusals, the stop paths (stale pidfile,
a server still rejecting connections, a server that never stops), the deadline,
the DSN, and the durable/runtime split. They are named TestSessionImage* so the
qualification workflow's -run selector actually runs them. The container smoke
checks that a fresh session has nothing listening and no cluster in the image,
then initdbs a cluster, starts it, commits one transaction and rolls another
back, reconnects over the DSN the helper prints, asserts the listener is
loopback and that no socket appears under /workspace while both servers run,
stops and restarts with the data intact, round-trips SQLite from shell and
Python, and starts, PINGs and stops Redis — as uid 1000, read-only rootfs, no
network at all.

The smoke also reports now. A failing check becomes a workflow annotation and
the sizes become notices, because a job log is not always reachable from where
the fix has to be made, and because size is a rollout gate rather than a
curiosity. The services layer measures 14 packages and 244,512 KiB installed —
the build diffs its own package set to get that, so it counts the
Debian-sourced dependencies too. libllvm19 and libz3-4 are 149 MiB of it, for
the query JIT postgresql-17 hard-depends on; removing them means building
PostgreSQL from source, which docs/session-image.md argues against.

* feat(egress): a default developer egress baseline at the dispatch seam (#73)

* feat(egress): a default developer egress baseline at the dispatch seam

A session's network is default-deny and the only way out is egressd's
per-session allowlist. Nothing in that allowlist made `npm ci` work, so the
first thing every new environment did was fail: a 403 from the proxy, a human
going to find out where npm keeps its tarballs, an environment edit, and a
restart — once per ecosystem, per environment. The host list that fixed it for
some people lived in a runbook, which means it was a default only for the
people who read the runbook.

This puts the default where the default actually is. controlapp's createSpec
is the one seam every dispatch passes in both compositions, and it now unions
a twelve-host developer baseline into the allowlist after the row's declared
hosts, the launch material's clone hosts, and the agent providers' hosts —
additive, deduped, and never written to the session row, so `egress_allow`
still means "what a human asked for".

The twelve are literal apexes reached by toolchains the session image actually
ships, each established by fetching through it and following the redirects
rather than by copying a vendor's firewall page. That method changed two
answers: GitHub release assets now land on release-assets.githubusercontent.com,
which nothing here carried, and proxy.golang.org serves its own module zips —
a 4 MB one included — so storage.googleapis.com buys nothing and would have
cost every GCS bucket on the internet. There are no wildcards and there is no
multi-tenant namespace on the list; a test enforces both. GitHub Actions job
logs, which redirect into productionresultssa*.blob.core.windows.net, are
therefore NOT supported by default, and docs/default-egress.md argues that
trade rather than hiding it.

Reachability is not authority: none of this issues a credential, none of it
lets a session publish a package, and private registries stay named per
environment beside the credential that reaches them. FleetOptions.DefaultEgress
is a pointer so a host can turn the baseline off (empty) or replace it, and it
is host policy — no API field, no environment column, so no tenant can widen
its own default.

Broadening the default makes one latent gap matter: the allowlist matches the
NAME a client wrote, and what that name resolves to is a nameserver's answer.
egressd now resolves the host itself, refuses loopback/RFC1918/link-local
(where every cloud metadata service lives)/CGNAT and the rest, and dials the
address it vetted instead of re-resolving the name — which is what makes "a
redirect cannot broaden private access" true rather than merely likely.
AllowPrivateDestinations lifts it for a local fleet whose origins are on
loopback; egressd exposes no flag for it.

The self-hosted launch resolver's clone hosts are now read out of the same
table rather than spelled a second time, so there is one list.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(egress): reserve dial time for fallback addresses

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(cli): finish hosted v0 readiness and safe session management (#70)

* feat(cli): minimal hosted-first v0 command surface

The CLI had grown twenty-odd commands, and `rainier --help` listed all of
them: transfers, snapshots, secrets, environments, contexts, workspaces and
the self-hosted credential vault sat beside the four things a hosted
developer actually does. A new developer could not read the product off one
screen, which is the only test of a command surface that matters.

The public surface is now sign in, check readiness, authenticate a coding
agent, and create and manage sessions — 23 lines of default help. Everything
else still dispatches, under `rainier help all`, labeled Advanced.

A session has three independent facts and this CLI keeps them apart: the
sandbox's lifecycle (`state`), the process inside it (`child_exit_code`), and
whether the control plane can reach it (`reachable`). They fail separately, so
they are three columns. A session whose agent exited is still Running — it
holds its filesystem and it is still attachable — and a runner that dropped
its link makes a session Unavailable, not Failed. Action eligibility comes
from the raw API's own transition rules and never from the display word:
`SuspendSession` accepts `running` and nothing else, so Stop is not offered
for a queued session merely because queued and creating share the label
Starting.

docs/cli-v0-contract.md is the authority for what each command promises and
which Cloud APIs each promise depends on. Rainier Cloud 81d1ad3 shipped the
compute enrollment and `workspace_not_ready`, so `status` reads the
workspace's own compute state — both `status` and `health`, because ready
capacity nobody can reach is not ready. Two contracts are still outstanding
and both are isolated in cmd/rainier/readiness.go: a bearer-reachable
onboarding destination, and a per-environment agent launch catalog. Neither is
guessed at.

New: `logout`, `status`, `info`, `stop`, `delete`, the `current` selector,
and `--json` on everything a script reads — carrying the canonical API facts
verbatim beside the derived ones. `diff` is removed completely: git inside the
session is the source of truth. `doctor`, `suspend`, `rm` and `agent ls`
remain as hidden aliases for the commands that replaced them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q3GPB683VghXL2XA4XWK7Y

* fix(cli): finish v0 readiness, session safety, and output contracts

* fix(credentials): fence delayed session credential deletions (#71)

* feat(image): PostgreSQL 17, SQLite and Redis in the session image (#72)

A session could not run an integration test. There is no way to get a database
after the fact — `sudo` is not installed, the rootfs is read-only, and no
package archive is on the egress allowlist — so the client AND the server
halves belong in the image, which is the one thing a Dedicated runner is
allowed to pull.

Nothing starts. `create_main_cluster = false` keeps postgresql-common from
running initdb into a directory the read-only rootfs could never use, and a
policy-rc.d refuses every maintainer-script service start for the duration of
the install. There is no service in the entrypoint and no port bound until a
developer runs one of the documented commands.

PGDG, and what the pin covers. Debian bookworm ships PostgreSQL 15 and the cell
is a 17 with store tests written against it; no 17 for bookworm avoids PGDG
directly or transitively. A second apt archive is a real trust decision, so the
signing key's full 40-hex fingerprint is an ARG a reviewer can read — and the
primary-key COUNT is asserted first and separately, because `gpg --dearmor`
converts every key in the file and `signed-by=` then trusts the whole keyring.
Versions resolve against a moving archive exactly as Debian's do;
rainier-apt-sources.txt records both archives. libpq5 and libpq-dev are named
explicitly with their major floored at the server's: apt is as free to resolve
the 15-to-17 upgrade by removing the -dev package as by upgrading it, and PGDG
ships one libpq for every major it carries, so an archive that has released an
18 correctly hands this image an 18.x libpq beside the 17 server.

Durable state goes on the workspace volume, because that is the only writable
path surviving a suspend. Sockets and pidfiles deliberately do not:
protocol/workspace.TarGz refuses a socket rather than skipping it, so one under
/workspace fails `rainier push`/`pull` of any tree containing it and an unclean
exit leaves it there to keep failing. They live on the per-container tmpfs,
which is where per-container state belongs.

`rainier-pg` and `rainier-redis` are root-owned helpers beside sessiond. They
install nothing and need no privilege, and the raw tools stay on PATH; they
exist because three things here are not the defaults. PostgreSQL's compiled-in
socket directory is on the read-only rootfs. This image generates no locales,
so an initdb inheriting an unset LANG builds an SQL_ASCII database that mangles
the first non-ASCII row a test inserts. And `pg_ctl -w stop` polls kill(pid, 0),
which cannot tell a shut-down postmaster from an unreaped zombie — while
postmaster.pid outlives the container it was written in, so it is never taken
as proof a server is running and a pid that may since have been reused is never
signalled.

TMPDIR is still not set, and a contract test now says so. /tmp is a
per-container noexec tmpfs and that is where agent scratch belongs — not in a
checkpoint, an archive or `rainier pull`. Exactly one build temp moves off it,
GOTMPDIR, because `go test` executes what it builds. Setting TMPDIR globally
would move Claude Code's and Codex's scratch onto the volume that leaves the
runner and quietly change the sandbox each believes it has.

Verification. Ten new tests run both helpers against stub binaries with no
docker, covering the initdb flags, the refusals, the stop paths (stale pidfile,
a server still rejecting connections, a server that never stops), the deadline,
the DSN, and the durable/runtime split. They are named TestSessionImage* so the
qualification workflow's -run selector actually runs them. The container smoke
checks that a fresh session has nothing listening and no cluster in the image,
then initdbs a cluster, starts it, commits one transaction and rolls another
back, reconnects over the DSN the helper prints, asserts the listener is
loopback and that no socket appears under /workspace while both servers run,
stops and restarts with the data intact, round-trips SQLite from shell and
Python, and starts, PINGs and stops Redis — as uid 1000, read-only rootfs, no
network at all.

The smoke also reports now. A failing check becomes a workflow annotation and
the sizes become notices, because a job log is not always reachable from where
the fix has to be made, and because size is a rollout gate rather than a
curiosity. The services layer measures 14 packages and 244,512 KiB installed —
the build diffs its own package set to get that, so it counts the
Debian-sourced dependencies too. libllvm19 and libz3-4 are 149 MiB of it, for
the query JIT postgresql-17 hard-depends on; removing them means building
PostgreSQL from source, which docs/session-image.md argues against.

* fix(cli): close session selection and diagnostic review gaps

* docs(cli): record complete race verification result

* fix(cli): address independent v0 lifecycle and output review

* test(relay): drain buffered output before asserting connection close

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore: refresh npm wrapper for v0.0.9

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant