From 29d08eb755e5665a31c33d9d1a96ffaebd9765fb Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Fri, 8 May 2026 14:52:02 -0700 Subject: [PATCH] fix(hermes): replay Discord facade connection fixes Replays the resolved diff from NVIDIA/NemoClaw#3238 onto a maintainer-owned branch. Signed-off-by: Aaron Erickson --- agents/hermes/discord-facade.py | 6 +++++- agents/hermes/discord-preload/sitecustomize.py | 14 ++++++++++++++ agents/hermes/policy-additions.yaml | 4 ++++ agents/hermes/policy-permissive.yaml | 1 + agents/hermes/start.sh | 9 ++++----- src/lib/agent/runtime.test.ts | 10 +++++++++- src/lib/agent/runtime.ts | 5 +++-- test/sandbox-init.test.ts | 18 ++++++++++++++++++ test/sandbox-provisioning.test.ts | 8 ++++++++ 9 files changed, 66 insertions(+), 9 deletions(-) diff --git a/agents/hermes/discord-facade.py b/agents/hermes/discord-facade.py index f089c5d8d0f..3e3151024bf 100755 --- a/agents/hermes/discord-facade.py +++ b/agents/hermes/discord-facade.py @@ -79,7 +79,10 @@ def _env_float(name: str, default: float) -> float: def _json_response(data: Any, status: int = 200) -> web.Response: - return web.json_response(data, status=status, dumps=lambda value: json.dumps(value, separators=(",", ":"))) + 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]: @@ -555,6 +558,7 @@ 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, diff --git a/agents/hermes/discord-preload/sitecustomize.py b/agents/hermes/discord-preload/sitecustomize.py index ac2443fda1d..9af27e396e0 100644 --- a/agents/hermes/discord-preload/sitecustomize.py +++ b/agents/hermes/discord-preload/sitecustomize.py @@ -45,6 +45,12 @@ def _rewrite_gateway_url(url: object) -> str | None: 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: @@ -52,6 +58,10 @@ async def _nemoclaw_request(self, method, str_or_url, **kwargs): 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): @@ -61,6 +71,10 @@ def _nemoclaw_ws_connect(self, url, **kwargs): 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 diff --git a/agents/hermes/policy-additions.yaml b/agents/hermes/policy-additions.yaml index d8b741d8705..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,6 +207,9 @@ 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/*" } diff --git a/agents/hermes/policy-permissive.yaml b/agents/hermes/policy-permissive.yaml index 668448e4392..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 diff --git a/agents/hermes/start.sh b/agents/hermes/start.sh index 932cd78d18d..5e473162435 100755 --- a/agents/hermes/start.sh +++ b/agents/hermes/start.sh @@ -260,12 +260,13 @@ start_socat_forwarder() { # sits between the Hermes process and the OpenShell proxy, URL-decoding # 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 @@ -286,17 +287,15 @@ start_discord_facade() { local log_path="/tmp/discord-facade.log" local launch_env=( "DISCORD_PROXY=${proxy_url}" - "HTTPS_PROXY=${proxy_url}" - "HTTP_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 python3 /usr/local/bin/nemoclaw-discord-facade & + 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 python3 /usr/local/bin/nemoclaw-discord-facade & + 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 diff --git a/src/lib/agent/runtime.test.ts b/src/lib/agent/runtime.test.ts index 1971d634728..c9bc88ecd9b 100644 --- a/src/lib/agent/runtime.test.ts +++ b/src/lib/agent/runtime.test.ts @@ -107,6 +107,14 @@ describe("buildRecoveryScript", () => { 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(); @@ -134,7 +142,7 @@ describe("buildRecoveryScript", () => { 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 python3 /usr/local/bin/nemoclaw-discord-facade &', + '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", diff --git a/src/lib/agent/runtime.ts b/src/lib/agent/runtime.ts index d35a40250e7..8f4ad00aa58 100644 --- a/src/lib/agent/runtime.ts +++ b/src/lib/agent/runtime.ts @@ -158,14 +158,15 @@ function hermesGatewayEnvPrefix(): string { } function hermesDecodeProxyRecoveryCommand(): string { + 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 python3 /usr/local/bin/nemoclaw-discord-facade &'; - return `if ! command -v ss >/dev/null 2>&1 || ! ${decodeProxyListening}; 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 && ${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;`; + `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/sandbox-init.test.ts b/test/sandbox-init.test.ts index becd7c70914..64a809f86b4 100644 --- a/test/sandbox-init.test.ts +++ b/test/sandbox-init.test.ts @@ -604,6 +604,24 @@ EOF ); }); + 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)", () => {