From 1b1bc15d070aa557d988c365728af57d1c91a427 Mon Sep 17 00:00:00 2001 From: Deepak Jain Date: Fri, 8 May 2026 13:30:15 -0700 Subject: [PATCH 1/3] fix(onboard): recreate reset provider during resume Fixes #3278 Signed-off-by: Deepak Jain --- src/lib/onboard.ts | 55 +++++++++++++++++++++++++++++- test/onboard.test.ts | 80 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+), 1 deletion(-) diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index c36d876765..b5ee811991 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -1869,6 +1869,51 @@ function providerExistsInGateway(name: string) { return onboardProviders.providerExistsInGateway(name, runOpenshell); } +function getRemoteProviderConfigForName( + provider: string | null | undefined, +): RemoteProviderConfigEntry | null { + if (!provider) return null; + if (provider === "nvidia-nim") return REMOTE_PROVIDER_CONFIG.build; + return ( + Object.values(REMOTE_PROVIDER_CONFIG).find((entry) => entry.providerName === provider) || null + ); +} + +async function ensureResumeProviderReady( + provider: string | null | undefined, + credentialEnv: string | null | undefined, +): Promise<{ forceInferenceSetup: boolean }> { + const config = getRemoteProviderConfigForName(provider); + if (!config || !provider) return { forceInferenceSetup: false }; + if (providerExistsInGateway(provider)) return { forceInferenceSetup: false }; + + const resolvedCredentialEnv = credentialEnv || config.credentialEnv; + const credentialValue = hydrateCredentialEnv(resolvedCredentialEnv); + if (!credentialValue) { + if (isNonInteractive()) { + console.error( + ` ${resolvedCredentialEnv} is required to recreate provider '${provider}' during resume.`, + ); + console.error( + ` Re-run without --non-interactive to enter it, or set ${resolvedCredentialEnv} and retry.`, + ); + process.exit(1); + } + console.log(""); + console.log(` [resume] Provider '${provider}' is missing from the gateway.`); + console.log(" Re-enter the API key so onboarding can recreate it before rebuilding."); + await replaceNamedCredential( + resolvedCredentialEnv, + `${config.label} API key`, + config.helpUrl, + (value) => validateNvidiaApiKeyValue(value, resolvedCredentialEnv), + ); + } else { + note(` [resume] Provider '${provider}' is missing from the gateway; recreating it.`); + } + return { forceInferenceSetup: true }; +} + function getMessagingChannelForEnvKey(envKey: string): string | null { if (envKey === "DISCORD_BOT_TOKEN") return "discord"; if (envKey === "SLACK_BOT_TOKEN") return "slack"; @@ -9914,6 +9959,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { let nimContainer = session?.nimContainer || null; let webSearchConfig = session?.webSearchConfig || null; let forceProviderSelection = false; + let forceInferenceSetup = false; while (true) { const resumeProviderSelection = !forceProviderSelection && @@ -9922,9 +9968,12 @@ async function onboard(opts: OnboardOptions = {}): Promise { typeof provider === "string" && typeof model === "string"; if (resumeProviderSelection) { + const resumeProvider = await ensureResumeProviderReady(provider, credentialEnv); + forceInferenceSetup = resumeProvider.forceInferenceSetup; skippedStepMessage("provider_selection", `${provider} / ${model}`); hydrateCredentialEnv(credentialEnv); } else { + forceInferenceSetup = false; // #2753: do not persist sandboxName to onboard-session.json before // the sandbox actually exists in the gateway (Step 6 markStepComplete // below). A SIGINT between any earlier step and createSandbox would @@ -9957,7 +10006,10 @@ async function onboard(opts: OnboardOptions = {}): Promise { } process.env.NEMOCLAW_OPENSHELL_BIN = getOpenshellBinary(); const resumeInference = - !forceProviderSelection && resume && isInferenceRouteReady(provider, model); + !forceProviderSelection && + !forceInferenceSetup && + resume && + isInferenceRouteReady(provider, model); if (resumeInference) { if (isRoutedInferenceProvider(provider)) { try { @@ -10425,6 +10477,7 @@ module.exports = { printSandboxCreateRecoveryHints, promptYesNoOrDefault, providerExistsInGateway, + ensureResumeProviderReady, parsePolicyPresetEnv, parseSandboxStatus, pruneStaleSandboxEntry, diff --git a/test/onboard.test.ts b/test/onboard.test.ts index 609638fea2..2f7bee363f 100644 --- a/test/onboard.test.ts +++ b/test/onboard.test.ts @@ -109,6 +109,10 @@ type OnboardTestInternals = { flavor: "openai" | "anthropic", ) => string; providerNameToOptionKey: (name?: string | null) => string | null; + ensureResumeProviderReady: ( + provider?: string | null, + credentialEnv?: string | null, + ) => Promise<{ forceInferenceSetup: boolean }>; parsePolicyPresetEnv: (value: string | null) => string[]; patchStagedDockerfile: ShimFn; pullAndResolveBaseImageDigest: () => { digest: string; ref: string } | null; @@ -158,6 +162,7 @@ function isOnboardTestInternals( typeof value.formatSandboxBuildEstimateNote === "function" && Object.prototype.hasOwnProperty.call(value, "providerNameToOptionKey") && typeof value.providerNameToOptionKey === "function" && + typeof value.ensureResumeProviderReady === "function" && typeof value.shouldRunCompatibleEndpointSandboxSmoke === "function" && typeof value.writeSandboxConfigSyncFile === "function" ); @@ -212,6 +217,7 @@ const { isLoopbackHostname, normalizeProviderBaseUrl, providerNameToOptionKey, + ensureResumeProviderReady, parsePolicyPresetEnv, patchStagedDockerfile, pullAndResolveBaseImageDigest, @@ -405,6 +411,80 @@ describe("onboard helpers", () => { ); }); + it("re-prompts and forces inference setup when a resumed remote provider was reset", () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-resume-provider-reset-")); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "resume-provider-reset.js"); + const onboardPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "onboard.js")); + const credentialsPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "credentials", "store.js")); + const runnerPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "runner.js")); + const openshellPath = path.join(fakeBin, "openshell"); + + fs.mkdirSync(fakeBin, { recursive: true }); + fs.writeFileSync(openshellPath, "#!/usr/bin/env bash\nexit 0\n", { mode: 0o755 }); + + const script = ` +const credentials = require(${credentialsPath}); +const runner = require(${runnerPath}); +const calls = []; +const saved = []; + +runner.run = (command) => { + calls.push(Array.isArray(command) ? command.slice(1).join(" ") : String(command)); + if (Array.isArray(command) && command.includes("provider") && command.includes("get")) { + return { status: 1, stdout: "", stderr: "provider not found" }; + } + return { status: 0, stdout: "", stderr: "" }; +}; +credentials.resolveProviderCredential = () => null; +credentials.prompt = async () => "fresh-compatible-key"; +credentials.saveCredential = (name, value) => saved.push({ name, value }); + +process.env.NEMOCLAW_OPENSHELL_BIN = ${JSON.stringify(openshellPath)}; +delete process.env.COMPATIBLE_API_KEY; + +const { ensureResumeProviderReady } = require(${onboardPath}); +(async () => { + const result = await ensureResumeProviderReady("compatible-endpoint", "COMPATIBLE_API_KEY"); + console.log(JSON.stringify({ + result, + saved, + envValue: process.env.COMPATIBLE_API_KEY, + providerGet: calls.some((call) => call === "provider get compatible-endpoint"), + })); +})().catch((error) => { + console.error(error); + process.exit(1); +}); +`; + fs.writeFileSync(scriptPath, script); + + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmpDir, + PATH: `${fakeBin}:${process.env.PATH || ""}`, + }, + }); + + assert.equal(result.status, 0, result.stderr); + const payload = parseStdoutJson<{ + result: { forceInferenceSetup: boolean }; + saved: Array<{ name: string; value: string }>; + envValue: string; + providerGet: boolean; + }>(result.stdout); + assert.equal(payload.providerGet, true); + assert.deepEqual(payload.result, { forceInferenceSetup: true }); + assert.deepEqual(payload.saved, [ + { name: "COMPATIBLE_API_KEY", value: "fresh-compatible-key" }, + ]); + assert.equal(payload.envValue, "fresh-compatible-key"); + }); + it("uses explicit messaging selections for policy suggestions when provided", () => { const originalTelegramBotToken = process.env.TELEGRAM_BOT_TOKEN; const originalDiscordBotToken = process.env.DISCORD_BOT_TOKEN; From e8ad557cf5815b6e94e3f166e86803331c26ed1e Mon Sep 17 00:00:00 2001 From: Test User Date: Fri, 8 May 2026 13:47:11 -0700 Subject: [PATCH 2/3] docs: describe resume provider helpers Signed-off-by: Test User --- src/lib/onboard.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index b5ee811991..a9faf620e8 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -1869,6 +1869,9 @@ function providerExistsInGateway(name: string) { return onboardProviders.providerExistsInGateway(name, runOpenshell); } +/** + * Resolve a persisted OpenShell provider name back to its onboard provider config. + */ function getRemoteProviderConfigForName( provider: string | null | undefined, ): RemoteProviderConfigEntry | null { @@ -1879,6 +1882,9 @@ function getRemoteProviderConfigForName( ); } +/** + * Ensure a resumed remote provider still exists, re-prompting for credentials when needed. + */ async function ensureResumeProviderReady( provider: string | null | undefined, credentialEnv: string | null | undefined, From ccf204036bfe360a77a169d5b266717f8f627b47 Mon Sep 17 00:00:00 2001 From: Test User Date: Fri, 8 May 2026 13:55:46 -0700 Subject: [PATCH 3/3] fix: handle routed provider resume recovery Signed-off-by: Test User --- src/lib/onboard.ts | 26 ++++++++++++++++---- test/onboard.test.ts | 56 ++++++++++++++++++++++++-------------------- 2 files changed, 52 insertions(+), 30 deletions(-) diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index a9faf620e8..e3ed6c254f 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -141,6 +141,7 @@ const { detectVllmProfile, installVllm } = require("./inference/vllm"); const inferenceConfig: typeof import("./inference/config") = require("./inference/config"); const { DEFAULT_CLOUD_MODEL, + DEFAULT_ROUTE_CREDENTIAL_ENV, INFERENCE_ROUTE_URL, MANAGED_PROVIDER_ID, getProviderSelectionConfig, @@ -1882,6 +1883,19 @@ function getRemoteProviderConfigForName( ); } +/** + * Choose the credential env used to recreate a missing provider during resume. + */ +function getResumeProviderCredentialEnv( + provider: string, + config: RemoteProviderConfigEntry | null, + credentialEnv: string | null | undefined, +): string { + if (credentialEnv) return credentialEnv; + if (config?.credentialEnv) return config.credentialEnv; + return isRoutedInferenceProvider(provider) ? DEFAULT_ROUTE_CREDENTIAL_ENV : ""; +} + /** * Ensure a resumed remote provider still exists, re-prompting for credentials when needed. */ @@ -1890,11 +1904,15 @@ async function ensureResumeProviderReady( credentialEnv: string | null | undefined, ): Promise<{ forceInferenceSetup: boolean }> { const config = getRemoteProviderConfigForName(provider); - if (!config || !provider) return { forceInferenceSetup: false }; + if (!provider || (!config && !isRoutedInferenceProvider(provider))) { + return { forceInferenceSetup: false }; + } if (providerExistsInGateway(provider)) return { forceInferenceSetup: false }; - const resolvedCredentialEnv = credentialEnv || config.credentialEnv; + const resolvedCredentialEnv = getResumeProviderCredentialEnv(provider, config, credentialEnv); const credentialValue = hydrateCredentialEnv(resolvedCredentialEnv); + const providerLabel = config?.label || getProviderLabel(provider) || provider; + const helpUrl = config?.helpUrl || null; if (!credentialValue) { if (isNonInteractive()) { console.error( @@ -1910,8 +1928,8 @@ async function ensureResumeProviderReady( console.log(" Re-enter the API key so onboarding can recreate it before rebuilding."); await replaceNamedCredential( resolvedCredentialEnv, - `${config.label} API key`, - config.helpUrl, + `${providerLabel} API key`, + helpUrl, (value) => validateNvidiaApiKeyValue(value, resolvedCredentialEnv), ); } else { diff --git a/test/onboard.test.ts b/test/onboard.test.ts index 2f7bee363f..91209ffed8 100644 --- a/test/onboard.test.ts +++ b/test/onboard.test.ts @@ -421,10 +421,11 @@ describe("onboard helpers", () => { const runnerPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "runner.js")); const openshellPath = path.join(fakeBin, "openshell"); - fs.mkdirSync(fakeBin, { recursive: true }); - fs.writeFileSync(openshellPath, "#!/usr/bin/env bash\nexit 0\n", { mode: 0o755 }); + try { + fs.mkdirSync(fakeBin, { recursive: true }); + fs.writeFileSync(openshellPath, "#!/usr/bin/env bash\nexit 0\n", { mode: 0o755 }); - const script = ` + const script = ` const credentials = require(${credentialsPath}); const runner = require(${runnerPath}); const calls = []; @@ -458,31 +459,34 @@ const { ensureResumeProviderReady } = require(${onboardPath}); process.exit(1); }); `; - fs.writeFileSync(scriptPath, script); + fs.writeFileSync(scriptPath, script); - const result = spawnSync(process.execPath, [scriptPath], { - cwd: repoRoot, - encoding: "utf-8", - env: { - ...process.env, - HOME: tmpDir, - PATH: `${fakeBin}:${process.env.PATH || ""}`, - }, - }); + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmpDir, + PATH: `${fakeBin}:${process.env.PATH || ""}`, + }, + }); - assert.equal(result.status, 0, result.stderr); - const payload = parseStdoutJson<{ - result: { forceInferenceSetup: boolean }; - saved: Array<{ name: string; value: string }>; - envValue: string; - providerGet: boolean; - }>(result.stdout); - assert.equal(payload.providerGet, true); - assert.deepEqual(payload.result, { forceInferenceSetup: true }); - assert.deepEqual(payload.saved, [ - { name: "COMPATIBLE_API_KEY", value: "fresh-compatible-key" }, - ]); - assert.equal(payload.envValue, "fresh-compatible-key"); + assert.equal(result.status, 0, result.stderr); + const payload = parseStdoutJson<{ + result: { forceInferenceSetup: boolean }; + saved: Array<{ name: string; value: string }>; + envValue: string; + providerGet: boolean; + }>(result.stdout); + assert.equal(payload.providerGet, true); + assert.deepEqual(payload.result, { forceInferenceSetup: true }); + assert.deepEqual(payload.saved, [ + { name: "COMPATIBLE_API_KEY", value: "fresh-compatible-key" }, + ]); + assert.equal(payload.envValue, "fresh-compatible-key"); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } }); it("uses explicit messaging selections for policy suggestions when provided", () => {