diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index 89bc3f368cc..afb144f068d 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -272,6 +272,12 @@ 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} \ @@ -279,6 +285,7 @@ ENV NEMOCLAW_MODEL=${NEMOCLAW_MODEL} \ 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} \ diff --git a/agents/hermes/config/build-env.ts b/agents/hermes/config/build-env.ts index 088a0012548..0128be351c7 100644 --- a/agents/hermes/config/build-env.ts +++ b/agents/hermes/config/build-env.ts @@ -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<{ @@ -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), @@ -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; diff --git a/agents/hermes/config/hermes-config.ts b/agents/hermes/config/hermes-config.ts index f5c12a90073..4bf2cc30564 100644 --- a/agents/hermes/config/hermes-config.ts +++ b/agents/hermes/config/hermes-config.ts @@ -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 diff --git a/agents/hermes/runtime-config-guard.py b/agents/hermes/runtime-config-guard.py index a6cc4d1370c..62669cbe7cd 100755 --- a/agents/hermes/runtime-config-guard.py +++ b/agents/hermes/runtime-config-guard.py @@ -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, @@ -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) diff --git a/ci/test-file-size-budget.json b/ci/test-file-size-budget.json index 162942308f6..8c05e23c8f3 100644 --- a/ci/test-file-size-budget.json +++ b/ci/test-file-size-budget.json @@ -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 } diff --git a/docs/inference/switch-inference-providers.mdx b/docs/inference/switch-inference-providers.mdx index 9410a71cbd4..ed03a87b638 100644 --- a/docs/inference/switch-inference-providers.mdx +++ b/docs/inference/switch-inference-providers.mdx @@ -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` | @@ -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. diff --git a/src/lib/adapters/http/curl-args.test.ts b/src/lib/adapters/http/curl-args.test.ts index 861795a4881..e3ad87a0a86 100644 --- a/src/lib/adapters/http/curl-args.test.ts +++ b/src/lib/adapters/http/curl-args.test.ts @@ -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/); + }); }); diff --git a/src/lib/adapters/http/curl-args.ts b/src/lib/adapters/http/curl-args.ts index 9ab69d40d25..8260534117f 100644 --- a/src/lib/adapters/http/curl-args.ts +++ b/src/lib/adapters/http/curl-args.ts @@ -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"; @@ -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"]); @@ -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); @@ -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; diff --git a/src/lib/adapters/http/probe.test.ts b/src/lib/adapters/http/probe.test.ts index 5fa66f75e98..65278926059 100644 --- a/src/lib/adapters/http/probe.test.ts +++ b/src/lib/adapters/http/probe.test.ts @@ -459,6 +459,94 @@ describe("http-probe helpers", () => { expect(spawnedEnv?.MY_SECRET_TOKEN).toBeUndefined(); }); + it("bypasses ambient proxies when --resolve pins the validated origin (#6293)", () => { + let spawnedEnv: NodeJS.ProcessEnv | undefined; + runCurlProbe( + ["-sS", "--resolve", "example.test:443:93.184.216.34", "https://example.test/models"], + { + pinnedAddresses: ["93.184.216.34"], + replaceEnv: true, + env: { + PATH: "/usr/bin", + HTTP_PROXY: "http://proxy.internal:3128", + HTTPS_PROXY: "http://proxy.internal:3128", + ALL_PROXY: "socks5://proxy.internal:1080", + http_proxy: "http://proxy.internal:3128", + https_proxy: "http://proxy.internal:3128", + all_proxy: "socks5://proxy.internal:1080", + }, + spawnSyncImpl: (_command, _args, options) => { + spawnedEnv = options.env as NodeJS.ProcessEnv; + return { + pid: 1, + output: [], + stdout: "200", + stderr: "", + status: 0, + signal: null, + }; + }, + }, + ); + + for (const name of [ + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + "http_proxy", + "https_proxy", + "all_proxy", + ]) { + expect(spawnedEnv?.[name]).toBeUndefined(); + } + expect(spawnedEnv?.NO_PROXY).toBe("*"); + expect(spawnedEnv?.no_proxy).toBe("*"); + }); + + it.each([ + "http://127.0.0.1:8000/v1/models", + "https://inference.local/v1/models", + "https://93.184.216.34/v1/models", + ])("bypasses ambient proxies for approved no-pin origin %s (#6293)", (url) => { + let spawnedEnv: NodeJS.ProcessEnv | undefined; + runCurlProbe(["-sS", url], { + pinnedAddresses: [], + replaceEnv: true, + env: { + HTTP_PROXY: "http://proxy.internal:3128", + HTTPS_PROXY: "http://proxy.internal:3128", + ALL_PROXY: "socks5://proxy.internal:1080", + http_proxy: "http://proxy.internal:3128", + https_proxy: "http://proxy.internal:3128", + all_proxy: "socks5://proxy.internal:1080", + }, + spawnSyncImpl: (_command, _args, options) => { + spawnedEnv = options.env as NodeJS.ProcessEnv; + return { + pid: 1, + output: [], + stdout: "200", + stderr: "", + status: 0, + signal: null, + }; + }, + }); + + for (const name of [ + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + "http_proxy", + "https_proxy", + "all_proxy", + ]) { + expect(spawnedEnv?.[name]).toBeUndefined(); + } + expect(spawnedEnv?.NO_PROXY).toBe("*"); + expect(spawnedEnv?.no_proxy).toBe("*"); + }); + it("scrubs credential-shaped env even when trustedConfigFiles is not supplied", () => { const original = { MY_PROBE_SECRET_TOKEN: process.env.MY_PROBE_SECRET_TOKEN, diff --git a/src/lib/adapters/http/probe.ts b/src/lib/adapters/http/probe.ts index 9e197a3b321..3c99bafa993 100644 --- a/src/lib/adapters/http/probe.ts +++ b/src/lib/adapters/http/probe.ts @@ -26,6 +26,12 @@ export interface CurlProbeOptions { timeoutMs?: number; /** Absolute or cwd-relative curl config files created by trusted NemoClaw callers. */ trustedConfigFiles?: readonly string[]; + /** + * Connection capability returned by the endpoint SSRF preflight. A defined + * value, including `[]` for an approved no-DNS origin, requires direct + * connection with ambient proxies disabled. + */ + pinnedAddresses?: readonly string[]; spawnSyncImpl?: ( command: string, args: readonly string[], @@ -42,9 +48,34 @@ export interface StreamingProbeResult { const DEFAULT_CURL_PROCESS_TIMEOUT_MS = 30_000; const CURL_PROCESS_TIMEOUT_SLACK_MS = 5_000; -function resolveCurlProbeSpawnEnv(opts: CurlProbeOptions): NodeJS.ProcessEnv { - if (opts.replaceEnv) return scrubCredentialEnv(opts.env ?? {}); - return buildScrubbedCurlProbeEnv(opts.env ?? {}); +function resolveCurlProbeSpawnEnv( + args: readonly string[], + opts: CurlProbeOptions, +): NodeJS.ProcessEnv { + const env = opts.replaceEnv + ? scrubCredentialEnv(opts.env ?? {}) + : buildScrubbedCurlProbeEnv(opts.env ?? {}); + const hasPreflightCapability = opts.pinnedAddresses !== undefined; + const hasResolvePin = args.some((arg) => arg === "--resolve" || arg.startsWith("--resolve=")); + if (!hasPreflightCapability && !hasResolvePin) return env; + + // A proxy defeats the preflight trust boundary: curl sends CONNECT host:port + // and delegates origin selection (and DNS for names) to the proxy. Every + // preflight-approved probe therefore bypasses all proxy env spellings, + // including approved no-pin loopback, managed-alias, and IP-literal origins. + for (const name of [ + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + "http_proxy", + "https_proxy", + "all_proxy", + ]) { + delete env[name]; + } + env.NO_PROXY = "*"; + env.no_proxy = "*"; + return env; } function validateTempPrefix(prefix: string): string { @@ -236,7 +267,7 @@ function runCurlProbeImpl(argv: string[], opts: CurlProbeOptions = {}): CurlProb cwd: opts.cwd ?? ROOT, encoding: "utf8", timeout, - env: resolveCurlProbeSpawnEnv(opts), + env: resolveCurlProbeSpawnEnv(args, opts), }, ); const body = fs.existsSync(bodyFile) ? fs.readFileSync(bodyFile, "utf8") : ""; @@ -345,7 +376,7 @@ function runChatCompletionsStreamingProbeImpl( cwd: opts.cwd ?? ROOT, encoding: "utf8", timeout, - env: resolveCurlProbeSpawnEnv(opts), + env: resolveCurlProbeSpawnEnv(args, opts), }, ); @@ -487,7 +518,7 @@ function captureSseEventCounts( cwd: opts.cwd ?? ROOT, encoding: "utf8", timeout, - env: resolveCurlProbeSpawnEnv(opts), + env: resolveCurlProbeSpawnEnv(args, opts), }, ); diff --git a/src/lib/inference/bedrock-runtime.test.ts b/src/lib/inference/bedrock-runtime.test.ts index 8792f5ee86a..a4700439b04 100644 --- a/src/lib/inference/bedrock-runtime.test.ts +++ b/src/lib/inference/bedrock-runtime.test.ts @@ -37,4 +37,17 @@ describe("Bedrock Runtime endpoint classification", () => { expect(isBedrockRuntimeEndpoint("https://proxy.example.com/v1/messages")).toBe(false); expect(isBedrockRuntimeEndpoint("https://api.anthropic.com/v1/messages")).toBe(false); }); + + it("requires the canonical authenticated TLS boundary for Bedrock Runtime", () => { + expect(isBedrockRuntimeEndpoint("https://bedrock-runtime.us-east-1.amazonaws.com:443")).toBe( + true, + ); + expect(isBedrockRuntimeEndpoint("http://bedrock-runtime.us-east-1.amazonaws.com")).toBe(false); + expect(isBedrockRuntimeEndpoint("https://bedrock-runtime.us-east-1.amazonaws.com:18147")).toBe( + false, + ); + expect(isBedrockRuntimeEndpoint("https://user@bedrock-runtime.us-east-1.amazonaws.com")).toBe( + false, + ); + }); }); diff --git a/src/lib/inference/bedrock-runtime.ts b/src/lib/inference/bedrock-runtime.ts index 6dd9f25567a..e7d2e511098 100644 --- a/src/lib/inference/bedrock-runtime.ts +++ b/src/lib/inference/bedrock-runtime.ts @@ -61,6 +61,7 @@ function classifyBedrockRuntimeHostname( export function classifyCustomAnthropicEndpoint( value: string | URL | null | undefined, ): CustomAnthropicEndpointClassification { + const original = parseEndpointUrl(value); const normalized = normalizeProviderBaseUrl(value, "anthropic"); const parsed = parseEndpointUrl(normalized); if (!parsed) { @@ -72,7 +73,17 @@ export function classifyCustomAnthropicEndpoint( } const bedrock = classifyBedrockRuntimeHostname(parsed.hostname); - if (!bedrock) { + // The dedicated adapter relies on normal TLS hostname verification rather + // than curl --resolve pinning. Only the canonical HTTPS origin is therefore + // eligible: a rebound address cannot receive credentials without presenting + // a certificate valid for the AWS-owned hostname. Plain HTTP, custom ports, + // and URLs carrying userinfo stay on the generic preflighted path. + const isCanonicalTlsOrigin = + original?.protocol === "https:" && + original.port === "" && + original.username === "" && + original.password === ""; + if (!bedrock || !isCanonicalTlsOrigin) { return { kind: "anthropic-messages", endpointUrl: normalized, diff --git a/src/lib/inference/compatible-endpoint-context.test.ts b/src/lib/inference/compatible-endpoint-context.test.ts new file mode 100644 index 00000000000..49ced977340 --- /dev/null +++ b/src/lib/inference/compatible-endpoint-context.test.ts @@ -0,0 +1,294 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + applyCompatibleEndpointContextWindow, + clearAutoDetectedCompatibleContextWindow, + resetCompatibleEndpointContextWindowAutoState, +} from "./compatible-endpoint-context"; + +beforeEach(() => { + resetCompatibleEndpointContextWindowAutoState(); +}); + +async function apply( + options: Parameters[2], + env: NodeJS.ProcessEnv = {}, +): Promise<{ env: NodeJS.ProcessEnv; messages: string[] }> { + const messages: string[] = []; + await applyCompatibleEndpointContextWindow("https://endpoint.example/v1", "model-a", { + env, + logger: { + log: (message: string) => messages.push(message), + warn: (message: string) => messages.push(message), + }, + // Inject a clearly-public resolver so the unconditional DNS SSRF preflight + // passes for the endpoint.example host these cases probe (#6293). Cases that + // exercise SSRF refusal use inline calls with a private-address resolver. + resolveHost: async () => [{ address: "93.184.216.34", family: 4 }], + ...options, + }); + return { env, messages }; +} + +describe("compatible-endpoint context window", () => { + it("bakes the endpoint's max_model_len into NEMOCLAW_CONTEXT_WINDOW (#6177)", async () => { + const fetchModels = vi.fn(() => ({ data: [{ id: "model-a", max_model_len: 65_536 }] })); + const { env, messages } = await apply({ fetchModels }); + + expect(env.NEMOCLAW_CONTEXT_WINDOW).toBe("65536"); + expect(fetchModels).toHaveBeenCalledWith("https://endpoint.example/v1", "", ["93.184.216.34"]); + expect(messages.some((m) => m.includes("65536"))).toBe(true); + }); + + it("resolves the API key from the credential env for the probe", async () => { + const fetchModels = vi.fn(() => ({ data: [{ id: "model-a", max_model_len: 32_768 }] })); + await apply({ + fetchModels, + credentialEnv: "COMPATIBLE_API_KEY", + resolveCredential: (name) => (name === "COMPATIBLE_API_KEY" ? "secret-key" : null), + }); + + expect(fetchModels).toHaveBeenCalledWith("https://endpoint.example/v1", "secret-key", [ + "93.184.216.34", + ]); + }); + + it("picks the exact model entry from a multi-model gateway response (#6177)", async () => { + const fetchModels = vi.fn(() => ({ + data: [ + { id: "other-model", max_model_len: 8_192 }, + { id: "model-a", max_model_len: 65_536 }, + ], + })); + const { env } = await apply({ fetchModels }); + + expect(env.NEMOCLAW_CONTEXT_WINDOW).toBe("65536"); + }); + + it("does not guess a context window from a multi-model gateway with no exact match (#6177)", async () => { + const fetchModels = vi.fn(() => ({ + data: [ + { id: "other-a", max_model_len: 8_192 }, + { id: "other-b", max_model_len: 16_384 }, + ], + })); + const { env, messages } = await apply({ fetchModels }); + + expect(env.NEMOCLAW_CONTEXT_WINDOW).toBeUndefined(); + expect(messages.some((m) => m.includes("none match 'model-a'"))).toBe(true); + }); + + it("uses the sole served model even when its id does not match (single-model endpoint)", async () => { + const fetchModels = vi.fn(() => ({ data: [{ id: "served-alias", max_model_len: 32_768 }] })); + const { env } = await apply({ fetchModels }); + + expect(env.NEMOCLAW_CONTEXT_WINDOW).toBe("32768"); + }); + + it("skips the probe for a sandbox-internal endpoint and leaves auto-detect (#6177)", async () => { + const fetchModels = vi.fn(() => ({ data: [{ id: "model-a", max_model_len: 65_536 }] })); + const messages: string[] = []; + const env: NodeJS.ProcessEnv = {}; + await applyCompatibleEndpointContextWindow("https://host.openshell.internal/v1", "model-a", { + env, + fetchModels, + logger: { log: (m) => messages.push(m), warn: (m) => messages.push(m) }, + }); + + expect(fetchModels).not.toHaveBeenCalled(); + expect(env.NEMOCLAW_CONTEXT_WINDOW).toBeUndefined(); + expect(messages).toEqual([]); + }); + + it("never downgrades an explicit NEMOCLAW_CONTEXT_WINDOW override (#6177)", async () => { + const fetchModels = vi.fn(() => ({ data: [{ id: "model-a", max_model_len: 8_192 }] })); + const { env, messages } = await apply({ fetchModels }, { NEMOCLAW_CONTEXT_WINDOW: "65536" }); + + expect(env.NEMOCLAW_CONTEXT_WINDOW).toBe("65536"); + expect(fetchModels).not.toHaveBeenCalled(); + expect(messages.some((m) => m.includes("Keeping configured context window"))).toBe(true); + }); + + it.each([ + "0", + "abc", + "-5", + "9999999999", + ])("ignores the invalid NEMOCLAW_CONTEXT_WINDOW override %j and auto-detects instead (#6293)", async (badValue) => { + const fetchModels = vi.fn(() => ({ data: [{ id: "model-a", max_model_len: 32_768 }] })); + const { env, messages } = await apply({ fetchModels }, { NEMOCLAW_CONTEXT_WINDOW: badValue }); + + expect(fetchModels).toHaveBeenCalled(); + expect(env.NEMOCLAW_CONTEXT_WINDOW).toBe("32768"); + expect(messages.some((m) => m.includes("Ignoring invalid NEMOCLAW_CONTEXT_WINDOW"))).toBe(true); + }); + + it("clears an invalid explicit override when the endpoint also cannot be probed (#6293)", async () => { + const fetchModels = vi.fn(() => null); + const { env } = await apply({ fetchModels }, { NEMOCLAW_CONTEXT_WINDOW: "0" }); + + expect(fetchModels).toHaveBeenCalled(); + expect(env.NEMOCLAW_CONTEXT_WINDOW).toBeUndefined(); + }); + + it("warns and keeps the default context window when the endpoint cannot be probed", async () => { + const fetchModels = vi.fn(() => null); + const { env, messages } = await apply({ fetchModels }); + + expect(env.NEMOCLAW_CONTEXT_WINDOW).toBeUndefined(); + expect(messages.some((m) => m.includes("Could not read the endpoint's /v1/models"))).toBe(true); + }); + + it("clears its own stale auto value when a re-probed endpoint reports nothing (#6177)", async () => { + // First endpoint auto-detects 65536 into the shared env. + const env: NodeJS.ProcessEnv = {}; + await apply({ fetchModels: () => ({ data: [{ id: "model-a", max_model_len: 65_536 }] }) }, env); + expect(env.NEMOCLAW_CONTEXT_WINDOW).toBe("65536"); + + // A later selection pass probes an endpoint that reports no max_model_len: + // the stale auto value must not survive (would look like a user override). + await apply({ fetchModels: () => ({ data: [{ id: "model-a" }] }) }, env); + expect(env.NEMOCLAW_CONTEXT_WINDOW).toBeUndefined(); + }); + + it("recomputes over its own prior auto value on a re-probe (#6177)", async () => { + const env: NodeJS.ProcessEnv = {}; + await apply({ fetchModels: () => ({ data: [{ id: "model-a", max_model_len: 65_536 }] }) }, env); + await apply({ fetchModels: () => ({ data: [{ id: "model-a", max_model_len: 16_384 }] }) }, env); + expect(env.NEMOCLAW_CONTEXT_WINDOW).toBe("16384"); + }); + + it("keeps a genuine user override even after a prior auto value was recorded (#6177)", async () => { + const env: NodeJS.ProcessEnv = {}; + await apply({ fetchModels: () => ({ data: [{ id: "model-a", max_model_len: 65_536 }] }) }, env); + // User pins a different value; a later probe must not overwrite it. + env.NEMOCLAW_CONTEXT_WINDOW = "200000"; + const { messages } = await apply( + { fetchModels: () => ({ data: [{ id: "model-a", max_model_len: 16_384 }] }) }, + env, + ); + expect(env.NEMOCLAW_CONTEXT_WINDOW).toBe("200000"); + expect(messages.some((m) => m.includes("Keeping configured context window"))).toBe(true); + }); + + it("does not crash on a malformed /v1/models body from an arbitrary endpoint (#6177)", async () => { + const env: NodeJS.ProcessEnv = {}; + // apply resolves (never throws) even on a malformed body; reaching the + // assertion proves it did not crash. + await apply({ fetchModels: () => ({ data: [null, "nope", 42] }) as unknown as object }, env); + expect(env.NEMOCLAW_CONTEXT_WINDOW).toBeUndefined(); + }); + + it.each([ + "http://10.0.0.1/v1", + "http://169.254.169.254/v1", + "http://172.16.0.1/v1", + "http://192.168.1.1/v1", + ])("rejects the non-loopback private-IP endpoint %s before probing /v1/models SSRF (#6293)", async (endpointUrl) => { + const fetchModels = vi.fn(() => ({ data: [{ id: "model-a", max_model_len: 65_536 }] })); + const messages: string[] = []; + const env: NodeJS.ProcessEnv = {}; + await applyCompatibleEndpointContextWindow(endpointUrl, "model-a", { + env, + fetchModels, + logger: { log: (m) => messages.push(m), warn: (m) => messages.push(m) }, + }); + + expect(fetchModels).not.toHaveBeenCalled(); + expect(env.NEMOCLAW_CONTEXT_WINDOW).toBeUndefined(); + expect(messages.some((m) => m.includes("private/internal address"))).toBe(true); + }); + + it.each([ + "http://127.0.0.1:8000/v1", + "http://localhost:8000/v1", + "http://[::1]:8000/v1", + ])("probes a loopback endpoint %s and propagates its max_model_len (#6293)", async (endpointUrl) => { + const fetchModels = vi.fn(() => ({ data: [{ id: "model-a", max_model_len: 65_536 }] })); + const env: NodeJS.ProcessEnv = {}; + await applyCompatibleEndpointContextWindow(endpointUrl, "model-a", { + env, + fetchModels, + logger: { log: () => undefined, warn: () => undefined }, + }); + + expect(fetchModels).toHaveBeenCalled(); + expect(env.NEMOCLAW_CONTEXT_WINDOW).toBe("65536"); + }); + + it.each([ + "10.0.0.8", + "169.254.169.254", + ])("refuses the /v1/models probe when a public host resolves to private %s via the DNS preflight (#6293)", async (privateAddress) => { + const fetchModels = vi.fn(() => ({ data: [{ id: "model-a", max_model_len: 65_536 }] })); + const messages: string[] = []; + const env: NodeJS.ProcessEnv = {}; + await applyCompatibleEndpointContextWindow("https://public-name.example/v1", "model-a", { + env, + fetchModels, + resolveHost: async () => [{ address: privateAddress, family: 4 }], + logger: { log: (m) => messages.push(m), warn: (m) => messages.push(m) }, + }); + + expect(fetchModels).not.toHaveBeenCalled(); + expect(env.NEMOCLAW_CONTEXT_WINDOW).toBeUndefined(); + expect(messages.some((m) => m.includes(privateAddress))).toBe(true); + }); + + it("probes when the injected resolver returns a public address (#6293)", async () => { + const fetchModels = vi.fn(() => ({ data: [{ id: "model-a", max_model_len: 65_536 }] })); + const env: NodeJS.ProcessEnv = {}; + await applyCompatibleEndpointContextWindow("https://public-name.example/v1", "model-a", { + env, + fetchModels, + resolveHost: async () => [{ address: "93.184.216.34", family: 4 }], + logger: { log: () => undefined, warn: () => undefined }, + }); + + expect(fetchModels).toHaveBeenCalled(); + expect(env.NEMOCLAW_CONTEXT_WINDOW).toBe("65536"); + }); + + it("clears a stale auto value when re-probing a now private-IP endpoint SSRF (#6293)", async () => { + const env: NodeJS.ProcessEnv = {}; + // First endpoint auto-detects a window into the shared env. + await applyCompatibleEndpointContextWindow("https://public.example/v1", "model-a", { + env, + fetchModels: () => ({ data: [{ id: "model-a", max_model_len: 65_536 }] }), + resolveHost: async () => [{ address: "93.184.216.34", family: 4 }], + logger: { log: () => undefined, warn: () => undefined }, + }); + expect(env.NEMOCLAW_CONTEXT_WINDOW).toBe("65536"); + + // A later pass selects a private-IP endpoint: the probe must be refused and + // the stale auto value dropped rather than left as a phantom user override. + const fetchModels = vi.fn(() => ({ data: [{ id: "model-a", max_model_len: 8_192 }] })); + await applyCompatibleEndpointContextWindow("http://10.0.0.1/v1", "model-a", { + env, + fetchModels, + logger: { log: () => undefined, warn: () => undefined }, + }); + expect(fetchModels).not.toHaveBeenCalled(); + expect(env.NEMOCLAW_CONTEXT_WINDOW).toBeUndefined(); + }); + + it("clearAutoDetectedCompatibleContextWindow drops a stale auto value but keeps a user override (#6177)", async () => { + // Auto-detected value is cleared when retrying away to another provider. + const autoEnv: NodeJS.ProcessEnv = {}; + await apply( + { fetchModels: () => ({ data: [{ id: "model-a", max_model_len: 65_536 }] }) }, + autoEnv, + ); + expect(autoEnv.NEMOCLAW_CONTEXT_WINDOW).toBe("65536"); + clearAutoDetectedCompatibleContextWindow(autoEnv); + expect(autoEnv.NEMOCLAW_CONTEXT_WINDOW).toBeUndefined(); + + // A user-supplied value this probe never wrote survives the clear. + const userEnv: NodeJS.ProcessEnv = { NEMOCLAW_CONTEXT_WINDOW: "200000" }; + clearAutoDetectedCompatibleContextWindow(userEnv); + expect(userEnv.NEMOCLAW_CONTEXT_WINDOW).toBe("200000"); + }); +}); diff --git a/src/lib/inference/compatible-endpoint-context.ts b/src/lib/inference/compatible-endpoint-context.ts new file mode 100644 index 00000000000..e680f343501 --- /dev/null +++ b/src/lib/inference/compatible-endpoint-context.ts @@ -0,0 +1,328 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { createOpenAiLikeAuthConfig } from "../adapters/http/auth-config"; +import { runCurlProbe } from "../adapters/http/probe"; +import { getCredential } from "../credentials/store"; +import { isLoopbackHostname, isPrivateHostname } from "../private-networks"; +import { + assertEndpointResolvesPublic, + buildResolvePinArgs, + type EndpointDnsLookupFn, +} from "./endpoint-ssrf-preflight"; +import { + hasExplicitContextWindow, + MAX_AUTODETECTED_OLLAMA_CONTEXT_WINDOW, + parsePositiveInteger, +} from "./ollama-runtime-context"; +import { resolveVllmContextWindowFromModels } from "./vllm-runtime-context"; + +// Explicit NEMOCLAW_CONTEXT_WINDOW overrides share the auto-detect ceiling so a +// user-supplied window can't bake an implausible value the probed path rejects. +const MAX_COMPATIBLE_CONTEXT_WINDOW = MAX_AUTODETECTED_OLLAMA_CONTEXT_WINDOW; + +// Hosts that only resolve inside the OpenShell sandbox network (or are the +// hijacked docker-internal alias), mirroring probeOpenAiLikeEndpoint's +// SANDBOX_INTERNAL_HOSTS / isHijackedDockerInternalUrl. A host-side GET to these +// cannot reach the real endpoint, so we skip the probe rather than emit a +// misleading warning and let Hermes auto-detect / an explicit override stand. +const NON_HOST_PROBEABLE_HOSTS = new Set(["host.openshell.internal", "host.docker.internal"]); + +function isHostProbeableEndpoint(endpointUrl: string): boolean { + try { + return !NON_HOST_PROBEABLE_HOSTS.has(new URL(endpointUrl).hostname); + } catch { + return false; + } +} + +// SSRF source boundary: true when the endpoint host is a private/internal +// address (and not one of the allowed sandbox-internal aliases, which are +// screened separately by isHostProbeableEndpoint). Reuses the shared +// isPrivateHostname validator so the /v1/models context probe never issues a +// host-side GET to an attacker-reachable private address. +// +// Loopback (127.0.0.0/8, ::1, localhost) is exempt, mirroring the same +// exemption in probeOpenAiLikeEndpoint: a locally-run vLLM/Ollama custom +// endpoint is reached host-side on loopback, and loopback only targets the +// probing host itself, not a pivot to other internal infrastructure. Without +// this the two probes disagreed — the chat-completions validation probe +// allowed the localhost endpoint but this context probe skipped it, so a +// localhost vLLM never propagated its max_model_len (#6177). See PR #6293 +// PRA-5 / PRA-19. +function isPrivateEndpoint(endpointUrl: string): boolean { + try { + const { hostname } = new URL(endpointUrl); + return isPrivateHostname(hostname) && !isLoopbackHostname(hostname); + } catch { + return false; + } +} + +/** Injectable `/v1/models` fetcher; returns parsed JSON, or null when unavailable. */ +export type CompatibleEndpointModelsFetcher = ( + endpointUrl: string, + apiKey: string, + /** + * SSRF-preflight-validated address(es) to pin the fetch curl to via + * `--resolve` (TOCTOU/DNS-rebinding defense, #6293). Optional so injected test + * fakes can ignore it. + */ + pinnedAddresses?: string[], +) => unknown | null; + +export interface ApplyCompatibleEndpointContextWindowOptions { + env?: NodeJS.ProcessEnv; + logger?: Pick; + /** Credential env used to authenticate the `/v1/models` probe. */ + credentialEnv?: string | null; + /** Already-resolved API key; takes precedence over `credentialEnv`. */ + apiKey?: string | null; + /** Override the default host curl fetch (unit tests inject a fake). */ + fetchModels?: CompatibleEndpointModelsFetcher; + /** Override credential resolution (unit tests inject a fake). */ + resolveCredential?: (credentialEnv: string) => string | null | undefined; + /** + * Injectable DNS resolver for the SSRF preflight run before the host-side + * `/v1/models` curl. When omitted under the unit-test runner the DNS + * preflight is skipped (the string-level private check still applies); + * production uses the real `dns/promises` resolver. + */ + resolveHost?: EndpointDnsLookupFn; +} + +/** + * GET `/models` on the host and return the parsed JSON body, or null + * when the endpoint is unreachable, errors, or returns a non-JSON body. This is + * the same source vLLM local onboarding reads, generalized to any configured + * OpenAI-compatible endpoint (custom / `compatible-endpoint`). Auth is sent when + * the endpoint requires an API key (e.g. a vLLM launched with `--api-key`). + * + * Security: this runs host-side during privileged onboarding, before the + * sandbox and its OpenShell network policy exist, and targets the same endpoint + * URL the immediately-preceding chat-completions validation probe already + * reached — so it adds no egress surface beyond that validation. The credential + * travels in a curl `--config` temp file (0600), never on the argv. + * + * SSRF: private/internal endpoints are rejected by the caller + * (`applyCompatibleEndpointContextWindow`) at the source boundary, before this + * fetch runs, using the shared `isPrivateHostname` validator — the same guard + * `probeOpenAiLikeEndpoint` applies. A self-hosted vLLM is reached through the + * sandbox-internal alias (`host.openshell.internal`), which is skipped + * separately, not via a raw private-LAN URL. This fetcher therefore only ever + * sees an already-validated, routable endpoint URL. + */ +export function fetchCompatibleEndpointModels( + endpointUrl: string, + apiKey: string, + pinnedAddresses?: string[], +): unknown | null { + const baseUrl = String(endpointUrl).replace(/\/+$/, ""); + const authConfig = createOpenAiLikeAuthConfig(apiKey || ""); + try { + const result = runCurlProbe( + [ + "-sS", + ...buildResolvePinArgs(`${baseUrl}/models`, pinnedAddresses), + "--connect-timeout", + "10", + "--max-time", + "15", + ...authConfig.args, + `${baseUrl}/models`, + ], + { trustedConfigFiles: authConfig.trustedConfigFiles, pinnedAddresses }, + ); + if (!result.ok || !result.body) return null; + try { + return JSON.parse(result.body); + } catch { + return null; + } + } finally { + authConfig.cleanup(); + } +} + +// The value this probe last auto-detected. onboard can re-run provider +// selection (e.g. after a failed `inference set` the user picks a different +// endpoint/model), so a value we set on an earlier pass must not be mistaken +// for a user override on the next — otherwise a stale window from endpoint A +// would be kept for endpoint B. Mirrors the Ollama auto-state contract. +// TODO(#6177): this auto-state tracking mirrors the Ollama contract +// (autoDetectedOllamaContextWindow in ollama-runtime-context.ts). If a third +// provider adopts the same "auto-detected vs user override" pattern, extract a +// shared trackAutoDetectedContextWindow helper instead of duplicating it again. +let autoDetectedCompatibleContextWindow: string | null = null; + +/** Test-only: forget any tracked auto value without touching the environment. */ +export function resetCompatibleEndpointContextWindowAutoState(): void { + autoDetectedCompatibleContextWindow = null; +} + +/** + * Drop a value this probe auto-detected on an earlier pass. onboard calls this + * before each provider-selection pass so that when the user retries away to a + * different provider, endpoint A's probed `max_model_len` is not left in the + * environment where `dockerfile-patch` would bake it as if the user had set it. + * A genuine user-supplied `NEMOCLAW_CONTEXT_WINDOW` (one this probe never wrote) + * is preserved because it never equals the tracked auto value (#6177). + */ +export function clearAutoDetectedCompatibleContextWindow( + env: NodeJS.ProcessEnv = process.env, +): void { + if ( + autoDetectedCompatibleContextWindow && + env.NEMOCLAW_CONTEXT_WINDOW === autoDetectedCompatibleContextWindow + ) { + delete env.NEMOCLAW_CONTEXT_WINDOW; + } + autoDetectedCompatibleContextWindow = null; +} + +/** + * Set `NEMOCLAW_CONTEXT_WINDOW` from a configured OpenAI-compatible endpoint's + * runtime `max_model_len` so custom / `compatible-endpoint` onboarding no longer + * falls back to a small architecture-default context (see #6177). + * + * - An explicit `NEMOCLAW_CONTEXT_WINDOW` always wins and is never downgraded. + * - A value this probe set on an earlier pass is not treated as an override; it + * is recomputed, or cleared when the new endpoint reports nothing usable. + * - A sandbox-internal / docker-internal endpoint URL is not host-probeable, so + * the probe is skipped and Hermes auto-detect is left in place. + * - When the endpoint cannot be probed, warn and keep the default context. + * - Under the unit-test runner the default curl fetch is skipped (endpoints are + * unreachable and curl would hang on DNS); pass `fetchModels` to exercise it. + * + * Source boundary: `/v1/models` is served by an out-of-repo endpoint the user + * configured. Invalid states tolerated — unreachable/timing-out host, non-JSON + * or non-vLLM body, missing/malformed/over-ceiling `max_model_len`, and + * ambiguous multi-model catalogs — all fall back to Hermes/OpenClaw auto-detect + * (never throw, never guess). NemoClaw cannot fix the producer, so it validates + * before consuming. Regression coverage: compatible-endpoint-context.test.ts and + * the real-server compatible-endpoint-context-probe.test.ts. Remove this probe + * only if a typed, validated cross-provider model-catalog fetch subsumes it. + */ +export async function applyCompatibleEndpointContextWindow( + endpointUrl: string, + model: string | null | undefined, + options: ApplyCompatibleEndpointContextWindowOptions = {}, +): Promise { + const env = options.env ?? process.env; + const logger = options.logger ?? console; + + const currentContextWindow = env.NEMOCLAW_CONTEXT_WINDOW; + const currentIsPreviousAuto = + !!currentContextWindow && + !!autoDetectedCompatibleContextWindow && + currentContextWindow === autoDetectedCompatibleContextWindow; + const userContextWindow = currentIsPreviousAuto ? null : currentContextWindow; + + const clearPreviousAuto = (): void => { + if (currentIsPreviousAuto) { + delete env.NEMOCLAW_CONTEXT_WINDOW; + autoDetectedCompatibleContextWindow = null; + } + }; + + if (hasExplicitContextWindow(userContextWindow)) { + // hasExplicitContextWindow only checks non-emptiness, so a malformed value + // ("0", "abc", or one above the auto-detect ceiling) would otherwise be + // kept verbatim and baked into config. Validate it the same way the probed + // path validates a discovered max_model_len; on an invalid override, warn + // and fall through to auto-detect instead of honoring an unusable value. + // See PR #6293 PRA-4 / PRA-7 (Nemotron). + const parsedOverride = parsePositiveInteger(userContextWindow); + if (parsedOverride && parsedOverride <= MAX_COMPATIBLE_CONTEXT_WINDOW) { + logger.log(` ℹ Keeping configured context window: ${parsedOverride} tokens`); + return; + } + logger.warn( + ` ⚠ Ignoring invalid NEMOCLAW_CONTEXT_WINDOW="${userContextWindow}"; it must be a ` + + `positive integer ≤ ${MAX_COMPATIBLE_CONTEXT_WINDOW}. Auto-detecting from the endpoint.`, + ); + // Drop the unusable override so a failed/skipped probe can't leave it to be + // baked downstream; auto-detect below re-populates it when the endpoint + // reports a valid max_model_len. + delete env.NEMOCLAW_CONTEXT_WINDOW; + } + + // A sandbox-internal endpoint (e.g. host.openshell.internal) resolves only + // inside the sandbox, so a host-side GET cannot reach it; skip cleanly and + // leave Hermes auto-detect rather than emit a misleading probe failure. + if (!isHostProbeableEndpoint(endpointUrl)) { + clearPreviousAuto(); + return; + } + + // SSRF source boundary: refuse to probe /v1/models on a private/internal + // endpoint. This path issues its own host-side GET independent of the + // chat-completions validation probe, so it must screen the URL itself with + // the shared isPrivateHostname validator (defense-in-depth alongside the + // DNS-pinning config-write boundary). See PR #6293 PRA-1/PRA-3. + if (isPrivateEndpoint(endpointUrl)) { + logger.warn( + " ⚠ Endpoint host is a private/internal address; skipping the /v1/models " + + "context probe. Use a routable public URL to auto-detect the context window.", + ); + clearPreviousAuto(); + return; + } + + const fetchModels = options.fetchModels; + + // DNS-backed SSRF: the string-level isPrivateEndpoint check above only sees + // literal IPs and reserved names. This host-side GET is its own curl boundary + // (independent of the chat-completions validation probe), so always run the + // resolver preflight — a public-looking name that resolves to loopback/ + // link-local/RFC1918 is refused before the fetch. It defaults to the real + // dns/promises resolver (assertEndpointResolvesPublic); tests inject + // options.resolveHost. No env-gated bypass: an ambient VITEST flag must never + // disable SSRF enforcement (cv review, PR #6293). + const preflight = await assertEndpointResolvesPublic(endpointUrl, options.resolveHost); + if (!preflight.ok) { + logger.warn( + ` ⚠ ${preflight.reason}; skipping the /v1/models context probe. ` + + "Use a routable public URL to auto-detect the context window.", + ); + clearPreviousAuto(); + return; + } + + const resolveCredential = options.resolveCredential ?? getCredential; + const apiKey = + options.apiKey ?? (options.credentialEnv ? resolveCredential(options.credentialEnv) : "") ?? ""; + + // Pin the /v1/models fetch to the address(es) the preflight just validated so + // a second DNS lookup in the fetch curl cannot rebind the host to a private + // address after the public preflight passed (TOCTOU — cv review, #6293). + const models = (fetchModels ?? fetchCompatibleEndpointModels)( + endpointUrl, + apiKey, + preflight.addresses, + ); + if (models === null || models === undefined) { + logger.warn( + " ⚠ Could not read the endpoint's /v1/models max_model_len; using the default context " + + "window. Set NEMOCLAW_CONTEXT_WINDOW to override.", + ); + clearPreviousAuto(); + return; + } + + // strictModelMatch: a compatible endpoint can be a shared gateway serving many + // models, so never guess the first entry's max_model_len for a model that is + // not an exact /v1/models id — that would bake an unrelated model's window. + const contextLength = resolveVllmContextWindowFromModels(models, model, logger, { + strictModelMatch: true, + }); + if (contextLength === null) { + clearPreviousAuto(); + return; + } + + const value = String(contextLength); + env.NEMOCLAW_CONTEXT_WINDOW = value; + autoDetectedCompatibleContextWindow = value; + logger.log(` ✓ Using endpoint max_model_len: ${value} tokens`); +} diff --git a/src/lib/inference/endpoint-ssrf-preflight.test.ts b/src/lib/inference/endpoint-ssrf-preflight.test.ts new file mode 100644 index 00000000000..92ced48158d --- /dev/null +++ b/src/lib/inference/endpoint-ssrf-preflight.test.ts @@ -0,0 +1,106 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from "vitest"; + +import { + assertEndpointResolvesPublic, + buildResolvePinArgs, + type EndpointDnsLookupFn, +} from "./endpoint-ssrf-preflight"; + +const resolverTo = (address: string): EndpointDnsLookupFn => + vi.fn(async () => [{ address, family: address.includes(":") ? 6 : 4 }]); + +describe("assertEndpointResolvesPublic (#6293)", () => { + it("allows a public hostname that resolves to a public address without ever needing a private check", async () => { + const lookup = resolverTo("93.184.216.34"); + const result = await assertEndpointResolvesPublic("https://vllm.example/v1", lookup); + expect(result.ok).toBe(true); + expect(lookup).toHaveBeenCalledWith("vllm.example", { all: true }); + }); + + it.each([ + "10.0.0.8", + "169.254.169.254", + "192.168.1.10", + "172.16.0.5", + "127.0.0.1", + ])("refuses a public hostname that resolves to the private/reserved address %s (#6293)", async (privateAddress) => { + const lookup = resolverTo(privateAddress); + const result = await assertEndpointResolvesPublic("https://public-name.example/v1", lookup); + expect(result.ok).toBe(false); + expect(result.reason).toContain(privateAddress); + }); + + it("refuses a literal private endpoint before resolving anything (#6293)", async () => { + const lookup = vi.fn(); + const result = await assertEndpointResolvesPublic("http://10.0.0.1/v1", lookup); + expect(result.ok).toBe(false); + expect(lookup).not.toHaveBeenCalled(); + }); + + it.each([ + "http://127.0.0.1:8000/v1", + "http://localhost:8000/v1", + "http://[::1]:8000/v1", + ])("allows the explicit loopback endpoint %s without resolving (#6293)", async (endpointUrl) => { + const lookup = vi.fn(); + const result = await assertEndpointResolvesPublic(endpointUrl, lookup); + expect(result.ok).toBe(true); + expect(result.addresses).toEqual([]); + expect(lookup).not.toHaveBeenCalled(); + }); + + it("allows a public IP literal without resolving (#6293)", async () => { + const lookup = vi.fn(); + const result = await assertEndpointResolvesPublic("https://93.184.216.34/v1", lookup); + expect(result.ok).toBe(true); + expect(result.addresses).toEqual([]); + expect(lookup).not.toHaveBeenCalled(); + }); + + it("keeps dual-stack addresses in one curl --resolve mapping (#6293)", () => { + expect( + buildResolvePinArgs("https://vllm.example/v1/models", [ + "93.184.216.34", + "2606:2800:220:1:248:1893:25c8:1946", + ]), + ).toEqual(["--resolve", "vllm.example:443:93.184.216.34,[2606:2800:220:1:248:1893:25c8:1946]"]); + }); + + it("fails closed when the resolver throws (#6293)", async () => { + const lookup: EndpointDnsLookupFn = vi.fn(async () => { + throw new Error("ENOTFOUND"); + }); + const result = await assertEndpointResolvesPublic("https://unresolvable.example/v1", lookup); + expect(result.ok).toBe(false); + expect(result.reason).toContain("cannot resolve"); + }); + + it("fails closed when the resolver returns no addresses (#6293)", async () => { + const lookup: EndpointDnsLookupFn = vi.fn(async () => []); + const result = await assertEndpointResolvesPublic("https://empty.example/v1", lookup); + expect(result.ok).toBe(false); + }); + + it("refuses a malformed endpoint URL (#6293)", async () => { + const result = await assertEndpointResolvesPublic("not a url", resolverTo("93.184.216.34")); + expect(result.ok).toBe(false); + }); + + it.each([ + "https://inference.local/v1", + "http://host.openshell.internal:8000/v1", + "http://host.docker.internal:11434/v1", + "http://host.containers.internal:11434/v1", + ])("exempts the OpenShell-managed alias %s without resolving or pinning (#6293)", async (endpointUrl) => { + const lookup = vi.fn(); + const result = await assertEndpointResolvesPublic(endpointUrl, lookup); + expect(result.ok).toBe(true); + // Managed aliases need no --resolve pin, but the defined empty capability + // still forces credentialed host probes to bypass ambient proxies. + expect(result.addresses).toEqual([]); + expect(lookup).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/inference/endpoint-ssrf-preflight.ts b/src/lib/inference/endpoint-ssrf-preflight.ts new file mode 100644 index 00000000000..0260c4cada3 --- /dev/null +++ b/src/lib/inference/endpoint-ssrf-preflight.ts @@ -0,0 +1,180 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { lookup as dnsLookup } from "node:dns/promises"; +import { isIP } from "node:net"; + +import { isLoopbackHostname, isPrivateHostname, isPrivateIp } from "../private-networks"; + +/** Injectable DNS resolver, shaped like `dns/promises` `lookup(host, {all:true})`. */ +export type EndpointDnsLookupFn = ( + hostname: string, + options: { all: true }, +) => Promise>; + +/** + * NemoClaw's own OpenShell-managed infrastructure hostnames. These resolve to + * the host loopback or the OpenShell L7 proxy *by design* (see + * `subprocess-env` `withLocalNoProxy` and `verify-deployment` for + * `inference.local`), so — unlike an arbitrary user-supplied public name — they + * are trusted aliases, not attacker-controlled names subject to DNS rebinding. + * They are exempt from the public-resolution requirement (like explicit + * loopback) and connect normally without `--resolve` pinning. This mirrors the + * MCP URL-target allowlist (`isOpenShellMcpHostAlias`), additionally covering + * `inference.local` — the managed sandbox inference route a compatible endpoint + * legitimately targets (#6293). + */ +const OPENSHELL_MANAGED_HOSTS = new Set([ + "inference.local", + "host.openshell.internal", + "host.docker.internal", + "host.containers.internal", +]); + +/** True when `hostname` is a NemoClaw OpenShell-managed infrastructure alias. */ +export function isOpenShellManagedHost(hostname: string): boolean { + const normalised = ( + hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname + ) + .replace(/\.$/, "") + .toLowerCase(); + return OPENSHELL_MANAGED_HOSTS.has(normalised); +} + +export interface EndpointSsrfPreflightResult { + ok: boolean; + /** Human-readable reason, present only when `ok === false`. */ + reason?: string; + /** + * Validated public addresses the endpoint host resolved to, for connection + * pinning (curl `--resolve`) so a subsequent probe cannot re-resolve the name + * to a rebound private/internal address (TOCTOU). Present only when + * `ok === true` and pinning applies — resolved public names and public IP + * literals. An empty array is the explicit trusted-no-pin capability for + * loopback, OpenShell-managed aliases, and public IP literals. Callers must + * preserve it so credentialed probes bypass ambient proxies even when no + * curl `--resolve` argument is needed. + */ + addresses?: string[]; +} + +/** + * DNS-backed SSRF preflight for a user-supplied inference endpoint, run before + * any privileged host-side curl during onboarding. + * + * The string-level `isPrivateHostname` guards elsewhere block literal private + * IPs and reserved names, but a public-looking name (`https://vllm.example/v1`) + * can still resolve to `127.0.0.1`, `169.254.169.254`, or RFC1918 space and make + * the onboarding host contact internal services before the sandbox and its + * OpenShell network policy exist. This resolves the hostname first and refuses + * when it — or any resolved address — is private/reserved. It complements the + * authoritative config-write DNS-pinning boundary (`validateUrlValueWithDnsResult`) + * which runs later, before the URL is persisted. + * + * Loopback (127.0.0.0/8, ::1, localhost) is exempt ONLY when the endpoint + * hostname is itself loopback — a locally-run vLLM/Ollama server the user + * explicitly configured. A public name that *resolves* to loopback is treated + * as a rebinding attempt and refused. The resolver is injectable for tests and + * the check fails closed on resolver error or an empty result. + * + * See PR #6293 PRA-4 (GPT-5.5 advisor). + */ +export async function assertEndpointResolvesPublic( + endpointUrl: string, + lookup: EndpointDnsLookupFn = dnsLookup as unknown as EndpointDnsLookupFn, +): Promise { + let hostname: string; + try { + hostname = new URL(String(endpointUrl)).hostname; + } catch { + return { ok: false, reason: `"${String(endpointUrl)}" is not a valid URL` }; + } + + // An explicit loopback host is a legitimate local inference server. + if (isLoopbackHostname(hostname)) return { ok: true, addresses: [] }; + + // NemoClaw's own OpenShell-managed aliases (inference.local, host.*.internal) + // resolve to the managed proxy/loopback by design and are trusted, not + // rebinding surfaces. Exempt like loopback — connect normally (no pinning) — + // and exempt BEFORE isPrivateHostname, which would otherwise reject their + // reserved .local/.internal suffixes (#6293). + if (isOpenShellManagedHost(hostname)) return { ok: true, addresses: [] }; + + // A literal private IP or reserved private name is refused without resolving. + if (isPrivateHostname(hostname)) { + return { ok: false, reason: `endpoint host "${hostname}" is a private/internal address` }; + } + + // A public IP literal needs neither DNS resolution nor connection pinning: + // the URL already contains the address curl will connect to. + const bare = + hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname; + if (isIP(bare)) return { ok: true, addresses: [] }; + + let addresses: Array<{ address: string; family?: number }>; + try { + addresses = await lookup(bare, { all: true }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { ok: false, reason: `cannot resolve endpoint host "${hostname}": ${message}` }; + } + if (!Array.isArray(addresses) || addresses.length === 0) { + return { ok: false, reason: `endpoint host "${hostname}" did not resolve to any address` }; + } + for (const { address } of addresses) { + // A resolved private address — including loopback reached via a public name + // (DNS rebinding) — is refused; the explicit-loopback case returned above. + if (isPrivateIp(address)) { + return { + ok: false, + reason: `endpoint host "${hostname}" resolves to private/internal address "${address}"`, + }; + } + } + return { ok: true, addresses: addresses.map(({ address }) => address) }; +} + +/** + * Build curl `--resolve ::` arguments that pin a probe's + * connection to the address(es) `assertEndpointResolvesPublic` already + * validated, while leaving the request URL (and therefore its Host header / TLS + * SNI) untouched. This closes the DNS-rebinding / TOCTOU window between the SSRF + * preflight and the privileged host-side probe curl: without pinning, curl would + * re-resolve the hostname and a second lookup could return a rebound + * private/internal address after the public preflight passed (cv review, #6293). + * + * `host` is the URL hostname (IPv6 brackets stripped, as curl `--resolve` + * expects a bare address); `port` is the explicit URL port or the scheme default + * (443 for https, 80 for http). Returns `[]` when there are no pinned addresses + * (explicit-loopback endpoints, or callers that never ran the preflight) so the + * probe connects normally, and `[]` on an unparseable URL. + */ +export function buildResolvePinArgs( + targetUrl: string, + pinnedAddresses?: readonly string[] | null, +): string[] { + if (!pinnedAddresses || pinnedAddresses.length === 0) return []; + let host: string; + let port: string; + try { + const url = new URL(String(targetUrl)); + host = + url.hostname.startsWith("[") && url.hostname.endsWith("]") + ? url.hostname.slice(1, -1) + : url.hostname; + port = url.port || (url.protocol === "https:" ? "443" : "80"); + } catch { + return []; + } + if (!host) return []; + const addresses = [...new Set(pinnedAddresses.filter(Boolean))]; + if (addresses.length === 0) return []; + // One --resolve entry preserves every accepted address. Repeating the same + // host:port entry makes curl retain only the last mapping, silently dropping + // dual-stack/failover addresses. Bracket IPv6 addresses in the comma list so + // curl can distinguish their colons from the host:port separators. + const encodedAddresses = addresses.map((address) => + address.includes(":") ? `[${address}]` : address, + ); + return ["--resolve", `${host}:${port}:${encodedAddresses.join(",")}`]; +} diff --git a/src/lib/inference/onboard-probes.test.ts b/src/lib/inference/onboard-probes.test.ts index 1bef5cc04e0..b07da7057af 100644 --- a/src/lib/inference/onboard-probes.test.ts +++ b/src/lib/inference/onboard-probes.test.ts @@ -19,7 +19,6 @@ const { getChatCompletionsProbePayload, getDeepSeekV4ProValidationProbeCurlArgs, getKimiK26ValidationProbeCurlArgs, - getValidationProbeCurlArgs, hasChatCompletionsToolCall, hasChatCompletionsToolCallLeak, hasResponsesToolCall, @@ -28,17 +27,6 @@ const { RETRIABLE_HTTP_PROBE_STATUSES, } = require("./onboard-probes"); -// Restore an env var to its pre-test value without branching at the call -// site. Centralizing the conditional keeps test bodies linear and keeps the -// codebase-growth-guardrails "if count" steady; see PR #5975 review. -function restoreEnv(name: string, original: string | undefined): void { - if (original === undefined) { - delete process.env[name]; - } else { - process.env[name] = original; - } -} - const FAKE_CONFIG_PATH = "/tmp/nemoclaw-test-credential.conf"; const FAKE_CREDENTIAL_ARGS = ["--config", FAKE_CONFIG_PATH] as const; @@ -321,27 +309,6 @@ describe("OpenAI-compatible inference probes", () => { }); }); - it("allows onboard validation max-time to be raised from the environment", () => { - const original = process.env.NEMOCLAW_ONBOARD_VALIDATION_TIMEOUT_SECONDS; - process.env.NEMOCLAW_ONBOARD_VALIDATION_TIMEOUT_SECONDS = "300"; - try { - expect(getValidationProbeCurlArgs({ isWsl: false })).toEqual([ - "--connect-timeout", - "10", - "--max-time", - "300", - ]); - expect(getKimiK26ValidationProbeCurlArgs({ isWsl: false })).toEqual([ - "--connect-timeout", - "10", - "--max-time", - "300", - ]); - } finally { - restoreEnv("NEMOCLAW_ONBOARD_VALIDATION_TIMEOUT_SECONDS", original); - } - }); - it("uses an extended validation budget for slow NVIDIA Build models", () => { for (const model of ["qwen/qwen3.5-397b-a17b", "deepseek-ai/deepseek-v4-flash"]) { const args = getChatCompletionsProbeCurlArgs({ @@ -467,6 +434,54 @@ describe("OpenAI-compatible inference probes", () => { }); }); + describe("private-address SSRF guard (#6293)", () => { + it("rejects a non-loopback private LAN endpoint before issuing any probe (#6293)", () => { + const result = probeOpenAiLikeEndpoint( + "http://192.168.1.50:8000/v1", + "openai/model", + "dummy", + { + skipResponsesProbe: true, + }, + ); + expect(result).toMatchObject({ ok: false }); + expect(result.message).toMatch(/private\/internal address/i); + }); + + it("rejects the link-local cloud-metadata endpoint before any probe (#6293)", () => { + const result = probeOpenAiLikeEndpoint("http://169.254.169.254/v1", "openai/model", "dummy", { + skipResponsesProbe: true, + }); + expect(result).toMatchObject({ ok: false }); + expect(result.message).toMatch(/private\/internal address/i); + }); + + it("allows a loopback endpoint so local inference validation can proceed (#6293)", () => { + const body = `if [ -n "$outfile" ]; then + cat <<'JSON' > "$outfile" +{"choices":[{"message":{"content":"OK"}}]} +JSON +fi +printf '200' +exit 0 +`; + withFakeCurlProbe( + { script: makeFakeCurlScript(body), dirPrefix: "nemoclaw-loopback-probe-" }, + () => { + const result = probeOpenAiLikeEndpoint( + "http://127.0.0.1:11434/v1", + "openai/model", + "dummy", + { + skipResponsesProbe: true, + }, + ); + expect(result).toMatchObject({ ok: true }); + }, + ); + }); + }); + describe("retriable HTTP statuses (#2980, #3033)", () => { it("retries 429 (rate limit)", () => { expect(RETRIABLE_HTTP_PROBE_STATUSES.has(429)).toBe(true); diff --git a/src/lib/inference/onboard-probes.ts b/src/lib/inference/onboard-probes.ts index f21255472c6..2f8b90552f5 100644 --- a/src/lib/inference/onboard-probes.ts +++ b/src/lib/inference/onboard-probes.ts @@ -33,6 +33,8 @@ const { isHijackedDockerInternalUrl, } = require("./onboard-host-docker-internal"); const { isNvcfFunctionNotFoundForAccount, nvcfFunctionNotFoundMessage } = require("../validation"); +const { isPrivateHostname, isLoopbackHostname } = require("../private-networks"); +const { buildResolvePinArgs } = require("./endpoint-ssrf-preflight"); const { executeProbeWithHttpRetry, isProbeTimeout, @@ -41,6 +43,13 @@ const { runChatCompletionsRetryLoop, } = require("./probe-retry"); const { probeAnthropicEndpoint } = require("./probe-anthropic"); +const { + getValidationProbeCurlArgs, + getDeepSeekV4ProValidationProbeCurlArgs, + getKimiK26ValidationProbeCurlArgs, + getExtendedNvidiaEndpointValidationProbeCurlArgs, + getProbeProcessTimeoutMs, +} = require("./probe-http-helpers"); const { getCurlTimingArgs, @@ -79,7 +88,6 @@ function openAiLikeFailureFromError(error) { // ── Helpers ────────────────────────────────────────────────────── -const ONBOARD_VALIDATION_TIMEOUT_ENV = "NEMOCLAW_ONBOARD_VALIDATION_TIMEOUT_SECONDS"; const EXTENDED_NVIDIA_ENDPOINT_VALIDATION_MODELS = new Set([ "qwen/qwen3.5-397b-a17b", "deepseek-ai/deepseek-v4-flash", @@ -224,61 +232,6 @@ function getProbeAuthMode(_provider) { return undefined; } -// Per-validation-probe curl timing. Tighter than the default 60s in -// getCurlTimingArgs() because validation must not hang the wizard for a -// minute on a misbehaving model. See issue #1601 (Bug 3). -function getValidationProbeCurlArgs(opts) { - const args = isWsl(opts) - ? ["--connect-timeout", "20", "--max-time", "30"] - : ["--connect-timeout", "10", "--max-time", "15"]; - return withValidationMaxTimeOverride(args); -} - -function getDeepSeekV4ProValidationProbeCurlArgs(opts) { - const args = isWsl(opts) - ? ["--connect-timeout", "30", "--max-time", "150"] - : ["--connect-timeout", "20", "--max-time", "120"]; - return withValidationMaxTimeOverride(args); -} - -function getKimiK26ValidationProbeCurlArgs(opts) { - const args = isWsl(opts) - ? ["--connect-timeout", "20", "--max-time", "90"] - : ["--connect-timeout", "10", "--max-time", "60"]; - return withValidationMaxTimeOverride(args); -} - -function getExtendedNvidiaEndpointValidationProbeCurlArgs(opts) { - const args = isWsl(opts) - ? ["--connect-timeout", "30", "--max-time", "300"] - : ["--connect-timeout", "10", "--max-time", "300"]; - return withValidationMaxTimeOverride(args); -} - -function getCurlMaxTimeSeconds(args) { - const maxTimeIndex = args.indexOf("--max-time"); - if (maxTimeIndex === -1) return 30; - const value = Number(args[maxTimeIndex + 1]); - return Number.isFinite(value) && value > 0 ? value : 30; -} - -function withValidationMaxTimeOverride(args) { - const raw = (process.env[ONBOARD_VALIDATION_TIMEOUT_ENV] || "").trim(); - if (!raw) return args; - const overrideSeconds = Math.ceil(Number(raw)); - if (!Number.isFinite(overrideSeconds) || overrideSeconds <= 0) return args; - if (overrideSeconds <= getCurlMaxTimeSeconds(args)) return args; - const maxTimeIndex = args.indexOf("--max-time"); - if (maxTimeIndex === -1) return args; - const next = [...args]; - next[maxTimeIndex + 1] = String(overrideSeconds); - return next; -} - -function getProbeProcessTimeoutMs(args) { - return (getCurlMaxTimeSeconds(args) + 5) * 1000; -} - // ── Responses API probe ────────────────────────────────────────── function probeResponsesToolCalling(endpointUrl, model, apiKey, options = {}) { @@ -289,6 +242,7 @@ function probeResponsesToolCalling(endpointUrl, model, apiKey, options = {}) { const result = runCurlProbe( [ "-sS", + ...buildResolvePinArgs(`${baseUrl}/responses`, options.pinnedAddresses), ...getValidationProbeCurlArgs(), "-H", "Content-Type: application/json", @@ -316,7 +270,10 @@ function probeResponsesToolCalling(endpointUrl, model, apiKey, options = {}) { }), `${baseUrl}/responses`, ], - { trustedConfigFiles: authConfig.trustedConfigFiles }, + { + trustedConfigFiles: authConfig.trustedConfigFiles, + pinnedAddresses: options.pinnedAddresses, + }, ); if (!result.ok) { @@ -348,6 +305,7 @@ function probeChatCompletionsToolCalling(endpointUrl, model, apiKey, options = { const timingArgs = options.timingArgs ?? getChatCompletionsProbeTimingArgs(model); const args = [ "-sS", + ...buildResolvePinArgs(`${baseUrl}/chat/completions`, options.pinnedAddresses), ...timingArgs, "-H", "Content-Type: application/json", @@ -423,6 +381,7 @@ function probeChatCompletionsToolCalling(endpointUrl, model, apiKey, options = { const result = runCurlProbe(args, { timeoutMs: getProbeProcessTimeoutMs(args), trustedConfigFiles: authConfig.trustedConfigFiles, + pinnedAddresses: options.pinnedAddresses, }); if (!result.ok) { @@ -523,13 +482,15 @@ export function getChatCompletionsProbeCurlArgs(opts: { model: string; url: string; isWsl?: boolean; + pinnedAddresses?: readonly string[]; }) { - const { credentialArgs, authHeader, model, url, isWsl: isWslOverride } = opts; + const { credentialArgs, authHeader, model, url, isWsl: isWslOverride, pinnedAddresses } = opts; const platformOptions = typeof isWslOverride === "boolean" ? { isWsl: isWslOverride } : undefined; const timingArgs = getChatCompletionsProbeTimingArgs(model, platformOptions); const credSlice = credentialArgs ?? authHeader ?? []; return [ "-sS", + ...buildResolvePinArgs(url, pinnedAddresses), ...timingArgs, "-H", "Content-Type: application/json", @@ -546,14 +507,16 @@ function runChatCompletionsProbe({ url, isWsl: isWslOverride, trustedConfigFiles, + pinnedAddresses, }) { const args = getChatCompletionsProbeCurlArgs({ credentialArgs, model, url, isWsl: isWslOverride, + pinnedAddresses, }); - const probeOpts = { timeoutMs: getProbeProcessTimeoutMs(args) }; + const probeOpts = { timeoutMs: getProbeProcessTimeoutMs(args), pinnedAddresses }; if (trustedConfigFiles && trustedConfigFiles.length > 0) { probeOpts.trustedConfigFiles = trustedConfigFiles; } @@ -580,6 +543,7 @@ function runDoubledTimeoutChatCompletionsRetry({ const doubledArgs = baseArgs.map((arg) => (/^\d+$/.test(arg) ? String(Number(arg) * 2) : arg)); const buildRetryArgs = () => [ "-sS", + ...buildResolvePinArgs(`${baseUrl}/chat/completions`, options.pinnedAddresses), ...doubledArgs, "-H", "Content-Type: application/json", @@ -593,12 +557,14 @@ function runDoubledTimeoutChatCompletionsRetry({ ? probeChatCompletionsToolCalling(endpointUrl, model, apiKey, { authMode: options.authMode, timingArgs: doubledArgs, + pinnedAddresses: options.pinnedAddresses, }) : (() => { const retryArgs = buildRetryArgs(); return runCurlProbe(retryArgs, { timeoutMs: getProbeProcessTimeoutMs(retryArgs), trustedConfigFiles: authConfig.trustedConfigFiles, + pinnedAddresses: options.pinnedAddresses, }); })(); return runChatCompletionsRetryLoop(runRetryProbe); @@ -634,7 +600,61 @@ function probeOpenAiLikeEndpoint(endpointUrl, model, apiKey, options = {}) { }; } + // SSRF source boundary: reject a private/internal endpoint before any curl. + // The sandbox-internal alias is handled above, and host.docker.internal is + // gated by the allowHostDockerInternal check at the top of this function — + // both are trusted sandbox->host bridges, so exempt the already-permitted + // hijacked-docker-internal alias here. Loopback (127.0.0.0/8, ::1, localhost) + // is likewise exempt: this shared probe is the same one local inference uses + // to validate a locally-run Ollama/vLLM/NIM server on the probing host, and + // loopback only reaches that host — it is not a pivot to other internal + // infrastructure. Everything else that resolves to a private/reserved address + // (LAN ranges, link-local metadata) is attacker-reachable SSRF surface and is + // refused. Reuses the shared validators (defense-in-depth alongside + // DNS-pinning at the config-write boundary). See PR #6293 PRA-2. + // + // DNS-backed SSRF (a public name resolving to a private address) is closed + // one layer up, before this synchronous shared probe is reached: the only + // untrusted-endpoint caller path (validateCustomOpenAiLikeSelection / + // validateCustomAnthropicSelection) runs assertEndpointResolvesPublic — a + // resolver-based preflight that fails closed — before invoking this probe, + // and the independent /v1/models context curl resolves inline in + // applyCompatibleEndpointContextWindow. This function stays synchronous (it + // has many callers and no async boundary), so the resolve step is not + // duplicated here; the literal string check below remains as the local + // belt-and-suspenders layer. See PR #6293 PRA-3. + let probeHostname; + try { + probeHostname = new URL(String(endpointUrl)).hostname; + } catch { + probeHostname = ""; + } + if ( + probeHostname && + isPrivateHostname(probeHostname) && + !isLoopbackHostname(probeHostname) && + !isHijackedDockerInternalUrl(endpointUrl) + ) { + return { + ok: false, + message: `Endpoint host "${probeHostname}" is a private/internal address and cannot be used as a remote inference endpoint. Use a routable public URL and retry onboard.`, + failures: [ + { + name: "Private-address endpoint", + httpStatus: 0, + curlStatus: 0, + message: "endpoint resolves to a private/internal address", + body: "", + }, + ], + }; + } + const baseUrl = String(endpointUrl).replace(/\/+$/, ""); + // Pin every probe curl to the SSRF-preflight-validated address(es) the caller + // captured, so a second DNS lookup here cannot rebind the hostname to a + // private/internal address after the public preflight (TOCTOU — cv, #6293). + const pinnedAddresses = options.pinnedAddresses; let authConfig; try { authConfig = buildOpenAiLikeAuthConfig(apiKey, options); @@ -644,7 +664,10 @@ function probeOpenAiLikeEndpoint(endpointUrl, model, apiKey, options = {}) { name: "Responses API with tool calling", api: "openai-responses", execute: () => - probeResponsesToolCalling(endpointUrl, model, apiKey, { authMode: options.authMode }), + probeResponsesToolCalling(endpointUrl, model, apiKey, { + authMode: options.authMode, + pinnedAddresses, + }), } : { name: "Responses API", @@ -653,6 +676,7 @@ function probeOpenAiLikeEndpoint(endpointUrl, model, apiKey, options = {}) { runCurlProbe( [ "-sS", + ...buildResolvePinArgs(`${baseUrl}/responses`, pinnedAddresses), ...getValidationProbeCurlArgs(), "-H", "Content-Type: application/json", @@ -664,7 +688,7 @@ function probeOpenAiLikeEndpoint(endpointUrl, model, apiKey, options = {}) { }), `${baseUrl}/responses`, ], - { trustedConfigFiles: authConfig.trustedConfigFiles }, + { trustedConfigFiles: authConfig.trustedConfigFiles, pinnedAddresses }, ), }; @@ -675,6 +699,7 @@ function probeOpenAiLikeEndpoint(endpointUrl, model, apiKey, options = {}) { options.requireChatCompletionsToolCalling === true ? probeChatCompletionsToolCalling(endpointUrl, model, apiKey, { authMode: options.authMode, + pinnedAddresses, }) : runChatCompletionsProbe({ credentialArgs: authConfig.args, @@ -682,6 +707,7 @@ function probeOpenAiLikeEndpoint(endpointUrl, model, apiKey, options = {}) { url: `${baseUrl}/chat/completions`, isWsl: options.isWsl, trustedConfigFiles: authConfig.trustedConfigFiles, + pinnedAddresses, }), }; @@ -714,6 +740,7 @@ function probeOpenAiLikeEndpoint(endpointUrl, model, apiKey, options = {}) { const streamResult = runStreamingEventProbe( [ "-sS", + ...buildResolvePinArgs(`${baseUrl}/responses`, pinnedAddresses), ...getValidationProbeCurlArgs(), "-H", "Content-Type: application/json", @@ -726,7 +753,7 @@ function probeOpenAiLikeEndpoint(endpointUrl, model, apiKey, options = {}) { }), `${baseUrl}/responses`, ], - { trustedConfigFiles: authConfig.trustedConfigFiles }, + { trustedConfigFiles: authConfig.trustedConfigFiles, pinnedAddresses }, ); if (!streamResult.ok && streamResult.missingEvents.length > 0) { // Backend responds but lacks required streaming events — fall back @@ -924,6 +951,7 @@ export function verifyOnboardInferenceSmoke(options: any) { const probe = probeOpenAiLikeEndpoint(endpointUrl, options.model, apiKey, { authMode: getProbeAuthMode(options.provider), skipResponsesProbe: true, + pinnedAddresses: options.pinnedAddresses, }); if (probe.ok) { diff --git a/src/lib/inference/probe-anthropic.ts b/src/lib/inference/probe-anthropic.ts index 921143e616b..77dddc67561 100644 --- a/src/lib/inference/probe-anthropic.ts +++ b/src/lib/inference/probe-anthropic.ts @@ -14,6 +14,7 @@ import { runCurlProbe, } from "../adapters/http/probe"; import { normalizeCredentialValue } from "../credentials/store"; +import { buildResolvePinArgs } from "./endpoint-ssrf-preflight"; export type AnthropicStreamingDiagnosticCode = | "anthropic-streaming-content-after-message-stop" @@ -50,6 +51,12 @@ export interface AnthropicProbeOptions { * surfaces in-sandbox as "no final response was produced" (#6289). */ probeStreaming?: boolean; + /** + * SSRF-preflight-validated address(es) to pin the probe curl to via + * `--resolve`, so a second DNS lookup here cannot rebind the endpoint host to + * a private/internal address after the public preflight (TOCTOU — #6293). + */ + pinnedAddresses?: readonly string[]; } // Streaming validation must not hang the onboarding wizard on an endpoint @@ -123,9 +130,11 @@ export function probeAnthropicEndpoint( try { authConfig = createXApiKeyAuthConfig(normalizeCredentialValue(apiKey)); const messagesUrl = `${String(endpointUrl).replace(/\/+$/, "")}/v1/messages`; + const resolvePinArgs = buildResolvePinArgs(messagesUrl, options.pinnedAddresses); const result = runCurlProbe( [ "-sS", + ...resolvePinArgs, ...getCurlTimingArgs(), ...authConfig.args, "-H", @@ -136,7 +145,10 @@ export function probeAnthropicEndpoint( anthropicMessagesPayload(model, false), messagesUrl, ], - { trustedConfigFiles: authConfig.trustedConfigFiles }, + { + trustedConfigFiles: authConfig.trustedConfigFiles, + pinnedAddresses: options.pinnedAddresses, + }, ); if (!result.ok) { return { @@ -157,6 +169,7 @@ export function probeAnthropicEndpoint( const streamResult = runAnthropicStreamingEventProbe( [ "-sS", + ...resolvePinArgs, ...STREAMING_PROBE_TIMING_ARGS, ...authConfig.args, "-H", @@ -167,7 +180,10 @@ export function probeAnthropicEndpoint( anthropicMessagesPayload(model, true), messagesUrl, ], - { trustedConfigFiles: authConfig.trustedConfigFiles }, + { + trustedConfigFiles: authConfig.trustedConfigFiles, + pinnedAddresses: options.pinnedAddresses, + }, ); if (!streamResult.ok) { return { diff --git a/src/lib/inference/probe-http-helpers.test.ts b/src/lib/inference/probe-http-helpers.test.ts new file mode 100644 index 00000000000..ca1a293c149 --- /dev/null +++ b/src/lib/inference/probe-http-helpers.test.ts @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { afterEach, describe, expect, it, vi } from "vitest"; + +const { + getKimiK26ValidationProbeCurlArgs, + getValidationProbeCurlArgs, +} = require("./probe-http-helpers"); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("validation probe curl timing helpers", () => { + it("allows onboard validation max-time to be raised from the environment", () => { + vi.stubEnv("NEMOCLAW_ONBOARD_VALIDATION_TIMEOUT_SECONDS", "300"); + expect(getValidationProbeCurlArgs({ isWsl: false })).toEqual([ + "--connect-timeout", + "10", + "--max-time", + "300", + ]); + expect(getKimiK26ValidationProbeCurlArgs({ isWsl: false })).toEqual([ + "--connect-timeout", + "10", + "--max-time", + "300", + ]); + }); +}); diff --git a/src/lib/inference/probe-http-helpers.ts b/src/lib/inference/probe-http-helpers.ts new file mode 100644 index 00000000000..3b96400607c --- /dev/null +++ b/src/lib/inference/probe-http-helpers.ts @@ -0,0 +1,70 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// Pure HTTP/curl-probe argument and timing builders extracted from +// onboard-probes.ts. These helpers only compute values from their inputs +// (and the documented NEMOCLAW_ONBOARD_VALIDATION_TIMEOUT_SECONDS override); +// they run no curl and touch no network/process state. Keeping them in a +// focused, typed module lets the probe driver stay small while these builders +// remain independently testable. See PR #6293 PRA-1. + +const { isWsl } = require("../platform"); + +type WslProbeOptions = { isWsl?: boolean } | undefined; + +const ONBOARD_VALIDATION_TIMEOUT_ENV = "NEMOCLAW_ONBOARD_VALIDATION_TIMEOUT_SECONDS"; + +// Per-validation-probe curl timing. Tighter than the default 60s in +// getCurlTimingArgs() because validation must not hang the wizard for a +// minute on a misbehaving model. See issue #1601 (Bug 3). +export function getValidationProbeCurlArgs(opts?: WslProbeOptions): string[] { + const args = isWsl(opts) + ? ["--connect-timeout", "20", "--max-time", "30"] + : ["--connect-timeout", "10", "--max-time", "15"]; + return withValidationMaxTimeOverride(args); +} + +export function getDeepSeekV4ProValidationProbeCurlArgs(opts?: WslProbeOptions): string[] { + const args = isWsl(opts) + ? ["--connect-timeout", "30", "--max-time", "150"] + : ["--connect-timeout", "20", "--max-time", "120"]; + return withValidationMaxTimeOverride(args); +} + +export function getKimiK26ValidationProbeCurlArgs(opts?: WslProbeOptions): string[] { + const args = isWsl(opts) + ? ["--connect-timeout", "20", "--max-time", "90"] + : ["--connect-timeout", "10", "--max-time", "60"]; + return withValidationMaxTimeOverride(args); +} + +export function getExtendedNvidiaEndpointValidationProbeCurlArgs(opts?: WslProbeOptions): string[] { + const args = isWsl(opts) + ? ["--connect-timeout", "30", "--max-time", "300"] + : ["--connect-timeout", "10", "--max-time", "300"]; + return withValidationMaxTimeOverride(args); +} + +export function getCurlMaxTimeSeconds(args: readonly string[]): number { + const maxTimeIndex = args.indexOf("--max-time"); + if (maxTimeIndex === -1) return 30; + const value = Number(args[maxTimeIndex + 1]); + return Number.isFinite(value) && value > 0 ? value : 30; +} + +export function withValidationMaxTimeOverride(args: string[]): string[] { + const raw = (process.env[ONBOARD_VALIDATION_TIMEOUT_ENV] || "").trim(); + if (!raw) return args; + const overrideSeconds = Math.ceil(Number(raw)); + if (!Number.isFinite(overrideSeconds) || overrideSeconds <= 0) return args; + if (overrideSeconds <= getCurlMaxTimeSeconds(args)) return args; + const maxTimeIndex = args.indexOf("--max-time"); + if (maxTimeIndex === -1) return args; + const next = [...args]; + next[maxTimeIndex + 1] = String(overrideSeconds); + return next; +} + +export function getProbeProcessTimeoutMs(args: readonly string[]): number { + return (getCurlMaxTimeSeconds(args) + 5) * 1000; +} diff --git a/src/lib/inference/vllm-runtime-context.test.ts b/src/lib/inference/vllm-runtime-context.test.ts index a4fd5fc42c0..2853b295c51 100644 --- a/src/lib/inference/vllm-runtime-context.test.ts +++ b/src/lib/inference/vllm-runtime-context.test.ts @@ -3,7 +3,10 @@ import { describe, expect, it } from "vitest"; -import { applyVllmRuntimeContextWindow } from "./vllm-runtime-context"; +import { + applyVllmRuntimeContextWindow, + resolveVllmContextWindowFromModels, +} from "./vllm-runtime-context"; function applyContextWindow( modelsResponse: unknown, @@ -74,6 +77,51 @@ describe("vLLM runtime context helpers", () => { expect(applyContextWindow(response, "").env.NEMOCLAW_CONTEXT_WINDOW).toBe("32768"); }); + it("ignores non-object /v1/models entries without throwing (#6177)", () => { + // Arbitrary compatible endpoints can return valid JSON that is not the vLLM + // shape; null/primitive entries must not crash the resolver. + expect(() => resolveVllmContextWindowFromModels({ data: [null] }, "model-a")).not.toThrow(); + expect(resolveVllmContextWindowFromModels({ data: [null] }, "model-a")).toBeNull(); + expect( + resolveVllmContextWindowFromModels( + { data: [null, "nope", { id: "model-a", max_model_len: 65_536 }] }, + "model-a", + ), + ).toBe(65_536); + }); + + it("under strictModelMatch, refuses to guess a window for multi-model gateways (#6177)", () => { + const warnings: string[] = []; + const logger = { warn: (message: string) => warnings.push(message) }; + const response = { + data: [ + { id: "model-a", max_model_len: 32_768 }, + { id: "model-b", max_model_len: 65_536 }, + ], + }; + + // Exact id still resolves. + expect( + resolveVllmContextWindowFromModels(response, "model-b", logger, { strictModelMatch: true }), + ).toBe(65_536); + // No exact match across multiple models → null (no first-entry guess). + expect( + resolveVllmContextWindowFromModels(response, "missing", logger, { strictModelMatch: true }), + ).toBeNull(); + expect(warnings.at(-1)).toContain("none match 'missing'"); + // A single served model is unambiguous even under strict matching. + expect( + resolveVllmContextWindowFromModels( + { data: [{ id: "solo", max_model_len: 16_384 }] }, + "x", + logger, + { + strictModelMatch: true, + }, + ), + ).toBe(16_384); + }); + it("applies detected max_model_len only when no explicit override is set", () => { const response = { data: [{ id: "model-a", max_model_len: 65_536 }] }; diff --git a/src/lib/inference/vllm-runtime-context.ts b/src/lib/inference/vllm-runtime-context.ts index 7ed7e6dd330..72f41314312 100644 --- a/src/lib/inference/vllm-runtime-context.ts +++ b/src/lib/inference/vllm-runtime-context.ts @@ -3,11 +3,26 @@ import { hasExplicitContextWindow, parsePositiveInteger } from "./ollama-runtime-context"; +// 4 MiB tokens (2^22) — far above any practical model context window, so it +// rejects obviously broken daemon responses while never clipping a real one. +// Matches the Ollama auto-detect ceiling (MAX_AUTODETECTED_OLLAMA_CONTEXT_WINDOW). const MAX_AUTODETECTED_VLLM_CONTEXT_WINDOW = 4_194_304; type ModelEntry = { id?: unknown; max_model_len?: unknown }; type ApplyOptions = { env?: NodeJS.ProcessEnv; logger?: Pick }; +export type ResolveVllmContextWindowOptions = { + /** + * When true, only fall back to the first entry if the response lists exactly + * one model. Multi-model responses with no exact `id` match return null + * instead of guessing. Use for shared OpenAI-compatible gateways (which can + * serve many models under aliases) so an unrelated model's `max_model_len` + * is never baked in; local vLLM keeps the permissive single-served-model + * fallback (#6177). + */ + strictModelMatch?: boolean; +}; + /** * Extract the runtime context window for `modelId` from a vLLM `/v1/models` * response (its `max_model_len`), validated against NemoClaw's auto-detect @@ -19,15 +34,32 @@ export function resolveVllmContextWindowFromModels( modelsResponse: unknown, modelId: string | null | undefined, logger: Pick = console, + options: ResolveVllmContextWindowOptions = {}, ): number | null { const data = (modelsResponse as { data?: unknown } | null | undefined)?.data; - const entries = Array.isArray(data) ? (data as ModelEntry[]) : []; + // Drop non-object entries defensively: a compatible endpoint's /v1/models body + // is arbitrary JSON and may contain nulls/primitives (e.g. `{"data":[null]}`), + // which would otherwise throw when we read `.id` and abort onboarding (#6177). + const entries = (Array.isArray(data) ? data : []).filter( + (candidate): candidate is ModelEntry => typeof candidate === "object" && candidate !== null, + ); if (entries.length === 0) return null; const target = String(modelId ?? "").trim(); - const entry = - (target && entries.find((candidate) => String(candidate.id ?? "").trim() === target)) || - entries[0]; + const exactMatch = target + ? entries.find((candidate) => String(candidate.id ?? "").trim() === target) + : undefined; + let entry = exactMatch; + if (!entry) { + if (options.strictModelMatch && entries.length > 1) { + logger.warn( + ` ⚠ Endpoint /v1/models lists ${entries.length} models and none match '${target}'; ` + + `not auto-detecting the context window. Set NEMOCLAW_CONTEXT_WINDOW to override.`, + ); + return null; + } + entry = entries[0]; + } const rawMaxModelLen = entry?.max_model_len; if ( rawMaxModelLen === undefined || diff --git a/src/lib/messaging/channels/whatsapp/runtime/whatsapp-qr-compact.test.ts b/src/lib/messaging/channels/whatsapp/runtime/whatsapp-qr-compact.test.ts index dc9e1d2d042..4c0e310503a 100644 --- a/src/lib/messaging/channels/whatsapp/runtime/whatsapp-qr-compact.test.ts +++ b/src/lib/messaging/channels/whatsapp/runtime/whatsapp-qr-compact.test.ts @@ -11,6 +11,7 @@ import { describe, expect, it, vi } from "vitest"; import { createOpenClawQrTerminalLoaderSource, + createOpenClawQrTerminalSyncLoadHook, describeOpenClawQrTerminalPatchSkip, isQrcodePackage, isQrcodeTerminalPackage, @@ -177,6 +178,17 @@ describe("patchOpenClawQrTerminalRendererSource (#4522)", () => { expect(loader).toContain("patchOpenClawQrTerminalRendererSource(source, integrity)"); }); + it("provides a synchronous registerHooks loader that composes with other preloads", () => { + const nextLoad = vi.fn(() => ({ format: "module", source: "export const value = 1;" })); + const hook = createOpenClawQrTerminalSyncLoadHook(); + + const result = hook("file:///tmp/unrelated.mjs", {}, nextLoad); + + expect(result).toEqual({ format: "module", source: "export const value = 1;" }); + expect(result).not.toBeInstanceOf(Promise); + expect(nextLoad).toHaveBeenCalledTimes(1); + }); + it("emits non-secret loader diagnostics when the source rewrite is skipped", () => { const write = vi.spyOn(process.stderr, "write").mockImplementation(() => true); try { diff --git a/src/lib/messaging/channels/whatsapp/runtime/whatsapp-qr-compact.ts b/src/lib/messaging/channels/whatsapp/runtime/whatsapp-qr-compact.ts index 0cfdf609825..888f8fbb22e 100644 --- a/src/lib/messaging/channels/whatsapp/runtime/whatsapp-qr-compact.ts +++ b/src/lib/messaging/channels/whatsapp/runtime/whatsapp-qr-compact.ts @@ -198,6 +198,35 @@ function warnWhatsappQrCompact(message) { } } +function openClawQrLoaderSourceToText(source) { + if (typeof source === "string") return source; + if (typeof Buffer !== "undefined") { + if (Buffer.isBuffer(source)) return source.toString("utf8"); + if (source instanceof Uint8Array) return Buffer.from(source).toString("utf8"); + if (source instanceof ArrayBuffer) return Buffer.from(source).toString("utf8"); + } + return null; +} + +function createOpenClawQrTerminalSyncLoadHook() { + var createHash = require("node:crypto").createHash; + return function nemoclawWhatsappQrLoadHook(urlValue, context, nextLoad) { + var result = nextLoad(urlValue, context); + if (!result || result.format !== "module") return result; + var source = openClawQrLoaderSourceToText(result.source); + if (source === null || !isOpenClawQrTerminalRendererSource(source)) return result; + var integrity = createHash("sha256").update(source).digest("hex"); + var skipReason = describeOpenClawQrTerminalPatchSkip(source, integrity); + if (skipReason) { + warnWhatsappQrCompact(skipReason); + return result; + } + var patched = patchOpenClawQrTerminalRendererSource(source, integrity); + if (patched === source) return result; + return Object.assign({}, result, { source: patched }); + }; +} + // `qrcode` package main: renderQrTerminal() calls qrcode.toString(text, opts). // Require an OWN toString (every object inherits Object.prototype.toString, so // a plain `typeof mod.toString` check would also match qrcode's internal @@ -366,6 +395,7 @@ export { patchOpenClawQrTerminalRendererSource, REVIEWED_OPENCLAW_QR_TERMINAL_RENDERER_SHA256, createOpenClawQrTerminalLoaderSource, + createOpenClawQrTerminalSyncLoadHook, patchQrcode, patchQrcodeTerminal, resolvePatchedModule, @@ -373,7 +403,10 @@ export { function installOpenClawQrTerminalSourceLoader(Module) { if (process.__nemoclawWhatsappQrCompactSourceLoaderInstalled) return; - if (!Module || typeof Module.register !== "function") { + if ( + !Module || + (typeof Module.registerHooks !== "function" && typeof Module.register !== "function") + ) { warnWhatsappQrCompact( "OpenClaw QR renderer source loader registration is unavailable; explicit compact quiet-zone rewrite skipped", ); @@ -388,6 +421,15 @@ function installOpenClawQrTerminalSourceLoader(Module) { } try { + // Node's synchronous registerHooks API must be used when available. Mixing + // an async Module.register loader with another preload's synchronous hook + // chain makes later ESM loads call loadSync on an async customization + // object (notably the Slack provider preload). Keeping every modern-Node + // preload in the same synchronous chain avoids that runtime failure. + if (typeof Module.registerHooks === "function") { + Module.registerHooks({ load: createOpenClawQrTerminalSyncLoadHook() }); + return; + } var loaderSource = createOpenClawQrTerminalLoaderSource(); var loaderUrl = "data:text/javascript;base64," + Buffer.from(loaderSource, "utf8").toString("base64"); diff --git a/src/lib/onboard/dockerfile-patch-hermes-context.test.ts b/src/lib/onboard/dockerfile-patch-hermes-context.test.ts new file mode 100644 index 00000000000..131605653d4 --- /dev/null +++ b/src/lib/onboard/dockerfile-patch-hermes-context.test.ts @@ -0,0 +1,88 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// Closes the onboard loop for #6177: once the compatible-endpoint probe sets +// NEMOCLAW_CONTEXT_WINDOW, dockerfile-patch must rewrite the Hermes Dockerfile's +// ARG so the baked value reaches build-env/config generation. This stages the +// real agents/hermes/Dockerfile so a future ARG rename cannot silently regress. + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { patchStagedDockerfile } from "./dockerfile-patch"; + +const HERMES_DOCKERFILE = path.join(import.meta.dirname, "../../../agents/hermes/Dockerfile"); +const tmpRoots: string[] = []; + +function stageHermesDockerfile(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-ctx-patch-")); + tmpRoots.push(dir); + const file = path.join(dir, "Dockerfile"); + fs.copyFileSync(HERMES_DOCKERFILE, file); + return file; +} + +function contextWindowArg(dockerfilePath: string): string | undefined { + return fs + .readFileSync(dockerfilePath, "utf8") + .split("\n") + .find((line) => line.startsWith("ARG NEMOCLAW_CONTEXT_WINDOW=")); +} + +function patchHermes(dockerfilePath: string): void { + patchStagedDockerfile( + dockerfilePath, + "nvidia/NVIDIA-Nemotron-3-Super-120B-A12B-NVFP4", + "http://127.0.0.1:18789", + "build-hermes-context", + "compatible-endpoint", + "openai-completions", + ); +} + +// Each test controls NEMOCLAW_CONTEXT_WINDOW via vi.stubEnv, and afterEach +// restores the real environment through vi.unstubAllEnvs (no manual delete, +// no branching — keeps the file within the changed-test-file if-statement guard). +afterEach(() => { + vi.unstubAllEnvs(); + for (const dir of tmpRoots.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("patchStagedDockerfile :: Hermes NEMOCLAW_CONTEXT_WINDOW (#6177)", () => { + it("declares an empty context-window ARG that defaults to Hermes auto-detect", () => { + expect(contextWindowArg(HERMES_DOCKERFILE)).toBe("ARG NEMOCLAW_CONTEXT_WINDOW="); + }); + + it("bakes a probed/explicit context window into the staged Hermes Dockerfile", () => { + const dockerfilePath = stageHermesDockerfile(); + vi.stubEnv("NEMOCLAW_CONTEXT_WINDOW", "65536"); + patchHermes(dockerfilePath); + expect(contextWindowArg(dockerfilePath)).toBe("ARG NEMOCLAW_CONTEXT_WINDOW=65536"); + }); + + it("leaves the empty default when no context window is configured", () => { + const dockerfilePath = stageHermesDockerfile(); + vi.stubEnv("NEMOCLAW_CONTEXT_WINDOW", ""); + patchHermes(dockerfilePath); + expect(contextWindowArg(dockerfilePath)).toBe("ARG NEMOCLAW_CONTEXT_WINDOW="); + }); + + it("ignores a malformed context window and preserves auto-detect", () => { + const dockerfilePath = stageHermesDockerfile(); + vi.stubEnv("NEMOCLAW_CONTEXT_WINDOW", "not-a-number"); + patchHermes(dockerfilePath); + expect(contextWindowArg(dockerfilePath)).toBe("ARG NEMOCLAW_CONTEXT_WINDOW="); + }); + + it("ignores an over-ceiling context window instead of baking an implausible value (#6293)", () => { + const dockerfilePath = stageHermesDockerfile(); + vi.stubEnv("NEMOCLAW_CONTEXT_WINDOW", "9999999999"); + patchHermes(dockerfilePath); + expect(contextWindowArg(dockerfilePath)).toBe("ARG NEMOCLAW_CONTEXT_WINDOW="); + }); +}); diff --git a/src/lib/onboard/dockerfile-patch.ts b/src/lib/onboard/dockerfile-patch.ts index dbf59aba1b6..70e26e8217f 100644 --- a/src/lib/onboard/dockerfile-patch.ts +++ b/src/lib/onboard/dockerfile-patch.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { getSandboxInferenceConfig } from "../inference/config"; +import { MAX_AUTODETECTED_OLLAMA_CONTEXT_WINDOW } from "../inference/ollama-runtime-context"; import { isWebSearchEnabled, type WebSearchConfig, @@ -175,7 +176,15 @@ export function patchStagedDockerfile( // Honor NEMOCLAW_CONTEXT_WINDOW / NEMOCLAW_MAX_TOKENS / NEMOCLAW_REASONING // so the user can tune model metadata without editing the Dockerfile. const contextWindow = process.env.NEMOCLAW_CONTEXT_WINDOW; - if (contextWindow && POSITIVE_INT_RE.test(contextWindow)) { + // Validate the ceiling as well as the format: POSITIVE_INT_RE alone would let + // an implausibly large value (which the auto-detect/probe paths reject) bake + // into the image ARG. Match the auto-detect ceiling. See PR #6293 PRA-4 + // (Nemotron). + if ( + contextWindow && + POSITIVE_INT_RE.test(contextWindow) && + Number(contextWindow) <= MAX_AUTODETECTED_OLLAMA_CONTEXT_WINDOW + ) { dockerfile = dockerfile.replace( /^ARG NEMOCLAW_CONTEXT_WINDOW=.*$/m, `ARG NEMOCLAW_CONTEXT_WINDOW=${sanitizeDockerArg(contextWindow)}`, diff --git a/src/lib/onboard/inference-providers/remote-openai-surface.test.ts b/src/lib/onboard/inference-providers/remote-openai-surface.test.ts index 34256a20388..47e12e4248e 100644 --- a/src/lib/onboard/inference-providers/remote-openai-surface.test.ts +++ b/src/lib/onboard/inference-providers/remote-openai-surface.test.ts @@ -22,6 +22,7 @@ function makeArgs(sandboxName: string | null) { endpointUrl: ENDPOINT, credentialEnv: CREDENTIAL_ENV, preferredInferenceApi: "openai-completions", + pinnedAddresses: ["93.184.216.34"], }; } @@ -100,7 +101,7 @@ describe("custom Anthropic provider replacement on the OpenAI surface", () => { OPENAI_SURFACE, MODEL, "test-secret", - { skipResponsesProbe: true }, + { skipResponsesProbe: true, pinnedAddresses: ["93.184.216.34"] }, ); expect(harness.readGatewayProviderMetadata).toHaveBeenCalledWith( PROVIDER, diff --git a/src/lib/onboard/inference-providers/remote.ts b/src/lib/onboard/inference-providers/remote.ts index 466818404f5..0ff06a5ecce 100644 --- a/src/lib/onboard/inference-providers/remote.ts +++ b/src/lib/onboard/inference-providers/remote.ts @@ -122,6 +122,7 @@ export async function setupRemoteProviderInference( credentialEnv: string | null; reuseGatewayCredentialWithoutLocalKey?: boolean; preferredInferenceApi?: string | null; + pinnedAddresses?: readonly string[]; }, deps: RemoteProviderDeps, ): Promise<{ done: true; result: SetupInferenceResult } | { done: false }> { @@ -133,6 +134,7 @@ export async function setupRemoteProviderInference( credentialEnv, reuseGatewayCredentialWithoutLocalKey, preferredInferenceApi, + pinnedAddresses, } = args; const { runOpenshell, @@ -243,6 +245,7 @@ export async function setupRemoteProviderInference( getCompatibleAnthropicOpenAiSurfaceBaseUrl(resolvedEndpointUrl); const surfaceProbe = probeOpenAiSurface(openAiSurfaceBaseUrl, model, credentialValue, { skipResponsesProbe: true, + pinnedAddresses, }); if (!surfaceProbe.ok) { providerResult = { diff --git a/src/lib/onboard/inference-providers/types.ts b/src/lib/onboard/inference-providers/types.ts index 89a75fc5ac7..7e59b3ef08c 100644 --- a/src/lib/onboard/inference-providers/types.ts +++ b/src/lib/onboard/inference-providers/types.ts @@ -65,6 +65,7 @@ export type VerifyOnboardInferenceSmoke = (input: { endpointUrl?: string | null; credentialEnv?: string | null; forceOpenAiLike?: boolean; + pinnedAddresses?: readonly string[]; }) => void; export type PromptValidationRecovery = ( diff --git a/src/lib/onboard/inference-selection-validation.test.ts b/src/lib/onboard/inference-selection-validation.test.ts index bfcd0e2233e..604740a33bf 100644 --- a/src/lib/onboard/inference-selection-validation.test.ts +++ b/src/lib/onboard/inference-selection-validation.test.ts @@ -1,6 +1,9 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import { createInferenceSelectionValidationHelpers } from "./inference-selection-validation"; @@ -60,6 +63,7 @@ describe("inference selection validation", () => { getCredential: () => "test-key", probeOpenAiLikeEndpoint, promptValidationRecovery, + resolveEndpointHost: async () => [{ address: "93.184.216.34", family: 4 }], }); try { @@ -79,6 +83,7 @@ describe("inference selection validation", () => { requireResponsesToolCalling: false, skipResponsesProbe: true, probeStreaming: false, + pinnedAddresses: ["93.184.216.34"], }, ); } finally { @@ -87,6 +92,134 @@ describe("inference selection validation", () => { } }); + it("refuses a custom OpenAI-like endpoint that resolves to a private address before probing (#6293)", async () => { + const probeOpenAiLikeEndpoint = vi.fn(() => ({ ok: true, api: "openai-completions" })); + const promptValidationRecovery = vi.fn(async () => "selection" as const); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + const helpers = createInferenceSelectionValidationHelpers({ + isNonInteractive: () => false, + agentProductName: () => "OpenClaw", + getCredential: () => "test-key", + probeOpenAiLikeEndpoint, + promptValidationRecovery, + resolveEndpointHost: async () => [{ address: "10.0.0.8", family: 4 }], + }); + + try { + await expect( + helpers.validateCustomOpenAiLikeSelection( + "Custom endpoint", + "https://public-name.example/v1", + "model-a", + "COMPATIBLE_API_KEY", + ), + ).resolves.toEqual({ ok: false, retry: "selection" }); + expect(probeOpenAiLikeEndpoint).not.toHaveBeenCalled(); + } finally { + error.mockRestore(); + } + }); + + it.each([ + "http://127.0.0.1:8000/v1", + "https://inference.local/v1", + "https://93.184.216.34/v1", + ])("carries the approved no-pin capability to probes for %s (#6293)", async (endpointUrl) => { + const probeOpenAiLikeEndpoint = vi.fn(() => ({ ok: true, api: "openai-completions" })); + const resolveEndpointHost = vi.fn(async () => [{ address: "10.0.0.8", family: 4 }]); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + const helpers = createInferenceSelectionValidationHelpers({ + isNonInteractive: () => false, + agentProductName: () => "OpenClaw", + getCredential: () => "test-key", + probeOpenAiLikeEndpoint, + promptValidationRecovery: vi.fn(async () => "selection" as const), + resolveEndpointHost, + }); + + try { + await expect( + helpers.validateCustomOpenAiLikeSelection( + "Custom endpoint", + endpointUrl, + "model-a", + "COMPATIBLE_API_KEY", + ), + ).resolves.toEqual({ + ok: true, + api: "openai-completions", + pinnedAddresses: [], + }); + expect(probeOpenAiLikeEndpoint).toHaveBeenCalledWith( + endpointUrl, + "model-a", + "test-key", + expect.objectContaining({ pinnedAddresses: [] }), + ); + expect(resolveEndpointHost).not.toHaveBeenCalled(); + } finally { + log.mockRestore(); + } + }); + + it("exits non-interactively when a custom Anthropic endpoint resolves to link-local metadata, without probing (#6293)", async () => { + const originalExitCode = process.exitCode; + const probeAnthropicEndpoint = vi.fn(); + const exit = vi.spyOn(process, "exit").mockImplementation((() => undefined) as never); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + const helpers = createInferenceSelectionValidationHelpers({ + isNonInteractive: () => true, + agentProductName: () => "OpenClaw", + getCredential: () => "test-key", + probeAnthropicEndpoint, + promptValidationRecovery: vi.fn(async () => "selection" as const), + resolveEndpointHost: async () => [{ address: "169.254.169.254", family: 4 }], + }); + + try { + await expect( + helpers.validateCustomAnthropicSelection( + "Custom Anthropic", + "https://metadata-name.example/v1", + "model-a", + "COMPATIBLE_ANTHROPIC_API_KEY", + ), + ).rejects.toThrow("Non-interactive endpoint validation failed."); + expect(probeAnthropicEndpoint).not.toHaveBeenCalled(); + expect(exit).toHaveBeenCalledWith(1); + } finally { + process.exitCode = originalExitCode; + exit.mockRestore(); + error.mockRestore(); + } + }); + + it("probes a custom endpoint that resolves to a public address (#6293)", async () => { + const probeOpenAiLikeEndpoint = vi.fn(() => ({ ok: true, api: "openai-completions" })); + const helpers = createInferenceSelectionValidationHelpers({ + isNonInteractive: () => false, + agentProductName: () => "OpenClaw", + getCredential: () => "test-key", + probeOpenAiLikeEndpoint, + promptValidationRecovery: vi.fn(async () => "selection" as const), + resolveEndpointHost: async () => [{ address: "93.184.216.34", family: 4 }], + }); + + await expect( + helpers.validateCustomOpenAiLikeSelection( + "Custom endpoint", + "https://vllm.public.test/v1", + "model-a", + "COMPATIBLE_API_KEY", + ), + ).resolves.toEqual({ + ok: true, + api: "openai-completions", + pinnedAddresses: ["93.184.216.34"], + }); + expect(probeOpenAiLikeEndpoint).toHaveBeenCalled(); + }); + it("requests streaming validation for OpenClaw custom Anthropic endpoints (#6289)", async () => { const probeAnthropicEndpoint = vi.fn(() => ({ ok: true, @@ -100,6 +233,7 @@ describe("inference selection validation", () => { getCredential: () => "test-key", probeAnthropicEndpoint, promptValidationRecovery: vi.fn(async () => "selection" as const), + resolveEndpointHost: async () => [{ address: "93.184.216.34", family: 4 }], }); try { @@ -110,12 +244,16 @@ describe("inference selection validation", () => { "nvidia/nemotron-3-super-v3", "COMPATIBLE_ANTHROPIC_API_KEY", ), - ).resolves.toEqual({ ok: true, api: "anthropic-messages" }); + ).resolves.toEqual({ + ok: true, + api: "anthropic-messages", + pinnedAddresses: ["93.184.216.34"], + }); expect(probeAnthropicEndpoint).toHaveBeenCalledWith( "https://compatible.example", "nvidia/nemotron-3-super-v3", "test-key", - { probeStreaming: true }, + { probeStreaming: true, pinnedAddresses: ["93.184.216.34"] }, ); } finally { log.mockRestore(); @@ -148,6 +286,7 @@ describe("inference selection validation", () => { probeAnthropicEndpoint, probeOpenAiLikeEndpoint, promptValidationRecovery: vi.fn(async () => "selection" as const), + resolveEndpointHost: async () => [{ address: "93.184.216.34", family: 4 }], }); try { @@ -160,12 +299,16 @@ describe("inference selection validation", () => { null, { intendedApi: "openai-completions" }, ), - ).resolves.toEqual({ ok: true, api: "openai-completions" }); + ).resolves.toEqual({ + ok: true, + api: "openai-completions", + pinnedAddresses: ["93.184.216.34"], + }); expect(probeOpenAiLikeEndpoint).toHaveBeenCalledWith( "https://compatible.example/v1", "nvidia/nemotron-3-super-v3", "test-key", - { skipResponsesProbe: true }, + { skipResponsesProbe: true, pinnedAddresses: ["93.184.216.34"] }, ); expect(probeAnthropicEndpoint).not.toHaveBeenCalled(); } finally { @@ -187,6 +330,7 @@ describe("inference selection validation", () => { getCredential: () => "test-key", probeAnthropicEndpoint, promptValidationRecovery: vi.fn(async () => "selection" as const), + resolveEndpointHost: async () => [{ address: "93.184.216.34", family: 4 }], }); try { @@ -200,7 +344,7 @@ describe("inference selection validation", () => { "https://compatible.example", "reasoning-model", "test-key", - { probeStreaming: false }, + { probeStreaming: false, pinnedAddresses: ["93.184.216.34"] }, ); } finally { log.mockRestore(); @@ -208,6 +352,93 @@ describe("inference selection validation", () => { } }); + it("pins the probe connection to the preflight-validated address against DNS rebinding (#6293)", async () => { + // Orchestration proof: the SSRF preflight validates the endpoint host to a + // PUBLIC address, then the probe must connect to exactly that address via + // curl --resolve. The injected resolver would hand back a PRIVATE address on + // a second lookup (a rebind), so if the probe re-resolved the name instead of + // pinning, it would reach 10.0.0.5. Asserting the real probe's curl argv + // carries --resolve ::93.184.216.34 proves the connection is + // pinned to the validated public IP and cannot be rebound. + vi.stubEnv("NEMOCLAW_REASONING", "yes"); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-pin-orchestration-")); + const fakeBin = path.join(tmpDir, "bin"); + const argsPath = path.join(tmpDir, "args.txt"); + fs.mkdirSync(fakeBin, { recursive: true }); + fs.writeFileSync( + path.join(fakeBin, "curl"), + `#!/usr/bin/env bash +printf '%s\\n' "$@" > "${argsPath}" +outfile="" +while [ "$#" -gt 0 ]; do + case "$1" in + -o) outfile="$2"; shift 2 ;; + -w) shift 2 ;; + *) shift ;; + esac +done +if [ -n "$outfile" ]; then + cat <<'JSON' > "$outfile" +{"choices":[{"message":{"content":"OK"}}]} +JSON +fi +printf '200' +exit 0 +`, + { mode: 0o755 }, + ); + + let resolveCall = 0; + const resolveEndpointHost = vi.fn(async () => { + resolveCall += 1; + // First lookup (the preflight) returns a public address; a hypothetical + // second lookup would rebind to a private address. + return resolveCall === 1 + ? [{ address: "93.184.216.34", family: 4 }] + : [{ address: "10.0.0.5", family: 4 }]; + }); + + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + const originalPath = process.env.PATH; + process.env.PATH = `${fakeBin}:${originalPath || ""}`; + const helpers = createInferenceSelectionValidationHelpers({ + isNonInteractive: () => false, + agentProductName: () => "OpenClaw", + getCredential: () => "test-key", + // Use the real probeOpenAiLikeEndpoint (no injection) so the full + // preflight → pinnedAddresses → curl --resolve chain is exercised. + promptValidationRecovery: vi.fn(async () => "selection" as const), + resolveEndpointHost, + }); + + try { + await expect( + helpers.validateCustomOpenAiLikeSelection( + "Custom endpoint", + "https://public-name.example/v1", + "model-a", + "COMPATIBLE_API_KEY", + ), + ).resolves.toEqual({ + ok: true, + api: "openai-completions", + pinnedAddresses: ["93.184.216.34"], + }); + + const recordedArgs = fs.readFileSync(argsPath, "utf8").split("\n"); + const resolveIdx = recordedArgs.indexOf("--resolve"); + expect(resolveIdx).toBeGreaterThanOrEqual(0); + expect(recordedArgs[resolveIdx + 1]).toBe("public-name.example:443:93.184.216.34"); + // The rebound private address must never appear in the pin. + expect(recordedArgs.join("\n")).not.toContain("10.0.0.5"); + } finally { + process.env.PATH = originalPath; + log.mockRestore(); + vi.unstubAllEnvs(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + it("keeps rejecting malformed native Anthropic streams for OpenClaw (#6289)", async () => { const probeAnthropicEndpoint = vi.fn(() => ({ ok: false, @@ -232,6 +463,7 @@ describe("inference selection validation", () => { getCredential: () => "test-key", probeAnthropicEndpoint, promptValidationRecovery, + resolveEndpointHost: async () => [{ address: "93.184.216.34", family: 4 }], }); try { diff --git a/src/lib/onboard/inference-selection-validation.ts b/src/lib/onboard/inference-selection-validation.ts index b9e032a428d..07937a9e866 100644 --- a/src/lib/onboard/inference-selection-validation.ts +++ b/src/lib/onboard/inference-selection-validation.ts @@ -10,7 +10,7 @@ const { probeAnthropicEndpoint, probeOpenAiLikeEndpoint } = endpointUrl: string, model: string, apiKey: string | null | undefined, - options?: { probeStreaming?: boolean }, + options?: { probeStreaming?: boolean; pinnedAddresses?: readonly string[] }, ): any; probeOpenAiLikeEndpoint( endpointUrl: string, @@ -20,13 +20,23 @@ const { probeAnthropicEndpoint, probeOpenAiLikeEndpoint } = ): any; }; +import { + assertEndpointResolvesPublic, + type EndpointDnsLookupFn, +} from "../inference/endpoint-ssrf-preflight"; import { shouldForceCompletionsApi } from "../validation"; import { getProbeRecovery } from "../validation-recovery"; import { summarizeProbeForDisplay } from "./probe-diagnostics"; import { normalizeReasoningFlag } from "./reasoning-mode"; export type EndpointValidationResult = - | { ok: true; api: string | null; retry?: undefined } + | { + ok: true; + api: string | null; + retry?: undefined; + /** Public addresses approved for this custom endpoint's host probes. */ + pinnedAddresses?: string[]; + } | { ok: false; retry: "credential" | "selection" | "retry" | "model"; api?: undefined }; export interface InferenceSelectionValidationDeps { @@ -35,6 +45,8 @@ export interface InferenceSelectionValidationDeps { getCredential?: typeof getCredential; probeAnthropicEndpoint?: typeof probeAnthropicEndpoint; probeOpenAiLikeEndpoint?: typeof probeOpenAiLikeEndpoint; + /** Injectable DNS resolver for the custom-endpoint SSRF preflight (tests). */ + resolveEndpointHost?: EndpointDnsLookupFn; promptValidationRecovery( label: string, recovery: ReturnType, @@ -109,6 +121,58 @@ export function createInferenceSelectionValidationHelpers( console.error(" Validation details were omitted to avoid exposing credentials."); } + // DNS-backed SSRF preflight for user-supplied custom endpoints. Resolves the + // endpoint host and fails closed before any host-side probe curl when it (or + // a resolved address) is private/reserved, so a public-looking name that + // resolves to loopback/link-local/RFC1918 cannot reach internal services + // during privileged onboarding. Returns a fail-closed EndpointValidationResult + // to short-circuit the caller, or null when the endpoint is safe to probe. + // See PR #6293 PRA-4. + async function preflightCustomEndpointOrFail( + label: string, + endpointUrl: string, + credentialEnv: string | null, + helpUrl: string | null, + ): Promise<{ blocked: EndpointValidationResult } | { pinnedAddresses?: string[] }> { + // Always run the SSRF preflight. It defaults to the real dns/promises + // resolver; tests inject deps.resolveEndpointHost. No env-gated bypass — an + // ambient VITEST flag must never disable SSRF enforcement (cv review, #6293). + const preflight = await assertEndpointResolvesPublic(endpointUrl, deps.resolveEndpointHost); + // On success, carry the validated address set forward so the probe pins its + // connection (curl --resolve) to a checked address; a second DNS lookup at + // the probe could otherwise rebind to a private/internal address after this + // public preflight (TOCTOU — cv review, #6293). + if (preflight.ok) return { pinnedAddresses: preflight.addresses }; + const syntheticProbe = { + ok: false as const, + message: preflight.reason, + failures: [ + { + name: "SSRF preflight", + httpStatus: 0, + curlStatus: 0, + message: preflight.reason ?? "endpoint resolves to a private/internal address", + body: "", + }, + ], + }; + printValidationFailure(label, syntheticProbe); + if (deps.isNonInteractive()) { + exitNonInteractiveValidationFailure(); + } + const retry = await deps.promptValidationRecovery( + label, + getProbeRecovery(syntheticProbe), + credentialEnv, + helpUrl, + ); + if (retry === "selection") { + console.log(" Please choose a provider/model again."); + console.log(""); + } + return { blocked: { ok: false, retry } }; + } + async function validateOpenAiLikeSelection( label: string, endpointUrl: string, @@ -190,6 +254,14 @@ export function createInferenceSelectionValidationHelpers( credentialEnv: string, helpUrl: string | null = null, ): Promise { + const preflight = await preflightCustomEndpointOrFail( + label, + endpointUrl, + credentialEnv, + helpUrl, + ); + if ("blocked" in preflight) return preflight.blocked; + const { pinnedAddresses } = preflight; const apiKey = resolveCredential(credentialEnv); const reasoningEnabled = normalizeReasoningFlag(process.env.NEMOCLAW_REASONING) === "true"; // Reasoning-only compatible endpoints often reject Responses, tool-call, and streaming probes. @@ -198,6 +270,7 @@ export function createInferenceSelectionValidationHelpers( skipResponsesProbe: reasoningEnabled || shouldForceCompletionsApi(process.env.NEMOCLAW_PREFERRED_API), probeStreaming: !reasoningEnabled, + pinnedAddresses, }); if (probe.ok) { if (probe.note) { @@ -207,7 +280,7 @@ export function createInferenceSelectionValidationHelpers( ` ${probe.label} available — ${deps.agentProductName()} will use ${probe.api}.`, ); } - return { ok: true, api: probe.api ?? "openai-completions" }; + return { ok: true, api: probe.api ?? "openai-completions", pinnedAddresses }; } printValidationFailure(label, probe); if (deps.isNonInteractive()) { @@ -236,6 +309,14 @@ export function createInferenceSelectionValidationHelpers( intendedApi?: "anthropic-messages" | "openai-completions"; } = {}, ): Promise { + const preflight = await preflightCustomEndpointOrFail( + label, + endpointUrl, + credentialEnv, + helpUrl, + ); + if ("blocked" in preflight) return preflight.blocked; + const { pinnedAddresses } = preflight; const apiKey = resolveCredential(credentialEnv); const reasoningEnabled = normalizeReasoningFlag(process.env.NEMOCLAW_REASONING) === "true"; const intendedApi = options.intendedApi ?? "anthropic-messages"; @@ -249,12 +330,13 @@ export function createInferenceSelectionValidationHelpers( getCompatibleAnthropicOpenAiSurfaceBaseUrl(endpointUrl), model, apiKey, - { skipResponsesProbe: true }, + { skipResponsesProbe: true, pinnedAddresses }, ) : runAnthropicProbe(endpointUrl, model, apiKey, { // Reasoning-only compatible endpoints often reject streaming probes, // so mirror the custom OpenAI-compatible path and skip streaming. probeStreaming: !reasoningEnabled, + pinnedAddresses, }); if (probe.ok) { if (probe.note) { @@ -264,7 +346,7 @@ export function createInferenceSelectionValidationHelpers( ` ${probe.label} available — ${deps.agentProductName()} will use ${intendedApi}.`, ); } - return { ok: true, api: intendedApi }; + return { ok: true, api: intendedApi, pinnedAddresses }; } printValidationFailure(label, probe); if (deps.isNonInteractive()) { diff --git a/src/lib/onboard/machine/handlers/provider-inference-context-window.test.ts b/src/lib/onboard/machine/handlers/provider-inference-context-window.test.ts new file mode 100644 index 00000000000..a53a799681e --- /dev/null +++ b/src/lib/onboard/machine/handlers/provider-inference-context-window.test.ts @@ -0,0 +1,74 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// Compatible-endpoint context-window regression cases for +// handleProviderInferenceState. Split out of provider-inference.test.ts so the +// primary handler spec stays within the growth guardrail (PR #6293 PRA-6). + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + applyCompatibleEndpointContextWindow, + resetCompatibleEndpointContextWindowAutoState, +} from "../../../inference/compatible-endpoint-context"; +import { handleProviderInferenceState } from "./provider-inference"; +import { baseOptions, baseSelection, createDeps } from "./provider-inference.test-support"; + +beforeEach(() => { + resetCompatibleEndpointContextWindowAutoState(); + delete process.env.NEMOCLAW_CONTEXT_WINDOW; +}); + +afterEach(() => { + delete process.env.NEMOCLAW_CONTEXT_WINDOW; + resetCompatibleEndpointContextWindowAutoState(); +}); + +describe("handleProviderInferenceState context window", () => { + it("clears a stale auto-detected compatible-endpoint context window before re-selecting (#6177)", async () => { + // Simulate an earlier compatible-endpoint pass auto-detecting a window. + await applyCompatibleEndpointContextWindow("https://endpoint-a.example/v1", "model-a", { + env: process.env, + fetchModels: () => ({ data: [{ id: "model-a", max_model_len: 65_536 }] }), + resolveHost: async () => [{ address: "93.184.216.34", family: 4 }], + }); + expect(process.env.NEMOCLAW_CONTEXT_WINDOW).toBe("65536"); + + // The next fresh provider-selection pass must clear it before selection, + // so Dockerfile patching for a different provider never sees the stale value. + const observed: Array = []; + const setupNim = vi.fn(async () => { + observed.push(process.env.NEMOCLAW_CONTEXT_WINDOW); + return { ...baseSelection }; + }); + const { deps } = createDeps({ setupNim }); + await handleProviderInferenceState(baseOptions(deps)); + + expect(setupNim).toHaveBeenCalledTimes(1); + expect(observed).toEqual([undefined]); + }); + + it("clears the stale auto-detected context window on the resume path too (#6293)", async () => { + // Simulate an earlier compatible-endpoint pass auto-detecting a window. + await applyCompatibleEndpointContextWindow("https://endpoint-a.example/v1", "model-a", { + env: process.env, + fetchModels: () => ({ data: [{ id: "model-a", max_model_len: 65_536 }] }), + resolveHost: async () => [{ address: "93.184.216.34", family: 4 }], + }); + expect(process.env.NEMOCLAW_CONTEXT_WINDOW).toBe("65536"); + + // PRA-3: the clear now runs at the top of the provider-selection loop, so a + // resume pass drops the stale auto value just like a fresh selection — it is + // no longer gated on the fresh-only branch. + const observed: Array = []; + const setupNim = vi.fn(async () => { + observed.push(process.env.NEMOCLAW_CONTEXT_WINDOW); + return { ...baseSelection }; + }); + const { deps } = createDeps({ setupNim }); + await handleProviderInferenceState({ ...baseOptions(deps), resume: true }); + + expect(process.env.NEMOCLAW_CONTEXT_WINDOW).toBeUndefined(); + expect(observed).toEqual([undefined]); + }); +}); diff --git a/src/lib/onboard/machine/handlers/provider-inference.test-support.ts b/src/lib/onboard/machine/handlers/provider-inference.test-support.ts new file mode 100644 index 00000000000..dbb9a294885 --- /dev/null +++ b/src/lib/onboard/machine/handlers/provider-inference.test-support.ts @@ -0,0 +1,178 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// Shared test scaffolding for handleProviderInferenceState specs. Extracted so +// the context-window regression cases can live in their own file without +// duplicating the deps/session factories (PR #6293 PRA-6). Not a *.test.ts +// file, so Vitest does not collect it as a suite. + +import { vi } from "vitest"; + +import type { + CurrentGatewayRouteCompatibilityCheck, + CurrentGatewayRouteDiscoveryPreflight, +} from "../../../inference/gateway-route-compatibility"; +import { createSession, type Session, type SessionUpdates } from "../../../state/onboard-session"; +import type { ProviderInferenceStateOptions, ProviderSelectionResult } from "./provider-inference"; + +export type Gpu = { type: string } | null; +export type Agent = { name: string; inference?: { provider_type?: string } } | null; +export type Host = { cpus?: number }; + +export const baseSelection: ProviderSelectionResult = { + model: "nvidia/test", + provider: "nvidia-prod", + endpointUrl: "https://integrate.api.nvidia.com/v1", + credentialEnv: "NVIDIA_INFERENCE_API_KEY", + hermesAuthMethod: null, + hermesToolGateways: [], + preferredInferenceApi: "openai-responses", + compatibleEndpointReasoning: null, + nimContainer: null, +}; + +export function createDeps( + overrides: Partial["deps"]> = {}, +) { + const calls = { + checkGatewayRouteCompatibility: vi.fn(() => ({ + ok: true, + })), + preflightGatewayRouteDiscovery: vi.fn(() => ({ + ok: true, + requiredModel: null, + requiredEndpointUrl: null, + requiredInferenceApi: null, + })), + setupNim: vi.fn(async () => ({ ...baseSelection })), + setupInference: vi.fn(async () => ({ ok: true as const })), + startStep: vi.fn(async () => undefined), + complete: vi.fn(async () => createSession()), + skipped: vi.fn(), + recoverProvider: vi.fn( + async ( + _gatewayName: string, + _provider: string | null | undefined, + credentialEnv: string | null | undefined, + ) => ({ + forceInferenceSetup: false, + credentialEnv: credentialEnv ?? null, + }), + ), + surfaceReady: vi.fn(() => true), + recordSkip: vi.fn(async () => createSession()), + repairEvent: vi.fn(async () => createSession()), + hydrate: vi.fn(), + repair: vi.fn(), + routeReady: vi.fn((_gatewayName: string, _provider: string, _model: string) => false), + reconcileRouter: vi.fn(async () => undefined), + reupsertRoutedProvider: vi.fn( + ( + _gatewayName: string, + _provider: string, + endpointUrl: string | null, + _credentialEnv: string | null, + ) => ({ + ok: true as const, + endpointUrl: "http://host.openshell.internal:4000/v1", + }), + ), + reserveRoute: vi.fn(() => true), + updateSandbox: vi.fn(), + promptName: vi.fn(async () => "my-assistant"), + promptYesNo: vi.fn(async () => true), + log: vi.fn(), + error: vi.fn(), + exit: vi.fn((code: number): never => { + throw new Error(`exit ${code}`); + }), + deleteEnv: vi.fn(), + }; + return { + calls, + deps: { + checkGatewayRouteCompatibility: calls.checkGatewayRouteCompatibility, + preflightGatewayRouteDiscovery: calls.preflightGatewayRouteDiscovery, + withGatewayRouteMutationLock: async ( + _gatewayName: string, + operation: () => Promise | T, + ) => await operation(), + normalizeHermesAuthMethod: (value: string | null | undefined) => + value === "oauth" || value === "api_key" ? value : null, + setupNim: calls.setupNim, + setupInference: calls.setupInference, + startRecordedStep: calls.startStep, + recordStepComplete: calls.complete, + toSessionUpdates: (updates: Record) => updates as SessionUpdates, + skippedStepMessage: calls.skipped, + ensureResumeProviderReady: calls.recoverProvider, + isResumeProviderSurfaceReady: calls.surfaceReady, + recordStateSkipped: calls.recordSkip, + recordRepairEvent: calls.repairEvent, + hydrateCredentialEnv: calls.hydrate, + configureCompatibleEndpointReasoning: async (value?: string | null) => + value === "true" ? "true" : "false", + clearCompatibleEndpointReasoning: () => null, + repairLocalInferenceSystemdOverrideOrExit: calls.repair, + isNonInteractive: () => true, + getOpenshellBinary: () => "/usr/bin/openshell", + needsBedrockRuntimeAdapter: () => false, + isInferenceRouteReady: calls.routeReady, + isRoutedInferenceProvider: (provider: string) => provider === "nvidia-router", + reconcileModelRouter: calls.reconcileRouter, + reupsertRoutedProvider: calls.reupsertRoutedProvider, + reserveSandboxInferenceRoute: calls.reserveRoute, + registryUpdateSandbox: calls.updateSandbox, + promptValidatedSandboxName: calls.promptName, + assessHost: () => ({ cpus: 8 }), + formatSandboxBuildEstimateNote: () => "estimate", + formatOnboardConfigSummary: (options: { + provider: string; + model: string; + sandboxName: string; + }) => `summary:${options.provider}/${options.model}/${options.sandboxName}`, + promptYesNoOrDefault: calls.promptYesNo, + cliName: () => "nemoclaw", + log: calls.log, + error: calls.error, + exitProcess: calls.exit, + deleteEnv: calls.deleteEnv, + ...overrides, + }, + }; +} + +export function baseOptions( + deps: ProviderInferenceStateOptions["deps"], + session: Session | null = createSession(), +): ProviderInferenceStateOptions { + return { + gatewayName: "nemoclaw", + resume: false, + fresh: false, + session, + gpu: { type: "nvidia" }, + sandboxName: null, + agent: null, + initial: { + model: session?.model ?? null, + provider: session?.provider ?? null, + endpointUrl: session?.endpointUrl ?? null, + credentialEnv: session?.credentialEnv ?? null, + hermesAuthMethod: session?.hermesAuthMethod ?? null, + hermesToolGateways: session?.hermesToolGateways ?? [], + preferredInferenceApi: session?.preferredInferenceApi ?? null, + compatibleEndpointReasoning: session?.compatibleEndpointReasoning ?? null, + nimContainer: session?.nimContainer ?? null, + webSearchConfig: session?.webSearchConfig ?? null, + }, + selectedMessagingChannels: [], + env: {}, + constants: { + hermesProviderName: "hermes-provider", + hermesApiKeyAuthMethod: "api_key", + hermesApiKeyCredentialEnv: "NOUS_API_KEY", + }, + deps, + }; +} diff --git a/src/lib/onboard/machine/handlers/provider-inference.test.ts b/src/lib/onboard/machine/handlers/provider-inference.test.ts index 99f6ee2a8e5..4c70a01e50f 100644 --- a/src/lib/onboard/machine/handlers/provider-inference.test.ts +++ b/src/lib/onboard/machine/handlers/provider-inference.test.ts @@ -7,180 +7,21 @@ import path from "node:path"; import { describe, expect, it, vi } from "vitest"; -import type { - CurrentGatewayRouteCompatibilityCheck, - CurrentGatewayRouteDiscoveryPreflight, -} from "../../../inference/gateway-route-compatibility"; -import { createSession, type Session, type SessionUpdates } from "../../../state/onboard-session"; +import { createSession } from "../../../state/onboard-session"; import { patchStagedDockerfile } from "../../dockerfile-patch"; import { clearCompatibleEndpointReasoning } from "../../reasoning-mode"; import { handleProviderInferenceState, type ProviderInferenceStateOptions, - type ProviderSelectionResult, } from "./provider-inference"; - -type Gpu = { type: string } | null; -type Agent = { name: string; inference?: { provider_type?: string } } | null; -type Host = { cpus?: number }; - -const baseSelection: ProviderSelectionResult = { - model: "nvidia/test", - provider: "nvidia-prod", - endpointUrl: "https://integrate.api.nvidia.com/v1", - credentialEnv: "NVIDIA_INFERENCE_API_KEY", - hermesAuthMethod: null, - hermesToolGateways: [], - preferredInferenceApi: "openai-responses", - compatibleEndpointReasoning: null, - nimContainer: null, -}; - -function createDeps( - overrides: Partial["deps"]> = {}, -) { - const calls = { - checkGatewayRouteCompatibility: vi.fn(() => ({ - ok: true, - })), - preflightGatewayRouteDiscovery: vi.fn(() => ({ - ok: true, - requiredModel: null, - requiredEndpointUrl: null, - requiredInferenceApi: null, - })), - setupNim: vi.fn(async () => ({ ...baseSelection })), - setupInference: vi.fn(async () => ({ ok: true as const })), - startStep: vi.fn(async () => undefined), - complete: vi.fn(async () => createSession()), - skipped: vi.fn(), - recoverProvider: vi.fn( - async ( - _gatewayName: string, - _provider: string | null | undefined, - credentialEnv: string | null | undefined, - ) => ({ - forceInferenceSetup: false, - credentialEnv: credentialEnv ?? null, - }), - ), - surfaceReady: vi.fn(() => true), - recordSkip: vi.fn(async () => createSession()), - repairEvent: vi.fn(async () => createSession()), - hydrate: vi.fn(), - repair: vi.fn(), - routeReady: vi.fn((_gatewayName: string, _provider: string, _model: string) => false), - reconcileRouter: vi.fn(async () => undefined), - reupsertRoutedProvider: vi.fn( - ( - _gatewayName: string, - _provider: string, - endpointUrl: string | null, - _credentialEnv: string | null, - ) => ({ - ok: true as const, - endpointUrl: "http://host.openshell.internal:4000/v1", - }), - ), - reserveRoute: vi.fn(() => true), - updateSandbox: vi.fn(), - promptName: vi.fn(async () => "my-assistant"), - promptYesNo: vi.fn(async () => true), - log: vi.fn(), - error: vi.fn(), - exit: vi.fn((code: number): never => { - throw new Error(`exit ${code}`); - }), - deleteEnv: vi.fn(), - }; - return { - calls, - deps: { - checkGatewayRouteCompatibility: calls.checkGatewayRouteCompatibility, - preflightGatewayRouteDiscovery: calls.preflightGatewayRouteDiscovery, - withGatewayRouteMutationLock: async ( - _gatewayName: string, - operation: () => Promise | T, - ) => await operation(), - normalizeHermesAuthMethod: (value: string | null | undefined) => - value === "oauth" || value === "api_key" ? value : null, - setupNim: calls.setupNim, - setupInference: calls.setupInference, - startRecordedStep: calls.startStep, - recordStepComplete: calls.complete, - toSessionUpdates: (updates: Record) => updates as SessionUpdates, - skippedStepMessage: calls.skipped, - ensureResumeProviderReady: calls.recoverProvider, - isResumeProviderSurfaceReady: calls.surfaceReady, - recordStateSkipped: calls.recordSkip, - recordRepairEvent: calls.repairEvent, - hydrateCredentialEnv: calls.hydrate, - configureCompatibleEndpointReasoning: async (value?: string | null) => - value === "true" ? "true" : "false", - clearCompatibleEndpointReasoning: () => null, - repairLocalInferenceSystemdOverrideOrExit: calls.repair, - isNonInteractive: () => true, - getOpenshellBinary: () => "/usr/bin/openshell", - needsBedrockRuntimeAdapter: () => false, - isInferenceRouteReady: calls.routeReady, - isRoutedInferenceProvider: (provider: string) => provider === "nvidia-router", - reconcileModelRouter: calls.reconcileRouter, - reupsertRoutedProvider: calls.reupsertRoutedProvider, - reserveSandboxInferenceRoute: calls.reserveRoute, - registryUpdateSandbox: calls.updateSandbox, - promptValidatedSandboxName: calls.promptName, - assessHost: () => ({ cpus: 8 }), - formatSandboxBuildEstimateNote: () => "estimate", - formatOnboardConfigSummary: (options: { - provider: string; - model: string; - sandboxName: string; - }) => `summary:${options.provider}/${options.model}/${options.sandboxName}`, - promptYesNoOrDefault: calls.promptYesNo, - cliName: () => "nemoclaw", - log: calls.log, - error: calls.error, - exitProcess: calls.exit, - deleteEnv: calls.deleteEnv, - ...overrides, - }, - }; -} - -function baseOptions( - deps: ProviderInferenceStateOptions["deps"], - session: Session | null = createSession(), -): ProviderInferenceStateOptions { - return { - gatewayName: "nemoclaw", - resume: false, - fresh: false, - session, - gpu: { type: "nvidia" }, - sandboxName: null, - agent: null, - initial: { - model: session?.model ?? null, - provider: session?.provider ?? null, - endpointUrl: session?.endpointUrl ?? null, - credentialEnv: session?.credentialEnv ?? null, - hermesAuthMethod: session?.hermesAuthMethod ?? null, - hermesToolGateways: session?.hermesToolGateways ?? [], - preferredInferenceApi: session?.preferredInferenceApi ?? null, - compatibleEndpointReasoning: session?.compatibleEndpointReasoning ?? null, - nimContainer: session?.nimContainer ?? null, - webSearchConfig: session?.webSearchConfig ?? null, - }, - selectedMessagingChannels: [], - env: {}, - constants: { - hermesProviderName: "hermes-provider", - hermesApiKeyAuthMethod: "api_key", - hermesApiKeyCredentialEnv: "NOUS_API_KEY", - }, - deps, - }; -} +import { + type Agent, + baseOptions, + baseSelection, + createDeps, + type Gpu, + type Host, +} from "./provider-inference.test-support"; describe("handleProviderInferenceState", () => { it("runs provider selection and inference setup on a fresh flow", async () => { diff --git a/src/lib/onboard/machine/handlers/provider-inference.ts b/src/lib/onboard/machine/handlers/provider-inference.ts index b6713f26999..37e1ec4020a 100644 --- a/src/lib/onboard/machine/handlers/provider-inference.ts +++ b/src/lib/onboard/machine/handlers/provider-inference.ts @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { clearAutoDetectedCompatibleContextWindow } from "../../../inference/compatible-endpoint-context"; import { resolveAgentProviderInferenceApi } from "../../../inference/config"; import type { CurrentGatewayRouteCompatibilityCheck, @@ -31,6 +32,8 @@ export interface ProviderInferenceSetupOptions { * compatible-anthropic-endpoint register type=openai). */ preferredInferenceApi?: string | null; + /** Public addresses approved for custom endpoint host probes. */ + endpointPinnedAddresses?: readonly string[]; } export interface ProviderSelectionResult { @@ -46,6 +49,7 @@ export interface ProviderSelectionResult { allowToolsIncompatible?: boolean; skipHostInferenceSmoke?: boolean; reuseGatewayCredentialWithoutLocalKey?: boolean; + endpointPinnedAddresses?: string[]; } export interface ProviderInferenceStateOptions { @@ -313,11 +317,19 @@ export async function handleProviderInferenceState({ let allowToolsIncompatible = false; let skipHostInferenceSmoke = false; let reuseGatewayCredentialWithoutLocalKey = false; + let endpointPinnedAddresses: string[] | undefined; const effectiveResume = resume && !fresh; const stateResults: OnboardStateTransitionResult[] = []; const retryStateResults: OnboardStateTransitionResult[] = []; while (true) { + // Drop a context window auto-detected by a prior compatible-endpoint pass + // before every provider-selection path — fresh, resume, and repair — so a + // retry to a different provider/endpoint cannot inherit endpoint A's probed + // max_model_len as a bogus user override. Only clears a value this process + // auto-detected, never a user override or a legitimately resumed window + // (#6177; resume/repair coverage per PR #6293 PRA-3). + clearAutoDetectedCompatibleContextWindow(process.env); let forceInferenceSetup = initialForceInferenceSetup; const resumeProviderSelection = !forceProviderSelection && @@ -465,6 +477,7 @@ export async function handleProviderInferenceState({ skipHostInferenceSmoke = selection.skipHostInferenceSmoke === true; reuseGatewayCredentialWithoutLocalKey = selection.reuseGatewayCredentialWithoutLocalKey === true; + endpointPinnedAddresses = selection.endpointPinnedAddresses; shouldRecordProviderSelection = true; } @@ -539,6 +552,7 @@ export async function handleProviderInferenceState({ ? { reuseGatewayCredentialWithoutLocalKey } : {}), ...(preferredInferenceApi ? { preferredInferenceApi } : {}), + ...(endpointPinnedAddresses ? { endpointPinnedAddresses } : {}), }; await deps.startRecordedStep("inference", { provider, model }); inferenceResult = await withInferenceTrace( @@ -723,6 +737,7 @@ export async function handleProviderInferenceState({ ...(skipHostInferenceSmoke ? { skipHostInferenceSmoke } : {}), ...(reuseGatewayCredentialWithoutLocalKey ? { reuseGatewayCredentialWithoutLocalKey } : {}), ...(preferredInferenceApi ? { preferredInferenceApi } : {}), + ...(endpointPinnedAddresses ? { endpointPinnedAddresses } : {}), }; await deps.startRecordedStep("inference", { provider, model }); inferenceResult = await withInferenceTrace( diff --git a/src/lib/onboard/setup-inference-route-containment.test.ts b/src/lib/onboard/setup-inference-route-containment.test.ts index 5a7d8e50adb..07b9c959a5a 100644 --- a/src/lib/onboard/setup-inference-route-containment.test.ts +++ b/src/lib/onboard/setup-inference-route-containment.test.ts @@ -7,6 +7,76 @@ import type { SandboxEntry } from "../state/registry"; import { createSetupInference, type SetupInferenceDeps } from "./setup-inference"; describe("onboard shared gateway route containment", () => { + it("preflights a resumed custom endpoint and pins the final host smoke (#6293)", async () => { + let lookupCount = 0; + const resolveEndpointHost = vi.fn(async () => { + lookupCount += 1; + return lookupCount === 1 + ? [{ address: "93.184.216.34", family: 4 }] + : [{ address: "10.0.0.8", family: 4 }]; + }); + const verifyOnboardInferenceSmoke = vi.fn(); + const setupInference = createSetupInference({ + checkGatewayRouteCompatibility: vi.fn(() => ({ ok: true as const })), + withSandboxMutationLock: async (_name: string, operation: () => Promise | T) => + await operation(), + withGatewayRouteMutationLock: async (_name: string, operation: () => Promise | T) => + await operation(), + step: vi.fn(), + getGatewayName: () => "nemoclaw", + runOpenshell: vi.fn(() => ({ status: 0, stdout: "", stderr: "" })), + updateSandbox: vi.fn(() => true), + upsertProvider: vi.fn(() => ({ ok: true })), + verifyInferenceRoute: vi.fn(), + verifyOnboardInferenceSmoke, + resolveEndpointHost, + isNonInteractive: () => true, + hermesProviderAuth: { HERMES_PROVIDER_NAME: "hermes-provider" }, + REMOTE_PROVIDER_CONFIG: { + custom: { + label: "Other OpenAI-compatible endpoint", + providerName: "compatible-endpoint", + providerType: "openai", + credentialEnv: "COMPATIBLE_API_KEY", + endpointUrl: "https://public-name.example/v1", + helpUrl: null, + modelMode: "input", + defaultModel: "model-a", + }, + }, + hydrateCredentialEnv: vi.fn(() => "secret"), + promptValidationRecovery: vi.fn(), + classifyApplyFailure: vi.fn(), + localInferenceTimeoutSecs: 60, + bedrockRuntimeOnboard: { + setupBedrockRuntimeInference: vi.fn(async () => ({ handled: false as const })), + }, + redact: (value: string) => value, + compactText: (value: string) => value, + log: vi.fn(), + error: vi.fn(), + exitProcess: vi.fn((code: number): never => { + throw new Error(`exit ${code}`); + }), + } as unknown as SetupInferenceDeps); + + await expect( + setupInference( + "sandbox-a", + "model-a", + "compatible-endpoint", + "https://public-name.example/v1", + "COMPATIBLE_API_KEY", + ), + ).resolves.toEqual({ ok: true }); + + expect(resolveEndpointHost).toHaveBeenCalledOnce(); + expect(verifyOnboardInferenceSmoke).toHaveBeenCalledWith( + expect.objectContaining({ pinnedAddresses: ["93.184.216.34"] }), + ); + expect(JSON.stringify(verifyOnboardInferenceSmoke.mock.calls)).not.toContain("10.0.0.8"); + }); + it("rejects a conflict before selecting the gateway or mutating provider state (#6315)", async () => { const events: string[] = []; const runOpenshell = vi.fn(() => { diff --git a/src/lib/onboard/setup-inference.ts b/src/lib/onboard/setup-inference.ts index 19928c41ced..cd0c3cb2e17 100644 --- a/src/lib/onboard/setup-inference.ts +++ b/src/lib/onboard/setup-inference.ts @@ -1,6 +1,11 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { isBedrockRuntimeEndpoint } from "../inference/bedrock-runtime"; +import { + assertEndpointResolvesPublic, + type EndpointDnsLookupFn, +} from "../inference/endpoint-ssrf-preflight"; import { type CurrentGatewayRouteCompatibilityCheck, formatGatewayRouteConflict, @@ -72,6 +77,8 @@ type ProviderBranchDeps = Pick< Pick; export type SetupInferenceDeps = ProviderBranchDeps & { + /** Injectable resolver for resumed custom-endpoint SSRF preflight tests. */ + resolveEndpointHost?: EndpointDnsLookupFn; checkGatewayRouteCompatibility: CurrentGatewayRouteCompatibilityCheck; withGatewayRouteMutationLock: typeof withGatewayRouteMutationLock; withSandboxMutationLock: typeof withSandboxMutationLock; @@ -235,6 +242,32 @@ export function createSetupInference( return deps.exitProcess(1); } deps.step(4, 8, "Setting up inference provider"); + let endpointPinnedAddresses = options.endpointPinnedAddresses; + // Strictly classified AWS Bedrock Runtime hostnames use the dedicated + // SigV4/bearer adapter rather than the generic curl probe path. Their + // hostname is constrained to AWS-owned suffixes by the classifier, so + // do not apply the custom-origin curl pinning contract here. + const usesBedrockRuntimeAdapter = + provider === "compatible-anthropic-endpoint" && isBedrockRuntimeEndpoint(endpointUrl); + if ( + (provider === "compatible-endpoint" || provider === "compatible-anthropic-endpoint") && + endpointUrl && + !usesBedrockRuntimeAdapter && + !endpointPinnedAddresses + ) { + const preflight = await assertEndpointResolvesPublic( + endpointUrl, + deps.resolveEndpointHost, + ); + if (!preflight.ok) { + deps.error( + ` Endpoint SSRF preflight failed: ${preflight.reason ?? "endpoint is not safe to probe"}`, + ); + if (deps.isNonInteractive()) return deps.exitProcess(1); + return { retry: "selection" }; + } + endpointPinnedAddresses = preflight.addresses; + } const runGatewayOpenshell = createGatewayScopedOpenshellRunner( deps.runOpenshell, gatewayName, @@ -261,7 +294,13 @@ export function createSetupInference( if (sandboxName) reserveRoute(sandboxName, selectedProvider, selectedModel); deps.verifyInferenceRoute(gatewayName, selectedProvider, selectedModel); }, - verifyOnboardInferenceSmoke: deps.verifyOnboardInferenceSmoke, + verifyOnboardInferenceSmoke: ( + input: Parameters[0], + ) => + deps.verifyOnboardInferenceSmoke({ + ...input, + pinnedAddresses: endpointPinnedAddresses, + }), isNonInteractive: deps.isNonInteractive, registry: { updateSandbox: (name: string) => reserveRoute(name, provider, model), @@ -312,6 +351,7 @@ export function createSetupInference( reuseGatewayCredentialWithoutLocalKey: options.reuseGatewayCredentialWithoutLocalKey === true, preferredInferenceApi: options.preferredInferenceApi ?? null, + pinnedAddresses: endpointPinnedAddresses, }, { ...commonDeps, @@ -389,7 +429,14 @@ export function createSetupInference( commonDeps.verifyInferenceRoute(provider, model); if (options.skipHostInferenceSmoke === true) deps.log(" Reusing existing gateway credential; skipping host inference smoke."); - else deps.verifyOnboardInferenceSmoke({ provider, model, endpointUrl, credentialEnv }); + else + deps.verifyOnboardInferenceSmoke({ + provider, + model, + endpointUrl, + credentialEnv, + pinnedAddresses: endpointPinnedAddresses, + }); if (sandboxName) { commonDeps.registry.updateSandbox(sandboxName); } diff --git a/src/lib/onboard/setup-nim-flow.ts b/src/lib/onboard/setup-nim-flow.ts index a3e46bd2591..58cfee9611f 100644 --- a/src/lib/onboard/setup-nim-flow.ts +++ b/src/lib/onboard/setup-nim-flow.ts @@ -336,6 +336,7 @@ export function createSetupNim( let compatibleEndpointReasoning: string | null = null; let allowToolsIncompatible = false; let reuseGatewayCredential = false; + let endpointPinnedAddresses: string[] | undefined; const nvidiaFeaturedModels = deps.createNvidiaFeaturedModelSession({ defaultModel: resolveAgentDefaultCloudModel(agent), writeLine: deps.log, @@ -352,6 +353,7 @@ export function createSetupNim( compatibleEndpointReasoning, nimContainer, allowToolsIncompatible, + ...(endpointPinnedAddresses ? { endpointPinnedAddresses } : {}), nvidiaFeaturedModels, }; state.assertRouteCompatible = () => { @@ -536,6 +538,7 @@ export function createSetupNim( hermesToolGateways, preferredInferenceApi, allowToolsIncompatible, + endpointPinnedAddresses, } = state); compatibleEndpointReasoning = state.compatibleEndpointReasoning ?? null; reuseGatewayCredential = state.reuseGatewayCredentialWithoutLocalKey === true; @@ -713,6 +716,7 @@ export function createSetupNim( allowToolsIncompatible, skipHostInferenceSmoke: reuseGatewayCredential, reuseGatewayCredentialWithoutLocalKey: reuseGatewayCredential, + ...(endpointPinnedAddresses ? { endpointPinnedAddresses } : {}), }; }; } diff --git a/src/lib/onboard/setup-nim-selection.ts b/src/lib/onboard/setup-nim-selection.ts index 0ef6a01b45b..5fa532281f9 100644 --- a/src/lib/onboard/setup-nim-selection.ts +++ b/src/lib/onboard/setup-nim-selection.ts @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { applyCompatibleEndpointContextWindow } from "../inference/compatible-endpoint-context"; import type { GatewayRouteDiscoveryConstraints } from "../inference/gateway-route-compatibility"; import type { NvidiaFeaturedModelSession } from "./nvidia-featured-model-selection"; @@ -20,6 +21,8 @@ export type SetupNimSelectionState = { nimContainer: string | null; allowToolsIncompatible: boolean; skipHostInferenceSmoke?: boolean; + /** Public addresses approved for the selected custom endpoint. */ + endpointPinnedAddresses?: string[]; reuseGatewayCredentialWithoutLocalKey?: boolean; nvidiaFeaturedModels?: NvidiaFeaturedModelSession; /** Attempt-wide shared-gateway guard, invoked after identity selection and before probes. */ @@ -49,6 +52,7 @@ export function applyCloudFallbackSelection( state.allowToolsIncompatible = false; state.skipHostInferenceSmoke = false; state.reuseGatewayCredentialWithoutLocalKey = false; + delete state.endpointPinnedAddresses; } export function clearNimContainerBeforeRetry(state: SetupNimSelectionState): void { @@ -106,7 +110,7 @@ type ProbeOptions = { }; type ValidationResult = - | { ok: true; api: string | null; retry?: never } + | { ok: true; api: string | null; retry?: never; pinnedAddresses?: string[] } | { ok: false; api?: string; retry?: "credential" | "retry" | "model" | "selection" | string }; type RemoteModelValidationResult = "selected" | "retry-model" | "retry-selection"; @@ -194,6 +198,7 @@ export function createRemoteModelValidator(deps: RemoteModelValidatorDeps): { selectedCredentialEnv, intendedInferenceApi = "anthropic-messages", }) => { + delete state.endpointPinnedAddresses; const selectedModel = deps.requireValue( deps.isBackToSelection(state.model) ? null : state.model, `Missing model for ${remoteConfig.label}`, @@ -217,6 +222,17 @@ export function createRemoteModelValidator(deps: RemoteModelValidatorDeps): { remoteConfig.helpUrl, ); if (validation.ok) { + if (validation.pinnedAddresses) + state.endpointPinnedAddresses = validation.pinnedAddresses; + else delete state.endpointPinnedAddresses; + // Probe the endpoint's runtime max_model_len so a custom vLLM endpoint + // gets its real context window baked in instead of a small + // architecture default; an explicit override always wins (#6177). + await applyCompatibleEndpointContextWindow( + state.endpointUrl || deps.OPENAI_ENDPOINT_URL, + selectedModel, + { credentialEnv: selectedCredentialEnv }, + ); const explicitApi = (process.env.NEMOCLAW_PREFERRED_API || "").trim().toLowerCase(); if ( explicitApi && @@ -251,6 +267,9 @@ export function createRemoteModelValidator(deps: RemoteModelValidatorDeps): { { intendedApi }, ); if (validation.ok) { + if (validation.pinnedAddresses) + state.endpointPinnedAddresses = validation.pinnedAddresses; + else delete state.endpointPinnedAddresses; state.preferredInferenceApi = validation.api; return "selected"; } diff --git a/src/lib/private-networks.ts b/src/lib/private-networks.ts index d9e814de263..8679a28c642 100644 --- a/src/lib/private-networks.ts +++ b/src/lib/private-networks.ts @@ -190,3 +190,27 @@ export function isPrivateHostname(hostname: string): boolean { } return isPrivateIp(normalised); } + +/** + * 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. + * + * Loopback is a proper subset of what isPrivateHostname matches, but it is + * semantically distinct for SSRF purposes: a loopback address only ever reaches + * a service on the probing host itself, so it is not a pivot to other internal + * infrastructure the way LAN ranges (10/8, 192.168/16) or link-local metadata + * (169.254.169.254) are. Host-side onboarding probes for locally-run inference + * servers (Ollama on 127.0.0.1, vLLM on localhost) legitimately target it, so + * callers that must still refuse genuine private-network SSRF can exempt + * loopback specifically without weakening the LAN/metadata blocks. + */ +export function isLoopbackHostname(hostname: string): boolean { + const stripped = + hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname; + const normalised = stripped.replace(/\.$/, "").toLowerCase(); + if (normalised === "localhost") return true; + const family = isIP(normalised); + if (family === 4) return normalised.startsWith("127."); + if (family === 6) return normalised === "::1"; + return false; +} diff --git a/src/lib/shields/index.ts b/src/lib/shields/index.ts index 795c72706e6..7c0a5c2828a 100644 --- a/src/lib/shields/index.ts +++ b/src/lib/shields/index.ts @@ -1998,7 +1998,12 @@ function lockAgentConfigUnderMutationLock( const { issues } = verifyShieldsLockState(sandboxName, target, { verifyChattr: chattrSucceeded, - verifyParentProtection: target.agentName === "hermes" || openClawProtocol, + // A sealed Hermes transaction deliberately keeps /sandbox frozen as + // root:root 0755 until finish publishes the prepared sticky/group + // parent metadata. Verify the recursively locked tree while rollback is + // still available, then verify the parent after the final commit below. + verifyParentProtection: + (target.agentName === "hermes" && transaction === null) || openClawProtocol, exec: (cmd: string[]) => privilegedSandboxExecCapture(sandboxName, cmd), assertLegacyLayout: assertNoLegacyStateLayout, }); @@ -2007,6 +2012,16 @@ function lockAgentConfigUnderMutationLock( const fileHashes = captureSealHashes(sandboxName, filesToLock); if (transaction) { finishHermesConfigShields(sandboxName, target, transaction.token); + transaction = null; + const committed = verifyShieldsLockState(sandboxName, target, { + verifyChattr: chattrSucceeded, + verifyParentProtection: true, + exec: (cmd: string[]) => privilegedSandboxExecCapture(sandboxName, cmd), + assertLegacyLayout: assertNoLegacyStateLayout, + }); + if (committed.issues.length > 0) { + throw new Error(`Config not locked: ${committed.issues.join(", ")}`); + } } return { chattrApplied: chattrSucceeded, fileHashes }; } catch (error) { diff --git a/test/compatible-endpoint-context-probe.test.ts b/test/compatible-endpoint-context-probe.test.ts new file mode 100644 index 00000000000..6f69bbd05dc --- /dev/null +++ b/test/compatible-endpoint-context-probe.test.ts @@ -0,0 +1,164 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// Real-server proof for the compatible-endpoint context probe (#6177): a local +// OpenAI-compatible server (spawned as a subprocess so the synchronous curl +// probe cannot deadlock the event loop) advertises a runtime max_model_len on +// /v1/models, and the actual curl-backed probe reads it into +// NEMOCLAW_CONTEXT_WINDOW — the value onboarding bakes into the Hermes config. + +import { afterEach, describe, expect, it } from "vitest"; + +import { + applyCompatibleEndpointContextWindow, + fetchCompatibleEndpointModels, +} from "../src/lib/inference/compatible-endpoint-context"; +import { + type FakeOpenAiCompatibleServer, + startFakeOpenAiCompatibleServer, +} from "./e2e/fixtures/fake-openai-compatible"; +import { testTimeout } from "./helpers/timeouts"; + +const MODEL = "nvidia/NVIDIA-Nemotron-3-Super-120B-A12B-NVFP4"; + +// The fake server binds to loopback (127.0.0.1). Loopback is an allowed +// host-side probe target (a locally-run vLLM/Ollama endpoint), so these +// happy-path cases could use its URL directly; they present a routable public +// hostname to the guard and inject a fetcher to the loopback server to keep the +// remote-endpoint path exercised. Loopback probing against the real server is +// asserted by its own case below; non-loopback private-IP rejection is covered +// by the unit tests in src/lib/inference/compatible-endpoint-context.test.ts. +const PUBLIC_ENDPOINT_URL = "https://vllm.public.test/v1"; + +// The DNS SSRF preflight now runs unconditionally, so inject a clearly-public +// resolver for the public hostname while the injected fetcher targets the +// loopback fake server (#6293). +const RESOLVE_PUBLIC = async () => [{ address: "93.184.216.34", family: 4 }]; + +let server: FakeOpenAiCompatibleServer | null = null; + +function fetchFromServer(apiKey: string): () => unknown | null { + return () => + fetchCompatibleEndpointModels((server as FakeOpenAiCompatibleServer).baseUrl, apiKey); +} + +afterEach(async () => { + await server?.close(); + server = null; +}); + +describe("compatible-endpoint context probe against a real server (#6177)", { + timeout: testTimeout(60_000), +}, () => { + it("reads max_model_len from a live /v1/models endpoint into NEMOCLAW_CONTEXT_WINDOW (#6177)", async () => { + server = await startFakeOpenAiCompatibleServer({ model: MODEL, maxModelLen: 65_536 }); + + const models = fetchCompatibleEndpointModels(server.baseUrl, ""); + expect(models).toMatchObject({ data: [{ id: MODEL, max_model_len: 65_536 }] }); + + const env: NodeJS.ProcessEnv = {}; + await applyCompatibleEndpointContextWindow(PUBLIC_ENDPOINT_URL, MODEL, { + env, + fetchModels: fetchFromServer(""), + resolveHost: RESOLVE_PUBLIC, + }); + expect(env.NEMOCLAW_CONTEXT_WINDOW).toBe("65536"); + }); + + it("sends the endpoint credential through curl's --config auth flow (#6177)", async () => { + server = await startFakeOpenAiCompatibleServer({ + model: MODEL, + maxModelLen: 32_768, + apiKey: "secret-key", + }); + + const env: NodeJS.ProcessEnv = {}; + await applyCompatibleEndpointContextWindow(PUBLIC_ENDPOINT_URL, MODEL, { + env, + apiKey: "secret-key", + fetchModels: fetchFromServer("secret-key"), + resolveHost: RESOLVE_PUBLIC, + }); + + expect(env.NEMOCLAW_CONTEXT_WINDOW).toBe("32768"); + // The real curl probe transmitted an Authorization header built from the + // credential (via the temp --config file), proving the auth path works. + expect( + server.requests().some((entry) => entry.path === "/v1/models" && entry.authorizationSent), + ).toBe(true); + }); + + it("enforces auth on /v1/models: sets the window with the key, skips it without (#6177)", async () => { + server = await startFakeOpenAiCompatibleServer({ + model: MODEL, + maxModelLen: 65_536, + apiKey: "secret-key", + requireAuthModels: true, + }); + + // Wrong/absent credential → the endpoint 401s → no window is set. + const noKeyEnv: NodeJS.ProcessEnv = {}; + await applyCompatibleEndpointContextWindow(PUBLIC_ENDPOINT_URL, MODEL, { + env: noKeyEnv, + apiKey: "", + fetchModels: fetchFromServer(""), + resolveHost: RESOLVE_PUBLIC, + }); + expect(noKeyEnv.NEMOCLAW_CONTEXT_WINDOW).toBeUndefined(); + // Assert the endpoint actually rejected the unauthenticated /v1/models + // request — an unset window alone could also come from a network failure. + expect( + server.requests().some((entry) => entry.path === "/v1/models" && entry.auth === "missing"), + ).toBe(true); + + // Correct credential → authorized → the window is read. + const keyedEnv: NodeJS.ProcessEnv = {}; + await applyCompatibleEndpointContextWindow(PUBLIC_ENDPOINT_URL, MODEL, { + env: keyedEnv, + apiKey: "secret-key", + fetchModels: fetchFromServer("secret-key"), + resolveHost: RESOLVE_PUBLIC, + }); + expect(keyedEnv.NEMOCLAW_CONTEXT_WINDOW).toBe("65536"); + expect( + server.requests().some((entry) => entry.path === "/v1/models" && entry.auth === "ok"), + ).toBe(true); + }); + + it("probes a real loopback endpoint and propagates its max_model_len (#6293)", async () => { + // The fake server binds to 127.0.0.1 — a loopback address. A locally-run + // vLLM/Ollama custom endpoint is legitimately reached host-side on loopback, + // so the source-boundary guard exempts loopback (mirroring the chat probe) + // and the real curl fetcher must run and propagate the window. Non-loopback + // private targets stay blocked — see the unit-test rejection cases. + server = await startFakeOpenAiCompatibleServer({ model: MODEL, maxModelLen: 65_536 }); + expect(new URL(server.baseUrl).hostname).toBe("127.0.0.1"); + const modelsRequestsBefore = server + .requests() + .filter((entry) => entry.path === "/v1/models").length; + + const env: NodeJS.ProcessEnv = {}; + await applyCompatibleEndpointContextWindow(server.baseUrl, MODEL, { + env, + fetchModels: fetchCompatibleEndpointModels, + }); + + const modelsRequestsAfter = server + .requests() + .filter((entry) => entry.path === "/v1/models").length; + expect(env.NEMOCLAW_CONTEXT_WINDOW).toBe("65536"); + expect(modelsRequestsAfter).toBeGreaterThan(modelsRequestsBefore); + }); + + it("keeps the default context window when the endpoint omits max_model_len (#6177)", async () => { + server = await startFakeOpenAiCompatibleServer({ model: MODEL }); + + const env: NodeJS.ProcessEnv = {}; + await applyCompatibleEndpointContextWindow(PUBLIC_ENDPOINT_URL, MODEL, { + env, + fetchModels: fetchFromServer(""), + resolveHost: RESOLVE_PUBLIC, + }); + expect(env.NEMOCLAW_CONTEXT_WINDOW).toBeUndefined(); + }); +}); diff --git a/test/e2e/fixtures/fake-openai-compatible.ts b/test/e2e/fixtures/fake-openai-compatible.ts index 8bf4c622390..d08266ef420 100644 --- a/test/e2e/fixtures/fake-openai-compatible.ts +++ b/test/e2e/fixtures/fake-openai-compatible.ts @@ -15,6 +15,7 @@ export interface FakeOpenAiCompatibleRequest { readonly path: string; readonly bodyBytes: number; readonly auth?: string; + readonly authorizationSent?: boolean; readonly model?: string; readonly stream?: boolean; readonly forbiddenMarkerMatches?: number; @@ -33,10 +34,12 @@ export interface FakeOpenAiCompatibleServerOptions { readonly chatContent?: string; readonly forbiddenMarkers?: readonly string[]; readonly host?: string; + readonly maxModelLen?: number; readonly model?: string; readonly port?: number; readonly publicHost?: string; readonly requireAuth?: boolean; + readonly requireAuthModels?: boolean; readonly responseText?: string; } @@ -77,7 +80,9 @@ function canReachModels(host: string, port: number): Promise { }, (res) => { res.resume(); - resolve(res.statusCode === 200); + // 401 still means the server is up — it just enforces auth on + // /v1/models (requireAuthModels), which the readiness probe omits. + resolve(res.statusCode === 200 || res.statusCode === 401); }, ); req.on("error", () => resolve(false)); @@ -129,11 +134,14 @@ export async function startFakeOpenAiCompatibleServer( NEMOCLAW_FAKE_OPENAI_FORBIDDEN_MARKERS: JSON.stringify(options.forbiddenMarkers ?? []), NEMOCLAW_FAKE_OPENAI_HOST: host, NEMOCLAW_FAKE_OPENAI_LOG_FILE: logFile, + NEMOCLAW_FAKE_OPENAI_MAX_MODEL_LEN: + options.maxModelLen !== undefined ? String(options.maxModelLen) : "", NEMOCLAW_FAKE_OPENAI_MODEL: options.model ?? "test-model", NEMOCLAW_FAKE_OPENAI_PORT: String(options.port ?? 0), NEMOCLAW_FAKE_OPENAI_PORT_FILE: portFile, NEMOCLAW_FAKE_OPENAI_REQUESTS_FILE: requestsFile, NEMOCLAW_FAKE_OPENAI_REQUIRE_AUTH: options.requireAuth ? "1" : "0", + NEMOCLAW_FAKE_OPENAI_REQUIRE_AUTH_MODELS: options.requireAuthModels ? "1" : "0", NEMOCLAW_FAKE_OPENAI_RESPONSE_TEXT: options.responseText ?? options.chatContent ?? "ok", }, stdio: "ignore", diff --git a/test/e2e/lib/fake-openai-compatible-api.mts b/test/e2e/lib/fake-openai-compatible-api.mts index 454b96142dd..4378c92dec8 100755 --- a/test/e2e/lib/fake-openai-compatible-api.mts +++ b/test/e2e/lib/fake-openai-compatible-api.mts @@ -13,8 +13,18 @@ const portFile = process.env.NEMOCLAW_FAKE_OPENAI_PORT_FILE || ""; const logFile = process.env.NEMOCLAW_FAKE_OPENAI_LOG_FILE || ""; const requestsFile = process.env.NEMOCLAW_FAKE_OPENAI_REQUESTS_FILE || ""; const model = process.env.NEMOCLAW_FAKE_OPENAI_MODEL || "test-model"; +// Optional runtime context window advertised on /v1/models, mirroring vLLM's +// max_model_len so onboarding can probe a real endpoint's context (#6177). +const maxModelLen = (() => { + const raw = (process.env.NEMOCLAW_FAKE_OPENAI_MAX_MODEL_LEN || "").trim(); + return /^[1-9][0-9]*$/.test(raw) ? Number(raw) : null; +})(); const apiKey = process.env.NEMOCLAW_FAKE_OPENAI_API_KEY || ""; const requireAuth = process.env.NEMOCLAW_FAKE_OPENAI_REQUIRE_AUTH === "1"; +// Opt-in auth enforcement on GET /v1/models specifically (real vLLM launched +// with --api-key gates it). Separate from requireAuth so existing tests, whose +// readiness probe hits /v1/models unauthenticated, keep working. See #6177. +const requireAuthModels = process.env.NEMOCLAW_FAKE_OPENAI_REQUIRE_AUTH_MODELS === "1"; const chatContent = process.env.NEMOCLAW_FAKE_OPENAI_CHAT_CONTENT || "ok"; const responseText = process.env.NEMOCLAW_FAKE_OPENAI_RESPONSE_TEXT || chatContent; const forbiddenMarkers = (() => { @@ -122,14 +132,25 @@ const server = createServer(async (req, res) => { const path = requestPath(req); if (req.method === "GET" && ["/v1/models", "/models"].includes(path)) { - log(`GET ${path}`); + const modelsAuthOk = !requireAuthModels || req.headers.authorization === `Bearer ${apiKey}`; + log(`GET ${path} auth=${modelsAuthOk ? "ok" : "missing"}`); recordRequest({ method: "GET", path, bodyBytes: 0, + auth: modelsAuthOk ? "ok" : "missing", + // Presence only (never the token) so callers can prove a probe sent its + // credential without leaking it into the requests log (#6177). + authorizationSent: Boolean(req.headers.authorization), forbiddenMarkerMatches: forbiddenMarkerMatches(req, Buffer.alloc(0)), }); - sendJson(res, 200, { object: "list", data: [{ id: model, object: "model" }] }); + if (!modelsAuthOk) { + sendJson(res, 401, { error: { message: "missing bearer credential" } }); + return; + } + const modelEntry: JsonObject = { id: model, object: "model" }; + if (maxModelLen !== null) modelEntry.max_model_len = maxModelLen; + sendJson(res, 200, { object: "list", data: [modelEntry] }); return; } @@ -141,6 +162,8 @@ const server = createServer(async (req, res) => { path, bodyBytes: raw.length, auth, + // Presence only (never the token), matching the models request record. + authorizationSent: Boolean(req.headers.authorization), model: payload.model, stream: Boolean(payload.stream), forbiddenMarkerMatches: forbiddenMarkerMatches(req, raw), diff --git a/test/e2e/lib/hermetic-compatible-inference.sh b/test/e2e/lib/hermetic-compatible-inference.sh index c3f5d7137ef..b63482b2b3e 100755 --- a/test/e2e/lib/hermetic-compatible-inference.sh +++ b/test/e2e/lib/hermetic-compatible-inference.sh @@ -10,31 +10,6 @@ # shellcheck source=test/e2e/lib/openai-compatible-api-proof.sh . "$(dirname "${BASH_SOURCE[0]}")/openai-compatible-api-proof.sh" -nemoclaw_e2e_host_ip_for_sandbox() { - local ip_addr - if command -v ip >/dev/null 2>&1; then - ip_addr="$(ip route get 1.1.1.1 2>/dev/null | awk '{for (i=1;i<=NF;i++) if ($i=="src") {print $(i+1); exit}}')" - if [ -n "$ip_addr" ]; then - echo "$ip_addr" - return - fi - fi - - if command -v hostname >/dev/null 2>&1; then - for ip_addr in $(hostname -I 2>/dev/null); do - case "$ip_addr" in - 127.* | ::1) ;; - *) - echo "$ip_addr" - return - ;; - esac - done - fi - - echo "127.0.0.1" -} - nemoclaw_e2e_start_hermetic_compatible_inference() { local fake_key fake_key="${NEMOCLAW_E2E_COMPATIBLE_API_KEY:-e2e-compatible-key}" @@ -44,7 +19,7 @@ nemoclaw_e2e_start_hermetic_compatible_inference() { export FAKE_OPENAI_PORT="${FAKE_OPENAI_PORT:-0}" export FAKE_OPENAI_HOST="${FAKE_OPENAI_HOST:-0.0.0.0}" export FAKE_OPENAI_READY_HOST="${FAKE_OPENAI_READY_HOST:-127.0.0.1}" - export FAKE_OPENAI_PUBLIC_HOST="${FAKE_OPENAI_PUBLIC_HOST:-$(nemoclaw_e2e_host_ip_for_sandbox)}" + export FAKE_OPENAI_PUBLIC_HOST="${FAKE_OPENAI_PUBLIC_HOST:-host.openshell.internal}" export FAKE_OPENAI_MODEL="${FAKE_OPENAI_MODEL:-${NEMOCLAW_E2E_COMPATIBLE_MODEL:-test-model}}" export FAKE_OPENAI_API_KEY="$fake_key" export FAKE_OPENAI_REQUIRE_AUTH=1 diff --git a/test/e2e/live/bedrock-runtime-compatible-anthropic.test.ts b/test/e2e/live/bedrock-runtime-compatible-anthropic.test.ts index 448b48fbcd9..cb04a6440bb 100644 --- a/test/e2e/live/bedrock-runtime-compatible-anthropic.test.ts +++ b/test/e2e/live/bedrock-runtime-compatible-anthropic.test.ts @@ -47,9 +47,9 @@ const require = createRequire(import.meta.url); const DIST_ENTRYPOINT = CLI_DIST_ENTRYPOINT; const BEDROCK_HOSTNAME = "bedrock-runtime.us-east-1.amazonaws.com"; -const BEDROCK_MOCK_PORT = Number(process.env.NEMOCLAW_BEDROCK_RUNTIME_MOCK_PORT ?? "18147"); +const BEDROCK_MOCK_PORT = 18147; const BEDROCK_ADAPTER_PORT = 11436; -const BEDROCK_ENDPOINT_URL = `http://${BEDROCK_HOSTNAME}:${BEDROCK_MOCK_PORT}`; +const BEDROCK_ENDPOINT_URL = `https://${BEDROCK_HOSTNAME}`; const BEDROCK_MODEL = process.env.NEMOCLAW_BEDROCK_RUNTIME_MODEL ?? "anthropic.claude-3-5-sonnet-20240620-v1:0"; const COMPATIBLE_KEY = @@ -131,7 +131,7 @@ function testEnv(home: string, extra: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv return testHomeEnvironment(home, extra); } -function onboardEnv(home: string, agent: AgentName): NodeJS.ProcessEnv { +function onboardEnv(home: string, agent: AgentName, caCertPath: string): NodeJS.ProcessEnv { return testEnv(home, { COMPATIBLE_ANTHROPIC_API_KEY: COMPATIBLE_KEY, NEMOCLAW_AGENT: agent, @@ -143,9 +143,47 @@ function onboardEnv(home: string, agent: AgentName): NodeJS.ProcessEnv { NEMOCLAW_RECREATE_SANDBOX: "1", NEMOCLAW_SANDBOX_NAME: SANDBOX_NAME, NEMOCLAW_YES: "1", + NODE_EXTRA_CA_CERTS: caCertPath, }); } +function createBedrockTlsFixture(home: string): { cert: Buffer; key: Buffer; certPath: string } { + const tlsDir = path.join(home, "bedrock-runtime-tls"); + const certPath = path.join(tlsDir, "cert.pem"); + const keyPath = path.join(tlsDir, "key.pem"); + fs.mkdirSync(tlsDir, { recursive: true }); + const generated = spawnSync( + "openssl", + [ + "req", + "-x509", + "-newkey", + "rsa:2048", + "-keyout", + keyPath, + "-out", + certPath, + "-days", + "1", + "-nodes", + "-subj", + `/CN=${BEDROCK_HOSTNAME}`, + "-addext", + `subjectAltName=DNS:${BEDROCK_HOSTNAME}`, + ], + { encoding: "utf8" }, + ); + expect( + generated.status, + `failed to generate Bedrock TLS fixture: ${generated.stderr || generated.error}`, + ).toBe(0); + return { + cert: fs.readFileSync(certPath), + key: fs.readFileSync(keyPath), + certPath, + }; +} + function redactedCommand(command: readonly string[], values: readonly string[]): string[] { return command.map((part) => redactString(part, values)); } @@ -347,6 +385,7 @@ async function startFakeBedrockRuntimeMock(options: { port: number; expectedBearer: string; expectedModel: string; + tls: { cert: Buffer; key: Buffer }; }): Promise { const codec = loadEventStreamCodec(); const logs: string[] = []; @@ -355,7 +394,7 @@ async function startFakeBedrockRuntimeMock(options: { const record = (line: string): void => { logs.push(line); }; - const server = http2.createServer(); + const server = http2.createSecureServer(options.tls); const sessions = new Set(); server.on("session", (session) => { @@ -638,6 +677,63 @@ async function mapBedrockHostToLoopback( expectExitZero(probe, "Bedrock Runtime hostname maps to localhost"); } +const bedrockTlsRedirectArgs = [ + "-t", + "nat", + "OUTPUT", + "-p", + "tcp", + "-d", + "127.0.0.1", + "--dport", + "443", + "-j", + "REDIRECT", + "--to-ports", + String(BEDROCK_MOCK_PORT), +]; + +async function installBedrockTlsRedirect(host: HostCliClient, home: string): Promise { + expectExitZero( + await host.command( + "sudo", + [ + "-n", + "iptables", + ...bedrockTlsRedirectArgs.slice(0, 2), + "-A", + ...bedrockTlsRedirectArgs.slice(2), + ], + { + artifactName: "install-bedrock-tls-port-redirect", + env: testEnv(home), + timeoutMs: 30_000, + }, + ), + "redirect canonical Bedrock TLS port to the unprivileged fake endpoint", + ); +} + +async function removeBedrockTlsRedirect(host: HostCliClient, home: string): Promise { + await bestEffort(() => + host.command( + "sudo", + [ + "-n", + "iptables", + ...bedrockTlsRedirectArgs.slice(0, 2), + "-D", + ...bedrockTlsRedirectArgs.slice(2), + ], + { + artifactName: "remove-bedrock-tls-port-redirect", + env: testEnv(home), + timeoutMs: 30_000, + }, + ), + ); +} + async function prepareSourceCliAndOpenShell(host: HostCliClient, home: string): Promise { expect( fs.existsSync(DIST_ENTRYPOINT), @@ -1224,6 +1320,9 @@ test("bedrock runtime compatible Anthropic endpoint routes through managed infer cleanup.add("restore /etc/hosts after Bedrock Runtime mapping", () => restoreHostsFile(host, hostsBackup, hostsBackupDir, home), ); + cleanup.add("remove Bedrock Runtime canonical TLS port redirect", () => + removeBedrockTlsRedirect(host, home), + ); cleanup.add("stop Bedrock Runtime adapter", () => stopBedrockAdapterBestEffort(home)); cleanup.add("stop fake Bedrock Runtime endpoint", async () => { if (mock) await mock.close(); @@ -1279,10 +1378,13 @@ test("bedrock runtime compatible Anthropic endpoint routes through managed infer await prepareSourceCliAndOpenShell(host, home); await mapBedrockHostToLoopback(host, home, hostsBackup, skip); + await installBedrockTlsRedirect(host, home); + const tls = createBedrockTlsFixture(home); mock = await startFakeBedrockRuntimeMock({ port: BEDROCK_MOCK_PORT, expectedBearer: COMPATIBLE_KEY, expectedModel: BEDROCK_MODEL, + tls, }); await cleanupSandboxState(host, home); @@ -1298,7 +1400,7 @@ test("bedrock runtime compatible Anthropic endpoint routes through managed infer { artifactName: `onboard-bedrock-runtime-${AGENT}`, artifacts, - env: onboardEnv(home, AGENT), + env: onboardEnv(home, AGENT, tls.certPath), redactionValues: [COMPATIBLE_KEY], timeoutMs: ONBOARD_TIMEOUT_MS, }, diff --git a/test/e2e/live/hermes-gpu-startup.test.ts b/test/e2e/live/hermes-gpu-startup.test.ts index 02758b507d2..7e1daede960 100644 --- a/test/e2e/live/hermes-gpu-startup.test.ts +++ b/test/e2e/live/hermes-gpu-startup.test.ts @@ -8,6 +8,7 @@ import { type HostCliClient, resultText, type SandboxClient, + trustedSandboxShellScript, validateSandboxName, } from "../fixtures/clients/index.ts"; import { expect, test } from "../fixtures/e2e-test.ts"; @@ -190,26 +191,7 @@ test("hermes-gpu-startup: selected OpenShell GPU route reaches stable Ready stat }); expect(dockerInfo.exitCode, resultText(dockerInfo)).toBe(0); - const hostAddressProbe = await host.command( - "bash", - [ - "-lc", - [ - 'ip_addr="$(ip route get 1.1.1.1 2>/dev/null | awk \'{for (i=1;i<=NF;i++) if ($i=="src") {print $(i+1); exit}}\')"', - 'test -n "$ip_addr" || ip_addr="$(hostname -I 2>/dev/null | awk \'{print $1}\')"', - 'test -n "$ip_addr"', - 'printf "%s\\n" "$ip_addr"', - ].join("\n"), - ], - { - artifactName: "phase-1-sandbox-reachable-host-address", - env: buildAvailabilityProbeEnv(), - timeoutMs: 30_000, - }, - ); - expect(hostAddressProbe.exitCode, resultText(hostAddressProbe)).toBe(0); - const hostAddress = hostAddressProbe.stdout.trim().split(/\s+/)[0]; - expect(hostAddress).toBeTruthy(); + const hostAddress = "host.openshell.internal"; const fake = await startFakeOpenAiCompatibleServer({ apiKey: FAKE_API_KEY, @@ -275,6 +257,25 @@ test("hermes-gpu-startup: selected OpenShell GPU route reaches stable Ready stat status, }); + const inference = await sandbox.execShell( + SANDBOX_NAME, + trustedSandboxShellScript( + `curl -fsS --max-time 60 https://inference.local/v1/chat/completions -H 'Content-Type: application/json' --data '${JSON.stringify( + { + model: FAKE_MODEL, + messages: [{ role: "user", content: "reply with OK" }], + max_tokens: 8, + }, + )}'`, + ), + { + artifactName: "phase-5-authenticated-inference-post", + env: commandEnv(), + timeoutMs: 90_000, + }, + ); + expect(inference.exitCode, resultText(inference)).toBe(0); + const fakeRequests = fake.requests(); const inferencePosts = fakeRequests.filter( (request) => @@ -288,6 +289,7 @@ test("hermes-gpu-startup: selected OpenShell GPU route reaches stable Ready stat `expected authenticated fake inference POST, got ${JSON.stringify(fakeRequests)}`, ).toBeGreaterThan(0); expect(inferencePosts.filter((request) => request.auth !== "ok")).toEqual([]); + expect(inferencePosts.filter((request) => request.authorizationSent !== true)).toEqual([]); expect(inferencePosts.filter((request) => (request.forbiddenMarkerMatches ?? 0) > 0)).toEqual([]); expect(JSON.stringify(fakeRequests)).not.toContain(EXTRA_PLACEHOLDER_TOKEN_A); expect(JSON.stringify(fakeRequests)).not.toContain(EXTRA_PLACEHOLDER_TOKEN_B); diff --git a/test/e2e/live/mcp-bridge-sandbox.ts b/test/e2e/live/mcp-bridge-sandbox.ts index 4ceb5ed662f..37ac1862c6a 100644 --- a/test/e2e/live/mcp-bridge-sandbox.ts +++ b/test/e2e/live/mcp-bridge-sandbox.ts @@ -4,15 +4,37 @@ import { shellQuote } from "../../../src/lib/core/shell-quote"; import { buildAvailabilityProbeEnv } from "../fixtures/availability-env.ts"; import type { HostCliClient } from "../fixtures/clients/host.ts"; -import { discoverHostAddress } from "../fixtures/host-address.ts"; import type { ShellProbeResult } from "../fixtures/shell-probe.ts"; const MCP_CURL_HTTP_CODE_MARKER = "NEMOCLAW_MCP_CURL_HTTP_CODE="; export type McpDnsRebindingAdapter = "mcporter" | "hermes-config" | "deepagents-config"; -export async function hostAddressForSandbox(host: HostCliClient): Promise { - return (await discoverHostAddress(host)).address; +export async function hostAddressForSandbox(_host: HostCliClient): Promise { + return "host.openshell.internal"; +} + +/** Concrete runner address used only to simulate a post-validation DNS rebind. */ +export async function hostPrivateAddressForSandbox(host: HostCliClient): Promise { + const probe = await host.command( + "bash", + [ + "-lc", + [ + 'ip_addr="$(ip route get 1.1.1.1 2>/dev/null | awk \'{for (i=1;i<=NF;i++) if ($i=="src") {print $(i+1); exit}}\')"', + 'if [ -n "$ip_addr" ]; then echo "$ip_addr"; exit 0; fi', + "ip_addr=\"$(hostname -I 2>/dev/null | awk '{print $1}')\"", + 'if [ -n "$ip_addr" ]; then echo "$ip_addr"; exit 0; fi', + "echo 127.0.0.1", + ].join("\n"), + ], + { + artifactName: "host-private-ip-for-mcp-rebinding", + env: buildAvailabilityProbeEnv(), + timeoutMs: 30_000, + }, + ); + return probe.stdout.trim().split(/\s+/)[0] || "127.0.0.1"; } export { diff --git a/test/e2e/live/mcp-bridge.test.ts b/test/e2e/live/mcp-bridge.test.ts index a06bbab873e..e5a6fc08b72 100644 --- a/test/e2e/live/mcp-bridge.test.ts +++ b/test/e2e/live/mcp-bridge.test.ts @@ -29,6 +29,7 @@ import { import { buildMcpDnsRebindingProbeScript, hostAddressForSandbox, + hostPrivateAddressForSandbox, isExpectedMcpCurlPolicyDenial, type McpDnsRebindingAdapter, remapDnsRebindingHostname, @@ -168,7 +169,6 @@ async function assertAdapterDnsRebindingDenied( options: { adapter: McpDnsRebindingAdapter; artifactPrefix: string; - hostAddress: string; sandboxName: string; secretPaths: string[]; }, @@ -276,12 +276,13 @@ async function assertAdapterDnsRebindingDenied( // reachable runner address would receive the request. The pinned v0.0.72 // implementation instead returns the one resolved-and-validated SocketAddr // list directly to connect; see the exact proxy.rs citation in the helper. - expect(options.hostAddress).not.toBe(REBIND_PUBLIC_IP); + const reboundAddress = await hostPrivateAddressForSandbox(host); + expect(reboundAddress).not.toBe(REBIND_PUBLIC_IP); await remapDnsRebindingHostname( host, options.sandboxName, hostsFixture, - options.hostAddress, + reboundAddress, `${options.artifactPrefix}-mcp-dns-rebinding-map-private-unpinned-after-add`, ); const denial = await sandbox.execShell( @@ -1023,7 +1024,6 @@ req.end(body); await assertAdapterDnsRebindingDenied(host, sandbox, cleanup, { adapter: "mcporter", artifactPrefix: "openclaw", - hostAddress, sandboxName: OPENCLAW_SANDBOX_NAME, secretPaths: ["/sandbox/.openclaw", "/sandbox/.mcp.json"], }); @@ -1261,7 +1261,6 @@ liveAgentMatrixTest( await assertAdapterDnsRebindingDenied(host, sandbox, cleanup, { adapter: "hermes-config", artifactPrefix: "hermes", - hostAddress, sandboxName: HERMES_SANDBOX_NAME, secretPaths: ["/sandbox/.hermes"], }); @@ -1414,7 +1413,6 @@ liveAgentMatrixTest( await assertAdapterDnsRebindingDenied(host, sandbox, cleanup, { adapter: "deepagents-config", artifactPrefix: "deepagents", - hostAddress, sandboxName: DEEPAGENTS_SANDBOX_NAME, secretPaths: ["/sandbox/.deepagents"], }); diff --git a/test/e2e/live/messaging-compatible-endpoint.test.ts b/test/e2e/live/messaging-compatible-endpoint.test.ts index 0be70c4af44..025a7ccfd8e 100644 --- a/test/e2e/live/messaging-compatible-endpoint.test.ts +++ b/test/e2e/live/messaging-compatible-endpoint.test.ts @@ -18,7 +18,6 @@ import { resultText } from "../fixtures/clients/command.ts"; import type { HostCliClient } from "../fixtures/clients/host.ts"; import { type SandboxClient, validateSandboxName } from "../fixtures/clients/sandbox.ts"; import { expect, test } from "../fixtures/e2e-test.ts"; -import { discoverHostAddress } from "../fixtures/host-address.ts"; import { closeServer, writeJsonResponse as jsonResponse, @@ -264,10 +263,6 @@ async function startCompatibleMock( throw new Error("compatible endpoint mock failed to answer /v1/models"); } -async function hostAddressForSandbox(host: HostCliClient): Promise { - return (await discoverHostAddress(host)).address; -} - async function sourceCliAvailable(host: HostCliClient): Promise { if (!fs.existsSync(CLI_DIST_ENTRYPOINT)) return false; const result = await host.command( @@ -582,11 +577,15 @@ test("messaging compatible endpoint routes Telegram-enabled OpenClaw through inf await compatibleMock.close(); }); - const hostAddress = await hostAddressForSandbox(host); - const endpointUrl = `http://${hostAddress}:${new URL(compatibleMock.localBaseUrl).port}/v1`; + const endpointUrl = `http://host.openshell.internal:${new URL(compatibleMock.localBaseUrl).port}/v1`; const hostReachability = await host.command( "curl", - ["-sf", "-H", `Authorization: Bearer ${COMPATIBLE_KEY}`, `${endpointUrl}/models`], + [ + "-sf", + "-H", + `Authorization: Bearer ${COMPATIBLE_KEY}`, + `${compatibleMock.localBaseUrl}/models`, + ], { artifactName: "compatible-endpoint-host-reachability", env: commandEnv(), diff --git a/test/e2e/live/onboard-resume.test.ts b/test/e2e/live/onboard-resume.test.ts index 15235ed72dc..45011aa4fdd 100644 --- a/test/e2e/live/onboard-resume.test.ts +++ b/test/e2e/live/onboard-resume.test.ts @@ -7,13 +7,12 @@ import path from "node:path"; import { buildAvailabilityProbeEnv } from "../fixtures/availability-env.ts"; import type { HostCliClient } from "../fixtures/clients/host.ts"; -import { validateSandboxName } from "../fixtures/clients/sandbox.ts"; +import { trustedSandboxShellScript, validateSandboxName } from "../fixtures/clients/sandbox.ts"; import { expect, test } from "../fixtures/e2e-test.ts"; import { type FakeOpenAiCompatibleServer, startFakeOpenAiCompatibleServer, } from "../fixtures/fake-openai-compatible.ts"; -import { discoverHostAddress } from "../fixtures/host-address.ts"; import { CLI_ENTRYPOINT } from "../fixtures/paths.ts"; // Disruption-recovery contract — regression for #446. @@ -108,31 +107,21 @@ function containsExactJsonToken(value: unknown, token: string): boolean { return false; } -async function hostAddressForSandbox(host: HostCliClient): Promise { - return (await discoverHostAddress(host)).address; -} - -function expectHermeticCompatibleInferenceUsed(fake: FakeOpenAiCompatibleServer): void { - const requests = fake.requests(); - const inferencePosts = requests.filter( - (entry) => - entry.method === "POST" && - ["/v1/chat/completions", "/chat/completions", "/v1/responses", "/responses"].includes( - entry.path, - ), - ); - expect( - inferencePosts.length, - `expected fake inference POST, got ${JSON.stringify(requests)}`, - ).toBeGreaterThan(0); +function expectHermeticCompatibleEndpointUsed( + fake: FakeOpenAiCompatibleServer, + requestOffset: number, +): void { + const requests = fake.requests().slice(requestOffset); expect( - requests.filter((entry) => entry.auth === "missing"), - `fake endpoint saw unauthenticated requests: ${JSON.stringify(requests)}`, - ).toEqual([]); - expect( - inferencePosts.filter((entry) => entry.auth !== "ok"), - `fake inference POST had missing auth: ${JSON.stringify(requests)}`, - ).toEqual([]); + requests.some( + (entry) => + entry.method === "POST" && + entry.path === "/v1/chat/completions" && + entry.authorizationSent === true && + entry.auth === "ok", + ), + `expected authenticated fake endpoint inference, got ${JSON.stringify(requests)}`, + ).toBe(true); } // The e2e-live Vitest project owns the NEMOCLAW_RUN_LIVE_E2E collection gate, @@ -180,13 +169,14 @@ test("onboard-resume: interrupted onboard then --resume completes without redoin // pass hosted NVIDIA inference secrets. Instead, this test exposes a local // fake OpenAI-compatible endpoint at a host address the OpenShell gateway and // sandbox can route to, matching test/e2e/lib/hermetic-compatible-inference.sh. - const fakePublicHost = await hostAddressForSandbox(host); + const fakePublicHost = "host.openshell.internal"; const fake = await startFakeOpenAiCompatibleServer({ apiKey: FAKE_COMPATIBLE_AUTH_VALUE, host: "0.0.0.0", model: FAKE_COMPATIBLE_MODEL, publicHost: fakePublicHost, requireAuth: true, + requireAuthModels: true, }); cleanup.add("close fake OpenAI-compatible endpoint", async () => { await artifacts.writeJson("fake-openai-compatible-requests.json", fake.requests()); @@ -197,8 +187,13 @@ test("onboard-resume: interrupted onboard then --resume completes without redoin model: FAKE_COMPATIBLE_MODEL, publicHost: fakePublicHost, }); - const modelsResponse = await fetch(`${fake.baseUrl}/models`); + const localModelsUrl = new URL(`${fake.baseUrl}/models`); + localModelsUrl.hostname = "127.0.0.1"; + const modelsResponse = await fetch(localModelsUrl, { + headers: { Authorization: `Bearer ${FAKE_COMPATIBLE_AUTH_VALUE}` }, + }); expect(modelsResponse.ok, `fake endpoint ${fake.baseUrl}/models should be reachable`).toBe(true); + const onboardingRequestOffset = fake.requests().length; // ────────────────────────────────────────────────────────────────── // Phase 0 (deferred): pre-cleanup of leftover sandbox/session state. @@ -312,6 +307,32 @@ test("onboard-resume: interrupted onboard then --resume completes without redoin }); expect(sandboxAfterInterrupt.exitCode, sandboxAfterInterrupt.stderr).toBe(0); + // Exercise the configured route through the sandbox. The OpenShell gateway + // must inject the stored compatible-endpoint credential upstream; this POST + // is the positive auth proof and is deliberately newer than fixture startup + // and the direct readiness fetch excluded by onboardingRequestOffset. + const inferenceAfterInterrupt = await sandbox.execShell( + SANDBOX_NAME, + trustedSandboxShellScript( + `curl -fsS --max-time 60 https://inference.local/v1/chat/completions -H 'Content-Type: application/json' --data '${JSON.stringify( + { + model: FAKE_COMPATIBLE_MODEL, + messages: [{ role: "user", content: "reply with OK" }], + max_tokens: 8, + }, + )}'`, + ), + { + artifactName: "phase-2-authenticated-inference-post", + env: buildAvailabilityProbeEnv(), + timeoutMs: 90_000, + }, + ); + expect( + inferenceAfterInterrupt.exitCode, + `${inferenceAfterInterrupt.stdout}\n${inferenceAfterInterrupt.stderr}`, + ).toBe(0); + // Assertion: session-file-present. expect(fs.existsSync(SESSION_FILE)).toBe(true); @@ -323,7 +344,7 @@ test("onboard-resume: interrupted onboard then --resume completes without redoin expect(interrupted.failure?.step).toBe("policies"); await artifacts.writeJson("phase-2-fake-openai-compatible-requests.json", fake.requests()); - expectHermeticCompatibleInferenceUsed(fake); + expectHermeticCompatibleEndpointUsed(fake, onboardingRequestOffset); // ────────────────────────────────────────────────────────────────── // Phase 3: resume — NVIDIA_INFERENCE_API_KEY and COMPATIBLE_API_KEY are diff --git a/test/e2e/support/mcp-bridge-sandbox.test.ts b/test/e2e/support/mcp-bridge-sandbox.test.ts index 4e46c90c50e..241b84dc7a8 100644 --- a/test/e2e/support/mcp-bridge-sandbox.test.ts +++ b/test/e2e/support/mcp-bridge-sandbox.test.ts @@ -13,6 +13,8 @@ import { testTimeout } from "../../helpers/timeouts"; import type { HostCliClient } from "../fixtures/clients/host.ts"; import { buildMcpDnsRebindingProbeScript, + hostAddressForSandbox, + hostPrivateAddressForSandbox, isExpectedMcpCurlPolicyDenial, restoreDnsRebindingHostsFixture, } from "../live/mcp-bridge-sandbox.ts"; @@ -81,6 +83,20 @@ async function captureRestoreScript(hostBackupPath: string, sandboxBackupPath: s } describe("MCP curl policy denial classification", SUITE_OPTIONS, () => { + it("separates the managed endpoint alias from the concrete rebinding address", async () => { + let probeScript = ""; + const host = { + command: async (_command: string, args: string[]) => { + probeScript = args[1] ?? ""; + return { ...denialResult(), stdout: "10.20.30.40\n" }; + }, + } as unknown as HostCliClient; + + await expect(hostAddressForSandbox(host)).resolves.toBe("host.openshell.internal"); + await expect(hostPrivateAddressForSandbox(host)).resolves.toBe("10.20.30.40"); + expect(probeScript).toContain("ip route get 1.1.1.1"); + }); + it("accepts an L7 HTTP 403 denial", () => { expect( isExpectedMcpCurlPolicyDenial(denialResult({ stdout: "NEMOCLAW_MCP_CURL_HTTP_CODE=403\n" })), diff --git a/test/generate-hermes-config.test.ts b/test/generate-hermes-config.test.ts index 6390fb988db..aa28c7549fe 100644 --- a/test/generate-hermes-config.test.ts +++ b/test/generate-hermes-config.test.ts @@ -9,6 +9,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import YAML from "yaml"; import { generateHermesConfig } from "../agents/hermes/config/generate.ts"; import { HERMES_PROXY_API_KEY_PLACEHOLDER } from "../src/lib/hermes-proxy-api-key"; +import { + applyCompatibleEndpointContextWindow, + resetCompatibleEndpointContextWindowAutoState, +} from "../src/lib/inference/compatible-endpoint-context"; import { applyMessagingBuildPhase, readMessagingBuildPlanFromEnv, @@ -507,6 +511,55 @@ describe("agents/hermes/generate-config.ts", () => { ]); }); + it("bakes NEMOCLAW_CONTEXT_WINDOW as model.context_length so Hermes cannot downgrade it (#6177)", () => { + const { config } = runConfigScript({ + NEMOCLAW_MODEL: "nvidia/NVIDIA-Nemotron-3-Super-120B-A12B-NVFP4", + NEMOCLAW_CONTEXT_WINDOW: "65536", + }); + + expect(config.model.context_length).toBe(65536); + // context_window is silently ignored by Hermes; we must not emit it. + expect(config.model.context_window).toBeUndefined(); + }); + + it("chains the endpoint probe through to model.context_length in the generated config (#6177)", async () => { + // Source-level regression across the boundary: the same probe onboarding + // calls resolves a compatible endpoint's max_model_len into + // NEMOCLAW_CONTEXT_WINDOW, and the real generator must bake it as + // model.context_length (never context_window). Uses an injected fetcher so + // no network is required. + resetCompatibleEndpointContextWindowAutoState(); + const model = "nvidia/NVIDIA-Nemotron-3-Super-120B-A12B-NVFP4"; + fs.mkdirSync(path.join(tmpDir, ".hermes"), { recursive: true }); + const env = buildHermesTestEnv({ NEMOCLAW_MODEL: model }); + await applyCompatibleEndpointContextWindow("https://endpoint.example/v1", model, { + env, + fetchModels: () => ({ data: [{ id: model, max_model_len: 65_536 }] }), + resolveHost: async () => [{ address: "93.184.216.34", family: 4 }], + }); + expect(env.NEMOCLAW_CONTEXT_WINDOW).toBe("65536"); + + withEnv(env, () => + generateHermesConfig({ env, scriptDir: SCRIPT_DIR, homeDir: tmpDir, log: () => {} }), + ); + const { config } = readGeneratedConfig(); + expect(config.model.context_length).toBe(65536); + expect(config.model.context_window).toBeUndefined(); + resetCompatibleEndpointContextWindowAutoState(); + }); + + it("omits context_length when no context window is configured so Hermes auto-detects (#6177)", () => { + const { config } = runConfigScript(); + + expect(config.model.context_length).toBeUndefined(); + }); + + it("ignores a malformed NEMOCLAW_CONTEXT_WINDOW and lets Hermes auto-detect (#6177)", () => { + const { config } = runConfigScript({ NEMOCLAW_CONTEXT_WINDOW: "not-a-number" }); + + expect(config.model.context_length).toBeUndefined(); + }); + it("falls back to a stable picker provider name when no upstream is named", () => { const { config } = runConfigScript(); diff --git a/test/helpers/hermes-wrapper-harness.ts b/test/helpers/hermes-wrapper-harness.ts new file mode 100644 index 00000000000..607d45a5826 --- /dev/null +++ b/test/helpers/hermes-wrapper-harness.ts @@ -0,0 +1,126 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// Shared test harness for the Hermes CLI wrapper suites +// (test/hermes-gateway-wrapper.test.ts and +// test/hermes-wrapper-oneshot-routing.test.ts). Both suites drive +// agents/hermes/hermes-wrapper.py by copying it into a temp dir alongside the +// runtime-env validator, planting stubs, and spawning it. Extracted here — a +// non-`.test.` module — so the shared `runWrapper` helper (and its planted-PATH +// `if` branch) lives in one place instead of being duplicated across the two +// files that were split for the test-file-size budget. + +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +export const WRAPPER = path.join( + import.meta.dirname, + "..", + "..", + "agents", + "hermes", + "hermes-wrapper.py", +); +export const VALIDATOR = path.join( + import.meta.dirname, + "..", + "..", + "agents", + "hermes", + "validate-env-secret-boundary.py", +); + +export function python3Available(): boolean { + try { + return spawnSync("python3", ["--version"], { timeout: 5000 }).status === 0; + } catch { + return false; + } +} +export const canRun = process.platform === "linux" && python3Available(); + +export type WrapperRun = { + status: number | null; + stdout: string; + stderr: string; + realInvoked: boolean; + realArgs: string; + realArgv: string[]; +}; + +export type StubBehaviour = { stdout?: string; stderr?: string; exitCode?: number }; + +export function runWrapper( + args: string[], + env: Record, + opts: { + shadowPython?: boolean; + shadowHelpers?: Record; + stub?: StubBehaviour; + stubMode?: number; + validatorScript?: string; + } = {}, +): WrapperRun { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-wrapper-")); + try { + fs.copyFileSync(WRAPPER, path.join(dir, "hermes")); + const validatorContent = opts.validatorScript ?? fs.readFileSync(VALIDATOR, "utf-8"); + // Source-layout filename lets the wrapper's dev fallback pick it up. + fs.writeFileSync(path.join(dir, "validate-env-secret-boundary.py"), validatorContent, { + mode: 0o755, + }); + fs.chmodSync(path.join(dir, "hermes"), 0o755); + + const marker = path.join(dir, "real-invoked.txt"); + const stubStdout = opts.stub?.stdout ?? ""; + const stubStderr = opts.stub?.stderr ?? ""; + const stubExit = opts.stub?.exitCode ?? 0; + const stubScript = [ + "#!/usr/bin/env bash", + `node -e 'require("node:fs").writeFileSync(process.argv[1], JSON.stringify(process.argv.slice(2)))' ${JSON.stringify(marker)} "$@"`, + stubStdout ? `cat <<'__NEMOCLAW_STUB_EOF__'\n${stubStdout}\n__NEMOCLAW_STUB_EOF__` : "", + stubStderr + ? `cat <<'__NEMOCLAW_STUB_ERR_EOF__' >&2\n${stubStderr}\n__NEMOCLAW_STUB_ERR_EOF__` + : "", + `exit ${stubExit}`, + "", + ].join("\n"); + fs.writeFileSync(path.join(dir, "hermes.real"), stubScript, { mode: opts.stubMode ?? 0o755 }); + + // Plant malicious helpers earlier on PATH; the wrapper must ignore them. + const planted: Record = { + ...(opts.shadowHelpers ?? {}), + ...(opts.shadowPython ? { python3: "#!/usr/bin/env bash\nexit 0\n" } : {}), + }; + let pathPrefix = ""; + if (Object.keys(planted).length > 0) { + const evilBin = path.join(dir, "evil-bin"); + fs.mkdirSync(evilBin); + for (const [name, script] of Object.entries(planted)) { + fs.writeFileSync(path.join(evilBin, name), script, { mode: 0o755 }); + } + pathPrefix = `${evilBin}${path.delimiter}`; + } + + const result = spawnSync(path.join(dir, "hermes"), args, { + encoding: "utf-8", + timeout: 10000, + env: { PATH: `${pathPrefix}${process.env.PATH ?? ""}`, HOME: dir, ...env }, + }); + + const realInvoked = fs.existsSync(marker); + const realArgv = realInvoked ? JSON.parse(fs.readFileSync(marker, "utf-8")) : []; + return { + status: result.status, + stdout: result.stdout ?? "", + stderr: result.stderr ?? "", + realInvoked, + realArgs: realArgv.join(" "), + realArgv, + }; + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} diff --git a/test/hermes-gateway-wrapper.test.ts b/test/hermes-gateway-wrapper.test.ts index 174a53d828c..118e494ecbc 100644 --- a/test/hermes-gateway-wrapper.test.ts +++ b/test/hermes-gateway-wrapper.test.ts @@ -22,108 +22,7 @@ import { beforeAll, describe, expect, it } from "vitest"; import { buildHermesConfig } from "../agents/hermes/config/hermes-config.ts"; import { buildOpenshellExecArgs } from "../src/lib/actions/sandbox/exec.ts"; - -const WRAPPER = path.join(import.meta.dirname, "..", "agents", "hermes", "hermes-wrapper.py"); -const VALIDATOR = path.join( - import.meta.dirname, - "..", - "agents", - "hermes", - "validate-env-secret-boundary.py", -); - -function python3Available(): boolean { - try { - return spawnSync("python3", ["--version"], { timeout: 5000 }).status === 0; - } catch { - return false; - } -} -const canRun = process.platform === "linux" && python3Available(); - -type WrapperRun = { - status: number | null; - stdout: string; - stderr: string; - realInvoked: boolean; - realArgs: string; - realArgv: string[]; -}; - -type StubBehaviour = { stdout?: string; stderr?: string; exitCode?: number }; - -function runWrapper( - args: string[], - env: Record, - opts: { - shadowPython?: boolean; - shadowHelpers?: Record; - stub?: StubBehaviour; - stubMode?: number; - validatorScript?: string; - } = {}, -): WrapperRun { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-wrapper-")); - try { - fs.copyFileSync(WRAPPER, path.join(dir, "hermes")); - const validatorContent = opts.validatorScript ?? fs.readFileSync(VALIDATOR, "utf-8"); - // Source-layout filename lets the wrapper's dev fallback pick it up. - fs.writeFileSync(path.join(dir, "validate-env-secret-boundary.py"), validatorContent, { - mode: 0o755, - }); - fs.chmodSync(path.join(dir, "hermes"), 0o755); - - const marker = path.join(dir, "real-invoked.txt"); - const stubStdout = opts.stub?.stdout ?? ""; - const stubStderr = opts.stub?.stderr ?? ""; - const stubExit = opts.stub?.exitCode ?? 0; - const stubScript = [ - "#!/usr/bin/env bash", - `node -e 'require("node:fs").writeFileSync(process.argv[1], JSON.stringify(process.argv.slice(2)))' ${JSON.stringify(marker)} "$@"`, - stubStdout ? `cat <<'__NEMOCLAW_STUB_EOF__'\n${stubStdout}\n__NEMOCLAW_STUB_EOF__` : "", - stubStderr - ? `cat <<'__NEMOCLAW_STUB_ERR_EOF__' >&2\n${stubStderr}\n__NEMOCLAW_STUB_ERR_EOF__` - : "", - `exit ${stubExit}`, - "", - ].join("\n"); - fs.writeFileSync(path.join(dir, "hermes.real"), stubScript, { mode: opts.stubMode ?? 0o755 }); - - // Plant malicious helpers earlier on PATH; the wrapper must ignore them. - const planted: Record = { - ...(opts.shadowHelpers ?? {}), - ...(opts.shadowPython ? { python3: "#!/usr/bin/env bash\nexit 0\n" } : {}), - }; - let pathPrefix = ""; - if (Object.keys(planted).length > 0) { - const evilBin = path.join(dir, "evil-bin"); - fs.mkdirSync(evilBin); - for (const [name, script] of Object.entries(planted)) { - fs.writeFileSync(path.join(evilBin, name), script, { mode: 0o755 }); - } - pathPrefix = `${evilBin}${path.delimiter}`; - } - - const result = spawnSync(path.join(dir, "hermes"), args, { - encoding: "utf-8", - timeout: 10000, - env: { PATH: `${pathPrefix}${process.env.PATH ?? ""}`, HOME: dir, ...env }, - }); - - const realInvoked = fs.existsSync(marker); - const realArgv = realInvoked ? JSON.parse(fs.readFileSync(marker, "utf-8")) : []; - return { - status: result.status, - stdout: result.stdout ?? "", - stderr: result.stderr ?? "", - realInvoked, - realArgs: realArgv.join(" "), - realArgv, - }; - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } -} +import { canRun, runWrapper, VALIDATOR, WRAPPER } from "./helpers/hermes-wrapper-harness.ts"; describe.skipIf(!canRun)("agents/hermes/hermes-wrapper.py", () => { // Surface a hard error in CI when the prerequisites are missing instead of @@ -201,272 +100,6 @@ describe.skipIf(!canRun)("agents/hermes/hermes-wrapper.py", () => { expect(run.realArgs).toBe("dashboard"); }); - it("routes resumed one-shot invocations through chat query so Hermes appends to the target session (#5254)", () => { - const run = runWrapper( - ["--resume", "20260612_050401_aa9d27", "-z", "What secret number did I give you?"], - {}, - ); - - expect(run.status).toBe(0); - expect(run.realArgv).toEqual([ - "chat", - "--query", - "What secret number did I give you?", - "--quiet", - "--resume", - "20260612_050401_aa9d27", - ]); - }); - - it("routes continued one-shot invocations through chat query while preserving provider/skill flags (#5254)", () => { - const run = runWrapper( - [ - "-c", - "daily check", - "--oneshot=Summarize the latest turn", - "--provider=custom", - "--skills=memory,session_search", - "--ignore-rules", - ], - {}, - ); - - expect(run.status).toBe(0); - expect(run.realArgv).toEqual([ - "chat", - "--query", - "Summarize the latest turn", - "--quiet", - "--continue", - "daily check", - "--provider", - "custom", - "--skills", - "memory,session_search", - "--ignore-rules", - ]); - }); - - it("preserves explicit approval flags without adding them to ordinary resumed one-shot invocations (#5254)", () => { - const run = runWrapper( - ["--resume", "20260612_050401_aa9d27", "-z", "Repeat it", "--yolo", "--accept-hooks"], - {}, - ); - - expect(run.status).toBe(0); - expect(run.realArgv).toEqual([ - "chat", - "--query", - "Repeat it", - "--quiet", - "--resume", - "20260612_050401_aa9d27", - "--yolo", - "--accept-hooks", - ]); - }); - - it("keeps translated resumed one-shot turns on the same fake session and reports exec failures (#5254)", () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-wrapper-session-")); - try { - fs.copyFileSync(WRAPPER, path.join(dir, "hermes")); - fs.chmodSync(path.join(dir, "hermes"), 0o755); - const statePath = path.join(dir, "sessions.json"); - fs.writeFileSync( - path.join(dir, "hermes.real"), - [ - "#!/usr/bin/env bash", - 'if [ "$1" = "-z" ]; then printf "seed:%s\\n" "$2" > "$NEMOCLAW_FAKE_SESSIONS"; exit 0; fi', - 'if [ "$1" = "chat" ] && [ "$2" = "--query" ] && [ "$4" = "--quiet" ] && { [ "$5" = "--resume" ] || [ "$5" = "--continue" ]; } && [ "$6" = "seed" ]; then printf "seed:%s\\n" "$3" >> "$NEMOCLAW_FAKE_SESSIONS"; exit 0; fi', - "exit 3", - "", - ].join("\n"), - { mode: 0o755 }, - ); - const invoke = (args: string[]) => - spawnSync(path.join(dir, "hermes"), args, { - encoding: "utf-8", - env: { PATH: process.env.PATH ?? "", HOME: dir, NEMOCLAW_FAKE_SESSIONS: statePath }, - timeout: 10_000, - }); - - expect(invoke(["-z", "seed prompt"]).status).toBe(0); - expect(invoke(["--resume", "seed", "-z", "resume prompt"]).status).toBe(0); - expect(invoke(["-c", "seed", "-z", "continue prompt"]).status).toBe(0); - expect(fs.readFileSync(statePath, "utf-8").trim().split("\n")).toEqual([ - "seed:seed prompt", - "seed:resume prompt", - "seed:continue prompt", - ]); - fs.chmodSync(path.join(dir, "hermes.real"), 0o644); - const blocked = invoke(["--resume", "seed", "-z", "after chmod"]); - expect(blocked.status).toBe(126); - expect(blocked.stderr).toContain("[SECURITY] Refusing to run hermes: failed to exec Hermes"); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } - }); - - it("leaves plain one-shot invocations on the upstream one-shot path (#5254)", () => { - const run = runWrapper(["-z", "Reply pong"], {}); - - expect(run.status).toBe(0); - expect(run.stderr).toBe(""); - expect(run.realInvoked).toBe(true); - expect(run.realArgs).toBe("-z Reply pong"); - }); - - it("routes equals-style resumed one-shot invocations through chat query (#5254)", () => { - const run = runWrapper(["--resume=20260612_050401_aa9d27", "--oneshot=Repeat a=b"], {}); - - expect(run.status).toBe(0); - expect(run.stderr).toBe(""); - expect(run.realInvoked).toBe(true); - expect(run.realArgs).toBe("chat --query Repeat a=b --quiet --resume 20260612_050401_aa9d27"); - }); - - it("passes positional subcommands through instead of translating nested one-shot flags (#5254)", () => { - const run = runWrapper(["chat", "--resume", "20260612_050401_aa9d27", "-z", "Repeat it"], {}); - - expect(run.status).toBe(0); - expect(run.stderr).toBe(""); - expect(run.realInvoked).toBe(true); - expect(run.realArgs).toBe("chat --resume 20260612_050401_aa9d27 -z Repeat it"); - }); - - it("passes unknown flags through instead of translating a partial allowlist match (#5254)", () => { - const run = runWrapper( - ["--resume", "20260612_050401_aa9d27", "--unknown", "-z", "Repeat it"], - {}, - ); - - expect(run.status).toBe(0); - expect(run.stderr).toBe(""); - expect(run.realInvoked).toBe(true); - expect(run.realArgs).toBe("--resume 20260612_050401_aa9d27 --unknown -z Repeat it"); - }); - - it("passes argv with -- marker through instead of translating after argument termination (#5254)", () => { - const run = runWrapper(["--resume", "20260612_050401_aa9d27", "--", "-z", "Repeat it"], {}); - - expect(run.status).toBe(0); - expect(run.stderr).toBe(""); - expect(run.realInvoked).toBe(true); - expect(run.realArgs).toBe("--resume 20260612_050401_aa9d27 -- -z Repeat it"); - }); - - it("passes mixed resume selectors through instead of translating ambiguous targets (#5254)", () => { - const run = runWrapper( - [ - "--continue", - "20260612_050401_aa9d27", - "--resume", - "20260612_050446_924bd8", - "-z", - "Repeat it", - ], - {}, - ); - - expect(run.status).toBe(0); - expect(run.stderr).toBe(""); - expect(run.realInvoked).toBe(true); - expect(run.realArgs).toBe( - "--continue 20260612_050401_aa9d27 --resume 20260612_050446_924bd8 -z Repeat it", - ); - }); - - it("passes multiple one-shot prompts through instead of dropping an earlier prompt (#5254)", () => { - const run = runWrapper( - ["-z", "First prompt", "-z", "Second prompt", "--resume", "20260612_050401_aa9d27"], - {}, - ); - - expect(run.status).toBe(0); - expect(run.stderr).toBe(""); - expect(run.realInvoked).toBe(true); - expect(run.realArgs).toBe("-z First prompt -z Second prompt --resume 20260612_050401_aa9d27"); - }); - - it("passes empty one-shot prompts through instead of translating an invalid query (#5254)", () => { - const run = runWrapper(["--oneshot=", "--resume", "20260612_050401_aa9d27"], {}); - - expect(run.status).toBe(0); - expect(run.stderr).toBe(""); - expect(run.realInvoked).toBe(true); - expect(run.realArgs).toBe("--oneshot= --resume 20260612_050401_aa9d27"); - }); - - it("passes --continue without a value through instead of translating a bare selector (#5254)", () => { - const run = runWrapper(["--continue", "-z", "Repeat it"], {}); - - expect(run.status).toBe(0); - expect(run.stderr).toBe(""); - expect(run.realInvoked).toBe(true); - expect(run.realArgs).toBe("--continue -z Repeat it"); - }); - - it("passes empty --continue values through instead of translating an invalid selector (#5254)", () => { - const run = runWrapper(["--continue=", "-z", "Repeat it"], {}); - - expect(run.status).toBe(0); - expect(run.stderr).toBe(""); - expect(run.realInvoked).toBe(true); - expect(run.realArgs).toBe("--continue= -z Repeat it"); - }); - - it("passes separated --continue with an empty value through instead of translating an invalid selector (#5254)", () => { - const run = runWrapper(["--continue", "", "-z", "Repeat it"], {}); - expect(run.realArgs).toBe("--continue -z Repeat it"); - }); - it("passes empty --resume values through instead of translating an invalid selector (#5254)", () => { - const run = runWrapper(["--resume=", "-z", "Repeat it"], {}); - - expect(run.status).toBe(0); - expect(run.stderr).toBe(""); - expect(run.realInvoked).toBe(true); - expect(run.realArgs).toBe("--resume= -z Repeat it"); - }); - - it("passes space-form one-shot without a prompt through instead of treating a flag as the prompt (#5254)", () => { - const run = runWrapper(["-z", "--resume", "20260612_050401_aa9d27"], {}); - - expect(run.status).toBe(0); - expect(run.stderr).toBe(""); - expect(run.realInvoked).toBe(true); - expect(run.realArgs).toBe("-z --resume 20260612_050401_aa9d27"); - }); - - it("passes separated --resume with an empty value through instead of translating an invalid selector (#5254)", () => { - const run = runWrapper(["--resume", "", "-z", "Repeat it"], {}); - - expect(run.status).toBe(0); - expect(run.stderr).toBe(""); - expect(run.realInvoked).toBe(true); - expect(run.realArgs).toBe("--resume -z Repeat it"); - }); - - it("passes separated --resume with a flag-like value through instead of translating an invalid selector (#5254)", () => { - const run = runWrapper(["--resume", "-z", "--oneshot=Repeat it"], {}); - - expect(run.status).toBe(0); - expect(run.stderr).toBe(""); - expect(run.realInvoked).toBe(true); - expect(run.realArgs).toBe("--resume -z --oneshot=Repeat it"); - }); - - it("passes value flags without required arguments through instead of translating partial argv (#5254)", () => { - const run = runWrapper( - ["--model", "--resume", "20260612_050401_aa9d27", "-z", "Repeat it"], - {}, - ); - - expect(run.status).toBe(0); - expect(run.stderr).toBe(""); - expect(run.realInvoked).toBe(true); - expect(run.realArgs).toBe("--model --resume 20260612_050401_aa9d27 -z Repeat it"); - }); - it("passes --version through (build assertion path) without invoking the guard", () => { const run = runWrapper(["--version"], { SLACK_BOT_TOKEN: "xoxb-real-1234567890" }); @@ -1420,6 +1053,7 @@ describe.skipIf(!canRun)("agents/hermes/hermes-wrapper.py", () => { providerKey: "custom", upstreamProvider: "nemoclaw-inference", inferenceApi: "", + contextWindow: null, toolDisclosure: "progressive" as const, webSearchProvider: null, messagingCredentialPlaceholders: [], @@ -1467,6 +1101,7 @@ describe.skipIf(!canRun)("agents/hermes/hermes-wrapper.py", () => { providerKey: "custom", upstreamProvider: "nemoclaw-inference", inferenceApi: "", + contextWindow: null, toolDisclosure: "progressive" as const, webSearchProvider: null, messagingCredentialPlaceholders: [], diff --git a/test/hermes-nonroot-strict-hash-reconciliation.test.ts b/test/hermes-nonroot-strict-hash-reconciliation.test.ts index 0cde8450c08..58a3e2e394a 100644 --- a/test/hermes-nonroot-strict-hash-reconciliation.test.ts +++ b/test/hermes-nonroot-strict-hash-reconciliation.test.ts @@ -117,6 +117,44 @@ raise SystemExit(module.main()) ); } +function runManagedFirstShieldsDown(fixture: ReconciliationFixture) { + const wrapper = String.raw` +import importlib.util +import sys + +source = sys.argv[1] +spec = importlib.util.spec_from_file_location("nemoclaw_runtime_config_guard_shields_fixture", source) +if spec is None or spec.loader is None: + raise SystemExit("could not load runtime guard fixture") +module = importlib.util.module_from_spec(spec) +sys.modules[spec.name] = module +spec.loader.exec_module(module) +module._managed_nonroot_reconciliation_is_allowed = lambda: True +sys.argv = [source, *sys.argv[2:]] +raise SystemExit(module.main()) +`; + return spawnSync( + "python3", + [ + "-c", + wrapper, + RUNTIME_CONFIG_GUARD, + "begin-shields-transition", + "--hermes-dir", + fixture.hermesDir, + "--hash-file", + fixture.hashPath, + "--state-file", + fixture.statePath, + "--shields-mode", + "mutable", + "--rollback-shields-mode", + "mutable", + ], + { encoding: "utf-8", timeout: 5000 }, + ); +} + function refreshCompatOnly(fixture: ReconciliationFixture): void { fs.writeFileSync(fixture.compatHashPath, hashInputs(fixture)); } @@ -266,6 +304,25 @@ print(json.dumps([private_live, canonical_mutable, foreign_private, unexpected_m } }); + it("reconciles the generated startup API key on the first shields-down transaction (#6381)", () => { + const fixture = createFixture(); + fs.appendFileSync(fixture.envPath, `API_SERVER_KEY=${"a".repeat(64)}\n`); + refreshCompatOnly(fixture); + + try { + expect(strictHashIsValid(fixture)).toBe(false); + const result = runManagedFirstShieldsDown(fixture); + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toMatch(/^lock_token=[0-9a-f]{64} original_locked=0\n$/u); + expect(strictHashIsValid(fixture)).toBe(true); + expect(fs.existsSync(fixture.statePath)).toBe(true); + } finally { + fs.chmodSync(fixture.sandboxDir, 0o700); + fs.chmodSync(fixture.hermesDir, 0o700); + fs.rmSync(fixture.root, { recursive: true, force: true }); + } + }); + it("refuses strict reconciliation from the shields-up root-locked posture (#2426)", () => { const fixture = createFixture(); fs.appendFileSync(fixture.envPath, `API_SERVER_KEY=${"8".repeat(64)}\n`); diff --git a/test/hermes-wrapper-oneshot-routing.test.ts b/test/hermes-wrapper-oneshot-routing.test.ts new file mode 100644 index 00000000000..cecb9b3d6bc --- /dev/null +++ b/test/hermes-wrapper-oneshot-routing.test.ts @@ -0,0 +1,305 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// Coverage for the hermes CLI wrapper's one-shot routing translation +// (agents/hermes/hermes-wrapper.py, #5254): resumed/continued one-shot +// invocations must be rewritten through `chat --query` so Hermes appends to the +// target session, while ambiguous or non-matching argv is passed straight +// through unchanged. Split out of test/hermes-gateway-wrapper.test.ts to keep +// each file within the test-file-size budget. +// +// Linux + python3 gated: the wrapper is a Python script invoked via its +// `#!/usr/bin/python3 -I` shebang. CI runs on Linux with python3 available, so +// the suite runs every PR; the gate exists so a maintainer cloning on macOS or +// Windows does not see a spurious red on `npm test`. See `.github/workflows/` +// for the canonical CI runner image. + +import assert from "node:assert"; +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { beforeAll, describe, expect, it } from "vitest"; + +import { canRun, runWrapper, WRAPPER } from "./helpers/hermes-wrapper-harness.ts"; + +describe.skipIf(!canRun)("agents/hermes/hermes-wrapper.py one-shot routing", () => { + // Surface a hard error in CI when the prerequisites are missing instead of + // silently skipping — a green CI run that never executed any wrapper test + // would mask regressions in the security boundary. Runs after + // `describe.skipIf` evaluates so non-Linux/python-less environments still + // skip cleanly without failing at module load. + beforeAll(() => { + assert( + !process.env.CI || canRun, + "Hermes wrapper integration tests require Linux + python3; CI environment did not meet both prerequisites", + ); + }); + + it("routes resumed one-shot invocations through chat query so Hermes appends to the target session (#5254)", () => { + const run = runWrapper( + ["--resume", "20260612_050401_aa9d27", "-z", "What secret number did I give you?"], + {}, + ); + + expect(run.status).toBe(0); + expect(run.realArgv).toEqual([ + "chat", + "--query", + "What secret number did I give you?", + "--quiet", + "--resume", + "20260612_050401_aa9d27", + ]); + }); + + it("routes continued one-shot invocations through chat query while preserving provider/skill flags (#5254)", () => { + const run = runWrapper( + [ + "-c", + "daily check", + "--oneshot=Summarize the latest turn", + "--provider=custom", + "--skills=memory,session_search", + "--ignore-rules", + ], + {}, + ); + + expect(run.status).toBe(0); + expect(run.realArgv).toEqual([ + "chat", + "--query", + "Summarize the latest turn", + "--quiet", + "--continue", + "daily check", + "--provider", + "custom", + "--skills", + "memory,session_search", + "--ignore-rules", + ]); + }); + + it("preserves explicit approval flags without adding them to ordinary resumed one-shot invocations (#5254)", () => { + const run = runWrapper( + ["--resume", "20260612_050401_aa9d27", "-z", "Repeat it", "--yolo", "--accept-hooks"], + {}, + ); + + expect(run.status).toBe(0); + expect(run.realArgv).toEqual([ + "chat", + "--query", + "Repeat it", + "--quiet", + "--resume", + "20260612_050401_aa9d27", + "--yolo", + "--accept-hooks", + ]); + }); + + it("keeps translated resumed one-shot turns on the same fake session and reports exec failures (#5254)", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-wrapper-session-")); + try { + fs.copyFileSync(WRAPPER, path.join(dir, "hermes")); + fs.chmodSync(path.join(dir, "hermes"), 0o755); + const statePath = path.join(dir, "sessions.json"); + fs.writeFileSync( + path.join(dir, "hermes.real"), + [ + "#!/usr/bin/env bash", + 'if [ "$1" = "-z" ]; then printf "seed:%s\\n" "$2" > "$NEMOCLAW_FAKE_SESSIONS"; exit 0; fi', + 'if [ "$1" = "chat" ] && [ "$2" = "--query" ] && [ "$4" = "--quiet" ] && { [ "$5" = "--resume" ] || [ "$5" = "--continue" ]; } && [ "$6" = "seed" ]; then printf "seed:%s\\n" "$3" >> "$NEMOCLAW_FAKE_SESSIONS"; exit 0; fi', + "exit 3", + "", + ].join("\n"), + { mode: 0o755 }, + ); + const invoke = (args: string[]) => + spawnSync(path.join(dir, "hermes"), args, { + encoding: "utf-8", + env: { PATH: process.env.PATH ?? "", HOME: dir, NEMOCLAW_FAKE_SESSIONS: statePath }, + timeout: 10_000, + }); + + expect(invoke(["-z", "seed prompt"]).status).toBe(0); + expect(invoke(["--resume", "seed", "-z", "resume prompt"]).status).toBe(0); + expect(invoke(["-c", "seed", "-z", "continue prompt"]).status).toBe(0); + expect(fs.readFileSync(statePath, "utf-8").trim().split("\n")).toEqual([ + "seed:seed prompt", + "seed:resume prompt", + "seed:continue prompt", + ]); + fs.chmodSync(path.join(dir, "hermes.real"), 0o644); + const blocked = invoke(["--resume", "seed", "-z", "after chmod"]); + expect(blocked.status).toBe(126); + expect(blocked.stderr).toContain("[SECURITY] Refusing to run hermes: failed to exec Hermes"); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it("leaves plain one-shot invocations on the upstream one-shot path (#5254)", () => { + const run = runWrapper(["-z", "Reply pong"], {}); + + expect(run.status).toBe(0); + expect(run.stderr).toBe(""); + expect(run.realInvoked).toBe(true); + expect(run.realArgs).toBe("-z Reply pong"); + }); + + it("routes equals-style resumed one-shot invocations through chat query (#5254)", () => { + const run = runWrapper(["--resume=20260612_050401_aa9d27", "--oneshot=Repeat a=b"], {}); + + expect(run.status).toBe(0); + expect(run.stderr).toBe(""); + expect(run.realInvoked).toBe(true); + expect(run.realArgs).toBe("chat --query Repeat a=b --quiet --resume 20260612_050401_aa9d27"); + }); + + it("passes positional subcommands through instead of translating nested one-shot flags (#5254)", () => { + const run = runWrapper(["chat", "--resume", "20260612_050401_aa9d27", "-z", "Repeat it"], {}); + + expect(run.status).toBe(0); + expect(run.stderr).toBe(""); + expect(run.realInvoked).toBe(true); + expect(run.realArgs).toBe("chat --resume 20260612_050401_aa9d27 -z Repeat it"); + }); + + it("passes unknown flags through instead of translating a partial allowlist match (#5254)", () => { + const run = runWrapper( + ["--resume", "20260612_050401_aa9d27", "--unknown", "-z", "Repeat it"], + {}, + ); + + expect(run.status).toBe(0); + expect(run.stderr).toBe(""); + expect(run.realInvoked).toBe(true); + expect(run.realArgs).toBe("--resume 20260612_050401_aa9d27 --unknown -z Repeat it"); + }); + + it("passes argv with -- marker through instead of translating after argument termination (#5254)", () => { + const run = runWrapper(["--resume", "20260612_050401_aa9d27", "--", "-z", "Repeat it"], {}); + + expect(run.status).toBe(0); + expect(run.stderr).toBe(""); + expect(run.realInvoked).toBe(true); + expect(run.realArgs).toBe("--resume 20260612_050401_aa9d27 -- -z Repeat it"); + }); + + it("passes mixed resume selectors through instead of translating ambiguous targets (#5254)", () => { + const run = runWrapper( + [ + "--continue", + "20260612_050401_aa9d27", + "--resume", + "20260612_050446_924bd8", + "-z", + "Repeat it", + ], + {}, + ); + + expect(run.status).toBe(0); + expect(run.stderr).toBe(""); + expect(run.realInvoked).toBe(true); + expect(run.realArgs).toBe( + "--continue 20260612_050401_aa9d27 --resume 20260612_050446_924bd8 -z Repeat it", + ); + }); + + it("passes multiple one-shot prompts through instead of dropping an earlier prompt (#5254)", () => { + const run = runWrapper( + ["-z", "First prompt", "-z", "Second prompt", "--resume", "20260612_050401_aa9d27"], + {}, + ); + + expect(run.status).toBe(0); + expect(run.stderr).toBe(""); + expect(run.realInvoked).toBe(true); + expect(run.realArgs).toBe("-z First prompt -z Second prompt --resume 20260612_050401_aa9d27"); + }); + + it("passes empty one-shot prompts through instead of translating an invalid query (#5254)", () => { + const run = runWrapper(["--oneshot=", "--resume", "20260612_050401_aa9d27"], {}); + + expect(run.status).toBe(0); + expect(run.stderr).toBe(""); + expect(run.realInvoked).toBe(true); + expect(run.realArgs).toBe("--oneshot= --resume 20260612_050401_aa9d27"); + }); + + it("passes --continue without a value through instead of translating a bare selector (#5254)", () => { + const run = runWrapper(["--continue", "-z", "Repeat it"], {}); + + expect(run.status).toBe(0); + expect(run.stderr).toBe(""); + expect(run.realInvoked).toBe(true); + expect(run.realArgs).toBe("--continue -z Repeat it"); + }); + + it("passes empty --continue values through instead of translating an invalid selector (#5254)", () => { + const run = runWrapper(["--continue=", "-z", "Repeat it"], {}); + + expect(run.status).toBe(0); + expect(run.stderr).toBe(""); + expect(run.realInvoked).toBe(true); + expect(run.realArgs).toBe("--continue= -z Repeat it"); + }); + + it("passes separated --continue with an empty value through instead of translating an invalid selector (#5254)", () => { + const run = runWrapper(["--continue", "", "-z", "Repeat it"], {}); + expect(run.realArgs).toBe("--continue -z Repeat it"); + }); + it("passes empty --resume values through instead of translating an invalid selector (#5254)", () => { + const run = runWrapper(["--resume=", "-z", "Repeat it"], {}); + + expect(run.status).toBe(0); + expect(run.stderr).toBe(""); + expect(run.realInvoked).toBe(true); + expect(run.realArgs).toBe("--resume= -z Repeat it"); + }); + + it("passes space-form one-shot without a prompt through instead of treating a flag as the prompt (#5254)", () => { + const run = runWrapper(["-z", "--resume", "20260612_050401_aa9d27"], {}); + + expect(run.status).toBe(0); + expect(run.stderr).toBe(""); + expect(run.realInvoked).toBe(true); + expect(run.realArgs).toBe("-z --resume 20260612_050401_aa9d27"); + }); + + it("passes separated --resume with an empty value through instead of translating an invalid selector (#5254)", () => { + const run = runWrapper(["--resume", "", "-z", "Repeat it"], {}); + + expect(run.status).toBe(0); + expect(run.stderr).toBe(""); + expect(run.realInvoked).toBe(true); + expect(run.realArgs).toBe("--resume -z Repeat it"); + }); + + it("passes separated --resume with a flag-like value through instead of translating an invalid selector (#5254)", () => { + const run = runWrapper(["--resume", "-z", "--oneshot=Repeat it"], {}); + + expect(run.status).toBe(0); + expect(run.stderr).toBe(""); + expect(run.realInvoked).toBe(true); + expect(run.realArgs).toBe("--resume -z --oneshot=Repeat it"); + }); + + it("passes value flags without required arguments through instead of translating partial argv (#5254)", () => { + const run = runWrapper( + ["--model", "--resume", "20260612_050401_aa9d27", "-z", "Repeat it"], + {}, + ); + + expect(run.status).toBe(0); + expect(run.stderr).toBe(""); + expect(run.realInvoked).toBe(true); + expect(run.realArgs).toBe("--model --resume 20260612_050401_aa9d27 -z Repeat it"); + }); +}); diff --git a/test/onboard-anthropic-compatible-openai-agent.test.ts b/test/onboard-anthropic-compatible-openai-agent.test.ts index 1867c735252..6923e321cba 100644 --- a/test/onboard-anthropic-compatible-openai-agent.test.ts +++ b/test/onboard-anthropic-compatible-openai-agent.test.ts @@ -71,6 +71,7 @@ describe("compatible-anthropic-endpoint registration for OpenAI-only agents (#62 // The probe must exercise the same /v1 base OpenShell will call at // runtime ( + /v1/chat/completions with /v1 dedup). expect(probeOpenAiLikeEndpoint).toHaveBeenCalledWith(SURFACE_URL, MODEL, "hub-secret", { + pinnedAddresses: ["93.184.216.34"], skipResponsesProbe: true, }); const createCommand = harness.commands.find(({ command }) => diff --git a/test/onboard-inference-smoke.test.ts b/test/onboard-inference-smoke.test.ts index 41d2a04eb03..7e248e16df5 100644 --- a/test/onboard-inference-smoke.test.ts +++ b/test/onboard-inference-smoke.test.ts @@ -105,7 +105,10 @@ process.env.NEMOCLAW_ONBOARD_INFERENCE_SMOKE_E2E = "1"; process.env.NEMOCLAW_TEST_NO_SLEEP = "1"; process.env.BROKEN_API_KEY = "test-key"; -const { setupInference } = require(${onboardPath}); +const { createSetupInference } = require(${onboardPath}); +const setupInference = createSetupInference({ + resolveEndpointHost: async () => [{ address: "93.184.216.34", family: 4 }], +}); (async () => { await setupInference( diff --git a/test/onboard-resume-provider-recovery.test.ts b/test/onboard-resume-provider-recovery.test.ts index 5c47efc81e9..90437c63209 100644 --- a/test/onboard-resume-provider-recovery.test.ts +++ b/test/onboard-resume-provider-recovery.test.ts @@ -542,6 +542,7 @@ onboardSession.loadSession = () => ({ credentials.prompt = async () => ""; credentials.ensureApiKey = async () => {}; process.env.NEMOCLAW_NON_INTERACTIVE = "1"; +require("node:dns/promises").lookup = async () => [{ address: "93.184.216.34", family: 4 }]; const { setupNim } = require(${onboardPath}); (async () => { diff --git a/test/onboard-selection-anthropic-retry.test.ts b/test/onboard-selection-anthropic-retry.test.ts index cd8fa4ed031..2b7104d1434 100644 --- a/test/onboard-selection-anthropic-retry.test.ts +++ b/test/onboard-selection-anthropic-retry.test.ts @@ -98,6 +98,7 @@ credentials.prompt = async (message) => { return answers.shift() || ""; }; runner.runCapture = () => ""; +require("node:dns/promises").lookup = async () => [{ address: "93.184.216.34", family: 4 }]; const { setupNim } = require(${onboardPath}); @@ -179,6 +180,7 @@ credentials.prompt = async (message) => { }; runner.runCapture = () => ""; +require("node:dns/promises").lookup = async () => [{ address: "93.184.216.34", family: 4 }]; const { setupNim } = require(${onboardPath}); (async () => { diff --git a/test/onboard-selection-windows-provider-rejection.test.ts b/test/onboard-selection-windows-provider-rejection.test.ts new file mode 100644 index 00000000000..337549361c4 --- /dev/null +++ b/test/onboard-selection-windows-provider-rejection.test.ts @@ -0,0 +1,159 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { describe, it, vi } from "vitest"; +import { getWindowsHostOllamaDockerRequirement } from "../src/lib/onboard/local-inference-topology.js"; +import { buildInferenceProviderMenu } from "../src/lib/onboard/provider-menu.js"; +import { resolveRequestedProviderSelection } from "../src/lib/onboard/provider-selection.js"; +import { reportProviderSelectionFailure } from "../src/lib/onboard/provider-selection-failure.js"; + +import { requireFailedProviderResolution } from "./support/onboard-selection-test-helpers.js"; + +const TEST_REMOTE_PROVIDER_CONFIG = { + build: { label: "NVIDIA Endpoints", providerName: "nvidia-prod" }, + openai: { label: "OpenAI", providerName: "openai-api" }, + custom: { + label: "Other OpenAI-compatible endpoint", + providerName: "compatible-endpoint", + }, + anthropic: { label: "Anthropic", providerName: "anthropic-prod" }, + anthropicCompatible: { + label: "Other Anthropic-compatible endpoint", + providerName: "compatible-anthropic-endpoint", + }, + gemini: { label: "Google Gemini", providerName: "gemini-api" }, +}; + +type WindowsRequirement = ReturnType; +type ProviderMenuOverrides = Partial[0]>; + +function buildProviderMenu(overrides: ProviderMenuOverrides = {}) { + return buildInferenceProviderMenu({ + remoteProviderConfig: TEST_REMOTE_PROVIDER_CONFIG, + agentProviderOptions: [], + experimental: false, + gpuNimCapable: false, + hasOllama: false, + ollamaRunning: false, + ollamaHost: null, + ollamaPort: 11434, + isWsl: false, + hasWindowsOllama: false, + isWindowsHostOllama: false, + windowsHostLabelSuffix: "", + windowsHostInstallLabel: "Install Ollama on Windows host (recommended)", + windowsHostStartLabel: () => "Start Ollama on Windows host (suggested)", + windowsOllamaReachable: false, + winOllamaLoopbackOnly: false, + ollamaInstallEntry: null, + vllmEntries: [], + routedEnabled: false, + ...overrides, + }); +} + +function buildWindowsProviderMenu( + requirement: WindowsRequirement, + overrides: ProviderMenuOverrides = {}, +) { + return buildProviderMenu({ + isWsl: true, + windowsHostLabelSuffix: requirement.supported ? "" : requirement.labelSuffix, + windowsHostInstallLabel: requirement.installLabel, + windowsHostStartLabel: requirement.startLabel, + ...overrides, + }); +} + +function resolveWindowsProvider( + options: Array<{ key: string; label: string }>, + requestedProvider: string, + overrides: Partial[0]> = {}, +) { + return resolveRequestedProviderSelection({ + options, + requestedProvider, + sandboxName: null, + remoteProviderConfig: TEST_REMOTE_PROVIDER_CONFIG, + isWsl: true, + isWindowsHostOllama: false, + windowsHostOllamaSupported: true, + hermesProviderAvailable: false, + readRecordedProvider: () => null, + readRecordedNimContainer: () => null, + readRecordedModel: () => null, + ...overrides, + }); +} + +describe("onboard Windows-host Ollama provider rejection", () => { + it("does not satisfy start-windows-ollama with WSL-local Ollama", () => { + const requirement = getWindowsHostOllamaDockerRequirement("docker-desktop"); + const { options } = buildWindowsProviderMenu(requirement, { + hasOllama: true, + ollamaRunning: true, + ollamaHost: "127.0.0.1", + hasWindowsOllama: false, + }); + const resolution = resolveWindowsProvider(options, "start-windows-ollama", { + isWsl: true, + isWindowsHostOllama: false, + }); + assert.equal(resolution.kind, "failure"); + const failedResolution = requireFailedProviderResolution(resolution); + + const setup = vi.fn(); + const switchHost = vi.fn(); + const errors: string[] = []; + reportProviderSelectionFailure({ + reason: failedResolution.reason, + isWindowsHostOllama: false, + rejectWindowsHostOllama: () => { + setup(); + switchHost(); + return true; + }, + writeError: (message) => errors.push(message), + }); + + assert.match(errors.join("\n"), /Requested provider 'start-windows-ollama' is not available/); + assert.equal(setup.mock.calls.length, 0); + assert.equal(switchHost.mock.calls.length, 0); + }); + + it("does not satisfy install-windows-ollama with non-WSL local Ollama", () => { + const requirement = getWindowsHostOllamaDockerRequirement(null); + const { options } = buildWindowsProviderMenu(requirement, { + hasOllama: true, + ollamaRunning: true, + ollamaHost: "127.0.0.1", + isWsl: false, + hasWindowsOllama: false, + }); + const resolution = resolveWindowsProvider(options, "install-windows-ollama", { + isWsl: false, + isWindowsHostOllama: false, + }); + assert.equal(resolution.kind, "failure"); + const failedResolution = requireFailedProviderResolution(resolution); + + const install = vi.fn(); + const setup = vi.fn(); + const errors: string[] = []; + reportProviderSelectionFailure({ + reason: failedResolution.reason, + isWindowsHostOllama: false, + rejectWindowsHostOllama: () => { + install(); + setup(); + return true; + }, + writeError: (message) => errors.push(message), + }); + + assert.match(errors.join("\n"), /Requested provider 'install-windows-ollama' is not available/); + assert.equal(install.mock.calls.length, 0); + assert.equal(setup.mock.calls.length, 0); + }); +}); diff --git a/test/onboard-selection.test.ts b/test/onboard-selection.test.ts index 34f55b451ff..381b3a2461d 100644 --- a/test/onboard-selection.test.ts +++ b/test/onboard-selection.test.ts @@ -244,6 +244,14 @@ const TEST_CUSTOM_OPENAI_CONFIG = { endpointUrl: TEST_OPENAI_ENDPOINT_URL, helpUrl: null, }; +// Shared expected 4th arg for probeOpenAiLikeEndpoint; pinnedAddresses is what +// the injected resolveEndpointHost returns via the SSRF preflight (#6293). +const EXPECTED_CUSTOM_ENDPOINT_PROBE_OPTIONS = { + requireResponsesToolCalling: true, + skipResponsesProbe: false, + probeStreaming: true, + pinnedAddresses: ["93.184.216.34"], +}; const TEST_CUSTOM_ANTHROPIC_CONFIG = { label: "Other Anthropic-compatible endpoint", endpointUrl: TEST_ANTHROPIC_ENDPOINT_URL, @@ -2861,6 +2869,7 @@ const { setupNim } = require(${onboardPath}); label: "Anthropic Messages API", }), promptValidationRecovery: recovery.promptValidationRecovery, + resolveEndpointHost: async () => [{ address: "93.184.216.34", family: 4 }], }); const state = makeRemoteSelectionState({ model, @@ -2926,6 +2935,7 @@ const { setupNim } = require(${onboardPath}); }; }, promptValidationRecovery: recovery.promptValidationRecovery, + resolveEndpointHost: async () => [{ address: "93.184.216.34", family: 4 }], }); const { validateSelectedRemoteModel } = createRemoteModelValidator( makeRemoteModelValidatorDeps({ @@ -2999,10 +3009,11 @@ const { setupNim } = require(${onboardPath}); getCredential: () => "ollama-key", probeOpenAiLikeEndpoint, promptValidationRecovery: recovery.promptValidationRecovery, + resolveEndpointHost: async () => [{ address: "93.184.216.34", family: 4 }], }); const state = makeRemoteSelectionState({ model: "my-model", - endpointUrl: "https://ollama.local:11434/v1", + endpointUrl: "https://ollama.public.test:11434/v1", }); const { validateSelectedRemoteModel } = createRemoteModelValidator( makeRemoteModelValidatorDeps({ @@ -3026,14 +3037,10 @@ const { setupNim } = require(${onboardPath}); assert.equal(state.preferredInferenceApi, "openai-completions"); assert.ok(lines.some((line) => line.includes("Using chat completions API"))); expect(probeOpenAiLikeEndpoint).toHaveBeenCalledWith( - "https://ollama.local:11434/v1", + "https://ollama.public.test:11434/v1", "my-model", "ollama-key", - { - requireResponsesToolCalling: true, - skipResponsesProbe: false, - probeStreaming: true, - }, + EXPECTED_CUSTOM_ENDPOINT_PROBE_OPTIONS, ); } finally { restoreProcessEnvValue("NEMOCLAW_PREFERRED_API", previousPreferredApi); @@ -3055,6 +3062,7 @@ const { setupNim } = require(${onboardPath}); getCredential: () => "sk-test", probeOpenAiLikeEndpoint, promptValidationRecovery: recovery.promptValidationRecovery, + resolveEndpointHost: async () => [{ address: "93.184.216.34", family: 4 }], }); const state = makeRemoteSelectionState({ model: "gpt-4o", @@ -3089,11 +3097,7 @@ const { setupNim } = require(${onboardPath}); "https://openai-proxy.example.com/v1", "gpt-4o", "sk-test", - { - requireResponsesToolCalling: true, - skipResponsesProbe: false, - probeStreaming: true, - }, + EXPECTED_CUSTOM_ENDPOINT_PROBE_OPTIONS, ); } finally { restoreProcessEnvValue("NEMOCLAW_PREFERRED_API", previousPreferredApi); @@ -3214,6 +3218,7 @@ const { setupNim } = require(${onboardPath}); }; }, promptValidationRecovery: recovery.promptValidationRecovery, + resolveEndpointHost: async () => [{ address: "93.184.216.34", family: 4 }], }); const { validateSelectedRemoteModel } = createRemoteModelValidator( makeRemoteModelValidatorDeps({ @@ -3761,6 +3766,10 @@ const { setupNim } = require(${onboardPath}); (async () => { process.env.COMPATIBLE_API_KEY = "proxy-bad"; + // The endpoint SSRF preflight now runs unconditionally (#6293); stub the DNS + // resolver to a public address so the fixture hostname resolves and the flow + // reaches validation instead of being refused (mirrors credentials/runner stubs). + require("node:dns/promises").lookup = async () => [{ address: "93.184.216.34", family: 4 }]; const originalLog = console.log; const originalError = console.error; const lines = []; @@ -4684,75 +4693,6 @@ const { setupNim } = require(${onboardPath}); } }); - it("does not satisfy start-windows-ollama with WSL-local Ollama", () => { - const requirement = getWindowsHostOllamaDockerRequirement("docker-desktop"); - const { options } = buildWindowsProviderMenu(requirement, { - hasOllama: true, - ollamaRunning: true, - ollamaHost: "127.0.0.1", - hasWindowsOllama: false, - }); - const resolution = resolveWindowsProvider(options, "start-windows-ollama", { - isWsl: true, - isWindowsHostOllama: false, - }); - assert.equal(resolution.kind, "failure"); - const failedResolution = requireFailedProviderResolution(resolution); - - const setup = vi.fn(); - const switchHost = vi.fn(); - const errors: string[] = []; - reportProviderSelectionFailure({ - reason: failedResolution.reason, - isWindowsHostOllama: false, - rejectWindowsHostOllama: () => { - setup(); - switchHost(); - return true; - }, - writeError: (message) => errors.push(message), - }); - - assert.match(errors.join("\n"), /Requested provider 'start-windows-ollama' is not available/); - assert.equal(setup.mock.calls.length, 0); - assert.equal(switchHost.mock.calls.length, 0); - }); - - it("does not satisfy install-windows-ollama with non-WSL local Ollama", () => { - const requirement = getWindowsHostOllamaDockerRequirement(null); - const { options } = buildWindowsProviderMenu(requirement, { - hasOllama: true, - ollamaRunning: true, - ollamaHost: "127.0.0.1", - isWsl: false, - hasWindowsOllama: false, - }); - const resolution = resolveWindowsProvider(options, "install-windows-ollama", { - isWsl: false, - isWindowsHostOllama: false, - }); - assert.equal(resolution.kind, "failure"); - const failedResolution = requireFailedProviderResolution(resolution); - - const install = vi.fn(); - const setup = vi.fn(); - const errors: string[] = []; - reportProviderSelectionFailure({ - reason: failedResolution.reason, - isWindowsHostOllama: false, - rejectWindowsHostOllama: () => { - install(); - setup(); - return true; - }, - writeError: (message) => errors.push(message), - }); - - assert.match(errors.join("\n"), /Requested provider 'install-windows-ollama' is not available/); - assert.equal(install.mock.calls.length, 0); - assert.equal(setup.mock.calls.length, 0); - }); - it("honours NEMOCLAW_LOCAL_INFERENCE_TIMEOUT for compatible-endpoint during inference setup (#2403)", () => { const repoRoot = path.join(import.meta.dirname, ".."); const tmpDir = fs.mkdtempSync( @@ -4801,7 +4741,7 @@ runner.runCapture = (cmd) => { process.env.COMPATIBLE_API_KEY = "test-key"; const { setupInference } = require(${onboardPath}); (async () => { - await setupInference(null, "qwen3.6:35b", "compatible-endpoint", "http://lan-server:11434/v1", "COMPATIBLE_API_KEY", null, [], { preferredInferenceApi: "openai-completions" }); + await setupInference(null, "qwen3.6:35b", "compatible-endpoint", "http://public-server.example:11434/v1", "COMPATIBLE_API_KEY", null, [], { preferredInferenceApi: "openai-completions", endpointPinnedAddresses: ["93.184.216.34"] }); process.exit(0); })().catch((err) => { console.error(err); process.exit(1); }); `; diff --git a/test/openclaw-slack-deny-feedback-patch.test.ts b/test/openclaw-slack-deny-feedback-patch.test.ts index 4fc18f9b445..d11ef1f2a14 100644 --- a/test/openclaw-slack-deny-feedback-patch.test.ts +++ b/test/openclaw-slack-deny-feedback-patch.test.ts @@ -18,6 +18,17 @@ const SLACK_GUARD = path.join( "runtime", "slack-channel-guard.ts", ); +const WHATSAPP_QR_COMPACT = path.join( + import.meta.dirname, + "..", + "src", + "lib", + "messaging", + "channels", + "whatsapp", + "runtime", + "whatsapp-qr-compact.ts", +); // Minimal stand-in for the compiled @openclaw/slack prepare module: a denying // channel gate that mirrors the real dist's deny-log line and exposes the same @@ -79,10 +90,15 @@ type FeedbackCall = { method: string; channel?: string; user?: string; text?: st function runGuardProbe( prepareFile: string, - options: { loadMode?: "require" | "import"; requireGuardTwice?: boolean } = {}, + options: { + loadMode?: "require" | "import"; + requireGuardTwice?: boolean; + withWhatsappPreload?: boolean; + } = {}, ) { const script = ` const guard = ${JSON.stringify(SLACK_GUARD)}; +${options.withWhatsappPreload ? `require(${JSON.stringify(WHATSAPP_QR_COMPACT)});` : ""} require(guard); ${options.requireGuardTwice ? "require(guard);" : ""} const { pathToFileURL } = require("node:url"); @@ -240,7 +256,10 @@ describe("OpenClaw Slack denial-feedback patch", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-slack-deny-esm-")); const prepareFile = writeSlackPackage(tmp, { moduleType: "esm" }); try { - const { result, output } = runGuardProbe(prepareFile, { loadMode: "import" }); + const { result, output } = runGuardProbe(prepareFile, { + loadMode: "import", + withWhatsappPreload: true, + }); expect(result.status, `${result.stdout}${result.stderr}`).toBe(0); expect(fs.readFileSync(prepareFile, "utf-8")).not.toContain( "__nemoclawNotifyDeniedSlackMention", diff --git a/test/package-contract/onboard/compatible-endpoint-reasoning.test.ts b/test/package-contract/onboard/compatible-endpoint-reasoning.test.ts index 3236e77d2d7..46c047a5055 100644 --- a/test/package-contract/onboard/compatible-endpoint-reasoning.test.ts +++ b/test/package-contract/onboard/compatible-endpoint-reasoning.test.ts @@ -66,6 +66,10 @@ const { setupNim } = require(${onboardPath}); (async () => { process.env.COMPATIBLE_API_KEY = "proxy-key"; process.env.NEMOCLAW_REASONING = "yes"; + // The endpoint SSRF preflight now runs unconditionally (#6293); stub the DNS + // resolver to a public address so the fixture hostname resolves and the flow + // reaches validation instead of being refused (mirrors credentials/runner stubs). + require("node:dns/promises").lookup = async () => [{ address: "93.184.216.34", family: 4 }]; const originalLog = console.log; const originalError = console.error; const lines = []; diff --git a/test/repro-2681-group-writable.test.ts b/test/repro-2681-group-writable.test.ts index c3c40c09cde..194e70d0340 100644 --- a/test/repro-2681-group-writable.test.ts +++ b/test/repro-2681-group-writable.test.ts @@ -125,7 +125,7 @@ function runMutableConfigNormalizer(configDir: string, ownedPaths: string[]) { function withMockedDockerExecFileSync( calls: string[][], run: () => T, - options: { symlinkedPaths?: ReadonlySet } = {}, + options: { hermesLockedTransaction?: boolean; symlinkedPaths?: ReadonlySet } = {}, ): T { // eslint-disable-next-line @typescript-eslint/no-require-imports const dockerExecModule = require("../src/lib/adapters/docker/exec.js") as { @@ -147,19 +147,30 @@ function withMockedDockerExecFileSync( }, } as any; + let hermesFinished = false; dockerExecModule.dockerExecFileSync = vi.fn((args: readonly string[]) => { const separator = args.indexOf("--"); const command = separator >= 0 ? args.slice(separator + 1) : [...args]; calls.push(command); const hermesGuardIndex = command.indexOf(HERMES_RUNTIME_CONFIG_GUARD); const hermesAction = command[hermesGuardIndex + 1] ?? ""; + switch (hermesAction) { + case "finish-shields-transition": + hermesFinished = true; + break; + } const hermesResponse = hermesGuardIndex < 0 ? undefined : (new Map([ ["--help", HERMES_SEALED_GUARD_HELP], ["begin-shields-transition", `lock_token=${HERMES_LOCK_TOKEN} original_locked=0`], - ["apply-shields-transition", "shields_mode=mutable chattr_applied=0"], + [ + "apply-shields-transition", + options.hermesLockedTransaction + ? "shields_mode=locked chattr_applied=1" + : "shields_mode=mutable chattr_applied=0", + ], ]).get(hermesAction) ?? ""); switch (hermesResponse) { case undefined: @@ -179,19 +190,27 @@ function withMockedDockerExecFileSync( const target = command.at(-1); switch (target) { case "/sandbox": - return "755 sandbox:sandbox\n"; + return options.hermesLockedTransaction + ? hermesFinished + ? "1775 root:sandbox\n" + : "755 root:root\n" + : "755 sandbox:sandbox\n"; case "/sandbox/.openclaw": return "2770 sandbox:sandbox\n"; case "/sandbox/.hermes": - return "3770 sandbox:sandbox\n"; + return options.hermesLockedTransaction ? "755 root:root\n" : "3770 sandbox:sandbox\n"; } if (typeof target === "string" && target.startsWith("/sandbox/.hermes/")) { - return "640 sandbox:sandbox\n"; + return options.hermesLockedTransaction ? "444 root:root\n" : "640 sandbox:sandbox\n"; } return "660 sandbox:sandbox\n"; } if (command[0] === "lsattr") { - return `---------------------- ${command.at(-1)}\n`; + return `${options.hermesLockedTransaction ? "----i-----------------" : "----------------------"} ${command.at(-1)}\n`; + } + switch (command[0]) { + case "sha256sum": + return `${"a".repeat(64)} ${command.at(-1)}\n`; } return ""; }); @@ -571,6 +590,45 @@ describe("mutable agent config permissions", () => { expect(commands).toContainEqual(["stat", "-c", "%a %U:%G", "/sandbox/.hermes/.env"]); }); + it("verifies the frozen Hermes tree before publishing and checking the locked parent", () => { + const commands: string[][] = []; + withMockedDockerExecFileSync( + commands, + () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { lockAgentConfig } = require("../src/lib/shields/index.js") as { + lockAgentConfig: ( + sandboxName: string, + target: { + agentName?: string; + configPath: string; + configDir: string; + sensitiveFiles?: string[]; + }, + ) => void; + }; + + lockAgentConfig("sandbox-pod", { + agentName: "hermes", + configPath: "/sandbox/.hermes/config.yaml", + configDir: "/sandbox/.hermes", + sensitiveFiles: ["/sandbox/.hermes/.env", "/sandbox/.hermes/.config-hash"], + }); + }, + { hermesLockedTransaction: true }, + ); + + const finishIndex = commands.findIndex((command) => + command.includes("finish-shields-transition"), + ); + const parentStats = commands + .map((command, index) => ({ command, index })) + .filter(({ command }) => command.at(-1) === "/sandbox" && command[0] === "stat"); + expect(finishIndex).toBeGreaterThan(0); + expect(parentStats).toHaveLength(1); + expect(parentStats[0].index).toBeGreaterThan(finishIndex); + }); + it("shields-up strips setgid from the OpenClaw config root before verifying lock", () => { const probe = spawnSync( process.execPath, diff --git a/test/support/setup-inference-test-harness.ts b/test/support/setup-inference-test-harness.ts index 11eaf2e6177..b105af4dd61 100644 --- a/test/support/setup-inference-test-harness.ts +++ b/test/support/setup-inference-test-harness.ts @@ -318,6 +318,9 @@ export function createDirectSetupInferenceHarnessFactory( }, hydrateCredentialEnv: (envName: string | null | undefined) => envName ? process.env[envName] || null : null, + // Direct setup tests use documentation-only hostnames and intentionally + // bypass the selection phase that normally supplies validated pins. + resolveEndpointHost: async () => [{ address: "93.184.216.34", family: 4 }], promptValidationRecovery: async () => "selection", validateLocalProvider: () => ({ ok: true }), getLocalProviderHealthCheck: () => null,