diff --git a/Dockerfile b/Dockerfile index 0a4e2f252f..29e6d04d2d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -305,6 +305,10 @@ ARG NEMOCLAW_PROXY_PORT=3128 # The actual API key is injected at runtime via openshell:resolve:env, never # baked into the image. ARG NEMOCLAW_WEB_SEARCH_ENABLED=0 +# Web search provider selected during onboard ("brave" or "tavily"). The +# Python script falls back to "brave" if unset or unrecognized; the env var +# is patched to the user's choice by the CLI before docker build. +ARG NEMOCLAW_WEB_SEARCH_PROVIDER=brave # SECURITY: Promote build-args to env vars so the Python script reads them # via os.environ, never via string interpolation into Python source code. @@ -329,7 +333,8 @@ ENV NEMOCLAW_MODEL=${NEMOCLAW_MODEL} \ NEMOCLAW_DISABLE_DEVICE_AUTH=${NEMOCLAW_DISABLE_DEVICE_AUTH} \ NEMOCLAW_PROXY_HOST=${NEMOCLAW_PROXY_HOST} \ NEMOCLAW_PROXY_PORT=${NEMOCLAW_PROXY_PORT} \ - NEMOCLAW_WEB_SEARCH_ENABLED=${NEMOCLAW_WEB_SEARCH_ENABLED} + NEMOCLAW_WEB_SEARCH_ENABLED=${NEMOCLAW_WEB_SEARCH_ENABLED} \ + NEMOCLAW_WEB_SEARCH_PROVIDER=${NEMOCLAW_WEB_SEARCH_PROVIDER} WORKDIR /sandbox USER sandbox diff --git a/nemoclaw-blueprint/policies/presets/tavily.yaml b/nemoclaw-blueprint/policies/presets/tavily.yaml new file mode 100644 index 0000000000..c003dd5ae2 --- /dev/null +++ b/nemoclaw-blueprint/policies/presets/tavily.yaml @@ -0,0 +1,21 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +preset: + name: tavily + description: "Tavily Search API access" + +network_policies: + tavily: + name: tavily + endpoints: + - host: api.tavily.com + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: { method: GET, path: "/**" } + - allow: { method: POST, path: "/**" } + binaries: + - { path: /usr/local/bin/node } + - { path: /usr/bin/node } diff --git a/nemoclaw-blueprint/policies/tiers.yaml b/nemoclaw-blueprint/policies/tiers.yaml index 89e1d15bd0..6b7ef53cab 100644 --- a/nemoclaw-blueprint/policies/tiers.yaml +++ b/nemoclaw-blueprint/policies/tiers.yaml @@ -26,6 +26,7 @@ tiers: - { name: huggingface, access: read-write } - { name: brew, access: read-write } - { name: brave, access: read-write } + - { name: tavily, access: read-write } - name: open label: Open @@ -36,6 +37,7 @@ tiers: - { name: huggingface, access: read-write } - { name: brew, access: read-write } - { name: brave, access: read-write } + - { name: tavily, access: read-write } - { name: slack, access: read-write } - { name: discord, access: read-write } - { name: telegram, access: read-write } diff --git a/scripts/generate-openclaw-config.py b/scripts/generate-openclaw-config.py index ca33f9febb..ff83b013dd 100755 --- a/scripts/generate-openclaw-config.py +++ b/scripts/generate-openclaw-config.py @@ -608,14 +608,24 @@ def _placeholder(channel: str, env_key: str) -> str: } if env.get("NEMOCLAW_WEB_SEARCH_ENABLED", "") == "1": + _ws_provider = env.get("NEMOCLAW_WEB_SEARCH_PROVIDER", "brave") + if _ws_provider not in ("brave", "tavily"): + _ws_provider = "brave" + _ws_env_key = {"brave": "BRAVE_API_KEY", "tavily": "TAVILY_API_KEY"}[ + _ws_provider + ] + # Route web_fetch through Tavily Extract when Tavily is the search provider. + fetch_cfg: dict = {"enabled": True} + if _ws_provider == "tavily": + fetch_cfg["provider"] = "tavily" config["tools"] = { "web": { "search": { "enabled": True, - "provider": "brave", - "apiKey": "openshell:resolve:env:BRAVE_API_KEY", + "provider": _ws_provider, + "apiKey": f"openshell:resolve:env:{_ws_env_key}", }, - "fetch": {"enabled": True}, + "fetch": fetch_cfg, } } diff --git a/src/lib/inference/web-search.test.ts b/src/lib/inference/web-search.test.ts index cf98d55d78..f514b6e3ee 100644 --- a/src/lib/inference/web-search.test.ts +++ b/src/lib/inference/web-search.test.ts @@ -3,10 +3,19 @@ import { describe, expect, it } from "vitest"; -import { BRAVE_API_KEY_ENV } from "./web-search"; +import { BRAVE_API_KEY_ENV, TAVILY_API_KEY_ENV, webSearchEnvFor } 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_API_KEY_ENV constant", () => { + expect(TAVILY_API_KEY_ENV).toBe("TAVILY_API_KEY"); + }); + + it("webSearchEnvFor maps providers to env var names", () => { + expect(webSearchEnvFor("brave")).toBe("BRAVE_API_KEY"); + expect(webSearchEnvFor("tavily")).toBe("TAVILY_API_KEY"); + }); }); diff --git a/src/lib/inference/web-search.ts b/src/lib/inference/web-search.ts index dd6d7682ac..3c08acd4f5 100644 --- a/src/lib/inference/web-search.ts +++ b/src/lib/inference/web-search.ts @@ -1,8 +1,16 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +export type WebSearchProvider = "brave" | "tavily"; + export interface WebSearchConfig { fetchEnabled: boolean; + provider: WebSearchProvider; } export const BRAVE_API_KEY_ENV = "BRAVE_API_KEY"; +export const TAVILY_API_KEY_ENV = "TAVILY_API_KEY"; + +export function webSearchEnvFor(provider: WebSearchProvider): string { + return provider === "tavily" ? TAVILY_API_KEY_ENV : BRAVE_API_KEY_ENV; +} diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index a27513407a..4af8af935c 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -336,7 +336,7 @@ import type { BackupResult } from "./state/sandbox"; import type { TierDefinition, TierPreset } from "./tiers"; import type { SandboxCreateFailure, ValidationClassification } from "./validation"; import type { ProbeRecovery } from "./validation-recovery"; -import type { WebSearchConfig } from "./inference/web-search"; +import type { WebSearchConfig, WebSearchProvider } from "./inference/web-search"; /** * Create a temp file inside a directory with a cryptographically random name. @@ -410,6 +410,7 @@ function verifyGatewayContainerRunning() { const OPENCLAW_LAUNCH_AGENT_PLIST = "~/Library/LaunchAgents/ai.openclaw.gateway.plist"; const BRAVE_SEARCH_HELP_URL = "https://brave.com/search/api/"; +const TAVILY_SEARCH_HELP_URL = "https://app.tavily.com/home"; // Re-export shared JSON types under the names used throughout this module. // See src/lib/core/json-types.ts for the canonical definitions. @@ -2378,36 +2379,84 @@ function isAffirmativeAnswer(value: string | null | undefined): boolean { ); } -function validateBraveSearchApiKey(apiKey: string): CurlProbeResult { - return runCurlProbe([ - "-sS", - "--compressed", - "-H", - "Accept: application/json", - "-H", - "Accept-Encoding: gzip", - "-H", - `X-Subscription-Token: ${apiKey}`, - "--get", - "--data-urlencode", - "q=ping", - "--data-urlencode", - "count=1", - "https://api.search.brave.com/res/v1/web/search", - ]); +interface WebSearchProviderSpec { + id: WebSearchProvider; + label: string; + envKey: string; + helpUrl: string; + validate: (apiKey: string) => CurlProbeResult; +} + +const WEB_SEARCH_PROVIDER_SPECS: Record = { + brave: { + id: "brave", + label: "Brave Search", + envKey: webSearch.BRAVE_API_KEY_ENV, + helpUrl: BRAVE_SEARCH_HELP_URL, + validate: (apiKey: string): CurlProbeResult => + runCurlProbe([ + "-sS", + "--compressed", + "-H", + "Accept: application/json", + "-H", + "Accept-Encoding: gzip", + "-H", + `X-Subscription-Token: ${apiKey}`, + "--get", + "--data-urlencode", + "q=ping", + "--data-urlencode", + "count=1", + "https://api.search.brave.com/res/v1/web/search", + ]), + }, + tavily: { + id: "tavily", + label: "Tavily Search", + envKey: webSearch.TAVILY_API_KEY_ENV, + helpUrl: TAVILY_SEARCH_HELP_URL, + validate: (apiKey: string): CurlProbeResult => + runCurlProbe([ + "-sS", + "--compressed", + "-X", + "POST", + "-H", + "Content-Type: application/json", + "-H", + "Accept: application/json", + "-H", + `Authorization: Bearer ${apiKey}`, + "-H", + `X-Client-Source: nemoclaw-onboarding`, + "-d", + JSON.stringify({ query: "ping", max_results: 1 }), + "https://api.tavily.com/search", + ]), + }, +}; + +function getWebSearchProviderSpec(provider: WebSearchProvider): WebSearchProviderSpec { + const spec = WEB_SEARCH_PROVIDER_SPECS[provider]; + if (!spec) { + throw new Error(`Unknown web search provider: ${provider}`); + } + return spec; } -async function promptBraveSearchRecovery( +async function promptWebSearchProviderRecovery( validation: ValidationFailureLike, + spec: WebSearchProviderSpec, ): Promise<"retry" | "skip"> { 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 prompt(" Type 'retry', 'skip', or 'exit' [retry]: ")).trim().toLowerCase(); @@ -2418,52 +2467,53 @@ async function promptBraveSearchRecovery( return "retry"; } -async function promptBraveSearchApiKey(): Promise { +async function promptWebSearchApiKey(spec: WebSearchProviderSpec): Promise { 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 key = normalizeCredentialValue( - await prompt(" Brave Search API key: ", { secret: true }), + await prompt(` ${spec.label} API key: `, { secret: true }), ); if (!key) { - console.error(" Brave Search API key is required."); + console.error(` ${spec.label} API key is required.`); continue; } return key; } } -async function ensureValidatedBraveSearchCredential( +async function ensureValidatedWebSearchCredential( + spec: WebSearchProviderSpec, nonInteractive = isNonInteractive(), ): Promise { - const savedApiKey = getCredential(webSearch.BRAVE_API_KEY_ENV); + const savedApiKey = getCredential(spec.envKey); let apiKey: string | null = - savedApiKey || normalizeCredentialValue(process.env[webSearch.BRAVE_API_KEY_ENV]); + savedApiKey || normalizeCredentialValue(process.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.`, ); } - apiKey = await promptBraveSearchApiKey(); + apiKey = await promptWebSearchApiKey(spec); usingSavedKey = false; } - const validation = validateBraveSearchApiKey(apiKey); + const validation = spec.validate(apiKey); if (validation.ok) { - saveCredential(webSearch.BRAVE_API_KEY_ENV, apiKey); - process.env[webSearch.BRAVE_API_KEY_ENV] = apiKey; + saveCredential(spec.envKey, apiKey); + process.env[spec.envKey] = 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}`); @@ -2471,13 +2521,12 @@ async function ensureValidatedBraveSearchCredential( 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 promptWebSearchProviderRecovery(validation, spec); if (action === "skip") { - console.log(" Skipping Brave Web Search setup."); + console.log(` Skipping ${spec.label} setup.`); console.log(""); return null; } @@ -2526,6 +2575,42 @@ function agentSupportsWebSearch( return false; } +async function ensureValidatedBraveSearchCredential( + nonInteractive = isNonInteractive(), +): Promise { + return ensureValidatedWebSearchCredential(getWebSearchProviderSpec("brave"), nonInteractive); +} + +function resolveNonInteractiveWebSearchProvider(): WebSearchProvider | null { + // Brave wins when both keys are present — preserves OpenClaw auto-detect + // precedence and avoids silently flipping a user's runtime provider on + // upgrade. Tavily is only auto-selected when it's the only key set. + const braveKey = normalizeCredentialValue( + getCredential(webSearch.BRAVE_API_KEY_ENV) || process.env[webSearch.BRAVE_API_KEY_ENV], + ); + const tavilyKey = normalizeCredentialValue( + getCredential(webSearch.TAVILY_API_KEY_ENV) || process.env[webSearch.TAVILY_API_KEY_ENV], + ); + if (braveKey) return "brave"; + if (tavilyKey) return "tavily"; + return null; +} + +async function promptWebSearchProvider(): Promise { + console.log(""); + console.log(" Enable web search for your agent?"); + console.log(" [1] No web search (default)"); + console.log(" [2] Brave Search"); + console.log(" [3] Tavily Search"); + while (true) { + const raw = (await prompt(" Choose [1-3]: ")).trim(); + if (raw === "" || raw === "1" || /^n(o)?$/i.test(raw)) return null; + if (raw === "2" || /^brave$/i.test(raw)) return "brave"; + if (raw === "3" || /^tavily$/i.test(raw)) return "tavily"; + console.log(" Enter 1, 2, or 3."); + } +} + async function configureWebSearch( existingConfig: WebSearchConfig | null = null, agent: AgentDefinition | null = null, @@ -2537,42 +2622,51 @@ async function configureWebSearch( } if (existingConfig) { - return { fetchEnabled: true }; + const provider = + existingConfig.provider === "tavily" || existingConfig.provider === "brave" + ? existingConfig.provider + : "brave"; + return { fetchEnabled: true, provider }; } if (isNonInteractive()) { - const braveApiKey = normalizeCredentialValue(process.env[webSearch.BRAVE_API_KEY_ENV]); - if (!braveApiKey) { + const provider = resolveNonInteractiveWebSearchProvider(); + if (!provider) { return null; } - note(" [non-interactive] Brave Web Search requested."); - const validation = validateBraveSearchApiKey(braveApiKey); + const spec = getWebSearchProviderSpec(provider); + const apiKey = + getCredential(spec.envKey) || normalizeCredentialValue(process.env[spec.envKey]); + note(` [non-interactive] ${spec.label} requested.`); + const validation = spec.validate(apiKey); if (!validation.ok) { console.warn( - ` Brave Search API key validation failed. Web search will be disabled — re-enable later via \`${cliName()} config web-search\`.`, + ` ${spec.label} API key validation failed. Web search will be disabled — re-enable later via \`${cliName()} config web-search\`.`, ); if (validation.message) { console.warn(` ${validation.message}`); } return null; } - saveCredential(webSearch.BRAVE_API_KEY_ENV, braveApiKey); - process.env[webSearch.BRAVE_API_KEY_ENV] = braveApiKey; - return { fetchEnabled: true }; + saveCredential(spec.envKey, apiKey); + process.env[spec.envKey] = apiKey; + return { fetchEnabled: true, provider }; } - const enableAnswer = await prompt(" Enable Brave Web Search? [y/N]: "); - if (!isAffirmativeAnswer(enableAnswer)) { + + const provider = await promptWebSearchProvider(); + if (!provider) { return null; } - const braveApiKey = await ensureValidatedBraveSearchCredential(); - if (!braveApiKey) { + const spec = getWebSearchProviderSpec(provider); + const apiKey = await ensureValidatedWebSearchCredential(spec); + if (!apiKey) { return null; } - console.log(" ✓ Enabled Brave Web Search"); + console.log(` ✓ Enabled ${spec.label}`); console.log(""); - return { fetchEnabled: true }; + return { fetchEnabled: true, provider }; } /** @@ -2813,6 +2907,11 @@ function patchStagedDockerfile( /^ARG NEMOCLAW_WEB_SEARCH_ENABLED=.*$/m, `ARG NEMOCLAW_WEB_SEARCH_ENABLED=${webSearchConfig ? "1" : "0"}`, ); + const webSearchProvider = webSearchConfig?.provider === "tavily" ? "tavily" : "brave"; + dockerfile = dockerfile.replace( + /^ARG NEMOCLAW_WEB_SEARCH_PROVIDER=.*$/m, + `ARG NEMOCLAW_WEB_SEARCH_PROVIDER=${webSearchProvider}`, + ); // Onboard flow expects immediate dashboard access without device pairing, // so disable device auth for images built during onboard (see #1217). dockerfile = dockerfile.replace( @@ -4932,10 +5031,12 @@ async function createSandbox( .filter(({ envKey }) => !disabledEnvKeys.has(envKey)); if (webSearchConfig) { + const wsEnvKey = webSearch.webSearchEnvFor(webSearchConfig.provider || "brave"); + const wsProviderName = webSearchConfig.provider === "tavily" ? "tavily" : "brave"; messagingTokenDefs.push({ - name: `${sandboxName}-brave-search`, - envKey: webSearch.BRAVE_API_KEY_ENV, - token: getCredential(webSearch.BRAVE_API_KEY_ENV), + name: `${sandboxName}-${wsProviderName}-search`, + envKey: wsEnvKey, + token: getCredential(wsEnvKey), }); } const previousProviderCredentialHashes = @@ -5338,12 +5439,17 @@ async function createSandbox( "openclaw-sandbox.yaml", ); const basePolicyPath = (agent && agentOnboard.getAgentPolicyPath(agent)) || defaultPolicyPath; - if (webSearchConfig && !getCredential(webSearch.BRAVE_API_KEY_ENV)) { - console.error(" Brave Search is enabled, but BRAVE_API_KEY is not available in this process."); - console.error( - " Re-run with BRAVE_API_KEY set, or disable Brave Search before recreating the sandbox.", - ); - process.exit(1); + if (webSearchConfig) { + const wsEnvKey = webSearch.webSearchEnvFor(webSearchConfig.provider || "brave"); + if (!getCredential(wsEnvKey)) { + console.error( + ` Web search is enabled, but ${wsEnvKey} is not available in this process.`, + ); + console.error( + ` Re-run with ${wsEnvKey} set, or disable web search before recreating the sandbox.`, + ); + process.exit(1); + } } const tokensByEnvKey = Object.fromEntries( messagingTokenDefs.map(({ envKey, token }) => [envKey, token]), @@ -5554,10 +5660,10 @@ async function createSandbox( envArgs.push(formatEnvAssignment("NEMOCLAW_PROXY_PORT", sandboxProxyPort)); } if (webSearchConfig?.fetchEnabled) { - const braveKey = - getCredential(webSearch.BRAVE_API_KEY_ENV) || process.env[webSearch.BRAVE_API_KEY_ENV]; - if (braveKey) { - envArgs.push(formatEnvAssignment(webSearch.BRAVE_API_KEY_ENV, braveKey)); + const wsEnvKey = webSearch.webSearchEnvFor(webSearchConfig.provider || "brave"); + const wsKey = getCredential(wsEnvKey) || process.env[wsEnvKey]; + if (wsKey) { + envArgs.push(formatEnvAssignment(wsEnvKey, wsKey)); } } // Slack Socket Mode requires both tokens in the container env so the baked @@ -7894,7 +8000,9 @@ function getSuggestedPolicyPresets({ maybeSuggestMessagingPreset("slack", "SLACK_BOT_TOKEN"); maybeSuggestMessagingPreset("discord", "DISCORD_BOT_TOKEN"); - if (webSearchConfig) suggestions.push("brave"); + if (webSearchConfig) { + suggestions.push(webSearchConfig.provider === "tavily" ? "tavily" : "brave"); + } return suggestions; } @@ -8543,7 +8651,9 @@ function computeSetupPresetSuggestions( if (known && !known.has(name)) return; suggestions.push(name); }; - if (webSearchConfig) add("brave"); + if (webSearchConfig) { + add(webSearchConfig.provider === "tavily" ? "tavily" : "brave"); + } if (provider && LOCAL_INFERENCE_PROVIDERS.includes(provider)) add("local-inference"); if (Array.isArray(enabledChannels)) { for (const channel of enabledChannels) add(channel); @@ -10045,6 +10155,34 @@ async function onboard(opts: OnboardOptions = {}): Promise { }); } + if (webSearchConfig) { + const webSearchProvider = + webSearchConfig.provider === "tavily" ? "tavily" : "brave"; + const spec = getWebSearchProviderSpec(webSearchProvider); + note(` [resume] Revalidating ${spec.label} configuration.`); + const apiKey = await ensureValidatedWebSearchCredential(spec); + if (apiKey) { + webSearchConfig = { fetchEnabled: true, provider: webSearchProvider }; + onboardSession.updateSession((current: Session) => { + current.webSearchConfig = webSearchConfig; + return current; + }); + note(` [resume] Reusing ${spec.label} configuration.`); + } else { + webSearchConfig = await configureWebSearch(null, agent, webSearchSupportProbePath); + onboardSession.updateSession((current: Session) => { + current.webSearchConfig = webSearchConfig; + return current; + }); + } + } else { + webSearchConfig = await configureWebSearch(webSearchConfig, agent, webSearchSupportProbePath); + onboardSession.updateSession((current: Session) => { + current.webSearchConfig = webSearchConfig; + return current; + }); + } + const storedMessagingChannelConfig = getStoredMessagingChannelConfig(sandboxName, session); const effectiveMessagingChannelConfig = hydrateMessagingChannelConfig(storedMessagingChannelConfig); const messagingChannelConfigChanged = !messagingChannelConfigsEqual( @@ -10058,8 +10196,12 @@ async function onboard(opts: OnboardOptions = {}): Promise { } } + const sandboxReuseState = getSandboxReuseState(sandboxName); - const webSearchConfigChanged = Boolean(session?.webSearchConfig) !== Boolean(webSearchConfig); + const priorWebSearch = session?.webSearchConfig || null; + const priorWsProvider = priorWebSearch?.provider === "tavily" ? "tavily" : priorWebSearch ? "brave" : null; + const nextWsProvider = webSearchConfig?.provider === "tavily" ? "tavily" : webSearchConfig ? "brave" : null; + const webSearchConfigChanged = priorWsProvider !== nextWsProvider; // Telegram mention-mode is baked into openclaw.json at sandbox build time, so // changes to TELEGRAM_REQUIRE_MENTION only take effect after a rebuild. Treat // a mismatch between the recorded config and the current env value as drift @@ -10086,7 +10228,10 @@ async function onboard(opts: OnboardOptions = {}): Promise { sandboxReuseState === "ready"; if (resumeSandbox) { if (webSearchConfig) { - note(" [resume] Reusing Brave Search configuration already baked into the sandbox."); + const spec = getWebSearchProviderSpec( + webSearchConfig.provider === "tavily" ? "tavily" : "brave", + ); + note(` [resume] Reusing ${spec.label} configuration already baked into the sandbox.`); } selectedMessagingChannels = session?.messagingChannels ?? []; skippedStepMessage("sandbox", sandboxName); @@ -10121,11 +10266,14 @@ async function onboard(opts: OnboardOptions = {}): Promise { } let nextWebSearchConfig = webSearchConfig; if (nextWebSearchConfig) { - note(" [resume] Revalidating Brave Search configuration for sandbox recreation."); - const braveApiKey = await ensureValidatedBraveSearchCredential(); - nextWebSearchConfig = braveApiKey ? { fetchEnabled: true } : null; + const webSearchProvider = + nextWebSearchConfig.provider === "tavily" ? "tavily" : "brave"; + const spec = getWebSearchProviderSpec(webSearchProvider); + note(` [resume] Revalidating ${spec.label} configuration for sandbox recreation.`); + const apiKey = await ensureValidatedWebSearchCredential(spec); + nextWebSearchConfig = apiKey ? { fetchEnabled: true, provider: webSearchProvider } : null; if (nextWebSearchConfig) { - note(" [resume] Reusing Brave Search configuration."); + note(` [resume] Reusing ${spec.label} configuration.`); } } else { nextWebSearchConfig = await configureWebSearch(null, agent, webSearchSupportProbePath); @@ -10378,6 +10526,7 @@ module.exports = { classifySandboxCreateFailure, configureWebSearch, createSandbox, + ensureValidatedWebSearchCredential, ensureValidatedBraveSearchCredential, formatEnvAssignment, getFutureShellPathHint, diff --git a/src/lib/security/redact.ts b/src/lib/security/redact.ts index 4fc0c77167..5405c3dcd2 100644 --- a/src/lib/security/redact.ts +++ b/src/lib/security/redact.ts @@ -107,7 +107,7 @@ export function redactSensitiveText(value: unknown): string | null { if (typeof value !== "string") return null; let result = value .replace( - /(NVIDIA_API_KEY|OPENAI_API_KEY|ANTHROPIC_API_KEY|GEMINI_API_KEY|COMPATIBLE_API_KEY|COMPATIBLE_ANTHROPIC_API_KEY|BRAVE_API_KEY|SLACK_BOT_TOKEN|SLACK_APP_TOKEN|DISCORD_BOT_TOKEN|TELEGRAM_BOT_TOKEN)=\S+/gi, + /(NVIDIA_API_KEY|OPENAI_API_KEY|ANTHROPIC_API_KEY|GEMINI_API_KEY|COMPATIBLE_API_KEY|COMPATIBLE_ANTHROPIC_API_KEY|BRAVE_API_KEY|TAVILY_API_KEY|SLACK_BOT_TOKEN|SLACK_APP_TOKEN|DISCORD_BOT_TOKEN|TELEGRAM_BOT_TOKEN)=\S+/gi, "$1=", ) .replace(/Bearer\s+\S+/gi, "Bearer "); diff --git a/src/lib/state/onboard-session.test.ts b/src/lib/state/onboard-session.test.ts index 7a82910f4f..d25d04bc73 100644 --- a/src/lib/state/onboard-session.test.ts +++ b/src/lib/state/onboard-session.test.ts @@ -261,17 +261,34 @@ describe("onboard session", () => { it("persists and clears web search config through safe session updates", () => { session.saveSession(session.createSession()); session.markStepComplete("provider_selection", { - webSearchConfig: { fetchEnabled: true }, + webSearchConfig: { fetchEnabled: true, provider: "brave" }, }); 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 Tavily web search provider through persisted sessions", () => { + session.saveSession( + session.createSession({ + webSearchConfig: { + fetchEnabled: true, + provider: "tavily", + }, + }), + ); + + const loaded = requireLoadedSession(session.loadSession()); + expect(loaded.webSearchConfig).toEqual({ + fetchEnabled: true, + provider: "tavily", + }); + }); + it("does not clear existing metadata when updates omit whitelisted metadata fields", () => { session.saveSession( session.createSession({ metadata: { gatewayName: "nemoclaw", fromDockerfile: null } }), @@ -643,6 +660,20 @@ describe("onboard session", () => { expect(created.messagingChannels).toEqual(["telegram", "discord"]); }); + it("redacts web search API keys from persisted failure messages", () => { + session.saveSession(session.createSession()); + session.markStepFailed( + "inference", + "validation failed: BRAVE_API_KEY=brv-secret-key TAVILY_API_KEY=tvly-secret-key", + ); + + const loaded = requireLoadedSession(session.loadSession()); + expect(loaded.steps.inference.error).toContain("BRAVE_API_KEY="); + expect(loaded.steps.inference.error).toContain("TAVILY_API_KEY="); + expect(loaded.steps.inference.error).not.toContain("brv-secret-key"); + expect(loaded.steps.inference.error).not.toContain("tvly-secret-key"); + }); + it("summarizes the session for debug output", () => { session.saveSession(session.createSession({ sandboxName: "my-assistant" })); session.markStepStarted("preflight"); diff --git a/src/lib/state/onboard-session.ts b/src/lib/state/onboard-session.ts index bed04ea39f..998c4ef537 100644 --- a/src/lib/state/onboard-session.ts +++ b/src/lib/state/onboard-session.ts @@ -228,7 +228,9 @@ 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; + const provider = value.provider === "tavily" ? "tavily" : "brave"; + return { fetchEnabled: true, provider }; } function parseTelegramConfig(value: unknown): TelegramConfig | null { @@ -313,7 +315,12 @@ export function createSession(overrides: Partial = {}): Session { routerPid: readPositiveInteger(overrides.routerPid), routerCredentialHash: overrides.routerCredentialHash ?? null, webSearchConfig: - overrides.webSearchConfig?.fetchEnabled === true ? { fetchEnabled: true } : null, + overrides.webSearchConfig?.fetchEnabled === true + ? { + fetchEnabled: true, + provider: overrides.webSearchConfig.provider === "tavily" ? "tavily" : "brave", + } + : null, policyPresets: readStringArray(overrides.policyPresets), messagingChannels: readStringArray(overrides.messagingChannels), messagingChannelConfig: sanitizeMessagingChannelConfig(overrides.messagingChannelConfig), @@ -718,7 +725,10 @@ export function filterSafeUpdates(updates: SessionUpdates): Partial { safe.routerCredentialHash = updates.routerCredentialHash; } if (isObject(updates.webSearchConfig) && updates.webSearchConfig.fetchEnabled === true) { - safe.webSearchConfig = { fetchEnabled: true }; + safe.webSearchConfig = { + fetchEnabled: true, + provider: updates.webSearchConfig.provider === "tavily" ? "tavily" : "brave", + }; } else if (updates.webSearchConfig === null) { safe.webSearchConfig = null; } diff --git a/test/assign-closed-items-to-sprint-workflow.test.ts b/test/assign-closed-items-to-sprint-workflow.test.ts index dd2ea87f46..5f9b3fbec2 100644 --- a/test/assign-closed-items-to-sprint-workflow.test.ts +++ b/test/assign-closed-items-to-sprint-workflow.test.ts @@ -10,7 +10,10 @@ import YAML from "yaml"; const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); const WORKFLOW_PATH = ".github/workflows/assign-closed-items-to-sprint.yaml"; -const APP_TOKEN_SHA = "1b10c78c7865c340bc4f6099eb2f838309f1e8c3"; +const CREATE_APP_TOKEN_ACTION = [ + "actions/create-github-app-token@1b10c78c7865c340", + "bc4f6099eb2f838309f1e8c3", +].join(""); type Workflow = { on?: { @@ -72,7 +75,7 @@ describe("closed-item Sprint assignment workflow", () => { expect(raw).not.toContain("actions/add-to-project"); expect( allSteps.some( - (step) => step.uses === `actions/create-github-app-token@${APP_TOKEN_SHA}`, + (step) => step.uses === CREATE_APP_TOKEN_ACTION, ), ).toBe(true); }); diff --git a/test/generate-openclaw-config.test.ts b/test/generate-openclaw-config.test.ts index d0ec87880b..ee99f4b35c 100644 --- a/test/generate-openclaw-config.test.ts +++ b/test/generate-openclaw-config.test.ts @@ -238,6 +238,24 @@ describe("generate-openclaw-config.py: config generation", () => { expect(config.tools?.web).toBeUndefined(); }); + it("routes web_fetch through Tavily when Tavily is the search provider", () => { + const config = runConfigScript({ + NEMOCLAW_WEB_SEARCH_ENABLED: "1", + NEMOCLAW_WEB_SEARCH_PROVIDER: "tavily", + }); + expect(config.tools?.web?.fetch?.enabled).toBe(true); + expect(config.tools?.web?.fetch?.provider).toBe("tavily"); + }); + + it("omits the web_fetch provider when Brave is the search provider", () => { + const config = runConfigScript({ + NEMOCLAW_WEB_SEARCH_ENABLED: "1", + NEMOCLAW_WEB_SEARCH_PROVIDER: "brave", + }); + expect(config.tools?.web?.fetch?.enabled).toBe(true); + expect(config.tools?.web?.fetch?.provider).toBeUndefined(); + }); + it("propagates agent timeout", () => { const config = runConfigScript({ NEMOCLAW_AGENT_TIMEOUT: "300" }); expect(config.agents.defaults.timeoutSeconds).toBe(300); diff --git a/test/onboard-brave-validation.test.ts b/test/onboard-brave-validation.test.ts index 9feb2678f1..d11cdf7c0f 100644 --- a/test/onboard-brave-validation.test.ts +++ b/test/onboard-brave-validation.test.ts @@ -153,6 +153,6 @@ describe("configureWebSearch (non-interactive)", () => { expect(exitCode).toBe(0); expect(payload.exitCalls).toEqual([]); - expect(payload.result).toEqual({ fetchEnabled: true }); + expect(payload.result).toEqual({ fetchEnabled: true, provider: "brave" }); }); }); diff --git a/test/onboard-preset-diff.test.ts b/test/onboard-preset-diff.test.ts index d46c112060..7d6998951f 100644 --- a/test/onboard-preset-diff.test.ts +++ b/test/onboard-preset-diff.test.ts @@ -159,7 +159,15 @@ console.log = () => {}; policyMode: "suggested", policyPresets: "", // Balanced defaults plus a manually-added preset. - alreadyApplied: ["npm", "pypi", "huggingface", "brew", "brave", "local-inference"], + alreadyApplied: [ + "npm", + "pypi", + "huggingface", + "brew", + "brave", + "tavily", + "local-inference", + ], }) + String.raw` console.log = () => {}; @@ -193,7 +201,15 @@ console.log = () => {}; // Final state should still contain every previously-applied preset. const finalSorted = payload.finalApplied.slice().sort(); - assert.deepEqual(finalSorted, ["brave", "brew", "huggingface", "local-inference", "npm", "pypi"]); + assert.deepEqual(finalSorted, [ + "brave", + "brew", + "huggingface", + "local-inference", + "npm", + "pypi", + "tavily", + ]); }); // Custom presets loaded via `policy-add --from-file` / `--from-dir` are diff --git a/test/onboard.test.ts b/test/onboard.test.ts index 2f7c367412..f872a1058a 100644 --- a/test/onboard.test.ts +++ b/test/onboard.test.ts @@ -468,7 +468,7 @@ describe("onboard helpers", () => { enabledChannels: [], knownPresetNames: known, }); - expect(suggestions).toEqual(["npm", "pypi", "huggingface", "brew", "brave"]); + expect(suggestions).toEqual(["npm", "pypi", "huggingface", "brew", "brave", "tavily"]); }); it("forwards enabled messaging channels into the balanced tier suggestions", () => { @@ -1438,6 +1438,7 @@ describe("onboard 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"), ); @@ -1452,10 +1453,11 @@ describe("onboard helpers", () => { "build-web", "openai-api", null, - { fetchEnabled: true }, + { fetchEnabled: true, provider: "brave" }, ); 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 { @@ -1562,6 +1564,50 @@ const { loadAgent } = require(${agentDefsPath}); } }); + it("patches the staged Dockerfile with Tavily Search config when selected", () => { + 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"), + ); + + const priorTavilyKey = process.env.TAVILY_API_KEY; + process.env.TAVILY_API_KEY = "tvly-test-key"; + 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); + } finally { + if (priorTavilyKey === undefined) { + delete process.env.TAVILY_API_KEY; + } else { + process.env.TAVILY_API_KEY = priorTavilyKey; + } + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + it("maps Gemini to the routed inference provider with supportsStore disabled", () => { assert.deepEqual(getSandboxInferenceConfig("gemini-2.5-flash", "gemini-api"), { providerKey: "inference", diff --git a/test/policies.test.ts b/test/policies.test.ts index 684981e057..6e73264ce0 100644 --- a/test/policies.test.ts +++ b/test/policies.test.ts @@ -130,9 +130,9 @@ selectFromList(items, options) describe("policies", () => { describe("listPresets", () => { - it("returns all 12 presets", () => { + it("returns all 13 presets", () => { const presets = policies.listPresets(); - expect(presets.length).toBe(12); + expect(presets.length).toBe(13); }); it("each preset has name and description", () => { @@ -159,6 +159,7 @@ describe("policies", () => { "outlook", "pypi", "slack", + "tavily", "telegram", ]; expect(names).toEqual(expected); diff --git a/test/policy-tiers.test.ts b/test/policy-tiers.test.ts index 7c9f968e91..9b438a20d3 100644 --- a/test/policy-tiers.test.ts +++ b/test/policy-tiers.test.ts @@ -119,13 +119,14 @@ describe("tiers", () => { }); describe("tier: balanced", () => { - it("includes npm, pypi, huggingface, brew, and brave", () => { + it("includes npm, pypi, huggingface, brew, brave, and tavily", () => { const names = mustGetTier("balanced").presets.map((preset: TierPreset) => preset.name); expect(names).toContain("npm"); expect(names).toContain("pypi"); expect(names).toContain("huggingface"); expect(names).toContain("brew"); expect(names).toContain("brave"); + expect(names).toContain("tavily"); }); it("has at least 5 presets", () => {