From ab67617132253ae813f3beaeef09c7c30fd74062 Mon Sep 17 00:00:00 2001 From: San Dang Date: Sat, 11 Jul 2026 00:42:25 +0700 Subject: [PATCH 1/6] fix(inference): route local compatible endpoints through gateway Signed-off-by: San Dang --- .../local-compatible-inference-setup.mdx | 28 ++++++-- src/lib/onboard/inference-providers/remote.ts | 64 ++++++++++++++++--- test/inference-options-docs.test.ts | 36 +++++++++-- test/onboard-inference-gateway-scope.test.ts | 49 ++++++++++++++ 4 files changed, 154 insertions(+), 23 deletions(-) diff --git a/docs/inference/local-compatible-inference-setup.mdx b/docs/inference/local-compatible-inference-setup.mdx index 1f0d7e8fdc..9cf9ba1f7a 100644 --- a/docs/inference/local-compatible-inference-setup.mdx +++ b/docs/inference/local-compatible-inference-setup.mdx @@ -56,10 +56,18 @@ $$nemoclaw onboard When the wizard asks you to choose an inference provider, select **Other OpenAI-compatible endpoint**. Enter the base URL of your local server, for example `http://localhost:8000/v1` on the default Docker-driver topology. -For a containerized OpenShell gateway that cannot reach host `localhost`, use the host-gateway URL for your setup, commonly `http://host.openshell.internal:8000/v1`. +For HTTP loopback endpoints on the default HTTP port or an unprivileged port, such as `http://localhost:8000/v1`, NemoClaw validates the endpoint from the host and registers the OpenShell gateway route through `host.openshell.internal:` so sandbox traffic can leave the container namespace. +This rewrite depends on an OpenShell topology that resolves `host.openshell.internal` inside the sandbox; if that bridge is unavailable, onboarding can still validate the host URL, but `$$nemoclaw status` is the authoritative runtime check. +Make sure the local server listens on an address reachable from containers, such as `0.0.0.0`; a server bound only to `127.0.0.1` can still be unreachable from the sandbox route. + + +Binding a model server to `0.0.0.0` exposes it on every host interface unless a host firewall or interface policy blocks access. +Restrict the listener to the OpenShell/Docker bridge when your server supports that, otherwise firewall the port to the bridge network or enable endpoint authentication before using dummy API keys. + + Port `8000` is included in NemoClaw's `local-inference` policy preset. -This is a sandbox-internal alias, so host-side endpoint probing is skipped during onboarding. -Use a routable endpoint when you need onboarding to verify the API, tool-calling, and streaming paths. +If you manually enter a sandbox-internal alias such as `http://host.openshell.internal:8000/v1`, host-side endpoint probing is skipped during onboarding. +Use a host-routable endpoint such as `localhost` when you need onboarding to verify the API, tool-calling, and streaming paths before gateway registration. Otherwise, confirm the runtime after onboarding with `$$nemoclaw status` and a short agent request. For GGUF models, start a compatible server such as `llama-server` yourself and enter the model id that server reports from `/v1/models`. @@ -73,7 +81,7 @@ Start llama.cpp on the host: ```bash llama-server \ -m /models/NVIDIA-Nemotron3-Nano-4B-Q4_K_M.gguf \ - --host 127.0.0.1 \ + --host 0.0.0.0 \ --port 8000 \ -c 16384 \ -ngl 999 \ @@ -81,11 +89,17 @@ llama-server \ --chat-template chatml ``` + +This example uses `--host 0.0.0.0` only to make the server reachable from containers. +Do not leave an unauthenticated model server exposed on public, corporate, or Wi-Fi interfaces; firewall port `8000` to the OpenShell/Docker bridge or enable API-key authentication. + + Select **Other OpenAI-compatible endpoint** during onboarding. Enter the server base URL, such as `http://localhost:8000/v1`, and the model id reported by `/v1/models`. -For a containerized OpenShell gateway, use the reachable host-service URL for your setup, commonly `http://host.openshell.internal:8000/v1`. -Host-side endpoint probing is skipped during onboarding for this sandbox-internal alias. -Use a routable endpoint when you need onboarding to verify the API, tool-calling, and streaming paths. +For HTTP loopback endpoints on the default HTTP port or an unprivileged port, NemoClaw validates the endpoint from the host and registers the OpenShell gateway route through `host.openshell.internal:` for sandbox traffic. +This rewrite depends on an OpenShell topology that resolves `host.openshell.internal` inside the sandbox; if that bridge is unavailable, onboarding can still validate the host URL, but `$$nemoclaw status` is the authoritative runtime check. +If you manually enter a sandbox-internal alias such as `http://host.openshell.internal:8000/v1`, host-side endpoint probing is skipped during onboarding. +Use a host-routable endpoint such as `localhost` when you need onboarding to verify the API, tool-calling, and streaming paths. After onboarding, run `$$nemoclaw status` and check the `Inference` row before starting long agent work. diff --git a/src/lib/onboard/inference-providers/remote.ts b/src/lib/onboard/inference-providers/remote.ts index 0fd7b946eb..3eaef6d811 100644 --- a/src/lib/onboard/inference-providers/remote.ts +++ b/src/lib/onboard/inference-providers/remote.ts @@ -23,6 +23,39 @@ const { probeOpenAiLikeEndpoint } = require("../../inference/onboard-probes") as type StaleProviderReplaceResult = { ok: boolean; status?: number | null; message?: string }; +// #5744: keep host-side validation on the user-entered loopback URL, but +// register the sandbox route through OpenShell's host bridge. Remove this when +// OpenShell can verify provider routes from the sandbox/gateway network context. +function gatewayReachableCompatibleEndpointUrl( + provider: string, + endpointUrl: string | null | undefined, +): string | null | undefined { + if (provider !== "compatible-endpoint" || !endpointUrl) return endpointUrl; + let parsed: URL; + try { + parsed = new URL(endpointUrl); + } catch { + return endpointUrl; + } + const hostname = parsed.hostname.replace(/^\[|\]$/g, "").toLowerCase(); + if (hostname.includes("%")) return endpointUrl; + const port = parsed.port ? Number(parsed.port) : null; + const isLoopback = hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1"; + if ( + parsed.protocol !== "http:" || + parsed.username || + parsed.password || + !isLoopback || + (port !== null && (!Number.isInteger(port) || port < 1024)) + ) { + return endpointUrl; + } + parsed.hostname = "host.openshell.internal"; + const pathname = parsed.pathname.replace(/\/+$/, ""); + parsed.pathname = pathname || "/"; + return parsed.pathname === "/" ? parsed.origin : `${parsed.origin}${parsed.pathname}`; +} + /** * Replace a provider that a prior Anthropic-Messages registration left behind * so it can be re-registered as `type=openai` for the OpenAI-compatible route @@ -232,6 +265,7 @@ export async function setupRemoteProviderInference( while (true) { const resolvedCredentialEnv = credentialEnv || (config && config.credentialEnv); const resolvedEndpointUrl = endpointUrl || (config && config.endpointUrl); + const gatewayEndpointUrl = gatewayReachableCompatibleEndpointUrl(provider, resolvedEndpointUrl); let providerResult; if (reuseGatewayCredentialWithoutLocalKey) { // This is only a last-moment existence probe. The primary authorization @@ -241,14 +275,23 @@ export async function setupRemoteProviderInference( ignoreError: true, suppressOutput: true, }); - providerResult = - existing.status === 0 - ? { ok: true } - : { - ok: false, - status: existing.status || 1, - message: `Recovered provider '${provider}' is no longer registered in OpenShell.`, - }; + if (existing.status !== 0) { + providerResult = { + ok: false, + status: existing.status || 1, + message: `Recovered provider '${provider}' is no longer registered in OpenShell.`, + }; + } else if (gatewayEndpointUrl !== resolvedEndpointUrl) { + providerResult = upsertProvider( + provider, + config.providerType, + resolvedCredentialEnv, + gatewayEndpointUrl, + {}, + ); + } else { + providerResult = { ok: true }; + } } else { const credentialValue = hydrateCredentialEnv(resolvedCredentialEnv); const env = @@ -311,7 +354,7 @@ export async function setupRemoteProviderInference( provider, config.providerType, resolvedCredentialEnv, - resolvedEndpointUrl, + gatewayEndpointUrl, env, ); } @@ -336,7 +379,8 @@ export async function setupRemoteProviderInference( return exitProcess(providerResult.status || 1); } const argsv = ["inference", "set"]; - if (config.skipVerify) { + if (config.skipVerify || gatewayEndpointUrl !== resolvedEndpointUrl) { + // Host-side verification cannot resolve the sandbox-only bridge URL. argsv.push("--no-verify"); } argsv.push("--provider", provider, "--model", model); diff --git a/test/inference-options-docs.test.ts b/test/inference-options-docs.test.ts index ab056e0016..b812f20af8 100644 --- a/test/inference-options-docs.test.ts +++ b/test/inference-options-docs.test.ts @@ -209,11 +209,29 @@ describe("inference setup navigation", () => { expect(section).toContain("[Use Ollama for Local Inference](use-local-inference)"); }); - it("uses a loopback-only bind for the raw model server example", () => { + it("uses a container-reachable bind for the raw model server example (#5744)", () => { const markdown = fs.readFileSync(selfHostedInferenceSetupPath, "utf8"); - - expect(markdown).toContain("--host 127.0.0.1"); - expect(markdown).not.toContain("--host 0.0.0.0"); + const bindGuidance = + "Make sure the local server listens on an address reachable from containers, such as `0.0.0.0`"; + const bindWarning = + "Binding a model server to `0.0.0.0` exposes it on every host interface unless a host firewall or interface policy blocks access."; + const exampleWarning = + "This example uses `--host 0.0.0.0` only to make the server reachable from containers."; + + expect(markdown).toContain("--host 0.0.0.0"); + expect(markdown).not.toContain("--host 127.0.0.1"); + expect( + markdown.slice(markdown.indexOf(bindGuidance), markdown.indexOf(bindGuidance) + 700), + ).toContain(bindWarning); + expect( + markdown.slice(markdown.indexOf("--host 0.0.0.0"), markdown.indexOf("--host 0.0.0.0") + 700), + ).toContain(exampleWarning); + expect(markdown).toContain( + "a server bound only to `127.0.0.1` can still be unreachable from the sandbox route.", + ); + expect(markdown).toContain( + "This rewrite depends on an OpenShell topology that resolves `host.openshell.internal` inside the sandbox", + ); }); it("routes vLLM tool-calling remediation to the self-hosted server guide", () => { @@ -279,10 +297,16 @@ describe("inference setup navigation", () => { expect(result.note).toContain("validation skipped"); expect(markdown).toContain("`http://host.openshell.internal:8000/v1`"); expect(markdown).toContain( - "This is a sandbox-internal alias, so host-side endpoint probing is skipped during onboarding.", + "For HTTP loopback endpoints on the default HTTP port or an unprivileged port, such as `http://localhost:8000/v1`, NemoClaw validates the endpoint from the host and registers the OpenShell gateway route through `host.openshell.internal:` so sandbox traffic can leave the container namespace.", + ); + expect(markdown).toContain( + "if that bridge is unavailable, onboarding can still validate the host URL, but `$$nemoclaw status` is the authoritative runtime check.", + ); + expect(markdown).toContain( + "If you manually enter a sandbox-internal alias such as `http://host.openshell.internal:8000/v1`, host-side endpoint probing is skipped during onboarding.", ); expect(markdown).toContain( - "Use a routable endpoint when you need onboarding to verify the API, tool-calling, and streaming paths.", + "Use a host-routable endpoint such as `localhost` when you need onboarding to verify the API, tool-calling, and streaming paths", ); const textAfterEachAlias = markdown.split(hostGatewayAlias).slice(1); expect(textAfterEachAlias).toHaveLength(2); diff --git a/test/onboard-inference-gateway-scope.test.ts b/test/onboard-inference-gateway-scope.test.ts index d45ddbff11..3762cd37fa 100644 --- a/test/onboard-inference-gateway-scope.test.ts +++ b/test/onboard-inference-gateway-scope.test.ts @@ -57,6 +57,55 @@ describe("onboarding inference gateway scope", () => { }); }); + it("registers loopback compatible endpoints through the gateway alias but keeps host smoke local (#5744)", async () => { + await withProcessEnv( + { COMPATIBLE_API_KEY: "sk-compatible-TEST-NOT-A-REAL-VALUE" }, + async () => { + const harness = createHarness({ + runOpenshell: (args) => + args.slice(0, 2).join(" ") === "provider get" ? { status: 1 } : undefined, + }); + const endpointUrl = "http://localhost:8000/v1"; + const model = "deepseek-ai/DeepSeek-V4-Flash"; + + await expect( + harness.setupInference( + "dcode-vllm-local", + model, + "compatible-endpoint", + endpointUrl, + "COMPATIBLE_API_KEY", + null, + [], + { gatewayName: GATEWAY }, + ), + ).resolves.toEqual({ ok: true }); + + expect(harness.commands.map(({ command }) => command)).toEqual([ + `provider get -g ${GATEWAY} compatible-endpoint`, + `provider create -g ${GATEWAY} --name compatible-endpoint --type openai --credential COMPATIBLE_API_KEY --config OPENAI_BASE_URL=http://host.openshell.internal:8000/v1`, + `inference set -g ${GATEWAY} --no-verify --provider compatible-endpoint --model ${model} --timeout 180`, + ]); + expect(harness.verifyOnboardInferenceSmoke).toHaveBeenCalledWith({ + provider: "compatible-endpoint", + model, + endpointUrl, + credentialEnv: "COMPATIBLE_API_KEY", + pinnedAddresses: [], + }); + expect(harness.updateSandbox).toHaveBeenCalledWith("dcode-vllm-local", { + provider: "compatible-endpoint", + model, + endpointUrl, + credentialEnv: "COMPATIBLE_API_KEY", + preferredInferenceApi: null, + gatewayName: GATEWAY, + }); + 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" }, From a5e56373aa3662c46a91d5c74f582c75c2434e3a Mon Sep 17 00:00:00 2001 From: San Dang Date: Sat, 11 Jul 2026 00:51:48 +0700 Subject: [PATCH 2/6] fix(inference): preserve loopback endpoint URL suffixes Signed-off-by: San Dang --- src/lib/onboard/inference-providers/remote.ts | 5 ++++- test/onboard-inference-gateway-scope.test.ts | 4 ++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/lib/onboard/inference-providers/remote.ts b/src/lib/onboard/inference-providers/remote.ts index 3eaef6d811..cd93fb37f2 100644 --- a/src/lib/onboard/inference-providers/remote.ts +++ b/src/lib/onboard/inference-providers/remote.ts @@ -53,7 +53,10 @@ function gatewayReachableCompatibleEndpointUrl( parsed.hostname = "host.openshell.internal"; const pathname = parsed.pathname.replace(/\/+$/, ""); parsed.pathname = pathname || "/"; - return parsed.pathname === "/" ? parsed.origin : `${parsed.origin}${parsed.pathname}`; + const routeSuffix = `${parsed.search}${parsed.hash}`; + return parsed.pathname === "/" + ? `${parsed.origin}${routeSuffix}` + : `${parsed.origin}${parsed.pathname}${routeSuffix}`; } /** diff --git a/test/onboard-inference-gateway-scope.test.ts b/test/onboard-inference-gateway-scope.test.ts index 3762cd37fa..d753666153 100644 --- a/test/onboard-inference-gateway-scope.test.ts +++ b/test/onboard-inference-gateway-scope.test.ts @@ -65,7 +65,7 @@ describe("onboarding inference gateway scope", () => { runOpenshell: (args) => args.slice(0, 2).join(" ") === "provider get" ? { status: 1 } : undefined, }); - const endpointUrl = "http://localhost:8000/v1"; + const endpointUrl = "http://localhost:8000/v1?tenant=local#models"; const model = "deepseek-ai/DeepSeek-V4-Flash"; await expect( @@ -83,7 +83,7 @@ describe("onboarding inference gateway scope", () => { expect(harness.commands.map(({ command }) => command)).toEqual([ `provider get -g ${GATEWAY} compatible-endpoint`, - `provider create -g ${GATEWAY} --name compatible-endpoint --type openai --credential COMPATIBLE_API_KEY --config OPENAI_BASE_URL=http://host.openshell.internal:8000/v1`, + `provider create -g ${GATEWAY} --name compatible-endpoint --type openai --credential COMPATIBLE_API_KEY --config OPENAI_BASE_URL=http://host.openshell.internal:8000/v1?tenant=local#models`, `inference set -g ${GATEWAY} --no-verify --provider compatible-endpoint --model ${model} --timeout 180`, ]); expect(harness.verifyOnboardInferenceSmoke).toHaveBeenCalledWith({ From eeedbd1ae525b334f1893c9bca7746a6a908d554 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Fri, 10 Jul 2026 15:58:31 -0700 Subject: [PATCH 3/6] fix(inference): constrain local compatible gateway routes Signed-off-by: Carlos Villela --- .github/workflows/e2e.yaml | 18 ++-- .../local-compatible-inference-setup.mdx | 14 ++- .../compatible-endpoint-gateway-route.test.ts | 92 +++++++++++++++++++ .../compatible-endpoint-gateway-route.ts | 89 ++++++++++++++++++ src/lib/onboard/inference-providers/remote.ts | 71 +++----------- test/e2e/fixtures/fake-openai-compatible.ts | 1 + test/e2e/lib/fake-openai-compatible-api.mts | 2 + test/e2e/live/inference-routing.test.ts | 77 +++++++++++----- .../dockerhub-auth-workflow-boundary.test.ts | 1 - test/e2e/support/hosted-inference.test.ts | 1 + test/inference-options-docs.test.ts | 15 ++- test/onboard-inference-gateway-scope.test.ts | 46 ++++++++++ tools/e2e/workflow-boundary.mts | 5 +- 13 files changed, 337 insertions(+), 95 deletions(-) create mode 100644 src/lib/onboard/inference-providers/compatible-endpoint-gateway-route.test.ts create mode 100644 src/lib/onboard/inference-providers/compatible-endpoint-gateway-route.ts diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index 8d0a63ab29..452662b409 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -1044,16 +1044,17 @@ jobs: ref: ${{ inputs.checkout_sha || github.sha }} persist-credentials: false + - *dockerhub-auth + - name: Prepare E2E workspace uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@50281ee84c4a6fc759da95ea28fc0b7d9c378a28 - name: Run inference routing live test - # Direct E2E coverage. The - # always-on PR-safe slices prove invalid-key and unreachable-endpoint - # classification/cleanup without spending live provider quota; real - # NVIDIA credential isolation and third-party provider smokes stay - # skipped unless their secrets are explicitly supplied by a future - # workflow. + # Direct E2E coverage. The always-on PR-safe slices prove invalid-key, + # unreachable-endpoint, and localhost-compatible gateway routing + # without spending live provider quota. Real NVIDIA credential + # isolation and third-party provider smokes stay skipped unless their + # secrets are explicitly supplied by a future workflow. run: | set -euo pipefail npx vitest run --project e2e-live \ @@ -1064,6 +1065,11 @@ jobs: if: always() uses: NVIDIA/NemoClaw/.github/actions/upload-e2e-artifacts@7768e15eb90d3ee2d33432f481dfe8747e4f6d57 + - name: Clean up Docker auth + if: always() + shell: bash + run: bash .github/scripts/docker-auth-cleanup.sh + cloud-inference: needs: generate-matrix if: ${{ (github.event_name != 'workflow_dispatch' || (inputs.jobs == '' && inputs.targets == '')) || contains(format(',{0},', inputs.jobs), ',cloud-inference,') || contains(format(',{0},', inputs.targets), ',cloud-inference,') }} diff --git a/docs/inference/local-compatible-inference-setup.mdx b/docs/inference/local-compatible-inference-setup.mdx index 9cf9ba1f7a..0e192f424e 100644 --- a/docs/inference/local-compatible-inference-setup.mdx +++ b/docs/inference/local-compatible-inference-setup.mdx @@ -56,7 +56,12 @@ $$nemoclaw onboard When the wizard asks you to choose an inference provider, select **Other OpenAI-compatible endpoint**. Enter the base URL of your local server, for example `http://localhost:8000/v1` on the default Docker-driver topology. -For HTTP loopback endpoints on the default HTTP port or an unprivileged port, such as `http://localhost:8000/v1`, NemoClaw validates the endpoint from the host and registers the OpenShell gateway route through `host.openshell.internal:` so sandbox traffic can leave the container namespace. +To qualify for automatic rewriting, an HTTP endpoint URL must use the exact loopback host `localhost`, `127.0.0.1`, or `[::1]`. +It must also specify a port allowed by NemoClaw's bundled `local-inference` policy: `8000`, `11434`, or `11435`. +NemoClaw validates the entered URL from the host and registers the OpenShell gateway route through `host.openshell.internal:` for sandbox traffic. +The sandbox policy must allow the selected bridge port. +NemoClaw leaves URLs without an explicit port, URLs on `:80` or another privileged port, and URLs on unsupported ports unchanged. +Those URLs require a separately compatible runtime topology and network policy. This rewrite depends on an OpenShell topology that resolves `host.openshell.internal` inside the sandbox; if that bridge is unavailable, onboarding can still validate the host URL, but `$$nemoclaw status` is the authoritative runtime check. Make sure the local server listens on an address reachable from containers, such as `0.0.0.0`; a server bound only to `127.0.0.1` can still be unreachable from the sandbox route. @@ -96,7 +101,12 @@ Do not leave an unauthenticated model server exposed on public, corporate, or Wi Select **Other OpenAI-compatible endpoint** during onboarding. Enter the server base URL, such as `http://localhost:8000/v1`, and the model id reported by `/v1/models`. -For HTTP loopback endpoints on the default HTTP port or an unprivileged port, NemoClaw validates the endpoint from the host and registers the OpenShell gateway route through `host.openshell.internal:` for sandbox traffic. +To qualify for automatic rewriting, an HTTP endpoint URL must use the exact loopback host `localhost`, `127.0.0.1`, or `[::1]`. +It must also specify a port allowed by NemoClaw's bundled `local-inference` policy: `8000`, `11434`, or `11435`. +NemoClaw validates the entered URL from the host and registers the OpenShell gateway route through `host.openshell.internal:` for sandbox traffic. +The sandbox policy must allow the selected bridge port. +NemoClaw leaves URLs without an explicit port, URLs on `:80` or another privileged port, and URLs on unsupported ports unchanged. +Those URLs require a separately compatible runtime topology and network policy. This rewrite depends on an OpenShell topology that resolves `host.openshell.internal` inside the sandbox; if that bridge is unavailable, onboarding can still validate the host URL, but `$$nemoclaw status` is the authoritative runtime check. If you manually enter a sandbox-internal alias such as `http://host.openshell.internal:8000/v1`, host-side endpoint probing is skipped during onboarding. Use a host-routable endpoint such as `localhost` when you need onboarding to verify the API, tool-calling, and streaming paths. diff --git a/src/lib/onboard/inference-providers/compatible-endpoint-gateway-route.test.ts b/src/lib/onboard/inference-providers/compatible-endpoint-gateway-route.test.ts new file mode 100644 index 0000000000..8eb070e598 --- /dev/null +++ b/src/lib/onboard/inference-providers/compatible-endpoint-gateway-route.test.ts @@ -0,0 +1,92 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import YAML from "yaml"; + +import { + BUNDLED_LOCAL_INFERENCE_GATEWAY_PORTS, + gatewayReachableCompatibleEndpointUrl, +} from "./compatible-endpoint-gateway-route"; + +describe("compatible endpoint gateway routing", () => { + it("matches the bundled local-inference host-gateway ports (#5744)", () => { + const policyPath = path.resolve( + import.meta.dirname, + "../../../../nemoclaw-blueprint/policies/presets/local-inference.yaml", + ); + const policy = YAML.parse(fs.readFileSync(policyPath, "utf8")); + const endpoints: Array<{ host?: string; port?: number }> = + policy.network_policies.local_inference.endpoints; + const hostGatewayPorts = endpoints + .filter(({ host }) => host === "host.openshell.internal") + .map(({ port }) => port) + .sort((left, right) => (left ?? 0) - (right ?? 0)); + + expect(hostGatewayPorts).toEqual( + [...BUNDLED_LOCAL_INFERENCE_GATEWAY_PORTS].sort((left, right) => left - right), + ); + }); + + it("rewrites exact HTTP loopback hosts on bundled local-inference ports (#5744)", () => { + for (const host of ["localhost", "127.0.0.1", "[::1]"]) { + for (const port of BUNDLED_LOCAL_INFERENCE_GATEWAY_PORTS) { + expect( + gatewayReachableCompatibleEndpointUrl( + "compatible-endpoint", + `http://${host}:${port}/v1/`, + ), + ).toBe(`http://host.openshell.internal:${port}/v1`); + } + } + }); + + it("preserves query strings and fragments for root and non-root routes (#5744)", () => { + expect( + gatewayReachableCompatibleEndpointUrl( + "compatible-endpoint", + "http://localhost:8000/?tenant=local#models", + ), + ).toBe("http://host.openshell.internal:8000?tenant=local#models"); + expect( + gatewayReachableCompatibleEndpointUrl( + "compatible-endpoint", + "http://localhost:8000/v1/?tenant=local#models", + ), + ).toBe("http://host.openshell.internal:8000/v1?tenant=local#models"); + }); + + it("leaves default, privileged, unsupported, and adjacent URL shapes unchanged (#5744)", () => { + const unchanged = [ + "http://localhost/v1", + "http://localhost:80/v1", + "http://localhost:1023/v1", + "http://localhost:9000/v1", + "https://localhost:8000/v1", + "http://user@localhost:8000/v1", + "http://localhost.example:8000/v1", + "http://localhost.:8000/v1", + "http://127.1:8000/v1", + "http://2130706433:8000/v1", + "http://127.0.0.2:8000/v1", + "http://host.openshell.internal:8000/v1", + "not a URL", + ]; + + for (const endpointUrl of unchanged) { + expect(gatewayReachableCompatibleEndpointUrl("compatible-endpoint", endpointUrl)).toBe( + endpointUrl, + ); + } + expect( + gatewayReachableCompatibleEndpointUrl( + "compatible-anthropic-endpoint", + "http://localhost:8000/v1", + ), + ).toBe("http://localhost:8000/v1"); + expect(gatewayReachableCompatibleEndpointUrl("compatible-endpoint", null)).toBeNull(); + expect(gatewayReachableCompatibleEndpointUrl("compatible-endpoint", undefined)).toBeUndefined(); + }); +}); diff --git a/src/lib/onboard/inference-providers/compatible-endpoint-gateway-route.ts b/src/lib/onboard/inference-providers/compatible-endpoint-gateway-route.ts new file mode 100644 index 0000000000..185654313a --- /dev/null +++ b/src/lib/onboard/inference-providers/compatible-endpoint-gateway-route.ts @@ -0,0 +1,89 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { RunOpenshell, UpsertProvider, UpsertProviderResult } from "./types"; + +// Keep this list aligned with the host.openshell.internal endpoints in +// nemoclaw-blueprint/policies/presets/local-inference.yaml. These are policy +// ports, not environment-overridable local provider ports. +export const BUNDLED_LOCAL_INFERENCE_GATEWAY_PORTS = [11434, 11435, 8000] as const; + +const BUNDLED_LOCAL_INFERENCE_GATEWAY_PORT_SET = new Set( + BUNDLED_LOCAL_INFERENCE_GATEWAY_PORTS, +); + +// #5744: keep host-side validation on the user-entered loopback URL, but +// register the sandbox route through OpenShell's host bridge. Remove this when +// OpenShell can verify provider routes from the sandbox/gateway network context. +export function gatewayReachableCompatibleEndpointUrl( + provider: string, + endpointUrl: string | null | undefined, +): string | null | undefined { + if (provider !== "compatible-endpoint" || !endpointUrl) return endpointUrl; + const hasExactLoopbackAuthority = + /^http:\/\/(?:localhost|127\.0\.0\.1|\[::1\]):[0-9]+(?:[/?#]|$)/i.test(endpointUrl); + let parsed: URL; + try { + parsed = new URL(endpointUrl); + } catch { + return endpointUrl; + } + const hostname = parsed.hostname.replace(/^\[|\]$/g, "").toLowerCase(); + const port = parsed.port ? Number(parsed.port) : null; + const isLoopback = hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1"; + if ( + parsed.protocol !== "http:" || + parsed.username || + parsed.password || + hostname.includes("%") || + !hasExactLoopbackAuthority || + !isLoopback || + port === null || + !Number.isInteger(port) || + !BUNDLED_LOCAL_INFERENCE_GATEWAY_PORT_SET.has(port) + ) { + return endpointUrl; + } + parsed.hostname = "host.openshell.internal"; + const pathname = parsed.pathname.replace(/\/+$/, ""); + parsed.pathname = pathname || "/"; + const routeSuffix = `${parsed.search}${parsed.hash}`; + return parsed.pathname === "/" + ? `${parsed.origin}${routeSuffix}` + : `${parsed.origin}${parsed.pathname}${routeSuffix}`; +} + +export function reuseRegisteredProviderWithGatewayEndpoint(args: { + provider: string; + providerType: string; + credentialEnv: string | null | undefined; + endpointUrl: string | null | undefined; + gatewayEndpointUrl: string | null | undefined; + runOpenshell: RunOpenshell; + upsertProvider: UpsertProvider; +}): UpsertProviderResult { + const { + provider, + providerType, + credentialEnv, + endpointUrl, + gatewayEndpointUrl, + runOpenshell, + upsertProvider, + } = args; + // The caller has already authorized the recovered provider's non-secret + // credential/config identity through assessRecoveredProviderCredentialReuse. + const existing = runOpenshell(["provider", "get", provider], { + ignoreError: true, + suppressOutput: true, + }); + if (existing.status !== 0) { + return { + ok: false, + status: existing.status || 1, + message: `Recovered provider '${provider}' is no longer registered in OpenShell.`, + }; + } + if (gatewayEndpointUrl === endpointUrl) return { ok: true }; + return upsertProvider(provider, providerType, credentialEnv, gatewayEndpointUrl, {}); +} diff --git a/src/lib/onboard/inference-providers/remote.ts b/src/lib/onboard/inference-providers/remote.ts index cd93fb37f2..1fe14c7ed1 100644 --- a/src/lib/onboard/inference-providers/remote.ts +++ b/src/lib/onboard/inference-providers/remote.ts @@ -10,6 +10,10 @@ import { getCompatibleAnthropicOpenAiSurfaceBaseUrl } from "../../inference/conf import { OPENROUTER_PROVIDER_NAME } from "../../inference/openrouter"; import { readGatewayProviderMetadata } from "../gateway-provider-metadata"; import { deleteProviderWithRecovery, parseAttachedSandboxes } from "../sandbox-provider-cleanup"; +import { + gatewayReachableCompatibleEndpointUrl, + reuseRegisteredProviderWithGatewayEndpoint, +} from "./compatible-endpoint-gateway-route"; import type { RemoteProviderDeps, SetupInferenceResult } from "./types"; const { probeOpenAiLikeEndpoint } = require("../../inference/onboard-probes") as { @@ -23,42 +27,6 @@ const { probeOpenAiLikeEndpoint } = require("../../inference/onboard-probes") as type StaleProviderReplaceResult = { ok: boolean; status?: number | null; message?: string }; -// #5744: keep host-side validation on the user-entered loopback URL, but -// register the sandbox route through OpenShell's host bridge. Remove this when -// OpenShell can verify provider routes from the sandbox/gateway network context. -function gatewayReachableCompatibleEndpointUrl( - provider: string, - endpointUrl: string | null | undefined, -): string | null | undefined { - if (provider !== "compatible-endpoint" || !endpointUrl) return endpointUrl; - let parsed: URL; - try { - parsed = new URL(endpointUrl); - } catch { - return endpointUrl; - } - const hostname = parsed.hostname.replace(/^\[|\]$/g, "").toLowerCase(); - if (hostname.includes("%")) return endpointUrl; - const port = parsed.port ? Number(parsed.port) : null; - const isLoopback = hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1"; - if ( - parsed.protocol !== "http:" || - parsed.username || - parsed.password || - !isLoopback || - (port !== null && (!Number.isInteger(port) || port < 1024)) - ) { - return endpointUrl; - } - parsed.hostname = "host.openshell.internal"; - const pathname = parsed.pathname.replace(/\/+$/, ""); - parsed.pathname = pathname || "/"; - const routeSuffix = `${parsed.search}${parsed.hash}`; - return parsed.pathname === "/" - ? `${parsed.origin}${routeSuffix}` - : `${parsed.origin}${parsed.pathname}${routeSuffix}`; -} - /** * Replace a provider that a prior Anthropic-Messages registration left behind * so it can be re-registered as `type=openai` for the OpenAI-compatible route @@ -271,30 +239,15 @@ export async function setupRemoteProviderInference( const gatewayEndpointUrl = gatewayReachableCompatibleEndpointUrl(provider, resolvedEndpointUrl); let providerResult; if (reuseGatewayCredentialWithoutLocalKey) { - // This is only a last-moment existence probe. The primary authorization - // of the provider's non-secret credential/config binding identity is - // assessRecoveredProviderCredentialReuse in recovered-provider-reuse.ts. - const existing = runOpenshell(["provider", "get", provider], { - ignoreError: true, - suppressOutput: true, + providerResult = reuseRegisteredProviderWithGatewayEndpoint({ + provider, + providerType: config.providerType, + credentialEnv: resolvedCredentialEnv, + endpointUrl: resolvedEndpointUrl, + gatewayEndpointUrl, + runOpenshell, + upsertProvider, }); - if (existing.status !== 0) { - providerResult = { - ok: false, - status: existing.status || 1, - message: `Recovered provider '${provider}' is no longer registered in OpenShell.`, - }; - } else if (gatewayEndpointUrl !== resolvedEndpointUrl) { - providerResult = upsertProvider( - provider, - config.providerType, - resolvedCredentialEnv, - gatewayEndpointUrl, - {}, - ); - } else { - providerResult = { ok: true }; - } } else { const credentialValue = hydrateCredentialEnv(resolvedCredentialEnv); const env = diff --git a/test/e2e/fixtures/fake-openai-compatible.ts b/test/e2e/fixtures/fake-openai-compatible.ts index d08266ef42..855d9aa9c2 100644 --- a/test/e2e/fixtures/fake-openai-compatible.ts +++ b/test/e2e/fixtures/fake-openai-compatible.ts @@ -13,6 +13,7 @@ const SERVER_SCRIPT = path.join(REPO_ROOT, "test/e2e/lib/fake-openai-compatible- export interface FakeOpenAiCompatibleRequest { readonly method: string; readonly path: string; + readonly hostHeader?: string; readonly bodyBytes: number; readonly auth?: string; readonly authorizationSent?: boolean; diff --git a/test/e2e/lib/fake-openai-compatible-api.mts b/test/e2e/lib/fake-openai-compatible-api.mts index 4378c92dec..1c81b30855 100755 --- a/test/e2e/lib/fake-openai-compatible-api.mts +++ b/test/e2e/lib/fake-openai-compatible-api.mts @@ -137,6 +137,7 @@ const server = createServer(async (req, res) => { recordRequest({ method: "GET", path, + hostHeader: req.headers.host, bodyBytes: 0, auth: modelsAuthOk ? "ok" : "missing", // Presence only (never the token) so callers can prove a probe sent its @@ -160,6 +161,7 @@ const server = createServer(async (req, res) => { recordRequest({ method: req.method || "GET", path, + hostHeader: req.headers.host, bodyBytes: raw.length, auth, // Presence only (never the token), matching the models request record. diff --git a/test/e2e/live/inference-routing.test.ts b/test/e2e/live/inference-routing.test.ts index ff18693451..9d6576b2fe 100644 --- a/test/e2e/live/inference-routing.test.ts +++ b/test/e2e/live/inference-routing.test.ts @@ -13,6 +13,7 @@ import type { HostCliClient } from "../fixtures/clients/host.ts"; import type { SandboxClient } from "../fixtures/clients/sandbox.ts"; import { trustedSandboxShellScript, validateSandboxName } from "../fixtures/clients/sandbox.ts"; import { expect, test } from "../fixtures/e2e-test.ts"; +import { startFakeOpenAiCompatibleServer } from "../fixtures/fake-openai-compatible.ts"; import { CLI_DIST_ENTRYPOINT, CLI_ENTRYPOINT, REPO_ROOT } from "../fixtures/paths.ts"; import { redactString } from "../fixtures/redaction.ts"; @@ -39,7 +40,7 @@ const CREDENTIAL_CLASSIFICATION_PATTERN = const TRANSPORT_CLASSIFICATION_PATTERN = /unreachable|timeout|connect|ECONNREFUSED|ETIMEDOUT|ENETUNREACH|EHOSTUNREACH|ENOTFOUND|EAI_AGAIN|No route to host|transport|network|endpoint|dns/i; -function shouldRunProviderSmoke(provider: "openai" | "anthropic" | "compatible"): boolean { +function shouldRunProviderSmoke(provider: "openai" | "anthropic"): boolean { // The former shell script auto-ran these smokes when provider secrets were // present. This live migration requires an explicit opt-in so PR-safe jobs // cannot spend third-party quota accidentally; any future secret-backed lane @@ -272,7 +273,7 @@ interface CleanupSandboxOptions { } function isExpectedPreOnboardCleanupMiss(text: string): boolean { - return /does not exist|run 'nemoclaw onboard'|no active gateway|not found|no such file|enoent/i.test( + return /does not exist|run 'nemoclaw onboard'|no active gateway|connection refused|not found|no such file|enoent/i.test( text, ); } @@ -973,29 +974,32 @@ test("TC-INF-03 Anthropic provider responds through inference.local", { test("TC-INF-09 custom OpenAI-compatible endpoint responds through inference.local", { timeout: 15 * 60_000, -}, async ({ artifacts, cleanup, host, sandbox, secrets, skip }) => { - if (!shouldRunProviderSmoke("compatible")) { - skipLive( - skip, - "set NEMOCLAW_INFERENCE_ROUTING_PROVIDER_SMOKE=compatible or all to run compatible endpoint smoke", - ); - } - const endpointUrl = - process.env.NEMOCLAW_ENDPOINT_URL ?? - skipLive(skip, "Missing NEMOCLAW_ENDPOINT_URL, NEMOCLAW_COMPAT_MODEL, or COMPATIBLE_API_KEY"); - const model = - process.env.NEMOCLAW_COMPAT_MODEL || - process.env.NEMOCLAW_MODEL || - skipLive(skip, "Missing NEMOCLAW_ENDPOINT_URL, NEMOCLAW_COMPAT_MODEL, or COMPATIBLE_API_KEY"); - const apiKey = - secrets.optional("COMPATIBLE_API_KEY") ?? - skipLive(skip, "Missing NEMOCLAW_ENDPOINT_URL, NEMOCLAW_COMPAT_MODEL, or COMPATIBLE_API_KEY"); +}, async ({ artifacts, cleanup, host, sandbox, skip }) => { + const model = "nemoclaw-e2e-compatible"; + const apiKey = "sk-compatible-TEST-NOT-A-REAL-VALUE"; await requireLivePrerequisites(host, skip); const sandboxName = inferenceSandboxName("e2e-compat-ep"); cleanup.add(`best-effort inference-routing compatible-endpoint cleanup for ${sandboxName}`, () => cleanupSandbox(host, sandbox, sandboxName), ); await cleanupSandbox(host, sandbox, sandboxName); + const fake = await startFakeOpenAiCompatibleServer({ + apiKey, + chatContent: "PONG", + host: "0.0.0.0", + model, + port: 8000, + publicHost: "localhost", + requireAuth: true, + requireAuthModels: true, + }); + cleanup.add("close inference-routing compatible endpoint", async () => { + try { + await artifacts.writeJson("tc-inf-09-compatible-endpoint-requests.json", fake.requests()); + } finally { + await fake.close(); + } + }); await artifacts.target.declare({ id: "inference-routing-compatible-endpoint", @@ -1003,7 +1007,7 @@ test("TC-INF-09 custom OpenAI-compatible endpoint responds through inference.loc "custom OpenAI-compatible endpoint onboards", "sandbox inference.local routes chat to compatible endpoint", ], - endpointUrl: redactString(endpointUrl, [apiKey]), + endpointUrl: fake.baseUrl, model, }); @@ -1012,8 +1016,9 @@ test("TC-INF-09 custom OpenAI-compatible endpoint responds through inference.loc sandboxName, { COMPATIBLE_API_KEY: apiKey, - NEMOCLAW_ENDPOINT_URL: endpointUrl, + NEMOCLAW_ENDPOINT_URL: fake.baseUrl, NEMOCLAW_MODEL: model, + NEMOCLAW_PREFERRED_API: "openai-completions", NEMOCLAW_PROVIDER: "custom", }, [apiKey], @@ -1023,6 +1028,27 @@ test("TC-INF-09 custom OpenAI-compatible endpoint responds through inference.loc cleanup.add(`strict inference-routing compatible-endpoint cleanup for ${sandboxName}`, () => cleanupSandbox(host, sandbox, sandboxName, { strict: true }), ); + const provider = await sandbox.openshell( + ["provider", "get", "-g", "nemoclaw", "compatible-endpoint"], + { + artifactName: "tc-inf-09-provider-get-compatible-endpoint", + env: buildAvailabilityProbeEnv(), + timeoutMs: 30_000, + }, + ); + const providerText = resultText(provider); + expect(provider.exitCode, providerText).toBe(0); + expect(providerText).toContain("Type: openai"); + expect(providerText).toContain("Credential keys: COMPATIBLE_API_KEY"); + expect(providerText).toContain("Config keys: OPENAI_BASE_URL"); + expect(fake.requests()).toContainEqual( + expect.objectContaining({ + auth: "ok", + hostHeader: "localhost:8000", + }), + ); + + const sandboxRequestOffset = fake.requests().length; await expectOpenAiChatThroughSandbox( sandbox, sandboxName, @@ -1030,4 +1056,13 @@ test("TC-INF-09 custom OpenAI-compatible endpoint responds through inference.loc [apiKey], "compatible-endpoint-inference-local-chat", ); + expect(fake.requests().slice(sandboxRequestOffset)).toContainEqual( + expect.objectContaining({ + auth: "ok", + hostHeader: "host.openshell.internal:8000", + method: "POST", + model, + path: "/v1/chat/completions", + }), + ); }); diff --git a/test/e2e/support/dockerhub-auth-workflow-boundary.test.ts b/test/e2e/support/dockerhub-auth-workflow-boundary.test.ts index 12867cdf7d..8cc9598f8e 100644 --- a/test/e2e/support/dockerhub-auth-workflow-boundary.test.ts +++ b/test/e2e/support/dockerhub-auth-workflow-boundary.test.ts @@ -17,7 +17,6 @@ const NO_IMAGE_E2E_JOBS = [ "docs-validation", "gateway-drift-preflight", "gateway-health-honest", - "inference-routing", "onboard-negative-paths", "openshell-version-pin", ] as const; diff --git a/test/e2e/support/hosted-inference.test.ts b/test/e2e/support/hosted-inference.test.ts index dc3c9b08e5..522c755ab1 100644 --- a/test/e2e/support/hosted-inference.test.ts +++ b/test/e2e/support/hosted-inference.test.ts @@ -437,6 +437,7 @@ describe("hosted inference E2E config", () => { expect.objectContaining({ auth: "ok", forbiddenMarkerMatches: 0, + hostHeader: new URL(fake.baseUrl).host, model: "nvidia/nvidia/fake-model", path: "/v1/chat/completions", stream: false, diff --git a/test/inference-options-docs.test.ts b/test/inference-options-docs.test.ts index b812f20af8..e32f2967da 100644 --- a/test/inference-options-docs.test.ts +++ b/test/inference-options-docs.test.ts @@ -7,8 +7,8 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import type * as TypeScript from "typescript"; import { describe, expect, it } from "vitest"; -import { shouldForceCompletionsApi } from "../src/lib/validation.js"; import { getSandboxRuntimeInferenceEndpoint } from "../src/lib/onboard/docker-gpu-local-inference.js"; +import { shouldForceCompletionsApi } from "../src/lib/validation.js"; const require = createRequire(import.meta.url); const ts = require("typescript") as typeof TypeScript; @@ -297,8 +297,19 @@ describe("inference setup navigation", () => { expect(result.note).toContain("validation skipped"); expect(markdown).toContain("`http://host.openshell.internal:8000/v1`"); expect(markdown).toContain( - "For HTTP loopback endpoints on the default HTTP port or an unprivileged port, such as `http://localhost:8000/v1`, NemoClaw validates the endpoint from the host and registers the OpenShell gateway route through `host.openshell.internal:` so sandbox traffic can leave the container namespace.", + "To qualify for automatic rewriting, an HTTP endpoint URL must use the exact loopback host `localhost`, `127.0.0.1`, or `[::1]`.", + ); + expect(markdown).toContain( + "It must also specify a port allowed by NemoClaw's bundled `local-inference` policy: `8000`, `11434`, or `11435`.", + ); + expect(markdown).toContain( + "NemoClaw validates the entered URL from the host and registers the OpenShell gateway route through `host.openshell.internal:` for sandbox traffic.", + ); + expect(markdown).toContain("The sandbox policy must allow the selected bridge port."); + expect(markdown).toContain( + "NemoClaw leaves URLs without an explicit port, URLs on `:80` or another privileged port, and URLs on unsupported ports unchanged.", ); + expect(markdown).not.toContain("the default HTTP port or an unprivileged port"); expect(markdown).toContain( "if that bridge is unavailable, onboarding can still validate the host URL, but `$$nemoclaw status` is the authoritative runtime check.", ); diff --git a/test/onboard-inference-gateway-scope.test.ts b/test/onboard-inference-gateway-scope.test.ts index d753666153..67606df0bc 100644 --- a/test/onboard-inference-gateway-scope.test.ts +++ b/test/onboard-inference-gateway-scope.test.ts @@ -106,6 +106,52 @@ describe("onboarding inference gateway scope", () => { ); }); + it("updates a recovered loopback route without re-exporting its gateway credential (#5744)", async () => { + await withProcessEnv({ COMPATIBLE_API_KEY: undefined }, async () => { + const harness = createHarness({ + runOpenshell: (args) => + args.slice(0, 2).join(" ") === "provider get" ? { status: 0 } : undefined, + }); + const model = "deepseek-ai/DeepSeek-V4-Flash"; + + await expect( + harness.setupInference( + "dcode-vllm-local", + model, + "compatible-endpoint", + "http://localhost:8000/v1", + "COMPATIBLE_API_KEY", + null, + [], + { + gatewayName: GATEWAY, + reuseGatewayCredentialWithoutLocalKey: true, + skipHostInferenceSmoke: true, + }, + ), + ).resolves.toEqual({ ok: true }); + + expect(harness.commands.map(({ command }) => command)).toEqual([ + `provider get -g ${GATEWAY} compatible-endpoint`, + `provider get -g ${GATEWAY} compatible-endpoint`, + `provider update -g ${GATEWAY} compatible-endpoint --config OPENAI_BASE_URL=http://host.openshell.internal:8000/v1`, + `inference set -g ${GATEWAY} --no-verify --provider compatible-endpoint --model ${model} --timeout 180`, + ]); + const providerUpdate = harness.commands.find(({ command }) => + command.startsWith("provider update "), + ); + expect(providerUpdate?.env).toEqual({}); + expect(harness.commands.every(({ env }) => env?.COMPATIBLE_API_KEY === undefined)).toBe(true); + expect(harness.verifyOnboardInferenceSmoke).not.toHaveBeenCalled(); + expect(harness.verifyInferenceRoute).toHaveBeenCalledWith( + GATEWAY, + "compatible-endpoint", + model, + ); + 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" }, diff --git a/tools/e2e/workflow-boundary.mts b/tools/e2e/workflow-boundary.mts index 825a758644..f031c7032c 100644 --- a/tools/e2e/workflow-boundary.mts +++ b/tools/e2e/workflow-boundary.mts @@ -61,7 +61,6 @@ const NO_IMAGE_E2E_JOBS = new Set([ "docs-validation", "gateway-drift-preflight", "gateway-health-honest", - "inference-routing", "onboard-negative-paths", "openshell-version-pin", ]); @@ -3487,9 +3486,7 @@ function validateJetsonRunnerDispatchGuard(errors: string[], jobs: WorkflowRecor asRecord(guard.env).JETSON_E2E_RUNNER_LABEL !== "${{ vars.JETSON_E2E_RUNNER_LABEL || 'linux-arm64-gpu-jetson-orin-latest-1' }}" ) { - errors.push( - "jetson-nvmap-gpu dispatch guard must receive the configured Jetson runner label", - ); + errors.push("jetson-nvmap-gpu dispatch guard must receive the configured Jetson runner label"); } requireRunContains(errors, guard, "allow_jetson_runner_queue=true"); requireRunContains(errors, guard, "timeout-minutes"); From e332453e789331e3baee4b4a098837b0edf86091 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Fri, 10 Jul 2026 16:04:11 -0700 Subject: [PATCH 4/6] test(e2e): map inference routing mock parity Signed-off-by: Carlos Villela --- test/e2e/live/inference-routing.test.ts | 2 +- test/e2e/mock-parity.json | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/test/e2e/live/inference-routing.test.ts b/test/e2e/live/inference-routing.test.ts index 9d6576b2fe..aae128e476 100644 --- a/test/e2e/live/inference-routing.test.ts +++ b/test/e2e/live/inference-routing.test.ts @@ -1036,7 +1036,7 @@ test("TC-INF-09 custom OpenAI-compatible endpoint responds through inference.loc timeoutMs: 30_000, }, ); - const providerText = resultText(provider); + const providerText = resultText(provider).replace(/\u001b\[[0-9;]*m/g, ""); expect(provider.exitCode, providerText).toBe(0); expect(providerText).toContain("Type: openai"); expect(providerText).toContain("Credential keys: COMPATIBLE_API_KEY"); diff --git a/test/e2e/mock-parity.json b/test/e2e/mock-parity.json index 6d47d2babd..1bd29441d0 100644 --- a/test/e2e/mock-parity.json +++ b/test/e2e/mock-parity.json @@ -2,6 +2,14 @@ "$comment": "SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.\nSPDX-License-Identifier: Apache-2.0", "version": 1, "entries": [ + { + "live": "test/e2e/live/inference-routing.test.ts", + "fast": [ + "src/lib/onboard/inference-providers/compatible-endpoint-gateway-route.test.ts", + "test/e2e/support/hosted-inference.test.ts", + "test/onboard-inference-gateway-scope.test.ts" + ] + }, { "live": "test/e2e/live/onboard-repair.test.ts", "fast": [ From d02337cafee39bb51dcb49bb23672d4a38a3b215 Mon Sep 17 00:00:00 2001 From: Charan Jagwani Date: Fri, 10 Jul 2026 17:09:03 -0700 Subject: [PATCH 5/6] docs(security): clarify bridge recovery credential boundary Signed-off-by: Charan Jagwani --- .../local-compatible-inference-setup.mdx | 1 + .../openclaw-2026.6.10-dependency-review.md | 19 +++++++++++-------- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/docs/inference/local-compatible-inference-setup.mdx b/docs/inference/local-compatible-inference-setup.mdx index 7a7dcce60a..25a044be3f 100644 --- a/docs/inference/local-compatible-inference-setup.mdx +++ b/docs/inference/local-compatible-inference-setup.mdx @@ -59,6 +59,7 @@ Enter the base URL of your local server, for example `http://localhost:8000/v1` To qualify for automatic rewriting, an HTTP endpoint URL must use the exact loopback host `localhost`, `127.0.0.1`, or `[::1]`. It must also specify a port allowed by NemoClaw's bundled `local-inference` policy: `8000`, `11434`, or `11435`. NemoClaw validates the entered URL from the host and registers the OpenShell gateway route through `host.openshell.internal:` for sandbox traffic. +During a rebuild that reuses this route without a host API key, NemoClaw applies the same config-only bridge rewrite without reading or passing the credential stored in OpenShell. The sandbox policy must allow the selected bridge port. NemoClaw leaves URLs without an explicit port, URLs on `:80` or another privileged port, and URLs on unsupported ports unchanged. Those URLs require a separately compatible runtime topology and network policy. diff --git a/docs/security/openclaw-2026.6.10-dependency-review.md b/docs/security/openclaw-2026.6.10-dependency-review.md index f727017696..58e0a165c4 100644 --- a/docs/security/openclaw-2026.6.10-dependency-review.md +++ b/docs/security/openclaw-2026.6.10-dependency-review.md @@ -191,21 +191,24 @@ At the host-caller boundary, NemoClaw no longer reads or writes device state dur ### Recovered Gateway Credential Boundary During rebuild, OpenShell remains the system of record for provider credential bytes. -NemoClaw does not read, export, or replace a credential that exists only in the gateway, and this recovery path never updates or repoints the registered provider. +NemoClaw does not read, export, or replace a credential that exists only in the gateway. +Except for the bounded compatible-endpoint bridge rewrite described below, recovery does not update or repoint the registered provider. NemoClaw accepts provider, model, preferred API, and custom endpoint metadata only as one complete route from either the current registry row or the matching onboard session; a partial registry row is never completed from older session data. The recovery path may omit direct host validation only when the selection was recovered from the target sandbox, provider/model values are complete and bounded, the preferred API is compatible with that provider type, and `openshell provider get` reports the exact provider name, type, credential-binding key, and expected endpoint-config key. Its display parser accepts OpenShell's ANSI-styled field labels but rejects escape or control bytes inside semantic values before applying the ASCII identifier and binding-key allowlists. Custom-endpoint reuse additionally requires the complete route to come from the current registry row, to canonicalize to the same recorded HTTP(S) identity, and every other registry entry using that global provider to record that same endpoint. Before destructive rebuild deletes the sandbox and registry row, NemoClaw captures a complete bounded route directly from that row and, when no host key exists, requires the same provider/model/API/endpoint and non-secret gateway bindings to pass the credential-reuse assessment before backup or deletion. It defensively copies and freezes the route behind a runtime `source: "registry"` check, then passes that route only in memory to the same sandbox's recreate provider-selection call via the immutable handoff; the persisted resume session carries only the ordinary route fields, never the handoff or its provenance marker. Session-only and explicit-environment endpoints never receive registry provenance, and the normal image/registry cleanup happens immediately. This preserves the registry-backed trust boundary without a phantom registry entry or persisted spoofable marker. OpenShell deliberately reports provider config keys but not config values, so NemoClaw cannot confirm the exact live endpoint value through this interface. -Credential-only recovery does not run `provider update`. -It verifies the non-secret provider shape, preserves the gateway's existing credential/config binding unchanged, and re-applies only `inference set` for the recovered provider/model. -An existing provider may already have been redirected out of band; that endpoint-value drift is the residual this interface cannot detect, while the recovery path itself cannot introduce or change that redirection. - -Invalid state: a rebuild with no host key probes a remote endpoint with an empty credential and fails after deleting the old sandbox, mixes partial current metadata with stale session fields, or silently reuses a gateway provider for an explicit, malformed, provider-incompatible, or conflicting-endpoint selection. -Source boundary: `src/lib/actions/sandbox/rebuild-provider-preflight.ts`, `src/lib/onboard/provider-recovery.ts`, `src/lib/onboard/recovered-provider-reuse.ts`, `src/lib/onboard/inference-providers/remote.ts`, `src/lib/onboard.ts`, and OpenShell's provider registry. +Recovery does not run `provider update` except for the bounded compatible-endpoint bridge rewrite. +After the existing non-secret provider-shape and binding authorization succeeds, an exact HTTP loopback endpoint on a bundled local-inference port receives a config-only update that replaces the loopback authority with `host.openshell.internal` while preserving the URL suffix. +The update passes no `--credential` flag or credential value. +All other recovered providers preserve the gateway's existing credential/config binding unchanged and re-apply only `inference set` for the recovered provider/model. +Outside this explicit rewrite, an existing provider may already have been redirected out of band; that endpoint-value drift is the residual this interface cannot detect, while the recovery path cannot introduce or change that redirection. + +Invalid state: a rebuild with no host key probes a remote endpoint with an empty credential and fails after deleting the old sandbox, mixes partial current metadata with stale session fields, silently reuses a gateway provider for an explicit, malformed, provider-incompatible, or conflicting-endpoint selection, or applies the bridge rewrite without an exact loopback authority, bundled port, and authorized provider binding. +Source boundary: `src/lib/actions/sandbox/rebuild-provider-preflight.ts`, `src/lib/onboard/provider-recovery.ts`, `src/lib/onboard/recovered-provider-reuse.ts`, `src/lib/onboard/inference-providers/compatible-endpoint-gateway-route.ts`, `src/lib/onboard/inference-providers/remote.ts`, `src/lib/onboard.ts`, and OpenShell's provider registry. Source-fix constraint: OpenShell intentionally does not expose stored credential or config values, so NemoClaw can reconcile only non-secret routing metadata and must fail closed if the exact provider shape or one complete recovery identity is unavailable. -Regression tests: `src/lib/actions/sandbox/rebuild-provider-preflight.test.ts` rejects incomplete, unbounded, spoofed, unauthenticated Bedrock, and conflicting keyless recovery before destructive work; `src/lib/onboard/provider-recovery.test.ts` rejects partial or unbounded live CLI output and mixed-source routes; `src/lib/onboard/gateway-provider-metadata.test.ts` rejects control-sequence, null-byte, and Unicode-homograph identities; `src/lib/onboard/rebuild-route-handoff.test.ts` proves defensive immutability and registry-only provenance; `src/lib/onboard/recovered-provider-reuse.test.ts` covers provider/API/endpoint compatibility and fail-closed cases; `test/onboard-remote-recreate-credential-reuse.test.ts` proves the route is re-applied without a provider update, credential flag, config replacement, or direct curl probe. +Regression tests: `src/lib/actions/sandbox/rebuild-provider-preflight.test.ts` rejects incomplete, unbounded, spoofed, unauthenticated Bedrock, and conflicting keyless recovery before destructive work; `src/lib/onboard/provider-recovery.test.ts` rejects partial or unbounded live CLI output and mixed-source routes; `src/lib/onboard/gateway-provider-metadata.test.ts` rejects control-sequence, null-byte, and Unicode-homograph identities; `src/lib/onboard/rebuild-route-handoff.test.ts` proves defensive immutability and registry-only provenance; `src/lib/onboard/recovered-provider-reuse.test.ts` covers provider/API/endpoint compatibility and fail-closed cases; `src/lib/onboard/inference-providers/compatible-endpoint-gateway-route.test.ts` constrains bridge rewrites to exact loopback authorities and bundled ports; `test/onboard-inference-gateway-scope.test.ts` proves the recovery update is config-only and receives no credential; `test/onboard-remote-recreate-credential-reuse.test.ts` proves other remote routes are re-applied without a provider update, credential flag, config replacement, or direct curl probe. The `hermes-discord` and `channels-add-remove` live jobs remain the real rebuild gates. Removal condition: replace this localized decision boundary when OpenShell provides a typed credential-preserving provider/route reconcile operation that validates through its stored credential without disclosing it. From 71bca2a11fffbab269c365081591fa57313eef17 Mon Sep 17 00:00:00 2001 From: Charan Jagwani Date: Fri, 10 Jul 2026 17:21:43 -0700 Subject: [PATCH 6/6] test(e2e): cover dcode local gateway routing Signed-off-by: Charan Jagwani --- test/e2e/lib/fake-openai-compatible-api.mts | 6 ++++ test/e2e/live/inference-routing.test.ts | 34 +++++++++++++++++++-- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/test/e2e/lib/fake-openai-compatible-api.mts b/test/e2e/lib/fake-openai-compatible-api.mts index 1c81b30855..e1a6fd41ee 100755 --- a/test/e2e/lib/fake-openai-compatible-api.mts +++ b/test/e2e/lib/fake-openai-compatible-api.mts @@ -64,11 +64,15 @@ function sendChatSse(res: ServerResponse, content: string): void { const chunk = JSON.stringify({ id: "chatcmpl-fake-openai-compatible", object: "chat.completion.chunk", + created: 0, + model, choices: [{ index: 0, delta: { role: "assistant", content }, finish_reason: null }], }); const doneChunk = JSON.stringify({ id: "chatcmpl-fake-openai-compatible", object: "chat.completion.chunk", + created: 0, + model, choices: [{ index: 0, delta: {}, finish_reason: "stop" }], }); const body = `data: ${chunk}\n\ndata: ${doneChunk}\n\ndata: [DONE]\n\n`; @@ -186,6 +190,8 @@ const server = createServer(async (req, res) => { sendJson(res, 200, { id: "chatcmpl-fake-openai-compatible", object: "chat.completion", + created: 0, + model, choices: [ { index: 0, diff --git a/test/e2e/live/inference-routing.test.ts b/test/e2e/live/inference-routing.test.ts index aae128e476..418cfc8c94 100644 --- a/test/e2e/live/inference-routing.test.ts +++ b/test/e2e/live/inference-routing.test.ts @@ -972,8 +972,8 @@ test("TC-INF-03 Anthropic provider responds through inference.local", { await expectAnthropicMessageThroughSandbox(sandbox, sandboxName, model, [apiKey]); }); -test("TC-INF-09 custom OpenAI-compatible endpoint responds through inference.local", { - timeout: 15 * 60_000, +test("TC-INF-09 Deep Agents Code uses a local compatible endpoint through inference.local (#5744)", { + timeout: 20 * 60_000, }, async ({ artifacts, cleanup, host, sandbox, skip }) => { const model = "nemoclaw-e2e-compatible"; const apiKey = "sk-compatible-TEST-NOT-A-REAL-VALUE"; @@ -1004,8 +1004,9 @@ test("TC-INF-09 custom OpenAI-compatible endpoint responds through inference.loc await artifacts.target.declare({ id: "inference-routing-compatible-endpoint", contract: [ - "custom OpenAI-compatible endpoint onboards", + "Deep Agents Code custom OpenAI-compatible endpoint onboards", "sandbox inference.local routes chat to compatible endpoint", + "dcode returns the compatible endpoint response through the rewritten gateway route", ], endpointUrl: fake.baseUrl, model, @@ -1016,6 +1017,7 @@ test("TC-INF-09 custom OpenAI-compatible endpoint responds through inference.loc sandboxName, { COMPATIBLE_API_KEY: apiKey, + NEMOCLAW_AGENT: "langchain-deepagents-code", NEMOCLAW_ENDPOINT_URL: fake.baseUrl, NEMOCLAW_MODEL: model, NEMOCLAW_PREFERRED_API: "openai-completions", @@ -1023,6 +1025,7 @@ test("TC-INF-09 custom OpenAI-compatible endpoint responds through inference.loc }, [apiKey], "tc-inf-09-onboard-compatible-endpoint", + 15 * 60_000, ); expectOnboardSuccess(onboard, "TC-INF-09 compatible-endpoint onboard"); cleanup.add(`strict inference-routing compatible-endpoint cleanup for ${sandboxName}`, () => @@ -1065,4 +1068,29 @@ test("TC-INF-09 custom OpenAI-compatible endpoint responds through inference.loc path: "/v1/chat/completions", }), ); + + const dcodeRequestOffset = fake.requests().length; + const dcode = await runNemoclawCli( + [sandboxName, "exec", "--", "dcode", "-n", "Reply with exactly one word: PONG"], + { + artifactName: "tc-inf-09-dcode-compatible-endpoint", + artifacts, + env: buildAvailabilityProbeEnv(), + redactionValues: [apiKey], + timeoutMs: 3 * 60_000, + }, + ); + const dcodeText = redactedResultText(dcode); + expect(dcode.timedOut, `TC-INF-09 dcode timed out\n${dcodeText}`).toBe(false); + expect(dcode.exitCode, `TC-INF-09 dcode failed\n${dcodeText}`).toBe(0); + expect(dcodeText).toMatch(/\bPONG\b/); + expect(fake.requests().slice(dcodeRequestOffset)).toContainEqual( + expect.objectContaining({ + auth: "ok", + hostHeader: "host.openshell.internal:8000", + method: "POST", + model, + path: "/v1/chat/completions", + }), + ); });