From 862519da5ce359cd2b2ad62d296e2d01ee8a6513 Mon Sep 17 00:00:00 2001 From: peteryuqin Date: Thu, 26 Mar 2026 00:32:03 -0400 Subject: [PATCH 1/4] feat(cli): add reconnect command --- bin/nemoclaw.js | 20 ++++++++- test/cli.test.js | 113 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 132 insertions(+), 1 deletion(-) diff --git a/bin/nemoclaw.js b/bin/nemoclaw.js index 77118bdd4..eba7ed825 100755 --- a/bin/nemoclaw.js +++ b/bin/nemoclaw.js @@ -38,7 +38,7 @@ const { parseGatewayInference } = require("./lib/inference-config"); const GLOBAL_COMMANDS = new Set([ "onboard", "list", "deploy", "setup", "setup-spark", - "start", "stop", "status", "debug", "uninstall", + "start", "stop", "status", "reconnect", "debug", "uninstall", "help", "--help", "-h", "--version", "-v", ]); @@ -566,6 +566,16 @@ function listSandboxes() { console.log(""); } +function resolveReconnectSandboxName(requestedName) { + const sandboxName = requestedName || registry.getDefault(); + if (!sandboxName) { + console.error(" No sandbox registered. Run `nemoclaw onboard` to create one first."); + process.exit(1); + } + validateName(sandboxName, "sandbox name"); + return sandboxName; +} + // ── Sandbox-scoped actions ─────────────────────────────────────── async function sandboxConnect(sandboxName) { @@ -578,6 +588,12 @@ async function sandboxConnect(sandboxName) { exitWithSpawnResult(result); } +async function reconnect(args = []) { + const sandboxName = resolveReconnectSandboxName(args[0]); + console.log(` Reconnecting to sandbox '${sandboxName}'...`); + await sandboxConnect(sandboxName); +} + // eslint-disable-next-line complexity async function sandboxStatus(sandboxName) { const sb = registry.getSandbox(sandboxName); @@ -728,6 +744,7 @@ function help() { ${G}Sandbox Management:${R} ${B}nemoclaw list${R} List all sandboxes + nemoclaw reconnect ${D}[name]${R} Recover the default or named sandbox after a reboot nemoclaw connect Shell into a running sandbox nemoclaw status Sandbox health + NIM status nemoclaw logs ${D}[--follow]${R} Stream sandbox logs @@ -785,6 +802,7 @@ const [cmd, ...args] = process.argv.slice(2); case "start": await start(); break; case "stop": stop(); break; case "status": showStatus(); break; + case "reconnect": await reconnect(args); break; case "debug": debug(args); break; case "uninstall": uninstall(args); break; case "list": listSandboxes(); break; diff --git a/test/cli.test.js b/test/cli.test.js index b26b097c9..196134e16 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -104,6 +104,119 @@ describe("CLI dispatch", () => { expect(r.out.includes("nemoclaw debug")).toBeTruthy(); }); + it("help mentions reconnect command", () => { + const r = run("help"); + expect(r.code).toBe(0); + expect(r.out.includes("nemoclaw reconnect")).toBeTruthy(); + }); + + it("reconnect uses the default sandbox when no name is provided", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-reconnect-default-")); + const localBin = path.join(home, "bin"); + const registryDir = path.join(home, ".nemoclaw"); + const markerFile = path.join(home, "reconnect-args"); + fs.mkdirSync(localBin, { recursive: true }); + fs.mkdirSync(registryDir, { recursive: true }); + fs.writeFileSync( + path.join(registryDir, "sandboxes.json"), + JSON.stringify({ + sandboxes: { + alpha: { + name: "alpha", + model: "test-model", + provider: "nvidia-prod", + gpuEnabled: false, + policies: [], + }, + }, + defaultSandbox: "alpha", + }), + { mode: 0o600 } + ); + fs.writeFileSync( + path.join(localBin, "openshell"), + [ + "#!/usr/bin/env bash", + `marker_file=${JSON.stringify(markerFile)}`, + "printf '%s\\n' \"$*\" >> \"$marker_file\"", + "if [ \"$1\" = \"sandbox\" ] && [ \"$2\" = \"get\" ] && [ \"$3\" = \"alpha\" ]; then", + " echo 'Sandbox: alpha'", + " exit 0", + "fi", + "if [ \"$1\" = \"forward\" ] || [ \"$1\" = \"sandbox\" ]; then", + " exit 0", + "fi", + "exit 0", + ].join("\n"), + { mode: 0o755 } + ); + + const r = runWithEnv("reconnect", { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + }); + + expect(r.code).toBe(0); + expect(r.out.includes("Reconnecting to sandbox 'alpha'")).toBeTruthy(); + const log = fs.readFileSync(markerFile, "utf8"); + expect(log).toContain("sandbox get alpha"); + expect(log).toContain("forward start --background 18789 alpha"); + expect(log).toContain("sandbox connect alpha"); + }); + + it("reconnect accepts an explicit sandbox name", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-reconnect-named-")); + const localBin = path.join(home, "bin"); + const registryDir = path.join(home, ".nemoclaw"); + const markerFile = path.join(home, "reconnect-args"); + fs.mkdirSync(localBin, { recursive: true }); + fs.mkdirSync(registryDir, { recursive: true }); + fs.writeFileSync( + path.join(registryDir, "sandboxes.json"), + JSON.stringify({ + sandboxes: { + beta: { + name: "beta", + model: "test-model", + provider: "nvidia-prod", + gpuEnabled: false, + policies: [], + }, + }, + defaultSandbox: "beta", + }), + { mode: 0o600 } + ); + fs.writeFileSync( + path.join(localBin, "openshell"), + [ + "#!/usr/bin/env bash", + `marker_file=${JSON.stringify(markerFile)}`, + "printf '%s\\n' \"$*\" >> \"$marker_file\"", + "if [ \"$1\" = \"sandbox\" ] && [ \"$2\" = \"get\" ] && [ \"$3\" = \"beta\" ]; then", + " echo 'Sandbox: beta'", + " exit 0", + "fi", + "if [ \"$1\" = \"forward\" ] || [ \"$1\" = \"sandbox\" ]; then", + " exit 0", + "fi", + "exit 0", + ].join("\n"), + { mode: 0o755 } + ); + + const r = runWithEnv("reconnect beta", { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + }); + + expect(r.code).toBe(0); + expect(r.out.includes("Reconnecting to sandbox 'beta'")).toBeTruthy(); + const log = fs.readFileSync(markerFile, "utf8"); + expect(log).toContain("sandbox get beta"); + expect(log).toContain("sandbox connect beta"); + }); + it("passes --follow through to openshell logs", () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-logs-follow-")); const localBin = path.join(home, "bin"); From 3aa894c32673d8955f09a7bcb75df6b295e1fdd4 Mon Sep 17 00:00:00 2001 From: peteryuqin Date: Thu, 26 Mar 2026 00:42:34 -0400 Subject: [PATCH 2/4] fix(cli): recover reconnect after missing gateway --- test/cli.test.js | 94 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/test/cli.test.js b/test/cli.test.js index 196134e16..7197420f0 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -214,9 +214,103 @@ describe("CLI dispatch", () => { expect(r.out.includes("Reconnecting to sandbox 'beta'")).toBeTruthy(); const log = fs.readFileSync(markerFile, "utf8"); expect(log).toContain("sandbox get beta"); + expect(log).toContain("forward start --background 18789 beta"); expect(log).toContain("sandbox connect beta"); }); + it("reconnect starts the named gateway when it is missing", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-reconnect-missing-gateway-")); + const localBin = path.join(home, "bin"); + const registryDir = path.join(home, ".nemoclaw"); + const markerFile = path.join(home, "reconnect-args"); + const stateFile = path.join(home, "gateway-state"); + fs.mkdirSync(localBin, { recursive: true }); + fs.mkdirSync(registryDir, { recursive: true }); + fs.writeFileSync(stateFile, "missing\n"); + fs.writeFileSync( + path.join(registryDir, "sandboxes.json"), + JSON.stringify({ + sandboxes: { + alpha: { + name: "alpha", + model: "test-model", + provider: "nvidia-prod", + gpuEnabled: false, + policies: [], + }, + }, + defaultSandbox: "alpha", + }), + { mode: 0o600 } + ); + fs.writeFileSync( + path.join(localBin, "openshell"), + [ + "#!/usr/bin/env bash", + `marker_file=${JSON.stringify(markerFile)}`, + `state_file=${JSON.stringify(stateFile)}`, + "state=$(tr -d '\\n' < \"$state_file\")", + "printf '%s\\n' \"$*\" >> \"$marker_file\"", + "if [ \"$1\" = \"sandbox\" ] && [ \"$2\" = \"get\" ] && [ \"$3\" = \"alpha\" ]; then", + " if [ \"$state\" = \"healthy\" ]; then", + " echo 'Sandbox: alpha'", + " exit 0", + " fi", + " echo 'Error: transport error: Connection refused' >&2", + " exit 1", + "fi", + "if [ \"$1\" = \"status\" ]; then", + " if [ \"$state\" = \"healthy\" ]; then", + " echo 'Server Status'", + " echo", + " echo ' Gateway: nemoclaw'", + " echo ' Status: Connected'", + " exit 0", + " fi", + " echo 'Gateway Status'", + " echo", + " echo ' Status: No gateway configured.'", + " exit 0", + "fi", + "if [ \"$1\" = \"gateway\" ] && [ \"$2\" = \"info\" ] && [ \"$3\" = \"-g\" ] && [ \"$4\" = \"nemoclaw\" ]; then", + " if [ \"$state\" = \"healthy\" ]; then", + " echo 'Gateway Info'", + " echo", + " echo ' Gateway: nemoclaw'", + " exit 0", + " fi", + " exit 1", + "fi", + "if [ \"$1\" = \"gateway\" ] && [ \"$2\" = \"select\" ] && [ \"$3\" = \"nemoclaw\" ]; then", + " exit 0", + "fi", + "if [ \"$1\" = \"gateway\" ] && [ \"$2\" = \"start\" ] && [ \"$3\" = \"--name\" ] && [ \"$4\" = \"nemoclaw\" ]; then", + " printf 'healthy\\n' > \"$state_file\"", + " exit 0", + "fi", + "if [ \"$1\" = \"forward\" ] || [ \"$1\" = \"sandbox\" ]; then", + " exit 0", + "fi", + "exit 0", + ].join("\n"), + { mode: 0o755 } + ); + + const r = runWithEnv("reconnect alpha", { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + }, 25000); + + expect(r.code).toBe(0); + expect(r.out.includes("Reconnecting to sandbox 'alpha'")).toBeTruthy(); + const log = fs.readFileSync(markerFile, "utf8"); + expect(log).toContain("gateway select nemoclaw"); + expect(log).toContain("gateway start --name nemoclaw"); + expect(log).toContain("sandbox get alpha"); + expect(log).toContain("forward start --background 18789 alpha"); + expect(log).toContain("sandbox connect alpha"); + }, 25000); + it("passes --follow through to openshell logs", () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-logs-follow-")); const localBin = path.join(home, "bin"); From 9afe0a34748241a8d7adb9bbc74f8fdd31c6de2f Mon Sep 17 00:00:00 2001 From: peteryuqin Date: Thu, 26 Mar 2026 16:17:11 -0400 Subject: [PATCH 3/4] test(cli): harden reconnect flow assertions --- test/cli.test.js | 42 ++++++++++++++++++++++++++++++------------ 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/test/cli.test.js b/test/cli.test.js index 7197420f0..f540b30ec 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -159,9 +159,12 @@ describe("CLI dispatch", () => { expect(r.code).toBe(0); expect(r.out.includes("Reconnecting to sandbox 'alpha'")).toBeTruthy(); const log = fs.readFileSync(markerFile, "utf8"); - expect(log).toContain("sandbox get alpha"); - expect(log).toContain("forward start --background 18789 alpha"); - expect(log).toContain("sandbox connect alpha"); + const iGet = log.indexOf("sandbox get alpha"); + const iForward = log.indexOf("forward start --background 18789 alpha"); + const iConnect = log.indexOf("sandbox connect alpha"); + expect(iGet).toBeGreaterThanOrEqual(0); + expect(iForward).toBeGreaterThan(iGet); + expect(iConnect).toBeGreaterThan(iForward); }); it("reconnect accepts an explicit sandbox name", () => { @@ -175,6 +178,13 @@ describe("CLI dispatch", () => { path.join(registryDir, "sandboxes.json"), JSON.stringify({ sandboxes: { + alpha: { + name: "alpha", + model: "test-model", + provider: "nvidia-prod", + gpuEnabled: false, + policies: [], + }, beta: { name: "beta", model: "test-model", @@ -183,7 +193,7 @@ describe("CLI dispatch", () => { policies: [], }, }, - defaultSandbox: "beta", + defaultSandbox: "alpha", }), { mode: 0o600 } ); @@ -213,9 +223,12 @@ describe("CLI dispatch", () => { expect(r.code).toBe(0); expect(r.out.includes("Reconnecting to sandbox 'beta'")).toBeTruthy(); const log = fs.readFileSync(markerFile, "utf8"); - expect(log).toContain("sandbox get beta"); - expect(log).toContain("forward start --background 18789 beta"); - expect(log).toContain("sandbox connect beta"); + const iGet = log.indexOf("sandbox get beta"); + const iForward = log.indexOf("forward start --background 18789 beta"); + const iConnect = log.indexOf("sandbox connect beta"); + expect(iGet).toBeGreaterThanOrEqual(0); + expect(iForward).toBeGreaterThan(iGet); + expect(iConnect).toBeGreaterThan(iForward); }); it("reconnect starts the named gateway when it is missing", () => { @@ -304,11 +317,16 @@ describe("CLI dispatch", () => { expect(r.code).toBe(0); expect(r.out.includes("Reconnecting to sandbox 'alpha'")).toBeTruthy(); const log = fs.readFileSync(markerFile, "utf8"); - expect(log).toContain("gateway select nemoclaw"); - expect(log).toContain("gateway start --name nemoclaw"); - expect(log).toContain("sandbox get alpha"); - expect(log).toContain("forward start --background 18789 alpha"); - expect(log).toContain("sandbox connect alpha"); + const iSelect = log.indexOf("gateway select nemoclaw"); + const iStart = log.indexOf("gateway start --name nemoclaw"); + const iGet = log.lastIndexOf("sandbox get alpha"); + const iForward = log.indexOf("forward start --background 18789 alpha"); + const iConnect = log.indexOf("sandbox connect alpha"); + expect(iSelect).toBeGreaterThanOrEqual(0); + expect(iStart).toBeGreaterThan(iSelect); + expect(iGet).toBeGreaterThan(iStart); + expect(iForward).toBeGreaterThan(iGet); + expect(iConnect).toBeGreaterThan(iForward); }, 25000); it("passes --follow through to openshell logs", () => { From c7cf950f15084114614a9b797a9553a594e54511 Mon Sep 17 00:00:00 2001 From: peteryuqin Date: Thu, 26 Mar 2026 21:53:54 -0400 Subject: [PATCH 4/4] fix(cli): validate reconnect args and sandbox existence --- bin/nemoclaw.js | 14 ++++++++ test/cli.test.js | 91 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+) diff --git a/bin/nemoclaw.js b/bin/nemoclaw.js index eba7ed825..ce54ae3df 100755 --- a/bin/nemoclaw.js +++ b/bin/nemoclaw.js @@ -573,6 +573,15 @@ function resolveReconnectSandboxName(requestedName) { process.exit(1); } validateName(sandboxName, "sandbox name"); + + if (requestedName) { + const existingSandbox = registry.getSandbox(sandboxName); + if (!existingSandbox) { + console.error(` Unknown sandbox '${sandboxName}'.`); + console.error(" Use `nemoclaw list` to view registered sandboxes."); + process.exit(1); + } + } return sandboxName; } @@ -589,6 +598,11 @@ async function sandboxConnect(sandboxName) { } async function reconnect(args = []) { + if (args.length > 1) { + console.error(" Too many positional arguments for `reconnect`."); + console.error(" Usage: `nemoclaw reconnect [sandbox-name]`."); + process.exit(1); + } const sandboxName = resolveReconnectSandboxName(args[0]); console.log(` Reconnecting to sandbox '${sandboxName}'...`); await sandboxConnect(sandboxName); diff --git a/test/cli.test.js b/test/cli.test.js index f540b30ec..d8cefabec 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -231,6 +231,97 @@ describe("CLI dispatch", () => { expect(iConnect).toBeGreaterThan(iForward); }); + it("reconnect rejects an explicit sandbox name that is not in the registry", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-reconnect-unknown-")); + 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: { + alpha: { + name: "alpha", + model: "test-model", + provider: "nvidia-prod", + gpuEnabled: false, + policies: [], + }, + }, + defaultSandbox: "alpha", + }), + { mode: 0o600 } + ); + fs.writeFileSync( + path.join(localBin, "openshell"), + [ + "#!/usr/bin/env bash", + "printf '%s\\n' \"$*\"", + "exit 0", + ].join("\n"), + { mode: 0o755 } + ); + + const r = runWithEnv("reconnect beta", { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + }); + + expect(r.code).toBe(1); + expect(r.out.includes("Unknown sandbox 'beta'")).toBeTruthy(); + expect(r.out.includes("Use `nemoclaw list` to view registered sandboxes.")).toBeTruthy(); + }); + + it("reconnect rejects more than one positional sandbox argument", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-reconnect-extra-")); + 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: { + alpha: { + name: "alpha", + model: "test-model", + provider: "nvidia-prod", + gpuEnabled: false, + policies: [], + }, + beta: { + name: "beta", + model: "test-model", + provider: "nvidia-prod", + gpuEnabled: false, + policies: [], + }, + }, + defaultSandbox: "alpha", + }), + { mode: 0o600 } + ); + fs.writeFileSync( + path.join(localBin, "openshell"), + [ + "#!/usr/bin/env bash", + "printf '%s\\n' \"$*\"", + "exit 0", + ].join("\n"), + { mode: 0o755 } + ); + + const r = runWithEnv("reconnect alpha beta", { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + }); + + expect(r.code).toBe(1); + expect(r.out.includes("Too many positional arguments for `reconnect`.")).toBeTruthy(); + expect(r.out.includes("Usage: `nemoclaw reconnect [sandbox-name]`.")).toBeTruthy(); + }); + it("reconnect starts the named gateway when it is missing", () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-reconnect-missing-gateway-")); const localBin = path.join(home, "bin");