Skip to content
Merged
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
42 changes: 14 additions & 28 deletions agents/hermes/mcp-config-transaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@
MAX_GATEWAY_PID_RECORD_BYTES = 4096
MCP_RACE_RECOVERY_ATTEMPTS = 3
MAX_GATEWAY_PUBLIC_PORT_RECORD_BYTES = 16
MAX_SERVICE_MANAGER_ENVIRONMENT_BYTES = 64 * 1024
MAX_GATEWAY_ENVIRONMENT_BYTES = 64 * 1024
GATEWAY_INTERNAL_PORT = 18642


Expand Down Expand Up @@ -1040,36 +1040,24 @@ def _gateway_identity() -> tuple[int, object] | None:
return numeric_pid, start_time


def _read_service_manager_environment(pid: int) -> bytes:
def _read_gateway_environment(pid: int) -> bytes:
try:
with open(f"/proc/{pid}/environ", "rb") as environment_file:
raw = environment_file.read(MAX_SERVICE_MANAGER_ENVIRONMENT_BYTES + 1)
except FileNotFoundError as error:
raise PermissionError(
"Hermes service-manager environment is unavailable"
) from error
if len(raw) > MAX_SERVICE_MANAGER_ENVIRONMENT_BYTES:
raise PermissionError("Hermes service-manager environment is too large")
raw = environment_file.read(MAX_GATEWAY_ENVIRONMENT_BYTES + 1)
except OSError as error:
raise PermissionError("Hermes gateway environment is unavailable") from error
if len(raw) > MAX_GATEWAY_ENVIRONMENT_BYTES:
raise PermissionError("Hermes gateway environment is too large")
return raw


def _service_manager_gateway_public_port(
def _gateway_environment_public_port(
identity: tuple[int, object],
) -> int:
gateway_pid = identity[0]
manager_pid = _process_parent_pid(gateway_pid)
if manager_pid is None or not _is_service_manager_process(manager_pid):
raise PermissionError(
"Hermes gateway is not running under the managed service lifecycle"
)

environment = _read_service_manager_environment(manager_pid)
if (
_gateway_identity() != identity
or _process_parent_pid(gateway_pid) != manager_pid
or not _is_service_manager_process(manager_pid)
):
raise PermissionError("Hermes service-manager identity changed while reading")
environment = _read_gateway_environment(gateway_pid)
if _gateway_identity() != identity:
raise PermissionError("Hermes gateway identity changed while reading")

prefix = b"NEMOCLAW_HERMES_API_PORT="
values = [
Expand All @@ -1078,15 +1066,13 @@ def _service_manager_gateway_public_port(
if entry.startswith(prefix)
]
if len(values) > 1:
raise PermissionError("Hermes service-manager API port is ambiguous")
raise PermissionError("Hermes gateway API port is ambiguous")
if not values or not values[0]:
return 8642
try:
decoded = values[0].decode("ascii")
except UnicodeDecodeError as error:
raise PermissionError(
"Hermes service-manager API port is malformed"
) from error
raise PermissionError("Hermes gateway API port is malformed") from error
return _parse_gateway_public_port(decoded)


Expand All @@ -1099,7 +1085,7 @@ def _resolve_gateway_public_port() -> int:
identity = _gateway_identity()
if identity is None:
raise PermissionError("Hermes gateway identity is unavailable")
return _service_manager_gateway_public_port(identity)
return _gateway_environment_public_port(identity)


def _configure_gateway_public_port() -> None:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
},
{
"path": "agents/hermes/mcp-config-transaction.py",
"sha256": "01096bf959d07493ada4525ae824f3fabcde02fa6c02b8b56c16f227489dae0a"
"sha256": "e3e51798c242b7ed54c1dff8203d3e73dbc2b9fcb8c7d271292f6b41f08bdd90"
}
]
},
Expand Down
123 changes: 101 additions & 22 deletions test/hermes-mcp-api-port.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { spawnSync } from "node:child_process";
import { spawn, spawnSync } from "node:child_process";
import path from "node:path";
import { describe, expect, it } from "vitest";

Expand All @@ -12,7 +12,43 @@ const TRANSACTION = path.resolve(
);

describe("Hermes MCP API port resolution", () => {
it("accepts only allocated ports from the stable service-manager environment (#8543)", () => {
it("reads the port from a same-identity gateway process environment (#9044)", () => {
const gateway = spawn(process.execPath, ["-e", "setTimeout(() => {}, 10000)"], {
env: { NEMOCLAW_HERMES_API_PORT: "8645", PATH: process.env.PATH },
stdio: "ignore",
});

try {
expect(gateway.pid).toBeTypeOf("number");
const result = spawnSync(
"python3",
[
"-c",
`
import importlib.util, json, sys, types
sys.modules["yaml"] = types.SimpleNamespace(YAMLError=type("YAMLError", (Exception,), {}))
spec = importlib.util.spec_from_file_location("mcp_tx", sys.argv[1])
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
identity = (int(sys.argv[2]), 333)
module._gateway_identity = lambda: identity
print(json.dumps({"port": module._gateway_environment_public_port(identity)}))
`,
TRANSACTION,
String(gateway.pid),
],
{ encoding: "utf8" },
);

expect(result.status, result.stderr).toBe(0);
expect(JSON.parse(result.stdout)).toEqual({ port: 8645 });
} finally {
gateway.kill("SIGKILL");
}
});

it("reads allocated ports from the identity-bound gateway environment (#9044)", () => {
const result = spawnSync(
"python3",
[
Expand All @@ -26,17 +62,15 @@ sys.modules[spec.name] = module
spec.loader.exec_module(module)
identity = (41, 333)
module._gateway_identity = lambda: identity
module._process_parent_pid = lambda pid: 40
module._is_service_manager_process = lambda pid: True
opened = []
accepted = []
for raw in (b"8642", b"8645", b"8652"):
module._read_service_manager_environment = (
lambda pid, value=raw: b"PATH=/usr/bin\\0NEMOCLAW_HERMES_API_PORT="
+ value
+ b"\\0"
module._read_gateway_environment = (
lambda pid, value=raw: opened.append(pid)
or b"PATH=/usr/bin\\0NEMOCLAW_HERMES_API_PORT=" + value + b"\\0"
)
accepted.append(module._service_manager_gateway_public_port(identity))
accepted.append(module._gateway_environment_public_port(identity))
rejected = []
for raw in (
Expand All @@ -45,24 +79,25 @@ for raw in (
"²".encode("utf-8"),
b"8645\\0NEMOCLAW_HERMES_API_PORT=8646",
):
module._read_service_manager_environment = (
lambda pid, value=raw: b"NEMOCLAW_HERMES_API_PORT=" + value + b"\\0"
module._read_gateway_environment = (
lambda pid, value=raw: opened.append(pid)
or b"NEMOCLAW_HERMES_API_PORT=" + value + b"\\0"
)
try:
module._service_manager_gateway_public_port(identity)
module._gateway_environment_public_port(identity)
except PermissionError as error:
rejected.append(str(error))
module._read_service_manager_environment = lambda pid: b"PATH=/usr/bin\\0"
absent = module._service_manager_gateway_public_port(identity)
module._read_gateway_environment = lambda pid: opened.append(pid) or b"PATH=/usr/bin\\0"
absent = module._gateway_environment_public_port(identity)
module._gateway_identity = lambda: (41, 999)
module._read_service_manager_environment = (
lambda pid: b"NEMOCLAW_HERMES_API_PORT=8645\\0"
module._read_gateway_environment = (
lambda pid: opened.append(pid) or b"NEMOCLAW_HERMES_API_PORT=8645\\0"
)
identity_change = ""
try:
module._service_manager_gateway_public_port(identity)
module._gateway_environment_public_port(identity)
except PermissionError as error:
identity_change = str(error)
Expand All @@ -71,6 +106,7 @@ print(json.dumps({
"rejected": rejected,
"absent": absent,
"identity_change": identity_change,
"opened": opened,
}))
`,
TRANSACTION,
Expand All @@ -84,11 +120,54 @@ print(json.dumps({
rejected: [
"Hermes API port is outside the allocated range",
"Hermes API port is outside the allocated range",
"Hermes service-manager API port is malformed",
"Hermes service-manager API port is ambiguous",
"Hermes gateway API port is malformed",
"Hermes gateway API port is ambiguous",
],
absent: 8642,
identity_change: "Hermes service-manager identity changed while reading",
identity_change: "Hermes gateway identity changed while reading",
opened: Array(9).fill(41),
});
});

it("rejects an unavailable gateway environment without using another identity (#9044)", () => {
const result = spawnSync(
"python3",
[
"-c",
`
import builtins, importlib.util, json, sys, types
sys.modules["yaml"] = types.SimpleNamespace(YAMLError=type("YAMLError", (Exception,), {}))
spec = importlib.util.spec_from_file_location("mcp_tx", sys.argv[1])
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
opened = []
real_open = builtins.open
def denied(path, *args, **kwargs):
opened.append(path)
raise PermissionError("denied")
builtins.open = denied
message = ""
try:
module._read_gateway_environment(41)
except PermissionError as error:
message = str(error)
finally:
builtins.open = real_open
print(json.dumps({"message": message, "opened": opened}))
`,
TRANSACTION,
],
{ encoding: "utf8" },
);

expect(result.status, result.stderr).toBe(0);
expect(JSON.parse(result.stdout)).toEqual({
message: "Hermes gateway environment is unavailable",
opened: ["/proc/41/environ"],
});
});

Expand Down Expand Up @@ -227,7 +306,7 @@ print(json.dumps({
});
});

it("prefers the marker over the service-manager environment (#8543)", () => {
it("prefers the root marker over the gateway environment (#8543)", () => {
const result = spawnSync(
"python3",
[
Expand All @@ -241,7 +320,7 @@ sys.modules[spec.name] = module
spec.loader.exec_module(module)
module._gateway_identity = lambda: (41, 333)
module._service_manager_gateway_public_port = lambda identity: 8649
module._gateway_environment_public_port = lambda identity: 8649
module._root_gateway_public_port_marker = lambda: 8647
marker_wins = module._resolve_gateway_public_port()
Expand Down
2 changes: 1 addition & 1 deletion test/openshell-0.0.101-migration-review.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,7 @@ describe("OpenShell 0.0.101 migration review", () => {
},
{
path: "agents/hermes/mcp-config-transaction.py",
sha256: "01096bf959d07493ada4525ae824f3fabcde02fa6c02b8b56c16f227489dae0a",
sha256: "e3e51798c242b7ed54c1dff8203d3e73dbc2b9fcb8c7d271292f6b41f08bdd90",
},
],
});
Expand Down
Loading