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
7 changes: 6 additions & 1 deletion docs/inference/custom-endpoint-security.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -78,12 +78,17 @@ Hermes Provider setup, host-side `config set`, and a direct blueprint run still
## Use the Sandbox Host Alias

NemoClaw accepts `http://host.openshell.internal:<port>` only when the URL includes an explicit port from `1024` through `65535`.
This narrow exception supports sandbox-to-host inference routes and is not a general bypass for private endpoints.
This narrow exception supports NemoClaw-managed sandbox-to-host inference routes and host-side `config set` writes to supported provider `baseUrl` fields that already use the OpenShell host bridge, including OpenClaw memory-search embedding providers.
Generic `config set` keys continue to reject the bridge host.
Accepting this URL shape does not by itself make an arbitrary local service, embedding provider, config key, or network route a supported NemoClaw integration.

For example, a containerized gateway can commonly reach a local server at `http://host.openshell.internal:8000/v1`.
Because this name is a sandbox-internal alias, onboarding skips the host-side endpoint probe.
Verify the runtime route after onboarding when you use it.

The exception is intentionally exact: use HTTP, the literal `host.openshell.internal` name, and an explicit unprivileged port.
NemoClaw still rejects HTTPS, privileged ports, subdomains, loopback names, private IP literals, userinfo, query strings, and fragments through this path.

## Related Topics

- [Set Up an OpenAI-Compatible Endpoint](set-up-openai-compatible-endpoint) for OpenAI-compatible servers.
Expand Down
51 changes: 51 additions & 0 deletions docs/inference/set-up-ollama.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,57 @@ $$nemoclaw onboard

If the probe cannot run because the host uses another routing model, onboarding continues and relies on the regular proxy health check.

<AgentOnly variant="openclaw">

## Point Memory Search at a Host Ollama Container

On DGX Spark and other local-inference hosts, you can use one local server for chat and a separate Ollama container for OpenClaw memory-search embeddings.
For example, managed vLLM can serve the chat model while a host-side Ollama container serves `qwen3-embedding:4b` on port `11434`.

The sandbox reaches that container through `http://host.openshell.internal:11434`.
The `local-inference` policy preset allows that egress path, while `config set` separately validates that the bridge URL is written only to supported provider `baseUrl` fields.

First make sure the embedding server is reachable from the sandbox:

```bash
$$nemoclaw my-assistant policy add local-inference --yes
$$nemoclaw my-assistant exec -- curl -fsS http://host.openshell.internal:11434/api/tags
```

Then define an OpenClaw provider for the embedding model and point memory search at it:

```bash
$$nemoclaw my-assistant shields down

$$nemoclaw my-assistant config set \
--key models.providers.ollama-mem \
--value '{"api":"ollama","baseUrl":"http://host.openshell.internal:11434","apiKey":"x","models":[{"id":"qwen3-embedding:4b","name":"Qwen3 embedding 4B"}]}' \
--config-accept-new-path

$$nemoclaw my-assistant config set \
--key agents.defaults.memorySearch.provider \
--value ollama-mem \
--config-accept-new-path \
--restart

$$nemoclaw my-assistant shields up
```

If your memory index already contains documents, check whether OpenClaw reports an index-identity mismatch, rebuild the index, then verify the configured memory provider:

```bash
$$nemoclaw my-assistant exec -- openclaw memory status --index
$$nemoclaw my-assistant exec -- openclaw memory index --force
$$nemoclaw my-assistant exec -- openclaw memory status --deep
```

Add `--agent <id>` to those `openclaw memory` commands when you are configuring a specific non-default OpenClaw agent.

Keep the Ollama container bound only to the local host or a reviewed host interface.
Port `11434` is commonly unauthenticated, so allow it only from the OpenShell Docker bridge or the equivalent local container network path.

</AgentOnly>

## Run Non-Interactive Onboarding

Use this configuration to onboard with Ollama without prompts.
Expand Down
22 changes: 4 additions & 18 deletions src/lib/actions/inference-set-route-containment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
isHttpsPinRuntimeEligible,
} from "../inference/https-pin-runtime";
import { resolveSandboxGatewayName } from "../onboard/gateway-binding";
import { isAllowedOpenShellSandboxBridgeUrl } from "../private-networks";
import { ConfigUrlValidationError } from "../sandbox/config";
import type { ConfigValue } from "../security/credential-filter";
import type { Session } from "../state/onboard-session";
Expand Down Expand Up @@ -47,8 +48,8 @@ type RewriteConfigUrlsWithDnsPinning = (value: ConfigValue) => Promise<ConfigVal
* Resolves a DNS-backed HTTPS custom endpoint to a pinned, locally-terminated
* route base URL instead of the raw operator-supplied URL. OpenShell never
* sees the real hostname; the returned URL always targets the trusted
* `host.openshell.internal` bridge, matching the shape already exempted by
* {@link ALLOWED_PRIVATE_CUSTOM_ENDPOINT_HOSTS}.
* `host.openshell.internal` bridge, matching the shape already exempted by the
* shared OpenShell sandbox bridge URL predicate.
*/
export interface EnsureHttpsPinRuntimeAdapterOptions {
gatewayName: string;
Expand Down Expand Up @@ -102,14 +103,6 @@ function hasExplicitCustomMetadata(options: ExplicitCustomRouteOptions): boolean
return Boolean(options.endpointUrl || options.credentialEnv || options.inferenceApi);
}

// TRUST BOUNDARY: host.openshell.internal is the single sandbox-to-host bridge
// hostname provisioned by OpenShell. It resolves to the Docker host gateway
// only inside the sandbox network namespace. This exemption is intentionally
// limited below to HTTP, an explicit unprivileged port, and the exact hostname;
// do not extend it to HTTPS, wildcard subdomains, localhost, RFC1918 literals,
// or other internal DNS names.
const ALLOWED_PRIVATE_CUSTOM_ENDPOINT_HOSTS = new Set(["host.openshell.internal"]);

function normalizeEndpointUrlShape(value: string): { url: URL; normalized: string } {
const url = new URL(value);
if (
Expand Down Expand Up @@ -150,14 +143,7 @@ export async function normalizeCustomEndpointUrl(
): Promise<string> {
const normalized = normalizeCustomEndpointUrlWithoutDns(value);
const shaped = normalizeEndpointUrlShape(normalized);
const hostname = shaped.url.hostname.replace(/\.$/, "").toLowerCase();
const port = Number(shaped.url.port);
if (
ALLOWED_PRIVATE_CUSTOM_ENDPOINT_HOSTS.has(hostname) &&
shaped.url.protocol === "http:" &&
Number.isInteger(port) &&
port >= 1024
) {
if (isAllowedOpenShellSandboxBridgeUrl(shaped.url)) {
// This is the single sandbox-to-host bridge name that NemoClaw itself
// provisions for local inference. Its supported routes are explicit
// unprivileged HTTP listeners; do not generalize this exemption to HTTPS,
Expand Down
27 changes: 27 additions & 0 deletions src/lib/onboard/inference-providers/hermes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,33 @@ describe("setupHermesProviderInference SSRF guard (#6072)", () => {
expect(deps.runOpenshell).toHaveBeenCalled();
});

it("accepts the exact OpenShell host bridge endpoint without DNS lookup (#7453)", async () => {
const lookup = vi.fn(async () => {
throw new Error("lookup should not run for the OpenShell host bridge");
});
const deps = makeDeps({ lookup });

await expect(
setupHermesProviderInference(
makeArgs("http://host.openshell.internal:11434/v1"),
deps as never,
),
).resolves.toEqual({ ok: true });

expect(lookup).not.toHaveBeenCalled();
expect(deps.runOpenshell).toHaveBeenCalledWith(
["inference", "set", "--no-verify", "--provider", "p", "--model", "m"],
{ ignoreError: true },
);
expect(deps.verifyOnboardInferenceSmoke).toHaveBeenCalledWith(
expect.objectContaining({
provider: "p",
model: "m",
endpointUrl: "http://host.openshell.internal:11434/v1",
}),
);
});

it("rejects a public HTTPS hostname that resolves to a public IP until runtime-aware pinning exists (#4684)", async () => {
const deps = makeDeps({
lookup: vi.fn(async () => [{ address: "8.8.8.8", family: 4 }]),
Expand Down
4 changes: 3 additions & 1 deletion src/lib/onboard/inference-providers/hermes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,9 @@ export async function setupHermesProviderInference(
// so a DNS-backed HTTPS endpoint must be set after onboarding via
// `inference set --endpoint-url`.
try {
const validated = await rewriteConfigUrlsWithDnsPinning(endpointUrl, deps.lookup);
const validated = await rewriteConfigUrlsWithDnsPinning(endpointUrl, deps.lookup, {
allowOpenShellBridge: true,
});
resolvedEndpointUrl = typeof validated === "string" ? validated : endpointUrl;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
Expand Down
8 changes: 4 additions & 4 deletions src/lib/policy/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
} from "../messaging/channels";
import { resolveSandboxGatewayName } from "../onboard/gateway-binding";
import { assertNoOpenShellGatewayEndpointOverride } from "../openshell-gateway-endpoint-guard";
import { OPENSHELL_SANDBOX_HOST_BRIDGE } from "../private-networks";
import { ROOT, run, runCapture } from "../runner";
import * as registry from "../state/registry";
import type { BaselineExclusionRuntimeStatus } from "./baseline-exclusion";
Expand Down Expand Up @@ -157,10 +158,9 @@ function loadPreset(name: string): string | null {
// The single sandbox->host bridge hostname OpenShell provisions. An endpoint
// that pins `allowed_ips` for THIS host is the legitimate host-gateway flow
// (e.g. web_fetch to host.openshell.internal); `allowed_ips` on any other host
// is a user-preset egress-bypass attempt (#6073). Mirrors the
// ALLOWED_PRIVATE_CUSTOM_ENDPOINT_HOSTS trust boundary in
// src/lib/actions/inference-set.ts.
const HOST_GATEWAY_BRIDGE_HOST = "host.openshell.internal";
// is a user-preset egress-bypass attempt (#6073). Keep this hostname shared
// with the config-set and inference endpoint bridge trust boundary.
const HOST_GATEWAY_BRIDGE_HOST = OPENSHELL_SANDBOX_HOST_BRIDGE;

function endpointHostIsGatewayBridge(ep: PolicyObject): boolean {
const host = (ep as { host?: unknown }).host;
Expand Down
21 changes: 21 additions & 0 deletions src/lib/private-networks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import { ROOT } from "./runner";

const NETWORKS_FILE = path.join(ROOT, "nemoclaw-blueprint", "private-networks.yaml");

export const OPENSHELL_SANDBOX_HOST_BRIDGE = "host.openshell.internal";

export interface NetworkEntry {
address: string;
prefix: number;
Expand Down Expand Up @@ -191,6 +193,25 @@ export function isPrivateHostname(hostname: string): boolean {
return isPrivateIp(normalised);
}

export function isAllowedOpenShellSandboxBridgeUrl(url: URL): boolean {
const hostname =
url.hostname.startsWith("[") && url.hostname.endsWith("]")
? url.hostname.slice(1, -1)
: url.hostname;
const port = Number(url.port);
return (
hostname.replace(/\.$/, "").toLowerCase() === OPENSHELL_SANDBOX_HOST_BRIDGE &&
url.protocol === "http:" &&
Number.isInteger(port) &&
port >= 1024 &&
port <= 65535 &&
!url.username &&
!url.password &&
!url.search &&
!url.hash
);
}

/**
* Return true when `hostname` is an IPv4/IPv6 loopback literal (127.0.0.0/8 or
* ::1) or the RFC 6761 `localhost` special-use name.
Expand Down
Loading
Loading