diff --git a/bin/lib/nim-images.json b/bin/lib/nim-images.json
index f639a24454f..bd66b994416 100644
--- a/bin/lib/nim-images.json
+++ b/bin/lib/nim-images.json
@@ -7,6 +7,7 @@
},
{
"name": "nvidia/nemotron-3-nano-30b-a3b",
+ "servedModel": "nvidia/nemotron-3-nano",
"image": "nvcr.io/nim/nvidia/nemotron-3-nano:latest",
"minGpuMemoryMB": 8192
},
diff --git a/ci/test-file-size-budget.json b/ci/test-file-size-budget.json
index 62b508af933..5f56329bbb2 100644
--- a/ci/test-file-size-budget.json
+++ b/ci/test-file-size-budget.json
@@ -10,7 +10,7 @@
"test/nemoclaw-start.test.ts": 4827,
"test/onboard-messaging.test.ts": 2062,
"test/onboard-selection.test.ts": 4834,
- "test/onboard.test.ts": 4057,
+ "test/onboard.test.ts": 4043,
"test/policies.test.ts": 2279
}
}
diff --git a/docs/about/release-notes.mdx b/docs/about/release-notes.mdx
index f6dfdf343d2..b250ad9a805 100644
--- a/docs/about/release-notes.mdx
+++ b/docs/about/release-notes.mdx
@@ -48,6 +48,23 @@ NemoClaw v0.0.74 upgrades the OpenShell policy boundary, adds managed MCP and pr
The selected mode persists through resume and transactional rebuilds, and model-specific compatibility safeguards can keep an incompatible model on direct disclosure.
Sandbox-first `inference get` and `inference set` commands now provide the same route controls as their global forms.
For more information, refer to [Tool Calling Reliability](../inference/tool-calling-reliability), [Model Capability Audit](../inference/model-capability-audit), and [NemoClaw CLI Commands Reference](../reference/commands).
+- Shared OpenShell gateways now enforce a single compatible inference route across every registered sandbox, including stopped sandboxes.
+ Onboarding, connect-time repair, and `inference set` reject provider/model conflicts; custom routes must also match the normalized endpoint and API family.
+ As a migration requirement, custom switches must provide `--endpoint-url` and an unambiguous API family, and incomplete legacy custom-route metadata fails closed until the sandbox is removed and re-onboarded with complete metadata.
+ After backing up an affected workspace, an OpenAI-compatible route can be re-onboarded with complete metadata as follows (replace the example endpoint, model, and sandbox name):
+
+ ```bash
+ $$nemoclaw legacy-sandbox destroy
+ NEMOCLAW_PROVIDER=custom \
+ NEMOCLAW_ENDPOINT_URL=https://endpoint.example/v1 \
+ NEMOCLAW_MODEL=your-model-id \
+ NEMOCLAW_PREFERRED_API=openai-completions \
+ $$nemoclaw onboard --name legacy-sandbox
+ ```
+
+ Hermes deterministically selects `openai-completions` for `compatible-anthropic-endpoint`, so that one route may omit `--inference-api`; explicit incompatible values are rejected.
+ Use a different `NEMOCLAW_GATEWAY_PORT` when sandboxes need independent routes.
+ For more information, refer to [Switch Inference Providers](../inference/switch-inference-providers), [NemoClaw CLI Commands Reference](../reference/commands), and [Troubleshooting](../reference/troubleshooting).
- LangChain Deep Agents Code now provides managed `status`, `whoami`, and `identity` commands without launching the interactive UI, validates the installed agent version during onboarding, and keeps credential-shaped or tracing configuration out of persisted runtime metadata.
Its rebuild path validates recreation before destructive handoff and preserves the managed proxy, tool-disclosure, and MCP boundaries.
For more information, refer to [Quickstart with LangChain Deep Agents Code](../../openclaw/get-started/quickstart-langchain-deepagents-code), [NemoClaw CLI Commands Reference](../reference/commands), and [Security Best Practices](../security/best-practices).
diff --git a/docs/inference/switch-inference-providers.mdx b/docs/inference/switch-inference-providers.mdx
index f2fd3e77a2b..4fa8c8dffc9 100644
--- a/docs/inference/switch-inference-providers.mdx
+++ b/docs/inference/switch-inference-providers.mdx
@@ -27,6 +27,17 @@ If you run `$$nemoclaw inference set` before NemoClaw has a selected sandbox, th
Run `$$nemoclaw onboard` first, then retry the switch.
+
+OpenShell currently exposes one inference route per gateway, not one route per sandbox.
+All registered sandboxes on the same gateway must use the same provider and model.
+Custom compatible routes must also use the same endpoint and API family.
+An OpenClaw native-Anthropic route and a Hermes OpenAI-frontend route therefore cannot share a gateway, even when their custom endpoint and model are otherwise identical.
+NemoClaw checks stopped sandboxes because they depend on that route when restarted.
+If a registered same-gateway sandbox lacks durable provider or model metadata, or a custom route lacks durable endpoint or API-family metadata, NemoClaw fails closed until you remove and re-onboard that sandbox with complete route metadata.
+When a route conflicts, onboarding, runtime switching, and connect-time repair exit non-zero before changing the gateway and name the affected sandboxes.
+Align the routes, remove the conflicting sandbox, or onboard it with another `NEMOCLAW_GATEWAY_PORT`.
+
+
## Find the Provider Name
The `--provider` value must match a provider already configured on the sandbox.
@@ -104,7 +115,11 @@ $$nemoclaw inference set --provider compatible-endpoint --model
$$nemoclaw inference set --provider compatible-anthropic-endpoint --model
```
-To change only the model on a sandbox that is already on this provider, omit `--endpoint-url` — `inference set` reuses the endpoint that onboarding established and does not repoint the gateway route. Any `--endpoint-url` you do pass is always validated by the host-side SSRF guard, so a URL that resolves to a private or internal address is rejected even if it is the same one onboarding recorded; omit the flag to keep the established endpoint. To point the sandbox at a genuinely different endpoint, re-run `$$nemoclaw onboard` with the new endpoint (rebuild reuses the recorded endpoint and cannot change it).
+To change only the model on a sandbox that is already on this provider, omit `--endpoint-url` when its durable registry entry contains both endpoint and API-family metadata.
+`inference set` reuses that recorded route and does not repoint the gateway.
+If the durable metadata is incomplete, NemoClaw fails closed and tells you to re-run onboarding.
+Any `--endpoint-url` you pass is always validated by the host-side SSRF guard, so a URL that resolves to a private or internal address is rejected even if it matches the recorded endpoint.
+To point the sandbox at a different endpoint, re-run `$$nemoclaw onboard` with the new endpoint because rebuild reuses the recorded endpoint and cannot change it.
@@ -145,6 +160,7 @@ $$nemoclaw rebuild
Resuming onboarding also detects and repairs a stale route.
`inference set` can select this provider after it has been registered on the verified OpenAI surface, but it fails before mutation for a legacy Anthropic registration because that command cannot change a gateway provider's protocol type.
+When supplying new custom metadata, Hermes may omit `--inference-api` for this provider because NemoClaw deterministically selects `openai-completions`; an explicit non-OpenAI value is rejected.
#### Switching from Responses API to Chat Completions
@@ -203,6 +219,7 @@ The error includes a redacted OpenShell detail, lists the registered providers w
Run `$$nemoclaw onboard` to register the provider, then retry `$$nemoclaw inference set`.
Use `--no-verify` only when OpenShell cannot verify the provider at switch time but you have already confirmed the provider and credential.
+`--no-verify` does not bypass the one-route-per-gateway compatibility check.
## Tune Model Metadata
diff --git a/docs/reference/commands-nemohermes.mdx b/docs/reference/commands-nemohermes.mdx
index 35a6af19022..bf973a54cad 100644
--- a/docs/reference/commands-nemohermes.mdx
+++ b/docs/reference/commands-nemohermes.mdx
@@ -541,8 +541,10 @@ If another terminal is already connected to the sandbox, `connect` prints a note
An unknown model slug, malformed extra-args JSON, or a gated model (for example `deepseek-r1-distill-70b`) with no `HF_TOKEN` or `HUGGING_FACE_HUB_TOKEN` exits non-zero with the same error the installer would emit, before any sandbox readiness probe or SSH attach.
Unset the managed-vLLM variable, or fix the value, before retrying.
-When the live OpenShell gateway inference route differs from the route recorded in the NemoClaw registry, `connect` prints an explicit warning and realigns the shared gateway to the recorded route.
-Use `nemohermes inference set --provider --model ` to make an intentional route change.
+When the live OpenShell gateway inference route differs from the route recorded in the NemoClaw registry, `connect` checks every registered sandbox on that gateway before attempting a repair.
+It realigns the route only when those registry entries are compatible with the requested provider and model.
+If another sandbox records a conflicting route, `connect` exits non-zero without changing the gateway and names the affected sandboxes.
+Use `nemohermes inference set --provider --model ` to make an intentional compatible route change.
If the sandbox is registered locally but missing from a healthy gateway, `connect` preserves the registry entry and points you to `rebuild --yes`, `onboard`, or `destroy` instead of deleting the metadata needed for recovery.
After a host reboot, the OpenShell gateway rotates its SSH host keys.
@@ -1921,6 +1923,12 @@ By default, the command syncs the default registered sandbox.
The command refuses before changing the OpenShell route when the selected sandbox has shields up.
Run `nemohermes shields down`, apply the inference change, then run `nemohermes shields up` again.
+Each OpenShell gateway exposes one inference route to every sandbox registered on that gateway.
+Before changing the route, NemoClaw compares the requested provider and model with every same-gateway registry entry, including stopped sandboxes.
+Custom compatible routes must also have matching normalized endpoint URLs and API families.
+If a route conflicts or a legacy custom route lacks enough endpoint or API-family metadata to prove compatibility, the command exits non-zero before changing the OpenShell route, agent config, or host registry and names the conflicting sandboxes.
+Align those sandboxes to the same route, remove the conflicting sandbox, or onboard it with another `NEMOCLAW_GATEWAY_PORT`.
+
```bash
nemohermes inference set --provider --model [--sandbox ] [--no-verify] [--endpoint-url ] [--credential-env ] [--inference-api ]
```
@@ -1933,19 +1941,21 @@ nemohermes my-assistant inference set --provider nvidia-prod --model nvidia/nemo
```
Pass both `--provider` and `--model` when you want NemoClaw to update the OpenShell inference route and sync the selected sandbox's agent config.
-If you only want the lower-level OpenShell route operation, run `openshell inference set -g nemoclaw --model --provider ` directly.
-When either flag is missing, `nemohermes inference set` prints that OpenShell command instead of an oclif flag-validation error.
+NemoClaw resolves the OpenShell gateway from the target sandbox's recorded gateway binding, including non-default `NEMOCLAW_GATEWAY_PORT` deployments.
+Do not run `openshell inference set` directly on a shared NemoClaw gateway because that bypasses registry compatibility checks and can break other sandboxes.
+When either flag is missing, `nemohermes inference set` reports both required flags without suggesting a raw OpenShell command.
The command updates the host registry immediately after the gateway route changes.
If the in-sandbox config sync fails, NemoClaw keeps the gateway and registry aligned, warns that the running image may still need a rebuild, and points you to `nemohermes rebuild`.
Supported provider names are `nvidia-prod`, `nvidia-nim`, `nvidia-router`, `openai-api`, `anthropic-prod`, `compatible-anthropic-endpoint`, `gemini-api`, `compatible-endpoint`, `hermes-provider`, `ollama-local`, and `vllm-local`.
Use `--no-verify` only when OpenShell cannot verify the provider at switch time but you have already confirmed the provider and credential.
-When switching to `compatible-endpoint` or `compatible-anthropic-endpoint` from a different provider family, pass `--endpoint-url` with the trusted custom provider URL so NemoClaw can persist durable rebuild metadata.
+When switching to `compatible-endpoint` or `compatible-anthropic-endpoint` from a different provider family, pass `--endpoint-url` with the trusted custom provider URL and, except for the Hermes case below, `--inference-api` with its API family so NemoClaw can persist a complete route identity for rebuild and shared-gateway checks.
+For a Hermes `compatible-anthropic-endpoint` target, `--inference-api` may be omitted because NemoClaw deterministically selects `openai-completions`; an explicit different API family is rejected.
NemoClaw rejects loopback, link-local, private, and internal endpoint addresses, including public hostnames that resolve to a private address.
For public HTTP URLs, NemoClaw stores the validated IP address to prevent DNS rebinding.
DNS-backed HTTPS URLs are rejected because NemoClaw cannot pin the downstream peer address while preserving TLS SNI and host validation across the OpenShell runtime boundary; HTTPS IP-literal URLs remain supported.
NemoClaw accepts `http://host.openshell.internal:` only with an explicit port from `1024` through `65535`; this narrow exception supports NemoClaw's sandbox-to-host inference routes and is not a general private-endpoint bypass.
-`--credential-env` and `--inference-api` may also be supplied for the compatible provider metadata; supported API values are `openai-completions`, `anthropic-messages`, and `openai-responses`.
+`--credential-env` may also be supplied for compatible provider metadata; supported `--inference-api` values are `openai-completions`, `anthropic-messages`, and `openai-responses`.
### `nemohermes setup`
diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx
index 857196a5edd..2314463c9fb 100644
--- a/docs/reference/commands.mdx
+++ b/docs/reference/commands.mdx
@@ -645,8 +645,10 @@ If another terminal is already connected to the sandbox, `connect` prints a note
An unknown model slug, malformed extra-args JSON, or a gated model (for example `deepseek-r1-distill-70b`) with no `HF_TOKEN` or `HUGGING_FACE_HUB_TOKEN` exits non-zero with the same error the installer would emit, before any sandbox readiness probe or SSH attach.
Unset the managed-vLLM variable, or fix the value, before retrying.
-When the live OpenShell gateway inference route differs from the route recorded in the NemoClaw registry, `connect` prints an explicit warning and realigns the shared gateway to the recorded route.
-Use `$$nemoclaw inference set --provider --model ` to make an intentional route change.
+When the live OpenShell gateway inference route differs from the route recorded in the NemoClaw registry, `connect` checks every registered sandbox on that gateway before attempting a repair.
+It realigns the route only when those registry entries are compatible with the requested provider and model.
+If another sandbox records a conflicting route, `connect` exits non-zero without changing the gateway and names the affected sandboxes.
+Use `$$nemoclaw inference set --provider --model ` to make an intentional compatible route change.
If the sandbox is registered locally but missing from a healthy gateway, `connect` preserves the registry entry and points you to `rebuild --yes`, `onboard`, or `destroy` instead of deleting the metadata needed for recovery.
After a host reboot, the OpenShell gateway rotates its SSH host keys.
@@ -2380,6 +2382,12 @@ By default, the command syncs the default registered sandbox.
The command refuses before changing the OpenShell route when the selected sandbox has shields up.
Run `$$nemoclaw shields down`, apply the inference change, then run `$$nemoclaw shields up` again.
+Each OpenShell gateway exposes one inference route to every sandbox registered on that gateway.
+Before changing the route, NemoClaw compares the requested provider and model with every same-gateway registry entry, including stopped sandboxes.
+Custom compatible routes must also have matching normalized endpoint URLs and API families.
+If a route conflicts or a legacy custom route lacks enough endpoint or API-family metadata to prove compatibility, the command exits non-zero before changing the OpenShell route, agent config, or host registry and names the conflicting sandboxes.
+Align those sandboxes to the same route, remove the conflicting sandbox, or onboard it with another `NEMOCLAW_GATEWAY_PORT`.
+
```bash
$$nemoclaw inference set --provider --model [--sandbox ] [--no-verify] [--endpoint-url ] [--credential-env ] [--inference-api ]
```
@@ -2392,19 +2400,21 @@ $$nemoclaw my-assistant inference set --provider nvidia-prod --model nvidia/nemo
```
Pass both `--provider` and `--model` when you want NemoClaw to update the OpenShell inference route and sync the selected sandbox's agent config.
-If you only want the lower-level OpenShell route operation, run `openshell inference set -g nemoclaw --model --provider ` directly.
-When either flag is missing, `$$nemoclaw inference set` prints that OpenShell command instead of an oclif flag-validation error.
+NemoClaw resolves the OpenShell gateway from the target sandbox's recorded gateway binding, including non-default `NEMOCLAW_GATEWAY_PORT` deployments.
+Do not run `openshell inference set` directly on a shared NemoClaw gateway because that bypasses registry compatibility checks and can break other sandboxes.
+When either flag is missing, `$$nemoclaw inference set` reports both required flags without suggesting a raw OpenShell command.
The command updates the host registry immediately after the gateway route changes.
If the in-sandbox config sync fails, NemoClaw keeps the gateway and registry aligned, warns that the running image may still need a rebuild, and points you to `$$nemoclaw rebuild`.
Supported provider names are `nvidia-prod`, `nvidia-nim`, `nvidia-router`, `openai-api`, `anthropic-prod`, `compatible-anthropic-endpoint`, `gemini-api`, `compatible-endpoint`, `hermes-provider`, `ollama-local`, and `vllm-local`.
Use `--no-verify` only when OpenShell cannot verify the provider at switch time but you have already confirmed the provider and credential.
-When switching to `compatible-endpoint` or `compatible-anthropic-endpoint` from a different provider family, pass `--endpoint-url` with the trusted custom provider URL so NemoClaw can persist durable rebuild metadata.
+When switching to `compatible-endpoint` or `compatible-anthropic-endpoint` from a different provider family, pass `--endpoint-url` with the trusted custom provider URL and, except for the Hermes case below, `--inference-api` with its API family so NemoClaw can persist a complete route identity for rebuild and shared-gateway checks.
+For a Hermes `compatible-anthropic-endpoint` target, `--inference-api` may be omitted because NemoClaw deterministically selects `openai-completions`; an explicit different API family is rejected.
NemoClaw rejects loopback, link-local, private, and internal endpoint addresses, including public hostnames that resolve to a private address.
For public HTTP URLs, NemoClaw stores the validated IP address to prevent DNS rebinding.
DNS-backed HTTPS URLs are rejected because NemoClaw cannot pin the downstream peer address while preserving TLS SNI and host validation across the OpenShell runtime boundary; HTTPS IP-literal URLs remain supported.
NemoClaw accepts `http://host.openshell.internal:` only with an explicit port from `1024` through `65535`; this narrow exception supports NemoClaw's sandbox-to-host inference routes and is not a general private-endpoint bypass.
-`--credential-env` and `--inference-api` may also be supplied for the compatible provider metadata; supported API values are `openai-completions`, `anthropic-messages`, and `openai-responses`.
+`--credential-env` may also be supplied for compatible provider metadata; supported `--inference-api` values are `openai-completions`, `anthropic-messages`, and `openai-responses`.
### `$$nemoclaw setup`
diff --git a/docs/reference/troubleshooting.mdx b/docs/reference/troubleshooting.mdx
index 83fe270f250..fbe61c7c79b 100644
--- a/docs/reference/troubleshooting.mdx
+++ b/docs/reference/troubleshooting.mdx
@@ -323,6 +323,46 @@ $$nemoclaw list
`$$nemoclaw list` prints the recorded dashboard URL for each sandbox.
+OpenShell currently exposes one inference route per gateway.
+Sandboxes that share a gateway must record the same provider and model.
+Custom compatible routes must also record the same normalized endpoint and API family.
+An OpenClaw sandbox using the native Anthropic Messages frontend and a Hermes sandbox using the managed OpenAI frontend need separate gateway ports, even when they use the same custom endpoint and model.
+NemoClaw includes stopped sandboxes in this check because they use the gateway route when restarted.
+
+If onboarding, `inference set`, or connect-time route repair reports a shared-gateway conflict, choose one of these recovery paths:
+
+- Align every named sandbox to the same route.
+- Remove a conflicting sandbox with `$$nemoclaw destroy` when you no longer need it.
+- Onboard the sandbox against another gateway port when it needs a different route.
+
+An aligned group cannot move to a wholly new route one sandbox at a time because the first change would conflict with the remaining registry rows.
+Back up each workspace, remove or migrate the peer sandboxes, switch the last sandbox on the original gateway, then re-onboard the peers with that same route or with separate gateway ports.
+
+If the error names incomplete legacy custom-route metadata, back up and remove the affected sandbox, then re-onboard it with an explicit custom endpoint and API family.
+For an OpenAI-compatible route, replace the example endpoint, model, and sandbox name in this recovery sequence:
+
+```bash
+$$nemoclaw legacy-sandbox destroy
+NEMOCLAW_PROVIDER=custom \
+ NEMOCLAW_ENDPOINT_URL=https://endpoint.example/v1 \
+ NEMOCLAW_MODEL=your-model-id \
+ NEMOCLAW_PREFERRED_API=openai-completions \
+ $$nemoclaw onboard --name legacy-sandbox
+```
+
+If the error names an invalid gateway binding, restore the affected row's known-good `gatewayName` and `gatewayPort` metadata from a trusted backup; otherwise back up and remove the sandbox, then re-onboard it.
+Do not guess or copy a binding from another sandbox because lifecycle commands use it to select the gateway.
+
+For example, keep the first sandbox on the default gateway and assign a separate gateway to the second sandbox:
+
+```bash
+$$nemoclaw onboard --name first-sandbox
+NEMOCLAW_GATEWAY_PORT=8990 $$nemoclaw onboard --name second-sandbox
+```
+
+NemoClaw derives a separate gateway name and state directory from the second port.
+Using a different dashboard port alone does not create a separate inference route.
+
## Onboarding
### Cgroup v2 errors during onboard
diff --git a/src/commands/inference/set.ts b/src/commands/inference/set.ts
index 6d5398b7c44..cbf1329f3ad 100644
--- a/src/commands/inference/set.ts
+++ b/src/commands/inference/set.ts
@@ -4,8 +4,8 @@
import { Flags } from "@oclif/core";
import { InferenceSetError, runInferenceSet } from "../../lib/actions/inference-set";
-import { CLI_NAME } from "../../lib/cli/branding";
import { nonEmptyFlag } from "../../lib/cli/flag-helpers";
+import { inferenceSetRequiredFlagsFailureLines } from "../../lib/cli/inference-set-help";
import { NemoClawCommand } from "../../lib/cli/nemoclaw-oclif-command";
// Global inference:set is paired with the sandbox-first sandbox:inference:set
@@ -49,7 +49,7 @@ export default class InferenceSetCommand extends NemoClawCommand {
public async run(): Promise {
const { flags } = await this.parse(InferenceSetCommand);
if (!flags.provider || !flags.model) {
- this.printOpenShellRedirect();
+ this.printRequiredFlags();
return;
}
try {
@@ -71,18 +71,9 @@ export default class InferenceSetCommand extends NemoClawCommand {
}
}
- private printOpenShellRedirect(): void {
+ private printRequiredFlags(): void {
this.failWithLines(
- [
- ` Unknown ${CLI_NAME} command: inference set`,
- "",
- " This operation belongs to OpenShell.",
- " Run: openshell inference set -g nemoclaw --model --provider ",
- ` To also sync the running sandbox config, pass --provider and --model to ${CLI_NAME} inference set.`,
- "",
- ` Run '${CLI_NAME} help' for NemoClaw commands.`,
- ],
- 1,
+ inferenceSetRequiredFlagsFailureLines("inference set", " [--sandbox ]"),
);
}
}
diff --git a/src/commands/sandbox/inference/oclif-command-adapters.test.ts b/src/commands/sandbox/inference/oclif-command-adapters.test.ts
index c3d96e6ee9c..457ea8c9d03 100644
--- a/src/commands/sandbox/inference/oclif-command-adapters.test.ts
+++ b/src/commands/sandbox/inference/oclif-command-adapters.test.ts
@@ -89,7 +89,7 @@ describe("sandbox inference oclif command adapters (#5977)", () => {
});
});
- it("prints the missing-flags redirect without calling runInferenceSet", async () => {
+ it("prints guarded missing-flags guidance without calling runInferenceSet", async () => {
const error = vi.spyOn(console, "error").mockImplementation(() => undefined);
const previousExitCode = process.exitCode;
process.exitCode = undefined;
@@ -101,6 +101,7 @@ describe("sandbox inference oclif command adapters (#5977)", () => {
expect(error).toHaveBeenCalledWith(
expect.stringContaining("inference set requires --provider and --model"),
);
+ expect(error).not.toHaveBeenCalledWith(expect.stringContaining("openshell inference set"));
} finally {
process.exitCode = previousExitCode;
error.mockRestore();
diff --git a/src/commands/sandbox/inference/set.ts b/src/commands/sandbox/inference/set.ts
index 3631c0fa87a..2af01c9c3ba 100644
--- a/src/commands/sandbox/inference/set.ts
+++ b/src/commands/sandbox/inference/set.ts
@@ -4,8 +4,8 @@
import { Flags } from "@oclif/core";
import { InferenceSetError, runInferenceSet } from "../../../lib/actions/inference-set";
-import { CLI_NAME } from "../../../lib/cli/branding";
import { nonEmptyFlag } from "../../../lib/cli/flag-helpers";
+import { inferenceSetRequiredFlagsFailureLines } from "../../../lib/cli/inference-set-help";
import { NemoClawCommand } from "../../../lib/cli/nemoclaw-oclif-command";
import { sandboxNameArg } from "../../../lib/sandbox/command-support";
@@ -53,7 +53,7 @@ export default class SandboxInferenceSetCommand extends NemoClawCommand {
public async run(): Promise {
const { args, flags } = await this.parse(SandboxInferenceSetCommand);
if (!flags.provider || !flags.model) {
- this.printOpenShellRedirect();
+ this.printRequiredFlags();
return;
}
try {
@@ -75,18 +75,7 @@ export default class SandboxInferenceSetCommand extends NemoClawCommand {
}
}
- private printOpenShellRedirect(): void {
- this.failWithLines(
- [
- ` ${CLI_NAME} inference set requires --provider and --model.`,
- "",
- " To change only the OpenShell route, run:",
- " openshell inference set -g nemoclaw --model --provider ",
- ` To also sync the sandbox config, pass --provider and --model to ${CLI_NAME} inference set.`,
- "",
- ` Run '${CLI_NAME} help' for NemoClaw commands.`,
- ],
- 1,
- );
+ private printRequiredFlags(): void {
+ this.failWithLines(inferenceSetRequiredFlagsFailureLines(" inference set"));
}
}
diff --git a/src/lib/actions/inference-set-compatible-provider.test.ts b/src/lib/actions/inference-set-compatible-provider.test.ts
index c63c2c8ce8f..31a69c06881 100644
--- a/src/lib/actions/inference-set-compatible-provider.test.ts
+++ b/src/lib/actions/inference-set-compatible-provider.test.ts
@@ -330,6 +330,8 @@ describe("runInferenceSet compatible providers", () => {
provider === "compatible-endpoint"
? "COMPATIBLE_API_KEY"
: "COMPATIBLE_ANTHROPIC_API_KEY",
+ inferenceApi:
+ provider === "compatible-endpoint" ? "openai-completions" : "anthropic-messages",
},
deps,
),
diff --git a/src/lib/actions/inference-set-gateway-route-containment.test.ts b/src/lib/actions/inference-set-gateway-route-containment.test.ts
new file mode 100644
index 00000000000..fb95d8836eb
--- /dev/null
+++ b/src/lib/actions/inference-set-gateway-route-containment.test.ts
@@ -0,0 +1,508 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import fs from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { withGatewayRouteMutationLock } from "../inference/gateway-route-mutation-lock";
+import type { ConfigObject } from "../security/credential-filter";
+import type { SandboxEntry } from "../state/registry";
+import { runInferenceSet } from "./inference-set";
+import { baseSession, createDeps, HERMES_TARGET } from "./inference-set.test-support";
+import {
+ finalizeInferenceSetRoute,
+ prepareInferenceSetRoute,
+} from "./inference-set-route-containment";
+
+const entry = (name: string, overrides: Partial = {}): SandboxEntry => ({
+ name,
+ agent: "openclaw",
+ gatewayName: "nemoclaw",
+ gatewayPort: 8080,
+ provider: "nvidia-prod",
+ model: "nvidia/model-a",
+ ...overrides,
+});
+
+describe("runtime shared gateway route containment", () => {
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ });
+
+ it("rejects an ambient gateway endpoint before OpenShell prep or state mutation", async () => {
+ vi.stubEnv("OPENSHELL_GATEWAY_ENDPOINT", "https://other.example.test");
+ const deps = createDeps({
+ config: {},
+ entries: [entry("alpha")],
+ defaultSandbox: "alpha",
+ });
+
+ await expect(
+ runInferenceSet(
+ { provider: "nvidia-prod", model: "nvidia/model-b", sandboxName: "alpha" },
+ deps,
+ ),
+ ).rejects.toMatchObject({
+ message: expect.stringContaining("Unset OPENSHELL_GATEWAY_ENDPOINT"),
+ exitCode: 2,
+ });
+
+ expect(deps.calls.prepareRunOpenshell).not.toHaveBeenCalled();
+ expect(deps.calls.captureOpenshell).not.toHaveBeenCalled();
+ expect(deps.calls.rewriteConfigUrlsWithDnsPinning).not.toHaveBeenCalled();
+ expect(deps.calls.readSandboxConfig).not.toHaveBeenCalled();
+ expect(deps.calls.writeSandboxConfig).not.toHaveBeenCalled();
+ expect(deps.calls.recomputeSandboxConfigHash).not.toHaveBeenCalled();
+ expect(deps.calls.updateSandbox).not.toHaveBeenCalled();
+ expect(deps.calls.updateSession).not.toHaveBeenCalled();
+ expect(deps.calls.appendAuditEntry).not.toHaveBeenCalled();
+ });
+
+ it("rejects a pending onboarding route reservation before any mutation", async () => {
+ const deps = createDeps({
+ config: {},
+ entries: [entry("alpha", { pendingRouteReservation: true })],
+ defaultSandbox: "alpha",
+ });
+
+ await expect(
+ runInferenceSet(
+ { provider: "nvidia-prod", model: "nvidia/model-b", sandboxName: "alpha" },
+ deps,
+ ),
+ ).rejects.toThrow("still being created by onboarding");
+
+ expect(deps.calls.prepareRunOpenshell).not.toHaveBeenCalled();
+ expect(deps.calls.captureOpenshell).not.toHaveBeenCalled();
+ expect(deps.calls.writeSandboxConfig).not.toHaveBeenCalled();
+ expect(deps.calls.updateSandbox).not.toHaveBeenCalled();
+ });
+
+ it("rejects a same-gateway conflict before OpenShell, config, or registry mutation (#6315)", async () => {
+ const deps = createDeps({
+ config: {},
+ entries: [entry("alpha"), entry("stopped-peer")],
+ defaultSandbox: "alpha",
+ });
+
+ await expect(
+ runInferenceSet(
+ { provider: "nvidia-prod", model: "nvidia/model-b", sandboxName: "alpha" },
+ deps,
+ ),
+ ).rejects.toThrow("stopped-peer");
+
+ expect(deps.calls.captureOpenshell).not.toHaveBeenCalled();
+ expect(deps.calls.readSandboxConfig).not.toHaveBeenCalled();
+ expect(deps.calls.writeSandboxConfig).not.toHaveBeenCalled();
+ expect(deps.calls.updateSandbox).not.toHaveBeenCalled();
+ expect(deps.calls.updateSession).not.toHaveBeenCalled();
+ expect(deps.calls.appendAuditEntry).not.toHaveBeenCalled();
+ });
+
+ it("targets the selected sandbox gateway and allows a conflicting route elsewhere (#6315)", async () => {
+ const deps = createDeps({
+ config: {},
+ entries: [
+ entry("alpha", { gatewayName: "nemoclaw-9090", gatewayPort: 9090 }),
+ entry("default-gateway-peer"),
+ ],
+ defaultSandbox: "alpha",
+ contextWindow: 32_768,
+ });
+
+ await expect(
+ runInferenceSet(
+ { provider: "nvidia-prod", model: "nvidia/model-b", sandboxName: "alpha" },
+ deps,
+ ),
+ ).resolves.toMatchObject({ sandboxName: "alpha", model: "nvidia/model-b" });
+
+ expect(deps.calls.captureOpenshell).toHaveBeenCalledWith(
+ [
+ "inference",
+ "set",
+ "-g",
+ "nemoclaw-9090",
+ "--provider",
+ "nvidia-prod",
+ "--model",
+ "nvidia/model-b",
+ ],
+ expect.objectContaining({ ignoreError: true }),
+ );
+ });
+
+ it("aborts before mutation when the target changes gateways while waiting", async () => {
+ const alpha = entry("alpha");
+ const deps = createDeps({
+ config: {},
+ entries: [alpha],
+ defaultSandbox: alpha.name,
+ withGatewayRouteMutationLock: async (gatewayName, operation) => {
+ expect(gatewayName).toBe("nemoclaw");
+ Object.assign(alpha, { gatewayName: "nemoclaw-9090", gatewayPort: 9090 });
+ return await operation();
+ },
+ });
+
+ await expect(
+ runInferenceSet(
+ { provider: "nvidia-prod", model: "nvidia/model-b", sandboxName: alpha.name },
+ deps,
+ ),
+ ).rejects.toMatchObject({
+ message: expect.stringContaining("moved from OpenShell gateway 'nemoclaw'"),
+ exitCode: 2,
+ });
+
+ expect(deps.calls.captureOpenshell).not.toHaveBeenCalled();
+ expect(deps.calls.rewriteConfigUrlsWithDnsPinning).not.toHaveBeenCalled();
+ expect(deps.calls.readSandboxConfig).not.toHaveBeenCalled();
+ expect(deps.calls.writeSandboxConfig).not.toHaveBeenCalled();
+ expect(deps.calls.updateSandbox).not.toHaveBeenCalled();
+ expect(deps.calls.updateSession).not.toHaveBeenCalled();
+ expect(deps.calls.appendAuditEntry).not.toHaveBeenCalled();
+ });
+
+ it("blocks a custom endpoint conflict before DNS validation or mutation (#6315)", async () => {
+ const deps = createDeps({
+ config: {},
+ entries: [
+ entry("alpha", {
+ provider: "compatible-endpoint",
+ model: "custom/model",
+ endpointUrl: "https://alpha.example.test/v1",
+ credentialEnv: "COMPATIBLE_API_KEY",
+ preferredInferenceApi: "openai-completions",
+ }),
+ entry("custom-peer", {
+ provider: "compatible-endpoint",
+ model: "custom/model",
+ endpointUrl: "https://peer.example.test/v1",
+ credentialEnv: "COMPATIBLE_API_KEY",
+ preferredInferenceApi: "openai-completions",
+ }),
+ ],
+ defaultSandbox: "alpha",
+ });
+
+ await expect(
+ runInferenceSet(
+ {
+ provider: "compatible-endpoint",
+ model: "custom/model",
+ sandboxName: "alpha",
+ endpointUrl: "https://alpha.example.test/v1",
+ credentialEnv: "COMPATIBLE_API_KEY",
+ inferenceApi: "openai-completions",
+ },
+ deps,
+ ),
+ ).rejects.toThrow("custom-peer");
+
+ expect(deps.calls.rewriteConfigUrlsWithDnsPinning).not.toHaveBeenCalled();
+ expect(deps.calls.captureOpenshell).not.toHaveBeenCalled();
+ expect(deps.calls.readSandboxConfig).not.toHaveBeenCalled();
+ expect(deps.calls.updateSandbox).not.toHaveBeenCalled();
+ });
+
+ it("refreshes peers after async endpoint validation before route mutation (#6315)", async () => {
+ const alpha = entry("alpha");
+ const peer = entry("late-peer", {
+ provider: "compatible-endpoint",
+ model: "custom/model",
+ endpointUrl: "https://peer.example.test/v1",
+ credentialEnv: "COMPATIBLE_API_KEY",
+ preferredInferenceApi: "openai-completions",
+ });
+ const deps = createDeps({ config: {}, entries: [alpha], defaultSandbox: "alpha" });
+ const listSandboxes = vi
+ .fn()
+ .mockReturnValueOnce({ sandboxes: [alpha], defaultSandbox: "alpha" })
+ .mockReturnValue({ sandboxes: [alpha, peer], defaultSandbox: "alpha" });
+ deps.listSandboxes = listSandboxes;
+
+ await expect(
+ runInferenceSet(
+ {
+ provider: "compatible-endpoint",
+ model: "custom/model",
+ sandboxName: "alpha",
+ endpointUrl: "https://alpha.example.test/v1",
+ credentialEnv: "COMPATIBLE_API_KEY",
+ inferenceApi: "openai-completions",
+ },
+ deps,
+ ),
+ ).rejects.toThrow("late-peer");
+
+ expect(listSandboxes).toHaveBeenCalledTimes(2);
+ expect(deps.calls.rewriteConfigUrlsWithDnsPinning).toHaveBeenCalledOnce();
+ expect(deps.calls.captureOpenshell).not.toHaveBeenCalled();
+ expect(deps.calls.readSandboxConfig).not.toHaveBeenCalled();
+ expect(deps.calls.updateSandbox).not.toHaveBeenCalled();
+ });
+
+ it("rechecks a DNS-normalized endpoint before route mutation (#6315)", async () => {
+ const customRoute = {
+ provider: "compatible-endpoint",
+ model: "custom/model",
+ endpointUrl: "http://public.example.test/v1",
+ credentialEnv: "COMPATIBLE_API_KEY",
+ preferredInferenceApi: "openai-completions",
+ } as const;
+ const deps = createDeps({
+ config: {},
+ entries: [entry("alpha", customRoute), entry("custom-peer", customRoute)],
+ defaultSandbox: "alpha",
+ rewriteConfigUrlsWithDnsPinning: async (value) =>
+ typeof value === "string" ? "http://203.0.113.10/v1" : value,
+ });
+
+ await expect(
+ runInferenceSet(
+ {
+ ...customRoute,
+ sandboxName: "alpha",
+ inferenceApi: "openai-completions",
+ },
+ deps,
+ ),
+ ).rejects.toThrow("custom-peer");
+
+ expect(deps.calls.rewriteConfigUrlsWithDnsPinning).toHaveBeenCalledOnce();
+ expect(deps.calls.captureOpenshell).not.toHaveBeenCalled();
+ expect(deps.calls.readSandboxConfig).not.toHaveBeenCalled();
+ expect(deps.calls.updateSandbox).not.toHaveBeenCalled();
+ });
+
+ it("catches a DNS change between the preliminary and finalized gateway route checks", async () => {
+ const firstEndpoint = "https://first.example.test/v1";
+ const secondEndpoint = "https://second.example.test/v1";
+ const customRoute = {
+ provider: "compatible-endpoint",
+ model: "custom/model",
+ endpointUrl: firstEndpoint,
+ credentialEnv: "COMPATIBLE_API_KEY",
+ preferredInferenceApi: "openai-completions",
+ } as const;
+ const alpha = entry("alpha", customRoute);
+ const peer = entry("custom-peer", customRoute);
+ const prepared = prepareInferenceSetRoute({
+ entry: alpha,
+ sandboxName: alpha.name,
+ provider: customRoute.provider,
+ model: customRoute.model,
+ customRoute: {
+ endpointUrl: firstEndpoint,
+ credentialEnv: customRoute.credentialEnv,
+ inferenceApi: customRoute.preferredInferenceApi,
+ },
+ session: null,
+ sandboxes: [alpha, peer],
+ });
+ const rewriteUrlWithDnsPinning = vi.fn().mockResolvedValueOnce(secondEndpoint);
+
+ await expect(
+ finalizeInferenceSetRoute({
+ prepared,
+ sandboxName: alpha.name,
+ provider: customRoute.provider,
+ model: customRoute.model,
+ canReuseRecordedRoute: false,
+ getSandboxes: () => [alpha, peer],
+ rewriteUrlWithDnsPinning,
+ }),
+ ).rejects.toThrow("custom-peer");
+
+ expect(rewriteUrlWithDnsPinning).toHaveBeenCalledOnce();
+ expect(rewriteUrlWithDnsPinning).toHaveBeenCalledWith(firstEndpoint);
+ });
+
+ it("blocks an incomplete legacy custom target even without a peer (#6315)", async () => {
+ const deps = createDeps({
+ config: {},
+ entries: [
+ entry("alpha", {
+ provider: "compatible-endpoint",
+ model: "custom/model",
+ endpointUrl: null,
+ preferredInferenceApi: null,
+ }),
+ ],
+ defaultSandbox: "alpha",
+ });
+
+ await expect(
+ runInferenceSet(
+ {
+ provider: "compatible-endpoint",
+ model: "custom/model",
+ sandboxName: "alpha",
+ },
+ deps,
+ ),
+ ).rejects.toThrow("requested custom route lacks durable endpoint or API-family metadata");
+
+ expect(deps.calls.rewriteConfigUrlsWithDnsPinning).not.toHaveBeenCalled();
+ expect(deps.calls.captureOpenshell).not.toHaveBeenCalled();
+ expect(deps.calls.readSandboxConfig).not.toHaveBeenCalled();
+ expect(deps.calls.updateSandbox).not.toHaveBeenCalled();
+ });
+
+ it("scopes Hermes provider inspection and route mutation to a non-default gateway", async () => {
+ const config: ConfigObject = { model: {} };
+ const deps = createDeps({
+ config,
+ entry: {
+ name: "hermes",
+ agent: "hermes",
+ gatewayName: "nemoclaw-9090",
+ gatewayPort: 9090,
+ provider: "compatible-anthropic-endpoint",
+ model: "old-model",
+ endpointUrl: "https://anthropic-compatible.example/v1",
+ credentialEnv: "COMPATIBLE_ANTHROPIC_API_KEY",
+ preferredInferenceApi: "openai-completions",
+ },
+ defaultSandbox: "hermes",
+ target: HERMES_TARGET,
+ session: baseSession({ agent: "hermes", sandboxName: "hermes" }),
+ });
+ deps.calls.captureOpenshell.mockImplementation((args: string[]) =>
+ args[0] === "provider"
+ ? {
+ status: 0,
+ output:
+ "Name: compatible-anthropic-endpoint\nType: openai\nCredential keys: COMPATIBLE_ANTHROPIC_API_KEY\nConfig keys: OPENAI_BASE_URL",
+ stdout: "",
+ stderr: "",
+ }
+ : { status: 0, output: "", stdout: "", stderr: "" },
+ );
+
+ await runInferenceSet(
+ {
+ provider: "compatible-anthropic-endpoint",
+ model: "new-model",
+ sandboxName: "hermes",
+ noVerify: true,
+ },
+ deps,
+ );
+
+ expect(deps.calls.captureOpenshell).toHaveBeenCalledWith(
+ ["provider", "get", "-g", "nemoclaw-9090", "compatible-anthropic-endpoint"],
+ expect.objectContaining({ ignoreError: true }),
+ );
+ expect(deps.calls.captureOpenshell).toHaveBeenCalledWith(
+ [
+ "inference",
+ "set",
+ "-g",
+ "nemoclaw-9090",
+ "--provider",
+ "compatible-anthropic-endpoint",
+ "--model",
+ "new-model",
+ "--no-verify",
+ ],
+ expect.objectContaining({ ignoreError: true }),
+ );
+ });
+
+ it("blocks a stopped legacy Hermes Anthropic route before gateway inspection", async () => {
+ const deps = createDeps({
+ config: { model: {} },
+ entries: [
+ entry("hermes", { agent: "hermes", provider: "hermes-provider", model: "old-model" }),
+ entry("stopped-hermes-peer", {
+ agent: "hermes",
+ provider: "compatible-anthropic-endpoint",
+ model: "new-model",
+ endpointUrl: "https://anthropic-compatible.example/v1",
+ credentialEnv: "COMPATIBLE_ANTHROPIC_API_KEY",
+ preferredInferenceApi: "anthropic-messages",
+ }),
+ ],
+ defaultSandbox: "hermes",
+ target: HERMES_TARGET,
+ session: baseSession({
+ agent: "hermes",
+ sandboxName: "hermes",
+ provider: "compatible-anthropic-endpoint",
+ model: "new-model",
+ endpointUrl: "https://anthropic-compatible.example/v1",
+ credentialEnv: "COMPATIBLE_ANTHROPIC_API_KEY",
+ preferredInferenceApi: "anthropic-messages",
+ }),
+ });
+
+ await expect(
+ runInferenceSet(
+ {
+ provider: "compatible-anthropic-endpoint",
+ model: "new-model",
+ sandboxName: "hermes",
+ },
+ deps,
+ ),
+ ).rejects.toThrow("stopped-hermes-peer");
+
+ expect(deps.calls.captureOpenshell).not.toHaveBeenCalled();
+ expect(deps.calls.readSandboxConfig).not.toHaveBeenCalled();
+ expect(deps.calls.updateSandbox).not.toHaveBeenCalled();
+ });
+
+ it("serializes same-gateway mutations and rejects a conflicting write", async () => {
+ const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "nemoclaw-route-lock-"));
+ try {
+ const entries = [entry("route-lock-alpha"), entry("route-lock-beta")];
+ const deps = createDeps({
+ config: { agents: { defaults: { model: {} } } },
+ entries,
+ withGatewayRouteMutationLock: (gatewayName, operation) =>
+ withGatewayRouteMutationLock(gatewayName, operation, {
+ stateDir,
+ pollIntervalMs: 1,
+ timeoutMs: 5_000,
+ }),
+ });
+ deps.calls.updateSandbox.mockImplementation(
+ (sandboxName: string, updates: Partial) => {
+ const target = entries.find((candidate) => candidate.name === sandboxName);
+ expect(target).toBeDefined();
+ Object.assign(target!, updates);
+ return true;
+ },
+ );
+
+ const results = await Promise.allSettled([
+ runInferenceSet(
+ { provider: "nvidia-prod", model: "nvidia/model-a", sandboxName: entries[0].name },
+ deps,
+ ),
+ runInferenceSet(
+ { provider: "anthropic-prod", model: "claude-new", sandboxName: entries[1].name },
+ deps,
+ ),
+ ]);
+
+ expect(results.map((result) => result.status).sort()).toEqual(["fulfilled", "rejected"]);
+ expect(
+ deps.calls.captureOpenshell.mock.calls.filter(
+ ([args]) => args[0] === "inference" && args[1] === "set",
+ ),
+ ).toHaveLength(1);
+ expect(entries).toEqual([
+ expect.objectContaining({ provider: "nvidia-prod", model: "nvidia/model-a" }),
+ expect.objectContaining({ provider: "nvidia-prod", model: "nvidia/model-a" }),
+ ]);
+ expect(deps.calls.withGatewayRouteMutationLock).toHaveBeenCalledTimes(2);
+ } finally {
+ await fs.rm(stateDir, { recursive: true, force: true });
+ }
+ });
+});
diff --git a/src/lib/actions/inference-set-hermes-run.test.ts b/src/lib/actions/inference-set-hermes-run.test.ts
index 6020356255a..4d19276c136 100644
--- a/src/lib/actions/inference-set-hermes-run.test.ts
+++ b/src/lib/actions/inference-set-hermes-run.test.ts
@@ -287,6 +287,8 @@ describe("runInferenceSet Hermes routing", () => {
provider: "compatible-anthropic-endpoint",
model: "anthropic.claude-3-5-sonnet-20240620-v1:0",
endpointUrl: "https://bedrock-runtime.us-east-1.amazonaws.com",
+ credentialEnv: "COMPATIBLE_ANTHROPIC_API_KEY",
+ preferredInferenceApi: "openai-completions",
},
defaultSandbox: "hermes",
target: HERMES_TARGET,
@@ -327,8 +329,20 @@ describe("runInferenceSet Hermes routing", () => {
const deps = createDeps({
config,
entries: [
- { name: "alpha", agent: "openclaw" },
- { name: "hermes-one", agent: "hermes" },
+ {
+ name: "alpha",
+ agent: "openclaw",
+ gatewayName: "nemoclaw-9090",
+ gatewayPort: 9090,
+ provider: "nvidia-prod",
+ model: "nvidia/model-a",
+ },
+ {
+ name: "hermes-one",
+ agent: "hermes",
+ provider: "hermes-provider",
+ model: "z-ai/glm-5.1",
+ },
],
defaultSandbox: "alpha",
requestedAgent: "hermes",
diff --git a/src/lib/actions/inference-set-openclaw-run.test.ts b/src/lib/actions/inference-set-openclaw-run.test.ts
index e6043a5ee3a..5a8fa243d67 100644
--- a/src/lib/actions/inference-set-openclaw-run.test.ts
+++ b/src/lib/actions/inference-set-openclaw-run.test.ts
@@ -120,6 +120,9 @@ describe("runInferenceSet OpenClaw routing", () => {
agent: "openclaw",
provider: "compatible-anthropic-endpoint",
model: "anthropic.claude-3-5-sonnet-20240620-v1:0",
+ endpointUrl: "https://inference.local/v1",
+ credentialEnv: "COMPATIBLE_ANTHROPIC_API_KEY",
+ preferredInferenceApi: "openai-completions",
},
session: baseSession({
provider: "compatible-anthropic-endpoint",
diff --git a/src/lib/actions/inference-set-provider-alias.test.ts b/src/lib/actions/inference-set-provider-alias.test.ts
index d8955ae201a..64d5fb589f9 100644
--- a/src/lib/actions/inference-set-provider-alias.test.ts
+++ b/src/lib/actions/inference-set-provider-alias.test.ts
@@ -289,6 +289,7 @@ describe("runInferenceSet SSRF-block guidance — facet 2 (#6321)", () => {
agent: "openclaw",
provider: "compatible-endpoint",
model: "nvidia/model-a",
+ endpointUrl: "https://inference-api.nvidia.com/v1",
credentialEnv: "COMPATIBLE_API_KEY",
preferredInferenceApi: "openai-completions",
},
@@ -327,6 +328,7 @@ describe("runInferenceSet SSRF-block guidance — facet 2 (#6321)", () => {
agent: "openclaw",
provider: "compatible-anthropic-endpoint",
model: "anthropic/model-a",
+ endpointUrl: "https://inference-api.nvidia.com/v1",
credentialEnv: "COMPATIBLE_ANTHROPIC_API_KEY",
preferredInferenceApi: "anthropic-messages",
},
@@ -436,6 +438,7 @@ describe("runInferenceSet SSRF-block guidance — facet 2 (#6321)", () => {
agent: "openclaw",
provider: "compatible-endpoint",
model: "nvidia/model-a",
+ endpointUrl: "https://inference-api.nvidia.com/v1",
credentialEnv: "COMPATIBLE_API_KEY",
preferredInferenceApi: "openai-completions",
},
@@ -472,6 +475,7 @@ describe("runInferenceSet SSRF-block guidance — facet 2 (#6321)", () => {
provider: "compatible-endpoint",
model: "nvidia/model-b",
endpointUrl: "https://inference-api.nvidia.com/v1",
+ inferenceApi: "openai-completions",
noVerify: true,
},
deps,
diff --git a/src/lib/actions/inference-set-route-containment.ts b/src/lib/actions/inference-set-route-containment.ts
new file mode 100644
index 00000000000..363e167da9d
--- /dev/null
+++ b/src/lib/actions/inference-set-route-containment.ts
@@ -0,0 +1,385 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import {
+ checkGatewayRouteCompatibility,
+ formatGatewayRouteConflict,
+} from "../inference/gateway-route-compatibility";
+import { resolveSandboxGatewayName } from "../onboard/gateway-binding";
+import type { ConfigValue } from "../security/credential-filter";
+import type { Session } from "../state/onboard-session";
+import type { SandboxEntry } from "../state/registry";
+import { InferenceSetError } from "./inference-set-error";
+
+/**
+ * Custom-route compatibility is intentionally checked twice. The invalid state
+ * is a requested endpoint whose DNS-pinned identity differs from the route that
+ * passed the preliminary registry check. The source boundary is the
+ * operator-supplied `--endpoint-url`; DNS validation is asynchronous, so the
+ * synchronous preparation phase cannot safely pin it. Finalization therefore
+ * validates the pinned URL against a fresh registry snapshot before any route,
+ * config, or registry mutation. The DNS-change regression test in
+ * inference-set-gateway-route-containment.test.ts protects this boundary.
+ * Collapse these phases only when preparation can consume fully DNS-validated
+ * metadata without introducing an earlier mutation or endpoint probe.
+ */
+export type RegistryInferenceMetadata = Pick<
+ SandboxEntry,
+ "endpointUrl" | "credentialEnv" | "preferredInferenceApi" | "nimContainer"
+>;
+
+export interface ExplicitCustomRouteOptions {
+ endpointUrl?: string | null;
+ credentialEnv?: string | null;
+ inferenceApi?: string | null;
+}
+
+type RewriteConfigUrlsWithDnsPinning = (value: ConfigValue) => Promise;
+
+export interface PreparedInferenceSetRoute {
+ gatewayName: string;
+ preliminaryExplicitMetadata: RegistryInferenceMetadata | null;
+ preliminaryRegistryMetadata: RegistryInferenceMetadata;
+}
+
+const CUSTOM_COMPATIBLE_CREDENTIAL_ENV: Record = {
+ "compatible-endpoint": "COMPATIBLE_API_KEY",
+ "compatible-anthropic-endpoint": "COMPATIBLE_ANTHROPIC_API_KEY",
+};
+
+const INFERENCE_SET_APIS = new Set([
+ "openai-completions",
+ "anthropic-messages",
+ "openai-responses",
+]);
+
+// Message prefix for the SSRF/DNS-pinning rejection thrown below. Keep this
+// shared so finalization can append model-switch guidance only to this case.
+export const ENDPOINT_URL_NOT_ALLOWED_PREFIX = "endpoint-url is not allowed:";
+
+function isCustomCompatibleProvider(provider: string): boolean {
+ return provider === "compatible-endpoint" || provider === "compatible-anthropic-endpoint";
+}
+
+function hasExplicitCustomMetadata(options: ExplicitCustomRouteOptions): boolean {
+ return Boolean(options.endpointUrl || options.credentialEnv || options.inferenceApi);
+}
+
+// TRUST BOUNDARY: host.openshell.internal is the single sandbox-to-host bridge
+// hostname provisioned by OpenShell. It resolves to the Docker host gateway
+// only inside the sandbox network namespace. This exemption is intentionally
+// limited below to HTTP, an explicit unprivileged port, and the exact hostname;
+// do not extend it to HTTPS, wildcard subdomains, localhost, RFC1918 literals,
+// or other internal DNS names.
+const ALLOWED_PRIVATE_CUSTOM_ENDPOINT_HOSTS = new Set(["host.openshell.internal"]);
+
+function normalizeEndpointUrlShape(value: string): { url: URL; normalized: string } {
+ const url = new URL(value);
+ if ((url.protocol !== "http:" && url.protocol !== "https:") || url.username || url.password) {
+ throw new Error("unsupported URL shape");
+ }
+ url.search = "";
+ url.hash = "";
+ const pathname = url.pathname.replace(/\/+$/, "");
+ url.pathname = pathname || "/";
+ return {
+ url,
+ normalized: url.pathname === "/" ? url.origin : `${url.origin}${url.pathname}`,
+ };
+}
+
+function normalizeCustomEndpointUrlWithoutDns(value: string | null | undefined): string {
+ const raw = typeof value === "string" ? value.trim() : "";
+ if (!raw)
+ throw new InferenceSetError("endpoint-url is required for custom-compatible metadata.", 2);
+ try {
+ return normalizeEndpointUrlShape(raw).normalized;
+ } catch {
+ throw new InferenceSetError(
+ "endpoint-url must be a valid http(s) URL without embedded credentials.",
+ 2,
+ );
+ }
+}
+
+export async function normalizeCustomEndpointUrl(
+ value: string | null | undefined,
+ rewriteUrlWithDnsPinning: RewriteConfigUrlsWithDnsPinning,
+): Promise {
+ const normalized = normalizeCustomEndpointUrlWithoutDns(value);
+ const shaped = normalizeEndpointUrlShape(normalized);
+ const hostname = shaped.url.hostname.replace(/\.$/, "").toLowerCase();
+ const port = Number(shaped.url.port);
+ if (
+ ALLOWED_PRIVATE_CUSTOM_ENDPOINT_HOSTS.has(hostname) &&
+ shaped.url.protocol === "http:" &&
+ Number.isInteger(port) &&
+ port >= 1024
+ ) {
+ // This is the single sandbox-to-host bridge name that NemoClaw itself
+ // provisions for local inference. Its supported routes are explicit
+ // unprivileged HTTP listeners; do not generalize this exemption to HTTPS,
+ // default/privileged ports, localhost, RFC1918 addresses, or arbitrary
+ // internal DNS names.
+ return normalized;
+ }
+
+ try {
+ const validated = await rewriteUrlWithDnsPinning(normalized);
+ if (typeof validated !== "string") throw new Error("URL validator returned a non-string value");
+ return normalizeEndpointUrlShape(validated).normalized;
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ throw new InferenceSetError(`${ENDPOINT_URL_NOT_ALLOWED_PREFIX} ${message}`, 2);
+ }
+}
+
+function normalizeExplicitCredentialEnv(
+ provider: string,
+ value: string | null | undefined,
+): string {
+ const expected = CUSTOM_COMPATIBLE_CREDENTIAL_ENV[provider];
+ const normalized = typeof value === "string" && value.trim() ? value.trim() : expected;
+ if (normalized !== expected) {
+ throw new InferenceSetError(
+ `credential-env for '${provider}' must be '${expected}' so rebuild can safely reuse it.`,
+ 2,
+ );
+ }
+ return normalized;
+}
+
+function allowedExplicitInferenceApis(provider: string): string[] {
+ return provider === "compatible-endpoint"
+ ? ["openai-completions", "openai-responses"]
+ : Array.from(INFERENCE_SET_APIS);
+}
+
+function normalizeExplicitInferenceApi(provider: string, value: string | null | undefined): string {
+ const normalized = typeof value === "string" ? value.trim() : "";
+ if (!normalized) {
+ throw new InferenceSetError(
+ `inference-api is required for '${provider}' so the shared gateway route can be identified safely.`,
+ 2,
+ );
+ }
+ const allowed = allowedExplicitInferenceApis(provider);
+ if (!allowed.includes(normalized)) {
+ throw new InferenceSetError(
+ `inference-api for '${provider}' must be one of: ${allowed.join(", ")}.`,
+ 2,
+ );
+ }
+ return normalized;
+}
+
+function explicitCustomProviderMetadataWithoutDns(
+ provider: string,
+ options: ExplicitCustomRouteOptions,
+): RegistryInferenceMetadata | null {
+ if (!hasExplicitCustomMetadata(options)) return null;
+ if (!isCustomCompatibleProvider(provider)) {
+ throw new InferenceSetError(
+ "endpoint-url, credential-env, and inference-api are only supported for compatible-endpoint and compatible-anthropic-endpoint.",
+ 2,
+ );
+ }
+
+ // Source boundary: custom-compatible endpoint URLs are operator-supplied and
+ // not discoverable from the gateway provider registry with a sandbox-scoped
+ // trust guarantee. Treat these explicit flags as the durable metadata source
+ // for this switch, after URL and credential-env validation, instead of
+ // borrowing from an unrelated onboard session or global OpenShell provider.
+ return {
+ endpointUrl: normalizeCustomEndpointUrlWithoutDns(options.endpointUrl),
+ credentialEnv: normalizeExplicitCredentialEnv(provider, options.credentialEnv),
+ preferredInferenceApi: normalizeExplicitInferenceApi(provider, options.inferenceApi),
+ nimContainer: null,
+ };
+}
+
+function matchingSessionMetadata(options: {
+ session: Session | null;
+ sandboxName: string;
+ provider: string;
+ model: string;
+}): RegistryInferenceMetadata | null {
+ const { session, sandboxName, provider, model } = options;
+ if (
+ session?.sandboxName !== sandboxName ||
+ session.provider !== provider ||
+ session.model !== model ||
+ !session.endpointUrl
+ ) {
+ return null;
+ }
+ return {
+ endpointUrl: session.endpointUrl,
+ credentialEnv: session.credentialEnv ?? null,
+ preferredInferenceApi: session.preferredInferenceApi ?? null,
+ nimContainer: session.nimContainer ?? null,
+ };
+}
+
+function registryMetadataForProviderSwitch(options: {
+ entry: SandboxEntry;
+ provider: string;
+ model: string;
+ sandboxName: string;
+ session: Session | null;
+ explicitMetadata: RegistryInferenceMetadata | null;
+}): RegistryInferenceMetadata {
+ const { entry, provider, model, sandboxName, session, explicitMetadata } = options;
+ if (explicitMetadata) return explicitMetadata;
+ if (entry.provider === provider) {
+ return {
+ endpointUrl: entry.endpointUrl ?? null,
+ credentialEnv: entry.credentialEnv ?? null,
+ preferredInferenceApi: entry.preferredInferenceApi ?? null,
+ nimContainer: entry.nimContainer ?? null,
+ };
+ }
+ const sessionMetadata = matchingSessionMetadata({ session, sandboxName, provider, model });
+ if (sessionMetadata) return sessionMetadata;
+ if (isCustomCompatibleProvider(provider)) {
+ throw new InferenceSetError(
+ `Cannot switch sandbox '${sandboxName}' to '${provider}' without trusted durable endpoint metadata. ` +
+ `Re-run onboarding for this custom endpoint or restore a matching onboard session before using inference set.`,
+ 2,
+ );
+ }
+ return {
+ endpointUrl: null,
+ credentialEnv: null,
+ preferredInferenceApi: null,
+ nimContainer: null,
+ };
+}
+
+function assertGatewayRouteCompatibility(options: {
+ gatewayName: string;
+ sandboxName: string;
+ provider: string;
+ model: string;
+ metadata: RegistryInferenceMetadata;
+ sandboxes: SandboxEntry[];
+}): void {
+ const compatibility = checkGatewayRouteCompatibility({
+ gatewayName: options.gatewayName,
+ sandboxName: options.sandboxName,
+ route: { provider: options.provider, model: options.model, ...options.metadata },
+ sandboxes: options.sandboxes,
+ });
+ if (!compatibility.ok) {
+ throw new InferenceSetError(formatGatewayRouteConflict(compatibility), 2);
+ }
+}
+
+export function prepareInferenceSetRoute(options: {
+ entry: SandboxEntry;
+ sandboxName: string;
+ provider: string;
+ model: string;
+ customRoute: ExplicitCustomRouteOptions;
+ session: Session | null;
+ sandboxes: SandboxEntry[];
+}): PreparedInferenceSetRoute {
+ let gatewayName: string;
+ try {
+ gatewayName = resolveSandboxGatewayName(options.entry);
+ } catch (error) {
+ const detail = error instanceof Error ? error.message : String(error);
+ throw new InferenceSetError(
+ `Cannot resolve the OpenShell gateway for sandbox '${options.sandboxName}': ${detail}`,
+ 2,
+ );
+ }
+
+ const preliminaryExplicitMetadata = explicitCustomProviderMetadataWithoutDns(
+ options.provider,
+ options.customRoute,
+ );
+ const preliminaryRegistryMetadata = registryMetadataForProviderSwitch({
+ entry: options.entry,
+ provider: options.provider,
+ model: options.model,
+ sandboxName: options.sandboxName,
+ session: options.session,
+ explicitMetadata: preliminaryExplicitMetadata,
+ });
+ assertGatewayRouteCompatibility({
+ gatewayName,
+ sandboxName: options.sandboxName,
+ provider: options.provider,
+ model: options.model,
+ metadata: preliminaryRegistryMetadata,
+ sandboxes: options.sandboxes,
+ });
+ return { gatewayName, preliminaryExplicitMetadata, preliminaryRegistryMetadata };
+}
+
+export async function finalizeInferenceSetRoute(options: {
+ prepared: PreparedInferenceSetRoute;
+ sandboxName: string;
+ provider: string;
+ model: string;
+ canReuseRecordedRoute: boolean;
+ getSandboxes: () => SandboxEntry[];
+ rewriteUrlWithDnsPinning: RewriteConfigUrlsWithDnsPinning;
+}): Promise<{
+ registryMetadata: RegistryInferenceMetadata;
+ explicitPreferredInferenceApi: string | null;
+}> {
+ const { prepared } = options;
+ if (!prepared.preliminaryExplicitMetadata) {
+ return {
+ registryMetadata: prepared.preliminaryRegistryMetadata,
+ explicitPreferredInferenceApi: null,
+ };
+ }
+ let endpointUrl: string;
+ try {
+ // A supplied endpoint always goes through the host DNS-pinning SSRF guard,
+ // even when it equals the value already recorded for this sandbox. The
+ // registry value is not exclusive onboarding provenance because inference
+ // set persists it too, so equality must never authorize a guard bypass.
+ endpointUrl = await normalizeCustomEndpointUrl(
+ prepared.preliminaryExplicitMetadata.endpointUrl,
+ options.rewriteUrlWithDnsPinning,
+ );
+ } catch (error) {
+ // Only augment the SSRF/DNS-pinning rejection. Missing or malformed URLs
+ // keep their original diagnostics so the guidance cannot contradict them.
+ if (
+ options.canReuseRecordedRoute &&
+ error instanceof InferenceSetError &&
+ error.message.startsWith(ENDPOINT_URL_NOT_ALLOWED_PREFIX)
+ ) {
+ throw new InferenceSetError(
+ `${error.message} This sandbox is already configured for '${options.provider}'. ` +
+ `To switch only the model, omit --endpoint-url — inference set reuses the endpoint ` +
+ `onboarding already established (the gateway route is not changed by inference set). ` +
+ `To point the sandbox at a different endpoint, re-run onboarding with the new endpoint ` +
+ `(rebuild reuses the recorded endpoint and cannot change it).`,
+ error.exitCode,
+ );
+ }
+ throw error;
+ }
+ const registryMetadata: RegistryInferenceMetadata = {
+ ...prepared.preliminaryExplicitMetadata,
+ endpointUrl,
+ };
+ assertGatewayRouteCompatibility({
+ gatewayName: prepared.gatewayName,
+ sandboxName: options.sandboxName,
+ provider: options.provider,
+ model: options.model,
+ metadata: registryMetadata,
+ sandboxes: options.getSandboxes(),
+ });
+ return {
+ registryMetadata,
+ explicitPreferredInferenceApi: registryMetadata.preferredInferenceApi ?? null,
+ };
+}
diff --git a/src/lib/actions/inference-set.test-support.ts b/src/lib/actions/inference-set.test-support.ts
index 6536fa83bf3..6e19b36d265 100644
--- a/src/lib/actions/inference-set.test-support.ts
+++ b/src/lib/actions/inference-set.test-support.ts
@@ -86,6 +86,7 @@ export function createDeps(options: {
prepareRunOpenshell?: () => void;
rewriteConfigUrlsWithDnsPinning?: (value: ConfigValue) => Promise;
restartSandboxGateway?: InferenceSetDeps["restartSandboxGateway"];
+ withGatewayRouteMutationLock?: InferenceSetDeps["withGatewayRouteMutationLock"];
}): InferenceSetDeps & {
calls: {
captureOpenshell: ReturnType;
@@ -102,6 +103,7 @@ export function createDeps(options: {
prepareRunOpenshell: ReturnType;
rewriteConfigUrlsWithDnsPinning: ReturnType;
restartSandboxGateway: ReturnType;
+ withGatewayRouteMutationLock: ReturnType;
};
getSession: () => Session | null;
} {
@@ -149,6 +151,11 @@ export function createDeps(options: {
forwardRecovered: true,
})),
),
+ withGatewayRouteMutationLock: vi.fn(
+ options.withGatewayRouteMutationLock ??
+ (async (_gatewayName: string, operation: () => Promise | unknown) =>
+ await operation()),
+ ),
};
return {
getDefaultSandbox: () => defaultSandbox,
@@ -173,6 +180,8 @@ export function createDeps(options: {
resolveContextWindowForModel: calls.resolveContextWindowForModel,
isSandboxConfigMutable: () => options.shieldsMutable ?? true,
rewriteConfigUrlsWithDnsPinning: calls.rewriteConfigUrlsWithDnsPinning,
+ withGatewayRouteMutationLock:
+ calls.withGatewayRouteMutationLock as InferenceSetDeps["withGatewayRouteMutationLock"],
restartSandboxGateway: calls.restartSandboxGateway,
calls,
getSession: () => session,
diff --git a/src/lib/actions/inference-set.ts b/src/lib/actions/inference-set.ts
index f3f5899afe0..8d28b84b130 100644
--- a/src/lib/actions/inference-set.ts
+++ b/src/lib/actions/inference-set.ts
@@ -14,13 +14,19 @@ import {
type SandboxInferenceConfig,
} from "../inference/config";
import { resolveContextWindowForModel } from "../inference/context-window";
+import { withGatewayRouteMutationLock } from "../inference/gateway-route-mutation-lock";
import { type ValidationResult, validateLocalProvider } from "../inference/local";
import { inferenceSelectionRegistryFields } from "../inference/selection";
+import { resolveSandboxGatewayName } from "../onboard/gateway-binding";
import {
matchesGatewayProviderBinding,
parseGatewayProviderMetadata,
} from "../onboard/gateway-provider-metadata";
import { ensureLocalProviderReachable } from "../onboard/local-inference-topology";
+import {
+ assertNoOpenShellGatewayEndpointOverride,
+ OpenShellGatewayEndpointOverrideError,
+} from "../openshell-gateway-endpoint-guard";
import {
type AgentConfigTarget,
readSandboxConfig,
@@ -53,7 +59,16 @@ import {
applyOpenClawAnthropicReplyBudget,
readOpenClawPrimaryReplyBudget,
} from "./inference-set-reply-budget";
-
+import {
+ finalizeInferenceSetRoute,
+ prepareInferenceSetRoute,
+ type RegistryInferenceMetadata,
+} from "./inference-set-route-containment";
+
+export {
+ ENDPOINT_URL_NOT_ALLOWED_PREFIX,
+ normalizeCustomEndpointUrl,
+} from "./inference-set-route-containment";
export { InferenceSetError };
export interface InferenceSetOptions {
@@ -109,6 +124,7 @@ export interface InferenceSetDeps extends InferenceGatewayRestartDeps {
resolveContextWindowForModel: (provider: string, model: string) => number | null;
isSandboxConfigMutable: (sandboxName: string) => boolean;
rewriteConfigUrlsWithDnsPinning: (value: ConfigValue) => Promise;
+ withGatewayRouteMutationLock: typeof withGatewayRouteMutationLock;
}
const SUPPORTED_PROVIDER_NAMES = [
@@ -204,6 +220,7 @@ function defaultDeps(): InferenceSetDeps {
ensureLocalProviderReachable,
resolveContextWindowForModel,
rewriteConfigUrlsWithDnsPinning,
+ withGatewayRouteMutationLock,
restartSandboxGateway: defaultInferenceGatewayRestart,
isSandboxConfigMutable: (sandboxName) => {
const { isShieldsDown }: typeof import("../shields") = require("../shields");
@@ -231,6 +248,15 @@ function normalizeSandboxAgent(agentName: string | null | undefined): string {
return (trimmed || "openclaw").toLowerCase();
}
+function assertSandboxRouteReservationComplete(entry: SandboxEntry): void {
+ if (entry.pendingRouteReservation === true) {
+ throw new InferenceSetError(
+ `Sandbox '${entry.name}' is still being created by onboarding. Wait for onboarding to finish or remove the incomplete sandbox before changing inference.`,
+ 2,
+ );
+ }
+}
+
function resolveTargetSandbox(
sandboxName: string | null | undefined,
deps: Pick<
@@ -244,6 +270,7 @@ function resolveTargetSandbox(
if (!entry) {
throw new InferenceSetError(`Sandbox '${explicitName}' is not registered.`, 2);
}
+ assertSandboxRouteReservationComplete(entry);
return {
sandboxName: explicitName,
entry,
@@ -254,7 +281,10 @@ function resolveTargetSandbox(
if (normalizeSandboxAgent(deps.getRequestedAgent()) === "hermes") {
const hermesSandboxes = deps
.listSandboxes()
- .sandboxes.filter((entry) => normalizeSandboxAgent(entry.agent) === "hermes");
+ .sandboxes.filter(
+ (entry) =>
+ entry.pendingRouteReservation !== true && normalizeSandboxAgent(entry.agent) === "hermes",
+ );
if (hermesSandboxes.length === 1) {
const entry = hermesSandboxes[0];
return { sandboxName: entry.name, entry, agentName: "hermes" };
@@ -285,6 +315,7 @@ function resolveTargetSandbox(
if (!entry) {
throw new InferenceSetError(`Sandbox '${targetName}' is not registered.`, 2);
}
+ assertSandboxRouteReservationComplete(entry);
return { sandboxName: targetName, entry, agentName: normalizeSandboxAgent(entry.agent) };
}
@@ -434,6 +465,7 @@ function updateMatchingOnboardSession(
}
function openshellInferenceSetArgs(options: {
+ gatewayName: string;
provider: string;
model: string;
noVerify?: boolean;
@@ -442,7 +474,7 @@ function openshellInferenceSetArgs(options: {
"inference",
"set",
"-g",
- "nemoclaw",
+ options.gatewayName,
"--provider",
options.provider,
"--model",
@@ -462,29 +494,10 @@ function getPreferredInferenceApi(config: ConfigObject): string | null {
return typeof inferenceProvider.api === "string" ? inferenceProvider.api : null;
}
-type RegistryInferenceMetadata = Pick<
- SandboxEntry,
- "endpointUrl" | "credentialEnv" | "preferredInferenceApi" | "nimContainer"
->;
-
-const CUSTOM_COMPATIBLE_CREDENTIAL_ENV: Record = {
- "compatible-endpoint": "COMPATIBLE_API_KEY",
- "compatible-anthropic-endpoint": "COMPATIBLE_ANTHROPIC_API_KEY",
-};
-
-const INFERENCE_SET_APIS = new Set([
- "openai-completions",
- "anthropic-messages",
- "openai-responses",
-]);
-
-function isCustomCompatibleProvider(provider: string): boolean {
- return provider === "compatible-endpoint" || provider === "compatible-anthropic-endpoint";
-}
-
function assertHermesCompatibleAnthropicOpenAiProvider(
sandboxName: string,
agentName: string,
+ gatewayName: string,
provider: string,
endpointUrl: string | null,
deps: InferenceSetDeps,
@@ -497,7 +510,7 @@ function assertHermesCompatibleAnthropicOpenAiProvider(
return;
}
- const result = deps.captureOpenshell(["provider", "get", provider], {
+ const result = deps.captureOpenshell(["provider", "get", "-g", gatewayName, provider], {
ignoreError: true,
includeStreams: true,
maxBuffer: OPEN_SHELL_FAILURE_CAPTURE_MAX_BUFFER,
@@ -522,245 +535,10 @@ function assertHermesCompatibleAnthropicOpenAiProvider(
);
}
-function hasExplicitCustomMetadata(options: InferenceSetOptions): boolean {
- return Boolean(options.endpointUrl || options.credentialEnv || options.inferenceApi);
-}
-
-// TRUST BOUNDARY: host.openshell.internal is the single sandbox-to-host bridge
-// hostname provisioned by OpenShell. It resolves to the Docker host gateway
-// only inside the sandbox network namespace. This exemption is intentionally
-// limited below to HTTP, an explicit unprivileged port, and the exact hostname;
-// do not extend it to HTTPS, wildcard subdomains, localhost, RFC1918 literals,
-// or other internal DNS names.
-const ALLOWED_PRIVATE_CUSTOM_ENDPOINT_HOSTS = new Set(["host.openshell.internal"]);
-
-function normalizeEndpointUrlShape(value: string): { url: URL; normalized: string } {
- const url = new URL(value);
- if ((url.protocol !== "http:" && url.protocol !== "https:") || url.username || url.password) {
- throw new Error("unsupported URL shape");
- }
- url.search = "";
- url.hash = "";
- const pathname = url.pathname.replace(/\/+$/, "");
- url.pathname = pathname || "/";
- return {
- url,
- normalized: url.pathname === "/" ? url.origin : `${url.origin}${url.pathname}`,
- };
-}
-
-// Canonical equality of an operator-supplied endpoint URL against the trusted,
-
-// Message prefix for the SSRF/DNS-pinning rejection thrown below. Kept as a
-// shared constant so the catch in explicitCustomProviderMetadata can recognise
-// exactly this case (and only this case) when it appends switch-model guidance.
-export const ENDPOINT_URL_NOT_ALLOWED_PREFIX = "endpoint-url is not allowed:";
-
-export async function normalizeCustomEndpointUrl(
- value: string | null | undefined,
- rewriteUrlWithDnsPinning: InferenceSetDeps["rewriteConfigUrlsWithDnsPinning"],
-): Promise {
- const raw = typeof value === "string" ? value.trim() : "";
- if (!raw)
- throw new InferenceSetError("endpoint-url is required for custom-compatible metadata.", 2);
- let shaped: { url: URL; normalized: string };
- try {
- shaped = normalizeEndpointUrlShape(raw);
- } catch {
- throw new InferenceSetError(
- "endpoint-url must be a valid http(s) URL without embedded credentials.",
- 2,
- );
- }
-
- const hostname = shaped.url.hostname.replace(/\.$/, "").toLowerCase();
- const port = Number(shaped.url.port);
- if (
- ALLOWED_PRIVATE_CUSTOM_ENDPOINT_HOSTS.has(hostname) &&
- shaped.url.protocol === "http:" &&
- Number.isInteger(port) &&
- port >= 1024
- ) {
- // This is the single sandbox-to-host bridge name that NemoClaw itself
- // provisions for local inference. Its supported routes are explicit
- // unprivileged HTTP listeners; do not generalize this exemption to HTTPS,
- // default/privileged ports, localhost, RFC1918 addresses, or arbitrary
- // internal DNS names.
- return shaped.normalized;
- }
-
- try {
- const validated = await rewriteUrlWithDnsPinning(shaped.normalized);
- if (typeof validated !== "string") throw new Error("URL validator returned a non-string value");
- return normalizeEndpointUrlShape(validated).normalized;
- } catch (error) {
- const message = error instanceof Error ? error.message : String(error);
- throw new InferenceSetError(`${ENDPOINT_URL_NOT_ALLOWED_PREFIX} ${message}`, 2);
- }
-}
-
-function normalizeExplicitCredentialEnv(
- provider: string,
- value: string | null | undefined,
-): string {
- const expected = CUSTOM_COMPATIBLE_CREDENTIAL_ENV[provider];
- const normalized = typeof value === "string" && value.trim() ? value.trim() : expected;
- if (normalized !== expected) {
- throw new InferenceSetError(
- `credential-env for '${provider}' must be '${expected}' so rebuild can safely reuse it.`,
- 2,
- );
- }
- return normalized;
-}
-
-function allowedExplicitInferenceApis(provider: string): string[] {
- return provider === "compatible-endpoint"
- ? ["openai-completions", "openai-responses"]
- : Array.from(INFERENCE_SET_APIS);
-}
-
-function normalizeExplicitInferenceApi(
- provider: string,
- value: string | null | undefined,
-): string | null {
- const normalized = typeof value === "string" ? value.trim() : "";
- if (!normalized) return null;
- const allowed = allowedExplicitInferenceApis(provider);
- if (!allowed.includes(normalized)) {
- throw new InferenceSetError(
- `inference-api for '${provider}' must be one of: ${allowed.join(", ")}.`,
- 2,
- );
- }
- return normalized;
-}
-
-async function explicitCustomProviderMetadata(
- provider: string,
- options: InferenceSetOptions,
- rewriteUrlWithDnsPinning: InferenceSetDeps["rewriteConfigUrlsWithDnsPinning"],
- sandboxAlreadyOnProvider: boolean,
-): Promise {
- if (!hasExplicitCustomMetadata(options)) return null;
- if (!isCustomCompatibleProvider(provider)) {
- throw new InferenceSetError(
- "endpoint-url, credential-env, and inference-api are only supported for compatible-endpoint and compatible-anthropic-endpoint.",
- 2,
- );
- }
-
- // Source boundary: custom-compatible endpoint URLs are operator-supplied and
- // not discoverable from the gateway provider registry with a sandbox-scoped
- // trust guarantee. Treat these explicit flags as the durable metadata source
- // for this switch, after URL and credential-env validation, instead of
- // borrowing from an unrelated onboard session or global OpenShell provider.
- //
- // #6321 facet 2: a supplied --endpoint-url ALWAYS goes through the host
- // DNS-pinning SSRF guard, even when it equals the endpoint onboarding recorded
- // for this sandbox. We deliberately do NOT trust the recorded registry value
- // to skip the guard: `endpointUrl` is not exclusively onboarding-provenanced —
- // this same `inference set` action persists it (see registryFields below) — so
- // a string-equality bypass would let a value this command wrote earlier
- // authorize a later switch to an internal-resolving endpoint. To change only
- // the model on the established route, omit --endpoint-url (the guard's
- // rejection is turned into that guidance below). See PR #6378 review.
- let endpointUrl: string;
- try {
- endpointUrl = await normalizeCustomEndpointUrl(options.endpointUrl, rewriteUrlWithDnsPinning);
- } catch (error) {
- // The supplied endpoint is NOT the one onboarding established for this
- // sandbox (or none is recorded). Keep the SSRF guard authoritative; when
- // the sandbox is already on this provider, turn the dead-end into guidance:
- // omit --endpoint-url to reuse the established endpoint for a model-only
- // switch. Only augment the SSRF/DNS-pinning rejection (the `endpoint-url is
- // not allowed: ...` case); a missing URL ("endpoint-url is required ...") or
- // a malformed one would read as contradictory advice, so leave those alone.
- if (
- sandboxAlreadyOnProvider &&
- error instanceof InferenceSetError &&
- error.message.startsWith(ENDPOINT_URL_NOT_ALLOWED_PREFIX)
- ) {
- throw new InferenceSetError(
- `${error.message} This sandbox is already configured for '${provider}'. ` +
- `To switch only the model, omit --endpoint-url — inference set reuses the endpoint ` +
- `onboarding already established (the gateway route is not changed by inference set). ` +
- `To point the sandbox at a different endpoint, re-run onboarding with the new endpoint ` +
- `(rebuild reuses the recorded endpoint and cannot change it).`,
- error.exitCode,
- );
- }
- throw error;
- }
- return {
- endpointUrl,
- credentialEnv: normalizeExplicitCredentialEnv(provider, options.credentialEnv),
- preferredInferenceApi: normalizeExplicitInferenceApi(provider, options.inferenceApi),
- nimContainer: null,
- };
-}
-
-function matchingSessionMetadata(options: {
- session: onboardSession.Session | null;
- sandboxName: string;
- provider: string;
- model: string;
-}): RegistryInferenceMetadata | null {
- const { session, sandboxName, provider, model } = options;
- if (
- session?.sandboxName !== sandboxName ||
- session.provider !== provider ||
- session.model !== model ||
- !session.endpointUrl
- ) {
- return null;
- }
- return {
- endpointUrl: session.endpointUrl,
- credentialEnv: session.credentialEnv ?? null,
- preferredInferenceApi: session.preferredInferenceApi ?? null,
- nimContainer: session.nimContainer ?? null,
- };
-}
-
-function registryMetadataForProviderSwitch(options: {
- entry: SandboxEntry;
- provider: string;
- model: string;
- sandboxName: string;
- session: onboardSession.Session | null;
- explicitMetadata: RegistryInferenceMetadata | null;
-}): RegistryInferenceMetadata {
- const { entry, provider, model, sandboxName, session, explicitMetadata } = options;
- if (explicitMetadata) return explicitMetadata;
- if (entry.provider === provider) {
- return {
- endpointUrl: entry.endpointUrl ?? null,
- credentialEnv: entry.credentialEnv ?? null,
- preferredInferenceApi: entry.preferredInferenceApi ?? null,
- nimContainer: entry.nimContainer ?? null,
- };
- }
- const sessionMetadata = matchingSessionMetadata({ session, sandboxName, provider, model });
- if (sessionMetadata) return sessionMetadata;
- if (isCustomCompatibleProvider(provider)) {
- throw new InferenceSetError(
- `Cannot switch sandbox '${sandboxName}' to '${provider}' without trusted durable endpoint metadata. ` +
- `Re-run onboarding for this custom endpoint or restore a matching onboard session before using inference set.`,
- 2,
- );
- }
- return {
- endpointUrl: null,
- credentialEnv: null,
- preferredInferenceApi: null,
- nimContainer: null,
- };
-}
-
async function runInferenceSetWithoutHostLock(
options: InferenceSetOptions,
- deps: InferenceSetDeps = defaultDeps(),
+ deps: InferenceSetDeps,
+ expectedGatewayName: string,
): Promise> {
// #6321: accept the installer-style provider name onboard uses (e.g.
// `anthropicCompatible`) as well as the OpenShell provider name, by
@@ -793,6 +571,77 @@ async function runInferenceSetWithoutHostLock(
2,
);
}
+ const session = deps.loadSession();
+ const explicitInferenceApi =
+ typeof options.inferenceApi === "string" && options.inferenceApi.trim()
+ ? options.inferenceApi.trim()
+ : null;
+ const explicitOrRecordedInferenceApi =
+ explicitInferenceApi ??
+ (entry.provider === provider ? (entry.preferredInferenceApi ?? null) : null);
+ if (
+ agentName === "hermes" &&
+ provider === "compatible-anthropic-endpoint" &&
+ explicitInferenceApi !== null &&
+ explicitInferenceApi !== "openai-completions"
+ ) {
+ throw new InferenceSetError(
+ "Hermes custom Anthropic endpoints require the managed openai-completions frontend. " +
+ "Set --inference-api openai-completions or omit --inference-api so NemoClaw selects it.",
+ 2,
+ );
+ }
+ const hasExplicitCustomRoute = Boolean(
+ options.endpointUrl || options.credentialEnv || options.inferenceApi,
+ );
+ const customRoute = hasExplicitCustomRoute
+ ? {
+ ...options,
+ // A same-provider request may omit --inference-api because the durable
+ // registry row already identifies the route family. New provider
+ // routes still require the operator to supply a complete identity.
+ inferenceApi: resolveAgentInferenceApi(agentName, provider, explicitOrRecordedInferenceApi),
+ }
+ : options;
+ const routeEntry = {
+ ...entry,
+ preferredInferenceApi: resolveAgentInferenceApi(
+ agentName,
+ provider,
+ entry.preferredInferenceApi ?? null,
+ ),
+ };
+ const routeSession = session
+ ? {
+ ...session,
+ preferredInferenceApi: resolveAgentInferenceApi(
+ agentName,
+ provider,
+ session.preferredInferenceApi ?? null,
+ ),
+ }
+ : null;
+ // Registered peers are compared exactly as recorded. In particular, a
+ // stopped legacy Hermes row that still records the Anthropic frontend will
+ // depend on that route when restarted and must not be normalized away.
+ const routeSandboxes = deps.listSandboxes().sandboxes;
+ const preparedRoute = prepareInferenceSetRoute({
+ entry: routeEntry,
+ sandboxName,
+ provider,
+ model,
+ customRoute,
+ session: routeSession,
+ sandboxes: routeSandboxes,
+ });
+ if (preparedRoute.gatewayName !== expectedGatewayName) {
+ throw new InferenceSetError(
+ `Sandbox '${sandboxName}' moved from OpenShell gateway '${expectedGatewayName}' to ` +
+ `'${preparedRoute.gatewayName}' while waiting for the route mutation lock. Retry the command.`,
+ 2,
+ );
+ }
+
const target = deps.resolveAgentConfig(sandboxName);
const targetAgent = normalizeSandboxAgent(target.agentName);
if (targetAgent !== agentName) {
@@ -807,37 +656,19 @@ async function runInferenceSetWithoutHostLock(
2,
);
}
- const session = deps.loadSession();
- const sandboxAlreadyOnProvider = entry.provider === provider;
- const explicitMetadata = await explicitCustomProviderMetadata(
- provider,
- options,
- deps.rewriteConfigUrlsWithDnsPinning,
- // #6321 facet 2: when the sandbox is already on this provider, an
- // SSRF-blocked --endpoint-url gets an actionable "omit it to switch model"
- // hint instead of a dead-end (see explicitCustomProviderMetadata).
- sandboxAlreadyOnProvider,
- );
- const explicitPreferredInferenceApi = explicitMetadata?.preferredInferenceApi ?? null;
- if (
- agentName === "hermes" &&
- provider === "compatible-anthropic-endpoint" &&
- explicitPreferredInferenceApi !== null &&
- explicitPreferredInferenceApi !== "openai-completions"
- ) {
- throw new InferenceSetError(
- "Hermes custom Anthropic endpoints require the managed openai-completions frontend. " +
- "Set --inference-api openai-completions or omit --inference-api so NemoClaw selects it.",
- 2,
- );
- }
- const registryMetadata = registryMetadataForProviderSwitch({
- entry,
+ const { registryMetadata, explicitPreferredInferenceApi } = await finalizeInferenceSetRoute({
+ prepared: preparedRoute,
+ sandboxName,
provider,
model,
- sandboxName,
- session,
- explicitMetadata,
+ canReuseRecordedRoute:
+ entry.provider === provider &&
+ typeof entry.endpointUrl === "string" &&
+ entry.endpointUrl.trim().length > 0 &&
+ typeof entry.preferredInferenceApi === "string" &&
+ entry.preferredInferenceApi.trim().length > 0,
+ getSandboxes: () => deps.listSandboxes().sandboxes,
+ rewriteUrlWithDnsPinning: deps.rewriteConfigUrlsWithDnsPinning,
});
// Local providers (ollama-local, vllm-local) route through the sandbox-facing
@@ -875,6 +706,7 @@ async function runInferenceSetWithoutHostLock(
assertHermesCompatibleAnthropicOpenAiProvider(
sandboxName,
agentName,
+ preparedRoute.gatewayName,
provider,
registryMetadata.endpointUrl ?? null,
deps,
@@ -882,7 +714,12 @@ async function runInferenceSetWithoutHostLock(
deps.log(` Setting OpenShell inference route: ${provider} / ${model}`);
const setResult = deps.captureOpenshell(
- openshellInferenceSetArgs({ provider, model, noVerify: effectiveNoVerify }),
+ openshellInferenceSetArgs({
+ gatewayName: preparedRoute.gatewayName,
+ provider,
+ model,
+ noVerify: effectiveNoVerify,
+ }),
{
ignoreError: true,
includeStreams: true,
@@ -1037,6 +874,14 @@ export async function runInferenceSet(
options: InferenceSetOptions,
deps: InferenceSetDeps = defaultDeps(),
): Promise {
+ try {
+ assertNoOpenShellGatewayEndpointOverride();
+ } catch (error) {
+ if (error instanceof OpenShellGatewayEndpointOverrideError) {
+ throw new InferenceSetError(error.message, 2);
+ }
+ throw error;
+ }
// Resolve once before acquiring so a default-sandbox change cannot make the
// protected callback mutate a different sandbox from the one whose lock we
// hold. Prime the default OpenShell runner before acquiring too: its legacy
@@ -1045,10 +890,16 @@ export async function runInferenceSet(
const selected = resolveTargetSandbox(options.sandboxName, deps);
deps.prepareRunOpenshell();
return withSandboxMutationLock(selected.sandboxName, async () => {
- const mutation = await withTimerBoundShieldsMutationLockAsync(
- selected.sandboxName,
- "inference set",
- () => runInferenceSetWithoutHostLock({ ...options, sandboxName: selected.sandboxName }, deps),
+ const lockedSelection = resolveTargetSandbox(selected.sandboxName, deps);
+ const gatewayName = resolveSandboxGatewayName(lockedSelection.entry);
+ const mutation = await deps.withGatewayRouteMutationLock(gatewayName, () =>
+ withTimerBoundShieldsMutationLockAsync(selected.sandboxName, "inference set", () =>
+ runInferenceSetWithoutHostLock(
+ { ...options, sandboxName: selected.sandboxName },
+ deps,
+ gatewayName,
+ ),
+ ),
);
// Release the config transition lock before the managed restart reacquires
// it, but retain the outer sandbox lifecycle lock so another process cannot
diff --git a/src/lib/actions/sandbox/connect-inference-gateway.ts b/src/lib/actions/sandbox/connect-inference-gateway.ts
new file mode 100644
index 00000000000..ad5c259faa6
--- /dev/null
+++ b/src/lib/actions/sandbox/connect-inference-gateway.ts
@@ -0,0 +1,46 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { assertGatewayRouteCompatibility } from "../../inference/gateway-route-compatibility";
+import { LOCAL_INFERENCE_TIMEOUT_SECS } from "../../onboard/env";
+import type { SandboxEntry } from "../../state/registry";
+import * as registry from "../../state/registry";
+
+export function buildGatewayInferenceGetArgs(gatewayName: string): string[] {
+ return ["inference", "get", "-g", gatewayName];
+}
+
+export function buildGatewayInferenceSetArgs(
+ gatewayName: string,
+ provider: string,
+ model: string,
+): string[] {
+ const args = [
+ "inference",
+ "set",
+ "-g",
+ gatewayName,
+ "--provider",
+ provider,
+ "--model",
+ model,
+ "--no-verify",
+ ];
+ if (["compatible-endpoint", "ollama-local", "vllm-local"].includes(provider)) {
+ args.push("--timeout", String(LOCAL_INFERENCE_TIMEOUT_SECS));
+ }
+ return args;
+}
+
+export function assertSandboxGatewayRouteCompatible(
+ sandboxName: string,
+ sb: SandboxEntry,
+ gatewayName: string,
+): void {
+ assertGatewayRouteCompatibility({
+ gatewayName,
+ sandboxName,
+ route: sb,
+ sandboxes: registry.listSandboxes().sandboxes,
+ });
+}
diff --git a/src/lib/actions/sandbox/connect-route-containment.test.ts b/src/lib/actions/sandbox/connect-route-containment.test.ts
new file mode 100644
index 00000000000..a95b9545e13
--- /dev/null
+++ b/src/lib/actions/sandbox/connect-route-containment.test.ts
@@ -0,0 +1,401 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { afterEach, beforeEach, describe, expect, it, type MockInstance, vi } from "vitest";
+
+import {
+ connectModulePath,
+ createConnectHarness,
+ requireDist,
+} from "../../../../test/support/connect-flow-test-harness";
+import type { SandboxEntry } from "../../state/registry";
+import {
+ repairSandboxInferenceRouteWithDeps,
+ type SandboxInferenceRouteRepairDeps,
+} from "./connect";
+
+describe("connect route containment", () => {
+ let exitSpy: MockInstance;
+ const originalStdoutIsTty = process.stdout.isTTY;
+
+ beforeEach(() => {
+ process.env.NEMOCLAW_TEST_NO_SLEEP = "1";
+ Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: true });
+ exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number | string | null) => {
+ throw new Error(`process.exit(${code ?? 0})`);
+ }) as never);
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ vi.unstubAllEnvs();
+ Object.defineProperty(process.stdout, "isTTY", {
+ configurable: true,
+ value: originalStdoutIsTty,
+ });
+ delete process.env.NEMOCLAW_TEST_NO_SLEEP;
+ delete require.cache[requireDist.resolve(connectModulePath)];
+ });
+
+ it("stops before the initial endpoint probe or repair mutation when routes conflict (#6315)", () => {
+ const conflict = new Error("shared gateway route conflict");
+ const assertRouteCompatible = vi.fn(() => {
+ throw conflict;
+ });
+ const probe = vi.fn(() => ({ healthy: false, broken: true, detail: "BROKEN 503" }));
+ const applyVmDnsMonkeypatch = vi.fn(() => ({ ok: false }));
+ const reapplyVmInferenceRoute = vi.fn(() => null);
+ const repairLegacyDnsProxy = vi.fn(() => ({ exitCode: 0 }));
+ const deps: SandboxInferenceRouteRepairDeps = {
+ probe,
+ shouldApplyVmDnsMonkeypatch: vi.fn(() => false),
+ applyVmDnsMonkeypatch,
+ reapplyVmInferenceRoute,
+ repairLegacyDnsProxy,
+ assertRouteCompatible,
+ };
+ const sandbox: SandboxEntry = {
+ name: "demo",
+ model: "nvidia/nemotron-3-super-120b-a12b",
+ provider: "nvidia-prod",
+ openshellDriver: "vm",
+ gpuEnabled: false,
+ policies: [],
+ };
+
+ expect(() => repairSandboxInferenceRouteWithDeps("vm-box", sandbox, {}, deps)).toThrow(
+ conflict,
+ );
+
+ expect(assertRouteCompatible).toHaveBeenCalledWith("vm-box", sandbox);
+ expect(probe).not.toHaveBeenCalled();
+ expect(applyVmDnsMonkeypatch).not.toHaveBeenCalled();
+ expect(reapplyVmInferenceRoute).not.toHaveBeenCalled();
+ expect(repairLegacyDnsProxy).not.toHaveBeenCalled();
+ });
+
+ it("exits before connect-time route writes when another sandbox conflicts (#6315)", async () => {
+ const alpha = {
+ name: "alpha",
+ agent: "openclaw",
+ gatewayName: "nemoclaw",
+ gatewayPort: 8080,
+ provider: "anthropic-prod",
+ model: "claude-sonnet-4-20250514",
+ } as const;
+ const harness = createConnectHarness({
+ inferenceGetOutput:
+ "Gateway inference:\n Provider: nvidia-prod\n Model: nvidia/nemotron-3-super-120b-a12b\n",
+ registryEntry: alpha,
+ registryEntries: [
+ alpha,
+ {
+ name: "stopped-peer",
+ agent: "openclaw",
+ gatewayName: "nemoclaw",
+ gatewayPort: 8080,
+ provider: "nvidia-prod",
+ model: "nvidia/nemotron-3-super-120b-a12b",
+ },
+ ],
+ });
+
+ await expect(harness.connectSandbox("alpha", { probeOnly: true })).rejects.toThrow(
+ "process.exit(1)",
+ );
+
+ expect(harness.ensureLiveSandboxSpy).not.toHaveBeenCalled();
+ expect(harness.checkAndRecoverSpy).not.toHaveBeenCalled();
+ expect(harness.captureOpenshellSpy).not.toHaveBeenCalled();
+ expect(harness.runOpenshellSpy).not.toHaveBeenCalled();
+ expect(harness.applyVmDnsMonkeypatchSpy).not.toHaveBeenCalled();
+ expect(harness.runSetupDnsProxySpy).not.toHaveBeenCalled();
+ expect(harness.spawnSyncSpy).not.toHaveBeenCalledWith(
+ "openshell",
+ ["sandbox", "connect", "alpha"],
+ expect.any(Object),
+ );
+ const errorOutput = harness.errorSpy.mock.calls.map((call) => String(call[0] ?? "")).join("\n");
+ expect(errorOutput).toContain("stopped-peer");
+ expect(errorOutput).toContain("NEMOCLAW_GATEWAY_PORT");
+ expect(exitSpy).toHaveBeenCalledWith(1);
+ });
+
+ it("rechecks peers after waiting for the shared gateway route lock", async () => {
+ let releaseLock!: () => void;
+ const released = new Promise((resolve) => {
+ releaseLock = resolve;
+ });
+ let reportLockEntered!: () => void;
+ const lockEntered = new Promise((resolve) => {
+ reportLockEntered = resolve;
+ });
+ const alpha = {
+ name: "alpha",
+ agent: "openclaw",
+ gatewayName: "nemoclaw",
+ gatewayPort: 8080,
+ provider: "nvidia-prod",
+ model: "nvidia/model-a",
+ } as const;
+ const harness = createConnectHarness({
+ registryEntry: alpha,
+ registryEntries: [alpha, { ...alpha, name: "peer" }],
+ withGatewayRouteMutationLock: async (_gatewayName, operation) => {
+ reportLockEntered();
+ await released;
+ return await operation();
+ },
+ });
+
+ const connect = harness.connectSandbox("alpha", { probeOnly: true });
+ await lockEntered;
+ const peer = harness.registryEntries.find((candidate) => candidate.name === "peer");
+ expect(peer).toBeDefined();
+ Object.assign(peer!, { provider: "anthropic-prod", model: "claude-new" });
+ releaseLock();
+
+ await expect(connect).rejects.toThrow("process.exit(1)");
+ expect(harness.withGatewayRouteMutationLockSpy).toHaveBeenCalledWith(
+ "nemoclaw",
+ expect.any(Function),
+ );
+ expect(harness.captureOpenshellSpy).toHaveBeenCalledOnce();
+ expect(harness.captureOpenshellSpy).toHaveBeenCalledWith(
+ ["inference", "get", "-g", "nemoclaw"],
+ { ignoreError: true, timeout: 15_000 },
+ );
+ expect(harness.runOpenshellSpy).not.toHaveBeenCalled();
+ expect(exitSpy).toHaveBeenCalledWith(1);
+ });
+
+ it("aborts before route reads or repairs when the target changes gateways while waiting", async () => {
+ let releaseLock!: () => void;
+ const released = new Promise((resolve) => {
+ releaseLock = resolve;
+ });
+ let reportLockEntered!: () => void;
+ const lockEntered = new Promise((resolve) => {
+ reportLockEntered = resolve;
+ });
+ const alpha = {
+ name: "alpha",
+ agent: "openclaw",
+ gatewayName: "nemoclaw",
+ gatewayPort: 8080,
+ provider: "nvidia-prod",
+ model: "nvidia/model-a",
+ } as const;
+ const harness = createConnectHarness({
+ registryEntry: alpha,
+ registryEntries: [alpha],
+ withGatewayRouteMutationLock: async (_gatewayName, operation) => {
+ reportLockEntered();
+ await released;
+ return await operation();
+ },
+ });
+
+ const connect = harness.connectSandbox("alpha", { probeOnly: true });
+ await lockEntered;
+ Object.assign(harness.registryEntries[0], {
+ gatewayName: "nemoclaw-9090",
+ gatewayPort: 9090,
+ });
+ releaseLock();
+
+ await expect(connect).rejects.toThrow("process.exit(1)");
+ expect(harness.captureOpenshellSpy).not.toHaveBeenCalled();
+ expect(harness.runOpenshellSpy).not.toHaveBeenCalled();
+ expect(harness.applyVmDnsMonkeypatchSpy).not.toHaveBeenCalled();
+ expect(harness.runSetupDnsProxySpy).not.toHaveBeenCalled();
+ expect(harness.errorSpy).toHaveBeenCalledWith(
+ expect.stringContaining("changed OpenShell gateways while waiting"),
+ );
+ expect(exitSpy).toHaveBeenCalledWith(1);
+ });
+
+ it("exits before managed route reads or repairs when an endpoint override is ambient", async () => {
+ vi.stubEnv("OPENSHELL_GATEWAY_ENDPOINT", "https://other.example.test");
+ const harness = createConnectHarness({
+ registryEntry: {
+ name: "alpha",
+ agent: "openclaw",
+ gatewayName: "nemoclaw",
+ gatewayPort: 8080,
+ provider: "nvidia-prod",
+ model: "nvidia/nemotron-3-super-120b-a12b",
+ },
+ });
+
+ await expect(harness.connectSandbox("alpha")).rejects.toThrow("process.exit(1)");
+
+ expect(harness.preflightVllmSpy).not.toHaveBeenCalled();
+ expect(harness.ensureLiveSandboxSpy).not.toHaveBeenCalled();
+ expect(harness.checkAndRecoverSpy).not.toHaveBeenCalled();
+ expect(harness.ensureOllamaAuthProxySpy).not.toHaveBeenCalled();
+ expect(harness.captureOpenshellSpy).not.toHaveBeenCalled();
+ expect(harness.runOpenshellSpy).not.toHaveBeenCalled();
+ expect(harness.applyVmDnsMonkeypatchSpy).not.toHaveBeenCalled();
+ expect(harness.runSetupDnsProxySpy).not.toHaveBeenCalled();
+ expect(harness.spawnSyncSpy).not.toHaveBeenCalledWith(
+ "openshell",
+ expect.any(Array),
+ expect.any(Object),
+ );
+ const errorOutput = harness.errorSpy.mock.calls.map((call) => String(call[0] ?? "")).join("\n");
+ expect(errorOutput).toContain("Unset OPENSHELL_GATEWAY_ENDPOINT");
+ expect(exitSpy).toHaveBeenCalledWith(1);
+ });
+
+ it("rejects a pending onboarding reservation before liveness or route work", async () => {
+ const harness = createConnectHarness({
+ registryEntry: {
+ name: "alpha",
+ pendingRouteReservation: true,
+ gatewayName: "nemoclaw",
+ provider: "nvidia-prod",
+ model: "nvidia/model-a",
+ },
+ });
+
+ await expect(harness.connectSandbox("alpha")).rejects.toThrow("process.exit(1)");
+
+ const output = harness.errorSpy.mock.calls.map((call) => String(call[0] ?? "")).join("\n");
+ expect(output).toContain("still being created by onboarding");
+ expect(harness.ensureLiveSandboxSpy).not.toHaveBeenCalled();
+ expect(harness.captureOpenshellSpy).not.toHaveBeenCalled();
+ expect(harness.runOpenshellSpy).not.toHaveBeenCalled();
+ });
+
+ it("exits before repairing a lone incomplete legacy custom route (#6315)", async () => {
+ const harness = createConnectHarness({
+ inferenceGetOutput:
+ "Gateway inference:\n Provider: nvidia-prod\n Model: nvidia/nemotron-3-super-120b-a12b\n",
+ registryEntry: {
+ name: "alpha",
+ agent: "openclaw",
+ gatewayName: "nemoclaw",
+ gatewayPort: 8080,
+ provider: "compatible-endpoint",
+ model: "custom/model",
+ endpointUrl: null,
+ preferredInferenceApi: null,
+ },
+ });
+
+ await expect(harness.connectSandbox("alpha", { probeOnly: true })).rejects.toThrow(
+ "process.exit(1)",
+ );
+
+ expect(harness.runOpenshellSpy).not.toHaveBeenCalled();
+ expect(harness.applyVmDnsMonkeypatchSpy).not.toHaveBeenCalled();
+ expect(harness.runSetupDnsProxySpy).not.toHaveBeenCalled();
+ expect(harness.spawnSyncSpy).not.toHaveBeenCalledWith(
+ "openshell",
+ ["sandbox", "connect", "alpha"],
+ expect.any(Object),
+ );
+ const errorOutput = harness.errorSpy.mock.calls.map((call) => String(call[0] ?? "")).join("\n");
+ expect(errorOutput).toContain(
+ "requested custom route lacks durable endpoint or API-family metadata",
+ );
+ expect(exitSpy).toHaveBeenCalledWith(1);
+ });
+
+ it("exits before an endpoint probe when an aligned route conflicts with a stopped sandbox (#6315)", async () => {
+ const alpha = {
+ name: "alpha",
+ agent: "openclaw",
+ gatewayName: "nemoclaw",
+ gatewayPort: 8080,
+ provider: "nvidia-prod",
+ model: "nvidia/nemotron-3-super-120b-a12b",
+ } as const;
+ const harness = createConnectHarness({
+ inferenceGetOutput:
+ "Gateway inference:\n Provider: nvidia-prod\n Model: nvidia/nemotron-3-super-120b-a12b\n",
+ registryEntry: alpha,
+ registryEntries: [
+ alpha,
+ {
+ name: "stopped-peer",
+ agent: "openclaw",
+ gatewayName: "nemoclaw",
+ gatewayPort: 8080,
+ provider: "anthropic-prod",
+ model: "claude-sonnet-4-20250514",
+ },
+ ],
+ });
+
+ await expect(harness.connectSandbox("alpha", { probeOnly: true })).rejects.toThrow(
+ "process.exit(1)",
+ );
+
+ const routeProbeCalls = harness.captureOpenshellSpy.mock.calls.filter((call) =>
+ JSON.stringify(call[0]).includes("inference.local/v1/models"),
+ );
+ expect(routeProbeCalls).toHaveLength(0);
+ expect(harness.runOpenshellSpy).not.toHaveBeenCalled();
+ expect(exitSpy).toHaveBeenCalledWith(1);
+ });
+
+ it("scopes every inference read and repair write to the target non-default gateway", async () => {
+ const alpha = {
+ name: "alpha",
+ agent: "openclaw",
+ gatewayName: "nemoclaw-9090",
+ gatewayPort: 9090,
+ openshellDriver: "docker",
+ provider: "anthropic-prod",
+ model: "claude-sonnet-4-20250514",
+ } as const;
+ const harness = createConnectHarness({
+ inferenceGetOutput:
+ "Gateway inference:\n Provider: nvidia-prod\n Model: nvidia/nemotron-3-super-120b-a12b\n",
+ inferenceProbeResponses: ["BROKEN 503", "BROKEN 503", "OK 200"],
+ registryEntry: alpha,
+ registryEntries: [
+ alpha,
+ {
+ name: "default-gateway-peer",
+ agent: "openclaw",
+ gatewayName: "nemoclaw",
+ gatewayPort: 8080,
+ provider: "nvidia-prod",
+ model: "nvidia/nemotron-3-super-120b-a12b",
+ },
+ ],
+ });
+
+ await expect(harness.connectSandbox("alpha", { probeOnly: true })).resolves.toBeUndefined();
+
+ const inferenceReads = harness.captureOpenshellSpy.mock.calls
+ .map((call) => call[0])
+ .filter((args) => Array.isArray(args) && args[0] === "inference" && args[1] === "get");
+ expect(inferenceReads).toEqual([["inference", "get", "-g", "nemoclaw-9090"]]);
+
+ const inferenceWrites = harness.runOpenshellSpy.mock.calls
+ .map((call) => call[0])
+ .filter((args) => Array.isArray(args) && args[0] === "inference" && args[1] === "set");
+ expect(inferenceWrites).toHaveLength(3);
+ for (const args of inferenceWrites) {
+ expect(args).toEqual([
+ "inference",
+ "set",
+ "-g",
+ "nemoclaw-9090",
+ "--provider",
+ "anthropic-prod",
+ "--model",
+ "claude-sonnet-4-20250514",
+ "--no-verify",
+ ]);
+ }
+ expect([...inferenceReads, ...inferenceWrites]).not.toContainEqual(
+ expect.arrayContaining(["-g", "nemoclaw"]),
+ );
+ expect(harness.runSetupDnsProxySpy).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/lib/actions/sandbox/connect-route-lifecycle.test.ts b/src/lib/actions/sandbox/connect-route-lifecycle.test.ts
index e04d2ca93ae..b6fe791d921 100644
--- a/src/lib/actions/sandbox/connect-route-lifecycle.test.ts
+++ b/src/lib/actions/sandbox/connect-route-lifecycle.test.ts
@@ -64,6 +64,8 @@ describe("connectSandbox route lifecycle", () => {
[
"inference",
"set",
+ "-g",
+ "nemoclaw",
"--provider",
"anthropic-prod",
"--model",
@@ -122,7 +124,7 @@ describe("connectSandbox route lifecycle", () => {
await expect(harness.connectSandbox("alpha", { probeOnly: true })).resolves.toBeUndefined();
expect(harness.captureOpenshellSpy).not.toHaveBeenCalledWith(
- ["inference", "get"],
+ ["inference", "get", "-g", "nemoclaw"],
expect.any(Object),
);
expect(harness.runOpenshellSpy).not.toHaveBeenCalled();
@@ -141,7 +143,7 @@ describe("connectSandbox route lifecycle", () => {
await expect(harness.connectSandbox("alpha", { probeOnly: true })).resolves.toBeUndefined();
expect(harness.captureOpenshellSpy).toHaveBeenCalledWith(
- ["inference", "get"],
+ ["inference", "get", "-g", "nemoclaw"],
expect.objectContaining({ ignoreError: true }),
);
expect(harness.runOpenshellSpy).not.toHaveBeenCalled();
@@ -167,6 +169,8 @@ describe("connectSandbox route lifecycle", () => {
[
"inference",
"set",
+ "-g",
+ "nemoclaw",
"--provider",
"nvidia-prod",
"--model",
diff --git a/src/lib/actions/sandbox/connect.ts b/src/lib/actions/sandbox/connect.ts
index d3e0fec4ca8..8e468adf110 100644
--- a/src/lib/actions/sandbox/connect.ts
+++ b/src/lib/actions/sandbox/connect.ts
@@ -23,10 +23,15 @@ import {
planInferenceRouteReconcile,
sanitizeRouteValueForDisplay,
} from "../../inference/config";
+import { GatewayRouteConflictError } from "../../inference/gateway-route-compatibility";
+import { withGatewayRouteMutationLock } from "../../inference/gateway-route-mutation-lock";
import { findReachableOllamaHost, probeLocalProviderHealth } from "../../inference/local";
import { ensureOllamaAuthProxy, probeOllamaAuthProxyHealth } from "../../inference/ollama/proxy";
-import { LOCAL_INFERENCE_TIMEOUT_SECS } from "../../onboard/env";
import { resolveSandboxGatewayName } from "../../onboard/gateway-binding";
+import {
+ assertNoOpenShellGatewayEndpointOverride,
+ OpenShellGatewayEndpointOverrideError,
+} from "../../openshell-gateway-endpoint-guard";
import { isWsl } from "../../platform";
import { ROOT } from "../../runner";
import * as sandboxVersion from "../../sandbox/version";
@@ -55,6 +60,11 @@ import {
exitOnMcpReconciliationRefusal,
exitOnSecretBoundaryRefusal,
} from "./connect-boundary-refusal";
+import {
+ assertSandboxGatewayRouteCompatible,
+ buildGatewayInferenceGetArgs,
+ buildGatewayInferenceSetArgs,
+} from "./connect-inference-gateway";
import {
buildSandboxInferenceRouteProbeArgs,
type InferenceRouteProbeAgent,
@@ -124,6 +134,7 @@ export type SandboxInferenceRouteRepairDeps = {
sandboxName: string,
quiet: boolean,
) => { exitCode: number; message?: string | null };
+ assertRouteCompatible?: (sandboxName: string, sb: SandboxEntry | null) => void;
log?: (message: string) => void;
error?: (message: string) => void;
};
@@ -214,15 +225,16 @@ function exitOnForwardRecoveryFailure(
process.exit(1);
}
-function runSandboxConnectProbe(sandboxName: string): void {
+async function runSandboxConnectProbe(sandboxName: string): Promise {
const agent = agentRuntime.getSessionAgent(sandboxName);
const agentName = agentRuntime.getAgentDisplayName(agent);
if (agent && !agentRuntime.hasGatewayRuntime(agent)) {
+ const routeResult = await ensureSandboxInferenceRoute(sandboxName, agent, { quiet: true });
runTerminalAgentConnectProbe({
agent,
agentName,
capture: captureOpenshell,
- ensureInferenceRoute: (name, options) => ensureSandboxInferenceRoute(name, agent, options),
+ ensureInferenceRoute: () => routeResult,
sandboxName,
});
return;
@@ -254,7 +266,7 @@ function runSandboxConnectProbe(sandboxName: string): void {
);
}
if (processCheck.wasRunning) {
- ensureSandboxInferenceRoute(sandboxName, agent, { quiet: true });
+ await ensureSandboxInferenceRoute(sandboxName, agent, { quiet: true });
// Defense-in-depth scope-upgrade approval on the probe-only / `recover`
// path (#4504): the gateway is up, so deterministically clear any pending
// allowlisted CLI/webchat scope upgrade. Best-effort; never throws.
@@ -269,13 +281,13 @@ function runSandboxConnectProbe(sandboxName: string): void {
return;
}
if (processCheck.recovered) {
- ensureSandboxInferenceRoute(sandboxName, agent, { quiet: true });
+ await ensureSandboxInferenceRoute(sandboxName, agent, { quiet: true });
// Same defense-in-depth approval after a recovery (#4504); best-effort.
runConnectAutoPairApprovalPass(sandboxName);
console.log(` Probe complete: recovered ${agentName} gateway in '${sandboxName}'.`);
return;
}
- ensureSandboxInferenceRoute(sandboxName, agent, { quiet: true });
+ await ensureSandboxInferenceRoute(sandboxName, agent, { quiet: true });
console.error(
` Probe failed: ${agentName} gateway is not running in '${sandboxName}' and automatic recovery failed.`,
);
@@ -397,22 +409,15 @@ function shouldUseLegacyDnsProxyRepair(sb: SandboxEntry | null): boolean {
return driver !== "vm" && driver !== "docker";
}
-function buildInferenceSetArgs(provider: string, model: string): string[] {
- const args = ["inference", "set", "--provider", provider, "--model", model, "--no-verify"];
- if (["compatible-endpoint", "ollama-local", "vllm-local"].includes(provider)) {
- args.push("--timeout", String(LOCAL_INFERENCE_TIMEOUT_SECS));
- }
- return args;
-}
-
function reapplyVmInferenceRoute(
sandboxName: string,
sb: SandboxEntry | null,
agent: InferenceRouteProbeAgent,
+ gatewayName: string,
): SandboxInferenceRouteProbe | null {
const inference = sb ? registry.getSandboxEntryInference(sb) : null;
if (inference?.kind !== "configured") return null;
- runOpenshell(buildInferenceSetArgs(inference.provider, inference.model), {
+ runOpenshell(buildGatewayInferenceSetArgs(gatewayName, inference.provider, inference.model), {
ignoreError: true,
timeout: OPENSHELL_OPERATION_TIMEOUT_MS,
});
@@ -430,6 +435,7 @@ export function repairSandboxInferenceRouteWithDeps(
if (deps.isRepairDisabled?.()) {
return { healthy: true, repairAttempted: false, detail: "route repair disabled" };
}
+ deps.assertRouteCompatible?.(sandboxName, sb);
const initialProbe = deps.probe(sandboxName);
if (initialProbe.healthy) {
return { healthy: true, repairAttempted: false, detail: initialProbe.detail };
@@ -437,7 +443,6 @@ export function repairSandboxInferenceRouteWithDeps(
if (!initialProbe.broken) {
return { healthy: true, repairAttempted: false, detail: initialProbe.detail };
}
-
if (!shouldUseLegacyDnsProxyRepair(sb)) {
if (deps.shouldApplyVmDnsMonkeypatch(sb)) {
if (!quiet) {
@@ -557,6 +562,7 @@ function repairSandboxInferenceRouteIfNeeded(
sandboxName: string,
sb: SandboxEntry | null,
agent: InferenceRouteProbeAgent,
+ gatewayName: string,
{ quiet = false }: { quiet?: boolean } = {},
): SandboxInferenceRouteRepairResult {
return repairSandboxInferenceRouteWithDeps(
@@ -568,12 +574,16 @@ function repairSandboxInferenceRouteIfNeeded(
probe: (name, options) => probeSandboxInferenceRoute(name, agent, options),
shouldApplyVmDnsMonkeypatch,
applyVmDnsMonkeypatch: applyOpenShellVmDnsMonkeypatch,
- reapplyVmInferenceRoute: (name, sandbox) => reapplyVmInferenceRoute(name, sandbox, agent),
+ reapplyVmInferenceRoute: (name, sandbox) =>
+ reapplyVmInferenceRoute(name, sandbox, agent, gatewayName),
repairLegacyDnsProxy: (name, isQuiet) =>
runSetupDnsProxy(
- { gatewayName: resolveSandboxGatewayName(sb), sandboxName: name },
+ { gatewayName, sandboxName: name },
{ log: isQuiet ? () => undefined : console.log },
),
+ assertRouteCompatible: (name, sandbox) => {
+ if (sandbox) assertSandboxGatewayRouteCompatible(name, sandbox, gatewayName);
+ },
},
);
}
@@ -676,6 +686,7 @@ function resetManagedInferenceRoute(
sandboxName: string,
sb: SandboxEntry,
agent: InferenceRouteProbeAgent,
+ gatewayName: string,
{ detail, quiet = false }: { detail: string; quiet?: boolean },
): boolean {
return resetManagedInferenceRouteWithDeps(
@@ -685,7 +696,7 @@ function resetManagedInferenceRoute(
{
verifyLocalInferenceRouteDependencies,
runInferenceSet: (provider, model) =>
- runOpenshell(buildInferenceSetArgs(provider, model), {
+ runOpenshell(buildGatewayInferenceSetArgs(gatewayName, provider, model), {
ignoreError: true,
timeout: OPENSHELL_OPERATION_TIMEOUT_MS,
}),
@@ -695,7 +706,7 @@ function resetManagedInferenceRoute(
);
}
-function ensureSandboxInferenceRoute(
+function ensureSandboxInferenceRouteUnlocked(
sandboxName: string,
agent: InferenceRouteProbeAgent,
{ quiet = false }: { quiet?: boolean } = {},
@@ -708,15 +719,18 @@ function ensureSandboxInferenceRoute(
// This projection is total; the catch below handles only later gateway and repair failures.
inference = registry.getSandboxEntryInference(sb);
if (inference.kind !== "configured") return { sandbox: sb, routeHealthy: null };
+ assertNoOpenShellGatewayEndpointOverride();
const { provider, model } = inference;
+ const gatewayName = resolveSandboxGatewayName(sb);
const live = parseGatewayInference(
- captureOpenshell(["inference", "get"], {
+ captureOpenshell(buildGatewayInferenceGetArgs(gatewayName), {
ignoreError: true,
timeout: OPENSHELL_PROBE_TIMEOUT_MS,
}).output,
);
const plan = planInferenceRouteReconcile(live, { provider, model });
if (plan.kind !== "aligned") {
+ assertSandboxGatewayRouteCompatible(sandboxName, sb, gatewayName);
const recordedRoute = `${sanitizeRouteValueForDisplay(provider)}/${sanitizeRouteValueForDisplay(model)}`;
if (plan.kind === "diverged") {
// Shared gateway: re-point loudly (even when quiet) — silent revert was
@@ -738,7 +752,7 @@ function ensureSandboxInferenceRoute(
// plan.kind === "repair": empty gateway, genuine repair — quiet-aware.
console.log(` Setting inference route to ${recordedRoute} for sandbox '${sandboxName}'`);
}
- const swapResult = runOpenshell(buildInferenceSetArgs(provider, model), {
+ const swapResult = runOpenshell(buildGatewayInferenceSetArgs(gatewayName, provider, model), {
ignoreError: true,
timeout: OPENSHELL_OPERATION_TIMEOUT_MS,
});
@@ -748,9 +762,11 @@ function ensureSandboxInferenceRoute(
);
}
}
- const repairResult = repairSandboxInferenceRouteIfNeeded(sandboxName, sb, agent, { quiet });
+ const repairResult = repairSandboxInferenceRouteIfNeeded(sandboxName, sb, agent, gatewayName, {
+ quiet,
+ });
if (!repairResult.healthy && repairResult.repairAttempted) {
- const resetResult = resetManagedInferenceRoute(sandboxName, sb, agent, {
+ const resetResult = resetManagedInferenceRoute(sandboxName, sb, agent, gatewayName, {
detail: repairResult.detail,
quiet,
});
@@ -759,6 +775,14 @@ function ensureSandboxInferenceRoute(
return { sandbox: sb, routeHealthy: repairResult.healthy };
} catch (error) {
if (!sb || inference?.kind !== "configured") return { sandbox: sb, routeHealthy: null };
+ if (error instanceof OpenShellGatewayEndpointOverrideError) {
+ console.error(` Error: ${error.message}`);
+ process.exit(1);
+ }
+ if (error instanceof GatewayRouteConflictError) {
+ console.error(` Error: ${error.message}`);
+ process.exit(1);
+ }
const detail = error instanceof Error && error.message ? error.message : String(error);
if (!quiet) {
console.error(` Error: failed to verify or repair inference route: ${detail}`);
@@ -772,12 +796,38 @@ function ensureSandboxInferenceRoute(
}
}
-function ensureSandboxInferenceRouteOrExit(
+async function ensureSandboxInferenceRoute(
+ sandboxName: string,
+ agent: InferenceRouteProbeAgent,
+ { quiet = false }: { quiet?: boolean } = {},
+): Promise {
+ const snapshot = registry.getSandbox(sandboxName);
+ if (!snapshot) return { sandbox: null, routeHealthy: null };
+ if (registry.getSandboxEntryInference(snapshot).kind !== "configured")
+ return { sandbox: snapshot, routeHealthy: null };
+ const gatewayName = resolveSandboxGatewayName(snapshot);
+ return withGatewayRouteMutationLock(gatewayName, () => {
+ const lockedSnapshot = registry.getSandbox(sandboxName);
+ if (
+ lockedSnapshot &&
+ registry.getSandboxEntryInference(lockedSnapshot).kind === "configured" &&
+ resolveSandboxGatewayName(lockedSnapshot) !== gatewayName
+ ) {
+ console.error(
+ ` Error: sandbox '${sandboxName}' changed OpenShell gateways while waiting to verify its inference route. Retry the command.`,
+ );
+ process.exit(1);
+ }
+ return ensureSandboxInferenceRouteUnlocked(sandboxName, agent, { quiet });
+ });
+}
+
+async function ensureSandboxInferenceRouteOrExit(
sandboxName: string,
agent: InferenceRouteProbeAgent,
{ quiet = false }: { quiet?: boolean } = {},
-): SandboxEntry | null {
- const result = ensureSandboxInferenceRoute(sandboxName, agent, { quiet });
+): Promise {
+ const result = await ensureSandboxInferenceRoute(sandboxName, agent, { quiet });
if (result.routeHealthy === false) {
process.exit(1);
}
@@ -859,6 +909,22 @@ export async function connectSandbox(
sandboxName: string,
{ probeOnly = false }: SandboxConnectOptions = {},
): Promise {
+ try {
+ assertNoOpenShellGatewayEndpointOverride();
+ const registered = registry.getSandbox(sandboxName);
+ if (registered?.pendingRouteReservation === true) {
+ throw new Error(
+ `Sandbox '${sandboxName}' is still being created by onboarding. Wait for onboarding to finish or remove the incomplete sandbox before connecting.`,
+ );
+ }
+ if (registered && registry.getSandboxEntryInference(registered).kind === "configured") {
+ const gatewayName = resolveSandboxGatewayName(registered);
+ assertSandboxGatewayRouteCompatible(sandboxName, registered, gatewayName);
+ }
+ } catch (error) {
+ console.error(` Error: ${error instanceof Error ? error.message : String(error)}`);
+ process.exit(1);
+ }
// probe-only / recover never install or serve a model, so skip the
// express-vLLM model preflight for them (it only steers the install path
// and would otherwise hard-exit a recovery on a stale NEMOCLAW_VLLM_MODEL).
@@ -884,7 +950,7 @@ export async function connectSandbox(
}
if (probeOnly) {
- return runSandboxConnectProbe(sandboxName);
+ return await runSandboxConnectProbe(sandboxName);
}
// Version staleness check — warn but don't block
@@ -1048,7 +1114,7 @@ export async function connectSandbox(
// cluster-wide inference.local route may still point at the other provider.
// After the sandbox is Ready, verify and recover the route before SSH.
const agent = agentRuntime.getSessionAgent(sandboxName);
- sb = ensureSandboxInferenceRouteOrExit(sandboxName, agent);
+ sb = await ensureSandboxInferenceRouteOrExit(sandboxName, agent);
maybeEnsureHermesToolGatewayBroker(sb);
// ── Auto-pair late scope-upgrade approval (#4263) ───────────────
diff --git a/src/lib/actions/sandbox/snapshot.test.ts b/src/lib/actions/sandbox/snapshot.test.ts
index 4634fb81e6d..d57c5f461b6 100644
--- a/src/lib/actions/sandbox/snapshot.test.ts
+++ b/src/lib/actions/sandbox/snapshot.test.ts
@@ -22,6 +22,8 @@ type SandboxRecord = {
gatewayName?: string | null;
imageTag?: string | null;
openshellDriver?: string | null;
+ provider?: string | null;
+ model?: string | null;
};
type DcodeProbeState = "active" | "idle" | "unverifiable" | "no-runtime";
@@ -209,6 +211,10 @@ vi.mock("../../state/gateway", () => ({
vi.mock("../../state/registry", () => ({
getCustomPolicies: getCustomPoliciesMock,
getSandbox: getSandboxMock,
+ listSandboxes: () => ({
+ sandboxes: ["alpha", "beta", "gamma"].map((name) => getSandboxMock(name)).filter(Boolean),
+ defaultSandbox: "alpha",
+ }),
registerSandbox: registerSandboxMock,
removeSandbox: vi.fn(),
}));
@@ -834,12 +840,16 @@ describe("runSandboxSnapshot", () => {
agent: "openclaw",
imageTag: "nemoclaw-alpha:test",
openshellDriver: "docker",
+ provider: "nvidia-nim",
+ model: "nvidia/model-a",
}
: {
name: "beta",
agent: "openclaw",
imageTag: "nemoclaw-beta:test",
openshellDriver: "docker",
+ provider: "nvidia-nim",
+ model: "nvidia/model-a",
},
);
parseLiveSandboxNamesMock.mockReturnValue(new Set(["alpha", "beta"]));
@@ -883,6 +893,45 @@ describe("runSandboxSnapshot", () => {
expect(restoreSandboxStateMock).toHaveBeenCalledWith("beta", "/tmp/backup-alpha");
});
+ it("blocks auto-create before deleting a destination when a gateway peer conflicts", async () => {
+ const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
+ getSandboxMock.mockImplementation((name) => ({
+ name: name ?? "alpha",
+ agent: "openclaw",
+ gatewayName: "nemoclaw",
+ imageTag: `nemoclaw-${name}:test`,
+ openshellDriver: "docker",
+ provider: name === "gamma" ? "anthropic-prod" : "nvidia-nim",
+ model: name === "gamma" ? "claude-new" : "nvidia/model-a",
+ }));
+ parseLiveSandboxNamesMock.mockReturnValue(new Set(["alpha", "beta"]));
+ captureOpenshellMock.mockImplementation((args) =>
+ openshellResponses(args, {
+ "sandbox exec": { status: 0, output: dcodeProbeOutput("no-runtime") },
+ "sandbox list": { status: 0, output: "alpha Ready\nbeta Ready\n" },
+ }),
+ );
+ getLatestBackupMock.mockReturnValue({
+ timestamp: "2026-06-15T00:00:00.000Z",
+ backupPath: "/tmp/backup-alpha",
+ });
+ const { runSandboxSnapshot } = await import("./snapshot");
+
+ await expect(
+ runSandboxSnapshot("alpha", {
+ kind: "restore",
+ to: "beta",
+ force: true,
+ yes: true,
+ }),
+ ).rejects.toMatchObject({ exitCode: 1 });
+
+ expect(consoleError.mock.calls.flat().join("\n")).toContain("gamma");
+ expect(lifecycleMock.events).not.toContain("delete");
+ expect(streamSandboxCreateMock).not.toHaveBeenCalled();
+ expect(registerSandboxMock).not.toHaveBeenCalled();
+ });
+
it("refuses snapshot creation before backup when the sandbox is not live", async () => {
parseLiveSandboxNamesMock.mockReturnValue(new Set(["beta"]));
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
diff --git a/src/lib/actions/sandbox/snapshot.ts b/src/lib/actions/sandbox/snapshot.ts
index 3976c507672..98faa982883 100644
--- a/src/lib/actions/sandbox/snapshot.ts
+++ b/src/lib/actions/sandbox/snapshot.ts
@@ -13,6 +13,11 @@ import { OPENSHELL_PROBE_TIMEOUT_MS } from "../../adapters/openshell/timeouts";
import { CLI_NAME } from "../../cli/branding";
import { prompt as askPrompt } from "../../credentials/store";
import { getSandboxDeleteOutcome } from "../../domain/sandbox/destroy";
+import {
+ checkGatewayRouteCompatibility,
+ formatGatewayRouteConflict,
+} from "../../inference/gateway-route-compatibility";
+import { withGatewayRouteMutationLock } from "../../inference/gateway-route-mutation-lock";
import * as nim from "../../inference/nim";
import { listMessagingProviderSuffixes } from "../../messaging/channels";
import { resolveSandboxGatewayName } from "../../onboard/gateway-binding";
@@ -27,6 +32,7 @@ import { isSandboxReady } from "../../state/gateway";
import { withSandboxMutationLock } from "../../state/mcp-lifecycle-lock";
import type { SandboxEntry } from "../../state/registry";
import * as registry from "../../state/registry";
+import { getSandboxEntryInference } from "../../state/registry-entry-view";
import * as sandboxState from "../../state/sandbox";
import { cleanupShieldsDestroyArtifacts, removeSandboxRegistryEntry } from "./destroy";
import {
@@ -780,16 +786,69 @@ async function runSnapshotRestoreUnlocked(
snapshotExit(1);
}
}
- if (targetEntry) {
- verifyRestoreDestinationOnOwnGateway(targetSandbox);
+ }
+ const sourceGatewayName = resolveSandboxGatewayName(srcEntry);
+ await withGatewayRouteMutationLock(sourceGatewayName, async () => {
+ if (!targetExists && registry.getSandbox(targetSandbox)) {
+ console.error(
+ ` Destination sandbox '${targetSandbox}' was registered while this restore was waiting. Retry with --force only after reviewing that sandbox.`,
+ );
+ snapshotExit(1);
+ }
+ const lockedSourceEntry = registry.getSandbox(sandboxName);
+ if (!lockedSourceEntry) {
+ console.error(
+ ` Cannot auto-create '${targetSandbox}': source '${sandboxName}' has no durable inference route metadata.`,
+ );
+ snapshotExit(1);
+ }
+ if (getSandboxEntryInference(lockedSourceEntry).kind !== "configured") {
+ console.error(
+ ` Cannot auto-create '${targetSandbox}': source '${sandboxName}' has no complete durable inference route.`,
+ );
+ snapshotExit(1);
+ }
+ const lockedFromImage = resolveSrcPodImage(sandboxName, lockedSourceEntry);
+ if (!lockedFromImage) {
+ console.error(
+ ` Cannot resolve the current image for source sandbox '${sandboxName}' — aborting before changing '${targetSandbox}'.`,
+ );
+ snapshotExit(1);
+ }
+ const lockedGatewayName = resolveSandboxGatewayName(lockedSourceEntry);
+ if (lockedGatewayName !== sourceGatewayName) {
+ console.error(
+ ` Source sandbox '${sandboxName}' changed OpenShell gateways while waiting to restore. Retry the command.`,
+ );
+ snapshotExit(1);
+ }
+ const compatibility = checkGatewayRouteCompatibility({
+ gatewayName: sourceGatewayName,
+ sandboxName: targetSandbox,
+ route: lockedSourceEntry,
+ sandboxes: registry.listSandboxes().sandboxes,
+ });
+ if (!compatibility.ok) {
+ console.error(` Error: ${formatGatewayRouteConflict(compatibility)}`);
+ snapshotExit(1);
+ }
+ if (targetExists) {
+ if (targetEntry) {
+ verifyRestoreDestinationOnOwnGateway(targetSandbox);
+ }
+ deleteSandboxForRestore(targetSandbox);
+ requireLiveSandboxesOnSandboxGateway(
+ sandboxName,
+ " Failed to re-select source sandbox gateway after deleting destination.",
+ );
}
- deleteSandboxForRestore(targetSandbox);
- requireLiveSandboxesOnSandboxGateway(
+ await autoCreateSandboxFromSource(
sandboxName,
- " Failed to re-select source sandbox gateway after deleting destination.",
+ targetSandbox,
+ lockedSourceEntry,
+ lockedFromImage,
);
- }
- await autoCreateSandboxFromSource(sandboxName, targetSandbox, srcEntry, fromImage);
+ });
}
withTimerBoundShieldsMutationLock(targetSandbox, "restore sandbox snapshot", () => {
// Serialize filesystem restore, mutable-permission repair, and policy
diff --git a/src/lib/cli/inference-set-help.ts b/src/lib/cli/inference-set-help.ts
new file mode 100644
index 00000000000..80c95ffc520
--- /dev/null
+++ b/src/lib/cli/inference-set-help.ts
@@ -0,0 +1,16 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { CLI_NAME } from "./branding";
+
+export function inferenceSetRequiredFlagsFailureLines(command: string, usageSuffix = ""): string[] {
+ const displayCommand = `${CLI_NAME} ${command}`;
+ return [
+ ` ${displayCommand} requires --provider and --model.`,
+ "",
+ ` Run: ${displayCommand} --provider --model ${usageSuffix}`,
+ " NemoClaw must perform this operation so it can protect every sandbox sharing the target gateway.",
+ "",
+ ` Run '${CLI_NAME} help' for NemoClaw commands.`,
+ ];
+}
diff --git a/src/lib/inference/gateway-route-compatibility.test.ts b/src/lib/inference/gateway-route-compatibility.test.ts
new file mode 100644
index 00000000000..c4d3f53c8ee
--- /dev/null
+++ b/src/lib/inference/gateway-route-compatibility.test.ts
@@ -0,0 +1,371 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { describe, expect, it } from "vitest";
+import type { SandboxEntry } from "../state/registry";
+import {
+ checkGatewayRouteCompatibility,
+ formatGatewayRouteConflict,
+ type GatewayInferenceRoute,
+ preflightGatewayRouteDiscovery,
+} from "./gateway-route-compatibility";
+
+const route = (
+ provider: string,
+ model: string,
+ overrides: Partial = {},
+): GatewayInferenceRoute => ({
+ provider,
+ model,
+ endpointUrl: null,
+ preferredInferenceApi: null,
+ credentialEnv: null,
+ ...overrides,
+});
+
+const discoveryRoute = (
+ provider: string,
+ overrides: Partial = {},
+): Omit & { model: string | null } => ({
+ ...route(provider, "discovery-pending", overrides),
+ model: null,
+});
+
+const sandbox = (name: string, overrides: Partial = {}): SandboxEntry => ({
+ name,
+ gatewayName: "nemoclaw",
+ gatewayPort: 8080,
+ provider: "nvidia-prod",
+ model: "nvidia/model-a",
+ ...overrides,
+});
+
+function check(requested: GatewayInferenceRoute, sandboxes: SandboxEntry[]) {
+ return checkGatewayRouteCompatibility({
+ gatewayName: "nemoclaw",
+ sandboxName: "target",
+ route: requested,
+ sandboxes,
+ });
+}
+
+function discover(
+ requested: Omit & { model: string | null },
+ sandboxes: SandboxEntry[],
+) {
+ return preflightGatewayRouteDiscovery({
+ gatewayName: "nemoclaw",
+ sandboxName: "target",
+ route: requested,
+ sandboxes,
+ });
+}
+
+describe("shared gateway inference route compatibility", () => {
+ it("allows unconstrained discovery when no configured same-gateway peer exists (#6315)", () => {
+ expect(
+ discover(discoveryRoute("nvidia-prod"), [
+ sandbox("other", { gatewayName: "nemoclaw-9090", gatewayPort: 9090 }),
+ ]),
+ ).toEqual({
+ ok: true,
+ requiredModel: null,
+ requiredEndpointUrl: null,
+ requiredInferenceApi: null,
+ });
+ });
+
+ it("constrains discovery to the durable same-gateway model (#6315)", () => {
+ expect(discover(discoveryRoute("nvidia-prod"), [sandbox("stopped-peer")])).toEqual({
+ ok: true,
+ requiredModel: "nvidia/model-a",
+ requiredEndpointUrl: null,
+ requiredInferenceApi: null,
+ });
+ });
+
+ it("constrains custom discovery to the durable endpoint and API family (#6315)", () => {
+ expect(
+ discover(discoveryRoute("compatible-endpoint"), [
+ sandbox("custom-peer", {
+ provider: "compatible-endpoint",
+ model: "custom/model",
+ endpointUrl: "https://example.test/v1",
+ preferredInferenceApi: "openai-completions",
+ }),
+ ]),
+ ).toEqual({
+ ok: true,
+ requiredModel: "custom/model",
+ requiredEndpointUrl: "https://example.test/v1",
+ requiredInferenceApi: "openai-completions",
+ });
+ });
+
+ it("blocks conflicting or unprovable discovery before a provider probe (#6315)", () => {
+ expect(discover(discoveryRoute("anthropic-prod"), [sandbox("stopped-peer")])).toMatchObject({
+ ok: false,
+ result: { conflicts: [{ sandboxName: "stopped-peer", reason: "provider-model" }] },
+ });
+ expect(
+ discover(discoveryRoute("nvidia-prod"), [
+ sandbox("unknown-gateway", { gatewayName: "not-a-nemoclaw-gateway", gatewayPort: null }),
+ ]),
+ ).toMatchObject({
+ ok: false,
+ result: {
+ conflicts: [{ sandboxName: "unknown-gateway", reason: "invalid-gateway-binding" }],
+ },
+ });
+ expect(
+ discover(discoveryRoute("nvidia-prod"), [
+ sandbox("recovered-live", { provider: null, model: null }),
+ ]),
+ ).toMatchObject({
+ ok: false,
+ result: {
+ conflicts: [{ sandboxName: "recovered-live", reason: "incomplete-route" }],
+ },
+ });
+ });
+
+ it("allows identical routes and ignores the target sandbox itself (#6315)", () => {
+ expect(
+ check(route("nvidia-prod", "nvidia/model-a"), [
+ sandbox("target", { provider: null, model: null }),
+ sandbox("stopped-peer"),
+ ]),
+ ).toEqual({ ok: true });
+ });
+
+ it("blocks provider or model conflicts from every same-gateway registry row (#6315)", () => {
+ const result = check(route("anthropic-prod", "claude-new"), [sandbox("stopped-peer")]);
+
+ expect(result).toMatchObject({
+ ok: false,
+ conflicts: [{ sandboxName: "stopped-peer", reason: "provider-model" }],
+ });
+ expect(formatGatewayRouteConflict(result as Exclude)).toContain(
+ "Stopped sandboxes are included",
+ );
+ });
+
+ it("allows different routes on different gateways (#6315)", () => {
+ expect(
+ check(route("anthropic-prod", "claude-new"), [
+ sandbox("other-gateway", {
+ gatewayName: "nemoclaw-9090",
+ gatewayPort: 9090,
+ provider: null,
+ model: null,
+ }),
+ ]),
+ ).toEqual({ ok: true });
+ });
+
+ it("normalizes equivalent custom endpoint URLs before comparison (#6315)", () => {
+ expect(
+ check(
+ route("compatible-endpoint", "custom/model", {
+ endpointUrl: "https://EXAMPLE.test/v1/?token=ignored",
+ preferredInferenceApi: "openai-completions",
+ }),
+ [
+ sandbox("custom-peer", {
+ provider: "compatible-endpoint",
+ model: "custom/model",
+ endpointUrl: "https://example.test/v1",
+ preferredInferenceApi: "openai-completions",
+ }),
+ ],
+ ),
+ ).toEqual({ ok: true });
+ });
+
+ it("normalizes Anthropic endpoint suffixes for custom route identity (#6315)", () => {
+ expect(
+ check(
+ route("compatible-anthropic-endpoint", "anthropic/model", {
+ endpointUrl: "https://example.test/v1/messages",
+ preferredInferenceApi: "anthropic-messages",
+ }),
+ [
+ sandbox("anthropic-peer", {
+ provider: "compatible-anthropic-endpoint",
+ model: "anthropic/model",
+ endpointUrl: "https://example.test",
+ preferredInferenceApi: "anthropic-messages",
+ }),
+ ],
+ ),
+ ).toEqual({ ok: true });
+ });
+
+ it("blocks Hermes OpenAI frontend against a recorded native Anthropic route", () => {
+ const result = check(
+ route("compatible-anthropic-endpoint", "anthropic/model", {
+ endpointUrl: "https://example.test/v1",
+ preferredInferenceApi: "openai-completions",
+ }),
+ [
+ sandbox("legacy-anthropic-peer", {
+ provider: "compatible-anthropic-endpoint",
+ model: "anthropic/model",
+ endpointUrl: "https://example.test",
+ preferredInferenceApi: "anthropic-messages",
+ }),
+ ],
+ );
+
+ expect(result).toMatchObject({
+ ok: false,
+ conflicts: [{ sandboxName: "legacy-anthropic-peer", reason: "custom-api" }],
+ });
+ });
+
+ it("ignores credential environment differences in route identity (#6315)", () => {
+ expect(
+ check(
+ route("compatible-endpoint", "custom/model", {
+ endpointUrl: "https://example.test/v1",
+ preferredInferenceApi: "openai-completions",
+ credentialEnv: "REQUESTED_KEY",
+ }),
+ [
+ sandbox("custom-peer", {
+ provider: "compatible-endpoint",
+ model: "custom/model",
+ endpointUrl: "https://example.test/v1",
+ preferredInferenceApi: "openai-completions",
+ credentialEnv: "RECORDED_KEY",
+ }),
+ ],
+ ),
+ ).toEqual({ ok: true });
+ });
+
+ it.each([
+ [
+ "endpoint",
+ { endpointUrl: "https://other.test/v1", preferredInferenceApi: "openai-completions" },
+ "custom-endpoint",
+ ],
+ [
+ "API family",
+ { endpointUrl: "https://example.test/v1", preferredInferenceApi: "openai-responses" },
+ "custom-api",
+ ],
+ ] as const)("blocks custom %s conflicts (#6315)", (_label, recordedMetadata, reason) => {
+ const result = check(
+ route("compatible-endpoint", "custom/model", {
+ endpointUrl: "https://example.test/v1",
+ preferredInferenceApi: "openai-completions",
+ }),
+ [
+ sandbox("custom-peer", {
+ provider: "compatible-endpoint",
+ model: "custom/model",
+ ...recordedMetadata,
+ }),
+ ],
+ );
+
+ expect(result).toMatchObject({ ok: false, conflicts: [{ reason }] });
+ });
+
+ it.each([
+ ["endpoint", null, "openai-completions"],
+ ["API family", "https://example.test/v1", null],
+ ] as const)("fails closed when legacy custom route %s metadata is missing (#6315)", (_label, endpointUrl, preferredInferenceApi) => {
+ const result = check(
+ route("compatible-endpoint", "custom/model", {
+ endpointUrl: "https://example.test/v1",
+ preferredInferenceApi: "openai-completions",
+ }),
+ [
+ sandbox("legacy-custom", {
+ provider: "compatible-endpoint",
+ model: "custom/model",
+ endpointUrl,
+ preferredInferenceApi,
+ }),
+ ],
+ );
+
+ expect(result).toMatchObject({
+ ok: false,
+ conflicts: [
+ {
+ sandboxName: "legacy-custom",
+ reason: "incomplete-custom-route",
+ scope: "registered",
+ },
+ ],
+ });
+ expect(formatGatewayRouteConflict(result as Exclude)).toContain(
+ "remove and re-onboard that sandbox with complete custom-route metadata",
+ );
+ });
+
+ it("fails closed when a requested custom route has no API metadata or peers (#6315)", () => {
+ const result = check(
+ route("compatible-endpoint", "custom/model", {
+ endpointUrl: "https://example.test/v1",
+ preferredInferenceApi: null,
+ }),
+ [],
+ );
+
+ expect(result).toMatchObject({
+ ok: false,
+ conflicts: [
+ {
+ sandboxName: "target",
+ reason: "incomplete-custom-route",
+ scope: "requested",
+ },
+ ],
+ });
+ expect(formatGatewayRouteConflict(result as Exclude)).toContain(
+ "requested custom route lacks durable endpoint or API-family metadata",
+ );
+ });
+
+ it.each([
+ ["provider and model", null, null],
+ ["model", "nvidia-prod", null],
+ ["provider", null, "nvidia/model-a"],
+ ] as const)("fails closed when a same-gateway registry row lacks %s metadata (#6315)", (_missing, provider, model) => {
+ const result = check(route("nvidia-prod", "nvidia/model-a"), [
+ sandbox("recovered-live", { provider, model }),
+ ]);
+
+ expect(result).toMatchObject({
+ ok: false,
+ conflicts: [
+ {
+ sandboxName: "recovered-live",
+ reason: "incomplete-route",
+ scope: "registered",
+ },
+ ],
+ });
+ expect(formatGatewayRouteConflict(result as Exclude)).toContain(
+ "lacks durable provider or model metadata",
+ );
+ });
+
+ it("fails closed when a registry row has an invalid gateway binding (#6315)", () => {
+ const result = check(route("nvidia-prod", "nvidia/model-a"), [
+ sandbox("unknown-gateway", { gatewayName: "not-a-nemoclaw-gateway", gatewayPort: null }),
+ ]);
+
+ expect(result).toMatchObject({
+ ok: false,
+ conflicts: [{ sandboxName: "unknown-gateway", reason: "invalid-gateway-binding" }],
+ });
+ expect(formatGatewayRouteConflict(result as Exclude)).toContain(
+ "restore its known-good gateway binding or remove and re-onboard that sandbox",
+ );
+ });
+});
diff --git a/src/lib/inference/gateway-route-compatibility.ts b/src/lib/inference/gateway-route-compatibility.ts
new file mode 100644
index 00000000000..1d96c186214
--- /dev/null
+++ b/src/lib/inference/gateway-route-compatibility.ts
@@ -0,0 +1,368 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { canonicalEndpoint, type EndpointFlavor } from "../core/url-utils";
+import { resolveSandboxGatewayName } from "../onboard/gateway-binding";
+import type { SandboxEntry } from "../state/registry";
+
+export type GatewayInferenceRoute = Pick<
+ SandboxEntry,
+ "provider" | "model" | "endpointUrl" | "preferredInferenceApi" | "credentialEnv"
+>;
+
+export interface GatewayRouteCompatibilityRequest {
+ gatewayName: string;
+ sandboxName: string | null;
+ route: GatewayInferenceRoute;
+ sandboxes: readonly SandboxEntry[];
+}
+
+export type CurrentGatewayRouteCompatibilityRequest = Pick<
+ GatewayRouteCompatibilityRequest,
+ "gatewayName" | "sandboxName" | "route"
+>;
+
+export type CurrentGatewayRouteCompatibilityCheck = (
+ request: CurrentGatewayRouteCompatibilityRequest,
+) => GatewayRouteCompatibilityResult;
+
+export type GatewayRouteConflictReason =
+ | "provider-model"
+ | "custom-endpoint"
+ | "custom-api"
+ | "incomplete-route"
+ | "incomplete-custom-route"
+ | "invalid-gateway-binding";
+
+export interface GatewayRouteConflict {
+ sandboxName: string;
+ reason: GatewayRouteConflictReason;
+ scope?: "requested" | "registered";
+}
+
+export type GatewayRouteCompatibilityResult =
+ | { ok: true }
+ | {
+ ok: false;
+ gatewayName: string;
+ sandboxName: string | null;
+ route: { provider: string; model: string };
+ conflicts: GatewayRouteConflict[];
+ };
+
+export interface GatewayRouteDiscoveryConstraints {
+ requiredModel: string | null;
+ requiredEndpointUrl: string | null;
+ requiredInferenceApi: string | null;
+}
+
+export type GatewayRouteDiscoveryResult =
+ | ({ ok: true } & GatewayRouteDiscoveryConstraints)
+ | { ok: false; result: Exclude };
+
+export type CurrentGatewayRouteDiscoveryPreflight = (
+ request: Omit & {
+ route: Omit & { model: string | null };
+ },
+) => GatewayRouteDiscoveryResult;
+
+const CUSTOM_ROUTE_PROVIDERS = new Set(["compatible-endpoint", "compatible-anthropic-endpoint"]);
+
+const SUPPORTED_INFERENCE_APIS = new Set([
+ "openai-completions",
+ "anthropic-messages",
+ "openai-responses",
+]);
+
+function nonEmptyString(value: unknown): string | null {
+ if (typeof value !== "string") return null;
+ const trimmed = value.trim();
+ return trimmed || null;
+}
+
+function configuredRoute(route: GatewayInferenceRoute): { provider: string; model: string } | null {
+ const provider = nonEmptyString(route.provider);
+ const model = nonEmptyString(route.model);
+ return provider && model ? { provider, model } : null;
+}
+
+function endpointFlavor(provider: string): EndpointFlavor {
+ return provider === "compatible-anthropic-endpoint" ? "anthropic" : "openai";
+}
+
+function normalizedInferenceApi(value: unknown): string | null {
+ const api = nonEmptyString(value);
+ return api && SUPPORTED_INFERENCE_APIS.has(api) ? api : null;
+}
+
+function customRouteConflict(
+ provider: string,
+ requested: GatewayInferenceRoute,
+ recorded: GatewayInferenceRoute,
+): GatewayRouteConflictReason | null {
+ const flavor = endpointFlavor(provider);
+ const requestedEndpoint = canonicalEndpoint(requested.endpointUrl, flavor);
+ const recordedEndpoint = canonicalEndpoint(recorded.endpointUrl, flavor);
+ const requestedApi = normalizedInferenceApi(requested.preferredInferenceApi);
+ const recordedApi = normalizedInferenceApi(recorded.preferredInferenceApi);
+ if (!requestedEndpoint || !recordedEndpoint || !requestedApi || !recordedApi) {
+ return "incomplete-custom-route";
+ }
+ if (requestedEndpoint !== recordedEndpoint) return "custom-endpoint";
+ if (requestedApi !== recordedApi) return "custom-api";
+ return null;
+}
+
+/**
+ * Constrain read-only route discovery from durable same-gateway registry peers.
+ * Missing requested model/API fields are allowed only when the gateway has no
+ * configured peer, or when every peer supplies one identical value that
+ * discovery must subsequently verify with the exact compatibility guard.
+ */
+export function preflightGatewayRouteDiscovery(
+ request: Parameters[0] & {
+ sandboxes: readonly SandboxEntry[];
+ },
+): GatewayRouteDiscoveryResult {
+ const provider = nonEmptyString(request.route.provider);
+ if (!provider) throw new Error("Requested gateway inference route requires a provider");
+ const peers: SandboxEntry[] = [];
+ const discoveryConflicts: GatewayRouteConflict[] = [];
+ for (const sandbox of request.sandboxes) {
+ if (sandbox.name === request.sandboxName) continue;
+ let recordedGatewayName: string;
+ try {
+ recordedGatewayName = resolveSandboxGatewayName(sandbox);
+ } catch {
+ discoveryConflicts.push({
+ sandboxName: sandbox.name,
+ reason: "invalid-gateway-binding",
+ scope: "registered",
+ });
+ continue;
+ }
+ if (recordedGatewayName !== request.gatewayName) continue;
+ if (configuredRoute(sandbox)) peers.push(sandbox);
+ else {
+ discoveryConflicts.push({
+ sandboxName: sandbox.name,
+ reason: "incomplete-route",
+ scope: "registered",
+ });
+ }
+ }
+ const requestedModel = nonEmptyString(request.route.model);
+ if (discoveryConflicts.length > 0) {
+ return {
+ ok: false,
+ result: {
+ ok: false,
+ gatewayName: request.gatewayName,
+ sandboxName: request.sandboxName,
+ route: { provider, model: requestedModel ?? "model discovery pending" },
+ conflicts: discoveryConflicts,
+ },
+ };
+ }
+ if (peers.length === 0) {
+ return {
+ ok: true,
+ requiredModel: null,
+ requiredEndpointUrl: null,
+ requiredInferenceApi: null,
+ };
+ }
+ const reference = peers[0];
+ const recorded = configuredRoute(reference);
+ if (!recorded) throw new Error("Gateway route discovery peer is not configured");
+ if (provider !== recorded.provider || (requestedModel && requestedModel !== recorded.model)) {
+ return {
+ ok: false,
+ result: {
+ ok: false,
+ gatewayName: request.gatewayName,
+ sandboxName: request.sandboxName,
+ route: { provider, model: requestedModel ?? recorded.model },
+ conflicts: peers.map((sandbox) => ({
+ sandboxName: sandbox.name,
+ reason: "provider-model" as const,
+ scope: "registered" as const,
+ })),
+ },
+ };
+ }
+ const custom = CUSTOM_ROUTE_PROVIDERS.has(provider);
+ const candidate: GatewayInferenceRoute = {
+ ...request.route,
+ provider,
+ model: requestedModel ?? recorded.model,
+ endpointUrl: nonEmptyString(request.route.endpointUrl) ?? reference.endpointUrl,
+ preferredInferenceApi:
+ nonEmptyString(request.route.preferredInferenceApi) ?? reference.preferredInferenceApi,
+ };
+ const compatibility = checkGatewayRouteCompatibility({ ...request, route: candidate });
+ if (!compatibility.ok) return { ok: false, result: compatibility };
+ return {
+ ok: true,
+ requiredModel: recorded.model,
+ requiredEndpointUrl: custom ? (nonEmptyString(reference.endpointUrl) ?? null) : null,
+ requiredInferenceApi: custom ? normalizedInferenceApi(reference.preferredInferenceApi) : null,
+ };
+}
+
+/**
+ * Compare a requested route with every durable registry row on the same
+ * OpenShell gateway. Registry rows are intentionally used without a live-state
+ * filter because stopped sandboxes still depend on the gateway route when they
+ * restart. The requested route must already carry the target agent's effective
+ * API family; recorded peer metadata is compared literally so this guard never
+ * silently treats a legacy sandbox as migrated.
+ */
+export function checkGatewayRouteCompatibility(
+ request: GatewayRouteCompatibilityRequest,
+): GatewayRouteCompatibilityResult {
+ const requested = configuredRoute(request.route);
+ if (!requested) {
+ throw new Error("Requested gateway inference route requires a provider and model");
+ }
+ if (
+ CUSTOM_ROUTE_PROVIDERS.has(requested.provider) &&
+ (!canonicalEndpoint(request.route.endpointUrl, endpointFlavor(requested.provider)) ||
+ !normalizedInferenceApi(request.route.preferredInferenceApi))
+ ) {
+ return {
+ ok: false,
+ gatewayName: request.gatewayName,
+ sandboxName: request.sandboxName,
+ route: requested,
+ conflicts: [
+ {
+ sandboxName: request.sandboxName ?? "requested route",
+ reason: "incomplete-custom-route",
+ scope: "requested",
+ },
+ ],
+ };
+ }
+
+ const conflicts: GatewayRouteConflict[] = [];
+ for (const sandbox of request.sandboxes) {
+ if (sandbox.name === request.sandboxName) continue;
+ let recordedGatewayName: string;
+ try {
+ recordedGatewayName = resolveSandboxGatewayName(sandbox);
+ } catch {
+ conflicts.push({
+ sandboxName: sandbox.name,
+ reason: "invalid-gateway-binding",
+ scope: "registered",
+ });
+ continue;
+ }
+ if (recordedGatewayName !== request.gatewayName) continue;
+ const recorded = configuredRoute(sandbox);
+ if (!recorded) {
+ conflicts.push({
+ sandboxName: sandbox.name,
+ reason: "incomplete-route",
+ scope: "registered",
+ });
+ continue;
+ }
+
+ if (recorded.provider !== requested.provider || recorded.model !== requested.model) {
+ conflicts.push({
+ sandboxName: sandbox.name,
+ reason: "provider-model",
+ scope: "registered",
+ });
+ continue;
+ }
+ if (CUSTOM_ROUTE_PROVIDERS.has(requested.provider)) {
+ const reason = customRouteConflict(requested.provider, request.route, sandbox);
+ if (reason) conflicts.push({ sandboxName: sandbox.name, reason, scope: "registered" });
+ }
+ }
+
+ return conflicts.length === 0
+ ? { ok: true }
+ : {
+ ok: false,
+ gatewayName: request.gatewayName,
+ sandboxName: request.sandboxName,
+ route: requested,
+ conflicts,
+ };
+}
+
+function safeDisplay(value: string): string {
+ return value.replace(/[\u0000-\u001f\u007f]/g, "?");
+}
+
+export function formatGatewayRouteConflict(
+ result: Exclude,
+): string {
+ const requestedRouteIncomplete = result.conflicts.some(
+ (conflict) => conflict.reason === "incomplete-custom-route" && conflict.scope === "requested",
+ );
+ const names = [
+ ...new Set(
+ result.conflicts
+ .filter((conflict) => conflict.scope !== "requested")
+ .map((conflict) => safeDisplay(conflict.sandboxName)),
+ ),
+ ]
+ .sort()
+ .map((name) => `'${name}'`)
+ .join(", ");
+ const target = result.sandboxName ? ` for sandbox '${safeDisplay(result.sandboxName)}'` : "";
+ const hasIncompleteCustomRoute = result.conflicts.some(
+ (conflict) => conflict.reason === "incomplete-custom-route",
+ );
+ const hasIncompleteRoute = result.conflicts.some(
+ (conflict) => conflict.reason === "incomplete-route",
+ );
+ const hasInvalidGatewayBinding = result.conflicts.some(
+ (conflict) => conflict.reason === "invalid-gateway-binding",
+ );
+ const detail = [
+ hasIncompleteCustomRoute
+ ? "At least one custom route lacks durable endpoint or API-family metadata, so compatibility cannot be proven; remove and re-onboard that sandbox with complete custom-route metadata."
+ : null,
+ hasIncompleteRoute
+ ? "At least one registered sandbox lacks durable provider or model metadata, so same-gateway compatibility cannot be proven; remove and re-onboard that sandbox with complete route metadata."
+ : null,
+ hasInvalidGatewayBinding
+ ? "At least one registry row has an invalid gateway binding, so gateway separation cannot be proven; restore its known-good gateway binding or remove and re-onboard that sandbox."
+ : null,
+ ]
+ .filter(Boolean)
+ .join(" ");
+ return (
+ `OpenShell gateway '${safeDisplay(result.gatewayName)}' has one inference route shared by every registered sandbox. ` +
+ `Cannot set ${safeDisplay(result.route.provider)} / ${safeDisplay(result.route.model)}${target}${
+ requestedRouteIncomplete
+ ? " because the requested custom route lacks durable endpoint or API-family metadata."
+ : ` because it conflicts with ${names}.`
+ }${detail && !requestedRouteIncomplete ? ` ${detail}` : ""}\n` +
+ "Stopped sandboxes are included because they use the same gateway route when restarted. " +
+ (requestedRouteIncomplete
+ ? "Remove and re-onboard the sandbox with complete custom-route metadata."
+ : "Align the routes, remove the conflicting sandbox, or use another NEMOCLAW_GATEWAY_PORT.")
+ );
+}
+
+export class GatewayRouteConflictError extends Error {
+ readonly result: Exclude;
+
+ constructor(result: Exclude) {
+ super(formatGatewayRouteConflict(result));
+ this.name = "GatewayRouteConflictError";
+ this.result = result;
+ }
+}
+
+export function assertGatewayRouteCompatibility(request: GatewayRouteCompatibilityRequest): void {
+ const result = checkGatewayRouteCompatibility(request);
+ if (!result.ok) throw new GatewayRouteConflictError(result);
+}
diff --git a/src/lib/inference/gateway-route-mutation-lock.test.ts b/src/lib/inference/gateway-route-mutation-lock.test.ts
new file mode 100644
index 00000000000..4185e35cc7c
--- /dev/null
+++ b/src/lib/inference/gateway-route-mutation-lock.test.ts
@@ -0,0 +1,84 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import fs from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { describe, expect, it } from "vitest";
+import { withGatewayRouteMutationLock } from "./gateway-route-mutation-lock";
+
+describe("gateway route mutation lock", () => {
+ it("serializes separate operations for the same gateway", async () => {
+ const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "nemoclaw-gateway-lock-"));
+ let releaseFirst!: () => void;
+ const firstReleased = new Promise((resolve) => {
+ releaseFirst = resolve;
+ });
+ let reportFirstEntered!: () => void;
+ const firstEntered = new Promise((resolve) => {
+ reportFirstEntered = resolve;
+ });
+ const events: string[] = [];
+ const options = { stateDir, pollIntervalMs: 1, timeoutMs: 5_000 };
+ try {
+ const first = withGatewayRouteMutationLock(
+ "nemoclaw",
+ async () => {
+ events.push("first-enter");
+ reportFirstEntered();
+ await firstReleased;
+ events.push("first-exit");
+ },
+ options,
+ );
+ await firstEntered;
+ const second = withGatewayRouteMutationLock(
+ "nemoclaw",
+ () => {
+ events.push("second-enter");
+ },
+ options,
+ );
+ await new Promise((resolve) => setTimeout(resolve, 20));
+ expect(events).toEqual(["first-enter"]);
+ releaseFirst();
+ await Promise.all([first, second]);
+ expect(events).toEqual(["first-enter", "first-exit", "second-enter"]);
+ } finally {
+ releaseFirst();
+ await fs.rm(stateDir, { recursive: true, force: true });
+ }
+ });
+
+ it("allows operations for different gateways to overlap", async () => {
+ const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "nemoclaw-gateway-lock-"));
+ let releaseFirst!: () => void;
+ const firstReleased = new Promise((resolve) => {
+ releaseFirst = resolve;
+ });
+ let reportFirstEntered!: () => void;
+ const firstEntered = new Promise((resolve) => {
+ reportFirstEntered = resolve;
+ });
+ const options = { stateDir, pollIntervalMs: 1, timeoutMs: 5_000 };
+ try {
+ const first = withGatewayRouteMutationLock(
+ "nemoclaw",
+ async () => {
+ reportFirstEntered();
+ await firstReleased;
+ },
+ options,
+ );
+ await firstEntered;
+ await expect(
+ withGatewayRouteMutationLock("nemoclaw-9090", () => "second", options),
+ ).resolves.toBe("second");
+ releaseFirst();
+ await first;
+ } finally {
+ releaseFirst();
+ await fs.rm(stateDir, { recursive: true, force: true });
+ }
+ });
+});
diff --git a/src/lib/inference/gateway-route-mutation-lock.ts b/src/lib/inference/gateway-route-mutation-lock.ts
new file mode 100644
index 00000000000..1e64c3ed06a
--- /dev/null
+++ b/src/lib/inference/gateway-route-mutation-lock.ts
@@ -0,0 +1,25 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { type McpLifecycleLockOptions, withMcpLifecycleLock } from "../state/mcp-lifecycle-lock";
+
+const GATEWAY_ROUTE_LOCK_PREFIX = "gateway-route:";
+
+/**
+ * Serializes host-side reads and writes of OpenShell's one-route-per-gateway
+ * inference state. The non-sandbox prefix keeps this lock namespace disjoint
+ * from user sandbox mutation locks while reusing their cross-process lease.
+ */
+export function withGatewayRouteMutationLock(
+ gatewayName: string,
+ operation: () => Promise | T,
+ options: McpLifecycleLockOptions = {},
+): Promise {
+ const normalizedGatewayName = gatewayName.trim();
+ if (!normalizedGatewayName) throw new Error("OpenShell gateway name is required.");
+ return withMcpLifecycleLock(
+ `${GATEWAY_ROUTE_LOCK_PREFIX}${normalizedGatewayName}`,
+ operation,
+ options,
+ );
+}
diff --git a/src/lib/inference/nim-route-identity.test.ts b/src/lib/inference/nim-route-identity.test.ts
new file mode 100644
index 00000000000..833baed04c3
--- /dev/null
+++ b/src/lib/inference/nim-route-identity.test.ts
@@ -0,0 +1,13 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { describe, expect, it } from "vitest";
+
+import { expectedServedModelId } from "./nim";
+
+describe("NIM route identity", () => {
+ it("uses durable served IDs when they differ from catalog IDs (#6315)", () => {
+ expect(expectedServedModelId("nvidia/nemotron-3-nano-30b-a3b")).toBe("nvidia/nemotron-3-nano");
+ expect(expectedServedModelId("meta/llama-3.1-8b-instruct")).toBe("meta/llama-3.1-8b-instruct");
+ });
+});
diff --git a/src/lib/inference/nim.ts b/src/lib/inference/nim.ts
index 0e9d4fb543b..3a297579506 100644
--- a/src/lib/inference/nim.ts
+++ b/src/lib/inference/nim.ts
@@ -38,6 +38,7 @@ export interface NimModel {
name: string;
image: string;
minGpuMemoryMB: number;
+ servedModel?: string;
}
export type NvidiaPlatform = "spark" | "station" | "jetson" | "linux";
@@ -348,6 +349,11 @@ export function getImageForModel(modelName: string): string | null {
return entry ? entry.image : null;
}
+export function expectedServedModelId(modelName: string): string {
+ const entry = nimImages.models.find((model: NimModel) => model.name === modelName);
+ return entry?.servedModel || modelName;
+}
+
export function listModels(): NimModel[] {
return nimImages.models.map((m: NimModel) => ({
name: m.name,
diff --git a/src/lib/inference/vllm.test.ts b/src/lib/inference/vllm.test.ts
index 55bba834c56..658eb0d0a98 100644
--- a/src/lib/inference/vllm.test.ts
+++ b/src/lib/inference/vllm.test.ts
@@ -27,7 +27,29 @@ vi.mock("./nim", () => ({
getGpuIndicesByName: mocks.getGpuIndicesByName,
}));
-import { buildVllmRunCommand, detectVllmProfile, installVllm, pullImage } from "./vllm";
+import {
+ buildVllmRunCommand,
+ detectVllmProfile,
+ installVllm,
+ pullImage,
+ resolveVllmServedModelId,
+} from "./vllm";
+
+describe("vLLM served route identity", () => {
+ it("uses one safe served-model override and rejects ambiguous aliases (#6315)", () => {
+ expect(resolveVllmServedModelId("catalog/model", [])).toBe("catalog/model");
+ expect(resolveVllmServedModelId("catalog/model", ["--served-model-name", "served/model"])).toBe(
+ "served/model",
+ );
+ expect(() =>
+ resolveVllmServedModelId("catalog/model", [
+ "--served-model-name",
+ "served/one",
+ "served/two",
+ ]),
+ ).toThrow("exactly one safe model ID");
+ });
+});
describe("vLLM profile detection", () => {
beforeEach(() => {
@@ -175,6 +197,7 @@ describe("installVllm model resolution", () => {
errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
stdoutWrite = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
delete process.env.NEMOCLAW_VLLM_MODEL;
+ delete process.env.NEMOCLAW_VLLM_EXTRA_ARGS_JSON;
delete process.env.HF_TOKEN;
delete process.env.HUGGING_FACE_HUB_TOKEN;
// Fail dockerPrereqsOk so the function returns before any docker work,
@@ -259,4 +282,44 @@ describe("installVllm model resolution", () => {
const errors = errSpy.mock.calls.map((c: unknown[]) => String(c[0])).join("\n");
expect(errors).toMatch(/gated on Hugging Face/);
});
+
+ it("guards the effective served model before any docker work (#6315)", async () => {
+ process.env.NEMOCLAW_VLLM_EXTRA_ARGS_JSON = JSON.stringify([
+ "--served-model-name",
+ "shared/served-model",
+ ]);
+ const profile = detectVllmProfile({ platform: "spark", type: "nvidia" })!;
+ const beforeInstall = vi.fn();
+
+ await installVllm(profile, {
+ hasImage: true,
+ nonInteractive: true,
+ promptFn: vi.fn(),
+ beforeInstall,
+ });
+
+ expect(beforeInstall).toHaveBeenCalledWith("shared/served-model");
+ expect(beforeInstall.mock.invocationCallOrder[0]).toBeLessThan(
+ mocks.runCapture.mock.invocationCallOrder[0],
+ );
+ });
+
+ it("performs no Docker work when the shared-gateway guard rejects installation (#6315)", async () => {
+ const profile = detectVllmProfile({ platform: "spark", type: "nvidia" })!;
+
+ await expect(
+ installVllm(profile, {
+ hasImage: true,
+ nonInteractive: true,
+ promptFn: vi.fn(),
+ beforeInstall: () => {
+ throw new Error("route conflict");
+ },
+ }),
+ ).rejects.toThrow("route conflict");
+
+ expect(mocks.runCapture).not.toHaveBeenCalled();
+ expect(mocks.dockerPullWithProgressWatchdog).not.toHaveBeenCalled();
+ expect(mocks.dockerSpawn).not.toHaveBeenCalled();
+ });
});
diff --git a/src/lib/inference/vllm.ts b/src/lib/inference/vllm.ts
index 437f34aee24..f06b4cb8dbd 100644
--- a/src/lib/inference/vllm.ts
+++ b/src/lib/inference/vllm.ts
@@ -9,12 +9,13 @@ import { dockerCapture, dockerPullWithProgressWatchdog, dockerSpawn } from "../a
import { buildValidatedCurlCommandArgs } from "../adapters/http/curl-args";
import { VLLM_PORT } from "../core/ports";
import { runCapture, runShell } from "../runner";
+import { isSafeModelId } from "../validation";
import { getGpuIndicesByName } from "./nim";
import {
- VLLM_EXTRA_ARGS_ENV,
- VLLM_MODELS,
buildVllmServeCommand,
parseVllmExtraServeArgs,
+ VLLM_EXTRA_ARGS_ENV,
+ VLLM_MODELS,
type VllmModelDef,
type VllmPlatform,
} from "./vllm-models";
@@ -493,6 +494,29 @@ interface InstallVllmOptions {
hasImage: boolean;
nonInteractive: boolean;
promptFn: (q: string) => Promise;
+ beforeInstall?: (modelId: string) => void;
+}
+
+export function resolveVllmServedModelId(modelId: string, extraServeArgs: string[]): string {
+ let override: string | null = null;
+ for (let index = 0; index < extraServeArgs.length; index += 1) {
+ const arg = extraServeArgs[index];
+ let values: string[] | null = null;
+ if (arg === "--served-model-name") {
+ values = [];
+ while (index + 1 < extraServeArgs.length && !extraServeArgs[index + 1].startsWith("-")) {
+ values.push(extraServeArgs[(index += 1)]);
+ }
+ } else if (arg.startsWith("--served-model-name=")) {
+ values = [arg.slice("--served-model-name=".length)];
+ }
+ if (!values) continue;
+ if (override || values.length !== 1 || !isSafeModelId(values[0])) {
+ throw new Error("--served-model-name must specify exactly one safe model ID");
+ }
+ override = values[0];
+ }
+ return override ?? modelId;
}
// Public entry point. Returns ok=false on any prereq, pull, run, or load
@@ -512,12 +536,15 @@ export async function installVllm(
const { model, source: modelSource } = resolved;
let extraServeArgs: string[];
+ let servedModelId: string;
try {
extraServeArgs = parseVllmExtraServeArgs();
+ servedModelId = resolveVllmServedModelId(model.id, extraServeArgs);
} catch (err) {
console.error(` vLLM install failed: ${(err as Error).message}`);
return { ok: false };
}
+ opts.beforeInstall?.(servedModelId);
console.log("");
console.log(` vLLM (${profile.name}):`);
diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts
index c77856ec604..db1482ceb99 100644
--- a/src/lib/onboard.ts
+++ b/src/lib/onboard.ts
@@ -346,6 +346,8 @@ const {
const registry: typeof import("./state/registry") = require("./state/registry");
const sandboxMutationLock: typeof import("./state/mcp-lifecycle-lock") =
require("./state/mcp-lifecycle-lock");
+const gatewayRouteMutationLock: typeof import("./inference/gateway-route-mutation-lock") =
+ require("./inference/gateway-route-mutation-lock");
const { resolveSandboxImageTagFromCreateOutput } =
require("./domain/sandbox/image-tag") as typeof import("./domain/sandbox/image-tag");
const nim: typeof import("./inference/nim") = require("./inference/nim");
@@ -607,6 +609,7 @@ import {
type SandboxGpuFlag,
} from "./onboard/sandbox-gpu-mode";
import type { SelectionDrift } from "./onboard/selection-drift";
+import { createSetupNimVllmHandler } from "./onboard/setup-nim-vllm";
import { formatOnboardConfigSummary, formatSandboxBuildEstimateNote } from "./onboard/summary";
import type {
ModelValidationResult,
@@ -909,20 +912,15 @@ function persistMigratedLegacyKeys(): void {
}
}
-function upsertProvider(
- name: string,
- type: string,
- credentialEnv: string,
- baseUrl: string | null,
- env: NodeJS.ProcessEnv = {},
-) {
+// biome-ignore format: keep src/lib/onboard.ts net-neutral for growth guardrail.
+function upsertProvider(name: string, type: string, credentialEnv: string, baseUrl: string | null, env: NodeJS.ProcessEnv = {}, gatewayName: string = GATEWAY_NAME) {
const result = onboardProviders.upsertProvider(
name,
type,
credentialEnv,
baseUrl,
env,
- runOpenshell,
+ setupInferenceFactory.createGatewayScopedOpenshellRunner(runOpenshell, gatewayName),
);
if (result.ok && credentialEnv) {
const stagedValue = stagedLegacyValues.get(credentialEnv);
@@ -996,12 +994,11 @@ function upsertMessagingProviders(
if (mutated) persistMigratedLegacyKeys();
return upserted;
}
-const providerExistsInGateway = (name: string) =>
- onboardProviders.providerExistsInGateway(name, runOpenshell);
-
-const { verifyInferenceRoute, isInferenceRouteReady } =
- inferenceRouteHelpers.createInferenceRouteHelpers(runCaptureOpenshell);
+// biome-ignore format: keep src/lib/onboard.ts net-neutral for growth guardrail.
+const providerExistsInGateway = (name: string, gatewayName: string = GATEWAY_NAME) => onboardProviders.providerExistsInGateway(name, setupInferenceFactory.createGatewayScopedOpenshellRunner(runOpenshell, gatewayName));
+// biome-ignore format: keep src/lib/onboard.ts net-neutral for growth guardrail.
+const { verifyInferenceRoute, isInferenceRouteReady, checkGatewayRouteCompatibility, preflightGatewayRouteDiscovery } = inferenceRouteHelpers.createInferenceRouteHelpers(runCaptureOpenshell);
const {
inspectSandboxForCreate,
pruneStaleSandboxEntry,
@@ -1102,6 +1099,13 @@ const {
assertOllamaUpgradeApplied,
});
+// biome-ignore format: keep src/lib/onboard.ts net-neutral for growth guardrail.
+const handleVllmSelection = createSetupNimVllmHandler({
+ VLLM_PORT, runCapture, getLocalProviderBaseUrl, getLocalProviderValidationBaseUrl,
+ isSafeModelId, requireValue, validateOpenAiLikeSelection,
+ applyVllmRuntimeContextWindow: localInference.applyVllmRuntimeContextWindow,
+ exitProcess: (code) => process.exit(code),
+});
const ollamaModelSize: typeof import("./inference/ollama/model-size") = require("./inference/ollama/model-size");
function isOpenshellInstalled(): boolean {
@@ -3076,9 +3080,14 @@ type OllamaModelSelectionOutcome =
async function selectAndValidateOllamaModel(
gpu: ReturnType,
provider: string,
- defaults: { requestedModel: string | null; recoveredModel: string | null },
+ defaults: {
+ requestedModel: string | null;
+ recoveredModel: string | null;
+ lockedModel?: string | null;
+ },
+ onModelSelected?: (model: string) => void,
): Promise {
- const { requestedModel, recoveredModel } = defaults;
+ const { requestedModel, recoveredModel, lockedModel } = defaults;
const probeFailures = new OllamaProbeFailureTracker();
const confirm = (question: string, defaultIsYes: boolean) =>
promptYesNoOrDefault(question, null, defaultIsYes);
@@ -3086,7 +3095,9 @@ async function selectAndValidateOllamaModel(
while (true) {
const installedModels = getOllamaModelOptions();
let model: string | typeof BACK_TO_SELECTION;
- if (isNonInteractive()) {
+ if (lockedModel) {
+ model = lockedModel;
+ } else if (isNonInteractive()) {
model = localInference.resolveNonInteractiveOllamaModel(requestedModel, recoveredModel, gpu);
} else {
model = await promptOllamaModel(gpu, { excludeModels: probeFailures.excludedModels() });
@@ -3097,6 +3108,7 @@ async function selectAndValidateOllamaModel(
return { outcome: "back-to-selection" };
}
const selectedModel = requireValue(model, "Expected an Ollama model selection");
+ onModelSelected?.(selectedModel);
if (!installedModels.includes(selectedModel)) {
const lookup = ollamaModelSize.getOllamaModelSize(selectedModel);
const sizeLabel = ollamaModelSize.formatModelSize(lookup);
@@ -3171,78 +3183,7 @@ type SetupNimSelectionState =
type SetupNimSelectionResult = "selected" | "retry-selection";
// biome-ignore format: keep src/lib/onboard.ts net-neutral for growth guardrail.
-type RemoteProviderSelectionArgs = { selected: ProviderChoice; requestedModel: string | null; recoveredFromSandbox: boolean; recoveredModel: string | null; sandboxName: string | null; intendedInferenceApi: string | null };
-
-async function handleVllmSelection(
- state: SetupNimSelectionState,
-): Promise {
- console.log(` ✓ Using existing vLLM on localhost:${VLLM_PORT}`);
- state.provider = "vllm-local";
- // Local vLLM uses an internal credential env, no user API key.
- state.credentialEnv = null;
- state.endpointUrl = getLocalProviderBaseUrl(state.provider);
- if (!state.endpointUrl) {
- console.error(" Local vLLM base URL could not be determined.");
- process.exit(1);
- }
-
- // Source boundary: local vLLM is an external process, so /v1/models can be
- // unreachable, malformed, empty, or return an unsafe served id. setupNim is
- // the last safe point before writing provider state, so fail closed here
- // rather than returning a partially configured local provider. Remove this
- // local guard only if the vLLM manager owns a typed, validated model probe.
- const vllmModelsRaw = runCapture(["curl", "-sf", `http://127.0.0.1:${VLLM_PORT}/v1/models`], {
- ignoreError: true,
- });
- let vllmModels: { data?: Array<{ id?: unknown }> } = {};
- try {
- vllmModels = JSON.parse(vllmModelsRaw);
- if (vllmModels.data && vllmModels.data.length > 0) {
- const detectedModel =
- typeof vllmModels.data[0]?.id === "string" ? vllmModels.data[0].id : null;
- state.model = detectedModel;
- if (!detectedModel || !isSafeModelId(detectedModel)) {
- console.error(` Detected model ID contains invalid characters: ${state.model}`);
- process.exit(1);
- }
- console.log(` Detected model: ${state.model}`);
- } else {
- console.error(" Could not detect model from vLLM. Please specify manually.");
- process.exit(1);
- }
- } catch {
- console.error(
- ` Could not query vLLM models endpoint. Is vLLM running on localhost:${VLLM_PORT}?`,
- );
- process.exit(1);
- }
-
- const validationBaseUrl = getLocalProviderValidationBaseUrl(state.provider);
- if (!validationBaseUrl) {
- console.error(" Local vLLM validation URL could not be determined.");
- process.exit(1);
- }
- const validation = await validateOpenAiLikeSelection(
- "Local vLLM",
- validationBaseUrl,
- requireValue(state.model as string | null | undefined, "Expected a detected vLLM model"),
- null,
- );
- if (validation.retry === "selection" || validation.retry === "model") {
- return "retry-selection";
- }
- if (!validation.ok) return "retry-selection";
-
- localInference.applyVllmRuntimeContextWindow(vllmModels, state.model as string);
- state.preferredInferenceApi = validation.api;
- // Force chat completions — vLLM's /v1/responses endpoint does not run the
- // --tool-call-parser, so tool calls arrive as raw text (#976).
- if (state.preferredInferenceApi !== "openai-completions") {
- console.log(" ℹ Using chat completions API (tool-call-parser requires /v1/chat/completions)");
- }
- state.preferredInferenceApi = "openai-completions";
- return "selected";
-}
+type RemoteProviderSelectionArgs = { selected: ProviderChoice; requestedModel: string | null; recoveredFromSandbox: boolean; recoveredModel: string | null; sandboxName: string | null; gatewayName: string | null; intendedInferenceApi: string | null };
async function handleRoutedSelection(
state: SetupNimSelectionState,
@@ -3254,6 +3195,18 @@ async function handleRoutedSelection(
return "retry-selection";
}
+ state.provider = bp.provider_name || "nvidia-router";
+ state.model = bp.model;
+ const { HOST_GATEWAY_URL } = require("./inference/local");
+ const routerEndpointUrl = bp.endpoint || "";
+ state.endpointUrl = routerEndpointUrl;
+ if (routerEndpointUrl.match(/localhost|127\.0\.0\.1/)) {
+ const u = new URL(routerEndpointUrl);
+ state.endpointUrl = `${HOST_GATEWAY_URL}:${u.port}${u.pathname}`;
+ }
+ state.preferredInferenceApi = "openai-completions";
+ state.assertRouteCompatible?.();
+
const routerCredentialEnv =
bp.router?.credential_env || bp.credential_env || DEFAULT_MODEL_ROUTER_CREDENTIAL_ENV;
state.credentialEnv = routerCredentialEnv;
@@ -3286,16 +3239,6 @@ async function handleRoutedSelection(
}
}
- state.provider = bp.provider_name || "nvidia-router";
- state.model = bp.model;
- const { HOST_GATEWAY_URL } = require("./inference/local");
- const routerEndpointUrl = bp.endpoint || "";
- state.endpointUrl = routerEndpointUrl;
- if (routerEndpointUrl.match(/localhost|127\.0\.0\.1/)) {
- const u = new URL(routerEndpointUrl);
- state.endpointUrl = `${HOST_GATEWAY_URL}:${u.port}${u.pathname}`;
- }
- state.preferredInferenceApi = "openai-completions";
console.log(` ✓ Using Model Router: ${state.provider} / ${state.model}`);
return "selected";
}
@@ -3313,6 +3256,7 @@ async function handleNimLocalSelection(
if (models.length === 0) {
console.log(" No NIM models fit your GPU VRAM. Falling back to cloud API.");
applyCloudFallbackSelection(state, REMOTE_PROVIDER_CONFIG.build);
+ state.assertRouteCompatible?.();
return "selected";
}
@@ -3342,7 +3286,17 @@ async function handleNimLocalSelection(
const modelChoice = await prompt(` Choose model [1]: `);
sel = selectFromNumberedMenuOrExit(modelChoice, 1, models);
}
- state.model = sel.name;
+ const catalogModel = sel.name;
+ state.model = nim.expectedServedModelId(catalogModel);
+ state.provider = "vllm-local";
+ state.credentialEnv = null;
+ state.endpointUrl = getLocalProviderBaseUrl(state.provider);
+ state.preferredInferenceApi = "openai-completions";
+ if (!state.endpointUrl) {
+ console.error(" Local NVIDIA NIM base URL could not be determined.");
+ process.exit(1);
+ }
+ state.assertRouteCompatible?.();
let ngcApiKey: string | null = null;
if (!nim.isNgcLoggedIn()) {
@@ -3386,12 +3340,12 @@ async function handleNimLocalSelection(
}
}
- console.log(` Pulling NIM image for ${state.model}...`);
- nim.pullNimImage(state.model);
+ console.log(` Pulling NIM image for ${catalogModel}...`);
+ nim.pullNimImage(catalogModel);
console.log(" Starting NIM container...");
const nimContainerNameLocal = nim.containerName(GATEWAY_NAME);
- state.nimContainer = nim.startNimContainerByName(nimContainerNameLocal, state.model, undefined, {
+ state.nimContainer = nim.startNimContainerByName(nimContainerNameLocal, catalogModel, undefined, {
ngcApiKey: ngcApiKey ?? undefined,
});
@@ -3399,17 +3353,12 @@ async function handleNimLocalSelection(
if (!nim.waitForNimHealth(undefined, undefined, { container: nimContainerNameLocal })) {
console.error(" NIM failed to start. Falling back to cloud API.");
applyCloudFallbackSelection(state, REMOTE_PROVIDER_CONFIG.build);
+ state.assertRouteCompatible?.();
return "selected";
}
- state.provider = "vllm-local";
- state.credentialEnv = null;
- state.endpointUrl = getLocalProviderBaseUrl(state.provider);
- if (!state.endpointUrl) {
- console.error(" Local NVIDIA NIM base URL could not be determined.");
- process.exit(1);
- }
- state.model = nim.adoptServedModelId(state.model);
+ state.model = nim.adoptServedModelId(catalogModel);
+ state.assertRouteCompatible?.();
const nimValidationUrl = getLocalProviderValidationBaseUrl(state.provider) || state.endpointUrl;
const validation = await validateOpenAiLikeSelection(
"Local NVIDIA NIM",
@@ -3440,6 +3389,7 @@ async function handleRemoteProviderSelection(args: RemoteProviderSelectionArgs,
state.credentialEnv = remoteConfig.credentialEnv;
state.endpointUrl = remoteConfig.endpointUrl;
state.preferredInferenceApi = null;
+ state.model = requestedModel || (recoveredFromSandbox ? recoveredModel : null);
if (selected.key === "custom" || selected.key === "anthropicCompatible") {
const kind = selected.key === "custom" ? "openai" : "anthropic";
@@ -3479,8 +3429,17 @@ async function handleRemoteProviderSelection(args: RemoteProviderSelectionArgs,
state.endpointUrl,
);
}
+ const explicitApi = (process.env.NEMOCLAW_PREFERRED_API || "").trim().toLowerCase();
+ state.preferredInferenceApi = selected.key === "custom" ? (explicitApi === "chat-completions" ? "openai-completions" : explicitApi || null) : null;
+ if (!state.preferredInferenceApi) {
+ state.preferredInferenceApi =
+ selected.key === "custom" ||
+ bedrockRuntimeOnboard.needsBedrockRuntimeAdapter(state.endpointUrl)
+ ? "openai-completions"
+ : "anthropic-messages";
+ }
}
-
+ state.assertRouteCompatible?.();
if (selected.key === "hermesProvider") {
const selectedHermesAuthMethod = await promptHermesAuthMethod();
if (isBackToSelection(selectedHermesAuthMethod)) {
@@ -3520,7 +3479,7 @@ async function handleRemoteProviderSelection(args: RemoteProviderSelectionArgs,
);
const defaultModel =
- requestedModel || (recoveredFromSandbox && recoveredModel) || remoteConfig.defaultModel;
+ requestedModel || (typeof state.model === "string" && state.model) || remoteConfig.defaultModel;
if (isNonInteractive()) {
state.model = defaultModel;
} else {
@@ -3550,6 +3509,7 @@ async function handleRemoteProviderSelection(args: RemoteProviderSelectionArgs,
return "retry-selection";
}
state.preferredInferenceApi = "openai-completions";
+ state.assertRouteCompatible?.();
console.log(` Using ${remoteConfig.label} with model: ${state.model}`);
return "selected";
}
@@ -3561,7 +3521,7 @@ async function handleRemoteProviderSelection(args: RemoteProviderSelectionArgs,
provider: state.provider,
helpUrl: REMOTE_PROVIDER_CONFIG.build.helpUrl,
recoveredFromSandbox,
- providerExistsInGateway,
+ providerExistsInGateway: (name) => providerExistsInGateway(name, args.gatewayName ?? GATEWAY_NAME),
});
state.skipHostInferenceSmoke = reuseGatewayCredential;
state.reuseGatewayCredentialWithoutLocalKey = reuseGatewayCredential;
@@ -3569,7 +3529,7 @@ async function handleRemoteProviderSelection(args: RemoteProviderSelectionArgs,
await ensureApiKey();
}
state.model = await state.nvidiaFeaturedModels!.select(
- requestedModel,
+ requestedModel || (typeof state.model === "string" ? state.model : null),
recoveredFromSandbox ? recoveredModel : null,
isNonInteractive(),
process.env.NEMOCLAW_MODEL,
@@ -3585,6 +3545,7 @@ async function handleRemoteProviderSelection(args: RemoteProviderSelectionArgs,
const _envModelRemote = (process.env.NEMOCLAW_MODEL || "").trim();
const defaultModel =
requestedModel ||
+ (typeof state.model === "string" && state.model) ||
_envModelRemote ||
(recoveredFromSandbox && recoveredModel) ||
remoteConfig.defaultModel;
@@ -3615,13 +3576,16 @@ async function handleRemoteProviderSelection(args: RemoteProviderSelectionArgs,
if (bedrockSelection.action === "selected") {
state.model = bedrockSelection.model;
state.preferredInferenceApi = bedrockSelection.preferredInferenceApi;
+ state.assertRouteCompatible?.();
return "selected";
}
if (isNonInteractive()) {
+ state.model = defaultModel;
+ state.assertRouteCompatible?.();
// biome-ignore format: keep src/lib/onboard.ts net-neutral for growth guardrail.
recoveredProviderReuse.resolveRecoveredProviderCredentialReuse(
{ selected, remoteConfig, state, selectedCredentialEnv, recoveredFromSandbox, selectedModel: defaultModel, sandboxName, recoveredRegistryRoute },
- { resolveProviderCredential, readRecordedInferenceRoute, readRecordedProviderEndpoints, readGatewayProviderMetadata: (provider) => onboardProviders.readGatewayProviderMetadata(provider, runOpenshell), note },
+ { resolveProviderCredential, readRecordedInferenceRoute, readRecordedProviderEndpoints, readGatewayProviderMetadata: (provider) => onboardProviders.readGatewayProviderMetadata(provider, runOpenshell, args.gatewayName ?? GATEWAY_NAME), note },
);
} else {
const credentialResult = await ensureNamedCredential(
@@ -3636,21 +3600,27 @@ async function handleRemoteProviderSelection(args: RemoteProviderSelectionArgs,
let modelValidator: ((candidate: string) => ModelValidationResult) | null = null;
if (selected.key === "openai" || selected.key === "gemini") {
const modelAuthMode = getProbeAuthMode(state.provider);
- modelValidator = (candidate) =>
- validateOpenAiLikeModel(
+ modelValidator = (candidate) => {
+ state.model = candidate;
+ state.assertRouteCompatible?.();
+ return validateOpenAiLikeModel(
remoteConfig.label,
state.endpointUrl || remoteConfig.endpointUrl,
candidate,
getCredential(selectedCredentialEnv) || "",
...(modelAuthMode ? [{ authMode: modelAuthMode }] : []),
);
+ };
} else if (selected.key === "anthropic") {
- modelValidator = (candidate) =>
- validateAnthropicModel(
+ modelValidator = (candidate) => {
+ state.model = candidate;
+ state.assertRouteCompatible?.();
+ return validateAnthropicModel(
state.endpointUrl || ANTHROPIC_ENDPOINT_URL,
candidate,
getCredential(selectedCredentialEnv) || "",
);
+ };
}
while (true) {
if (isNonInteractive()) {
@@ -3670,6 +3640,7 @@ async function handleRemoteProviderSelection(args: RemoteProviderSelectionArgs,
console.log("");
return "retry-selection";
}
+ state.assertRouteCompatible?.();
const validationResult = state.reuseGatewayCredentialWithoutLocalKey
? "selected"
@@ -3677,7 +3648,10 @@ async function handleRemoteProviderSelection(args: RemoteProviderSelectionArgs,
// biome-ignore format: keep src/lib/onboard.ts net-neutral for growth guardrail.
{ selected, remoteConfig, state, selectedCredentialEnv, intendedInferenceApi },
);
- if (validationResult === "selected") break;
+ if (validationResult === "selected") {
+ state.assertRouteCompatible?.();
+ break;
+ }
if (validationResult === "retry-selection") return "retry-selection";
}
}
@@ -3687,6 +3661,7 @@ async function handleRemoteProviderSelection(args: RemoteProviderSelectionArgs,
isBackToSelection(state.model) ? null : state.model,
`Missing model for ${remoteConfig.label}`,
);
+ state.assertRouteCompatible?.();
const buildValidation = await buildCredentialReuse.resolveBuildPreferredInferenceApi({
reuseGatewayCredentialWithoutLocalKey: state.skipHostInferenceSmoke === true,
note,
@@ -3707,6 +3682,7 @@ async function handleRemoteProviderSelection(args: RemoteProviderSelectionArgs,
});
if (buildValidation.retrySelection) return "retry-selection";
state.preferredInferenceApi = buildValidation.preferredInferenceApi;
+ state.assertRouteCompatible?.();
}
console.log(` Using ${remoteConfig.label} with model: ${state.model}`);
@@ -3757,6 +3733,7 @@ function getSetupNimDeps(): SetupNimDeps {
handleVllmSelection,
handleRoutedSelection,
coerceAgentInferenceApi: inferenceConfig.coerceAgentInferenceApi,
+ resolveAgentInferenceApi: inferenceConfig.resolveAgentInferenceApi,
clearCompatibleEndpointReasoning: reasoningMode.clearCompatibleEndpointReasoning,
maybePromptForInferenceInputCapability: (model) =>
inferenceInputCapability.maybePromptForInferenceInputCapability(model, {
@@ -3767,11 +3744,13 @@ function getSetupNimDeps(): SetupNimDeps {
}
const setupNim = setupNimFlow.createSetupNim(getSetupNimDeps());
-
// ── Step 4: Inference provider ───────────────────────────────────
function getSetupInferenceDeps(): SetupInferenceDeps {
return {
+ checkGatewayRouteCompatibility,
+ withGatewayRouteMutationLock: gatewayRouteMutationLock.withGatewayRouteMutationLock,
+ withSandboxMutationLock: sandboxMutationLock.withSandboxMutationLock,
step,
getGatewayName: () => GATEWAY_NAME,
runOpenshell,
@@ -3779,7 +3758,7 @@ function getSetupInferenceDeps(): SetupInferenceDeps {
verifyInferenceRoute,
verifyOnboardInferenceSmoke,
isNonInteractive,
- updateSandbox: registry.updateSandbox,
+ updateSandbox: registry.reserveSandboxInferenceRoute,
hermesProviderAuth,
getHermesToolGatewayBroker,
providerExistsInGateway,
@@ -4066,7 +4045,7 @@ async function preflightAuthoritativeRebuildTarget(
ensureOpenshellForOnboard((code) =>
fail(`OpenShell component preflight exited with code ${String(code)}`),
),
- inferenceRouteReady: isInferenceRouteReady,
+ inferenceRouteReady: (p, m) => isInferenceRouteReady(authoritativeGateway.name, p, m),
captureForwardList: () => runCaptureOpenshell(["forward", "list"], { ignoreError: true }),
checkPort: (port) => checkPortAvailable(port),
},
@@ -4082,6 +4061,7 @@ async function preflightAuthoritativeRebuildTarget(
// ── Main ─────────────────────────────────────────────────────────
const onboard = onboardEntryOptions.withNonInteractiveEnvironment(runOnboard);
async function runOnboard(opts: OnboardOptions = {}): Promise {
+ setupInferenceFactory.assertNoOpenShellGatewayEndpointOverride();
const requestedToolDisclosure = toolDisclosureFlow.applyOnboardToolDisclosureRequest(
opts.toolDisclosure,
);
@@ -4155,7 +4135,6 @@ async function runOnboard(opts: OnboardOptions = {}): Promise {
console.error(` rm -f "${lockResult.lockFile}"`);
process.exit(1);
}
-
// Stage any pre-fix plaintext credentials.json into process.env so the
// provider upserts later in this run can pick the values up. The file is
// NOT removed here — the secure unlink runs only after onboarding
@@ -4306,13 +4285,17 @@ async function runOnboard(opts: OnboardOptions = {}): Promise {
const recordedSandboxName =
session?.steps?.sandbox?.status === "complete" ? session?.sandboxName || null : null;
-
+ const gatewaySandboxName = resume ? (recordedSandboxName ?? requestedSandboxName) : null;
+ // biome-ignore format: keep src/lib/onboard.ts net-neutral for growth guardrail.
+ const onboardGateway = gatewayBinding.resolveCoreOnboardGatewayBinding({ authoritativeGateway, currentGateway: { name: GATEWAY_NAME, port: GATEWAY_PORT }, resume, sandbox: gatewaySandboxName ? registry.getSandbox(gatewaySandboxName) : null });
+ // biome-ignore format: keep src/lib/onboard.ts net-neutral for growth guardrail.
+ ({ name: GATEWAY_NAME, port: GATEWAY_PORT } = onboardGateway);
+ process.env.OPENSHELL_GATEWAY = GATEWAY_NAME;
console.log("");
console.log(` ${cliDisplayName()} Onboarding`);
if (isNonInteractive()) note(" (non-interactive mode)");
if (resume) note(" (resume mode)");
console.log(" ===================");
-
const explicitSandboxGpuFlag = resolveSandboxGpuFlagFromOptions(opts);
const recordedGpuPassthroughBeforePreflight = session?.gpuPassthrough === true;
type InitialOnboardFlowContext =
@@ -4454,9 +4437,11 @@ async function runOnboard(opts: OnboardOptions = {}): Promise {
sandboxGpuConfig,
gpuPassthrough,
};
-
+ // biome-ignore format: keep src/lib/onboard.ts net-neutral for growth guardrail.
+ const runCoreGatewayOpenshell = setupInferenceFactory.createGatewayScopedOpenshellRunner(runOpenshell, GATEWAY_NAME);
const [providerInferencePhase, sandboxPhase] =
createCoreOnboardFlowPhases({
+ gatewayName: GATEWAY_NAME,
forceProviderSelection: forceProviderSelectionForAgentChange,
...authoritativeRebuildTarget.rebuildProviderFlowOptions(opts, coreFlowContext),
env: process.env,
@@ -4466,9 +4451,21 @@ async function runOnboard(opts: OnboardOptions = {}): Promise {
hermesApiKeyCredentialEnv: HERMES_NOUS_API_KEY_CREDENTIAL_ENV,
},
providerDeps: {
+ checkGatewayRouteCompatibility,
+ preflightGatewayRouteDiscovery,
+ withGatewayRouteMutationLock: gatewayRouteMutationLock.withGatewayRouteMutationLock,
normalizeHermesAuthMethod,
- setupNim: (gpu, sandboxName, agent, recoverProvider) =>
- setupNim(gpu, sandboxName, agent, recoverProvider, opts.rebuildRegistryInferenceRoute),
+ setupNim: (g, s, a, recover, gateway, assertRouteCompatible, canProbeRoute) =>
+ setupNim(
+ g,
+ s,
+ a,
+ recover,
+ opts.rebuildRegistryInferenceRoute,
+ gateway,
+ assertRouteCompatible,
+ canProbeRoute,
+ ),
setupInference,
startRecordedStep,
recordStepComplete,
@@ -4490,9 +4487,10 @@ async function runOnboard(opts: OnboardOptions = {}): Promise {
isInferenceRouteReady,
isRoutedInferenceProvider,
reconcileModelRouter,
- reupsertRoutedProvider: (p, url, ce) => {
+ reupsertRoutedProvider: (gatewayName, p, url, ce) => {
const r = routedInference.upsertRoutedProvider(p, url, ce, {
- upsertProvider,
+ // biome-ignore format: keep src/lib/onboard.ts net-neutral for growth guardrail.
+ upsertProvider: setupInferenceFactory.bindGatewayUpsertProvider(upsertProvider, gatewayName),
hydrateCredentialEnv,
});
return {
@@ -4502,6 +4500,7 @@ async function runOnboard(opts: OnboardOptions = {}): Promise {
status: r.result.status,
};
},
+ reserveSandboxInferenceRoute: registry.reserveSandboxInferenceRoute,
registryUpdateSandbox: (name, updates) => registry.updateSandbox(name, updates),
promptValidatedSandboxName,
assessHost,
@@ -4522,6 +4521,8 @@ async function runOnboard(opts: OnboardOptions = {}): Promise {
rootDir: ROOT,
},
sandboxDeps: {
+ checkGatewayRouteCompatibility,
+ withGatewayRouteMutationLock: gatewayRouteMutationLock.withGatewayRouteMutationLock,
resolvePath: preparedDcodeRuntime.resolveDockerfileProbePath,
agentSupportsWebSearch,
agentSupportsWebSearchProvider,
@@ -4580,6 +4581,7 @@ async function runOnboard(opts: OnboardOptions = {}): Promise {
resume,
recordStateResult: recordCompatibleStateResult,
});
+ setupInferenceFactory.selectGatewayForFollowupOrExit(GATEWAY_NAME, runOpenshell);
const coreContext = coreFlowResult.context;
session = coreContext.session;
sandboxName = coreContext.sandboxName;
@@ -4651,8 +4653,8 @@ async function runOnboard(opts: OnboardOptions = {}): Promise {
loadSession: onboardSession.loadSession,
getActiveSandbox: (name) => registry.getSandbox(name),
mergePolicyMessagingChannels,
- verifyCompatibleEndpointSandboxSmoke: (options) =>
- verifyCompatibleEndpointSandboxSmoke({ ...options, runOpenshell, redact }),
+ // biome-ignore format: keep src/lib/onboard.ts net-neutral for growth guardrail.
+ verifyCompatibleEndpointSandboxSmoke: (options) => verifyCompatibleEndpointSandboxSmoke({ ...options, runOpenshell: runCoreGatewayOpenshell, redact }),
preparePolicyPresetResumeSelection: (name, options) =>
preparePolicyPresetResumeSelection({ policies }, name, options),
arePolicyPresetsApplied,
@@ -4745,12 +4747,10 @@ async function runOnboard(opts: OnboardOptions = {}): Promise {
releaseOnboardLock();
onboardRuntimeBoundary.clear();
onboardTracing.finishOnboardTrace(onboardTrace, traceCompleted);
- if (authoritativeGateway) {
- GATEWAY_NAME = previousGatewayBinding.name;
- GATEWAY_PORT = previousGatewayBinding.port;
- if (previousOpenshellGateway === undefined) delete process.env.OPENSHELL_GATEWAY;
- else process.env.OPENSHELL_GATEWAY = previousOpenshellGateway;
- }
+ GATEWAY_NAME = previousGatewayBinding.name;
+ GATEWAY_PORT = previousGatewayBinding.port;
+ if (previousOpenshellGateway === undefined) delete process.env.OPENSHELL_GATEWAY;
+ else process.env.OPENSHELL_GATEWAY = previousOpenshellGateway;
}
}
diff --git a/src/lib/onboard/gateway-binding.test.ts b/src/lib/onboard/gateway-binding.test.ts
index 97aae82bb80..129d71b7dcc 100644
--- a/src/lib/onboard/gateway-binding.test.ts
+++ b/src/lib/onboard/gateway-binding.test.ts
@@ -19,6 +19,7 @@ import {
BASE_GATEWAY_NAME,
BASE_GATEWAY_STATE_DIR_NAME,
createDynamicGatewayRuntimeHelpers,
+ resolveCoreOnboardGatewayBinding,
resolveGatewayCompatContainerName,
resolveGatewayName,
resolveGatewayPortFromName,
@@ -238,6 +239,61 @@ describe("resolveSandboxGatewayName", () => {
});
});
+describe("resolveCoreOnboardGatewayBinding", () => {
+ const currentGateway = { name: "nemoclaw", port: DEFAULT_GATEWAY_PORT };
+
+ it("prefers the authoritative rebuild handoff when the registry row is gone", () => {
+ expect(
+ resolveCoreOnboardGatewayBinding({
+ authoritativeGateway: { name: "nemoclaw-9090", port: 9090 },
+ currentGateway,
+ resume: true,
+ sandbox: null,
+ }),
+ ).toEqual({ name: "nemoclaw-9090", port: 9090 });
+ });
+
+ it("uses the registered sandbox binding for an ordinary resume", () => {
+ expect(
+ resolveCoreOnboardGatewayBinding({
+ currentGateway,
+ resume: true,
+ sandbox: { gatewayName: "nemoclaw-9090", gatewayPort: 9090 },
+ }),
+ ).toEqual({ name: "nemoclaw-9090", port: 9090 });
+ });
+
+ it("keeps the requested gateway for fresh or pre-registration flows", () => {
+ expect(
+ resolveCoreOnboardGatewayBinding({
+ currentGateway: { name: "nemoclaw-9191", port: 9191 },
+ resume: false,
+ sandbox: { gatewayPort: 9090 },
+ }),
+ ).toEqual({ name: "nemoclaw-9191", port: 9191 });
+ expect(
+ resolveCoreOnboardGatewayBinding({
+ currentGateway: { name: "nemoclaw-9191", port: 9191 },
+ resume: true,
+ sandbox: null,
+ }),
+ ).toEqual({ name: "nemoclaw-9191", port: 9191 });
+ });
+
+ it("uses the default for legacy rows and rejects invalid persisted bindings", () => {
+ expect(resolveCoreOnboardGatewayBinding({ currentGateway, resume: true, sandbox: {} })).toEqual(
+ { name: BASE_GATEWAY_NAME, port: DEFAULT_GATEWAY_PORT },
+ );
+ expect(() =>
+ resolveCoreOnboardGatewayBinding({
+ currentGateway,
+ resume: true,
+ sandbox: { gatewayName: "../other" },
+ }),
+ ).toThrow(/Invalid persisted sandbox gateway binding/);
+ });
+});
+
describe("docker-driver compat container is gateway-port scoped (#4422)", () => {
function withTempState(
fn: (paths: { gatewayBin: string; sandboxBin: string; stateDir: string }) => T,
diff --git a/src/lib/onboard/gateway-binding.ts b/src/lib/onboard/gateway-binding.ts
index 9d247985bff..f5808b8fe77 100644
--- a/src/lib/onboard/gateway-binding.ts
+++ b/src/lib/onboard/gateway-binding.ts
@@ -141,6 +141,21 @@ export function resolveSandboxGatewayName(
throw new Error(`Invalid persisted sandbox gateway binding (${detail.join(", ")})`);
}
+/** Resolve one attempt-wide onboarding target without overriding an authoritative rebuild. */
+export function resolveCoreOnboardGatewayBinding(options: {
+ authoritativeGateway?: { name: string; port: number } | null;
+ currentGateway: { name: string; port: number };
+ resume: boolean;
+ sandbox: SandboxGatewayBinding | null | undefined;
+}): { name: string; port: number } {
+ if (options.authoritativeGateway) return { ...options.authoritativeGateway };
+ if (!options.resume || !options.sandbox) return { ...options.currentGateway };
+ const name = resolveSandboxGatewayName(options.sandbox);
+ const port = resolveGatewayPortFromName(name);
+ if (port === null) throw new Error(`Invalid resolved onboarding gateway name: ${name}`);
+ return { name, port };
+}
+
/**
* Resolve the Docker-driver gateway state directory leaf name for a gateway
* port. The state dir holds the gateway pid file and runtime marker, so a
diff --git a/src/lib/onboard/gateway-provider-metadata.test.ts b/src/lib/onboard/gateway-provider-metadata.test.ts
index e59dcd72869..d0032203659 100644
--- a/src/lib/onboard/gateway-provider-metadata.test.ts
+++ b/src/lib/onboard/gateway-provider-metadata.test.ts
@@ -109,6 +109,22 @@ describe("gateway provider metadata", () => {
});
});
+ it("scopes provider inspection to an explicit non-default gateway", () => {
+ const runOpenshell = vi.fn(() => ({ status: 0, stdout: COMPLETE_OUTPUT }));
+
+ expect(
+ readGatewayProviderMetadata("compatible-endpoint", runOpenshell, "nemoclaw-9090"),
+ ).toEqual(parseGatewayProviderMetadata(COMPLETE_OUTPUT));
+ expect(runOpenshell).toHaveBeenCalledWith(
+ ["provider", "get", "-g", "nemoclaw-9090", "compatible-endpoint"],
+ {
+ ignoreError: true,
+ suppressOutput: true,
+ stdio: ["ignore", "pipe", "pipe"],
+ },
+ );
+ });
+
it("accepts providers with no credential or config bindings", () => {
expect(
parseGatewayProviderMetadata(
diff --git a/src/lib/onboard/gateway-provider-metadata.ts b/src/lib/onboard/gateway-provider-metadata.ts
index 488952d8a9f..c78d0fe776b 100644
--- a/src/lib/onboard/gateway-provider-metadata.ts
+++ b/src/lib/onboard/gateway-provider-metadata.ts
@@ -153,10 +153,14 @@ export function parseGatewayProviderMetadata(output: string): GatewayProviderMet
export function readGatewayProviderMetadata(
name: string,
runOpenshell: GatewayProviderRunner,
+ gatewayName?: string | null,
): GatewayProviderMetadata | null {
if (!isSafeIdentifier(name, MAX_PROVIDER_NAME_LENGTH)) return null;
- const result = runOpenshell(["provider", "get", name], {
+ const args = ["provider", "get"];
+ if (gatewayName) args.push("-g", gatewayName);
+ args.push(name);
+ const result = runOpenshell(args, {
ignoreError: true,
suppressOutput: true,
stdio: ["ignore", "pipe", "pipe"],
diff --git a/src/lib/onboard/inference-route.test.ts b/src/lib/onboard/inference-route.test.ts
index e624491f79f..b7f90c7055b 100644
--- a/src/lib/onboard/inference-route.test.ts
+++ b/src/lib/onboard/inference-route.test.ts
@@ -29,7 +29,9 @@ describe("verifyInferenceRoute", () => {
gatewayRoute("compatible-endpoint", "test-model"),
);
- expect(() => helpers.verifyInferenceRoute("compatible-endpoint", "test-model")).not.toThrow();
+ expect(() =>
+ helpers.verifyInferenceRoute("nemoclaw", "compatible-endpoint", "test-model"),
+ ).not.toThrow();
});
it("rejects a different live gateway route after provider recreation (#6114)", () => {
@@ -39,9 +41,9 @@ describe("verifyInferenceRoute", () => {
}) as never);
const helpers = createInferenceRouteHelpers(() => gatewayRoute("openai-api", "other-model"));
- expect(() => helpers.verifyInferenceRoute("compatible-endpoint", "test-model")).toThrow(
- "process.exit(1)",
- );
+ expect(() =>
+ helpers.verifyInferenceRoute("nemoclaw", "compatible-endpoint", "test-model"),
+ ).toThrow("process.exit(1)");
expect(exit).toHaveBeenCalledWith(1);
expect(errors.mock.calls.flat().join("\n")).toContain(
"does not match provider 'compatible-endpoint' and model 'test-model'",
diff --git a/src/lib/onboard/inference-route.ts b/src/lib/onboard/inference-route.ts
index af378656bce..810f2464c5c 100644
--- a/src/lib/onboard/inference-route.ts
+++ b/src/lib/onboard/inference-route.ts
@@ -2,13 +2,23 @@
// SPDX-License-Identifier: Apache-2.0
import { parseGatewayInference } from "../inference/config";
+import {
+ type CurrentGatewayRouteCompatibilityCheck,
+ type CurrentGatewayRouteDiscoveryPreflight,
+ checkGatewayRouteCompatibility as checkGatewayRouteCompatibilityForRegistry,
+ preflightGatewayRouteDiscovery as preflightGatewayRouteDiscoveryForRegistry,
+} from "../inference/gateway-route-compatibility";
+import { listSandboxes } from "../state/registry";
type RunCaptureOpenshell = (args: string[], options?: { ignoreError?: boolean }) => string | null;
-export function createInferenceRouteHelpers(runCaptureOpenshell: RunCaptureOpenshell) {
- function verifyInferenceRoute(provider: string, model: string): void {
+export function createInferenceRouteHelpers(
+ runCaptureOpenshell: RunCaptureOpenshell,
+ listSandboxesFn: typeof listSandboxes = listSandboxes,
+) {
+ function verifyInferenceRoute(gatewayName: string, provider: string, model: string): void {
const live = parseGatewayInference(
- runCaptureOpenshell(["inference", "get"], { ignoreError: true }),
+ runCaptureOpenshell(["inference", "get", "-g", gatewayName], { ignoreError: true }),
);
if (!live) {
console.error(" OpenShell inference route was not configured.");
@@ -22,12 +32,29 @@ export function createInferenceRouteHelpers(runCaptureOpenshell: RunCaptureOpens
}
}
- function isInferenceRouteReady(provider: string, model: string): boolean {
+ function isInferenceRouteReady(gatewayName: string, provider: string, model: string): boolean {
const live = parseGatewayInference(
- runCaptureOpenshell(["inference", "get"], { ignoreError: true }),
+ runCaptureOpenshell(["inference", "get", "-g", gatewayName], { ignoreError: true }),
);
return Boolean(live && live.provider === provider && live.model === model);
}
- return { verifyInferenceRoute, isInferenceRouteReady };
+ const checkGatewayRouteCompatibility: CurrentGatewayRouteCompatibilityCheck = (request) =>
+ checkGatewayRouteCompatibilityForRegistry({
+ ...request,
+ sandboxes: listSandboxesFn().sandboxes,
+ });
+
+ const preflightGatewayRouteDiscovery: CurrentGatewayRouteDiscoveryPreflight = (request) =>
+ preflightGatewayRouteDiscoveryForRegistry({
+ ...request,
+ sandboxes: listSandboxesFn().sandboxes,
+ });
+
+ return {
+ verifyInferenceRoute,
+ isInferenceRouteReady,
+ checkGatewayRouteCompatibility,
+ preflightGatewayRouteDiscovery,
+ };
}
diff --git a/src/lib/onboard/machine/core-flow-phases.test.ts b/src/lib/onboard/machine/core-flow-phases.test.ts
index 84082dd39a2..7fc4b99ee45 100644
--- a/src/lib/onboard/machine/core-flow-phases.test.ts
+++ b/src/lib/onboard/machine/core-flow-phases.test.ts
@@ -75,6 +75,7 @@ function createPhases(
} = {},
) {
return createCoreOnboardFlowPhases({
+ gatewayName: "nemoclaw",
forceProviderSelection: false,
env: {},
constants: {
@@ -83,6 +84,17 @@ function createPhases(
hermesApiKeyCredentialEnv: "HERMES_API_KEY",
},
providerDeps: {
+ checkGatewayRouteCompatibility: () => ({ ok: true }),
+ preflightGatewayRouteDiscovery: () => ({
+ ok: true,
+ requiredModel: null,
+ requiredEndpointUrl: null,
+ requiredInferenceApi: null,
+ }),
+ withGatewayRouteMutationLock: async (
+ _gatewayName: string,
+ operation: () => Promise | T,
+ ) => await operation(),
normalizeHermesAuthMethod: (value) =>
value === "oauth" || value === "api_key" ? value : null,
setupNim: vi.fn(async () => ({
@@ -103,10 +115,16 @@ function createPhases(
),
toSessionUpdates: (updates) => updates as SessionUpdates,
skippedStepMessage: vi.fn(),
- ensureResumeProviderReady: vi.fn(async () => ({
- forceInferenceSetup: false,
- credentialEnv: null,
- })),
+ ensureResumeProviderReady: vi.fn(
+ async (
+ _gatewayName: string,
+ _provider: string | null | undefined,
+ _credentialEnv: string | null | undefined,
+ ) => ({
+ forceInferenceSetup: false,
+ credentialEnv: null,
+ }),
+ ),
isResumeProviderSurfaceReady: vi.fn(() => true),
recordStateSkipped: vi.fn(async () => createSession()),
recordRepairEvent: vi.fn(async () => createSession()),
@@ -117,10 +135,14 @@ function createPhases(
isNonInteractive: () => true,
getOpenshellBinary: () => "openshell",
needsBedrockRuntimeAdapter: () => false,
- isInferenceRouteReady: () => false,
+ isInferenceRouteReady: (_gatewayName, _provider, _model) => false,
isRoutedInferenceProvider: () => false,
reconcileModelRouter: vi.fn(async () => undefined),
- reupsertRoutedProvider: () => ({ ok: true, endpointUrl: "https://example.test/v1" }),
+ reupsertRoutedProvider: (_gatewayName, _provider, _endpointUrl, _credentialEnv) => ({
+ ok: true,
+ endpointUrl: "https://example.test/v1",
+ }),
+ reserveSandboxInferenceRoute: vi.fn(() => true),
registryUpdateSandbox: vi.fn(),
promptValidatedSandboxName: vi.fn(async () => "my-sandbox"),
assessHost: () => ({ memoryGb: 64 }),
@@ -153,7 +175,17 @@ function createPhases(
getDcodeSelectionDrift: () => ({ changed: false, unknown: false }),
hasSandboxGpuDrift: () => false,
getSandboxHermesToolGateways: () => [],
- getSandboxRegistryEntry: () => null,
+ getSandboxRegistryEntry: () => ({
+ name: "my-sandbox",
+ provider: "nim",
+ model: "nvidia/test",
+ endpointUrl: "https://example.test/v1",
+ credentialEnv: "NVIDIA_INFERENCE_API_KEY",
+ preferredInferenceApi: "chat",
+ gatewayName: "nemoclaw",
+ gpuEnabled: false,
+ policies: [],
+ }),
normalizeHermesToolGatewaySelections: (value) => (Array.isArray(value) ? value : []),
stringSetsEqual: (left, right) =>
left.length === right.length && left.every((item) => right.includes(item)),
@@ -188,6 +220,11 @@ function createPhases(
throw new Error(`exit ${code}`);
}) as (code: number) => never,
...overrides.sandboxDeps,
+ checkGatewayRouteCompatibility:
+ overrides.sandboxDeps?.checkGatewayRouteCompatibility ?? (() => ({ ok: true })),
+ withGatewayRouteMutationLock:
+ overrides.sandboxDeps?.withGatewayRouteMutationLock ??
+ (async (_gatewayName: string, operation: () => Promise | T) => await operation()),
},
});
}
@@ -261,6 +298,9 @@ describe("core onboard flow phases", () => {
"my-sandbox",
{ name: "openclaw" },
false,
+ "nemoclaw",
+ expect.any(Function),
+ expect.any(Function),
);
});
@@ -268,13 +308,26 @@ describe("core onboard flow phases", () => {
const setupInference = vi.fn(async () => ({ ok: true as const }));
const [providerPhase, sandboxPhase] = createPhases({
providerDeps: {
- ensureResumeProviderReady: vi.fn(async () => ({
+ ensureResumeProviderReady: vi.fn(async (_gatewayName, _provider, _credentialEnv) => ({
forceInferenceSetup: false,
credentialEnv: "HERMES_API_KEY",
})),
- isInferenceRouteReady: () => true,
+ isInferenceRouteReady: (_gatewayName, _provider, _model) => true,
setupInference,
},
+ sandboxDeps: {
+ getSandboxRegistryEntry: () => ({
+ name: "my-sandbox",
+ provider: "hermes",
+ model: "nvidia/test",
+ endpointUrl: null,
+ credentialEnv: "HERMES_API_KEY",
+ preferredInferenceApi: null,
+ gatewayName: "nemoclaw",
+ gpuEnabled: false,
+ policies: [],
+ }),
+ },
});
const session = createSession({
model: "nvidia/test",
@@ -307,7 +360,7 @@ describe("core onboard flow phases", () => {
"HERMES_API_KEY",
"api_key",
["nous-web"],
- { allowToolsIncompatible: false },
+ { gatewayName: "nemoclaw", allowToolsIncompatible: false },
);
expect(result.context.hermesToolGateways).toEqual(["nous-web"]);
diff --git a/src/lib/onboard/machine/core-flow-phases.ts b/src/lib/onboard/machine/core-flow-phases.ts
index 73166a68807..7b4b9b582cd 100644
--- a/src/lib/onboard/machine/core-flow-phases.ts
+++ b/src/lib/onboard/machine/core-flow-phases.ts
@@ -25,6 +25,7 @@ export interface CoreOnboardFlowPhaseOptions<
MessagingChannelConfig = unknown,
ResourceProfile = unknown,
> {
+ gatewayName: string;
forceProviderSelection: boolean;
forceInferenceSetup?: boolean;
authoritativeResumeConfig?: boolean;
@@ -56,6 +57,7 @@ export function createCoreOnboardFlowPhases<
): [OnboardSequencePhase, OnboardSequencePhase] {
const providerInferencePhase = createProviderInferencePhase(async (context) => {
const providerInferenceResult = await handleProviderInferenceState({
+ gatewayName: options.gatewayName,
resume: context.resume,
fresh: context.fresh,
session: context.session,
@@ -106,6 +108,7 @@ export function createCoreOnboardFlowPhases<
const sandboxStateResult = await handleSandboxState({
resume: context.resume,
fresh: context.fresh,
+ gatewayName: options.gatewayName,
authoritativeResumeConfig: options.authoritativeResumeConfig,
resumeAgentChanged: options.sandbox.resumeAgentChanged,
session: context.session,
diff --git a/src/lib/onboard/machine/handlers/provider-inference-route-containment.test.ts b/src/lib/onboard/machine/handlers/provider-inference-route-containment.test.ts
new file mode 100644
index 00000000000..994add89efb
--- /dev/null
+++ b/src/lib/onboard/machine/handlers/provider-inference-route-containment.test.ts
@@ -0,0 +1,296 @@
+// 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 {
+ CurrentGatewayRouteCompatibilityCheck,
+ CurrentGatewayRouteDiscoveryPreflight,
+} from "../../../inference/gateway-route-compatibility";
+import { createSession, type Session, type SessionUpdates } from "../../../state/onboard-session";
+import {
+ handleProviderInferenceState,
+ type ProviderInferenceStateOptions,
+ type ProviderSelectionResult,
+} from "./provider-inference";
+
+type Options = ProviderInferenceStateOptions;
+
+const fallbackSelection: ProviderSelectionResult = {
+ model: "nvidia/test",
+ provider: "nvidia-prod",
+ endpointUrl: "https://integrate.api.nvidia.com/v1",
+ credentialEnv: "NVIDIA_INFERENCE_API_KEY",
+ hermesAuthMethod: null,
+ hermesToolGateways: [],
+ preferredInferenceApi: "openai-responses",
+ compatibleEndpointReasoning: null,
+ nimContainer: null,
+};
+
+function createDeps() {
+ const calls = {
+ checkGatewayRouteCompatibility: vi.fn(() => ({
+ ok: true,
+ })),
+ preflightGatewayRouteDiscovery: vi.fn(() => ({
+ ok: true,
+ requiredModel: null,
+ requiredEndpointUrl: null,
+ requiredInferenceApi: null,
+ })),
+ selectionProbe: vi.fn(),
+ setupNim: vi.fn(
+ async (_gpu, _sandbox, _agent, _recover, _gateway, guard) => {
+ guard?.({
+ provider: fallbackSelection.provider,
+ model: fallbackSelection.model,
+ endpointUrl: fallbackSelection.endpointUrl,
+ credentialEnv: fallbackSelection.credentialEnv,
+ preferredInferenceApi: fallbackSelection.preferredInferenceApi,
+ });
+ calls.selectionProbe();
+ return { ...fallbackSelection };
+ },
+ ),
+ setupInference: vi.fn(async () => ({ ok: true as const })),
+ recordStepComplete: vi.fn(async () => createSession()),
+ surfaceReady: vi.fn(() => true),
+ reconcileRouter: vi.fn(async () => undefined),
+ reupsertRoutedProvider: vi.fn(
+ (_provider: string, endpointUrl: string | null, _credentialEnv: string | null) => ({
+ ok: true as const,
+ endpointUrl: endpointUrl ?? "http://host.openshell.internal:4000/v1",
+ }),
+ ),
+ updateSandbox: vi.fn(),
+ log: vi.fn(),
+ error: vi.fn(),
+ exit: vi.fn((code: number): never => {
+ throw new Error(`exit ${code}`);
+ }),
+ };
+ const deps: Options["deps"] = {
+ checkGatewayRouteCompatibility: calls.checkGatewayRouteCompatibility,
+ preflightGatewayRouteDiscovery: calls.preflightGatewayRouteDiscovery,
+ withGatewayRouteMutationLock: async (_gatewayName, operation) => await operation(),
+ normalizeHermesAuthMethod: () => null,
+ setupNim: calls.setupNim,
+ setupInference: calls.setupInference,
+ startRecordedStep: vi.fn(async () => undefined),
+ recordStepComplete: calls.recordStepComplete,
+ toSessionUpdates: (updates: Record) => updates as SessionUpdates,
+ skippedStepMessage: vi.fn(),
+ ensureResumeProviderReady: vi.fn(async (_gatewayName, _provider, credentialEnv) => ({
+ forceInferenceSetup: false,
+ credentialEnv: credentialEnv ?? null,
+ })),
+ isResumeProviderSurfaceReady: calls.surfaceReady,
+ recordStateSkipped: vi.fn(async () => createSession()),
+ recordRepairEvent: vi.fn(async () => createSession()),
+ hydrateCredentialEnv: vi.fn(() => "test-key"),
+ configureCompatibleEndpointReasoning: vi.fn(async () => "false" as const),
+ clearCompatibleEndpointReasoning: () => null,
+ repairLocalInferenceSystemdOverrideOrExit: vi.fn(),
+ isNonInteractive: () => true,
+ getOpenshellBinary: () => "/usr/bin/openshell",
+ needsBedrockRuntimeAdapter: () => false,
+ isInferenceRouteReady: () => true,
+ isRoutedInferenceProvider: (provider) => provider === "nvidia-router",
+ reconcileModelRouter: calls.reconcileRouter,
+ reupsertRoutedProvider: calls.reupsertRoutedProvider,
+ reserveSandboxInferenceRoute: vi.fn(() => true),
+ registryUpdateSandbox: calls.updateSandbox,
+ promptValidatedSandboxName: vi.fn(async () => "target-sandbox"),
+ assessHost: () => ({ cpus: 8 }),
+ formatSandboxBuildEstimateNote: () => "estimate",
+ formatOnboardConfigSummary: ({ provider, model, sandboxName }) =>
+ `summary:${provider}/${model}/${sandboxName}`,
+ promptYesNoOrDefault: vi.fn(async () => true),
+ cliName: () => "nemoclaw",
+ log: calls.log,
+ error: calls.error,
+ exitProcess: calls.exit,
+ deleteEnv: vi.fn(),
+ };
+ return { calls, deps };
+}
+
+function resumeOptions(
+ deps: Options["deps"],
+ session: Session,
+ selectedMessagingChannels: string[] = [],
+): Options {
+ return {
+ gatewayName: "nemoclaw-9090",
+ resume: true,
+ fresh: false,
+ session,
+ gpu: null,
+ sandboxName: "target-sandbox",
+ agent: null,
+ initial: {
+ model: session.model,
+ provider: session.provider,
+ endpointUrl: session.endpointUrl,
+ credentialEnv: session.credentialEnv,
+ hermesAuthMethod: session.hermesAuthMethod,
+ hermesToolGateways: session.hermesToolGateways ?? [],
+ preferredInferenceApi: session.preferredInferenceApi,
+ compatibleEndpointReasoning: session.compatibleEndpointReasoning,
+ nimContainer: session.nimContainer,
+ webSearchConfig: session.webSearchConfig,
+ },
+ selectedMessagingChannels,
+ env: {},
+ constants: {
+ hermesProviderName: "hermes-provider",
+ hermesApiKeyAuthMethod: "api_key",
+ hermesApiKeyCredentialEnv: "NOUS_API_KEY",
+ },
+ deps,
+ };
+}
+
+function rejectRoute(
+ calls: ReturnType["calls"],
+ provider: string,
+ model: string,
+) {
+ calls.checkGatewayRouteCompatibility.mockReturnValue({
+ ok: false,
+ gatewayName: "nemoclaw-9090",
+ sandboxName: "target-sandbox",
+ route: { provider, model },
+ conflicts: [{ sandboxName: "existing-sandbox", reason: "provider-model" }],
+ });
+ calls.preflightGatewayRouteDiscovery.mockReturnValue({
+ ok: false,
+ result: {
+ ok: false,
+ gatewayName: "nemoclaw-9090",
+ sandboxName: "target-sandbox",
+ route: { provider, model },
+ conflicts: [{ sandboxName: "existing-sandbox", reason: "provider-model" }],
+ },
+ });
+}
+
+describe("provider route containment", () => {
+ it("rejects a fresh selection before completing its session step or starting inference", async () => {
+ const { calls, deps } = createDeps();
+ rejectRoute(calls, "nvidia-prod", "nvidia/test");
+ const options = resumeOptions(deps, createSession());
+
+ await expect(
+ handleProviderInferenceState({ ...options, resume: false, sandboxName: null }),
+ ).rejects.toThrow("exit 1");
+
+ expect(calls.setupNim).toHaveBeenCalledOnce();
+ expect(calls.preflightGatewayRouteDiscovery).toHaveBeenCalledWith({
+ gatewayName: "nemoclaw-9090",
+ sandboxName: null,
+ route: {
+ provider: "nvidia-prod",
+ model: "nvidia/test",
+ endpointUrl: "https://integrate.api.nvidia.com/v1",
+ preferredInferenceApi: "openai-responses",
+ credentialEnv: "NVIDIA_INFERENCE_API_KEY",
+ },
+ });
+ expect(calls.checkGatewayRouteCompatibility).not.toHaveBeenCalled();
+ expect(calls.selectionProbe).not.toHaveBeenCalled();
+ expect(calls.recordStepComplete).not.toHaveBeenCalled();
+ expect(calls.surfaceReady).not.toHaveBeenCalled();
+ expect(calls.setupInference).not.toHaveBeenCalled();
+ expect(calls.updateSandbox).not.toHaveBeenCalled();
+ });
+
+ it("blocks routed-provider repair before gateway or registry mutation", async () => {
+ const session = createSession({ provider: "nvidia-router", model: "router/model" });
+ session.steps.provider_selection.status = "complete";
+ const { calls, deps } = createDeps();
+ rejectRoute(calls, "nvidia-router", "router/model");
+
+ await expect(handleProviderInferenceState(resumeOptions(deps, session))).rejects.toThrow(
+ "exit 1",
+ );
+
+ expect(calls.checkGatewayRouteCompatibility).toHaveBeenCalledWith({
+ gatewayName: "nemoclaw-9090",
+ sandboxName: "target-sandbox",
+ route: {
+ provider: "nvidia-router",
+ model: "router/model",
+ endpointUrl: null,
+ preferredInferenceApi: null,
+ },
+ });
+ expect(calls.reconcileRouter).not.toHaveBeenCalled();
+ expect(calls.surfaceReady).not.toHaveBeenCalled();
+ expect(calls.reupsertRoutedProvider).not.toHaveBeenCalled();
+ expect(calls.updateSandbox).not.toHaveBeenCalled();
+ expect(calls.setupInference).not.toHaveBeenCalled();
+ });
+
+ it("rechecks routed repair after waiting for the gateway lock", async () => {
+ const session = createSession({ provider: "nvidia-router", model: "router/model" });
+ session.steps.provider_selection.status = "complete";
+ const { calls, deps } = createDeps();
+ let releaseLock!: () => void;
+ const lockReleased = new Promise((resolve) => {
+ releaseLock = resolve;
+ });
+ let reportLockEntered!: () => void;
+ const lockEntered = new Promise((resolve) => {
+ reportLockEntered = resolve;
+ });
+ deps.withGatewayRouteMutationLock = async (_gatewayName, operation) => {
+ reportLockEntered();
+ await lockReleased;
+ return await operation();
+ };
+
+ const repair = handleProviderInferenceState(resumeOptions(deps, session));
+ await lockEntered;
+ rejectRoute(calls, "nvidia-router", "router/model");
+ releaseLock();
+
+ await expect(repair).rejects.toThrow("exit 1");
+ expect(calls.reconcileRouter).not.toHaveBeenCalled();
+ expect(calls.reupsertRoutedProvider).not.toHaveBeenCalled();
+ expect(calls.updateSandbox).not.toHaveBeenCalled();
+ });
+
+ it("blocks compatible-endpoint messaging refresh before endpoint or gateway work", async () => {
+ const session = createSession({
+ provider: "compatible-endpoint",
+ model: "custom/model",
+ endpointUrl: "https://example.test/v1",
+ credentialEnv: "COMPATIBLE_API_KEY",
+ preferredInferenceApi: "openai-completions",
+ });
+ session.steps.provider_selection.status = "complete";
+ const { calls, deps } = createDeps();
+ rejectRoute(calls, "compatible-endpoint", "custom/model");
+
+ await expect(
+ handleProviderInferenceState(resumeOptions(deps, session, ["telegram"])),
+ ).rejects.toThrow("exit 1");
+
+ expect(calls.checkGatewayRouteCompatibility).toHaveBeenCalledWith({
+ gatewayName: "nemoclaw-9090",
+ sandboxName: "target-sandbox",
+ route: {
+ provider: "compatible-endpoint",
+ model: "custom/model",
+ endpointUrl: "https://example.test/v1",
+ preferredInferenceApi: "openai-completions",
+ },
+ });
+ expect(calls.setupInference).not.toHaveBeenCalled();
+ expect(calls.surfaceReady).not.toHaveBeenCalled();
+ expect(calls.updateSandbox).not.toHaveBeenCalled();
+ expect(calls.error).toHaveBeenCalledWith(expect.stringContaining("existing-sandbox"));
+ });
+});
diff --git a/src/lib/onboard/machine/handlers/provider-inference-route-containment.ts b/src/lib/onboard/machine/handlers/provider-inference-route-containment.ts
new file mode 100644
index 00000000000..e6ff6327a18
--- /dev/null
+++ b/src/lib/onboard/machine/handlers/provider-inference-route-containment.ts
@@ -0,0 +1,64 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import {
+ type CurrentGatewayRouteCompatibilityCheck,
+ type CurrentGatewayRouteDiscoveryPreflight,
+ formatGatewayRouteConflict,
+ type GatewayInferenceRoute,
+ type GatewayRouteDiscoveryConstraints,
+} from "../../../inference/gateway-route-compatibility";
+
+export interface ProviderInferenceRouteContainmentDeps {
+ checkGatewayRouteCompatibility: CurrentGatewayRouteCompatibilityCheck;
+ preflightGatewayRouteDiscovery: CurrentGatewayRouteDiscoveryPreflight;
+ error(message: string): void;
+ exitProcess(code: number): never;
+}
+
+export type ProviderInferenceProbeRoute = Omit & {
+ model: string | null;
+};
+
+export function assertProviderInferenceRouteCompatible(
+ deps: ProviderInferenceRouteContainmentDeps,
+ gatewayName: string,
+ sandboxName: string | null,
+ route: GatewayInferenceRoute,
+): void {
+ const compatibility = deps.checkGatewayRouteCompatibility({ gatewayName, sandboxName, route });
+ if (!compatibility.ok) {
+ deps.error(` Error: ${formatGatewayRouteConflict(compatibility)}`);
+ deps.exitProcess(1);
+ }
+}
+
+/** Constrain discovery from durable peers, then exact-check complete route identities. */
+export function guardProviderInferenceRouteSelection(
+ deps: ProviderInferenceRouteContainmentDeps,
+ gatewayName: string,
+ sandboxName: string | null,
+ route: ProviderInferenceProbeRoute,
+): GatewayRouteDiscoveryConstraints {
+ const model = typeof route.model === "string" && route.model.trim() ? route.model : null;
+ const preflight = deps.preflightGatewayRouteDiscovery({
+ gatewayName,
+ sandboxName,
+ route: { ...route, model },
+ });
+ if (!preflight.ok) {
+ deps.error(` Error: ${formatGatewayRouteConflict(preflight.result)}`);
+ deps.exitProcess(1);
+ }
+ const provider = typeof route.provider === "string" ? route.provider.trim() : "";
+ const completeCustomRoute =
+ !["compatible-endpoint", "compatible-anthropic-endpoint"].includes(provider) ||
+ (typeof route.endpointUrl === "string" &&
+ route.endpointUrl.trim().length > 0 &&
+ typeof route.preferredInferenceApi === "string" &&
+ route.preferredInferenceApi.trim().length > 0);
+ if (model && completeCustomRoute) {
+ assertProviderInferenceRouteCompatible(deps, gatewayName, sandboxName, { ...route, model });
+ }
+ return preflight;
+}
diff --git a/src/lib/onboard/machine/handlers/provider-inference.test.ts b/src/lib/onboard/machine/handlers/provider-inference.test.ts
index 7cea905c692..99f6ee2a8e5 100644
--- a/src/lib/onboard/machine/handlers/provider-inference.test.ts
+++ b/src/lib/onboard/machine/handlers/provider-inference.test.ts
@@ -7,6 +7,10 @@ import path from "node:path";
import { describe, expect, it, vi } from "vitest";
+import type {
+ CurrentGatewayRouteCompatibilityCheck,
+ CurrentGatewayRouteDiscoveryPreflight,
+} from "../../../inference/gateway-route-compatibility";
import { createSession, type Session, type SessionUpdates } from "../../../state/onboard-session";
import { patchStagedDockerfile } from "../../dockerfile-patch";
import { clearCompatibleEndpointReasoning } from "../../reasoning-mode";
@@ -36,13 +40,26 @@ function createDeps(
overrides: Partial["deps"]> = {},
) {
const calls = {
+ checkGatewayRouteCompatibility: vi.fn(() => ({
+ ok: true,
+ })),
+ preflightGatewayRouteDiscovery: vi.fn(() => ({
+ ok: true,
+ requiredModel: null,
+ requiredEndpointUrl: null,
+ requiredInferenceApi: null,
+ })),
setupNim: vi.fn(async () => ({ ...baseSelection })),
setupInference: vi.fn(async () => ({ ok: true as const })),
startStep: vi.fn(async () => undefined),
complete: vi.fn(async () => createSession()),
skipped: vi.fn(),
recoverProvider: vi.fn(
- async (_provider: string | null | undefined, credentialEnv: string | null | undefined) => ({
+ async (
+ _gatewayName: string,
+ _provider: string | null | undefined,
+ credentialEnv: string | null | undefined,
+ ) => ({
forceInferenceSetup: false,
credentialEnv: credentialEnv ?? null,
}),
@@ -52,14 +69,20 @@ function createDeps(
repairEvent: vi.fn(async () => createSession()),
hydrate: vi.fn(),
repair: vi.fn(),
- routeReady: vi.fn(() => false),
+ routeReady: vi.fn((_gatewayName: string, _provider: string, _model: string) => false),
reconcileRouter: vi.fn(async () => undefined),
reupsertRoutedProvider: vi.fn(
- (_provider: string, endpointUrl: string | null, _credentialEnv: string | null) => ({
+ (
+ _gatewayName: string,
+ _provider: string,
+ endpointUrl: string | null,
+ _credentialEnv: string | null,
+ ) => ({
ok: true as const,
endpointUrl: "http://host.openshell.internal:4000/v1",
}),
),
+ reserveRoute: vi.fn(() => true),
updateSandbox: vi.fn(),
promptName: vi.fn(async () => "my-assistant"),
promptYesNo: vi.fn(async () => true),
@@ -73,6 +96,12 @@ function createDeps(
return {
calls,
deps: {
+ checkGatewayRouteCompatibility: calls.checkGatewayRouteCompatibility,
+ preflightGatewayRouteDiscovery: calls.preflightGatewayRouteDiscovery,
+ withGatewayRouteMutationLock: async (
+ _gatewayName: string,
+ operation: () => Promise | T,
+ ) => await operation(),
normalizeHermesAuthMethod: (value: string | null | undefined) =>
value === "oauth" || value === "api_key" ? value : null,
setupNim: calls.setupNim,
@@ -97,6 +126,7 @@ function createDeps(
isRoutedInferenceProvider: (provider: string) => provider === "nvidia-router",
reconcileModelRouter: calls.reconcileRouter,
reupsertRoutedProvider: calls.reupsertRoutedProvider,
+ reserveSandboxInferenceRoute: calls.reserveRoute,
registryUpdateSandbox: calls.updateSandbox,
promptValidatedSandboxName: calls.promptName,
assessHost: () => ({ cpus: 8 }),
@@ -122,6 +152,7 @@ function baseOptions(
session: Session | null = createSession(),
): ProviderInferenceStateOptions {
return {
+ gatewayName: "nemoclaw",
resume: false,
fresh: false,
session,
@@ -158,7 +189,15 @@ describe("handleProviderInferenceState", () => {
const result = await handleProviderInferenceState(baseOptions(deps));
expect(calls.startStep).toHaveBeenNthCalledWith(1, "provider_selection");
- expect(calls.setupNim).toHaveBeenCalledWith({ type: "nvidia" }, null, null, true);
+ expect(calls.setupNim).toHaveBeenCalledWith(
+ { type: "nvidia" },
+ null,
+ null,
+ true,
+ "nemoclaw",
+ expect.any(Function),
+ expect.any(Function),
+ );
expect(calls.promptName).toHaveBeenCalledWith(null);
expect(calls.log).toHaveBeenCalledWith("summary:nvidia-prod/nvidia/test/my-assistant");
expect(calls.startStep).toHaveBeenNthCalledWith(2, "inference", {
@@ -173,7 +212,11 @@ describe("handleProviderInferenceState", () => {
"NVIDIA_INFERENCE_API_KEY",
null,
[],
- { allowToolsIncompatible: false, preferredInferenceApi: "openai-responses" },
+ {
+ gatewayName: "nemoclaw",
+ allowToolsIncompatible: false,
+ preferredInferenceApi: "openai-responses",
+ },
);
expect(calls.deleteEnv).toHaveBeenCalledWith("NVIDIA_INFERENCE_API_KEY");
expect(result).toMatchObject({
@@ -237,7 +280,11 @@ describe("handleProviderInferenceState", () => {
"COMPATIBLE_ANTHROPIC_API_KEY",
null,
[],
- { allowToolsIncompatible: false, preferredInferenceApi: "openai-completions" },
+ {
+ gatewayName: "nemoclaw",
+ allowToolsIncompatible: false,
+ preferredInferenceApi: "openai-completions",
+ },
);
expect(result.preferredInferenceApi).toBe("openai-completions");
});
@@ -275,7 +322,11 @@ describe("handleProviderInferenceState", () => {
"COMPATIBLE_ANTHROPIC_API_KEY",
null,
[],
- { allowToolsIncompatible: false, preferredInferenceApi: "openai-completions" },
+ {
+ gatewayName: "nemoclaw",
+ allowToolsIncompatible: false,
+ preferredInferenceApi: "openai-completions",
+ },
);
expect(calls.complete).toHaveBeenCalledWith(
"inference",
@@ -294,13 +345,15 @@ describe("handleProviderInferenceState", () => {
credentialEnv: "COMPATIBLE_ANTHROPIC_API_KEY",
preferredInferenceApi: "openai-completions",
});
+ const surfaceReady = vi.fn(() => false);
const { deps, calls } = createDeps({
isInferenceRouteReady: vi.fn(() => true),
- isResumeProviderSurfaceReady: vi.fn(() => false),
+ isResumeProviderSurfaceReady: surfaceReady,
});
await handleProviderInferenceState({
...baseOptions(deps, session),
+ gatewayName: "nemoclaw-9090",
resume: true,
authoritativeResumeConfig: true,
agent: { name: "hermes" },
@@ -310,6 +363,13 @@ describe("handleProviderInferenceState", () => {
expect(calls.log).toHaveBeenCalledWith(
" [resume] Refreshing the gateway provider to match the required inference surface.",
);
+ expect(surfaceReady).toHaveBeenCalledWith(
+ "nemoclaw-9090",
+ "compatible-anthropic-endpoint",
+ "openai-completions",
+ "COMPATIBLE_ANTHROPIC_API_KEY",
+ "https://inference-api.nvidia.com",
+ );
expect(calls.setupInference).toHaveBeenCalledWith(
"hermes-custom",
"nvidia/nvidia/nemotron-3-super-v3",
@@ -318,7 +378,11 @@ describe("handleProviderInferenceState", () => {
"COMPATIBLE_ANTHROPIC_API_KEY",
null,
[],
- { allowToolsIncompatible: false, preferredInferenceApi: "openai-completions" },
+ {
+ gatewayName: "nemoclaw-9090",
+ allowToolsIncompatible: false,
+ preferredInferenceApi: "openai-completions",
+ },
);
});
@@ -397,7 +461,15 @@ describe("handleProviderInferenceState", () => {
sandboxName: "dcode-station",
});
- expect(calls.setupNim).toHaveBeenCalledWith({ type: "nvidia" }, "dcode-station", null, false);
+ expect(calls.setupNim).toHaveBeenCalledWith(
+ { type: "nvidia" },
+ "dcode-station",
+ null,
+ false,
+ "nemoclaw",
+ expect.any(Function),
+ expect.any(Function),
+ );
});
it("does not use resume shortcuts when fresh is also set", async () => {
@@ -414,7 +486,15 @@ describe("handleProviderInferenceState", () => {
expect(calls.recoverProvider).not.toHaveBeenCalled();
expect(calls.skipped).not.toHaveBeenCalledWith("provider_selection", expect.anything());
- expect(calls.setupNim).toHaveBeenCalledWith({ type: "nvidia" }, "dcode-station", null, false);
+ expect(calls.setupNim).toHaveBeenCalledWith(
+ { type: "nvidia" },
+ "dcode-station",
+ null,
+ false,
+ "nemoclaw",
+ expect.any(Function),
+ expect.any(Function),
+ );
expect(calls.setupInference).toHaveBeenCalled();
});
@@ -436,7 +516,11 @@ describe("handleProviderInferenceState", () => {
});
expect(calls.setupNim).not.toHaveBeenCalled();
- expect(calls.recoverProvider).toHaveBeenCalledWith("compatible-endpoint", "COMPATIBLE_API_KEY");
+ expect(calls.recoverProvider).toHaveBeenCalledWith(
+ "nemoclaw",
+ "compatible-endpoint",
+ "COMPATIBLE_API_KEY",
+ );
expect(calls.complete).toHaveBeenCalledWith(
"provider_selection",
expect.objectContaining({
@@ -451,6 +535,35 @@ describe("handleProviderInferenceState", () => {
endpointUrl: "https://compatible.example.test/v1",
preferredInferenceApi: "openai-completions",
});
+ expect(calls.reserveRoute).toHaveBeenCalledWith("mcp-rebuild", {
+ provider: "compatible-endpoint",
+ model: "mock/mcp-bridge",
+ endpointUrl: "https://compatible.example.test/v1",
+ credentialEnv: "COMPATIBLE_API_KEY",
+ preferredInferenceApi: "openai-completions",
+ gatewayName: "nemoclaw",
+ });
+ });
+
+ it("stops an authoritative rebuild before inference state when route persistence throws", async () => {
+ const session = createSession({ provider: "openai-api", model: "gpt-test" });
+ const { deps, calls } = createDeps({ isInferenceRouteReady: vi.fn(() => true) });
+ calls.reserveRoute.mockImplementation(() => {
+ throw new Error("registry save failed");
+ });
+
+ await expect(
+ handleProviderInferenceState({
+ ...baseOptions(deps, session),
+ resume: true,
+ authoritativeResumeConfig: true,
+ sandboxName: "failed-rebuild",
+ }),
+ ).rejects.toThrow("registry save failed");
+
+ expect(calls.skipped).not.toHaveBeenCalledWith("inference", expect.anything());
+ expect(calls.recordSkip).not.toHaveBeenCalledWith("inference", expect.anything());
+ expect(calls.complete).not.toHaveBeenCalledWith("inference", expect.anything());
});
it("clears non-NVIDIA provider credentials when inference setup fails", async () => {
@@ -519,7 +632,7 @@ describe("handleProviderInferenceState", () => {
expect(calls.setupNim).not.toHaveBeenCalled();
expect(calls.setupInference).not.toHaveBeenCalled();
- expect(calls.recoverProvider).toHaveBeenCalledWith("ollama-local", null);
+ expect(calls.recoverProvider).toHaveBeenCalledWith("nemoclaw", "ollama-local", null);
expect(calls.skipped).toHaveBeenCalledWith("provider_selection", "ollama-local / llama3.1");
expect(calls.recordSkip).toHaveBeenCalledWith("provider_selection", {
reason: "resume",
@@ -580,7 +693,10 @@ describe("handleProviderInferenceState", () => {
"COMPATIBLE_ANTHROPIC_API_KEY",
null,
[],
- expect.objectContaining({ preferredInferenceApi: "openai-completions" }),
+ expect.objectContaining({
+ gatewayName: "nemoclaw",
+ preferredInferenceApi: "openai-completions",
+ }),
);
// The coerced value is persisted only after the setup succeeded, with the
// inference step record — never with a pre-setup provider_selection write
@@ -722,7 +838,7 @@ describe("handleProviderInferenceState", () => {
"COMPATIBLE_API_KEY",
null,
[],
- { allowToolsIncompatible: false },
+ { gatewayName: "nemoclaw", allowToolsIncompatible: false },
);
});
@@ -758,7 +874,7 @@ describe("handleProviderInferenceState", () => {
"COMPATIBLE_API_KEY",
null,
[],
- { allowToolsIncompatible: false },
+ { gatewayName: "nemoclaw", allowToolsIncompatible: false },
);
});
@@ -795,7 +911,7 @@ describe("handleProviderInferenceState", () => {
"COMPATIBLE_API_KEY",
null,
[],
- { allowToolsIncompatible: false },
+ { gatewayName: "nemoclaw", allowToolsIncompatible: false },
);
expect(calls.log).toHaveBeenCalledWith(
" [resume] Refreshing compatible-endpoint inference route for messaging.",
@@ -868,6 +984,7 @@ describe("handleProviderInferenceState", () => {
null,
[],
{
+ gatewayName: "nemoclaw",
allowToolsIncompatible: false,
skipHostInferenceSmoke: true,
reuseGatewayCredentialWithoutLocalKey: true,
@@ -961,7 +1078,7 @@ describe("handleProviderInferenceState", () => {
"COMPATIBLE_API_KEY",
null,
[],
- { allowToolsIncompatible: false },
+ { gatewayName: "nemoclaw", allowToolsIncompatible: false },
);
expect(calls.log).toHaveBeenCalledWith(
" [resume] Refreshing compatible-endpoint inference route for messaging.",
@@ -980,6 +1097,7 @@ describe("handleProviderInferenceState", () => {
});
expect(calls.reconcileRouter).toHaveBeenCalledOnce();
+ expect(calls.reserveRoute).not.toHaveBeenCalled();
});
// #5974 instance 5: the Model Router Python preflight (`prepareModelRouterVenv`)
@@ -1033,6 +1151,7 @@ describe("handleProviderInferenceState", () => {
expect(calls.reconcileRouter).toHaveBeenCalledOnce();
expect(calls.reupsertRoutedProvider).toHaveBeenCalledWith(
+ "nemoclaw",
"nvidia-router",
"http://localhost:4000/v1",
"NVIDIA_INFERENCE_API_KEY",
@@ -1041,6 +1160,63 @@ describe("handleProviderInferenceState", () => {
expect(result.endpointUrl).toBe("http://host.openshell.internal:4000/v1");
});
+ it("reserves an authoritative routed repair inside the same gateway lock", async () => {
+ const session = createSession({
+ provider: "nvidia-router",
+ model: "router/model",
+ endpointUrl: "http://localhost:4000/v1",
+ credentialEnv: "NVIDIA_INFERENCE_API_KEY",
+ });
+ session.steps.provider_selection.status = "complete";
+ let insideGatewayLock = false;
+ const gatewayLocks: string[] = [];
+ const withGatewayRouteMutationLock: ProviderInferenceStateOptions<
+ Gpu,
+ Agent,
+ Host
+ >["deps"]["withGatewayRouteMutationLock"] = async (gatewayName, operation) => {
+ gatewayLocks.push(gatewayName);
+ insideGatewayLock = true;
+ try {
+ return await operation();
+ } finally {
+ insideGatewayLock = false;
+ }
+ };
+ const { deps, calls } = createDeps({
+ isInferenceRouteReady: vi.fn(() => true),
+ withGatewayRouteMutationLock,
+ });
+ calls.reconcileRouter.mockImplementation(async () => {
+ expect(insideGatewayLock).toBe(true);
+ });
+ calls.reupsertRoutedProvider.mockImplementation(() => {
+ expect(insideGatewayLock).toBe(true);
+ return { ok: true, endpointUrl: "http://host.openshell.internal:4000/v1" };
+ });
+ calls.reserveRoute.mockImplementation(() => {
+ expect(insideGatewayLock).toBe(true);
+ return true;
+ });
+
+ await handleProviderInferenceState({
+ ...baseOptions(deps, session),
+ resume: true,
+ authoritativeResumeConfig: true,
+ sandboxName: "router-rebuild",
+ });
+
+ expect(gatewayLocks).toEqual(["nemoclaw"]);
+ expect(calls.reserveRoute).toHaveBeenCalledWith("router-rebuild", {
+ provider: "nvidia-router",
+ model: "router/model",
+ endpointUrl: "http://host.openshell.internal:4000/v1",
+ credentialEnv: "NVIDIA_INFERENCE_API_KEY",
+ preferredInferenceApi: null,
+ gatewayName: "nemoclaw",
+ });
+ });
+
it("aborts resume when re-upserting the routed provider fails (#4564)", async () => {
const session = createSession({
provider: "nvidia-router",
@@ -1150,7 +1326,11 @@ describe("handleProviderInferenceState", () => {
null,
null,
[],
- { allowToolsIncompatible: true, preferredInferenceApi: "openai-responses" },
+ {
+ gatewayName: "nemoclaw",
+ allowToolsIncompatible: true,
+ preferredInferenceApi: "openai-responses",
+ },
);
});
});
diff --git a/src/lib/onboard/machine/handlers/provider-inference.ts b/src/lib/onboard/machine/handlers/provider-inference.ts
index e943d4d85f4..b6713f26999 100644
--- a/src/lib/onboard/machine/handlers/provider-inference.ts
+++ b/src/lib/onboard/machine/handlers/provider-inference.ts
@@ -2,14 +2,25 @@
// SPDX-License-Identifier: Apache-2.0
import { resolveAgentProviderInferenceApi } from "../../../inference/config";
+import type {
+ CurrentGatewayRouteCompatibilityCheck,
+ CurrentGatewayRouteDiscoveryPreflight,
+ GatewayRouteDiscoveryConstraints,
+} from "../../../inference/gateway-route-compatibility";
import type { WebSearchConfig } from "../../../inference/web-search";
import type { HermesAuthMethod, Session, SessionUpdates } from "../../../state/onboard-session";
import { withInferenceTrace, withProviderSelectionTrace } from "../../tracing";
import { advanceTo, type OnboardStateTransitionResult, retryTo } from "../result";
+import {
+ assertProviderInferenceRouteCompatible,
+ guardProviderInferenceRouteSelection,
+ type ProviderInferenceProbeRoute,
+} from "./provider-inference-route-containment";
export type ProviderInferenceRetry = { retry: "selection" } | { ok: true; retry?: undefined };
export interface ProviderInferenceSetupOptions {
+ gatewayName?: string;
allowToolsIncompatible?: boolean;
skipHostInferenceSmoke?: boolean;
reuseGatewayCredentialWithoutLocalKey?: boolean;
@@ -38,6 +49,7 @@ export interface ProviderSelectionResult {
}
export interface ProviderInferenceStateOptions {
+ gatewayName: string;
resume: boolean;
fresh: boolean;
session: Session | null;
@@ -69,12 +81,23 @@ export interface ProviderInferenceStateOptions {
hermesApiKeyCredentialEnv: string;
};
deps: {
+ checkGatewayRouteCompatibility: CurrentGatewayRouteCompatibilityCheck;
+ preflightGatewayRouteDiscovery: CurrentGatewayRouteDiscoveryPreflight;
+ withGatewayRouteMutationLock(
+ gatewayName: string,
+ operation: () => Promise | T,
+ ): Promise;
normalizeHermesAuthMethod(value: string | null | undefined): HermesAuthMethod | null;
setupNim(
gpu: Gpu,
sandboxName: string | null,
agent: Agent,
allowRecordedProviderRecovery?: boolean,
+ gatewayName?: string,
+ assertRouteCompatible?: (
+ route: ProviderInferenceProbeRoute,
+ ) => GatewayRouteDiscoveryConstraints,
+ canProbeRoute?: (provider: string) => boolean,
): Promise;
setupInference(
sandboxName: string | null,
@@ -94,10 +117,12 @@ export interface ProviderInferenceStateOptions {
toSessionUpdates(updates: Record): SessionUpdates;
skippedStepMessage(stepName: string, detail?: string | null): void;
ensureResumeProviderReady(
+ gatewayName: string,
provider: string | null | undefined,
credentialEnv: string | null | undefined,
): Promise<{ forceInferenceSetup: boolean; credentialEnv: string | null }>;
isResumeProviderSurfaceReady(
+ gatewayName: string,
provider: string | null | undefined,
preferredInferenceApi: string | null | undefined,
credentialEnv: string | null | undefined,
@@ -125,14 +150,26 @@ export interface ProviderInferenceStateOptions {
isNonInteractive(): boolean;
getOpenshellBinary(): string;
needsBedrockRuntimeAdapter(provider: string, endpointUrl: string | null): boolean;
- isInferenceRouteReady(provider: string, model: string): boolean;
+ isInferenceRouteReady(gatewayName: string, provider: string, model: string): boolean;
isRoutedInferenceProvider(provider: string): boolean;
reconcileModelRouter(): Promise;
reupsertRoutedProvider(
+ gatewayName: string,
provider: string,
endpointUrl: string | null,
credentialEnv: string | null,
): { ok: boolean; endpointUrl: string; message?: string; status?: number };
+ reserveSandboxInferenceRoute(
+ sandboxName: string,
+ route: {
+ provider: string;
+ model: string;
+ endpointUrl: string | null;
+ credentialEnv: string | null;
+ preferredInferenceApi: string | null;
+ gatewayName: string;
+ },
+ ): boolean;
registryUpdateSandbox(sandboxName: string, updates: { nimContainer?: string | null }): void;
promptValidatedSandboxName(agent: Agent): Promise;
assessHost(): Host;
@@ -232,6 +269,7 @@ function shouldRefreshCompatibleEndpointRouteForMessaging(
}
export async function handleProviderInferenceState({
+ gatewayName,
resume,
fresh,
session,
@@ -289,7 +327,13 @@ export async function handleProviderInferenceState({
typeof model === "string";
let shouldRecordProviderSelection = false;
if (resumeProviderSelection) {
- const recovery = await deps.ensureResumeProviderReady(provider, credentialEnv);
+ assertProviderInferenceRouteCompatible(deps, gatewayName, sandboxName, {
+ provider,
+ model,
+ endpointUrl,
+ preferredInferenceApi,
+ });
+ const recovery = await deps.ensureResumeProviderReady(gatewayName, provider, credentialEnv);
forceInferenceSetup ||= recovery.forceInferenceSetup;
credentialEnv = recovery.credentialEnv;
// Rebuild may be resuming a legacy session whose step marker was never
@@ -307,6 +351,7 @@ export async function handleProviderInferenceState({
}
if (
!deps.isResumeProviderSurfaceReady(
+ gatewayName,
provider,
preferredInferenceApi,
credentialEnv,
@@ -385,7 +430,27 @@ export async function handleProviderInferenceState({
const selection = await withProviderSelectionTrace(
sandboxName,
(agent as { name?: string } | null)?.name,
- () => deps.setupNim(gpu, sandboxName, agent, !fresh),
+ () =>
+ deps.setupNim(
+ gpu,
+ sandboxName,
+ agent,
+ !fresh,
+ gatewayName,
+ (route) => guardProviderInferenceRouteSelection(deps, gatewayName, sandboxName, route),
+ (provider) =>
+ deps.preflightGatewayRouteDiscovery({
+ gatewayName,
+ sandboxName,
+ route: {
+ provider,
+ model: null,
+ endpointUrl: null,
+ preferredInferenceApi: null,
+ credentialEnv: null,
+ },
+ }).ok,
+ ),
);
model = selection.model;
provider = selection.provider;
@@ -418,6 +483,14 @@ export async function handleProviderInferenceState({
provider,
preferredInferenceApi,
);
+ if (!resumeProviderSelection) {
+ assertProviderInferenceRouteCompatible(deps, gatewayName, sandboxName, {
+ provider,
+ model,
+ endpointUrl,
+ preferredInferenceApi,
+ });
+ }
if (shouldRecordProviderSelection) {
session = await deps.recordStepComplete(
"provider_selection",
@@ -451,7 +524,7 @@ export async function handleProviderInferenceState({
!forceProviderSelection &&
!forceInferenceSetup &&
effectiveResume &&
- deps.isInferenceRouteReady(provider, model);
+ deps.isInferenceRouteReady(gatewayName, provider, model);
if (resumeInference) {
if (provider === constants.hermesProviderName) {
let inferenceResult: ProviderInferenceRetry;
@@ -459,6 +532,7 @@ export async function handleProviderInferenceState({
if (!sandboxName) sandboxName = await deps.promptValidatedSandboxName(agent);
const confirmedSandboxName = sandboxName;
const inferenceOptions = {
+ gatewayName,
allowToolsIncompatible,
...(skipHostInferenceSmoke ? { skipHostInferenceSmoke } : {}),
...(reuseGatewayCredentialWithoutLocalKey
@@ -509,27 +583,88 @@ export async function handleProviderInferenceState({
);
break;
}
- if (deps.isRoutedInferenceProvider(provider)) {
- try {
- await deps.reconcileModelRouter();
- } catch (err) {
- deps.error(
- ` ✗ Failed to reconcile model router: ${err instanceof Error ? err.message : String(err)}`,
- );
- deps.exitProcess(1);
- }
+ const authoritativeReservationName = authoritativeResumeConfig
+ ? (sandboxName ?? (await deps.promptValidatedSandboxName(agent)))
+ : null;
+ if (authoritativeReservationName) sandboxName = authoritativeReservationName;
+ const routedInferenceProvider = deps.isRoutedInferenceProvider(provider);
+ if (routedInferenceProvider) {
// #4564: re-upsert the gateway provider with the sandbox-facing
// endpoint so a stale localhost base URL recorded by an earlier run is
// repaired on resume instead of surviving and breaking inference.local.
- const reupserted = deps.reupsertRoutedProvider(provider, endpointUrl, credentialEnv);
+ const routedRepair = await deps.withGatewayRouteMutationLock(gatewayName, async () => {
+ assertProviderInferenceRouteCompatible(deps, gatewayName, sandboxName, {
+ provider: selectedProvider,
+ model: selectedModel,
+ endpointUrl,
+ preferredInferenceApi,
+ });
+ try {
+ await deps.reconcileModelRouter();
+ } catch (err) {
+ deps.error(
+ ` ✗ Failed to reconcile model router: ${err instanceof Error ? err.message : String(err)}`,
+ );
+ deps.exitProcess(1);
+ }
+ const reupserted = deps.reupsertRoutedProvider(
+ gatewayName,
+ selectedProvider,
+ endpointUrl,
+ credentialEnv,
+ );
+ const reserved =
+ reupserted.ok && authoritativeReservationName
+ ? deps.reserveSandboxInferenceRoute(authoritativeReservationName, {
+ provider: selectedProvider,
+ model: selectedModel,
+ endpointUrl: reupserted.endpointUrl,
+ credentialEnv,
+ preferredInferenceApi,
+ gatewayName,
+ })
+ : null;
+ return { reupserted, reserved };
+ });
+ const { reupserted, reserved } = routedRepair;
if (!reupserted.ok) {
deps.error(
` ${reupserted.message ?? "Failed to update the routed inference provider."}`,
);
deps.exitProcess(reupserted.status ?? 1);
}
+ if (reserved === false) {
+ deps.error(
+ ` Failed to reserve inference route for sandbox '${authoritativeReservationName}'.`,
+ );
+ deps.exitProcess(1);
+ }
endpointUrl = reupserted.endpointUrl;
}
+ if (authoritativeReservationName && !routedInferenceProvider) {
+ const reserved = await deps.withGatewayRouteMutationLock(gatewayName, () => {
+ assertProviderInferenceRouteCompatible(deps, gatewayName, authoritativeReservationName, {
+ provider: selectedProvider,
+ model: selectedModel,
+ endpointUrl,
+ preferredInferenceApi,
+ });
+ return deps.reserveSandboxInferenceRoute(authoritativeReservationName, {
+ provider: selectedProvider,
+ model: selectedModel,
+ endpointUrl,
+ credentialEnv,
+ preferredInferenceApi,
+ gatewayName,
+ });
+ });
+ if (!reserved) {
+ deps.error(
+ ` Failed to reserve inference route for sandbox '${authoritativeReservationName}'.`,
+ );
+ deps.exitProcess(1);
+ }
+ }
deps.skippedStepMessage("inference", `${provider} / ${model}`);
await deps.recordStateSkipped("inference", {
reason: "resume",
@@ -583,6 +718,7 @@ export async function handleProviderInferenceState({
}
const inferenceOptions = {
+ gatewayName,
allowToolsIncompatible,
...(skipHostInferenceSmoke ? { skipHostInferenceSmoke } : {}),
...(reuseGatewayCredentialWithoutLocalKey ? { reuseGatewayCredentialWithoutLocalKey } : {}),
diff --git a/src/lib/onboard/machine/handlers/sandbox-dcode-selection.test.ts b/src/lib/onboard/machine/handlers/sandbox-dcode-selection.test.ts
index cc8aa2f93fd..285afcd467f 100644
--- a/src/lib/onboard/machine/handlers/sandbox-dcode-selection.test.ts
+++ b/src/lib/onboard/machine/handlers/sandbox-dcode-selection.test.ts
@@ -140,19 +140,33 @@ describe("handleSandboxState live DCode selection", () => {
});
expect(getDcodeSelectionDrift).not.toHaveBeenCalled();
+ expect(calls.createSandbox).not.toHaveBeenCalled();
+ expect(calls.updateSandbox).toHaveBeenCalledWith("saved", {
+ pendingRouteReservation: undefined,
+ });
+ });
+
+ it("fails closed for missing registry selection before live reuse (#6311)", async () => {
+ const getDcodeSelectionDrift = vi.fn(() => ({ changed: false, unknown: false }));
+ const { deps, calls } = createDeps({
+ getSandboxReuseState: () => "ready",
+ getDcodeSelectionDrift,
+ getSandboxRegistryEntry: (name) => dcodeRegistryEntry(name, {}),
+ });
+
+ await expect(handleSandboxState(dcodeOptions(deps))).rejects.toThrow("exit 1");
+
expect(calls.createSandbox).not.toHaveBeenCalled();
expect(calls.updateSandbox).not.toHaveBeenCalled();
});
- it.each([
- ["missing fields", {}],
- ["stale", { provider: "old-provider", model: "old-model" }],
- ])("backfills %s registry selection after verified live reuse (#6311)", async (_label, selection) => {
+ it("backfills stale registry selection after verified live reuse (#6311)", async () => {
const getDcodeSelectionDrift = vi.fn(() => ({ changed: false, unknown: false }));
const { deps, calls } = createDeps({
getSandboxReuseState: () => "ready",
getDcodeSelectionDrift,
- getSandboxRegistryEntry: (name) => dcodeRegistryEntry(name, selection),
+ getSandboxRegistryEntry: (name) =>
+ dcodeRegistryEntry(name, { provider: "old-provider", model: "old-model" }),
});
await handleSandboxState(dcodeOptions(deps));
@@ -162,8 +176,8 @@ describe("handleSandboxState live DCode selection", () => {
provider: "provider",
model: "model",
});
- expect(getDcodeSelectionDrift.mock.invocationCallOrder[0]).toBeLessThan(
- calls.updateSandbox.mock.invocationCallOrder[0],
- );
+ expect(calls.updateSandbox).toHaveBeenCalledWith("saved", {
+ pendingRouteReservation: undefined,
+ });
});
});
diff --git a/src/lib/onboard/machine/handlers/sandbox-route-mutation-lock.test.ts b/src/lib/onboard/machine/handlers/sandbox-route-mutation-lock.test.ts
new file mode 100644
index 00000000000..941803dcbdc
--- /dev/null
+++ b/src/lib/onboard/machine/handlers/sandbox-route-mutation-lock.test.ts
@@ -0,0 +1,123 @@
+// 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 { handleSandboxState } from "./sandbox";
+import { baseOptions, createDeps } from "./sandbox-test-fixtures";
+
+describe("sandbox registration route transaction", () => {
+ it("rechecks compatibility after waiting for the gateway lock and before create", async () => {
+ let releaseGateway!: () => void;
+ const gatewayReleased = new Promise((resolve) => {
+ releaseGateway = resolve;
+ });
+ let reportGatewayEntered!: () => void;
+ const gatewayEntered = new Promise((resolve) => {
+ reportGatewayEntered = resolve;
+ });
+ const checkGatewayRouteCompatibility = vi.fn(() => ({
+ ok: false as const,
+ gatewayName: "nemoclaw",
+ sandboxName: "my-assistant",
+ route: { provider: "provider", model: "model" },
+ conflicts: [{ sandboxName: "peer", reason: "provider-model" as const }],
+ }));
+ const { calls, deps } = createDeps({
+ checkGatewayRouteCompatibility,
+ withSandboxMutationLock: async (_sandboxName, operation) => await operation(),
+ withGatewayRouteMutationLock: async (_gatewayName, operation) => {
+ reportGatewayEntered();
+ await gatewayReleased;
+ return await operation();
+ },
+ });
+
+ const onboard = handleSandboxState(baseOptions(deps));
+ await gatewayEntered;
+ expect(checkGatewayRouteCompatibility).not.toHaveBeenCalled();
+ releaseGateway();
+
+ await expect(onboard).rejects.toThrow("exit 1");
+ expect(checkGatewayRouteCompatibility).toHaveBeenCalledWith(
+ expect.objectContaining({ gatewayName: "nemoclaw", sandboxName: null }),
+ );
+ expect(calls.createSandbox).not.toHaveBeenCalled();
+ expect(calls.updateSandbox).not.toHaveBeenCalled();
+ expect(calls.removeSandbox).not.toHaveBeenCalled();
+ expect(calls.startStep).not.toHaveBeenCalled();
+ expect(calls.updateSession).not.toHaveBeenCalled();
+ expect(calls.error).toHaveBeenCalledWith(expect.stringContaining("peer"));
+ });
+
+ it("holds sandbox then gateway locks through sandbox creation and route registration", async () => {
+ const events: string[] = [];
+ const { deps } = createDeps({
+ checkGatewayRouteCompatibility: () => {
+ events.push("guard");
+ return { ok: true };
+ },
+ withSandboxMutationLock: async (_sandboxName, operation) => {
+ events.push("sandbox-lock");
+ return await operation();
+ },
+ withGatewayRouteMutationLock: async (_gatewayName, operation) => {
+ events.push("gateway-lock");
+ return await operation();
+ },
+ createSandbox: async () => {
+ events.push("create");
+ return "my-assistant";
+ },
+ updateSandboxRegistry: () => {
+ events.push("registry");
+ },
+ });
+
+ await expect(handleSandboxState(baseOptions(deps))).resolves.toMatchObject({
+ sandboxName: "my-assistant",
+ });
+ expect(events).toEqual(["sandbox-lock", "gateway-lock", "guard", "create", "registry"]);
+ });
+
+ it("fails when a competing same-name registration changed routes", async () => {
+ const checkGatewayRouteCompatibility = vi.fn((request) =>
+ request.sandboxName === null
+ ? {
+ ok: false as const,
+ gatewayName: "nemoclaw",
+ sandboxName: null,
+ route: { provider: "provider", model: "model" },
+ conflicts: [{ sandboxName: "my-assistant", reason: "provider-model" as const }],
+ }
+ : { ok: true as const },
+ );
+ const { calls, deps } = createDeps({
+ checkGatewayRouteCompatibility,
+ getSandboxRegistryEntry: () => ({
+ name: "my-assistant",
+ provider: "other-provider",
+ model: "other-model",
+ }),
+ });
+
+ await expect(handleSandboxState(baseOptions(deps))).rejects.toThrow("exit 1");
+
+ expect(checkGatewayRouteCompatibility).toHaveBeenCalledWith(
+ expect.objectContaining({ sandboxName: null }),
+ );
+ expect(calls.createSandbox).not.toHaveBeenCalled();
+ expect(calls.updateSandbox).not.toHaveBeenCalled();
+ expect(calls.startStep).not.toHaveBeenCalled();
+ });
+
+ it("fails when the route reservation disappears before creation", async () => {
+ const { calls, deps } = createDeps({ getSandboxRegistryEntry: () => null });
+
+ await expect(handleSandboxState(baseOptions(deps))).rejects.toThrow("exit 1");
+
+ expect(calls.error).toHaveBeenCalledWith(expect.stringContaining("disappeared"));
+ expect(calls.createSandbox).not.toHaveBeenCalled();
+ expect(calls.updateSandbox).not.toHaveBeenCalled();
+ expect(calls.startStep).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/lib/onboard/machine/handlers/sandbox-test-fixtures.ts b/src/lib/onboard/machine/handlers/sandbox-test-fixtures.ts
index c6fd61367a2..8280b538a1c 100644
--- a/src/lib/onboard/machine/handlers/sandbox-test-fixtures.ts
+++ b/src/lib/onboard/machine/handlers/sandbox-test-fixtures.ts
@@ -96,6 +96,7 @@ export function createDeps(
) {
let session = createSession();
const calls = {
+ checkGatewayRouteCompatibility: vi.fn(() => ({ ok: true as const })),
note: vi.fn(),
updateSession: vi.fn((mutator: (value: Session) => Session | void) => {
session = mutator(session) ?? session;
@@ -127,6 +128,17 @@ export function createDeps(
exit: vi.fn((code: number): never => {
throw new Error(`exit ${code}`);
}),
+ withGatewayRouteMutationLock: vi.fn(),
+ };
+ const runWithGatewayRouteMutationLock = async (
+ gatewayName: string,
+ operation: () => Promise | T,
+ ): Promise => {
+ if (overrides.withGatewayRouteMutationLock) {
+ return await overrides.withGatewayRouteMutationLock(gatewayName, operation);
+ }
+ calls.withGatewayRouteMutationLock(gatewayName, operation);
+ return await operation();
};
return {
calls,
@@ -144,6 +156,10 @@ export function createDeps(
getSandboxHermesToolGateways: () => [],
getSandboxRegistryEntry: (name: string) => ({
name,
+ provider: "provider",
+ model: "model",
+ endpointUrl: null,
+ preferredInferenceApi: "openai-completions",
webSearchEnabled: false,
toolDisclosure: "progressive" as const,
fromDockerfile: null,
@@ -180,6 +196,9 @@ export function createDeps(
error: calls.error,
exitProcess: calls.exit,
...overrides,
+ checkGatewayRouteCompatibility:
+ overrides.checkGatewayRouteCompatibility ?? calls.checkGatewayRouteCompatibility,
+ withGatewayRouteMutationLock: runWithGatewayRouteMutationLock,
},
getSession: () => session,
};
@@ -207,6 +226,7 @@ export function baseOptions(
resume: false,
fresh: false,
resumeAgentChanged: false,
+ gatewayName: "nemoclaw",
session,
sandboxName: null,
model: "model",
diff --git a/src/lib/onboard/machine/handlers/sandbox-tool-disclosure.test.ts b/src/lib/onboard/machine/handlers/sandbox-tool-disclosure.test.ts
index 847bf6dd7b4..9c702635c15 100644
--- a/src/lib/onboard/machine/handlers/sandbox-tool-disclosure.test.ts
+++ b/src/lib/onboard/machine/handlers/sandbox-tool-disclosure.test.ts
@@ -11,8 +11,19 @@ vi.mock("../../messaging-channel-setup", () => ({
detectMessagingChannelsFromEnv: vi.fn(() => []),
}));
+const registeredEntry = (name: string, overrides: Record = {}) => ({
+ name,
+ provider: "provider",
+ model: "model",
+ endpointUrl: null,
+ credentialEnv: null,
+ preferredInferenceApi: "openai-completions" as const,
+ gatewayName: "nemoclaw",
+ ...overrides,
+});
+
describe("handleSandboxState tool disclosure", () => {
- it("does not claim an unregistered live sandbox as a managed legacy migration", async () => {
+ it("fails closed without claiming an unregistered live sandbox as a managed migration", async () => {
const session = createSession({ sandboxName: "saved", toolDisclosure: "progressive" });
session.steps.sandbox.status = "complete";
const { deps, calls } = createDeps({
@@ -20,11 +31,13 @@ describe("handleSandboxState tool disclosure", () => {
getSandboxRegistryEntry: () => null,
});
- await handleSandboxState({
- ...baseOptions(deps, session),
- resume: true,
- sandboxName: "saved",
- });
+ await expect(
+ handleSandboxState({
+ ...baseOptions(deps, session),
+ resume: true,
+ sandboxName: "saved",
+ }),
+ ).rejects.toThrow("exit 1");
expect(calls.createSandbox).not.toHaveBeenCalled();
expect(calls.removeSandbox).not.toHaveBeenCalled();
@@ -48,12 +61,12 @@ describe("handleSandboxState tool disclosure", () => {
session.steps.sandbox.status = "complete";
const { deps, calls } = createDeps({
getSandboxReuseState: () => "ready",
- getSandboxRegistryEntry: (name) => ({
- name,
- nemoclawVersion: "0.1.0",
- toolDisclosure: recorded,
- fromDockerfile: null,
- }),
+ getSandboxRegistryEntry: (name) =>
+ registeredEntry(name, {
+ nemoclawVersion: "0.1.0",
+ toolDisclosure: recorded,
+ fromDockerfile: null,
+ }),
});
await handleSandboxState({
@@ -78,11 +91,11 @@ describe("handleSandboxState tool disclosure", () => {
updateSession: vi.fn(
(mutator: (value: Session) => Session | void) => mutator(session) ?? session,
),
- getSandboxRegistryEntry: (name) => ({
- name,
- nemoclawVersion: "0.1.0",
- toolDisclosure: recordedMode,
- }),
+ getSandboxRegistryEntry: (name) =>
+ registeredEntry(name, {
+ nemoclawVersion: "0.1.0",
+ toolDisclosure: recordedMode,
+ }),
});
await handleSandboxState({
@@ -116,11 +129,11 @@ describe("handleSandboxState tool disclosure", () => {
session.steps.sandbox.status = "complete";
const { deps, calls } = createDeps({
getSandboxReuseState: () => "ready",
- getSandboxRegistryEntry: (name) => ({
- name,
- nemoclawVersion: null,
- fromDockerfile: "/tmp/Dockerfile.custom",
- }),
+ getSandboxRegistryEntry: (name) =>
+ registeredEntry(name, {
+ nemoclawVersion: null,
+ fromDockerfile: "/tmp/Dockerfile.custom",
+ }),
});
await handleSandboxState({
@@ -141,23 +154,23 @@ describe("handleSandboxState tool disclosure", () => {
session.steps.sandbox.status = "complete";
const { deps, calls } = createDeps({
getSandboxReuseState: () => "ready",
- getSandboxRegistryEntry: (name) => ({
- name,
- nemoclawVersion: "0.1.0",
- mcp: {
- version: 1,
- bridges: {
- fake: {
- server: "fake",
- agent: "openclaw",
- url: "https://mcp.example.test",
- env: [],
- policyName: "mcp-bridge-fake",
- addedAt: "2026-07-03T00:00:00.000Z",
+ getSandboxRegistryEntry: (name) =>
+ registeredEntry(name, {
+ nemoclawVersion: "0.1.0",
+ mcp: {
+ version: 1,
+ bridges: {
+ fake: {
+ server: "fake",
+ agent: "openclaw",
+ url: "https://mcp.example.test",
+ env: [],
+ policyName: "mcp-bridge-fake",
+ addedAt: "2026-07-03T00:00:00.000Z",
+ },
},
},
- },
- }),
+ }),
});
await handleSandboxState({
diff --git a/src/lib/onboard/machine/handlers/sandbox.test.ts b/src/lib/onboard/machine/handlers/sandbox.test.ts
index ef1ed80aaac..560978b1d6b 100644
--- a/src/lib/onboard/machine/handlers/sandbox.test.ts
+++ b/src/lib/onboard/machine/handlers/sandbox.test.ts
@@ -144,6 +144,17 @@ describe("handleSandboxState", () => {
const recordStateSkipped = vi.fn(async () => skippedSession);
const { deps, calls } = createDeps({
getSandboxReuseState: () => "ready",
+ getSandboxRegistryEntry: () => ({
+ name: "saved",
+ pendingRouteReservation: true,
+ provider: "provider",
+ model: "model",
+ endpointUrl: null,
+ preferredInferenceApi: "openai-completions",
+ toolDisclosure: "progressive",
+ fromDockerfile: null,
+ hermesAuthMethod: null,
+ }),
recordStateSkipped,
});
@@ -154,6 +165,9 @@ describe("handleSandboxState", () => {
});
expect(calls.createSandbox).not.toHaveBeenCalled();
+ expect(calls.updateSandbox).toHaveBeenCalledWith("saved", {
+ pendingRouteReservation: undefined,
+ });
expect(calls.skipped).toHaveBeenCalledWith("sandbox", "saved");
expect(recordStateSkipped).toHaveBeenCalledWith("sandbox", {
reason: "resume",
@@ -228,6 +242,10 @@ describe("handleSandboxState", () => {
getSandboxReuseState: () => "ready",
getSandboxRegistryEntry: (name) => ({
name,
+ provider: "provider",
+ model: "model",
+ endpointUrl: null,
+ preferredInferenceApi: "openai-completions",
nemoclawVersion: "0.1.0",
toolDisclosure: "progressive",
}),
diff --git a/src/lib/onboard/machine/handlers/sandbox.ts b/src/lib/onboard/machine/handlers/sandbox.ts
index 111af3c7536..908addb1456 100644
--- a/src/lib/onboard/machine/handlers/sandbox.ts
+++ b/src/lib/onboard/machine/handlers/sandbox.ts
@@ -1,6 +1,10 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
+import {
+ type CurrentGatewayRouteCompatibilityCheck,
+ formatGatewayRouteConflict,
+} from "../../../inference/gateway-route-compatibility";
import {
parseExplicitWebSearchProvider,
type WebSearchConfig as SharedWebSearchConfig,
@@ -13,7 +17,9 @@ import {
import type { SandboxMessagingPlan } from "../../../messaging/manifest";
import type { HermesAuthMethod, Session, SessionUpdates } from "../../../state/onboard-session";
import type { SandboxEntry } from "../../../state/registry";
+import { getSandboxEntryInference } from "../../../state/registry-entry-view";
import { toolDisclosureOrDefault } from "../../../tool-disclosure";
+import { resolveSandboxGatewayName } from "../../gateway-binding";
import { withSandboxPhaseTrace } from "../../tracing";
import type { SandboxCreateIntent } from "../../types";
import { branchTo, type OnboardStateTransitionResult } from "../result";
@@ -40,6 +46,7 @@ export interface SandboxStateOptions<
/** Internal rebuild mode: null web-search state is an authoritative disable, not a prompt. */
authoritativeResumeConfig?: boolean;
resumeAgentChanged: boolean;
+ gatewayName: string;
session: Session | null;
sandboxName: string | null;
model: string;
@@ -60,6 +67,11 @@ export interface SandboxStateOptions<
rootDir: string;
env: NodeJS.ProcessEnv;
deps: dcodeResume.Deps & {
+ checkGatewayRouteCompatibility: CurrentGatewayRouteCompatibilityCheck;
+ withGatewayRouteMutationLock(
+ gatewayName: string,
+ operation: () => Promise | T,
+ ): Promise;
resolvePath(value: string): string;
agentSupportsWebSearch(
agent: Agent,
@@ -416,39 +428,86 @@ class SandboxStateFlow<
return dcodeResume.preserveManagedDcodeRegistryEntry(this.options, decision);
}
+ private assertGatewayRouteCompatible(sandboxName: string | null): void {
+ const targetEntry = sandboxName ? this.deps.getSandboxRegistryEntry(sandboxName) : null;
+ if (!sandboxName || !targetEntry) {
+ this.failGatewayRouteCheck(
+ ` Error: sandbox route reservation '${sandboxName ?? "unknown"}' disappeared while onboarding was in progress. Retry onboarding.`,
+ );
+ }
+ if (getSandboxEntryInference(targetEntry).kind !== "configured") {
+ this.failGatewayRouteCheck(
+ ` Error: sandbox '${sandboxName}' has incomplete route metadata, so its shared-gateway compatibility cannot be proven. Remove and re-onboard that sandbox.`,
+ );
+ }
+ if (resolveSandboxGatewayName(targetEntry) !== this.options.gatewayName) {
+ this.failGatewayRouteCheck(
+ ` Error: sandbox '${sandboxName}' changed OpenShell gateways while onboarding was in progress. Retry onboarding.`,
+ );
+ }
+ const compatibility = this.deps.checkGatewayRouteCompatibility({
+ gatewayName: this.options.gatewayName,
+ sandboxName: null,
+ route: {
+ provider: this.options.provider,
+ model: this.options.model,
+ endpointUrl: this.options.endpointUrl,
+ preferredInferenceApi: this.options.preferredInferenceApi,
+ credentialEnv: this.options.credentialEnv,
+ },
+ });
+ if (!compatibility.ok) {
+ this.failGatewayRouteCheck(` Error: ${formatGatewayRouteConflict(compatibility)}`);
+ }
+ }
+
+ private failGatewayRouteCheck(message: string): never {
+ this.deps.error(message);
+ this.deps.exitProcess(1);
+ throw new Error("exitProcess returned while aborting an incompatible gateway route");
+ }
+
private async reuseSandbox(
state: SandboxStepState,
): Promise> {
- if (state.webSearchConfig) {
- const provider = webSearchProviderForConfig(
- state.webSearchConfig as unknown as SharedWebSearchConfig,
- );
- this.deps.note(
- ` [resume] Reusing ${webSearchLabelFor(provider)} configuration already baked into the sandbox.`,
+ return this.deps.withGatewayRouteMutationLock(this.options.gatewayName, async () => {
+ this.assertGatewayRouteCompatible(state.sandboxName);
+ if (state.webSearchConfig) {
+ const provider = webSearchProviderForConfig(
+ state.webSearchConfig as unknown as SharedWebSearchConfig,
+ );
+ this.deps.note(
+ ` [resume] Reusing ${webSearchLabelFor(provider)} configuration already baked into the sandbox.`,
+ );
+ }
+ const messaging = reconcileReusedSandboxMessaging(
+ state.session?.messagingPlan ?? null,
+ this.options.agent,
+ this.deps,
);
- }
- const messaging = reconcileReusedSandboxMessaging(
- state.session?.messagingPlan ?? null,
- this.options.agent,
- this.deps,
- );
- if (messaging.changed) {
- this.deps.updateSession((current) => {
- current.messagingPlan = messaging.plan;
- return current;
+ if (messaging.changed) {
+ this.deps.updateSession((current) => {
+ current.messagingPlan = messaging.plan;
+ return current;
+ });
+ }
+ this.backfillReusedSandboxFidelity(state);
+ if (state.sandboxName) {
+ this.deps.updateSandboxRegistry(state.sandboxName, {
+ pendingRouteReservation: undefined,
+ });
+ }
+ this.deps.skippedStepMessage("sandbox", state.sandboxName);
+ const skippedSession = await this.deps.recordStateSkipped("sandbox", {
+ reason: "resume",
+ sandboxName: state.sandboxName,
});
- }
- this.backfillReusedSandboxFidelity(state);
- this.deps.skippedStepMessage("sandbox", state.sandboxName);
- const skippedSession = await this.deps.recordStateSkipped("sandbox", {
- reason: "resume",
- sandboxName: state.sandboxName,
+ return {
+ ...state,
+ session: skippedSession,
+ selectedMessagingChannels: messaging.selectedChannels,
+ };
});
- return {
- ...state,
- session: skippedSession,
- selectedMessagingChannels: messaging.selectedChannels,
- };
}
private backfillReusedSandboxFidelity(state: SandboxStepState): void {
@@ -507,68 +566,85 @@ class SandboxStateFlow<
this.options.hermesToolGateways,
);
const resourceProfile = await this.deps.selectResourceProfileForSandbox();
- if (this.options.fresh) {
- this.deps.stopStaleDashboardListenersForSandbox(
- this.deps.listRegistrySandboxes().sandboxes,
+ const createAndRecord = async (): Promise> => {
+ this.assertGatewayRouteCompatible(requestedSandboxName);
+ await applySandboxResumeDecision(decision, state.sandboxName, this.deps);
+ await this.deps.startRecordedStep("sandbox", {
+ provider: this.options.provider,
+ model: this.options.model,
+ });
+ if (this.options.fresh) {
+ this.deps.stopStaleDashboardListenersForSandbox(
+ this.deps.listRegistrySandboxes().sandboxes,
+ requestedSandboxName,
+ );
+ }
+ this.deps.updateSession((current) => {
+ current.messagingPlan = messagingPlan;
+ return current;
+ });
+ const sandboxName = await withSandboxPhaseTrace(
requestedSandboxName,
+ this.options.provider,
+ this.options.model,
+ (this.options.agent as { name?: string } | null)?.name,
+ () =>
+ this.deps.createSandbox(
+ this.options.gpu,
+ this.options.model,
+ this.options.provider,
+ this.options.preferredInferenceApi,
+ requestedSandboxName,
+ state.webSearchConfig,
+ state.selectedMessagingChannels,
+ this.options.fromDockerfile,
+ this.options.agent,
+ this.options.controlUiPort,
+ this.options.sandboxGpuConfig,
+ resourceProfile,
+ effectiveHermesToolGateways,
+ this.options.hermesAuthMethod,
+ {
+ recreate: decision.kind !== "create",
+ toolDisclosure: toolDisclosureOrDefault(state.session?.toolDisclosure),
+ },
+ ),
);
- }
- const sandboxName = await withSandboxPhaseTrace(
- requestedSandboxName,
- this.options.provider,
- this.options.model,
- (this.options.agent as { name?: string } | null)?.name,
- () =>
- this.deps.createSandbox(
- this.options.gpu,
- this.options.model,
- this.options.provider,
- this.options.preferredInferenceApi,
- requestedSandboxName,
- state.webSearchConfig,
- state.selectedMessagingChannels,
- this.options.fromDockerfile,
- this.options.agent,
- this.options.controlUiPort,
- this.options.sandboxGpuConfig,
- resourceProfile,
- effectiveHermesToolGateways,
- this.options.hermesAuthMethod,
- {
- recreate: decision.kind !== "create",
- toolDisclosure: toolDisclosureOrDefault(state.session?.toolDisclosure),
- },
- ),
- );
- // createSandbox() owns the build fingerprint. In particular, reusing an
- // image must not stamp it with the current version and hide build drift.
- const { nemoclawVersion: _builtFingerprint, ...agentRegistryFields } =
- this.deps.getSandboxAgentRegistryFields(this.options.agent, !this.options.fromDockerfile);
- // Preserve the validated route and credential env-var name, never a credential value.
- this.deps.updateSandboxRegistry(sandboxName, {
- model: this.options.model,
- provider: this.options.provider,
- endpointUrl: this.options.endpointUrl,
- credentialEnv: this.options.credentialEnv,
- nimContainer: this.options.nimContainer,
- preferredInferenceApi: this.options.preferredInferenceApi,
- ...agentRegistryFields,
- });
- // Finalization marks the default so a cancelled onboarding cannot leave a
- // partially configured sandbox selected as the default.
- const completedSession = await this.deps.recordStepComplete(
- "sandbox",
- this.deps.toSessionUpdates({
- sandboxName,
- provider: this.options.provider,
+ // createSandbox() owns the build fingerprint. In particular, reusing an
+ // image must not stamp it with the current version and hide build drift.
+ const { nemoclawVersion: _builtFingerprint, ...agentRegistryFields } =
+ this.deps.getSandboxAgentRegistryFields(this.options.agent, !this.options.fromDockerfile);
+ // Preserve the validated route and credential env-var name, never a credential value.
+ this.deps.updateSandboxRegistry(sandboxName, {
model: this.options.model,
+ provider: this.options.provider,
+ endpointUrl: this.options.endpointUrl,
+ credentialEnv: this.options.credentialEnv,
nimContainer: this.options.nimContainer,
- webSearchConfig: state.webSearchConfig,
- messagingPlan,
- hermesToolGateways: effectiveHermesToolGateways,
- }),
- );
- return { ...state, sandboxName, session: completedSession };
+ preferredInferenceApi: this.options.preferredInferenceApi,
+ ...agentRegistryFields,
+ });
+ // Finalization marks the default so a cancelled onboarding cannot leave a
+ // partially configured sandbox selected as the default.
+ const completedSession = await this.deps.recordStepComplete(
+ "sandbox",
+ this.deps.toSessionUpdates({
+ sandboxName,
+ provider: this.options.provider,
+ model: this.options.model,
+ nimContainer: this.options.nimContainer,
+ webSearchConfig: state.webSearchConfig,
+ messagingPlan,
+ hermesToolGateways: effectiveHermesToolGateways,
+ }),
+ );
+ return { ...state, sandboxName, session: completedSession };
+ };
+ const withGatewayLock = () =>
+ this.deps.withGatewayRouteMutationLock(this.options.gatewayName, createAndRecord);
+ return this.deps.withSandboxMutationLock
+ ? this.deps.withSandboxMutationLock(requestedSandboxName, withGatewayLock)
+ : withGatewayLock();
}
private async recreateSandbox(
@@ -592,14 +668,6 @@ class SandboxStateFlow<
state.webSearchConfig as unknown as SharedWebSearchConfig | null,
webSearchConfig as unknown as SharedWebSearchConfig | null,
);
- // Validate the replacement provider before any resume cleanup removes the
- // still-live sandbox from the registry. A bad or missing credential must
- // leave the existing sandbox recoverable.
- await applySandboxResumeDecision(decision, state.sandboxName, this.deps);
- await this.deps.startRecordedStep("sandbox", {
- provider: this.options.provider,
- model: this.options.model,
- });
const requestedSandboxName =
state.sandboxName ?? (await this.deps.promptValidatedSandboxName(this.options.agent));
const messaging = await reconcileSandboxMessaging({
@@ -609,14 +677,9 @@ class SandboxStateFlow<
agent: this.options.agent,
deps: this.deps,
});
- const session = this.deps.updateSession((current) => {
- current.messagingPlan = messaging.plan;
- return current;
- });
return this.createAndRecordSandbox(
{
...state,
- session,
sandboxName: requestedSandboxName,
webSearchConfig,
webSearchConfigChanged,
diff --git a/src/lib/onboard/provider-host-state.test.ts b/src/lib/onboard/provider-host-state.test.ts
index e2f2ec566bd..82693b2460c 100644
--- a/src/lib/onboard/provider-host-state.test.ts
+++ b/src/lib/onboard/provider-host-state.test.ts
@@ -57,6 +57,42 @@ function detectWithDeps(
}
describe("detectInferenceProviderHostState", () => {
+ it("suppresses local endpoint probes when route preflight disallows them (#6315)", () => {
+ const runCapture = vi.fn(() => "{}");
+ const findReachableOllamaHost = vi.fn(() => "127.0.0.1");
+ const deps = buildDeps({
+ runCapture,
+ findReachableOllamaHost,
+ isWsl: vi.fn(() => true),
+ detectWindowsHostOllama: vi.fn(() => ({
+ installed: true,
+ installedPath: "C:\\Ollama\\ollama.exe",
+ loopbackOnly: false,
+ })),
+ });
+
+ const state = detectInferenceProviderHostState({
+ gpu: null,
+ experimental: false,
+ probeOllama: false,
+ probeVllm: false,
+ platform: "linux",
+ env: {},
+ log: () => {},
+ deps,
+ });
+
+ expect(findReachableOllamaHost).not.toHaveBeenCalled();
+ expect(state.ollamaRunning).toBe(false);
+ expect(state.vllmRunning).toBe(false);
+ expect(state.windowsOllamaReachable).toBe(false);
+ expect(
+ runCapture.mock.calls.some(([command]) =>
+ command.join(" ").match(/\/v1\/models|\/api\/tags/),
+ ),
+ ).toBe(false);
+ });
+
it("collects local Ollama and vLLM state into one provider host snapshot", () => {
const deps = buildDeps({
hostCommandExists: vi.fn((command) => command === "ollama"),
diff --git a/src/lib/onboard/provider-host-state.ts b/src/lib/onboard/provider-host-state.ts
index ea7ea688c9f..b127f2a8a5b 100644
--- a/src/lib/onboard/provider-host-state.ts
+++ b/src/lib/onboard/provider-host-state.ts
@@ -18,7 +18,7 @@ import {
type WindowsHostOllamaDockerRequirement,
} from "./local-inference-topology";
import { warnAboutArm64NimImageCompatibility } from "./nim-image-compat-warning";
-import { resolveOllamaInstallMenuEntry, type OllamaInstallMenuResult } from "./ollama-install-menu";
+import { type OllamaInstallMenuResult, resolveOllamaInstallMenuEntry } from "./ollama-install-menu";
import { buildVllmMenuEntries, type VllmMenuEntry } from "./vllm-menu";
import { detectWindowsHostOllama, type WindowsHostOllamaState } from "./windows-host-ollama";
@@ -54,6 +54,8 @@ export interface InferenceProviderHostState {
export interface DetectInferenceProviderHostStateInput {
gpu: InferenceProviderHostGpu | null | undefined;
experimental: boolean;
+ probeOllama?: boolean;
+ probeVllm?: boolean;
platform?: NodeJS.Platform;
env?: NodeJS.ProcessEnv;
log?: (message?: string) => void;
@@ -157,10 +159,10 @@ export function detectInferenceProviderHostState(
const platform = input.platform ?? process.platform;
const isWsl = deps.isWsl({ platform, env: input.env });
const hasOllama = deps.hostCommandExists("ollama");
- const ollamaHost = deps.findReachableOllamaHost();
+ const ollamaHost = input.probeOllama === false ? null : deps.findReachableOllamaHost();
const ollamaRunning = ollamaHost !== null;
const isWindowsHostOllama = ollamaHost === OLLAMA_HOST_DOCKER_INTERNAL;
- const vllmRunning = probeVllmRunning(deps.runCapture);
+ const vllmRunning = input.probeVllm === false ? false : probeVllmRunning(deps.runCapture);
const vllmProfile = deps.detectVllmProfile(input.gpu);
const hasVllmImage = !!(
vllmProfile &&
@@ -171,11 +173,10 @@ export function detectInferenceProviderHostState(
);
const winOllamaState = deps.detectWindowsHostOllama();
const hasWindowsOllama = winOllamaState.installed;
- const windowsOllamaReachable = probeWindowsOllamaReachable({
- isWsl,
- isWindowsHostOllama,
- runCapture: deps.runCapture,
- });
+ const windowsOllamaReachable =
+ input.probeOllama === false
+ ? false
+ : probeWindowsOllamaReachable({ isWsl, isWindowsHostOllama, runCapture: deps.runCapture });
maybeWarnAboutDuplicateOllamaDaemons({
isWsl,
diff --git a/src/lib/onboard/resume-provider-shim.ts b/src/lib/onboard/resume-provider-shim.ts
index f75544c2eec..c3ef1337dd1 100644
--- a/src/lib/onboard/resume-provider-shim.ts
+++ b/src/lib/onboard/resume-provider-shim.ts
@@ -31,7 +31,7 @@ const onboardProviders = require("./providers") as {
// and its `module.exports.resumeProviderShimDeps` is populated.
type OnboardLazy = {
isNonInteractive: ResumeProviderRecoveryDeps["isNonInteractive"];
- providerExistsInGateway: ResumeProviderRecoveryDeps["providerExistsInGateway"];
+ providerExistsInGateway(name: string, gatewayName: string): boolean;
resumeProviderShimDeps: {
isRoutedInferenceProvider: ResumeProviderRecoveryDeps["isRoutedInferenceProvider"];
replaceNamedCredential: ResumeProviderRecoveryDeps["replaceNamedCredential"];
@@ -39,6 +39,7 @@ type OnboardLazy = {
};
export async function ensureResumeProviderReady(
+ gatewayName: string,
provider: string | null | undefined,
credentialEnv: string | null | undefined,
): Promise {
@@ -47,7 +48,7 @@ export async function ensureResumeProviderReady(
remoteProviderConfig: onboardProviders.REMOTE_PROVIDER_CONFIG,
defaultRouteCredentialEnv: DEFAULT_ROUTE_CREDENTIAL_ENV,
isRoutedInferenceProvider: o.resumeProviderShimDeps.isRoutedInferenceProvider,
- providerExistsInGateway: o.providerExistsInGateway,
+ providerExistsInGateway: (name) => o.providerExistsInGateway(name, gatewayName),
hydrateCredentialEnv,
getProviderLabel: onboardProviders.getProviderLabel,
isNonInteractive: o.isNonInteractive,
@@ -61,6 +62,7 @@ export async function ensureResumeProviderReady(
}
export function isResumeProviderSurfaceReady(
+ gatewayName: string,
provider: string | null | undefined,
preferredInferenceApi: string | null | undefined,
credentialEnv: string | null | undefined,
@@ -77,6 +79,7 @@ export function isResumeProviderSurfaceReady(
const metadata = readGatewayProviderMetadata(
provider,
runOpenshell as unknown as Parameters[1],
+ gatewayName,
);
return matchesGatewayProviderBinding(metadata, {
name: provider,
diff --git a/src/lib/onboard/setup-inference-gateway-scope.test.ts b/src/lib/onboard/setup-inference-gateway-scope.test.ts
new file mode 100644
index 00000000000..ac103d14df2
--- /dev/null
+++ b/src/lib/onboard/setup-inference-gateway-scope.test.ts
@@ -0,0 +1,208 @@
+// 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 { createInferenceRouteHelpers } from "./inference-route";
+import {
+ bindGatewayUpsertProvider,
+ createGatewayScopedOpenshellRunner,
+ scopeGatewayOpenshellArgs,
+ selectGatewayForFollowupOrExit,
+} from "./setup-inference";
+
+const GATEWAY = "nemoclaw-9090";
+
+describe("gateway-scoped onboarding OpenShell commands", () => {
+ it.each([
+ [
+ ["provider", "get", "openai-api"],
+ ["provider", "get", "-g", GATEWAY, "openai-api"],
+ ],
+ [
+ ["inference", "set", "--provider", "openai-api", "--model", "gpt-test"],
+ ["inference", "set", "-g", GATEWAY, "--provider", "openai-api", "--model", "gpt-test"],
+ ],
+ [
+ ["sandbox", "provider", "detach", "alpha", "openai-api"],
+ ["sandbox", "provider", "detach", "-g", GATEWAY, "alpha", "openai-api"],
+ ],
+ ])("adds the target gateway to %j", (input, expected) => {
+ expect(scopeGatewayOpenshellArgs(input, GATEWAY)).toEqual(expected);
+ });
+
+ it("targets sandbox execution at the same gateway", () => {
+ expect(
+ scopeGatewayOpenshellArgs(["sandbox", "exec", "-n", "alpha", "--", "true"], GATEWAY),
+ ).toEqual(["sandbox", "exec", "-g", GATEWAY, "-n", "alpha", "--", "true"]);
+ });
+
+ it("does not treat gateway-like sandbox payload arguments as OpenShell options", () => {
+ expect(
+ scopeGatewayOpenshellArgs(
+ [
+ "sandbox",
+ "exec",
+ "-n",
+ "alpha",
+ "--",
+ "tool",
+ "--gateway",
+ "payload-gateway",
+ "--gateway-endpoint=https://payload.example.test",
+ ],
+ GATEWAY,
+ ),
+ ).toEqual([
+ "sandbox",
+ "exec",
+ "-g",
+ GATEWAY,
+ "-n",
+ "alpha",
+ "--",
+ "tool",
+ "--gateway",
+ "payload-gateway",
+ "--gateway-endpoint=https://payload.example.test",
+ ]);
+ });
+
+ it.each([
+ ["--gateway-endpoint", "https://other.example.test"],
+ ["--gateway-endpoint=https://other.example.test"],
+ ])("rejects an explicit endpoint override before the payload separator: %j", (...endpointArgs) => {
+ expect(() =>
+ scopeGatewayOpenshellArgs(["provider", "get", ...endpointArgs, "openai-api"], GATEWAY),
+ ).toThrow(/--gateway-endpoint may bypass the gateway recorded/);
+ });
+
+ it.each([
+ ["-g", GATEWAY],
+ ["--gateway", GATEWAY],
+ [`--gateway=${GATEWAY}`],
+ ])("accepts an identical existing target: %j", (...gatewayArgs) => {
+ const command = ["provider", "list", ...gatewayArgs];
+ expect(scopeGatewayOpenshellArgs(command, GATEWAY)).toEqual(command);
+ });
+
+ it("rejects a conflicting, duplicate, missing, or selection-based target", () => {
+ expect(() =>
+ scopeGatewayOpenshellArgs(["provider", "get", "-g", "nemoclaw", "openai-api"], GATEWAY),
+ ).toThrow(/instead of 'nemoclaw-9090'/);
+ expect(() =>
+ scopeGatewayOpenshellArgs(["inference", "get", "-g", GATEWAY, "--gateway", GATEWAY], GATEWAY),
+ ).toThrow(/multiple gateway targets/);
+ expect(() => scopeGatewayOpenshellArgs(["provider", "list", "-g"], GATEWAY)).toThrow(
+ /instead of 'nemoclaw-9090'/,
+ );
+ expect(() => scopeGatewayOpenshellArgs(["gateway", "select", GATEWAY], GATEWAY)).toThrow(
+ /must not change the selected gateway/,
+ );
+ });
+
+ it("scopes every command sent through the runner without mutating the caller argv", () => {
+ const run = vi.fn((_args: string[], _options?: { ignoreError?: boolean }) => ({ status: 0 }));
+ const scoped = createGatewayScopedOpenshellRunner(run, GATEWAY);
+ const command = ["provider", "delete", "openai-api"];
+ scoped(command, { ignoreError: true });
+ expect(command).toEqual(["provider", "delete", "openai-api"]);
+ expect(run).toHaveBeenCalledWith(["provider", "delete", "-g", GATEWAY, "openai-api"], {
+ ignoreError: true,
+ });
+ });
+
+ it("rejects an ambient endpoint override before creating a scoped runner", () => {
+ const run = vi.fn();
+ expect(() =>
+ createGatewayScopedOpenshellRunner(run, GATEWAY, {
+ OPENSHELL_GATEWAY_ENDPOINT: "https://other.example.test",
+ }),
+ ).toThrow(/OPENSHELL_GATEWAY_ENDPOINT is set/);
+ expect(run).not.toHaveBeenCalled();
+ });
+
+ it("keeps an omitted provider env separate from the bound gateway", () => {
+ const upsert = vi.fn(() => ({ ok: true }));
+ bindGatewayUpsertProvider(upsert, GATEWAY)("openai-api", "openai", "OPENAI_API_KEY", null);
+ expect(upsert).toHaveBeenCalledWith(
+ "openai-api",
+ "openai",
+ "OPENAI_API_KEY",
+ null,
+ undefined,
+ GATEWAY,
+ );
+ });
+
+ it("selects the managed gateway for follow-up commands and fails closed on error", () => {
+ const run = vi.fn().mockReturnValueOnce({ status: 0 }).mockReturnValueOnce({ status: 17 });
+ const error = vi.fn();
+ const exitProcess = vi.fn((code: number): never => {
+ throw new Error(`exit ${code}`);
+ });
+
+ expect(() => selectGatewayForFollowupOrExit(GATEWAY, run, error, exitProcess)).not.toThrow();
+ expect(() => selectGatewayForFollowupOrExit(GATEWAY, run, error, exitProcess)).toThrow(
+ "exit 17",
+ );
+ expect(run).toHaveBeenNthCalledWith(1, ["gateway", "select", GATEWAY], {
+ ignoreError: true,
+ });
+ expect(run).toHaveBeenNthCalledWith(2, ["gateway", "select", GATEWAY], {
+ ignoreError: true,
+ });
+ expect(error).toHaveBeenCalledWith(expect.stringContaining("No follow-up operations"));
+ });
+});
+
+describe("gateway-scoped inference route readers", () => {
+ const output = [
+ "Gateway inference:",
+ " Provider: openai-api",
+ " Model: gpt-test",
+ " Version: 1",
+ ].join("\n");
+
+ it("uses the explicit gateway for verification and readiness", () => {
+ const capture = vi.fn(() => output);
+ const route = createInferenceRouteHelpers(capture);
+
+ route.verifyInferenceRoute(GATEWAY, "openai-api", "gpt-test");
+ expect(route.isInferenceRouteReady(GATEWAY, "openai-api", "gpt-test")).toBe(true);
+ expect(route.isInferenceRouteReady(GATEWAY, "openai-api", "other")).toBe(false);
+ expect(capture).toHaveBeenCalledTimes(3);
+ for (const call of capture.mock.calls) {
+ expect(call).toEqual([["inference", "get", "-g", GATEWAY], { ignoreError: true }]);
+ }
+ });
+
+ it("reads compatibility peers through the injected registry boundary", () => {
+ const listSandboxes = vi.fn(() => ({
+ defaultSandbox: "alpha",
+ sandboxes: [
+ {
+ name: "alpha",
+ gatewayName: GATEWAY,
+ gatewayPort: 9090,
+ provider: "openai-api",
+ model: "gpt-test",
+ gpuEnabled: false,
+ policies: [],
+ },
+ ],
+ }));
+ const route = createInferenceRouteHelpers(
+ vi.fn(() => null),
+ listSandboxes,
+ );
+
+ expect(
+ route.checkGatewayRouteCompatibility({
+ gatewayName: GATEWAY,
+ sandboxName: "alpha",
+ route: { provider: "openai-api", model: "gpt-test" },
+ }),
+ ).toEqual({ ok: true });
+ expect(listSandboxes).toHaveBeenCalledOnce();
+ });
+});
diff --git a/src/lib/onboard/setup-inference-route-containment.test.ts b/src/lib/onboard/setup-inference-route-containment.test.ts
new file mode 100644
index 00000000000..5a7d8e50adb
--- /dev/null
+++ b/src/lib/onboard/setup-inference-route-containment.test.ts
@@ -0,0 +1,167 @@
+// 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 { checkGatewayRouteCompatibility } from "../inference/gateway-route-compatibility";
+import type { SandboxEntry } from "../state/registry";
+import { createSetupInference, type SetupInferenceDeps } from "./setup-inference";
+
+describe("onboard shared gateway route containment", () => {
+ it("rejects a conflict before selecting the gateway or mutating provider state (#6315)", async () => {
+ const events: string[] = [];
+ const runOpenshell = vi.fn(() => {
+ events.push("openshell");
+ return { status: 0 };
+ });
+ const updateSandbox = vi.fn(() => true);
+ const upsertProvider = vi.fn(() => ({ ok: true }));
+ const verifyInferenceRoute = vi.fn();
+ const verifyOnboardInferenceSmoke = vi.fn();
+ const getGatewayName = vi.fn(() => "nemoclaw-9090");
+ const error = vi.fn((message: string) => events.push(`error:${message}`));
+ const exitProcess = vi.fn((code: number): never => {
+ events.push(`exit:${code}`);
+ throw new Error(`exit ${code}`);
+ });
+ const checkGatewayRouteCompatibility = vi.fn(() => {
+ events.push("guard");
+ return {
+ ok: false as const,
+ gatewayName: "nemoclaw-9090",
+ sandboxName: "new-sandbox",
+ route: { provider: "anthropic-prod", model: "claude-new" },
+ conflicts: [{ sandboxName: "stopped-sandbox", reason: "provider-model" as const }],
+ };
+ });
+ const setupInference = createSetupInference({
+ checkGatewayRouteCompatibility,
+ withSandboxMutationLock: async (_sandboxName: string, operation: () => Promise | T) =>
+ await operation(),
+ withGatewayRouteMutationLock: async (
+ _gatewayName: string,
+ operation: () => Promise | T,
+ ) => {
+ events.push("lock");
+ return await operation();
+ },
+ step: () => events.push("step"),
+ getGatewayName,
+ runOpenshell,
+ updateSandbox,
+ upsertProvider,
+ verifyInferenceRoute,
+ verifyOnboardInferenceSmoke,
+ error,
+ exitProcess,
+ } as unknown as SetupInferenceDeps);
+
+ await expect(
+ setupInference(
+ "new-sandbox",
+ "claude-new",
+ "anthropic-prod",
+ "https://api.anthropic.com",
+ "ANTHROPIC_API_KEY",
+ ),
+ ).rejects.toThrow("exit 1");
+
+ expect(events.slice(0, 2)).toEqual(["lock", "guard"]);
+ expect(getGatewayName).toHaveBeenCalledOnce();
+ expect(checkGatewayRouteCompatibility).toHaveBeenCalledWith(
+ expect.objectContaining({ gatewayName: "nemoclaw-9090" }),
+ );
+ expect(runOpenshell).not.toHaveBeenCalled();
+ expect(upsertProvider).not.toHaveBeenCalled();
+ expect(verifyInferenceRoute).not.toHaveBeenCalled();
+ expect(verifyOnboardInferenceSmoke).not.toHaveBeenCalled();
+ expect(updateSandbox).not.toHaveBeenCalled();
+ expect(error).toHaveBeenCalledWith(expect.stringContaining("stopped-sandbox"));
+ expect(exitProcess).toHaveBeenCalledWith(1);
+ });
+
+ it("reserves a fresh route before smoke failure lets another setup mutate it (#6315)", async () => {
+ const reservations: SandboxEntry[] = [];
+ let lockTail = Promise.resolve();
+ const withGatewayRouteMutationLock = async (
+ _gatewayName: string,
+ operation: () => Promise | T,
+ ): Promise => {
+ const previous = lockTail;
+ let release!: () => void;
+ lockTail = new Promise((resolve) => {
+ release = resolve;
+ });
+ await previous;
+ try {
+ return await operation();
+ } finally {
+ release();
+ }
+ };
+ const updateSandbox = vi.fn(
+ (name: string, route: Parameters[1]) => {
+ reservations.push({ name, ...route });
+ return true;
+ },
+ );
+ const runOpenshell = vi.fn(() => ({ status: 0 }));
+ const exitProcess = vi.fn((code: number): never => {
+ throw new Error(`exit ${code}`);
+ });
+ const setupInference = createSetupInference({
+ checkGatewayRouteCompatibility: (
+ request: Parameters[0],
+ ) => checkGatewayRouteCompatibility({ ...request, sandboxes: reservations }),
+ withSandboxMutationLock: async (_sandboxName: string, operation: () => Promise | T) =>
+ await operation(),
+ withGatewayRouteMutationLock,
+ step: vi.fn(),
+ getGatewayName: () => "nemoclaw",
+ runOpenshell,
+ updateSandbox,
+ upsertProvider: vi.fn(() => ({ ok: true })),
+ verifyInferenceRoute: vi.fn(),
+ verifyOnboardInferenceSmoke: vi.fn(() => {
+ throw new Error("smoke failed");
+ }),
+ isNonInteractive: () => true,
+ hermesProviderAuth: { HERMES_PROVIDER_NAME: "hermes-provider" },
+ isRoutedInferenceProvider: () => true,
+ reconcileModelRouter: vi.fn(async () => undefined),
+ routedInference: {
+ upsertRoutedProvider: vi.fn(() => ({
+ ok: true,
+ endpointUrl: "http://router.test/v1",
+ result: { ok: true },
+ })),
+ },
+ hydrateCredentialEnv: vi.fn(() => "secret"),
+ redact: (value: string) => value,
+ compactText: (value: string) => value,
+ log: vi.fn(),
+ error: vi.fn(),
+ exitProcess,
+ } as unknown as SetupInferenceDeps);
+
+ const results = await Promise.allSettled([
+ setupInference("alpha", "model-a", "router-a", "http://router-a.test/v1", "ROUTER_KEY"),
+ setupInference("beta", "model-b", "router-b", "http://router-b.test/v1", "ROUTER_KEY"),
+ ]);
+
+ expect(results).toEqual([
+ { status: "rejected", reason: expect.objectContaining({ message: "smoke failed" }) },
+ { status: "rejected", reason: expect.objectContaining({ message: "exit 1" }) },
+ ]);
+ expect(runOpenshell).toHaveBeenCalledTimes(1);
+ expect(updateSandbox).toHaveBeenCalledWith("alpha", {
+ provider: "router-a",
+ model: "model-a",
+ endpointUrl: "http://router-a.test/v1",
+ credentialEnv: "ROUTER_KEY",
+ preferredInferenceApi: null,
+ gatewayName: "nemoclaw",
+ });
+ expect(reservations).toHaveLength(1);
+ expect(exitProcess).toHaveBeenCalledWith(1);
+ });
+});
diff --git a/src/lib/onboard/setup-inference.ts b/src/lib/onboard/setup-inference.ts
index 0b00e46aa40..19928c41ced 100644
--- a/src/lib/onboard/setup-inference.ts
+++ b/src/lib/onboard/setup-inference.ts
@@ -1,6 +1,20 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
+import {
+ type CurrentGatewayRouteCompatibilityCheck,
+ formatGatewayRouteConflict,
+} from "../inference/gateway-route-compatibility";
+import { withGatewayRouteMutationLock } from "../inference/gateway-route-mutation-lock";
+import {
+ assertNoExplicitOpenShellGatewayEndpoint,
+ assertNoOpenShellGatewayEndpointOverride,
+ type OpenShellGatewayEndpointEnvironment,
+} from "../openshell-gateway-endpoint-guard";
+import { withSandboxMutationLock } from "../state/mcp-lifecycle-lock";
+
+export { assertNoOpenShellGatewayEndpointOverride };
+
import type { HermesAuthMethod } from "./hermes-auth";
import type {
CommonDeps,
@@ -17,20 +31,13 @@ import type { ProviderInferenceSetupOptions } from "./machine/handlers/provider-
type ProviderBranchDeps = Pick<
CommonDeps,
- | "upsertProvider"
- | "verifyInferenceRoute"
- | "verifyOnboardInferenceSmoke"
- | "isNonInteractive"
- | "exitProcess"
- | "error"
- | "log"
+ "verifyOnboardInferenceSmoke" | "isNonInteractive" | "exitProcess" | "error" | "log"
> &
Pick<
HermesDeps,
| "lookup"
| "hermesProviderAuth"
| "getHermesToolGatewayBroker"
- | "providerExistsInGateway"
| "normalizeHermesAuthMethod"
| "resolveHermesNousApiKey"
| "checkHermesProviderStoreReachable"
@@ -65,11 +72,24 @@ type ProviderBranchDeps = Pick<
Pick;
export type SetupInferenceDeps = ProviderBranchDeps & {
+ checkGatewayRouteCompatibility: CurrentGatewayRouteCompatibilityCheck;
+ withGatewayRouteMutationLock: typeof withGatewayRouteMutationLock;
+ withSandboxMutationLock: typeof withSandboxMutationLock;
step: (current: number, total: number, label: string) => void;
getGatewayName: () => string;
runOpenshell: import("./openshell-cli").OpenshellCliHelpers["runOpenshell"];
+ upsertProvider: (
+ name: string,
+ type: string,
+ credentialEnv: string,
+ baseUrl: string | null,
+ env: NodeJS.ProcessEnv | undefined,
+ gatewayName: string,
+ ) => ReturnType;
+ verifyInferenceRoute: (gatewayName: string, provider: string, model: string) => void;
+ providerExistsInGateway: (name: string, gatewayName: string) => boolean;
run: typeof import("../runner").run;
- updateSandbox: CommonDeps["registry"]["updateSandbox"];
+ updateSandbox: typeof import("../state/registry").reserveSandboxInferenceRoute;
localInferenceTimeoutSecs: number;
vllmLocalCredentialEnv: string;
ollamaProxyCredentialEnv: string;
@@ -85,11 +105,79 @@ export type SetupInferenceDeps = ProviderBranchDeps & {
exitProcess: (code: number) => never;
};
-function resolveLocalInferenceRouteApplier(deps: SetupInferenceDeps) {
+export function scopeGatewayOpenshellArgs(args: string[], gatewayName: string): string[] {
+ if (!gatewayName) throw new Error("OpenShell gateway name is required.");
+ assertNoExplicitOpenShellGatewayEndpoint(args);
+ if (args[0] === "gateway" && args[1] === "select") {
+ throw new Error("Gateway-scoped OpenShell operations must not change the selected gateway.");
+ }
+ const providerCommand = args[0] === "inference" || args[0] === "provider";
+ const sandboxCommand = args[0] === "sandbox" && typeof args[1] === "string";
+ const sandboxProviderCommand = sandboxCommand && args[1] === "provider";
+ if (!providerCommand && !sandboxCommand) return [...args];
+ const gatewayFlagIndex = sandboxProviderCommand ? 3 : 2;
+ const separatorIndex = args.indexOf("--");
+ const optionEnd = separatorIndex === -1 ? args.length : separatorIndex;
+ const gatewayTargets = args.slice(0, optionEnd).flatMap((value, index) => {
+ if (index < gatewayFlagIndex) return [];
+ if (value === "-g" || value === "--gateway") return [args[index + 1] ?? ""];
+ return value.startsWith("--gateway=") ? [value.slice("--gateway=".length)] : [];
+ });
+ if (gatewayTargets.length > 1) {
+ throw new Error("OpenShell command contains multiple gateway targets.");
+ }
+ const existingGatewayName = gatewayTargets[0];
+ if (existingGatewayName !== undefined) {
+ if (existingGatewayName !== gatewayName) {
+ throw new Error(
+ `OpenShell command targets gateway '${existingGatewayName}' instead of '${gatewayName}'.`,
+ );
+ }
+ return [...args];
+ }
+ return [...args.slice(0, gatewayFlagIndex), "-g", gatewayName, ...args.slice(gatewayFlagIndex)];
+}
+
+export function createGatewayScopedOpenshellRunner(
+ runOpenshell: (args: string[], ...rest: Rest) => Result,
+ gatewayName: string,
+ env: OpenShellGatewayEndpointEnvironment = process.env,
+): (args: string[], ...rest: Rest) => Result {
+ assertNoOpenShellGatewayEndpointOverride(env);
+ return (args, ...rest) => runOpenshell(scopeGatewayOpenshellArgs(args, gatewayName), ...rest);
+}
+
+export function bindGatewayUpsertProvider(
+ upsertProvider: SetupInferenceDeps["upsertProvider"],
+ gatewayName: string,
+): CommonDeps["upsertProvider"] {
+ return (name, type, credentialEnv, baseUrl, env) =>
+ upsertProvider(name, type, credentialEnv, baseUrl, env, gatewayName);
+}
+
+export function selectGatewayForFollowupOrExit(
+ gatewayName: string,
+ runOpenshell: SetupInferenceDeps["runOpenshell"],
+ error: (message: string) => void = console.error,
+ exitProcess: (code: number) => never = (code) => process.exit(code),
+): void {
+ const selected = runOpenshell(["gateway", "select", gatewayName], { ignoreError: true });
+ if (selected.status === 0) return;
+ error(
+ ` Error: OpenShell could not select managed gateway '${gatewayName}' after onboarding. ` +
+ "No follow-up operations were run against an ambient gateway.",
+ );
+ exitProcess(typeof selected.status === "number" && selected.status !== 0 ? selected.status : 1);
+}
+
+function resolveLocalInferenceRouteApplier(
+ deps: SetupInferenceDeps,
+ runOpenshell: SetupInferenceDeps["runOpenshell"],
+) {
return (
deps.applyLocalInferenceRoute ??
createLocalInferenceRouteApplier({
- runOpenshell: deps.runOpenshell,
+ runOpenshell,
isNonInteractive: deps.isNonInteractive,
promptValidationRecovery: deps.promptValidationRecovery,
classifyApplyFailure: deps.classifyApplyFailure,
@@ -129,137 +217,187 @@ export function createSetupInference(
hermesToolGateways: string[] = [],
options: ProviderInferenceSetupOptions = {},
): Promise {
- deps.step(4, 8, "Setting up inference provider");
- deps.runOpenshell(["gateway", "select", deps.getGatewayName()], { ignoreError: true });
+ const gatewayName = options.gatewayName ?? deps.getGatewayName();
+ const mutateGatewayRoute = (): Promise =>
+ deps.withGatewayRouteMutationLock(gatewayName, async () => {
+ const compatibility = deps.checkGatewayRouteCompatibility({
+ gatewayName,
+ sandboxName,
+ route: {
+ provider,
+ model,
+ endpointUrl,
+ preferredInferenceApi: options.preferredInferenceApi ?? null,
+ },
+ });
+ if (!compatibility.ok) {
+ deps.error(` Error: ${formatGatewayRouteConflict(compatibility)}`);
+ return deps.exitProcess(1);
+ }
+ deps.step(4, 8, "Setting up inference provider");
+ const runGatewayOpenshell = createGatewayScopedOpenshellRunner(
+ deps.runOpenshell,
+ gatewayName,
+ );
+ let routeReserved = false;
+ const reserveRoute = (name: string, selectedProvider: string, selectedModel: string) => {
+ if (routeReserved) return true;
+ const reserved = deps.updateSandbox(name, {
+ provider: selectedProvider,
+ model: selectedModel,
+ endpointUrl,
+ credentialEnv,
+ preferredInferenceApi: options.preferredInferenceApi ?? null,
+ gatewayName,
+ });
+ routeReserved = reserved;
+ return reserved;
+ };
- const commonDeps = {
- runOpenshell: deps.runOpenshell,
- upsertProvider: deps.upsertProvider,
- verifyInferenceRoute: deps.verifyInferenceRoute,
- verifyOnboardInferenceSmoke: deps.verifyOnboardInferenceSmoke,
- isNonInteractive: deps.isNonInteractive,
- registry: { updateSandbox: deps.updateSandbox },
- exitProcess: deps.exitProcess,
- error: deps.error,
- log: deps.log,
- } satisfies CommonDeps;
+ const commonDeps = {
+ runOpenshell: runGatewayOpenshell,
+ upsertProvider: bindGatewayUpsertProvider(deps.upsertProvider, gatewayName),
+ verifyInferenceRoute: (selectedProvider: string, selectedModel: string) => {
+ if (sandboxName) reserveRoute(sandboxName, selectedProvider, selectedModel);
+ deps.verifyInferenceRoute(gatewayName, selectedProvider, selectedModel);
+ },
+ verifyOnboardInferenceSmoke: deps.verifyOnboardInferenceSmoke,
+ isNonInteractive: deps.isNonInteractive,
+ registry: {
+ updateSandbox: (name: string) => reserveRoute(name, provider, model),
+ },
+ exitProcess: deps.exitProcess,
+ error: deps.error,
+ log: deps.log,
+ } satisfies CommonDeps;
- if (provider === deps.hermesProviderAuth.HERMES_PROVIDER_NAME) {
- return inferenceProviders.setupHermesProviderInference(
- {
- sandboxName,
- model,
- provider,
- endpointUrl,
- credentialEnv,
- hermesAuthMethod,
- hermesToolGateways,
- },
- {
- ...commonDeps,
- hermesProviderAuth: deps.hermesProviderAuth,
- getHermesToolGatewayBroker: deps.getHermesToolGatewayBroker,
- providerExistsInGateway: deps.providerExistsInGateway,
- normalizeHermesAuthMethod: deps.normalizeHermesAuthMethod,
- resolveHermesNousApiKey: deps.resolveHermesNousApiKey,
- checkHermesProviderStoreReachable: deps.checkHermesProviderStoreReachable,
- hermesAuthMethodLabel: deps.hermesAuthMethodLabel,
- hermesConstants: deps.hermesConstants,
- requireValue: deps.requireValue,
- redact: deps.redact,
- compactText: deps.compactText,
- lookup: deps.lookup,
- },
- );
- }
+ if (provider === deps.hermesProviderAuth.HERMES_PROVIDER_NAME) {
+ return inferenceProviders.setupHermesProviderInference(
+ {
+ sandboxName,
+ model,
+ provider,
+ endpointUrl,
+ credentialEnv,
+ hermesAuthMethod,
+ hermesToolGateways,
+ },
+ {
+ ...commonDeps,
+ hermesProviderAuth: deps.hermesProviderAuth,
+ getHermesToolGatewayBroker: deps.getHermesToolGatewayBroker,
+ providerExistsInGateway: (name: string) =>
+ deps.providerExistsInGateway(name, gatewayName),
+ normalizeHermesAuthMethod: deps.normalizeHermesAuthMethod,
+ resolveHermesNousApiKey: deps.resolveHermesNousApiKey,
+ checkHermesProviderStoreReachable: deps.checkHermesProviderStoreReachable,
+ hermesAuthMethodLabel: deps.hermesAuthMethodLabel,
+ hermesConstants: deps.hermesConstants,
+ requireValue: deps.requireValue,
+ redact: deps.redact,
+ compactText: deps.compactText,
+ lookup: deps.lookup,
+ },
+ );
+ }
- if (inferenceProviders.isRemoteProviderName(provider)) {
- const outcome = await inferenceProviders.setupRemoteProviderInference(
- {
- sandboxName,
- model,
- provider,
- endpointUrl,
- credentialEnv,
- reuseGatewayCredentialWithoutLocalKey:
- options.reuseGatewayCredentialWithoutLocalKey === true,
- preferredInferenceApi: options.preferredInferenceApi ?? null,
- },
- {
- ...commonDeps,
- REMOTE_PROVIDER_CONFIG: deps.REMOTE_PROVIDER_CONFIG,
- hydrateCredentialEnv: deps.hydrateCredentialEnv,
- promptValidationRecovery: deps.promptValidationRecovery,
- classifyApplyFailure: deps.classifyApplyFailure,
- LOCAL_INFERENCE_TIMEOUT_SECS: deps.localInferenceTimeoutSecs,
- bedrockRuntimeOnboard: deps.bedrockRuntimeOnboard,
- redact: deps.redact,
- compactText: deps.compactText,
- probeOpenAiLikeEndpoint: deps.probeOpenAiLikeEndpoint,
- readGatewayProviderMetadata: deps.readGatewayProviderMetadata,
- deleteGatewayProvider: deps.deleteGatewayProvider,
- },
- );
- if (outcome.done) return outcome.result;
- } else if (provider === "vllm-local") {
- const outcome = await inferenceProviders.setupVllmLocalInference(
- { model, provider },
- {
- ...commonDeps,
- validateLocalProvider: deps.validateLocalProvider,
- getLocalProviderHealthCheck: deps.getLocalProviderHealthCheck,
- getLocalProviderBaseUrl: deps.getLocalProviderBaseUrl,
- applyLocalInferenceRoute: resolveLocalInferenceRouteApplier(deps),
- run: deps.run,
- VLLM_LOCAL_CREDENTIAL_ENV: deps.vllmLocalCredentialEnv,
- },
- );
- if (outcome.done) return outcome.result;
- } else if (provider === "ollama-local") {
- const outcome = await inferenceProviders.setupOllamaLocalInference(
- { model, provider, allowToolsIncompatible: options.allowToolsIncompatible === true },
- {
- ...commonDeps,
- validateLocalProvider: deps.validateLocalProvider,
- getLocalProviderBaseUrl: deps.getLocalProviderBaseUrl,
- applyLocalInferenceRoute: resolveLocalInferenceRouteApplier(deps),
- getOllamaWarmupCommand: deps.getOllamaWarmupCommand,
- run: deps.run,
- shouldFrontOllamaWithProxy: deps.shouldFrontOllamaWithProxy,
- ensureOllamaAuthProxy: deps.ensureOllamaAuthProxy,
- isProxyHealthy: deps.isProxyHealthy,
- getOllamaProxyToken: deps.getOllamaProxyToken,
- persistAndProbeOllamaProxy: deps.persistAndProbeOllamaProxy,
- localInference: deps.localInference,
- OLLAMA_PROXY_CREDENTIAL_ENV: deps.ollamaProxyCredentialEnv,
- },
- );
- if (outcome.done) return outcome.result;
- } else if (deps.isRoutedInferenceProvider(provider)) {
- await inferenceProviders.setupRoutedInference(
- { model, provider, endpointUrl, credentialEnv },
- {
- ...commonDeps,
- reconcileModelRouter: deps.reconcileModelRouter,
- routedInference: deps.routedInference,
- hydrateCredentialEnv: deps.hydrateCredentialEnv,
- redact: deps.redact,
- compactText: deps.compactText,
- },
- );
- } else {
- deps.error(` Unsupported provider configuration: ${provider}`);
- deps.exitProcess(1);
- }
+ if (inferenceProviders.isRemoteProviderName(provider)) {
+ const outcome = await inferenceProviders.setupRemoteProviderInference(
+ {
+ sandboxName,
+ model,
+ provider,
+ endpointUrl,
+ credentialEnv,
+ reuseGatewayCredentialWithoutLocalKey:
+ options.reuseGatewayCredentialWithoutLocalKey === true,
+ preferredInferenceApi: options.preferredInferenceApi ?? null,
+ },
+ {
+ ...commonDeps,
+ REMOTE_PROVIDER_CONFIG: deps.REMOTE_PROVIDER_CONFIG,
+ hydrateCredentialEnv: deps.hydrateCredentialEnv,
+ promptValidationRecovery: deps.promptValidationRecovery,
+ classifyApplyFailure: deps.classifyApplyFailure,
+ LOCAL_INFERENCE_TIMEOUT_SECS: deps.localInferenceTimeoutSecs,
+ bedrockRuntimeOnboard: deps.bedrockRuntimeOnboard,
+ redact: deps.redact,
+ compactText: deps.compactText,
+ probeOpenAiLikeEndpoint: deps.probeOpenAiLikeEndpoint,
+ readGatewayProviderMetadata: deps.readGatewayProviderMetadata,
+ deleteGatewayProvider: deps.deleteGatewayProvider,
+ },
+ );
+ if (outcome.done) return outcome.result;
+ } else if (provider === "vllm-local") {
+ const outcome = await inferenceProviders.setupVllmLocalInference(
+ { model, provider },
+ {
+ ...commonDeps,
+ validateLocalProvider: deps.validateLocalProvider,
+ getLocalProviderHealthCheck: deps.getLocalProviderHealthCheck,
+ getLocalProviderBaseUrl: deps.getLocalProviderBaseUrl,
+ applyLocalInferenceRoute: resolveLocalInferenceRouteApplier(
+ deps,
+ runGatewayOpenshell,
+ ),
+ run: deps.run,
+ VLLM_LOCAL_CREDENTIAL_ENV: deps.vllmLocalCredentialEnv,
+ },
+ );
+ if (outcome.done) return outcome.result;
+ } else if (provider === "ollama-local") {
+ const outcome = await inferenceProviders.setupOllamaLocalInference(
+ { model, provider, allowToolsIncompatible: options.allowToolsIncompatible === true },
+ {
+ ...commonDeps,
+ validateLocalProvider: deps.validateLocalProvider,
+ getLocalProviderBaseUrl: deps.getLocalProviderBaseUrl,
+ applyLocalInferenceRoute: resolveLocalInferenceRouteApplier(
+ deps,
+ runGatewayOpenshell,
+ ),
+ getOllamaWarmupCommand: deps.getOllamaWarmupCommand,
+ run: deps.run,
+ shouldFrontOllamaWithProxy: deps.shouldFrontOllamaWithProxy,
+ ensureOllamaAuthProxy: deps.ensureOllamaAuthProxy,
+ isProxyHealthy: deps.isProxyHealthy,
+ getOllamaProxyToken: deps.getOllamaProxyToken,
+ persistAndProbeOllamaProxy: deps.persistAndProbeOllamaProxy,
+ localInference: deps.localInference,
+ OLLAMA_PROXY_CREDENTIAL_ENV: deps.ollamaProxyCredentialEnv,
+ },
+ );
+ if (outcome.done) return outcome.result;
+ } else if (deps.isRoutedInferenceProvider(provider)) {
+ await inferenceProviders.setupRoutedInference(
+ { model, provider, endpointUrl, credentialEnv },
+ {
+ ...commonDeps,
+ reconcileModelRouter: deps.reconcileModelRouter,
+ routedInference: deps.routedInference,
+ hydrateCredentialEnv: deps.hydrateCredentialEnv,
+ redact: deps.redact,
+ compactText: deps.compactText,
+ },
+ );
+ } else {
+ deps.error(` Unsupported provider configuration: ${provider}`);
+ deps.exitProcess(1);
+ }
- deps.verifyInferenceRoute(provider, model);
- if (options.skipHostInferenceSmoke === true)
- deps.log(" Reusing existing gateway credential; skipping host inference smoke.");
- else deps.verifyOnboardInferenceSmoke({ provider, model, endpointUrl, credentialEnv });
- if (sandboxName) {
- deps.updateSandbox(sandboxName, { model, provider });
- }
- deps.log(` ✓ Inference route set: ${provider} / ${model}`);
- return { ok: true };
+ commonDeps.verifyInferenceRoute(provider, model);
+ if (options.skipHostInferenceSmoke === true)
+ deps.log(" Reusing existing gateway credential; skipping host inference smoke.");
+ else deps.verifyOnboardInferenceSmoke({ provider, model, endpointUrl, credentialEnv });
+ if (sandboxName) {
+ commonDeps.registry.updateSandbox(sandboxName);
+ }
+ deps.log(` ✓ Inference route set: ${provider} / ${model}`);
+ return { ok: true as const };
+ });
+ return sandboxName
+ ? deps.withSandboxMutationLock(sandboxName, mutateGatewayRoute)
+ : mutateGatewayRoute();
};
}
diff --git a/src/lib/onboard/setup-nim-flow.test.ts b/src/lib/onboard/setup-nim-flow.test.ts
index cdc1fdf40ad..a53fe5a3501 100644
--- a/src/lib/onboard/setup-nim-flow.test.ts
+++ b/src/lib/onboard/setup-nim-flow.test.ts
@@ -123,6 +123,8 @@ function makeDeps(overrides: Partial = {}): SetupNimFlowDeps {
handleVllmSelection: async () => unexpected("vLLM selection"),
handleRoutedSelection: async () => unexpected("routed selection"),
coerceAgentInferenceApi: (_agent, preferredInferenceApi) => preferredInferenceApi,
+ resolveAgentInferenceApi: (_agentName, _provider, preferredInferenceApi) =>
+ preferredInferenceApi,
clearCompatibleEndpointReasoning: () => null,
maybePromptForInferenceInputCapability: vi.fn(async () => {}),
};
@@ -165,6 +167,66 @@ describe("createSetupNim", () => {
});
});
+ it("lets a same-gateway route constraint override the Deep Agents default before probing", async () => {
+ const ultra = "nvidia/nemotron-3-ultra-550b-a55b";
+ const sharedModel = "nvidia/nemotron-3-super-120b-a12b";
+ const providerProbe = vi.fn();
+ const select = vi.fn(async (requestedModel: string | null) => requestedModel ?? ultra);
+ const createNvidiaFeaturedModelSession = vi.fn<
+ SetupNimFlowDeps["createNvidiaFeaturedModelSession"]
+ >(() => ({ select }));
+ const routeGuard = vi.fn((route: { model: string | null }) => ({
+ requiredModel: route.model ? null : sharedModel,
+ requiredEndpointUrl: null,
+ requiredInferenceApi: null,
+ }));
+ const handleRemoteProviderSelection = vi.fn(
+ async (_args, state) => {
+ state.provider = "nvidia-prod";
+ state.model = null;
+ state.endpointUrl = "https://integrate.api.nvidia.com/v1";
+ state.credentialEnv = "NVIDIA_INFERENCE_API_KEY";
+ state.assertRouteCompatible?.();
+ state.model = await state.nvidiaFeaturedModels!.select(
+ typeof state.model === "string" ? state.model : null,
+ null,
+ true,
+ );
+ state.assertRouteCompatible?.();
+ providerProbe(state.model);
+ return "selected";
+ },
+ );
+ const setupNim = createSetupNim(
+ makeDeps({
+ isNonInteractive: () => true,
+ getNonInteractiveProvider: () => "build",
+ createNvidiaFeaturedModelSession,
+ handleRemoteProviderSelection,
+ }),
+ );
+ const dcodeAgent = {
+ name: "langchain-deepagents-code",
+ inference: { default_model: ultra },
+ } as AgentDefinition;
+
+ const result = await setupNim(null, "dcode", dcodeAgent, true, null, "nemoclaw", routeGuard);
+
+ expect(createNvidiaFeaturedModelSession).toHaveBeenCalledWith({
+ defaultModel: ultra,
+ writeLine: expect.any(Function),
+ });
+ expect(select).toHaveBeenCalledWith(sharedModel, null, true);
+ expect(routeGuard.mock.calls[0]?.[0]).toMatchObject({ model: null });
+ expect(routeGuard.mock.calls.slice(1).map(([route]) => route.model)).toEqual([
+ sharedModel,
+ sharedModel,
+ sharedModel,
+ ]);
+ expect(providerProbe).toHaveBeenCalledWith(sharedModel);
+ expect(result.model).toBe(sharedModel);
+ });
+
it("announces detected Ollama but still prompts and defaults to NVIDIA Endpoints (#6245)", async () => {
vi.stubEnv("NEMOCLAW_PROVIDER", "");
const step = vi.fn();
@@ -198,13 +260,14 @@ describe("createSetupNim", () => {
}),
);
- const result = await setupNim(null);
+ const result = await setupNim(null, null, null, true, null, "nemoclaw-9090");
expect(step).toHaveBeenCalledWith(3, 8, "Configuring inference provider");
expect(log).toHaveBeenCalledWith(" Detected local inference option: Ollama");
expect(prompt).toHaveBeenCalledOnce();
expect(prompt).toHaveBeenCalledWith(" Choose [1]: ");
expect(handleRemoteProviderSelection).toHaveBeenCalledOnce();
+ expect(handleRemoteProviderSelection.mock.calls[0]?.[0].gatewayName).toBe("nemoclaw-9090");
expect(maybePromptForInferenceInputCapability).toHaveBeenCalledWith(
"nvidia/nemotron-3-super-120b-a12b",
);
@@ -251,6 +314,172 @@ describe("createSetupNim", () => {
expect(result).toMatchObject({ model: "final-model", provider: "nvidia-prod" });
});
+ it("suppresses unrelated local endpoint probes for an explicit remote selection (#6315)", async () => {
+ const detectInferenceProviderHostState = vi.fn(() => makeHostState());
+ const canProbeRoute = vi.fn(() => true);
+ const handleRemoteProviderSelection = vi.fn(
+ async (_args, state) => {
+ state.model = "gpt-test";
+ state.provider = "openai-api";
+ state.endpointUrl = "https://api.openai.com/v1";
+ state.credentialEnv = "OPENAI_API_KEY";
+ return "selected";
+ },
+ );
+ const setupNim = createSetupNim(
+ makeDeps({
+ isNonInteractive: () => true,
+ getNonInteractiveProvider: () => "openai",
+ detectInferenceProviderHostState,
+ handleRemoteProviderSelection,
+ }),
+ );
+
+ await setupNim(null, null, null, true, null, "nemoclaw", undefined, canProbeRoute);
+
+ expect(detectInferenceProviderHostState).toHaveBeenCalledWith({
+ gpu: null,
+ experimental: false,
+ probeOllama: false,
+ probeVllm: false,
+ });
+ expect(canProbeRoute).not.toHaveBeenCalled();
+ });
+
+ it("checks shared-gateway compatibility before interactive local discovery probes (#6315)", async () => {
+ const events: string[] = [];
+ const canProbeRoute = vi.fn((provider: string) => {
+ events.push(`preflight:${provider}`);
+ return false;
+ });
+ const detectInferenceProviderHostState = vi.fn((input) => {
+ events.push(`detect:${String(input.probeOllama)}:${String(input.probeVllm)}`);
+ return makeHostState();
+ });
+ const handleRemoteProviderSelection = vi.fn(
+ async (_args, state) => {
+ state.model = "nvidia/test";
+ state.provider = "nvidia-prod";
+ state.endpointUrl = "https://integrate.api.nvidia.com/v1";
+ state.credentialEnv = "NVIDIA_INFERENCE_API_KEY";
+ return "selected";
+ },
+ );
+ const setupNim = createSetupNim(
+ makeDeps({ detectInferenceProviderHostState, handleRemoteProviderSelection }),
+ );
+
+ await setupNim(null, null, null, true, null, "nemoclaw", undefined, canProbeRoute);
+
+ expect(events).toEqual([
+ "preflight:ollama-local",
+ "preflight:vllm-local",
+ "detect:false:false",
+ ]);
+ });
+
+ it("rejects a known local route before host detection when its model conflicts (#6315)", async () => {
+ const detectInferenceProviderHostState = vi.fn(() => makeHostState());
+ const routeGuard = vi.fn(() => {
+ throw new Error("route conflict");
+ });
+ const setupNim = createSetupNim(
+ makeDeps({
+ isNonInteractive: () => true,
+ getNonInteractiveProvider: () => "ollama",
+ getNonInteractiveModel: () => "conflict/model",
+ detectInferenceProviderHostState,
+ }),
+ );
+
+ await expect(setupNim(null, null, null, true, null, "nemoclaw", routeGuard)).rejects.toThrow(
+ "route conflict",
+ );
+ expect(routeGuard).toHaveBeenCalledWith(
+ expect.objectContaining({ provider: "ollama-local", model: "conflict/model" }),
+ );
+ expect(detectInferenceProviderHostState).not.toHaveBeenCalled();
+ });
+
+ it("applies same-gateway discovery constraints before a provider probe (#6315)", async () => {
+ const providerProbe = vi.fn();
+ const routeGuard = vi.fn(
+ (route: { model: string | null; preferredInferenceApi?: string | null }) => ({
+ requiredModel: route.model ? null : "shared/model",
+ requiredEndpointUrl: "https://shared.example.test/v1",
+ requiredInferenceApi: route.preferredInferenceApi ? null : "openai-responses",
+ }),
+ );
+ const handleRemoteProviderSelection = vi.fn(
+ async (_args, state) => {
+ state.provider = "compatible-endpoint";
+ state.model = null;
+ state.endpointUrl = "https://shared.example.test/v1";
+ state.credentialEnv = "COMPATIBLE_API_KEY";
+ state.preferredInferenceApi = null;
+ state.assertRouteCompatible?.();
+ expect(state.model).toBe("shared/model");
+ expect(state.preferredInferenceApi).toBe("openai-responses");
+ providerProbe();
+ return "selected";
+ },
+ );
+ const setupNim = createSetupNim(
+ makeDeps({
+ isNonInteractive: () => true,
+ getNonInteractiveProvider: () => "custom",
+ handleRemoteProviderSelection,
+ }),
+ );
+
+ const result = await setupNim(null, null, null, true, null, "nemoclaw", routeGuard);
+
+ expect(providerProbe).toHaveBeenCalledOnce();
+ expect(routeGuard).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ provider: "compatible-endpoint",
+ model: "shared/model",
+ preferredInferenceApi: "openai-responses",
+ }),
+ );
+ expect(result.model).toBe("shared/model");
+ });
+
+ it("guards custom Anthropic routes with the final Hermes API identity (#6315)", async () => {
+ const agent = { name: "hermes" } as AgentDefinition;
+ const routeGuard = vi.fn((route) => {
+ expect(route.preferredInferenceApi).toBe("openai-completions");
+ return { requiredModel: null, requiredEndpointUrl: null, requiredInferenceApi: null };
+ });
+ const handleRemoteProviderSelection = vi.fn(
+ async (_args, state) => {
+ state.provider = "compatible-anthropic-endpoint";
+ state.model = "anthropic/model";
+ state.endpointUrl = "https://anthropic.example.test";
+ state.credentialEnv = "ANTHROPIC_COMPATIBLE_API_KEY";
+ state.preferredInferenceApi = "anthropic-messages";
+ state.assertRouteCompatible?.();
+ return "selected";
+ },
+ );
+ const setupNim = createSetupNim(
+ makeDeps({
+ isNonInteractive: () => true,
+ getNonInteractiveProvider: () => "anthropicCompatible",
+ resolveAgentInferenceApi: (agentName, provider, preferredInferenceApi) =>
+ agentName === "hermes" && provider === "compatible-anthropic-endpoint"
+ ? "openai-completions"
+ : preferredInferenceApi,
+ handleRemoteProviderSelection,
+ }),
+ );
+
+ const result = await setupNim(null, null, agent, true, null, "nemoclaw", routeGuard);
+
+ expect(routeGuard).toHaveBeenCalled();
+ expect(result.preferredInferenceApi).toBe("openai-completions");
+ });
+
it("recovers a recorded provider and model without prompting in non-interactive mode (#6245)", async () => {
const prompt = vi.fn(async () => unexpected("interactive provider prompt"));
const note = vi.fn();
@@ -373,9 +602,24 @@ describe("createSetupNim", () => {
it("continues from a successful managed vLLM install into provider selection (#6245)", async () => {
const profile = { name: "DGX Spark" } as VllmProfile;
const prompt = vi.fn(async () => unexpected("provider prompt"));
- const installVllm = vi.fn(async () => ({ ok: true }));
+ const detectInferenceProviderHostState = vi.fn(() =>
+ makeHostState({
+ vllmProfile: profile,
+ hasVllmImage: true,
+ vllmEntries: [{ key: "install-vllm", label: "Start vLLM (DGX Spark)" }],
+ }),
+ );
+ const installVllm = vi.fn(async (_profile, options) => {
+ options.beforeInstall?.("vllm-model");
+ return { ok: true };
+ });
+ const routeGuard = vi.fn(() => ({
+ requiredModel: null,
+ requiredEndpointUrl: null,
+ requiredInferenceApi: null,
+ }));
const handleVllmSelection = vi.fn(async (state) => {
- state.model = "vllm-model";
+ expect(state.model).toBe("vllm-model");
state.provider = "vllm";
state.endpointUrl = "http://127.0.0.1:8000/v1";
state.credentialEnv = null;
@@ -387,25 +631,34 @@ describe("createSetupNim", () => {
isNonInteractive: () => true,
getNonInteractiveProvider: () => "install-vllm",
prompt,
- detectInferenceProviderHostState: () =>
- makeHostState({
- vllmProfile: profile,
- hasVllmImage: true,
- vllmEntries: [{ key: "install-vllm", label: "Start vLLM (DGX Spark)" }],
- }),
+ detectInferenceProviderHostState,
installVllm,
handleVllmSelection,
}),
);
- const result = await setupNim(null);
+ const result = await setupNim(null, null, null, true, null, "nemoclaw", routeGuard);
expect(installVllm).toHaveBeenCalledWith(profile, {
hasImage: true,
nonInteractive: true,
promptFn: prompt,
+ beforeInstall: expect.any(Function),
});
expect(prompt).not.toHaveBeenCalled();
+ expect(detectInferenceProviderHostState).toHaveBeenCalledWith({
+ gpu: null,
+ experimental: false,
+ probeOllama: false,
+ probeVllm: true,
+ });
+ expect(routeGuard).toHaveBeenCalledWith({
+ provider: "vllm-local",
+ model: "vllm-model",
+ endpointUrl: null,
+ preferredInferenceApi: "openai-completions",
+ credentialEnv: null,
+ });
expect(handleVllmSelection).toHaveBeenCalledOnce();
expect(result).toMatchObject({
model: "vllm-model",
diff --git a/src/lib/onboard/setup-nim-flow.ts b/src/lib/onboard/setup-nim-flow.ts
index b419ea910a7..a3e46bd2591 100644
--- a/src/lib/onboard/setup-nim-flow.ts
+++ b/src/lib/onboard/setup-nim-flow.ts
@@ -6,16 +6,19 @@ import {
resolveAgentDefaultCloudModel,
resolveAgentProviderInferenceApi,
} from "../inference/config";
+import type { GatewayRouteDiscoveryConstraints } from "../inference/gateway-route-compatibility";
import type { VllmProfile } from "../inference/vllm";
import { isBackToSelection } from "../navigation";
import type { HermesAuthMethod } from "./hermes-auth";
import type { ProviderSelectionResult } from "./machine/handlers/provider-inference";
+import type { ProviderInferenceProbeRoute } from "./machine/handlers/provider-inference-route-containment";
import type {
NvidiaFeaturedModelSession,
NvidiaFeaturedModelSessionOptions,
} from "./nvidia-featured-model-selection";
import type { InferenceProviderHostGpu, InferenceProviderHostState } from "./provider-host-state";
import { buildInferenceProviderMenu, type ProviderMenuChoice } from "./provider-menu";
+import { providerNameToOptionKey } from "./provider-recovery";
import { resolveRequestedProviderSelection } from "./provider-selection";
import { reportProviderSelectionFailure } from "./provider-selection-failure";
import { promptForInferenceProviderSelection } from "./provider-selection-prompt";
@@ -34,6 +37,7 @@ export interface SetupNimRemoteProviderConfigEntry {
}
export interface SetupNimRemoteSelectionArgs {
+ gatewayName: string | null;
selected: ProviderMenuChoice;
requestedModel: string | null;
recoveredFromSandbox: boolean;
@@ -48,6 +52,9 @@ export type SetupNim = (
agent?: AgentDefinition | null,
recoverProvider?: boolean,
rebuildRegistryInferenceRoute?: RebuildRouteHandoff | null,
+ gatewayName?: string | null,
+ assertRouteCompatible?: (route: ProviderInferenceProbeRoute) => GatewayRouteDiscoveryConstraints,
+ canProbeRoute?: (provider: string) => boolean,
) => Promise;
export interface SetupNimFlowDeps {
@@ -65,6 +72,8 @@ export interface SetupNimFlowDeps {
detectInferenceProviderHostState(input: {
gpu: InferenceProviderHostGpu | null | undefined;
experimental: boolean;
+ probeOllama?: boolean;
+ probeVllm?: boolean;
}): InferenceProviderHostState;
getAgentInferenceProviderOptions(agent: AgentDefinition | null | undefined): string[];
loadRoutedProfile(): { router?: { enabled?: boolean } } | null | undefined;
@@ -129,6 +138,7 @@ export interface SetupNimFlowDeps {
hasImage: boolean;
nonInteractive: boolean;
promptFn: (question: string) => Promise;
+ beforeInstall?: (modelId: string) => void;
},
): Promise<{ ok: boolean }>;
handleVllmSelection(state: SetupNimSelectionState): Promise;
@@ -137,6 +147,11 @@ export interface SetupNimFlowDeps {
agent: AgentDefinition | null,
preferredInferenceApi: string | null,
): string | null;
+ resolveAgentInferenceApi(
+ agentName: string | null,
+ provider: string,
+ preferredInferenceApi: string | null,
+ ): string | null;
clearCompatibleEndpointReasoning(): null;
maybePromptForInferenceInputCapability(model: string | null): Promise;
}
@@ -175,6 +190,121 @@ function clearReasoningUnlessCompatible(
return deps.clearCompatibleEndpointReasoning();
}
+function applyGatewayRouteDiscoveryConstraints(
+ state: SetupNimSelectionState,
+ constraints: GatewayRouteDiscoveryConstraints,
+): void {
+ if (!state.model && constraints.requiredModel) {
+ state.model = constraints.requiredModel;
+ }
+ if (!state.endpointUrl && constraints.requiredEndpointUrl) {
+ state.endpointUrl = constraints.requiredEndpointUrl;
+ }
+ if (!state.preferredInferenceApi && constraints.requiredInferenceApi) {
+ state.preferredInferenceApi = constraints.requiredInferenceApi;
+ }
+}
+
+const OLLAMA_PROBE_PROVIDER_KEYS = new Set([
+ "ollama",
+ "install-ollama",
+ "start-windows-ollama",
+ "install-windows-ollama",
+]);
+const VLLM_ROUTE_PROVIDER_KEYS = new Set(["vllm", "install-vllm"]);
+const VLLM_PROBE_PROVIDER_KEYS = new Set(["vllm", "install-vllm"]);
+
+function localProviderProbeIntent(providerKey: string | null): {
+ ollama: boolean;
+ vllm: boolean;
+} {
+ if (!providerKey) return { ollama: true, vllm: true };
+ return {
+ ollama: OLLAMA_PROBE_PROVIDER_KEYS.has(providerKey),
+ vllm: VLLM_PROBE_PROVIDER_KEYS.has(providerKey),
+ };
+}
+
+function localProbeRouteProvider(providerKey: string | null): string | null {
+ if (providerKey && OLLAMA_PROBE_PROVIDER_KEYS.has(providerKey)) return "ollama-local";
+ if (providerKey && VLLM_ROUTE_PROVIDER_KEYS.has(providerKey)) return "vllm-local";
+ return null;
+}
+
+function prepareProviderDiscovery(options: {
+ deps: SetupNimFlowDeps;
+ sandboxName: string | null;
+ recoverProvider: boolean;
+ rebuildRegistryInferenceRoute: RebuildRouteHandoff | null;
+ assertRouteCompatible?: (route: ProviderInferenceProbeRoute) => GatewayRouteDiscoveryConstraints;
+ canProbeRoute?: (provider: string) => boolean;
+}): {
+ requestedProvider: string | null;
+ requestedModel: string | null;
+ recoveredRegistryRoute: RegistryInferenceRoute | null;
+ probeOllama: boolean;
+ probeVllm: boolean;
+} {
+ const {
+ deps,
+ sandboxName,
+ recoverProvider,
+ rebuildRegistryInferenceRoute,
+ assertRouteCompatible,
+ canProbeRoute,
+ } = options;
+ const nonInteractive = deps.isNonInteractive();
+ const requestedProvider = deps.getNonInteractiveProvider();
+ const requestedModel = nonInteractive
+ ? deps.getNonInteractiveModel(requestedProvider || "build")
+ : null;
+ const recoveredRegistryRoute =
+ rebuildRegistryInferenceRoute?.sandboxName === sandboxName &&
+ rebuildRegistryInferenceRoute.route.source === "registry"
+ ? rebuildRegistryInferenceRoute.route
+ : null;
+ const recoveredProbeProvider =
+ nonInteractive && !requestedProvider && recoverProvider
+ ? (recoveredRegistryRoute?.provider ?? deps.readRecordedProvider(sandboxName))
+ : null;
+ const recoveredProbeKey = providerNameToOptionKey(
+ deps.remoteProviderConfig,
+ recoveredProbeProvider,
+ {
+ hasNimContainer:
+ recoveredProbeProvider === "vllm-local" &&
+ Boolean(deps.readRecordedNimContainer(sandboxName)),
+ },
+ );
+ const providerIntentKey =
+ requestedProvider || recoveredProbeKey || (nonInteractive ? "build" : null);
+ const intent = localProviderProbeIntent(providerIntentKey);
+ const guardedProvider = localProbeRouteProvider(providerIntentKey);
+ if (guardedProvider && assertRouteCompatible) {
+ const recoveredModel =
+ recoveredRegistryRoute?.model ??
+ (!requestedProvider && recoverProvider ? deps.readRecordedModel(sandboxName) : null);
+ assertRouteCompatible({
+ provider: guardedProvider,
+ model: requestedModel || recoveredModel,
+ endpointUrl: null,
+ preferredInferenceApi: null,
+ credentialEnv: null,
+ });
+ }
+ const ollamaPreflightPassed =
+ guardedProvider === "ollama-local" && Boolean(assertRouteCompatible);
+ const vllmPreflightPassed = guardedProvider === "vllm-local" && Boolean(assertRouteCompatible);
+ return {
+ requestedProvider,
+ requestedModel,
+ recoveredRegistryRoute,
+ probeOllama:
+ intent.ollama && (ollamaPreflightPassed || (canProbeRoute?.("ollama-local") ?? true)),
+ probeVllm: intent.vllm && (vllmPreflightPassed || (canProbeRoute?.("vllm-local") ?? true)),
+ };
+}
+
export function createSetupNim(
defaults: SetupNimFlowDeps,
overrides: Partial = {},
@@ -187,6 +317,11 @@ export function createSetupNim(
agent: AgentDefinition | null = null,
recoverProvider = true,
rebuildRegistryInferenceRoute: RebuildRouteHandoff | null = null,
+ gatewayName: string | null = null,
+ assertRouteCompatible?: (
+ route: ProviderInferenceProbeRoute,
+ ) => GatewayRouteDiscoveryConstraints,
+ canProbeRoute?: (provider: string) => boolean,
): Promise {
deps.step(3, 8, "Configuring inference provider");
@@ -205,10 +340,60 @@ export function createSetupNim(
defaultModel: resolveAgentDefaultCloudModel(agent),
writeLine: deps.log,
});
+ const createSelectionState = (): SetupNimSelectionState => {
+ const state: SetupNimSelectionState = {
+ model,
+ provider,
+ endpointUrl,
+ credentialEnv,
+ hermesAuthMethod,
+ hermesToolGateways,
+ preferredInferenceApi,
+ compatibleEndpointReasoning,
+ nimContainer,
+ allowToolsIncompatible,
+ nvidiaFeaturedModels,
+ };
+ state.assertRouteCompatible = () => {
+ const effectiveInferenceApi = () =>
+ deps.resolveAgentInferenceApi(
+ agent?.name ?? null,
+ state.provider,
+ deps.coerceAgentInferenceApi(agent, state.preferredInferenceApi),
+ );
+ const route = (): ProviderInferenceProbeRoute => ({
+ provider: state.provider,
+ model: typeof state.model === "string" && state.model.trim() ? state.model.trim() : null,
+ endpointUrl: state.endpointUrl,
+ preferredInferenceApi: effectiveInferenceApi(),
+ credentialEnv: state.credentialEnv,
+ });
+ const constraints = assertRouteCompatible?.(route()) ?? {
+ requiredModel: null,
+ requiredEndpointUrl: null,
+ requiredInferenceApi: null,
+ };
+ applyGatewayRouteDiscoveryConstraints(state, constraints);
+ assertRouteCompatible?.(route());
+ return constraints;
+ };
+ return state;
+ };
+ const { requestedProvider, requestedModel, recoveredRegistryRoute, probeOllama, probeVllm } =
+ prepareProviderDiscovery({
+ deps,
+ sandboxName,
+ recoverProvider,
+ rebuildRegistryInferenceRoute,
+ assertRouteCompatible,
+ canProbeRoute,
+ });
const providerHostState = deps.detectInferenceProviderHostState({
gpu,
experimental: deps.experimental,
+ probeOllama,
+ probeVllm,
});
const {
hasOllama,
@@ -228,15 +413,6 @@ export function createSetupNim(
ollamaInstallMenu,
gpuNimCapable,
} = providerHostState;
- const requestedProvider = deps.getNonInteractiveProvider();
- const requestedModel = deps.isNonInteractive()
- ? deps.getNonInteractiveModel(requestedProvider || "build")
- : null;
- const recoveredRegistryRoute =
- rebuildRegistryInferenceRoute?.sandboxName === sandboxName &&
- rebuildRegistryInferenceRoute.route.source === "registry"
- ? rebuildRegistryInferenceRoute.route
- : null;
const agentProviderOptions = deps.getAgentInferenceProviderOptions(agent);
const blueprintRouterCfg = deps.loadRoutedProfile();
@@ -277,6 +453,7 @@ export function createSetupNim(
let selected: ProviderMenuChoice | undefined;
let recoveredFromSandbox = false;
let recoveredModel: string | null = null;
+ let preparedVllmState: SetupNimSelectionState | null = null;
hermesAuthMethod = null;
if (deps.isNonInteractive() || requestedProvider) {
@@ -332,19 +509,7 @@ export function createSetupNim(
}
if (deps.remoteProviderConfig[selected.key]) {
- const state: SetupNimSelectionState = {
- model,
- provider,
- endpointUrl,
- credentialEnv,
- hermesAuthMethod,
- hermesToolGateways,
- preferredInferenceApi,
- compatibleEndpointReasoning,
- nimContainer,
- allowToolsIncompatible,
- nvidiaFeaturedModels,
- };
+ const state = createSelectionState();
const result = await deps.handleRemoteProviderSelection(
{
selected,
@@ -352,6 +517,7 @@ export function createSetupNim(
recoveredFromSandbox,
recoveredModel,
sandboxName,
+ gatewayName,
intendedInferenceApi: resolveValidationInferenceApi(
selected.key,
deps.remoteProviderConfig[selected.key].providerName,
@@ -376,17 +542,7 @@ export function createSetupNim(
if (result === "retry-selection") continue selectionLoop;
break;
} else if (selected.key === "nim-local") {
- const state: SetupNimSelectionState = {
- model,
- provider,
- endpointUrl,
- credentialEnv,
- hermesAuthMethod,
- hermesToolGateways,
- preferredInferenceApi,
- nimContainer,
- allowToolsIncompatible,
- };
+ const state = createSelectionState();
const result = await deps.handleNimLocalSelection(
gpu,
{ requestedModel, recoveredFromSandbox, recoveredModel },
@@ -408,17 +564,7 @@ export function createSetupNim(
if (rejectWindowsHostOllama(selected.key, isWindowsHostOllama)) {
continue selectionLoop;
}
- const state: SetupNimSelectionState = {
- model,
- provider,
- endpointUrl,
- credentialEnv,
- hermesAuthMethod,
- hermesToolGateways,
- preferredInferenceApi,
- nimContainer,
- allowToolsIncompatible,
- };
+ const state = createSelectionState();
const result = await deps.handleRunningOllamaSelection(
gpu,
requestedModel,
@@ -440,17 +586,7 @@ export function createSetupNim(
if (rejectWindowsHostOllama(selected.key, true)) {
continue selectionLoop;
}
- const state: SetupNimSelectionState = {
- model,
- provider,
- endpointUrl,
- credentialEnv,
- hermesAuthMethod,
- hermesToolGateways,
- preferredInferenceApi,
- nimContainer,
- allowToolsIncompatible,
- };
+ const state = createSelectionState();
const result = await deps.handleWindowsHostOllamaSelection(
gpu,
selected.key,
@@ -471,17 +607,7 @@ export function createSetupNim(
if (result === "retry-selection") continue selectionLoop;
break;
} else if (selected.key === "install-ollama") {
- const state: SetupNimSelectionState = {
- model,
- provider,
- endpointUrl,
- credentialEnv,
- hermesAuthMethod,
- hermesToolGateways,
- preferredInferenceApi,
- nimContainer,
- allowToolsIncompatible,
- };
+ const state = createSelectionState();
const result = await deps.handleInstallOllamaSelection(
gpu,
requestedModel,
@@ -505,10 +631,20 @@ export function createSetupNim(
if (deps.isNonInteractive()) deps.exitProcess(1);
continue selectionLoop;
}
+ const vllmState = createSelectionState();
+ preparedVllmState = vllmState;
const result = await deps.installVllm(vllmProfile, {
hasImage: hasVllmImage,
nonInteractive: deps.isNonInteractive(),
promptFn: deps.prompt,
+ beforeInstall: (modelId) => {
+ vllmState.provider = "vllm-local";
+ vllmState.model = modelId;
+ vllmState.endpointUrl = null;
+ vllmState.credentialEnv = null;
+ vllmState.preferredInferenceApi = "openai-completions";
+ vllmState.assertRouteCompatible?.();
+ },
});
if (!result.ok) {
if (deps.isNonInteractive())
@@ -521,17 +657,8 @@ export function createSetupNim(
};
}
if (selected.key === "vllm") {
- const state: SetupNimSelectionState = {
- model,
- provider,
- endpointUrl,
- credentialEnv,
- hermesAuthMethod,
- hermesToolGateways,
- preferredInferenceApi,
- nimContainer,
- allowToolsIncompatible,
- };
+ const state = preparedVllmState ?? createSelectionState();
+ state.model = preparedVllmState?.model ?? requestedModel ?? recoveredModel;
const result = await deps.handleVllmSelection(state);
({
model,
@@ -545,17 +672,7 @@ export function createSetupNim(
if (result === "retry-selection") continue selectionLoop;
break;
} else if (selected.key === "routed") {
- const state: SetupNimSelectionState = {
- model,
- provider,
- endpointUrl,
- credentialEnv,
- hermesAuthMethod,
- hermesToolGateways,
- preferredInferenceApi,
- nimContainer,
- allowToolsIncompatible,
- };
+ const state = createSelectionState();
const result = await deps.handleRoutedSelection(state);
({
model,
@@ -586,7 +703,11 @@ export function createSetupNim(
credentialEnv,
hermesAuthMethod,
hermesToolGateways,
- preferredInferenceApi: deps.coerceAgentInferenceApi(agent, preferredInferenceApi),
+ preferredInferenceApi: deps.resolveAgentInferenceApi(
+ agent?.name ?? null,
+ provider,
+ deps.coerceAgentInferenceApi(agent, preferredInferenceApi),
+ ),
compatibleEndpointReasoning,
nimContainer,
allowToolsIncompatible,
diff --git a/src/lib/onboard/setup-nim-ollama.test.ts b/src/lib/onboard/setup-nim-ollama.test.ts
index 69ead2d4023..efd93ece2ac 100644
--- a/src/lib/onboard/setup-nim-ollama.test.ts
+++ b/src/lib/onboard/setup-nim-ollama.test.ts
@@ -3,7 +3,7 @@
import assert from "node:assert/strict";
-import { describe, it, vi } from "vitest";
+import { describe, expect, it, vi } from "vitest";
import { createSetupNimOllamaHandlers } from "./setup-nim-ollama";
import type { SetupNimSelectionState } from "./setup-nim-selection";
@@ -61,6 +61,92 @@ function makeDeps(overrides: Partial = {}): Deps {
}
describe("createSetupNimOllamaHandlers", () => {
+ it("guards the selected route before systemd recovery and model preparation (#6315)", async () => {
+ const events: string[] = [];
+ const state = makeState();
+ state.assertRouteCompatible = () => {
+ events.push(`guard:${String(state.model)}`);
+ return {
+ requiredModel: "required/model",
+ requiredEndpointUrl: null,
+ requiredInferenceApi: null,
+ };
+ };
+ const { handleRunningOllamaSelection } = createSetupNimOllamaHandlers(
+ makeDeps({
+ ensureOllamaLoopbackSystemdOverride: () => {
+ events.push("systemd");
+ return "unchanged";
+ },
+ selectAndValidateOllamaModel: async (_gpu, _provider, args, onModelSelected) => {
+ expect(args.lockedModel).toBe("required/model");
+ events.push("prepare-model");
+ onModelSelected?.("required/model");
+ return { outcome: "selected", model: "required/model", allowToolsIncompatible: false };
+ },
+ }),
+ );
+
+ await handleRunningOllamaSelection(null, "required/model", null, true, state);
+
+ expect(events).toEqual([
+ "guard:required/model",
+ "systemd",
+ "prepare-model",
+ "guard:required/model",
+ ]);
+ });
+
+ it("does not install Ollama when shared-gateway preflight rejects", async () => {
+ const state = makeState();
+ state.assertRouteCompatible = () => {
+ throw new Error("route conflict");
+ };
+ const install = vi.fn(() => ({ ok: true }));
+ const { handleInstallOllamaSelection } = createSetupNimOllamaHandlers(
+ makeDeps({ installOllamaOnLinux: install, installOllamaOnMacOS: install }),
+ );
+
+ await expect(
+ handleInstallOllamaSelection(null, "conflict/model", null, state, {
+ hasUpgradableOllama: false,
+ }),
+ ).rejects.toThrow("route conflict");
+ expect(install).not.toHaveBeenCalled();
+ });
+
+ it("does not switch, install, or restart Windows Ollama when preflight rejects", async () => {
+ const state = makeState();
+ state.assertRouteCompatible = () => {
+ throw new Error("route conflict");
+ };
+ const switchHost = vi.fn();
+ const install = vi.fn(async () => ({ ok: true }));
+ const restart = vi.fn(() => true);
+ const { handleWindowsHostOllamaSelection } = createSetupNimOllamaHandlers(
+ makeDeps({
+ switchToWindowsOllamaHost: switchHost,
+ installOllamaOnWindowsHost: install,
+ setupWindowsOllamaWith0000Binding: restart,
+ }),
+ );
+
+ await expect(
+ handleWindowsHostOllamaSelection(
+ null,
+ "start-windows-ollama",
+ "conflict/model",
+ true,
+ false,
+ null,
+ state,
+ ),
+ ).rejects.toThrow("route conflict");
+ expect(switchHost).not.toHaveBeenCalled();
+ expect(install).not.toHaveBeenCalled();
+ expect(restart).not.toHaveBeenCalled();
+ });
+
it("preserves accepted tools-incompatible state for running Ollama", async () => {
const state = makeState();
const { handleRunningOllamaSelection } = createSetupNimOllamaHandlers(makeDeps());
diff --git a/src/lib/onboard/setup-nim-ollama.ts b/src/lib/onboard/setup-nim-ollama.ts
index dd974950389..c6c29e70370 100644
--- a/src/lib/onboard/setup-nim-ollama.ts
+++ b/src/lib/onboard/setup-nim-ollama.ts
@@ -26,7 +26,12 @@ type SetupNimOllamaDeps = {
selectAndValidateOllamaModel: (
gpu: any,
provider: string,
- args: { requestedModel: string | null; recoveredModel: string | null },
+ args: {
+ requestedModel: string | null;
+ recoveredModel: string | null;
+ lockedModel?: string | null;
+ },
+ onModelSelected?: (model: string) => void,
) => Promise<
| { outcome: "back-to-selection" }
| { outcome: "selected"; model: string; allowToolsIncompatible: boolean }
@@ -83,11 +88,23 @@ export function createSetupNimOllamaHandlers(deps: SetupNimOllamaDeps): {
state: SetupNimSelectionState,
requestedModel: string | null,
recoveredModel: string | null,
+ lockedModel: string | null,
): Promise {
- const result = await deps.selectAndValidateOllamaModel(gpu, state.provider, {
- requestedModel,
- recoveredModel,
- });
+ const constrainedModel = typeof state.model === "string" ? state.model : requestedModel;
+ const result = await deps.selectAndValidateOllamaModel(
+ gpu,
+ state.provider,
+ {
+ requestedModel: constrainedModel,
+ recoveredModel,
+ lockedModel,
+ },
+ (model) => {
+ state.model = model;
+ state.preferredInferenceApi = "openai-completions";
+ state.assertRouteCompatible?.();
+ },
+ );
if (result.outcome === "back-to-selection") return "retry-selection";
state.model = result.model;
state.allowToolsIncompatible = result.allowToolsIncompatible;
@@ -110,6 +127,7 @@ export function createSetupNimOllamaHandlers(deps: SetupNimOllamaDeps): {
state.provider = "ollama-local";
state.credentialEnv = null;
state.endpointUrl = deps.getLocalProviderBaseUrl(state.provider);
+ state.preferredInferenceApi = "openai-completions";
state.skipHostInferenceSmoke = false;
if (!state.endpointUrl) {
console.error(" Local Ollama base URL could not be determined.");
@@ -117,6 +135,16 @@ export function createSetupNimOllamaHandlers(deps: SetupNimOllamaDeps): {
}
}
+ function preflightOllamaRoute(
+ state: SetupNimSelectionState,
+ requestedModel: string | null,
+ recoveredModel: string | null,
+ ): string | null {
+ configureOllamaState(state);
+ state.model = requestedModel || recoveredModel;
+ return state.assertRouteCompatible?.().requiredModel ?? null;
+ }
+
function applyOllamaFallbackState(
state: SetupNimSelectionState,
result: Extract["result"],
@@ -143,6 +171,7 @@ export function createSetupNimOllamaHandlers(deps: SetupNimOllamaDeps): {
if (!deps.checkOllamaPortsOrWarn({ isNonInteractive: deps.isNonInteractive })) {
return "retry-selection";
}
+ const lockedModel = preflightOllamaRoute(state, requestedModel, null);
const isInstall = selectedKey === "install-windows-ollama";
const isSwitch = !isInstall && windowsOllamaReachable;
const isRestart = !isInstall && !isSwitch && winOllamaLoopbackOnly;
@@ -192,8 +221,7 @@ export function createSetupNimOllamaHandlers(deps: SetupNimOllamaDeps): {
}
console.log(` ✓ Using Ollama on host.docker.internal:${deps.OLLAMA_PORT}`);
}
- configureOllamaState(state);
- const result = await selectModel(gpu, state, requestedModel, null);
+ const result = await selectModel(gpu, state, requestedModel, null, lockedModel);
if (result === "retry-selection") deps.resetOllamaHostCache();
return result;
}
@@ -208,6 +236,8 @@ export function createSetupNimOllamaHandlers(deps: SetupNimOllamaDeps): {
if (!deps.checkOllamaPortsOrWarn({ isNonInteractive: deps.isNonInteractive })) {
return "retry-selection";
}
+ const initialState = { ...state, hermesToolGateways: [...state.hermesToolGateways] };
+ const lockedModel = preflightOllamaRoute(state, requestedModel, recoveredModel);
let ollamaReady = ollamaRunning;
const overrideState = deps.ensureOllamaLoopbackSystemdOverride({
isNonInteractive: deps.isNonInteractive,
@@ -236,13 +266,14 @@ export function createSetupNimOllamaHandlers(deps: SetupNimOllamaDeps): {
// Fallback crosses a provider boundary, so write a complete safe state
// rather than merging over stale cloud/NIM/Ollama selection fields.
applyOllamaFallbackState(state, startup.result);
+ state.assertRouteCompatible?.();
return "selected";
case "ready":
startProxyOrAnnounceDirect();
- configureOllamaState(state);
- return selectModel(gpu, state, requestedModel, recoveredModel);
+ return selectModel(gpu, state, requestedModel, recoveredModel, lockedModel);
default: {
const kind = (startup as { kind?: unknown }).kind;
+ Object.assign(state, initialState);
console.error(` Unknown Ollama startup outcome: ${String(kind)}`);
deps.process.exit(1);
}
@@ -259,6 +290,7 @@ export function createSetupNimOllamaHandlers(deps: SetupNimOllamaDeps): {
if (!deps.checkOllamaPortsOrWarn({ isNonInteractive: deps.isNonInteractive })) {
return "retry-selection";
}
+ const lockedModel = preflightOllamaRoute(state, requestedModel, recoveredModel);
const isUpgrade = ollamaInstallMenu.hasUpgradableOllama;
const installResult =
deps.process.platform === "darwin"
@@ -276,8 +308,7 @@ export function createSetupNimOllamaHandlers(deps: SetupNimOllamaDeps): {
return "retry-selection";
}
startProxyOrAnnounceDirect();
- configureOllamaState(state);
- return selectModel(gpu, state, requestedModel, recoveredModel);
+ return selectModel(gpu, state, requestedModel, recoveredModel, lockedModel);
}
return {
diff --git a/src/lib/onboard/setup-nim-selection.ts b/src/lib/onboard/setup-nim-selection.ts
index 547bfc43367..0ef6a01b45b 100644
--- a/src/lib/onboard/setup-nim-selection.ts
+++ b/src/lib/onboard/setup-nim-selection.ts
@@ -1,6 +1,7 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
+import type { GatewayRouteDiscoveryConstraints } from "../inference/gateway-route-compatibility";
import type { NvidiaFeaturedModelSession } from "./nvidia-featured-model-selection";
export { createNvidiaFeaturedModelSession } from "./nvidia-featured-model-selection";
@@ -21,6 +22,8 @@ export type SetupNimSelectionState = {
skipHostInferenceSmoke?: boolean;
reuseGatewayCredentialWithoutLocalKey?: boolean;
nvidiaFeaturedModels?: NvidiaFeaturedModelSession;
+ /** Attempt-wide shared-gateway guard, invoked after identity selection and before probes. */
+ assertRouteCompatible?: () => GatewayRouteDiscoveryConstraints;
};
export type CloudFallbackConfig = {
diff --git a/src/lib/onboard/setup-nim-vllm.test.ts b/src/lib/onboard/setup-nim-vllm.test.ts
new file mode 100644
index 00000000000..3713b534576
--- /dev/null
+++ b/src/lib/onboard/setup-nim-vllm.test.ts
@@ -0,0 +1,85 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { requireValue } from "../core/require-value";
+import type { SetupNimSelectionState } from "./setup-nim-flow";
+import { createSetupNimVllmHandler, type SetupNimVllmDeps } from "./setup-nim-vllm";
+
+function state(model: string | null): SetupNimSelectionState {
+ return {
+ model,
+ provider: "nvidia-prod",
+ endpointUrl: null,
+ credentialEnv: null,
+ hermesAuthMethod: null,
+ hermesToolGateways: [],
+ preferredInferenceApi: null,
+ nimContainer: null,
+ allowToolsIncompatible: false,
+ };
+}
+
+function deps(overrides: Partial = {}): SetupNimVllmDeps {
+ return {
+ VLLM_PORT: 8000,
+ runCapture: () => JSON.stringify({ data: [{ id: "served/model" }] }),
+ getLocalProviderBaseUrl: () => "http://host.openshell.internal:8000/v1",
+ getLocalProviderValidationBaseUrl: () => "http://127.0.0.1:8000/v1",
+ isSafeModelId: () => true,
+ requireValue,
+ validateOpenAiLikeSelection: async () => ({ ok: true, api: "openai-completions" }),
+ applyVllmRuntimeContextWindow: vi.fn(),
+ exitProcess: (code) => {
+ throw new Error(`exit ${code}`);
+ },
+ ...overrides,
+ };
+}
+
+beforeEach(() => {
+ vi.spyOn(console, "log").mockImplementation(() => {});
+ vi.spyOn(console, "error").mockImplementation(() => {});
+});
+
+afterEach(() => vi.restoreAllMocks());
+
+describe("setupNim vLLM route containment", () => {
+ it("preflights before discovery and exact-checks the detected model before validation (#6315)", async () => {
+ const events: string[] = [];
+ const selection = state(null);
+ selection.assertRouteCompatible = () => {
+ events.push(selection.model ? "exact" : "preflight");
+ return { requiredModel: null, requiredEndpointUrl: null, requiredInferenceApi: null };
+ };
+ const handler = createSetupNimVllmHandler(
+ deps({
+ runCapture: () => {
+ events.push("probe");
+ return JSON.stringify({ data: [{ id: "served/model" }] });
+ },
+ validateOpenAiLikeSelection: async () => {
+ events.push("validate");
+ return { ok: true, api: "openai-completions" };
+ },
+ }),
+ );
+
+ await expect(handler(selection)).resolves.toBe("selected");
+ expect(events).toEqual(["preflight", "probe", "exact", "validate"]);
+ });
+
+ it("rejects a detected model that differs from the durable shared route before validation", async () => {
+ const validate = vi.fn(async () => ({ ok: true }));
+ const selection = state("required/model");
+ selection.assertRouteCompatible = () => ({
+ requiredModel: "required/model",
+ requiredEndpointUrl: null,
+ requiredInferenceApi: null,
+ });
+ const handler = createSetupNimVllmHandler(deps({ validateOpenAiLikeSelection: validate }));
+
+ await expect(handler(selection)).rejects.toThrow("exit 1");
+ expect(validate).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/lib/onboard/setup-nim-vllm.ts b/src/lib/onboard/setup-nim-vllm.ts
new file mode 100644
index 00000000000..38e3624a18e
--- /dev/null
+++ b/src/lib/onboard/setup-nim-vllm.ts
@@ -0,0 +1,101 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import type { SetupNimSelectionResult, SetupNimSelectionState } from "./setup-nim-flow";
+
+type VllmModels = { data?: Array<{ id?: unknown }> };
+
+export interface SetupNimVllmDeps {
+ VLLM_PORT: number;
+ runCapture(args: string[], options: { ignoreError: boolean }): string;
+ getLocalProviderBaseUrl(provider: string): string | null;
+ getLocalProviderValidationBaseUrl(provider: string): string | null;
+ isSafeModelId(model: string): boolean;
+ requireValue(value: T | null | undefined, message: string): T;
+ validateOpenAiLikeSelection(
+ label: string,
+ endpointUrl: string,
+ model: string,
+ credentialEnv: string | null,
+ ): Promise<{ ok: boolean; retry?: string; api?: string | null }>;
+ applyVllmRuntimeContextWindow(models: VllmModels, model: string): void;
+ exitProcess(code: number): never;
+}
+
+export function createSetupNimVllmHandler(
+ deps: SetupNimVllmDeps,
+): (state: SetupNimSelectionState) => Promise {
+ return async function handleVllmSelection(
+ state: SetupNimSelectionState,
+ ): Promise {
+ console.log(` ✓ Using existing vLLM on localhost:${deps.VLLM_PORT}`);
+ state.provider = "vllm-local";
+ state.credentialEnv = null;
+ state.endpointUrl = deps.getLocalProviderBaseUrl(state.provider);
+ if (!state.endpointUrl) {
+ console.error(" Local vLLM base URL could not be determined.");
+ deps.exitProcess(1);
+ }
+ state.preferredInferenceApi = "openai-completions";
+ state.assertRouteCompatible?.();
+ const requiredModel = typeof state.model === "string" ? state.model : null;
+
+ const raw = deps.runCapture(["curl", "-sf", `http://127.0.0.1:${deps.VLLM_PORT}/v1/models`], {
+ ignoreError: true,
+ });
+ let models: VllmModels;
+ try {
+ models = JSON.parse(raw);
+ } catch {
+ console.error(
+ ` Could not query vLLM models endpoint. Is vLLM running on localhost:${deps.VLLM_PORT}?`,
+ );
+ deps.exitProcess(1);
+ }
+ const detectedModel =
+ models.data && models.data.length > 0 && typeof models.data[0]?.id === "string"
+ ? models.data[0].id
+ : null;
+ if (!detectedModel) {
+ console.error(" Could not detect model from vLLM. Please specify manually.");
+ deps.exitProcess(1);
+ }
+ if (!deps.isSafeModelId(detectedModel)) {
+ console.error(" Detected vLLM model ID contains invalid characters.");
+ deps.exitProcess(1);
+ }
+ if (requiredModel && detectedModel !== requiredModel) {
+ console.error(
+ ` Detected vLLM model '${detectedModel}' does not match the shared gateway route '${requiredModel}'.`,
+ );
+ deps.exitProcess(1);
+ }
+ state.model = detectedModel;
+ state.assertRouteCompatible?.();
+ console.log(` Detected model: ${state.model}`);
+
+ const validationBaseUrl = deps.getLocalProviderValidationBaseUrl(state.provider);
+ if (!validationBaseUrl) {
+ console.error(" Local vLLM validation URL could not be determined.");
+ deps.exitProcess(1);
+ }
+ const validation = await deps.validateOpenAiLikeSelection(
+ "Local vLLM",
+ validationBaseUrl,
+ deps.requireValue(state.model, "Expected a detected vLLM model"),
+ null,
+ );
+ if (validation.retry === "selection" || validation.retry === "model" || !validation.ok) {
+ return "retry-selection";
+ }
+
+ deps.applyVllmRuntimeContextWindow(models, state.model);
+ if (validation.api !== "openai-completions") {
+ console.log(
+ " ℹ Using chat completions API (tool-call-parser requires /v1/chat/completions)",
+ );
+ }
+ state.preferredInferenceApi = "openai-completions";
+ return "selected";
+ };
+}
diff --git a/src/lib/openshell-gateway-endpoint-guard.ts b/src/lib/openshell-gateway-endpoint-guard.ts
new file mode 100644
index 00000000000..04d8fff4e57
--- /dev/null
+++ b/src/lib/openshell-gateway-endpoint-guard.ts
@@ -0,0 +1,41 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+export const OPENSHELL_GATEWAY_ENDPOINT_ENV = "OPENSHELL_GATEWAY_ENDPOINT";
+
+export type OpenShellGatewayEndpointEnvironment = {
+ OPENSHELL_GATEWAY_ENDPOINT?: string;
+};
+
+export class OpenShellGatewayEndpointOverrideError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = "OpenShellGatewayEndpointOverrideError";
+ }
+}
+
+export function assertNoOpenShellGatewayEndpointOverride(
+ env: OpenShellGatewayEndpointEnvironment = process.env,
+): void {
+ const endpoint = env.OPENSHELL_GATEWAY_ENDPOINT;
+ if (typeof endpoint !== "string" || !endpoint.trim()) return;
+
+ throw new OpenShellGatewayEndpointOverrideError(
+ `${OPENSHELL_GATEWAY_ENDPOINT_ENV} is set, so OpenShell may bypass the gateway recorded for this sandbox. ` +
+ `Unset ${OPENSHELL_GATEWAY_ENDPOINT_ENV} and retry.`,
+ );
+}
+
+export function assertNoExplicitOpenShellGatewayEndpoint(args: readonly string[]): void {
+ const separatorIndex = args.indexOf("--");
+ const optionEnd = separatorIndex === -1 ? args.length : separatorIndex;
+ for (let index = 0; index < optionEnd; index += 1) {
+ const arg = args[index];
+ if (arg === "--gateway-endpoint" || arg.startsWith("--gateway-endpoint=")) {
+ throw new OpenShellGatewayEndpointOverrideError(
+ "OpenShell --gateway-endpoint may bypass the gateway recorded for this sandbox. " +
+ "Remove --gateway-endpoint and retry.",
+ );
+ }
+ }
+}
diff --git a/src/lib/registry-recovery-action.test.ts b/src/lib/registry-recovery-action.test.ts
index fbd69dfc089..d827725b3f4 100644
--- a/src/lib/registry-recovery-action.test.ts
+++ b/src/lib/registry-recovery-action.test.ts
@@ -138,6 +138,58 @@ describe("recoverRegistryEntries seed-time guard (#2753)", () => {
expect(recovered?.policies).toEqual(["npm"]);
});
+ it("restores complete custom-route identity from a confirmed session", async () => {
+ vi.mocked(loadSession).mockReturnValue({
+ sandboxName: "custom-route",
+ provider: "compatible-endpoint",
+ model: "nvidia/nemotron-3-ultra",
+ endpointUrl: "https://inference.example.test/v1",
+ credentialEnv: "COMPATIBLE_API_KEY",
+ preferredInferenceApi: "openai-completions",
+ policyPresets: [],
+ nimContainer: null,
+ steps: {
+ sandbox: { status: "complete", startedAt: null, completedAt: null, error: null },
+ },
+ } as never);
+
+ const result = await recoverRegistryEntries();
+
+ expect(result.recoveredFromSession).toBe(true);
+ expect(result.sandboxes.find((sandbox) => sandbox.name === "custom-route")).toMatchObject({
+ provider: "compatible-endpoint",
+ model: "nvidia/nemotron-3-ultra",
+ endpointUrl: "https://inference.example.test/v1",
+ credentialEnv: "COMPATIBLE_API_KEY",
+ preferredInferenceApi: "openai-completions",
+ });
+ });
+
+ it("still fails closed for a confirmed legacy custom route without full identity", async () => {
+ const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {});
+ vi.mocked(loadSession).mockReturnValue({
+ sandboxName: "legacy-custom-route",
+ provider: "compatible-endpoint",
+ model: "nvidia/nemotron-3-ultra",
+ endpointUrl: null,
+ credentialEnv: "COMPATIBLE_API_KEY",
+ preferredInferenceApi: null,
+ policyPresets: [],
+ nimContainer: null,
+ steps: {
+ sandbox: { status: "complete", startedAt: null, completedAt: null, error: null },
+ },
+ } as never);
+
+ const result = await recoverRegistryEntries();
+
+ expect(result.recoveredFromSession).toBe(false);
+ expect(result.sandboxes).toEqual([]);
+ expect(consoleWarn.mock.calls.flat().join("\n")).toContain(
+ "requested custom route lacks durable endpoint or API-family metadata",
+ );
+ });
+
it("returns empty recovery when there is no session and no registry entries", async () => {
vi.mocked(loadSession).mockReturnValue(null);
diff --git a/src/lib/registry-recovery-action.ts b/src/lib/registry-recovery-action.ts
index 3c9139f2224..ce53389df33 100644
--- a/src/lib/registry-recovery-action.ts
+++ b/src/lib/registry-recovery-action.ts
@@ -4,15 +4,23 @@
import { resolveOpenshell } from "./adapters/openshell/resolve";
import { captureOpenshell } from "./adapters/openshell/runtime";
import { OPENSHELL_PROBE_TIMEOUT_MS } from "./adapters/openshell/timeouts";
+import { GATEWAY_PORT } from "./core/ports";
import {
getNamedGatewayLifecycleState,
recoverNamedGatewayRuntime,
} from "./gateway-runtime-action";
+import {
+ checkGatewayRouteCompatibility,
+ formatGatewayRouteConflict,
+} from "./inference/gateway-route-compatibility";
+import { withGatewayRouteMutationLock } from "./inference/gateway-route-mutation-lock";
+import { resolveGatewayName, resolveSandboxGatewayName } from "./onboard/gateway-binding";
import { validateName } from "./runner";
import { parseLiveSandboxEntries } from "./runtime-recovery";
import * as onboardSession from "./state/onboard-session";
import type { SandboxEntry } from "./state/registry";
import * as registry from "./state/registry";
+import { getSandboxEntryInference } from "./state/registry-entry-view";
/**
* #5714: a sandbox surfaced display-only by unseeded `nemoclaw list` recovery.
@@ -30,7 +38,18 @@ export type RecoveredSandboxEntry = SandboxEntry & {
type Session = ReturnType;
type RecoveredSandboxMetadata = Partial<
- Pick
+ Pick<
+ SandboxEntry,
+ | "model"
+ | "provider"
+ | "gpuEnabled"
+ | "policies"
+ | "nimContainer"
+ | "agent"
+ | "endpointUrl"
+ | "credentialEnv"
+ | "preferredInferenceApi"
+ >
> & {
policyPresets?: string[] | null;
};
@@ -55,6 +74,9 @@ function buildRecoveredSandboxEntry(
? metadata.policyPresets
: [],
nimContainer: metadata.nimContainer || null,
+ endpointUrl: metadata.endpointUrl ?? null,
+ credentialEnv: metadata.credentialEnv ?? null,
+ preferredInferenceApi: metadata.preferredInferenceApi ?? null,
};
// Only assert `agent` when recovery actually knows it. Object.assign in
// updateSandbox would otherwise overwrite a persisted agent (e.g. "hermes")
@@ -72,7 +94,11 @@ function buildRecoveredSandboxEntry(
* merging into an existing one. Returns true only when a new entry was created.
* Invalid sandbox names are skipped (returns false).
*/
-function upsertRecoveredSandbox(name: string, metadata: RecoveredSandboxMetadata = {}) {
+function upsertRecoveredSandbox(
+ name: string,
+ metadata: RecoveredSandboxMetadata = {},
+ gatewayName = resolveGatewayName(GATEWAY_PORT),
+) {
let validName;
try {
validName = validateName(name, "sandbox name");
@@ -80,8 +106,39 @@ function upsertRecoveredSandbox(name: string, metadata: RecoveredSandboxMetadata
return false;
}
- const entry = buildRecoveredSandboxEntry(validName, metadata);
- if (registry.getSandbox(validName)) {
+ const existing = registry.getSandbox(validName);
+ if (existing && resolveSandboxGatewayName(existing) !== gatewayName) return false;
+ const recovered = buildRecoveredSandboxEntry(validName, metadata);
+ // A persisted row is the atomic source of truth for its complete route
+ // identity. Never fill one of its missing fields from a historical session:
+ // mixing provider/model from the registry with endpoint/API metadata from
+ // another route can manufacture an identity that never existed.
+ const routeIdentity = existing ?? recovered;
+ const entry = {
+ ...recovered,
+ provider: routeIdentity.provider ?? null,
+ model: routeIdentity.model ?? null,
+ endpointUrl: routeIdentity.endpointUrl ?? null,
+ credentialEnv: routeIdentity.credentialEnv ?? null,
+ preferredInferenceApi: routeIdentity.preferredInferenceApi ?? null,
+ gatewayName: existing?.gatewayName ?? gatewayName,
+ };
+ const inference = getSandboxEntryInference(entry);
+ if (inference.kind === "configured") {
+ const compatibility = checkGatewayRouteCompatibility({
+ gatewayName,
+ sandboxName: validName,
+ route: entry,
+ sandboxes: registry.listSandboxes().sandboxes,
+ });
+ if (!compatibility.ok) {
+ console.warn(
+ ` Skipping unsafe registry recovery: ${formatGatewayRouteConflict(compatibility)}`,
+ );
+ return false;
+ }
+ }
+ if (existing) {
registry.updateSandbox(validName, entry);
return false;
}
@@ -149,6 +206,7 @@ function seedRecoveryMetadata(
current: { sandboxes: SandboxEntry[] },
session: Session | null,
requestedSandboxName: string | null,
+ gatewayName = resolveGatewayName(GATEWAY_PORT),
) {
const metadataByName = new Map(
current.sandboxes.map((sandbox: SandboxEntry) => [sandbox.name, sandbox]),
@@ -167,6 +225,9 @@ function seedRecoveryMetadata(
nimContainer: session.nimContainer || null,
policyPresets: session.policyPresets || null,
agent: session.agent || null,
+ endpointUrl: session.endpointUrl ?? null,
+ credentialEnv: session.credentialEnv ?? null,
+ preferredInferenceApi: session.preferredInferenceApi ?? null,
}),
);
const sessionSandboxMissing = !current.sandboxes.some(
@@ -180,6 +241,7 @@ function seedRecoveryMetadata(
recoveredFromSession = upsertRecoveredSandbox(
session.sandboxName,
metadataByName.get(session.sandboxName),
+ gatewayName,
);
}
return { metadataByName, recoveredFromSession };
@@ -239,7 +301,10 @@ interface LiveGatewayRecovery {
*/
async function recoverRegistryFromLiveGateway(
metadataByName: Map,
- { readOnly = false }: { readOnly?: boolean } = {},
+ {
+ readOnly = false,
+ gatewayName = resolveGatewayName(GATEWAY_PORT),
+ }: { readOnly?: boolean; gatewayName?: string } = {},
): Promise {
if (!resolveOpenshell()) {
return { recoveredFromGateway: 0, ephemeralSandboxes: [] };
@@ -290,7 +355,7 @@ async function recoverRegistryFromLiveGateway(
recoveredFromGateway += 1;
continue;
}
- if (upsertRecoveredSandbox(name, metadata)) {
+ if (upsertRecoveredSandbox(name, metadata, gatewayName)) {
recoveredFromGateway += 1;
}
}
@@ -327,19 +392,13 @@ function applyRecoveredDefault(
* live sandboxes as display-only entries without persisting them. Returns the
* registry listing plus `recoveredFromSession`/`recoveredFromGateway` markers.
*/
-export async function recoverRegistryEntries({
- requestedSandboxName = null,
-}: {
- requestedSandboxName?: string | null;
-} = {}) {
- const current = registry.listSandboxes();
- const session = onboardSession.loadSession();
- const recoveryCheck = shouldRecoverRegistryEntries(current, session, requestedSandboxName);
- if (!recoveryCheck.shouldRecover) {
- return { ...current, recoveredFromSession: false, recoveredFromGateway: 0 };
- }
-
- const seeded = seedRecoveryMetadata(current, session, requestedSandboxName);
+async function recoverRegistryEntriesFromSnapshot(
+ current: ReturnType,
+ session: Session | null,
+ requestedSandboxName: string | null,
+ gatewayName: string,
+) {
+ const seeded = seedRecoveryMetadata(current, session, requestedSandboxName, gatewayName);
// A seed is any signal that the user expects a specific sandbox to exist:
// existing registry entries, a *confirmed* onboard session, or an explicit
// requested name. With a seed we allow active gateway recovery (which may
@@ -357,6 +416,7 @@ export async function recoverRegistryEntries({
current.sandboxes.length > 0 || hasConfirmedSession || Boolean(requestedSandboxName);
const gateway = await recoverRegistryFromLiveGateway(seeded.metadataByName, {
readOnly: !hasRecoverySeed,
+ gatewayName,
});
const recovered = applyRecoveredDefault(current.defaultSandbox, requestedSandboxName, session);
// Merge display-only (ephemeral) live-gateway sandboxes that were not
@@ -374,3 +434,41 @@ export async function recoverRegistryEntries({
recoveredFromGateway: gateway.recoveredFromGateway,
};
}
+
+export async function recoverRegistryEntries({
+ requestedSandboxName = null,
+}: {
+ requestedSandboxName?: string | null;
+} = {}) {
+ const current = registry.listSandboxes();
+ const session = onboardSession.loadSession();
+ const recoveryCheck = shouldRecoverRegistryEntries(current, session, requestedSandboxName);
+ if (!recoveryCheck.shouldRecover) {
+ return { ...current, recoveredFromSession: false, recoveredFromGateway: 0 };
+ }
+
+ const hasConfirmedSession = isSessionSandboxConfirmed(session) && Boolean(session?.sandboxName);
+ const hasRecoverySeed =
+ current.sandboxes.length > 0 || hasConfirmedSession || Boolean(requestedSandboxName);
+ const gatewayName = resolveGatewayName(GATEWAY_PORT);
+ if (!hasRecoverySeed) {
+ return recoverRegistryEntriesFromSnapshot(current, session, requestedSandboxName, gatewayName);
+ }
+ return withGatewayRouteMutationLock(gatewayName, async () => {
+ const lockedCurrent = registry.listSandboxes();
+ const lockedSession = onboardSession.loadSession();
+ const lockedCheck = shouldRecoverRegistryEntries(
+ lockedCurrent,
+ lockedSession,
+ requestedSandboxName,
+ );
+ return lockedCheck.shouldRecover
+ ? recoverRegistryEntriesFromSnapshot(
+ lockedCurrent,
+ lockedSession,
+ requestedSandboxName,
+ gatewayName,
+ )
+ : { ...lockedCurrent, recoveredFromSession: false, recoveredFromGateway: 0 };
+ });
+}
diff --git a/src/lib/registry-recovery-seeded-paths.test.ts b/src/lib/registry-recovery-seeded-paths.test.ts
index cfef57cdb4f..fd4c3d6fcc7 100644
--- a/src/lib/registry-recovery-seeded-paths.test.ts
+++ b/src/lib/registry-recovery-seeded-paths.test.ts
@@ -3,6 +3,8 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
+import { runInferenceSet } from "./actions/inference-set.js";
+import { createDeps } from "./actions/inference-set.test-support.js";
import type { SandboxEntry } from "./state/registry.js";
interface MockRegistryState {
@@ -70,8 +72,8 @@ import { loadSession } from "./state/onboard-session.js";
const gammaEntry = (policies: string[]): SandboxEntry => ({
name: "gamma",
- provider: "existing-provider",
- model: "existing-model",
+ provider: "nvidia-prod",
+ model: "nvidia/nemotron-3-super-120b-a12b",
gpuEnabled: false,
policies,
});
@@ -130,6 +132,61 @@ describe("recoverRegistryEntries seeded recovery paths", () => {
expect(mockRegistryState.defaultSandbox).toBe("gamma");
});
+ it("fails closed instead of restoring a conflicting session route", async () => {
+ const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {});
+ mockRegistryState.sandboxes.gamma = {
+ ...gammaEntry([]),
+ provider: "existing-provider",
+ model: "existing-model",
+ };
+ mockRegistryState.defaultSandbox = "gamma";
+ vi.mocked(loadSession).mockReturnValue(completedSession("alpha", []));
+ vi.mocked(parseLiveSandboxEntries).mockReturnValue([{ name: "alpha", phase: "Ready" }]);
+
+ const result = await recoverRegistryEntries();
+
+ expect(result.recoveredFromSession).toBe(false);
+ expect(mockRegistryState.sandboxes.alpha).toBeUndefined();
+ expect(consoleWarn.mock.calls.flat().join("\n")).toContain("gamma");
+ });
+
+ it("keeps an existing route identity atomic when session metadata is stale", async () => {
+ mockRegistryState.sandboxes.alpha = {
+ name: "alpha",
+ provider: "nvidia-prod",
+ model: "nvidia/nemotron-3-super-120b-a12b",
+ endpointUrl: null,
+ credentialEnv: "NVIDIA_API_KEY",
+ preferredInferenceApi: null,
+ gpuEnabled: false,
+ policies: [],
+ };
+ vi.mocked(loadSession).mockReturnValue({
+ sandboxName: "alpha",
+ provider: "compatible-endpoint",
+ model: "nvidia/nemotron-3-ultra",
+ endpointUrl: "https://historical.example.test/v1",
+ credentialEnv: "COMPATIBLE_API_KEY",
+ preferredInferenceApi: "openai-completions",
+ policyPresets: [],
+ nimContainer: null,
+ steps: {
+ sandbox: { status: "complete", startedAt: null, completedAt: null, error: null },
+ },
+ } as never);
+ vi.mocked(parseLiveSandboxEntries).mockReturnValue([{ name: "alpha", phase: "Ready" }]);
+
+ await recoverRegistryEntries({ requestedSandboxName: "missing-sandbox" });
+
+ expect(mockRegistryState.sandboxes.alpha).toMatchObject({
+ provider: "nvidia-prod",
+ model: "nvidia/nemotron-3-super-120b-a12b",
+ endpointUrl: null,
+ credentialEnv: "NVIDIA_API_KEY",
+ preferredInferenceApi: null,
+ });
+ });
+
it("skips invalid session and live sandbox names during seeded recovery", async () => {
mockRegistryState.sandboxes.gamma = gammaEntry([]);
mockRegistryState.defaultSandbox = "gamma";
@@ -206,4 +263,44 @@ describe("recoverRegistryEntries seeded recovery paths", () => {
expect(mockRegistryState.sandboxes.beta).toBeUndefined();
expect(mockRegistryState.defaultSandbox).toBeNull();
});
+
+ it("blocks route mutation after seeded recovery persists a live row without route metadata (#6315)", async () => {
+ mockRegistryState.sandboxes.gamma = gammaEntry([]);
+ mockRegistryState.defaultSandbox = "gamma";
+ vi.mocked(parseLiveSandboxEntries).mockReturnValue([
+ { name: "recovered-live", phase: "Ready" },
+ ]);
+
+ await recoverRegistryEntries({ requestedSandboxName: "missing-sandbox" });
+ expect(mockRegistryState.sandboxes["recovered-live"]).toMatchObject({
+ gatewayName: "nemoclaw",
+ provider: null,
+ model: null,
+ });
+
+ const deps = createDeps({
+ config: {},
+ entries: Object.values(mockRegistryState.sandboxes),
+ defaultSandbox: "gamma",
+ });
+ await expect(
+ runInferenceSet(
+ { provider: "nvidia-prod", model: "nvidia/model-b", sandboxName: "gamma" },
+ deps,
+ ),
+ ).rejects.toMatchObject({
+ message: expect.stringMatching(/recovered-live.*lacks durable provider or model metadata/s),
+ exitCode: 2,
+ });
+
+ expect(deps.calls.rewriteConfigUrlsWithDnsPinning).not.toHaveBeenCalled();
+ expect(deps.calls.captureOpenshell).not.toHaveBeenCalled();
+ expect(deps.calls.readSandboxConfig).not.toHaveBeenCalled();
+ expect(deps.calls.writeSandboxConfig).not.toHaveBeenCalled();
+ expect(deps.calls.recomputeSandboxConfigHash).not.toHaveBeenCalled();
+ expect(deps.calls.updateSandbox).not.toHaveBeenCalled();
+ expect(deps.calls.updateSession).not.toHaveBeenCalled();
+ expect(deps.calls.appendAuditEntry).not.toHaveBeenCalled();
+ expect(deps.calls.restartSandboxGateway).not.toHaveBeenCalled();
+ });
});
diff --git a/src/lib/state/registry-route-reservation.test.ts b/src/lib/state/registry-route-reservation.test.ts
new file mode 100644
index 00000000000..7293c4175ab
--- /dev/null
+++ b/src/lib/state/registry-route-reservation.test.ts
@@ -0,0 +1,88 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import fs from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+describe("sandbox inference route reservation", () => {
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ vi.resetModules();
+ });
+
+ it("persists a complete route without claiming the default sandbox", async () => {
+ const home = await fs.mkdtemp(path.join(os.tmpdir(), "nemoclaw-route-reservation-"));
+ vi.stubEnv("HOME", home);
+ vi.resetModules();
+ try {
+ const registry = await import("./registry");
+
+ expect(
+ registry.reserveSandboxInferenceRoute("alpha", {
+ provider: "compatible-endpoint",
+ model: "model-a",
+ endpointUrl: "https://api.example.test/v1",
+ credentialEnv: "CUSTOM_API_KEY",
+ preferredInferenceApi: "openai-responses",
+ gatewayName: "nemoclaw-9090",
+ }),
+ ).toBe(true);
+
+ expect(registry.listSandboxes()).toMatchObject({
+ defaultSandbox: null,
+ sandboxes: [
+ {
+ name: "alpha",
+ provider: "compatible-endpoint",
+ model: "model-a",
+ endpointUrl: "https://api.example.test/v1",
+ credentialEnv: "CUSTOM_API_KEY",
+ preferredInferenceApi: "openai-responses",
+ gatewayName: "nemoclaw-9090",
+ },
+ ],
+ });
+ expect(registry.getDefault()).toBeNull();
+ expect(registry.setDefault("alpha")).toBe(false);
+ } finally {
+ await fs.rm(home, { recursive: true, force: true });
+ }
+ });
+
+ it("retargets an existing row to the gateway protected by the reservation", async () => {
+ const home = await fs.mkdtemp(path.join(os.tmpdir(), "nemoclaw-route-reservation-"));
+ vi.stubEnv("HOME", home);
+ vi.resetModules();
+ try {
+ const registry = await import("./registry");
+ registry.registerSandbox({
+ name: "alpha",
+ provider: "nvidia-prod",
+ model: "model-a",
+ gatewayName: "nemoclaw",
+ gatewayPort: 8080,
+ });
+
+ registry.reserveSandboxInferenceRoute("alpha", {
+ provider: "anthropic-prod",
+ model: "model-b",
+ endpointUrl: null,
+ credentialEnv: "ANTHROPIC_API_KEY",
+ preferredInferenceApi: "anthropic-messages",
+ gatewayName: "nemoclaw-9090",
+ });
+
+ expect(registry.getSandbox("alpha")).toMatchObject({
+ gatewayName: "nemoclaw-9090",
+ provider: "anthropic-prod",
+ model: "model-b",
+ pendingRouteReservation: true,
+ });
+ expect(registry.getSandbox("alpha")?.gatewayPort).toBeUndefined();
+ } finally {
+ await fs.rm(home, { recursive: true, force: true });
+ }
+ });
+});
diff --git a/src/lib/state/registry.ts b/src/lib/state/registry.ts
index 2b72ae66a66..b39ba51b9df 100644
--- a/src/lib/state/registry.ts
+++ b/src/lib/state/registry.ts
@@ -5,7 +5,10 @@ import fs from "node:fs";
import path from "node:path";
import { isErrnoException } from "../core/errno";
import type { InferenceSelection } from "../inference/selection";
-import { inferenceSelectionRegistryFields } from "../inference/selection";
+import {
+ inferenceSelectionRegistryFields,
+ normalizeInferenceSelection,
+} from "../inference/selection";
import { normalizeToolDisclosure, type ToolDisclosure } from "../tool-disclosure";
import { ensureConfigDir, readConfigFile, writeConfigFile } from "./config-io";
import {
@@ -79,6 +82,8 @@ export interface SandboxGpuProofResult {
export interface SandboxEntry extends Partial {
name: string;
+ /** Route-only placeholder created before sandbox creation; never eligible as the default. */
+ pendingRouteReservation?: true;
createdAt?: string;
gpuEnabled?: boolean;
hostGpuDetected?: boolean;
@@ -449,10 +454,16 @@ export function getSandbox(name: string): SandboxEntry | null {
export function getDefault(): string | null {
const data = load();
- if (data.defaultSandbox && data.sandboxes[data.defaultSandbox]) {
+ if (
+ data.defaultSandbox &&
+ data.sandboxes[data.defaultSandbox] &&
+ data.sandboxes[data.defaultSandbox].pendingRouteReservation !== true
+ ) {
return data.defaultSandbox;
}
- const names = Object.keys(data.sandboxes);
+ const names = Object.values(data.sandboxes)
+ .filter((sandbox) => sandbox.pendingRouteReservation !== true)
+ .map((sandbox) => sandbox.name);
return names.length > 0 ? names[0] || null : null;
}
@@ -515,6 +526,42 @@ export function registerSandbox(entry: SandboxEntry): void {
});
}
+type SandboxInferenceRouteReservation = Pick<
+ InferenceSelection,
+ "provider" | "model" | "endpointUrl" | "credentialEnv" | "preferredInferenceApi"
+> & {
+ gatewayName: string;
+};
+
+/**
+ * Persist a route dependency before releasing the shared-gateway mutation
+ * lock. A newly reserved row deliberately does not claim the default sandbox;
+ * normal sandbox registration replaces it after creation completes.
+ */
+export function reserveSandboxInferenceRoute(
+ name: string,
+ route: SandboxInferenceRouteReservation,
+): boolean {
+ return withLock(() => {
+ const data = load();
+ const existing = data.sandboxes[name];
+ const normalized = normalizeInferenceSelection(route);
+ data.sandboxes[name] = {
+ ...(existing ?? { name, pendingRouteReservation: true as const }),
+ pendingRouteReservation: true,
+ provider: normalized.provider,
+ model: normalized.model,
+ endpointUrl: normalized.endpointUrl,
+ credentialEnv: normalized.credentialEnv,
+ preferredInferenceApi: normalized.preferredInferenceApi,
+ gatewayName: route.gatewayName,
+ gatewayPort: undefined,
+ };
+ save(data);
+ return true;
+ });
+}
+
export function updateSandbox(name: string, updates: Partial): boolean {
return withLock(() => {
const data = load();
@@ -578,7 +625,9 @@ export function listSandboxes(): { sandboxes: SandboxEntry[]; defaultSandbox: st
export function setDefault(name: string): boolean {
return withLock(() => {
- const registry = reversibleRemoval.setDefaultInRegistry(load(), name);
+ const current = load();
+ if (current.sandboxes[name]?.pendingRouteReservation === true) return false;
+ const registry = reversibleRemoval.setDefaultInRegistry(current, name);
if (!registry) return false;
save(registry);
return true;
diff --git a/test/cli/list-inference.test.ts b/test/cli/list-inference.test.ts
index 79bfaff3865..f9271ee89ec 100644
--- a/test/cli/list-inference.test.ts
+++ b/test/cli/list-inference.test.ts
@@ -20,7 +20,7 @@ import {
describe("CLI dispatch", () => {
it(
- "redirects `inference set` to openshell when provider or model is missing",
+ "keeps `inference set` inside NemoClaw when provider or model is missing",
() => {
for (const argv of [
"inference set 2>&1",
@@ -29,11 +29,13 @@ describe("CLI dispatch", () => {
]) {
const r = run(argv);
expect(r.code, `nemoclaw ${argv}`).toBe(1);
- expect(r.out, `nemoclaw ${argv}`).toContain("Unknown nemoclaw command: inference set");
- expect(r.out, `nemoclaw ${argv}`).toContain("This operation belongs to OpenShell.");
expect(r.out, `nemoclaw ${argv}`).toContain(
- "Run: openshell inference set -g nemoclaw --model --provider ",
+ "nemoclaw inference set requires --provider and --model",
);
+ expect(r.out, `nemoclaw ${argv}`).toContain(
+ "Run: nemoclaw inference set --provider --model [--sandbox ]",
+ );
+ expect(r.out, `nemoclaw ${argv}`).not.toContain("openshell inference set");
expect(r.out, `nemoclaw ${argv}`).not.toContain("Missing required flag");
expect(r.out, `nemoclaw ${argv}`).not.toContain("FailedFlagValidationError");
expect(r.out, `nemoclaw ${argv}`).not.toContain("node_modules/@oclif/core");
@@ -65,11 +67,11 @@ describe("CLI dispatch", () => {
hermesCode = result.code;
}
expect(hermesCode).toBe(1);
- expect(hermesOut).toContain("Unknown nemohermes command: inference set");
- expect(hermesOut).toContain("This operation belongs to OpenShell.");
+ expect(hermesOut).toContain("nemohermes inference set requires --provider and --model");
expect(hermesOut).toContain(
- "Run: openshell inference set -g nemoclaw --model --provider ",
+ "Run: nemohermes inference set --provider --model [--sandbox ]",
);
+ expect(hermesOut).not.toContain("openshell inference set");
},
testTimeout(15_000),
);
diff --git a/test/onboard-anthropic-compatible-openai-agent.test.ts b/test/onboard-anthropic-compatible-openai-agent.test.ts
index 2380e5d6c33..1867c735252 100644
--- a/test/onboard-anthropic-compatible-openai-agent.test.ts
+++ b/test/onboard-anthropic-compatible-openai-agent.test.ts
@@ -81,7 +81,7 @@ describe("compatible-anthropic-endpoint registration for OpenAI-only agents (#62
expect(createCommand?.command).toContain(`--credential ${CREDENTIAL_ENV}`);
expect(
harness.commands.some(({ command }) =>
- command.includes(`inference set --provider ${PROVIDER} --model ${MODEL}`),
+ command.includes(`inference set -g nemoclaw --provider ${PROVIDER} --model ${MODEL}`),
),
).toBe(true);
});
@@ -101,9 +101,9 @@ describe("compatible-anthropic-endpoint registration for OpenAI-only agents (#62
});
// Plain delete succeeded (default status 0) — no force-detach recovery.
- expect(harness.commands.some(({ command }) => command === `provider delete ${PROVIDER}`)).toBe(
- true,
- );
+ expect(
+ harness.commands.some(({ command }) => command === `provider delete -g nemoclaw ${PROVIDER}`),
+ ).toBe(true);
expect(deleteGatewayProvider).not.toHaveBeenCalled();
const createCommand = harness.commands.find(({ command }) =>
command.startsWith("provider create"),
diff --git a/test/onboard-exit-handler.test.ts b/test/onboard-exit-handler.test.ts
index c387e740a89..c3ebfd536a3 100644
--- a/test/onboard-exit-handler.test.ts
+++ b/test/onboard-exit-handler.test.ts
@@ -37,6 +37,12 @@ function requireLoadedSession(sessionDeps = onboardSession) {
return loaded ?? sessionDeps.createSession();
}
+function writeSuccessfulOpenShell(tmpDir: string): string {
+ const openshellPath = path.join(tmpDir, "openshell");
+ fs.writeFileSync(openshellPath, `#!${process.execPath}\nprocess.exit(0);\n`, { mode: 0o755 });
+ return openshellPath;
+}
+
describe("onboard exit handler registration", () => {
let tmpDir: string;
let listeners: Array<(code: number) => void>;
@@ -192,6 +198,7 @@ const { onboard } = require(${onboardPath});
it("onboard() does not mark a completed session failed on later nonzero exit", () => {
const repoRoot = path.join(import.meta.dirname, "..");
const scriptPath = path.join(tmpDir, "onboard-exit-completed.cjs");
+ const openshellPath = writeSuccessfulOpenShell(tmpDir);
const onboardPath = JSON.stringify(path.join(repoRoot, "src", "lib", "onboard.ts"));
const initialPhasesPath = JSON.stringify(
path.join(repoRoot, "src", "lib", "onboard", "machine", "initial-flow-phases.ts"),
@@ -324,6 +331,7 @@ const { onboard } = require(${onboardPath});
HOME: tmpDir,
TMPDIR: tmpDir,
NEMOCLAW_TEST_NO_SLEEP: "1",
+ NEMOCLAW_OPENSHELL_BIN: openshellPath,
},
timeout: 60_000,
});
diff --git a/test/onboard-fsm-live-slices.test.ts b/test/onboard-fsm-live-slices.test.ts
index 8ed4fd1a17b..5b8a2f91bf0 100644
--- a/test/onboard-fsm-live-slices.test.ts
+++ b/test/onboard-fsm-live-slices.test.ts
@@ -12,7 +12,14 @@ const repoRoot = path.join(import.meta.dirname, "..");
const probeTimeoutMs = 10_000;
type SliceName = "initial" | "core" | "final";
-type ProbeMode = "fresh" | "resume-initial" | "ahead-core";
+type ProbeMode =
+ | "fresh"
+ | "endpoint-override"
+ | "resume-initial"
+ | "resume-core-gateway"
+ | "resume-incomplete-core-gateway"
+ | "authoritative-core-gateway"
+ | "ahead-core";
interface ProbeOptions {
slice: SliceName;
@@ -72,6 +79,11 @@ const requiredDistArtifacts: readonly DistArtifact[] = [
"provider-inference.ts",
),
},
+ {
+ label: "gateway handler",
+ sourcePath: path.join(repoRoot, "src", "lib", "onboard", "machine", "handlers", "gateway.ts"),
+ distPath: path.join(repoRoot, "src", "lib", "onboard", "machine", "handlers", "gateway.ts"),
+ },
];
function distArtifactStatus(): { ok: true } | { ok: false; reason: string } {
@@ -103,11 +115,18 @@ function assertFreshDistArtifacts(): void {
);
}
+function writeSuccessfulOpenShell(tmpDir: string): string {
+ const openshellPath = path.join(tmpDir, "openshell");
+ fs.writeFileSync(openshellPath, `#!${process.execPath}\nprocess.exit(0);\n`, { mode: 0o755 });
+ return openshellPath;
+}
+
function probeEnvironment(tmpDir: string): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {
HOME: tmpDir,
TMPDIR: tmpDir,
PATH: process.env.PATH || "/usr/bin:/bin",
+ NEMOCLAW_OPENSHELL_BIN: writeSuccessfulOpenShell(tmpDir),
NODE_ENV: "test",
NEMOCLAW_NON_INTERACTIVE: "1",
NEMOCLAW_SANDBOX_NAME: "fsm-sandbox",
@@ -161,6 +180,10 @@ function runSliceProbe(options: ProbeOptions) {
const providerHandlerPath = JSON.stringify(
path.join(repoRoot, "src", "lib", "onboard", "machine", "handlers", "provider-inference.ts"),
);
+ const gatewayHandlerPath = JSON.stringify(
+ path.join(repoRoot, "src", "lib", "onboard", "machine", "handlers", "gateway.ts"),
+ );
+ const registryPath = JSON.stringify(path.join(repoRoot, "src", "lib", "state", "registry.ts"));
fs.writeFileSync(
scriptPath,
@@ -171,6 +194,8 @@ const { advanceTo, branchTo } = require(${resultPath});
const onboardSession = require(${sessionPath});
const preflightHandlers = require(${preflightHandlerPath});
const providerHandlers = require(${providerHandlerPath});
+const gatewayHandlers = require(${gatewayHandlerPath});
+const registry = require(${registryPath});
const called = [];
const sentinel = new Error("slice-called");
@@ -178,13 +203,20 @@ function machine(state, revision = 1) {
return { version: 1, state, stateEnteredAt: null, revision };
}
-function seedResumeSession(state) {
- onboardSession.saveSession(onboardSession.createSession({
+function seedResumeSession(state, sandboxComplete = true) {
+ const session = onboardSession.createSession({
mode: "non-interactive",
sandboxName: "fsm-sandbox",
+ provider: "openai-api",
+ model: "gpt-test",
machine: machine(state),
metadata: { gatewayName: "nemoclaw", fromDockerfile: null },
- }));
+ });
+ for (const step of ["preflight", "gateway", "provider_selection"]) {
+ session.steps[step].status = "complete";
+ }
+ if (sandboxComplete) session.steps.sandbox.status = "complete";
+ onboardSession.saveSession(session);
}
function baseContext(context, overrides = {}) {
@@ -211,7 +243,21 @@ function baseContext(context, overrides = {}) {
};
}
-preflightHandlers.handlePreflightState = async () => {
+preflightHandlers.handlePreflightState = async (options) => {
+ if (scenario.mode.includes("core-gateway")) {
+ return {
+ gpu: null,
+ sandboxGpuConfig: { sandboxGpuEnabled: false, mode: "0" },
+ resumePreflight: true,
+ resumeHasResolvedGpuIntent: true,
+ requestedGpuPassthrough: false,
+ gpuPassthrough: false,
+ effectiveSandboxGpuFlag: "disable",
+ effectiveSandboxGpuDevice: null,
+ session: options.session,
+ stateResult: advanceTo("gateway", { metadata: { state: "preflight" } }),
+ };
+ }
if (scenario.mode !== "resume-initial") {
throw new Error("unexpected preflight compatibility handler");
}
@@ -219,11 +265,25 @@ preflightHandlers.handlePreflightState = async () => {
throw sentinel;
};
-providerHandlers.handleProviderInferenceState = async () => {
- if (scenario.mode !== "ahead-core") {
+gatewayHandlers.handleGatewayState = async (options) => {
+ if (!scenario.mode.includes("core-gateway")) {
+ throw new Error("unexpected gateway compatibility handler");
+ }
+ called.push("gateway:" + options.gatewayName + ":" + process.env.OPENSHELL_GATEWAY);
+ return {
+ gatewayReuseState: "healthy",
+ session: options.session,
+ stateResult: advanceTo("provider_selection", { metadata: { state: "gateway" } }),
+ };
+};
+
+providerHandlers.handleProviderInferenceState = async (options) => {
+ if (scenario.mode !== "ahead-core" && !scenario.mode.includes("core-gateway")) {
throw new Error("unexpected provider compatibility handler");
}
- called.push("provider-compat");
+ called.push(
+ scenario.mode === "ahead-core" ? "provider-compat" : "provider-compat:" + options.gatewayName,
+ );
throw sentinel;
};
@@ -268,6 +328,18 @@ flowSlices.runFinalOnboardFlowSequence = async ({ context }) => {
if (scenario.mode === "resume-initial") {
seedResumeSession("preflight");
}
+if (scenario.mode.includes("core-gateway")) {
+ seedResumeSession("inference", scenario.mode !== "resume-incomplete-core-gateway");
+}
+if (scenario.mode === "resume-core-gateway" || scenario.mode === "resume-incomplete-core-gateway") {
+ registry.registerSandbox({
+ name: "fsm-sandbox",
+ provider: "openai-api",
+ model: "gpt-test",
+ gatewayName: "nemoclaw-9090",
+ gatewayPort: 9090,
+ });
+}
const { onboard } = require(${onboardPath});
@@ -279,11 +351,23 @@ const { onboard } = require(${onboardPath});
acceptThirdPartySoftware: true,
noGpu: true,
sandboxName: "fsm-sandbox",
- resume: scenario.mode === "resume-initial",
+ resume: scenario.mode === "resume-initial" || scenario.mode.includes("core-gateway"),
+ ...(scenario.mode === "authoritative-core-gateway"
+ ? {
+ authoritativeResumeConfig: true,
+ targetGatewayName: "nemoclaw-9090",
+ targetGatewayPort: 9090,
+ }
+ : {}),
});
throw new Error("expected slice sentinel");
} catch (error) {
- if (error === sentinel || error?.message === sentinel.message) {
+ if (
+ error === sentinel ||
+ error?.message === sentinel.message ||
+ (scenario.mode === "endpoint-override" &&
+ error?.name === "OpenShellGatewayEndpointOverrideError")
+ ) {
console.log(JSON.stringify({ called }));
return;
}
@@ -300,7 +384,12 @@ const { onboard } = require(${onboardPath});
{
cwd: repoRoot,
encoding: "utf-8",
- env: probeEnvironment(tmpDir),
+ env: {
+ ...probeEnvironment(tmpDir),
+ ...(scenario.mode === "endpoint-override"
+ ? { OPENSHELL_GATEWAY_ENDPOINT: "http://127.0.0.1:65535" }
+ : {}),
+ },
timeout: probeTimeoutMs,
},
);
@@ -335,6 +424,10 @@ describe("live onboard FSM slice boundaries", () => {
assert.deepEqual(runSliceProbe({ slice: "initial" }), ["initial"]);
});
+ it("rejects an ambient gateway endpoint before entering the initial slice", () => {
+ assert.deepEqual(runSliceProbe({ slice: "initial", mode: "endpoint-override" }), []);
+ });
+
it("enters the core slice after the initial slice reaches provider selection", () => {
assert.deepEqual(runSliceProbe({ slice: "core" }), ["initial", "core"]);
});
@@ -355,4 +448,25 @@ describe("live onboard FSM slice boundaries", () => {
"provider-compat",
]);
});
+
+ it("routes ordinary resume through the sandbox's recorded gateway", () => {
+ assert.deepEqual(runSliceProbe({ slice: "core", mode: "resume-core-gateway" }), [
+ "gateway:nemoclaw-9090:nemoclaw-9090",
+ "provider-compat:nemoclaw-9090",
+ ]);
+ });
+
+ it("routes an incomplete registered resume through its requested sandbox gateway", () => {
+ assert.deepEqual(runSliceProbe({ slice: "core", mode: "resume-incomplete-core-gateway" }), [
+ "gateway:nemoclaw-9090:nemoclaw-9090",
+ "provider-compat:nemoclaw-9090",
+ ]);
+ });
+
+ it("keeps an authoritative rebuild gateway after the registry row is removed", () => {
+ assert.deepEqual(runSliceProbe({ slice: "core", mode: "authoritative-core-gateway" }), [
+ "gateway:nemoclaw-9090:nemoclaw-9090",
+ "provider-compat:nemoclaw-9090",
+ ]);
+ });
});
diff --git a/test/onboard-inference-failure-paths.test.ts b/test/onboard-inference-failure-paths.test.ts
index 6dba8bedce6..ce09d4efec4 100644
--- a/test/onboard-inference-failure-paths.test.ts
+++ b/test/onboard-inference-failure-paths.test.ts
@@ -71,7 +71,7 @@ function stubMissingBedrockAuth(): void {
function expectNoPostFailureSideEffects(
harness: DirectSetupInferenceHarness,
- expectedCommands = ["gateway select nemoclaw"],
+ expectedCommands: string[] = [],
): void {
expect(harness.commands.map(({ command }) => command)).toEqual(expectedCommands);
expect(harness.verifyInferenceRoute).not.toHaveBeenCalled();
@@ -79,6 +79,21 @@ function expectNoPostFailureSideEffects(
expect(harness.updateSandbox).not.toHaveBeenCalled();
}
+function expectNemoclawScopedRunner(
+ harness: DirectSetupInferenceHarness,
+ runOpenshell: SetupInferenceDeps["runOpenshell"],
+): void {
+ expect(runOpenshell).not.toBe(harness.runOpenshell);
+ const commandCount = harness.commands.length;
+ runOpenshell(["provider", "list"], { ignoreError: true });
+ expect(harness.commands.at(-1)).toEqual({
+ command: "provider list -g nemoclaw",
+ env: undefined,
+ ignoreError: true,
+ });
+ harness.commands.splice(commandCount);
+}
+
describe("setupInference dependency failures", () => {
afterEach(() => {
vi.unstubAllEnvs();
@@ -177,6 +192,7 @@ describe("setupInference dependency failures", () => {
"OPENAI_API_KEY",
expect.any(String),
{ OPENAI_API_KEY: "openai-secret" },
+ "nemoclaw",
);
expect(promptValidationRecovery).not.toHaveBeenCalled();
expect(exitProcess).toHaveBeenCalledOnce();
@@ -223,8 +239,7 @@ describe("setupInference dependency failures", () => {
expect(harness.errors.join("\n")).toContain("route failed");
expect(harness.errors.join("\n")).not.toContain(NVIDIA_REDACTION_CANARY);
expectNoPostFailureSideEffects(harness, [
- "gateway select nemoclaw",
- "inference set --no-verify --provider openai-api --model gpt-test",
+ "inference set -g nemoclaw --no-verify --provider openai-api --model gpt-test",
]);
});
@@ -348,6 +363,9 @@ describe("setupInference dependency failures", () => {
expect(harness.errors).toEqual([
" container cannot reach Ollama",
" Diagnostic: proxy probe failed",
+ ...(process.platform === "darwin"
+ ? [" On macOS, local inference also depends on OpenShell host routing support."]
+ : []),
]);
expectNoPostFailureSideEffects(harness);
});
@@ -384,13 +402,17 @@ describe("setupInference dependency failures", () => {
it("exits through injected Hermes boundaries when provider storage is unavailable", async () => {
const exitProcess = createInjectedExit();
- const isHermesProviderRegistered = vi.fn(() => true);
+ const isHermesProviderRegistered = vi.fn(
+ (_runOpenshell: SetupInferenceDeps["runOpenshell"]) => true,
+ );
const ensureHermesProviderApiKeyCredentials = vi.fn(async () => ({}));
const ensureHermesProviderOAuthCredentials = vi.fn(async () => ({}));
- const checkHermesProviderStoreReachable = vi.fn(() => ({
- ok: false,
- message: "provider store unavailable",
- }));
+ const checkHermesProviderStoreReachable = vi.fn(
+ (_runOpenshell: SetupInferenceDeps["runOpenshell"]) => ({
+ ok: false,
+ message: "provider store unavailable",
+ }),
+ );
const harness = createDirectSetupInferenceHarness({
overrides: {
isNonInteractive: () => true,
@@ -409,7 +431,8 @@ describe("setupInference dependency failures", () => {
harness.setupInference("test-box", "moonshotai/kimi-k2.6", "hermes-provider"),
).rejects.toThrow("EXIT_CALLED:1");
- expect(checkHermesProviderStoreReachable).toHaveBeenCalledWith(harness.runOpenshell);
+ const runGatewayOpenshell = checkHermesProviderStoreReachable.mock.calls[0][0];
+ expectNemoclawScopedRunner(harness, runGatewayOpenshell);
expect(isHermesProviderRegistered).not.toHaveBeenCalled();
expect(ensureHermesProviderApiKeyCredentials).not.toHaveBeenCalled();
expect(ensureHermesProviderOAuthCredentials).not.toHaveBeenCalled();
@@ -425,14 +448,18 @@ describe("setupInference dependency failures", () => {
it("exits through injected boundaries when Hermes API-key preparation throws", async () => {
const exitProcess = createInjectedExit();
- const isHermesProviderRegistered = vi.fn(() => false);
+ const isHermesProviderRegistered = vi.fn(
+ (_runOpenshell: SetupInferenceDeps["runOpenshell"]) => false,
+ );
const ensureHermesProviderApiKeyCredentials = vi.fn(async () => {
throw new Error("API-key preparation failed");
});
const ensureHermesProviderOAuthCredentials = vi.fn(async () => ({}));
const providerExistsInGateway = vi.fn(() => true);
const resolveHermesNousApiKey = vi.fn(() => "nous-secret");
- const checkHermesProviderStoreReachable = vi.fn(() => ({ ok: true }));
+ const checkHermesProviderStoreReachable = vi.fn(
+ (_runOpenshell: SetupInferenceDeps["runOpenshell"]) => ({ ok: true }),
+ );
const harness = createDirectSetupInferenceHarness({
overrides: {
isNonInteractive: () => true,
@@ -461,13 +488,14 @@ describe("setupInference dependency failures", () => {
),
).rejects.toThrow("EXIT_CALLED:1");
- expect(checkHermesProviderStoreReachable).toHaveBeenCalledWith(harness.runOpenshell);
- expect(isHermesProviderRegistered).toHaveBeenCalledWith(harness.runOpenshell);
+ const runGatewayOpenshell = checkHermesProviderStoreReachable.mock.calls[0][0];
+ expectNemoclawScopedRunner(harness, runGatewayOpenshell);
+ expect(isHermesProviderRegistered).toHaveBeenCalledWith(runGatewayOpenshell);
expect(providerExistsInGateway).not.toHaveBeenCalled();
expect(ensureHermesProviderApiKeyCredentials).toHaveBeenCalledOnce();
expect(ensureHermesProviderApiKeyCredentials).toHaveBeenCalledWith("test-box", {
apiKey: "nous-secret",
- runOpenshell: harness.runOpenshell,
+ runOpenshell: runGatewayOpenshell,
baseUrl: undefined,
});
expect(ensureHermesProviderOAuthCredentials).not.toHaveBeenCalled();
@@ -481,14 +509,18 @@ describe("setupInference dependency failures", () => {
it("exits through injected boundaries when Hermes OAuth preparation throws", async () => {
const exitProcess = createInjectedExit();
- const isHermesProviderRegistered = vi.fn(() => false);
+ const isHermesProviderRegistered = vi.fn(
+ (_runOpenshell: SetupInferenceDeps["runOpenshell"]) => false,
+ );
const ensureHermesProviderApiKeyCredentials = vi.fn(async () => ({}));
const ensureHermesProviderOAuthCredentials = vi.fn(async () => {
throw new Error("OAuth preparation failed");
});
const providerExistsInGateway = vi.fn(() => true);
const resolveHermesNousApiKey = vi.fn(() => "unused-key");
- const checkHermesProviderStoreReachable = vi.fn(() => ({ ok: true }));
+ const checkHermesProviderStoreReachable = vi.fn(
+ (_runOpenshell: SetupInferenceDeps["runOpenshell"]) => ({ ok: true }),
+ );
const harness = createDirectSetupInferenceHarness({
overrides: {
isNonInteractive: () => true,
@@ -517,15 +549,16 @@ describe("setupInference dependency failures", () => {
),
).rejects.toThrow("EXIT_CALLED:1");
- expect(checkHermesProviderStoreReachable).toHaveBeenCalledWith(harness.runOpenshell);
- expect(isHermesProviderRegistered).toHaveBeenCalledWith(harness.runOpenshell);
+ const runGatewayOpenshell = checkHermesProviderStoreReachable.mock.calls[0][0];
+ expectNemoclawScopedRunner(harness, runGatewayOpenshell);
+ expect(isHermesProviderRegistered).toHaveBeenCalledWith(runGatewayOpenshell);
expect(providerExistsInGateway).not.toHaveBeenCalled();
expect(resolveHermesNousApiKey).not.toHaveBeenCalled();
expect(ensureHermesProviderApiKeyCredentials).not.toHaveBeenCalled();
expect(ensureHermesProviderOAuthCredentials).toHaveBeenCalledOnce();
expect(ensureHermesProviderOAuthCredentials).toHaveBeenCalledWith("test-box", {
allowInteractiveLogin: false,
- runOpenshell: harness.runOpenshell,
+ runOpenshell: runGatewayOpenshell,
baseUrl: undefined,
toolGatewayPresets: [],
});
@@ -779,8 +812,7 @@ describe("setupInference dependency failures", () => {
" Bedrock Runtime adapter ready: region us-east-1, sandbox route http://host.openshell.internal:11436/v1, host log /tmp/bedrock-adapter.log",
]);
expectNoPostFailureSideEffects(harness, [
- "gateway select nemoclaw",
- `inference set --no-verify --provider compatible-anthropic-endpoint --model ${BEDROCK_MODEL} --timeout 180`,
+ `inference set -g nemoclaw --no-verify --provider compatible-anthropic-endpoint --model ${BEDROCK_MODEL} --timeout 180`,
]);
});
@@ -823,8 +855,7 @@ describe("setupInference dependency failures", () => {
" Bedrock Runtime adapter ready: region us-east-1, sandbox route http://host.openshell.internal:11436/v1, host log /tmp/bedrock-adapter.log",
]);
expectNoPostFailureSideEffects(harness, [
- "gateway select nemoclaw",
- `inference set --no-verify --provider compatible-anthropic-endpoint --model ${BEDROCK_MODEL} --timeout 180`,
+ `inference set -g nemoclaw --no-verify --provider compatible-anthropic-endpoint --model ${BEDROCK_MODEL} --timeout 180`,
]);
});
@@ -887,7 +918,9 @@ describe("setupInference dependency failures", () => {
const reconcileModelRouter = vi.fn(async () => {});
const upsertProvider = vi.fn(() => ({ ok: true }));
const hydrateCredentialEnv = vi.fn(() => "unused-secret");
- const upsertRoutedProvider = vi.fn(() => ({
+ const upsertRoutedProvider = vi.fn<
+ SetupInferenceDeps["routedInference"]["upsertRoutedProvider"]
+ >(() => ({
ok: false,
result: { status: 29, message: "routed provider registration rejected" },
}));
@@ -918,10 +951,30 @@ describe("setupInference dependency failures", () => {
"nvidia-router",
"http://host.openshell.internal:4000/v1",
"NVIDIA_INFERENCE_API_KEY",
- { upsertProvider, hydrateCredentialEnv },
+ {
+ upsertProvider: expect.any(Function),
+ hydrateCredentialEnv,
+ },
);
+ const routedUpsertProvider = upsertRoutedProvider.mock.calls[0][3].upsertProvider;
+ expect(routedUpsertProvider).not.toBe(upsertProvider);
expect(upsertProvider).not.toHaveBeenCalled();
expect(hydrateCredentialEnv).not.toHaveBeenCalled();
+ routedUpsertProvider(
+ "nvidia-router",
+ "openai",
+ "NVIDIA_INFERENCE_API_KEY",
+ "http://host.openshell.internal:4000/v1",
+ { NVIDIA_INFERENCE_API_KEY: "test-secret" },
+ );
+ expect(upsertProvider).toHaveBeenCalledWith(
+ "nvidia-router",
+ "openai",
+ "NVIDIA_INFERENCE_API_KEY",
+ "http://host.openshell.internal:4000/v1",
+ { NVIDIA_INFERENCE_API_KEY: "test-secret" },
+ "nemoclaw",
+ );
expect(exitProcess).toHaveBeenCalledOnce();
expect(exitProcess).toHaveBeenCalledWith(29);
expect(harness.errors).toEqual([" routed provider registration rejected"]);
@@ -970,8 +1023,7 @@ describe("setupInference dependency failures", () => {
expect(harness.errors.join("\n")).toContain("routed apply failed");
expect(harness.errors.join("\n")).not.toContain(NVIDIA_REDACTION_CANARY);
expectNoPostFailureSideEffects(harness, [
- "gateway select nemoclaw",
- "inference set --no-verify --provider nvidia-router --model router/model",
+ "inference set -g nemoclaw --no-verify --provider nvidia-router --model router/model",
]);
});
@@ -1001,15 +1053,19 @@ describe("setupInference dependency failures", () => {
expect(reconcileModelRouter).toHaveBeenCalledOnce();
expect(upsertRoutedProvider).toHaveBeenCalledOnce();
expect(harness.commands).toEqual([
- { command: "gateway select nemoclaw", ignoreError: true, env: undefined },
{
- command: "inference set --no-verify --provider nvidia-router --model router/model",
+ command:
+ "inference set -g nemoclaw --no-verify --provider nvidia-router --model router/model",
ignoreError: true,
env: undefined,
},
]);
expect(harness.verifyInferenceRoute).toHaveBeenCalledOnce();
- expect(harness.verifyInferenceRoute).toHaveBeenCalledWith("nvidia-router", "router/model");
+ expect(harness.verifyInferenceRoute).toHaveBeenCalledWith(
+ "nemoclaw",
+ "nvidia-router",
+ "router/model",
+ );
expect(harness.verifyOnboardInferenceSmoke).toHaveBeenCalledOnce();
expect(harness.verifyOnboardInferenceSmoke).toHaveBeenCalledWith({
provider: "nvidia-router",
@@ -1021,6 +1077,10 @@ describe("setupInference dependency failures", () => {
expect(harness.updateSandbox).toHaveBeenCalledWith("test-box", {
model: "router/model",
provider: "nvidia-router",
+ endpointUrl: "http://host.openshell.internal:4000/v1",
+ credentialEnv: "NVIDIA_INFERENCE_API_KEY",
+ preferredInferenceApi: null,
+ gatewayName: "nemoclaw",
});
expect(harness.logs).toEqual([" ✓ Inference route set: nvidia-router / router/model"]);
expect(harness.errors).toEqual([]);
diff --git a/test/onboard-inference-gateway-scope.test.ts b/test/onboard-inference-gateway-scope.test.ts
new file mode 100644
index 00000000000..d45ddbff111
--- /dev/null
+++ b/test/onboard-inference-gateway-scope.test.ts
@@ -0,0 +1,127 @@
+// 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 { SetupInference, SetupInferenceDeps } from "../src/lib/onboard/setup-inference.js";
+import {
+ createDirectCommandRouter,
+ createDirectSetupInferenceHarnessFactory,
+ withProcessEnv,
+} from "./support/setup-inference-test-harness.js";
+
+const onboard = require("../src/lib/onboard") as {
+ createSetupInference: (overrides?: Partial) => SetupInference;
+};
+
+const createHarness = createDirectSetupInferenceHarnessFactory(onboard.createSetupInference);
+const GATEWAY = "nemoclaw-9090";
+
+function expectCommandsTargetOnly(commands: Array<{ command: string }>): void {
+ expect(commands.some(({ command }) => command.startsWith("gateway select"))).toBe(false);
+ const gatewayStateCommands = commands.filter(
+ ({ command }) => /^(provider|inference) /.test(command) || /^sandbox provider /.test(command),
+ );
+ for (const { command } of gatewayStateCommands) {
+ expect(command.match(/(?:^| )-g nemoclaw-9090(?: |$)/g)).toHaveLength(1);
+ }
+}
+
+describe("onboarding inference gateway scope", () => {
+ it("targets a non-default gateway for provider creation, route apply, and verification", async () => {
+ await withProcessEnv({ OPENAI_API_KEY: "sk-TEST-NOT-A-REAL-VALUE" }, async () => {
+ const harness = createHarness({
+ runOpenshell: (args) =>
+ args.slice(0, 2).join(" ") === "provider get" ? { status: 1 } : undefined,
+ });
+
+ await expect(
+ harness.setupInference(
+ "test-box",
+ "gpt-test",
+ "openai-api",
+ "https://api.openai.com/v1",
+ "OPENAI_API_KEY",
+ null,
+ [],
+ { gatewayName: GATEWAY },
+ ),
+ ).resolves.toEqual({ ok: true });
+
+ expect(harness.commands.map(({ command }) => command)).toEqual([
+ `provider get -g ${GATEWAY} openai-api`,
+ `provider create -g ${GATEWAY} --name openai-api --type openai --credential OPENAI_API_KEY --config OPENAI_BASE_URL=https://api.openai.com/v1`,
+ `inference set -g ${GATEWAY} --no-verify --provider openai-api --model gpt-test`,
+ ]);
+ expect(harness.verifyInferenceRoute).toHaveBeenCalledWith(GATEWAY, "openai-api", "gpt-test");
+ expectCommandsTargetOnly(harness.commands);
+ });
+ });
+
+ it("keeps compatible-endpoint replacement and detach recovery on the target gateway", async () => {
+ await withProcessEnv(
+ { COMPATIBLE_ANTHROPIC_API_KEY: "sk-ant-TEST-NOT-A-REAL-VALUE" },
+ async () => {
+ const commandRouter = createDirectCommandRouter([
+ {
+ name: "provider-get",
+ matches: (command) => command.startsWith(`provider get -g ${GATEWAY}`),
+ results: [
+ {
+ status: 0,
+ stdout: [
+ "Name: compatible-anthropic-endpoint",
+ "Type: anthropic",
+ "Credential keys: COMPATIBLE_ANTHROPIC_API_KEY",
+ "Config keys: ANTHROPIC_BASE_URL",
+ ].join("\n"),
+ },
+ { status: 1 },
+ ],
+ },
+ {
+ name: "provider-delete",
+ matches: (command) => command.startsWith(`provider delete -g ${GATEWAY}`),
+ results: [
+ {
+ status: 1,
+ stderr:
+ "provider 'compatible-anthropic-endpoint' is attached to sandbox(es): test-box",
+ },
+ {
+ status: 1,
+ stderr:
+ "provider 'compatible-anthropic-endpoint' is attached to sandbox(es): test-box",
+ },
+ { status: 0 },
+ ],
+ },
+ ]);
+ const harness = createHarness({
+ runOpenshell: commandRouter.runOpenshell,
+ overrides: {
+ probeOpenAiLikeEndpoint: vi.fn(() => ({ ok: true })),
+ },
+ });
+
+ await expect(
+ harness.setupInference(
+ "test-box",
+ "claude-test",
+ "compatible-anthropic-endpoint",
+ "https://example.test",
+ "COMPATIBLE_ANTHROPIC_API_KEY",
+ null,
+ [],
+ { gatewayName: GATEWAY, preferredInferenceApi: "openai-completions" },
+ ),
+ ).resolves.toEqual({ ok: true });
+
+ expect(commandRouter.callCount("provider-delete")).toBe(3);
+ expect(harness.commands.map(({ command }) => command)).toContain(
+ `sandbox provider detach -g ${GATEWAY} test-box compatible-anthropic-endpoint`,
+ );
+ expectCommandsTargetOnly(harness.commands);
+ },
+ );
+ });
+});
diff --git a/test/onboard-inference-smoke.test.ts b/test/onboard-inference-smoke.test.ts
index 25af5044bf2..41d2a04eb03 100644
--- a/test/onboard-inference-smoke.test.ts
+++ b/test/onboard-inference-smoke.test.ts
@@ -114,6 +114,9 @@ const { setupInference } = require(${onboardPath});
"compatible-endpoint",
"https://broken.example.invalid/v1",
"BROKEN_API_KEY",
+ null,
+ [],
+ { preferredInferenceApi: "openai-completions" },
);
console.log(JSON.stringify({ outcome: "resolved", calls }));
})().catch((error) => {
diff --git a/test/onboard-messaging.test.ts b/test/onboard-messaging.test.ts
index 445df67d002..3f5e70209b3 100644
--- a/test/onboard-messaging.test.ts
+++ b/test/onboard-messaging.test.ts
@@ -535,9 +535,9 @@ registry.registerSandbox({
runner.run = (command, opts = {}) => {
const normalized = _n(command);
commands.push({ command: normalized, env: opts.env || null });
- if (normalized.includes("provider get my-assistant-discord-bridge")) return { status: 0 };
- if (normalized.includes("provider get my-assistant-slack-bridge")) return { status: 0 };
- if (normalized.includes("provider get my-assistant-slack-app")) return { status: 0 };
+ if (normalized.includes("provider get -g nemoclaw my-assistant-discord-bridge")) return { status: 0 };
+ if (normalized.includes("provider get -g nemoclaw my-assistant-slack-bridge")) return { status: 0 };
+ if (normalized.includes("provider get -g nemoclaw my-assistant-slack-app")) return { status: 0 };
if (normalized.includes("provider get")) return { status: 1 };
return { status: 0 };
};
@@ -699,7 +699,7 @@ registry.registerSandbox({
runner.run = (command, opts = {}) => {
const normalized = _n(command);
commands.push({ command: normalized, env: opts.env || null });
- if (normalized.includes("provider get my-assistant-telegram-bridge")) return { status: 0 };
+ if (normalized.includes("provider get -g nemoclaw my-assistant-telegram-bridge")) return { status: 0 };
if (normalized.includes("provider get")) return { status: 1 };
return { status: 0 };
};
diff --git a/test/onboard-nim-route-containment.test.ts b/test/onboard-nim-route-containment.test.ts
new file mode 100644
index 00000000000..8d1b3b3abe8
--- /dev/null
+++ b/test/onboard-nim-route-containment.test.ts
@@ -0,0 +1,120 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import assert from "node:assert/strict";
+import { spawnSync } from "node:child_process";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+
+import { describe, it } from "vitest";
+
+import { testTimeout } from "./helpers/timeouts";
+
+describe("onboard provider-discovery route containment", () => {
+ it(
+ "rejects NIM and custom routes before provisioning, credentials, or endpoint probes (#6315)",
+ () => {
+ const repoRoot = path.join(import.meta.dirname, "..");
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-nim-route-guard-"));
+ const scriptPath = path.join(tmpDir, "nim-route-guard.js");
+ const onboardPath = JSON.stringify(path.join(repoRoot, "src", "lib", "onboard.ts"));
+ const credentialsPath = JSON.stringify(
+ path.join(repoRoot, "src", "lib", "credentials", "store.ts"),
+ );
+ const runnerPath = JSON.stringify(path.join(repoRoot, "src", "lib", "runner.ts"));
+ const nimPath = JSON.stringify(path.join(repoRoot, "src", "lib", "inference", "nim.ts"));
+ const script = String.raw`
+const runner = require(${runnerPath});
+const credentials = require(${credentialsPath});
+const nim = require(${nimPath});
+const calls = [];
+runner.runCapture = (command) => {
+ const text = Array.isArray(command) ? command.join(" ") : String(command);
+ if (/https?:|\/v1\/models|\/api\/tags/.test(text)) calls.push("endpoint-probe");
+ return "";
+};
+credentials.prompt = async () => { calls.push("credential-prompt"); return ""; };
+credentials.saveCredential = () => { calls.push("credential-save"); };
+nim.listModels = () => [{
+ name: "nvidia/nemotron-3-nano-30b-a3b",
+ image: "fake",
+ minGpuMemoryMB: 8000,
+}];
+nim.isNgcLoggedIn = () => { calls.push("ngc-login-check"); return true; };
+nim.dockerLoginNgc = () => { calls.push("ngc-login"); return true; };
+nim.pullNimImage = () => { calls.push("pull"); return "image"; };
+nim.containerName = () => { calls.push("container-name"); return "nim-test"; };
+nim.startNimContainerByName = () => { calls.push("start"); return "nim-test"; };
+nim.waitForNimHealth = () => { calls.push("health"); return true; };
+nim.adoptServedModelId = () => { calls.push("served-model"); return "served/model"; };
+const { setupNim } = require(${onboardPath});
+(async () => {
+ const originalLog = console.log;
+ console.log = () => {};
+ const runGuarded = async (gpu) => {
+ calls.length = 0;
+ let message = "";
+ let route = null;
+ try {
+ await setupNim(gpu, null, null, true, null, "nemoclaw", (candidate) => {
+ route = candidate;
+ calls.push("guard");
+ throw new Error("route conflict");
+ }, () => false);
+ } catch (error) {
+ message = error instanceof Error ? error.message : String(error);
+ }
+ return { calls: [...calls], message, route };
+ };
+ const nimResult = await runGuarded({
+ type: "nvidia",
+ totalMemoryMB: 16000,
+ nimCapable: true,
+ });
+ process.env.NEMOCLAW_PROVIDER = "custom";
+ process.env.NEMOCLAW_MODEL = "custom/model";
+ process.env.NEMOCLAW_ENDPOINT_URL = "https://custom.example.test/v1";
+ const customResult = await runGuarded(null);
+ console.log = originalLog;
+ originalLog(JSON.stringify({ customResult, nimResult }));
+})().catch((error) => {
+ console.error(error);
+ process.exit(1);
+});
+`;
+ fs.writeFileSync(scriptPath, script);
+ try {
+ const result = spawnSync(process.execPath, [scriptPath], {
+ cwd: repoRoot,
+ encoding: "utf-8",
+ env: {
+ ...process.env,
+ HOME: tmpDir,
+ NEMOCLAW_EXPERIMENTAL: "1",
+ NEMOCLAW_MODEL: "nvidia/nemotron-3-nano-30b-a3b",
+ NEMOCLAW_NON_INTERACTIVE: "1",
+ NEMOCLAW_PROVIDER: "nim-local",
+ },
+ });
+ assert.equal(result.status, 0, result.stderr);
+ const payload = JSON.parse(result.stdout.trim());
+ assert.equal(payload.nimResult.message, "route conflict");
+ assert.deepEqual(payload.nimResult.calls, ["guard"]);
+ assert.equal(payload.nimResult.route.provider, "vllm-local");
+ assert.equal(payload.customResult.message, "route conflict");
+ assert.deepEqual(payload.customResult.calls, ["guard"]);
+ assert.deepEqual(payload.customResult.route, {
+ provider: "compatible-endpoint",
+ model: "custom/model",
+ endpointUrl: "https://custom.example.test/v1",
+ preferredInferenceApi: "openai-completions",
+ credentialEnv: "COMPATIBLE_API_KEY",
+ });
+ } finally {
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+ }
+ },
+ testTimeout(60_000),
+ );
+});
diff --git a/test/onboard-prepared-gateway-handoff.test.ts b/test/onboard-prepared-gateway-handoff.test.ts
index 3d08ed1c0f1..7f1fec4d8a9 100644
--- a/test/onboard-prepared-gateway-handoff.test.ts
+++ b/test/onboard-prepared-gateway-handoff.test.ts
@@ -156,11 +156,11 @@ describe("prepared DCode gateway handoff", () => {
});
});
- it("continues clearing an ordinary onboard run's ambient gateway (#6195)", () => {
+ it("scopes an ordinary onboard run to the default gateway (#6315)", () => {
assert.deepEqual(runHandoffScenario("ordinary"), {
error: null,
flowCalls: 1,
- gatewayAtInitialFlow: null,
+ gatewayAtInitialFlow: "nemoclaw",
});
});
diff --git a/test/onboard-remote-recreate-credential-reuse.test.ts b/test/onboard-remote-recreate-credential-reuse.test.ts
index 09d10ae7c1b..cc29ab1723a 100644
--- a/test/onboard-remote-recreate-credential-reuse.test.ts
+++ b/test/onboard-remote-recreate-credential-reuse.test.ts
@@ -45,7 +45,7 @@ Gateway inference:
Version: 1
EOF
fi
-if [ "$1" = "provider" ] && [ "$2" = "get" ] && [ "$3" = "compatible-endpoint" ]; then
+if [ "$1" = "provider" ] && [ "$2" = "get" ] && [ "$3" = "-g" ] && [ "$5" = "compatible-endpoint" ]; then
cat <<'EOF'
Provider:
@@ -120,6 +120,7 @@ const { setupNim, setupInference } = require(${onboardPath});
selected.hermesAuthMethod,
selected.hermesToolGateways,
{
+ preferredInferenceApi: selected.preferredInferenceApi,
skipHostInferenceSmoke: selected.skipHostInferenceSmoke,
reuseGatewayCredentialWithoutLocalKey:
process.env.NEMOCLAW_TEST_OMIT_REUSE_AUTHORIZATION === "1"
@@ -171,13 +172,19 @@ const { setupNim, setupInference } = require(${onboardPath});
});
assert.deepEqual(
curlUrls,
- ["http://127.0.0.1:11434/api/tags", "http://127.0.0.1:8000/v1/models"],
- `only exact loopback discovery probes may run without a local credential: ${curlLog}`,
+ [],
+ `remote recovery must not run unrelated local endpoint probes: ${curlLog}`,
);
const openshellLog = fs.readFileSync(openshellLogPath, "utf8");
- assert.match(openshellLog, /provider get compatible-endpoint/);
- assert.match(openshellLog, /inference set --no-verify --provider compatible-endpoint/);
- assert.ok(!openshellLog.includes("provider update compatible-endpoint"), openshellLog);
+ assert.match(openshellLog, /provider get -g nemoclaw compatible-endpoint/);
+ assert.match(
+ openshellLog,
+ /inference set -g nemoclaw --no-verify --provider compatible-endpoint/,
+ );
+ assert.ok(
+ !openshellLog.includes("provider update -g nemoclaw compatible-endpoint"),
+ openshellLog,
+ );
assert.ok(!openshellLog.includes("OPENAI_BASE_URL="), openshellLog);
assert.ok(!openshellLog.includes("--credential"), openshellLog);
@@ -236,7 +243,7 @@ const { setupNim, setupInference } = require(${onboardPath});
assert.match(unauthorizedOutput, /A host credential is required to configure provider/);
const unauthorizedOpenshellLog = fs.readFileSync(openshellLogPath, "utf8");
assert.ok(
- !unauthorizedOpenshellLog.includes("provider update compatible-endpoint") &&
+ !unauthorizedOpenshellLog.includes("provider update") &&
!unauthorizedOpenshellLog.includes("inference set"),
`smoke suppression alone must not authorize gateway credential reuse: ${unauthorizedOpenshellLog}`,
);
@@ -274,7 +281,7 @@ const { setupNim, setupInference } = require(${onboardPath});
);
const conflictingEndpointOpenshellLog = fs.readFileSync(openshellLogPath, "utf8");
assert.ok(
- !conflictingEndpointOpenshellLog.includes("provider update compatible-endpoint") &&
+ !conflictingEndpointOpenshellLog.includes("provider update") &&
!conflictingEndpointOpenshellLog.includes("inference set"),
`endpoint drift must fail before provider or route mutation: ${conflictingEndpointOpenshellLog}`,
);
diff --git a/test/onboard-selection.test.ts b/test/onboard-selection.test.ts
index 9d08d3551c3..34f55b451ff 100644
--- a/test/onboard-selection.test.ts
+++ b/test/onboard-selection.test.ts
@@ -192,12 +192,13 @@ function makeSetupNimFlowDeps(overrides: Partial = {}): SetupN
handleVllmSelection: async () => unexpected("vLLM selection"),
handleRoutedSelection: async () => unexpected("routed selection"),
coerceAgentInferenceApi: (_agent, preferredInferenceApi) => preferredInferenceApi,
+ resolveAgentInferenceApi: (_agentName, _provider, preferredInferenceApi) =>
+ preferredInferenceApi,
clearCompatibleEndpointReasoning: () => null,
maybePromptForInferenceInputCapability: async () => {},
...overrides,
};
}
-
function makeInstallOllamaLinuxOptions(
overrides: Partial = {},
): InstallOllamaLinuxOptions {
@@ -223,7 +224,6 @@ function makeInstallOllamaLinuxOptions(
...overrides,
};
}
-
function successfulRunShellResult(): ReturnType<
NonNullable
> {
@@ -4801,7 +4801,7 @@ runner.runCapture = (cmd) => {
process.env.COMPATIBLE_API_KEY = "test-key";
const { setupInference } = require(${onboardPath});
(async () => {
- await setupInference(null, "qwen3.6:35b", "compatible-endpoint", "http://lan-server:11434/v1", "COMPATIBLE_API_KEY");
+ await setupInference(null, "qwen3.6:35b", "compatible-endpoint", "http://lan-server:11434/v1", "COMPATIBLE_API_KEY", null, [], { preferredInferenceApi: "openai-completions" });
process.exit(0);
})().catch((err) => { console.error(err); process.exit(1); });
`;
diff --git a/test/onboard.test.ts b/test/onboard.test.ts
index 400aa4c0f58..6889188576f 100644
--- a/test/onboard.test.ts
+++ b/test/onboard.test.ts
@@ -736,11 +736,8 @@ startGateway(null).catch(() => {});
assert.deepEqual(evidence.argvContainingSecret, []);
assert.deepEqual(evidence.secretBearingCommands, ["provider update"]);
assert.equal(evidence.providerCommand.env.NVIDIA_INFERENCE_API_KEY, credentialValue);
- assert.equal(
- evidence.unscopedCommandKinds.join(","),
- "gateway select,provider get,inference set",
- );
- assert.deepEqual(evidence.unscopedCredentialValues, [null, null, null]);
+ assert.deepEqual(evidence.unscopedCommandKinds, []);
+ assert.deepEqual(evidence.unscopedCredentialValues, []);
assert.deepEqual(evidence.unscopedCommandsContainingSecret, []);
assert.deepEqual(evidence.setupCredentialValues, [credentialValue, credentialValue]);
assert.equal(evidence.parentCredentialUnchanged, true);
@@ -754,7 +751,7 @@ startGateway(null).catch(() => {});
async () => {
const harness = createDirectSetupInferenceHarness({
runOpenshell: (args) =>
- args.join(" ") === "provider get hermes-provider"
+ args.join(" ") === "provider get -g nemoclaw hermes-provider"
? { status: 0, stdout: "Provider: hermes-provider", stderr: "" }
: undefined,
overrides: { isNonInteractive: () => true },
@@ -770,11 +767,14 @@ startGateway(null).catch(() => {});
);
const commands = harness.commands;
- assert.equal(commands.length, 4);
- assert.match(commands[0].command, /gateway select nemoclaw/);
- assert.match(commands[1].command, /provider list/);
- assert.match(commands[2].command, /provider get hermes-provider/);
- assert.match(commands[3].command, /inference set --no-verify --provider hermes-provider/);
+ assert.equal(commands.length, 3);
+ assert.equal(commands[0].command, "provider list -g nemoclaw");
+ assert.equal(commands[1].command, "provider get -g nemoclaw hermes-provider");
+ assert.match(
+ commands[2].command,
+ /inference set -g nemoclaw --no-verify --provider hermes-provider/,
+ );
+ assert.ok(!commands.some((entry) => entry.command.startsWith("gateway select")));
assert.ok(!commands.some((entry) => /provider (create|update)/.test(entry.command)));
assert.ok(!commands.some((entry) => entry.env?.NOUS_API_KEY || entry.env?.OPENAI_API_KEY));
assert.ok(
@@ -798,7 +798,7 @@ startGateway(null).catch(() => {});
const setupBedrockRuntimeInference = bedrockRuntimeOnboard.setupBedrockRuntimeInference;
const harness = createDirectSetupInferenceHarness({
runOpenshell: (args) =>
- args.join(" ") === "provider get compatible-anthropic-endpoint"
+ args.join(" ") === "provider get -g nemoclaw compatible-anthropic-endpoint"
? { status: 1, stdout: "", stderr: "" }
: undefined,
overrides: {
@@ -865,12 +865,10 @@ startGateway(null).catch(() => {});
);
assert.match(
commands.at(-1)?.command || "",
- /inference set --no-verify --provider compatible-anthropic-endpoint --model anthropic\.claude-3-5-sonnet-20240620-v1:0/,
+ /inference set -g nemoclaw --no-verify --provider compatible-anthropic-endpoint --model anthropic\.claude-3-5-sonnet-20240620-v1:0/,
);
- expect(updateSandbox).toHaveBeenCalledWith("test-box", {
- model: "anthropic.claude-3-5-sonnet-20240620-v1:0",
- provider: "compatible-anthropic-endpoint",
- });
+ // biome-ignore format: keep the complete route reservation assertion within this legacy file's enforced budget.
+ expect(updateSandbox).toHaveBeenCalledWith("test-box", { model: "anthropic.claude-3-5-sonnet-20240620-v1:0", provider: "compatible-anthropic-endpoint", endpointUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", credentialEnv: "COMPATIBLE_ANTHROPIC_API_KEY", preferredInferenceApi: null, gatewayName: "nemoclaw" });
});
});
it("resolves a sandbox name before reconciling Hermes Provider on resume", {
@@ -975,7 +973,7 @@ registry.getSandbox = (name) =>
policies: ["nous-web"],
}
: null;
-registry.updateSandbox = (name, updates) => {
+registry.reserveSandboxInferenceRoute = (name, updates) => {
registryUpdates.push({ name, updates });
return true;
};
@@ -1091,7 +1089,7 @@ const { onboard } = require(${onboardPath});
);
assert.ok(
payload.commands.some((entry) =>
- /inference set --no-verify --provider hermes-provider/.test(entry.command),
+ /inference set -g nemoclaw --no-verify --provider hermes-provider/.test(entry.command),
),
"resume should reach openshell inference set",
);
@@ -1121,7 +1119,7 @@ const { onboard } = require(${onboardPath});
async () => {
const harness = createDirectSetupInferenceHarness({
runOpenshell: (args) =>
- args.join(" ") === "provider get hermes-provider"
+ args.join(" ") === "provider get -g nemoclaw hermes-provider"
? { status: 0, stdout: "Provider: hermes-provider", stderr: "" }
: undefined,
overrides: { isNonInteractive: () => true },
@@ -1137,7 +1135,7 @@ const { onboard } = require(${onboardPath});
);
const update = harness.commands.find((entry) =>
- /provider update hermes-provider/.test(entry.command),
+ /provider update -g nemoclaw hermes-provider/.test(entry.command),
);
assert.ok(update);
assert.match(update.command, /--credential NOUS_API_KEY/);
@@ -1148,7 +1146,7 @@ const { onboard } = require(${onboardPath});
);
assert.match(
harness.commands.at(-1)?.command || "",
- /inference set --no-verify --provider hermes-provider/,
+ /inference set -g nemoclaw --no-verify --provider hermes-provider/,
);
},
);
@@ -1197,19 +1195,7 @@ const { onboard } = require(${onboardPath});
});
it("recovers the Ollama auth proxy on WSL when the sandbox needs proxy fronting", async () => {
const proxyCalls: string[] = [];
- let harness: ReturnType;
- const applyLocalInferenceRoute = createLocalInferenceRouteApplier({
- runOpenshell: (args, options) => harness.runOpenshell(args, options),
- isNonInteractive: () => false,
- promptValidationRecovery: async () => "selection",
- classifyApplyFailure: () => ({}) as never,
- compactText: (value) => value.trim(),
- redact: (value) => value,
- localInferenceTimeoutSecs: 120,
- error: vi.fn(),
- exitProcess: () => assert.fail("unexpected exit"),
- });
- harness = createDirectSetupInferenceHarness({
+ const harness = createDirectSetupInferenceHarness({
runOpenshell: (args) =>
args.slice(0, 2).join(" ") === "provider get"
? { status: 1, stdout: "", stderr: "" }
@@ -1230,7 +1216,7 @@ const { onboard } = require(${onboardPath});
persistAndProbeOllamaProxy: async (token: string) => {
proxyCalls.push(`persist:${token}`);
},
- applyLocalInferenceRoute,
+ applyLocalInferenceRoute: undefined,
},
});
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
@@ -1250,7 +1236,7 @@ const { onboard } = require(${onboardPath});
assert.doesNotMatch(providerCommand.command, /proxy-token/);
assert.ok(
harness.commands.some((entry) =>
- entry.command.includes("inference set --no-verify --provider ollama-local"),
+ entry.command.includes("inference set -g nemoclaw --no-verify --provider ollama-local"),
),
"expected ollama-local inference route to be selected",
);
@@ -1301,7 +1287,7 @@ const { onboard } = require(${onboardPath});
warn.mockRestore();
}
const setCmd = harness.commands.find((entry) =>
- entry.command.includes("inference set --no-verify --provider ollama-local"),
+ entry.command.includes("inference set -g nemoclaw --no-verify --provider ollama-local"),
);
assert.ok(setCmd, "expected ollama-local inference set command to be issued");
assert.equal(
@@ -1346,7 +1332,7 @@ const { onboard } = require(${onboardPath});
);
const setCmd = harness.commands.find((entry) =>
- entry.command.includes("inference set --no-verify --provider vllm-local"),
+ entry.command.includes("inference set -g nemoclaw --no-verify --provider vllm-local"),
);
assert.ok(setCmd, "expected vllm-local inference set command to be issued");
assert.equal(
@@ -1370,7 +1356,7 @@ const { onboard } = require(${onboardPath});
fs.writeFileSync(
fakeOpenshell,
`#!/usr/bin/env bash
-if [ "$1" = "inference" ] && [ "$2" = "get" ]; then
+if [ "$1" = "inference" ] && [ "$2" = "get" ] && [ "$3" = "-g" ] && [ "$4" = "team-gateway" ]; then
cat <<'EOF'
Gateway inference:
@@ -1391,9 +1377,9 @@ exit 1
`
const { isInferenceRouteReady } = require(${onboardPath});
console.log(JSON.stringify({
- same: isInferenceRouteReady("nvidia-prod", "nvidia/nemotron-3-super-120b-a12b"),
- otherModel: isInferenceRouteReady("nvidia-prod", "nvidia/other-model"),
- otherProvider: isInferenceRouteReady("openai-api", "nvidia/nemotron-3-super-120b-a12b"),
+ same: isInferenceRouteReady("team-gateway", "nvidia-prod", "nvidia/nemotron-3-super-120b-a12b"),
+ otherModel: isInferenceRouteReady("team-gateway", "nvidia-prod", "nvidia/other-model"),
+ otherProvider: isInferenceRouteReady("team-gateway", "openai-api", "nvidia/nemotron-3-super-120b-a12b"),
}));
`,
);
@@ -1547,13 +1533,14 @@ console.log(JSON.stringify({
);
const commands = harness.commands;
- assert.equal(commands.length, 4);
- assert.match(commands[0].command, /gateway select nemoclaw/);
- assert.match(commands[1].command, /provider get/);
- assert.match(commands[2].command, /--type anthropic/);
- assert.match(commands[2].command, /--credential ANTHROPIC_API_KEY/);
- assert.doesNotMatch(commands[2].command, /sk-ant-TEST-NOT-A-REAL-VALUE/);
- assert.match(commands[3].command, /--provider anthropic-prod/);
+ assert.equal(commands.length, 3);
+ assert.match(commands[0].command, /^provider get -g nemoclaw /);
+ assert.match(commands[1].command, /^provider create -g nemoclaw /);
+ assert.match(commands[1].command, /--type anthropic/);
+ assert.match(commands[1].command, /--credential ANTHROPIC_API_KEY/);
+ assert.doesNotMatch(commands[1].command, /sk-ant-TEST-NOT-A-REAL-VALUE/);
+ assert.match(commands[2].command, /^inference set -g nemoclaw /);
+ assert.match(commands[2].command, /--provider anthropic-prod/);
});
});
it("updates OpenAI-compatible providers without passing an unsupported --type flag", async () => {
@@ -1574,12 +1561,11 @@ console.log(JSON.stringify({
);
const commands = harness.commands;
- assert.equal(commands.length, 4);
- assert.match(commands[0].command, /gateway select nemoclaw/);
- assert.match(commands[1].command, /provider get/);
- assert.match(commands[2].command, /provider update openai-api/);
- assert.doesNotMatch(commands[2].command, /--type/);
- assert.match(commands[3].command, /inference set --no-verify/);
+ assert.equal(commands.length, 3);
+ assert.match(commands[0].command, /^provider get -g nemoclaw /);
+ assert.match(commands[1].command, /^provider update -g nemoclaw openai-api/);
+ assert.doesNotMatch(commands[1].command, /--type/);
+ assert.match(commands[2].command, /^inference set -g nemoclaw --no-verify/);
});
});
it("re-prompts for credentials when openshell inference set fails with authorization errors", async () => {
@@ -1703,7 +1689,7 @@ console.log(JSON.stringify({
"legacy credentials.json must survive the staging-only hydrate path",
);
const providerUpdate = harness.commands.find((entry) =>
- entry.command.includes("provider update openai-api"),
+ entry.command.includes("provider update -g nemoclaw openai-api"),
);
assert.ok(providerUpdate, "expected provider update command");
assert.equal(providerUpdate.env?.OPENAI_API_KEY, "sk-TEST-NOT-A-REAL-STORED-KEY");
@@ -3911,8 +3897,8 @@ const { createSandbox } = require(${onboardPath});
"OPENAI_API_KEY",
);
- // gateway select + provider get + provider update + inference set
- assert.equal(harness.commands.length, 4);
+ // provider get + provider update + inference set
+ assert.equal(harness.commands.length, 3);
});
});
it("accepts gateway inference output that omits the Route line", async () => {
@@ -3946,8 +3932,8 @@ const { createSandbox } = require(${onboardPath});
"OPENAI_API_KEY",
);
- // gateway select + provider get + provider update + inference set
- assert.equal(harness.commands.length, 4);
+ // provider get + provider update + inference set
+ assert.equal(harness.commands.length, 3);
});
});
it("uses the sandbox-base registry in pullAndResolveBaseImageDigest (#1904)", () => {
diff --git a/test/rebuild-stale-recovery.test.ts b/test/rebuild-stale-recovery.test.ts
index 1c59beffe2d..eac5a4695f9 100644
--- a/test/rebuild-stale-recovery.test.ts
+++ b/test/rebuild-stale-recovery.test.ts
@@ -54,7 +54,7 @@ installRebuildFlowTestHooks();
* matches it, and whose fake `openshell sandbox list` returns EMPTY — modelling
* the stale state where the live gateway no longer knows the sandbox.
*/
-function createStaleFixture() {
+function createStaleFixture({ failSandboxCreate = false }: { failSandboxCreate?: boolean } = {}) {
const sandboxName = "my-assistant";
const provider = "nvidia-prod";
const credentialEnv = "NVIDIA_INFERENCE_API_KEY";
@@ -139,6 +139,7 @@ const requiredFeatures = "request-body-credential-rewrite websocket-credential-r
if (a[0]==="-V" || a[0]==="--version") { process.stdout.write("openshell 0.0.72\\n"); process.exit(0); }
if (a[0]==="sandbox" && a[1]==="list") { process.stdout.write("\\n"); process.exit(0); }
if (a[0]==="sandbox" && a[1]==="delete") { process.exit(0); }
+if (a[0]==="sandbox" && a[1]==="create" && ${JSON.stringify(failSandboxCreate)}) { process.stderr.write("injected sandbox create failure\\n"); process.exit(1); }
if (a[0]==="sandbox" && a[1]==="get") { process.stderr.write("Error: × Not Found: sandbox not found\\n"); process.exit(1); }
if (a[0]==="status") { ${healthyTargetStatus} }
if (a[0]==="gateway" && a[1]==="info") { process.stdout.write("Gateway Info\\n\\nGateway: ${targetGatewayName}\\nGateway endpoint: https://127.0.0.1:${targetGatewayPort}/\\n"); process.exit(0); }
@@ -349,11 +350,11 @@ describe("stale sandbox rebuild recovery (#4497)", () => {
timeout: 90_000,
}, () => {
// Stale recovery removes the registry entry before the recreate (the
- // recreate re-adds it on success). The fixture's onboard --resume cannot
- // complete, so the recreate fails — the entry must be restored so the
+ // recreate re-adds it on success). Inject a sandbox-create failure so the
+ // recreate fails — the entry must be restored so the
// recommended `rebuild --yes` stays retryable instead of failing at
// dispatch with "not found in registry" (#4497).
- const fixture = createStaleFixture();
+ const fixture = createStaleFixture({ failSandboxCreate: true });
const result = runRebuild(fixture);
const output = (result.stderr || "") + (result.stdout || "");
diff --git a/test/sandbox-connect-inference/route-swap-repair.test.ts b/test/sandbox-connect-inference/route-swap-repair.test.ts
index f874ad0900b..e38dc87eaea 100644
--- a/test/sandbox-connect-inference/route-swap-repair.test.ts
+++ b/test/sandbox-connect-inference/route-swap-repair.test.ts
@@ -29,9 +29,11 @@ describe("sandbox connect inference route swap (#1248)", () => {
expect(result.status).toBe(0);
const state = JSON.parse(fs.readFileSync(stateFile, "utf-8"));
- expect(state.inferenceGetCalls).toEqual([[]]);
+ expect(state.inferenceGetCalls).toEqual([["-g", "nemoclaw"]]);
expect(state.inferenceSetCalls.length).toBe(1);
expect(state.inferenceSetCalls[0]).toEqual([
+ "-g",
+ "nemoclaw",
"--provider",
"anthropic-prod",
"--model",
@@ -84,7 +86,17 @@ describe("sandbox connect inference route swap (#1248)", () => {
const curlCalls = state.curlCalls as string[][];
const curlEnvs = state.curlEnvs as Record[];
expect(state.inferenceSetCalls).toEqual([
- ["--provider", "ollama-local", "--model", "qwen3:0.6b", "--no-verify", "--timeout", "321"],
+ [
+ "-g",
+ "nemoclaw",
+ "--provider",
+ "ollama-local",
+ "--model",
+ "qwen3:0.6b",
+ "--no-verify",
+ "--timeout",
+ "321",
+ ],
]);
if (!isHostWsl()) {
expect(curlCalls.some((call) => call.join(" ").includes("127.0.0.1:11435/v1/models"))).toBe(
@@ -155,7 +167,17 @@ describe("sandbox connect inference route swap (#1248)", () => {
.map((call, index) => (call.join(" ").includes("host.docker.internal:11434") ? index : -1))
.filter((index) => index >= 0);
expect(state.inferenceSetCalls).toEqual([
- ["--provider", "ollama-local", "--model", "qwen3:0.6b", "--no-verify", "--timeout", "180"],
+ [
+ "-g",
+ "nemoclaw",
+ "--provider",
+ "ollama-local",
+ "--model",
+ "qwen3:0.6b",
+ "--no-verify",
+ "--timeout",
+ "180",
+ ],
]);
expect(windowsHostIndexes.length).toBeGreaterThan(0);
for (const index of windowsHostIndexes) {
diff --git a/test/support/connect-flow-test-harness.ts b/test/support/connect-flow-test-harness.ts
index 21fe98c98db..a273b5adbe5 100644
--- a/test/support/connect-flow-test-harness.ts
+++ b/test/support/connect-flow-test-harness.ts
@@ -10,6 +10,8 @@ import type { SecretBoundaryRefusalReason } from "../../src/lib/actions/sandbox/
import type { SandboxEntry } from "../../src/lib/state/registry";
type ConnectSandbox = typeof import("../../src/lib/actions/sandbox/connect")["connectSandbox"];
+type GatewayRouteMutationLock =
+ typeof import("../../src/lib/inference/gateway-route-mutation-lock")["withGatewayRouteMutationLock"];
export const requireDist = createRequire(import.meta.url);
export const connectModulePath = "../../src/lib/actions/sandbox/connect.js";
@@ -25,13 +27,16 @@ export type ConnectHarness = {
checkAndRecoverSpy: MockInstance;
connectSandbox: ConnectSandbox;
ensureOllamaAuthProxySpy: MockInstance;
+ ensureLiveSandboxSpy: MockInstance;
errorSpy: MockInstance;
logSpy: MockInstance;
preflightVllmSpy: MockInstance;
+ registryEntries: SandboxEntry[];
runAutoPairSpy: MockInstance;
runOpenshellSpy: MockInstance;
runSetupDnsProxySpy: MockInstance;
spawnSyncSpy: MockInstance;
+ withGatewayRouteMutationLockSpy: MockInstance;
};
export type ConnectHarnessOptions = {
@@ -39,6 +44,7 @@ export type ConnectHarnessOptions = {
inferenceGetOutput?: string;
inferenceProbeResponses?: string[];
registryEntry?: Partial;
+ registryEntries?: Array & Pick>;
sessionAgent?: unknown;
listOutput?: string;
processCheck?: {
@@ -56,6 +62,7 @@ export type ConnectHarnessOptions = {
spawnSignal?: NodeJS.Signals | null;
spawnStatus?: number | null;
sttyThrows?: boolean;
+ withGatewayRouteMutationLock?: GatewayRouteMutationLock;
};
function throwSttyFailure(): never {
@@ -97,6 +104,9 @@ export function createConnectHarness(options: ConnectHarnessOptions = {}): Conne
"../../src/lib/actions/sandbox/gateway-failure-classifier.js",
);
const ollamaProxy = requireDist("../../src/lib/inference/ollama/proxy.js");
+ const gatewayRouteMutationLock = requireDist(
+ "../../src/lib/inference/gateway-route-mutation-lock.js",
+ );
const sandboxVersion = requireDist("../../src/lib/sandbox/version.js");
const registry = requireDist("../../src/lib/state/registry.js");
const sandboxSession = requireDist("../../src/lib/state/sandbox-session.js");
@@ -105,7 +115,7 @@ export function createConnectHarness(options: ConnectHarnessOptions = {}): Conne
const preflightVllmSpy = vi
.spyOn(connectVllmPreflight, "preflightVllmModelEnvOrExit")
.mockImplementation(() => undefined);
- vi.spyOn(gatewayState, "ensureLiveSandboxOrExit").mockResolvedValue({
+ const ensureLiveSandboxSpy = vi.spyOn(gatewayState, "ensureLiveSandboxOrExit").mockResolvedValue({
state: "present",
output: "Name: alpha\nPhase: Ready\n",
});
@@ -134,6 +144,13 @@ export function createConnectHarness(options: ConnectHarnessOptions = {}): Conne
return { status: 0, output: "" };
});
const runOpenshellSpy = vi.spyOn(runtime, "runOpenshell").mockReturnValue({ status: 0 });
+ const withGatewayRouteMutationLockSpy = vi
+ .spyOn(gatewayRouteMutationLock, "withGatewayRouteMutationLock")
+ .mockImplementation(
+ (options.withGatewayRouteMutationLock ??
+ (async (_gatewayName: string, operation: () => Promise | unknown) =>
+ await operation())) as never,
+ );
const runSetupDnsProxySpy = vi.spyOn(dns, "runSetupDnsProxy").mockReturnValue({ exitCode: 0 });
const applyVmDnsMonkeypatchSpy = vi
.spyOn(vmDnsMonkeypatch, "applyOpenShellVmDnsMonkeypatch")
@@ -152,7 +169,7 @@ export function createConnectHarness(options: ConnectHarnessOptions = {}): Conne
const ensureOllamaAuthProxySpy = vi
.spyOn(ollamaProxy, "ensureOllamaAuthProxy")
.mockImplementation(() => undefined);
- vi.spyOn(registry, "getSandbox").mockReturnValue({
+ const primaryRegistryEntry: SandboxEntry = {
name: "alpha",
agent: options.agentName ?? "openclaw",
provider: null,
@@ -160,6 +177,27 @@ export function createConnectHarness(options: ConnectHarnessOptions = {}): Conne
gpuEnabled: false,
policies: [],
...options.registryEntry,
+ };
+ const registryEntries: SandboxEntry[] = options.registryEntries
+ ? options.registryEntries.map((candidate) =>
+ candidate.name === primaryRegistryEntry.name
+ ? { ...primaryRegistryEntry, ...candidate }
+ : {
+ agent: "openclaw",
+ provider: null,
+ model: null,
+ gpuEnabled: false,
+ policies: [],
+ ...candidate,
+ },
+ )
+ : [primaryRegistryEntry];
+ vi.spyOn(registry, "getSandbox").mockImplementation(
+ (name: unknown) => registryEntries.find((candidate) => candidate.name === String(name)) ?? null,
+ );
+ vi.spyOn(registry, "listSandboxes").mockReturnValue({
+ sandboxes: registryEntries,
+ defaultSandbox: primaryRegistryEntry.name,
});
vi.spyOn(agentRuntime, "getSessionAgent").mockReturnValue(
(options.sessionAgent ?? { name: "openclaw" }) as never,
@@ -179,12 +217,15 @@ export function createConnectHarness(options: ConnectHarnessOptions = {}): Conne
checkAndRecoverSpy,
connectSandbox: requireDist(connectModulePath).connectSandbox,
ensureOllamaAuthProxySpy,
+ ensureLiveSandboxSpy,
errorSpy,
logSpy,
preflightVllmSpy,
+ registryEntries,
runAutoPairSpy,
runOpenshellSpy,
runSetupDnsProxySpy,
spawnSyncSpy,
+ withGatewayRouteMutationLockSpy,
};
}
diff --git a/test/support/setup-inference-test-harness.ts b/test/support/setup-inference-test-harness.ts
index 9aada712d9c..11eaf2e6177 100644
--- a/test/support/setup-inference-test-harness.ts
+++ b/test/support/setup-inference-test-harness.ts
@@ -6,7 +6,11 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { vi } from "vitest";
-import type { SetupInference, SetupInferenceDeps } from "../../src/lib/onboard/setup-inference.js";
+import {
+ createGatewayScopedOpenshellRunner,
+ type SetupInference,
+ type SetupInferenceDeps,
+} from "../../src/lib/onboard/setup-inference.js";
const onboardProviderHelpers = require("../../src/lib/onboard/providers") as {
upsertProvider: (
@@ -17,6 +21,7 @@ const onboardProviderHelpers = require("../../src/lib/onboard/providers") as {
env: Record,
runOpenshell: DirectRunOpenshell,
) => { ok: boolean; status?: number; message?: string };
+ providerExistsInGateway: (name: string, runOpenshell: DirectRunOpenshell) => boolean;
};
const localInferenceModule =
require("../../src/lib/inference/local") as typeof import("../../src/lib/inference/local.js");
@@ -175,10 +180,16 @@ const setupCredentialBefore = process.env[credentialEnv] || null;
/^provider (create|update) /.test(argv.join(" ")),
);
if (!providerCommand) throw new Error("Production setupInference did not mutate a provider");
- const unscopedPatterns = [/^gateway select /, /^provider get /, /^inference set /];
- const unscopedCommands = unscopedPatterns
- .map((pattern) => commands.find(({ argv }) => pattern.test(argv.join(" "))))
- .filter((command): command is ProductionOpenshellCommandRecord => command !== undefined);
+ const unscopedCommands = commands.filter(({ argv }) => {
+ if (argv[0] === "gateway" && argv[1] === "select") return true;
+ if (argv[0] !== "provider" && argv[0] !== "inference") return false;
+ return (
+ !argv.some(
+ (arg, index) =>
+ (arg === "-g" || arg === "--gateway") && typeof argv[index + 1] === "string",
+ ) && !argv.some((arg) => arg.startsWith("--gateway="))
+ );
+ });
const containsSecret = ({ env }: ProductionOpenshellCommandRecord) =>
Object.values(env).some((value) => value.includes(options.credentialValue));
const credentialEvidence = {
@@ -271,6 +282,7 @@ export function createDirectSetupInferenceHarnessFactory(
return directRunResult(options.runOpenshell?.(args, runOptions, commands));
};
const setupInference = createSetupInference({
+ checkGatewayRouteCompatibility: () => ({ ok: true }),
step: () => {},
getGatewayName: () => "nemoclaw",
runOpenshell,
@@ -279,18 +291,24 @@ export function createDirectSetupInferenceHarnessFactory(
type: string,
credentialEnv: string,
baseUrl: string | null,
- env: Record = {},
+ env: Record | undefined,
+ gatewayName: string,
) =>
onboardProviderHelpers.upsertProvider(
name,
type,
credentialEnv,
baseUrl,
- env,
- runOpenshell,
+ env ?? {},
+ createGatewayScopedOpenshellRunner(runOpenshell, gatewayName),
),
verifyInferenceRoute,
verifyOnboardInferenceSmoke,
+ providerExistsInGateway: (name: string, gatewayName: string) =>
+ onboardProviderHelpers.providerExistsInGateway(
+ name,
+ createGatewayScopedOpenshellRunner(runOpenshell, gatewayName),
+ ),
isNonInteractive: () => false,
updateSandbox,
resolveHermesNousApiKey: () => process.env.NOUS_API_KEY || null,