Skip to content
Merged
2 changes: 1 addition & 1 deletion ci/source-architecture-budget.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"src/lib/core/json-types.ts": 37,
"src/lib/core/ports.ts": 89,
"src/lib/core/shell-quote.ts": 28,
"src/lib/core/url-utils.ts": 27,
"src/lib/core/url-utils.ts": 29,
"src/lib/core/wait.ts": 35,
"src/lib/credentials/store.ts": 46,
"src/lib/inference/config.ts": 30,
Expand Down
2 changes: 2 additions & 0 deletions docs/inference/custom-endpoint-security.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ Blocked special-purpose targets include documentation, translation, benchmarking
It also rejects public hostnames that resolve to a blocked address.

A direct blueprint run rejects endpoint URLs that contain userinfo, such as an embedded username or password, before DNS resolution.
Custom endpoint onboarding rejects endpoint URLs that contain userinfo, query, or fragment components instead of stripping those components.
NemoClaw does not forward those components to the endpoint.
Configure the provider credential separately instead of putting it in the endpoint URL.

Managed provider defaults that do not provide an explicit custom endpoint through these paths are unaffected.
Expand Down
2 changes: 1 addition & 1 deletion docs/inference/set-up-openai-compatible-endpoint.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ NEMOCLAW_PROVIDER=custom \
| Variable | Purpose |
|---|---|
| `NEMOCLAW_PROVIDER` | Set to `custom`. |
| `NEMOCLAW_ENDPOINT_URL` | Base URL of the server. |
| `NEMOCLAW_ENDPOINT_URL` | Base URL of the server, without userinfo, query, or fragment components. |
| `NEMOCLAW_MODEL` | Model ID reported by the server. |
| `NEMOCLAW_COMPATIBLE_AUTH_MODE` | Set to `none` to explicitly select no authentication for an HTTP endpoint using `localhost`, `127.0.0.1`, or `[::1]` and port `8000`, `11434`, or `11435`. |
| `NEMOCLAW_REASONING` | Enables reasoning-only validation with the case-insensitive true values `true`, `1`, `yes`, and `y`. |
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -4619,7 +4619,7 @@ Set them before running `$$nemoclaw onboard`.
| `NEMOCLAW_LLAMACPP_RECIPE` | repository-owned managed-inference recipe ID | Selects the managed llama.cpp recipe when `NEMOCLAW_PROVIDER=install-llama-cpp`. When unset, NemoClaw selects the one shipped managed recipe. An unknown recipe or a stale or incompatible readiness report fails before image, model, or runtime effects. |
| `NEMOCLAW_MODEL` | model ID | Selects an explicit model for a non-interactive onboarding run. NemoClaw preserves it across a detected provider switch, even when it matches the recorded provider's default. When this variable is unset during such a switch, NemoClaw ignores the `NEMOCLAW_PROVIDER_MODEL` compatibility fallback and uses normal provider model selection. |
| `NEMOCLAW_TOOL_DISCLOSURE` | `progressive` or `direct` | Selects progressive tool discovery or the prior direct-exposure behavior. Defaults to `progressive`; `--tool-disclosure` takes precedence when both are set. |
| `NEMOCLAW_ENDPOINT_URL` | URL | Custom endpoint URL. Used together with `NEMOCLAW_PROVIDER=custom` for OpenAI-compatible endpoints or `NEMOCLAW_PROVIDER=anthropicCompatible` for Anthropic-compatible endpoints. |
| `NEMOCLAW_ENDPOINT_URL` | URL | Custom endpoint URL. Used together with `NEMOCLAW_PROVIDER=custom` for OpenAI-compatible endpoints or `NEMOCLAW_PROVIDER=anthropicCompatible` for Anthropic-compatible endpoints. Onboarding rejects a URL that contains userinfo, query, or fragment components. |
| `NEMOCLAW_COMPATIBLE_AUTH_MODE` | `none` or unset | Explicitly selects no authentication for an HTTP OpenAI-compatible endpoint using `localhost`, `127.0.0.1`, or `[::1]` and port `8000`, `11434`, or `11435` during non-interactive onboarding. |
| `NEMOCLAW_TRUSTED_PRIVATE_HOSTS` | comma-separated exact hostnames or IP literals | Allows operator-owned RFC1918, CGNAT, or IPv6 unique local destinations through supported inference, managed MCP, and custom-policy registration paths. Link-local metadata and other reserved ranges remain blocked; DNS resolution and exact address pinning remain active; wildcards are not supported. |
| `NEMOCLAW_TRUSTED_PRIVATE_INFERENCE_HOSTS` | comma-separated exact hostnames or IP literals | Inference-only compatibility alias. Inference onboarding combines entries from this variable and `NEMOCLAW_TRUSTED_PRIVATE_HOSTS`. |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ describe("rebuildSandbox flow: target session", () => {
harness.session.provider = "compatible-endpoint";
harness.session.model = "session-model";
harness.session.preferredInferenceApi = "openai-completions";
harness.session.endpointUrl = "https://my-custom-endpoint.example/v1?x=1#frag";
harness.session.endpointUrl = "https://my-custom-endpoint.example/v1/";

await expect(
harness.rebuildSandbox("alpha", ["--yes"], { throwOnError: true }),
Expand Down
61 changes: 51 additions & 10 deletions src/lib/actions/sandbox/rebuild-resume-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,10 +113,7 @@ describe("getRebuildEndpointFromRegistry", () => {

it("uses canonical durable custom endpoint metadata from the sandbox registry", () => {
expect(
getRebuildEndpointFromRegistry(
"compatible-endpoint",
" http://127.0.0.1:19999/v1/?x=1#frag ",
),
getRebuildEndpointFromRegistry("compatible-endpoint", " http://127.0.0.1:19999/v1/ "),
).toEqual({
known: true,
endpointUrl: "http://127.0.0.1:19999/v1",
Expand All @@ -133,6 +130,21 @@ describe("getRebuildEndpointFromRegistry", () => {
expect(
getRebuildEndpointFromRegistry("compatible-endpoint", "https://u:p@example.test/v1"),
).toEqual({ known: false });
expect(
getRebuildEndpointFromRegistry("compatible-endpoint", "https://example.test/v1?x=1"),
).toEqual({ known: false });
expect(getRebuildEndpointFromRegistry("compatible-endpoint", "http://@example.test/v1")).toEqual(
{ known: false },
);
expect(
getRebuildEndpointFromRegistry(
"compatible-endpoint",
"https:user:password@example.test/v1",
),
).toEqual({ known: false });
expect(
getRebuildEndpointFromRegistry("compatible-endpoint", "https://example.test/v1#frag"),
).toEqual({ known: false });
});
});

Expand Down Expand Up @@ -236,7 +248,7 @@ describe("prepareRebuildResumeConfig", () => {
sandboxName: "alpha",
provider: "compatible-endpoint",
model: "m",
endpointUrl: " http://127.0.0.1:19999/v1/?x=1#frag ",
endpointUrl: " http://127.0.0.1:19999/v1/ ",
});
const config = prepareRebuildResumeConfig(
"alpha",
Expand Down Expand Up @@ -265,7 +277,7 @@ describe("prepareRebuildResumeConfig", () => {
entry({
provider: "compatible-endpoint",
model: "m",
endpointUrl: "https://registry.example.test/v1?x=1#frag",
endpointUrl: "https://registry.example.test/v1",
preferredInferenceApi: "openai-completions",
}),
null,
Expand Down Expand Up @@ -293,7 +305,7 @@ describe("prepareRebuildResumeConfig", () => {
sandboxName: "alpha",
provider: "compatible-endpoint",
model: "m",
endpointUrl: "https://session.example.test/v1?x=1#frag",
endpointUrl: "https://session.example.test/v1",
});
const restore = snapshotEnv([
"NEMOCLAW_SANDBOX_NAME",
Expand Down Expand Up @@ -406,7 +418,7 @@ describe("prepareRebuildResumeConfig", () => {
try {
process.env.NEMOCLAW_SANDBOX_NAME = "alpha";
process.env.NEMOCLAW_PROVIDER = "custom";
process.env.NEMOCLAW_ENDPOINT_URL = " http://127.0.0.1:19999/v1/?x=1#frag ";
process.env.NEMOCLAW_ENDPOINT_URL = " http://127.0.0.1:19999/v1/ ";
process.env.NEMOCLAW_MODEL = "m";
const config = prepareRebuildResumeConfig(
"alpha",
Expand Down Expand Up @@ -438,7 +450,7 @@ describe("prepareRebuildResumeConfig", () => {
try {
process.env.NEMOCLAW_SANDBOX_NAME = "alpha";
process.env.NEMOCLAW_PROVIDER = "anthropicCompatible";
process.env.NEMOCLAW_ENDPOINT_URL = "https://anthropic.example.test/v1?x=1#frag";
process.env.NEMOCLAW_ENDPOINT_URL = "https://anthropic.example.test/v1";
process.env.NEMOCLAW_MODEL = "claude-like";
const config = prepareRebuildResumeConfig(
"alpha",
Expand All @@ -458,6 +470,33 @@ describe("prepareRebuildResumeConfig", () => {
}
});

it("rejects component-bearing explicit endpoints for the anthropicCompatible alias (#9106)", () => {
vi.spyOn(onboardSession, "loadSession").mockReturnValue({ sandboxName: "other" });
const restore = snapshotEnv([
"NEMOCLAW_SANDBOX_NAME",
"NEMOCLAW_PROVIDER",
"NEMOCLAW_ENDPOINT_URL",
"NEMOCLAW_MODEL",
]);
try {
process.env.NEMOCLAW_SANDBOX_NAME = "alpha";
process.env.NEMOCLAW_PROVIDER = "anthropicCompatible";
process.env.NEMOCLAW_ENDPOINT_URL = "https://u:p@anthropic.example.test/v1?x=1#frag";
process.env.NEMOCLAW_MODEL = "claude-like";
expect(() =>
prepareRebuildResumeConfig(
"alpha",
entry({ provider: "compatible-anthropic-endpoint", model: "claude-like" }),
null,
noopLog,
throwingBail,
),
).toThrow("Cannot determine recreate endpoint");
} finally {
restore();
}
});

it("rejects explicit target endpoints that do not exactly match the target boundary", () => {
const cases = [
{ name: "wrong sandbox", sandboxName: "beta" },
Expand All @@ -467,6 +506,8 @@ describe("prepareRebuildResumeConfig", () => {
{ name: "wrong model", model: "other-model" },
{ name: "unsupported url", endpointUrl: "file:///tmp/x" },
{ name: "userinfo url", endpointUrl: "https://u:p@example.test/v1" },
{ name: "query url", endpointUrl: "https://example.test/v1?x=1" },
{ name: "fragment url", endpointUrl: "https://example.test/v1#frag" },
];
for (const testCase of cases) {
vi.restoreAllMocks();
Expand Down Expand Up @@ -663,7 +704,7 @@ describe("prepareRebuildResumeConfig", () => {
entry({
provider: "compatible-endpoint",
model: "m",
endpointUrl: " https://example.test/v1?x=1#frag ",
endpointUrl: " https://example.test/v1/ ",
credentialEnv: "COMPATIBLE_API_KEY",
}),
null,
Expand Down
9 changes: 5 additions & 4 deletions src/lib/actions/sandbox/rebuild-resume-preflight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// SPDX-License-Identifier: Apache-2.0

import { D, R } from "../../cli/terminal-style";
import { endpointUrlHasUserinfoQueryOrFragment } from "../../core/url-utils";
import type { InferenceSelection } from "../../inference/selection";
import type { RegistryInferenceRoute } from "../../onboard/rebuild-route-handoff";
import { isRecoveredProviderCredentialReuseSelectionKey } from "../../onboard/recovered-provider-reuse";
Expand Down Expand Up @@ -97,13 +98,13 @@ const SESSION_ONLY_ENDPOINT_PROVIDER_NAMES = new Set(

export function canonicalCustomEndpointUrl(value: string | null | undefined): string | null {
const raw = typeof value === "string" ? value.trim() : "";
// #9106: reject userinfo, query, and fragment components instead of
// stripping them, matching onboarding intake.
if (endpointUrlHasUserinfoQueryOrFragment(raw)) return null;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
try {
const url = new URL(raw);
const supportedProtocol = url.protocol === "http:" || url.protocol === "https:";
const hasUserInfo = Boolean(url.username || url.password);
if (!supportedProtocol || hasUserInfo) return null;
url.search = "";
url.hash = "";
if (!supportedProtocol) return null;
const pathname = url.pathname.replace(/\/+$/, "");
url.pathname = pathname || "/";
return url.pathname === "/" ? url.origin : `${url.origin}${url.pathname}`;
Expand Down
31 changes: 31 additions & 0 deletions src/lib/core/url-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { describe, expect, it } from "vitest";
import {
canonicalEndpoint,
compactText,
endpointUrlHasUserinfoQueryOrFragment,
formatEnvAssignment,
isLoopbackHostname,
normalizeProviderBaseUrl,
Expand Down Expand Up @@ -114,6 +115,36 @@ describe("canonicalEndpoint", () => {
});
});

describe("endpointUrlHasUserinfoQueryOrFragment", () => {
it.each([
["query string", "http://127.0.0.1:8000/v1/custom-path?param=value", true],
["fragment", "https://proxy.example.com/v1#fragment", true],
["userinfo", "https://user:password@proxy.example.com/v1", true],
["username only", "https://user@proxy.example.com/v1", true],
["empty userinfo delimiter", "http://@example.test/v1", true],
["userinfo without slashes", "https:user:password@proxy.example.com/v1", true],
["userinfo after one slash", "https:/user:password@proxy.example.com/v1", true],
["userinfo after backslashes", "https:\\\\user:password@proxy.example.com/v1", true],
["empty userinfo after one slash", "https:/@example.test/v1", true],
["empty userinfo after extra slashes", "https:////@example.test/v1", true],
["at sign in the path", "https://example.com/v1/@user", false],
["scheme-less userinfo", "user:password@proxy.example.com/v1", true],
["scheme-less userinfo with query", "user:password@proxy.example.com/v1?x=1", true],
["userinfo in an unparseable URL", "https://user:password@proxy example.com/v1", true],
["userinfo with invalid percent-encoding", "https://user:password@proxy.example.com/%ZZ", true],
["bare trailing query delimiter", "https://proxy.example.com/v1?", true],
["bare trailing fragment delimiter", "https://proxy.example.com/v1#", true],
["clean base URL with path", "http://127.0.0.1:8000/v1/custom-path", false],
["clean origin", "https://proxy.example.com", false],
["unparseable input with a query", "not a url ?x=1", true],
["unparseable input without a query", "not a url", false],
["empty input", "", false],
["whitespace input", " ", false],
] as const)("classifies %s (#9106)", (_label, input, expected) => {
expect(endpointUrlHasUserinfoQueryOrFragment(input)).toBe(expected);
});
});

describe("isLoopbackHostname", () => {
it.each([
["localhost", true],
Expand Down
25 changes: 25 additions & 0 deletions src/lib/core/url-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,31 @@ export function normalizeProviderBaseUrl(
}
}

/** True when an endpoint input carries userinfo, query, or fragment components. */
export function endpointUrlHasUserinfoQueryOrFragment(value: string | null | undefined): boolean {
const raw = String(value || "").trim();
if (!raw) return false;
try {
const url = new URL(raw);
// A scheme-less input such as user:pass@host/v1 parses with scheme
// "user:" and empty userinfo; classify it from the raw string instead.
if (url.protocol !== "http:" && url.protocol !== "https:") {
return /[?#@]/.test(raw);
}
// Parsed fields catch every userinfo form the WHATWG parser accepts,
// including special-scheme URLs without canonical `//`. Test the raw
// authority as well so an empty userinfo delimiter (empty url.username)
// remains visible before parsing normalizes it away.
return (
Boolean(url.username || url.password) ||
/^https?:[\\/]*[^/?#\\]*@/i.test(raw) ||
/[?#]/.test(raw)
);
} catch {
return /[?#@]/.test(raw);
}
}

/** Return the bounded canonical form of a credential-free HTTP(S) provider endpoint. */
export function canonicalEndpoint(
value: string | null | undefined,
Expand Down
26 changes: 4 additions & 22 deletions src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ const {
clearNimContainerBeforeRetry,
createNvidiaFeaturedModelSession,
createRemoteModelValidator,
resolveCompatibleEndpointInput,
resolveCompatibleEndpointSelection,
}: typeof import("./onboard/setup-nim-selection") = require("./onboard/setup-nim-selection");
const setupNimFlow: typeof import("./onboard/setup-nim-flow") = require("./onboard/setup-nim-flow");
const openrouterSelection: typeof import("./onboard/openrouter-selection") = require("./onboard/openrouter-selection");
Expand Down Expand Up @@ -2818,7 +2818,7 @@ async function handleRemoteProviderSelection(args: RemoteProviderSelectionArgs,

if (selected.key === "custom" || selected.key === "anthropicCompatible") {
const kind = selected.key === "custom" ? "openai" : "anthropic";
const endpointInput = await resolveCompatibleEndpointInput({
const endpointSelection = await resolveCompatibleEndpointSelection({
kind,
envUrl: process.env.NEMOCLAW_ENDPOINT_URL,
recoveredEndpointUrl: recoveredFromSandbox
Expand All @@ -2828,28 +2828,10 @@ async function handleRemoteProviderSelection(args: RemoteProviderSelectionArgs,
nonInteractive: isNonInteractive(),
prompt,
});
const navigation = getNavigationChoice(endpointInput);
if (navigation === "back") {
console.log(" Returning to provider selection.");
console.log("");
return "retry-selection";
}
if (navigation === "exit") {
exitOnboardFromPrompt();
}
state.endpointUrl = normalizeProviderBaseUrl(endpointInput, kind);
if (!state.endpointUrl) {
console.error(
selected.key === "custom"
? " Endpoint URL is required for Other OpenAI-compatible endpoint."
: " Endpoint URL is required for Other Anthropic-compatible endpoint.",
);
if (isNonInteractive()) {
process.exit(1);
}
console.log("");
if (endpointSelection.action === "retry-selection") {
return "retry-selection";
}
state.endpointUrl = endpointSelection.endpointUrl;
if (selected.key === "anthropicCompatible") {
state.endpointUrl = bedrockRuntimeOnboard.normalizeCustomAnthropicEndpointUrl(
state.endpointUrl,
Expand Down
Loading
Loading