Skip to content
1 change: 1 addition & 0 deletions docs/get-started/quickstart-hermes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ Use these details when your first-run path needs more control.
The wizard asks for an inference provider, model, required credential, and sandbox name before it prints the review summary.
After confirmation, NemoClaw registers inference, prompts for optional Tavily Search and supported messaging channels, builds and starts the sandbox, sets up Hermes, and applies the selected network policy tier and presets.
At any prompt, press Enter to accept the default shown in `[brackets]`, type `back` to return to the previous prompt, or type `exit` to quit.
At prompts that display the navigation hint, type `b` to return to provider selection.

The default Hermes sandbox name is `hermes`.
Use a distinct name, such as `my-hermes`, when you run Hermes and OpenClaw sandboxes side by side.
Expand Down
1 change: 1 addition & 0 deletions docs/get-started/quickstart-langchain-deepagents-code.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ nemoclaw onboard --agent langchain

The wizard asks for an inference provider, model, required credential, sandbox name, and policy tier before it prints the review summary.
At any prompt, press Enter to accept the default shown in `[brackets]`, type `back` to return to the previous prompt, or type `exit` to quit.
At prompts that display the navigation hint, type `b` to return to the previous prompt.
The default Deep Agents sandbox name is `deepagents-code`.
Use a distinct name, such as `my-deepagents`, when you run Deep Agents, Hermes, and OpenClaw sandboxes side by side.
Refer to [Choose an Inference Provider](../inference/learn-and-choose/choose-inference-provider) for provider-specific prompts.
Expand Down
1 change: 1 addition & 0 deletions docs/get-started/quickstart.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,7 @@ Use these details when your first-run path needs more control.
It prints a review summary before it registers the provider with OpenShell.
After confirmation, NemoClaw registers inference, prompts for optional web search and messaging channels, builds and starts the sandbox, sets up OpenClaw, and applies the selected network policy tier and presets.
At any prompt, press Enter to accept the default shown in `[brackets]`, type `back` to return to the previous prompt, or type `exit` to quit.
At prompts that display the navigation hint, type `b` to return to provider selection.

If registered sandboxes already exist, the installer prepares the current NemoClaw CLI without replacing OpenShell, requires a fresh backup of every registered sandbox before it changes the gateway, and runs `nemoclaw upgrade-sandboxes --auto` after the host upgrade.
After backup, it retires the running gateway before replacing OpenShell only when the installed OpenShell version is outside the current release's supported range; an unknown installed version or an invalid or missing range stops the update without retiring the gateway, while any retirement failure stops the update with the sandbox backups preserved.
Expand Down
2 changes: 1 addition & 1 deletion src/lib/credentials/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ export function normalizeCredentialValue(value: CredentialInput): string {
export function getCredentialPromptIntent(value: CredentialInput): CredentialPromptIntent {
const normalized = normalizeCredentialValue(value);
const navigation = normalized.toLowerCase();
if (navigation === "back") return { kind: "back" };
if (navigation === "b" || 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 };
Expand Down
6 changes: 6 additions & 0 deletions src/lib/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,9 @@ export type BackToSelection = typeof BACK_TO_SELECTION;
export function isBackToSelection(value: unknown): value is BackToSelection {
return value === BACK_TO_SELECTION;
}

export const NAVIGATION_HINT = " Enter b to go back, or exit to quit.";

export function printNavigationHint(log: (message?: string) => void = console.log): void {
log(NAVIGATION_HINT);
}
46 changes: 44 additions & 2 deletions src/lib/onboard/credential-navigation.test.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,57 @@
// 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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import * as credentials from "../credentials/store";

import {
BACK_TO_SELECTION,
getNavigationChoice,
replaceNamedCredential,
returningToProviderSelection,
shouldReturnToProviderSelection,
} from "./credential-navigation";

describe("credential prompt navigation helpers", () => {
let navigationKeyBeforeTest: string | undefined;

beforeEach(() => {
navigationKeyBeforeTest = process.env.NEMOCLAW_TEST_NAVIGATION_KEY;
vi.stubEnv("NEMOCLAW_TEST_NAVIGATION_KEY", navigationKeyBeforeTest);
});

afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllEnvs();
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

describe("credential prompt navigation helpers (#6005)", () => {
it("accepts the short b token as back so the advertised key works", () => {
expect(getNavigationChoice("b")).toBe("back");
expect(getNavigationChoice(" B ")).toBe("back");
expect(getNavigationChoice("back")).toBe("back");
expect(getNavigationChoice("exit")).toBe("exit");
expect(getNavigationChoice("nvapi-xxxx")).toBeNull();
});

it("routes a trimmed short b token through the real secret prompt without staging it", async () => {
vi.spyOn(credentials, "prompt").mockResolvedValue(" B ");
const saveCredential = vi.spyOn(credentials, "saveCredential");
const exitOnboard = vi.fn(() => {
throw new Error("unexpected exit");
}) as unknown as () => never;

const result = await replaceNamedCredential({
envName: "NEMOCLAW_TEST_NAVIGATION_KEY",
label: "Test credential",
exitOnboardFromPrompt: exitOnboard,
});

expect(result).toBe(BACK_TO_SELECTION);
expect(saveCredential).not.toHaveBeenCalled();
expect(process.env.NEMOCLAW_TEST_NAVIGATION_KEY).toBe(navigationKeyBeforeTest);
});

it("treats both the shared back sentinel and credential back intents as provider-selection navigation", () => {
const exitOnboard = vi.fn(() => {
throw new Error("unexpected exit");
Expand Down
11 changes: 9 additions & 2 deletions src/lib/onboard/credential-navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@
// SPDX-License-Identifier: Apache-2.0

import * as credentials from "../credentials/store";
import { BACK_TO_SELECTION, type BackToSelection, isBackToSelection } from "../navigation";
import {
BACK_TO_SELECTION,
type BackToSelection,
isBackToSelection,
printNavigationHint,
} from "../navigation";

export type BackNavigationResult = BackToSelection | { kind: "back" };
export type { BackToSelection };
Expand All @@ -12,7 +17,7 @@ export function getNavigationChoice(value = ""): "back" | "exit" | null {
const normalized = String(value || "")
.trim()
.toLowerCase();
if (normalized === "back") return "back";
if (normalized === "back" || normalized === "b") return "back";
if (normalized === "exit" || normalized === "quit") return "exit";
return null;
}
Expand Down Expand Up @@ -81,6 +86,8 @@ export async function replaceNamedCredential({
console.log("");
}

printNavigationHint();

while (true) {
const key = await readCredentialValue(` ${label}: `, exitOnboardFromPrompt);
if (isBackToSelection(key)) return key;
Expand Down
51 changes: 51 additions & 0 deletions src/lib/onboard/hermes-auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@

import { afterEach, describe, expect, it, vi } from "vitest";

import { NAVIGATION_HINT } from "../navigation";
import {
createHermesAuthHelpers,
HERMES_AUTH_METHOD_API_KEY,
HERMES_AUTH_METHOD_OAUTH,
HERMES_NOUS_API_KEY_CREDENTIAL_ENV,
type HermesAuthFlowDeps,
} from "./hermes-auth";
import { getNavigationChoice as getPromptNavigationChoice } from "./prompt-helpers";

function clearHermesAuthEnvironment(): void {
vi.stubEnv("NEMOCLAW_HERMES_AUTH_METHOD", undefined);
Expand Down Expand Up @@ -46,6 +48,55 @@ afterEach(() => {
vi.unstubAllEnvs();
});

describe("Hermes auth back-navigation affordance (#6005)", () => {
it("prints the navigation hint before the interactive auth-method prompt", async () => {
clearHermesAuthEnvironment();
const events: string[] = [];
vi.spyOn(console, "log").mockImplementation((message?: unknown) => {
events.push(String(message));
});
const deps = createDeps({
isNonInteractive: vi.fn(() => false),
prompt: vi.fn(async () => {
events.push("prompt");
return "1";
}),
});

await createHermesAuthHelpers(deps).promptHermesAuthMethod();

const hintIndex = events.indexOf(NAVIGATION_HINT);
const promptIndex = events.indexOf("prompt");
expect(hintIndex).toBeGreaterThanOrEqual(0);
expect(promptIndex).toBeGreaterThanOrEqual(0);
expect(hintIndex).toBeLessThan(promptIndex);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it("returns to provider selection when the auth-method prompt replies back", async () => {
clearHermesAuthEnvironment();
vi.spyOn(console, "log").mockImplementation(() => undefined);
const deps = createDeps({
isNonInteractive: vi.fn(() => false),
prompt: vi.fn(async () => "b"),
getNavigationChoice: getPromptNavigationChoice,
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const result = await createHermesAuthHelpers(deps).promptHermesAuthMethod();

expect(result).toBe(deps.backToSelection);
});

it("does not print the navigation hint in non-interactive mode", async () => {
clearHermesAuthEnvironment();
const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
const deps = createDeps({ isNonInteractive: vi.fn(() => true) });

await createHermesAuthHelpers(deps).promptHermesAuthMethod();

expect(logSpy.mock.calls.some((call) => call[0] === NAVIGATION_HINT)).toBe(false);
});
});

describe("Hermes authentication exit boundaries", () => {
it("uses the injected exit for an unsupported requested auth method", async () => {
vi.stubEnv("NEMOCLAW_HERMES_AUTH_METHOD", "certificate");
Expand Down
3 changes: 3 additions & 0 deletions src/lib/onboard/hermes-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import { normalizeCredentialValue } from "../credentials/store";
import type { HermesAuthMethod } from "../hermes-provider-auth";
import * as hermesProviderAuth from "../hermes-provider-auth";
import { printNavigationHint } from "../navigation";

export type { HermesAuthMethod };

Expand Down Expand Up @@ -126,6 +127,7 @@ export function createHermesAuthHelpers(deps: HermesAuthFlowDeps): HermesAuthHel

const defaultIdx =
(requested ? methods.findIndex((method) => method.key === requested) : 0) + 1;
printNavigationHint();
const choice = await deps.prompt(` Choose [${defaultIdx}]: `);
const navigation = deps.getNavigationChoice(choice);
if (navigation === "back") return deps.backToSelection;
Expand Down Expand Up @@ -159,6 +161,7 @@ export function createHermesAuthHelpers(deps: HermesAuthFlowDeps): HermesAuthHel
console.log("");
console.log(" Hermes Provider Nous API Key");
console.log(` Create or copy a key from ${HERMES_NOUS_API_KEY_HELP_URL}`);
printNavigationHint();
const rawKey = await deps.prompt(" Nous API Key: ", {
secret: true,
});
Expand Down
28 changes: 27 additions & 1 deletion src/lib/onboard/prompt-helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@

import { describe, expect, it, vi } from "vitest";
// Import source directly so tests cannot pass against a stale build.
import { promptOrDefault, selectFromNumberedMenuOrExit } from "./prompt-helpers";
import {
getNavigationChoice,
promptOrDefault,
selectFromNumberedMenuOrExit,
} from "./prompt-helpers";

function makeDeps(promptReply: string) {
return {
Expand All @@ -13,6 +17,28 @@ function makeDeps(promptReply: string) {
};
}

describe("getNavigationChoice back token (#6005)", () => {
it("treats the short b token as back so the advertised key works", () => {
expect(getNavigationChoice("b")).toBe("back");
expect(getNavigationChoice(" B ")).toBe("back");
});

it("still treats the full back word as back", () => {
expect(getNavigationChoice("back")).toBe("back");
});

it("keeps exit and quit mapped to exit", () => {
expect(getNavigationChoice("exit")).toBe("exit");
expect(getNavigationChoice("quit")).toBe("exit");
});

it("returns null for ordinary values", () => {
expect(getNavigationChoice("brave")).toBeNull();
expect(getNavigationChoice("2")).toBeNull();
expect(getNavigationChoice("")).toBeNull();
});
});

describe("promptOrDefault interactive default fallback (#4387)", () => {
it("returns defaultValue when the user just presses Enter (empty reply)", async () => {
const deps = makeDeps("");
Expand Down
2 changes: 1 addition & 1 deletion src/lib/onboard/prompt-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export function getNavigationChoice(value = ""): "back" | "exit" | null {
const normalized = String(value || "")
.trim()
.toLowerCase();
if (normalized === "back") return "back";
if (normalized === "back" || normalized === "b") return "back";
if (normalized === "exit" || normalized === "quit") return "exit";
return null;
}
Expand Down
Loading