From 754403d0c03e63a9d4c091d02d49acf4dbc68328 Mon Sep 17 00:00:00 2001 From: Hung Le Date: Tue, 2 Jun 2026 08:23:33 +0530 Subject: [PATCH 1/8] fix(onboard): nim pull --- src/lib/adapters/docker/image.ts | 8 + src/lib/adapters/docker/index.test.ts | 20 ++- src/lib/adapters/docker/inspect.ts | 9 +- src/lib/inference/nim.test.ts | 203 ++++++++++++++++++++++++++ src/lib/inference/nim.ts | 122 +++++++++++++++- src/lib/onboard.ts | 8 + 6 files changed, 367 insertions(+), 3 deletions(-) diff --git a/src/lib/adapters/docker/image.ts b/src/lib/adapters/docker/image.ts index 44f052e1cd9..67c55870199 100644 --- a/src/lib/adapters/docker/image.ts +++ b/src/lib/adapters/docker/image.ts @@ -43,6 +43,14 @@ export function dockerRmi(imageRef: string, opts: DockerRunOptions = {}): Docker return dockerRun(["rmi", imageRef], opts); } +export function dockerTag( + source: string, + target: string, + opts: DockerRunOptions = {}, +): DockerRunResult { + return dockerRun(["tag", source, target], opts); +} + export function dockerListImagesFormat( reference: string, format: string, diff --git a/src/lib/adapters/docker/index.test.ts b/src/lib/adapters/docker/index.test.ts index 74f0ebf1f7e..924c778b82e 100644 --- a/src/lib/adapters/docker/index.test.ts +++ b/src/lib/adapters/docker/index.test.ts @@ -17,11 +17,13 @@ import { dockerContainerInspectFormat, dockerInfoFormat, dockerListVolumesByPrefix, + dockerManifestInspect, dockerPull, - dockerRename, dockerRemoveVolumesByPrefix, + dockerRename, dockerRmi, dockerRunDetached, + dockerTag, } from "./index"; describe("docker helpers", () => { @@ -130,6 +132,22 @@ describe("docker helpers", () => { ]); }); + it("prefixes docker argv for manifest inspect and tag helpers (#3885)", () => { + runCaptureMock.mockReturnValue('{"manifests":[]}'); + + dockerManifestInspect("nvcr.io/nim/nvidia/x:latest", { ignoreError: true }); + dockerTag("nvcr.io/nim/nvidia/x@sha256:abc", "nvcr.io/nim/nvidia/x:latest"); + + expect(runCaptureMock).toHaveBeenCalledWith( + ["docker", "manifest", "inspect", "nvcr.io/nim/nvidia/x:latest"], + { ignoreError: true }, + ); + expect(runMock).toHaveBeenCalledWith( + ["docker", "tag", "nvcr.io/nim/nvidia/x@sha256:abc", "nvcr.io/nim/nvidia/x:latest"], + {}, + ); + }); + it("filters docker volume names by exact prefix", () => { runCaptureMock.mockReturnValue( [ diff --git a/src/lib/adapters/docker/inspect.ts b/src/lib/adapters/docker/inspect.ts index 8fed1735e12..b542118be95 100644 --- a/src/lib/adapters/docker/inspect.ts +++ b/src/lib/adapters/docker/inspect.ts @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { dockerCapture, dockerRun, type DockerCaptureOptions, type DockerRunOptions } from "./run"; +import { type DockerCaptureOptions, type DockerRunOptions, dockerCapture, dockerRun } from "./run"; export function dockerInspect(args: readonly string[], opts: DockerRunOptions = {}) { return dockerRun(["inspect", ...args], opts); @@ -26,3 +26,10 @@ export function dockerContainerInspectFormat( ): string { return dockerCapture(["inspect", "--type", "container", "--format", format, containerName], opts); } + +// Capture `docker manifest inspect ` (registry manifest, not a local image). +// For a multi-arch tag this is the OCI image index JSON; callers parse it to +// resolve a per-arch digest. Returns "" on failure when ignoreError is set. +export function dockerManifestInspect(imageRef: string, opts: DockerCaptureOptions = {}): string { + return dockerCapture(["manifest", "inspect", imageRef], opts); +} diff --git a/src/lib/inference/nim.test.ts b/src/lib/inference/nim.test.ts index 97fe4769403..2b03e46ba42 100644 --- a/src/lib/inference/nim.test.ts +++ b/src/lib/inference/nim.test.ts @@ -106,6 +106,209 @@ describe("nim", () => { }); }); + // NIM-style OCI index: per-arch linux images + buildkit attestation manifests + // (unknown/unknown) that trip the NGC pull. See #3885. + const NIM_INDEX_JSON = JSON.stringify({ + schemaVersion: 2, + mediaType: "application/vnd.oci.image.index.v1+json", + manifests: [ + { + mediaType: "application/vnd.oci.image.manifest.v1+json", + size: 10203, + digest: "sha256:amd64image", + platform: { architecture: "amd64", os: "linux" }, + }, + { + mediaType: "application/vnd.oci.image.manifest.v1+json", + size: 566, + digest: "sha256:amd64attestation", + platform: { architecture: "unknown", os: "unknown" }, + }, + { + mediaType: "application/vnd.oci.image.manifest.v1+json", + size: 10201, + digest: "sha256:arm64image", + platform: { architecture: "arm64", os: "linux" }, + }, + { + mediaType: "application/vnd.oci.image.manifest.v1+json", + size: 566, + digest: "sha256:arm64attestation", + platform: { architecture: "unknown", os: "unknown" }, + }, + ], + }); + const DIGEST_BY_ARCH: Record = { + amd64: "sha256:amd64image", + arm64: "sha256:arm64image", + }; + + describe("nodeArchToOci", () => { + it("maps x64 to amd64 and passes other arches through", () => { + expect(nim.nodeArchToOci("x64")).toBe("amd64"); + expect(nim.nodeArchToOci("arm64")).toBe("arm64"); + expect(nim.nodeArchToOci("ppc64le")).toBe("ppc64le"); + }); + }); + + describe("imageRepository", () => { + it("drops :tag and @digest, preserving the registry path", () => { + expect(nim.imageRepository("nvcr.io/nim/nvidia/nemotron-3-nano:latest")).toBe( + "nvcr.io/nim/nvidia/nemotron-3-nano", + ); + expect(nim.imageRepository("nvcr.io/nim/nvidia/nemotron-3-nano@sha256:abc")).toBe( + "nvcr.io/nim/nvidia/nemotron-3-nano", + ); + expect(nim.imageRepository("repo/image")).toBe("repo/image"); + }); + + it("treats a colon only in the final path segment as a tag (registry port)", () => { + expect(nim.imageRepository("localhost:5000/team/image:1.0")).toBe( + "localhost:5000/team/image", + ); + }); + }); + + describe("selectPlatformManifestDigest", () => { + it("returns the linux digest for the requested arch, excluding attestation manifests", () => { + expect(nim.selectPlatformManifestDigest(NIM_INDEX_JSON, "arm64")).toBe("sha256:arm64image"); + expect(nim.selectPlatformManifestDigest(NIM_INDEX_JSON, "amd64")).toBe("sha256:amd64image"); + }); + + it("returns null when no entry matches the arch", () => { + expect(nim.selectPlatformManifestDigest(NIM_INDEX_JSON, "ppc64le")).toBeNull(); + }); + + it("returns null for a single-image manifest (no manifests array)", () => { + const single = JSON.stringify({ + schemaVersion: 2, + mediaType: "application/vnd.oci.image.manifest.v1+json", + config: {}, + layers: [], + }); + expect(nim.selectPlatformManifestDigest(single, "amd64")).toBeNull(); + }); + + it("returns null for malformed or empty JSON", () => { + expect(nim.selectPlatformManifestDigest("not json", "amd64")).toBeNull(); + expect(nim.selectPlatformManifestDigest("", "amd64")).toBeNull(); + }); + }); + + describe("pullNimImage", () => { + function findCall(run: Mock, verb: string): string[] | undefined { + const found = run.mock.calls.find((c) => { + const argv = c[0] as string[]; + return Array.isArray(argv) && argv[0] === "docker" && argv[1] === verb; + }); + return found ? (found[0] as string[]) : undefined; + } + + it("resolves the host-arch manifest digest, pulls by digest, then tags back (#3885)", () => { + const run = vi.fn(); + const runCapture = vi.fn((cmd: string | string[]) => { + if (Array.isArray(cmd) && cmd.includes("manifest") && cmd.includes("inspect")) { + return NIM_INDEX_JSON; + } + return ""; + }); + const { nimModule, restore } = loadNimWithMockedRunner(runCapture, run); + try { + const image = nimModule.pullNimImage("nvidia/nemotron-3-nano-30b-a3b"); + expect(image).toBe("nvcr.io/nim/nvidia/nemotron-3-nano:latest"); + + const ociArch = nimModule.nodeArchToOci(process.arch); + const expectedDigest = DIGEST_BY_ARCH[ociArch]; + expect(expectedDigest).toBeDefined(); + const expectedRef = `nvcr.io/nim/nvidia/nemotron-3-nano@${expectedDigest}`; + + // Pull the per-arch digest, never the bare :latest index (the index pull + // is what triggers the attestation-manifest fetch). + expect(findCall(run, "pull")).toEqual(["docker", "pull", expectedRef]); + expect( + run.mock.calls.some( + (c) => + Array.isArray(c[0]) && + (c[0] as string[])[2] === "nvcr.io/nim/nvidia/nemotron-3-nano:latest", + ), + ).toBe(false); + // Re-tag so the run path can start the container by its :latest ref. + expect(findCall(run, "tag")).toEqual([ + "docker", + "tag", + expectedRef, + "nvcr.io/nim/nvidia/nemotron-3-nano:latest", + ]); + } finally { + restore(); + } + }); + + it("falls back to a plain tag pull when manifest inspect yields no index (#3885)", () => { + const run = vi.fn(); + const runCapture = vi.fn(() => ""); // manifest inspect unavailable / not an index + const { nimModule, restore } = loadNimWithMockedRunner(runCapture, run); + try { + nimModule.pullNimImage("nvidia/nemotron-3-nano-30b-a3b"); + expect(findCall(run, "pull")).toEqual([ + "docker", + "pull", + "nvcr.io/nim/nvidia/nemotron-3-nano:latest", + ]); + expect(findCall(run, "tag")).toBeUndefined(); + } finally { + restore(); + } + }); + }); + + describe("parseServedModelId", () => { + it("returns the first data[].id from a /v1/models body", () => { + const body = JSON.stringify({ + object: "list", + data: [{ id: "nvidia/nemotron-3-nano", object: "model", owned_by: "vllm" }], + }); + expect(nim.parseServedModelId(body)).toBe("nvidia/nemotron-3-nano"); + }); + + it("returns null for empty data, missing data, or malformed JSON", () => { + expect(nim.parseServedModelId(JSON.stringify({ object: "list", data: [] }))).toBeNull(); + expect(nim.parseServedModelId(JSON.stringify({ object: "list" }))).toBeNull(); + expect(nim.parseServedModelId("not json")).toBeNull(); + expect(nim.parseServedModelId("")).toBeNull(); + }); + }); + + describe("getServedModelId", () => { + it("curls /v1/models and returns the served id", () => { + const runCapture = vi.fn((cmd: string | string[]) => { + if (Array.isArray(cmd) && cmd.some((a) => a.includes("/v1/models"))) { + return JSON.stringify({ data: [{ id: "nvidia/nemotron-3-nano" }] }); + } + return ""; + }); + const { nimModule, restore } = loadNimWithMockedRunner(runCapture); + try { + expect(nimModule.getServedModelId(8000)).toBe("nvidia/nemotron-3-nano"); + const call = runCapture.mock.calls.find( + (c) => Array.isArray(c[0]) && (c[0] as string[]).some((a) => a.includes("/v1/models")), + ); + expect(call?.[0]).toContain("http://127.0.0.1:8000/v1/models"); + } finally { + restore(); + } + }); + + it("returns null when the endpoint is unreachable", () => { + const { nimModule, restore } = loadNimWithMockedRunner(vi.fn(() => "")); + try { + expect(nimModule.getServedModelId(8000)).toBeNull(); + } finally { + restore(); + } + }); + }); + describe("containerName", () => { it("prefixes with nemoclaw-nim-", () => { expect(nim.containerName("my-sandbox")).toBe("nemoclaw-nim-my-sandbox"); diff --git a/src/lib/inference/nim.ts b/src/lib/inference/nim.ts index 49aa2892003..c369755b1ea 100644 --- a/src/lib/inference/nim.ts +++ b/src/lib/inference/nim.ts @@ -10,11 +10,13 @@ const { dockerForceRm, dockerLoginPasswordStdin, dockerLogs, + dockerManifestInspect, dockerPort, dockerPull, dockerRm, dockerRunDetached, dockerStop, + dockerTag, } = require("../adapters/docker"); const { sleepSeconds } = require("../core/wait"); const nimImages = require("../../../bin/lib/nim-images.json"); @@ -314,6 +316,38 @@ export function canRunNimWithMemory(totalMemoryMB: number): boolean { return nimImages.models.some((m: NimModel) => m.minGpuMemoryMB <= totalMemoryMB); } +// First model id from a NIM/vLLM `GET /v1/models` body, or null if absent/ +// unparseable. NIM may serve a different id than NemoClaw's catalog name. +export function parseServedModelId(modelsJson: string): string | null { + try { + const doc = JSON.parse(modelsJson); + const data = Array.isArray(doc?.data) ? doc.data : []; + for (const entry of data) { + if (typeof entry?.id === "string" && entry.id.length > 0) return entry.id; + } + } catch { + /* not JSON */ + } + return null; +} + +// Model id a running local NIM actually serves; null if unreachable/empty. +export function getServedModelId(port = VLLM_PORT): string | null { + const out = runCapture( + [ + "curl", + "-sf", + "--connect-timeout", + "5", + "--max-time", + "5", + `http://127.0.0.1:${Number(port)}/v1/models`, + ], + { ignoreError: true }, + ); + return out ? parseServedModelId(out) : null; +} + export function detectGpu(): GpuDetection | null { // Try NVIDIA first — query name, total, and free VRAM in a single call so // the preflight line can show the GPU model alongside the memory size and @@ -601,6 +635,92 @@ export function dockerLoginNgc(apiKey: string): boolean { return result.status === 0; } +// Node's process.arch → OCI manifest "architecture" (x64 → amd64; others match). +export function nodeArchToOci(arch: string): string { + if (arch === "x64") return "amd64"; + return arch; +} + +interface ManifestPlatform { + architecture?: string; + os?: string; +} +interface ManifestIndexEntry { + digest?: string; + platform?: ManifestPlatform; +} +interface ManifestIndexDoc { + manifests?: ManifestIndexEntry[]; +} + +// Linux image-manifest digest for `ociArch` from `docker manifest inspect` JSON, +// or null if not a multi-arch index / no match / unparseable. The arch+os match +// also skips buildkit attestation manifests (platform unknown/unknown). See #3885. +export function selectPlatformManifestDigest( + manifestJson: string, + ociArch: string, +): string | null { + let doc: ManifestIndexDoc; + try { + doc = JSON.parse(manifestJson); + } catch { + return null; + } + const manifests = Array.isArray(doc?.manifests) ? doc.manifests : []; + for (const entry of manifests) { + if ( + entry?.platform?.architecture === ociArch && + entry?.platform?.os === "linux" && + typeof entry.digest === "string" && + entry.digest.length > 0 + ) { + return entry.digest; + } + } + return null; +} + +// Repository portion of an image ref, dropping `:tag`/`@digest`. Only a colon +// in the final path segment counts as a tag, so `host:port/repo:tag` is safe. +export function imageRepository(imageRef: string): string { + const lastSlash = imageRef.lastIndexOf("/"); + const prefix = lastSlash === -1 ? "" : imageRef.slice(0, lastSlash + 1); + const lastSegment = lastSlash === -1 ? imageRef : imageRef.slice(lastSlash + 1); + const atIdx = lastSegment.indexOf("@"); + if (atIdx !== -1) return prefix + lastSegment.slice(0, atIdx); + const colonIdx = lastSegment.indexOf(":"); + if (colonIdx !== -1) return prefix + lastSegment.slice(0, colonIdx); + return imageRef; +} + +// Pull `image`, avoiding a NIM-on-NGC break: NIM `:latest` is a multi-arch index +// bundling buildkit attestation manifests (unknown/unknown). Docker's containerd +// image store (default on 29.x) pulls the arch layers, then fetches the +// attestation manifest, which nvcr.io rejects ("Incorrect Repository Format") — +// aborting after all layers, leaving no image. So resolve the index to the +// host-arch digest and pull that single manifest (no index → no attestation +// fetch). Fall back to a plain pull when the ref is not a resolvable index. #3885. +function pullImageResolvingPlatform(image: string): void { + let manifestJson = ""; + try { + manifestJson = dockerManifestInspect(image, { ignoreError: true }) || ""; + } catch { + manifestJson = ""; + } + const digest = manifestJson + ? selectPlatformManifestDigest(manifestJson, nodeArchToOci(process.arch)) + : null; + if (!digest) { + dockerPull(image); + return; + } + const digestRef = `${imageRepository(image)}@${digest}`; + console.log(` Resolved ${nodeArchToOci(process.arch)} manifest: ${digestRef}`); + dockerPull(digestRef); + // Tag back to the friendly ref so the run path starts the container by `image`. + dockerTag(digestRef, image); +} + export function pullNimImage(model: string): string { const image = getImageForModel(model); if (!image) { @@ -608,7 +728,7 @@ export function pullNimImage(model: string): string { process.exit(1); } console.log(` Pulling NIM image: ${image}`); - dockerPull(image); + pullImageResolvingPlatform(image); return image; } diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 504004f75fd..29f24102191 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -4825,6 +4825,14 @@ async function setupNim( console.error(" Local NVIDIA NIM base URL could not be determined."); process.exit(1); } + // NIM serves the id from its image config, which may differ from the + // catalog name; validating/routing with the catalog id 404s. Adopt the + // served id for validation, route, and OpenClaw config. + const servedModelId = nim.getServedModelId(); + if (servedModelId && servedModelId !== model) { + console.log(` NIM serves "${servedModelId}" (catalog "${model}"); using served id.`); + model = servedModelId; + } const nimValidationUrl = getLocalProviderValidationBaseUrl(provider) || endpointUrl; const validation = await validateOpenAiLikeSelection( "Local NVIDIA NIM", From 6d2eec48f5aa34ffdb3b2e3cf6bd1ab6ec9d76d9 Mon Sep 17 00:00:00 2001 From: Hung Le Date: Tue, 2 Jun 2026 09:26:33 +0530 Subject: [PATCH 2/8] fix(onboard): raise local NIM health timeout to 1200s --- src/lib/inference/nim.test.ts | 15 +++++++++++++++ src/lib/inference/nim.ts | 9 +++++++++ src/lib/onboard.ts | 9 ++++++++- 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/lib/inference/nim.test.ts b/src/lib/inference/nim.test.ts index 2b03e46ba42..d240bd9f0e2 100644 --- a/src/lib/inference/nim.test.ts +++ b/src/lib/inference/nim.test.ts @@ -309,6 +309,21 @@ describe("nim", () => { }); }); + describe("resolveNimHealthTimeoutSeconds", () => { + it("honors a positive integer override", () => { + expect(nim.resolveNimHealthTimeoutSeconds("900")).toBe(900); + }); + + it("falls back to the default for unset, empty, non-numeric, or non-positive values", () => { + const def = nim.DEFAULT_NIM_HEALTH_TIMEOUT_SECONDS; + expect(nim.resolveNimHealthTimeoutSeconds(undefined)).toBe(def); + expect(nim.resolveNimHealthTimeoutSeconds("")).toBe(def); + expect(nim.resolveNimHealthTimeoutSeconds("abc")).toBe(def); + expect(nim.resolveNimHealthTimeoutSeconds("0")).toBe(def); + expect(nim.resolveNimHealthTimeoutSeconds("-5")).toBe(def); + }); + }); + describe("containerName", () => { it("prefixes with nemoclaw-nim-", () => { expect(nim.containerName("my-sandbox")).toBe("nemoclaw-nim-my-sandbox"); diff --git a/src/lib/inference/nim.ts b/src/lib/inference/nim.ts index c369755b1ea..48c49b560d3 100644 --- a/src/lib/inference/nim.ts +++ b/src/lib/inference/nim.ts @@ -793,6 +793,15 @@ export interface WaitForNimHealthOptions { container?: string; } +// NIM first-load (in-container weight download + engine warmup) takes minutes, +// longer for big models on unified-memory hosts; the 300s default times out +// mid-load. Override with NEMOCLAW_NIM_HEALTH_TIMEOUT_SECONDS. +export const DEFAULT_NIM_HEALTH_TIMEOUT_SECONDS = 1200; +export function resolveNimHealthTimeoutSeconds(raw?: string): number { + const parsed = Number.parseInt(raw ?? "", 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_NIM_HEALTH_TIMEOUT_SECONDS; +} + export function waitForNimHealth( port = VLLM_PORT, timeout = 300, diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 29f24102191..f9b3c579655 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -4810,7 +4810,14 @@ async function setupNim( }); console.log(" Waiting for NIM to become healthy..."); - if (!nim.waitForNimHealth(undefined, undefined, { container: nimContainerNameLocal })) { + const nimHealthTimeoutSec = nim.resolveNimHealthTimeoutSeconds( + process.env.NEMOCLAW_NIM_HEALTH_TIMEOUT_SECONDS, + ); + if ( + !nim.waitForNimHealth(undefined, nimHealthTimeoutSec, { + container: nimContainerNameLocal, + }) + ) { console.error(" NIM failed to start. Falling back to cloud API."); model = null; nimContainer = null; From a879db81f6b14b5b538dbf4f7cb167a65129fbf4 Mon Sep 17 00:00:00 2001 From: Hung Le Date: Wed, 3 Jun 2026 05:26:35 +0530 Subject: [PATCH 3/8] fix(inference): pass codebase-growth-guardrails and env-var-docs gates --- src/lib/inference/nim.test.ts | 44 +++++++++++++++++++++++--------- src/lib/inference/nim.ts | 47 ++++++++++++++++------------------- src/lib/onboard.ts | 24 +++--------------- 3 files changed, 58 insertions(+), 57 deletions(-) diff --git a/src/lib/inference/nim.test.ts b/src/lib/inference/nim.test.ts index d240bd9f0e2..5e31f6fb87a 100644 --- a/src/lib/inference/nim.test.ts +++ b/src/lib/inference/nim.test.ts @@ -309,18 +309,38 @@ describe("nim", () => { }); }); - describe("resolveNimHealthTimeoutSeconds", () => { - it("honors a positive integer override", () => { - expect(nim.resolveNimHealthTimeoutSeconds("900")).toBe(900); - }); - - it("falls back to the default for unset, empty, non-numeric, or non-positive values", () => { - const def = nim.DEFAULT_NIM_HEALTH_TIMEOUT_SECONDS; - expect(nim.resolveNimHealthTimeoutSeconds(undefined)).toBe(def); - expect(nim.resolveNimHealthTimeoutSeconds("")).toBe(def); - expect(nim.resolveNimHealthTimeoutSeconds("abc")).toBe(def); - expect(nim.resolveNimHealthTimeoutSeconds("0")).toBe(def); - expect(nim.resolveNimHealthTimeoutSeconds("-5")).toBe(def); + describe("adoptServedModelId", () => { + it("returns the served id when it differs from the catalog name (#3885)", () => { + const runCapture = vi.fn(() => JSON.stringify({ data: [{ id: "nvidia/nemotron-3-nano" }] })); + const { nimModule, restore } = loadNimWithMockedRunner(runCapture); + try { + expect(nimModule.adoptServedModelId("nvidia/nemotron-3-nano-30b-a3b", 8000)).toBe( + "nvidia/nemotron-3-nano", + ); + } finally { + restore(); + } + }); + + it("keeps the catalog value when the served id matches or is unavailable", () => { + const match = loadNimWithMockedRunner( + vi.fn(() => JSON.stringify({ data: [{ id: "meta/llama-3.1-8b-instruct" }] })), + ); + try { + expect(match.nimModule.adoptServedModelId("meta/llama-3.1-8b-instruct", 8000)).toBe( + "meta/llama-3.1-8b-instruct", + ); + } finally { + match.restore(); + } + const down = loadNimWithMockedRunner(vi.fn(() => "")); + try { + expect(down.nimModule.adoptServedModelId("nvidia/nemotron-3-nano-30b-a3b", 8000)).toBe( + "nvidia/nemotron-3-nano-30b-a3b", + ); + } finally { + down.restore(); + } }); }); diff --git a/src/lib/inference/nim.ts b/src/lib/inference/nim.ts index 48c49b560d3..e98fc4024b0 100644 --- a/src/lib/inference/nim.ts +++ b/src/lib/inference/nim.ts @@ -316,8 +316,7 @@ export function canRunNimWithMemory(totalMemoryMB: number): boolean { return nimImages.models.some((m: NimModel) => m.minGpuMemoryMB <= totalMemoryMB); } -// First model id from a NIM/vLLM `GET /v1/models` body, or null if absent/ -// unparseable. NIM may serve a different id than NemoClaw's catalog name. +// First model id from a NIM `/v1/models` body, or null if absent/unparseable. export function parseServedModelId(modelsJson: string): string | null { try { const doc = JSON.parse(modelsJson); @@ -348,6 +347,17 @@ export function getServedModelId(port = VLLM_PORT): string | null { return out ? parseServedModelId(out) : null; } +// Route to the id NIM serves (from its image config), which can differ from the +// catalog name; the catalog id 404s on validation/routing. See #3885. +export function adoptServedModelId(catalogModel: string | null, port = VLLM_PORT): string | null { + const served = getServedModelId(port); + if (served && served !== catalogModel) { + console.log(` NIM serves "${served}" (catalog "${catalogModel}"); using served id.`); + return served; + } + return catalogModel; +} + export function detectGpu(): GpuDetection | null { // Try NVIDIA first — query name, total, and free VRAM in a single call so // the preflight line can show the GPU model alongside the memory size and @@ -654,8 +664,7 @@ interface ManifestIndexDoc { } // Linux image-manifest digest for `ociArch` from `docker manifest inspect` JSON, -// or null if not a multi-arch index / no match / unparseable. The arch+os match -// also skips buildkit attestation manifests (platform unknown/unknown). See #3885. +// or null if not a multi-arch index / no match. Arch+os match skips attestations. export function selectPlatformManifestDigest( manifestJson: string, ociArch: string, @@ -680,8 +689,7 @@ export function selectPlatformManifestDigest( return null; } -// Repository portion of an image ref, dropping `:tag`/`@digest`. Only a colon -// in the final path segment counts as a tag, so `host:port/repo:tag` is safe. +// Repository portion of an image ref, dropping `:tag`/`@digest` (port-safe). export function imageRepository(imageRef: string): string { const lastSlash = imageRef.lastIndexOf("/"); const prefix = lastSlash === -1 ? "" : imageRef.slice(0, lastSlash + 1); @@ -693,13 +701,10 @@ export function imageRepository(imageRef: string): string { return imageRef; } -// Pull `image`, avoiding a NIM-on-NGC break: NIM `:latest` is a multi-arch index -// bundling buildkit attestation manifests (unknown/unknown). Docker's containerd -// image store (default on 29.x) pulls the arch layers, then fetches the -// attestation manifest, which nvcr.io rejects ("Incorrect Repository Format") — -// aborting after all layers, leaving no image. So resolve the index to the -// host-arch digest and pull that single manifest (no index → no attestation -// fetch). Fall back to a plain pull when the ref is not a resolvable index. #3885. +// Pull `image` avoiding the NIM-on-NGC break: docker's containerd store fetches +// the index's buildkit attestation manifest, which nvcr.io rejects ("Incorrect +// Repository Format") after pulling all layers. Pull the host-arch manifest by +// digest instead (no index walk); plain pull when not a resolvable index. #3885. function pullImageResolvingPlatform(image: string): void { let manifestJson = ""; try { @@ -793,18 +798,10 @@ export interface WaitForNimHealthOptions { container?: string; } -// NIM first-load (in-container weight download + engine warmup) takes minutes, -// longer for big models on unified-memory hosts; the 300s default times out -// mid-load. Override with NEMOCLAW_NIM_HEALTH_TIMEOUT_SECONDS. -export const DEFAULT_NIM_HEALTH_TIMEOUT_SECONDS = 1200; -export function resolveNimHealthTimeoutSeconds(raw?: string): number { - const parsed = Number.parseInt(raw ?? "", 10); - return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_NIM_HEALTH_TIMEOUT_SECONDS; -} - export function waitForNimHealth( port = VLLM_PORT, - timeout = 300, + // First load (weight download + warmup) takes minutes, longer for big models. + timeout = 1200, opts: WaitForNimHealthOptions = {}, ): boolean { const start = Date.now(); @@ -835,8 +832,8 @@ export function waitForNimHealth( /* ignored */ } // Short-circuit if the container has already exited — typically NGC auth - // failure or OOM during model load. Without this, the wizard polls the - // full timeout (default 300s) against a dead container. See #3333. + // failure or OOM during model load — instead of polling the full timeout + // against a dead container. See #3333. if (container) { const state = dockerContainerInspectFormat("{{.State.Status}}", container, { ignoreError: true, diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 2688b098c4a..472d931073e 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -4807,14 +4807,7 @@ async function setupNim( }); console.log(" Waiting for NIM to become healthy..."); - const nimHealthTimeoutSec = nim.resolveNimHealthTimeoutSeconds( - process.env.NEMOCLAW_NIM_HEALTH_TIMEOUT_SECONDS, - ); - if ( - !nim.waitForNimHealth(undefined, nimHealthTimeoutSec, { - container: nimContainerNameLocal, - }) - ) { + if (!nim.waitForNimHealth(undefined, undefined, { container: nimContainerNameLocal })) { console.error(" NIM failed to start. Falling back to cloud API."); model = null; nimContainer = null; @@ -4829,14 +4822,7 @@ async function setupNim( console.error(" Local NVIDIA NIM base URL could not be determined."); process.exit(1); } - // NIM serves the id from its image config, which may differ from the - // catalog name; validating/routing with the catalog id 404s. Adopt the - // served id for validation, route, and OpenClaw config. - const servedModelId = nim.getServedModelId(); - if (servedModelId && servedModelId !== model) { - console.log(` NIM serves "${servedModelId}" (catalog "${model}"); using served id.`); - model = servedModelId; - } + model = nim.adoptServedModelId(model); const nimValidationUrl = getLocalProviderValidationBaseUrl(provider) || endpointUrl; const validation = await validateOpenAiLikeSelection( "Local NVIDIA NIM", @@ -4850,10 +4836,8 @@ async function setupNim( if (!validation.ok) { continue selectionLoop; } - preferredInferenceApi = validation.api; - // NIM uses vLLM internally — same tool-call-parser limitation - // applies to /v1/responses. Force chat completions. - if (preferredInferenceApi !== "openai-completions") { + // NIM (vLLM) mishandles the /v1/responses developer role; force chat completions. + if (validation.api !== "openai-completions") { console.log( " ℹ Using chat completions API (tool-call-parser requires /v1/chat/completions)", ); From d6482832dbe3fb5b70966c35c991985f2a8d1946 Mon Sep 17 00:00:00 2001 From: Hung Le Date: Wed, 3 Jun 2026 05:46:54 +0530 Subject: [PATCH 4/8] fix(inference): validate NIM served model id with isSafeModelId --- src/lib/inference/nim.test.ts | 13 +++++++++++++ src/lib/inference/nim.ts | 16 ++++++++++------ 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/src/lib/inference/nim.test.ts b/src/lib/inference/nim.test.ts index 5e31f6fb87a..b923db5aa3f 100644 --- a/src/lib/inference/nim.test.ts +++ b/src/lib/inference/nim.test.ts @@ -342,6 +342,19 @@ describe("nim", () => { down.restore(); } }); + + it("ignores an unsafe served id and keeps the catalog value (#3885)", () => { + const m = loadNimWithMockedRunner( + vi.fn(() => JSON.stringify({ data: [{ id: "bad id; rm -rf /" }] })), + ); + try { + expect(m.nimModule.adoptServedModelId("nvidia/nemotron-3-nano-30b-a3b", 8000)).toBe( + "nvidia/nemotron-3-nano-30b-a3b", + ); + } finally { + m.restore(); + } + }); }); describe("containerName", () => { diff --git a/src/lib/inference/nim.ts b/src/lib/inference/nim.ts index e98fc4024b0..892a3093fa9 100644 --- a/src/lib/inference/nim.ts +++ b/src/lib/inference/nim.ts @@ -22,6 +22,7 @@ const { sleepSeconds } = require("../core/wait"); const nimImages = require("../../../bin/lib/nim-images.json"); import { VLLM_PORT } from "../core/ports"; +import { isSafeModelId } from "../validation"; import { isDenylistedNvidiaGpuName, isPlausibleNvidiaGpuName, @@ -347,15 +348,18 @@ export function getServedModelId(port = VLLM_PORT): string | null { return out ? parseServedModelId(out) : null; } -// Route to the id NIM serves (from its image config), which can differ from the -// catalog name; the catalog id 404s on validation/routing. See #3885. +// Adopt the id NIM serves (from /v1/models) when it differs from the catalog +// name and is safe; the catalog id otherwise 404s on validation. See #3885. export function adoptServedModelId(catalogModel: string | null, port = VLLM_PORT): string | null { const served = getServedModelId(port); - if (served && served !== catalogModel) { - console.log(` NIM serves "${served}" (catalog "${catalogModel}"); using served id.`); - return served; + if (!served || served === catalogModel) return catalogModel; + // /v1/models is local-controlled — refuse an unsafe id; don't echo it (log-injection). + if (!isSafeModelId(served)) { + console.error(` NIM reported an invalid model id; keeping "${catalogModel}".`); + return catalogModel; } - return catalogModel; + console.log(` NIM serves "${served}" (catalog "${catalogModel}"); using served id.`); + return served; } export function detectGpu(): GpuDetection | null { From 52c960d25ee69396783f6664199218f687f58bbe Mon Sep 17 00:00:00 2001 From: Hung Le Date: Wed, 3 Jun 2026 05:54:04 +0530 Subject: [PATCH 5/8] fix(inference): log NIM digest-pull fallback, test thrown manifest inspect --- src/lib/inference/nim.test.ts | 19 +++++++++++++++++++ src/lib/inference/nim.ts | 3 +++ 2 files changed, 22 insertions(+) diff --git a/src/lib/inference/nim.test.ts b/src/lib/inference/nim.test.ts index b923db5aa3f..daa08d224ed 100644 --- a/src/lib/inference/nim.test.ts +++ b/src/lib/inference/nim.test.ts @@ -260,6 +260,25 @@ describe("nim", () => { restore(); } }); + + it("falls back to a plain tag pull when dockerManifestInspect throws (#3885)", () => { + const run = vi.fn(); + const runCapture = vi.fn(() => { + throw new Error("docker manifest unavailable"); + }); + const { nimModule, restore } = loadNimWithMockedRunner(runCapture, run); + try { + nimModule.pullNimImage("nvidia/nemotron-3-nano-30b-a3b"); + expect(findCall(run, "pull")).toEqual([ + "docker", + "pull", + "nvcr.io/nim/nvidia/nemotron-3-nano:latest", + ]); + expect(findCall(run, "tag")).toBeUndefined(); + } finally { + restore(); + } + }); }); describe("parseServedModelId", () => { diff --git a/src/lib/inference/nim.ts b/src/lib/inference/nim.ts index 892a3093fa9..eefb6f45347 100644 --- a/src/lib/inference/nim.ts +++ b/src/lib/inference/nim.ts @@ -720,6 +720,9 @@ function pullImageResolvingPlatform(image: string): void { ? selectPlatformManifestDigest(manifestJson, nodeArchToOci(process.arch)) : null; if (!digest) { + // No resolvable multi-arch index — plain tag pull. On Docker 29.x this can + // re-hit the NGC attestation failure (#3885); surface the path taken. + console.log(` No platform manifest resolved; pulling ${image} by tag.`); dockerPull(image); return; } From 1ece6b907dea44146067f2257827c5ba4b34cfc6 Mon Sep 17 00:00:00 2001 From: Hung Le Date: Wed, 3 Jun 2026 06:07:08 +0530 Subject: [PATCH 6/8] fix(inference): resolve pinning process.arch in NIM digest-pull test --- src/lib/inference/nim.test.ts | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/src/lib/inference/nim.test.ts b/src/lib/inference/nim.test.ts index daa08d224ed..fd58694c3cb 100644 --- a/src/lib/inference/nim.test.ts +++ b/src/lib/inference/nim.test.ts @@ -138,11 +138,6 @@ describe("nim", () => { }, ], }); - const DIGEST_BY_ARCH: Record = { - amd64: "sha256:amd64image", - arm64: "sha256:arm64image", - }; - describe("nodeArchToOci", () => { it("maps x64 to amd64 and passes other arches through", () => { expect(nim.nodeArchToOci("x64")).toBe("amd64"); @@ -205,6 +200,7 @@ describe("nim", () => { } it("resolves the host-arch manifest digest, pulls by digest, then tags back (#3885)", () => { + const origArch = Object.getOwnPropertyDescriptor(process, "arch"); const run = vi.fn(); const runCapture = vi.fn((cmd: string | string[]) => { if (Array.isArray(cmd) && cmd.includes("manifest") && cmd.includes("inspect")) { @@ -214,13 +210,11 @@ describe("nim", () => { }); const { nimModule, restore } = loadNimWithMockedRunner(runCapture, run); try { + Object.defineProperty(process, "arch", { value: "x64", configurable: true }); const image = nimModule.pullNimImage("nvidia/nemotron-3-nano-30b-a3b"); expect(image).toBe("nvcr.io/nim/nvidia/nemotron-3-nano:latest"); - const ociArch = nimModule.nodeArchToOci(process.arch); - const expectedDigest = DIGEST_BY_ARCH[ociArch]; - expect(expectedDigest).toBeDefined(); - const expectedRef = `nvcr.io/nim/nvidia/nemotron-3-nano@${expectedDigest}`; + const expectedRef = "nvcr.io/nim/nvidia/nemotron-3-nano@sha256:amd64image"; // Pull the per-arch digest, never the bare :latest index (the index pull // is what triggers the attestation-manifest fetch). @@ -240,6 +234,7 @@ describe("nim", () => { "nvcr.io/nim/nvidia/nemotron-3-nano:latest", ]); } finally { + if (origArch) Object.defineProperty(process, "arch", origArch); restore(); } }); From 1bd4c407ce1b9de614574740a63a37ab5ad3d5f8 Mon Sep 17 00:00:00 2001 From: Hung Le Date: Fri, 5 Jun 2026 03:06:01 +0530 Subject: [PATCH 7/8] fix(inference): use DEFAULT_NIM_HEALTH_TIMEOUT_SECONDS const (1200s) --- src/lib/inference/nim.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/lib/inference/nim.ts b/src/lib/inference/nim.ts index c830e559612..500a1f3216e 100644 --- a/src/lib/inference/nim.ts +++ b/src/lib/inference/nim.ts @@ -32,6 +32,7 @@ import { const UNIFIED_MEMORY_GPU_TAGS = ["GB10", "Thor", "Orin", "Xavier", "Jetson", "Tegra"]; const NIM_STATUS_PROBE_TIMEOUT_MS = 5000; +export const DEFAULT_NIM_HEALTH_TIMEOUT_SECONDS = 1200; export interface NimModel { name: string; @@ -870,8 +871,7 @@ export interface WaitForNimHealthOptions { export function waitForNimHealth( port = VLLM_PORT, - // First load (weight download + warmup) takes minutes, longer for big models. - timeout = 1200, + timeout = DEFAULT_NIM_HEALTH_TIMEOUT_SECONDS, opts: WaitForNimHealthOptions = {}, ): boolean { const start = Date.now(); @@ -902,8 +902,8 @@ export function waitForNimHealth( /* ignored */ } // Short-circuit if the container has already exited — typically NGC auth - // failure or OOM during model load — instead of polling the full timeout - // against a dead container. See #3333. + // failure or OOM during model load. Without this, the wizard polls the + // full timeout (default 1200s) against a dead container. See #3333. if (container) { const state = dockerContainerInspectFormat("{{.State.Status}}", container, { ignoreError: true, From f19fb97b15b5b250f0f434e1c63b043fe62f9b88 Mon Sep 17 00:00:00 2001 From: Hung Le Date: Fri, 5 Jun 2026 03:33:57 +0530 Subject: [PATCH 8/8] fix(inference): align NIM health-timeout test with 1200s default --- src/lib/inference/nim.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/inference/nim.test.ts b/src/lib/inference/nim.test.ts index 192844522e9..7d4fad5abd9 100644 --- a/src/lib/inference/nim.test.ts +++ b/src/lib/inference/nim.test.ts @@ -1604,9 +1604,9 @@ describe("nim", () => { const { nimModule, restore } = loadNimWithMockedRunner(runCapture); try { - expect(nimModule.DEFAULT_NIM_HEALTH_TIMEOUT_SECONDS).toBe(900); + expect(nimModule.DEFAULT_NIM_HEALTH_TIMEOUT_SECONDS).toBe(1200); expect(nimModule.waitForNimHealth(9000)).toBe(true); - expect(consoleLogs.some((line) => line.includes("timeout: 900s"))).toBe(true); + expect(consoleLogs.some((line) => line.includes("timeout: 1200s"))).toBe(true); } finally { console.log = origLog; restore();