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 asclaude-runneragainst the shared/opt/gmail-toolsclone — seegmail-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-toolscheckouts. - 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, withshell=Falseand 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
| Component | Location | Purpose |
|---|---|---|
gmail-session-manager | /opt/gmail-session-manager/ on bms-4, systemd, port 9998, as gmail-runner | HTTP backend — /health, /cmd |
| Source | infra-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-tools | radieu/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 user | gmail-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-bot | bms-4 n8n | Wave 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 /health→200 {"status":"ok"}, no auth.POST /cmd→ requiresX-Session-Key: <GMAIL_SESSION_MANAGER_KEY>(constant-time compare viahmac.compare_digest). Body:{"cmd", "args", "tg_chat_id", "tg_user_id", "tg_message_id"}. Reply:{"reply": "<text for Telegram>"}.- No
/notifyendpoint (that’s aclaude-session-manager-only worker-fleet callback; omitted here to shrink attack surface).
Command → skill → tier map
| Command | Backing skill | Tier |
|---|---|---|
/inbox, /inbox_audit | inspect-inbox-health | T0 |
/filters | list-filters.js | T0 |
/digest | report-inbox-digest | T0 |
/tracker | sync-gmail-tracker-sheet --status | T0 |
/tracker_sync | sync-gmail-tracker-sheet --daily | T1 |
/cal | calendar-cli.js | T0 |
/flights | sync-flight-bookings-to-calendar | T2 — propose, then /confirm |
/pay | record-payment-obligation | T2 — propose, then /confirm |
/trash | mutate-gmail-safely | T2 — propose, then /confirm |
/confirm <token> / /cancel <token> / /pending | microservice-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-runnercreated (useradd -m -s /usr/sbin/nologin, uid 1004, home0750) — no sudoers entry, no age key, no SSH keys. - [B]
GMAIL_SESSION_MANAGER_KEY(fresh random secret) added tosecrets/n8n-bms4.env.sops. - [C]
radieu/gmail-toolscloned asroot(using the existing repo-writeGH_TOKENfrom the already-deployed/opt/p24-infra/bms-4/n8n-bms4-gh.env, viagh repo clone— not an embedded-token remote URL, learning from the #3286 exposure pattern) to/home/gmail-runner/telegram-workdir/gmail-tools, thenchown -R gmail-runner:gmail-runner(0750).npm ci --omit=devrun asgmail-runner.gmail-runnerholds no GitHub token. - [D]
/var/lib/gmail-telegram/{sessions,pending,creds}created, 0700, ownergmail-runner. - [E]
gmail-session-managerdeployed to/opt/gmail-session-manager/—server.py,mcp.json(emptymcpServers),PROMPT.md(untrusted-content warning). Bound to172.18.0.1:9998(Docker bridge only, confirmed viadocker network inspect bms-4_default). - [F]
gmail-session-manager.serviceinstalled — 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 ACCEPTadded, 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(idC9Y4XJhOzmEvCEDU),accessTokenfield 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 Command→Route Command(13-way switch,fallbackOutput: unknown) → 13Exec: <cmd>httpRequestnodes, eachPOST http://172.18.0.1:9998/cmdwith headerX-Session-Key: {{ $env.GMAIL_SESSION_MANAGER_KEY }}and body{cmd, args, tg_chat_id, tg_user_id, tg_message_id}matchingserver.py’shandle_cmd/COMMAND_TIERSexactly (underscore command names —inbox_audit,tracker_sync— not dashed, to matchCOMMAND_TIERS/INTERNAL_COMMANDSkeys verbatim). T0/T1 skill commands use a 310000ms node timeout (≥ the backend’s 300sCLAUDE_TIMEOUT_SECONDS, avoiding the BrokenPipe class of bug documented intelegram-claude-bot-operations.md); the 3 internal commands (confirm/cancel/pending, no Claude subprocess) use 130000ms (≥EXECUTE_TIMEOUT_SECONDS).Merge Results→Send Telegram Reply— plain text, no Markdown code-fence wrap and noparse_mode(per #4813’s rationale: email subjects break Telegram’s legacy Markdown parser), reading$json.reply(matchingserver.py’s{"reply": ...}response shape).- Auth allowlist: Telegram user id
8155641922— same id already hardcoded in the existingp24-claude-bot/telegram-claude-botworkflow’s “Check Authorized User” node (verified by reading that workflow’s ownn8n-workflow.json, not assumed). No evidence found of a different id intended specifically for this bot. - Left inactive deliberately —
gmail-session-manager.servicehas 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 asgmail-tools’ ownauth.js).gmail-runnergets its own grant, not a copy ofclaude-runner’s existing token — this is an intentional isolation requirement. Verified 2026-08-04: still missing —/var/lib/gmail-telegram/creds/token.jsondoes 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 fromGMAIL_TOOLS_GCP_OAUTH_CLIENT_ID/_SECRETinsecrets/gmail-tools.env.sops) to/var/lib/gmail-telegram/creds/gcp-oauth.keys.json, ownergmail-runner:gmail-runner, mode0600. This does not complete wave 4 — the actual per-user OAuth consent (token.json, containing arefresh_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, ownergmail-runner) and addGOOGLE_CREDS_PATH/GOOGLE_TOKEN_PATHto the systemd unit’sEnvironmentFile, thensystemctl enable --now gmail-session-managerand activate thetelegram-gmail-botworkflow. - Wave 5 (Telegram bot token) — done (2026-08-04).
TELEGRAM_GMAIL_BOT_API_KEYadded tosecrets/n8n-bms4.env.sopsand distributed to bms-4 (seedocs/secrets-rotation-log.md2026-08-04 entry). - Wave 6 remaining — once wave 4 lands: start
gmail-session-manager.service(curl -sf http://localhost:9998/health→ok), activate the already-builttelegram-gmail-botworkflow, 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).
Related
- Design + full endpoint contract: p24-infra#4813 (“Task [E] detail”)
- Sibling pattern this borrows the process-group-reaping /
BOT_COMMANDSsync approach from:telegram-claude-bot-operations.md - The other, separate gmail-tools Telegram bot prototype:
gmail-tools-telegram-bot-operations.md - BMS-4 server doc:
docs/servers/p4-ovh-bms-4-ns3101999-operations.md - gmail-tools application-side design:
radieu/gmail-toolsissues 13-16 (merged)