diff --git a/.github/workflows/nightly-e2e.yaml b/.github/workflows/nightly-e2e.yaml index 41abd4343ad..de452fafaa0 100644 --- a/.github/workflows/nightly-e2e.yaml +++ b/.github/workflows/nightly-e2e.yaml @@ -11,6 +11,8 @@ # for Telegram + Discord. Uses fake tokens. See PR #1081. # sandbox-survival-e2e Sandbox survival across gateway restarts (onboard, inference, # gateway stop/start, verify sandbox + workspace + inference). +# hermes-e2e Hermes Agent E2E — install → onboard --agent hermes → health +# probe → live inference. Validates the multi-agent architecture. # gpu-e2e Local Ollama inference on a GPU self-hosted runner. # Controlled by the GPU_E2E_ENABLED repository variable. # Set vars.GPU_E2E_ENABLED to "true" in repo settings to enable. @@ -225,6 +227,37 @@ jobs: path: /tmp/nemoclaw-e2e-install.log if-no-files-found: ignore + # ── Hermes Agent E2E ───────────────────────────────────────── + # Validates the multi-agent architecture by onboarding with --agent hermes, + # verifying the Hermes health probe, and running live inference through the + # Hermes sandbox. See: PR #1618 + hermes-e2e: + if: github.repository == 'NVIDIA/NemoClaw' + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Run Hermes Agent E2E test + env: + NVIDIA_API_KEY: ${{ secrets.NVIDIA_API_KEY }} + NEMOCLAW_NON_INTERACTIVE: "1" + NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1" + NEMOCLAW_SANDBOX_NAME: "e2e-hermes" + NEMOCLAW_RECREATE_SANDBOX: "1" + NEMOCLAW_AGENT: "hermes" + GITHUB_TOKEN: ${{ github.token }} + run: bash test/e2e/test-hermes-e2e.sh + + - name: Upload install log on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: hermes-e2e-install-log + path: /tmp/nemoclaw-e2e-hermes-install.log + if-no-files-found: ignore + # ── GPU E2E (Ollama local inference) ────────────────────────── # Enable by setting repository variable GPU_E2E_ENABLED=true # (Settings → Secrets and variables → Actions → Variables) @@ -277,8 +310,8 @@ jobs: notify-on-failure: runs-on: ubuntu-latest - needs: [cloud-e2e, cloud-experimental-e2e, messaging-providers-e2e, sandbox-survival-e2e, gpu-e2e] - if: ${{ always() && (needs.cloud-e2e.result == 'failure' || needs.cloud-experimental-e2e.result == 'failure' || needs.messaging-providers-e2e.result == 'failure' || needs.sandbox-survival-e2e.result == 'failure' || needs.gpu-e2e.result == 'failure') }} + needs: [cloud-e2e, cloud-experimental-e2e, messaging-providers-e2e, sandbox-survival-e2e, hermes-e2e, gpu-e2e] + if: ${{ always() && (needs.cloud-e2e.result == 'failure' || needs.cloud-experimental-e2e.result == 'failure' || needs.messaging-providers-e2e.result == 'failure' || needs.sandbox-survival-e2e.result == 'failure' || needs.hermes-e2e.result == 'failure' || needs.gpu-e2e.result == 'failure') }} permissions: issues: write steps: diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile new file mode 100644 index 00000000000..9a8c85415d9 --- /dev/null +++ b/agents/hermes/Dockerfile @@ -0,0 +1,112 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Hermes sandbox image — Hermes Agent + NemoClaw plugin inside OpenShell +# +# Layers PR-specific code (plugin, config, startup script) on top of the +# pre-built Hermes base image. Mirrors the OpenClaw Dockerfile structure. + +ARG BASE_IMAGE=ghcr.io/nvidia/nemoclaw/hermes-sandbox-base:latest + +FROM ${BASE_IMAGE} + +# Harden: remove unnecessary build tools and network probes +RUN (apt-get remove --purge -y gcc gcc-12 g++ g++-12 cpp cpp-12 make \ + netcat-openbsd netcat-traditional ncat 2>/dev/null || true) \ + && apt-get autoremove --purge -y \ + && rm -rf /var/lib/apt/lists/* + +# Patch: disable Hermes's TelegramFallbackTransport inside the sandbox. +# The fallback transport rewrites api.telegram.org to raw IPs, which +# OpenShell's L7 proxy rejects (hostname-based policy). Without it, +# python-telegram-bot uses default httpx transport which respects +# HTTPS_PROXY and routes through the proxy correctly. +# Must run as root before USER sandbox. +# Replace _fallback_ips method to always return empty list, which +# skips the entire fallback transport code path. +RUN python3 -c "\ +import pathlib, re; \ +p = pathlib.Path('/usr/local/lib/python3.11/dist-packages/gateway/platforms/telegram.py'); \ +src = p.read_text(); \ +patched = src.replace( \ + 'fallback_ips = self._fallback_ips()', \ + 'fallback_ips = [] # NemoClaw: disabled for sandbox proxy compatibility'); \ +patched = patched.replace( \ + 'if not fallback_ips:', \ + 'if False: # NemoClaw: skip fallback discovery'); \ +assert 'NemoClaw: disabled' in patched, 'Patch failed: _fallback_ips line not found'; \ +p.write_text(patched); \ +print('[patch] Disabled TelegramFallbackTransport for sandbox proxy compatibility')" + +# Copy NemoClaw plugin for Hermes (Python-based) +COPY agents/hermes/plugin/ /opt/nemoclaw-hermes-plugin/ + +# Copy config generation script and URL-decode proxy +COPY agents/hermes/generate-config.ts /opt/nemoclaw-generate-config.ts +COPY agents/hermes/decode-proxy.py /usr/local/bin/nemoclaw-decode-proxy +RUN chmod 755 /usr/local/bin/nemoclaw-decode-proxy + +# Copy blueprint (shared infrastructure) +COPY nemoclaw-blueprint/ /opt/nemoclaw-blueprint/ + +# Copy startup script +COPY agents/hermes/start.sh /usr/local/bin/nemoclaw-start +RUN chmod 755 /usr/local/bin/nemoclaw-start + +# Build args for config that varies per deployment. +ARG NEMOCLAW_MODEL=nvidia/nemotron-3-super-120b-a12b +ARG NEMOCLAW_PROVIDER_KEY=custom +ARG NEMOCLAW_INFERENCE_BASE_URL=https://inference.local/v1 +ARG CHAT_UI_URL=http://127.0.0.1:8642 +ARG NEMOCLAW_MESSAGING_CHANNELS_B64=W10= +ARG NEMOCLAW_MESSAGING_ALLOWED_IDS_B64=e30= +ARG NEMOCLAW_BUILD_ID=default + +# Promote build-args to env vars for the config generation script. +ENV NEMOCLAW_MODEL=${NEMOCLAW_MODEL} \ + NEMOCLAW_PROVIDER_KEY=${NEMOCLAW_PROVIDER_KEY} \ + NEMOCLAW_INFERENCE_BASE_URL=${NEMOCLAW_INFERENCE_BASE_URL} \ + CHAT_UI_URL=${CHAT_UI_URL} \ + NEMOCLAW_MESSAGING_CHANNELS_B64=${NEMOCLAW_MESSAGING_CHANNELS_B64} \ + NEMOCLAW_MESSAGING_ALLOWED_IDS_B64=${NEMOCLAW_MESSAGING_ALLOWED_IDS_B64} + +WORKDIR /sandbox +USER sandbox + +# Set up blueprint for local resolution +RUN mkdir -p /sandbox/.nemoclaw/blueprints/0.1.0 \ + && cp -r /opt/nemoclaw-blueprint/* /sandbox/.nemoclaw/blueprints/0.1.0/ + +# Generate Hermes config.yaml and .env from build args. +# config.yaml is immutable at runtime (Landlock read-only on /sandbox/.hermes). +# .env holds API key placeholders for OpenShell provider pipeline. +# SECURITY: Uses a separate script file instead of inline code to avoid +# code injection via build-arg interpolation (same concern as OpenClaw C-2). +RUN node --experimental-strip-types /opt/nemoclaw-generate-config.ts + +# Install NemoClaw plugin into Hermes +RUN mkdir -p /sandbox/.hermes-data/plugins/nemoclaw \ + && cp -r /opt/nemoclaw-hermes-plugin/* /sandbox/.hermes-data/plugins/nemoclaw/ + +# Lock .hermes via DAC: chown to root so sandbox user cannot modify config. +# Same pattern as OpenClaw — Landlock provides defense-in-depth. +# hadolint ignore=DL3002 +USER root +RUN chown root:root /sandbox/.hermes \ + && rm -rf /root/.cache/pip /sandbox/.cache \ + && find /sandbox/.hermes -mindepth 1 -maxdepth 1 -exec chown -h root:root {} + \ + && chmod 755 /sandbox/.hermes \ + && chmod 444 /sandbox/.hermes/config.yaml \ + && chmod 444 /sandbox/.hermes/.env + +# Pin config hash at build time for integrity verification at startup. +RUN sha256sum /sandbox/.hermes/config.yaml /sandbox/.hermes/.env \ + > /sandbox/.hermes/.config-hash \ + && chmod 444 /sandbox/.hermes/.config-hash \ + && chown root:root /sandbox/.hermes/.config-hash + +# start.sh handles privilege separation: runs as root initially to perform +# symlink validation and hardening, then drops to 'gateway' user via gosu +# for the agent process. See the root-path branch in start.sh. +ENTRYPOINT ["/usr/local/bin/nemoclaw-start"] +CMD ["/bin/bash"] diff --git a/agents/hermes/Dockerfile.base b/agents/hermes/Dockerfile.base new file mode 100644 index 00000000000..f577d783e09 --- /dev/null +++ b/agents/hermes/Dockerfile.base @@ -0,0 +1,100 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# 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. +# +# Mirrors the OpenClaw Dockerfile.base structure but installs Hermes +# (Python-based) instead of OpenClaw (Node-based). +# +# ── When to rebuild ───────────────────────────────────────────── +# 1. Hermes version bump — change the HERMES_VERSION below +# 2. New apt package needed — add it to the apt-get install list +# 3. gosu upgrade — update URL, checksum, and version +# 4. node:22-slim digest rot — update-docker-pin.sh updates both +# 5. New .hermes subdirectory — add mkdir + symlink below +# ──────────────────────────────────────────────────────────────── + +FROM node:22-slim@sha256:4f77a690f2f8946ab16fe1e791a3ac0667ae1c3575c3e4d0d4589e9ed5bfaf3d + +ENV DEBIAN_FRONTEND=noninteractive + +# Hermes version pinned for reproducibility. +# Calver tag v2026.4.8 = semver 0.8.0. +ARG HERMES_VERSION=v2026.4.8 + +RUN apt-get update && apt-get install -y --no-install-recommends \ + python3=3.11.2-1+b1 \ + python3-pip=23.0.1+dfsg-1 \ + python3-venv=3.11.2-1+b1 \ + curl=7.88.1-10+deb12u14 \ + git=1:2.39.5-0+deb12u3 \ + ca-certificates=20230311+deb12u1 \ + iproute2=6.1.0-3 \ + iptables=1.8.9-2 \ + libcap2-bin=1:2.66-4+deb12u2+b2 \ + socat=1.7.4.4-2 \ + && rm -rf /var/lib/apt/lists/* + +# gosu for privilege separation (gateway vs sandbox user). +# Identical to OpenClaw base — pinned to 1.19 with checksum. +# hadolint ignore=DL4006 +RUN arch="$(dpkg --print-architecture)" \ + && case "$arch" in \ + amd64) gosu_asset="gosu-amd64"; gosu_sha256="52c8749d0142edd234e9d6bd5237dff2d81e71f43537e2f4f66f75dd4b243dd0" ;; \ + arm64) gosu_asset="gosu-arm64"; gosu_sha256="3a8ef022d82c0bc4a98bcb144e77da714c25fcfa64dccc57f6aba7ae47ff1a44" ;; \ + *) echo "Unsupported architecture for gosu: $arch" >&2; exit 1 ;; \ + esac \ + && curl -fsSL -o /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/1.19/${gosu_asset}" \ + && echo "${gosu_sha256} /usr/local/bin/gosu" | sha256sum -c - \ + && chmod +x /usr/local/bin/gosu \ + && gosu --version + +# Create sandbox user (matches OpenShell convention) and gateway user. +# Same UID/GID layout as OpenClaw — OpenShell expects these names. +RUN groupadd -r gateway && useradd -r -g gateway -d /sandbox -s /usr/sbin/nologin gateway \ + && groupadd -r sandbox && useradd -r -g sandbox -d /sandbox -s /bin/bash sandbox \ + && mkdir -p /sandbox/.nemoclaw \ + && chown -R sandbox:sandbox /sandbox + +# Split .hermes into immutable config dir + writable state dir. +# Same pattern as OpenClaw: Landlock makes /sandbox/.hermes read-only, +# writable state lives in .hermes-data, reached via symlinks. +RUN mkdir -p /sandbox/.hermes-data/memories \ + /sandbox/.hermes-data/sessions \ + /sandbox/.hermes-data/skills \ + /sandbox/.hermes-data/plugins \ + /sandbox/.hermes-data/cron \ + /sandbox/.hermes-data/logs \ + /sandbox/.hermes-data/skins \ + /sandbox/.hermes-data/plans \ + /sandbox/.hermes-data/workspace \ + /sandbox/.hermes-data/profiles \ + /sandbox/.hermes-data/cache \ + /sandbox/.hermes-data/pairing \ + && mkdir -p /sandbox/.hermes \ + && ln -s /sandbox/.hermes-data/memories /sandbox/.hermes/memories \ + && ln -s /sandbox/.hermes-data/sessions /sandbox/.hermes/sessions \ + && ln -s /sandbox/.hermes-data/skills /sandbox/.hermes/skills \ + && ln -s /sandbox/.hermes-data/plugins /sandbox/.hermes/plugins \ + && ln -s /sandbox/.hermes-data/cron /sandbox/.hermes/cron \ + && ln -s /sandbox/.hermes-data/logs /sandbox/.hermes/logs \ + && ln -s /sandbox/.hermes-data/skins /sandbox/.hermes/skins \ + && ln -s /sandbox/.hermes-data/plans /sandbox/.hermes/plans \ + && ln -s /sandbox/.hermes-data/workspace /sandbox/.hermes/workspace \ + && ln -s /sandbox/.hermes-data/profiles /sandbox/.hermes/profiles \ + && ln -s /sandbox/.hermes-data/cache /sandbox/.hermes/cache \ + && ln -s /sandbox/.hermes-data/pairing /sandbox/.hermes/pairing \ + && chown -R sandbox:sandbox /sandbox/.hermes /sandbox/.hermes-data + +# 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 diff --git a/agents/hermes/decode-proxy.py b/agents/hermes/decode-proxy.py new file mode 100755 index 00000000000..b3f09058479 --- /dev/null +++ b/agents/hermes/decode-proxy.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +""" +URL-decoding HTTP proxy for OpenShell placeholder rewriting. + +Python HTTP clients (httpx) URL-encode colons in URL paths, turning +openshell:resolve:env:TOKEN into openshell%3Aresolve%3Aenv%3ATOKEN. +OpenShell's L7 proxy doesn't recognize the encoded form. + +This proxy sits between the Python process and the OpenShell proxy, +URL-decodes the CONNECT target and request paths so the placeholders +are restored before reaching the L7 proxy. + +Usage: Launched by start.sh, listens on 127.0.0.1:3129. + HTTPS_PROXY=http://127.0.0.1:3129 hermes gateway run +""" + +import asyncio +import sys +from urllib.parse import unquote + + +UPSTREAM_HOST = "10.200.0.1" +UPSTREAM_PORT = 3128 +LISTEN_HOST = "127.0.0.1" +LISTEN_PORT = 3129 + + +async def handle_client(reader, writer): + """Proxy a single connection, URL-decoding the initial request line.""" + up_writer = None + try: + first_line = await asyncio.wait_for(reader.readline(), timeout=10) + if not first_line: + writer.close() + return + + # Decode only the request target (second token) so valid percent-encoding + # like %2F or %3F in the method/version is preserved. Only the path + # contains openshell%3Aresolve placeholders that need decoding. + parts = first_line.decode("utf-8", errors="replace").split(" ", 2) + if len(parts) == 3: + parts[1] = unquote(parts[1]) + decoded_line = " ".join(parts).encode("utf-8") + + # Read remaining headers + headers = bytearray(decoded_line) + while True: + line = await asyncio.wait_for(reader.readline(), timeout=10) + headers.extend(line) + if line == b"\r\n" or line == b"\n" or not line: + break + + # Connect to upstream proxy + up_reader, up_writer = await asyncio.open_connection( + UPSTREAM_HOST, UPSTREAM_PORT + ) + up_writer.write(bytes(headers)) + await up_writer.drain() + + # Bidirectional relay + await asyncio.gather( + _relay(reader, up_writer), + _relay(up_reader, writer), + ) + except (asyncio.TimeoutError, ConnectionError, OSError): + pass + finally: + for w in (up_writer, writer): + if w is not None: + try: + w.close() + await w.wait_closed() + except (ConnectionError, OSError): + pass + + +async def _relay(src, dst): + """Copy data from src to dst until EOF.""" + try: + while True: + data = await src.read(65536) + if not data: + break + dst.write(data) + await dst.drain() + except (ConnectionError, OSError): + pass + + +async def main(): + server = await asyncio.start_server(handle_client, LISTEN_HOST, LISTEN_PORT) + print( + f"[decode-proxy] Listening on {LISTEN_HOST}:{LISTEN_PORT} -> {UPSTREAM_HOST}:{UPSTREAM_PORT}", + file=sys.stderr, + ) + async with server: + await server.serve_forever() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/agents/hermes/generate-config.ts b/agents/hermes/generate-config.ts new file mode 100644 index 00000000000..96d76db4746 --- /dev/null +++ b/agents/hermes/generate-config.ts @@ -0,0 +1,129 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// Generate Hermes config.yaml and .env from NemoClaw build-arg env vars. +// +// Called at Docker image build time. Reads NEMOCLAW_* env vars and writes: +// ~/.hermes/config.yaml — Hermes configuration (immutable at runtime) +// ~/.hermes/.env — Messaging token placeholders (immutable at runtime) +// +// Only sets what's required for Hermes to run inside OpenShell: +// - Model and inference endpoint (custom provider pointing at inference.local) +// - API server on internal port (socat forwards to public port) +// - Messaging platform tokens (if configured during onboard) +// Everything else uses Hermes defaults. + +import { writeFileSync, chmodSync } from "node:fs"; +import { join } from "node:path"; +import { homedir } from "node:os"; + +const TOKEN_ENV: Record = { + telegram: "TELEGRAM_BOT_TOKEN", + discord: "DISCORD_BOT_TOKEN", + slack: "SLACK_BOT_TOKEN", +}; + +function main(): void { + const model = process.env.NEMOCLAW_MODEL!; + const baseUrl = process.env.NEMOCLAW_INFERENCE_BASE_URL!; + + const channelsB64 = process.env.NEMOCLAW_MESSAGING_CHANNELS_B64 || "W10="; + const allowedIdsB64 = process.env.NEMOCLAW_MESSAGING_ALLOWED_IDS_B64 || "e30="; + + const msgChannels: string[] = JSON.parse( + Buffer.from(channelsB64, "base64").toString("utf-8"), + ); + const allowedIds: Record = JSON.parse( + Buffer.from(allowedIdsB64, "base64").toString("utf-8"), + ); + + const config: Record = { + _config_version: 12, + model: { + default: model, + provider: "custom", + base_url: baseUrl, + }, + }; + + // Messaging platforms (if configured during onboard) + const platformsConfig: Record> = {}; + for (const ch of msgChannels) { + if (ch in TOKEN_ENV) { + const pCfg: Record = { + enabled: true, + token: `openshell:resolve:env:${TOKEN_ENV[ch]}`, + }; + if (ch in allowedIds && allowedIds[ch]?.length) { + pCfg.allowed_users = allowedIds[ch].map(String).join(","); + } + platformsConfig[ch] = pCfg; + } + } + + if (Object.keys(platformsConfig).length > 0) { + config.platforms = platformsConfig; + } + + // API server — internal port only. + // Hermes binds to 127.0.0.1 regardless of config (upstream bug). + // socat in start.sh forwards 0.0.0.0:8642 -> 127.0.0.1:18642. + const platforms = (config.platforms ?? {}) as Record; + platforms.api_server = { + enabled: true, + extra: { + port: 18642, + host: "127.0.0.1", + }, + }; + config.platforms = platforms; + + // Write config.yaml — use inline YAML serialization (no external dep) + const configPath = join(homedir(), ".hermes", "config.yaml"); + writeFileSync(configPath, toYaml(config)); + chmodSync(configPath, 0o600); + + // Write .env — only messaging token placeholders + const envLines: string[] = []; + for (const ch of msgChannels) { + if (ch in TOKEN_ENV) { + envLines.push(`${TOKEN_ENV[ch]}=openshell:resolve:env:${TOKEN_ENV[ch]}`); + } + } + + const envPath = join(homedir(), ".hermes", ".env"); + writeFileSync(envPath, envLines.length > 0 ? envLines.join("\n") + "\n" : ""); + chmodSync(envPath, 0o600); + + console.log(`[config] Wrote ${configPath} (model=${model}, provider=custom)`); + console.log(`[config] Wrote ${envPath} (${envLines.length} entries)`); +} + +/** Minimal YAML serializer for flat/nested objects — no external dependency. */ +function toYaml(obj: Record, indent: number = 0): string { + const pad = " ".repeat(indent); + let out = ""; + for (const [key, value] of Object.entries(obj)) { + if (value === null || value === undefined) { + out += `${pad}${key}: null\n`; + } else if (typeof value === "object" && !Array.isArray(value)) { + out += `${pad}${key}:\n`; + out += toYaml(value as Record, indent + 1); + } else if (typeof value === "string") { + out += `${pad}${key}: ${yamlString(value)}\n`; + } else if (typeof value === "number" || typeof value === "boolean") { + out += `${pad}${key}: ${value}\n`; + } + } + return out; +} + +/** Quote a YAML string if it contains special characters. */ +function yamlString(s: string): string { + if (/[:{}\[\],&*?|>!%@`#'"]/.test(s) || s.includes("\n") || s.trim() !== s) { + return JSON.stringify(s); + } + return s; +} + +main(); diff --git a/agents/hermes/manifest.yaml b/agents/hermes/manifest.yaml new file mode 100644 index 00000000000..f8594c54eb3 --- /dev/null +++ b/agents/hermes/manifest.yaml @@ -0,0 +1,102 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Agent manifest for Hermes Agent (Nous Research). +# Declares the integration contract between NemoClaw and the sandboxed agent. + +name: hermes +display_name: "Hermes Agent" +description: "Self-improving AI agent with learning loop (Nous Research)" +version_constraint: ">=0.8.0" +language: python +license: MIT +homepage: "https://github.com/NousResearch/hermes-agent" + +# ── Binary & process ──────────────────────────────────────────── +install_method: curl # curl install.sh | bash +binary_path: /usr/local/bin/hermes +gateway_command: "hermes gateway run" + +# ── Health probe ──────────────────────────────────────────────── +# The API server adapter listens on 8642 by default and exposes +# GET /health -> {"status": "ok", "platform": "hermes-agent"} +health_probe: + url: "http://localhost:8642/health" + port: 8642 + timeout_seconds: 90 + +# ── Dashboard / UI ────────────────────────────────────────────── +# Hermes exposes an OpenAI-compatible API, not a web dashboard. +# Any OpenAI-compatible frontend (Open WebUI, LobeChat, etc.) can +# connect at http://localhost:8642/v1. +forward_ports: + - 8642 + +# ── Configuration ─────────────────────────────────────────────── +config: + immutable_dir: /sandbox/.hermes + writable_dir: /sandbox/.hermes-data + config_file: config.yaml # relative to immutable_dir + env_file: .env # relative to immutable_dir — API keys + auth_file: auth.json # OAuth tokens (Nous Portal, Codex, etc.) + format: yaml + +# ── State directories ────────────────────────────────────────── +# Symlinked from immutable_dir -> writable_dir so the agent can +# write state while the config directory stays Landlock read-only. +state_dirs: + - memories + - sessions + - skills + - plugins + - cron + - logs + - skins + - plans + - workspace + - profiles + - cache + - pairing + +# ── Authentication ────────────────────────────────────────────── +# Hermes does NOT have OpenClaw-style browser device pairing. +# Web UI auth is Bearer token via API_SERVER_KEY env var. +# DM pairing for messaging users: `hermes pairing approve ` +device_pairing: false +web_auth_method: bearer_token +web_auth_env: API_SERVER_KEY + +# ── Messaging platforms ───────────────────────────────────────── +# Hermes natively supports 14 platforms. We start with the 3 that +# OpenShell already has L7 proxy support for. +messaging_platforms: + supported: + - telegram + - discord + - slack + # Future: whatsapp, signal, matrix, mattermost, email, etc. + # Each needs a network policy entry before enabling. + +# ── Inference ─────────────────────────────────────────────────── +# Hermes supports custom OpenAI-compatible endpoints natively via +# provider: "custom" + base_url in config.yaml. This is how we +# route through OpenShell's inference.local proxy. +inference: + provider_type: custom + base_url_config_key: "model.base_url" + model_config_key: "model.default" + proxy_support: implicit # via httpx (OpenAI SDK dep) + +# ── Phone-home hosts ─────────────────────────────────────────── +# Agent-specific egress endpoints needed for updates, auth, etc. +phone_home_hosts: + - nousresearch.com + - hermes-agent.nousresearch.com + +# ── Package registry ─────────────────────────────────────────── +# Hermes uses pip for plugin/skill installs (replaces npm for OpenClaw). +package_registry: + hosts: + - pypi.org + - files.pythonhosted.org + binary: /usr/local/bin/pip3 diff --git a/agents/hermes/plugin/__init__.py b/agents/hermes/plugin/__init__.py new file mode 100644 index 00000000000..77928a2a65c --- /dev/null +++ b/agents/hermes/plugin/__init__.py @@ -0,0 +1,13 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +""" +NemoClaw plugin for Hermes Agent. + +Placeholder — registers with Hermes so NemoClaw can be discovered +as an installed plugin. No tools or hooks are added. +""" + + +def register(ctx): + """No-op registration. Hermes requires this function to exist.""" + pass diff --git a/agents/hermes/plugin/plugin.yaml b/agents/hermes/plugin/plugin.yaml new file mode 100644 index 00000000000..05ff9e7cd8c --- /dev/null +++ b/agents/hermes/plugin/plugin.yaml @@ -0,0 +1,8 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: nemoclaw +version: "0.0.11" +description: "NemoClaw sandbox integration for Hermes running inside OpenShell" +author: "NVIDIA Corporation" +manifest_version: 1 diff --git a/agents/hermes/policy-additions.yaml b/agents/hermes/policy-additions.yaml new file mode 100644 index 00000000000..1f23618d769 --- /dev/null +++ b/agents/hermes/policy-additions.yaml @@ -0,0 +1,208 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Sandbox policy for the Hermes Agent. +# Based on the OpenClaw policy with agent-specific adjustments: +# - .hermes / .hermes-data instead of .openclaw / .openclaw-data +# - Nous Research phone-home endpoints instead of OpenClaw/ClawHub +# - PyPI instead of npm registry +# - /usr/local/bin/hermes and python3 binary restrictions +# +# Principle: deny by default, allow only what's needed for core functionality. + +version: 1 + +filesystem_policy: + include_workdir: true + read_only: + - /usr + - /lib + - /proc + - /dev/urandom + - /app + - /etc + - /var/log + - /sandbox/.hermes # Immutable config (Landlock enforced) + read_write: + - /sandbox + - /tmp + - /dev/null + - /sandbox/.hermes-data # Writable agent state (symlinked from .hermes) + +landlock: + compatibility: best_effort + +process: + run_as_user: sandbox + run_as_group: sandbox + +network_policies: + claude_code: + name: claude_code + endpoints: + - host: api.anthropic.com + port: 443 + protocol: rest + enforcement: enforce + tls: terminate + rules: + - allow: { method: POST, path: "/v1/messages" } + - allow: { method: POST, path: "/v1/messages/batches" } + - allow: { method: GET, path: "/v1/messages/batches/**" } + - allow: { method: POST, path: "/v1/complete" } + - host: statsig.anthropic.com + port: 443 + protocol: rest + enforcement: enforce + tls: terminate + rules: + - allow: { method: POST, path: "/**" } + - host: sentry.io + port: 443 + protocol: rest + enforcement: enforce + tls: terminate + rules: + - allow: { method: POST, path: "/api/*/envelope/**" } + - allow: { method: POST, path: "/api/*/store/**" } + binaries: + - { path: /usr/local/bin/claude } + + nvidia: + name: nvidia + endpoints: + - host: integrate.api.nvidia.com + port: 443 + protocol: rest + enforcement: enforce + tls: terminate + rules: + - allow: { method: POST, path: "/v1/chat/completions" } + - allow: { method: POST, path: "/v1/completions" } + - allow: { method: POST, path: "/v1/embeddings" } + - allow: { method: GET, path: "/v1/models" } + - allow: { method: GET, path: "/v1/models/**" } + - host: inference-api.nvidia.com + port: 443 + protocol: rest + enforcement: enforce + tls: terminate + rules: + - allow: { method: POST, path: "/v1/chat/completions" } + - allow: { method: POST, path: "/v1/completions" } + - allow: { method: POST, path: "/v1/embeddings" } + - allow: { method: GET, path: "/v1/models" } + - allow: { method: GET, path: "/v1/models/**" } + binaries: + - { path: /usr/local/bin/claude } + - { path: /usr/local/bin/hermes } + - { path: /usr/bin/python3.11 } + + github: + name: github + endpoints: + - host: github.com + port: 443 + access: full + - host: api.github.com + port: 443 + access: full + binaries: + - { path: /usr/bin/gh } + - { path: /usr/bin/git } + + # ── Nous Research — Hermes auth, updates, portal ────────────── + nous_research: + name: nous_research + endpoints: + - host: nousresearch.com + port: 443 + protocol: rest + enforcement: enforce + tls: terminate + rules: + - allow: { method: GET, path: "/**" } + - allow: { method: POST, path: "/**" } + - host: hermes-agent.nousresearch.com + port: 443 + protocol: rest + enforcement: enforce + tls: terminate + rules: + - allow: { method: GET, path: "/**" } + - allow: { method: POST, path: "/**" } + - host: api.nousresearch.com + port: 443 + protocol: rest + enforcement: enforce + tls: terminate + rules: + - allow: { method: GET, path: "/**" } + - allow: { method: POST, path: "/**" } + binaries: + - { path: /usr/local/bin/hermes } + - { path: /usr/bin/python3.11 } + + # ── PyPI — needed for pip install (skill/plugin deps) ───────── + pypi: + name: pypi + endpoints: + - host: pypi.org + port: 443 + protocol: rest + enforcement: enforce + tls: terminate + rules: + - allow: { method: GET, path: "/**" } + - host: files.pythonhosted.org + port: 443 + protocol: rest + enforcement: enforce + tls: terminate + rules: + - allow: { method: GET, path: "/**" } + binaries: + - { path: /usr/local/bin/pip3 } + - { path: /usr/bin/python3.11 } + + # ── Messaging — pre-allowed for agent notifications ─────────── + telegram: + name: telegram + endpoints: + - host: api.telegram.org + port: 443 + protocol: rest + enforcement: enforce + tls: terminate + rules: + - allow: { method: GET, path: "/bot*/**" } + - allow: { method: POST, path: "/bot*/**" } + - allow: { method: GET, path: "/file/bot*/**" } + binaries: + - { path: /usr/local/bin/node } + - { path: /usr/bin/python3.11 } + + discord: + name: discord + endpoints: + - host: discord.com + port: 443 + protocol: rest + enforcement: enforce + tls: terminate + rules: + - allow: { method: GET, path: "/**" } + - allow: { method: POST, path: "/**" } + - host: gateway.discord.gg + port: 443 + access: full + - host: cdn.discordapp.com + port: 443 + protocol: rest + enforcement: enforce + tls: terminate + rules: + - allow: { method: GET, path: "/**" } + binaries: + - { path: /usr/local/bin/node } + - { path: /usr/bin/python3.11 } diff --git a/agents/hermes/policy-permissive.yaml b/agents/hermes/policy-permissive.yaml new file mode 100644 index 00000000000..717d4134fb3 --- /dev/null +++ b/agents/hermes/policy-permissive.yaml @@ -0,0 +1,207 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Permissive policy for Hermes Agent — used by --dangerously-skip-permissions. +# All known Hermes-relevant endpoints opened with access: full (no L7 filtering). +# Filesystem: include_workdir: true makes the sandbox home directory writable. +# +# WARNING: This policy disables most sandbox security restrictions. +# Do not use in production. + +version: 1 + +filesystem_policy: + include_workdir: true + read_only: + - /usr + - /lib + - /proc + - /dev/urandom + - /app + - /etc + - /var/log + - /sandbox/.hermes + read_write: + - /tmp + - /dev/null + - /sandbox/.hermes-data + - /sandbox/.nemoclaw + +landlock: + compatibility: best_effort + +process: + run_as_user: sandbox + run_as_group: sandbox + +network_policies: + + claude_code: + name: claude_code + endpoints: + - host: api.anthropic.com + port: 443 + access: full + - host: statsig.anthropic.com + port: 443 + access: full + - host: sentry.io + port: 443 + access: full + + nvidia: + name: nvidia + endpoints: + - host: integrate.api.nvidia.com + port: 443 + access: full + - host: inference-api.nvidia.com + port: 443 + access: full + + github: + name: github + endpoints: + - host: github.com + port: 443 + access: full + - host: api.github.com + port: 443 + access: full + + nous_research: + name: nous_research + endpoints: + - host: nousresearch.com + port: 443 + access: full + - host: hermes-agent.nousresearch.com + port: 443 + access: full + - host: api.nousresearch.com + port: 443 + access: full + + pypi: + name: pypi + endpoints: + - host: pypi.org + port: 443 + access: full + - host: files.pythonhosted.org + port: 443 + access: full + + # ── Messaging endpoints ── + + telegram: + name: telegram + endpoints: + - host: api.telegram.org + port: 443 + access: full + + discord: + name: discord + endpoints: + - host: discord.com + port: 443 + access: full + - host: gateway.discord.gg + port: 443 + access: full + - host: cdn.discordapp.com + port: 443 + access: full + - host: media.discordapp.net + port: 443 + access: full + + slack: + name: slack + endpoints: + - host: slack.com + port: 443 + access: full + - host: api.slack.com + port: 443 + access: full + - host: hooks.slack.com + port: 443 + access: full + - host: wss-primary.slack.com + port: 443 + access: full + - host: wss-backup.slack.com + port: 443 + access: full + + # ── Third-party services ── + + brew: + name: brew + endpoints: + - host: formulae.brew.sh + port: 443 + access: full + - host: ghcr.io + port: 443 + access: full + - host: raw.githubusercontent.com + port: 443 + access: full + - host: objects.githubusercontent.com + port: 443 + access: full + - host: pkg-containers.githubusercontent.com + port: 443 + access: full + + jira: + name: jira + endpoints: + - host: "*.atlassian.net" + port: 443 + access: full + - host: api.atlassian.com + port: 443 + access: full + - host: auth.atlassian.com + port: 443 + access: full + + outlook: + name: outlook + endpoints: + - host: outlook.office365.com + port: 443 + access: full + - host: outlook.office.com + port: 443 + access: full + - host: graph.microsoft.com + port: 443 + access: full + - host: login.microsoftonline.com + port: 443 + access: full + + huggingface: + name: huggingface + endpoints: + - host: huggingface.co + port: 443 + access: full + - host: cdn-lfs.huggingface.co + port: 443 + access: full + - host: router.huggingface.co + port: 443 + access: full + + brave: + name: brave + endpoints: + - host: api.search.brave.com + port: 443 + access: full diff --git a/agents/hermes/start.sh b/agents/hermes/start.sh new file mode 100755 index 00000000000..c2d8fb7aab2 --- /dev/null +++ b/agents/hermes/start.sh @@ -0,0 +1,408 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# NemoClaw sandbox entrypoint for Hermes Agent. +# +# Mirrors scripts/nemoclaw-start.sh (OpenClaw) but launches `hermes gateway +# start` instead of `openclaw gateway run`. Key differences: +# - 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 +# +# 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 +# startup to detect tampering. + +set -euo pipefail + +# Harden: limit process count to prevent fork bombs +if ! ulimit -Su 512 2>/dev/null; then + echo "[SECURITY] Could not set soft nproc limit (container runtime may restrict ulimit)" >&2 +fi +if ! ulimit -Hu 512 2>/dev/null; then + echo "[SECURITY] Could not set hard nproc limit (container runtime may restrict ulimit)" >&2 +fi + +# SECURITY: Lock down PATH +export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + +# ── Drop unnecessary Linux capabilities ────────────────────────── +if [ "${NEMOCLAW_CAPS_DROPPED:-}" != "1" ] && command -v capsh >/dev/null 2>&1; then + if capsh --has-p=cap_setpcap 2>/dev/null; then + export NEMOCLAW_CAPS_DROPPED=1 + exec capsh \ + --drop=cap_net_raw,cap_dac_override,cap_sys_chroot,cap_fsetid,cap_setfcap,cap_mknod,cap_audit_write,cap_net_bind_service \ + -- -c 'exec /usr/local/bin/nemoclaw-start "$@"' -- "$@" + else + echo "[SECURITY] CAP_SETPCAP not available — runtime already restricts capabilities" >&2 + fi +elif [ "${NEMOCLAW_CAPS_DROPPED:-}" != "1" ]; then + echo "[SECURITY WARNING] capsh not available — running with default capabilities" >&2 +fi + +# Normalize the self-wrapper bootstrap (same as OpenClaw entrypoint). +if [ "${1:-}" = "env" ]; then + _raw_args=("$@") + _self_wrapper_index="" + for ((i = 1; i < ${#_raw_args[@]}; i += 1)); do + case "${_raw_args[$i]}" in + *=*) ;; + nemoclaw-start | /usr/local/bin/nemoclaw-start) + _self_wrapper_index="$i" + break + ;; + *) + break + ;; + esac + done + if [ -n "$_self_wrapper_index" ]; then + for ((i = 1; i < _self_wrapper_index; i += 1)); do + export "${_raw_args[$i]}" + done + set -- "${_raw_args[@]:$((_self_wrapper_index + 1))}" + fi +fi + +case "${1:-}" in + nemoclaw-start | /usr/local/bin/nemoclaw-start) shift ;; +esac +NEMOCLAW_CMD=("$@") +CHAT_UI_URL="${CHAT_UI_URL:-http://127.0.0.1:8642}" +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="$(command -v hermes)" # Resolve once, use absolute path everywhere + +# Hermes writes state files (PID, state.db, .channel_directory) directly into +# HERMES_HOME. We cannot point it at the immutable /sandbox/.hermes dir. +# Instead: verify integrity of the immutable source, then copy config to the +# writable .hermes-data dir so Hermes can coexist with its own state files. +HERMES_IMMUTABLE="/sandbox/.hermes" +HERMES_WRITABLE="/sandbox/.hermes-data" + +# ── Config integrity check ────────────────────────────────────── +verify_config_integrity() { + local hash_file="${HERMES_IMMUTABLE}/.config-hash" + if [ ! -f "$hash_file" ]; then + echo "[SECURITY] Config hash file missing — refusing to start without integrity verification" >&2 + return 1 + fi + if ! (cd "${HERMES_IMMUTABLE}" && sha256sum -c "$hash_file" --status 2>/dev/null); then + echo "[SECURITY] Hermes config integrity check FAILED — config may have been tampered with" >&2 + return 1 + fi +} + +# Copy verified immutable config into the writable HERMES_HOME so the +# gateway process can read it alongside its own state files. +deploy_config_to_writable() { + # When running as root, use gosu to write as sandbox user (owner of .hermes-data). + if [ "$(id -u)" -eq 0 ]; then + gosu sandbox cp "${HERMES_IMMUTABLE}/config.yaml" "${HERMES_WRITABLE}/config.yaml" + gosu sandbox cp "${HERMES_IMMUTABLE}/.env" "${HERMES_WRITABLE}/.env" + else + cp "${HERMES_IMMUTABLE}/config.yaml" "${HERMES_WRITABLE}/config.yaml" + cp "${HERMES_IMMUTABLE}/.env" "${HERMES_WRITABLE}/.env" + fi + chmod 600 "${HERMES_WRITABLE}/config.yaml" "${HERMES_WRITABLE}/.env" 2>/dev/null || true + echo "[config] Deployed verified config to ${HERMES_WRITABLE}" >&2 +} + +install_configure_guard() { + local marker_begin="# nemoclaw-configure-guard begin" + local marker_end="# nemoclaw-configure-guard end" + local snippet + read -r -d '' snippet <<'GUARD' || true +# nemoclaw-configure-guard begin +hermes() { + case "$1" in + setup|doctor) + echo "Error: 'hermes $1' cannot modify config inside the sandbox." >&2 + echo "The sandbox config is read-only (Landlock enforced) for security." >&2 + echo "" >&2 + echo "To change your configuration, exit the sandbox and run:" >&2 + echo " nemoclaw onboard --resume" >&2 + return 1 + ;; + esac + command hermes "$@" +} +# nemoclaw-configure-guard end +GUARD + + for rc_file in "${_SANDBOX_HOME}/.bashrc" "${_SANDBOX_HOME}/.profile"; do + if [ -f "$rc_file" ] && grep -qF "$marker_begin" "$rc_file" 2>/dev/null; then + local tmp + tmp="$(mktemp)" + awk -v b="$marker_begin" -v e="$marker_end" \ + '$0==b{s=1;next} $0==e{s=0;next} !s' "$rc_file" >"$tmp" + printf '%s\n' "$snippet" >>"$tmp" + cat "$tmp" >"$rc_file" + rm -f "$tmp" + elif [ -w "$rc_file" ] || [ -w "$(dirname "$rc_file")" ]; then + printf '\n%s\n' "$snippet" >>"$rc_file" + fi + done +} + +validate_hermes_symlinks() { + local entry name target expected + for entry in /sandbox/.hermes/*; do + [ -L "$entry" ] || continue + name="$(basename "$entry")" + target="$(readlink -f "$entry" 2>/dev/null || true)" + expected="/sandbox/.hermes-data/$name" + if [ "$target" != "$expected" ]; then + echo "[SECURITY] Symlink $entry points to unexpected target: $target (expected $expected)" >&2 + return 1 + fi + done +} + +harden_hermes_symlinks() { + local entry hardened failed + hardened=0 + failed=0 + + if ! command -v chattr >/dev/null 2>&1; then + echo "[SECURITY] chattr not available — relying on DAC + Landlock for .hermes hardening" >&2 + return 0 + fi + + if chattr +i /sandbox/.hermes 2>/dev/null; then + hardened=$((hardened + 1)) + else + failed=$((failed + 1)) + fi + + for entry in /sandbox/.hermes/*; do + [ -L "$entry" ] || continue + if chattr +i "$entry" 2>/dev/null; then + hardened=$((hardened + 1)) + else + failed=$((failed + 1)) + fi + done + + if [ "$failed" -gt 0 ]; then + echo "[SECURITY] Immutable hardening applied to $hardened path(s); $failed path(s) could not be hardened — continuing with DAC + Landlock" >&2 + elif [ "$hardened" -gt 0 ]; then + echo "[SECURITY] Immutable hardening applied to /sandbox/.hermes and validated symlinks" >&2 + fi +} + +configure_messaging_channels() { + # Channel entries are baked into config.yaml at image build time via + # NEMOCLAW_MESSAGING_CHANNELS_B64. Placeholder tokens flow through to + # the L7 proxy for rewriting at egress. + [ -n "${TELEGRAM_BOT_TOKEN:-}" ] || [ -n "${DISCORD_BOT_TOKEN:-}" ] || [ -n "${SLACK_BOT_TOKEN:-}" ] || return 0 + + echo "[channels] Messaging channels active (baked at build time):" >&2 + [ -n "${TELEGRAM_BOT_TOKEN:-}" ] && echo "[channels] telegram" >&2 + [ -n "${DISCORD_BOT_TOKEN:-}" ] && echo "[channels] discord" >&2 + [ -n "${SLACK_BOT_TOKEN:-}" ] && echo "[channels] slack" >&2 + return 0 +} + +print_dashboard_urls() { + local local_url + 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 +} + +# ── 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. +# socat bridges 0.0.0.0:PUBLIC_PORT → 127.0.0.1:INTERNAL_PORT. +SOCAT_PID="" +start_socat_forwarder() { + if ! command -v socat >/dev/null 2>&1; then + echo "[gateway] socat not available — 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 + 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 +} + +# ── URL-decode proxy ───────────────────────────────────────────── +# Python HTTP clients (httpx) URL-encode colons in paths, breaking +# OpenShell's openshell:resolve:env: placeholder pattern. This proxy +# sits between the Hermes process and the OpenShell proxy, URL-decoding +# paths so the L7 proxy recognizes the placeholders. +DECODE_PROXY_PID="" +DECODE_PROXY_PORT=3129 +start_decode_proxy() { + nohup python3 /usr/local/bin/nemoclaw-decode-proxy >/dev/null 2>&1 & + DECODE_PROXY_PID=$! + # Wait for it to start listening + local attempts=0 + while [ "$attempts" -lt 10 ]; do + if ss -tln 2>/dev/null | grep -q "127.0.0.1:${DECODE_PROXY_PORT}"; then + echo "[gateway] decode-proxy listening on 127.0.0.1:${DECODE_PROXY_PORT} (pid $DECODE_PROXY_PID)" >&2 + return + fi + sleep 0.5 + attempts=$((attempts + 1)) + done + echo "[gateway] decode-proxy failed to start — placeholder rewriting may not work" >&2 +} + +# Forward SIGTERM/SIGINT to child processes for graceful shutdown. +cleanup() { + echo "[gateway] received signal, forwarding to children..." >&2 + local gateway_status=0 + kill -TERM "$GATEWAY_PID" 2>/dev/null || true + [ -n "${SOCAT_PID:-}" ] && kill -TERM "$SOCAT_PID" 2>/dev/null || true + [ -n "${DECODE_PROXY_PID:-}" ] && kill -TERM "$DECODE_PROXY_PID" 2>/dev/null || true + wait "$GATEWAY_PID" 2>/dev/null || gateway_status=$? + exit "$gateway_status" +} + +# ── Proxy environment ──────────────────────────────────────────── +PROXY_HOST="${NEMOCLAW_PROXY_HOST:-10.200.0.1}" +PROXY_PORT="${NEMOCLAW_PROXY_PORT:-3128}" +_PROXY_URL="http://${PROXY_HOST}:${PROXY_PORT}" +_NO_PROXY_VAL="localhost,127.0.0.1,::1,${PROXY_HOST}" +export HTTP_PROXY="$_PROXY_URL" +export HTTPS_PROXY="$_PROXY_URL" +export NO_PROXY="$_NO_PROXY_VAL" +export http_proxy="$_PROXY_URL" +export https_proxy="$_PROXY_URL" +export no_proxy="$_NO_PROXY_VAL" + +_PROXY_MARKER_BEGIN="# nemoclaw-proxy-config begin" +_PROXY_MARKER_END="# nemoclaw-proxy-config end" +_PROXY_SNIPPET="${_PROXY_MARKER_BEGIN} +export HTTP_PROXY=\"$_PROXY_URL\" +export HTTPS_PROXY=\"$_PROXY_URL\" +export NO_PROXY=\"$_NO_PROXY_VAL\" +export http_proxy=\"$_PROXY_URL\" +export https_proxy=\"$_PROXY_URL\" +export no_proxy=\"$_NO_PROXY_VAL\" +${_PROXY_MARKER_END}" + +if [ "$(id -u)" -eq 0 ]; then + _SANDBOX_HOME=$(getent passwd sandbox 2>/dev/null | cut -d: -f6) + _SANDBOX_HOME="${_SANDBOX_HOME:-/sandbox}" +else + _SANDBOX_HOME="${HOME:-/sandbox}" +fi + +_write_proxy_snippet() { + local target="$1" + if [ -f "$target" ] && grep -qF "$_PROXY_MARKER_BEGIN" "$target" 2>/dev/null; then + local tmp + tmp="$(mktemp)" + awk -v b="$_PROXY_MARKER_BEGIN" -v e="$_PROXY_MARKER_END" \ + '$0==b{s=1;next} $0==e{s=0;next} !s' "$target" >"$tmp" + printf '%s\n' "$_PROXY_SNIPPET" >>"$tmp" + cat "$tmp" >"$target" + rm -f "$tmp" + return 0 + fi + printf '\n%s\n' "$_PROXY_SNIPPET" >>"$target" +} + +# Write proxy snippet — may fail after capsh drops cap_dac_override +# (root can no longer write sandbox-owned files). Non-fatal. +if [ -w "$_SANDBOX_HOME" ]; then + _write_proxy_snippet "${_SANDBOX_HOME}/.bashrc" 2>/dev/null || true + _write_proxy_snippet "${_SANDBOX_HOME}/.profile" 2>/dev/null || true +fi + +# ── Main ───────────────────────────────────────────────────────── + +echo 'Setting up NemoClaw (Hermes)...' >&2 + +# ── Non-root fallback ────────────────────────────────────────── +if [ "$(id -u)" -ne 0 ]; then + echo "[gateway] Running as non-root (uid=$(id -u)) — privilege separation disabled" >&2 + export HOME=/sandbox + export HERMES_HOME="${HERMES_WRITABLE}" + + if ! verify_config_integrity; then + echo "[SECURITY] Config integrity check failed — refusing to start (non-root mode)" >&2 + exit 1 + fi + deploy_config_to_writable + install_configure_guard + configure_messaging_channels + + if [ ${#NEMOCLAW_CMD[@]} -gt 0 ]; then + exec "${NEMOCLAW_CMD[@]}" + fi + + touch /tmp/gateway.log + chmod 600 /tmp/gateway.log + + # Start decode proxy and Hermes gateway + start_decode_proxy + HERMES_HOME="${HERMES_WRITABLE}" \ + HTTPS_PROXY="http://127.0.0.1:${DECODE_PROXY_PORT}" \ + 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 "$HERMES" gateway run >/tmp/gateway.log 2>&1 & + GATEWAY_PID=$! + echo "[gateway] hermes gateway launched (pid $GATEWAY_PID)" >&2 + trap cleanup SIGTERM SIGINT + start_socat_forwarder + print_dashboard_urls + + wait "$GATEWAY_PID" + exit $? +fi + +# ── Root path (full privilege separation via gosu) ───────────── + +verify_config_integrity +deploy_config_to_writable +install_configure_guard +configure_messaging_channels + +if [ ${#NEMOCLAW_CMD[@]} -gt 0 ]; then + exec gosu sandbox "${NEMOCLAW_CMD[@]}" +fi + +# SECURITY: Protect gateway log from sandbox user tampering +touch /tmp/gateway.log +chown gateway:gateway /tmp/gateway.log +chmod 600 /tmp/gateway.log + +# Verify ALL symlinks in .hermes point to expected .hermes-data targets. +validate_hermes_symlinks + +# Lock .hermes directory after validation. +harden_hermes_symlinks + +# Start the gateway as the 'gateway' user. +# Start decode proxy and gateway +start_decode_proxy +HERMES_HOME="${HERMES_WRITABLE}" \ + HTTPS_PROXY="http://127.0.0.1:${DECODE_PROXY_PORT}" \ + 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 & +GATEWAY_PID=$! +echo "[gateway] hermes gateway launched as 'gateway' user (pid $GATEWAY_PID)" >&2 +trap cleanup SIGTERM SIGINT +start_socat_forwarder +print_dashboard_urls + +# Keep container running by waiting on the gateway process. +wait "$GATEWAY_PID" diff --git a/agents/openclaw/manifest.yaml b/agents/openclaw/manifest.yaml new file mode 100644 index 00000000000..e162ed93c8b --- /dev/null +++ b/agents/openclaw/manifest.yaml @@ -0,0 +1,90 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Agent manifest for OpenClaw. +# Declares the integration contract between NemoClaw and the sandboxed agent. +# This documents the current (default) agent — artifacts still live at root +# level for backward compatibility. Phase 3 of the multi-agent plan will +# move them here. + +name: openclaw +display_name: "OpenClaw" +description: "Gateway-based AI agent with plugin ecosystem (openclaw.ai)" +version_constraint: ">=2026.3.0" +language: nodejs +license: Apache-2.0 +homepage: "https://openclaw.ai" + +# ── Binary & process ──────────────────────────────────────────── +install_method: npm # npm install -g openclaw@ +binary_path: /usr/local/bin/openclaw +gateway_command: "openclaw gateway run" + +# ── Health probe ──────────────────────────────────────────────── +health_probe: + url: "http://localhost:18789/" + port: 18789 + timeout_seconds: 30 + +# ── Dashboard / UI ────────────────────────────────────────────── +forward_ports: + - 18789 + +# ── Configuration ─────────────────────────────────────────────── +config: + immutable_dir: /sandbox/.openclaw + writable_dir: /sandbox/.openclaw-data + config_file: openclaw.json # relative to immutable_dir + format: json + +# ── State directories ────────────────────────────────────────── +state_dirs: + - agents + - extensions + - workspace + - skills + - hooks + - identity + - devices + - canvas + - cron + - memory + - telegram + - credentials + +# ── Authentication ────────────────────────────────────────────── +device_pairing: true +web_auth_method: device_pairing + +# ── Messaging platforms ───────────────────────────────────────── +messaging_platforms: + supported: + - telegram + - discord + - slack + +# ── Inference ─────────────────────────────────────────────────── +inference: + provider_type: gateway_managed + proxy_support: explicit # configured in openclaw.json providers block + +# ── Phone-home hosts ─────────────────────────────────────────── +phone_home_hosts: + - openclaw.ai + - docs.openclaw.ai + - clawhub.ai + +# ── Package registry ─────────────────────────────────────────── +package_registry: + hosts: + - registry.npmjs.org + binary: /usr/local/bin/npm + +# ── Current artifact locations ───────────────────────────────── +# These will move into agents/openclaw/ in Phase 3. +_legacy_paths: + dockerfile_base: Dockerfile.base + dockerfile: Dockerfile + start_script: scripts/nemoclaw-start.sh + policy: nemoclaw-blueprint/policies/openclaw-sandbox.yaml + plugin: nemoclaw/ diff --git a/agents/openclaw/policy-permissive.yaml b/agents/openclaw/policy-permissive.yaml new file mode 100644 index 00000000000..3f5250efdaf --- /dev/null +++ b/agents/openclaw/policy-permissive.yaml @@ -0,0 +1,214 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Permissive policy for OpenClaw — used by --dangerously-skip-permissions. +# All known OpenClaw-relevant endpoints opened with access: full (no L7 filtering). +# Filesystem: include_workdir: true makes the sandbox home directory writable. +# +# WARNING: This policy disables most sandbox security restrictions. +# Do not use in production. + +version: 1 + +filesystem_policy: + include_workdir: true + read_only: + - /usr + - /lib + - /proc + - /dev/urandom + - /app + - /etc + - /var/log + read_write: + - /tmp + - /dev/null + - /sandbox/.openclaw-data + - /sandbox/.nemoclaw + +landlock: + compatibility: best_effort + +process: + run_as_user: sandbox + run_as_group: sandbox + +network_policies: + + claude_code: + name: claude_code + endpoints: + - host: api.anthropic.com + port: 443 + access: full + - host: statsig.anthropic.com + port: 443 + access: full + - host: sentry.io + port: 443 + access: full + + nvidia: + name: nvidia + endpoints: + - host: integrate.api.nvidia.com + port: 443 + access: full + - host: inference-api.nvidia.com + port: 443 + access: full + + github: + name: github + endpoints: + - host: github.com + port: 443 + access: full + - host: api.github.com + port: 443 + access: full + + clawhub: + name: clawhub + endpoints: + - host: clawhub.ai + port: 443 + access: full + + openclaw_api: + name: openclaw_api + endpoints: + - host: openclaw.ai + port: 443 + access: full + + openclaw_docs: + name: openclaw_docs + endpoints: + - host: docs.openclaw.ai + port: 443 + access: full + + npm_registry: + name: npm_registry + endpoints: + - host: registry.npmjs.org + port: 443 + access: full + - host: registry.yarnpkg.com + port: 443 + access: full + + # ── Messaging endpoints ── + + telegram: + name: telegram + endpoints: + - host: api.telegram.org + port: 443 + access: full + + discord: + name: discord + endpoints: + - host: discord.com + port: 443 + access: full + - host: gateway.discord.gg + port: 443 + access: full + - host: cdn.discordapp.com + port: 443 + access: full + - host: media.discordapp.net + port: 443 + access: full + + slack: + name: slack + endpoints: + - host: slack.com + port: 443 + access: full + - host: api.slack.com + port: 443 + access: full + - host: hooks.slack.com + port: 443 + access: full + - host: wss-primary.slack.com + port: 443 + access: full + - host: wss-backup.slack.com + port: 443 + access: full + + # ── Third-party services ── + + brew: + name: brew + endpoints: + - host: formulae.brew.sh + port: 443 + access: full + - host: ghcr.io + port: 443 + access: full + - host: raw.githubusercontent.com + port: 443 + access: full + - host: objects.githubusercontent.com + port: 443 + access: full + - host: pkg-containers.githubusercontent.com + port: 443 + access: full + + jira: + name: jira + endpoints: + - host: "*.atlassian.net" + port: 443 + access: full + - host: api.atlassian.com + port: 443 + access: full + - host: auth.atlassian.com + port: 443 + access: full + + outlook: + name: outlook + endpoints: + - host: outlook.office365.com + port: 443 + access: full + - host: outlook.office.com + port: 443 + access: full + - host: graph.microsoft.com + port: 443 + access: full + - host: login.microsoftonline.com + port: 443 + access: full + + huggingface: + name: huggingface + endpoints: + - host: huggingface.co + port: 443 + access: full + - host: cdn-lfs.huggingface.co + port: 443 + access: full + - host: router.huggingface.co + port: 443 + access: full + + brave: + name: brave + endpoints: + - host: api.search.brave.com + port: 443 + access: full diff --git a/bin/lib/agent-defs.js b/bin/lib/agent-defs.js new file mode 100644 index 00000000000..086c5bcc4ce --- /dev/null +++ b/bin/lib/agent-defs.js @@ -0,0 +1,7 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// Thin re-export shim — the implementation lives in src/lib/agent-defs.ts, +// compiled to dist/lib/agent-defs.js. + +module.exports = require("../../dist/lib/agent-defs"); diff --git a/bin/lib/agent-onboard.js b/bin/lib/agent-onboard.js new file mode 100644 index 00000000000..94cd467c8cf --- /dev/null +++ b/bin/lib/agent-onboard.js @@ -0,0 +1,7 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// Thin re-export shim — the implementation lives in src/lib/agent-onboard.ts, +// compiled to dist/lib/agent-onboard.js. + +module.exports = require("../../dist/lib/agent-onboard"); diff --git a/bin/lib/agent-runtime.js b/bin/lib/agent-runtime.js new file mode 100644 index 00000000000..447eee0dc81 --- /dev/null +++ b/bin/lib/agent-runtime.js @@ -0,0 +1,7 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// Thin re-export shim — the implementation lives in src/lib/agent-runtime.ts, +// compiled to dist/lib/agent-runtime.js. + +module.exports = require("../../dist/lib/agent-runtime"); diff --git a/package-lock.json b/package-lock.json index 9b0036cddea..912de862385 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ ], "license": "Apache-2.0", "dependencies": { + "js-yaml": "^4.1.1", "p-retry": "^4.6.2", "yaml": "^2.8.3" }, @@ -23,7 +24,7 @@ "@commitlint/config-conventional": "^20.5.0", "@eslint/js": "^10.0.1", "@j178/prek": "^0.3.6", - "@types/node": "^25.5.0", + "@types/node": "^25.5.2", "@typescript-eslint/parser": "^8.58.1", "@vitest/coverage-v8": "^4.1.0", "eslint": "^10.1.0", @@ -1517,9 +1518,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.5.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", - "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", + "version": "25.5.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.2.tgz", + "integrity": "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==", "dev": true, "license": "MIT", "dependencies": { @@ -1870,7 +1871,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/array-ify": { @@ -3407,7 +3407,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, "license": "MIT", "dependencies": { "argparse": "^2.0.1" diff --git a/package.json b/package.json index 229bb421627..037ee332ee9 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "prepublishOnly": "cd nemoclaw && env -u npm_config_global -u npm_config_prefix -u npm_config_omit npm install --ignore-scripts && ./node_modules/.bin/tsc" }, "dependencies": { + "js-yaml": "^4.1.1", "p-retry": "^4.6.2", "yaml": "^2.8.3" }, @@ -52,7 +53,7 @@ "@commitlint/config-conventional": "^20.5.0", "@eslint/js": "^10.0.1", "@j178/prek": "^0.3.6", - "@types/node": "^25.5.0", + "@types/node": "^25.5.2", "@typescript-eslint/parser": "^8.58.1", "@vitest/coverage-v8": "^4.1.0", "eslint": "^10.1.0", diff --git a/src/lib/agent-defs.ts b/src/lib/agent-defs.ts new file mode 100644 index 00000000000..f9e8cecee62 --- /dev/null +++ b/src/lib/agent-defs.ts @@ -0,0 +1,275 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// Agent definition loader — reads agents/*/manifest.yaml and provides +// accessors for agent-specific configuration used during onboarding. + +import fs from "fs"; +import path from "path"; +// eslint-disable-next-line @typescript-eslint/no-require-imports +const yaml = require("js-yaml"); + +import { ROOT } from "./runner"; + +export const AGENTS_DIR = path.join(ROOT, "agents"); + +export interface AgentHealthProbe { + url: string; + port: number; + timeout_seconds: number; +} + +export interface AgentConfigPaths { + immutableDir: string; + writableDir: string; + configFile: string; + envFile: string | null; + format: string; +} + +export interface AgentLegacyPaths { + dockerfileBase: string | null; + dockerfile: string | null; + startScript: string | null; + policy: string | null; + plugin: string | null; +} + +export interface AgentDefinition { + name: string; + description?: string; + display_name?: string; + binary_path?: string; + gateway_command?: string; + device_pairing?: boolean; + phone_home_hosts?: string[]; + forward_ports?: number[]; + health_probe?: AgentHealthProbe; + config?: Record; + state_dirs?: string[]; + messaging_platforms?: { supported?: string[] }; + _legacy_paths?: Record; + agentDir: string; + manifestPath: string; + readonly displayName: string; + readonly healthProbe: AgentHealthProbe; + readonly forwardPort: number; + readonly configPaths: AgentConfigPaths; + readonly stateDirs: string[]; + readonly hasDevicePairing: boolean; + readonly phoneHomeHosts: string[]; + readonly messagingPlatforms: string[]; + readonly dockerfileBasePath: string | null; + readonly dockerfilePath: string | null; + readonly startScriptPath: string | null; + readonly policyAdditionsPath: string | null; + readonly policyPermissivePath: string | null; + readonly pluginDir: string | null; + readonly legacyPaths: AgentLegacyPaths | null; + [key: string]: unknown; +} + +export interface AgentChoice { + name: string; + displayName: string; + description: string; +} + +const _cache = new Map(); + +/** + * List available agent names by scanning agents/ for directories with + * a manifest.yaml file. + */ +export function listAgents(): string[] { + if (!fs.existsSync(AGENTS_DIR)) return []; + return fs + .readdirSync(AGENTS_DIR, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .filter((d) => fs.existsSync(path.join(AGENTS_DIR, d.name, "manifest.yaml"))) + .map((d) => d.name) + .sort(); +} + +/** + * Load and parse an agent manifest. + */ +export function loadAgent(name: string): AgentDefinition { + if (_cache.has(name)) return _cache.get(name)!; + + const manifestPath = path.join(AGENTS_DIR, name, "manifest.yaml"); + if (!fs.existsSync(manifestPath)) { + throw new Error(`Agent '${name}' not found: ${manifestPath}`); + } + + const raw = yaml.load(fs.readFileSync(manifestPath, "utf8")) as Record; + const agentDir = path.join(AGENTS_DIR, name); + + const agent: AgentDefinition = { + // Raw manifest fields + ...raw, + + // Computed paths + agentDir, + manifestPath, + + get displayName(): string { + return (raw.display_name as string) || (raw.name as string); + }, + + get healthProbe(): AgentHealthProbe { + return ( + (raw.health_probe as AgentHealthProbe) || { + url: "http://localhost:18789/", + port: 18789, + timeout_seconds: 30, + } + ); + }, + + get forwardPort(): number { + const ports = (raw.forward_ports as number[]) || []; + return ports[0] || 18789; + }, + + get configPaths(): AgentConfigPaths { + const cfg = (raw.config as Record) || {}; + return { + immutableDir: cfg.immutable_dir || "/sandbox/.openclaw", + writableDir: cfg.writable_dir || "/sandbox/.openclaw-data", + configFile: cfg.config_file || "openclaw.json", + envFile: cfg.env_file || null, + format: cfg.format || "json", + }; + }, + + get stateDirs(): string[] { + return (raw.state_dirs as string[]) || []; + }, + + get hasDevicePairing(): boolean { + return raw.device_pairing === true; + }, + + get phoneHomeHosts(): string[] { + return (raw.phone_home_hosts as string[]) || []; + }, + + get messagingPlatforms(): string[] { + const mp = (raw.messaging_platforms as { supported?: string[] }) || {}; + return mp.supported || []; + }, + + get dockerfileBasePath(): string | null { + const p = path.join(agentDir, "Dockerfile.base"); + return fs.existsSync(p) ? p : null; + }, + + get dockerfilePath(): string | null { + const p = path.join(agentDir, "Dockerfile"); + return fs.existsSync(p) ? p : null; + }, + + get startScriptPath(): string | null { + const p = path.join(agentDir, "start.sh"); + return fs.existsSync(p) ? p : null; + }, + + get policyAdditionsPath(): string | null { + const p = path.join(agentDir, "policy-additions.yaml"); + return fs.existsSync(p) ? p : null; + }, + + get policyPermissivePath(): string | null { + const p = path.join(agentDir, "policy-permissive.yaml"); + return fs.existsSync(p) ? p : null; + }, + + get pluginDir(): string | null { + const p = path.join(agentDir, "plugin"); + return fs.existsSync(p) ? p : null; + }, + + get legacyPaths(): AgentLegacyPaths | null { + if (!raw._legacy_paths) return null; + const lp = raw._legacy_paths as Record; + return { + dockerfileBase: lp.dockerfile_base ? path.join(ROOT, lp.dockerfile_base) : null, + dockerfile: lp.dockerfile ? path.join(ROOT, lp.dockerfile) : null, + startScript: lp.start_script ? path.join(ROOT, lp.start_script) : null, + policy: lp.policy ? path.join(ROOT, lp.policy) : null, + plugin: lp.plugin ? path.join(ROOT, lp.plugin) : null, + }; + }, + } as AgentDefinition; + + _cache.set(name, agent); + return agent; +} + +/** + * Get agent choices for interactive prompt (name, display_name, description). + * OpenClaw is listed first as the default. + */ +export function getAgentChoices(): AgentChoice[] { + const agents = listAgents().map((name) => { + const a = loadAgent(name); + return { + name: a.name as string, + displayName: a.displayName, + description: (a.description as string) || "", + }; + }); + + agents.sort((a, b) => { + if (a.name === "openclaw") return -1; + if (b.name === "openclaw") return 1; + return a.name.localeCompare(b.name); + }); + + return agents; +} + +/** + * Resolve the effective agent from CLI flags, env vars, or session state. + * Priority: explicit flag > env var > session > default ("openclaw"). + */ +export function resolveAgentName({ + agentFlag = null, + session = null, +}: { + agentFlag?: string | null; + session?: { agent?: string } | null; +} = {}): string { + if (agentFlag) { + const available = listAgents(); + if (!available.includes(agentFlag)) { + const choices = available.join(", "); + throw new Error(`Unknown agent '${agentFlag}'. Available: ${choices}`); + } + return agentFlag; + } + + const envAgent = process.env.NEMOCLAW_AGENT; + if (envAgent) { + const available = listAgents(); + if (!available.includes(envAgent)) { + const choices = available.join(", "); + throw new Error(`Unknown agent '${envAgent}' (from NEMOCLAW_AGENT). Available: ${choices}`); + } + return envAgent; + } + + if (session && session.agent) { + const available = listAgents(); + if (!available.includes(session.agent)) { + console.error( + ` Warning: session references unknown agent '${session.agent}', falling back to openclaw.`, + ); + return "openclaw"; + } + return session.agent; + } + + return "openclaw"; +} diff --git a/src/lib/agent-onboard.ts b/src/lib/agent-onboard.ts new file mode 100644 index 00000000000..3f97260f95a --- /dev/null +++ b/src/lib/agent-onboard.ts @@ -0,0 +1,243 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// Agent-specific onboarding logic — called from onboard.ts when a +// non-default agent (e.g. Hermes) is selected via --agent flag or +// NEMOCLAW_AGENT env var. The OpenClaw path never touches this module. + +import fs from "fs"; +import os from "os"; +import path from "path"; +import { spawnSync } from "child_process"; + +import { ROOT, run, shellQuote } from "./runner"; +import { loadAgent, resolveAgentName, type AgentDefinition } from "./agent-defs"; +import { getProviderSelectionConfig } from "./inference-config"; +import * as onboardSession from "./onboard-session"; + +export interface OnboardContext { + step: (current: number, total: number, message: string) => void; + runCaptureOpenshell: (args: string[], opts?: { ignoreError?: boolean }) => string | null; + openshellShellCommand: (args: string[]) => string; + buildSandboxConfigSyncScript: (config: Record) => string; + writeSandboxConfigSyncFile: (script: string) => string; + cleanupTempDir: (file: string, prefix: string) => void; + startRecordedStep: (stepName: string, updates: Record) => void; + skippedStepMessage: (stepName: string, sandboxName: string) => void; +} + +/** + * Resolve the effective agent from CLI flags, env, or session. + * Returns null for openclaw (default path), loaded agent object otherwise. + */ +export function resolveAgent({ + agentFlag = null, + session = null, +}: { + agentFlag?: string | null; + session?: { agent?: string } | null; +} = {}): AgentDefinition | null { + const name = resolveAgentName({ agentFlag, session }); + if (name === "openclaw") return null; + return loadAgent(name); +} + +/** + * Stage build context for an agent-specific sandbox image. + * Builds the base image if the agent defines one and it's not cached locally. + */ +export function createAgentSandbox(agent: AgentDefinition): { + buildCtx: string; + stagedDockerfile: string; +} { + const agentDockerfile = agent.dockerfilePath; + const baseDockerfile = agent.dockerfileBasePath; + + if (baseDockerfile) { + const baseImageTag = `ghcr.io/nvidia/nemoclaw/${agent.name}-sandbox-base:latest`; + const inspectResult = run(`docker image inspect ${shellQuote(baseImageTag)} >/dev/null 2>&1`, { + ignoreError: true, + }); + if (inspectResult.status !== 0) { + console.log(` Building ${agent.displayName} base image (first time only)...`); + run( + `docker build -f ${shellQuote(baseDockerfile)} -t ${shellQuote(baseImageTag)} ${shellQuote(ROOT)}`, + { stdio: ["ignore", "inherit", "inherit"] }, + ); + console.log(` \u2713 Base image built: ${baseImageTag}`); + } else { + console.log(` Base image exists: ${baseImageTag}`); + } + } + + const buildCtx = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-build-")); + fs.cpSync(ROOT, buildCtx, { + recursive: true, + filter: (src) => { + const base = path.basename(src); + return !["node_modules", ".git", ".venv", "__pycache__", ".claude"].includes(base); + }, + }); + const stagedDockerfile = path.join(buildCtx, "Dockerfile"); + fs.copyFileSync(agentDockerfile!, stagedDockerfile); + console.log(` Using ${agent.displayName} Dockerfile: ${agentDockerfile}`); + + return { buildCtx, stagedDockerfile }; +} + +/** + * Get the agent-specific network policy path, or null to use the default. + */ +export function getAgentPolicyPath(agent: AgentDefinition): string | null { + return agent.policyAdditionsPath || null; +} + +/** + * Get the agent-specific permissive policy path, or null to use the global fallback. + */ +export function getAgentPermissivePolicyPath(agent: AgentDefinition): string | null { + return agent.policyPermissivePath || null; +} + +function sleep(seconds: number): void { + spawnSync("sleep", [String(seconds)]); +} + +/** + * Handle the full agent setup step (step 7) including resume detection. + * For non-OpenClaw agents: writes config into the sandbox and verifies + * the agent's health probe. + */ +export async function handleAgentSetup( + sandboxName: string, + model: string, + provider: string, + agent: AgentDefinition, + resume: boolean, + _session: unknown, + ctx: OnboardContext, +): Promise { + const { + step, + runCaptureOpenshell, + openshellShellCommand, + buildSandboxConfigSyncScript, + writeSandboxConfigSyncFile, + cleanupTempDir, + startRecordedStep, + skippedStepMessage, + } = ctx; + + if (resume && sandboxName) { + const probe = agent.healthProbe; + if (probe?.url) { + const result = runCaptureOpenshell( + ["sandbox", "exec", sandboxName, "curl", "-sf", "--max-time", "3", probe.url], + { ignoreError: true }, + ); + if (result && result.includes("ok")) { + skippedStepMessage("agent_setup", sandboxName); + onboardSession.markStepComplete("agent_setup", { sandboxName, provider, model }); + return; + } + } + } + + startRecordedStep("agent_setup", { sandboxName, provider, model }); + step(7, 8, `Setting up ${agent.displayName} inside sandbox`); + + const selectionConfig = getProviderSelectionConfig(provider, model); + if (selectionConfig) { + const sandboxConfig = { + ...selectionConfig, + agent: agent.name, + onboardedAt: new Date().toISOString(), + }; + const script = buildSandboxConfigSyncScript(sandboxConfig); + const scriptFile = writeSandboxConfigSyncFile(script); + try { + run( + `${openshellShellCommand(["sandbox", "connect", sandboxName])} < ${shellQuote(scriptFile)}`, + { stdio: ["ignore", "ignore", "inherit"] }, + ); + } finally { + cleanupTempDir(scriptFile, "nemoclaw-sync"); + } + } + + const probe = agent.healthProbe; + if (probe?.url) { + const timeoutSecs = probe.timeout_seconds || 60; + const pollInterval = 3; + const maxAttempts = Math.ceil(timeoutSecs / pollInterval); + console.log(` Waiting for ${agent.displayName} gateway (up to ${timeoutSecs}s)...`); + let healthy = false; + for (let i = 0; i < maxAttempts; i++) { + const result = runCaptureOpenshell( + ["sandbox", "exec", sandboxName, "curl", "-sf", "--max-time", "3", probe.url], + { ignoreError: true }, + ); + if (result && result.includes("ok")) { + healthy = true; + break; + } + sleep(pollInterval); + } + if (healthy) { + console.log(` \u2713 ${agent.displayName} gateway is healthy`); + } else { + console.log( + ` \u26a0 ${agent.displayName} gateway did not respond within ${timeoutSecs}s.`, + ); + console.log( + ` The gateway may still be starting. Check: nemoclaw ${sandboxName} logs`, + ); + } + } else { + console.log(` \u2713 ${agent.displayName} configured inside sandbox`); + } + + onboardSession.markStepComplete("agent_setup", { sandboxName, provider, model }); +} + +/** + * Get dashboard info for a non-OpenClaw agent. + */ +export function getAgentDashboardInfo(agent: AgentDefinition): { + port: number; + displayName: string; +} { + return { + port: agent.forwardPort, + displayName: agent.displayName, + }; +} + +/** + * Print the dashboard UI section for a non-OpenClaw agent. + */ +export function printDashboardUi( + _sandboxName: string, + token: string | null, + agent: AgentDefinition, + deps: { + note: (msg: string) => void; + buildControlUiUrls: (token: string | null, port: number) => string[]; + }, +): void { + const info = getAgentDashboardInfo(agent); + if (token) { + console.log(` ${info.displayName} UI (tokenized URL; treat it like a password)`); + console.log(` Port ${info.port} must be forwarded before opening this URL.`); + for (const url of deps.buildControlUiUrls(token, info.port)) { + console.log(` ${url}`); + } + } else { + deps.note(" Could not read gateway token from the sandbox (download failed)."); + console.log(` ${info.displayName} UI`); + console.log(` Port ${info.port} must be forwarded before opening this URL.`); + for (const url of deps.buildControlUiUrls(null, info.port)) { + console.log(` ${url}`); + } + } +} diff --git a/src/lib/agent-runtime.ts b/src/lib/agent-runtime.ts new file mode 100644 index 00000000000..85a1e969e25 --- /dev/null +++ b/src/lib/agent-runtime.ts @@ -0,0 +1,92 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// Agent-specific runtime logic — called from nemoclaw.ts when the active +// sandbox uses a non-OpenClaw agent. Reads the agent from the onboard session +// and provides agent-aware health probes, recovery scripts, and display names. +// When the session agent is openclaw (or absent), all functions return +// defaults that match the hardcoded OpenClaw values on main. + +import * as registry from "./registry"; +import * as onboardSession from "./onboard-session"; +import { loadAgent, type AgentDefinition } from "./agent-defs"; +import { shellQuote } from "./runner"; + +/** + * Resolve the agent for a sandbox. Checks the per-sandbox registry first + * (so status/connect/recovery use the right agent even when multiple + * sandboxes exist), then falls back to the global onboard session. + * Returns the loaded agent definition for non-OpenClaw agents, or null. + */ +export function getSessionAgent(sandboxName?: string): AgentDefinition | null { + try { + if (sandboxName) { + const sb = registry.getSandbox(sandboxName); + if (sb?.agent && sb.agent !== "openclaw") { + return loadAgent(sb.agent); + } + if (sb?.agent === "openclaw" || (sb && !sb.agent)) { + return null; + } + } + const session = onboardSession.loadSession(); + const name = session?.agent || "openclaw"; + if (name === "openclaw") return null; + return loadAgent(name); + } catch { + return null; + } +} + +/** + * Get the health probe URL for the agent. + * Returns the agent's configured probe URL, or the OpenClaw default. + */ +export function getHealthProbeUrl(agent: AgentDefinition | null): string { + if (!agent) return "http://127.0.0.1:18789/"; + return agent.healthProbe?.url || "http://127.0.0.1:18789/"; +} + +/** + * Build the recovery shell script for a non-OpenClaw agent. + * Returns the script string, or null if agent is null (use existing inline + * OpenClaw script instead). + */ +export function buildRecoveryScript(agent: AgentDefinition | null): string | null { + if (!agent) return null; + + const probeUrl = getHealthProbeUrl(agent); + const binaryPath = agent.binary_path || "/usr/local/bin/openclaw"; + const gatewayCmd = agent.gateway_command || "openclaw gateway run"; + const isHermes = agent.name === "hermes"; + const hermesHome = isHermes ? "export HERMES_HOME=/sandbox/.hermes-data; " : ""; + + return [ + "[ -f ~/.bashrc ] && . ~/.bashrc 2>/dev/null;", + hermesHome, + `if curl -sf --max-time 3 ${shellQuote(probeUrl)} > /dev/null 2>&1; then echo ALREADY_RUNNING; exit 0; fi;`, + "rm -f /tmp/gateway.log;", + "touch /tmp/gateway.log; chmod 600 /tmp/gateway.log;", + `AGENT_BIN=${shellQuote(binaryPath as string)}; if [ ! -x "$AGENT_BIN" ]; then AGENT_BIN="$(command -v ${shellQuote((binaryPath as string).split("/").pop()!)})"; fi;`, + 'if [ -z "$AGENT_BIN" ]; then echo AGENT_MISSING; exit 1; fi;', + `nohup ${gatewayCmd} > /tmp/gateway.log 2>&1 &`, + "GPID=$!; sleep 2;", + 'if kill -0 "$GPID" 2>/dev/null; then echo "GATEWAY_PID=$GPID"; else echo GATEWAY_FAILED; cat /tmp/gateway.log 2>/dev/null | tail -5; fi', + ].join(" "); +} + +/** + * Get the display name for the current agent. + */ +export function getAgentDisplayName(agent: AgentDefinition | null): string { + return agent ? agent.displayName : "OpenClaw"; +} + +/** + * Get the gateway command for the current agent. + */ +export function getGatewayCommand(agent: AgentDefinition | null): string { + return agent + ? (agent.gateway_command as string) || "openclaw gateway run" + : "openclaw gateway run"; +} diff --git a/src/lib/dashboard.ts b/src/lib/dashboard.ts index 45af5c59c48..ab1855f328d 100644 --- a/src/lib/dashboard.ts +++ b/src/lib/dashboard.ts @@ -27,9 +27,12 @@ export function resolveDashboardForwardTarget( } } -export function buildControlUiUrls(token: string | null = null): string[] { +export function buildControlUiUrls( + token: string | null = null, + port: number = CONTROL_UI_PORT, +): string[] { const hash = token ? `#token=${token}` : ""; - const baseUrl = `http://127.0.0.1:${CONTROL_UI_PORT}`; + const baseUrl = `http://127.0.0.1:${port}`; const urls = [`${baseUrl}${CONTROL_UI_PATH}${hash}`]; const chatUi = (process.env.CHAT_UI_URL || "").trim().replace(/\/$/, ""); if (chatUi && /^https?:\/\//i.test(chatUi) && chatUi !== baseUrl) { diff --git a/src/lib/onboard-session.ts b/src/lib/onboard-session.ts index 65a8febc449..8673a281e88 100644 --- a/src/lib/onboard-session.ts +++ b/src/lib/onboard-session.ts @@ -49,6 +49,7 @@ export interface Session { lastStepStarted: string | null; lastCompletedStep: string | null; failure: SessionFailure | null; + agent: string | null; sandboxName: string | null; provider: string | null; model: string | null; @@ -186,6 +187,7 @@ export function createSession(overrides: Partial = {}): Session { lastStepStarted: overrides.lastStepStarted || null, lastCompletedStep: overrides.lastCompletedStep || null, failure: overrides.failure || null, + agent: overrides.agent || null, sandboxName: overrides.sandboxName || null, provider: overrides.provider || null, model: overrides.model || null, @@ -220,6 +222,7 @@ export function normalizeSession(data: unknown): Session | null { mode: typeof d.mode === "string" ? d.mode : undefined, startedAt: typeof d.startedAt === "string" ? d.startedAt : undefined, updatedAt: typeof d.updatedAt === "string" ? d.updatedAt : undefined, + agent: typeof d.agent === "string" ? d.agent : null, sandboxName: typeof d.sandboxName === "string" ? d.sandboxName : null, provider: typeof d.provider === "string" ? d.provider : null, model: typeof d.model === "string" ? d.model : null, diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 7aefbff41f3..562ab5287cd 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -2,7 +2,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 // -// Interactive onboarding wizard — 7 steps from zero to running sandbox. +// Interactive onboarding wizard — 8 steps from zero to running sandbox. // Supports non-interactive mode via --non-interactive flag or // NEMOCLAW_NON_INTERACTIVE=1 env var for CI/CD pipelines. @@ -60,6 +60,7 @@ const { getMemoryInfo, planHostRemediation, } = require("../../bin/lib/preflight"); +const agentOnboard = require("../../bin/lib/agent-onboard"); // Typed modules (compiled from src/lib/*.ts → dist/lib/*.js) const gatewayState = require("../../dist/lib/gateway-state"); @@ -1556,6 +1557,16 @@ function getResumeConfigConflicts(session, opts = {}) { }); } + const requestedAgent = opts.agent || process.env.NEMOCLAW_AGENT || null; + const recordedAgent = session?.agent || null; + if (requestedAgent && recordedAgent && requestedAgent !== recordedAgent) { + conflicts.push({ + field: "agent", + requested: requestedAgent, + recorded: recordedAgent, + }); + } + return conflicts; } @@ -2264,12 +2275,14 @@ async function createSandbox( webSearchConfig = null, enabledChannels = null, fromDockerfile = null, + agent = null, dangerouslySkipPermissions = false, ) { step(6, 8, "Creating sandbox"); const sandboxName = sandboxNameOverride || (await promptValidatedSandboxName()); - const chatUiUrl = process.env.CHAT_UI_URL || `http://127.0.0.1:${CONTROL_UI_PORT}`; + const effectivePort = agent ? agent.forwardPort : CONTROL_UI_PORT; + const chatUiUrl = process.env.CHAT_UI_URL || `http://127.0.0.1:${effectivePort}`; // Check whether messaging providers will be needed — this must happen before // the sandbox reuse decision so we can detect stale sandboxes that were created @@ -2401,15 +2414,27 @@ async function createSandbox( fs.copyFileSync(fromResolved, stagedDockerfile); } console.log(` Using custom Dockerfile: ${fromResolved}`); + } else if (agent) { + const agentBuild = agentOnboard.createAgentSandbox(agent); + buildCtx = agentBuild.buildCtx; + stagedDockerfile = agentBuild.stagedDockerfile; } else { ({ buildCtx, stagedDockerfile } = stageOptimizedSandboxBuildContext(ROOT)); } // Create sandbox (use -- echo to avoid dropping into interactive shell) // Pass the base policy so sandbox starts in proxy mode (required for policy updates later) - const basePolicyPath = dangerouslySkipPermissions - ? path.join(ROOT, "nemoclaw-blueprint", "policies", "openclaw-sandbox-permissive.yaml") - : path.join(ROOT, "nemoclaw-blueprint", "policies", "openclaw-sandbox.yaml"); + const globalPermissivePath = path.join(ROOT, "nemoclaw-blueprint", "policies", "openclaw-sandbox-permissive.yaml"); + let basePolicyPath; + if (dangerouslySkipPermissions) { + // Permissive mode: use agent-specific permissive policy if available, + // otherwise fall back to the global permissive policy. + const agentPermissive = agent && agentOnboard.getAgentPermissivePolicyPath(agent); + basePolicyPath = agentPermissive || globalPermissivePath; + } else { + const defaultPolicyPath = path.join(ROOT, "nemoclaw-blueprint", "policies", "openclaw-sandbox.yaml"); + basePolicyPath = (agent && agentOnboard.getAgentPolicyPath(agent)) || defaultPolicyPath; + } const createArgs = [ "--from", `${buildCtx}/Dockerfile`, @@ -2598,6 +2623,7 @@ async function createSandbox( registry.registerSandbox({ name: sandboxName, gpuEnabled: !!gpu, + agent: agent ? agent.name : null, dangerouslySkipPermissions: dangerouslySkipPermissions || undefined, }); @@ -4005,7 +4031,8 @@ const { resolveDashboardForwardTarget, buildControlUiUrls } = dashboard; function ensureDashboardForward(sandboxName, chatUiUrl = `http://127.0.0.1:${CONTROL_UI_PORT}`) { const forwardTarget = resolveDashboardForwardTarget(chatUiUrl); - runOpenshell(["forward", "stop", String(CONTROL_UI_PORT)], { ignoreError: true }); + const portToStop = String(new URL(chatUiUrl).port || CONTROL_UI_PORT); + runOpenshell(["forward", "stop", portToStop], { ignoreError: true }); // Use stdio "ignore" to prevent spawnSync from waiting on inherited pipe fds. // The --background flag forks a child that inherits stdout/stderr; if those are // pipes, spawnSync blocks until the background process exits (never). @@ -4061,7 +4088,7 @@ function fetchGatewayAuthTokenFromSandbox(sandboxName) { // buildControlUiUrls — see dashboard import above -function printDashboard(sandboxName, model, provider, nimContainer = null) { +function printDashboard(sandboxName, model, provider, nimContainer = null, agent = null) { const nimStat = nimContainer ? nim.nimStatusByName(nimContainer) : nim.nimStatus(sandboxName); const nimLabel = nimStat.running ? "running" : "not running"; @@ -4089,7 +4116,9 @@ function printDashboard(sandboxName, model, provider, nimContainer = null) { console.log(` Status: nemoclaw ${sandboxName} status`); console.log(` Logs: nemoclaw ${sandboxName} logs --follow`); console.log(""); - if (token) { + if (agent) { + agentOnboard.printDashboardUi(sandboxName, token, agent, { note, buildControlUiUrls }); + } else if (token) { console.log(" OpenClaw UI (tokenized URL; treat it like a password)"); console.log(` Port ${CONTROL_UI_PORT} must be forwarded before opening this URL.`); for (const url of buildControlUiUrls(token)) { @@ -4223,6 +4252,7 @@ async function onboard(opts = {}) { const resumeConflicts = getResumeConfigConflicts(session, { nonInteractive: isNonInteractive(), fromDockerfile: requestedFromDockerfile, + agent: opts.agent || null, }); if (resumeConflicts.length > 0) { for (const conflict of resumeConflicts) { @@ -4230,6 +4260,10 @@ async function onboard(opts = {}) { console.error( ` Resumable state belongs to sandbox '${conflict.recorded}', not '${conflict.requested}'.`, ); + } else if (conflict.field === "agent") { + console.error( + ` Session was started with agent '${conflict.recorded}', not '${conflict.requested}'.`, + ); } else if (conflict.field === "fromDockerfile") { if (!conflict.recorded) { console.error( @@ -4288,6 +4322,14 @@ async function onboard(opts = {}) { if (resume) note(" (resume mode)"); console.log(" ==================="); + const agent = agentOnboard.resolveAgent({ agentFlag: opts.agent, session }); + if (agent) { + onboardSession.updateSession((s) => { + s.agent = agent.name; + return s; + }); + } + let gpu; const resumePreflight = resume && session?.steps?.preflight?.status === "complete"; if (resumePreflight) { @@ -4466,19 +4508,33 @@ async function onboard(opts = {}) { webSearchConfig, enabledChannels, fromDockerfile, + agent, dangerouslySkipPermissions, ); onboardSession.markStepComplete("sandbox", { sandboxName, provider, model, nimContainer }); } - const resumeOpenclaw = resume && sandboxName && isOpenclawReady(sandboxName); - if (resumeOpenclaw) { - skippedStepMessage("openclaw", sandboxName); - onboardSession.markStepComplete("openclaw", { sandboxName, provider, model }); + if (agent) { + await agentOnboard.handleAgentSetup(sandboxName, model, provider, agent, resume, session, { + step, + runCaptureOpenshell, + openshellShellCommand, + buildSandboxConfigSyncScript, + writeSandboxConfigSyncFile, + cleanupTempDir, + startRecordedStep, + skippedStepMessage, + }); } else { - startRecordedStep("openclaw", { sandboxName, provider, model }); - await setupOpenclaw(sandboxName, model, provider); - onboardSession.markStepComplete("openclaw", { sandboxName, provider, model }); + const resumeOpenclaw = resume && sandboxName && isOpenclawReady(sandboxName); + if (resumeOpenclaw) { + skippedStepMessage("openclaw", sandboxName); + onboardSession.markStepComplete("openclaw", { sandboxName, provider, model }); + } else { + startRecordedStep("openclaw", { sandboxName, provider, model }); + await setupOpenclaw(sandboxName, model, provider); + onboardSession.markStepComplete("openclaw", { sandboxName, provider, model }); + } } const recordedPolicyPresets = Array.isArray(session?.policyPresets) @@ -4538,7 +4594,7 @@ async function onboard(opts = {}) { onboardSession.completeSession({ sandboxName, provider, model }); completed = true; - printDashboard(sandboxName, model, provider, nimContainer); + printDashboard(sandboxName, model, provider, nimContainer, agent); } finally { releaseOnboardLock(); } diff --git a/src/lib/policies.ts b/src/lib/policies.ts index f8ece402b3e..2dcd37060ed 100644 --- a/src/lib/policies.ts +++ b/src/lib/policies.ts @@ -11,6 +11,7 @@ const readline = require("readline"); const YAML = require("yaml"); const { ROOT, run, runCapture, shellQuote } = require("./runner"); const registry = require("./registry"); +const { loadAgent } = require("./agent-defs"); const PRESETS_DIR = path.join(ROOT, "nemoclaw-blueprint", "policies", "presets"); function getOpenshellCommand() { @@ -349,6 +350,24 @@ const PERMISSIVE_POLICY_PATH = path.join( "openclaw-sandbox-permissive.yaml", ); +function resolvePermissivePolicyPath(sandboxName) { + // Use agent-specific permissive policy if the sandbox has an agent with one. + try { + const sandbox = registry.getSandbox(sandboxName); + if (sandbox?.agent && sandbox.agent !== "openclaw") { + const agent = loadAgent(sandbox.agent); + if (agent?.policyPermissivePath) return agent.policyPermissivePath; + } + if (sandbox?.agent === "openclaw") { + const agent = loadAgent("openclaw"); + if (agent?.policyPermissivePath) return agent.policyPermissivePath; + } + } catch { + // Fall through to global permissive policy + } + return PERMISSIVE_POLICY_PATH; +} + function applyPermissivePolicy(sandboxName) { const isRfc1123Label = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(sandboxName); if (!sandboxName || sandboxName.length > 63 || !isRfc1123Label) { @@ -358,12 +377,13 @@ function applyPermissivePolicy(sandboxName) { ); } - if (!fs.existsSync(PERMISSIVE_POLICY_PATH)) { - throw new Error(`Permissive policy not found: ${PERMISSIVE_POLICY_PATH}`); + const policyPath = resolvePermissivePolicyPath(sandboxName); + if (!fs.existsSync(policyPath)) { + throw new Error(`Permissive policy not found: ${policyPath}`); } console.log(" Applying permissive policy (--dangerously-skip-permissions)..."); - run(buildPolicySetCommand(PERMISSIVE_POLICY_PATH, sandboxName)); + run(buildPolicySetCommand(policyPath, sandboxName)); console.log(" Applied permissive policy."); const sandbox = registry.getSandbox(sandboxName); diff --git a/src/lib/registry.ts b/src/lib/registry.ts index d9e72540346..4969a969795 100644 --- a/src/lib/registry.ts +++ b/src/lib/registry.ts @@ -14,6 +14,7 @@ export interface SandboxEntry { provider?: string | null; gpuEnabled?: boolean; policies?: string[]; + agent?: string | null; dangerouslySkipPermissions?: boolean; } @@ -157,6 +158,7 @@ export function registerSandbox(entry: SandboxEntry): void { provider: entry.provider || null, gpuEnabled: entry.gpuEnabled || false, policies: entry.policies || [], + agent: entry.agent || null, dangerouslySkipPermissions: entry.dangerouslySkipPermissions === true ? true : undefined, }; diff --git a/src/lib/web-search.test.ts b/src/lib/web-search.test.ts index 7edae02ec42..ffa87ac664c 100644 --- a/src/lib/web-search.test.ts +++ b/src/lib/web-search.test.ts @@ -35,7 +35,7 @@ describe("web-search helpers", () => { it("includes the explicit exposure caveat in the warning text", () => { const warning = getBraveExposureWarningLines().join(" "); - expect(warning).toContain("sandbox OpenClaw config"); - expect(warning).toContain("OpenClaw agent will be able to read"); + expect(warning).toContain("sandbox agent config"); + expect(warning).toContain("sandboxed agent will be able to read"); }); }); diff --git a/src/lib/web-search.ts b/src/lib/web-search.ts index 58676ddaf06..a545d28f90b 100644 --- a/src/lib/web-search.ts +++ b/src/lib/web-search.ts @@ -13,8 +13,8 @@ export function encodeDockerJsonArg(value: unknown): string { export function getBraveExposureWarningLines(): string[] { return [ - "NemoClaw will store the Brave API key in sandbox OpenClaw config.", - "The OpenClaw agent will be able to read that key.", + "NemoClaw will store the Brave API key in the sandbox agent config.", + "The sandboxed agent will be able to read that key.", ]; } diff --git a/src/nemoclaw.ts b/src/nemoclaw.ts index 2cb402e1171..dc571334f77 100644 --- a/src/nemoclaw.ts +++ b/src/nemoclaw.ts @@ -60,6 +60,7 @@ const { buildVersionedUninstallUrl, runUninstallCommand, } = require("./lib/uninstall-command"); +const agentRuntime = require("../bin/lib/agent-runtime"); // ── Global commands ────────────────────────────────────────────── @@ -213,9 +214,11 @@ function executeSandboxCommand(sandboxName, command) { * Returns true (running), false (stopped), or null (cannot determine). */ function isSandboxGatewayRunning(sandboxName) { + const agent = agentRuntime.getSessionAgent(sandboxName); + const probeUrl = agentRuntime.getHealthProbeUrl(agent); const result = executeSandboxCommand( sandboxName, - "curl -sf --max-time 3 http://127.0.0.1:18789/ > /dev/null 2>&1 && echo RUNNING || echo STOPPED", + `curl -sf --max-time 3 ${shellQuote(probeUrl)} > /dev/null 2>&1 && echo RUNNING || echo STOPPED`, ); if (!result) return null; if (result.stdout === "RUNNING") return true; @@ -229,29 +232,33 @@ function isSandboxGatewayRunning(sandboxName) { * in the background. Returns true on success. */ function recoverSandboxProcesses(sandboxName) { + const agent = agentRuntime.getSessionAgent(sandboxName); + const agentScript = agentRuntime.buildRecoveryScript(agent); // The recovery script runs as the sandbox user (non-root). This matches // the non-root fallback path in nemoclaw-start.sh — no privilege // separation, but the gateway runs and inference works. - const script = [ - // Source proxy config (written to .bashrc by nemoclaw-start on first boot) - "[ -f ~/.bashrc ] && . ~/.bashrc 2>/dev/null;", - // Re-check liveness before touching anything — another caller may have - // already recovered the gateway between our initial check and now (TOCTOU). - "if curl -sf --max-time 3 http://127.0.0.1:18789/ > /dev/null 2>&1; then echo ALREADY_RUNNING; exit 0; fi;", - // Clean stale lock files from the previous run (gateway checks these) - "rm -rf /tmp/openclaw-*/gateway.*.lock 2>/dev/null;", - // Clean stale temp files from the previous run - "rm -f /tmp/gateway.log /tmp/auto-pair.log;", - "touch /tmp/gateway.log; chmod 600 /tmp/gateway.log;", - "touch /tmp/auto-pair.log; chmod 600 /tmp/auto-pair.log;", - // Resolve and start gateway - 'OPENCLAW="$(command -v openclaw)";', - 'if [ -z "$OPENCLAW" ]; then echo OPENCLAW_MISSING; exit 1; fi;', - 'nohup "$OPENCLAW" gateway run > /tmp/gateway.log 2>&1 &', - "GPID=$!; sleep 2;", - // Verify the gateway actually started (didn't crash immediately) - 'if kill -0 "$GPID" 2>/dev/null; then echo "GATEWAY_PID=$GPID"; else echo GATEWAY_FAILED; cat /tmp/gateway.log 2>/dev/null | tail -5; fi', - ].join(" "); + const script = + agentScript || + [ + // Source proxy config (written to .bashrc by nemoclaw-start on first boot) + "[ -f ~/.bashrc ] && . ~/.bashrc 2>/dev/null;", + // Re-check liveness before touching anything — another caller may have + // already recovered the gateway between our initial check and now (TOCTOU). + "if curl -sf --max-time 3 http://127.0.0.1:18789/ > /dev/null 2>&1; then echo ALREADY_RUNNING; exit 0; fi;", + // Clean stale lock files from the previous run (gateway checks these) + "rm -rf /tmp/openclaw-*/gateway.*.lock 2>/dev/null;", + // Clean stale temp files from the previous run + "rm -f /tmp/gateway.log /tmp/auto-pair.log;", + "touch /tmp/gateway.log; chmod 600 /tmp/gateway.log;", + "touch /tmp/auto-pair.log; chmod 600 /tmp/auto-pair.log;", + // Resolve and start gateway + 'OPENCLAW="$(command -v openclaw)";', + 'if [ -z "$OPENCLAW" ]; then echo OPENCLAW_MISSING; exit 1; fi;', + 'nohup "$OPENCLAW" gateway run > /tmp/gateway.log 2>&1 &', + "GPID=$!; sleep 2;", + // Verify the gateway actually started (didn't crash immediately) + 'if kill -0 "$GPID" 2>/dev/null; then echo "GATEWAY_PID=$GPID"; else echo GATEWAY_FAILED; cat /tmp/gateway.log 2>/dev/null | tail -5; fi', + ].join(" "); const result = executeSandboxCommand(sandboxName, script); if (!result) return false; @@ -262,11 +269,14 @@ function recoverSandboxProcesses(sandboxName) { } /** - * Re-establish the dashboard port forward (18789) to the sandbox. + * Re-establish the dashboard port forward to the sandbox. + * Uses the agent's forward port when a non-OpenClaw agent is active. */ function ensureSandboxPortForward(sandboxName) { - runOpenshell(["forward", "stop", DASHBOARD_FORWARD_PORT], { ignoreError: true }); - runOpenshell(["forward", "start", "--background", DASHBOARD_FORWARD_PORT, sandboxName], { + const agent = agentRuntime.getSessionAgent(sandboxName); + const port = agent ? String(agent.forwardPort) : DASHBOARD_FORWARD_PORT; + runOpenshell(["forward", "stop", port], { ignoreError: true }); + runOpenshell(["forward", "start", "--background", port, sandboxName], { ignoreError: true, }); } @@ -286,9 +296,10 @@ function checkAndRecoverSandboxProcesses(sandboxName, { quiet = false } = {}) { } // Gateway not running — attempt recovery + const _recoveryAgent = agentRuntime.getSessionAgent(sandboxName); if (!quiet) { console.log(""); - console.log(" OpenClaw gateway is not running inside the sandbox (sandbox likely restarted)."); + console.log(` ${agentRuntime.getAgentDisplayName(_recoveryAgent)} gateway is not running inside the sandbox (sandbox likely restarted).`); console.log(" Recovering..."); } @@ -306,13 +317,13 @@ function checkAndRecoverSandboxProcesses(sandboxName, { quiet = false } = {}) { } ensureSandboxPortForward(sandboxName); if (!quiet) { - console.log(` ${G}✓${R} OpenClaw gateway restarted inside sandbox.`); + console.log(` ${G}✓${R} ${agentRuntime.getAgentDisplayName(_recoveryAgent)} gateway restarted inside sandbox.`); console.log(` ${G}✓${R} Dashboard port forward re-established.`); } } else if (!quiet) { - console.error(" Could not restart OpenClaw gateway automatically."); + console.error(` Could not restart ${agentRuntime.getAgentDisplayName(_recoveryAgent)} gateway automatically.`); console.error(" Connect to the sandbox and run manually:"); - console.error(" nohup openclaw gateway run > /tmp/gateway.log 2>&1 &"); + console.error(` ${agentRuntime.getGatewayCommand(_recoveryAgent)}`); } return { checked: true, wasRunning: false, recovered }; @@ -330,6 +341,7 @@ function buildRecoveredSandboxEntry(name, metadata = {}) { ? metadata.policyPresets : [], nimContainer: metadata.nimContainer || null, + agent: metadata.agent || null, }; } @@ -780,13 +792,30 @@ async function onboard(args) { if (!fromDockerfile || fromDockerfile.startsWith("--")) { console.error(" --from requires a path to a Dockerfile"); console.error( - ` Usage: nemoclaw onboard [--non-interactive] [--resume] [--recreate-sandbox] [--from ] [--dangerously-skip-permissions] [${NOTICE_ACCEPT_FLAG}]`, + ` Usage: nemoclaw onboard [--non-interactive] [--resume] [--recreate-sandbox] [--from ] [${NOTICE_ACCEPT_FLAG}]`, ); process.exit(1); } args = [...args.slice(0, fromIdx), ...args.slice(fromIdx + 2)]; } + let agentFlag = null; + const agentIdx = args.indexOf("--agent"); + if (agentIdx !== -1) { + agentFlag = args[agentIdx + 1]; + if (!agentFlag || agentFlag.startsWith("--")) { + console.error(" --agent requires a name"); + process.exit(1); + } + const { listAgents } = require("../bin/lib/agent-defs"); + const knownAgents = listAgents(); + if (!knownAgents.includes(agentFlag)) { + console.error(` Unknown agent '${agentFlag}'. Available: ${knownAgents.join(", ")}`); + process.exit(1); + } + args = [...args.slice(0, agentIdx), ...args.slice(agentIdx + 2)]; + } + const allowedArgs = new Set([ "--non-interactive", "--resume", @@ -798,7 +827,7 @@ async function onboard(args) { if (unknownArgs.length > 0) { console.error(` Unknown onboard option(s): ${unknownArgs.join(", ")}`); console.error( - ` Usage: nemoclaw onboard [--non-interactive] [--resume] [--recreate-sandbox] [--from ] [--dangerously-skip-permissions] [${NOTICE_ACCEPT_FLAG}]`, + ` Usage: nemoclaw onboard [--non-interactive] [--resume] [--recreate-sandbox] [--from ] [--agent ] [--dangerously-skip-permissions] [${NOTICE_ACCEPT_FLAG}]`, ); process.exit(1); } @@ -814,6 +843,7 @@ async function onboard(args) { recreateSandbox, fromDockerfile, acceptThirdPartySoftware, + agent: agentFlag, dangerouslySkipPermissions, }); } @@ -1108,20 +1138,22 @@ async function sandboxStatus(sandboxName) { if (lookup.state === "present") { const processCheck = checkAndRecoverSandboxProcesses(sandboxName, { quiet: true }); if (processCheck.checked) { + const _sa = agentRuntime.getSessionAgent(sandboxName); + const _saName = agentRuntime.getAgentDisplayName(_sa); if (processCheck.wasRunning) { - console.log(` OpenClaw: ${G}running${R}`); + console.log(` ${_saName}: ${G}running${R}`); } else if (processCheck.recovered) { - console.log(` OpenClaw: ${G}recovered${R} (gateway restarted after sandbox restart)`); + console.log(` ${_saName}: ${G}recovered${R} (gateway restarted after sandbox restart)`); } else { - console.log(` OpenClaw: ${_RD}not running${R}`); + console.log(` ${_saName}: ${_RD}not running${R}`); console.log(""); - console.log(" The sandbox is alive but the OpenClaw gateway process is not running."); + console.log(` The sandbox is alive but the ${_saName} gateway process is not running.`); console.log(" This typically happens after a gateway restart (e.g., laptop close/open)."); console.log(""); console.log(" To recover, run:"); console.log(` ${D}nemoclaw ${sandboxName} connect${R} (auto-recovers on connect)`); console.log(" Or manually inside the sandbox:"); - console.log(` ${D}nohup openclaw gateway run > /tmp/gateway.log 2>&1 &${R}`); + console.log(` ${D}${agentRuntime.getGatewayCommand(_sa)}${R}`); } } } @@ -1303,7 +1335,6 @@ function help() { ${G}Getting Started:${R} ${B}nemoclaw onboard${R} Configure inference endpoint and credentials nemoclaw onboard ${D}--from ${R} Use a custom Dockerfile for the sandbox image - nemoclaw onboard ${D}--dangerously-skip-permissions${R} Apply maximally permissive sandbox policy ${D}(non-interactive: ${NOTICE_ACCEPT_FLAG} or ${NOTICE_ACCEPT_ENV}=1)${R} ${G}Sandbox Management:${R} diff --git a/test/credentials.test.ts b/test/credentials.test.ts index 77afa9674e0..e69f6fcf86d 100644 --- a/test/credentials.test.ts +++ b/test/credentials.test.ts @@ -76,6 +76,7 @@ describe("credential prompts", () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-creds-")); // Isolate from real environment so getCredential only checks the file. vi.stubEnv("NVIDIA_API_KEY", ""); + vi.stubEnv("OTHER_KEY", ""); const credentials = await importCredentialsModule(home); credentials.saveCredential("NVIDIA_API_KEY", "nvapi-bad-key"); diff --git a/test/e2e/test-hermes-e2e.sh b/test/e2e/test-hermes-e2e.sh new file mode 100755 index 00000000000..ef029b0d9de --- /dev/null +++ b/test/e2e/test-hermes-e2e.sh @@ -0,0 +1,546 @@ +#!/bin/bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Hermes Agent E2E: install → onboard --agent hermes → verify sandbox → live inference +# +# Proves the COMPLETE Hermes user journey including agent selection, health +# probe verification, and real inference through the sandbox. Uses the same +# install.sh --non-interactive path as the OpenClaw E2E but passes +# NEMOCLAW_AGENT=hermes to select the Hermes agent during onboarding. +# +# Prerequisites: +# - Docker running +# - NVIDIA_API_KEY set (real key, starts with nvapi-) +# - Network access to integrate.api.nvidia.com +# +# Environment variables: +# NEMOCLAW_NON_INTERACTIVE=1 — required (enables non-interactive install + onboard) +# NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 — required for non-interactive install/onboard +# NEMOCLAW_AGENT=hermes — auto-set if not already set +# NEMOCLAW_SANDBOX_NAME — sandbox name (default: e2e-hermes) +# NEMOCLAW_RECREATE_SANDBOX=1 — recreate sandbox if it exists from a previous run +# NVIDIA_API_KEY — required for NVIDIA Endpoints inference +# +# Usage: +# NEMOCLAW_NON_INTERACTIVE=1 NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 NVIDIA_API_KEY=nvapi-... bash test/e2e/test-hermes-e2e.sh + +set -uo pipefail + +PASS=0 +FAIL=0 +SKIP=0 +TOTAL=0 + +pass() { + ((PASS++)) + ((TOTAL++)) + printf '\033[32m PASS: %s\033[0m\n' "$1" +} +fail() { + ((FAIL++)) + ((TOTAL++)) + printf '\033[31m FAIL: %s\033[0m\n' "$1" +} +skip() { + ((SKIP++)) + ((TOTAL++)) + printf '\033[33m SKIP: %s\033[0m\n' "$1" +} +section() { + echo "" + printf '\033[1;36m=== %s ===\033[0m\n' "$1" +} +info() { printf '\033[1;34m [info]\033[0m %s\n' "$1"; } + +# 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() { + python3 -c " +import json, sys +try: + r = json.load(sys.stdin) + c = r['choices'][0]['message'] + content = c.get('content') or c.get('reasoning_content') or '' + print(content.strip()) +except Exception as e: + print(f'PARSE_ERROR: {e}', file=sys.stderr) + sys.exit(1) +" +} + +# Determine repo root +if [ -d /workspace ] && [ -f /workspace/install.sh ]; then + REPO="/workspace" +elif [ -f "$(cd "$(dirname "$0")/../.." && pwd)/install.sh" ]; then + REPO="$(cd "$(dirname "$0")/../.." && pwd)" +else + echo "ERROR: Cannot find repo root." + exit 1 +fi + +SANDBOX_NAME="${NEMOCLAW_SANDBOX_NAME:-e2e-hermes}" +export NEMOCLAW_AGENT="${NEMOCLAW_AGENT:-hermes}" + +# Hermes health probe endpoint (from agents/hermes/manifest.yaml) +HERMES_HEALTH_URL="http://localhost:8642/health" + +# ══════════════════════════════════════════════════════════════════ +# Phase 0: Pre-cleanup +# ══════════════════════════════════════════════════════════════════ +section "Phase 0: Pre-cleanup" +info "Destroying any leftover sandbox/gateway from previous runs..." +if command -v nemoclaw >/dev/null 2>&1; then + nemoclaw "$SANDBOX_NAME" destroy --yes 2>/dev/null || true +fi +if command -v openshell >/dev/null 2>&1; then + openshell sandbox delete "$SANDBOX_NAME" 2>/dev/null || true + openshell gateway destroy -g nemoclaw 2>/dev/null || true +fi +pass "Pre-cleanup complete" + +# ══════════════════════════════════════════════════════════════════ +# Phase 1: Prerequisites +# ══════════════════════════════════════════════════════════════════ +section "Phase 1: Prerequisites" + +if docker info >/dev/null 2>&1; then + pass "Docker is running" +else + fail "Docker is not running — cannot continue" + exit 1 +fi + +if [ -n "${NVIDIA_API_KEY:-}" ] && [[ "${NVIDIA_API_KEY}" == nvapi-* ]]; then + pass "NVIDIA_API_KEY is set (starts with nvapi-)" +else + fail "NVIDIA_API_KEY not set or invalid — required for live inference" + exit 1 +fi + +if curl -sf --max-time 10 https://integrate.api.nvidia.com/v1/models >/dev/null 2>&1; then + pass "Network access to integrate.api.nvidia.com" +else + fail "Cannot reach integrate.api.nvidia.com" + exit 1 +fi + +if [ "${NEMOCLAW_NON_INTERACTIVE:-}" != "1" ]; then + fail "NEMOCLAW_NON_INTERACTIVE=1 is required" + exit 1 +fi + +if [ "${NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE:-}" != "1" ]; then + fail "NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 is required for non-interactive install" + exit 1 +fi + +# Verify agents/hermes/ exists in repo +if [ -d "$REPO/agents/hermes" ] && [ -f "$REPO/agents/hermes/manifest.yaml" ]; then + pass "agents/hermes/ directory and manifest.yaml exist" +else + fail "agents/hermes/ not found — is the hermes-agent-support branch checked out?" + exit 1 +fi + +info "NEMOCLAW_AGENT=${NEMOCLAW_AGENT}" + +# ══════════════════════════════════════════════════════════════════ +# Phase 2: Install nemoclaw (non-interactive mode, --agent hermes) +# ══════════════════════════════════════════════════════════════════ +section "Phase 2: Install nemoclaw (non-interactive mode, agent=hermes)" + +cd "$REPO" || { + fail "Could not cd to repo root: $REPO" + exit 1 +} + +info "Running install.sh --non-interactive with NEMOCLAW_AGENT=hermes..." +info "This installs Node.js, openshell, NemoClaw, and runs onboard with Hermes agent." +info "Expected duration: 10-15 minutes on first run (Hermes base image build)." + +INSTALL_LOG="/tmp/nemoclaw-e2e-hermes-install.log" +# Write to a file instead of piping through tee. openshell's background +# port-forward inherits pipe file descriptors, which prevents tee from exiting. +# Use tail -f in the background for real-time output in CI logs. +bash install.sh --non-interactive >"$INSTALL_LOG" 2>&1 & +install_pid=$! +tail -f "$INSTALL_LOG" --pid=$install_pid 2>/dev/null & +tail_pid=$! +wait $install_pid +install_exit=$? +kill $tail_pid 2>/dev/null || true +wait $tail_pid 2>/dev/null || true + +# Source shell profile to pick up nvm/PATH changes from install.sh +if [ -f "$HOME/.bashrc" ]; then + # shellcheck source=/dev/null + source "$HOME/.bashrc" 2>/dev/null || true +fi +# Ensure nvm is loaded in current shell +export NVM_DIR="${NVM_DIR:-$HOME/.nvm}" +if [ -s "$NVM_DIR/nvm.sh" ]; then + # shellcheck source=/dev/null + . "$NVM_DIR/nvm.sh" +fi +# Ensure ~/.local/bin is on PATH (openshell may be installed there in non-interactive mode) +if [ -d "$HOME/.local/bin" ] && [[ ":$PATH:" != *":$HOME/.local/bin:"* ]]; then + export PATH="$HOME/.local/bin:$PATH" +fi + +if [ $install_exit -eq 0 ]; then + pass "install.sh completed (exit 0)" +else + fail "install.sh failed (exit $install_exit)" + exit 1 +fi + +# Verify nemoclaw is on PATH +if command -v nemoclaw >/dev/null 2>&1; then + pass "nemoclaw installed at $(command -v nemoclaw)" +else + fail "nemoclaw not found on PATH after install" + exit 1 +fi + +# Verify openshell was installed +if command -v openshell >/dev/null 2>&1; then + pass "openshell installed ($(openshell --version 2>&1 || echo unknown))" +else + fail "openshell not found on PATH after install" + exit 1 +fi + +if nemoclaw --help >/dev/null 2>&1; then + pass "nemoclaw --help exits 0" +else + fail "nemoclaw --help failed" +fi + +# ══════════════════════════════════════════════════════════════════ +# Phase 3: Sandbox verification (Hermes-specific) +# ══════════════════════════════════════════════════════════════════ +section "Phase 3: Sandbox verification (Hermes)" + +# 3a: nemoclaw list +if list_output=$(nemoclaw list 2>&1); then + if grep -Fq -- "$SANDBOX_NAME" <<<"$list_output"; then + pass "nemoclaw list contains '${SANDBOX_NAME}'" + else + fail "nemoclaw list does not contain '${SANDBOX_NAME}'" + fi +else + fail "nemoclaw list failed: ${list_output:0:200}" +fi + +# 3b: nemoclaw status +if status_output=$(nemoclaw "$SANDBOX_NAME" status 2>&1); then + pass "nemoclaw ${SANDBOX_NAME} status exits 0" +else + fail "nemoclaw ${SANDBOX_NAME} status failed: ${status_output:0:200}" +fi + +# 3c: Session records agent=hermes +session_file="$HOME/.nemoclaw/onboard-session.json" +if [ -f "$session_file" ]; then + if grep -qE '"agent"\s*:\s*"hermes"' "$session_file"; then + pass "Onboard session records agent=hermes" + else + fail "Onboard session does not contain agent=hermes" + info "Session contents: $(head -20 "$session_file" 2>/dev/null)" + fi +else + fail "Session file not found: $session_file" +fi + +# 3d: Inference must be configured by onboard +if inf_check=$(openshell inference get 2>&1); then + if grep -qi "nvidia-prod" <<<"$inf_check"; then + pass "Inference configured via onboard" + else + fail "Inference not configured — onboard did not set up nvidia-prod provider" + fi +else + fail "openshell inference get failed: ${inf_check:0:200}" +fi + +# 3e: Policy presets applied +if policy_output=$(openshell policy get --full "$SANDBOX_NAME" 2>&1); then + if grep -qi "network_policies" <<<"$policy_output"; then + pass "Policy applied to sandbox" + else + fail "No network policy found on sandbox" + fi +else + fail "openshell policy get failed: ${policy_output:0:200}" +fi + +# ══════════════════════════════════════════════════════════════════ +# Phase 4: Hermes agent health verification +# ══════════════════════════════════════════════════════════════════ +section "Phase 4: Hermes agent health" + +# 4a: Health probe via SSH into sandbox +info "Checking Hermes health probe at ${HERMES_HEALTH_URL} inside sandbox..." +ssh_config="$(mktemp)" +hermes_healthy=false + +if openshell sandbox ssh-config "$SANDBOX_NAME" >"$ssh_config" 2>/dev/null; then + TIMEOUT_CMD="" + command -v timeout >/dev/null 2>&1 && TIMEOUT_CMD="timeout 60" + command -v gtimeout >/dev/null 2>&1 && TIMEOUT_CMD="gtimeout 60" + + # Retry health check — Hermes may still be starting + for attempt in $(seq 1 15); do + health_response=$($TIMEOUT_CMD ssh -F "$ssh_config" \ + -o StrictHostKeyChecking=no \ + -o UserKnownHostsFile=/dev/null \ + -o ConnectTimeout=10 \ + -o LogLevel=ERROR \ + "openshell-${SANDBOX_NAME}" \ + "curl -sf ${HERMES_HEALTH_URL}" \ + 2>&1) || true + + if echo "$health_response" | grep -qi '"ok"'; then + hermes_healthy=true + break + fi + info "Health check attempt ${attempt}/15 — waiting 4s..." + sleep 4 + done + + if $hermes_healthy; then + pass "Hermes health probe returned ok" + info "Response: ${health_response:0:200}" + else + fail "Hermes health probe did not return ok after 15 attempts" + info "Last response: ${health_response:0:200}" + fi +else + fail "Could not get SSH config for sandbox ${SANDBOX_NAME}" +fi + +# 4b: Verify Hermes binary exists in sandbox +if openshell sandbox ssh-config "$SANDBOX_NAME" >"$ssh_config" 2>/dev/null; then + hermes_version=$($TIMEOUT_CMD ssh -F "$ssh_config" \ + -o StrictHostKeyChecking=no \ + -o UserKnownHostsFile=/dev/null \ + -o ConnectTimeout=10 \ + -o LogLevel=ERROR \ + "openshell-${SANDBOX_NAME}" \ + "hermes --version 2>&1 || echo MISSING" \ + 2>&1) || true + + if echo "$hermes_version" | grep -qi "MISSING\|not found\|No such file"; then + fail "Hermes binary not found in sandbox" + else + pass "Hermes binary found in sandbox: ${hermes_version:0:100}" + fi +fi + +# 4c: Verify Hermes config integrity (config hash check) +config_hash_check=$($TIMEOUT_CMD ssh -F "$ssh_config" \ + -o StrictHostKeyChecking=no \ + -o UserKnownHostsFile=/dev/null \ + -o ConnectTimeout=10 \ + -o LogLevel=ERROR \ + "openshell-${SANDBOX_NAME}" \ + "test -f /sandbox/.hermes/config.yaml && echo EXISTS || echo MISSING" \ + 2>&1) || true + +if echo "$config_hash_check" | grep -q "EXISTS"; then + pass "Hermes config.yaml exists at /sandbox/.hermes/config.yaml" +else + fail "Hermes config.yaml not found at /sandbox/.hermes/config.yaml" +fi + +# 4d: Verify immutable config directory (Landlock read-only) +writable_check=$($TIMEOUT_CMD ssh -F "$ssh_config" \ + -o StrictHostKeyChecking=no \ + -o UserKnownHostsFile=/dev/null \ + -o ConnectTimeout=10 \ + -o LogLevel=ERROR \ + "openshell-${SANDBOX_NAME}" \ + "touch /sandbox/.hermes/test-write 2>&1 && echo WRITABLE && rm -f /sandbox/.hermes/test-write || echo READ_ONLY" \ + 2>&1) || true + +if echo "$writable_check" | grep -q "READ_ONLY"; then + pass "Hermes config directory is read-only (immutable)" +elif echo "$writable_check" | grep -q "WRITABLE"; then + fail "Hermes config directory is writable — should be immutable" +else + skip "Could not determine config directory mutability: ${writable_check:0:100}" +fi + +# 4e: Verify writable data directory exists +data_dir_check=$($TIMEOUT_CMD ssh -F "$ssh_config" \ + -o StrictHostKeyChecking=no \ + -o UserKnownHostsFile=/dev/null \ + -o ConnectTimeout=10 \ + -o LogLevel=ERROR \ + "openshell-${SANDBOX_NAME}" \ + "test -d /sandbox/.hermes-data && echo EXISTS || echo MISSING" \ + 2>&1) || true + +if echo "$data_dir_check" | grep -q "EXISTS"; then + pass "Hermes writable data directory exists at /sandbox/.hermes-data" +else + fail "Hermes writable data directory not found at /sandbox/.hermes-data" +fi + +rm -f "$ssh_config" + +# ══════════════════════════════════════════════════════════════════ +# Phase 5: Live inference — the real proof +# ══════════════════════════════════════════════════════════════════ +section "Phase 5: Live inference" + +# ── Test 5a: Direct NVIDIA Endpoints ── +info "[LIVE] Direct API test → integrate.api.nvidia.com..." +api_response=$(curl -s --max-time 30 \ + -X POST https://integrate.api.nvidia.com/v1/chat/completions \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $NVIDIA_API_KEY" \ + -d '{ + "model": "nvidia/nemotron-3-super-120b-a12b", + "messages": [{"role": "user", "content": "Reply with exactly one word: PONG"}], + "max_tokens": 100 + }' 2>/dev/null) || true + +if [ -n "$api_response" ]; then + api_content=$(echo "$api_response" | parse_chat_content 2>/dev/null) || true + if grep -qi "PONG" <<<"$api_content"; then + pass "[LIVE] Direct API: model responded with PONG" + else + fail "[LIVE] Direct API: expected PONG, got: ${api_content:0:200}" + fi +else + fail "[LIVE] Direct API: empty response from curl" +fi + +# ── Test 5b: Inference through the sandbox (THE definitive test) ── +info "[LIVE] Sandbox inference test → user → sandbox → gateway → NVIDIA API..." +ssh_config="$(mktemp)" +sandbox_response="" + +if openshell sandbox ssh-config "$SANDBOX_NAME" >"$ssh_config" 2>/dev/null; then + # Use timeout if available (Linux, Homebrew), fall back to plain ssh + TIMEOUT_CMD="" + command -v timeout >/dev/null 2>&1 && TIMEOUT_CMD="timeout 90" + command -v gtimeout >/dev/null 2>&1 && TIMEOUT_CMD="gtimeout 90" + sandbox_response=$($TIMEOUT_CMD ssh -F "$ssh_config" \ + -o StrictHostKeyChecking=no \ + -o UserKnownHostsFile=/dev/null \ + -o ConnectTimeout=10 \ + -o LogLevel=ERROR \ + "openshell-${SANDBOX_NAME}" \ + "curl -s --max-time 60 https://inference.local/v1/chat/completions \ + -H 'Content-Type: application/json' \ + -d '{\"model\":\"nvidia/nemotron-3-super-120b-a12b\",\"messages\":[{\"role\":\"user\",\"content\":\"Reply with exactly one word: PONG\"}],\"max_tokens\":100}'" \ + 2>&1) || true +fi +rm -f "$ssh_config" + +if [ -n "$sandbox_response" ]; then + sandbox_content=$(echo "$sandbox_response" | parse_chat_content 2>/dev/null) || true + if grep -qi "PONG" <<<"$sandbox_content"; then + pass "[LIVE] Sandbox inference: model responded with PONG through Hermes sandbox" + info "Full path proven: user → Hermes sandbox → openshell gateway → NVIDIA Endpoints → response" + else + fail "[LIVE] Sandbox inference: expected PONG, got: ${sandbox_content:0:200}" + fi +else + fail "[LIVE] Sandbox inference: no response from inference.local inside Hermes sandbox" +fi + +# ══════════════════════════════════════════════════════════════════ +# Phase 6: NemoClaw CLI operations (Hermes-specific) +# ══════════════════════════════════════════════════════════════════ +section "Phase 6: NemoClaw CLI operations (Hermes)" + +# ── Test 6a: nemoclaw logs ── +info "Testing sandbox log retrieval..." +logs_output=$(nemoclaw "$SANDBOX_NAME" logs 2>&1) || true +if [ -n "$logs_output" ]; then + pass "nemoclaw logs: produced output ($(echo "$logs_output" | wc -l | tr -d ' ') lines)" +else + fail "nemoclaw logs: no output" +fi + +# ══════════════════════════════════════════════════════════════════ +# Phase 7: OpenClaw regression (ensure default agent path still works) +# ══════════════════════════════════════════════════════════════════ +section "Phase 7: OpenClaw regression check" + +# Verify that the agent-defs module can still load the openclaw manifest +info "Verifying OpenClaw agent manifest is still loadable..." +openclaw_check=$(node -e " + const { loadAgent, listAgents } = require('$REPO/bin/lib/agent-defs'); + const agents = listAgents(); + console.log('agents:', agents.join(', ')); + const oc = loadAgent('openclaw'); + console.log('openclaw_display:', oc.displayName); + console.log('openclaw_port:', oc.forwardPort); + const h = loadAgent('hermes'); + console.log('hermes_display:', h.displayName); + console.log('hermes_port:', h.forwardPort); +" 2>&1) || true + +if echo "$openclaw_check" | grep -q "openclaw_display:.*OpenClaw"; then + pass "OpenClaw agent manifest loads correctly" +else + fail "OpenClaw agent manifest failed to load" + info "Output: ${openclaw_check:0:300}" +fi + +if echo "$openclaw_check" | grep -q "hermes_display:.*Hermes"; then + pass "Hermes agent manifest loads correctly" +else + fail "Hermes agent manifest failed to load" + info "Output: ${openclaw_check:0:300}" +fi + +if echo "$openclaw_check" | grep -q "agents:.*openclaw.*hermes\|agents:.*hermes.*openclaw"; then + pass "Both agents listed by listAgents()" +else + fail "listAgents() did not return both openclaw and hermes" + info "Output: ${openclaw_check:0:300}" +fi + +# ══════════════════════════════════════════════════════════════════ +# Phase 8: Cleanup +# ══════════════════════════════════════════════════════════════════ +section "Phase 8: Cleanup" + +nemoclaw "$SANDBOX_NAME" destroy --yes 2>&1 | tail -3 || true +openshell gateway destroy -g nemoclaw 2>/dev/null || true + +# Verify against the registry file directly. `nemoclaw list` triggers +# gateway recovery which can restart a destroyed gateway and re-import stale +# sandbox entries — that's a separate issue, so avoid it here. +registry_file="${HOME}/.nemoclaw/sandboxes.json" +if [ -f "$registry_file" ] && grep -Fq "\"${SANDBOX_NAME}\"" "$registry_file"; then + fail "Sandbox ${SANDBOX_NAME} still in registry after destroy" +else + pass "Sandbox ${SANDBOX_NAME} removed" +fi + +# ══════════════════════════════════════════════════════════════════ +# Summary +# ══════════════════════════════════════════════════════════════════ +echo "" +echo "========================================" +echo " Hermes Agent E2E Results:" +echo " Passed: $PASS" +echo " Failed: $FAIL" +echo " Skipped: $SKIP" +echo " Total: $TOTAL" +echo "========================================" + +if [ "$FAIL" -eq 0 ]; then + printf '\n\033[1;32m Hermes E2E PASSED — agent selection + inference verified end-to-end.\033[0m\n' + exit 0 +else + printf '\n\033[1;31m %d test(s) failed.\033[0m\n' "$FAIL" + exit 1 +fi diff --git a/test/onboard.test.ts b/test/onboard.test.ts index 36d747cd7a4..ca8de9519ae 100644 --- a/test/onboard.test.ts +++ b/test/onboard.test.ts @@ -830,6 +830,36 @@ describe("onboard helpers", () => { } }); + it("detects resume conflicts when a different agent is requested", () => { + expect( + getResumeConfigConflicts( + { + sandboxName: "my-assistant", + agent: "openclaw", + }, + { agent: "hermes" }, + ), + ).toEqual([ + { + field: "agent", + requested: "hermes", + recorded: "openclaw", + }, + ]); + }); + + it("allows resume when requested agent matches recorded agent", () => { + expect( + getResumeConfigConflicts( + { + sandboxName: "my-assistant", + agent: "hermes", + }, + { agent: "hermes" }, + ), + ).toEqual([]); + }); + it("returns a future-shell PATH hint for user-local openshell installs", () => { expect(getFutureShellPathHint("/home/test/.local/bin", "/usr/local/bin:/usr/bin")).toBe( 'export PATH="/home/test/.local/bin:$PATH"', @@ -1600,7 +1630,7 @@ const { setupInference } = require(${onboardPath}); assert.match( source, - /startRecordedStep\("sandbox", \{ sandboxName, provider, model \}\);\s*sandboxName = await createSandbox\(\s*gpu,\s*model,\s*provider,\s*preferredInferenceApi,\s*sandboxName,\s*webSearchConfig,\s*enabledChannels,\s*fromDockerfile,\s*dangerouslySkipPermissions,\s*\);/, + /startRecordedStep\("sandbox", \{ sandboxName, provider, model \}\);\s*sandboxName = await createSandbox\(\s*gpu,\s*model,\s*provider,\s*preferredInferenceApi,\s*sandboxName,\s*webSearchConfig,\s*enabledChannels,\s*fromDockerfile,\s*agent,\s*dangerouslySkipPermissions,\s*\);/, ); });