Files
Sylpheed/docker/decoder/entrypoint.sh
MechaCat02 e53d687755
Some checks failed
CI / Native — ubuntu-latest (push) Failing after 8m7s
CI / WASM — Web (push) Failing after 6m50s
CI / Formatting (push) Failing after 1m4s
CI / Native — macos-latest (push) Has been cancelled
CI / Native — windows-latest (push) Has been cancelled
docker: support a long-lived Claude token, and stop the seeding fighting it
The rotating OAuth credential file is why the agents kept parking, and a
long-lived token removes the failure by construction instead of recovering from
it after the fact.

MEASURED 2026-09-04. ~/.claude/.credentials.json holds a refresh token that
ROTATES ON USE. Seeding both containers from the host left three clients holding
one token; the first to refresh invalidated the other two, and on the failed
refresh Claude Code CLEARS the stored tokens -- writes empty strings, keeps the
metadata, and parks at "Login expired".

  decoder credentials emptied  13:04:28
  decoder last transcript      13:04:29   <- one second later

The emptying and the park are the same event, which is why it never self-heals:
not a stale token a retry could fix, but no token at all, with no browser in the
container to complete /login. A hollow file passes every "does it exist" check --
508 B healthy against 280 B emptied -- which is how three separate diagnoses
missed it. And recovery re-armed the bug: after re-seeding, host and decoder held
the IDENTICAL refresh token hash.

`claude setup-token` issues a long-lived token against the same Claude
subscription. Checked, not assumed: `claude auth login` defaults to --claudeai
and it is `--console` that means Console/API billing, so this is not the separate
API bill. `CLAUDE_CODE_OAUTH_TOKEN` is recognised by the installed binary.

Passed as an ENVIRONMENT VARIABLE, both halves of the failure are gone: nothing
rotates, so peers cannot invalidate each other, and there is no file for Claude
Code to empty on a failure.

Both launchers read $HOME/.sylph-claude-token if present -- same pattern as
SYLPH_GIT_CREDENTIALS -- and both entrypoints skip OAuth seeding entirely when
the variable is set, because copying the rotating file in would re-create the
exact collision the token exists to remove.

Inert until the file exists. Without it, nothing changes.

Also worth recording for the preflight work: `claude auth status` prints JSON
with loggedIn/authMethod/subscriptionType. That is a far better SessionStart
assertion than checking a file exists, and it would have caught this on the first
iteration rather than the third incident.
2026-09-04 15:20:31 +02:00

302 lines
16 KiB
Bash
Executable File

#!/usr/bin/env bash
# Bring up the headless display, then hand over to the command.
#
# Xvfb and openbox are started HERE, as children of PID 1 (tini), rather than by
# the toolkit scripts. That is the fix for the long-standing "Xvfb and the
# emulator die on their own every few minutes" note: nothing owned those
# processes, so nothing kept them alive, and a run could sit for 300 s in front
# of a visible MAIN MENU reporting "no main menu" because the display had gone.
# A container-lifetime display makes that failure mode impossible.
set -euo pipefail
log() { printf '[entrypoint] %s\n' "$*" >&2; }
DISPLAY="${DISPLAY:-:98}"
GEOM="${SCREEN_GEOMETRY:-1280x720x24}"
export DISPLAY
# ── Display ──────────────────────────────────────────────────────────────────
if ! xdpyinfo -display "$DISPLAY" >/dev/null 2>&1; then
rm -f "/tmp/.X${DISPLAY#:}-lock" "/tmp/.X11-unix/X${DISPLAY#:}" 2>/dev/null || true
# GLX and RANDR are both required: Canary's window is GTK+OpenGL even when the
# graphics backend is Vulkan, and xwininfo-based screen oracles need RANDR.
Xvfb "$DISPLAY" -screen 0 "$GEOM" -ac -nolisten tcp \
+extension GLX +extension RANDR >/tmp/xvfb.log 2>&1 &
for _ in $(seq 1 50); do
xdpyinfo -display "$DISPLAY" >/dev/null 2>&1 && break
sleep 0.2
done
fi
if ! xdpyinfo -display "$DISPLAY" >/dev/null 2>&1; then
log "FATAL: no display on $DISPLAY — see /tmp/xvfb.log"
exit 1
fi
# A window manager is not cosmetic here: without one the emulator window is
# never mapped at a known position, and every pixel oracle reads the wrong rows.
if ! pgrep -x openbox >/dev/null 2>&1; then
openbox >/tmp/openbox.log 2>&1 &
sleep 0.5
fi
log "display $DISPLAY ready ($GEOM)"
# ── Vulkan ───────────────────────────────────────────────────────────────────
# Prefer the real GPU when /dev/dri was passed through; fall back to lavapipe,
# which is slow but correct and needs no host device.
LVP=$(ls /usr/share/vulkan/icd.d/lvp_icd*.json 2>/dev/null | head -1)
if [ "${SYLPH_VULKAN:-auto}" = "sw" ] || [ ! -e /dev/dri/renderD128 ]; then
[ -n "$LVP" ] && export VK_ICD_FILENAMES="$LVP"
else
unset LIBGL_ALWAYS_SOFTWARE
fi
# Report what Vulkan ACTUALLY enumerated, not what we asked for. Announcing
# "host GPU via /dev/dri" because the device node exists is how an agent ends up
# believing it has hardware while running llvmpipe — an NVIDIA card needs the
# NVIDIA Container Toolkit, and /dev/dri alone does nothing for it.
if command -v vulkaninfo >/dev/null 2>&1; then
vkdev=$(vulkaninfo --summary 2>/dev/null | awk -F= '/deviceName/{gsub(/^ +| +$/,"",$2); print $2; exit}')
case "${vkdev:-}" in
"") log "Vulkan: NO DEVICE — vulkaninfo enumerated nothing" ;;
llvmpipe*|lavapipe*) log "Vulkan: $vkdev (SOFTWARE — correct but slow)" ;;
*) log "Vulkan: $vkdev (hardware)" ;;
esac
fi
# ── Build parallelism ────────────────────────────────────────────────────────
# Bounded by MEMORY, not just cores. A full-parallel build of this tree has
# OOM-killed the host outright, and inside a half-the-box container the ceiling
# is lower still. ~1.5 GiB per C++ TU is the rule of thumb that has held.
# nproc reports the HOST's core count: --cpus is a CFS quota, not a mask. Using
# it would oversubscribe a half-the-box container by exactly 2x, so read the
# quota the cgroup actually grants.
cpus=$(nproc)
if [ -r /sys/fs/cgroup/cpu.max ]; then
read -r _q _p < /sys/fs/cgroup/cpu.max || true
if [ "${_q:-max}" != max ] && [ "${_p:-0}" -gt 0 ]; then
cpus=$(( (_q + _p - 1) / _p ))
[ "$cpus" -lt 1 ] && cpus=1
fi
fi
mem_gib=$(awk '/MemAvailable/{printf "%d", $2/1048576}' /proc/meminfo)
# MemAvailable is the HOST's too under cgroup v2; prefer the container's cap.
if [ -r /sys/fs/cgroup/memory.max ]; then
_m=$(cat /sys/fs/cgroup/memory.max)
[ "$_m" != max ] && mem_gib=$(( _m / 1073741824 ))
fi
[ "${mem_gib:-0}" -lt 1 ] && mem_gib=1
# 🔴 THIS CONSTANT WAS WRONG, AND IT COST A RUN. `mem_gib * 2 / 3` assumes
# ~1.5 GB per job. On 2026-09-01 a raw `cargo test --release -p sylpheed-formats`
# ran 4 jobs in a 6 GB container and was OOM-killed mid-task. Release-mode rustc
# on this workspace needs closer to 2 GB, so the divisor is 2, not 3/2.
#
# ⚠️ And the kill reported `OOMKilled: true` with **ExitCode 0**, so it read as a
# clean exit — which is why the restart policy is `unless-stopped` rather than
# `on-failure`.
by_mem=$(( mem_gib / 2 ))
[ "$by_mem" -lt 1 ] && by_mem=1
jobs=$(( cpus < by_mem ? cpus : by_mem ))
# An EXPLICIT cap from the launcher wins. Without this the launcher's
# `-e CARGO_BUILD_JOBS=...` was computed, exported over, and silently discarded
# — the guardrail was set and then removed three lines later.
if [ -n "${CARGO_BUILD_JOBS:-}" ] && [ "${CARGO_BUILD_JOBS}" -ge 1 ] 2>/dev/null; then
jobs="$CARGO_BUILD_JOBS"
_why=" (explicit, from the launcher)"
fi
export SYLPH_JOBS="$jobs" CARGO_BUILD_JOBS="$jobs" CMAKE_BUILD_PARALLEL_LEVEL="$jobs"
log "build parallelism: $jobs${_why:-} (cpus=$cpus, mem=${mem_gib}GiB avail)"
mkdir -p "$HOME/shots" "$HOME/logs"
# ── The repository, cloned into THIS AGENT'S OWN volume ─────────────────────
# Not a bind mount of a human's working tree. That arrangement bit this project
# three times: an agent's `git config --local` captured a human's commits, a
# credential helper leaked a container-only path onto the host, and a `git add
# -A` swept an agent's in-flight files into somebody else's commit. Separate
# checkouts make all three impossible rather than discouraged.
#
# 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 ! 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"
_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 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.
mkdir -p /exchange/files 2>/dev/null || true
# ── Claude Code config ───────────────────────────────────────────────────────
# ── Claude state: this agent's own, seeded once from the host ───────────────
# Isolated per agent. Both working directories are /work, and Claude Code keys
# its per-project state off the working directory -- so a SHARED ~/.claude put
# two independent agents in the same projects/-work/ directory, which undoes the
# point of giving them separate checkouts.
#
# Seeded rather than shared because credentials live in .credentials.json and a
# token refresh must be able to write. Copying once means each agent refreshes
# its own token and neither can corrupt the host's.
# Re-seed whenever the HOST's credentials are newer than ours, not only when
# ours are missing. The missing-only guard meant an expired token could never be
# replaced: the file existed, so the copy was skipped, and restarting the
# container changed nothing. A human re-logging in on the host is exactly the
# recovery path, and it has to reach here.
#
# Newer-wins rather than always-copy, because the container refreshes its own
# token during a run and that copy may legitimately be the fresher one.
# 🔴 A LONG-LIVED TOKEN WINS, AND THE SEEDING MUST NOT FIGHT IT.
#
# With CLAUDE_CODE_OAUTH_TOKEN set, copying the host's rotating credential file
# in would re-create the exact collision the token exists to remove: three
# clients on one rotating refresh token, the losers of a rotation race getting
# their stored tokens CLEARED to empty strings and parking at "Login expired".
# Measured 2026-09-04 -- see the launcher.
if [ -n "${CLAUDE_CODE_OAUTH_TOKEN:-}" ]; then
log "auth: using the long-lived token from the environment; not seeding OAuth"
elif [ -d "$HOME/.claude.seed" ] && \
{ [ ! -s "$HOME/.claude/.credentials.json" ] || \
[ "$HOME/.claude.seed/.credentials.json" -nt "$HOME/.claude/.credentials.json" ]; }; then
mkdir -p "$HOME/.claude"
cp -a "$HOME/.claude.seed/.credentials.json" "$HOME/.claude/" 2>/dev/null || true
for f in settings.json CLAUDE.md; do
[ -e "$HOME/.claude.seed/$f" ] && cp -a "$HOME/.claude.seed/$f" "$HOME/.claude/" 2>/dev/null || true
done
echo "[entrypoint] refreshed ~/.claude credentials from the host"
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
# lastOnboardingVersion differs from the installed version — so a container with
# a newer Claude than the host stops on the theme picker, with no error and no
# log line, and an unattended agent sits there forever.
if [ -f "$HOME/.claude.host.json" ] && [ ! -s "$HOME/.claude.json" ]; then
cp "$HOME/.claude.host.json" "$HOME/.claude.json" 2>/dev/null || true
fi
# Nothing is restored into `~/.claude.json` on purpose. Resuming is done by
# SESSION ID off the transcript instead — see the resume block below for why
# the index is useless for this.
# `credential.helper=store` rewrites this file by rename-over-target, which
# fails with EBUSY on a bind mount -- reported as `fatal: unable to write
# credential store`, while the push itself succeeds. A fatal line that is
# routinely wrong teaches the reader to ignore the one that is real.
if [ -f "$HOME/.git-credentials.host" ]; then
cp "$HOME/.git-credentials.host" "$HOME/.git-credentials" 2>/dev/null || true
chmod 600 "$HOME/.git-credentials" 2>/dev/null || true
fi
CLAUDE_VER=$(claude --version 2>/dev/null | grep -oE '^[0-9][0-9.]*' || echo 0.0.0)
python3 /usr/local/bin/seed-claude-config.py "$HOME/.claude.json" "$CLAUDE_VER" \
"$PWD" "${PROJECT_DIR:-/work}" "$HOME" || true
chmod 600 "$HOME/.claude.json" 2>/dev/null || true
# ── Claude Code ──────────────────────────────────────────────────────────────
if [ "${SYLPH_AUTONOMOUS:-0}" = "1" ]; then
# Drop the image's default CMD first, or `claude` is handed the literal string
# "bash" as its prompt and answers a question nobody asked.
if [ "$#" -eq 1 ] && [ "$1" = "bash" ]; then
set --
fi
# ── Resume across a restart ────────────────────────────────────────────────
#
# The container restarts automatically now, and a restart that opens a BLANK
# session throws away everything the agent knew. That is not hypothetical: on
# 2026-09-01 an OOM kill ended a run mid-task with a 2.7 MB transcript and two
# files uncommitted in the volume.
#
# 🔴 RESUME BY SESSION ID, NOT BY `--continue`. Measured 2026-09-01:
#
# `--continue` resolves through `~/.claude.json`'s per-project `history` and
# `lastSessionId`. Those are written at a GRACEFUL SHUTDOWN — mid-session the
# live file has `history: None`, `lastSessionId: None`. A container that is
# OOM-killed or `docker rm -f`ed never writes them, which is exactly the case
# this feature exists for. So `--continue` answered "No conversation found to
# continue" with 33 MB of perfectly good transcripts in the volume beside it,
# and persisting `.claude.json` did not help because the fields were never
# populated in the first place.
#
# The TRANSCRIPTS are durable and are named by session id, so read the id off
# the newest one for this working directory. Claude Code has not started yet
# at this point, so the newest is the previous run's.
#
# The /loop prompt is still passed, so the loop is RE-ARMED rather than merely
# restored — a resumed conversation with no wake-up scheduled answers once and
# stops, which looks like resuming and is not.
SYLPH_STAMP="$HOME/.claude/.sylph-last-start"
SYLPH_RESUME=0
SYLPH_SESSION=""
SYLPH_PROJ="$HOME/.claude/projects/$(printf '%s' "$PWD" | sed 's#/#-#g')"
if [ -d "$SYLPH_PROJ" ]; then
_newest=$(ls -1t "$SYLPH_PROJ"/*.jsonl 2>/dev/null | head -1)
if [ -n "$_newest" ]; then
SYLPH_SESSION=$(basename "$_newest" .jsonl)
SYLPH_RESUME=1
fi
fi
# 🔴 A POISONED TRANSCRIPT MUST NOT CRASH-LOOP. If the last start was under
# two minutes ago we are already in a restart loop, and continuing back into
# whatever killed us is the one thing guaranteed not to help. Start fresh and
# say so, rather than burning tokens on the same death repeatedly.
if [ "$SYLPH_RESUME" = "1" ] && [ -f "$SYLPH_STAMP" ]; then
_last=$(cat "$SYLPH_STAMP" 2>/dev/null || echo 0)
_now=$(date +%s)
if [ $((_now - _last)) -lt 120 ]; then
SYLPH_RESUME=0
log "restarted <120s after the last start — restart loop suspected;"
log " starting a FRESH session rather than continuing into the same death"
fi
fi
mkdir -p "$HOME/.claude" 2>/dev/null || true
date +%s > "$SYLPH_STAMP" 2>/dev/null || true
if [ "$SYLPH_RESUME" = "1" ] && [ "$#" -eq 1 ]; then
set -- "$1
⚠️ YOU WERE RESTARTED, and this session was resumed — your context is intact,
but the process that was running when it died is gone. Before anything else:
1. \`git -C /work status\`. Whatever you had in progress is still in the tree,
UNCOMMITTED. Commit it and \`push-work\` before starting anything new.
2. Any build, test or capture you had running did NOT finish. Do not read its
absence as a result.
3. The likeliest cause is an OOM kill — this container is capped at 6 GB.
\`CARGO_BUILD_JOBS\` is now set for you in the environment; do not raise it,
and prefer \`build-reborn test\` over a raw \`cargo test --release\`, which
bypasses the wrapper's job cap. That is exactly what killed the run on
2026-09-01."
log "resuming session ${SYLPH_SESSION%%-*}… with a restart notice"
fi
[ "$SYLPH_RESUME" = "1" ] && set -- --resume "$SYLPH_SESSION" "$@"
# The flag the user asked for. It is refused under root, which is why this
# image runs as `agent`.
# Remote Control registers the session with your account so you can chat with
# the agent from claude.ai or your phone — the point of a detached run being
# that you are not sitting in front of it. The name is passed EXPLICITLY: the
# flag's value is optional, so a bare `--remote-control` would swallow the
# /loop prompt that follows as the session name.
if [ "${SYLPH_REMOTE:-1}" != "0" ]; then
set -- --remote-control "${SYLPH_REMOTE_NAME:-sylpheed-agent}" "$@"
log "Remote Control enabled as '${SYLPH_REMOTE_NAME:-sylpheed-agent}'"
fi
# claude-autonomous wraps `claude --dangerously-skip-permissions` in a pty and
# answers the one-time first-run gates. The Bypass Permissions disclaimer in
# particular has no config key that skips it, so unattended it hangs forever.
set -- claude-autonomous "$@"
log "starting Claude Code with --dangerously-skip-permissions in $(pwd)"
fi
exec "$@"