From b43f53d3184bb49dda5963f02a75bf09c388592f Mon Sep 17 00:00:00 2001 From: Karsten Kosmala Date: Mon, 5 Oct 2026 09:54:27 +0200 Subject: [PATCH] Start the box image under tini The box and login containers got their init from Docker's `Init: true`. Anything else that runs the image has to know to add one, or the entrypoint's `sleep` is PID 1: it ignores SIGTERM, so every stop waits out the grace period, and it reaps nothing, so every process orphaned in the box stays a zombie and counts against the pids limit. The image now installs tini and starts its entrypoint under it, and the box and login containers drop `Init: true`. Both have to change together: under docker-init, tini is not PID 1 and reaps nothing without `-s`. The root helper in `oneShot` replaces the entrypoint, so it keeps `Init: true`. The gateway already reads `tini` as a box's init. Co-Authored-By: Claude Opus 5.5 --- ARCHITECTURE.md | 13 ++++++++----- box-image/Dockerfile | 10 ++++++++-- orchestrator/src/docker.ts | 10 +++++----- orchestrator/src/gateway/background.test.ts | 14 ++++++++++++++ 4 files changed, 35 insertions(+), 12 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d05f158..d297575 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1636,11 +1636,14 @@ arrangement — a `user:` on the orchestrator service and a data directory owned by the same uid — rather than something the shipped compose does. It runs non-root with `ReadonlyRootfs`, `CapDrop: ALL`, -`no-new-privileges`, a tmpfs `/tmp`, memory, CPU and pids limits, and -`Init: true`. That last one matters: the kernel discards default-disposition -signals for PID 1, so without docker-init the entrypoint's `sleep` would never -see SIGTERM and every stop would wait out the grace period. The only -caller-supplied values are the box id and the profile secrets. +`no-new-privileges`, a tmpfs `/tmp`, and memory, CPU and pids limits. The +image's entrypoint starts under tini, which is PID 1. That matters: the kernel +discards default-disposition signals for PID 1, so without an init the +entrypoint's `sleep` would never see SIGTERM and every stop would wait out the +grace period, and nothing would reap the processes orphaned in the box. The +init lives in the image rather than in Docker's `Init: true`, so it is there +whatever runs the image. The only caller-supplied values are the box id and +the profile secrets. The entrypoint installs the agent configuration into `~/.claude`, sets the git and gh identity, and then holds the container open. diff --git a/box-image/Dockerfile b/box-image/Dockerfile index e50df6d..ef40074 100644 --- a/box-image/Dockerfile +++ b/box-image/Dockerfile @@ -47,10 +47,12 @@ FROM ubuntu:26.04 AS image # untagged copy after a pull replaced it. LABEL boxes.image=box -# curl needs a trust store to add the repositories below. +# curl needs a trust store to add the repositories below. tini is PID 1; +# see the ENTRYPOINT. RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates \ curl \ + tini \ && rm -rf /var/lib/apt/lists/* # Node from NodeSource, because Ubuntu's own stays at the release's version. @@ -513,4 +515,8 @@ RUN touch /tmp/checks-passed # stage, so a failed check fails the build. FROM image COPY --from=test /tmp/checks-passed /etc/boxes/checks-passed -ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] +# tini as PID 1, because the kernel discards default-disposition signals for +# PID 1: the entrypoint's sleep would never see SIGTERM, and every stop would +# wait out the grace period. tini also reaps the processes orphaned in the +# box, which would otherwise stay zombies and count against the pids limit. +ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/entrypoint.sh"] diff --git a/orchestrator/src/docker.ts b/orchestrator/src/docker.ts index dee31d1..b693cb3 100644 --- a/orchestrator/src/docker.ts +++ b/orchestrator/src/docker.ts @@ -522,6 +522,8 @@ async function oneShot(spec: { SecurityOpt: ['no-new-privileges:true'], Privileged: false, RestartPolicy: { Name: 'no' }, + // The entrypoint, and tini with it, is replaced above, so the init + // comes from Docker. Init: true, }, }); @@ -588,10 +590,9 @@ export async function createContainer(spec: CreateContainerSpec, cfg: Config): P NanoCpus: Math.round(cfg.BOX_CPUS * 1e9), PidsLimit: cfg.BOX_PIDS_LIMIT, RestartPolicy: { Name: 'no' }, - // The kernel discards default-disposition signals for PID 1, so the - // entrypoint's sleep never sees SIGTERM. docker-init forwards the signal - // and reaps, which keeps stops prompt. - Init: true, + // No `Init`: the image's entrypoint starts under tini, which reaps and + // forwards SIGTERM. A docker-init in front of it would leave tini a + // child that reaps nothing. // Stated explicitly so a later edit cannot loosen them by omission. Privileged: false, PublishAllPorts: false, @@ -672,7 +673,6 @@ export async function createLoginContainer(spec: { // limit can break a Node CLI. PidsLimit: 256, RestartPolicy: { Name: 'no' }, - Init: true, Privileged: false, PublishAllPorts: false, }, diff --git a/orchestrator/src/gateway/background.test.ts b/orchestrator/src/gateway/background.test.ts index d2a2b3c..6cc8762 100644 --- a/orchestrator/src/gateway/background.test.ts +++ b/orchestrator/src/gateway/background.test.ts @@ -204,6 +204,20 @@ test('a box holding both adapters and doing nothing is idle', () => { assert.deepEqual(reading.work, []); }); +test('a box whose image starts it under tini is idle', () => { + const reading = readBox( + table( + [1, 0, '/usr/bin/tini -- /usr/local/bin/entrypoint.sh'], + [7, 1, 'sleep infinity'], + [22977, 0, 'node /usr/local/bin/claude-agent-acp'], + [23019, 22977, CLAUDE_AGENT], + ), + BOTH, + ); + assert.equal(reading.busy, false); + assert.deepEqual(reading.work, []); +}); + test('a box numbered by the host rather than by itself is still idle', () => { // `docker top` prints host pids, so no process of the box is 1. const reading = readBox(