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
33 changes: 33 additions & 0 deletions src/lib/actions/uninstall/run-plan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
8 changes: 6 additions & 2 deletions src/lib/actions/uninstall/run-plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
79 changes: 79 additions & 0 deletions src/lib/onboard.test.ts
Original file line number Diff line number Diff line change
@@ -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",
]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

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 `<name>` 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("<name>");
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");
});
});
6 changes: 3 additions & 3 deletions src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -10412,9 +10414,7 @@ async function onboard(opts: OnboardOptions = {}): Promise<void> {
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 <name> destroy --yes && nemoclaw onboard --gpu`);
reportGpuPassthroughRecovery();
process.exit(1);
}
}
Expand Down
55 changes: 55 additions & 0 deletions src/lib/onboard/gpu-recovery.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Loading