docker: support a long-lived Claude token, and stop the seeding fighting it
Some checks failed
Some checks failed
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.
This commit is contained in:
@@ -156,7 +156,16 @@ mkdir -p /exchange/files 2>/dev/null || true
|
||||
#
|
||||
# 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.
|
||||
if [ -d "$HOME/.claude.seed" ] && \
|
||||
# 🔴 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"
|
||||
|
||||
@@ -185,6 +185,33 @@ docker_args() {
|
||||
# Read-only, and only ever used by `push-work`, which refuses anything but an
|
||||
# auto/* branch and never force-pushes. Without this the agent's work only
|
||||
# exists inside the container and dies with it.
|
||||
# ── Claude auth ──
|
||||
#
|
||||
# 🔴 THE ROTATING OAUTH FILE IS WHY THIS AGENT KEPT PARKING, and a long-lived
|
||||
# token removes the failure by construction rather than recovering from it.
|
||||
#
|
||||
# Measured 2026-09-04: `~/.claude/.credentials.json` holds a REFRESH TOKEN THAT
|
||||
# ROTATES ON USE. Seeding both containers from the host's copy 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** -- it
|
||||
# writes empty strings, keeps the metadata, and parks at "Login expired". The
|
||||
# decoder's file was caught emptied at 13:04:28 with its last work at 13:04:29.
|
||||
# A hollow file passes every "does it exist" check, which is why three separate
|
||||
# diagnoses missed it.
|
||||
#
|
||||
# `claude setup-token` issues a LONG-LIVED token against the same Claude
|
||||
# subscription (not Console/API billing -- `claude auth login` defaults to
|
||||
# `--claudeai`, and `--console` is the billed one). Passed as an environment
|
||||
# variable it cannot be rotated out from under a peer and there is no file for
|
||||
# Claude Code to empty, so both halves of the failure are gone.
|
||||
#
|
||||
# Inert until the file exists: without it the OAuth path below is unchanged.
|
||||
CLAUDETOK="${SYLPH_CLAUDE_TOKEN:-$HOME/.sylph-claude-token}"
|
||||
if [ -f "$CLAUDETOK" ]; then
|
||||
_out+=(-e "CLAUDE_CODE_OAUTH_TOKEN=$(tr -d '[:space:]' < "$CLAUDETOK")")
|
||||
echo "==> auth: long-lived token from $CLAUDETOK (no rotating credential file)" >&2
|
||||
fi
|
||||
|
||||
GITCRED="${SYLPH_GIT_CREDENTIALS:-$HOME/.sylph-git-credentials}"
|
||||
if [ -f "$GITCRED" ]; then
|
||||
_out+=(-v "$GITCRED:/sylph-home/re/.git-credentials.host:ro")
|
||||
|
||||
@@ -38,7 +38,14 @@ echo "[entrypoint] display $DISPLAY ready ($SCREEN_GEOMETRY)"
|
||||
#
|
||||
# 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.
|
||||
if [ -d "$HOME/.claude.seed" ] && \
|
||||
# 🔴 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 collision the token exists to remove: three clients on one
|
||||
# rotating refresh token, and the loser of a rotation race gets its stored tokens
|
||||
# CLEARED to empty strings by Claude Code and parks. Measured 2026-09-04.
|
||||
if [ -n "${CLAUDE_CODE_OAUTH_TOKEN:-}" ]; then
|
||||
echo "[entrypoint] auth: 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"
|
||||
|
||||
@@ -105,6 +105,20 @@ docker_args() {
|
||||
# routinely wrong teaches the reader to ignore the one that is real. Mounting
|
||||
# rw would also silence it, but then the container can clobber the host's
|
||||
# credential file; copying cannot.
|
||||
# ── Claude auth ──
|
||||
# See the decoder's launcher for the full note. Short version: the OAuth
|
||||
# credential file holds a refresh token that ROTATES ON USE, three clients were
|
||||
# seeded from one copy, and the loser of a rotation race has its tokens CLEARED
|
||||
# to empty strings by Claude Code and parks at "Login expired". A long-lived
|
||||
# `claude setup-token` credential passed in the environment has nothing to
|
||||
# rotate and no file to empty. Same subscription, not API billing.
|
||||
# Inert until the file exists.
|
||||
local claudetok="${SYLPH_CLAUDE_TOKEN:-$HOME/.sylph-claude-token}"
|
||||
if [ -f "$claudetok" ]; then
|
||||
_out+=(-e "CLAUDE_CODE_OAUTH_TOKEN=$(tr -d '[:space:]' < "$claudetok")")
|
||||
echo "==> auth: long-lived token from $claudetok (no rotating credential file)" >&2
|
||||
fi
|
||||
|
||||
local gitcred="${SYLPH_GIT_CREDENTIALS:-$HOME/.sylph-git-credentials}"
|
||||
if [ -f "$gitcred" ]; then
|
||||
_out+=(-v "$gitcred:/sylph-home/port/.git-credentials.host:ro")
|
||||
|
||||
Reference in New Issue
Block a user