diff --git a/scripts/dev-tier-selector.js b/scripts/dev-tier-selector.js index 543d821749..617aac3023 100644 --- a/scripts/dev-tier-selector.js +++ b/scripts/dev-tier-selector.js @@ -22,7 +22,7 @@ const creds = require("../dist/lib/credentials/store.js"); const runner = require("../dist/lib/runner.js"); const registry = require("../dist/lib/state/registry.js"); -creds.ensureApiKey = async () => {}; +creds.ensureApiKey = async () => ({ kind: "credential", value: "dev-tier-selector" }); creds.getCredential = () => null; creds.prompt = (msg) => new Promise((resolve) => { diff --git a/src/lib/credentials/store.ts b/src/lib/credentials/store.ts index 1c6c41af9d..5e370ea5ea 100644 --- a/src/lib/credentials/store.ts +++ b/src/lib/credentials/store.ts @@ -19,6 +19,11 @@ import { rejectSymlinksOnPath } from "../state/config-io"; const UNSAFE_HOME_PATHS = new Set(["/tmp", "/var/tmp", "/dev/shm", "/"]); type CredentialInput = string | null | undefined; +export type CredentialPromptIntent = + | { kind: "credential"; value: string } + | { kind: "back" } + | { kind: "exit" } + | { kind: "help" }; // Credential env keys NemoClaw knows how to round-trip. listCredentialKeys() // projects the in-process env through this set; entries not in the set are @@ -126,6 +131,15 @@ export function normalizeCredentialValue(value: CredentialInput): string { return value.replace(/\r/g, "").trim(); } +export function getCredentialPromptIntent(value: CredentialInput): CredentialPromptIntent { + const normalized = normalizeCredentialValue(value); + const navigation = normalized.toLowerCase(); + if (navigation === "back") return { kind: "back" }; + if (navigation === "exit" || navigation === "quit") return { kind: "exit" }; + if (navigation === "?" || navigation === "help") return { kind: "help" }; + return { kind: "credential", value: normalized }; +} + /** * Stage a credential for the current process. The OpenShell upsert that * follows in onboarding (`openshell provider create/update --credential KEY`) @@ -643,17 +657,24 @@ export function prompt(question: string, opts: { secret?: boolean } = {}): Promi }); } +export async function readCredentialPrompt( + question: string, + promptImpl: typeof prompt = prompt, +): Promise { + return getCredentialPromptIntent(await promptImpl(question, { secret: true })); +} + /** * Ensure `NVIDIA_API_KEY` is staged for this process. Returns immediately * if it is already in env, otherwise prompts interactively (validating * the `nvapi-` prefix) and stages the result. Onboarding registers the * value with the OpenShell gateway later in the flow. */ -export async function ensureApiKey(): Promise { +export async function ensureApiKey(): Promise { let key = getCredential("NVIDIA_API_KEY"); if (key) { process.env.NVIDIA_API_KEY = key; - return; + return { kind: "credential", value: key }; } console.log(""); @@ -668,7 +689,13 @@ export async function ensureApiKey(): Promise { console.log(""); while (true) { - key = normalizeCredentialValue(await prompt(" NVIDIA API Key: ", { secret: true })); + const input = getCredentialPromptIntent(await prompt(" NVIDIA API Key: ", { secret: true })); + if (input.kind === "help") { + console.log(" Type back to choose a different provider, or exit to quit."); + continue; + } + if (input.kind !== "credential") return input; + key = input.value; if (!key) { console.error(" NVIDIA API Key is required."); @@ -689,4 +716,5 @@ export async function ensureApiKey(): Promise { console.log(" Key staged for the OpenShell gateway. It is held in process memory only;"); console.log(" onboarding registers it with the gateway and nothing is written to disk."); console.log(""); + return { kind: "credential", value: key }; } diff --git a/src/lib/inference/model-prompts.ts b/src/lib/inference/model-prompts.ts index a91c2fcb3a..b37e4d4c1e 100644 --- a/src/lib/inference/model-prompts.ts +++ b/src/lib/inference/model-prompts.ts @@ -1,14 +1,20 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { CLOUD_MODEL_OPTIONS, HERMES_PROVIDER_MODEL_OPTIONS } from "./config"; +import { + BACK_TO_SELECTION, + type BackToSelection, +} from "../navigation"; import { isSafeModelId } from "../validation"; +import { CLOUD_MODEL_OPTIONS, HERMES_PROVIDER_MODEL_OPTIONS } from "./config"; import { validateNvidiaEndpointModel } from "./provider-models"; // credentials.ts still uses CommonJS-style exports. const { getCredential, prompt } = require("../credentials/store"); -export const BACK_TO_SELECTION = "__NEMOCLAW_BACK_TO_SELECTION__"; +export type { BackToSelection }; +export { BACK_TO_SELECTION }; +export type ModelPromptResult = string | BackToSelection; export const REMOTE_MODEL_OPTIONS: Record = { openai: ["gpt-5.4", "gpt-5.4-mini", "gpt-5.4-nano", "gpt-5.4-pro-2026-03-05"], @@ -40,7 +46,7 @@ export interface ModelPromptOptions { validateNvidiaEndpointModelFn?: (model: string, apiKey: string) => PromptValidationResult; cloudModelOptions?: Array<{ id: string; label: string }>; remoteModelOptions?: Record; - backToSelection?: string; + backToSelection?: BackToSelection; /** Pre-fill this model ID as the default in interactive prompts. */ defaultModelId?: string; /** Show only this many remote models in the first menu before offering Other. */ @@ -91,7 +97,7 @@ export async function promptManualModelId( errorLabel: string, validator: ((model: string) => PromptValidationResult) | null = null, options: ModelPromptOptions = {}, -): Promise { +): Promise { const deps = resolvePromptOptions(options); while (true) { const manual = await deps.promptFn(promptLabel); @@ -123,7 +129,7 @@ export async function promptManualModelId( } } -export async function promptCloudModel(options: ModelPromptOptions = {}): Promise { +export async function promptCloudModel(options: ModelPromptOptions = {}): Promise { const deps = resolvePromptOptions(options); const defaultModelId = options.defaultModelId ?? ""; @@ -180,7 +186,7 @@ export async function promptRemoteModel( defaultModel: string, validator: ((model: string) => PromptValidationResult) | null = null, options: ModelPromptOptions = {}, -): Promise { +): Promise { const deps = resolvePromptOptions(options); const modelOptions = deps.remoteModelOptions[providerKey] || []; const defaultIndex = modelOptions.indexOf(defaultModel); @@ -242,7 +248,7 @@ async function promptFullRemoteModelList( defaultModel: string, validator: ((model: string) => PromptValidationResult) | null, options: ModelPromptOptions, -): Promise { +): Promise { const deps = resolvePromptOptions(options); const defaultIndex = Math.max(0, modelOptions.indexOf(defaultModel)); @@ -275,7 +281,7 @@ export async function promptInputModel( defaultModel: string, validator: ((model: string) => PromptValidationResult) | null = null, options: ModelPromptOptions = {}, -): Promise { +): Promise { const deps = resolvePromptOptions(options); while (true) { const value = await deps.promptFn(` ${label} model [${defaultModel}]: `); diff --git a/src/lib/navigation.ts b/src/lib/navigation.ts new file mode 100644 index 0000000000..2f8a4d8cf8 --- /dev/null +++ b/src/lib/navigation.ts @@ -0,0 +1,9 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export const BACK_TO_SELECTION = Object.freeze({ kind: "NEMOCLAW_BACK_TO_SELECTION" }); +export type BackToSelection = typeof BACK_TO_SELECTION; + +export function isBackToSelection(value: unknown): value is BackToSelection { + return value === BACK_TO_SELECTION; +} diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 5602757800..ccfd4deffc 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -285,6 +285,14 @@ const { resolveProviderCredential, saveCredential, } = credentials; +const credentialNavigation: typeof import("./onboard/credential-navigation") = + require("./onboard/credential-navigation"); +const { + BACK_TO_SELECTION, + createCredentialPromptHelpers, + getNavigationChoice, + isBackToSelection, +} = credentialNavigation; const { hashCredential }: typeof import("./security/credential-hash") = require("./security/credential-hash"); const { cleanupStaleHostFiles, @@ -436,7 +444,6 @@ const DIM = USE_COLOR ? "\x1b[2m" : ""; const RESET = USE_COLOR ? "\x1b[0m" : ""; let OPENSHELL_BIN: string | null = null; const GATEWAY_NAME = "nemoclaw"; -const BACK_TO_SELECTION = "__NEMOCLAW_BACK_TO_SELECTION__"; type HermesAuthMethod = "oauth" | "api_key"; const HERMES_AUTH_METHOD_OAUTH: HermesAuthMethod = "oauth"; const HERMES_AUTH_METHOD_API_KEY: HermesAuthMethod = "api_key"; @@ -1325,20 +1332,14 @@ const { runCurlProbe, } = httpProbe; -function getNavigationChoice(value = ""): "back" | "exit" | null { - const normalized = String(value || "") - .trim() - .toLowerCase(); - if (normalized === "back") return "back"; - if (normalized === "exit" || normalized === "quit") return "exit"; - return null; -} - function exitOnboardFromPrompt(): never { console.log(" Exiting onboarding."); process.exit(1); } +const credentialPrompt = createCredentialPromptHelpers(exitOnboardFromPrompt); +const replaceNamedCredential = credentialPrompt.replaceNamedCredential; + function normalizeHermesAuthMethod(value: string | null | undefined): HermesAuthMethod | null { const normalized = String(value || "") .trim() @@ -1428,7 +1429,7 @@ function stageNousApiKeyProviderEnv(): void { } } -async function ensureHermesNousApiKeyEnv(): Promise { +async function ensureHermesNousApiKeyEnv(): Promise { const existing = resolveHermesNousApiKey(); if (existing) { process.env[HERMES_NOUS_API_KEY_CREDENTIAL_ENV] = existing; @@ -1437,11 +1438,8 @@ async function ensureHermesNousApiKeyEnv(): Promise { console.log(""); console.log(" Hermes Provider Nous API Key"); console.log(` Create or copy a key from ${HERMES_NOUS_API_KEY_HELP_URL}`); - const key = normalizeCredentialValue( - await prompt(" Nous API Key: ", { - secret: true, - }), - ); + const key = await credentialPrompt.readValue(" Nous API Key: "); + if (isBackToSelection(key)) return key; const validationError = validateNvidiaApiKeyValue(key, HERMES_NOUS_API_KEY_CREDENTIAL_ENV); if (validationError) { console.error(validationError); @@ -1505,37 +1503,7 @@ const { shouldForceCompletionsApi, } = validation; -async function replaceNamedCredential( - envName: string, - label: string, - helpUrl: string | null = null, - validator: ((value: string) => string | null) | null = null, -): Promise { - if (helpUrl) { - console.log(""); - console.log(` Get your ${label} from: ${helpUrl}`); - console.log(""); - } - - while (true) { - const key = normalizeCredentialValue(await prompt(` ${label}: `, { secret: true })); - if (!key) { - console.error(` ${label} is required.`); - continue; - } - const validationError = typeof validator === "function" ? validator(key) : null; - if (validationError) { - console.error(validationError); - continue; - } - saveCredential(envName, key); - process.env[envName] = key; - console.log(""); - console.log(" Credential staged. Onboarding will register it with the OpenShell gateway."); - console.log(""); - return key; - } -} +// validateNvidiaApiKeyValue — see validation import above async function promptValidationRecovery( label: string, @@ -1572,11 +1540,20 @@ async function promptValidationRecovery( // nvapi- prefix when credentialEnv === "NVIDIA_API_KEY", so passing it // unconditionally here is safe for Anthropic/OpenAI/Gemini too. const validator = (key: string) => validateNvidiaApiKeyValue(key, credentialEnv); + const replaceCredential = async (): Promise<"credential" | "selection"> => { + const result = await credentialPrompt.replaceNamedCredential( + credentialEnv, + `${label} API key`, + helpUrl, + validator, + ); + if (credentialPrompt.returningToProviderSelection(result)) return "selection"; + return "credential"; + }; if (looksLikeToken) { console.log(" ⚠️ That looks like an API key — do not paste credentials here."); console.log(" Treating as 'retry'. You will be prompted to enter the key securely."); - await replaceNamedCredential(credentialEnv, `${label} API key`, helpUrl, validator); - return "credential"; + return replaceCredential(); } if (choice === "back") { console.log(" Returning to provider selection."); @@ -1587,8 +1564,7 @@ async function promptValidationRecovery( exitOnboardFromPrompt(); } if (choice === "" || choice === "retry") { - await replaceNamedCredential(credentialEnv, `${label} API key`, helpUrl, validator); - return "credential"; + return replaceCredential(); } console.log(" Please choose a provider/model again."); console.log(""); @@ -1977,15 +1953,17 @@ async function promptBraveSearchRecovery( return "retry"; } -async function promptBraveSearchApiKey(): Promise { +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 }), - ); + const value = await credentialPrompt.readValue(" Brave Search API key: "); + if (isBackToSelection(value)) { + return value; + } + const key = normalizeCredentialValue(value); if (!key) { console.error(" Brave Search API key is required."); continue; @@ -1996,7 +1974,7 @@ async function promptBraveSearchApiKey(): Promise { async function ensureValidatedBraveSearchCredential( nonInteractive = isNonInteractive(), -): Promise { +): Promise { const savedApiKey = getCredential(webSearch.BRAVE_API_KEY_ENV); let apiKey: string | null = savedApiKey || normalizeCredentialValue(process.env[webSearch.BRAVE_API_KEY_ENV]); @@ -2009,7 +1987,11 @@ async function ensureValidatedBraveSearchCredential( "Brave Search requires BRAVE_API_KEY or a saved Brave Search credential in non-interactive mode.", ); } - apiKey = await promptBraveSearchApiKey(); + const promptedApiKey = await promptBraveSearchApiKey(); + if (isBackToSelection(promptedApiKey)) { + return promptedApiKey; + } + apiKey = promptedApiKey; usingSavedKey = false; } @@ -2086,6 +2068,9 @@ async function configureWebSearch( } const braveApiKey = await ensureValidatedBraveSearchCredential(); + if (isBackToSelection(braveApiKey)) { + return configureWebSearch(existingConfig, agent, dockerfilePathOverride); + } if (!braveApiKey) { return null; } @@ -3181,23 +3166,6 @@ function attachGatewayMetadataIfNeeded({ return false; } -async function ensureNamedCredential( - envName: string | null, - label: string, - helpUrl: string | null = null, -): Promise { - if (!envName) { - console.error(` Missing credential target for ${label}.`); - process.exit(1); - } - let key = getCredential(envName); - if (key) { - process.env[envName] = key; - return key; - } - return replaceNamedCredential(envName, label, helpUrl); -} - function waitForSandboxReady(sandboxName: string, attempts = 10, delaySeconds = 2): boolean { for (let i = 0; i < attempts; i += 1) { const list = runCaptureOpenshell(["sandbox", "list"], { ignoreError: true }); @@ -5948,13 +5916,13 @@ async function selectAndValidateOllamaModel( const { requestedModel, recoveredModel } = defaults; while (true) { const installedModels = getOllamaModelOptions(); - let model: string; + let model: string | typeof BACK_TO_SELECTION; if (isNonInteractive()) { model = requestedModel || recoveredModel || getDefaultOllamaModel(gpu); } else { model = await promptOllamaModel(gpu); } - if (model === BACK_TO_SELECTION) { + if (isBackToSelection(model)) { console.log(" Returning to provider selection."); console.log(""); return { outcome: "back-to-selection" }; @@ -6044,7 +6012,7 @@ async function setupNim( }> { step(3, 8, "Configuring inference provider"); - let model: string | null = null; + let model: string | typeof BACK_TO_SELECTION | null = null; let provider: string = REMOTE_PROVIDER_CONFIG.build.providerName; let nimContainer: string | null = null; let endpointUrl: string | null = REMOTE_PROVIDER_CONFIG.build.endpointUrl; @@ -6052,6 +6020,16 @@ async function setupNim( let hermesAuthMethod: HermesAuthMethod | null = null; let hermesToolGateways: string[] = []; let preferredInferenceApi: string | null = null; + const resetSelectionAttemptState = () => { + model = null; + provider = REMOTE_PROVIDER_CONFIG.build.providerName; + nimContainer = null; + endpointUrl = REMOTE_PROVIDER_CONFIG.build.endpointUrl; + credentialEnv = REMOTE_PROVIDER_CONFIG.build.credentialEnv; + hermesAuthMethod = null; + hermesToolGateways = []; + preferredInferenceApi = null; + }; // Detect local inference options. Bound curl with --connect-timeout/--max-time // so a half-open port or stalled listener cannot hang the onboard at step 3 @@ -6216,12 +6194,12 @@ async function setupNim( if (options.length > 1) { selectionLoop: while (true) { + resetSelectionAttemptState(); let selected: ProviderChoice | undefined; // Hoisted so downstream model-selection branches can fall back to a // recorded model from the same recovery decision. let recoveredFromSandbox = false; let recoveredModel: string | null = null; - hermesAuthMethod = null; if (isNonInteractive() || requestedProvider) { let providerKey = requestedProvider; @@ -6414,10 +6392,8 @@ async function setupNim( if (selected.key === "hermesProvider") { const selectedHermesAuthMethod = await promptHermesAuthMethod(); - if (selectedHermesAuthMethod === BACK_TO_SELECTION) { + if (credentialPrompt.returningToProviderSelection(selectedHermesAuthMethod)) { hermesAuthMethod = null; - console.log(" Returning to provider selection."); - console.log(""); continue selectionLoop; } hermesAuthMethod = selectedHermesAuthMethod; @@ -6432,7 +6408,11 @@ async function setupNim( process.exit(1); } } else { - await ensureHermesNousApiKeyEnv(); + const hermesCredentialResult = await ensureHermesNousApiKeyEnv(); + if (credentialPrompt.returningToProviderSelection(hermesCredentialResult)) { + hermesAuthMethod = null; + continue selectionLoop; + } } } else { credentialEnv = remoteConfig.credentialEnv; @@ -6475,11 +6455,7 @@ async function setupNim( }, ); } - if (model === BACK_TO_SELECTION) { - console.log(" Returning to provider selection."); - console.log(""); - continue selectionLoop; - } + if (credentialPrompt.returningToProviderSelection(model)) continue selectionLoop; preferredInferenceApi = "openai-completions"; console.log(` Using ${remoteConfig.label} with model: ${model}`); break; @@ -6516,7 +6492,7 @@ async function setupNim( process.exit(1); } } else { - await ensureApiKey(); + if (credentialPrompt.returningToProviderSelection(await ensureApiKey())) continue selectionLoop; } const _envModel = (process.env.NEMOCLAW_MODEL || "").trim(); model = @@ -6526,11 +6502,7 @@ async function setupNim( ? DEFAULT_CLOUD_MODEL : await promptCloudModel({ defaultModelId: _envModel || undefined })) || DEFAULT_CLOUD_MODEL; - if (model === BACK_TO_SELECTION) { - console.log(" Returning to provider selection."); - console.log(""); - continue selectionLoop; - } + if (credentialPrompt.returningToProviderSelection(model)) continue selectionLoop; } else { // NEMOCLAW_PROVIDER_KEY is a universal alias: if the specific credential env // isn't already set, use NEMOCLAW_PROVIDER_KEY as the API key for this provider. @@ -6564,7 +6536,7 @@ async function setupNim( backToSelection: BACK_TO_SELECTION, isNonInteractive, promptInputModel, - replaceNamedCredential, + replaceNamedCredential: credentialPrompt.replaceNamedCredential, }); if (bedrockSelection.action === "retry-selection") { console.log(" Returning to provider selection."); @@ -6584,11 +6556,12 @@ async function setupNim( process.exit(1); } } else { - await ensureNamedCredential( + const credentialResult = await credentialPrompt.ensureNamedCredential( selectedCredentialEnv, remoteConfig.label + " API key", remoteConfig.helpUrl, ); + if (credentialPrompt.returningToProviderSelection(credentialResult)) continue selectionLoop; } let modelValidator: ((candidate: string) => ModelValidationResult) | null = null; if (selected.key === "openai" || selected.key === "gemini") { @@ -6622,11 +6595,7 @@ async function setupNim( } else { model = await promptInputModel(remoteConfig.label, defaultModel, modelValidator); } - if (model === BACK_TO_SELECTION) { - console.log(" Returning to provider selection."); - console.log(""); - continue selectionLoop; - } + if (credentialPrompt.returningToProviderSelection(model)) continue selectionLoop; if (selected.key === "custom") { const validation = await validateCustomOpenAiLikeSelection( @@ -6785,6 +6754,7 @@ async function setupNim( const models = nim.listModels().filter((m) => m.minGpuMemoryMB <= localGpu.totalMemoryMB); if (models.length === 0) { console.log(" No NIM models fit your GPU VRAM. Falling back to cloud API."); + model = requestedModel || (recoveredFromSandbox && recoveredModel) || DEFAULT_CLOUD_MODEL; } else { let sel; if (isNonInteractive()) { @@ -6829,9 +6799,8 @@ async function setupNim( console.log(" NGC API Key required to pull NIM images."); console.log(" Get one from: https://org.ngc.nvidia.com/setup/api-key"); console.log(""); - let ngcKey = normalizeCredentialValue( - await prompt(" NGC API Key: ", { secret: true }), - ); + let ngcKey = await credentialPrompt.readValue(" NGC API Key: "); + if (credentialPrompt.returningToProviderSelection(ngcKey)) continue selectionLoop; if (!ngcKey) { console.error(" NGC API Key is required for Local NIM."); process.exit(1); @@ -6839,7 +6808,8 @@ async function setupNim( if (!nim.dockerLoginNgc(ngcKey)) { console.error(" Failed to login to NGC registry. Check your API key and try again."); console.log(""); - ngcKey = normalizeCredentialValue(await prompt(" NGC API Key: ", { secret: true })); + ngcKey = await credentialPrompt.readValue(" NGC API Key: "); + if (credentialPrompt.returningToProviderSelection(ngcKey)) continue selectionLoop; if (!ngcKey || !nim.dockerLoginNgc(ngcKey)) { console.error(" NGC login failed. Cannot pull NIM images."); process.exit(1); @@ -6861,9 +6831,9 @@ async function setupNim( console.log(""); console.log(" NGC API Key required to download NIM model weights at runtime."); console.log(" (Docker is logged in to nvcr.io, but the key was not saved.)"); - ngcApiKey = normalizeCredentialValue( - await prompt(" NGC API Key: ", { secret: true }), - ); + const ngcKey = await credentialPrompt.readValue(" NGC API Key: "); + if (credentialPrompt.returningToProviderSelection(ngcKey)) continue selectionLoop; + ngcApiKey = ngcKey || null; } } @@ -7202,6 +7172,9 @@ async function setupNim( console.error(" Local vLLM validation URL could not be determined."); process.exit(1); } + if (isBackToSelection(model)) { + continue selectionLoop; + } const validation = await validateOpenAiLikeSelection( "Local vLLM", validationBaseUrl, @@ -7258,7 +7231,13 @@ async function setupNim( console.log(" Model Router accepts NVIDIA API keys (nvapi-...)."); console.log(" Get one at https://build.nvidia.com"); console.log(""); - await ensureNamedCredential(routerCredentialEnv, "Model Router API key", null); + const routerCredentialResult = await credentialPrompt.ensureNamedCredential( + routerCredentialEnv, + "Model Router API key", + null, + ); + if (credentialPrompt.returningToProviderSelection(routerCredentialResult)) + continue selectionLoop; } } provider = bp.provider_name || "nvidia-router"; @@ -7278,7 +7257,7 @@ async function setupNim( } return { - model, + model: isBackToSelection(model) ? null : model, provider, endpointUrl, credentialEnv, @@ -9517,6 +9496,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { removeSandboxFromRegistry: registry.removeSandbox.bind(registry), repairRecordedSandbox, ensureValidatedBraveSearchCredential, + isBackToSelection, configureWebSearch, startRecordedStep, getRecordedMessagingChannelsForResume, diff --git a/src/lib/onboard/bedrock-runtime.test.ts b/src/lib/onboard/bedrock-runtime.test.ts index 9ef5fd0fa9..e786d6d2c0 100644 --- a/src/lib/onboard/bedrock-runtime.test.ts +++ b/src/lib/onboard/bedrock-runtime.test.ts @@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { selectBedrockRuntimeCustomAnthropic } from "../../../dist/lib/onboard/bedrock-runtime"; +import { BACK_TO_SELECTION } from "../../../dist/lib/onboard/credential-navigation"; const BEDROCK_URL = "https://bedrock-runtime.us-east-1.amazonaws.com"; @@ -35,7 +36,7 @@ describe("Bedrock Runtime onboarding helper", () => { label: "Other Anthropic-compatible endpoint", helpUrl: null, defaultModel: "anthropic.claude", - backToSelection: "__BACK__", + backToSelection: BACK_TO_SELECTION, isNonInteractive: () => false, promptInputModel, replaceNamedCredential, @@ -53,6 +54,35 @@ describe("Bedrock Runtime onboarding helper", () => { }); }); + it("returns to provider selection when the Bedrock-compatible credential prompt chooses back", async () => { + clearBedrockAuthEnv(); + const replaceNamedCredential = vi.fn(async () => BACK_TO_SELECTION); + const promptInputModel = vi.fn(async () => { + throw new Error("model prompt should not run after back navigation"); + }); + + const result = await selectBedrockRuntimeCustomAnthropic({ + selectedKey: "anthropicCompatible", + endpointUrl: BEDROCK_URL, + credentialEnv: "COMPATIBLE_ANTHROPIC_API_KEY", + label: "Other Anthropic-compatible endpoint", + helpUrl: null, + defaultModel: "anthropic.claude", + backToSelection: BACK_TO_SELECTION, + isNonInteractive: () => false, + promptInputModel, + replaceNamedCredential, + }); + + expect(replaceNamedCredential).toHaveBeenCalledWith( + "COMPATIBLE_ANTHROPIC_API_KEY", + "Other Anthropic-compatible endpoint API key", + null, + ); + expect(promptInputModel).not.toHaveBeenCalled(); + expect(result).toEqual({ action: "retry-selection" }); + }); + it("accepts an explicit AWS profile without prompting for the compatible endpoint key", async () => { clearBedrockAuthEnv(); process.env.AWS_PROFILE = "bedrock-dev"; @@ -65,7 +95,7 @@ describe("Bedrock Runtime onboarding helper", () => { label: "Other Anthropic-compatible endpoint", helpUrl: null, defaultModel: "anthropic.claude", - backToSelection: "__BACK__", + backToSelection: BACK_TO_SELECTION, isNonInteractive: () => true, promptInputModel: vi.fn(async () => { throw new Error("non-interactive selection should not prompt"); diff --git a/src/lib/onboard/bedrock-runtime.ts b/src/lib/onboard/bedrock-runtime.ts index 72cfe58e2f..5c01f33433 100644 --- a/src/lib/onboard/bedrock-runtime.ts +++ b/src/lib/onboard/bedrock-runtime.ts @@ -10,6 +10,7 @@ import { isBedrockRuntimeEndpoint, } from "../inference/bedrock-runtime"; import { ensureBedrockRuntimeAdapter } from "../inference/bedrock-runtime-adapter"; +import type { BackToSelection } from "../navigation"; import { redact } from "../runner"; import * as registry from "../state/registry"; import { LOCAL_INFERENCE_TIMEOUT_SECS } from "./env"; @@ -61,10 +62,18 @@ export async function selectBedrockRuntimeCustomAnthropic(options: { label: string; helpUrl: string | null; defaultModel: string; - backToSelection: string; + backToSelection: BackToSelection; isNonInteractive: () => boolean; - promptInputModel: (label: string, defaultModel: string, validator: null) => Promise; - replaceNamedCredential: (envName: string, label: string, helpUrl: string | null) => Promise; + promptInputModel: ( + label: string, + defaultModel: string, + validator: null, + ) => Promise; + replaceNamedCredential: ( + envName: string, + label: string, + helpUrl: string | null, + ) => Promise; }): Promise< | { action: "not-bedrock" } | { action: "retry-selection" } @@ -82,7 +91,14 @@ export async function selectBedrockRuntimeCustomAnthropic(options: { printMissingBedrockAuth(); process.exit(1); } - await options.replaceNamedCredential(credentialEnv, `${options.label} API key`, options.helpUrl); + const credentialResult = await options.replaceNamedCredential( + credentialEnv, + `${options.label} API key`, + options.helpUrl, + ); + if (credentialResult === options.backToSelection) { + return { action: "retry-selection" }; + } } const model = options.isNonInteractive() @@ -91,6 +107,9 @@ export async function selectBedrockRuntimeCustomAnthropic(options: { if (model === options.backToSelection) { return { action: "retry-selection" }; } + if (typeof model !== "string") { + return { action: "retry-selection" }; + } return { action: "selected", model, preferredInferenceApi: "openai-completions" }; } diff --git a/src/lib/onboard/credential-navigation.test.ts b/src/lib/onboard/credential-navigation.test.ts new file mode 100644 index 0000000000..4941668578 --- /dev/null +++ b/src/lib/onboard/credential-navigation.test.ts @@ -0,0 +1,53 @@ +// 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 { + BACK_TO_SELECTION, + returningToProviderSelection, + shouldReturnToProviderSelection, +} from "../../../dist/lib/onboard/credential-navigation"; + +describe("credential prompt navigation helpers", () => { + it("treats both the shared back sentinel and credential back intents as provider-selection navigation", () => { + const exitOnboard = vi.fn(() => { + throw new Error("unexpected exit"); + }) as unknown as () => never; + + expect(shouldReturnToProviderSelection(BACK_TO_SELECTION, exitOnboard)).toBe(true); + expect(shouldReturnToProviderSelection({ kind: "back" }, exitOnboard)).toBe(true); + expect(shouldReturnToProviderSelection({ kind: "credential", value: "back" }, exitOnboard)).toBe( + false, + ); + expect(exitOnboard).not.toHaveBeenCalled(); + }); + + it("exits for credential exit intents instead of treating them as back navigation", () => { + const exitError = new Error("exit"); + const exitOnboard = vi.fn(() => { + throw exitError; + }) as unknown as () => never; + + expect(() => shouldReturnToProviderSelection({ kind: "exit" }, exitOnboard)).toThrow(exitError); + expect(exitOnboard).toHaveBeenCalledTimes(1); + }); + + it("prints the provider-selection message whenever a value returns to provider selection", () => { + const logs: string[] = []; + const originalLog = console.log; + console.log = (...args: unknown[]) => logs.push(args.join(" ")); + try { + const exitOnboard = vi.fn(() => { + throw new Error("unexpected exit"); + }) as unknown as () => never; + + expect(returningToProviderSelection({ kind: "back" }, exitOnboard)).toBe(true); + expect(returningToProviderSelection({ kind: "help" }, exitOnboard)).toBe(false); + } finally { + console.log = originalLog; + } + + expect(logs).toEqual([" Returning to provider selection.", ""]); + }); +}); diff --git a/src/lib/onboard/credential-navigation.ts b/src/lib/onboard/credential-navigation.ts new file mode 100644 index 0000000000..ace42defa6 --- /dev/null +++ b/src/lib/onboard/credential-navigation.ts @@ -0,0 +1,159 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import * as credentials from "../credentials/store"; +import { + BACK_TO_SELECTION, + type BackToSelection, + isBackToSelection, +} from "../navigation"; + +export type BackNavigationResult = BackToSelection | { kind: "back" }; +export type { BackToSelection }; +export { BACK_TO_SELECTION, isBackToSelection }; + +export function getNavigationChoice(value = ""): "back" | "exit" | null { + const normalized = String(value || "") + .trim() + .toLowerCase(); + if (normalized === "back") return "back"; + if (normalized === "exit" || normalized === "quit") return "exit"; + return null; +} + +export function getCredentialPromptNavigation(intent: unknown): "back" | "exit" | null { + if (!intent || typeof intent !== "object") return null; + const kind = (intent as { kind?: unknown }).kind; + if (kind === "back" || kind === "exit") return kind; + return null; +} + +export function printReturningToProviderSelection(): void { + console.log(" Returning to provider selection."); + console.log(""); +} + +export function shouldReturnToProviderSelection( + result: unknown, + exitOnboardFromPrompt: () => never, +): boolean { + const navigation = getCredentialPromptNavigation(result); + if (navigation === "exit") exitOnboardFromPrompt(); + return navigation === "back" || isBackToSelection(result); +} + +export function returningToProviderSelection( + result: unknown, + exitOnboardFromPrompt: () => never, +): result is BackNavigationResult { + if (!shouldReturnToProviderSelection(result, exitOnboardFromPrompt)) return false; + printReturningToProviderSelection(); + return true; +} + +export async function readCredentialValue( + question: string, + exitOnboardFromPrompt: () => never, +): Promise { + while (true) { + const input = await credentials.readCredentialPrompt(question, credentials.prompt); + if (shouldReturnToProviderSelection(input, exitOnboardFromPrompt)) return BACK_TO_SELECTION; + if (input.kind === "help") { + console.log(" Type back to choose a different provider, or exit to quit."); + continue; + } + return input.kind === "credential" ? input.value : ""; + } +} + +export async function replaceNamedCredential({ + envName, + label, + helpUrl = null, + validator = null, + exitOnboardFromPrompt, +}: { + envName: string; + label: string; + helpUrl?: string | null; + validator?: ((value: string) => string | null) | null; + exitOnboardFromPrompt: () => never; +}): Promise { + if (helpUrl) { + console.log(""); + console.log(` Get your ${label} from: ${helpUrl}`); + console.log(""); + } + + while (true) { + const key = await readCredentialValue(` ${label}: `, exitOnboardFromPrompt); + if (isBackToSelection(key)) return key; + if (!key) { + console.error(` ${label} is required.`); + continue; + } + const validationError = typeof validator === "function" ? validator(key) : null; + if (validationError) { + console.error(validationError); + continue; + } + credentials.saveCredential(envName, key); + process.env[envName] = key; + console.log(""); + console.log(" Credential staged. Onboarding will register it with the OpenShell gateway."); + console.log(""); + return key; + } +} + +export async function ensureNamedCredential({ + envName, + label, + helpUrl = null, + exitOnboardFromPrompt, +}: { + envName: string | null; + label: string; + helpUrl?: string | null; + exitOnboardFromPrompt: () => never; +}): Promise { + if (!envName) { + console.error(` Missing credential target for ${label}.`); + process.exit(1); + } + const key = credentials.getCredential(envName); + if (key) { + process.env[envName] = key; + return key; + } + return replaceNamedCredential({ envName, label, helpUrl, exitOnboardFromPrompt }); +} + +export function createCredentialPromptHelpers(exitOnboardFromPrompt: () => never): { + readValue: (question: string) => Promise; + replaceNamedCredential: ( + envName: string, + label: string, + helpUrl?: string | null, + validator?: ((value: string) => string | null) | null, + ) => Promise; + ensureNamedCredential: ( + envName: string | null, + label: string, + helpUrl?: string | null, + ) => Promise; + shouldReturnToProviderSelection: (result: unknown) => boolean; + returningToProviderSelection: (result: unknown) => result is BackNavigationResult; +} { + return { + readValue: (question) => readCredentialValue(question, exitOnboardFromPrompt), + replaceNamedCredential: (envName, label, helpUrl = null, validator = null) => + replaceNamedCredential({ envName, label, helpUrl, validator, exitOnboardFromPrompt }), + ensureNamedCredential: (envName, label, helpUrl = null) => + ensureNamedCredential({ envName, label, helpUrl, exitOnboardFromPrompt }), + shouldReturnToProviderSelection: (result) => + shouldReturnToProviderSelection(result, exitOnboardFromPrompt), + returningToProviderSelection: (result) => + returningToProviderSelection(result, exitOnboardFromPrompt), + }; +} diff --git a/src/lib/onboard/machine/handlers/sandbox.test.ts b/src/lib/onboard/machine/handlers/sandbox.test.ts index e8a12d7601..c1a88b3a64 100644 --- a/src/lib/onboard/machine/handlers/sandbox.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox.test.ts @@ -24,6 +24,7 @@ function createDeps(overrides: Partial "brave-key"), + isBackToSelection: vi.fn(() => false), configureWebSearch: vi.fn(async () => null as WebSearchConfig | null), startStep: vi.fn(async () => undefined), getRecordedChannels: vi.fn(() => null), @@ -61,6 +62,7 @@ function createDeps(overrides: Partial { expect(calls.createSandbox).toHaveBeenCalled(); }); + it("drops saved web search config when credential revalidation returns to provider selection", async () => { + const session = createSession({ sandboxName: "saved", webSearchConfig: { fetchEnabled: true } }); + session.steps.sandbox.status = "complete"; + const backToSelection = Object.freeze({ kind: "NEMOCLAW_BACK_TO_SELECTION" }); + const { deps, calls } = createDeps({ + getSandboxReuseState: () => "not_ready", + ensureValidatedBraveSearchCredential: vi.fn(async () => backToSelection), + isBackToSelection: vi.fn((value: unknown) => value === backToSelection), + }); + + const result = await handleSandboxState({ + ...baseOptions(deps, session), + resume: true, + sandboxName: "saved", + webSearchConfig: { fetchEnabled: true }, + }); + + expect(calls.configureWebSearch).not.toHaveBeenCalled(); + expect(calls.createSandbox).toHaveBeenCalledWith( + { type: "nvidia" }, + "model", + "provider", + "openai-completions", + "saved", + null, + [], + null, + null, + null, + { sandboxGpuEnabled: false, mode: "0" }, + [], + ); + expect(result.webSearchConfig).toBeNull(); + }); + it("uses recorded messaging channels on non-interactive resume", async () => { const { deps, calls } = createDeps({ getRecordedMessagingChannelsForResume: vi.fn(() => ["discord"]) }); diff --git a/src/lib/onboard/machine/handlers/sandbox.ts b/src/lib/onboard/machine/handlers/sandbox.ts index 47755a05cc..8ee564da33 100644 --- a/src/lib/onboard/machine/handlers/sandbox.ts +++ b/src/lib/onboard/machine/handlers/sandbox.ts @@ -40,7 +40,8 @@ export interface SandboxStateOptions; + ensureValidatedBraveSearchCredential(): Promise; + isBackToSelection(value: unknown): boolean; configureWebSearch( existingConfig: WebSearchConfig | null, agent: Agent, @@ -216,7 +217,11 @@ export async function handleSandboxState { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-creds-")); + const credentials = await importCredentialsModule(home); + + await expect( + credentials.readCredentialPrompt("secret: ", async () => " back \r\n"), + ).resolves.toEqual({ kind: "back" }); + await expect( + credentials.readCredentialPrompt("secret: ", async () => "QUIT"), + ).resolves.toEqual({ kind: "exit" }); + await expect( + credentials.readCredentialPrompt("secret: ", async () => "?"), + ).resolves.toEqual({ kind: "help" }); + await expect( + credentials.readCredentialPrompt("secret: ", async () => " help "), + ).resolves.toEqual({ kind: "help" }); + await expect( + credentials.readCredentialPrompt("secret: ", async () => " sk-real-key "), + ).resolves.toEqual({ kind: "credential", value: "sk-real-key" }); + }); + + it("re-prompts shared credential prompts after help input", () => { + const script = ` +const credentials = require(${JSON.stringify(path.join(import.meta.dirname, "..", "dist", "lib", "credentials", "store.js"))}); +const { createCredentialPromptHelpers } = require(${JSON.stringify(path.join(import.meta.dirname, "..", "dist", "lib", "onboard", "credential-navigation.js"))}); +const answers = ["help", "sk-real-key"]; +const logs = []; +credentials.prompt = async () => answers.shift() || ""; +const originalLog = console.log; +console.log = (...args) => logs.push(args.join(" ")); +createCredentialPromptHelpers(() => { throw new Error("unexpected exit"); }).readValue("secret: ") + .then((value) => { + console.log = originalLog; + console.log(JSON.stringify({ value, logs, remaining: answers.length })); + }) + .catch((err) => { console.log = originalLog; console.error(err && err.stack ? err.stack : String(err)); process.exit(1); }); +`; + const result = spawnSync(process.execPath, ["-e", script], { + encoding: "utf-8", + timeout: 5000, + }); + + expect(result.status).toBe(0); + const payload = JSON.parse(String(result.stdout).trim()); + expect(payload).toEqual({ + value: "sk-real-key", + logs: [" Type back to choose a different provider, or exit to quit."], + remaining: 0, + }); + }); + it("re-raises SIGINT from standard readline prompts instead of treating it like an empty answer", async () => { const readline = require("node:readline") as typeof import("node:readline"); const rl = new EventEmitter() as EventEmitter & { @@ -728,7 +779,7 @@ set -euo pipefail pipe="$(mktemp -u)" mkfifo "$pipe" trap 'rm -f "$pipe"' EXIT -{ printf 'not-a-key\\n'; sleep 0.2; printf 'nvapi-good-key\\n'; } > "$pipe" & +{ printf 'not-a-key\\n'; sleep 1; printf 'nvapi-good-key\\n'; } > "$pipe" & ${JSON.stringify(process.execPath)} ${JSON.stringify(scriptFile)} < "$pipe" `; let result: ReturnType; @@ -752,6 +803,46 @@ ${JSON.stringify(process.execPath)} ${JSON.stringify(scriptFile)} < "$pipe" expect(result.stdout).toContain("STAGED=nvapi-good-key"); }); + it("returns navigation from the NVIDIA API key prompt without staging it", () => { + const script = ` +const { ensureApiKey } = require(${JSON.stringify(path.join(import.meta.dirname, "..", "dist", "lib", "credentials", "store.js"))}); +delete process.env.NVIDIA_API_KEY; +ensureApiKey() + .then((result) => console.log(JSON.stringify({ result, key: process.env.NVIDIA_API_KEY || null }))) + .catch((err) => { console.error(err && err.stack ? err.stack : String(err)); process.exit(1); }); +`; + const result = spawnSync(process.execPath, ["-e", script], { + encoding: "utf-8", + input: "back\n", + env: { ...process.env, NVIDIA_API_KEY: "" }, + timeout: 5000, + }); + + expect(result.status).toBe(0); + const payload = JSON.parse(String(result.stdout).trim().split("\n").pop() || "{}"); + expect(payload).toEqual({ result: { kind: "back" }, key: null }); + }); + + it("returns exit from the NVIDIA API key prompt without staging it", () => { + const script = ` +const { ensureApiKey } = require(${JSON.stringify(path.join(import.meta.dirname, "..", "dist", "lib", "credentials", "store.js"))}); +delete process.env.NVIDIA_API_KEY; +ensureApiKey() + .then((result) => console.log(JSON.stringify({ result, key: process.env.NVIDIA_API_KEY || null }))) + .catch((err) => { console.error(err && err.stack ? err.stack : String(err)); process.exit(1); }); +`; + const result = spawnSync(process.execPath, ["-e", script], { + encoding: "utf-8", + input: "exit\n", + env: { ...process.env, NVIDIA_API_KEY: "" }, + timeout: 5000, + }); + + expect(result.status).toBe(0); + const payload = JSON.parse(String(result.stdout).trim().split("\n").pop() || "{}"); + expect(payload).toEqual({ result: { kind: "exit" }, key: null }); + }); + it("normal and secret prompts re-ref, cleanup stdin, and preserve masked input", () => { const script = ` const { prompt } = require(${JSON.stringify(path.join(import.meta.dirname, "..", "dist", "lib", "credentials", "store.js"))}); diff --git a/test/onboard-brave-validation.test.ts b/test/onboard-brave-validation.test.ts index 588a38b107..1f5f4b1378 100644 --- a/test/onboard-brave-validation.test.ts +++ b/test/onboard-brave-validation.test.ts @@ -7,6 +7,10 @@ import os from "node:os"; import path from "node:path"; import { describe, it, expect } from "vitest"; +import { testTimeout } from "./helpers/timeouts"; + +const BRAVE_VALIDATION_TEST_TIMEOUT_MS = testTimeout(60_000); + type ConfigureWebSearchOutcome = { result: { fetchEnabled: boolean } | null; exitCalls: number[]; @@ -119,6 +123,137 @@ function restore() { }; } +function runInteractiveConfigureWebSearch(spec: { answers: string[] }): { + exitCode: number; + payload: { + outcome: "completed" | "exit"; + result?: { fetchEnabled: boolean } | null; + exitCode?: number; + logs: string[]; + errors: string[]; + prompts: Array<{ message: string; secret: boolean }>; + saved: Array<{ key: string; value: string }>; + braveKey: string | null; + }; + stderr: string; +} { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-brave-interactive-")); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "configure-web-search-interactive.js"); + const outputPath = path.join(tmpDir, "outcome.json"); + const onboardPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "onboard.js")); + const credentialsPath = JSON.stringify( + path.join(repoRoot, "dist", "lib", "credentials", "store.js"), + ); + const outputPathLiteral = JSON.stringify(outputPath); + + setupBraveCurlShim(fakeBin, { status: "200", body: '{"web":{"results":[]}}' }); + + const script = String.raw` +const fs = require("node:fs"); + +const clearEnv = [ + "BRAVE_API_KEY", + "NEMOCLAW_NON_INTERACTIVE", + "NEMOCLAW_PROVIDER", + "NEMOCLAW_MODEL", + "NEMOCLAW_YES", + "NEMOCLAW_PREFERRED_API", + "NEMOCLAW_EXPERIMENTAL", +]; +for (const key of clearEnv) { + delete process.env[key]; +} + +const credentials = require(${credentialsPath}); +const answers = ${JSON.stringify(spec.answers)}; +const logs = []; +const errors = []; +const prompts = []; +const saved = []; + +credentials.prompt = async (message, opts = {}) => { + prompts.push({ message, secret: opts.secret === true }); + return answers.shift() || ""; +}; +const originalSaveCredential = credentials.saveCredential; +credentials.saveCredential = (key, value) => { + saved.push({ key, value }); + return originalSaveCredential(key, value); +}; + +const { configureWebSearch } = require(${onboardPath}); +const originalExit = process.exit; +const originalLog = console.log; +const originalError = console.error; +process.exit = (code) => { + const error = new Error("process.exit:" + code); + error.exitCode = code; + throw error; +}; +console.log = (...args) => logs.push(args.join(" ")); +console.error = (...args) => errors.push(args.join(" ")); + +function writePayload(payload) { + fs.writeFileSync(${outputPathLiteral}, JSON.stringify({ + ...payload, + logs, + errors, + prompts, + saved, + braveKey: process.env.BRAVE_API_KEY || null, + })); +} + +(async () => { + try { + const result = await configureWebSearch(null); + writePayload({ outcome: "completed", result }); + } catch (error) { + if (error && error.exitCode !== undefined) { + writePayload({ outcome: "exit", exitCode: error.exitCode }); + return; + } + throw error; + } finally { + process.exit = originalExit; + console.log = originalLog; + console.error = originalError; + } +})().catch((error) => { + process.exit = originalExit; + console.log = originalLog; + console.error = originalError; + 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 || ""}`, + }, + timeout: BRAVE_VALIDATION_TEST_TIMEOUT_MS, + }); + + if (!fs.existsSync(outputPath)) { + throw new Error( + `Outcome file missing. exit=${result.status}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`, + ); + } + return { + exitCode: typeof result.status === "number" ? result.status : -1, + payload: JSON.parse(fs.readFileSync(outputPath, "utf-8")), + stderr: result.stderr ?? "", + }; +} + describe("configureWebSearch (non-interactive)", () => { it("skips unsupported Hermes without prompting for Brave", () => { const repoRoot = path.join(import.meta.dirname, ".."); @@ -215,3 +350,39 @@ const { loadAgent } = require(${agentDefsPath}); expect(payload.result).toEqual({ fetchEnabled: true }); }); }); + +describe("configureWebSearch (interactive)", () => { + it("returns to the Brave Search enable prompt when backing out of the API key prompt", () => { + const { exitCode, payload } = runInteractiveConfigureWebSearch({ + answers: ["y", "back", "n"], + }); + + expect(exitCode).toBe(0); + expect(payload.outcome).toBe("completed"); + expect(payload.result).toBeNull(); + expect(payload.braveKey).toBeNull(); + expect(payload.errors).toEqual([]); + expect(payload.saved.every((entry) => entry.value !== "back")).toBe(true); + expect( + payload.prompts.filter((entry) => /Enable Brave Web Search\?/.test(entry.message)), + ).toHaveLength(2); + expect( + payload.prompts.some( + (entry) => /Brave Search API key: /.test(entry.message) && entry.secret, + ), + ).toBe(true); + }); + + it("exits from the Brave Search API key prompt", () => { + const { exitCode, payload } = runInteractiveConfigureWebSearch({ + answers: ["y", "exit"], + }); + + expect(exitCode).toBe(0); + expect(payload.outcome).toBe("exit"); + expect(payload.exitCode).toBe(1); + expect(payload.braveKey).toBeNull(); + expect(payload.saved).toEqual([]); + expect(payload.logs.some((line) => line.includes("Exiting onboarding."))).toBe(true); + }); +}); diff --git a/test/onboard-selection.test.ts b/test/onboard-selection.test.ts index 477f537956..4f8e4673e5 100644 --- a/test/onboard-selection.test.ts +++ b/test/onboard-selection.test.ts @@ -126,6 +126,217 @@ printf '%s' "$status" ); } +type CredentialBackScenario = { + name: string; + answers: string[]; + credentialEnv: string; + promptPattern: RegExp; + expectedOutcome?: "back" | "exit"; + env?: Record; + agent?: "hermes"; + gpu?: Record | null; + stubNim?: boolean; +}; + +function writeAlwaysOkCurl(fakeBin: string) { + fs.writeFileSync( + path.join(fakeBin, "curl"), + `#!/usr/bin/env bash +body='{"id":"resp_123"}' +status="200" +outfile="" +while [ "$#" -gt 0 ]; do + case "$1" in + -o) outfile="$2"; shift 2 ;; + *) shift ;; + esac +done +if [ -n "$outfile" ]; then + printf '%s' "$body" > "$outfile" +fi +printf '%s' "$status" +`, + { mode: 0o755 }, + ); +} + +function runCredentialBackScenario(scenario: CredentialBackScenario) { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-credential-back-")); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join( + tmpDir, + `${scenario.name.toLowerCase().replace(/[^a-z0-9]+/g, "-")}.js`, + ); + const onboardPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "onboard.js")); + const credentialsPath = JSON.stringify( + path.join(repoRoot, "dist", "lib", "credentials", "store.js"), + ); + const runnerPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "runner.js")); + const agentDefsPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "agent", "defs.js")); + const nimPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "inference", "nim.js")); + + fs.mkdirSync(fakeBin, { recursive: true }); + writeAlwaysOkCurl(fakeBin); + + const script = String.raw` +const answers = ${JSON.stringify(scenario.answers)}; +const expectedOutcome = ${JSON.stringify(scenario.expectedOutcome || "back")}; +const scenarioEnv = ${JSON.stringify(scenario.env || {})}; +const messages = []; +const prompts = []; +const saved = []; +const clearCredentialEnv = [ + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "GEMINI_API_KEY", + "COMPATIBLE_API_KEY", + "COMPATIBLE_ANTHROPIC_API_KEY", + "NOUS_API_KEY", + "NVIDIA_API_KEY", + "NGC_API_KEY", + "NEMOCLAW_PROVIDER_KEY", +]; +const clearOnboardControlEnv = [ + "NEMOCLAW_NON_INTERACTIVE", + "NEMOCLAW_PROVIDER", + "NEMOCLAW_MODEL", + "NEMOCLAW_YES", + "NEMOCLAW_PREFERRED_API", + "NEMOCLAW_EXPERIMENTAL", +]; + +for (const key of [...clearCredentialEnv, ...clearOnboardControlEnv]) { + delete process.env[key]; +} +Object.assign(process.env, scenarioEnv); + +const credentials = require(${credentialsPath}); +const runner = require(${runnerPath}); +const nim = require(${nimPath}); + +credentials.prompt = async (message, opts = {}) => { + messages.push(message); + prompts.push({ message, secret: opts.secret === true }); + return answers.shift() || ""; +}; +credentials.ensureApiKey = async () => { + return { kind: "credential", value: "nvapi-good" }; +}; +const originalSaveCredential = credentials.saveCredential; +credentials.saveCredential = (key, value) => { + saved.push({ key, value }); + return originalSaveCredential(key, value); +}; +runner.runCapture = () => ""; + +if (${JSON.stringify(scenario.stubNim === true)}) { + nim.isNgcLoggedIn = () => false; + nim.dockerLoginNgc = () => { + throw new Error("NGC login should not run after back navigation"); + }; + nim.pullNimImage = () => "image"; + nim.startNimContainerByName = () => "container"; + nim.waitForNimHealth = () => true; +} + +const { setupNim } = require(${onboardPath}); +const agent = ${JSON.stringify(scenario.agent || null)} + ? require(${agentDefsPath}).loadAgent(${JSON.stringify(scenario.agent || null)}) + : null; + +(async () => { + const originalLog = console.log; + const originalError = console.error; + const originalExit = process.exit; + const lines = []; + console.log = (...args) => lines.push(args.join(" ")); + console.error = (...args) => lines.push(args.join(" ")); + if (expectedOutcome === "exit") { + process.exit = (code) => { + const error = new Error("process.exit:" + code); + error.exitCode = code; + throw error; + }; + } + try { + const result = await setupNim(${JSON.stringify(scenario.gpu ?? null)}, null, agent); + originalLog(JSON.stringify({ + outcome: "completed", + result, + messages, + prompts, + lines, + saved, + credentialValue: process.env[${JSON.stringify(scenario.credentialEnv)}] || null, + })); + } catch (error) { + if (expectedOutcome !== "exit" || error.exitCode === undefined) { + throw error; + } + originalLog(JSON.stringify({ + outcome: "exit", + exitCode: error.exitCode, + messages, + prompts, + lines, + saved, + credentialValue: process.env[${JSON.stringify(scenario.credentialEnv)}] || null, + })); + } finally { + console.log = originalLog; + console.error = originalError; + process.exit = originalExit; + } +})().catch((error) => { + console.error(error && error.stack ? error.stack : String(error)); + process.exit(1); +}); +`; + 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 || ""}`, + }, + timeout: PROVIDER_SELECTION_TEST_TIMEOUT_MS, + }); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout.trim()); + if (scenario.expectedOutcome === "exit") { + assert.equal(payload.outcome, "exit"); + assert.equal(payload.exitCode, 1); + assert.equal(payload.credentialValue, null); + assert.deepEqual(payload.saved, []); + assert.ok(payload.lines.some((line: string) => line.includes("Exiting onboarding."))); + assert.ok( + payload.prompts.some( + (entry: { message: string; secret: boolean }) => + scenario.promptPattern.test(entry.message) && entry.secret, + ), + ); + return; + } + assert.equal(payload.outcome, "completed"); + assert.equal(payload.result.provider, "nvidia-prod"); + assert.ok(payload.lines.some((line: string) => line.includes("Returning to provider selection."))); + assert.ok( + payload.prompts.some( + (entry: { message: string; secret: boolean }) => + scenario.promptPattern.test(entry.message) && entry.secret, + ), + ); + assert.ok( + payload.saved.every((entry: { key: string; value: string }) => entry.value !== "back"), + ); + assert.equal(payload.credentialValue, null); +} + describe("onboard provider selection UX", { timeout: PROVIDER_SELECTION_TEST_TIMEOUT_MS }, () => { it("prompts explicitly instead of silently auto-selecting detected Ollama", () => { const repoRoot = path.join(import.meta.dirname, ".."); @@ -3510,6 +3721,211 @@ const { setupNim } = require(${onboardPath}); ); }); + it("lets users type back at a secret provider credential prompt to return to provider selection", () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-credential-back-")); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "credential-back-check.js"); + const onboardPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "onboard.js")); + const credentialsPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "credentials", "store.js")); + const runnerPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "runner.js")); + + fs.mkdirSync(fakeBin, { recursive: true }); + fs.writeFileSync( + path.join(fakeBin, "curl"), + `#!/usr/bin/env bash +body='{"id":"resp_123"}' +status="200" +outfile="" +while [ "$#" -gt 0 ]; do + case "$1" in + -o) outfile="$2"; shift 2 ;; + *) shift ;; + esac +done +printf '%s' "$body" > "$outfile" +printf '%s' "$status" +`, + { mode: 0o755 }, + ); + + const script = String.raw` +const clearCredentialEnv = [ + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "GEMINI_API_KEY", + "COMPATIBLE_API_KEY", + "COMPATIBLE_ANTHROPIC_API_KEY", + "NOUS_API_KEY", + "NVIDIA_API_KEY", + "NGC_API_KEY", + "NEMOCLAW_PROVIDER_KEY", +]; +const clearOnboardControlEnv = [ + "NEMOCLAW_NON_INTERACTIVE", + "NEMOCLAW_PROVIDER", + "NEMOCLAW_MODEL", + "NEMOCLAW_YES", + "NEMOCLAW_PREFERRED_API", + "NEMOCLAW_EXPERIMENTAL", +]; + +for (const key of [...clearCredentialEnv, ...clearOnboardControlEnv]) { + delete process.env[key]; +} + +const credentials = require(${credentialsPath}); +const runner = require(${runnerPath}); + +const answers = ["2", "back", "1", ""]; +const messages = []; +const prompts = []; +const saved = []; + +credentials.prompt = async (message, opts = {}) => { + messages.push(message); + prompts.push({ message, secret: opts.secret === true }); + return answers.shift() || ""; +}; +credentials.ensureApiKey = async () => { + return { kind: "credential", value: "nvapi-good" }; +}; +const originalSaveCredential = credentials.saveCredential; +credentials.saveCredential = (key, value) => { + saved.push({ key, value }); + return originalSaveCredential(key, value); +}; +runner.runCapture = () => ""; + +const { setupNim } = require(${onboardPath}); + +(async () => { + const originalLog = console.log; + const originalError = console.error; + const lines = []; + console.log = (...args) => lines.push(args.join(" ")); + console.error = (...args) => lines.push(args.join(" ")); + try { + const result = await setupNim(null); + originalLog(JSON.stringify({ + result, + messages, + prompts, + lines, + saved, + openaiKey: process.env.OPENAI_API_KEY || null, + })); + } finally { + console.log = originalLog; + console.error = originalError; + } +})().catch((error) => { + console.error(error); + process.exit(1); +}); +`; + 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 || ""}`, + }, + timeout: PROVIDER_SELECTION_TEST_TIMEOUT_MS, + }); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout.trim()); + assert.equal(payload.result.provider, "nvidia-prod"); + assert.equal(payload.openaiKey, null); + assert.ok( + payload.saved.every((entry: { key: string; value: string }) => entry.value !== "back"), + ); + assert.ok( + payload.lines.some((line: string) => line.includes("Returning to provider selection.")), + ); + assert.ok( + payload.prompts.some( + (entry: { message: string; secret: boolean }) => + /OpenAI API key: /.test(entry.message) && entry.secret, + ), + ); + assert.equal(payload.messages.filter((message: string) => /Choose \[/.test(message)).length, 2); + }); + + const secretCredentialBackScenarios: CredentialBackScenario[] = [ + { + name: "Anthropic", + answers: ["4", "back", "1", ""], + credentialEnv: "ANTHROPIC_API_KEY", + promptPattern: /Anthropic API key: /, + }, + { + name: "Anthropic exit", + answers: ["4", "exit"], + credentialEnv: "ANTHROPIC_API_KEY", + promptPattern: /Anthropic API key: /, + expectedOutcome: "exit", + }, + { + name: "Google Gemini", + answers: ["6", "back", "1", ""], + credentialEnv: "GEMINI_API_KEY", + promptPattern: /Google Gemini API key: /, + }, + { + name: "Other OpenAI-compatible endpoint", + answers: ["3", "https://proxy.example.com/v1", "back", "1", ""], + credentialEnv: "COMPATIBLE_API_KEY", + promptPattern: /Other OpenAI-compatible endpoint API key: /, + }, + { + name: "Other Anthropic-compatible endpoint", + answers: ["5", "https://proxy.example.com", "back", "1", ""], + credentialEnv: "COMPATIBLE_ANTHROPIC_API_KEY", + promptPattern: /Other Anthropic-compatible endpoint API key: /, + }, + { + name: "Model Router", + answers: ["8", "back", "1", ""], + credentialEnv: "NVIDIA_API_KEY", + promptPattern: /Model Router API key: /, + }, + { + name: "Hermes Provider Nous API key", + answers: ["9", "2", "back", "1", ""], + credentialEnv: "NOUS_API_KEY", + promptPattern: /Nous API Key: /, + agent: "hermes", + }, + { + name: "Local NIM NGC API key", + answers: ["7", "", "back", "1", ""], + credentialEnv: "NGC_API_KEY", + promptPattern: /NGC API Key: /, + env: { NEMOCLAW_EXPERIMENTAL: "1" }, + gpu: { + type: "nvidia", + name: "test-gpu", + count: 1, + totalMemoryMB: 999999, + perGpuMB: 999999, + nimCapable: true, + }, + stubNim: true, + }, + ]; + + for (const scenario of secretCredentialBackScenarios) { + const action = scenario.expectedOutcome === "exit" ? "exit" : "back"; + it(`lets users type ${action} at the ${scenario.name} secret credential prompt`, () => { + runCredentialBackScenario(scenario); + }); + } + it("lets users type back after a transport validation failure to return to provider selection", () => { const repoRoot = path.join(import.meta.dirname, ".."); const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-transport-back-"));