From 3a25bbcfc4c1417784feceadbdb0b52f11d05b29 Mon Sep 17 00:00:00 2001 From: Ben Barclay Date: Fri, 1 May 2026 18:53:06 +1000 Subject: [PATCH 1/8] Correctly install Hermes Agent dependencies This change introduces UV to sync the .[all] target for all python dependencies. This was introduced due to missing dependencies such as `discord.py` in the logs. Additionally runs npm install to get the JS dependencies for the TUI and web dashboards. (cherry picked from commit 2a1a9d7dd582202b98b400205c97b5869c2c01c4) --- agents/hermes/Dockerfile.base | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/agents/hermes/Dockerfile.base b/agents/hermes/Dockerfile.base index a31e4f25b63..80b5513d88d 100644 --- a/agents/hermes/Dockerfile.base +++ b/agents/hermes/Dockerfile.base @@ -82,8 +82,12 @@ RUN mkdir -p /sandbox/.hermes/memories \ # 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 +RUN pip3 install --no-cache-dir --break-system-packages uv +RUN mkdir /opt/hermes && curl -L https://github.com/NousResearch/hermes-agent/archive/refs/tags/${HERMES_VERSION}.tar.gz | tar -xz -C /opt/hermes --strip-components=1 +WORKDIR /opt/hermes +RUN uv venv && \ + uv pip install --no-cache-dir -e ".[all]" && \ + npm install --prefer-offline --no-audit + +ENV PATH="/opt/hermes/.venv/bin:${PATH}" +RUN hermes --version From 886a8b5083c09a4ed0f690f0edb2ee1a4817cd3e Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Fri, 1 May 2026 08:12:23 -0700 Subject: [PATCH 2/8] fix(hermes): scope prebaked dependencies to onboarding support --- agents/hermes/Dockerfile.base | 37 ++++++++++++++++++++++------------- agents/hermes/manifest.yaml | 7 ++++--- 2 files changed, 27 insertions(+), 17 deletions(-) diff --git a/agents/hermes/Dockerfile.base b/agents/hermes/Dockerfile.base index 80b5513d88d..3c691fdbfc6 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,10 @@ 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 UV_VERSION=0.11.8 RUN apt-get update && apt-get install -y --no-install-recommends \ python3=3.11.2-1+b1 \ @@ -78,16 +81,22 @@ RUN mkdir -p /sandbox/.hermes/memories \ /sandbox/.hermes/pairing \ && chown -R sandbox:sandbox /sandbox/.hermes -# 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 uv -RUN mkdir /opt/hermes && curl -L https://github.com/NousResearch/hermes-agent/archive/refs/tags/${HERMES_VERSION}.tar.gz | tar -xz -C /opt/hermes --strip-components=1 +# 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 \ + && echo "${HERMES_TARBALL_SHA256} /tmp/hermes.tar.gz" | sha256sum -c - \ + && tar -xzf /tmp/hermes.tar.gz -C /opt/hermes --strip-components=1 \ + && rm /tmp/hermes.tar.gz WORKDIR /opt/hermes -RUN uv venv && \ - uv pip install --no-cache-dir -e ".[all]" && \ - npm install --prefer-offline --no-audit +RUN uv sync --frozen --no-dev --extra messaging --extra web --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 -ENV PATH="/opt/hermes/.venv/bin:${PATH}" -RUN 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" From 763ef914f1fb3e410ca020fb3a8094b95f662b2d Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Fri, 1 May 2026 08:30:24 -0700 Subject: [PATCH 3/8] fix(hermes): allow gateway runtime state writes --- agents/hermes/Dockerfile | 14 +++++++++++++- agents/hermes/Dockerfile.base | 23 +++++++++++++++++++---- agents/hermes/start.sh | 4 +++- test/sandbox-provisioning.test.ts | 11 +++++++++++ 4 files changed, 46 insertions(+), 6 deletions(-) diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index 315fd0c50f6..fdbe5f9eff6 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -211,7 +211,19 @@ RUN set -eu; \ done; \ rm -rf /root/.cache/pip /sandbox/.cache \ && chown -R sandbox:sandbox /sandbox/.hermes \ - && chmod 750 /sandbox/.hermes \ + && chmod 770 /sandbox/.hermes \ + /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 \ && chmod 640 /sandbox/.hermes/config.yaml \ && chmod 640 /sandbox/.hermes/.env diff --git a/agents/hermes/Dockerfile.base b/agents/hermes/Dockerfile.base index 3c691fdbfc6..a36becb7419 100644 --- a/agents/hermes/Dockerfile.base +++ b/agents/hermes/Dockerfile.base @@ -57,11 +57,12 @@ 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 @@ -79,7 +80,20 @@ RUN mkdir -p /sandbox/.hermes/memories \ /sandbox/.hermes/profiles \ /sandbox/.hermes/cache \ /sandbox/.hermes/pairing \ - && chown -R sandbox:sandbox /sandbox/.hermes + && chown -R sandbox:sandbox /sandbox/.hermes \ + && chmod 770 /sandbox/.hermes \ + /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 # Install Hermes Agent from the selected GitHub release. # The image prebakes only the extras mapped to NemoClaw-supported onboarding @@ -89,9 +103,10 @@ RUN mkdir -p /sandbox/.hermes/memories \ 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 \ - && echo "${HERMES_TARBALL_SHA256} /tmp/hermes.tar.gz" | sha256sum -c - \ + && 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 + && rm /tmp/hermes.tar.gz /tmp/hermes.tar.gz.sha256 WORKDIR /opt/hermes RUN uv sync --frozen --no-dev --extra messaging --extra web --no-cache \ && ln -sf /opt/hermes/.venv/bin/hermes /usr/local/bin/hermes \ diff --git a/agents/hermes/start.sh b/agents/hermes/start.sh index 0046c0f16f6..98cd47dd0a6 100755 --- a/agents/hermes/start.sh +++ b/agents/hermes/start.sh @@ -79,7 +79,9 @@ 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`. +# and group-readable by the gateway user. Runtime state dirs are group-writable +# because the Hermes gateway writes logs, PID files, and channel directory state. +# Immutability is opt-in via `shields up`. HERMES_DIR="/sandbox/.hermes" HERMES_HASH_FILE="/etc/nemoclaw/hermes.config-hash" diff --git a/test/sandbox-provisioning.test.ts b/test/sandbox-provisioning.test.ts index cbe72a22004..13290e38892 100644 --- a/test/sandbox-provisioning.test.ts +++ b/test/sandbox-provisioning.test.ts @@ -21,6 +21,7 @@ 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"); describe("sandbox provisioning: unified .openclaw layout (#2227)", () => { const src = fs.readFileSync(DOCKERFILE_BASE, "utf-8"); @@ -71,6 +72,7 @@ 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"); it("final image validates the manifest-declared hermes binary path", () => { expect(src).toContain('hermes_path="$(command -v hermes 2>/dev/null || true)"'); @@ -78,6 +80,15 @@ 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"); + for (const dockerSrc of [src, baseSrc]) { + expect(dockerSrc).toContain("chmod 770 /sandbox/.hermes"); + expect(dockerSrc).toContain("/sandbox/.hermes/logs"); + expect(dockerSrc).toContain("/sandbox/.hermes/cache"); + } + }); }); describe("sandbox provisioning: gateway auth token externalization (#2378)", () => { From 8c1b79666aae09dd8e90e735512784d2f5db8e95 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Fri, 1 May 2026 08:46:23 -0700 Subject: [PATCH 4/8] fix(hermes): start gateway log under gateway user --- agents/hermes/Dockerfile.base | 9 ++++++++- agents/hermes/start.sh | 2 +- test/e2e/test-rebuild-hermes.sh | 9 ++++++--- test/sandbox-provisioning.test.ts | 5 +++++ 4 files changed, 20 insertions(+), 5 deletions(-) diff --git a/agents/hermes/Dockerfile.base b/agents/hermes/Dockerfile.base index a36becb7419..58ba1c0d967 100644 --- a/agents/hermes/Dockerfile.base +++ b/agents/hermes/Dockerfile.base @@ -26,6 +26,7 @@ ENV DEBIAN_FRONTEND=noninteractive # 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 \ @@ -108,7 +109,13 @@ RUN mkdir -p /opt/hermes \ && 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 -RUN uv sync --frozen --no-dev --extra messaging --extra web --no-cache \ +# 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 diff --git a/agents/hermes/start.sh b/agents/hermes/start.sh index 98cd47dd0a6..907ba356721 100755 --- a/agents/hermes/start.sh +++ b/agents/hermes/start.sh @@ -516,7 +516,7 @@ 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 # NOTE: PIDs are collected after launch; a signal arriving between trap diff --git a/test/e2e/test-rebuild-hermes.sh b/test/e2e/test-rebuild-hermes.sh index 7183c877a7e..3a6c7d0013f 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,8 @@ 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_TARBALL_SHA256="5e4529b8cb6e4821eb916b81517e48125109b1764d6d1e68a204a9f0ddf2d98c" MARKER_FILE="/sandbox/.hermes/memories/rebuild-marker.txt" MARKER_CONTENT="REBUILD_HM_E2E_$(date +%s)" REGISTRY_FILE="$HOME/.nemoclaw/sandboxes.json" @@ -110,6 +111,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 +173,7 @@ reg = {'sandboxes': {'${SANDBOX_NAME}': { 'policies': [], 'policyTier': None, 'agent': 'hermes', - 'agentVersion': '2026.3.12' + 'agentVersion': '2026.4.13' }}, 'defaultSandbox': '${SANDBOX_NAME}'} with open('${REGISTRY_FILE}', 'w') as f: json.dump(reg, f, indent=2) diff --git a/test/sandbox-provisioning.test.ts b/test/sandbox-provisioning.test.ts index 13290e38892..dfde0eca612 100644 --- a/test/sandbox-provisioning.test.ts +++ b/test/sandbox-provisioning.test.ts @@ -22,6 +22,7 @@ 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_START = path.join(ROOT, "agents", "hermes", "start.sh"); describe("sandbox provisioning: unified .openclaw layout (#2227)", () => { const src = fs.readFileSync(DOCKERFILE_BASE, "utf-8"); @@ -73,6 +74,7 @@ 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)"'); @@ -83,6 +85,9 @@ describe("Hermes sandbox provisioning", () => { 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 770 /sandbox/.hermes"); expect(dockerSrc).toContain("/sandbox/.hermes/logs"); From 7bdd6cf9640d229485431ab65fa9dc81aa0fcd09 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Fri, 1 May 2026 09:02:21 -0700 Subject: [PATCH 5/8] test(hermes): surface startup logs in e2e --- agents/hermes/start.sh | 23 +++++++++++++++ test/e2e/test-hermes-e2e.sh | 49 +++++++++++++++++++++++++++++++ test/e2e/test-rebuild-hermes.sh | 36 +++++++++++++++++++++++ test/sandbox-provisioning.test.ts | 8 +++++ 4 files changed, 116 insertions(+) diff --git a/agents/hermes/start.sh b/agents/hermes/start.sh index 907ba356721..49aef2f108d 100755 --- a/agents/hermes/start.sh +++ b/agents/hermes/start.sh @@ -39,6 +39,20 @@ 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. +_START_LOG="/tmp/nemoclaw-start.log" +if [ "$(id -u)" -eq 0 ]; then + : >"$_START_LOG" + chown root:root "$_START_LOG" + chmod 600 "$_START_LOG" +else + : >"$_START_LOG" + chmod 600 "$_START_LOG" 2>/dev/null || true +fi +exec > >(tee -a "$_START_LOG") 2> >(tee -a "$_START_LOG" >&2) + # ── Drop unnecessary Linux capabilities (shared) ──────────────── drop_capabilities /usr/local/bin/nemoclaw-start "$@" @@ -188,6 +202,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. @@ -473,11 +492,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 @@ -519,11 +540,13 @@ HERMES_HOME="${HERMES_DIR}" \ 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..6533f2446b3 100755 --- a/test/e2e/test-hermes-e2e.sh +++ b/test/e2e/test-hermes-e2e.sh @@ -53,6 +53,54 @@ 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 + 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 + + # shellcheck disable=SC2016 # script is intentionally evaluated inside the sandbox + diag_output=$(openshell sandbox exec -n "$SANDBOX_NAME" -- sh -lc ' +set +e +echo "== identity ==" +id 2>&1 || true +echo "== listening sockets ==" +ss -tlnp 2>&1 || ss -tln 2>&1 || true +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 +echo "== hermes-related processes ==" +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 +echo "== /tmp/nemoclaw-start.log tail ==" +tail -n 80 /tmp/nemoclaw-start.log 2>&1 || true +echo "== /tmp/gateway.log tail ==" +tail -n 120 /tmp/gateway.log 2>&1 || true +' 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 +244,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 3a6c7d0013f..49c4f1d2f67 100755 --- a/test/e2e/test-rebuild-hermes.sh +++ b/test/e2e/test-rebuild-hermes.sh @@ -50,12 +50,48 @@ 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 + } + + diag "Hermes sandbox runtime logs:" + # shellcheck disable=SC2016 # script is intentionally evaluated inside the sandbox + openshell sandbox exec -n "$SANDBOX_NAME" -- sh -lc ' +set +e +echo "== identity ==" +id 2>&1 || true +echo "== listening sockets ==" +ss -tlnp 2>&1 || ss -tln 2>&1 || true +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 +echo "== hermes-related processes ==" +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 +echo "== /tmp/nemoclaw-start.log tail ==" +tail -n 80 /tmp/nemoclaw-start.log 2>&1 || true +echo "== /tmp/gateway.log tail ==" +tail -n 120 /tmp/gateway.log 2>&1 || true +' 2>&1 | sed 's/^/[DIAG] /' +} + export NEMOCLAW_REBUILD_VERBOSE=1 # ── Preflight ─────────────────────────────────────────────────────── diff --git a/test/sandbox-provisioning.test.ts b/test/sandbox-provisioning.test.ts index dfde0eca612..71603cdce19 100644 --- a/test/sandbox-provisioning.test.ts +++ b/test/sandbox-provisioning.test.ts @@ -94,6 +94,14 @@ describe("Hermes sandbox provisioning", () => { 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")'); + }); }); describe("sandbox provisioning: gateway auth token externalization (#2378)", () => { From ad69173b69534cde28fad0724b48599c81a66966 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Fri, 1 May 2026 09:16:30 -0700 Subject: [PATCH 6/8] test(hermes): keep runtime diagnostics single-line --- test/e2e/test-hermes-e2e.sh | 32 ++++++++++--------------------- test/e2e/test-rebuild-hermes.sh | 34 ++++++++++++--------------------- 2 files changed, 22 insertions(+), 44 deletions(-) diff --git a/test/e2e/test-hermes-e2e.sh b/test/e2e/test-hermes-e2e.sh index 6533f2446b3..21328caf9e9 100755 --- a/test/e2e/test-hermes-e2e.sh +++ b/test/e2e/test-hermes-e2e.sh @@ -60,7 +60,7 @@ dump_hermes_diagnostics() { return fi - local sandboxes diag_output + 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 @@ -72,28 +72,16 @@ dump_hermes_diagnostics() { 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_output=$(openshell sandbox exec -n "$SANDBOX_NAME" -- sh -lc ' -set +e -echo "== identity ==" -id 2>&1 || true -echo "== listening sockets ==" -ss -tlnp 2>&1 || ss -tln 2>&1 || true -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 -echo "== hermes-related processes ==" -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 -echo "== /tmp/nemoclaw-start.log tail ==" -tail -n 80 /tmp/nemoclaw-start.log 2>&1 || true -echo "== /tmp/gateway.log tail ==" -tail -n 120 /tmp/gateway.log 2>&1 || true -' 2>&1 || true) + 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" diff --git a/test/e2e/test-rebuild-hermes.sh b/test/e2e/test-rebuild-hermes.sh index 49c4f1d2f67..0660cbea062 100755 --- a/test/e2e/test-rebuild-hermes.sh +++ b/test/e2e/test-rebuild-hermes.sh @@ -67,29 +67,19 @@ dump_hermes_sandbox_logs() { return } - diag "Hermes sandbox runtime logs:" + 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 - openshell sandbox exec -n "$SANDBOX_NAME" -- sh -lc ' -set +e -echo "== identity ==" -id 2>&1 || true -echo "== listening sockets ==" -ss -tlnp 2>&1 || ss -tln 2>&1 || true -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 -echo "== hermes-related processes ==" -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 -echo "== /tmp/nemoclaw-start.log tail ==" -tail -n 80 /tmp/nemoclaw-start.log 2>&1 || true -echo "== /tmp/gateway.log tail ==" -tail -n 120 /tmp/gateway.log 2>&1 || true -' 2>&1 | sed 's/^/[DIAG] /' + 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 From 91e8273905b0d4b90d1a61498860bb025623647e Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Fri, 1 May 2026 09:30:23 -0700 Subject: [PATCH 7/8] fix(hermes): allow policy read access to venv --- agents/hermes/policy-additions.yaml | 1 + agents/hermes/policy-permissive.yaml | 1 + test/sandbox-provisioning.test.ts | 10 ++++++++++ 3 files changed, 12 insertions(+) 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/test/sandbox-provisioning.test.ts b/test/sandbox-provisioning.test.ts index 71603cdce19..6ff26bfd15d 100644 --- a/test/sandbox-provisioning.test.ts +++ b/test/sandbox-provisioning.test.ts @@ -22,6 +22,8 @@ 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)", () => { @@ -102,6 +104,14 @@ describe("Hermes sandbox provisioning", () => { 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)", () => { From 2e5fddb744bf240d7df4113674fa00fed5013501 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Fri, 1 May 2026 11:30:40 -0700 Subject: [PATCH 8/8] Fix Hermes runtime permissions and e2e feedback --- agents/hermes/Dockerfile | 14 +++++++-- agents/hermes/Dockerfile.base | 17 +++++++--- agents/hermes/start.sh | 50 ++++++++++++++++++++---------- test/e2e/test-rebuild-hermes.sh | 7 +++-- test/e2e/test-runtime-overrides.sh | 5 +-- test/sandbox-provisioning.test.ts | 3 +- 6 files changed, 67 insertions(+), 29 deletions(-) diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index fdbe5f9eff6..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,7 +212,9 @@ RUN set -eu; \ done; \ rm -rf /root/.cache/pip /sandbox/.cache \ && chown -R sandbox:sandbox /sandbox/.hermes \ - && chmod 770 /sandbox/.hermes \ + && chown gateway:sandbox /sandbox/.hermes/runtime \ + && chmod 750 /sandbox/.hermes \ + && chmod 770 \ /sandbox/.hermes/memories \ /sandbox/.hermes/sessions \ /sandbox/.hermes/skills \ @@ -224,8 +227,13 @@ RUN set -eu; \ /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 58ba1c0d967..5ac4f7c332a 100644 --- a/agents/hermes/Dockerfile.base +++ b/agents/hermes/Dockerfile.base @@ -67,8 +67,9 @@ RUN groupadd -r sandbox \ && 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 \ @@ -81,8 +82,11 @@ RUN mkdir -p /sandbox/.hermes/memories \ /sandbox/.hermes/profiles \ /sandbox/.hermes/cache \ /sandbox/.hermes/pairing \ + /sandbox/.hermes/runtime \ && chown -R sandbox:sandbox /sandbox/.hermes \ - && chmod 770 /sandbox/.hermes \ + && chown gateway:sandbox /sandbox/.hermes/runtime \ + && chmod 750 /sandbox/.hermes \ + && chmod 770 \ /sandbox/.hermes/memories \ /sandbox/.hermes/sessions \ /sandbox/.hermes/skills \ @@ -94,7 +98,12 @@ RUN mkdir -p /sandbox/.hermes/memories \ /sandbox/.hermes/workspace \ /sandbox/.hermes/profiles \ /sandbox/.hermes/cache \ - /sandbox/.hermes/pairing + /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 diff --git a/agents/hermes/start.sh b/agents/hermes/start.sh index 49aef2f108d..79a5a57419c 100755 --- a/agents/hermes/start.sh +++ b/agents/hermes/start.sh @@ -42,14 +42,38 @@ 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 - : >"$_START_LOG" - chown root:root "$_START_LOG" - chmod 600 "$_START_LOG" + prepare_restricted_log "$_START_LOG" root:root 600 else - : >"$_START_LOG" - chmod 600 "$_START_LOG" 2>/dev/null || true + prepare_restricted_log "$_START_LOG" "" 600 fi exec > >(tee -a "$_START_LOG") 2> >(tee -a "$_START_LOG" >&2) @@ -91,10 +115,9 @@ 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. Runtime state dirs are group-writable -# because the Hermes gateway writes logs, PID files, and channel directory state. +# 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" @@ -474,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 @@ -521,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 diff --git a/test/e2e/test-rebuild-hermes.sh b/test/e2e/test-rebuild-hermes.sh index 0660cbea062..611de52e8f7 100755 --- a/test/e2e/test-rebuild-hermes.sh +++ b/test/e2e/test-rebuild-hermes.sh @@ -31,6 +31,7 @@ SANDBOX_NAME="${NEMOCLAW_SANDBOX_NAME:-e2e-rebuild-hm}" register_sandbox_for_teardown "$SANDBOX_NAME" 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)" @@ -199,7 +200,7 @@ reg = {'sandboxes': {'${SANDBOX_NAME}': { 'policies': [], 'policyTier': None, 'agent': 'hermes', - 'agentVersion': '2026.4.13' + 'agentVersion': '${OLD_HERMES_REGISTRY_VERSION}' }}, 'defaultSandbox': '${SANDBOX_NAME}'} with open('${REGISTRY_FILE}', 'w') as f: json.dump(reg, f, indent=2) @@ -278,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 6ff26bfd15d..afa959ba5ec 100644 --- a/test/sandbox-provisioning.test.ts +++ b/test/sandbox-provisioning.test.ts @@ -91,7 +91,8 @@ describe("Hermes sandbox provisioning", () => { `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 770 /sandbox/.hermes"); + 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"); }