From a4227f51894b77803ae0b6c47b42db22b042eff3 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Mon, 27 Apr 2026 09:30:02 +0000 Subject: [PATCH] fix(onboard): don't abort onboard when Brave Search key validation fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary `nemoclaw onboard --non-interactive` aborted with exit 1 whenever the Brave Web Search API key validation failed (HTTP 429 / 403 / network error / invalid key), even though Brave Web Search is an optional integration. Users were left with provider/gateway/inference partially configured but no sandbox. Downgrade the failure to a warning and skip the integration so the wizard continues to steps [5/8]–[8/8] and exits 0 if everything else succeeds. ## Related issues Closes #2507 ## Changes - src/lib/onboard.ts: in the non-interactive branch of configureWebSearch, replace process.exit(1) on validateBraveSearchApiKey failure with console.warn + return null. The warning points users at \`nemoclaw config web-search\` to re-enable the integration later. Matches the existing "no web search" return contract already used elsewhere in the function. - test/onboard-brave-validation.test.ts: regression test using the curl-shim + spawned-Node-script pattern from test/onboard-selection.test.ts. Covers (a) HTTP 429 -> result is null, no process.exit calls, warning mentions the recovery command; (b) HTTP 200 -> result is { fetchEnabled: true }. Signed-off-by: Tinson Lai --- src/lib/onboard.ts | 8 +- test/onboard-brave-validation.test.ts | 158 ++++++++++++++++++++++++++ 2 files changed, 163 insertions(+), 3 deletions(-) create mode 100644 test/onboard-brave-validation.test.ts diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index e7ecca6805a..5b478b6dbd9 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -1390,11 +1390,13 @@ async function configureWebSearch( note(" [non-interactive] Brave Web Search requested."); const validation = validateBraveSearchApiKey(braveApiKey); if (!validation.ok) { - console.error(" Brave Search API key validation failed."); + console.warn( + " Brave Search API key validation failed. Web search will be disabled — re-enable later via `nemoclaw config web-search`.", + ); if (validation.message) { - console.error(` ${validation.message}`); + console.warn(` ${validation.message}`); } - process.exit(1); + return null; } saveCredential(webSearch.BRAVE_API_KEY_ENV, braveApiKey); process.env[webSearch.BRAVE_API_KEY_ENV] = braveApiKey; diff --git a/test/onboard-brave-validation.test.ts b/test/onboard-brave-validation.test.ts new file mode 100644 index 00000000000..9feb2678f1c --- /dev/null +++ b/test/onboard-brave-validation.test.ts @@ -0,0 +1,158 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, it, expect } from "vitest"; + +type ConfigureWebSearchOutcome = { + result: { fetchEnabled: boolean } | null; + exitCalls: number[]; + logs: string[]; + warnings: string[]; + errors: string[]; +}; + +function setupBraveCurlShim( + fakeBin: string, + spec: { status: string; body: string }, +): void { + fs.mkdirSync(fakeBin, { recursive: true }); + fs.writeFileSync( + path.join(fakeBin, "curl"), + `#!/usr/bin/env bash +outfile="" +while [ "$#" -gt 0 ]; do + case "$1" in + -o) outfile="$2"; shift 2 ;; + *) shift ;; + esac +done +printf '%s' ${JSON.stringify(spec.body)} > "$outfile" +printf '%s' '${spec.status}' +`, + { mode: 0o755 }, + ); +} + +function runConfigureWebSearch(spec: { + status: string; + body: string; + apiKey: string; +}): { exitCode: number; payload: ConfigureWebSearchOutcome; stderr: string } { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-brave-")); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "configure-web-search.js"); + const outputPath = path.join(tmpDir, "outcome.json"); + const onboardPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "onboard.js")); + const outputPathLiteral = JSON.stringify(outputPath); + + setupBraveCurlShim(fakeBin, { status: spec.status, body: spec.body }); + + const script = String.raw` +const fs = require("node:fs"); +const { configureWebSearch } = require(${onboardPath}); + +const exitCalls = []; +const logs = []; +const warnings = []; +const errors = []; +const originalExit = process.exit; +const originalLog = console.log; +const originalWarn = console.warn; +const originalError = console.error; +process.exit = ((code) => { + exitCalls.push(typeof code === "number" ? code : 0); +}); +console.log = (...args) => logs.push(args.join(" ")); +console.warn = (...args) => warnings.push(args.join(" ")); +console.error = (...args) => errors.push(args.join(" ")); + +function restore() { + process.exit = originalExit; + console.log = originalLog; + console.warn = originalWarn; + console.error = originalError; +} + +(async () => { + let result = null; + try { + result = await configureWebSearch(null); + } finally { + restore(); + } + fs.writeFileSync(${outputPathLiteral}, JSON.stringify({ result, exitCalls, logs, warnings, errors })); +})().catch((error) => { + restore(); + console.error("UNEXPECTED:", error && error.stack ? error.stack : String(error)); + process.exit(2); +}); +`; + fs.writeFileSync(scriptPath, script); + + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmpDir, + PATH: `${fakeBin}:${process.env.PATH || ""}`, + NEMOCLAW_NON_INTERACTIVE: "1", + BRAVE_API_KEY: spec.apiKey, + }, + }); + + if (!fs.existsSync(outputPath)) { + throw new Error( + `Outcome file missing. exit=${result.status}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`, + ); + } + const payload = JSON.parse(fs.readFileSync(outputPath, "utf-8")) as ConfigureWebSearchOutcome; + return { + exitCode: typeof result.status === "number" ? result.status : -1, + payload, + stderr: result.stderr ?? "", + }; +} + +describe("configureWebSearch (non-interactive)", () => { + it("skips Brave Web Search and returns null when key validation hits HTTP 429", () => { + const { exitCode, payload } = runConfigureWebSearch({ + status: "429", + body: + '{"type":"ErrorResponse","error":{"id":"abc","status":429,' + + '"detail":"Request rate limit exceeded for plan",' + + '"meta":{"plan":"Free","rate_limit":1,"rate_current":1}}}', + apiKey: "fake-rate-limited-key", + }); + + expect(exitCode).toBe(0); + expect(payload.exitCalls).toEqual([]); + expect(payload.result).toBeNull(); + expect(payload.errors).toEqual([]); + expect( + payload.warnings.some((line) => + line.includes("Brave Search API key validation failed"), + ), + ).toBe(true); + expect( + payload.warnings.some((line) => line.includes("nemoclaw config web-search")), + ).toBe(true); + }); + + it("enables Brave Web Search when validation succeeds", () => { + const { exitCode, payload } = runConfigureWebSearch({ + status: "200", + body: '{"web":{"results":[]}}', + apiKey: "fake-valid-key", + }); + + expect(exitCode).toBe(0); + expect(payload.exitCalls).toEqual([]); + expect(payload.result).toEqual({ fetchEnabled: true }); + }); +});