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
24 changes: 24 additions & 0 deletions .github/workflows/nightly-e2e.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@
# agent state, and the same agent type running.
# hermes-e2e Hermes Agent E2E — install → onboard --agent hermes → health
# probe → live inference. Validates the multi-agent architecture.
# hermes-dashboard-e2e Hermes Agent E2E with optional web dashboard enabled,
# validating API/dashboard forwards and host reachability.
# hermes-root-entrypoint-smoke-e2e
# Builds the real Hermes image and verifies root entrypoint startup,
# gateway-user execution, v0.14 layout repair, and PID migration.
Expand Down Expand Up @@ -94,6 +96,7 @@ on:
token-rotation-e2e, sandbox-survival-e2e,
openshell-gateway-upgrade-e2e,
issue-2478-crash-loop-recovery-e2e, hermes-e2e,
hermes-dashboard-e2e,
hermes-root-entrypoint-smoke-e2e,
openclaw-onboard-security-posture-e2e,
hermes-onboard-security-posture-e2e,
Expand Down Expand Up @@ -685,6 +688,24 @@ jobs:
secrets:
NVIDIA_API_KEY: ${{ secrets.NVIDIA_API_KEY }}
BRAVE_API_KEY: ${{ secrets.BRAVE_API_KEY }}
hermes-dashboard-e2e:
if: >-
github.repository == 'NVIDIA/NemoClaw' && (github.event_name != 'workflow_dispatch' ||
inputs.jobs == '' ||
contains(format(',{0},', inputs.jobs), ',hermes-dashboard-e2e,'))
uses: ./.github/workflows/e2e-script.yaml
with:
ref: ${{ inputs.target_ref || github.ref }}
script: test/e2e/test-hermes-e2e.sh
timeout_minutes: 60
artifact_name: "hermes-dashboard-e2e-install-log"
artifact_path: "/tmp/nemoclaw-e2e-hermes-install.log"
env_json: '{"NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE":"1","NEMOCLAW_AGENT":"hermes","NEMOCLAW_E2E_HERMES_DASHBOARD":"1","NEMOCLAW_HERMES_DASHBOARD":"1","NEMOCLAW_NON_INTERACTIVE":"1","NEMOCLAW_RECREATE_SANDBOX":"1","NEMOCLAW_SANDBOX_NAME":"e2e-hermes-dashboard"}'
nvidia_api_key: true
github_token: true
secrets:
NVIDIA_API_KEY: ${{ secrets.NVIDIA_API_KEY }}
BRAVE_API_KEY: ${{ secrets.BRAVE_API_KEY }}
hermes-root-entrypoint-smoke-e2e:
if: >-
github.repository == 'NVIDIA/NemoClaw' && (github.event_name != 'workflow_dispatch' ||
Expand Down Expand Up @@ -1845,6 +1866,7 @@ jobs:
sandbox-survival-e2e,
issue-2478-crash-loop-recovery-e2e,
hermes-e2e,
hermes-dashboard-e2e,
hermes-root-entrypoint-smoke-e2e,
openclaw-onboard-security-posture-e2e,
hermes-onboard-security-posture-e2e,
Expand Down Expand Up @@ -1949,6 +1971,7 @@ jobs:
sandbox-survival-e2e,
issue-2478-crash-loop-recovery-e2e,
hermes-e2e,
hermes-dashboard-e2e,
hermes-root-entrypoint-smoke-e2e,
openclaw-onboard-security-posture-e2e,
hermes-onboard-security-posture-e2e,
Expand Down Expand Up @@ -2110,6 +2133,7 @@ jobs:
sandbox-survival-e2e,
issue-2478-crash-loop-recovery-e2e,
hermes-e2e,
hermes-dashboard-e2e,
hermes-root-entrypoint-smoke-e2e,
openclaw-onboard-security-posture-e2e,
hermes-onboard-security-posture-e2e,
Expand Down
11 changes: 8 additions & 3 deletions agents/hermes/Dockerfile.base
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ ENV DEBIAN_FRONTEND=noninteractive
# Calver tag v2026.5.16 = Hermes Agent v0.14.0.
ARG HERMES_VERSION=v2026.5.16
ARG HERMES_TARBALL_SHA256=c0a554050a50ee9a62f3fa5cd288a167ba5640c42d647d100cdea084b7294143
ARG HERMES_UV_EXTRAS="messaging web"
ARG HERMES_UV_EXTRAS="messaging web pty"
ARG UV_VERSION=0.11.8

RUN apt-get update && apt-get install -y --no-install-recommends \
Expand Down Expand Up @@ -154,8 +154,9 @@ RUN printf '%s\n' \

# Install Hermes Agent from the selected GitHub release.
# The image prebakes only the extras mapped to NemoClaw-supported onboarding
# integrations: messaging (Telegram, Discord, Slack, WeChat, WhatsApp) and
# web (API health/UI runtime). New Hermes integrations should be installed
# integrations: messaging (Telegram, Discord, Slack, WeChat, WhatsApp),
# web (API health/UI runtime), and pty (optional browser TUI bridge).
# New Hermes integrations should be installed
# by the agent workflow when they are enabled rather than shipped in the
# base image by default.
# Root Node dependencies provide Hermes browser tooling such as agent-browser.
Expand All @@ -175,6 +176,10 @@ RUN set -eu; \
done; \
uv sync --frozen --no-dev "$@" --no-cache \
&& npm ci --prefer-offline --no-audit --no-fund \
&& npm ci --prefix ui-tui --prefer-offline --no-audit --no-fund \
&& npm run build --prefix ui-tui \
&& npm ci --prefix web --prefer-offline --no-audit --no-fund \
&& npm run build --prefix web \
&& rm -rf /tmp/camoufox-* \
&& ln -sf /opt/hermes/.venv/bin/hermes /usr/local/bin/hermes \
&& ln -sf /opt/hermes/.venv/bin/hermes-agent /usr/local/bin/hermes-agent \
Expand Down
7 changes: 7 additions & 0 deletions agents/hermes/manifest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@ dashboard:
kind: api # "ui" or "api"
label: "OpenAI-compatible API"
path: "/v1" # appended to the base URL when displayed
dashboard_ui:
label: "Web dashboard"
port: 9119
path: "/"
enable_env: NEMOCLAW_HERMES_DASHBOARD
port_env: NEMOCLAW_HERMES_DASHBOARD_PORT
tui_env: NEMOCLAW_HERMES_DASHBOARD_TUI
forward_ports:
- 8642

Expand Down
151 changes: 143 additions & 8 deletions agents/hermes/start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
# - No device-pairing auto-pair watcher (Hermes has no browser pairing)
# - Config is YAML (config.yaml + .env) not JSON (openclaw.json)
# - Gateway listens on internal port 18642, socat forwards to 8642
# - Optional dashboard listens on internal port 19119, socat forwards to 9119
#
# SECURITY: The gateway runs as a separate user so the sandboxed agent cannot
# kill it or restart it with a tampered config. Config hash is verified at
Expand Down Expand Up @@ -113,6 +114,10 @@ PUBLIC_PORT=8642
# Hermes binds to 127.0.0.1 regardless of config (upstream bug).
# Run it on an internal port and use socat to expose on PUBLIC_PORT.
INTERNAL_PORT=18642
HERMES_DASHBOARD_ENABLED="${NEMOCLAW_HERMES_DASHBOARD:-0}"
HERMES_DASHBOARD_PUBLIC_PORT="${NEMOCLAW_HERMES_DASHBOARD_PORT:-9119}"
HERMES_DASHBOARD_INTERNAL_PORT="${NEMOCLAW_HERMES_DASHBOARD_INTERNAL_PORT:-19119}"
HERMES_DASHBOARD_TUI="${NEMOCLAW_HERMES_DASHBOARD_TUI:-0}"
HERMES="$(command -v hermes)" # Resolve once, use absolute path everywhere

# Hermes resolves config and runtime state relative to HERMES_HOME. The config
Expand All @@ -123,6 +128,61 @@ HERMES="$(command -v hermes)" # Resolve once, use absolute path everywhere
HERMES_DIR="/sandbox/.hermes"
HERMES_HASH_FILE="/etc/nemoclaw/hermes.config-hash"

truthy_env() {
case "$(printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]')" in
1 | true | yes | on) return 0 ;;
*) return 1 ;;
esac
}

validate_tcp_port() {
local name="$1"
local value="$2"
case "$value" in
'' | *[!0-9]*)
echo "[gateway] ERROR: ${name} must be an integer TCP port, got '${value}'" >&2
exit 1
;;
esac
if [ "$value" -lt 1024 ] || [ "$value" -gt 65535 ]; then
echo "[gateway] ERROR: ${name} must be between 1024 and 65535, got '${value}'" >&2
exit 1
fi
}

validate_port_configuration() {
validate_tcp_port PUBLIC_PORT "$PUBLIC_PORT"
validate_tcp_port INTERNAL_PORT "$INTERNAL_PORT"
validate_tcp_port HERMES_DASHBOARD_PUBLIC_PORT "$HERMES_DASHBOARD_PUBLIC_PORT"
validate_tcp_port HERMES_DASHBOARD_INTERNAL_PORT "$HERMES_DASHBOARD_INTERNAL_PORT"
if [ "$HERMES_DASHBOARD_PUBLIC_PORT" -eq "$PUBLIC_PORT" ]; then
echo "[gateway] ERROR: HERMES_DASHBOARD_PUBLIC_PORT must not equal PUBLIC_PORT (${PUBLIC_PORT})" >&2
exit 1
fi
if [ "$HERMES_DASHBOARD_INTERNAL_PORT" -eq "$INTERNAL_PORT" ]; then
echo "[gateway] ERROR: HERMES_DASHBOARD_INTERNAL_PORT must not equal INTERNAL_PORT (${INTERNAL_PORT})" >&2
exit 1
fi
if [ "$HERMES_DASHBOARD_PUBLIC_PORT" -eq "$INTERNAL_PORT" ]; then
echo "[gateway] ERROR: HERMES_DASHBOARD_PUBLIC_PORT must not equal INTERNAL_PORT (${INTERNAL_PORT})" >&2
exit 1
fi
if [ "$HERMES_DASHBOARD_INTERNAL_PORT" -eq "$PUBLIC_PORT" ]; then
echo "[gateway] ERROR: HERMES_DASHBOARD_INTERNAL_PORT must not equal PUBLIC_PORT (${PUBLIC_PORT})" >&2
exit 1
fi
}

validate_port_configuration

hermes_dashboard_enabled() {
truthy_env "$HERMES_DASHBOARD_ENABLED"
}

hermes_dashboard_tui_enabled() {
truthy_env "$HERMES_DASHBOARD_TUI"
}

# verify_config_integrity is provided by sandbox-init.sh (parameterized).

# configure_messaging_channels is provided by sandbox-init.sh (shared).
Expand All @@ -132,6 +192,9 @@ print_dashboard_urls() {
local_url="http://127.0.0.1:${PUBLIC_PORT}/v1"
echo "[gateway] Hermes API: ${local_url}" >&2
echo "[gateway] Health: ${local_url%/v1}/health" >&2
if hermes_dashboard_enabled; then
echo "[gateway] Dashboard: http://127.0.0.1:${HERMES_DASHBOARD_PUBLIC_PORT}/" >&2
fi
echo "[gateway] Connect any OpenAI-compatible frontend to this endpoint." >&2
}

Expand All @@ -140,6 +203,11 @@ start_gateway_log_stream() {
GATEWAY_LOG_TAIL_PID=$!
}

start_dashboard_log_stream() {
{ tail -n +1 -F /tmp/hermes-dashboard.log 2>/dev/null | sed -u 's/^/[dashboard-log:] /' >&2; } &
DASHBOARD_LOG_TAIL_PID=$!
}

retry_tirith_marker_if_needed() {
local marker="${HERMES_DIR}/.tirith-install-failed"
local reason
Expand Down Expand Up @@ -187,6 +255,8 @@ has_live_hermes_gateway() {

cleanup_orphan_socat_forwarders() {
local proc_root="${NEMOCLAW_PROC_ROOT:-/proc}"
local dashboard_internal="${HERMES_DASHBOARD_INTERNAL_PORT:-19119}"
local dashboard_public="${HERMES_DASHBOARD_PUBLIC_PORT:-9119}"
local cmdline_file pid cmdline

for cmdline_file in "${proc_root}"/[0-9]*/cmdline; do
Expand All @@ -198,6 +268,10 @@ cleanup_orphan_socat_forwarders() {
echo "[gateway] Removing orphaned socat forwarder for ${PUBLIC_PORT}->${INTERNAL_PORT} (pid ${pid})" >&2
kill "$pid" 2>/dev/null || true
;;
*socat*"TCP-LISTEN:${dashboard_public}"*"TCP:127.0.0.1:${dashboard_internal}"*)
echo "[gateway] Removing orphaned dashboard socat forwarder for ${dashboard_public}->${dashboard_internal} (pid ${pid})" >&2
kill "$pid" 2>/dev/null || true
;;
esac
done
}
Expand Down Expand Up @@ -328,23 +402,76 @@ cleanup_stale_hermes_gateway_runtime() {
# OpenShell needs the port accessible on 0.0.0.0 for port forwarding.
# socat bridges 0.0.0.0:PUBLIC_PORT → 127.0.0.1:INTERNAL_PORT.
SOCAT_PID=""
DASHBOARD_SOCAT_PID=""
start_socat_forwarder() {
local label="${1:-gateway}"
local public_port="${2:-$PUBLIC_PORT}"
local internal_port="${3:-$INTERNAL_PORT}"
local pid_var="${4:-SOCAT_PID}"

if ! command -v socat >/dev/null 2>&1; then
echo "[gateway] socat not available — port forwarding from host may not work" >&2
echo "[gateway] socat not available — ${label} port forwarding from host may not work" >&2
return
fi
local attempts=0
while [ "$attempts" -lt 30 ]; do
if ss -tln 2>/dev/null | grep -q "127.0.0.1:${INTERNAL_PORT}"; then
if ss -tln 2>/dev/null | grep -q "127.0.0.1:${internal_port}"; then
break
fi
sleep 1
attempts=$((attempts + 1))
done
nohup socat TCP-LISTEN:"${PUBLIC_PORT}",bind=0.0.0.0,fork,reuseaddr \
TCP:127.0.0.1:"${INTERNAL_PORT}" >/dev/null 2>&1 &
SOCAT_PID=$!
echo "[gateway] socat forwarder 0.0.0.0:${PUBLIC_PORT} → 127.0.0.1:${INTERNAL_PORT} (pid $SOCAT_PID)" >&2
nohup socat TCP-LISTEN:"${public_port}",bind=0.0.0.0,fork,reuseaddr \
TCP:127.0.0.1:"${internal_port}" >/dev/null 2>&1 &
printf -v "$pid_var" '%s' "$!"
echo "[gateway] ${label} socat forwarder 0.0.0.0:${public_port} → 127.0.0.1:${internal_port} (pid ${!pid_var})" >&2
}

build_hermes_dashboard_args() {
HERMES_DASHBOARD_ARGS=(
dashboard
--host
127.0.0.1
--port
"$HERMES_DASHBOARD_INTERNAL_PORT"
--skip-build
--no-open
)
if hermes_dashboard_tui_enabled; then
HERMES_DASHBOARD_ARGS+=(--tui)
fi
}

start_hermes_dashboard_current_user() {
hermes_dashboard_enabled || return 0

build_hermes_dashboard_args
prepare_restricted_log /tmp/hermes-dashboard.log "" 600
HERMES_HOME="${HERMES_DIR}" \
nohup "$HERMES" "${HERMES_DASHBOARD_ARGS[@]}" >/tmp/hermes-dashboard.log 2>&1 &
HERMES_DASHBOARD_PID=$!
echo "[gateway] hermes dashboard launched (pid $HERMES_DASHBOARD_PID)" >&2
start_dashboard_log_stream
start_socat_forwarder "dashboard" \
"$HERMES_DASHBOARD_PUBLIC_PORT" \
"$HERMES_DASHBOARD_INTERNAL_PORT" \
DASHBOARD_SOCAT_PID
}

start_hermes_dashboard_sandbox_user() {
hermes_dashboard_enabled || return 0

build_hermes_dashboard_args
prepare_restricted_log /tmp/hermes-dashboard.log sandbox:sandbox 600
HERMES_HOME="${HERMES_DIR}" \
nohup "${STEP_DOWN_PREFIX_SANDBOX[@]}" sh -c 'umask 0077; exec "$@" >/tmp/hermes-dashboard.log 2>&1' sh "$HERMES" "${HERMES_DASHBOARD_ARGS[@]}" &
HERMES_DASHBOARD_PID=$!
echo "[gateway] hermes dashboard launched as 'sandbox' user (pid $HERMES_DASHBOARD_PID)" >&2
start_dashboard_log_stream
start_socat_forwarder "dashboard" \
"$HERMES_DASHBOARD_PUBLIC_PORT" \
"$HERMES_DASHBOARD_INTERNAL_PORT" \
DASHBOARD_SOCAT_PID
}

# ── Messaging egress ─────────────────────────────────────────────
Expand Down Expand Up @@ -736,15 +863,19 @@ if [ "$(id -u)" -ne 0 ]; then
GATEWAY_PID=$!
echo "[gateway] hermes gateway launched (pid $GATEWAY_PID)" >&2
start_gateway_log_stream
start_hermes_dashboard_current_user
# NOTE: PIDs are collected after launch; a signal arriving between trap
# registration and the final append is a small race window (same as before
# the shared-library refactor). Acceptable for entrypoint-level cleanup.
SANDBOX_CHILD_PIDS=("$GATEWAY_PID")
[ -n "${GATEWAY_LOG_TAIL_PID:-}" ] && SANDBOX_CHILD_PIDS+=("$GATEWAY_LOG_TAIL_PID")
[ -n "${HERMES_DASHBOARD_PID:-}" ] && SANDBOX_CHILD_PIDS+=("$HERMES_DASHBOARD_PID")
[ -n "${DASHBOARD_LOG_TAIL_PID:-}" ] && SANDBOX_CHILD_PIDS+=("$DASHBOARD_LOG_TAIL_PID")
[ -n "${DASHBOARD_SOCAT_PID:-}" ] && SANDBOX_CHILD_PIDS+=("$DASHBOARD_SOCAT_PID")
# shellcheck disable=SC2034 # read by cleanup_on_signal from sandbox-init.sh
SANDBOX_WAIT_PID="$GATEWAY_PID"
trap cleanup_on_signal SIGTERM SIGINT
start_socat_forwarder
start_socat_forwarder "api" "$PUBLIC_PORT" "$INTERNAL_PORT" SOCAT_PID
[ -n "${SOCAT_PID:-}" ] && SANDBOX_CHILD_PIDS+=("$SOCAT_PID")
print_dashboard_urls

Expand Down Expand Up @@ -780,15 +911,19 @@ HERMES_HOME="${HERMES_DIR}" \
GATEWAY_PID=$!
echo "[gateway] hermes gateway launched as 'gateway' user (pid $GATEWAY_PID)" >&2
start_gateway_log_stream
start_hermes_dashboard_sandbox_user
# NOTE: PIDs are collected after launch; a signal arriving between trap
# registration and the final append is a small race window (same as before
# the shared-library refactor). Acceptable for entrypoint-level cleanup.
SANDBOX_CHILD_PIDS=("$GATEWAY_PID")
[ -n "${GATEWAY_LOG_TAIL_PID:-}" ] && SANDBOX_CHILD_PIDS+=("$GATEWAY_LOG_TAIL_PID")
[ -n "${HERMES_DASHBOARD_PID:-}" ] && SANDBOX_CHILD_PIDS+=("$HERMES_DASHBOARD_PID")
[ -n "${DASHBOARD_LOG_TAIL_PID:-}" ] && SANDBOX_CHILD_PIDS+=("$DASHBOARD_LOG_TAIL_PID")
[ -n "${DASHBOARD_SOCAT_PID:-}" ] && SANDBOX_CHILD_PIDS+=("$DASHBOARD_SOCAT_PID")
# shellcheck disable=SC2034 # read by cleanup_on_signal from sandbox-init.sh
SANDBOX_WAIT_PID="$GATEWAY_PID"
trap cleanup_on_signal SIGTERM SIGINT
start_socat_forwarder
start_socat_forwarder "api" "$PUBLIC_PORT" "$INTERNAL_PORT" SOCAT_PID
[ -n "${SOCAT_PID:-}" ] && SANDBOX_CHILD_PIDS+=("$SOCAT_PID")
print_dashboard_urls

Expand Down
Loading
Loading