Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
22 changes: 20 additions & 2 deletions bin/nemoclaw.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]);

Expand Down Expand Up @@ -134,7 +134,7 @@ async function recoverNamedGatewayRuntime() {
}

const shouldStartGateway = [before.state, after.state].some((state) =>
["named_unhealthy", "named_unreachable", "connected_other"].includes(state)
["named_unhealthy", "named_unreachable", "connected_other", "missing_named"].includes(state)
);

if (shouldStartGateway) {
Expand Down Expand Up @@ -557,6 +557,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) {
Expand All @@ -571,6 +581,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);
Expand Down Expand Up @@ -721,6 +737,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 <name> connect Shell into a running sandbox
nemoclaw <name> status Sandbox health + NIM status
nemoclaw <name> logs ${D}[--follow]${R} Stream sandbox logs
Expand Down Expand Up @@ -778,6 +795,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;
Expand Down
225 changes: 225 additions & 0 deletions test/cli.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,231 @@ 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");
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", () => {
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: {
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",
`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");
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", () => {
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");
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", () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-logs-follow-"));
const localBin = path.join(home, "bin");
Expand Down