Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
8183149
fix(sandbox): give each Hermes sandbox its own OpenAI-compatible API …
laitingsheng Aug 7, 2026
0595532
fix(sandbox): resolve the Hermes API port for every host-side consumer
laitingsheng Aug 7, 2026
525b49e
Merge remote-tracking branch 'origin/main' into fix/hermes-api-port-p…
laitingsheng Aug 7, 2026
d1232f9
merge: resolve conflicts with main
github-actions[bot] Aug 7, 2026
fe72ed9
Merge remote-tracking branch 'origin/main' into fix/hermes-api-port-p…
laitingsheng Aug 9, 2026
38d5b86
Merge branch 'main' into fix/hermes-api-port-per-sandbox
cv Aug 9, 2026
cc177e7
fix(sandbox): keep the managed startup profile schema dependency-free
laitingsheng Aug 9, 2026
159b667
fix(sandbox): announce the sandbox API port from an API-kind dashboard
laitingsheng Aug 9, 2026
9120850
fix(sandbox): publish the Hermes API port in the same-uid topology
laitingsheng Aug 9, 2026
055fcc3
docs(sandbox): correct the Hermes API port override consequence
laitingsheng Aug 9, 2026
1b819cc
Merge remote-tracking branch 'origin/fix/hermes-api-port-per-sandbox'…
laitingsheng Aug 9, 2026
e929e2d
fix(sandbox): harden Hermes API port resolution
cv Aug 9, 2026
717a9c8
fix(sandbox): remove unused gateway import
cv Aug 9, 2026
a1ebcd1
test(installer): cover registered Hermes forward restore
cv Aug 9, 2026
d925fc8
docs(sandbox): correct the Hermes API port recreate remedy
laitingsheng Aug 9, 2026
a96a0e1
test(sandbox): cover Hermes port trust boundaries
cv Aug 9, 2026
b7502f7
test(sandbox): group Hermes probe coverage
cv Aug 9, 2026
a400cf2
Merge remote-tracking branch 'origin/fix/hermes-api-port-per-sandbox'…
laitingsheng Aug 9, 2026
bf43b74
Merge remote-tracking branch 'origin/fix/hermes-api-port-per-sandbox'…
laitingsheng Aug 9, 2026
3306685
Merge remote-tracking branch 'origin/main' into fix/hermes-api-port-p…
laitingsheng Aug 9, 2026
5a02138
test(hermes): cover the API port marker reader and resolver
laitingsheng Aug 9, 2026
e869820
test(sandbox): cover Hermes API range exhaustion before forced restore
laitingsheng Aug 9, 2026
ca256de
fix(docs): keep the headless token unset contract intact
laitingsheng Aug 9, 2026
98d4595
test(hermes): prove the marker safety checks and probe failure path
laitingsheng Aug 9, 2026
e46d21f
Merge branch 'main' into fix/hermes-api-port-per-sandbox
cv Aug 9, 2026
279f2f4
merge: resolve conflicts with main
github-actions[bot] Aug 10, 2026
9f3c07e
merge: resolve conflicts with main
github-actions[bot] Aug 10, 2026
7449608
fix(hermes): complete API port readiness gates
apurvvkumaria Aug 11, 2026
005c28c
merge: update #8577 with main
apurvvkumaria Aug 11, 2026
b0ff41f
test(hermes): close API port review gaps
prekshivyas Aug 11, 2026
e839d4a
test(e2e): preserve activation failure logs
apurvvkumaria Aug 11, 2026
3205b85
test(e2e): restrict activation failure artifacts
apurvvkumaria Aug 11, 2026
016ab43
Merge remote-tracking branch 'origin/main' into codex/fix-pr-8577
prekshivyas Aug 11, 2026
fc653dc
Merge remote-tracking branch 'origin/main' into codex/fix-pr-8577
prekshivyas Aug 11, 2026
1bdb0b0
Merge remote-tracking branch 'origin/main' into codex/fix-pr-8577
prekshivyas Aug 11, 2026
bac2935
merge: resolve conflicts with main
github-actions[bot] Aug 12, 2026
a3fc7ae
Merge remote-tracking branch 'origin/main' into fix/hermes-api-port-p…
laitingsheng Aug 12, 2026
490d916
docs(hermes): state the per-sandbox API port conditions
laitingsheng Aug 12, 2026
a5fd737
chore(sandbox): format the Hermes gateway log tail command
laitingsheng Aug 12, 2026
b69119a
Merge remote-tracking branch 'origin/main' into fix/hermes-api-port-p…
laitingsheng Aug 12, 2026
ab43d9d
test(sandbox): extract the gateway launch process helper
laitingsheng Aug 12, 2026
e7febca
fix(hermes): restore the runtime directory trust boundary
laitingsheng Aug 12, 2026
9a68d40
test(e2e): capture container logs through the audited shell probe
laitingsheng Aug 12, 2026
6234714
docs(hermes): state the API port override range
laitingsheng Aug 12, 2026
f85744f
merge: resolve conflicts with main
github-actions[bot] Aug 12, 2026
9675497
docs(e2e): clarify shell probe persistence
apurvvkumaria Aug 12, 2026
997ba63
merge: resolve conflicts with main
github-actions[bot] Aug 12, 2026
70da1ca
merge: resolve conflicts with main
github-actions[bot] Aug 12, 2026
182c640
merge: resolve conflicts with main
github-actions[bot] Aug 12, 2026
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
164 changes: 159 additions & 5 deletions agents/hermes/mcp-config-transaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
STRICT_HASH_PATH = "/etc/nemoclaw/hermes.config-hash"
GUARD_PATH = "/usr/local/lib/nemoclaw/hermes-runtime-config-guard.py"
ROOT_LIFECYCLE_MARKER = "/run/nemoclaw/hermes-root-lifecycle"
GATEWAY_PUBLIC_PORT_PATH = "/run/nemoclaw/hermes-api-port"
SERVICE_MANAGER_PATH = b"/usr/local/bin/nemoclaw-start"
RELOAD_TIMEOUT_SECONDS = 300
SERVER_NAME_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_-]{0,63}$")
Expand Down Expand Up @@ -93,7 +94,91 @@
MAX_ERROR_MESSAGE_LENGTH = 512
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
GATEWAY_INTERNAL_PORT = 18642


def _parse_gateway_public_port(raw: str) -> int:
"""Parse one allocated Hermes API port."""
if re.fullmatch(r"[0-9]+", raw) is None:
raise PermissionError("Hermes API port is malformed")
try:
port = int(raw, 10)
except ValueError as error:
raise PermissionError("Hermes API port is malformed") from error
if not 8642 <= port <= 8652:
raise PermissionError("Hermes API port is outside the allocated range")
return port


def _root_gateway_public_port_marker() -> int | None:
"""Read the root-owned API-port marker without following links."""
no_follow = getattr(os, "O_NOFOLLOW", 0)
if not no_follow:
raise PermissionError("Hermes API port marker cannot be opened safely")
flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | no_follow

try:
descriptor = os.open(GATEWAY_PUBLIC_PORT_PATH, flags)
except FileNotFoundError:
return None
except OSError as error:
raise PermissionError(
"Hermes API port marker cannot be opened safely"
) from error

try:
before = os.fstat(descriptor)
if (
not stat.S_ISREG(before.st_mode)
or before.st_uid != 0
or before.st_gid != 0
or stat.S_IMODE(before.st_mode) != 0o444
or before.st_nlink != 1
or before.st_size <= 0
or before.st_size > MAX_GATEWAY_PUBLIC_PORT_RECORD_BYTES
):
raise PermissionError("Hermes API port marker is unsafe")
raw = os.read(descriptor, MAX_GATEWAY_PUBLIC_PORT_RECORD_BYTES + 1)
after = os.fstat(descriptor)
if (
len(raw) != before.st_size
or len(raw) > MAX_GATEWAY_PUBLIC_PORT_RECORD_BYTES
or (
before.st_dev,
before.st_ino,
before.st_mode,
before.st_uid,
before.st_gid,
before.st_nlink,
before.st_size,
before.st_mtime_ns,
before.st_ctime_ns,
)
!= (
after.st_dev,
after.st_ino,
after.st_mode,
after.st_uid,
after.st_gid,
after.st_nlink,
after.st_size,
after.st_mtime_ns,
after.st_ctime_ns,
)
):
raise PermissionError("Hermes API port marker changed while reading")
finally:
os.close(descriptor)

try:
decoded = raw.decode("ascii").strip()
except UnicodeDecodeError as error:
raise PermissionError("Hermes API port marker is malformed") from error
return _parse_gateway_public_port(decoded)


GATEWAY_PUBLIC_PORT = 8642
TRUSTED_HERMES_GATEWAY_LAUNCHERS = {
b"/usr/local/bin/hermes.real",
Expand Down Expand Up @@ -955,6 +1040,73 @@ def _gateway_identity() -> tuple[int, object] | None:
return numeric_pid, start_time


def _read_service_manager_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")
return raw


def _service_manager_gateway_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")

prefix = b"NEMOCLAW_HERMES_API_PORT="
values = [
entry[len(prefix) :]
for entry in environment.split(b"\0")
if entry.startswith(prefix)
]
if len(values) > 1:
raise PermissionError("Hermes service-manager 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
return _parse_gateway_public_port(decoded)


def _resolve_gateway_public_port() -> int:
marker_port = _root_gateway_public_port_marker()
if marker_port is not None:
return marker_port
if os.geteuid() == 0:
raise PermissionError("Hermes root API port marker is unavailable")
identity = _gateway_identity()
if identity is None:
raise PermissionError("Hermes gateway identity is unavailable")
return _service_manager_gateway_public_port(identity)


def _configure_gateway_public_port() -> None:
global GATEWAY_PUBLIC_PORT
GATEWAY_PUBLIC_PORT = _resolve_gateway_public_port()


def _gateway_health_endpoint_ready(port: int, timeout_seconds: float = 2) -> bool:
connection = http.client.HTTPConnection("127.0.0.1", port, timeout=timeout_seconds)
try:
Expand All @@ -981,12 +1133,12 @@ def probe_timeout() -> float:
if internal_timeout <= 0 or not _gateway_health_endpoint_ready(
GATEWAY_INTERNAL_PORT, internal_timeout
):
return False, "waiting-for-internal-health-on-18642"
return False, "waiting-for-internal-health"
public_timeout = probe_timeout()
if public_timeout <= 0 or not _gateway_health_endpoint_ready(
GATEWAY_PUBLIC_PORT, public_timeout
):
return False, "waiting-for-public-relay-health-on-8642"
return False, "waiting-for-public-relay-health"
return True, "waiting-for-stable-replacement-identity"


Expand All @@ -1012,8 +1164,8 @@ def reload_gateway() -> bool:
re_kick_sent = False
phase_order = {
"waiting-for-replacement-identity": 0,
"waiting-for-internal-health-on-18642": 1,
"waiting-for-public-relay-health-on-8642": 2,
"waiting-for-internal-health": 1,
"waiting-for-public-relay-health": 2,
"waiting-for-stable-replacement-identity": 3,
}
last_safe_phase = "waiting-for-replacement-identity"
Expand Down Expand Up @@ -1053,7 +1205,7 @@ def reload_gateway() -> bool:
# The managed supervisor owns the public socat relay. Once the
# replacement gateway is internally healthy, another gateway
# signal cannot repair that relay and only creates crash churn.
and observed_phase != "waiting-for-public-relay-health-on-8642"
and observed_phase != "waiting-for-public-relay-health"
and current is not None
and _gateway_has_managed_parent(current[0])
and _gateway_identity() == current
Expand Down Expand Up @@ -1122,13 +1274,15 @@ def probe() -> dict[str, object]:
"""Prove the packaged helper is available without mutating config."""
if os.geteuid() != 0:
_assert_non_root_lifecycle_identity()
_configure_gateway_public_port()
return {"ok": True}


def execute(action: str, payload: dict[str, object]) -> dict[str, object]:
_validate_payload(action, payload)
if os.geteuid() != 0:
_assert_non_root_lifecycle_identity()
_configure_gateway_public_port()
return apply_transaction_and_reload(action, payload)


Expand Down
29 changes: 27 additions & 2 deletions agents/hermes/plugin/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -1039,6 +1039,30 @@ def _load_hermes_config():
return None


def _hermes_api_port():
"""Read the per-sandbox port the OpenAI-compatible API is exposed on.

NemoClaw allocates this port per sandbox so two Hermes sandboxes can serve
inference on one host. The plugin normally inherits the allocated value
from the managed supervisor. The root-separated topology also publishes a
root-owned marker for processes that do not inherit that environment.
"""
raw = os.environ.get("NEMOCLAW_HERMES_API_PORT", "").strip()
if not raw:
try:
with open("/run/nemoclaw/hermes-api-port") as f:
raw = f.read().strip()
except OSError:
return 8642
if re.fullmatch(r"[0-9]+", raw) is None:
return 8642
try:
port = int(raw)
except ValueError:
return 8642
return port if 8642 <= port <= 8652 else 8642


def _get_sandbox_info():
"""Gather sandbox status information."""
hermes_cfg = _load_hermes_config()
Expand All @@ -1059,10 +1083,11 @@ def _get_sandbox_info():
provider = nemoclaw_cfg.get("provider", provider)

# Check gateway health
api_port = _hermes_api_port()
gateway_ok = False
try:
result = subprocess.run(
["curl", "-sf", "http://localhost:8642/health"],
["curl", "-sf", f"http://localhost:{api_port}/health"],
capture_output=True,
text=True,
timeout=5,
Expand All @@ -1079,7 +1104,7 @@ def _get_sandbox_info():
"provider": provider,
"base_url": base_url,
"gateway": "running" if gateway_ok else "stopped",
"port": 8642,
"port": api_port,
}


Expand Down
Loading