Files
ironclaw/Dockerfile
Henry Park 053c83f513 fix(docker): forward-port the 1.3 in-worker SSH and workspace-root fixes to main (#7723, #7804) (#7915)
* fix(docker): restore Reborn in-worker SSH (#7723)

Co-authored-by: Henry Park <16583448+henrypark133@users.noreply.github.com>
(cherry picked from commit b0ba342268)

* fix(workspace): honor IRONCLAW_REBORN_WORKSPACE_ROOT on 1.3 (#7804)

* fix(workspace): honor IRONCLAW_REBORN_WORKSPACE_ROOT on 1.3

The durable workspace-root override landed on release/2026-08-11 in
e12bbae4d2 and was never forward-ported: neither origin/main nor
release/2026-08-17 has it. Both CLI boot paths resolve the workspace
root from `std::env::current_dir()`, so a container's project files and
landed attachments are written under the container workdir instead of
the mounted volume, and do not survive a redeploy.

Port the workspace-root slice only. The two release-branch commits that
carry it (e12bbae4d2, bdf8aef022) also carry the whole rc1->1.1/1.2
startup-migration program (~11.7k lines); that stack is out of scope
here and is already staged on
origin/port/firat-workspace-artifacts-1.2-to-1.3.

- `local_runtime_workspace_root` resolves the override and falls back to
  cwd, used by both the standalone and hosted single-tenant builders.
  `optional_path_env` rejects an empty value rather than silently
  treating it as unset.
- The Docker entrypoint defaults the root to `$IRONCLAW_REBORN_HOME/workspace`
  and fails closed on Railway when it resolves outside
  RAILWAY_VOLUME_MOUNT_PATH, matching the existing IRONCLAW_REBORN_HOME guard.
- The root pass also creates and chowns the workspace root before the
  gosu privilege drop. This has no counterpart on release/2026-08-11,
  which has no root pass; without it a workspace root outside
  IRONCLAW_REBORN_HOME fails the later mkdir as the unprivileged user.

Test: `scripts/ci/test-reborn-docker-entrypoint.sh` gains a default-root
assertion and an explicit-override case, and the existing ssh_root chown
assertion now pins the workspace root. Both new assertions were verified
to fail when the entrypoint default is broken.

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

* fix(workspace): resolve workspace root before the Railway guard

Addresses review on #7804.

Drop the workspace root from the root pass's mkdir/chown (P2). The
containment check runs after the gosu privilege drop, so the root pass
reached chown before validation: `IRONCLAW_REBORN_WORKSPACE_ROOT=/etc`
would have had its ownership changed to `ironclaw` before startup failed
closed. Removing it also deletes this port's only deviation from the
release/2026-08-11 original. The default root still works unprivileged —
IRONCLAW_REBORN_HOME is chowned in the root pass, so the later profile-
gated mkdir creates the subdirectory as `ironclaw`. A root outside
IRONCLAW_REBORN_HOME now fails loudly at that mkdir instead of silently
widening ownership.

Compare canonicalized paths in the Railway containment guard (P1). A
symlink beneath the mount whose target is outside it, or a `..` segment,
passed the lexical prefix test while the runtime resolved the real
ephemeral target — booting a deployment whose project files silently do
not persist, the exact failure the guard exists to prevent. Both sides
are resolved, so a mount path that itself traverses a symlink does not
reject every root. The diagnostic reports resolved and original spellings.

Test: the entrypoint self-test gains a rejected escaping-symlink case, a
contained positive control, and a trailing-slash normalization case. The
new checks use `assert_eq`, which the regression-test gate recognizes as
a meaningful shell assertion — `[ ... != ... ]` is not in its vocabulary,
which is why the required check rejected the previous commit. Each
assertion was verified to fail under a targeted mutation (lexical
containment restored, default root broken, normalization removed).

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

---------

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

* fix(docker): harden the forward-ported SSH and workspace-root paths

Review of the two forward-ported commits surfaced four defects. All of them
are latent in the shipped 1.3.x code, not introduced by the port -- `git blame`
puts every one on d4cf2411b0 (#7723) or ab7ac3d87e (#7804). They are fixed here
rather than ported forward knowingly.

**Workspace root is now prepared before the privilege drop.** The root branch
chowned only $IRONCLAW_REBORN_HOME and /workspace, then exec'd gosu; the
resolved $IRONCLAW_REBORN_WORKSPACE_ROOT was created afterwards, unprivileged.
An explicit override onto a fresh root-owned volume therefore hit EACCES and,
under `set -eu`, aborted the boot. The chown stays non-recursive so
start-sshd.sh keeps root ownership of $IRONCLAW_REBORN_HOME/ssh.

**The Railway containment guard no longer accepts an unresolved path.**
`readlink -f` exits non-zero when any non-final component is missing, so the
`|| printf` fallback handed the raw spelling to a glob comparison, and
`$RAILWAY_VOLUME_MOUNT_PATH/missing/../../tmp` passed the very `..`-escape
check the guard exists to enforce -- silently placing project files on
ephemeral storage. Verified empirically in debian:bookworm-slim (coreutils
9.1). Now `readlink -m`, which canonicalizes missing components, with no
raw-spelling fallback: an unresolvable path fails closed.

**sshd forwarding is disabled explicitly.** OpenSSH defaults
AllowTcpForwarding and AllowAgentForwarding to yes; the generated config now
sets those and PermitTunnel to no.

**A pasted private key is refused before it reaches disk.** `ssh-keygen -l -f`
exits 0 on a private key, so one supplied by mistake was written verbatim into
authorized_keys (mode 644, persisted). It never granted a login -- PEM lines do
not match authorized_keys grammar -- so the harm was secret persistence plus a
silently broken setup. The value is now rejected on a PEM header or on carrying
more than one key, without ever echoing the key material.

That last check needs care: an operator almost always supplies the key via
`cat id_ed25519.pub`, whose value carries a trailing newline. A naive
line-count rejection would refuse the most common paste, and because the
entrypoint runs start-sshd under `set -e` that would abort the whole container
boot rather than merely disabling SSH. Surrounding whitespace and blank lines
are therefore stripped before the one-key check, and the PEM check runs first
so a real private key still gets the actionable "supply the matching .pub"
message.

Verified against a real sshd in debian:bookworm-slim, comparing behavior with
the shipped release/1.3.1 script as the baseline:

- 16/16 regression checks, against a 12-pass/1-fail baseline. Every baseline
  pass survives; real logins still succeed for ed25519, RSA and ECDSA.
- 10/10 operator-paste cases: plain, trailing newline, leading newline,
  trailing spaces, CRLF and surrounding blank lines all accept and complete a
  real SSH login; private key, two keys, garbage and whitespace-only are
  refused with accurate messages.
- Unset key still exits 0 with no listener, preserving the opt-in contract.
- Generated config still passes `sshd -t` with every original auth directive.

The trailing-newline case is pinned in CI by a second container in the existing
`Verify in-worker SSH` step, reusing the image already built there -- it adds
about ten seconds to a job that takes roughly twenty-two minutes, and no new
job. The other three fixes carry shell-suite regression tests, each proven to
fail before its fix. Fixes 3 and 4 cannot be reached from that suite
(start-sshd.sh needs real root and a real sshd binary), which is precisely why
the CI container check exists.

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

* docs(docker): document the in-worker SSH ingress and its privilege model

`IRONCLAW_REBORN_SSH_PUBLIC_KEY` was documented nowhere, despite being the
single switch that stands up an SSH listener in the runtime image. AGENTS.md
requires environment variables to be documented in `.env.example`.

- `.env.example` gains the variable next to IRONCLAW_REBORN_WORKSPACE_ROOT:
  off unless set, enables sshd on container port 2222, public-key-only login
  as `agent`, and the port must be published by the orchestrator to be
  reachable.
- The operator Docker guide gains an SSH Access section covering the same
  three facts plus the auth directives the generated sshd_config actually
  sets, and states plainly that `agent` is a uid-1000 alias of `ironclaw` --
  an SSH session holds the full runtime identity, so the private key deserves
  the same care as shell access to the service.
- Two Dockerfile comments (no build-logic change) record why `agent` is a
  deliberate UID alias, so a later reader does not mistake it for a
  lower-privileged account and widen the SSH surface on that assumption, and
  that the entrypoint performs the only privilege drop -- anything bypassing
  it (`docker run --entrypoint`, `docker exec` without `--user`, a platform
  custom start command) runs as root.

Every claim was checked against docker/reborn/start-sshd.sh, entrypoint.sh and
the Dockerfile runtime stage rather than restated from the review comments.

Verified: check-guidance.py OK, docs_publication_boundary.py OK.

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

* fix(docker): never chown an unvalidated workspace root as root

The previous commit's workspace-root fix introduced a privilege escalation.
It added $IRONCLAW_REBORN_WORKSPACE_ROOT to the root branch's `chown`, but the
Railway containment check that validates an operator-supplied override runs
*after* the privilege drop -- it needs $effective_profile, which is resolved
from the config file further down, past `exec gosu`. The raw value was
therefore chowned to the runtime uid before anything could reject it.

Reproduced in debian:bookworm-slim: with
`IRONCLAW_REBORN_WORKSPACE_ROOT=/etc` on a Railway-profile boot, /etc went
from root:root to ironclaw:ironclaw, and only then did startup fail. A
non-recursive chown of /etc still lets uid 1000 create entries there
(/etc/ld.so.preload being the obvious one), so this is a real escalation
primitive, and the container filesystem keeps it across restarts.

The root pass now pre-creates the workspace root only when it provably lives
inside a directory this entrypoint already manages -- under
$IRONCLAW_REBORN_HOME, or under the Railway volume mount -- comparing
canonicalized paths so `$RAILWAY_VOLUME_MOUNT_PATH/../etc` cannot spell its
way in. Anything else is left untouched: on Railway the containment check
rejects it moments later, and elsewhere the later unprivileged `mkdir -p`
reports the failure exactly as it did before. That still covers the case the
fix exists for, an override onto a fresh root-owned volume mount, which is
what the deployment guide documents.

Regression tests (`workspace_root_outside_managed`) assert the root pass never
passes an out-of-tree path to chown, in both a plain and a `..`-spelled form;
both fail without the guard and pass with it. Two existing cases were
reconciled with the new shape: the chown stub now appends, because the root
pass legitimately issues two chown calls rather than one, and an overwriting
stub silently dropped the first; and `workspace_root_privdrop` now sets
RAILWAY_VOLUME_MOUNT_PATH, making explicit the volume-mount scenario its own
comment already described.

Not addressed here: $IRONCLAW_REBORN_HOME is chowned on the same path with no
validation either. That predates this PR, and unlike the workspace root it has
no containment contract to respect -- any path is a legitimate home by design
-- so tightening it is a behavior change to shipped configuration handling
rather than a fix to this regression. Tracked with the other uid-alias
findings.

Verified: entrypoint self-tests pass on debian:bookworm-slim as a non-root
user (as CI runs them); the real-sshd regression harness still reports 16/16
against the release/1.3.1 baseline; ws12 workflow contracts and check-guidance
pass.

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

* fix(docker): refuse a filesystem-root home or workspace root

`${VAR%/}` turns the single-character input `/` into an empty string, and
neither root path handled that. `IRONCLAW_REBORN_HOME=/` reached
`mkdir -p "" /workspace` and died with a confusing "cannot create directory
''". `IRONCLAW_REBORN_WORKSPACE_ROOT=/` reached `readlink -m ""` in the
containment guard, which exits non-zero and printed nothing at all -- under
`set -eu` the boot died silently, with no diagnostic for an operator to act
on. That silent mode was introduced by the containment guard two commits ago.

The obvious repair -- restoring `/` the way the file already does for
RAILWAY_VOLUME_MOUNT_PATH eight lines above -- is wrong here, and testing it
is what showed why. Restoring the value makes the root pass's
`chown ironclaw:ironclaw "$IRONCLAW_REBORN_HOME"` newly *reachable* as
`chown ironclaw:ironclaw /`, handing uid 1000 ownership of the container's
entire root filesystem. Verified in a container: the chown succeeds. That
trades a loud crash for a total loss of the root boundary, which is strictly
worse than the bug being fixed.

Both values are therefore refused with a diagnostic naming the variable.
Nothing legitimately runs with either path set to the filesystem root, so the
safe reading of `/` is operator error -- stop and say so.

Regression tests (`slash_root`) drive both variables through the root pass and
assert three things: the run is refused, the diagnostic names the variable,
and *nothing is chowned on the way to that refusal*. That last assertion is
the one that matters -- it fails against the restore-to-`/` shape as well as
against the original bug, so it pins the escalation rather than just the
crash. All three assertions fail without the guards and pass with them.

Verified in debian:bookworm-slim: `IRONCLAW_REBORN_HOME=/` leaves `/` owned by
root:root and exits with the diagnostic; the entrypoint suite passes as a
non-root user (as CI runs it); the real-sshd regression harness still reports
16/16 against the release/1.3.1 baseline, including a full
entrypoint -> start-sshd -> real SSH login as agent:1000 with the ssh state
directory still root-owned.

Also adds the missing Rust coverage for the same variable:
`local_runtime_workspace_root` now has tests for an explicit override, the
unset fallback to the current directory, and the set-but-empty failure
asserting the error names IRONCLAW_REBORN_WORKSPACE_ROOT. Each was proven
discriminating by breaking the production function and confirming only the
matching test failed. Scoped to the resolution function deliberately: proving
the value reaches the composed RebornHostBindings would require a new public
accessor in ironclaw_composition, which is a production API change and does
not belong in a release-blocking forward-port.

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

---------

Co-authored-by: Henry Park <16583448+henrypark133@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 00:14:28 +00:00

161 lines
6.1 KiB
Docker

# Multi-stage Dockerfile for the standalone Reborn CLI HTTP service.
#
# Build:
# docker build -f Dockerfile -t ironclaw-reborn:latest .
#
# Run locally:
# docker run --rm --env-file .env.reborn -p 127.0.0.1:3000:3000 ironclaw-reborn:latest
#
# Railway:
# Set Dockerfile path to Dockerfile and IRONCLAW_REBORN_SERVE_HOST=0.0.0.0.
# Railway supplies PORT. Set IRONCLAW_REBORN_PROFILE=hosted-single-tenant for
# Postgres-backed storage, hosted-single-tenant-volume for a volume-backed
# preview, or hosted-single-tenant-volume-sandboxed-railway for the explicit
# Railway Sandbox preview described in docs/internal/reborn/railway-sandbox-operator.md.
FROM node:22.23.1-bookworm-slim@sha256:813a7480f28fdadac1f7f5c824bcdad435b5bc1322a5968bbbdef8d058f9dff4 AS node_toolchain
FROM debian:bookworm-slim@sha256:7b140f374b289a7c2befc338f42ebe6441b7ea838a042bbd5acbfca6ec875818 AS railway_cli
ARG TARGETARCH
ARG RAILWAY_CLI_VERSION=5.30.4
RUN apt-get -o Acquire::Retries=3 update \
&& apt-get -o Acquire::Retries=3 install -y --no-install-recommends \
ca-certificates \
curl \
&& case "$TARGETARCH" in \
amd64) \
railway_target="x86_64-unknown-linux-gnu"; \
railway_sha256="33addd7729e99291f329ac671b02e9fe14fec8b7d9cdc11be77569739dae5c0e"; \
;; \
arm64) \
railway_target="aarch64-unknown-linux-musl"; \
railway_sha256="11c24392e5e3551687c5e35ade2eec63e2ea7689603117de83f4f480dbb2d2a7"; \
;; \
*) \
echo "unsupported Railway CLI architecture: $TARGETARCH" >&2; \
exit 1 \
;; \
esac \
&& railway_archive="railway-v${RAILWAY_CLI_VERSION}-${railway_target}.tar.gz" \
&& curl -fsSL \
"https://github.com/railwayapp/cli/releases/download/v${RAILWAY_CLI_VERSION}/${railway_archive}" \
-o "/tmp/${railway_archive}" \
&& echo "${railway_sha256} /tmp/${railway_archive}" | sha256sum -c - \
&& tar -xzf "/tmp/${railway_archive}" -C /tmp railway \
&& install -m 0755 /tmp/railway /usr/local/bin/railway \
&& railway --version
FROM rust:1.96-bookworm@sha256:5e2214abe154fe26e39f64488952e5c991eeed1d6d6da7cc8381ae83927f0cfc AS chef
COPY --from=node_toolchain /usr/local/bin/node /usr/local/bin/node
COPY --from=node_toolchain /usr/local/lib/node_modules/ /usr/local/lib/node_modules/
WORKDIR /app
COPY .cargo/config.toml .cargo/config.toml
RUN ln -sf ../lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \
&& ln -sf ../lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx \
&& ln -sf ../lib/node_modules/corepack/dist/corepack.js /usr/local/bin/corepack \
&& node --version \
&& npm --version \
&& corepack --version \
&& corepack enable pnpm \
&& cargo install --locked cargo-chef@0.1.77
FROM chef AS planner
COPY Cargo.toml Cargo.lock ./
COPY crates/ crates/
COPY tools/ironclaw_stress/ tools/ironclaw_stress/
COPY skills/ skills/
COPY tests/ tests/
RUN mkdir -p src \
&& printf 'fn main() {}\n' > src/main.rs \
&& printf '\n' > src/lib.rs
RUN cargo chef prepare --recipe-path recipe.json
FROM chef AS deps
ENV CARGO_PROFILE_DIST_PANIC=abort \
CARGO_PROFILE_DIST_CODEGEN_UNITS=1
COPY --from=planner /app/recipe.json recipe.json
COPY crates/product/ironclaw_webui/frontend/ crates/product/ironclaw_webui/frontend/
WORKDIR /app/crates/product/ironclaw_webui/frontend
RUN pnpm install --frozen-lockfile
WORKDIR /app
RUN cargo chef cook \
--profile dist \
--package ironclaw \
--recipe-path recipe.json
FROM deps AS builder
COPY Cargo.toml Cargo.lock ./
COPY crates/ crates/
COPY tools/ironclaw_stress/ tools/ironclaw_stress/
COPY migrations/ migrations/
COPY skills/ skills/
COPY tests/ tests/
RUN mkdir -p src \
&& printf 'fn main() {}\n' > src/main.rs \
&& printf '\n' > src/lib.rs
WORKDIR /app/crates/product/ironclaw_webui/frontend
RUN pnpm install --frozen-lockfile
WORKDIR /app
RUN cargo build \
--profile dist \
--package ironclaw \
--bin ironclaw
FROM debian:bookworm-slim@sha256:7b140f374b289a7c2befc338f42ebe6441b7ea838a042bbd5acbfca6ec875818 AS runtime
RUN apt-get -o Acquire::Retries=3 update \
&& apt-get -o Acquire::Retries=3 install -y --no-install-recommends \
ca-certificates \
curl \
gosu \
openssh-server \
postgresql-client \
sqlite3 \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/dist/ironclaw /usr/local/bin/ironclaw
COPY --from=railway_cli /usr/local/bin/railway /usr/local/bin/railway
COPY docker/reborn/config.toml /opt/ironclaw/reborn/config.toml
COPY docker/reborn/config.hosted-single-tenant.toml /opt/ironclaw/reborn/config.hosted-single-tenant.toml
COPY docker/reborn/config.hosted-single-tenant-volume.toml /opt/ironclaw/reborn/config.hosted-single-tenant-volume.toml
COPY docker/reborn/config.production.toml /opt/ironclaw/reborn/config.production.toml
COPY docker/reborn/entrypoint.sh /usr/local/bin/ironclaw-reborn-entrypoint
COPY docker/reborn/start-sshd.sh /usr/local/bin/ironclaw-reborn-start-sshd
ENV HOME=/home/ironclaw \
IRONCLAW_REBORN_LOG=info \
IRONCLAW_REBORN_SERVE_HOST=127.0.0.1
# `agent` is a deliberate uid-1000 alias of `ironclaw`, not a separate,
# lower-privileged account. A session logged in as `agent` (e.g. over SSH)
# holds the full runtime identity and can read/write everything the IronClaw
# process owns.
RUN useradd -m -d /home/ironclaw -u 1000 ironclaw \
&& useradd --non-unique --uid 1000 --gid ironclaw --home-dir /workspace --shell /bin/bash agent \
&& passwd -d agent \
&& mkdir -p /data/ironclaw-reborn /workspace \
&& chown -R ironclaw:ironclaw /home/ironclaw /data/ironclaw-reborn /workspace \
&& chmod +x /usr/local/bin/ironclaw-reborn-entrypoint /usr/local/bin/ironclaw-reborn-start-sshd
WORKDIR /workspace
EXPOSE 3000 2222
# The container starts as root; ironclaw-reborn-entrypoint performs the ONLY
# privilege drop (exec gosu ironclaw). Anything that bypasses the entrypoint —
# `docker run --entrypoint`, `docker exec` without `--user`, or a platform
# custom start command — runs as root instead.
USER root
ENTRYPOINT ["ironclaw-reborn-entrypoint"]