Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 8 additions & 2 deletions box-image/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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"]
10 changes: 5 additions & 5 deletions orchestrator/src/docker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
});
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
},
Expand Down
14 changes: 14 additions & 0 deletions orchestrator/src/gateway/background.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading