Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
a9766b2
fix(inference): honor compatible-endpoint max_model_len for Hermes co…
yimoj Jul 6, 2026
44f0e00
test(inference): avoid adding if statements in #6177 Hermes context t…
yimoj Jul 6, 2026
f9a62f9
test(inference): prove endpoint auth flow; address #6177 review notes
yimoj Jul 6, 2026
09d89c8
fix(inference): skip host probe for sandbox-internal endpoints (#6177)
yimoj Jul 6, 2026
28401a5
test(inference): enforce /v1/models auth in probe E2E; clarify SSRF s…
yimoj Jul 6, 2026
4816821
chore(inference): review-nit polish for #6177 context propagation
yimoj Jul 6, 2026
2de6fc9
test(inference): chain endpoint probe to generated Hermes context_len…
yimoj Jul 6, 2026
defffcf
test(inference): assert /v1/models rejection and add issue-ref (#6177)
yimoj Jul 6, 2026
535d2a6
fix(inference): block private/internal endpoints at the SSRF source b…
prekshivyas Jul 6, 2026
0c9e678
fix(inference): exempt host.docker.internal from SSRF probe block; fi…
prekshivyas Jul 6, 2026
ae46f45
Merge remote-tracking branch 'upstream/main' into pr6293-fix
prekshivyas Jul 6, 2026
63ef306
fix(inference): exempt loopback from the onboard-probe SSRF guard (#6…
prekshivyas Jul 6, 2026
cc0e9fe
Merge remote-tracking branch 'upstream/main' into pr6293-fix
prekshivyas Jul 6, 2026
fcf67c3
chore: drop accidentally-committed local dogfood-env.sh from PR branch
prekshivyas Jul 6, 2026
110efaf
Merge branch 'main' into fix/6177-compatible-endpoint-context
prekshivyas Jul 6, 2026
f71e4b9
fix(inference): make compatible-endpoint loopback + context-window ha…
prekshivyas Jul 6, 2026
e77887e
fix(security): close DNS-backed SSRF in custom-endpoint onboarding pr…
prekshivyas Jul 6, 2026
c01fafe
chore: re-trigger PR CI/advisor for #6293 after DNS-preflight push
prekshivyas Jul 6, 2026
c8a5381
Merge remote-tracking branch 'upstream/main' into pr6293-fix
prekshivyas Jul 6, 2026
9bd6754
fix(security): pin the DNS SSRF preflight to the /v1/models curl boun…
prekshivyas Jul 6, 2026
0a903b6
fix(security): pin DNS-validated IP into onboarding curls, close rebi…
prekshivyas Jul 6, 2026
9fbc5e5
Revert "fix(security): pin DNS-validated IP into onboarding curls, cl…
prekshivyas Jul 6, 2026
1946150
Merge branch 'main' into fix/6177-compatible-endpoint-context
prekshivyas Jul 6, 2026
576167e
Merge remote-tracking branch 'origin/main' into fix/6177-compatible-e…
prekshivyas Jul 7, 2026
84c3d7d
fix(inference): always run endpoint SSRF preflight; drop VITEST bypas…
prekshivyas Jul 7, 2026
a0cace3
Merge branch 'main' into fix/6177-compatible-endpoint-context
prekshivyas Jul 7, 2026
c6b4e56
test(inference): inject SSRF resolvers + share hermes harness (#6293)
prekshivyas Jul 7, 2026
47429a9
Merge branch 'fix/6177-compatible-endpoint-context' of github.com:NVI…
prekshivyas Jul 7, 2026
86efe42
Merge branch 'main' into fix/6177-compatible-endpoint-context
prekshivyas Jul 7, 2026
6cfea8e
fix(inference): pin probe connections to preflight-validated address …
prekshivyas Jul 7, 2026
139fc63
fix(onboard): clear auto-detected context window on all selection pat…
prekshivyas Jul 7, 2026
191efbc
refactor(inference): extract pure HTTP probe helpers into a focused m…
prekshivyas Jul 7, 2026
f967a76
fix(inference): declare pinnedAddresses on the anthropic probe dep op…
prekshivyas Jul 7, 2026
816f5b5
test(inference): use vi.stubEnv in probe-http-helpers test (#6293)
prekshivyas Jul 7, 2026
5d0f4da
fix(inference): exempt OpenShell-managed hosts from SSRF preflight (#…
prekshivyas Jul 7, 2026
fde4bcd
Merge remote-tracking branch 'origin/main' into fix/6177-compatible-e…
prekshivyas Jul 7, 2026
6dbc6db
test(onboard): import shared provider-inference types in handler spec…
prekshivyas Jul 7, 2026
257f921
test(onboard): expect pinned addresses in custom-endpoint probe asser…
prekshivyas Jul 7, 2026
3986d60
fix(security): close remaining endpoint DNS rebinding paths (#6293)
prekshivyas Jul 7, 2026
c6e6e7a
Merge remote-tracking branch 'upstream/main' into fix/pr-6293-cv
prekshivyas Jul 7, 2026
abc6a17
test(e2e): align endpoint fixtures with SSRF pinning (#6293)
prekshivyas Jul 7, 2026
47dc8d0
test(e2e): route local inference through managed host alias (#6293)
prekshivyas Jul 7, 2026
2e07baf
test(e2e): align resumed endpoint verification (#6293)
prekshivyas Jul 7, 2026
48002c5
fix(security): constrain trusted Bedrock endpoints (#6293)
prekshivyas Jul 7, 2026
482db5d
test(e2e): keep Bedrock fixture branch-neutral (#6293)
prekshivyas Jul 7, 2026
bfe7c9f
test(e2e): preserve MCP rebinding proof address (#6293)
prekshivyas Jul 7, 2026
9d77c59
fix: close exact-head review and e2e failures
prekshivyas Jul 7, 2026
42f9fcd
test: accept authenticated models retry on resume
prekshivyas Jul 7, 2026
0b21fc0
fix: complete Hermes and resume live proofs
prekshivyas Jul 7, 2026
b7dea31
test: keep Hermes ordering fixture linear
prekshivyas Jul 7, 2026
bb95c6a
test: record authenticated compatible posts
prekshivyas Jul 7, 2026
e3a69ce
test: exercise Hermes GPU inference route
prekshivyas Jul 7, 2026
655fa10
Merge current main into fix/6177-compatible-endpoint-context
prekshivyas Jul 8, 2026
424a5e7
Merge latest main into fix/6177-compatible-endpoint-context
prekshivyas Jul 8, 2026
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: 7 additions & 0 deletions agents/hermes/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -272,13 +272,20 @@ ARG NEMOCLAW_HERMES_TOOL_GATEWAY_BROKER=0
ARG NEMOCLAW_HERMES_TOOL_GATEWAY_PRESETS_B64=W10=
ARG NEMOCLAW_BUILD_ID=default
ARG NEMOCLAW_DARWIN_VM_COMPAT=0
# Total model context window (input + output tokens). Empty by default so
# Hermes auto-detects from the endpoint's /v1/models max_model_len; onboard
# rewrites this ARG (via dockerfile-patch) when it probes a runtime value or
# the user sets NEMOCLAW_CONTEXT_WINDOW, so Hermes' NemotronH metadata default
# cannot override the real window (#6177).
ARG NEMOCLAW_CONTEXT_WINDOW=

# Promote build-args to env vars for the config generation script.
ENV NEMOCLAW_MODEL=${NEMOCLAW_MODEL} \
NEMOCLAW_PROVIDER_KEY=${NEMOCLAW_PROVIDER_KEY} \
NEMOCLAW_UPSTREAM_PROVIDER=${NEMOCLAW_UPSTREAM_PROVIDER} \
NEMOCLAW_INFERENCE_BASE_URL=${NEMOCLAW_INFERENCE_BASE_URL} \
NEMOCLAW_INFERENCE_API=${NEMOCLAW_INFERENCE_API} \
NEMOCLAW_CONTEXT_WINDOW=${NEMOCLAW_CONTEXT_WINDOW} \
NEMOCLAW_TOOL_DISCLOSURE=${NEMOCLAW_TOOL_DISCLOSURE} \
CHAT_UI_URL=${CHAT_UI_URL} \
NEMOCLAW_MESSAGING_PLAN_B64=${NEMOCLAW_MESSAGING_PLAN_B64} \
Expand Down
13 changes: 13 additions & 0 deletions agents/hermes/config/build-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ export type HermesBuildSettings = {
providerKey: string;
upstreamProvider: string;
inferenceApi: string;
/** Total context window (tokens); null lets Hermes auto-detect from /v1/models. */
contextWindow: number | null;
toolDisclosure: "progressive" | "direct";
webSearchProvider: HermesWebSearchProvider | null;
messagingCredentialPlaceholders: Array<{
Expand All @@ -36,6 +38,7 @@ export function readHermesBuildSettings(env: NodeJS.ProcessEnv): HermesBuildSett
providerKey: env.NEMOCLAW_PROVIDER_KEY || "custom",
upstreamProvider: env.NEMOCLAW_UPSTREAM_PROVIDER || env.NEMOCLAW_PROVIDER_KEY || "custom",
inferenceApi: env.NEMOCLAW_INFERENCE_API || "",
contextWindow: readContextWindow(env),
toolDisclosure: readToolDisclosureEnv(env),
webSearchProvider: readWebSearchProvider(env),
messagingCredentialPlaceholders: readMessagingCredentialPlaceholders(env),
Expand All @@ -46,6 +49,16 @@ export function readHermesBuildSettings(env: NodeJS.ProcessEnv): HermesBuildSett
};
}

// Parse NEMOCLAW_CONTEXT_WINDOW as a positive integer of tokens. Empty, absent,
// or malformed values return null so the generated config omits context_length
// and Hermes keeps auto-detecting from the endpoint's /v1/models. See #6177.
function readContextWindow(env: NodeJS.ProcessEnv): number | null {
const raw = (env.NEMOCLAW_CONTEXT_WINDOW || "").trim();
if (!/^[1-9][0-9]*$/.test(raw)) return null;
const parsed = Number(raw);
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null;
}

function readWebSearchProvider(env: NodeJS.ProcessEnv): HermesWebSearchProvider | null {
if (env.NEMOCLAW_WEB_SEARCH_ENABLED !== "1") return null;

Expand Down
13 changes: 13 additions & 0 deletions agents/hermes/config/hermes-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,19 @@ export function buildHermesConfig(
};
const apiMode = hermesApiMode(settings.inferenceApi);
if (apiMode) modelConfig.api_mode = apiMode;
// context_length on the model block is Hermes' highest-priority context
// override — above live /v1/models discovery and its built-in model-metadata
// registry. Setting it stops NemotronH-family models from falling back to a
// small architecture default when the endpoint actually serves a larger
// max_model_len (#6177). Omit it (null) to let Hermes auto-detect. Hermes
// reads only `context_length`; `context_window` is silently ignored.
//
// No separate auxiliary/compression context key is written: Hermes derives
// its compression trigger (compression.threshold × context_length) from the
// main model's context_length, so setting it here is sufficient for the
// reported "Cannot compress further" failure — the auxiliary/curator model is
// configured via auxiliary.* and needs no dedicated context length here.
if (settings.contextWindow !== null) modelConfig.context_length = settings.contextWindow;

// Surface the managed endpoint to Hermes' model picker. The inline `model:`
// block above is enough for the gateway to ROUTE inference, but the picker
Expand Down
19 changes: 17 additions & 2 deletions agents/hermes/runtime-config-guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -2653,7 +2653,7 @@ def seal_restart(
try:
_verify_strict_hash(hermes_dir, hash_file)
except StrictHashMismatchError:
if purpose != "config-write" or expected_config_sha256 is None:
if purpose not in ("config-write", "shields-mutable") or expected_config_sha256 is None:
raise
_reconcile_nonroot_startup_api_key_hash(
hermes_dir,
Expand Down Expand Up @@ -3441,8 +3441,23 @@ def begin_shields_transition(
rollback_mode or "mutable",
)

# A fresh managed non-root Hermes start mints exactly one API_SERVER_KEY and
# refreshes its sandbox-owned compatibility anchor, while the root-owned
# strict anchor deliberately remains unchanged. The first shields-down is
# the next root transaction and must admit that same narrowly reviewed
# reconciliation as write-config. Derive the expected config digest from
# the existing strict anchor so shields can never bless config drift.
strict_config_sha256, _strict_env_sha256, _strict_mcp_state = _parse_config_hash(
_read_hash_file(hash_file),
os.path.join(hermes_dir, "config.yaml"),
os.path.join(hermes_dir, ".env"),
)
original_locked = seal_restart(
hermes_dir, hash_file, state_file, purpose="shields-mutable"
hermes_dir,
hash_file,
state_file,
purpose="shields-mutable",
expected_config_sha256=strict_config_sha256,
)
try:
state_data = _load_restart_state(state_file)
Expand Down
2 changes: 1 addition & 1 deletion ci/test-file-size-budget.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"test/install-preflight.test.ts": 3934,
"test/nemoclaw-start.test.ts": 4826,
"test/onboard-messaging.test.ts": 2062,
"test/onboard-selection.test.ts": 4834,
"test/onboard-selection.test.ts": 4774,
"test/onboard.test.ts": 4043,
"test/policies.test.ts": 2243
}
Expand Down
5 changes: 4 additions & 1 deletion docs/inference/switch-inference-providers.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -304,7 +304,7 @@ To change these values, set the corresponding environment variables before runni

| Variable | Values | Default |
|---|---|---|
| `NEMOCLAW_CONTEXT_WINDOW` | Positive integer (tokens) | `131072` |
| `NEMOCLAW_CONTEXT_WINDOW` | Positive integer (tokens) | `131072` (OpenClaw baked metadata); Hermes leaves it unset to auto-detect |
| `NEMOCLAW_MAX_TOKENS` | Positive integer (tokens) | `4096` |
| `NEMOCLAW_REASONING` | `true` or `false` | `false` |
| `NEMOCLAW_INFERENCE_INPUTS` | `text` or `text,image` | `text` |
Expand All @@ -314,6 +314,9 @@ To change these values, set the corresponding environment variables before runni
NemoClaw ignores invalid values and bakes the default into the image.
For Local Ollama, onboarding loads the selected model first and uses Ollama's reported runtime context length when `NEMOCLAW_CONTEXT_WINDOW` is unset.
For local vLLM, onboarding uses the runtime `max_model_len` value when the server reports one and `NEMOCLAW_CONTEXT_WINDOW` is unset.
For an OpenAI-compatible endpoint (the **Other OpenAI-compatible endpoint** provider, including a self-hosted vLLM server), onboarding probes the endpoint's `/v1/models` response and uses its reported `max_model_len` when `NEMOCLAW_CONTEXT_WINDOW` is unset, so the agent gets the endpoint's real context window instead of a small architecture default.
Set `NEMOCLAW_CONTEXT_WINDOW` to override the probed value.
For Hermes, the resolved window is written as `model.context_length` in the generated `config.yaml`, and leaving it unset lets Hermes auto-detect.
Use `NEMOCLAW_INFERENCE_INPUTS=text,image` only for a model that accepts image input through the selected provider.
During interactive onboarding, NemoClaw prompts for **Text only** or **Text + Image** when the discovered model name looks multimodal and `NEMOCLAW_INFERENCE_INPUTS` is not already valid.
Non-interactive onboarding uses the environment value or the default `text` setting.
Expand Down
54 changes: 54 additions & 0 deletions src/lib/adapters/http/curl-args.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,4 +116,58 @@ describe("validateCurlProbeArgs — credential-leak defence", () => {
),
).not.toThrow();
});

it("accepts only an exact public --resolve mapping for the probe destination (#6293)", () => {
expect(() =>
validateCurlProbeArgs(
[
"-sS",
"--resolve",
"example.test:443:93.184.216.34,[2606:2800:220:1:248:1893:25c8:1946]",
"https://example.test/v1/models",
],
{ pinnedAddresses: ["93.184.216.34", "2606:2800:220:1:248:1893:25c8:1946"] },
),
).not.toThrow();
});

it.each([
["other.test:443:93.184.216.34", ["93.184.216.34"]],
["example.test:80:93.184.216.34", ["93.184.216.34"]],
["example.test:443:not-an-ip", ["not-an-ip"]],
["example.test:443:10.0.0.8", ["10.0.0.8"]],
["example.test:443:93.184.216.35", ["93.184.216.34"]],
])("rejects an unsafe or mismatched --resolve mapping %s (#6293)", (mapping, approved) => {
expect(() =>
validateCurlProbeArgs(["-sS", "--resolve", mapping, "https://example.test/v1/models"], {
pinnedAddresses: approved,
}),
).toThrow(/--resolve/);
});

it("rejects --resolve without an approved address capability (#6293)", () => {
expect(() =>
validateCurlProbeArgs([
"-sS",
"--resolve",
"example.test:443:93.184.216.34",
"https://example.test/v1/models",
]),
).toThrow(/pinnedAddresses/);
});

it("rejects repeated --resolve entries instead of letting curl drop earlier addresses (#6293)", () => {
expect(() =>
validateCurlProbeArgs(
[
"--resolve",
"example.test:443:93.184.216.34",
"--resolve",
"example.test:443:93.184.216.34",
"https://example.test/v1/models",
],
{ pinnedAddresses: ["93.184.216.34"] },
),
).toThrow(/only one --resolve/);
});
});
78 changes: 77 additions & 1 deletion src/lib/adapters/http/curl-args.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { isIP } from "node:net";
import path from "node:path";

import { isCredentialShapedName } from "../../security/credential-env";
import { ROOT } from "../../state/paths";

Expand All @@ -16,6 +16,8 @@ export interface CurlProbeArgOptions {
* hardcoded host.
*/
allowRedirects?: boolean;
/** Public addresses approved by the endpoint SSRF preflight. */
pinnedAddresses?: readonly string[];
}

const CURL_CONFIG_OPTIONS = new Set(["--config", "-K"]);
Expand Down Expand Up @@ -162,12 +164,76 @@ function isTrustedCurlConfigPath(value: string, opts: CurlProbeArgOptions): bool
.includes(candidate);
}

function normalizeHostname(hostname: string): string {
return (hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname)
.replace(/\.$/, "")
.toLowerCase();
}

function defaultUrlPort(url: URL): string {
return url.port || (url.protocol === "https:" ? "443" : "80");
}

function parseResolveAddresses(raw: string): string[] {
return raw
.split(",")
.map((value) => (value.startsWith("[") && value.endsWith("]") ? value.slice(1, -1) : value));
}

function isPrivateResolveAddress(address: string): boolean {
// Keep the generic curl validator import-light: many command tests mock the
// runner module that private-networks uses only to locate its YAML. Load the
// canonical classifier only for the uncommon --resolve validation path.
const { isPrivateIp } =
require("../../private-networks") as typeof import("../../private-networks");
return isPrivateIp(address);
}

function assertResolveMatchesApprovedEndpoint(
value: string,
target: URL,
opts: CurlProbeArgOptions,
): void {
const firstSeparator = value.indexOf(":");
const secondSeparator = value.indexOf(":", firstSeparator + 1);
if (firstSeparator <= 0 || secondSeparator <= firstSeparator + 1) {
throw new Error("curl probe --resolve must use host:port:address[,address] syntax");
}
const host = normalizeHostname(value.slice(0, firstSeparator));
const port = value.slice(firstSeparator + 1, secondSeparator);
const addresses = parseResolveAddresses(value.slice(secondSeparator + 1));
const approved = [...new Set(opts.pinnedAddresses ?? [])];
if (approved.length === 0) {
throw new Error("curl probe --resolve requires SSRF-preflight-approved pinnedAddresses");
}
if (host !== normalizeHostname(target.hostname) || port !== defaultUrlPort(target)) {
throw new Error("curl probe --resolve host and port must match the probe URL");
}
if (addresses.length === 0 || addresses.some((address) => isIP(address) === 0)) {
throw new Error("curl probe --resolve addresses must be numeric IP addresses");
}
if (addresses.some((address) => isPrivateResolveAddress(address))) {
throw new Error("curl probe --resolve must not map the destination to a private address");
}
const actualSet = new Set(addresses);
const approvedSet = new Set(approved);
if (
actualSet.size !== addresses.length ||
actualSet.size !== approvedSet.size ||
[...actualSet].some((address) => !approvedSet.has(address))
) {
throw new Error("curl probe --resolve addresses must exactly match pinnedAddresses");
}
}

export function validateCurlProbeArgs(
argv: string[],
opts: CurlProbeArgOptions = {},
): { args: string[]; url: string } {
const args = [...argv];
const url = normalizeHttpProbeUrl(args.pop());
const parsedUrl = new URL(url);
let sawResolve = false;
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
const { option, inlineValue } = splitCurlOptionArg(arg);
Expand Down Expand Up @@ -212,6 +278,16 @@ export function validateCurlProbeArgs(
if (inlineValue === undefined) index += 1;
continue;
}
if (option === "--resolve") {
if (sawResolve) {
throw new Error("curl probe accepts only one --resolve mapping per transfer");
}
const value = getCurlOptionValue(args, index, option, inlineValue);
assertResolveMatchesApprovedEndpoint(value, parsedUrl, opts);
sawResolve = true;
if (inlineValue === undefined) index += 1;
continue;
}
if (CURL_SAFE_VALUE_OPTIONS.has(option)) {
getCurlOptionValue(args, index, option, inlineValue);
if (inlineValue === undefined) index += 1;
Expand Down
Loading
Loading