docker: a container the autonomous RE agent can be turned loose in

Builds and runs both halves of the project -- Canary as the oracle, Reborn as
the port -- plus the dynamic-RE toolkit. Claude Code runs with
--dangerously-skip-permissions as an unprivileged `agent` user, because that
flag is refused under root.

Capped at half the machine, computed at launch: --cpus nproc/2, --memory half
of MemTotal with --memory-swap equal to it (no swap escape hatch -- a swapping
build thrashes the host, which is the failure the cap exists to prevent), and
build parallelism derived INSIDE the container from available memory rather
than core count, since a full-parallel build of this tree has OOM-killed the
host outright.

Three things the old box got wrong are fixed rather than reproduced: a real
toolchain (so rebuild_canary.sh's hand-relinking is obsolete), numpy and Pillow
(whose absence silently disabled every image oracle and looked like a logic
bug), and a display owned by PID 1 (so Xvfb no longer "dies on its own every
few minutes" -- it was being reaped because nothing owned it).

Verified end to end, not by inspection: the image builds, sylph-doctor is green,
`build-canary` links xenia_canary inside the container, and that binary then
runs -- guest memory and the JIT code cache appear in /dev/shm within 4 s,
1 205 log lines, gmem.py reads guest RAM, pad.py drives the file pad, and
screenshot captures the display.

Five environment defects found and fixed on the way, each of which fails in a
way that points somewhere else entirely:

  * /dev/shm is `noexec` under Docker. Xenia maps its JIT code cache out of an
    shm file, so it died with "Unable to allocate code cache generated code
    storage / Cannot initalize processor" -- which reads as an address-space
    clash, not a mount flag. Now `--tmpfs /dev/shm:rw,exec`.
  * An unknown xenia flag HANGS rather than errors: ParseLaunchArguments calls
    ShowSimpleMessageBox before logging is initialised, and that SDL dialog
    blocks on XIfEvent forever. `--audio` (which the RE notes recommend) is not
    a cvar in this tree; the symptom was a 10x10 window and an empty log.
  * Named volumes come up root-owned unless their mount point exists in the
    image, so the first cmake configure failed on pkgRedirects.
  * Ubuntu 24.04 ships its own uid-1000 account, colliding with the host user.
  * Ubuntu's spirv-opt has no --canonicalize-ids, so the shader step dies ~500
    objects in; the launcher mounts the host's LunarG SDK instead of baking one
    in, which also keeps shader output byte-identical to a host build.

Known limits, stated rather than papered over: on an NVIDIA host without the
NVIDIA Container Toolkit there is no hardware Vulkan (/dev/dri alone does
nothing for NVIDIA), and under lavapipe the emulator runs correctly but was not
observed to reach a rendered frame within a couple of minutes. gdb needs `sudo`
inside the container because the host's yama ptrace_scope outranks SYS_PTRACE.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-08-17 23:05:48 +02:00
parent 22dafe5c8b
commit 64f9d15610
11 changed files with 1000 additions and 0 deletions

142
docker/agent/Dockerfile Normal file
View File

@@ -0,0 +1,142 @@
# Autonomous RE agent container for Project Sylpheed.
#
# Builds and runs BOTH halves of the project — Xenia Canary (C++/CMake/Ninja)
# as the behaviour oracle, and Sylpheed Reborn (Rust/Bevy) as the port — plus
# the dynamic-RE toolkit that drives the emulator and reads its guest memory.
#
# Three things here exist because their absence cost the previous agent real
# hours, and they are load-bearing rather than nice-to-have:
#
# 1. A REAL toolchain. The old box shipped runtime sonames only (libgtk-3.so.0
# but no libgtk-3.so), no cmake/ninja/clang and no libstdc++fs, so a full
# build was impossible and `tools/re-capture/rebuild_canary.sh` had to
# hand-relink object files. With -dev packages present that script is
# obsolete; use `build-canary`.
# 2. numpy and Pillow. Their absence silently disabled entities2.py,
# flight_probe.py and every image oracle, and the failure looked like a
# logic bug rather than a missing package.
# 3. A display that outlives the turn. Xvfb kept dying "on its own every few
# minutes"; it was being reaped because nothing owned it. Here it is a
# child of PID 1 and lives exactly as long as the container.
#
# Clang is pinned to 19 to match the host that produced the checked-in build
# caches (Ubuntu clang 19.1.1).
FROM ubuntu:24.04
ENV DEBIAN_FRONTEND=noninteractive \
LANG=C.UTF-8 \
TZ=Etc/UTC
# ── System packages ──────────────────────────────────────────────────────────
RUN apt-get update && apt-get install -y --no-install-recommends \
# toolchain
build-essential cmake ninja-build pkg-config git curl wget ca-certificates \
clang-19 lld-19 llvm-19 libc++-19-dev libc++abi-19-dev \
# Canary: GTK window, SDL input/audio, Vulkan, compression
libgtk-3-dev libsdl2-dev liblz4-dev libvulkan-dev libx11-xcb-dev \
libxcb1-dev libxrandr-dev libssl-dev libfuse2t64 \
# Shader toolchain: the GPU build shells out to `glslangValidator` and the
# SPIR-V tools to compile xenia's own shaders. Missing them does not fail
# configure — it fails ~500 objects in, as a Python FileNotFoundError.
glslang-tools spirv-tools spirv-headers \
# Vulkan runtime — lavapipe (software) plus the real ICDs for /dev/dri
mesa-vulkan-drivers vulkan-tools libvulkan1 libgl1-mesa-dri libglx-mesa0 \
# Reborn / Bevy: audio, input, windowing
libasound2-dev libudev-dev libwayland-dev libxkbcommon-dev \
libx11-dev libxi-dev libxcursor-dev libxinerama-dev libxext-dev \
# headless display + window manager + the screenshot path
xvfb x11-utils x11-xserver-utils openbox xdotool imagemagick ffmpeg \
# dynamic RE
python3 python3-numpy python3-pil python3-pip \
gdb strace ltrace binutils file xxd ripgrep jq unzip zip p7zip-full \
procps psmisc lsof less nano tini sudo \
&& rm -rf /var/lib/apt/lists/*
# Pin the unversioned tool names to 19 so CMake, and anything that shells out to
# `clang`, agree with what the caches were built by.
RUN for t in clang clang++ lld ld.lld llvm-ar llvm-ranlib llvm-nm clang-cpp; do \
src="/usr/bin/${t}-19"; \
[ -e "$src" ] && update-alternatives --install "/usr/bin/${t}" "$t" "$src" 200 || true; \
done
# duckdb reads the static-analysis database (sylpheed.db); it is not packaged.
# PEP 668 marks the system env externally-managed, and this image has no other
# Python consumer to protect, so installing into it is the honest simple option.
RUN pip3 install --no-cache-dir --break-system-packages duckdb
# ── Node + Claude Code ───────────────────────────────────────────────────────
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& npm install -g @anthropic-ai/claude-code \
&& npm cache clean --force \
&& rm -rf /var/lib/apt/lists/*
# ── The agent user ───────────────────────────────────────────────────────────
# NOT root, and not negotiable: Claude Code refuses --dangerously-skip-permissions
# when it has root privileges. uid/gid 1000 matches the host account so files
# written into the bind-mounted repos keep the right ownership.
ARG AGENT_UID=1000
ARG AGENT_GID=1000
# Ubuntu 24.04 ships its own `ubuntu` account at uid/gid 1000, so the common
# case — matching a host user who is also 1000 — collides with it. Remove the
# stock account first; nothing in this image uses it.
RUN if getent passwd "${AGENT_UID}" >/dev/null; then \
userdel -r "$(getent passwd "${AGENT_UID}" | cut -d: -f1)" 2>/dev/null || true; \
fi; \
if getent group "${AGENT_GID}" >/dev/null; then \
groupdel "$(getent group "${AGENT_GID}" | cut -d: -f1)" 2>/dev/null || true; \
fi; \
groupadd -g "${AGENT_GID}" agent \
&& useradd -m -u "${AGENT_UID}" -g "${AGENT_GID}" -s /bin/bash -d /sylph-home/re agent \
&& mkdir -p /sylph-home/re /work \
&& chown -R "${AGENT_UID}:${AGENT_GID}" /sylph-home \
&& echo 'agent ALL=(ALL) NOPASSWD: ALL' > /etc/sudoers.d/agent
COPY bin/ /usr/local/bin/
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/* /usr/local/bin/entrypoint.sh
USER agent
WORKDIR /work
# ── Rust ─────────────────────────────────────────────────────────────────────
# CARGO_TARGET_DIR deliberately points OUTSIDE the bind-mounted repo: the host
# also builds Reborn, and sharing target/ makes the two invalidate each other's
# incremental state on every switch.
ENV RUSTUP_HOME=/sylph-home/re/.rustup \
CARGO_HOME=/sylph-home/re/.cargo \
CARGO_TARGET_DIR=/sylph-home/re/target-container \
PATH=/sylph-home/re/.cargo/bin:/usr/local/bin:/usr/bin:/bin
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
| sh -s -- -y --default-toolchain stable --profile minimal \
--component clippy --component rustfmt \
&& rustup target add wasm32-unknown-unknown
# trunk serves the Reborn viewer's wasm build; the release binary avoids a
# ten-minute `cargo install`.
RUN curl -fsSL https://github.com/trunk-rs/trunk/releases/download/v0.21.4/trunk-x86_64-unknown-linux-gnu.tar.gz \
| tar -xz -C /sylph-home/re/.cargo/bin trunk
# Create the volume mount points HERE, owned by `agent`. Docker seeds an empty
# named volume from whatever the image has at that path — including ownership —
# but if the path does not exist it creates a root-owned directory instead, and
# the first write fails with something as unhelpful as
# "CMake Error: Unable to (re)create the private pkgRedirects directory".
RUN mkdir -p /sylph-home/re/target-container /sylph-home/re/canary-build /sylph-home/re/.claude
# ── Runtime environment ──────────────────────────────────────────────────────
# DISPLAY :98 and HOME /sylph-home/re are what tools/re-capture/*.sh already
# assume; keeping them means the existing toolkit runs unmodified.
ENV HOME=/sylph-home/re \
DISPLAY=:98 \
SCREEN_GEOMETRY=1280x720x24 \
PROJECT_DIR=/work \
XENIA_PAD_FILE=/tmp/xenia_pad.txt \
XENIA_BUILD_DIR=/sylph-home/re/canary-build \
SDL_AUDIODRIVER=dummy \
LIBGL_ALWAYS_SOFTWARE=1 \
PATH=/work/Syplheed-Reborn/tools/re-capture/bin:/work/Syplheed-Reborn/tools/re-capture:/sylph-home/re/.cargo/bin:/usr/local/bin:/usr/bin:/bin
ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/entrypoint.sh"]
CMD ["bash"]