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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
182 changes: 23 additions & 159 deletions src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@
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,
Expand Down Expand Up @@ -409,7 +412,6 @@
}
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.
Expand Down Expand Up @@ -1868,6 +1870,26 @@
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(
Expand Down Expand Up @@ -2395,164 +2417,6 @@
);
}

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<string> {
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<string | null> {
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<WebSearchConfig | null> {
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
Expand Down
89 changes: 89 additions & 0 deletions src/lib/onboard/web-search-config.test.ts
Original file line number Diff line number Diff line change
@@ -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<Parameters<typeof createWebSearchConfigHelpers>[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();
});
});
Loading
Loading