Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/security/credential-storage.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ This means you can:

When the host environment is empty, day-two operations such as `$$nemoclaw <name> 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 <name> 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

Expand Down
59 changes: 59 additions & 0 deletions src/lib/actions/sandbox/rebuild-provider-preflight.ts
Original file line number Diff line number Diff line change
@@ -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;
}
18 changes: 13 additions & 5 deletions src/lib/actions/sandbox/rebuild.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)",
);
Expand All @@ -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.`);
Expand Down
42 changes: 38 additions & 4 deletions test/rebuild-credential-preflight.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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=<your-key>");
// Should say sandbox is untouched
expect(output).toContain("untouched");
// Sandbox should still be in the registry (not destroyed)
Expand Down Expand Up @@ -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,
}, () => {
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -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");
Expand Down Expand Up @@ -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);
});
Expand Down
Loading