diff --git a/Dockerfile b/Dockerfile index 48d1cd079e8..6120e450509 100644 --- a/Dockerfile +++ b/Dockerfile @@ -286,6 +286,9 @@ 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: brave (default), gemini, or tavily. +# Controls which plugin entry and credential env var are written to openclaw.json. +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. @@ -308,7 +311,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/gemini.yaml b/nemoclaw-blueprint/policies/presets/gemini.yaml new file mode 100644 index 00000000000..8468cf9673d --- /dev/null +++ b/nemoclaw-blueprint/policies/presets/gemini.yaml @@ -0,0 +1,22 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +preset: + name: gemini + description: "Google Gemini API access for web search" + +network_policies: + gemini: + name: gemini + endpoints: + - host: generativelanguage.googleapis.com + port: 443 + protocol: rest + enforcement: enforce + tls: terminate + 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/presets/tavily.yaml b/nemoclaw-blueprint/policies/presets/tavily.yaml new file mode 100644 index 00000000000..66343a679be --- /dev/null +++ b/nemoclaw-blueprint/policies/presets/tavily.yaml @@ -0,0 +1,22 @@ +# 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 + tls: terminate + rules: + - allow: { method: GET, path: "/**" } + - allow: { method: POST, path: "/**" } + binaries: + - { path: /usr/local/bin/node } + - { path: /usr/bin/node } diff --git a/scripts/generate-openclaw-config.py b/scripts/generate-openclaw-config.py index 5c86ed4d5df..50cccc2bfc9 100755 --- a/scripts/generate-openclaw-config.py +++ b/scripts/generate-openclaw-config.py @@ -30,6 +30,7 @@ NEMOCLAW_PROXY_HOST Egress proxy host (default: 10.200.0.1) NEMOCLAW_PROXY_PORT Egress proxy port (default: 3128) NEMOCLAW_WEB_SEARCH_ENABLED Set to "1" to enable web search tools + NEMOCLAW_WEB_SEARCH_PROVIDER Provider name: brave|gemini|tavily (default: brave) """ from __future__ import annotations @@ -295,12 +296,48 @@ def _placeholder(channel: str, env_key: str) -> str: } if env.get("NEMOCLAW_WEB_SEARCH_ENABLED", "") == "1": + provider = env.get("NEMOCLAW_WEB_SEARCH_PROVIDER", "brave").strip().lower() + if provider not in ("brave", "gemini", "tavily"): + provider = "brave" + + # Map provider to credential env var and plugin entry + provider_config = { + "brave": { + "credential_env": "BRAVE_API_KEY", + "plugin_entry": "brave", + }, + "gemini": { + "credential_env": "GEMINI_API_KEY", + "plugin_entry": "google", + }, + "tavily": { + "credential_env": "TAVILY_API_KEY", + "plugin_entry": "tavily", + }, + }[provider] + + credential_env = provider_config["credential_env"] + plugin_entry = provider_config["plugin_entry"] + + # Build plugin config + web_search_plugin_config: dict = { + "apiKey": f"openshell:resolve:env:{credential_env}", + } + if provider == "gemini": + web_search_plugin_config["model"] = "gemini-2.5-flash" + + config.setdefault("plugins", {}).setdefault("entries", {})[plugin_entry] = { + "enabled": True, + "config": { + "webSearch": web_search_plugin_config, + }, + } + config["tools"] = { "web": { "search": { "enabled": True, - "provider": "brave", - "apiKey": "openshell:resolve:env:BRAVE_API_KEY", + "provider": provider, }, "fetch": {"enabled": True}, } diff --git a/src/lib/onboard-session.test.ts b/src/lib/onboard-session.test.ts index a088457404b..dfcc6c129b5 100644 --- a/src/lib/onboard-session.test.ts +++ b/src/lib/onboard-session.test.ts @@ -185,11 +185,11 @@ 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: { provider: "brave", fetchEnabled: true }, }); let loaded = requireLoadedSession(session.loadSession()); - expect(loaded.webSearchConfig).toEqual({ fetchEnabled: true }); + expect(loaded.webSearchConfig).toEqual({ provider: "brave", fetchEnabled: true }); session.completeSession({ webSearchConfig: null }); loaded = requireLoadedSession(session.loadSession()); diff --git a/src/lib/onboard-session.ts b/src/lib/onboard-session.ts index f5597965da8..9299c2346d7 100644 --- a/src/lib/onboard-session.ts +++ b/src/lib/onboard-session.ts @@ -12,7 +12,8 @@ import path from "node:path"; import { redactSensitiveText, redactUrl } from "./redact"; import { isErrnoException } from "./errno"; -import type { WebSearchConfig } from "./web-search"; +import { normalizePersistedWebSearchConfig } from "./web-search"; +import type { PersistedWebSearchConfig } from "./web-search"; export const SESSION_VERSION = 1; export const SESSION_DIR = path.join(process.env.HOME || "/tmp", ".nemoclaw"); @@ -74,7 +75,7 @@ export interface Session { credentialEnv: string | null; preferredInferenceApi: string | null; nimContainer: string | null; - webSearchConfig: WebSearchConfig | null; + webSearchConfig: PersistedWebSearchConfig | null; policyPresets: string[] | null; messagingChannels: string[] | null; // SHA-256 hex digest of every legacy credential value successfully @@ -116,7 +117,7 @@ export interface SessionUpdates { credentialEnv?: string; preferredInferenceApi?: string; nimContainer?: string; - webSearchConfig?: WebSearchConfig | null; + webSearchConfig?: PersistedWebSearchConfig | null; policyPresets?: string[]; messagingChannels?: string[]; migratedLegacyValueHashes?: Record; @@ -205,8 +206,8 @@ function readStepStatus(value: SessionJsonValue | undefined): StepStatus | null return isStepStatus(value) ? value : null; } -function parseWebSearchConfig(value: SessionJsonValue | undefined): WebSearchConfig | null { - return isObject(value) && value.fetchEnabled === true ? { fetchEnabled: true } : null; +function parseWebSearchConfig(value: SessionJsonValue | undefined): PersistedWebSearchConfig | null { + return normalizePersistedWebSearchConfig(value); } function parseSessionMetadata(value: SessionJsonValue | undefined): SessionMetadata | undefined { @@ -281,8 +282,9 @@ export function createSession(overrides: Partial = {}): Session { credentialEnv: overrides.credentialEnv ?? null, preferredInferenceApi: overrides.preferredInferenceApi ?? null, nimContainer: overrides.nimContainer ?? null, - webSearchConfig: - overrides.webSearchConfig?.fetchEnabled === true ? { fetchEnabled: true } : null, + webSearchConfig: overrides.webSearchConfig + ? normalizePersistedWebSearchConfig(overrides.webSearchConfig) ?? null + : null, policyPresets: readStringArray(overrides.policyPresets), messagingChannels: readStringArray(overrides.messagingChannels), migratedLegacyValueHashes: overrides.migratedLegacyValueHashes @@ -615,10 +617,11 @@ export function filterSafeUpdates(updates: SessionUpdates): Partial { if (typeof updates.preferredInferenceApi === "string") safe.preferredInferenceApi = updates.preferredInferenceApi; if (typeof updates.nimContainer === "string") safe.nimContainer = updates.nimContainer; - if (isObject(updates.webSearchConfig) && updates.webSearchConfig.fetchEnabled === true) { - safe.webSearchConfig = { fetchEnabled: true }; - } else if (updates.webSearchConfig === null) { + if (updates.webSearchConfig === null) { safe.webSearchConfig = null; + } else if (isObject(updates.webSearchConfig)) { + const normalized = normalizePersistedWebSearchConfig(updates.webSearchConfig); + if (normalized) safe.webSearchConfig = normalized; } if (Array.isArray(updates.policyPresets)) { safe.policyPresets = updates.policyPresets.filter((value) => typeof value === "string"); diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 1c24f6f6400..a6fa6ee8e3f 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -297,7 +297,7 @@ import type { ProbeRecovery } from "./validation-recovery"; import type { SandboxCreateFailure, ValidationClassification } from "./validation"; import type { TierDefinition, TierPreset } from "./tiers"; import type { StreamSandboxCreateResult } from "./sandbox-create-stream"; -import type { WebSearchConfig } from "./web-search"; +import type { WebSearchConfig, WebSearchProvider, PersistedWebSearchConfig } from "./web-search"; import type { ModelCatalogFetchResult, ModelValidationResult, @@ -1550,6 +1550,173 @@ function agentSupportsWebSearch( return false; } +/** + * Resolve the web search provider from the environment variable, falling back + * to "brave" when unset or invalid. Used in non-interactive mode. + */ +function resolveNonInteractiveWebSearchProvider(): WebSearchProvider { + const envValue = process.env[webSearch.WEB_SEARCH_PROVIDER_ENV]; + if (!envValue) return "brave"; + const parsed = webSearch.parseWebSearchProvider(envValue); + if (!parsed) { + throw new Error( + `Invalid NEMOCLAW_WEB_SEARCH_PROVIDER="${envValue}". Valid values: brave, gemini, tavily.`, + ); + } + return parsed; +} + +/** + * Prompt the user to select a web search provider interactively. + * Returns null if the user chooses to skip. + */ +async function promptWebSearchProviderSelection(): Promise { + const providers = webSearch.listWebSearchProviders(); + console.log(""); + console.log(" Web search providers:"); + for (let i = 0; i < providers.length; i++) { + console.log(` ${i + 1}) ${providers[i].label}`); + } + console.log(` ${providers.length + 1}) Skip`); + console.log(""); + + while (true) { + const answer = ( + await prompt(` Select provider [1-${providers.length + 1}]: `) + ).trim(); + const index = parseInt(answer, 10); + if (index >= 1 && index <= providers.length) { + return providers[index - 1].provider; + } + if (index === providers.length + 1) { + return null; + } + // Also accept provider names directly + const parsed = webSearch.parseWebSearchProvider(answer); + if (parsed) return parsed; + if (answer.toLowerCase() === "skip") return null; + console.log(` Please enter a number 1-${providers.length + 1} or provider name.`); + } +} + +/** + * Validate a web search API key for the given provider. + * Currently only Brave has a live validation endpoint. For Gemini and Tavily, + * we accept any non-empty key (format validation only). + */ +function validateWebSearchApiKey( + provider: WebSearchProvider, + apiKey: string, +): ValidationResult { + if (provider === "brave") { + return validateBraveSearchApiKey(apiKey); + } + // For Gemini and Tavily, perform basic format validation. + // Live validation would require making an actual search request. + const trimmed = (apiKey || "").trim(); + if (!trimmed) { + return { ok: false, message: "API key cannot be empty." }; + } + // Gemini keys are typically 39 chars (AIza...), Tavily keys are 20+ chars. + // Reject obviously invalid short strings. + if (trimmed.length < 10) { + return { ok: false, message: `API key is too short (${trimmed.length} chars). Check that you pasted the full key.` }; + } + return { ok: true }; +} + +/** + * Provider-agnostic recovery prompt for web search credential validation failures. + */ +async function promptWebSearchRecovery( + providerLabel: string, + validation: ValidationFailureLike, +): Promise<"retry" | "skip"> { + const recovery = classifyValidationFailure(validation); + + if (recovery.kind === "credential") { + console.log(` ${providerLabel} rejected that API key.`); + } else if (recovery.kind === "transport") { + console.log(getTransportRecoveryMessage(validation)); + } else { + console.log(` ${providerLabel} validation did not succeed.`); + } + + const answer = (await prompt(" Type 'retry', 'skip', or 'exit' [retry]: ")).trim().toLowerCase(); + if (answer === "skip") return "skip"; + if (answer === "exit" || answer === "quit") { + exitOnboardFromPrompt(); + } + return "retry"; +} + +/** + * Prompt for and validate a web search API key for the given provider. + * Returns the validated key or null if the user skips. + */ +async function ensureValidatedWebSearchCredential( + provider: WebSearchProvider, + nonInteractive = isNonInteractive(), +): Promise { + const meta = webSearch.getWebSearchProvider(provider); + const savedApiKey = getCredential(meta.credentialEnv); + let apiKey: string | null = + savedApiKey || normalizeCredentialValue(process.env[meta.credentialEnv]); + let usingSavedKey = Boolean(savedApiKey); + + while (true) { + if (!apiKey) { + if (nonInteractive) { + throw new Error( + `${meta.label} requires ${meta.credentialEnv} or a saved credential in non-interactive mode.`, + ); + } + console.log(""); + console.log(` Get your ${meta.label} API key from: ${meta.helpUrl}`); + console.log(""); + apiKey = normalizeCredentialValue( + await prompt(` ${meta.label} API key: `, { secret: true }), + ); + if (!apiKey) { + console.error(` ${meta.label} API key is required.`); + continue; + } + usingSavedKey = false; + } + + const validation = validateWebSearchApiKey(provider, apiKey); + if (validation.ok) { + saveCredential(meta.credentialEnv, apiKey); + process.env[meta.credentialEnv] = apiKey; + return apiKey; + } + + const prefix = usingSavedKey + ? ` Saved ${meta.label} API key validation failed.` + : ` ${meta.label} API key validation failed.`; + console.error(prefix); + if (validation.message) { + console.error(` ${validation.message}`); + } + + if (nonInteractive) { + throw new Error( + validation.message || `${meta.label} API key validation failed in non-interactive mode.`, + ); + } + + const action = await promptWebSearchRecovery(meta.label, validation); + if (action === "skip") { + console.log(` Skipping ${meta.label} setup.`); + console.log(""); + return null; + } + + apiKey = null; + usingSavedKey = false; + } +} + async function configureWebSearch( existingConfig: WebSearchConfig | null = null, agent: AgentDefinition | null = null, @@ -1561,42 +1728,48 @@ async function configureWebSearch( } if (existingConfig) { - return { fetchEnabled: true }; + return existingConfig; } if (isNonInteractive()) { - const braveApiKey = normalizeCredentialValue(process.env[webSearch.BRAVE_API_KEY_ENV]); - if (!braveApiKey) { + const provider = resolveNonInteractiveWebSearchProvider(); + const meta = webSearch.getWebSearchProvider(provider); + const apiKey = + getCredential(meta.credentialEnv) || + normalizeCredentialValue(process.env[meta.credentialEnv]); + if (!apiKey) { return null; } - note(" [non-interactive] Brave Web Search requested."); - const validation = validateBraveSearchApiKey(braveApiKey); + note(` [non-interactive] ${meta.label} web search requested.`); + const validation = validateWebSearchApiKey(provider, 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\`.`, + ` ${meta.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(meta.credentialEnv, apiKey); + process.env[meta.credentialEnv] = apiKey; + return { provider, fetchEnabled: true }; } - const enableAnswer = await prompt(" Enable Brave Web Search? [y/N]: "); - if (!isAffirmativeAnswer(enableAnswer)) { + + const provider = await promptWebSearchProviderSelection(); + if (!provider) { return null; } - const braveApiKey = await ensureValidatedBraveSearchCredential(); - if (!braveApiKey) { + const apiKey = await ensureValidatedWebSearchCredential(provider); + if (!apiKey) { return null; } - console.log(" ✓ Enabled Brave Web Search"); + const meta = webSearch.getWebSearchProvider(provider); + console.log(` ✓ Enabled ${meta.label}`); console.log(""); - return { fetchEnabled: true }; + return { provider, fetchEnabled: true }; } /** @@ -1826,6 +1999,21 @@ function patchStagedDockerfile( /^ARG NEMOCLAW_WEB_SEARCH_ENABLED=.*$/m, `ARG NEMOCLAW_WEB_SEARCH_ENABLED=${webSearchConfig ? "1" : "0"}`, ); + if (webSearchConfig && webSearchConfig.provider && webSearchConfig.provider !== "brave") { + // For non-Brave providers, the Dockerfile must declare the NEMOCLAW_WEB_SEARCH_PROVIDER + // ARG. If it doesn't (e.g. an older custom Dockerfile), the replace is a no-op and the + // image would silently default to Brave — fail fast instead. + if (!/^\s*ARG\s+NEMOCLAW_WEB_SEARCH_PROVIDER=/m.test(dockerfile)) { + throw new Error( + `Selected web search provider "${webSearchConfig.provider}" but the Dockerfile does not declare ARG NEMOCLAW_WEB_SEARCH_PROVIDER. ` + + "Update the Dockerfile or select Brave instead.", + ); + } + dockerfile = dockerfile.replace( + /^ARG NEMOCLAW_WEB_SEARCH_PROVIDER=.*$/m, + `ARG NEMOCLAW_WEB_SEARCH_PROVIDER=${webSearchConfig.provider}`, + ); + } // Onboard flow expects immediate dashboard access without device pairing, // so disable device auth for images built during onboard (see #1217). dockerfile = dockerfile.replace( @@ -3582,8 +3770,10 @@ function formatOnboardConfigSummary({ Array.isArray(enabledChannels) && enabledChannels.length > 0 ? enabledChannels.join(", ") : "none"; - const webSearch = - webSearchConfig && webSearchConfig.fetchEnabled === true ? "enabled" : "disabled"; + const webSearchLabel = + webSearchConfig && webSearchConfig.fetchEnabled === true + ? `enabled (${webSearchConfig.provider})` + : "disabled"; const apiKeyLine = credentialEnv ? ` API key: ${credentialEnv} (staged for OpenShell gateway registration)` : ` API key: (not required for ${provider ?? "this provider"})`; @@ -3598,7 +3788,7 @@ function formatOnboardConfigSummary({ ` Provider: ${provider ?? "(unset)"}`, ` Model: ${model ?? "(unset)"}`, apiKeyLine, - ` Web search: ${webSearch}`, + ` Web search: ${webSearchLabel}`, ` Messaging: ${messaging}`, ` Sandbox name: ${sandboxName}`, ...noteLines, @@ -3758,10 +3948,11 @@ async function createSandbox( .filter(({ envKey }) => !disabledEnvKeys.has(envKey)); if (webSearchConfig) { + const wsMeta = webSearch.getWebSearchProvider(webSearchConfig.provider); messagingTokenDefs.push({ - name: `${sandboxName}-brave-search`, - envKey: webSearch.BRAVE_API_KEY_ENV, - token: getCredential(webSearch.BRAVE_API_KEY_ENV), + name: `${sandboxName}-${wsMeta.policyPreset}-search`, + envKey: wsMeta.credentialEnv, + token: getCredential(wsMeta.credentialEnv), }); } const hasMessagingTokens = messagingTokenDefs.some(({ token }) => !!token); @@ -4143,12 +4334,17 @@ async function createSandbox( } console.log(` Creating sandbox '${sandboxName}' (this takes a few minutes on first run)...`); - 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 wsMeta = webSearch.getWebSearchProvider(webSearchConfig.provider); + if (!getCredential(wsMeta.credentialEnv)) { + console.error( + ` ${wsMeta.label} is enabled, but ${wsMeta.credentialEnv} is not available in this process.`, + ); + console.error( + ` Re-run with ${wsMeta.credentialEnv} set, or disable web search before recreating the sandbox.`, + ); + process.exit(1); + } } const tokensByEnvKey = Object.fromEntries( messagingTokenDefs.map(({ envKey, token }) => [envKey, token]), @@ -4300,10 +4496,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 wsMeta = webSearch.getWebSearchProvider(webSearchConfig.provider); + const wsKey = getCredential(wsMeta.credentialEnv) || process.env[wsMeta.credentialEnv]; + if (wsKey) { + envArgs.push(formatEnvAssignment(wsMeta.credentialEnv, wsKey)); } } // Slack Socket Mode requires both tokens in the container env so the baked @@ -6177,7 +6373,10 @@ function getSuggestedPolicyPresets({ maybeSuggestMessagingPreset("slack", "SLACK_BOT_TOKEN"); maybeSuggestMessagingPreset("discord", "DISCORD_BOT_TOKEN"); - if (webSearchConfig) suggestions.push("brave"); + if (webSearchConfig) { + const wsMeta = webSearch.getWebSearchProvider(webSearchConfig.provider); + suggestions.push(wsMeta.policyPreset); + } return suggestions; } @@ -6827,7 +7026,9 @@ function computeSetupPresetSuggestions( if (known && !known.has(name)) return; suggestions.push(name); }; - if (webSearchConfig) add("brave"); + if (webSearchConfig) { + add(webSearch.getWebSearchProvider(webSearchConfig.provider).policyPreset); + } if (provider && LOCAL_INFERENCE_PROVIDERS.includes(provider)) add("local-inference"); if (Array.isArray(enabledChannels)) { for (const channel of enabledChannels) add(channel); @@ -8054,7 +8255,10 @@ async function onboard(opts: OnboardOptions = {}): Promise { let credentialEnv = session?.credentialEnv || null; let preferredInferenceApi = session?.preferredInferenceApi || null; let nimContainer = session?.nimContainer || null; - let webSearchConfig = session?.webSearchConfig || null; + let webSearchConfig: WebSearchConfig | null = + session?.webSearchConfig?.fetchEnabled === true + ? (session.webSearchConfig as WebSearchConfig) + : null; let forceProviderSelection = false; while (true) { const resumeProviderSelection = @@ -8186,7 +8390,8 @@ async function onboard(opts: OnboardOptions = {}): Promise { sandboxReuseState === "ready"; if (resumeSandbox) { if (webSearchConfig) { - note(" [resume] Reusing Brave Search configuration already baked into the sandbox."); + const wsLabel = webSearch.getWebSearchProvider(webSearchConfig.provider).label; + note(` [resume] Reusing ${wsLabel} configuration already baked into the sandbox.`); } selectedMessagingChannels = session?.messagingChannels ?? []; skippedStepMessage("sandbox", sandboxName); @@ -8210,12 +8415,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; + if (nextWebSearchConfig && nextWebSearchConfig.fetchEnabled) { + const resumeProvider = (nextWebSearchConfig as WebSearchConfig).provider ?? "brave"; + const meta = webSearch.getWebSearchProvider(resumeProvider); + note(` [resume] Revalidating ${meta.label} configuration for sandbox recreation.`); + const apiKey = await ensureValidatedWebSearchCredential(resumeProvider); + nextWebSearchConfig = apiKey ? { provider: resumeProvider, fetchEnabled: true } : null; if (nextWebSearchConfig) { - note(" [resume] Reusing Brave Search configuration."); + note(` [resume] Reusing ${meta.label} configuration.`); } } else { nextWebSearchConfig = await configureWebSearch(null, agent, webSearchSupportProbePath); diff --git a/src/lib/web-search.test.ts b/src/lib/web-search.test.ts index cf98d55d781..3bf83bd9d5e 100644 --- a/src/lib/web-search.test.ts +++ b/src/lib/web-search.test.ts @@ -3,10 +3,327 @@ import { describe, expect, it } from "vitest"; -import { BRAVE_API_KEY_ENV } from "./web-search"; +import { + BRAVE_API_KEY_ENV, + GEMINI_API_KEY_ENV, + TAVILY_API_KEY_ENV, + WEB_SEARCH_PROVIDER_ENV, + DEFAULT_GEMINI_WEB_SEARCH_MODEL, + listWebSearchProviders, + parseWebSearchProvider, + getWebSearchProvider, + normalizePersistedWebSearchConfig, + normalizeWebSearchConfig, + getWebSearchCredentialEnvNames, + getWebSearchExposureWarningLines, + buildWebSearchConfigFragment, + encodeDockerJsonArg, + buildWebSearchDockerConfig, +} from "./web-search"; + +import type { WebSearchConfig, WebSearchProvider } from "./web-search"; describe("web-search module", () => { - it("exports BRAVE_API_KEY_ENV constant", () => { - expect(BRAVE_API_KEY_ENV).toBe("BRAVE_API_KEY"); + describe("constants", () => { + it("exports credential env constants", () => { + expect(BRAVE_API_KEY_ENV).toBe("BRAVE_API_KEY"); + expect(GEMINI_API_KEY_ENV).toBe("GEMINI_API_KEY"); + expect(TAVILY_API_KEY_ENV).toBe("TAVILY_API_KEY"); + }); + + it("exports provider env constant", () => { + expect(WEB_SEARCH_PROVIDER_ENV).toBe("NEMOCLAW_WEB_SEARCH_PROVIDER"); + }); + + it("exports default Gemini model", () => { + expect(DEFAULT_GEMINI_WEB_SEARCH_MODEL).toBe("gemini-2.5-flash"); + }); + }); + + describe("listWebSearchProviders()", () => { + it("returns all three providers", () => { + const providers = listWebSearchProviders(); + expect(providers).toHaveLength(3); + expect(providers.map((p) => p.provider).sort()).toEqual(["brave", "gemini", "tavily"]); + }); + + it("each provider has required metadata fields", () => { + for (const p of listWebSearchProviders()) { + expect(p.label).toBeTruthy(); + expect(p.helpUrl).toMatch(/^https:\/\//); + expect(p.credentialEnv).toBeTruthy(); + expect(p.pluginEntry).toBeTruthy(); + expect(p.policyPreset).toBeTruthy(); + } + }); + }); + + describe("parseWebSearchProvider()", () => { + it("parses valid provider strings", () => { + expect(parseWebSearchProvider("brave")).toBe("brave"); + expect(parseWebSearchProvider("gemini")).toBe("gemini"); + expect(parseWebSearchProvider("tavily")).toBe("tavily"); + }); + + it("normalizes case and whitespace", () => { + expect(parseWebSearchProvider("BRAVE")).toBe("brave"); + expect(parseWebSearchProvider(" Gemini ")).toBe("gemini"); + expect(parseWebSearchProvider("TAVILY")).toBe("tavily"); + }); + + it("returns null for invalid values", () => { + expect(parseWebSearchProvider("invalid")).toBeNull(); + expect(parseWebSearchProvider("")).toBeNull(); + expect(parseWebSearchProvider(null)).toBeNull(); + expect(parseWebSearchProvider(undefined)).toBeNull(); + expect(parseWebSearchProvider(123)).toBeNull(); + expect(parseWebSearchProvider({})).toBeNull(); + }); + }); + + describe("getWebSearchProvider()", () => { + it("returns metadata for brave", () => { + const meta = getWebSearchProvider("brave"); + expect(meta.label).toBe("Brave Search"); + expect(meta.credentialEnv).toBe("BRAVE_API_KEY"); + expect(meta.pluginEntry).toBe("brave"); + expect(meta.policyPreset).toBe("brave"); + }); + + it("returns metadata for gemini", () => { + const meta = getWebSearchProvider("gemini"); + expect(meta.label).toBe("Google Gemini"); + expect(meta.credentialEnv).toBe("GEMINI_API_KEY"); + expect(meta.pluginEntry).toBe("google"); + expect(meta.policyPreset).toBe("gemini"); + }); + + it("returns metadata for tavily", () => { + const meta = getWebSearchProvider("tavily"); + expect(meta.label).toBe("Tavily"); + expect(meta.credentialEnv).toBe("TAVILY_API_KEY"); + expect(meta.pluginEntry).toBe("tavily"); + expect(meta.policyPreset).toBe("tavily"); + }); + }); + + describe("normalizePersistedWebSearchConfig()", () => { + it("returns null for non-objects", () => { + expect(normalizePersistedWebSearchConfig(null)).toBeNull(); + expect(normalizePersistedWebSearchConfig(undefined)).toBeNull(); + expect(normalizePersistedWebSearchConfig("string")).toBeNull(); + expect(normalizePersistedWebSearchConfig(42)).toBeNull(); + expect(normalizePersistedWebSearchConfig([])).toBeNull(); + }); + + it("returns null when fetchEnabled is missing", () => { + expect(normalizePersistedWebSearchConfig({ provider: "brave" })).toBeNull(); + }); + + it("handles enabled config with provider", () => { + const result = normalizePersistedWebSearchConfig({ + provider: "gemini", + fetchEnabled: true, + }); + expect(result).toEqual({ provider: "gemini", fetchEnabled: true }); + }); + + it("defaults to brave when provider is missing and fetchEnabled is true", () => { + const result = normalizePersistedWebSearchConfig({ fetchEnabled: true }); + expect(result).toEqual({ provider: "brave", fetchEnabled: true }); + }); + + it("returns null for invalid provider with fetchEnabled true", () => { + expect( + normalizePersistedWebSearchConfig({ provider: "invalid", fetchEnabled: true }), + ).toBeNull(); + }); + + it("handles disabled config without provider", () => { + const result = normalizePersistedWebSearchConfig({ fetchEnabled: false }); + expect(result).toEqual({ fetchEnabled: false }); + }); + + it("handles disabled config with valid provider", () => { + const result = normalizePersistedWebSearchConfig({ + provider: "tavily", + fetchEnabled: false, + }); + expect(result).toEqual({ provider: "tavily", fetchEnabled: false }); + }); + + it("returns null for disabled config with invalid provider", () => { + expect( + normalizePersistedWebSearchConfig({ provider: "invalid", fetchEnabled: false }), + ).toBeNull(); + }); + }); + + describe("normalizeWebSearchConfig()", () => { + it("returns enabled config", () => { + const result = normalizeWebSearchConfig({ provider: "tavily", fetchEnabled: true }); + expect(result).toEqual({ provider: "tavily", fetchEnabled: true }); + }); + + it("returns null for disabled config", () => { + expect(normalizeWebSearchConfig({ fetchEnabled: false })).toBeNull(); + }); + + it("returns null for invalid input", () => { + expect(normalizeWebSearchConfig(null)).toBeNull(); + expect(normalizeWebSearchConfig({})).toBeNull(); + }); + }); + + describe("getWebSearchCredentialEnvNames()", () => { + it("returns all credential env names", () => { + const envNames = getWebSearchCredentialEnvNames(); + expect(envNames).toContain("BRAVE_API_KEY"); + expect(envNames).toContain("GEMINI_API_KEY"); + expect(envNames).toContain("TAVILY_API_KEY"); + expect(envNames).toHaveLength(3); + }); + }); + + describe("getWebSearchExposureWarningLines()", () => { + it("returns provider-specific warning for brave", () => { + const lines = getWebSearchExposureWarningLines("brave"); + expect(lines[0]).toContain("Brave Search"); + expect(lines).toHaveLength(2); + }); + + it("returns provider-specific warning for gemini", () => { + const lines = getWebSearchExposureWarningLines("gemini"); + expect(lines[0]).toContain("Google Gemini"); + }); + + it("returns provider-specific warning for tavily", () => { + const lines = getWebSearchExposureWarningLines("tavily"); + expect(lines[0]).toContain("Tavily"); + }); + }); + + describe("buildWebSearchConfigFragment()", () => { + it("returns empty object for null config", () => { + expect(buildWebSearchConfigFragment(null, null)).toEqual({}); + }); + + it("returns empty object for disabled config", () => { + const config = { provider: "brave", fetchEnabled: false } as unknown as WebSearchConfig; + expect(buildWebSearchConfigFragment(config, null)).toEqual({}); + }); + + it("builds brave config fragment", () => { + const config: WebSearchConfig = { provider: "brave", fetchEnabled: true }; + const result = buildWebSearchConfigFragment(config, "test-key"); + expect(result).toEqual({ + plugins: { + entries: { + brave: { + enabled: true, + config: { + webSearch: { + apiKey: "openshell:resolve:env:BRAVE_API_KEY", + }, + }, + }, + }, + }, + tools: { + web: { + search: { enabled: true, provider: "brave" }, + fetch: { enabled: true }, + }, + }, + }); + }); + + it("builds gemini config fragment with model", () => { + const config: WebSearchConfig = { provider: "gemini", fetchEnabled: true }; + const result = buildWebSearchConfigFragment(config, "test-key"); + expect(result).toEqual({ + plugins: { + entries: { + google: { + enabled: true, + config: { + webSearch: { + model: DEFAULT_GEMINI_WEB_SEARCH_MODEL, + apiKey: "openshell:resolve:env:GEMINI_API_KEY", + }, + }, + }, + }, + }, + tools: { + web: { + search: { enabled: true, provider: "gemini" }, + fetch: { enabled: true }, + }, + }, + }); + }); + + it("builds tavily config fragment", () => { + const config: WebSearchConfig = { provider: "tavily", fetchEnabled: true }; + const result = buildWebSearchConfigFragment(config, "test-key"); + expect(result).toEqual({ + plugins: { + entries: { + tavily: { + enabled: true, + config: { + webSearch: { + apiKey: "openshell:resolve:env:TAVILY_API_KEY", + }, + }, + }, + }, + }, + tools: { + web: { + search: { enabled: true, provider: "tavily" }, + fetch: { enabled: true }, + }, + }, + }); + }); + + it("omits apiKey from fragment when key is null", () => { + const config: WebSearchConfig = { provider: "brave", fetchEnabled: true }; + const result = buildWebSearchConfigFragment(config, null); + expect( + (result as any).plugins.entries.brave.config.webSearch.apiKey, + ).toBeUndefined(); + }); + }); + + describe("encodeDockerJsonArg()", () => { + it("encodes value as base64 JSON", () => { + const result = encodeDockerJsonArg({ foo: "bar" }); + const decoded = JSON.parse(Buffer.from(result, "base64").toString("utf8")); + expect(decoded).toEqual({ foo: "bar" }); + }); + + it("handles null/undefined as empty object", () => { + const result = encodeDockerJsonArg(null); + const decoded = JSON.parse(Buffer.from(result, "base64").toString("utf8")); + expect(decoded).toEqual({}); + }); + }); + + describe("buildWebSearchDockerConfig()", () => { + it("returns base64-encoded config fragment", () => { + const config: WebSearchConfig = { provider: "brave", fetchEnabled: true }; + const result = buildWebSearchDockerConfig(config, "key"); + const decoded = JSON.parse(Buffer.from(result, "base64").toString("utf8")); + expect(decoded.tools.web.search.provider).toBe("brave"); + }); + + it("returns base64 empty object for null config", () => { + const result = buildWebSearchDockerConfig(null, null); + const decoded = JSON.parse(Buffer.from(result, "base64").toString("utf8")); + expect(decoded).toEqual({}); + }); }); }); diff --git a/src/lib/web-search.ts b/src/lib/web-search.ts index dd6d7682ac9..95463baa967 100644 --- a/src/lib/web-search.ts +++ b/src/lib/web-search.ts @@ -1,8 +1,213 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +/** + * Web search provider model — types, metadata registry, and config fragment + * builders for Brave, Gemini, and Tavily web search providers. + * + * This module is imported by onboard, session management, and the Dockerfile + * config generator. It must remain side-effect-free. + */ + +// ── Types ──────────────────────────────────────────────────────── + +export type WebSearchProvider = "brave" | "gemini" | "tavily"; + export interface WebSearchConfig { + provider: WebSearchProvider; fetchEnabled: boolean; } +export interface DisabledWebSearchConfig { + provider?: WebSearchProvider; + fetchEnabled: false; +} + +export type PersistedWebSearchConfig = WebSearchConfig | DisabledWebSearchConfig; + +export interface WebSearchProviderMetadata { + provider: WebSearchProvider; + label: string; + helpUrl: string; + credentialEnv: string; + pluginEntry: string; + policyPreset: string; +} + +// ── Constants ──────────────────────────────────────────────────── + export const BRAVE_API_KEY_ENV = "BRAVE_API_KEY"; +export const GEMINI_API_KEY_ENV = "GEMINI_API_KEY"; +export const TAVILY_API_KEY_ENV = "TAVILY_API_KEY"; +export const WEB_SEARCH_PROVIDER_ENV = "NEMOCLAW_WEB_SEARCH_PROVIDER"; +export const DEFAULT_GEMINI_WEB_SEARCH_MODEL = "gemini-2.5-flash"; + +// ── Provider Registry ──────────────────────────────────────────── + +const WEB_SEARCH_PROVIDERS: Record = { + brave: { + provider: "brave", + label: "Brave Search", + helpUrl: "https://api.search.brave.com/app/keys", + credentialEnv: BRAVE_API_KEY_ENV, + pluginEntry: "brave", + policyPreset: "brave", + }, + gemini: { + provider: "gemini", + label: "Google Gemini", + helpUrl: "https://aistudio.google.com/app/apikey", + credentialEnv: GEMINI_API_KEY_ENV, + pluginEntry: "google", + policyPreset: "gemini", + }, + tavily: { + provider: "tavily", + label: "Tavily", + helpUrl: "https://app.tavily.com", + credentialEnv: TAVILY_API_KEY_ENV, + pluginEntry: "tavily", + policyPreset: "tavily", + }, +}; + +// ── Provider Accessors ─────────────────────────────────────────── + +export function listWebSearchProviders(): WebSearchProviderMetadata[] { + return Object.values(WEB_SEARCH_PROVIDERS); +} + +export function parseWebSearchProvider(value: unknown): WebSearchProvider | null { + if (typeof value !== "string") return null; + const normalized = value.trim().toLowerCase(); + return Object.hasOwn(WEB_SEARCH_PROVIDERS, normalized) + ? (normalized as WebSearchProvider) + : null; +} + +export function getWebSearchProvider(provider: WebSearchProvider): WebSearchProviderMetadata { + return WEB_SEARCH_PROVIDERS[provider]; +} + +// ── Config Normalization ───────────────────────────────────────── + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Normalize a persisted web search config value from disk. Handles: + * - Missing provider field (defaults to "brave" for backward compat) + * - Invalid provider values (returns null) + * - Explicit disable (fetchEnabled: false) + */ +export function normalizePersistedWebSearchConfig( + value: unknown, +): PersistedWebSearchConfig | null { + if (!isObject(value) || typeof value.fetchEnabled !== "boolean") return null; + + if (value.fetchEnabled === false) { + const provider = + value.provider === undefined ? undefined : parseWebSearchProvider(value.provider); + if (value.provider !== undefined && !provider) return null; + return provider ? { provider, fetchEnabled: false } : { fetchEnabled: false }; + } + + // fetchEnabled === true — provider is required (default: "brave" for backward compat) + const provider = + value.provider === undefined ? "brave" : parseWebSearchProvider(value.provider); + if (!provider) return null; + return { provider, fetchEnabled: true }; +} + +/** + * Normalize to an enabled WebSearchConfig or null. + */ +export function normalizeWebSearchConfig(value: unknown): WebSearchConfig | null { + const normalized = normalizePersistedWebSearchConfig(value); + return normalized?.fetchEnabled === true ? (normalized as WebSearchConfig) : null; +} + +// ── Credential Helpers ─────────────────────────────────────────── + +/** + * Return all credential env var names across all providers. + * Used for credential redaction in logs and session serialization. + */ +export function getWebSearchCredentialEnvNames(): string[] { + return listWebSearchProviders().map((p) => p.credentialEnv); +} + +/** + * Return user-facing warning lines about credential exposure for a provider. + */ +export function getWebSearchExposureWarningLines(provider: WebSearchProvider): string[] { + const { label } = getWebSearchProvider(provider); + return [ + `NemoClaw will store a ${label} API key resolver in sandbox OpenClaw config.`, + "The OpenClaw agent will be able to resolve and read that key at runtime.", + ]; +} + +// ── Config Fragment Builders ───────────────────────────────────── + +/** + * Build the OpenClaw config fragment for a given web search provider. + * Returns an empty object when config is null or disabled. + */ +export function buildWebSearchConfigFragment( + config: WebSearchConfig | null, + apiKey: string | null, +): Record { + const normalized = normalizeWebSearchConfig(config); + if (!normalized) return {}; + + const { credentialEnv, pluginEntry } = getWebSearchProvider(normalized.provider); + const apiKeyRef = apiKey ? `openshell:resolve:env:${credentialEnv}` : null; + + return { + plugins: { + entries: { + [pluginEntry]: { + enabled: true, + config: { + webSearch: { + ...(normalized.provider === "gemini" + ? { model: DEFAULT_GEMINI_WEB_SEARCH_MODEL } + : {}), + ...(apiKeyRef ? { apiKey: apiKeyRef } : {}), + }, + }, + }, + }, + }, + tools: { + web: { + search: { + enabled: true, + provider: normalized.provider, + }, + fetch: { + enabled: true, + }, + }, + }, + }; +} + +/** + * Encode a config fragment as base64 JSON for Docker build-arg transport. + */ +export function encodeDockerJsonArg(value: unknown): string { + return Buffer.from(JSON.stringify(value ?? {}), "utf8").toString("base64"); +} + +/** + * Build the base64-encoded Docker build arg for web search config. + */ +export function buildWebSearchDockerConfig( + config: WebSearchConfig | null, + apiKey: string | null, +): string { + return encodeDockerJsonArg(buildWebSearchConfigFragment(config, apiKey)); +} diff --git a/test/onboard-brave-validation.test.ts b/test/onboard-brave-validation.test.ts index 9feb2678f1c..960c249aeb3 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({ provider: "brave", fetchEnabled: true }); }); }); diff --git a/test/onboard.test.ts b/test/onboard.test.ts index a127751f2dd..228305704bf 100644 --- a/test/onboard.test.ts +++ b/test/onboard.test.ts @@ -1048,7 +1048,7 @@ describe("onboard helpers", () => { "build-web", "openai-api", null, - { fetchEnabled: true }, + { provider: "brave", fetchEnabled: true }, ); const patched = fs.readFileSync(dockerfilePath, "utf8"); assert.match(patched, /^ARG NEMOCLAW_WEB_SEARCH_ENABLED=1$/m); diff --git a/test/policies.test.ts b/test/policies.test.ts index 1507fdd21cd..35ca6c07659 100644 --- a/test/policies.test.ts +++ b/test/policies.test.ts @@ -121,9 +121,9 @@ selectFromList(items, options) describe("policies", () => { describe("listPresets", () => { - it("returns all 12 presets", () => { + it("returns all 14 presets", () => { const presets = policies.listPresets(); - expect(presets.length).toBe(12); + expect(presets.length).toBe(14); }); it("each preset has name and description", () => { @@ -142,6 +142,7 @@ describe("policies", () => { "brave", "brew", "discord", + "gemini", "github", "huggingface", "jira", @@ -150,6 +151,7 @@ describe("policies", () => { "outlook", "pypi", "slack", + "tavily", "telegram", ]; expect(names).toEqual(expected);