# radial-turn: the sandboxed image `radiald run` launches (via `docker run`, see # packages/daemon/src/container.ts's `dockerRunArgs`) once per turn. # # Build from the repo root: # docker build -f docker/Dockerfile -t radial-turn:latest . # (scripts/build-images.mjs / `pnpm images` does exactly this, after `pnpm build`.) # # Assumptions, called out because Docker cannot be run in this development environment to verify # them directly (see the phase-3 workstream E-final report for what was and wasn't exercised): # - The build stage needs network access to the npm registry (`pnpm install`); the runtime stage # needs it too, to `npm install -g` pnpm and the three agent CLIs. Both are operator/build-time # concerns, not exercised by this repo's own `pnpm build`/`test` gate. # - `pnpm deploy --legacy --prod` was verified locally (outside Docker) against this workspace: # it assembles a self-contained `@radial/sidecar` install, resolving its workspace dependencies # (@radial/core, @radial/atproto, @radial/ingest — none of which declare any *runtime* npm # dependencies of their own) into a real `node_modules`. That means the runtime stage needs # none of the rest of the monorepo, nor a pnpm virtual store — just the deployed directory. # (`--legacy` is required: pnpm v10's default injected-workspace deploy needs # `inject-workspace-packages=true`, which this workspace does not set.) # - The daemon supplies mounts (/work rw for implementation turns and ro otherwise, /bundle ro, # /run/radial rw), env (RADIAL_SIDECAR_SOCKET, RADIAL_TURN_TOKEN, whichever provider # credential names the acting profile's harness declares — `Harness.credentialEnv` plus # `run.modelEnv`, so ANTHROPIC_API_KEY / CLAUDE_CODE_OAUTH_TOKEN for claude, any of pi's # provider keys for pi, and CODEX_API_KEY / CODEX_ACCESS_TOKEN or a scratch managed-auth home # for codex — # GH_TOKEN or, on a tangled project, GIT_SSH_COMMAND + # RADIAL_PUSH_REMOTE alongside a read-only /run/radial-forge mount, see # turn.ts), and the argv of whichever harness the profile names (`claude -p ...`, # `pi --mode json ...` or `codex exec --json ...`, see harness.ts) at # `docker run` time. This image deliberately sets no ENTRYPOINT/CMD and no opinionated WORKDIR # (turn.ts passes `-w /work` on every run). # - `/tmp` and `/home/radial` are tmpfs-mounted by the daemon at run time # (ContainerSpec.tmpfs); the rootfs is otherwise read-only (ContainerSpec.readOnlyRootfs), so # this image must not need to write anywhere else (npm/claude/pi/codex config, git's global # config, etc. all resolve under $HOME=/home/radial — codex's $CODEX_HOME defaults to # $HOME/.codex). # - All three agent CLIs are installed unpinned, matching how `@anthropic-ai/claude-code` has # always been installed here. Pinning them is the follow-up if a bad upstream release lands. FROM node:24-slim AS build # CI=true: pnpm must never prompt (no TTY in a docker build); COREPACK_ENABLE_DOWNLOAD_PROMPT # likewise silences corepack's download confirmation. The root .dockerignore keeps the host's # node_modules/dist out of the build context so `pnpm install` starts from a clean slate. ENV CI=true COREPACK_ENABLE_DOWNLOAD_PROMPT=0 RUN corepack enable WORKDIR /repo COPY . . RUN pnpm install --frozen-lockfile RUN pnpm build RUN pnpm --filter=@radial/sidecar deploy /out --prod --legacy FROM node:24-slim AS runtime # Implementation turns use git directly. On GitHub they also use gh, with the operator's ephemeral # GH_TOKEN; on tangled they push over ssh with the per-turn key the daemon bind-mounts at # /run/radial-forge (turn.ts) — hence openssh-client, which node:24-slim does not ship. # # ripgrep is pi's: its `grep` tool shells out to `rg` and DOWNLOADS one at first use when the # binary is missing — which, under a read-only rootfs with a tmpfs $HOME, means a download per # turn at best. Installing it here is what pi's own containerization guide recommends. RUN apt-get update \ && apt-get install -y --no-install-recommends git gh openssh-client ripgrep ca-certificates \ && rm -rf /var/lib/apt/lists/* # Project checks in a turn invoke pnpm directly. Corepack was enabled in the discarded build stage # only, so without a runtime install agents first fail with "pnpm: not found" and have to discover # an incidental package-manager path. Install the exact workspace version as a global executable; # unlike a Corepack shim this never needs to download a package manager after the read-only # container has started. The smoke test below exercises it as uid 1000, the same user as a turn. RUN npm install -g --ignore-scripts pnpm@10.28.2 \ && pnpm --version \ && npm cache clean --force # @anthropic-ai/claude-code: harness.ts's ClaudeCodeHarness shells out to `claude --print ...`. # Installed globally so it's on PATH for every user of the image, not just whoever installs it. # # install.cjs (the package's postinstall) is what copies the platform-native binary from the # optional dependency over the bin/claude.exe placeholder. npm >= 11.16 blocks install scripts by # default and only *warns*, so leaving it implicit makes the build succeed while shipping a stub # that exits 1 with "claude native binary not installed" on the first turn. Running it explicitly # is also idempotent if npm's policy changes and the postinstall does fire. # `claude --version` then fails the build rather than deferring the failure to run time. RUN npm install -g @anthropic-ai/claude-code \ && node "$(npm root -g)/@anthropic-ai/claude-code/install.cjs" \ && claude --version \ && npm cache clean --force # @earendil-works/pi-coding-agent: harness.ts's PiHarness shells out to `pi --mode json ...`. # --ignore-scripts is what pi's own containerization guide installs with (it needs no lifecycle # script, unlike the claude package above); `pi --version` fails the build here rather than # deferring a broken install to the first turn. RUN npm install -g --ignore-scripts @earendil-works/pi-coding-agent \ && pi --version \ && npm cache clean --force # @openai/codex: harness.ts's CodexHarness shells out to `codex exec --json ...`. The npm package # is a thin wrapper around a platform-native Rust binary shipped in optional dependencies, which # npm resolves from the platform triple at install time — no lifecycle script is involved, so # --ignore-scripts costs nothing here and keeps the install policy uniform with pi's. # `codex --version` fails the build rather than deferring a broken install to the first turn. RUN npm install -g --ignore-scripts @openai/codex \ && codex --version \ && npm cache clean --force # The self-contained sidecar deploy from the build stage: dist/ for @radial/sidecar plus # node_modules holding @radial/core, @radial/atproto, @radial/ingest (each dependency-free at # runtime beyond node builtins and each other). COPY --from=build /out /opt/radial/sidecar RUN chmod -R a+rX /opt/radial/sidecar # A thin wrapper rather than a raw symlink onto PATH: this doesn't depend on cli.js's own # `#!/usr/bin/env node` shebang or execute bit surviving the multi-stage COPY unchanged. RUN printf '#!/bin/sh\nexec node /opt/radial/sidecar/dist/cli.js "$@"\n' > /usr/local/bin/radial \ && chmod 0755 /usr/local/bin/radial # Non-root uid/gid 1000, matching ContainerSpec's default `--user 1000:1000` (container.ts). # node:24-slim already ships uid/gid 1000 (the `node` user); the runtime only needs the uid and a # home directory at /home/radial ($HOME below) — the username is never referenced. RUN mkdir -p /home/radial && chown 1000:1000 /home/radial USER 1000:1000 ENV HOME=/home/radial # No WORKDIR: neutral by design (see header comment) — `docker run -w /work ...` supplies it.