Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 12 additions & 6 deletions .github/workflows/e2e.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand All @@ -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,') }}
Expand Down
1 change: 1 addition & 0 deletions docs/inference/how-inference-routing-works.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<AgentOnly variant="deepagents">

Expand Down
20 changes: 14 additions & 6 deletions docs/inference/set-up-openai-compatible-endpoint.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<Warning>
The examples on this page listen on every IPv4 interface so both host-side onboarding and a containerized gateway can reach the server.
Expand All @@ -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:<port>` 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 <name> 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 <name> 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.
Expand Down
19 changes: 11 additions & 8 deletions docs/security/openclaw-2026.6.10-dependency-review.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Original file line number Diff line number Diff line change
@@ -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();
});
});
Original file line number Diff line number Diff line change
@@ -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<number>(
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, {});
}
Loading
Loading