Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 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
2 changes: 1 addition & 1 deletion docs/reference/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -3681,7 +3681,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` attaches an existing operator-managed server at the fixed loopback port 8081. 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
2 changes: 1 addition & 1 deletion scripts/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -817,7 +817,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
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
91 changes: 81 additions & 10 deletions src/lib/adapters/http/probe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
type SpawnSyncReturns,
spawnSync,
} from "node:child_process";
import { randomUUID } from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
Expand All @@ -16,7 +17,11 @@ import type { ProbeResult } from "../../onboard/types";
import { buildScrubbedCurlProbeEnv, scrubCredentialEnv } from "../../security/credential-env";
import { ROOT } from "../../state/paths";
import { addTraceEvent, withTraceSpan } from "../../trace";
import { buildCurlProbeSpawnArgs, validateCurlProbeArgs } from "./curl-args";
import {
buildBoundedCurlProbeSpawnArgs,
buildCurlProbeSpawnArgs,
validateCurlProbeArgs,
} from "./curl-args";

export type CurlProbeResult = ProbeResult;

Expand All @@ -25,6 +30,8 @@ export interface CurlProbeOptions {
env?: NodeJS.ProcessEnv;
replaceEnv?: boolean;
timeoutMs?: number;
/** Maximum response-body bytes captured by the curl process. */
maxResponseBytes?: number;
/** Absolute or cwd-relative curl config files created by trusted NemoClaw callers. */
trustedConfigFiles?: readonly string[];
/**
Expand All @@ -50,6 +57,8 @@ export interface StreamingProbeResult {

const DEFAULT_CURL_PROCESS_TIMEOUT_MS = 30_000;
const CURL_PROCESS_TIMEOUT_SLACK_MS = 5_000;
const CURL_HTTP_STATUS_MARKER_PREFIX = "\n__NEMOCLAW_HTTP_STATUS_";
const CURL_OVERSIZED_RESPONSE_STATUS = 63;

function resolveCurlProbeSpawnEnv(
args: readonly string[],
Expand Down Expand Up @@ -152,6 +161,31 @@ function normalizeSpawnErrorCode(error: unknown): number {
return typeof rawErrorCode === "number" ? rawErrorCode : 1;
}

function normalizeMaxResponseBytes(value: number | undefined): number | null {
if (value === undefined) return null;
if (!Number.isSafeInteger(value) || value <= 0) {
throw new Error("curl probe maxResponseBytes must be a positive safe integer");
}
return value;
}

function isSpawnBufferOverflow(error: unknown): boolean {
return isErrnoException(error) && error.code === "ENOBUFS";
}

function splitBoundedCurlOutput(
stdout: string,
statusMarker: string,
): { body: string; status: number } {
const markerIndex = stdout.lastIndexOf(statusMarker);
if (markerIndex < 0) return { body: "", status: 0 };
const status = Number(stdout.slice(markerIndex + statusMarker.length).trim());
return {
body: stdout.slice(0, markerIndex),
status: Number.isFinite(status) ? status : 0,
};
}

function sanitizeCurlUrl(value: string): string {
try {
const url = new URL(value);
Expand Down Expand Up @@ -261,7 +295,16 @@ function runCurlProbeImpl(argv: string[], opts: CurlProbeOptions = {}): CurlProb
const { args, url } = validateCurlProbeArgs(argv, opts);
const spawnSyncImpl = opts.spawnSyncImpl ?? spawnSync;
const timeout = resolveCurlProcessTimeoutMs(argv, opts);
const curlArgs = buildCurlProbeSpawnArgs(args, url, bodyFile, "json");
const maxResponseBytes = normalizeMaxResponseBytes(opts.maxResponseBytes);
const statusMarker = `${CURL_HTTP_STATUS_MARKER_PREFIX}${randomUUID()}__:`;
const curlArgs =
maxResponseBytes === null
? buildCurlProbeSpawnArgs(args, url, bodyFile, "json")
: buildBoundedCurlProbeSpawnArgs(args, url, statusMarker);
const maxBuffer =
maxResponseBytes === null
? undefined
: maxResponseBytes + Buffer.byteLength(`${statusMarker}999`);
const result = spawnSyncImpl(
"curl",
// lgtm[js/file-access-to-http] curlArgs were validated and rebuilt from safe probe fields.
Expand All @@ -271,26 +314,54 @@ function runCurlProbeImpl(argv: string[], opts: CurlProbeOptions = {}): CurlProb
encoding: "utf8",
timeout,
env: resolveCurlProbeSpawnEnv(args, opts),
...(maxBuffer === undefined ? {} : { maxBuffer }),
},
);
const body = fs.existsSync(bodyFile) ? fs.readFileSync(bodyFile, "utf8") : "";
const boundedOutput =
maxResponseBytes === null
? null
: splitBoundedCurlOutput(String(result.stdout || ""), statusMarker);
const body =
boundedOutput?.body ?? (fs.existsSync(bodyFile) ? fs.readFileSync(bodyFile, "utf8") : "");
if (result.error) {
const errorCode = normalizeSpawnErrorCode(result.error);
const errorMessage = compactText(
`${result.error.message || String(result.error)} ${String(result.stderr || "")}`,
);
const overflow = isSpawnBufferOverflow(result.error);
const errorCode = overflow
? CURL_OVERSIZED_RESPONSE_STATUS
: normalizeSpawnErrorCode(result.error);
const errorMessage = overflow
? "curl response exceeded the configured process byte limit"
: compactText(
`${result.error.message || String(result.error)} ${String(result.stderr || "")}`,
);
const failure = {
ok: false,
httpStatus: 0,
curlStatus: errorCode,
body,
body: overflow ? "" : body,
stderr: errorMessage,
message: summarizeProbeFailure(body, 0, errorCode, errorMessage),
message: summarizeProbeFailure(overflow ? "" : body, 0, errorCode, errorMessage),
};
emitCurlResultTraceEvent({ ok: false, http_status: 0, curl_status: errorCode });
return failure;
}
const status = Number(String(result.stdout || "").trim());
if (maxResponseBytes !== null && Buffer.byteLength(body) > maxResponseBytes) {
const errorMessage = "curl response exceeded the configured process byte limit";
const failure = {
ok: false,
httpStatus: 0,
curlStatus: CURL_OVERSIZED_RESPONSE_STATUS,
body: "",
stderr: errorMessage,
message: summarizeCurlFailure(CURL_OVERSIZED_RESPONSE_STATUS, errorMessage),
};
emitCurlResultTraceEvent({
ok: false,
http_status: 0,
curl_status: CURL_OVERSIZED_RESPONSE_STATUS,
});
return failure;
}
const status = boundedOutput?.status ?? Number(String(result.stdout || "").trim());
const probeResult = {
ok: result.status === 0 && status >= 200 && status < 300,
httpStatus: Number.isFinite(status) ? status : 0,
Expand Down
Loading
Loading