From 67f00e4fec2666c9a34bab486250c700bdbf2637 Mon Sep 17 00:00:00 2001 From: Yanyun Liao Date: Mon, 17 Aug 2026 20:37:54 +0800 Subject: [PATCH 1/3] fix(onboard): select the provider a requested serving profile needs `onboard --profile` validated the requested profile, recorded its provenance, and exported NEMOCLAW_SERVING_PRESET, but never chose a provider. The preset only picks the model once a local-inference provider has been selected, so onboarding fell through to the interactive provider menu with the profile unapplied and its provenance block never printed. Because --profile also rejects an explicit NEMOCLAW_PROVIDER, there was no way to complete the run, and with no TTY it blocked on the hidden prompt. Derive the provider from the profile's backend and export it alongside the preset for the same single run. A backend with no provider wired up is now reported instead of silently falling through to the menu. Fixes #9313 Signed-off-by: Yanyun Liao --- src/lib/onboard/command.test.ts | 60 ++++++++++++++++++++++++++++++++- src/lib/onboard/command.ts | 48 +++++++++++++++++++++++++- 2 files changed, 106 insertions(+), 2 deletions(-) diff --git a/src/lib/onboard/command.test.ts b/src/lib/onboard/command.test.ts index 99d10b9491e..8e45fb59ce3 100644 --- a/src/lib/onboard/command.test.ts +++ b/src/lib/onboard/command.test.ts @@ -10,7 +10,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { getCredential } from "../credentials/store"; import { loadServingCatalog } from "../inference/serving/catalog-loader"; import { servingProfileProvenance } from "../inference/serving/profile-provenance"; -import { resolveOnboardOptions, runOnboardCommand } from "./command"; +import { resolveOnboardOptions, runOnboardCommand, servingProfileProviderKey } from "./command"; import type { OnboardFlags } from "./command-support"; import { PortableInferenceDescriptorError } from "./experimental/portable-inference-descriptor"; import { invalidGatewayManagementDeclarationError } from "./gateway-management"; @@ -20,6 +20,7 @@ import { LOCAL_MODEL_PROFILE_RUNTIME_ENV, } from "./local-model-profile/plan"; import { OnboardResumeIntentError, OnboardResumeIntentRaceError } from "./session-bootstrap"; +import { MANAGED_VLLM_PROVIDER_KEY } from "./vllm-menu"; afterEach(() => { vi.unstubAllEnvs(); @@ -747,6 +748,63 @@ describe("onboard command options", () => { expect(env.NEMOCLAW_SERVING_PRESET).toBeUndefined(); }); + it("selects the profile's backend provider so onboarding skips the menu (#9313)", async () => { + // The preset alone only picks the model once a provider is chosen. Without + // a provider the run fell through to the interactive provider menu with the + // requested profile never applied. + const vllmProfile = { + ...COMPATIBLE_NANO_PROFILE, + id: "vllm.dgx-spark-gb10.single.muse-glimmer-30b-nvfp4-w4a4", + displayName: "Muse Glimmer 30B NVFP4 W4A4 on one DGX Spark", + backend: "vllm", + }; + const env: NodeJS.ProcessEnv = {}; + let observedProvider: string | undefined; + let observedPreset: string | undefined; + await runOnboardCommand({ + flags: { profile: vllmProfile.id }, + env, + listServingProfiles: () => [vllmProfile], + runOnboard: async () => { + observedProvider = env.NEMOCLAW_PROVIDER; + observedPreset = env.NEMOCLAW_SERVING_PRESET; + }, + }); + + expect(observedProvider).toBe("install-vllm"); + expect(observedPreset).toBe(vllmProfile.id); + // Scoped to the run, like the preset itself. + expect(env.NEMOCLAW_PROVIDER).toBeUndefined(); + expect(env.NEMOCLAW_SERVING_PRESET).toBeUndefined(); + }); + + it("selects the managed llama.cpp provider for a llama-cpp profile (#9313)", async () => { + const env: NodeJS.ProcessEnv = {}; + let observedProvider: string | undefined; + await runOnboardCommand({ + flags: { profile: COMPATIBLE_NANO_PROFILE.id }, + env, + listServingProfiles: () => [COMPATIBLE_NANO_PROFILE], + runOnboard: async () => { + observedProvider = env.NEMOCLAW_PROVIDER; + }, + }); + + expect(observedProvider).toBe("install-llama-cpp"); + expect(env.NEMOCLAW_PROVIDER).toBeUndefined(); + }); + + it("maps each serving backend to the provider that can run it (#9313)", () => { + // A backend with no provider returns null, which `resolveServingProfile` + // reports instead of accepting the flag and then asking for a provider. + const withBackend = (backend: string) => ({ recipe: { backend } }) as never; + + // Pinned against the provider menu's own key so the two cannot drift. + expect(servingProfileProviderKey(withBackend("vllm"))).toBe(MANAGED_VLLM_PROVIDER_KEY); + expect(servingProfileProviderKey(withBackend("install-llama-cpp"))).toBe("install-llama-cpp"); + expect(servingProfileProviderKey(withBackend("future-backend"))).toBeNull(); + }); + it("records an installer profile without activating the disabled generic preset", async () => { const env: NodeJS.ProcessEnv = { [LOCAL_MODEL_PROFILE_ENABLED_ENV]: "1", diff --git a/src/lib/onboard/command.ts b/src/lib/onboard/command.ts index 8190d3b24f6..7af10a6ad60 100644 --- a/src/lib/onboard/command.ts +++ b/src/lib/onboard/command.ts @@ -275,7 +275,16 @@ function resolveServingProfile( throw error; } validateServingProfileConflicts(selectedProfileId, deps); - return servingProfileProvenance(catalog, selectedProfileId); + const provenance = servingProfileProvenance(catalog, selectedProfileId); + if (!servingProfileProviderKey(provenance)) { + // Reporting this beats the old behaviour of accepting the flag and then + // asking which provider to use, which left the profile silently unapplied. + fail( + deps, + ` Serving profile '${selectedProfileId}' uses backend '${provenance.recipe.backend}', which onboarding cannot configure with --profile.`, + ); + } + return provenance; } function resolveInstallerServingProfile( @@ -323,6 +332,29 @@ function activeServingProfileId(provenance: ServingProfileProvenance | null): st return provenance.preset.id; } +/** + * Provider the requested serving profile has to run through. + * + * The preset alone only tells provider selection *which* profile to serve once + * a local-inference provider has been chosen; it never chooses the provider. + * Because `--profile` also rejects an explicit `NEMOCLAW_PROVIDER`, leaving + * this unset dropped onboarding into the interactive provider menu with the + * requested profile unusable (#9313). Returns null for a backend that has no + * provider wired up, which the caller reports rather than silently ignoring. + */ +export function servingProfileProviderKey(provenance: ServingProfileProvenance): string | null { + switch (provenance.recipe.backend) { + // Kept as literals so this module does not take a dependency on the + // provider menu; `command.test.ts` asserts they match its exported keys. + case "vllm": + return "install-vllm"; + case "install-llama-cpp": + return "install-llama-cpp"; + default: + return null; + } +} + function resolveResumedServingProfile( requested: ServingProfileProvenance | null, deps: ResolveOnboardOptionsDeps, @@ -477,9 +509,23 @@ function applyServingProfileEnvironment( if (!options.servingProfile) return () => {}; const previous = env[NEMOCLAW_SERVING_PRESET_ENV]; env[NEMOCLAW_SERVING_PRESET_ENV] = options.servingProfile; + // The preset selects the model once a provider is chosen; the profile's + // backend is what selects the provider. Setting only the former left the + // provider unresolved and onboarding fell back to the menu (#9313). + // `validateServingProfileConflicts` already rejected an operator-supplied + // NEMOCLAW_PROVIDER, so nothing of the caller's is being overwritten here. + const providerKey = options.servingProfileProvenance + ? servingProfileProviderKey(options.servingProfileProvenance) + : null; + const previousProvider = env.NEMOCLAW_PROVIDER; + if (providerKey) env.NEMOCLAW_PROVIDER = providerKey; return () => { if (previous === undefined) delete env[NEMOCLAW_SERVING_PRESET_ENV]; else env[NEMOCLAW_SERVING_PRESET_ENV] = previous; + if (providerKey) { + if (previousProvider === undefined) delete env.NEMOCLAW_PROVIDER; + else env.NEMOCLAW_PROVIDER = previousProvider; + } }; } From 33cd1a2852c627477475e0f03b19d3069cbf351c Mon Sep 17 00:00:00 2001 From: Yanyun Liao Date: Mon, 17 Aug 2026 21:50:46 +0800 Subject: [PATCH 2/3] fix(onboard): check the provider mapping on every profile path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The unmapped-backend check only guarded an explicit --profile, but the installer and resume paths produce provenance that reaches the same environment application. An unmapped backend arriving that way would set the preset while leaving the provider unresolved — the silent fall-through to the provider menu this change set fixes. Check the profile the run actually settles on, after installer and resume resolution, so all three paths converge on the same authoritative result. Signed-off-by: Yanyun Liao --- src/lib/onboard/command.test.ts | 38 +++++++++++++++++++++++++++++++++ src/lib/onboard/command.ts | 33 +++++++++++++++++----------- 2 files changed, 59 insertions(+), 12 deletions(-) diff --git a/src/lib/onboard/command.test.ts b/src/lib/onboard/command.test.ts index 8e45fb59ce3..e48f6f24bfe 100644 --- a/src/lib/onboard/command.test.ts +++ b/src/lib/onboard/command.test.ts @@ -794,6 +794,44 @@ describe("onboard command options", () => { expect(env.NEMOCLAW_PROVIDER).toBeUndefined(); }); + it("rejects an unmapped backend on the resume path too (#9313)", () => { + // Explicit --profile, the installer path, and resume all converge on the + // same environment application, so the check lives at the end of the + // lifecycle rather than on the explicit path alone. Resume replays a + // recorded profile: a backend with no provider must be reported instead of + // resuming into the provider menu. + const catalog = loadServingCatalog(); + // Retarget preset and recipe together; provenance requires them to agree. + const patchedCatalog = { + ...catalog, + presets: catalog.presets.map((preset) => ({ + ...preset, + spec: { ...preset.spec, plan: { ...preset.spec.plan, backend: "future-backend" } }, + })), + recipes: catalog.recipes.map((recipe) => ({ + ...recipe, + spec: { ...recipe.spec, backend: "future-backend" }, + })), + }; + const recorded = servingProfileProvenance( + patchedCatalog as never, + catalog.presets[0]!.metadata.id, + ); + const errors: string[] = []; + + expect(() => + resolve( + { resume: true }, + { + loadServingCatalog: () => patchedCatalog as never, + loadSession: () => ({ servingProfileProvenance: recorded }) as never, + error: (message = "") => errors.push(message), + }, + ), + ).toThrow("exit:1"); + expect(errors.join("\n")).toContain("which onboarding cannot configure"); + }); + it("maps each serving backend to the provider that can run it (#9313)", () => { // A backend with no provider returns null, which `resolveServingProfile` // reports instead of accepting the flag and then asking for a provider. diff --git a/src/lib/onboard/command.ts b/src/lib/onboard/command.ts index 7af10a6ad60..fad97fd85a5 100644 --- a/src/lib/onboard/command.ts +++ b/src/lib/onboard/command.ts @@ -275,16 +275,7 @@ function resolveServingProfile( throw error; } validateServingProfileConflicts(selectedProfileId, deps); - const provenance = servingProfileProvenance(catalog, selectedProfileId); - if (!servingProfileProviderKey(provenance)) { - // Reporting this beats the old behaviour of accepting the flag and then - // asking which provider to use, which left the profile silently unapplied. - fail( - deps, - ` Serving profile '${selectedProfileId}' uses backend '${provenance.recipe.backend}', which onboarding cannot configure with --profile.`, - ); - } - return provenance; + return servingProfileProvenance(catalog, selectedProfileId); } function resolveInstallerServingProfile( @@ -323,8 +314,26 @@ function resolveServingProfileLifecycle( ); } const requested = explicit ?? installerProfile; - if (!resume) return requested; - return resolveResumedServingProfile(requested, deps); + const settled = resume ? resolveResumedServingProfile(requested, deps) : requested; + // Check the profile the run will actually apply, not just an explicit + // --profile: the installer and resume paths reach the same environment + // application, and an unmapped backend there would set the preset while + // leaving the provider unresolved — the silent fall-through to the provider + // menu this fixes (#9313). + return assertServingProfileProviderSupported(settled, deps); +} + +function assertServingProfileProviderSupported( + provenance: ServingProfileProvenance | null, + deps: ResolveOnboardOptionsDeps, +): ServingProfileProvenance | null { + const unsupported = provenance !== null && servingProfileProviderKey(provenance) === null; + return unsupported + ? fail( + deps, + ` Serving profile '${provenance.preset.id}' uses backend '${provenance.recipe.backend}', which onboarding cannot configure.`, + ) + : provenance; } function activeServingProfileId(provenance: ServingProfileProvenance | null): string | null { From 08a49f80bb0c4c42942807fb9f9a450898b3e4b2 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Mon, 17 Aug 2026 09:40:30 -0700 Subject: [PATCH 3/3] test(onboard): cover profile provider cleanup Signed-off-by: Apurv Kumaria --- docs/inference/set-up-vllm.mdx | 2 + src/lib/onboard/command.test.ts | 96 ++++++++++++++++++--------------- 2 files changed, 56 insertions(+), 42 deletions(-) diff --git a/docs/inference/set-up-vllm.mdx b/docs/inference/set-up-vllm.mdx index 71cac0768a6..36d4893200e 100644 --- a/docs/inference/set-up-vllm.mdx +++ b/docs/inference/set-up-vllm.mdx @@ -119,6 +119,8 @@ $$nemoclaw onboard --profile ``` The same profile selector works with interactive and non-interactive onboarding. +NemoClaw selects the inference provider required by the profile's backend, so onboarding does not show the provider menu. +If the profile's backend has no corresponding inference provider, onboarding exits before it changes runtime resources. NemoClaw rejects unknown, ambiguous, disabled, incompatible, or conflicting selections before image or model downloads begin. Before confirmation, the review screen shows the resolved profile and recipe IDs, model, immutable runtime image, support state, and estimated image and model downloads. Do not combine `--profile` with `NEMOCLAW_PROVIDER`, `NEMOCLAW_MODEL`, `NEMOCLAW_VLLM_MODEL`, `NEMOCLAW_MANAGED_CLUSTER_PEERS`, or `NEMOCLAW_VLLM_EXTRA_ARGS_JSON` overrides. diff --git a/src/lib/onboard/command.test.ts b/src/lib/onboard/command.test.ts index e48f6f24bfe..b515ac516d5 100644 --- a/src/lib/onboard/command.test.ts +++ b/src/lib/onboard/command.test.ts @@ -687,49 +687,61 @@ describe("onboard command options", () => { } }); - it("restores every scoped command value before exiting on a handled error (#9035)", async () => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-handled-error-environment-")); - const manifestPath = path.join(tmpDir, "agents.yaml"); - fs.writeFileSync(manifestPath, "agents: []\n"); - const env: NodeJS.ProcessEnv = { - NEMOCLAW_EXTRA_AGENTS_JSON: "previous-agents", - NEMOCLAW_OLLAMA_NO_AUTOSTART: "previous-autostart", - NEMOCLAW_SERVING_PRESET: "previous-serving", - NEMOCLAW_TOOL_DISCLOSURE: "previous-disclosure", - }; - let environmentAtExit: NodeJS.ProcessEnv | null = null; - - try { - await expect( - runOnboardCommand({ - flags: { - agents: manifestPath, - "no-ollama-autostart": true, - profile: COMPATIBLE_NANO_PROFILE.id, - "tool-disclosure": "direct", - }, - env, - listServingProfiles: () => [COMPATIBLE_NANO_PROFILE], - runOnboard: async () => { - throw invalidGatewayManagementDeclarationError("unsupported contract"); - }, - error: () => {}, - exit: (code): never => { - environmentAtExit = { ...env }; - throw new Error(`exit:${code}`); - }, - }), - ).rejects.toThrow("exit:1"); - expect(environmentAtExit).toEqual({ + it.each([ + { providerState: "unset", previousProvider: undefined }, + { providerState: "blank", previousProvider: "" }, + ])( + "restores every scoped command value before a handled-error exit when the provider is $providerState (#9035)", + async ({ previousProvider }) => { + const tmpDir = fs.mkdtempSync( + path.join(os.tmpdir(), "nemoclaw-handled-error-environment-"), + ); + const manifestPath = path.join(tmpDir, "agents.yaml"); + fs.writeFileSync(manifestPath, "agents: []\n"); + const env: NodeJS.ProcessEnv = { NEMOCLAW_EXTRA_AGENTS_JSON: "previous-agents", NEMOCLAW_OLLAMA_NO_AUTOSTART: "previous-autostart", - NEMOCLAW_SERVING_PRESET: "previous-serving", + NEMOCLAW_SERVING_PRESET: COMPATIBLE_NANO_PROFILE.id, NEMOCLAW_TOOL_DISCLOSURE: "previous-disclosure", + ...(previousProvider === undefined ? {} : { NEMOCLAW_PROVIDER: previousProvider }), + }; + let environmentAtExit: NodeJS.ProcessEnv | null = null; + const runOnboard = vi.fn(async () => { + throw invalidGatewayManagementDeclarationError("unsupported contract"); }); - } finally { - fs.rmSync(tmpDir, { recursive: true, force: true }); - } - }); + + try { + await expect( + runOnboardCommand({ + flags: { + agents: manifestPath, + "no-ollama-autostart": true, + profile: COMPATIBLE_NANO_PROFILE.id, + "tool-disclosure": "direct", + }, + env, + listServingProfiles: () => [COMPATIBLE_NANO_PROFILE], + runOnboard, + error: () => {}, + exit: (code): never => { + environmentAtExit = { ...env }; + throw new Error(`exit:${code}`); + }, + }), + ).rejects.toThrow("exit:1"); + expect(runOnboard).toHaveBeenCalledOnce(); + expect(environmentAtExit).toEqual({ + NEMOCLAW_EXTRA_AGENTS_JSON: "previous-agents", + NEMOCLAW_OLLAMA_NO_AUTOSTART: "previous-autostart", + NEMOCLAW_SERVING_PRESET: COMPATIBLE_NANO_PROFILE.id, + NEMOCLAW_TOOL_DISCLOSURE: "previous-disclosure", + ...(previousProvider === undefined ? {} : { NEMOCLAW_PROVIDER: previousProvider }), + }); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }, + ); it("scopes the selected catalog preset to one onboarding run (#8384)", async () => { const env: NodeJS.ProcessEnv = {}; @@ -748,7 +760,7 @@ describe("onboard command options", () => { expect(env.NEMOCLAW_SERVING_PRESET).toBeUndefined(); }); - it("selects the profile's backend provider so onboarding skips the menu (#9313)", async () => { + it("selects the profile's inference provider so onboarding skips the menu (#9313)", async () => { // The preset alone only picks the model once a provider is chosen. Without // a provider the run fell through to the interactive provider menu with the // requested profile never applied. @@ -837,7 +849,7 @@ describe("onboard command options", () => { // reports instead of accepting the flag and then asking for a provider. const withBackend = (backend: string) => ({ recipe: { backend } }) as never; - // Pinned against the provider menu's own key so the two cannot drift. + // Uses the provider menu's exported key so the two cannot drift. expect(servingProfileProviderKey(withBackend("vllm"))).toBe(MANAGED_VLLM_PROVIDER_KEY); expect(servingProfileProviderKey(withBackend("install-llama-cpp"))).toBe("install-llama-cpp"); expect(servingProfileProviderKey(withBackend("future-backend"))).toBeNull(); @@ -1061,7 +1073,7 @@ describe("onboard command options", () => { name: "serving profile", flags: { profile: COMPATIBLE_NANO_PROFILE.id } as OnboardFlags, listServingProfiles: () => [COMPATIBLE_NANO_PROFILE], - keys: ["NEMOCLAW_SERVING_PRESET"], + keys: ["NEMOCLAW_PROVIDER", "NEMOCLAW_SERVING_PRESET"], }, ])("restores the $name environment when an agents manifest is invalid", async (testCase) => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-invalid-agents-manifest-"));