diff --git a/src/lib/command-registry.test.ts b/src/lib/command-registry.test.ts index 56380901e4..59cc09ed7e 100644 --- a/src/lib/command-registry.test.ts +++ b/src/lib/command-registry.test.ts @@ -17,10 +17,10 @@ import type { CommandDef } from "./command-registry"; describe("command-registry", () => { describe("COMMANDS array", () => { - it("should contain exactly 48 commands", () => { + it("should contain exactly 49 commands", () => { // 23 global (18 visible + 5 hidden help/version aliases) - // 25 sandbox (21 visible + 4 hidden shields/config) - expect(COMMANDS).toHaveLength(48); + // 26 sandbox (22 visible + 4 hidden shields/config) + expect(COMMANDS).toHaveLength(49); }); it("should have no duplicate usage strings", () => { @@ -52,9 +52,9 @@ describe("command-registry", () => { }); describe("sandboxCommands()", () => { - it("should return exactly 25 entries", () => { - // 21 visible + 4 hidden (shields×3 + config get) - expect(sandboxCommands()).toHaveLength(25); + it("should return exactly 26 entries", () => { + // 22 visible + 4 hidden (shields×3 + config get) + expect(sandboxCommands()).toHaveLength(26); }); it("every entry has scope sandbox", () => { @@ -68,7 +68,7 @@ describe("command-registry", () => { it("should exclude 9 hidden commands (39 visible)", () => { // 5 hidden global (help, --help, -h, --version, -v) + // 4 hidden sandbox (shields×3, config get) - expect(visibleCommands()).toHaveLength(39); + expect(visibleCommands()).toHaveLength(40); }); it("no visible command has hidden=true", () => { @@ -165,9 +165,9 @@ describe("command-registry", () => { }); describe("sandboxActionTokens()", () => { - it("returns exactly 16 unique action tokens including empty string", () => { + it("returns exactly 17 unique action tokens including empty string", () => { const tokens = sandboxActionTokens(); - expect(tokens).toHaveLength(16); + expect(tokens).toHaveLength(17); // Must contain the same set as the old sandboxActions array const expected = new Set([ "connect", @@ -185,6 +185,7 @@ describe("command-registry", () => { "config", "channels", "gateway-token", + "recover", "", ]); expect(new Set(tokens)).toEqual(expected); diff --git a/src/lib/command-registry.ts b/src/lib/command-registry.ts index 08babd9e36..b1c0017a02 100644 --- a/src/lib/command-registry.ts +++ b/src/lib/command-registry.ts @@ -169,6 +169,12 @@ export const COMMANDS: readonly CommandDef[] = [ group: "Sandbox Management", scope: "sandbox", }, + { + usage: "nemoclaw recover", + description: "Restart gateway and port-forward for a sandbox", + group: "Sandbox Management", + scope: "sandbox", + }, { usage: "nemoclaw destroy", description: "Stop NIM + delete sandbox", diff --git a/src/nemoclaw.ts b/src/nemoclaw.ts index e621f74c32..4d25cac3e1 100644 --- a/src/nemoclaw.ts +++ b/src/nemoclaw.ts @@ -4445,6 +4445,12 @@ const [cmd, ...args] = process.argv.slice(2); case "rebuild": await sandboxRebuild(cmd, actionArgs); break; + case "recover": { + const result = checkAndRecoverSandboxProcesses(cmd); + if (!result.checked) process.exit(0); + if (!result.recovered && !result.wasRunning) process.exit(1); + break; + } case "snapshot": await sandboxSnapshot(cmd, actionArgs); break; @@ -4599,7 +4605,7 @@ const [cmd, ...args] = process.argv.slice(2); default: console.error(` Unknown action: ${action}`); console.error( - ` Valid actions: connect, status, logs, policy-add, policy-remove, policy-list, skill, snapshot, share, rebuild, shields, config, channels, gateway-token, destroy`, + ` Valid actions: connect, status, logs, policy-add, policy-remove, policy-list, skill, snapshot, share, rebuild, recover, shields, config, channels, gateway-token, destroy`, ); process.exit(1); } diff --git a/test/recover-command.test.ts b/test/recover-command.test.ts new file mode 100644 index 0000000000..6cc4b5eb07 --- /dev/null +++ b/test/recover-command.test.ts @@ -0,0 +1,96 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, it, expect, afterAll } from "vitest"; +import { execSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const CLI = path.join(import.meta.dirname, "..", "bin", "nemoclaw.js"); + +function runWithEnv(args: string, env: Record = {}) { + try { + const out = execSync(`node "${CLI}" ${args}`, { + encoding: "utf-8", + timeout: Number(process.env.NEMOCLAW_EXEC_TIMEOUT || 10000), + env: { + ...process.env, + NEMOCLAW_HEALTH_POLL_COUNT: "1", + NEMOCLAW_HEALTH_POLL_INTERVAL: "0", + ...env, + }, + }); + return { code: 0, out }; + } catch (err: unknown) { + const e = err as { status: number; stdout?: string; stderr?: string }; + return { code: e.status, out: (e.stdout || "") + (e.stderr || "") }; + } +} + +function setupSandboxHome() { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-recover-")); + const localBin = path.join(home, "bin"); + const registryDir = path.join(home, ".nemoclaw"); + fs.mkdirSync(localBin, { recursive: true }); + fs.mkdirSync(registryDir, { recursive: true }); + + fs.writeFileSync( + path.join(registryDir, "sandboxes.json"), + JSON.stringify({ + sandboxes: { + "test-sb": { + name: "test-sb", + model: "test-model", + provider: "nvidia-prod", + gpuEnabled: false, + policies: [], + }, + }, + defaultSandbox: "test-sb", + }), + { mode: 0o600 }, + ); + + // Stub openshell so sandbox exec calls don't fail + fs.writeFileSync( + path.join(localBin, "openshell"), + ["#!/usr/bin/env bash", 'echo "{}"', "exit 0"].join("\n"), + { mode: 0o755 }, + ); + + return { home, localBin }; +} + +describe("nemoclaw recover", () => { + const homes: string[] = []; + afterAll(() => { + for (const home of homes) { + fs.rmSync(home, { recursive: true, force: true }); + } + }); + + it("recover is listed as a valid action in the error message for unknown actions", () => { + const { home } = setupSandboxHome(); + homes.push(home); + const r = runWithEnv("test-sb bogusaction", { + HOME: home, + PATH: `${path.join(home, "bin")}:${process.env.PATH}`, + }); + expect(r.code).toBe(1); + expect(r.out).toContain("recover"); + expect(r.out).toContain("Valid actions"); + }); + + it("recover exits 0 when gateway is not detectable (no-op / idempotent)", () => { + const { home } = setupSandboxHome(); + homes.push(home); + const r = runWithEnv("test-sb recover", { + HOME: home, + PATH: `${path.join(home, "bin")}:${process.env.PATH}`, + }); + // checkAndRecoverSandboxProcesses returns early when isSandboxGatewayRunning + // returns null (sandbox not reachable) — no crash, exit 0 + expect(r.code).toBe(0); + }); +});