diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 9ef98da0365..172f5d8799d 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -38,6 +38,9 @@ const { const { agentSupportsWebSearch, }: typeof import("./onboard/web-search-support") = require("./onboard/web-search-support"); +const { + createWebSearchConfigHelpers, +}: typeof import("./onboard/web-search-config") = require("./onboard/web-search-config"); const { buildDirectGpuPolicyYaml, buildDirectSandboxGpuProofCommands, @@ -409,7 +412,6 @@ function verifyGatewayContainerRunning() { } const OPENCLAW_LAUNCH_AGENT_PLIST = "~/Library/LaunchAgents/ai.openclaw.gateway.plist"; -const BRAVE_SEARCH_HELP_URL = "https://brave.com/search/api/"; // Re-export shared JSON types under the names used throughout this module. // See src/lib/core/json-types.ts for the canonical definitions. @@ -1868,6 +1870,26 @@ const { shouldForceCompletionsApi, } = validation; +const { + validateBraveSearchApiKey, + ensureValidatedBraveSearchCredential, + configureWebSearch, +} = createWebSearchConfigHelpers({ + runCurlProbe, + classifyValidationFailure, + getTransportRecoveryMessage, + getCredential, + saveCredential, + normalizeCredentialValue, + prompt, + isNonInteractive, + note, + cliName, + exitOnboardFromPrompt, + agentSupportsWebSearch, + rootDir: ROOT, +}); + // validateNvidiaApiKeyValue — see validation import above async function replaceNamedCredential( @@ -2395,164 +2417,6 @@ 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", - ]); -} - -async function promptBraveSearchRecovery( - validation: ValidationFailureLike, -): Promise<"retry" | "skip"> { - const recovery = classifyValidationFailure(validation); - - if (recovery.kind === "credential") { - console.log(" Brave Search rejected that API key."); - } else if (recovery.kind === "transport") { - console.log(getTransportRecoveryMessage(validation)); - } else { - console.log(" Brave Search 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"; -} - -async function promptBraveSearchApiKey(): Promise { - console.log(""); - console.log(` Get your Brave Search API key from: ${BRAVE_SEARCH_HELP_URL}`); - console.log(""); - - while (true) { - const key = normalizeCredentialValue( - await prompt(" Brave Search API key: ", { secret: true }), - ); - if (!key) { - console.error(" Brave Search API key is required."); - continue; - } - return key; - } -} - -async function ensureValidatedBraveSearchCredential( - nonInteractive = isNonInteractive(), -): Promise { - const savedApiKey = getCredential(webSearch.BRAVE_API_KEY_ENV); - let apiKey: string | null = - savedApiKey || normalizeCredentialValue(process.env[webSearch.BRAVE_API_KEY_ENV]); - 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.", - ); - } - apiKey = await promptBraveSearchApiKey(); - usingSavedKey = false; - } - - const validation = validateBraveSearchApiKey(apiKey); - if (validation.ok) { - saveCredential(webSearch.BRAVE_API_KEY_ENV, apiKey); - process.env[webSearch.BRAVE_API_KEY_ENV] = apiKey; - return apiKey; - } - - const prefix = usingSavedKey - ? " Saved Brave Search API key validation failed." - : " Brave Search API key validation failed."; - console.error(prefix); - if (validation.message) { - console.error(` ${validation.message}`); - } - - if (nonInteractive) { - throw new Error( - validation.message || "Brave Search API key validation failed in non-interactive mode.", - ); - } - - const action = await promptBraveSearchRecovery(validation); - if (action === "skip") { - console.log(" Skipping Brave Web Search setup."); - console.log(""); - return null; - } - - apiKey = null; - usingSavedKey = false; - } -} - -async function configureWebSearch( - existingConfig: WebSearchConfig | null = null, - agent: AgentDefinition | null = null, - dockerfilePathOverride: string | null = null, -): Promise { - if (!agentSupportsWebSearch(agent, dockerfilePathOverride, ROOT)) { - note(` Web search is not yet supported by ${agent?.displayName ?? "this agent"}. Skipping.`); - return null; - } - - if (existingConfig) { - return { fetchEnabled: true }; - } - - if (isNonInteractive()) { - const braveApiKey = normalizeCredentialValue(process.env[webSearch.BRAVE_API_KEY_ENV]); - if (!braveApiKey) { - return null; - } - 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 \`${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 }; - } - const enableAnswer = await prompt(" Enable Brave Web Search? [y/N]: "); - if (!isAffirmativeAnswer(enableAnswer)) { - return null; - } - - const braveApiKey = await ensureValidatedBraveSearchCredential(); - if (!braveApiKey) { - return null; - } - - console.log(" ✓ Enabled Brave Web Search"); - console.log(""); - return { fetchEnabled: true }; -} - /** * Post-creation probe: verify web search is actually functional inside the * sandbox. Hermes silently ignores unknown web.backend values, so checking diff --git a/src/lib/onboard/web-search-config.test.ts b/src/lib/onboard/web-search-config.test.ts new file mode 100644 index 00000000000..636de84dc0d --- /dev/null +++ b/src/lib/onboard/web-search-config.test.ts @@ -0,0 +1,89 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from "vitest"; + +import { createWebSearchConfigHelpers } from "./web-search-config"; + +function makeDeps(overrides: Partial[0]> = {}) { + const env: NodeJS.ProcessEnv = {}; + return { + env, + runCurlProbe: vi.fn(() => ({ ok: true, httpStatus: 200, curlStatus: 0, body: "{}", stderr: "", message: "ok" })), + classifyValidationFailure: vi.fn(() => ({ kind: "credential" as const, retry: "credential" as const })), + getTransportRecoveryMessage: vi.fn(() => "transport failed"), + getCredential: vi.fn(() => null), + saveCredential: vi.fn(), + normalizeCredentialValue: vi.fn((value: string | undefined | null) => String(value || "").trim()), + prompt: vi.fn(async () => ""), + isNonInteractive: vi.fn(() => false), + note: vi.fn(), + cliName: vi.fn(() => "nemoclaw"), + exitOnboardFromPrompt: vi.fn(() => { + throw new Error("exit"); + }) as () => never, + agentSupportsWebSearch: vi.fn(() => true), + rootDir: "/repo", + ...overrides, + }; +} + +describe("web search config helpers", () => { + it("validates Brave Search with the expected curl request", () => { + const deps = makeDeps(); + const helpers = createWebSearchConfigHelpers(deps); + + expect(helpers.validateBraveSearchApiKey("brave-key").ok).toBe(true); + + expect(deps.runCurlProbe).toHaveBeenCalledWith( + expect.arrayContaining(["-H", "X-Subscription-Token: brave-key", "https://api.search.brave.com/res/v1/web/search"]), + ); + }); + + it("skips configuration when the agent image does not support web search", async () => { + const deps = makeDeps({ agentSupportsWebSearch: vi.fn(() => false) }); + const helpers = createWebSearchConfigHelpers(deps); + + await expect(helpers.configureWebSearch(null, { name: "hermes", displayName: "Hermes" })).resolves.toBeNull(); + + expect(deps.note).toHaveBeenCalledWith(" Web search is not yet supported by Hermes. Skipping."); + }); + + it("returns an enabled config when existing config is present", async () => { + const deps = makeDeps(); + const helpers = createWebSearchConfigHelpers(deps); + + await expect(helpers.configureWebSearch({ fetchEnabled: true })).resolves.toEqual({ fetchEnabled: true }); + expect(deps.prompt).not.toHaveBeenCalled(); + }); + + it("uses BRAVE_API_KEY non-interactively when validation succeeds", async () => { + const deps = makeDeps({ isNonInteractive: vi.fn(() => true) }); + deps.env.BRAVE_API_KEY = " brave-key "; + const helpers = createWebSearchConfigHelpers(deps); + + await expect(helpers.configureWebSearch()).resolves.toEqual({ fetchEnabled: true }); + + expect(deps.saveCredential).toHaveBeenCalledWith("BRAVE_API_KEY", "brave-key"); + expect(deps.env.BRAVE_API_KEY).toBe("brave-key"); + }); + + it("prompts for and saves a Brave API key interactively", async () => { + const deps = makeDeps({ prompt: vi.fn().mockResolvedValueOnce("y").mockResolvedValueOnce(" brave-key ") }); + const helpers = createWebSearchConfigHelpers(deps); + + await expect(helpers.configureWebSearch()).resolves.toEqual({ fetchEnabled: true }); + + expect(deps.saveCredential).toHaveBeenCalledWith("BRAVE_API_KEY", "brave-key"); + }); + + it("returns null when interactive recovery chooses skip", async () => { + const deps = makeDeps({ + runCurlProbe: vi.fn(() => ({ ok: false, httpStatus: 401, curlStatus: 0, body: "", stderr: "", message: "bad key" })), + prompt: vi.fn().mockResolvedValueOnce("brave-key").mockResolvedValueOnce("skip"), + }); + const helpers = createWebSearchConfigHelpers(deps); + + await expect(helpers.ensureValidatedBraveSearchCredential(false)).resolves.toBeNull(); + }); +}); diff --git a/src/lib/onboard/web-search-config.ts b/src/lib/onboard/web-search-config.ts new file mode 100644 index 00000000000..ed9fa6bd4aa --- /dev/null +++ b/src/lib/onboard/web-search-config.ts @@ -0,0 +1,209 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { CurlProbeResult } from "../adapters/http/probe"; +import type { ValidationClassification } from "../validation"; +import type { ValidationFailureLike } from "./types"; +import type { WebSearchAgent } from "./web-search-support"; +import { BRAVE_API_KEY_ENV, type WebSearchConfig } from "../inference/web-search"; + +type PromptOptions = { secret?: boolean }; +type PromptFn = (message: string, options?: PromptOptions) => Promise; +type RunCurlProbeFn = (args: string[]) => CurlProbeResult; +type ClassifyValidationFailureFn = (failure: ValidationFailureLike) => ValidationClassification; +type GetTransportRecoveryMessageFn = (failure: ValidationFailureLike) => string; +type AgentSupportsWebSearchFn = ( + agent: WebSearchAgent, + dockerfilePathOverride?: string | null, + rootDir?: string, +) => boolean; + +export type WebSearchConfigDeps = { + runCurlProbe: RunCurlProbeFn; + classifyValidationFailure: ClassifyValidationFailureFn; + getTransportRecoveryMessage: GetTransportRecoveryMessageFn; + getCredential: (envName: string) => string | null; + saveCredential: (envName: string, value: string) => void; + normalizeCredentialValue: (value: string | undefined | null) => string; + prompt: PromptFn; + isNonInteractive: () => boolean; + note: (message: string) => void; + cliName: () => string; + exitOnboardFromPrompt: () => never; + agentSupportsWebSearch: AgentSupportsWebSearchFn; + rootDir: string; + env?: NodeJS.ProcessEnv; +}; + +export const BRAVE_SEARCH_HELP_URL = "https://brave.com/search/api/"; + +export function createWebSearchConfigHelpers(deps: WebSearchConfigDeps) { + const env = deps.env ?? process.env; + + function validateBraveSearchApiKey(apiKey: string): CurlProbeResult { + return deps.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", + ]); + } + + async function promptBraveSearchRecovery( + validation: ValidationFailureLike, + ): Promise<"retry" | "skip"> { + const recovery = deps.classifyValidationFailure(validation); + + if (recovery.kind === "credential") { + console.log(" Brave Search rejected that API key."); + } else if (recovery.kind === "transport") { + console.log(deps.getTransportRecoveryMessage(validation)); + } else { + console.log(" Brave Search 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") { + deps.exitOnboardFromPrompt(); + } + return "retry"; + } + + async function promptBraveSearchApiKey(): Promise { + console.log(""); + console.log(` Get your Brave Search API key from: ${BRAVE_SEARCH_HELP_URL}`); + console.log(""); + + while (true) { + const key = deps.normalizeCredentialValue( + await deps.prompt(" Brave Search API key: ", { secret: true }), + ); + if (!key) { + console.error(" Brave Search API key is required."); + continue; + } + return key; + } + } + + async function ensureValidatedBraveSearchCredential( + nonInteractive = deps.isNonInteractive(), + ): Promise { + const savedApiKey = deps.getCredential(BRAVE_API_KEY_ENV); + let apiKey: string | null = savedApiKey || deps.normalizeCredentialValue(env[BRAVE_API_KEY_ENV]); + 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.", + ); + } + apiKey = await promptBraveSearchApiKey(); + usingSavedKey = false; + } + + const validation = validateBraveSearchApiKey(apiKey); + if (validation.ok) { + deps.saveCredential(BRAVE_API_KEY_ENV, apiKey); + env[BRAVE_API_KEY_ENV] = apiKey; + return apiKey; + } + + const prefix = usingSavedKey + ? " Saved Brave Search API key validation failed." + : " Brave Search API key validation failed."; + console.error(prefix); + if (validation.message) { + console.error(` ${validation.message}`); + } + + if (nonInteractive) { + throw new Error( + validation.message || "Brave Search API key validation failed in non-interactive mode.", + ); + } + + const action = await promptBraveSearchRecovery(validation); + if (action === "skip") { + console.log(" Skipping Brave Web Search setup."); + console.log(""); + return null; + } + + apiKey = null; + usingSavedKey = false; + } + } + + async function configureWebSearch( + existingConfig: WebSearchConfig | null = null, + agent: WebSearchAgent = null, + dockerfilePathOverride: string | null = null, + ): Promise { + if (!deps.agentSupportsWebSearch(agent, dockerfilePathOverride, deps.rootDir)) { + deps.note(` Web search is not yet supported by ${agent?.displayName ?? "this agent"}. Skipping.`); + return null; + } + + if (existingConfig) { + return { fetchEnabled: true }; + } + + if (deps.isNonInteractive()) { + const braveApiKey = deps.normalizeCredentialValue(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; + } + deps.saveCredential(BRAVE_API_KEY_ENV, braveApiKey); + env[BRAVE_API_KEY_ENV] = braveApiKey; + return { fetchEnabled: true }; + } + const enableAnswer = await deps.prompt(" Enable Brave Web Search? [y/N]: "); + if (!["y", "yes"].includes(enableAnswer.trim().toLowerCase())) { + return null; + } + + const braveApiKey = await ensureValidatedBraveSearchCredential(); + if (!braveApiKey) { + return null; + } + + console.log(" ✓ Enabled Brave Web Search"); + console.log(""); + return { fetchEnabled: true }; + } + + return { + validateBraveSearchApiKey, + promptBraveSearchRecovery, + promptBraveSearchApiKey, + ensureValidatedBraveSearchCredential, + configureWebSearch, + }; +}