diff --git a/ci/source-architecture-budget.json b/ci/source-architecture-budget.json index 26d55a168d5..1cc4d44d583 100644 --- a/ci/source-architecture-budget.json +++ b/ci/source-architecture-budget.json @@ -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, diff --git a/docs/inference/custom-endpoint-security.mdx b/docs/inference/custom-endpoint-security.mdx index d355ba30ef4..05faadb1e62 100644 --- a/docs/inference/custom-endpoint-security.mdx +++ b/docs/inference/custom-endpoint-security.mdx @@ -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. diff --git a/docs/inference/set-up-openai-compatible-endpoint.mdx b/docs/inference/set-up-openai-compatible-endpoint.mdx index 50a8eb25cfc..2174606176a 100644 --- a/docs/inference/set-up-openai-compatible-endpoint.mdx +++ b/docs/inference/set-up-openai-compatible-endpoint.mdx @@ -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`. | diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 6e281572e0f..cd3fd61880a 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -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`. | diff --git a/src/lib/actions/sandbox/rebuild-flow-target-session.test.ts b/src/lib/actions/sandbox/rebuild-flow-target-session.test.ts index 7eccac91ecb..cb6df07fb17 100644 --- a/src/lib/actions/sandbox/rebuild-flow-target-session.test.ts +++ b/src/lib/actions/sandbox/rebuild-flow-target-session.test.ts @@ -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 }), diff --git a/src/lib/actions/sandbox/rebuild-resume-config.test.ts b/src/lib/actions/sandbox/rebuild-resume-config.test.ts index 5b794ae3e60..84fd6e57768 100644 --- a/src/lib/actions/sandbox/rebuild-resume-config.test.ts +++ b/src/lib/actions/sandbox/rebuild-resume-config.test.ts @@ -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", @@ -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 }); }); }); @@ -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", @@ -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, @@ -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", @@ -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", @@ -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", @@ -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" }, @@ -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(); @@ -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, diff --git a/src/lib/actions/sandbox/rebuild-resume-preflight.ts b/src/lib/actions/sandbox/rebuild-resume-preflight.ts index 7bbf1b2d482..a71c5e99c70 100644 --- a/src/lib/actions/sandbox/rebuild-resume-preflight.ts +++ b/src/lib/actions/sandbox/rebuild-resume-preflight.ts @@ -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"; @@ -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; 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}`; diff --git a/src/lib/core/url-utils.test.ts b/src/lib/core/url-utils.test.ts index 7dcdf14834f..f7a697a5d60 100644 --- a/src/lib/core/url-utils.test.ts +++ b/src/lib/core/url-utils.test.ts @@ -6,6 +6,7 @@ import { describe, expect, it } from "vitest"; import { canonicalEndpoint, compactText, + endpointUrlHasUserinfoQueryOrFragment, formatEnvAssignment, isLoopbackHostname, normalizeProviderBaseUrl, @@ -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], diff --git a/src/lib/core/url-utils.ts b/src/lib/core/url-utils.ts index 02bc9bfdd24..0f249f7de9a 100644 --- a/src/lib/core/url-utils.ts +++ b/src/lib/core/url-utils.ts @@ -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, diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 1f100e0d9a7..0708914e0d3 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -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"); @@ -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 @@ -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, diff --git a/src/lib/onboard/setup-nim-selection.ts b/src/lib/onboard/setup-nim-selection.ts index 62cb4a5ac61..874729c279e 100644 --- a/src/lib/onboard/setup-nim-selection.ts +++ b/src/lib/onboard/setup-nim-selection.ts @@ -1,12 +1,18 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { + canonicalEndpoint, + endpointUrlHasUserinfoQueryOrFragment, + normalizeProviderBaseUrl, +} from "../core/url-utils"; import { applyCompatibleEndpointContextWindow } from "../inference/compatible-endpoint-context"; import type { TrustedPrivateEndpointCapability } from "../inference/endpoint-ssrf-preflight"; import type { GatewayRouteDiscoveryConstraints } from "../inference/gateway-route-compatibility"; import { getProbeExtraHeaders } from "../inference/onboard-probes"; import type { OnboardInferenceCapabilityCache } from "./inference-capability-cache"; import type { NvidiaFeaturedModelSession } from "./nvidia-featured-model-selection"; +import { exitOnboardFromPrompt, getNavigationChoice } from "./prompt-helpers"; import type { ReasoningEffort } from "./reasoning-mode"; export { createNvidiaFeaturedModelSession } from "./nvidia-featured-model-selection"; @@ -110,6 +116,70 @@ export async function resolveCompatibleEndpointInput(args: { ); } +export type CompatibleEndpointSelection = + | { action: "retry-selection" } + | { action: "selected"; endpointUrl: string }; + +/** + * Resolve and validate the compatible-endpoint base URL: handle back/exit + * navigation, reject inputs that carry components NemoClaw cannot forward + * (#9106), and require a non-empty normalized base URL. + */ +export async function resolveCompatibleEndpointSelection(args: { + kind: CompatibleEndpointKind; + envUrl: string | null | undefined; + recoveredEndpointUrl: string | null | undefined; + nonInteractive: boolean; + prompt: (message: string) => Promise; +}): Promise { + const endpointInput = await resolveCompatibleEndpointInput(args); + const navigation = getNavigationChoice(endpointInput); + if (navigation === "back") { + console.log(" Returning to provider selection."); + console.log(""); + return { action: "retry-selection" }; + } + if (navigation === "exit") { + exitOnboardFromPrompt(); + } + // #9106: reject instead of silently stripping components that NemoClaw + // cannot forward to the endpoint. + if (endpointUrlHasUserinfoQueryOrFragment(endpointInput)) { + console.error(" Endpoint URL must not contain userinfo, query, or fragment components."); + // canonicalEndpoint returns null unless the stripped base is a + // credential-free http(s) URL, so the hint never echoes userinfo or + // query values. + const strippedBaseUrl = canonicalEndpoint( + normalizeProviderBaseUrl(endpointInput, args.kind), + args.kind, + ); + if (strippedBaseUrl) { + console.error( + ` NemoClaw does not forward these components to the endpoint. Use: ${strippedBaseUrl}`, + ); + } + if (args.nonInteractive) { + process.exit(1); + } + console.log(""); + return { action: "retry-selection" }; + } + const endpointUrl = normalizeProviderBaseUrl(endpointInput, args.kind); + if (!endpointUrl) { + console.error( + args.kind === "openai" + ? " Endpoint URL is required for Other OpenAI-compatible endpoint." + : " Endpoint URL is required for Other Anthropic-compatible endpoint.", + ); + if (args.nonInteractive) { + process.exit(1); + } + console.log(""); + return { action: "retry-selection" }; + } + return { action: "selected", endpointUrl }; +} + type ProviderChoice = { key: string; }; diff --git a/test/onboard-endpoint-url-rejection.test.ts b/test/onboard-endpoint-url-rejection.test.ts new file mode 100644 index 00000000000..ef85938d1bb --- /dev/null +++ b/test/onboard-endpoint-url-rejection.test.ts @@ -0,0 +1,165 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { describe, it } from "vitest"; + +describe("custom endpoint URL component rejection", () => { + it("rejects a query-bearing NEMOCLAW_ENDPOINT_URL instead of silently stripping it (#9106)", () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-endpoint-url-rejection-")); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "check.js"); + const onboardPath = JSON.stringify(path.join(repoRoot, "src", "lib", "onboard.ts")); + const runnerPath = JSON.stringify(path.join(repoRoot, "src", "lib", "runner.ts")); + + fs.mkdirSync(fakeBin, { recursive: true }); + fs.writeFileSync( + path.join(fakeBin, "curl"), + `#!/usr/bin/env bash +printf '000' +`, + { mode: 0o755 }, + ); + fs.writeFileSync( + scriptPath, + String.raw` +const runner = require(${runnerPath}); +runner.runCapture = () => ""; +const { setupNim } = require(${onboardPath}); + +Object.assign(process.env, { + NEMOCLAW_NON_INTERACTIVE: "1", + NEMOCLAW_PROVIDER: "custom", + NEMOCLAW_ENDPOINT_URL: "http://127.0.0.1:8000/v1/custom-path?param=value", + NEMOCLAW_MODEL: "mock-model", + NEMOCLAW_COMPATIBLE_AUTH_MODE: "none", + NEMOCLAW_PREFERRED_API: "chat-completions", +}); + +const originalLog = console.log; +console.log = () => {}; +process.exit = (code) => { + throw Object.assign(new Error("exit"), { code }); +}; + +setupNim(null).then( + () => { + originalLog(JSON.stringify({ resolved: true })); + }, + (error) => { + originalLog(JSON.stringify({ exitCode: error.code })); + }, +); +`, + ); + + try { + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: { ...process.env, HOME: tmpDir, PATH: `${fakeBin}:${process.env.PATH || ""}` }, + }); + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(JSON.parse(result.stdout.trim()), { exitCode: 1 }); + assert.match( + result.stderr, + /Endpoint URL must not contain userinfo, query, or fragment components\./, + ); + assert.match(result.stderr, /Use: http:\/\/127\.0\.0\.1:8000\/v1\/custom-path/); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("re-prompts after rejecting a query-bearing endpoint URL in interactive mode (#9106)", () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-endpoint-url-reprompt-")); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "reprompt-check.js"); + const onboardPath = JSON.stringify(path.join(repoRoot, "src", "lib", "onboard.ts")); + const credentialsPath = JSON.stringify( + path.join(repoRoot, "src", "lib", "credentials", "store.ts"), + ); + const runnerPath = JSON.stringify(path.join(repoRoot, "src", "lib", "runner.ts")); + + fs.mkdirSync(fakeBin, { recursive: true }); + fs.writeFileSync( + path.join(fakeBin, "curl"), + `#!/usr/bin/env bash +printf '000' +`, + { mode: 0o755 }, + ); + fs.writeFileSync( + scriptPath, + String.raw` +const credentials = require(${credentialsPath}); +const runner = require(${runnerPath}); + +const answers = ["4", "https://proxy.example.com/v1/custom-path?param=value#frag", "4", "exit"]; +const messages = []; +credentials.prompt = async (message) => { + messages.push(message); + return answers.shift() || ""; +}; +runner.runCapture = () => ""; + +const { setupNim } = require(${onboardPath}); + +const originalLog = console.log; +const originalError = console.error; +const lines = []; +console.log = (...args) => lines.push(args.join(" ")); +console.error = (...args) => lines.push(args.join(" ")); +process.exit = (code) => { + throw Object.assign(new Error("exit"), { code }); +}; + +setupNim(null).then( + () => { + originalLog(JSON.stringify({ resolved: true, lines, messages })); + }, + (error) => { + originalLog(JSON.stringify({ exitCode: error.code, lines, messages })); + }, +); +`, + ); + + try { + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: { ...process.env, HOME: tmpDir, PATH: `${fakeBin}:${process.env.PATH || ""}` }, + }); + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout.trim()); + assert.equal(payload.exitCode, 1); + assert.ok( + payload.lines.some((line: string) => + line.includes("Endpoint URL must not contain userinfo, query, or fragment components."), + ), + ); + assert.ok( + payload.lines.some((line: string) => + line.includes("Use: https://proxy.example.com/v1/custom-path"), + ), + ); + assert.ok(payload.lines.every((line: string) => !line.includes("param=value"))); + assert.ok(payload.lines.every((line: string) => !line.includes("#frag"))); + assert.equal( + payload.messages.filter((message: string) => /OpenAI-compatible base URL/.test(message)) + .length, + 2, + ); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); +}); diff --git a/test/onboard-selection-anthropic-retry.test.ts b/test/onboard-selection-anthropic-retry.test.ts index 45832fecbf1..c2655115854 100644 --- a/test/onboard-selection-anthropic-retry.test.ts +++ b/test/onboard-selection-anthropic-retry.test.ts @@ -171,7 +171,7 @@ const { setupNim } = require(${onboardPath}); const credentials = require(${credentialsPath}); const runner = require(${runnerPath}); -const answers = ["6", "https://proxy.example.com/v1/messages?token=secret#frag", "claude-proxy", "retry", "anthropic-proxy-good", "claude-proxy"]; +const answers = ["6", "https://proxy.example.com/v1/messages", "claude-proxy", "retry", "anthropic-proxy-good", "claude-proxy"]; const messages = []; credentials.prompt = async (message) => { diff --git a/test/onboard-selection.test.ts b/test/onboard-selection.test.ts index add5ae5bac4..344204de165 100644 --- a/test/onboard-selection.test.ts +++ b/test/onboard-selection.test.ts @@ -3243,7 +3243,7 @@ ${onboardChildRuntimeSource} const credentials = require(${credentialsPath}); const runner = require(${runnerPath}); -const { messages } = installPromptQueue(credentials, ["4", "https://proxy.example.com/v1/chat/completions?token=secret#frag", "custom-model", "retry", "proxy-good", "custom-model"]); +const { messages } = installPromptQueue(credentials, ["4", "https://proxy.example.com/v1/chat/completions", "custom-model", "retry", "proxy-good", "custom-model"]); runner.runCapture = () => ""; const { setupNim } = require(${onboardPath});