Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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