diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index d9ba28d490c..df92adb95d5 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -30,21 +30,27 @@ RUN (apt-get remove --purge -y gcc gcc-12 g++ g++-12 cpp cpp-12 make \ # Hermes v2026.4.13+ auto-detects HTTPS_PROXY and skips fallback-IP # transport when a proxy is present. The sandbox proxy chain -# (decode-proxy → OpenShell L7 proxy) handles credential placeholder -# rewriting and hostname-based policy enforcement. No monkey patch needed. +# (decode-proxy -> OpenShell L7 proxy) handles REST credential placeholder +# rewriting and hostname-based policy enforcement. A Hermes-only local Discord +# facade handles discord.py's Gateway session inside the sandbox and forwards +# REST through the same placeholder-substitution path. ENV HERMES_TELEGRAM_DISABLE_FALLBACK_IPS=1 # Copy NemoClaw plugin for Hermes (Python-based) COPY agents/hermes/plugin/ /opt/nemoclaw-hermes-plugin/ RUN chmod -R a+rX /opt/nemoclaw-hermes-plugin/ -# Copy config generator and URL-decode proxy +# Copy config generator, Discord facade, and URL-decode proxy COPY agents/hermes/generate-config.ts /opt/nemoclaw-hermes-config/generate-config.ts COPY agents/hermes/config/ /opt/nemoclaw-hermes-config/config/ RUN find /opt/nemoclaw-hermes-config -type d -exec chmod 755 {} + \ && find /opt/nemoclaw-hermes-config -type f -exec chmod 444 {} + COPY agents/hermes/decode-proxy.py /usr/local/bin/nemoclaw-decode-proxy -RUN chmod 755 /usr/local/bin/nemoclaw-decode-proxy +COPY agents/hermes/discord-facade.py /usr/local/bin/nemoclaw-discord-facade +COPY agents/hermes/discord-preload/ /opt/nemoclaw-hermes-discord-preload/ +RUN chmod 755 /usr/local/bin/nemoclaw-decode-proxy /usr/local/bin/nemoclaw-discord-facade \ + && find /opt/nemoclaw-hermes-discord-preload -type d -exec chmod 755 {} + \ + && find /opt/nemoclaw-hermes-discord-preload -type f -exec chmod 444 {} + # Copy blueprint (shared infrastructure) COPY nemoclaw-blueprint/ /opt/nemoclaw-blueprint/ diff --git a/agents/hermes/config/messaging-config.ts b/agents/hermes/config/messaging-config.ts index 869ec0466b9..bdce3b19e9f 100644 --- a/agents/hermes/config/messaging-config.ts +++ b/agents/hermes/config/messaging-config.ts @@ -9,6 +9,9 @@ const CHANNEL_TOKEN_ENVS: Record = { slack: ["SLACK_BOT_TOKEN", "SLACK_APP_TOKEN"], }; +const HERMES_DISCORD_PROXY = "http://127.0.0.1:3129"; +const HERMES_DISCORD_FACADE = "http://127.0.0.1:3130"; + export function buildMessagingEnvLines( enabledChannels: Set, allowedIds: MessagingAllowedIds, @@ -21,6 +24,14 @@ export function buildMessagingEnvLines( for (const envKey of envKeys) { envLines.push(`${envKey}=openshell:resolve:env:${envKey}`); } + if (channel === "discord") { + envLines.push(`DISCORD_PROXY=${HERMES_DISCORD_PROXY}`); + envLines.push(`NEMOCLAW_DISCORD_FACADE_URL=${HERMES_DISCORD_FACADE}`); + const guildIds = Object.keys(discordGuilds).filter(Boolean); + if (guildIds.length > 0) { + envLines.push(`NEMOCLAW_DISCORD_GUILD_IDS=${guildIds.join(",")}`); + } + } } const discordAllowedUsers = collectDiscordAllowedUsers(allowedIds, discordGuilds); diff --git a/agents/hermes/decode-proxy.py b/agents/hermes/decode-proxy.py index b3f09058479..65a94fed109 100755 --- a/agents/hermes/decode-proxy.py +++ b/agents/hermes/decode-proxy.py @@ -12,6 +12,10 @@ URL-decodes the CONNECT target and request paths so the placeholders are restored before reaching the L7 proxy. +This is intentionally not a WebSocket frame rewriter. After the initial +HTTP proxy request is forwarded, bytes are relayed unchanged; Discord +gateway IDENTIFY payloads are not inspected or modified here. + Usage: Launched by start.sh, listens on 127.0.0.1:3129. HTTPS_PROXY=http://127.0.0.1:3129 hermes gateway run """ diff --git a/agents/hermes/discord-facade.py b/agents/hermes/discord-facade.py new file mode 100755 index 00000000000..ca7637b7d81 --- /dev/null +++ b/agents/hermes/discord-facade.py @@ -0,0 +1,962 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +""" +Sandbox-local Discord REST/Gateway facade for Hermes. + +Hermes still starts discord.py with the OpenShell placeholder token. The +sitecustomize preload rewrites discord.py's Discord REST and Gateway transports +to this loopback service. The facade accepts the placeholder on the local +Gateway, forwards non-emulated REST requests through DISCORD_PROXY, and accepts +Discord outgoing interaction webhooks for injection as Gateway dispatches. +""" + +from __future__ import annotations + +import asyncio +import binascii +import contextlib +import copy +import json +import logging +import os +import re +import secrets +import shlex +import shutil +import signal +import sys +import time +from dataclasses import dataclass, field +from typing import Any +from urllib.parse import parse_qsl, urlencode + +try: + from aiohttp import ClientSession, WSMsgType, web +except Exception as exc: # pragma: no cover - exercised in the sandbox image + print(f"[discord-facade] aiohttp is required: {exc}", file=sys.stderr) + sys.exit(1) + + +LOGGER = logging.getLogger("nemoclaw.discord_facade") + +DEFAULT_TOKEN_PLACEHOLDER = "openshell:resolve:env:DISCORD_BOT_TOKEN" +DEFAULT_LISTEN_HOST = "127.0.0.1" +DEFAULT_LISTEN_PORT = 3130 +DISCORD_API_ORIGIN = "https://discord.com" +INTERACTION_TOKEN_TTL_SECONDS = 15 * 60 +MAX_INTERACTION_TOKENS = 1024 +APPLICATION_COMMANDS_RE = re.compile(r"^/api/v\d+/applications/(\d+)/commands/?$") +APPLICATION_COMMAND_RE = re.compile(r"^/api/v\d+/applications/(\d+)/commands/(\d+)/?$") +INTERACTION_CALLBACK_RE = re.compile(r"^/api/v\d+/interactions/(\d+)/([^/]+)/callback/?$") +WEBHOOK_TOKEN_RE = re.compile(r"^/api/v\d+/webhooks/(\d+)/([^/]+)(/.*)?$") + + +@dataclass(eq=False) +class GatewayPeer: + ws: web.WebSocketResponse + session_id: str = field(default_factory=lambda: secrets.token_hex(16)) + sequence: int = 0 + identified: bool = False + + +def _env_int(name: str, default: int) -> int: + raw = os.getenv(name, "").strip() + if not raw: + return default + try: + return int(raw) + except ValueError: + LOGGER.warning("Ignoring invalid %s=%r", name, raw) + return default + + +def _env_float(name: str, default: float) -> float: + raw = os.getenv(name, "").strip() + if not raw: + return default + try: + return float(raw) + except ValueError: + LOGGER.warning("Ignoring invalid %s=%r", name, raw) + return default + + +def _json_response(data: Any, status: int = 200) -> web.Response: + body = json.dumps(data, separators=(",", ":")).encode("utf-8") + return web.Response( + body=body, status=status, content_type="application/json" + ) + + +def _csv_env(*names: str) -> list[str]: + values: list[str] = [] + for name in names: + raw = os.getenv(name, "") + for item in raw.split(","): + cleaned = item.strip() + if cleaned: + values.append(cleaned) + return values + + +def _redact_path(path: str) -> str: + match = WEBHOOK_TOKEN_RE.match(path) + if match: + suffix = match.group(3) or "" + return f"/api/v10/webhooks/{match.group(1)}/{suffix}" + match = INTERACTION_CALLBACK_RE.match(path) + if match: + return f"/api/v10/interactions/{match.group(1)}//callback" + return path + + +class DiscordFacade: + def __init__( + self, + *, + host: str, + port: int, + placeholder_token: str, + upstream_proxy: str | None, + public_base_url: str | None, + public_key: str | None, + ) -> None: + self.host = host + self.port = port + self.placeholder_token = placeholder_token + self.upstream_proxy = upstream_proxy + self.public_base_url = public_base_url + self.public_key = public_key + self.application_id = os.getenv("NEMOCLAW_DISCORD_APPLICATION_ID", "313700000000000001") + self.bot_user_id = os.getenv("NEMOCLAW_DISCORD_BOT_USER_ID", "313700000000000002") + self.bot_username = os.getenv("NEMOCLAW_DISCORD_BOT_USERNAME", "Hermes") + self.synthetic_reaction_user_id = os.getenv( + "NEMOCLAW_DISCORD_REACTION_USER_ID", + "313700000000000003", + ) + self._peers: set[GatewayPeer] = set() + self._interaction_tokens: dict[str, tuple[str, float]] = {} + self._session: ClientSession | None = None + self._poll_task: asyncio.Task[None] | None = None + self._poll_interval = _env_float("NEMOCLAW_DISCORD_POLL_INTERVAL_SECONDS", 10) + self._poll_channel_ids = set(_csv_env("NEMOCLAW_DISCORD_POLL_CHANNEL_IDS")) + self._poll_guild_ids = set( + _csv_env("NEMOCLAW_DISCORD_POLL_GUILD_IDS", "NEMOCLAW_DISCORD_GUILD_IDS") + ) + self._poll_discovered_channels: set[str] = set() + self._message_cache: dict[str, dict[str, Any]] = {} + self._channel_message_ids: dict[str, set[str]] = {} + self._thread_cache: dict[str, dict[str, Any]] = {} + self._poll_warning_keys: set[str] = set() + self._command_counter = 313700000000010000 + self._commands: dict[str, dict[str, Any]] = {} + + @property + def gateway_url(self) -> str: + return f"ws://{self.host}:{self.port}/gateway" + + async def start(self) -> web.AppRunner: + self._session = ClientSession() + app = web.Application(client_max_size=2 * 1024 * 1024) + app.add_routes( + [ + web.get("/gateway", self.handle_gateway), + web.post("/interactions", self.handle_interaction), + web.get("/health", self.handle_health), + web.route("*", "/api/{tail:.*}", self.handle_rest), + ] + ) + runner = web.AppRunner(app, access_log=None) + await runner.setup() + site = web.TCPSite(runner, self.host, self.port) + await site.start() + LOGGER.info("Discord facade listening on http://%s:%s", self.host, self.port) + self._start_polling() + return runner + + async def start_public_interactions(self, host: str, port: int) -> web.AppRunner: + app = web.Application(client_max_size=2 * 1024 * 1024) + app.add_routes([web.post("/interactions", self.handle_interaction)]) + runner = web.AppRunner(app, access_log=None) + await runner.setup() + site = web.TCPSite(runner, host, port) + await site.start() + LOGGER.info("Discord public interactions listener on http://%s:%s", host, port) + return runner + + async def close(self) -> None: + if self._poll_task is not None: + self._poll_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await self._poll_task + self._poll_task = None + if self._session is not None: + await self._session.close() + self._session = None + + async def handle_health(self, _request: web.Request) -> web.Response: + return _json_response({"ok": True, "peers": len(self._peers)}) + + async def handle_gateway(self, request: web.Request) -> web.WebSocketResponse: + ws = web.WebSocketResponse(heartbeat=None, compress=False) + await ws.prepare(request) + peer = GatewayPeer(ws=ws) + self._peers.add(peer) + LOGGER.info("Discord facade Gateway client connected") + try: + await self._send_gateway(peer, op=10, data={"heartbeat_interval": 41250}) + async for msg in ws: + if msg.type != WSMsgType.TEXT: + continue + try: + payload = json.loads(msg.data) + except json.JSONDecodeError: + await ws.close(code=4002, message=b"invalid payload") + break + await self._handle_gateway_payload(peer, payload) + finally: + self._peers.discard(peer) + LOGGER.info("Discord facade Gateway client disconnected") + return ws + + async def _handle_gateway_payload(self, peer: GatewayPeer, payload: dict[str, Any]) -> None: + op = payload.get("op") + if op == 1: + await self._send_gateway(peer, op=11, data=None) + return + if op == 2: + token = str((payload.get("d") or {}).get("token") or "") + if token != self.placeholder_token: + LOGGER.error( + "Rejecting Discord Gateway IDENTIFY that did not use the OpenShell placeholder" + ) + await peer.ws.close(code=4004, message=b"authentication failed") + return + peer.identified = True + await self._dispatch_ready(peer) + return + if op == 6: + token = str((payload.get("d") or {}).get("token") or "") + if token != self.placeholder_token: + await peer.ws.close(code=4004, message=b"authentication failed") + return + peer.identified = True + await self._dispatch(peer, "RESUMED", {"_trace": ["nemoclaw-discord-facade"]}) + return + if op in (3, 4): + return + LOGGER.debug("Ignoring Discord Gateway opcode %r at facade boundary", op) + + async def _dispatch_ready(self, peer: GatewayPeer) -> None: + await self._dispatch( + peer, + "READY", + { + "v": 10, + "session_id": peer.session_id, + "resume_gateway_url": self.gateway_url, + "user": self._bot_user(), + "application": { + "id": self.application_id, + "flags": 0, + }, + "guilds": [], + "private_channels": [], + "relationships": [], + "shard": [0, 1], + "_trace": ["nemoclaw-discord-facade"], + }, + ) + + def _bot_user(self) -> dict[str, Any]: + return { + "id": self.bot_user_id, + "username": self.bot_username, + "global_name": self.bot_username, + "discriminator": "0000", + "avatar": None, + "bot": True, + "system": False, + "mfa_enabled": False, + "verified": True, + "email": None, + "flags": 0, + "premium_type": 0, + "public_flags": 0, + } + + async def _send_gateway( + self, + peer: GatewayPeer, + *, + op: int, + data: Any, + event_type: str | None = None, + ) -> None: + payload = {"op": op, "d": data, "s": peer.sequence if event_type else None, "t": event_type} + await peer.ws.send_str(json.dumps(payload, separators=(",", ":"))) + + async def _dispatch(self, peer: GatewayPeer, event_type: str, data: Any) -> None: + peer.sequence += 1 + await self._send_gateway(peer, op=0, data=data, event_type=event_type) + + async def dispatch_to_all(self, event_type: str, data: dict[str, Any]) -> None: + peers = [peer for peer in self._peers if peer.identified and not peer.ws.closed] + for peer in peers: + await self._dispatch(peer, event_type, data) + + def _start_polling(self) -> None: + if self._poll_interval <= 0: + return + if not self._poll_channel_ids and not self._poll_guild_ids: + return + self._poll_task = asyncio.create_task(self._poll_loop()) + LOGGER.info( + "Discord facade REST poller enabled (guilds=%d channels=%d interval=%ss)", + len(self._poll_guild_ids), + len(self._poll_channel_ids), + self._poll_interval, + ) + + async def _poll_loop(self) -> None: + while True: + try: + await self._poll_once() + except asyncio.CancelledError: + raise + except Exception as exc: + LOGGER.warning("Discord REST polling failed: %s", exc) + await asyncio.sleep(self._poll_interval) + + async def _poll_once(self) -> None: + await self._refresh_poll_targets() + for channel_id in sorted(self._poll_channel_ids | self._poll_discovered_channels): + await self._poll_channel_messages(channel_id) + for guild_id in sorted(self._poll_guild_ids): + await self._poll_guild_threads(guild_id) + + async def _refresh_poll_targets(self) -> None: + if not self._poll_guild_ids: + return + discovered: set[str] = set() + for guild_id in sorted(self._poll_guild_ids): + channels = await self._discord_json("GET", f"/api/v10/guilds/{guild_id}/channels") + if not isinstance(channels, list): + continue + for channel in channels: + if not isinstance(channel, dict): + continue + channel_type = int(channel.get("type", -1)) + if channel_type in {0, 5}: + channel_id = str(channel.get("id") or "") + if channel_id: + discovered.add(channel_id) + self._poll_discovered_channels = discovered + + async def _poll_channel_messages(self, channel_id: str) -> None: + messages = await self._discord_json( + "GET", + f"/api/v10/channels/{channel_id}/messages?limit=25", + ) + if not isinstance(messages, list): + return + current_ids: set[str] = set() + for raw in reversed(messages): + if not isinstance(raw, dict): + continue + message = self._normalize_message(raw, channel_id) + message_id = str(message.get("id") or "") + if not message_id: + continue + current_ids.add(message_id) + previous = self._message_cache.get(message_id) + if previous is None: + self._message_cache[message_id] = message + await self.dispatch_to_all("MESSAGE_CREATE", self._strip_internal_fields(message)) + continue + reactions_changed = previous.get("_nemoclaw_reactions") != message.get("_nemoclaw_reactions") + if self._message_changed(previous, message): + self._message_cache[message_id] = message + await self.dispatch_to_all("MESSAGE_UPDATE", self._strip_internal_fields(message)) + await self._dispatch_reaction_deltas(previous, message) + if reactions_changed: + self._message_cache[message_id] = message + + previous_ids = self._channel_message_ids.get(channel_id, set()) + if len(messages) < 25: + for deleted_id in sorted(previous_ids - current_ids): + deleted = self._message_cache.pop(deleted_id, {}) + payload = { + "id": deleted_id, + "channel_id": channel_id, + } + if deleted.get("guild_id"): + payload["guild_id"] = deleted["guild_id"] + await self.dispatch_to_all("MESSAGE_DELETE", payload) + self._channel_message_ids[channel_id] = current_ids + + async def _poll_guild_threads(self, guild_id: str) -> None: + payload = await self._discord_json("GET", f"/api/v10/guilds/{guild_id}/threads/active") + if not isinstance(payload, dict): + return + threads = payload.get("threads") + if not isinstance(threads, list): + return + current_ids: set[str] = set() + for raw in threads: + if not isinstance(raw, dict): + continue + thread = dict(raw) + thread_id = str(thread.get("id") or "") + if not thread_id: + continue + current_ids.add(thread_id) + self._poll_discovered_channels.add(thread_id) + previous = self._thread_cache.get(thread_id) + if previous is None: + self._thread_cache[thread_id] = thread + await self.dispatch_to_all("THREAD_CREATE", thread) + elif previous != thread: + self._thread_cache[thread_id] = thread + await self.dispatch_to_all("THREAD_UPDATE", thread) + + for thread_id in sorted(set(self._thread_cache) - current_ids): + old = self._thread_cache.get(thread_id, {}) + if str(old.get("guild_id") or "") != guild_id: + continue + self._thread_cache.pop(thread_id, None) + await self.dispatch_to_all( + "THREAD_DELETE", + { + "id": thread_id, + "guild_id": guild_id, + "parent_id": old.get("parent_id"), + "type": old.get("type", 11), + }, + ) + + def _normalize_message(self, raw: dict[str, Any], channel_id: str) -> dict[str, Any]: + message = dict(raw) + message.setdefault("channel_id", channel_id) + message.setdefault("type", 0) + message.setdefault("content", "") + message.setdefault("mentions", []) + message.setdefault("mention_roles", []) + message.setdefault("mention_everyone", False) + message.setdefault("attachments", []) + message.setdefault("embeds", []) + message.setdefault("pinned", False) + message.setdefault("tts", False) + message["_nemoclaw_reactions"] = self._reaction_counts(message) + return message + + @staticmethod + def _message_changed(previous: dict[str, Any], current: dict[str, Any]) -> bool: + keys = {"content", "edited_timestamp", "pinned", "attachments", "embeds", "flags"} + return any(previous.get(key) != current.get(key) for key in keys) + + @staticmethod + def _strip_internal_fields(message: dict[str, Any]) -> dict[str, Any]: + public = dict(message) + public.pop("_nemoclaw_reactions", None) + return public + + @staticmethod + def _reaction_counts(message: dict[str, Any]) -> dict[str, tuple[int, dict[str, Any]]]: + counts: dict[str, tuple[int, dict[str, Any]]] = {} + for reaction in message.get("reactions", []) or []: + if not isinstance(reaction, dict): + continue + emoji = reaction.get("emoji") if isinstance(reaction.get("emoji"), dict) else {} + emoji_key = str(emoji.get("id") or emoji.get("name") or "") + if not emoji_key: + continue + counts[emoji_key] = (int(reaction.get("count") or 0), emoji) + return counts + + async def _dispatch_reaction_deltas( + self, + previous: dict[str, Any], + current: dict[str, Any], + ) -> None: + old_counts = previous.get("_nemoclaw_reactions", {}) + new_counts = current.get("_nemoclaw_reactions", {}) + for emoji_key, (new_count, emoji) in new_counts.items(): + old_count = old_counts.get(emoji_key, (0, emoji))[0] + if new_count == old_count: + continue + event_type = "MESSAGE_REACTION_ADD" if new_count > old_count else "MESSAGE_REACTION_REMOVE" + payload = { + "user_id": self.synthetic_reaction_user_id, + "channel_id": current.get("channel_id"), + "message_id": current.get("id"), + "emoji": emoji, + } + if current.get("guild_id"): + payload["guild_id"] = current["guild_id"] + await self.dispatch_to_all(event_type, payload) + + async def _discord_json(self, method: str, path: str) -> Any: + if self._session is None: + return None + headers = { + "Authorization": f"Bot {self.placeholder_token}", + "User-Agent": "NemoClawDiscordFacade/1.0", + } + try: + async with self._session.request( + method, + f"{DISCORD_API_ORIGIN}{path}", + headers=headers, + proxy=self.upstream_proxy, + allow_redirects=False, + ) as response: + if response.status >= 400: + key = f"{method} {path.split('?')[0]} {response.status}" + if key not in self._poll_warning_keys: + self._poll_warning_keys.add(key) + LOGGER.warning("Discord REST poll returned HTTP %s for %s", response.status, path.split("?")[0]) + return None + body = await response.read() + except Exception as exc: + key = f"{method} {path.split('?')[0]} error" + if key not in self._poll_warning_keys: + self._poll_warning_keys.add(key) + LOGGER.warning("Discord REST poll failed for %s: %s", path.split("?")[0], exc) + return None + if not body: + return None + try: + return json.loads(body.decode("utf-8")) + except json.JSONDecodeError: + return None + + async def handle_rest(self, request: web.Request) -> web.Response: + path = request.path + method = request.method.upper() + if method == "GET" and re.fullmatch(r"/api/v\d+/gateway(?:/bot)?", path): + return _json_response( + { + "url": self.gateway_url, + "shards": 1, + "session_start_limit": { + "total": 1000, + "remaining": 1000, + "reset_after": 0, + "max_concurrency": 1, + }, + } + ) + if method == "GET" and path in ("/api/v10/users/@me", "/api/v9/users/@me"): + return _json_response(self._bot_user()) + if method == "GET" and path in ( + "/api/v10/oauth2/applications/@me", + "/api/v9/oauth2/applications/@me", + "/api/v10/applications/@me", + "/api/v9/applications/@me", + ): + return _json_response(self._application_payload()) + if method == "GET" and path.endswith("/users/@me/guilds"): + return _json_response([]) + if match := APPLICATION_COMMANDS_RE.match(path): + return await self._handle_application_commands(request, match.group(1)) + if match := APPLICATION_COMMAND_RE.match(path): + return await self._handle_application_command(request, match.group(1), match.group(2)) + if match := INTERACTION_CALLBACK_RE.match(path): + return await self._handle_interaction_callback(request, match.group(1), match.group(2)) + if match := WEBHOOK_TOKEN_RE.match(path): + return await self._forward_with_interaction_token(request, match) + return await self._forward_rest(request) + + def _application_payload(self) -> dict[str, Any]: + return { + "id": self.application_id, + "name": "Hermes", + "icon": None, + "description": "Hermes Discord facade", + "bot_public": False, + "bot_require_code_grant": False, + "flags": 0, + "verify_key": self.public_key or "", + "owner": self._bot_user(), + } + + async def _handle_application_commands(self, request: web.Request, app_id: str) -> web.Response: + method = request.method.upper() + if method == "GET": + return _json_response(list(self._commands.values())) + if method == "PUT": + payload = await self._read_json(request) + commands = payload if isinstance(payload, list) else [] + self._commands.clear() + for command in commands: + stored = self._store_command(app_id, command) + self._commands[stored["id"]] = stored + return _json_response(list(self._commands.values())) + if method == "POST": + payload = await self._read_json(request) + stored = self._store_command(app_id, payload if isinstance(payload, dict) else {}) + self._commands[stored["id"]] = stored + return _json_response(stored, status=201) + return _json_response({"message": "method not allowed"}, status=405) + + async def _handle_application_command( + self, + request: web.Request, + app_id: str, + command_id: str, + ) -> web.Response: + method = request.method.upper() + if method == "PATCH": + payload = await self._read_json(request) + current = self._commands.get(command_id, {"id": command_id, "application_id": app_id}) + current.update(payload if isinstance(payload, dict) else {}) + current.setdefault("version", str(int(current["id"]) + 1)) + self._commands[command_id] = current + return _json_response(current) + if method == "DELETE": + self._commands.pop(command_id, None) + return web.Response(status=204) + return await self._forward_rest(request) + + def _store_command(self, app_id: str, payload: dict[str, Any]) -> dict[str, Any]: + self._command_counter += 1 + command_id = str(self._command_counter) + stored = dict(payload) + stored.setdefault("type", 1) + stored.setdefault("name", f"command-{command_id}") + stored.setdefault("description", "") + stored.setdefault("options", []) + stored.update( + { + "id": command_id, + "application_id": app_id, + "version": str(self._command_counter + 1), + } + ) + return stored + + async def _handle_interaction_callback( + self, + request: web.Request, + interaction_id: str, + local_token: str, + ) -> web.Response: + real_token = self._resolve_interaction_token(local_token) + if real_token is not None: + path = f"/api/v10/interactions/{interaction_id}/{real_token}/callback" + return await self._forward_rest(request, override_path=path) + return await self._forward_rest(request) + + async def _forward_with_interaction_token( + self, + request: web.Request, + match: re.Match[str], + ) -> web.Response: + local_token = match.group(2) + real_token = self._resolve_interaction_token(local_token) + if real_token is None: + return await self._forward_rest(request) + suffix = match.group(3) or "" + path = f"/api/v10/webhooks/{match.group(1)}/{real_token}{suffix}" + return await self._forward_rest(request, override_path=path) + + async def handle_interaction(self, request: web.Request) -> web.Response: + body = await self._read_bytes(request) + if not self._verify_signature(request, body): + return _json_response({"error": "invalid signature"}, status=401) + try: + payload = json.loads(body.decode("utf-8")) + except json.JSONDecodeError: + return _json_response({"error": "invalid json"}, status=400) + if payload.get("type") == 1: + return _json_response({"type": 1}) + + local_payload = self._localize_interaction_token(payload) + await self.dispatch_to_all("INTERACTION_CREATE", local_payload) + return _json_response({"type": 5}) + + def _localize_interaction_token(self, payload: dict[str, Any]) -> dict[str, Any]: + copied = copy.deepcopy(payload) + token = str(copied.get("token") or "") + if token: + local_token = f"nemoclaw-local-{secrets.token_urlsafe(24)}" + self._store_interaction_token(local_token, token) + copied["token"] = local_token + return copied + + def _store_interaction_token(self, local_token: str, real_token: str) -> None: + self._prune_interaction_tokens() + self._interaction_tokens[local_token] = ( + real_token, + time.monotonic() + INTERACTION_TOKEN_TTL_SECONDS, + ) + self._prune_interaction_tokens() + + def _resolve_interaction_token(self, local_token: str) -> str | None: + self._prune_interaction_tokens() + entry = self._interaction_tokens.get(local_token) + if entry is None: + return None + return entry[0] + + def _prune_interaction_tokens(self) -> None: + now = time.monotonic() + for local_token, (_real_token, expires_at) in list(self._interaction_tokens.items()): + if expires_at <= now: + self._interaction_tokens.pop(local_token, None) + overflow = len(self._interaction_tokens) - MAX_INTERACTION_TOKENS + if overflow > 0: + oldest = sorted(self._interaction_tokens.items(), key=lambda item: item[1][1]) + for local_token, _entry in oldest[:overflow]: + self._interaction_tokens.pop(local_token, None) + + def _verify_signature(self, request: web.Request, body: bytes) -> bool: + public_key = (self.public_key or "").strip() + if not public_key: + LOGGER.warning("Discord interaction rejected: DISCORD_PUBLIC_KEY is not configured") + return False + signature_hex = request.headers.get("X-Signature-Ed25519", "") + timestamp = request.headers.get("X-Signature-Timestamp", "") + try: + signature = binascii.unhexlify(signature_hex) + verify_key = binascii.unhexlify(public_key) + except (binascii.Error, ValueError): + return False + message = timestamp.encode("utf-8") + body + try: + from nacl.signing import VerifyKey + + VerifyKey(verify_key).verify(message, signature) + return True + except ImportError: + pass + except Exception: + return False + try: + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey + + Ed25519PublicKey.from_public_bytes(verify_key).verify(signature, message) + return True + except Exception: + return False + + async def _forward_rest( + self, + request: web.Request, + *, + override_path: str | None = None, + ) -> web.Response: + if self._session is None: + return _json_response({"message": "facade session unavailable"}, status=503) + path = override_path or request.path_qs + if override_path and request.query_string: + separator = "&" if "?" in path else "?" + path = f"{path}{separator}{request.query_string}" + target = f"{DISCORD_API_ORIGIN}{path}" + headers = { + key: value + for key, value in request.headers.items() + if key.lower() not in {"host", "content-length", "accept-encoding"} + } + body = await self._read_bytes(request) + LOGGER.debug("Forwarding Discord REST %s %s", request.method, _redact_path(path)) + try: + async with self._session.request( + request.method, + target, + headers=headers, + data=body if body else None, + proxy=self.upstream_proxy, + allow_redirects=False, + ) as response: + response_body = await response.read() + response_headers = { + key: value + for key, value in response.headers.items() + if key.lower() + not in { + "content-encoding", + "content-length", + "transfer-encoding", + "connection", + } + } + return web.Response( + status=response.status, + body=response_body, + headers=response_headers, + ) + except Exception as exc: + LOGGER.warning( + "Discord REST forward failed for %s %s: %s", + request.method, + _redact_path(path), + exc, + ) + return _json_response({"message": "discord rest forward failed"}, status=502) + + async def _read_json(self, request: web.Request) -> Any: + body = await self._read_bytes(request) + if not body: + return None + try: + return json.loads(body.decode("utf-8")) + except json.JSONDecodeError: + return None + + async def _read_bytes(self, request: web.Request) -> bytes: + return await request.read() + + async def register_interactions_endpoint(self) -> None: + endpoint = (self.public_base_url or "").rstrip("/") + if not endpoint: + return + app_id = os.getenv("NEMOCLAW_DISCORD_APPLICATION_ID", "").strip() + if not app_id: + LOGGER.warning("Cannot register Discord interactions endpoint without application id") + return + path = f"/api/v10/applications/{app_id}" + payload = {"interactions_endpoint_url": f"{endpoint}/interactions"} + fake_request = _SyntheticRequest("PATCH", path, payload, self.placeholder_token) + response = await self._forward_rest(fake_request) # type: ignore[arg-type] + if response.status >= 400: + LOGGER.warning("Discord interactions endpoint registration returned HTTP %s", response.status) + else: + LOGGER.info("Registered Discord interactions endpoint URL") + + +class _SyntheticRequest: + def __init__(self, method: str, path: str, payload: dict[str, Any], placeholder_token: str) -> None: + self.method = method + self.path = path + self.path_qs = path + self.query_string = "" + self.headers = { + "Authorization": f"Bot {placeholder_token}", + "Content-Type": "application/json", + "User-Agent": "NemoClawDiscordFacade/1.0", + } + self._body = json.dumps(payload, separators=(",", ":")).encode("utf-8") + + async def read(self) -> bytes: + return self._body + + +def _tunnel_requested() -> bool: + return bool( + os.getenv("NEMOCLAW_DISCORD_TUNNEL_COMMAND", "").strip() + or os.getenv("NEMOCLAW_DISCORD_ENABLE_TUNNEL", "").strip() == "1" + ) + + +async def _run_tunnel_command( + public_url_file: str, + *, + local_url: str, +) -> tuple[asyncio.subprocess.Process | None, asyncio.Task[None] | None]: + command = os.getenv("NEMOCLAW_DISCORD_TUNNEL_COMMAND", "").strip() + if not command and os.getenv("NEMOCLAW_DISCORD_ENABLE_TUNNEL", "").strip() == "1": + cloudflared = shutil.which("cloudflared") + if cloudflared: + command = f"{shlex.quote(cloudflared)} tunnel --url {shlex.quote(local_url)}" + else: + LOGGER.warning("Discord interactions tunnel requested but cloudflared is not installed") + if not command: + return None, None + LOGGER.info("Starting sandbox-owned Discord interactions tunnel command") + proc = await asyncio.create_subprocess_shell( + command, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + ) + + async def _capture_url() -> None: + url_re = re.compile(rb"https://[A-Za-z0-9.-]+") + assert proc.stdout is not None + async for line in proc.stdout: + match = url_re.search(line) + if match: + url = match.group(0).decode("utf-8").rstrip("/") + with open(public_url_file, "w", encoding="utf-8") as handle: + handle.write(url + "\n") + LOGGER.info("Discord interactions tunnel URL discovered") + + capture_task = asyncio.create_task(_capture_url()) + + def _log_capture_failure(task: asyncio.Task[None]) -> None: + try: + task.result() + except asyncio.CancelledError: + pass + except Exception as exc: + LOGGER.warning("Discord interactions tunnel URL capture failed: %s", exc) + + capture_task.add_done_callback(_log_capture_failure) + return proc, capture_task + + +async def main() -> None: + logging.basicConfig( + level=os.getenv("NEMOCLAW_DISCORD_FACADE_LOG_LEVEL", "INFO").upper(), + format="[discord-facade] %(levelname)s: %(message)s", + ) + host = os.getenv("NEMOCLAW_DISCORD_FACADE_HOST", DEFAULT_LISTEN_HOST) + port = _env_int("NEMOCLAW_DISCORD_FACADE_PORT", DEFAULT_LISTEN_PORT) + interactions_host = os.getenv("NEMOCLAW_DISCORD_INTERACTIONS_HOST", DEFAULT_LISTEN_HOST) + interactions_port = _env_int("NEMOCLAW_DISCORD_INTERACTIONS_PORT", port + 1) + public_url_file = os.getenv("NEMOCLAW_DISCORD_TUNNEL_URL_FILE", "/tmp/nemoclaw-discord-tunnel-url") + public_base_url = os.getenv("NEMOCLAW_DISCORD_PUBLIC_URL", "").strip() or None + + facade = DiscordFacade( + host=host, + port=port, + placeholder_token=os.getenv("NEMOCLAW_DISCORD_PLACEHOLDER", DEFAULT_TOKEN_PLACEHOLDER), + upstream_proxy=os.getenv("DISCORD_PROXY") or os.getenv("HTTPS_PROXY") or None, + public_base_url=public_base_url, + public_key=os.getenv("DISCORD_PUBLIC_KEY") or os.getenv("NEMOCLAW_DISCORD_PUBLIC_KEY"), + ) + runner = await facade.start() + interactions_runner: web.AppRunner | None = None + if public_base_url or _tunnel_requested(): + interactions_runner = await facade.start_public_interactions(interactions_host, interactions_port) + tunnel_proc, tunnel_capture_task = await _run_tunnel_command( + public_url_file, + local_url=f"http://{interactions_host}:{interactions_port}", + ) + if not public_base_url: + for _ in range(20 if tunnel_proc else 1): + if os.path.exists(public_url_file): + with open(public_url_file, "r", encoding="utf-8") as handle: + public_base_url = handle.read().strip() or None + if public_base_url: + break + await asyncio.sleep(0.25) + if public_base_url: + facade.public_base_url = public_base_url + await facade.register_interactions_endpoint() + + stop_event = asyncio.Event() + loop = asyncio.get_running_loop() + for sig in (signal.SIGTERM, signal.SIGINT): + loop.add_signal_handler(sig, stop_event.set) + await stop_event.wait() + if interactions_runner is not None: + await interactions_runner.cleanup() + await runner.cleanup() + await facade.close() + if tunnel_proc and tunnel_proc.returncode is None: + tunnel_proc.terminate() + try: + await asyncio.wait_for(tunnel_proc.wait(), timeout=5) + except asyncio.TimeoutError: + tunnel_proc.kill() + if tunnel_capture_task is not None and not tunnel_capture_task.done(): + tunnel_capture_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await tunnel_capture_task + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/agents/hermes/discord-preload/sitecustomize.py b/agents/hermes/discord-preload/sitecustomize.py new file mode 100644 index 00000000000..9af27e396e0 --- /dev/null +++ b/agents/hermes/discord-preload/sitecustomize.py @@ -0,0 +1,81 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +"""Redirect discord.py Discord transports to NemoClaw's local facade.""" + +from __future__ import annotations + +import os +from urllib.parse import ParseResult, parse_qsl, urlencode, urlparse, urlunparse + + +FACADE_URL = os.getenv("NEMOCLAW_DISCORD_FACADE_URL", "").strip() +if FACADE_URL: + try: + import aiohttp + except Exception: + aiohttp = None + + if aiohttp is not None: + _facade = urlparse(FACADE_URL) + _original_request = aiohttp.ClientSession._request + _original_ws_connect = aiohttp.ClientSession.ws_connect + _api_hosts = {"discord.com", "discordapp.com", "canary.discord.com", "ptb.discord.com"} + _gateway_hosts = {"gateway.discord.gg"} + + def _replace_netloc(parsed: ParseResult, *, scheme: str, path: str) -> str: + return urlunparse((scheme, _facade.netloc, path, "", parsed.query, "")) + + def _rewrite_rest_url(url: object) -> str | None: + parsed = urlparse(str(url)) + if parsed.hostname not in _api_hosts: + return None + if not parsed.path.startswith("/api"): + return None + return _replace_netloc(parsed, scheme=_facade.scheme or "http", path=parsed.path) + + def _rewrite_gateway_url(url: object) -> str | None: + parsed = urlparse(str(url)) + hostname = parsed.hostname or "" + if hostname not in _gateway_hosts and not hostname.endswith(".discord.gg"): + return None + query = dict(parse_qsl(parsed.query, keep_blank_values=True)) + if "v" not in query: + query["v"] = "10" + rewritten_query = urlencode(query) + scheme = "wss" if (_facade.scheme == "https") else "ws" + return urlunparse((scheme, _facade.netloc, "/gateway", "", rewritten_query, "")) + + def _is_facade_url(url: object) -> bool: + try: + return urlparse(str(url)).netloc == _facade.netloc + except Exception: + return False + + async def _nemoclaw_request(self, method, str_or_url, **kwargs): + rewritten = _rewrite_rest_url(str_or_url) + if rewritten: + kwargs.pop("proxy", None) + kwargs.pop("proxy_auth", None) + kwargs.pop("ssl", None) + str_or_url = rewritten + elif _is_facade_url(str_or_url): + kwargs.pop("proxy", None) + kwargs.pop("proxy_auth", None) + kwargs.pop("ssl", None) + return await _original_request(self, method, str_or_url, **kwargs) + + def _nemoclaw_ws_connect(self, url, **kwargs): + rewritten = _rewrite_gateway_url(url) + if rewritten: + kwargs.pop("proxy", None) + kwargs.pop("proxy_auth", None) + kwargs.pop("ssl", None) + url = rewritten + elif _is_facade_url(url): + kwargs.pop("proxy", None) + kwargs.pop("proxy_auth", None) + kwargs.pop("ssl", None) + return _original_ws_connect(self, url, **kwargs) + + aiohttp.ClientSession._request = _nemoclaw_request + aiohttp.ClientSession.ws_connect = _nemoclaw_ws_connect diff --git a/agents/hermes/policy-additions.yaml b/agents/hermes/policy-additions.yaml index b41e9e588bc..a47dbc033bf 100644 --- a/agents/hermes/policy-additions.yaml +++ b/agents/hermes/policy-additions.yaml @@ -18,6 +18,7 @@ filesystem_policy: - /usr - /lib - /opt/hermes + - /opt/nemoclaw-hermes-discord-preload - /proc - /dev/urandom - /app @@ -206,9 +207,23 @@ network_policies: rules: - allow: { method: GET, path: "/**" } - allow: { method: POST, path: "/**" } + - allow: { method: GET, path: "/gateway*" } + - allow: { method: GET, path: "/api/v*/gateway/bot" } + - allow: { method: GET, path: "/api/v*/applications/@me" } + - allow: { method: PUT, path: "/api/v*/applications/*/commands" } + - allow: { method: PUT, path: "/api/v*/channels/*/messages/*/reactions/*/@me" } + - allow: { method: PATCH, path: "/api/v*/applications/*" } + - allow: { method: PATCH, path: "/api/v*/applications/*/commands/*" } + - allow: { method: PATCH, path: "/api/v*/channels/*/messages/*" } + - allow: { method: PATCH, path: "/api/v*/webhooks/*/*/messages/*" } + - allow: { method: DELETE, path: "/api/v*/applications/*/commands/*" } + - allow: { method: DELETE, path: "/api/v*/channels/*/messages/*" } + - allow: { method: DELETE, path: "/api/v*/channels/*/messages/*/reactions/*/*" } + - allow: { method: DELETE, path: "/api/v*/webhooks/*/*/messages/*" } - host: gateway.discord.gg port: 443 access: full + tls: skip - host: cdn.discordapp.com port: 443 protocol: rest diff --git a/agents/hermes/policy-permissive.yaml b/agents/hermes/policy-permissive.yaml index 9b4effadf42..2dc6f532f0e 100644 --- a/agents/hermes/policy-permissive.yaml +++ b/agents/hermes/policy-permissive.yaml @@ -19,6 +19,7 @@ filesystem_policy: - /usr - /lib - /opt/hermes + - /opt/nemoclaw-hermes-discord-preload - /proc - /dev/urandom - /app @@ -188,9 +189,8 @@ network_policies: access: full - host: gateway.discord.gg port: 443 - protocol: rest - enforcement: enforce access: full + tls: skip - host: cdn.discordapp.com port: 443 protocol: rest diff --git a/agents/hermes/start.sh b/agents/hermes/start.sh index 9615fbdc09b..5e473162435 100755 --- a/agents/hermes/start.sh +++ b/agents/hermes/start.sh @@ -258,11 +258,15 @@ start_socat_forwarder() { # Python HTTP clients (httpx) URL-encode colons in paths, breaking # OpenShell's openshell:resolve:env: placeholder pattern. This proxy # sits between the Hermes process and the OpenShell proxy, URL-decoding -# paths so the L7 proxy recognizes the placeholders. +# request targets so the L7 proxy recognizes REST placeholders. It relays +# upgraded WebSocket bytes unchanged and does not rewrite Discord IDENTIFY. +HERMES_VENV_PYTHON="/opt/hermes/.venv/bin/python" DECODE_PROXY_PID="" DECODE_PROXY_PORT=3129 +DISCORD_FACADE_PID="" +DISCORD_FACADE_PORT=3130 start_decode_proxy() { - nohup python3 /usr/local/bin/nemoclaw-decode-proxy >/dev/null 2>&1 & + nohup "$HERMES_VENV_PYTHON" /usr/local/bin/nemoclaw-decode-proxy >/dev/null 2>&1 & DECODE_PROXY_PID=$! # Wait for it to start listening local attempts=0 @@ -277,6 +281,35 @@ start_decode_proxy() { echo "[gateway] decode-proxy failed to start — placeholder rewriting may not work" >&2 } +start_discord_facade() { + local facade_url="http://127.0.0.1:${DISCORD_FACADE_PORT}" + local proxy_url="http://127.0.0.1:${DECODE_PROXY_PORT}" + local log_path="/tmp/discord-facade.log" + local launch_env=( + "DISCORD_PROXY=${proxy_url}" + "NEMOCLAW_DISCORD_FACADE_PORT=${DISCORD_FACADE_PORT}" + ) + + if [ "$(id -u)" -eq 0 ] && command -v gosu >/dev/null 2>&1 && id gateway >/dev/null 2>&1; then + prepare_restricted_log "$log_path" gateway:gateway 600 + nohup env -u NEMOCLAW_DISCORD_FACADE_URL -u PYTHONPATH "${launch_env[@]}" gosu gateway sh -c 'umask 0007; exec "$@" >/tmp/discord-facade.log 2>&1' sh "$HERMES_VENV_PYTHON" /usr/local/bin/nemoclaw-discord-facade & + else + prepare_restricted_log "$log_path" "" 600 + nohup env -u NEMOCLAW_DISCORD_FACADE_URL -u PYTHONPATH "${launch_env[@]}" sh -c 'umask 0007; exec "$@" >/tmp/discord-facade.log 2>&1' sh "$HERMES_VENV_PYTHON" /usr/local/bin/nemoclaw-discord-facade & + fi + DISCORD_FACADE_PID=$! + local attempts=0 + while [ "$attempts" -lt 10 ]; do + if ss -tln 2>/dev/null | grep -q "127.0.0.1:${DISCORD_FACADE_PORT}"; then + echo "[gateway] discord facade listening on ${facade_url} (pid $DISCORD_FACADE_PID)" >&2 + return + fi + sleep 0.5 + attempts=$((attempts + 1)) + done + echo "[gateway] discord facade failed to start — Hermes Discord gateway emulation may not work" >&2 +} + # cleanup_on_signal is provided by sandbox-init.sh. It reads # SANDBOX_CHILD_PIDS (array of all PIDs) and SANDBOX_WAIT_PID (the # primary process whose exit status is returned). @@ -293,6 +326,8 @@ export NO_PROXY="$_NO_PROXY_VAL" export http_proxy="$_PROXY_URL" export https_proxy="$_PROXY_URL" export no_proxy="$_NO_PROXY_VAL" +export NEMOCLAW_DISCORD_FACADE_URL="http://127.0.0.1:${DISCORD_FACADE_PORT}" +export PYTHONPATH="/opt/nemoclaw-hermes-discord-preload${PYTHONPATH:+:${PYTHONPATH}}" # Resolve sandbox home dir early — used by proxy-env writing and # install_configure_guard before the non-root/root branch below. @@ -319,6 +354,9 @@ export http_proxy="$_PROXY_URL" export https_proxy="$_PROXY_URL" export no_proxy="$_NO_PROXY_VAL" export HERMES_HOME="${HERMES_DIR}" +export DISCORD_PROXY="http://127.0.0.1:${DECODE_PROXY_PORT}" +export NEMOCLAW_DISCORD_FACADE_URL="http://127.0.0.1:${DISCORD_FACADE_PORT}" +export PYTHONPATH="/opt/nemoclaw-hermes-discord-preload\${PYTHONPATH:+:\${PYTHONPATH}}" PROXYEOF } | emit_sandbox_sourced_file "$_PROXY_ENV_FILE" @@ -505,8 +543,12 @@ if [ "$(id -u)" -ne 0 ]; then # Start decode proxy and Hermes gateway start_decode_proxy + start_discord_facade umask 0007 HERMES_HOME="${HERMES_DIR}" \ + DISCORD_PROXY="http://127.0.0.1:${DECODE_PROXY_PORT}" \ + NEMOCLAW_DISCORD_FACADE_URL="http://127.0.0.1:${DISCORD_FACADE_PORT}" \ + PYTHONPATH="/opt/nemoclaw-hermes-discord-preload${PYTHONPATH:+:${PYTHONPATH}}" \ HTTPS_PROXY="http://127.0.0.1:${DECODE_PROXY_PORT}" \ HTTP_PROXY="http://127.0.0.1:${DECODE_PROXY_PORT}" \ https_proxy="http://127.0.0.1:${DECODE_PROXY_PORT}" \ @@ -520,6 +562,7 @@ if [ "$(id -u)" -ne 0 ]; then # the shared-library refactor). Acceptable for entrypoint-level cleanup. SANDBOX_CHILD_PIDS=("$GATEWAY_PID") [ -n "${DECODE_PROXY_PID:-}" ] && SANDBOX_CHILD_PIDS+=("$DECODE_PROXY_PID") + [ -n "${DISCORD_FACADE_PID:-}" ] && SANDBOX_CHILD_PIDS+=("$DISCORD_FACADE_PID") [ -n "${GATEWAY_LOG_TAIL_PID:-}" ] && SANDBOX_CHILD_PIDS+=("$GATEWAY_LOG_TAIL_PID") # shellcheck disable=SC2034 # read by cleanup_on_signal from sandbox-init.sh SANDBOX_WAIT_PID="$GATEWAY_PID" @@ -551,7 +594,11 @@ validate_tmp_permissions # Start decode proxy and gateway start_decode_proxy +start_discord_facade HERMES_HOME="${HERMES_DIR}" \ + DISCORD_PROXY="http://127.0.0.1:${DECODE_PROXY_PORT}" \ + NEMOCLAW_DISCORD_FACADE_URL="http://127.0.0.1:${DISCORD_FACADE_PORT}" \ + PYTHONPATH="/opt/nemoclaw-hermes-discord-preload${PYTHONPATH:+:${PYTHONPATH}}" \ HTTPS_PROXY="http://127.0.0.1:${DECODE_PROXY_PORT}" \ HTTP_PROXY="http://127.0.0.1:${DECODE_PROXY_PORT}" \ https_proxy="http://127.0.0.1:${DECODE_PROXY_PORT}" \ @@ -565,6 +612,7 @@ start_gateway_log_stream # the shared-library refactor). Acceptable for entrypoint-level cleanup. SANDBOX_CHILD_PIDS=("$GATEWAY_PID") [ -n "${DECODE_PROXY_PID:-}" ] && SANDBOX_CHILD_PIDS+=("$DECODE_PROXY_PID") +[ -n "${DISCORD_FACADE_PID:-}" ] && SANDBOX_CHILD_PIDS+=("$DISCORD_FACADE_PID") [ -n "${GATEWAY_LOG_TAIL_PID:-}" ] && SANDBOX_CHILD_PIDS+=("$GATEWAY_LOG_TAIL_PID") # shellcheck disable=SC2034 # read by cleanup_on_signal from sandbox-init.sh SANDBOX_WAIT_PID="$GATEWAY_PID" diff --git a/src/lib/agent/runtime.test.ts b/src/lib/agent/runtime.test.ts index 1f41fd3492a..c9bc88ecd9b 100644 --- a/src/lib/agent/runtime.test.ts +++ b/src/lib/agent/runtime.test.ts @@ -96,13 +96,64 @@ describe("buildRecoveryScript", () => { const script = buildRecoveryScript(hermesAgent, 8642); expect(script).toContain("export HERMES_HOME=/sandbox/.hermes"); expect(script).toContain("HERMES_HOME=/sandbox/.hermes"); + expect(script).toContain("DISCORD_PROXY=http://127.0.0.1:3129"); + expect(script).toContain("NEMOCLAW_DISCORD_FACADE_URL=http://127.0.0.1:3130"); + expect(script).toContain("PYTHONPATH=/opt/nemoclaw-hermes-discord-preload"); expect(script).toContain("HTTPS_PROXY=http://127.0.0.1:3129"); expect(script).toContain("nemoclaw-decode-proxy"); + expect(script).toContain("nemoclaw-discord-facade"); expect(script).toContain('"$AGENT_BIN" gateway run'); expect(script).not.toContain('"$AGENT_BIN" gateway run --port 8642'); expect(script).not.toContain("hermes gateway run --port 8642"); }); + it("launches Hermes decode-proxy and Discord facade under the venv interpreter during recovery", () => { + const script = buildRecoveryScript(hermesAgent, 8642); + expect(script).toContain("/opt/hermes/.venv/bin/python /usr/local/bin/nemoclaw-decode-proxy"); + expect(script).toContain("/opt/hermes/.venv/bin/python /usr/local/bin/nemoclaw-discord-facade"); + expect(script).not.toMatch(/(? { + const recoveryScript = buildRecoveryScript(hermesAgent, 8642); + expect(recoveryScript).not.toBeNull(); + for (const script of [recoveryScript!, buildManualRecoveryCommand(hermesAgent, 8642)]) { + expect(script).toContain( + 'command -v ss >/dev/null 2>&1 && ss -tln 2>/dev/null | grep -Eq "127\\.0\\.0\\.1:3129([[:space:]]|$)" && break', + ); + expect(script).toContain( + 'command -v ss >/dev/null 2>&1 && ss -tln 2>/dev/null | grep -Eq "127\\.0\\.0\\.1:3130([[:space:]]|$)" && break', + ); + expect(script).not.toContain('grep -q "127.0.0.1:3129"'); + expect(script).not.toContain('grep -q "127.0.0.1:3130"'); + expect(script).not.toContain("do ! command -v ss >/dev/null 2>&1 || ss -tln"); + } + }); + + it("prepares the Hermes Discord facade log before child-side redirection", () => { + const recoveryScript = buildRecoveryScript(hermesAgent, 8642); + expect(recoveryScript).not.toBeNull(); + for (const script of [recoveryScript!, buildManualRecoveryCommand(hermesAgent, 8642)]) { + expect(script).toContain("/tmp/discord-facade.log"); + expect(script).toContain("/tmp/discord-facade-recovery.log"); + expect(script).toContain("O_NOFOLLOW"); + expect(script).toContain("_DISCORD_FACADE_LOG='/tmp/discord-facade.log'"); + expect(script).toContain("_DISCORD_FACADE_LOG='/tmp/discord-facade-recovery.log'"); + expect(script).toContain('DISCORD_FACADE_LOG="$_DISCORD_FACADE_LOG"'); + expect(script).toContain( + 'sh -c \'umask 0007; exec "$@" >>"$DISCORD_FACADE_LOG" 2>&1\' sh /opt/hermes/.venv/bin/python /usr/local/bin/nemoclaw-discord-facade &', + ); + expect(script).not.toContain( + "nohup python3 /usr/local/bin/nemoclaw-discord-facade >/tmp/discord-facade.log 2>&1", + ); + expect(script).not.toContain('exec "$@" >/tmp/discord-facade.log 2>&1'); + expect(script.indexOf("O_NOFOLLOW")).toBeLessThan( + script.indexOf('DISCORD_FACADE_LOG="$_DISCORD_FACADE_LOG"'), + ); + } + }); + it("falls back to openclaw gateway run when gateway_command is absent", () => { const agent = makeAgent({ gateway_command: undefined }); const script = buildRecoveryScript(agent, 19000); @@ -316,8 +367,12 @@ describe("buildManualRecoveryCommand (#2426)", () => { it("omits --port for Hermes and uses the current Hermes home", () => { const cmd = buildManualRecoveryCommand(hermesAgent, 8642); expect(cmd).toContain("HERMES_HOME=/sandbox/.hermes"); + expect(cmd).toContain("DISCORD_PROXY=http://127.0.0.1:3129"); + expect(cmd).toContain("NEMOCLAW_DISCORD_FACADE_URL=http://127.0.0.1:3130"); + expect(cmd).toContain("PYTHONPATH=/opt/nemoclaw-hermes-discord-preload"); expect(cmd).toContain("HTTPS_PROXY=http://127.0.0.1:3129"); expect(cmd).toContain("nemoclaw-decode-proxy"); + expect(cmd).toContain("nemoclaw-discord-facade"); expect(cmd).toContain("nohup hermes gateway run"); expect(cmd).not.toContain("--port 8642"); expect(cmd).not.toContain("/sandbox/.hermes-data"); diff --git a/src/lib/agent/runtime.ts b/src/lib/agent/runtime.ts index 4372b791169..8f4ad00aa58 100644 --- a/src/lib/agent/runtime.ts +++ b/src/lib/agent/runtime.ts @@ -144,8 +144,12 @@ function gatewayLaunchCommand(command: string, runAsUser?: string): string { function hermesGatewayEnvPrefix(): string { const decodeProxy = "http://127.0.0.1:3129"; + const discordFacade = "http://127.0.0.1:3130"; return [ "HERMES_HOME=/sandbox/.hermes", + `DISCORD_PROXY=${decodeProxy}`, + `NEMOCLAW_DISCORD_FACADE_URL=${discordFacade}`, + "PYTHONPATH=/opt/nemoclaw-hermes-discord-preload${PYTHONPATH:+:${PYTHONPATH}}", `HTTPS_PROXY=${decodeProxy}`, `HTTP_PROXY=${decodeProxy}`, `https_proxy=${decodeProxy}`, @@ -154,7 +158,15 @@ function hermesGatewayEnvPrefix(): string { } function hermesDecodeProxyRecoveryCommand(): string { - return 'if ! command -v ss >/dev/null 2>&1 || ! ss -tln 2>/dev/null | grep -q "127.0.0.1:3129"; then nohup python3 /usr/local/bin/nemoclaw-decode-proxy >/dev/null 2>&1 & for _i in 1 2 3 4 5 6 7 8 9 10; do ! command -v ss >/dev/null 2>&1 || ss -tln 2>/dev/null | grep -q "127.0.0.1:3129" && break; sleep 0.5; done; fi;'; + const hermesVenvPython = "/opt/hermes/.venv/bin/python"; + const decodeProxyListening = 'ss -tln 2>/dev/null | grep -Eq "127\\.0\\.0\\.1:3129([[:space:]]|$)"'; + const facadeListening = 'ss -tln 2>/dev/null | grep -Eq "127\\.0\\.0\\.1:3130([[:space:]]|$)"'; + const primaryFacadeLog = "/tmp/discord-facade.log"; + const fallbackFacadeLog = "/tmp/discord-facade-recovery.log"; + const facadeLogSetup = `${buildNoFollowLogSetupCommand(primaryFacadeLog, undefined, "0o600")} || exit 1; _DISCORD_FACADE_LOG=${shellQuote(primaryFacadeLog)}; if ! : >> "$_DISCORD_FACADE_LOG" 2>/dev/null; then ${buildNoFollowLogSetupCommand(fallbackFacadeLog, undefined, "0o600")} || exit 1; _DISCORD_FACADE_LOG=${shellQuote(fallbackFacadeLog)}; : >> "$_DISCORD_FACADE_LOG" 2>/dev/null || exit 1; fi`; + const facadeLaunch = + `nohup env -u NEMOCLAW_DISCORD_FACADE_URL -u PYTHONPATH DISCORD_PROXY=http://127.0.0.1:3129 HTTPS_PROXY=http://127.0.0.1:3129 HTTP_PROXY=http://127.0.0.1:3129 NEMOCLAW_DISCORD_FACADE_PORT=3130 DISCORD_FACADE_LOG="$_DISCORD_FACADE_LOG" sh -c 'umask 0007; exec "$@" >>"$DISCORD_FACADE_LOG" 2>&1' sh ${hermesVenvPython} /usr/local/bin/nemoclaw-discord-facade &`; + return `if ! command -v ss >/dev/null 2>&1 || ! ${decodeProxyListening}; then nohup ${hermesVenvPython} /usr/local/bin/nemoclaw-decode-proxy >/dev/null 2>&1 & for _i in 1 2 3 4 5 6 7 8 9 10; do command -v ss >/dev/null 2>&1 && ${decodeProxyListening} && break; sleep 0.5; done; fi; if ! command -v ss >/dev/null 2>&1 || ! ${facadeListening}; then ${facadeLogSetup}; ${facadeLaunch} for _i in 1 2 3 4 5 6 7 8 9 10; do command -v ss >/dev/null 2>&1 && ${facadeListening} && break; sleep 0.5; done; fi;`; } /** diff --git a/test/e2e/test-hermes-discord-e2e.sh b/test/e2e/test-hermes-discord-e2e.sh index ef4a7e15fa5..c4eb5f67307 100755 --- a/test/e2e/test-hermes-discord-e2e.sh +++ b/test/e2e/test-hermes-discord-e2e.sh @@ -90,12 +90,14 @@ dump_hermes_discord_diagnostics() { diag_script='set +e' diag_script+='; echo "== hermes config =="; sed -n "1,120p" /sandbox/.hermes/config.yaml 2>&1 || true' diag_script+='; echo "== hermes env keys =="; cut -d= -f1 /sandbox/.hermes/.env 2>&1 || true' + diag_script+='; echo "== hermes runtime status =="; cat /sandbox/.hermes/gateway_state.json 2>&1 || true' diag_script+='; echo "== hermes health =="; curl -sf http://localhost:8642/health 2>&1 || true' diag_script+='; echo "== hermes-related processes =="' # shellcheck disable=SC2016 # script is intentionally evaluated inside the sandbox - diag_script+='; for p in /proc/[0-9]*; do cmd=$(tr "\000" " " < "$p/cmdline" 2>/dev/null || true); case "$cmd" in *hermes*|*socat*|*nemoclaw-decode-proxy*) echo "$(basename "$p") $cmd" ;; esac; done' + diag_script+='; for p in /proc/[0-9]*; do cmd=$(tr "\000" " " < "$p/cmdline" 2>/dev/null || true); case "$cmd" in *hermes*|*socat*|*nemoclaw-decode-proxy*|*nemoclaw-discord-facade*) echo "$(basename "$p") $cmd" ;; esac; done' diag_script+='; echo "== /tmp/nemoclaw-start.log tail =="; tail -n 80 /tmp/nemoclaw-start.log 2>&1 || true' diag_script+='; echo "== /tmp/gateway.log tail =="; tail -n 120 /tmp/gateway.log 2>&1 || true' + diag_script+='; echo "== /tmp/discord-facade.log tail =="; tail -n 120 /tmp/discord-facade.log 2>&1 || true' diag_output=$(openshell sandbox exec -n "$SANDBOX_NAME" -- sh -lc "$diag_script" 2>&1 || true) echo "$diag_output" | while IFS= read -r line; do @@ -320,6 +322,7 @@ if [ "$DISCORD_REQUIRE_MENTION" = "0" ]; then expected_require_mention="false" fi expected_allowed_users="${DISCORD_ALLOWED_IDS// /}" +expected_guild_ids="${DISCORD_SERVER_IDS// /}" config_probe=$( sandbox_exec_stdin "EXPECTED_REQUIRE_MENTION=$expected_require_mention python3 -" <<'PY' @@ -367,13 +370,16 @@ else fi env_probe=$( - sandbox_exec_stdin "EXPECTED_ALLOWED_USERS=$expected_allowed_users python3 -" <<'PY' + sandbox_exec_stdin "EXPECTED_ALLOWED_USERS=$expected_allowed_users EXPECTED_GUILD_IDS=$expected_guild_ids python3 -" <<'PY' import os from pathlib import Path text = Path("/sandbox/.hermes/.env").read_text(encoding="utf-8") errors = [] required = [ "DISCORD_BOT_TOKEN=openshell:resolve:env:DISCORD_BOT_TOKEN", + "DISCORD_PROXY=http://127.0.0.1:3129", + "NEMOCLAW_DISCORD_FACADE_URL=http://127.0.0.1:3130", + f"NEMOCLAW_DISCORD_GUILD_IDS={os.environ['EXPECTED_GUILD_IDS']}", f"DISCORD_ALLOWED_USERS={os.environ['EXPECTED_ALLOWED_USERS']}", ] for line in required: @@ -389,11 +395,120 @@ PY ) if [ "$env_probe" = "OK" ]; then - pass ".hermes/.env contains Discord placeholder and allowed users" + pass ".hermes/.env contains Discord placeholder, proxy bridge, and allowed users" else fail ".hermes/.env check failed: ${env_probe:0:400}" fi +gateway_proxy_log=$(sandbox_exec "grep -F 'Using proxy for Discord: http://127.0.0.1:3129' /tmp/gateway.log 2>/dev/null | tail -1 || true") +if [ -n "$gateway_proxy_log" ]; then + info "Hermes Discord proxy diagnostic: ${gateway_proxy_log:0:200}" +else + info "Hermes Discord proxy diagnostic log line not present; relying on env, facade, and REST checks" +fi + +facade_health="" +for facade_attempt in $(seq 1 15); do + facade_health=$(sandbox_exec "curl -sf http://127.0.0.1:3130/health 2>/dev/null || true") + if echo "$facade_health" | grep -qi '"ok":true'; then + break + fi + if [ "$facade_attempt" -lt 15 ]; then + info "Facade health check attempt ${facade_attempt}/15 - waiting 4s..." + sleep 4 + fi +done +if echo "$facade_health" | grep -qi '"ok":true'; then + pass "Hermes fake Discord facade is healthy inside the sandbox" +else + fail "Hermes fake Discord facade did not answer health probe: ${facade_health:0:200}" +fi + +facade_protocol=$( + cat <<'PY' | sandbox_exec_stdin 'NEMOCLAW_DISCORD_FACADE_URL=http://127.0.0.1:3130 PYTHONPATH=/opt/nemoclaw-hermes-discord-preload python3 - 2>&1' 2>/dev/null || true +import asyncio +import json + +from aiohttp import ClientSession, WSMsgType + +PLACEHOLDER = "openshell:resolve:env:DISCORD_BOT_TOKEN" + + +async def receive_json(ws, label): + msg = await ws.receive(timeout=5) + if msg.type != WSMsgType.TEXT: + raise AssertionError(f"{label}: expected text frame, got {msg.type} {msg.data!r}") + return json.loads(msg.data) + + +async def main(): + async with ClientSession() as session: + async with session.get("https://discord.com/api/v10/gateway") as response: + data = await response.json() + assert response.status == 200, data + assert data["url"] == "ws://127.0.0.1:3130/gateway", data + + async with session.ws_connect("wss://gateway.discord.gg/?v=10&encoding=json") as ws: + hello = await receive_json(ws, "HELLO") + assert hello["op"] == 10, hello + + await ws.send_json({ + "op": 2, + "d": { + "token": PLACEHOLDER, + "intents": 0, + "properties": { + "os": "linux", + "browser": "nemoclaw-e2e", + "device": "nemoclaw-e2e", + }, + }, + }) + ready = await receive_json(ws, "READY") + assert ready["op"] == 0 and ready["t"] == "READY", ready + assert ready["d"]["resume_gateway_url"] == "ws://127.0.0.1:3130/gateway", ready + + await ws.send_json({"op": 1, "d": ready.get("s")}) + ack = await receive_json(ws, "HEARTBEAT_ACK") + assert ack["op"] == 11, ack + + async with session.ws_connect("wss://gateway.discord.gg/?v=10&encoding=json") as ws: + hello = await receive_json(ws, "reject HELLO") + assert hello["op"] == 10, hello + await ws.send_json({ + "op": 2, + "d": { + "token": "not-the-openshell-placeholder", + "intents": 0, + "properties": { + "os": "linux", + "browser": "nemoclaw-e2e", + "device": "nemoclaw-e2e", + }, + }, + }) + close = await ws.receive(timeout=5) + assert close.type in (WSMsgType.CLOSE, WSMsgType.CLOSED, WSMsgType.CLOSING), close + assert ws.close_code == 4004 or close.data == 4004, (ws.close_code, close.data) + + async with session.post("http://127.0.0.1:3130/interactions", json={"type": 1}) as response: + assert response.status == 401, await response.text() + + print("OK preload_rest=local preload_gateway=ready heartbeat=ack reject=4004 unsigned_interaction=401") + + +asyncio.run(main()) +PY +) +info "Fake Discord facade protocol probe: ${facade_protocol:0:300}" +if echo "$facade_protocol" | grep -q "OK preload_rest=local preload_gateway=ready heartbeat=ack reject=4004 unsigned_interaction=401"; then + pass "Hermes Discord preload and fake Gateway protocol path work inside the sandbox" +elif echo "$facade_protocol" | grep -q "ModuleNotFoundError"; then + fail "Hermes Discord facade protocol probe could not import required Python modules: ${facade_protocol:0:300}" +else + fail "Hermes Discord facade protocol probe failed: ${facade_protocol:0:300}" +fi + token_file_hits=$(printf '%s' "$DISCORD_TOKEN" | sandbox_exec_stdin 'grep -Fq -f - /sandbox/.hermes/config.yaml /sandbox/.hermes/.env 2>/dev/null && echo LEAK || echo OK') if [ "$token_file_hits" = "OK" ]; then pass "Raw Discord token absent from Hermes config.yaml and .env" @@ -408,8 +523,12 @@ if [ -z "$sandbox_env_all" ]; then skip "Sandbox environment dump is empty" elif echo "$sandbox_env_all" | grep -qF "$DISCORD_TOKEN"; then fail "Raw Discord token found in sandbox environment" +elif ! echo "$sandbox_env_all" | grep -qx "DISCORD_PROXY=http://127.0.0.1:3129"; then + fail "Sandbox environment missing DISCORD_PROXY bridge setting" +elif ! echo "$sandbox_env_all" | grep -qx "NEMOCLAW_DISCORD_FACADE_URL=http://127.0.0.1:3130"; then + fail "Sandbox environment missing fake Discord facade setting" else - pass "Raw Discord token absent from sandbox environment" + pass "Raw Discord token absent from sandbox environment; Discord proxy and facade settings are present" fi sandbox_ps=$(sandbox_exec 'cat /proc/[0-9]*/cmdline 2>/dev/null | tr "\0" "\n"') @@ -428,7 +547,7 @@ else pass "Raw Discord token absent from sandbox filesystem" fi -section "Phase 6: Discord placeholder egress" +section "Phase 6: Discord REST placeholder egress" dc_api=$(sandbox_exec 'NODE_NO_WARNINGS=1 node -e " const fs = require(\"fs\"); @@ -477,7 +596,7 @@ except Exception: if [ "$dc_status" = "200" ]; then pass "Discord users/@me returned 200 with configured token" elif [ "$dc_status" = "401" ]; then - pass "Discord users/@me returned 401 - fake token reached Discord through the placeholder/proxy path" + pass "Discord users/@me returned 401 - REST path reached Discord; this is not gateway IDENTIFY auth proof" elif [ "$dc_error" = "timeout" ]; then skip "Discord API timed out" elif [ -n "$dc_error" ]; then @@ -486,7 +605,52 @@ else fail "Unexpected Discord API response: ${dc_api:0:300}" fi -section "Phase 7: Cleanup" +section "Phase 7: Discord gateway auth boundary" + +gateway_connected_status="" +for gw_attempt in $(seq 1 10); do + gateway_connected_status=$( + sandbox_exec_stdin 'python3 -' <<'PY' +import json +from pathlib import Path + +path = Path("/sandbox/.hermes/gateway_state.json") +try: + payload = json.loads(path.read_text(encoding="utf-8")) +except FileNotFoundError: + print("MISSING /sandbox/.hermes/gateway_state.json") +except Exception as exc: + print(f"ERROR reading gateway_state.json: {type(exc).__name__}: {exc}") +else: + platforms = payload.get("platforms") if isinstance(payload, dict) else {} + discord = platforms.get("discord") if isinstance(platforms, dict) else {} + if isinstance(discord, dict) and discord.get("state") == "connected": + print("CONNECTED") + else: + print(json.dumps( + { + "gateway_state": payload.get("gateway_state") if isinstance(payload, dict) else None, + "discord": discord if isinstance(discord, dict) else None, + }, + sort_keys=True, + )) +PY + ) + [ "$gateway_connected_status" = "CONNECTED" ] && break + if [ "$gw_attempt" -lt 10 ]; then + info "Gateway runtime status check attempt ${gw_attempt}/10 - waiting 3s..." + sleep 3 + fi +done +if [ "$gateway_connected_status" = "CONNECTED" ]; then + pass "Hermes Discord gateway reached READY through the fake local Gateway" +else + info "Hermes Discord runtime status: ${gateway_connected_status:0:400}" + fail "Hermes Discord gateway did not reach READY through the fake local Gateway" + dump_hermes_discord_diagnostics +fi + +section "Phase 8: Cleanup" if [[ "${NEMOCLAW_E2E_KEEP_SANDBOX:-}" != "1" ]]; then nemoclaw "$SANDBOX_NAME" destroy --yes 2>&1 | tail -3 || true diff --git a/test/e2e/test-messaging-providers.sh b/test/e2e/test-messaging-providers.sh index 9590fd88e8e..c7838ace9d1 100755 --- a/test/e2e/test-messaging-providers.sh +++ b/test/e2e/test-messaging-providers.sh @@ -938,12 +938,12 @@ else fi fi -# M13c: Full Discord gateway handshake via ws-proxy-fix CONNECT tunnel (#1570). +# M13c: Unauthenticated Discord gateway transport via ws-proxy-fix CONNECT tunnel (#1570). # The `ws` library opens WebSocket connections via https.request() with an # Upgrade: websocket header. The preload patches https.request() to issue a # CONNECT tunnel for Discord gateway hosts. # -# This test exercises the real Discord gateway protocol end-to-end: +# This test exercises the transport/protocol path, not bot authentication: # 1. https.request with Upgrade: websocket → CONNECT tunnel via proxy # 2. Receive Discord Hello (opcode 10) with heartbeat_interval # 3. Send a Heartbeat (opcode 1) back to the gateway @@ -951,7 +951,9 @@ fi # 5. Send close frame and disconnect cleanly # # If the CONNECT tunnel is broken the connection never upgrades (400 from L7 -# proxy) and none of the protocol steps succeed. +# proxy) and none of the protocol steps succeed. This deliberately does not +# send IDENTIFY, so it must not be treated as proof that placeholder tokens are +# rewritten inside gateway WebSocket payloads. dc_ws_tunnel=$(sandbox_exec 'node -e " const https = require(\"https\"); const crypto = require(\"crypto\"); @@ -1129,7 +1131,7 @@ else fi if echo "$dc_ws_tunnel" | grep -q "HEARTBEAT_ACK op=11"; then - pass "M13e: Sent Heartbeat, received ACK (opcode 11) — full round-trip verified" + pass "M13e: Sent Heartbeat, received ACK (opcode 11) — unauthenticated transport round-trip verified" elif echo "$dc_ws_tunnel" | grep -q "SENT_HEARTBEAT"; then fail "M13e: Sent Heartbeat but never received ACK" else diff --git a/test/generate-hermes-config.test.ts b/test/generate-hermes-config.test.ts index f22e070779c..e9db16e48c5 100644 --- a/test/generate-hermes-config.test.ts +++ b/test/generate-hermes-config.test.ts @@ -143,6 +143,9 @@ describe("agents/hermes/generate-config.ts", () => { expect(config.platforms.discord).toBeUndefined(); expect(JSON.stringify(config)).not.toContain("DISCORD_BOT_TOKEN"); expect(envFile).toContain("DISCORD_BOT_TOKEN=openshell:resolve:env:DISCORD_BOT_TOKEN\n"); + expect(envFile).toContain("DISCORD_PROXY=http://127.0.0.1:3129\n"); + expect(envFile).toContain("NEMOCLAW_DISCORD_FACADE_URL=http://127.0.0.1:3130\n"); + expect(envFile).toContain("NEMOCLAW_DISCORD_GUILD_IDS=1491590992753590594\n"); expect(envFile).toContain("DISCORD_ALLOWED_USERS=1005536447329222676\n"); }); diff --git a/test/hermes-discord-facade.test.ts b/test/hermes-discord-facade.test.ts new file mode 100644 index 00000000000..ede951fa39a --- /dev/null +++ b/test/hermes-discord-facade.test.ts @@ -0,0 +1,442 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +const ROOT = path.resolve(import.meta.dirname, ".."); +const FACADE = path.join(ROOT, "agents", "hermes", "discord-facade.py"); +const PRELOAD = path.join(ROOT, "agents", "hermes", "discord-preload", "sitecustomize.py"); +const sanitizedEnv = Object.fromEntries( + Object.entries(process.env).filter( + ([key, value]) => + value !== undefined && !key.startsWith("DISCORD_") && !key.startsWith("TELEGRAM_"), + ), +) as Record; +const hasCryptography = + spawnSync("python3", ["-c", "import cryptography.hazmat.primitives.asymmetric.ed25519"], { + env: sanitizedEnv, + stdio: "ignore", + }).status === 0; + +function runPython(source: string, env: Record = {}) { + return spawnSync("python3", ["-"], { + input: source, + encoding: "utf-8", + env: { + ...sanitizedEnv, + ...env, + }, + timeout: 10_000, + }); +} + +function pythonPrelude(): string { + return ` +import asyncio +import importlib.util +import json +import sys +import types + +aiohttp = types.ModuleType("aiohttp") + +class FakeResponse: + def __init__(self, *, status=200, body=b"", headers=None): + self.status = status + self.body = body + self.headers = headers or {} + +class FakeWeb: + Response = FakeResponse + class WebSocketResponse: + pass + class Request: + pass + @staticmethod + def json_response(data, status=200, dumps=json.dumps): + return FakeResponse(status=status, body=dumps(data).encode("utf-8"), headers={"Content-Type": "application/json"}) + +class FakeClientSession: + pass + +aiohttp.ClientSession = FakeClientSession +aiohttp.WSMsgType = types.SimpleNamespace(TEXT=1) +aiohttp.web = FakeWeb +sys.modules["aiohttp"] = aiohttp + +spec = importlib.util.spec_from_file_location("discord_facade", ${JSON.stringify(FACADE)}) +discord_facade = importlib.util.module_from_spec(spec) +sys.modules["discord_facade"] = discord_facade +spec.loader.exec_module(discord_facade) +`; +} + +describe("Hermes Discord facade", () => { + it("accepts only the OpenShell placeholder in local Gateway IDENTIFY frames", () => { + const result = runPython(`${pythonPrelude()} +class FakeWS: + def __init__(self): + self.sent = [] + self.closed = None + async def send_str(self, value): + self.sent.append(json.loads(value)) + async def close(self, code=None, message=b""): + self.closed = (code, message) + +async def main(): + facade = discord_facade.DiscordFacade( + host="127.0.0.1", + port=3130, + placeholder_token=discord_facade.DEFAULT_TOKEN_PLACEHOLDER, + upstream_proxy=None, + public_base_url=None, + public_key=None, + ) + good_ws = FakeWS() + good_peer = discord_facade.GatewayPeer(ws=good_ws) + await facade._handle_gateway_payload(good_peer, {"op": 2, "d": {"token": discord_facade.DEFAULT_TOKEN_PLACEHOLDER}}) + assert good_peer.identified is True + assert good_ws.sent[-1]["t"] == "READY" + assert good_ws.sent[-1]["d"]["user"]["bot"] is True + + bad_ws = FakeWS() + bad_peer = discord_facade.GatewayPeer(ws=bad_ws) + realish = "mfa.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA.BBBBBB.CCCCCCCCCCCCCCCCCCCCCCCCCCCCC" + await facade._handle_gateway_payload(bad_peer, {"op": 2, "d": {"token": realish}}) + assert bad_peer.identified is False + assert bad_ws.closed[0] == 4004 + +asyncio.run(main()) +`); + + expect(result.status, result.stderr || result.stdout).toBe(0); + }); + + it("forwards REST requests with the placeholder Authorization header intact", () => { + const result = runPython(`${pythonPrelude()} +class ForwardedResponse: + status = 202 + headers = {"Content-Type": "application/json"} + async def read(self): + return b'{"ok":true}' + +class ForwardContext: + async def __aenter__(self): + return ForwardedResponse() + async def __aexit__(self, exc_type, exc, tb): + return False + +class FakeSession: + def __init__(self): + self.calls = [] + def request(self, method, target, headers, data, proxy, allow_redirects): + self.calls.append({ + "method": method, + "target": target, + "headers": headers, + "data": data, + "proxy": proxy, + "allow_redirects": allow_redirects, + }) + return ForwardContext() + +class FakeRequest: + method = "POST" + path = "/api/v10/channels/123/messages" + path_qs = "/api/v10/channels/123/messages" + query_string = "" + headers = { + "Host": "127.0.0.1:3130", + "Authorization": "Bot openshell:resolve:env:DISCORD_BOT_TOKEN", + "Content-Type": "application/json", + "Content-Length": "2", + } + async def read(self): + return b"{}" + +async def main(): + session = FakeSession() + facade = discord_facade.DiscordFacade( + host="127.0.0.1", + port=3130, + placeholder_token=discord_facade.DEFAULT_TOKEN_PLACEHOLDER, + upstream_proxy="http://127.0.0.1:3129", + public_base_url=None, + public_key=None, + ) + facade._session = session + response = await facade._forward_rest(FakeRequest()) + assert response.status == 202 + assert len(session.calls) == 1 + call = session.calls[0] + assert call["target"] == "https://discord.com/api/v10/channels/123/messages" + assert call["headers"]["Authorization"] == "Bot openshell:resolve:env:DISCORD_BOT_TOKEN" + assert "Content-Length" not in call["headers"] + assert call["proxy"] == "http://127.0.0.1:3129" + +asyncio.run(main()) +`); + + expect(result.status, result.stderr || result.stdout).toBe(0); + }); + + it("synthesizes message, reaction, and thread Gateway events from REST polling", () => { + const result = runPython(`${pythonPrelude()} +from urllib.parse import urlparse + +state = { + "channels": [{"id": "200", "type": 0, "guild_id": "100"}], + "messages": [{ + "id": "300", + "channel_id": "200", + "guild_id": "100", + "content": "hello", + "author": {"id": "400", "username": "user", "discriminator": "0000"}, + "timestamp": "2026-05-07T00:00:00.000000+00:00", + "reactions": [{"count": 1, "emoji": {"name": "thumbsup"}}], + }], + "threads": [{"id": "500", "guild_id": "100", "parent_id": "200", "type": 11, "name": "thread"}], +} + +class PollResponse: + def __init__(self, payload): + self.status = 200 + self.headers = {"Content-Type": "application/json"} + self.payload = payload + async def read(self): + return json.dumps(self.payload).encode("utf-8") + +class PollContext: + def __init__(self, payload): + self.payload = payload + async def __aenter__(self): + return PollResponse(self.payload) + async def __aexit__(self, exc_type, exc, tb): + return False + +class PollSession: + def request(self, method, target, headers, proxy, allow_redirects): + path = urlparse(target).path + assert headers["Authorization"] == "Bot openshell:resolve:env:DISCORD_BOT_TOKEN" + if path == "/api/v10/guilds/100/channels": + return PollContext(state["channels"]) + if path == "/api/v10/channels/200/messages": + return PollContext(state["messages"]) + if path == "/api/v10/guilds/100/threads/active": + return PollContext({"threads": state["threads"]}) + raise AssertionError(path) + +async def main(): + facade = discord_facade.DiscordFacade( + host="127.0.0.1", + port=3130, + placeholder_token=discord_facade.DEFAULT_TOKEN_PLACEHOLDER, + upstream_proxy="http://127.0.0.1:3129", + public_base_url=None, + public_key=None, + ) + facade._session = PollSession() + facade._poll_guild_ids = {"100"} + events = [] + async def record(event_type, data): + events.append((event_type, data)) + facade.dispatch_to_all = record + + await facade._poll_once() + assert [event[0] for event in events] == ["MESSAGE_CREATE", "THREAD_CREATE"] + + events.clear() + state["messages"][0]["content"] = "hello edited" + state["messages"][0]["edited_timestamp"] = "2026-05-07T00:01:00.000000+00:00" + state["messages"][0]["reactions"][0]["count"] = 2 + await facade._poll_once() + event_names = [event[0] for event in events] + assert "MESSAGE_UPDATE" in event_names + assert "MESSAGE_REACTION_ADD" in event_names + + events.clear() + state["messages"] = [ + { + "id": str(1000 + index), + "channel_id": "200", + "guild_id": "100", + "content": f"page item {index}", + "author": {"id": "400", "username": "user", "discriminator": "0000"}, + "timestamp": "2026-05-07T00:02:00.000000+00:00", + "reactions": [], + } + for index in range(25) + ] + await facade._poll_once() + event_names = [event[0] for event in events] + assert "MESSAGE_DELETE" not in event_names + + events.clear() + state["messages"] = [] + state["threads"] = [] + await facade._poll_once() + event_names = [event[0] for event in events] + assert "MESSAGE_DELETE" in event_names + assert "THREAD_DELETE" in event_names + +asyncio.run(main()) +`); + + expect(result.status, result.stderr || result.stdout).toBe(0); + }); + + (hasCryptography ? it : it.skip)( + "validates Discord interaction signatures and keeps real interaction tokens out of Gateway payloads", + () => { + const result = runPython(`${pythonPrelude()} +import json +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from cryptography.hazmat.primitives import serialization + +private_key = Ed25519PrivateKey.generate() +public_key = private_key.public_key().public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw, +).hex() +timestamp = "1710000000" +body = b'{"type":2,"token":"real-interaction-token","id":"42"}' +signature = private_key.sign(timestamp.encode("utf-8") + body).hex() + +class FakeRequest: + headers = { + "X-Signature-Ed25519": signature, + "X-Signature-Timestamp": timestamp, + } + +facade = discord_facade.DiscordFacade( + host="127.0.0.1", + port=3130, + placeholder_token=discord_facade.DEFAULT_TOKEN_PLACEHOLDER, + upstream_proxy=None, + public_base_url=None, + public_key=public_key, +) +assert facade._verify_signature(FakeRequest(), body) is True +FakeRequest.headers["X-Signature-Ed25519"] = "00" * 64 +assert facade._verify_signature(FakeRequest(), body) is False + +localized = facade._localize_interaction_token(json.loads(body)) +assert localized["token"].startswith("nemoclaw-local-") +assert "real-interaction-token" not in json.dumps(localized) +assert facade._interaction_tokens[localized["token"]][0] == "real-interaction-token" +facade._interaction_tokens["expired-token"] = ("stale", 0.0) +facade._prune_interaction_tokens() +assert "expired-token" not in facade._interaction_tokens +`); + + expect(result.status, result.stderr || result.stdout).toBe(0); + }, + ); + + it("maps localized interaction callback tokens back to Discord before forwarding", () => { + const result = runPython(`${pythonPrelude()} +import asyncio + +class ForwardResponse: + status = 204 + headers = {"Content-Type": "application/json"} + async def read(self): + return b"" + +class ForwardContext: + async def __aenter__(self): + return ForwardResponse() + async def __aexit__(self, exc_type, exc, tb): + return False + +class ForwardSession: + def __init__(self): + self.calls = [] + def request(self, method, target, headers, data, proxy, allow_redirects): + self.calls.append({ + "method": method, + "target": target, + "headers": headers, + "data": data, + "proxy": proxy, + "allow_redirects": allow_redirects, + }) + return ForwardContext() + +class FakeRequest: + method = "POST" + path = "/api/v10/interactions/42/nemoclaw-local-token/callback" + path_qs = path + query_string = "" + headers = {"Authorization": "Bot placeholder", "Content-Type": "application/json"} + async def read(self): + return b'{"type":4,"data":{"content":"done"}}' + +async def main(): + facade = discord_facade.DiscordFacade( + host="127.0.0.1", + port=3130, + placeholder_token=discord_facade.DEFAULT_TOKEN_PLACEHOLDER, + upstream_proxy="http://127.0.0.1:3129", + public_base_url=None, + public_key=None, + ) + session = ForwardSession() + facade._session = session + facade._store_interaction_token("nemoclaw-local-token", "real-interaction-token") + response = await facade._handle_interaction_callback(FakeRequest(), "42", "nemoclaw-local-token") + assert response.status == 204 + assert len(session.calls) == 1 + assert session.calls[0]["target"] == "https://discord.com/api/v10/interactions/42/real-interaction-token/callback" + assert session.calls[0]["data"] == b'{"type":4,"data":{"content":"done"}}' + assert session.calls[0]["proxy"] == "http://127.0.0.1:3129" + +asyncio.run(main()) +`); + + expect(result.status, result.stderr || result.stdout).toBe(0); + }); +}); + +describe("Hermes Discord preload", () => { + it("rewrites discord.py REST and Gateway aiohttp calls to the local facade", () => { + const result = runPython(` +import asyncio +import importlib.util +import os +import sys +import types + +aiohttp = types.ModuleType("aiohttp") + +class ClientSession: + async def _request(self, method, url, **kwargs): + return {"method": method, "url": str(url), "kwargs": kwargs} + def ws_connect(self, url, **kwargs): + return {"url": str(url), "kwargs": kwargs} + +aiohttp.ClientSession = ClientSession +sys.modules["aiohttp"] = aiohttp +os.environ["NEMOCLAW_DISCORD_FACADE_URL"] = "http://127.0.0.1:3130" + +spec = importlib.util.spec_from_file_location("sitecustomize", ${JSON.stringify(PRELOAD)}) +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) + +async def main(): + session = aiohttp.ClientSession() + rest = await session._request("GET", "https://discord.com/api/v10/users/@me?x=1", proxy="http://proxy") + assert rest["url"] == "http://127.0.0.1:3130/api/v10/users/@me?x=1" + assert "proxy" not in rest["kwargs"] + ws = session.ws_connect("wss://gateway.discord.gg/?encoding=json", proxy="http://proxy") + assert ws["url"] == "ws://127.0.0.1:3130/gateway?encoding=json&v=10" + assert "proxy" not in ws["kwargs"] + +asyncio.run(main()) +`); + + expect(result.status, result.stderr || result.stdout).toBe(0); + }); +}); diff --git a/test/policies.test.ts b/test/policies.test.ts index 684981e0574..f3b6dec7c1b 100644 --- a/test/policies.test.ts +++ b/test/policies.test.ts @@ -763,6 +763,80 @@ describe("policies", () => { } }); + it("Hermes Discord gateway policy uses the OpenClaw L4 WebSocket tunnel shape", () => { + const policyFiles = [ + path.join(REPO_ROOT, "agents/hermes/policy-additions.yaml"), + path.join(REPO_ROOT, "agents/hermes/policy-permissive.yaml"), + ]; + + for (const file of policyFiles) { + const content = fs.readFileSync(file, "utf8"); + const gatewaySection = + content.split("host: gateway.discord.gg")[1]?.split("- host:")[0] ?? ""; + expect(gatewaySection).toContain("access: full"); + expect(gatewaySection).toContain("tls: skip"); + expect(gatewaySection).not.toContain("protocol: rest"); + expect(gatewaySection).not.toContain("rules:"); + } + }); + + it("Hermes Discord REST mutations are scoped to discord.com", () => { + const content = fs.readFileSync( + path.join(REPO_ROOT, "agents/hermes/policy-additions.yaml"), + "utf8", + ); + const parsed = YAML.parse(content); + const networkPolicies = parsed.network_policies as Record< + string, + { + endpoints?: Array<{ + host?: string; + rules?: Array<{ allow?: { method?: string; path?: string } }>; + }>; + } + >; + const rulesFor = (policy: string, host: string) => + (networkPolicies[policy]?.endpoints ?? []) + .filter((endpoint) => endpoint.host === host) + .flatMap((endpoint) => endpoint.rules ?? []) + .map((rule) => rule.allow) + .filter((rule): rule is { method: string; path: string } => + Boolean(rule?.method && rule?.path), + ); + const sortRules = (rules: Array<{ method: string; path: string }>) => + [...rules].sort((a, b) => + `${a.method} ${a.path}`.localeCompare(`${b.method} ${b.path}`), + ); + + const nousRules = rulesFor("nous_research", "nousresearch.com"); + expect(nousRules).not.toContainEqual({ method: "PUT", path: "/**" }); + expect(nousRules).not.toContainEqual({ method: "PATCH", path: "/**" }); + expect( + nousRules.filter((rule) => ["PUT", "PATCH", "DELETE"].includes(rule.method)), + ).toEqual([]); + + const discordMutationRules = sortRules( + rulesFor("discord", "discord.com").filter((rule) => + ["PUT", "PATCH", "DELETE"].includes(rule.method), + ), + ); + expect(discordMutationRules).toEqual( + sortRules([ + { method: "PUT", path: "/api/v*/applications/*/commands" }, + { method: "PUT", path: "/api/v*/channels/*/messages/*/reactions/*/@me" }, + { method: "PATCH", path: "/api/v*/applications/*" }, + { method: "PATCH", path: "/api/v*/applications/*/commands/*" }, + { method: "PATCH", path: "/api/v*/channels/*/messages/*" }, + { method: "PATCH", path: "/api/v*/webhooks/*/*/messages/*" }, + { method: "DELETE", path: "/api/v*/applications/*/commands/*" }, + { method: "DELETE", path: "/api/v*/channels/*/messages/*" }, + { method: "DELETE", path: "/api/v*/channels/*/messages/*/reactions/*/*" }, + { method: "DELETE", path: "/api/v*/webhooks/*/*/messages/*" }, + ]), + ); + expect(discordMutationRules.some((rule) => rule.path === "/**")).toBe(false); + }); + it("REST policy YAML avoids deprecated tls: terminate", () => { const agentsDir = path.join(REPO_ROOT, "agents"); const agentPolicyFiles = fs.existsSync(agentsDir) diff --git a/test/sandbox-init.test.ts b/test/sandbox-init.test.ts index 2e5ce4f8949..64a809f86b4 100644 --- a/test/sandbox-init.test.ts +++ b/test/sandbox-init.test.ts @@ -577,6 +577,51 @@ EOF expect(src).not.toContain("_PROXY_MARKER_BEGIN"); }); + it("hermes start.sh routes Discord through the local decode proxy", () => { + const src = readFileSync(join(import.meta.dirname, "../agents/hermes/start.sh"), "utf-8"); + expect(src).toContain('export DISCORD_PROXY="http://127.0.0.1:${DECODE_PROXY_PORT}"'); + expect(src).toContain('DISCORD_PROXY="http://127.0.0.1:${DECODE_PROXY_PORT}"'); + expect(src).toContain("start_discord_facade"); + expect(src).toContain('NEMOCLAW_DISCORD_FACADE_URL="http://127.0.0.1:${DISCORD_FACADE_PORT}"'); + expect(src).toContain("nemoclaw-discord-facade"); + }); + + it("hermes start.sh prepares the Discord facade log before child redirection", () => { + const src = readFileSync(join(import.meta.dirname, "../agents/hermes/start.sh"), "utf-8"); + const startFn = src.match(/start_discord_facade\(\) \{([\s\S]*?)^}/m); + expect(startFn).toBeTruthy(); + const body = startFn![1]; + expect(body).toContain('local log_path="/tmp/discord-facade.log"'); + expect(body).toContain('prepare_restricted_log "$log_path" gateway:gateway 600'); + expect(body).toContain('prepare_restricted_log "$log_path" "" 600'); + expect(body).toContain("gosu gateway sh -c"); + expect(body).toContain('exec "$@" >/tmp/discord-facade.log 2>&1'); + expect(body).not.toContain( + "gosu gateway python3 /usr/local/bin/nemoclaw-discord-facade >/tmp/discord-facade.log", + ); + expect(body).not.toContain( + "python3 /usr/local/bin/nemoclaw-discord-facade >/tmp/discord-facade.log", + ); + }); + + it("hermes start.sh launches the Discord facade and decode proxy under the Hermes venv interpreter", () => { + const src = readFileSync(join(import.meta.dirname, "../agents/hermes/start.sh"), "utf-8"); + expect(src).toContain('HERMES_VENV_PYTHON="/opt/hermes/.venv/bin/python"'); + + const facadeFn = src.match(/start_discord_facade\(\) \{([\s\S]*?)^}/m); + expect(facadeFn).toBeTruthy(); + const facadeBody = facadeFn![1]; + expect(facadeBody).toContain('"$HERMES_VENV_PYTHON" /usr/local/bin/nemoclaw-discord-facade'); + // Must not launch via bare python3 — that's the system interpreter. + expect(facadeBody).not.toMatch(/(? { const src = readFileSync(join(import.meta.dirname, "../agents/hermes/start.sh"), "utf-8"); expect(src).toContain("validate_tmp_permissions"); diff --git a/test/sandbox-provisioning.test.ts b/test/sandbox-provisioning.test.ts index 918d280d8d6..e8d8e26aa11 100644 --- a/test/sandbox-provisioning.test.ts +++ b/test/sandbox-provisioning.test.ts @@ -481,6 +481,14 @@ describe("Hermes sandbox provisioning", () => { expect(policySrc).toContain("- /opt/hermes"); expect(permissivePolicySrc).toContain("- /opt/hermes"); }); + + it("allowlists the Discord sitecustomize preload dir so Python can load the facade shim", () => { + const policySrc = fs.readFileSync(HERMES_POLICY, "utf-8"); + const permissivePolicySrc = fs.readFileSync(HERMES_POLICY_PERMISSIVE, "utf-8"); + + expect(policySrc).toContain("- /opt/nemoclaw-hermes-discord-preload"); + expect(permissivePolicySrc).toContain("- /opt/nemoclaw-hermes-discord-preload"); + }); }); describe("sandbox provisioning: gateway auth token externalization (#2378)", () => {