diff --git a/docs/inference/custom-endpoint-security.mdx b/docs/inference/custom-endpoint-security.mdx index df58e4ba032..e8519400c17 100644 --- a/docs/inference/custom-endpoint-security.mdx +++ b/docs/inference/custom-endpoint-security.mdx @@ -36,6 +36,10 @@ Set `NEMOCLAW_TRUSTED_PRIVATE_INFERENCE_HOSTS` to a comma-separated list of exac NemoClaw still resolves DNS, pins the validation connection, and rejects wildcard or suffix matches, link-local metadata, reserved destinations, and resolver failures. This allowlist does not relax direct blueprint, `config set`, or unrelated persisted-URL validation. +After onboarding records an admitted custom endpoint, `inference set` accepts that same canonical URL for a model change without resolving it again. +The registry must record onboarding as the endpoint source, and the supplied URL must match exactly after normalization. +Legacy entries without a source, endpoints recorded by `inference set`, and different URLs still pass through the full server-side request forgery validation path. + ## Use a Public Endpoint For a public HTTP URL, NemoClaw stores the validated IP address so the downstream runtime cannot resolve the hostname again and reach another address. diff --git a/docs/inference/switch-models.mdx b/docs/inference/switch-models.mdx index 18d977632ac..d403e4cc606 100644 --- a/docs/inference/switch-models.mdx +++ b/docs/inference/switch-models.mdx @@ -39,6 +39,8 @@ $$nemoclaw shields up For a compatible endpoint, omit `--endpoint-url` when the durable registry entry already contains the endpoint and API-family metadata. NemoClaw reuses the recorded route and does not repoint the gateway. +You can also re-supply the same endpoint URL when the registry records that onboarding established it. +NemoClaw requires an exact canonical match and does not extend that trust to a different URL or an endpoint recorded by `inference set`. If the route metadata is incomplete, NemoClaw stops and tells you to re-run onboarding. For Hermes, the command also mirrors the selected model into the dashboard profile. diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index bdf50367d57..05307e1bd37 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -2945,6 +2945,8 @@ Use `--no-verify` only when OpenShell cannot verify the provider at switch time When switching to `compatible-endpoint` or `compatible-anthropic-endpoint` from a different provider family, pass `--endpoint-url` with the trusted custom provider URL and, except for the Hermes case below, `--inference-api` with its API family so NemoClaw can persist a complete route identity for rebuild and shared-gateway checks. For a Hermes `compatible-anthropic-endpoint` target, `--inference-api` may be omitted because NemoClaw deterministically selects `openai-completions`; an explicit different API family is rejected. NemoClaw rejects loopback, link-local, private, and internal endpoint addresses, including public hostnames that resolve to a private address. +For a same-provider model change, pass `--endpoint-url` with the exact canonical endpoint URL that the target sandbox registry identifies as onboarding-established. +Missing or `inference set` provenance and every different URL remain subject to the full address validation above. For public HTTP URLs, NemoClaw stores the validated IP address to prevent DNS rebinding. DNS-backed HTTPS URLs are rejected because NemoClaw cannot pin the downstream peer address while preserving TLS SNI and host validation across the OpenShell runtime boundary; HTTPS IP-literal URLs remain supported. NemoClaw accepts `http://host.openshell.internal:` only with an explicit port from `1024` through `65535`; this narrow exception supports NemoClaw's sandbox-to-host inference routes and is not a general private-endpoint bypass. diff --git a/src/lib/actions/inference-set-gateway-route-containment.test.ts b/src/lib/actions/inference-set-gateway-route-containment.test.ts index fb95d8836eb..56b20907e3e 100644 --- a/src/lib/actions/inference-set-gateway-route-containment.test.ts +++ b/src/lib/actions/inference-set-gateway-route-containment.test.ts @@ -312,6 +312,7 @@ describe("runtime shared gateway route containment", () => { provider: customRoute.provider, model: customRoute.model, canReuseRecordedRoute: false, + onboardEndpointUrl: null, getSandboxes: () => [alpha, peer], rewriteUrlWithDnsPinning, }), diff --git a/src/lib/actions/inference-set-provider-alias.test.ts b/src/lib/actions/inference-set-provider-alias.test.ts index 4985a5f0df1..74c8b11a451 100644 --- a/src/lib/actions/inference-set-provider-alias.test.ts +++ b/src/lib/actions/inference-set-provider-alias.test.ts @@ -279,11 +279,10 @@ describe("runInferenceSet SSRF-block guidance — facet 2 (#6321)", () => { }); } - it("keeps the SSRF guard AND adds an actionable hint when the sandbox is already on this provider", async () => { - // The reporter's case: a sandbox onboarded on compatible-endpoint against an - // internal Hub. `inference set --endpoint-url ` still (correctly) - // trips the SSRF guard — but the message now tells the operator they can - // omit --endpoint-url to switch only the model. + it("keeps the SSRF guard when same-endpoint onboarding provenance is missing", async () => { + // Legacy registry rows have no machine-checkable endpoint source. Exact + // string equality is insufficient because inference set also persists the + // current endpoint, so the guarded path remains authoritative. const deps = createDeps({ config: { agents: { defaults: { model: { primary: "inference/nvidia/model-a" } } } }, entry: { @@ -319,10 +318,11 @@ describe("runInferenceSet SSRF-block guidance — facet 2 (#6321)", () => { expectNoInferenceMutation(deps.calls); }); - it("keeps the SSRF guard AND guides on the anthropicCompatible provider family (#6321)", async () => { - // The reporter's exact provider family: the same-URL switch on - // compatible-anthropic-endpoint (reached via the anthropicCompatible alias) - // must still hit the guard and receive the omit-flag guidance. + it("accepts the same onboard-provenanced internal endpoint for anthropicCompatible (#6321)", async () => { + // The reporter's exact provider family now has a durable trust boundary: + // the canonical supplied URL must match the URL whose registry source is + // onboarding. DNS re-resolution is not required for that exact identity. + const guard = ssrfGuard(); const deps = createDeps({ config: { agents: { defaults: { model: { primary: "inference/anthropic/model-a" } } } }, entry: { @@ -331,34 +331,65 @@ describe("runInferenceSet SSRF-block guidance — facet 2 (#6321)", () => { provider: "compatible-anthropic-endpoint", model: "anthropic/model-a", endpointUrl: "https://inference-api.nvidia.com/v1", + endpointSource: "onboard", credentialEnv: "COMPATIBLE_ANTHROPIC_API_KEY", preferredInferenceApi: "anthropic-messages", }, - rewriteConfigUrlsWithDnsPinning: ssrfGuard(), + rewriteConfigUrlsWithDnsPinning: guard, }); - const attempt = runInferenceSet( - { - provider: "anthropicCompatible", - model: "anthropic/model-b", + await expect( + runInferenceSet( + { + provider: "anthropicCompatible", + model: "anthropic/model-b", + endpointUrl: "https://inference-api.nvidia.com/v1", + noVerify: true, + }, + deps, + ), + ).resolves.toBeTruthy(); + expect(guard).not.toHaveBeenCalled(); + expect(deps.calls.updateSandbox.mock.calls.at(-1)).toEqual([ + "alpha", + expect.objectContaining({ endpointSource: "onboard" }), + ]); + }); + + it("accepts the same onboard-provenanced internal endpoint after canonicalization (#6321)", async () => { + const guard = ssrfGuard(); + const deps = createDeps({ + config: { + agents: { defaults: { model: { primary: "inference/nvidia/model-a" } } }, + models: { providers: { inference: { api: "openai-completions", models: [] } } }, + }, + entry: { + name: "alpha", + agent: "openclaw", + provider: "compatible-endpoint", + model: "nvidia/model-a", endpointUrl: "https://inference-api.nvidia.com/v1", - noVerify: true, + endpointSource: "onboard", + credentialEnv: "COMPATIBLE_API_KEY", + preferredInferenceApi: "openai-completions", }, - deps, - ); - await expect(attempt).rejects.toThrow(/endpoint-url is not allowed:/); - await expect(attempt).rejects.toThrow(/already configured for 'compatible-anthropic-endpoint'/); - await expect(attempt).rejects.toThrow(/omit --endpoint-url/); - expectNoInferenceMutation(deps.calls); + rewriteConfigUrlsWithDnsPinning: guard, + }); + + await expect( + runInferenceSet( + { + provider: "compatible-endpoint", + model: "nvidia/model-b", + endpointUrl: "https://inference-api.nvidia.com/v1/", + noVerify: true, + }, + deps, + ), + ).resolves.toBeTruthy(); + expect(guard).not.toHaveBeenCalled(); }); - it("re-supplying the SAME onboard-recorded internal endpoint is rejected with omit-guidance (no bypass) (#6321)", async () => { - // The recorded `entry.endpointUrl` is NOT trusted to skip the guard: this - // same `inference set` action persists endpointUrl, so a string-equality - // bypass would be self-authorizing (a value this command wrote could later - // authorize an internal-resolving switch). Re-supplying the exact recorded - // internal URL therefore still goes through the DNS-pinning SSRF guard and is - // rejected — with actionable guidance to omit --endpoint-url for a model-only - // switch on the already-established route (see the guided-path test below). + it("keeps the SSRF guard for an inference-set-authored endpoint", async () => { const guard = ssrfGuard(); const deps = createDeps({ config: { @@ -371,6 +402,7 @@ describe("runInferenceSet SSRF-block guidance — facet 2 (#6321)", () => { provider: "compatible-endpoint", model: "nvidia/model-a", endpointUrl: "https://inference-api.nvidia.com/v1", + endpointSource: "inference-set", credentialEnv: "COMPATIBLE_API_KEY", preferredInferenceApi: "openai-completions", }, @@ -381,23 +413,21 @@ describe("runInferenceSet SSRF-block guidance — facet 2 (#6321)", () => { { provider: "compatible-endpoint", model: "nvidia/model-b", - // Same internal URL onboarding recorded, even a trailing-slash variant. - endpointUrl: "https://inference-api.nvidia.com/v1/", + endpointUrl: "https://inference-api.nvidia.com/v1", noVerify: true, }, deps, ); + await expect(attempt).rejects.toThrow(/endpoint-url is not allowed:/); await expect(attempt).rejects.toThrow(/omit --endpoint-url/); - // The guard WAS consulted for the re-supplied URL — no string-equality bypass. expect(guard).toHaveBeenCalled(); expectNoInferenceMutation(deps.calls); }); it("still blocks a DIFFERENT internal endpoint even on a same-provider sandbox (no blanket exemption) (#6321)", async () => { - // Every supplied `--endpoint-url` goes through the SSRF guard (no bypass), - // so a *different* internal URL than the recorded one is blocked. Pinned as a - // regression: the fix does not hand the sandbox a way to reach arbitrary - // internal services. + // Onboarding provenance authorizes only the exact canonical endpoint it + // accompanies. A different internal URL still reaches the SSRF guard, so + // the fix cannot be used to reach arbitrary internal services. const deps = createDeps({ config: { agents: { defaults: { model: { primary: "inference/nvidia/model-a" } } } }, entry: { @@ -406,6 +436,7 @@ describe("runInferenceSet SSRF-block guidance — facet 2 (#6321)", () => { provider: "compatible-endpoint", model: "nvidia/model-a", endpointUrl: "https://inference-api.nvidia.com/v1", + endpointSource: "onboard", credentialEnv: "COMPATIBLE_API_KEY", preferredInferenceApi: "openai-completions", }, diff --git a/src/lib/actions/inference-set-route-containment.ts b/src/lib/actions/inference-set-route-containment.ts index 363e167da9d..132f41d6287 100644 --- a/src/lib/actions/inference-set-route-containment.ts +++ b/src/lib/actions/inference-set-route-containment.ts @@ -25,7 +25,7 @@ import { InferenceSetError } from "./inference-set-error"; */ export type RegistryInferenceMetadata = Pick< SandboxEntry, - "endpointUrl" | "credentialEnv" | "preferredInferenceApi" | "nimContainer" + "endpointUrl" | "endpointSource" | "credentialEnv" | "preferredInferenceApi" | "nimContainer" >; export interface ExplicitCustomRouteOptions { @@ -192,6 +192,7 @@ function explicitCustomProviderMetadataWithoutDns( // borrowing from an unrelated onboard session or global OpenShell provider. return { endpointUrl: normalizeCustomEndpointUrlWithoutDns(options.endpointUrl), + endpointSource: "inference-set", credentialEnv: normalizeExplicitCredentialEnv(provider, options.credentialEnv), preferredInferenceApi: normalizeExplicitInferenceApi(provider, options.inferenceApi), nimContainer: null, @@ -215,6 +216,7 @@ function matchingSessionMetadata(options: { } return { endpointUrl: session.endpointUrl, + endpointSource: null, credentialEnv: session.credentialEnv ?? null, preferredInferenceApi: session.preferredInferenceApi ?? null, nimContainer: session.nimContainer ?? null, @@ -234,6 +236,7 @@ function registryMetadataForProviderSwitch(options: { if (entry.provider === provider) { return { endpointUrl: entry.endpointUrl ?? null, + endpointSource: entry.endpointSource ?? null, credentialEnv: entry.credentialEnv ?? null, preferredInferenceApi: entry.preferredInferenceApi ?? null, nimContainer: entry.nimContainer ?? null, @@ -250,6 +253,7 @@ function registryMetadataForProviderSwitch(options: { } return { endpointUrl: null, + endpointSource: null, credentialEnv: null, preferredInferenceApi: null, nimContainer: null, @@ -324,6 +328,7 @@ export async function finalizeInferenceSetRoute(options: { provider: string; model: string; canReuseRecordedRoute: boolean; + onboardEndpointUrl: string | null; getSandboxes: () => SandboxEntry[]; rewriteUrlWithDnsPinning: RewriteConfigUrlsWithDnsPinning; }): Promise<{ @@ -338,15 +343,29 @@ export async function finalizeInferenceSetRoute(options: { }; } let endpointUrl: string; + let endpointSource: RegistryInferenceMetadata["endpointSource"]; try { - // A supplied endpoint always goes through the host DNS-pinning SSRF guard, - // even when it equals the value already recorded for this sandbox. The - // registry value is not exclusive onboarding provenance because inference - // set persists it too, so equality must never authorize a guard bypass. - endpointUrl = await normalizeCustomEndpointUrl( + const suppliedEndpoint = normalizeCustomEndpointUrlWithoutDns( prepared.preliminaryExplicitMetadata.endpointUrl, - options.rewriteUrlWithDnsPinning, ); + const onboardEndpoint = options.onboardEndpointUrl + ? normalizeCustomEndpointUrlWithoutDns(options.onboardEndpointUrl) + : null; + // The recorded URL alone is not an authority boundary because inference + // set writes it too. Bypass DNS re-resolution only when the registry also + // carries the endpoint's onboarding source and the canonical identities + // match exactly. Missing, inference-set, or mismatched provenance remains + // on the full DNS-pinning SSRF path (#6321). + if (onboardEndpoint !== null && suppliedEndpoint === onboardEndpoint) { + endpointUrl = suppliedEndpoint; + endpointSource = "onboard"; + } else { + endpointUrl = await normalizeCustomEndpointUrl( + suppliedEndpoint, + options.rewriteUrlWithDnsPinning, + ); + endpointSource = "inference-set"; + } } catch (error) { // Only augment the SSRF/DNS-pinning rejection. Missing or malformed URLs // keep their original diagnostics so the guidance cannot contradict them. @@ -369,6 +388,7 @@ export async function finalizeInferenceSetRoute(options: { const registryMetadata: RegistryInferenceMetadata = { ...prepared.preliminaryExplicitMetadata, endpointUrl, + endpointSource, }; assertGatewayRouteCompatibility({ gatewayName: prepared.gatewayName, diff --git a/src/lib/actions/inference-set.ts b/src/lib/actions/inference-set.ts index 9b1275cda94..e5b76a1c408 100644 --- a/src/lib/actions/inference-set.ts +++ b/src/lib/actions/inference-set.ts @@ -736,6 +736,10 @@ async function runInferenceSetWithoutHostLock( entry.endpointUrl.trim().length > 0 && typeof entry.preferredInferenceApi === "string" && entry.preferredInferenceApi.trim().length > 0, + onboardEndpointUrl: + entry.provider === provider && entry.endpointSource === "onboard" + ? (entry.endpointUrl ?? null) + : null, getSandboxes: () => deps.listSandboxes().sandboxes, rewriteUrlWithDnsPinning: deps.rewriteConfigUrlsWithDnsPinning, }); @@ -814,6 +818,7 @@ async function runInferenceSetWithoutHostLock( provider, model, endpointUrl: registryMetadata.endpointUrl ?? null, + endpointSource: registryMetadata.endpointSource ?? null, credentialEnv: registryMetadata.credentialEnv ?? null, preferredInferenceApi, nimContainer: registryMetadata.nimContainer ?? null, diff --git a/src/lib/actions/sandbox/rebuild-gpu-opt-out.test.ts b/src/lib/actions/sandbox/rebuild-gpu-opt-out.test.ts index 7294c76d84a..ccb5abd9204 100644 --- a/src/lib/actions/sandbox/rebuild-gpu-opt-out.test.ts +++ b/src/lib/actions/sandbox/rebuild-gpu-opt-out.test.ts @@ -203,6 +203,24 @@ describe("buildRebuildRecreateOnboardOpts", () => { expect(opts.toolDisclosure).toBe("direct"); }); + it("preserves only recognized endpoint provenance across authoritative rebuild", () => { + const onboard = buildRebuildRecreateOnboardOpts({ + ...baseArgs, + sb: { ...dashboard, endpointSource: "onboard" }, + }); + const legacy = buildRebuildRecreateOnboardOpts({ ...baseArgs, sb: dashboard }); + const malformed = buildRebuildRecreateOnboardOpts({ + ...baseArgs, + sb: { ...dashboard, endpointSource: "forged" } as typeof dashboard & { + endpointSource: never; + }, + }); + + expect(onboard.endpointSource).toBe("onboard"); + expect(legacy.endpointSource).toBeNull(); + expect(malformed.endpointSource).toBeNull(); + }); + it("carries durable observability intent into inner onboard", () => { const enabled = buildRebuildRecreateOnboardOpts({ ...baseArgs, diff --git a/src/lib/actions/sandbox/rebuild-gpu-opt-out.ts b/src/lib/actions/sandbox/rebuild-gpu-opt-out.ts index edf092ce6cb..66fa1ed4f87 100644 --- a/src/lib/actions/sandbox/rebuild-gpu-opt-out.ts +++ b/src/lib/actions/sandbox/rebuild-gpu-opt-out.ts @@ -2,6 +2,10 @@ // SPDX-License-Identifier: Apache-2.0 import { loadAgent } from "../../agent/defs"; +import { + type InferenceEndpointSource, + normalizeInferenceEndpointSource, +} from "../../inference/selection"; import { shouldManageDashboardForAgent } from "../../onboard/dashboard-runtime"; import { type DcodeAutoApprovalMode, @@ -38,6 +42,7 @@ export type RebuildGpuOptOutEntry = { dcodeAutoApprovalMode?: DcodeAutoApprovalMode; observabilityEnabled?: boolean; policyTier?: string | null; + endpointSource?: InferenceEndpointSource | null; }; // Modern source of truth is the persisted `sandboxGpuMode` string ("0" / "1" / @@ -95,6 +100,7 @@ export type RebuildRecreateOnboardOpts = { nonInteractive: true; recreateSandbox: true; authoritativeResumeConfig: true; + endpointSource?: InferenceEndpointSource | null; acceptThirdPartySoftware: true; agent: string | null | undefined; fromDockerfile: string | null; @@ -167,6 +173,7 @@ export function buildRebuildRecreateOnboardOpts(args: { nonInteractive: true, recreateSandbox: true, authoritativeResumeConfig: true, + endpointSource: normalizeInferenceEndpointSource(args.sb?.endpointSource), acceptThirdPartySoftware: args.usageNoticeAccepted, agent: args.rebuildAgent, fromDockerfile: args.storedFromDockerfile, diff --git a/src/lib/actions/sandbox/rebuild-preflight-target-phase.test.ts b/src/lib/actions/sandbox/rebuild-preflight-target-phase.test.ts index f846dd1e6ce..f8012c24aaf 100644 --- a/src/lib/actions/sandbox/rebuild-preflight-target-phase.test.ts +++ b/src/lib/actions/sandbox/rebuild-preflight-target-phase.test.ts @@ -20,6 +20,7 @@ const registryRoute: RegistryInferenceRoute = { provider: target.provider, model: target.model, endpointUrl: "https://inference.example.test/v1", + endpointSource: null, preferredInferenceApi: "openai-completions", source: "registry", }; diff --git a/src/lib/actions/sandbox/rebuild-resume-config.test.ts b/src/lib/actions/sandbox/rebuild-resume-config.test.ts index cdc175e8cff..ec4e4fd2627 100644 --- a/src/lib/actions/sandbox/rebuild-resume-config.test.ts +++ b/src/lib/actions/sandbox/rebuild-resume-config.test.ts @@ -282,6 +282,7 @@ describe("prepareRebuildResumeConfig", () => { provider: "compatible-endpoint", model: "m", endpointUrl: "https://registry.example.test/v1", + endpointSource: null, preferredInferenceApi: "openai-completions", source: "registry", }); diff --git a/src/lib/actions/sandbox/rebuild-resume-preflight.ts b/src/lib/actions/sandbox/rebuild-resume-preflight.ts index 3f750f0f160..7bbf1b2d482 100644 --- a/src/lib/actions/sandbox/rebuild-resume-preflight.ts +++ b/src/lib/actions/sandbox/rebuild-resume-preflight.ts @@ -168,6 +168,7 @@ function getRegistryInferenceRoute( provider: registrySelection.provider, model: registrySelection.model, endpointUrl: rebuildEndpoint.endpointUrl, + endpointSource: registrySelection.endpointSource ?? null, preferredInferenceApi: registrySelection.preferredInferenceApi, source: "registry", }; diff --git a/src/lib/actions/sandbox/rebuild-target-staging.test.ts b/src/lib/actions/sandbox/rebuild-target-staging.test.ts index 585127ed567..ea073baa9c3 100644 --- a/src/lib/actions/sandbox/rebuild-target-staging.test.ts +++ b/src/lib/actions/sandbox/rebuild-target-staging.test.ts @@ -23,6 +23,7 @@ const REGISTRY_ROUTE: RegistryInferenceRoute = { provider: "compatible-endpoint", model: "nvidia/model", endpointUrl: "https://inference.example.test/v1", + endpointSource: null, preferredInferenceApi: "openai-completions", source: "registry", }; diff --git a/src/lib/inference/selection.test.ts b/src/lib/inference/selection.test.ts index 6db11d82b11..01214672dfd 100644 --- a/src/lib/inference/selection.test.ts +++ b/src/lib/inference/selection.test.ts @@ -5,6 +5,30 @@ import { describe, expect, it } from "vitest"; import { normalizeInferenceSelection } from "./selection"; describe("normalizeInferenceSelection", () => { + it("persists recognized endpoint provenance only with a recorded endpoint", () => { + expect( + normalizeInferenceSelection({ + endpointUrl: "https://inference.example/v1", + endpointSource: "onboard", + }).endpointSource, + ).toBe("onboard"); + expect( + normalizeInferenceSelection({ + endpointUrl: "https://inference.example/v1", + endpointSource: "inference-set", + }).endpointSource, + ).toBe("inference-set"); + expect( + normalizeInferenceSelection({ + endpointUrl: "https://inference.example/v1", + endpointSource: "forged", + } as never).endpointSource, + ).toBeNull(); + expect( + normalizeInferenceSelection({ endpointUrl: null, endpointSource: "onboard" }).endpointSource, + ).toBeNull(); + }); + it("persists canonical compatible-endpoint reasoning values", () => { expect( normalizeInferenceSelection({ diff --git a/src/lib/inference/selection.ts b/src/lib/inference/selection.ts index 9cddf240c1a..be41b1701f8 100644 --- a/src/lib/inference/selection.ts +++ b/src/lib/inference/selection.ts @@ -1,10 +1,13 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +export type InferenceEndpointSource = "onboard" | "inference-set"; + export interface InferenceSelection { provider: string | null; model: string | null; endpointUrl: string | null; + endpointSource?: InferenceEndpointSource | null; credentialEnv: string | null; preferredInferenceApi: string | null; compatibleEndpointReasoning: "true" | "false" | null; @@ -35,6 +38,10 @@ function nullableInferenceApi(value: unknown): string | null { return normalized && SUPPORTED_INFERENCE_APIS.has(normalized) ? normalized : null; } +export function normalizeInferenceEndpointSource(value: unknown): InferenceEndpointSource | null { + return value === "onboard" || value === "inference-set" ? value : null; +} + function nullableCompatibleEndpointReasoning( provider: string | null, value: unknown, @@ -46,10 +53,12 @@ function nullableCompatibleEndpointReasoning( export function normalizeInferenceSelection(input: InferenceSelectionInput): InferenceSelection { const provider = nullableString(input?.provider); + const endpointUrl = nullableString(input?.endpointUrl); return { provider, model: nullableString(input?.model), - endpointUrl: nullableString(input?.endpointUrl), + endpointUrl, + endpointSource: endpointUrl ? normalizeInferenceEndpointSource(input?.endpointSource) : null, credentialEnv: nullableString(input?.credentialEnv), preferredInferenceApi: nullableInferenceApi(input?.preferredInferenceApi), compatibleEndpointReasoning: nullableCompatibleEndpointReasoning( diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 3c117bbf2d7..12ae08d5718 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -2884,12 +2884,8 @@ async function createSandboxWithBaseImageResolution( register: (openclawImagePluginInstalls) => sandboxRegistration.registerCreatedSandbox({ sandboxName, - inferenceSelection: sandboxRegistration.selection( - sandboxName, - provider, - model, - preferredInferenceApi, - ), + // biome-ignore format: keep src/lib/onboard.ts net-neutral for growth guardrail. + inferenceSelection: sandboxRegistration.selection(sandboxName, provider, model, preferredInferenceApi, createIntent?.endpointSource ?? null), runtimeFields: sandboxRuntimeFields, agent, agentVersionKnown: !fromDockerfile, @@ -4427,6 +4423,9 @@ async function runOnboard(opts: OnboardOptions = {}): Promise { requestedDcodeAutoApprovalMode: runtimeControlRequests.requestedDcodeAutoApprovalMode, authoritativePolicyTier: opts.authoritativeResumeConfig === true ? (opts.policyTier ?? null) : undefined, + endpointSource: opts.endpointSource, + endpointSourceProvider: opts.rebuildRegistryInferenceRoute?.route.provider ?? null, + endpointSourceEndpointUrl: opts.rebuildRegistryInferenceRoute?.route.endpointUrl ?? null, recreateSandbox: isRecreateSandbox, controlUiPort: _preflightDashboardPort, rootDir: ROOT, diff --git a/src/lib/onboard/machine/core-flow-phases.test.ts b/src/lib/onboard/machine/core-flow-phases.test.ts index ba55a402459..a78e29bdda5 100644 --- a/src/lib/onboard/machine/core-flow-phases.test.ts +++ b/src/lib/onboard/machine/core-flow-phases.test.ts @@ -415,6 +415,7 @@ describe("core onboard flow phases", () => { { gatewayName: "nemoclaw", allowToolsIncompatible: false, + endpointSource: null, reservationSessionId: session.sessionId, }, ); @@ -432,6 +433,79 @@ describe("core onboard flow phases", () => { }); }); + it.each([ + [ + "matching", + "compatible-endpoint", + "https://persisted.example.test/v1", + "onboard", + "https://persisted.example.test/v1", + true, + ], + [ + "endpoint-mismatched", + "compatible-endpoint", + "https://other.example.test/v1", + null, + null, + false, + ], + ["provider-mismatched", "nvidia-prod", "https://persisted.example.test/v1", null, null, false], + ] as const)("binds %s persisted onboard provenance to its exact provider endpoint", async (_label, registeredProvider, registeredEndpointUrl, expectedSource, expectedOnboardEndpointUrl, expectTrustedUrl) => { + const setupInference = vi.fn(async () => ({ ok: true as const })); + const getSandboxRegistryEntry = vi.fn((_sandboxName: string) => ({ + name: "my-sandbox", + provider: registeredProvider, + model: "custom/model", + endpointUrl: registeredEndpointUrl, + endpointSource: "onboard" as const, + credentialEnv: "COMPATIBLE_API_KEY", + preferredInferenceApi: "openai-completions", + gatewayName: "nemoclaw", + gpuEnabled: false, + policies: [], + })); + const [providerPhase] = createPhases({ + providerDeps: { + setupInference, + hydrateCredentialEnv: vi.fn(() => "host-key"), + }, + sandboxDeps: { + getSandboxRegistryEntry, + }, + }); + const session = createSession({ + provider: "compatible-endpoint", + model: "custom/model", + endpointUrl: "https://persisted.example.test/v1", + credentialEnv: "COMPATIBLE_API_KEY", + preferredInferenceApi: "openai-completions", + steps: { provider_selection: completeStep() }, + }); + + const result = await providerPhase.run( + context({ + resume: true, + session, + provider: "compatible-endpoint", + model: "custom/model", + endpointUrl: "https://persisted.example.test/v1", + credentialEnv: "COMPATIBLE_API_KEY", + preferredInferenceApi: "openai-completions", + }), + ); + + const inferenceOptions = setupInference.mock.calls[0]?.at(-1) as + | { endpointSource?: string | null; onboardEndpointUrl?: string } + | undefined; + expect(inferenceOptions).toMatchObject({ endpointSource: expectedSource }); + expect(inferenceOptions?.onboardEndpointUrl ?? null).toBe(expectedOnboardEndpointUrl); + expect(Object.hasOwn(inferenceOptions ?? {}, "onboardEndpointUrl")).toBe(expectTrustedUrl); + expect(result.context.endpointSource).toBe(expectedSource); + expect(result.context.onboardEndpointUrl ?? null).toBe(expectedOnboardEndpointUrl); + expect(getSandboxRegistryEntry).toHaveBeenCalledWith("my-sandbox"); + }); + it("uses the strict runner for fresh provider selection sessions", async () => { const calls: string[] = []; const applied: string[] = []; diff --git a/src/lib/onboard/machine/core-flow-phases.ts b/src/lib/onboard/machine/core-flow-phases.ts index ccf8e404add..a931bab9e59 100644 --- a/src/lib/onboard/machine/core-flow-phases.ts +++ b/src/lib/onboard/machine/core-flow-phases.ts @@ -1,6 +1,10 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { + type InferenceEndpointSource, + normalizeInferenceEndpointSource, +} from "../../inference/selection"; import type { WebSearchConfig } from "../../inference/web-search"; import type { DcodeAutoApprovalMode } from "../dcode-auto-approval"; import type { @@ -47,6 +51,9 @@ export interface CoreOnboardFlowPhaseOptions< requestedObservabilityEnabled?: boolean | null; requestedDcodeAutoApprovalMode?: DcodeAutoApprovalMode | null; authoritativePolicyTier?: string | null; + endpointSource?: InferenceEndpointSource | null; + endpointSourceProvider?: string | null; + endpointSourceEndpointUrl?: string | null; recreateSandbox: (requested?: boolean) => boolean; controlUiPort: number | null; rootDir: string; @@ -61,6 +68,49 @@ export interface CoreOnboardFlowPhaseOptions< >["deps"]; } +interface EndpointProvenance { + endpointSource: InferenceEndpointSource | null; + onboardEndpointUrl: string | null; +} + +function endpointProvenanceForPhase( + context: Pick, + configuredSource: InferenceEndpointSource | null | undefined, + configuredProvider: string | null | undefined, + configuredEndpointUrl: string | null | undefined, + getSandboxRegistryEntry: (name: string) => { + provider?: unknown; + endpointUrl?: unknown; + endpointSource?: unknown; + } | null, +): EndpointProvenance { + if (context.fresh) { + return { endpointSource: "onboard", onboardEndpointUrl: context.endpointUrl }; + } + if (configuredSource !== undefined) { + const endpointSource = normalizeInferenceEndpointSource(configuredSource); + if ( + endpointSource === "onboard" && + (configuredProvider !== context.provider || configuredEndpointUrl !== context.endpointUrl) + ) { + return { endpointSource: null, onboardEndpointUrl: null }; + } + return { + endpointSource, + onboardEndpointUrl: endpointSource === "onboard" ? (configuredEndpointUrl ?? null) : null, + }; + } + const entry = context.sandboxName ? getSandboxRegistryEntry(context.sandboxName) : null; + const endpointSource = normalizeInferenceEndpointSource(entry?.endpointSource); + if (endpointSource !== "onboard") { + return { endpointSource, onboardEndpointUrl: null }; + } + if (entry?.provider !== context.provider || entry.endpointUrl !== context.endpointUrl) { + return { endpointSource: null, onboardEndpointUrl: null }; + } + return { endpointSource, onboardEndpointUrl: context.endpointUrl }; +} + export function createCoreOnboardFlowPhases< Context extends OnboardFlowContext, Host = unknown, @@ -70,6 +120,13 @@ export function createCoreOnboardFlowPhases< options: CoreOnboardFlowPhaseOptions, ): [OnboardSequencePhase, OnboardSequencePhase] { const providerInferencePhase = createProviderInferencePhase(async (context) => { + const endpointProvenance = endpointProvenanceForPhase( + context, + options.sandbox.endpointSource, + options.sandbox.endpointSourceProvider, + options.sandbox.endpointSourceEndpointUrl, + options.sandboxDeps.getSandboxRegistryEntry, + ); const providerInferenceResult = await handleProviderInferenceState({ gatewayName: options.gatewayName, resume: context.resume, @@ -87,6 +144,8 @@ export function createCoreOnboardFlowPhases< model: context.model, provider: context.provider, endpointUrl: context.endpointUrl, + endpointSource: endpointProvenance.endpointSource, + onboardEndpointUrl: endpointProvenance.onboardEndpointUrl, credentialEnv: context.credentialEnv, hermesAuthMethod: context.hermesAuthMethod, hermesToolGateways: context.hermesToolGateways, @@ -108,6 +167,8 @@ export function createCoreOnboardFlowPhases< model: providerInferenceResult.model, provider: providerInferenceResult.provider, endpointUrl: providerInferenceResult.endpointUrl, + endpointSource: providerInferenceResult.endpointSource, + onboardEndpointUrl: providerInferenceResult.onboardEndpointUrl, credentialEnv: providerInferenceResult.credentialEnv, hermesAuthMethod: providerInferenceResult.hermesAuthMethod, hermesToolGateways: providerInferenceResult.hermesToolGateways, @@ -121,12 +182,26 @@ export function createCoreOnboardFlowPhases< }); const sandboxPhase = createSandboxPhase(async (context) => { + const endpointProvenance = + context.endpointSource !== undefined + ? { + endpointSource: context.endpointSource, + onboardEndpointUrl: context.onboardEndpointUrl ?? null, + } + : endpointProvenanceForPhase( + context, + options.sandbox.endpointSource, + options.sandbox.endpointSourceProvider, + options.sandbox.endpointSourceEndpointUrl, + options.sandboxDeps.getSandboxRegistryEntry, + ); const sandboxStateResult = await handleSandboxState({ resume: context.resume, fresh: context.fresh, gatewayName: options.gatewayName, authoritativeResumeConfig: options.authoritativeResumeConfig, authoritativePolicyTier: options.sandbox.authoritativePolicyTier, + endpointSource: endpointProvenance.endpointSource, resumeAgentChanged: options.sandbox.resumeAgentChanged, requestedObservabilityEnabled: options.sandbox.requestedObservabilityEnabled, requestedDcodeAutoApprovalMode: options.sandbox.requestedDcodeAutoApprovalMode, diff --git a/src/lib/onboard/machine/flow-context.ts b/src/lib/onboard/machine/flow-context.ts index 060ad49759b..deaa62b8c38 100644 --- a/src/lib/onboard/machine/flow-context.ts +++ b/src/lib/onboard/machine/flow-context.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import type { WebSearchConfig } from "../../inference/web-search"; +import type { InferenceEndpointSource } from "../../inference/selection"; import type { Session } from "../../state/onboard-session"; import type { OnboardStateHandlerResult } from "./runner"; @@ -17,6 +18,8 @@ export interface OnboardFlowContext true), reconcileRouter: vi.fn(async () => undefined), reupsertRoutedProvider: vi.fn( - (_provider: string, endpointUrl: string | null, _credentialEnv: string | null) => ({ + ( + _gatewayName: string, + _provider: string, + endpointUrl: string | null, + _credentialEnv: string | null, + ) => ({ ok: true as const, endpointUrl: endpointUrl ?? "http://host.openshell.internal:4000/v1", }), ), + reserveRoute: vi.fn(() => true), updateSandbox: vi.fn(), log: vi.fn(), error: vi.fn(), @@ -105,7 +111,7 @@ function createDeps() { isRoutedInferenceProvider: (provider) => provider === "nvidia-router", reconcileModelRouter: calls.reconcileRouter, reupsertRoutedProvider: calls.reupsertRoutedProvider, - reserveSandboxInferenceRoute: vi.fn(() => true), + reserveSandboxInferenceRoute: calls.reserveRoute, registryUpdateSandbox: calls.updateSandbox, promptValidatedSandboxName: vi.fn(async () => "target-sandbox"), assessHost: () => ({ cpus: 8 }), @@ -253,18 +259,65 @@ describe("provider route containment", () => { expect(calls.error).not.toHaveBeenCalled(); }); + it("binds fresh onboard provenance to the exact selected endpoint", async () => { + const { calls, deps } = createDeps(); + calls.setupNim.mockResolvedValue({ + ...fallbackSelection, + provider: "compatible-endpoint", + model: "custom/model", + endpointUrl: "https://selected.example.test/v1", + endpointSource: "onboard", + credentialEnv: "COMPATIBLE_API_KEY", + preferredInferenceApi: "openai-completions", + }); + const options = resumeOptions(deps, createSession()); + + await handleProviderInferenceState({ + ...options, + resume: false, + fresh: true, + sandboxName: null, + }); + + expect(calls.setupInference.mock.calls[0]?.at(-1)).toMatchObject({ + endpointSource: "onboard", + onboardEndpointUrl: "https://selected.example.test/v1", + }); + }); + + it("drops onboard trust when a resumed endpoint differs from its canonical endpoint", async () => { + const session = createSession({ + provider: "hermes-provider", + model: "custom/model", + endpointUrl: "https://current.example.test/v1", + credentialEnv: "NOUS_API_KEY", + }); + session.steps.provider_selection.status = "complete"; + const { calls, deps } = createDeps(); + const options = resumeOptions(deps, session); + options.initial.endpointSource = "onboard"; + options.initial.onboardEndpointUrl = "https://persisted.example.test/v1"; + + const result = await handleProviderInferenceState(options); + + const inferenceOptions = calls.setupInference.mock.calls[0]?.at(-1); + expect(inferenceOptions).toMatchObject({ endpointSource: null }); + expect(inferenceOptions).not.toHaveProperty("onboardEndpointUrl"); + expect(result).toMatchObject({ endpointSource: null, onboardEndpointUrl: null }); + }); + it("allows routed-provider repair across a valid peer-route difference (#6315)", async () => { const session = createSession({ provider: "nvidia-router", model: "router/model" }); session.steps.provider_selection.status = "complete"; const { calls, deps } = createDeps(); reportDifferentRoute(calls, "nvidia-router", "router/model"); - await expect(handleProviderInferenceState(resumeOptions(deps, session))).resolves.toMatchObject( - { - provider: "nvidia-router", - model: "router/model", - }, - ); + const options = resumeOptions(deps, session); + options.initial.endpointSource = "inference-set"; + await expect(handleProviderInferenceState(options)).resolves.toMatchObject({ + provider: "nvidia-router", + model: "router/model", + }); expect(calls.checkGatewayRouteCompatibility).toHaveBeenCalledWith({ gatewayName: "nemoclaw-9090", @@ -280,6 +333,16 @@ describe("provider route containment", () => { expect(calls.reconcileRouter).toHaveBeenCalledOnce(); expect(calls.surfaceReady).toHaveBeenCalledOnce(); expect(calls.reupsertRoutedProvider).toHaveBeenCalledOnce(); + expect(calls.reserveRoute).toHaveBeenCalledWith("target-sandbox", { + provider: "nvidia-router", + model: "router/model", + endpointUrl: "http://host.openshell.internal:4000/v1", + endpointSource: "inference-set", + credentialEnv: null, + preferredInferenceApi: null, + gatewayName: "nemoclaw-9090", + reservationSessionId: session.sessionId, + }); expect(calls.updateSandbox).not.toHaveBeenCalled(); expect(calls.setupInference).not.toHaveBeenCalled(); }); @@ -325,9 +388,12 @@ describe("provider route containment", () => { const { calls, deps } = createDeps(); reportDifferentRoute(calls, "compatible-endpoint", "custom/model"); - await expect( - handleProviderInferenceState(resumeOptions(deps, session, ["telegram"])), - ).resolves.toMatchObject({ provider: "compatible-endpoint", model: "custom/model" }); + const options = resumeOptions(deps, session, ["telegram"]); + options.initial.endpointSource = "inference-set"; + await expect(handleProviderInferenceState(options)).resolves.toMatchObject({ + provider: "compatible-endpoint", + model: "custom/model", + }); expect(calls.checkGatewayRouteCompatibility).toHaveBeenCalledWith({ gatewayName: "nemoclaw-9090", @@ -341,6 +407,10 @@ describe("provider route containment", () => { }, }); expect(calls.setupInference).toHaveBeenCalledOnce(); + expect(calls.setupInference.mock.calls[0]?.at(-1)).toMatchObject({ + endpointSource: "inference-set", + }); + expect(calls.setupInference.mock.calls[0]?.at(-1)).not.toHaveProperty("onboardEndpointUrl"); expect(calls.surfaceReady).toHaveBeenCalledOnce(); expect(calls.updateSandbox).not.toHaveBeenCalled(); expect(calls.error).not.toHaveBeenCalled(); diff --git a/src/lib/onboard/machine/handlers/provider-inference.test.ts b/src/lib/onboard/machine/handlers/provider-inference.test.ts index b9f9980b89e..c640df8d08e 100644 --- a/src/lib/onboard/machine/handlers/provider-inference.test.ts +++ b/src/lib/onboard/machine/handlers/provider-inference.test.ts @@ -24,6 +24,19 @@ import { type Host, } from "./provider-inference.test-support"; +function setupOptions( + session: { sessionId: string }, + overrides: Record = {}, +): Record { + return { + gatewayName: "nemoclaw", + allowToolsIncompatible: false, + endpointSource: null, + reservationSessionId: session.sessionId, + ...overrides, + }; +} + describe("handleProviderInferenceState", () => { it("runs provider selection and inference setup on a fresh flow", async () => { const { deps, calls } = createDeps(); @@ -43,6 +56,11 @@ describe("handleProviderInferenceState", () => { expect.any(Function), session.sessionId, ); + const selectionUpdates = ( + calls.complete.mock.calls as unknown as Array<[string, Record]> + ).find(([stepName]) => stepName === "provider_selection")?.[1]; + expect(selectionUpdates).not.toHaveProperty("endpointSource"); + expect(selectionUpdates).not.toHaveProperty("onboardEndpointUrl"); expect(calls.promptName).toHaveBeenCalledWith(null); expect(calls.log).toHaveBeenCalledWith("summary:nvidia-prod/nvidia/test/my-assistant"); expect(calls.startStep).toHaveBeenNthCalledWith(2, "inference", { @@ -57,12 +75,9 @@ describe("handleProviderInferenceState", () => { "NVIDIA_INFERENCE_API_KEY", null, [], - { - gatewayName: "nemoclaw", - allowToolsIncompatible: false, + setupOptions(session, { preferredInferenceApi: "openai-responses", - reservationSessionId: session.sessionId, - }, + }), ); expect(calls.deleteEnv).toHaveBeenCalledWith("NVIDIA_INFERENCE_API_KEY"); expect(result).toMatchObject({ @@ -128,12 +143,9 @@ describe("handleProviderInferenceState", () => { "COMPATIBLE_ANTHROPIC_API_KEY", null, [], - { - gatewayName: "nemoclaw", - allowToolsIncompatible: false, + setupOptions(session, { preferredInferenceApi: "openai-completions", - reservationSessionId: session.sessionId, - }, + }), ); expect(result.preferredInferenceApi).toBe("openai-completions"); }); @@ -172,12 +184,9 @@ describe("handleProviderInferenceState", () => { "COMPATIBLE_ANTHROPIC_API_KEY", null, [], - { - gatewayName: "nemoclaw", - allowToolsIncompatible: false, + setupOptions(session, { preferredInferenceApi: "openai-completions", - reservationSessionId: session.sessionId, - }, + }), ); expect(calls.complete).toHaveBeenCalledWith( "inference", @@ -230,12 +239,10 @@ describe("handleProviderInferenceState", () => { "COMPATIBLE_ANTHROPIC_API_KEY", null, [], - { + setupOptions(session, { gatewayName: "nemoclaw-9090", - allowToolsIncompatible: false, preferredInferenceApi: "openai-completions", - reservationSessionId: session.sessionId, - }, + }), ); }); @@ -375,6 +382,7 @@ describe("handleProviderInferenceState", () => { provider: "compatible-endpoint", model: "mock/mcp-bridge", endpointUrl: "https://compatible.example.test/v1", + endpointSource: null, credentialEnv: "COMPATIBLE_API_KEY", preferredInferenceApi: "openai-completions", gatewayName: "nemoclaw", @@ -660,8 +668,10 @@ describe("handleProviderInferenceState", () => { const { deps, calls } = createDeps({ isInferenceRouteReady: vi.fn(() => true) }); calls.promptName.mockResolvedValueOnce("tm"); + const options = baseOptions(deps, session); + options.initial.endpointSource = "inference-set"; const result = await handleProviderInferenceState({ - ...baseOptions(deps, session), + ...options, resume: true, sandboxName: null, }); @@ -673,6 +683,7 @@ describe("handleProviderInferenceState", () => { provider: "nvidia-prod", model: "nvidia/nemotron-test", endpointUrl: "https://integrate.api.nvidia.com/v1", + endpointSource: "inference-set", credentialEnv: "NVIDIA_INFERENCE_API_KEY", preferredInferenceApi: "openai-responses", gatewayName: "nemoclaw", @@ -727,6 +738,7 @@ describe("handleProviderInferenceState", () => { provider: "nvidia-prod", model: "nvidia/test", endpointUrl: "https://integrate.api.nvidia.com/v1", + endpointSource: null, credentialEnv: "NVIDIA_INFERENCE_API_KEY", preferredInferenceApi: "openai-responses", gatewayName: "nemoclaw", @@ -916,11 +928,7 @@ describe("handleProviderInferenceState", () => { "COMPATIBLE_API_KEY", null, [], - { - gatewayName: "nemoclaw", - allowToolsIncompatible: false, - reservationSessionId: session.sessionId, - }, + setupOptions(session), ); }); @@ -957,11 +965,7 @@ describe("handleProviderInferenceState", () => { "COMPATIBLE_API_KEY", null, [], - { - gatewayName: "nemoclaw", - allowToolsIncompatible: false, - reservationSessionId: session.sessionId, - }, + setupOptions(session), ); }); @@ -999,11 +1003,7 @@ describe("handleProviderInferenceState", () => { "COMPATIBLE_API_KEY", null, [], - { - gatewayName: "nemoclaw", - allowToolsIncompatible: false, - reservationSessionId: session.sessionId, - }, + setupOptions(session), ); expect(calls.log).toHaveBeenCalledWith( " [resume] Refreshing compatible-endpoint inference route for messaging.", @@ -1106,15 +1106,12 @@ describe("handleProviderInferenceState", () => { "COMPATIBLE_API_KEY", null, [], - { - gatewayName: "nemoclaw", - allowToolsIncompatible: false, + setupOptions(session, { skipHostInferenceSmoke: true, reuseGatewayCredentialWithoutLocalKey: true, preferredInferenceApi: "openai-completions", - reservationSessionId: session.sessionId, isRecordedProviderRecoveryAuthorized: expect.any(Function), - }, + }), ); expect(recoveryAuthorization?.()).toBe(true); }); @@ -1202,11 +1199,7 @@ describe("handleProviderInferenceState", () => { "COMPATIBLE_API_KEY", null, [], - { - gatewayName: "nemoclaw", - allowToolsIncompatible: false, - reservationSessionId: session.sessionId, - }, + setupOptions(session), ); expect(calls.log).toHaveBeenCalledWith( " [resume] Refreshing compatible-endpoint inference route for messaging.", @@ -1229,6 +1222,7 @@ describe("handleProviderInferenceState", () => { provider: "nvidia-router", model: "router/model", endpointUrl: "http://host.openshell.internal:4000/v1", + endpointSource: null, credentialEnv: null, preferredInferenceApi: null, gatewayName: "nemoclaw", @@ -1349,6 +1343,7 @@ describe("handleProviderInferenceState", () => { provider: "nvidia-router", model: "router/model", endpointUrl: "http://host.openshell.internal:4000/v1", + endpointSource: null, credentialEnv: "NVIDIA_INFERENCE_API_KEY", preferredInferenceApi: null, gatewayName: "nemoclaw", @@ -1489,12 +1484,10 @@ describe("handleProviderInferenceState", () => { null, null, [], - { - gatewayName: "nemoclaw", + setupOptions(session, { allowToolsIncompatible: true, preferredInferenceApi: "openai-responses", - reservationSessionId: session.sessionId, - }, + }), ); }); }); diff --git a/src/lib/onboard/machine/handlers/provider-inference.ts b/src/lib/onboard/machine/handlers/provider-inference.ts index a7c36e58e8f..2e86c3734ee 100644 --- a/src/lib/onboard/machine/handlers/provider-inference.ts +++ b/src/lib/onboard/machine/handlers/provider-inference.ts @@ -11,6 +11,7 @@ import { isAdvisoryGatewayRouteConflict, } from "../../../inference/gateway-route-compatibility"; import { getOllamaContextWindowFloorForAgent } from "../../../inference/ollama-runtime-context"; +import type { InferenceEndpointSource } from "../../../inference/selection"; import type { WebSearchConfig } from "../../../inference/web-search"; import type { HermesAuthMethod, Session, SessionUpdates } from "../../../state/onboard-session"; import type { OnboardInferenceCapabilityCache } from "../../inference-capability-cache"; @@ -35,6 +36,8 @@ export interface ProviderInferenceSetupOptions { allowToolsIncompatible?: boolean; skipHostInferenceSmoke?: boolean; reuseGatewayCredentialWithoutLocalKey?: boolean; + /** Exact onboarding-provenanced endpoint permitted to skip DNS re-resolution. */ + onboardEndpointUrl?: string; /** * Resolved (agent-coerced) inference API for the selection. Lets the * remote-provider registration pick the gateway surface that matches the @@ -44,6 +47,8 @@ export interface ProviderInferenceSetupOptions { preferredInferenceApi?: string | null; /** Public addresses approved for custom endpoint host probes. */ endpointPinnedAddresses?: readonly string[]; + /** Durable route provenance to preserve when reserving a refreshed route. */ + endpointSource?: InferenceEndpointSource | null; /** Non-forgeable proof of the exact private subset admitted by the custom preflight. */ endpointTrustedPrivateCapability?: TrustedPrivateEndpointCapability; /** One-shot host capability cache carried only through this onboarding run. */ @@ -58,6 +63,7 @@ export interface ProviderSelectionResult { model: string | null; provider: string; endpointUrl: string | null; + endpointSource?: InferenceEndpointSource | null; credentialEnv: string | null; hermesAuthMethod: HermesAuthMethod | null; hermesToolGateways: string[]; @@ -95,6 +101,9 @@ export interface ProviderInferenceStateOptions { model: string | null; provider: string | null; endpointUrl: string | null; + endpointSource?: InferenceEndpointSource | null; + /** Canonical endpoint paired with onboard provenance; never inferred from a later URL. */ + onboardEndpointUrl?: string | null; credentialEnv: string | null; hermesAuthMethod: HermesAuthMethod | null; hermesToolGateways: string[]; @@ -199,6 +208,7 @@ export interface ProviderInferenceStateOptions { provider: string; model: string; endpointUrl: string | null; + endpointSource: InferenceEndpointSource | null; credentialEnv: string | null; preferredInferenceApi: string | null; gatewayName: string; @@ -238,6 +248,8 @@ export interface ProviderInferenceStateResult { model: string; provider: string; endpointUrl: string | null; + endpointSource: InferenceEndpointSource | null; + onboardEndpointUrl: string | null; credentialEnv: string | null; hermesAuthMethod: HermesAuthMethod | null; hermesToolGateways: string[]; @@ -278,6 +290,16 @@ function agentName(agent: unknown): string { return typeof name === "string" && name.length > 0 ? name : "openclaw"; } +function endpointSourceForCurrentUrl( + endpointSource: InferenceEndpointSource | null, + endpointUrl: string | null, + onboardEndpointUrl: string | null, +): InferenceEndpointSource | null { + return endpointSource === "onboard" && (!onboardEndpointUrl || endpointUrl !== onboardEndpointUrl) + ? null + : endpointSource; +} + function hasActiveMessagingChannels( selectedMessagingChannels: string[], session: Session | null, @@ -351,6 +373,12 @@ export async function handleProviderInferenceState({ let skipHostInferenceSmoke = false; let reuseGatewayCredentialWithoutLocalKey = false; let endpointPinnedAddresses: string[] | undefined; + let endpointSource: InferenceEndpointSource | null = initial.endpointSource ?? null; + let onboardEndpointUrl = + endpointSource === "onboard" && initial.onboardEndpointUrl === initial.endpointUrl + ? initial.onboardEndpointUrl + : null; + endpointSource = endpointSourceForCurrentUrl(endpointSource, endpointUrl, onboardEndpointUrl); let endpointTrustedPrivateCapability: TrustedPrivateEndpointCapability | undefined; let inferenceCapabilityCache: OnboardInferenceCapabilityCache | undefined; let vllmModelIdentity: string | undefined; @@ -546,6 +574,9 @@ export async function handleProviderInferenceState({ recoveredRecordedProvider = selection.recoveredFromSandbox === true; forceInferenceSetup ||= recoveredRecordedProvider; endpointPinnedAddresses = selection.endpointPinnedAddresses; + endpointSource = selection.endpointSource ?? null; + onboardEndpointUrl = + endpointSource === "onboard" && selection.endpointUrl ? selection.endpointUrl : null; endpointTrustedPrivateCapability = selection.endpointTrustedPrivateCapability; inferenceCapabilityCache = selection.inferenceCapabilityCache; vllmModelIdentity = selection.vllmModelIdentity; @@ -577,6 +608,9 @@ export async function handleProviderInferenceState({ }); } if (shouldRecordProviderSelection) { + // Provider selection is not yet durable route trust. Deliberately omit + // endpointSource/onboardEndpointUrl here so an interrupted run fails + // closed and revalidates the endpoint before inference setup on resume. session = await deps.recordStepComplete( "provider_selection", deps.toSessionUpdates({ @@ -604,6 +638,8 @@ export async function handleProviderInferenceState({ }), ); env.NEMOCLAW_OPENSHELL_BIN = deps.getOpenshellBinary(); + endpointSource = endpointSourceForCurrentUrl(endpointSource, endpointUrl, onboardEndpointUrl); + if (endpointSource !== "onboard") onboardEndpointUrl = null; const needsBedrockRuntimeAdapter = deps.needsBedrockRuntimeAdapter(provider, endpointUrl); const resumeInference = !needsBedrockRuntimeAdapter && @@ -626,6 +662,8 @@ export async function handleProviderInferenceState({ : {}), ...(preferredInferenceApi ? { preferredInferenceApi } : {}), ...(endpointPinnedAddresses ? { endpointPinnedAddresses } : {}), + endpointSource, + ...(endpointSource === "onboard" && onboardEndpointUrl ? { onboardEndpointUrl } : {}), ...(endpointTrustedPrivateCapability ? { endpointTrustedPrivateCapability } : {}), ...(inferenceCapabilityCache ? { inferenceCapabilityCache } : {}), reservationSessionId: session?.sessionId, @@ -706,21 +744,27 @@ export async function handleProviderInferenceState({ endpointUrl, credentialEnv, ); + const reservationEndpointSource = endpointSourceForCurrentUrl( + endpointSource, + reupserted.endpointUrl, + onboardEndpointUrl, + ); const reserved = reupserted.ok && resumeReservationName ? deps.reserveSandboxInferenceRoute(resumeReservationName, { provider: selectedProvider, model: selectedModel, endpointUrl: reupserted.endpointUrl, + endpointSource: reservationEndpointSource, credentialEnv, preferredInferenceApi, gatewayName, reservationSessionId: session?.sessionId, }) : null; - return { reupserted, reserved }; + return { reupserted, reservationEndpointSource, reserved }; }); - const { reupserted, reserved } = routedRepair; + const { reupserted, reservationEndpointSource, reserved } = routedRepair; if (!reupserted.ok) { deps.error( ` ${reupserted.message ?? "Failed to update the routed inference provider."}`, @@ -732,6 +776,8 @@ export async function handleProviderInferenceState({ deps.exitProcess(1); } endpointUrl = reupserted.endpointUrl; + endpointSource = reservationEndpointSource; + if (endpointSource !== "onboard") onboardEndpointUrl = null; } if (resumeReservationName && !routedInferenceProvider) { const reserved = await deps.withGatewayRouteMutationLock(gatewayName, () => { @@ -746,6 +792,7 @@ export async function handleProviderInferenceState({ provider: selectedProvider, model: selectedModel, endpointUrl, + endpointSource, credentialEnv, preferredInferenceApi, gatewayName, @@ -816,6 +863,8 @@ export async function handleProviderInferenceState({ ...(reuseGatewayCredentialWithoutLocalKey ? { reuseGatewayCredentialWithoutLocalKey } : {}), ...(preferredInferenceApi ? { preferredInferenceApi } : {}), ...(endpointPinnedAddresses ? { endpointPinnedAddresses } : {}), + endpointSource, + ...(endpointSource === "onboard" && onboardEndpointUrl ? { onboardEndpointUrl } : {}), ...(endpointTrustedPrivateCapability ? { endpointTrustedPrivateCapability } : {}), ...(inferenceCapabilityCache ? { inferenceCapabilityCache } : {}), ...providerRecovery.setupOptions( @@ -882,6 +931,8 @@ export async function handleProviderInferenceState({ model, provider, endpointUrl, + endpointSource, + onboardEndpointUrl, credentialEnv, hermesAuthMethod, hermesToolGateways, diff --git a/src/lib/onboard/machine/handlers/sandbox-dcode-selection.test.ts b/src/lib/onboard/machine/handlers/sandbox-dcode-selection.test.ts index 3dd5bf5ed34..e1c6ca37ffc 100644 --- a/src/lib/onboard/machine/handlers/sandbox-dcode-selection.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox-dcode-selection.test.ts @@ -62,6 +62,7 @@ describe("handleSandboxState live DCode selection", () => { await handleSandboxState({ ...baseOptions(deps, session), + fresh: true, agent: { name: "langchain-deepagents-code" }, }); @@ -70,6 +71,7 @@ describe("handleSandboxState live DCode selection", () => { recreate: false, toolDisclosure: "progressive", observabilityEnabled: true, + endpointSource: "onboard", observabilityRequestedExplicitly: true, dcodeAutoApprovalMode: "disabled", extraProviders: [], @@ -190,6 +192,7 @@ describe("handleSandboxState live DCode selection", () => { recreate: true, toolDisclosure: "progressive", observabilityEnabled: false, + endpointSource: null, dcodeAutoApprovalMode: "disabled", extraProviders: [], }); @@ -212,6 +215,7 @@ describe("handleSandboxState live DCode selection", () => { recreate: true, toolDisclosure: "progressive", observabilityEnabled: false, + endpointSource: null, dcodeAutoApprovalMode: "disabled", extraProviders: [], }); diff --git a/src/lib/onboard/machine/handlers/sandbox-tool-disclosure.test.ts b/src/lib/onboard/machine/handlers/sandbox-tool-disclosure.test.ts index 922d27e084b..5d49ec77937 100644 --- a/src/lib/onboard/machine/handlers/sandbox-tool-disclosure.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox-tool-disclosure.test.ts @@ -72,6 +72,7 @@ describe("handleSandboxState tool disclosure", () => { await handleSandboxState({ ...baseOptions(deps, session), resume: true, + endpointSource: "inference-set", sandboxName: "saved", }); @@ -101,6 +102,7 @@ describe("handleSandboxState tool disclosure", () => { await handleSandboxState({ ...baseOptions(deps, session), resume: true, + endpointSource: "inference-set", sandboxName: "saved", }); @@ -125,6 +127,7 @@ describe("handleSandboxState tool disclosure", () => { recreate: true, toolDisclosure: requestedMode, observabilityEnabled: false, + endpointSource: "inference-set", extraProviders: [], reuseRegisteredCredentials: true, }, diff --git a/src/lib/onboard/machine/handlers/sandbox.test.ts b/src/lib/onboard/machine/handlers/sandbox.test.ts index 6a8de4d223d..53ffca98c48 100644 --- a/src/lib/onboard/machine/handlers/sandbox.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox.test.ts @@ -53,7 +53,7 @@ describe("handleSandboxState", () => { }); calls.setupMessaging.mockResolvedValue(["telegram"]); - const result = await handleSandboxState(baseOptions(deps)); + const result = await handleSandboxState({ ...baseOptions(deps), fresh: true }); expect(calls.startStep).toHaveBeenCalledWith("sandbox", { sandboxName: "my-assistant", @@ -82,6 +82,7 @@ describe("handleSandboxState", () => { recreate: false, toolDisclosure: "progressive", observabilityEnabled: false, + endpointSource: "onboard", extraProviders: [], }, ); @@ -531,6 +532,7 @@ describe("handleSandboxState", () => { recreate: false, toolDisclosure: "progressive", observabilityEnabled: false, + endpointSource: null, extraProviders: [], }, ); @@ -792,6 +794,7 @@ describe("handleSandboxState", () => { recreate: true, toolDisclosure: "progressive", observabilityEnabled: false, + endpointSource: null, extraProviders: [], }, ); @@ -1010,6 +1013,7 @@ describe("handleSandboxState", () => { recreate: true, toolDisclosure: "progressive", observabilityEnabled: false, + endpointSource: null, extraProviders: [], reuseRegisteredCredentials: true, }, @@ -1131,6 +1135,7 @@ describe("handleSandboxState", () => { recreate: true, toolDisclosure: "progressive", observabilityEnabled: false, + endpointSource: null, extraProviders: [], reuseRegisteredCredentials: true, }, diff --git a/src/lib/onboard/machine/handlers/sandbox.ts b/src/lib/onboard/machine/handlers/sandbox.ts index 5169e0726b8..b262a2daaea 100644 --- a/src/lib/onboard/machine/handlers/sandbox.ts +++ b/src/lib/onboard/machine/handlers/sandbox.ts @@ -7,6 +7,7 @@ import { type GatewayRouteCompatibilityResult, isAdvisoryGatewayRouteConflict, } from "../../../inference/gateway-route-compatibility"; +import type { InferenceEndpointSource } from "../../../inference/selection"; import { parseExplicitWebSearchProvider, type WebSearchConfig as SharedWebSearchConfig, @@ -111,6 +112,8 @@ export interface SandboxStateOptions< authoritativeResumeConfig?: boolean; /** Internal rebuild tier that must govern create-time and resumed policy selection. */ authoritativePolicyTier?: string | null; + /** Endpoint source to preserve during an authoritative rebuild. */ + endpointSource?: InferenceEndpointSource | null; resumeAgentChanged: boolean; requestedObservabilityEnabled?: boolean | null; requestedDcodeAutoApprovalMode?: DcodeAutoApprovalMode | null; @@ -345,6 +348,13 @@ function hasResourceProfileEnvOverride(env: NodeJS.ProcessEnv): boolean { return Boolean(env.NEMOCLAW_RESOURCE_PROFILE || env.NEMOCLAW_CPU || env.NEMOCLAW_RAM); } +function endpointSourceForCreateIntent( + fresh: boolean, + endpointSource: InferenceEndpointSource | null | undefined, +): InferenceEndpointSource | null { + return fresh ? "onboard" : (endpointSource ?? null); +} + type SandboxCreationDecision = Exclude; type CompleteSandboxCreateIntent = SandboxCreateIntent & { readonly resolved: ResolvedSandboxCreateIntent; @@ -1137,6 +1147,10 @@ class SandboxStateFlow< observabilityEnabled: state.session?.observabilityEnabled === true, ...(reuseRegisteredCredentials ? { reuseRegisteredCredentials: true as const } : {}), ...(this.options.endpointUrl ? { endpointUrl: this.options.endpointUrl } : {}), + endpointSource: endpointSourceForCreateIntent( + this.options.fresh, + this.options.endpointSource, + ), ...(state.session?.observabilityRequestedExplicitly === true ? { observabilityRequestedExplicitly: true as const } : {}), @@ -1232,6 +1246,7 @@ class SandboxStateFlow< model: this.options.model, provider: this.options.provider, endpointUrl: this.options.endpointUrl, + endpointSource: createIntent.endpointSource ?? null, credentialEnv: this.options.credentialEnv, nimContainer: this.options.nimContainer, preferredInferenceApi: this.options.preferredInferenceApi, diff --git a/src/lib/onboard/provider-recovery.test.ts b/src/lib/onboard/provider-recovery.test.ts index cc37fc19ed5..eccefa91656 100644 --- a/src/lib/onboard/provider-recovery.test.ts +++ b/src/lib/onboard/provider-recovery.test.ts @@ -237,6 +237,7 @@ describe("provider recovery persisted routing state", () => { provider: "compatible-endpoint", model: "model-a", endpointUrl: "https://registry.example/v1", + endpointSource: null, preferredInferenceApi: "openai-completions", source: "registry", }); @@ -244,6 +245,7 @@ describe("provider recovery persisted routing state", () => { provider: "compatible-endpoint", model: "model-b", endpointUrl: "https://session.example/v1", + endpointSource: null, preferredInferenceApi: "openai-responses", source: "session", }); @@ -262,6 +264,7 @@ describe("provider recovery persisted routing state", () => { provider: "compatible-endpoint", model: "registry-model", endpointUrl: "https://registry.example/v1", + endpointSource: null, preferredInferenceApi: "openai-completions", nimContainer: "registry-container", }); @@ -303,6 +306,7 @@ describe("provider recovery persisted routing state", () => { provider: "compatible-endpoint", model: "registry-model", endpointUrl: "https://registry.example/v1", + endpointSource: null, preferredInferenceApi: "openai-completions", source: "registry", }); diff --git a/src/lib/onboard/provider-recovery.ts b/src/lib/onboard/provider-recovery.ts index 48c60e98bc0..e656c5f929f 100644 --- a/src/lib/onboard/provider-recovery.ts +++ b/src/lib/onboard/provider-recovery.ts @@ -4,6 +4,10 @@ import * as onboardSession from "../state/onboard-session"; import * as registry from "../state/registry"; import { isSafeModelId } from "../validation"; +import { + type InferenceEndpointSource, + normalizeInferenceEndpointSource, +} from "../inference/selection"; export type RemoteProviderConfigEntryLike = { providerName?: string }; @@ -74,6 +78,7 @@ export interface RecordedInferenceRoute { provider: string; model: string; endpointUrl: string | null; + endpointSource?: InferenceEndpointSource | null; preferredInferenceApi: string; source: "registry" | "session"; } @@ -150,6 +155,7 @@ function completeRecordedInferenceRoute( provider?: unknown; model?: unknown; endpointUrl?: unknown; + endpointSource?: unknown; preferredInferenceApi?: unknown; }, source: RecordedInferenceRoute["source"], @@ -165,7 +171,10 @@ function completeRecordedInferenceRoute( typeof value.endpointUrl === "string" && value.endpointUrl.trim() ? value.endpointUrl.trim() : null; - return { ...inference, endpointUrl, preferredInferenceApi, source }; + const endpointSource = endpointUrl + ? normalizeInferenceEndpointSource(value.endpointSource) + : null; + return { ...inference, endpointUrl, endpointSource, preferredInferenceApi, source }; } export function createProviderRecoveryHelpers(deps: ProviderRecoveryDeps): ProviderRecoveryHelpers { diff --git a/src/lib/onboard/rebuild-route-handoff.test.ts b/src/lib/onboard/rebuild-route-handoff.test.ts index e6b6c31ac92..525c49e6f61 100644 --- a/src/lib/onboard/rebuild-route-handoff.test.ts +++ b/src/lib/onboard/rebuild-route-handoff.test.ts @@ -16,6 +16,7 @@ function registryRoute(): RegistryInferenceRoute { provider: "compatible-endpoint", model: "nvidia/model", endpointUrl: "https://inference.example.test/v1", + endpointSource: "onboard", preferredInferenceApi: "openai-completions", source: "registry", }; diff --git a/src/lib/onboard/rebuild-route-handoff.ts b/src/lib/onboard/rebuild-route-handoff.ts index 68aca9b6226..f04485c1881 100644 --- a/src/lib/onboard/rebuild-route-handoff.ts +++ b/src/lib/onboard/rebuild-route-handoff.ts @@ -40,6 +40,7 @@ export function createRebuildRouteHandoff( provider: route.provider, model: route.model, endpointUrl: route.endpointUrl, + endpointSource: route.endpointSource ?? null, preferredInferenceApi: route.preferredInferenceApi, source: "registry", }); @@ -112,6 +113,7 @@ function freezeRoute(route: RegistryInferenceRoute): RegistryInferenceRoute { provider: route.provider, model: route.model, endpointUrl: route.endpointUrl, + endpointSource: route.endpointSource ?? null, preferredInferenceApi: route.preferredInferenceApi, source: "registry", }); @@ -122,6 +124,7 @@ function routesMatch(left: RegistryInferenceRoute, right: RegistryInferenceRoute left.provider === right.provider && left.model === right.model && left.endpointUrl === right.endpointUrl && + (left.endpointSource ?? null) === (right.endpointSource ?? null) && left.preferredInferenceApi === right.preferredInferenceApi ); } diff --git a/src/lib/onboard/sandbox-registration.test.ts b/src/lib/onboard/sandbox-registration.test.ts index e135fa21512..aeea20e7d51 100644 --- a/src/lib/onboard/sandbox-registration.test.ts +++ b/src/lib/onboard/sandbox-registration.test.ts @@ -287,10 +287,13 @@ describe("selection", () => { nimContainer: "wrong", }); - expect(selection("demo", "compatible-endpoint", "llama", "openai-completions")).toEqual({ + expect( + selection("demo", "compatible-endpoint", "llama", "openai-completions", "onboard"), + ).toEqual({ provider: "compatible-endpoint", model: "llama", endpointUrl: null, + endpointSource: null, credentialEnv: null, preferredInferenceApi: "openai-completions", compatibleEndpointReasoning: null, @@ -309,10 +312,13 @@ describe("selection", () => { nimContainer: "nim-right", }); - expect(selection("demo", "compatible-endpoint", "llama", "openai-completions")).toEqual({ + expect( + selection("demo", "compatible-endpoint", "llama", "openai-completions", "onboard"), + ).toEqual({ provider: "compatible-endpoint", model: "llama", endpointUrl: "https://right.test/v1", + endpointSource: "onboard", credentialEnv: "COMPATIBLE_API_KEY", preferredInferenceApi: "openai-completions", compatibleEndpointReasoning: "true", diff --git a/src/lib/onboard/sandbox-registration.ts b/src/lib/onboard/sandbox-registration.ts index 7a20b52a092..d0554b60859 100644 --- a/src/lib/onboard/sandbox-registration.ts +++ b/src/lib/onboard/sandbox-registration.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import type { AgentDefinition } from "../agent/defs"; -import type { InferenceSelection } from "../inference/selection"; +import type { InferenceEndpointSource, InferenceSelection } from "../inference/selection"; import { inferenceSelectionRegistryFields } from "../inference/selection"; import { type WebSearchConfig, webSearchProviderForConfig } from "../inference/web-search"; import * as onboardSession from "../state/onboard-session"; @@ -91,6 +91,7 @@ export function selection( provider: string, model: string, preferredInferenceApi: string | null, + endpointSource: InferenceEndpointSource | null, ): InferenceSelection { const session = onboardSession.loadSession(); const sessionMatches = @@ -101,6 +102,7 @@ export function selection( provider, model, endpointUrl: sessionMatches ? (session.endpointUrl ?? null) : null, + endpointSource: sessionMatches ? endpointSource : null, credentialEnv: sessionMatches ? (session.credentialEnv ?? null) : null, preferredInferenceApi, compatibleEndpointReasoning: sessionMatches diff --git a/src/lib/onboard/setup-inference-route-containment.test.ts b/src/lib/onboard/setup-inference-route-containment.test.ts index a3ee7bda379..15044ddc88d 100644 --- a/src/lib/onboard/setup-inference-route-containment.test.ts +++ b/src/lib/onboard/setup-inference-route-containment.test.ts @@ -401,6 +401,9 @@ describe("onboard shared gateway route containment", () => { "router-a", "http://router-a.test/v1", "ROUTER_KEY", + null, + [], + { endpointSource: "inference-set" }, ); await vi.waitFor(() => expect(verifyOnboardInferenceSmoke).toHaveBeenCalledOnce()); expect(reservations).toEqual([ @@ -434,9 +437,11 @@ describe("onboard shared gateway route containment", () => { provider: "router-a", model: "model-a", endpointUrl: "http://router-a.test/v1", + endpointSource: "inference-set", credentialEnv: "ROUTER_KEY", preferredInferenceApi: null, gatewayName: "nemoclaw", + reservationSessionId: undefined, }); expect(reservations).toHaveLength(2); expect(updateSandbox).toHaveBeenCalledTimes(2); diff --git a/src/lib/onboard/setup-inference.ts b/src/lib/onboard/setup-inference.ts index 3026fdf5651..3219ab04a7e 100644 --- a/src/lib/onboard/setup-inference.ts +++ b/src/lib/onboard/setup-inference.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { isBedrockRuntimeEndpoint } from "../inference/bedrock-runtime"; +import { canonicalEndpoint } from "../core/url-utils"; import { assertEndpointResolvesPublic, type EndpointDnsLookupFn, @@ -24,6 +25,17 @@ import { withSandboxMutationLock } from "../state/mcp-lifecycle-lock"; export { assertNoOpenShellGatewayEndpointOverride }; import type { HermesAuthMethod } from "./hermes-auth"; + +function matchesOnboardEndpoint( + provider: string, + endpointUrl: string | null, + onboardEndpointUrl: string | undefined, +): boolean { + if (!endpointUrl || !onboardEndpointUrl) return false; + const flavor = provider === "compatible-anthropic-endpoint" ? "anthropic" : "openai"; + const selected = canonicalEndpoint(endpointUrl, flavor); + return selected !== null && selected === canonicalEndpoint(onboardEndpointUrl, flavor); +} import type { CommonDeps, HermesDeps, @@ -231,6 +243,8 @@ export function createSetupInference( options: ProviderInferenceSetupOptions = {}, ): Promise { const gatewayName = options.gatewayName ?? deps.getGatewayName(); + const endpointSource = + options.endpointSource === undefined ? "onboard" : options.endpointSource; const mutateGatewayRoute = (): Promise => deps.withGatewayRouteMutationLock(gatewayName, async () => { if ( @@ -269,10 +283,16 @@ export function createSetupInference( // do not apply the custom-origin curl pinning contract here. const usesBedrockRuntimeAdapter = provider === "compatible-anthropic-endpoint" && isBedrockRuntimeEndpoint(endpointUrl); + const usesOnboardEndpoint = matchesOnboardEndpoint( + provider, + endpointUrl, + options.onboardEndpointUrl, + ); if ( (provider === "compatible-endpoint" || provider === "compatible-anthropic-endpoint") && endpointUrl && !usesBedrockRuntimeAdapter && + !usesOnboardEndpoint && !endpointPinnedAddresses ) { const preflight = await assertEndpointResolvesPublic( @@ -307,6 +327,7 @@ export function createSetupInference( provider: selectedProvider, model: selectedModel, endpointUrl, + endpointSource, credentialEnv, preferredInferenceApi: options.preferredInferenceApi ?? null, gatewayName, diff --git a/src/lib/onboard/setup-nim-flow.test.ts b/src/lib/onboard/setup-nim-flow.test.ts index 3bfe5647aaf..e8151790ab1 100644 --- a/src/lib/onboard/setup-nim-flow.test.ts +++ b/src/lib/onboard/setup-nim-flow.test.ts @@ -288,6 +288,7 @@ describe("createSetupNim", () => { model: "nvidia/nemotron-3-super-120b-a12b", provider: "nvidia-prod", endpointUrl: "https://integrate.api.nvidia.com/v1", + endpointSource: null, credentialEnv: "NVIDIA_INFERENCE_API_KEY", hermesAuthMethod: null, hermesToolGateways: [], @@ -592,6 +593,7 @@ describe("createSetupNim", () => { provider: "openai-api", model: "handoff-model", endpointUrl: "https://handoff.example.com/v1", + endpointSource: "inference-set", preferredInferenceApi: "openai-responses", source: "registry", } as const; @@ -645,6 +647,7 @@ describe("createSetupNim", () => { model: "handoff-model", provider: "openai-api", endpointUrl: "https://handoff.example.com/v1", + endpointSource: "inference-set", preferredInferenceApi: "openai-completions", compatibleEndpointReasoning: null, skipHostInferenceSmoke: true, diff --git a/src/lib/onboard/setup-nim-flow.ts b/src/lib/onboard/setup-nim-flow.ts index 17e5220a2bd..9def65923a5 100644 --- a/src/lib/onboard/setup-nim-flow.ts +++ b/src/lib/onboard/setup-nim-flow.ts @@ -643,11 +643,20 @@ export function createSetupNim( deps, ); const selectedModel = isBackToSelection(model) ? null : model; + const recoveredRegistryRouteMatches = + recoveredRegistryRoute?.provider === provider && + recoveredRegistryRoute.endpointUrl === endpointUrl; + const endpointSource = recoveredRegistryRouteMatches + ? (recoveredRegistryRoute.endpointSource ?? null) + : endpointPinnedAddresses || endpointTrustedPrivateCapability + ? "onboard" + : null; await deps.maybePromptForInferenceInputCapability(selectedModel); return { model: selectedModel, provider, endpointUrl, + endpointSource, credentialEnv, hermesAuthMethod, hermesToolGateways, diff --git a/src/lib/onboard/types.ts b/src/lib/onboard/types.ts index b95e0c28494..ca303aa833b 100644 --- a/src/lib/onboard/types.ts +++ b/src/lib/onboard/types.ts @@ -64,6 +64,8 @@ export interface SandboxCreateIntent { readonly dcodeAutoApprovalMode?: import("./dcode-auto-approval").DcodeAutoApprovalMode; /** Non-secret upstream endpoint metadata for managed image config generation. */ readonly endpointUrl?: string | null; + /** Provenance for the endpoint recorded with the created sandbox. */ + readonly endpointSource?: import("../inference/selection").InferenceEndpointSource | null; /** Internal authoritative rebuild tier used before replacement registration completes. */ readonly policyTier?: string | null; /** Gateway-level extra providers reconciled immediately before sandbox creation. */ @@ -76,6 +78,8 @@ export type OnboardOptions = { nonInteractive?: boolean; recreateSandbox?: boolean; authoritativeResumeConfig?: boolean; + /** Internal endpoint provenance preserved across an authoritative rebuild. */ + endpointSource?: import("../inference/selection").InferenceEndpointSource | null; /** Internal authoritative rebuild target; never exposed as a public CLI option. */ targetGatewayName?: string | null; /** Internal authoritative rebuild target; must match targetGatewayName. */ diff --git a/src/lib/state/registry.ts b/src/lib/state/registry.ts index f577be81ab6..46353f4579c 100644 --- a/src/lib/state/registry.ts +++ b/src/lib/state/registry.ts @@ -563,7 +563,12 @@ export function registerSandbox(entry: SandboxEntry): void { type SandboxInferenceRouteReservation = Pick< InferenceSelection, - "provider" | "model" | "endpointUrl" | "credentialEnv" | "preferredInferenceApi" + | "provider" + | "model" + | "endpointUrl" + | "endpointSource" + | "credentialEnv" + | "preferredInferenceApi" > & { gatewayName: string; reservationSessionId?: string; @@ -589,6 +594,7 @@ export function reserveSandboxInferenceRoute( provider: normalized.provider, model: normalized.model, endpointUrl: normalized.endpointUrl, + endpointSource: normalized.endpointSource, credentialEnv: normalized.credentialEnv, preferredInferenceApi: normalized.preferredInferenceApi, gatewayName: route.gatewayName, diff --git a/test/onboard-fsm-live-slices.test.ts b/test/onboard-fsm-live-slices.test.ts index 3d4e7b808b5..571b33568be 100644 --- a/test/onboard-fsm-live-slices.test.ts +++ b/test/onboard-fsm-live-slices.test.ts @@ -18,6 +18,7 @@ type ProbeMode = | "resume-initial" | "resume-core-gateway" | "resume-incomplete-core-gateway" + | "resume-core-gateway-provenance-resolver" | "authoritative-core-gateway" | "authoritative-core-gateway-policy-tier" | "ordinary-policy-tier" @@ -206,10 +207,18 @@ const registry = require(${registryPath}); const called = []; const sentinel = new Error("slice-called"); -if (scenario.mode.endsWith("policy-tier")) { +if (scenario.mode.endsWith("policy-tier") || scenario.mode.endsWith("provenance-resolver")) { coreFlowPhases.createCoreOnboardFlowPhases = (options) => { - const tier = options.sandbox.authoritativePolicyTier; - called.push("authoritative-policy-tier:" + (tier === undefined ? "undefined" : String(tier))); + const detail = scenario.mode.endsWith("provenance-resolver") + ? (() => { + const entry = options.sandboxDeps.getSandboxRegistryEntry("fsm-sandbox"); + return ["registry-provenance", entry?.provider, entry?.endpointUrl, entry?.endpointSource].join(":"); + })() + : "authoritative-policy-tier:" + + (options.sandbox.authoritativePolicyTier === undefined + ? "undefined" + : String(options.sandbox.authoritativePolicyTier)); + called.push(detail); throw sentinel; }; } @@ -346,11 +355,17 @@ if (scenario.mode === "resume-initial") { if (scenario.mode.includes("core-gateway")) { seedResumeSession("inference", scenario.mode !== "resume-incomplete-core-gateway"); } -if (scenario.mode === "resume-core-gateway" || scenario.mode === "resume-incomplete-core-gateway") { +if ( + scenario.mode === "resume-core-gateway" || + scenario.mode === "resume-incomplete-core-gateway" || + scenario.mode === "resume-core-gateway-provenance-resolver" +) { registry.registerSandbox({ name: "fsm-sandbox", provider: "openai-api", model: "gpt-test", + endpointUrl: "https://persisted.example.test/v1", + endpointSource: "onboard", gatewayName: "nemoclaw-9090", gatewayPort: 9090, }); @@ -481,6 +496,16 @@ describe("live onboard FSM slice boundaries", () => { ]); }); + it("wires the live sandbox registry resolver into core provenance", () => { + assert.deepEqual( + runSliceProbe({ slice: "core", mode: "resume-core-gateway-provenance-resolver" }), + [ + "gateway:nemoclaw-9090:nemoclaw-9090", + "registry-provenance:openai-api:https://persisted.example.test/v1:onboard", + ], + ); + }); + it("keeps an authoritative rebuild gateway after the registry row is removed", () => { assert.deepEqual(runSliceProbe({ slice: "core", mode: "authoritative-core-gateway" }), [ "gateway:nemoclaw-9090:nemoclaw-9090", diff --git a/test/onboard-inference-failure-paths.test.ts b/test/onboard-inference-failure-paths.test.ts index ce09d4efec4..085632d12ea 100644 --- a/test/onboard-inference-failure-paths.test.ts +++ b/test/onboard-inference-failure-paths.test.ts @@ -1078,6 +1078,7 @@ describe("setupInference dependency failures", () => { model: "router/model", provider: "nvidia-router", endpointUrl: "http://host.openshell.internal:4000/v1", + endpointSource: "onboard", credentialEnv: "NVIDIA_INFERENCE_API_KEY", preferredInferenceApi: null, gatewayName: "nemoclaw", diff --git a/test/onboard-inference-gateway-scope.test.ts b/test/onboard-inference-gateway-scope.test.ts index 67606df0bc0..fc9a224ebf4 100644 --- a/test/onboard-inference-gateway-scope.test.ts +++ b/test/onboard-inference-gateway-scope.test.ts @@ -92,14 +92,18 @@ describe("onboarding inference gateway scope", () => { endpointUrl, credentialEnv: "COMPATIBLE_API_KEY", pinnedAddresses: [], + trustedPrivateCapability: undefined, + capabilityCache: undefined, }); expect(harness.updateSandbox).toHaveBeenCalledWith("dcode-vllm-local", { provider: "compatible-endpoint", model, endpointUrl, + endpointSource: "onboard", credentialEnv: "COMPATIBLE_API_KEY", preferredInferenceApi: null, gatewayName: GATEWAY, + reservationSessionId: undefined, }); expectCommandsTargetOnly(harness.commands); }, diff --git a/test/onboard-inference-reconciliation.test.ts b/test/onboard-inference-reconciliation.test.ts index ce26d31d189..cbffc310cee 100644 --- a/test/onboard-inference-reconciliation.test.ts +++ b/test/onboard-inference-reconciliation.test.ts @@ -150,7 +150,7 @@ describe("onboard helpers", () => { /inference set -g nemoclaw --no-verify --provider compatible-anthropic-endpoint --model anthropic\.claude-3-5-sonnet-20240620-v1:0/, ); // biome-ignore format: keep the complete route reservation assertion within this legacy file's enforced budget. - expect(updateSandbox).toHaveBeenCalledWith("test-box", { model: "anthropic.claude-3-5-sonnet-20240620-v1:0", provider: "compatible-anthropic-endpoint", endpointUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", credentialEnv: "COMPATIBLE_ANTHROPIC_API_KEY", preferredInferenceApi: null, gatewayName: "nemoclaw" }); + expect(updateSandbox).toHaveBeenCalledWith("test-box", { model: "anthropic.claude-3-5-sonnet-20240620-v1:0", provider: "compatible-anthropic-endpoint", endpointUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", endpointSource: "onboard", credentialEnv: "COMPATIBLE_ANTHROPIC_API_KEY", preferredInferenceApi: null, gatewayName: "nemoclaw" }); }); }); it("resolves a sandbox name before reconciling Hermes Provider on resume", { diff --git a/test/onboard-remote-recreate-credential-reuse.test.ts b/test/onboard-remote-recreate-credential-reuse.test.ts index cc29ab1723a..27d21c5bed0 100644 --- a/test/onboard-remote-recreate-credential-reuse.test.ts +++ b/test/onboard-remote-recreate-credential-reuse.test.ts @@ -86,6 +86,7 @@ const registryRoute = { provider: "compatible-endpoint", model: "nvidia/nemotron-3-ultra", endpointUrl: "https://inference-api.nvidia.com/v1", + endpointSource: "onboard", preferredInferenceApi: "openai-completions", source: "registry", }; @@ -126,6 +127,9 @@ const { setupNim, setupInference } = require(${onboardPath}); process.env.NEMOCLAW_TEST_OMIT_REUSE_AUTHORIZATION === "1" ? undefined : selected.reuseGatewayCredentialWithoutLocalKey, + endpointSource: selected.endpointSource, + onboardEndpointUrl: + selected.endpointSource === "onboard" ? selected.endpointUrl : undefined, }, ); console.log(JSON.stringify(selected));