diff --git a/templates/hermes/Dockerfile b/templates/hermes/Dockerfile index a2780d3..61985d0 100644 --- a/templates/hermes/Dockerfile +++ b/templates/hermes/Dockerfile @@ -1,10 +1,49 @@ # Pinned by digest; the upstream tag floats. FROM docker.io/nousresearch/hermes-agent:v2026.8.27@sha256:e0df6adebddf29b91112aefc999d4aaf6846c9eb544faca5672a16a13590ff79 +# nginx fronts the one port the platform routes, so two listeners can share it: the dashboard, and +# the Telegram webhook server the gateway opens when TELEGRAM_WEBHOOK_URL is set. The webhook path +# must not sit behind the dashboard's sign-in: Telegram authenticates each update with its own +# secret token, never with our username and password. See nginx.conf. Upstream is Debian 13, so +# nginx-light comes from apt like every other package in the image. +RUN apt-get update && apt-get install -y --no-install-recommends nginx-light patch \ + && rm -rf /var/lib/apt/lists/* +COPY nginx.conf /etc/nginx/hermes.conf + +# Telegram webhook mode already exists upstream, but on boot it registers the webhook with +# drop_pending_updates=True, on the (wrong) theory that Telegram keeps no queue for webhooks. It +# does keep every delivery it has no 2xx for yet, and on a machine that scales to zero the update +# that woke the machine is in flight while the gateway boots. Dropping pending updates there lost +# the first message after every wake (seen on the PoC). One line: keep them, Telegram redelivers. +COPY telegram-keep-pending-updates.patch /tmp/telegram-keep-pending-updates.patch +RUN patch -p1 -d /opt/hermes --forward --no-backup-if-mismatch < /tmp/telegram-keep-pending-updates.patch \ + && rm /tmp/telegram-keep-pending-updates.patch \ + && python3 -m py_compile /opt/hermes/plugins/platforms/telegram/adapter.py + +# Slack over the inbound Events API. Upstream's Slack adapter speaks Socket Mode only, an outbound +# connection a scale-to-zero platform cannot see; slack_bolt ships aiohttp helpers for the Events +# API, and this patch wires them in behind SLACK_SIGNING_SECRET plus SLACK_EVENTS_URL. This image +# offers no Socket Mode. Applied against the digest-pinned upstream file, so a pin bump that touches +# the adapter fails this step loudly instead of drifting. Candidate for an upstream PR; see +# slack-events-api.patch for the change itself. +COPY slack-events-api.patch /tmp/slack-events-api.patch +RUN patch -p1 -d /opt/hermes --forward --no-backup-if-mismatch < /tmp/slack-events-api.patch \ + && rm /tmp/slack-events-api.patch \ + && python3 -m py_compile /opt/hermes/plugins/platforms/slack/adapter.py + +# The dashboard's Channels page and the Slack plugin manifest describe Slack in Socket Mode terms +# (app-level token required). This image runs Slack over the Events API only, so the same patch +# mechanism rewrites that one card: bot token plus signing secret, the request URL to paste into the +# Slack app spelled out, and no app-level token field. One place to configure Slack, one story. +COPY dashboard-slack-events.patch /tmp/dashboard-slack-events.patch +RUN patch -p1 -d /opt/hermes --forward --no-backup-if-mismatch < /tmp/dashboard-slack-events.patch \ + && rm /tmp/dashboard-slack-events.patch \ + && python3 -m py_compile /opt/hermes/hermes_cli/web_server.py /opt/hermes/hermes_cli/config_defaults.py + # Upstream's main process is the interactive TUI, which exits without a TTY. This # image supplies the main process instead: bootstrap auth, bring up the s6-supervised -# gateway service, then serve the dashboard. Upstream's ENTRYPOINT stays: it routes -# an executable through, and on a deployed machine PID 1 is s6-svscan (a 2.1.x note +# gateway service, then serve the dashboard behind nginx. Upstream's ENTRYPOINT stays: it +# routes an executable through, and on a deployed machine PID 1 is s6-svscan (a 2.1.x note # here claimed Fly skips s6; observed machines say otherwise), so the s6 service # tree the entrypoint relies on is present. COPY entrypoint.sh /entrypoint.sh diff --git a/templates/hermes/README.md b/templates/hermes/README.md index 1213fe4..2b14c2a 100644 --- a/templates/hermes/README.md +++ b/templates/hermes/README.md @@ -9,8 +9,10 @@ This template runs [Hermes Agent](https://github.com/NousResearch/hermes-agent) Hermes is an autonomous agent: you give it a task and it works on it with its own tools rather than answering a single prompt. The template wraps the upstream image with a start command (upstream's default process is an interactive terminal UI that exits without a TTY) and runs the -web dashboard as the main process with the messaging gateway supervised beside it, state on a -persistent volume. +web dashboard behind a small nginx on the service port, with the messaging gateway supervised +beside it, state on a persistent volume. nginx exists so the same port can also publish the +Telegram and Slack webhook endpoints, which is what lets those bots keep working on a machine that +scales to zero (see [Scale to zero](#scale-to-zero)). The dashboard is what the service URL serves. Chat lives there too, so a fresh deploy is usable with no messaging platform configured at all. When you want the agent on Telegram, Slack, Discord, @@ -51,18 +53,32 @@ skip that step), and messaging platforms on the Channels page. | `ADMIN_USERNAME` | yes | Sign-in username for the dashboard. You choose it; it may not contain a colon (HTTP basic auth uses one to separate user from password) or start with a dash. | | `ADMIN_PASSWORD` | yes | Sign-in password for the dashboard. You choose it. | | `OPENROUTER_API_KEY` | no | Key the agent uses for model calls. Get one at , or add it later on the dashboard's API Keys page; chat needs it to answer. | -| `TELEGRAM_BOT_TOKEN` | no | Token for a Telegram bot, created with @BotFather. Leave blank to connect Telegram (or anything else) later from the Channels page. | +| `TELEGRAM_BOT_TOKEN` | no | Token for a Telegram bot, created with @BotFather. Telegram is connected over an inbound webhook that the template registers for you, so it is the one channel that keeps working when the machine scales to zero. Leave blank to connect Telegram (or anything else) later from the Channels page. | | `TELEGRAM_ALLOWED_USERS` | no | Comma-separated **numeric** Telegram user IDs allowed to use the bot. Not @usernames: the adapter compares the user id and never reads the username. Get yours from @userinfobot. | -| `DISCORD_BOT_TOKEN` | no | Token for a Discord bot from the Discord Developer Portal. | +| `DISCORD_BOT_TOKEN` | no | Token for a Discord bot from the Discord Developer Portal. Discord delivers messages over a connection the bot opens, so turn always-on on in the console before connecting it. | | `DISCORD_ALLOWED_USERS` | no | Comma-separated numeric Discord user IDs allowed to use the bot. | -| `SLACK_BOT_TOKEN` | no | Slack bot token (`xoxb-...`). Slack needs `SLACK_APP_TOKEN` too. | -| `SLACK_APP_TOKEN` | no | Slack app-level token (`xapp-...`) for Socket Mode, which needs no public callback URL. | +| `SLACK_BOT_TOKEN` | no | Slack bot token (`xoxb-...`) from OAuth & Permissions. Slack reaches this deployment over its inbound Events API, so the bot works on a scale-to-zero machine; `SLACK_SIGNING_SECRET` goes with it. | +| `SLACK_SIGNING_SECRET` | no | The Slack app's signing secret, from Basic Information > App Credentials. Needed whenever `SLACK_BOT_TOKEN` is set; see [Scale to zero](#scale-to-zero) for the one-time setup in the Slack app. This image does not offer Slack's Socket Mode (no app-level token). | | `SLACK_ALLOWED_USERS` | no | Comma-separated Slack member IDs (e.g. `U01ABC2DEF3`) allowed to use the bot. | | `HERMES_GATEWAY_TOKEN` | generated | Generated 64-character token; you do not set this. | - -Set by the template, not by you: `HERMES_HOME=/data/.hermes` (state on the volume) and -`HERMES_DASHBOARD_PORT=8080`. The entrypoint binds the dashboard and starts the gateway as a -managed daemon the dashboard's System and Channels pages control. +| `TELEGRAM_WEBHOOK_SECRET` | generated | Generated 32-character token Telegram signs each webhook update with; you do not set this, and only the machine and Telegram ever see it. | + +Set by the template, not by you: `HERMES_HOME=/data/.hermes` (state on the volume), +`HERMES_DASHBOARD_PORT=8081` (the dashboard listens on a port the platform does not route, behind +nginx, which owns the service port 8080), the Telegram webhook settings `TELEGRAM_WEBHOOK_URL=/telegram`, +`TELEGRAM_WEBHOOK_PORT=8443`, `TELEGRAM_WEBHOOK_HOST=127.0.0.1`, and the Slack Events API settings +`SLACK_EVENTS_URL=/slack/events`, `SLACK_EVENTS_PORT=8444`, `SLACK_EVENTS_HOST=127.0.0.1`. +The entrypoint checks the nginx config, starts nginx and the dashboard, and starts the gateway as +a managed daemon the dashboard's System and Channels pages control. + +The image applies three small patches to upstream at build time. `slack-events-api.patch`: the +Slack adapter serves Slack's Events API through slack_bolt's own HTTP helpers (upstream speaks +Socket Mode only). `dashboard-slack-events.patch`: the Channels page's Slack card asks for the +signing secret and shows the Request URL instead of asking for a Socket Mode app-level token. +`telegram-keep-pending-updates.patch`: upstream registers the Telegram webhook with +`drop_pending_updates=True` on every boot, and on a machine that scales to zero the message that +woke the machine is still in flight while the gateway boots, so that flag threw away the first +message after every wake; the patch keeps pending updates and Telegram redelivers them. ## After deploy @@ -80,6 +96,43 @@ managed daemon the dashboard's System and Channels pages control. 6. State lives under `/data/.hermes`, so restarts and redeploys keep the agent's memory. Deleting the volume resets it. +## Scale to zero + +The platform decides a machine is idle from the traffic it can see, which is inbound traffic +through its router. Messaging channels differ in which direction their traffic flows, and that +decides whether a channel survives scale to zero: + +- **Telegram: yes.** The template registers a webhook with Telegram at deploy time (the + `TELEGRAM_WEBHOOK_*` settings above), so Telegram pushes each update to the service URL. That is + inbound traffic: it wakes a stopped machine, the gateway handles the update, and the machine can + scale to zero again afterwards. Telegram waits for the answer and retries deliveries, so the + first message after an idle period is answered once the machine is up: a wake takes ten to + twenty seconds and the gateway a few more to start, so expect the reply to that first message + after about half a minute, and later messages within seconds. +- **Slack: yes.** This image runs Slack over the Events API only. Set `SLACK_BOT_TOKEN` and + `SLACK_SIGNING_SECRET` (at deploy time or later on the dashboard's Channels page, which shows the + exact Request URL), then in the Slack app's settings: turn **Socket Mode** off, open **Event + Subscriptions**, enable events, and paste `/slack/events` as the Request URL. Slack + verifies the URL immediately, so open the dashboard first so the machine is awake, and it must + also be awake later when you save scope or event changes. Subscribe to the bot events Hermes + documents (`message.im`, `message.channels`, `message.groups`, `message.mpim`, `app_mention`) and + reinstall the app. From then on Slack pushes each event to the service URL: inbound traffic that + wakes the machine. Slack expects an answer within 3 seconds and a wake takes about 15, so the + first message after an idle period is delivered on Slack's retry about a minute later; messages + while the machine is awake are answered at once. A bot token without the signing secret is + reported as a configuration error on the Channels page rather than silently falling back to + Socket Mode, and a bot that goes quiet after the switch almost always has the Slack app's Socket + Mode toggle still on. +- **Discord: no.** The Discord gateway is a connection the bot opens outward. The router never sees + it, an idle machine is stopped, the connection dies, and a Discord message cannot wake it: the bot + stays silent until someone opens the dashboard. Discord offers no inbound delivery for channel + messages, so Discord needs always-on. + +The template ships with the platform's scale-to-zero default: the machine stops while idle and +wakes on the next dashboard visit, Telegram message or Slack event, and you pay only for the time it +runs. If you connect Discord, turn always-on on in the console first; it costs a small continuous +RAM charge and keeps that connection alive. + ## Links - Documentation: diff --git a/templates/hermes/dashboard-slack-events.patch b/templates/hermes/dashboard-slack-events.patch new file mode 100644 index 0000000..3589804 --- /dev/null +++ b/templates/hermes/dashboard-slack-events.patch @@ -0,0 +1,120 @@ +--- a/hermes_cli/web_server.py ++++ b/hermes_cli/web_server.py +@@ -8895,11 +8895,18 @@ + "required_env": ("DISCORD_BOT_TOKEN",), + }, + "slack": { ++ # InstaCloud template patch: this image runs Slack over the inbound Events API (see ++ # plugins/platforms/slack/adapter.py), so the card asks for the signing secret instead ++ # of the Socket Mode app-level token. + "name": "Slack", +- "description": "Use Hermes from Slack via Socket Mode. Add allowed Slack member IDs so connected bots can respond.", ++ "description": ( ++ "Use Hermes from Slack over the Events API: Slack posts each event to this " ++ "deployment's URL, so the bot keeps working on a machine that scales to zero. " ++ "Add allowed Slack member IDs so connected bots can respond." ++ ), + "docs_url": "https://api.slack.com/apps", +- "env_vars": ("SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", "SLACK_ALLOWED_USERS"), +- "required_env": ("SLACK_BOT_TOKEN", "SLACK_APP_TOKEN"), ++ "env_vars": ("SLACK_BOT_TOKEN", "SLACK_SIGNING_SECRET", "SLACK_ALLOWED_USERS"), ++ "required_env": ("SLACK_BOT_TOKEN", "SLACK_SIGNING_SECRET"), + }, + "mattermost": { + "name": "Mattermost", +@@ -9152,6 +9159,26 @@ + # toggles, Twilio, HASS, Email, etc.). Anything missing from OPTIONAL_ENV_VARS + # falls back here so the UI can still render a friendly label. + _MESSAGING_ENV_FALLBACKS: dict[str, dict[str, Any]] = { ++ # InstaCloud template patch: the Events API credential the Slack card asks for. The help text ++ # spells out the request URL this deployment serves, read from the environment the template ++ # sets, so the operator can paste it into the Slack app without looking anything up. ++ "SLACK_SIGNING_SECRET": { ++ "description": ( ++ "Slack app signing secret, used to verify the events Slack posts to this deployment. " ++ "Basic Information > App Credentials > Signing Secret." ++ ), ++ "prompt": "Slack Signing Secret", ++ "help": ( ++ "In the Slack app: turn Socket Mode OFF, open Event Subscriptions, enable events and " ++ "set the Request URL to " ++ + (os.environ.get("SLACK_EVENTS_URL") or "/slack/events") ++ + " (keep the dashboard open while Slack verifies it), subscribe to the bot events " ++ "message.im, message.channels, message.groups, message.mpim and app_mention, then " ++ "reinstall the app to the workspace." ++ ), ++ "url": "https://api.slack.com/apps", ++ "password": True, ++ }, + "SIGNAL_HTTP_URL": { + "description": "signal-cli REST API base URL, e.g. http://127.0.0.1:8080", + "prompt": "Signal bridge URL", +@@ -9484,6 +9511,14 @@ + + def _messaging_env_info(key: str) -> dict[str, Any]: + info = OPTIONAL_ENV_VARS.get(key) or _MESSAGING_ENV_FALLBACKS.get(key) or {} ++ # InstaCloud template patch: a plugin manifest entry carries no `help`, so fill it (and a ++ # missing url) from the fallback table, which is where the Slack request URL guidance lives. ++ fallback = _MESSAGING_ENV_FALLBACKS.get(key) ++ if fallback and info is not fallback: ++ info = dict(info) ++ for field in ("help", "url", "description"): ++ if not info.get(field) and fallback.get(field): ++ info[field] = fallback[field] + return { + "description": info.get("description", ""), + "prompt": info.get("prompt", key), +--- a/hermes_cli/config_defaults.py ++++ b/hermes_cli/config_defaults.py +@@ -4694,16 +4694,9 @@ + "password": True, + "category": "messaging", + }, +- "SLACK_APP_TOKEN": { +- "description": "Slack app-level token (xapp-) for Socket Mode. Get from Basic Information → " +- "App-Level Tokens. Also ensure Event Subscriptions include: message.im, " +- "message.channels, message.groups, message.mpim, app_mention", +- "prompt": "Slack App Token (xapp-...)", +- "help": "In your Slack app, enable Socket Mode, then create Basic Information > App-Level Tokens with the connections:write scope.", +- "url": "https://api.slack.com/apps", +- "password": True, +- "category": "messaging", +- }, ++ # InstaCloud template patch: SLACK_APP_TOKEN (Socket Mode) is not offered in this image, which ++ # runs Slack over the inbound Events API; the dashboard's Channels page and `hermes gateway ++ # setup` both read this table, so removing it here removes the field from both. + "SLACK_ALLOWED_USERS": { + "description": "Comma-separated Slack member IDs allowed to use Hermes, e.g. U01ABC2DEF3. Without this, Slack may connect but deny messages by default.", + "prompt": "Allowed Slack member IDs", +--- a/plugins/platforms/slack/plugin.yaml ++++ b/plugins/platforms/slack/plugin.yaml +@@ -4,10 +4,12 @@ + version: 1.0.0 + description: > + Slack gateway adapter for Hermes Agent. +- Connects to Slack via slack-bolt in Socket Mode and relays messages +- between Slack channels/DMs and the Hermes agent. Supports slash +- commands, threads, mrkdwn rendering, approval blocks, free-response +- channels, mention gating, and channel skill bindings. ++ Receives Slack events over the Events API (slack-bolt, inbound HTTP) and ++ relays messages between Slack channels/DMs and the Hermes agent. Supports ++ slash commands, threads, mrkdwn rendering, approval blocks, free-response ++ channels, mention gating, and channel skill bindings. InstaCloud template ++ build: Socket Mode is not offered here, so a scale-to-zero machine can be ++ woken by an incoming event. + author: NousResearch + requires_env: + - name: SLACK_BOT_TOKEN +@@ -15,9 +17,9 @@ + prompt: "Slack Bot Token (xoxb-...)" + url: "https://api.slack.com/apps" + password: true +- - name: SLACK_APP_TOKEN +- description: "Slack app-level token for Socket Mode (xapp-..., scope connections:write)" +- prompt: "Slack App Token (xapp-...)" ++ - name: SLACK_SIGNING_SECRET ++ description: "Slack app signing secret (Basic Information > App Credentials), verifies the events Slack posts to this deployment" ++ prompt: "Slack Signing Secret" + url: "https://api.slack.com/apps" + password: true + optional_env: diff --git a/templates/hermes/entrypoint.sh b/templates/hermes/entrypoint.sh index f17675b..6936b50 100755 --- a/templates/hermes/entrypoint.sh +++ b/templates/hermes/entrypoint.sh @@ -52,8 +52,49 @@ if [ ! -f "${HERMES_HOME}/gateway_state.json" ]; then hermes gateway start || echo "entrypoint: gateway start failed; start it from the dashboard's System page" >&2 fi -# The dashboard is the main process of the s6 CMD service: the /api/status healthcheck -# tracks the UI users actually reach, and gateway restarts never touch it. If it dies, -# s6 reruns this script, which is idempotent. +# Telegram arrives over an INBOUND webhook when the manifest's TELEGRAM_WEBHOOK_URL is in the +# environment (the gateway registers the URL with Telegram itself on connect). The gateway's +# webhook server listens on loopback and nginx publishes it at /telegram on the routed port, next +# to the dashboard. An inbound update is traffic the platform can see and wake a machine for; the +# long poll upstream defaults to is not. Slack gets the same treatment: the adapter patched in the +# Dockerfile serves Slack's Events API on loopback and nginx publishes it at /slack/events (this +# image offers no Socket Mode). The Discord gateway is still an outbound connection, so a Discord +# deployment has to turn always-on on in the console: see the README's scale-to-zero section. +# Nothing here depends on a channel token being set: without one the adapter never starts and +# these variables are inert. + +# Upstream's s6 runs this script as the unprivileged hermes user, so nginx gets its pid file and +# temp directories under /tmp (nginx.conf points there); /run and /var/lib/nginx are root's. +mkdir -p /tmp/hermes-nginx + +# Checked before anything starts, so a config nginx will not load fails the boot with nginx's own +# message instead of leaving the dashboard up behind a dead port. +nginx -t -c /etc/nginx/hermes.conf + +# Both processes are children of this script, which is the s6 CMD service's main process. Either +# dying is fatal: s6 then reruns this script, which is idempotent (the config writes above use +# --force and the gateway seed checks its own marker). Backgrounding nginx under an exec'd dashboard +# would instead leave a dead nginx unnoticed behind a healthy-looking container. # --skip-build serves the dist baked into the image instead of running npm at boot. -exec hermes dashboard --host 0.0.0.0 --port "${HERMES_DASHBOARD_PORT:-8080}" --no-open --skip-build +nginx -c /etc/nginx/hermes.conf -g 'daemon off;' & +nginx_pid=$! +# 0.0.0.0, NOT 127.0.0.1, even though only nginx on this machine ever connects: upstream treats a +# loopback bind as a trusted local operator and switches its sign-in gate OFF (auth_required=false, +# the SPA served to anyone), which behind a public reverse proxy is an open dashboard. A +# non-loopback bind keeps the gate on, exactly as 2.3.x had it. The port is not one the platform +# routes, so nothing but nginx reaches it anyway. +hermes dashboard --host 0.0.0.0 --port "${HERMES_DASHBOARD_PORT:-8081}" --no-open --skip-build & +dashboard_pid=$! + +# A signal is an orderly stop (s6 sends TERM on stop and on the platform's suspend), so only a +# child dying on its own is a failure. +stopping="" +trap 'stopping=1; kill -TERM "$nginx_pid" "$dashboard_pid" 2>/dev/null || true' TERM INT +wait -n || true +if [[ -n "$stopping" ]]; then + wait || true + exit 0 +fi +echo "entrypoint: nginx or the dashboard exited on its own, restarting the service" >&2 +kill -TERM "$nginx_pid" "$dashboard_pid" 2>/dev/null || true +exit 1 diff --git a/templates/hermes/insta.template.yaml b/templates/hermes/insta.template.yaml index b40581f..2a4ca33 100644 --- a/templates/hermes/insta.template.yaml +++ b/templates/hermes/insta.template.yaml @@ -1,5 +1,5 @@ code: hermes -version: 2.3.1 +version: 2.4.0 maintainer: official sourceRepo: InsForge/insta-oss @@ -12,23 +12,50 @@ upstream: # Declare once, reference many: one generated value can be reused across services and vars generated: gateway_token: secret:64 + # Telegram signs every webhook update with this; the gateway refuses to start webhook mode + # without one. Only the machine and Telegram ever see it, so a minted value is the right kind. + telegram_webhook_secret: secret:32 services: hermes: type: web - image: ghcr.io/insforge/insta-oss/templates/hermes:2.3.1 # built from ./Dockerfile by templates-build-images.yml + image: ghcr.io/insforge/insta-oss/templates/hermes:2.4.0 # built from ./Dockerfile by templates-build-images.yml port: 8080 healthcheck: /api/status - # Channels arrive on OUTBOUND connections (Telegram long poll, Slack Socket Mode, ...), - # so an idle machine stops and nothing can wake it. - alwaysOn: true + # No alwaysOn since 2.4.0: the dashboard is reached INBOUND, Telegram arrives on an INBOUND + # webhook (TELEGRAM_WEBHOOK_URL below) and so does Slack (SLACK_EVENTS_URL below; this image + # runs Slack over the Events API only, Socket Mode is not offered), all traffic the platform's + # router can see and wake a stopped machine for, so the platform's scale-to-zero default + # applies. Discord (gateway) is an OUTBOUND connection the router never sees: on a + # scaled-to-zero machine it drops when the machine stops and cannot wake it, so a Discord + # deployment must turn always-on on from the console. The README's scale-to-zero section and + # the variable descriptions below say so. volume: true env: fixed: HERMES_HOME: /data/.hermes - HERMES_DASHBOARD_PORT: "8080" + # The dashboard listens on 8081, a port the platform does not route, behind nginx, which + # owns the routed port 8080 and publishes the Telegram and Slack webhook paths beside it. + # See nginx.conf and the bind note in entrypoint.sh. + HERMES_DASHBOARD_PORT: "8081" + # The gateway registers this URL with Telegram on connect and serves it on the loopback + # port below; nginx forwards /telegram there. Inert until a Telegram bot token is set, + # whether at deploy time or later from the dashboard's Channels page. + TELEGRAM_WEBHOOK_URL: ${services.hermes.url}/telegram + TELEGRAM_WEBHOOK_PORT: "8443" + TELEGRAM_WEBHOOK_HOST: 127.0.0.1 + # Slack's Events API request URL, served by the patched adapter (see the Dockerfile) on the + # loopback port below; nginx forwards /slack/events there. With this set, a Slack bot token + # without SLACK_SIGNING_SECRET is a configuration error the gateway reports by name instead + # of silently falling back to Socket Mode. Unlike Telegram, Slack does not learn this URL + # from the bot: the operator pastes it into the Slack app's Event Subscriptions page, where + # Slack verifies it on the spot (the README and the dashboard's Channels page walk through it). + SLACK_EVENTS_URL: ${services.hermes.url}/slack/events + SLACK_EVENTS_PORT: "8444" + SLACK_EVENTS_HOST: 127.0.0.1 generated: HERMES_GATEWAY_TOKEN: ${gateway_token} + TELEGRAM_WEBHOOK_SECRET: ${telegram_webhook_secret} required: # Neither a `default:` nor a `generate:`, matching the other agent UIs: the operator types # both or does not deploy. A generated password would be one NOBODY can read back, since a @@ -45,18 +72,18 @@ services: OPENROUTER_API_KEY: description: "OpenRouter API key the agent uses for model calls (openrouter.ai/keys). Leave blank to add it later on the dashboard's API Keys page; chat starts answering once a provider key is set" TELEGRAM_BOT_TOKEN: - description: "Telegram bot token, created with @BotFather. Leave blank to connect channels later from the dashboard's Channels page" + description: "Telegram bot token, created with @BotFather. Telegram connects over an inbound webhook, so this channel keeps working on a scale-to-zero machine. Leave blank to connect channels later from the dashboard's Channels page" TELEGRAM_ALLOWED_USERS: # The adapter compares user ids and never reads usernames; the old wording silently locked owners out. description: "Comma-separated numeric Telegram user IDs allowed to use the bot (not @usernames; get yours from @userinfobot)" DISCORD_BOT_TOKEN: - description: "Discord bot token from the Discord Developer Portal. Leave blank to connect channels later from the dashboard" + description: "Discord bot token from the Discord Developer Portal. Discord needs an always-on machine (it delivers messages over a connection the bot opens). Leave blank to connect channels later from the dashboard" DISCORD_ALLOWED_USERS: description: "Comma-separated numeric Discord user IDs allowed to use the bot" SLACK_BOT_TOKEN: - description: "Slack bot token (xoxb-...). Slack needs SLACK_APP_TOKEN too; both can also be set later from the dashboard" - SLACK_APP_TOKEN: - description: "Slack app-level token (xapp-...) for Socket Mode; pairs with SLACK_BOT_TOKEN" + description: "Slack bot token (xoxb-...) from OAuth & Permissions. Slack events reach this deployment over Slack's inbound Events API, so the bot keeps working on a scale-to-zero machine; SLACK_SIGNING_SECRET is needed with it" + SLACK_SIGNING_SECRET: + description: "Slack app signing secret (Basic Information > App Credentials). After deploy, point the app's Event Subscriptions request URL at /slack/events and turn Socket Mode off in the Slack app; the dashboard's Channels page shows the exact URL" SLACK_ALLOWED_USERS: description: "Comma-separated Slack member IDs allowed to use the bot (e.g. U01ABC2DEF3)" diff --git a/templates/hermes/nginx.conf b/templates/hermes/nginx.conf new file mode 100644 index 0000000..2bbf5c7 --- /dev/null +++ b/templates/hermes/nginx.conf @@ -0,0 +1,93 @@ +# The one listener the platform routes (8080), shared by two upstreams: +# /telegram the gateway's Telegram webhook server (127.0.0.1:8443). Telegram reaches it +# with its own secret token, so it must never sit behind the dashboard's sign-in. +# An inbound update is also what wakes a scaled-to-zero machine: the platform +# counts traffic through the router, and a long poll the gateway opened itself +# is invisible to it. +# /slack/events the gateway's Slack Events API listener (127.0.0.1:8444), same reasoning: +# Slack signs each request with the app's signing secret, and an inbound event +# wakes the machine. Only active when SLACK_SIGNING_SECRET is set (see the +# Dockerfile's patch); otherwise nothing listens there and the route answers 502. +# everything else the dashboard on 8081 (bound 0.0.0.0 on purpose: a loopback bind makes +# upstream drop its sign-in gate; see entrypoint.sh), which keeps its own auth. +# Upstream's s6 runs the CMD service as the unprivileged hermes user, so nothing here may touch +# /run or /var/lib/nginx: the pid file and every temp path live under /tmp/hermes-nginx, which the +# entrypoint creates. No `user` directive either: nginx refuses it when the master is not root. +worker_processes 1; +error_log stderr warn; +pid /tmp/hermes-nginx/nginx.pid; + +events { + worker_connections 512; +} + +http { + access_log off; + server_tokens off; + default_type application/octet-stream; + include /etc/nginx/mime.types; + + # All five are set, not just the ones this config uses: nginx creates every temp directory at + # startup and exits if it cannot, so an unset one fails the boot rather than the request that + # would have needed it. + client_body_temp_path /tmp/hermes-nginx/body; + proxy_temp_path /tmp/hermes-nginx/proxy; + fastcgi_temp_path /tmp/hermes-nginx/fastcgi; + scgi_temp_path /tmp/hermes-nginx/scgi; + uwsgi_temp_path /tmp/hermes-nginx/uwsgi; + + # The dashboard's chat, terminal and event streams all upgrade to WebSocket. + map $http_upgrade $connection_upgrade { + default upgrade; + '' close; + } + + # The edge terminates TLS and speaks plain HTTP here, so $scheme is always http. Forward the + # scheme the browser used when the edge says so; under a plain docker run there is no edge and + # $scheme is the right answer. + map $http_x_forwarded_proto $forwarded_proto { + default $http_x_forwarded_proto; + '' $scheme; + } + + server { + listen 0.0.0.0:8080; + + # Files handed to the agent from the dashboard, and media Telegram forwards. + client_max_body_size 64m; + + # Exact match: the gateway serves this one path on that port and nothing else. + location = /telegram { + proxy_pass http://127.0.0.1:8443; + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $forwarded_proto; + } + + # Exact match again: Slack posts events, interactive payloads and the url_verification + # challenge to this one URL. + location = /slack/events { + proxy_pass http://127.0.0.1:8444; + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $forwarded_proto; + } + + location / { + proxy_pass http://127.0.0.1:8081; + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $forwarded_proto; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + # An idle chat or terminal socket carries nothing for minutes at a time; the default + # 60s read timeout would cut it mid-session. + proxy_read_timeout 1h; + proxy_send_timeout 1h; + proxy_buffering off; + } + } +} diff --git a/templates/hermes/slack-events-api.patch b/templates/hermes/slack-events-api.patch new file mode 100644 index 0000000..ce7db1c --- /dev/null +++ b/templates/hermes/slack-events-api.patch @@ -0,0 +1,250 @@ +--- a/plugins/platforms/slack/adapter.py ++++ b/plugins/platforms/slack/adapter.py +@@ -34,6 +34,17 @@ + AsyncSocketModeHandler = Any + AsyncWebClient = Any + ++# InstaCloud template patch: slack_bolt's aiohttp helpers for the inbound Events API transport ++# (see SlackAdapter._start_events_server). Its own try block on purpose: a missing piece here must ++# disable only that transport, never Socket Mode above. ++try: ++ from slack_bolt.adapter.aiohttp import to_bolt_request as _slack_to_bolt_request ++ from slack_bolt.adapter.aiohttp import to_aiohttp_response as _slack_to_aiohttp_response ++ ++ SLACK_EVENTS_HTTP_AVAILABLE = True ++except ImportError: ++ SLACK_EVENTS_HTTP_AVAILABLE = False ++ + import sys + from pathlib import Path as _Path + +@@ -1163,6 +1174,15 @@ + # user messages without bot_id/subtype=bot_message markers. + self._user_is_bot_cache: Dict[Tuple[str, str], bool] = {} + self._socket_mode_task: Optional[asyncio.Task] = None ++ # Events API transport (InstaCloud template patch). Set from ++ # SLACK_SIGNING_SECRET plus SLACK_EVENTS_URL in connect(): Slack then ++ # POSTs events to an inbound aiohttp listener instead of the bot ++ # holding a Socket Mode connection open, which is traffic a ++ # scale-to-zero platform can see and wake a stopped machine for. ++ # None means Socket Mode, upstream's default. ++ self._events_url: Optional[str] = None ++ self._signing_secret: Optional[str] = None ++ self._events_runner: Optional[Any] = None + # Multi-workspace support + self._team_clients: Dict[str, Any] = {} # team_id → WebClient + self._team_bot_user_ids: Dict[str, str] = {} # team_id → bot_user_id +@@ -1468,6 +1488,20 @@ + through that would race a moving target. See + slackapi/python-slack-sdk#1913. + """ ++ # Events API mode: stop the inbound listener instead. Same call sites ++ # (reconnect, disconnect, failed start), so one place covers both. ++ runner = self._events_runner ++ self._events_runner = None ++ if runner is not None: ++ try: ++ await runner.cleanup() ++ except Exception as e: # pragma: no cover - defensive logging ++ logger.warning( ++ "[Slack] Error while stopping the Events API listener: %s", ++ e, ++ exc_info=True, ++ ) ++ + handler = self._handler + task = self._socket_mode_task + self._handler = None +@@ -1487,6 +1521,68 @@ + e, + exc_info=True, + ) ++ ++ async def _start_events_server(self) -> None: ++ """Serve Slack's Events API over an inbound HTTP listener. ++ ++ InstaCloud template patch. Slack POSTs each event to SLACK_EVENTS_URL, ++ which the reverse proxy in front of the container forwards to this ++ listener. Inbound traffic is what a scale-to-zero platform can see and ++ wake a stopped machine for; the outbound Socket Mode connection ++ upstream defaults to is invisible to it. Bolt's request handler ++ verifies every request against SLACK_SIGNING_SECRET, answers Slack's ++ url_verification challenge itself, and acknowledges an event before ++ the listeners run, so Slack's 3 second deadline holds while the ++ machine is awake. A retry Slack sends because its first attempt found ++ the machine asleep is a duplicate message event, which ++ _handle_slack_message already drops by message ts (self._dedup). ++ """ ++ if not self._app or not self._events_url or not self._signing_secret: ++ raise RuntimeError( ++ "Events API mode requires an initialized app, SLACK_EVENTS_URL " ++ "and SLACK_SIGNING_SECRET" ++ ) ++ if not SLACK_EVENTS_HTTP_AVAILABLE: ++ raise RuntimeError( ++ "slack_bolt's aiohttp adapter is not importable; Events API mode unavailable" ++ ) ++ from urllib.parse import urlparse ++ from aiohttp import web ++ ++ path = urlparse(self._events_url).path or "/slack/events" ++ host = (os.getenv("SLACK_EVENTS_HOST") or "127.0.0.1").strip() ++ port = int((os.getenv("SLACK_EVENTS_PORT") or "8444").strip()) ++ ++ bolt_app = self._app ++ ++ async def _events(request: web.Request) -> web.StreamResponse: ++ # The same dispatch slack_bolt's own aiohttp examples use: signature ++ # verification, url_verification and the ack all happen inside it. ++ # Both helpers are coroutines in current slack_bolt and plain ++ # functions in older releases; await whichever comes back. ++ bolt_req = _slack_to_bolt_request(request) ++ if hasattr(bolt_req, "__await__"): ++ bolt_req = await bolt_req ++ bolt_resp = await bolt_app.async_dispatch(bolt_req) ++ response = _slack_to_aiohttp_response(bolt_resp) ++ if hasattr(response, "__await__"): ++ response = await response ++ return response ++ ++ app = web.Application() ++ app.router.add_post(path, _events) ++ runner = web.AppRunner(app, access_log=None) ++ await runner.setup() ++ site = web.TCPSite(runner, host, port) ++ await site.start() ++ self._events_runner = runner ++ logger.info( ++ "[Slack] Events API listener on http://%s:%d%s, public URL %s", ++ host, ++ port, ++ path, ++ self._events_url, ++ ) + + async def _socket_transport_connected(self) -> Optional[bool]: + """Best-effort check of current Socket Mode transport state.""" +@@ -1618,6 +1714,9 @@ + self._ensure_socket_watchdog() + + def _ensure_socket_watchdog(self) -> None: ++ if self._events_url: ++ # Events API mode has no Socket Mode transport to watch. ++ return + if self._socket_watchdog_task is None or self._socket_watchdog_task.done(): + task = asyncio.create_task(self._socket_watchdog_loop()) + self._socket_watchdog_task = task +@@ -2039,6 +2138,29 @@ + app_token = get_secret("SLACK_APP_TOKEN") + except UnscopedSecretError: + app_token = os.getenv("SLACK_APP_TOKEN") ++ ++ # InstaCloud template patch: a signing secret plus a public events URL ++ # selects the inbound Events API transport, where the app-level token ++ # (Socket Mode only) is not needed. See _start_events_server. ++ try: ++ signing_secret = get_secret("SLACK_SIGNING_SECRET") ++ except UnscopedSecretError: ++ signing_secret = os.getenv("SLACK_SIGNING_SECRET") ++ events_url = (os.getenv("SLACK_EVENTS_URL") or "").strip() ++ if signing_secret and events_url: ++ self._events_url = events_url ++ self._signing_secret = str(signing_secret).strip() ++ if app_token: ++ logger.warning( ++ "[Slack] Both SLACK_SIGNING_SECRET and SLACK_APP_TOKEN are set: " ++ "using the Events API at %s and ignoring the app token. Turn " ++ "Socket Mode OFF in the Slack app, or Slack keeps delivering " ++ "events over Socket Mode and nothing reaches this URL.", ++ events_url, ++ ) ++ else: ++ self._events_url = None ++ self._signing_secret = None + + if not raw_token: + logger.error( +@@ -2055,8 +2177,32 @@ + retryable=False, + ) + return False +- if not app_token: ++ if events_url and not signing_secret: ++ # InstaCloud template patch: with a public events URL configured this ++ # deployment receives Slack events inbound, so a bot token without the ++ # signing secret is a configuration error to report by name, not a ++ # reason to fall back to Socket Mode (an outbound connection a ++ # scale-to-zero machine cannot keep alive). + logger.error( ++ "[Slack] SLACK_SIGNING_SECRET not set — this deployment receives " ++ "Slack events over the Events API at %s and needs the app's signing " ++ "secret to verify them. Paste it from Basic Information > App " ++ "Credentials, set the Slack app's Event Subscriptions request URL " ++ "to that address, turn Socket Mode off, then restart the gateway.", ++ events_url, ++ ) ++ self._set_fatal_error( ++ "missing_slack_signing_secret", ++ "SLACK_SIGNING_SECRET not configured. This deployment receives Slack " ++ f"events over the Events API at {events_url}: paste the app's signing " ++ "secret (Basic Information > App Credentials), set the Slack app's " ++ "Event Subscriptions request URL to that address, turn Socket Mode " ++ "off, then restart the gateway.", ++ retryable=False, ++ ) ++ return False ++ if not app_token and not self._events_url: ++ logger.error( + "[Slack] SLACK_APP_TOKEN not set — this is a permanent config " + "error; set SLACK_APP_TOKEN via `hermes gateway setup` " + "or in the active profile's ~/.hermes/.env file, then restart " +@@ -2112,9 +2258,14 @@ + + lock_acquired = False + try: +- if not self._acquire_platform_lock( +- "slack-app-token", app_token, "Slack app token" +- ): ++ # InstaCloud template patch: the one-gateway-per-app lock keys on the ++ # app-level token, which the Events API transport does not have (the ++ # lock path would call .encode() on None). Key it on the bot token then. ++ if self._events_url: ++ lock_args = ("slack-events-bot-token", str(raw_token), "Slack bot token") ++ else: ++ lock_args = ("slack-app-token", app_token, "Slack app token") ++ if not self._acquire_platform_lock(*lock_args): + return False + lock_acquired = True + self._running = False +@@ -2169,6 +2320,9 @@ + self._app = AsyncApp( + token=primary_token, + client=primary_client, ++ # None under Socket Mode (upstream's default); the Events API ++ # listener needs it to verify Slack's request signatures. ++ signing_secret=self._signing_secret, + before_authorize=_slack_per_request_proxy_middleware(proxy_url), + ) + _apply_slack_proxy(self._app.client, proxy_url) +@@ -2431,7 +2585,10 @@ + # down whatever we managed to start, leave ``_running=False``, and + # let the ``finally`` block release the platform lock cleanly. + try: +- self._start_socket_mode_handler() ++ if self._events_url: ++ await self._start_events_server() ++ else: ++ self._start_socket_mode_handler() + self._running = True + self._ensure_socket_watchdog() + except Exception: +@@ -2445,7 +2602,8 @@ + raise + + logger.info( +- "[Slack] Socket Mode connected (%d workspace(s))", ++ "[Slack] %s connected (%d workspace(s))", ++ "Events API listener" if self._events_url else "Socket Mode", + len(self._team_clients), + ) + diff --git a/templates/hermes/telegram-keep-pending-updates.patch b/templates/hermes/telegram-keep-pending-updates.patch new file mode 100644 index 0000000..e70dd61 --- /dev/null +++ b/templates/hermes/telegram-keep-pending-updates.patch @@ -0,0 +1,22 @@ +--- a/plugins/platforms/telegram/adapter.py ++++ b/plugins/platforms/telegram/adapter.py +@@ -4793,11 +4793,14 @@ + webhook_url=webhook_url, + secret_token=webhook_secret, + allowed_updates=Update.ALL_TYPES, +- # Webhooks are push-based — Telegram does not hold a +- # server-side getUpdates queue, so this flag is a no-op +- # in practice. Mirror the polling path's reconnect +- # semantics for consistency. +- drop_pending_updates=not is_reconnect, ++ # InstaCloud template patch. Telegram DOES keep webhook ++ # deliveries it has not had a 2xx for, and setWebhook with ++ # drop_pending_updates=True discards them. On a host that ++ # scales to zero the update that woke the machine is exactly ++ # such a delivery: it is in flight while this gateway boots, ++ # so dropping pending updates here lost the first message ++ # after every wake. Keep them; Telegram redelivers. ++ drop_pending_updates=False, + ) + self._webhook_mode = True + self._polling_progress_accepting = False