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/how-inference-routing-works.mdx b/docs/inference/how-inference-routing-works.mdx index 2a5058e0e5..9f3bd0a277 100644 --- a/docs/inference/how-inference-routing-works.mdx +++ b/docs/inference/how-inference-routing-works.mdx @@ -35,6 +35,7 @@ The sandbox does not receive the raw upstream API key. Local Ollama and local vLLM routes do not require the host `OPENAI_API_KEY`. NemoClaw uses provider-specific local tokens for those routes. Rebuilds of legacy local-inference sandboxes migrate away from stale OpenAI credential requirements. +When a rebuild reuses an automatically bridged compatible-endpoint route without a host API key, NemoClaw reapplies the config-only bridge rewrite without reading or passing the credential stored in OpenShell. diff --git a/docs/inference/set-up-openai-compatible-endpoint.mdx b/docs/inference/set-up-openai-compatible-endpoint.mdx index 54b63f2585..299ec36110 100644 --- a/docs/inference/set-up-openai-compatible-endpoint.mdx +++ b/docs/inference/set-up-openai-compatible-endpoint.mdx @@ -25,7 +25,7 @@ The following example starts vLLM on port `8000`. vllm serve meta-llama/Llama-3.1-8B-Instruct --host 0.0.0.0 --port 8000 ``` -Port `8000` is included in the `local-inference` network policy preset. +Port `8000` is one of NemoClaw's bundled host-gateway ports. The examples on this page listen on every IPv4 interface so both host-side onboarding and a containerized gateway can reach the server. @@ -45,11 +45,19 @@ $$nemoclaw onboard Select **Other OpenAI-compatible endpoint**. Enter the server base URL and the model ID reported by the server. -Use `http://localhost:8000/v1` when the default Docker-driver topology makes the host endpoint reachable at that address. -For a containerized OpenShell gateway that cannot reach host `localhost`, use the host-gateway URL for your environment, commonly `http://host.openshell.internal:8000/v1`. - -For host-routable endpoints, NemoClaw verifies the API, tool-calling, and streaming paths before sandbox creation. -The host-side endpoint probe is skipped for the sandbox-internal `host.openshell.internal` alias, so verify the runtime route after onboarding when you use that address. +Use a host-routable URL such as `http://localhost:8000/v1` when you want onboarding to verify the API, tool-calling, and streaming paths before gateway registration. +To qualify for automatic rewriting, an HTTP endpoint URL must use the exact loopback host `localhost`, `127.0.0.1`, or `[::1]`. +Automatic rewriting is limited to NemoClaw's bundled host-gateway ports: `8000`, `11434`, and `11435`. +NemoClaw validates the entered URL from the host and registers the OpenShell gateway route through `host.openshell.internal:` for sandbox traffic. +Sandbox inference requests continue to use the base `inference.local` policy, so the managed compatible-endpoint route does not require adding the `local-inference` preset. +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 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. + +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, verify the runtime route after onboarding with `$$nemoclaw status` and a short agent request. The wizard prompts for an API key. Enter any non-empty placeholder such as `dummy` when the server does not require authentication. 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. 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 0fd7b946eb..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 { @@ -232,23 +236,18 @@ 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 - // 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, }); - providerResult = - existing.status === 0 - ? { ok: true } - : { - ok: false, - status: existing.status || 1, - message: `Recovered provider '${provider}' is no longer registered in OpenShell.`, - }; } else { const credentialValue = hydrateCredentialEnv(resolvedCredentialEnv); const env = @@ -311,7 +310,7 @@ export async function setupRemoteProviderInference( provider, config.providerType, resolvedCredentialEnv, - resolvedEndpointUrl, + gatewayEndpointUrl, env, ); } @@ -336,7 +335,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/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..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`; @@ -137,6 +141,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 +165,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. @@ -184,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 ff18693451..418cfc8c94 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, ); } @@ -971,39 +972,43 @@ 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, -}, 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"); +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"; 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", 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: redactString(endpointUrl, [apiKey]), + endpointUrl: fake.baseUrl, model, }); @@ -1012,17 +1017,41 @@ test("TC-INF-09 custom OpenAI-compatible endpoint responds through inference.loc sandboxName, { COMPATIBLE_API_KEY: apiKey, - NEMOCLAW_ENDPOINT_URL: endpointUrl, + NEMOCLAW_AGENT: "langchain-deepagents-code", + NEMOCLAW_ENDPOINT_URL: fake.baseUrl, NEMOCLAW_MODEL: model, + NEMOCLAW_PREFERRED_API: "openai-completions", NEMOCLAW_PROVIDER: "custom", }, [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}`, () => 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).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"); + 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 +1059,38 @@ 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", + }), + ); + + 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", + }), + ); }); diff --git a/test/e2e/mock-parity.json b/test/e2e/mock-parity.json index 8755fa7af3..778cff9352 100644 --- a/test/e2e/mock-parity.json +++ b/test/e2e/mock-parity.json @@ -3,11 +3,17 @@ "version": 1, "entries": [ { - "live": "test/e2e/live/full-e2e.test.ts", + "live": "test/e2e/live/inference-routing.test.ts", "fast": [ - "test/e2e/support/onboard-performance.test.ts" + "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/full-e2e.test.ts", + "fast": ["test/e2e/support/onboard-performance.test.ts"] + }, { "live": "test/e2e/live/onboard-repair.test.ts", "fast": [ 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 18994deb72..588fce76b8 100644 --- a/test/inference-options-docs.test.ts +++ b/test/inference-options-docs.test.ts @@ -34,6 +34,12 @@ const compatibleEndpointPath = path.join( "inference", "set-up-openai-compatible-endpoint.mdx", ); +const inferenceRoutingPath = path.join( + repoRoot, + "docs", + "inference", + "how-inference-routing-works.mdx", +); const compatibleApiPath = path.join( repoRoot, "docs", @@ -242,7 +248,7 @@ describe("inference setup navigation", () => { expect(markdown).toContain("[Set Up NVIDIA NIM](set-up-nvidia-nim)"); }); - it("uses container-reachable binds with restricted exposure guidance", () => { + it("uses container-reachable binds with restricted exposure guidance (#5744)", () => { const markdown = fs.readFileSync(compatibleEndpointPath, "utf8"); const hostValues = Array.from( markdown.matchAll(/--host(?:=|\s+)([^\s`\\]+)/g), @@ -343,7 +349,39 @@ describe("inference setup navigation", () => { expect(result).toMatchObject({ api: null, label: null, ok: true }); expect(result.note).toContain("validation skipped"); expect(markdown).toContain("`http://host.openshell.internal:8000/v1`"); - expect(markdown).toContain("host-side endpoint probe is skipped"); + expect(markdown).toContain( + "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( + "Automatic rewriting is limited to NemoClaw's bundled host-gateway ports: `8000`, `11434`, and `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( + "Sandbox inference requests continue to use the base `inference.local` policy, so the managed compatible-endpoint route does not require adding the `local-inference` preset.", + ); + 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.", + ); + 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 host-routable endpoint such as `localhost` when you need onboarding to verify the API, tool-calling, and streaming paths", + ); + }); + + it("documents credential-free recovery of automatically bridged routes (#5744)", () => { + const markdown = fs.readFileSync(inferenceRoutingPath, "utf8"); + + expect(markdown).toContain( + "When a rebuild reuses an automatically bridged compatible-endpoint route without a host API key, NemoClaw reapplies the config-only bridge rewrite without reading or passing the credential stored in OpenShell.", + ); }); it("keeps provider credentials out of documented helper argv", () => { @@ -372,9 +410,7 @@ describe("inference setup navigation", () => { expect(vllm).toContain( "NemoClaw uses that value for the configured context window unless you set `NEMOCLAW_CONTEXT_WINDOW`.", ); - expect(endpoint).toContain( - "Port `8000` is included in the `local-inference` network policy preset.", - ); + expect(endpoint).toContain("Port `8000` is one of NemoClaw's bundled host-gateway ports."); expect(vllm).toContain("Docker's `--restart unless-stopped` policy"); expect(verification).toContain( "The `Inference` row checks the sandbox's `inference.local` path", diff --git a/test/onboard-inference-gateway-scope.test.ts b/test/onboard-inference-gateway-scope.test.ts index d45ddbff11..67606df0bc 100644 --- a/test/onboard-inference-gateway-scope.test.ts +++ b/test/onboard-inference-gateway-scope.test.ts @@ -57,6 +57,101 @@ 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?tenant=local#models"; + 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?tenant=local#models`, + `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("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");