From f9261ccfea6f090c407cc1c89389f039332d940a Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Wed, 1 Jul 2026 18:57:29 -0700 Subject: [PATCH 1/3] feat(onboard): add Tavily web search providers Revive Tavily onboarding from #2105 for the current OpenClaw and Hermes architectures. Co-authored-by: Lakshya Agarwal Signed-off-by: Lakshya Agarwal Signed-off-by: Carlos Villela --- Dockerfile | 24 +- agents/hermes/Dockerfile | 4 + agents/hermes/config/build-env.ts | 14 + agents/hermes/config/hermes-config.ts | 18 +- agents/hermes/config/hermes-env.ts | 16 +- agents/hermes/config/managed-tool-gateway.ts | 11 + agents/hermes/policy-permissive.yaml | 12 + agents/hermes/seed-dashboard-config.py | 35 +- agents/openclaw/policy-permissive.yaml | 12 + ci/platform-matrix.json | 2 +- docs/deployment/deploy-to-remote-gpu.mdx | 2 +- docs/get-started/quickstart-hermes.mdx | 17 +- docs/get-started/quickstart.mdx | 27 +- docs/manage-sandboxes/runtime-controls.mdx | 9 +- .../customize-network-policy.mdx | 3 +- .../integration-policy-examples.mdx | 46 +- docs/reference/commands-nemohermes.mdx | 33 +- docs/reference/commands.mdx | 57 ++- docs/reference/network-policies.mdx | 11 +- docs/reference/platform-support.mdx | 2 +- docs/reference/troubleshooting.mdx | 67 ++- docs/security/best-practices.mdx | 13 + docs/security/credential-storage.mdx | 13 + .../policies/openclaw-sandbox-permissive.yaml | 12 + .../policies/presets/tavily.yaml | 8 +- .../provider-profiles/tavily-hermes-v1.yaml | 26 ++ scripts/generate-openclaw-config.mts | 34 +- scripts/install.sh | 7 +- src/lib/inference/web-search.test.ts | 69 ++- src/lib/inference/web-search.ts | 92 ++++ .../applier/build/messaging-build-applier.mts | 24 +- src/lib/onboard.ts | 33 +- .../onboard/brave-provider-profile.test.ts | 45 ++ src/lib/onboard/brave-provider-profile.ts | 94 +++- src/lib/onboard/dockerfile-patch.test.ts | 40 ++ src/lib/onboard/dockerfile-patch.ts | 12 +- .../onboard/extra-placeholder-keys.test.ts | 4 +- src/lib/onboard/extra-placeholder-keys.ts | 8 +- .../onboard/machine/core-flow-phases.test.ts | 2 +- src/lib/onboard/machine/core-flow-phases.ts | 2 + src/lib/onboard/machine/final-flow-phases.ts | 1 + src/lib/onboard/machine/flow-context.test.ts | 2 + src/lib/onboard/machine/flow-context.ts | 3 + src/lib/onboard/machine/handlers/policies.ts | 4 + .../onboard/machine/handlers/sandbox.test.ts | 142 +++++- src/lib/onboard/machine/handlers/sandbox.ts | 138 +++++- src/lib/onboard/messaging-prep.test.ts | 54 ++- src/lib/onboard/messaging-prep.ts | 37 +- src/lib/onboard/policy-presets.ts | 4 +- .../onboard/policy-resume-selection.test.ts | 82 ++++ src/lib/onboard/policy-resume-selection.ts | 45 +- src/lib/onboard/policy-selection.ts | 74 ++- .../sandbox-messaging-preflight.test.ts | 25 +- .../onboard/sandbox-messaging-preflight.ts | 13 +- src/lib/onboard/sandbox-provider-cleanup.ts | 1 + src/lib/onboard/summary.test.ts | 9 + src/lib/onboard/summary.ts | 10 +- src/lib/onboard/web-search-flow.test.ts | 227 +++++++++- src/lib/onboard/web-search-flow.ts | 428 +++++++++++++----- src/lib/onboard/web-search-support.test.ts | 55 ++- src/lib/onboard/web-search-support.ts | 45 +- src/lib/onboard/web-search-verify.test.ts | 111 ++++- src/lib/onboard/web-search-verify.ts | 169 +++++-- src/lib/policy/index.ts | 3 +- src/lib/state/onboard-session.test.ts | 36 +- src/lib/state/onboard-session.ts | 12 +- src/lib/state/openclaw-config-merge.test.ts | 92 ++++ src/lib/state/openclaw-config-merge.ts | 45 +- test/cli/destroy-detach-order.test.ts | 1 + test/generate-hermes-config.test.ts | 66 +++ ...enerate-openclaw-config-web-search.test.ts | 53 +++ test/generate-openclaw-config.test.ts | 2 +- test/hermes-gateway-wrapper.test.ts | 2 + test/messaging-build-applier.test.ts | 23 + test/onboard-brave-validation.test.ts | 25 +- test/onboard-policy-suggestions.test.ts | 46 +- test/sandbox-provider-cleanup.test.ts | 11 +- test/sandbox-provisioning-tavily.test.ts | 116 +++++ test/sandbox-provisioning.test.ts | 1 + test/seed-hermes-dashboard-config.test.ts | 47 +- test/tavily-preset.test.ts | 3 + test/validate-blueprint.test.ts | 63 ++- 82 files changed, 2874 insertions(+), 412 deletions(-) create mode 100644 nemoclaw-blueprint/provider-profiles/tavily-hermes-v1.yaml create mode 100644 src/lib/onboard/policy-resume-selection.test.ts create mode 100644 test/generate-openclaw-config-web-search.test.ts create mode 100644 test/sandbox-provisioning-tavily.test.ts diff --git a/Dockerfile b/Dockerfile index af84f993e20..59a8a40c78e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -655,11 +655,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 @@ -689,6 +688,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} \ @@ -735,8 +735,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 diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index 5ec250e5fc7..a218598aabb 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -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 @@ -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} diff --git a/agents/hermes/config/build-env.ts b/agents/hermes/config/build-env.ts index 3e53673a752..dfb140134b4 100644 --- a/agents/hermes/config/build-env.ts +++ b/agents/hermes/config/build-env.ts @@ -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; @@ -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", @@ -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) { diff --git a/agents/hermes/config/hermes-config.ts b/agents/hermes/config/hermes-config.ts index 12e6772f3f0..66e8b7cd87c 100644 --- a/agents/hermes/config/hermes-config.ts +++ b/agents/hermes/config/hermes-config.ts @@ -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", @@ -150,9 +154,10 @@ export function buildHermesConfig(settings: HermesBuildSettings): Record 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}`); @@ -161,6 +166,13 @@ export function buildHermesConfig(settings: HermesBuildSettings): Record; +export function effectiveManagedToolGatewayPresets( + settings: Pick, +): 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 = [ diff --git a/agents/hermes/policy-permissive.yaml b/agents/hermes/policy-permissive.yaml index a6663e08742..8f6ad98ebd6 100644 --- a/agents/hermes/policy-permissive.yaml +++ b/agents/hermes/policy-permissive.yaml @@ -345,3 +345,15 @@ network_policies: access: full binaries: - { path: "/**" } + + tavily: + name: tavily + endpoints: + - host: api.tavily.com + port: 443 + protocol: rest + enforcement: enforce + request_body_credential_rewrite: true + access: full + binaries: + - { path: "/**" } diff --git a/agents/hermes/seed-dashboard-config.py b/agents/hermes/seed-dashboard-config.py index 5517da31ad6..3606a3a53a9 100755 --- a/agents/hermes/seed-dashboard-config.py +++ b/agents/hermes/seed-dashboard-config.py @@ -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. @@ -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. @@ -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): @@ -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) @@ -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: @@ -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 diff --git a/agents/openclaw/policy-permissive.yaml b/agents/openclaw/policy-permissive.yaml index 18c4f7f1f5f..bafbe53bd62 100644 --- a/agents/openclaw/policy-permissive.yaml +++ b/agents/openclaw/policy-permissive.yaml @@ -317,3 +317,15 @@ network_policies: access: full binaries: - { path: "/**" } + + tavily: + name: tavily + endpoints: + - host: api.tavily.com + port: 443 + protocol: rest + enforcement: enforce + request_body_credential_rewrite: true + access: full + binaries: + - { path: "/**" } diff --git a/ci/platform-matrix.json b/ci/platform-matrix.json index 69823f9b65b..13f367cc5b2 100644 --- a/ci/platform-matrix.json +++ b/ci/platform-matrix.json @@ -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." } ], diff --git a/docs/deployment/deploy-to-remote-gpu.mdx b/docs/deployment/deploy-to-remote-gpu.mdx index 2a408665940..7324132912b 100644 --- a/docs/deployment/deploy-to-remote-gpu.mdx +++ b/docs/deployment/deploy-to-remote-gpu.mdx @@ -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: diff --git a/docs/get-started/quickstart-hermes.mdx b/docs/get-started/quickstart-hermes.mdx index 9c4e687c1d4..ab4c1c05275 100644 --- a/docs/get-started/quickstart-hermes.mdx +++ b/docs/get-started/quickstart-hermes.mdx @@ -56,7 +56,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`. @@ -70,9 +70,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. @@ -94,17 +99,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= export NVIDIA_INFERENCE_API_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`. diff --git a/docs/get-started/quickstart.mdx b/docs/get-started/quickstart.mdx index 5db159d8228..07ca3a887bf 100644 --- a/docs/get-started/quickstart.mdx +++ b/docs/get-started/quickstart.mdx @@ -192,8 +192,26 @@ Non-interactive runs (`NEMOCLAW_NON_INTERACTIVE=1`) print the summary for log cl ### Configure Web Search and Messaging After you confirm the summary, NemoClaw registers the selected provider with the OpenShell gateway and sets the `inference.local` route. -The wizard then asks whether to enable Brave Web Search. -If you enable it, enter a Brave Search API key when prompted. +The wizard then asks whether to enable web search and offers Brave Search or Tavily Search. +Enter `BRAVE_API_KEY` for Brave Search or `TAVILY_API_KEY` for Tavily Search when prompted. +NemoClaw validates the selected key before it builds the sandbox, registers a sandbox-scoped OpenShell provider, and writes only an OpenShell resolver placeholder into the OpenClaw configuration. +OpenShell replaces the placeholder with the real key at egress. + +For non-interactive onboarding, select the provider explicitly and export its key. + +```bash +export NEMOCLAW_WEB_SEARCH_PROVIDER=tavily +export TAVILY_API_KEY= +nemoclaw onboard --non-interactive +``` + +Set `NEMOCLAW_WEB_SEARCH_PROVIDER=none` to disable web search explicitly. +When you leave the selector unset, OpenClaw chooses Brave Search when `BRAVE_API_KEY` is available, then Tavily Search when only `TAVILY_API_KEY` is available. +Brave Search wins when both keys are available so existing non-interactive setups keep their previous behavior. + +The web search provider is part of the sandbox image and agent configuration. +If you change or disable it later, rerun onboarding with the new selection and accept the sandbox recreation, or pass `--recreate-sandbox`. +NemoClaw backs up the supported workspace state before recreation and restores it into the replacement sandbox. The wizard also offers messaging channels such as Telegram, Discord, Slack, WeChat, and WhatsApp. Press a channel number to toggle it, then press Enter to continue. @@ -207,7 +225,8 @@ Review [Messaging Channels](../manage-sandboxes/messaging-channels) before enabl After the sandbox image builds and OpenClaw starts inside the sandbox, NemoClaw asks which network policy tier to apply. Web search and messaging selections happen before this point so the sandbox image and the policy suggestions stay aligned. -The default **Balanced** tier includes common development presets such as npm, PyPI, Hugging Face, Homebrew, and Brave Search when the selected agent supports web search. Apply the `weather` preset explicitly if your agent needs read-only weather lookups. +The default **Balanced** tier includes common development presets such as npm, PyPI, Hugging Face, and Homebrew, plus the `brave` or `tavily` preset when you selected that web search provider. +Apply the `weather` preset explicitly if your agent needs read-only weather lookups. OpenClaw sandboxes also receive the `openclaw-pricing` preset automatically so session-cost records can populate without manual configuration. Use the arrow keys or `j` and `k` to move, Space to select, and Enter to confirm. @@ -216,6 +235,8 @@ Press `r` to toggle a selected preset between read-only and read-write when the When the install completes, a summary confirms the running environment. Before printing the summary, NemoClaw verifies that the sandbox gateway and dashboard port forward are reachable. +When web search is enabled, NemoClaw also checks the selected OpenClaw provider configuration and sends a real search request through the sandbox egress path. +This check reports a warning instead of aborting onboarding when the provider or egress path needs attention. NemoClaw reports inference route and messaging bridge checks as warnings when they need more time or additional configuration. The `Model` and provider line reflects the inference option you picked during onboarding. The example below shows the result if you picked an OpenAI-compatible endpoint during onboarding. diff --git a/docs/manage-sandboxes/runtime-controls.mdx b/docs/manage-sandboxes/runtime-controls.mdx index 56ef658cbf9..bdf392c30fd 100644 --- a/docs/manage-sandboxes/runtime-controls.mdx +++ b/docs/manage-sandboxes/runtime-controls.mdx @@ -27,7 +27,7 @@ The following table maps each commonly changed item to the layer that owns it an | Inference provider (cloud, NVIDIA Endpoints, local Ollama / vLLM, compatible-endpoint, …) | Runtime route and config update while shields are down; rebuild only if you need to recreate the image | Run `$$nemoclaw shields down`, then `$$nemoclaw inference set`, then restore shields | | Inference model on the current provider | Runtime route and config update while shields are down | Run `$$nemoclaw shields down`, then `$$nemoclaw inference set`, then restore shields | | Sub-agent (Hermes / OpenClaw / …) | Re-onboard required (the sub-agent and its workspace are baked at onboard) | `$$nemoclaw onboard --recreate-sandbox` | -| Network policy preset (slack, discord, telegram, brave, …) | Runtime. Applies on the next request; rebuild only required if the preset adds bind-mounted secrets | `$$nemoclaw policy-add ` / `policy-remove ` | +| Network policy preset (slack, discord, telegram, brave, tavily, and others) | Runtime. Applies on the next request; rebuild only required if the preset adds bind-mounted secrets | `$$nemoclaw policy-add ` / `policy-remove ` | | Network allow-list (custom hosts) | Runtime. Picks up at next request | `openshell policy set` or interactive approval prompt at the gateway | | Channel tokens (Slack / Discord / Telegram bot credentials) | Rebuild required (tokens are baked into the sandbox image at onboard so they never leave the host clear-text) | `$$nemoclaw channels add ` then accept the rebuild prompt | | Channel enable/disable (turn a configured channel off without removing the token) | Rebuild required (`openclaw.json` is the source of truth at runtime, refer to #3453) | `$$nemoclaw channels stop ` then rebuild | @@ -35,12 +35,12 @@ The following table maps each commonly changed item to the layer that owns it an | Dashboard bind address (loopback compared to all interfaces) | Runtime. Applies on next `connect` | `NEMOCLAW_DASHBOARD_BIND=0.0.0.0 $$nemoclaw connect` (refer to #3259) | | Gateway process environment or startup-only plugin state | Runtime after gateway restart | `$$nemoclaw gateway restart` | | Default OpenClaw workspace template seed (`AGENTS.md`, `SOUL.md`, `IDENTITY.md`, `USER.md`, `TOOLS.md`, `HEARTBEAT.md`) | Locked at first sandbox boot. Re-onboard required to change the bake-time choice. | Set `NEMOCLAW_MINIMAL_BOOTSTRAP=1` before `$$nemoclaw onboard` to skip default template seeding for new/pristine workspaces. **Does not delete files already present.** Partial mitigation for #2598 (cuts ~3k tokens of project-context overhead off OpenClaw's per-turn bootstrap injection). | -| Web search backend (Brave, Tavily, and so on) | Runtime through `web.backend` config flag; rebuild only if `web.fetchEnabled` flips | `$$nemoclaw config set --key web.backend --value tavily` | +| Web search provider (Brave, Tavily, or disabled) | Rebuild required. Onboarding bakes the provider plugin configuration and credential attachment into the image. | Set `NEMOCLAW_WEB_SEARCH_PROVIDER=brave`, `tavily`, or `none`, rerun `$$nemoclaw onboard`, and accept recreation or pass `--recreate-sandbox`. | | Filesystem layout (Landlock zones, read-only mounts, container caps) | **Locked at creation**. No runtime change | Re-onboard with `$$nemoclaw onboard --recreate-sandbox` | | Sandbox name | **Locked at creation** | Re-onboard with a different `--name` | | GPU passthrough enable / device selector | **Locked at creation** | Re-onboard with `--gpu` / `--sandbox-gpu-device` | | Agents allow-list (`agents.list` in `openclaw.json`) | Runtime. OpenClaw hot-reloads on config change | Prefer agent or NemoClaw commands that keep host and sandbox state aligned | -| `openclaw.json` keys (general: model, agents.list, web.backend, channel config, and so on) | Mixed. Supported config and inference updates run while shields are down; image, policy, and channel changes can still require rebuild. | Use `$$nemoclaw inference set` or `$$nemoclaw config set` so the config and integrity hash change together | +| `openclaw.json` keys (general model, agents.list, supported plugin config, channel config, and other settings) | Mixed. Supported config and inference updates run while shields are down; image, policy, web search, and channel changes can still require rebuild. | Use `$$nemoclaw inference set` or `$$nemoclaw config set` so the config and integrity hash change together | If a row above conflicts with what you observe, the runtime source of truth inside the sandbox is `/sandbox/.openclaw/openclaw.json`; the host registry caches metadata but the image and OpenClaw read from the in-sandbox file. OpenClaw config and inference changes are refused while shields are up. @@ -60,12 +60,13 @@ If preflight detects an unsafe path, invalid config, invalid ownership posture, | Inference provider (cloud, NVIDIA Endpoints, local Ollama / vLLM, compatible-endpoint, …) | Runtime route changes apply immediately; rebuild if you need to rebake model metadata into the image | `$$nemoclaw inference set` for route changes, or `$$nemoclaw rebuild` after changing build-time settings | | Inference model on the current provider | Hot-reloadable through the Hermes config sync path | `$$nemoclaw inference set` | | Agent runtime (Hermes compared to OpenClaw) | Re-onboard required (the agent and its state layout are baked at onboard) | `$$nemoclaw onboard --recreate-sandbox` or `nemoclaw onboard --agent openclaw --recreate-sandbox` | -| Network policy preset (slack, discord, telegram, brave, …) | Runtime. Applies on the next request; rebuild only required if the preset adds bind-mounted secrets | `$$nemoclaw policy-add ` / `policy-remove ` | +| Network policy preset (slack, discord, telegram, tavily, and others) | Runtime. Applies on the next request; rebuild only required if the preset adds bind-mounted secrets | `$$nemoclaw policy-add ` / `policy-remove ` | | Network allow-list (custom hosts) | Runtime. Picks up at next request | `openshell policy set` or interactive approval prompt at the gateway | | Channel tokens (Slack / Discord / Telegram bot credentials) | Rebuild required (tokens are baked into the sandbox image at onboard so they never leave the host clear-text) | `$$nemoclaw channels add ` then accept the rebuild prompt | | Channel enable/disable (turn a configured channel off without removing the token) | Rebuild required (`/sandbox/.hermes/.env` and Hermes config are baked at image build time) | `$$nemoclaw channels stop ` then rebuild | | API/dashboard forward port | Runtime. The host-side forward is re-resolved on next `connect`; the Hermes entrypoint supervisor continues to own the internal API and dashboard relays. | `$$nemoclaw connect` or `openshell forward start` | | Hermes plugin code, Langfuse settings, or other startup-only runtime config | Runtime after a supported host-side update and gateway restart | Bake plugin code into the image or use a supported host config command, then run `$$nemoclaw gateway restart` | +| Web search provider (Tavily or disabled) | Rebuild required. Onboarding bakes `web.backend`, the environment placeholder, and the credential attachment into the image. | Set `NEMOCLAW_WEB_SEARCH_PROVIDER=tavily` or `none`, rerun `$$nemoclaw onboard`, and accept recreation or pass `--recreate-sandbox`. | | Filesystem layout (Landlock zones, read-only mounts, container caps) | **Locked at creation**. No runtime change | Re-onboard with `$$nemoclaw onboard --recreate-sandbox` | | Sandbox name | **Locked at creation** | Re-onboard with a different `--name` | | GPU passthrough enable / device selector | **Locked at creation** | Re-onboard with `--gpu` / `--sandbox-gpu-device` | diff --git a/docs/network-policy/customize-network-policy.mdx b/docs/network-policy/customize-network-policy.mdx index ef7697688e8..6ff98de50af 100644 --- a/docs/network-policy/customize-network-policy.mdx +++ b/docs/network-policy/customize-network-policy.mdx @@ -220,6 +220,7 @@ Available presets: | `outlook` | Microsoft 365 and Outlook | | `pypi` | Python Package Index | | `slack` | Slack API and webhooks | +| `tavily` | Tavily Search API | | `telegram` | Telegram Bot API | | `wechat` | WeChat (personal) iLink Bot API (experimental) | | `whatsapp` | WhatsApp Web messaging (experimental) | @@ -412,7 +413,7 @@ For `unsupported`, surface the limitation to the user without retrying. ## Related Topics - [Approve or Deny Agent Network Requests](approve-network-requests) for real-time operator approval. -- [Common Integration Policy Examples](integration-policy-examples) for maintained preset examples such as Outlook, messaging, GitHub, Jira, Brave Search, package managers, Hugging Face, and local inference. +- [Common Integration Policy Examples](integration-policy-examples) for maintained preset examples such as Outlook, messaging, GitHub, Jira, web search, package managers, Hugging Face, and local inference. - [Network Policies](../reference/network-policies) for the full baseline policy reference. - OpenShell [Policy Schema](https://docs.nvidia.com/openshell/latest/reference/policy-schema.html) for the full YAML policy schema reference. - OpenShell [Sandbox Policies](https://docs.nvidia.com/openshell/latest/sandboxes/policies.html) for applying, iterating, and debugging policies at the OpenShell layer. diff --git a/docs/network-policy/integration-policy-examples.mdx b/docs/network-policy/integration-policy-examples.mdx index 62f675ac24d..d914b1945fe 100644 --- a/docs/network-policy/integration-policy-examples.mdx +++ b/docs/network-policy/integration-policy-examples.mdx @@ -4,7 +4,7 @@ title: "Common NemoClaw Integration Policy Examples" sidebar-title: "Integration Policy Examples" description: "Guided examples for adding post-install integration policy access to a NemoClaw sandbox." -description-agent: "Guides users through common post-install integration policy setup for maintained NemoClaw policy presets, including Outlook, messaging channels, GitHub, Jira, Brave Search, package managers, Hugging Face, local inference, and OpenShell approval workflows." +description-agent: "Guides users through common post-install integration policy setup for maintained NemoClaw policy presets, including Outlook, messaging channels, GitHub, Jira, Brave and Tavily web search, package managers, Hugging Face, local inference, and OpenShell approval workflows." keywords: ["nemoclaw integration policy examples", "post-install policy setup", "openshell approval workflow", "policy preset"] content: type: "how_to" @@ -63,6 +63,7 @@ NemoClaw ships maintained policy presets for common services in `nemoclaw-bluepr | Public reference APIs | `public-reference` | | Python Package Index | `pypi` | | Slack messaging | `slack` | +| Tavily Search | `tavily` | | Telegram Bot API | `telegram` | | Weather and geocoding APIs | `weather` | | WeChat (personal) iLink Bot API (experimental) | `wechat` | @@ -237,17 +238,52 @@ $$nemoclaw my-assistant policy-remove github --yes $$nemoclaw my-assistant policy-remove jira --yes ``` -## Brave Search +## Web Search -The default Balanced policy tier includes `brave`. -If you chose Restricted during onboarding or removed the preset later, add it before enabling Brave Search workflows: +Web search requires both the selected provider's credential and its matching network policy preset. +Onboarding suggests `brave` or `tavily` only when you selected that provider, including under the Restricted tier. +If you unselected or removed the matching preset, preview and add it before using web search. + + + +OpenClaw supports Brave Search and Tavily Search. +Apply only the preset that matches the provider you selected during onboarding. + +Use these commands for Brave Search. ```bash $$nemoclaw my-assistant policy-add brave --dry-run $$nemoclaw my-assistant policy-add brave --yes ``` -The Brave Search API key is still configured separately during onboarding or through the web search setup flow. +Use these commands for Tavily Search. + +```bash +$$nemoclaw my-assistant policy-add tavily --dry-run +$$nemoclaw my-assistant policy-add tavily --yes +``` + +Rerun onboarding when you change providers because the OpenClaw plugin configuration and OpenShell credential attachment are part of the sandbox image. +Configure the matching `BRAVE_API_KEY` or `TAVILY_API_KEY` during that onboarding run. + + + + +Hermes supports Tavily Search through NemoClaw onboarding and does not support Brave Search. +Apply the `tavily` preset if it is missing. + +```bash +$$nemoclaw my-assistant policy-add tavily --dry-run +$$nemoclaw my-assistant policy-add tavily --yes +``` + +Rerun onboarding when you enable or disable Tavily because the Hermes backend and OpenShell credential attachment are part of the sandbox image. +Configure `TAVILY_API_KEY` during that onboarding run. + + + +The `tavily` preset enables request-body credential rewriting because Hermes sends its resolver placeholder in the JSON `api_key` field. +OpenShell replaces that placeholder at egress, so the raw key is not written into the sandbox configuration. ## Weather and Public Reference Lookups diff --git a/docs/reference/commands-nemohermes.mdx b/docs/reference/commands-nemohermes.mdx index 3d54a051797..1602c668100 100644 --- a/docs/reference/commands-nemohermes.mdx +++ b/docs/reference/commands-nemohermes.mdx @@ -149,8 +149,8 @@ Three tiers are available: | Tier | Description | |------|-------------| -| Restricted | Base sandbox only. No third-party network access beyond inference and core agent tooling. | -| Balanced (default) | Full dev tooling and web search when the active agent supports web search. Package installs, model downloads, and inference. No messaging platform access. | +| Restricted | No tier defaults. Web search or messaging integrations selected earlier can still add their required presets; deselect them during policy review for baseline-only access. | +| Balanced (default) | Full dev tooling and a selected, supported web search provider. Package installs, model downloads, and inference. No messaging platform access by default. | | Open | Broad access across third-party services including messaging and productivity. Agent-specific unsupported presets are filtered out. | After selecting a tier, the wizard shows a combined preset and access-mode screen where you can include or exclude individual presets and toggle each between read and read-write access. @@ -174,9 +174,12 @@ Onboarding applies tier defaults and preserves any presets you previously added Use `custom` with `NEMOCLAW_POLICY_PRESETS` when you want the explicit list to be authoritative. Onboarding removes any preset that is not in the list. `skip` leaves the applied set untouched and does not apply tier defaults. -NemoClaw filters tier suggestions and resume selections by active agent support, so unsupported presets such as Brave Search are not reapplied to agents that do not support them. +NemoClaw filters tier suggestions and resume selections by active agent support and the selected web search provider. +During automatic suggestion and resume reconciliation, it removes stale `brave`, `tavily`, and Hermes `nous-web` selections when they conflict with the active agent or selected provider. +An explicit `custom` preset list or interactive manual selection remains operator-controlled. Hermes managed-tool gateway selections add matching Hermes-specific policy presets, such as `nous-web`, `nous-image`, `nous-audio`, `nous-browser`, and `nous-code`, without applying unsupported OpenClaw-only presets. +When Tavily Search is selected, it replaces `nous-web` as the Hermes web search and extract backend while the other selected Nous tools remain enabled. | Value | Behaviour | |-------|-----------| @@ -204,9 +207,22 @@ curl -fsSL https://www.nvidia.com/nemoclaw.sh | NEMOCLAW_NON_INTERACTIVE=1 NEMOC If the installer cannot prompt for the notice in a terminal and no explicit acceptance is set, it exits before installing Node.js or the NemoClaw CLI. -Hermes does not use NemoClaw's OpenClaw Brave Search setup. -If you authenticate Hermes through Nous Portal OAuth, the wizard can prompt for managed Nous tool gateways such as web search. -API-key mode is inference-only and does not enable managed tool gateways. +Hermes supports Tavily Search through NemoClaw onboarding and does not support Brave Search. +To enable Tavily in non-interactive mode, set the provider and matching key. + +```bash +NEMOCLAW_WEB_SEARCH_PROVIDER=tavily \ +TAVILY_API_KEY=... \ + nemohermes onboard --non-interactive +``` + +Set `NEMOCLAW_WEB_SEARCH_PROVIDER=none` to disable web search explicitly. +When the selector is unset, NemoClaw enables Tavily when `TAVILY_API_KEY` is available and ignores `BRAVE_API_KEY` for Hermes. +An explicit Tavily selection with no key exits before sandbox creation. +A Tavily key that fails validation prints a warning, disables web search for that run, and lets the rest of onboarding continue. +Changing or disabling Tavily recreates the sandbox because the Hermes backend, environment placeholder, and credential attachment are part of the image. +If you also select the Nous-managed web gateway through Nous Portal OAuth, Tavily replaces `nous-web` while other selected Nous tools remain enabled. +API-key mode is inference-only and does not enable managed Nous tool gateways. The wizard prompts for a sandbox name. Names must be 1 to 63 characters, lowercase, start with a letter, contain only letters, numbers, and internal hyphens, and end with a letter or number. @@ -1950,6 +1966,9 @@ Set them before running `nemohermes onboard`. | `NEMOCLAW_OLLAMA_INSTALL_MODE` | `system`, `user`, or empty/unset | Pins the Linux Ollama install location. Refer to the Linux Ollama install mode details below. | | `NEMOCLAW_PROXY_HOST` | hostname or IP | Overrides the sandbox-side outbound HTTP proxy host. Defaults to `10.200.0.1`. | | `NEMOCLAW_PROXY_PORT` | integer port | Overrides the sandbox-side outbound HTTP proxy port. Defaults to `3128`. | +| `NEMOCLAW_WEB_SEARCH_PROVIDER` | `brave`, `tavily`, or `none` | Selects the web search provider in non-interactive onboarding or disables web search explicitly. OpenClaw supports `brave` and `tavily`; Hermes supports `tavily` only. When unset, supported credential variables trigger implicit selection with Brave-first precedence. | +| `BRAVE_API_KEY` | Brave Search API key | Supplies and implicitly selects Brave Search for OpenClaw when no web search provider is set. NemoClaw validates the key and stores it in OpenShell rather than the sandbox. | +| `TAVILY_API_KEY` | Tavily Search API key | Supplies and implicitly selects Tavily Search when no higher-precedence supported web search credential is available. NemoClaw validates the key and stores it in OpenShell rather than the sandbox. | | `NEMOCLAW_OPENCLAW_OTEL` | `1` to enable | Enables OpenClaw conversation diagnostics export through the `diagnostics-otel` plugin. Disabled by default. | | `NEMOCLAW_OPENCLAW_OTEL_ENDPOINT` | OTLP/HTTP URL | Sets the OpenTelemetry collector endpoint for OpenClaw diagnostics. Defaults to `http://host.openshell.internal:4318` when `NEMOCLAW_OPENCLAW_OTEL=1`. | | `NEMOCLAW_OPENCLAW_OTEL_SERVICE_NAME` | service name | Sets the OTEL `service.name` for OpenClaw gateway spans. Defaults to `openclaw-gateway`. | @@ -1992,7 +2011,7 @@ The Hermes profile `.env` files are operator-owned: write `${TELEGRAM_BOT_TOKEN_ NemoClaw never reads, writes, or rewrites these `.env` files; verify after onboarding that each profile's `.env` references the placeholder and that no raw bot token value sits on disk. Entries are split on whitespace and commas and must match `^[A-Z][A-Z0-9_]{0,127}$`. -Each entry must extend a canonical channel envKey with a non-empty `_` (for example `TELEGRAM_BOT_TOKEN_AGENT_A`); the canonical envKeys are `TELEGRAM_BOT_TOKEN`, `DISCORD_BOT_TOKEN`, `SLACK_BOT_TOKEN`, `SLACK_APP_TOKEN`, `WECHAT_BOT_TOKEN`, and `BRAVE_API_KEY`. +Each entry must extend a canonical channel envKey with a non-empty `_` (for example `TELEGRAM_BOT_TOKEN_AGENT_A`); the canonical envKeys are `TELEGRAM_BOT_TOKEN`, `DISCORD_BOT_TOKEN`, `SLACK_BOT_TOKEN`, `SLACK_APP_TOKEN`, `WECHAT_BOT_TOKEN`, `BRAVE_API_KEY`, and `TAVILY_API_KEY`. Bare canonical envKeys, the control env itself, and arbitrary host secret names (`GITHUB_TOKEN`, `AWS_SECRET_ACCESS_KEY`, `KUBECONFIG`, and similar) are refused so they cannot leak into the sandbox provider gateway. Duplicates are dropped silently. The list is capped at 32 entries per sandbox. diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 926127f9078..74275aa1638 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -193,8 +193,8 @@ Three tiers are available: | Tier | Description | |------|-------------| -| Restricted | Base sandbox only. No third-party network access beyond inference and core agent tooling. | -| Balanced (default) | Full dev tooling and web search when the active agent supports web search. Package installs, model downloads, and inference. No messaging platform access. | +| Restricted | No tier defaults. Web search or messaging integrations selected earlier can still add their required presets; deselect them during policy review for baseline-only access. | +| Balanced (default) | Full dev tooling and a selected, supported web search provider. Package installs, model downloads, and inference. No messaging platform access by default. | | Open | Broad access across third-party services including messaging and productivity. Agent-specific unsupported presets are filtered out. | After selecting a tier, the wizard shows a combined preset and access-mode screen where you can include or exclude individual presets and toggle each between read and read-write access. @@ -218,11 +218,14 @@ Onboarding applies tier defaults and preserves any presets you previously added Use `custom` with `NEMOCLAW_POLICY_PRESETS` when you want the explicit list to be authoritative. Onboarding removes any preset that is not in the list. `skip` leaves the applied set untouched and does not apply tier defaults. -NemoClaw filters tier suggestions and resume selections by active agent support, so unsupported presets such as Brave Search are not reapplied to agents that do not support them. +NemoClaw filters tier suggestions and resume selections by active agent support and the selected web search provider. +During automatic suggestion and resume reconciliation, it removes stale `brave`, `tavily`, and Hermes `nous-web` selections when they conflict with the active agent or selected provider. +An explicit `custom` preset list or interactive manual selection remains operator-controlled. Hermes managed-tool gateway selections add matching Hermes-specific policy presets, such as `nous-web`, `nous-image`, `nous-audio`, `nous-browser`, and `nous-code`, without applying unsupported OpenClaw-only presets. +When Tavily Search is selected, it replaces `nous-web` as the Hermes web search and extract backend while the other selected Nous tools remain enabled. @@ -234,9 +237,10 @@ Hermes managed-tool gateway selections add matching Hermes-specific policy prese -If you enable Brave Search during onboarding, NemoClaw registers a Brave Search OpenShell provider and keeps `openclaw.json` on an OpenShell credential placeholder. -At egress, OpenShell rewrites Brave's `X-Subscription-Token` header with the real `BRAVE_API_KEY`. -Treat Brave Search as an explicit opt-in and use a dedicated low-privilege Brave key. +OpenClaw onboarding supports Brave Search and Tavily Search. +NemoClaw registers a sandbox-scoped OpenShell provider and keeps `openclaw.json` on an OpenShell credential placeholder. +At egress, OpenShell rewrites Brave's `X-Subscription-Token` header with `BRAVE_API_KEY` or Tavily's `Authorization` header with `TAVILY_API_KEY`. +Treat web search as an explicit opt-in and use a dedicated low-privilege key. For non-interactive onboarding, you must explicitly accept the third-party software notice: @@ -261,24 +265,42 @@ If the installer cannot prompt for the notice in a terminal and no explicit acce -To enable Brave Search in non-interactive mode, set: +To enable Tavily Search in non-interactive mode, set the provider and matching key. ```bash -BRAVE_API_KEY=... \ +NEMOCLAW_WEB_SEARCH_PROVIDER=tavily \ +TAVILY_API_KEY=... \ $$nemoclaw onboard --non-interactive ``` -`BRAVE_API_KEY` enables Brave Search in non-interactive mode and also enables `web_fetch`. -If Brave Search key validation fails in non-interactive mode, onboarding prints a warning, skips web search setup, and continues with the rest of the sandbox setup. -After fixing the key, rerun onboarding with `BRAVE_API_KEY` set so NemoClaw can validate the key, register the Brave Search provider, and apply the `brave` policy preset. -If the sandbox already exists without web search, accept the recreate prompt or pass `--recreate-sandbox`. +Use `NEMOCLAW_WEB_SEARCH_PROVIDER=brave` with `BRAVE_API_KEY` for Brave Search, or set the provider to `none` to disable web search explicitly. +When the provider selector is unset, NemoClaw chooses Brave Search when `BRAVE_API_KEY` is available, then Tavily Search when only `TAVILY_API_KEY` is available. +Brave Search wins when both keys are available to preserve the historical non-interactive behavior. +An explicit provider with no matching key exits before sandbox creation. +A provider key that fails validation prints a warning, disables web search for that run, and lets the rest of onboarding continue. +After fixing the key, rerun onboarding so NemoClaw can validate it, register the selected provider, and apply the matching policy preset. +Changing or disabling the selected provider recreates the sandbox because the plugin configuration and credential attachment are part of the image. +Accept the recreate prompt or pass `--recreate-sandbox`. -Hermes does not use NemoClaw's OpenClaw Brave Search setup. -If you authenticate Hermes through Nous Portal OAuth, the wizard can prompt for managed Nous tool gateways such as web search. -API-key mode is inference-only and does not enable managed tool gateways. +Hermes supports Tavily Search through NemoClaw onboarding and does not support Brave Search. +To enable Tavily in non-interactive mode, set the provider and matching key. + +```bash +NEMOCLAW_WEB_SEARCH_PROVIDER=tavily \ +TAVILY_API_KEY=... \ + $$nemoclaw onboard --non-interactive +``` + +Set `NEMOCLAW_WEB_SEARCH_PROVIDER=none` to disable web search explicitly. +When the selector is unset, NemoClaw enables Tavily when `TAVILY_API_KEY` is available and ignores `BRAVE_API_KEY` for Hermes. +An explicit Tavily selection with no key exits before sandbox creation. +A Tavily key that fails validation prints a warning, disables web search for that run, and lets the rest of onboarding continue. +Changing or disabling Tavily recreates the sandbox because the Hermes backend, environment placeholder, and credential attachment are part of the image. +If you also select the Nous-managed web gateway through Nous Portal OAuth, Tavily replaces `nous-web` while other selected Nous tools remain enabled. +API-key mode is inference-only and does not enable managed Nous tool gateways. @@ -2352,6 +2374,9 @@ Set them before running `$$nemoclaw onboard`. | `NEMOCLAW_OLLAMA_INSTALL_MODE` | `system`, `user`, or empty/unset | Pins the Linux Ollama install location. Refer to the Linux Ollama install mode details below. | | `NEMOCLAW_PROXY_HOST` | hostname or IP | Overrides the sandbox-side outbound HTTP proxy host. Defaults to `10.200.0.1`. | | `NEMOCLAW_PROXY_PORT` | integer port | Overrides the sandbox-side outbound HTTP proxy port. Defaults to `3128`. | +| `NEMOCLAW_WEB_SEARCH_PROVIDER` | `brave`, `tavily`, or `none` | Selects the web search provider in non-interactive onboarding or disables web search explicitly. OpenClaw supports `brave` and `tavily`; Hermes supports `tavily` only. When unset, supported credential variables trigger implicit selection with Brave-first precedence. | +| `BRAVE_API_KEY` | Brave Search API key | Supplies and implicitly selects Brave Search for OpenClaw when no web search provider is set. NemoClaw validates the key and stores it in OpenShell rather than the sandbox. | +| `TAVILY_API_KEY` | Tavily Search API key | Supplies and implicitly selects Tavily Search when no higher-precedence supported web search credential is available. NemoClaw validates the key and stores it in OpenShell rather than the sandbox. | | `NEMOCLAW_OPENCLAW_OTEL` | `1` to enable | Enables OpenClaw conversation diagnostics export through the `diagnostics-otel` plugin. Disabled by default. | | `NEMOCLAW_OPENCLAW_OTEL_ENDPOINT` | OTLP/HTTP URL | Sets the OpenTelemetry collector endpoint for OpenClaw diagnostics. Defaults to `http://host.openshell.internal:4318` when `NEMOCLAW_OPENCLAW_OTEL=1`. | | `NEMOCLAW_OPENCLAW_OTEL_SERVICE_NAME` | service name | Sets the OTEL `service.name` for OpenClaw gateway spans. Defaults to `openclaw-gateway`. | @@ -2417,7 +2442,7 @@ The Hermes profile `.env` files are operator-owned: write `${TELEGRAM_BOT_TOKEN_ NemoClaw never reads, writes, or rewrites these `.env` files; verify after onboarding that each profile's `.env` references the placeholder and that no raw bot token value sits on disk. Entries are split on whitespace and commas and must match `^[A-Z][A-Z0-9_]{0,127}$`. -Each entry must extend a canonical channel envKey with a non-empty `_` (for example `TELEGRAM_BOT_TOKEN_AGENT_A`); the canonical envKeys are `TELEGRAM_BOT_TOKEN`, `DISCORD_BOT_TOKEN`, `SLACK_BOT_TOKEN`, `SLACK_APP_TOKEN`, `WECHAT_BOT_TOKEN`, and `BRAVE_API_KEY`. +Each entry must extend a canonical channel envKey with a non-empty `_` (for example `TELEGRAM_BOT_TOKEN_AGENT_A`); the canonical envKeys are `TELEGRAM_BOT_TOKEN`, `DISCORD_BOT_TOKEN`, `SLACK_BOT_TOKEN`, `SLACK_APP_TOKEN`, `WECHAT_BOT_TOKEN`, `BRAVE_API_KEY`, and `TAVILY_API_KEY`. Bare canonical envKeys, the control env itself, and arbitrary host secret names (`GITHUB_TOKEN`, `AWS_SECRET_ACCESS_KEY`, `KUBECONFIG`, and similar) are refused so they cannot leak into the sandbox provider gateway. Duplicates are dropped silently. The list is capped at 32 entries per sandbox. diff --git a/docs/reference/network-policies.mdx b/docs/reference/network-policies.mdx index 0ebb0006ca4..b3d149c1d27 100644 --- a/docs/reference/network-policies.mdx +++ b/docs/reference/network-policies.mdx @@ -68,15 +68,18 @@ The baseline policy is always applied regardless of the selected tier. | Tier | Presets included | Description | |------|------------------|-------------| -| Restricted | None | Base sandbox only. No third-party network access beyond inference and core agent tooling. Restricted mode suppresses agent-required preset additions, such as OpenClaw pricing fetches; reapply them later with `policy-add` if cost recording or other agent-side features are needed. | -| Balanced (default) | `npm`, `pypi`, `huggingface`, `brew`, `brave when supported` | Full dev tooling and web search for agents that support web search. No messaging platform access. Apply the `weather` preset explicitly if your agent needs read-only weather lookups. | -| Open | `npm`, `pypi`, `huggingface`, `brew`, `brave when supported`, `weather`, `public-reference`, `slack`, `discord`, `telegram`, `wechat` (experimental), `whatsapp` (experimental), `jira`, `outlook` | Broad access across third-party services including messaging, productivity, weather, and public-reference APIs. | +| Restricted | No tier defaults | Starts from the baseline policy. Web search or messaging integrations selected earlier can still suggest their required presets; deselect them during policy review for baseline-only access. Restricted suppresses other agent-required additions, such as OpenClaw pricing fetches; reapply them later with `policy-add` if needed. | +| Balanced (default) | `npm`, `pypi`, `huggingface`, `brew`, selected `brave` or `tavily` web search preset | Full dev tooling and web search when you select a provider the active agent supports. No messaging platform access. Apply the `weather` preset explicitly if your agent needs read-only weather lookups. | +| Open | `npm`, `pypi`, `huggingface`, `brew`, selected `brave` or `tavily` web search preset, `weather`, `public-reference`, `slack`, `discord`, `telegram`, `wechat` (experimental), `whatsapp` (experimental), `jira`, `outlook` | Broad access across third-party services including messaging, productivity, weather, and public-reference APIs. | After selecting a tier, a combined preset and access-mode screen lets you include or exclude individual presets and toggle each between read (GET only) and read-write (GET + POST/PUT/PATCH) access. Tier-default presets are pre-selected; additional presets can be added from the full list. NemoClaw filters tier defaults by the active agent's supported integrations. -For example, Hermes onboarding omits the Brave Search preset because Hermes does not use NemoClaw's OpenClaw web-search configuration. +OpenClaw can select `brave` or `tavily`, while Hermes can select `tavily` only. +NemoClaw automatically suggests the preset that matches the selected provider and removes stale web search presets during resume reconciliation when you switch providers or disable web search. +Explicit custom preset lists and manual interactive selections remain operator-controlled. Hermes managed-tool gateway selections can add Hermes-specific presets, such as Nous-hosted web, image, audio, browser, or code tools, without applying unsupported OpenClaw-only presets. +When Hermes uses Tavily, NemoClaw removes `nous-web` from the effective managed-tool selection while preserving other selected Nous tool presets. OpenClaw onboarding also adds the `openclaw-pricing` preset on top of tier defaults so session-cost records can populate from LiteLLM and OpenRouter without manual configuration. When the OpenClaw OTEL diagnostics feature is enabled with a local endpoint, NemoClaw adds the `openclaw-diagnostics-otel-local` preset on the same basis. The applied set therefore reflects the chosen tier *plus* any agent-required presets, so `policy-list` may show one or more presets that do not appear in the tier table above. diff --git a/docs/reference/platform-support.mdx b/docs/reference/platform-support.mdx index 4596529e4d2..8899fb98b02 100644 --- a/docs/reference/platform-support.mdx +++ b/docs/reference/platform-support.mdx @@ -136,7 +136,7 @@ Each row below is a launch-facing capability claim that NemoClaw makes in docs, | Agent skills | Tested | Packaged agent skills are discoverable by Cursor, Claude Code, and other coding assistants under `.agents/skills/`. Skills also install into the sandbox with `$$nemoclaw skill install`. | | State migration | Tested | Sandbox state migrates across rebuilds with credentials intentionally excluded. Hermes excludes `auth.json` and restores its SQLite session DB through the backup API. OpenClaw config merge prevents stale state from overwriting fresh values. | | Blueprint versioning | Tested | Versioned, digest-verified, and reproducible blueprint lifecycle. Drives `$$nemoclaw rebuild` and the migration safeguards above. | -| Web search backend | Tested with limitations | 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. | +| Web search backend | Tested with limitations | 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. | {/* capability-status:end */} ## Deployment Paths diff --git a/docs/reference/troubleshooting.mdx b/docs/reference/troubleshooting.mdx index 55a28ee2d49..41a95978898 100644 --- a/docs/reference/troubleshooting.mdx +++ b/docs/reference/troubleshooting.mdx @@ -377,12 +377,14 @@ If GitHub release metadata is unavailable, the script uses its bundled fallback During sandbox creation, the OpenClaw image setup can install managed plugins for selected features such as web search or diagnostics. If the build reaches `openclaw plugins install` and the npm registry or ClawHub is blocked, NemoClaw classifies that narrow failure and prints a policy hint instead of only generic resume guidance. +Brave Search uses an external OpenClaw plugin and can reach this install path. +Tavily ships with the pinned OpenClaw runtime, so NemoClaw verifies the bundled extension instead of installing a separate Tavily package. Check that the active policy and host network allow the npm registry and ClawHub endpoints needed by the plugin, or disable the feature that requested the plugin. For example, if the plugin is for web search, disable that feature and resume onboarding: ```bash -NEMOCLAW_WEB_SEARCH_ENABLED=0 $$nemoclaw onboard --resume +NEMOCLAW_WEB_SEARCH_PROVIDER=none $$nemoclaw onboard --resume ``` If you want the feature, fix the network or policy path first, then resume onboarding: @@ -391,6 +393,50 @@ If you want the feature, fix the network or policy path first, then resume onboa $$nemoclaw onboard --resume ``` +### Web search verification reports a warning + +When web search is enabled, onboarding checks the selected agent configuration and sends a real search request through the sandbox egress path. +The verification is best effort, so a failed check prints a warning and lets onboarding finish. + +First confirm that the provider credential and matching policy preset exist. + +```bash +$$nemoclaw credentials list +$$nemoclaw policy-list +``` + +Look for `-brave-search` with the `brave` preset or `-tavily-search` with the `tavily` preset. +Do not replace an `openshell:resolve:env:` value in the sandbox configuration with a raw API key. + + + +Confirm that OpenClaw reports the provider selected during onboarding. + +```bash +$$nemoclaw config get --key tools.web.search --format yaml +``` + +The provider should be `brave` or `tavily` and `enabled` should be `true`. +If the provider is wrong, rerun onboarding with `NEMOCLAW_WEB_SEARCH_PROVIDER=brave` or `tavily` and the matching `BRAVE_API_KEY` or `TAVILY_API_KEY`. + + + + +Confirm that the generated Hermes configuration selects the Tavily backend. + +```bash +$$nemoclaw exec -- cat /sandbox/.hermes/config.yaml +``` + +The output should include a `web` mapping with `backend: tavily`. +If it does not, rerun onboarding with `NEMOCLAW_WEB_SEARCH_PROVIDER=tavily` and `TAVILY_API_KEY`. + + + +Rerunning onboarding with a different provider recreates the sandbox because the provider configuration and credential attachment are build-time inputs. +NemoClaw validates the replacement key before it removes the existing sandbox, then backs up and restores the supported workspace state during recreation. +If the configuration is correct but the egress probe fails, keep the matching preset applied and inspect the blocked request with `openshell term` before widening any policy rule. + ### Sandbox containers cannot reach the gateway On native Linux Docker-driver hosts, `$$nemoclaw onboard` verifies the route that sandbox containers use to reach the OpenShell gateway. @@ -1793,7 +1839,7 @@ Skills that require macOS-only binaries cannot be enabled on Brev. Skills that require additional CLI binaries require a custom sandbox image rebuild. For credentials, use the supported host-side setup flow. -Re-run onboarding for inference or Brave Search credentials, or use `$$nemoclaw channels add ` for messaging channels. +Rerun onboarding for inference or web search credentials, or use `$$nemoclaw channels add ` for messaging channels. To add a binary to the sandbox image, update the sandbox `Dockerfile.base` to install the required package, then rebuild: ```bash @@ -1998,11 +2044,20 @@ nemohermes credentials list Reset a specific provider's credentials with `nemohermes credentials reset ` and re-onboard if the stored value is wrong. -### `Brave Search` policy preset has no effect under Hermes +### Brave Search is unsupported under Hermes + +Hermes does not have a NemoClaw Brave Search backend. +Adding the `brave` preset to a Hermes sandbox opens Brave's endpoints but does not configure Hermes to use the credential. +Use Tavily Search through NemoClaw onboarding instead. + +```bash +NEMOCLAW_WEB_SEARCH_PROVIDER=tavily \ +TAVILY_API_KEY= \ + nemohermes onboard --recreate-sandbox +``` -The Hermes wizard intentionally omits the Brave Search preset because Hermes does not use NemoClaw's OpenClaw web-search configuration (refer to [Quickstart with Hermes](../../hermes/get-started/quickstart) and [Network Policies](network-policies)). -If you add the `brave` preset to a Hermes sandbox after onboarding, the L7 egress allowlist opens for Brave's endpoints but the agent itself does not start consuming the credential. -Configure Hermes web search from the agent's own configuration inside the sandbox. +NemoClaw writes `web.backend: tavily`, applies the `tavily` policy preset, and configures request-body credential rewriting for Hermes. +If the same onboarding run selected the Nous-managed web gateway, Tavily replaces `nous-web` while selected Nous image, audio, browser, and code tools remain enabled. ### Re-onboarding asks every messaging prompt again diff --git a/docs/security/best-practices.mdx b/docs/security/best-practices.mdx index 4f142214c2d..2c3b412ff9e 100644 --- a/docs/security/best-practices.mdx +++ b/docs/security/best-practices.mdx @@ -216,11 +216,24 @@ NemoClaw ships preset policy files in `nemoclaw-blueprint/policies/presets/` for | `outlook` | Microsoft 365, Outlook. | Gives agent access to email. | | `pypi` | Python Package Index (GET and HEAD only). | Allows installing arbitrary Python packages, which may contain malicious code. Publishing is blocked. | | `slack` | Slack API, Socket Mode, webhooks. | WebSocket uses `access: full`. Agent can post to any channel the bot token has access to. | +| `tavily` | Tavily Search API. | Agent can submit search queries and extraction targets to Tavily. The preset allows GET and POST requests from the maintained agent runtimes and enables request-body credential rewriting for Hermes. | | `telegram` | Telegram Bot API. | Agent can send messages to any chat the bot token has access to. | Apply presets only when the agent's task requires the integration. Review the preset's YAML file before applying to understand the endpoints, methods, and binary restrictions it adds. +### Web Search Credential Rewriting + +NemoClaw registers each selected web search credential in a sandbox-scoped OpenShell provider and writes a resolver placeholder into the agent configuration. +OpenClaw sends Brave's placeholder in the `X-Subscription-Token` header and Tavily's placeholder in the `Authorization` header. +Hermes sends the Tavily placeholder in the JSON `api_key` field, so the `tavily` policy preset enables `request_body_credential_rewrite` for `api.tavily.com`. +OpenShell replaces these placeholders only when the request reaches the matching egress policy path. +The raw `BRAVE_API_KEY` or `TAVILY_API_KEY` is not written into the sandbox configuration. + +The `tavily` preset also restricts egress to the maintained Python, Node.js, and curl paths used by the supported agents and onboarding verifier. +Do not replace these paths with a broad `/**` binary rule. +Broader binary access would let unrelated sandbox processes send data to Tavily through the same allowed endpoint. + ## Filesystem Controls NemoClaw restricts which paths the agent can read and write, protecting system binaries, configuration files, and gateway credentials. diff --git a/docs/security/credential-storage.mdx b/docs/security/credential-storage.mdx index 9a6fc43ffba..1c7677dd58d 100644 --- a/docs/security/credential-storage.mdx +++ b/docs/security/credential-storage.mdx @@ -47,6 +47,19 @@ Both commands show the provider names registered with the gateway. The CLI cannot read the values back. OpenShell deliberately preserves this property. +## Web Search Credentials + +Web search follows the same OpenShell provider boundary as inference and messaging credentials. +OpenClaw supports `BRAVE_API_KEY` and `TAVILY_API_KEY`, while Hermes supports `TAVILY_API_KEY` only. +NemoClaw registers the selected key in a sandbox-scoped provider named `-brave-search` or `-tavily-search` and writes `openshell:resolve:env:` into the agent configuration. + +OpenShell replaces the Brave placeholder in the `X-Subscription-Token` header and the OpenClaw Tavily placeholder in the `Authorization` header. +Hermes sends its Tavily placeholder in the JSON `api_key` field. +The `tavily` policy preset enables request-body credential rewriting so OpenShell replaces that body value at egress without exposing the raw key to Hermes. + +Use a dedicated low-scope search key and keep the matching `brave` or `tavily` policy preset applied only while the sandbox needs web search. +Rerun onboarding when you change providers because the provider selection and credential attachment are part of the sandbox image. + NemoClaw still keeps non-secret operational state under `~/.nemoclaw/` (such as the sandbox registry). That directory is created with mode `0700` and contains no credential material. diff --git a/nemoclaw-blueprint/policies/openclaw-sandbox-permissive.yaml b/nemoclaw-blueprint/policies/openclaw-sandbox-permissive.yaml index b0915b2b70c..6508883ff08 100644 --- a/nemoclaw-blueprint/policies/openclaw-sandbox-permissive.yaml +++ b/nemoclaw-blueprint/policies/openclaw-sandbox-permissive.yaml @@ -364,3 +364,15 @@ network_policies: access: full binaries: - { path: "/**" } + + tavily: + name: tavily + endpoints: + - host: api.tavily.com + port: 443 + protocol: rest + enforcement: enforce + request_body_credential_rewrite: true + access: full + binaries: + - { path: "/**" } diff --git a/nemoclaw-blueprint/policies/presets/tavily.yaml b/nemoclaw-blueprint/policies/presets/tavily.yaml index 94705a34cf8..6e8ae530290 100644 --- a/nemoclaw-blueprint/policies/presets/tavily.yaml +++ b/nemoclaw-blueprint/policies/presets/tavily.yaml @@ -13,13 +13,17 @@ network_policies: port: 443 protocol: rest enforcement: enforce + # Hermes sends the resolver placeholder as the JSON `api_key` field. + request_body_credential_rewrite: true rules: - allow: { method: GET, path: "/**" } - allow: { method: POST, path: "/**" } binaries: - # OpenShell attributes Deep Agents Code Tavily requests to this managed - # Python venv, which its strict Landlock policy mounts read-only. + # OpenShell attributes Tavily requests to each agent's managed Python + # interpreter. Keep both paths exact so unrelated Python runtimes remain + # outside the Tavily egress boundary. - { path: /opt/venv/bin/python3* } + - { path: /opt/hermes/.venv/bin/python } - { path: /usr/local/bin/node } - { path: /usr/bin/node } - { path: /usr/local/bin/curl } diff --git a/nemoclaw-blueprint/provider-profiles/tavily-hermes-v1.yaml b/nemoclaw-blueprint/provider-profiles/tavily-hermes-v1.yaml new file mode 100644 index 00000000000..712843b5e5e --- /dev/null +++ b/nemoclaw-blueprint/provider-profiles/tavily-hermes-v1.yaml @@ -0,0 +1,26 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +id: tavily-hermes-v1 +display_name: Tavily Search for Hermes +description: Tavily Search API access for the Hermes managed Python runtime +category: agent +credentials: + - name: api_key + description: Tavily Search API key + env_vars: + - TAVILY_API_KEY + required: true + auth_style: bearer + header_name: authorization + query_param: '' +endpoints: + - host: api.tavily.com + port: 443 + protocol: rest + access: read-write + enforcement: enforce +binaries: + - /opt/hermes/.venv/bin/python + - /usr/local/bin/curl + - /usr/bin/curl +inference_capable: false diff --git a/scripts/generate-openclaw-config.mts b/scripts/generate-openclaw-config.mts index 9a1fd5b527b..c8a184275cf 100755 --- a/scripts/generate-openclaw-config.mts +++ b/scripts/generate-openclaw-config.mts @@ -19,6 +19,7 @@ // NEMOCLAW_EXTRA_AGENTS_JSON_B64, // NEMOCLAW_PROXY_HOST, NEMOCLAW_PROXY_PORT, // NEMOCLAW_OPENCLAW_MANAGED_PROXY, NEMOCLAW_WEB_SEARCH_ENABLED, +// NEMOCLAW_WEB_SEARCH_PROVIDER, // NEMOCLAW_OPENCLAW_OTEL, NEMOCLAW_OPENCLAW_OTEL_ENDPOINT, // NEMOCLAW_OPENCLAW_OTEL_SERVICE_NAME, NEMOCLAW_OPENCLAW_OTEL_SAMPLE_RATE. @@ -72,6 +73,11 @@ const SMALL_OLLAMA_CONTEXT_THRESHOLD = OPENCLAW_DEFAULT_RESERVE_TOKENS_FLOOR + OPENCLAW_MIN_PROMPT_BUDGET_TOKENS; const LOCAL_OLLAMA_UPSTREAM_PROVIDER = "ollama-local"; const FALSE_VALUES = new Set(["0", "false", "no", "off"]); +const WEB_SEARCH_PROVIDERS = { + brave: { credentialEnv: "BRAVE_API_KEY" }, + tavily: { credentialEnv: "TAVILY_API_KEY" }, +} as const; +type WebSearchProvider = keyof typeof WEB_SEARCH_PROVIDERS; const DEFAULT_OPENCLAW_OTEL_ENDPOINT = "http://host.openshell.internal:4318"; const DEFAULT_OPENCLAW_OTEL_SERVICE_NAME = "openclaw-gateway"; const SCRIPT_PATH = fileURLToPath(import.meta.url); @@ -81,6 +87,14 @@ function isObject(value: unknown): value is JsonObject { return typeof value === "object" && value !== null && !Array.isArray(value); } +function resolveWebSearchProvider(env: Env): WebSearchProvider { + const provider = (env.NEMOCLAW_WEB_SEARCH_PROVIDER || "brave").trim(); + if (provider === "brave" || provider === "tavily") return provider; + throw new Error( + `NEMOCLAW_WEB_SEARCH_PROVIDER must be "brave" or "tavily", got ${JSON.stringify(provider)}`, + ); +} + function unique(values: Iterable): T[] { return [...new Set(values)]; } @@ -1270,18 +1284,16 @@ export function buildConfig(env: Env = process.env): JsonObject { tools.web.fetch = { enabled: true, useTrustedEnvProxy: true }; if (env.NEMOCLAW_WEB_SEARCH_ENABLED === "1") { - // OpenClaw 2026.5.x: web-search providers are external plugins. The - // provider-owned apiKey lives under plugins.entries..config, - // not inline in tools.web.search. Writing the legacy inline shape makes - // the build-time `openclaw plugins install` exit non-zero during its - // pre-install config validation (the brave plugin is not installed yet), - // aborting the image build under `set -eu` before `doctor --fix` can - // migrate it. Emit the current schema directly so install validates - // cleanly. See NemoClaw #5266 (follow-up to #4955 / #3948). - tools.web.search = { enabled: true, provider: "brave" }; - config.plugins.entries.brave = { + // OpenClaw 2026.5.x keeps provider-owned credentials under + // plugins.entries..config rather than inline on tools.web.search. + // Brave is installed externally during the image build; Tavily ships as a + // bundled OpenClaw extension. Both use the same plugin-scoped config shape. + const webSearchProvider = resolveWebSearchProvider(env); + const credentialEnv = WEB_SEARCH_PROVIDERS[webSearchProvider].credentialEnv; + tools.web.search = { enabled: true, provider: webSearchProvider }; + config.plugins.entries[webSearchProvider] = { enabled: true, - config: { webSearch: { apiKey: "openshell:resolve:env:BRAVE_API_KEY" } }, + config: { webSearch: { apiKey: `openshell:resolve:env:${credentialEnv}` } }, }; } diff --git a/scripts/install.sh b/scripts/install.sh index 9c71589c774..35c6cd18a44 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -635,7 +635,10 @@ usage() { printf " NEMOCLAW_MODEL Inference model to configure\n" printf " NEMOCLAW_POLICY_MODE suggested | custom | skip\n" printf " NEMOCLAW_POLICY_PRESETS Comma-separated policy presets\n" - printf " BRAVE_API_KEY Enable Brave Search with this API key (kept behind OpenShell provider rewrite)\n" + printf " NEMOCLAW_WEB_SEARCH_PROVIDER brave | tavily | none (Hermes supports tavily only)\n" + printf " BRAVE_API_KEY Enable Brave Search for OpenClaw when the provider is unset\n" + printf " TAVILY_API_KEY Enable Tavily Search when no higher-precedence supported key is set\n" + printf " Web search keys stay behind OpenShell credential rewrite\n" printf " NEMOCLAW_EXPERIMENTAL=1 Show experimental/local options\n" printf " CHAT_UI_URL Chat UI URL to open after setup\n" printf " Messaging credential env vars Auto-enable matching messaging policy support\n" @@ -2533,7 +2536,7 @@ describe_express_install() { case "$tier" in balanced) - policy_summary="base sandbox policy plus npm, pypi, huggingface, brew, brave when supported" + policy_summary="base sandbox policy plus npm, pypi, huggingface, brew, and the selected web-search preset" policy_summary="${policy_summary}, and local-inference access when needed" ;; restricted) diff --git a/src/lib/inference/web-search.test.ts b/src/lib/inference/web-search.test.ts index cf98d55d781..d78ad865d8b 100644 --- a/src/lib/inference/web-search.test.ts +++ b/src/lib/inference/web-search.test.ts @@ -3,10 +3,77 @@ import { describe, expect, it } from "vitest"; -import { BRAVE_API_KEY_ENV } from "./web-search"; +import { + BRAVE_API_KEY_ENV, + DEFAULT_WEB_SEARCH_PROVIDER, + normalizeWebSearchConfig, + parseExplicitWebSearchProvider, + TAVILY_API_KEY_ENV, + WEB_SEARCH_PROVIDER_ENV, + webSearchConfigsEqual, + webSearchEnvFor, + webSearchProviderForConfig, +} from "./web-search"; describe("web-search module", () => { it("exports BRAVE_API_KEY_ENV constant", () => { expect(BRAVE_API_KEY_ENV).toBe("BRAVE_API_KEY"); }); + + it("exports Tavily and explicit-provider environment names", () => { + expect(TAVILY_API_KEY_ENV).toBe("TAVILY_API_KEY"); + expect(WEB_SEARCH_PROVIDER_ENV).toBe("NEMOCLAW_WEB_SEARCH_PROVIDER"); + }); + + it("maps providers to their credential environment names", () => { + expect(webSearchEnvFor("brave")).toBe(BRAVE_API_KEY_ENV); + expect(webSearchEnvFor("tavily")).toBe(TAVILY_API_KEY_ENV); + }); + + it("defaults legacy provider-less configs to Brave", () => { + expect(DEFAULT_WEB_SEARCH_PROVIDER).toBe("brave"); + expect(webSearchProviderForConfig({})).toBe("brave"); + expect(normalizeWebSearchConfig({ fetchEnabled: true })).toEqual({ + fetchEnabled: true, + provider: "brave", + }); + }); + + it("normalizes and compares provider-aware enabled state", () => { + expect(normalizeWebSearchConfig({ fetchEnabled: true, provider: "tavily" })).toEqual({ + fetchEnabled: true, + provider: "tavily", + }); + expect(normalizeWebSearchConfig({ fetchEnabled: false, provider: "tavily" })).toBeNull(); + expect( + normalizeWebSearchConfig({ fetchEnabled: true, provider: "invalid" as never }), + ).toBeNull(); + expect( + webSearchConfigsEqual({ fetchEnabled: true }, { fetchEnabled: true, provider: "brave" }), + ).toBe(true); + expect( + webSearchConfigsEqual( + { fetchEnabled: true, provider: "brave" }, + { fetchEnabled: true, provider: "tavily" }, + ), + ).toBe(false); + }); + + it("parses explicit provider selection and disable aliases", () => { + expect(parseExplicitWebSearchProvider(undefined)).toEqual({ + specified: false, + provider: null, + }); + expect(parseExplicitWebSearchProvider(" TAVILY ")).toEqual({ + specified: true, + provider: "tavily", + }); + expect(parseExplicitWebSearchProvider("off")).toEqual({ + specified: true, + provider: null, + }); + expect(() => parseExplicitWebSearchProvider("google")).toThrow( + /Valid values: brave, tavily, none/, + ); + }); }); diff --git a/src/lib/inference/web-search.ts b/src/lib/inference/web-search.ts index dd6d7682ac9..e0395bd07e6 100644 --- a/src/lib/inference/web-search.ts +++ b/src/lib/inference/web-search.ts @@ -1,8 +1,100 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +export const WEB_SEARCH_PROVIDERS = ["brave", "tavily"] as const; + +export type WebSearchProvider = (typeof WEB_SEARCH_PROVIDERS)[number]; + export interface WebSearchConfig { fetchEnabled: boolean; + /** + * Optional only for compatibility with sessions and callers created before + * provider selection existed. Every persistence and runtime boundary + * normalizes a missing provider to Brave. + */ + provider?: WebSearchProvider; } +export const DEFAULT_WEB_SEARCH_PROVIDER: WebSearchProvider = "brave"; +export const WEB_SEARCH_PROVIDER_ENV = "NEMOCLAW_WEB_SEARCH_PROVIDER"; export const BRAVE_API_KEY_ENV = "BRAVE_API_KEY"; +export const TAVILY_API_KEY_ENV = "TAVILY_API_KEY"; + +export function isWebSearchProvider(value: unknown): value is WebSearchProvider { + return value === "brave" || value === "tavily"; +} + +export type ExplicitWebSearchProviderSelection = + | { specified: false; provider: null } + | { specified: true; provider: WebSearchProvider | null }; + +export function parseExplicitWebSearchProvider( + value: string | null | undefined, +): ExplicitWebSearchProviderSelection { + const normalized = (value ?? "").trim().toLowerCase(); + if (!normalized) return { specified: false, provider: null }; + if (isWebSearchProvider(normalized)) return { specified: true, provider: normalized }; + if (["none", "off", "disabled", "no", "0"].includes(normalized)) { + return { specified: true, provider: null }; + } + throw new Error( + `Unsupported ${WEB_SEARCH_PROVIDER_ENV}: ${value}. Valid values: brave, tavily, none.`, + ); +} + +export function normalizeWebSearchProvider(value: unknown): WebSearchProvider { + return isWebSearchProvider(value) ? value : DEFAULT_WEB_SEARCH_PROVIDER; +} + +export function webSearchProviderForConfig( + config: Pick | null | undefined, +): WebSearchProvider { + return normalizeWebSearchProvider(config?.provider); +} + +export function webSearchEnvFor(provider: WebSearchProvider): string { + return provider === "tavily" ? TAVILY_API_KEY_ENV : BRAVE_API_KEY_ENV; +} + +export function webSearchLabelFor(provider: WebSearchProvider): string { + return provider === "tavily" ? "Tavily Search" : "Brave Search"; +} + +export function webSearchProviderForEnvKey(envKey: string): WebSearchProvider | null { + if (envKey === BRAVE_API_KEY_ENV) return "brave"; + if (envKey === TAVILY_API_KEY_ENV) return "tavily"; + return null; +} + +export function isWebSearchEnabled( + config: Pick | null | undefined, +): boolean { + return config?.fetchEnabled === true; +} + +export function normalizeWebSearchConfig( + config: Partial | null | undefined, +): WebSearchConfig | null { + if (!isWebSearchEnabled(config as WebSearchConfig | null | undefined)) return null; + const provider = + config?.provider === undefined + ? DEFAULT_WEB_SEARCH_PROVIDER + : isWebSearchProvider(config.provider) + ? config.provider + : null; + if (!provider) return null; + return { + fetchEnabled: true, + provider, + }; +} + +export function webSearchConfigsEqual( + left: Partial | null | undefined, + right: Partial | null | undefined, +): boolean { + const normalizedLeft = normalizeWebSearchConfig(left); + const normalizedRight = normalizeWebSearchConfig(right); + if (!normalizedLeft || !normalizedRight) return normalizedLeft === normalizedRight; + return normalizedLeft.provider === normalizedRight.provider; +} diff --git a/src/lib/messaging/applier/build/messaging-build-applier.mts b/src/lib/messaging/applier/build/messaging-build-applier.mts index 65a89b5cdc3..3c01dc005fd 100755 --- a/src/lib/messaging/applier/build/messaging-build-applier.mts +++ b/src/lib/messaging/applier/build/messaging-build-applier.mts @@ -520,17 +520,27 @@ export function openClawDoctorEnvOverrides( plan: MessagingBuildPlan | null, env: Env = process.env, ): Record { - if (!plan) return {}; - const active = new Set(activeChannels(plan)); const overrides: Record = {}; - for (const binding of plan.credentialBindings) { - if (!active.has(binding.channelId)) continue; - if (typeof binding.providerEnvKey === "string" && typeof binding.placeholder === "string") { - overrides[binding.providerEnvKey] = binding.placeholder; + if (plan) { + const active = new Set(activeChannels(plan)); + for (const binding of plan.credentialBindings) { + if (!active.has(binding.channelId)) continue; + if (typeof binding.providerEnvKey === "string" && typeof binding.placeholder === "string") { + overrides[binding.providerEnvKey] = binding.placeholder; + } } } if (isTruthyEnv(env.NEMOCLAW_WEB_SEARCH_ENABLED)) { - overrides.BRAVE_API_KEY = "openshell:resolve:env:BRAVE_API_KEY"; + const provider = (env.NEMOCLAW_WEB_SEARCH_PROVIDER || "brave").trim(); + if (provider === "brave") { + overrides.BRAVE_API_KEY = "openshell:resolve:env:BRAVE_API_KEY"; + } else if (provider === "tavily") { + overrides.TAVILY_API_KEY = "openshell:resolve:env:TAVILY_API_KEY"; + } else { + throw new MessagingBuildApplierError( + `Unsupported NEMOCLAW_WEB_SEARCH_PROVIDER: ${provider || ""}`, + ); + } } return overrides; } diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index a4b9f265cc3..4001d537aea 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -85,6 +85,7 @@ const { }: typeof import("./onboard/dockerfile-patch") = require("./onboard/dockerfile-patch"); const { agentSupportsWebSearch, + agentSupportsWebSearchProvider, }: typeof import("./onboard/web-search-support") = require("./onboard/web-search-support"); const onboardDashboard: typeof import("./onboard/dashboard") = require("./onboard/dashboard"); const dashboardRuntime: typeof import("./onboard/dashboard-runtime") = require("./onboard/dashboard-runtime"); @@ -947,7 +948,11 @@ function upsertMessagingProviders( tokenDefs: MessagingTokenDef[], options: { replaceExisting?: boolean } = {}, ) { - braveProviderProfile.ensureBraveProviderProfile(tokenDefs, { root: ROOT, runOpenshell, redact }); + braveProviderProfile.ensureWebSearchProviderProfiles(tokenDefs, { + root: ROOT, + runOpenshell, + redact, + }); const upserted = onboardProviders.upsertMessagingProviders(tokenDefs, runOpenshell, options); // upsertMessagingProviders process.exits on failure, so reaching this // point means every entry in tokenDefs that had a token was registered. @@ -1005,14 +1010,18 @@ const { isAffirmativeAnswer, }); -const { ensureValidatedBraveSearchCredential, configureWebSearch, verifyWebSearchInsideSandbox } = - createWebSearchFlowHelpers({ - prompt, - note, - isNonInteractive, - cliName, - runCaptureOpenshell, - }); +const { + ensureValidatedWebSearchCredential, + ensureValidatedBraveSearchCredential, + configureWebSearch, + verifyWebSearchInsideSandbox, +} = createWebSearchFlowHelpers({ + prompt, + note, + isNonInteractive, + cliName, + runCaptureOpenshell, +}); // getSandboxInferenceConfig — moved to onboard-providers.ts @@ -2603,6 +2612,7 @@ async function createSandbox( channels: MESSAGING_CHANNELS, enabledChannels, sandboxName, + agentName: agent?.name ?? "openclaw", webSearchConfig, env: process.env, }, @@ -5070,6 +5080,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { sandboxDeps: { resolvePath: path.resolve, agentSupportsWebSearch, + agentSupportsWebSearchProvider, note, updateSession: onboardSession.updateSession, getStoredMessagingChannelConfig, @@ -5082,7 +5093,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { stringSetsEqual, removeSandboxFromRegistry: registry.removeSandbox.bind(registry), repairRecordedSandbox, - ensureValidatedBraveSearchCredential, + ensureValidatedWebSearchCredential, isBackToSelection, configureWebSearch, startRecordedStep, @@ -5300,6 +5311,7 @@ module.exports = { classifySandboxCreateFailure, configureWebSearch, createSandbox, + ensureValidatedWebSearchCredential, ensureValidatedBraveSearchCredential, formatEnvAssignment, getFutureShellPathHint, @@ -5371,6 +5383,7 @@ module.exports = { openshellArgv, runCaptureOpenshell, agentSupportsWebSearch, + agentSupportsWebSearchProvider, setupInference, setupMessagingChannels, MESSAGING_CHANNELS, diff --git a/src/lib/onboard/brave-provider-profile.test.ts b/src/lib/onboard/brave-provider-profile.test.ts index a666b77993d..cd7cdc6507f 100644 --- a/src/lib/onboard/brave-provider-profile.test.ts +++ b/src/lib/onboard/brave-provider-profile.test.ts @@ -7,7 +7,11 @@ import { BRAVE_PROVIDER_PROFILE_ID, braveProviderProfilePath, ensureBraveProviderProfile, + ensureWebSearchProviderProfiles, + HERMES_TAVILY_PROVIDER_PROFILE_ID, shouldEnableBraveWebSearch, + TAVILY_PROVIDER_PROFILE_ID, + webSearchProviderProfilePath, } from "./brave-provider-profile"; function makeDeps(runOpenshell: ReturnType, overrides: Record = {}) { @@ -51,6 +55,47 @@ describe("ensureBraveProviderProfile", () => { ); }); + it("imports Tavily and Brave profiles when both have tokens", () => { + const runOpenshell = vi.fn(() => ({ status: 0, stderr: "", stdout: "" })); + ensureWebSearchProviderProfiles( + [ + { providerType: TAVILY_PROVIDER_PROFILE_ID, token: "tvly-test" }, + { providerType: BRAVE_PROVIDER_PROFILE_ID, token: "brv-test" }, + ], + makeDeps(runOpenshell), + ); + expect(runOpenshell).toHaveBeenNthCalledWith( + 1, + ["provider", "profile", "import", "--file", webSearchProviderProfilePath("/repo", "tavily")], + expect.objectContaining({ ignoreError: true }), + ); + expect(runOpenshell).toHaveBeenNthCalledWith( + 2, + ["provider", "profile", "import", "--file", braveProviderProfilePath("/repo")], + expect.objectContaining({ ignoreError: true }), + ); + }); + + it("uses a versioned Hermes profile instead of accepting a stale Tavily profile", () => { + const runOpenshell = vi.fn(() => ({ status: 0, stderr: "", stdout: "" })); + + ensureWebSearchProviderProfiles( + [{ providerType: HERMES_TAVILY_PROVIDER_PROFILE_ID, token: "tvly-test" }], + makeDeps(runOpenshell), + ); + + expect(runOpenshell).toHaveBeenCalledWith( + [ + "provider", + "profile", + "import", + "--file", + webSearchProviderProfilePath("/repo", HERMES_TAVILY_PROVIDER_PROFILE_ID), + ], + expect.objectContaining({ ignoreError: true }), + ); + }); + it("treats an existing-profile diagnostic as success on re-onboard", () => { const runOpenshell = vi.fn(() => ({ status: 1, diff --git a/src/lib/onboard/brave-provider-profile.ts b/src/lib/onboard/brave-provider-profile.ts index 493011bfa54..b63a9a5822b 100644 --- a/src/lib/onboard/brave-provider-profile.ts +++ b/src/lib/onboard/brave-provider-profile.ts @@ -4,8 +4,20 @@ import path from "node:path"; import { compactText } from "../core/url-utils"; +import { isWebSearchEnabled } from "../inference/web-search"; export const BRAVE_PROVIDER_PROFILE_ID = "brave"; +export const TAVILY_PROVIDER_PROFILE_ID = "tavily"; +// OpenShell custom profiles are immutable after import. Use a versioned Hermes +// profile so upgrades never accept the earlier Deep Agents-only Tavily binary +// allowlist as compatible with Hermes. +export const HERMES_TAVILY_PROVIDER_PROFILE_ID = "tavily-hermes-v1"; +export const WEB_SEARCH_PROVIDER_PROFILE_IDS = [ + BRAVE_PROVIDER_PROFILE_ID, + TAVILY_PROVIDER_PROFILE_ID, + HERMES_TAVILY_PROVIDER_PROFILE_ID, +] as const; +export type WebSearchProviderProfileId = (typeof WEB_SEARCH_PROVIDER_PROFILE_IDS)[number]; /** * Single source of truth for "the user opted in to Brave Search at runtime." @@ -18,7 +30,13 @@ export const BRAVE_PROVIDER_PROFILE_ID = "brave"; export function shouldEnableBraveWebSearch( webSearchConfig: { fetchEnabled?: boolean | null } | null | undefined, ): boolean { - return Boolean(webSearchConfig?.fetchEnabled); + return shouldEnableWebSearch(webSearchConfig); +} + +export function shouldEnableWebSearch( + webSearchConfig: { fetchEnabled?: boolean | null } | null | undefined, +): boolean { + return isWebSearchEnabled(webSearchConfig as { fetchEnabled: boolean } | null | undefined); } export type BraveProviderProfileDeps = { @@ -46,7 +64,14 @@ function bufferOrStringToText(value: string | Buffer | null | undefined): string } export function braveProviderProfilePath(root: string): string { - return path.join(root, "nemoclaw-blueprint", "provider-profiles", "brave.yaml"); + return webSearchProviderProfilePath(root, "brave"); +} + +export function webSearchProviderProfilePath( + root: string, + provider: WebSearchProviderProfileId, +): string { + return path.join(root, "nemoclaw-blueprint", "provider-profiles", `${provider}.yaml`); } /** @@ -60,28 +85,53 @@ export function ensureBraveProviderProfile( tokenDefs: readonly TokenDefShape[], deps: BraveProviderProfileDeps, ): void { - const needs = tokenDefs.some( - ({ providerType, token }) => providerType === BRAVE_PROVIDER_PROFILE_ID && Boolean(token), - ); - if (!needs) return; + ensureWebSearchProviderProfiles(tokenDefs, deps); +} + +/** Register every selected web-search provider profile before token upsert. */ +export function ensureWebSearchProviderProfiles( + tokenDefs: readonly TokenDefShape[], + deps: BraveProviderProfileDeps, +): void { + const neededProviders = new Set(); + for (const { providerType, token } of tokenDefs) { + if (!token) continue; + if ( + typeof providerType === "string" && + (WEB_SEARCH_PROVIDER_PROFILE_IDS as readonly string[]).includes(providerType) + ) { + neededProviders.add(providerType as WebSearchProviderProfileId); + } + } + if (neededProviders.size === 0) return; const errorLog = deps.log ?? console.error; const exit = deps.exit ?? ((code?: number) => process.exit(code)); - const result = deps.runOpenshell( - ["provider", "profile", "import", "--file", braveProviderProfilePath(deps.root)], - { ignoreError: true, stdio: ["ignore", "pipe", "pipe"] }, - ); - if (result.status === 0) return; - - // OpenShell reports re-imports of an already-registered custom profile as - // a non-zero exit. Tolerate that so re-onboard / recreate keeps working. - const rawDiagnostic = `${bufferOrStringToText(result.stderr)} ${bufferOrStringToText(result.stdout)}`; - if (/already exists/i.test(rawDiagnostic)) return; - - const diagnostic = compactText(deps.redact(rawDiagnostic)); - errorLog("\n ✗ Failed to register the Brave Search provider profile with OpenShell."); - if (diagnostic) errorLog(` ${diagnostic.slice(0, 500)}`); - errorLog(" Update OpenShell with scripts/install-openshell.sh and re-run onboarding."); - exit(result.status || 1); + for (const provider of neededProviders) { + const result = deps.runOpenshell( + [ + "provider", + "profile", + "import", + "--file", + webSearchProviderProfilePath(deps.root, provider), + ], + { ignoreError: true, stdio: ["ignore", "pipe", "pipe"] }, + ); + if (result.status === 0) continue; + + // OpenShell reports re-imports of an already-registered custom profile as + // a non-zero exit. Tolerate that so re-onboard / recreate keeps working. + const rawDiagnostic = `${bufferOrStringToText(result.stderr)} ${bufferOrStringToText(result.stdout)}`; + if (/already exists/i.test(rawDiagnostic)) continue; + + const diagnostic = compactText(deps.redact(rawDiagnostic)); + errorLog( + `\n ✗ Failed to register the ${provider} web-search provider profile with OpenShell.`, + ); + if (diagnostic) errorLog(` ${diagnostic.slice(0, 500)}`); + errorLog(" Update OpenShell with scripts/install-openshell.sh and re-run onboarding."); + exit(result.status || 1); + } } diff --git a/src/lib/onboard/dockerfile-patch.test.ts b/src/lib/onboard/dockerfile-patch.test.ts index 2d860d17b97..c1107988550 100644 --- a/src/lib/onboard/dockerfile-patch.test.ts +++ b/src/lib/onboard/dockerfile-patch.test.ts @@ -1194,6 +1194,7 @@ describe("dockerfile patch helpers", () => { "ARG NEMOCLAW_INFERENCE_API=openai-completions", "ARG NEMOCLAW_INFERENCE_COMPAT_B64=e30=", "ARG NEMOCLAW_WEB_SEARCH_ENABLED=0", + "ARG NEMOCLAW_WEB_SEARCH_PROVIDER=brave", "ARG NEMOCLAW_BUILD_ID=default", ].join("\n"), ); @@ -1212,6 +1213,7 @@ describe("dockerfile patch helpers", () => { ); const patched = fs.readFileSync(dockerfilePath, "utf8"); assert.match(patched, /^ARG NEMOCLAW_WEB_SEARCH_ENABLED=1$/m); + assert.match(patched, /^ARG NEMOCLAW_WEB_SEARCH_PROVIDER=brave$/m); // Regression guard: the old secret-bearing build arg must not reappear. assert.doesNotMatch(patched, /NEMOCLAW_WEB_CONFIG_B64/); } finally { @@ -1223,4 +1225,42 @@ describe("dockerfile patch helpers", () => { fs.rmSync(tmpDir, { recursive: true, force: true }); } }); + + it("patches the staged Dockerfile with Tavily as the selected web-search provider", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-dockerfile-tavily-")); + const dockerfilePath = path.join(tmpDir, "Dockerfile"); + fs.writeFileSync( + dockerfilePath, + [ + "ARG NEMOCLAW_MODEL=nvidia/nemotron-3-super-120b-a12b", + "ARG NEMOCLAW_PROVIDER_KEY=nvidia", + "ARG NEMOCLAW_PRIMARY_MODEL_REF=nvidia/nemotron-3-super-120b-a12b", + "ARG CHAT_UI_URL=http://127.0.0.1:18789", + "ARG NEMOCLAW_INFERENCE_BASE_URL=https://inference.local/v1", + "ARG NEMOCLAW_INFERENCE_API=openai-completions", + "ARG NEMOCLAW_INFERENCE_COMPAT_B64=e30=", + "ARG NEMOCLAW_WEB_SEARCH_ENABLED=0", + "ARG NEMOCLAW_WEB_SEARCH_PROVIDER=brave", + "ARG NEMOCLAW_BUILD_ID=default", + ].join("\n"), + ); + + try { + patchStagedDockerfile( + dockerfilePath, + "gpt-5.4", + "http://127.0.0.1:18789", + "build-web", + "openai-api", + null, + { fetchEnabled: true, provider: "tavily" }, + ); + const patched = fs.readFileSync(dockerfilePath, "utf8"); + assert.match(patched, /^ARG NEMOCLAW_WEB_SEARCH_ENABLED=1$/m); + assert.match(patched, /^ARG NEMOCLAW_WEB_SEARCH_PROVIDER=tavily$/m); + assert.doesNotMatch(patched, /TAVILY_API_KEY/); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); }); diff --git a/src/lib/onboard/dockerfile-patch.ts b/src/lib/onboard/dockerfile-patch.ts index 23dd8099e80..76ce298876c 100644 --- a/src/lib/onboard/dockerfile-patch.ts +++ b/src/lib/onboard/dockerfile-patch.ts @@ -4,7 +4,11 @@ import fs from "node:fs"; import { getSandboxInferenceConfig } from "../inference/config"; -import type { WebSearchConfig } from "../inference/web-search"; +import { + isWebSearchEnabled, + type WebSearchConfig, + webSearchProviderForConfig, +} from "../inference/web-search"; import { hydrateDerivedSandboxMessagingPlanFields, MessagingSetupApplier } from "../messaging"; import { parseSandboxMessagingPlan } from "../messaging/plan-validation"; @@ -265,7 +269,11 @@ export function patchStagedDockerfile( } dockerfile = dockerfile.replace( /^ARG NEMOCLAW_WEB_SEARCH_ENABLED=.*$/m, - `ARG NEMOCLAW_WEB_SEARCH_ENABLED=${sanitizeDockerArg(webSearchConfig ? "1" : "0")}`, + `ARG NEMOCLAW_WEB_SEARCH_ENABLED=${sanitizeDockerArg(isWebSearchEnabled(webSearchConfig) ? "1" : "0")}`, + ); + dockerfile = dockerfile.replace( + /^ARG NEMOCLAW_WEB_SEARCH_PROVIDER=.*$/m, + `ARG NEMOCLAW_WEB_SEARCH_PROVIDER=${sanitizeDockerArg(webSearchProviderForConfig(webSearchConfig))}`, ); for (const envKey of [ "NEMOCLAW_OPENCLAW_OTEL", diff --git a/src/lib/onboard/extra-placeholder-keys.test.ts b/src/lib/onboard/extra-placeholder-keys.test.ts index 91bb0aa120a..34c9245ffca 100644 --- a/src/lib/onboard/extra-placeholder-keys.test.ts +++ b/src/lib/onboard/extra-placeholder-keys.test.ts @@ -20,6 +20,7 @@ const CANONICAL_ENVKEYS_FIXTURE = new Set([ "SLACK_APP_TOKEN", "WECHAT_BOT_TOKEN", "BRAVE_API_KEY", + "TAVILY_API_KEY", ]); describe("parseExtraPlaceholderKeys", () => { @@ -139,7 +140,7 @@ describe("extraPlaceholderProviderSlug", () => { }); describe("canonicalPlaceholderKeys", () => { - it("returns the canonical channel envKeys plus BRAVE_API_KEY", () => { + it("returns the canonical channel envKeys plus web-search API keys", () => { const canonical = canonicalPlaceholderKeys(); for (const expected of [ "TELEGRAM_BOT_TOKEN", @@ -148,6 +149,7 @@ describe("canonicalPlaceholderKeys", () => { "SLACK_APP_TOKEN", "WECHAT_BOT_TOKEN", "BRAVE_API_KEY", + "TAVILY_API_KEY", ]) { expect(canonical.has(expected)).toBe(true); } diff --git a/src/lib/onboard/extra-placeholder-keys.ts b/src/lib/onboard/extra-placeholder-keys.ts index 8cb88b6e462..e73d7fbff0e 100644 --- a/src/lib/onboard/extra-placeholder-keys.ts +++ b/src/lib/onboard/extra-placeholder-keys.ts @@ -2,8 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 import { getCredential, normalizeCredentialValue } from "../credentials/store"; -import { getChannelTokenKeys, listChannels } from "../sandbox/channels"; import * as webSearch from "../inference/web-search"; +import { getChannelTokenKeys, listChannels } from "../sandbox/channels"; interface MessagingTokenDefShape { name: string; @@ -26,7 +26,9 @@ export interface ExtraPlaceholderKeysResult { export function canonicalPlaceholderKeys(): Set { const channels = listChannels(); return new Set( - channels.flatMap((c) => getChannelTokenKeys(c)).concat(webSearch.BRAVE_API_KEY_ENV), + channels + .flatMap((c) => getChannelTokenKeys(c)) + .concat(webSearch.BRAVE_API_KEY_ENV, webSearch.TAVILY_API_KEY_ENV), ); } @@ -100,7 +102,7 @@ export function registerExtraPlaceholderProviders( ); for (const warning of parsed.warnings) log(warning); for (const envKey of parsed.keys) { - // Match the brave-search precedence in src/lib/onboard.ts: the credential + // Match web-search precedence: the credential // store wins so a same-named host env var cannot override an out-of-process // credential that the operator has staged through `nemoclaw credentials // set`. Collapse the empty-string result from normalizeCredentialValue to diff --git a/src/lib/onboard/machine/core-flow-phases.test.ts b/src/lib/onboard/machine/core-flow-phases.test.ts index 20ed7b60d45..2ee75f4d952 100644 --- a/src/lib/onboard/machine/core-flow-phases.test.ts +++ b/src/lib/onboard/machine/core-flow-phases.test.ts @@ -155,7 +155,7 @@ function createPhases( left.length === right.length && left.every((item) => right.includes(item)), removeSandboxFromRegistry: vi.fn(), repairRecordedSandbox: vi.fn(), - ensureValidatedBraveSearchCredential: vi.fn(async () => null), + ensureValidatedWebSearchCredential: vi.fn(async () => null), isBackToSelection: () => false, configureWebSearch: vi.fn(async () => null), startRecordedStep: vi.fn(async () => undefined), diff --git a/src/lib/onboard/machine/core-flow-phases.ts b/src/lib/onboard/machine/core-flow-phases.ts index dec13cdddf8..f31674c4297 100644 --- a/src/lib/onboard/machine/core-flow-phases.ts +++ b/src/lib/onboard/machine/core-flow-phases.ts @@ -126,6 +126,8 @@ export function createCoreOnboardFlowPhases< session: sandboxStateResult.session, sandboxName: sandboxStateResult.sandboxName, webSearchConfig: sandboxStateResult.webSearchConfig, + webSearchConfigChanged: sandboxStateResult.webSearchConfigChanged, + hermesToolGateways: sandboxStateResult.hermesToolGateways, selectedMessagingChannels: sandboxStateResult.selectedMessagingChannels, webSearchSupported: sandboxStateResult.webSearchSupported, }), diff --git a/src/lib/onboard/machine/final-flow-phases.ts b/src/lib/onboard/machine/final-flow-phases.ts index 645eace9fa5..fdd97490317 100644 --- a/src/lib/onboard/machine/final-flow-phases.ts +++ b/src/lib/onboard/machine/final-flow-phases.ts @@ -77,6 +77,7 @@ export function createFinalOnboardFlowPhases< credentialEnv: context.credentialEnv, selectedMessagingChannels: context.selectedMessagingChannels, webSearchConfig: context.webSearchConfig, + webSearchConfigChanged: context.webSearchConfigChanged === true, webSearchSupported: context.webSearchSupported, hermesToolGateways: context.hermesToolGateways, agent: context.agent, diff --git a/src/lib/onboard/machine/flow-context.test.ts b/src/lib/onboard/machine/flow-context.test.ts index 1e015a20214..1cb5f4ad6db 100644 --- a/src/lib/onboard/machine/flow-context.test.ts +++ b/src/lib/onboard/machine/flow-context.test.ts @@ -143,6 +143,8 @@ describe("onboard flow context helpers", () => { session: createSession(), sandboxName: "my-assistant", webSearchConfig: null, + webSearchConfigChanged: false, + hermesToolGateways: [], selectedMessagingChannels: ["telegram"], webSearchSupported: true, }); diff --git a/src/lib/onboard/machine/flow-context.ts b/src/lib/onboard/machine/flow-context.ts index 319f7c17bb2..b3a7cf9e9eb 100644 --- a/src/lib/onboard/machine/flow-context.ts +++ b/src/lib/onboard/machine/flow-context.ts @@ -24,6 +24,7 @@ export interface OnboardFlowContext { credentialEnv: string | null; selectedMessagingChannels: string[]; webSearchConfig: WebSearchConfig | null; + webSearchConfigChanged?: boolean; webSearchSupported: boolean; hermesToolGateways: string[]; agent: Agent; @@ -73,6 +74,7 @@ export interface PoliciesStateOptions { hermesToolGateways: string[]; agent?: string | null; webSearchConfig: WebSearchConfig | null; + webSearchConfigChanged: boolean; webSearchSupported: boolean; tierName?: string | null; }, @@ -131,6 +133,7 @@ export async function handlePoliciesState({ credentialEnv, selectedMessagingChannels, webSearchConfig, + webSearchConfigChanged = false, webSearchSupported, hermesToolGateways, agent, @@ -168,6 +171,7 @@ export async function handlePoliciesState({ hermesToolGateways, agent: normalizeAgentName((agent as { name?: string } | null)?.name), webSearchConfig, + webSearchConfigChanged, webSearchSupported, tierName: activeSandbox?.policyTier ?? null, }); diff --git a/src/lib/onboard/machine/handlers/sandbox.test.ts b/src/lib/onboard/machine/handlers/sandbox.test.ts index b012c3540f0..07d0e31a20b 100644 --- a/src/lib/onboard/machine/handlers/sandbox.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox.test.ts @@ -85,7 +85,7 @@ async function withEnv(key: string, value: string, run: () => Promise): Pr type Gpu = { type: string } | null; type Agent = { displayName?: string; name?: string } | null; -type WebSearchConfig = { fetchEnabled: true }; +type WebSearchConfig = { fetchEnabled: true; provider?: "brave" | "tavily" }; type MessagingChannelConfig = Record; type SandboxGpuConfig = { sandboxGpuEnabled: boolean; mode: string }; type ResourceProfile = { cpu: string; memory: string }; @@ -155,7 +155,7 @@ function createDeps( left.length === right.length && left.every((value) => right.includes(value)), removeSandboxFromRegistry: calls.removeSandbox, repairRecordedSandbox: calls.repairSandbox, - ensureValidatedBraveSearchCredential: calls.validateBrave, + ensureValidatedWebSearchCredential: calls.validateBrave, isBackToSelection: calls.isBackToSelection, configureWebSearch: calls.configureWebSearch, startRecordedStep: calls.startStep, @@ -272,6 +272,7 @@ describe("handleSandboxState", () => { expect(result).toMatchObject({ sandboxName: "my-assistant", selectedMessagingChannels: ["telegram"], + webSearchConfigChanged: true, webSearchSupported: true, }); expect(result.session?.sandboxName).toBe("my-assistant"); @@ -284,6 +285,41 @@ describe("handleSandboxState", () => { }); }); + it("removes the conflicting Hermes nous-web gateway when Tavily is selected", async () => { + const { deps, calls } = createDeps(); + + const result = await handleSandboxState({ + ...baseOptions(deps), + agent: { name: "hermes", displayName: "Hermes" }, + webSearchConfig: { fetchEnabled: true, provider: "tavily" }, + hermesToolGateways: ["nous-web", "nous-audio"], + }); + + expect(calls.createSandbox).toHaveBeenCalledWith( + expect.anything(), + "model", + "provider", + "openai-completions", + "my-assistant", + { fetchEnabled: true, provider: "tavily" }, + [], + null, + { name: "hermes", displayName: "Hermes" }, + null, + expect.anything(), + null, + ["nous-audio"], + ); + expect(result.hermesToolGateways).toEqual(["nous-audio"]); + expect(calls.note).toHaveBeenCalledWith( + " Tavily Search replaces Hermes managed Web search/extract; keeping the other selected Nous tools.", + ); + expect(calls.complete).toHaveBeenCalledWith( + "sandbox", + expect.objectContaining({ hermesToolGateways: ["nous-audio"] }), + ); + }); + it("reuses a completed ready sandbox on resume", async () => { const session = createSession({ sandboxName: "saved", @@ -310,9 +346,31 @@ describe("handleSandboxState", () => { sandboxName: "saved", }); expect(result.selectedMessagingChannels).toEqual(["slack"]); + expect(result.webSearchConfigChanged).toBe(false); expect(result.session).toBe(skippedSession); }); + it("marks web search changed when recreate implicitly enables Tavily", async () => { + const session = createSession({ sandboxName: "saved" }); + session.steps.sandbox.status = "complete"; + const { deps } = createDeps({ + getSandboxReuseState: () => "not_ready", + configureWebSearch: vi.fn(async () => ({ + fetchEnabled: true as const, + provider: "tavily" as const, + })), + }); + + const result = await handleSandboxState({ + ...baseOptions(deps, session), + resume: true, + sandboxName: "saved", + }); + + expect(result.webSearchConfig).toEqual({ fetchEnabled: true, provider: "tavily" }); + expect(result.webSearchConfigChanged).toBe(true); + }); + it("removes registry state when messaging config drift forces sandbox recreation", async () => { const session = createSession(); session.steps.sandbox.status = "complete"; @@ -404,7 +462,7 @@ describe("handleSandboxState", () => { }); expect(calls.note).toHaveBeenCalledWith( - " Web search is not yet supported by this sandbox image. Clearing stale config.", + " Brave Search is not yet supported by this sandbox image. Clearing stale config.", ); expect(calls.note).toHaveBeenCalledWith( " [resume] Web Search configuration changed; recreating sandbox.", @@ -413,6 +471,82 @@ describe("handleSandboxState", () => { expect(calls.createSandbox).toHaveBeenCalled(); }); + it("recreates when an explicit web-search provider differs from saved state", async () => { + const session = createSession({ + sandboxName: "saved", + webSearchConfig: { fetchEnabled: true, provider: "brave" }, + }); + session.steps.sandbox.status = "complete"; + const { deps, calls } = createDeps({ + getSandboxReuseState: () => "ready", + agentSupportsWebSearchProvider: () => true, + }); + + const result = await withEnv("NEMOCLAW_WEB_SEARCH_PROVIDER", "tavily", () => + handleSandboxState({ + ...baseOptions(deps, session), + resume: true, + sandboxName: "saved", + webSearchConfig: { fetchEnabled: true, provider: "brave" }, + }), + ); + + expect(calls.note).toHaveBeenCalledWith( + " [resume] Web Search configuration changed; recreating sandbox.", + ); + expect(calls.removeSandbox).toHaveBeenCalledWith("saved"); + expect(calls.validateBrave).toHaveBeenCalledWith({ + fetchEnabled: true, + provider: "tavily", + }); + expect(calls.createSandbox).toHaveBeenCalledWith( + { type: "nvidia" }, + "model", + "provider", + "openai-completions", + "saved", + { fetchEnabled: true, provider: "tavily" }, + [], + null, + null, + null, + { sandboxGpuEnabled: false, mode: "0" }, + null, + [], + ); + expect(result.webSearchConfigChanged).toBe(true); + }); + + it("keeps registry state intact when replacement provider validation fails", async () => { + const session = createSession({ + sandboxName: "saved", + webSearchConfig: { fetchEnabled: true, provider: "brave" }, + }); + session.steps.sandbox.status = "complete"; + const { deps, calls } = createDeps({ + getSandboxReuseState: () => "ready", + agentSupportsWebSearchProvider: () => true, + ensureValidatedWebSearchCredential: vi.fn(async () => { + throw new Error("Tavily credential rejected"); + }), + }); + + await expect( + withEnv("NEMOCLAW_WEB_SEARCH_PROVIDER", "tavily", () => + handleSandboxState({ + ...baseOptions(deps, session), + resume: true, + sandboxName: "saved", + webSearchConfig: { fetchEnabled: true, provider: "brave" }, + }), + ), + ).rejects.toThrow("Tavily credential rejected"); + + expect(calls.removeSandbox).not.toHaveBeenCalled(); + expect(calls.repairSandbox).not.toHaveBeenCalled(); + expect(calls.createSandbox).not.toHaveBeenCalled(); + }); + it("drops saved web search config when credential revalidation returns to provider selection", async () => { const session = createSession({ sandboxName: "saved", @@ -422,7 +556,7 @@ describe("handleSandboxState", () => { const backToSelection = Object.freeze({ kind: "NEMOCLAW_BACK_TO_SELECTION" }); const { deps, calls } = createDeps({ getSandboxReuseState: () => "not_ready", - ensureValidatedBraveSearchCredential: vi.fn(async () => backToSelection), + ensureValidatedWebSearchCredential: vi.fn(async () => backToSelection), isBackToSelection: vi.fn((value: unknown) => value === backToSelection), }); diff --git a/src/lib/onboard/machine/handlers/sandbox.ts b/src/lib/onboard/machine/handlers/sandbox.ts index e4449e73fdc..1e65034f445 100644 --- a/src/lib/onboard/machine/handlers/sandbox.ts +++ b/src/lib/onboard/machine/handlers/sandbox.ts @@ -1,6 +1,14 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { + parseExplicitWebSearchProvider, + type WebSearchConfig as SharedWebSearchConfig, + WEB_SEARCH_PROVIDER_ENV, + webSearchConfigsEqual, + webSearchLabelFor, + webSearchProviderForConfig, +} from "../../../inference/web-search"; import type { SandboxMessagingPlan } from "../../../messaging/manifest"; import type { Session, SessionUpdates } from "../../../state/onboard-session"; import { withSandboxPhaseTrace } from "../../tracing"; @@ -45,6 +53,12 @@ export interface SandboxStateOptions< dockerfilePathOverride: string | null, rootDir: string, ): boolean; + agentSupportsWebSearchProvider?( + agent: Agent, + provider: "brave" | "tavily", + dockerfilePathOverride: string | null, + rootDir: string, + ): boolean; note(message: string): void; updateSession(mutator: (session: Session) => Session | void): Session; getStoredMessagingChannelConfig( @@ -65,7 +79,7 @@ export interface SandboxStateOptions< stringSetsEqual(left: string[], right: string[]): boolean; removeSandboxFromRegistry(sandboxName: string): void; repairRecordedSandbox(sandboxName: string | null): void; - ensureValidatedBraveSearchCredential(): Promise; + ensureValidatedWebSearchCredential(config: WebSearchConfig): Promise; isBackToSelection(value: unknown): boolean; configureWebSearch( existingConfig: WebSearchConfig | null, @@ -137,6 +151,8 @@ export interface SandboxStateOptions< export interface SandboxStateResult { sandboxName: string; webSearchConfig: WebSearchConfig | null; + webSearchConfigChanged: boolean; + hermesToolGateways: string[]; selectedMessagingChannels: string[]; webSearchSupported: boolean; session: Session | null; @@ -147,12 +163,42 @@ interface SandboxStepState { readonly session: Session | null; readonly sandboxName: string | null; readonly webSearchConfig: WebSearchConfig | null; + readonly webSearchConfigChanged: boolean; readonly selectedMessagingChannels: string[]; readonly webSearchSupported: boolean; readonly webSearchSupportDropped: boolean; readonly webSearchSupportProbePath: string | null; } +function resolveRequestedWebSearchConfig( + current: WebSearchConfig | null, +): WebSearchConfig | null { + const explicit = parseExplicitWebSearchProvider(process.env[WEB_SEARCH_PROVIDER_ENV]); + if (!explicit.specified) return current; + if (!explicit.provider) return null; + return { fetchEnabled: true, provider: explicit.provider } as WebSearchConfig; +} + +function knownAgentSupportsWebSearchProvider( + agent: { name?: string } | null, + provider: "brave" | "tavily", +): boolean { + return agent?.name?.trim().toLowerCase() !== "hermes" || provider === "tavily"; +} + +function effectiveHermesToolGatewaysForWebSearch( + agent: { name?: string } | null, + webSearchConfig: SharedWebSearchConfig | null, + gateways: string[], +): string[] { + const isHermes = agent?.name?.trim().toLowerCase() === "hermes"; + const tavilySelected = + webSearchConfig !== null && webSearchProviderForConfig(webSearchConfig) === "tavily"; + return isHermes && tavilySelected + ? gateways.filter((gateway) => gateway !== "nous-web") + : [...gateways]; +} + type SandboxCreationDecision = Exclude; class SandboxStateFlow< @@ -194,12 +240,33 @@ class SandboxStateFlow< probePath, this.options.rootDir, ); - const dropped = Boolean(this.options.webSearchConfig) && !supported; + const requestedWebSearchConfig = resolveRequestedWebSearchConfig(this.options.webSearchConfig); + const webSearchConfigChanged = !webSearchConfigsEqual( + this.options.session?.webSearchConfig, + requestedWebSearchConfig as unknown as SharedWebSearchConfig | null, + ); + const provider = requestedWebSearchConfig + ? webSearchProviderForConfig(requestedWebSearchConfig as unknown as SharedWebSearchConfig) + : null; + const providerSupported = provider + ? (this.deps.agentSupportsWebSearchProvider?.( + this.options.agent, + provider, + probePath, + this.options.rootDir, + ) ?? + knownAgentSupportsWebSearchProvider( + this.options.agent as { name?: string } | null, + provider, + )) + : true; + const dropped = Boolean(requestedWebSearchConfig) && (!supported || !providerSupported); if (!dropped) { return { session: this.options.session, sandboxName: this.options.sandboxName, - webSearchConfig: this.options.webSearchConfig, + webSearchConfig: requestedWebSearchConfig, + webSearchConfigChanged, selectedMessagingChannels: this.options.selectedMessagingChannels, webSearchSupported: supported, webSearchSupportDropped: false, @@ -208,7 +275,7 @@ class SandboxStateFlow< } this.deps.note( - ` Web search is not yet supported by ${(this.options.agent as { displayName?: string } | null)?.displayName ?? "this sandbox image"}. Clearing stale config.`, + ` ${provider ? webSearchLabelFor(provider) : "Web search"} is not yet supported by ${(this.options.agent as { displayName?: string } | null)?.displayName ?? "this sandbox image"}. Clearing stale config.`, ); if (this.options.session) this.options.session.webSearchConfig = null; const session = this.deps.updateSession((current) => { @@ -219,6 +286,7 @@ class SandboxStateFlow< session, sandboxName: this.options.sandboxName, webSearchConfig: null, + webSearchConfigChanged, selectedMessagingChannels: this.options.selectedMessagingChannels, webSearchSupported: supported, webSearchSupportDropped: true, @@ -237,14 +305,17 @@ class SandboxStateFlow< this.deps.getSandboxHermesToolGateways(state.sandboxName), ) : []; + const effectiveToolGateways = effectiveHermesToolGatewaysForWebSearch( + this.options.agent as { name?: string } | null, + state.webSearchConfig as unknown as SharedWebSearchConfig | null, + this.options.hermesToolGateways, + ); return decideSandboxResume({ resume: this.options.resume, resumeAgentChanged: this.options.resumeAgentChanged, sandboxStepComplete: state.session?.steps?.sandbox?.status === "complete", sandboxReuseState: this.deps.getSandboxReuseState(state.sandboxName), - webSearchConfigChanged: - state.webSearchSupportDropped || - Boolean(state.session?.webSearchConfig) !== Boolean(state.webSearchConfig), + webSearchConfigChanged: state.webSearchSupportDropped || state.webSearchConfigChanged, sandboxGpuConfigChanged: state.sandboxName ? this.deps.hasSandboxGpuDrift(state.sandboxName, this.options.sandboxGpuConfig) : false, @@ -254,7 +325,7 @@ class SandboxStateFlow< ), hermesToolGatewayConfigChanged: !this.deps.stringSetsEqual( recordedToolGateways, - this.options.hermesToolGateways, + effectiveToolGateways, ), }); } @@ -263,8 +334,11 @@ class SandboxStateFlow< state: SandboxStepState, ): Promise> { if (state.webSearchConfig) { + const provider = webSearchProviderForConfig( + state.webSearchConfig as unknown as SharedWebSearchConfig, + ); this.deps.note( - " [resume] Reusing Brave Search configuration already baked into the sandbox.", + ` [resume] Reusing ${webSearchLabelFor(provider)} configuration already baked into the sandbox.`, ); } const messaging = reconcileReusedSandboxMessaging( @@ -300,10 +374,14 @@ class SandboxStateFlow< state.webSearchSupportProbePath, ); } - this.deps.note(" [resume] Revalidating Brave Search configuration for sandbox recreation."); - const credential = await this.deps.ensureValidatedBraveSearchCredential(); + const provider = webSearchProviderForConfig( + state.webSearchConfig as unknown as SharedWebSearchConfig, + ); + const label = webSearchLabelFor(provider); + this.deps.note(` [resume] Revalidating ${label} configuration for sandbox recreation.`); + const credential = await this.deps.ensureValidatedWebSearchCredential(state.webSearchConfig); if (this.deps.isBackToSelection(credential) || !credential) return null; - this.deps.note(" [resume] Reusing Brave Search configuration."); + this.deps.note(` [resume] Reusing ${label} configuration.`); return state.webSearchConfig; } @@ -312,6 +390,11 @@ class SandboxStateFlow< requestedSandboxName: string, messagingPlan: SandboxMessagingPlan | null, ): Promise> { + const effectiveHermesToolGateways = effectiveHermesToolGatewaysForWebSearch( + this.options.agent as { name?: string } | null, + state.webSearchConfig as unknown as SharedWebSearchConfig | null, + this.options.hermesToolGateways, + ); const resourceProfile = await this.deps.selectResourceProfileForSandbox(); if (this.options.fresh) { this.deps.stopStaleDashboardListenersForSandbox( @@ -338,7 +421,7 @@ class SandboxStateFlow< this.options.controlUiPort, this.options.sandboxGpuConfig, resourceProfile, - this.options.hermesToolGateways, + effectiveHermesToolGateways, ), ); // createSandbox() owns the build fingerprint. In particular, reusing an @@ -363,7 +446,7 @@ class SandboxStateFlow< nimContainer: this.options.nimContainer, webSearchConfig: state.webSearchConfig, messagingPlan, - hermesToolGateways: this.options.hermesToolGateways, + hermesToolGateways: effectiveHermesToolGateways, }), ); return { ...state, sandboxName, session: completedSession }; @@ -373,8 +456,17 @@ class SandboxStateFlow< state: SandboxStepState, decision: SandboxCreationDecision, ): Promise> { - await applySandboxResumeDecision(decision, state.sandboxName, this.deps); const webSearchConfig = await this.resolveWebSearchForCreation(state); + const webSearchConfigChanged = + state.webSearchConfigChanged || + !webSearchConfigsEqual( + state.webSearchConfig as unknown as SharedWebSearchConfig | null, + webSearchConfig as unknown as SharedWebSearchConfig | null, + ); + // Validate the replacement provider before any resume cleanup removes the + // still-live sandbox from the registry. A bad or missing credential must + // leave the existing sandbox recoverable. + await applySandboxResumeDecision(decision, state.sandboxName, this.deps); await this.deps.startRecordedStep("sandbox", { provider: this.options.provider, model: this.options.model, @@ -398,6 +490,7 @@ class SandboxStateFlow< session, sandboxName: requestedSandboxName, webSearchConfig, + webSearchConfigChanged, selectedMessagingChannels: messaging.selectedChannels, }, requestedSandboxName, @@ -410,9 +503,24 @@ class SandboxStateFlow< this.deps.error(" Onboarding state is incomplete after sandbox setup."); return this.deps.exitProcess(1); } + const hermesToolGateways = effectiveHermesToolGatewaysForWebSearch( + this.options.agent as { name?: string } | null, + state.webSearchConfig as unknown as SharedWebSearchConfig | null, + this.options.hermesToolGateways, + ); + if ( + this.options.hermesToolGateways.includes("nous-web") && + !hermesToolGateways.includes("nous-web") + ) { + this.deps.note( + " Tavily Search replaces Hermes managed Web search/extract; keeping the other selected Nous tools.", + ); + } return { sandboxName: state.sandboxName, webSearchConfig: state.webSearchConfig, + webSearchConfigChanged: state.webSearchConfigChanged, + hermesToolGateways, selectedMessagingChannels: state.selectedMessagingChannels, webSearchSupported: state.webSearchSupported, session: state.session, diff --git a/src/lib/onboard/messaging-prep.test.ts b/src/lib/onboard/messaging-prep.test.ts index e661462dfef..a6cce0a280b 100644 --- a/src/lib/onboard/messaging-prep.test.ts +++ b/src/lib/onboard/messaging-prep.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it, vi } from "vitest"; -import { BRAVE_API_KEY_ENV } from "../inference/web-search"; +import { BRAVE_API_KEY_ENV, TAVILY_API_KEY_ENV } from "../inference/web-search"; import { listChannels } from "../sandbox/channels"; import { type CreateSandboxMessagingPrepInput, @@ -85,6 +85,7 @@ describe("prepareCreateSandboxMessaging", () => { ); expect(result.missingBraveApiKey).toBe(true); + expect(result.missingWebSearchCredentialEnv).toBe(BRAVE_API_KEY_ENV); expect(result.extraPlaceholderKeys).toEqual([]); expect(result.messagingTokenDefs.some(({ envKey }) => envKey === BRAVE_API_KEY_ENV)).toBe( false, @@ -92,6 +93,21 @@ describe("prepareCreateSandboxMessaging", () => { expect(registerExtraPlaceholderProviders).not.toHaveBeenCalled(); }); + it("reports a missing Tavily key using the selected provider credential", () => { + const result = prepareCreateSandboxMessaging( + createInput({ + webSearchConfig: { fetchEnabled: true, provider: "tavily" }, + env: { [BRAVE_API_KEY_ENV]: "brv-does-not-satisfy-tavily" }, + }), + ); + + expect(result.missingBraveApiKey).toBe(false); + expect(result.missingWebSearchCredentialEnv).toBe(TAVILY_API_KEY_ENV); + expect(result.messagingTokenDefs.some(({ envKey }) => envKey === TAVILY_API_KEY_ENV)).toBe( + false, + ); + }); + it("adds the Brave provider token from the credential store before host env fallback", () => { const registerExtraPlaceholderProviders = vi.fn(() => []); @@ -105,6 +121,7 @@ describe("prepareCreateSandboxMessaging", () => { ); expect(result.missingBraveApiKey).toBe(false); + expect(result.missingWebSearchCredentialEnv).toBeNull(); expect(result.hasMessagingTokens).toBe(true); expect(result.messagingTokenDefs).toContainEqual({ name: "demo-brave-search", @@ -118,6 +135,41 @@ describe("prepareCreateSandboxMessaging", () => { ); }); + it("adds a per-sandbox Tavily provider with credential-store precedence", () => { + const result = prepareCreateSandboxMessaging( + createInput({ + webSearchConfig: { fetchEnabled: true, provider: "tavily" }, + env: { [TAVILY_API_KEY_ENV]: "tvly-host" }, + getCredential: (envKey) => (envKey === TAVILY_API_KEY_ENV ? "tvly-store" : null), + }), + ); + + expect(result.missingWebSearchCredentialEnv).toBeNull(); + expect(result.messagingTokenDefs).toContainEqual({ + name: "demo-tavily-search", + envKey: TAVILY_API_KEY_ENV, + token: "tvly-store", + providerType: "tavily", + }); + }); + + it("uses the versioned Hermes Tavily profile for Hermes sandboxes", () => { + const result = prepareCreateSandboxMessaging( + createInput({ + agentName: "hermes", + webSearchConfig: { fetchEnabled: true, provider: "tavily" }, + env: { [TAVILY_API_KEY_ENV]: "tvly-host" }, + }), + ); + + expect(result.messagingTokenDefs).toContainEqual({ + name: "demo-tavily-search", + envKey: TAVILY_API_KEY_ENV, + token: "tvly-host", + providerType: "tavily-hermes-v1", + }); + }); + it("removes both Slack bot and app token definitions when Slack is disabled", () => { const result = prepareCreateSandboxMessaging( createInput({ diff --git a/src/lib/onboard/messaging-prep.ts b/src/lib/onboard/messaging-prep.ts index 1c59fd84e83..6f45be814c2 100644 --- a/src/lib/onboard/messaging-prep.ts +++ b/src/lib/onboard/messaging-prep.ts @@ -18,6 +18,7 @@ export interface MessagingTokenDef { export interface CreateSandboxMessagingPrepInput { sandboxName: string; + agentName?: string | null; channels: readonly NamedMessagingChannel[]; enabledChannels: readonly string[] | null; disabledChannels: readonly string[]; @@ -44,6 +45,8 @@ export interface CreateSandboxMessagingPrepResult { hasMessagingTokens: boolean; reusableMessagingProviders: string[]; reusableMessagingChannels: string[]; + missingWebSearchCredentialEnv: string | null; + /** @deprecated Compatibility alias for older callers and tests. */ missingBraveApiKey: boolean; } @@ -75,15 +78,17 @@ export function prepareCreateSandboxMessaging( .filter(({ envKey }) => !enabledEnvKeys || enabledEnvKeys.has(envKey)) .filter(({ envKey }) => !disabledEnvKeys.has(envKey)); - const braveWebSearchEnabled = braveProviderProfile.shouldEnableBraveWebSearch( - input.webSearchConfig, - ); - const braveApiKey = braveWebSearchEnabled - ? input.getCredential(webSearch.BRAVE_API_KEY_ENV) || - input.normalizeCredentialValue(input.env[webSearch.BRAVE_API_KEY_ENV]) + const webSearchEnabled = braveProviderProfile.shouldEnableWebSearch(input.webSearchConfig); + const webSearchProvider = webSearch.webSearchProviderForConfig(input.webSearchConfig); + const webSearchCredentialEnv = webSearch.webSearchEnvFor(webSearchProvider); + const webSearchApiKey = webSearchEnabled + ? input.getCredential(webSearchCredentialEnv) || + input.normalizeCredentialValue(input.env[webSearchCredentialEnv]) : null; - const missingBraveApiKey = braveWebSearchEnabled && !braveApiKey; - if (missingBraveApiKey) { + const missingWebSearchCredentialEnv = + webSearchEnabled && !webSearchApiKey ? webSearchCredentialEnv : null; + const missingBraveApiKey = missingWebSearchCredentialEnv === webSearch.BRAVE_API_KEY_ENV; + if (missingWebSearchCredentialEnv) { return { disabledChannelNames, messagingTokenDefs, @@ -91,16 +96,21 @@ export function prepareCreateSandboxMessaging( hasMessagingTokens: messagingTokenDefs.some(({ token }) => !!token), reusableMessagingProviders: [], reusableMessagingChannels: [], + missingWebSearchCredentialEnv, missingBraveApiKey, }; } - if (braveWebSearchEnabled) { + if (webSearchEnabled) { + const providerType = + webSearchProvider === "tavily" && input.agentName?.trim().toLowerCase() === "hermes" + ? braveProviderProfile.HERMES_TAVILY_PROVIDER_PROFILE_ID + : webSearchProvider; messagingTokenDefs.push({ - name: `${input.sandboxName}-brave-search`, - envKey: webSearch.BRAVE_API_KEY_ENV, - token: braveApiKey, - providerType: braveProviderProfile.BRAVE_PROVIDER_PROFILE_ID, + name: `${input.sandboxName}-${webSearchProvider}-search`, + envKey: webSearchCredentialEnv, + token: webSearchApiKey, + providerType, }); } @@ -132,6 +142,7 @@ export function prepareCreateSandboxMessaging( hasMessagingTokens, reusableMessagingProviders, reusableMessagingChannels, + missingWebSearchCredentialEnv, missingBraveApiKey, }; } diff --git a/src/lib/onboard/policy-presets.ts b/src/lib/onboard/policy-presets.ts index 92517cef4fe..dfa2b1789b0 100644 --- a/src/lib/onboard/policy-presets.ts +++ b/src/lib/onboard/policy-presets.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { getCredential } from "../credentials/store"; -import type { WebSearchConfig } from "../inference/web-search"; +import { type WebSearchConfig, webSearchProviderForConfig } from "../inference/web-search"; import { listMessagingCredentialMetadata, listMessagingPolicyPresetMetadata, @@ -78,7 +78,7 @@ export function getSuggestedPolicyPresets({ ); } - if (webSearchConfig) suggestions.push("brave"); + if (webSearchConfig) suggestions.push(webSearchProviderForConfig(webSearchConfig)); return suggestions; } diff --git a/src/lib/onboard/policy-resume-selection.test.ts b/src/lib/onboard/policy-resume-selection.test.ts new file mode 100644 index 00000000000..ebe16d1387d --- /dev/null +++ b/src/lib/onboard/policy-resume-selection.test.ts @@ -0,0 +1,82 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { preparePolicyPresetResumeSelection } from "./policy-resume-selection"; + +type Preset = { name: string; access?: string }; + +function policies(options: { applied?: string[]; custom?: string[] } = {}) { + const setupPresets = ["npm", "brave", "tavily"].map((name) => ({ name })); + const customPresets = (options.custom ?? []).map((name) => ({ name })); + return { + setupPolicyPresetSupported: () => true, + listSetupPolicyPresets: () => setupPresets, + listCustomPresets: () => customPresets, + getAppliedPresets: () => options.applied ?? [], + clampSetupPolicyPresetNames( + names: string[], + selectablePresets: Preset[], + _supportOptions: { webSearchSupported?: boolean | null } | undefined, + customPresetNames: Set = new Set(), + ) { + const selectable = new Set(selectablePresets.map((preset) => preset.name)); + return names.filter((name) => selectable.has(name) || customPresetNames.has(name)); + }, + }; +} + +function prepare( + recordedPolicyPresets: string[], + provider: "brave" | "tavily", + webSearchConfigChanged = false, +) { + return preparePolicyPresetResumeSelection({ policies: policies() }, "alpha", { + recordedPolicyPresets, + agent: "openclaw", + webSearchConfig: { fetchEnabled: true, provider }, + webSearchConfigChanged, + webSearchSupported: true, + }); +} + +describe("preparePolicyPresetResumeSelection web search reconciliation", () => { + it("replaces stale Brave policy with Tavily during a provider switch", () => { + const result = prepare(["brave"], "tavily"); + + expect(result.policyPresets).toEqual(["tavily"]); + expect(result.recordedPolicyPresetsNeedReconcile).toBe(true); + }); + + it("adds Tavily when web search becomes enabled on resume", () => { + const result = prepare(["npm"], "tavily", true); + + expect(result.policyPresets).toEqual(["npm", "tavily"]); + expect(result.recordedPolicyPresetsNeedReconcile).toBe(true); + }); + + it("preserves an intentionally removed provider preset when configuration is unchanged", () => { + const result = prepare(["npm"], "tavily"); + + expect(result.policyPresets).toEqual(["npm"]); + expect(result.recordedPolicyPresetsNeedReconcile).toBe(false); + }); + + it("preserves an operator-owned preset name while adding the active provider", () => { + const result = preparePolicyPresetResumeSelection( + { policies: policies({ custom: ["brave"] }) }, + "alpha", + { + recordedPolicyPresets: ["brave"], + agent: "openclaw", + webSearchConfig: { fetchEnabled: true, provider: "tavily" }, + webSearchConfigChanged: true, + webSearchSupported: true, + }, + ); + + expect(result.policyPresets).toEqual(["brave", "tavily"]); + expect(result.recordedPolicyPresetsNeedReconcile).toBe(true); + }); +}); diff --git a/src/lib/onboard/policy-resume-selection.ts b/src/lib/onboard/policy-resume-selection.ts index d87fe916ae7..e73398c5722 100644 --- a/src/lib/onboard/policy-resume-selection.ts +++ b/src/lib/onboard/policy-resume-selection.ts @@ -1,7 +1,7 @@ // 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 { type WebSearchConfig, webSearchProviderForConfig } from "../inference/web-search"; import { filterSetupPolicyPresetNamesForAgent, filterSetupPolicyPresetsForAgent, @@ -12,7 +12,7 @@ import { pruneDisabledMessagingPolicyPresets, } from "./messaging-policy-presets"; import { - isStaleBuiltinBravePolicyPreset, + isStaleBuiltinWebSearchPolicyPreset, mergeRequiredSetupPolicyPresets, type PreparedPolicyResumeSelection, } from "./policy-selection"; @@ -49,6 +49,7 @@ export function preparePolicyPresetResumeSelection( hermesToolGateways?: string[] | null; agent?: string | null; webSearchConfig?: WebSearchConfig | null; + webSearchConfigChanged?: boolean; webSearchSupported?: boolean | null; env?: NodeJS.ProcessEnv; tierName?: string | null; @@ -74,18 +75,18 @@ export function preparePolicyPresetResumeSelection( supportOptions, customPolicyPresetNames, ); - const isStaleBuiltinBrave = (name: string) => - isStaleBuiltinBravePolicyPreset(name, { + const isStaleBuiltinWebSearch = (name: string) => + isStaleBuiltinWebSearchPolicyPreset(name, { webSearchConfig: options.webSearchConfig, customPresetNames: customPolicyPresetNames, }); + const recordedBuiltinWebSearchProviderChanged = clampedRecordedPolicyPresets.some( + (name) => (name === "brave" || name === "tavily") && isStaleBuiltinWebSearch(name), + ); let policyPresets = pruneDisabledMessagingPolicyPresets( - clampedRecordedPolicyPresets.filter((name) => !isStaleBuiltinBrave(name)), + clampedRecordedPolicyPresets.filter((name) => !isStaleBuiltinWebSearch(name)), options.disabledChannels, ); - const recordedPolicyPresetsNeedReconcile = - Array.isArray(options.recordedPolicyPresets) && - policyPresets.length !== options.recordedPolicyPresets.length; const appliedPolicyPresetsForSupport = deps.policies .clampSetupPolicyPresetNames( appliedPolicyPresets, @@ -93,7 +94,7 @@ export function preparePolicyPresetResumeSelection( supportOptions, customPolicyPresetNames, ) - .filter((name) => !isStaleBuiltinBrave(name)); + .filter((name) => !isStaleBuiltinWebSearch(name)); const disabledMessagingPolicyPresetApplied = hasDisabledMessagingPolicyPreset( appliedPolicyPresetsForSupport, options.disabledChannels, @@ -111,8 +112,34 @@ export function preparePolicyPresetResumeSelection( knownPresetNames: selectablePolicyPresets.map((preset) => preset.name), env: options.env, tierName: options.tierName, + webSearchConfig: options.webSearchConfig, + customPresetNames: customPolicyPresetNames, }); + + // Provider switches are build-time changes, but their matching egress + // preset is runtime state. Resume must add the newly active provider after + // pruning the stale one or the replacement sandbox cannot reach search. + const activeWebSearchPreset = options.webSearchConfig + ? webSearchProviderForConfig(options.webSearchConfig) + : null; + const selectablePolicyPresetNames = new Set( + selectablePolicyPresets.map((preset) => preset.name), + ); + if ( + activeWebSearchPreset && + options.webSearchSupported !== false && + (options.webSearchConfigChanged === true || recordedBuiltinWebSearchProviderChanged) && + selectablePolicyPresetNames.has(activeWebSearchPreset) && + !policyPresets.includes(activeWebSearchPreset) + ) { + policyPresets.push(activeWebSearchPreset); + } } + const recordedPolicyPresetsNeedReconcile = + Array.isArray(options.recordedPolicyPresets) && + (policyPresets.length !== options.recordedPolicyPresets.length || + policyPresets.some((name) => !options.recordedPolicyPresets?.includes(name)) || + options.recordedPolicyPresets.some((name) => !policyPresets.includes(name))); const suppressedForTier = options.tierName ? new Set(suppressedAgentRequiredPresets(options.tierName, options.agent)) : null; diff --git a/src/lib/onboard/policy-selection.ts b/src/lib/onboard/policy-selection.ts index 804a4723577..06669e7987f 100644 --- a/src/lib/onboard/policy-selection.ts +++ b/src/lib/onboard/policy-selection.ts @@ -1,7 +1,7 @@ // 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 { type WebSearchConfig, webSearchProviderForConfig } from "../inference/web-search"; import { filterSetupPolicyPresetNamesForAgent, filterSetupPolicyPresetsForAgent, @@ -56,6 +56,7 @@ export type SetupPresetSuggestionOptions = { knownPresetNames?: string[] | null; webSearchSupported?: boolean | null; hermesToolGateways?: string[] | null; + customPresetNames?: ReadonlySet | null; env?: NodeJS.ProcessEnv; }; @@ -114,14 +115,23 @@ export function mergeRequiredSetupPolicyPresets( knownPresetNames?: string[] | Set | null; env?: NodeJS.ProcessEnv; tierName?: string | null; + webSearchConfig?: WebSearchConfig | null; + customPresetNames?: ReadonlySet | null; } = {}, ): string[] { const agentFilteredPresets = filterSetupPolicyPresetNamesForAgent(policyPresets, options.agent); + const effectiveHermesToolGateways = (options.hermesToolGateways ?? []).filter( + (name) => + !isStaleBuiltinWebSearchPolicyPreset(name, { + webSearchConfig: options.webSearchConfig, + customPresetNames: options.customPresetNames, + }), + ); const mergedPresets = mergeRequiredOpenclawOtelPolicyPresets( mergeRequiredMessagingChannelPolicyPresets( mergeRequiredHermesToolGatewayPolicyPresets( agentFilteredPresets, - options.hermesToolGateways, + effectiveHermesToolGateways, options.knownPresetNames, ), options.enabledChannels, @@ -144,7 +154,25 @@ export function isStaleBuiltinBravePolicyPreset( customPresetNames?: ReadonlySet | null; } = {}, ): boolean { - return name === "brave" && !options.webSearchConfig && !options.customPresetNames?.has(name); + return isStaleBuiltinWebSearchPolicyPreset(name, options); +} + +export function isStaleBuiltinWebSearchPolicyPreset( + name: string, + options: { + webSearchConfig?: WebSearchConfig | null; + customPresetNames?: ReadonlySet | null; + } = {}, +): boolean { + if (options.customPresetNames?.has(name)) return false; + if (name === "nous-web") { + return Boolean( + options.webSearchConfig && webSearchProviderForConfig(options.webSearchConfig) === "tavily", + ); + } + if (name !== "brave" && name !== "tavily") return false; + if (!options.webSearchConfig) return true; + return name !== webSearchProviderForConfig(options.webSearchConfig); } export function computeSetupPresetSuggestions( @@ -170,17 +198,31 @@ export function computeSetupPresetSuggestions( .resolveTierPresets(tierName) .map((preset) => preset.name) .filter((name) => setupPolicyPresetAppliesToAgent(name, agent)) - .filter((name) => !isStaleBuiltinBravePolicyPreset(name, { webSearchConfig })) + .filter( + (name) => + !isStaleBuiltinWebSearchPolicyPreset(name, { + webSearchConfig, + customPresetNames: options.customPresetNames, + }), + ) .filter((name) => deps.policies.setupPolicyPresetSupported(name, supportOptions)) .filter((name) => !known || known.has(name)); const add = (name: string) => { if (!setupPolicyPresetAppliesToAgent(name, agent)) return; + if ( + isStaleBuiltinWebSearchPolicyPreset(name, { + webSearchConfig, + customPresetNames: options.customPresetNames, + }) + ) { + return; + } 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 (webSearchConfig) add(webSearchProviderForConfig(webSearchConfig)); if (provider && deps.localInferenceProviders.includes(provider)) add("local-inference"); if (tierName !== RESTRICTED_TIER_NAME) { for (const preset of agentRequiredPresetAdditions(agent, env)) add(preset); @@ -256,12 +298,12 @@ async function setupPoliciesWithSelectionInner( supportOptions, customPresetNames, ); - const isStaleBuiltinBrave = (name: string) => - isStaleBuiltinBravePolicyPreset(name, { webSearchConfig, customPresetNames }); + const isStaleBuiltinWebSearch = (name: string) => + isStaleBuiltinWebSearchPolicyPreset(name, { webSearchConfig, customPresetNames }); const appliedForPreservation = pruneDisabledMessagingPolicyPresets( applied, disabledChannels, - ).filter((name) => !isStaleBuiltinBrave(name)); + ).filter((name) => !isStaleBuiltinWebSearch(name)); const pruneDisabledPresets = (presetNames: string[]) => pruneDisabledMessagingPolicyPresets(presetNames, disabledChannels); const filterSupportedPresetNames = (presetNames: string[]) => @@ -284,6 +326,7 @@ async function setupPoliciesWithSelectionInner( // below uses the newly-selected `tierName` from `selectPolicyTier()`. const recordedTierName = deps.getRecordedPolicyTier?.(sandboxName) ?? null; if (chosen !== null) { + chosen = chosen.filter((name) => !isStaleBuiltinWebSearch(name)); const knownSelectablePresets = new Set(selectablePresets.map((preset) => preset.name)); chosen = mergeRequiredSetupPolicyPresets(chosen, { enabledChannels, @@ -292,6 +335,8 @@ async function setupPoliciesWithSelectionInner( knownPresetNames: knownSelectablePresets, env: deps.env, tierName: recordedTierName, + webSearchConfig, + customPresetNames, }); chosen = pruneDisabledPresets(chosen); } @@ -314,6 +359,7 @@ async function setupPoliciesWithSelectionInner( computeSetupPresetSuggestions(deps, tierName, { enabledChannels, webSearchConfig, + customPresetNames, provider, agent, knownPresetNames: allPresets.map((preset) => preset.name), @@ -344,7 +390,11 @@ async function setupPoliciesWithSelectionInner( 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); + if (envPresets.length > 0) { + chosen = filterSupportedPresetNames(envPresets).filter( + (name) => !isStaleBuiltinWebSearch(name), + ); + } } else { console.warn(` Unsupported NEMOCLAW_POLICY_MODE: ${policyMode}`); console.warn( @@ -365,6 +415,8 @@ async function setupPoliciesWithSelectionInner( knownPresetNames: knownPresets, env: deps.env, tierName, + webSearchConfig, + customPresetNames, }); chosen = pruneDisabledPresets(chosen); @@ -384,7 +436,7 @@ async function setupPoliciesWithSelectionInner( const kept: string[] = []; for (const name of appliedForPreservation) { if (chosenSet.has(name)) continue; - if (isStaleBuiltinBrave(name)) continue; + if (isStaleBuiltinWebSearch(name)) continue; if (suppressedNames.has(name)) continue; chosen.push(name); chosenSet.add(name); @@ -425,6 +477,8 @@ async function setupPoliciesWithSelectionInner( knownPresetNames: knownNames, env: deps.env, tierName, + webSearchConfig, + customPresetNames, }, ), ); diff --git a/src/lib/onboard/sandbox-messaging-preflight.test.ts b/src/lib/onboard/sandbox-messaging-preflight.test.ts index 3aaa0bbd62b..4128633e6d1 100644 --- a/src/lib/onboard/sandbox-messaging-preflight.test.ts +++ b/src/lib/onboard/sandbox-messaging-preflight.test.ts @@ -22,6 +22,7 @@ function createResult(overrides = {}) { hasMessagingTokens: false, reusableMessagingProviders: [], reusableMessagingChannels: [], + missingWebSearchCredentialEnv: null, missingBraveApiKey: false, ...overrides, }; @@ -245,14 +246,34 @@ describe("prepareSandboxMessagingPreflight", () => { it("fails before recreate/delete when Brave search has no API key", async () => { const deps = createDeps({ - prepareCreateSandboxMessaging: vi.fn(() => createResult({ missingBraveApiKey: true })), + prepareCreateSandboxMessaging: vi.fn(() => + createResult({ + missingWebSearchCredentialEnv: "BRAVE_API_KEY", + missingBraveApiKey: true, + }), + ), }); await expect(prepareSandboxMessagingPreflight(baseInput, deps)).rejects.toMatchObject({ code: 1, }); expect(deps.error).toHaveBeenCalledWith( - " Brave Search is enabled, but BRAVE_API_KEY is not available in this process.", + " Web search is enabled, but BRAVE_API_KEY is not available in this process.", + ); + }); + + it("names the selected Tavily credential when recreate preflight fails", async () => { + const deps = createDeps({ + prepareCreateSandboxMessaging: vi.fn(() => + createResult({ missingWebSearchCredentialEnv: "TAVILY_API_KEY" }), + ), + }); + + await expect(prepareSandboxMessagingPreflight(baseInput, deps)).rejects.toMatchObject({ + code: 1, + }); + expect(deps.error).toHaveBeenCalledWith( + " Web search is enabled, but TAVILY_API_KEY is not available in this process.", ); }); }); diff --git a/src/lib/onboard/sandbox-messaging-preflight.ts b/src/lib/onboard/sandbox-messaging-preflight.ts index 69860ca1a45..06b9149fe2c 100644 --- a/src/lib/onboard/sandbox-messaging-preflight.ts +++ b/src/lib/onboard/sandbox-messaging-preflight.ts @@ -8,14 +8,15 @@ import { type MessagingConflictGuardDeps, } from "./messaging-conflict-guard"; import { - prepareCreateSandboxMessaging as defaultPrepareCreateSandboxMessaging, type CreateSandboxMessagingPrepInput, type CreateSandboxMessagingPrepResult, + prepareCreateSandboxMessaging as defaultPrepareCreateSandboxMessaging, type NamedMessagingChannel, } from "./messaging-prep"; export interface SandboxMessagingPreflightInput { sandboxName: string; + agentName?: string | null; channels: readonly NamedMessagingChannel[]; enabledChannels: readonly string[] | null; webSearchConfig: WebSearchConfig | null; @@ -68,6 +69,7 @@ export async function prepareSandboxMessagingPreflight( const result = (deps.prepareCreateSandboxMessaging ?? defaultPrepareCreateSandboxMessaging)({ sandboxName: input.sandboxName, + agentName: input.agentName, channels: input.channels, enabledChannels: input.enabledChannels, disabledChannels, @@ -81,11 +83,10 @@ export async function prepareSandboxMessagingPreflight( providerExistsInGateway: deps.providerExistsInGateway, }); - if (result.missingBraveApiKey) { - deps.error(" Brave Search is enabled, but BRAVE_API_KEY is not available in this process."); - deps.error( - " Re-run with BRAVE_API_KEY set, or disable Brave Search before recreating the sandbox.", - ); + if (result.missingWebSearchCredentialEnv) { + const envKey = result.missingWebSearchCredentialEnv; + deps.error(` Web search is enabled, but ${envKey} is not available in this process.`); + deps.error(` Re-run with ${envKey} set, or disable web search before recreating the sandbox.`); deps.exitProcess(1); } diff --git a/src/lib/onboard/sandbox-provider-cleanup.ts b/src/lib/onboard/sandbox-provider-cleanup.ts index cbc441b4ac5..746421041a4 100644 --- a/src/lib/onboard/sandbox-provider-cleanup.ts +++ b/src/lib/onboard/sandbox-provider-cleanup.ts @@ -37,6 +37,7 @@ export type SandboxRecreateCleanupDeps = DetachSandboxProvidersDeps & { export const SANDBOX_PROVIDER_SUFFIXES = [ ...listMessagingProviderSuffixes().map((suffix) => suffix.replace(/^-/, "")), "brave-search", + "tavily-search", ] as readonly string[]; export type SandboxProviderSuffix = string; diff --git a/src/lib/onboard/summary.test.ts b/src/lib/onboard/summary.test.ts index 5a071b0b6b1..cdf42df98d2 100644 --- a/src/lib/onboard/summary.test.ts +++ b/src/lib/onboard/summary.test.ts @@ -27,6 +27,7 @@ describe("onboard summary helpers", () => { "summary shows API key staging state without printing env var names", ); assert.ok(summary.includes("enabled"), "summary includes web-search enabled"); + assert.ok(summary.includes("Brave Search"), "legacy web-search config defaults to Brave"); assert.ok(summary.includes("telegram, slack"), "summary lists enabled channels"); assert.ok(summary.includes("my-assistant"), "summary shows sandbox name"); assert.ok( @@ -70,6 +71,14 @@ describe("onboard summary helpers", () => { }); assert.ok(!orphanSummary.includes("undefined"), "null fields never render as 'undefined'"); assert.ok(orphanSummary.includes("(unset)"), "null fields fall back to '(unset)'"); + + const tavilySummary = formatOnboardConfigSummary({ + provider: "nvidia-prod", + model: "test-model", + webSearchConfig: { fetchEnabled: true, provider: "tavily" }, + sandboxName: "tavily-agent", + }); + assert.ok(tavilySummary.includes("enabled (Tavily Search)")); }); it("formatSandboxBuildEstimateNote warns when runtime is under-provisioned (#2514)", () => { diff --git a/src/lib/onboard/summary.ts b/src/lib/onboard/summary.ts index ef56fc8a6ce..716c5092506 100644 --- a/src/lib/onboard/summary.ts +++ b/src/lib/onboard/summary.ts @@ -6,7 +6,11 @@ import { HERMES_PROVIDER_NAME, type HermesAuthMethod, } from "../hermes-provider-auth"; -import type { WebSearchConfig } from "../inference/web-search"; +import { + type WebSearchConfig, + webSearchLabelFor, + webSearchProviderForConfig, +} from "../inference/web-search"; import { hermesToolGatewayLabels } from "./hermes-managed-tools"; const HERMES_AUTH_METHOD_OAUTH: HermesAuthMethod = "oauth"; @@ -87,7 +91,9 @@ export function formatOnboardConfigSummary({ ? enabledChannels.join(", ") : "none"; const webSearch = - webSearchConfig && webSearchConfig.fetchEnabled === true ? "enabled" : "disabled"; + webSearchConfig && webSearchConfig.fetchEnabled === true + ? `enabled (${webSearchLabelFor(webSearchProviderForConfig(webSearchConfig))})` + : "disabled"; const effectiveHermesAuthMethod = normalizeHermesAuthMethod(hermesAuthMethod) || (provider === HERMES_PROVIDER_NAME && credentialEnv === HERMES_NOUS_API_KEY_CREDENTIAL_ENV diff --git a/src/lib/onboard/web-search-flow.test.ts b/src/lib/onboard/web-search-flow.test.ts index ba2de3dcba0..6705212ae69 100644 --- a/src/lib/onboard/web-search-flow.test.ts +++ b/src/lib/onboard/web-search-flow.test.ts @@ -3,9 +3,11 @@ import fs from "node:fs"; import os from "node:os"; +import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { testTimeoutOptions } from "../../../test/helpers/timeouts"; import { runCurlProbe } from "../adapters/http/probe"; +import { BACK_TO_SELECTION } from "./credential-navigation"; import { createWebSearchFlowHelpers } from "./web-search-flow"; vi.mock("../adapters/http/probe", () => ({ @@ -23,43 +25,226 @@ vi.mock("../runner", () => ({ ROOT: "/tmp/nemoclaw-web-search-flow-test", })); -function braveProbeTempDirs(): string[] { - return fs - .readdirSync(os.tmpdir()) - .filter((entry) => entry.startsWith("nemoclaw-brave-probe-")) - .sort(); -} - -function helpers() { +function helpers(overrides: Record = {}) { return createWebSearchFlowHelpers({ prompt: async () => "", note: () => {}, isNonInteractive: () => true, cliName: () => "nemoclaw", runCaptureOpenshell: () => null, + ...overrides, }); } -describe("web search flow Brave validation", () => { +describe("web search provider validation", () => { beforeEach(() => { - vi.mocked(runCurlProbe).mockClear(); + vi.mocked(runCurlProbe).mockReset(); + vi.mocked(runCurlProbe).mockReturnValue({ + ok: true, + httpStatus: 200, + curlStatus: 0, + body: "{}", + stderr: "", + message: "ok", + }); }); it.each([ - ["LF", "brv-good-prefix\nconfig = injected"], - ["CR", "brv-good-prefix\rconfig = injected"], - ])( - "rejects %s-bearing keys before writing a trusted curl config", + ["brave", "LF", "brv-good-prefix\nconfig = injected"], + ["brave", "CR", "brv-good-prefix\rconfig = injected"], + ["tavily", "LF", "tvly-good-prefix\nconfig = injected"], + ["tavily", "CR", "tvly-good-prefix\rconfig = injected"], + ] as const)( + "rejects %s keys containing %s before writing a trusted curl config", testTimeoutOptions(15_000), - (_label, apiKey) => { - const before = braveProbeTempDirs(); + (provider, _label, apiKey) => { + const mkdtemp = vi.spyOn(fs, "mkdtempSync"); - const result = helpers().validateBraveSearchApiKey(apiKey); + try { + const result = helpers().validateWebSearchApiKey(provider, apiKey); - expect(result.ok).toBe(false); - expect(result.message).toContain("must not contain line breaks"); - expect(runCurlProbe).not.toHaveBeenCalled(); - expect(braveProbeTempDirs()).toEqual(before); + expect(result.ok).toBe(false); + expect(result.message).toContain("must not contain line breaks"); + expect(runCurlProbe).not.toHaveBeenCalled(); + expect(mkdtemp).not.toHaveBeenCalled(); + } finally { + mkdtemp.mockRestore(); + } }, ); + + it.each([ + ["brave", "brv-secret", "X-Subscription-Token: brv-secret"], + ["tavily", "tvly-secret", "Authorization: Bearer tvly-secret"], + ] as const)("keeps the %s key out of curl argv in a temporary 0600 config", (provider, apiKey, header) => { + let configPath = ""; + vi.mocked(runCurlProbe).mockImplementationOnce((args, options) => { + configPath = String(options?.trustedConfigFiles?.[0] ?? ""); + expect(configPath).not.toBe(""); + expect(args.join(" ")).not.toContain(apiKey); + expect(args).toContain(configPath); + expect(fs.statSync(configPath).mode & 0o777).toBe(0o600); + expect(fs.readFileSync(configPath, "utf8")).toContain(header); + return { + ok: true, + httpStatus: 200, + curlStatus: 0, + body: "{}", + stderr: "", + message: "ok", + }; + }); + + expect(helpers().validateWebSearchApiKey(provider, apiKey).ok).toBe(true); + expect(fs.existsSync(configPath)).toBe(false); + }); + + it("uses a POST JSON probe for Tavily", () => { + helpers().validateTavilySearchApiKey("tvly-secret"); + + expect(runCurlProbe).toHaveBeenCalledWith( + expect.arrayContaining([ + "--connect-timeout", + "10", + "--max-time", + "15", + "-X", + "POST", + "--data-raw", + JSON.stringify({ query: "ping", max_results: 1 }), + "https://api.tavily.com/search", + ]), + expect.objectContaining({ trustedConfigFiles: [expect.any(String)] }), + ); + }); +}); + +describe("web search provider selection", () => { + beforeEach(() => { + vi.mocked(runCurlProbe).mockReset(); + vi.mocked(runCurlProbe).mockReturnValue({ + ok: true, + httpStatus: 200, + curlStatus: 0, + body: "{}", + stderr: "", + message: "ok", + }); + }); + + it("honors an explicit provider before implicit credential detection", () => { + const env = { + NEMOCLAW_WEB_SEARCH_PROVIDER: "tavily", + BRAVE_API_KEY: "brv-key", + TAVILY_API_KEY: "tvly-key", + }; + + expect(helpers({ env }).resolveNonInteractiveWebSearchProvider()).toBe("tavily"); + }); + + it("preserves Brave-first precedence when both credentials are configured implicitly", () => { + const env = { BRAVE_API_KEY: "brv-key", TAVILY_API_KEY: "tvly-key" }; + + expect(helpers({ env }).resolveNonInteractiveWebSearchProvider()).toBe("brave"); + }); + + it("selects supported Tavily implicitly for Hermes when both credentials exist", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-web-search-")); + const dockerfile = path.join(root, "Dockerfile"); + fs.writeFileSync( + dockerfile, + "ARG NEMOCLAW_WEB_SEARCH_ENABLED=0\nARG NEMOCLAW_WEB_SEARCH_PROVIDER=tavily\n", + ); + const env = { BRAVE_API_KEY: "brv-unrelated", TAVILY_API_KEY: "tvly-key" }; + + try { + await expect( + helpers({ env }).configureWebSearch(null, { + name: "hermes", + displayName: "Hermes", + dockerfilePath: dockerfile, + } as never), + ).resolves.toEqual({ fetchEnabled: true, provider: "tavily" }); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + delete process.env.TAVILY_API_KEY; + } + }); + + it("skips an explicitly unsupported Brave selection for Hermes", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-brave-search-")); + const dockerfile = path.join(root, "Dockerfile"); + fs.writeFileSync( + dockerfile, + "ARG NEMOCLAW_WEB_SEARCH_ENABLED=0\nARG NEMOCLAW_WEB_SEARCH_PROVIDER=tavily\n", + ); + + try { + await expect( + helpers({ + env: { + NEMOCLAW_WEB_SEARCH_PROVIDER: "brave", + BRAVE_API_KEY: "brv-key", + }, + }).configureWebSearch(null, { + name: "hermes", + displayName: "Hermes", + dockerfilePath: dockerfile, + } as never), + ).resolves.toBeNull(); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it("uses saved credentials before host env values", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-web-search-config-")); + const dockerfile = path.join(root, "Dockerfile"); + fs.writeFileSync( + dockerfile, + "ARG NEMOCLAW_WEB_SEARCH_ENABLED=0\nARG NEMOCLAW_WEB_SEARCH_PROVIDER=brave\n", + ); + const saveCredential = vi.fn(); + const env = { + NEMOCLAW_WEB_SEARCH_PROVIDER: "tavily", + TAVILY_API_KEY: "tvly-host", + }; + const flow = helpers({ + env, + getCredential: (envKey: string) => (envKey === "TAVILY_API_KEY" ? "tvly-saved" : null), + saveCredential, + }); + + try { + await expect( + flow.configureWebSearch(null, { name: "openclaw", dockerfilePath: dockerfile } as never), + ).resolves.toEqual({ fetchEnabled: true, provider: "tavily" }); + expect(saveCredential).toHaveBeenCalledWith("TAVILY_API_KEY", "tvly-saved"); + expect(env.TAVILY_API_KEY).toBe("tvly-saved"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + delete process.env.TAVILY_API_KEY; + } + }); + + it("offers Brave and Tavily interactively and returns to the menu from a key prompt", async () => { + const replies = ["3", "back", "2"]; + const flow = helpers({ + isNonInteractive: () => false, + prompt: async () => replies.shift() ?? "", + }); + + await expect(flow.promptWebSearchProvider()).resolves.toBe("tavily"); + await expect(flow.promptWebSearchApiKey("tavily")).resolves.toBe(BACK_TO_SELECTION); + await expect(flow.promptWebSearchProvider()).resolves.toBe("brave"); + }); + + it("offers only Tavily when it is the agent's sole supported provider", async () => { + const flow = helpers({ + isNonInteractive: () => false, + prompt: async () => "2", + }); + + await expect(flow.promptWebSearchProvider(["tavily"])).resolves.toBe("tavily"); + }); }); diff --git a/src/lib/onboard/web-search-flow.ts b/src/lib/onboard/web-search-flow.ts index f88c5d028ae..5ba970f22f6 100644 --- a/src/lib/onboard/web-search-flow.ts +++ b/src/lib/onboard/web-search-flow.ts @@ -1,15 +1,24 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; +import { createCurlAuthConfig } from "../adapters/http/auth-config"; import type { CurlProbeResult } from "../adapters/http/probe"; import { runCurlProbe } from "../adapters/http/probe"; import type { AgentDefinition } from "../agent/defs"; import { getCredential, normalizeCredentialValue, saveCredential } from "../credentials/store"; -import type { WebSearchConfig } from "../inference/web-search"; -import { BRAVE_API_KEY_ENV } from "../inference/web-search"; +import { + BRAVE_API_KEY_ENV, + normalizeWebSearchConfig, + parseExplicitWebSearchProvider, + TAVILY_API_KEY_ENV, + WEB_SEARCH_PROVIDER_ENV, + WEB_SEARCH_PROVIDERS, + type WebSearchConfig, + type WebSearchProvider, + webSearchEnvFor, + webSearchLabelFor, + webSearchProviderForConfig, +} from "../inference/web-search"; import { ROOT } from "../runner"; import { classifyValidationFailure } from "../validation"; import { getTransportRecoveryMessage } from "../validation-recovery"; @@ -18,14 +27,40 @@ import { type BackToSelection, isBackToSelection, } from "./credential-navigation"; -import { exitOnboardFromPrompt, isAffirmativeAnswer } from "./prompt-helpers"; +import { exitOnboardFromPrompt } from "./prompt-helpers"; import type { ValidationFailureLike } from "./types"; -import { agentSupportsWebSearch } from "./web-search-support"; +import { agentSupportsWebSearch, agentSupportsWebSearchProvider } from "./web-search-support"; import { verifyWebSearchInsideSandbox as verifyWebSearchInsideSandboxWithDeps } from "./web-search-verify"; const BRAVE_SEARCH_HELP_URL = "https://brave.com/search/api/"; -const BRAVE_CURL_CONFIG_PREFIX = "nemoclaw-brave-probe"; -const BRAVE_API_KEY_LINE_BREAK_MESSAGE = "Brave Search API key must not contain line breaks."; +const TAVILY_SEARCH_HELP_URL = "https://app.tavily.com/home"; +const WEB_SEARCH_VALIDATION_TIMING_ARGS = ["--connect-timeout", "10", "--max-time", "15"] as const; +const CURL_CONFIG_PREFIX: Record = { + brave: "nemoclaw-brave-probe", + tavily: "nemoclaw-tavily-probe", +}; + +type WebSearchProviderSpec = { + provider: WebSearchProvider; + envKey: string; + label: string; + helpUrl: string; +}; + +const WEB_SEARCH_PROVIDER_SPECS: Record = { + brave: { + provider: "brave", + envKey: BRAVE_API_KEY_ENV, + label: webSearchLabelFor("brave"), + helpUrl: BRAVE_SEARCH_HELP_URL, + }, + tavily: { + provider: "tavily", + envKey: TAVILY_API_KEY_ENV, + label: webSearchLabelFor("tavily"), + helpUrl: TAVILY_SEARCH_HELP_URL, + }, +}; export interface WebSearchFlowDeps { prompt(question: string, options?: { secret?: boolean }): Promise; @@ -33,15 +68,33 @@ export interface WebSearchFlowDeps { isNonInteractive(): boolean; cliName(): string; runCaptureOpenshell(args: string[], opts?: Record): string | null; + env?: NodeJS.ProcessEnv; + getCredential?: (envKey: string) => string | null; + saveCredential?: (envKey: string, value: string) => void; } export interface WebSearchFlowHelpers { + validateWebSearchApiKey(provider: WebSearchProvider, apiKey: string): CurlProbeResult; validateBraveSearchApiKey(apiKey: string): CurlProbeResult; + validateTavilySearchApiKey(apiKey: string): CurlProbeResult; + promptWebSearchRecovery( + provider: WebSearchProvider, + validation: ValidationFailureLike, + ): Promise<"retry" | "skip">; promptBraveSearchRecovery(validation: ValidationFailureLike): Promise<"retry" | "skip">; + promptWebSearchApiKey(provider: WebSearchProvider): Promise; promptBraveSearchApiKey(): Promise; - ensureValidatedBraveSearchCredential( + promptWebSearchProvider( + providers?: readonly WebSearchProvider[], + ): Promise; + resolveNonInteractiveWebSearchProvider(): WebSearchProvider | null; + ensureValidatedWebSearchCredential( + providerOrConfig: WebSearchProvider | WebSearchConfig, nonInteractive?: boolean, ): Promise; + ensureValidatedBraveSearchCredential( + nonInteractiveOrConfig?: boolean | WebSearchConfig, + ): Promise; configureWebSearch( existingConfig?: WebSearchConfig | null, agent?: AgentDefinition | null, @@ -54,41 +107,43 @@ export interface WebSearchFlowHelpers { } export function createWebSearchFlowHelpers(deps: WebSearchFlowDeps): WebSearchFlowHelpers { - function escapeCurlConfigValue(value: string): string { - return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); + const env = deps.env ?? process.env; + const readCredential = deps.getCredential ?? getCredential; + const persistCredential = deps.saveCredential ?? saveCredential; + + function providerSpec(provider: WebSearchProvider): WebSearchProviderSpec { + return WEB_SEARCH_PROVIDER_SPECS[provider]; } - function braveCurlConfig(apiKey: string): string { - const tokenHeader = escapeCurlConfigValue(`X-Subscription-Token: ${apiKey}`); + function curlConfigHeaders(provider: WebSearchProvider, apiKey: string): string[] { + const authHeader = + provider === "tavily" ? `Authorization: Bearer ${apiKey}` : `X-Subscription-Token: ${apiKey}`; return [ - 'header = "Accept: application/json"', - 'header = "Accept-Encoding: gzip"', - `header = "${tokenHeader}"`, - "", - ].join("\n"); + "Accept: application/json", + ...(provider === "brave" ? ["Accept-Encoding: gzip"] : ["Content-Type: application/json"]), + authHeader, + ]; } - function writeBraveCurlConfig(apiKey: string): { configPath: string; cleanup: () => void } { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), `${BRAVE_CURL_CONFIG_PREFIX}-`)); - const configPath = path.join(dir, "curl.conf"); - try { - fs.writeFileSync(configPath, braveCurlConfig(apiKey), { mode: 0o600 }); - } catch (error) { - fs.rmSync(dir, { recursive: true, force: true }); - throw error; + function validationArgs(provider: WebSearchProvider, authArgs: readonly string[]): string[] { + if (provider === "tavily") { + return [ + "-sS", + ...WEB_SEARCH_VALIDATION_TIMING_ARGS, + "--compressed", + ...authArgs, + "-X", + "POST", + "--data-raw", + JSON.stringify({ query: "ping", max_results: 1 }), + "https://api.tavily.com/search", + ]; } - return { - configPath, - cleanup: () => fs.rmSync(dir, { recursive: true, force: true }), - }; - } - - function braveSearchArgs(configPath: string): string[] { return [ "-sS", + ...WEB_SEARCH_VALIDATION_TIMING_ARGS, "--compressed", - "--config", - configPath, + ...authArgs, "--get", "--data-urlencode", "q=ping", @@ -98,133 +153,288 @@ export function createWebSearchFlowHelpers(deps: WebSearchFlowDeps): WebSearchFl ]; } - function invalidBraveSearchApiKey(message: string): CurlProbeResult { + function invalidApiKey(provider: WebSearchProvider, message: string): CurlProbeResult { return { ok: false, httpStatus: 0, curlStatus: 0, body: "", stderr: "", - message, + message: `${providerSpec(provider).label} API key ${message}`, }; } - function validateBraveSearchApiKey(apiKey: string): CurlProbeResult { + function validateWebSearchApiKey(provider: WebSearchProvider, apiKey: string): CurlProbeResult { if (/[\r\n]/.test(apiKey)) { - return invalidBraveSearchApiKey(BRAVE_API_KEY_LINE_BREAK_MESSAGE); + return invalidApiKey(provider, "must not contain line breaks."); + } + if (apiKey.includes("\0")) { + return invalidApiKey(provider, "must not contain NUL bytes."); } - const { configPath, cleanup } = writeBraveCurlConfig(apiKey); + const authConfig = createCurlAuthConfig( + curlConfigHeaders(provider, apiKey).map((value) => ({ kind: "header", value })), + { prefix: CURL_CONFIG_PREFIX[provider] }, + ); try { - return runCurlProbe(braveSearchArgs(configPath), { trustedConfigFiles: [configPath] }); + return runCurlProbe(validationArgs(provider, authConfig.args), { + trustedConfigFiles: authConfig.trustedConfigFiles, + }); } finally { - cleanup(); + authConfig.cleanup(); } } - async function promptBraveSearchRecovery( + function validateBraveSearchApiKey(apiKey: string): CurlProbeResult { + return validateWebSearchApiKey("brave", apiKey); + } + + function validateTavilySearchApiKey(apiKey: string): CurlProbeResult { + return validateWebSearchApiKey("tavily", apiKey); + } + + async function promptWebSearchRecovery( + provider: WebSearchProvider, validation: ValidationFailureLike, ): Promise<"retry" | "skip"> { + const spec = providerSpec(provider); const recovery = classifyValidationFailure(validation); if (recovery.kind === "credential") { - console.log(" Brave Search rejected that API key."); + console.log(` ${spec.label} rejected that API key.`); } else if (recovery.kind === "transport") { console.log(getTransportRecoveryMessage(validation)); } else { - console.log(" Brave Search validation did not succeed."); + console.log(` ${spec.label} validation did not succeed.`); } const answer = (await deps.prompt(" Type 'retry', 'skip', or 'exit' [retry]: ")) .trim() .toLowerCase(); if (answer === "skip") return "skip"; - if (answer === "exit" || answer === "quit") { - exitOnboardFromPrompt(); - } + if (answer === "exit" || answer === "quit") exitOnboardFromPrompt(); return "retry"; } - async function promptBraveSearchApiKey(): Promise { + function promptBraveSearchRecovery(validation: ValidationFailureLike): Promise<"retry" | "skip"> { + return promptWebSearchRecovery("brave", validation); + } + + async function promptWebSearchApiKey( + provider: WebSearchProvider, + ): Promise { + const spec = providerSpec(provider); console.log(""); - console.log(` Get your Brave Search API key from: ${BRAVE_SEARCH_HELP_URL}`); + console.log(` Get your ${spec.label} API key from: ${spec.helpUrl}`); console.log(""); while (true) { - const value = await deps.prompt(" Brave Search API key: ", { secret: true }); + const value = await deps.prompt(` ${spec.label} API key: `, { secret: true }); const intent = normalizeCredentialValue(value).toLowerCase(); if (intent === "back") return BACK_TO_SELECTION; - if (intent === "exit" || intent === "quit") { - exitOnboardFromPrompt(); - } + if (intent === "exit" || intent === "quit") exitOnboardFromPrompt(); if (intent === "?" || intent === "help") { console.log(" Type back to choose again, or exit to quit."); continue; } const key = normalizeCredentialValue(value); if (!key) { - console.error(" Brave Search API key is required."); + console.error(` ${spec.label} API key is required.`); continue; } return key; } } - async function ensureValidatedBraveSearchCredential( + function promptBraveSearchApiKey(): Promise { + return promptWebSearchApiKey("brave"); + } + + function configuredCredential(provider: WebSearchProvider): string { + const envKey = webSearchEnvFor(provider); + return readCredential(envKey) || normalizeCredentialValue(env[envKey]); + } + + function stageValidatedCredential(provider: WebSearchProvider, apiKey: string): void { + const envKey = webSearchEnvFor(provider); + persistCredential(envKey, apiKey); + env[envKey] = apiKey; + process.env[envKey] = apiKey; + } + + async function ensureValidatedWebSearchCredential( + providerOrConfig: WebSearchProvider | WebSearchConfig, nonInteractive = deps.isNonInteractive(), ): Promise { - const savedApiKey = getCredential(BRAVE_API_KEY_ENV); - let apiKey: string | null = - savedApiKey || normalizeCredentialValue(process.env[BRAVE_API_KEY_ENV]); + const provider = + typeof providerOrConfig === "string" + ? providerOrConfig + : webSearchProviderForConfig(providerOrConfig); + const spec = providerSpec(provider); + const savedApiKey = readCredential(spec.envKey); + let apiKey = savedApiKey || normalizeCredentialValue(env[spec.envKey]); let usingSavedKey = Boolean(savedApiKey); while (true) { if (!apiKey) { if (nonInteractive) { throw new Error( - "Brave Search requires BRAVE_API_KEY or a saved Brave Search credential in non-interactive mode.", + `${spec.label} requires ${spec.envKey} or a saved ${spec.label} credential in non-interactive mode.`, ); } - const promptedApiKey = await promptBraveSearchApiKey(); - if (isBackToSelection(promptedApiKey)) { - return promptedApiKey; - } + const promptedApiKey = await promptWebSearchApiKey(provider); + if (isBackToSelection(promptedApiKey)) return promptedApiKey; apiKey = promptedApiKey; usingSavedKey = false; } - const validation = validateBraveSearchApiKey(apiKey); + const validation = validateWebSearchApiKey(provider, apiKey); if (validation.ok) { - saveCredential(BRAVE_API_KEY_ENV, apiKey); - process.env[BRAVE_API_KEY_ENV] = apiKey; + stageValidatedCredential(provider, apiKey); return apiKey; } const prefix = usingSavedKey - ? " Saved Brave Search API key validation failed." - : " Brave Search API key validation failed."; + ? ` Saved ${spec.label} API key validation failed.` + : ` ${spec.label} API key validation failed.`; console.error(prefix); - if (validation.message) { - console.error(` ${validation.message}`); - } + if (validation.message) console.error(` ${validation.message}`); if (nonInteractive) { throw new Error( - validation.message || "Brave Search API key validation failed in non-interactive mode.", + validation.message || `${spec.label} API key validation failed in non-interactive mode.`, ); } - const action = await promptBraveSearchRecovery(validation); + const action = await promptWebSearchRecovery(provider, validation); if (action === "skip") { - console.log(" Skipping Brave Web Search setup."); + console.log(` Skipping ${spec.label} setup.`); console.log(""); return null; } - - apiKey = null; + apiKey = ""; usingSavedKey = false; } } + function ensureValidatedBraveSearchCredential( + nonInteractiveOrConfig: boolean | WebSearchConfig = deps.isNonInteractive(), + ): Promise { + if (typeof nonInteractiveOrConfig === "boolean") { + return ensureValidatedWebSearchCredential("brave", nonInteractiveOrConfig); + } + return ensureValidatedWebSearchCredential(nonInteractiveOrConfig); + } + + function resolveNonInteractiveWebSearchProvider(): WebSearchProvider | null { + const explicit = parseExplicitWebSearchProvider(env[WEB_SEARCH_PROVIDER_ENV]); + if (explicit.specified) return explicit.provider; + + // Preserve the historical implicit behavior: Brave wins when both keys + // exist. Tavily is auto-selected only when it is the sole configured key. + if (configuredCredential("brave")) return "brave"; + if (configuredCredential("tavily")) return "tavily"; + return null; + } + + async function promptWebSearchProvider( + providers: readonly WebSearchProvider[] = WEB_SEARCH_PROVIDERS, + ): Promise { + console.log(""); + console.log(" Enable web search for your agent?"); + console.log(" [1] No web search (default)"); + providers.forEach((provider, index) => { + console.log(` [${index + 2}] ${providerSpec(provider).label}`); + }); + while (true) { + const maxChoice = providers.length + 1; + const raw = (await deps.prompt(` Choose [1-${maxChoice}]: `)).trim().toLowerCase(); + if (raw === "" || raw === "1" || raw === "n" || raw === "no") return null; + const namedProvider = providers.find((provider) => raw === provider); + if (namedProvider) return namedProvider; + const selectedIndex = /^\d+$/.test(raw) ? Number(raw) - 2 : -1; + if (selectedIndex >= 0 && selectedIndex < providers.length) { + return providers[selectedIndex]; + } + // Preserve the former yes/no behavior by selecting the first supported + // provider. OpenClaw keeps Brave first; Hermes exposes only Tavily. + if ((raw === "y" || raw === "yes") && providers.length > 0) return providers[0]; + if (raw === "exit" || raw === "quit") exitOnboardFromPrompt(); + console.log(` Enter a number from 1 to ${maxChoice}.`); + } + } + + function providerIsSupported( + provider: WebSearchProvider, + agent: AgentDefinition | null, + dockerfilePathOverride: string | null, + ): boolean { + return agentSupportsWebSearchProvider(agent, provider, dockerfilePathOverride, ROOT); + } + + function providerSupported( + provider: WebSearchProvider, + agent: AgentDefinition | null, + dockerfilePathOverride: string | null, + ): boolean { + if (providerIsSupported(provider, agent, dockerfilePathOverride)) return true; + deps.note( + ` ${providerSpec(provider).label} is not supported by ${agent?.displayName ?? "this sandbox image"}. Skipping.`, + ); + return false; + } + + async function configureNonInteractiveWebSearch( + existingConfig: WebSearchConfig | null, + agent: AgentDefinition | null, + dockerfilePathOverride: string | null, + ): Promise { + const explicit = parseExplicitWebSearchProvider(env[WEB_SEARCH_PROVIDER_ENV]); + if (explicit.specified && !explicit.provider) return null; + + let provider = + explicit.provider ?? + (existingConfig ? webSearchProviderForConfig(existingConfig) : null) ?? + resolveNonInteractiveWebSearchProvider(); + + // Implicit detection keeps Brave-first precedence among providers the + // selected agent actually supports. Thus OpenClaw remains backward + // compatible, while Hermes can use a configured Tavily key even when an + // unrelated Brave key is also present in the host credential store. + if (!explicit.specified && !existingConfig) { + provider = + (["brave", "tavily"] as const).find( + (candidate) => + Boolean(configuredCredential(candidate)) && + providerIsSupported(candidate, agent, dockerfilePathOverride), + ) ?? provider; + } + if (!provider) return null; + if (!providerSupported(provider, agent, dockerfilePathOverride)) return null; + + const spec = providerSpec(provider); + const apiKey = configuredCredential(provider); + if (!apiKey) { + if (explicit.specified || existingConfig) { + throw new Error( + `${spec.label} requires ${spec.envKey} or a saved ${spec.label} credential in non-interactive mode.`, + ); + } + return null; + } + + deps.note(` [non-interactive] ${spec.label} requested.`); + const validation = validateWebSearchApiKey(provider, apiKey); + if (!validation.ok) { + console.warn( + ` ${spec.label} API key validation failed. Web search will be disabled — re-enable it by rerunning ${deps.cliName()} onboard.`, + ); + if (validation.message) console.warn(` ${validation.message}`); + return null; + } + stageValidatedCredential(provider, apiKey); + return { fetchEnabled: true, provider }; + } + async function configureWebSearch( existingConfig: WebSearchConfig | null = null, agent: AgentDefinition | null = null, @@ -237,48 +447,29 @@ export function createWebSearchFlowHelpers(deps: WebSearchFlowDeps): WebSearchFl return null; } - if (existingConfig) { - return { fetchEnabled: true }; - } + existingConfig = normalizeWebSearchConfig(existingConfig); if (deps.isNonInteractive()) { - const braveApiKey = - getCredential(BRAVE_API_KEY_ENV) || - normalizeCredentialValue(process.env[BRAVE_API_KEY_ENV]); - if (!braveApiKey) { - return null; - } - deps.note(" [non-interactive] Brave Web Search requested."); - const validation = validateBraveSearchApiKey(braveApiKey); - if (!validation.ok) { - console.warn( - ` Brave Search API key validation failed. Web search will be disabled — re-enable later via \`${deps.cliName()} config web-search\`.`, - ); - if (validation.message) { - console.warn(` ${validation.message}`); - } - return null; - } - saveCredential(BRAVE_API_KEY_ENV, braveApiKey); - process.env[BRAVE_API_KEY_ENV] = braveApiKey; - return { fetchEnabled: true }; - } - const enableAnswer = await deps.prompt(" Enable Brave Web Search? [y/N]: "); - if (!isAffirmativeAnswer(enableAnswer)) { - return null; + return configureNonInteractiveWebSearch(existingConfig, agent, dockerfilePathOverride); } - const braveApiKey = await ensureValidatedBraveSearchCredential(); - if (isBackToSelection(braveApiKey)) { - return configureWebSearch(existingConfig, agent, dockerfilePathOverride); - } - if (!braveApiKey) { - return null; - } + if (existingConfig) return normalizeWebSearchConfig(existingConfig); - console.log(" ✓ Enabled Brave Web Search"); - console.log(""); - return { fetchEnabled: true }; + const supportedProviders = WEB_SEARCH_PROVIDERS.filter((provider) => + providerIsSupported(provider, agent, dockerfilePathOverride), + ); + while (true) { + const provider = await promptWebSearchProvider(supportedProviders); + if (!provider) return null; + + const apiKey = await ensureValidatedWebSearchCredential(provider); + if (isBackToSelection(apiKey)) continue; + if (!apiKey) return null; + + console.log(` ✓ Enabled ${providerSpec(provider).label}`); + console.log(""); + return { fetchEnabled: true, provider }; + } } function verifyWebSearchInsideSandbox( @@ -292,9 +483,16 @@ export function createWebSearchFlowHelpers(deps: WebSearchFlowDeps): WebSearchFl } return { + validateWebSearchApiKey, validateBraveSearchApiKey, + validateTavilySearchApiKey, + promptWebSearchRecovery, promptBraveSearchRecovery, + promptWebSearchApiKey, promptBraveSearchApiKey, + promptWebSearchProvider, + resolveNonInteractiveWebSearchProvider, + ensureValidatedWebSearchCredential, ensureValidatedBraveSearchCredential, configureWebSearch, verifyWebSearchInsideSandbox, diff --git a/src/lib/onboard/web-search-support.test.ts b/src/lib/onboard/web-search-support.test.ts index fe399dc0f63..1d22bd6eeb3 100644 --- a/src/lib/onboard/web-search-support.test.ts +++ b/src/lib/onboard/web-search-support.test.ts @@ -7,7 +7,7 @@ import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { agentSupportsWebSearch } from "./web-search-support"; +import { agentSupportsWebSearch, agentSupportsWebSearchProvider } from "./web-search-support"; const tmpRoots: string[] = []; @@ -33,16 +33,63 @@ describe("agentSupportsWebSearch", () => { it("detects default, OpenClaw, and Hermes agent support", () => { expect(agentSupportsWebSearch(null)).toBe(true); expect(agentSupportsWebSearch({ name: "openclaw" })).toBe(true); - expect(agentSupportsWebSearch({ name: "hermes" })).toBe(false); + expect(agentSupportsWebSearch({ name: "hermes" })).toBe(true); }); - it("returns false for Hermes regardless of Dockerfile support", () => { + it("accepts Hermes when its Dockerfile declares web-search support", () => { const root = tmpRoot(); const dockerfile = writeDockerfile(root, "ARG NEMOCLAW_WEB_SEARCH_ENABLED=1\n"); expect(agentSupportsWebSearch({ name: "hermes", dockerfilePath: dockerfile }, null, root)).toBe( - false, + true, ); + expect( + agentSupportsWebSearchProvider( + { name: "hermes", dockerfilePath: dockerfile }, + "brave", + null, + root, + ), + ).toBe(false); + }); + + it("requires a provider selector arg for Tavily while preserving legacy Brave support", () => { + const root = tmpRoot(); + const legacyDockerfile = writeDockerfile( + root, + "ARG NEMOCLAW_WEB_SEARCH_ENABLED=1\n", + "Legacyfile", + ); + const providerAwareDockerfile = writeDockerfile( + root, + "ARG NEMOCLAW_WEB_SEARCH_ENABLED=1\nARG NEMOCLAW_WEB_SEARCH_PROVIDER=brave\n", + "Providerfile", + ); + + expect( + agentSupportsWebSearchProvider( + { name: "openclaw", dockerfilePath: legacyDockerfile }, + "brave", + null, + root, + ), + ).toBe(true); + expect( + agentSupportsWebSearchProvider( + { name: "openclaw", dockerfilePath: legacyDockerfile }, + "tavily", + null, + root, + ), + ).toBe(false); + expect( + agentSupportsWebSearchProvider( + { name: "openclaw", dockerfilePath: providerAwareDockerfile }, + "tavily", + null, + root, + ), + ).toBe(true); }); it("uses an override Dockerfile path first", () => { diff --git a/src/lib/onboard/web-search-support.ts b/src/lib/onboard/web-search-support.ts index 0846d3119e1..cf44b0bf5c7 100644 --- a/src/lib/onboard/web-search-support.ts +++ b/src/lib/onboard/web-search-support.ts @@ -4,6 +4,7 @@ import fs from "node:fs"; import path from "node:path"; +import type { WebSearchProvider } from "../inference/web-search"; import { ROOT } from "../state/paths"; export type WebSearchAgent = @@ -19,7 +20,7 @@ export type WebSearchAgent = * Check whether the agent's Dockerfile declares ARG NEMOCLAW_WEB_SEARCH_ENABLED. * If the ARG is absent, the patchStagedDockerfile replace is a silent no-op and * the config generator has no code path to emit a web search block — so offering - * the Brave prompt would mislead the user. + * the web-search prompt would mislead the user. * * OpenClaw uses the root Dockerfile (not agents/openclaw/Dockerfile), so we * fall back to the root Dockerfile when the agent-specific one doesn't exist. @@ -29,12 +30,40 @@ export function agentSupportsWebSearch( dockerfilePathOverride: string | null = null, rootDir = ROOT, ): boolean { - // Hermes has native web tools, but the NemoClaw onboarding wizard wires the - // OpenClaw Brave provider path. Do not offer a Brave prompt for Hermes until - // that provider is supported end to end. - if (agent?.name === "hermes") { - return false; + const candidates = [ + dockerfilePathOverride, + agent?.dockerfilePath, + path.join(rootDir, "Dockerfile"), + ].filter( + (candidate): candidate is string => typeof candidate === "string" && candidate.length > 0, + ); + + for (const dockerfilePath of candidates) { + try { + const content = fs.readFileSync(dockerfilePath, "utf-8"); + return /^\s*ARG\s+NEMOCLAW_WEB_SEARCH_ENABLED=/m.test(content); + } catch { + // Try the next candidate; custom Dockerfile paths can disappear between resume runs. + } } + return false; +} + +/** + * Tavily needs the provider selector build arg in addition to the legacy + * enable flag. Brave remains compatible with older custom Dockerfiles that + * only declare NEMOCLAW_WEB_SEARCH_ENABLED because Brave is the historical + * default when no provider arg exists. + */ +export function agentSupportsWebSearchProvider( + agent: WebSearchAgent, + provider: WebSearchProvider, + dockerfilePathOverride: string | null = null, + rootDir = ROOT, +): boolean { + // Hermes currently exposes only its native Tavily backend. Brave remains + // OpenClaw-only until Hermes ships a compatible Brave backend. + if (agent?.name?.trim().toLowerCase() === "hermes" && provider !== "tavily") return false; const candidates = [ dockerfilePathOverride, @@ -47,7 +76,9 @@ export function agentSupportsWebSearch( for (const dockerfilePath of candidates) { try { const content = fs.readFileSync(dockerfilePath, "utf-8"); - return /^\s*ARG\s+NEMOCLAW_WEB_SEARCH_ENABLED=/m.test(content); + const enabled = /^\s*ARG\s+NEMOCLAW_WEB_SEARCH_ENABLED=/m.test(content); + if (!enabled) return false; + return provider === "brave" || /^\s*ARG\s+NEMOCLAW_WEB_SEARCH_PROVIDER=/m.test(content); } catch { // Try the next candidate; custom Dockerfile paths can disappear between resume runs. } diff --git a/src/lib/onboard/web-search-verify.test.ts b/src/lib/onboard/web-search-verify.test.ts index 7f575757475..0d4cb410bd4 100644 --- a/src/lib/onboard/web-search-verify.test.ts +++ b/src/lib/onboard/web-search-verify.test.ts @@ -18,24 +18,64 @@ function deps(output: string | null | Array) { } describe("verifyWebSearchInsideSandbox", () => { - it("reports active Hermes web backend", () => { - const d = deps("web.backend: brave\n"); + it("verifies Hermes Tavily egress through JSON body credential rewriting", () => { + const d = deps([ + "web:\n backend: tavily\n", + JSON.stringify({ results: [{ title: "NVIDIA" }] }) + "\nHTTP_STATUS:200\n", + ]); verifyWebSearchInsideSandbox("alpha", { name: "hermes" }, d); - expect(d.log).toHaveBeenCalledWith(" ✓ Web search is active inside sandbox"); + expect(d.runCaptureOpenshell).toHaveBeenCalledTimes(2); + expect(d.runCaptureOpenshell.mock.calls[0][0]).toEqual([ + "sandbox", + "exec", + "-n", + "alpha", + "--", + "cat", + "/sandbox/.hermes/config.yaml", + ]); + expect(d.runCaptureOpenshell.mock.calls[1][0]).toEqual([ + "sandbox", + "exec", + "-n", + "alpha", + "--", + "sh", + "-lc", + expect.stringContaining('"api_key":"openshell:resolve:env:TAVILY_API_KEY"'), + ]); + expect(d.log).toHaveBeenCalledWith(" ✓ Tavily Search egress verified inside sandbox"); expect(d.warn).not.toHaveBeenCalled(); }); - it("warns when Hermes does not report active web backend", () => { - const d = deps("active toolsets: shell\n"); + it("does not treat pinned Hermes dump-shaped output as an active Tavily backend", () => { + const d = deps("active toolsets: web, shell\n"); verifyWebSearchInsideSandbox("alpha", { name: "hermes" }, d); expect(d.warn).toHaveBeenCalledWith( - " ⚠ Web search was configured but Hermes does not report an active web backend.", + " ⚠ Tavily Search was configured but Hermes config does not select web.backend=tavily.", + ); + expect(d.warn).toHaveBeenCalledWith( + " Check: nemoclaw alpha exec -- cat /sandbox/.hermes/config.yaml", + ); + expect(d.runCaptureOpenshell).toHaveBeenCalledTimes(1); + }); + + it("warns when the Hermes config is missing or malformed", () => { + const missing = deps(null); + verifyWebSearchInsideSandbox("alpha", { name: "hermes" }, missing); + expect(missing.warn).toHaveBeenCalledWith( + " ⚠ Could not read Hermes config to verify Tavily Search.", + ); + + const malformed = deps("web: [\n"); + verifyWebSearchInsideSandbox("alpha", { name: "hermes" }, malformed); + expect(malformed.warn).toHaveBeenCalledWith( + " ⚠ Could not parse Hermes config to verify Tavily Search.", ); - expect(d.warn).toHaveBeenCalledWith(" Check: nemoclaw alpha exec hermes dump"); }); it("verifies OpenClaw Brave Search egress through the subscription-token header", () => { @@ -72,6 +112,63 @@ describe("verifyWebSearchInsideSandbox", () => { expect(d.log).toHaveBeenCalledWith(" ✓ Brave Search egress verified inside sandbox"); }); + it("verifies OpenClaw Tavily Search egress through the bearer header", () => { + const d = deps([ + JSON.stringify({ + tools: { web: { search: { enabled: true, provider: "tavily" } } }, + plugins: { + entries: { + tavily: { + enabled: true, + config: { webSearch: { apiKey: "openshell:resolve:env:TAVILY_API_KEY" } }, + }, + }, + }, + }), + JSON.stringify({ results: [{ title: "NVIDIA" }] }) + "\nHTTP_STATUS:200\n", + ]); + + verifyWebSearchInsideSandbox("alpha", { name: "openclaw" }, d); + + expect(d.runCaptureOpenshell).toHaveBeenCalledTimes(2); + expect(d.runCaptureOpenshell.mock.calls[1][0]).toEqual([ + "sandbox", + "exec", + "-n", + "alpha", + "--", + "sh", + "-lc", + expect.stringContaining("Authorization: Bearer openshell:resolve:env:TAVILY_API_KEY"), + ]); + expect(d.runCaptureOpenshell.mock.calls[1][0][7]).toContain("https://api.tavily.com/search"); + expect(d.log).toHaveBeenCalledWith(" ✓ Tavily Search egress verified inside sandbox"); + }); + + it("does not accept an empty Tavily results array as successful verification", () => { + const d = deps([ + JSON.stringify({ + tools: { web: { search: { enabled: true, provider: "tavily" } } }, + plugins: { + entries: { + tavily: { + enabled: true, + config: { webSearch: { apiKey: "openshell:resolve:env:TAVILY_API_KEY" } }, + }, + }, + }, + }), + JSON.stringify({ results: [] }) + "\nHTTP_STATUS:200\n", + ]); + + verifyWebSearchInsideSandbox("alpha", { name: "openclaw" }, d); + + expect(d.warn).toHaveBeenCalledWith( + " ⚠ Tavily Search config exists, but egress verification returned HTTP 200.", + ); + expect(d.log).not.toHaveBeenCalled(); + }); + it("still probes legacy configs that carry the apiKey inline on tools.web.search", () => { const d = deps([ JSON.stringify({ diff --git a/src/lib/onboard/web-search-verify.ts b/src/lib/onboard/web-search-verify.ts index 3b3c7f72282..fd46d1ca753 100644 --- a/src/lib/onboard/web-search-verify.ts +++ b/src/lib/onboard/web-search-verify.ts @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import YAML from "yaml"; import { shellQuote } from "../core/shell-quote"; export type WebSearchVerifyAgent = @@ -51,13 +52,67 @@ function hasBraveResult(body: string): boolean { } } +function buildTavilyEgressProbeCommand(apiKey: string): string { + return [ + "curl", + "-sS", + "--compressed", + "--max-time", + "20", + "-X", + "POST", + "https://api.tavily.com/search", + "-H", + `Authorization: Bearer ${apiKey}`, + "-H", + "Content-Type: application/json", + "--data", + JSON.stringify({ query: "NVIDIA", max_results: 1 }), + "-w", + "\nHTTP_STATUS:%{http_code}\n", + ] + .map(shellQuote) + .join(" "); +} + +function buildTavilyBodyEgressProbeCommand(apiKey: string): string { + return [ + "curl", + "-sS", + "--compressed", + "--max-time", + "20", + "-X", + "POST", + "https://api.tavily.com/search", + "-H", + "Content-Type: application/json", + "--data", + JSON.stringify({ api_key: apiKey, query: "NVIDIA", max_results: 1 }), + "-w", + "\nHTTP_STATUS:%{http_code}\n", + ] + .map(shellQuote) + .join(" "); +} + +function hasTavilyResult(body: string): boolean { + try { + const parsed = JSON.parse(body); + return Array.isArray(parsed?.results) && parsed.results.length > 0; + } catch { + return false; + } +} + /** * Post-creation probe: verify web search is actually functional inside the - * sandbox. Hermes silently ignores unknown web.backend values, so checking - * the config file alone is insufficient — we need to ask the runtime. + * sandbox. Hermes silently ignores unknown web.backend values, so config + * inspection is paired with a real egress request. * - * For Hermes: runs `hermes dump` and checks for an active web backend. - * For OpenClaw: checks that the tools.web.search block is present in the config. + * For Hermes: checks the configured Tavily backend, then proves body credential + * rewriting and egress with a real search request. + * For OpenClaw: checks the tools.web.search block, then proves provider egress. * * This is a best-effort warning — it does not abort onboarding. */ @@ -71,35 +126,67 @@ export function verifyWebSearchInsideSandbox( const agentName = agent?.name || "openclaw"; try { if (agentName === "hermes") { - // `hermes dump` outputs config_overrides and active toolsets. - // Look for the web backend in its output. - const dump = deps.runCaptureOpenshell( - ["sandbox", "exec", "-n", sandboxName, "--", "hermes", "dump"], + // Hermes v2026.6.19 `dump` does not expose web.backend. Inspect the + // generated config directly, then prove that the configured body + // placeholder is rewritten on a real request. + const configText = deps.runCaptureOpenshell( + ["sandbox", "exec", "-n", sandboxName, "--", "cat", "/sandbox/.hermes/config.yaml"], { ignoreError: true, timeout: 10_000, }, ); - if (!dump) { - warn(" ⚠ Could not verify web search config inside sandbox (hermes dump failed)."); + if (!configText) { + warn(" ⚠ Could not read Hermes config to verify Tavily Search."); return; } - // A working web backend shows as an explicit config override or active-toolset entry. - // Avoid broad /web.*search/ matching so warning text never looks like success. - const hasWebBackend = - /^\s*web\.backend:\s*\S+/m.test(dump) || - /^\s*active toolsets:\s*.*\bweb\b/im.test(dump) || - /^\s*toolsets:\s*.*\bweb\b/im.test(dump); - if (!hasWebBackend) { - warn(" ⚠ Web search was configured but Hermes does not report an active web backend."); + let config: { web?: { backend?: unknown } }; + try { + config = YAML.parse(configText) as { web?: { backend?: unknown } }; + } catch { + warn(" ⚠ Could not parse Hermes config to verify Tavily Search."); + return; + } + if (config?.web?.backend !== "tavily") { + warn( + " ⚠ Tavily Search was configured but Hermes config does not select web.backend=tavily.", + ); warn(" The agent may not have accepted the web search configuration."); - warn(` Check: ${deps.cliName()} ${sandboxName} exec hermes dump`); + warn( + ` Check: ${deps.cliName()} ${sandboxName} exec -- cat /sandbox/.hermes/config.yaml`, + ); + return; + } + + const placeholder = "openshell:resolve:env:TAVILY_API_KEY"; + const probe = deps.runCaptureOpenshell( + [ + "sandbox", + "exec", + "-n", + sandboxName, + "--", + "sh", + "-lc", + buildTavilyBodyEgressProbeCommand(placeholder), + ], + { ignoreError: true, timeout: 30_000 }, + ); + if (!probe) { + warn(" ⚠ Tavily Search config exists, but the egress verification request failed."); + return; + } + const statusMatch = probe.match(/(?:^|\n)HTTP_STATUS:(\d{3})(?:\n|$)/); + const status = statusMatch?.[1] || "unknown"; + const body = probe.replace(/(?:^|\n)HTTP_STATUS:\d{3}\s*$/m, "").trim(); + if (status === "200" && hasTavilyResult(body)) { + log(" ✓ Tavily Search egress verified inside sandbox"); } else { - log(" ✓ Web search is active inside sandbox"); + warn(` ⚠ Tavily Search config exists, but egress verification returned HTTP ${status}.`); } } else if (agentName === "openclaw") { - // OpenClaw: verify tools.web.search block exists, then prove the - // placeholder works at egress through Brave's X-Subscription-Token header. + // OpenClaw: verify tools.web.search exists, then prove the selected + // provider placeholder works at egress through its credential header. const configCheck = deps.runCaptureOpenshell( ["sandbox", "exec", "-n", sandboxName, "--", "cat", "/sandbox/.openclaw/openclaw.json"], { ignoreError: true, timeout: 10_000 }, @@ -117,10 +204,12 @@ export function verifyWebSearchInsideSandbox( ); return; } - if (search.provider !== "brave") { - log(" ✓ Web search is active inside sandbox"); + const provider = search.provider; + if (provider !== "brave" && provider !== "tavily") { + warn(` ⚠ Web search provider '${String(provider)}' cannot be verified.`); return; } + const providerLabel = provider === "tavily" ? "Tavily Search" : "Brave Search"; // Current OpenClaw schema keeps the provider-owned apiKey under // plugins.entries..config.webSearch; older configs carried // it inline on tools.web.search. Accept both so the probe keeps @@ -128,7 +217,7 @@ export function verifyWebSearchInsideSandbox( const pluginApiKey = parsed?.plugins?.entries?.[search.provider]?.config?.webSearch?.apiKey; const apiKey = typeof pluginApiKey === "string" ? pluginApiKey : search.apiKey; if (typeof apiKey !== "string" || apiKey.trim() === "") { - warn(" ⚠ Brave Search is enabled but openclaw.json has no API key placeholder."); + warn(` ⚠ ${providerLabel} is enabled but openclaw.json has no API key placeholder.`); return; } // Refuse to interpolate raw secrets into the curl argv. The probe @@ -137,35 +226,33 @@ export function verifyWebSearchInsideSandbox( // testing the thing we care about. if (!/^openshell:resolve:env:[A-Za-z0-9_]+$/.test(apiKey.trim())) { warn( - " ⚠ Brave Search apiKey in openclaw.json is not an OpenShell placeholder; skipping egress probe.", + ` ⚠ ${providerLabel} apiKey in openclaw.json is not an OpenShell placeholder; skipping egress probe.`, ); return; } + const probeCommand = + provider === "tavily" + ? buildTavilyEgressProbeCommand(apiKey) + : buildBraveEgressProbeCommand(apiKey); const probe = deps.runCaptureOpenshell( - [ - "sandbox", - "exec", - "-n", - sandboxName, - "--", - "sh", - "-lc", - buildBraveEgressProbeCommand(apiKey), - ], + ["sandbox", "exec", "-n", sandboxName, "--", "sh", "-lc", probeCommand], { ignoreError: true, timeout: 30_000 }, ); if (!probe) { - warn(" ⚠ Brave Search config exists, but the egress verification request failed."); + warn(` ⚠ ${providerLabel} config exists, but the egress verification request failed.`); return; } const statusMatch = probe.match(/(?:^|\n)HTTP_STATUS:(\d{3})(?:\n|$)/); const status = statusMatch?.[1] || "unknown"; const body = probe.replace(/(?:^|\n)HTTP_STATUS:\d{3}\s*$/m, "").trim(); - if (status === "200" && hasBraveResult(body)) { - log(" ✓ Brave Search egress verified inside sandbox"); + const hasResult = provider === "tavily" ? hasTavilyResult(body) : hasBraveResult(body); + if (status === "200" && hasResult) { + log(` ✓ ${providerLabel} egress verified inside sandbox`); } else { - warn(` ⚠ Brave Search config exists, but egress verification returned HTTP ${status}.`); - if (status === "401" || status === "403") { + warn( + ` ⚠ ${providerLabel} config exists, but egress verification returned HTTP ${status}.`, + ); + if (provider === "brave" && (status === "401" || status === "403")) { // A 401/403 with the placeholder in the request typically means // the L7 proxy did not rewrite X-Subscription-Token. The most // common cause is a legacy `${sandbox}-brave-search` provider diff --git a/src/lib/policy/index.ts b/src/lib/policy/index.ts index c3019dfd4f7..b105ae4b15d 100644 --- a/src/lib/policy/index.ts +++ b/src/lib/policy/index.ts @@ -291,7 +291,8 @@ function setupPolicyPresetSupported( name: string, options: SetupPolicyPresetSupportOptions = {}, ): boolean { - return name !== "brave" || options.webSearchSupported !== false; + const isWebSearchPreset = name === "brave" || name === "tavily"; + return !isWebSearchPreset || options.webSearchSupported !== false; } function filterSetupPolicyPresets( diff --git a/src/lib/state/onboard-session.test.ts b/src/lib/state/onboard-session.test.ts index dda5efd6d74..dcb105f638d 100644 --- a/src/lib/state/onboard-session.test.ts +++ b/src/lib/state/onboard-session.test.ts @@ -877,13 +877,47 @@ describe("onboard session", () => { }); let loaded = requireLoadedSession(session.loadSession()); - expect(loaded.webSearchConfig).toEqual({ fetchEnabled: true }); + expect(loaded.webSearchConfig).toEqual({ fetchEnabled: true, provider: "brave" }); session.completeSession({ webSearchConfig: null }); loaded = requireLoadedSession(session.loadSession()); expect(loaded.webSearchConfig).toBeNull(); }); + it("round-trips an explicit Tavily web search provider", () => { + session.saveSession( + session.createSession({ + webSearchConfig: { fetchEnabled: true, provider: "tavily" }, + }), + ); + + expect(requireLoadedSession(session.loadSession()).webSearchConfig).toEqual({ + fetchEnabled: true, + provider: "tavily", + }); + }); + + it("migrates provider-less enabled web search state to Brave when loading", () => { + session.saveSession(session.createSession()); + const persisted = JSON.parse(fs.readFileSync(session.SESSION_FILE, "utf8")); + persisted.webSearchConfig = { fetchEnabled: true }; + fs.writeFileSync(session.SESSION_FILE, JSON.stringify(persisted)); + + expect(requireLoadedSession(session.loadSession()).webSearchConfig).toEqual({ + fetchEnabled: true, + provider: "brave", + }); + }); + + it("fails closed for an invalid persisted web search provider", () => { + session.saveSession(session.createSession()); + const persisted = JSON.parse(fs.readFileSync(session.SESSION_FILE, "utf8")); + persisted.webSearchConfig = { fetchEnabled: true, provider: "unexpected" }; + fs.writeFileSync(session.SESSION_FILE, JSON.stringify(persisted)); + + expect(requireLoadedSession(session.loadSession()).webSearchConfig).toBeNull(); + }); + it("does not clear existing metadata when updates omit whitelisted metadata fields", () => { session.saveSession( session.createSession({ metadata: { gatewayName: "nemoclaw", fromDockerfile: null } }), diff --git a/src/lib/state/onboard-session.ts b/src/lib/state/onboard-session.ts index 6940c6fd0eb..f4c0b88a0c1 100644 --- a/src/lib/state/onboard-session.ts +++ b/src/lib/state/onboard-session.ts @@ -13,7 +13,7 @@ import path from "node:path"; import { isErrnoException } from "../core/errno"; import type { JsonObject, JsonValue } from "../core/json-types"; -import type { WebSearchConfig } from "../inference/web-search"; +import { normalizeWebSearchConfig, type WebSearchConfig } from "../inference/web-search"; import type { SandboxMessagingPlan } from "../messaging/manifest"; import { compactSandboxMessagingPlanForPersistence } from "../messaging/persistence"; import { parseSandboxMessagingPlan } from "../messaging/plan-validation"; @@ -279,7 +279,8 @@ function readStepStatus(value: SessionJsonValue | undefined): StepStatus | null } function parseWebSearchConfig(value: SessionJsonValue | undefined): WebSearchConfig | null { - return isObject(value) && value.fetchEnabled === true ? { fetchEnabled: true } : null; + if (!isObject(value) || value.fetchEnabled !== true) return null; + return normalizeWebSearchConfig(value as Partial); } function parseTelegramConfig(value: unknown): TelegramConfig | null { @@ -454,8 +455,7 @@ export function createSession(overrides: Partial = {}): Session { nimContainer: overrides.nimContainer ?? null, routerPid: readPositiveInteger(overrides.routerPid), routerCredentialHash: overrides.routerCredentialHash ?? null, - webSearchConfig: - overrides.webSearchConfig?.fetchEnabled === true ? { fetchEnabled: true } : null, + webSearchConfig: normalizeWebSearchConfig(overrides.webSearchConfig), hermesToolGateways: readStringArray(overrides.hermesToolGateways), policyPresets: readStringArray(overrides.policyPresets), messagingPlan: parseSandboxMessagingPlan(overrides.messagingPlan), @@ -987,7 +987,9 @@ export function filterSafeUpdates(updates: SessionUpdates): Partial { safe.routerCredentialHash = updates.routerCredentialHash; } if (isObject(updates.webSearchConfig) && updates.webSearchConfig.fetchEnabled === true) { - safe.webSearchConfig = { fetchEnabled: true }; + safe.webSearchConfig = normalizeWebSearchConfig( + updates.webSearchConfig as Partial, + ); } else if (updates.webSearchConfig === null) { safe.webSearchConfig = null; } diff --git a/src/lib/state/openclaw-config-merge.test.ts b/src/lib/state/openclaw-config-merge.test.ts index 22a2ede2944..c933411aa44 100644 --- a/src/lib/state/openclaw-config-merge.test.ts +++ b/src/lib/state/openclaw-config-merge.test.ts @@ -260,4 +260,96 @@ describe("mergeOpenClawRestoredConfig", () => { }, }); }); + + it("keeps fresh Tavily search config authoritative while preserving user plugins", () => { + const merged = mergeOpenClawRestoredConfig( + { + tools: { + web: { + search: { enabled: true, provider: "brave" }, + fetch: { enabled: false, maxChars: 5000 }, + customSetting: "keep-me", + }, + }, + plugins: { + entries: { + brave: { + enabled: true, + config: { webSearch: { apiKey: "openshell:resolve:env:OLD_BRAVE_API_KEY" } }, + }, + customPlugin: { enabled: true, config: { value: "keep-me" } }, + }, + }, + }, + { + tools: { + web: { + search: { enabled: true, provider: "tavily" }, + fetch: { enabled: true, useTrustedEnvProxy: true }, + }, + }, + plugins: { + entries: { + tavily: { + enabled: true, + config: { webSearch: { apiKey: "openshell:resolve:env:TAVILY_API_KEY" } }, + }, + }, + }, + }, + ) as { + tools: { web: Record }; + plugins: { entries: Record }; + }; + + expect(merged.tools.web.search).toEqual({ enabled: true, provider: "tavily" }); + expect(merged.tools.web.fetch).toEqual({ + enabled: false, + maxChars: 5000, + useTrustedEnvProxy: true, + }); + expect(merged.tools.web.customSetting).toBe("keep-me"); + expect(merged.plugins.entries.tavily).toEqual({ + enabled: true, + config: { webSearch: { apiKey: "openshell:resolve:env:TAVILY_API_KEY" } }, + }); + expect(merged.plugins.entries.brave).toBeUndefined(); + expect(merged.plugins.entries.customPlugin).toEqual({ + enabled: true, + config: { value: "keep-me" }, + }); + }); + + it("does not resurrect web search config or managed plugins after disablement", () => { + const merged = mergeOpenClawRestoredConfig( + { + tools: { + web: { + search: { enabled: true, provider: "tavily" }, + fetch: { enabled: false }, + }, + }, + plugins: { + entries: { + tavily: { + enabled: true, + config: { webSearch: { apiKey: "openshell:resolve:env:TAVILY_API_KEY" } }, + }, + customPlugin: { enabled: true }, + }, + }, + }, + { + tools: { web: { fetch: { enabled: true, useTrustedEnvProxy: true } } }, + plugins: { entries: {} }, + }, + ) as { + tools: { web: Record }; + plugins: { entries: Record }; + }; + + expect(merged.tools.web.search).toBeUndefined(); + expect(merged.plugins.entries.tavily).toBeUndefined(); + expect(merged.plugins.entries.customPlugin).toEqual({ enabled: true }); + }); }); diff --git a/src/lib/state/openclaw-config-merge.ts b/src/lib/state/openclaw-config-merge.ts index bab09e701a4..31a5f63d3d3 100644 --- a/src/lib/state/openclaw-config-merge.ts +++ b/src/lib/state/openclaw-config-merge.ts @@ -21,6 +21,10 @@ export const OPENCLAW_CONFIG_RESTORE_OWNERSHIP = { managedChannels: MANAGED_OPENCLAW_CHANNEL_NAMES, /** Current generated entries win by id; backup-only user entries are kept. */ currentGeneratedEntryMaps: ["plugins.entries"], + /** Fresh web-search selection owns these bundled/external plugin entries. */ + managedWebSearchPluginEntries: ["brave", "tavily"], + /** Fresh web-search selection owns this path, including its absence. */ + managedWebSearchConfigPaths: ["tools.web.search"], /** * Provider entries are reconciled by id: the fresh rebuild owns routing and * credential fields, while backed-up non-secret model tuning is restored. @@ -38,6 +42,9 @@ const MANAGED_OPENCLAW_CHANNELS = new Set( const PROVIDER_RUNTIME_OWNED_FIELDS = OPENCLAW_CONFIG_RESTORE_OWNERSHIP.providerRuntimeOwnedFields; const MODEL_RUNTIME_OWNED_FIELDS = OPENCLAW_CONFIG_RESTORE_OWNERSHIP.modelRuntimeOwnedFields; +const MANAGED_WEB_SEARCH_PLUGIN_ENTRIES = new Set( + OPENCLAW_CONFIG_RESTORE_OWNERSHIP.managedWebSearchPluginEntries, +); function isPlainJsonObject(value: unknown): value is Record { return isRecord(value); @@ -102,12 +109,41 @@ function mergeOpenClawEntryMap( currentEntries: unknown, ): Record | undefined { if (!isPlainJsonObject(backupEntries) && !isPlainJsonObject(currentEntries)) return undefined; - return { - ...(isPlainJsonObject(backupEntries) ? cloneJson(backupEntries) : {}), + const merged: Record = {}; + if (isPlainJsonObject(backupEntries)) { + for (const [key, value] of Object.entries(backupEntries)) { + // Search-provider plugins are selected by the fresh rebuild. Omitting + // one is meaningful: provider switches and disablement must not restore + // a stale Brave/Tavily entry from the durable snapshot. + if (MANAGED_WEB_SEARCH_PLUGIN_ENTRIES.has(key)) continue; + merged[key] = cloneJson(value); + } + } + if (isPlainJsonObject(currentEntries)) { // Current generated entries win so rebuild does not restore stale runtime // placeholders, model routing, or plugin enablement for NemoClaw-managed ids. - ...(isPlainJsonObject(currentEntries) ? cloneJson(currentEntries) : {}), - }; + Object.assign(merged, cloneJson(currentEntries)); + } + return merged; +} + +function mergeOpenClawTools(backupTools: unknown, currentTools: unknown): unknown { + if (!isPlainJsonObject(backupTools)) return cloneJson(currentTools); + if (!isPlainJsonObject(currentTools)) return cloneJson(backupTools); + + const merged = mergeJsonObjects(currentTools, backupTools); + const backupWeb = isPlainJsonObject(backupTools.web) ? backupTools.web : {}; + const currentWeb = isPlainJsonObject(currentTools.web) ? currentTools.web : {}; + const mergedWeb = mergeJsonObjects(currentWeb, backupWeb); + + // The fresh generator owns tools.web.search, including omission when web + // search is disabled. Preserve unrelated user web-tool settings around it. + if ("search" in currentWeb) mergedWeb.search = cloneJson(currentWeb.search); + else delete mergedWeb.search; + + if (Object.keys(mergedWeb).length > 0) merged.web = mergedWeb; + else delete merged.web; + return merged; } function modelEntryId(entry: unknown): string | null { @@ -250,6 +286,7 @@ export function mergeOpenClawRestoredConfig( merged.channels = mergeOpenClawChannels(backedUpConfig.channels, currentConfig.channels); merged.models = mergeOpenClawModels(backedUpConfig.models, currentConfig.models); merged.plugins = mergeOpenClawPlugins(backedUpConfig.plugins, currentConfig.plugins); + merged.tools = mergeOpenClawTools(backedUpConfig.tools, currentConfig.tools); return merged; } diff --git a/test/cli/destroy-detach-order.test.ts b/test/cli/destroy-detach-order.test.ts index 4c20122eef4..ec5a8a86d9e 100644 --- a/test/cli/destroy-detach-order.test.ts +++ b/test/cli/destroy-detach-order.test.ts @@ -76,6 +76,7 @@ describe("CLI dispatch", () => { "sandbox provider detach alpha alpha-slack-bridge", "sandbox provider detach alpha alpha-slack-app", "sandbox provider detach alpha alpha-brave-search", + "sandbox provider detach alpha alpha-tavily-search", ]; for (const line of expectedDetachLines) { const idx = indexOfArg(log, line); diff --git a/test/generate-hermes-config.test.ts b/test/generate-hermes-config.test.ts index b5b44287169..cf47c257453 100644 --- a/test/generate-hermes-config.test.ts +++ b/test/generate-hermes-config.test.ts @@ -27,6 +27,8 @@ const CONFIG_MODULE_DIR = path.join(import.meta.dirname, "..", "agents", "hermes const BASE_ENV: Record = { NEMOCLAW_MODEL: "test-model", NEMOCLAW_INFERENCE_BASE_URL: "https://inference.local/v1", + NEMOCLAW_WEB_SEARCH_ENABLED: "0", + NEMOCLAW_WEB_SEARCH_PROVIDER: "brave", NEMOCLAW_MESSAGING_CHANNELS_B64: encodeJson([]), NEMOCLAW_MESSAGING_ALLOWED_IDS_B64: encodeJson({}), NEMOCLAW_DISCORD_GUILDS_B64: encodeJson({}), @@ -283,6 +285,51 @@ describe("agents/hermes/generate-config.ts", () => { expect(envFile).not.toContain("API_SERVER_KEY="); }); + it("configures Hermes' native Tavily backend with an egress-resolved credential", () => { + const { config, envFile } = runConfigScript({ + NEMOCLAW_WEB_SEARCH_ENABLED: "1", + NEMOCLAW_WEB_SEARCH_PROVIDER: "tavily", + }); + + expect(config.web).toEqual({ backend: "tavily" }); + expect(envFile).toContain("TAVILY_API_KEY=openshell:resolve:env:TAVILY_API_KEY\n"); + expect(findRawSecretEnvEntries(envFile)).toEqual([]); + }); + + it("does not configure Tavily when web search is disabled", () => { + const { config, envFile } = runConfigScript({ + NEMOCLAW_WEB_SEARCH_ENABLED: "0", + NEMOCLAW_WEB_SEARCH_PROVIDER: "tavily", + }); + + expect(config.web).toBeUndefined(); + expect(envFile).not.toContain("TAVILY_API_KEY="); + }); + + it("fails fast for unsupported web-search provider values", () => { + const result = runConfigScriptRaw({ + NEMOCLAW_WEB_SEARCH_ENABLED: "1", + NEMOCLAW_WEB_SEARCH_PROVIDER: "search.example.com", + }); + + expect(result.status).not.toBe(0); + expect(`${result.stderr}\n${result.stdout}`).toContain( + 'Hermes NEMOCLAW_WEB_SEARCH_PROVIDER must be "tavily"', + ); + }); + + it("fails closed when Brave is requested for Hermes", () => { + const result = runConfigScriptRaw({ + NEMOCLAW_WEB_SEARCH_ENABLED: "1", + NEMOCLAW_WEB_SEARCH_PROVIDER: "brave", + }); + + expect(result.status).not.toBe(0); + expect(`${result.stderr}\n${result.stdout}`).toContain( + 'Hermes NEMOCLAW_WEB_SEARCH_PROVIDER must be "tavily"', + ); + }); + it("records the upstream provider and model as a self-describing annotation", () => { const { config } = runConfigScript({ NEMOCLAW_PROVIDER_KEY: "nvidia-prod", @@ -493,6 +540,25 @@ describe("agents/hermes/generate-config.ts", () => { expect(envFile).toContain("MODAL_GATEWAY_URL=http://host.openshell.internal:11436/modal\n"); }); + it("prefers selected Tavily over nous-web while preserving other managed tools", () => { + const { config, envFile } = runConfigScript({ + NEMOCLAW_WEB_SEARCH_ENABLED: "1", + NEMOCLAW_WEB_SEARCH_PROVIDER: "tavily", + NEMOCLAW_HERMES_TOOL_GATEWAY_BROKER: "1", + NEMOCLAW_HERMES_TOOL_GATEWAY_PRESETS_B64: encodeJson(["nous-web", "nous-audio"]), + }); + + expect(config.web).toEqual({ backend: "tavily" }); + expect(config.tts).toEqual({ provider: "openai", use_gateway: true }); + expect(config.stt).toEqual({ provider: "openai", use_gateway: true }); + expect(envFile).toContain("TAVILY_API_KEY=openshell:resolve:env:TAVILY_API_KEY\n"); + expect(envFile).not.toContain("FIRECRAWL_GATEWAY_URL="); + expect(envFile).toContain( + "OPENAI_AUDIO_GATEWAY_URL=http://host.openshell.internal:11436/openai-audio\n", + ); + expect(envFile).toContain("NEMOCLAW_HERMES_TOOL_GATEWAY_BROKER=1\n"); + }); + it("fails fast for unknown managed-tool gateway presets", () => { const result = runConfigScriptRaw({ NEMOCLAW_HERMES_TOOL_GATEWAY_BROKER: "1", diff --git a/test/generate-openclaw-config-web-search.test.ts b/test/generate-openclaw-config-web-search.test.ts new file mode 100644 index 00000000000..aedaf52dee7 --- /dev/null +++ b/test/generate-openclaw-config-web-search.test.ts @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { buildConfig } from "../scripts/generate-openclaw-config.mts"; + +const BASE_ENV: Record = { + NEMOCLAW_MODEL: "test-model", + NEMOCLAW_PROVIDER_KEY: "test-provider", + NEMOCLAW_PRIMARY_MODEL_REF: "test-ref", + CHAT_UI_URL: "http://127.0.0.1:18789", + NEMOCLAW_INFERENCE_BASE_URL: "http://localhost:8080", + NEMOCLAW_INFERENCE_API: "openai", + NEMOCLAW_INFERENCE_COMPAT_B64: Buffer.from("{}").toString("base64"), + NEMOCLAW_PROXY_HOST: "10.200.0.1", + NEMOCLAW_PROXY_PORT: "3128", + NEMOCLAW_CONTEXT_WINDOW: "131072", + NEMOCLAW_MAX_TOKENS: "4096", + NEMOCLAW_REASONING: "false", + NEMOCLAW_AGENT_TIMEOUT: "600", +}; + +function buildWebSearchConfig(env: Record) { + return buildConfig({ ...BASE_ENV, ...env }); +} + +describe("generate-openclaw-config.mts: Tavily web search", () => { + it("emits the bundled plugin's credential path", () => { + const config = buildWebSearchConfig({ + NEMOCLAW_WEB_SEARCH_ENABLED: "1", + NEMOCLAW_WEB_SEARCH_PROVIDER: "tavily", + }); + + expect(config.tools?.web?.search).toEqual({ enabled: true, provider: "tavily" }); + expect(config.plugins?.entries?.tavily).toEqual({ + enabled: true, + config: { webSearch: { apiKey: "openshell:resolve:env:TAVILY_API_KEY" } }, + }); + expect(config.plugins?.entries?.brave).toBeUndefined(); + expect(config.tools?.web?.search?.apiKey).toBeUndefined(); + expect(config.tools?.web?.fetch).toEqual({ enabled: true, useTrustedEnvProxy: true }); + }); + + it("rejects an unknown provider instead of silently selecting one", () => { + expect(() => + buildWebSearchConfig({ + NEMOCLAW_WEB_SEARCH_ENABLED: "1", + NEMOCLAW_WEB_SEARCH_PROVIDER: "unknown", + }), + ).toThrow('NEMOCLAW_WEB_SEARCH_PROVIDER must be "brave" or "tavily"'); + }); +}); diff --git a/test/generate-openclaw-config.test.ts b/test/generate-openclaw-config.test.ts index 61dc8a8e4b6..de65d0fea17 100644 --- a/test/generate-openclaw-config.test.ts +++ b/test/generate-openclaw-config.test.ts @@ -796,7 +796,7 @@ describe("generate-openclaw-config.mts: config generation", () => { expect(config.tools?.web?.search).toBeUndefined(); }); - it("enables web search when env is '1' using the current plugin schema", () => { + it("defaults enabled web search to Brave using the current plugin schema", () => { const config = runConfigScript({ NEMOCLAW_WEB_SEARCH_ENABLED: "1" }); expect(config.tools?.toolSearch).toBe(true); // #5266: apiKey lives under plugins.entries.brave.config (not inline on diff --git a/test/hermes-gateway-wrapper.test.ts b/test/hermes-gateway-wrapper.test.ts index 322637810d3..9a234c75391 100644 --- a/test/hermes-gateway-wrapper.test.ts +++ b/test/hermes-gateway-wrapper.test.ts @@ -1124,6 +1124,7 @@ describe.skipIf(!canRun)("agents/hermes/hermes-wrapper.py", () => { providerKey: "custom", upstreamProvider: "nemoclaw-inference", inferenceApi: "", + webSearchProvider: null, messagingCredentialPlaceholders: [], managedToolGateways: { brokerEnabled: false, presets: [] }, }; @@ -1169,6 +1170,7 @@ describe.skipIf(!canRun)("agents/hermes/hermes-wrapper.py", () => { providerKey: "custom", upstreamProvider: "nemoclaw-inference", inferenceApi: "", + webSearchProvider: null, messagingCredentialPlaceholders: [], managedToolGateways: { brokerEnabled: false, presets: [] }, }; diff --git a/test/messaging-build-applier.test.ts b/test/messaging-build-applier.test.ts index c32ecd8a5bc..bf28e58abed 100644 --- a/test/messaging-build-applier.test.ts +++ b/test/messaging-build-applier.test.ts @@ -212,6 +212,29 @@ describe("messaging-build-applier.mts: agent-install", () => { expect(payload.doctorEnv.BRAVE_API_KEY).toBe("openshell:resolve:env:BRAVE_API_KEY"); }); + it("preserves only the selected Tavily placeholder when doctor runs after messaging render", () => { + const payload = parseDryRun({ + OPENCLAW_VERSION: "2026.5.27", + NEMOCLAW_WEB_SEARCH_ENABLED: "1", + NEMOCLAW_WEB_SEARCH_PROVIDER: "tavily", + NEMOCLAW_MESSAGING_CHANNELS_B64: channelsB64(["slack"]), + }); + + expect(payload.doctorEnv.TAVILY_API_KEY).toBe("openshell:resolve:env:TAVILY_API_KEY"); + expect(payload.doctorEnv.BRAVE_API_KEY).toBeUndefined(); + }); + + it("rejects an unknown selected web-search provider before running doctor", () => { + const result = runDryRun({ + NEMOCLAW_WEB_SEARCH_ENABLED: "1", + NEMOCLAW_WEB_SEARCH_PROVIDER: "unknown", + NEMOCLAW_MESSAGING_CHANNELS_B64: channelsB64(["telegram"]), + }); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("Unsupported NEMOCLAW_WEB_SEARCH_PROVIDER: unknown"); + }); + it("fails fast on malformed messaging plans", () => { const result = runDryRun({ OPENCLAW_VERSION: "2026.5.22", diff --git a/test/onboard-brave-validation.test.ts b/test/onboard-brave-validation.test.ts index 7185af71311..a2846340fb6 100644 --- a/test/onboard-brave-validation.test.ts +++ b/test/onboard-brave-validation.test.ts @@ -12,7 +12,7 @@ import { testTimeout } from "./helpers/timeouts"; const BRAVE_VALIDATION_TEST_TIMEOUT_MS = testTimeout(60_000); type ConfigureWebSearchOutcome = { - result: { fetchEnabled: boolean } | null; + result: { fetchEnabled: boolean; provider?: "brave" | "tavily" } | null; exitCalls: number[]; logs: string[]; warnings: string[]; @@ -121,6 +121,7 @@ function restore() { HOME: tmpDir, PATH: `${fakeBin}:${process.env.PATH || ""}`, NEMOCLAW_NON_INTERACTIVE: "1", + NEMOCLAW_WEB_SEARCH_PROVIDER: "brave", BRAVE_API_KEY: spec.apiKey, }, }); @@ -142,7 +143,7 @@ function runInteractiveConfigureWebSearch(spec: { answers: string[] }): { exitCode: number; payload: { outcome: "completed" | "exit"; - result?: { fetchEnabled: boolean } | null; + result?: { fetchEnabled: boolean; provider?: "brave" | "tavily" } | null; exitCode?: number; logs: string[]; errors: string[]; @@ -176,6 +177,8 @@ const clearEnv = [ "NEMOCLAW_YES", "NEMOCLAW_PREFERRED_API", "NEMOCLAW_EXPERIMENTAL", + "NEMOCLAW_WEB_SEARCH_PROVIDER", + "TAVILY_API_KEY", ]; for (const key of clearEnv) { delete process.env[key]; @@ -297,6 +300,8 @@ require.cache[require.resolve(${credentialsPath})] = { exports: mockedCredentials, }; process.env.BRAVE_API_KEY = "brv-test-key"; +process.env.NEMOCLAW_NON_INTERACTIVE = "1"; +process.env.NEMOCLAW_WEB_SEARCH_PROVIDER = "brave"; const { configureWebSearch } = require(${onboardPath}); const { loadAgent } = require(${agentDefsPath}); @@ -356,7 +361,9 @@ require.cache[require.resolve(${credentialsPath})] = { exports: mockedCredentials, }; delete process.env.BRAVE_API_KEY; +delete process.env.TAVILY_API_KEY; process.env.NEMOCLAW_NON_INTERACTIVE = "1"; +process.env.NEMOCLAW_WEB_SEARCH_PROVIDER = "brave"; const { configureWebSearch } = require(${onboardPath}); (async () => { const result = await configureWebSearch(null); @@ -380,7 +387,7 @@ const { configureWebSearch } = require(${onboardPath}); }); expect(result.status).toBe(0); const payload = JSON.parse(fs.readFileSync(outputPath, "utf-8")); - expect(payload.result).toEqual({ fetchEnabled: true }); + expect(payload.result).toEqual({ fetchEnabled: true, provider: "brave" }); expect(payload.braveKey).toBe("saved-brave-key"); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); @@ -404,7 +411,7 @@ const { configureWebSearch } = require(${onboardPath}); expect( payload.warnings.some((line) => line.includes("Brave Search API key validation failed")), ).toBe(true); - expect(payload.warnings.some((line) => line.includes("nemoclaw config web-search"))).toBe(true); + expect(payload.warnings.some((line) => line.includes("nemoclaw onboard"))).toBe(true); }); it("enables Brave Web Search when validation succeeds", () => { @@ -416,12 +423,12 @@ const { configureWebSearch } = require(${onboardPath}); expect(exitCode).toBe(0); expect(payload.exitCalls).toEqual([]); - expect(payload.result).toEqual({ fetchEnabled: true }); + expect(payload.result).toEqual({ fetchEnabled: true, provider: "brave" }); }); }); describe("configureWebSearch (interactive)", () => { - it("returns to the Brave Search enable prompt when backing out of the API key prompt", () => { + it("returns to provider selection when backing out of the Brave API key prompt", () => { const { exitCode, payload } = runInteractiveConfigureWebSearch({ answers: ["y", "back", "n"], }); @@ -432,9 +439,9 @@ describe("configureWebSearch (interactive)", () => { expect(payload.braveKey).toBeNull(); expect(payload.errors).toEqual([]); expect(payload.saved.every((entry) => entry.value !== "back")).toBe(true); - expect( - payload.prompts.filter((entry) => /Enable Brave Web Search\?/.test(entry.message)), - ).toHaveLength(2); + expect(payload.prompts.filter((entry) => /Choose \[1-3\]:/.test(entry.message))).toHaveLength( + 2, + ); expect( payload.prompts.some((entry) => /Brave Search API key: /.test(entry.message) && entry.secret), ).toBe(true); diff --git a/test/onboard-policy-suggestions.test.ts b/test/onboard-policy-suggestions.test.ts index 07e8b8cef40..7fc5f52fcb3 100644 --- a/test/onboard-policy-suggestions.test.ts +++ b/test/onboard-policy-suggestions.test.ts @@ -27,6 +27,7 @@ const { computeSetupPresetSuggestions, filterSetupPolicyPresets, getSuggestedPol provider?: string | null; agent?: string | null; env?: NodeJS.ProcessEnv; + webSearchConfig?: { fetchEnabled?: boolean; provider?: "brave" | "tavily" } | null; }) => string[]; }; const { mergeRequiredSetupPolicyPresets, suppressedAgentRequiredPresets } = @@ -40,6 +41,7 @@ const { mergeRequiredSetupPolicyPresets, suppressedAgentRequiredPresets } = knownPresetNames?: string[] | Set | null; env?: NodeJS.ProcessEnv; tierName?: string | null; + webSearchConfig?: { fetchEnabled?: boolean; provider?: "brave" | "tavily" } | null; }, ) => string[]; suppressedAgentRequiredPresets: ( @@ -92,6 +94,7 @@ describe("onboard policy preset suggestions", () => { "huggingface", "brew", "brave", + "tavily", "slack", "discord", "telegram", @@ -333,6 +336,45 @@ describe("onboard policy preset suggestions", () => { expect(suggestions).toEqual(["npm", "pypi", "huggingface", "brew", "brave"]); }); + it("selects Tavily and removes the stale Brave tier default", () => { + const knownWithTavily = [...known, "tavily"]; + const suggestions = computeSetupPresetSuggestions("balanced", { + enabledChannels: [], + knownPresetNames: knownWithTavily, + webSearchConfig: { fetchEnabled: true, provider: "tavily" }, + webSearchSupported: true, + }); + + expect(suggestions).toContain("tavily"); + expect(suggestions).not.toContain("brave"); + expect( + getSuggestedPolicyPresets({ + enabledChannels: [], + webSearchConfig: { fetchEnabled: true, provider: "tavily" }, + }), + ).toContain("tavily"); + + const hermesOpen = computeSetupPresetSuggestions("open", { + enabledChannels: [], + knownPresetNames: knownWithTavily, + agent: "hermes", + hermesToolGateways: ["nous-web", "nous-audio"], + webSearchConfig: { fetchEnabled: true, provider: "tavily" }, + webSearchSupported: true, + }); + expect(hermesOpen).not.toContain("nous-web"); + expect(hermesOpen).toContain("nous-audio"); + + expect( + mergeRequiredSetupPolicyPresets(["nous-audio"], { + agent: "hermes", + hermesToolGateways: ["nous-web", "nous-audio"], + knownPresetNames: knownWithTavily, + webSearchConfig: { fetchEnabled: true, provider: "tavily" }, + }), + ).toEqual(["nous-audio"]); + }); + it("filters tier defaults to known presets for agent-specific onboarding", () => { const suggestions = computeSetupPresetSuggestions("balanced", { enabledChannels: [], @@ -341,7 +383,7 @@ describe("onboard policy preset suggestions", () => { expect(suggestions).toEqual(["npm", "pypi", "huggingface", "brew"]); }); - it("omits Brave when web search is unsupported", () => { + it("omits web-search presets when web search is unsupported", () => { const allPresets = known.map((name) => ({ name })); const unsupportedPresets = filterSetupPolicyPresets(allPresets, { webSearchSupported: false, @@ -350,7 +392,9 @@ describe("onboard policy preset suggestions", () => { webSearchSupported: true, }).map((p) => p.name); expect(unsupportedPresets).not.toContain("brave"); + expect(unsupportedPresets).not.toContain("tavily"); expect(supportedPresets).toContain("brave"); + expect(supportedPresets).toContain("tavily"); }); it("drops Brave tier defaults when web search is unsupported", () => { diff --git a/test/sandbox-provider-cleanup.test.ts b/test/sandbox-provider-cleanup.test.ts index f110557cf42..7a2edf8115f 100644 --- a/test/sandbox-provider-cleanup.test.ts +++ b/test/sandbox-provider-cleanup.test.ts @@ -40,6 +40,7 @@ describe("SANDBOX_PROVIDER_SUFFIXES", () => { "slack-app", "teams-bridge", "brave-search", + "tavily-search", ].sort(), ); }); @@ -207,7 +208,7 @@ describe("detachSandboxProviders", () => { expect(result.detached).toHaveLength(SANDBOX_PROVIDER_SUFFIXES.length - 1); }); - it("includes the Brave search provider in the detach set", () => { + it("includes Brave and Tavily search providers in the detach set", () => { const { runOpenshell, calls } = buildRunOpenshell(new Map()); detachSandboxProviders("spark-nemo", { runOpenshell }); @@ -220,6 +221,14 @@ describe("detachSandboxProviders", () => { argv[4] === "spark-nemo-brave-search", ); expect(braveCall).toBeDefined(); + const tavilyCall = calls.find( + (argv) => + argv[0] === "sandbox" && + argv[1] === "provider" && + argv[2] === "detach" && + argv[4] === "spark-nemo-tavily-search", + ); + expect(tavilyCall).toBeDefined(); }); }); diff --git a/test/sandbox-provisioning-tavily.test.ts b/test/sandbox-provisioning-tavily.test.ts new file mode 100644 index 00000000000..66c5a59d0b9 --- /dev/null +++ b/test/sandbox-provisioning-tavily.test.ts @@ -0,0 +1,116 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +const DOCKERFILE = path.join(import.meta.dirname, "..", "Dockerfile"); + +function dockerRunCommandBetween( + dockerfile: string, + startMarker: string, + endMarker: string, +): string { + const start = dockerfile.indexOf(startMarker); + const end = dockerfile.indexOf(endMarker, start); + if (start === -1 || end === -1 || end <= start) { + throw new Error(`Expected Dockerfile block between ${startMarker} and ${endMarker}`); + } + const runIndex = dockerfile.indexOf("RUN ", start); + if (runIndex === -1 || runIndex > end) { + throw new Error(`Expected RUN instruction after ${startMarker}`); + } + const runLines: string[] = []; + for (const line of dockerfile.slice(runIndex, end).split("\n")) { + runLines.push(line); + if (!line.trimEnd().endsWith("\\")) break; + } + return runLines + .join("\n") + .trim() + .replace(/^RUN\s+/, "") + .replace(/\\\n/g, " "); +} + +function runPluginInstallBlock( + functionDefinition: string, + env: Record, +): { calls: string; result: ReturnType } { + const dockerfile = fs.readFileSync(DOCKERFILE, "utf-8"); + const command = dockerRunCommandBetween( + dockerfile, + "# Install non-messaging OpenClaw plugins", + "# hadolint ignore=DL3059,DL4006\nRUN node --experimental-strip-types /src/lib/messaging/applier/build/messaging-build-applier.mts --agent openclaw --phase agent-install", + ); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-tavily-plugin-")); + const logPath = path.join(tmp, "calls.log"); + const scriptPath = path.join(tmp, "run-docker-block.sh"); + fs.writeFileSync( + scriptPath, + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + `call_log=${JSON.stringify(logPath)}`, + functionDefinition, + command, + ].join("\n"), + { mode: 0o700 }, + ); + + try { + const result = spawnSync("bash", [scriptPath], { + encoding: "utf-8", + env: { ...process.env, ...env }, + timeout: 5000, + }); + const calls = fs.existsSync(logPath) ? fs.readFileSync(logPath, "utf-8") : ""; + return { calls, result }; + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } +} + +const TAVILY_BUILD_ENV = { + NEMOCLAW_OPENCLAW_OTEL: "0", + NEMOCLAW_WEB_SEARCH_ENABLED: "1", + NEMOCLAW_WEB_SEARCH_PROVIDER: "tavily", + OPENCLAW_VERSION: "2026.5.27", +}; + +describe("sandbox provisioning: bundled OpenClaw Tavily extension", () => { + it("inspects the bundled extension and preserves its placeholder during doctor", () => { + const { result, calls } = runPluginInstallBlock( + [ + "openclaw() {", + ' printf "%s|TAVILY_API_KEY=%s\\n" "$*" "${TAVILY_API_KEY:-}" >> "$call_log"', + "}", + ].join("\n"), + TAVILY_BUILD_ENV, + ); + + expect(result.status, `stderr: ${result.stderr}`).toBe(0); + expect(calls.trim().split("\n")).toEqual([ + "plugins inspect tavily --json|TAVILY_API_KEY=", + "doctor --fix --non-interactive|TAVILY_API_KEY=openshell:resolve:env:TAVILY_API_KEY", + ]); + expect(calls).not.toContain("plugins install"); + }); + + it("fails closed when the bundled extension cannot be inspected", () => { + const { result, calls } = runPluginInstallBlock( + [ + "openclaw() {", + ' printf "%s\\n" "$*" >> "$call_log"', + ' if [ "$*" = "plugins inspect tavily --json" ]; then return 41; fi', + "}", + ].join("\n"), + TAVILY_BUILD_ENV, + ); + + expect(result.status).toBe(41); + expect(calls.trim()).toBe("plugins inspect tavily --json"); + }); +}); diff --git a/test/sandbox-provisioning.test.ts b/test/sandbox-provisioning.test.ts index 725fc961ba2..72ffb84c7c0 100644 --- a/test/sandbox-provisioning.test.ts +++ b/test/sandbox-provisioning.test.ts @@ -322,6 +322,7 @@ describe("sandbox provisioning: non-messaging OpenClaw plugins", () => { { NEMOCLAW_OPENCLAW_OTEL: "0", NEMOCLAW_WEB_SEARCH_ENABLED: "1", + NEMOCLAW_WEB_SEARCH_PROVIDER: "brave", OPENCLAW_VERSION: "2026.5.22", }, ); diff --git a/test/seed-hermes-dashboard-config.test.ts b/test/seed-hermes-dashboard-config.test.ts index 924ccbadce4..52d56e6167e 100644 --- a/test/seed-hermes-dashboard-config.test.ts +++ b/test/seed-hermes-dashboard-config.test.ts @@ -15,8 +15,8 @@ import { spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import YAML from "yaml"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import YAML from "yaml"; const SCRIPT_PATH = path.join( import.meta.dirname, @@ -33,6 +33,7 @@ const PY_YAML_AVAILABLE = const GENERATED_HEX_TOKEN = Array.from({ length: 64 }, (_value, index) => (index % 16).toString(16), ).join(""); +const TAVILY_API_KEY_PLACEHOLDER = "openshell:resolve:env:TAVILY_API_KEY"; const GATEWAY_CONFIG = { _config_version: 12, @@ -121,6 +122,33 @@ describe.skipIf(!PY_YAML_AVAILABLE)("seed-dashboard-config.py", () => { expect(dash._nemoclaw_upstream).toEqual(GATEWAY_CONFIG._nemoclaw_upstream); }); + it("mirrors only the exact native Tavily backend into dashboard config", () => { + const src = writeYaml("gw.yaml", { + ...GATEWAY_CONFIG, + web: { backend: "tavily", use_gateway: true, api_key: "do-not-copy" }, + }); + const dst = writeYaml("dash.yaml", { web: { max_results: 3 } }); + + const res = runSeed(src, dst); + + expect(res.status).toBe(0); + expect(readYaml(dst).web).toEqual({ max_results: 3, backend: "tavily" }); + }); + + it("removes the managed Tavily backend after the gateway disables it", () => { + const enabledSrc = writeYaml("gw-enabled.yaml", { + ...GATEWAY_CONFIG, + web: { backend: "tavily" }, + }); + const disabledSrc = writeYaml("gw-disabled.yaml", GATEWAY_CONFIG); + const dst = writeYaml("dash.yaml", { web: { max_results: 3 } }); + + expect(runSeed(enabledSrc, dst).status).toBe(0); + expect(readYaml(dst).web).toEqual({ max_results: 3, backend: "tavily" }); + expect(runSeed(disabledSrc, dst).status).toBe(0); + expect(readYaml(dst).web).toEqual({ max_results: 3 }); + }); + it("synthesizes Hermes v16 providers from legacy gateway routing", () => { const legacy = { _config_version: 12, @@ -175,6 +203,7 @@ describe.skipIf(!PY_YAML_AVAILABLE)("seed-dashboard-config.py", () => { "API_SERVER_HOST=127.0.0.1", "API_SERVER_PORT=18642", `API_SERVER_KEY=${GENERATED_HEX_TOKEN}`, + `TAVILY_API_KEY=${TAVILY_API_KEY_PLACEHOLDER}`, "FIRECRAWL_GATEWAY_URL=http://host.openshell.internal:11436/firecrawl", "NEMOCLAW_HERMES_TOOL_GATEWAY_BROKER=1", "MODAL_GATEWAY_URL=http://host.openshell.internal:11436/modal", @@ -193,6 +222,7 @@ describe.skipIf(!PY_YAML_AVAILABLE)("seed-dashboard-config.py", () => { "API_SERVER_HOST=127.0.0.1", "API_SERVER_PORT=18642", `API_SERVER_KEY=${GENERATED_HEX_TOKEN}`, + `TAVILY_API_KEY=${TAVILY_API_KEY_PLACEHOLDER}`, "FIRECRAWL_GATEWAY_URL=http://host.openshell.internal:11436/firecrawl", "NEMOCLAW_HERMES_TOOL_GATEWAY_BROKER=1", "MODAL_GATEWAY_URL=http://host.openshell.internal:11436/modal", @@ -248,6 +278,21 @@ describe.skipIf(!PY_YAML_AVAILABLE)("seed-dashboard-config.py", () => { } }); + it("rejects a literal Tavily key instead of mirroring it into the dashboard .env", () => { + const src = writeYaml("gw.yaml", GATEWAY_CONFIG); + const dst = path.join(tmpDir, "dash.yaml"); + const envSrc = path.join(tmpDir, "gw.env"); + const envDst = path.join(tmpDir, "dash.env"); + fs.writeFileSync(envSrc, "TAVILY_API_KEY=tvly-test-literal\nAPI_SERVER_HOST=127.0.0.1\n"); + + const res = runSeed(src, dst, envSrc, envDst); + + expect(res.status).toBe(1); + expect(res.stderr).toContain("TAVILY_API_KEY"); + expect(res.stderr).not.toContain("tvly-test-literal"); + expect(fs.existsSync(envDst)).toBe(false); + }); + it("applies requested dashboard seed owner and mode before the atomic rename", () => { const uid = process.getuid?.() ?? Number.NaN; const gid = process.getgid?.() ?? Number.NaN; diff --git a/test/tavily-preset.test.ts b/test/tavily-preset.test.ts index 1f2b0ea0c9b..96c5a66b5c4 100644 --- a/test/tavily-preset.test.ts +++ b/test/tavily-preset.test.ts @@ -10,6 +10,7 @@ type TavilyEndpoint = { port: number; protocol: string; enforcement: string; + request_body_credential_rewrite?: boolean; rules: Array<{ allow: { method: string; path: string } }>; tls?: string; }; @@ -38,6 +39,7 @@ describe("tavily opt-in preset", () => { port: 443, protocol: "rest", enforcement: "enforce", + request_body_credential_rewrite: true, rules: [ { allow: { method: "GET", path: "/**" } }, { allow: { method: "POST", path: "/**" } }, @@ -46,6 +48,7 @@ describe("tavily opt-in preset", () => { ]); expect(policy?.binaries).toEqual([ { path: "/opt/venv/bin/python3*" }, + { path: "/opt/hermes/.venv/bin/python" }, { path: "/usr/local/bin/node" }, { path: "/usr/bin/node" }, { path: "/usr/local/bin/curl" }, diff --git a/test/validate-blueprint.test.ts b/test/validate-blueprint.test.ts index 22d9cc05dcd..96ceafd4745 100644 --- a/test/validate-blueprint.test.ts +++ b/test/validate-blueprint.test.ts @@ -9,7 +9,7 @@ */ import { readFileSync } from "node:fs"; -import { describe, it, expect } from "vitest"; +import { describe, expect, it } from "vitest"; import YAML from "yaml"; const BLUEPRINT_PATH = new URL("../nemoclaw-blueprint/blueprint.yaml", import.meta.url); @@ -29,6 +29,10 @@ const TAVILY_PROVIDER_PROFILE_PATH = new URL( "../nemoclaw-blueprint/provider-profiles/tavily.yaml", import.meta.url, ); +const TAVILY_PROVIDER_PROFILE_FOR_HERMES_PATH = new URL( + "../nemoclaw-blueprint/provider-profiles/tavily-hermes-v1.yaml", + import.meta.url, +); const TAVILY_POLICY_PRESET_PATH = new URL( "../nemoclaw-blueprint/policies/presets/tavily.yaml", import.meta.url, @@ -42,6 +46,14 @@ const PERMISSIVE_POLICY_PATH = new URL( import.meta.url, ); const HERMES_POLICY_PATH = new URL("../agents/hermes/policy-additions.yaml", import.meta.url); +const hermesPermissivePolicyPath = new URL( + "../agents/hermes/policy-permissive.yaml", + import.meta.url, +); +const OPENCLAW_PERMISSIVE_POLICY_PATH = new URL( + "../agents/openclaw/policy-permissive.yaml", + import.meta.url, +); const REQUIRED_PROFILE_FIELDS: ReadonlyArray = [ "provider_type", "endpoint", @@ -488,8 +500,12 @@ describe("Brave Search provider profile", () => { describe("Tavily Search provider profile", () => { const profile = loadYaml(TAVILY_PROVIDER_PROFILE_PATH); + const hermesProfile = loadYaml(TAVILY_PROVIDER_PROFILE_FOR_HERMES_PATH); const preset = loadYaml(TAVILY_POLICY_PRESET_PATH); const deepAgentsPolicy = loadYaml(DEEPAGENTS_POLICY_PATH); + const defaultOpenClawPermissivePolicy = loadYaml(PERMISSIVE_POLICY_PATH); + const hermesPermissivePolicy = loadYaml(hermesPermissivePolicyPath); + const openClawPermissivePolicy = loadYaml(OPENCLAW_PERMISSIVE_POLICY_PATH); it("routes TAVILY_API_KEY through a bearer authorization header", () => { expect(profile.id).toBe("tavily"); @@ -526,7 +542,7 @@ describe("Tavily Search provider profile", () => { it("keeps its binary allowlist aligned with the Tavily policy preset", () => { const presetBinaries = preset.network_policies?.tavily?.binaries?.map(({ path }) => path); - expect(profile.binaries).toEqual(presetBinaries); + for (const binary of profile.binaries ?? []) expect(presetBinaries).toContain(binary); }); it("anchors managed Python access to Deep Agents Code's read-only venv", () => { @@ -537,6 +553,49 @@ describe("Tavily Search provider profile", () => { expect(managedInferenceBinaries).toContainEqual({ path: managedPython }); expect(profile.binaries).toContain(managedPython); }); + + it("supports Hermes' exact managed Python path and JSON credential rewrite", () => { + const endpoint = preset.network_policies?.tavily?.endpoints?.find( + (candidate) => candidate.host === "api.tavily.com", + ); + + expect(hermesProfile).toMatchObject({ + id: "tavily-hermes-v1", + credentials: [ + expect.objectContaining({ + env_vars: ["TAVILY_API_KEY"], + auth_style: "bearer", + header_name: "authorization", + }), + ], + endpoints: [expect.objectContaining({ host: "api.tavily.com", port: 443 })], + binaries: ["/opt/hermes/.venv/bin/python", "/usr/local/bin/curl", "/usr/bin/curl"], + }); + expect(endpoint).toMatchObject({ + protocol: "rest", + enforcement: "enforce", + request_body_credential_rewrite: true, + }); + }); + + it("preserves Tavily credential rewriting when agent shields are down", () => { + for (const policy of [ + defaultOpenClawPermissivePolicy, + openClawPermissivePolicy, + hermesPermissivePolicy, + ]) { + const endpoint = policy.network_policies?.tavily?.endpoints?.find( + (candidate) => candidate.host === "api.tavily.com", + ); + + expect(endpoint).toMatchObject({ + protocol: "rest", + enforcement: "enforce", + access: "full", + request_body_credential_rewrite: true, + }); + } + }); }); describe("permissive sandbox policy", () => { From 25a6295df81dec954cbe5a96ca66a87256a1333b Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Thu, 2 Jul 2026 10:01:54 -0700 Subject: [PATCH 2/3] fix(onboard): address Tavily review findings Tighten Tavily egress and preserve fresh OpenClaw search ownership. Isolate injected environment state and correct agent-specific documentation. Signed-off-by: Carlos Villela --- .../quickstart-langchain-deepagents-code.mdx | 2 +- .../integration-policy-examples.mdx | 3 +- docs/reference/commands-nemohermes.mdx | 7 ++-- docs/reference/commands.mdx | 12 ++++--- docs/security/best-practices.mdx | 5 +-- .../policies/presets/tavily.yaml | 11 ++++--- src/lib/onboard.ts | 21 +++--------- src/lib/onboard/machine/core-flow-phases.ts | 1 + .../onboard/machine/handlers/sandbox.test.ts | 33 +++++++++---------- src/lib/onboard/machine/handlers/sandbox.ts | 11 +++++-- src/lib/onboard/messaging-prep.test.ts | 4 --- src/lib/onboard/messaging-prep.ts | 5 --- .../sandbox-messaging-preflight.test.ts | 2 -- src/lib/onboard/web-search-flow.test.ts | 16 ++++++--- src/lib/onboard/web-search-flow.ts | 1 - src/lib/state/openclaw-config-merge.test.ts | 33 +++++++++++++++++++ src/lib/state/openclaw-config-merge.ts | 12 +++---- .../09-deepagents-code-tavily-opt-in.sh | 17 +++++++--- test/langchain-deepagents-code-image.test.ts | 8 +++-- test/sync-agent-variant-docs.test.ts | 16 +++++++++ test/tavily-preset.test.ts | 8 ++--- test/validate-blueprint.test.ts | 7 ++++ 22 files changed, 147 insertions(+), 88 deletions(-) diff --git a/docs/get-started/quickstart-langchain-deepagents-code.mdx b/docs/get-started/quickstart-langchain-deepagents-code.mdx index f35ea12b60a..69dfd3eae71 100644 --- a/docs/get-started/quickstart-langchain-deepagents-code.mdx +++ b/docs/get-started/quickstart-langchain-deepagents-code.mdx @@ -100,7 +100,7 @@ nemo-deepagents credentials add tavily-search --type tavily --credential TAVILY_ nemo-deepagents 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. diff --git a/docs/network-policy/integration-policy-examples.mdx b/docs/network-policy/integration-policy-examples.mdx index d914b1945fe..8f9a0bdefc2 100644 --- a/docs/network-policy/integration-policy-examples.mdx +++ b/docs/network-policy/integration-policy-examples.mdx @@ -282,7 +282,8 @@ Configure `TAVILY_API_KEY` during that onboarding run. -The `tavily` preset enables request-body credential rewriting because Hermes sends its resolver placeholder in the JSON `api_key` field. +The `tavily` preset permits only `POST /search` and `POST /extract` to `api.tavily.com`. +It enables request-body credential rewriting because Hermes sends its resolver placeholder in the JSON `api_key` field. OpenShell replaces that placeholder at egress, so the raw key is not written into the sandbox configuration. ## Weather and Public Reference Lookups diff --git a/docs/reference/commands-nemohermes.mdx b/docs/reference/commands-nemohermes.mdx index 1602c668100..e38355cf6af 100644 --- a/docs/reference/commands-nemohermes.mdx +++ b/docs/reference/commands-nemohermes.mdx @@ -1966,9 +1966,6 @@ Set them before running `nemohermes onboard`. | `NEMOCLAW_OLLAMA_INSTALL_MODE` | `system`, `user`, or empty/unset | Pins the Linux Ollama install location. Refer to the Linux Ollama install mode details below. | | `NEMOCLAW_PROXY_HOST` | hostname or IP | Overrides the sandbox-side outbound HTTP proxy host. Defaults to `10.200.0.1`. | | `NEMOCLAW_PROXY_PORT` | integer port | Overrides the sandbox-side outbound HTTP proxy port. Defaults to `3128`. | -| `NEMOCLAW_WEB_SEARCH_PROVIDER` | `brave`, `tavily`, or `none` | Selects the web search provider in non-interactive onboarding or disables web search explicitly. OpenClaw supports `brave` and `tavily`; Hermes supports `tavily` only. When unset, supported credential variables trigger implicit selection with Brave-first precedence. | -| `BRAVE_API_KEY` | Brave Search API key | Supplies and implicitly selects Brave Search for OpenClaw when no web search provider is set. NemoClaw validates the key and stores it in OpenShell rather than the sandbox. | -| `TAVILY_API_KEY` | Tavily Search API key | Supplies and implicitly selects Tavily Search when no higher-precedence supported web search credential is available. NemoClaw validates the key and stores it in OpenShell rather than the sandbox. | | `NEMOCLAW_OPENCLAW_OTEL` | `1` to enable | Enables OpenClaw conversation diagnostics export through the `diagnostics-otel` plugin. Disabled by default. | | `NEMOCLAW_OPENCLAW_OTEL_ENDPOINT` | OTLP/HTTP URL | Sets the OpenTelemetry collector endpoint for OpenClaw diagnostics. Defaults to `http://host.openshell.internal:4318` when `NEMOCLAW_OPENCLAW_OTEL=1`. | | `NEMOCLAW_OPENCLAW_OTEL_SERVICE_NAME` | service name | Sets the OTEL `service.name` for OpenClaw gateway spans. Defaults to `openclaw-gateway`. | @@ -1984,10 +1981,12 @@ Set them before running `nemohermes onboard`. | `NEMOCLAW_MINIMAL_BOOTSTRAP` | `1` to enable | Skips default OpenClaw workspace-template seeding for new pristine workspaces. Existing files are not deleted; refer to [Runtime Controls](../manage-sandboxes/runtime-controls). | | `NEMOCLAW_MODEL_ROUTER_PYTHON` | absolute path | Pins the host Python interpreter used to create the Model Router virtual environment. Strict. NemoClaw probes only that interpreter and aborts with the failure reason if it does not qualify, rather than silently falling back to another python. Relative command names such as `python3.12` are rejected. When unset, NemoClaw probes `python3.13`, `python3.12`, `python3.11`, `python3.10`, and bare `python3`, retains every interpreter whose version is in `[3.10, 3.14)` and whose `ensurepip`, `pyexpat`, `ssl`, and `venv` stdlib modules import cleanly, and tries `python -m venv` on each in priority order until one succeeds. Set the pin when the auto-discovered interpreter is broken (for example, Homebrew `python@3.14` with a `pyexpat` dlopen mismatch on macOS). | -Hermes-specific provider authentication: +Hermes-specific onboarding configuration: | Variable | Format | Effect | |----------|--------|--------| +| `NEMOCLAW_WEB_SEARCH_PROVIDER` | `tavily` or `none` | Selects Tavily Search in non-interactive onboarding or disables web search explicitly. When unset, `TAVILY_API_KEY` implicitly selects Tavily. | +| `TAVILY_API_KEY` | Tavily Search API key | Supplies and implicitly selects Tavily Search when no web search provider is set. NemoClaw validates the key and stores it in OpenShell rather than the sandbox. | | `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. | diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 74275aa1638..f64c7b2a005 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -2374,9 +2374,6 @@ Set them before running `$$nemoclaw onboard`. | `NEMOCLAW_OLLAMA_INSTALL_MODE` | `system`, `user`, or empty/unset | Pins the Linux Ollama install location. Refer to the Linux Ollama install mode details below. | | `NEMOCLAW_PROXY_HOST` | hostname or IP | Overrides the sandbox-side outbound HTTP proxy host. Defaults to `10.200.0.1`. | | `NEMOCLAW_PROXY_PORT` | integer port | Overrides the sandbox-side outbound HTTP proxy port. Defaults to `3128`. | -| `NEMOCLAW_WEB_SEARCH_PROVIDER` | `brave`, `tavily`, or `none` | Selects the web search provider in non-interactive onboarding or disables web search explicitly. OpenClaw supports `brave` and `tavily`; Hermes supports `tavily` only. When unset, supported credential variables trigger implicit selection with Brave-first precedence. | -| `BRAVE_API_KEY` | Brave Search API key | Supplies and implicitly selects Brave Search for OpenClaw when no web search provider is set. NemoClaw validates the key and stores it in OpenShell rather than the sandbox. | -| `TAVILY_API_KEY` | Tavily Search API key | Supplies and implicitly selects Tavily Search when no higher-precedence supported web search credential is available. NemoClaw validates the key and stores it in OpenShell rather than the sandbox. | | `NEMOCLAW_OPENCLAW_OTEL` | `1` to enable | Enables OpenClaw conversation diagnostics export through the `diagnostics-otel` plugin. Disabled by default. | | `NEMOCLAW_OPENCLAW_OTEL_ENDPOINT` | OTLP/HTTP URL | Sets the OpenTelemetry collector endpoint for OpenClaw diagnostics. Defaults to `http://host.openshell.internal:4318` when `NEMOCLAW_OPENCLAW_OTEL=1`. | | `NEMOCLAW_OPENCLAW_OTEL_SERVICE_NAME` | service name | Sets the OTEL `service.name` for OpenClaw gateway spans. Defaults to `openclaw-gateway`. | @@ -2394,10 +2391,13 @@ Set them before running `$$nemoclaw onboard`. -OpenClaw-specific build-time agent configuration: +OpenClaw-specific onboarding configuration: | Variable | Format | Effect | |----------|--------|--------| +| `NEMOCLAW_WEB_SEARCH_PROVIDER` | `brave`, `tavily`, or `none` | Selects Brave Search or Tavily Search in non-interactive onboarding, or disables web search explicitly. When unset, `BRAVE_API_KEY` implicitly selects Brave before `TAVILY_API_KEY` can implicitly select Tavily. | +| `BRAVE_API_KEY` | Brave Search API key | Supplies and implicitly selects Brave Search when no web search provider is set. NemoClaw validates the key and stores it in OpenShell rather than the sandbox. | +| `TAVILY_API_KEY` | Tavily Search API key | Supplies and implicitly selects Tavily Search when no provider is set and no Brave key is available. NemoClaw validates the key and stores it in OpenShell rather than the sandbox. | | `NEMOCLAW_AGENT_TIMEOUT` | positive integer (seconds) | Overrides `agents.defaults.timeoutSeconds` in the built OpenClaw config. Raise for slow inference. | | `NEMOCLAW_AUTO_PAIR_SLOW_INTERVAL_SECS` | positive number of seconds | Sets the post-pairing poll cadence for the in-sandbox OpenClaw auto-pair watcher. Defaults to `5` so late allowlisted CLI and browser scope upgrades are approved before clients time out. Raise only on load-sensitive gateways. | | `NEMOCLAW_AUTO_PAIR_FAST_REENTRY_POLLS` | positive integer | Sets how many fast polls run after the watcher observes a fresh allowlisted scope-upgrade request. Defaults to `5`; set lower only when you need to reduce gateway polling. | @@ -2411,10 +2411,12 @@ OpenClaw-specific build-time agent configuration: -Hermes-specific provider authentication: +Hermes-specific onboarding configuration: | Variable | Format | Effect | |----------|--------|--------| +| `NEMOCLAW_WEB_SEARCH_PROVIDER` | `tavily` or `none` | Selects Tavily Search in non-interactive onboarding or disables web search explicitly. When unset, `TAVILY_API_KEY` implicitly selects Tavily. | +| `TAVILY_API_KEY` | Tavily Search API key | Supplies and implicitly selects Tavily Search when no web search provider is set. NemoClaw validates the key and stores it in OpenShell rather than the sandbox. | | `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. | diff --git a/docs/security/best-practices.mdx b/docs/security/best-practices.mdx index 2c3b412ff9e..35976554411 100644 --- a/docs/security/best-practices.mdx +++ b/docs/security/best-practices.mdx @@ -216,7 +216,7 @@ NemoClaw ships preset policy files in `nemoclaw-blueprint/policies/presets/` for | `outlook` | Microsoft 365, Outlook. | Gives agent access to email. | | `pypi` | Python Package Index (GET and HEAD only). | Allows installing arbitrary Python packages, which may contain malicious code. Publishing is blocked. | | `slack` | Slack API, Socket Mode, webhooks. | WebSocket uses `access: full`. Agent can post to any channel the bot token has access to. | -| `tavily` | Tavily Search API. | Agent can submit search queries and extraction targets to Tavily. The preset allows GET and POST requests from the maintained agent runtimes and enables request-body credential rewriting for Hermes. | +| `tavily` | Tavily Search API. | Agent can submit search queries and extraction targets to Tavily. The preset allows only `POST /search` and `POST /extract` from the maintained agent runtimes and enables request-body credential rewriting for Hermes. | | `telegram` | Telegram Bot API. | Agent can send messages to any chat the bot token has access to. | Apply presets only when the agent's task requires the integration. @@ -230,7 +230,8 @@ Hermes sends the Tavily placeholder in the JSON `api_key` field, so the `tavily` OpenShell replaces these placeholders only when the request reaches the matching egress policy path. The raw `BRAVE_API_KEY` or `TAVILY_API_KEY` is not written into the sandbox configuration. -The `tavily` preset also restricts egress to the maintained Python, Node.js, and curl paths used by the supported agents and onboarding verifier. +The `tavily` preset restricts agent egress to the maintained Python and Node.js paths used by the supported agents. +Its exact curl paths are used only by onboarding's post-create verifier. Do not replace these paths with a broad `/**` binary rule. Broader binary access would let unrelated sandbox processes send data to Tavily through the same allowed endpoint. diff --git a/nemoclaw-blueprint/policies/presets/tavily.yaml b/nemoclaw-blueprint/policies/presets/tavily.yaml index 6e8ae530290..ef8b5e1d754 100644 --- a/nemoclaw-blueprint/policies/presets/tavily.yaml +++ b/nemoclaw-blueprint/policies/presets/tavily.yaml @@ -16,15 +16,16 @@ network_policies: # Hermes sends the resolver placeholder as the JSON `api_key` field. request_body_credential_rewrite: true rules: - - allow: { method: GET, path: "/**" } - - allow: { method: POST, path: "/**" } + - allow: { method: POST, path: "/search" } + - allow: { method: POST, path: "/extract" } binaries: - # OpenShell attributes Tavily requests to each agent's managed Python - # interpreter. Keep both paths exact so unrelated Python runtimes remain - # outside the Tavily egress boundary. + # Agent runtimes: Deep Agents Code's managed Python (including versioned + # python3 names), Hermes' exact venv Python, and OpenClaw's Node paths. - { path: /opt/venv/bin/python3* } - { path: /opt/hermes/.venv/bin/python } - { path: /usr/local/bin/node } - { path: /usr/bin/node } + # Exact curl paths are used only by onboarding's post-create verifier; + # requests remain constrained by the two POST rules above. - { path: /usr/local/bin/curl } - { path: /usr/bin/curl } diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 4001d537aea..dc60754d496 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -948,11 +948,8 @@ function upsertMessagingProviders( tokenDefs: MessagingTokenDef[], options: { replaceExisting?: boolean } = {}, ) { - braveProviderProfile.ensureWebSearchProviderProfiles(tokenDefs, { - root: ROOT, - runOpenshell, - redact, - }); + // biome-ignore format: keep src/lib/onboard.ts net-neutral for growth guardrail. + braveProviderProfile.ensureWebSearchProviderProfiles(tokenDefs, { root: ROOT, runOpenshell, redact }); const upserted = onboardProviders.upsertMessagingProviders(tokenDefs, runOpenshell, options); // upsertMessagingProviders process.exits on failure, so reaching this // point means every entry in tokenDefs that had a token was registered. @@ -1010,18 +1007,8 @@ const { isAffirmativeAnswer, }); -const { - ensureValidatedWebSearchCredential, - ensureValidatedBraveSearchCredential, - configureWebSearch, - verifyWebSearchInsideSandbox, -} = createWebSearchFlowHelpers({ - prompt, - note, - isNonInteractive, - cliName, - runCaptureOpenshell, -}); +// biome-ignore format: keep src/lib/onboard.ts net-neutral for growth guardrail. +const { ensureValidatedWebSearchCredential, ensureValidatedBraveSearchCredential, configureWebSearch, verifyWebSearchInsideSandbox } = createWebSearchFlowHelpers({ prompt, note, isNonInteractive, cliName, runCaptureOpenshell }); // getSandboxInferenceConfig — moved to onboard-providers.ts diff --git a/src/lib/onboard/machine/core-flow-phases.ts b/src/lib/onboard/machine/core-flow-phases.ts index f31674c4297..9f8f1f16268 100644 --- a/src/lib/onboard/machine/core-flow-phases.ts +++ b/src/lib/onboard/machine/core-flow-phases.ts @@ -118,6 +118,7 @@ export function createCoreOnboardFlowPhases< hermesToolGateways: context.hermesToolGateways, controlUiPort: options.sandbox.controlUiPort, rootDir: options.sandbox.rootDir, + env: options.env, deps: options.sandboxDeps, }); diff --git a/src/lib/onboard/machine/handlers/sandbox.test.ts b/src/lib/onboard/machine/handlers/sandbox.test.ts index 07d0e31a20b..653f095bc76 100644 --- a/src/lib/onboard/machine/handlers/sandbox.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox.test.ts @@ -222,6 +222,7 @@ function baseOptions( hermesToolGateways: [], controlUiPort: null, rootDir: "/repo", + env: {}, deps, }; } @@ -312,7 +313,7 @@ describe("handleSandboxState", () => { ); expect(result.hermesToolGateways).toEqual(["nous-audio"]); expect(calls.note).toHaveBeenCalledWith( - " Tavily Search replaces Hermes managed Web search/extract; keeping the other selected Nous tools.", + " Tavily Search replaces Hermes managed Web search/extract and removes the conflicting nous-web selection.", ); expect(calls.complete).toHaveBeenCalledWith( "sandbox", @@ -482,14 +483,13 @@ describe("handleSandboxState", () => { agentSupportsWebSearchProvider: () => true, }); - const result = await withEnv("NEMOCLAW_WEB_SEARCH_PROVIDER", "tavily", () => - handleSandboxState({ - ...baseOptions(deps, session), - resume: true, - sandboxName: "saved", - webSearchConfig: { fetchEnabled: true, provider: "brave" }, - }), - ); + const result = await handleSandboxState({ + ...baseOptions(deps, session), + resume: true, + sandboxName: "saved", + webSearchConfig: { fetchEnabled: true, provider: "brave" }, + env: { NEMOCLAW_WEB_SEARCH_PROVIDER: "tavily" }, + }); expect(calls.note).toHaveBeenCalledWith( " [resume] Web Search configuration changed; recreating sandbox.", @@ -532,14 +532,13 @@ describe("handleSandboxState", () => { }); await expect( - withEnv("NEMOCLAW_WEB_SEARCH_PROVIDER", "tavily", () => - handleSandboxState({ - ...baseOptions(deps, session), - resume: true, - sandboxName: "saved", - webSearchConfig: { fetchEnabled: true, provider: "brave" }, - }), - ), + handleSandboxState({ + ...baseOptions(deps, session), + resume: true, + sandboxName: "saved", + webSearchConfig: { fetchEnabled: true, provider: "brave" }, + env: { NEMOCLAW_WEB_SEARCH_PROVIDER: "tavily" }, + }), ).rejects.toThrow("Tavily credential rejected"); expect(calls.removeSandbox).not.toHaveBeenCalled(); diff --git a/src/lib/onboard/machine/handlers/sandbox.ts b/src/lib/onboard/machine/handlers/sandbox.ts index 1e65034f445..74d0ae5fffc 100644 --- a/src/lib/onboard/machine/handlers/sandbox.ts +++ b/src/lib/onboard/machine/handlers/sandbox.ts @@ -46,6 +46,7 @@ export interface SandboxStateOptions< hermesToolGateways: string[]; controlUiPort: number | null; rootDir: string; + env: NodeJS.ProcessEnv; deps: { resolvePath(value: string): string; agentSupportsWebSearch( @@ -172,8 +173,9 @@ interface SandboxStepState { function resolveRequestedWebSearchConfig( current: WebSearchConfig | null, + env: NodeJS.ProcessEnv, ): WebSearchConfig | null { - const explicit = parseExplicitWebSearchProvider(process.env[WEB_SEARCH_PROVIDER_ENV]); + const explicit = parseExplicitWebSearchProvider(env[WEB_SEARCH_PROVIDER_ENV]); if (!explicit.specified) return current; if (!explicit.provider) return null; return { fetchEnabled: true, provider: explicit.provider } as WebSearchConfig; @@ -240,7 +242,10 @@ class SandboxStateFlow< probePath, this.options.rootDir, ); - const requestedWebSearchConfig = resolveRequestedWebSearchConfig(this.options.webSearchConfig); + const requestedWebSearchConfig = resolveRequestedWebSearchConfig( + this.options.webSearchConfig, + this.options.env, + ); const webSearchConfigChanged = !webSearchConfigsEqual( this.options.session?.webSearchConfig, requestedWebSearchConfig as unknown as SharedWebSearchConfig | null, @@ -513,7 +518,7 @@ class SandboxStateFlow< !hermesToolGateways.includes("nous-web") ) { this.deps.note( - " Tavily Search replaces Hermes managed Web search/extract; keeping the other selected Nous tools.", + " Tavily Search replaces Hermes managed Web search/extract and removes the conflicting nous-web selection.", ); } return { diff --git a/src/lib/onboard/messaging-prep.test.ts b/src/lib/onboard/messaging-prep.test.ts index a6cce0a280b..886d3489961 100644 --- a/src/lib/onboard/messaging-prep.test.ts +++ b/src/lib/onboard/messaging-prep.test.ts @@ -84,7 +84,6 @@ describe("prepareCreateSandboxMessaging", () => { }), ); - expect(result.missingBraveApiKey).toBe(true); expect(result.missingWebSearchCredentialEnv).toBe(BRAVE_API_KEY_ENV); expect(result.extraPlaceholderKeys).toEqual([]); expect(result.messagingTokenDefs.some(({ envKey }) => envKey === BRAVE_API_KEY_ENV)).toBe( @@ -101,7 +100,6 @@ describe("prepareCreateSandboxMessaging", () => { }), ); - expect(result.missingBraveApiKey).toBe(false); expect(result.missingWebSearchCredentialEnv).toBe(TAVILY_API_KEY_ENV); expect(result.messagingTokenDefs.some(({ envKey }) => envKey === TAVILY_API_KEY_ENV)).toBe( false, @@ -120,7 +118,6 @@ describe("prepareCreateSandboxMessaging", () => { }), ); - expect(result.missingBraveApiKey).toBe(false); expect(result.missingWebSearchCredentialEnv).toBeNull(); expect(result.hasMessagingTokens).toBe(true); expect(result.messagingTokenDefs).toContainEqual({ @@ -215,7 +212,6 @@ describe("prepareCreateSandboxMessaging", () => { }), ); - expect(result.missingBraveApiKey).toBe(false); expect(result.messagingTokenDefs).toContainEqual({ name: "demo-brave-search", envKey: BRAVE_API_KEY_ENV, diff --git a/src/lib/onboard/messaging-prep.ts b/src/lib/onboard/messaging-prep.ts index 6f45be814c2..fa9079a9755 100644 --- a/src/lib/onboard/messaging-prep.ts +++ b/src/lib/onboard/messaging-prep.ts @@ -46,8 +46,6 @@ export interface CreateSandboxMessagingPrepResult { reusableMessagingProviders: string[]; reusableMessagingChannels: string[]; missingWebSearchCredentialEnv: string | null; - /** @deprecated Compatibility alias for older callers and tests. */ - missingBraveApiKey: boolean; } export function prepareCreateSandboxMessaging( @@ -87,7 +85,6 @@ export function prepareCreateSandboxMessaging( : null; const missingWebSearchCredentialEnv = webSearchEnabled && !webSearchApiKey ? webSearchCredentialEnv : null; - const missingBraveApiKey = missingWebSearchCredentialEnv === webSearch.BRAVE_API_KEY_ENV; if (missingWebSearchCredentialEnv) { return { disabledChannelNames, @@ -97,7 +94,6 @@ export function prepareCreateSandboxMessaging( reusableMessagingProviders: [], reusableMessagingChannels: [], missingWebSearchCredentialEnv, - missingBraveApiKey, }; } @@ -143,6 +139,5 @@ export function prepareCreateSandboxMessaging( reusableMessagingProviders, reusableMessagingChannels, missingWebSearchCredentialEnv, - missingBraveApiKey, }; } diff --git a/src/lib/onboard/sandbox-messaging-preflight.test.ts b/src/lib/onboard/sandbox-messaging-preflight.test.ts index 4128633e6d1..c6747c70962 100644 --- a/src/lib/onboard/sandbox-messaging-preflight.test.ts +++ b/src/lib/onboard/sandbox-messaging-preflight.test.ts @@ -23,7 +23,6 @@ function createResult(overrides = {}) { reusableMessagingProviders: [], reusableMessagingChannels: [], missingWebSearchCredentialEnv: null, - missingBraveApiKey: false, ...overrides, }; } @@ -249,7 +248,6 @@ describe("prepareSandboxMessagingPreflight", () => { prepareCreateSandboxMessaging: vi.fn(() => createResult({ missingWebSearchCredentialEnv: "BRAVE_API_KEY", - missingBraveApiKey: true, }), ), }); diff --git a/src/lib/onboard/web-search-flow.test.ts b/src/lib/onboard/web-search-flow.test.ts index 6705212ae69..6bc522f0ecb 100644 --- a/src/lib/onboard/web-search-flow.test.ts +++ b/src/lib/onboard/web-search-flow.test.ts @@ -83,8 +83,16 @@ describe("web search provider validation", () => { expect(configPath).not.toBe(""); expect(args.join(" ")).not.toContain(apiKey); expect(args).toContain(configPath); - expect(fs.statSync(configPath).mode & 0o777).toBe(0o600); - expect(fs.readFileSync(configPath, "utf8")).toContain(header); + const configFd = fs.openSync( + configPath, + fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0), + ); + try { + expect(fs.fstatSync(configFd).mode & 0o777).toBe(0o600); + expect(fs.readFileSync(configFd, "utf8")).toContain(header); + } finally { + fs.closeSync(configFd); + } return { ok: true, httpStatus: 200, @@ -167,7 +175,6 @@ describe("web search provider selection", () => { ).resolves.toEqual({ fetchEnabled: true, provider: "tavily" }); } finally { fs.rmSync(root, { recursive: true, force: true }); - delete process.env.TAVILY_API_KEY; } }); @@ -214,6 +221,7 @@ describe("web search provider selection", () => { getCredential: (envKey: string) => (envKey === "TAVILY_API_KEY" ? "tvly-saved" : null), saveCredential, }); + const processEnvValue = process.env.TAVILY_API_KEY; try { await expect( @@ -221,9 +229,9 @@ describe("web search provider selection", () => { ).resolves.toEqual({ fetchEnabled: true, provider: "tavily" }); expect(saveCredential).toHaveBeenCalledWith("TAVILY_API_KEY", "tvly-saved"); expect(env.TAVILY_API_KEY).toBe("tvly-saved"); + expect(process.env.TAVILY_API_KEY).toBe(processEnvValue); } finally { fs.rmSync(root, { recursive: true, force: true }); - delete process.env.TAVILY_API_KEY; } }); diff --git a/src/lib/onboard/web-search-flow.ts b/src/lib/onboard/web-search-flow.ts index 5ba970f22f6..546d232b3a7 100644 --- a/src/lib/onboard/web-search-flow.ts +++ b/src/lib/onboard/web-search-flow.ts @@ -258,7 +258,6 @@ export function createWebSearchFlowHelpers(deps: WebSearchFlowDeps): WebSearchFl const envKey = webSearchEnvFor(provider); persistCredential(envKey, apiKey); env[envKey] = apiKey; - process.env[envKey] = apiKey; } async function ensureValidatedWebSearchCredential( diff --git a/src/lib/state/openclaw-config-merge.test.ts b/src/lib/state/openclaw-config-merge.test.ts index c933411aa44..2586d172a3f 100644 --- a/src/lib/state/openclaw-config-merge.test.ts +++ b/src/lib/state/openclaw-config-merge.test.ts @@ -352,4 +352,37 @@ describe("mergeOpenClawRestoredConfig", () => { expect(merged.plugins.entries.tavily).toBeUndefined(); expect(merged.plugins.entries.customPlugin).toEqual({ enabled: true }); }); + + it("does not restore managed search state when fresh config omits whole sections", () => { + const merged = mergeOpenClawRestoredConfig( + { + tools: { + customTool: { enabled: true }, + web: { + search: { enabled: true, provider: "tavily" }, + fetch: { enabled: false }, + }, + }, + plugins: { + entries: { + tavily: { + enabled: true, + config: { webSearch: { apiKey: "openshell:resolve:env:TAVILY_API_KEY" } }, + }, + customPlugin: { enabled: true }, + }, + }, + }, + { gateway: { auth: { token: "fresh-token" } } }, + ) as { + tools: { customTool: unknown; web: Record }; + plugins: { entries: Record }; + }; + + expect(merged.tools.web.search).toBeUndefined(); + expect(merged.tools.web.fetch).toEqual({ enabled: false }); + expect(merged.tools.customTool).toEqual({ enabled: true }); + expect(merged.plugins.entries.tavily).toBeUndefined(); + expect(merged.plugins.entries.customPlugin).toEqual({ enabled: true }); + }); }); diff --git a/src/lib/state/openclaw-config-merge.ts b/src/lib/state/openclaw-config-merge.ts index 31a5f63d3d3..d3eaaec3088 100644 --- a/src/lib/state/openclaw-config-merge.ts +++ b/src/lib/state/openclaw-config-merge.ts @@ -129,11 +129,11 @@ function mergeOpenClawEntryMap( function mergeOpenClawTools(backupTools: unknown, currentTools: unknown): unknown { if (!isPlainJsonObject(backupTools)) return cloneJson(currentTools); - if (!isPlainJsonObject(currentTools)) return cloneJson(backupTools); + const current = isPlainJsonObject(currentTools) ? currentTools : {}; - const merged = mergeJsonObjects(currentTools, backupTools); + const merged = mergeJsonObjects(current, backupTools); const backupWeb = isPlainJsonObject(backupTools.web) ? backupTools.web : {}; - const currentWeb = isPlainJsonObject(currentTools.web) ? currentTools.web : {}; + const currentWeb = isPlainJsonObject(current.web) ? current.web : {}; const mergedWeb = mergeJsonObjects(currentWeb, backupWeb); // The fresh generator owns tools.web.search, including omission when web @@ -261,10 +261,10 @@ function mergeOpenClawModels(backupModels: unknown, currentModels: unknown): unk function mergeOpenClawPlugins(backupPlugins: unknown, currentPlugins: unknown): unknown { if (!isPlainJsonObject(backupPlugins)) return cloneJson(currentPlugins); - if (!isPlainJsonObject(currentPlugins)) return cloneJson(backupPlugins); + const current = isPlainJsonObject(currentPlugins) ? currentPlugins : {}; - const merged = mergeJsonObjects(currentPlugins, backupPlugins); - const entries = mergeOpenClawEntryMap(backupPlugins.entries, currentPlugins.entries); + const merged = mergeJsonObjects(current, backupPlugins); + const entries = mergeOpenClawEntryMap(backupPlugins.entries, current.entries); if (entries) merged.entries = entries; return merged; } diff --git a/test/e2e/e2e-cloud-experimental/checks/09-deepagents-code-tavily-opt-in.sh b/test/e2e/e2e-cloud-experimental/checks/09-deepagents-code-tavily-opt-in.sh index 40c0d4db181..3f093dd621f 100755 --- a/test/e2e/e2e-cloud-experimental/checks/09-deepagents-code-tavily-opt-in.sh +++ b/test/e2e/e2e-cloud-experimental/checks/09-deepagents-code-tavily-opt-in.sh @@ -38,6 +38,7 @@ nemoclaw_cli() { python_probe_source() { cat <<'PY' +import json import sys import urllib.error import urllib.request @@ -62,8 +63,14 @@ def is_policy_denial(text): url = sys.argv[1] +request = urllib.request.Request( + url, + data=json.dumps({'query': 'nemoclaw reachability probe', 'max_results': 1}).encode('utf-8'), + headers={'Content-Type': 'application/json'}, + method='POST', +) try: - with urllib.request.urlopen(url, timeout=8) as response: + with urllib.request.urlopen(request, timeout=8) as response: print(f'REACHED:{response.status}') except urllib.error.HTTPError as exc: body = '' @@ -122,7 +129,7 @@ if [ "${NEMOCLAW_E2E_TAVILY_SELF_TEST:-}" = "probe-command-shape" ]; then ;; esac } - python_probe "https://api.tavily.com/" + python_probe "https://api.tavily.com/search" exit 0 fi @@ -161,7 +168,7 @@ pass "tavily policy preset applies" sleep "${NEMOCLAW_E2E_POLICY_SETTLE_SECONDS:-5}" -PROBE_OUTPUT="$(python_probe "https://api.tavily.com/")" +PROBE_OUTPUT="$(python_probe "https://api.tavily.com/search")" if echo "$PROBE_OUTPUT" | grep -q "REACHED:"; then pass "managed Deep Agents Code python can reach Tavily after policy-add" elif echo "$PROBE_OUTPUT" | grep -q "BLOCKED:"; then @@ -170,7 +177,7 @@ else fail_test "Tavily probe lacked reachability evidence after policy-add: $PROBE_OUTPUT" fi -SYSTEM_PROBE_OUTPUT="$(python_probe "https://api.tavily.com/" "/usr/bin/python3" || true)" +SYSTEM_PROBE_OUTPUT="$(python_probe "https://api.tavily.com/search" "/usr/bin/python3" || true)" if echo "$SYSTEM_PROBE_OUTPUT" | grep -q "BLOCKED:" && ! echo "$SYSTEM_PROBE_OUTPUT" | grep -q "REACHED:"; then pass "system Python remains blocked from Tavily after policy-add" elif echo "$SYSTEM_PROBE_OUTPUT" | grep -q "REACHED:"; then @@ -181,7 +188,7 @@ fi PROJECT_OUT="$(sandbox_exec "if ! test -x ${PROJECT_PYTHON@Q}; then python3 -m venv --copies ${PROJECT_VENV@Q}; fi; test -x ${PROJECT_PYTHON@Q} && readlink -f ${PROJECT_PYTHON@Q}" || true)" if echo "$PROJECT_OUT" | grep -Fxq "$PROJECT_PYTHON"; then - PROJECT_PROBE_OUTPUT="$(python_probe "https://api.tavily.com/" "$PROJECT_PYTHON" || true)" + PROJECT_PROBE_OUTPUT="$(python_probe "https://api.tavily.com/search" "$PROJECT_PYTHON" || true)" if echo "$PROJECT_PROBE_OUTPUT" | grep -q "BLOCKED:" && ! echo "$PROJECT_PROBE_OUTPUT" | grep -q "REACHED:"; then pass "project venv Python under /sandbox remains blocked from Tavily after policy-add" elif echo "$PROJECT_PROBE_OUTPUT" | grep -q "REACHED:"; then diff --git a/test/langchain-deepagents-code-image.test.ts b/test/langchain-deepagents-code-image.test.ts index 1edfb5f4d09..a8e527cc845 100644 --- a/test/langchain-deepagents-code-image.test.ts +++ b/test/langchain-deepagents-code-image.test.ts @@ -517,14 +517,18 @@ describe("LangChain Deep Agents Code image contracts", () => { ); expect(tavilyOptInCheck).toContain("policy-add tavily --dry-run"); expect(tavilyOptInCheck).toContain("policy-add tavily --yes"); - expect(tavilyOptInCheck).toContain("https://api.tavily.com/"); + expect(tavilyOptInCheck).toContain("https://api.tavily.com/search"); + expect(tavilyOptInCheck).toContain("urllib.request.Request"); + expect(tavilyOptInCheck).toContain("method='POST'"); expect(tavilyOptInCheck).toContain("python_probe_source"); expect(tavilyOptInCheck).toContain("base64 | tr -d"); expect(tavilyOptInCheck).toContain("${python_bin@Q} -c"); expect(tavilyOptInCheck).toContain("NEMOCLAW_E2E_TAVILY_SELF_TEST"); expect(tavilyOptInCheck).toContain("/opt/venv/"); expect(tavilyOptInCheck).toContain("managed Deep Agents Code python can reach Tavily"); - expect(tavilyOptInCheck).toContain('python_probe "https://api.tavily.com/" "/usr/bin/python3"'); + expect(tavilyOptInCheck).toContain( + 'python_probe "https://api.tavily.com/search" "/usr/bin/python3"', + ); expect(tavilyOptInCheck).toContain( "system Python remains blocked from Tavily after policy-add", ); diff --git a/test/sync-agent-variant-docs.test.ts b/test/sync-agent-variant-docs.test.ts index bc77854a07e..d6241c67036 100644 --- a/test/sync-agent-variant-docs.test.ts +++ b/test/sync-agent-variant-docs.test.ts @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; import { renderHermesCommandsReference } from "../scripts/sync-agent-variant-docs"; @@ -50,4 +51,19 @@ The gateway state path is \`~/.local/state/nemoclaw\`. expect(rendered).not.toContain("~/.local/state/nemohermes"); expect(rendered).not.toContain("nemohermes onboard --agent hermes"); }); + + it("renders Hermes-only web search environment guidance", () => { + const source = readFileSync(new URL("../docs/reference/commands.mdx", import.meta.url), "utf8"); + const rendered = renderHermesCommandsReference(source); + const onboardingStart = rendered.indexOf("### Onboarding Configuration"); + const onboardingEnd = rendered.indexOf("#### Extra placeholder keys", onboardingStart); + const onboarding = rendered.slice(onboardingStart, onboardingEnd); + + expect(onboardingStart).toBeGreaterThanOrEqual(0); + expect(onboardingEnd).toBeGreaterThan(onboardingStart); + expect(onboarding).toContain("| `NEMOCLAW_WEB_SEARCH_PROVIDER` | `tavily` or `none` |"); + expect(onboarding).toContain("| `TAVILY_API_KEY` | Tavily Search API key |"); + expect(onboarding).not.toContain("| `BRAVE_API_KEY` |"); + expect(onboarding).not.toContain("Brave-first precedence"); + }); }); diff --git a/test/tavily-preset.test.ts b/test/tavily-preset.test.ts index 96c5a66b5c4..4e6fa01dc71 100644 --- a/test/tavily-preset.test.ts +++ b/test/tavily-preset.test.ts @@ -10,6 +10,7 @@ type TavilyEndpoint = { port: number; protocol: string; enforcement: string; + access?: string; request_body_credential_rewrite?: boolean; rules: Array<{ allow: { method: string; path: string } }>; tls?: string; @@ -18,7 +19,6 @@ type TavilyEndpoint = { type TavilyPolicy = { endpoints?: TavilyEndpoint[]; binaries?: Array<{ path: string }>; - access?: string; }; describe("tavily opt-in preset", () => { @@ -41,8 +41,8 @@ describe("tavily opt-in preset", () => { enforcement: "enforce", request_body_credential_rewrite: true, rules: [ - { allow: { method: "GET", path: "/**" } }, - { allow: { method: "POST", path: "/**" } }, + { allow: { method: "POST", path: "/search" } }, + { allow: { method: "POST", path: "/extract" } }, ], }, ]); @@ -54,7 +54,7 @@ describe("tavily opt-in preset", () => { { path: "/usr/local/bin/curl" }, { path: "/usr/bin/curl" }, ]); - expect(policy).not.toHaveProperty("access", "full"); + expect(policy?.endpoints?.[0]).not.toHaveProperty("access"); expect(policy?.endpoints?.[0]).not.toHaveProperty("tls", "skip"); }); }); diff --git a/test/validate-blueprint.test.ts b/test/validate-blueprint.test.ts index 96ceafd4745..03b2db97fac 100644 --- a/test/validate-blueprint.test.ts +++ b/test/validate-blueprint.test.ts @@ -575,7 +575,12 @@ describe("Tavily Search provider profile", () => { protocol: "rest", enforcement: "enforce", request_body_credential_rewrite: true, + rules: [ + { allow: { method: "POST", path: "/search" } }, + { allow: { method: "POST", path: "/extract" } }, + ], }); + expect(endpoint).not.toHaveProperty("access"); }); it("preserves Tavily credential rewriting when agent shields are down", () => { @@ -594,6 +599,8 @@ describe("Tavily Search provider profile", () => { access: "full", request_body_credential_rewrite: true, }); + expect(endpoint?.rules).toBeUndefined(); + expect(policy.network_policies?.tavily?.binaries).toEqual([{ path: "/**" }]); } }); }); From 892993a5285413d18e4c73b3180ddd35f5923037 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Thu, 2 Jul 2026 10:25:47 -0700 Subject: [PATCH 3/3] fix(policy): align Tavily provider profile scope Apply exact search and extract rules to provider-derived policy. Document the shields-down exception and keep provisioning tests branch-free. Signed-off-by: Carlos Villela --- agents/hermes/policy-permissive.yaml | 2 ++ agents/openclaw/policy-permissive.yaml | 2 ++ .../policies/openclaw-sandbox-permissive.yaml | 2 ++ .../provider-profiles/tavily-hermes-v1.yaml | 8 ++++- .../provider-profiles/tavily.yaml | 5 ++- test/sandbox-provisioning-tavily.test.ts | 21 ++++++------ test/validate-blueprint.test.ts | 33 +++++++++++++------ 7 files changed, 50 insertions(+), 23 deletions(-) diff --git a/agents/hermes/policy-permissive.yaml b/agents/hermes/policy-permissive.yaml index 8f6ad98ebd6..3d0c51ac35a 100644 --- a/agents/hermes/policy-permissive.yaml +++ b/agents/hermes/policy-permissive.yaml @@ -346,6 +346,8 @@ network_policies: 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: diff --git a/agents/openclaw/policy-permissive.yaml b/agents/openclaw/policy-permissive.yaml index bafbe53bd62..8b468ca5fe0 100644 --- a/agents/openclaw/policy-permissive.yaml +++ b/agents/openclaw/policy-permissive.yaml @@ -318,6 +318,8 @@ network_policies: 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: diff --git a/nemoclaw-blueprint/policies/openclaw-sandbox-permissive.yaml b/nemoclaw-blueprint/policies/openclaw-sandbox-permissive.yaml index 6508883ff08..4535a635462 100644 --- a/nemoclaw-blueprint/policies/openclaw-sandbox-permissive.yaml +++ b/nemoclaw-blueprint/policies/openclaw-sandbox-permissive.yaml @@ -365,6 +365,8 @@ network_policies: 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: diff --git a/nemoclaw-blueprint/provider-profiles/tavily-hermes-v1.yaml b/nemoclaw-blueprint/provider-profiles/tavily-hermes-v1.yaml index 712843b5e5e..45a5693c419 100644 --- a/nemoclaw-blueprint/provider-profiles/tavily-hermes-v1.yaml +++ b/nemoclaw-blueprint/provider-profiles/tavily-hermes-v1.yaml @@ -10,6 +10,9 @@ credentials: env_vars: - TAVILY_API_KEY required: true + # OpenShell stores static placement as profile metadata, while runtime + # injection resolves env placeholders. The Tavily policy independently + # enables JSON-body rewriting for Hermes' native `api_key` field. auth_style: bearer header_name: authorization query_param: '' @@ -17,8 +20,11 @@ endpoints: - host: api.tavily.com port: 443 protocol: rest - access: read-write enforcement: enforce + request_body_credential_rewrite: true + rules: + - allow: { method: POST, path: "/search" } + - allow: { method: POST, path: "/extract" } binaries: - /opt/hermes/.venv/bin/python - /usr/local/bin/curl diff --git a/nemoclaw-blueprint/provider-profiles/tavily.yaml b/nemoclaw-blueprint/provider-profiles/tavily.yaml index 7ec89679359..c5d6cb213f9 100644 --- a/nemoclaw-blueprint/provider-profiles/tavily.yaml +++ b/nemoclaw-blueprint/provider-profiles/tavily.yaml @@ -17,8 +17,11 @@ endpoints: - host: api.tavily.com port: 443 protocol: rest - access: read-write enforcement: enforce + request_body_credential_rewrite: true + rules: + - allow: { method: POST, path: "/search" } + - allow: { method: POST, path: "/extract" } binaries: # OpenShell attributes Deep Agents Code Tavily requests to this managed # Python venv, which its strict Landlock policy mounts read-only. diff --git a/test/sandbox-provisioning-tavily.test.ts b/test/sandbox-provisioning-tavily.test.ts index 66c5a59d0b9..77c542581ee 100644 --- a/test/sandbox-provisioning-tavily.test.ts +++ b/test/sandbox-provisioning-tavily.test.ts @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; @@ -16,19 +17,17 @@ function dockerRunCommandBetween( ): string { const start = dockerfile.indexOf(startMarker); const end = dockerfile.indexOf(endMarker, start); - if (start === -1 || end === -1 || end <= start) { - throw new Error(`Expected Dockerfile block between ${startMarker} and ${endMarker}`); - } + assert( + start !== -1 && end !== -1 && end > start, + `Expected Dockerfile block between ${startMarker} and ${endMarker}`, + ); const runIndex = dockerfile.indexOf("RUN ", start); - if (runIndex === -1 || runIndex > end) { - throw new Error(`Expected RUN instruction after ${startMarker}`); - } - const runLines: string[] = []; - for (const line of dockerfile.slice(runIndex, end).split("\n")) { - runLines.push(line); - if (!line.trimEnd().endsWith("\\")) break; - } + assert(runIndex !== -1 && runIndex <= end, `Expected RUN instruction after ${startMarker}`); + const runLines = dockerfile.slice(runIndex, end).split("\n"); + const finalLine = runLines.findIndex((line) => !line.trimEnd().endsWith("\\")); + assert(finalLine !== -1, `Expected terminated RUN instruction after ${startMarker}`); return runLines + .slice(0, finalLine + 1) .join("\n") .trim() .replace(/^RUN\s+/, "") diff --git a/test/validate-blueprint.test.ts b/test/validate-blueprint.test.ts index 03b2db97fac..77cfcc9e3a4 100644 --- a/test/validate-blueprint.test.ts +++ b/test/validate-blueprint.test.ts @@ -128,6 +128,8 @@ type ProviderProfileEndpoint = { protocol?: string; access?: string; enforcement?: string; + request_body_credential_rewrite?: boolean; + rules?: Rule[]; }; type ProviderProfile = { @@ -518,16 +520,27 @@ describe("Tavily Search provider profile", () => { ]); }); - it("matches the Tavily Search API endpoint used by the policy preset", () => { - expect(profile.endpoints).toEqual([ - expect.objectContaining({ - host: "api.tavily.com", - port: 443, - protocol: "rest", - access: "read-write", - enforcement: "enforce", - }), - ]); + it("keeps both provider policy layers aligned with the least-privilege preset", () => { + const presetEndpoint = preset.network_policies?.tavily?.endpoints?.[0]; + const expectedRules = [ + { allow: { method: "POST", path: "/search" } }, + { allow: { method: "POST", path: "/extract" } }, + ]; + + expect(presetEndpoint?.rules).toEqual(expectedRules); + for (const candidate of [profile, hermesProfile]) { + expect(candidate.endpoints).toEqual([ + { + host: "api.tavily.com", + port: 443, + protocol: "rest", + enforcement: "enforce", + request_body_credential_rewrite: true, + rules: expectedRules, + }, + ]); + expect(candidate.endpoints?.[0]).not.toHaveProperty("access"); + } }); it("limits the binary allowlist to runtimes the Tavily client actually uses", () => {