From 0caf9bc2fbc714696d2e5d962abff0fb36804f64 Mon Sep 17 00:00:00 2001 From: Brian Taylor Date: Mon, 16 Mar 2026 22:56:44 -0700 Subject: [PATCH 1/9] test: add GPU detection tests with dependency injection Add dependency injection to detectGpu() via an optional opts parameter, enabling deterministic tests for all 4 code paths: standard NVIDIA, DGX Spark GB10 unified memory, Apple Silicon, and no-GPU fallback. Signed-off-by: Brian Taylor Signed-off-by: Brian Taylor --- bin/lib/nim.js | 17 ++++---- test/nim.test.js | 104 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+), 7 deletions(-) diff --git a/bin/lib/nim.js b/bin/lib/nim.js index 4f2233e435f..054678c9509 100644 --- a/bin/lib/nim.js +++ b/bin/lib/nim.js @@ -23,10 +23,13 @@ function listModels() { })); } -function detectGpu() { +function detectGpu(opts) { + const runCmd = (opts && opts.runCapture) || runCapture; + const platform = (opts && opts.platform) || process.platform; + // Try NVIDIA first — query VRAM try { - const output = runCapture( + const output = runCmd( "nvidia-smi --query-gpu=memory.total --format=csv,noheader,nounits", { ignoreError: true } ); @@ -48,7 +51,7 @@ function detectGpu() { // Fallback: DGX Spark (GB10) — VRAM not queryable due to unified memory architecture try { - const nameOutput = runCapture( + const nameOutput = runCmd( "nvidia-smi --query-gpu=name --format=csv,noheader,nounits", { ignoreError: true } ); @@ -56,7 +59,7 @@ function detectGpu() { // GB10 has 128GB unified memory shared with Grace CPU — use system RAM let totalMemoryMB = 0; try { - const memLine = runCapture("free -m | awk '/Mem:/ {print $2}'", { ignoreError: true }); + const memLine = runCmd("free -m | awk '/Mem:/ {print $2}'", { ignoreError: true }); if (memLine) totalMemoryMB = parseInt(memLine.trim(), 10) || 0; } catch {} return { @@ -71,9 +74,9 @@ function detectGpu() { } catch {} // macOS: detect Apple Silicon or discrete GPU - if (process.platform === "darwin") { + if (platform === "darwin") { try { - const spOutput = runCapture( + const spOutput = runCmd( "system_profiler SPDisplaysDataType 2>/dev/null", { ignoreError: true } ); @@ -92,7 +95,7 @@ function detectGpu() { } else { // Apple Silicon shares system RAM — read total memory try { - const memBytes = runCapture("sysctl -n hw.memsize", { ignoreError: true }); + const memBytes = runCmd("sysctl -n hw.memsize", { ignoreError: true }); if (memBytes) memoryMB = Math.floor(parseInt(memBytes, 10) / 1024 / 1024); } catch {} } diff --git a/test/nim.test.js b/test/nim.test.js index 8166cf6c430..dee2ec8508e 100644 --- a/test/nim.test.js +++ b/test/nim.test.js @@ -74,4 +74,108 @@ describe("nim", () => { assert.equal(st.running, false); }); }); + + describe("detectGpu (injected)", () => { + function mockRunCapture(responses) { + return function (cmd) { + for (const [pattern, response] of responses) { + if (cmd.includes(pattern)) { + if (response instanceof Error) throw response; + return response; + } + } + throw new Error("mock: no match for " + cmd); + }; + } + + it("detects standard NVIDIA GPU", () => { + const gpu = nim.detectGpu({ + runCapture: mockRunCapture([ + ["memory.total", "8192"], + ]), + }); + assert.equal(gpu.type, "nvidia"); + assert.equal(gpu.count, 1); + assert.equal(gpu.totalMemoryMB, 8192); + assert.equal(gpu.perGpuMB, 8192); + assert.equal(gpu.nimCapable, true); + assert.equal(gpu.spark, undefined); + }); + + it("detects multiple NVIDIA GPUs", () => { + const gpu = nim.detectGpu({ + runCapture: mockRunCapture([ + ["memory.total", "8192\n8192"], + ]), + }); + assert.equal(gpu.type, "nvidia"); + assert.equal(gpu.count, 2); + assert.equal(gpu.totalMemoryMB, 16384); + assert.equal(gpu.perGpuMB, 8192); + }); + + it("detects DGX Spark GB10", () => { + const gpu = nim.detectGpu({ + runCapture: mockRunCapture([ + ["memory.total", ""], + ["name", "NVIDIA GB10"], + ["free -m", "122880"], + ]), + }); + assert.equal(gpu.type, "nvidia"); + assert.equal(gpu.spark, true); + assert.equal(gpu.count, 1); + assert.equal(gpu.totalMemoryMB, 122880); + }); + + it("handles Spark with free -m failure", () => { + const gpu = nim.detectGpu({ + runCapture: mockRunCapture([ + ["memory.total", ""], + ["name", "NVIDIA GB10"], + ["free -m", new Error("command failed")], + ]), + }); + assert.equal(gpu.type, "nvidia"); + assert.equal(gpu.spark, true); + assert.equal(gpu.totalMemoryMB, 0); + }); + + it("detects Apple Silicon", () => { + const gpu = nim.detectGpu({ + platform: "darwin", + runCapture: mockRunCapture([ + ["memory.total", new Error("no nvidia-smi")], + ["name", new Error("no nvidia-smi")], + ["system_profiler", "Chipset Model: Apple M2 Pro\n VRAM (Total): 16 GB\n Total Number of Cores: 19"], + ]), + }); + assert.equal(gpu.type, "apple"); + assert.equal(gpu.name, "Apple M2 Pro"); + assert.equal(gpu.nimCapable, false); + assert.equal(gpu.totalMemoryMB, 16384); + assert.equal(gpu.cores, 19); + }); + + it("returns null when no GPU detected", () => { + const gpu = nim.detectGpu({ + platform: "linux", + runCapture: mockRunCapture([ + ["memory.total", new Error("no nvidia-smi")], + ["name", new Error("no nvidia-smi")], + ]), + }); + assert.equal(gpu, null); + }); + + it("non-GB10 NVIDIA has no spark property", () => { + const gpu = nim.detectGpu({ + runCapture: mockRunCapture([ + ["memory.total", "24576"], + ]), + }); + assert.equal(gpu.type, "nvidia"); + assert.equal(gpu.spark, undefined); + }); + }); }); From d821b3a2990327c6ca6ea9e1615ffd1264042dbb Mon Sep 17 00:00:00 2001 From: Brian Taylor Date: Tue, 17 Mar 2026 17:27:01 -0700 Subject: [PATCH 2/9] test: add Apple Silicon unified memory path coverage - Add test for the sysctl hw.memsize fallback when system_profiler reports no VRAM (the actual Apple Silicon code path) - Rename existing Apple test to clarify it covers the discrete VRAM parsing branch - Use more specific mock pattern "query-gpu=name" to avoid substring collisions --- test/nim.test.js | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/test/nim.test.js b/test/nim.test.js index dee2ec8508e..322490e9eeb 100644 --- a/test/nim.test.js +++ b/test/nim.test.js @@ -141,7 +141,7 @@ describe("nim", () => { assert.equal(gpu.totalMemoryMB, 0); }); - it("detects Apple Silicon", () => { + it("detects macOS discrete GPU via VRAM", () => { const gpu = nim.detectGpu({ platform: "darwin", runCapture: mockRunCapture([ @@ -157,6 +157,23 @@ describe("nim", () => { assert.equal(gpu.cores, 19); }); + it("detects Apple Silicon with unified memory", () => { + const gpu = nim.detectGpu({ + platform: "darwin", + runCapture: mockRunCapture([ + ["memory.total", new Error("no nvidia-smi")], + ["query-gpu=name", new Error("no nvidia-smi")], + ["system_profiler", "Chipset Model: Apple M4\n Total Number of Cores: 10"], + ["hw.memsize", "17179869184"], + ]), + }); + assert.equal(gpu.type, "apple"); + assert.equal(gpu.name, "Apple M4"); + assert.equal(gpu.nimCapable, false); + assert.equal(gpu.totalMemoryMB, 16384); + assert.equal(gpu.cores, 10); + }); + it("returns null when no GPU detected", () => { const gpu = nim.detectGpu({ platform: "linux", From 4e4d7c1ffd1d2b1587d8b38ecc38ae7542302e41 Mon Sep 17 00:00:00 2001 From: Brian Taylor Date: Tue, 17 Mar 2026 18:04:52 -0700 Subject: [PATCH 3/9] docs: add JSDoc to detectGpu for docstring coverage check --- bin/lib/nim.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/bin/lib/nim.js b/bin/lib/nim.js index 054678c9509..a0d555cb3a3 100644 --- a/bin/lib/nim.js +++ b/bin/lib/nim.js @@ -23,6 +23,14 @@ function listModels() { })); } +/** + * Detect GPU hardware. Returns an object describing the GPU (type, count, + * memory, capabilities) or null if no GPU is found. + * @param {object} [opts] - Optional overrides for dependency injection. + * @param {Function} [opts.runCapture] - Command runner (default: runner.runCapture). + * @param {string} [opts.platform] - OS platform (default: process.platform). + * @returns {{ type: string, count: number, totalMemoryMB: number, perGpuMB: number, nimCapable: boolean, spark?: boolean, name?: string, cores?: number } | null} + */ function detectGpu(opts) { const runCmd = (opts && opts.runCapture) || runCapture; const platform = (opts && opts.platform) || process.platform; From 3f50def2a34b6023b4d00704624400c5c3395799 Mon Sep 17 00:00:00 2001 From: Brian Taylor Date: Tue, 17 Mar 2026 18:13:21 -0700 Subject: [PATCH 4/9] fix: omit cores property when unknown instead of returning null Aligns runtime behavior with JSDoc contract (cores?: number). When system_profiler does not report core count, the property is now omitted entirely rather than set to null. --- bin/lib/nim.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/lib/nim.js b/bin/lib/nim.js index a0d555cb3a3..448a5bd55f7 100644 --- a/bin/lib/nim.js +++ b/bin/lib/nim.js @@ -112,7 +112,7 @@ function detectGpu(opts) { type: "apple", name, count: 1, - cores: coresMatch ? parseInt(coresMatch[1], 10) : null, + ...(coresMatch ? { cores: parseInt(coresMatch[1], 10) } : {}), totalMemoryMB: memoryMB, perGpuMB: memoryMB, nimCapable: false, From 4cfea55352710f356c1f6af9d64a62824f7137f4 Mon Sep 17 00:00:00 2001 From: Brian Taylor Date: Tue, 17 Mar 2026 18:25:47 -0700 Subject: [PATCH 5/9] docs: add JSDoc to all nim.js functions for docstring coverage --- bin/lib/nim.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/bin/lib/nim.js b/bin/lib/nim.js index 448a5bd55f7..71fb434a998 100644 --- a/bin/lib/nim.js +++ b/bin/lib/nim.js @@ -6,15 +6,18 @@ const { run, runCapture } = require("./runner"); const nimImages = require("./nim-images.json"); +/** @param {string} sandboxName @returns {string} Docker container name. */ function containerName(sandboxName) { return `nemoclaw-nim-${sandboxName}`; } +/** @param {string} modelName @returns {string|null} NIM container image or null. */ function getImageForModel(modelName) { const entry = nimImages.models.find((m) => m.name === modelName); return entry ? entry.image : null; } +/** @returns {Array<{name: string, image: string, minGpuMemoryMB: number}>} */ function listModels() { return nimImages.models.map((m) => ({ name: m.name, @@ -125,6 +128,7 @@ function detectGpu(opts) { return null; } +/** @param {string} model - Model name to pull. @returns {string} Image tag. */ function pullNimImage(model) { const image = getImageForModel(model); if (!image) { @@ -136,6 +140,7 @@ function pullNimImage(model) { return image; } +/** @param {string} sandboxName @param {string} model @param {number} [port=8000] @returns {string} Container name. */ function startNimContainer(sandboxName, model, port = 8000) { const name = containerName(sandboxName); const image = getImageForModel(model); @@ -154,6 +159,7 @@ function startNimContainer(sandboxName, model, port = 8000) { return name; } +/** @param {number} [port=8000] @param {number} [timeout=300] @returns {boolean} True if healthy. */ function waitForNimHealth(port = 8000, timeout = 300) { const start = Date.now(); const interval = 5000; @@ -176,6 +182,7 @@ function waitForNimHealth(port = 8000, timeout = 300) { return false; } +/** @param {string} sandboxName - Stop and remove the NIM container. */ function stopNimContainer(sandboxName) { const name = containerName(sandboxName); console.log(` Stopping NIM container: ${name}`); @@ -183,6 +190,7 @@ function stopNimContainer(sandboxName) { run(`docker rm ${name} 2>/dev/null || true`, { ignoreError: true }); } +/** @param {string} sandboxName @returns {{running: boolean, healthy?: boolean, container: string, state?: string}} */ function nimStatus(sandboxName) { const name = containerName(sandboxName); try { From 6f749789dbb027ddeb8d47e758c50c97491659c1 Mon Sep 17 00:00:00 2001 From: Brian Taylor Date: Tue, 17 Mar 2026 18:27:32 -0700 Subject: [PATCH 6/9] feat(nim): add GPU model pre-selector with recommended model Adds suggestModelsForGpu() that ranks NIM models by VRAM fit and marks the optimal model as recommended. Also surfaces GPU name in NVIDIA detection for better display during onboarding. --- bin/lib/nim.js | 35 ++++++++++++++++++++++++++++++++ bin/lib/onboard.js | 10 ++++++---- test/nim.test.js | 50 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 91 insertions(+), 4 deletions(-) diff --git a/bin/lib/nim.js b/bin/lib/nim.js index 71fb434a998..0a69c1f27fb 100644 --- a/bin/lib/nim.js +++ b/bin/lib/nim.js @@ -49,8 +49,18 @@ function detectGpu(opts) { const perGpuMB = lines.map((l) => parseInt(l.trim(), 10)).filter((n) => !isNaN(n)); if (perGpuMB.length > 0) { const totalMemoryMB = perGpuMB.reduce((a, b) => a + b, 0); + // Query GPU name for display + let name; + try { + name = runCmd( + "nvidia-smi --query-gpu=name --format=csv,noheader,nounits", + { ignoreError: true } + ); + if (name) name = name.split("\n")[0].trim(); + } catch {} return { type: "nvidia", + name, count: perGpuMB.length, totalMemoryMB, perGpuMB: perGpuMB[0], @@ -128,6 +138,30 @@ function detectGpu(opts) { return null; } +/** + * Suggest NIM models ranked by fit for a given GPU. + * Returns models sorted by VRAM requirement (descending), with the largest + * model that uses ≤90% of available VRAM marked as recommended. + * @param {{ totalMemoryMB: number, nimCapable: boolean } | null} gpu + * @returns {Array<{ name: string, image: string, minGpuMemoryMB: number, recommended: boolean }>} + */ +function suggestModelsForGpu(gpu) { + if (!gpu || !gpu.nimCapable) return []; + const vram = gpu.totalMemoryMB; + const fits = listModels() + .filter((m) => m.minGpuMemoryMB <= vram) + .sort((a, b) => b.minGpuMemoryMB - a.minGpuMemoryMB); + + // Mark the largest model that fits within 90% VRAM as recommended + const threshold = vram * 0.9; + let recommended = false; + return fits.map((m) => { + const rec = !recommended && m.minGpuMemoryMB <= threshold; + if (rec) recommended = true; + return { ...m, recommended: rec }; + }); +} + /** @param {string} model - Model name to pull. @returns {string} Image tag. */ function pullNimImage(model) { const image = getImageForModel(model); @@ -218,6 +252,7 @@ module.exports = { getImageForModel, listModels, detectGpu, + suggestModelsForGpu, pullNimImage, startNimContainer, waitForNimHealth, diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index 23f19b01a89..fdc40471e25 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -314,7 +314,8 @@ async function preflight() { // GPU const gpu = nim.detectGpu(); if (gpu && gpu.type === "nvidia") { - console.log(` ✓ NVIDIA GPU detected: ${gpu.count} GPU(s), ${gpu.totalMemoryMB} MB VRAM`); + const label = gpu.name ? `${gpu.name}, ` : ""; + console.log(` ✓ NVIDIA GPU detected: ${label}${gpu.count} GPU(s), ${gpu.totalMemoryMB} MB VRAM`); } else if (gpu && gpu.type === "apple") { console.log(` ✓ Apple GPU detected: ${gpu.name}${gpu.cores ? ` (${gpu.cores} cores)` : ""}, ${gpu.totalMemoryMB} MB unified memory`); console.log(" ⓘ NIM requires NVIDIA GPU — will use cloud inference"); @@ -539,8 +540,8 @@ async function setupNim(sandboxName, gpu) { } if (selected.key === "nim") { - // List models that fit GPU VRAM - const models = nim.listModels().filter((m) => m.minGpuMemoryMB <= gpu.totalMemoryMB); + // List models that fit GPU VRAM, ranked with recommendation + const models = nim.suggestModelsForGpu(gpu); if (models.length === 0) { console.log(" No NIM models fit your GPU VRAM. Falling back to cloud API."); } else { @@ -560,7 +561,8 @@ async function setupNim(sandboxName, gpu) { console.log(""); console.log(" Models that fit your GPU:"); models.forEach((m, i) => { - console.log(` ${i + 1}) ${m.name} (min ${m.minGpuMemoryMB} MB)`); + const tag = m.recommended ? " (recommended)" : ""; + console.log(` ${i + 1}) ${m.name} (min ${m.minGpuMemoryMB} MB)${tag}`); }); console.log(""); diff --git a/test/nim.test.js b/test/nim.test.js index 322490e9eeb..7cad7be12c7 100644 --- a/test/nim.test.js +++ b/test/nim.test.js @@ -68,6 +68,54 @@ describe("nim", () => { }); }); + describe("suggestModelsForGpu", () => { + it("returns empty for null GPU", () => { + assert.deepEqual(nim.suggestModelsForGpu(null), []); + }); + + it("returns empty for non-nimCapable GPU", () => { + assert.deepEqual(nim.suggestModelsForGpu({ totalMemoryMB: 16384, nimCapable: false }), []); + }); + + it("filters models that exceed VRAM", () => { + const models = nim.suggestModelsForGpu({ totalMemoryMB: 8000, nimCapable: true }); + for (const m of models) { + assert.ok(m.minGpuMemoryMB <= 8000, `${m.name} requires ${m.minGpuMemoryMB} MB`); + } + }); + + it("sorts by VRAM descending", () => { + const models = nim.suggestModelsForGpu({ totalMemoryMB: 200000, nimCapable: true }); + for (let i = 1; i < models.length; i++) { + assert.ok(models[i - 1].minGpuMemoryMB >= models[i].minGpuMemoryMB, + "models should be sorted by VRAM descending"); + } + }); + + it("marks exactly one model as recommended", () => { + const models = nim.suggestModelsForGpu({ totalMemoryMB: 200000, nimCapable: true }); + const recommended = models.filter((m) => m.recommended); + assert.equal(recommended.length, 1, "exactly one model should be recommended"); + }); + + it("recommended model fits within 90% VRAM", () => { + const vram = 32000; + const models = nim.suggestModelsForGpu({ totalMemoryMB: vram, nimCapable: true }); + const rec = models.find((m) => m.recommended); + if (rec) { + assert.ok(rec.minGpuMemoryMB <= vram * 0.9, + `recommended model (${rec.minGpuMemoryMB} MB) should fit within 90% of ${vram} MB`); + } + }); + + it("each entry has recommended boolean", () => { + const models = nim.suggestModelsForGpu({ totalMemoryMB: 200000, nimCapable: true }); + for (const m of models) { + assert.equal(typeof m.recommended, "boolean"); + } + }); + }); + describe("nimStatus", () => { it("returns not running for nonexistent container", () => { const st = nim.nimStatus("nonexistent-test-xyz"); @@ -92,9 +140,11 @@ describe("nim", () => { const gpu = nim.detectGpu({ runCapture: mockRunCapture([ ["memory.total", "8192"], + ["query-gpu=name", "NVIDIA GeForce RTX 4090"], ]), }); assert.equal(gpu.type, "nvidia"); + assert.equal(gpu.name, "NVIDIA GeForce RTX 4090"); assert.equal(gpu.count, 1); assert.equal(gpu.totalMemoryMB, 8192); assert.equal(gpu.perGpuMB, 8192); From 54d90851b74b4ee01f7c0ebe674d9c22a54277d6 Mon Sep 17 00:00:00 2001 From: Brian Taylor Date: Tue, 17 Mar 2026 19:16:43 -0700 Subject: [PATCH 7/9] security: address CodeRabbit findings in nim.js - Validate sandboxName in containerName() to prevent command injection via shell metacharacters (regex: /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/) - Replace process.exit(1) with throw in pullNimImage and startNimContainer so callers can handle errors gracefully - Accept port parameter in nimStatus() instead of hardcoding 8000 - Add tests for sandbox name validation and throw behavior --- bin/lib/nim.js | 15 ++++++++------- test/nim.test.js | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/bin/lib/nim.js b/bin/lib/nim.js index 0a69c1f27fb..12d4f213e22 100644 --- a/bin/lib/nim.js +++ b/bin/lib/nim.js @@ -8,6 +8,9 @@ const nimImages = require("./nim-images.json"); /** @param {string} sandboxName @returns {string} Docker container name. */ function containerName(sandboxName) { + if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(sandboxName)) { + throw new Error(`Invalid sandbox name: ${sandboxName}`); + } return `nemoclaw-nim-${sandboxName}`; } @@ -166,8 +169,7 @@ function suggestModelsForGpu(gpu) { function pullNimImage(model) { const image = getImageForModel(model); if (!image) { - console.error(` Unknown model: ${model}`); - process.exit(1); + throw new Error(`Unknown model: ${model}`); } console.log(` Pulling NIM image: ${image}`); run(`docker pull ${image}`); @@ -179,8 +181,7 @@ function startNimContainer(sandboxName, model, port = 8000) { const name = containerName(sandboxName); const image = getImageForModel(model); if (!image) { - console.error(` Unknown model: ${model}`); - process.exit(1); + throw new Error(`Unknown model: ${model}`); } // Stop any existing container with same name @@ -224,8 +225,8 @@ function stopNimContainer(sandboxName) { run(`docker rm ${name} 2>/dev/null || true`, { ignoreError: true }); } -/** @param {string} sandboxName @returns {{running: boolean, healthy?: boolean, container: string, state?: string}} */ -function nimStatus(sandboxName) { +/** @param {string} sandboxName @param {number} [port=8000] @returns {{running: boolean, healthy?: boolean, container: string, state?: string}} */ +function nimStatus(sandboxName, port = 8000) { const name = containerName(sandboxName); try { const state = runCapture( @@ -236,7 +237,7 @@ function nimStatus(sandboxName) { let healthy = false; if (state === "running") { - const health = runCapture(`curl -sf http://localhost:8000/v1/models 2>/dev/null`, { + const health = runCapture(`curl -sf http://localhost:${port}/v1/models 2>/dev/null`, { ignoreError: true, }); healthy = !!health; diff --git a/test/nim.test.js b/test/nim.test.js index 7cad7be12c7..6e1bd1618ae 100644 --- a/test/nim.test.js +++ b/test/nim.test.js @@ -39,6 +39,18 @@ describe("nim", () => { it("prefixes with nemoclaw-nim-", () => { assert.equal(nim.containerName("my-sandbox"), "nemoclaw-nim-my-sandbox"); }); + + it("rejects names with shell metacharacters", () => { + assert.throws(() => nim.containerName("foo;rm -rf /"), /Invalid sandbox name/); + }); + + it("rejects empty string", () => { + assert.throws(() => nim.containerName(""), /Invalid sandbox name/); + }); + + it("accepts alphanumeric with dots and underscores", () => { + assert.equal(nim.containerName("my_sandbox.1"), "nemoclaw-nim-my_sandbox.1"); + }); }); describe("detectGpu", () => { @@ -116,6 +128,12 @@ describe("nim", () => { }); }); + describe("pullNimImage", () => { + it("throws for unknown model instead of process.exit", () => { + assert.throws(() => nim.pullNimImage("bogus/nonexistent"), /Unknown model/); + }); + }); + describe("nimStatus", () => { it("returns not running for nonexistent container", () => { const st = nim.nimStatus("nonexistent-test-xyz"); From 97f38ea58ebfdfa54ad5ff568651268aa9a4ac60 Mon Sep 17 00:00:00 2001 From: Brian Taylor Date: Wed, 18 Mar 2026 14:36:51 -0700 Subject: [PATCH 8/9] fix: check port 8000 before starting local NIM container Verify the NIM port is available before pulling the image and starting the container. Without this check, a port conflict (e.g. an existing vLLM instance) causes an opaque docker failure instead of a clear onboarding error with the blocking process identified. --- bin/lib/onboard.js | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index fdc40471e25..14797bef2a9 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -572,14 +572,22 @@ async function setupNim(sandboxName, gpu) { } model = sel.name; + const nimPort = 8000; + const nimPortCheck = await checkPortAvailable(nimPort); + if (!nimPortCheck.ok) { + console.error(` Port ${nimPort} is already in use (${nimPortCheck.process || "unknown"}).`); + console.error(" Stop the existing service or choose a different provider."); + process.exit(1); + } + console.log(` Pulling NIM image for ${model}...`); nim.pullNimImage(model); console.log(" Starting NIM container..."); - nimContainer = nim.startNimContainer(sandboxName, model); + nimContainer = nim.startNimContainer(sandboxName, model, nimPort); console.log(" Waiting for NIM to become healthy..."); - if (!nim.waitForNimHealth()) { + if (!nim.waitForNimHealth(nimPort)) { console.error(" NIM failed to start. Falling back to cloud API."); model = null; nimContainer = null; From 8f18ff98082a2a73c82e347bc6240b8f27f43b46 Mon Sep 17 00:00:00 2001 From: Brian Taylor Date: Wed, 18 Mar 2026 14:37:36 -0700 Subject: [PATCH 9/9] fix: add per-request timeout to NIM health probe Without a timeout, a single curl invocation can stall indefinitely on a localhost socket, defeating the outer polling loop's timeout. Add curl --max-time 10 and execSync timeout: 15000 as a belt-and-suspenders bound on each probe iteration. --- bin/lib/nim.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bin/lib/nim.js b/bin/lib/nim.js index 12d4f213e22..e880a967923 100644 --- a/bin/lib/nim.js +++ b/bin/lib/nim.js @@ -202,8 +202,9 @@ function waitForNimHealth(port = 8000, timeout = 300) { while ((Date.now() - start) / 1000 < timeout) { try { - const result = runCapture(`curl -sf http://localhost:${port}/v1/models`, { + const result = runCapture(`curl -sf --max-time 10 http://localhost:${port}/v1/models`, { ignoreError: true, + timeout: 15000, }); if (result) { console.log(" NIM is healthy.");