Skip to content
70 changes: 56 additions & 14 deletions src/lib/actions/sandbox/rebuild.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,21 @@ const hermesProviderAuth = require("../../hermes-provider-auth") as {
baseUrl?: string,
) => void;
};
const { LOCAL_INFERENCE_PROVIDERS, REMOTE_PROVIDER_CONFIG } = require("../../onboard/providers") as {
type ProviderLookup =
| { kind: "exists" }
| { kind: "missing" }
| { kind: "lookup_failed"; message: string };

const {
LOCAL_INFERENCE_PROVIDERS,
REMOTE_PROVIDER_CONFIG,
lookupProviderInGateway,
isMutableEndpointProvider,
} = require("../../onboard/providers") as {
LOCAL_INFERENCE_PROVIDERS: string[];
REMOTE_PROVIDER_CONFIG: Record<string, { providerName: string; credentialEnv: string | null }>;
lookupProviderInGateway: (name: string, runOpenshellFn: typeof runOpenshell) => ProviderLookup;
isMutableEndpointProvider: (name: string) => boolean;
};

import {
Expand Down Expand Up @@ -363,20 +375,50 @@ export async function rebuildSandbox(
`Preflight credential check: ${rebuildCredentialEnv} → ${credentialValue ? "present" : "MISSING"}`,
);
if (!credentialValue) {
console.error("");
console.error(` ${_RD}Rebuild preflight failed:${R} provider credential not found.`);
console.error(` The non-interactive recreate step requires ${rebuildCredentialEnv},`);
console.error(" but it is not set in the environment.");
console.error("");
console.error(" To fix, do one of:");
console.error(` export ${rebuildCredentialEnv}=<your-key>`);
console.error(` ${CLI_NAME} onboard # re-enter the key interactively`);
console.error("");
console.error(" Sandbox is untouched — no data was lost.");
bail(`Missing credential: ${rebuildCredentialEnv}`);
return;
const gatewayProviderName = rebuildProvider || null;
const skipGatewayFallback =
Boolean(gatewayProviderName) && isMutableEndpointProvider(gatewayProviderName as string);
const lookup: ProviderLookup =
gatewayProviderName && !skipGatewayFallback
? lookupProviderInGateway(gatewayProviderName, runOpenshell)
: { kind: "missing" };
log(
`Preflight credential check: gateway provider '${gatewayProviderName || "(none)"}' → ${lookup.kind}${skipGatewayFallback ? " (skipped, mutable endpoint)" : ""}`,
);
if (lookup.kind === "exists") {
console.log(
` ${D}Note. '${gatewayProviderName}' is already registered in the OpenShell gateway. ` +
`Skipping host env credential check, the gateway will reuse the stored credential. ` +
`To rotate, export ${rebuildCredentialEnv}=<new-key> before rebuild.${R}`,
);
rebuildCredentialEnv = null;
} else if (lookup.kind === "lookup_failed") {
console.error("");
console.error(` ${_RD}Rebuild preflight failed.${R} OpenShell gateway lookup for provider '${gatewayProviderName}' returned an error.`);
console.error(` ${lookup.message}`);
console.error(" This is likely a gateway connectivity or RPC issue, not a missing credential.");
console.error(" Check OpenShell status with \`openshell status\` and retry.");
console.error("");
console.error(" Sandbox is untouched, no data was lost.");
bail(`Gateway lookup failed for ${gatewayProviderName}`);
return;
} else {
console.error("");
console.error(` ${_RD}Rebuild preflight failed.${R} provider credential not found.`);
console.error(` The non-interactive recreate step requires ${rebuildCredentialEnv},`);
console.error(" but it is not set in the environment.");
console.error("");
console.error(" To fix, do one of:");
console.error(` export ${rebuildCredentialEnv}=<your-key>`);
console.error(` ${CLI_NAME} onboard # re-enter the key interactively`);
console.error("");
console.error(" Sandbox is untouched, no data was lost.");
bail(`Missing credential: ${rebuildCredentialEnv}`);
return;
Comment thread
ssam18 marked this conversation as resolved.
}
}
} else {
}
if (!rebuildCredentialEnv) {
// No credentialEnv in session — local inference (Ollama/vLLM) or
// session was lost. Either way, skip the credential preflight;
// onboard will handle it.
Expand Down
50 changes: 25 additions & 25 deletions src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ const {
setOnboardBrandingAgent,
}: typeof import("./onboard/branding") = require("./onboard/branding");
const { cleanupTempDir }: typeof import("./onboard/temp-files") = require("./onboard/temp-files");
const {
reuseGatewayOrUpsertInferenceProvider,
}: typeof import("./onboard/inference-provider-upsert") = require("./onboard/inference-provider-upsert");
const { stopStaleDashboardListenersForSandbox } = require("./onboard/stale-gateway-cleanup");
const { looksLikeForwardPortConflict, runBackgroundForwardStartWithPortReleaseRetries }: typeof import("./onboard/forward-start") = require("./onboard/forward-start");
const {
Expand Down Expand Up @@ -7503,32 +7506,29 @@ async function setupInference(
resolvedCredentialEnv && credentialValue
? { [resolvedCredentialEnv]: credentialValue }
: {};
const providerResult = upsertProvider(
provider,
config.providerType,
resolvedCredentialEnv,
resolvedEndpointUrl,
env,
const upsertOutcome = await reuseGatewayOrUpsertInferenceProvider(
{
provider,
providerType: config.providerType,
credentialEnv: resolvedCredentialEnv,
endpointUrl: resolvedEndpointUrl,
env,
credentialValue,
label: config.label,
helpUrl: config.helpUrl,
},
{
upsertProvider,
providerExistsInGateway,
isMutableEndpointProvider: onboardProviders.isMutableEndpointProvider,
isNonInteractive,
promptValidationRecovery,
classifyApplyFailure,
},
);
if (!providerResult.ok) {
console.error(` ${providerResult.message}`);
if (isNonInteractive()) {
process.exit(providerResult.status || 1);
}
const retry = await promptValidationRecovery(
config.label,
classifyApplyFailure(providerResult.message),
resolvedCredentialEnv,
config.helpUrl,
);
if (retry === "credential" || retry === "retry") {
continue;
}
if (retry === "selection" || retry === "model") {
return { retry: "selection" };
}
process.exit(providerResult.status || 1);
}
if (upsertOutcome.kind === "exit") process.exit(upsertOutcome.code);
if (upsertOutcome.kind === "retry") continue;
if (upsertOutcome.kind === "selection") return { retry: "selection" };
const args = ["inference", "set"];
if (config.skipVerify) {
args.push("--no-verify");
Expand Down
88 changes: 88 additions & 0 deletions src/lib/onboard/inference-provider-upsert.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
//
// Issue 3895. Sandbox rebuild calls back into onboard --resume after the
// host shell has been closed, so NVIDIA_API_KEY and friends are no longer
// in process.env even though the original onboard registered the provider
// in the OpenShell gateway. Re-running upsertProvider with an empty env
// would overwrite the gateway-stored credential, so this helper short
// circuits the upsert when the gateway already holds the credential and
// the host has nothing to contribute.

import type { ProbeRecovery } from "../validation-recovery";
import type { ValidationClassification } from "../validation";

export type UpsertResult = { ok: boolean; status?: number; message?: string };

export type UpsertOutcome =
| { kind: "ok" }
| { kind: "retry" }
| { kind: "selection" }
| { kind: "exit"; code: number };

export type RecoveryChoice = "credential" | "retry" | "selection" | "model" | string;

export interface InferenceProviderUpsertSpec {
provider: string;
providerType: string;
credentialEnv: string;
endpointUrl: string | null;
env: Record<string, string>;
credentialValue: string | null;
label: string;
helpUrl: string | null;
}

export interface InferenceProviderUpsertHandlers {
upsertProvider: (
name: string,
type: string,
credentialEnv: string,
baseUrl: string | null,
env: Record<string, string>,
) => UpsertResult;
providerExistsInGateway: (name: string) => boolean;
isMutableEndpointProvider: (name: string) => boolean;
isNonInteractive: () => boolean;
promptValidationRecovery: (
label: string,
classification: ProbeRecovery,
credentialEnv: string,
helpUrl: string | null,
) => Promise<RecoveryChoice>;
classifyApplyFailure: (message: string) => ValidationClassification;
}

export async function reuseGatewayOrUpsertInferenceProvider(
spec: InferenceProviderUpsertSpec,
handlers: InferenceProviderUpsertHandlers,
): Promise<UpsertOutcome> {
if (
!spec.credentialValue &&
!handlers.isMutableEndpointProvider(spec.provider) &&
handlers.providerExistsInGateway(spec.provider)
) {
return { kind: "ok" };
}
const result = handlers.upsertProvider(
spec.provider,
spec.providerType,
spec.credentialEnv,
spec.endpointUrl,
spec.env,
);
if (result.ok) return { kind: "ok" };
console.error(` ${result.message}`);
if (handlers.isNonInteractive()) {
return { kind: "exit", code: result.status || 1 };
}
const retry = await handlers.promptValidationRecovery(
spec.label,
handlers.classifyApplyFailure(result.message ?? ""),
spec.credentialEnv,
spec.helpUrl,
);
if (retry === "credential" || retry === "retry") return { kind: "retry" };
if (retry === "selection" || retry === "model") return { kind: "selection" };
return { kind: "exit", code: result.status || 1 };
}
35 changes: 35 additions & 0 deletions src/lib/onboard/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,39 @@ function providerExistsInGateway(name, _runOpenshell) {
return result.status === 0;
}

const MUTABLE_ENDPOINT_PROVIDERS = new Set([
"compatible-endpoint",
"compatible-anthropic-endpoint",
]);

function isMutableEndpointProvider(name) {
return MUTABLE_ENDPOINT_PROVIDERS.has(name);
}

function lookupProviderInGateway(name, _runOpenshell) {
const result = _runOpenshell(["provider", "get", name], {
ignoreError: true,
stdio: ["ignore", "pipe", "pipe"],
});
if (result.status === 0) {
return { kind: "exists" };
}
const stderr = String(result.stderr || "").trim();
const stdout = String(result.stdout || "").trim();
const haystack = `${stderr} ${stdout}`.toLowerCase();
const looksLikeNotFound =
/not found|does not exist|no such provider|unknown provider/.test(haystack) ||
(result.status === 1 && !stderr && !stdout);
if (looksLikeNotFound) {
return { kind: "missing" };
}
const message =
compactText(redact(stderr)) ||
compactText(redact(stdout)) ||
`openshell provider get exited with status ${result.status}`;
return { kind: "lookup_failed", message };
}

/**
* Create or update an OpenShell provider in the gateway.
*
Expand Down Expand Up @@ -337,6 +370,8 @@ module.exports = {
buildProviderArgs,
upsertProvider,
providerExistsInGateway,
lookupProviderInGateway,
isMutableEndpointProvider,
upsertMessagingProviders,
getSandboxInferenceConfig,
};
Loading