Skip to content
Closed
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
41 changes: 41 additions & 0 deletions src/lib/onboard/credential-navigation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@
import { describe, expect, it, vi } from "vitest";

import * as credentials from "../credentials/store";
import { validateNvidiaApiKeyValue } from "../validation";
import {
BACK_TO_SELECTION,
replaceNamedCredential,
returningToProviderSelection,
shouldReturnToProviderSelection,
} from "./credential-navigation";
import { createValidationRecoveryPromptHelpers } from "./validation-recovery-prompt";

describe("credential prompt navigation helpers", () => {
it("treats both the shared back sentinel and credential back intents as provider-selection navigation", () => {
Expand Down Expand Up @@ -74,4 +76,43 @@ describe("credential prompt navigation helpers", () => {
vi.restoreAllMocks();
}
});

it("returns to provider selection instead of staging back as the re-entered API key (#3697)", async () => {
const answers = ["retry", "", "back"];
const exitOnboardFromPrompt = vi.fn(() => {
throw new Error("unexpected exit");
}) as unknown as () => never;
vi.spyOn(console, "log").mockImplementation(() => {});
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
const { promptValidationRecovery } = createValidationRecoveryPromptHelpers({
isNonInteractive: () => false,
prompt: async () => answers.shift() ?? "",
validateNvidiaApiKeyValue: (key, credentialEnv) =>
validateNvidiaApiKeyValue(key, credentialEnv ?? undefined),
getTransportRecoveryMessage: () => "",
exitOnboardFromPrompt,
});
delete process.env.OPENAI_API_KEY;
try {
await expect(
promptValidationRecovery(
"OpenAI",
{ kind: "credential", retry: "credential" },
"OPENAI_API_KEY",
),
).resolves.toBe("selection");
expect(process.env.OPENAI_API_KEY).toBeUndefined();
expect(answers).toEqual([]);
expect(exitOnboardFromPrompt).not.toHaveBeenCalled();
// The escape route is the contract here, so a bare required-field notice is not enough:
// the re-prompt has to name both ways out of the loop, in either order.
const [[requiredMessage]] = consoleError.mock.calls;
expect(requiredMessage).toContain("is required.");
expect(requiredMessage).toContain("back");
expect(requiredMessage).toContain("exit");
} finally {
delete process.env.OPENAI_API_KEY;
vi.restoreAllMocks();
}
});
});
32 changes: 23 additions & 9 deletions src/lib/onboard/validation-recovery-prompt.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { normalizeCredentialValue, saveCredential } from "../credentials/store";
import { getCredentialPromptIntent, saveCredential } from "../credentials/store";
import { BACK_TO_SELECTION, type BackToSelection, isBackToSelection } from "../navigation";
import type { ProbeRecovery } from "../validation-recovery";

export interface ValidationRecoveryPromptDeps {
Expand All @@ -18,7 +19,7 @@ export interface ValidationRecoveryPromptHelpers {
label: string,
helpUrl?: string | null,
validator?: ((value: string) => string | null) | null,
): Promise<string>;
): Promise<string | BackToSelection>;
promptValidationRecovery(
label: string,
recovery: ProbeRecovery,
Expand All @@ -35,17 +36,20 @@ export function createValidationRecoveryPromptHelpers(
label: string,
helpUrl: string | null = null,
validator: ((value: string) => string | null) | null = null,
): Promise<string> {
): Promise<string | BackToSelection> {
if (helpUrl) {
console.log("");
console.log(` Get your ${label} from: ${helpUrl}`);
console.log("");
}

while (true) {
const key = normalizeCredentialValue(await deps.prompt(` ${label}: `, { secret: true }));
const intent = getCredentialPromptIntent(await deps.prompt(` ${label}: `, { secret: true }));
if (intent.kind === "exit") deps.exitOnboardFromPrompt();
if (intent.kind === "back") return BACK_TO_SELECTION;
const key = intent.kind === "credential" ? intent.value : "";
if (!key) {
console.error(` ${label} is required.`);
console.error(` ${label} is required. Type back to change provider, or exit to quit.`);
continue;
}
const validationError = typeof validator === "function" ? validator(key) : null;
Expand Down Expand Up @@ -103,8 +107,13 @@ export function createValidationRecoveryPromptHelpers(
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";
const pasted = await replaceNamedCredential(
credentialEnv,
`${label} API key`,
helpUrl,
validator,
);
return isBackToSelection(pasted) ? "selection" : "credential";
}
if (choice === "back") {
console.log(" Returning to provider selection.");
Expand All @@ -115,8 +124,13 @@ export function createValidationRecoveryPromptHelpers(
deps.exitOnboardFromPrompt();
}
if (choice === "" || choice === "retry") {
await replaceNamedCredential(credentialEnv, `${label} API key`, helpUrl, validator);
return "credential";
const replaced = await replaceNamedCredential(
credentialEnv,
`${label} API key`,
helpUrl,
validator,
);
return isBackToSelection(replaced) ? "selection" : "credential";
}
console.log(" Please choose a provider/model again.");
console.log("");
Expand Down
Loading