Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 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
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 @@ -1032,6 +1032,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 @@ -1052,10 +1076,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 @@ -1072,7 +1097,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
93 changes: 86 additions & 7 deletions agents/hermes/start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -170,8 +170,33 @@ else
fi
fi

if [ "$_dashboard_port" -eq 8642 ]; then
echo "[SECURITY] Invalid Hermes dashboard port 8642 - reserved for the Hermes OpenAI-compatible API" >&2
# The API port is a per-sandbox host resource: the host forwards the same
# number it is exposed on here, so two sandboxes on one host need two values.
# NemoClaw allocates the port and passes it in; the default keeps a sandbox
# whose create environment carries no value on the original port.
HERMES_DEFAULT_API_PORT=8642
HERMES_API_PORT_RANGE_END=8652
HERMES_RUNTIME_DIR=/run/nemoclaw
_api_port_raw="${NEMOCLAW_HERMES_API_PORT:-}"
if [ -z "$_api_port_raw" ]; then
PUBLIC_PORT="$HERMES_DEFAULT_API_PORT"
else
PUBLIC_PORT="$(printf '%s' "$_api_port_raw" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
_api_port_valid=1
case "$PUBLIC_PORT" in
*[!0-9]* | '') _api_port_valid=0 ;;
esac
if [ "$_api_port_valid" -eq 1 ] && { [ "$PUBLIC_PORT" -lt "$HERMES_DEFAULT_API_PORT" ] || [ "$PUBLIC_PORT" -gt "$HERMES_API_PORT_RANGE_END" ]; }; then
_api_port_valid=0
fi
if [ "$_api_port_valid" -ne 1 ]; then
echo "[SECURITY] Invalid NEMOCLAW_HERMES_API_PORT='${NEMOCLAW_HERMES_API_PORT}' - must be an integer from ${HERMES_DEFAULT_API_PORT} through ${HERMES_API_PORT_RANGE_END}" >&2
exit 1
fi
fi

if [ "$_dashboard_port" -eq "$PUBLIC_PORT" ]; then
echo "[SECURITY] Invalid Hermes dashboard port ${_dashboard_port} - reserved for the Hermes OpenAI-compatible API" >&2
exit 1
fi

Expand All @@ -181,7 +206,6 @@ else
CHAT_UI_URL="${CHAT_UI_URL:-http://127.0.0.1:${_dashboard_port}}"
fi

PUBLIC_PORT=8642
# Hermes binds the API server to 127.0.0.1. Run it on an internal port and
# use socat to expose the OpenAI-compatible API on PUBLIC_PORT.
INTERNAL_PORT=18642
Expand Down Expand Up @@ -2888,6 +2912,59 @@ prepare_hermes_nonroot_runtime() {
prepare_tirith_marker_retry || return 1
}

prepare_hermes_root_runtime_dir() {
local runtime_metadata
if [ -L "$HERMES_RUNTIME_DIR" ]; then
echo "[SECURITY] Refusing Hermes startup because $HERMES_RUNTIME_DIR is a symbolic link" >&2
return 1
fi
if [ ! -e "$HERMES_RUNTIME_DIR" ]; then
install -d -m 0755 -o root -g root -- "$HERMES_RUNTIME_DIR" || {
echo "[SECURITY] Refusing Hermes startup because $HERMES_RUNTIME_DIR could not be created safely" >&2
return 1
}
fi
if [ ! -d "$HERMES_RUNTIME_DIR" ] || [ -L "$HERMES_RUNTIME_DIR" ]; then
echo "[SECURITY] Refusing Hermes startup because $HERMES_RUNTIME_DIR is not a real directory" >&2
return 1
fi
runtime_metadata="$(stat -c '%u:%g:%a' -- "$HERMES_RUNTIME_DIR" 2>/dev/null)" || {
echo "[SECURITY] Refusing Hermes startup because $HERMES_RUNTIME_DIR metadata is unavailable" >&2
return 1
}
if [ "$runtime_metadata" != "0:0:755" ]; then
echo "[SECURITY] Refusing Hermes startup because $HERMES_RUNTIME_DIR must be root-owned with mode 0755" >&2
return 1
fi
return 0
}

publish_hermes_root_runtime_marker() {
local marker_name="$1"
local marker_value="$2"
local marker_path temporary_marker
case "$marker_name" in
'' | *[!A-Za-z0-9_-]*)
echo "[SECURITY] Refusing Hermes startup because the runtime marker name is invalid" >&2
return 1
;;
esac
prepare_hermes_root_runtime_dir || return 1
marker_path="${HERMES_RUNTIME_DIR}/${marker_name}"
temporary_marker="$(mktemp "${HERMES_RUNTIME_DIR}/.${marker_name}.XXXXXX")" || {
echo "[SECURITY] Refusing Hermes startup because ${marker_path} could not be prepared" >&2
return 1
}
if ! printf '%s\n' "$marker_value" >"$temporary_marker" \
|| ! chown root:root "$temporary_marker" \
|| ! chmod 0444 "$temporary_marker" \
|| ! mv -f -- "$temporary_marker" "$marker_path"; then
rm -f -- "$temporary_marker"
echo "[SECURITY] Refusing Hermes startup because ${marker_path} could not be published atomically" >&2
return 1
fi
}

prepare_hermes_root_runtime() {
verify_hermes_config_integrity || return 1
ensure_hermes_config_root_mode || return 1
Expand Down Expand Up @@ -3285,10 +3362,12 @@ fi
# add when the root-lifecycle marker identifies the legacy topology.
# removalCondition: remove this marker stamp when OpenShell unifies the topology
# or exposes an attested execution-identity capability.
install -d -m 0755 -o root -g root /run/nemoclaw
printf '%s\n' 'root-separated' >/run/nemoclaw/hermes-root-lifecycle
chown root:root /run/nemoclaw/hermes-root-lifecycle
chmod 0444 /run/nemoclaw/hermes-root-lifecycle
publish_hermes_root_runtime_marker hermes-root-lifecycle root-separated || exit 1

# SECURITY: publish the resolved API port as a root-owned read-only marker.
# Root-separated helpers read this marker. The temporary file receives its
# final ownership and mode before one atomic rename replaces any stale entry.
publish_hermes_root_runtime_marker hermes-api-port "$PUBLIC_PORT" || exit 1

# SECURITY: Protect gateway log from sandbox user tampering
prepare_restricted_log /tmp/gateway.log gateway:gateway 600
Expand Down
Loading
Loading