diff --git a/ci/platform-matrix.json b/ci/platform-matrix.json index ba07d9edefd..5c7d94c53b8 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 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 9e7c1cd46c9..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`. 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 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 4b0418d371d..359ae3e1e61 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. | Requires the dedicated installer flag on DGX Spark and 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`. | | Existing llama.cpp | You already operate an authenticated llama.cpp server on loopback port `8081`. | Experimental. Always available for explicit selection and attaches only after cooperative fingerprinting succeeds. | `/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`. | @@ -34,7 +34,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. | Requires the dedicated installer flag on DGX Spark and 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`. | | Existing llama.cpp | You already operate an authenticated llama.cpp server on loopback port `8081`. | Experimental. Always available for explicit selection and attaches only after cooperative fingerprinting succeeds. | `/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`. | @@ -69,6 +69,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 offers this path as option 2 after the existing automatic managed-vLLM option. Before you start, confirm these prerequisites: @@ -104,6 +105,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. +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. @@ -111,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: @@ -126,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-on-two-dgx-sparks.mdx b/docs/inference/set-up-vllm-on-two-dgx-sparks.mdx index 0fd5090452a..4d518bf54eb 100644 --- a/docs/inference/set-up-vllm-on-two-dgx-sparks.mdx +++ b/docs/inference/set-up-vllm-on-two-dgx-sparks.mdx @@ -20,8 +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. +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. @@ -133,7 +134,7 @@ curl -fsSL https://www.nvidia.com/nemoclaw.sh | \ bash ``` -Accept the usage notice and the **Run express install with these settings?** prompt. +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. diff --git a/docs/inference/set-up-vllm.mdx b/docs/inference/set-up-vllm.mdx index 11dc2dacccc..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 @@ -232,6 +238,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. @@ -257,8 +264,14 @@ 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, 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. +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/docs/reference/platform-support.mdx b/docs/reference/platform-support.mdx index 7edf78678fe..fb7b2c7fdf0 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 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 8e9efd4c7e7..4426c6b9d38 100644 --- a/docs/resources/prompt-assets/dgx-spark.md +++ b/docs/resources/prompt-assets/dgx-spark.md @@ -7,17 +7,33 @@ 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. -Include the third-party-software notice, then ask: "Run Express install with these settings?" +Explain that Express keeps the selected agent, leaves optional setup at its defaults, and offers two DGX Spark inference setups. +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. + +Include the third-party-software notice after the user chooses, then ask: "Run Express install with these settings?" Choices: -1. Yes, use the DGX Spark Express defaults. -2. No, let me choose the runtime and model. +1. Yes, use the selected DGX Spark inference setup. +2. No, continue with the normal provider selection. -If Express is selected: +For option 1: - 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`. +- 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. +- 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/docs/resources/starter-prompt.md b/docs/resources/starter-prompt.md index 257641dd68e..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/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/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/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/scripts/install.sh b/scripts/install.sh index 6c316efc32f..4566500864a 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -4384,6 +4384,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" @@ -4399,9 +4433,21 @@ 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 - export NEMOCLAW_VLLM_MODEL + 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") @@ -4837,9 +4883,15 @@ 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 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." + 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="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." @@ -5023,6 +5075,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 @@ -5036,6 +5092,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/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) => { 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/local-vllm-auth.test.ts b/src/lib/inference/local-vllm-auth.test.ts index ec77707a1ba..a429639d10a 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, @@ -91,10 +102,24 @@ 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(); 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 +267,66 @@ 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("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(hostLocalDiagnosticCapture); + + 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 3341ea9ac93..0aab425d927 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", @@ -1126,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": @@ -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": @@ -1166,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", @@ -1197,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", @@ -1225,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( 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..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,10 @@ // 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"; + import { beforeEach, describe, expect, it, vi } from "vitest"; import type { VllmProfile } from "../vllm.js"; import { @@ -86,6 +90,18 @@ describe("host-local vLLM selection", () => { intent: { preset: selection.preset.metadata.id }, }), ); + 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`, + ); + 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/inference/serving/vllm-host-local-lifecycle.test.ts b/src/lib/inference/serving/vllm-host-local-lifecycle.test.ts index 45ec6606dc7..3816ce2a81b 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,24 @@ 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( + ( + _capture: NonNullable, + _dockerEnv?: Record, + ) => "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 +60,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 +71,7 @@ function inspect( key = API_KEY, fingerprint = runtimeAuthFingerprint(key), labels: Record = {}, + bridgeHost = "172.18.0.1", ) { return JSON.stringify([ { @@ -68,13 +86,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 +145,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..d16b1ba2b93 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,19 +262,26 @@ 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, }); } -/** 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 { - 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.ts b/src/lib/onboard.ts index 33178216098..f97501bfb40 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"); @@ -3587,12 +3588,11 @@ const setupMessagingChannels = createSetupMessagingChannels({ }); // ── Step 7: OpenClaw ───────────────────────────────────────────── - function syncNemoClawConfigInSandbox(sandboxName: string, provider: string, model: string): void { 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..5006d8a5992 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).toBeNull(); + 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, @@ -435,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 1c66ec43d1c..90e406772a7 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,15 +229,50 @@ 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); } +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, @@ -326,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/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/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/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/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 676a4f7816f..c1d8b40b352 100644 --- a/test/e2e/README.md +++ b/test/e2e/README.md @@ -706,6 +706,32 @@ 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 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 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, 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. + +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-vllm \ +npx tsx tools/e2e/live-vitest-invocation.mts run \ + --test-path test/e2e/live/spark-express-vllm.test.ts +``` + +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. Live modules import `fixtures/e2e-test.ts`; selected integration modules import 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 new file mode 100644 index 00000000000..8c7ebf395da --- /dev/null +++ b/test/e2e/live/spark-express-vllm.test.ts @@ -0,0 +1,416 @@ +// 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 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 { + 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 { type CommandExitResult, resultText } from "../fixtures/clients/command.ts"; +import type { HostCliClient } from "../fixtures/clients/host.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, + inspectSandboxIdentity, + 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-vllm"; +const VLLM_CONTAINER = "nemoclaw-vllm"; +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[]; + 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; RW: boolean; 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", + }); + 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", + env: e2eEnv(), + timeoutMs: 30_000, + }); + expect( + classifyDockerContainerInspection(result), + `Refusing to replace a pre-existing ${VLLM_CONTAINER} container.\n${resultText(result)}`, + ).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 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"); + 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( + 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"], { + 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, +): 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", "get", sandboxName], { + artifactName: "failure-spark-express-sandbox-get", + env: e2eEnv(), + timeoutMs: 30_000, + }); +} + +async function removeExactVllmContainer( + host: HostCliClient, + containerId: string, + artifactName: string, +): Promise { + 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", + [ + "--noprofile", + "--norc", + "-c", + [ + "set -euo pipefail", + `container=${VLLM_CONTAINER}`, + '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 "$expected_id" >/dev/null', + ].join("\n"), + "spark-express-vllm-cleanup", + containerId, + ], + { artifactName, env: e2eEnv(), timeoutMs: 120_000 }, + ); + expect(result.exitCode, resultText(result)).toBe(0); +} + +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", + "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 }) => { + validateSandboxName(SANDBOX_NAME); + assertLocalDockerEnvironment(process.env); + const plan = vllmProfilePlan(); + const baseProfile = detectVllmProfile({ platform: "spark" }); + assert(baseProfile, "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: + "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); + + let createdContainerId: string | null = null; + 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}`, () => + createdSandboxId + ? removeExactSandbox(host, sandbox, SANDBOX_NAME, createdSandboxId) + : Promise.resolve(), + ); + await assertVllmContainerAbsent(host); + await assertSandboxAbsent(host, SANDBOX_NAME); + + progress.phase("select Spark Express option 2 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", + "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" ]]', + '[[ -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, + }, + ); + + const inspectionResult = await host.command("docker", ["inspect", VLLM_CONTAINER], { + artifactName: "spark-express-vllm-container-inspect", + env: e2eEnv(), + 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; + 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); + 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); + expect(inspection.Config.Image).toBe(plan.recipe.spec.runtime.image); + expect(inspection.Config.Entrypoint).toEqual(["/bin/bash"]); + expect(inspection.Config.Cmd[0]).toBe("-lc"); + 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, + "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); + 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"]] })]), + ); + expect(inspection.Mounts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + Destination: `${plan.recipe.spec.runtime.modelCache.target}/hub`, + RW: false, + 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( + "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", + env: e2eEnv(), + timeoutMs: 30_000, + }, + ); + expect(denied.exitCode, resultText(denied)).toBe(0); + expect(denied.stdout.trim()).toBe("56 403"); +}); diff --git a/test/e2e/mock-parity.json b/test/e2e/mock-parity.json index 9bdcb8a571a..3260cec4e68 100644 --- a/test/e2e/mock-parity.json +++ b/test/e2e/mock-parity.json @@ -512,6 +512,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": [ 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..6c80a259ce8 --- /dev/null +++ b/test/e2e/support/spark-express-vllm-safety.test.ts @@ -0,0 +1,72 @@ +// 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, + inspectSandboxIdentity, + 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", + ); + }); + + 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 new file mode 100644 index 00000000000..8874aebcd25 --- /dev/null +++ b/test/e2e/support/spark-express-vllm-safety.ts @@ -0,0 +1,78 @@ +// 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; +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(); + 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), + ); +} + +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 }; +} diff --git a/test/helpers/installer-express-prompt-pty-harness.ts b/test/helpers/installer-express-prompt-pty-harness.ts index ee16af1c0e0..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\\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_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\\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_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 35ca7236a51..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 ' @@ -177,26 +177,60 @@ 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 managed vLLM with automatic DGX Spark serving-profile selection/, ); - 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(/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=install-vllm MODEL= VLLM_MODEL= POLICY=suggested YES=1 SANDBOX=my-assistant/, + /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(/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/, + ); + }); + + 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", @@ -212,10 +246,26 @@ 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)", () => { + 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 +273,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 SPARK_SELECTION=fixed-vllm/, ); }); 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"]); diff --git a/test/starter-prompt-docs.test.ts b/test/starter-prompt-docs.test.ts index b42b1ff5b60..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 = "bf46e62f901825f19e570c17f8c870a0eae04fbc"; +const promptAssetRevision = "4394858b3bae38b04768619f99b9614161f1b565"; 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 + "b0192f72e9a55349a7ca9c5ec3da639bd81a2c74b676f997b3e5193bc10053e1", // gitleaks:allow -- pinned prompt-asset SHA-256 ), dgxStation: definePromptAsset( "docs/resources/prompt-assets/dgx-station.md", @@ -815,7 +815,15 @@ 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( + "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", + ); expect(stationSource).toContain("`nemotron-3-ultra-550b-a55b`"); expect(stationSource).toContain("`nemotron-ultra`"); expect(stationSource).toContain("`deepseek-v4-flash`");