Plan #6650 — Telegram bridge /auth-worker + /auth-code worker re-auth

Issue: radieu/p24-infra#6650 Type: New Feature (design/plan) Target file: infra-src/telegram-claude-bot/claude-session-manager/server.py Status: design — for /review-plan council (risk + security + compliance), then implementation PR.


1. Problem

Re-authenticating a claude-runner / claude-runner-2 OAuth account on a host today is a fully manual, root-terminal procedure: ssh root@<host> → tmux new-session → runuser -l <account> -c 'claude auth login' → scrape the OAuth URL → open it in a browser → paste the redirect code back → verify → clean up. This is exactly Option A / Option B of docs/playbooks/claude-oauth-reauth.md (§lines 105–172, 176–224).

That manual latency is the recurring pain behind **#6613 / #6071 / 5290 (refresh-token race / synthetic-auth fail): when a token dies, the operator has to be at a workstation with an SSH key and a browser to fix it. The only irreducibly human step in that whole procedure is opening the OAuth URL in a browser and clicking Authorize — that is the entire point of an OAuth device flow and must not be automated. Everything around it (start the process, scrape the URL, paste the code, verify, clean up) can be driven from Telegram.

Goal: add two Telegram bridge commands that collapse the manual procedure to: /auth-worker claude-runner-2 → (operator opens the returned URL, authorizes, copies the code) → /auth-code <code> → done. This shortens time-to-reauth; it does not fix the root-cause refresh-token rotation race (that stays tracked on #5290).


2. Feasibility — verified against the code (2026-08-25, this branch)

Confirmed by reading server.py and the surrounding files (not assumed):

ClaimVerified?Evidence
BOT_COMMANDS registry exists as the single source of truth for the / menu✅server.py:39-53; synced via sync_bot_commands() → setMyCommands (:64-88)
handle_cmd(cmd, args, extra) elif-chain dispatcher exists✅server.py:311-550
tmux new-session -d / send-keys / capture-pane patterns already used✅start/send/log handlers, server.py:329-366, 446-450
A process-group-reaping runner (run_pgroup) and a simple run_cmd exist✅server.py:251-304
The manual tmux + URL-scrape + code-inject procedure is already documented✅claude-oauth-reauth.md:107-172 — the new commands wrap those exact steps
A unit-test harness for this file exists✅tests/telegram/test_claude_session_manager.py (13 tests, imports server.py as a module)
An ops doc for this bridge exists✅docs/telegram-claude-bot-operations.md

⚠️ Correction to an assumption in the issue body

The issue states the new commands should be “gated on an authorized Telegram chat_id (like the rest of the bridge)” and implies that gate lives in server.py. It does not. server.py’s only in-process authenticator is the shared X-Session-Key header on /cmd and /notify (server.py:566-571, 613-618). The chat_id allowlist lives in the n8n workflow, in the Check Authorized User → Ignore Unauthorized nodes (n8n-workflow.json:24, 52, 672-677), which run before any request reaches server.py.

Consequence for this plan: the new commands inherit the n8n chat_id gate and the X-Session-Key gate for free, but they must not rely on server.py doing any chat-level auth (it never has). Because these commands escalate to a root-SSH surface, the plan adds a defense-in-depth in-process account allowlist inside server.py regardless (§4.3) — so even a misconfigured n8n router or a leaked X-Session-Key cannot make the bridge run claude auth login as an arbitrary user.

Assumptions still to confirm at implementation time (cannot verify from the repo)

These are host-state facts, not code — the implementer (an infra-task/sys-admin verification step, since a dev-issue worker cannot SSH) must confirm before/while wiring:

  1. The bridge process (user claude-runner, per claude-session-manager.service:User=claude-runner) has a root SSH key at ~/.ssh/vps_root_key, and ssh -i ~/.ssh/vps_root_key root@localhost whoami returns root. (Issue claims this was confirmed 2026-08-25.)
  2. There is no passwordless sudo for claude-runner (so ssh root@localhost is the escalation mechanism, not sudo).
  3. runuser -l claude-runner-2 -c 'claude auth login' works as root on the host (the account exists and has Claude Code installed).

If any of these is false, implementation stops and reports — do not improvise an alternative escalation path.


3. High-level flow (unchanged from the issue, refined)

Two commands, one shared tmux session per account (auth_<account>):

/auth-worker <account>

  1. Validate <account> against the allowlist regex ^claude-runner(-2)?$ (default claude-runner if empty).
  2. Kill any stale auth_<account> tmux session (idempotent restart, matches playbook §line 112).
  3. ssh -i ~/.ssh/vps_root_key root@localhost → tmux new-session -d -s auth_<account> -x 220 -y 50 'runuser -l <account> -c "claude auth login"'.
  4. Poll tmux capture-pane -t auth_<account> -p (up to ~30s) for the OAuth URL, extract it with the exact playbook regex grep -o 'https://[^ ]*oauth[^ ]*' (claude-oauth-reauth.md:129), and return only that URL to Telegram.
  5. On timeout, kill the session and return a clear, actionable error — never a raw pane dump.

/auth-code <code> (continuation of the same session)

  1. Validate <code> against a strict regex (see §4.4) and resolve the target account (see §4.5 — the account is inferred from the single live auth_* session, not re-typed).
  2. tmux send-keys -t auth_<account> '<code>' Enter (via the same root SSH).
  3. Wait ~8s (matches playbook §line 152–153), then verify with runuser -l <account> -c 'claude -p "say ok" --print' and return OK / FAIL only — never the verification stdout verbatim if it could contain anything sensitive; summarize.
  4. tmux kill-session -t auth_<account> to clean up (playbook §line 171).

The browser Authorize step between the two commands is the human anchor and is out of scope.


4. Detailed design

4.1 BOT_COMMANDS additions (server.py:39-53)

{"command": "auth-worker", "description": "Re-auth a claude-runner account: start OAuth, return the login URL (<account>)"},
{"command": "auth-code",   "description": "Finish re-auth: paste the OAuth authorization code (<code>)"},

Telegram menu names auto-convert -→_ via _menu_command_name() (server.py:56-61); the n8n “Extract Command” node normalises _→- back to the canonical hyphenated router form (server.py:33-37). No manual BotFather edit needed — setMyCommands runs at startup.

4.2 handle_cmd branches (server.py, new elif blocks before the else)

Add elif cmd == "auth-worker": and elif cmd == "auth-code":. Both use a new dedicated helper run_root(cmd, timeout) that wraps the root-SSH invocation in one place so the escalation surface is auditable and testable (rather than inlining ssh -i … in each branch).

4.3 Account allowlist (defense-in-depth, in-process)

import re
ALLOWED_AUTH_ACCOUNTS = ("claude-runner", "claude-runner-2")
_ACCOUNT_RE = re.compile(r"^claude-runner(-2)?$")

Reject anything not matching _ACCOUNT_RE before it is interpolated into any shell/SSH command. This is the primary guard against an arbitrary --user, and it holds even if the n8n chat_id gate or X-Session-Key is bypassed.

4.4 Input validation (shell-injection safety — hard requirement)

Both <account> and <code> flow Telegram → n8n → args → a shell=True subprocess (the existing bridge pattern, run_cmd/run_pgroup both use shell=True). They must be validated with a strict allowlist regex and never passed unquoted:

  • account: ^claude-runner(-2)?$ (§4.3).
  • code: ^[A-Za-z0-9_-]{4,120}$ — OAuth authorization codes are URL-safe base64-ish; reject whitespace, quotes, ;, $, backticks, &, |, newlines outright.
  • Additionally wrap every interpolated value in shlex.quote(...) as belt-and-suspenders (the repo already imports shlex, server.py:6), and prefer building the tmux target name from the validated account only.

A value that fails validation returns a usage error and runs nothing.

4.5 Session/state model between the two commands

claude auth login blocks waiting for the pasted code, so the auth_<account> tmux session must persist between /auth-worker and /auth-code. Design decisions:

  • Session naming: auth_<account> (e.g. auth_claude-runner-2). Deterministic, so /auth-code can find it.
  • Account inference for /auth-code: to avoid the operator re-typing the account, /auth-code lists live auth_* tmux sessions; exactly one live session → use it; zero → “no auth in progress, run /auth-worker first”; more than one → require the operator to disambiguate (/auth-code <account> <code>), rejecting an ambiguous paste. (Concurrent re-auth of both accounts is rare but must not silently paste a code into the wrong session.)
  • Timeout / stale cleanup: /auth-worker kills any pre-existing auth_<account> first. Also add a hard cap — if the URL never appears within the poll window, kill the session and report; do not leave an orphaned root-owned tmux + claude process (mirrors the #4002 orphan concern the file already guards against with run_pgroup).

4.6 Secret-safety in output (ABSOLUTE)

  • The device-code URL is a one-time, non-secret value — returning it to Telegram is fine and is the whole point.
  • Never capture-pane the whole scrollback into the reply. After a successful paste the pane can contain post-auth material; the verify step’s stdout must be summarized to OK/FAIL, not echoed. Extract only the URL via the narrow regex on /auth-worker.
  • Never read, echo, or transmit ~/.claude/.credentials.json or any token value.
  • The root SSH key path is referenced by name only; its contents never touch output.

4.7 Timeouts

run_cmd’s default timeout=10 (server.py:251) is too short for the URL poll. The /auth-worker poll loop needs ~30s (playbook uses seq 30); implement it as a bounded poll (capture-pane every 1–2s up to 30s) rather than one long blocking call, so a fast URL returns fast. /auth-code’s verify needs ~15–20s (8s settle + a claude -p round-trip).


5. Scope

Files to change

FileChange
infra-src/telegram-claude-bot/claude-session-manager/server.pyAdd 2 BOT_COMMANDS entries; add run_root() helper; add auth-worker + auth-code handle_cmd branches; add account/code validation + allowlist constants
infra-src/telegram-claude-bot/n8n-workflow.jsonRoute the two new commands through the existing dispatcher (they inherit the Check Authorized User chat_id gate); confirm the “Extract Command” _→- normalisation covers them
tests/telegram/test_claude_session_manager.pyUnit tests (§7) — module-import style, mock subprocess/run_root, never call real claude/ssh
docs/telegram-claude-bot-operations.mdDocument the two commands, the account allowlist, the human-anchor step, and the security model
docs/playbooks/claude-oauth-reauth.mdAdd a new Option 0.5 — Telegram-driven re-auth ahead of Option A/B, cross-linking these commands as the fast path

Out of scope (deliberate)

  • Automating the browser Authorize click (irreducible OAuth human anchor).
  • Durable fix for the refresh-token rotation race — stays on #5290; these commands only shorten re-auth time.
  • Re-adding the claude-token-refresh cron — separate mechanism, explicitly excluded per #4467.
  • vps-i1 support in the first cut. vps-i1 does not use the tmux model — it uses a process//proc/<pid>/fd approach (claude-oauth-reauth.md:120, 209-224, reauth-ionos.py). A ssh root@<host> multi-host parameter is a clean follow-up once bms-4 is proven; noting it here so the run_root() helper is written host-parameterizable (default localhost) from day one, but the vps-i1 code path is a separate issue.

6. Regression risks

AreaRiskMitigation
Existing bridge commandsNew elif branches touch shared dispatchAppend-only; no change to existing branches; existing 13 tests must still pass
Root-SSH surfaceBridge (as claude-runner) gains a command that runs as rootSingle audited run_root() chokepoint; strict account allowlist + code regex + shlex.quote; n8n chat_id gate + X-Session-Key unchanged upstream
Orphaned processesAbandoned claude auth login leaves a root tmux + processKill-on-timeout + kill-on-complete; /auth-worker pre-kills stale session
Secret leakagePost-auth pane / verify output could contain token materialURL-only extraction; verify summarized to OK/FAIL; never full pane dump
Telegram menu driftNew commands missing from / menusetMyCommands regenerates from BOT_COMMANDS at startup (already wired)

DB schema changes: no.


7. Test plan (tests/telegram/test_claude_session_manager.py)

Pure unit tests, matching the existing module-import + monkeypatch style (no real claude/ssh):

  • auth-worker rejects a non-allowlisted account (root, claude-runner-3, foo; rm -rf) → returns usage error, runs no subprocess.
  • auth-worker with a valid account calls run_root with a command containing tmux new-session -d -s auth_<account> and the validated account only.
  • URL extraction returns only the https://…oauth… match from a mocked pane, and the reply contains no other pane text.
  • auth-worker timeout path (mocked pane never yields a URL) → kills the session and returns the timeout error, not a pane dump.
  • auth-code rejects codes with shell metacharacters (;, $(...), backtick, space, |).
  • auth-code with a valid code calls run_root with tmux send-keys … Enter, then a verify, then kill-session.
  • auth-code with zero live auth_* sessions → “no auth in progress” error.
  • auth-code verify output is summarized to OK/FAIL (assert a known token-shaped string in the mocked verify stdout is not present in the returned reply).
  • BOT_COMMANDS contains both new entries and _menu_command_name yields valid ^[a-z0-9_]{1,32}$ menu names for them.

Run: python -m pytest tests/telegram/test_claude_session_manager.py -v.


8. Rollout & review

  1. Implement on plan/6650-… → feat/6650-… (implementation PR references this plan).
  2. /review-plan council — risk + security + compliance lenses required because this widens the Telegram → root-SSH surface. Min 3 iteration cycles per the plan-issue workflow.
  3. Deploy: the bridge runs from /opt/claude-session-manager/server.py on bms-4 (claude-session-manager.service). Deploying the new server.py is a sys-admin/infra-task step (SCP + systemctl restart claude-session-manager + confirm assumptions §2). A dev-issue worker ships the PR only; it cannot restart the service.
  4. Compliance: no new service/credential/container — no dev_r_services row is required. The two commands are new capabilities of an existing, already-registered service; the ops-doc update (§5) is the compliance artifact.

Plan authored by role-scoped worker (dev-coder) for #6650. Role: dev-coder.