diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index 25fd2fb5c6f..ca9d68ccad1 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -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, @@ -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, @@ -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"); @@ -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; @@ -2249,7 +2261,7 @@ async function recoverGatewayRuntime() { } return true; } - sleep(2); + if (i < recoveryPollCount - 1) sleep(recoveryPollInterval); } return false; diff --git a/nemoclaw/src/blueprint/runner.test.ts b/nemoclaw/src/blueprint/runner.test.ts index a00aee730b6..f13a78dc544 100644 --- a/nemoclaw/src/blueprint/runner.test.ts +++ b/nemoclaw/src/blueprint/runner.test.ts @@ -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 ──────────────────────────────────────── @@ -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(); return { ...original, existsSync: (p: string) => store.has(p), diff --git a/nemoclaw/src/blueprint/snapshot.test.ts b/nemoclaw/src/blueprint/snapshot.test.ts index 6e51d5b7d47..803fff1f6f8 100644 --- a/nemoclaw/src/blueprint/snapshot.test.ts +++ b/nemoclaw/src/blueprint/snapshot.test.ts @@ -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 ──────────────────────────────────────── @@ -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(); + const original = await importOriginal(); return { ...original, existsSync: (p: string) => store.has(p), @@ -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 diff --git a/nemoclaw/src/blueprint/state.test.ts b/nemoclaw/src/blueprint/state.test.ts index 5a80aff48ff..d2efc8ff10c 100644 --- a/nemoclaw/src/blueprint/state.test.ts +++ b/nemoclaw/src/blueprint/state.test.ts @@ -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(); vi.mock("node:fs", async (importOriginal) => { - const original = await importOriginal() as typeof import("node:fs"); + const original = await importOriginal(); return { ...original, existsSync: (p: string) => store.has(p), diff --git a/test/cli.test.js b/test/cli.test.js index 976999a00aa..241e1d3bd81 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -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) { @@ -1226,7 +1232,7 @@ describe("CLI dispatch", () => { HOME: home, PATH: `${localBin}:${process.env.PATH || ""}`, }, - 25000, + 10000, ); expect(r.code).toBe(0); @@ -1234,7 +1240,7 @@ describe("CLI dispatch", () => { 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-")); @@ -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-")); @@ -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-")); @@ -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-")); @@ -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(); @@ -1632,7 +1638,7 @@ describe("CLI dispatch", () => { HOME: home, PATH: `${localBin}:${process.env.PATH || ""}`, }, - 25000, + 10000, ); expect(statusResult.code).toBe(0); expect( @@ -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-")); @@ -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", () => { diff --git a/test/uninstall.test.js b/test/uninstall.test.js index 975646c36e2..5e370997104 100644 --- a/test/uninstall.test.js +++ b/test/uninstall.test.js @@ -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"], { @@ -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", @@ -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"); @@ -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");