diff --git a/src/lib/actions/uninstall/run-plan.test.ts b/src/lib/actions/uninstall/run-plan.test.ts index 32ae0ae9611..87ec3565d7d 100644 --- a/src/lib/actions/uninstall/run-plan.test.ts +++ b/src/lib/actions/uninstall/run-plan.test.ts @@ -533,4 +533,37 @@ describe("uninstall run plan", () => { expect(warnings).toContain("Failed to disable /swapfile; skipping swap cleanup."); expect(logs).not.toContain("Swap file removed"); }); + + it("reports skipped openshell cleanup without the contradictory past-tense wording", () => { + const logs: string[] = []; + const warnings: string[] = []; + const result = runUninstallPlan( + { assumeYes: true, deleteModels: false, keepOpenShell: true }, + { + commandExists: () => true, + env: { HOME: "/tmp/nemoclaw-uninstall-test-skip-wording" } as NodeJS.ProcessEnv, + error: (line) => warnings.push(line), + existsSync: () => false, + isTty: false, + kill: () => true, + log: (line) => logs.push(line), + rmSync: vi.fn(), + run: (command, args) => { + if (command === "openshell") { + return { status: 1, stdout: "", stderr: "" }; + } + if (args[0] === "-c") return ok("/fake/bin/tool\n"); + if (args[0] === "-f") return ok(""); + return ok(); + }, + runDocker: () => ok(""), + }, + ); + + expect(result.exitCode).toBe(0); + expect(warnings).toContain("Skipped gateway 'nemoclaw' (already absent or unavailable)"); + expect(warnings).toContain("Skipped all OpenShell sandboxes (already absent or unavailable)"); + expect(warnings.every((line) => !/^Destroyed .+ skipped$/.test(line))).toBe(true); + expect(warnings.every((line) => !/^Deleted .+ skipped$/.test(line))).toBe(true); + }); }); diff --git a/src/lib/actions/uninstall/run-plan.ts b/src/lib/actions/uninstall/run-plan.ts index f8fd7fcccb0..6cc916e4f41 100644 --- a/src/lib/actions/uninstall/run-plan.ts +++ b/src/lib/actions/uninstall/run-plan.ts @@ -209,8 +209,12 @@ function confirm(options: UninstallRunOptions, runtime: UninstallRuntime): boole function runOptional(runtime: UninstallRuntime, description: string, command: string, args: string[]): void { const result = runtime.run(command, args, { env: runtime.env, stdio: "ignore" }); - if (result.status === 0) runtime.log(description); - else runtime.warn(`${description} skipped`); + if (result.status === 0) { + runtime.log(description); + return; + } + const target = description.replace(/^(Destroyed|Deleted|Stopped|Removed)\s+/i, ""); + runtime.warn(`Skipped ${target} (already absent or unavailable)`); } function stopHelperServices(paths: UninstallPaths, runtime: UninstallRuntime): void { diff --git a/src/lib/onboard.test.ts b/src/lib/onboard.test.ts new file mode 100644 index 00000000000..42caf71b241 --- /dev/null +++ b/src/lib/onboard.test.ts @@ -0,0 +1,79 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { gpuPassthroughRecoveryLines, reportGpuPassthroughRecovery } from "./onboard/gpu-recovery"; + +describe("gpuPassthroughRecoveryLines", () => { + it("clears the gateway directly when no sandboxes are registered (no NemoClaw uninstall, so the CLI survives)", () => { + const lines = gpuPassthroughRecoveryLines([]); + expect(lines).toEqual([ + " Existing gateway was started without GPU passthrough.", + " No sandboxes are registered, so there is nothing to destroy.", + " To enable GPU, clear the stale gateway and re-onboard:", + " openshell gateway destroy -g nemoclaw", + " nemoclaw onboard --gpu", + ]); + }); + + it("falls back to a direct gateway-removal hint when the registry cannot be read", () => { + const lines = gpuPassthroughRecoveryLines(null); + expect(lines).toEqual([ + " Existing gateway was started without GPU passthrough.", + " Could not read the NemoClaw sandbox registry; cannot enumerate sandboxes.", + " To enable GPU, clear the stale gateway directly and re-onboard:", + " openshell gateway destroy -g nemoclaw", + " nemoclaw onboard --gpu", + ]); + }); + + it("appends --cleanup-gateway to the single destroy command so the stale gateway is actually removed", () => { + const lines = gpuPassthroughRecoveryLines(["my-assistant"]); + expect(lines).toEqual([ + " Existing gateway was started without GPU passthrough.", + " To enable GPU, destroy the registered sandbox (`my-assistant`) and re-onboard:", + " nemoclaw my-assistant destroy --yes --cleanup-gateway", + " nemoclaw onboard --gpu", + ]); + }); + + it("only puts --cleanup-gateway on the last destroy command when more than one sandbox is registered", () => { + const lines = gpuPassthroughRecoveryLines(["alpha", "beta"]); + expect(lines).toEqual([ + " Existing gateway was started without GPU passthrough.", + " To enable GPU, destroy the registered sandboxes (`alpha`, `beta`) and re-onboard:", + " nemoclaw alpha destroy --yes", + " nemoclaw beta destroy --yes --cleanup-gateway", + " nemoclaw onboard --gpu", + ]); + }); + + it("never emits the literal `` placeholder or a `nemoclaw uninstall && nemoclaw onboard` chain in any branch", () => { + for (const names of [null, [], ["x"], ["alpha", "beta"]] as const) { + const joined = gpuPassthroughRecoveryLines(names).join("\n"); + expect(joined).not.toContain(""); + expect(joined).not.toContain("nemoclaw uninstall && nemoclaw onboard"); + } + }); +}); + +describe("reportGpuPassthroughRecovery", () => { + it("routes the registered sandbox names through the printer", () => { + const printed: string[] = []; + reportGpuPassthroughRecovery((line) => printed.push(line), () => ["alpha"]); + expect(printed).toEqual(gpuPassthroughRecoveryLines(["alpha"])); + }); + + it("falls back to the registry-unreadable guidance when the lookup throws (does not collapse to 'no sandboxes')", () => { + const printed: string[] = []; + reportGpuPassthroughRecovery( + (line) => printed.push(line), + () => { + throw new Error("registry unreachable"); + }, + ); + expect(printed).toEqual(gpuPassthroughRecoveryLines(null)); + expect(printed.join("\n")).toContain("Could not read the NemoClaw sandbox registry"); + }); +}); diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index f4d59c3f991..17b8f4ada68 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -353,6 +353,8 @@ const RESET = USE_COLOR ? "\x1b[0m" : ""; let OPENSHELL_BIN: string | null = null; const GATEWAY_NAME = "nemoclaw"; const BACK_TO_SELECTION = "__NEMOCLAW_BACK_TO_SELECTION__"; + +const { reportGpuPassthroughRecovery }: typeof import("./onboard/gpu-recovery") = require("./onboard/gpu-recovery"); type HermesAuthMethod = "oauth" | "api_key"; const HERMES_AUTH_METHOD_OAUTH: HermesAuthMethod = "oauth"; const HERMES_AUTH_METHOD_API_KEY: HermesAuthMethod = "api_key"; @@ -10412,9 +10414,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { const gpuOutput = String(gpuCheck.stdout || "").trim(); const gatewayHasGpu = gpuCheck.status === 0 && gpuOutput !== "null" && gpuOutput !== "[]"; if (!gatewayHasGpu) { - console.error(" Existing gateway was started without GPU passthrough."); - console.error(" To enable GPU, destroy the existing sandbox and gateway, then re-onboard:"); - console.error(` nemoclaw destroy --yes && nemoclaw onboard --gpu`); + reportGpuPassthroughRecovery(); process.exit(1); } } diff --git a/src/lib/onboard/gpu-recovery.ts b/src/lib/onboard/gpu-recovery.ts new file mode 100644 index 00000000000..8b8a3b2794e --- /dev/null +++ b/src/lib/onboard/gpu-recovery.ts @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +const GATEWAY_REMOVAL_COMMAND = "openshell gateway destroy -g nemoclaw"; +const ONBOARD_GPU_COMMAND = "nemoclaw onboard --gpu"; + +export function gpuPassthroughRecoveryLines(registeredNames: readonly string[] | null): string[] { + const lines: string[] = [" Existing gateway was started without GPU passthrough."]; + if (registeredNames === null) { + lines.push(" Could not read the NemoClaw sandbox registry; cannot enumerate sandboxes."); + lines.push(" To enable GPU, clear the stale gateway directly and re-onboard:"); + lines.push(` ${GATEWAY_REMOVAL_COMMAND}`); + lines.push(` ${ONBOARD_GPU_COMMAND}`); + return lines; + } + if (registeredNames.length === 0) { + lines.push(" No sandboxes are registered, so there is nothing to destroy."); + lines.push(" To enable GPU, clear the stale gateway and re-onboard:"); + lines.push(` ${GATEWAY_REMOVAL_COMMAND}`); + lines.push(` ${ONBOARD_GPU_COMMAND}`); + return lines; + } + const plural = registeredNames.length === 1 ? "" : "es"; + const list = registeredNames.map((n) => `\`${n}\``).join(", "); + lines.push(` To enable GPU, destroy the registered sandbox${plural} (${list}) and re-onboard:`); + registeredNames.forEach((name, index) => { + const isLast = index === registeredNames.length - 1; + const flags = isLast ? " --yes --cleanup-gateway" : " --yes"; + lines.push(` nemoclaw ${name} destroy${flags}`); + }); + lines.push(` ${ONBOARD_GPU_COMMAND}`); + return lines; +} + +function defaultRegisteredSandboxNames(): readonly string[] | null { + try { + const registry = require("../state/registry") as typeof import("../state/registry"); + return registry.listSandboxes().sandboxes.map((s) => s.name).filter(Boolean); + } catch { + return null; + } +} + +export function reportGpuPassthroughRecovery( + emit: (line: string) => void = console.error, + listRegisteredSandboxes: () => readonly string[] | null = defaultRegisteredSandboxNames, +): void { + let names: readonly string[] | null; + try { + names = listRegisteredSandboxes(); + } catch { + names = null; + } + for (const line of gpuPassthroughRecoveryLines(names)) emit(line); +}