Telegram Gmail Bot — Operations Guide

Service: gmail-session-manager + n8n workflow telegram-gmail-bot Host: bms-4 (54.36.123.110) Issue: p24-infra#4813 · Return-To: radieu/gmail-tools#24 Status: Waves 1-3 deployed (2026-08-03), wave 5 (Telegram bot token) done (2026-08-04). Wave 4 (Google OAuth for gmail-runner) verified still missing 2026-08-04 — no token.json, but the shared OAuth client credential (gcp-oauth.keys.json, client id/secret only — not a user grant) has been staged into /var/lib/gmail-telegram/creds/ from the existing gmail-tools deployment. Wave 6’s n8n workflow (telegram-gmail-bot, id YjtA6n33pWvpReIo) has been built and verified inactive — see “Wave 6 progress” below. Service (gmail-session-manager.service) still not started — blocked on the actual wave-4 OAuth consent grant, which only the account owner can perform interactively.

Not the same thing as google-worker-bot / gmail-worker-bot. That is an earlier, separate prototype running as claude-runner against the shared /opt/gmail-tools clone — see gmail-tools-telegram-bot-operations.md. This service is a from-scratch redesign with its own OS user, port, secret, and a tiered propose-then-confirm permission model, built specifically because this bot’s trigger (Telegram messages routed to Gmail data) sits adjacent to attacker-authored email content. The two share no code, no credentials, no port, and no n8n workflow.


Why a separate design

Every other Telegram bot on bms-4 (telegram-claude-bot, google-worker-bot) either grants a persistent Claude session broad tool access, or shells out with the account owner as the only input source. This bot’s commands can surface third-party email content (senders, subjects, bodies) into a Claude subprocess’s context. The design therefore:

  • Runs as its own unprivileged OS user (gmail-runner) with no path to any other identity’s secrets, SSH keys, or the shared /opt/p24-infra / /opt/gmail-tools checkouts.
  • Splits every command into a tier: T0 (read-only, answered directly), T1 (idempotent, answered directly), or T2 (mutating — proposed, then require an explicit /confirm).
  • For T2 commands, the Claude subprocess that runs during the propose phase has no mutating tool in its allowlist at all — it can only describe what it would do. The actual mutation (canonical_argv, stored verbatim in a pending-action file) is executed by the Python service itself after /confirm, with shell=False and an explicit argv list — the model is never in the loop for the step that has real-world effect.

Architecture

[Telegram user (owner only, allowlisted in n8n)]
         |
         v
[Telegram Bot API] --webhook--> [n8n on bms-4] --HTTP POST /cmd--> [gmail-session-manager :9998]
                                  (X-Session-Key: GMAIL_SESSION_MANAGER_KEY)          |
                                                                                       v
                                                              T0/T1: `claude -p` (read-only
                                                              allowlist) -> direct reply
                                                              T2: `claude -p` (read-only
                                                              allowlist, propose-only) ->
                                                              pending/<chat_id>/<token>.json
                                                                       |
                                                     /confirm <token>  v
                                                              execute canonical_argv directly
                                                              (shell=False, no Claude call)

Components

ComponentLocationPurpose
gmail-session-manager/opt/gmail-session-manager/ on bms-4, systemd, port 9998, as gmail-runnerHTTP backend — /health, /cmd
Sourceinfra-src/gmail-session-manager/ (server.py, gmail-session-manager.service, mcp.json, PROMPT.md)Deployed manually to bms-4 (no CI auto-deploy, same pattern as claude-session-manager)
Dedicated clone/home/gmail-runner/telegram-workdir/gmail-toolsradieu/gmail-tools, cloned by root then chown gmail-runner:gmail-runner (0750), npm ci --omit=dev as gmail-runner
State dir/var/lib/gmail-telegram/{sessions,pending,creds}0700, owner gmail-runner — session history, pending T2 actions, (wave 4) Google creds
OS usergmail-runner (uid 1004)No sudoers entry, no SOPS age key, no SSH keys, no GitHub token, no read access to /opt/p24-infra or /home/claude-runner
n8n workflow telegram-gmail-botbms-4 n8nWave 6 — not yet created. Clones infra-src/telegram-claude-bot/n8n-workflow.json as a starting point, re-pointed at http://172.18.0.1:9998/cmd

Endpoint contract

  • GET /health200 {"status":"ok"}, no auth.
  • POST /cmd → requires X-Session-Key: <GMAIL_SESSION_MANAGER_KEY> (constant-time compare via hmac.compare_digest). Body: {"cmd", "args", "tg_chat_id", "tg_user_id", "tg_message_id"}. Reply: {"reply": "<text for Telegram>"}.
  • No /notify endpoint (that’s a claude-session-manager-only worker-fleet callback; omitted here to shrink attack surface).

Command → skill → tier map

CommandBacking skillTier
/inbox, /inbox_auditinspect-inbox-healthT0
/filterslist-filters.jsT0
/digestreport-inbox-digestT0
/trackersync-gmail-tracker-sheet --statusT0
/tracker_syncsync-gmail-tracker-sheet --dailyT1
/calcalendar-cli.jsT0
/flightssync-flight-bookings-to-calendarT2 — propose, then /confirm
/payrecord-payment-obligationT2 — propose, then /confirm
/trashmutate-gmail-safelyT2 — propose, then /confirm
/confirm <token> / /cancel <token> / /pendingmicroservice-internal

T0/T1 (and the T2 propose phase) run under a fixed allowlist: Read, Glob, Grep, and named Bash(node scripts/...) / Bash(node calendar-cli.js ...) entries — no bare Bash, no Write. Always disallowed: Bash(rm*), Bash(git push*), Bash(gh *), Bash(curl*), WebFetch, WebSearch, Write(*credentials/*), Read(*credentials/*), Read(*.env*), Task. --setting-sources user so the clone’s own (permissive, interactive-dev) .claude/settings.json never applies to the bot. See infra-src/gmail-session-manager/server.py for the exact lists.

Pending T2 action state machine

pending/<chat_id>/<token>.json (mode 0600): token, chat_id, command, tier, canonical_argv, effect_summary, created_at, expires_at (default +300s), state. States: pending -> claimed (atomically flipped under a per-chat lock before execution starts, so a redelivered Telegram webhook can’t double-execute) -> executed | failed | indeterminate. Max 3 pending actions per chat — a 4th is rejected outright, never silently evicting an older one. /trash never trusts exit code 0 alone; the skeleton currently marks it indeterminate pending a real read-back check (not yet wired — see server.py for the TODO).

Deployment history (waves 1-3, 2026-08-03)

Executed via a supervised p24-infra session with real SSH/root access — the documented workaround for the unattended queue worker’s lack of root (see #4813 comment thread). Nothing else on bms-4 (n8n, claude-session-manager, google-worker-bot) was touched.

  • [A] gmail-runner created (useradd -m -s /usr/sbin/nologin, uid 1004, home 0750) — no sudoers entry, no age key, no SSH keys.
  • [B] GMAIL_SESSION_MANAGER_KEY (fresh random secret) added to secrets/n8n-bms4.env.sops.
  • [C] radieu/gmail-tools cloned as root (using the existing repo-write GH_TOKEN from the already-deployed /opt/p24-infra/bms-4/n8n-bms4-gh.env, via gh repo clone — not an embedded-token remote URL, learning from the #3286 exposure pattern) to /home/gmail-runner/telegram-workdir/gmail-tools, then chown -R gmail-runner:gmail-runner (0750). npm ci --omit=dev run as gmail-runner. gmail-runner holds no GitHub token.
  • [D] /var/lib/gmail-telegram/{sessions,pending,creds} created, 0700, owner gmail-runner.
  • [E] gmail-session-manager deployed to /opt/gmail-session-manager/server.py, mcp.json (empty mcpServers), PROMPT.md (untrusted-content warning). Bound to 172.18.0.1:9998 (Docker bridge only, confirmed via docker network inspect bms-4_default).
  • [F] gmail-session-manager.service installed — not enabled, not started (no valid Google credentials until wave 4).
  • [G] iptables -A INPUT -s 172.18.0.0/16 -p tcp -m tcp --dport 9998 -j ACCEPT added, mirroring the existing 9997 rule verbatim in structure.

Verification after [E]-[G]: curl -sf http://localhost:9998/health returns connection-refused (service not started — expected, not a bug). ss -ltnp | grep 9998 shows nothing listening yet. systemctl status gmail-session-manager shows inactive (dead), enabled: no.

Wave 6 progress (2026-08-04)

The telegram-gmail-bot n8n workflow has been built and verified inactive:

  • Workflow id YjtA6n33pWvpReIo, 21 nodes, active: false.
  • New Telegram credential Telegram Bot - Gmail Assistant (id C9Y4XJhOzmEvCEDU), accessToken field bound via env expression ={{ $env.TELEGRAM_GMAIL_BOT_API_KEY }} (n8n accepted the credential-field expression without error — the value itself was never extracted or displayed in this session; it resolves from bms-4’s already-deployed n8n container env).
  • Extract CommandRoute Command (13-way switch, fallbackOutput: unknown) → 13 Exec: <cmd> httpRequest nodes, each POST http://172.18.0.1:9998/cmd with header X-Session-Key: {{ $env.GMAIL_SESSION_MANAGER_KEY }} and body {cmd, args, tg_chat_id, tg_user_id, tg_message_id} matching server.py’s handle_cmd/COMMAND_TIERS exactly (underscore command names — inbox_audit, tracker_sync — not dashed, to match COMMAND_TIERS/INTERNAL_COMMANDS keys verbatim). T0/T1 skill commands use a 310000ms node timeout (≥ the backend’s 300s CLAUDE_TIMEOUT_SECONDS, avoiding the BrokenPipe class of bug documented in telegram-claude-bot-operations.md); the 3 internal commands (confirm/cancel/pending, no Claude subprocess) use 130000ms (≥ EXECUTE_TIMEOUT_SECONDS).
  • Merge ResultsSend Telegram Replyplain text, no Markdown code-fence wrap and no parse_mode (per #4813’s rationale: email subjects break Telegram’s legacy Markdown parser), reading $json.reply (matching server.py’s {"reply": ...} response shape).
  • Auth allowlist: Telegram user id 8155641922 — same id already hardcoded in the existing p24-claude-bot/telegram-claude-bot workflow’s “Check Authorized User” node (verified by reading that workflow’s own n8n-workflow.json, not assumed). No evidence found of a different id intended specifically for this bot.
  • Left inactive deliberately — gmail-session-manager.service has no valid Google credentials yet (wave 4 still open), so activating now would mean every message silently fails.

BLOCKED — wave 4

  • Wave 4 (Google OAuth for gmail-runner) — needs an interactive OAuth consent flow (same shape as gmail-tools’ own auth.js). gmail-runner gets its own grant, not a copy of claude-runner’s existing token — this is an intentional isolation requirement. Verified 2026-08-04: still missing/var/lib/gmail-telegram/creds/token.json does not exist. The shared OAuth client credential (gcp-oauth.keys.json — client id/secret, not a per-user grant) has been staged: copied from /opt/gmail-tools/credentials/gcp-oauth.keys.json (claude-runner’s deployment, sourced from GMAIL_TOOLS_GCP_OAUTH_CLIENT_ID/_SECRET in secrets/gmail-tools.env.sops) to /var/lib/gmail-telegram/creds/gcp-oauth.keys.json, owner gmail-runner:gmail-runner, mode 0600. This does not complete wave 4 — the actual per-user OAuth consent (token.json, containing a refresh_token) still requires the account owner to click through a consent screen interactively; no automated path exists. Once granted, save the token under /var/lib/gmail-telegram/creds/token.json (0600, owner gmail-runner) and add GOOGLE_CREDS_PATH/GOOGLE_TOKEN_PATH to the systemd unit’s EnvironmentFile, then systemctl enable --now gmail-session-manager and activate the telegram-gmail-bot workflow.
  • Wave 5 (Telegram bot token)done (2026-08-04). TELEGRAM_GMAIL_BOT_API_KEY added to secrets/n8n-bms4.env.sops and distributed to bms-4 (see docs/secrets-rotation-log.md 2026-08-04 entry).
  • Wave 6 remaining — once wave 4 lands: start gmail-session-manager.service (curl -sf http://localhost:9998/healthok), activate the already-built telegram-gmail-bot workflow, then verify a T0 command + unauthorized-sender silence + disallowed-tool denial per #4813 task [K].

Incident response

/health returns connection-refused

Expected until wave 4 completes — the service is intentionally not started (no valid Google credentials). Not a bug. Once wave 4/5 land: systemctl enable --now gmail-session-manager then re-check.

Deploying a server.py change

No CI auto-deploy (matches claude-session-manager). On Windows, extract with [System.IO.File]::WriteAllText(...) (not PowerShell >/Out-File — those add CRLF/BOM and silently corrupt the Python source). scp to bms-4, cp into /opt/gmail-session-manager/server.py, chown gmail-runner:gmail-runner, systemctl restart gmail-session-manager (once it’s actually running).