diff --git a/docs/security/credential-storage.mdx b/docs/security/credential-storage.mdx index 036a1507ba8..9970c998f60 100644 --- a/docs/security/credential-storage.mdx +++ b/docs/security/credential-storage.mdx @@ -59,6 +59,8 @@ This means you can: When the host environment is empty, day-two operations such as `$$nemoclaw rebuild` and remote-provider updates can reuse the credential already registered with the OpenShell gateway. Export the credential only when you want to create, replace, or rotate the stored provider value. +For rebuilds that use a non-local upstream provider, the matching OpenShell provider entry must still exist. +If the sandbox registry points at a provider that is missing from OpenShell, `$$nemoclaw rebuild` stops before backup or delete even when the matching credential environment variable is exported; rerun `$$nemoclaw onboard` or re-register the provider first. ## Deploy Reads from Environment Only diff --git a/src/lib/actions/sandbox/rebuild-provider-preflight.ts b/src/lib/actions/sandbox/rebuild-provider-preflight.ts new file mode 100644 index 00000000000..81408aefbe0 --- /dev/null +++ b/src/lib/actions/sandbox/rebuild-provider-preflight.ts @@ -0,0 +1,59 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { runOpenshell } from "../../adapters/openshell/runtime"; +import { RD as _RD, R } from "../../cli/terminal-style"; +import { isLocalInferenceProvider } from "./rebuild-resume-config"; + +const hermesProviderAuth = require("../../hermes-provider-auth") as { + HERMES_PROVIDER_NAME: string; +}; +const { providerExistsInGateway } = require("../../onboard/providers") as { + providerExistsInGateway: (name: string, runOpenshellFn: typeof runOpenshell) => boolean; +}; + +function printMissingRebuildGatewayProvider(provider: string, credentialEnv: string | null): void { + console.error(""); + console.error( + ` ${_RD}Rebuild preflight failed:${R} provider '${provider}' is not registered in OpenShell.`, + ); + console.error(" The sandbox registry still points at this upstream provider,"); + console.error(" so rebuild will not recreate it before destroying the sandbox."); + if (credentialEnv) { + console.error(` Rebuild cannot rely on ${credentialEnv} while that provider is missing.`); + } + console.error(""); + console.error(" Re-register the provider in OpenShell or rerun onboard, then retry rebuild."); + console.error(" Sandbox is untouched — no data was lost."); +} + +export function shouldVerifyRebuildGatewayProvider( + provider: string | null | undefined, +): provider is string { + return Boolean( + provider && + !isLocalInferenceProvider(provider) && + provider !== hermesProviderAuth.HERMES_PROVIDER_NAME, + ); +} + +export function checkRebuildGatewayProviderOrBail( + provider: string | null | undefined, + credentialEnv: string | null, + log: (msg: string) => void, + bail: (msg: string, code?: number) => never, +): boolean { + if (!shouldVerifyRebuildGatewayProvider(provider)) return true; + + const providerRegisteredInGateway = providerExistsInGateway(provider, runOpenshell); + log( + `Preflight gateway provider check: provider '${provider}' is ${ + providerRegisteredInGateway ? "registered" : "missing" + } in OpenShell`, + ); + if (providerRegisteredInGateway) return true; + + printMissingRebuildGatewayProvider(provider, credentialEnv); + bail(`Missing gateway provider: ${provider}`); + return false; +} diff --git a/src/lib/actions/sandbox/rebuild.ts b/src/lib/actions/sandbox/rebuild.ts index 403717b6c95..b4f1f23c4f6 100644 --- a/src/lib/actions/sandbox/rebuild.ts +++ b/src/lib/actions/sandbox/rebuild.ts @@ -22,9 +22,6 @@ const hermesProviderAuth = require("../../hermes-provider-auth") as { baseUrl?: string, ) => void; }; -const { providerExistsInGateway } = require("../../onboard/providers") as { - providerExistsInGateway: (name: string, runOpenshellFn: typeof runOpenshell) => boolean; -}; import { detectOpenShellStateRpcPreflightIssue, @@ -80,6 +77,10 @@ import { resolveRebuildLiveState, } from "./rebuild-flow-helpers"; import { buildRebuildRecreateOnboardOpts } from "./rebuild-gpu-opt-out"; +import { + checkRebuildGatewayProviderOrBail, + shouldVerifyRebuildGatewayProvider, +} from "./rebuild-provider-preflight"; import { getRebuildCredentialEnvFromRegistry, isLocalInferenceProvider, @@ -434,6 +435,7 @@ function preflightRebuildCredentials( } const rebuildProvider = sb.provider; + // Compatibility boundary for GH #2519: pre-fix local-provider sessions could // persist credentialEnv="OPENAI_API_KEY" even though current local-provider // write paths persist null. Only a session for this sandbox plus a local @@ -469,6 +471,9 @@ function preflightRebuildCredentials( } if (!rebuildCredentialEnv) { + if (!checkRebuildGatewayProviderOrBail(rebuildProvider, rebuildCredentialEnv, log, bail)) { + return false; + } log( "Preflight credential check: no credentialEnv in session (local inference or missing session)", ); @@ -479,13 +484,16 @@ function preflightRebuildCredentials( log( `Preflight credential check: ${rebuildCredentialEnv} → ${credentialValue ? "present" : "MISSING"}`, ); - if (credentialValue) return true; - if (rebuildProvider && providerExistsInGateway(rebuildProvider, runOpenshell)) { + if (!checkRebuildGatewayProviderOrBail(rebuildProvider, rebuildCredentialEnv, log, bail)) { + return false; + } + if (!credentialValue && shouldVerifyRebuildGatewayProvider(rebuildProvider)) { log( `Preflight credential check: provider '${rebuildProvider}' registered in gateway — skipping env check for ${rebuildCredentialEnv}`, ); return true; } + if (credentialValue) return true; console.error(""); console.error(` ${_RD}Rebuild preflight failed:${R} provider credential not found.`); diff --git a/test/rebuild-credential-preflight.test.ts b/test/rebuild-credential-preflight.test.ts index 66664dfd2dc..18a0c768413 100644 --- a/test/rebuild-credential-preflight.test.ts +++ b/test/rebuild-credential-preflight.test.ts @@ -478,9 +478,12 @@ describe("Issue #2273: atomic rebuild", () => { const result = runRebuild(f); const output = (result.stderr || "") + (result.stdout || ""); - // Should mention preflight failure + // Should prefer the missing-provider abort over the generic missing-env fallback. expect(output).toContain("preflight failed"); + expect(output).toContain("provider 'nvidia-prod' is not registered in OpenShell"); expect(output).toContain("NVIDIA_INFERENCE_API_KEY"); + expect(output).not.toContain("provider credential not found"); + expect(output).not.toContain("export NVIDIA_INFERENCE_API_KEY="); // Should say sandbox is untouched expect(output).toContain("untouched"); // Sandbox should still be in the registry (not destroyed) @@ -508,6 +511,32 @@ describe("Issue #2273: atomic rebuild", () => { expect(output).toContain("Backing up sandbox state"); }); + it("aborts before backup when the gateway provider is missing even with host credential", { + timeout: 60_000, + }, () => { + const f = createFixture({ + credentialEnv: "NVIDIA_INFERENCE_API_KEY", + provider: "nvidia-prod", + providerRegistered: false, + }); + + const result = runRebuild(f, { + NVIDIA_INFERENCE_API_KEY: "nvapi-test-key-for-rebuild", + }); + const output = (result.stderr || "") + (result.stdout || ""); + + expect(result.status).not.toBe(0); + expect(output).toContain("preflight failed"); + expect(output).toContain("provider 'nvidia-prod' is not registered in OpenShell"); + expect(output).toContain("NVIDIA_INFERENCE_API_KEY"); + expect(output).toContain("Sandbox is untouched"); + expect(output).not.toContain("Backing up sandbox state"); + expect(output).not.toContain("Old sandbox deleted"); + expect(output).not.toContain("Creating new sandbox with current image"); + expect(output).not.toContain("missing from gateway; recreating it"); + expect(registryHasSandbox(f)).toBe(true); + }); + it("copies Hermes messaging channels from the registry into the rebuild resume session", { timeout: 60_000, }, () => { @@ -631,7 +660,8 @@ describe("Issue #2273: atomic rebuild", () => { expect(result.status).not.toBe(0); expect(output).toContain("preflight failed"); - expect(output).toContain("requires OPENAI_API_KEY"); + expect(output).toContain("provider 'openai-api' is not registered in OpenShell"); + expect(output).toContain("OPENAI_API_KEY"); expect(output).not.toContain("Backing up sandbox state"); expect(output).not.toContain("Old sandbox deleted"); expect(registryHasSandbox(f)).toBe(true); @@ -656,7 +686,8 @@ describe("Issue #2273: atomic rebuild", () => { expect(result.status).not.toBe(0); expect(output).toContain("preflight failed"); - expect(output).toContain("requires OPENAI_API_KEY"); + expect(output).toContain("provider 'openai-api' is not registered in OpenShell"); + expect(output).toContain("OPENAI_API_KEY"); expect(output).not.toContain("Backing up sandbox state"); expect(output).not.toContain("Old sandbox deleted"); expect(registryHasSandbox(f)).toBe(true); @@ -682,7 +713,8 @@ describe("Issue #2273: atomic rebuild", () => { expect(result.status).not.toBe(0); expect(output).toContain("preflight failed"); - expect(output).toContain("requires OPENAI_API_KEY"); + expect(output).toContain("provider 'openai-api' is not registered in OpenShell"); + expect(output).toContain("OPENAI_API_KEY"); expect(output).not.toContain("GH #2519"); expect(output).not.toContain("Backing up sandbox state"); expect(output).not.toContain("Old sandbox deleted"); @@ -786,7 +818,9 @@ describe("Issue #2273: atomic rebuild", () => { expect(result.status).not.toBe(0); expect(output).toContain("preflight failed"); + expect(output).toContain("provider 'nvidia-prod' is not registered in OpenShell"); expect(output).toContain("NVIDIA_INFERENCE_API_KEY"); + expect(output).not.toContain("provider credential not found"); expect(output).toContain("untouched"); expect(registryHasSandbox(f)).toBe(true); });