From 1d1ffc5750c888d616db786f87a3dc418d2f0213 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Fri, 4 Sep 2026 15:37:15 +0200 Subject: [PATCH] docker: stop the wrapper typing into live sessions, and support per-agent logins Both agents stopped, and the decoder diagnosed it itself: "I received '2' and '1' but I don't have a pending question those would answer -- I was in the middle of setting up the /loop cron job." claude-autonomous matched the BARE SUBSTRINGS 'Choose', 'trust' and 'accept' to answer Claude Code's one-time first-run gates. The /loop prompt is echoed into the terminal, and that day's briefs contain 'accepted as-is' and 'least trustworthy' -- so expect matched the agent's OWN INSTRUCTIONS and typed 2\r and 1\r into a running session, which then sat waiting for a human to explain them. The old comment argued a multi-word pattern 'never matches' because the gate text wraps. True of a literal string, false of a whitespace-tolerant regex, which is what these now are: \s+ spans the wrap, and the terminal is 200 columns wide. Measured, old against new, against the real brief text and a real gate: {accept} brief 0 gate 1 (case-sensitive; briefs say 'accepted') {Yes,\s*I\s+accept} brief 0 gate 1 {trust} brief 1 <- the trigger {Do\s+you\s+trust\s+the\s+files} brief 0 Two defences, because one is not enough for something that can type: patterns prose cannot match, and gates skipped ENTIRELY on resume (SYLPH_SKIP_GATES) -- a resumed session cannot show a first-run gate, so there is nothing to answer and everything to lose. Timeout cut 90s -> 25s for the same reason. Also: SYLPH_OWN_LOGIN. Remote Control stopped registering under the long-lived token, and the likely reason is scope -- `claude auth login` requests user:sessions:claude_code and the token's auth status reports no email, org or subscription. A per-agent `claude auth login` restores Remote Control AND avoids the rotation collision, because each agent holds its own grant rather than a copy of one. The flag stops the entrypoint seeding the host's credentials over it. --- docker/decoder/bin/claude-autonomous | 76 +++++++++++++++++++--------- docker/decoder/entrypoint.sh | 12 ++++- docker/decoder/sylph-decoder | 6 +++ docker/port/bin/claude-autonomous | 76 +++++++++++++++++++--------- docker/port/entrypoint.sh | 12 ++++- docker/port/sylph-port | 6 +++ 6 files changed, 140 insertions(+), 48 deletions(-) diff --git a/docker/decoder/bin/claude-autonomous b/docker/decoder/bin/claude-autonomous index 23d36932..4384be67 100755 --- a/docker/decoder/bin/claude-autonomous +++ b/docker/decoder/bin/claude-autonomous @@ -78,32 +78,62 @@ trap { forward TERM } SIGTERM trap { forward INT } SIGINT trap { forward HUP } SIGHUP -expect { - -re {Choose} { - if {!$answered_theme} { set answered_theme 1; send "\r" } - exp_continue - } - -re {trust} { - if {!$answered_trust} { - set answered_trust 1 - send_user "\n\[claude-autonomous] accepting the workspace trust prompt\n" - send "1\r" +# 🔴 THIS BLOCK TYPED INTO A LIVE SESSION, and the single-word patterns were why. +# +# 2026-09-04: both agents stopped, and the decoder said so itself -- +# +# "I received '2' and '1' but I don't have a pending question those would +# answer -- I was in the middle of setting up the /loop cron job." +# +# The patterns were the bare substrings `Choose`, `trust` and `accept`. The +# /loop PROMPT is echoed into the terminal, and that day's brief contained +# "H3, the plate delay, is ACCEPTED" and "Do not choose what jump means". So +# expect matched the agent's own instructions and sent `2\r` and `1\r` into a +# running session, which then sat waiting for a human to explain them. +# +# The original comment argued that a multi-word pattern "never matches" because +# the gate text wraps. That is true of a LITERAL multi-word string and false of a +# whitespace-tolerant regex, which is what these now are: `\s+` spans the wrap. +# The terminal is also 200 columns wide (set above), so these lines rarely wrap +# at all. +# +# Two defences, because one is not enough for something that can type: +# 1. patterns specific enough that ordinary prose cannot match them +# 2. gates are skipped ENTIRELY when resuming -- a resumed session cannot show +# a first-run gate, so there is nothing to answer and everything to lose +if {[info exists env(SYLPH_SKIP_GATES)] && $env(SYLPH_SKIP_GATES) ne "0"} { + send_user "\[claude-autonomous] resuming: first-run gates cannot appear, not watching for them\n" +} else { + # Shorter than the old 90 s. The gates appear immediately or not at all, and + # every extra second is a second in which this can type into a live session. + set timeout 25 + expect { + -re {Choose\s+the\s+text\s+style} { + if {!$answered_theme} { set answered_theme 1; send "\r" } + exp_continue } - exp_continue - } - -re {accept} { - if {!$answered_bypass} { - set answered_bypass 1 - send_user "\n\[claude-autonomous] accepting the Bypass Permissions disclaimer\n" - send "2\r" + -re {Do\s+you\s+trust\s+the\s+files} { + if {!$answered_trust} { + set answered_trust 1 + send_user "\n\[claude-autonomous] accepting the workspace trust prompt\n" + send "1\r" + } + exp_continue } - exp_continue + -re {Yes,\s*I\s+accept} { + if {!$answered_bypass} { + set answered_bypass 1 + send_user "\n\[claude-autonomous] accepting the Bypass Permissions disclaimer\n" + send "2\r" + } + exp_continue + } + timeout { + # No gate appeared. Stop matching so nothing later in the run can be + # answered by accident -- which is exactly what used to happen. + } + eof { exit } } - timeout { - # No new gate for a while: the session is up (or never had one). Stop - # matching so nothing later in the run can be answered by accident. - } - eof { exit } } # Hand the terminal over for the rest of the run. diff --git a/docker/decoder/entrypoint.sh b/docker/decoder/entrypoint.sh index 94bbcfb9..f9d4e39b 100755 --- a/docker/decoder/entrypoint.sh +++ b/docker/decoder/entrypoint.sh @@ -163,7 +163,13 @@ mkdir -p /exchange/files 2>/dev/null || true # 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 +# 🔴 PER-AGENT LOGIN: never seed. Set SYLPH_OWN_LOGIN=1 once this container +# has run `claude auth login` itself. Its grant is its OWN -- copying the +# host's over it re-creates the rotation collision that empties credentials +# and parks the session, which is the whole reason per-agent logins exist. +if [ -n "${SYLPH_OWN_LOGIN:-}" ] && [ "${SYLPH_OWN_LOGIN}" != "0" ]; then + log "auth: this agent has its own login; not seeding from the host" +elif [ -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" ] || \ @@ -278,6 +284,10 @@ but the process that was running when it died is gone. Before anything else: 2026-09-01." log "resuming session ${SYLPH_SESSION%%-*}… with a restart notice" fi + # Tell the gate-answering wrapper to stand down: a resumed session cannot + # show a first-run gate, and on 2026-09-04 its single-word patterns matched + # the /loop prompt itself and typed "2" and "1" into a live session. + [ "$SYLPH_RESUME" = "1" ] && export SYLPH_SKIP_GATES=1 [ "$SYLPH_RESUME" = "1" ] && set -- --resume "$SYLPH_SESSION" "$@" # The flag the user asked for. It is refused under root, which is why this diff --git a/docker/decoder/sylph-decoder b/docker/decoder/sylph-decoder index 61fa86f2..29689f65 100755 --- a/docker/decoder/sylph-decoder +++ b/docker/decoder/sylph-decoder @@ -206,6 +206,12 @@ docker_args() { # 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. + # Pass through: set SYLPH_OWN_LOGIN=1 when this container has run + # `claude auth login` itself, so the entrypoint never copies the host's + # rotating credentials over its own grant. Remote Control needs a real + # login -- the long-lived token does not carry the sessions scope. + [ -n "${SYLPH_OWN_LOGIN:-}" ] && _out+=(-e "SYLPH_OWN_LOGIN=$SYLPH_OWN_LOGIN") + CLAUDETOK="${SYLPH_CLAUDE_TOKEN:-$HOME/.sylph-claude-token}" if [ -f "$CLAUDETOK" ]; then _out+=(-e "CLAUDE_CODE_OAUTH_TOKEN=$(tr -d '[:space:]' < "$CLAUDETOK")") diff --git a/docker/port/bin/claude-autonomous b/docker/port/bin/claude-autonomous index 23d36932..4384be67 100755 --- a/docker/port/bin/claude-autonomous +++ b/docker/port/bin/claude-autonomous @@ -78,32 +78,62 @@ trap { forward TERM } SIGTERM trap { forward INT } SIGINT trap { forward HUP } SIGHUP -expect { - -re {Choose} { - if {!$answered_theme} { set answered_theme 1; send "\r" } - exp_continue - } - -re {trust} { - if {!$answered_trust} { - set answered_trust 1 - send_user "\n\[claude-autonomous] accepting the workspace trust prompt\n" - send "1\r" +# 🔴 THIS BLOCK TYPED INTO A LIVE SESSION, and the single-word patterns were why. +# +# 2026-09-04: both agents stopped, and the decoder said so itself -- +# +# "I received '2' and '1' but I don't have a pending question those would +# answer -- I was in the middle of setting up the /loop cron job." +# +# The patterns were the bare substrings `Choose`, `trust` and `accept`. The +# /loop PROMPT is echoed into the terminal, and that day's brief contained +# "H3, the plate delay, is ACCEPTED" and "Do not choose what jump means". So +# expect matched the agent's own instructions and sent `2\r` and `1\r` into a +# running session, which then sat waiting for a human to explain them. +# +# The original comment argued that a multi-word pattern "never matches" because +# the gate text wraps. That is true of a LITERAL multi-word string and false of a +# whitespace-tolerant regex, which is what these now are: `\s+` spans the wrap. +# The terminal is also 200 columns wide (set above), so these lines rarely wrap +# at all. +# +# Two defences, because one is not enough for something that can type: +# 1. patterns specific enough that ordinary prose cannot match them +# 2. gates are skipped ENTIRELY when resuming -- a resumed session cannot show +# a first-run gate, so there is nothing to answer and everything to lose +if {[info exists env(SYLPH_SKIP_GATES)] && $env(SYLPH_SKIP_GATES) ne "0"} { + send_user "\[claude-autonomous] resuming: first-run gates cannot appear, not watching for them\n" +} else { + # Shorter than the old 90 s. The gates appear immediately or not at all, and + # every extra second is a second in which this can type into a live session. + set timeout 25 + expect { + -re {Choose\s+the\s+text\s+style} { + if {!$answered_theme} { set answered_theme 1; send "\r" } + exp_continue } - exp_continue - } - -re {accept} { - if {!$answered_bypass} { - set answered_bypass 1 - send_user "\n\[claude-autonomous] accepting the Bypass Permissions disclaimer\n" - send "2\r" + -re {Do\s+you\s+trust\s+the\s+files} { + if {!$answered_trust} { + set answered_trust 1 + send_user "\n\[claude-autonomous] accepting the workspace trust prompt\n" + send "1\r" + } + exp_continue } - exp_continue + -re {Yes,\s*I\s+accept} { + if {!$answered_bypass} { + set answered_bypass 1 + send_user "\n\[claude-autonomous] accepting the Bypass Permissions disclaimer\n" + send "2\r" + } + exp_continue + } + timeout { + # No gate appeared. Stop matching so nothing later in the run can be + # answered by accident -- which is exactly what used to happen. + } + eof { exit } } - timeout { - # No new gate for a while: the session is up (or never had one). Stop - # matching so nothing later in the run can be answered by accident. - } - eof { exit } } # Hand the terminal over for the rest of the run. diff --git a/docker/port/entrypoint.sh b/docker/port/entrypoint.sh index b5ec652b..c8c90ba0 100755 --- a/docker/port/entrypoint.sh +++ b/docker/port/entrypoint.sh @@ -43,7 +43,13 @@ echo "[entrypoint] display $DISPLAY ready ($SCREEN_GEOMETRY)" # 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 +# 🔴 PER-AGENT LOGIN: never seed. Set SYLPH_OWN_LOGIN=1 once this container +# has run `claude auth login` itself. Its grant is its OWN -- copying the +# host's over it re-creates the rotation collision that empties credentials +# and parks the session, which is the whole reason per-agent logins exist. +if [ -n "${SYLPH_OWN_LOGIN:-}" ] && [ "${SYLPH_OWN_LOGIN}" != "0" ]; then + echo "[entrypoint] auth: this agent has its own login; not seeding from the host" +elif [ -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" ] || \ @@ -183,6 +189,10 @@ but the process that was running when it died is gone. Before anything else: killed the decoder's run on 2026-09-01." echo "[entrypoint] resuming session ${SYLPH_SESSION%%-*}… with a restart notice" fi + # Tell the gate-answering wrapper to stand down: a resumed session cannot + # show a first-run gate, and on 2026-09-04 its single-word patterns matched + # the /loop prompt itself and typed "2" and "1" into a live session. + [ "$SYLPH_RESUME" = "1" ] && export SYLPH_SKIP_GATES=1 [ "$SYLPH_RESUME" = "1" ] && set -- --resume "$SYLPH_SESSION" "$@" # Remote Control registers the session with the account so the agent can be diff --git a/docker/port/sylph-port b/docker/port/sylph-port index 71b4c281..246c5101 100755 --- a/docker/port/sylph-port +++ b/docker/port/sylph-port @@ -113,6 +113,12 @@ docker_args() { # `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. + # Pass through: set SYLPH_OWN_LOGIN=1 when this container has run + # `claude auth login` itself, so the entrypoint never copies the host's + # rotating credentials over its own grant. Remote Control needs a real + # login -- the long-lived token does not carry the sessions scope. + [ -n "${SYLPH_OWN_LOGIN:-}" ] && _out+=(-e "SYLPH_OWN_LOGIN=$SYLPH_OWN_LOGIN") + local claudetok="${SYLPH_CLAUDE_TOKEN:-$HOME/.sylph-claude-token}" if [ -f "$claudetok" ]; then _out+=(-e "CLAUDE_CODE_OAUTH_TOKEN=$(tr -d '[:space:]' < "$claudetok")")