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.
302 lines
16 KiB
Bash
Executable File
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 "$@"
|