From 824b150be4039ffafc0b3b0d51a6743dd509d55f Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Sat, 29 Aug 2026 12:52:13 +0200 Subject: [PATCH] containers: fix volume ownership and make the clone guard survive interruption Two bugs, both mine, both found by starting the thing. **Volume mount points must exist AND be owned by the agent before USER agent.** Docker seeds a named volume from whatever the image has at that path, ownership included, and creates a ROOT-OWNED directory when the path is absent. Either way the agent cannot write, and the failure surfaced far from its cause: "clone FAILED", with no permission error anywhere in sight. The port's own Dockerfile already carried a comment explaining this trap, which I then walked into for /work and /exchange. **The clone guard checked for a .git directory, not a usable HEAD.** A clone interrupted partway -- the container was removed while one ran -- leaves a .git with no commits, and a presence check then skips the retry forever and hands the agent an empty repository that looks like a checkout. It now verifies HEAD, and clones via a temp directory so a partial result never lands in /work at all. Also: the port launcher's path defaults still assumed the old repo root, so it mounted no disc; and the stale /reborn notice is gone now that there is one repository. Verified running: both agents cloned c58196b, `share` on PATH from /work/tools, /exchange agent-owned, canary at /canary for the decoder, disc at /disc for the port. --- docker/decoder/Dockerfile | 4 ++-- docker/decoder/entrypoint.sh | 19 +++++++++++++++---- docker/decoder/sylph-decoder | 4 ++-- docker/port/Dockerfile | 4 ++-- docker/port/entrypoint.sh | 22 +++++++++++++++------- docker/port/sylph-port | 9 ++++++--- 6 files changed, 42 insertions(+), 20 deletions(-) diff --git a/docker/decoder/Dockerfile b/docker/decoder/Dockerfile index 4480c4e8..0e4d3db2 100644 --- a/docker/decoder/Dockerfile +++ b/docker/decoder/Dockerfile @@ -92,8 +92,8 @@ RUN if getent passwd "${AGENT_UID}" >/dev/null; then \ 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 \ + && mkdir -p /sylph-home/re /work /exchange \ + && chown -R "${AGENT_UID}:${AGENT_GID}" /sylph-home /work /exchange \ && echo 'agent ALL=(ALL) NOPASSWD: ALL' > /etc/sudoers.d/agent COPY bin/ /usr/local/bin/ diff --git a/docker/decoder/entrypoint.sh b/docker/decoder/entrypoint.sh index ef566023..e0b981d9 100755 --- a/docker/decoder/entrypoint.sh +++ b/docker/decoder/entrypoint.sh @@ -101,12 +101,23 @@ mkdir -p "$HOME/shots" "$HOME/logs" # # Cloned ONCE. Never auto-pulled: pulling under a running agent moves files out # from under whatever it is mid-edit, which is the same class of bug again. -if [ ! -d /work/.git ]; then +if ! git -C /work rev-parse --verify HEAD >/dev/null 2>&1; then + # Checks for a usable HEAD, not merely a .git directory. A clone interrupted + # partway -- the container stopped while it ran, which has happened -- leaves + # a .git with no commits, and a presence check would then skip the retry + # forever and hand the agent an empty repository. echo "[entrypoint] cloning ${SYLPH_REPO_URL:-https://git.mc02.dev/fabi/Sylpheed.git} into /work" - git clone --quiet "${SYLPH_REPO_URL:-https://git.mc02.dev/fabi/Sylpheed.git}" /work || { - echo "[entrypoint] clone FAILED -- the agent has no repository" >&2; } + _tmp=$(mktemp -d) + if git clone --quiet "${SYLPH_REPO_URL:-https://git.mc02.dev/fabi/Sylpheed.git}" "$_tmp/r"; then + find /work -mindepth 1 -maxdepth 1 -exec rm -rf {} + 2>/dev/null || true + mv "$_tmp/r"/.[!.]* "$_tmp/r"/* /work/ 2>/dev/null || true + echo "[entrypoint] /work at $(git -C /work rev-parse --short HEAD) on $(git -C /work rev-parse --abbrev-ref HEAD)" + else + echo "[entrypoint] clone FAILED -- the agent has no repository" >&2 + fi + rm -rf "$_tmp" else - echo "[entrypoint] /work is at $(git -C /work rev-parse --short HEAD 2>/dev/null) on $(git -C /work rev-parse --abbrev-ref HEAD 2>/dev/null)" + echo "[entrypoint] /work at $(git -C /work rev-parse --short HEAD) on $(git -C /work rev-parse --abbrev-ref HEAD)" fi # The shared exchange, for transient files that must not enter git history. diff --git a/docker/decoder/sylph-decoder b/docker/decoder/sylph-decoder index a4b5ed6c..78c6c48a 100755 --- a/docker/decoder/sylph-decoder +++ b/docker/decoder/sylph-decoder @@ -234,9 +234,9 @@ case "${1:-}" in # wakeup cannot end the run. Set SYLPH_LOOP_INTERVAL= (empty) to self-pace. INTERVAL="${SYLPH_LOOP_INTERVAL-45m}" declare -a ARGS; docker_args ARGS - ARGS+=(-e SYLPH_AUTONOMOUS=1 -w "$PROJECT") + ARGS+=(-e SYLPH_AUTONOMOUS=1 -w /work) echo "==> loose | cpus=$CPUS mem=${MEM_GB}g shm=${SHM_GB}g" - echo "==> project: $PROJECT (mounted at its own path, so memory carries over)" + echo "==> repo: own clone in volume sylpheed-decoder-repo -> /work" echo "==> pacing: ${INTERVAL:-self-paced}" docker rm -f "$NAME" >/dev/null 2>&1 || true docker run -d -i -t "${ARGS[@]}" "$IMAGE" "/loop ${INTERVAL:+$INTERVAL }$TASK" >/dev/null diff --git a/docker/port/Dockerfile b/docker/port/Dockerfile index 4cce0756..a828d7aa 100644 --- a/docker/port/Dockerfile +++ b/docker/port/Dockerfile @@ -69,8 +69,8 @@ RUN if getent passwd "${AGENT_UID}" >/dev/null; then \ fi; \ groupadd -g "${AGENT_GID}" agent \ && useradd -m -u "${AGENT_UID}" -g "${AGENT_GID}" -s /bin/bash -d /sylph-home/port agent \ - && mkdir -p /sylph-home/port /work /reborn \ - && chown -R "${AGENT_UID}:${AGENT_GID}" /sylph-home \ + && mkdir -p /sylph-home/port /work /exchange /reborn \ + && chown -R "${AGENT_UID}:${AGENT_GID}" /sylph-home /work /exchange \ && echo 'agent ALL=(ALL) NOPASSWD: ALL' > /etc/sudoers.d/agent COPY bin/ /usr/local/bin/ diff --git a/docker/port/entrypoint.sh b/docker/port/entrypoint.sh index 1bb97ffb..999ef4ed 100755 --- a/docker/port/entrypoint.sh +++ b/docker/port/entrypoint.sh @@ -20,9 +20,6 @@ if ! xdpyinfo -display "$DISPLAY" >/dev/null 2>&1; then fi echo "[entrypoint] display $DISPLAY ready ($SCREEN_GEOMETRY)" -if [ -d /reborn ]; then - echo "[entrypoint] /reborn mounted read-only — HANDOFF.md is the contract" -fi # Seed ~/.claude.json from the host's read-only copy, then stamp onboarding as # complete. Claude Code re-runs its first-run wizard whenever @@ -54,12 +51,23 @@ chmod 600 "$HOME/.claude.json" 2>/dev/null || true # # Cloned ONCE. Never auto-pulled: pulling under a running agent moves files out # from under whatever it is mid-edit, which is the same class of bug again. -if [ ! -d /work/.git ]; then +if ! git -C /work rev-parse --verify HEAD >/dev/null 2>&1; then + # Checks for a usable HEAD, not merely a .git directory. A clone interrupted + # partway -- the container stopped while it ran, which has happened -- leaves + # a .git with no commits, and a presence check would then skip the retry + # forever and hand the agent an empty repository. echo "[entrypoint] cloning ${SYLPH_REPO_URL:-https://git.mc02.dev/fabi/Sylpheed.git} into /work" - git clone --quiet "${SYLPH_REPO_URL:-https://git.mc02.dev/fabi/Sylpheed.git}" /work || { - echo "[entrypoint] clone FAILED -- the agent has no repository" >&2; } + _tmp=$(mktemp -d) + if git clone --quiet "${SYLPH_REPO_URL:-https://git.mc02.dev/fabi/Sylpheed.git}" "$_tmp/r"; then + find /work -mindepth 1 -maxdepth 1 -exec rm -rf {} + 2>/dev/null || true + mv "$_tmp/r"/.[!.]* "$_tmp/r"/* /work/ 2>/dev/null || true + echo "[entrypoint] /work at $(git -C /work rev-parse --short HEAD) on $(git -C /work rev-parse --abbrev-ref HEAD)" + else + echo "[entrypoint] clone FAILED -- the agent has no repository" >&2 + fi + rm -rf "$_tmp" else - echo "[entrypoint] /work is at $(git -C /work rev-parse --short HEAD 2>/dev/null) on $(git -C /work rev-parse --abbrev-ref HEAD 2>/dev/null)" + echo "[entrypoint] /work at $(git -C /work rev-parse --short HEAD) on $(git -C /work rev-parse --abbrev-ref HEAD)" fi # The shared exchange, for transient files that must not enter git history. diff --git a/docker/port/sylph-port b/docker/port/sylph-port index 04f28dd1..3a547df6 100755 --- a/docker/port/sylph-port +++ b/docker/port/sylph-port @@ -32,7 +32,10 @@ set -euo pipefail HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # The repo to mount at /work. Overridable so this script can be run from a # worktree -- a human editing on `main` must not repoint the agent's checkout. -REPO="${SYLPH_PORT_REPO:-$(cd "$HERE/.." && pwd)}" +# The checkout is now a volume the container clones into, so this is only used +# to locate things that live BESIDE the repository -- the disc, chiefly. Three +# levels up from docker/port/ is the workspace root. +WORKSPACE="$(cd "$HERE/../../.." && pwd)" IMAGE="${SYLPH_PORT_IMAGE:-sylpheed-port:latest}" NAME="${SYLPH_PORT_NAME:-sylpheed-port}" @@ -43,7 +46,7 @@ NAME="${SYLPH_PORT_NAME:-sylpheed-port}" CPUS="${SYLPH_PORT_CPUS:-3}" MEM_GB="${SYLPH_PORT_MEM_GB:-4}" -DISC="${SYLPH_DISC:-$(cd "$REPO/../sylph_extract" 2>/dev/null && pwd || true)}" +DISC="${SYLPH_DISC:-$(cd "$WORKSPACE/sylph_extract" 2>/dev/null && pwd || true)}" docker_args() { local _out=( @@ -135,7 +138,7 @@ case "${1:-}" in # wake-up silently ends the loop. INTERVAL="${SYLPH_LOOP_INTERVAL-45m}" echo "==> loose | cpus=$CPUS mem=${MEM_GB}g pacing=${INTERVAL:-self}" - echo "==> repo: $REPO" + echo "==> repo: own clone in volume sylpheed-port-repo -> /work" docker run -d -i -t "${ARGS[@]}" -e SYLPH_AUTONOMOUS=1 -w /work "$IMAGE" \ "/loop ${INTERVAL:+$INTERVAL }$TASK" >/dev/null echo