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):
| Claim | Verified? | 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:
- The bridge process (user
claude-runner, perclaude-session-manager.service:User=claude-runner) has a root SSH key at~/.ssh/vps_root_key, andssh -i ~/.ssh/vps_root_key root@localhost whoamireturnsroot. (Issue claims this was confirmed 2026-08-25.) - There is no passwordless
sudoforclaude-runner(sossh root@localhostis the escalation mechanism, notsudo). 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>
- Validate
<account>against the allowlist regex^claude-runner(-2)?$(defaultclaude-runnerif empty). - Kill any stale
auth_<account>tmux session (idempotent restart, matches playbook §line 112). 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"'.- Poll
tmux capture-pane -t auth_<account> -p(up to ~30s) for the OAuth URL, extract it with the exact playbook regexgrep -o 'https://[^ ]*oauth[^ ]*'(claude-oauth-reauth.md:129), and return only that URL to Telegram. - On timeout, kill the session and return a clear, actionable error — never a raw pane dump.
/auth-code <code> (continuation of the same session)
- Validate
<code>against a strict regex (see §4.4) and resolve the target account (see §4.5 — the account is inferred from the single liveauth_*session, not re-typed). tmux send-keys -t auth_<account> '<code>' Enter(via the same root SSH).- 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. 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 importsshlex,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-codecan find it. - Account inference for
/auth-code: to avoid the operator re-typing the account,/auth-codelists liveauth_*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-workerkills any pre-existingauth_<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 +claudeprocess (mirrors the #4002 orphan concern the file already guards against withrun_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-panethe 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.jsonor 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
| File | Change |
|---|---|
infra-src/telegram-claude-bot/claude-session-manager/server.py | Add 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.json | Route 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.py | Unit tests (§7) — module-import style, mock subprocess/run_root, never call real claude/ssh |
docs/telegram-claude-bot-operations.md | Document the two commands, the account allowlist, the human-anchor step, and the security model |
docs/playbooks/claude-oauth-reauth.md | Add 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-refreshcron — 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>/fdapproach (claude-oauth-reauth.md:120, 209-224,reauth-ionos.py). Assh root@<host>multi-host parameter is a clean follow-up once bms-4 is proven; noting it here so therun_root()helper is written host-parameterizable (defaultlocalhost) from day one, but the vps-i1 code path is a separate issue.
6. Regression risks
| Area | Risk | Mitigation |
|---|---|---|
| Existing bridge commands | New elif branches touch shared dispatch | Append-only; no change to existing branches; existing 13 tests must still pass |
| Root-SSH surface | Bridge (as claude-runner) gains a command that runs as root | Single audited run_root() chokepoint; strict account allowlist + code regex + shlex.quote; n8n chat_id gate + X-Session-Key unchanged upstream |
| Orphaned processes | Abandoned claude auth login leaves a root tmux + process | Kill-on-timeout + kill-on-complete; /auth-worker pre-kills stale session |
| Secret leakage | Post-auth pane / verify output could contain token material | URL-only extraction; verify summarized to OK/FAIL; never full pane dump |
| Telegram menu drift | New commands missing from / menu | setMyCommands 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-workerrejects a non-allowlisted account (root,claude-runner-3,foo; rm -rf) → returns usage error, runs no subprocess. -
auth-workerwith a valid account callsrun_rootwith a command containingtmux 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-workertimeout path (mocked pane never yields a URL) → kills the session and returns the timeout error, not a pane dump. -
auth-coderejects codes with shell metacharacters (;,$(...), backtick, space,|). -
auth-codewith a valid code callsrun_rootwithtmux send-keys … Enter, then a verify, thenkill-session. -
auth-codewith zero liveauth_*sessions → “no auth in progress” error. -
auth-codeverify 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_COMMANDScontains both new entries and_menu_command_nameyields 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
- Implement on
plan/6650-…→feat/6650-…(implementation PR references this plan). /review-plancouncil — risk + security + compliance lenses required because this widens the Telegram → root-SSH surface. Min 3 iteration cycles per the plan-issue workflow.- Deploy: the bridge runs from
/opt/claude-session-manager/server.pyon bms-4 (claude-session-manager.service). Deploying the newserver.pyis asys-admin/infra-taskstep (SCP +systemctl restart claude-session-manager+ confirm assumptions §2). Adev-issueworker ships the PR only; it cannot restart the service. - Compliance: no new service/credential/container — no
dev_r_servicesrow 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.