mirror of
https://github.com/nearai/ironclaw.git
synced 2026-09-02 23:56:24 +08:00
* fix(docker): restore Reborn in-worker SSH (#7723) Co-authored-by: Henry Park <16583448+henrypark133@users.noreply.github.com> (cherry picked from commitb0ba342268) * 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 ine12bbae4d2and 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 commita5b8345d21) * 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 ond4cf2411b0(#7723) orab7ac3d87e(#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>
161 lines
6.1 KiB
Docker
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"]
|