From e53d687755e7171a686a45b6af2d1117d73642cb Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Fri, 4 Sep 2026 15:20:31 +0200 Subject: [PATCH] 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. --- docker/decoder/entrypoint.sh | 11 ++++++++++- docker/decoder/sylph-decoder | 27 +++++++++++++++++++++++++++ docker/port/entrypoint.sh | 9 ++++++++- docker/port/sylph-port | 14 ++++++++++++++ 4 files changed, 59 insertions(+), 2 deletions(-) diff --git a/docker/decoder/entrypoint.sh b/docker/decoder/entrypoint.sh index dee593f8..94bbcfb9 100755 --- a/docker/decoder/entrypoint.sh +++ b/docker/decoder/entrypoint.sh @@ -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" diff --git a/docker/decoder/sylph-decoder b/docker/decoder/sylph-decoder index e61c7de4..61fa86f2 100755 --- a/docker/decoder/sylph-decoder +++ b/docker/decoder/sylph-decoder @@ -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") diff --git a/docker/port/entrypoint.sh b/docker/port/entrypoint.sh index d74991a6..b5ec652b 100755 --- a/docker/port/entrypoint.sh +++ b/docker/port/entrypoint.sh @@ -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" diff --git a/docker/port/sylph-port b/docker/port/sylph-port index beb274e2..71b4c281 100755 --- a/docker/port/sylph-port +++ b/docker/port/sylph-port @@ -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")