diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index 48881fce3a..9b4868e7ba 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -54,6 +54,7 @@ RUN chmod -R a+rX /opt/nemoclaw-hermes-plugin/ # Copy config generator 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 {} + @@ -88,6 +89,8 @@ ARG NEMOCLAW_TELEGRAM_CONFIG_B64=e30= # is never baked here — it flows through the OpenShell L7 proxy via the # WECHAT_BOT_TOKEN credential slot. ARG NEMOCLAW_WECHAT_CONFIG_B64=e30= +ARG NEMOCLAW_HERMES_TOOL_GATEWAY_BROKER=0 +ARG NEMOCLAW_HERMES_TOOL_GATEWAY_PRESETS_B64=W10= ARG NEMOCLAW_BUILD_ID=default ARG NEMOCLAW_DARWIN_VM_COMPAT=0 @@ -100,7 +103,9 @@ ENV NEMOCLAW_MODEL=${NEMOCLAW_MODEL} \ 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_WECHAT_CONFIG_B64=${NEMOCLAW_WECHAT_CONFIG_B64} + NEMOCLAW_WECHAT_CONFIG_B64=${NEMOCLAW_WECHAT_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 9c791fc176..6ebd2c0151 100644 --- a/agents/hermes/config/build-env.ts +++ b/agents/hermes/config/build-env.ts @@ -33,6 +33,10 @@ export type HermesBuildSettings = { baseUrl: string; providerKey: string; inferenceApi: string; + managedToolGateways: { + brokerEnabled: boolean; + presets: string[]; + }; messaging: { enabledChannels: Set; allowedIds: MessagingAllowedIds; @@ -51,6 +55,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 eaffc4ed39..fc1f71c2a1 100644 --- a/agents/hermes/config/hermes-config.ts +++ b/agents/hermes/config/hermes-config.ts @@ -2,9 +2,32 @@ // 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"; +const API_SERVER_TOOLSETS = [ + "web", + "browser", + "terminal", + "file", + "code_execution", + "vision", + "image_gen", + "skills", + "todo", + "memory", + "session_search", + "delegation", + "cronjob", + "nemoclaw", + "audio", +]; + export function buildHermesConfig(settings: HermesBuildSettings): Record { + const apiServerToolsets = [...API_SERVER_TOOLSETS]; const config: Record = { _config_version: 12, model: { @@ -31,6 +54,12 @@ 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 fced4bd91f..147869756c 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, WechatConfig } from "./build-env.ts"; +import { loadManagedToolGatewayMatrix } from "./managed-tool-gateway.ts"; // Maps each Hermes-supported channel to the in-sandbox env-var name(s) the // adapter reads. The values are the names Hermes expects — not the names @@ -23,9 +24,22 @@ export function buildMessagingEnvLines( allowedIds: MessagingAllowedIds, discordGuilds: DiscordGuilds, wechatConfig: WechatConfig, + 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"); + for (const preset of managedToolGatewayPresets) { + const entry = matrix[preset]; + if (!entry) { + throw new Error(`Unknown Hermes managed-tool gateway preset: ${preset}`); + } + 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 726fc122f9..97d8863810 100644 --- a/agents/hermes/generate-config.ts +++ b/agents/hermes/generate-config.ts @@ -42,6 +42,9 @@ function main(): void { settings.messaging.allowedIds, settings.messaging.discordGuilds, settings.messaging.wechatConfig, + 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 0000000000..f0f16d8801 --- /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.ts b/agents/hermes/host/tool-gateway-broker.ts new file mode 100755 index 0000000000..960ff9eecf --- /dev/null +++ b/agents/hermes/host/tool-gateway-broker.ts @@ -0,0 +1,645 @@ +#!/usr/bin/env node +// @ts-nocheck +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +/* global AbortSignal, 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, gives the sandbox only an opaque per-sandbox + * broker token, and keeps the raw refresh token in this host process after + * OAuth onboarding. 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"; +const HERMES_INFERENCE_PROVIDER_NAME = + process.env.HERMES_INFERENCE_PROVIDER_NAME || "hermes-provider"; +const HERMES_INFERENCE_CREDENTIAL_ENV = + process.env.HERMES_INFERENCE_CREDENTIAL_ENV || "OPENAI_API_KEY"; + +function readPositiveIntEnv(name, fallback, min) { + const parsed = parseInt(process.env[name] || String(fallback), 10); + if (!Number.isFinite(parsed)) return fallback; + return Math.max(min, parsed); +} + +const AGENT_KEY_MIN_TTL_SECONDS = readPositiveIntEnv( + "HERMES_INFERENCE_AGENT_KEY_MIN_TTL_SECONDS", + 1800, + 300, +); +const AGENT_KEY_REFRESH_INTERVAL_MS = readPositiveIntEnv( + "HERMES_INFERENCE_AGENT_KEY_REFRESH_INTERVAL_MS", + 600000, + 60_000, +); +const UPSTREAM_REQUEST_TIMEOUT_MS = readPositiveIntEnv( + "HERMES_TOOL_GATEWAY_UPSTREAM_TIMEOUT_MS", + 60_000, + 1000, +); +const DEFAULT_INFERENCE_BASE_URL = "https://inference-api.nousresearch.com/v1"; +const TRUSTED_INFERENCE_BASE_URLS = new Set([DEFAULT_INFERENCE_BASE_URL]); + +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 findStateByBrokerToken(brokerToken) { + const digest = sha256(brokerToken); + for (const file of stateFiles()) { + const loaded = loadStateFile(file); + if (!loaded) continue; + const brokerTokenHash = loaded.state.broker_token_sha256; + if (!brokerTokenHash) continue; + if (timingSafeEqualString(String(brokerTokenHash), 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 trimmed = auth.trim(); + const separator = trimmed.indexOf(" "); + if (separator > 0) { + const scheme = trimmed.slice(0, separator).toLowerCase(); + const token = trimmed.slice(separator + 1).trim(); + if ((scheme === "bearer" || scheme === "key") && token) return token; + } + } + 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 resolveRuntimeRefreshToken(loaded) { + const refreshToken = String(process.env[CREDENTIAL_ENV] || "").trim(); + if (!refreshToken) { + return null; + } + const expectedHash = String(loaded?.state?.refresh_token_sha256 || ""); + if (!expectedHash || !timingSafeEqualString(expectedHash, sha256(refreshToken))) { + return null; + } + return refreshToken; +} + +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 timestampExpiresSoon(isoTimestamp, skewMs = 300_000) { + if (typeof isoTimestamp !== "string" || !isoTimestamp.trim()) return true; + const ms = Date.parse(isoTimestamp); + if (!Number.isFinite(ms)) return true; + return ms - Date.now() < skewMs; +} + +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", + }); + } +} + +function updateOpenshellInferenceProvider(apiKey, baseUrl) { + const args = [ + "provider", + "update", + HERMES_INFERENCE_PROVIDER_NAME, + "--credential", + HERMES_INFERENCE_CREDENTIAL_ENV, + ]; + if (typeof baseUrl === "string" && baseUrl.trim()) { + args.push("--config", `OPENAI_BASE_URL=${baseUrl.trim()}`); + } + const result = spawnSync(OPENSHELL_BIN, args, { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + env: { ...process.env, [HERMES_INFERENCE_CREDENTIAL_ENV]: apiKey }, + timeout: 30_000, + }); + if (result.status !== 0) { + throw Object.assign(new Error("openshell_inference_provider_update_failed"), { + code: "openshell_inference_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; + } + process.env[CREDENTIAL_ENV] = nextRefreshToken; + + return payload.access_token; +} + +function agentKeyExpiresAt() { + return new Date(Date.now() + AGENT_KEY_MIN_TTL_SECONDS * 1000).toISOString(); +} + +function trustedInferenceBaseUrl(value) { + const normalized = String(value || "").trim().replace(/\/+$/, ""); + for (const candidate of TRUSTED_INFERENCE_BASE_URLS) { + if (normalized === candidate) return candidate; + } + return DEFAULT_INFERENCE_BASE_URL; +} + +async function mintAgentKey(accessToken) { + const resp = await fetch(`${PORTAL_BASE_URL}/api/oauth/agent-key`, { + method: "POST", + headers: { + Accept: "application/json", + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ min_ttl_seconds: AGENT_KEY_MIN_TTL_SECONDS }), + }); + if (!resp.ok) { + const code = + resp.status === 400 || resp.status === 401 ? "reauth_required" : "agent_key_failed"; + throw Object.assign(new Error(`agent_key_failed_http_${resp.status}`), { code }); + } + const payload = await resp.json(); + if (!payload?.api_key) { + throw Object.assign(new Error("agent_key_response_missing_api_key"), { + code: "agent_key_failed", + }); + } + return payload; +} + +async function ensureInferenceAgentKey(loaded, refreshToken, options = {}) { + if (!options.force && !timestampExpiresSoon(loaded?.state?.inference_agent_key_expires_at)) { + return false; + } + const accessToken = await refreshAccessToken(refreshToken, loaded); + const agentKey = await mintAgentKey(accessToken); + const inferenceBaseUrl = trustedInferenceBaseUrl(agentKey.inference_base_url); + updateOpenshellInferenceProvider(agentKey.api_key, inferenceBaseUrl); + const nextState = { + ...loaded.state, + inference_provider_name: HERMES_INFERENCE_PROVIDER_NAME, + inference_credential_env: HERMES_INFERENCE_CREDENTIAL_ENV, + inference_base_url: inferenceBaseUrl, + inference_agent_key_expires_at: agentKeyExpiresAt(), + inference_agent_key_rotated_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; + atomicWriteJson(loaded.file, nextState); + loaded.state = nextState; + return true; +} + +async function refreshManagedInferenceForRuntimeCredentials(options = {}) { + for (const file of stateFiles()) { + const loaded = loadStateFile(file); + if (!loaded) continue; + const refreshToken = resolveRuntimeRefreshToken(loaded); + if (!refreshToken) continue; + try { + await ensureInferenceAgentKey(loaded, refreshToken, options); + } catch (err) { + const code = errorCode(err) || "agent_key_refresh_failed"; + console.error(`Hermes inference provider refresh failed: ${code}`); + } + } +} + +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; +} + +function isAbortError(err) { + return ( + err && + typeof err === "object" && + (err.name === "AbortError" || err.name === "TimeoutError" || err.code === "ABORT_ERR") + ); +} + +async function handleProxy(req, res, route) { + const brokerToken = extractRefreshToken(req); + if (!brokerToken) { + sendText( + res, + 401, + "Hermes managed tools require Nous Portal OAuth. Re-run nemohermes onboard --resume.", + ); + return; + } + + const loaded = findStateByBrokerToken(brokerToken) || findStateByRefreshToken(brokerToken); + if (!loaded) { + sendText( + res, + 401, + "Unknown Hermes tool-gateway credential. Re-run nemohermes onboard --resume.", + ); + return; + } + const refreshToken = loaded.state.broker_token_sha256 + ? resolveRuntimeRefreshToken(loaded) + : brokerToken; + if (!refreshToken) { + sendText( + res, + 401, + "Hermes managed-tool broker needs fresh host OAuth. Re-run nemohermes onboard --resume.", + ); + return; + } + + let accessToken; + try { + accessToken = await refreshAccessToken(refreshToken, loaded); + ensureInferenceAgentKey(loaded, refreshToken).catch((err) => { + const code = errorCode(err) || "agent_key_refresh_failed"; + console.error(`Hermes inference provider refresh failed: ${code}`); + }); + } 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", + signal: AbortSignal.timeout(UPSTREAM_REQUEST_TIMEOUT_MS), + }); + } catch (err) { + if (isAbortError(err)) { + sendText(res, 504, "upstream gateway request timed out"); + return; + } + 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; + } + if (req.url === "/internal/refresh-inference") { + const remote = req.socket?.remoteAddress || ""; + if (!["127.0.0.1", "::1", "::ffff:127.0.0.1"].includes(remote)) { + sendText(res, 404, "unknown Hermes managed-tool gateway route"); + return; + } + await refreshManagedInferenceForRuntimeCredentials({ force: true }); + sendJson(res, 200, { ok: true }); + 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}`); + refreshManagedInferenceForRuntimeCredentials().catch((err) => { + const code = errorCode(err) || "agent_key_refresh_failed"; + console.error(`Hermes inference provider refresh failed: ${code}`); + }); +}); + +const refreshTimer = setInterval(() => { + refreshManagedInferenceForRuntimeCredentials().catch((err) => { + const code = errorCode(err) || "agent_key_refresh_failed"; + console.error(`Hermes inference provider refresh failed: ${code}`); + }); +}, AGENT_KEY_REFRESH_INTERVAL_MS); +refreshTimer.unref?.(); + +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 6823d956de..c246fc52c3 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,952 @@ 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 ipaddress import json import os import subprocess +import sys +from dataclasses import replace as dataclass_replace +from types import SimpleNamespace +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" +_URL_SAFETY_PATCH_ATTR = "_nemoclaw_broker_url_safety_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", +) + +_BROKER_ALWAYS_BLOCKED_HOSTNAMES = { + "localhost", + "metadata.google.internal", + "metadata.goog", +} +_BROKER_ALWAYS_BLOCKED_SUFFIXES = ( + ".internal", + ".lan", + ".local", + ".localhost", +) +_BROKER_ALWAYS_BLOCKED_IPS = { + ipaddress.ip_address("169.254.169.254"), + ipaddress.ip_address("169.254.170.2"), + ipaddress.ip_address("169.254.169.253"), + ipaddress.ip_address("fd00:ec2::254"), + ipaddress.ip_address("100.100.100.200"), +} +_BROKER_CGNAT_NETWORK = ipaddress.ip_network("100.64.0.0/10") + + +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: + # Hermes may load this plugin before hermes_cli.config is importable. + 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: + # Runtime env still works when Hermes' optional dotenv loader is absent. + 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 _broker_resolve_managed_tool_gateway(vendor, gateway_builder=None, token_reader=None): + if not _broker_mode_enabled(): + return None + resolved_gateway_builder = gateway_builder or _broker_gateway_url + resolved_token_reader = token_reader or _broker_user_token + gateway_origin = str(resolved_gateway_builder(vendor) or "").strip().rstrip("/") + nous_user_token = str(resolved_token_reader() or "").strip() + if not gateway_origin or not nous_user_token: + return None + return SimpleNamespace( + vendor=vendor, + gateway_origin=gateway_origin, + nous_user_token=nous_user_token, + managed_mode=True, + ) + + +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 _broker_safe_url(url): + """DNS-independent URL preflight for managed gateway mode. + + Hermes' normal SSRF guard performs sandbox-local DNS resolution and fails + closed on DNS errors. In NemoClaw broker mode the sandbox never connects to + the target URL directly; it sends the URL to a host broker and the Nous + managed gateway enforces upstream egress. This guard keeps cheap local + protection for obvious metadata/private literal targets while allowing + normal DNS hostnames through even when sandbox DNS is unavailable. + """ + try: + parsed = urlparse(str(url or "")) + scheme = (parsed.scheme or "").lower() + hostname = (parsed.hostname or "").strip().lower().rstrip(".") + if scheme not in {"http", "https"} or not hostname: + return False + if hostname in _BROKER_ALWAYS_BLOCKED_HOSTNAMES or hostname.endswith( + _BROKER_ALWAYS_BLOCKED_SUFFIXES, + ): + return False + try: + ip = ipaddress.ip_address(hostname) + except ValueError: + labels = hostname.split(".") + if len(labels) < 2: + return False + if all(label.isdigit() for label in labels): + return False + return True + if ip in _BROKER_ALWAYS_BLOCKED_IPS: + return False + if ip.version == 4 and ip in _BROKER_CGNAT_NETWORK: + return False + return not ( + ip.is_private + or ip.is_loopback + or ip.is_link_local + or ip.is_reserved + or ip.is_multicast + or ip.is_unspecified + ) + except Exception: + return False + + +def _install_broker_url_safety_patch(): + """Avoid sandbox-DNS SSRF false negatives for brokered web/browser tools.""" + if not _broker_mode_enabled(): + return False + + patched = False + try: + module = __import__("tools.url_safety", fromlist=["is_safe_url"]) + if not getattr(module, _URL_SAFETY_PATCH_ATTR, False): + module.is_safe_url = _broker_safe_url + setattr(module, _URL_SAFETY_PATCH_ATTR, True) + patched = True + except Exception: + # Some Hermes builds do not ship web URL-safety helpers. + pass + + web_tools = sys.modules.get("tools.web_tools") + if web_tools is not None and hasattr(web_tools, "is_safe_url"): + setattr(web_tools, "is_safe_url", _broker_safe_url) + patched = True + + browser_tool = sys.modules.get("tools.browser_tool") + if browser_tool is not None and hasattr(browser_tool, "_is_safe_url"): + setattr(browser_tool, "_is_safe_url", _broker_safe_url) + if hasattr(browser_tool, "_allow_private_urls_resolved"): + setattr(browser_tool, "_allow_private_urls_resolved", False) + patched = True + + return patched + + +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 hasattr(module, "resolve_managed_tool_gateway"): + setattr(module, "resolve_managed_tool_gateway", _broker_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 = _broker_resolve_managed_tool_gateway("openai-audio") + if managed_gateway is None and callable(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: + # Fall back to Hermes' native audio config when broker config is incomplete. + 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: + # Some handle types are not dataclasses; try constructor replacement next. + 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: + # Fall back to in-place attribute replacement for mutable handle objects. + 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: + # Tunnel teardown is best-effort after the client disconnects. + pass + try: + if upstream is not None: + upstream.close() + except Exception: + # Tunnel teardown is best-effort after the upstream disconnects. + 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 _reap_browser_use_cdp_tunnel(proc): + if proc.poll() is None: + proc.terminate() + try: + proc.wait(timeout=1) + except Exception: + try: + if proc.poll() is None: + proc.kill() + proc.wait(timeout=1) + except Exception: + # Tunnel cleanup must not fail plugin shutdown. + pass + + +def _cleanup_browser_use_cdp_tunnels(): + for remote_url, (proc, _url) in list(_BROWSER_USE_CDP_TUNNELS.items()): + _reap_browser_use_cdp_tunnel(proc) + _BROWSER_USE_CDP_TUNNELS.pop(remote_url, None) + + +atexit.register(_cleanup_browser_use_cdp_tunnels) + + +def _start_browser_use_cdp_tunnel(cdp_url): + for remote_url, (proc, _url) in list(_BROWSER_USE_CDP_TUNNELS.items()): + if proc.poll() is not None: + _reap_browser_use_cdp_tunnel(proc) + _BROWSER_USE_CDP_TUNNELS.pop(remote_url, None) + + 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(): + _reap_browser_use_cdp_tunnel(proc) + 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) + setattr(module, "resolve_managed_tool_gateway", _broker_resolve_managed_tool_gateway) + + patched = _install_audio_gateway_preference_patch() or patched + patched = _install_transcription_gateway_patch() or patched + patched = _install_fal_queue_handle_patch() or patched + patched = _install_broker_url_safety_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.""" @@ -78,6 +1018,7 @@ def _get_sandbox_info(): if result.returncode == 0: gateway_ok = True except Exception: + # Status output should still render if the local health probe fails. pass return { @@ -90,6 +1031,93 @@ 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." + ) + agent_identity_line = ( + "- You are Hermes Agent running in a NemoClaw-managed OpenShell sandbox, " + + "not a host-only assistant." + ) + child_tool_line = ( + "- 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." + ) + config_line = ( + f"- Parent Hermes sandbox config lives under {hermes_home} and " + + "/sandbox/.hermes when available. Use nemoclaw_status or " + + "nemoclaw_info for NemoClaw environment questions." + ) + tools_line = ( + "- NemoClaw tools available: nemoclaw_status, nemoclaw_info, " + + "nemoclaw_reload_skills, transcribe_audio." + ) + + return "\n".join( + [ + "NemoClaw runtime context:", + agent_identity_line, + child_tool_line, + config_line, + f"- NemoClaw provider state: model={info['model']}, " + f"provider={info['provider']}, endpoint={info['base_url']}, " + f"gateway={info['gateway']}.", + tools_line, + 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 +1139,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 +1214,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 +1251,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 +1303,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 a5669713ce..d2ab157182 100644 --- a/agents/hermes/policy-additions.yaml +++ b/agents/hermes/policy-additions.yaml @@ -37,6 +37,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: @@ -81,7 +99,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: @@ -99,62 +120,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* } diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 133f695658..3e05b7cc30 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -1162,6 +1162,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. @@ -1173,6 +1176,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 0000000000..20700db56c --- /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 0000000000..66c21ec1a3 --- /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 0000000000..b657358135 --- /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 0000000000..811246df8e --- /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 0000000000..f0d065842a --- /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/inference-set.test.ts b/src/lib/actions/inference-set.test.ts index 92d8b6f278..ae091f7adf 100644 --- a/src/lib/actions/inference-set.test.ts +++ b/src/lib/actions/inference-set.test.ts @@ -81,6 +81,7 @@ function baseSession(overrides: Partial = {}): Session { messagingChannelConfig: null, disabledChannels: null, migratedLegacyValueHashes: null, + hermesToolGateways: null, gpuPassthrough: false, telegramConfig: null, wechatConfig: null, diff --git a/src/lib/actions/sandbox/connect.ts b/src/lib/actions/sandbox/connect.ts index 06fd31d01b..b8c5cf7259 100644 --- a/src/lib/actions/sandbox/connect.ts +++ b/src/lib/actions/sandbox/connect.ts @@ -555,6 +555,23 @@ function ensureSandboxInferenceRouteOrExit( return result.sandbox; } +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); @@ -704,6 +721,7 @@ export async function connectSandbox( // cluster-wide inference.local route may still point at the other provider. // After the sandbox is Ready, verify and recover the route before SSH. sb = ensureSandboxInferenceRouteOrExit(sandboxName); + maybeEnsureHermesToolGatewayBroker(sb); // Print a one-shot hint before dropping the user into the sandbox // shell so a fresh user knows the first thing to type. Without this, diff --git a/src/lib/actions/sandbox/rebuild.ts b/src/lib/actions/sandbox/rebuild.ts index 27ce3dc11f..276a339654 100644 --- a/src/lib/actions/sandbox/rebuild.ts +++ b/src/lib/actions/sandbox/rebuild.ts @@ -542,6 +542,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; // Snapshot the operator's paused channel set BEFORE `removeSandboxRegistryEntry` @@ -575,6 +595,7 @@ export async function rebuildSandbox( s.messagingChannels = rebuildMessagingChannels; s.messagingChannelConfig = rebuildMessagingChannelConfig; s.disabledChannels = rebuildDisabledChannels; + 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 @@ -702,6 +723,9 @@ export async function rebuildSandbox( ...(hasRebuildMessagingChannels ? { messagingChannels: [...rebuildMessagingChannels] } : {}), disabledChannels: rebuildDisabledChannels.length > 0 ? [...rebuildDisabledChannels] : undefined, + ...(hasRebuildHermesToolGateways + ? { hermesToolGateways: [...rebuildHermesToolGateways] } + : {}), ...(sb.providerCredentialHashes ? { providerCredentialHashes: sb.providerCredentialHashes } : {}), }; if (Object.keys(preservedRegistryFields).length > 0) { diff --git a/src/lib/actions/sandbox/status.ts b/src/lib/actions/sandbox/status.ts index 8ff1dad99e..3254c5770a 100644 --- a/src/lib/actions/sandbox/status.ts +++ b/src/lib/actions/sandbox/status.ts @@ -88,9 +88,27 @@ function printInferenceProbeLine(probe: ProviderHealthStatus): void { console.log(` ${probe.detail}`); } +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 62ceea134a..6882f1c176 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,84 @@ 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 { providerName: `${sandboxName}-hermes-tool-gateway`, brokerToken: "broker-3" }; + }, + ensureHermesToolGatewayBroker: (options: { refreshToken?: string }) => { + expect(options.refreshToken).toBe("refresh-3"); + return 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 5c356d8b24..172ce6a2fb 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, + ) => { providerName: string; brokerToken: string }; + ensureHermesToolGatewayBroker: (options?: { refreshToken?: string }) => 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({ refreshToken: tokens.refresh_token })) { + 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 0000000000..710ffa579a --- /dev/null +++ b/src/lib/hermes-tool-gateway-broker.ts @@ -0,0 +1,347 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// @ts-nocheck +// +// 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.ts", +); +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 generateHermesToolGatewayBrokerToken() { + return `nc_broker_${crypto.randomBytes(32).toString("base64url")}`; +} + +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 readHermesToolGatewayProviderState(sandboxName) { + const file = getHermesToolGatewayStatePath(sandboxName); + try { + const parsed = JSON.parse(fs.readFileSync(file, "utf8")); + return parsed && typeof parsed === "object" ? parsed : null; + } catch { + return null; + } +} + +function getHermesToolGatewayBrokerToken(sandboxName) { + const state = readHermesToolGatewayProviderState(sandboxName); + const token = state && typeof state.broker_token === "string" ? state.broker_token.trim() : ""; + return token || null; +} + +function persistHermesToolGatewayProviderState(sandboxName, refreshToken, brokerToken = null) { + const file = getHermesToolGatewayStatePath(sandboxName); + const previous = readHermesToolGatewayProviderState(sandboxName); + const normalizedBrokerToken = + typeof brokerToken === "string" && brokerToken.trim() + ? brokerToken.trim() + : typeof previous?.broker_token === "string" && previous.broker_token.trim() + ? previous.broker_token.trim() + : generateHermesToolGatewayBrokerToken(); + atomicWriteJson(file, { + version: 1, + sandbox: validateName(sandboxName, "sandbox name"), + provider_name: getHermesToolGatewayProviderName(sandboxName), + credential_env: HERMES_TOOL_GATEWAY_REFRESH_CREDENTIAL_ENV, + broker_token: normalizedBrokerToken, + broker_token_sha256: hashRefreshToken(normalizedBrokerToken), + 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, brokerToken: normalizedBrokerToken }; +} + +function registerHermesToolGatewayRefreshProvider(sandboxName, refreshToken, runOpenshell) { + const normalized = String(refreshToken || "").trim(); + if (!normalized) { + throw new Error("Hermes tool gateway refresh credential is empty"); + } + const state = 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, brokerToken: state.brokerToken }; +} + +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.ts")); +} + +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(refreshToken) { + ensurePrivateDir(HERMES_TOOL_GATEWAY_STATE_DIR); + const credentialEnv = {}; + if (typeof refreshToken === "string" && refreshToken.trim()) { + credentialEnv[HERMES_TOOL_GATEWAY_REFRESH_CREDENTIAL_ENV] = refreshToken.trim(); + } + const child = spawn(process.execPath, ["--experimental-strip-types", 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", + ...credentialEnv, + }), + }); + child.unref(); + writePid(child.pid); + writeBrokerHash(brokerRuntimeHash()); + return child.pid || null; +} + +function ensureHermesToolGatewayBroker(options = {}) { + const refreshToken = + typeof options.refreshToken === "string" && options.refreshToken.trim() + ? options.refreshToken.trim() + : ""; + if (refreshToken) { + killStaleHermesToolGatewayBroker(); + const nextPid = spawnHermesToolGatewayBroker(refreshToken); + for (let attempt = 0; attempt < 20; attempt++) { + if (isHermesToolGatewayBrokerProcess(nextPid) && isHermesToolGatewayBrokerHealthy()) { + brokerStartedThisRun = true; + return true; + } + sleep(250); + } + return false; + } + + 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; + } + // Raw Nous OAuth stays out of durable ~/.nemoclaw state. If the broker is + // not already healthy, a fresh OAuth run must provide the refresh token. + 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, + generateHermesToolGatewayBrokerToken, + getHermesToolGatewayProviderName, + getHermesToolGatewayStatePath, + getHermesToolGatewayBrokerToken, + 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 7b6ebd354d..b2a982ba96 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 af0dad416f..2c8168cf42 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 f991f2accc..4e7e38f410 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -189,6 +189,10 @@ const { const onboardProviders = require("./onboard/providers"); const hermesProviderAuth = require("./hermes-provider-auth"); +function getHermesToolGatewayBroker(): any { + return require("./hermes-tool-gateway-broker"); +} + type RemoteProviderConfigEntry = { label: string; providerName: string; @@ -346,6 +350,12 @@ import { sanitizeMessagingChannelConfig, } from "./messaging-channel-config"; import { streamGatewayStart } from "./onboard/gateway"; +import { + HERMES_TOOL_GATEWAY_PRESET_NAMES, + mergeRequiredHermesToolGatewayPolicyPresets, + setupHermesToolGateways, + stringSetsEqual, +} from "./onboard/hermes-managed-tools"; import { filterEnabledChannelsByAgent, getAvailableMessagingChannelsForAgent, @@ -360,6 +370,12 @@ import type { } from "./onboard/openshell-install"; import { decidePolicyCarryForward } from "./onboard/policy-carryforward"; import { getSuggestedPolicyPresets } from "./onboard/policy-presets"; +import { + computeSetupPresetSuggestions as computeSetupPresetSuggestionsImpl, + setupPoliciesWithSelection as setupPoliciesWithSelectionImpl, + type SetupPolicySelectionOptions, + type SetupPresetSuggestionOptions, +} from "./onboard/policy-selection"; import { getResumeSandboxGpuOverrides, resolveSandboxGpuConfig, @@ -1349,6 +1365,17 @@ const { const { hydrateCredentialEnv }: typeof import("./onboard/credential-env") = require("./onboard/credential-env"); +function normalizeHermesToolGatewaySelections(value: unknown): string[] { + if (!Array.isArray(value)) return []; + const selected = new Set(); + for (const preset of value) { + if (typeof preset === "string" && HERMES_TOOL_GATEWAY_PRESET_NAMES.has(preset)) { + selected.add(preset); + } + } + return [...selected].sort(); +} + const { summarizeCurlFailure, summarizeProbeFailure, @@ -1481,6 +1508,30 @@ async function ensureHermesNousApiKeyEnv(): Promise { return key; } +function openshellResultMessage(result: { + stdout?: string | Buffer | null; + stderr?: string | Buffer | null; +}): string { + return compactText(redact(`${result.stderr || ""} ${result.stdout || ""}`)); +} + +function checkHermesProviderStoreReachable( + runOpenshellImpl: typeof runOpenshell = runOpenshell, +): { ok: true } | { ok: false; message: string } { + const result = runOpenshellImpl(["provider", "list"], { + ignoreError: true, + stdio: ["ignore", "pipe", "pipe"], + timeout: 10_000, + }); + if (result.status === 0) return { ok: true }; + return { + ok: false, + message: + openshellResultMessage(result) || + "OpenShell provider storage is unreachable; the gateway may be stopped or refusing connections.", + }; +} + async function selectOnboardAgent({ agentFlag = null, session = null, @@ -4708,6 +4759,7 @@ async function createSandbox( agent: AgentDefinition | null = null, controlUiPort: number | null = null, sandboxGpuConfig: SandboxGpuConfig | null = null, + hermesToolGateways: string[] = [], ) { step(6, 8, "Creating sandbox"); @@ -4968,6 +5020,10 @@ async function createSandbox( const selectionDrift = getSelectionDrift(sandboxName, provider, model, { runOpenshell }); const confirmedSelectionDrift = selectionDrift.changed && !selectionDrift.unknown; const sandboxGpuDrift = hasSandboxGpuDrift(sandboxName, effectiveSandboxGpuConfig); + const recordedHermesToolGateways = normalizeHermesToolGatewaySelections( + 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 @@ -4981,7 +5037,8 @@ async function createSandbox( !recreateForAgentDrift && !needsProviderMigration && !sandboxGpuDrift && - !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 @@ -5163,6 +5220,8 @@ async function createSandbox( note(` Sandbox '${sandboxName}' exists — recreating to apply model/provider change.`); } else if (sandboxGpuDrift) { note(` Sandbox '${sandboxName}' exists — recreating to apply sandbox GPU settings.`); + } 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") { @@ -5337,7 +5396,11 @@ async function createSandbox( const initialSandboxPolicy = prepareInitialSandboxCreatePolicy( basePolicyPath, activeMessagingChannels, - { directGpu: effectiveSandboxGpuConfig.sandboxGpuEnabled, dockerGpuPatch: useDockerGpuPatch }, + { + directGpu: effectiveSandboxGpuConfig.sandboxGpuEnabled, + dockerGpuPatch: useDockerGpuPatch, + additionalPresets: hermesToolGateways, + }, ); if (initialSandboxPolicy.cleanup) { process.on("exit", initialSandboxPolicy.cleanup); @@ -5370,6 +5433,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(); @@ -5475,7 +5542,16 @@ async function createSandbox( effectiveSandboxGpuConfig, provider, { dockerDriverGateway: isLinuxDockerDriverGatewayEnabled(), log: console.log }, - ); + ); + let hermesToolBrokerToken: string | null = null; + if (hermesToolGateways.length > 0) { + hermesToolBrokerToken = getHermesToolGatewayBroker().getHermesToolGatewayBrokerToken(sandboxName); + if (!hermesToolBrokerToken) { + console.error(" Hermes managed tools were selected, but no broker token was prepared."); + console.error(" Re-run OAuth setup with managed tools enabled and retry onboarding."); + process.exit(1); + } + } patchStagedDockerfile( stagedDockerfile, model, @@ -5494,6 +5570,7 @@ async function createSandbox( // compatibility path disabled unless a future VM-specific flow opts in. false, sandboxInferenceBaseUrlOverride, + hermesToolGateways, ); // Only pass non-sensitive env vars to the sandbox. Credentials flow through // OpenShell providers — the gateway injects them as placeholders and the L7 @@ -5539,6 +5616,10 @@ async function createSandbox( if (sandboxProxyPort && isValidProxyPort(sandboxProxyPort)) { envArgs.push(formatEnvAssignment("NEMOCLAW_PROXY_PORT", sandboxProxyPort)); } + if (hermesToolBrokerToken) { + // Runtime-only: do not bake the per-sandbox broker token into image layers. + envArgs.push(formatEnvAssignment("TOOL_GATEWAY_USER_TOKEN", hermesToolBrokerToken)); + } if (webSearchConfig?.fetchEnabled) { const braveKey = getCredential(webSearch.BRAVE_API_KEY_ENV) || process.env[webSearch.BRAVE_API_KEY_ENV]; @@ -5785,6 +5866,7 @@ async function createSandbox( enabledChannels != null ? [...new Set(enabledChannels)] : activeMessagingChannels, messagingChannelConfig: messagingChannelConfig || undefined, disabledChannels: disabledChannels.length > 0 ? [...disabledChannels] : undefined, + hermesToolGateways: hermesToolGateways.length > 0 ? [...hermesToolGateways] : undefined, dashboardPort: actualDashboardPort, }); registry.setDefault(sandboxName); @@ -6107,6 +6189,7 @@ async function setupNim( endpointUrl: string | null; credentialEnv: string | null; hermesAuthMethod: HermesAuthMethod | null; + hermesToolGateways: string[]; preferredInferenceApi: string | null; nimContainer: string | null; }> { @@ -6118,6 +6201,7 @@ async function setupNim( let endpointUrl: string | null = REMOTE_PROVIDER_CONFIG.build.endpointUrl; let credentialEnv: string | null = REMOTE_PROVIDER_CONFIG.build.credentialEnv; let hermesAuthMethod: HermesAuthMethod | null = null; + let hermesToolGateways: string[] = []; let preferredInferenceApi: string | null = null; // Detect local inference options. Bound curl with --connect-timeout/--max-time @@ -6457,6 +6541,7 @@ async function setupNim( } if (selected.key !== "hermesProvider") { hermesAuthMethod = null; + hermesToolGateways = []; } if (REMOTE_PROVIDER_CONFIG[selected.key]) { @@ -6547,6 +6632,15 @@ async function setupNim( } else { credentialEnv = remoteConfig.credentialEnv; } + const recordedHermesToolGateways = sandboxName + ? normalizeHermesToolGatewaySelections(registry.getSandbox(sandboxName)?.hermesToolGateways) + : null; + hermesToolGateways = await setupHermesToolGateways( + provider, + hermesAuthMethod, + recordedHermesToolGateways, + { prompt, note, isNonInteractive }, + ); const defaultModel = requestedModel || @@ -7353,6 +7447,7 @@ async function setupNim( endpointUrl, credentialEnv, hermesAuthMethod, + hermesToolGateways, preferredInferenceApi, nimContainer, }; @@ -7367,6 +7462,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 }); @@ -7378,11 +7474,26 @@ async function setupInference( (credentialEnv === HERMES_NOUS_API_KEY_CREDENTIAL_ENV ? HERMES_AUTH_METHOD_API_KEY : HERMES_AUTH_METHOD_OAUTH); + const providerStore = checkHermesProviderStoreReachable(runOpenshell); + if (!providerStore.ok) { + console.error(" ✗ OpenShell provider storage is unreachable."); + console.error(` ${providerStore.message}`); + console.error(" Restart or recreate the OpenShell gateway, then rerun onboarding."); + if (isNonInteractive()) process.exit(1); + return { retry: "selection" }; + } 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) { @@ -7398,6 +7509,7 @@ async function setupInference( allowInteractiveLogin: !isNonInteractive(), runOpenshell, baseUrl: endpointUrl || undefined, + toolGatewayPresets: hermesToolGateways, }); if (!state) { const authLabel = hermesAuthMethodLabel(resolvedHermesAuthMethod); @@ -8490,208 +8602,39 @@ async function presetsCheckboxSelector( function computeSetupPresetSuggestions( tierName: string, - options: { - enabledChannels?: string[] | null; - webSearchConfig?: WebSearchConfig | null; - provider?: string | null; - knownPresetNames?: string[] | null; - webSearchSupported?: boolean | null; - } = {}, + options: SetupPresetSuggestionOptions = {}, ): string[] { - const { enabledChannels = null, webSearchConfig = null, provider = null } = options; - const known = Array.isArray(options.knownPresetNames) ? new Set(options.knownPresetNames) : null; - const supportOptions = { webSearchSupported: options.webSearchSupported }; - const suggestions = tiers - .resolveTierPresets(tierName) - .map((p) => p.name) - .filter((name) => policies.setupPolicyPresetSupported(name, supportOptions)) - .filter((name) => !known || known.has(name)); - const add = (name: string) => { - if (!policies.setupPolicyPresetSupported(name, supportOptions)) return; - if (suggestions.includes(name)) return; - if (known && !known.has(name)) return; - suggestions.push(name); - }; - if (webSearchConfig) add("brave"); - if (provider && LOCAL_INFERENCE_PROVIDERS.includes(provider)) add("local-inference"); - if (Array.isArray(enabledChannels)) { - for (const channel of enabledChannels) add(channel); - } - return suggestions; + return computeSetupPresetSuggestionsImpl( + { policies, tiers, localInferenceProviders: LOCAL_INFERENCE_PROVIDERS }, + tierName, + options, + ); } async function setupPoliciesWithSelection( sandboxName: string, - options: { - selectedPresets?: string[] | null; - onSelection?: ((policyPresets: string[]) => void) | null; - webSearchConfig?: WebSearchConfig | null; - enabledChannels?: string[] | null; - provider?: string | null; - knownPresetNames?: string[]; - webSearchSupported?: boolean | null; - } = {}, + options: SetupPolicySelectionOptions = {}, ) { - const selectedPresets = Array.isArray(options.selectedPresets) ? options.selectedPresets : null; - const onSelection = typeof options.onSelection === "function" ? options.onSelection : null; - const webSearchConfig = options.webSearchConfig || null; - const enabledChannels = Array.isArray(options.enabledChannels) ? options.enabledChannels : null; - const provider = options.provider || null; - - step(8, 8, "Policy presets"); - - const supportOptions = { webSearchSupported: options.webSearchSupported }; - const allPresets = policies.listSetupPolicyPresets(sandboxName, supportOptions); - const knownPresets = new Set(allPresets.map((p) => p.name)); - const customPresetNames = new Set( - policies.listCustomPresets(sandboxName).map((p: { name: string }) => p.name), - ); - const currentAppliedPresets = policies.getAppliedPresets(sandboxName); - const selectablePresets = [ - ...allPresets, - ...currentAppliedPresets.map((name) => ({ name })), - ]; - const applied = policies.clampSetupPolicyPresetNames( - currentAppliedPresets, - selectablePresets, - supportOptions, - customPresetNames, + const selectedTier = await setupPoliciesWithSelectionImpl( + { + policies, + tiers, + localInferenceProviders: LOCAL_INFERENCE_PROVIDERS, + step, + note, + isNonInteractive, + waitForSandboxReady, + syncPresetSelection, + selectPolicyTier, + setPolicyTier: (sandbox, tierName) => registry.updateSandbox(sandbox, { policyTier: tierName }), + selectTierPresetsAndAccess, + parsePolicyPresetEnv, + env: process.env, + }, + sandboxName, + options, ); - const filterSupportedPresetNames = (presetNames: string[]) => - presetNames.filter( - (name) => - customPresetNames.has(name) || policies.setupPolicyPresetSupported(name, supportOptions), - ); - let chosen = selectedPresets !== null - ? policies.clampSetupPolicyPresetNames( - selectedPresets, - selectablePresets, - supportOptions, - customPresetNames, - ) - : null; - - // Resume path: caller supplies the preset list from a previous run. - if (selectedPresets !== null) { - const resumeSelection = chosen || []; - if (onSelection) onSelection(resumeSelection); - if (!waitForSandboxReady(sandboxName)) { - console.error(` Sandbox '${sandboxName}' was not ready for policy application.`); - process.exit(1); - } - note(` [resume] Reapplying policy presets: ${resumeSelection.join(", ")}`); - syncPresetSelection(sandboxName, currentAppliedPresets, resumeSelection); - return resumeSelection; - } - - // Tier selection — determines the default preset list for this install. - const tierName = await selectPolicyTier(); - registry.updateSandbox(sandboxName, { policyTier: tierName }); - const suggestions = computeSetupPresetSuggestions(tierName, { - enabledChannels, - webSearchConfig, - provider, - knownPresetNames: allPresets.map((p) => p.name), - webSearchSupported: options.webSearchSupported, - }); - - if (isNonInteractive()) { - const policyMode = (process.env.NEMOCLAW_POLICY_MODE || "suggested").trim().toLowerCase(); - chosen = suggestions; - let isAuthoritative = false; - - if (policyMode === "skip" || policyMode === "none" || policyMode === "no") { - note(" [non-interactive] Skipping policy presets."); - return []; - } - - if (policyMode === "custom" || policyMode === "list") { - const envPresets = parsePolicyPresetEnv(process.env.NEMOCLAW_POLICY_PRESETS || ""); - if (envPresets.length === 0) { - console.error(" NEMOCLAW_POLICY_PRESETS is required when NEMOCLAW_POLICY_MODE=custom."); - process.exit(1); - } - chosen = filterSupportedPresetNames(envPresets); - isAuthoritative = true; - } else if (policyMode === "suggested" || policyMode === "default" || policyMode === "auto") { - const envPresets = parsePolicyPresetEnv(process.env.NEMOCLAW_POLICY_PRESETS || ""); - if (envPresets.length > 0) chosen = filterSupportedPresetNames(envPresets); - } else { - // #2429: step 8/8 runs after the sandbox is created. Exiting here left - // the sandbox with no presets. Warn, optionally suggest the intended - // variable, and fall through to the tier-derived suggestions list. - console.warn(` Unsupported NEMOCLAW_POLICY_MODE: ${policyMode}`); - console.warn( - " Valid values: suggested, custom, skip (aliases: default/auto, list, none/no).", - ); - if (tiers.getTier(policyMode)) { - console.warn( - ` '${policyMode}' is a policy tier — did you mean NEMOCLAW_POLICY_TIER=${policyMode}?`, - ); - } - console.warn(` Falling back to suggested presets for tier '${tierName}'.`); - } - - const invalidPresets = chosen.filter((name) => !knownPresets.has(name)); - if (invalidPresets.length > 0) { - console.error(` Unknown policy preset(s): ${invalidPresets.join(", ")}`); - process.exit(1); - } - - // Suggested mode is additive: presets the user added beyond the tier - // defaults (typically via `nemoclaw policy-add`, including custom - // presets loaded with `--from-file`/`--from-dir`) must survive a - // re-onboard. `applied` comes from the registry and is the source of - // truth for what is currently on the sandbox, so trust it directly - // instead of intersecting with the built-in list. Custom mode remains - // authoritative — the operator-supplied list is exactly what the - // sandbox ends up with, and deselected presets are removed. - if (!isAuthoritative) { - const chosenSet = new Set(chosen); - const preserved: string[] = []; - for (const name of applied) { - if (chosenSet.has(name)) continue; - chosen.push(name); - chosenSet.add(name); - preserved.push(name); - } - if (preserved.length > 0) { - note(` [non-interactive] Preserving previously-applied presets: ${preserved.join(", ")}`); - } - } - - if (onSelection) onSelection(chosen); - if (!waitForSandboxReady(sandboxName)) { - console.error(` Sandbox '${sandboxName}' was not ready for policy application.`); - process.exit(1); - } - note(` [non-interactive] Applying policy presets: ${chosen.join(", ")}`); - syncPresetSelection(sandboxName, currentAppliedPresets, chosen); - return chosen; - } - - // Interactive: combined tier preset selector + access-mode toggle. - // extraSelected seeds the initial checked state beyond the tier defaults: - // - presets already applied from a previous run - // - credential-based additions from suggestions (e.g. brave when webSearchConfig is set) - const knownNames = new Set(allPresets.map((p) => p.name)); - const extraSelected = [ - ...applied.filter((name) => knownNames.has(name)), - ...suggestions.filter((name) => knownNames.has(name) && !applied.includes(name)), - ]; - const resolvedPresets = await selectTierPresetsAndAccess(tierName, allPresets, extraSelected); - const interactiveChoice = resolvedPresets.map((p) => p.name); - - if (onSelection) onSelection(interactiveChoice); - if (!waitForSandboxReady(sandboxName)) { - console.error(` Sandbox '${sandboxName}' was not ready for policy application.`); - process.exit(1); - } - - const accessByName: Record = {}; - for (const p of resolvedPresets) accessByName[p.name] = p.access; - syncPresetSelection(sandboxName, currentAppliedPresets, interactiveChoice, accessByName); - return interactiveChoice; + return selectedTier; } // ── Dashboard ──────────────────────────────────────────────────── @@ -9117,6 +9060,7 @@ function toSessionUpdates( policyPresets?: string[] | null; messagingChannels?: string[] | null; messagingChannelConfig?: MessagingChannelConfig | null; + hermesToolGateways?: string[] | null; } = {}, ): SessionUpdates { const normalized: SessionUpdates = {}; @@ -9136,11 +9080,14 @@ function toSessionUpdates( if (updates.nimContainer !== undefined) normalized.nimContainer = toNullableString(updates.nimContainer); if (updates.webSearchConfig !== undefined) normalized.webSearchConfig = updates.webSearchConfig; - if (updates.policyPresets) normalized.policyPresets = updates.policyPresets; - if (updates.messagingChannels) normalized.messagingChannels = updates.messagingChannels; + if (updates.policyPresets !== undefined) normalized.policyPresets = updates.policyPresets; + if (updates.messagingChannels !== undefined) + normalized.messagingChannels = updates.messagingChannels; if (updates.messagingChannelConfig !== undefined) { normalized.messagingChannelConfig = updates.messagingChannelConfig; } + if (updates.hermesToolGateways !== undefined) + normalized.hermesToolGateways = updates.hermesToolGateways; return normalized; } @@ -9159,7 +9106,7 @@ function startRecordedStep( if (updates.sandboxName !== undefined) session.sandboxName = updates.sandboxName; if (updates.provider !== undefined) session.provider = updates.provider; if (updates.model !== undefined) session.model = updates.model; - if (updates.policyPresets) session.policyPresets = updates.policyPresets; + if (updates.policyPresets !== undefined) session.policyPresets = updates.policyPresets; return session; }); } @@ -9736,6 +9683,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { session?.credentialEnv === HERMES_NOUS_API_KEY_CREDENTIAL_ENV ? HERMES_AUTH_METHOD_API_KEY : null); + let hermesToolGateways = normalizeHermesToolGatewaySelections(session?.hermesToolGateways); let preferredInferenceApi = session?.preferredInferenceApi || null; let nimContainer = session?.nimContainer || null; let webSearchConfig = session?.webSearchConfig || null; @@ -9767,6 +9715,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { endpointUrl = selection.endpointUrl; credentialEnv = selection.credentialEnv; hermesAuthMethod = selection.hermesAuthMethod; + hermesToolGateways = selection.hermesToolGateways; preferredInferenceApi = selection.preferredInferenceApi; nimContainer = selection.nimContainer; onboardSession.markStepComplete( @@ -9777,6 +9726,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { endpointUrl, credentialEnv, hermesAuthMethod, + hermesToolGateways, preferredInferenceApi, nimContainer, }), @@ -9792,6 +9742,9 @@ async function onboard(opts: OnboardOptions = {}): Promise { !forceProviderSelection && resume && isInferenceRouteReady(provider, model); if (resumeInference) { if (provider === hermesProviderAuth.HERMES_PROVIDER_NAME) { + if (!sandboxName) { + sandboxName = await promptValidatedSandboxName(agent); + } startRecordedStep("inference", { provider, model }); const inferenceResult = await setupInference( sandboxName, @@ -9800,6 +9753,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { endpointUrl, credentialEnv, hermesAuthMethod, + hermesToolGateways, ); if (inferenceResult?.retry === "selection") { forceProviderSelection = true; @@ -9807,7 +9761,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { } onboardSession.markStepComplete( "inference", - toSessionUpdates({ provider, model, hermesAuthMethod, nimContainer }), + toSessionUpdates({ provider, model, hermesAuthMethod, nimContainer, hermesToolGateways }), ); break; } @@ -9827,7 +9781,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { } onboardSession.markStepComplete( "inference", - toSessionUpdates({ provider, model, hermesAuthMethod, nimContainer }), + toSessionUpdates({ provider, model, hermesAuthMethod, nimContainer, hermesToolGateways }), ); break; } @@ -9846,6 +9800,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { credentialEnv, hermesAuthMethod, webSearchConfig, + hermesToolGateways, enabledChannels: selectedMessagingChannels.length > 0 ? selectedMessagingChannels : null, sandboxName, notes: buildEstimateNote ? [buildEstimateNote] : [], @@ -9871,6 +9826,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { endpointUrl, credentialEnv, hermesAuthMethod, + hermesToolGateways, ); delete process.env.NVIDIA_API_KEY; if (inferenceResult?.retry === "selection") { @@ -9882,7 +9838,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { } onboardSession.markStepComplete( "inference", - toSessionUpdates({ provider, model, hermesAuthMethod, nimContainer }), + toSessionUpdates({ provider, model, hermesAuthMethod, nimContainer, hermesToolGateways }), ); break; } @@ -9939,6 +9895,13 @@ async function onboard(opts: OnboardOptions = {}): Promise { ? hasSandboxGpuDrift(sandboxName, sandboxGpuConfig) : false; const wechatConfigChanged = hasWechatConfigDrift(session); + const recordedHermesToolGateways = sandboxName + ? normalizeHermesToolGatewaySelections(registry.getSandbox(sandboxName)?.hermesToolGateways) + : []; + const hermesToolGatewayConfigChanged = !stringSetsEqual( + recordedHermesToolGateways, + hermesToolGateways, + ); const resumeSandbox = resume && !webSearchConfigChanged && @@ -9946,6 +9909,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { !sandboxGpuConfigChanged && !wechatConfigChanged && !messagingChannelConfigChanged && + !hermesToolGatewayConfigChanged && session?.steps?.sandbox?.status === "complete" && sandboxReuseState === "ready"; if (resumeSandbox) { @@ -9981,6 +9945,11 @@ async function onboard(opts: OnboardOptions = {}): Promise { if (sandboxName) { registry.removeSandbox(sandboxName); } + } else if (hermesToolGatewayConfigChanged) { + note(" [resume] Hermes managed tool gateway selection changed; recreating sandbox."); + if (sandboxName) { + registry.removeSandbox(sandboxName); + } } else if (sandboxReuseState === "not_ready") { note( ` [resume] Recorded sandbox '${sandboxName}' exists but is not ready; recreating it.`, @@ -10049,6 +10018,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { agent, opts.controlUiPort || null, sandboxGpuConfig, + hermesToolGateways, ); webSearchConfig = nextWebSearchConfig; registry.updateSandbox(sandboxName, { @@ -10066,6 +10036,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { nimContainer, webSearchConfig, messagingChannelConfig, + hermesToolGateways, }), ); } @@ -10099,14 +10070,14 @@ async function onboard(opts: OnboardOptions = {}): Promise { skippedStepMessage("openclaw", sandboxName); onboardSession.markStepComplete( "openclaw", - toSessionUpdates({ sandboxName, provider, model, hermesAuthMethod }), + toSessionUpdates({ sandboxName, provider, model, hermesAuthMethod, hermesToolGateways }), ); } else { startRecordedStep("openclaw", { sandboxName, provider, model }); await setupOpenclaw(sandboxName, model, provider); onboardSession.markStepComplete( "openclaw", - toSessionUpdates({ sandboxName, provider, model, hermesAuthMethod }), + toSessionUpdates({ sandboxName, provider, model, hermesAuthMethod, hermesToolGateways }), ); } onboardSession.markStepSkipped("agent_setup"); @@ -10130,17 +10101,26 @@ async function onboard(opts: OnboardOptions = {}): Promise { agent, }); const policyPresetSupportOptions = { webSearchSupported }; - const recordedPolicyPresetsForSupport = policies.clampSetupPolicyPresetNames( + const selectablePolicyPresetsForSupport = [ + ...policies.listSetupPolicyPresets(sandboxName, policyPresetSupportOptions), + ...policies.getAppliedPresets(sandboxName).map((name) => ({ name })), + ]; + const customPolicyPresetNames = new Set( + policies.listCustomPresets(sandboxName).map((p: { name: string }) => p.name), + ); + let recordedPolicyPresetsForSupport = policies.clampSetupPolicyPresetNames( recordedPolicyPresets || [], - [ - ...policies.listSetupPolicyPresets(sandboxName, policyPresetSupportOptions), - ...policies.getAppliedPresets(sandboxName).map((name) => ({ name })), - ], + selectablePolicyPresetsForSupport, policyPresetSupportOptions, - new Set( - policies.listCustomPresets(sandboxName).map((p: { name: string }) => p.name), - ), + customPolicyPresetNames, ); + if (recordedPolicyPresets) { + recordedPolicyPresetsForSupport = mergeRequiredHermesToolGatewayPolicyPresets( + recordedPolicyPresetsForSupport, + hermesToolGateways, + selectablePolicyPresetsForSupport.map((p) => p.name), + ); + } const recordedPolicyPresetsHaveUnsupported = Array.isArray(recordedPolicyPresets) && recordedPolicyPresetsForSupport.length !== recordedPolicyPresets.length; @@ -10179,6 +10159,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { webSearchConfig, provider, webSearchSupported, + hermesToolGateways, onSelection: (policyPresets) => { onboardSession.updateSession((current: Session) => { current.policyPresets = policyPresets; @@ -10197,7 +10178,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 @@ -10381,6 +10362,7 @@ module.exports = { arePolicyPresetsApplied, getSuggestedPolicyPresets, computeSetupPresetSuggestions, + mergeRequiredHermesToolGatewayPolicyPresets, filterSetupPolicyPresets: policies.filterSetupPolicyPresets, LOCAL_INFERENCE_PROVIDERS, presetsCheckboxSelector, diff --git a/src/lib/onboard/dockerfile-patch.ts b/src/lib/onboard/dockerfile-patch.ts index a45a4ec950..cb218f57f7 100644 --- a/src/lib/onboard/dockerfile-patch.ts +++ b/src/lib/onboard/dockerfile-patch.ts @@ -50,6 +50,7 @@ export function patchStagedDockerfile( wechatConfig: LooseObject = {}, darwinVmCompat = false, inferenceBaseUrlOverride: string | null = null, + hermesToolGateways: string[] = [], ): void { const sanitizedModel = sanitizeDockerArg(model); const sandboxInference = getSandboxInferenceConfig( @@ -232,5 +233,15 @@ export function patchStagedDockerfile( `ARG NEMOCLAW_WECHAT_CONFIG_B64=${encodeSanitizedDockerJsonArg(wechatConfig)}`, ); } + 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=${encodeSanitizedDockerJsonArg(hermesToolGateways)}`, + ); + } fs.writeFileSync(dockerfilePath, dockerfile); } diff --git a/src/lib/onboard/hermes-managed-tools.ts b/src/lib/onboard/hermes-managed-tools.ts new file mode 100644 index 0000000000..1e90a5760e --- /dev/null +++ b/src/lib/onboard/hermes-managed-tools.ts @@ -0,0 +1,313 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import * as hermesProviderAuth from "../hermes-provider-auth"; +import type { HermesAuthMethod } from "../hermes-provider-auth"; + +type PromptFn = (message: string) => Promise; +type RawInput = NodeJS.ReadStream & { + setRawMode?: (mode: boolean) => void; + ref?: () => void; + unref?: () => void; +}; + +type SelectDeps = { + prompt: PromptFn; + note: (message: string) => void; + isNonInteractive: () => boolean; + input?: RawInput; + output?: NodeJS.WriteStream; +}; + +export 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; + +export const HERMES_TOOL_GATEWAY_PRESET_NAMES = new Set( + HERMES_TOOL_GATEWAY_PRESETS.map((preset) => preset.name), +); + +export 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)) { + 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; +} + +export function getRequestedHermesToolGateways( + env: NodeJS.ProcessEnv = process.env, +): string[] | null { + const raw = env.NEMOCLAW_HERMES_TOOL_GATEWAYS || env.NEMOCLAW_HERMES_TOOL_GATEWAY_PRESETS || ""; + if (!raw) return null; + return parseHermesToolGatewayPresetEnv(raw); +} + +export 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(", "); +} + +export function defaultHermesToolGatewaySelection(): string[] { + return HERMES_TOOL_GATEWAY_PRESETS.filter((preset) => preset.defaultSelected).map( + (preset) => preset.name, + ); +} + +function resolveHermesToolGatewaySelection(part: string) { + const index = /^[0-9]+$/.test(part) ? Number(part) - 1 : -1; + if (index >= 0) return HERMES_TOOL_GATEWAY_PRESETS[index] || null; + const normalized = part.toLowerCase(); + return ( + HERMES_TOOL_GATEWAY_PRESETS.find( + (candidate) => + candidate.name === normalized || + candidate.name === `nous-${normalized}` || + candidate.label.toLowerCase() === normalized, + ) || null + ); +} + +async function selectHermesToolGatewaysInteractive( + initialSelected: string[], + deps: SelectDeps, +): Promise { + const selected = new Set( + initialSelected.filter((name) => HERMES_TOOL_GATEWAY_PRESET_NAMES.has(name)), + ); + const output = deps.output || process.stdout; + const input = deps.input || (process.stdin as RawInput); + + if (!input.isTTY || !output.isTTY) { + console.log(""); + console.log(" Hermes managed Nous tools (OAuth subscription only):"); + HERMES_TOOL_GATEWAY_PRESETS.forEach((preset, index) => { + const marker = selected.has(preset.name) ? "[✓]" : "[ ]"; + console.log(` ${index + 1}) ${marker} ${preset.label} — ${preset.description}`); + }); + console.log(""); + console.log(" Enter comma-separated numbers/names, Enter for current selection, or 'none'."); + const answer = (await deps.prompt(" Managed tools: ")).trim(); + if (!answer) return [...selected]; + if (/^(none|no|skip)$/i.test(answer)) return []; + + const resolved: string[] = []; + for (const part of answer + .split(",") + .map((value) => value.trim()) + .filter(Boolean)) { + const preset = resolveHermesToolGatewaySelection(part); + if (!preset) { + console.error(` Unknown managed tool selection: ${part}`); + process.exit(1); + } + if (!resolved.includes(preset.name)) resolved.push(preset.name); + } + return resolved; + } + + const linesAbovePrompt = HERMES_TOOL_GATEWAY_PRESETS.length + 3; + let firstDraw = true; + const showList = () => { + if (!firstDraw) { + output.write(`\r\x1b[${linesAbovePrompt}A\x1b[J`); + } + firstDraw = false; + output.write("\n"); + output.write(" Hermes managed Nous tools (OAuth subscription only):\n"); + HERMES_TOOL_GATEWAY_PRESETS.forEach((preset, index) => { + const marker = selected.has(preset.name) ? "[✓]" : "[ ]"; + output.write(` [${index + 1}] ${marker} ${preset.label} — ${preset.description}\n`); + }); + output.write("\n"); + output.write(` Press 1-${HERMES_TOOL_GATEWAY_PRESETS.length} to toggle, a for all/none, Enter when done: `); + }; + + showList(); + + await new Promise((resolve, reject) => { + let rawModeEnabled = false; + let finished = false; + + function cleanup() { + input.removeListener("data", onData); + if (rawModeEnabled && typeof input.setRawMode === "function") { + input.setRawMode(false); + } + if (typeof input.pause === "function") input.pause(); + if (typeof input.unref === "function") input.unref(); + } + + function finish(): void { + if (finished) return; + finished = true; + cleanup(); + output.write("\n"); + resolve(); + } + + function toggleAll(): void { + if (selected.size === HERMES_TOOL_GATEWAY_PRESETS.length) selected.clear(); + else for (const preset of HERMES_TOOL_GATEWAY_PRESETS) selected.add(preset.name); + } + + function onData(chunk: Buffer | string): void { + const text = chunk.toString("utf8"); + for (let i = 0; i < text.length; i += 1) { + const ch = text[i]; + if (ch === "\u0003") { + cleanup(); + reject(Object.assign(new Error("Prompt interrupted"), { code: "SIGINT" })); + process.kill(process.pid, "SIGINT"); + return; + } + if (ch === "\r" || ch === "\n") { + finish(); + return; + } + if (ch === "a" || ch === "A") { + toggleAll(); + showList(); + continue; + } + const num = Number.parseInt(ch, 10); + if (num >= 1 && num <= HERMES_TOOL_GATEWAY_PRESETS.length) { + const preset = HERMES_TOOL_GATEWAY_PRESETS[num - 1]; + if (selected.has(preset.name)) selected.delete(preset.name); + else selected.add(preset.name); + showList(); + } + } + } + + if (typeof input.ref === "function") input.ref(); + input.setEncoding("utf8"); + if (typeof input.resume === "function") input.resume(); + if (typeof input.setRawMode === "function") { + input.setRawMode(true); + rawModeEnabled = true; + } + input.on("data", onData); + }); + + return [...selected]; +} + +export 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; +} + +export async function setupHermesToolGateways( + provider: string | null, + hermesAuthMethod: HermesAuthMethod | null, + existing: string[] | null = null, + deps: SelectDeps, +): Promise { + if (provider !== hermesProviderAuth.HERMES_PROVIDER_NAME) return []; + if (hermesAuthMethod === "api_key") { + const requested = getRequestedHermesToolGateways(); + if (requested && requested.length > 0) { + deps.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) { + deps.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)); + } + if (deps.isNonInteractive()) return []; + + const selected = await selectHermesToolGatewaysInteractive(defaultHermesToolGatewaySelection(), deps); + if (selected.length === 0) { + console.log(" Skipping Hermes managed tools."); + } + return selected; +} + +export function mergeRequiredHermesToolGatewayPolicyPresets( + policyPresets: string[] = [], + hermesToolGateways: string[] | null | undefined = null, + allowedPresetNames: string[] | Set | null = null, +): string[] { + const allowed = + allowedPresetNames instanceof Set + ? allowedPresetNames + : Array.isArray(allowedPresetNames) + ? new Set(allowedPresetNames) + : null; + const merged = [...policyPresets]; + const seen = new Set(merged); + if (!Array.isArray(hermesToolGateways)) return merged; + + for (const presetName of hermesToolGateways) { + if (!HERMES_TOOL_GATEWAY_PRESET_NAMES.has(presetName)) continue; + if (allowed && !allowed.has(presetName)) continue; + if (seen.has(presetName)) continue; + merged.push(presetName); + seen.add(presetName); + } + return merged; +} diff --git a/src/lib/onboard/initial-policy.test.ts b/src/lib/onboard/initial-policy.test.ts index 6993f0d6a2..833852710c 100644 --- a/src/lib/onboard/initial-policy.test.ts +++ b/src/lib/onboard/initial-policy.test.ts @@ -165,4 +165,15 @@ network_policies: expect(prepared.cleanup?.()).toBe(true); expect(fs.existsSync(prepared.policyPath)).toBe(false); }); + + it("merges additional create-time presets with channel presets", () => { + const basePolicyPath = tmpPolicy("version: 1\nnetwork_policies:\n base: {}\n"); + + const prepared = prepareInitialSandboxCreatePolicy(basePolicyPath, ["slack"], { + additionalPresets: ["nous-web"], + }); + + expect(prepared.appliedPresets).toEqual(["slack", "nous-web"]); + expect(prepared.cleanup?.()).toBe(true); + }); }); diff --git a/src/lib/onboard/initial-policy.ts b/src/lib/onboard/initial-policy.ts index cc410fbf36..95f6a48708 100644 --- a/src/lib/onboard/initial-policy.ts +++ b/src/lib/onboard/initial-policy.ts @@ -164,7 +164,7 @@ export function getNetworkPolicyNames(policyContent: string): Set | null export function prepareInitialSandboxCreatePolicy( basePolicyPath: string, activeMessagingChannels: string[], - options: { directGpu?: boolean; dockerGpuPatch?: boolean } = {}, + options: { directGpu?: boolean; dockerGpuPatch?: boolean; additionalPresets?: string[] } = {}, ): InitialSandboxPolicy { const directGpuPolicy = options.directGpu ? prepareDirectGpuSandboxPolicy(basePolicyPath, { @@ -175,9 +175,12 @@ export function prepareInitialSandboxCreatePolicy( const cleanupFns = directGpuPolicy?.cleanup ? [directGpuPolicy.cleanup] : []; const requestedCreateTimePresets = [ ...new Set( - activeMessagingChannels.flatMap( - (channel) => CREATE_TIME_POLICY_PRESETS_BY_CHANNEL[channel] || [], - ), + [ + ...activeMessagingChannels.flatMap( + (channel) => CREATE_TIME_POLICY_PRESETS_BY_CHANNEL[channel] || [], + ), + ...(options.additionalPresets || []), + ], ), ]; const combinedCleanup = diff --git a/src/lib/onboard/policy-selection.ts b/src/lib/onboard/policy-selection.ts new file mode 100644 index 0000000000..bfb29500d4 --- /dev/null +++ b/src/lib/onboard/policy-selection.ts @@ -0,0 +1,277 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { WebSearchConfig } from "../inference/web-search"; +import { + HERMES_TOOL_GATEWAY_PRESET_NAMES, + mergeRequiredHermesToolGatewayPolicyPresets, +} from "./hermes-managed-tools"; + +type Preset = { name: string; access?: string }; +type SupportOptions = { webSearchSupported?: boolean | null }; +type PoliciesApi = { + setupPolicyPresetSupported(name: string, options?: SupportOptions): boolean; + listSetupPolicyPresets(sandboxName: string, options?: SupportOptions): Preset[]; + listCustomPresets(sandboxName: string): Preset[]; + getAppliedPresets(sandboxName: string): string[]; + clampSetupPolicyPresetNames( + names: string[], + selectablePresets: Preset[], + options?: SupportOptions, + customPresetNames?: Set, + ): string[]; +}; +type TiersApi = { + resolveTierPresets(tierName: string): Preset[]; + getTier(tierName: string): unknown; +}; + +export type SetupPresetSuggestionOptions = { + enabledChannels?: string[] | null; + webSearchConfig?: WebSearchConfig | null; + provider?: string | null; + knownPresetNames?: string[] | null; + webSearchSupported?: boolean | null; + hermesToolGateways?: string[] | null; +}; + +export type SetupPolicySelectionOptions = { + selectedPresets?: string[] | null; + onSelection?: ((policyPresets: string[]) => void) | null; + webSearchConfig?: WebSearchConfig | null; + enabledChannels?: string[] | null; + provider?: string | null; + knownPresetNames?: string[]; + webSearchSupported?: boolean | null; + hermesToolGateways?: string[] | null; +}; + +export type SetupPolicySelectionDeps = { + policies: PoliciesApi; + tiers: TiersApi; + localInferenceProviders: readonly string[]; + step: (number: number, total: number, title: string) => void; + note: (message: string) => void; + isNonInteractive: () => boolean; + waitForSandboxReady: (sandboxName: string) => boolean; + syncPresetSelection: ( + sandboxName: string, + currentAppliedPresets: string[], + selectedPresets: string[], + accessByName?: Record, + ) => void; + selectPolicyTier: () => Promise; + setPolicyTier?: (sandboxName: string, tierName: string) => void; + selectTierPresetsAndAccess: ( + tierName: string, + presets: Preset[], + extraSelected: string[], + ) => Promise>; + parsePolicyPresetEnv: (raw: string) => string[]; + env?: NodeJS.ProcessEnv; +}; + +export function computeSetupPresetSuggestions( + deps: { + policies: PoliciesApi; + tiers: TiersApi; + localInferenceProviders: readonly string[]; + }, + tierName: string, + options: SetupPresetSuggestionOptions = {}, +): string[] { + const { enabledChannels = null, webSearchConfig = null, provider = null } = options; + const known = Array.isArray(options.knownPresetNames) ? new Set(options.knownPresetNames) : null; + const supportOptions = { webSearchSupported: options.webSearchSupported }; + const suggestions = deps.tiers + .resolveTierPresets(tierName) + .map((preset) => preset.name) + .filter((name) => deps.policies.setupPolicyPresetSupported(name, supportOptions)) + .filter((name) => !known || known.has(name)); + const add = (name: string) => { + if (!deps.policies.setupPolicyPresetSupported(name, supportOptions)) return; + if (suggestions.includes(name)) return; + if (known && !known.has(name)) return; + suggestions.push(name); + }; + if (webSearchConfig) add("brave"); + if (provider && deps.localInferenceProviders.includes(provider)) add("local-inference"); + if (Array.isArray(enabledChannels)) { + for (const channel of enabledChannels) add(channel); + } + if (Array.isArray(options.hermesToolGateways)) { + for (const preset of options.hermesToolGateways) { + if (HERMES_TOOL_GATEWAY_PRESET_NAMES.has(preset)) add(preset); + } + } + return suggestions; +} + +export async function setupPoliciesWithSelection( + deps: SetupPolicySelectionDeps, + sandboxName: string, + options: SetupPolicySelectionOptions = {}, +): Promise { + const selectedPresets = Array.isArray(options.selectedPresets) ? options.selectedPresets : null; + const onSelection = typeof options.onSelection === "function" ? options.onSelection : null; + 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; + + deps.step(8, 8, "Policy presets"); + + const supportOptions = { webSearchSupported: options.webSearchSupported }; + const allPresets = deps.policies.listSetupPolicyPresets(sandboxName, supportOptions); + const knownPresets = new Set(allPresets.map((preset) => preset.name)); + const customPresetNames = new Set( + deps.policies.listCustomPresets(sandboxName).map((preset) => preset.name), + ); + const currentAppliedPresets = deps.policies.getAppliedPresets(sandboxName); + const selectablePresets = [ + ...allPresets, + ...currentAppliedPresets.map((name) => ({ name })), + ]; + const applied = deps.policies.clampSetupPolicyPresetNames( + currentAppliedPresets, + selectablePresets, + supportOptions, + customPresetNames, + ); + const filterSupportedPresetNames = (presetNames: string[]) => + presetNames.filter( + (name) => + customPresetNames.has(name) || + deps.policies.setupPolicyPresetSupported(name, supportOptions), + ); + let chosen = + selectedPresets !== null + ? deps.policies.clampSetupPolicyPresetNames( + selectedPresets, + selectablePresets, + supportOptions, + customPresetNames, + ) + : null; + if (chosen) { + chosen = mergeRequiredHermesToolGatewayPolicyPresets( + chosen, + hermesToolGateways, + new Set(selectablePresets.map((preset) => preset.name)), + ); + } + + if (selectedPresets !== null) { + const resumeSelection = chosen || []; + if (onSelection) onSelection(resumeSelection); + if (!deps.waitForSandboxReady(sandboxName)) { + console.error(` Sandbox '${sandboxName}' was not ready for policy application.`); + process.exit(1); + } + deps.note(` [resume] Reapplying policy presets: ${resumeSelection.join(", ")}`); + deps.syncPresetSelection(sandboxName, currentAppliedPresets, resumeSelection); + return resumeSelection; + } + + const tierName = await deps.selectPolicyTier(); + deps.setPolicyTier?.(sandboxName, tierName); + const suggestions = computeSetupPresetSuggestions(deps, tierName, { + enabledChannels, + webSearchConfig, + provider, + knownPresetNames: allPresets.map((preset) => preset.name), + webSearchSupported: options.webSearchSupported, + hermesToolGateways, + }); + + if (deps.isNonInteractive()) { + const policyMode = (deps.env?.NEMOCLAW_POLICY_MODE || "suggested").trim().toLowerCase(); + chosen = suggestions; + let isAuthoritative = false; + + if (policyMode === "skip" || policyMode === "none" || policyMode === "no") { + deps.note(" [non-interactive] Skipping policy presets."); + return []; + } + + if (policyMode === "custom" || policyMode === "list") { + const envPresets = deps.parsePolicyPresetEnv(deps.env?.NEMOCLAW_POLICY_PRESETS || ""); + if (envPresets.length === 0) { + console.error(" NEMOCLAW_POLICY_PRESETS is required when NEMOCLAW_POLICY_MODE=custom."); + process.exit(1); + } + chosen = filterSupportedPresetNames(envPresets); + isAuthoritative = true; + } else if (policyMode === "suggested" || policyMode === "default" || policyMode === "auto") { + const envPresets = deps.parsePolicyPresetEnv(deps.env?.NEMOCLAW_POLICY_PRESETS || ""); + if (envPresets.length > 0) chosen = filterSupportedPresetNames(envPresets); + } else { + console.warn(` Unsupported NEMOCLAW_POLICY_MODE: ${policyMode}`); + console.warn( + " Valid values: suggested, custom, skip (aliases: default/auto, list, none/no).", + ); + if (deps.tiers.getTier(policyMode)) { + console.warn( + ` '${policyMode}' is a policy tier — did you mean NEMOCLAW_POLICY_TIER=${policyMode}?`, + ); + } + console.warn(` Falling back to suggested presets for tier '${tierName}'.`); + } + + chosen = mergeRequiredHermesToolGatewayPolicyPresets(chosen, hermesToolGateways, knownPresets); + + const invalidPresets = chosen.filter((name) => !knownPresets.has(name)); + if (invalidPresets.length > 0) { + console.error(` Unknown policy preset(s): ${invalidPresets.join(", ")}`); + process.exit(1); + } + + if (!isAuthoritative) { + const chosenSet = new Set(chosen); + const preserved: string[] = []; + for (const name of applied) { + if (chosenSet.has(name)) continue; + chosen.push(name); + chosenSet.add(name); + preserved.push(name); + } + if (preserved.length > 0) { + deps.note(` [non-interactive] Preserving previously-applied presets: ${preserved.join(", ")}`); + } + } + + if (onSelection) onSelection(chosen); + if (!deps.waitForSandboxReady(sandboxName)) { + console.error(` Sandbox '${sandboxName}' was not ready for policy application.`); + process.exit(1); + } + deps.note(` [non-interactive] Applying policy presets: ${chosen.join(", ")}`); + deps.syncPresetSelection(sandboxName, currentAppliedPresets, chosen); + return chosen; + } + + const knownNames = new Set(allPresets.map((preset) => preset.name)); + const extraSelected = [ + ...applied.filter((name) => knownNames.has(name)), + ...suggestions.filter((name) => knownNames.has(name) && !applied.includes(name)), + ]; + const resolvedPresets = await deps.selectTierPresetsAndAccess(tierName, allPresets, extraSelected); + const interactiveChoice = mergeRequiredHermesToolGatewayPolicyPresets( + resolvedPresets.map((preset) => preset.name), + hermesToolGateways, + knownNames, + ); + + if (onSelection) onSelection(interactiveChoice); + if (!deps.waitForSandboxReady(sandboxName)) { + console.error(` Sandbox '${sandboxName}' was not ready for policy application.`); + process.exit(1); + } + + const accessByName: Record = {}; + for (const preset of resolvedPresets) accessByName[preset.name] = preset.access; + deps.syncPresetSelection(sandboxName, currentAppliedPresets, interactiveChoice, accessByName); + return interactiveChoice; +} diff --git a/src/lib/onboard/summary.ts b/src/lib/onboard/summary.ts index eec5bb1fc5..ef56fc8a6c 100644 --- a/src/lib/onboard/summary.ts +++ b/src/lib/onboard/summary.ts @@ -7,6 +7,7 @@ import { type HermesAuthMethod, } from "../hermes-provider-auth"; import type { WebSearchConfig } from "../inference/web-search"; +import { hermesToolGatewayLabels } from "./hermes-managed-tools"; const HERMES_AUTH_METHOD_OAUTH: HermesAuthMethod = "oauth"; const HERMES_AUTH_METHOD_API_KEY: HermesAuthMethod = "api_key"; @@ -24,6 +25,7 @@ export type OnboardConfigSummary = { hermesAuthMethod?: HermesAuthMethod | string | null; webSearchConfig?: WebSearchConfig | null; enabledChannels?: string[] | null; + hermesToolGateways?: string[] | null; sandboxName: string; notes?: string[] | null; }; @@ -75,6 +77,7 @@ export function formatOnboardConfigSummary({ hermesAuthMethod = null, webSearchConfig = null, enabledChannels = null, + hermesToolGateways = null, sandboxName, notes = [], }: OnboardConfigSummary): string { @@ -110,6 +113,7 @@ export function formatOnboardConfigSummary({ ` Model: ${model ?? "(unset)"}`, apiKeyLine, ` Web search: ${webSearch}`, + ` Managed tools: ${hermesToolGatewayLabels(hermesToolGateways)}`, ` Messaging: ${messaging}`, ` Sandbox name: ${sandboxName}`, ...noteLines, diff --git a/src/lib/state/onboard-session.ts b/src/lib/state/onboard-session.ts index 72e89a8acc..f05c1116e8 100644 --- a/src/lib/state/onboard-session.ts +++ b/src/lib/state/onboard-session.ts @@ -83,6 +83,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; @@ -159,8 +160,9 @@ export interface SessionUpdates { routerPid?: number; routerCredentialHash?: string; webSearchConfig?: WebSearchConfig | null; - policyPresets?: string[]; - messagingChannels?: string[]; + hermesToolGateways?: string[] | null; + policyPresets?: string[] | null; + messagingChannels?: string[] | null; messagingChannelConfig?: MessagingChannelConfig | null; disabledChannels?: string[] | null; migratedLegacyValueHashes?: Record; @@ -186,6 +188,7 @@ export interface DebugSessionSummary { hermesAuthMethod: HermesAuthMethod | null; preferredInferenceApi: string | null; nimContainer: string | null; + hermesToolGateways: string[] | null; policyPresets: string[] | null; gpuPassthrough: boolean; lastStepStarted: string | null; @@ -354,6 +357,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), @@ -395,6 +399,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), @@ -808,10 +813,21 @@ export function filterSafeUpdates(updates: SessionUpdates): Partial { } else if (updates.webSearchConfig === null) { safe.webSearchConfig = null; } - if (Array.isArray(updates.policyPresets)) { + if (updates.hermesToolGateways === null) { + safe.hermesToolGateways = null; + } else if (Array.isArray(updates.hermesToolGateways)) { + safe.hermesToolGateways = updates.hermesToolGateways.filter( + (value) => typeof value === "string", + ); + } + if (updates.policyPresets === null) { + safe.policyPresets = null; + } else if (Array.isArray(updates.policyPresets)) { safe.policyPresets = updates.policyPresets.filter((value) => typeof value === "string"); } - if (Array.isArray(updates.messagingChannels)) { + if (updates.messagingChannels === null) { + safe.messagingChannels = null; + } else if (Array.isArray(updates.messagingChannels)) { safe.messagingChannels = updates.messagingChannels.filter((value) => typeof value === "string"); } if (updates.messagingChannelConfig === null) { @@ -955,6 +971,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 5e816e96c9..548859d436 100644 --- a/src/lib/state/registry.ts +++ b/src/lib/state/registry.ts @@ -37,6 +37,7 @@ export interface SandboxEntry { providerCredentialHashes?: Record; messagingChannels?: string[]; messagingChannelConfig?: MessagingChannelConfig; + hermesToolGateways?: string[]; disabledChannels?: string[]; dashboardPort?: number | null; } @@ -214,6 +215,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 34458964fc..6871146c66 100644 --- a/test/generate-hermes-config.test.ts +++ b/test/generate-hermes-config.test.ts @@ -119,6 +119,53 @@ 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).not.toContain("TOOL_GATEWAY_USER_TOKEN="); + 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("fails fast for unknown managed-tool gateway presets", () => { + const result = runConfigScriptRaw({ + NEMOCLAW_HERMES_TOOL_GATEWAY_BROKER: "1", + NEMOCLAW_HERMES_TOOL_GATEWAY_PRESETS_B64: encodeJson(["nous-web", "nous-typo"]), + }); + + expect(result.status).not.toBe(0); + expect(`${result.stderr}\n${result.stdout}`).toContain( + "Unknown Hermes managed-tool gateway preset: nous-typo", + ); + }); + 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 2df02244e8..22487517f0 100644 --- a/test/hermes-plugin-handlers.test.ts +++ b/test/hermes-plugin-handlers.test.ts @@ -71,4 +71,152 @@ 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"] = "broker-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" +os.environ["OPENAI_AUDIO_GATEWAY_URL"] = "http://host.openshell.internal:11436/openai-audio" + +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("broker-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 0000000000..e992524a97 --- /dev/null +++ b/test/hermes-tool-gateway-broker.test.ts @@ -0,0 +1,377 @@ +// 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.ts", +); +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"); +} + +async function waitUntil(predicate: () => boolean): Promise { + for (let i = 0; i < 50; i++) { + if (predicate()) return; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error("condition was not met"); +} + +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", + `printf '%s\\n' "$*" >> "${openshellLog}"`, + `printf 'refresh=%s\\n' "$NEMOCLAW_HERMES_TOOL_GATEWAY_REFRESH_TOKEN" >> "${openshellLog}"`, + `printf 'openai=%s\\n' "$OPENAI_API_KEY" >> "${openshellLog}"`, + "exit 0", + "", + ].join("\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", + broker_token: "broker-1", + broker_token_sha256: sha256("broker-1"), + refresh_token_sha256: sha256("refresh-1"), + client_id: "hermes-cli", + }, + null, + 2, + ), + { mode: 0o600 }, + ); + + const tokenRequests: Array<{ body: string; refreshHeader?: string }> = []; + const agentKeyRequests: Array<{ body: string; authorization?: string }> = []; + const portal = http.createServer((req, res) => { + const chunks: Buffer[] = []; + req.on("data", (chunk) => chunks.push(chunk)); + req.on("end", () => { + const body = Buffer.concat(chunks).toString("utf8"); + res.writeHead(200, { "Content-Type": "application/json" }); + if (req.url === "/api/oauth/agent-key") { + agentKeyRequests.push({ + body, + authorization: req.headers.authorization, + }); + res.end( + JSON.stringify({ + api_key: "agent-key-2", + expires_in: 1800, + inference_base_url: "https://inference-api.nousresearch.com/v1", + }), + ); + return; + } + tokenRequests.push({ + body, + refreshHeader: req.headers["x-nous-refresh-token"] as string | undefined, + }); + 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": "fixture_session=1; HttpOnly; Secure; SameSite=Strict", + }); + 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, ["--experimental-strip-types", 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, + NEMOCLAW_HERMES_TOOL_GATEWAY_REFRESH_TOKEN: "refresh-1", + }, + 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); + await waitUntil(() => { + try { + return fs.readFileSync(openshellLog, "utf8").includes("provider update hermes-provider"); + } catch { + return false; + } + }); + + 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-broker-token" }, + }); + 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 broker-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(tokenRequests).toHaveLength(1); + expect(tokenRequests[0]?.refreshHeader).toBe("refresh-1"); + expect(new URLSearchParams(tokenRequests[0]?.body).get("refresh_token")).toBeNull(); + expect(new URLSearchParams(tokenRequests[0]?.body).get("grant_type")).toBe("refresh_token"); + expect(agentKeyRequests).toHaveLength(1); + expect(agentKeyRequests[0]?.authorization).toBe("Bearer access-2"); + expect(JSON.parse(agentKeyRequests[0]?.body || "{}")).toEqual({ + min_ttl_seconds: 1800, + }); + 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"); + expect(openshellOutput).toContain( + "provider update hermes-provider --credential OPENAI_API_KEY --config OPENAI_BASE_URL=https://inference-api.nousresearch.com/v1", + ); + expect(openshellOutput).toContain("openai=agent-key-2"); + expect(rotatedState.inference_provider_name).toBe("hermes-provider"); + expect(rotatedState.inference_credential_env).toBe("OPENAI_API_KEY"); + expect(rotatedState.inference_agent_key_expires_at).toBeTruthy(); + + const checks = [ + ["/browser-use/browsers", { "X-Browser-Use-API-Key": "broker-1" }, "browser"], + ["/fal-queue/fal-ai/test", { Authorization: "Key broker-1" }, "fal"], + ["/openai-audio/v1/audio/speech", { "openai-api-key": "broker-1" }, "audio"], + ["/modal/sandboxes", { Authorization: "Bearer broker-1" }, "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(tokenRequests).toHaveLength(1); + expect(agentKeyRequests).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"); + expect(output).not.toContain("agent-key-2"); + } 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 9dcb2dc192..bd1ac94036 100644 --- a/test/onboard.test.ts +++ b/test/onboard.test.ts @@ -45,6 +45,16 @@ function parseStdoutJson(stdout: string): T { return JSON.parse(line); } +function stripMessagingEnv(source: NodeJS.ProcessEnv): Record { + const env = { ...source } as Record; + for (const key of Object.keys(env)) { + if (key.startsWith("DISCORD_") || key.startsWith("TELEGRAM_")) { + delete env[key]; + } + } + return env; +} + type OnboardTestInternalsCandidate = Partial | null; function isOnboardTestInternals( @@ -618,10 +628,11 @@ const { setupInference } = require(${onboardPath}); expect(result.status).toBe(0); const commands = parseStdoutJson(result.stdout); - assert.equal(commands.length, 3); + assert.equal(commands.length, 4); assert.match(commands[0].command, /gateway select nemoclaw/); - assert.match(commands[1].command, /provider get hermes-provider/); - assert.match(commands[2].command, /inference set --no-verify --provider hermes-provider/); + assert.match(commands[1].command, /provider list/); + assert.match(commands[2].command, /provider get hermes-provider/); + assert.match(commands[3].command, /inference set --no-verify --provider hermes-provider/); assert.ok(!commands.some((entry) => /provider (create|update)/.test(entry.command))); assert.ok(!commands.some((entry) => entry.env?.NOUS_API_KEY || entry.env?.OPENAI_API_KEY)); assert.ok( @@ -630,6 +641,242 @@ const { setupInference } = require(${onboardPath}); ); }); + it("resolves a sandbox name before reconciling Hermes Provider on resume", { timeout: 60_000 }, () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-hermes-resume-")); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "hermes-resume-sandbox-name-check.js"); + const openshellPath = JSON.stringify(path.join(fakeBin, "openshell")); + const onboardPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "onboard.js")); + const runnerPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "runner.js")); + const registryPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "state", "registry.js")); + const sessionPath = JSON.stringify( + path.join(repoRoot, "dist", "lib", "state", "onboard-session.js"), + ); + const credentialsPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "credentials", "store.js")); + const nimPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "inference", "nim.js")); + const gatewayStatePath = JSON.stringify(path.join(repoRoot, "dist", "lib", "state", "gateway.js")); + const dockerDriverPlatformPath = JSON.stringify( + path.join(repoRoot, "dist", "lib", "onboard", "docker-driver-platform.js"), + ); + const gatewayGpuPassthroughPath = JSON.stringify( + path.join(repoRoot, "dist", "lib", "onboard", "gateway-gpu-passthrough.js"), + ); + const onboardProbesPath = JSON.stringify( + path.join(repoRoot, "dist", "lib", "inference", "onboard-probes.js"), + ); + + fs.mkdirSync(fakeBin, { recursive: true }); + fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { + mode: 0o755, + }); + + const script = String.raw` +const runner = require(${runnerPath}); +const registry = require(${registryPath}); +const onboardSession = require(${sessionPath}); +const credentials = require(${credentialsPath}); +const nim = require(${nimPath}); +const gatewayState = require(${gatewayStatePath}); +const dockerDriverPlatform = require(${dockerDriverPlatformPath}); +const gatewayGpuPassthrough = require(${gatewayGpuPassthroughPath}); +const onboardProbes = require(${onboardProbesPath}); + +const _n = (c) => (Array.isArray(c) ? c.join(" ") : String(c)).replace(/'/g, ""); +const commands = []; +const prompts = []; +const registryUpdates = []; +const done = new Error("INFERENCE_STEP_DONE"); +let inferenceSessionSnapshot = null; + +delete process.env.NEMOCLAW_NON_INTERACTIVE; +delete process.env.NEMOCLAW_SANDBOX_NAME; +delete process.env.NOUS_API_KEY; +for (const key of Object.keys(process.env)) { + if (key.startsWith("DISCORD_") || key.startsWith("TELEGRAM_")) { + delete process.env[key]; + } +} +process.env.NEMOCLAW_OPENSHELL_BIN = ${openshellPath}; +process.env.OPENSHELL_GATEWAY = "nemoclaw"; + +try { + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true }); +} catch { + process.stdin.isTTY = true; + process.stdout.isTTY = true; +} + +runner.run = (command, opts = {}) => { + const normalized = _n(command); + commands.push({ command: normalized, env: opts.env || null }); + return { status: 0, stdout: "", stderr: "" }; +}; +runner.runCapture = (command) => { + const normalized = _n(command); + if (normalized.includes("inference get")) { + return [ + "Gateway inference:", + "", + " Route: inference.local", + " Provider: hermes-provider", + " Model: moonshotai/kimi-k2.6", + " Version: 1", + ].join("\\n"); + } + return ""; +}; + +registry.getSandbox = (name) => + name === "hermes-resume" + ? { + name, + gpuEnabled: false, + provider: "hermes-provider", + model: "moonshotai/kimi-k2.6", + hermesToolGateways: [], + messagingChannels: [], + policies: ["nous-web"], + } + : null; +registry.updateSandbox = (name, updates) => { + registryUpdates.push({ name, updates }); + return true; +}; +registry.setDefault = () => true; +registry.removeSandbox = () => true; + +credentials.prompt = async (question) => { + prompts.push(String(question)); + if (String(question).includes("Sandbox name")) return "hermes-resume"; + return "yes"; +}; + +nim.detectGpu = () => null; +gatewayState.getGatewayReuseState = () => "healthy"; +gatewayState.shouldSelectNamedGatewayForReuse = () => false; +gatewayState.getSandboxStateFromOutputs = () => "ready"; +gatewayState.isGatewayHealthy = () => true; +dockerDriverPlatform.isLinuxDockerDriverGatewayEnabled = () => false; +gatewayGpuPassthrough.reconcileGatewayGpuReuseForGpuIntent = ({ gatewayReuseState }) => gatewayReuseState; +onboardProbes.verifyOnboardInferenceSmoke = () => {}; + +const complete = () => ({ + status: "complete", + startedAt: new Date().toISOString(), + completedAt: new Date().toISOString(), + error: null, +}); +onboardSession.saveSession( + onboardSession.createSession({ + mode: "interactive", + agent: "hermes", + sandboxName: null, + provider: "hermes-provider", + model: "moonshotai/kimi-k2.6", + endpointUrl: "https://inference-api.nousresearch.com/v1", + credentialEnv: "NOUS_API_KEY", + hermesAuthMethod: "api_key", + hermesToolGateways: [], + policyPresets: ["nous-web"], + metadata: { gatewayName: "nemoclaw", fromDockerfile: null }, + steps: { + preflight: complete(), + gateway: complete(), + provider_selection: complete(), + }, + }), +); + +const originalMarkStepComplete = onboardSession.markStepComplete; +onboardSession.markStepComplete = (stepName, updates = {}) => { + const result = originalMarkStepComplete(stepName, updates); + if (stepName === "inference") { + inferenceSessionSnapshot = result; + throw done; + } + return result; +}; + +const { onboard } = require(${onboardPath}); + +(async () => { + try { + await onboard({ resume: true, agent: "hermes", acceptThirdPartySoftware: true, noGpu: true }); + throw new Error("Expected onboarding to reach the inference step"); + } catch (error) { + if (error === done || error?.message === done.message) { + console.log(JSON.stringify({ + commands, + prompts, + registryUpdates, + inferenceSessionSandboxName: inferenceSessionSnapshot?.sandboxName ?? null, + })); + return; + } + console.error(error); + process.exit(1); + } +})(); +`; + fs.writeFileSync(scriptPath, script); + + const env: Record = { + ...stripMessagingEnv(process.env), + HOME: tmpDir, + PATH: `${fakeBin}:${process.env.PATH || ""}`, + NEMOCLAW_OPENSHELL_BIN: path.join(fakeBin, "openshell"), + }; + delete env.NEMOCLAW_NON_INTERACTIVE; + delete env.NEMOCLAW_SANDBOX_NAME; + delete env.NOUS_API_KEY; + + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env, + }); + + assert.equal(result.status, 0, result.stderr); + assert.doesNotMatch( + `${result.stderr}\n${result.stdout}`, + /Hermes Provider requires a sandbox name/, + ); + const payload = parseStdoutJson<{ + commands: CommandEntry[]; + prompts: string[]; + registryUpdates: Array<{ name: string; updates: Record }>; + inferenceSessionSandboxName: string | null; + }>(result.stdout); + + assert.ok( + payload.prompts.some((question) => question.includes("Sandbox name")), + "resume should prompt for the missing sandbox name before Hermes inference reconciliation", + ); + assert.ok( + payload.commands.some((entry) => + /inference set --no-verify --provider hermes-provider/.test(entry.command), + ), + "resume should reach openshell inference set", + ); + assert.ok(!payload.commands.some((entry) => /provider (create|update)/.test(entry.command))); + assert.equal( + payload.inferenceSessionSandboxName, + null, + "resume inference must not persist sandboxName before sandbox creation", + ); + assert.ok( + payload.registryUpdates.some( + (call) => + call.name === "hermes-resume" && + call.updates.provider === "hermes-provider" && + call.updates.model === "moonshotai/kimi-k2.6", + ), + "Hermes setup should reconcile inference against the resolved sandbox name", + ); + }); + it("reconciles a registered Hermes Provider when a fresh shell Nous key is selected", () => { const repoRoot = path.join(import.meta.dirname, ".."); const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-hermes-update-")); diff --git a/test/policies.test.ts b/test/policies.test.ts index 6e9c7a9dab..fe6590a927 100644 --- a/test/policies.test.ts +++ b/test/policies.test.ts @@ -129,9 +129,9 @@ selectFromList(items, options) describe("policies", () => { describe("listPresets", () => { - it("returns all 14 presets", () => { + it("returns all 19 presets", () => { const presets = policies.listPresets(); - expect(presets.length).toBe(14); + expect(presets.length).toBe(19); }); it("each preset has name and description", () => { @@ -154,6 +154,11 @@ describe("policies", () => { "huggingface", "jira", "local-inference", + "nous-audio", + "nous-browser", + "nous-code", + "nous-image", + "nous-web", "npm", "outlook", "pypi", @@ -329,6 +334,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", () => {