Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 18 additions & 6 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -666,11 +666,10 @@ ARG NEMOCLAW_DARWIN_VM_COMPAT=0
# before running `nemoclaw onboard`. See #1409.
ARG NEMOCLAW_PROXY_HOST=10.200.0.1
ARG NEMOCLAW_PROXY_PORT=3128
# Non-secret flag: set to "1" when the user configured Brave Search during
# onboard. Controls whether the web search block is written to openclaw.json.
# The actual API key is injected at runtime via openshell:resolve:env, never
# baked into the image.
# Non-secret web-search selection from onboard. The actual API key is injected
# at runtime via openshell:resolve:env, never baked into the image.
ARG NEMOCLAW_WEB_SEARCH_ENABLED=0
ARG NEMOCLAW_WEB_SEARCH_PROVIDER=brave
ARG NEMOCLAW_OPENCLAW_OTEL=0
ARG NEMOCLAW_OPENCLAW_OTEL_ENDPOINT=http://host.openshell.internal:4318
ARG NEMOCLAW_OPENCLAW_OTEL_SERVICE_NAME=openclaw-gateway
Expand Down Expand Up @@ -700,6 +699,7 @@ ENV NEMOCLAW_MODEL=${NEMOCLAW_MODEL} \
NEMOCLAW_PROXY_HOST=${NEMOCLAW_PROXY_HOST} \
NEMOCLAW_PROXY_PORT=${NEMOCLAW_PROXY_PORT} \
NEMOCLAW_WEB_SEARCH_ENABLED=${NEMOCLAW_WEB_SEARCH_ENABLED} \
NEMOCLAW_WEB_SEARCH_PROVIDER=${NEMOCLAW_WEB_SEARCH_PROVIDER} \
NEMOCLAW_OPENCLAW_OTEL=${NEMOCLAW_OPENCLAW_OTEL} \
NEMOCLAW_OPENCLAW_OTEL_ENDPOINT=${NEMOCLAW_OPENCLAW_OTEL_ENDPOINT} \
NEMOCLAW_OPENCLAW_OTEL_SERVICE_NAME=${NEMOCLAW_OPENCLAW_OTEL_SERVICE_NAME} \
Expand Down Expand Up @@ -746,8 +746,20 @@ RUN set -eu; \
openclaw plugins install "npm:@openclaw/diagnostics-otel@${OPENCLAW_VERSION}" --pin; \
fi; \
if [ "$NEMOCLAW_WEB_SEARCH_ENABLED" = "1" ]; then \
openclaw plugins install "npm:@openclaw/brave-plugin@${OPENCLAW_VERSION}" --pin; \
BRAVE_API_KEY=openshell:resolve:env:BRAVE_API_KEY openclaw doctor --fix --non-interactive; \
case "$NEMOCLAW_WEB_SEARCH_PROVIDER" in \
brave) \
openclaw plugins install "npm:@openclaw/brave-plugin@${OPENCLAW_VERSION}" --pin; \
BRAVE_API_KEY=openshell:resolve:env:BRAVE_API_KEY openclaw doctor --fix --non-interactive \
;; \
tavily) \
openclaw plugins inspect tavily --json > /dev/null; \
TAVILY_API_KEY=openshell:resolve:env:TAVILY_API_KEY openclaw doctor --fix --non-interactive \
;; \
*) \
echo "ERROR: unsupported web-search provider: $NEMOCLAW_WEB_SEARCH_PROVIDER" >&2; \
exit 1 \
;; \
esac; \
elif [ "$NEMOCLAW_OPENCLAW_OTEL" = "1" ]; then \
openclaw doctor --fix --non-interactive; \
fi
Expand Down
4 changes: 4 additions & 0 deletions agents/hermes/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,8 @@ ARG NEMOCLAW_INFERENCE_API=openai-completions
# API remains exposed separately on port 8642.
ARG CHAT_UI_URL=http://127.0.0.1:18789
ARG NEMOCLAW_MESSAGING_PLAN_B64=
ARG NEMOCLAW_WEB_SEARCH_ENABLED=0
ARG NEMOCLAW_WEB_SEARCH_PROVIDER=tavily
ARG NEMOCLAW_HERMES_TOOL_GATEWAY_BROKER=0
ARG NEMOCLAW_HERMES_TOOL_GATEWAY_PRESETS_B64=W10=
ARG NEMOCLAW_BUILD_ID=default
Expand All @@ -226,6 +228,8 @@ ENV NEMOCLAW_MODEL=${NEMOCLAW_MODEL} \
NEMOCLAW_INFERENCE_API=${NEMOCLAW_INFERENCE_API} \
CHAT_UI_URL=${CHAT_UI_URL} \
NEMOCLAW_MESSAGING_PLAN_B64=${NEMOCLAW_MESSAGING_PLAN_B64} \
NEMOCLAW_WEB_SEARCH_ENABLED=${NEMOCLAW_WEB_SEARCH_ENABLED} \
NEMOCLAW_WEB_SEARCH_PROVIDER=${NEMOCLAW_WEB_SEARCH_PROVIDER} \
NEMOCLAW_HERMES_TOOL_GATEWAY_BROKER=${NEMOCLAW_HERMES_TOOL_GATEWAY_BROKER} \
NEMOCLAW_HERMES_TOOL_GATEWAY_PRESETS_B64=${NEMOCLAW_HERMES_TOOL_GATEWAY_PRESETS_B64}

Expand Down
14 changes: 14 additions & 0 deletions agents/hermes/config/build-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,15 @@ import { Buffer } from "node:buffer";

import { normalizeProviderPlaceholderForEnvKey } from "../../../src/lib/messaging/provider-placeholders.ts";

export type HermesWebSearchProvider = "tavily";

export type HermesBuildSettings = {
model: string;
baseUrl: string;
providerKey: string;
upstreamProvider: string;
inferenceApi: string;
webSearchProvider: HermesWebSearchProvider | null;
messagingCredentialPlaceholders: Array<{
envKey: string;
placeholder: string;
Expand All @@ -31,6 +34,7 @@ export function readHermesBuildSettings(env: NodeJS.ProcessEnv): HermesBuildSett
providerKey: env.NEMOCLAW_PROVIDER_KEY || "custom",
upstreamProvider: env.NEMOCLAW_UPSTREAM_PROVIDER || env.NEMOCLAW_PROVIDER_KEY || "custom",
inferenceApi: env.NEMOCLAW_INFERENCE_API || "",
webSearchProvider: readWebSearchProvider(env),
messagingCredentialPlaceholders: readMessagingCredentialPlaceholders(env),
managedToolGateways: {
brokerEnabled: env.NEMOCLAW_HERMES_TOOL_GATEWAY_BROKER === "1",
Expand All @@ -39,6 +43,16 @@ export function readHermesBuildSettings(env: NodeJS.ProcessEnv): HermesBuildSett
};
}

function readWebSearchProvider(env: NodeJS.ProcessEnv): HermesWebSearchProvider | null {
if (env.NEMOCLAW_WEB_SEARCH_ENABLED !== "1") return null;

const provider = (env.NEMOCLAW_WEB_SEARCH_PROVIDER || "tavily").trim();
if (provider === "tavily") return provider;
throw new Error(
`Hermes NEMOCLAW_WEB_SEARCH_PROVIDER must be "tavily", got ${JSON.stringify(provider)}`,
);
}

function readRequiredEnv(env: NodeJS.ProcessEnv, name: string): string {
const value = env[name];
if (!value) {
Expand Down
18 changes: 15 additions & 3 deletions agents/hermes/config/hermes-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@
// SPDX-License-Identifier: Apache-2.0

import type { HermesBuildSettings } from "./build-env.ts";
import { applyManagedToolConfig, loadManagedToolGatewayMatrix } from "./managed-tool-gateway.ts";
import {
applyManagedToolConfig,
effectiveManagedToolGatewayPresets,
loadManagedToolGatewayMatrix,
} from "./managed-tool-gateway.ts";

const REMOTE_PLATFORM_TOOLSETS = [
"web",
Expand Down Expand Up @@ -150,9 +154,10 @@ export function buildHermesConfig(settings: HermesBuildSettings): Record<string,
},
};

if (settings.managedToolGateways.brokerEnabled) {
const managedToolGatewayPresets = effectiveManagedToolGatewayPresets(settings);
if (managedToolGatewayPresets.length > 0) {
const matrix = loadManagedToolGatewayMatrix();
for (const preset of settings.managedToolGateways.presets) {
for (const preset of managedToolGatewayPresets) {
const entry = matrix[preset];
if (!entry) {
throw new Error(`Unknown Hermes managed-tool gateway preset: ${preset}`);
Expand All @@ -161,6 +166,13 @@ export function buildHermesConfig(settings: HermesBuildSettings): Record<string,
}
}

// An explicitly selected Tavily credential takes precedence over the
// Nous-managed Firecrawl gateway. Replacing the whole section also removes
// `use_gateway: true`, which would otherwise keep Hermes on Firecrawl.
if (settings.webSearchProvider === "tavily") {
config.web = { backend: "tavily" };
}

return config;
}

Expand Down
16 changes: 13 additions & 3 deletions agents/hermes/config/hermes-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@
// SPDX-License-Identifier: Apache-2.0

import type { HermesBuildSettings } from "./build-env.ts";
import { loadManagedToolGatewayMatrix } from "./managed-tool-gateway.ts";
import {
effectiveManagedToolGatewayPresets,
loadManagedToolGatewayMatrix,
} from "./managed-tool-gateway.ts";

const TAVILY_API_KEY_PLACEHOLDER = "openshell:resolve:env:TAVILY_API_KEY";

export function buildHermesEnvLines(settings: HermesBuildSettings): string[] {
const envLines = ["API_SERVER_PORT=18642", "API_SERVER_HOST=127.0.0.1"];
Expand All @@ -11,11 +16,16 @@ export function buildHermesEnvLines(settings: HermesBuildSettings): string[] {
envLines.push(`${envKey}=${placeholder}`);
}

if (!settings.managedToolGateways.brokerEnabled) return envLines;
if (settings.webSearchProvider === "tavily") {
envLines.push(`TAVILY_API_KEY=${TAVILY_API_KEY_PLACEHOLDER}`);
}

const managedToolGatewayPresets = effectiveManagedToolGatewayPresets(settings);
if (managedToolGatewayPresets.length === 0) return envLines;

const matrix = loadManagedToolGatewayMatrix();
envLines.push("NEMOCLAW_HERMES_TOOL_GATEWAY_BROKER=1");
for (const preset of settings.managedToolGateways.presets) {
for (const preset of managedToolGatewayPresets) {
const entry = matrix[preset];
if (!entry) {
throw new Error(`Unknown Hermes managed-tool gateway preset: ${preset}`);
Expand Down
11 changes: 11 additions & 0 deletions agents/hermes/config/managed-tool-gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import { existsSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import type { HermesBuildSettings } from "./build-env.ts";

export type ManagedToolGatewayEntry = {
service: string;
Expand All @@ -14,6 +15,16 @@ export type ManagedToolGatewayEntry = {

export type ManagedToolGatewayMatrix = Record<string, ManagedToolGatewayEntry>;

export function effectiveManagedToolGatewayPresets(
settings: Pick<HermesBuildSettings, "managedToolGateways" | "webSearchProvider">,
): string[] {
if (!settings.managedToolGateways.brokerEnabled) return [];

return settings.managedToolGateways.presets.filter(
(preset) => !(settings.webSearchProvider === "tavily" && preset === "nous-web"),
);
}

export function loadManagedToolGatewayMatrix(): ManagedToolGatewayMatrix {
const scriptDir = dirname(fileURLToPath(import.meta.url));
const candidates = [
Expand Down
14 changes: 14 additions & 0 deletions agents/hermes/policy-permissive.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -345,3 +345,17 @@ network_policies:
access: full
binaries:
- { path: "/**" }

# Shields-down policies intentionally keep full host and binary scope. The
# maintained Tavily preset and provider profiles constrain normal access.
tavily:
name: tavily
endpoints:
- host: api.tavily.com
port: 443
protocol: rest
enforcement: enforce
request_body_credential_rewrite: true
access: full
binaries:
- { path: "/**" }
35 changes: 33 additions & 2 deletions agents/hermes/seed-dashboard-config.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,9 @@
/ ``model.base_url`` are empty so the auto-detect chain finds nothing.

This script mirrors the routing keys (``model``, ``custom_providers``, and the
informational ``_nemoclaw_upstream``) from the gateway config into the dashboard
config, preserving every other dashboard-local key. It also copies only the
informational ``_nemoclaw_upstream``) plus the exact native Tavily backend from
the gateway config into the dashboard config, preserving every other
dashboard-local key. It also copies only the
dashboard-needed dotenv keys (local API server context and managed-tool gateway
URLs) into the dashboard ``HERMES_HOME`` when paths are supplied, because Hermes
0.16 moved parts of dashboard chat/model setup behind dotenv loading.
Expand Down Expand Up @@ -65,6 +66,10 @@
"API_SERVER_HOST",
"API_SERVER_PORT",
"API_SERVER_KEY",
# This is a resolver placeholder, not a provider credential. It must
# remain exact so the dashboard cannot use this mirror to carry a raw
# Tavily key across the gateway/dashboard privilege boundary.
"TAVILY_API_KEY",
# Managed tool gateway broker URLs needed by dashboard-launched Hermes
# code paths. Do not copy messaging/provider/user credentials across
# this boundary; those stay in the gateway-owned .env.
Expand All @@ -77,6 +82,7 @@
}
)
API_SERVER_KEY_RE = re.compile(r"^[0-9a-f]{64}$")
TAVILY_API_KEY_PLACEHOLDER = "openshell:resolve:env:TAVILY_API_KEY"


class UnsafeDashboardSeedPathError(Exception):
Expand Down Expand Up @@ -313,6 +319,12 @@ def _route_api_mode(gateway: dict) -> str:

def _normalized_routing(gateway: dict) -> dict:
routing = {key: gateway[key] for key in _ROUTING_KEYS if key in gateway}
web = gateway.get("web")
if isinstance(web, dict) and web.get("backend") == "tavily":
# The backend selector is non-secret and must match the resolver-only
# TAVILY_API_KEY mirrored into the dashboard dotenv. Copy no other web
# settings across this privilege boundary.
routing["web"] = {"backend": "tavily"}
provider_name = _route_provider_name(gateway)
provider_key = _provider_key(provider_name)
model_name = _route_model_name(gateway)
Expand Down Expand Up @@ -402,6 +414,13 @@ def parse_env_assignment(line: str) -> tuple[str, str] | None:
file=sys.stderr,
)
return False
if key == "TAVILY_API_KEY" and value != TAVILY_API_KEY_PLACEHOLDER:
print(
"[SECURITY] Refusing to seed dashboard env because TAVILY_API_KEY "
"is not the canonical OpenShell resolver placeholder",
file=sys.stderr,
)
return False
mirrored_lines.append(line)

def write_env(dst_handle: TextIO) -> None:
Expand Down Expand Up @@ -471,6 +490,18 @@ def main(argv: list[str]) -> int:
)
dashboard = {}

# The seeder owns only web.backend. Merge or remove that field while
# preserving unrelated dashboard-local web settings.
managed_web = routing.pop("web", None)
dashboard_web = dict(dashboard.get("web") if isinstance(dashboard.get("web"), dict) else {})
if isinstance(managed_web, dict) and managed_web.get("backend") == "tavily":
dashboard_web["backend"] = "tavily"
elif dashboard_web.get("backend") == "tavily":
dashboard_web.pop("backend", None)
if dashboard_web:
dashboard["web"] = dashboard_web
else:
dashboard.pop("web", None)
dashboard.update(routing)

import yaml
Expand Down
14 changes: 14 additions & 0 deletions agents/openclaw/policy-permissive.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -317,3 +317,17 @@ network_policies:
access: full
binaries:
- { path: "/**" }

# Shields-down policies intentionally keep full host and binary scope. The
# maintained Tavily preset and provider profiles constrain normal access.
tavily:
name: tavily
endpoints:
- host: api.tavily.com
port: 443
protocol: rest
enforcement: enforce
request_body_credential_rewrite: true
access: full
binaries:
- { path: "/**" }
2 changes: 1 addition & 1 deletion ci/platform-matrix.json
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@
{
"name": "Web search backend",
"status": "caveated",
"notes": "Runtime-configurable web-search backend plumbed through the OpenShell gateway. Brave is the currently-implemented backend. See `src/lib/onboard/brave-provider-profile.ts` and `src/lib/onboard/web-search-flow.ts`. Users supply backend credentials during an onboard prompt. NemoClaw does not bundle a key."
"notes": "Onboarding supports Brave and Tavily for OpenClaw and Tavily for Hermes. Provider selection, agent configuration, and credential attachment are build-time inputs, so changing the provider recreates the sandbox. OpenShell replaces resolver placeholders at egress, including JSON request-body rewriting for Hermes Tavily. Users supply the backend credential; NemoClaw does not bundle a key."
}
],

Expand Down
2 changes: 1 addition & 1 deletion docs/deployment/deploy-to-remote-gpu.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ The post-create readiness wait defaults to 180 seconds (`NEMOCLAW_SANDBOX_READY_

- DGX Station first runs with large quantized models (70B+ parameter footprints, NVFP4 weights).
- Cloud VMs where the local image-build cache is cold and the upload runs over the public network.
- Hosts onboarding the Brave Web Search preset on the first run (the egress policy stack adds boot work).
- Hosts enabling a web search provider on the first run because the provider and egress policy stack add boot work.

Raise the budget before re-running onboard:

Expand Down
17 changes: 15 additions & 2 deletions docs/get-started/quickstart-hermes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ nemohermes onboard
## Respond to the Wizard

The onboard wizard asks for an inference provider, model, any required credential, and sandbox name before it prints the review summary.
After you confirm, NemoClaw registers inference, prompts for supported messaging channels, builds and starts the sandbox, sets up Hermes, then applies the selected network policy tier and presets.
After you confirm, NemoClaw registers inference, prompts for optional Tavily Search and supported messaging channels, builds and starts the sandbox, sets up Hermes, then applies the selected network policy tier and presets.
At any prompt, press Enter to accept the default shown in `[brackets]`, type `back` to return to the previous prompt, or type `exit` to quit.

The default Hermes sandbox name is `hermes`.
Expand All @@ -83,9 +83,14 @@ Sandbox name [hermes]: my-hermes
Choose the inference provider that matches where you want Hermes model traffic to go.
The provider options and credential environment variables are the same as the standard NemoClaw quickstart.
For provider-specific prompts, refer to the [Inference Options](../inference/inference-options) page.
The Hermes wizard does not ask for Brave Web Search because Hermes does not use NemoClaw's OpenClaw web-search configuration.
The Hermes wizard offers Tavily Search as its web search provider.
Hermes does not support the NemoClaw Brave Search path.
If you enable Tavily Search, enter `TAVILY_API_KEY` when prompted.
NemoClaw validates the key, stores it in a sandbox-scoped OpenShell provider, writes `web.backend: tavily` into the Hermes configuration, and writes only an OpenShell resolver placeholder into the generated environment.
If you authenticate Hermes through Nous Portal OAuth, the wizard can also prompt for managed Nous tool gateways such as web search, image generation, audio, browser automation, or managed code execution.
Those choices add the matching Hermes policy presets to the sandbox.
If you select both Tavily Search and the managed Nous web gateway, Tavily becomes the Hermes web search and extract backend.
NemoClaw removes `nous-web` from the effective managed-tool selection while preserving selected Nous image, audio, browser, and code tools.
API-key mode is inference-only and does not enable managed tool gateways.

After provider and model selection, review the summary and confirm the build.
Expand All @@ -107,17 +112,25 @@ export NEMOCLAW_AGENT=hermes
export NEMOCLAW_NON_INTERACTIVE=1
export NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1
export NEMOCLAW_SANDBOX_NAME=my-hermes
export NEMOCLAW_WEB_SEARCH_PROVIDER=tavily
export TAVILY_API_KEY=<your-tavily-key>
export NVIDIA_INFERENCE_API_KEY=<your-key>
curl -fsSL https://www.nvidia.com/nemoclaw.sh | bash
```

Use the provider variables from [Inference Options](../inference/inference-options) when you choose a different provider.
Set `NEMOCLAW_WEB_SEARCH_PROVIDER=none` when you want to disable web search explicitly.
When the selector is unset, Hermes enables Tavily automatically when `TAVILY_API_KEY` is available and ignores `BRAVE_API_KEY` because Brave Search is unsupported for Hermes.
Changing or disabling Tavily requires a sandbox recreation because the backend, credential attachment, and policy selection are build-time inputs.
Rerun onboarding with the new selection and accept the recreation, or pass `--recreate-sandbox`.
If a scripted installer rerun finds a failed onboarding session, choose whether to discard the saved state with `--fresh` or retry it with `nemohermes onboard --resume`.
For the recovery commands, refer to [Previous onboarding session failed](../reference/troubleshooting#previous-onboarding-session-failed).

## Connect to Hermes

When onboarding completes, NemoClaw prints the sandbox name, model, lifecycle commands, the Hermes dashboard URL, and the OpenAI-compatible API URL.
When Tavily is enabled, onboarding reads the generated Hermes configuration to confirm `web.backend: tavily` and sends a real search request through OpenShell's request-body credential rewrite path.
This verification reports a warning instead of aborting onboarding when the configuration or egress path needs attention.
Hermes exposes its built-in browser dashboard on port `18789`.
NemoClaw also forwards the OpenAI-compatible API on port `8642` for local clients, and the summary announces both URLs.
NemoClaw builds the Hermes dashboard assets into the sandbox image, so the dashboard starts without running `npm` as the sandbox user under `/opt/hermes`.
Expand Down
2 changes: 1 addition & 1 deletion docs/get-started/quickstart-langchain-deepagents-code.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ nemo-deepagents credentials add tavily-search --type tavily --credential TAVILY_
nemo-deepagents <sandbox-name> rebuild
```

The `tavily` preset only opens egress to `api.tavily.com:443`.
The shared `tavily` preset only opens `POST /search` and `POST /extract` egress to `api.tavily.com:443`.
Keep `TAVILY_API_KEY` in the host shell only; the gateway injects it at egress, and the sandbox never sees the raw value.
Because OpenShell attributes the harness's calls to the sandbox `python3` interpreter, this egress is process-wide for sandbox Python rather than a `dcode`-only boundary.

Expand Down
Loading
Loading