Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
18 changes: 17 additions & 1 deletion src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3635,6 +3635,7 @@ async function setupNim(
// See #2273.
hydrateCredentialEnv(credentialEnv);

let reuseGatewayCredentialWithoutLocalKey = false;
if (selected.key === "build") {
// Let NEMOCLAW_PROVIDER_KEY fill the NVIDIA key without overriding explicit env.
const _nvProviderKey = (process.env.NEMOCLAW_PROVIDER_KEY || "").trim();
Expand All @@ -3656,6 +3657,15 @@ async function setupNim(
} else if (!providerExistsInGateway(provider)) {
logMissingNvidiaApiKeyHelp(REMOTE_PROVIDER_CONFIG.build.helpUrl);
process.exit(1);
} else {
// No local API key is staged, but the OpenShell gateway already
// holds a validated credential for this provider (recovered from
// the existing sandbox, e.g. --recreate-sandbox). The gateway is
// the system of record and nothing is written to disk, so we have
// no key to probe the endpoint with. Reuse the gateway credential
// and skip endpoint re-validation rather than probing the endpoint
// unauthenticated, which would fail. See issue #5441.
reuseGatewayCredentialWithoutLocalKey = true;
}
} else {
await ensureApiKey();
Expand Down Expand Up @@ -3897,7 +3907,13 @@ async function setupNim(
}
}

if (selected.key === "build") {
if (selected.key === "build" && reuseGatewayCredentialWithoutLocalKey) {
// The gateway already holds a validated credential for this provider
// and no local key is available to probe with, so skip endpoint
// re-validation and reuse the existing credential. See issue #5441.
note(" Reusing existing gateway credential; skipping endpoint re-validation.");
preferredInferenceApi = "openai-completions";
} else if (selected.key === "build") {
while (true) {
const validation = await validateOpenAiLikeSelection(
remoteConfig.label,
Expand Down
149 changes: 149 additions & 0 deletions test/onboard-build-recreate-credential-reuse.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, it } from "vitest";

import { testTimeoutOptions } from "./helpers/timeouts";

// Regression guard for #5441. In non-interactive `onboard --recreate-sandbox`
// the NVIDIA Endpoints ("build") provider is recovered from the existing
// sandbox's saved gateway state. The OpenShell gateway already holds the
// validated credential and nothing is written to disk, so the host process has
// no local API key. The flow used to probe the endpoint anyway — unauthenticated
// — and abort at [3/8] with "endpoint validation failed". It must instead reuse
// the gateway credential and skip endpoint re-validation.
const REPO_ROOT = path.join(import.meta.dirname, "..");

describe("onboard build recreate credential reuse (#5441)", () => {
it(
"reuses the gateway credential and skips endpoint re-validation when no local key is staged",
testTimeoutOptions(90_000),
() => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-build-recreate-"));
const fakeBin = path.join(tmpDir, "bin");
const home = path.join(tmpDir, "home");
const scriptPath = path.join(tmpDir, "setup-nim-recreate.cjs");
const curlLogPath = path.join(tmpDir, "curl-probes.log");
const onboardPath = JSON.stringify(path.join(REPO_ROOT, "dist", "lib", "onboard.js"));

fs.mkdirSync(fakeBin, { recursive: true });
fs.mkdirSync(home, { recursive: true });

// Fake openshell: the gateway already holds an nvidia-prod inference route
// (recovered via `inference get`) and the provider exists (`provider get`
// returns success). Everything else is a no-op success.
fs.writeFileSync(
path.join(fakeBin, "openshell"),
`#!/usr/bin/env bash
if [ "$1" = "inference" ] && [ "$2" = "get" ]; then
cat <<'EOF'
Gateway inference:

Route: inference.local
Provider: nvidia-prod
Model: nvidia/llama-3.3-nemotron-super-49b-v1
Version: 1
EOF
exit 0
fi
if [ "$1" = "provider" ] && [ "$2" = "get" ]; then
exit 0
fi
exit 0
`,
{ mode: 0o755 },
);

// Fake curl logs every invocation and fails. If the fix regresses and the
// endpoint probe runs, it would call curl and the run would abort — the
// assertions below catch both the abort and the unexpected probe.
fs.writeFileSync(
path.join(fakeBin, "curl"),
`#!/usr/bin/env bash
printf '%s\\n' "$*" >> "$NEMOCLAW_FAKE_CURL_LOG"
exit 1
`,
{ mode: 0o755 },
);

fs.writeFileSync(
scriptPath,
String.raw`
process.env.NEMOCLAW_NON_INTERACTIVE = "1";
process.env.NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE = "1";
process.env.NEMOCLAW_TEST_NO_SLEEP = "1";
// Critically: NVIDIA_INFERENCE_API_KEY is NOT set — the credential lives only
// in the gateway, mirroring an interactive onboard that typed the key.
delete process.env.NVIDIA_INFERENCE_API_KEY;
delete process.env.NVIDIA_API_KEY;
delete process.env.NEMOCLAW_PROVIDER_KEY;
delete process.env.NEMOCLAW_PROVIDER;

const { setupNim } = require(${onboardPath});

(async () => {
const result = await setupNim(null, "rg-test-noninter", null);
console.log(JSON.stringify({ outcome: "resolved", provider: result.provider }));
})().catch((error) => {
console.error(error && error.stack ? error.stack : error);
console.log(JSON.stringify({ outcome: "rejected" }));
process.exitCode = 3;
});
`,
);

try {
const result = spawnSync(process.execPath, [scriptPath], {
cwd: REPO_ROOT,
encoding: "utf8",
env: {
...process.env,
HOME: home,
PATH: `${fakeBin}:${process.env.PATH || ""}`,
VITEST: "false",
NEMOCLAW_TEST_NO_SLEEP: "1",
NEMOCLAW_FAKE_CURL_LOG: curlLogPath,
NVIDIA_INFERENCE_API_KEY: "",
NVIDIA_API_KEY: "",
},
timeout: 80_000,
});

const output = `${result.stdout || ""}\n${result.stderr || ""}`;
assert.equal(
result.status,
0,
`setupNim aborted non-interactive build recreate instead of reusing the gateway credential; output:\n${output}`,
);
assert.match(
output,
/Reusing existing gateway credential; skipping endpoint re-validation\./,
`setupNim did not reuse the gateway credential; output:\n${output}`,
);
assert.match(
output,
/"outcome":"resolved"/,
`setupNim did not resolve the build provider selection; output:\n${output}`,
);
assert.match(
output,
/"provider":"nvidia-prod"/,
`setupNim did not select the recovered nvidia-prod provider; output:\n${output}`,
);

const curlLog = fs.existsSync(curlLogPath) ? fs.readFileSync(curlLogPath, "utf8") : "";
assert.ok(
!curlLog.includes("/chat/completions") && !curlLog.includes("/responses"),
`setupNim probed the endpoint despite having no local credential; curl log:\n${curlLog}`,
);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
},
);
});
Loading