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
5 changes: 3 additions & 2 deletions docs/reference/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -3625,7 +3625,8 @@ Passthrough commands do not consume flags intended for the downstream command as
</AgentOnly>

If a port value is not a valid integer or falls outside the allowed range, the CLI exits with an error.
`NEMOCLAW_GATEWAY_PORT` also cannot overlap configured service, vLLM, Ollama, Ollama proxy, OpenRouter runtime adapter, or HTTPS Pin Runtime adapter ports, and cannot use reserved auto-allocation ranges or the default inference/proxy ports `8000`, `11434`, `11435`, `11437`, and `11438`.
`NEMOCLAW_GATEWAY_PORT` also cannot overlap configured service, vLLM, Ollama, Ollama proxy, OpenRouter runtime adapter, or HTTPS Pin Runtime adapter ports, and cannot use reserved auto-allocation ranges or the default inference/proxy ports `8000`, `8081`, `11434`, `11435`, `11437`, and `11438`.
Port `8081` is reserved for authenticated llama.cpp existing-server attachment and cannot be assigned to any configurable NemoClaw service port.
When you select OpenRouter, `NEMOCLAW_OPENROUTER_RUNTIME_ADAPTER_PORT` must also be distinct from the gateway, vLLM, Ollama, Ollama proxy, and HTTPS Pin Runtime adapter ports.
When you run multiple NemoClaw gateways with different `NEMOCLAW_GATEWAY_PORT` values, NemoClaw derives a separate gateway name, state directory, and compatibility container name from the port so one gateway does not tear down another.
Only port `8080` uses a NemoClaw-managed Linux systemd user service or macOS Homebrew service.
Expand Down Expand Up @@ -3690,7 +3691,7 @@ Set them before running `$$nemoclaw onboard`.

| Variable | Format | Effect |
|----------|--------|--------|
| `NEMOCLAW_PROVIDER` | provider key (e.g. `build`, `openrouter`, `openai`, `anthropic`, `anthropicCompatible`, `gemini`, `ollama`, `custom`, `vllm`, `nim-local`, `routed`, `hermes-provider`, `install-vllm`, `install-ollama`, `install-windows-ollama`, `start-windows-ollama`) | Selects the inference provider during onboarding. The wizard skips the provider menu in both interactive and non-interactive runs when this is set. Aliases: `cloud` → `build`, `open-router` / `openrouterai` → `openrouter`, `nim` → `nim-local`, `hermes` / `nous` / `nous-portal` → `hermes-provider`, `anthropiccompatible` → `anthropicCompatible`. Invalid values fail fast with the list of accepted keys. |
| `NEMOCLAW_PROVIDER` | provider key (e.g. `build`, `openrouter`, `openai`, `anthropic`, `anthropicCompatible`, `gemini`, `ollama`, `custom`, `vllm`, `nim-local`, `routed`, `hermes-provider`, `llama-cpp`, `install-vllm`, `install-ollama`, `install-windows-ollama`, `start-windows-ollama`) | Selects the inference provider during onboarding. The wizard skips the provider menu in both interactive and non-interactive runs when this is set. `llama-cpp` selects attachment of an authenticated, operator-managed llama.cpp server on loopback port `8081`. Set `NEMOCLAW_LLAMACPP_LOCAL_TOKEN`; set `NEMOCLAW_MODEL` to the served alias when the server exposes multiple models. If the server does not provide consistent native llama.cpp evidence, select `custom`. Aliases: `cloud` → `build`, `open-router` / `openrouterai` → `openrouter`, `nim` → `nim-local`, `hermes` / `nous` / `nous-portal` → `hermes-provider`, `anthropiccompatible` → `anthropicCompatible`. Invalid values fail fast with the list of accepted keys. |
| `NEMOCLAW_TOOL_DISCLOSURE` | `progressive` or `direct` | Selects progressive tool discovery or the prior direct-exposure behavior. Defaults to `progressive`; `--tool-disclosure` takes precedence when both are set. |
| `NEMOCLAW_ENDPOINT_URL` | URL | Custom endpoint URL. Used together with `NEMOCLAW_PROVIDER=custom` for OpenAI-compatible endpoints or `NEMOCLAW_PROVIDER=anthropicCompatible` for Anthropic-compatible endpoints. |
| `NEMOCLAW_COMPATIBLE_AUTH_MODE` | `none` or unset | Explicitly selects no authentication for an HTTP OpenAI-compatible endpoint using `localhost`, `127.0.0.1`, or `[::1]` and port `8000`, `11434`, or `11435` during non-interactive onboarding. |
Expand Down
2 changes: 1 addition & 1 deletion install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ bootstrap_usage() {
printf " Exact JSON array of pre-fingerprint managed sandbox names\n"
printf " NEMOCLAW_PROVIDER build | openrouter | openai | anthropic | anthropicCompatible\n"
printf " | gemini | ollama | custom | nim-local | vllm | routed\n"
printf " | hermes-provider\n"
printf " | hermes-provider | llama-cpp\n"
printf " (aliases: cloud -> build, nim -> nim-local)\n"
printf " NEMOCLAW_POLICY_MODE suggested | custom | skip\n"
printf "\n"
Expand Down
13 changes: 12 additions & 1 deletion nemoclaw-blueprint/policies/presets/local-inference.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,23 @@

preset:
name: local-inference
description: "Local inference access (Ollama, vLLM) via host gateway"
description: "Local inference access (Ollama, vLLM, llama.cpp) through the OpenShell gateway"

network_policies:
local_inference:
name: local_inference
endpoints:
- host: host.openshell.internal
port: 8081
protocol: rest
enforcement: enforce
allowed_ips:
- 10.0.0.0/8
- 172.16.0.0/12
- 192.168.0.0/16
rules:
- allow: { method: GET, path: "/**" }
- allow: { method: POST, path: "/**" }
- host: host.openshell.internal
port: 11434
protocol: rest
Expand Down
9 changes: 7 additions & 2 deletions scripts/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ resolve_nemoclaw_gateway_port() {
error "NEMOCLAW_GATEWAY_PORT must not overlap the 18789-18799 dashboard port range."
fi
case "$port" in
8000 | 11434 | 11435 | 11436 | 11437)
8000 | 8081 | 11434 | 11435 | 11436 | 11437)
error "NEMOCLAW_GATEWAY_PORT must not overlap a reserved inference or runtime-adapter port ($port)."
;;
esac
Expand All @@ -238,6 +238,7 @@ resolve_nemoclaw_gateway_port() {
NEMOCLAW_OLLAMA_PROXY_PORT
NEMOCLAW_BEDROCK_RUNTIME_ADAPTER_PORT
NEMOCLAW_OPENROUTER_RUNTIME_ADAPTER_PORT
NEMOCLAW_HTTPS_PIN_RUNTIME_ADAPTER_PORT
)
local -a configured_ports=(
"${NEMOCLAW_DASHBOARD_PORT:-18789}"
Expand All @@ -246,12 +247,16 @@ resolve_nemoclaw_gateway_port() {
"${NEMOCLAW_OLLAMA_PROXY_PORT:-11435}"
"${NEMOCLAW_BEDROCK_RUNTIME_ADAPTER_PORT:-11436}"
"${NEMOCLAW_OPENROUTER_RUNTIME_ADAPTER_PORT:-11437}"
"${NEMOCLAW_HTTPS_PIN_RUNTIME_ADAPTER_PORT:-11438}"
)
local i configured_port
for i in "${!configured_ports[@]}"; do
configured_port="${configured_ports[$i]}"
configured_port="${configured_port#"${configured_port%%[![:space:]]*}"}"
configured_port="${configured_port%"${configured_port##*[![:space:]]}"}"
if [[ "$configured_port" =~ ^0*8081$ ]]; then
error "${configured_names[$i]} must not overlap the fixed llama.cpp inference port (8081)."
fi
if [[ "$configured_port" =~ ^[0-9]+$ ]] && [ "$port" -eq "$configured_port" ]; then
error "NEMOCLAW_GATEWAY_PORT conflicts with ${configured_names[$i]} ($configured_port)."
fi
Expand Down Expand Up @@ -817,7 +822,7 @@ usage() {
printf " NEMOCLAW_INSTALL_REF Exact Git ref/SHA to install\n"
printf " NEMOCLAW_PROVIDER build | openrouter | openai | anthropic | anthropicCompatible\n"
printf " | gemini | ollama | custom | nim-local | vllm | routed\n"
printf " | hermes-provider\n"
printf " | hermes-provider | llama-cpp\n"
printf " (aliases: cloud -> build, nim -> nim-local)\n"
printf " NEMOCLAW_MODEL Inference model to configure\n"
printf " NEMOCLAW_POLICY_MODE suggested | custom | skip\n"
Expand Down
5 changes: 5 additions & 0 deletions scripts/lib/runtime.sh
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,11 @@ _validate_port() {
return 1
;;
esac
if [[ "$value" =~ ^0*8081$ ]]; then
printf 'Invalid %s=%s (conflicts with fixed llama.cpp inference port 8081)\n' \
"$name" "$value" >&2
return 1
fi
if ! { [ "$value" -ge 1024 ] && [ "$value" -le 65535 ]; }; then
printf 'Invalid %s=%s (expected 1024-65535)\n' "$name" "$value" >&2
return 1
Expand Down
13 changes: 12 additions & 1 deletion src/lib/actions/sandbox/rebuild-route-preflight.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,15 @@ const remoteProviders = [
(provider): provider is typeof provider & { credentialEnv: string } =>
typeof provider.credentialEnv === "string" && provider.credentialEnv.length > 0,
);
const remoteProviderRouteOverrides = new Map<string, Partial<SandboxEntry>>([
[
REMOTE_PROVIDER_CONFIG["llama-cpp"].providerName,
{
endpointUrl: REMOTE_PROVIDER_CONFIG["llama-cpp"].endpointUrl ?? null,
preferredInferenceApi: "openai-completions",
},
],
]);

describe("commitRebuildRoutePreflight", () => {
it("includes a credential-bearing provider in the migration matrix (#7798)", () => {
Expand All @@ -98,10 +107,12 @@ describe("commitRebuildRoutePreflight", () => {
it.each(
remoteProviders,
)("migrates missing shared-gateway credential identity for $providerName (#7798)", (providerConfig) => {
const routeOverrides = remoteProviderRouteOverrides.get(providerConfig.providerName) ?? {};
const target = sandbox("target", providerConfig.providerName, {
...routeOverrides,
credentialEnv: providerConfig.credentialEnv,
});
const peer = sandbox("peer", providerConfig.providerName);
const peer = sandbox("peer", providerConfig.providerName, routeOverrides);
const state = transactionDependencies(registry(target, peer));

const result = commitRebuildRoutePreflight(
Expand Down
26 changes: 25 additions & 1 deletion src/lib/adapters/http/curl-args.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,33 @@
import { describe, expect, it } from "vitest";

import { assertEndpointResolvesPublic } from "../../inference/endpoint-ssrf-preflight";
import { validateCurlProbeArgs } from "./curl-args";
import { buildBoundedCurlProbeSpawnArgs, validateCurlProbeArgs } from "./curl-args";

describe("validateCurlProbeArgs — credential-leak defence", () => {
it("allows a fixed response-byte cap for bounded llama.cpp probes (#8161)", () => {
expect(
validateCurlProbeArgs(["-sS", "--max-filesize", "262144", "http://127.0.0.1:8081/v1/models"])
.args,
).toEqual(["-sS", "--max-filesize", "262144"]);
});

it("rebuilds bounded llama.cpp probe argv from validated fields (#8161)", () => {
expect(
buildBoundedCurlProbeSpawnArgs(
["-sS", "--max-filesize", "262144"],
"http://127.0.0.1:8081/v1/models",
"\n__NEMOCLAW_HTTP_STATUS_test__:",
),
).toEqual([
"-sS",
"--max-filesize",
"262144",
"-w",
"\n__NEMOCLAW_HTTP_STATUS_test__:%{http_code}",
"http://127.0.0.1:8081/v1/models",
]);
});

it("rejects an inline Authorization header so credentials cannot reach argv", () => {
expect(() =>
validateCurlProbeArgs([
Expand Down
17 changes: 16 additions & 1 deletion src/lib/adapters/http/curl-args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,13 @@ const CURL_SAFE_FLAG_OPTIONS = new Set([
// genuinely need to follow redirects from a fixed, hardcoded host (e.g. the
// Ollama manifest probe) must opt in via CurlProbeArgOptions.allowRedirects.
const CURL_REDIRECT_FLAG_OPTIONS = new Set(["-L", "-sfL", "--location"]);
const CURL_SAFE_VALUE_OPTIONS = new Set(["--connect-timeout", "--max-time", "-X", "--request"]);
const CURL_SAFE_VALUE_OPTIONS = new Set([
"--connect-timeout",
"--max-time",
"--max-filesize",
"-X",
"--request",
]);
const CURL_FORBIDDEN_MULTI_TRANSFER_OPTIONS = new Set(["--next"]);
const CURL_SHORT_OPTIONS_WITH_VALUES = new Set(["-K", "-b", "-T", "-d", "-F", "-H", "-X"]);

Expand Down Expand Up @@ -388,3 +394,12 @@ export function buildCurlProbeSpawnArgs(
// lgtm[js/file-access-to-http] URL/argv are validated; file-backed config paths must be explicitly trusted.
return [...args, ...outputArgs, ...statusArgs, url];
}

export function buildBoundedCurlProbeSpawnArgs(
args: string[],
url: string,
statusMarker: string,
): string[] {
// lgtm[js/file-access-to-http] URL/argv are validated; the status marker is generated in-process.
return [...args, "-w", `${statusMarker}%{http_code}`, url];
}
88 changes: 88 additions & 0 deletions src/lib/adapters/http/probe.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { spawn } from "node:child_process";
import { once } from "node:events";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
Expand Down Expand Up @@ -104,6 +106,92 @@ describe("http-probe helpers", () => {
expect(fs.existsSync(path.dirname(outputPath))).toBe(false);
});

it("captures a response at the process byte limit without a body temp file (#8161)", () => {
const body = "x".repeat(1024);
let maxBuffer: number | undefined;
const result = runCurlProbe(["-sS", "https://example.test/models"], {
maxResponseBytes: 1024,
spawnSyncImpl: (_command, args, options) => {
maxBuffer = options.maxBuffer;
expect(args).not.toContain("-o");
const writeOut = args[args.indexOf("-w") + 1];
const statusMarker = writeOut.slice(0, -"%{http_code}".length);
return {
pid: 1,
output: [],
stdout: `${body}${statusMarker}200`,
stderr: "",
status: 0,
signal: null,
};
},
});

expect(result).toMatchObject({ ok: true, httpStatus: 200, curlStatus: 0, body });
expect(maxBuffer).toBeGreaterThan(1024);
expect(maxBuffer).toBeLessThan(1150);
});

it("maps a process buffer overflow to curl's oversized-response status (#8161)", () => {
const result = runCurlProbe(["-sS", "https://example.test/models"], {
maxResponseBytes: 1024,
spawnSyncImpl: () => ({
pid: 1,
output: [],
stdout: "partial untrusted response",
stderr: "partial diagnostic",
status: null,
signal: "SIGTERM",
error: Object.assign(new Error("spawnSync curl ENOBUFS"), { code: "ENOBUFS" }),
}),
});

expect(result).toMatchObject({
ok: false,
httpStatus: 0,
curlStatus: 63,
body: "",
stderr: "curl response exceeded the configured process byte limit",
});
expect(result.message).not.toContain("partial untrusted response");
expect(result.message).not.toContain("partial diagnostic");
});

it("aborts an unknown-length chunked response at the process byte limit (#8161)", async () => {
const serverScript = String.raw`
const http = require("node:http");
const server = http.createServer((_request, response) => {
response.writeHead(200, {
"Content-Type": "application/json",
"Transfer-Encoding": "chunked",
});
response.write("x".repeat(128 * 1024));
response.end("x".repeat(128 * 1024));
});
server.listen(0, "127.0.0.1", () => {
process.stdout.write(String(server.address().port) + "\n");
});
process.on("SIGTERM", () => server.close(() => process.exit(0)));
`;
const server = spawn(process.execPath, ["-e", serverScript], {
stdio: ["ignore", "pipe", "pipe"],
});
const exit = once(server, "exit");
const [portOutput] = await once(server.stdout, "data");

try {
const result = runCurlProbe(
["-sS", "--max-time", "5", `http://127.0.0.1:${Number(String(portOutput).trim())}/`],
{ maxResponseBytes: 1024, pinnedAddresses: [] },
);

expect(result).toMatchObject({ ok: false, httpStatus: 0, curlStatus: 63, body: "" });
} finally {
server.kill("SIGTERM");
await exit;
}
});

it("lets the process wrapper outlive curl --max-time", () => {
let timeout: number | undefined;
const result = runCurlProbe(["-sS", "--max-time", "60", "https://example.test/models"], {
Expand Down
Loading
Loading