From 7f9b5bf62adb79a55cb4bba0d3e4c49a2e2ace16 Mon Sep 17 00:00:00 2001 From: Shannon Sands Date: Mon, 11 May 2026 09:36:31 +1000 Subject: [PATCH] feat(hermes): add managed tool gateway broker --- agents/hermes/Dockerfile | 7 +- agents/hermes/config/build-env.ts | 12 + agents/hermes/config/hermes-config.ts | 13 + agents/hermes/config/managed-tool-gateway.ts | 56 + agents/hermes/config/messaging-config.ts | 15 + agents/hermes/generate-config.ts | 3 + .../host/managed-tool-gateway-matrix.json | 116 +++ agents/hermes/host/tool-gateway-broker.js | 426 ++++++++ agents/hermes/plugin/__init__.py | 969 +++++++++++++++++- agents/hermes/policy-additions.yaml | 79 +- docs/reference/commands.md | 5 + .../policies/presets/nous-audio.yaml | 42 + .../policies/presets/nous-browser.yaml | 128 +++ .../policies/presets/nous-code.yaml | 42 + .../policies/presets/nous-image.yaml | 42 + .../policies/presets/nous-web.yaml | 42 + src/lib/actions/sandbox/connect.ts | 18 + src/lib/actions/sandbox/rebuild.ts | 24 + src/lib/actions/sandbox/status.ts | 18 + src/lib/hermes-provider-auth.test.ts | 94 ++ src/lib/hermes-provider-auth.ts | 26 + src/lib/hermes-tool-gateway-broker.ts | 301 ++++++ src/lib/oauth-device-code.test.ts | 16 +- src/lib/oauth-device-code.ts | 4 +- src/lib/onboard.ts | 254 ++++- src/lib/state/onboard-session.ts | 11 + src/lib/state/registry.ts | 5 + test/generate-hermes-config.test.ts | 37 + test/hermes-plugin-handlers.test.ts | 149 +++ test/hermes-tool-gateway-broker.test.ts | 324 ++++++ test/onboard.test.ts | 2 +- test/policies.test.ts | 49 +- 32 files changed, 3229 insertions(+), 100 deletions(-) create mode 100644 agents/hermes/config/managed-tool-gateway.ts create mode 100644 agents/hermes/host/managed-tool-gateway-matrix.json create mode 100755 agents/hermes/host/tool-gateway-broker.js create mode 100644 nemoclaw-blueprint/policies/presets/nous-audio.yaml create mode 100644 nemoclaw-blueprint/policies/presets/nous-browser.yaml create mode 100644 nemoclaw-blueprint/policies/presets/nous-code.yaml create mode 100644 nemoclaw-blueprint/policies/presets/nous-image.yaml create mode 100644 nemoclaw-blueprint/policies/presets/nous-web.yaml create mode 100644 src/lib/hermes-tool-gateway-broker.ts create mode 100644 test/hermes-tool-gateway-broker.test.ts diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index df92adb95d5..db64cb769e0 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -43,6 +43,7 @@ RUN chmod -R a+rX /opt/nemoclaw-hermes-plugin/ # Copy config generator, Discord facade, and URL-decode proxy COPY agents/hermes/generate-config.ts /opt/nemoclaw-hermes-config/generate-config.ts COPY agents/hermes/config/ /opt/nemoclaw-hermes-config/config/ +COPY agents/hermes/host/managed-tool-gateway-matrix.json /opt/nemoclaw-hermes-config/managed-tool-gateway-matrix.json RUN find /opt/nemoclaw-hermes-config -type d -exec chmod 755 {} + \ && find /opt/nemoclaw-hermes-config -type f -exec chmod 444 {} + COPY agents/hermes/decode-proxy.py /usr/local/bin/nemoclaw-decode-proxy @@ -76,6 +77,8 @@ ARG NEMOCLAW_MESSAGING_CHANNELS_B64=W10= ARG NEMOCLAW_MESSAGING_ALLOWED_IDS_B64=e30= ARG NEMOCLAW_DISCORD_GUILDS_B64=e30= ARG NEMOCLAW_TELEGRAM_CONFIG_B64=e30= +ARG NEMOCLAW_HERMES_TOOL_GATEWAY_BROKER=0 +ARG NEMOCLAW_HERMES_TOOL_GATEWAY_PRESETS_B64=W10= ARG NEMOCLAW_BUILD_ID=default # Promote build-args to env vars for the config generation script. @@ -86,7 +89,9 @@ ENV NEMOCLAW_MODEL=${NEMOCLAW_MODEL} \ NEMOCLAW_MESSAGING_CHANNELS_B64=${NEMOCLAW_MESSAGING_CHANNELS_B64} \ NEMOCLAW_MESSAGING_ALLOWED_IDS_B64=${NEMOCLAW_MESSAGING_ALLOWED_IDS_B64} \ NEMOCLAW_DISCORD_GUILDS_B64=${NEMOCLAW_DISCORD_GUILDS_B64} \ - NEMOCLAW_TELEGRAM_CONFIG_B64=${NEMOCLAW_TELEGRAM_CONFIG_B64} + NEMOCLAW_TELEGRAM_CONFIG_B64=${NEMOCLAW_TELEGRAM_CONFIG_B64} \ + NEMOCLAW_HERMES_TOOL_GATEWAY_BROKER=${NEMOCLAW_HERMES_TOOL_GATEWAY_BROKER} \ + NEMOCLAW_HERMES_TOOL_GATEWAY_PRESETS_B64=${NEMOCLAW_HERMES_TOOL_GATEWAY_PRESETS_B64} WORKDIR /sandbox USER sandbox diff --git a/agents/hermes/config/build-env.ts b/agents/hermes/config/build-env.ts index ed5aaca8f45..f6f7688e629 100644 --- a/agents/hermes/config/build-env.ts +++ b/agents/hermes/config/build-env.ts @@ -22,6 +22,10 @@ export type HermesBuildSettings = { baseUrl: string; providerKey: string; inferenceApi: string; + managedToolGateways: { + brokerEnabled: boolean; + presets: string[]; + }; messaging: { enabledChannels: Set; allowedIds: MessagingAllowedIds; @@ -39,6 +43,14 @@ export function readHermesBuildSettings(env: NodeJS.ProcessEnv): HermesBuildSett baseUrl, providerKey: env.NEMOCLAW_PROVIDER_KEY || "custom", inferenceApi: env.NEMOCLAW_INFERENCE_API || "", + managedToolGateways: { + brokerEnabled: env.NEMOCLAW_HERMES_TOOL_GATEWAY_BROKER === "1", + presets: readBase64Json( + env, + "NEMOCLAW_HERMES_TOOL_GATEWAY_PRESETS_B64", + "W10=", + ), + }, messaging: { enabledChannels: new Set( readBase64Json(env, "NEMOCLAW_MESSAGING_CHANNELS_B64", "W10="), diff --git a/agents/hermes/config/hermes-config.ts b/agents/hermes/config/hermes-config.ts index eaffc4ed39f..9d80c20eb71 100644 --- a/agents/hermes/config/hermes-config.ts +++ b/agents/hermes/config/hermes-config.ts @@ -2,6 +2,10 @@ // SPDX-License-Identifier: Apache-2.0 import type { HermesBuildSettings } from "./build-env.ts"; +import { + applyManagedToolConfig, + loadManagedToolGatewayMatrix, +} from "./managed-tool-gateway.ts"; import { buildDiscordConfig } from "./messaging-config.ts"; export function buildHermesConfig(settings: HermesBuildSettings): Record { @@ -40,6 +44,15 @@ export function buildHermesConfig(settings: HermesBuildSettings): Record; + envKey: string; + envValue: string; +}; + +export type ManagedToolGatewayMatrix = Record; + +export function loadManagedToolGatewayMatrix(): ManagedToolGatewayMatrix { + const scriptDir = dirname(fileURLToPath(import.meta.url)); + const candidates = [ + process.env.NEMOCLAW_HERMES_TOOL_GATEWAY_MATRIX_PATH, + join(scriptDir, "hermes-managed-tool-gateway-matrix.json"), + join(scriptDir, "../hermes-managed-tool-gateway-matrix.json"), + join(scriptDir, "../host/managed-tool-gateway-matrix.json"), + "/opt/nemoclaw-hermes-config/managed-tool-gateway-matrix.json", + ].filter((candidate): candidate is string => Boolean(candidate)); + + for (const candidate of candidates) { + if (!existsSync(candidate)) continue; + return JSON.parse(readFileSync(candidate, "utf8")) as ManagedToolGatewayMatrix; + } + + throw new Error("Hermes managed tool gateway matrix not found"); +} + +export function applyManagedToolConfig( + config: Record, + entryConfig: Record, +): void { + for (const [section, sectionValue] of Object.entries(entryConfig)) { + if ( + sectionValue && + typeof sectionValue === "object" && + !Array.isArray(sectionValue) && + config[section] && + typeof config[section] === "object" && + !Array.isArray(config[section]) + ) { + config[section] = { + ...(config[section] as Record), + ...(sectionValue as Record), + }; + } else { + config[section] = sectionValue; + } + } +} diff --git a/agents/hermes/config/messaging-config.ts b/agents/hermes/config/messaging-config.ts index eb27fdeb568..c79b585cf67 100644 --- a/agents/hermes/config/messaging-config.ts +++ b/agents/hermes/config/messaging-config.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import type { DiscordGuilds, MessagingAllowedIds } from "./build-env.ts"; +import { loadManagedToolGatewayMatrix } from "./managed-tool-gateway.ts"; const CHANNEL_TOKEN_ENVS: Record = { telegram: ["TELEGRAM_BOT_TOKEN"], @@ -16,9 +17,23 @@ export function buildMessagingEnvLines( enabledChannels: Set, allowedIds: MessagingAllowedIds, discordGuilds: DiscordGuilds, + managedToolGatewayPresets: string[] = [], ): string[] { const envLines = ["API_SERVER_PORT=18642", "API_SERVER_HOST=127.0.0.1"]; + if (managedToolGatewayPresets.length > 0) { + const matrix = loadManagedToolGatewayMatrix(); + envLines.push("NEMOCLAW_HERMES_TOOL_GATEWAY_BROKER=1"); + envLines.push( + "TOOL_GATEWAY_USER_TOKEN=openshell:resolve:env:NEMOCLAW_HERMES_TOOL_GATEWAY_REFRESH_TOKEN", + ); + for (const preset of managedToolGatewayPresets) { + const entry = matrix[preset]; + if (!entry) continue; + envLines.push(`${entry.envKey}=${entry.envValue}`); + } + } + for (const channel of enabledChannels) { const envKeys = CHANNEL_TOKEN_ENVS[channel] ?? []; for (const envKey of envKeys) { diff --git a/agents/hermes/generate-config.ts b/agents/hermes/generate-config.ts index 114dc5771a8..6fa4717dd6e 100644 --- a/agents/hermes/generate-config.ts +++ b/agents/hermes/generate-config.ts @@ -41,6 +41,9 @@ function main(): void { settings.messaging.enabledChannels, settings.messaging.allowedIds, settings.messaging.discordGuilds, + settings.managedToolGateways.brokerEnabled + ? settings.managedToolGateways.presets + : [], ); const written = writeHermesConfigFiles(config, envLines); diff --git a/agents/hermes/host/managed-tool-gateway-matrix.json b/agents/hermes/host/managed-tool-gateway-matrix.json new file mode 100644 index 00000000000..f0f16d8801f --- /dev/null +++ b/agents/hermes/host/managed-tool-gateway-matrix.json @@ -0,0 +1,116 @@ +{ + "nous-web": { + "service": "firecrawl", + "description": "Nous Portal managed web search and crawl gateway", + "config": { + "web": { + "backend": "firecrawl", + "use_gateway": true + } + }, + "envKey": "FIRECRAWL_GATEWAY_URL", + "envValue": "http://host.openshell.internal:11436/firecrawl", + "brokerPath": "/firecrawl", + "upstream": "https://firecrawl-gateway.nousresearch.com", + "sandboxAuthHeaders": ["Authorization: Bearer", "x-firecrawl-api-key", "x-api-key"], + "upstreamAuthHeader": "Authorization: Bearer", + "policyPreset": "nous-web", + "tools": ["web_search", "web_extract"] + }, + "nous-audio": { + "service": "openai-audio", + "description": "Nous Portal managed audio generation and transcription gateway", + "config": { + "tts": { + "provider": "openai", + "use_gateway": true + }, + "stt": { + "provider": "openai", + "use_gateway": true + } + }, + "envKey": "OPENAI_AUDIO_GATEWAY_URL", + "envValue": "http://host.openshell.internal:11436/openai-audio", + "brokerPath": "/openai-audio", + "upstream": "https://openai-audio-gateway.nousresearch.com", + "sandboxAuthHeaders": ["Authorization: Bearer", "openai-api-key", "x-api-key"], + "upstreamAuthHeader": "Authorization: Bearer", + "policyPreset": "nous-audio", + "tools": ["text_to_speech", "transcribe_audio"] + }, + "nous-browser": { + "service": "browser-use", + "description": "Nous Portal managed browser automation gateway", + "config": { + "browser": { + "cloud_provider": "browser-use", + "use_gateway": true + } + }, + "envKey": "BROWSER_USE_GATEWAY_URL", + "envValue": "http://host.openshell.internal:11436/browser-use", + "brokerPath": "/browser-use", + "upstream": "https://browser-use-gateway.nousresearch.com", + "sandboxAuthHeaders": ["X-Browser-Use-API-Key", "x-api-key"], + "upstreamAuthHeader": "X-Browser-Use-API-Key", + "policyPreset": "nous-browser", + "tools": [ + "browser_navigate", + "browser_snapshot", + "browser_click", + "browser_type", + "browser_scroll", + "browser_back", + "browser_press" + ], + "transportExceptions": [ + "*.cdp1.browser-use.com", + "*.cdp2.browser-use.com", + "*.cdp3.browser-use.com", + "*.cdp4.browser-use.com", + "*.cdp5.browser-use.com", + "*.cdp6.browser-use.com", + "*.cdp7.browser-use.com", + "*.cdp8.browser-use.com", + "*.cdp9.browser-use.com", + "*.cdp10.browser-use.com" + ] + }, + "nous-image": { + "service": "fal-queue", + "description": "Nous Portal managed image generation gateway", + "config": { + "image_gen": { + "use_gateway": true + } + }, + "envKey": "FAL_QUEUE_GATEWAY_URL", + "envValue": "http://host.openshell.internal:11436/fal-queue", + "brokerPath": "/fal-queue", + "upstream": "https://fal-queue-gateway.nousresearch.com", + "sandboxAuthHeaders": ["Authorization: Key", "x-fal-key", "x-api-key"], + "upstreamAuthHeader": "Authorization: Key", + "policyPreset": "nous-image", + "tools": ["image_generate"] + }, + "nous-code": { + "service": "modal", + "description": "Nous Portal managed sandboxed code execution gateway", + "config": { + "terminal": { + "backend": "modal", + "modal_mode": "managed", + "timeout": 180 + } + }, + "envKey": "MODAL_GATEWAY_URL", + "envValue": "http://host.openshell.internal:11436/modal", + "brokerPath": "/modal", + "upstream": "https://modal-gateway.nousresearch.com", + "sandboxAuthHeaders": ["Authorization: Bearer", "x-api-key"], + "upstreamAuthHeader": "Authorization: Bearer", + "policyPreset": "nous-code", + "tools": ["terminal"] + } +} diff --git a/agents/hermes/host/tool-gateway-broker.js b/agents/hermes/host/tool-gateway-broker.js new file mode 100755 index 00000000000..4785557cf00 --- /dev/null +++ b/agents/hermes/host/tool-gateway-broker.js @@ -0,0 +1,426 @@ +#!/usr/bin/env node +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +/* global fetch, URLSearchParams */ + +/** + * Host-side Hermes managed-tool gateway broker. + * + * Hermes managed tools need a Nous subscription credential, but the sandbox + * must not own raw Nous OAuth state. NemoClaw stores the refresh credential in + * OpenShell provider storage, generates sandbox .env placeholders, and lets + * OpenShell resolve the placeholder into an auth header when the sandbox calls + * this broker. The broker refreshes on the host with x-nous-refresh-token, + * injects a short-lived access token upstream, and persists only a refresh-token + * hash so rotated refresh tokens can update OpenShell without writing raw + * OAuth/API secrets to ~/.nemoclaw. + */ + +const crypto = require("crypto"); +const fs = require("fs"); +const http = require("http"); +const path = require("path"); +const { spawnSync } = require("child_process"); + +const PORT = parseInt(process.env.HERMES_TOOL_GATEWAY_PORT || "11436", 10); +const STATE_DIR = process.env.HERMES_TOOL_GATEWAY_STATE_DIR; +const MATRIX_PATH = + process.env.HERMES_TOOL_GATEWAY_MATRIX_PATH || + path.join(__dirname, "managed-tool-gateway-matrix.json"); +const PORTAL_BASE_URL = ( + process.env.NOUS_PORTAL_BASE_URL || "https://portal.nousresearch.com" +).replace(/\/+$/, ""); +const CLIENT_ID = process.env.HERMES_TOOL_GATEWAY_CLIENT_ID || "hermes-cli"; +const OPENSHELL_BIN = process.env.NEMOCLAW_OPENSHELL_BIN || "openshell"; +const CREDENTIAL_ENV = + process.env.HERMES_TOOL_GATEWAY_REFRESH_CREDENTIAL_ENV || + "NEMOCLAW_HERMES_TOOL_GATEWAY_REFRESH_TOKEN"; + +if (!STATE_DIR) { + console.error("HERMES_TOOL_GATEWAY_STATE_DIR required"); + process.exit(1); +} + +const HOP_BY_HOP_HEADERS = new Set([ + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", +]); +const DECODED_RESPONSE_HEADERS = new Set(["content-encoding", "content-length", "content-md5"]); +const STRIPPED_SECRET_HEADERS = new Set([ + "authorization", + "cookie", + "x-api-key", + "api-key", + "x-browser-use-api-key", + "openai-api-key", + "x-fal-key", + "x-firecrawl-api-key", +]); +const TOKEN_HEADERS = [ + "x-api-key", + "api-key", + "x-browser-use-api-key", + "openai-api-key", + "x-fal-key", + "x-firecrawl-api-key", +]; + +const accessTokenCache = new Map(); + +function sha256(value) { + return crypto.createHash("sha256").update(String(value)).digest("hex"); +} + +function loadMatrix() { + try { + const matrix = JSON.parse(fs.readFileSync(MATRIX_PATH, "utf8")); + return Object.fromEntries( + Object.values(matrix) + .filter((entry) => entry && typeof entry === "object") + .map((entry) => [entry.service, entry]) + .filter(([service, entry]) => { + return typeof service === "string" && typeof entry.upstream === "string"; + }), + ); + } catch (error) { + console.error(`failed to load Hermes tool gateway matrix: ${error.message || error}`); + process.exit(1); + } +} + +const MATRIX = loadMatrix(); + +function stateFiles() { + try { + return fs + .readdirSync(STATE_DIR) + .filter((name) => name.endsWith(".json")) + .map((name) => path.join(STATE_DIR, name)); + } catch { + return []; + } +} + +function loadStateFile(file) { + try { + const parsed = JSON.parse(fs.readFileSync(file, "utf8")); + if (!parsed || typeof parsed !== "object") return null; + if (!parsed.refresh_token_sha256 || !parsed.provider_name) return null; + return { file, state: parsed }; + } catch { + return null; + } +} + +function findStateByRefreshToken(refreshToken) { + const digest = sha256(refreshToken); + for (const file of stateFiles()) { + const loaded = loadStateFile(file); + if (!loaded) continue; + if (timingSafeEqualString(String(loaded.state.refresh_token_sha256 || ""), digest)) { + return loaded; + } + } + return null; +} + +function timingSafeEqualString(a, b) { + const aBuf = Buffer.from(String(a || "")); + const bBuf = Buffer.from(String(b || "")); + if (aBuf.length !== bBuf.length) return false; + return crypto.timingSafeEqual(aBuf, bBuf); +} + +function extractRefreshToken(req) { + const auth = req.headers.authorization; + if (typeof auth === "string") { + const match = auth.match(/^(?:Bearer|Key)\s+(.+)$/i); + if (match) return match[1].trim(); + } + for (const headerName of TOKEN_HEADERS) { + const value = req.headers[headerName]; + if (typeof value === "string" && value.trim()) return value.trim(); + if (Array.isArray(value) && value.length > 0) return String(value[0]).trim(); + } + return null; +} + +function parseRoute(reqUrl) { + const url = new URL(reqUrl || "/", "http://broker.local"); + const parts = url.pathname.split("/").filter(Boolean); + const service = parts[0] || ""; + const entry = MATRIX[service]; + if (!entry) return null; + const upstreamBase = String(entry.upstream).replace(/\/+$/, ""); + const suffix = "/" + parts.slice(1).join("/"); + return { + service, + entry, + upstreamUrl: upstreamBase + (suffix === "/" ? "/" : suffix) + (url.search || ""), + }; +} + +function tokenExpiresSoon(cacheEntry) { + if (!cacheEntry?.expiresAt) return true; + return cacheEntry.expiresAt - Date.now() < 120_000; +} + +function atomicWriteJson(file, value) { + fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 }); + const tmp = path.join( + path.dirname(file), + `.${path.basename(file)}.${process.pid}.${Date.now()}.${Math.random() + .toString(36) + .slice(2)}.tmp`, + ); + fs.writeFileSync(tmp, JSON.stringify(value, null, 2) + "\n", { mode: 0o600 }); + fs.chmodSync(tmp, 0o600); + fs.renameSync(tmp, file); + fs.chmodSync(file, 0o600); +} + +function updateOpenshellRefreshProvider(state, refreshToken) { + const providerName = String(state.provider_name || ""); + if (!providerName) return; + const result = spawnSync( + OPENSHELL_BIN, + ["provider", "update", providerName, "--credential", CREDENTIAL_ENV], + { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + env: { ...process.env, [CREDENTIAL_ENV]: refreshToken }, + timeout: 30_000, + }, + ); + if (result.status !== 0) { + throw Object.assign(new Error("openshell_provider_update_failed"), { + code: "openshell_provider_update_failed", + }); + } +} + +async function refreshAccessToken(refreshToken, loaded) { + const digest = sha256(refreshToken); + const cached = accessTokenCache.get(digest); + if (cached?.accessToken && !tokenExpiresSoon(cached)) { + return cached.accessToken; + } + + const body = new URLSearchParams({ + grant_type: "refresh_token", + client_id: loaded.state.client_id || CLIENT_ID, + }); + const resp = await fetch(`${PORTAL_BASE_URL}/api/oauth/token`, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded", + "x-nous-refresh-token": refreshToken, + }, + body, + }); + + if (!resp.ok) { + const code = resp.status === 400 || resp.status === 401 ? "reauth_required" : "refresh_failed"; + throw Object.assign(new Error(`refresh_failed_http_${resp.status}`), { code }); + } + + const payload = await resp.json(); + if (!payload?.access_token) { + throw Object.assign(new Error("token_response_missing_access_token"), { + code: "refresh_failed", + }); + } + + const expiresIn = + typeof payload.expires_in === "number" && Number.isFinite(payload.expires_in) + ? payload.expires_in + : 900; + const nextRefreshToken = + typeof payload.refresh_token === "string" && payload.refresh_token + ? payload.refresh_token + : refreshToken; + const nextDigest = sha256(nextRefreshToken); + accessTokenCache.delete(digest); + accessTokenCache.set(nextDigest, { + accessToken: payload.access_token, + expiresAt: Date.now() + expiresIn * 1000, + }); + + if (nextDigest !== digest) { + updateOpenshellRefreshProvider(loaded.state, nextRefreshToken); + const nextState = { + ...loaded.state, + refresh_token_sha256: nextDigest, + rotated_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; + atomicWriteJson(loaded.file, nextState); + loaded.state = nextState; + } + + return payload.access_token; +} + +function readRequestBody(req) { + return new Promise((resolve, reject) => { + const chunks = []; + req.on("data", (chunk) => chunks.push(chunk)); + req.on("end", () => resolve(Buffer.concat(chunks))); + req.on("error", reject); + }); +} + +function buildForwardHeaders(req, route, accessToken) { + const headers = {}; + for (const [name, value] of Object.entries(req.headers)) { + const lower = name.toLowerCase(); + if (lower === "host" || lower === "content-length" || lower === "accept-encoding") continue; + if (HOP_BY_HOP_HEADERS.has(lower) || STRIPPED_SECRET_HEADERS.has(lower)) continue; + headers[name] = Array.isArray(value) ? value.join(", ") : String(value); + } + headers["accept-encoding"] = "identity"; + switch (route.service) { + case "browser-use": + headers["X-Browser-Use-API-Key"] = accessToken; + break; + case "fal-queue": + headers.authorization = `Key ${accessToken}`; + break; + default: + headers.authorization = `Bearer ${accessToken}`; + break; + } + return headers; +} + +function forwardResponseHeaders(upstreamResp) { + const headers = {}; + upstreamResp.headers.forEach((value, name) => { + const lower = name.toLowerCase(); + if ( + HOP_BY_HOP_HEADERS.has(lower) || + DECODED_RESPONSE_HEADERS.has(lower) || + lower === "set-cookie" + ) { + return; + } + headers[name] = value; + }); + return headers; +} + +function sendJson(res, status, payload) { + res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" }); + res.end(JSON.stringify(payload)); +} + +function sendText(res, status, text) { + res.writeHead(status, { "Content-Type": "text/plain; charset=utf-8" }); + res.end(text); +} + +function errorCode(err) { + return err && typeof err === "object" && typeof err.code === "string" ? err.code : null; +} + +async function handleProxy(req, res, route) { + const refreshToken = extractRefreshToken(req); + if (!refreshToken) { + sendText(res, 401, "Hermes managed tools require Nous Portal OAuth. Re-run nemohermes onboard --resume."); + return; + } + if (refreshToken.startsWith("openshell:resolve:env:")) { + sendText(res, 401, "OpenShell did not resolve the Hermes tool-gateway credential placeholder."); + return; + } + + const loaded = findStateByRefreshToken(refreshToken); + if (!loaded) { + sendText(res, 401, "Unknown Hermes tool-gateway credential. Re-run nemohermes onboard --resume."); + return; + } + + let accessToken; + try { + accessToken = await refreshAccessToken(refreshToken, loaded); + } catch (err) { + const code = errorCode(err); + if (code === "reauth_required") { + sendText( + res, + 401, + "Nous OAuth refresh failed. Re-run nemohermes onboard --resume to re-authorize managed tools.", + ); + return; + } + console.error(`Hermes tool gateway refresh failed: ${code || "refresh_failed"}`); + sendText(res, 502, "Hermes tool gateway could not refresh host-side OAuth."); + return; + } + + let body; + try { + body = await readRequestBody(req); + } catch { + sendText(res, 400, "failed to read request body"); + return; + } + + let upstreamResp; + try { + upstreamResp = await fetch(route.upstreamUrl, { + method: req.method, + headers: buildForwardHeaders(req, route, accessToken), + body: req.method === "GET" || req.method === "HEAD" ? undefined : body, + redirect: "manual", + }); + } catch { + sendText(res, 502, "upstream gateway request failed"); + return; + } + + const buffer = Buffer.from(await upstreamResp.arrayBuffer()); + res.writeHead(upstreamResp.status, forwardResponseHeaders(upstreamResp)); + res.end(buffer); +} + +const server = http.createServer((req, res) => { + Promise.resolve() + .then(async () => { + if (req.url === "/health") { + sendJson(res, 200, { + ok: true, + services: Object.keys(MATRIX).sort(), + }); + return; + } + const route = parseRoute(req.url); + if (!route) { + sendText(res, 404, "unknown Hermes managed-tool gateway route"); + return; + } + await handleProxy(req, res, route); + }) + .catch((err) => { + console.error(`Hermes tool gateway internal error: ${err?.message || err}`); + if (!res.headersSent) { + sendText(res, 500, "Hermes tool gateway internal error"); + } else { + res.end(); + } + }); +}); + +server.listen(PORT, "0.0.0.0", () => { + console.error(`Hermes managed-tool gateway broker listening on :${PORT}`); +}); + +process.on("SIGTERM", () => server.close(() => process.exit(0))); +process.on("SIGINT", () => server.close(() => process.exit(0))); diff --git a/agents/hermes/plugin/__init__.py b/agents/hermes/plugin/__init__.py index 6823d956dea..7e788280389 100644 --- a/agents/hermes/plugin/__init__.py +++ b/agents/hermes/plugin/__init__.py @@ -3,8 +3,9 @@ """ NemoClaw plugin for Hermes Agent. -Provides sandbox status tools, skill hot-reload, and a startup banner when -Hermes runs inside an OpenShell sandbox managed by NemoClaw. +Provides sandbox status tools, skill hot-reload, managed-tool broker patches, +and quiet runtime grounding when Hermes runs inside an OpenShell sandbox +managed by NemoClaw. Skill hot-reload: Hermes caches its skill slash-command registry in a module-global dict on first scan. New skills dropped on disk are invisible @@ -12,13 +13,809 @@ tool that clears the cache and re-scans, letting the agent pick up new skills without a gateway restart. The on_session_start hook also refreshes skills automatically at session boundaries. + +Runtime grounding: earlier versions injected a visible startup banner, but +Hermes TUI renders plugin-injected messages through the interrupt queue. This +plugin now uses Hermes' pre_llm_call context hook so the model sees the +NemoClaw sandbox/tool-execution topology without leaking visual noise into the +chat transcript. """ +import atexit import json import os import subprocess +import sys +from dataclasses import replace as dataclass_replace +from urllib.parse import urlparse, urlunparse + import yaml +_BROKER_PATCH_ATTR = "_nemoclaw_tool_gateway_broker_patch_installed" +_AUDIO_GATEWAY_PREFERENCE_PATCH_ATTR = "_nemoclaw_audio_gateway_preference_patch_installed" +_TRANSCRIPTION_GATEWAY_PATCH_ATTR = "_nemoclaw_transcription_gateway_patch_installed" +_FAL_QUEUE_HANDLE_PATCH_ATTR = "_nemoclaw_fal_queue_handle_patch_installed" +_FIRECRAWL_PATH_PATCH_ATTR = "_nemoclaw_firecrawl_path_patch_installed" +_BROWSER_CDP_TUNNEL_PATCH_ATTR = "_nemoclaw_browser_use_cdp_tunnel_patch_installed" +_BROWSER_SESSION_STATE_PATCH_ATTR = "_nemoclaw_browser_use_session_state_patch_installed" +_BROWSER_USE_CDP_TUNNELS = {} + +_TOOL_GATEWAY_URL_ENV = { + "firecrawl": "FIRECRAWL_GATEWAY_URL", + "fal-queue": "FAL_QUEUE_GATEWAY_URL", + "openai-audio": "OPENAI_AUDIO_GATEWAY_URL", + "browser-use": "BROWSER_USE_GATEWAY_URL", + "modal": "MODAL_GATEWAY_URL", +} + +_NEMOCLAW_CONTEXT_KEYWORDS = ( + "browser", + "config", + "discord", + "environment", + "gateway", + "hermes", + "host", + "logs", + "modal", + "nemoclaw", + "openshell", + "sandbox", + "skill", + "slack", + "status", + "telegram", + "tool", + "where am i", + "whoami", +) + + +def _get_env_value(key, default=None): + """Read env from os.environ, then Hermes' dotenv-aware config loader.""" + value = os.getenv(key) + if value is not None: + return value + + try: + from hermes_cli.config import get_env_value + + value = get_env_value(key) + if value is not None: + return value + except Exception: + pass + + env_paths = [] + hermes_home = os.getenv("HERMES_HOME") + if hermes_home: + env_paths.append(os.path.join(hermes_home, ".env")) + env_paths.extend( + [ + "/sandbox/.hermes-data/.env", + "/sandbox/.hermes/.env", + os.path.expanduser("~/.hermes/.env"), + ], + ) + + for env_path in env_paths: + if not env_path or not os.path.exists(env_path): + continue + try: + with open(env_path, encoding="utf-8") as f: + for line in f: + stripped = line.strip() + if not stripped or stripped.startswith("#") or "=" not in stripped: + continue + name, raw = stripped.split("=", 1) + if name == key: + return raw.strip().strip('"').strip("'") + except Exception: + continue + + return default + + +def _load_hermes_dotenv(): + """Populate os.environ from Hermes .env when this plugin is loaded cold.""" + try: + from hermes_cli.env_loader import load_hermes_dotenv + + hermes_home = os.getenv("HERMES_HOME") + if not hermes_home and os.path.isdir("/sandbox/.hermes-data"): + hermes_home = "/sandbox/.hermes-data" + load_hermes_dotenv(hermes_home=hermes_home) + except Exception: + pass + + +def _broker_mode_enabled(): + return _get_env_value("NEMOCLAW_HERMES_TOOL_GATEWAY_BROKER") == "1" + + +def _broker_gateway_url(vendor): + """Resolve a managed tool gateway URL without requiring sandbox OAuth.""" + vendor = str(vendor or "").strip() + env_key = _TOOL_GATEWAY_URL_ENV.get(vendor, f"{vendor.upper().replace('-', '_')}_GATEWAY_URL") + explicit = (_get_env_value(env_key, "") or "").strip().rstrip("/") + if explicit: + return explicit + + scheme = (_get_env_value("TOOL_GATEWAY_SCHEME", "https") or "https").strip().lower() + if scheme not in {"http", "https"}: + scheme = "https" + domain = (_get_env_value("TOOL_GATEWAY_DOMAIN", "") or "").strip().strip("/") + if domain: + return f"{scheme}://{vendor}-gateway.{domain}" + return f"{scheme}://{vendor}-gateway.nousresearch.com" + + +def _broker_user_token(): + token = _get_env_value("TOOL_GATEWAY_USER_TOKEN", "") + return token.strip() if isinstance(token, str) and token.strip() else None + + +def _config_prefers_gateway(section_name): + try: + from hermes_cli.config import load_config + + section = (load_config() or {}).get(section_name) + return isinstance(section, dict) and bool(section.get("use_gateway")) + except Exception: + return False + + +def _patch_loaded_tool_module(module_name, managed_gateway_module=None): + module = sys.modules.get(module_name) + if module is None: + return False + + def _enabled(): + return True + + patched = False + if hasattr(module, "managed_nous_tools_enabled"): + setattr(module, "managed_nous_tools_enabled", _enabled) + patched = True + if hasattr(module, "build_vendor_gateway_url"): + setattr(module, "build_vendor_gateway_url", _broker_gateway_url) + patched = True + if hasattr(module, "_read_nous_access_token"): + setattr(module, "_read_nous_access_token", _broker_user_token) + patched = True + if managed_gateway_module is not None and hasattr(module, "resolve_managed_tool_gateway"): + setattr( + module, + "resolve_managed_tool_gateway", + managed_gateway_module.resolve_managed_tool_gateway, + ) + patched = True + return patched + + +def _install_audio_gateway_preference_patch(): + """Make OpenAI-audio helper imports prefer the broker in audio gateway mode. + + Hermes transcription currently checks direct OpenAI env keys before trying + the managed OpenAI-audio gateway. NemoClaw may also need an OPENAI_API_KEY + placeholder for a separate inference path, so the audio tools must not + treat that placeholder as direct voice/STT auth when `stt.use_gateway` or + `tts.use_gateway` is configured. + """ + if not _broker_mode_enabled(): + return False + + try: + module = __import__("tools.tool_backend_helpers", fromlist=["resolve_openai_audio_api_key"]) + except Exception: + return False + + if not hasattr(module, "resolve_openai_audio_api_key") or getattr( + module, + _AUDIO_GATEWAY_PREFERENCE_PATCH_ATTR, + False, + ): + return False + + original = module.resolve_openai_audio_api_key + + def resolve_openai_audio_api_key(): + if _broker_mode_enabled() and ( + _config_prefers_gateway("stt") or _config_prefers_gateway("tts") + ): + return "" + return original() + + module.resolve_openai_audio_api_key = resolve_openai_audio_api_key + setattr(module, _AUDIO_GATEWAY_PREFERENCE_PATCH_ATTR, True) + return True + + +def _managed_openai_audio_client_config(resolve_managed_tool_gateway): + managed_gateway = resolve_managed_tool_gateway("openai-audio") + if managed_gateway is None: + return None + return ( + managed_gateway.nous_user_token, + f"{managed_gateway.gateway_origin.rstrip('/')}/v1", + ) + + +def _install_transcription_gateway_patch(): + """Prefer NemoClaw's OpenAI-audio broker for Hermes transcription tools.""" + if not _broker_mode_enabled(): + return False + + try: + module = __import__("tools.transcription_tools", fromlist=["_resolve_openai_audio_client_config"]) + except Exception: + return False + + if not hasattr(module, "_resolve_openai_audio_client_config") or getattr( + module, + _TRANSCRIPTION_GATEWAY_PATCH_ATTR, + False, + ): + return False + + original = module._resolve_openai_audio_client_config + + def _resolve_openai_audio_client_config(): + if _config_prefers_gateway("stt"): + try: + managed_config = _managed_openai_audio_client_config( + module.resolve_managed_tool_gateway, + ) + if managed_config is not None: + return managed_config + except Exception: + pass + return original() + + def _has_openai_audio_backend(): + try: + _resolve_openai_audio_client_config() + return True + except ValueError: + return False + + module._resolve_openai_audio_client_config = _resolve_openai_audio_client_config + if hasattr(module, "_has_openai_audio_backend"): + module._has_openai_audio_backend = _has_openai_audio_backend + setattr(module, _TRANSCRIPTION_GATEWAY_PATCH_ATTR, True) + return True + + +def _rewrite_fal_queue_url(url, broker_base): + parsed = urlparse(str(url or "")) + broker = urlparse(str(broker_base or "").rstrip("/")) + if not parsed.scheme or not parsed.netloc or not broker.scheme or not broker.netloc: + return url + if parsed.netloc == broker.netloc: + return url + if parsed.hostname != "fal-queue-gateway.nousresearch.com": + return url + + broker_prefix = broker.path.rstrip("/") + path = parsed.path or "/" + if broker_prefix and not path.startswith(f"{broker_prefix}/"): + path = f"{broker_prefix}{path}" + return urlunparse((broker.scheme, broker.netloc, path, "", parsed.query, parsed.fragment)) + + +def _replace_fal_handle_urls(handle, urls): + try: + return dataclass_replace(handle, **urls) + except Exception: + pass + + try: + return handle.__class__( + request_id=handle.request_id, + response_url=urls["response_url"], + status_url=urls["status_url"], + cancel_url=urls["cancel_url"], + client=handle.client, + ) + except Exception: + pass + + for name, value in urls.items(): + try: + object.__setattr__(handle, name, value) + except Exception: + return handle + return handle + + +def _install_fal_queue_handle_patch(): + """Keep FAL queue polling/result URLs on NemoClaw's broker route. + + Hermes submits managed FAL jobs through the configured queue origin, but + the gateway response can contain absolute status/result URLs for the + upstream ``fal-queue-gateway.nousresearch.com`` host. The sandbox policy + intentionally blocks that direct host, so rewrite returned handle URLs back + to ``http://host.openshell.internal:11436/fal-queue/...`` before + ``handler.get()`` starts polling. + """ + if not _broker_mode_enabled(): + return False + + try: + module = __import__("tools.image_generation_tool", fromlist=["_ManagedFalSyncClient"]) + except Exception: + return False + + client_cls = getattr(module, "_ManagedFalSyncClient", None) + if client_cls is None or getattr(client_cls, _FAL_QUEUE_HANDLE_PATCH_ATTR, False): + return False + + original = client_cls.submit + + def submit(self, *args, **kwargs): + handle = original(self, *args, **kwargs) + broker_base = getattr(self, "_queue_url_format", "") or _broker_gateway_url("fal-queue") + urls = { + "response_url": _rewrite_fal_queue_url( + getattr(handle, "response_url", ""), + broker_base, + ), + "status_url": _rewrite_fal_queue_url(getattr(handle, "status_url", ""), broker_base), + "cancel_url": _rewrite_fal_queue_url(getattr(handle, "cancel_url", ""), broker_base), + } + if ( + urls["response_url"] == getattr(handle, "response_url", None) + and urls["status_url"] == getattr(handle, "status_url", None) + and urls["cancel_url"] == getattr(handle, "cancel_url", None) + ): + return handle + return _replace_fal_handle_urls(handle, urls) + + client_cls.submit = submit + setattr(client_cls, _FAL_QUEUE_HANDLE_PATCH_ATTR, True) + return True + + +_BROWSER_USE_CDP_TUNNEL_SCRIPT = r""" +import base64 +import os +import select +import socket +import ssl +import sys +import threading +import time +from urllib.parse import urlparse + +remote_url = sys.argv[1] +remote = urlparse(remote_url) +proxy = urlparse(os.environ.get("HTTPS_PROXY") or os.environ.get("HTTP_PROXY") or "") +if remote.scheme != "wss" or not remote.hostname or not proxy.hostname: + raise SystemExit(2) + +target_host = remote.hostname +target_port = remote.port or 443 +target_path = remote.path or "/" +if remote.query: + target_path += "?" + remote.query +proxy_port = proxy.port or 8080 +idle_timeout = int(os.environ.get("NEMOCLAW_CDP_TUNNEL_IDLE_SECONDS", "600")) +last_activity = time.monotonic() + + +def _read_headers(sock): + data = b"" + while b"\r\n\r\n" not in data: + chunk = sock.recv(65536) + if not chunk: + break + data += chunk + if len(data) > 262144: + raise OSError("header too large") + return data + + +def _connect_remote(): + raw = socket.create_connection((proxy.hostname, proxy_port), timeout=15) + host_header = f"{target_host}:{target_port}" + lines = [ + f"CONNECT {host_header} HTTP/1.1", + f"Host: {host_header}", + ] + if proxy.username: + user = proxy.username or "" + password = proxy.password or "" + token = base64.b64encode(f"{user}:{password}".encode()).decode() + lines.append(f"Proxy-Authorization: Basic {token}") + raw.sendall(("\r\n".join(lines) + "\r\n\r\n").encode("ascii")) + response = _read_headers(raw) + first = response.split(b"\r\n", 1)[0] + if b" 200 " not in first: + raise OSError("proxy CONNECT failed") + context = ssl.create_default_context() + return context.wrap_socket(raw, server_hostname=target_host) + + +def _rewrite_request(data): + header, sep, rest = data.partition(b"\r\n\r\n") + text = header.decode("iso-8859-1") + lines = text.split("\r\n") + if not lines: + raise OSError("empty request") + first = lines[0].split(" ") + method = first[0] if first else "GET" + rewritten = [f"{method} {target_path} HTTP/1.1", f"Host: {target_host}"] + for line in lines[1:]: + lower = line.lower() + if lower.startswith("host:"): + continue + rewritten.append(line) + return ("\r\n".join(rewritten) + "\r\n\r\n").encode("iso-8859-1") + rest + + +def _pipe(left, right): + sockets = [left, right] + while True: + readable, _, _ = select.select(sockets, [], [], 120) + if not readable: + return + for sock in readable: + data = sock.recv(65536) + if not data: + return + (right if sock is left else left).sendall(data) + + +def _handle(client): + global last_activity + upstream = None + try: + first_request = _read_headers(client) + if not first_request: + return + upstream = _connect_remote() + upstream.sendall(_rewrite_request(first_request)) + last_activity = time.monotonic() + _pipe(client, upstream) + finally: + last_activity = time.monotonic() + try: + client.close() + except Exception: + pass + try: + if upstream is not None: + upstream.close() + except Exception: + pass + + +listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) +listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) +listener.bind(("127.0.0.1", 0)) +listener.listen(16) +listener.settimeout(1.0) +print(listener.getsockname()[1], flush=True) + +while True: + if time.monotonic() - last_activity > idle_timeout: + break + try: + client, _addr = listener.accept() + except socket.timeout: + continue + thread = threading.Thread(target=_handle, args=(client,), daemon=True) + thread.start() +""" + + +def _cleanup_browser_use_cdp_tunnels(): + for proc, _url in list(_BROWSER_USE_CDP_TUNNELS.values()): + if proc.poll() is None: + proc.terminate() + + +atexit.register(_cleanup_browser_use_cdp_tunnels) + + +def _start_browser_use_cdp_tunnel(cdp_url): + parsed = urlparse(str(cdp_url or "")) + host = (parsed.hostname or "").lower() + if parsed.scheme != "wss" or not host.endswith(".browser-use.com"): + return cdp_url + + existing = _BROWSER_USE_CDP_TUNNELS.get(cdp_url) + if existing is not None: + proc, local_url = existing + if proc.poll() is None: + return local_url + + try: + proc = subprocess.Popen( + [sys.executable, "-c", _BROWSER_USE_CDP_TUNNEL_SCRIPT, cdp_url], + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + env=os.environ.copy(), + ) + port = proc.stdout.readline().strip() if proc.stdout else "" + if not port.isdigit(): + proc.terminate() + return cdp_url + except Exception: + return cdp_url + + local_url = urlunparse(("ws", f"127.0.0.1:{port}", parsed.path or "/", "", parsed.query, "")) + _BROWSER_USE_CDP_TUNNELS[cdp_url] = (proc, local_url) + return local_url + + +def _install_browser_cdp_tunnel_patch(): + """Route Browser Use CDP sockets through OpenShell's HTTP proxy. + + ``agent-browser`` can use the OpenShell proxy for ordinary HTTP requests, + but its native CDP websocket connector currently dials WSS endpoints + directly. Direct egress is blocked in the sandbox. Browser Use session + creation still goes through the Nous gateway; this tunnel only bridges the + returned short-lived CDP capability URL through the already-enforced proxy. + """ + if not _broker_mode_enabled(): + return False + + try: + module = __import__("tools.browser_tool", fromlist=["_resolve_cdp_override"]) + except Exception: + return False + + if not hasattr(module, "_resolve_cdp_override") or getattr( + module, + _BROWSER_CDP_TUNNEL_PATCH_ATTR, + False, + ): + return False + + original = module._resolve_cdp_override + + def _resolve_cdp_override(cdp_url): + resolved = original(cdp_url) + return _start_browser_use_cdp_tunnel(resolved) + + module._resolve_cdp_override = _resolve_cdp_override + setattr(module, _BROWSER_CDP_TUNNEL_PATCH_ATTR, True) + return True + + +def _reset_browser_tool_provider_cache(): + """Let Hermes re-read Browser Use broker config after cold imports. + + ``tools.browser_tool`` caches the cloud-provider decision for the process + lifetime. Hermes may import it before this plugin has hydrated broker env + from ``.env``, which leaves browser tools stuck in local-CDP mode even + though NemoClaw configured ``browser.cloud_provider=browser-use``. + """ + try: + module = __import__("tools.browser_tool", fromlist=["_get_cloud_provider"]) + except Exception: + return False + + changed = False + for name, value in [ + ("_cached_cloud_provider", None), + ("_cloud_provider_resolved", False), + ]: + if hasattr(module, name): + setattr(module, name, value) + changed = True + changed = _evict_local_browser_tool_sessions(module) or changed + return changed + + +def _is_local_browser_tool_session(session): + if not isinstance(session, dict): + return False + features = session.get("features") + if isinstance(features, dict) and features.get("local"): + return True + return bool(session.get("fallback_from_cloud")) + + +def _evict_local_browser_tool_sessions(module, task_id=None): + active_sessions = getattr(module, "_active_sessions", None) + if not isinstance(active_sessions, dict): + return False + + if task_id is None: + candidate_ids = list(active_sessions.keys()) + else: + candidate_ids = [task_id] if task_id in active_sessions else [] + stale_ids = [ + candidate_id + for candidate_id in candidate_ids + if _is_local_browser_tool_session(active_sessions.get(candidate_id)) + ] + if not stale_ids: + return False + + def _remove(): + for stale_id in stale_ids: + active_sessions.pop(stale_id, None) + last_activity = getattr(module, "_session_last_activity", None) + if isinstance(last_activity, dict): + for stale_id in stale_ids: + last_activity.pop(stale_id, None) + recording_sessions = getattr(module, "_recording_sessions", None) + if hasattr(recording_sessions, "discard"): + for stale_id in stale_ids: + recording_sessions.discard(stale_id) + + lock = getattr(module, "_cleanup_lock", None) + if hasattr(lock, "__enter__") and hasattr(lock, "__exit__"): + with lock: + _remove() + else: + _remove() + return True + + +def _install_browser_session_state_patch(): + """Prevent stale local-CDP fallback sessions from poisoning Browser Use. + + Hermes caches browser sessions by task ID. If it imports or runs browser + tools before NemoClaw's broker config is hydrated, the first browser call + may fall back to local Chromium and cache that local session. Subsequent + calls then reuse local CDP without re-checking Browser Use. In broker mode + local browser egress is not the intended path, so evict only local/fallback + sessions and let Hermes create a fresh Browser Use cloud session. + """ + if not _broker_mode_enabled(): + return False + + try: + module = __import__("tools.browser_tool", fromlist=["_get_session_info"]) + except Exception: + return False + + changed = _evict_local_browser_tool_sessions(module) + if not hasattr(module, "_get_session_info") or getattr( + module, + _BROWSER_SESSION_STATE_PATCH_ATTR, + False, + ): + return changed + + original = module._get_session_info + + def _get_session_info(task_id=None): + _evict_local_browser_tool_sessions(module, task_id or "default") + return original(task_id) + + module._get_session_info = _get_session_info + setattr(module, _BROWSER_SESSION_STATE_PATCH_ATTR, True) + return True + + +def _install_firecrawl_path_patch(): + """Preserve broker path prefixes with firecrawl-py's absolute v2 paths. + + firecrawl-py sends endpoints like ``/v2/search``. Its URL builder uses + urljoin, so an ``api_url`` of ``http://host:11436/firecrawl`` becomes + ``http://host:11436/v2/search`` and bypasses NemoClaw's broker route + prefix. In broker mode, preserve the configured base path while leaving + normal Firecrawl URLs untouched. + """ + if not _broker_mode_enabled(): + return False + + try: + from firecrawl.v2.utils import http_client + except Exception: + return False + + client_cls = getattr(http_client, "HttpClient", None) + if client_cls is None or getattr(client_cls, _FIRECRAWL_PATH_PATCH_ATTR, False): + return False + + original = client_cls._build_url + + def _build_url(self, endpoint): + api_url = getattr(self, "api_url", "") + parsed_base = urlparse(api_url) + parsed_endpoint = urlparse(str(endpoint or "")) + if ( + _broker_mode_enabled() + and parsed_base.scheme + and parsed_base.netloc + and parsed_base.path + and str(endpoint or "").startswith("/") + and not parsed_endpoint.netloc + ): + path = parsed_base.path.rstrip("/") + (parsed_endpoint.path or "/") + return urlunparse( + ( + parsed_base.scheme, + parsed_base.netloc, + path, + "", + parsed_endpoint.query, + "", + ), + ) + return original(self, endpoint) + + client_cls._build_url = _build_url + setattr(client_cls, _FIRECRAWL_PATH_PATCH_ATTR, True) + return True + + +def _install_nous_tool_broker_patch(): + """Patch Hermes managed-tool availability for NemoClaw broker mode. + + Hermes currently gates managed Nous tools on in-sandbox Nous auth state. + NemoClaw deliberately does not write Nous OAuth access or refresh tokens + into the sandbox. OAuth refresh, agent-key minting, and vendor gateway + auth happen on the host instead: + + sandbox tool call -> host.openshell.internal:11436/ + broker token placeholder -> host broker -> Nous access token upstream + + This shim only tells Hermes that externally managed gateway auth is + available when NEMOCLAW_HERMES_TOOL_GATEWAY_BROKER=1. It does not mint, + refresh, or expose OAuth tokens in the sandbox. Long term, this should be + replaced by an upstream Hermes setting for externally managed Nous Tool + Gateway auth. + """ + _load_hermes_dotenv() + if not _broker_mode_enabled(): + return False + + patched = False + + def _enabled(): + return True + + for module_name in [ + "tools.tool_backend_helpers", + "tools.managed_tool_gateway", + "hermes_cli.nous_subscription", + ]: + try: + module = __import__(module_name, fromlist=["managed_nous_tools_enabled"]) + except Exception: + continue + if getattr(module, _BROKER_PATCH_ATTR, False): + patched = True + continue + if hasattr(module, "managed_nous_tools_enabled"): + setattr(module, "managed_nous_tools_enabled", _enabled) + setattr(module, _BROKER_PATCH_ATTR, True) + patched = True + if module_name == "tools.managed_tool_gateway": + setattr(module, "build_vendor_gateway_url", _broker_gateway_url) + setattr(module, "read_nous_access_token", _broker_user_token) + + patched = _install_audio_gateway_preference_patch() or patched + patched = _install_transcription_gateway_patch() or patched + patched = _install_fal_queue_handle_patch() or patched + + managed_gateway = sys.modules.get("tools.managed_tool_gateway") + for module_name in [ + "tools.web_tools", + "tools.tts_tool", + "tools.transcription_tools", + "tools.image_generation_tool", + "tools.browser_providers.browser_use", + "tools.environments.managed_modal", + "tools.terminal_tool", + ]: + patched = _patch_loaded_tool_module(module_name, managed_gateway) or patched + + patched = _install_firecrawl_path_patch() or patched + patched = _install_browser_cdp_tunnel_patch() or patched + patched = _install_browser_session_state_patch() or patched + patched = _reset_browser_tool_provider_cache() or patched + + return patched + def _load_nemoclaw_config(): """Load NemoClaw onboard config from ~/.nemoclaw/config.json.""" @@ -90,6 +887,81 @@ def _get_sandbox_info(): } +def _active_managed_gateway_services(): + """List managed Nous services that have broker URLs configured.""" + services = [] + for service, env_key in _TOOL_GATEWAY_URL_ENV.items(): + if _get_env_value(env_key, ""): + services.append(service) + return services + + +def _should_inject_nemoclaw_context(user_message=None, is_first_turn=False): + """Return whether this turn needs NemoClaw runtime grounding.""" + if is_first_turn: + return True + text = str(user_message or "").lower() + return any(keyword in text for keyword in _NEMOCLAW_CONTEXT_KEYWORDS) + + +def _build_nemoclaw_agent_context(platform=None): + """Build quiet, ephemeral context for Hermes' pre_llm_call hook.""" + info = _get_sandbox_info() + hermes_home = ( + os.getenv("HERMES_HOME") + or _get_env_value("HERMES_HOME", "") + or "/sandbox/.hermes-data" + ) + services = _active_managed_gateway_services() + service_text = ", ".join(services) if services else "none detected" + broker_state = "enabled" if _broker_mode_enabled() else "not enabled" + platform_text = str(platform or "").strip() + platform_line = ( + f"- Current Hermes messaging platform: {platform_text}. Messaging adapters " + "run in the parent Hermes gateway sandbox; child tool-execution containers " + "will not show their host/gateway config." + if platform_text + else "- Messaging adapters run in the parent Hermes gateway sandbox; child " + "tool-execution containers will not show their host/gateway config." + ) + + return "\n".join( + [ + "NemoClaw runtime context:", + "- You are Hermes Agent running in a NemoClaw-managed OpenShell sandbox, " + "not a host-only assistant.", + "- Some tools, especially managed code/terminal tools, execute in child " + "tool sandboxes such as Modal. Seeing /__modal, MODAL_SANDBOX_ID, a " + "missing hermes binary, or missing ~/.hermes-data inside a tool shell " + "means that shell is a child tool sandbox, not proof that Hermes is " + "running on the host.", + f"- Parent Hermes sandbox config lives under {hermes_home} and " + "/sandbox/.hermes when available. Use nemoclaw_status or " + "nemoclaw_info for NemoClaw environment questions.", + f"- NemoClaw provider state: model={info['model']}, " + f"provider={info['provider']}, endpoint={info['base_url']}, " + f"gateway={info['gateway']}.", + "- NemoClaw tools available: nemoclaw_status, nemoclaw_info, " + "nemoclaw_reload_skills, transcribe_audio.", + f"- Managed Nous tool broker: {broker_state}; configured services: " + f"{service_text}. Raw Nous OAuth tokens are host-managed by NemoClaw " + "and should not be expected inside the sandbox.", + platform_line, + ], + ) + + +def _pre_llm_call(**kwargs): + """Inject non-visible NemoClaw runtime context into relevant Hermes turns.""" + if not _should_inject_nemoclaw_context( + user_message=kwargs.get("user_message"), + is_first_turn=bool(kwargs.get("is_first_turn")), + ): + return None + _install_nous_tool_broker_patch() + return {"context": _build_nemoclaw_agent_context(platform=kwargs.get("platform"))} + + def _handle_status(tool_input=None, context=None, **_kwargs): """Handle the nemoclaw_status tool call.""" info = _get_sandbox_info() @@ -111,6 +983,36 @@ def _handle_info(tool_input=None, context=None, **_kwargs): return json.dumps(_get_sandbox_info(), indent=2) +def _handle_transcribe_audio(tool_input=None, context=None, **_kwargs): + """Transcribe an audio file from the parent Hermes sandbox.""" + _install_nous_tool_broker_patch() + args = tool_input if isinstance(tool_input, dict) else {} + file_path = str(args.get("file_path") or "").strip() + model = args.get("model") + + if not file_path: + return json.dumps( + { + "success": False, + "transcript": "", + "error": "file_path is required", + }, + ) + + try: + from tools.transcription_tools import transcribe_audio + + result = transcribe_audio(file_path, model=str(model).strip() if model else None) + except Exception as exc: + result = { + "success": False, + "transcript": "", + "error": f"Transcription failed: {exc}", + } + + return json.dumps(result, indent=2, ensure_ascii=False) + + def _reload_skills(): """Clear the Hermes skill slash-command cache and re-scan skill directories. @@ -156,6 +1058,7 @@ def _handle_reload_skills(tool_input=None, context=None, **_kwargs): def register(ctx): """Register NemoClaw tools and hooks with Hermes.""" + _install_nous_tool_broker_patch() # Register status tool ctx.register_tool( @@ -192,6 +1095,38 @@ def register(ctx): description="NemoClaw sandbox info (JSON)", ) + ctx.register_tool( + name="transcribe_audio", + toolset="audio", + schema={ + "type": "function", + "function": { + "name": "transcribe_audio", + "description": ( + "Transcribe an audio file that already exists in the Hermes " + "sandbox. In NemoClaw broker mode this uses the managed " + "OpenAI-audio gateway instead of direct OpenAI credentials." + ), + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to an audio file inside the Hermes sandbox.", + }, + "model": { + "type": "string", + "description": "Optional transcription model override.", + }, + }, + "required": ["file_path"], + }, + }, + }, + handler=_handle_transcribe_audio, + description="Transcribe audio through the configured Hermes STT backend", + ) + # Register skill reload tool ctx.register_tool( name="nemoclaw_reload_skills", @@ -212,28 +1147,16 @@ def register(ctx): description="Reload skills from disk without gateway restart", ) - # Startup banner on session start + # Ground the model quietly through Hermes' context hook. This replaces the + # old visible startup banner without reintroducing TUI interrupt noise. + ctx.register_hook("pre_llm_call", _pre_llm_call) + + # Refresh skills silently on session start. Earlier versions injected a + # system banner here, but that can interrupt the user's first prompt in the + # Hermes TUI because plugin-injected messages travel through Hermes's + # interrupt queue. Keep startup native and expose status through tools. def _on_session_start(**kwargs): - # Refresh skill cache so skills installed since last session are - # immediately available as slash commands. + _install_nous_tool_broker_patch() _reload_skills() - info = _get_sandbox_info() - banner = ( - "\n" - " \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n" - " \u2502 NemoClaw registered (Hermes) \u2502\n" - " \u2502 \u2502\n" - f" \u2502 Model: {info['model']:<40}\u2502\n" - f" \u2502 Provider: {info['provider']:<40}\u2502\n" - f" \u2502 Gateway: {info['gateway']:<40}\u2502\n" - " \u2502 Tools: nemoclaw_status, nemoclaw_info, \u2502\n" - " \u2502 nemoclaw_reload_skills \u2502\n" - " \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n" - ) - try: - ctx.inject_message(banner, role="system") - except Exception: - print(banner) - ctx.register_hook("on_session_start", _on_session_start) diff --git a/agents/hermes/policy-additions.yaml b/agents/hermes/policy-additions.yaml index a47dbc033bf..3426efd39ee 100644 --- a/agents/hermes/policy-additions.yaml +++ b/agents/hermes/policy-additions.yaml @@ -38,6 +38,24 @@ process: run_as_group: sandbox network_policies: + managed_inference: + name: managed_inference + endpoints: + - host: inference.local + port: 443 + protocol: rest + enforcement: enforce + 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/hermes } + - { path: /usr/bin/python3.11 } + - { path: /opt/hermes/.venv/bin/python } + nvidia: name: nvidia endpoints: @@ -80,7 +98,10 @@ network_policies: - { path: /usr/bin/git } - { path: /opt/hermes/.venv/bin/python } - # ── Nous Research — Hermes auth, updates, portal ────────────── + # ── Nous Research — public metadata and agent updates ───────── + # Nous Portal OAuth, managed inference, and managed tool gateway auth are + # host-managed by NemoClaw/OpenShell. The sandbox should not reach the Portal + # or Nous vendor gateway hosts directly. nous_research: name: nous_research endpoints: @@ -98,62 +119,6 @@ network_policies: rules: - allow: { method: GET, path: "/**" } - allow: { method: POST, path: "/**" } - - host: inference-api.nousresearch.com - port: 443 - protocol: rest - enforcement: enforce - rules: - - allow: { method: GET, path: "/**" } - - allow: { method: POST, path: "/**" } - - host: portal.nousresearch.com - port: 443 - protocol: rest - enforcement: enforce - rules: - - allow: { method: GET, path: "/**" } - - allow: { method: POST, path: "/**" } - - host: browser-use-gateway.nousresearch.com - port: 443 - protocol: rest - enforcement: enforce - rules: - - allow: { method: GET, path: "/**" } - - allow: { method: POST, path: "/**" } - - host: modal-gateway.nousresearch.com - port: 443 - protocol: rest - enforcement: enforce - rules: - - allow: { method: GET, path: "/**" } - - allow: { method: POST, path: "/**" } - - host: openai-audio-gateway.nousresearch.com - port: 443 - protocol: rest - enforcement: enforce - rules: - - allow: { method: GET, path: "/**" } - - allow: { method: POST, path: "/**" } - - host: fal-queue-gateway.nousresearch.com - port: 443 - protocol: rest - enforcement: enforce - rules: - - allow: { method: GET, path: "/**" } - - allow: { method: POST, path: "/**" } - - host: firecrawl-gateway.nousresearch.com - port: 443 - protocol: rest - enforcement: enforce - rules: - - allow: { method: GET, path: "/**" } - - allow: { method: POST, path: "/**" } - - host: tool-gateway.nousresearch.com - port: 443 - protocol: rest - enforcement: enforce - rules: - - allow: { method: GET, path: "/**" } - - allow: { method: POST, path: "/**" } binaries: - { path: /usr/local/bin/hermes } - { path: /usr/bin/python3.11 } diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 809199bbdd8..8fe2231b2bf 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -1012,6 +1012,9 @@ Defaults are unchanged when no variable is set. If `NEMOCLAW_DASHBOARD_PORT` or the port from `CHAT_UI_URL` is already occupied by another sandbox, onboarding scans `18789` through `18799` and uses the next free dashboard port. Pass `--control-ui-port ` to require a specific port. +Hermes Provider sandboxes that use Nous Portal OAuth and selected managed Nous tools also start a Hermes-owned host broker on port `11436`. +That broker is not part of the default OpenClaw service set; it is started only for Hermes managed-tool gateway sessions. + ### Onboarding Configuration These variables let you tune onboarding without editing the Dockerfile or passing repeated flags. @@ -1023,6 +1026,8 @@ Set them before running `nemoclaw onboard`. | `NEMOCLAW_HERMES_AUTH_METHOD` | `oauth` | Selects Hermes Provider authentication in non-interactive onboarding. Valid values: `oauth`, `nous-portal-oauth`, `api-key`, `nous-api-key`. | | `NEMOCLAW_HERMES_AUTH` | same as `NEMOCLAW_HERMES_AUTH_METHOD` | Back-compatible alias for Hermes Provider authentication selection. | | `NEMOCLAW_NOUS_AUTH_METHOD` | same as `NEMOCLAW_HERMES_AUTH_METHOD` | Nous-specific alias for Hermes Provider authentication selection. | +| `NEMOCLAW_HERMES_TOOL_GATEWAYS` | comma-separated managed-tool presets | Selects Hermes managed Nous tools in non-interactive OAuth onboarding. Valid values: `nous-web`, `nous-image`, `nous-audio`, `nous-browser`, `nous-code`. These require Nous Portal OAuth/subscription; API-key mode remains inference-only. | +| `NEMOCLAW_HERMES_TOOL_GATEWAY_PRESETS` | same as `NEMOCLAW_HERMES_TOOL_GATEWAYS` | Back-compatible alias for selecting Hermes managed-tool presets. | | `NEMOCLAW_ENDPOINT_URL` | URL | Custom OpenAI-compatible endpoint URL. Used together with `NEMOCLAW_PROVIDER=compatible`. | | `NEMOCLAW_PREFERRED_API` | `completions` (currently the only honored value) | Forces the validation probe to use the `/v1/chat/completions` API path instead of the newer `/v1/responses` API. | | `NEMOCLAW_INFERENCE_INPUTS` | comma-separated list of `text` and/or `image` | Declares model input modalities for vision-capable models. Validated strictly; unknown tokens are ignored. | diff --git a/nemoclaw-blueprint/policies/presets/nous-audio.yaml b/nemoclaw-blueprint/policies/presets/nous-audio.yaml new file mode 100644 index 00000000000..20700db56ca --- /dev/null +++ b/nemoclaw-blueprint/policies/presets/nous-audio.yaml @@ -0,0 +1,42 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +preset: + name: nous-audio + description: "Nous Portal managed audio generation and transcription gateway" + +network_policies: + nous_audio: + name: nous_audio + endpoints: + - host: host.openshell.internal + port: 11436 + protocol: rest + enforcement: enforce + # host.openshell.internal resolves to the Docker host gateway, which + # is intentionally private. Keep the L7 broker path allowlist, and + # explicitly allow only RFC1918 host-gateway resolutions for this + # fixed hostname/port so OpenShell SSRF protection still blocks other + # private destinations. + allowed_ips: + - 10.0.0.0/8 + - 172.16.0.0/12 + - 192.168.0.0/16 + rules: + - allow: { method: GET, path: "/openai-audio" } + - allow: { method: GET, path: "/openai-audio/**" } + - allow: { method: POST, path: "/openai-audio" } + - allow: { method: POST, path: "/openai-audio/**" } + - allow: { method: PUT, path: "/openai-audio" } + - allow: { method: PUT, path: "/openai-audio/**" } + - allow: { method: PATCH, path: "/openai-audio" } + - allow: { method: PATCH, path: "/openai-audio/**" } + - allow: { method: DELETE, path: "/openai-audio" } + - allow: { method: DELETE, path: "/openai-audio/**" } + binaries: + - { path: /usr/local/bin/hermes } + - { path: /usr/bin/python3 } + - { path: /usr/bin/python3.11 } + - { path: /opt/hermes/.venv/bin/python } + - { path: /usr/bin/curl } + - { path: /usr/local/bin/curl } diff --git a/nemoclaw-blueprint/policies/presets/nous-browser.yaml b/nemoclaw-blueprint/policies/presets/nous-browser.yaml new file mode 100644 index 00000000000..66c21ec1a3e --- /dev/null +++ b/nemoclaw-blueprint/policies/presets/nous-browser.yaml @@ -0,0 +1,128 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +preset: + name: nous-browser + description: "Nous Portal managed browser automation gateway" + +network_policies: + nous_browser: + name: nous_browser + endpoints: + - host: host.openshell.internal + port: 11436 + protocol: rest + enforcement: enforce + # host.openshell.internal resolves to the Docker host gateway, which + # is intentionally private. Keep the L7 broker path allowlist, and + # explicitly allow only RFC1918 host-gateway resolutions for this + # fixed hostname/port so OpenShell SSRF protection still blocks other + # private destinations. + allowed_ips: + - 10.0.0.0/8 + - 172.16.0.0/12 + - 192.168.0.0/16 + rules: + - allow: { method: GET, path: "/browser-use" } + - allow: { method: GET, path: "/browser-use/**" } + - allow: { method: POST, path: "/browser-use" } + - allow: { method: POST, path: "/browser-use/**" } + - allow: { method: PUT, path: "/browser-use" } + - allow: { method: PUT, path: "/browser-use/**" } + - allow: { method: PATCH, path: "/browser-use" } + - allow: { method: PATCH, path: "/browser-use/**" } + - allow: { method: DELETE, path: "/browser-use" } + - allow: { method: DELETE, path: "/browser-use/**" } + # Browser Use returns a short-lived CDP capability URL after the + # Nous-authenticated session is created through the host broker. The CDP + # websocket leg carries no Nous OAuth token, but agent-browser must be + # able to connect to it to drive the managed cloud browser. + - host: "*.cdp1.browser-use.com" + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: { method: GET, path: "/json/version" } + - allow: { method: GET, path: "/devtools/**" } + - host: "*.cdp2.browser-use.com" + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: { method: GET, path: "/json/version" } + - allow: { method: GET, path: "/devtools/**" } + - host: "*.cdp3.browser-use.com" + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: { method: GET, path: "/json/version" } + - allow: { method: GET, path: "/devtools/**" } + - host: "*.cdp4.browser-use.com" + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: { method: GET, path: "/json/version" } + - allow: { method: GET, path: "/devtools/**" } + - host: "*.cdp5.browser-use.com" + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: { method: GET, path: "/json/version" } + - allow: { method: GET, path: "/devtools/**" } + - host: "*.cdp6.browser-use.com" + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: { method: GET, path: "/json/version" } + - allow: { method: GET, path: "/devtools/**" } + - host: "*.cdp7.browser-use.com" + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: { method: GET, path: "/json/version" } + - allow: { method: GET, path: "/devtools/**" } + - host: "*.cdp8.browser-use.com" + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: { method: GET, path: "/json/version" } + - allow: { method: GET, path: "/devtools/**" } + - host: "*.cdp9.browser-use.com" + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: { method: GET, path: "/json/version" } + - allow: { method: GET, path: "/devtools/**" } + - host: "*.cdp10.browser-use.com" + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: { method: GET, path: "/json/version" } + - allow: { method: GET, path: "/devtools/**" } + binaries: + - { path: /usr/local/bin/hermes } + - { path: /usr/local/bin/node* } + - { path: /usr/local/bin/npx* } + - { path: /usr/bin/node* } + - { path: /usr/bin/npx* } + - { path: /opt/hermes/node_modules/.bin/agent-browser* } + - { path: /opt/hermes/node_modules/.bin/playwright* } + - { path: /sandbox/.hermes-data/node/bin/node* } + - { path: /sandbox/.hermes-data/node/bin/npx* } + - { path: /sandbox/.hermes-data/node/bin/agent-browser* } + - { path: /sandbox/.hermes/node/bin/node* } + - { path: /sandbox/.hermes/node/bin/npx* } + - { path: /sandbox/.hermes/node/bin/agent-browser* } + - { path: /usr/bin/python3 } + - { path: /usr/bin/python3.11 } + - { path: /opt/hermes/.venv/bin/python } + - { path: /usr/bin/curl } + - { path: /usr/local/bin/curl } diff --git a/nemoclaw-blueprint/policies/presets/nous-code.yaml b/nemoclaw-blueprint/policies/presets/nous-code.yaml new file mode 100644 index 00000000000..b6573581350 --- /dev/null +++ b/nemoclaw-blueprint/policies/presets/nous-code.yaml @@ -0,0 +1,42 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +preset: + name: nous-code + description: "Nous Portal managed sandboxed code execution gateway" + +network_policies: + nous_code: + name: nous_code + endpoints: + - host: host.openshell.internal + port: 11436 + protocol: rest + enforcement: enforce + # host.openshell.internal resolves to the Docker host gateway, which + # is intentionally private. Keep the L7 broker path allowlist, and + # explicitly allow only RFC1918 host-gateway resolutions for this + # fixed hostname/port so OpenShell SSRF protection still blocks other + # private destinations. + allowed_ips: + - 10.0.0.0/8 + - 172.16.0.0/12 + - 192.168.0.0/16 + rules: + - allow: { method: GET, path: "/modal" } + - allow: { method: GET, path: "/modal/**" } + - allow: { method: POST, path: "/modal" } + - allow: { method: POST, path: "/modal/**" } + - allow: { method: PUT, path: "/modal" } + - allow: { method: PUT, path: "/modal/**" } + - allow: { method: PATCH, path: "/modal" } + - allow: { method: PATCH, path: "/modal/**" } + - allow: { method: DELETE, path: "/modal" } + - allow: { method: DELETE, path: "/modal/**" } + binaries: + - { path: /usr/local/bin/hermes } + - { path: /usr/bin/python3 } + - { path: /usr/bin/python3.11 } + - { path: /opt/hermes/.venv/bin/python } + - { path: /usr/bin/curl } + - { path: /usr/local/bin/curl } diff --git a/nemoclaw-blueprint/policies/presets/nous-image.yaml b/nemoclaw-blueprint/policies/presets/nous-image.yaml new file mode 100644 index 00000000000..811246df8e1 --- /dev/null +++ b/nemoclaw-blueprint/policies/presets/nous-image.yaml @@ -0,0 +1,42 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +preset: + name: nous-image + description: "Nous Portal managed image generation gateway" + +network_policies: + nous_image: + name: nous_image + endpoints: + - host: host.openshell.internal + port: 11436 + protocol: rest + enforcement: enforce + # host.openshell.internal resolves to the Docker host gateway, which + # is intentionally private. Keep the L7 broker path allowlist, and + # explicitly allow only RFC1918 host-gateway resolutions for this + # fixed hostname/port so OpenShell SSRF protection still blocks other + # private destinations. + allowed_ips: + - 10.0.0.0/8 + - 172.16.0.0/12 + - 192.168.0.0/16 + rules: + - allow: { method: GET, path: "/fal-queue" } + - allow: { method: GET, path: "/fal-queue/**" } + - allow: { method: POST, path: "/fal-queue" } + - allow: { method: POST, path: "/fal-queue/**" } + - allow: { method: PUT, path: "/fal-queue" } + - allow: { method: PUT, path: "/fal-queue/**" } + - allow: { method: PATCH, path: "/fal-queue" } + - allow: { method: PATCH, path: "/fal-queue/**" } + - allow: { method: DELETE, path: "/fal-queue" } + - allow: { method: DELETE, path: "/fal-queue/**" } + binaries: + - { path: /usr/local/bin/hermes } + - { path: /usr/bin/python3 } + - { path: /usr/bin/python3.11 } + - { path: /opt/hermes/.venv/bin/python } + - { path: /usr/bin/curl } + - { path: /usr/local/bin/curl } diff --git a/nemoclaw-blueprint/policies/presets/nous-web.yaml b/nemoclaw-blueprint/policies/presets/nous-web.yaml new file mode 100644 index 00000000000..f0d065842a0 --- /dev/null +++ b/nemoclaw-blueprint/policies/presets/nous-web.yaml @@ -0,0 +1,42 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +preset: + name: nous-web + description: "Nous Portal managed web search and crawl gateway" + +network_policies: + nous_web: + name: nous_web + endpoints: + - host: host.openshell.internal + port: 11436 + protocol: rest + enforcement: enforce + # host.openshell.internal resolves to the Docker host gateway, which + # is intentionally private. Keep the L7 broker path allowlist, and + # explicitly allow only RFC1918 host-gateway resolutions for this + # fixed hostname/port so OpenShell SSRF protection still blocks other + # private destinations. + allowed_ips: + - 10.0.0.0/8 + - 172.16.0.0/12 + - 192.168.0.0/16 + rules: + - allow: { method: GET, path: "/firecrawl" } + - allow: { method: GET, path: "/firecrawl/**" } + - allow: { method: POST, path: "/firecrawl" } + - allow: { method: POST, path: "/firecrawl/**" } + - allow: { method: PUT, path: "/firecrawl" } + - allow: { method: PUT, path: "/firecrawl/**" } + - allow: { method: PATCH, path: "/firecrawl" } + - allow: { method: PATCH, path: "/firecrawl/**" } + - allow: { method: DELETE, path: "/firecrawl" } + - allow: { method: DELETE, path: "/firecrawl/**" } + binaries: + - { path: /usr/local/bin/hermes } + - { path: /usr/bin/python3 } + - { path: /usr/bin/python3.11 } + - { path: /opt/hermes/.venv/bin/python } + - { path: /usr/bin/curl } + - { path: /usr/local/bin/curl } diff --git a/src/lib/actions/sandbox/connect.ts b/src/lib/actions/sandbox/connect.ts index 193047d5116..47d780612a5 100644 --- a/src/lib/actions/sandbox/connect.ts +++ b/src/lib/actions/sandbox/connect.ts @@ -227,6 +227,23 @@ function ensureSandboxInferenceRoute( return sb; } +function maybeEnsureHermesToolGatewayBroker(sb: SandboxEntry | null): void { + if ( + !sb || + sb.agent !== "hermes" || + !Array.isArray(sb.hermesToolGateways) || + sb.hermesToolGateways.length === 0 + ) { + return; + } + try { + const hermesToolGatewayBroker = require("../../hermes-tool-gateway-broker"); + hermesToolGatewayBroker.ensureHermesToolGatewayBrokerForSandboxEntry(sb); + } catch { + /* non-fatal — managed-tool calls will surface broker guidance if needed */ + } +} + function exitWithSpawnResult(result: SpawnLikeResult): void { if (result.status !== null) { process.exit(result.status); @@ -288,6 +305,7 @@ export async function connectSandbox( // cluster-wide inference.local route may still point at the *other* // provider. Re-set it to match this sandbox's persisted config. const sb = ensureSandboxInferenceRoute(sandboxName); + maybeEnsureHermesToolGatewayBroker(sb); const rawTimeout = process.env.NEMOCLAW_CONNECT_TIMEOUT; let timeout = 120; diff --git a/src/lib/actions/sandbox/rebuild.ts b/src/lib/actions/sandbox/rebuild.ts index 79ac00acfb7..f340fb5283b 100644 --- a/src/lib/actions/sandbox/rebuild.ts +++ b/src/lib/actions/sandbox/rebuild.ts @@ -482,6 +482,26 @@ export async function rebuildSandbox( sessionMatchesSandbox ? sessionBefore?.messagingChannelConfig ?? null : null; const rebuildMessagingChannelConfig = sb.messagingChannelConfig ?? sessionMessagingChannelConfig ?? null; + const rebuildsHermesSandbox = rebuildAgent === "hermes"; + let registryHermesToolGateways: string[] | null = null; + if (rebuildsHermesSandbox && Array.isArray(sb.hermesToolGateways)) { + registryHermesToolGateways = sb.hermesToolGateways.filter( + (value: unknown): value is string => typeof value === "string", + ); + } + const sessionHermesToolGateways = + rebuildsHermesSandbox && + sessionMatchesSandbox && Array.isArray(sessionBefore?.hermesToolGateways) + ? sessionBefore.hermesToolGateways.filter( + (value: unknown): value is string => typeof value === "string", + ) + : null; + const rebuildHermesToolGateways = rebuildsHermesSandbox + ? registryHermesToolGateways ?? sessionHermesToolGateways ?? [] + : []; + const hasRebuildHermesToolGateways = + rebuildsHermesSandbox && + (registryHermesToolGateways !== null || sessionHermesToolGateways !== null); const hasRebuildMessagingChannels = registryMessagingChannels !== null || sessionMessagingChannels !== null; log( @@ -499,6 +519,7 @@ export async function rebuildSandbox( s.agent = rebuildAgent; s.messagingChannels = rebuildMessagingChannels; s.messagingChannelConfig = rebuildMessagingChannelConfig; + s.hermesToolGateways = rebuildsHermesSandbox ? rebuildHermesToolGateways : []; // Persist inference selection from the about-to-be-removed registry entry // so onboard --resume can recreate with the same provider/model in // non-interactive mode. Without this the registry is gone by the time @@ -624,6 +645,9 @@ export async function rebuildSandbox( const preservedRegistryFields = { ...(hasRebuildMessagingChannels ? { messagingChannels: [...rebuildMessagingChannels] } : {}), + ...(hasRebuildHermesToolGateways + ? { hermesToolGateways: [...rebuildHermesToolGateways] } + : {}), ...(Array.isArray(sb.disabledChannels) && sb.disabledChannels.length > 0 ? { disabledChannels: [...sb.disabledChannels] } : {}), diff --git a/src/lib/actions/sandbox/status.ts b/src/lib/actions/sandbox/status.ts index ef907317d10..8a716cb8d3c 100644 --- a/src/lib/actions/sandbox/status.ts +++ b/src/lib/actions/sandbox/status.ts @@ -55,9 +55,27 @@ export function getSandboxStatusInferenceHealth( }); } +function maybeEnsureHermesToolGatewayBroker(sb: registry.SandboxEntry | null): void { + if ( + !sb || + sb.agent !== "hermes" || + !Array.isArray(sb.hermesToolGateways) || + sb.hermesToolGateways.length === 0 + ) { + return; + } + try { + const hermesToolGatewayBroker = require("../../hermes-tool-gateway-broker"); + hermesToolGatewayBroker.ensureHermesToolGatewayBrokerForSandboxEntry(sb, { quiet: true }); + } catch { + /* non-fatal — status should still show sandbox diagnostics */ + } +} + // eslint-disable-next-line complexity export async function showSandboxStatus(sandboxName: string): Promise { const sb = registry.getSandbox(sandboxName); + maybeEnsureHermesToolGatewayBroker(sb); // #2666: never let an unexpected throw from the gateway probe (e.g. openshell // hanging when its container is stopped and the published port is held by a // foreign listener) suppress the sandbox header. The downstream switch diff --git a/src/lib/hermes-provider-auth.test.ts b/src/lib/hermes-provider-auth.test.ts index 62ceea134ab..005e9e6cfa9 100644 --- a/src/lib/hermes-provider-auth.test.ts +++ b/src/lib/hermes-provider-auth.test.ts @@ -17,6 +17,14 @@ const DIST_AUTH = path.join( "lib", "hermes-provider-auth.js", ); +const DIST_BROKER = path.join( + import.meta.dirname, + "..", + "..", + "dist", + "lib", + "hermes-tool-gateway-broker.js", +); function clearDistModule(modulePath: string): void { try { @@ -31,8 +39,17 @@ function loadAuth(): Record { return require(DIST_AUTH); } +function loadAuthWithBrokerStub(brokerStub: Record): Record { + clearDistModule(DIST_AUTH); + clearDistModule(DIST_BROKER); + const broker = require(DIST_BROKER); + Object.assign(broker, brokerStub); + return require(DIST_AUTH); +} + afterEach(() => { clearDistModule(DIST_AUTH); + clearDistModule(DIST_BROKER); }); describe("Hermes provider OpenShell credential handoff", () => { @@ -147,4 +164,81 @@ describe("Hermes provider OpenShell credential handoff", () => { fs.rmSync(tmp, { recursive: true, force: true }); } }); + + it("registers a separate managed-tool refresh provider without writing raw OAuth state", async () => { + const originalHome = process.env.HOME; + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-tool-oauth-")); + try { + process.env.HOME = tmp; + const brokerCalls: Array<{ sandboxName?: string; refreshToken?: string }> = []; + const auth = loadAuthWithBrokerStub({ + registerHermesToolGatewayRefreshProvider: ( + sandboxName: string, + refreshToken: string, + ) => { + brokerCalls.push({ sandboxName, refreshToken }); + return `${sandboxName}-hermes-tool-gateway`; + }, + ensureHermesToolGatewayBroker: () => true, + }); + const providerCalls: Array<{ args: string[]; env?: Record }> = []; + const state = await auth.ensureHermesProviderOAuthCredentials("my-assistant", { + allowInteractiveLogin: true, + fetch: (async (url, init) => { + if (String(url).endsWith("/api/oauth/device/code")) { + return new Response( + JSON.stringify({ + device_code: "device-1", + user_code: "USER-1", + verification_uri: "https://portal.example/verify", + expires_in: 900, + interval: 1, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + if (String(url).endsWith("/api/oauth/token")) { + return new Response( + JSON.stringify({ + access_token: "access-3", + refresh_token: "refresh-3", + expires_in: 900, + token_type: "Bearer", + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + const headers = new Headers(init?.headers); + expect(headers.get("authorization")).toBe("Bearer access-3"); + return new Response( + JSON.stringify({ + api_key: "agent-key-3", + key_id: "agent-key-id", + expires_in: 1800, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + }) as typeof fetch, + log: () => {}, + noBrowser: true, + runOpenshell: (args: string[], opts: { env?: Record } = {}) => { + providerCalls.push({ args, env: opts.env }); + if (args[0] === "provider" && args[1] === "get") { + return { status: 1, stdout: "", stderr: "" }; + } + return { status: 0, stdout: "", stderr: "" }; + }, + toolGatewayPresets: ["nous-web", "nous-audio"], + }); + + expect(state.auth_method).toBe("oauth"); + expect(providerCalls.some((call) => call.env?.OPENAI_API_KEY === "agent-key-3")).toBe(true); + expect(brokerCalls).toEqual([{ sandboxName: "my-assistant", refreshToken: "refresh-3" }]); + expect(fs.existsSync(path.join(tmp, ".nemoclaw", "hermes-oauth"))).toBe(false); + } finally { + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); }); diff --git a/src/lib/hermes-provider-auth.ts b/src/lib/hermes-provider-auth.ts index 5c356d8b247..c9a3405c730 100644 --- a/src/lib/hermes-provider-auth.ts +++ b/src/lib/hermes-provider-auth.ts @@ -25,6 +25,19 @@ const onboardProviders = require("./onboard/providers") as { ) => { ok: boolean; status?: number; message?: string }; }; +type HermesToolGatewayBroker = { + registerHermesToolGatewayRefreshProvider: ( + sandboxName: string, + refreshToken: string, + runOpenshell: RunOpenshell, + ) => string; + ensureHermesToolGatewayBroker: () => boolean; +}; + +function getHermesToolGatewayBroker(): HermesToolGatewayBroker { + return require("./hermes-tool-gateway-broker") as HermesToolGatewayBroker; +} + export const HERMES_PROVIDER_NAME = "hermes-provider"; export const HERMES_INFERENCE_CREDENTIAL_ENV = "OPENAI_API_KEY"; export const HERMES_NOUS_API_KEY_CREDENTIAL_ENV = "NOUS_API_KEY"; @@ -105,6 +118,7 @@ export async function ensureHermesProviderOAuthCredentials( fetch = undefined, noBrowser = false, baseUrl = oauth.DEFAULT_INFERENCE_BASE_URL, + toolGatewayPresets = [], }: { allowInteractiveLogin?: boolean; runOpenshell?: RunOpenshell | null; @@ -112,6 +126,7 @@ export async function ensureHermesProviderOAuthCredentials( fetch?: typeof globalThis.fetch; noBrowser?: boolean; baseUrl?: string; + toolGatewayPresets?: string[]; } = {}, ): Promise { if (!runOpenshell) { @@ -133,6 +148,17 @@ export async function ensureHermesProviderOAuthCredentials( HERMES_INFERENCE_CREDENTIAL_ENV, inferenceBaseUrl, ); + if (Array.isArray(toolGatewayPresets) && toolGatewayPresets.length > 0) { + const hermesToolGateway = getHermesToolGatewayBroker(); + hermesToolGateway.registerHermesToolGatewayRefreshProvider( + _sandboxName, + tokens.refresh_token, + runOpenshell, + ); + if (!hermesToolGateway.ensureHermesToolGatewayBroker()) { + throw new Error("Hermes managed-tool gateway broker did not become ready"); + } + } return { auth_method: "oauth", provider: HERMES_PROVIDER_NAME, diff --git a/src/lib/hermes-tool-gateway-broker.ts b/src/lib/hermes-tool-gateway-broker.ts new file mode 100644 index 00000000000..b99ea2730d8 --- /dev/null +++ b/src/lib/hermes-tool-gateway-broker.ts @@ -0,0 +1,301 @@ +// @ts-nocheck +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// Thin lifecycle glue for the Hermes managed-tool host broker. + +const crypto = require("crypto"); +const fs = require("fs"); +const path = require("path"); +const { spawn } = require("child_process"); + +const { ROOT, run, runCapture, validateName } = require("./runner"); +const { buildSubprocessEnv } = require("./subprocess-env"); +const { getCredsDir } = require("./credentials/store"); +const oauth = require("./oauth-device-code"); +const onboardProviders = require("./onboard/providers"); + +const HERMES_TOOL_GATEWAY_REFRESH_CREDENTIAL_ENV = + "NEMOCLAW_HERMES_TOOL_GATEWAY_REFRESH_TOKEN"; +const HERMES_TOOL_GATEWAY_PORT = 11436; +const HERMES_TOOL_GATEWAY_STATE_DIR = path.join(getCredsDir(), "hermes-tool-gateway"); +const HERMES_TOOL_GATEWAY_PID_PATH = path.join( + getCredsDir(), + "hermes-tool-gateway-broker.pid", +); +const HERMES_TOOL_GATEWAY_HASH_PATH = path.join( + getCredsDir(), + "hermes-tool-gateway-broker.hash", +); +const HERMES_TOOL_GATEWAY_SCRIPT = path.join( + ROOT, + "agents", + "hermes", + "host", + "tool-gateway-broker.js", +); +const HERMES_TOOL_GATEWAY_MATRIX_PATH = path.join( + ROOT, + "agents", + "hermes", + "host", + "managed-tool-gateway-matrix.json", +); + +let brokerStartedThisRun = false; + +function sleep(ms) { + const lock = new Int32Array(new SharedArrayBuffer(4)); + Atomics.wait(lock, 0, 0, ms); +} + +function ensurePrivateDir(dir) { + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + fs.chmodSync(dir, 0o700); +} + +function hashRefreshToken(refreshToken) { + return crypto.createHash("sha256").update(String(refreshToken || "")).digest("hex"); +} + +function getHermesToolGatewayProviderName(sandboxName) { + return `${validateName(sandboxName, "sandbox name")}-hermes-tool-gateway`; +} + +function getHermesToolGatewayStatePath(sandboxName) { + ensurePrivateDir(HERMES_TOOL_GATEWAY_STATE_DIR); + return path.join( + HERMES_TOOL_GATEWAY_STATE_DIR, + `${validateName(sandboxName, "sandbox name")}.json`, + ); +} + +function atomicWriteJson(file, value) { + ensurePrivateDir(path.dirname(file)); + const tmp = path.join( + path.dirname(file), + `.${path.basename(file)}.${process.pid}.${Date.now()}.${Math.random() + .toString(36) + .slice(2)}.tmp`, + ); + fs.writeFileSync(tmp, JSON.stringify(value, null, 2) + "\n", { mode: 0o600 }); + fs.chmodSync(tmp, 0o600); + fs.renameSync(tmp, file); + fs.chmodSync(file, 0o600); +} + +function persistHermesToolGatewayProviderState(sandboxName, refreshToken) { + const file = getHermesToolGatewayStatePath(sandboxName); + atomicWriteJson(file, { + version: 1, + sandbox: validateName(sandboxName, "sandbox name"), + provider_name: getHermesToolGatewayProviderName(sandboxName), + credential_env: HERMES_TOOL_GATEWAY_REFRESH_CREDENTIAL_ENV, + refresh_token_sha256: hashRefreshToken(refreshToken), + client_id: oauth.DEFAULT_CLIENT_ID, + portal_base_url: oauth.DEFAULT_PORTAL_BASE_URL, + updated_at: new Date().toISOString(), + }); + return file; +} + +function registerHermesToolGatewayRefreshProvider(sandboxName, refreshToken, runOpenshell) { + const normalized = String(refreshToken || "").trim(); + if (!normalized) { + throw new Error("Hermes tool gateway refresh credential is empty"); + } + persistHermesToolGatewayProviderState(sandboxName, normalized); + const providerName = getHermesToolGatewayProviderName(sandboxName); + const result = onboardProviders.upsertProvider( + providerName, + "generic", + HERMES_TOOL_GATEWAY_REFRESH_CREDENTIAL_ENV, + null, + { [HERMES_TOOL_GATEWAY_REFRESH_CREDENTIAL_ENV]: normalized }, + runOpenshell, + ); + if (!result.ok) { + throw new Error(result.message || `failed to upsert provider '${providerName}'`); + } + return providerName; +} + +function readPid() { + try { + const pid = Number.parseInt(fs.readFileSync(HERMES_TOOL_GATEWAY_PID_PATH, "utf8").trim(), 10); + return Number.isInteger(pid) && pid > 0 ? pid : null; + } catch { + return null; + } +} + +function writePid(pid) { + if (!Number.isInteger(pid) || pid <= 0) return; + ensurePrivateDir(getCredsDir()); + fs.writeFileSync(HERMES_TOOL_GATEWAY_PID_PATH, `${pid}\n`, { mode: 0o600 }); + fs.chmodSync(HERMES_TOOL_GATEWAY_PID_PATH, 0o600); +} + +function clearPid() { + try { + fs.unlinkSync(HERMES_TOOL_GATEWAY_PID_PATH); + } catch { + /* ignore */ + } +} + +function brokerRuntimeHash() { + return crypto + .createHash("sha256") + .update( + JSON.stringify({ + port: HERMES_TOOL_GATEWAY_PORT, + script: HERMES_TOOL_GATEWAY_SCRIPT, + matrix: HERMES_TOOL_GATEWAY_MATRIX_PATH, + stateDir: HERMES_TOOL_GATEWAY_STATE_DIR, + }), + ) + .digest("hex"); +} + +function readBrokerHash() { + try { + return fs.readFileSync(HERMES_TOOL_GATEWAY_HASH_PATH, "utf8").trim() || null; + } catch { + return null; + } +} + +function writeBrokerHash(hash) { + ensurePrivateDir(getCredsDir()); + fs.writeFileSync(HERMES_TOOL_GATEWAY_HASH_PATH, `${hash}\n`, { mode: 0o600 }); + fs.chmodSync(HERMES_TOOL_GATEWAY_HASH_PATH, 0o600); +} + +function clearBrokerHash() { + try { + fs.unlinkSync(HERMES_TOOL_GATEWAY_HASH_PATH); + } catch { + /* ignore */ + } +} + +function isHermesToolGatewayBrokerProcess(pid) { + if (!Number.isInteger(pid) || pid <= 0) return false; + const cmdline = runCapture(["ps", "-p", String(pid), "-o", "args="], { ignoreError: true }); + return Boolean(cmdline && cmdline.includes("tool-gateway-broker.js")); +} + +function isHermesToolGatewayBrokerHealthy() { + const result = run( + [ + "curl", + "-sf", + "--connect-timeout", + "3", + "--max-time", + "5", + `http://127.0.0.1:${HERMES_TOOL_GATEWAY_PORT}/health`, + ], + { ignoreError: true, suppressOutput: true }, + ); + return result.status === 0; +} + +function killStaleHermesToolGatewayBroker() { + const pid = readPid(); + if (isHermesToolGatewayBrokerProcess(pid)) { + run(["kill", String(pid)], { ignoreError: true, suppressOutput: true }); + } + clearPid(); + clearBrokerHash(); +} + +function spawnHermesToolGatewayBroker() { + ensurePrivateDir(HERMES_TOOL_GATEWAY_STATE_DIR); + const child = spawn(process.execPath, [HERMES_TOOL_GATEWAY_SCRIPT], { + detached: true, + stdio: "ignore", + cwd: ROOT, + env: buildSubprocessEnv({ + HERMES_TOOL_GATEWAY_PORT: String(HERMES_TOOL_GATEWAY_PORT), + HERMES_TOOL_GATEWAY_STATE_DIR, + HERMES_TOOL_GATEWAY_MATRIX_PATH, + HERMES_TOOL_GATEWAY_REFRESH_CREDENTIAL_ENV, + NOUS_PORTAL_BASE_URL: process.env.NOUS_PORTAL_BASE_URL || oauth.DEFAULT_PORTAL_BASE_URL, + NEMOCLAW_OPENSHELL_BIN: process.env.NEMOCLAW_OPENSHELL_BIN || "openshell", + }), + }); + child.unref(); + writePid(child.pid); + writeBrokerHash(brokerRuntimeHash()); + return child.pid || null; +} + +function ensureHermesToolGatewayBroker(options = {}) { + const desiredHash = brokerRuntimeHash(); + const hashMatches = readBrokerHash() === desiredHash; + if ( + !options.forceRestart && + hashMatches && + brokerStartedThisRun && + isHermesToolGatewayBrokerHealthy() + ) { + return true; + } + const pid = readPid(); + if ( + !options.forceRestart && + hashMatches && + isHermesToolGatewayBrokerProcess(pid) && + isHermesToolGatewayBrokerHealthy() + ) { + brokerStartedThisRun = true; + return true; + } + if (!options.forceRestart && hashMatches && isHermesToolGatewayBrokerHealthy()) { + brokerStartedThisRun = true; + return true; + } + killStaleHermesToolGatewayBroker(); + const nextPid = spawnHermesToolGatewayBroker(); + for (let attempt = 0; attempt < 20; attempt++) { + if (isHermesToolGatewayBrokerProcess(nextPid) && isHermesToolGatewayBrokerHealthy()) { + brokerStartedThisRun = true; + return true; + } + sleep(250); + } + return false; +} + +function isHermesManagedToolGatewayEntry(entry) { + const enabled = + entry && + entry.agent === "hermes" && + Array.isArray(entry.hermesToolGateways) && + entry.hermesToolGateways.length > 0; + return Boolean(enabled); +} + +function ensureHermesToolGatewayBrokerForSandboxEntry(entry, options = {}) { + const enabled = isHermesManagedToolGatewayEntry(entry); + if (!enabled) return false; + return ensureHermesToolGatewayBroker(options); +} + +module.exports = { + HERMES_TOOL_GATEWAY_REFRESH_CREDENTIAL_ENV, + HERMES_TOOL_GATEWAY_STATE_DIR, + HERMES_TOOL_GATEWAY_PORT, + hashRefreshToken, + getHermesToolGatewayProviderName, + getHermesToolGatewayStatePath, + persistHermesToolGatewayProviderState, + registerHermesToolGatewayRefreshProvider, + isHermesToolGatewayBrokerHealthy, + killStaleHermesToolGatewayBroker, + ensureHermesToolGatewayBroker, + isHermesManagedToolGatewayEntry, + ensureHermesToolGatewayBrokerForSandboxEntry, +}; diff --git a/src/lib/oauth-device-code.test.ts b/src/lib/oauth-device-code.test.ts index 7b6ebd354d3..b2a982ba968 100644 --- a/src/lib/oauth-device-code.test.ts +++ b/src/lib/oauth-device-code.test.ts @@ -42,13 +42,20 @@ describe("pollForToken", () => { }); describe("refreshAccessTokenWithRefreshToken", () => { - it("uses the host-side refresh-token grant form body", async () => { - const calls: Array<{ url: string; body: string; signal: AbortSignal | null }> = []; + it("sends the refresh token in x-nous-refresh-token instead of the form body", async () => { + const calls: Array<{ + url: string; + body: string; + refreshHeader: string | null; + signal: AbortSignal | null; + }> = []; const token = await refreshAccessTokenWithRefreshToken("refresh-1", { fetch: (async (url, init) => { + const headers = new Headers(init?.headers); calls.push({ url: String(url), body: String(init?.body ?? ""), + refreshHeader: headers.get("x-nous-refresh-token"), signal: init?.signal instanceof AbortSignal ? init.signal : null, }); return new Response( @@ -71,9 +78,8 @@ describe("refreshAccessTokenWithRefreshToken", () => { expect(new URLSearchParams(calls[0]?.body).get("grant_type")).toBe( "refresh_token", ); - expect(new URLSearchParams(calls[0]?.body).get("refresh_token")).toBe( - "refresh-1", - ); + expect(new URLSearchParams(calls[0]?.body).get("refresh_token")).toBeNull(); + expect(calls[0]?.refreshHeader).toBe("refresh-1"); expect(new URLSearchParams(calls[0]?.body).get("client_id")).toBe( "hermes-cli", ); diff --git a/src/lib/oauth-device-code.ts b/src/lib/oauth-device-code.ts index af0dad416f3..2c8168cf420 100644 --- a/src/lib/oauth-device-code.ts +++ b/src/lib/oauth-device-code.ts @@ -143,6 +143,7 @@ async function postForm( body: Record, fetchImpl: typeof fetch, requestTimeoutMs?: number, + extraHeaders: Record = {}, ): Promise { const timeout = createRequestTimeout(requestTimeoutMs); try { @@ -151,6 +152,7 @@ async function postForm( headers: { Accept: "application/json", "Content-Type": "application/x-www-form-urlencoded", + ...extraHeaders, }, body: new URLSearchParams(body).toString(), signal: timeout.signal, @@ -283,11 +285,11 @@ export async function refreshAccessTokenWithRefreshToken( `${portalBaseUrl}/api/oauth/token`, { grant_type: "refresh_token", - refresh_token: refreshToken, client_id: clientId, }, fetchImpl, opts.requestTimeoutMs, + { "x-nous-refresh-token": refreshToken }, ); if (resp.status !== 200) { diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index c61a7e1f3fb..c451b7c7754 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -150,6 +150,10 @@ const { const onboardProviders = require("./onboard/providers"); const hermesProviderAuth = require("./hermes-provider-auth"); +function getHermesToolGatewayBroker(): any { + return require("./hermes-tool-gateway-broker"); +} + const CUSTOM_BUILD_CONTEXT_WARN_BYTES = 100_000_000; const CUSTOM_BUILD_CONTEXT_IGNORES = new Set([ "node_modules", @@ -380,6 +384,41 @@ const HERMES_AUTH_METHOD_API_KEY: HermesAuthMethod = "api_key"; const HERMES_NOUS_API_KEY_CREDENTIAL_ENV = hermesProviderAuth.HERMES_NOUS_API_KEY_CREDENTIAL_ENV || "NOUS_API_KEY"; const HERMES_NOUS_API_KEY_HELP_URL = "https://portal.nousresearch.com/manage-subscription"; +const HERMES_TOOL_GATEWAY_PRESETS = [ + { + name: "nous-web", + label: "Web search/extract", + description: "Firecrawl via Nous managed gateway", + defaultSelected: true, + }, + { + name: "nous-image", + label: "Image generation", + description: "FAL queue via Nous managed gateway", + defaultSelected: true, + }, + { + name: "nous-audio", + label: "Audio TTS/STT", + description: "OpenAI-compatible audio via Nous managed gateway", + defaultSelected: true, + }, + { + name: "nous-browser", + label: "Cloud browser", + description: "Browser Use via Nous managed gateway", + defaultSelected: true, + }, + { + name: "nous-code", + label: "Managed code execution", + description: "Modal via Nous managed gateway", + defaultSelected: false, + }, +] as const; +const HERMES_TOOL_GATEWAY_PRESET_NAMES = new Set( + HERMES_TOOL_GATEWAY_PRESETS.map((preset) => preset.name), +); /** * Probe whether the gateway Docker container is actually running. @@ -1616,6 +1655,121 @@ async function ensureHermesNousApiKeyEnv(): Promise { return key; } +function parseHermesToolGatewayPresetEnv(raw: string | null | undefined): string[] { + const values = String(raw || "") + .split(",") + .map((value) => value.trim()) + .filter(Boolean); + const selected: string[] = []; + for (const value of values) { + const normalized = value.toLowerCase(); + const name = normalized.startsWith("nous-") ? normalized : `nous-${normalized}`; + if (!HERMES_TOOL_GATEWAY_PRESET_NAMES.has(name as any)) { + console.error(` Unknown Hermes managed tool gateway: ${value}`); + console.error( + ` Valid values: ${HERMES_TOOL_GATEWAY_PRESETS.map((preset) => preset.name).join(", ")}`, + ); + process.exit(1); + } + if (!selected.includes(name)) selected.push(name); + } + return selected; +} + +function getRequestedHermesToolGateways(): string[] | null { + const raw = + process.env.NEMOCLAW_HERMES_TOOL_GATEWAYS || + process.env.NEMOCLAW_HERMES_TOOL_GATEWAY_PRESETS || + ""; + if (!raw) return null; + return parseHermesToolGatewayPresetEnv(raw); +} + +function hermesToolGatewayLabels(presets: string[] | null | undefined): string { + if (!Array.isArray(presets) || presets.length === 0) return "none"; + const byName = new Map( + HERMES_TOOL_GATEWAY_PRESETS.map((preset) => [preset.name, preset.label]), + ); + return presets.map((name) => byName.get(name) || name).join(", "); +} + +function stringSetsEqual(a: string[] | null | undefined, b: string[] | null | undefined): boolean { + const left = new Set(Array.isArray(a) ? a : []); + const right = new Set(Array.isArray(b) ? b : []); + if (left.size !== right.size) return false; + for (const value of left) { + if (!right.has(value)) return false; + } + return true; +} + +async function setupHermesToolGateways( + provider: string | null, + hermesAuthMethod: HermesAuthMethod | null, + existing: string[] | null = null, +): Promise { + if (provider !== hermesProviderAuth.HERMES_PROVIDER_NAME) return []; + if (hermesAuthMethod === HERMES_AUTH_METHOD_API_KEY) { + const requested = getRequestedHermesToolGateways(); + if (requested && requested.length > 0) { + note( + " Hermes managed tool gateways require Nous Portal OAuth/subscription; API-key mode is inference-only.", + ); + } + return []; + } + + const requested = getRequestedHermesToolGateways(); + if (requested) { + if (requested.length > 0) { + note(` [env] Hermes managed tools: ${hermesToolGatewayLabels(requested)}`); + } + return requested; + } + if (Array.isArray(existing) && existing.length > 0) { + return existing.filter((name) => HERMES_TOOL_GATEWAY_PRESET_NAMES.has(name as any)); + } + if (isNonInteractive()) return []; + + console.log(""); + console.log(" Hermes managed Nous tools (OAuth subscription only):"); + HERMES_TOOL_GATEWAY_PRESETS.forEach((preset, index) => { + const marker = preset.defaultSelected ? "[✓]" : "[ ]"; + console.log(` ${index + 1}) ${marker} ${preset.label} — ${preset.description}`); + }); + console.log(""); + console.log(" Enter comma-separated numbers/names, Enter for defaults, or 'none' to skip."); + const answer = (await prompt(" Managed tools: ")).trim(); + if (!answer) { + return HERMES_TOOL_GATEWAY_PRESETS.filter((preset) => preset.defaultSelected).map( + (preset) => preset.name, + ); + } + if (/^(none|no|skip)$/i.test(answer)) return []; + + const selected: string[] = []; + for (const part of answer.split(",").map((value) => value.trim()).filter(Boolean)) { + const index = /^[0-9]+$/.test(part) ? Number(part) - 1 : -1; + const preset = + index >= 0 + ? HERMES_TOOL_GATEWAY_PRESETS[index] + : HERMES_TOOL_GATEWAY_PRESETS.find((candidate) => { + const normalized = part.toLowerCase(); + return ( + candidate.name === normalized || + candidate.name === `nous-${normalized}` || + candidate.label.toLowerCase() === normalized + ); + }); + if (!preset) { + console.error(` Unknown managed tool selection: ${part}`); + process.exit(1); + } + if (!selected.includes(preset.name)) selected.push(preset.name); + } + return selected; +} + async function selectOnboardAgent({ agentFlag = null, session = null, @@ -1923,12 +2077,16 @@ function getNetworkPolicyNames(policyContent: string): Set | null { function prepareInitialSandboxCreatePolicy( basePolicyPath: string, activeMessagingChannels: string[], + hermesToolGateways: string[] = [], ): InitialSandboxPolicy { const requestedCreateTimePresets = [ ...new Set( - activeMessagingChannels.flatMap( - (channel) => CREATE_TIME_POLICY_PRESETS_BY_CHANNEL[channel] || [], - ), + [ + ...activeMessagingChannels.flatMap( + (channel) => CREATE_TIME_POLICY_PRESETS_BY_CHANNEL[channel] || [], + ), + ...hermesToolGateways, + ], ), ]; @@ -2822,6 +2980,7 @@ function patchStagedDockerfile( discordGuilds: LooseObject = {}, baseImageRef: string | null = null, telegramConfig: LooseObject = {}, + hermesToolGateways: string[] = [], ) { const { providerKey, primaryModelRef, inferenceBaseUrl, inferenceApi, inferenceCompat } = getSandboxInferenceConfig(model, provider, preferredInferenceApi); @@ -2979,6 +3138,16 @@ function patchStagedDockerfile( `ARG NEMOCLAW_TELEGRAM_CONFIG_B64=${encodeDockerJsonArg(telegramConfig)}`, ); } + if (hermesToolGateways.length > 0) { + dockerfile = dockerfile.replace( + /^ARG NEMOCLAW_HERMES_TOOL_GATEWAY_BROKER=.*$/m, + "ARG NEMOCLAW_HERMES_TOOL_GATEWAY_BROKER=1", + ); + dockerfile = dockerfile.replace( + /^ARG NEMOCLAW_HERMES_TOOL_GATEWAY_PRESETS_B64=.*$/m, + `ARG NEMOCLAW_HERMES_TOOL_GATEWAY_PRESETS_B64=${encodeDockerJsonArg(hermesToolGateways)}`, + ); + } fs.writeFileSync(dockerfilePath, dockerfile); } @@ -4850,6 +5019,7 @@ type OnboardConfigSummary = { hermesAuthMethod?: HermesAuthMethod | string | null; webSearchConfig?: WebSearchConfig | null; enabledChannels?: string[] | null; + hermesToolGateways?: string[] | null; sandboxName: string; notes?: string[] | null; }; @@ -4893,6 +5063,7 @@ function formatOnboardConfigSummary({ hermesAuthMethod = null, webSearchConfig = null, enabledChannels = null, + hermesToolGateways = null, sandboxName, notes = [], }: OnboardConfigSummary): string { @@ -4929,6 +5100,7 @@ function formatOnboardConfigSummary({ ` Model: ${model ?? "(unset)"}`, apiKeyLine, ` Web search: ${webSearch}`, + ` Managed tools: ${hermesToolGatewayLabels(hermesToolGateways)}`, ` Messaging: ${messaging}`, ` Sandbox name: ${sandboxName}`, ...noteLines, @@ -4948,6 +5120,7 @@ async function createSandbox( agent: AgentDefinition | null = null, controlUiPort: number | null = null, gpuPassthrough: boolean = false, + hermesToolGateways: string[] = [], ) { step(6, 8, "Creating sandbox"); @@ -5183,6 +5356,8 @@ async function createSandbox( messagingTokenDefs.some(({ name, token }) => token && !providerExistsInGateway(name)); const selectionDrift = getSelectionDrift(sandboxName, provider, model); const confirmedSelectionDrift = selectionDrift.changed && !selectionDrift.unknown; + const recordedHermesToolGateways = registry.getSandbox(sandboxName)?.hermesToolGateways ?? []; + const hermesToolGatewayDrift = !stringSetsEqual(recordedHermesToolGateways, hermesToolGateways); // Detect whether any messaging credential has been rotated since the // sandbox was created. Provider credentials are resolved once at sandbox @@ -5195,7 +5370,8 @@ async function createSandbox( !isRecreateSandbox() && !recreateForAgentDrift && !needsProviderMigration && - !credentialRotation.changed + !credentialRotation.changed && + !hermesToolGatewayDrift ) { // Guard against reusing a CPU-only sandbox when GPU passthrough is enabled. // Placed before the non-interactive / interactive split so all reuse @@ -5371,6 +5547,8 @@ async function createSandbox( console.log(" Recreating to ensure credentials flow through the provider pipeline."); } else if (confirmedSelectionDrift) { note(` Sandbox '${sandboxName}' exists — recreating to apply model/provider change.`); + } else if (hermesToolGatewayDrift) { + note(` Sandbox '${sandboxName}' exists — recreating to apply Hermes managed-tool changes.`); } else if (credentialRotation.changed) { // Message already printed above during backup. } else if (existingSandboxState === "ready") { @@ -5536,6 +5714,7 @@ async function createSandbox( const initialSandboxPolicy = prepareInitialSandboxCreatePolicy( basePolicyPath, activeMessagingChannels, + hermesToolGateways, ); if (initialSandboxPolicy.cleanup) { process.on("exit", initialSandboxPolicy.cleanup); @@ -5567,6 +5746,10 @@ async function createSandbox( for (const p of messagingProviders) { createArgs.push("--provider", p); } + if (hermesToolGateways.length > 0) { + const hermesToolGateway = getHermesToolGatewayBroker(); + createArgs.push("--provider", hermesToolGateway.getHermesToolGatewayProviderName(sandboxName)); + } console.log(` Creating sandbox '${sandboxName}' (this takes a few minutes on first run)...`); const messagingChannelConfig = readMessagingChannelConfigFromEnv(); @@ -5677,6 +5860,7 @@ async function createSandbox( discordGuilds, resolved ? resolved.ref : null, telegramConfig, + hermesToolGateways, ); // Only pass non-sensitive env vars to the sandbox. Credentials flow through // OpenShell providers — the gateway injects them as placeholders and the L7 @@ -5923,6 +6107,7 @@ async function createSandbox( messagingChannels: activeMessagingChannels, messagingChannelConfig: messagingChannelConfig || undefined, disabledChannels: disabledChannels.length > 0 ? [...disabledChannels] : undefined, + hermesToolGateways: hermesToolGateways.length > 0 ? [...hermesToolGateways] : undefined, dashboardPort: actualDashboardPort, }); registry.setDefault(sandboxName); @@ -7482,6 +7667,7 @@ async function setupInference( endpointUrl: string | null = null, credentialEnv: string | null = null, hermesAuthMethod: HermesAuthMethod | string | null = null, + hermesToolGateways: string[] = [], ): Promise<{ ok: true; retry?: undefined } | { retry: "selection" }> { step(4, 8, "Setting up inference provider"); runOpenshell(["gateway", "select", GATEWAY_NAME], { ignoreError: true }); @@ -7494,10 +7680,17 @@ async function setupInference( ? HERMES_AUTH_METHOD_API_KEY : HERMES_AUTH_METHOD_OAUTH); const providerRegistered = hermesProviderAuth.isHermesProviderRegistered(runOpenshell); + const toolGatewayProviderRegistered = + hermesToolGateways.length === 0 + ? true + : providerExistsInGateway( + getHermesToolGatewayBroker().getHermesToolGatewayProviderName(targetSandbox), + ); const hasFreshNousApiKey = resolvedHermesAuthMethod === HERMES_AUTH_METHOD_API_KEY && !!resolveHermesNousApiKey(); const shouldPrepareHermesCredentials = !providerRegistered || + !toolGatewayProviderRegistered || hasFreshNousApiKey || (resolvedHermesAuthMethod === HERMES_AUTH_METHOD_OAUTH && !isNonInteractive()); if (shouldPrepareHermesCredentials) { @@ -7513,6 +7706,7 @@ async function setupInference( allowInteractiveLogin: !isNonInteractive(), runOpenshell, baseUrl: endpointUrl || undefined, + toolGatewayPresets: hermesToolGateways, }); if (!state) { const authLabel = hermesAuthMethodLabel(resolvedHermesAuthMethod); @@ -8200,10 +8394,12 @@ function getSuggestedPolicyPresets({ enabledChannels = null, webSearchConfig = null, provider = null, + hermesToolGateways = null, }: { enabledChannels?: string[] | null; webSearchConfig?: WebSearchConfig | null; provider?: string | null; + hermesToolGateways?: string[] | null; } = {}): string[] { const suggestions = ["pypi", "npm"]; @@ -8231,6 +8427,11 @@ function getSuggestedPolicyPresets({ maybeSuggestMessagingPreset("discord", "DISCORD_BOT_TOKEN"); if (webSearchConfig) suggestions.push("brave"); + if (Array.isArray(hermesToolGateways)) { + for (const preset of hermesToolGateways) { + if (HERMES_TOOL_GATEWAY_PRESET_NAMES.has(preset as any)) suggestions.push(preset); + } + } return suggestions; } @@ -8270,6 +8471,7 @@ async function _setupPolicies( enabledChannels?: string[] | null; webSearchConfig?: WebSearchConfig | null; provider?: string | null; + hermesToolGateways?: string[] | null; } = {}, ) { step(8, 8, "Policy presets"); @@ -8868,11 +9070,17 @@ function computeSetupPresetSuggestions( enabledChannels?: string[] | null; webSearchConfig?: WebSearchConfig | null; provider?: string | null; + hermesToolGateways?: string[] | null; knownPresetNames?: string[] | null; webSearchSupported?: boolean | null; } = {}, ): string[] { - const { enabledChannels = null, webSearchConfig = null, provider = null } = options; + const { + enabledChannels = null, + webSearchConfig = null, + provider = null, + hermesToolGateways = null, + } = options; const known = Array.isArray(options.knownPresetNames) ? new Set(options.knownPresetNames) : null; const supportOptions = { webSearchSupported: options.webSearchSupported }; const suggestions = tiers @@ -8891,6 +9099,9 @@ function computeSetupPresetSuggestions( if (Array.isArray(enabledChannels)) { for (const channel of enabledChannels) add(channel); } + if (Array.isArray(hermesToolGateways)) { + for (const preset of hermesToolGateways) add(preset); + } return suggestions; } @@ -8902,6 +9113,7 @@ async function setupPoliciesWithSelection( webSearchConfig?: WebSearchConfig | null; enabledChannels?: string[] | null; provider?: string | null; + hermesToolGateways?: string[] | null; knownPresetNames?: string[]; webSearchSupported?: boolean | null; } = {}, @@ -8911,6 +9123,9 @@ async function setupPoliciesWithSelection( const webSearchConfig = options.webSearchConfig || null; const enabledChannels = Array.isArray(options.enabledChannels) ? options.enabledChannels : null; const provider = options.provider || null; + const hermesToolGateways = Array.isArray(options.hermesToolGateways) + ? options.hermesToolGateways + : null; step(8, 8, "Policy presets"); @@ -8960,6 +9175,7 @@ async function setupPoliciesWithSelection( enabledChannels, webSearchConfig, provider, + hermesToolGateways, knownPresetNames: allPresets.map((p) => p.name), webSearchSupported: options.webSearchSupported, }); @@ -9726,6 +9942,7 @@ function toSessionUpdates( preferredInferenceApi?: string | null; nimContainer?: string | null; webSearchConfig?: WebSearchConfig | null; + hermesToolGateways?: string[] | null; policyPresets?: string[] | null; messagingChannels?: string[] | null; messagingChannelConfig?: MessagingChannelConfig | null; @@ -9748,6 +9965,7 @@ function toSessionUpdates( if (updates.nimContainer !== undefined) normalized.nimContainer = toNullableString(updates.nimContainer); if (updates.webSearchConfig !== undefined) normalized.webSearchConfig = updates.webSearchConfig; + if (updates.hermesToolGateways) normalized.hermesToolGateways = updates.hermesToolGateways; if (updates.policyPresets) normalized.policyPresets = updates.policyPresets; if (updates.messagingChannels) normalized.messagingChannels = updates.messagingChannels; if (updates.messagingChannelConfig !== undefined) { @@ -10291,6 +10509,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { let preferredInferenceApi = session?.preferredInferenceApi || null; let nimContainer = session?.nimContainer || null; let webSearchConfig = session?.webSearchConfig || null; + let hermesToolGateways = session?.hermesToolGateways || []; let forceProviderSelection = false; while (true) { const resumeProviderSelection = @@ -10335,6 +10554,15 @@ async function onboard(opts: OnboardOptions = {}): Promise { console.error(" Inference selection did not yield a provider/model."); process.exit(1); } + hermesToolGateways = await setupHermesToolGateways( + provider, + hermesAuthMethod, + hermesToolGateways, + ); + onboardSession.updateSession((current: Session) => { + current.hermesToolGateways = hermesToolGateways; + return current; + }); process.env.NEMOCLAW_OPENSHELL_BIN = getOpenshellBinary(); const resumeInference = !forceProviderSelection && resume && isInferenceRouteReady(provider, model); @@ -10348,6 +10576,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { endpointUrl, credentialEnv, hermesAuthMethod, + hermesToolGateways, ); if (inferenceResult?.retry === "selection") { forceProviderSelection = true; @@ -10355,7 +10584,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { } onboardSession.markStepComplete( "inference", - toSessionUpdates({ provider, model, hermesAuthMethod, nimContainer }), + toSessionUpdates({ provider, model, hermesAuthMethod, nimContainer, hermesToolGateways }), ); break; } @@ -10375,7 +10604,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { } onboardSession.markStepComplete( "inference", - toSessionUpdates({ provider, model, hermesAuthMethod, nimContainer }), + toSessionUpdates({ provider, model, hermesAuthMethod, nimContainer, hermesToolGateways }), ); break; } @@ -10399,12 +10628,13 @@ async function onboard(opts: OnboardOptions = {}): Promise { credentialEnv, hermesAuthMethod, webSearchConfig, + hermesToolGateways, enabledChannels: selectedMessagingChannels.length > 0 ? selectedMessagingChannels : null, sandboxName, notes: buildEstimateNote ? [buildEstimateNote] : [], }), ); - console.log(" Web search and messaging channels will be prompted next."); + console.log(" Web search and messaging channels will be prompted after inference setup."); if (!isNonInteractive()) { if (!(await promptYesNoOrDefault(" Apply this configuration?", null, true))) { console.log(` Aborted. Re-run \`${cliName()} onboard\` to start over.`); @@ -10424,6 +10654,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { endpointUrl, credentialEnv, hermesAuthMethod, + hermesToolGateways, ); delete process.env.NVIDIA_API_KEY; if (inferenceResult?.retry === "selection") { @@ -10435,7 +10666,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { } onboardSession.markStepComplete( "inference", - toSessionUpdates({ provider, model, hermesAuthMethod, nimContainer }), + toSessionUpdates({ provider, model, hermesAuthMethod, nimContainer, hermesToolGateways }), ); break; } @@ -10575,6 +10806,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { agent, opts.controlUiPort || null, gpuPassthrough, + hermesToolGateways, ); webSearchConfig = nextWebSearchConfig; // Persist model and provider after the sandbox entry exists in the registry. @@ -10595,6 +10827,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { nimContainer, webSearchConfig, messagingChannelConfig, + hermesToolGateways, }), ); } @@ -10701,6 +10934,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { : recordedMessagingChannels, webSearchConfig, provider, + hermesToolGateways, webSearchSupported, onSelection: (policyPresets) => { onboardSession.updateSession((current: Session) => { @@ -10720,7 +10954,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { } onboardSession.completeSession( - toSessionUpdates({ sandboxName, provider, model, hermesAuthMethod }), + toSessionUpdates({ sandboxName, provider, model, hermesAuthMethod, hermesToolGateways }), ); completed = true; // Onboarding finished successfully. Delete the legacy plaintext diff --git a/src/lib/state/onboard-session.ts b/src/lib/state/onboard-session.ts index e35286008da..580add467ca 100644 --- a/src/lib/state/onboard-session.ts +++ b/src/lib/state/onboard-session.ts @@ -82,6 +82,7 @@ export interface Session { routerPid: number | null; routerCredentialHash: string | null; webSearchConfig: WebSearchConfig | null; + hermesToolGateways: string[] | null; policyPresets: string[] | null; messagingChannels: string[] | null; messagingChannelConfig: MessagingChannelConfig | null; @@ -137,6 +138,7 @@ export interface SessionUpdates { routerPid?: number; routerCredentialHash?: string; webSearchConfig?: WebSearchConfig | null; + hermesToolGateways?: string[]; policyPresets?: string[]; messagingChannels?: string[]; messagingChannelConfig?: MessagingChannelConfig | null; @@ -162,6 +164,7 @@ export interface DebugSessionSummary { hermesAuthMethod: HermesAuthMethod | null; preferredInferenceApi: string | null; nimContainer: string | null; + hermesToolGateways: string[] | null; policyPresets: string[] | null; gpuPassthrough: boolean; lastStepStarted: string | null; @@ -326,6 +329,7 @@ export function createSession(overrides: Partial = {}): Session { routerCredentialHash: overrides.routerCredentialHash ?? null, webSearchConfig: overrides.webSearchConfig?.fetchEnabled === true ? { fetchEnabled: true } : null, + hermesToolGateways: readStringArray(overrides.hermesToolGateways), policyPresets: readStringArray(overrides.policyPresets), messagingChannels: readStringArray(overrides.messagingChannels), messagingChannelConfig: sanitizeMessagingChannelConfig(overrides.messagingChannelConfig), @@ -365,6 +369,7 @@ export function normalizeSession(data: Session | SessionJsonValue | undefined): routerPid: readPositiveInteger(data.routerPid), routerCredentialHash: readString(data.routerCredentialHash), webSearchConfig: parseWebSearchConfig(data.webSearchConfig), + hermesToolGateways: readStringArray(data.hermesToolGateways), policyPresets: readStringArray(data.policyPresets), messagingChannels: readStringArray(data.messagingChannels), messagingChannelConfig: sanitizeMessagingChannelConfig(data.messagingChannelConfig), @@ -776,6 +781,11 @@ export function filterSafeUpdates(updates: SessionUpdates): Partial { } else if (updates.webSearchConfig === null) { safe.webSearchConfig = null; } + if (Array.isArray(updates.hermesToolGateways)) { + safe.hermesToolGateways = updates.hermesToolGateways.filter( + (value) => typeof value === "string", + ); + } if (Array.isArray(updates.policyPresets)) { safe.policyPresets = updates.policyPresets.filter((value) => typeof value === "string"); } @@ -910,6 +920,7 @@ export function summarizeForDebug( hermesAuthMethod: session.hermesAuthMethod, preferredInferenceApi: session.preferredInferenceApi, nimContainer: session.nimContainer, + hermesToolGateways: session.hermesToolGateways, policyPresets: session.policyPresets, gpuPassthrough: session.gpuPassthrough, lastStepStarted: session.lastStepStarted, diff --git a/src/lib/state/registry.ts b/src/lib/state/registry.ts index b29e0b9acd8..819b48bcd74 100644 --- a/src/lib/state/registry.ts +++ b/src/lib/state/registry.ts @@ -31,6 +31,7 @@ export interface SandboxEntry { providerCredentialHashes?: Record; messagingChannels?: string[]; messagingChannelConfig?: MessagingChannelConfig; + hermesToolGateways?: string[]; disabledChannels?: string[]; dashboardPort?: number | null; } @@ -202,6 +203,10 @@ export function registerSandbox(entry: SandboxEntry): void { entry.messagingChannelConfig && Object.keys(entry.messagingChannelConfig).length > 0 ? { ...entry.messagingChannelConfig } : undefined, + hermesToolGateways: + Array.isArray(entry.hermesToolGateways) && entry.hermesToolGateways.length > 0 + ? [...entry.hermesToolGateways] + : undefined, disabledChannels: Array.isArray(entry.disabledChannels) && entry.disabledChannels.length > 0 ? [...entry.disabledChannels] diff --git a/test/generate-hermes-config.test.ts b/test/generate-hermes-config.test.ts index 7704eb0fec5..07ee2b8883b 100644 --- a/test/generate-hermes-config.test.ts +++ b/test/generate-hermes-config.test.ts @@ -118,6 +118,43 @@ describe("agents/hermes/generate-config.ts", () => { expect(envFile).toContain("API_SERVER_HOST=127.0.0.1\n"); }); + it("generates managed-tool gateway config and env for selected Nous presets", () => { + const { config, envFile } = runConfigScript({ + NEMOCLAW_HERMES_TOOL_GATEWAY_BROKER: "1", + NEMOCLAW_HERMES_TOOL_GATEWAY_PRESETS_B64: encodeJson([ + "nous-web", + "nous-audio", + "nous-browser", + "nous-image", + "nous-code", + ]), + }); + + expect(config.web).toEqual({ backend: "firecrawl", use_gateway: true }); + expect(config.tts).toEqual({ provider: "openai", use_gateway: true }); + expect(config.stt).toEqual({ provider: "openai", use_gateway: true }); + expect(config.browser).toEqual({ cloud_provider: "browser-use", use_gateway: true }); + expect(config.image_gen).toEqual({ use_gateway: true }); + expect(config.terminal).toMatchObject({ backend: "modal", modal_mode: "managed" }); + expect(envFile).toContain("NEMOCLAW_HERMES_TOOL_GATEWAY_BROKER=1\n"); + expect(envFile).toContain( + "TOOL_GATEWAY_USER_TOKEN=openshell:resolve:env:NEMOCLAW_HERMES_TOOL_GATEWAY_REFRESH_TOKEN\n", + ); + expect(envFile).toContain( + "FIRECRAWL_GATEWAY_URL=http://host.openshell.internal:11436/firecrawl\n", + ); + expect(envFile).toContain( + "OPENAI_AUDIO_GATEWAY_URL=http://host.openshell.internal:11436/openai-audio\n", + ); + expect(envFile).toContain( + "BROWSER_USE_GATEWAY_URL=http://host.openshell.internal:11436/browser-use\n", + ); + expect(envFile).toContain( + "FAL_QUEUE_GATEWAY_URL=http://host.openshell.internal:11436/fal-queue\n", + ); + expect(envFile).toContain("MODAL_GATEWAY_URL=http://host.openshell.internal:11436/modal\n"); + }); + it("writes Discord settings in Hermes' top-level schema and keeps tokens in .env", () => { const { config, envFile } = runConfigScript({ NEMOCLAW_MESSAGING_CHANNELS_B64: encodeJson(["discord"]), diff --git a/test/hermes-plugin-handlers.test.ts b/test/hermes-plugin-handlers.test.ts index 2df02244e8a..c952f941833 100644 --- a/test/hermes-plugin-handlers.test.ts +++ b/test/hermes-plugin-handlers.test.ts @@ -71,4 +71,153 @@ print(json.dumps(result)) expect(result.reload).toContain("alpha: First skill"); expect(result.reload).toContain("beta: Second skill"); }); + + it("patches Hermes managed-tool modules for NemoClaw broker mode", () => { + const output = runPython(` +import importlib.util +import json +import os +import pathlib +import sys +import types + +plugin_path = pathlib.Path(sys.argv[1]) +yaml_stub = types.ModuleType("yaml") +yaml_stub.safe_load = lambda *_args, **_kwargs: {} +sys.modules.setdefault("yaml", yaml_stub) + +os.environ["NEMOCLAW_HERMES_TOOL_GATEWAY_BROKER"] = "1" +os.environ["TOOL_GATEWAY_USER_TOKEN"] = "openshell:resolve:env:NEMOCLAW_HERMES_TOOL_GATEWAY_REFRESH_TOKEN" +os.environ["FAL_QUEUE_GATEWAY_URL"] = "http://host.openshell.internal:11436/fal-queue" +os.environ["FIRECRAWL_GATEWAY_URL"] = "http://host.openshell.internal:11436/firecrawl" + +def add_module(name, module): + sys.modules[name] = module + parent, _, child = name.rpartition(".") + if parent: + parent_module = sys.modules.setdefault(parent, types.ModuleType(parent)) + setattr(parent_module, child, module) + return module + +hermes_config = add_module("hermes_cli.config", types.ModuleType("hermes_cli.config")) +hermes_config.get_env_value = lambda key: os.environ.get(key) +hermes_config.load_config = lambda: { + "tts": {"use_gateway": True}, + "stt": {"use_gateway": True}, +} + +managed = add_module("tools.managed_tool_gateway", types.ModuleType("tools.managed_tool_gateway")) +managed.managed_nous_tools_enabled = lambda: False +managed.build_vendor_gateway_url = lambda vendor: "direct" +managed.read_nous_access_token = lambda: None +managed.resolve_managed_tool_gateway = lambda vendor: types.SimpleNamespace( + nous_user_token="broker-token", + gateway_origin=f"http://host.openshell.internal:11436/{vendor}", +) + +web = add_module("tools.web_tools", types.ModuleType("tools.web_tools")) +web.managed_nous_tools_enabled = lambda: False +web.build_vendor_gateway_url = lambda vendor: "direct" +web._read_nous_access_token = lambda: None +web.resolve_managed_tool_gateway = lambda vendor: None + +helpers = add_module("tools.tool_backend_helpers", types.ModuleType("tools.tool_backend_helpers")) +helpers.managed_nous_tools_enabled = lambda: False +helpers.resolve_openai_audio_api_key = lambda: "direct-openai-key" + +transcription = add_module("tools.transcription_tools", types.ModuleType("tools.transcription_tools")) +transcription.resolve_managed_tool_gateway = managed.resolve_managed_tool_gateway +transcription._resolve_openai_audio_client_config = lambda: ("direct-openai-key", "https://api.openai.com/v1") +transcription._has_openai_audio_backend = lambda: False + +image = add_module("tools.image_generation_tool", types.ModuleType("tools.image_generation_tool")) +class ManagedFalSyncClient: + def __init__(self): + self._queue_url_format = os.environ["FAL_QUEUE_GATEWAY_URL"] + def submit(self): + return types.SimpleNamespace( + request_id="req-1", + response_url="https://fal-queue-gateway.nousresearch.com/result/req-1", + status_url="https://fal-queue-gateway.nousresearch.com/status/req-1", + cancel_url="https://fal-queue-gateway.nousresearch.com/cancel/req-1", + client="client", + ) +image._ManagedFalSyncClient = ManagedFalSyncClient + +browser = add_module("tools.browser_tool", types.ModuleType("tools.browser_tool")) +browser._cached_cloud_provider = "local" +browser._cloud_provider_resolved = True +browser._active_sessions = {"default": {"features": {"local": True}}} +browser._session_last_activity = {"default": 1} +browser._recording_sessions = set(["default"]) +browser._get_session_info = lambda task_id=None: {"task_id": task_id or "default"} +browser._resolve_cdp_override = lambda cdp_url: cdp_url + +firecrawl_client = types.ModuleType("firecrawl.v2.utils.http_client") +class HttpClient: + def __init__(self): + self.api_url = os.environ["FIRECRAWL_GATEWAY_URL"] + def _build_url(self, endpoint): + return "http://host.openshell.internal:11436/v2/search" +firecrawl_client.HttpClient = HttpClient +add_module("firecrawl.v2.utils.http_client", firecrawl_client) + +spec = importlib.util.spec_from_file_location("hermes_plugin", plugin_path) +plugin = importlib.util.module_from_spec(spec) +spec.loader.exec_module(plugin) +patched = plugin._install_nous_tool_broker_patch() +fal_handle = image._ManagedFalSyncClient().submit() +firecrawl_url = firecrawl_client.HttpClient()._build_url("/v2/search") + +result = { + "patched": patched, + "managed_enabled": managed.managed_nous_tools_enabled(), + "web_enabled": web.managed_nous_tools_enabled(), + "web_url": web.build_vendor_gateway_url("firecrawl"), + "web_token": web._read_nous_access_token(), + "audio_key": helpers.resolve_openai_audio_api_key(), + "stt_config": transcription._resolve_openai_audio_client_config(), + "fal_status_url": fal_handle.status_url, + "browser_cache": [browser._cached_cloud_provider, browser._cloud_provider_resolved], + "browser_sessions": browser._active_sessions, + "firecrawl_url": firecrawl_url, +} +print(json.dumps(result)) +`); + + const result = JSON.parse(output) as { + patched: boolean; + managed_enabled: boolean; + web_enabled: boolean; + web_url: string; + web_token: string; + audio_key: string; + stt_config: [string, string]; + fal_status_url: string; + browser_cache: [unknown, boolean]; + browser_sessions: Record; + firecrawl_url: string; + }; + + expect(result.patched).toBe(true); + expect(result.managed_enabled).toBe(true); + expect(result.web_enabled).toBe(true); + expect(result.web_url).toBe("http://host.openshell.internal:11436/firecrawl"); + expect(result.web_token).toBe( + "openshell:resolve:env:NEMOCLAW_HERMES_TOOL_GATEWAY_REFRESH_TOKEN", + ); + expect(result.audio_key).toBe(""); + expect(result.stt_config).toEqual([ + "broker-token", + "http://host.openshell.internal:11436/openai-audio/v1", + ]); + expect(result.fal_status_url).toBe( + "http://host.openshell.internal:11436/fal-queue/status/req-1", + ); + expect(result.browser_cache).toEqual([null, false]); + expect(result.browser_sessions).toEqual({}); + expect(result.firecrawl_url).toBe( + "http://host.openshell.internal:11436/firecrawl/v2/search", + ); + }); }); diff --git a/test/hermes-tool-gateway-broker.test.ts b/test/hermes-tool-gateway-broker.test.ts new file mode 100644 index 00000000000..3d8ab90c75d --- /dev/null +++ b/test/hermes-tool-gateway-broker.test.ts @@ -0,0 +1,324 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +/* global fetch */ + +import { spawn, type ChildProcess } from "node:child_process"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import http from "node:http"; +import { createRequire } from "node:module"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; +import zlib from "node:zlib"; + +import { afterEach, describe, expect, it } from "vitest"; + +const SCRIPT = path.join( + import.meta.dirname, + "..", + "agents", + "hermes", + "host", + "tool-gateway-broker.js", +); +const require = createRequire(import.meta.url); +const DIST_WRAPPER = path.join( + import.meta.dirname, + "..", + "dist", + "lib", + "hermes-tool-gateway-broker.js", +); + +let children: ChildProcess[] = []; + +function sha256(value: string): string { + return crypto.createHash("sha256").update(value).digest("hex"); +} + +function freePort(): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.on("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(() => reject(new Error("no port"))); + return; + } + const port = address.port; + server.close(() => resolve(port)); + }); + }); +} + +function listen(server: http.Server): Promise { + return new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + resolve(typeof address === "object" && address ? address.port : 0); + }); + }); +} + +function close(server: http.Server): Promise { + return new Promise((resolve) => server.close(() => resolve())); +} + +async function waitForHealth(port: number): Promise { + for (let i = 0; i < 50; i++) { + try { + const resp = await fetch(`http://127.0.0.1:${port}/health`); + if (resp.status === 200) return; + } catch { + // keep polling + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error("broker did not become healthy"); +} + +afterEach(() => { + for (const child of children) child.kill("SIGTERM"); + children = []; +}); + +describe("Hermes managed-tool gateway broker", () => { + it("only auto-recovers for Hermes sandboxes with selected managed tools", () => { + delete require.cache[require.resolve(DIST_WRAPPER)]; + const broker = require(DIST_WRAPPER); + + expect( + broker.isHermesManagedToolGatewayEntry({ + agent: "openclaw", + hermesToolGateways: ["nous-web"], + }), + ).toBe(false); + expect( + broker.ensureHermesToolGatewayBrokerForSandboxEntry({ + agent: "openclaw", + hermesToolGateways: ["nous-web"], + }), + ).toBe(false); + expect( + broker.isHermesManagedToolGatewayEntry({ + agent: "hermes", + hermesToolGateways: [], + }), + ).toBe(false); + expect( + broker.isHermesManagedToolGatewayEntry({ + agent: "hermes", + hermesToolGateways: ["nous-web"], + }), + ).toBe(true); + }); + + it("refreshes via header, replaces upstream auth, normalizes responses, and rotates OpenShell storage", async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-tool-broker-")); + const stateDir = path.join(tmp, "state"); + const binDir = path.join(tmp, "bin"); + fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 }); + fs.mkdirSync(binDir, { recursive: true }); + const openshellLog = path.join(tmp, "openshell.log"); + const openshellBin = path.join(binDir, "openshell"); + fs.writeFileSync( + openshellBin, + `#!/bin/sh\nprintf '%s\\n' "$*" >> "${openshellLog}"\nprintf 'refresh=%s\\n' "$NEMOCLAW_HERMES_TOOL_GATEWAY_REFRESH_TOKEN" >> "${openshellLog}"\nexit 0\n`, + { mode: 0o755 }, + ); + const statePath = path.join(stateDir, "sandbox.json"); + fs.writeFileSync( + statePath, + JSON.stringify( + { + version: 1, + sandbox: "sandbox", + provider_name: "sandbox-hermes-tool-gateway", + credential_env: "NEMOCLAW_HERMES_TOOL_GATEWAY_REFRESH_TOKEN", + refresh_token_sha256: sha256("refresh-1"), + client_id: "hermes-cli", + }, + null, + 2, + ), + { mode: 0o600 }, + ); + + const portalRequests: Array<{ body: string; refreshHeader?: string }> = []; + const portal = http.createServer((req, res) => { + const chunks: Buffer[] = []; + req.on("data", (chunk) => chunks.push(chunk)); + req.on("end", () => { + portalRequests.push({ + body: Buffer.concat(chunks).toString("utf8"), + refreshHeader: req.headers["x-nous-refresh-token"] as string | undefined, + }); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + access_token: "access-2", + refresh_token: "refresh-2", + expires_in: 900, + token_type: "Bearer", + }), + ); + }); + }); + const portalPort = await listen(portal); + + const upstreamRequests: Array<{ + url?: string; + authorization?: string; + browserUseApiKey?: string; + apiKey?: string; + acceptEncoding?: string; + }> = []; + const upstream = http.createServer((req, res) => { + upstreamRequests.push({ + url: req.url, + authorization: req.headers.authorization, + browserUseApiKey: req.headers["x-browser-use-api-key"] as string | undefined, + apiKey: req.headers["x-api-key"] as string | undefined, + acceptEncoding: req.headers["accept-encoding"] as string | undefined, + }); + const body = zlib.gzipSync(JSON.stringify({ ok: true, path: req.url })); + res.writeHead(200, { + "Content-Type": "application/json", + "Content-Encoding": "gzip", + "Content-Length": String(body.length), + "Content-MD5": "not-a-real-digest", + "Set-Cookie": "secret=1", + }); + res.end(body); + }); + const upstreamPort = await listen(upstream); + const matrixPath = path.join(tmp, "matrix.json"); + const upstreamBase = `http://127.0.0.1:${upstreamPort}`; + fs.writeFileSync( + matrixPath, + JSON.stringify({ + "nous-web": { service: "firecrawl", upstream: upstreamBase }, + "nous-image": { service: "fal-queue", upstream: upstreamBase }, + "nous-audio": { service: "openai-audio", upstream: upstreamBase }, + "nous-browser": { service: "browser-use", upstream: upstreamBase }, + "nous-code": { service: "modal", upstream: upstreamBase }, + }), + ); + const brokerPort = await freePort(); + + const child = spawn(process.execPath, [SCRIPT], { + env: { + ...process.env, + HERMES_TOOL_GATEWAY_PORT: String(brokerPort), + HERMES_TOOL_GATEWAY_STATE_DIR: stateDir, + HERMES_TOOL_GATEWAY_MATRIX_PATH: matrixPath, + NOUS_PORTAL_BASE_URL: `http://127.0.0.1:${portalPort}`, + NEMOCLAW_OPENSHELL_BIN: openshellBin, + }, + stdio: ["ignore", "pipe", "pipe"], + }); + children.push(child); + + let output = ""; + child.stdout.on("data", (chunk) => { + output += chunk.toString(); + }); + child.stderr.on("data", (chunk) => { + output += chunk.toString(); + }); + + try { + await waitForHealth(brokerPort); + + const unknown = await fetch(`http://127.0.0.1:${brokerPort}/unknown`); + expect(unknown.status).toBe(404); + + const denied = await fetch(`http://127.0.0.1:${brokerPort}/firecrawl/v1/scrape`, { + headers: { Authorization: "Bearer wrong-refresh" }, + }); + expect(denied.status).toBe(401); + + const firecrawl = await fetch( + `http://127.0.0.1:${brokerPort}/firecrawl/v1/scrape?debug=1`, + { + method: "POST", + headers: { + Authorization: "Bearer refresh-1", + "Content-Type": "application/json", + "x-api-key": "sandbox-secret", + }, + body: JSON.stringify({ url: "https://example.com" }), + }, + ); + expect(firecrawl.status).toBe(200); + expect(firecrawl.headers.get("content-encoding")).toBeNull(); + expect(firecrawl.headers.get("content-length")).toBeNull(); + expect(firecrawl.headers.get("content-md5")).toBeNull(); + expect(firecrawl.headers.get("set-cookie")).toBeNull(); + expect(await firecrawl.json()).toEqual({ ok: true, path: "/v1/scrape?debug=1" }); + expect(portalRequests).toHaveLength(1); + expect(portalRequests[0]?.refreshHeader).toBe("refresh-1"); + expect(new URLSearchParams(portalRequests[0]?.body).get("refresh_token")).toBeNull(); + expect(new URLSearchParams(portalRequests[0]?.body).get("grant_type")).toBe( + "refresh_token", + ); + expect(upstreamRequests[0]).toMatchObject({ + url: "/v1/scrape?debug=1", + authorization: "Bearer access-2", + acceptEncoding: "identity", + }); + expect(upstreamRequests[0]?.apiKey).toBeUndefined(); + + const rotatedState = JSON.parse(fs.readFileSync(statePath, "utf8")); + expect(rotatedState.refresh_token_sha256).toBe(sha256("refresh-2")); + const openshellOutput = fs.readFileSync(openshellLog, "utf8"); + expect(openshellOutput).toContain( + "provider update sandbox-hermes-tool-gateway --credential NEMOCLAW_HERMES_TOOL_GATEWAY_REFRESH_TOKEN", + ); + expect(openshellOutput).toContain("refresh=refresh-2"); + + const checks = [ + ["/browser-use/browsers", { "X-Browser-Use-API-Key": "refresh-2" }, "browser"], + ["/fal-queue/fal-ai/test", { Authorization: "Key refresh-2" }, "fal"], + ["/openai-audio/v1/audio/speech", { "openai-api-key": "refresh-2" }, "audio"], + ["/modal/sandboxes", { Authorization: "Bearer refresh-2" }, "modal"], + ] as const; + for (const [route, headers] of checks) { + const resp = await fetch(`http://127.0.0.1:${brokerPort}${route}`, { + method: "POST", + headers, + body: "{}", + }); + expect(resp.status).toBe(200); + } + expect(upstreamRequests[1]).toMatchObject({ + url: "/browsers", + browserUseApiKey: "access-2", + }); + expect(upstreamRequests[1]?.authorization).toBeUndefined(); + expect(upstreamRequests[2]).toMatchObject({ + url: "/fal-ai/test", + authorization: "Key access-2", + }); + expect(upstreamRequests[3]).toMatchObject({ + url: "/v1/audio/speech", + authorization: "Bearer access-2", + }); + expect(upstreamRequests[4]).toMatchObject({ + url: "/sandboxes", + authorization: "Bearer access-2", + }); + expect(portalRequests).toHaveLength(1); + expect(output).not.toContain("refresh-1"); + expect(output).not.toContain("refresh-2"); + expect(output).not.toContain("access-2"); + expect(output).not.toContain("sandbox-secret"); + } finally { + await close(portal); + await close(upstream); + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); +}); diff --git a/test/onboard.test.ts b/test/onboard.test.ts index f4c32b95f2c..cbba4146a4d 100644 --- a/test/onboard.test.ts +++ b/test/onboard.test.ts @@ -4420,7 +4420,7 @@ const { setupInference } = require(${onboardPath}); source, // #2753: sandboxName is intentionally absent from the options here so // the session does not record a name before createSandbox completes. - /startRecordedStep\("sandbox", \{ provider, model \}\);\s*const recordedMessagingChannels = getRecordedMessagingChannelsForResume\(resume, session\);[\s\S]*?selectedMessagingChannels = recordedMessagingChannels;[\s\S]*?selectedMessagingChannels = await setupMessagingChannels\(\);[\s\S]*?const messagingChannelConfig = readMessagingChannelConfigFromEnv\(\);[\s\S]*?onboardSession\.updateSession\(\(current[^)]*\) => \{\s*current\.messagingChannels = selectedMessagingChannels;\s*current\.messagingChannelConfig = messagingChannelConfig;\s*return current;\s*\}\);[\s\S]*?sandboxName = await createSandbox\(\s*gpu,\s*model,\s*provider,\s*preferredInferenceApi,\s*sandboxName,\s*nextWebSearchConfig,\s*selectedMessagingChannels,\s*fromDockerfile,\s*agent,\s*opts\.controlUiPort \|\| null,\s*gpuPassthrough,\s*\);/, + /startRecordedStep\("sandbox", \{ provider, model \}\);\s*const recordedMessagingChannels = getRecordedMessagingChannelsForResume\(resume, session\);[\s\S]*?selectedMessagingChannels = recordedMessagingChannels;[\s\S]*?selectedMessagingChannels = await setupMessagingChannels\(\);[\s\S]*?const messagingChannelConfig = readMessagingChannelConfigFromEnv\(\);[\s\S]*?onboardSession\.updateSession\(\(current[^)]*\) => \{\s*current\.messagingChannels = selectedMessagingChannels;\s*current\.messagingChannelConfig = messagingChannelConfig;\s*return current;\s*\}\);[\s\S]*?sandboxName = await createSandbox\(\s*gpu,\s*model,\s*provider,\s*preferredInferenceApi,\s*sandboxName,\s*nextWebSearchConfig,\s*selectedMessagingChannels,\s*fromDockerfile,\s*agent,\s*opts\.controlUiPort \|\| null,\s*gpuPassthrough,\s*hermesToolGateways,\s*\);/, ); }); diff --git a/test/policies.test.ts b/test/policies.test.ts index f3b6dec7c1b..44c1c1efc7f 100644 --- a/test/policies.test.ts +++ b/test/policies.test.ts @@ -130,9 +130,9 @@ selectFromList(items, options) describe("policies", () => { describe("listPresets", () => { - it("returns all 12 presets", () => { + it("returns all 17 presets", () => { const presets = policies.listPresets(); - expect(presets.length).toBe(12); + expect(presets.length).toBe(17); }); it("each preset has name and description", () => { @@ -155,6 +155,11 @@ describe("policies", () => { "huggingface", "jira", "local-inference", + "nous-audio", + "nous-browser", + "nous-code", + "nous-image", + "nous-web", "npm", "outlook", "pypi", @@ -222,6 +227,46 @@ describe("policies", () => { expect(content).toContain("/usr/bin/curl"); expect(content).toContain("/usr/bin/python3"); }); + + it("Nous managed-tool presets expose only the host broker plus Browser Use CDP exception", () => { + const matrix = JSON.parse( + fs.readFileSync( + path.join(REPO_ROOT, "agents", "hermes", "host", "managed-tool-gateway-matrix.json"), + "utf8", + ), + ); + const vendorHosts = [ + "firecrawl-gateway.nousresearch.com", + "fal-queue-gateway.nousresearch.com", + "openai-audio-gateway.nousresearch.com", + "browser-use-gateway.nousresearch.com", + "modal-gateway.nousresearch.com", + ]; + + for (const [presetName, entry] of Object.entries(matrix) as Array< + [string, { brokerPath: string }] + >) { + const content = requirePresetContent(policies.loadPreset(presetName)); + const parsed = YAML.parse(content); + const policyEntries = Object.values(parsed.network_policies ?? {}) as Array<{ + endpoints?: Array<{ host?: string; port?: number }>; + }>; + const endpoints = policyEntries.flatMap((policy) => policy.endpoints ?? []); + const brokerEndpoint = endpoints.find( + (endpoint) => endpoint.host === "host.openshell.internal" && endpoint.port === 11436, + ); + expect(brokerEndpoint, `missing broker endpoint for ${presetName}`).toBeDefined(); + expect(JSON.stringify(brokerEndpoint)).toContain(entry.brokerPath); + for (const host of vendorHosts) { + expect(content).not.toContain(host); + } + if (presetName === "nous-browser") { + expect(content).toContain("*.cdp1.browser-use.com"); + } else { + expect(content).not.toContain("browser-use.com"); + } + } + }); }); describe("getPresetEndpoints", () => {