diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index 315fd0c50f6..653f4f4bd86 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -187,7 +187,8 @@ RUN set -eu; \ "$config_dir/workspace" \ "$config_dir/profiles" \ "$config_dir/cache" \ - "$config_dir/pairing"; \ + "$config_dir/pairing" \ + "$config_dir/runtime"; \ if [ -e "$data_dir" ] || [ -L "$data_dir" ]; then \ echo "ERROR: legacy data dir still exists after cleanup: $data_dir" >&2; \ exit 1; \ @@ -211,9 +212,28 @@ RUN set -eu; \ done; \ rm -rf /root/.cache/pip /sandbox/.cache \ && chown -R sandbox:sandbox /sandbox/.hermes \ + && chown gateway:sandbox /sandbox/.hermes/runtime \ && chmod 750 /sandbox/.hermes \ + && chmod 770 \ + /sandbox/.hermes/memories \ + /sandbox/.hermes/sessions \ + /sandbox/.hermes/skills \ + /sandbox/.hermes/plugins \ + /sandbox/.hermes/cron \ + /sandbox/.hermes/logs \ + /sandbox/.hermes/skins \ + /sandbox/.hermes/plans \ + /sandbox/.hermes/workspace \ + /sandbox/.hermes/profiles \ + /sandbox/.hermes/cache \ + /sandbox/.hermes/pairing \ + /sandbox/.hermes/runtime \ && chmod 640 /sandbox/.hermes/config.yaml \ - && chmod 640 /sandbox/.hermes/.env + && chmod 640 /sandbox/.hermes/.env \ + && for name in state.db state.db-wal state.db-shm gateway.pid gateway.lock gateway_state.json channel_directory.json; do \ + rm -f "/sandbox/.hermes/${name}"; \ + ln -s "runtime/${name}" "/sandbox/.hermes/${name}"; \ + done # Pin config hash at build time for integrity verification at startup. RUN mkdir -p /etc/nemoclaw \ diff --git a/agents/hermes/Dockerfile.base b/agents/hermes/Dockerfile.base index a31e4f25b63..5ac4f7c332a 100644 --- a/agents/hermes/Dockerfile.base +++ b/agents/hermes/Dockerfile.base @@ -4,7 +4,8 @@ # Hermes sandbox base image — expensive, rarely-changing layers. # # Contains: node:22-slim (OpenShell needs Node), apt packages, gosu, -# user/group setup, .hermes directory structure, Hermes CLI, and PyYAML. +# user/group setup, .hermes directory structure, Hermes CLI, and the +# dependencies for NemoClaw-supported Hermes integrations. # # Mirrors the OpenClaw Dockerfile.base structure but installs Hermes # (Python-based) instead of OpenClaw (Node-based). @@ -22,8 +23,11 @@ FROM node:22-slim@sha256:4f77a690f2f8946ab16fe1e791a3ac0667ae1c3575c3e4d0d4589e9 ENV DEBIAN_FRONTEND=noninteractive # Hermes version pinned for reproducibility. -# Calver tag v2026.4.13 = semver 0.9.0. -ARG HERMES_VERSION=v2026.4.13 +# Calver tag v2026.4.23 = semver 0.11.0. +ARG HERMES_VERSION=v2026.4.23 +ARG HERMES_TARBALL_SHA256=1ee1be80a2112b7edc581770cee8858e725ba110cc423979cd7102492504bc6b +ARG HERMES_UV_EXTRAS="messaging web" +ARG UV_VERSION=0.11.8 RUN apt-get update && apt-get install -y --no-install-recommends \ python3=3.11.2-1+b1 \ @@ -54,16 +58,18 @@ RUN arch="$(dpkg --print-architecture)" \ # Create sandbox user (matches OpenShell convention) and gateway user. # gateway is a member of the sandbox group so it can read Hermes config files -# with group-read permissions while the sandbox user remains the only writer. +# and write Hermes runtime state while config files remain non-group-writable. RUN groupadd -r sandbox \ && groupadd -r gateway \ && useradd -r -g gateway -G sandbox -d /sandbox -s /usr/sbin/nologin gateway \ && useradd -r -g sandbox -d /sandbox -s /bin/bash sandbox \ + && usermod -a -G sandbox root \ && mkdir -p /sandbox/.nemoclaw \ && chown -R sandbox:sandbox /sandbox -# Create .hermes with all state subdirs directly (mutable by default). -# No separate .hermes-data or symlink bridge. +# Create .hermes with mutable integration dirs plus a scoped runtime dir. +# Top-level gateway state names redirect into runtime so the config root can +# remain non-group-writable without reintroducing the old .hermes-data bridge. RUN mkdir -p /sandbox/.hermes/memories \ /sandbox/.hermes/sessions \ /sandbox/.hermes/skills \ @@ -76,14 +82,52 @@ RUN mkdir -p /sandbox/.hermes/memories \ /sandbox/.hermes/profiles \ /sandbox/.hermes/cache \ /sandbox/.hermes/pairing \ - && chown -R sandbox:sandbox /sandbox/.hermes + /sandbox/.hermes/runtime \ + && chown -R sandbox:sandbox /sandbox/.hermes \ + && chown gateway:sandbox /sandbox/.hermes/runtime \ + && chmod 750 /sandbox/.hermes \ + && chmod 770 \ + /sandbox/.hermes/memories \ + /sandbox/.hermes/sessions \ + /sandbox/.hermes/skills \ + /sandbox/.hermes/plugins \ + /sandbox/.hermes/cron \ + /sandbox/.hermes/logs \ + /sandbox/.hermes/skins \ + /sandbox/.hermes/plans \ + /sandbox/.hermes/workspace \ + /sandbox/.hermes/profiles \ + /sandbox/.hermes/cache \ + /sandbox/.hermes/pairing \ + /sandbox/.hermes/runtime \ + && for name in state.db state.db-wal state.db-shm gateway.pid gateway.lock gateway_state.json channel_directory.json; do \ + rm -f "/sandbox/.hermes/${name}"; \ + ln -s "runtime/${name}" "/sandbox/.hermes/${name}"; \ + done + +# Install Hermes Agent from the selected GitHub release. +# The image prebakes only the extras mapped to NemoClaw-supported onboarding +# integrations: messaging (Telegram, Discord, Slack) and web (API health/UI +# runtime). New Hermes integrations should be installed by the agent workflow +# when they are enabled rather than shipped in the base image by default. +RUN pip3 install --no-cache-dir --break-system-packages "uv==${UV_VERSION}" +RUN mkdir -p /opt/hermes \ + && curl -fsSL "https://github.com/NousResearch/hermes-agent/archive/refs/tags/${HERMES_VERSION}.tar.gz" -o /tmp/hermes.tar.gz \ + && printf '%s /tmp/hermes.tar.gz\n' "${HERMES_TARBALL_SHA256}" > /tmp/hermes.tar.gz.sha256 \ + && sha256sum -c /tmp/hermes.tar.gz.sha256 \ + && tar -xzf /tmp/hermes.tar.gz -C /opt/hermes --strip-components=1 \ + && rm /tmp/hermes.tar.gz /tmp/hermes.tar.gz.sha256 +WORKDIR /opt/hermes +# hadolint ignore=SC2086 +RUN set -eu; \ + set --; \ + for extra in ${HERMES_UV_EXTRAS}; do \ + set -- "$@" --extra "$extra"; \ + done; \ + uv sync --frozen --no-dev "$@" --no-cache \ + && ln -sf /opt/hermes/.venv/bin/hermes /usr/local/bin/hermes \ + && ln -sf /opt/hermes/.venv/bin/hermes-agent /usr/local/bin/hermes-agent \ + && ln -sf /opt/hermes/.venv/bin/hermes-acp /usr/local/bin/hermes-acp -# Install Hermes Agent from GitHub release. -# Hermes is not on PyPI — official install is via their install script or -# direct pip install from the release tarball. -# hadolint ignore=DL3013 -RUN pip3 install --no-cache-dir --break-system-packages \ - "hermes-agent @ https://github.com/NousResearch/hermes-agent/archive/refs/tags/${HERMES_VERSION}.tar.gz" \ - "pyyaml==6.0.3" \ - "python-telegram-bot>=21.0" \ - && hermes --version +ENV PATH="/usr/local/bin:/opt/hermes/.venv/bin:${PATH}" +RUN /usr/local/bin/hermes --version diff --git a/agents/hermes/manifest.yaml b/agents/hermes/manifest.yaml index 501464f94fb..506febe18c7 100644 --- a/agents/hermes/manifest.yaml +++ b/agents/hermes/manifest.yaml @@ -16,7 +16,7 @@ homepage: "https://github.com/NousResearch/hermes-agent" install_method: curl # curl install.sh | bash binary_path: /usr/local/bin/hermes version_command: "hermes --version" -expected_version: "2026.4.8" +expected_version: "2026.4.23" gateway_command: "hermes gateway run" # ── Health probe ──────────────────────────────────────────────── @@ -33,8 +33,9 @@ health_probe: # etc.) can connect at http://localhost:8642/v1. Auth is via a bearer # token in the Authorization header, not a URL fragment. # -# The optional `hermes dashboard` web UI (pip install hermes-agent[web]) -# listens on a different port and is not installed by default. +# NemoClaw prebakes Hermes' `web` extra so the API/health runtime is available. +# The optional `hermes dashboard` web UI listens on a different port and is not +# exposed by default. dashboard: kind: api # "ui" or "api" label: "OpenAI-compatible API" diff --git a/agents/hermes/policy-additions.yaml b/agents/hermes/policy-additions.yaml index f55dbd1f278..d97b85009be 100644 --- a/agents/hermes/policy-additions.yaml +++ b/agents/hermes/policy-additions.yaml @@ -17,6 +17,7 @@ filesystem_policy: read_only: - /usr - /lib + - /opt/hermes - /proc - /dev/urandom - /app diff --git a/agents/hermes/policy-permissive.yaml b/agents/hermes/policy-permissive.yaml index 0727fedb932..9537dfc2d7b 100644 --- a/agents/hermes/policy-permissive.yaml +++ b/agents/hermes/policy-permissive.yaml @@ -18,6 +18,7 @@ filesystem_policy: read_only: - /usr - /lib + - /opt/hermes - /proc - /dev/urandom - /app diff --git a/agents/hermes/start.sh b/agents/hermes/start.sh index 0046c0f16f6..79a5a57419c 100755 --- a/agents/hermes/start.sh +++ b/agents/hermes/start.sh @@ -39,6 +39,44 @@ fi # SECURITY: Lock down PATH export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" +# ── Early stderr/stdout capture ────────────────────────────────── +# Capture all entrypoint output to /tmp/nemoclaw-start.log so startup +# failures before /tmp/gateway.log exists are still diagnosable. +prepare_restricted_log() { + local path="$1" + local owner="${2:-}" + local mode="${3:-600}" + local dir base tmp + + dir="$(dirname "$path")" + base="$(basename "$path")" + tmp="$(mktemp "${dir}/.${base}.tmp.XXXXXX")" || return 1 + : >"$tmp" || { + rm -f "$tmp" + return 1 + } + if [ "$(id -u)" -eq 0 ] && [ -n "$owner" ] && ! chown "$owner" "$tmp"; then + rm -f "$tmp" + return 1 + fi + if ! chmod "$mode" "$tmp"; then + rm -f "$tmp" + return 1 + fi + if ! mv -f "$tmp" "$path"; then + rm -f "$tmp" + return 1 + fi +} + +_START_LOG="/tmp/nemoclaw-start.log" +if [ "$(id -u)" -eq 0 ]; then + prepare_restricted_log "$_START_LOG" root:root 600 +else + prepare_restricted_log "$_START_LOG" "" 600 +fi +exec > >(tee -a "$_START_LOG") 2> >(tee -a "$_START_LOG" >&2) + # ── Drop unnecessary Linux capabilities (shared) ──────────────── drop_capabilities /usr/local/bin/nemoclaw-start "$@" @@ -77,9 +115,10 @@ PUBLIC_PORT=8642 INTERNAL_PORT=18642 HERMES="$(command -v hermes)" # Resolve once, use absolute path everywhere -# Hermes writes state files (PID, state.db, .channel_directory) directly into -# HERMES_HOME alongside config. Config is mutable by default for the sandbox user -# and group-readable by the gateway user. Immutability is opt-in via `shields up`. +# Hermes resolves config and runtime state relative to HERMES_HOME. The config +# root is mutable by the sandbox owner and readable by the gateway group, while +# gateway-created top-level state is redirected to a scoped runtime directory. +# Immutability is opt-in via `shields up`. HERMES_DIR="/sandbox/.hermes" HERMES_HASH_FILE="/etc/nemoclaw/hermes.config-hash" @@ -186,6 +225,11 @@ print_dashboard_urls() { echo "[gateway] Connect any OpenAI-compatible frontend to this endpoint." >&2 } +start_gateway_log_stream() { + { tail -n +1 -F /tmp/gateway.log 2>/dev/null | sed -u 's/^/[gateway-log:] /' >&2; } & + GATEWAY_LOG_TAIL_PID=$! +} + # ── socat forwarder ────────────────────────────────────────────── # Hermes API server binds to 127.0.0.1 regardless of config (upstream bug). # OpenShell needs the port accessible on 0.0.0.0 for port forwarding. @@ -453,9 +497,7 @@ if [ "$(id -u)" -ne 0 ]; then exec "${NEMOCLAW_CMD[@]}" fi - # TODO(#2277-P2): migrate to shared emit_restricted_log() helper - touch /tmp/gateway.log - chmod 600 /tmp/gateway.log + prepare_restricted_log /tmp/gateway.log "" 600 # Defence-in-depth: verify /tmp file permissions before launching services. # shellcheck disable=SC2119 @@ -471,11 +513,13 @@ if [ "$(id -u)" -ne 0 ]; then nohup "$HERMES" gateway run >/tmp/gateway.log 2>&1 & GATEWAY_PID=$! echo "[gateway] hermes gateway launched (pid $GATEWAY_PID)" >&2 + start_gateway_log_stream # 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 "${DECODE_PROXY_PID:-}" ] && SANDBOX_CHILD_PIDS+=("$DECODE_PROXY_PID") + [ -n "${GATEWAY_LOG_TAIL_PID:-}" ] && SANDBOX_CHILD_PIDS+=("$GATEWAY_LOG_TAIL_PID") # shellcheck disable=SC2034 # read by cleanup_on_signal from sandbox-init.sh SANDBOX_WAIT_PID="$GATEWAY_PID" trap cleanup_on_signal SIGTERM SIGINT @@ -498,10 +542,7 @@ if [ ${#NEMOCLAW_CMD[@]} -gt 0 ]; then fi # SECURITY: Protect gateway log from sandbox user tampering -# TODO(#2277-P2): migrate to shared emit_restricted_log() helper -touch /tmp/gateway.log -chown gateway:gateway /tmp/gateway.log -chmod 600 /tmp/gateway.log +prepare_restricted_log /tmp/gateway.log gateway:gateway 600 # Defence-in-depth: verify /tmp file permissions before launching services. # shellcheck disable=SC2119 @@ -514,14 +555,16 @@ HERMES_HOME="${HERMES_DIR}" \ HTTP_PROXY="http://127.0.0.1:${DECODE_PROXY_PORT}" \ https_proxy="http://127.0.0.1:${DECODE_PROXY_PORT}" \ http_proxy="http://127.0.0.1:${DECODE_PROXY_PORT}" \ - nohup gosu gateway "$HERMES" gateway run >/tmp/gateway.log 2>&1 & + nohup gosu gateway sh -c 'exec "$@" >/tmp/gateway.log 2>&1' sh "$HERMES" gateway run & GATEWAY_PID=$! echo "[gateway] hermes gateway launched as 'gateway' user (pid $GATEWAY_PID)" >&2 +start_gateway_log_stream # 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 "${DECODE_PROXY_PID:-}" ] && SANDBOX_CHILD_PIDS+=("$DECODE_PROXY_PID") +[ -n "${GATEWAY_LOG_TAIL_PID:-}" ] && SANDBOX_CHILD_PIDS+=("$GATEWAY_LOG_TAIL_PID") # shellcheck disable=SC2034 # read by cleanup_on_signal from sandbox-init.sh SANDBOX_WAIT_PID="$GATEWAY_PID" trap cleanup_on_signal SIGTERM SIGINT diff --git a/test/e2e/test-hermes-e2e.sh b/test/e2e/test-hermes-e2e.sh index f95819cb09b..21328caf9e9 100755 --- a/test/e2e/test-hermes-e2e.sh +++ b/test/e2e/test-hermes-e2e.sh @@ -53,6 +53,42 @@ section() { } info() { printf '\033[1;34m [info]\033[0m %s\n' "$1"; } +dump_hermes_diagnostics() { + info "--- Hermes sandbox diagnostics ---" + if ! command -v openshell >/dev/null 2>&1; then + info "openshell is not available for sandbox diagnostics" + return + fi + + local sandboxes diag_output diag_script + sandboxes=$(openshell sandbox list 2>&1 || true) + info "openshell sandbox list:" + echo "$sandboxes" | tail -20 | while IFS= read -r line; do + info " $line" + done + + if ! grep -Fq -- "$SANDBOX_NAME" <<<"$sandboxes"; then + info "sandbox '${SANDBOX_NAME}' is not visible to openshell" + return + fi + + diag_script='set +e' + diag_script+='; echo "== identity =="; id 2>&1 || true' + diag_script+='; echo "== listening sockets =="; ss -tlnp 2>&1 || ss -tln 2>&1 || true' + diag_script+='; echo "== log and state paths =="; ls -ld /tmp /sandbox/.hermes /sandbox/.hermes/logs 2>&1 || true; ls -l /tmp/nemoclaw-start.log /tmp/gateway.log 2>&1 || true' + diag_script+='; echo "== hermes-related processes =="' + # shellcheck disable=SC2016 # script is intentionally evaluated inside the sandbox + diag_script+='; for p in /proc/[0-9]*; do cmd=$(tr "\000" " " < "$p/cmdline" 2>/dev/null || true); case "$cmd" in *hermes*|*socat*|*nemoclaw-decode-proxy*) echo "$(basename "$p") $cmd" ;; esac; done' + diag_script+='; echo "== /tmp/nemoclaw-start.log tail =="; tail -n 80 /tmp/nemoclaw-start.log 2>&1 || true' + diag_script+='; echo "== /tmp/gateway.log tail =="; tail -n 120 /tmp/gateway.log 2>&1 || true' + diag_output=$(openshell sandbox exec -n "$SANDBOX_NAME" -- sh -lc "$diag_script" 2>&1 || true) + + echo "$diag_output" | while IFS= read -r line; do + info " $line" + done + info "--- End Hermes sandbox diagnostics ---" +} + # Parse chat completion response — handles both content and reasoning_content # (nemotron-3-super is a reasoning model that may put output in reasoning_content) parse_chat_content() { @@ -196,6 +232,7 @@ if [ $install_exit -eq 0 ]; then pass "install.sh completed (exit 0)" else fail "install.sh failed (exit $install_exit)" + dump_hermes_diagnostics exit 1 fi diff --git a/test/e2e/test-rebuild-hermes.sh b/test/e2e/test-rebuild-hermes.sh index 7183c877a7e..611de52e8f7 100755 --- a/test/e2e/test-rebuild-hermes.sh +++ b/test/e2e/test-rebuild-hermes.sh @@ -5,7 +5,7 @@ # Hermes rebuild upgrade E2E — same upgrade scenario as OpenClaw but for Hermes: # # 1. Install NemoClaw (install.sh) -# 2. Build a Hermes base image with an OLDER version (v2026.3.12) +# 2. Build a Hermes base image with an OLDER version (v2026.4.13) # 3. Build a minimal Hermes sandbox image (no current-Dockerfile patches) # 4. Create sandbox via openshell directly # 5. Write marker files into Hermes state dirs @@ -30,7 +30,9 @@ SANDBOX_NAME="${NEMOCLAW_SANDBOX_NAME:-e2e-rebuild-hm}" . "$(dirname "${BASH_SOURCE[0]}")/lib/sandbox-teardown.sh" register_sandbox_for_teardown "$SANDBOX_NAME" -OLD_HERMES_VERSION="v2026.3.12" +OLD_HERMES_VERSION="v2026.4.13" +OLD_HERMES_REGISTRY_VERSION="${OLD_HERMES_VERSION#v}" +OLD_HERMES_TARBALL_SHA256="5e4529b8cb6e4821eb916b81517e48125109b1764d6d1e68a204a9f0ddf2d98c" MARKER_FILE="/sandbox/.hermes/memories/rebuild-marker.txt" MARKER_CONTENT="REBUILD_HM_E2E_$(date +%s)" REGISTRY_FILE="$HOME/.nemoclaw/sandboxes.json" @@ -49,12 +51,38 @@ fail() { echo -e "${YELLOW}[DIAG]${NC} Session: $(cat "${SESSION_FILE}" 2>/dev/null || echo 'not found')" >&2 echo -e "${YELLOW}[DIAG]${NC} Sandboxes: $(openshell sandbox list 2>&1 || echo 'openshell unavailable')" >&2 echo -e "${YELLOW}[DIAG]${NC} Docker: $(docker ps --format '{{.Names}} {{.Image}} {{.Status}}' 2>&1 | head -5)" >&2 + dump_hermes_sandbox_logs >&2 || true echo -e "${YELLOW}[DIAG]${NC} --- End diagnostics ---" >&2 exit 1 } info() { echo -e "${YELLOW}[INFO]${NC} $1"; } diag() { echo -e "${YELLOW}[DIAG]${NC} $1"; } +dump_hermes_sandbox_logs() { + command -v openshell >/dev/null 2>&1 || { + diag "openshell is not available for sandbox log diagnostics" + return + } + openshell sandbox list 2>&1 | grep -Fq -- "$SANDBOX_NAME" || { + diag "sandbox '${SANDBOX_NAME}' is not visible to openshell" + return + } + + local diag_script + diag_script='set +e' + diag_script+='; echo "== identity =="; id 2>&1 || true' + diag_script+='; echo "== listening sockets =="; ss -tlnp 2>&1 || ss -tln 2>&1 || true' + diag_script+='; echo "== log and state paths =="; ls -ld /tmp /sandbox/.hermes /sandbox/.hermes/logs 2>&1 || true; ls -l /tmp/nemoclaw-start.log /tmp/gateway.log 2>&1 || true' + diag_script+='; echo "== hermes-related processes =="' + # shellcheck disable=SC2016 # script is intentionally evaluated inside the sandbox + diag_script+='; for p in /proc/[0-9]*; do cmd=$(tr "\000" " " < "$p/cmdline" 2>/dev/null || true); case "$cmd" in *hermes*|*socat*|*nemoclaw-decode-proxy*) echo "$(basename "$p") $cmd" ;; esac; done' + diag_script+='; echo "== /tmp/nemoclaw-start.log tail =="; tail -n 80 /tmp/nemoclaw-start.log 2>&1 || true' + diag_script+='; echo "== /tmp/gateway.log tail =="; tail -n 120 /tmp/gateway.log 2>&1 || true' + + diag "Hermes sandbox runtime logs:" + openshell sandbox exec -n "$SANDBOX_NAME" -- sh -lc "$diag_script" 2>&1 | sed 's/^/[DIAG] /' +} + export NEMOCLAW_REBUILD_VERBOSE=1 # ── Preflight ─────────────────────────────────────────────────────── @@ -110,6 +138,8 @@ OLD_BASE_TAG="nemoclaw-hermes-old-base:e2e-rebuild" docker build \ --build-arg "HERMES_VERSION=${OLD_HERMES_VERSION}" \ + --build-arg "HERMES_TARBALL_SHA256=${OLD_HERMES_TARBALL_SHA256}" \ + --build-arg "HERMES_UV_EXTRAS=messaging" \ -f "${REPO_ROOT}/agents/hermes/Dockerfile.base" \ -t "${OLD_BASE_TAG}" \ "${REPO_ROOT}" \ @@ -170,7 +200,7 @@ reg = {'sandboxes': {'${SANDBOX_NAME}': { 'policies': [], 'policyTier': None, 'agent': 'hermes', - 'agentVersion': '2026.3.12' + 'agentVersion': '${OLD_HERMES_REGISTRY_VERSION}' }}, 'defaultSandbox': '${SANDBOX_NAME}'} with open('${REGISTRY_FILE}', 'w') as f: json.dump(reg, f, indent=2) @@ -249,10 +279,10 @@ with open('${REGISTRY_FILE}') as f: sb = data.get('sandboxes', {}).get('${SANDBOX_NAME}', {}) print(sb.get('agentVersion', 'null')) " 2>/dev/null || echo "error") -if [ "$REGISTRY_VERSION" != "null" ] && [ "$REGISTRY_VERSION" != "error" ] && [ "$REGISTRY_VERSION" != "2026.3.12" ]; then +if [ "$REGISTRY_VERSION" != "null" ] && [ "$REGISTRY_VERSION" != "error" ] && [ "$REGISTRY_VERSION" != "$OLD_HERMES_REGISTRY_VERSION" ]; then pass "Registry agentVersion updated to ${REGISTRY_VERSION}" else - fail "Registry agentVersion not updated: got '${REGISTRY_VERSION}', expected != '2026.3.12'" + fail "Registry agentVersion not updated: got '${REGISTRY_VERSION}', expected != '${OLD_HERMES_REGISTRY_VERSION}'" fi # No credentials in backup diff --git a/test/e2e/test-runtime-overrides.sh b/test/e2e/test-runtime-overrides.sh index 129f2318da2..63eb32ccbf1 100755 --- a/test/e2e/test-runtime-overrides.sh +++ b/test/e2e/test-runtime-overrides.sh @@ -51,7 +51,7 @@ info "Logging Docker stderr to: $LOG_FILE" run_override() { local env_args=("$@") docker run --rm "${env_args[@]}" "$IMAGE" \ - bash -c 'cat /sandbox/.openclaw/openclaw.json' 2>>"$LOG_FILE" + bash -c 'cat /sandbox/.openclaw/openclaw.json; printf "\n"' 2>>"$LOG_FILE" } # Helper: run entrypoint with env vars and capture stderr for validation messages. @@ -172,7 +172,8 @@ NEW_LEN=$(echo "$CFG" | jq '.gateway.controlUi.allowedOrigins | length') if [ "$HAS_ORIGIN" = "true" ] && [ "$NEW_LEN" -gt "$BASELINE_ORIGINS" ]; then pass "CORS origin added: $CORS" else - fail "CORS origin not found in allowedOrigins" + ORIGINS=$(echo "$CFG" | jq -c '.gateway.controlUi.allowedOrigins // []' 2>/dev/null || printf '%s' "$CFG") + fail "CORS origin not found in allowedOrigins: ${ORIGINS}" fi # ── Test 7: Combined overrides ─────────────────────────────────── diff --git a/test/sandbox-provisioning.test.ts b/test/sandbox-provisioning.test.ts index cbe72a22004..afa959ba5ec 100644 --- a/test/sandbox-provisioning.test.ts +++ b/test/sandbox-provisioning.test.ts @@ -21,6 +21,10 @@ const DOCKERFILE = path.join(ROOT, "Dockerfile"); const DOCKERFILE_BASE = path.join(ROOT, "Dockerfile.base"); const DOCKERFILE_SANDBOX = path.join(ROOT, "test", "Dockerfile.sandbox"); const HERMES_DOCKERFILE = path.join(ROOT, "agents", "hermes", "Dockerfile"); +const HERMES_DOCKERFILE_BASE = path.join(ROOT, "agents", "hermes", "Dockerfile.base"); +const HERMES_POLICY = path.join(ROOT, "agents", "hermes", "policy-additions.yaml"); +const HERMES_POLICY_PERMISSIVE = path.join(ROOT, "agents", "hermes", "policy-permissive.yaml"); +const HERMES_START = path.join(ROOT, "agents", "hermes", "start.sh"); describe("sandbox provisioning: unified .openclaw layout (#2227)", () => { const src = fs.readFileSync(DOCKERFILE_BASE, "utf-8"); @@ -71,6 +75,8 @@ describe("sandbox provisioning: procps debug tools (#2343)", () => { describe("Hermes sandbox provisioning", () => { const src = fs.readFileSync(HERMES_DOCKERFILE, "utf-8"); + const baseSrc = fs.readFileSync(HERMES_DOCKERFILE_BASE, "utf-8"); + const startSrc = fs.readFileSync(HERMES_START, "utf-8"); it("final image validates the manifest-declared hermes binary path", () => { expect(src).toContain('hermes_path="$(command -v hermes 2>/dev/null || true)"'); @@ -78,6 +84,35 @@ describe("Hermes sandbox provisioning", () => { expect(src).toContain("test -x /usr/local/bin/hermes"); expect(src).toContain("/usr/local/bin/hermes --version"); }); + + it("grants the Hermes gateway group write access to runtime state directories", () => { + expect(baseSrc).toContain("usermod -a -G sandbox root"); + expect(startSrc).toContain( + `nohup gosu gateway sh -c 'exec "$@" >/tmp/gateway.log 2>&1' sh "$HERMES" gateway run`, + ); + for (const dockerSrc of [src, baseSrc]) { + expect(dockerSrc).toContain("chmod 750 /sandbox/.hermes"); + expect(dockerSrc).toContain("/sandbox/.hermes/runtime"); + expect(dockerSrc).toContain("/sandbox/.hermes/logs"); + expect(dockerSrc).toContain("/sandbox/.hermes/cache"); + } + }); + + it("captures Hermes entrypoint and gateway startup logs for diagnostics", () => { + expect(startSrc).toContain('_START_LOG="/tmp/nemoclaw-start.log"'); + expect(startSrc).toContain('exec > >(tee -a "$_START_LOG") 2> >(tee -a "$_START_LOG" >&2)'); + expect(startSrc).toContain("start_gateway_log_stream"); + expect(startSrc).toContain("sed -u 's/^/[gateway-log:] /'"); + expect(startSrc).toContain('SANDBOX_CHILD_PIDS+=("$GATEWAY_LOG_TAIL_PID")'); + }); + + it("allows OpenShell to execute the Hermes venv behind the /usr/local/bin symlink", () => { + const policySrc = fs.readFileSync(HERMES_POLICY, "utf-8"); + const permissivePolicySrc = fs.readFileSync(HERMES_POLICY_PERMISSIVE, "utf-8"); + + expect(policySrc).toContain("- /opt/hermes"); + expect(permissivePolicySrc).toContain("- /opt/hermes"); + }); }); describe("sandbox provisioning: gateway auth token externalization (#2378)", () => {