diff --git a/ci/platform-matrix.json b/ci/platform-matrix.json
index ed73d215241..f903e2ffc8f 100644
--- a/ci/platform-matrix.json
+++ b/ci/platform-matrix.json
@@ -147,7 +147,7 @@
"name": "Local vLLM (managed install/start)",
"status": "caveated",
"endpoint_type": "Local OpenAI-compatible",
- "notes": "Appears by default on DGX Spark and DGX Station. Generic Linux NVIDIA GPU hosts require `NEMOCLAW_EXPERIMENTAL=1` or `NEMOCLAW_PROVIDER=install-vllm`. Host must have the NVIDIA Container Toolkit installed and a CDI spec present (`onboard` asserts CDI presence). NemoClaw pulls or starts the stable NGC vLLM container for each host profile. See `src/lib/inference/vllm.ts:55,177` for the pins. DGX Spark and DGX Station use `nvcr.io/nvidia/vllm:26.05.post1-py3`; generic Linux NVIDIA GPU hosts use `nvcr.io/nvidia/vllm:26.03.post1-py3`. Validated defaults are listed in `src/lib/inference/vllm-models.ts`: DGX Spark uses `nvidia/Qwen3.6-35B-A3B-NVFP4`, DGX Station uses `deepseek-ai/DeepSeek-V4-Flash`, and Linux NVIDIA GPU uses `nvidia/NVIDIA-Nemotron-3-Nano-4B-FP8`. Image pulls require NGC registry login (`docker login nvcr.io`); onboard prompts for the NGC API key when authentication is missing."
+ "notes": "Appears by default on DGX Spark and DGX Station. DGX Station remains deferred until its managed-vLLM onboarding path is validated end-to-end on physical hardware. Generic Linux NVIDIA GPU hosts require `NEMOCLAW_EXPERIMENTAL=1` or `NEMOCLAW_PROVIDER=install-vllm`. Host must have the NVIDIA Container Toolkit installed and a CDI spec present (`onboard` asserts CDI presence). NemoClaw pins each host profile to an immutable NGC platform digest. DGX Spark and DGX Station use the `linux/arm64` digest published under `nvcr.io/nvidia/vllm:26.05.post1-py3`; generic Linux NVIDIA GPU hosts use the matching `linux/arm64` or `linux/amd64` digest published under `nvcr.io/nvidia/vllm:26.03.post1-py3`. Validated defaults are listed in `src/lib/inference/vllm-models.ts`: DGX Spark uses `nvidia/Qwen3.6-35B-A3B-NVFP4`, DGX Station uses `deepseek-ai/DeepSeek-V4-Flash`, and Linux NVIDIA GPU uses `nvidia/NVIDIA-Nemotron-3-Nano-4B-FP8`. Image pulls require NGC registry login (`docker login nvcr.io`); onboard prompts for the NGC API key when authentication is missing."
}
],
diff --git a/docs/inference/choose-inference-provider.mdx b/docs/inference/choose-inference-provider.mdx
index 614f67c32ea..a7521fc8b2e 100644
--- a/docs/inference/choose-inference-provider.mdx
+++ b/docs/inference/choose-inference-provider.mdx
@@ -32,7 +32,7 @@ Use this status table to distinguish validated provider integrations from adapte
| Local Ollama | Tested with limitations | Local Ollama API | Available when Ollama is installed or running on the host. Validated default models: `qwen3.6:35b` (high VRAM), `nemotron-3-nano:30b` (medium VRAM), `qwen3.5:9b` (low VRAM fallback). |
| Local NVIDIA NIM | Experimental | Local OpenAI-compatible | Requires `NEMOCLAW_EXPERIMENTAL=1` and a NIM-capable NVIDIA GPU. Host must have the NVIDIA Container Toolkit installed and a CDI spec present (`onboard` asserts CDI presence with `assertCdiNvidiaGpuSpecPresent`, `src/lib/onboard/fatal-runtime-preflight.ts`). NIM images pull from `nvcr.io` and require NGC registry login. NemoClaw gates this path behind the experimental flag because it does not auto-select a NIM image for the host today. You must explicitly pick from the validated image list. On Linux arm64 DGX Spark and DGX Station hosts, onboarding warns that some NIM images may not publish a `linux/arm64` manifest; the warning is advisory, and the selected image pull can still fail when the registry has no matching platform manifest. Managed vLLM has host-specific default models and is not gated on the same boxes. Validated images referenced in `src/lib/inference/config.ts` and `nemoclaw/src/index.ts`: `nvidia/nemotron-3-super-120b-a12b` (default cloud model), `nvidia/nemotron-3-nano-30b-a3b`, `nvidia/llama-3.3-nemotron-super-49b-v1.5`. |
| Local vLLM (already running) | Tested with limitations | Local OpenAI-compatible | Appears in the onboarding menu when NemoClaw detects a server already on `localhost:8000`. No flag required. Model is whatever the existing server serves. |
-| Local vLLM (managed install/start) | Tested with limitations | Local OpenAI-compatible | Appears by default on DGX Spark and DGX Station. Generic Linux NVIDIA GPU hosts require `NEMOCLAW_EXPERIMENTAL=1` or `NEMOCLAW_PROVIDER=install-vllm`. Host must have the NVIDIA Container Toolkit installed and a CDI spec present (`onboard` asserts CDI presence). NemoClaw pulls or starts the stable NGC vLLM container for each host profile. See `src/lib/inference/vllm.ts:55,177` for the pins. DGX Spark and DGX Station use `nvcr.io/nvidia/vllm:26.05.post1-py3`; generic Linux NVIDIA GPU hosts use `nvcr.io/nvidia/vllm:26.03.post1-py3`. Validated defaults are listed in `src/lib/inference/vllm-models.ts`: DGX Spark uses `nvidia/Qwen3.6-35B-A3B-NVFP4`, DGX Station uses `deepseek-ai/DeepSeek-V4-Flash`, and Linux NVIDIA GPU uses `nvidia/NVIDIA-Nemotron-3-Nano-4B-FP8`. Image pulls require NGC registry login (`docker login nvcr.io`); onboard prompts for the NGC API key when authentication is missing. |
+| Local vLLM (managed install/start) | Tested with limitations | Local OpenAI-compatible | Appears by default on DGX Spark and DGX Station. DGX Station remains deferred until its managed-vLLM onboarding path is validated end-to-end on physical hardware. Generic Linux NVIDIA GPU hosts require `NEMOCLAW_EXPERIMENTAL=1` or `NEMOCLAW_PROVIDER=install-vllm`. Host must have the NVIDIA Container Toolkit installed and a CDI spec present (`onboard` asserts CDI presence). NemoClaw pins each host profile to an immutable NGC platform digest. DGX Spark and DGX Station use the `linux/arm64` digest published under `nvcr.io/nvidia/vllm:26.05.post1-py3`; generic Linux NVIDIA GPU hosts use the matching `linux/arm64` or `linux/amd64` digest published under `nvcr.io/nvidia/vllm:26.03.post1-py3`. Validated defaults are listed in `src/lib/inference/vllm-models.ts`: DGX Spark uses `nvidia/Qwen3.6-35B-A3B-NVFP4`, DGX Station uses `deepseek-ai/DeepSeek-V4-Flash`, and Linux NVIDIA GPU uses `nvidia/NVIDIA-Nemotron-3-Nano-4B-FP8`. Image pulls require NGC registry login (`docker login nvcr.io`); onboard prompts for the NGC API key when authentication is missing. |
{/* provider-status:end */}
## Hosted Providers
diff --git a/docs/inference/set-up-vllm.mdx b/docs/inference/set-up-vllm.mdx
index b16091329ae..03801cec44f 100644
--- a/docs/inference/set-up-vllm.mdx
+++ b/docs/inference/set-up-vllm.mdx
@@ -68,13 +68,53 @@ In an interactive run, the managed path lists the supported model registry for y
Press **Enter** to use the profile default or select another validated model with its registered `vllm serve` arguments.
NemoClaw pulls the vLLM image, downloads model weights into `~/.cache/huggingface`, starts the `nemoclaw-vllm` container on `localhost:8000`, streams Hugging Face download progress, and polls `/v1/models` until the model is ready.
-Managed DGX Spark and DGX Station profiles use `nvcr.io/nvidia/vllm:26.05.post1-py3`.
+Managed profiles use immutable NGC platform digests:
+
+- DGX Spark and DGX Station use the `linux/arm64` digest `sha256:9204569b17ee4c0eff75194b8e6e458479c8aee18953b5ab9cf359fcdac659e2` with a compressed layer size of `9.60 GB` under `nvcr.io/nvidia/vllm:26.05.post1-py3`.
+ DGX Station remains deferred until this path is validated end-to-end on physical hardware.
+- Generic Linux `arm64` hosts use `sha256:447995cbb57e6c7cf792cab95e9852e5f62b5fb6d2f39e030fa4eda9a54eadb4` with a compressed layer size of `9.28 GB` under `nvcr.io/nvidia/vllm:26.03.post1-py3`.
+- Generic Linux `amd64` hosts use `sha256:7be6c2f676c36059a494fe17254e69ae5c677535ba6191044e5fc8e42a91c773` with a compressed layer size of `8.93 GB` under `nvcr.io/nvidia/vllm:26.03.post1-py3`.
+
Image pulls from `nvcr.io` require NGC registry login.
The first run can take 10 to 30 minutes.
Later runs reuse the cached image and model weights.
The managed container uses Docker's `--restart unless-stopped` policy, so Docker restarts it unless an operator explicitly stops it.
+### Check Storage Before Downloads
+
+For an uncached image, NemoClaw checks Docker image storage before the watched image pull starts.
+It checks the Hugging Face model cache before that pull and checks it again afterward before `hf download` starts.
+The image estimate includes compressed content, unpacked layers, and pull-staging headroom.
+The model estimate includes the model's full published size plus temporary-write headroom because a local cache cannot prove which files a mutable remote revision will reuse.
+
+For Docker's classic image store, NemoClaw checks the absolute `DockerRootDir` reported by `docker info`.
+For Docker's containerd image store, it checks the top-level `root` from `/etc/containerd/config.toml`, or `/var/lib/containerd` when that configuration file is absent, and also checks `DockerRootDir` for pull staging.
+The check uses the location with the least available capacity.
+The model-cache check uses `~/.cache/huggingface`, or its nearest existing parent filesystem when the cache directory does not exist yet.
+
+NemoClaw recognizes an image cache hit only when Docker contains the exact platform digest.
+A cache hit skips the cold-pull capacity check, but the explicit watched `docker pull` still runs against that immutable digest.
+Before trusting the host model-cache capacity, NemoClaw also verifies that the selected Docker daemon maps bind mounts to the local host; a remote or unverifiable Docker host makes the model-cache check inconclusive.
+An explicit Unix socket is trusted only when it is Docker's default `/var/run/docker.sock`; any other socket can forward to a daemon with a different filesystem and is treated as unverifiable.
+Known container markers are rejected immediately.
+Before trusting client-side image-store capacity, NemoClaw requires the selected default Docker socket peer to have a visible PID and share the client's mount namespace; a nested PID namespace or unverifiable peer fails closed before the image pull.
+Before trusting model-cache capacity, NemoClaw also asks the pinned cached vLLM image to read a random mode-`0600` sentinel through a bounded, read-only Docker bind mount.
+The pull is idempotent for the cached digest and cannot select different layers from a moved release tag.
+The model-download helper and the long-lived vLLM container use `--pull=never`, so an implicit pull cannot bypass the storage gate.
+The second model-cache check detects capacity consumed by the image pull before the model download command can start.
+
+When capacity is insufficient or NemoClaw cannot verify it, the warning identifies the image or model, available capacity, approximate requirement, checked path and source, remediation, and diagnostic commands.
+Interactive setup continues only when you enter `y` or `yes` explicitly.
+Pressing **Enter**, entering `n` or `no`, or entering any other response stops before the guarded pull or model download starts.
+
+
+`--yes` and `NEMOCLAW_YES=1` do not accept managed-vLLM storage warnings.
+Non-interactive setup stops unless you set `NEMOCLAW_IGNORE_VLLM_DISK_SPACE=1` exactly.
+This override accepts both insufficient-capacity and inconclusive checks for Docker image storage and the model cache.
+Use it only after you independently verify that both storage locations can complete the downloads.
+
+
If image pull output stops making progress, a watchdog stops the stalled pull without imposing a fixed wall-clock limit on slow but active downloads.
If vLLM does not become ready, NemoClaw prints a short tail of the container logs before exiting.
diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx
index dd9d9b50cd1..1788df4dde3 100644
--- a/docs/reference/commands.mdx
+++ b/docs/reference/commands.mdx
@@ -3250,6 +3250,7 @@ Set them before running `$$nemoclaw onboard`.
| `NEMOCLAW_INSTALL_TAG` | release tag | For internal installer commands: the release tag to install. Defaults to the admin-promoted `lkg` tag when unset. Overridden by the `--install-tag` flag. |
| `NEMOCLAW_VLLM_MODEL` | registry slug or Hugging Face model id | Selects the model the managed-vLLM install path serves. Recognised slugs: `qwen3.6-27b`, `qwen3.6-35b-a3b-nvfp4`, `nemotron-3-nano-4b`, `deepseek-v4-flash`, `deepseek-r1-distill-70b`. Unset uses the per-platform profile default. Gated models (e.g. `deepseek-r1-distill-70b`) require `HF_TOKEN` or `HUGGING_FACE_HUB_TOKEN`. |
| `NEMOCLAW_VLLM_EXTRA_ARGS_JSON` | JSON array of non-blank strings | Appends advanced operator-owned tokens to the managed `vllm serve` command after NemoClaw's registry defaults. Example: `["--max-num-seqs","2"]`. Malformed JSON, non-string tokens, or blank tokens fail before Docker work starts. |
+| `NEMOCLAW_IGNORE_VLLM_DISK_SPACE` | exactly `1` | Allows managed vLLM to continue when Docker image-storage or model-cache capacity is insufficient or cannot be verified; other values do not bypass the checks. |
| `NEMOCLAW_MINIMAL_BOOTSTRAP` | `1` to enable | Skips default OpenClaw workspace-template seeding for new pristine workspaces. Existing files are not deleted; refer to [Runtime Controls](../manage-sandboxes/runtime-controls). |
@@ -3399,7 +3400,7 @@ Set them before running `$$nemoclaw onboard`.
| Variable | Format | Effect |
|----------|--------|--------|
-| `NEMOCLAW_YES` | `1` to enable | Auto-accepts confirmation prompts (`--yes` equivalent) including in helpers like the Ollama proxy auth setup. |
+| `NEMOCLAW_YES` | `1` to enable | Auto-accepts confirmation prompts (`--yes` equivalent) including in helpers like the Ollama proxy auth setup, but does not accept managed-vLLM storage warnings. |
| `NEMOCLAW_OLLAMA_NO_AUTOSTART` | `1` to enable | Skips the wizard's eager Ollama auto-start during inference-provider selection (equivalent to passing `--no-ollama-autostart`). When set and Ollama is not running on `localhost:11434`, the `$$nemoclaw onboard` Local Ollama path prints a warning and selects the default fallback model instead of spawning `ollama serve`. The flag covers only the provider-selection step; later setup steps (auth proxy, validation, model warm) still expect a reachable Ollama. On Linux hosts with a systemd Ollama unit, the loopback-override path may still restart the daemon before this gate runs. |
| `NEMOCLAW_NON_INTERACTIVE_SUDO_MODE` | `prompt` or empty/unset | When set to `prompt`, allows non-interactive onboarding to use prompt-capable `sudo` for host setup steps that require elevation, which can ask for a password. Empty/unset is the default and uses `sudo -n`, which fails instead of asking for a password. Any other value is rejected. |
| `NEMOCLAW_NO_EXPRESS` | `1` to enable | Installer-only. Skips the DGX Spark, DGX Station, and Windows WSL express install prompt and continues with the normal interactive onboarding flow. |
diff --git a/docs/reference/platform-support.mdx b/docs/reference/platform-support.mdx
index 89a2a5c2317..039e1c92b05 100644
--- a/docs/reference/platform-support.mdx
+++ b/docs/reference/platform-support.mdx
@@ -104,7 +104,7 @@ NemoClaw routes inference through the OpenShell gateway. Each row below is a pro
| Local Ollama | Tested with limitations | Local Ollama API | Available when Ollama is installed or running on the host. Validated default models: `qwen3.6:35b` (high VRAM), `nemotron-3-nano:30b` (medium VRAM), `qwen3.5:9b` (low VRAM fallback). |
| Local NVIDIA NIM | Experimental | Local OpenAI-compatible | Requires `NEMOCLAW_EXPERIMENTAL=1` and a NIM-capable NVIDIA GPU. Host must have the NVIDIA Container Toolkit installed and a CDI spec present (`onboard` asserts CDI presence with `assertCdiNvidiaGpuSpecPresent`, `src/lib/onboard/fatal-runtime-preflight.ts`). NIM images pull from `nvcr.io` and require NGC registry login. NemoClaw gates this path behind the experimental flag because it does not auto-select a NIM image for the host today. You must explicitly pick from the validated image list. On Linux arm64 DGX Spark and DGX Station hosts, onboarding warns that some NIM images may not publish a `linux/arm64` manifest; the warning is advisory, and the selected image pull can still fail when the registry has no matching platform manifest. Managed vLLM has host-specific default models and is not gated on the same boxes. Validated images referenced in `src/lib/inference/config.ts` and `nemoclaw/src/index.ts`: `nvidia/nemotron-3-super-120b-a12b` (default cloud model), `nvidia/nemotron-3-nano-30b-a3b`, `nvidia/llama-3.3-nemotron-super-49b-v1.5`. |
| Local vLLM (already running) | Tested with limitations | Local OpenAI-compatible | Appears in the onboarding menu when NemoClaw detects a server already on `localhost:8000`. No flag required. Model is whatever the existing server serves. |
-| Local vLLM (managed install/start) | Tested with limitations | Local OpenAI-compatible | Appears by default on DGX Spark and DGX Station. Generic Linux NVIDIA GPU hosts require `NEMOCLAW_EXPERIMENTAL=1` or `NEMOCLAW_PROVIDER=install-vllm`. Host must have the NVIDIA Container Toolkit installed and a CDI spec present (`onboard` asserts CDI presence). NemoClaw pulls or starts the stable NGC vLLM container for each host profile. See `src/lib/inference/vllm.ts:55,177` for the pins. DGX Spark and DGX Station use `nvcr.io/nvidia/vllm:26.05.post1-py3`; generic Linux NVIDIA GPU hosts use `nvcr.io/nvidia/vllm:26.03.post1-py3`. Validated defaults are listed in `src/lib/inference/vllm-models.ts`: DGX Spark uses `nvidia/Qwen3.6-35B-A3B-NVFP4`, DGX Station uses `deepseek-ai/DeepSeek-V4-Flash`, and Linux NVIDIA GPU uses `nvidia/NVIDIA-Nemotron-3-Nano-4B-FP8`. Image pulls require NGC registry login (`docker login nvcr.io`); onboard prompts for the NGC API key when authentication is missing. |
+| Local vLLM (managed install/start) | Tested with limitations | Local OpenAI-compatible | Appears by default on DGX Spark and DGX Station. DGX Station remains deferred until its managed-vLLM onboarding path is validated end-to-end on physical hardware. Generic Linux NVIDIA GPU hosts require `NEMOCLAW_EXPERIMENTAL=1` or `NEMOCLAW_PROVIDER=install-vllm`. Host must have the NVIDIA Container Toolkit installed and a CDI spec present (`onboard` asserts CDI presence). NemoClaw pins each host profile to an immutable NGC platform digest. DGX Spark and DGX Station use the `linux/arm64` digest published under `nvcr.io/nvidia/vllm:26.05.post1-py3`; generic Linux NVIDIA GPU hosts use the matching `linux/arm64` or `linux/amd64` digest published under `nvcr.io/nvidia/vllm:26.03.post1-py3`. Validated defaults are listed in `src/lib/inference/vllm-models.ts`: DGX Spark uses `nvidia/Qwen3.6-35B-A3B-NVFP4`, DGX Station uses `deepseek-ai/DeepSeek-V4-Flash`, and Linux NVIDIA GPU uses `nvidia/NVIDIA-Nemotron-3-Nano-4B-FP8`. Image pulls require NGC registry login (`docker login nvcr.io`); onboard prompts for the NGC API key when authentication is missing. |
{/* provider-status-full:end */}
## Messaging Integrations
diff --git a/src/lib/inference/vllm-docker-env.test.ts b/src/lib/inference/vllm-docker-env.test.ts
new file mode 100644
index 00000000000..af06687ff3d
--- /dev/null
+++ b/src/lib/inference/vllm-docker-env.test.ts
@@ -0,0 +1,41 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { buildVllmDockerEnv } from "./vllm-docker-env";
+
+afterEach(() => {
+ vi.unstubAllEnvs();
+});
+
+describe("managed vLLM Docker client environment", () => {
+ it("forwards one Docker context while retaining subprocess secret filtering (#6757)", () => {
+ vi.stubEnv("DOCKER_CONFIG", "/tmp/nemoclaw-docker-config");
+ vi.stubEnv("DOCKER_CONTEXT", "remote-builder");
+ vi.stubEnv("DOCKER_HOST", "ssh://fallback.example.test");
+ vi.stubEnv("DOCKER_TLS", "1");
+ vi.stubEnv("UNRELATED_SECRET", "do-not-forward");
+
+ const env = buildVllmDockerEnv({ HF_TOKEN: "hf_test" });
+
+ expect(env).toEqual(
+ expect.objectContaining({
+ DOCKER_CONFIG: "/tmp/nemoclaw-docker-config",
+ DOCKER_CONTEXT: "remote-builder",
+ DOCKER_HOST: "ssh://fallback.example.test",
+ DOCKER_TLS: "1",
+ HF_TOKEN: "hf_test",
+ }),
+ );
+ expect(env.UNRELATED_SECRET).toBeUndefined();
+ });
+
+ it("does not inherit Docker selectors omitted from an explicit source (#6757)", () => {
+ vi.stubEnv("DOCKER_HOST", "ssh://ambient.example.test");
+
+ const env = buildVllmDockerEnv({}, { DOCKER_CONTEXT: "requested-context" });
+
+ expect(env.DOCKER_CONTEXT).toBe("requested-context");
+ expect(env.DOCKER_HOST).toBeUndefined();
+ });
+});
diff --git a/src/lib/inference/vllm-docker-env.ts b/src/lib/inference/vllm-docker-env.ts
new file mode 100644
index 00000000000..986d2286e9d
--- /dev/null
+++ b/src/lib/inference/vllm-docker-env.ts
@@ -0,0 +1,34 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { buildSubprocessEnv } from "../subprocess-env";
+
+const DOCKER_CLIENT_ENV_NAMES = [
+ "DOCKER_API_VERSION",
+ "DOCKER_CERT_PATH",
+ "DOCKER_CONFIG",
+ "DOCKER_CONTEXT",
+ "DOCKER_HOST",
+ "DOCKER_TLS",
+ "DOCKER_TLS_VERIFY",
+] as const;
+
+/**
+ * Use one Docker client selection for every managed-vLLM subprocess while
+ * retaining the repository's child-process environment sanitization.
+ */
+export function buildVllmDockerEnv(
+ extra: Record = {},
+ source: NodeJS.ProcessEnv = process.env,
+): Record {
+ const dockerEnv: Record = {};
+ for (const name of DOCKER_CLIENT_ENV_NAMES) {
+ const value = source[name];
+ if (value !== undefined) dockerEnv[name] = value;
+ }
+ const env = buildSubprocessEnv({ ...dockerEnv, ...extra });
+ for (const name of DOCKER_CLIENT_ENV_NAMES) {
+ if (source[name] === undefined && extra[name] === undefined) delete env[name];
+ }
+ return env;
+}
diff --git a/src/lib/inference/vllm-models.test.ts b/src/lib/inference/vllm-models.test.ts
index bbe843206e2..1eb1aacdd14 100644
--- a/src/lib/inference/vllm-models.test.ts
+++ b/src/lib/inference/vllm-models.test.ts
@@ -16,6 +16,25 @@ import {
} from "./vllm-models";
describe("vllm model registry", () => {
+ it("records a finite positive Hugging Face download size for every model", () => {
+ for (const model of VLLM_MODELS) {
+ expect(Number.isFinite(model.downloadSizeBytes)).toBe(true);
+ expect(model.downloadSizeBytes).toBeGreaterThan(0);
+ }
+ });
+
+ it("pins the official Hugging Face repository totals", () => {
+ expect(
+ Object.fromEntries(VLLM_MODELS.map((model) => [model.envValue, model.downloadSizeBytes])),
+ ).toEqual({
+ "qwen3.6-27b": 30_900_000_000,
+ "deepseek-r1-distill-70b": 141_000_000_000,
+ "nemotron-3-nano-4b": 5_280_000_000,
+ "deepseek-v4-flash": 160_000_000_000,
+ "qwen3.6-35b-a3b-nvfp4": 23_500_000_000,
+ });
+ });
+
it("returns null when NEMOCLAW_VLLM_MODEL is unset so the caller can fall back to the profile default", () => {
expect(selectVllmModelFromEnv({} as NodeJS.ProcessEnv)).toBeNull();
});
diff --git a/src/lib/inference/vllm-models.ts b/src/lib/inference/vllm-models.ts
index 7359fc9bfd0..d38f13dac8b 100644
--- a/src/lib/inference/vllm-models.ts
+++ b/src/lib/inference/vllm-models.ts
@@ -35,6 +35,8 @@ export interface VllmModelDef {
label: string;
/** Stable identifier accepted via `NEMOCLAW_VLLM_MODEL`. */
envValue: string;
+ /** Approximate full Hugging Face repository download size in bytes. */
+ downloadSizeBytes: number;
/** `--max-model-len` flag value. */
maxModelLen: number;
/** Model-specific flags appended after the shared serving flags. */
@@ -63,6 +65,7 @@ export const VLLM_MODELS: readonly VllmModelDef[] = [
id: "Qwen/Qwen3.6-27B-FP8",
label: "Qwen3.6 27B FP8",
envValue: "qwen3.6-27b",
+ downloadSizeBytes: 30_900_000_000,
maxModelLen: 262144,
modelArgs: [
"--gpu-memory-utilization",
@@ -85,6 +88,7 @@ export const VLLM_MODELS: readonly VllmModelDef[] = [
id: "deepseek-ai/DeepSeek-R1-Distill-Llama-70B",
label: "DeepSeek-R1 Distill Llama 70B",
envValue: "deepseek-r1-distill-70b",
+ downloadSizeBytes: 141_000_000_000,
maxModelLen: 32768,
modelArgs: [
"--gpu-memory-utilization",
@@ -104,6 +108,7 @@ export const VLLM_MODELS: readonly VllmModelDef[] = [
id: "nvidia/NVIDIA-Nemotron-3-Nano-4B-FP8",
label: "NVIDIA Nemotron-3 Nano 4B FP8",
envValue: "nemotron-3-nano-4b",
+ downloadSizeBytes: 5_280_000_000,
// Matches the model card's `max_position_embeddings` and the vLLM
// example NVIDIA publishes for this checkpoint. The previous value
// (262000) was an undocumented round-down with no headroom rationale.
@@ -133,6 +138,7 @@ export const VLLM_MODELS: readonly VllmModelDef[] = [
id: "deepseek-ai/DeepSeek-V4-Flash",
label: "DeepSeek V4 Flash",
envValue: "deepseek-v4-flash",
+ downloadSizeBytes: 160_000_000_000,
maxModelLen: 1048576,
modelArgs: [
"--kv-cache-dtype",
@@ -173,6 +179,7 @@ export const VLLM_MODELS: readonly VllmModelDef[] = [
id: "nvidia/Qwen3.6-35B-A3B-NVFP4",
label: "Qwen3.6 35B-A3B NVFP4",
envValue: "qwen3.6-35b-a3b-nvfp4",
+ downloadSizeBytes: 23_500_000_000,
maxModelLen: 262144,
// Additive flags on top of the shared serving defaults. The shared flags
// already cover --tensor-parallel-size/--pipeline-parallel-size/
diff --git a/src/lib/inference/vllm-storage.test.ts b/src/lib/inference/vllm-storage.test.ts
new file mode 100644
index 00000000000..2ac7bc1c6e8
--- /dev/null
+++ b/src/lib/inference/vllm-storage.test.ts
@@ -0,0 +1,484 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import {
+ dockerSocketPeerSharesMountNamespace,
+ formatStorageBytes,
+ imageStorageRequirementBytes,
+ modelStorageRequirementBytes,
+ probeDockerBindIdentity,
+ probeDockerHostLocality,
+ probeDockerStorage,
+ probeModelCacheStorage,
+ resolveDockerStorageLocations,
+} from "./vllm-storage";
+
+const GIB = 1024n ** 3n;
+const tempDirs: string[] = [];
+
+function nativeDockerInfo(overrides: Record = {}): string {
+ return JSON.stringify({
+ ClientInfo: { Context: "default" },
+ DockerRootDir: "/var/lib/docker",
+ Driver: "overlay2",
+ DriverStatus: [],
+ Name: "dgx-station",
+ OperatingSystem: "Ubuntu 24.04",
+ OSType: "linux",
+ SecurityOptions: [],
+ ...overrides,
+ });
+}
+
+const nativeHost = {
+ clientContainerized: false,
+ dockerContext: undefined,
+ dockerHost: undefined,
+ osRelease: "6.8.0-generic",
+ platform: "linux" as NodeJS.Platform,
+ dockerSocketPeerSharesMountNamespace: () => true,
+};
+
+afterEach(() => {
+ vi.restoreAllMocks();
+ vi.unstubAllEnvs();
+ for (const dir of tempDirs.splice(0)) fs.rmSync(dir, { force: true, recursive: true });
+});
+
+describe("managed vLLM storage requirements", () => {
+ it("reserves compressed, unpacked, and staging space for an image pull (#6757)", () => {
+ expect(imageStorageRequirementBytes(1_000_000_000)).toBe(3_000_000_000n + 3n * GIB);
+ });
+
+ it("requires the full model size plus temporary-file headroom (#6757)", () => {
+ expect(modelStorageRequirementBytes(Number(10n * GIB))).toBe(12n * GIB);
+ });
+
+ it("formats available and required bytes as rounded GiB values", () => {
+ expect(formatStorageBytes(2n * GIB)).toBe("2 GiB");
+ expect(formatStorageBytes((23n * GIB) / 10n)).toBe("2.3 GiB");
+ });
+});
+
+describe("Docker image-storage detection", () => {
+ it("uses DockerRootDir for the classic image store (#6757)", () => {
+ expect(resolveDockerStorageLocations(nativeDockerInfo(), nativeHost)).toEqual({
+ ok: true,
+ locations: [{ path: "/var/lib/docker", source: "Docker root directory" }],
+ });
+ });
+
+ it("checks both the configured containerd root and Docker pull staging (#6757)", () => {
+ const statfs = vi.fn((target: string) => ({
+ bavail: target === "/var/lib/docker" ? 2n : 40n,
+ bsize: GIB,
+ }));
+ const result = probeDockerStorage({
+ ...nativeHost,
+ dockerInfo: () =>
+ nativeDockerInfo({
+ Driver: "overlayfs",
+ DriverStatus: [["driver-type", "io.containerd.snapshotter.v1"]],
+ }),
+ exists: (target) => target === "/etc/containerd/config.toml",
+ readFile: () => 'root = "/mnt/containerd"',
+ statfs,
+ });
+
+ expect(result).toEqual({
+ ok: true,
+ capacity: {
+ availableBytes: 2n * GIB,
+ path: "/var/lib/docker",
+ source: "Docker pull staging",
+ },
+ });
+ expect(statfs).toHaveBeenCalledWith("/mnt/containerd");
+ expect(statfs).toHaveBeenCalledWith("/var/lib/docker");
+ });
+
+ it("uses the documented containerd root when no config file exists (#6757)", () => {
+ const result = resolveDockerStorageLocations(
+ nativeDockerInfo({
+ Driver: "overlayfs",
+ DriverStatus: [["driver-type", "io.containerd.snapshotter.v1"]],
+ }),
+ { ...nativeHost, exists: () => false },
+ );
+
+ expect(result).toEqual({
+ ok: true,
+ locations: [
+ { path: "/var/lib/containerd", source: "containerd image store" },
+ { path: "/var/lib/docker", source: "Docker pull staging" },
+ ],
+ });
+ });
+
+ it("fails closed for overlayfs without the exact containerd marker (#6757)", () => {
+ const result = resolveDockerStorageLocations(
+ nativeDockerInfo({
+ Driver: "overlayfs",
+ DriverStatus: [["note", "io.containerd.snapshotter.v1"]],
+ }),
+ nativeHost,
+ );
+
+ expect(result).toEqual({
+ ok: false,
+ reason: "docker reported ambiguous image-storage driver overlayfs",
+ });
+ });
+
+ it("fails closed when containerd imports can override the configured root (#6757)", () => {
+ const result = resolveDockerStorageLocations(
+ nativeDockerInfo({
+ Driver: "overlayfs",
+ DriverStatus: [["driver-type", "io.containerd.snapshotter.v1"]],
+ }),
+ {
+ ...nativeHost,
+ exists: (target) => target === "/etc/containerd/config.toml",
+ readFile: () => 'root = "/mnt/containerd"\nimports = ["/etc/containerd/conf.d/*.toml"]',
+ },
+ );
+
+ expect(result).toEqual({
+ ok: false,
+ reason: "containerd config imports other files that can override its image-store root",
+ });
+ });
+
+ it("fails closed with an actionable reason for rootless containerd (#6757)", () => {
+ const result = resolveDockerStorageLocations(
+ nativeDockerInfo({
+ Driver: "overlayfs",
+ DriverStatus: [["driver-type", "io.containerd.snapshotter.v1"]],
+ SecurityOptions: ["name=rootless"],
+ }),
+ nativeHost,
+ );
+
+ expect(result).toEqual({
+ ok: false,
+ reason:
+ "rootless Docker detected; managed vLLM cannot verify the containerd image-store location",
+ });
+ });
+
+ it("returns an inconclusive result for remote Docker contexts (#6757)", () => {
+ expect(
+ resolveDockerStorageLocations(nativeDockerInfo(), {
+ ...nativeHost,
+ dockerHost: "ssh://builder.example.test",
+ }),
+ ).toEqual({
+ ok: false,
+ reason: "Docker uses a remote endpoint (ssh://builder.example.test)",
+ });
+ });
+
+ it("honors DOCKER_HOST precedence over DOCKER_CONTEXT (#6757)", () => {
+ expect(
+ resolveDockerStorageLocations(nativeDockerInfo(), {
+ ...nativeHost,
+ dockerContext: "default",
+ dockerHost: "ssh://builder.example.test",
+ }),
+ ).toEqual({
+ ok: false,
+ reason: "Docker uses a remote endpoint (ssh://builder.example.test)",
+ });
+ });
+
+ it("ignores a named DOCKER_CONTEXT when DOCKER_HOST selects a local socket (#6757)", () => {
+ expect(
+ resolveDockerStorageLocations(nativeDockerInfo(), {
+ ...nativeHost,
+ dockerContext: "remote-builder",
+ dockerHost: "unix:///var/run/docker.sock",
+ }),
+ ).toEqual({
+ ok: true,
+ locations: [{ path: "/var/lib/docker", source: "Docker root directory" }],
+ });
+ });
+
+ it("fails closed for a named Docker context whose filesystem is not local (#6757)", () => {
+ expect(
+ resolveDockerStorageLocations(
+ nativeDockerInfo({ ClientInfo: { Context: "remote-builder" } }),
+ nativeHost,
+ ),
+ ).toEqual({
+ ok: false,
+ reason:
+ "Docker uses a named context (remote-builder) whose host filesystem cannot be verified",
+ });
+ });
+
+ it.each([
+ {
+ boundary: "non-Linux host",
+ expected: "Docker runs behind a darwin host boundary",
+ info: nativeDockerInfo(),
+ overrides: { ...nativeHost, platform: "darwin" as NodeJS.Platform },
+ },
+ {
+ boundary: "WSL kernel",
+ expected: "Docker runs behind a WSL host boundary",
+ info: nativeDockerInfo(),
+ overrides: { ...nativeHost, osRelease: "5.15.153.1-microsoft-standard-WSL2" },
+ },
+ {
+ boundary: "Docker Desktop VM",
+ expected: "Docker runs inside a VM or compatibility layer",
+ info: nativeDockerInfo({ OperatingSystem: "Docker Desktop" }),
+ overrides: nativeHost,
+ },
+ {
+ boundary: "Colima VM",
+ expected: "Docker runs inside a VM or compatibility layer",
+ info: nativeDockerInfo({ OperatingSystem: "Colima" }),
+ overrides: nativeHost,
+ },
+ {
+ boundary: "Podman compatibility layer",
+ expected: "Docker runs inside a VM or compatibility layer",
+ info: nativeDockerInfo({ OperatingSystem: "Podman Engine" }),
+ overrides: nativeHost,
+ },
+ ])("fails closed across a $boundary (#6757)", ({ expected, info, overrides }) => {
+ expect(resolveDockerStorageLocations(info, overrides)).toEqual({
+ ok: false,
+ reason: expected,
+ });
+ });
+
+ it("checks Docker host locality without requiring image-store capacity (#6757)", () => {
+ expect(
+ probeDockerHostLocality({
+ ...nativeHost,
+ dockerInfo: () => nativeDockerInfo({ Driver: "unrecognized" }),
+ }),
+ ).toEqual({ ok: true });
+
+ expect(
+ probeDockerHostLocality({
+ ...nativeHost,
+ dockerHost: "ssh://builder.example.test",
+ dockerInfo: () => nativeDockerInfo({ Driver: "unrecognized" }),
+ }),
+ ).toEqual({
+ ok: false,
+ reason: "Docker uses a remote endpoint (ssh://builder.example.test)",
+ });
+ });
+
+ it("fails closed for a non-default Unix socket that can forward a remote daemon (#6757)", () => {
+ vi.stubEnv("DOCKER_HOST", "unix:///tmp/forwarded-remote.sock");
+ vi.stubEnv("DOCKER_CONTEXT", "default");
+
+ expect(
+ probeDockerHostLocality({
+ clientContainerized: false,
+ dockerInfo: () => nativeDockerInfo(),
+ osRelease: nativeHost.osRelease,
+ platform: nativeHost.platform,
+ dockerSocketPeerSharesMountNamespace: () => true,
+ }),
+ ).toEqual({
+ ok: false,
+ reason:
+ "Docker uses a non-default socket (unix:///tmp/forwarded-remote.sock) whose daemon host filesystem cannot be verified",
+ });
+ });
+
+ it("fails closed when the default Docker socket is mounted into a client container (#6757)", () => {
+ vi.stubEnv("DOCKER_HOST", "unix:///var/run/docker.sock");
+ vi.stubEnv("DOCKER_CONTEXT", "default");
+ vi.spyOn(fs, "existsSync").mockImplementation((target) => target === "/.dockerenv");
+
+ expect(
+ probeDockerHostLocality({
+ dockerInfo: () => nativeDockerInfo(),
+ osRelease: nativeHost.osRelease,
+ platform: nativeHost.platform,
+ }),
+ ).toEqual({
+ ok: false,
+ reason:
+ "Docker client runs inside a container, so daemon bind-mount storage cannot be verified",
+ });
+ });
+
+ it("classifies the Docker socket peer mount namespace without trusting procfs PID 1", () => {
+ const capture = vi.fn(() => "shared\n");
+
+ expect(dockerSocketPeerSharesMountNamespace("/var/run/docker.sock", capture, "linux")).toBe(
+ true,
+ );
+ expect(capture).toHaveBeenCalledWith(
+ expect.arrayContaining(["python3", "-I", "-c", "/var/run/docker.sock"]),
+ { ignoreError: true, timeout: 5_000 },
+ );
+ capture.mockReturnValueOnce("different\n");
+ expect(dockerSocketPeerSharesMountNamespace("/var/run/docker.sock", capture, "linux")).toBe(
+ false,
+ );
+ capture.mockReturnValueOnce("unknown\n");
+ expect(
+ dockerSocketPeerSharesMountNamespace("/var/run/docker.sock", capture, "linux"),
+ ).toBeNull();
+ });
+
+ it("fails closed when the Docker socket peer has a different mount namespace (#6757)", () => {
+ expect(
+ probeDockerHostLocality({
+ ...nativeHost,
+ dockerHost: "unix:///var/run/docker.sock",
+ dockerInfo: () => nativeDockerInfo(),
+ dockerSocketPeerSharesMountNamespace: () => false,
+ }),
+ ).toEqual({
+ ok: false,
+ reason:
+ "Docker client and socket peer use different mount namespaces, so daemon filesystem identity cannot be verified",
+ });
+ });
+
+ it("fails closed before statfs when a nested PID namespace hides the socket peer (#6757)", () => {
+ const statfs = vi.fn(() => ({ bavail: 1_000n, bsize: GIB }));
+
+ expect(
+ probeDockerStorage({
+ ...nativeHost,
+ dockerHost: "unix:///var/run/docker.sock",
+ dockerInfo: () => nativeDockerInfo(),
+ dockerSocketPeerSharesMountNamespace: () => null,
+ statfs,
+ }),
+ ).toEqual({
+ ok: false,
+ reason: "Docker socket peer PID or mount namespace could not be verified",
+ });
+ expect(statfs).not.toHaveBeenCalled();
+ });
+
+ it("reads the Docker selection when each storage probe starts (#6757)", () => {
+ vi.stubEnv("DOCKER_CONTEXT", "remote-builder");
+ vi.stubEnv("DOCKER_HOST", "");
+
+ expect(
+ probeDockerStorage({
+ clientContainerized: false,
+ dockerInfo: () => nativeDockerInfo(),
+ osRelease: nativeHost.osRelease,
+ platform: nativeHost.platform,
+ dockerSocketPeerSharesMountNamespace: () => true,
+ }),
+ ).toEqual({
+ ok: false,
+ reason:
+ "Docker uses a named context (remote-builder) whose host filesystem cannot be verified",
+ });
+ });
+
+ it("does not fall back to DockerRootDir when containerd config is unreadable (#6757)", () => {
+ const result = resolveDockerStorageLocations(
+ nativeDockerInfo({
+ Driver: "overlayfs",
+ DriverStatus: [["driver-type", "io.containerd.snapshotter.v1"]],
+ }),
+ {
+ ...nativeHost,
+ exists: (target) => target === "/etc/containerd/config.toml",
+ readFile: () => {
+ throw new Error("permission denied");
+ },
+ },
+ );
+
+ expect(result).toEqual({
+ ok: false,
+ reason: "could not read /etc/containerd/config.toml: permission denied",
+ });
+ });
+});
+
+describe("Hugging Face model-cache storage", () => {
+ it("proves Docker bind identity with a bounded read-only sentinel round trip (#6757)", () => {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-vllm-bind-identity-"));
+ tempDirs.push(root);
+ const cacheDir = path.join(root, ".cache", "huggingface");
+ const dockerReadBind = vi.fn((_image: string, sourcePath: string) => {
+ const fd = fs.openSync(sourcePath, "r");
+ try {
+ expect(fs.fstatSync(fd).mode & 0o777).toBe(0o600);
+ const token = fs.readFileSync(fd, "utf8");
+ expect(sourcePath).not.toContain(token.slice("nemoclaw-storage-token:".length));
+ return token;
+ } finally {
+ fs.closeSync(fd);
+ }
+ });
+
+ expect(
+ probeDockerBindIdentity(cacheDir, "example.test/vllm@sha256:pinned", {
+ ...nativeHost,
+ dockerInfo: () => nativeDockerInfo(),
+ dockerReadBind,
+ }),
+ ).toEqual({ ok: true });
+ expect(dockerReadBind).toHaveBeenCalledWith(
+ "example.test/vllm@sha256:pinned",
+ expect.stringMatching(/\.nemoclaw-storage-probe-/),
+ );
+ expect(fs.readdirSync(root)).toEqual([]);
+ });
+
+ it("fails closed when namespace-local PID 1 hides a private mount namespace (#6757)", () => {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-vllm-bind-identity-"));
+ tempDirs.push(root);
+
+ expect(
+ probeDockerBindIdentity(
+ path.join(root, ".cache", "huggingface"),
+ "example.test/vllm@sha256:pinned",
+ {
+ ...nativeHost,
+ clientContainerized: false,
+ dockerHost: "unix:///var/run/docker.sock",
+ dockerInfo: () => nativeDockerInfo(),
+ dockerReadBind: () => "",
+ },
+ ),
+ ).toEqual({
+ ok: false,
+ reason:
+ "Docker daemon could not read the client storage sentinel; bind-mount filesystem identity cannot be verified",
+ });
+ expect(fs.readdirSync(root)).toEqual([]);
+ });
+
+ it("checks the nearest existing filesystem before creating the cache directory (#6757)", () => {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-vllm-storage-"));
+ tempDirs.push(root);
+ const cacheDir = path.join(root, ".cache", "huggingface");
+ const statfs = vi.fn(() => ({ bavail: 7n, bsize: GIB }));
+
+ expect(probeModelCacheStorage(cacheDir, { statfs })).toEqual({
+ ok: true,
+ capacity: {
+ availableBytes: 7n * GIB,
+ path: root,
+ source: "model cache filesystem",
+ },
+ });
+ expect(statfs).toHaveBeenCalledWith(root);
+ });
+});
diff --git a/src/lib/inference/vllm-storage.ts b/src/lib/inference/vllm-storage.ts
new file mode 100644
index 00000000000..afcabc3aedf
--- /dev/null
+++ b/src/lib/inference/vllm-storage.ts
@@ -0,0 +1,497 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { randomUUID } from "node:crypto";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { parse as parseToml } from "smol-toml";
+import { dockerCapture } from "../adapters/docker";
+import { runCapture } from "../runner";
+import { buildVllmDockerEnv } from "./vllm-docker-env";
+
+export const VLLM_STORAGE_OVERRIDE_ENV = "NEMOCLAW_IGNORE_VLLM_DISK_SPACE";
+
+const GIB_BYTES = 1024n ** 3n;
+const IMAGE_PULL_TEMP_HEADROOM_BYTES = 3n * GIB_BYTES;
+const MODEL_MINIMUM_HEADROOM_BYTES = 2n * GIB_BYTES;
+const DEFAULT_CONTAINERD_ROOT = "/var/lib/containerd";
+const DEFAULT_CONTAINERD_CONFIG = "/etc/containerd/config.toml";
+const DEFAULT_DOCKER_SOCKET = "/var/run/docker.sock";
+const DOCKER_SOCKET_PEER_PROBE_TIMEOUT_MS = 5_000;
+// SO_PEERCRED translates the daemon PID into the caller's PID namespace. A
+// hidden peer therefore exposes the namespace-local PID 1 topology directly,
+// while equal mountinfo snapshots prove that client-side statfs sees the same
+// mount table as the process serving the selected Docker socket.
+const DOCKER_SOCKET_PEER_PROBE = String.raw`
+import socket
+import struct
+import sys
+
+def read_mountinfo(target):
+ with open(target, "rb") as stream:
+ return stream.read()
+
+sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+sock.settimeout(2)
+try:
+ sock.connect(sys.argv[1])
+ size = struct.calcsize("3i")
+ peer_pid, _, _ = struct.unpack(
+ "3i", sock.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, size)
+ )
+ if peer_pid <= 0:
+ print("unknown")
+ else:
+ own_before = read_mountinfo("/proc/self/mountinfo")
+ peer = read_mountinfo(f"/proc/{peer_pid}/mountinfo")
+ own_after = read_mountinfo("/proc/self/mountinfo")
+ if own_before != own_after:
+ print("unknown")
+ else:
+ print("shared" if own_before == peer else "different")
+except (OSError, ValueError):
+ print("unknown")
+finally:
+ sock.close()
+`;
+
+interface StorageProbeDeps {
+ clientContainerized: boolean;
+ dockerContext: string | undefined;
+ dockerHost: string | undefined;
+ dockerInfo: () => string;
+ dockerReadBind: (image: string, sourcePath: string) => string;
+ dockerSocketPeerSharesMountNamespace: () => boolean | null;
+ exists: (target: string) => boolean;
+ osRelease: string;
+ platform: NodeJS.Platform;
+ readFile: (target: string) => string;
+ statfs: (target: string) => { bavail: bigint; bsize: bigint };
+}
+
+function clientLooksContainerized(): boolean {
+ if (String(process.env.container ?? "").trim()) return true;
+ if (
+ fs.existsSync("/.dockerenv") ||
+ fs.existsSync("/run/.containerenv") ||
+ fs.existsSync("/run/systemd/container")
+ ) {
+ return true;
+ }
+ try {
+ return /(?:^|[/:.-])(?:docker|kubepods|containerd|libpod|lxc)(?:$|[/:.-])/im.test(
+ fs.readFileSync("/proc/self/cgroup", "utf8"),
+ );
+ } catch {
+ return false;
+ }
+}
+
+export function dockerSocketPeerSharesMountNamespace(
+ socketPath: string,
+ capture: (
+ command: readonly string[],
+ options: { ignoreError: true; timeout: number },
+ ) => string = runCapture,
+ platform: NodeJS.Platform = process.platform,
+): boolean | null {
+ if (platform !== "linux") return null;
+ const result = capture(["python3", "-I", "-c", DOCKER_SOCKET_PEER_PROBE, socketPath], {
+ ignoreError: true,
+ timeout: DOCKER_SOCKET_PEER_PROBE_TIMEOUT_MS,
+ }).trim();
+ if (result === "shared") return true;
+ if (result === "different") return false;
+ return null;
+}
+
+function defaultStorageProbeDeps(): StorageProbeDeps {
+ const dockerEnv = buildVllmDockerEnv();
+ return {
+ clientContainerized: clientLooksContainerized(),
+ dockerContext: dockerEnv.DOCKER_CONTEXT,
+ dockerHost: dockerEnv.DOCKER_HOST,
+ dockerInfo: () =>
+ dockerCapture(["info", "--format", "{{json .}}"], {
+ env: dockerEnv,
+ ignoreError: true,
+ timeout: 10_000,
+ }),
+ dockerReadBind: (image, sourcePath) =>
+ dockerCapture(
+ [
+ "run",
+ "--rm",
+ "--network=none",
+ "--pull=never",
+ "--entrypoint",
+ "/bin/cat",
+ "--mount",
+ `type=bind,src=${sourcePath},dst=/nemoclaw-storage-sentinel,readonly`,
+ image,
+ "/nemoclaw-storage-sentinel",
+ ],
+ {
+ env: dockerEnv,
+ ignoreError: true,
+ timeout: 30_000,
+ },
+ ),
+ dockerSocketPeerSharesMountNamespace: () => {
+ const endpoint = dockerEnv.DOCKER_HOST?.trim() ?? "";
+ const socketPath = endpoint.startsWith("unix://")
+ ? endpoint.slice("unix://".length)
+ : path.isAbsolute(endpoint)
+ ? endpoint
+ : endpoint
+ ? ""
+ : DEFAULT_DOCKER_SOCKET;
+ return socketPath ? dockerSocketPeerSharesMountNamespace(socketPath) : null;
+ },
+ exists: fs.existsSync,
+ osRelease: os.release(),
+ platform: process.platform,
+ readFile: (target) => fs.readFileSync(target, "utf8"),
+ statfs: (target) => fs.statfsSync(target, { bigint: true }),
+ };
+}
+
+export interface StorageCapacity {
+ availableBytes: bigint;
+ path: string;
+ source: string;
+}
+
+export type StorageProbeResult =
+ | { ok: true; capacity: StorageCapacity }
+ | { ok: false; reason: string; path?: string; source?: string };
+
+export type DockerHostLocalityResult = { ok: true } | { ok: false; reason: string };
+
+interface DockerInfoShape {
+ ClientInfo?: { Context?: unknown };
+ DockerRootDir?: unknown;
+ Driver?: unknown;
+ DriverStatus?: unknown;
+ Name?: unknown;
+ OperatingSystem?: unknown;
+ OSType?: unknown;
+ SecurityOptions?: unknown;
+}
+
+interface DockerStorageLocation {
+ path: string;
+ source: string;
+}
+
+function positiveBytes(value: number, label: string): bigint {
+ if (!Number.isFinite(value) || value <= 0) {
+ throw new Error(`${label} must be a positive finite byte count`);
+ }
+ return BigInt(Math.ceil(value));
+}
+
+/**
+ * Containerd retains compressed content alongside an unpacked snapshot, and
+ * pull staging can briefly coexist with both. Three times the advertised
+ * compressed size plus a fixed staging allowance is intentionally
+ * conservative for the pinned multi-gigabyte NGC images.
+ */
+export function imageStorageRequirementBytes(downloadSizeBytes: number): bigint {
+ return (
+ positiveBytes(downloadSizeBytes, "vLLM image download size") * 3n +
+ IMAGE_PULL_TEMP_HEADROOM_BYTES
+ );
+}
+
+/**
+ * Hugging Face downloads need the published repository size plus temporary
+ * write headroom. A local cache cannot prove which blobs a mutable remote
+ * revision will reuse, so the estimate deliberately does not subtract it.
+ */
+export function modelStorageRequirementBytes(downloadSizeBytes: number): bigint {
+ const downloadBytes = positiveBytes(downloadSizeBytes, "vLLM model download size");
+ const tenPercent = (downloadBytes + 9n) / 10n;
+ const headroom =
+ tenPercent > MODEL_MINIMUM_HEADROOM_BYTES ? tenPercent : MODEL_MINIMUM_HEADROOM_BYTES;
+ return downloadBytes + headroom;
+}
+
+export function formatStorageBytes(bytes: bigint): string {
+ const roundedTenths = (bytes * 10n + GIB_BYTES / 2n) / GIB_BYTES;
+ const whole = roundedTenths / 10n;
+ const fraction = roundedTenths % 10n;
+ return fraction === 0n ? `${String(whole)} GiB` : `${String(whole)}.${String(fraction)} GiB`;
+}
+
+function parseDockerInfo(raw: string): DockerInfoShape | null {
+ try {
+ const parsed = JSON.parse(raw) as unknown;
+ return parsed && typeof parsed === "object" ? (parsed as DockerInfoShape) : null;
+ } catch {
+ return null;
+ }
+}
+
+function isContainerdImageStore(info: DockerInfoShape): boolean {
+ return (
+ Array.isArray(info.DriverStatus) &&
+ info.DriverStatus.some(
+ (entry) =>
+ Array.isArray(entry) &&
+ entry[0] === "driver-type" &&
+ entry[1] === "io.containerd.snapshotter.v1",
+ )
+ );
+}
+
+function absoluteString(value: unknown): string | null {
+ return typeof value === "string" && path.isAbsolute(value) ? value : null;
+}
+
+type ContainerdRootResult = { ok: true; root: string } | { ok: false; reason: string };
+
+function containerdRootFromConfig(deps: StorageProbeDeps): ContainerdRootResult {
+ if (!deps.exists(DEFAULT_CONTAINERD_CONFIG)) {
+ return { ok: true, root: DEFAULT_CONTAINERD_ROOT };
+ }
+ try {
+ const parsed = parseToml(deps.readFile(DEFAULT_CONTAINERD_CONFIG)) as Record;
+ if (parsed.imports !== undefined) {
+ if (!Array.isArray(parsed.imports)) {
+ return { ok: false, reason: "containerd config declares malformed imports" };
+ }
+ if (parsed.imports.length > 0) {
+ return {
+ ok: false,
+ reason: "containerd config imports other files that can override its image-store root",
+ };
+ }
+ }
+ const root = absoluteString(parsed.root);
+ if (root) return { ok: true, root };
+ if (parsed.root !== undefined) {
+ return {
+ ok: false,
+ reason: "containerd config does not declare an absolute image-store root",
+ };
+ }
+ return { ok: true, root: DEFAULT_CONTAINERD_ROOT };
+ } catch (err) {
+ return {
+ ok: false,
+ reason: `could not read ${DEFAULT_CONTAINERD_CONFIG}: ${(err as Error).message}`,
+ };
+ }
+}
+
+function nativeDockerHostProblem(info: DockerInfoShape, deps: StorageProbeDeps): string | null {
+ if (deps.platform !== "linux") return `Docker runs behind a ${deps.platform} host boundary`;
+ if (/microsoft|wsl/i.test(deps.osRelease)) return "Docker runs behind a WSL host boundary";
+ if (deps.clientContainerized) {
+ return "Docker client runs inside a container, so daemon bind-mount storage cannot be verified";
+ }
+ if (info.OSType !== "linux") return "Docker is not using a Linux engine";
+
+ const product = `${String(info.Name ?? "")} ${String(info.OperatingSystem ?? "")}`;
+ if (/docker desktop|colima|podman/i.test(product)) {
+ return "Docker runs inside a VM or compatibility layer";
+ }
+
+ const dockerHost = deps.dockerHost?.trim() ?? "";
+ // DOCKER_HOST takes precedence over DOCKER_CONTEXT in the Docker CLI.
+ const explicitContext = dockerHost ? "" : deps.dockerContext?.trim();
+ const reportedContext =
+ typeof info.ClientInfo?.Context === "string" ? info.ClientInfo.Context.trim() : "";
+ const context = explicitContext || reportedContext;
+ if (!context) return "docker info did not report the effective Docker context";
+ if (context !== "default") {
+ return `Docker uses a named context (${context}) whose host filesystem cannot be verified`;
+ }
+
+ const endpoint = dockerHost;
+ if (endpoint) {
+ const localSocket = endpoint.startsWith("unix://") || path.isAbsolute(endpoint);
+ if (!localSocket) return `Docker uses a remote endpoint (${endpoint})`;
+ if (endpoint !== DEFAULT_DOCKER_SOCKET && endpoint !== `unix://${DEFAULT_DOCKER_SOCKET}`) {
+ return `Docker uses a non-default socket (${endpoint}) whose daemon host filesystem cannot be verified`;
+ }
+ }
+
+ const sharesPeerMountNamespace = deps.dockerSocketPeerSharesMountNamespace();
+ if (sharesPeerMountNamespace === false) {
+ return "Docker client and socket peer use different mount namespaces, so daemon filesystem identity cannot be verified";
+ }
+ if (sharesPeerMountNamespace === null) {
+ return "Docker socket peer PID or mount namespace could not be verified";
+ }
+ return null;
+}
+
+export function probeDockerHostLocality(
+ overrides: Partial = {},
+): DockerHostLocalityResult {
+ const deps = { ...defaultStorageProbeDeps(), ...overrides };
+ const info = parseDockerInfo(deps.dockerInfo());
+ if (!info) return { ok: false, reason: "docker info did not return valid JSON" };
+ const hostProblem = nativeDockerHostProblem(info, deps);
+ return hostProblem ? { ok: false, reason: hostProblem } : { ok: true };
+}
+
+export function resolveDockerStorageLocations(
+ rawInfo: string,
+ overrides: Partial = {},
+): { ok: true; locations: DockerStorageLocation[] } | { ok: false; reason: string } {
+ const deps = { ...defaultStorageProbeDeps(), ...overrides };
+ const info = parseDockerInfo(rawInfo);
+ if (!info) return { ok: false, reason: "docker info did not return valid JSON" };
+ const hostProblem = nativeDockerHostProblem(info, deps);
+ if (hostProblem) return { ok: false, reason: hostProblem };
+
+ const dockerRoot = absoluteString(info.DockerRootDir);
+ if (!dockerRoot) {
+ return { ok: false, reason: "docker info did not report an absolute DockerRootDir" };
+ }
+ if (!isContainerdImageStore(info)) {
+ const driver = typeof info.Driver === "string" ? info.Driver : "";
+ const classicDrivers = new Set([
+ "aufs",
+ "btrfs",
+ "devicemapper",
+ "fuse-overlayfs",
+ "overlay2",
+ "vfs",
+ "zfs",
+ ]);
+ if (!classicDrivers.has(driver)) {
+ return {
+ ok: false,
+ reason: driver
+ ? `docker reported ambiguous image-storage driver ${driver}`
+ : "docker info did not report a recognized image-storage driver",
+ };
+ }
+ return {
+ ok: true,
+ locations: [{ path: dockerRoot, source: "Docker root directory" }],
+ };
+ }
+
+ if (
+ Array.isArray(info.SecurityOptions) &&
+ info.SecurityOptions.some((entry) => String(entry).includes("rootless"))
+ ) {
+ return {
+ ok: false,
+ reason:
+ "rootless Docker detected; managed vLLM cannot verify the containerd image-store location",
+ };
+ }
+ const containerd = containerdRootFromConfig(deps);
+ if (!containerd.ok) return containerd;
+ return {
+ ok: true,
+ locations: [
+ { path: containerd.root, source: "containerd image store" },
+ { path: dockerRoot, source: "Docker pull staging" },
+ ],
+ };
+}
+
+function capacityForLocation(
+ location: DockerStorageLocation,
+ statfs: StorageProbeDeps["statfs"],
+): StorageProbeResult {
+ try {
+ const stats = statfs(location.path);
+ const availableBytes = stats.bavail * stats.bsize;
+ if (availableBytes < 0n) throw new Error("filesystem reported negative available space");
+ return { ok: true, capacity: { ...location, availableBytes } };
+ } catch (err) {
+ return {
+ ok: false,
+ reason: `could not inspect ${location.path}: ${(err as Error).message}`,
+ path: location.path,
+ source: location.source,
+ };
+ }
+}
+
+export function probeDockerStorage(overrides: Partial = {}): StorageProbeResult {
+ const deps = { ...defaultStorageProbeDeps(), ...overrides };
+ const rawInfo = deps.dockerInfo();
+ const resolved = resolveDockerStorageLocations(rawInfo, deps);
+ if (!resolved.ok) return resolved;
+
+ let limiting: StorageCapacity | null = null;
+ for (const location of resolved.locations) {
+ const result = capacityForLocation(location, deps.statfs);
+ if (!result.ok) return result;
+ if (!limiting || result.capacity.availableBytes < limiting.availableBytes) {
+ limiting = result.capacity;
+ }
+ }
+ return limiting
+ ? { ok: true, capacity: limiting }
+ : { ok: false, reason: "docker info did not report a usable image-storage path" };
+}
+
+function nearestExistingPath(target: string, exists: (candidate: string) => boolean): string {
+ let candidate = path.resolve(target);
+ while (!exists(candidate)) {
+ const parent = path.dirname(candidate);
+ if (parent === candidate) return candidate;
+ candidate = parent;
+ }
+ return candidate;
+}
+
+export function probeDockerBindIdentity(
+ cacheDir: string,
+ image: string,
+ overrides: Partial = {},
+): DockerHostLocalityResult {
+ const deps = { ...defaultStorageProbeDeps(), ...overrides };
+ const info = parseDockerInfo(deps.dockerInfo());
+ if (!info) return { ok: false, reason: "docker info did not return valid JSON" };
+ const hostProblem = nativeDockerHostProblem(info, deps);
+ if (hostProblem) return { ok: false, reason: hostProblem };
+
+ const target = nearestExistingPath(cacheDir, deps.exists);
+ const token = `nemoclaw-storage-token:${randomUUID()}`;
+ const sentinelPath = path.join(target, `.nemoclaw-storage-probe-${randomUUID()}`);
+ try {
+ fs.writeFileSync(sentinelPath, token, { encoding: "utf8", flag: "wx", mode: 0o600 });
+ if (deps.dockerReadBind(image, sentinelPath) !== token) {
+ return {
+ ok: false,
+ reason:
+ "Docker daemon could not read the client storage sentinel; bind-mount filesystem identity cannot be verified",
+ };
+ }
+ return { ok: true };
+ } catch (err) {
+ return {
+ ok: false,
+ reason: `could not verify Docker bind-mount filesystem identity at ${target}: ${(err as Error).message}`,
+ };
+ } finally {
+ fs.rmSync(sentinelPath, { force: true });
+ }
+}
+
+export function probeModelCacheStorage(
+ cacheDir: string,
+ overrides: Partial = {},
+): StorageProbeResult {
+ const deps = { ...defaultStorageProbeDeps(), ...overrides };
+ const target = nearestExistingPath(cacheDir, deps.exists);
+ return capacityForLocation(
+ {
+ path: target,
+ source: target === path.resolve(cacheDir) ? "model cache" : "model cache filesystem",
+ },
+ deps.statfs,
+ );
+}
diff --git a/src/lib/inference/vllm.test.ts b/src/lib/inference/vllm.test.ts
index c68018e3385..d018753b1fc 100644
--- a/src/lib/inference/vllm.test.ts
+++ b/src/lib/inference/vllm.test.ts
@@ -9,11 +9,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
dockerCapture: vi.fn(),
dockerForceRm: vi.fn(),
+ dockerImageInspectFormat: vi.fn(),
dockerPullWithProgressWatchdog: vi.fn(),
dockerRunDetached: vi.fn(),
dockerSpawn: vi.fn(),
dockerStop: vi.fn(),
getGpuIndicesByName: vi.fn<(_pattern: RegExp) => number[]>(() => []),
+ probeDockerBindIdentity: vi.fn(),
+ probeDockerHostLocality: vi.fn(),
+ probeDockerStorage: vi.fn(),
+ probeModelCacheStorage: vi.fn(),
runCapture: vi.fn(),
}));
@@ -24,6 +29,7 @@ vi.mock("../runner", () => ({
vi.mock("../adapters/docker", () => ({
dockerCapture: mocks.dockerCapture,
dockerForceRm: mocks.dockerForceRm,
+ dockerImageInspectFormat: mocks.dockerImageInspectFormat,
dockerPullWithProgressWatchdog: mocks.dockerPullWithProgressWatchdog,
dockerRunDetached: mocks.dockerRunDetached,
dockerSpawn: mocks.dockerSpawn,
@@ -34,6 +40,17 @@ vi.mock("./nim", () => ({
getGpuIndicesByName: mocks.getGpuIndicesByName,
}));
+vi.mock("./vllm-storage", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ probeDockerBindIdentity: mocks.probeDockerBindIdentity,
+ probeDockerHostLocality: mocks.probeDockerHostLocality,
+ probeDockerStorage: mocks.probeDockerStorage,
+ probeModelCacheStorage: mocks.probeModelCacheStorage,
+ };
+});
+
import {
buildVllmRunArgs,
detectVllmProfile,
@@ -42,6 +59,20 @@ import {
resolveVllmServedModelId,
} from "./vllm";
+beforeEach(() => {
+ mocks.dockerImageInspectFormat.mockReturnValue("");
+ mocks.probeDockerBindIdentity.mockReturnValue({ ok: true });
+ mocks.probeDockerHostLocality.mockReturnValue({ ok: true });
+ mocks.probeDockerStorage.mockReturnValue({
+ ok: true,
+ capacity: { availableBytes: 1_000_000_000_000n, path: "/docker", source: "Docker" },
+ });
+ mocks.probeModelCacheStorage.mockReturnValue({
+ ok: true,
+ capacity: { availableBytes: 1_000_000_000_000n, path: "/models", source: "model cache" },
+ });
+});
+
function mockDockerSpawnSuccess(): EventEmitter & {
stdout: EventEmitter;
stderr: EventEmitter;
@@ -76,6 +107,21 @@ function mockSuccessfulVllmInstall(containerName: string): void {
mocks.dockerCapture.mockReturnValue(`${containerName}\n`);
}
+function mockInconclusiveDockerStorage(): void {
+ mocks.probeDockerStorage.mockReturnValue({
+ ok: false,
+ reason: "Docker uses a remote endpoint (ssh://builder.example.test)",
+ });
+}
+
+function mockInconclusiveModelCacheStorage(): void {
+ mocks.dockerImageInspectFormat.mockReturnValue("sha256:cached-image");
+ mocks.probeModelCacheStorage.mockReturnValue({
+ ok: false,
+ reason: "could not inspect the model cache: permission denied",
+ });
+}
+
describe("vLLM served route identity", () => {
it("uses one safe served-model override and rejects ambiguous aliases (#6315)", () => {
expect(resolveVllmServedModelId("catalog/model", [])).toBe("catalog/model");
@@ -102,7 +148,10 @@ describe("vLLM profile detection", () => {
expect(profile).not.toBeNull();
expect(profile!.name).toBe("DGX Station");
- expect(profile!.image).toBe("nvcr.io/nvidia/vllm:26.05.post1-py3");
+ expect(profile!.image).toBe(
+ "nvcr.io/nvidia/vllm@sha256:9204569b17ee4c0eff75194b8e6e458479c8aee18953b5ab9cf359fcdac659e2",
+ );
+ expect(profile!.imageDownloadSizeBytes).toBe(9_603_085_145);
expect(profile!.defaultModel.id).toBe("deepseek-ai/DeepSeek-V4-Flash");
expect(profile!.defaultModel.envValue).toBe("deepseek-v4-flash");
});
@@ -112,19 +161,49 @@ describe("vLLM profile detection", () => {
expect(profile).not.toBeNull();
expect(profile!.name).toBe("DGX Spark");
- expect(profile!.image).toBe("nvcr.io/nvidia/vllm:26.05.post1-py3");
+ expect(profile!.image).toBe(
+ "nvcr.io/nvidia/vllm@sha256:9204569b17ee4c0eff75194b8e6e458479c8aee18953b5ab9cf359fcdac659e2",
+ );
expect(profile!.defaultModel.id).toBe("nvidia/Qwen3.6-35B-A3B-NVFP4");
expect(profile!.defaultModel.envValue).toBe("qwen3.6-35b-a3b-nvfp4");
});
- it("keeps generic Linux on the smaller Nemotron Nano default", () => {
- const profile = detectVllmProfile({ platform: "linux", type: "nvidia" });
+ it.each([
+ {
+ arch: "arm64",
+ image:
+ "nvcr.io/nvidia/vllm@sha256:447995cbb57e6c7cf792cab95e9852e5f62b5fb6d2f39e030fa4eda9a54eadb4",
+ imageDownloadSizeBytes: 9_278_081_698,
+ },
+ {
+ arch: "x64",
+ image:
+ "nvcr.io/nvidia/vllm@sha256:7be6c2f676c36059a494fe17254e69ae5c677535ba6191044e5fc8e42a91c773",
+ imageDownloadSizeBytes: 8_928_665_752,
+ },
+ ] as const)("keeps generic Linux on the smaller Nemotron Nano default for $arch", async ({
+ arch,
+ image,
+ imageDownloadSizeBytes,
+ }) => {
+ const originalArch = Object.getOwnPropertyDescriptor(process, "arch")!;
+ try {
+ Object.defineProperty(process, "arch", { configurable: true, value: arch });
+ vi.resetModules();
+ const { detectVllmProfile: detectVllmProfileForArch } = await import("./vllm");
- expect(profile).not.toBeNull();
- expect(profile!.name).toBe("Linux + NVIDIA GPU");
- expect(profile!.image).toBe("nvcr.io/nvidia/vllm:26.03.post1-py3");
- expect(profile!.defaultModel.id).toBe("nvidia/NVIDIA-Nemotron-3-Nano-4B-FP8");
- expect(profile!.defaultModel.envValue).toBe("nemotron-3-nano-4b");
+ const profile = detectVllmProfileForArch({ platform: "linux", type: "nvidia" });
+
+ expect(profile).not.toBeNull();
+ expect(profile!.name).toBe("Linux + NVIDIA GPU");
+ expect(profile!.image).toBe(image);
+ expect(profile!.imageDownloadSizeBytes).toBe(imageDownloadSizeBytes);
+ expect(profile!.defaultModel.id).toBe("nvidia/NVIDIA-Nemotron-3-Nano-4B-FP8");
+ expect(profile!.defaultModel.envValue).toBe("nemotron-3-nano-4b");
+ } finally {
+ Object.defineProperty(process, "arch", originalArch);
+ vi.resetModules();
+ }
});
it("generic-Linux default model pins the tool-call flags (#6314)", () => {
@@ -168,10 +247,14 @@ describe("vLLM image pull", () => {
await expect(pullImage(profile!)).resolves.toEqual({ ok: true });
- expect(mocks.dockerPullWithProgressWatchdog).toHaveBeenCalledWith(profile!.image, {
- maxTimeoutMs: profile!.pullTimeoutSec * 1000,
- logLine: expect.any(Function),
- });
+ expect(mocks.dockerPullWithProgressWatchdog).toHaveBeenCalledWith(
+ profile!.image,
+ expect.objectContaining({
+ env: expect.any(Object),
+ maxTimeoutMs: profile!.pullTimeoutSec * 1000,
+ logLine: expect.any(Function),
+ }),
+ );
const options = mocks.dockerPullWithProgressWatchdog.mock.calls[0][1];
options.logLine("abc123def: Downloading 1MB/10MB");
expect(stdoutWrite).toHaveBeenCalledWith(" ==> abc123def: Downloading 1MB/10MB\n");
@@ -207,7 +290,7 @@ describe("vLLM run command", () => {
const profile = detectVllmProfile({ platform: "spark", type: "nvidia" });
expect(profile).not.toBeNull();
const args = buildVllmRunArgs(profile!, profile!.defaultModel, profile!.dockerRunFlags);
- expect(args.slice(0, 2)).toEqual(["--restart", "unless-stopped"]);
+ expect(args.slice(0, 3)).toEqual(["--pull=never", "--restart", "unless-stopped"]);
expect(args).toContain("--name");
expect(args[args.indexOf("--name") + 1]).toBe(profile!.containerName);
expect(args).toContain("8000:8000");
@@ -288,6 +371,7 @@ describe("installVllm model resolution", () => {
stdoutWrite = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
delete process.env.NEMOCLAW_VLLM_MODEL;
delete process.env.NEMOCLAW_VLLM_EXTRA_ARGS_JSON;
+ delete process.env.NEMOCLAW_IGNORE_VLLM_DISK_SPACE;
delete process.env.HF_TOKEN;
delete process.env.HUGGING_FACE_HUB_TOKEN;
// Fail dockerPrereqsOk so the function returns before any docker work,
@@ -413,10 +497,478 @@ describe("installVllm model resolution", () => {
expect(mocks.dockerSpawn).not.toHaveBeenCalled();
});
+ it.each([
+ "n",
+ "",
+ "later",
+ ])("stops an uncached image pull when the storage warning receives '%s' (#6757)", async (storageReply) => {
+ const profile = detectVllmProfile({ platform: "station", type: "nvidia" })!;
+ process.env.NEMOCLAW_VLLM_MODEL = profile.defaultModel.envValue;
+ mockSuccessfulVllmInstall(profile.containerName);
+ mocks.probeDockerStorage.mockReturnValue({
+ ok: true,
+ capacity: { availableBytes: 1n, path: "/docker-low", source: "Docker pull staging" },
+ });
+ const replies = ["y", storageReply];
+
+ const result = await installVllm(profile, {
+ hasImage: false,
+ nonInteractive: false,
+ promptFn: vi.fn(async () => replies.shift() ?? ""),
+ });
+
+ expect(result).toEqual({ ok: false });
+ expect(mocks.dockerPullWithProgressWatchdog).not.toHaveBeenCalled();
+ expect(mocks.dockerSpawn).not.toHaveBeenCalled();
+ const errors = errSpy.mock.calls.map((call: unknown[]) => String(call[0])).join("\n");
+ expect(errors).toContain("Insufficient Docker storage for the managed vLLM image");
+ expect(errors).toContain(profile.image);
+ expect(errors).toContain("Available:");
+ expect(errors).toContain("Required:");
+ expect(errors).toContain("docker system df");
+ });
+
+ it("continues an uncached image pull only after an explicit storage yes (#6757)", async () => {
+ const profile = detectVllmProfile({ platform: "spark", type: "nvidia" })!;
+ process.env.NEMOCLAW_VLLM_MODEL = profile.defaultModel.envValue;
+ mockSuccessfulVllmInstall(profile.containerName);
+ mocks.probeDockerStorage.mockReturnValue({
+ ok: true,
+ capacity: { availableBytes: 1n, path: "/docker-low", source: "Docker root directory" },
+ });
+ const replies = ["y", "y"];
+
+ const result = await installVllm(profile, {
+ hasImage: false,
+ nonInteractive: false,
+ promptFn: vi.fn(async () => replies.shift() ?? ""),
+ });
+
+ expect(result).toEqual({ ok: true });
+ expect(mocks.dockerPullWithProgressWatchdog).toHaveBeenCalledTimes(1);
+ expect(mocks.dockerSpawn).toHaveBeenCalledTimes(1);
+ });
+
+ it("fails safely before downloads in non-interactive low-storage setup (#6757)", async () => {
+ const profile = detectVllmProfile({ platform: "station", type: "nvidia" })!;
+ process.env.NEMOCLAW_VLLM_MODEL = profile.defaultModel.envValue;
+ process.env.NEMOCLAW_YES = "1";
+ mockSuccessfulVllmInstall(profile.containerName);
+ mocks.probeDockerStorage.mockReturnValue({
+ ok: true,
+ capacity: { availableBytes: 1n, path: "/docker-low", source: "containerd image store" },
+ });
+
+ const result = await installVllm(profile, {
+ hasImage: false,
+ nonInteractive: true,
+ promptFn: vi.fn(),
+ });
+
+ expect(result).toEqual({ ok: false });
+ expect(mocks.dockerPullWithProgressWatchdog).not.toHaveBeenCalled();
+ expect(mocks.dockerSpawn).not.toHaveBeenCalled();
+ expect(errSpy).toHaveBeenCalledWith(
+ expect.stringContaining("Non-interactive setup stops before the guarded download"),
+ );
+ });
+
+ it.each([
+ {
+ name: "inconclusive Docker storage in non-interactive mode",
+ hasImage: false,
+ nonInteractive: true,
+ replies: [],
+ expectedPromptCalls: 0,
+ expectedWarning: "Unable to verify Docker storage for the managed vLLM image",
+ setup: mockInconclusiveDockerStorage,
+ },
+ {
+ name: "inconclusive Docker storage after an interactive decline",
+ hasImage: false,
+ nonInteractive: false,
+ replies: ["y", "n"],
+ expectedPromptCalls: 2,
+ expectedWarning: "Unable to verify Docker storage for the managed vLLM image",
+ setup: mockInconclusiveDockerStorage,
+ },
+ {
+ name: "inconclusive model-cache storage in non-interactive mode",
+ hasImage: true,
+ nonInteractive: true,
+ replies: [],
+ expectedPromptCalls: 0,
+ expectedWarning: "Unable to verify model-cache storage for managed vLLM",
+ setup: mockInconclusiveModelCacheStorage,
+ },
+ {
+ name: "inconclusive model-cache storage after an interactive decline",
+ hasImage: true,
+ nonInteractive: false,
+ replies: ["y", "n"],
+ expectedPromptCalls: 2,
+ expectedWarning: "Unable to verify model-cache storage for managed vLLM",
+ setup: mockInconclusiveModelCacheStorage,
+ },
+ ] as const)("$name stops before guarded downloads (#6757)", async (testCase) => {
+ const profile = detectVllmProfile({ platform: "station", type: "nvidia" })!;
+ process.env.NEMOCLAW_VLLM_MODEL = profile.defaultModel.envValue;
+ mockSuccessfulVllmInstall(profile.containerName);
+ testCase.setup();
+ const replies = [...testCase.replies];
+ const promptFn = vi.fn(async () => replies.shift() ?? "");
+
+ const result = await installVllm(profile, {
+ hasImage: testCase.hasImage,
+ nonInteractive: testCase.nonInteractive,
+ promptFn,
+ });
+
+ expect(result).toEqual({ ok: false });
+ expect(promptFn).toHaveBeenCalledTimes(testCase.expectedPromptCalls);
+ expect(mocks.dockerPullWithProgressWatchdog).not.toHaveBeenCalled();
+ expect(mocks.dockerSpawn).not.toHaveBeenCalled();
+ const errors = errSpy.mock.calls.map((call: unknown[]) => String(call[0])).join("\n");
+ expect(errors).toContain(testCase.expectedWarning);
+ expect(errors).toContain("Available: unknown (");
+ });
+
+ it("does not pull an uncached image when a nested PID namespace hides the Docker peer (#6757)", async () => {
+ const profile = detectVllmProfile({ platform: "station", type: "nvidia" })!;
+ process.env.NEMOCLAW_VLLM_MODEL = profile.defaultModel.envValue;
+ mockSuccessfulVllmInstall(profile.containerName);
+ mocks.probeDockerStorage.mockReturnValue({
+ ok: false,
+ reason: "Docker socket peer PID or mount namespace could not be verified",
+ });
+
+ const result = await installVllm(profile, {
+ hasImage: false,
+ nonInteractive: true,
+ promptFn: vi.fn(),
+ });
+
+ expect(result).toEqual({ ok: false });
+ expect(mocks.probeDockerStorage).toHaveBeenCalledTimes(1);
+ expect(mocks.dockerPullWithProgressWatchdog).not.toHaveBeenCalled();
+ expect(mocks.probeDockerBindIdentity).not.toHaveBeenCalled();
+ expect(mocks.dockerSpawn).not.toHaveBeenCalled();
+ expect(mocks.dockerRunDetached).not.toHaveBeenCalled();
+ expect(errSpy).toHaveBeenCalledWith(
+ expect.stringContaining("Docker socket peer PID or mount namespace could not be verified"),
+ );
+ });
+
+ it("honors only the dedicated disk-space override in non-interactive setup (#6757)", async () => {
+ const profile = detectVllmProfile({ platform: "station", type: "nvidia" })!;
+ process.env.NEMOCLAW_VLLM_MODEL = profile.defaultModel.envValue;
+ process.env.NEMOCLAW_IGNORE_VLLM_DISK_SPACE = "1";
+ mockSuccessfulVllmInstall(profile.containerName);
+ mocks.probeDockerStorage.mockReturnValue({
+ ok: true,
+ capacity: { availableBytes: 1n, path: "/docker-low", source: "containerd image store" },
+ });
+ mocks.probeModelCacheStorage.mockReturnValue({
+ ok: true,
+ capacity: { availableBytes: 1n, path: "/models-low", source: "model cache" },
+ });
+
+ const result = await installVllm(profile, {
+ hasImage: false,
+ nonInteractive: true,
+ promptFn: vi.fn(),
+ });
+
+ expect(result).toEqual({ ok: true });
+ expect(mocks.dockerPullWithProgressWatchdog).toHaveBeenCalledTimes(1);
+ expect(mocks.dockerSpawn).toHaveBeenCalledTimes(1);
+ expect(errSpy).toHaveBeenCalledWith(
+ expect.stringContaining("NEMOCLAW_IGNORE_VLLM_DISK_SPACE=1"),
+ );
+ });
+
+ it("stops before either download when model-cache capacity is declined (#6757)", async () => {
+ const profile = detectVllmProfile({ platform: "spark", type: "nvidia" })!;
+ process.env.NEMOCLAW_VLLM_MODEL = profile.defaultModel.envValue;
+ mockSuccessfulVllmInstall(profile.containerName);
+ mocks.probeModelCacheStorage.mockReturnValue({
+ ok: true,
+ capacity: { availableBytes: 1n, path: "/models-low", source: "model cache" },
+ });
+ const replies = ["y", "n"];
+
+ const result = await installVllm(profile, {
+ hasImage: false,
+ nonInteractive: false,
+ promptFn: vi.fn(async () => replies.shift() ?? ""),
+ });
+
+ expect(result).toEqual({ ok: false });
+ expect(mocks.dockerPullWithProgressWatchdog).not.toHaveBeenCalled();
+ expect(mocks.dockerSpawn).not.toHaveBeenCalled();
+ const errors = errSpy.mock.calls.map((call: unknown[]) => String(call[0])).join("\n");
+ expect(errors).toContain("Insufficient model-cache storage for managed vLLM");
+ expect(errors).toContain(profile.defaultModel.id);
+ });
+
+ it("reuses an authoritatively cached image without a cold-pull capacity check (#6757)", async () => {
+ const profile = detectVllmProfile({ platform: "spark", type: "nvidia" })!;
+ mockSuccessfulVllmInstall(profile.containerName);
+ mocks.dockerImageInspectFormat.mockReturnValue("sha256:cached-image");
+ mocks.probeDockerStorage.mockImplementation(() => {
+ throw new Error("cached images must not probe cold-pull capacity");
+ });
+
+ const result = await installVllm(profile, {
+ hasImage: false,
+ nonInteractive: true,
+ promptFn: vi.fn(),
+ });
+
+ expect(result).toEqual({ ok: true });
+ expect(mocks.dockerImageInspectFormat).toHaveBeenCalledWith(
+ "{{.Id}}",
+ profile.image,
+ expect.objectContaining({ env: expect.any(Object), ignoreError: true, timeout: 10_000 }),
+ );
+ expect(mocks.probeDockerStorage).not.toHaveBeenCalled();
+ expect(mocks.dockerPullWithProgressWatchdog).toHaveBeenCalledTimes(1);
+ expect(mocks.dockerSpawn).toHaveBeenCalledTimes(1);
+ const [downloadArgs] = mocks.dockerSpawn.mock.calls[0] as [string[]];
+ expect(downloadArgs).toContain("--pull=never");
+ });
+
+ it("guards a stale cached-image hint before any implicit pull can start (#6757)", async () => {
+ const profile = detectVllmProfile({ platform: "spark", type: "nvidia" })!;
+ process.env.NEMOCLAW_VLLM_MODEL = profile.defaultModel.envValue;
+ mockSuccessfulVllmInstall(profile.containerName);
+ mocks.dockerImageInspectFormat.mockReturnValue("");
+ mocks.probeDockerStorage.mockReturnValue({
+ ok: true,
+ capacity: { availableBytes: 1n, path: "/docker-low", source: "Docker root directory" },
+ });
+ const replies = ["y", "n"];
+
+ const result = await installVllm(profile, {
+ hasImage: true,
+ nonInteractive: false,
+ promptFn: vi.fn(async () => replies.shift() ?? ""),
+ });
+
+ expect(result).toEqual({ ok: false });
+ expect(mocks.probeDockerStorage).toHaveBeenCalledTimes(1);
+ expect(mocks.dockerPullWithProgressWatchdog).not.toHaveBeenCalled();
+ expect(mocks.dockerSpawn).not.toHaveBeenCalled();
+ });
+
+ it("rechecks model capacity after the image pull before hf download (#6757)", async () => {
+ const profile = detectVllmProfile({ platform: "spark", type: "nvidia" })!;
+ process.env.NEMOCLAW_VLLM_MODEL = profile.defaultModel.envValue;
+ mockSuccessfulVllmInstall(profile.containerName);
+ mocks.probeModelCacheStorage
+ .mockReturnValueOnce({
+ ok: true,
+ capacity: {
+ availableBytes: 1_000_000_000_000n,
+ path: "/shared",
+ source: "model cache",
+ },
+ })
+ .mockReturnValueOnce({
+ ok: true,
+ capacity: { availableBytes: 1n, path: "/shared", source: "model cache" },
+ });
+
+ const result = await installVllm(profile, {
+ hasImage: false,
+ nonInteractive: true,
+ promptFn: vi.fn(),
+ });
+
+ expect(result).toEqual({ ok: false });
+ expect(mocks.dockerPullWithProgressWatchdog).toHaveBeenCalledTimes(1);
+ expect(mocks.probeDockerHostLocality).toHaveBeenCalledTimes(1);
+ expect(mocks.probeDockerBindIdentity).toHaveBeenCalledTimes(1);
+ expect(mocks.probeModelCacheStorage).toHaveBeenCalledTimes(2);
+ expect(mocks.dockerSpawn).not.toHaveBeenCalled();
+ });
+
+ it("rechecks model capacity for a cached image before hf download (#6757)", async () => {
+ const profile = detectVllmProfile({ platform: "spark", type: "nvidia" })!;
+ process.env.NEMOCLAW_VLLM_MODEL = profile.defaultModel.envValue;
+ mockSuccessfulVllmInstall(profile.containerName);
+ mocks.dockerImageInspectFormat.mockReturnValue("sha256:cached-image");
+ mocks.probeModelCacheStorage
+ .mockReturnValueOnce({
+ ok: true,
+ capacity: {
+ availableBytes: 1_000_000_000_000n,
+ path: "/shared",
+ source: "model cache",
+ },
+ })
+ .mockReturnValueOnce({
+ ok: true,
+ capacity: { availableBytes: 1n, path: "/shared", source: "model cache" },
+ });
+
+ const result = await installVllm(profile, {
+ hasImage: true,
+ nonInteractive: true,
+ promptFn: vi.fn(),
+ });
+
+ expect(result).toEqual({ ok: false });
+ expect(mocks.probeDockerStorage).not.toHaveBeenCalled();
+ expect(mocks.dockerPullWithProgressWatchdog).toHaveBeenCalledTimes(1);
+ expect(mocks.probeDockerHostLocality).not.toHaveBeenCalled();
+ expect(mocks.probeDockerBindIdentity).toHaveBeenCalledTimes(2);
+ expect(mocks.probeModelCacheStorage).toHaveBeenCalledTimes(2);
+ expect(mocks.dockerSpawn).not.toHaveBeenCalled();
+ });
+
+ it.each([
+ {
+ reason: "Docker uses a remote endpoint (ssh://builder.example.test)",
+ selectorDescription: "remote DOCKER_HOST",
+ selectorName: "DOCKER_HOST" as const,
+ selectorValue: "ssh://builder.example.test",
+ },
+ {
+ reason:
+ "Docker uses a non-default socket (unix:///tmp/forwarded-remote.sock) whose daemon host filesystem cannot be verified",
+ selectorDescription: "forwarded Unix DOCKER_HOST",
+ selectorName: "DOCKER_HOST" as const,
+ selectorValue: "unix:///tmp/forwarded-remote.sock",
+ },
+ {
+ reason:
+ "Docker uses a named context (remote-builder) whose host filesystem cannot be verified",
+ selectorDescription: "named DOCKER_CONTEXT",
+ selectorName: "DOCKER_CONTEXT" as const,
+ selectorValue: "remote-builder",
+ },
+ {
+ reason:
+ "Docker client runs inside a container, so daemon bind-mount storage cannot be verified",
+ selectorDescription: "default socket mounted into a client container",
+ selectorName: "DOCKER_HOST" as const,
+ selectorValue: "unix:///var/run/docker.sock",
+ },
+ {
+ reason:
+ "Docker daemon could not read the client storage sentinel; bind-mount filesystem identity cannot be verified",
+ selectorDescription: "namespace-local PID 1 bind mismatch",
+ selectorName: "DOCKER_HOST" as const,
+ selectorValue: "unix:///var/run/docker.sock",
+ },
+ ])("blocks a cached image for an unverifiable $selectorDescription (#6757)", async ({
+ reason,
+ selectorName,
+ selectorValue,
+ }) => {
+ delete process.env.DOCKER_HOST;
+ delete process.env.DOCKER_CONTEXT;
+ process.env[selectorName] = selectorValue;
+ const profile = detectVllmProfile({ platform: "spark", type: "nvidia" })!;
+ mockSuccessfulVllmInstall(profile.containerName);
+ mocks.dockerImageInspectFormat.mockReturnValue("sha256:cached-image");
+ mocks.probeDockerBindIdentity.mockReturnValue({
+ ok: false,
+ reason,
+ });
+
+ const result = await installVllm(profile, {
+ hasImage: true,
+ nonInteractive: true,
+ promptFn: vi.fn(),
+ });
+
+ expect(result).toEqual({ ok: false });
+ expect(mocks.dockerImageInspectFormat).toHaveBeenCalledWith(
+ "{{.Id}}",
+ profile.image,
+ expect.objectContaining({
+ env: expect.objectContaining({ [selectorName]: selectorValue }),
+ }),
+ );
+ expect(mocks.probeDockerBindIdentity).toHaveBeenCalledWith(
+ path.join(os.homedir(), ".cache", "huggingface"),
+ profile.image,
+ );
+ expect(mocks.probeModelCacheStorage).not.toHaveBeenCalled();
+ expect(mocks.dockerPullWithProgressWatchdog).not.toHaveBeenCalled();
+ expect(mocks.dockerSpawn).not.toHaveBeenCalled();
+ expect(mocks.dockerRunDetached).not.toHaveBeenCalled();
+ expect(errSpy).toHaveBeenCalledWith(expect.stringContaining(reason));
+ });
+
+ it("allows the dedicated override for an unverifiable cached-image host (#6757)", async () => {
+ process.env.DOCKER_HOST = "ssh://builder.example.test";
+ delete process.env.DOCKER_CONTEXT;
+ process.env.NEMOCLAW_IGNORE_VLLM_DISK_SPACE = "1";
+ const profile = detectVllmProfile({ platform: "spark", type: "nvidia" })!;
+ mockSuccessfulVllmInstall(profile.containerName);
+ mocks.dockerImageInspectFormat.mockReturnValue("sha256:cached-image");
+ mocks.probeDockerBindIdentity.mockReturnValue({
+ ok: false,
+ reason: "Docker uses a remote endpoint (ssh://builder.example.test)",
+ });
+
+ const result = await installVllm(profile, {
+ hasImage: true,
+ nonInteractive: true,
+ promptFn: vi.fn(),
+ });
+
+ expect(result).toEqual({ ok: true });
+ expect(mocks.probeDockerBindIdentity).toHaveBeenCalledTimes(2);
+ expect(mocks.probeModelCacheStorage).not.toHaveBeenCalled();
+ expect(mocks.dockerPullWithProgressWatchdog).toHaveBeenCalledTimes(1);
+ expect(mocks.dockerSpawn).toHaveBeenCalledTimes(1);
+ expect(errSpy).toHaveBeenCalledWith(
+ expect.stringContaining("NEMOCLAW_IGNORE_VLLM_DISK_SPACE=1"),
+ );
+ });
+
+ it("uses one Docker context throughout a successful managed install (#6757)", async () => {
+ process.env.DOCKER_CONTEXT = "local-test-context";
+ delete process.env.DOCKER_HOST;
+ const profile = detectVllmProfile({ platform: "spark", type: "nvidia" })!;
+ mockSuccessfulVllmInstall(profile.containerName);
+
+ const result = await installVllm(profile, {
+ hasImage: false,
+ nonInteractive: true,
+ promptFn: vi.fn(),
+ });
+
+ expect(result).toEqual({ ok: true });
+ expect(mocks.probeDockerStorage).toHaveBeenCalledTimes(1);
+
+ const dockerAdapterOptions = [
+ ...mocks.dockerImageInspectFormat.mock.calls.map((call) => call[2]),
+ ...mocks.dockerPullWithProgressWatchdog.mock.calls.map((call) => call[1]),
+ ...mocks.dockerSpawn.mock.calls.map((call) => call[1]),
+ ...mocks.dockerForceRm.mock.calls.map((call) => call[1]),
+ ...mocks.dockerRunDetached.mock.calls.map((call) => call[1]),
+ ...mocks.dockerCapture.mock.calls.map((call) => call[1]),
+ ];
+ expect(dockerAdapterOptions).toHaveLength(6);
+ for (const options of dockerAdapterOptions) {
+ expect(options).toEqual(
+ expect.objectContaining({
+ env: expect.objectContaining({ DOCKER_CONTEXT: "local-test-context" }),
+ }),
+ );
+ }
+ });
+
it("starts the long-lived vLLM container through Docker argv, not a shell command", async () => {
process.env.HF_TOKEN = "hf_test";
const profile = detectVllmProfile({ platform: "spark", type: "nvidia" })!;
mockSuccessfulVllmInstall(profile.containerName);
+ mocks.dockerImageInspectFormat.mockReturnValue("sha256:cached-image");
const result = await installVllm(profile, {
hasImage: true,
@@ -435,12 +987,21 @@ describe("installVllm model resolution", () => {
{ env?: Record },
];
expect(args).toEqual(
- expect.arrayContaining(["--restart", "unless-stopped", "-e", "HF_TOKEN", profile.image]),
+ expect.arrayContaining([
+ "--pull=never",
+ "--restart",
+ "unless-stopped",
+ "-e",
+ "HF_TOKEN",
+ profile.image,
+ ]),
);
expect(args.join(" ")).not.toContain("hf_test");
expect(args.some((arg) => arg.includes("docker run"))).toBe(false);
expect(args[args.indexOf("-lc") + 1]).toContain("vllm serve");
- expect(opts).toEqual(expect.objectContaining({ env: { HF_TOKEN: "hf_test" } }));
+ expect(opts).toEqual(
+ expect.objectContaining({ env: expect.objectContaining({ HF_TOKEN: "hf_test" }) }),
+ );
});
it("rejects invalid profile run flags before launching the long-lived container", async () => {
@@ -450,6 +1011,7 @@ describe("installVllm model resolution", () => {
buildDockerRunFlags: () => ["--label", ""],
};
mockSuccessfulVllmInstall(profile.containerName);
+ mocks.dockerImageInspectFormat.mockReturnValue("sha256:cached-image");
const result = await installVllm(profile, {
hasImage: true,
diff --git a/src/lib/inference/vllm.ts b/src/lib/inference/vllm.ts
index 4c375742e25..ce199445608 100644
--- a/src/lib/inference/vllm.ts
+++ b/src/lib/inference/vllm.ts
@@ -10,6 +10,7 @@ import path from "node:path";
import {
dockerCapture,
dockerForceRm,
+ dockerImageInspectFormat,
dockerPullWithProgressWatchdog,
dockerRunDetached,
dockerSpawn,
@@ -17,9 +18,11 @@ import {
} from "../adapters/docker";
import { buildValidatedCurlCommandArgs } from "../adapters/http/curl-args";
import { VLLM_PORT } from "../core/ports";
+import { isAffirmativeAnswer } from "../onboard/prompt-helpers";
import { runCapture } from "../runner";
import { isSafeModelId } from "../validation";
import { getGpuIndicesByName } from "./nim";
+import { buildVllmDockerEnv } from "./vllm-docker-env";
import {
buildVllmServeCommand,
parseVllmExtraServeArgs,
@@ -29,6 +32,17 @@ import {
type VllmPlatform,
} from "./vllm-models";
import { resolveVllmInstallModel } from "./vllm-prompt";
+import {
+ formatStorageBytes,
+ imageStorageRequirementBytes,
+ modelStorageRequirementBytes,
+ probeDockerBindIdentity,
+ probeDockerHostLocality,
+ probeDockerStorage,
+ probeModelCacheStorage,
+ type StorageProbeResult,
+ VLLM_STORAGE_OVERRIDE_ENV,
+} from "./vllm-storage";
// Per-platform install recipe. Add new platforms by appending an entry to
// the profile table at the bottom of this file. The menu key in onboard.ts
@@ -39,7 +53,10 @@ export interface VllmProfile {
// filters the registry. Decoupled from `name` so future user-facing label
// tweaks don't change which models are offered.
platform: VllmPlatform;
- image: string; // container image
+ image: string; // platform-specific image pinned by digest
+ // Compressed size of that exact platform manifest. The storage preflight
+ // adds unpacking and pull-staging headroom.
+ imageDownloadSizeBytes: number;
// Default model when NEMOCLAW_VLLM_MODEL is unset. Per-platform default
// because Spark/Station can host larger recipes, but generic discrete-GPU
// Linux falls back to the small Nemotron-Nano-4B that fits on consumer
@@ -61,9 +78,28 @@ export interface VllmProfile {
loadTimeoutSec: number;
}
-const VLLM_IMAGES = {
- ngc2603Post1: "nvcr.io/nvidia/vllm:26.03.post1-py3",
- ngc2605Post1: "nvcr.io/nvidia/vllm:26.05.post1-py3",
+// Platform manifests and decimal compressed sizes published by NGC for the
+// named release tags. Pinning the digest makes a cache hit authoritative: an
+// explicit pull cannot begin downloading different same-tag layers.
+export const VLLM_IMAGES = {
+ ngc2603Post1: {
+ tag: "nvcr.io/nvidia/vllm:26.03.post1-py3",
+ amd64: {
+ ref: "nvcr.io/nvidia/vllm@sha256:7be6c2f676c36059a494fe17254e69ae5c677535ba6191044e5fc8e42a91c773",
+ downloadSizeBytes: 8_928_665_752,
+ },
+ arm64: {
+ ref: "nvcr.io/nvidia/vllm@sha256:447995cbb57e6c7cf792cab95e9852e5f62b5fb6d2f39e030fa4eda9a54eadb4",
+ downloadSizeBytes: 9_278_081_698,
+ },
+ },
+ ngc2605Post1: {
+ tag: "nvcr.io/nvidia/vllm:26.05.post1-py3",
+ arm64: {
+ ref: "nvcr.io/nvidia/vllm@sha256:9204569b17ee4c0eff75194b8e6e458479c8aee18953b5ab9cf359fcdac659e2",
+ downloadSizeBytes: 9_603_085_145,
+ },
+ },
} as const;
function nemotronNanoModel(): VllmModelDef {
@@ -153,7 +189,8 @@ export function buildHfTokenForwardEnv(
const SPARK_PROFILE: VllmProfile = {
name: "DGX Spark",
platform: "spark",
- image: VLLM_IMAGES.ngc2605Post1,
+ image: VLLM_IMAGES.ngc2605Post1.arm64.ref,
+ imageDownloadSizeBytes: VLLM_IMAGES.ngc2605Post1.arm64.downloadSizeBytes,
defaultModel: qwen35bNvfp4Model(),
containerName: "nemoclaw-vllm",
dockerRunFlags: vllmDockerRunFlags(),
@@ -165,7 +202,8 @@ const SPARK_PROFILE: VllmProfile = {
const STATION_PROFILE: VllmProfile = {
name: "DGX Station",
platform: "station",
- image: VLLM_IMAGES.ngc2605Post1,
+ image: VLLM_IMAGES.ngc2605Post1.arm64.ref,
+ imageDownloadSizeBytes: VLLM_IMAGES.ngc2605Post1.arm64.downloadSizeBytes,
defaultModel: deepseekV4FlashModel(),
containerName: "nemoclaw-vllm",
dockerRunFlags: SPARK_PROFILE.dockerRunFlags,
@@ -187,16 +225,26 @@ const STATION_PROFILE: VllmProfile = {
// Generic discrete-GPU Linux. Uses a small nemotron model that fits on
// most GPUs.
-const GENERIC_LINUX_PROFILE: VllmProfile = {
- name: "Linux + NVIDIA GPU",
- platform: "linux",
- image: VLLM_IMAGES.ngc2603Post1,
- defaultModel: nemotronNanoModel(),
- containerName: "nemoclaw-vllm",
- dockerRunFlags: SPARK_PROFILE.dockerRunFlags,
- pullTimeoutSec: SPARK_PROFILE.pullTimeoutSec,
- loadTimeoutSec: SPARK_PROFILE.loadTimeoutSec,
-};
+const genericLinuxImage =
+ process.arch === "arm64"
+ ? VLLM_IMAGES.ngc2603Post1.arm64
+ : process.arch === "x64"
+ ? VLLM_IMAGES.ngc2603Post1.amd64
+ : null;
+
+const GENERIC_LINUX_PROFILE: VllmProfile | null = genericLinuxImage
+ ? {
+ name: "Linux + NVIDIA GPU",
+ platform: "linux",
+ image: genericLinuxImage.ref,
+ imageDownloadSizeBytes: genericLinuxImage.downloadSizeBytes,
+ defaultModel: nemotronNanoModel(),
+ containerName: "nemoclaw-vllm",
+ dockerRunFlags: SPARK_PROFILE.dockerRunFlags,
+ pullTimeoutSec: SPARK_PROFILE.pullTimeoutSec,
+ loadTimeoutSec: SPARK_PROFILE.loadTimeoutSec,
+ }
+ : null;
export function detectVllmProfile(
gpu:
@@ -246,6 +294,7 @@ export async function pullImage(profile: VllmProfile): Promise<{ ok: boolean; re
// profile, so all profiles intentionally share the 15-minute stall default.
// The profile-specific maximum still bounds the complete pull operation.
const result = await dockerPullWithProgressWatchdog(profile.image, {
+ env: buildVllmDockerEnv(),
maxTimeoutMs: profile.pullTimeoutSec * 1000,
logLine: emit,
});
@@ -276,6 +325,7 @@ function downloadModel(
"run",
"-t",
"--rm",
+ "--pull=never",
"--entrypoint",
"hf",
"-v",
@@ -287,7 +337,10 @@ function downloadModel(
"download",
model.id,
],
- { stdio: ["ignore", "pipe", "pipe"] },
+ {
+ env: buildVllmDockerEnv(buildHfTokenForwardEnv()),
+ stdio: ["ignore", "pipe", "pipe"],
+ },
);
const tail: string[] = [];
@@ -387,6 +440,7 @@ export function buildVllmRunArgs(
"vLLM docker run flags",
);
return [
+ "--pull=never",
"--restart",
"unless-stopped",
...safeRunFlags,
@@ -425,13 +479,14 @@ function startContainer(
// Validate every launch input before replacing a potentially healthy
// existing container. Once validated, teardown keeps startup idempotent.
dockerForceRm(profile.containerName, {
+ env: buildVllmDockerEnv(),
ignoreError: true,
suppressOutput: true,
});
const result = dockerRunDetached(runArgs, {
+ env: buildVllmDockerEnv(buildHfTokenForwardEnv()),
ignoreError: true,
suppressOutput: true,
- env: buildHfTokenForwardEnv(),
});
if (result.status !== 0) {
return { ok: false, reason: `docker run failed (exit ${String(result.status)})` };
@@ -469,6 +524,7 @@ function vllmEndpointReady(): boolean {
function readContainerLogTail(profile: VllmProfile, lineCount = 80): string[] {
const output = dockerCapture(["logs", "--tail", String(lineCount), profile.containerName], {
+ env: buildVllmDockerEnv(),
ignoreError: true,
}).trim();
if (!output) return [];
@@ -537,11 +593,118 @@ function waitForVllmReady(profile: VllmProfile): Promise<{ ok: boolean; reason?:
function containerStillRunning(profile: VllmProfile): boolean {
const out = dockerCapture(
["ps", "--filter", `name=${profile.containerName}`, "--format", "{{.Names}}"],
- { ignoreError: true },
+ { env: buildVllmDockerEnv(), ignoreError: true },
).trim();
return out === profile.containerName;
}
+interface StorageWarning {
+ item: string;
+ itemLabel: "Image" | "Model";
+ probe: StorageProbeResult;
+ question: string;
+ remediation: readonly string[];
+ requiredBytes: bigint;
+ subject: string;
+}
+
+function printStorageWarning(warning: StorageWarning): void {
+ const insufficient =
+ warning.probe.ok && warning.probe.capacity.availableBytes < warning.requiredBytes;
+ console.error("");
+ console.error(` ${insufficient ? "Insufficient" : "Unable to verify"} ${warning.subject}.`);
+ console.error("");
+ console.error(` ${warning.itemLabel}: ${warning.item}`);
+ console.error(
+ ` Available: ${
+ warning.probe.ok
+ ? formatStorageBytes(warning.probe.capacity.availableBytes)
+ : `unknown (${warning.probe.reason})`
+ }`,
+ );
+ console.error(` Required: approximately ${formatStorageBytes(warning.requiredBytes)}`);
+ if (warning.probe.ok) {
+ console.error(` Storage: ${warning.probe.capacity.source} (${warning.probe.capacity.path})`);
+ } else if (warning.probe.path) {
+ console.error(` Storage: ${warning.probe.source ?? "filesystem"} (${warning.probe.path})`);
+ }
+ console.error("");
+ for (const line of warning.remediation) console.error(` ${line}`);
+}
+
+async function storageWarningAccepted(
+ warning: StorageWarning,
+ opts: InstallVllmOptions,
+ env: NodeJS.ProcessEnv = process.env,
+): Promise {
+ if (warning.probe.ok && warning.probe.capacity.availableBytes >= warning.requiredBytes) {
+ return true;
+ }
+ printStorageWarning(warning);
+ if (env[VLLM_STORAGE_OVERRIDE_ENV] === "1") {
+ console.error(` Continuing because ${VLLM_STORAGE_OVERRIDE_ENV}=1.`);
+ return true;
+ }
+ if (opts.nonInteractive) {
+ console.error(
+ ` Non-interactive setup stops before the guarded download. Set ${VLLM_STORAGE_OVERRIDE_ENV}=1 to override.`,
+ );
+ return false;
+ }
+ return isAffirmativeAnswer(await opts.promptFn(warning.question));
+}
+
+async function imageStorageAccepted(
+ profile: VllmProfile,
+ opts: InstallVllmOptions,
+): Promise {
+ return storageWarningAccepted(
+ {
+ item: profile.image,
+ itemLabel: "Image",
+ probe: probeDockerStorage(),
+ question: " Continue with the pull anyway? [y/N]: ",
+ remediation: [
+ "Free or expand Docker storage before continuing.",
+ "Useful diagnostics:",
+ " docker system df",
+ " docker info --format '{{.DockerRootDir}}'",
+ ],
+ requiredBytes: imageStorageRequirementBytes(profile.imageDownloadSizeBytes),
+ subject: "Docker storage for the managed vLLM image",
+ },
+ opts,
+ );
+}
+
+async function modelStorageAccepted(
+ model: VllmModelDef,
+ opts: InstallVllmOptions,
+ bindProbeImage?: string,
+): Promise {
+ const cacheDir = hostHfCacheDir();
+ const dockerHost = bindProbeImage
+ ? probeDockerBindIdentity(cacheDir, bindProbeImage)
+ : probeDockerHostLocality();
+ const probe: StorageProbeResult = dockerHost.ok ? probeModelCacheStorage(cacheDir) : dockerHost;
+ return storageWarningAccepted(
+ {
+ item: model.id,
+ itemLabel: "Model",
+ probe,
+ question: " Continue with the model download anyway? [y/N]: ",
+ remediation: [
+ `Free or expand storage for ${cacheDir} before continuing.`,
+ "Useful diagnostic:",
+ ' df -h "$HOME/.cache/huggingface"',
+ ],
+ requiredBytes: modelStorageRequirementBytes(model.downloadSizeBytes),
+ subject: "model-cache storage for managed vLLM",
+ },
+ opts,
+ );
+}
+
interface InstallVllmOptions {
hasImage: boolean;
nonInteractive: boolean;
@@ -549,6 +712,16 @@ interface InstallVllmOptions {
beforeInstall?: (modelId: string) => void;
}
+function imageIsCached(profile: VllmProfile): boolean {
+ return Boolean(
+ dockerImageInspectFormat("{{.Id}}", profile.image, {
+ env: buildVllmDockerEnv(),
+ ignoreError: true,
+ timeout: 10_000,
+ }).trim(),
+ );
+}
+
export function resolveVllmServedModelId(modelId: string, extraServeArgs: string[]): string {
let override: string | null = null;
for (let index = 0; index < extraServeArgs.length; index += 1) {
@@ -615,7 +788,7 @@ export async function installVllm(
const proceed = opts.nonInteractive
? true
- : (await opts.promptFn(" Continue? [y/N]: ")).trim().toLowerCase().startsWith("y");
+ : isAffirmativeAnswer(await opts.promptFn(" Continue? [y/N]: "));
if (!proceed) return { ok: false };
console.log("");
@@ -627,12 +800,26 @@ export async function installVllm(
return { ok: false };
}
+ const hasImage = imageIsCached(profile);
+ if (!hasImage && !(await imageStorageAccepted(profile, opts))) {
+ return { ok: false };
+ }
+ if (!(await modelStorageAccepted(model, opts, hasImage ? profile.image : undefined))) {
+ return { ok: false };
+ }
+
const pull = await pullImage(profile);
if (!pull.ok) {
console.error(` vLLM install failed: ${String(pull.reason)}`);
return { ok: false };
}
+ // The image now exists, so this re-probe also proves daemon/client bind
+ // identity before their individually valid estimates can overcommit storage.
+ if (!(await modelStorageAccepted(model, opts, profile.image))) {
+ return { ok: false };
+ }
+
const modelDownload = await downloadModel(profile, model);
if (!modelDownload.ok) {
console.error(` vLLM install failed: ${String(modelDownload.reason)}`);
@@ -652,6 +839,7 @@ export async function installVllm(
if (!ready.ok) {
printContainerLogTail(profile);
dockerStop(profile.containerName, {
+ env: buildVllmDockerEnv(),
ignoreError: true,
suppressOutput: true,
});
diff --git a/src/lib/onboard/provider-host-state.test.ts b/src/lib/onboard/provider-host-state.test.ts
index 82693b2460c..794179a35f2 100644
--- a/src/lib/onboard/provider-host-state.test.ts
+++ b/src/lib/onboard/provider-host-state.test.ts
@@ -43,12 +43,13 @@ function buildDeps(
function detectWithDeps(
deps: DetectInferenceProviderHostStateDeps,
gpu: InferenceProviderHostGpu | null = null,
+ env: NodeJS.ProcessEnv = {},
) {
return detectInferenceProviderHostState({
gpu,
experimental: true,
platform: "linux",
- env: {},
+ env,
log: () => {},
installedOllamaVersion: "0.24.0",
runningOllamaVersion: "0.24.0",
@@ -94,17 +95,19 @@ describe("detectInferenceProviderHostState", () => {
});
it("collects local Ollama and vLLM state into one provider host snapshot", () => {
+ const dockerCapture = vi.fn(() => "sha256:cached-image\n");
const deps = buildDeps({
hostCommandExists: vi.fn((command) => command === "ollama"),
findReachableOllamaHost: vi.fn(() => "127.0.0.1"),
runCapture: vi.fn((command) =>
command.join(" ").includes(`http://127.0.0.1:8000/v1/models`) ? "{}" : "",
),
- dockerCapture: vi.fn(() => "sha256:cached-image\n"),
+ dockerCapture,
detectVllmProfile: vi.fn(() => ({
name: "Linux + NVIDIA GPU",
platform: "linux" as const,
image: "nvcr.io/nvidia/vllm:test",
+ imageDownloadSizeBytes: 1,
defaultModel: {} as never,
containerName: "nemoclaw-vllm",
dockerRunFlags: [],
@@ -113,7 +116,14 @@ describe("detectInferenceProviderHostState", () => {
})),
});
- const state = detectWithDeps(deps, { nimCapable: true, type: "nvidia", platform: "linux" });
+ const state = detectWithDeps(
+ deps,
+ { nimCapable: true, type: "nvidia", platform: "linux" },
+ {
+ DOCKER_CONTEXT: "remote-builder",
+ DOCKER_HOST: "ssh://fallback.example.test",
+ },
+ );
expect(state.hasOllama).toBe(true);
expect(state.ollamaRunning).toBe(true);
@@ -125,6 +135,17 @@ describe("detectInferenceProviderHostState", () => {
expect(state.gpuNimCapable).toBe(true);
expect(state.ollamaInstallMenu.entry).toBeNull();
expect(deps.getWindowsHostOllamaDockerRequirement).toHaveBeenCalledWith(null);
+ expect(dockerCapture).toHaveBeenCalledWith(
+ ["image", "inspect", "--format", "{{.Id}}", "nvcr.io/nvidia/vllm:test"],
+ expect.objectContaining({
+ env: expect.objectContaining({
+ DOCKER_CONTEXT: "remote-builder",
+ DOCKER_HOST: "ssh://fallback.example.test",
+ }),
+ ignoreError: true,
+ timeout: 10_000,
+ }),
+ );
});
it("detects a reachable Windows-host Ollama beside WSL-local Ollama and warns outside mirrored networking", () => {
diff --git a/src/lib/onboard/provider-host-state.ts b/src/lib/onboard/provider-host-state.ts
index b127f2a8a5b..fcbd75f02cd 100644
--- a/src/lib/onboard/provider-host-state.ts
+++ b/src/lib/onboard/provider-host-state.ts
@@ -6,6 +6,7 @@ import { OLLAMA_PORT, VLLM_PORT } from "../core/ports";
import { findReachableOllamaHost, OLLAMA_HOST_DOCKER_INTERNAL } from "../inference/local";
import type { NvidiaPlatform } from "../inference/nim";
import { detectVllmProfile, type VllmProfile } from "../inference/vllm";
+import { buildVllmDockerEnv } from "../inference/vllm-docker-env";
import {
type ContainerRuntime,
isWsl as defaultIsWsl,
@@ -23,7 +24,10 @@ import { buildVllmMenuEntries, type VllmMenuEntry } from "./vllm-menu";
import { detectWindowsHostOllama, type WindowsHostOllamaState } from "./windows-host-ollama";
type RunCapture = (args: string[], options?: { ignoreError?: boolean }) => string;
-type DockerCapture = (args: string[], options?: { ignoreError?: boolean }) => string;
+type DockerCapture = (
+ args: string[],
+ options?: { env?: NodeJS.ProcessEnv; ignoreError?: boolean; timeout?: number },
+) => string;
export interface InferenceProviderHostGpu {
nimCapable?: boolean;
@@ -166,7 +170,13 @@ export function detectInferenceProviderHostState(
const vllmProfile = deps.detectVllmProfile(input.gpu);
const hasVllmImage = !!(
vllmProfile &&
- deps.dockerCapture(["images", "-q", vllmProfile.image], { ignoreError: true }).trim()
+ deps
+ .dockerCapture(["image", "inspect", "--format", "{{.Id}}", vllmProfile.image], {
+ env: buildVllmDockerEnv({}, input.env),
+ ignoreError: true,
+ timeout: 10_000,
+ })
+ .trim()
);
const windowsHostOllamaDockerRequirement = deps.getWindowsHostOllamaDockerRequirement(
isWsl ? deps.getContainerRuntime() : null,
diff --git a/test/detect-vllm-profile.test.ts b/test/detect-vllm-profile.test.ts
index 8e02019d533..70d45b06805 100644
--- a/test/detect-vllm-profile.test.ts
+++ b/test/detect-vllm-profile.test.ts
@@ -1,7 +1,7 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
-import { describe, expect, it } from "vitest";
+import { describe, expect, it, vi } from "vitest";
import {
buildHfTokenDockerArgs,
@@ -15,7 +15,9 @@ describe("detectVllmProfile", () => {
expect(profile).not.toBeNull();
expect(profile!.name).toBe("DGX Spark");
expect(profile!.defaultModel.id).toBe("nvidia/Qwen3.6-35B-A3B-NVFP4");
- expect(profile!.image).toBe("nvcr.io/nvidia/vllm:26.05.post1-py3");
+ expect(profile!.image).toBe(
+ "nvcr.io/nvidia/vllm@sha256:9204569b17ee4c0eff75194b8e6e458479c8aee18953b5ab9cf359fcdac659e2",
+ );
});
it("returns the Spark profile when legacy gpu.spark is true", () => {
@@ -28,17 +30,49 @@ describe("detectVllmProfile", () => {
const profile = detectVllmProfile({ platform: "station", type: "nvidia" });
expect(profile).not.toBeNull();
expect(profile!.name).toBe("DGX Station");
- expect(profile!.image).toBe("nvcr.io/nvidia/vllm:26.05.post1-py3");
+ expect(profile!.image).toBe(
+ "nvcr.io/nvidia/vllm@sha256:9204569b17ee4c0eff75194b8e6e458479c8aee18953b5ab9cf359fcdac659e2",
+ );
expect(profile!.defaultModel.id).toBe("deepseek-ai/DeepSeek-V4-Flash");
expect(profile!.defaultModel.envValue).toBe("deepseek-v4-flash");
});
- it("returns the generic Linux profile for non-Spark/Station NVIDIA hosts", () => {
- const profile = detectVllmProfile({ type: "nvidia" });
- expect(profile).not.toBeNull();
- expect(profile!.name).toBe("Linux + NVIDIA GPU");
- expect(profile!.defaultModel.id).toContain("Nemotron-3-Nano-4B");
- expect(profile!.image).toBe("nvcr.io/nvidia/vllm:26.03.post1-py3");
+ it.each([
+ {
+ arch: "arm64",
+ image:
+ "nvcr.io/nvidia/vllm@sha256:447995cbb57e6c7cf792cab95e9852e5f62b5fb6d2f39e030fa4eda9a54eadb4",
+ imageDownloadSizeBytes: 9_278_081_698,
+ },
+ {
+ arch: "x64",
+ image:
+ "nvcr.io/nvidia/vllm@sha256:7be6c2f676c36059a494fe17254e69ae5c677535ba6191044e5fc8e42a91c773",
+ imageDownloadSizeBytes: 8_928_665_752,
+ },
+ ] as const)("returns the generic Linux profile for non-Spark/Station NVIDIA $arch hosts", async ({
+ arch,
+ image,
+ imageDownloadSizeBytes,
+ }) => {
+ const originalArch = Object.getOwnPropertyDescriptor(process, "arch")!;
+ try {
+ Object.defineProperty(process, "arch", { configurable: true, value: arch });
+ vi.resetModules();
+ const { detectVllmProfile: detectVllmProfileForArch } = await import(
+ "../src/lib/inference/vllm.js"
+ );
+
+ const profile = detectVllmProfileForArch({ type: "nvidia" });
+ expect(profile).not.toBeNull();
+ expect(profile!.name).toBe("Linux + NVIDIA GPU");
+ expect(profile!.defaultModel.id).toContain("Nemotron-3-Nano-4B");
+ expect(profile!.image).toBe(image);
+ expect(profile!.imageDownloadSizeBytes).toBe(imageDownloadSizeBytes);
+ } finally {
+ Object.defineProperty(process, "arch", originalArch);
+ vi.resetModules();
+ }
});
it("prefers Spark over generic when both flags qualify", () => {
diff --git a/test/inference-options-docs.test.ts b/test/inference-options-docs.test.ts
index 09e81f4c1ec..3521a46df7d 100644
--- a/test/inference-options-docs.test.ts
+++ b/test/inference-options-docs.test.ts
@@ -8,6 +8,7 @@ import { fileURLToPath } from "node:url";
import type * as TypeScript from "typescript";
import { describe, expect, it } from "vitest";
import { parse } from "yaml";
+import { VLLM_IMAGES } from "../src/lib/inference/vllm.js";
import { getSandboxRuntimeInferenceEndpoint } from "../src/lib/onboard/docker-gpu-local-inference.js";
import { shouldForceCompletionsApi } from "../src/lib/validation.js";
@@ -289,6 +290,35 @@ describe("inference setup navigation", () => {
expect(markdown).toContain("only from the OpenShell Docker subnet to its gateway address");
});
+ it("keeps managed image tags, digests, and compressed sizes in sync with source", () => {
+ const markdown = fs.readFileSync(vllmSetupPath, "utf8");
+ const entries = [
+ {
+ prefix: "- DGX Spark and DGX Station",
+ image: VLLM_IMAGES.ngc2605Post1.arm64,
+ tag: VLLM_IMAGES.ngc2605Post1.tag,
+ },
+ {
+ prefix: "- Generic Linux `arm64` hosts",
+ image: VLLM_IMAGES.ngc2603Post1.arm64,
+ tag: VLLM_IMAGES.ngc2603Post1.tag,
+ },
+ {
+ prefix: "- Generic Linux `amd64` hosts",
+ image: VLLM_IMAGES.ngc2603Post1.amd64,
+ tag: VLLM_IMAGES.ngc2603Post1.tag,
+ },
+ ] as const;
+
+ for (const { prefix, image, tag } of entries) {
+ const line = markdown.split("\n").find((candidate) => candidate.startsWith(prefix));
+ expect(line, `missing managed-image documentation for ${prefix}`).toBeDefined();
+ expect(line).toContain(`\`${image.ref.split("@")[1]}\``);
+ expect(line).toContain(`\`${(image.downloadSizeBytes / 1_000_000_000).toFixed(2)} GB\``);
+ expect(line).toContain(`\`${tag}\``);
+ }
+ });
+
it("keeps tool-calling remediation canonical in troubleshooting", () => {
const markdown = fs.readFileSync(troubleshootingPath, "utf8");
const start = markdown.indexOf("### Tool calls appear as assistant text");