Skip to content
Merged
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
20 changes: 16 additions & 4 deletions bin/lib/onboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@ const os = require("os");
const path = require("path");
const { spawn, spawnSync } = require("child_process");
const pRetry = require("p-retry");

/** Parse a numeric env var, returning `fallback` when unset or non-finite. */
function envInt(name, fallback) {
const raw = process.env[name];
if (raw === undefined || raw === "") return fallback;
const n = Number(raw);
return Number.isFinite(n) ? Math.max(0, Math.round(n)) : fallback;
}
const { ROOT, SCRIPTS, run, runCapture, shellQuote } = require("./runner");
const {
getDefaultOllamaModel,
Expand Down Expand Up @@ -2146,7 +2154,9 @@ async function startGatewayWithOptions(_gpu, { exitOnFailure = true } = {}) {
() => {
runOpenshell(["gateway", "start", ...gwArgs], { ignoreError: true, env: gatewayEnv });

for (let i = 0; i < 5; i++) {
const healthPollCount = envInt("NEMOCLAW_HEALTH_POLL_COUNT", 5);
const healthPollInterval = envInt("NEMOCLAW_HEALTH_POLL_INTERVAL", 2);
for (let i = 0; i < healthPollCount; i++) {
const status = runCaptureOpenshell(["status"], { ignoreError: true });
const namedInfo = runCaptureOpenshell(["gateway", "info", "-g", GATEWAY_NAME], {
ignoreError: true,
Expand All @@ -2155,7 +2165,7 @@ async function startGatewayWithOptions(_gpu, { exitOnFailure = true } = {}) {
if (isGatewayHealthy(status, namedInfo, currentInfo)) {
return; // success
}
if (i < 4) sleep(2);
if (i < healthPollCount - 1) sleep(healthPollInterval);
}

throw new Error("Gateway failed to start");
Expand Down Expand Up @@ -2237,7 +2247,9 @@ async function recoverGatewayRuntime() {
});
runOpenshell(["gateway", "select", GATEWAY_NAME], { ignoreError: true });

for (let i = 0; i < 10; i++) {
const recoveryPollCount = envInt("NEMOCLAW_HEALTH_POLL_COUNT", 10);
const recoveryPollInterval = envInt("NEMOCLAW_HEALTH_POLL_INTERVAL", 2);
for (let i = 0; i < recoveryPollCount; i++) {
status = runCaptureOpenshell(["status"], { ignoreError: true });
if (status.includes("Connected") && isSelectedGateway(status)) {
process.env.OPENSHELL_GATEWAY = GATEWAY_NAME;
Expand All @@ -2249,7 +2261,7 @@ async function recoverGatewayRuntime() {
}
return true;
}
sleep(2);
if (i < recoveryPollCount - 1) sleep(recoveryPollInterval);
}

return false;
Expand Down
3 changes: 2 additions & 1 deletion nemoclaw/src/blueprint/runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// SPDX-License-Identifier: Apache-2.0

import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import type fs from "node:fs";
import YAML from "yaml";

// ── In-memory filesystem ────────────────────────────────────────
Expand Down Expand Up @@ -32,7 +33,7 @@ vi.mock("node:crypto", () => ({
}));

vi.mock("node:fs", async (importOriginal) => {
const original = await importOriginal() as typeof import("node:fs");
const original = await importOriginal<typeof fs>();
return {
...original,
existsSync: (p: string) => store.has(p),
Expand Down
6 changes: 3 additions & 3 deletions nemoclaw/src/blueprint/snapshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// SPDX-License-Identifier: Apache-2.0

import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import type fs from "node:fs";
const SNAP = "/snap/20260323";

// ── In-memory filesystem ────────────────────────────────────────
Expand All @@ -23,13 +24,12 @@ function addDir(p: string): void {

const FAKE_HOME = "/fakehome";


vi.mock("node:os", () => ({
homedir: () => FAKE_HOME,
}));

vi.mock("node:fs", async (importOriginal) => {
const original = await importOriginal<typeof import("node:fs")>();
const original = await importOriginal<typeof fs>();
return {
...original,
existsSync: (p: string) => store.has(p),
Expand Down Expand Up @@ -127,7 +127,7 @@ describe("snapshot", () => {

expect(result).not.toBeNull();
if (!result) throw new Error("createSnapshot returned null");

expect(result.startsWith(SNAPSHOTS_DIR)).toBe(true);

// Manifest was written
Expand Down
3 changes: 2 additions & 1 deletion nemoclaw/src/blueprint/state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@
// SPDX-License-Identifier: Apache-2.0

import { describe, it, expect, beforeEach, vi } from "vitest";
import type fs from "node:fs";
import { loadState, saveState, clearState, type NemoClawState } from "./state.js";

const store = new Map<string, string>();

vi.mock("node:fs", async (importOriginal) => {
const original = await importOriginal() as typeof import("node:fs");
const original = await importOriginal<typeof fs>();
return {
...original,
existsSync: (p: string) => store.has(p),
Expand Down
34 changes: 20 additions & 14 deletions test/cli.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,13 @@ function runWithEnv(args, env = {}, timeout = 10000) {
const out = execSync(`node "${CLI}" ${args}`, {
encoding: "utf-8",
timeout,
env: { ...process.env, HOME: "/tmp/nemoclaw-cli-test-" + Date.now(), ...env },
env: {
...process.env,
HOME: "/tmp/nemoclaw-cli-test-" + Date.now(),
NEMOCLAW_HEALTH_POLL_COUNT: "1",
NEMOCLAW_HEALTH_POLL_INTERVAL: "0",
...env,
},
});
return { code: 0, out };
} catch (err) {
Expand Down Expand Up @@ -1226,15 +1232,15 @@ describe("CLI dispatch", () => {
HOME: home,
PATH: `${localBin}:${process.env.PATH || ""}`,
},
25000,
10000,
);

expect(r.code).toBe(0);
expect(r.out.includes("Could not verify sandbox 'alpha'")).toBeTruthy();
expect(r.out.includes("gateway identity drift after restart")).toBeTruthy();
const saved = JSON.parse(fs.readFileSync(path.join(registryDir, "sandboxes.json"), "utf8"));
expect(saved.sandboxes.alpha).toBeTruthy();
}, 25000);
}, 10000);

it("recovers status after gateway runtime is reattached", () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-recover-status-"));
Expand Down Expand Up @@ -1366,14 +1372,14 @@ describe("CLI dispatch", () => {
HOME: home,
PATH: `${localBin}:${process.env.PATH || ""}`,
},
25000,
10000,
);

expect(r.code).toBe(0);
expect(r.out.includes("Recovered NemoClaw gateway runtime")).toBeFalsy();
expect(r.out.includes("Could not verify sandbox 'alpha'")).toBeTruthy();
expect(r.out.includes("verify the active gateway")).toBeTruthy();
}, 25000);
}, 10000);

it("matches ANSI-decorated gateway transport errors when printing lifecycle hints", () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-ansi-transport-hint-"));
Expand Down Expand Up @@ -1430,12 +1436,12 @@ describe("CLI dispatch", () => {
HOME: home,
PATH: `${localBin}:${process.env.PATH || ""}`,
},
25000,
10000,
);

expect(r.code).toBe(0);
expect(r.out.includes("current gateway/runtime is not reachable")).toBeTruthy();
}, 25000);
}, 10000);

it("matches ANSI-decorated gateway auth errors when printing lifecycle hints", () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-ansi-auth-hint-"));
Expand Down Expand Up @@ -1492,14 +1498,14 @@ describe("CLI dispatch", () => {
HOME: home,
PATH: `${localBin}:${process.env.PATH || ""}`,
},
25000,
10000,
);

expect(r.code).toBe(0);
expect(
r.out.includes("Verify the active gateway and retry after re-establishing the runtime."),
).toBeTruthy();
}, 25000);
}, 10000);

it("explains unrecoverable gateway trust rotation after restart", () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-identity-drift-"));
Expand Down Expand Up @@ -1555,7 +1561,7 @@ describe("CLI dispatch", () => {
HOME: home,
PATH: `${localBin}:${process.env.PATH || ""}`,
},
25000,
10000,
);
expect(statusResult.code).toBe(0);
expect(statusResult.out.includes("gateway trust material rotated after restart")).toBeTruthy();
Expand Down Expand Up @@ -1632,7 +1638,7 @@ describe("CLI dispatch", () => {
HOME: home,
PATH: `${localBin}:${process.env.PATH || ""}`,
},
25000,
10000,
);
expect(statusResult.code).toBe(0);
expect(
Expand All @@ -1651,7 +1657,7 @@ describe("CLI dispatch", () => {
connectResult.out.includes("gateway is still refusing connections after restart"),
).toBeTruthy();
expect(connectResult.out.includes("If the gateway never becomes healthy")).toBeTruthy();
}, 25000);
}, 10000);

it("explains when the named gateway is no longer configured after restart or rebuild", () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-gateway-missing-"));
Expand Down Expand Up @@ -1709,14 +1715,14 @@ describe("CLI dispatch", () => {
HOME: home,
PATH: `${localBin}:${process.env.PATH || ""}`,
},
25000,
10000,
);
expect(statusResult.code).toBe(0);
expect(
statusResult.out.includes("gateway is no longer configured after restart/rebuild"),
).toBeTruthy();
expect(statusResult.out.includes("Start the gateway again")).toBeTruthy();
}, 25000);
}, 10000);
});

describe("list shows live gateway inference", () => {
Expand Down
63 changes: 19 additions & 44 deletions test/uninstall.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,21 +37,15 @@ describe("uninstall CLI flags", () => {
});

it("--yes skips the confirmation prompt and completes successfully", () => {
const tmp = fs.mkdtempSync(
path.join(os.tmpdir(), "nemoclaw-uninstall-yes-"),
);
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-uninstall-yes-"));
const fakeBin = path.join(tmp, "bin");
fs.mkdirSync(fakeBin);

try {
for (const cmd of ["npm", "openshell", "docker", "ollama", "pgrep"]) {
fs.writeFileSync(
path.join(fakeBin, cmd),
"#!/usr/bin/env bash\nexit 0\n",
{
mode: 0o755,
},
);
fs.writeFileSync(path.join(fakeBin, cmd), "#!/usr/bin/env bash\nexit 0\n", {
mode: 0o755,
});
}

const result = spawnSync("bash", [UNINSTALL_SCRIPT, "--yes"], {
Expand Down Expand Up @@ -79,10 +73,7 @@ describe("uninstall helpers", () => {
it("returns the expected gateway volume candidate", () => {
const result = spawnSync(
"bash",
[
"-c",
`source "${UNINSTALL_SCRIPT}"; gateway_volume_candidates nemoclaw`,
],
["-c", `source "${UNINSTALL_SCRIPT}"; gateway_volume_candidates nemoclaw`],
{
cwd: path.join(import.meta.dirname, ".."),
encoding: "utf-8",
Expand All @@ -94,9 +85,7 @@ describe("uninstall helpers", () => {
});

it("removes the user-local nemoclaw shim", () => {
const tmp = fs.mkdtempSync(
path.join(os.tmpdir(), "nemoclaw-uninstall-shim-"),
);
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-uninstall-shim-"));
const shimDir = path.join(tmp, ".local", "bin");
const shimPath = path.join(shimDir, "nemoclaw");
const targetPath = path.join(tmp, "prefix", "bin", "nemoclaw");
Expand All @@ -106,51 +95,37 @@ describe("uninstall helpers", () => {
fs.writeFileSync(targetPath, "#!/usr/bin/env bash\n", { mode: 0o755 });
fs.symlinkSync(targetPath, shimPath);

const result = spawnSync(
"bash",
["-c", `source "${UNINSTALL_SCRIPT}"; remove_nemoclaw_cli`],
{
cwd: path.join(import.meta.dirname, ".."),
encoding: "utf-8",
env: createFakeNpmEnv(tmp),
},
);
const result = spawnSync("bash", ["-c", `source "${UNINSTALL_SCRIPT}"; remove_nemoclaw_cli`], {
cwd: path.join(import.meta.dirname, ".."),
encoding: "utf-8",
env: createFakeNpmEnv(tmp),
});

expect(result.status).toBe(0);
expect(fs.existsSync(shimPath)).toBe(false);
});

it("preserves a user-managed nemoclaw file in the shim directory", () => {
const tmp = fs.mkdtempSync(
path.join(os.tmpdir(), "nemoclaw-uninstall-preserve-"),
);
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-uninstall-preserve-"));
const shimDir = path.join(tmp, ".local", "bin");
const shimPath = path.join(shimDir, "nemoclaw");

fs.mkdirSync(shimDir, { recursive: true });
fs.writeFileSync(shimPath, "#!/usr/bin/env bash\n", { mode: 0o755 });

const result = spawnSync(
"bash",
["-c", `source "${UNINSTALL_SCRIPT}"; remove_nemoclaw_cli`],
{
cwd: path.join(import.meta.dirname, ".."),
encoding: "utf-8",
env: createFakeNpmEnv(tmp),
},
);
const result = spawnSync("bash", ["-c", `source "${UNINSTALL_SCRIPT}"; remove_nemoclaw_cli`], {
cwd: path.join(import.meta.dirname, ".."),
encoding: "utf-8",
env: createFakeNpmEnv(tmp),
});

expect(result.status).toBe(0);
expect(fs.existsSync(shimPath)).toBe(true);
expect(`${result.stdout}${result.stderr}`).toMatch(
/not an installer-managed shim/,
);
expect(`${result.stdout}${result.stderr}`).toMatch(/not an installer-managed shim/);
});

it("removes the onboard session file as part of NemoClaw state cleanup", () => {
const tmp = fs.mkdtempSync(
path.join(os.tmpdir(), "nemoclaw-uninstall-session-"),
);
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-uninstall-session-"));
const stateDir = path.join(tmp, ".nemoclaw");
const sessionPath = path.join(stateDir, "onboard-session.json");

Expand Down
Loading