From 68f74a2ea3fe1c9a73d0186a2b0f23b1b6554172 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Thu, 6 Aug 2026 19:13:38 -0700 Subject: [PATCH 01/19] feat(installer): route Spark express through local profile Signed-off-by: Aaron Erickson --- ci/platform-matrix.json | 2 +- docs/get-started/prerequisites.mdx | 2 +- .../choose-local-inference-server.mdx | 6 +- .../set-up-vllm-on-two-dgx-sparks.mdx | 28 +- docs/inference/set-up-vllm.mdx | 9 +- docs/reference/platform-support.mdx | 2 +- docs/resources/prompt-assets/dgx-spark.md | 8 +- scripts/install.sh | 11 +- test/e2e/README.md | 19 ++ test/e2e/live/spark-express-vllm.test.ts | 258 ++++++++++++++++++ .../installer-express-prompt-pty-harness.ts | 8 +- test/install-express-prompt.test.ts | 12 +- 12 files changed, 330 insertions(+), 35 deletions(-) create mode 100644 test/e2e/live/spark-express-vllm.test.ts diff --git a/ci/platform-matrix.json b/ci/platform-matrix.json index 271b17923f4..1f4da43caba 100644 --- a/ci/platform-matrix.json +++ b/ci/platform-matrix.json @@ -47,7 +47,7 @@ "status": "tested", "prd_priority": "P1", "ci_tested": true, - "notes": "Use the standard installer and `$$nemoclaw onboard`. The automatic two-DGX Spark managed-vLLM profile is Experimental, and physical two-node end-to-end validation is pending. For the profile requirements and controls, see [Set Up vLLM on Two DGX Sparks](../inference/local-inference/set-up-vllm-on-two-dgx-sparks). For the validated single-DGX Spark walkthrough with local inference, see the [NVIDIA Spark playbook](https://build.nvidia.com/spark/nemoclaw)." + "notes": "Use the standard installer and `$$nemoclaw onboard`. DGX Spark Express selects the fixed single-host vLLM profile from the serving catalog. The explicit two-DGX Spark managed-vLLM profile is Experimental, and physical two-node end-to-end validation is pending. For the profile requirements and controls, see [Set Up vLLM on Two DGX Sparks](../inference/local-inference/set-up-vllm-on-two-dgx-sparks). For the validated single-DGX Spark walkthrough with local inference, see the [NVIDIA Spark playbook](https://build.nvidia.com/spark/nemoclaw)." }, { "name": "Windows WSL2", diff --git a/docs/get-started/prerequisites.mdx b/docs/get-started/prerequisites.mdx index 9e7c1cd46c9..72f2d3597a1 100644 --- a/docs/get-started/prerequisites.mdx +++ b/docs/get-started/prerequisites.mdx @@ -101,7 +101,7 @@ The table comes from [`ci/platform-matrix.json`](https://github.com/NVIDIA/NemoC {/* platform-matrix:begin */} | OS | Container runtime | Status | Notes | |----|-------------------|--------|-------| -| DGX OS (Spark) | Docker | Tested | Use the standard installer and `$$nemoclaw onboard`. The automatic two-DGX Spark managed-vLLM profile is Experimental, and physical two-node end-to-end validation is pending. For the profile requirements and controls, see [Set Up vLLM on Two DGX Sparks](../inference/local-inference/set-up-vllm-on-two-dgx-sparks). For the validated single-DGX Spark walkthrough with local inference, see the [NVIDIA Spark playbook](https://build.nvidia.com/spark/nemoclaw). | +| DGX OS (Spark) | Docker | Tested | Use the standard installer and `$$nemoclaw onboard`. DGX Spark Express selects the fixed single-host vLLM profile from the serving catalog. The explicit two-DGX Spark managed-vLLM profile is Experimental, and physical two-node end-to-end validation is pending. For the profile requirements and controls, see [Set Up vLLM on Two DGX Sparks](../inference/local-inference/set-up-vllm-on-two-dgx-sparks). For the validated single-DGX Spark walkthrough with local inference, see the [NVIDIA Spark playbook](https://build.nvidia.com/spark/nemoclaw). | | DGX OS (Station) | Docker | Tested with limitations | Tested with limitations across qualified profiles on one physical DGX Station GB300; see [Additional Setup for DGX Station](additional-setup/dgx-station-preparation) for accepted profiles, the pending no-OTA DGX OS `7.6.x` end-to-end qualification, runtime gates, and current dual-Station and dedicated CI limitations. | | Linux | Docker | Tested | Primary tested path. Ubuntu 24.04 has host-level onboarding validation. A digest-pinned Ubuntu 26.04 userspace lane builds the CLI and runs preflight, installer, and platform contracts on eligible main pushes; Docker-host, AppArmor, Landlock, and live onboarding validation on 26.04 remain pending. Other distros (Ubuntu 22.04, Fedora, Rocky, Alma, NixOS, Arch) may work but are not validated. | | macOS (Apple Silicon) | Colima, Docker Desktop | Tested with limitations | Start the container runtime (Colima or Docker Desktop) before running the installer. When Homebrew is available, OpenShell uses its official formula and the gateway appears in `brew services list` as `openshell`; without Homebrew, NemoClaw uses the standalone OpenShell install and detached gateway fallback. Homebrew Colima users must install both Colima and the Docker CLI (`brew install colima docker`) before `docker info` can work. Xcode Command Line Tools (`xcode-select --install`) are typically required for Node native modules during install. NemoClaw recommends them but does not enforce them during preflight. | diff --git a/docs/inference/choose-local-inference-server.mdx b/docs/inference/choose-local-inference-server.mdx index c1603a8c719..6772956d437 100644 --- a/docs/inference/choose-local-inference-server.mdx +++ b/docs/inference/choose-local-inference-server.mdx @@ -23,7 +23,7 @@ OpenShell intercepts that traffic and forwards it to the local endpoint configur | Ollama | You want the default local option and want NemoClaw to install, start, or use Ollama on supported hosts. | Appears when Ollama is installed or running, and the wizard can offer installation on supported hosts. | Ollama through the managed local route. | | Existing vLLM | You already run vLLM on `localhost:8000`. | Appears when NemoClaw detects the server. | `/v1/chat/completions`. | | Managed vLLM | You want NemoClaw to pull an image, download model weights, and manage the server container. | Appears by default on DGX Spark and DGX Station, while generic Linux NVIDIA GPU hosts require `NEMOCLAW_EXPERIMENTAL=1` or `NEMOCLAW_PROVIDER=install-vllm`. | `/v1/chat/completions`. | -| Fixed local model profile | You need one catalog-selected model and serving runtime combination. | Requires the dedicated installer flag on DGX Spark and does not appear in the provider menu. | `/v1/chat/completions`. | +| Fixed local model profile | You need one catalog-selected model and serving runtime combination. | DGX Spark Express selects the vLLM profile. Direct installation can use the dedicated installer flag. The profile does not appear in the provider menu. | `/v1/chat/completions`. | | NVIDIA NIM | You want NemoClaw to pull and manage a validated NIM container on a NIM-capable NVIDIA GPU. | Experimental and requires `NEMOCLAW_EXPERIMENTAL=1`. | `/v1/chat/completions`. | @@ -32,7 +32,7 @@ OpenShell intercepts that traffic and forwards it to the local endpoint configur |---|---|---|---| | Existing vLLM | You already run vLLM on `localhost:8000`. | Appears when NemoClaw detects the server. | `/v1/chat/completions`. | | Managed vLLM | You want NemoClaw to pull an image, download model weights, and manage the server container. | Appears by default on DGX Spark and DGX Station, while generic Linux NVIDIA GPU hosts require `NEMOCLAW_EXPERIMENTAL=1` or `NEMOCLAW_PROVIDER=install-vllm`. | `/v1/chat/completions`. | -| Fixed local model profile | You need one catalog-selected model and serving runtime combination. | Requires the dedicated installer flag on DGX Spark and does not appear in the provider menu. | `/v1/chat/completions`. | +| Fixed local model profile | You need one catalog-selected model and serving runtime combination. | DGX Spark Express selects the vLLM profile. Direct installation can use the dedicated installer flag. The profile does not appear in the provider menu. | `/v1/chat/completions`. | | NVIDIA NIM | You want NemoClaw to pull and manage a validated NIM container on a NIM-capable NVIDIA GPU. | Experimental and requires `NEMOCLAW_EXPERIMENTAL=1`. | `/v1/chat/completions`. | @@ -65,6 +65,7 @@ Refer to [Set Up vLLM](set-up-vllm). Use this feature-gated path to install one catalog-selected model-and-runtime combination for vLLM or llama.cpp. The installer does not accept a provider or model override for this profile. +DGX Spark Express enables this path and selects vLLM automatically. Before you start, confirm these prerequisites: @@ -120,6 +121,7 @@ curl -fsSL https://www.nvidia.com/nemoclaw.sh | \ ``` The flag enables the dedicated profile gate, disables Express selection, and selects the matching catalog recipe. +DGX Spark Express enables the same profile gate and selects the vLLM runtime. Do not combine this path with `NEMOCLAW_PROVIDER` or `NEMOCLAW_MODEL`. The vLLM profile rejects `NEMOCLAW_VLLM_MODEL`, `NEMOCLAW_VLLM_PORT`, and `NEMOCLAW_VLLM_EXTRA_ARGS_JSON` before installation. diff --git a/docs/inference/set-up-vllm-on-two-dgx-sparks.mdx b/docs/inference/set-up-vllm-on-two-dgx-sparks.mdx index 0fd5090452a..3ca289c9c35 100644 --- a/docs/inference/set-up-vllm-on-two-dgx-sparks.mdx +++ b/docs/inference/set-up-vllm-on-two-dgx-sparks.mdx @@ -10,7 +10,7 @@ content: type: "how_to" --- -Use this workflow to let DGX Spark Express qualify two trusted DGX Spark systems for distributed vLLM serving. +Use this workflow to request and qualify two trusted DGX Spark systems for distributed vLLM serving. The profile serves DeepSeek V4 Flash 0731 through the managed `inference.local` route. @@ -20,9 +20,9 @@ Use it only for evaluation until the required physical validation is complete. ## Understand Profile Selection -DGX Spark Express attempts automatic profile selection only after it selects the managed vLLM provider. -A provider other than `install-vllm`, an existing local inference route, `NEMOCLAW_VLLM_MODEL`, or `NEMOCLAW_VLLM_EXTRA_ARGS_JSON` remains authoritative. -Without explicit inference intent, the detector derives one exact counterpart from each qualified private `/30` rail. +DGX Spark Express selects the fixed single-host local model profile and does not select this distributed profile automatically. +Request this profile explicitly with `NEMOCLAW_SERVING_PRESET`. +The distributed detector derives one exact counterpart from each qualified private `/30` rail when you do not set an exact peer. It does not scan a subnet, add SSH host keys, add authorized keys, or change SSH configuration. Both derived addresses must already have strict public-key SSH trust. @@ -35,8 +35,7 @@ Declining setup creates no binding. After confirmation, NemoClaw attempts to remove every binding when setup fails or the durable runtime receipt is committed. If cleanup cannot be confirmed, a new managed-vLLM install stops before mutation and preserves the claims for explicit recovery or removal. -When no matching cluster qualifies during automatic selection, NemoClaw retains the existing single-DGX Spark managed vLLM path. -This ordinary no-match result occurs before the distributed image or model download starts. +When no matching cluster qualifies, the explicit distributed request stops before the image or model download starts. Existing related runtime state is not an ordinary no-match result. NemoClaw stops when either host has a related vLLM or DGX Station container, an ambiguous runtime inspection, or a listener on port `8000` or `25000`. @@ -47,12 +46,12 @@ NemoClaw leaves the existing files, containers, and listeners untouched instead Set `NEMOCLAW_MANAGED_CLUSTER_PEERS` to a comma-separated list of exact pretrusted peers. The current profile declares `nodeCount: 2`, so its strict selection requires one peer. Set `NEMOCLAW_SERVING_PRESET=vllm.dgx-spark-gb10.dual.deepseek-v4-flash-0731` to request the exact serving profile. -Either setting makes qualification strict, so a missed requirement stops setup instead of selecting the single-DGX Spark profile. +Either setting makes qualification strict, so a missed requirement stops setup. ## Prepare Both Systems Prepare both DGX Spark systems before you run the installer. -The automatic path requires all of these conditions on both systems: +The distributed path requires all of these conditions on both systems: - The host is an `arm64` DGX Spark with exactly one GB10 GPU. - The current controller is a non-root user whose user ID (UID) matches the local host probe. @@ -115,9 +114,9 @@ The detector qualifies both ConnectX-7 rails. This profile revision binds vLLM, NCCL, Gloo, and tensor-parallel traffic to the first qualified rail in deterministic interface order. Do not interpret two qualified `200,000 Mbps` rails as measured aggregate serving throughput. -## Run Automatic Express Installation +## Request the Distributed Profile -Run the interactive installer on the system that should become the head. +Run the installer on the system that should become the head. ```bash curl -fsSL https://www.nvidia.com/nemoclaw.sh | \ @@ -130,11 +129,16 @@ curl -fsSL https://www.nvidia.com/nemoclaw.sh | \ NEMOCLAW_AGENT=langchain-deepagents-code \ + NEMOCLAW_NON_INTERACTIVE=1 \ + NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 \ + NEMOCLAW_PROVIDER=install-vllm \ + NEMOCLAW_SERVING_PRESET=vllm.dgx-spark-gb10.dual.deepseek-v4-flash-0731 \ + NEMOCLAW_SANDBOX_NAME=my-assistant \ bash ``` -Accept the usage notice and the **Run express install with these settings?** prompt. -DGX Spark Express prints the selected topology, immutable image, pinned model revision, cache paths, and worker-first launch order before image, model, or container mutation. +NemoClaw derives the counterpart from the qualified direct rails and stops when it cannot select exactly one peer. +Before image, model, or container mutation, NemoClaw prints the selected topology, immutable image, pinned model revision, cache paths, and worker-first launch order. For optional authenticated model downloads, follow [Authenticate Hugging Face Downloads](set-up-vllm#authenticate-hugging-face-downloads) before you start the installer. The public model can download anonymously, but Hugging Face can rate-limit anonymous transfers. diff --git a/docs/inference/set-up-vllm.mdx b/docs/inference/set-up-vllm.mdx index 11dc2dacccc..ef532f26ae2 100644 --- a/docs/inference/set-up-vllm.mdx +++ b/docs/inference/set-up-vllm.mdx @@ -257,8 +257,13 @@ On DGX Station, accepting the installer express prompt selects `NEMOCLAW_VLLM_MO Express uses the single-Station Ultra recipe unless a trusted reciprocal pair qualifies for distributed serving. Refer to [Set Up vLLM on Two DGX Stations](set-up-vllm-on-two-dgx-stations) for pair qualification, lifecycle receipts, runtime topology, and network controls. -On DGX Spark, Express selects the pinned two-node profile when exactly one pretrusted reciprocal pair qualifies and no inference intent or related runtime conflicts. -Refer to [Set Up vLLM on Two DGX Sparks](set-up-vllm-on-two-dgx-sparks) for automatic and explicit selection, profile requirements, and cleanup behavior. +On DGX Spark, accepting the installer Express prompt selects the fixed local model profile for vLLM. +The serving catalog supplies the pinned model, runtime image, port, and serve arguments. +The installer rejects provider and model overrides. +The dedicated local-model onboarder rejects vLLM model, port, and serve-argument overrides before it starts the managed container. +Set `NEMOCLAW_VLLM_MODEL` only when you intend to use the customizable managed-vLLM path instead of the fixed profile. +Refer to [Choose a Local Inference Server](choose-local-inference-server#install-a-fixed-local-model-profile) for fixed-profile installation and verification. +Refer to [Set Up vLLM on Two DGX Sparks](set-up-vllm-on-two-dgx-sparks) when you need to request the Experimental distributed profile explicitly. To select the existing `deepseek-v4-flash` recipe while retaining the same one-confirmation express flow, run: diff --git a/docs/reference/platform-support.mdx b/docs/reference/platform-support.mdx index 502553460f7..3c91f873e98 100644 --- a/docs/reference/platform-support.mdx +++ b/docs/reference/platform-support.mdx @@ -78,7 +78,7 @@ For install requirements and the shorter setup-oriented platform view, refer to {/* platform-matrix-full:begin */} | OS | Container runtime | Status | PRD priority | CI | Notes | |----|-------------------|--------|--------------|----|-------| -| DGX OS (Spark) | Docker | Tested | P1 | Yes | Use the standard installer and `$$nemoclaw onboard`. The automatic two-DGX Spark managed-vLLM profile is Experimental, and physical two-node end-to-end validation is pending. For the profile requirements and controls, see [Set Up vLLM on Two DGX Sparks](../inference/local-inference/set-up-vllm-on-two-dgx-sparks). For the validated single-DGX Spark walkthrough with local inference, see the [NVIDIA Spark playbook](https://build.nvidia.com/spark/nemoclaw). | +| DGX OS (Spark) | Docker | Tested | P1 | Yes | Use the standard installer and `$$nemoclaw onboard`. DGX Spark Express selects the fixed single-host vLLM profile from the serving catalog. The explicit two-DGX Spark managed-vLLM profile is Experimental, and physical two-node end-to-end validation is pending. For the profile requirements and controls, see [Set Up vLLM on Two DGX Sparks](../inference/local-inference/set-up-vllm-on-two-dgx-sparks). For the validated single-DGX Spark walkthrough with local inference, see the [NVIDIA Spark playbook](https://build.nvidia.com/spark/nemoclaw). | | DGX OS (Station) | Docker | Tested with limitations | P1 | No | The PRD marks this platform as P1. Physical validation on one DGX Station GB300 covers generic Ubuntu 24.04 ARM64, stock DGX OS `7.5.0`, the April 2026 NVIDIA Colossus BaseOS profile, and the June 2026 NVIDIA AI Developer Tools profile. A physical no-OTA DGX OS `7.6.0` host provided the release and hardware profile used for its stable workstation-family classifier and passed read-only eligibility and runtime-command preflight. Full Station Express end-to-end qualification for the accepted no-OTA DGX OS `7.6.x` profile is pending. The profile remains subject to the same physical GB300, driver, ECC, Docker, CDI, and container GPU validation. Clean-host end-to-end validation passed on generic Ubuntu and Colossus BaseOS; stock DGX OS and AI Developer Tools completed Station Express validation. The DGX OS `7.5.0` run used released OpenShell `0.0.85`, local Nemotron Ultra serving, sandbox `cuInit(0)`, and a Hermes write/read file-tool task. A dual-Station configuration has not been validated, and dedicated CI coverage is not available. Direct-GPU policies expose only the exact read-only BDF directory for each discovered display-class PCI device with NVIDIA vendor ID (`0x10de`) and GB300 device ID (`0x31c2` or `0x31c3`) plus required existing topology and module paths; they do not expose `/sys`, the PCI parent subtree, or sysfs write access. During physical validation, reads of `/sys/fs/cgroup/cgroup.controllers` and `/sys/class/net/lo/address` remained denied. For canonical hardware qualification, image requirements, preparation, repair limits, reboot handoff, and the explicit temporary metadata override, see [Prepare DGX Station to Install NemoClaw](../get-started/additional-setup/dgx-station-preparation). | | Linux | Docker | Tested | P0 | Yes | Primary tested path. Ubuntu 24.04 has host-level onboarding validation. A digest-pinned Ubuntu 26.04 userspace lane builds the CLI and runs preflight, installer, and platform contracts on eligible main pushes; Docker-host, AppArmor, Landlock, and live onboarding validation on 26.04 remain pending. Other distros (Ubuntu 22.04, Fedora, Rocky, Alma, NixOS, Arch) may work but are not validated. | | macOS (Apple Silicon) | Colima, Docker Desktop | Tested with limitations | P0 | Yes | Start the container runtime (Colima or Docker Desktop) before running the installer. When Homebrew is available, OpenShell uses its official formula and the gateway appears in `brew services list` as `openshell`; without Homebrew, NemoClaw uses the standalone OpenShell install and detached gateway fallback. Homebrew Colima users must install both Colima and the Docker CLI (`brew install colima docker`) before `docker info` can work. Xcode Command Line Tools (`xcode-select --install`) are typically required for Node native modules during install. NemoClaw recommends them but does not enforce them during preflight. | diff --git a/docs/resources/prompt-assets/dgx-spark.md b/docs/resources/prompt-assets/dgx-spark.md index 8e9efd4c7e7..cb907e42e90 100644 --- a/docs/resources/prompt-assets/dgx-spark.md +++ b/docs/resources/prompt-assets/dgx-spark.md @@ -7,7 +7,7 @@ Use these instructions only after hardware detection confirms DGX Spark. -Explain that Express keeps the selected agent, uses local vLLM with the default Qwen model, leaves optional setup at its defaults, and downloads the vLLM container and model. +Explain that Express keeps the selected agent, uses the fixed DGX Spark vLLM profile, leaves optional setup at its defaults, and downloads the catalog-selected container and model. Include the third-party-software notice, then ask: "Run Express install with these settings?" Choices: @@ -16,8 +16,10 @@ Choices: If Express is selected: -- Set `NEMOCLAW_PROVIDER=install-vllm`. -- Leave `NEMOCLAW_VLLM_MODEL` and `NEMOCLAW_MODEL` unset so the installed release selects its DGX Spark default, currently `nvidia/Qwen3.6-35B-A3B-NVFP4`. +- Set `NEMOCLAW_ENABLE_LOCAL_MODEL_PROFILE=1` and `NEMOCLAW_LOCAL_MODEL_RUNTIME=vllm`. +- Leave `NEMOCLAW_PROVIDER`, `NEMOCLAW_MODEL`, `NEMOCLAW_VLLM_MODEL`, `NEMOCLAW_VLLM_PORT`, and `NEMOCLAW_VLLM_EXTRA_ARGS_JSON` unset. +- Explain that the serving catalog selects the fixed model, runtime image, port, and vLLM arguments. +- The current DGX Spark vLLM profile serves `nvidia/Qwen3.6-35B-A3B-NVFP4`. - Set `NEMOCLAW_AGENT` to the agent already selected in the starter prompt. - Set `NEMOCLAW_NON_INTERACTIVE=1`, `NEMOCLAW_NON_INTERACTIVE_SUDO_MODE=prompt`, `NEMOCLAW_YES=1`, and `NEMOCLAW_POLICY_MODE=suggested`. - Set `NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1` when Express is accepted. diff --git a/scripts/install.sh b/scripts/install.sh index a99a0ec4c97..027f42db777 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -4315,9 +4315,14 @@ activate_express_install() { case "$platform" in "DGX Spark") export NEMOCLAW_SANDBOX_NAME="${NEMOCLAW_SANDBOX_NAME:-my-assistant}" - export NEMOCLAW_PROVIDER=install-vllm if [ -n "${NEMOCLAW_VLLM_MODEL:-}" ]; then + unset NEMOCLAW_ENABLE_LOCAL_MODEL_PROFILE NEMOCLAW_LOCAL_MODEL_RUNTIME + export NEMOCLAW_PROVIDER=install-vllm export NEMOCLAW_VLLM_MODEL + else + unset NEMOCLAW_PROVIDER + export NEMOCLAW_ENABLE_LOCAL_MODEL_PROFILE=1 + export NEMOCLAW_LOCAL_MODEL_RUNTIME=vllm fi ;; "DGX Station") @@ -4757,8 +4762,8 @@ describe_express_install() { inference_summary="managed local vLLM with model ${NEMOCLAW_VLLM_MODEL}" inference_disclosure="The explicit model remains authoritative, so this run keeps the existing single-host DGX Spark profile. Managed vLLM pulls the configured image/model and runs only its dedicated container." else - inference_summary="managed vLLM with automatic DGX Spark serving-profile selection" - inference_disclosure="With no explicit inference intent or related runtime, one exactly qualified pretrusted managed cluster topology selects a matching pinned distributed profile. An ordinary no-match keeps the existing single-host DGX Spark profile; any related or ambiguous setup remains untouched and stops installation. Managed vLLM pulls the selected image/model and runs only its dedicated containers. The selected distributed profile is experimental pending physical end-to-end validation." + inference_summary="the fixed DGX Spark model and vLLM serving profile" + inference_disclosure="The serving catalog owns the model, image, port, and vLLM arguments. The dedicated local-model onboarder rejects model and runtime overrides before starting its managed container." fi sandbox_summary="${NEMOCLAW_SANDBOX_NAME:-my-assistant}" ;; diff --git a/test/e2e/README.md b/test/e2e/README.md index 08c1177a440..dbe27d23d7a 100644 --- a/test/e2e/README.md +++ b/test/e2e/README.md @@ -675,6 +675,25 @@ Validate phase coverage without executing test bodies with: npm run test:e2e-phases:check ``` +### DGX Spark Express vLLM + +`spark-express-vllm.test.ts` is a physical-host qualification for the catalog-backed Express path. +It requires a qualified NVIDIA DGX Spark with Docker, NVIDIA Container Toolkit, OpenShell prerequisites, enough storage for the pinned image and model, and no unrelated `nemoclaw-vllm` container. +The test preserves the Hugging Face cache but deletes its dedicated sandbox and owned vLLM container. + +Run the target from a clean candidate checkout on the Spark host: + +```bash +E2E_JOB=1 \ +E2E_TARGET_ID=spark-express-vllm \ +NEMOCLAW_RUN_LIVE_E2E=1 \ +NEMOCLAW_SANDBOX_NAME=e2e-spark-express-vllm \ +npx tsx tools/e2e/live-vitest-invocation.mts run \ + --test-path test/e2e/live/spark-express-vllm.test.ts +``` + +The passing target proves that Express selects the fixed vLLM preset and recipe, the managed container carries exact catalog provenance, `inference.local` completes a chat request, and unrelated sandbox egress receives an HTTP `403` response. + The checker preserves coverage for every file under `test/e2e/live/` and adds workflow-selected integration files from the authoritative shared-job planner. Live modules import `fixtures/e2e-test.ts`; selected integration modules import diff --git a/test/e2e/live/spark-express-vllm.test.ts b/test/e2e/live/spark-express-vllm.test.ts new file mode 100644 index 00000000000..e4711402e79 --- /dev/null +++ b/test/e2e/live/spark-express-vllm.test.ts @@ -0,0 +1,258 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** Exercises the catalog-backed DGX Spark Express vLLM path on physical hardware. */ + +import { loadServingCatalog } from "../../../src/lib/inference/serving/catalog-loader.ts"; +import type { HostLocalInferenceServingRecipe } from "../../../src/lib/inference/serving/types.ts"; +import { + LOCAL_MODEL_PROFILE_ENABLED_ENV, + LOCAL_MODEL_PROFILE_RUNTIME_ENV, + resolveLocalModelProfilePlan, +} from "../../../src/lib/onboard/local-model-profile/plan.ts"; +import { buildAvailabilityProbeEnv } from "../fixtures/availability-env.ts"; +import { resultText } from "../fixtures/clients/command.ts"; +import type { HostCliClient } from "../fixtures/clients/host.ts"; +import { trustedSandboxShellScript } from "../fixtures/clients/sandbox.ts"; +import { expect, test } from "../fixtures/e2e-test.ts"; +import { REPO_ROOT } from "../fixtures/paths.ts"; +import { + cleanupSandbox, + expectOpenAiChatThroughSandbox, + requireLivePrerequisites, +} from "./inference-routing-helpers.ts"; + +const SANDBOX_NAME = process.env.NEMOCLAW_SANDBOX_NAME ?? "e2e-spark-express-vllm"; +const VLLM_CONTAINER = "nemoclaw-vllm"; +const TEST_TIMEOUT_MS = 65 * 60_000; +const ONBOARD_TIMEOUT_MS = 55 * 60_000; + +interface VllmContainerInspection { + readonly Config: { + readonly Cmd: string[]; + readonly Entrypoint: string[]; + readonly Image: string; + readonly Labels: Record; + }; + readonly HostConfig: { + readonly DeviceRequests: Array<{ Count: number; Capabilities: string[][] }>; + readonly IpcMode: string; + readonly NetworkMode: string; + readonly PortBindings: Record>; + readonly ShmSize: number; + }; + readonly Mounts: Array<{ Destination: string; Type: string }>; +} + +function e2eEnv(extra: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv { + return { + ...buildAvailabilityProbeEnv(), + NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1", + NEMOCLAW_ENABLE_LOCAL_MODEL_PROFILE: "", + NEMOCLAW_FRESH: "1", + NEMOCLAW_LOCAL_MODEL_RUNTIME: "", + NEMOCLAW_MODEL: "", + NEMOCLAW_NON_INTERACTIVE: "1", + NEMOCLAW_POLICY_TIER: "balanced", + NEMOCLAW_PROVIDER: "", + NEMOCLAW_RECREATE_SANDBOX: "1", + NEMOCLAW_SANDBOX_NAME: SANDBOX_NAME, + NEMOCLAW_VLLM_EXTRA_ARGS_JSON: "", + NEMOCLAW_VLLM_MODEL: "", + NEMOCLAW_VLLM_PORT: "", + OPENSHELL_GATEWAY: "nemoclaw", + ...extra, + }; +} + +function vllmProfilePlan() { + const plan = resolveLocalModelProfilePlan(loadServingCatalog(), { + [LOCAL_MODEL_PROFILE_ENABLED_ENV]: "1", + [LOCAL_MODEL_PROFILE_RUNTIME_ENV]: "vllm", + }); + if (plan?.runtime !== "vllm") { + throw new Error("the vLLM local-model profile did not resolve from the serving catalog"); + } + return plan; +} + +async function removeOwnedVllmContainer(host: HostCliClient, artifactName: string): Promise { + const result = await host.command( + "bash", + [ + "--noprofile", + "--norc", + "-c", + [ + "set -euo pipefail", + `container=${VLLM_CONTAINER}`, + 'if ! label="$(docker inspect --format \'{{ index .Config.Labels "com.nvidia.nemoclaw.managed-vllm" }}\' "$container" 2>/dev/null)"; then exit 0; fi', + '[[ "$label" == "true" ]] || { echo "refusing to remove an unmanaged $container container" >&2; exit 70; }', + 'docker rm -f "$container" >/dev/null', + ].join("\n"), + ], + { artifactName, env: e2eEnv(), timeoutMs: 120_000 }, + ); + expect(result.exitCode, resultText(result)).toBe(0); +} + +function assertRecipeCommand(command: string, recipe: HostLocalInferenceServingRecipe): void { + expect(command).toContain(`vllm serve ${recipe.spec.model.id}`); + expect(command).toContain(`--revision ${recipe.spec.model.revision}`); + expect(command).toContain(`--served-model-name ${recipe.spec.model.servedName}`); + expect(command).not.toContain("pip install"); + for (const argument of recipe.spec.serve.arguments) { + expect(command).toContain(argument.name); + expect(command).toContain( + argument.value === undefined ? argument.name : String(argument.value), + ); + } +} + +test("DGX Spark Express materializes the fixed vLLM profile and routes sandbox inference", { + timeout: TEST_TIMEOUT_MS, + meta: { + e2ePhases: [ + "qualify the physical DGX Spark host", + "activate Spark Express and onboard through the local-model profile", + "verify catalog-owned vLLM runtime configuration", + "prove sandbox inference and unrelated egress denial", + ], + }, +}, async ({ artifacts, cleanup, host, progress, sandbox, skip }) => { + const plan = vllmProfilePlan(); + await artifacts.target.declare({ + id: "spark-express-vllm", + boundary: + "DGX Spark Express activation + serving catalog preset/recipe + dedicated onboarder + managed vLLM + OpenShell sandbox", + catalogDigest: plan.catalogDigest, + presetId: plan.preset.metadata.id, + presetDigest: plan.presetDigest, + recipeId: plan.recipe.metadata.id, + recipeDigest: plan.recipeDigest, + sandboxName: SANDBOX_NAME, + }); + + progress.phase("qualify the physical DGX Spark host"); + await requireLivePrerequisites(host, skip); + const platform = await host.command( + "bash", + [ + "--noprofile", + "--norc", + "-c", + "source scripts/install.sh >/dev/null; detect_express_platform", + ], + { + artifactName: "spark-express-platform", + cwd: REPO_ROOT, + env: e2eEnv(), + timeoutMs: 30_000, + }, + ); + expect(platform.exitCode, resultText(platform)).toBe(0); + expect(platform.stdout.trim()).toBe("DGX Spark"); + const nvidia = await host.command("nvidia-smi", [], { + artifactName: "spark-express-nvidia-smi", + env: e2eEnv(), + timeoutMs: 30_000, + }); + expect(nvidia.exitCode, resultText(nvidia)).toBe(0); + + cleanup.add(`remove ${VLLM_CONTAINER}`, () => + removeOwnedVllmContainer(host, "cleanup-spark-express-vllm-container"), + ); + cleanup.add(`remove sandbox ${SANDBOX_NAME}`, () => + cleanupSandbox(host, sandbox, SANDBOX_NAME, { strict: true }), + ); + await cleanupSandbox(host, sandbox, SANDBOX_NAME); + await removeOwnedVllmContainer(host, "preclean-spark-express-vllm-container"); + + progress.phase("activate Spark Express and onboard through the local-model profile"); + const onboard = await host.command( + "bash", + [ + "--noprofile", + "--norc", + "-c", + [ + "set -euo pipefail", + "source scripts/install.sh >/dev/null", + 'activate_express_install "DGX Spark"', + '[[ "${NEMOCLAW_ENABLE_LOCAL_MODEL_PROFILE:-}" == "1" ]]', + '[[ "${NEMOCLAW_LOCAL_MODEL_RUNTIME:-}" == "vllm" ]]', + '[[ -z "${NEMOCLAW_PROVIDER:-}" ]]', + '[[ -z "${NEMOCLAW_MODEL:-}" ]]', + '[[ -z "${NEMOCLAW_VLLM_MODEL:-}" ]]', + "exec node bin/nemoclaw.js onboard --fresh --non-interactive --yes --yes-i-accept-third-party-software", + ].join("\n"), + ], + { + artifactName: "spark-express-vllm-onboard", + cwd: REPO_ROOT, + env: e2eEnv(), + timeoutMs: ONBOARD_TIMEOUT_MS, + }, + ); + expect(onboard.exitCode, resultText(onboard)).toBe(0); + + progress.phase("verify catalog-owned vLLM runtime configuration"); + const inspectionResult = await host.command("docker", ["inspect", VLLM_CONTAINER], { + artifactName: "spark-express-vllm-container-inspect", + env: e2eEnv(), + timeoutMs: 30_000, + }); + expect(inspectionResult.exitCode, resultText(inspectionResult)).toBe(0); + const [inspection] = JSON.parse(inspectionResult.stdout) as VllmContainerInspection[]; + expect(inspection.Config.Image).toBe(plan.recipe.spec.runtime.image); + expect(inspection.Config.Entrypoint).toEqual(["/bin/bash"]); + expect(inspection.Config.Cmd[0]).toBe("-lc"); + assertRecipeCommand(inspection.Config.Cmd[1] ?? "", plan.recipe); + expect(inspection.Config.Labels).toMatchObject({ + "com.nvidia.nemoclaw.managed-vllm": "true", + "com.nvidia.nemoclaw.serving-catalog-digest": plan.catalogDigest, + "com.nvidia.nemoclaw.serving-preset": plan.preset.metadata.id, + "com.nvidia.nemoclaw.serving-preset-digest": plan.presetDigest, + "com.nvidia.nemoclaw.serving-recipe": plan.recipe.metadata.id, + "com.nvidia.nemoclaw.serving-recipe-digest": plan.recipeDigest, + }); + expect(inspection.HostConfig.NetworkMode).toBe(plan.recipe.spec.runtime.networkMode); + expect(inspection.HostConfig.IpcMode).toBe(plan.recipe.spec.runtime.ipcMode); + expect(inspection.HostConfig.ShmSize).toBe(plan.recipe.spec.runtime.sharedMemoryBytes); + expect(inspection.HostConfig.PortBindings["8000/tcp"]).toEqual([ + { HostIp: "127.0.0.1", HostPort: "8000" }, + ]); + expect(inspection.HostConfig.DeviceRequests).toEqual( + expect.arrayContaining([expect.objectContaining({ Count: -1, Capabilities: [["gpu"]] })]), + ); + expect(inspection.Mounts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + Destination: plan.recipe.spec.runtime.modelCache.target, + Type: "bind", + }), + ]), + ); + + progress.phase("prove sandbox inference and unrelated egress denial"); + await expectOpenAiChatThroughSandbox( + sandbox, + SANDBOX_NAME, + plan.recipe.spec.model.servedName, + [], + "spark-express-inference-local-chat", + ); + const denied = await sandbox.execShell( + SANDBOX_NAME, + trustedSandboxShellScript( + "curl -sS -o /dev/null -w '%{http_code}' --max-time 20 https://example.com/", + ), + { + artifactName: "spark-express-unrelated-egress-denied", + env: e2eEnv(), + timeoutMs: 30_000, + }, + ); + expect(denied.exitCode, resultText(denied)).toBe(0); + expect(denied.stdout.trim()).toBe("403"); +}); diff --git a/test/helpers/installer-express-prompt-pty-harness.ts b/test/helpers/installer-express-prompt-pty-harness.ts index ee16af1c0e0..a2a5e883d52 100644 --- a/test/helpers/installer-express-prompt-pty-harness.ts +++ b/test/helpers/installer-express-prompt-pty-harness.ts @@ -64,10 +64,10 @@ classify_dgx_station_release() { printf "%s" "\${EXPRESS_RELEASE_STATE:-generic- station_installer_revision() { printf 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; } station_express_resume_generation() { printf '0123456789abcdef0123456789abcdef'; } bash() { - printf "RESULT NON_INTERACTIVE=%s SUDO_MODE=%s PROVIDER=%s MODEL=%s VLLM_MODEL=%s POLICY=%s YES=%s SANDBOX=%s STATION_EXPRESS=%s\\n" \ + printf "RESULT NON_INTERACTIVE=%s SUDO_MODE=%s PROVIDER=%s MODEL=%s VLLM_MODEL=%s POLICY=%s YES=%s SANDBOX=%s STATION_EXPRESS=%s PROFILE_GATE=%s PROFILE_RUNTIME=%s\\n" \ "\${NON_INTERACTIVE:-}" "\${NEMOCLAW_NON_INTERACTIVE_SUDO_MODE:-}" "\${NEMOCLAW_PROVIDER:-}" "\${NEMOCLAW_MODEL:-}" \ "\${NEMOCLAW_VLLM_MODEL:-}" "\${NEMOCLAW_POLICY_MODE:-}" "\${NEMOCLAW_YES:-}" "\${NEMOCLAW_SANDBOX_NAME:-}" \ - "\${NEMOCLAW_STATION_EXPRESS:-}" + "\${NEMOCLAW_STATION_EXPRESS:-}" "\${NEMOCLAW_ENABLE_LOCAL_MODEL_PROFILE:-}" "\${NEMOCLAW_LOCAL_MODEL_RUNTIME:-}" exit 0 } main "$@" @@ -84,10 +84,10 @@ if [ "\${FORCE_EXPRESS_PROMPT_READ_FAILURE:-}" = "1" ]; then read() { return 1; } fi maybe_offer_express_install -printf "RESULT NON_INTERACTIVE=%s SUDO_MODE=%s PROVIDER=%s MODEL=%s VLLM_MODEL=%s POLICY=%s YES=%s SANDBOX=%s STATION_EXPRESS=%s\\n" \\ +printf "RESULT NON_INTERACTIVE=%s SUDO_MODE=%s PROVIDER=%s MODEL=%s VLLM_MODEL=%s POLICY=%s YES=%s SANDBOX=%s STATION_EXPRESS=%s PROFILE_GATE=%s PROFILE_RUNTIME=%s\\n" \\ "\${NON_INTERACTIVE:-}" "\${NEMOCLAW_NON_INTERACTIVE_SUDO_MODE:-}" "\${NEMOCLAW_PROVIDER:-}" "\${NEMOCLAW_MODEL:-}" \\ "\${NEMOCLAW_VLLM_MODEL:-}" "\${NEMOCLAW_POLICY_MODE:-}" "\${NEMOCLAW_YES:-}" "\${NEMOCLAW_SANDBOX_NAME:-}" \\ - "\${NEMOCLAW_STATION_EXPRESS:-}" + "\${NEMOCLAW_STATION_EXPRESS:-}" "\${NEMOCLAW_ENABLE_LOCAL_MODEL_PROFILE:-}" "\${NEMOCLAW_LOCAL_MODEL_RUNTIME:-}" ''' env = dict(os.environ) env["INSTALLER_UNDER_TEST"] = installer diff --git a/test/install-express-prompt.test.ts b/test/install-express-prompt.test.ts index 35ca7236a51..6c0d10a31b0 100644 --- a/test/install-express-prompt.test.ts +++ b/test/install-express-prompt.test.ts @@ -182,17 +182,16 @@ DGX_COMMIT_ID="d0e99cc"\nDGX_PLATFORM="DGX Server for GALAXY-GB300" expect(result.status, output).toBe(0); expect(output).toMatch(/Detected DGX Spark/); expect(output).toMatch( - /Express install will configure managed vLLM with automatic DGX Spark serving-profile selection/, + /Express install will configure the fixed DGX Spark model and vLLM serving profile/, ); - expect(output).toMatch(/one exactly qualified pretrusted managed cluster topology/); - expect(output).toMatch(/related or ambiguous setup remains untouched and stops installation/); - expect(output).toMatch(/experimental pending physical end-to-end validation/); + expect(output).toMatch(/The serving catalog owns the model, image, port, and vLLM arguments/); + expect(output).toMatch(/dedicated local-model onboarder rejects model and runtime overrides/); expect(output).toMatch(/Sandbox name: my-assistant/); expect(output).toMatch(/Sandbox policy: suggested mode, tier 'balanced'/); expect(output).toMatch(/Run express install/); expect(output).toMatch(/Using express install for DGX Spark/); expect(output).toMatch( - /RESULT NON_INTERACTIVE=1 SUDO_MODE=prompt PROVIDER=install-vllm MODEL= VLLM_MODEL= POLICY=suggested YES=1 SANDBOX=my-assistant/, + /RESULT NON_INTERACTIVE=1 SUDO_MODE=prompt PROVIDER= MODEL= VLLM_MODEL= POLICY=suggested YES=1 SANDBOX=my-assistant STATION_EXPRESS= PROFILE_GATE=1 PROFILE_RUNTIME=vllm/, ); expect(output).toMatch(/STATION_EXPRESS=\s/); }); @@ -212,6 +211,7 @@ DGX_COMMIT_ID="d0e99cc"\nDGX_PLATFORM="DGX Server for GALAXY-GB300" expect(output).toMatch( /RESULT NON_INTERACTIVE=1 SUDO_MODE=prompt PROVIDER=install-vllm MODEL= VLLM_MODEL=custom-qwen3\.6 POLICY=suggested YES=1 SANDBOX=my-assistant/, ); + expect(output).toMatch(/PROFILE_GATE= PROFILE_RUNTIME=/); }); it("preserves an explicit NEMOCLAW_SANDBOX_NAME over the DGX Spark default (#6525)", () => { @@ -223,7 +223,7 @@ DGX_COMMIT_ID="d0e99cc"\nDGX_PLATFORM="DGX Server for GALAXY-GB300" expect(output).toMatch(/Detected DGX Spark/); expect(output).toMatch(/Sandbox name: custom-spark/); expect(output).toMatch( - /RESULT NON_INTERACTIVE=1 SUDO_MODE=prompt PROVIDER=install-vllm MODEL= VLLM_MODEL= POLICY=suggested YES=1 SANDBOX=custom-spark/, + /RESULT NON_INTERACTIVE=1 SUDO_MODE=prompt PROVIDER= MODEL= VLLM_MODEL= POLICY=suggested YES=1 SANDBOX=custom-spark STATION_EXPRESS= PROFILE_GATE=1 PROFILE_RUNTIME=vllm/, ); }); From abbd22601a3a9833d0b5c899fc16b880fd2986a3 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Thu, 6 Aug 2026 19:16:06 -0700 Subject: [PATCH 02/19] docs: repin Spark express prompt asset Signed-off-by: Aaron Erickson --- docs/resources/starter-prompt.md | 6 +++--- test/starter-prompt-docs.test.ts | 11 ++++++++--- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/docs/resources/starter-prompt.md b/docs/resources/starter-prompt.md index 257641dd68e..ca83ffc45ba 100644 --- a/docs/resources/starter-prompt.md +++ b/docs/resources/starter-prompt.md @@ -79,9 +79,9 @@ Set `NEMOCLAW_AGENT=langchain-deepagents-code` for Deep Agents, or use `nemo-dee After the readiness check, load exactly one matching instruction asset before provider selection: -- Confirmed DGX Spark: [DGX Spark Express instructions](https://raw.githubusercontent.com/NVIDIA/NemoClaw/bf46e62f901825f19e570c17f8c870a0eae04fbc/docs/resources/prompt-assets/dgx-spark.md). -- Confirmed DGX Station: [DGX Station installation instructions](https://raw.githubusercontent.com/NVIDIA/NemoClaw/bf46e62f901825f19e570c17f8c870a0eae04fbc/docs/resources/prompt-assets/dgx-station.md). -- Officially detected Windows WSL: [Windows WSL Express instructions](https://raw.githubusercontent.com/NVIDIA/NemoClaw/bf46e62f901825f19e570c17f8c870a0eae04fbc/docs/resources/prompt-assets/windows-wsl.md). +- Confirmed DGX Spark: [DGX Spark Express instructions](https://raw.githubusercontent.com/NVIDIA/NemoClaw/68f74a2ea3fe1c9a73d0186a2b0f23b1b6554172/docs/resources/prompt-assets/dgx-spark.md). +- Confirmed DGX Station: [DGX Station installation instructions](https://raw.githubusercontent.com/NVIDIA/NemoClaw/68f74a2ea3fe1c9a73d0186a2b0f23b1b6554172/docs/resources/prompt-assets/dgx-station.md). +- Officially detected Windows WSL: [Windows WSL Express instructions](https://raw.githubusercontent.com/NVIDIA/NemoClaw/68f74a2ea3fe1c9a73d0186a2b0f23b1b6554172/docs/resources/prompt-assets/windows-wsl.md). Read the matching raw Markdown file completely and follow it before continuing. Do not load a platform asset for any other computer. diff --git a/test/starter-prompt-docs.test.ts b/test/starter-prompt-docs.test.ts index b42b1ff5b60..9b61edb01c9 100644 --- a/test/starter-prompt-docs.test.ts +++ b/test/starter-prompt-docs.test.ts @@ -30,7 +30,7 @@ const repoRoot = path.resolve(__dirname, ".."); const starterPromptMarkdownSource = path.join(repoRoot, "docs", "resources", "starter-prompt.md"); // CI resolves this Git commit and byte-compares its prompt-asset blobs with // the local files. The digests independently assert those same immutable bytes. -const promptAssetRevision = "bf46e62f901825f19e570c17f8c870a0eae04fbc"; +const promptAssetRevision = "68f74a2ea3fe1c9a73d0186a2b0f23b1b6554172"; type PromptAsset = { path: string; @@ -49,7 +49,7 @@ function definePromptAsset(assetPath: string, pinnedSha256: string): PromptAsset const promptAssets = { dgxSpark: definePromptAsset( "docs/resources/prompt-assets/dgx-spark.md", - "806e87f2ae7e4a4be731c7ce3b1ecde9ff8be170563d4338a5f79f76ce25e034", // gitleaks:allow -- pinned prompt-asset SHA-256 + "2d576fbd7231dc23fe3fb25056bc2584596d81e2448a3e023ad9427ab0a49c9f", // gitleaks:allow -- pinned prompt-asset SHA-256 ), dgxStation: definePromptAsset( "docs/resources/prompt-assets/dgx-station.md", @@ -815,7 +815,12 @@ describe("starter prompt docs CTA", () => { ); expect(sparkSource).toContain("nvidia/Qwen3.6-35B-A3B-NVFP4"); - expect(sparkSource).toContain("Leave `NEMOCLAW_VLLM_MODEL` and `NEMOCLAW_MODEL` unset"); + expect(sparkSource).toContain( + "Set `NEMOCLAW_ENABLE_LOCAL_MODEL_PROFILE=1` and `NEMOCLAW_LOCAL_MODEL_RUNTIME=vllm`", + ); + expect(sparkSource).toContain( + "Leave `NEMOCLAW_PROVIDER`, `NEMOCLAW_MODEL`, `NEMOCLAW_VLLM_MODEL`, `NEMOCLAW_VLLM_PORT`, and `NEMOCLAW_VLLM_EXTRA_ARGS_JSON` unset", + ); expect(stationSource).toContain("`nemotron-3-ultra-550b-a55b`"); expect(stationSource).toContain("`nemotron-ultra`"); expect(stationSource).toContain("`deepseek-v4-flash`"); From e7073ef09d9017cc619f60178ed92f7f2ea2374a Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Thu, 6 Aug 2026 19:46:33 -0700 Subject: [PATCH 03/19] feat(installer): add Spark express profile choice Signed-off-by: Aaron Erickson --- ci/platform-matrix.json | 2 +- docs/get-started/prerequisites.mdx | 2 +- .../choose-local-inference-server.mdx | 8 +- .../set-up-vllm-on-two-dgx-sparks.mdx | 29 +++--- docs/inference/set-up-vllm.mdx | 12 +-- docs/reference/platform-support.mdx | 2 +- docs/resources/prompt-assets/dgx-spark.md | 24 +++-- scripts/install.sh | 72 +++++++++++++-- test/e2e/README.md | 7 +- test/e2e/live/spark-express-vllm.test.ts | 88 ++++++++++++++----- .../installer-express-prompt-pty-harness.ts | 16 ++-- test/install-express-prompt.test.ts | 46 ++++++++-- test/starter-prompt-docs.test.ts | 7 +- 13 files changed, 230 insertions(+), 85 deletions(-) diff --git a/ci/platform-matrix.json b/ci/platform-matrix.json index 1f4da43caba..a25fee5f834 100644 --- a/ci/platform-matrix.json +++ b/ci/platform-matrix.json @@ -47,7 +47,7 @@ "status": "tested", "prd_priority": "P1", "ci_tested": true, - "notes": "Use the standard installer and `$$nemoclaw onboard`. DGX Spark Express selects the fixed single-host vLLM profile from the serving catalog. The explicit two-DGX Spark managed-vLLM profile is Experimental, and physical two-node end-to-end validation is pending. For the profile requirements and controls, see [Set Up vLLM on Two DGX Sparks](../inference/local-inference/set-up-vllm-on-two-dgx-sparks). For the validated single-DGX Spark walkthrough with local inference, see the [NVIDIA Spark playbook](https://build.nvidia.com/spark/nemoclaw)." + "notes": "Use the standard installer and `$$nemoclaw onboard`. DGX Spark Express keeps automatic managed-vLLM serving-profile selection as option 1 and offers the fixed single-host catalog profile as option 2. The automatic two-DGX Spark managed-vLLM profile is Experimental, and physical two-node end-to-end validation is pending. For the profile requirements and controls, see [Set Up vLLM on Two DGX Sparks](../inference/local-inference/set-up-vllm-on-two-dgx-sparks). For the validated single-DGX Spark walkthrough with local inference, see the [NVIDIA Spark playbook](https://build.nvidia.com/spark/nemoclaw)." }, { "name": "Windows WSL2", diff --git a/docs/get-started/prerequisites.mdx b/docs/get-started/prerequisites.mdx index 72f2d3597a1..020dc6bcfac 100644 --- a/docs/get-started/prerequisites.mdx +++ b/docs/get-started/prerequisites.mdx @@ -101,7 +101,7 @@ The table comes from [`ci/platform-matrix.json`](https://github.com/NVIDIA/NemoC {/* platform-matrix:begin */} | OS | Container runtime | Status | Notes | |----|-------------------|--------|-------| -| DGX OS (Spark) | Docker | Tested | Use the standard installer and `$$nemoclaw onboard`. DGX Spark Express selects the fixed single-host vLLM profile from the serving catalog. The explicit two-DGX Spark managed-vLLM profile is Experimental, and physical two-node end-to-end validation is pending. For the profile requirements and controls, see [Set Up vLLM on Two DGX Sparks](../inference/local-inference/set-up-vllm-on-two-dgx-sparks). For the validated single-DGX Spark walkthrough with local inference, see the [NVIDIA Spark playbook](https://build.nvidia.com/spark/nemoclaw). | +| DGX OS (Spark) | Docker | Tested | Use the standard installer and `$$nemoclaw onboard`. DGX Spark Express keeps automatic managed-vLLM serving-profile selection as option 1 and offers the fixed single-host catalog profile as option 2. The automatic two-DGX Spark managed-vLLM profile is Experimental, and physical two-node end-to-end validation is pending. For the profile requirements and controls, see [Set Up vLLM on Two DGX Sparks](../inference/local-inference/set-up-vllm-on-two-dgx-sparks). For the validated single-DGX Spark walkthrough with local inference, see the [NVIDIA Spark playbook](https://build.nvidia.com/spark/nemoclaw). | | DGX OS (Station) | Docker | Tested with limitations | Tested with limitations across qualified profiles on one physical DGX Station GB300; see [Additional Setup for DGX Station](additional-setup/dgx-station-preparation) for accepted profiles, the pending no-OTA DGX OS `7.6.x` end-to-end qualification, runtime gates, and current dual-Station and dedicated CI limitations. | | Linux | Docker | Tested | Primary tested path. Ubuntu 24.04 has host-level onboarding validation. A digest-pinned Ubuntu 26.04 userspace lane builds the CLI and runs preflight, installer, and platform contracts on eligible main pushes; Docker-host, AppArmor, Landlock, and live onboarding validation on 26.04 remain pending. Other distros (Ubuntu 22.04, Fedora, Rocky, Alma, NixOS, Arch) may work but are not validated. | | macOS (Apple Silicon) | Colima, Docker Desktop | Tested with limitations | Start the container runtime (Colima or Docker Desktop) before running the installer. When Homebrew is available, OpenShell uses its official formula and the gateway appears in `brew services list` as `openshell`; without Homebrew, NemoClaw uses the standalone OpenShell install and detached gateway fallback. Homebrew Colima users must install both Colima and the Docker CLI (`brew install colima docker`) before `docker info` can work. Xcode Command Line Tools (`xcode-select --install`) are typically required for Node native modules during install. NemoClaw recommends them but does not enforce them during preflight. | diff --git a/docs/inference/choose-local-inference-server.mdx b/docs/inference/choose-local-inference-server.mdx index dcd4544944f..eabda1cb1b0 100644 --- a/docs/inference/choose-local-inference-server.mdx +++ b/docs/inference/choose-local-inference-server.mdx @@ -23,7 +23,7 @@ OpenShell intercepts that traffic and forwards it to the local endpoint configur | Ollama | You want the default local option and want NemoClaw to install, start, or use Ollama on supported hosts. | Appears when Ollama is installed or running, and the wizard can offer installation on supported hosts. | Ollama through the managed local route. | | Existing vLLM | You already run vLLM on `localhost:8000`. | Appears when NemoClaw detects the server. | `/v1/chat/completions`. | | Managed vLLM | You want NemoClaw to pull an image, download model weights, and manage the server container. | Appears by default on DGX Spark and DGX Station, while generic Linux NVIDIA GPU hosts require `NEMOCLAW_EXPERIMENTAL=1` or `NEMOCLAW_PROVIDER=install-vllm`. | `/v1/chat/completions`. | -| Fixed vLLM profile | You need the fixed catalog-selected vLLM model and serving configuration. | DGX Spark Express selects this profile. Direct installation can use the dedicated installer flag. The profile does not appear in the provider menu. | `/v1/chat/completions`. | +| Fixed vLLM profile | You need the fixed catalog-selected vLLM model and serving configuration. | Appears as option 2 in DGX Spark Express. Direct installation can use the dedicated installer flag. The profile does not appear in the provider menu. | `/v1/chat/completions`. | | Managed llama.cpp | You want NemoClaw to acquire a verified GGUF file and manage an authenticated llama.cpp container. | Experimental. Appears on a qualified DGX Spark host and supports explicit non-interactive selection. | `/v1/chat/completions`. | | NVIDIA NIM | You want NemoClaw to pull and manage a validated NIM container on a NIM-capable NVIDIA GPU. | Experimental and requires `NEMOCLAW_EXPERIMENTAL=1`. | `/v1/chat/completions`. | @@ -33,7 +33,7 @@ OpenShell intercepts that traffic and forwards it to the local endpoint configur |---|---|---|---| | Existing vLLM | You already run vLLM on `localhost:8000`. | Appears when NemoClaw detects the server. | `/v1/chat/completions`. | | Managed vLLM | You want NemoClaw to pull an image, download model weights, and manage the server container. | Appears by default on DGX Spark and DGX Station, while generic Linux NVIDIA GPU hosts require `NEMOCLAW_EXPERIMENTAL=1` or `NEMOCLAW_PROVIDER=install-vllm`. | `/v1/chat/completions`. | -| Fixed vLLM profile | You need the fixed catalog-selected vLLM model and serving configuration. | DGX Spark Express selects this profile. Direct installation can use the dedicated installer flag. The profile does not appear in the provider menu. | `/v1/chat/completions`. | +| Fixed vLLM profile | You need the fixed catalog-selected vLLM model and serving configuration. | Appears as option 2 in DGX Spark Express. Direct installation can use the dedicated installer flag. The profile does not appear in the provider menu. | `/v1/chat/completions`. | | Managed llama.cpp | You want NemoClaw to acquire a verified GGUF file and manage an authenticated llama.cpp container. | Experimental. Appears on a qualified DGX Spark host and supports explicit non-interactive selection. | `/v1/chat/completions`. | | NVIDIA NIM | You want NemoClaw to pull and manage a validated NIM container on a NIM-capable NVIDIA GPU. | Experimental and requires `NEMOCLAW_EXPERIMENTAL=1`. | `/v1/chat/completions`. | @@ -67,7 +67,7 @@ Refer to [Set Up vLLM](set-up-vllm). Use this feature-gated path to install the fixed catalog-selected vLLM model and runtime combination. The installer does not accept a provider or model override for this profile. -DGX Spark Express enables this path and selects vLLM automatically. +DGX Spark Express offers this path as option 2 after the existing automatic managed-vLLM option. Before you start, confirm these prerequisites: @@ -103,7 +103,7 @@ curl -fsSL https://www.nvidia.com/nemoclaw.sh | \ ``` The flag enables the dedicated vLLM profile gate, disables Express selection, and selects the matching catalog recipe. -DGX Spark Express enables the same profile gate and selects the vLLM runtime. +Selecting Express option 2 enables the same profile gate and selects the vLLM runtime. Do not combine this path with `NEMOCLAW_PROVIDER` or `NEMOCLAW_MODEL`. The profile rejects `NEMOCLAW_VLLM_MODEL`, `NEMOCLAW_VLLM_PORT`, and `NEMOCLAW_VLLM_EXTRA_ARGS_JSON` before installation. diff --git a/docs/inference/set-up-vllm-on-two-dgx-sparks.mdx b/docs/inference/set-up-vllm-on-two-dgx-sparks.mdx index 3ca289c9c35..4d518bf54eb 100644 --- a/docs/inference/set-up-vllm-on-two-dgx-sparks.mdx +++ b/docs/inference/set-up-vllm-on-two-dgx-sparks.mdx @@ -10,7 +10,7 @@ content: type: "how_to" --- -Use this workflow to request and qualify two trusted DGX Spark systems for distributed vLLM serving. +Use this workflow to let DGX Spark Express qualify two trusted DGX Spark systems for distributed vLLM serving. The profile serves DeepSeek V4 Flash 0731 through the managed `inference.local` route. @@ -20,9 +20,10 @@ Use it only for evaluation until the required physical validation is complete. ## Understand Profile Selection -DGX Spark Express selects the fixed single-host local model profile and does not select this distributed profile automatically. -Request this profile explicitly with `NEMOCLAW_SERVING_PRESET`. -The distributed detector derives one exact counterpart from each qualified private `/30` rail when you do not set an exact peer. +DGX Spark Express attempts automatic profile selection only after you keep option 1, the default managed-vLLM path. +Option 2 selects the fixed single-host catalog profile and does not attempt distributed selection. +A provider other than `install-vllm`, an existing local inference route, `NEMOCLAW_MODEL`, `NEMOCLAW_VLLM_MODEL`, or `NEMOCLAW_VLLM_EXTRA_ARGS_JSON` remains authoritative. +Without explicit inference intent, the detector derives one exact counterpart from each qualified private `/30` rail. It does not scan a subnet, add SSH host keys, add authorized keys, or change SSH configuration. Both derived addresses must already have strict public-key SSH trust. @@ -35,7 +36,8 @@ Declining setup creates no binding. After confirmation, NemoClaw attempts to remove every binding when setup fails or the durable runtime receipt is committed. If cleanup cannot be confirmed, a new managed-vLLM install stops before mutation and preserves the claims for explicit recovery or removal. -When no matching cluster qualifies, the explicit distributed request stops before the image or model download starts. +When no matching cluster qualifies during automatic selection, NemoClaw retains the existing single-DGX Spark managed vLLM path. +This ordinary no-match result occurs before the distributed image or model download starts. Existing related runtime state is not an ordinary no-match result. NemoClaw stops when either host has a related vLLM or DGX Station container, an ambiguous runtime inspection, or a listener on port `8000` or `25000`. @@ -46,12 +48,12 @@ NemoClaw leaves the existing files, containers, and listeners untouched instead Set `NEMOCLAW_MANAGED_CLUSTER_PEERS` to a comma-separated list of exact pretrusted peers. The current profile declares `nodeCount: 2`, so its strict selection requires one peer. Set `NEMOCLAW_SERVING_PRESET=vllm.dgx-spark-gb10.dual.deepseek-v4-flash-0731` to request the exact serving profile. -Either setting makes qualification strict, so a missed requirement stops setup. +Either setting makes qualification strict, so a missed requirement stops setup instead of selecting the single-DGX Spark profile. ## Prepare Both Systems Prepare both DGX Spark systems before you run the installer. -The distributed path requires all of these conditions on both systems: +The automatic path requires all of these conditions on both systems: - The host is an `arm64` DGX Spark with exactly one GB10 GPU. - The current controller is a non-root user whose user ID (UID) matches the local host probe. @@ -114,9 +116,9 @@ The detector qualifies both ConnectX-7 rails. This profile revision binds vLLM, NCCL, Gloo, and tensor-parallel traffic to the first qualified rail in deterministic interface order. Do not interpret two qualified `200,000 Mbps` rails as measured aggregate serving throughput. -## Request the Distributed Profile +## Run Automatic Express Installation -Run the installer on the system that should become the head. +Run the interactive installer on the system that should become the head. ```bash curl -fsSL https://www.nvidia.com/nemoclaw.sh | \ @@ -129,16 +131,11 @@ curl -fsSL https://www.nvidia.com/nemoclaw.sh | \ NEMOCLAW_AGENT=langchain-deepagents-code \ - NEMOCLAW_NON_INTERACTIVE=1 \ - NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 \ - NEMOCLAW_PROVIDER=install-vllm \ - NEMOCLAW_SERVING_PRESET=vllm.dgx-spark-gb10.dual.deepseek-v4-flash-0731 \ - NEMOCLAW_SANDBOX_NAME=my-assistant \ bash ``` -NemoClaw derives the counterpart from the qualified direct rails and stops when it cannot select exactly one peer. -Before image, model, or container mutation, NemoClaw prints the selected topology, immutable image, pinned model revision, cache paths, and worker-first launch order. +Keep option 1, **Managed vLLM with automatic serving-profile selection**, then accept the **Run express install with these settings?** prompt. +DGX Spark Express prints the selected topology, immutable image, pinned model revision, cache paths, and worker-first launch order before image, model, or container mutation. For optional authenticated model downloads, follow [Authenticate Hugging Face Downloads](set-up-vllm#authenticate-hugging-face-downloads) before you start the installer. The public model can download anonymously, but Hugging Face can rate-limit anonymous transfers. diff --git a/docs/inference/set-up-vllm.mdx b/docs/inference/set-up-vllm.mdx index ef532f26ae2..52aa03e7c8c 100644 --- a/docs/inference/set-up-vllm.mdx +++ b/docs/inference/set-up-vllm.mdx @@ -257,13 +257,13 @@ On DGX Station, accepting the installer express prompt selects `NEMOCLAW_VLLM_MO Express uses the single-Station Ultra recipe unless a trusted reciprocal pair qualifies for distributed serving. Refer to [Set Up vLLM on Two DGX Stations](set-up-vllm-on-two-dgx-stations) for pair qualification, lifecycle receipts, runtime topology, and network controls. -On DGX Spark, accepting the installer Express prompt selects the fixed local model profile for vLLM. -The serving catalog supplies the pinned model, runtime image, port, and serve arguments. -The installer rejects provider and model overrides. -The dedicated local-model onboarder rejects vLLM model, port, and serve-argument overrides before it starts the managed container. -Set `NEMOCLAW_VLLM_MODEL` only when you intend to use the customizable managed-vLLM path instead of the fixed profile. +On DGX Spark, Express keeps automatic managed-vLLM serving-profile selection as option 1, the default. +It offers the fixed local vLLM profile as option 2. +When you select option 2, the serving catalog supplies the pinned model, runtime image, port, and serve arguments. +The installer rejects provider and model overrides for that fixed profile, and the dedicated onboarder rejects vLLM model, port, and serve-argument overrides before it starts the managed container. +Explicit `NEMOCLAW_MODEL` or `NEMOCLAW_VLLM_MODEL` intent retains the customizable managed-vLLM path and skips the fixed-profile choice. Refer to [Choose a Local Inference Server](choose-local-inference-server#install-a-fixed-local-model-profile) for fixed-profile installation and verification. -Refer to [Set Up vLLM on Two DGX Sparks](set-up-vllm-on-two-dgx-sparks) when you need to request the Experimental distributed profile explicitly. +Refer to [Set Up vLLM on Two DGX Sparks](set-up-vllm-on-two-dgx-sparks) for automatic and explicit distributed-profile selection, profile requirements, and cleanup behavior. To select the existing `deepseek-v4-flash` recipe while retaining the same one-confirmation express flow, run: diff --git a/docs/reference/platform-support.mdx b/docs/reference/platform-support.mdx index 3c91f873e98..c3cfea5a737 100644 --- a/docs/reference/platform-support.mdx +++ b/docs/reference/platform-support.mdx @@ -78,7 +78,7 @@ For install requirements and the shorter setup-oriented platform view, refer to {/* platform-matrix-full:begin */} | OS | Container runtime | Status | PRD priority | CI | Notes | |----|-------------------|--------|--------------|----|-------| -| DGX OS (Spark) | Docker | Tested | P1 | Yes | Use the standard installer and `$$nemoclaw onboard`. DGX Spark Express selects the fixed single-host vLLM profile from the serving catalog. The explicit two-DGX Spark managed-vLLM profile is Experimental, and physical two-node end-to-end validation is pending. For the profile requirements and controls, see [Set Up vLLM on Two DGX Sparks](../inference/local-inference/set-up-vllm-on-two-dgx-sparks). For the validated single-DGX Spark walkthrough with local inference, see the [NVIDIA Spark playbook](https://build.nvidia.com/spark/nemoclaw). | +| DGX OS (Spark) | Docker | Tested | P1 | Yes | Use the standard installer and `$$nemoclaw onboard`. DGX Spark Express keeps automatic managed-vLLM serving-profile selection as option 1 and offers the fixed single-host catalog profile as option 2. The automatic two-DGX Spark managed-vLLM profile is Experimental, and physical two-node end-to-end validation is pending. For the profile requirements and controls, see [Set Up vLLM on Two DGX Sparks](../inference/local-inference/set-up-vllm-on-two-dgx-sparks). For the validated single-DGX Spark walkthrough with local inference, see the [NVIDIA Spark playbook](https://build.nvidia.com/spark/nemoclaw). | | DGX OS (Station) | Docker | Tested with limitations | P1 | No | The PRD marks this platform as P1. Physical validation on one DGX Station GB300 covers generic Ubuntu 24.04 ARM64, stock DGX OS `7.5.0`, the April 2026 NVIDIA Colossus BaseOS profile, and the June 2026 NVIDIA AI Developer Tools profile. A physical no-OTA DGX OS `7.6.0` host provided the release and hardware profile used for its stable workstation-family classifier and passed read-only eligibility and runtime-command preflight. Full Station Express end-to-end qualification for the accepted no-OTA DGX OS `7.6.x` profile is pending. The profile remains subject to the same physical GB300, driver, ECC, Docker, CDI, and container GPU validation. Clean-host end-to-end validation passed on generic Ubuntu and Colossus BaseOS; stock DGX OS and AI Developer Tools completed Station Express validation. The DGX OS `7.5.0` run used released OpenShell `0.0.85`, local Nemotron Ultra serving, sandbox `cuInit(0)`, and a Hermes write/read file-tool task. A dual-Station configuration has not been validated, and dedicated CI coverage is not available. Direct-GPU policies expose only the exact read-only BDF directory for each discovered display-class PCI device with NVIDIA vendor ID (`0x10de`) and GB300 device ID (`0x31c2` or `0x31c3`) plus required existing topology and module paths; they do not expose `/sys`, the PCI parent subtree, or sysfs write access. During physical validation, reads of `/sys/fs/cgroup/cgroup.controllers` and `/sys/class/net/lo/address` remained denied. For canonical hardware qualification, image requirements, preparation, repair limits, reboot handoff, and the explicit temporary metadata override, see [Prepare DGX Station to Install NemoClaw](../get-started/additional-setup/dgx-station-preparation). | | Linux | Docker | Tested | P0 | Yes | Primary tested path. Ubuntu 24.04 has host-level onboarding validation. A digest-pinned Ubuntu 26.04 userspace lane builds the CLI and runs preflight, installer, and platform contracts on eligible main pushes; Docker-host, AppArmor, Landlock, and live onboarding validation on 26.04 remain pending. Other distros (Ubuntu 22.04, Fedora, Rocky, Alma, NixOS, Arch) may work but are not validated. | | macOS (Apple Silicon) | Colima, Docker Desktop | Tested with limitations | P0 | Yes | Start the container runtime (Colima or Docker Desktop) before running the installer. When Homebrew is available, OpenShell uses its official formula and the gateway appears in `brew services list` as `openshell`; without Homebrew, NemoClaw uses the standalone OpenShell install and detached gateway fallback. Homebrew Colima users must install both Colima and the Docker CLI (`brew install colima docker`) before `docker info` can work. Xcode Command Line Tools (`xcode-select --install`) are typically required for Node native modules during install. NemoClaw recommends them but does not enforce them during preflight. | diff --git a/docs/resources/prompt-assets/dgx-spark.md b/docs/resources/prompt-assets/dgx-spark.md index cb907e42e90..e7667b463e1 100644 --- a/docs/resources/prompt-assets/dgx-spark.md +++ b/docs/resources/prompt-assets/dgx-spark.md @@ -7,19 +7,29 @@ Use these instructions only after hardware detection confirms DGX Spark. -Explain that Express keeps the selected agent, uses the fixed DGX Spark vLLM profile, leaves optional setup at its defaults, and downloads the catalog-selected container and model. -Include the third-party-software notice, then ask: "Run Express install with these settings?" -Choices: +Explain that Express keeps the selected agent, leaves optional setup at its defaults, and offers two DGX Spark inference setups. +Ask the user to choose: -1. Yes, use the DGX Spark Express defaults. -2. No, let me choose the runtime and model. +1. Managed vLLM with automatic serving-profile selection. This is the default and preserves the current Express behavior. +2. `nvidia/Qwen3.6-35B-A3B-NVFP4` with the fixed catalog-backed vLLM profile. -If Express is selected: +After the user chooses, include the third-party-software notice and ask: "Run Express install with these settings?" + +For option 1: + +- Set `NEMOCLAW_PROVIDER=install-vllm`. +- Leave `NEMOCLAW_ENABLE_LOCAL_MODEL_PROFILE`, `NEMOCLAW_LOCAL_MODEL_RUNTIME`, `NEMOCLAW_MODEL`, and `NEMOCLAW_VLLM_MODEL` unset. +- Explain that the installed release performs automatic DGX Spark serving-profile selection. + +For option 2: - Set `NEMOCLAW_ENABLE_LOCAL_MODEL_PROFILE=1` and `NEMOCLAW_LOCAL_MODEL_RUNTIME=vllm`. - Leave `NEMOCLAW_PROVIDER`, `NEMOCLAW_MODEL`, `NEMOCLAW_VLLM_MODEL`, `NEMOCLAW_VLLM_PORT`, and `NEMOCLAW_VLLM_EXTRA_ARGS_JSON` unset. - Explain that the serving catalog selects the fixed model, runtime image, port, and vLLM arguments. -- The current DGX Spark vLLM profile serves `nvidia/Qwen3.6-35B-A3B-NVFP4`. +- Explain that the fixed profile serves `nvidia/Qwen3.6-35B-A3B-NVFP4`. + +For either accepted Express option: + - Set `NEMOCLAW_AGENT` to the agent already selected in the starter prompt. - Set `NEMOCLAW_NON_INTERACTIVE=1`, `NEMOCLAW_NON_INTERACTIVE_SUDO_MODE=prompt`, `NEMOCLAW_YES=1`, and `NEMOCLAW_POLICY_MODE=suggested`. - Set `NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1` when Express is accepted. diff --git a/scripts/install.sh b/scripts/install.sh index 9506c61b498..c708ba34282 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -4300,6 +4300,40 @@ resolve_pending_express_wsl_provider() { fi } +select_spark_express_inference() { + local input_fd="${1:-0}" + local reply="" + + unset _SPARK_EXPRESS_INFERENCE_SELECTION + if [ -n "${NEMOCLAW_MODEL:-}" ] || [ -n "${NEMOCLAW_VLLM_MODEL:-}" ]; then + return 0 + fi + + printf " Choose the DGX Spark inference setup:\n" + printf " 1) Managed vLLM with automatic serving-profile selection (default)\n" + printf " 2) Qwen3.6 35B-A3B NVFP4 with the fixed catalog-backed vLLM profile\n" + while true; do + printf " Choose 1 or 2 [1]: " + if ! IFS= read -r -u "$input_fd" reply; then + return 1 + fi + reply="$(printf "%s" "$reply" | tr '[:upper:]' '[:lower:]')" + case "$reply" in + "" | 1) + _SPARK_EXPRESS_INFERENCE_SELECTION="managed-vllm" + return 0 + ;; + 2) + _SPARK_EXPRESS_INFERENCE_SELECTION="fixed-vllm" + return 0 + ;; + *) + warn "Choose 1 or 2." + ;; + esac + done +} + activate_express_install() { local platform="$1" _SELECTED_EXPRESS_PLATFORM="$platform" @@ -4315,14 +4349,21 @@ activate_express_install() { case "$platform" in "DGX Spark") export NEMOCLAW_SANDBOX_NAME="${NEMOCLAW_SANDBOX_NAME:-my-assistant}" - if [ -n "${NEMOCLAW_VLLM_MODEL:-}" ]; then - unset NEMOCLAW_ENABLE_LOCAL_MODEL_PROFILE NEMOCLAW_LOCAL_MODEL_RUNTIME - export NEMOCLAW_PROVIDER=install-vllm - export NEMOCLAW_VLLM_MODEL - else + if [ "${_SPARK_EXPRESS_INFERENCE_SELECTION:-managed-vllm}" = "fixed-vllm" ]; then + if [ -n "${NEMOCLAW_PROVIDER:-}" ] || [ -n "${NEMOCLAW_MODEL:-}" ] \ + || [ -n "${NEMOCLAW_VLLM_MODEL:-}" ] || [ -n "${NEMOCLAW_VLLM_PORT:-}" ] \ + || [ -n "${NEMOCLAW_VLLM_EXTRA_ARGS_JSON:-}" ]; then + error "The fixed DGX Spark vLLM profile does not accept provider, model, port, or serve-argument overrides." + fi unset NEMOCLAW_PROVIDER export NEMOCLAW_ENABLE_LOCAL_MODEL_PROFILE=1 export NEMOCLAW_LOCAL_MODEL_RUNTIME=vllm + else + unset NEMOCLAW_ENABLE_LOCAL_MODEL_PROFILE NEMOCLAW_LOCAL_MODEL_RUNTIME + export NEMOCLAW_PROVIDER=install-vllm + if [ -n "${NEMOCLAW_VLLM_MODEL:-}" ]; then + export NEMOCLAW_VLLM_MODEL + fi fi ;; "DGX Station") @@ -4758,12 +4799,18 @@ describe_express_install() { case "$platform" in "DGX Spark") - if [ -n "${NEMOCLAW_VLLM_MODEL:-}" ]; then + if [ "${_SPARK_EXPRESS_INFERENCE_SELECTION:-managed-vllm}" = "fixed-vllm" ]; then + inference_summary="Qwen3.6 35B-A3B NVFP4 with the fixed catalog-backed vLLM profile" + inference_disclosure="The serving catalog owns the model, image, port, and vLLM arguments. The dedicated local-model onboarder rejects model and runtime overrides before starting its managed container." + elif [ -n "${NEMOCLAW_VLLM_MODEL:-}" ]; then inference_summary="managed local vLLM with model ${NEMOCLAW_VLLM_MODEL}" inference_disclosure="The explicit model remains authoritative, so this run keeps the existing single-host DGX Spark profile. Managed vLLM pulls the configured image/model and runs only its dedicated container." + elif [ -n "${NEMOCLAW_MODEL:-}" ]; then + inference_summary="managed local vLLM with explicit model intent ${NEMOCLAW_MODEL}" + inference_disclosure="The explicit model remains authoritative, so this run keeps the customizable managed-vLLM path and does not offer the fixed profile." else - inference_summary="the fixed DGX Spark model and vLLM serving profile" - inference_disclosure="The serving catalog owns the model, image, port, and vLLM arguments. The dedicated local-model onboarder rejects model and runtime overrides before starting its managed container." + inference_summary="managed vLLM with automatic DGX Spark serving-profile selection" + inference_disclosure="With no explicit inference intent or related runtime, one exactly qualified pretrusted managed cluster topology selects a matching pinned distributed profile. An ordinary no-match keeps the existing single-host DGX Spark profile; any related or ambiguous setup remains untouched and stops installation. Managed vLLM pulls the selected image/model and runs only its dedicated containers. The selected distributed profile is experimental pending physical end-to-end validation." fi sandbox_summary="${NEMOCLAW_SANDBOX_NAME:-my-assistant}" ;; @@ -4944,6 +4991,10 @@ maybe_offer_express_install() { local reply="" if [ -t 0 ]; then info "Detected ${platform}." + if [ "$platform" = "DGX Spark" ] && ! select_spark_express_inference 0; then + info "Skipping express install (unable to read from TTY)." + return 0 + fi describe_express_install "$platform" printf " Run express install with these settings? [Y/n]: " if ! IFS= read -r reply; then @@ -4957,6 +5008,11 @@ maybe_offer_express_install() { fi elif { exec 3/dev/null; then info "Detected ${platform}." + if [ "$platform" = "DGX Spark" ] && ! select_spark_express_inference 3; then + exec 3<&- + info "Skipping express install (unable to read from TTY)." + return 0 + fi describe_express_install "$platform" printf " Run express install with these settings? [Y/n]: " if ! IFS= read -r reply <&3; then diff --git a/test/e2e/README.md b/test/e2e/README.md index dbe27d23d7a..42df11b8fdb 100644 --- a/test/e2e/README.md +++ b/test/e2e/README.md @@ -677,9 +677,10 @@ npm run test:e2e-phases:check ### DGX Spark Express vLLM -`spark-express-vllm.test.ts` is a physical-host qualification for the catalog-backed Express path. +`spark-express-vllm.test.ts` is a physical-host qualification for the second DGX Spark Express inference option, the catalog-backed fixed vLLM profile. It requires a qualified NVIDIA DGX Spark with Docker, NVIDIA Container Toolkit, OpenShell prerequisites, enough storage for the pinned image and model, and no unrelated `nemoclaw-vllm` container. -The test preserves the Hugging Face cache but deletes its dedicated sandbox and owned vLLM container. +The test fails closed when that container name already exists. +It preserves the Hugging Face cache but deletes its dedicated sandbox and only the exact container ID created during the run. Run the target from a clean candidate checkout on the Spark host: @@ -692,7 +693,7 @@ npx tsx tools/e2e/live-vitest-invocation.mts run \ --test-path test/e2e/live/spark-express-vllm.test.ts ``` -The passing target proves that Express selects the fixed vLLM preset and recipe, the managed container carries exact catalog provenance, `inference.local` completes a chat request, and unrelated sandbox egress receives an HTTP `403` response. +The passing target proves that Express option 2 selects the fixed vLLM preset and recipe, the managed container carries exact catalog provenance and the exact catalog-derived serve command, `inference.local` completes a chat request, and unrelated sandbox egress receives an HTTP `403` response. The checker preserves coverage for every file under `test/e2e/live/` and adds workflow-selected integration files from the authoritative shared-job planner. diff --git a/test/e2e/live/spark-express-vllm.test.ts b/test/e2e/live/spark-express-vllm.test.ts index e4711402e79..d7d8666ed99 100644 --- a/test/e2e/live/spark-express-vllm.test.ts +++ b/test/e2e/live/spark-express-vllm.test.ts @@ -4,7 +4,9 @@ /** Exercises the catalog-backed DGX Spark Express vLLM path on physical hardware. */ import { loadServingCatalog } from "../../../src/lib/inference/serving/catalog-loader.ts"; -import type { HostLocalInferenceServingRecipe } from "../../../src/lib/inference/serving/types.ts"; +import { materializeHostLocalVllmSelection } from "../../../src/lib/inference/serving/host-local-vllm-selection.ts"; +import { detectVllmProfile } from "../../../src/lib/inference/vllm.ts"; +import { buildVllmServeCommand } from "../../../src/lib/inference/vllm-models.ts"; import { LOCAL_MODEL_PROFILE_ENABLED_ENV, LOCAL_MODEL_PROFILE_RUNTIME_ENV, @@ -28,6 +30,7 @@ const TEST_TIMEOUT_MS = 65 * 60_000; const ONBOARD_TIMEOUT_MS = 55 * 60_000; interface VllmContainerInspection { + readonly Id: string; readonly Config: { readonly Cmd: string[]; readonly Entrypoint: string[]; @@ -76,7 +79,26 @@ function vllmProfilePlan() { return plan; } -async function removeOwnedVllmContainer(host: HostCliClient, artifactName: string): Promise { +async function assertVllmContainerAbsent(host: HostCliClient): Promise { + const result = await host.command("docker", ["inspect", VLLM_CONTAINER], { + artifactName: "preflight-spark-express-vllm-container", + env: e2eEnv(), + timeoutMs: 30_000, + }); + expect( + result.exitCode, + `Refusing to replace a pre-existing ${VLLM_CONTAINER} container.\n${resultText(result)}`, + ).not.toBe(0); +} + +async function removeExactVllmContainer( + host: HostCliClient, + containerId: string, + artifactName: string, +): Promise { + if (!/^[a-f0-9]{64}$/u.test(containerId)) { + throw new Error("cleanup requires the exact full Docker container ID created by this test"); + } const result = await host.command( "bash", [ @@ -86,41 +108,47 @@ async function removeOwnedVllmContainer(host: HostCliClient, artifactName: strin [ "set -euo pipefail", `container=${VLLM_CONTAINER}`, - 'if ! label="$(docker inspect --format \'{{ index .Config.Labels "com.nvidia.nemoclaw.managed-vllm" }}\' "$container" 2>/dev/null)"; then exit 0; fi', + 'expected_id="$1"', + 'if ! current_id="$(docker inspect --format \'{{.Id}}\' "$container" 2>/dev/null)"; then exit 0; fi', + '[[ "$current_id" == "$expected_id" ]] || { echo "refusing to remove a replacement $container container" >&2; exit 70; }', + 'label="$(docker inspect --format \'{{ index .Config.Labels "com.nvidia.nemoclaw.managed-vllm" }}\' "$expected_id")"', '[[ "$label" == "true" ]] || { echo "refusing to remove an unmanaged $container container" >&2; exit 70; }', - 'docker rm -f "$container" >/dev/null', + 'docker rm -f "$expected_id" >/dev/null', ].join("\n"), + "spark-express-vllm-cleanup", + containerId, ], { artifactName, env: e2eEnv(), timeoutMs: 120_000 }, ); expect(result.exitCode, resultText(result)).toBe(0); } -function assertRecipeCommand(command: string, recipe: HostLocalInferenceServingRecipe): void { - expect(command).toContain(`vllm serve ${recipe.spec.model.id}`); - expect(command).toContain(`--revision ${recipe.spec.model.revision}`); - expect(command).toContain(`--served-model-name ${recipe.spec.model.servedName}`); - expect(command).not.toContain("pip install"); - for (const argument of recipe.spec.serve.arguments) { - expect(command).toContain(argument.name); - expect(command).toContain( - argument.value === undefined ? argument.name : String(argument.value), - ); - } -} - -test("DGX Spark Express materializes the fixed vLLM profile and routes sandbox inference", { +test("DGX Spark Express option 2 materializes the fixed vLLM profile and routes sandbox inference", { timeout: TEST_TIMEOUT_MS, meta: { e2ePhases: [ "qualify the physical DGX Spark host", - "activate Spark Express and onboard through the local-model profile", + "select Spark Express option 2 and onboard through the local-model profile", "verify catalog-owned vLLM runtime configuration", "prove sandbox inference and unrelated egress denial", ], }, }, async ({ artifacts, cleanup, host, progress, sandbox, skip }) => { const plan = vllmProfilePlan(); + const baseProfile = detectVllmProfile({ platform: "spark" }); + if (!baseProfile) throw new Error("the DGX Spark vLLM base profile is unavailable"); + const materialized = materializeHostLocalVllmSelection( + { + outcome: "selected", + selection: "explicit", + catalogDigest: plan.catalogDigest, + presetDigest: plan.presetDigest, + recipeDigest: plan.recipeDigest, + preset: plan.preset, + recipe: plan.recipe, + }, + baseProfile, + ); await artifacts.target.declare({ id: "spark-express-vllm", boundary: @@ -159,16 +187,19 @@ test("DGX Spark Express materializes the fixed vLLM profile and routes sandbox i }); expect(nvidia.exitCode, resultText(nvidia)).toBe(0); + let createdContainerId: string | null = null; cleanup.add(`remove ${VLLM_CONTAINER}`, () => - removeOwnedVllmContainer(host, "cleanup-spark-express-vllm-container"), + createdContainerId + ? removeExactVllmContainer(host, createdContainerId, "cleanup-spark-express-vllm-container") + : Promise.resolve(), ); cleanup.add(`remove sandbox ${SANDBOX_NAME}`, () => cleanupSandbox(host, sandbox, SANDBOX_NAME, { strict: true }), ); await cleanupSandbox(host, sandbox, SANDBOX_NAME); - await removeOwnedVllmContainer(host, "preclean-spark-express-vllm-container"); + await assertVllmContainerAbsent(host); - progress.phase("activate Spark Express and onboard through the local-model profile"); + progress.phase("select Spark Express option 2 and onboard through the local-model profile"); const onboard = await host.command( "bash", [ @@ -178,6 +209,10 @@ test("DGX Spark Express materializes the fixed vLLM profile and routes sandbox i [ "set -euo pipefail", "source scripts/install.sh >/dev/null", + "exec 9<<<'2'", + "select_spark_express_inference 9", + "exec 9<&-", + '[[ "${_SPARK_EXPRESS_INFERENCE_SELECTION:-}" == "fixed-vllm" ]]', 'activate_express_install "DGX Spark"', '[[ "${NEMOCLAW_ENABLE_LOCAL_MODEL_PROFILE:-}" == "1" ]]', '[[ "${NEMOCLAW_LOCAL_MODEL_RUNTIME:-}" == "vllm" ]]', @@ -194,7 +229,6 @@ test("DGX Spark Express materializes the fixed vLLM profile and routes sandbox i timeoutMs: ONBOARD_TIMEOUT_MS, }, ); - expect(onboard.exitCode, resultText(onboard)).toBe(0); progress.phase("verify catalog-owned vLLM runtime configuration"); const inspectionResult = await host.command("docker", ["inspect", VLLM_CONTAINER], { @@ -202,12 +236,18 @@ test("DGX Spark Express materializes the fixed vLLM profile and routes sandbox i env: e2eEnv(), timeoutMs: 30_000, }); + if (inspectionResult.exitCode === 0) { + const [candidate] = JSON.parse(inspectionResult.stdout) as VllmContainerInspection[]; + if (candidate && /^[a-f0-9]{64}$/u.test(candidate.Id)) createdContainerId = candidate.Id; + } + expect(onboard.exitCode, resultText(onboard)).toBe(0); expect(inspectionResult.exitCode, resultText(inspectionResult)).toBe(0); const [inspection] = JSON.parse(inspectionResult.stdout) as VllmContainerInspection[]; + expect(inspection.Id).toBe(createdContainerId); expect(inspection.Config.Image).toBe(plan.recipe.spec.runtime.image); expect(inspection.Config.Entrypoint).toEqual(["/bin/bash"]); expect(inspection.Config.Cmd[0]).toBe("-lc"); - assertRecipeCommand(inspection.Config.Cmd[1] ?? "", plan.recipe); + expect(inspection.Config.Cmd[1]).toBe(buildVllmServeCommand(materialized.model, e2eEnv())); expect(inspection.Config.Labels).toMatchObject({ "com.nvidia.nemoclaw.managed-vllm": "true", "com.nvidia.nemoclaw.serving-catalog-digest": plan.catalogDigest, diff --git a/test/helpers/installer-express-prompt-pty-harness.ts b/test/helpers/installer-express-prompt-pty-harness.ts index a2a5e883d52..3e232eace25 100644 --- a/test/helpers/installer-express-prompt-pty-harness.ts +++ b/test/helpers/installer-express-prompt-pty-harness.ts @@ -64,10 +64,11 @@ classify_dgx_station_release() { printf "%s" "\${EXPRESS_RELEASE_STATE:-generic- station_installer_revision() { printf 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; } station_express_resume_generation() { printf '0123456789abcdef0123456789abcdef'; } bash() { - printf "RESULT NON_INTERACTIVE=%s SUDO_MODE=%s PROVIDER=%s MODEL=%s VLLM_MODEL=%s POLICY=%s YES=%s SANDBOX=%s STATION_EXPRESS=%s PROFILE_GATE=%s PROFILE_RUNTIME=%s\\n" \ + printf "RESULT NON_INTERACTIVE=%s SUDO_MODE=%s PROVIDER=%s MODEL=%s VLLM_MODEL=%s POLICY=%s YES=%s SANDBOX=%s STATION_EXPRESS=%s PROFILE_GATE=%s PROFILE_RUNTIME=%s SPARK_SELECTION=%s\\n" \ "\${NON_INTERACTIVE:-}" "\${NEMOCLAW_NON_INTERACTIVE_SUDO_MODE:-}" "\${NEMOCLAW_PROVIDER:-}" "\${NEMOCLAW_MODEL:-}" \ "\${NEMOCLAW_VLLM_MODEL:-}" "\${NEMOCLAW_POLICY_MODE:-}" "\${NEMOCLAW_YES:-}" "\${NEMOCLAW_SANDBOX_NAME:-}" \ - "\${NEMOCLAW_STATION_EXPRESS:-}" "\${NEMOCLAW_ENABLE_LOCAL_MODEL_PROFILE:-}" "\${NEMOCLAW_LOCAL_MODEL_RUNTIME:-}" + "\${NEMOCLAW_STATION_EXPRESS:-}" "\${NEMOCLAW_ENABLE_LOCAL_MODEL_PROFILE:-}" "\${NEMOCLAW_LOCAL_MODEL_RUNTIME:-}" \ + "\${_SPARK_EXPRESS_INFERENCE_SELECTION:-}" exit 0 } main "$@" @@ -84,10 +85,11 @@ if [ "\${FORCE_EXPRESS_PROMPT_READ_FAILURE:-}" = "1" ]; then read() { return 1; } fi maybe_offer_express_install -printf "RESULT NON_INTERACTIVE=%s SUDO_MODE=%s PROVIDER=%s MODEL=%s VLLM_MODEL=%s POLICY=%s YES=%s SANDBOX=%s STATION_EXPRESS=%s PROFILE_GATE=%s PROFILE_RUNTIME=%s\\n" \\ +printf "RESULT NON_INTERACTIVE=%s SUDO_MODE=%s PROVIDER=%s MODEL=%s VLLM_MODEL=%s POLICY=%s YES=%s SANDBOX=%s STATION_EXPRESS=%s PROFILE_GATE=%s PROFILE_RUNTIME=%s SPARK_SELECTION=%s\\n" \\ "\${NON_INTERACTIVE:-}" "\${NEMOCLAW_NON_INTERACTIVE_SUDO_MODE:-}" "\${NEMOCLAW_PROVIDER:-}" "\${NEMOCLAW_MODEL:-}" \\ "\${NEMOCLAW_VLLM_MODEL:-}" "\${NEMOCLAW_POLICY_MODE:-}" "\${NEMOCLAW_YES:-}" "\${NEMOCLAW_SANDBOX_NAME:-}" \\ - "\${NEMOCLAW_STATION_EXPRESS:-}" "\${NEMOCLAW_ENABLE_LOCAL_MODEL_PROFILE:-}" "\${NEMOCLAW_LOCAL_MODEL_RUNTIME:-}" + "\${NEMOCLAW_STATION_EXPRESS:-}" "\${NEMOCLAW_ENABLE_LOCAL_MODEL_PROFILE:-}" "\${NEMOCLAW_LOCAL_MODEL_RUNTIME:-}" \\ + "\${_SPARK_EXPRESS_INFERENCE_SELECTION:-}" ''' env = dict(os.environ) env["INSTALLER_UNDER_TEST"] = installer @@ -144,7 +146,11 @@ while True: ready, _, _ = select.select([fd], [], [], 0.1) if ready: pty_closed = read_output() - if (not sent) and b"[Y/n]" in output: + spark_choice_ready = ( + b"Choose the DGX Spark inference setup" in output + and b"Choose 1 or 2 [1]:" in output + ) + if (not sent) and (spark_choice_ready or b"[Y/n]" in output): os.write(fd, answer) sent = True if pty_closed: diff --git a/test/install-express-prompt.test.ts b/test/install-express-prompt.test.ts index 6c0d10a31b0..74e6434519e 100644 --- a/test/install-express-prompt.test.ts +++ b/test/install-express-prompt.test.ts @@ -177,25 +177,42 @@ DGX_COMMIT_ID="d0e99cc"\nDGX_PLATFORM="DGX Server for GALAXY-GB300" }); it("offers express install when curl-piped stdin still has a controlling TTY", () => { - const result = runExpressPromptWithTty("y\n", "pipe"); + const result = runExpressPromptWithTty("\ny\n", "pipe"); const output = `${result.stdout}${result.stderr}`; expect(result.status, output).toBe(0); expect(output).toMatch(/Detected DGX Spark/); + expect(output).toMatch(/Choose the DGX Spark inference setup/); + expect(output).toMatch(/Managed vLLM with automatic serving-profile selection \(default\)/); + expect(output).toMatch(/Qwen3\.6 35B-A3B NVFP4 with the fixed catalog-backed vLLM profile/); expect(output).toMatch( - /Express install will configure the fixed DGX Spark model and vLLM serving profile/, + /Express install will configure managed vLLM with automatic DGX Spark serving-profile selection/, ); - expect(output).toMatch(/The serving catalog owns the model, image, port, and vLLM arguments/); - expect(output).toMatch(/dedicated local-model onboarder rejects model and runtime overrides/); + expect(output).toMatch(/ordinary no-match keeps the existing single-host DGX Spark profile/); expect(output).toMatch(/Sandbox name: my-assistant/); expect(output).toMatch(/Sandbox policy: suggested mode, tier 'balanced'/); expect(output).toMatch(/Run express install/); expect(output).toMatch(/Using express install for DGX Spark/); expect(output).toMatch( - /RESULT NON_INTERACTIVE=1 SUDO_MODE=prompt PROVIDER= MODEL= VLLM_MODEL= POLICY=suggested YES=1 SANDBOX=my-assistant STATION_EXPRESS= PROFILE_GATE=1 PROFILE_RUNTIME=vllm/, + /RESULT NON_INTERACTIVE=1 SUDO_MODE=prompt PROVIDER=install-vllm MODEL= VLLM_MODEL= POLICY=suggested YES=1 SANDBOX=my-assistant STATION_EXPRESS= PROFILE_GATE= PROFILE_RUNTIME= SPARK_SELECTION=managed-vllm/, ); expect(output).toMatch(/STATION_EXPRESS=\s/); }); + it("offers the fixed catalog-backed vLLM profile as the second Spark Express option", () => { + const result = runExpressPromptWithTty("2\ny\n", "pipe"); + const output = `${result.stdout}${result.stderr}`; + expect(result.status, output).toBe(0); + expect(output).toMatch(/Choose 1 or 2 \[1\]/); + expect(output).toMatch( + /Express install will configure Qwen3\.6 35B-A3B NVFP4 with the fixed catalog-backed vLLM profile/, + ); + expect(output).toMatch(/The serving catalog owns the model, image, port, and vLLM arguments/); + expect(output).toMatch(/dedicated local-model onboarder rejects model and runtime overrides/); + expect(output).toMatch( + /RESULT NON_INTERACTIVE=1 SUDO_MODE=prompt PROVIDER= MODEL= VLLM_MODEL= POLICY=suggested YES=1 SANDBOX=my-assistant STATION_EXPRESS= PROFILE_GATE=1 PROFILE_RUNTIME=vllm SPARK_SELECTION=fixed-vllm/, + ); + }); + it("preserves a preset Spark vLLM model in the prompt and exported env", () => { const result = runExpressPromptWithTty("y\n", "pipe", "DGX Spark", { NEMOCLAW_VLLM_MODEL: "custom-qwen3.6", @@ -214,8 +231,23 @@ DGX_COMMIT_ID="d0e99cc"\nDGX_PLATFORM="DGX Server for GALAXY-GB300" expect(output).toMatch(/PROFILE_GATE= PROFILE_RUNTIME=/); }); - it("preserves an explicit NEMOCLAW_SANDBOX_NAME over the DGX Spark default (#6525)", () => { + it("keeps an explicit generic model on the customizable Spark Express path", () => { const result = runExpressPromptWithTty("y\n", "pipe", "DGX Spark", { + NEMOCLAW_MODEL: "catalog/model", + }); + const output = `${result.stdout}${result.stderr}`; + expect(result.status, output).toBe(0); + expect(output).not.toMatch(/Choose the DGX Spark inference setup/); + expect(output).not.toMatch(/fixed catalog-backed vLLM profile/); + expect(output).toMatch(/managed local vLLM with explicit model intent catalog\/model/); + expect(output).toMatch(/does not offer the fixed profile/); + expect(output).toMatch( + /RESULT NON_INTERACTIVE=1 SUDO_MODE=prompt PROVIDER=install-vllm MODEL=catalog\/model VLLM_MODEL= POLICY=suggested YES=1 SANDBOX=my-assistant STATION_EXPRESS= PROFILE_GATE= PROFILE_RUNTIME= SPARK_SELECTION=/, + ); + }); + + it("preserves an explicit NEMOCLAW_SANDBOX_NAME over the DGX Spark default (#6525)", () => { + const result = runExpressPromptWithTty("2\ny\n", "pipe", "DGX Spark", { NEMOCLAW_SANDBOX_NAME: "custom-spark", }); const output = `${result.stdout}${result.stderr}`; @@ -223,7 +255,7 @@ DGX_COMMIT_ID="d0e99cc"\nDGX_PLATFORM="DGX Server for GALAXY-GB300" expect(output).toMatch(/Detected DGX Spark/); expect(output).toMatch(/Sandbox name: custom-spark/); expect(output).toMatch( - /RESULT NON_INTERACTIVE=1 SUDO_MODE=prompt PROVIDER= MODEL= VLLM_MODEL= POLICY=suggested YES=1 SANDBOX=custom-spark STATION_EXPRESS= PROFILE_GATE=1 PROFILE_RUNTIME=vllm/, + /RESULT NON_INTERACTIVE=1 SUDO_MODE=prompt PROVIDER= MODEL= VLLM_MODEL= POLICY=suggested YES=1 SANDBOX=custom-spark STATION_EXPRESS= PROFILE_GATE=1 PROFILE_RUNTIME=vllm SPARK_SELECTION=fixed-vllm/, ); }); diff --git a/test/starter-prompt-docs.test.ts b/test/starter-prompt-docs.test.ts index 9b61edb01c9..d522a2a9160 100644 --- a/test/starter-prompt-docs.test.ts +++ b/test/starter-prompt-docs.test.ts @@ -49,7 +49,7 @@ function definePromptAsset(assetPath: string, pinnedSha256: string): PromptAsset const promptAssets = { dgxSpark: definePromptAsset( "docs/resources/prompt-assets/dgx-spark.md", - "2d576fbd7231dc23fe3fb25056bc2584596d81e2448a3e023ad9427ab0a49c9f", // gitleaks:allow -- pinned prompt-asset SHA-256 + "84883d46c3a6481e062bdc21083bc16031a9f62a57212ccb9b83b0aaaf281f4f", // gitleaks:allow -- pinned prompt-asset SHA-256 ), dgxStation: definePromptAsset( "docs/resources/prompt-assets/dgx-station.md", @@ -816,7 +816,10 @@ describe("starter prompt docs CTA", () => { expect(sparkSource).toContain("nvidia/Qwen3.6-35B-A3B-NVFP4"); expect(sparkSource).toContain( - "Set `NEMOCLAW_ENABLE_LOCAL_MODEL_PROFILE=1` and `NEMOCLAW_LOCAL_MODEL_RUNTIME=vllm`", + "Managed vLLM with automatic serving-profile selection. This is the default", + ); + expect(sparkSource).toContain( + "`nvidia/Qwen3.6-35B-A3B-NVFP4` with the fixed catalog-backed vLLM profile", ); expect(sparkSource).toContain( "Leave `NEMOCLAW_PROVIDER`, `NEMOCLAW_MODEL`, `NEMOCLAW_VLLM_MODEL`, `NEMOCLAW_VLLM_PORT`, and `NEMOCLAW_VLLM_EXTRA_ARGS_JSON` unset", From 4394858b3bae38b04768619f99b9614161f1b565 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Thu, 6 Aug 2026 19:48:49 -0700 Subject: [PATCH 04/19] docs: clarify Spark express profile choices Signed-off-by: Aaron Erickson --- docs/resources/prompt-assets/dgx-spark.md | 8 ++++++-- docs/resources/starter-prompt.md | 6 +++--- test/starter-prompt-docs.test.ts | 4 ++-- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/docs/resources/prompt-assets/dgx-spark.md b/docs/resources/prompt-assets/dgx-spark.md index e7667b463e1..4426c6b9d38 100644 --- a/docs/resources/prompt-assets/dgx-spark.md +++ b/docs/resources/prompt-assets/dgx-spark.md @@ -8,12 +8,16 @@ Use these instructions only after hardware detection confirms DGX Spark. Explain that Express keeps the selected agent, leaves optional setup at its defaults, and offers two DGX Spark inference setups. -Ask the user to choose: +Inference choices: 1. Managed vLLM with automatic serving-profile selection. This is the default and preserves the current Express behavior. 2. `nvidia/Qwen3.6-35B-A3B-NVFP4` with the fixed catalog-backed vLLM profile. -After the user chooses, include the third-party-software notice and ask: "Run Express install with these settings?" +Include the third-party-software notice after the user chooses, then ask: "Run Express install with these settings?" +Choices: + +1. Yes, use the selected DGX Spark inference setup. +2. No, continue with the normal provider selection. For option 1: diff --git a/docs/resources/starter-prompt.md b/docs/resources/starter-prompt.md index ca83ffc45ba..a28ebe47f3c 100644 --- a/docs/resources/starter-prompt.md +++ b/docs/resources/starter-prompt.md @@ -79,9 +79,9 @@ Set `NEMOCLAW_AGENT=langchain-deepagents-code` for Deep Agents, or use `nemo-dee After the readiness check, load exactly one matching instruction asset before provider selection: -- Confirmed DGX Spark: [DGX Spark Express instructions](https://raw.githubusercontent.com/NVIDIA/NemoClaw/68f74a2ea3fe1c9a73d0186a2b0f23b1b6554172/docs/resources/prompt-assets/dgx-spark.md). -- Confirmed DGX Station: [DGX Station installation instructions](https://raw.githubusercontent.com/NVIDIA/NemoClaw/68f74a2ea3fe1c9a73d0186a2b0f23b1b6554172/docs/resources/prompt-assets/dgx-station.md). -- Officially detected Windows WSL: [Windows WSL Express instructions](https://raw.githubusercontent.com/NVIDIA/NemoClaw/68f74a2ea3fe1c9a73d0186a2b0f23b1b6554172/docs/resources/prompt-assets/windows-wsl.md). +- Confirmed DGX Spark: [DGX Spark Express instructions](https://raw.githubusercontent.com/NVIDIA/NemoClaw/e7073ef09d9017cc619f60178ed92f7f2ea2374a/docs/resources/prompt-assets/dgx-spark.md). +- Confirmed DGX Station: [DGX Station installation instructions](https://raw.githubusercontent.com/NVIDIA/NemoClaw/e7073ef09d9017cc619f60178ed92f7f2ea2374a/docs/resources/prompt-assets/dgx-station.md). +- Officially detected Windows WSL: [Windows WSL Express instructions](https://raw.githubusercontent.com/NVIDIA/NemoClaw/e7073ef09d9017cc619f60178ed92f7f2ea2374a/docs/resources/prompt-assets/windows-wsl.md). Read the matching raw Markdown file completely and follow it before continuing. Do not load a platform asset for any other computer. diff --git a/test/starter-prompt-docs.test.ts b/test/starter-prompt-docs.test.ts index d522a2a9160..5531ea1d847 100644 --- a/test/starter-prompt-docs.test.ts +++ b/test/starter-prompt-docs.test.ts @@ -30,7 +30,7 @@ const repoRoot = path.resolve(__dirname, ".."); const starterPromptMarkdownSource = path.join(repoRoot, "docs", "resources", "starter-prompt.md"); // CI resolves this Git commit and byte-compares its prompt-asset blobs with // the local files. The digests independently assert those same immutable bytes. -const promptAssetRevision = "68f74a2ea3fe1c9a73d0186a2b0f23b1b6554172"; +const promptAssetRevision = "e7073ef09d9017cc619f60178ed92f7f2ea2374a"; type PromptAsset = { path: string; @@ -49,7 +49,7 @@ function definePromptAsset(assetPath: string, pinnedSha256: string): PromptAsset const promptAssets = { dgxSpark: definePromptAsset( "docs/resources/prompt-assets/dgx-spark.md", - "84883d46c3a6481e062bdc21083bc16031a9f62a57212ccb9b83b0aaaf281f4f", // gitleaks:allow -- pinned prompt-asset SHA-256 + "b0192f72e9a55349a7ca9c5ec3da639bd81a2c74b676f997b3e5193bc10053e1", // gitleaks:allow -- pinned prompt-asset SHA-256 ), dgxStation: definePromptAsset( "docs/resources/prompt-assets/dgx-station.md", From bcb9a8ccda8199a9d0f926b33eb0c832a0efbd46 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Thu, 6 Aug 2026 19:50:12 -0700 Subject: [PATCH 05/19] docs: repin Spark express prompt asset Signed-off-by: Aaron Erickson --- docs/resources/starter-prompt.md | 6 +++--- test/starter-prompt-docs.test.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/resources/starter-prompt.md b/docs/resources/starter-prompt.md index a28ebe47f3c..5f959732c92 100644 --- a/docs/resources/starter-prompt.md +++ b/docs/resources/starter-prompt.md @@ -79,9 +79,9 @@ Set `NEMOCLAW_AGENT=langchain-deepagents-code` for Deep Agents, or use `nemo-dee After the readiness check, load exactly one matching instruction asset before provider selection: -- Confirmed DGX Spark: [DGX Spark Express instructions](https://raw.githubusercontent.com/NVIDIA/NemoClaw/e7073ef09d9017cc619f60178ed92f7f2ea2374a/docs/resources/prompt-assets/dgx-spark.md). -- Confirmed DGX Station: [DGX Station installation instructions](https://raw.githubusercontent.com/NVIDIA/NemoClaw/e7073ef09d9017cc619f60178ed92f7f2ea2374a/docs/resources/prompt-assets/dgx-station.md). -- Officially detected Windows WSL: [Windows WSL Express instructions](https://raw.githubusercontent.com/NVIDIA/NemoClaw/e7073ef09d9017cc619f60178ed92f7f2ea2374a/docs/resources/prompt-assets/windows-wsl.md). +- Confirmed DGX Spark: [DGX Spark Express instructions](https://raw.githubusercontent.com/NVIDIA/NemoClaw/4394858b3bae38b04768619f99b9614161f1b565/docs/resources/prompt-assets/dgx-spark.md). +- Confirmed DGX Station: [DGX Station installation instructions](https://raw.githubusercontent.com/NVIDIA/NemoClaw/4394858b3bae38b04768619f99b9614161f1b565/docs/resources/prompt-assets/dgx-station.md). +- Officially detected Windows WSL: [Windows WSL Express instructions](https://raw.githubusercontent.com/NVIDIA/NemoClaw/4394858b3bae38b04768619f99b9614161f1b565/docs/resources/prompt-assets/windows-wsl.md). Read the matching raw Markdown file completely and follow it before continuing. Do not load a platform asset for any other computer. diff --git a/test/starter-prompt-docs.test.ts b/test/starter-prompt-docs.test.ts index 5531ea1d847..7351155fa2e 100644 --- a/test/starter-prompt-docs.test.ts +++ b/test/starter-prompt-docs.test.ts @@ -30,7 +30,7 @@ const repoRoot = path.resolve(__dirname, ".."); const starterPromptMarkdownSource = path.join(repoRoot, "docs", "resources", "starter-prompt.md"); // CI resolves this Git commit and byte-compares its prompt-asset blobs with // the local files. The digests independently assert those same immutable bytes. -const promptAssetRevision = "e7073ef09d9017cc619f60178ed92f7f2ea2374a"; +const promptAssetRevision = "4394858b3bae38b04768619f99b9614161f1b565"; type PromptAsset = { path: string; From ec2def00437bf70ff59b74a5029a7abe60c1f145 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Thu, 6 Aug 2026 20:02:38 -0700 Subject: [PATCH 06/19] test(e2e): keep Spark profile target linear Signed-off-by: Aaron Erickson --- test/e2e/live/spark-express-vllm.test.ts | 33 +++++++++++++++--------- 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/test/e2e/live/spark-express-vllm.test.ts b/test/e2e/live/spark-express-vllm.test.ts index d7d8666ed99..7dcc793b6e1 100644 --- a/test/e2e/live/spark-express-vllm.test.ts +++ b/test/e2e/live/spark-express-vllm.test.ts @@ -3,6 +3,8 @@ /** Exercises the catalog-backed DGX Spark Express vLLM path on physical hardware. */ +import assert from "node:assert/strict"; + import { loadServingCatalog } from "../../../src/lib/inference/serving/catalog-loader.ts"; import { materializeHostLocalVllmSelection } from "../../../src/lib/inference/serving/host-local-vllm-selection.ts"; import { detectVllmProfile } from "../../../src/lib/inference/vllm.ts"; @@ -13,7 +15,7 @@ import { resolveLocalModelProfilePlan, } from "../../../src/lib/onboard/local-model-profile/plan.ts"; import { buildAvailabilityProbeEnv } from "../fixtures/availability-env.ts"; -import { resultText } from "../fixtures/clients/command.ts"; +import { type CommandExitResult, resultText } from "../fixtures/clients/command.ts"; import type { HostCliClient } from "../fixtures/clients/host.ts"; import { trustedSandboxShellScript } from "../fixtures/clients/sandbox.ts"; import { expect, test } from "../fixtures/e2e-test.ts"; @@ -73,12 +75,21 @@ function vllmProfilePlan() { [LOCAL_MODEL_PROFILE_ENABLED_ENV]: "1", [LOCAL_MODEL_PROFILE_RUNTIME_ENV]: "vllm", }); - if (plan?.runtime !== "vllm") { - throw new Error("the vLLM local-model profile did not resolve from the serving catalog"); - } + assert( + plan?.runtime === "vllm", + "the vLLM local-model profile did not resolve from the serving catalog", + ); return plan; } +function capturedVllmContainerId(result: CommandExitResult): string | null { + const [candidate] = + result.exitCode === 0 + ? (JSON.parse(result.stdout) as VllmContainerInspection[]) + : ([] as VllmContainerInspection[]); + return candidate && /^[a-f0-9]{64}$/u.test(candidate.Id) ? candidate.Id : null; +} + async function assertVllmContainerAbsent(host: HostCliClient): Promise { const result = await host.command("docker", ["inspect", VLLM_CONTAINER], { artifactName: "preflight-spark-express-vllm-container", @@ -96,9 +107,10 @@ async function removeExactVllmContainer( containerId: string, artifactName: string, ): Promise { - if (!/^[a-f0-9]{64}$/u.test(containerId)) { - throw new Error("cleanup requires the exact full Docker container ID created by this test"); - } + assert( + /^[a-f0-9]{64}$/u.test(containerId), + "cleanup requires the exact full Docker container ID created by this test", + ); const result = await host.command( "bash", [ @@ -136,7 +148,7 @@ test("DGX Spark Express option 2 materializes the fixed vLLM profile and routes }, async ({ artifacts, cleanup, host, progress, sandbox, skip }) => { const plan = vllmProfilePlan(); const baseProfile = detectVllmProfile({ platform: "spark" }); - if (!baseProfile) throw new Error("the DGX Spark vLLM base profile is unavailable"); + assert(baseProfile, "the DGX Spark vLLM base profile is unavailable"); const materialized = materializeHostLocalVllmSelection( { outcome: "selected", @@ -236,10 +248,7 @@ test("DGX Spark Express option 2 materializes the fixed vLLM profile and routes env: e2eEnv(), timeoutMs: 30_000, }); - if (inspectionResult.exitCode === 0) { - const [candidate] = JSON.parse(inspectionResult.stdout) as VllmContainerInspection[]; - if (candidate && /^[a-f0-9]{64}$/u.test(candidate.Id)) createdContainerId = candidate.Id; - } + createdContainerId = capturedVllmContainerId(inspectionResult); expect(onboard.exitCode, resultText(onboard)).toBe(0); expect(inspectionResult.exitCode, resultText(inspectionResult)).toBe(0); const [inspection] = JSON.parse(inspectionResult.stdout) as VllmContainerInspection[]; From f52f8c0b6ae1aabd2137ab3157b360f513f20f70 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Thu, 6 Aug 2026 20:08:32 -0700 Subject: [PATCH 07/19] test(installer): cover Spark profile override conflicts Signed-off-by: Aaron Erickson --- test/install-express-prompt.test.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/test/install-express-prompt.test.ts b/test/install-express-prompt.test.ts index 74e6434519e..4197f33f21c 100644 --- a/test/install-express-prompt.test.ts +++ b/test/install-express-prompt.test.ts @@ -213,6 +213,21 @@ DGX_COMMIT_ID="d0e99cc"\nDGX_PLATFORM="DGX Server for GALAXY-GB300" ); }); + it.each([ + ["port", { NEMOCLAW_VLLM_PORT: "18000" }], + ["serve arguments", { NEMOCLAW_VLLM_EXTRA_ARGS_JSON: '["--max-model-len","4096"]' }], + ])("rejects a preset %s before activating the fixed Spark Express profile", (_, extraEnv) => { + const result = runExpressPromptWithTty("2\ny\n", "pipe", "DGX Spark", extraEnv); + const output = `${result.stdout}${result.stderr}`; + + expect(result.status, output).not.toBe(0); + expect(output).toContain( + "The fixed DGX Spark vLLM profile does not accept provider, model, port, or serve-argument overrides.", + ); + expect(output).not.toContain("RESULT NON_INTERACTIVE="); + expect(output).not.toContain("PROFILE_GATE=1"); + }); + it("preserves a preset Spark vLLM model in the prompt and exported env", () => { const result = runExpressPromptWithTty("y\n", "pipe", "DGX Spark", { NEMOCLAW_VLLM_MODEL: "custom-qwen3.6", From 8641844026566836a08c12ca19a5c0ad943c4d61 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Thu, 6 Aug 2026 20:15:24 -0700 Subject: [PATCH 08/19] test(e2e): map Spark profile mock parity Signed-off-by: Aaron Erickson --- test/e2e/mock-parity.json | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/test/e2e/mock-parity.json b/test/e2e/mock-parity.json index 0b1a7f3e78c..6f0c61d6680 100644 --- a/test/e2e/mock-parity.json +++ b/test/e2e/mock-parity.json @@ -501,6 +501,20 @@ "test/e2e/support/e2e-clients.test.ts" ] }, + { + "live": "test/e2e/live/spark-express-vllm.test.ts", + "fast": [ + "src/lib/inference/serving/catalog.test.ts", + "src/lib/inference/serving/host-local-vllm-selection.test.ts", + "src/lib/inference/vllm-models.test.ts", + "src/lib/onboard/local-model-profile/onboarder.test.ts", + "src/lib/onboard/local-model-profile/plan.test.ts", + "test/e2e/support/e2e-cleanup-resources.test.ts", + "test/e2e/support/e2e-clients.test.ts", + "test/install-express-prompt.test.ts", + "test/install-local-model-profile.test.ts" + ] + }, { "live": "test/e2e/live/spark-install.test.ts", "fast": [ From eb7dde65eed99ccde63bfc7e30afafd9315a61f7 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sun, 9 Aug 2026 13:20:15 -0700 Subject: [PATCH 09/19] fix(onboard): recover Spark fixed-profile installs --- docs/inference/set-up-vllm.mdx | 4 +- scripts/install.sh | 2 +- src/lib/agent/onboard.ts | 4 +- .../serving/host-local-vllm-selection.test.ts | 15 +++++ .../serving/host-local-vllm-selection.ts | 9 ++- src/lib/onboard.ts | 3 +- src/lib/onboard/command.test.ts | 33 ++++++++++ src/lib/onboard/command.ts | 37 ++++++++++- src/lib/onboard/config-sync.test.ts | 19 +++++- src/lib/onboard/config-sync.ts | 5 ++ src/lib/onboard/openclaw-setup.test.ts | 51 +++++++++++++++ src/lib/onboard/openclaw-setup.ts | 4 +- test/e2e/README.md | 14 +++-- test/e2e/live/spark-express-vllm.test.ts | 62 ++++++++++++++++--- .../support/spark-express-vllm-safety.test.ts | 52 ++++++++++++++++ test/e2e/support/spark-express-vllm-safety.ts | 40 ++++++++++++ test/install-express-prompt.test.ts | 7 ++- 17 files changed, 336 insertions(+), 25 deletions(-) create mode 100644 src/lib/onboard/openclaw-setup.test.ts create mode 100644 test/e2e/support/spark-express-vllm-safety.test.ts create mode 100644 test/e2e/support/spark-express-vllm-safety.ts diff --git a/docs/inference/set-up-vllm.mdx b/docs/inference/set-up-vllm.mdx index 52aa03e7c8c..e576b091603 100644 --- a/docs/inference/set-up-vllm.mdx +++ b/docs/inference/set-up-vllm.mdx @@ -232,6 +232,7 @@ An inconclusive Docker image-storage check retains its existing automatic-contin The model-cache guard applies when the selected registered recipe declares a pinned snapshot size. NemoClaw creates `~/.cache/huggingface` as the host user and runs the one-shot download container with the host UID and GID, avoiding root-owned cache paths. +The long-lived vLLM container mounts only the Hugging Face hub cache read-only and uses offline mode, so it cannot change host-cache ownership. Hugging Face credentials are forwarded only to that temporary downloader, not retained by the long-lived vLLM container. A legacy root-owned or otherwise unwritable required model-cache path stops before the image pull and prints a scoped ownership-repair command; NemoClaw never changes its ownership automatically. @@ -262,7 +263,8 @@ It offers the fixed local vLLM profile as option 2. When you select option 2, the serving catalog supplies the pinned model, runtime image, port, and serve arguments. The installer rejects provider and model overrides for that fixed profile, and the dedicated onboarder rejects vLLM model, port, and serve-argument overrides before it starts the managed container. Explicit `NEMOCLAW_MODEL` or `NEMOCLAW_VLLM_MODEL` intent retains the customizable managed-vLLM path and skips the fixed-profile choice. -Refer to [Choose a Local Inference Server](choose-local-inference-server#install-a-fixed-local-model-profile) for fixed-profile installation and verification. +If onboarding stops after you select option 2, `$$nemoclaw onboard --resume` reuses the recorded fixed profile instead of returning to automatic serving-profile selection. +Refer to [Choose a Local Inference Server](choose-local-inference-server#install-a-fixed-vllm-profile) for fixed-profile installation and verification. Refer to [Set Up vLLM on Two DGX Sparks](set-up-vllm-on-two-dgx-sparks) for automatic and explicit distributed-profile selection, profile requirements, and cleanup behavior. To select the existing `deepseek-v4-flash` recipe while retaining the same one-confirmation express flow, run: diff --git a/scripts/install.sh b/scripts/install.sh index 2f760673163..8de700880e1 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -4885,7 +4885,7 @@ describe_express_install() { "DGX Spark") if [ "${_SPARK_EXPRESS_INFERENCE_SELECTION:-managed-vllm}" = "fixed-vllm" ]; then inference_summary="Qwen3.6 35B-A3B NVFP4 with the fixed catalog-backed vLLM profile" - inference_disclosure="The serving catalog owns the model, image, port, and vLLM arguments. The dedicated local-model onboarder rejects model and runtime overrides before starting its managed container." + inference_disclosure="The serving catalog owns the model, image, port, and vLLM arguments. The installer rejects provider and model overrides, and the dedicated local-model onboarder rejects vLLM model, port, and serve-argument overrides before starting its managed container." elif [ -n "${NEMOCLAW_VLLM_MODEL:-}" ]; then inference_summary="managed local vLLM with model ${NEMOCLAW_VLLM_MODEL}" inference_disclosure="The explicit model remains authoritative, so this run keeps the existing single-host DGX Spark profile. Managed vLLM pulls the configured image/model and runs only its dedicated container." diff --git a/src/lib/agent/onboard.ts b/src/lib/agent/onboard.ts index 85abe76aa60..48dc5337791 100644 --- a/src/lib/agent/onboard.ts +++ b/src/lib/agent/onboard.ts @@ -10,7 +10,7 @@ import { getAgentBranding } from "../cli/branding"; import type { JsonObject as LooseObject } from "../core/json-types"; import { sleepSeconds } from "../core/wait"; import { getProviderSelectionConfig } from "../inference/config"; -import { runSandboxConfigSync } from "../onboard/config-sync"; +import { runSandboxConfigSync, sandboxConfigSyncArgs } from "../onboard/config-sync"; import { isValidForwardPort } from "../onboard/dashboard-runtime"; import { redact, run } from "../runner"; import * as baseImage from "./base-image"; @@ -286,7 +286,7 @@ export async function handleAgentSetup( return cfg ? { ...cfg, agent: agent.name } : null; }, runConnectScript: (name, scriptContent) => { - run([openshellBin, "sandbox", "connect", name], { + run([openshellBin, ...sandboxConfigSyncArgs(name)], { stdio: ["pipe", "ignore", "inherit"], input: scriptContent, }); diff --git a/src/lib/inference/serving/host-local-vllm-selection.test.ts b/src/lib/inference/serving/host-local-vllm-selection.test.ts index e1ccbc23ff8..17ffbda0668 100644 --- a/src/lib/inference/serving/host-local-vllm-selection.test.ts +++ b/src/lib/inference/serving/host-local-vllm-selection.test.ts @@ -1,6 +1,9 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import os from "node:os"; +import path from "node:path"; + import { beforeEach, describe, expect, it, vi } from "vitest"; import type { VllmProfile } from "../vllm.js"; import { @@ -86,6 +89,18 @@ describe("host-local vLLM selection", () => { intent: { preset: selection.preset.metadata.id }, }), ); + if (result.kind !== "selected") throw new Error("expected a selected host-local profile"); + expect(result.model.runtime?.dockerRunArgs).toContain( + `type=bind,source=${path.join(os.homedir(), ".cache", "huggingface", "hub")},target=${selection.recipe.spec.runtime.modelCache.target}/hub,readonly`, + ); + expect(result.model.runtime?.dockerRunArgs?.join("\n")).not.toContain( + `source=${path.join(os.homedir(), ".cache", "huggingface")},target=`, + ); + expect(result.model.serveEnv).toMatchObject({ + HF_HOME: selection.recipe.spec.runtime.modelCache.target, + HF_HUB_OFFLINE: "1", + TRANSFORMERS_OFFLINE: "1", + }); }); it.each([ diff --git a/src/lib/inference/serving/host-local-vllm-selection.ts b/src/lib/inference/serving/host-local-vllm-selection.ts index 4b23d21be4c..62fe2996b39 100644 --- a/src/lib/inference/serving/host-local-vllm-selection.ts +++ b/src/lib/inference/serving/host-local-vllm-selection.ts @@ -79,7 +79,7 @@ function dockerRunArguments(recipe: HostLocalInferenceServingRecipe): string[] { "--ipc", recipe.spec.runtime.ipcMode, "--mount", - `type=bind,source=${path.join(os.homedir(), ".cache", "huggingface")},target=${recipe.spec.runtime.modelCache.target}`, + `type=bind,source=${path.join(os.homedir(), ".cache", "huggingface", "hub")},target=${recipe.spec.runtime.modelCache.target}/hub,readonly`, "--shm-size", `${String(sharedMemoryBytes)}b`, "--ulimit", @@ -123,7 +123,12 @@ export function materializeHostLocalVllmSelection( ) { throw new Error("host-local vLLM recipe is missing required runtime or model fields"); } - const serveEnvironment = { ...runtime.environment }; + const serveEnvironment = { + ...runtime.environment, + HF_HOME: runtime.modelCache.target, + HF_HUB_OFFLINE: "1", + TRANSFORMERS_OFFLINE: "1", + }; const model: VllmModelDef = { id: recipe.spec.model.id, label: recipe.metadata.displayName ?? recipe.metadata.id, diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 96a549f2657..9d5541ab455 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -66,6 +66,7 @@ const { const { buildSandboxConfigSyncScript, runSandboxConfigSync, + sandboxConfigSyncArgs, writeSandboxConfigSyncFile, }: typeof import("./onboard/config-sync") = require("./onboard/config-sync"); const dockerGpuLocalInference: typeof import("./onboard/docker-gpu-local-inference") = require("./onboard/docker-gpu-local-inference"); @@ -3606,7 +3607,7 @@ function syncNemoClawConfigInSandbox(sandboxName: string, provider: string, mode runSandboxConfigSync(sandboxName, { getSelectionConfig: () => getProviderSelectionConfig(provider, model), runConnectScript: (name, scriptContent) => { - run(openshellArgv(["sandbox", "connect", name]), { + run(openshellArgv(sandboxConfigSyncArgs(name)), { stdio: ["pipe", "ignore", "inherit"], input: scriptContent, }); diff --git a/src/lib/onboard/command.test.ts b/src/lib/onboard/command.test.ts index fd03af62888..659b1f6709d 100644 --- a/src/lib/onboard/command.test.ts +++ b/src/lib/onboard/command.test.ts @@ -13,6 +13,10 @@ import { resolveOnboardOptions, runOnboardCommand } from "./command"; import type { OnboardFlags } from "./command-support"; import { invalidGatewayManagementDeclarationError } from "./gateway-management"; import { GatewayAuthorityError } from "./gateway-teardown-authority"; +import { + LOCAL_MODEL_PROFILE_ENABLED_ENV, + LOCAL_MODEL_PROFILE_RUNTIME_ENV, +} from "./local-model-profile/plan"; afterEach(() => { vi.unstubAllEnvs(); @@ -145,6 +149,35 @@ describe("onboard command options", () => { expect(errors.join("\n")).toContain("changed since onboarding started"); }); + it("records installer local-model profile intent before onboarding and reuses it on resume", () => { + const catalog = loadServingCatalog(); + const fresh = resolve( + {}, + { + env: { + [LOCAL_MODEL_PROFILE_ENABLED_ENV]: "1", + [LOCAL_MODEL_PROFILE_RUNTIME_ENV]: "vllm", + }, + loadServingCatalog: () => catalog, + }, + ); + + expect(fresh.servingProfile).toBe("local-model-profile.vllm.spark.v1"); + expect(fresh.servingProfileProvenance?.preset.id).toBe("local-model-profile.vllm.spark.v1"); + + const resumed = resolve( + { resume: true }, + { + env: {}, + loadServingCatalog: () => catalog, + loadSession: () => ({ + servingProfileProvenance: fresh.servingProfileProvenance, + }), + }, + ); + expect(resumed.servingProfileProvenance).toEqual(fresh.servingProfileProvenance); + }); + it("keeps legacy resume compatible but refuses to add new profile intent (#8384)", () => { expect( resolve({ resume: true }, { loadSession: () => ({}) as never }).servingProfile, diff --git a/src/lib/onboard/command.ts b/src/lib/onboard/command.ts index 1c66ec43d1c..92ef5bff68f 100644 --- a/src/lib/onboard/command.ts +++ b/src/lib/onboard/command.ts @@ -32,6 +32,11 @@ import { } from "./docker-driver-platform"; import { GatewayManagementDeclarationError } from "./gateway-management"; import { GatewayAuthorityError, gatewayAuthorityFailureLines } from "./gateway-teardown-authority"; +import { + LOCAL_MODEL_PROFILE_ENABLED_ENV, + LOCAL_MODEL_PROFILE_RUNTIME_ENV, + resolveLocalModelProfilePlan, +} from "./local-model-profile/plan"; import { managedSandboxFeatureIssue } from "./managed-sandbox-feature"; import { DCODE_OBSERVABILITY_FEATURE } from "./observability-policy-presets"; import { isOpenclawAgent } from "./openclaw-otel-policy-presets"; @@ -224,11 +229,41 @@ function resolveServingProfile( return servingProfileProvenance(catalog, selectedProfileId); } +function resolveInstallerServingProfile( + deps: ResolveOnboardOptionsDeps, +): ServingProfileProvenance | null { + const hasInstallerProfileIntent = + String(deps.env[LOCAL_MODEL_PROFILE_ENABLED_ENV] ?? "").trim() !== "" || + String(deps.env[LOCAL_MODEL_PROFILE_RUNTIME_ENV] ?? "").trim() !== ""; + if (!hasInstallerProfileIntent) return null; + try { + const catalog = (deps.loadServingCatalog ?? loadServingCatalog)(); + const plan = resolveLocalModelProfilePlan(catalog, deps.env); + if (!plan) return null; + validateServingProfileConflicts(plan.preset.metadata.id, deps); + return servingProfileProvenance(catalog, plan.preset.metadata.id); + } catch (error) { + fail(deps, ` ${error instanceof Error ? error.message : String(error)}`); + } +} + function resolveServingProfileLifecycle( flags: OnboardFlags, deps: ResolveOnboardOptionsDeps, ): ServingProfileProvenance | null { - const requested = resolveServingProfile(flags.profile, deps); + const explicit = resolveServingProfile(flags.profile, deps); + const installerProfile = resolveInstallerServingProfile(deps); + if ( + explicit && + installerProfile && + JSON.stringify(explicit) !== JSON.stringify(installerProfile) + ) { + fail( + deps, + ` --profile ${explicit.preset.id} conflicts with installer local model profile ${installerProfile.preset.id}.`, + ); + } + const requested = explicit ?? installerProfile; if (flags.resume !== true) return requested; return resolveResumedServingProfile(requested, deps); } diff --git a/src/lib/onboard/config-sync.test.ts b/src/lib/onboard/config-sync.test.ts index 5294f8bc775..ed1725c6dbd 100644 --- a/src/lib/onboard/config-sync.test.ts +++ b/src/lib/onboard/config-sync.test.ts @@ -8,7 +8,11 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; -import { buildSandboxConfigSyncScript, writeSandboxConfigSyncFile } from "./config-sync"; +import { + buildSandboxConfigSyncScript, + sandboxConfigSyncArgs, + writeSandboxConfigSyncFile, +} from "./config-sync"; const itUnix = process.platform === "win32" ? it.skip : it; @@ -38,6 +42,19 @@ function modeBits(file: string): number { } describe("sandbox config sync helpers", () => { + it("uses noninteractive sandbox exec for stdin scripts", () => { + expect(sandboxConfigSyncArgs("spark-box")).toEqual([ + "sandbox", + "exec", + "-n", + "spark-box", + "--no-tty", + "--", + "bash", + "-s", + ]); + }); + it("builds a sandbox sync script that records provider selection without rewriting OpenClaw config", () => { const script = buildSandboxConfigSyncScript({ endpointType: "custom", diff --git a/src/lib/onboard/config-sync.ts b/src/lib/onboard/config-sync.ts index a576b48fe8e..e2449ddd06b 100644 --- a/src/lib/onboard/config-sync.ts +++ b/src/lib/onboard/config-sync.ts @@ -11,6 +11,11 @@ export interface RunSandboxConfigSyncDeps { runConnectScript: (sandboxName: string, scriptContent: string) => void; } +/** Run config sync without allocating the interactive sandbox terminal transport. */ +export function sandboxConfigSyncArgs(sandboxName: string): string[] { + return ["sandbox", "exec", "-n", sandboxName, "--no-tty", "--", "bash", "-s"]; +} + // Write `~/.nemoclaw/config.json` and normalize OpenClaw config-dir perms // inside the sandbox. Idempotent — safe to invoke from the rebuild resume // path where the Dockerfile leaves config.json as a zero-byte placeholder diff --git a/src/lib/onboard/openclaw-setup.test.ts b/src/lib/onboard/openclaw-setup.test.ts new file mode 100644 index 00000000000..924e825f7aa --- /dev/null +++ b/src/lib/onboard/openclaw-setup.test.ts @@ -0,0 +1,51 @@ +// 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 { describe, expect, it, vi } from "vitest"; +import { createOpenclawSetup } from "./openclaw-setup"; + +describe("OpenClaw sandbox setup", () => { + it("syncs config through noninteractive sandbox exec", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-setup-")); + const scriptFile = path.join(tempDir, "sync.sh"); + fs.writeFileSync(scriptFile, "set -e\n", { mode: 0o600 }); + const run = vi.fn(); + const cleanupTempDir = vi.fn(); + try { + const setup = createOpenclawSetup({ + step: vi.fn(), + agentProductName: () => "OpenClaw", + getProviderSelectionConfig: () => ({ provider: "vllm-local" }), + buildSandboxConfigSyncScript: () => "set -e", + writeSandboxConfigSyncFile: () => scriptFile, + run, + openshellArgv: (args) => ["/usr/bin/openshell", ...args], + cleanupTempDir, + }); + + await setup("spark-box", "model", "provider"); + + expect(run).toHaveBeenCalledWith( + [ + "/usr/bin/openshell", + "sandbox", + "exec", + "-n", + "spark-box", + "--no-tty", + "--", + "bash", + "-s", + ], + { input: "set -e\n", stdio: ["pipe", "ignore", "inherit"] }, + ); + expect(cleanupTempDir).toHaveBeenCalledWith(scriptFile, "nemoclaw-sync"); + } finally { + fs.rmSync(tempDir, { force: true, recursive: true }); + } + }); +}); diff --git a/src/lib/onboard/openclaw-setup.ts b/src/lib/onboard/openclaw-setup.ts index de8fd11e3fe..4772134fc96 100644 --- a/src/lib/onboard/openclaw-setup.ts +++ b/src/lib/onboard/openclaw-setup.ts @@ -3,6 +3,8 @@ import fs from "node:fs"; +import { sandboxConfigSyncArgs } from "./config-sync"; + export interface OpenclawSetupDeps { step(n: number, total: number, msg: string): void; agentProductName(): string; @@ -32,7 +34,7 @@ export function createOpenclawSetup(deps: OpenclawSetupDeps) { const scriptFile = deps.writeSandboxConfigSyncFile(script); try { const scriptContent = fs.readFileSync(scriptFile, "utf-8"); - deps.run(deps.openshellArgv(["sandbox", "connect", sandboxName]), { + deps.run(deps.openshellArgv(sandboxConfigSyncArgs(sandboxName)), { stdio: ["pipe", "ignore", "inherit"], input: scriptContent, }); diff --git a/test/e2e/README.md b/test/e2e/README.md index 0426bcaead5..f16632f3761 100644 --- a/test/e2e/README.md +++ b/test/e2e/README.md @@ -705,8 +705,14 @@ npm run test:e2e-phases:check `spark-express-vllm.test.ts` is a physical-host qualification for the second DGX Spark Express inference option, the catalog-backed fixed vLLM profile. It requires a qualified NVIDIA DGX Spark with Docker, NVIDIA Container Toolkit, OpenShell prerequisites, enough storage for the pinned image and model, and no unrelated `nemoclaw-vllm` container. -The test fails closed when that container name already exists. -It preserves the Hugging Face cache but deletes its dedicated sandbox and only the exact container ID created during the run. +The target accepts only a local Docker socket and the default Docker context, rejects remote selectors, and treats Docker inspection errors as preflight failures instead of absent resources. +The target sources `scripts/install.sh` from the candidate checkout, calls the Express option-selection functions with option 2, and invokes the candidate CLI directly for onboarding. +It does not run the hosted installer bootstrap, clone or ref selection, dependency installation, CLI exposure, or the real terminal prompt. +Separate installer tests own those earlier boundaries. +The live target refuses to replace a pre-existing sandbox or `nemoclaw-vllm` container. +It preserves the shared Hugging Face cache and removes only the exact sandbox and managed container that the run created. +If onboarding exits nonzero, the target captures the managed-container log tail and sandbox status before cleanup. +The standard E2E artifacts retain bounded command output. Run the target from a clean candidate checkout on the Spark host: @@ -714,12 +720,12 @@ Run the target from a clean candidate checkout on the Spark host: E2E_JOB=1 \ E2E_TARGET_ID=spark-express-vllm \ NEMOCLAW_RUN_LIVE_E2E=1 \ -NEMOCLAW_SANDBOX_NAME=e2e-spark-express-vllm \ +NEMOCLAW_SANDBOX_NAME=e2e-spark-vllm \ npx tsx tools/e2e/live-vitest-invocation.mts run \ --test-path test/e2e/live/spark-express-vllm.test.ts ``` -The passing target proves that Express option 2 selects the fixed vLLM preset and recipe, the managed container carries exact catalog provenance and the exact catalog-derived serve command, `inference.local` completes a chat request, and unrelated sandbox egress receives an HTTP `403` response. +A passing target establishes that the source-checkout option-2 path selects the fixed vLLM preset and recipe, the managed container carries exact catalog provenance and the exact catalog-derived serve command, `inference.local` completes a chat request, and unrelated sandbox egress receives an HTTP `403` response. The checker preserves coverage for every file under `test/e2e/live/` and adds workflow-selected integration files from the authoritative shared-job planner. diff --git a/test/e2e/live/spark-express-vllm.test.ts b/test/e2e/live/spark-express-vllm.test.ts index 7dcc793b6e1..e172ab262c2 100644 --- a/test/e2e/live/spark-express-vllm.test.ts +++ b/test/e2e/live/spark-express-vllm.test.ts @@ -17,16 +17,21 @@ import { import { buildAvailabilityProbeEnv } from "../fixtures/availability-env.ts"; import { type CommandExitResult, resultText } from "../fixtures/clients/command.ts"; import type { HostCliClient } from "../fixtures/clients/host.ts"; -import { trustedSandboxShellScript } from "../fixtures/clients/sandbox.ts"; +import { trustedSandboxShellScript, validateSandboxName } from "../fixtures/clients/sandbox.ts"; import { expect, test } from "../fixtures/e2e-test.ts"; import { REPO_ROOT } from "../fixtures/paths.ts"; +import { + assertLocalDockerEnvironment, + classifyDockerContainerInspection, + listedSandboxNames, +} from "../support/spark-express-vllm-safety.ts"; import { cleanupSandbox, expectOpenAiChatThroughSandbox, requireLivePrerequisites, } from "./inference-routing-helpers.ts"; -const SANDBOX_NAME = process.env.NEMOCLAW_SANDBOX_NAME ?? "e2e-spark-express-vllm"; +const SANDBOX_NAME = process.env.NEMOCLAW_SANDBOX_NAME ?? "e2e-spark-vllm"; const VLLM_CONTAINER = "nemoclaw-vllm"; const TEST_TIMEOUT_MS = 65 * 60_000; const ONBOARD_TIMEOUT_MS = 55 * 60_000; @@ -46,7 +51,7 @@ interface VllmContainerInspection { readonly PortBindings: Record>; readonly ShmSize: number; }; - readonly Mounts: Array<{ Destination: string; Type: string }>; + readonly Mounts: Array<{ Destination: string; RW: boolean; Type: string }>; } function e2eEnv(extra: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv { @@ -97,9 +102,37 @@ async function assertVllmContainerAbsent(host: HostCliClient): Promise { timeoutMs: 30_000, }); expect( - result.exitCode, + classifyDockerContainerInspection(result), `Refusing to replace a pre-existing ${VLLM_CONTAINER} container.\n${resultText(result)}`, - ).not.toBe(0); + ).toBe("absent"); +} + +async function assertSandboxAbsent(host: HostCliClient, sandboxName: string): Promise { + const result = await host.command("openshell", ["sandbox", "list", "--names"], { + artifactName: "preflight-spark-express-sandbox-list", + env: e2eEnv(), + timeoutMs: 30_000, + }); + expect( + listedSandboxNames(result).has(sandboxName), + `Refusing to replace a pre-existing ${sandboxName} sandbox.\n${resultText(result)}`, + ).toBe(false); +} + +async function captureOnboardFailureDiagnostics( + host: HostCliClient, + sandboxName: string, +): Promise { + await host.command("docker", ["logs", "--tail", "300", VLLM_CONTAINER], { + artifactName: "failure-spark-express-vllm-container-logs", + env: e2eEnv(), + timeoutMs: 30_000, + }); + await host.command("openshell", ["sandbox", "status", sandboxName], { + artifactName: "failure-spark-express-sandbox-status", + env: e2eEnv(), + timeoutMs: 30_000, + }); } async function removeExactVllmContainer( @@ -146,6 +179,8 @@ test("DGX Spark Express option 2 materializes the fixed vLLM profile and routes ], }, }, async ({ artifacts, cleanup, host, progress, sandbox, skip }) => { + validateSandboxName(SANDBOX_NAME); + assertLocalDockerEnvironment(process.env); const plan = vllmProfilePlan(); const baseProfile = detectVllmProfile({ platform: "spark" }); assert(baseProfile, "the DGX Spark vLLM base profile is unavailable"); @@ -200,16 +235,20 @@ test("DGX Spark Express option 2 materializes the fixed vLLM profile and routes expect(nvidia.exitCode, resultText(nvidia)).toBe(0); let createdContainerId: string | null = null; + let ownsSandboxName = false; cleanup.add(`remove ${VLLM_CONTAINER}`, () => createdContainerId ? removeExactVllmContainer(host, createdContainerId, "cleanup-spark-express-vllm-container") : Promise.resolve(), ); cleanup.add(`remove sandbox ${SANDBOX_NAME}`, () => - cleanupSandbox(host, sandbox, SANDBOX_NAME, { strict: true }), + ownsSandboxName + ? cleanupSandbox(host, sandbox, SANDBOX_NAME, { strict: true }) + : Promise.resolve(), ); - await cleanupSandbox(host, sandbox, SANDBOX_NAME); await assertVllmContainerAbsent(host); + await assertSandboxAbsent(host, SANDBOX_NAME); + ownsSandboxName = true; progress.phase("select Spark Express option 2 and onboard through the local-model profile"); const onboard = await host.command( @@ -242,13 +281,17 @@ test("DGX Spark Express option 2 materializes the fixed vLLM profile and routes }, ); - progress.phase("verify catalog-owned vLLM runtime configuration"); const inspectionResult = await host.command("docker", ["inspect", VLLM_CONTAINER], { artifactName: "spark-express-vllm-container-inspect", env: e2eEnv(), timeoutMs: 30_000, }); createdContainerId = capturedVllmContainerId(inspectionResult); + if (onboard.exitCode !== 0) { + await captureOnboardFailureDiagnostics(host, SANDBOX_NAME); + } + + progress.phase("verify catalog-owned vLLM runtime configuration"); expect(onboard.exitCode, resultText(onboard)).toBe(0); expect(inspectionResult.exitCode, resultText(inspectionResult)).toBe(0); const [inspection] = JSON.parse(inspectionResult.stdout) as VllmContainerInspection[]; @@ -277,7 +320,8 @@ test("DGX Spark Express option 2 materializes the fixed vLLM profile and routes expect(inspection.Mounts).toEqual( expect.arrayContaining([ expect.objectContaining({ - Destination: plan.recipe.spec.runtime.modelCache.target, + Destination: `${plan.recipe.spec.runtime.modelCache.target}/hub`, + RW: false, Type: "bind", }), ]), diff --git a/test/e2e/support/spark-express-vllm-safety.test.ts b/test/e2e/support/spark-express-vllm-safety.test.ts new file mode 100644 index 00000000000..c93c85de034 --- /dev/null +++ b/test/e2e/support/spark-express-vllm-safety.test.ts @@ -0,0 +1,52 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; +import { + assertLocalDockerEnvironment, + classifyDockerContainerInspection, + listedSandboxNames, +} from "./spark-express-vllm-safety.ts"; + +const result = (exitCode: number, stdout = "", stderr = "") => ({ + command: ["docker"], + cwd: "/tmp", + durationMs: 1, + exitCode, + stderr, + stdout, +}); + +describe("DGX Spark Express vLLM qualification safety", () => { + it("accepts only local Docker selectors (#8379)", () => { + expect(() => assertLocalDockerEnvironment({})).not.toThrow(); + expect(() => + assertLocalDockerEnvironment({ DOCKER_HOST: "unix:///var/run/docker.sock" }), + ).not.toThrow(); + expect(() => assertLocalDockerEnvironment({ DOCKER_HOST: "ssh://spark.example" })).toThrow( + "local Docker socket", + ); + expect(() => assertLocalDockerEnvironment({ DOCKER_CONTEXT: "remote-spark" })).toThrow( + "default local Docker context", + ); + }); + + it("distinguishes an absent container from Docker daemon failures (#8379)", () => { + expect(classifyDockerContainerInspection(result(0, "[]"))).toBe("present"); + expect( + classifyDockerContainerInspection(result(1, "", "Error: No such object: nemoclaw-vllm")), + ).toBe("absent"); + expect(() => + classifyDockerContainerInspection( + result(1, "", "Cannot connect to the Docker daemon at unix:///var/run/docker.sock"), + ), + ).toThrow("Docker container inspection failed"); + }); + + it("refuses to treat a failed sandbox listing as an empty host (#8379)", () => { + expect(listedSandboxNames(result(0, "alpha\nbeta\n"))).toEqual(new Set(["alpha", "beta"])); + expect(() => listedSandboxNames(result(1, "", "gateway unavailable"))).toThrow( + "OpenShell sandbox listing failed", + ); + }); +}); diff --git a/test/e2e/support/spark-express-vllm-safety.ts b/test/e2e/support/spark-express-vllm-safety.ts new file mode 100644 index 00000000000..58078a3395c --- /dev/null +++ b/test/e2e/support/spark-express-vllm-safety.ts @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { CommandExitResult } from "../fixtures/clients/command.ts"; +import { resultText } from "../fixtures/clients/command.ts"; + +const DOCKER_NOT_FOUND_PATTERN = /no such (?:object|container)/iu; + +export function assertLocalDockerEnvironment(env: NodeJS.ProcessEnv): void { + const host = String(env.DOCKER_HOST ?? "").trim(); + const context = String(env.DOCKER_CONTEXT ?? "").trim(); + if (host && !host.startsWith("unix://")) { + throw new Error( + `DGX Spark qualification requires a local Docker socket; got DOCKER_HOST=${host}`, + ); + } + if (context && context !== "default") { + throw new Error( + `DGX Spark qualification requires the default local Docker context; got DOCKER_CONTEXT=${context}`, + ); + } +} + +export function classifyDockerContainerInspection(result: CommandExitResult): "absent" | "present" { + if (result.exitCode === 0) return "present"; + if (DOCKER_NOT_FOUND_PATTERN.test(`${result.stdout}\n${result.stderr}`)) return "absent"; + throw new Error(`Docker container inspection failed: ${resultText(result)}`); +} + +export function listedSandboxNames(result: CommandExitResult): Set { + if (result.exitCode !== 0) { + throw new Error(`OpenShell sandbox listing failed: ${resultText(result)}`); + } + return new Set( + result.stdout + .split(/\r?\n/u) + .map((name) => name.trim()) + .filter(Boolean), + ); +} diff --git a/test/install-express-prompt.test.ts b/test/install-express-prompt.test.ts index 4197f33f21c..2c2d35ff4fb 100644 --- a/test/install-express-prompt.test.ts +++ b/test/install-express-prompt.test.ts @@ -55,7 +55,7 @@ describe("installer express install prompt (sourced)", () => { source "$INSTALLER_UNDER_TEST" >/dev/null classify_dgx_station_release() { if [[ -z "$EXPRESS_DGX_RELEASE_PATH" ]]; then - bash "$STATION_PREPARE" --classify-dgx-release + printf "generic-ubuntu" return fi bash -c ' @@ -207,7 +207,10 @@ DGX_COMMIT_ID="d0e99cc"\nDGX_PLATFORM="DGX Server for GALAXY-GB300" /Express install will configure Qwen3\.6 35B-A3B NVFP4 with the fixed catalog-backed vLLM profile/, ); expect(output).toMatch(/The serving catalog owns the model, image, port, and vLLM arguments/); - expect(output).toMatch(/dedicated local-model onboarder rejects model and runtime overrides/); + expect(output).toMatch(/installer rejects provider and model overrides/); + expect(output).toMatch( + /dedicated local-model onboarder rejects vLLM model, port, and serve-argument overrides/, + ); expect(output).toMatch( /RESULT NON_INTERACTIVE=1 SUDO_MODE=prompt PROVIDER= MODEL= VLLM_MODEL= POLICY=suggested YES=1 SANDBOX=my-assistant STATION_EXPRESS= PROFILE_GATE=1 PROFILE_RUNTIME=vllm SPARK_SELECTION=fixed-vllm/, ); From a1e2d320e3bd7616df48c2e4abc5013da0ae407b Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sun, 9 Aug 2026 13:43:52 -0700 Subject: [PATCH 10/19] fix(onboard): keep installer profile out of generic selector --- src/lib/onboard/command.test.ts | 22 +++++++++++++++++++++- src/lib/onboard/command.ts | 7 ++++++- test/e2e/README.md | 2 +- test/e2e/live/spark-express-vllm.test.ts | 4 ++-- 4 files changed, 30 insertions(+), 5 deletions(-) diff --git a/src/lib/onboard/command.test.ts b/src/lib/onboard/command.test.ts index 659b1f6709d..5006d8a5992 100644 --- a/src/lib/onboard/command.test.ts +++ b/src/lib/onboard/command.test.ts @@ -162,7 +162,7 @@ describe("onboard command options", () => { }, ); - expect(fresh.servingProfile).toBe("local-model-profile.vllm.spark.v1"); + expect(fresh.servingProfile).toBeNull(); expect(fresh.servingProfileProvenance?.preset.id).toBe("local-model-profile.vllm.spark.v1"); const resumed = resolve( @@ -468,6 +468,26 @@ describe("onboard command options", () => { expect(env.NEMOCLAW_SERVING_PRESET).toBeUndefined(); }); + it("records an installer profile without activating the disabled generic preset", async () => { + const env: NodeJS.ProcessEnv = { + [LOCAL_MODEL_PROFILE_ENABLED_ENV]: "1", + [LOCAL_MODEL_PROFILE_RUNTIME_ENV]: "vllm", + }; + await runOnboardCommand({ + flags: {}, + env, + runOnboard: async (options) => { + expect(options.servingProfile).toBeNull(); + expect(options.servingProfileProvenance?.preset.id).toBe( + "local-model-profile.vllm.spark.v1", + ); + expect(env.NEMOCLAW_SERVING_PRESET).toBeUndefined(); + }, + }); + + expect(env.NEMOCLAW_SERVING_PRESET).toBeUndefined(); + }); + it("prepares and scopes portable profile defaults around onboarding", async () => { const env: NodeJS.ProcessEnv = { NEMOCLAW_EXPERIMENTAL_PROFILE: "previous-profile", diff --git a/src/lib/onboard/command.ts b/src/lib/onboard/command.ts index 92ef5bff68f..90e406772a7 100644 --- a/src/lib/onboard/command.ts +++ b/src/lib/onboard/command.ts @@ -268,6 +268,11 @@ function resolveServingProfileLifecycle( return resolveResumedServingProfile(requested, deps); } +function activeServingProfileId(provenance: ServingProfileProvenance | null): string | null { + if (!provenance || provenance.preset.supportState === "disabled") return null; + return provenance.preset.id; +} + function resolveResumedServingProfile( requested: ServingProfileProvenance | null, deps: ResolveOnboardOptionsDeps, @@ -361,7 +366,7 @@ export function resolveOnboardOptions( autoYes: withPortableDefault(flags.yes, experimentalProfile), noOllamaAutostart: withPortableDefault(flags["no-ollama-autostart"], experimentalProfile), experimentalProfile, - servingProfile: servingProfileProvenance?.preset.id ?? null, + servingProfile: activeServingProfileId(servingProfileProvenance), servingProfileProvenance, }; } diff --git a/test/e2e/README.md b/test/e2e/README.md index f16632f3761..6a72c50f62a 100644 --- a/test/e2e/README.md +++ b/test/e2e/README.md @@ -711,7 +711,7 @@ It does not run the hosted installer bootstrap, clone or ref selection, dependen Separate installer tests own those earlier boundaries. The live target refuses to replace a pre-existing sandbox or `nemoclaw-vllm` container. It preserves the shared Hugging Face cache and removes only the exact sandbox and managed container that the run created. -If onboarding exits nonzero, the target captures the managed-container log tail and sandbox status before cleanup. +If onboarding exits nonzero, the target captures the managed-container log tail and sandbox details before cleanup. The standard E2E artifacts retain bounded command output. Run the target from a clean candidate checkout on the Spark host: diff --git a/test/e2e/live/spark-express-vllm.test.ts b/test/e2e/live/spark-express-vllm.test.ts index e172ab262c2..e5e89dc3bf2 100644 --- a/test/e2e/live/spark-express-vllm.test.ts +++ b/test/e2e/live/spark-express-vllm.test.ts @@ -128,8 +128,8 @@ async function captureOnboardFailureDiagnostics( env: e2eEnv(), timeoutMs: 30_000, }); - await host.command("openshell", ["sandbox", "status", sandboxName], { - artifactName: "failure-spark-express-sandbox-status", + await host.command("openshell", ["sandbox", "get", sandboxName], { + artifactName: "failure-spark-express-sandbox-get", env: e2eEnv(), timeoutMs: 30_000, }); From 34dd5e4a1928e307ce8756499555540c030ad610 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sun, 9 Aug 2026 14:36:24 -0700 Subject: [PATCH 11/19] fix(inference): route Spark vLLM through private bridge --- .../choose-local-inference-server.mdx | 19 +++-- docs/inference/set-up-vllm.mdx | 6 ++ src/lib/inference/local-vllm-auth.test.ts | 49 +++++++++++ src/lib/inference/local.ts | 84 ++++++++++++------- .../serving/vllm-host-local-lifecycle.test.ts | 58 ++++++++++++- .../serving/vllm-host-local-lifecycle.ts | 53 +++++++++--- .../serving/vllm-host-local-network.test.ts | 33 ++++++++ .../serving/vllm-host-local-network.ts | 55 ++++++++++++ .../inference/serving/vllm-managed-support.ts | 5 ++ src/lib/inference/vllm.test.ts | 77 +++++++++++++++++ src/lib/inference/vllm.ts | 52 ++++++++++-- src/lib/onboard/inference-providers/types.ts | 6 +- src/lib/onboard/setup-inference.ts | 6 +- src/lib/onboard/setup-nim-vllm.test.ts | 9 +- src/lib/onboard/setup-nim-vllm.ts | 10 ++- test/e2e/README.md | 2 +- test/e2e/live/spark-express-vllm.test.ts | 68 +++++++++++++-- .../support/spark-express-vllm-safety.test.ts | 20 +++++ test/e2e/support/spark-express-vllm-safety.ts | 38 +++++++++ 19 files changed, 581 insertions(+), 69 deletions(-) create mode 100644 src/lib/inference/serving/vllm-host-local-network.test.ts create mode 100644 src/lib/inference/serving/vllm-host-local-network.ts diff --git a/docs/inference/choose-local-inference-server.mdx b/docs/inference/choose-local-inference-server.mdx index 4d24156d071..359ae3e1e61 100644 --- a/docs/inference/choose-local-inference-server.mdx +++ b/docs/inference/choose-local-inference-server.mdx @@ -113,10 +113,12 @@ The profile performs these actions: - Uses the pinned runtime image and fixed serving command from the catalog. - Stores model files in the host Hugging Face cache. -- Publishes the authenticated server only on `127.0.0.1:8000`. +- Publishes the authenticated server on `127.0.0.1:8000` and the exact private IPv4 gateway of the `openshell-docker` bridge, such as `172.18.0.1:8000`. +- Never publishes the Docker host port on `0.0.0.0` or `::`. - Reuses the owner-only host-global managed-vLLM API key. -After the runtime passes its readiness check, onboarding registers the provider and routes sandbox traffic through `inference.local`. +After the runtime passes its readiness check, onboarding registers the provider at `https://inference.local` inside the sandbox. +OpenShell reaches the authenticated server through the private bridge binding, while host-side readiness and recovery use the loopback binding. Verify the sandbox route: @@ -128,13 +130,20 @@ $$nemoclaw my-assistant doctor Accept the result when `status` reports the inference route as `reachable` and `doctor` exits with status `0`. Route reachability does not by itself establish successful model invocation. -Verify the loopback publication: +Verify the bounded host publication: ```bash -docker port nemoclaw-vllm +docker network inspect \ + --format '{{json .IPAM.Config}}' \ + openshell-docker +docker container inspect \ + --format '{{json (index .NetworkSettings.Ports "8000/tcp")}}' \ + nemoclaw-vllm ``` -The result must contain `127.0.0.1:8000`. +The bridge result must contain exactly one private IPv4 `Gateway` value. +The container result must contain exactly two bindings with `HostPort` set to `8000`: one `HostIp` value of `127.0.0.1` and one that exactly matches the private bridge gateway. +Reject the runtime if a binding uses `0.0.0.0`, `::`, an empty address, another host address, or an additional entry. If installation stops after a model download, rerun the same installer command. The runtime reuses only an artifact that passes its recorded identity checks. diff --git a/docs/inference/set-up-vllm.mdx b/docs/inference/set-up-vllm.mdx index e576b091603..9ffa691e7ae 100644 --- a/docs/inference/set-up-vllm.mdx +++ b/docs/inference/set-up-vllm.mdx @@ -24,6 +24,10 @@ Use a host firewall with default-deny inbound rules. Allow TCP port `8000` only from the OpenShell Docker subnet to its gateway address, keep loopback access, and deny the port on every other interface. Do not expose the port to your LAN or the internet. +An authenticated fixed profile publishes Docker port `8000` on exactly two host addresses: `127.0.0.1` and the exact private IPv4 gateway of the `openshell-docker` bridge. +It never publishes that host port on `0.0.0.0` or `::`. +The provider route remains `https://inference.local` inside the sandbox, and OpenShell reaches the server through the private bridge binding. + The qualified two-DGX Spark and two-DGX Station paths use bearer-protected `/v1` routes and Docker host networking on both runtime containers; network isolation remains required for other endpoints and distributed traffic. Follow [Set Up vLLM on Two DGX Sparks](set-up-vllm-on-two-dgx-sparks) or [Set Up vLLM on Two DGX Stations](set-up-vllm-on-two-dgx-stations) for their isolation requirements. @@ -74,6 +78,8 @@ vllm serve /models/Hermes-3-Llama-3.1-8B \ Use the parser required by your selected model instead of copying `hermes` for another model family. Without both `--enable-auto-tool-choice` and a matching parser, tool calls can appear as raw assistant text instead of structured `tool_calls`. +The `--host` value in this operator-run example controls the listener inside that server process. +For the authenticated fixed profile, evaluate host exposure from its published Docker port bindings as described in [Install a Fixed vLLM Profile](choose-local-inference-server#install-a-fixed-vllm-profile). ## Install or Start Managed vLLM diff --git a/src/lib/inference/local-vllm-auth.test.ts b/src/lib/inference/local-vllm-auth.test.ts index ec77707a1ba..25591dc27e6 100644 --- a/src/lib/inference/local-vllm-auth.test.ts +++ b/src/lib/inference/local-vllm-auth.test.ts @@ -17,6 +17,12 @@ const lifecycle = vi.hoisted(() => ({ const managedClusterRecovery = vi.hoisted(() => ({ endpoint: vi.fn(), })); +const hostLocalRecovery = vi.hoisted(() => ({ + endpoint: vi.fn(), +})); +const managedBridge = vi.hoisted(() => ({ + host: vi.fn(() => "172.18.0.1"), +})); const managedKey = vi.hoisted(() => ({ load: vi.fn(), })); @@ -27,6 +33,11 @@ vi.mock("./vllm-station-cluster-lifecycle", () => ({ vi.mock("./serving/managed-cluster-runtime-receipt", () => ({ recoverInstalledManagedClusterVllmEndpoint: managedClusterRecovery.endpoint, })); +vi.mock("./serving/vllm-host-local-lifecycle", async (importOriginal) => ({ + ...(await importOriginal()), + recoverHostLocalManagedVllmEndpoint: hostLocalRecovery.endpoint, + resolveManagedVllmBridgeHost: managedBridge.host, +})); vi.mock("./vllm-api-key", async (importOriginal) => ({ ...(await importOriginal()), loadManagedVllmApiKey: managedKey.load, @@ -95,6 +106,10 @@ beforeEach(() => { vi.stubEnv(LOCAL_INFERENCE_SANDBOX_HOST_URL_ENV, undefined); managedClusterRecovery.endpoint.mockReset(); managedClusterRecovery.endpoint.mockReturnValue(null); + hostLocalRecovery.endpoint.mockReset(); + hostLocalRecovery.endpoint.mockReturnValue(null); + managedBridge.host.mockReset(); + managedBridge.host.mockReturnValue("172.18.0.1"); managedKey.load.mockReset(); managedKey.load.mockReturnValue(API_KEY); lifecycle.baseUrl.mockReset(); @@ -242,6 +257,40 @@ describe("managed vLLM authentication", () => { }); }); + it("separates the host-local validation URL from the sandbox route (#8379)", () => { + lifecycle.baseUrl.mockReturnValue(null); + const recoverHostLocalManagedVllmEndpointImpl = vi.fn(() => ({ + baseUrl: "http://127.0.0.1:8000", + apiKey: API_KEY, + })); + + expect( + getManagedVllmProviderBinding({ + loadApiKeyImpl: () => API_KEY, + recoverHostLocalManagedVllmEndpointImpl, + }), + ).toEqual({ + baseUrl: "http://host.openshell.internal:8000/v1", + validationBaseUrl: "http://127.0.0.1:8000/v1", + apiKey: API_KEY, + }); + }); + + it("pins host-local reachability to the exact OpenShell bridge (#8379)", () => { + lifecycle.baseUrl.mockReturnValue(null); + hostLocalRecovery.endpoint.mockReturnValue({ + baseUrl: "http://127.0.0.1:8000", + apiKey: API_KEY, + }); + + const command = getLocalProviderContainerReachabilityCheck("vllm-local"); + + expect(command).toContain("host.openshell.internal:172.18.0.1"); + expect(command).not.toContain("host.openshell.internal:host-gateway"); + expect(command?.at(-1)).toBe("http://host.openshell.internal:8000/health"); + expect(managedBridge.host).toHaveBeenCalledOnce(); + }); + it("uses /health only for unauthenticated availability checks", () => { expect(getLocalProviderHealthEndpoint("vllm-local")).toBe(`${BASE_URL}/v1/models`); expect(getLocalProviderHealthCheck("vllm-local")).toEqual([ diff --git a/src/lib/inference/local.ts b/src/lib/inference/local.ts index 3341ea9ac93..a3d83851980 100644 --- a/src/lib/inference/local.ts +++ b/src/lib/inference/local.ts @@ -50,7 +50,10 @@ import { type RecoveredManagedClusterVllmEndpoint, recoverInstalledManagedClusterVllmEndpoint, } from "./serving/managed-cluster-runtime-receipt"; -import { recoverHostLocalManagedVllmEndpoint } from "./serving/vllm-host-local-lifecycle"; +import { + recoverHostLocalManagedVllmEndpoint, + resolveManagedVllmBridgeHost, +} from "./serving/vllm-host-local-lifecycle"; import { loadManagedVllmApiKey } from "./vllm-api-key"; import { applyVllmRuntimeContextWindow as applyVllmRuntimeContextWindowFromModels } from "./vllm-runtime-context"; import { getDualStationManagedVllmBaseUrl } from "./vllm-station-cluster-lifecycle"; @@ -430,17 +433,14 @@ function configuredLocalInferenceHostUrl(hostUrl?: string | null): string | null ); } -type RecoveredManagedVllmBaseUrl = - | { readonly kind: "available"; readonly baseUrl: string | null } +type RecoveredManagedVllmBinding = + | { readonly kind: "available"; readonly binding: ManagedVllmProviderBinding | null } | { readonly kind: "unavailable" }; -function recoveredManagedVllmBaseUrl(): RecoveredManagedVllmBaseUrl { - if (configuredLocalInferenceHostUrl()) return { kind: "available", baseUrl: null }; +function recoveredManagedVllmBinding(): RecoveredManagedVllmBinding { + if (configuredLocalInferenceHostUrl()) return { kind: "available", binding: null }; try { - return { - kind: "available", - baseUrl: getManagedVllmProviderBinding()?.baseUrl.replace(/\/v1\/?$/, "") ?? null, - }; + return { kind: "available", binding: getManagedVllmProviderBinding() }; } catch { return { kind: "unavailable" }; } @@ -448,6 +448,7 @@ function recoveredManagedVllmBaseUrl(): RecoveredManagedVllmBaseUrl { export interface ManagedVllmProviderBinding { baseUrl: string; + validationBaseUrl?: string; apiKey: string; } @@ -578,7 +579,8 @@ export function getManagedVllmProviderState( if (hostLocalEndpoint) { return { kind: "ready", - baseUrl: `${hostLocalEndpoint.baseUrl.replace(/\/+$/, "")}/v1`, + baseUrl: `${HOST_GATEWAY_URL}:${String(VLLM_PORT)}/v1`, + validationBaseUrl: `${hostLocalEndpoint.baseUrl.replace(/\/+$/, "")}/v1`, apiKey: hostLocalEndpoint.apiKey, }; } @@ -601,7 +603,11 @@ export function getManagedVllmProviderBinding( } throw new Error("Managed vLLM authentication is missing."); } - return { baseUrl: state.baseUrl, apiKey: state.apiKey }; + return { + baseUrl: state.baseUrl, + ...(state.validationBaseUrl ? { validationBaseUrl: state.validationBaseUrl } : {}), + apiKey: state.apiKey, + }; } export function getLocalProviderBaseUrl( @@ -613,9 +619,9 @@ export function getLocalProviderBaseUrl( switch (provider) { case "vllm-local": { if (!configuredHostUrl) { - const managed = recoveredManagedVllmBaseUrl(); + const managed = recoveredManagedVllmBinding(); if (managed.kind === "unavailable") return null; - if (managed.baseUrl) return `${managed.baseUrl}/v1`; + if (managed.binding) return managed.binding.baseUrl; } return `${hostUrl}:${VLLM_PORT}/v1`; } @@ -630,9 +636,11 @@ export function getLocalProviderBaseUrl( export function getLocalProviderValidationBaseUrl(provider: string): string | null { switch (provider) { case "vllm-local": { - const managed = recoveredManagedVllmBaseUrl(); + const managed = recoveredManagedVllmBinding(); if (managed.kind === "unavailable") return null; - return managed.baseUrl ? `${managed.baseUrl}/v1` : `http://127.0.0.1:${VLLM_PORT}/v1`; + return managed.binding + ? (managed.binding.validationBaseUrl ?? managed.binding.baseUrl) + : `http://127.0.0.1:${VLLM_PORT}/v1`; } case "ollama-local": return `http://${getResolvedOllamaHost()}:${OLLAMA_PORT}/v1`; @@ -644,10 +652,13 @@ export function getLocalProviderValidationBaseUrl(provider: string): string | nu export function getLocalProviderHealthEndpoint(provider: string): string | null { switch (provider) { case "vllm-local": { - const managed = recoveredManagedVllmBaseUrl(); + const managed = recoveredManagedVllmBinding(); if (managed.kind === "unavailable") return null; - return managed.baseUrl - ? `${managed.baseUrl}/v1/models` + const managedBaseUrl = managed.binding + ? (managed.binding.validationBaseUrl ?? managed.binding.baseUrl) + : null; + return managedBaseUrl + ? `${managedBaseUrl}/models` : `http://127.0.0.1:${VLLM_PORT}/v1/models`; } case "ollama-local": @@ -660,9 +671,15 @@ export function getLocalProviderHealthEndpoint(provider: string): string | null /** Lightweight endpoint used only to prove that the local service is reachable. */ export function getLocalProviderAvailabilityEndpoint(provider: string): string | null { if (provider === "vllm-local") { - const managed = recoveredManagedVllmBaseUrl(); + const managed = recoveredManagedVllmBinding(); if (managed.kind === "unavailable") return null; - if (managed.baseUrl) return `${managed.baseUrl}/health`; + if (managed.binding) { + const validationRoot = (managed.binding.validationBaseUrl ?? managed.binding.baseUrl).replace( + /\/v1\/?$/, + "", + ); + return `${validationRoot}/health`; + } return `http://127.0.0.1:${VLLM_PORT}/v1/models`; } return getLocalProviderHealthEndpoint(provider); @@ -888,8 +905,11 @@ export function probeLocalProviderHealth( }; } const managedBinding = managedState.kind === "ready" ? managedState : null; - const endpoint = managedBinding - ? `${managedBinding.baseUrl}/models` + const managedValidationBaseUrl = managedBinding + ? (managedBinding.validationBaseUrl ?? managedBinding.baseUrl) + : null; + const endpoint = managedValidationBaseUrl + ? `${managedValidationBaseUrl}/models` : provider === "vllm-local" ? `http://127.0.0.1:${VLLM_PORT}/v1/models` : getLocalProviderHealthEndpoint(provider); @@ -898,7 +918,9 @@ export function probeLocalProviderHealth( const runCurlProbeImpl = options.runCurlProbeImpl ?? runLocalCurlProbe; let result: CurlProbeResult; if (managedBinding) { - result = probeVllmModels(managedBinding.baseUrl, managedBinding.apiKey, { runCurlProbeImpl }); + result = probeVllmModels(managedValidationBaseUrl!, managedBinding.apiKey, { + runCurlProbeImpl, + }); } else { result = runCurlProbeImpl(["-sS", "--connect-timeout", "3", "--max-time", "5", endpoint]); } @@ -1002,15 +1024,18 @@ export function probeLocalProviderHealth( export function getLocalProviderContainerReachabilityCheck(provider: string): string[] | null { switch (provider) { case "vllm-local": { - const managed = recoveredManagedVllmBaseUrl(); + const managed = recoveredManagedVllmBinding(); if (managed.kind === "unavailable") return null; - const managedBaseUrl = managed.baseUrl; + const managedBaseUrl = managed.binding?.baseUrl.replace(/\/v1\/?$/, "") ?? null; + const hostAlias = managed.binding?.validationBaseUrl + ? `host.openshell.internal:${resolveManagedVllmBridgeHost()}` + : "host.openshell.internal:host-gateway"; return [ ...(managedBaseUrl ? ["docker", "--context", "default"] : ["docker"]), "run", "--rm", "--add-host", - "host.openshell.internal:host-gateway", + hostAlias, CONTAINER_REACHABILITY_IMAGE, "--connect-timeout", "5", @@ -1153,10 +1178,11 @@ export function validateLocalProvider( function getContainerCheckUrl(provider: string): string | null { switch (provider) { case "vllm-local": { - const managed = recoveredManagedVllmBaseUrl(); + const managed = recoveredManagedVllmBinding(); if (managed.kind === "unavailable") return null; - return managed.baseUrl - ? `${managed.baseUrl}/health` + const managedBaseUrl = managed.binding?.baseUrl.replace(/\/v1\/?$/, "") ?? null; + return managedBaseUrl + ? `${managedBaseUrl}/health` : `http://host.openshell.internal:${VLLM_PORT}/v1/models`; } case "ollama-local": diff --git a/src/lib/inference/serving/vllm-host-local-lifecycle.test.ts b/src/lib/inference/serving/vllm-host-local-lifecycle.test.ts index 45ec6606dc7..a89f020f011 100644 --- a/src/lib/inference/serving/vllm-host-local-lifecycle.test.ts +++ b/src/lib/inference/serving/vllm-host-local-lifecycle.test.ts @@ -18,9 +18,19 @@ import { HOST_LOCAL_VLLM_RECIPE_DIGEST_LABEL, HOST_LOCAL_VLLM_RECIPE_LABEL, persistHostLocalVllmRuntimeReceipt, + type RecoverHostLocalManagedVllmOptions, recoverHostLocalManagedVllmEndpoint, } from "./vllm-host-local-lifecycle"; +const networkMocks = vi.hoisted(() => ({ + resolveBridgeHost: vi.fn(() => "172.18.0.1"), +})); + +vi.mock("./vllm-host-local-network", async (importOriginal) => ({ + ...(await importOriginal()), + resolveManagedVllmBridgeHost: networkMocks.resolveBridgeHost, +})); + const API_KEY = "b".repeat(64); const IDENTITY = { catalogDigest: `sha256:${"1".repeat(64)}`, @@ -45,6 +55,8 @@ function stateDir(): string { } afterEach(() => { + vi.unstubAllEnvs(); + networkMocks.resolveBridgeHost.mockClear(); for (const directory of temporaryDirectories.splice(0)) { fs.rmSync(directory, { force: true, recursive: true }); } @@ -54,6 +66,7 @@ function inspect( key = API_KEY, fingerprint = runtimeAuthFingerprint(key), labels: Record = {}, + bridgeHost = "172.18.0.1", ) { return JSON.stringify([ { @@ -68,13 +81,45 @@ function inspect( ...labels, }, }, - NetworkSettings: { Ports: { "8000/tcp": [{ HostIp: "127.0.0.1", HostPort: "8000" }] } }, + NetworkSettings: { + Ports: { + "8000/tcp": [ + { HostIp: "127.0.0.1", HostPort: "8000" }, + { HostIp: bridgeHost, HostPort: "8000" }, + ], + }, + }, }, ]); } describe("host-local managed vLLM recovery", () => { - it("recovers one owned loopback endpoint with the matching key", () => { + it("pins inspection and bridge discovery to the physical default Docker daemon", () => { + vi.stubEnv("DOCKER_CONTEXT", "remote-context"); + vi.stubEnv("DOCKER_HOST", "tcp://remote.example:2376"); + vi.stubEnv("DOCKER_CONFIG", "/tmp/remote-docker-config"); + const capture = vi.fn( + (..._args: Parameters>) => + inspect(), + ); + + expect( + recoverHostLocalManagedVllmEndpoint({ + dockerCapture: capture, + loadApiKey: () => API_KEY, + }), + ).toEqual({ baseUrl: "http://127.0.0.1:8000", apiKey: API_KEY }); + + expect(capture).toHaveBeenCalledOnce(); + const dockerOptions = capture.mock.calls[0]?.[1]; + expect(dockerOptions?.env).toMatchObject({ DOCKER_CONTEXT: "default" }); + expect(dockerOptions?.env).not.toHaveProperty("DOCKER_HOST"); + expect(dockerOptions?.env).not.toHaveProperty("DOCKER_CONFIG"); + expect(networkMocks.resolveBridgeHost).toHaveBeenCalledWith(capture, dockerOptions?.env); + expect(networkMocks.resolveBridgeHost.mock.calls[0]?.[1]).toBe(dockerOptions?.env); + }); + + it("recovers one owned endpoint with loopback and current OpenShell bridge bindings", () => { const observed = vi.fn(); expect( recoverHostLocalManagedVllmEndpoint({ @@ -95,6 +140,15 @@ describe("host-local managed vLLM recovery", () => { ).toThrow("missing or mismatched"); }); + it("rejects a private binding that is not the current OpenShell bridge (#8379)", () => { + expect(() => + recoverHostLocalManagedVllmEndpoint({ + dockerInspect: () => inspect(API_KEY, runtimeAuthFingerprint(API_KEY), {}, "172.19.0.1"), + loadApiKey: () => API_KEY, + }), + ).toThrow("unsafe or incomplete"); + }); + it("recovers a profile-labeled runtime only with an exact ownership receipt", () => { const directory = stateDir(); persistHostLocalVllmRuntimeReceipt( diff --git a/src/lib/inference/serving/vllm-host-local-lifecycle.ts b/src/lib/inference/serving/vllm-host-local-lifecycle.ts index f8823f10d19..1b6d2f81c91 100644 --- a/src/lib/inference/serving/vllm-host-local-lifecycle.ts +++ b/src/lib/inference/serving/vllm-host-local-lifecycle.ts @@ -8,8 +8,14 @@ import path from "node:path"; import { dockerCapture } from "../../adapters/docker/local-model-runtime"; import { writeLocalAdapterJsonFile } from "../local-adapter-lifecycle"; import { loadManagedVllmApiKey, managedVllmStateDir } from "../vllm-api-key"; -import { buildVllmDockerEnv } from "../vllm-docker-env"; +import { buildLocalManagedVllmDockerEnv } from "../vllm-docker-env"; import { runtimeAuthFingerprint } from "./runtime-auth-fingerprint"; +import { + resolveManagedVllmBridgeHost, + validateManagedVllmBridgeHost, +} from "./vllm-host-local-network"; + +export { resolveManagedVllmBridgeHost }; export const HOST_LOCAL_VLLM_CONTAINER_NAME = "nemoclaw-vllm" as const; export const HOST_LOCAL_VLLM_MANAGED_LABEL = "com.nvidia.nemoclaw.managed-vllm" as const; @@ -50,9 +56,11 @@ interface HostLocalVllmRuntimeReceipt { } export interface RecoverHostLocalManagedVllmOptions { + dockerCapture?: typeof dockerCapture; dockerInspect?: () => string; loadApiKey?: () => string | null; onManagedContainerObserved?: () => void; + resolveBridgeHost?: () => string; stateDir?: string; } @@ -254,9 +262,12 @@ function equalHex(left: string, right: string): boolean { return timingSafeEqual(Buffer.from(left, "hex"), Buffer.from(right, "hex")); } -function inspectHostLocalContainer(): string { - return dockerCapture(["container", "inspect", HOST_LOCAL_VLLM_CONTAINER_NAME], { - env: buildVllmDockerEnv(), +function inspectHostLocalContainer( + capture: typeof dockerCapture, + dockerEnv: Record, +): string { + return capture(["container", "inspect", HOST_LOCAL_VLLM_CONTAINER_NAME], { + env: dockerEnv, ignoreError: true, timeout: 10_000, }); @@ -266,7 +277,11 @@ function inspectHostLocalContainer(): string { export function recoverHostLocalManagedVllmEndpoint( options: RecoverHostLocalManagedVllmOptions = {}, ): { baseUrl: string; apiKey: string } | null { - const source = (options.dockerInspect ?? inspectHostLocalContainer)().trim(); + const capture = options.dockerCapture ?? dockerCapture; + const dockerEnv = buildLocalManagedVllmDockerEnv(); + const source = ( + options.dockerInspect ?? (() => inspectHostLocalContainer(capture, dockerEnv)) + )().trim(); if (!source) return null; let parsed: unknown; @@ -295,7 +310,24 @@ export function recoverHostLocalManagedVllmEndpoint( ports && typeof ports === "object" && !Array.isArray(ports) ? (ports as Record)["8000/tcp"] : null; - const binding = Array.isArray(portBindings) && portBindings.length === 1 ? portBindings[0] : null; + const bindings = Array.isArray(portBindings) ? portBindings : []; + const loopbackBinding = bindings.find( + (binding) => + binding && + typeof binding === "object" && + (binding as { HostIp?: unknown }).HostIp === "127.0.0.1", + ); + const expectedBridgeHost = validateManagedVllmBridgeHost( + options.resolveBridgeHost + ? options.resolveBridgeHost() + : resolveManagedVllmBridgeHost(capture, dockerEnv), + ); + const bridgeBinding = bindings.find( + (binding) => + binding && + typeof binding === "object" && + (binding as { HostIp?: unknown }).HostIp === expectedBridgeHost, + ); const env = Array.isArray(row.Config?.Env) ? row.Config.Env : []; const configuredKeyRows = env.filter( (value): value is string => typeof value === "string" && value.startsWith("VLLM_API_KEY="), @@ -306,10 +338,11 @@ export function recoverHostLocalManagedVllmEndpoint( !/^[a-f0-9]{12,64}$/.test(row.Id) || row.Name !== `/${HOST_LOCAL_VLLM_CONTAINER_NAME}` || row.State?.Running !== true || - !binding || - typeof binding !== "object" || - (binding as { HostIp?: unknown }).HostIp !== "127.0.0.1" || - (binding as { HostPort?: unknown }).HostPort !== String(HOST_LOCAL_VLLM_PORT) || + bindings.length !== 2 || + !loopbackBinding || + !bridgeBinding || + (loopbackBinding as { HostPort?: unknown }).HostPort !== String(HOST_LOCAL_VLLM_PORT) || + (bridgeBinding as { HostPort?: unknown }).HostPort !== String(HOST_LOCAL_VLLM_PORT) || configuredKeyRows.length !== 1 || typeof authFingerprint !== "string" ) { diff --git a/src/lib/inference/serving/vllm-host-local-network.test.ts b/src/lib/inference/serving/vllm-host-local-network.test.ts new file mode 100644 index 00000000000..74a6a58779c --- /dev/null +++ b/src/lib/inference/serving/vllm-host-local-network.test.ts @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from "vitest"; + +import type { dockerCapture } from "../../adapters/docker/local-model-runtime"; +import { resolveManagedVllmBridgeHost } from "./vllm-host-local-network"; + +function capture(value: string): typeof dockerCapture { + return vi.fn(() => value) as unknown as typeof dockerCapture; +} + +describe("managed host-local vLLM bridge", () => { + it("resolves one private OpenShell bridge gateway (#8379)", () => { + const run = capture(JSON.stringify([{ Subnet: "172.18.0.0/16", Gateway: "172.18.0.1" }])); + const dockerEnv = { DOCKER_CONTEXT: "default", PATH: "/usr/bin" }; + expect(resolveManagedVllmBridgeHost(run, dockerEnv)).toBe("172.18.0.1"); + expect(run).toHaveBeenCalledWith( + ["network", "inspect", "--format", "{{json .IPAM.Config}}", "openshell-docker"], + { env: dockerEnv, ignoreError: true, timeout: 10_000 }, + ); + }); + + it.each([ + ["invalid JSON", "not-json"], + ["missing gateway", JSON.stringify([{ Subnet: "172.18.0.0/16" }])], + ["public gateway", JSON.stringify([{ Gateway: "203.0.113.1" }])], + ["zero gateway", JSON.stringify([{ Gateway: "0.0.0.0" }])], + ["multiple gateways", JSON.stringify([{ Gateway: "172.18.0.1" }, { Gateway: "172.19.0.1" }])], + ])("rejects %s", (_label, value) => { + expect(() => resolveManagedVllmBridgeHost(capture(value))).toThrow(/OpenShell bridge|private/); + }); +}); diff --git a/src/lib/inference/serving/vllm-host-local-network.ts b/src/lib/inference/serving/vllm-host-local-network.ts new file mode 100644 index 00000000000..03a286769c1 --- /dev/null +++ b/src/lib/inference/serving/vllm-host-local-network.ts @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import net from "node:net"; + +import { dockerCapture } from "../../adapters/docker/local-model-runtime"; +export function validateManagedVllmBridgeHost(value: string): string { + const [first, second] = value.split(".").map(Number); + const privateIpv4 = + net.isIP(value) === 4 && + (first === 10 || + (first === 172 && second! >= 16 && second! <= 31) || + (first === 192 && second === 168)); + if (!privateIpv4) { + throw new Error("Managed host-local vLLM requires one private OpenShell bridge address"); + } + return value; +} + +function isManagedVllmBridgeHost(value: unknown): value is string { + if (typeof value !== "string") return false; + try { + validateManagedVllmBridgeHost(value); + return true; + } catch { + return false; + } +} + +export function resolveManagedVllmBridgeHost( + capture: typeof dockerCapture = dockerCapture, + dockerEnv?: Record, +): string { + const raw = capture( + ["network", "inspect", "--format", "{{json .IPAM.Config}}", "openshell-docker"], + { ...(dockerEnv ? { env: dockerEnv } : {}), ignoreError: true, timeout: 10_000 }, + ).trim(); + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw new Error("Managed host-local vLLM could not inspect the OpenShell bridge"); + } + const gateways = Array.isArray(parsed) + ? parsed.flatMap((entry) => { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) return []; + const gateway = (entry as Record).Gateway; + return isManagedVllmBridgeHost(gateway) ? [gateway] : []; + }) + : []; + if (gateways.length !== 1) { + throw new Error("Managed host-local vLLM requires one private OpenShell bridge address"); + } + return validateManagedVllmBridgeHost(gateways[0]!); +} diff --git a/src/lib/inference/serving/vllm-managed-support.ts b/src/lib/inference/serving/vllm-managed-support.ts index d332232b8fa..66200ec4453 100644 --- a/src/lib/inference/serving/vllm-managed-support.ts +++ b/src/lib/inference/serving/vllm-managed-support.ts @@ -4,6 +4,7 @@ export { ensureDualStationVllmApiKey, loadDualStationVllmApiKey } from "../vllm-api-key.js"; export { buildLocalDualStationDockerEnv, + buildLocalManagedVllmDockerEnv, buildRemoteVllmDockerEnv, buildVllmDockerEnv, } from "../vllm-docker-env.js"; @@ -19,3 +20,7 @@ export { persistHostLocalVllmRuntimeReceipt, recoverHostLocalManagedVllmEndpoint, } from "./vllm-host-local-lifecycle.js"; +export { + resolveManagedVllmBridgeHost, + validateManagedVllmBridgeHost, +} from "./vllm-host-local-network.js"; diff --git a/src/lib/inference/vllm.test.ts b/src/lib/inference/vllm.test.ts index bf64d14ecc1..38c4eb55297 100644 --- a/src/lib/inference/vllm.test.ts +++ b/src/lib/inference/vllm.test.ts @@ -445,6 +445,24 @@ describe("vLLM run command", () => { expect(args).toContain("8000:8000"); }); + it("publishes authenticated managed vLLM only on loopback and the private OpenShell bridge (#8379)", () => { + const profile = detectVllmProfile({ platform: "spark", type: "nvidia" })!; + const model = { ...profile.defaultModel, managedBearerAuth: true as const }; + const args = buildVllmRunArgs( + profile, + model, + profile.dockerRunFlags, + { + VLLM_API_KEY: "a".repeat(64), + }, + "172.18.0.1", + ); + const bindings = args.flatMap((value, index) => (value === "-p" ? [args[index + 1]] : [])); + + expect(bindings).toEqual(["127.0.0.1:8000:8000", "172.18.0.1:8000:8000"]); + expect(bindings.join(" ")).not.toMatch(/(?:0\.0\.0\.0|\[?::\]?):8000/u); + }); + it("labels catalog-selected host-local containers with immutable recipe provenance (#8246)", () => { const profile = detectVllmProfile({ platform: "spark", type: "nvidia" }); expect(profile).not.toBeNull(); @@ -993,6 +1011,7 @@ describe("installVllm model resolution", () => { hasImage: true, nonInteractive: true, promptFn: vi.fn(), + resolveManagedBridgeHost: () => "172.18.0.1", }); expect(result).toEqual({ ok: false }); expect(errSpy).toHaveBeenCalledWith(expect.stringContaining("unauthenticated model inventory")); @@ -1087,6 +1106,64 @@ describe("installVllm model resolution", () => { } }); + it("pins authenticated host-local installs to the physical default daemon (#8379)", async () => { + process.env.DOCKER_CONTEXT = "remote-builder"; + process.env.DOCKER_HOST = "ssh://remote.example.test"; + process.env.DOCKER_CONFIG = "/tmp/remote-docker-config"; + const baseProfile = detectVllmProfile({ platform: "spark", type: "nvidia" })!; + const servingCatalog = { + catalogDigest: `sha256:${"1".repeat(64)}`, + presetId: "local-model-profile.vllm.spark.v1", + presetDigest: `sha256:${"2".repeat(64)}`, + recipeId: "vllm.qwen3-6-35b-a3b-nvfp4.spark-single.v1", + recipeDigest: `sha256:${"3".repeat(64)}`, + }; + const model = { ...baseProfile.defaultModel, managedBearerAuth: true as const }; + const profile = { ...baseProfile, defaultModel: model, servingCatalog }; + mocks.resolveHostLocalVllmSelection.mockReturnValue({ + kind: "selected", + profile, + model, + presetId: servingCatalog.presetId, + recipeId: servingCatalog.recipeId, + }); + mockSuccessfulVllmInstall(mocks, profile.containerName); + const resolveManagedBridgeHost = vi.fn(() => "172.18.0.1"); + + const result = await installVllm(baseProfile, { + hasImage: false, + nonInteractive: true, + promptFn: vi.fn(), + resolveManagedBridgeHost, + }); + + expect(result).toEqual({ ok: false }); + expect(mocks.probeDockerStorage).toHaveBeenCalledWith( + expect.objectContaining({ dockerContext: "default", dockerHost: undefined }), + ); + 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]), + ...mocks.dockerStop.mock.calls.map((call) => call[1]), + ]; + expect(dockerAdapterOptions.length).toBeGreaterThan(0); + for (const options of dockerAdapterOptions) { + expect(options.env.DOCKER_CONTEXT).toBe("default"); + expect(options.env.DOCKER_HOST).toBeUndefined(); + expect(options.env.DOCKER_CONFIG).toBeUndefined(); + } + expect(resolveManagedBridgeHost).toHaveBeenCalledWith( + expect.objectContaining({ + DOCKER_CONTEXT: "default", + VLLM_API_KEY: "b".repeat(64), + }), + ); + }); + it("fails before image pull when the host Hugging Face cache cannot be created", async () => { const profile = detectVllmProfile({ platform: "spark", type: "nvidia" })!; mockSuccessfulVllmInstall(mocks, profile.containerName); diff --git a/src/lib/inference/vllm.ts b/src/lib/inference/vllm.ts index 29810de3389..c1f4bc67e8b 100644 --- a/src/lib/inference/vllm.ts +++ b/src/lib/inference/vllm.ts @@ -36,6 +36,7 @@ import { import { getGpuIndicesByName } from "./nim"; import { buildLocalDualStationDockerEnv, + buildLocalManagedVllmDockerEnv, buildRemoteVllmDockerEnv, buildVllmDockerEnv, ensureDualStationVllmApiKey, @@ -45,9 +46,11 @@ import { recoverHostLocalManagedVllmEndpoint, recoverInstalledManagedClusterVllmEndpoint, resolveHostLocalVllmSelection, + resolveManagedVllmBridgeHost, resolveVllmInstallModel, runtimeAuthFingerprint, tryInstallManagedClusterManagedVllm, + validateManagedVllmBridgeHost, } from "./serving/vllm-managed-support"; import { assertGatedModelAccess, @@ -515,6 +518,7 @@ export function buildVllmRunArgs( model: VllmModelDef, runFlags: readonly string[], env: NodeJS.ProcessEnv = process.env, + managedBridgeHost?: string, ): string[] { assertVllmRegistryDigestRef(profile.image); const image = validateDockerArg(profile.image, "vLLM image"); @@ -524,6 +528,9 @@ export function buildVllmRunArgs( if (model.managedBearerAuth && !/^[a-f0-9]{64}$/.test(managedApiKey)) { throw new Error("Managed host-local vLLM requires a valid host-global API key"); } + const managedPublishHost = model.managedBearerAuth + ? validateManagedVllmBridgeHost(managedBridgeHost ?? "") + : null; return [ "--pull=never", "--init", @@ -556,6 +563,7 @@ export function buildVllmRunArgs( : []), "-p", `${model.managedBearerAuth ? "127.0.0.1:" : ""}${String(VLLM_PORT)}:8000`, + ...(model.managedBearerAuth ? ["-p", `${managedPublishHost}:${String(VLLM_PORT)}:8000`] : []), "--name", containerName, "--entrypoint", @@ -714,8 +722,11 @@ function inspectVllmContainerOwnership(containerName: string): VllmContainerOwne function vllmContainerReplacementTarget( containerName: string, + dockerEnv?: Record, ): { ok: true; containerId?: string } | { ok: false; reason: string } { - const ownership = inspectVllmContainerOwnership(containerName); + const ownership = dockerEnv + ? inspectVllmContainerOwnershipInDockerEnv(containerName, dockerEnv) + : inspectVllmContainerOwnership(containerName); if (ownership.kind === "foreign") { return { ok: false, @@ -840,6 +851,8 @@ function startContainer( profile: VllmProfile, model: VllmModelDef, dockerEnv: Record = buildVllmDockerEnv(), + resolveBridgeHost: (dockerEnv: Record) => string = (env) => + resolveManagedVllmBridgeHost(dockerCapture, env), ): { ok: true; containerId: string } | { ok: false; reason: string } { emit(`Starting vLLM container (${profile.containerName})`); // The explicit download completed before this long-lived container starts, @@ -849,13 +862,22 @@ function startContainer( const resolvedFlags = profile.buildDockerRunFlags ? profile.buildDockerRunFlags() : profile.dockerRunFlags; - runArgs = buildVllmRunArgs(profile, model, resolvedFlags, dockerEnv); + runArgs = buildVllmRunArgs( + profile, + model, + resolvedFlags, + dockerEnv, + model.managedBearerAuth ? resolveBridgeHost(dockerEnv) : undefined, + ); } catch (err) { return { ok: false, reason: (err as Error).message }; } // Re-check immediately before teardown. Removing the inspected container ID // avoids deleting an unrelated same-name container if the name changes hands. - const replacement = vllmContainerReplacementTarget(profile.containerName); + const replacement = vllmContainerReplacementTarget( + profile.containerName, + model.managedBearerAuth ? dockerEnv : undefined, + ); if (!replacement.ok) return replacement; if (replacement.containerId) { dockerForceRm(replacement.containerId, { @@ -1421,6 +1443,7 @@ interface InstallVllmOptions { nonInteractive: boolean; promptFn: (q: string) => Promise; beforeInstall?: (modelId: string) => void; + resolveManagedBridgeHost?: (dockerEnv: Record) => string; } export function imageIsCached( @@ -1672,7 +1695,9 @@ async function runVllmInstall( } const localDockerEnv = dualStationPlan ? buildLocalDualStationDockerEnv() - : buildVllmDockerEnv(hostLocalApiKey ? { VLLM_API_KEY: hostLocalApiKey } : {}); + : hostLocalApiKey + ? buildLocalManagedVllmDockerEnv({ VLLM_API_KEY: hostLocalApiKey }) + : buildVllmDockerEnv(); opts.beforeInstall?.(servedModelId); console.log(""); @@ -1729,7 +1754,10 @@ async function runVllmInstall( return { ok: false }; } } else { - const replacement = vllmContainerReplacementTarget(runtimeProfile.containerName); + const replacement = vllmContainerReplacementTarget( + runtimeProfile.containerName, + model.managedBearerAuth ? localDockerEnv : undefined, + ); if (!replacement.ok) { console.error(` vLLM install failed: ${replacement.reason}`); return { ok: false }; @@ -1780,7 +1808,10 @@ async function runVllmInstall( // A cold image pull can consume the same host filesystem that backs the // Hugging Face cache. Re-probe the model destination after the pull before // `hf download` starts. - if (!hasImage && !(await managedStorageAccepted(runtimeProfile, model, true, opts))) { + if ( + !hasImage && + !(await managedStorageAccepted(runtimeProfile, model, true, opts, localDockerEnv)) + ) { return { ok: false }; } @@ -1965,7 +1996,12 @@ async function runVllmInstall( } } - const start = startContainer(runtimeProfile, model, localDockerEnv); + const start = startContainer( + runtimeProfile, + model, + localDockerEnv, + opts.resolveManagedBridgeHost, + ); if (!start.ok) { console.error(` vLLM install failed: ${String(start.reason)}`); return { ok: false }; @@ -2011,7 +2047,7 @@ async function runVllmInstall( if (!ready.ok) { printContainerLogTail(runtimeProfile, localDockerEnv); dockerStop(runtimeProfile.containerName, { - env: buildVllmDockerEnv(), + env: localDockerEnv, ignoreError: true, suppressOutput: true, }); diff --git a/src/lib/onboard/inference-providers/types.ts b/src/lib/onboard/inference-providers/types.ts index b1840a05329..452ef02f48e 100644 --- a/src/lib/onboard/inference-providers/types.ts +++ b/src/lib/onboard/inference-providers/types.ts @@ -226,7 +226,11 @@ export type VllmDeps = CommonDeps & { applyLocalInferenceRoute: (provider: string, model: string) => Promise; run: RunFn; VLLM_LOCAL_CREDENTIAL_ENV: string; - getManagedVllmProviderBinding: () => { baseUrl: string; apiKey: string } | null; + getManagedVllmProviderBinding: () => { + baseUrl: string; + validationBaseUrl?: string; + apiKey: string; + } | null; }; export type OllamaDeps = CommonDeps & { diff --git a/src/lib/onboard/setup-inference.ts b/src/lib/onboard/setup-inference.ts index 423b1ba0553..fdd769e98ff 100644 --- a/src/lib/onboard/setup-inference.ts +++ b/src/lib/onboard/setup-inference.ts @@ -119,7 +119,11 @@ export type SetupInferenceDeps = ProviderBranchDeps & { updateSandbox: typeof import("../state/registry").reserveSandboxInferenceRoute; localInferenceTimeoutSecs: number; vllmLocalCredentialEnv: string; - getManagedVllmProviderBinding?: () => { baseUrl: string; apiKey: string } | null; + getManagedVllmProviderBinding?: () => { + baseUrl: string; + validationBaseUrl?: string; + apiKey: string; + } | null; ollamaProxyCredentialEnv: string; isRoutedInferenceProvider: (provider: string) => boolean; applyLocalInferenceRoute?: VllmDeps["applyLocalInferenceRoute"]; diff --git a/src/lib/onboard/setup-nim-vllm.test.ts b/src/lib/onboard/setup-nim-vllm.test.ts index a83fe7a746e..e99d9d68216 100644 --- a/src/lib/onboard/setup-nim-vllm.test.ts +++ b/src/lib/onboard/setup-nim-vllm.test.ts @@ -92,13 +92,15 @@ describe("setupNim vLLM route containment", () => { ok: true, api: "openai-completions", })); + const selection = state(null); const handler = createSetupNimVllmHandler( deps({ runCapture, - getLocalProviderBaseUrl: () => "http://10.40.0.1:8000/v1", + getLocalProviderBaseUrl: () => "http://host.openshell.internal:8000/v1", getLocalProviderValidationBaseUrl: () => "http://10.40.0.1:8000/v1", getManagedVllmProviderBinding: () => ({ - baseUrl: "http://10.40.0.1:8000/v1", + baseUrl: "http://host.openshell.internal:8000/v1", + validationBaseUrl: "http://10.40.0.1:8000/v1", apiKey, }), queryVllmModels, @@ -106,7 +108,8 @@ describe("setupNim vLLM route containment", () => { }), ); - await expect(handler(state(null))).resolves.toBe("selected"); + await expect(handler(selection)).resolves.toBe("selected"); + expect(selection.endpointUrl).toBe("http://host.openshell.internal:8000/v1"); expect(runCapture).not.toHaveBeenCalled(); expect(queryVllmModels).toHaveBeenCalledWith("http://10.40.0.1:8000/v1", apiKey); expect(validateOpenAiLikeSelection).toHaveBeenCalledWith( diff --git a/src/lib/onboard/setup-nim-vllm.ts b/src/lib/onboard/setup-nim-vllm.ts index 5d276679342..2129be5c504 100644 --- a/src/lib/onboard/setup-nim-vllm.ts +++ b/src/lib/onboard/setup-nim-vllm.ts @@ -32,7 +32,11 @@ export interface SetupNimVllmDeps { runCapture(args: string[], options: { ignoreError: boolean }): string; getLocalProviderBaseUrl(provider: string): string | null; getLocalProviderValidationBaseUrl(provider: string): string | null; - getManagedVllmProviderBinding(): { baseUrl: string; apiKey: string } | null; + getManagedVllmProviderBinding(): { + baseUrl: string; + validationBaseUrl?: string; + apiKey: string; + } | null; queryVllmModels(baseUrl: string, apiKey: string): string; isSafeModelId(model: string): boolean; requireValue(value: T | null | undefined, message: string): T; @@ -258,7 +262,9 @@ export function createSetupNimVllmHandler( const requiredModel = typeof state.model === "string" ? state.model : null; const validationBaseUrl = - managedBinding?.baseUrl ?? deps.getLocalProviderValidationBaseUrl(state.provider); + managedBinding?.validationBaseUrl ?? + managedBinding?.baseUrl ?? + deps.getLocalProviderValidationBaseUrl(state.provider); if (!validationBaseUrl) { console.error(" Local vLLM validation URL could not be determined."); deps.exitProcess(1); diff --git a/test/e2e/README.md b/test/e2e/README.md index 6a72c50f62a..a601fae4bb3 100644 --- a/test/e2e/README.md +++ b/test/e2e/README.md @@ -710,7 +710,7 @@ The target sources `scripts/install.sh` from the candidate checkout, calls the E It does not run the hosted installer bootstrap, clone or ref selection, dependency installation, CLI exposure, or the real terminal prompt. Separate installer tests own those earlier boundaries. The live target refuses to replace a pre-existing sandbox or `nemoclaw-vllm` container. -It preserves the shared Hugging Face cache and removes only the exact sandbox and managed container that the run created. +It preserves the shared Hugging Face cache, records the created sandbox and container identities, and revalidates each identity before cleanup. If onboarding exits nonzero, the target captures the managed-container log tail and sandbox details before cleanup. The standard E2E artifacts retain bounded command output. diff --git a/test/e2e/live/spark-express-vllm.test.ts b/test/e2e/live/spark-express-vllm.test.ts index e5e89dc3bf2..0b76feeb2ca 100644 --- a/test/e2e/live/spark-express-vllm.test.ts +++ b/test/e2e/live/spark-express-vllm.test.ts @@ -7,6 +7,7 @@ import assert from "node:assert/strict"; import { loadServingCatalog } from "../../../src/lib/inference/serving/catalog-loader.ts"; import { materializeHostLocalVllmSelection } from "../../../src/lib/inference/serving/host-local-vllm-selection.ts"; +import { resolveManagedVllmBridgeHost } from "../../../src/lib/inference/serving/vllm-host-local-network.ts"; import { detectVllmProfile } from "../../../src/lib/inference/vllm.ts"; import { buildVllmServeCommand } from "../../../src/lib/inference/vllm-models.ts"; import { @@ -23,6 +24,7 @@ import { REPO_ROOT } from "../fixtures/paths.ts"; import { assertLocalDockerEnvironment, classifyDockerContainerInspection, + inspectSandboxIdentity, listedSandboxNames, } from "../support/spark-express-vllm-safety.ts"; import { @@ -119,6 +121,39 @@ async function assertSandboxAbsent(host: HostCliClient, sandboxName: string): Pr ).toBe(false); } +async function inspectSandbox(host: HostCliClient, sandboxName: string, artifactName: string) { + const result = await host.command("openshell", ["sandbox", "get", "-o", "json", sandboxName], { + artifactName, + env: e2eEnv(), + timeoutMs: 30_000, + }); + return inspectSandboxIdentity(result, sandboxName); +} + +async function removeExactSandbox( + host: HostCliClient, + sandbox: Parameters[1], + sandboxName: string, + sandboxId: string, +): Promise { + const current = await inspectSandbox(host, sandboxName, "cleanup-spark-express-sandbox-inspect"); + if (current.kind === "absent") return; + expect( + current.id, + `Refusing to remove replacement sandbox ${sandboxName}; expected ${sandboxId}, got ${current.id}`, + ).toBe(sandboxId); + await cleanupSandbox(host, sandbox, sandboxName, { strict: true }); + const list = await host.command("openshell", ["sandbox", "list", "--names"], { + artifactName: "cleanup-spark-express-sandbox-list", + env: e2eEnv(), + timeoutMs: 30_000, + }); + expect( + listedSandboxNames(list).has(sandboxName), + `Sandbox ${sandboxName} still exists after cleanup.`, + ).toBe(false); +} + async function captureOnboardFailureDiagnostics( host: HostCliClient, sandboxName: string, @@ -235,20 +270,19 @@ test("DGX Spark Express option 2 materializes the fixed vLLM profile and routes expect(nvidia.exitCode, resultText(nvidia)).toBe(0); let createdContainerId: string | null = null; - let ownsSandboxName = false; + let createdSandboxId: string | null = null; cleanup.add(`remove ${VLLM_CONTAINER}`, () => createdContainerId ? removeExactVllmContainer(host, createdContainerId, "cleanup-spark-express-vllm-container") : Promise.resolve(), ); cleanup.add(`remove sandbox ${SANDBOX_NAME}`, () => - ownsSandboxName - ? cleanupSandbox(host, sandbox, SANDBOX_NAME, { strict: true }) + createdSandboxId + ? removeExactSandbox(host, sandbox, SANDBOX_NAME, createdSandboxId) : Promise.resolve(), ); await assertVllmContainerAbsent(host); await assertSandboxAbsent(host, SANDBOX_NAME); - ownsSandboxName = true; progress.phase("select Spark Express option 2 and onboard through the local-model profile"); const onboard = await host.command( @@ -287,12 +321,22 @@ test("DGX Spark Express option 2 materializes the fixed vLLM profile and routes timeoutMs: 30_000, }); createdContainerId = capturedVllmContainerId(inspectionResult); + const sandboxInspection = await inspectSandbox( + host, + SANDBOX_NAME, + "spark-express-vllm-sandbox-inspect", + ); + createdSandboxId = sandboxInspection.kind === "present" ? sandboxInspection.id : null; if (onboard.exitCode !== 0) { await captureOnboardFailureDiagnostics(host, SANDBOX_NAME); } progress.phase("verify catalog-owned vLLM runtime configuration"); expect(onboard.exitCode, resultText(onboard)).toBe(0); + expect( + createdSandboxId, + "onboarding did not create the expected sandbox identity", + ).not.toBeNull(); expect(inspectionResult.exitCode, resultText(inspectionResult)).toBe(0); const [inspection] = JSON.parse(inspectionResult.stdout) as VllmContainerInspection[]; expect(inspection.Id).toBe(createdContainerId); @@ -311,9 +355,19 @@ test("DGX Spark Express option 2 materializes the fixed vLLM profile and routes expect(inspection.HostConfig.NetworkMode).toBe(plan.recipe.spec.runtime.networkMode); expect(inspection.HostConfig.IpcMode).toBe(plan.recipe.spec.runtime.ipcMode); expect(inspection.HostConfig.ShmSize).toBe(plan.recipe.spec.runtime.sharedMemoryBytes); - expect(inspection.HostConfig.PortBindings["8000/tcp"]).toEqual([ - { HostIp: "127.0.0.1", HostPort: "8000" }, - ]); + const portBindings = inspection.HostConfig.PortBindings["8000/tcp"]; + expect(portBindings).toHaveLength(2); + expect(portBindings).toContainEqual({ HostIp: "127.0.0.1", HostPort: "8000" }); + expect(portBindings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + HostIp: expect.stringMatching(/^(?!127\.0\.0\.1$)(?!0\.0\.0\.0$).+/u), + HostPort: "8000", + }), + ]), + ); + const bridgeBinding = portBindings.find(({ HostIp }) => HostIp !== "127.0.0.1"); + expect(bridgeBinding?.HostIp).toBe(resolveManagedVllmBridgeHost()); expect(inspection.HostConfig.DeviceRequests).toEqual( expect.arrayContaining([expect.objectContaining({ Count: -1, Capabilities: [["gpu"]] })]), ); diff --git a/test/e2e/support/spark-express-vllm-safety.test.ts b/test/e2e/support/spark-express-vllm-safety.test.ts index c93c85de034..6c80a259ce8 100644 --- a/test/e2e/support/spark-express-vllm-safety.test.ts +++ b/test/e2e/support/spark-express-vllm-safety.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from "vitest"; import { assertLocalDockerEnvironment, classifyDockerContainerInspection, + inspectSandboxIdentity, listedSandboxNames, } from "./spark-express-vllm-safety.ts"; @@ -49,4 +50,23 @@ describe("DGX Spark Express vLLM qualification safety", () => { "OpenShell sandbox listing failed", ); }); + + it("requires an exact sandbox identity and distinguishes absence from inspection failure (#8379)", () => { + const id = "05d120e4-484c-47d4-9a59-77a08cbb6e67"; + expect( + inspectSandboxIdentity(result(0, JSON.stringify({ id, name: "spark-e2e" })), "spark-e2e"), + ).toEqual({ kind: "present", id }); + expect(inspectSandboxIdentity(result(1, "", "sandbox not found"), "spark-e2e")).toEqual({ + kind: "absent", + }); + expect(() => + inspectSandboxIdentity(result(0, JSON.stringify({ id, name: "replacement" })), "spark-e2e"), + ).toThrow("expected identity"); + expect(() => inspectSandboxIdentity(result(1, "", "gateway unavailable"), "spark-e2e")).toThrow( + "sandbox inspection failed", + ); + expect(() => + inspectSandboxIdentity(result(1, "", "gateway 'nemoclaw' not found"), "spark-e2e"), + ).toThrow("sandbox inspection failed"); + }); }); diff --git a/test/e2e/support/spark-express-vllm-safety.ts b/test/e2e/support/spark-express-vllm-safety.ts index 58078a3395c..8874aebcd25 100644 --- a/test/e2e/support/spark-express-vllm-safety.ts +++ b/test/e2e/support/spark-express-vllm-safety.ts @@ -5,6 +5,13 @@ import type { CommandExitResult } from "../fixtures/clients/command.ts"; import { resultText } from "../fixtures/clients/command.ts"; const DOCKER_NOT_FOUND_PATTERN = /no such (?:object|container)/iu; +const SANDBOX_NOT_FOUND_PATTERN = + /(?:\bsandbox(?:\s+['"][^'"]+['"])?\s+(?:not found|does not exist)\b|\bno such sandbox\b)/iu; +const SANDBOX_ID_PATTERN = /^[a-f0-9]{8}-(?:[a-f0-9]{4}-){3}[a-f0-9]{12}$/u; + +export type SandboxInspection = + | { readonly kind: "absent" } + | { readonly kind: "present"; readonly id: string }; export function assertLocalDockerEnvironment(env: NodeJS.ProcessEnv): void { const host = String(env.DOCKER_HOST ?? "").trim(); @@ -38,3 +45,34 @@ export function listedSandboxNames(result: CommandExitResult): Set { .filter(Boolean), ); } + +export function inspectSandboxIdentity( + result: CommandExitResult, + expectedName: string, +): SandboxInspection { + if (result.exitCode !== 0) { + if (SANDBOX_NOT_FOUND_PATTERN.test(`${result.stdout}\n${result.stderr}`)) { + return { kind: "absent" }; + } + throw new Error(`OpenShell sandbox inspection failed: ${resultText(result)}`); + } + + let value: unknown; + try { + value = JSON.parse(result.stdout); + } catch { + throw new Error("OpenShell sandbox inspection returned invalid JSON."); + } + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("OpenShell sandbox inspection returned an invalid record."); + } + const record = value as Record; + if ( + record.name !== expectedName || + typeof record.id !== "string" || + !SANDBOX_ID_PATTERN.test(record.id) + ) { + throw new Error("OpenShell sandbox inspection did not match the expected identity."); + } + return { kind: "present", id: record.id }; +} From ad4fc15d287e84605b0568c1219a6f2a5856c5a2 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sun, 9 Aug 2026 14:39:29 -0700 Subject: [PATCH 12/19] test(inference): type managed bridge resolver --- .../inference/serving/vllm-host-local-lifecycle.test.ts | 7 ++++++- src/lib/inference/serving/vllm-host-local-lifecycle.ts | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/lib/inference/serving/vllm-host-local-lifecycle.test.ts b/src/lib/inference/serving/vllm-host-local-lifecycle.test.ts index a89f020f011..3816ce2a81b 100644 --- a/src/lib/inference/serving/vllm-host-local-lifecycle.test.ts +++ b/src/lib/inference/serving/vllm-host-local-lifecycle.test.ts @@ -23,7 +23,12 @@ import { } from "./vllm-host-local-lifecycle"; const networkMocks = vi.hoisted(() => ({ - resolveBridgeHost: vi.fn(() => "172.18.0.1"), + resolveBridgeHost: vi.fn( + ( + _capture: NonNullable, + _dockerEnv?: Record, + ) => "172.18.0.1", + ), })); vi.mock("./vllm-host-local-network", async (importOriginal) => ({ diff --git a/src/lib/inference/serving/vllm-host-local-lifecycle.ts b/src/lib/inference/serving/vllm-host-local-lifecycle.ts index 1b6d2f81c91..d16b1ba2b93 100644 --- a/src/lib/inference/serving/vllm-host-local-lifecycle.ts +++ b/src/lib/inference/serving/vllm-host-local-lifecycle.ts @@ -273,7 +273,7 @@ function inspectHostLocalContainer( }); } -/** Recover only the exact authenticated, loopback-published host-local container. */ +/** Recover only the exact authenticated host-local container with bounded host bindings. */ export function recoverHostLocalManagedVllmEndpoint( options: RecoverHostLocalManagedVllmOptions = {}, ): { baseUrl: string; apiKey: string } | null { From ff22a4b7a7d9c0d46b399ea08521c22a08fce8fc Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sun, 9 Aug 2026 14:43:09 -0700 Subject: [PATCH 13/19] chore(onboard): meet entrypoint growth budget --- src/lib/onboard.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 9d5541ab455..3a78fcb4a87 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -3602,7 +3602,6 @@ const setupMessagingChannels = createSetupMessagingChannels({ }); // ── Step 7: OpenClaw ───────────────────────────────────────────── - function syncNemoClawConfigInSandbox(sandboxName: string, provider: string, model: string): void { runSandboxConfigSync(sandboxName, { getSelectionConfig: () => getProviderSelectionConfig(provider, model), From 9175c7b3524a37d3e269da55f50a28e597bad77f Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sun, 9 Aug 2026 14:45:57 -0700 Subject: [PATCH 14/19] test(e2e): keep Spark qualification linear --- .../serving/host-local-vllm-selection.test.ts | 3 ++- test/e2e/live/spark-express-vllm.test.ts | 23 ++++++++++++++----- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/src/lib/inference/serving/host-local-vllm-selection.test.ts b/src/lib/inference/serving/host-local-vllm-selection.test.ts index 17ffbda0668..9bc67c0b449 100644 --- a/src/lib/inference/serving/host-local-vllm-selection.test.ts +++ b/src/lib/inference/serving/host-local-vllm-selection.test.ts @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import assert from "node:assert/strict"; import os from "node:os"; import path from "node:path"; @@ -89,7 +90,7 @@ describe("host-local vLLM selection", () => { intent: { preset: selection.preset.metadata.id }, }), ); - if (result.kind !== "selected") throw new Error("expected a selected host-local profile"); + assert(result.kind === "selected", "expected a selected host-local profile"); expect(result.model.runtime?.dockerRunArgs).toContain( `type=bind,source=${path.join(os.homedir(), ".cache", "huggingface", "hub")},target=${selection.recipe.spec.runtime.modelCache.target}/hub,readonly`, ); diff --git a/test/e2e/live/spark-express-vllm.test.ts b/test/e2e/live/spark-express-vllm.test.ts index 0b76feeb2ca..6cde3544048 100644 --- a/test/e2e/live/spark-express-vllm.test.ts +++ b/test/e2e/live/spark-express-vllm.test.ts @@ -137,10 +137,21 @@ async function removeExactSandbox( sandboxId: string, ): Promise { const current = await inspectSandbox(host, sandboxName, "cleanup-spark-express-sandbox-inspect"); - if (current.kind === "absent") return; + return current.kind === "absent" + ? undefined + : removePresentExactSandbox(host, sandbox, sandboxName, sandboxId, current.id); +} + +async function removePresentExactSandbox( + host: HostCliClient, + sandbox: Parameters[1], + sandboxName: string, + sandboxId: string, + currentSandboxId: string, +): Promise { expect( - current.id, - `Refusing to remove replacement sandbox ${sandboxName}; expected ${sandboxId}, got ${current.id}`, + currentSandboxId, + `Refusing to remove replacement sandbox ${sandboxName}; expected ${sandboxId}, got ${currentSandboxId}`, ).toBe(sandboxId); await cleanupSandbox(host, sandbox, sandboxName, { strict: true }); const list = await host.command("openshell", ["sandbox", "list", "--names"], { @@ -327,9 +338,9 @@ test("DGX Spark Express option 2 materializes the fixed vLLM profile and routes "spark-express-vllm-sandbox-inspect", ); createdSandboxId = sandboxInspection.kind === "present" ? sandboxInspection.id : null; - if (onboard.exitCode !== 0) { - await captureOnboardFailureDiagnostics(host, SANDBOX_NAME); - } + await (onboard.exitCode !== 0 + ? captureOnboardFailureDiagnostics(host, SANDBOX_NAME) + : Promise.resolve()); progress.phase("verify catalog-owned vLLM runtime configuration"); expect(onboard.exitCode, resultText(onboard)).toBe(0); From 05363b446006308dcca4d118555b1fd436a5d1e4 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sun, 9 Aug 2026 15:08:57 -0700 Subject: [PATCH 15/19] test(onboard): cover non-default config sync Signed-off-by: Carlos Villela --- src/lib/agent/onboard.test.ts | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/lib/agent/onboard.test.ts b/src/lib/agent/onboard.test.ts index a5aa5633df3..b48ba579658 100644 --- a/src/lib/agent/onboard.test.ts +++ b/src/lib/agent/onboard.test.ts @@ -2,6 +2,17 @@ // SPDX-License-Identifier: Apache-2.0 import { afterEach, beforeEach, describe, expect, it, type MockInstance, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + run: vi.fn(), +})); + +vi.mock("../runner", async (importOriginal) => ({ + ...(await importOriginal()), + run: mocks.run, +})); + +import { sandboxConfigSyncArgs } from "../onboard/config-sync"; import type { AgentDefinition } from "./defs"; // Import source directly so tests cannot pass against a stale build. import { @@ -305,6 +316,7 @@ describe("agent setup session boundaries", () => { } afterEach(() => { + mocks.run.mockReset(); vi.restoreAllMocks(); }); @@ -345,6 +357,28 @@ describe("agent setup session boundaries", () => { expect(context.recordStepFailed).not.toHaveBeenCalled(); }); + it("writes non-default agent configuration through noninteractive sandbox exec", async () => { + const runCaptureOpenshell = vi.fn(() => "NEMOCLAW_AGENT_BINARY_CHECK:ok"); + const { context } = createAgentSetupContext(runCaptureOpenshell); + const agent = makeAgent({ + name: "hermes", + healthProbe: { url: "", port: 0, timeout_seconds: 0 }, + }); + + await handleAgentSetup("sandbox-x", "meta-llama", "vllm-local", agent, false, null, context); + + expect(mocks.run).toHaveBeenCalledTimes(1); + const [args, options] = mocks.run.mock.calls[0]; + expect(args).toEqual(["/usr/bin/openshell", ...sandboxConfigSyncArgs("sandbox-x")]); + expect(options).toMatchObject({ + input: expect.any(String), + stdio: ["pipe", "ignore", "inherit"], + }); + expect(options.input).toContain('"provider": "vllm-local"'); + expect(options.input).toContain('"model": "meta-llama"'); + expect(options.input).toContain('"agent": "hermes"'); + }); + it("retries a configured gateway probe through the supplied scheduler", async () => { let nowMs = 0; const sleepSeconds = vi.fn((seconds: number) => { From e8b2faf247dc49d48f0ded4f9ebb5a16a9ce0788 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sun, 9 Aug 2026 16:57:15 -0700 Subject: [PATCH 16/19] fix(inference): align vllm diagnostics with bridge --- src/lib/inference/local-vllm-auth.test.ts | 31 +++++++++++++++++++++++ src/lib/inference/local.test.ts | 2 +- src/lib/inference/local.ts | 28 ++++++++++---------- 3 files changed, 47 insertions(+), 14 deletions(-) diff --git a/src/lib/inference/local-vllm-auth.test.ts b/src/lib/inference/local-vllm-auth.test.ts index 25591dc27e6..075a5d3a925 100644 --- a/src/lib/inference/local-vllm-auth.test.ts +++ b/src/lib/inference/local-vllm-auth.test.ts @@ -291,6 +291,37 @@ describe("managed vLLM authentication", () => { expect(managedBridge.host).toHaveBeenCalledOnce(); }); + it("pins host-local diagnostics to the exact OpenShell bridge (#8379)", () => { + lifecycle.baseUrl.mockReturnValue(null); + hostLocalRecovery.endpoint.mockReturnValue({ + baseUrl: "http://127.0.0.1:8000", + apiKey: API_KEY, + }); + const capture = vi.fn((argv: readonly string[]) => { + if (argv[0] === "curl") return "200"; + if (argv.at(-1) === "/etc/hosts") return "172.18.0.1\thost.openshell.internal"; + if (argv.includes("-o")) return "503"; + return ""; + }); + + const result = validateLocalProvider("vllm-local", capture, () => undefined); + const dockerCommands = capture.mock.calls + .map(([argv]) => argv) + .filter((argv) => argv[0] === "docker"); + + expect(result.ok).toBe(false); + expect(result.diagnostic).toContain("Container curl returned HTTP 503"); + expect(result.diagnostic).toContain("host.openshell.internal resolved to: 172.18.0.1"); + expect(dockerCommands).toHaveLength(5); + expect( + dockerCommands.every((argv) => argv.includes("host.openshell.internal:172.18.0.1")), + ).toBe(true); + expect( + dockerCommands.every((argv) => !argv.includes("host.openshell.internal:host-gateway")), + ).toBe(true); + expect(managedBridge.host).toHaveBeenCalledOnce(); + }); + it("uses /health only for unauthenticated availability checks", () => { expect(getLocalProviderHealthEndpoint("vllm-local")).toBe(`${BASE_URL}/v1/models`); expect(getLocalProviderHealthCheck("vllm-local")).toEqual([ diff --git a/src/lib/inference/local.test.ts b/src/lib/inference/local.test.ts index a5f23869b74..0beb6dc8180 100644 --- a/src/lib/inference/local.test.ts +++ b/src/lib/inference/local.test.ts @@ -298,7 +298,7 @@ describe("local inference helpers", () => { const result = validateLocalProvider("ollama-local", mockCapture, mockSleep); expect(result.ok).toBe(false); expect(result.diagnostic).toMatch(/HTTP 502/); - expect(result.diagnostic).toMatch(/host-gateway resolved to/); + expect(result.diagnostic).toMatch(/host\.openshell\.internal resolved to/); expect(sleepCalls).toEqual([2, 2]); }); diff --git a/src/lib/inference/local.ts b/src/lib/inference/local.ts index a3d83851980..0aab425d927 100644 --- a/src/lib/inference/local.ts +++ b/src/lib/inference/local.ts @@ -1151,7 +1151,7 @@ export function validateLocalProvider( } // All retries exhausted — collect diagnostics - const diagnostic = collectContainerDiagnostic(provider, capture); + const diagnostic = collectContainerDiagnostic(containerCommand, capture); switch (provider) { case "vllm-local": @@ -1192,22 +1192,24 @@ function getContainerCheckUrl(provider: string): string | null { } } -function collectContainerDiagnostic(provider: string, capture: RunCaptureFn): string { - const url = getContainerCheckUrl(provider); - if (!url) return "Managed vLLM state could not be inspected safely."; - const dockerCommand = - provider === "vllm-local" && url.endsWith("/health") - ? ["docker", "--context", "default"] - : ["docker"]; +function collectContainerDiagnostic(containerCommand: string[], capture: RunCaptureFn): string { + const url = containerCommand.at(-1); + const dockerRunIndex = containerCommand.indexOf("run"); + const addHostIndex = containerCommand.indexOf("--add-host"); + const hostAlias = containerCommand[addHostIndex + 1]; + if (!url || dockerRunIndex < 1 || addHostIndex < 0 || !hostAlias) { + return `Docker command failed (invalid reachability command). Retried ${CONTAINER_CHECK_MAX_ATTEMPTS} times.`; + } + const dockerCommand = containerCommand.slice(0, dockerRunIndex); try { - // Get HTTP status code + // Reuse the exact Docker context, host mapping, and URL from the failed check. const httpStatus = capture( [ ...dockerCommand, "run", "--rm", "--add-host", - "host.openshell.internal:host-gateway", + hostAlias, CONTAINER_REACHABILITY_IMAGE, "-s", "-o", @@ -1223,14 +1225,14 @@ function collectContainerDiagnostic(provider: string, capture: RunCaptureFn): st { ignoreError: true }, ); - // Get /etc/hosts to see host-gateway resolution + // Confirm that Docker applied the same host mapping used by the failed check. const hostsOutput = capture( [ ...dockerCommand, "run", "--rm", "--add-host", - "host.openshell.internal:host-gateway", + hostAlias, CONTAINER_REACHABILITY_IMAGE, "cat", "/etc/hosts", @@ -1251,7 +1253,7 @@ function collectContainerDiagnostic(provider: string, capture: RunCaptureFn): st .split(/\r?\n/) .find((l: string) => l.includes("host.openshell.internal")); if (gwLine) { - parts.push(`host-gateway resolved to: ${gwLine.trim().split(/\s+/)[0]}`); + parts.push(`host.openshell.internal resolved to: ${gwLine.trim().split(/\s+/)[0]}`); } } parts.push( From 64cf09fd3450c26575f3f9443cdae854a1294848 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sun, 9 Aug 2026 17:12:24 -0700 Subject: [PATCH 17/19] test(e2e): allow reasoning chat probe to finish --- test/e2e/live/inference-routing-helpers.ts | 3 ++- test/e2e/live/spark-express-vllm.test.ts | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/test/e2e/live/inference-routing-helpers.ts b/test/e2e/live/inference-routing-helpers.ts index a576101b35f..7197f8a1d2c 100644 --- a/test/e2e/live/inference-routing-helpers.ts +++ b/test/e2e/live/inference-routing-helpers.ts @@ -32,6 +32,7 @@ const ONBOARD_ARGS = [ "--yes", "--yes-i-accept-third-party-software", ]; +const OPENAI_CHAT_PROBE_MAX_TOKENS = 1024; const STACK_TRACE_PATTERNS = [ /^\s+at (Object\.|Module\.|node:internal|process\.)/m, /\bat node:internal/m, @@ -375,7 +376,7 @@ async function expectOpenAiChatThroughSandbox( const payload = JSON.stringify({ model, messages: [{ role: "user", content: "Reply with exactly one word: PONG" }], - max_tokens: 50, + max_tokens: OPENAI_CHAT_PROBE_MAX_TOKENS, }); const response = await sandbox.exec( sandboxName, diff --git a/test/e2e/live/spark-express-vllm.test.ts b/test/e2e/live/spark-express-vllm.test.ts index 6cde3544048..8c7ebf395da 100644 --- a/test/e2e/live/spark-express-vllm.test.ts +++ b/test/e2e/live/spark-express-vllm.test.ts @@ -403,7 +403,7 @@ test("DGX Spark Express option 2 materializes the fixed vLLM profile and routes const denied = await sandbox.execShell( SANDBOX_NAME, trustedSandboxShellScript( - "curl -sS -o /dev/null -w '%{http_code}' --max-time 20 https://example.com/", + "status=0; code=$(curl -sS -o /dev/null -w '%{http_connect}' --max-time 20 https://example.com/) || status=$?; printf '%s %s' \"$status\" \"$code\"", ), { artifactName: "spark-express-unrelated-egress-denied", @@ -412,5 +412,5 @@ test("DGX Spark Express option 2 materializes the fixed vLLM profile and routes }, ); expect(denied.exitCode, resultText(denied)).toBe(0); - expect(denied.stdout.trim()).toBe("403"); + expect(denied.stdout.trim()).toBe("56 403"); }); From 2f5e9fcefacede631c7846932b60232902e0e31d Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sun, 9 Aug 2026 18:14:48 -0700 Subject: [PATCH 18/19] test(inference): keep diagnostic mock linear Signed-off-by: Carlos Villela --- src/lib/inference/local-vllm-auth.test.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/lib/inference/local-vllm-auth.test.ts b/src/lib/inference/local-vllm-auth.test.ts index 075a5d3a925..a429639d10a 100644 --- a/src/lib/inference/local-vllm-auth.test.ts +++ b/src/lib/inference/local-vllm-auth.test.ts @@ -102,6 +102,16 @@ function productionManagedBaseUrlResolver( }); } +function hostLocalDiagnosticCapture(argv: readonly string[]): string { + return argv[0] === "curl" + ? "200" + : argv.at(-1) === "/etc/hosts" + ? "172.18.0.1\thost.openshell.internal" + : argv.includes("-o") + ? "503" + : ""; +} + beforeEach(() => { vi.stubEnv(LOCAL_INFERENCE_SANDBOX_HOST_URL_ENV, undefined); managedClusterRecovery.endpoint.mockReset(); @@ -297,12 +307,7 @@ describe("managed vLLM authentication", () => { baseUrl: "http://127.0.0.1:8000", apiKey: API_KEY, }); - const capture = vi.fn((argv: readonly string[]) => { - if (argv[0] === "curl") return "200"; - if (argv.at(-1) === "/etc/hosts") return "172.18.0.1\thost.openshell.internal"; - if (argv.includes("-o")) return "503"; - return ""; - }); + const capture = vi.fn(hostLocalDiagnosticCapture); const result = validateLocalProvider("vllm-local", capture, () => undefined); const dockerCommands = capture.mock.calls From 7f1d958562bbc2915704d551f97b1132991c62f2 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sun, 9 Aug 2026 18:40:21 -0700 Subject: [PATCH 19/19] fix(inference): publish llama.cpp qualification port Signed-off-by: Carlos Villela --- .../checks/run-llama-cpp-dgx-spark-qualification.mts | 10 +++++++++- test/llama-cpp-dgx-spark-qualification-runner.test.ts | 2 +- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/scripts/checks/run-llama-cpp-dgx-spark-qualification.mts b/scripts/checks/run-llama-cpp-dgx-spark-qualification.mts index 7e2e1be3ae5..8203944b42a 100644 --- a/scripts/checks/run-llama-cpp-dgx-spark-qualification.mts +++ b/scripts/checks/run-llama-cpp-dgx-spark-qualification.mts @@ -335,7 +335,7 @@ export function buildServerContainerArgv( if (plan.qualification.requestGuard !== "required") { throw new Error("llama.cpp qualification requires the declarative request guard"); } - return buildLlamaCppRequestGuardDockerArgv(plan.recipe, { + const argv = buildLlamaCppRequestGuardDockerArgv(plan.recipe, { apiKeyHostPath: options.apiKeyHostPath, containerName: options.containerName, imageReference: options.imageReference, @@ -346,6 +346,14 @@ export function buildServerContainerArgv( runtimeGid: options.runtimeGid, runtimeUid: options.runtimeUid, }); + const entrypointIndex = argv.indexOf("--entrypoint"); + const hostPort = options.hostPort === undefined ? "" : String(options.hostPort); + return [ + ...argv.slice(0, entrypointIndex), + "--publish", + `127.0.0.1:${hostPort}:${String(plan.recipe.serve.port)}`, + ...argv.slice(entrypointIndex), + ]; } export function validateOpenClawQualificationImageLabels( diff --git a/test/llama-cpp-dgx-spark-qualification-runner.test.ts b/test/llama-cpp-dgx-spark-qualification-runner.test.ts index 4b52566c403..387b4c0c1f0 100644 --- a/test/llama-cpp-dgx-spark-qualification-runner.test.ts +++ b/test/llama-cpp-dgx-spark-qualification-runner.test.ts @@ -382,7 +382,7 @@ describe("trusted llama.cpp DGX Spark qualification runner", () => { runtimeGid: 1001, runtimeUid: 1001, }); - expect(valuesAfter(agentQualificationArgv, "--publish")).toEqual([]); + expect(valuesAfter(agentQualificationArgv, "--publish")).toEqual(["127.0.0.1:8081:8081"]); expect(valuesAfter(argv, "--network")).toEqual(["qualified-internal"]); expect(valuesAfter(argv, "--user")).toEqual(["1001:1001"]); expect(valuesAfter(argv, "--api-key-file")).toEqual(["/run/secrets/llama-cpp-api-key"]);