Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 14 additions & 6 deletions gateway/platforms/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -4573,22 +4573,30 @@ async def send_slash_confirm(
session_key: str,
confirm_id: str,
metadata: Optional[Dict[str, Any]] = None,
allow_always: bool = True,
) -> SendResult:
"""Send a three-option slash-command confirmation prompt.
"""Send a slash-command confirmation prompt.

Used by the gateway's generic slash-confirm primitive (see
``GatewayRunner._request_slash_confirm``) for commands that have a
non-destructive but expensive side effect the user should explicitly
acknowledge — the current caller is ``/reload-mcp``, which
invalidates the provider prompt cache.
acknowledge — e.g. ``/reload-mcp`` (invalidates the provider prompt
cache) and the destructive session commands (/new, /reset, /undo).

Platforms with inline-button support (Telegram, Discord, Slack,
Matrix, Feishu) should override this to render three buttons:
Approve Once / Always Approve / Cancel. Button callbacks MUST be
routed back through the gateway by calling
Matrix, Feishu) should override this to render the choice buttons.
Button callbacks MUST be routed back through the gateway by calling
``GatewayRunner._resolve_slash_confirm(confirm_id, choice)`` where
``choice`` is ``"once"`` / ``"always"`` / ``"cancel"``.

``allow_always`` controls whether the middle "Always Approve" button
is rendered. It defaults to ``True`` (three buttons: Approve Once /
Always Approve / Cancel). Callers pass ``False`` for rare,
high-stakes commands where a permanent one-tap opt-out would be a
footgun — currently ``/update``, which pulls new code and restarts
the gateway. With ``allow_always=False`` only two buttons render
(Approve Once / Cancel) and there is no persist-the-opt-out path.

Platforms without button UIs leave this as the default and fall
through to the gateway's text fallback (which sends ``message`` as
plain text and intercepts the next ``/approve`` / ``/always`` /
Expand Down
50 changes: 30 additions & 20 deletions gateway/platforms/whatsapp_cloud.py
Original file line number Diff line number Diff line change
Expand Up @@ -910,38 +910,48 @@ async def send_slash_confirm(
session_key: str,
confirm_id: str,
metadata: Optional[Dict[str, Any]] = None,
allow_always: bool = True,
) -> SendResult:
"""Render a 3-button slash-command confirmation prompt.

Mirrors Telegram's send_slash_confirm: Approve Once / Always /
Cancel. The confirm_id is supplied by the caller (slash command
handler) — we just store the session_key mapping for the inbound
resolver to look up.
"""Render a slash-command confirmation prompt.

Mirrors Telegram's send_slash_confirm: Approve Once / Cancel, plus a
middle "Always" button when ``allow_always`` is True (the default).
Callers pass False for rare high-stakes commands (``/update``). The
confirm_id is supplied by the caller (slash command handler) — we
just store the session_key mapping for the inbound resolver to look
up.
"""
if self._http_client is None:
return SendResult(success=False, error="Not connected")

body_text = self._truncate_body(f"*{title}*\n\n{message}")
reply_to = (metadata or {}).get("reply_to_message_id") if metadata else None

buttons = [
{
"type": "reply",
"reply": {"id": f"sc:once:{confirm_id}", "title": "✅ Approve Once"},
},
]
if allow_always:
buttons.append(
{
"type": "reply",
"reply": {"id": f"sc:always:{confirm_id}", "title": "🔒 Always"},
}
)
buttons.append(
{
"type": "reply",
"reply": {"id": f"sc:cancel:{confirm_id}", "title": "❌ Cancel"},
}
)

interactive = {
"type": "button",
"body": {"text": body_text},
"action": {
"buttons": [
{
"type": "reply",
"reply": {"id": f"sc:once:{confirm_id}", "title": "✅ Approve Once"},
},
{
"type": "reply",
"reply": {"id": f"sc:always:{confirm_id}", "title": "🔒 Always"},
},
{
"type": "reply",
"reply": {"id": f"sc:cancel:{confirm_id}", "title": "❌ Cancel"},
},
],
"buttons": buttons,
},
}

Expand Down
28 changes: 20 additions & 8 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -26702,6 +26702,7 @@ async def _request_slash_confirm(
title: str,
message: str,
handler,
allow_always: bool = True,
) -> Optional[str]:
"""Ask the user to confirm an expensive slash command.

Expand All @@ -26710,6 +26711,11 @@ async def _request_slash_confirm(
The handler runs on the event loop when the user responds; its
return value is sent back as a gateway message.

``allow_always`` (default True) is forwarded to the adapter's
``send_slash_confirm`` to control whether the middle "Always
Approve" button renders. Pass False for rare high-stakes commands
(e.g. ``/update``) where a permanent one-tap opt-out is a footgun.

Returns a short acknowledgment string to send immediately (before
the user's response). If buttons rendered successfully the ack
is ``None`` (buttons are self-explanatory); if we fell back to
Expand Down Expand Up @@ -26739,15 +26745,21 @@ async def _request_slash_confirm(

used_buttons = False
if adapter is not None:
# Build kwargs: only pass allow_always when it is False (non-default).
# Legacy adapters without the parameter will use their own default
# (True) and won't raise TypeError on an unexpected kwarg.
kwargs = dict(
chat_id=source.chat_id,
title=title,
message=message,
session_key=session_key,
confirm_id=confirm_id,
metadata=metadata,
)
if not allow_always:
kwargs["allow_always"] = False
try:
button_result = await adapter.send_slash_confirm(
chat_id=source.chat_id,
title=title,
message=message,
session_key=session_key,
confirm_id=confirm_id,
metadata=metadata,
)
button_result = await adapter.send_slash_confirm(**kwargs)
if button_result and getattr(button_result, "success", False):
used_buttons = True
except Exception as exc:
Expand Down
68 changes: 58 additions & 10 deletions gateway/slash_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -6417,19 +6417,25 @@ def _collect_and_upload():
# profile's diagnostics from another profile's chat.
return await self._run_in_executor_with_context(_collect_and_upload)

async def _handle_update_command(self, event: MessageEvent) -> str:
async def _handle_update_command(self, event: MessageEvent) -> Optional[str]:
"""Handle /update command — update Hermes Agent to the latest version.

Spawns ``hermes update`` in a detached session (via ``setsid``) so it
survives the gateway restart that ``hermes update`` may trigger. Marker
files are written so either the current gateway process or the next one
can notify the user when the update finishes.
Runs cheap pre-flight validation (platform allowed, not a managed
install, is a git repo, ``hermes`` binary resolvable) and fast-fails
with an error *before* prompting. Then ALWAYS routes through the
slash-confirm primitive — native Approve Once / Cancel buttons on
Telegram, Discord, and Slack, text fallback elsewhere — because
``hermes update`` pulls new code and restarts the running gateway,
interrupting every active session on the host.

Deliberately renders NO "Always Approve" button and offers no config
opt-out (``allow_always=False``): a permanent one-tap disable of the
confirmation on such a rare, high-stakes command would reintroduce
the exact accidental-fire footgun this prompt exists to prevent.

The actual detached spawn lives in ``_execute_update``.
"""
from gateway.run import _hermes_home, _resolve_hermes_bin
import json
import shutil
import subprocess
from datetime import datetime
from gateway.run import _resolve_hermes_bin
from hermes_cli.config import is_managed, format_managed_message

# Block non-messaging platforms (API server, webhooks, ACP)
Expand Down Expand Up @@ -6458,6 +6464,48 @@ async def _handle_update_command(self, event: MessageEvent) -> str:
if not hermes_cmd:
return t("gateway.update.hermes_cmd_not_found")

# /update always confirms — no opt-out. It pulls new code and
# restarts the running gateway (interrupting every active session on
# the host), so it's a rare, high-stakes action where an accidental
# invoke must never fire instantly. Unlike /reload-mcp and the
# destructive session commands, there is deliberately NO "Always
# Approve" button and no config gate to permanently disable the
# prompt (allow_always=False) — a one-tap permanent opt-out would
# reintroduce exactly the footgun this confirmation exists to close.
async def _on_confirm(choice: str) -> Optional[str]:
if choice == "cancel":
return t("gateway.update.cancelled")
# Any non-cancel choice ("once"; "always" can still arrive via a
# typed /always on a text-fallback platform) proceeds exactly
# once. Nothing is persisted.
return await self._execute_update(event, hermes_cmd)

return await self._request_slash_confirm(
event=event,
command="update",
title="/update",
message=t("gateway.update.confirm_prompt"),
handler=_on_confirm,
allow_always=False,
)

async def _execute_update(self, event: MessageEvent, hermes_cmd: list) -> str:
"""Spawn the detached ``hermes update`` process.

Spawns ``hermes update`` in a detached session (via ``setsid``) so it
survives the gateway restart that ``hermes update`` may trigger. Marker
files are written so either the current gateway process or the next one
can notify the user when the update finishes.

``hermes_cmd`` is the argv list already resolved by the caller via
``_resolve_hermes_bin`` (validation happens in ``_handle_update_command``).
"""
from gateway.run import _hermes_home
import json
import shutil
import subprocess
from datetime import datetime

pending_path = _hermes_home / ".update_pending.json"
output_path = _hermes_home / ".update_output.txt"
exit_code_path = _hermes_home / ".update_exit_code"
Expand Down
2 changes: 2 additions & 0 deletions locales/af.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,8 @@ Future messages in this room will use that transcript until `/reset` or another
hermes_cmd_not_found: "✗ Kon nie die `hermes`-opdrag vind nie. Hermes loop, maar die opdateeropdrag kon nie die uitvoerbare lêer op PATH of via die huidige Python-vertolker vind nie. Probeer `hermes update` met die hand in jou terminale uitvoer."
start_failed: "✗ Kon nie opdatering begin nie: {error}"
starting: "⚕ Begin Hermes-opdatering… Ek sal vordering hier stroom."
confirm_prompt: "⚠️ **Bevestig /update**\n\nDit trek die jongste Hermes-kode en **herbegin die gateway** — elke aktiewe sessie op hierdie gasheer word onderbreek terwyl dit herverbind.\n\nKies:\n• **Approve Once** — opdateer nou\n• **Cancel** — moenie opdateer nie\n\n_Teks-alternatief: antwoord `/approve` of `/cancel`._"
cancelled: "🟡 /update gekanselleer. Hermes is nie opgedateer nie."

usage:
rate_limits: "⏱️ **Tariefperke:** {state}"
Expand Down
2 changes: 2 additions & 0 deletions locales/ar.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,8 @@ gateway:
hermes_cmd_not_found: "✗ تعذّر تحديد موقع أمر `hermes`. Hermes يعمل، لكن أمر التحديث لم يجد الملف التنفيذي على PATH أو عبر مُفسّر Python الحالي. جرّب تشغيل `hermes update` يدويًا في طرفيتك."
start_failed: "✗ فشل بدء التحديث: {error}"
starting: "⚕ جارٍ بدء تحديث Hermes… سأبثّ التقدّم هنا."
confirm_prompt: "⚠️ **تأكيد /update**\n\nسيؤدي هذا إلى سحب أحدث كود Hermes و**إعادة تشغيل البوابة** — سيتم مقاطعة كل جلسة نشطة على هذا المضيف أثناء إعادة الاتصال.\n\nاختر:\n• **الموافقة مرة واحدة** — تحديث الآن\n• **إلغاء** — لا يحدث\n\n_بديل النص: ردّ بـ /approve أو /cancel._"
cancelled: "🟡 تم إلغاء /update. لم يُحدَّث Hermes."

usage:
rate_limits: "⏱️ **حدود المعدّل:** {state}"
Expand Down
2 changes: 2 additions & 0 deletions locales/de.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,8 @@ Future messages in this room will use that transcript until `/reset` or another
hermes_cmd_not_found: "✗ Der Befehl `hermes` konnte nicht gefunden werden. Hermes läuft, aber der Update-Befehl konnte das ausführbare Programm weder im PATH noch über den aktuellen Python-Interpreter finden. Versuchen Sie, `hermes update` manuell im Terminal auszuführen."
start_failed: "✗ Update konnte nicht gestartet werden: {error}"
starting: "⚕ Hermes-Update wird gestartet… Ich streame den Fortschritt hier."
confirm_prompt: "⚠️ **Update bestätigen**\n\nDies zieht den neuesten Hermes-Code und **startet das Gateway neu** — jede aktive Sitzung auf diesem Host wird unterbrochen, während es sich neu verbindet.\n\nWählen Sie:\n• **Einmal genehmigen** — jetzt aktualisieren\n• **Abbrechen** — nicht aktualisieren\n\n_Text-Alternative: Antworten Sie mit `/approve` oder `/cancel`._"
cancelled: "🟡 /update abgebrochen. Hermes wurde nicht aktualisiert."

usage:
rate_limits: "⏱️ **Ratenlimits:** {state}"
Expand Down
2 changes: 2 additions & 0 deletions locales/en.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,8 @@ gateway:
hermes_cmd_not_found: "✗ Could not locate the `hermes` command. Hermes is running, but the update command could not find the executable on PATH or via the current Python interpreter. Try running `hermes update` manually in your terminal."
start_failed: "✗ Failed to start update: {error}"
starting: "⚕ Starting Hermes update… I'll stream progress here."
confirm_prompt: "⚠️ **Confirm /update**\n\nThis pulls the latest Hermes code and **restarts the gateway** — every active session on this host is interrupted while it reconnects.\n\nChoose:\n• **Approve Once** — update now\n• **Cancel** — do not update\n\n_Text fallback: reply `/approve` or `/cancel`._"
cancelled: "🟡 /update cancelled. Hermes was not updated."

usage:
rate_limits: "⏱️ **Rate Limits:** {state}"
Expand Down
2 changes: 2 additions & 0 deletions locales/es.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,8 @@ gateway:
hermes_cmd_not_found: "✗ No se pudo localizar el comando `hermes`. Hermes está en ejecución, pero el comando de actualización no encontró el ejecutable en PATH ni a través del intérprete de Python actual. Intenta ejecutar `hermes update` manualmente en tu terminal."
start_failed: "✗ No se pudo iniciar la actualización: {error}"
starting: "⚕ Iniciando la actualización de Hermes… Transmitiré el progreso aquí."
confirm_prompt: "⚠️ **Confirmar /update**\n\nEsto descarga el código más reciente de Hermes y **reinicia el gateway** — cada sesión activa en este host se interrumpe mientras se reconecta.\n\nElige:\n• **Aprobar una vez** — actualizar ahora\n• **Cancelar** — no actualizar\n\n_Alternativa de texto: responde `/approve` o `/cancel`._"
cancelled: "🟡 /update cancelado. Hermes no ha sido actualizado."

usage:
rate_limits: "⏱️ **Límites de tasa:** {state}"
Expand Down
2 changes: 2 additions & 0 deletions locales/fr.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,8 @@ Future messages in this room will use that transcript until `/reset` or another
hermes_cmd_not_found: "✗ Impossible de localiser la commande `hermes`. Hermes est en cours d'exécution, mais la commande de mise à jour n'a pas pu trouver l'exécutable dans le PATH ni via l'interpréteur Python actuel. Essayez d'exécuter `hermes update` manuellement dans votre terminal."
start_failed: "✗ Échec du démarrage de la mise à jour : {error}"
starting: "⚕ Démarrage de la mise à jour Hermes… Je diffuserai la progression ici."
confirm_prompt: "⚠️ **Confirmer /update**\n\nCela récupère le code Hermes le plus récent et **redémarre le gateway** — chaque session active sur cet hôte est interrompue pendant la reconnexion.\n\nChoisissez :\n• **Approuver une fois** — mettre à jour maintenant\n• **Annuler** — ne pas mettre à jour\n\n_Alternative texte : répondez `/approve` ou `/cancel`._"
cancelled: "🟡 /update annulé. Hermes n'a pas été mis à jour."

usage:
rate_limits: "⏱️ **Limites de débit :** {state}"
Expand Down
2 changes: 2 additions & 0 deletions locales/ga.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,8 @@ Future messages in this room will use that transcript until `/reset` or another
hermes_cmd_not_found: "✗ Níorbh fhéidir an t-ordú `hermes` a aimsiú. Tá Hermes ag rith, ach níorbh fhéidir leis an ordú nuashonraithe an inrite a aimsiú ar PATH ná tríd an léirmhínitheoir Python reatha. Bain triail as `hermes update` a rith de láimh i do theirminéal."
start_failed: "✗ Theip ar nuashonrú a thosú: {error}"
starting: "⚕ Ag tosú nuashonrú Hermes… Cuirfidh mé an dul chun cinn ar shruth anseo."
confirm_prompt: "⚠️ **Dearbhaigh /update**\n\nTarraingíonn sé seo an cód Hermes is déanaí agus **atosaíonn sé an gateway** — cuirtear isteach ar gach seisiún gníomhach ar an óstach seo agus é ag athcheangal.\n\nRoghnaigh:\n• **Approve Once** — nuashonraigh anois\n• **Cancel** — ná nuashonraigh\n\n_Cúltaca téacs: freagair `/approve` nó `/cancel`._"
cancelled: "🟡 /update cealaithe. Níor nuashonraíodh Hermes."

usage:
rate_limits: "⏱️ **Teorainneacha Ráta:** {state}"
Expand Down
2 changes: 2 additions & 0 deletions locales/hu.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,8 @@ Future messages in this room will use that transcript until `/reset` or another
hermes_cmd_not_found: "✗ Nem sikerült megtalálni a `hermes` parancsot. A Hermes fut, de a frissítőparancs nem találta a futtatható fájlt a PATH-on vagy a jelenlegi Python interpreteren keresztül. Próbáld futtatni a `hermes update` parancsot manuálisan a terminálban."
start_failed: "✗ Nem sikerült elindítani a frissítést: {error}"
starting: "⚕ Hermes frissítés indítása… A folyamatot itt fogom közvetíteni."
confirm_prompt: "⚠️ **A /update megerősítése**\n\nEz lehúzza a legújabb Hermes-kódot, és **újraindítja az Átjárót** — minden aktív munkamenet ezen a gépen megszakad, amíg az újracsatlakozik.\n\nVálassz:\n• **Egyszeri jóváhagyás** — frissítés most\n• **Megszakítás** — ne frissítsen\n\n_Szöveges alternatíva: válaszolj `/approve` vagy `/cancel` paranccsal._"
cancelled: "🟡 /update megszakítva. A Hermes nem frissült."

usage:
rate_limits: "⏱️ **Sebességkorlátok:** {state}"
Expand Down
Loading
Loading