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
34 changes: 25 additions & 9 deletions src/nemoclaw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ const {
validateName,
} = require("./lib/runner");
const { resolveOpenshell } = require("./lib/resolve-openshell");
const { startGatewayForRecovery } = require("./lib/onboard");
const { startGatewayForRecovery, pruneKnownHostsEntries } = require("./lib/onboard");
const {
getCredential,
deleteCredential,
Expand Down Expand Up @@ -474,7 +474,8 @@ async function recoverRegistryEntries({ requestedSandboxName = null } = {}) {
}

const seeded = seedRecoveryMetadata(current, session, requestedSandboxName);
const shouldProbeLiveGateway = current.sandboxes.length > 0 || Boolean(session?.sandboxName);
const shouldProbeLiveGateway =
current.sandboxes.length > 0 || Boolean(session?.sandboxName) || Boolean(requestedSandboxName);
const recoveredFromGateway = shouldProbeLiveGateway
? await recoverRegistryFromLiveGateway(seeded.metadataByName)
: 0;
Expand Down Expand Up @@ -760,15 +761,30 @@ async function ensureLiveSandboxOrExit(sandboxName) {
process.exit(1);
}
if (lookup.state === "identity_drift") {
console.error(
` Sandbox '${sandboxName}' is recorded locally, but the gateway trust material rotated after restart.`,
);
if (lookup.output) {
console.error(lookup.output);
// Gateway SSH keys rotated after restart — clear stale known_hosts and retry.
console.error(" Gateway SSH identity changed after restart — clearing stale host keys...");
const knownHostsPath = path.join(os.homedir(), ".ssh", "known_hosts");
if (fs.existsSync(knownHostsPath)) {
try {
const kh = fs.readFileSync(knownHostsPath, "utf8");
const cleaned = pruneKnownHostsEntries(kh);
if (cleaned !== kh) fs.writeFileSync(knownHostsPath, cleaned);
} catch {
/* best-effort cleanup */
}
}
const retry = await getReconciledSandboxGatewayState(sandboxName);
if (retry.state === "present") {
console.error(" ✓ Reconnected after clearing stale SSH host keys.");
return retry;
}
// Retry failed — fall through to error
console.error(
" Existing sandbox connections cannot be reattached safely after this gateway identity change.",
` Could not reconnect to sandbox '${sandboxName}' after clearing stale host keys.`,
);
if (retry.output) {
console.error(retry.output);
}
console.error(
" Recreate this sandbox with `nemoclaw onboard` once the gateway runtime is stable.",
);
Comment on lines +776 to 790

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Handle the "missing" retry result explicitly.

After pruning known_hosts, the retry can legitimately resolve to state === "missing". This branch currently reports a reconnect failure instead, so the stale registry/session entry survives and the user gets the wrong next step.

Suggested fix
     const retry = await getReconciledSandboxGatewayState(sandboxName);
     if (retry.state === "present") {
       console.error("  ✓ Reconnected after clearing stale SSH host keys.");
       return retry;
     }
+    if (retry.state === "missing") {
+      registry.removeSandbox(sandboxName);
+      const session = onboardSession.loadSession();
+      if (session && session.sandboxName === sandboxName) {
+        onboardSession.updateSession((s) => {
+          s.sandboxName = null;
+          return s;
+        });
+      }
+      console.error(`  Sandbox '${sandboxName}' is not present in the live OpenShell gateway.`);
+      console.error("  Removed stale local registry entry.");
+      process.exit(1);
+    }
     // Retry failed — fall through to error
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/nemoclaw.ts` around lines 776 - 790, The code treats any non-"present"
retry as a reconnect failure; explicitly handle the "missing" retry result
returned by getReconciledSandboxGatewayState(sandboxName) — add a branch
checking if retry.state === "missing" and log a clear message that the sandbox
is now missing after pruning known_hosts (indicating the stale registry/session
was cleared), provide the correct next step (e.g., suggest `nemoclaw onboard`),
and return retry instead of falling through to the generic failure/error path so
the stale entry does not survive and the user gets the proper guidance.

Expand Down Expand Up @@ -2392,7 +2408,7 @@ const [cmd, ...args] = process.argv.slice(2);
// command, attempt recovery — the sandbox may still be live with a stale registry.
if (
!registry.getSandbox(cmd) &&
["connect", "skill", "shields", "config"].includes(args[0] || "")
["connect", "skill", "shields", "config", ""].includes(args[0] || "")
) {
validateName(cmd, "sandbox name");
await recoverRegistryEntries({ requestedSandboxName: cmd });
Expand Down
4 changes: 3 additions & 1 deletion test/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1940,7 +1940,9 @@ describe("CLI dispatch", () => {
PATH: `${localBin}:${process.env.PATH || ""}`,
});
expect(connectResult.code).toBe(1);
expect(connectResult.out.includes("gateway trust material rotated after restart")).toBeTruthy();
// After the auto-recovery attempt (clear stale host keys + retry), the
// fake openshell still returns the handshake error, so recovery fails.
expect(connectResult.out.includes("Could not reconnect")).toBeTruthy();
expect(connectResult.out.includes("Recreate this sandbox")).toBeTruthy();
});

Expand Down
320 changes: 320 additions & 0 deletions test/reboot-identity-drift.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,320 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
//
// Regression tests for SSH identity drift after host reboot.
// Covers: https://github.com/NVIDIA/NemoClaw/issues/2056
//
// Simulates the post-reboot scenario where the gateway restarts with new SSH
// keys, causing "handshake verification failed" errors. Verifies:
// 1. The registry recovery gate triggers for bare `nemoclaw <name>` (no action)
// 2. Identity drift is detected and surfaced (current behavior)
//
// Once the fix for #2056 lands, update these tests to assert auto-recovery.

import { spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";

const tmpFixtures: string[] = [];

afterEach(() => {
for (const dir of tmpFixtures.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});

/**
* Build a temp HOME with a NemoClaw registry and a fake openshell binary.
*
* @param sandboxName - name of the sandbox in the registry
* @param mode - "healthy" | "identity_drift" | "gateway_down"
*/
function setupFixture(sandboxName: string, mode: "healthy" | "identity_drift" | "gateway_down") {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-reboot-"));
tmpFixtures.push(tmpDir);
const homeLocalBin = path.join(tmpDir, ".local", "bin");
const registryDir = path.join(tmpDir, ".nemoclaw");
const openshellPath = path.join(homeLocalBin, "openshell");

fs.mkdirSync(homeLocalBin, { recursive: true });
fs.mkdirSync(registryDir, { recursive: true });

fs.writeFileSync(
path.join(registryDir, "sandboxes.json"),
JSON.stringify({
defaultSandbox: sandboxName,
sandboxes: {
[sandboxName]: {
name: sandboxName,
model: "nvidia/test-model",
provider: "nvidia-prod",
gpuEnabled: false,
policies: [],
},
},
}),
{ mode: 0o600 },
);

// Fake openshell binary that simulates post-reboot states
const handshakeError =
"ssh: handshake verification failed — gateway identity has changed since last connection";

let sandboxGetBehavior: string;
let statusBehavior: string;
let gatewayInfoBehavior: string;
let gatewayStartBehavior: string;

switch (mode) {
case "healthy":
statusBehavior = `process.stdout.write("Gateway: nemoclaw\\nStatus: Connected\\n"); process.exit(0);`;
gatewayInfoBehavior = `process.stdout.write("Gateway: nemoclaw\\nGateway endpoint: https://127.0.0.1:8080\\n"); process.exit(0);`;
sandboxGetBehavior = `process.stdout.write("Sandbox:\\n\\n Id: abc\\n Name: ${sandboxName}\\n Phase: Ready\\n"); process.exit(0);`;
gatewayStartBehavior = `process.exit(0);`;
break;
case "identity_drift":
// Gateway is running but SSH keys have changed — sandbox commands fail
statusBehavior = `process.stdout.write("Gateway: nemoclaw\\nStatus: Connected\\n"); process.exit(0);`;
gatewayInfoBehavior = `process.stdout.write("Gateway: nemoclaw\\nGateway endpoint: https://127.0.0.1:8080\\n"); process.exit(0);`;
sandboxGetBehavior = `process.stderr.write("${handshakeError}\\n"); process.exit(1);`;
gatewayStartBehavior = `process.exit(0);`;
break;
case "gateway_down":
// Gateway container is not running — simulates post-reboot before recovery
statusBehavior = `process.stdout.write("No gateway configured\\n"); process.exit(1);`;
gatewayInfoBehavior = `process.stdout.write("No gateway metadata found\\n"); process.exit(1);`;
sandboxGetBehavior = `process.stderr.write("Connection refused\\n"); process.exit(1);`;
gatewayStartBehavior = `process.exit(0);`;
break;
}

fs.writeFileSync(
openshellPath,
`#!${process.execPath}
const args = process.argv.slice(2);

if (args[0] === "status") {
${statusBehavior}
}

if (args[0] === "gateway" && args[1] === "info") {
${gatewayInfoBehavior}
}

if (args[0] === "gateway" && args[1] === "start") {
${gatewayStartBehavior}
}

if (args[0] === "gateway" && args[1] === "select") {
process.exit(0);
}

if (args[0] === "sandbox" && args[1] === "get" && args[2] === ${JSON.stringify(sandboxName)}) {
${sandboxGetBehavior}
}

if (args[0] === "sandbox" && args[1] === "list") {
process.stdout.write("${sandboxName}\\n");
process.exit(0);
}

if (args[0] === "sandbox" && args[1] === "connect") {
process.exit(0);
}

if (args[0] === "policy" && args[1] === "get") {
process.exit(1);
}

if (args[0] === "inference" && args[1] === "get") {
process.stdout.write("Gateway inference:\\n Provider: nvidia-prod\\n Model: nvidia/test-model\\n");
process.exit(0);
}

if (args[0] === "forward") {
process.exit(0);
}

if (args[0] === "logs") {
process.exit(0);
}

process.exit(0);
`,
{ mode: 0o755 },
);

return { tmpDir, sandboxName };
}

/**
* Simulates a cleared/corrupt registry (sandbox entry missing) where the
* gateway is still live. The recovery gate should attempt to rebuild the
* registry from the live gateway for both explicit and bare commands.
*/
function setupEmptyRegistry(sandboxName: string) {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-reboot-noreg-"));
tmpFixtures.push(tmpDir);
const homeLocalBin = path.join(tmpDir, ".local", "bin");
const registryDir = path.join(tmpDir, ".nemoclaw");
const openshellPath = path.join(homeLocalBin, "openshell");

fs.mkdirSync(homeLocalBin, { recursive: true });
fs.mkdirSync(registryDir, { recursive: true });

// Empty registry — no sandboxes known
fs.writeFileSync(
path.join(registryDir, "sandboxes.json"),
JSON.stringify({ defaultSandbox: null, sandboxes: {} }),
{ mode: 0o600 },
);

// Fake openshell — gateway is healthy and sandbox is live
fs.writeFileSync(
openshellPath,
`#!${process.execPath}
const args = process.argv.slice(2);

if (args[0] === "status") {
process.stdout.write("Gateway: nemoclaw\\nStatus: Connected\\n");
process.exit(0);
}

if (args[0] === "gateway" && args[1] === "info") {
process.stdout.write("Gateway: nemoclaw\\nGateway endpoint: https://127.0.0.1:8080\\n");
process.exit(0);
}

if (args[0] === "gateway" && args[1] === "select") {
process.exit(0);
}

if (args[0] === "gateway" && args[1] === "start") {
process.exit(0);
}

if (args[0] === "sandbox" && args[1] === "get" && args[2] === ${JSON.stringify(sandboxName)}) {
process.stdout.write("Sandbox:\\n\\n Id: abc\\n Name: ${sandboxName}\\n Phase: Ready\\n");
process.exit(0);
}

if (args[0] === "sandbox" && args[1] === "list") {
process.stdout.write("${sandboxName}\\n");
process.exit(0);
}

if (args[0] === "sandbox" && args[1] === "connect") {
process.exit(0);
}

if (args[0] === "policy" && args[1] === "get") {
process.exit(1);
}

if (args[0] === "inference" && args[1] === "get") {
process.stdout.write("Gateway inference:\\n Provider: nvidia-prod\\n Model: nvidia/test-model\\n");
process.exit(0);
}

if (args[0] === "forward") {
process.exit(0);
}

if (args[0] === "logs") {
process.exit(0);
}

process.exit(0);
`,
{ mode: 0o755 },
);

return { tmpDir, sandboxName };
}

function runCli(tmpDir: string, args: string[]) {
const repoRoot = path.join(import.meta.dirname, "..");
return spawnSync(process.execPath, [path.join(repoRoot, "bin", "nemoclaw.js"), ...args], {
cwd: repoRoot,
encoding: "utf-8",
env: {
...process.env,
HOME: tmpDir,
PATH: "/usr/bin:/bin",
NEMOCLAW_NO_CONNECT_HINT: "1",
},
timeout: Number(process.env.NEMOCLAW_EXEC_TIMEOUT || 15_000),
});
}

describe("post-reboot SSH identity drift (#2056)", () => {
it(
"bare `nemoclaw <name>` (no action) resolves to connect and finds registry entry",
{ timeout: Number(process.env.NEMOCLAW_TEST_TIMEOUT || 20_000) },
() => {
const { tmpDir, sandboxName } = setupFixture("reboot-test", "healthy");
const result = runCli(tmpDir, [sandboxName]);
expect(result.status).toBe(0);
},
);

it(
"explicit `nemoclaw <name> connect` works for healthy sandbox",
{ timeout: Number(process.env.NEMOCLAW_TEST_TIMEOUT || 20_000) },
() => {
const { tmpDir, sandboxName } = setupFixture("reboot-explicit", "healthy");
const result = runCli(tmpDir, [sandboxName, "connect"]);
expect(result.status).toBe(0);
},
);

it(
"identity drift is detected when gateway SSH keys have changed",
{ timeout: Number(process.env.NEMOCLAW_TEST_TIMEOUT || 20_000) },
() => {
const { tmpDir, sandboxName } = setupFixture("drift-sandbox", "identity_drift");
const result = runCli(tmpDir, [sandboxName, "connect"]);
const combined = (result.stdout || "") + (result.stderr || "");
expect(combined).toMatch(/identity|handshake|drift|changed/i);
expect(result.status).not.toBe(0);
},
);

it(
"bare `nemoclaw <name>` also detects identity drift (not silent failure)",
{ timeout: Number(process.env.NEMOCLAW_TEST_TIMEOUT || 20_000) },
() => {
const { tmpDir, sandboxName } = setupFixture("drift-bare", "identity_drift");
const result = runCli(tmpDir, [sandboxName]);
const combined = (result.stdout || "") + (result.stderr || "");
expect(combined).not.toMatch(/unknown command/i);
expect(result.status).not.toBe(0);
},
);
});

describe("post-reboot registry recovery gate (#2056)", () => {
it(
"explicit `nemoclaw <name> connect` recovers registry from live gateway",
{ timeout: Number(process.env.NEMOCLAW_TEST_TIMEOUT || 20_000) },
() => {
const { tmpDir, sandboxName } = setupEmptyRegistry("orphan-explicit");
const result = runCli(tmpDir, [sandboxName, "connect"]);
expect(result.status).toBe(0);
},
);

it(
"bare `nemoclaw <name>` recovers registry from live gateway",
{ timeout: Number(process.env.NEMOCLAW_TEST_TIMEOUT || 20_000) },
() => {
const { tmpDir, sandboxName } = setupEmptyRegistry("orphan-bare");
const result = runCli(tmpDir, [sandboxName]);
const combined = (result.stdout || "") + (result.stderr || "");
expect(combined).not.toMatch(/unknown command/i);
expect(result.status).toBe(0);
},
);
});
Loading