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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion agents/hermes/discord-facade.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down Expand Up @@ -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,
Expand Down
14 changes: 14 additions & 0 deletions agents/hermes/discord-preload/sitecustomize.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,23 @@ 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:
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):
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions agents/hermes/policy-additions.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ filesystem_policy:
- /usr
- /lib
- /opt/hermes
- /opt/nemoclaw-hermes-discord-preload
- /proc
- /dev/urandom
- /app
Expand Down Expand Up @@ -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/*" }
Expand Down
1 change: 1 addition & 0 deletions agents/hermes/policy-permissive.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ filesystem_policy:
- /usr
- /lib
- /opt/hermes
- /opt/nemoclaw-hermes-discord-preload
- /proc
- /dev/urandom
- /app
Expand Down
9 changes: 4 additions & 5 deletions agents/hermes/start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
10 changes: 9 additions & 1 deletion src/lib/agent/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(/(?<![\w/])python3 \/usr\/local\/bin\/nemoclaw-decode-proxy/);
expect(script).not.toMatch(/(?<![\w/])python3 \/usr\/local\/bin\/nemoclaw-discord-facade/);
});

it("waits for Hermes proxy recovery ports only after ss finds them", () => {
const recoveryScript = buildRecoveryScript(hermesAgent, 8642);
expect(recoveryScript).not.toBeNull();
Expand Down Expand Up @@ -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",
Expand Down
5 changes: 3 additions & 2 deletions src/lib/agent/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;`;
}

/**
Expand Down
18 changes: 18 additions & 0 deletions test/sandbox-init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(/(?<![\w/"])python3 \/usr\/local\/bin\/nemoclaw-discord-facade/);

const decodeFn = src.match(/start_decode_proxy\(\) \{([\s\S]*?)^}/m);
expect(decodeFn).toBeTruthy();
const decodeBody = decodeFn![1];
expect(decodeBody).toContain('"$HERMES_VENV_PYTHON" /usr/local/bin/nemoclaw-decode-proxy');
expect(decodeBody).not.toMatch(/(?<![\w/"])python3 \/usr\/local\/bin\/nemoclaw-decode-proxy/);
});

it("hermes start.sh calls validate_tmp_permissions", () => {
const src = readFileSync(join(import.meta.dirname, "../agents/hermes/start.sh"), "utf-8");
expect(src).toContain("validate_tmp_permissions");
Expand Down
8 changes: 8 additions & 0 deletions test/sandbox-provisioning.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)", () => {
Expand Down
Loading