From 46c3f92c17bfe8c854413c5cd3f95e0f354f97b2 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Mon, 27 Apr 2026 21:45:59 -0700 Subject: [PATCH 1/4] fix(onboard): decouple local inference providers from OPENAI_API_KEY Local Ollama and local vLLM (incl. NIM) providers were capturing the host's OPENAI_API_KEY into onboard-session.json and credentials.json, then registering the gateway provider with credentialEnv=OPENAI_API_KEY. A stale or invalid host OPENAI_API_KEY would override the local proxy bearer at request time and surface as HTTP 401 on every prompt; an unset OPENAI_API_KEY would also block `nemoclaw rebuild --auto` preflight even though the sandbox never needed an OpenAI key. The wizard now records credentialEnv=null for ollama-local and vllm-local. setupInference registers the gateway under dedicated internal env names (NEMOCLAW_OLLAMA_PROXY_TOKEN / NEMOCLAW_VLLM_LOCAL_TOKEN) and prunes any pre-fix OPENAI_API_KEY entry from credentials.json so \`unset OPENAI_API_KEY; nemoclaw onboard\` behaves as expected. The rebuild preflight migrates legacy sandboxes that already recorded credentialEnv=OPENAI_API_KEY by printing a one-time notice and proceeding without demanding a host API key. Refs GH #2519 Signed-off-by: Aaron Erickson --- src/lib/onboard-providers.ts | 12 ++++ src/lib/onboard.ts | 72 ++++++++++++++++++----- src/nemoclaw.ts | 19 ++++++ test/onboard-selection.test.ts | 15 +++++ test/rebuild-credential-preflight.test.ts | 30 ++++++++++ 5 files changed, 134 insertions(+), 14 deletions(-) diff --git a/src/lib/onboard-providers.ts b/src/lib/onboard-providers.ts index 0dcf951e58c..a0bf8f422fa 100644 --- a/src/lib/onboard-providers.ts +++ b/src/lib/onboard-providers.ts @@ -86,6 +86,16 @@ const REMOTE_PROVIDER_CONFIG = { // Providers that run on the host and need the local-inference policy preset. const LOCAL_INFERENCE_PROVIDERS = ["ollama-local", "vllm-local"]; +// Internal credential env names for local inference providers. Decoupled +// from OPENAI_API_KEY so the gateway plumbing for local Ollama / vLLM (incl. +// NIM, which is vLLM under the hood) never reads or caches the user's host +// OpenAI key. See GH #2519: a stale host OPENAI_API_KEY was leaking into +// inference.local and producing HTTP 401s on every prompt. vLLM-local does +// not enforce the bearer at the runtime, but using a dedicated env name +// prevents the same hijacking. +const OLLAMA_PROXY_CREDENTIAL_ENV = "NEMOCLAW_OLLAMA_PROXY_TOKEN"; +const VLLM_LOCAL_CREDENTIAL_ENV = "NEMOCLAW_VLLM_LOCAL_TOKEN"; + const DISCORD_SNOWFLAKE_RE = /^[0-9]{17,19}$/; // ── Provider label ─────────────────────────────────────────────── @@ -332,6 +342,8 @@ module.exports = { GEMINI_ENDPOINT_URL, REMOTE_PROVIDER_CONFIG, LOCAL_INFERENCE_PROVIDERS, + OLLAMA_PROXY_CREDENTIAL_ENV, + VLLM_LOCAL_CREDENTIAL_ENV, DISCORD_SNOWFLAKE_RE, getProviderLabel, getEffectiveProviderName, diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index ad724b274d8..2f1cd3c0714 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -94,6 +94,8 @@ const { GEMINI_ENDPOINT_URL, REMOTE_PROVIDER_CONFIG, LOCAL_INFERENCE_PROVIDERS, + OLLAMA_PROXY_CREDENTIAL_ENV, + VLLM_LOCAL_CREDENTIAL_ENV, DISCORD_SNOWFLAKE_RE, getProviderLabel, getEffectiveProviderName, @@ -107,6 +109,8 @@ const { GEMINI_ENDPOINT_URL: string; REMOTE_PROVIDER_CONFIG: Record; LOCAL_INFERENCE_PROVIDERS: string[]; + OLLAMA_PROXY_CREDENTIAL_ENV: string; + VLLM_LOCAL_CREDENTIAL_ENV: string; DISCORD_SNOWFLAKE_RE: RegExp; getProviderLabel: (key: string) => string; getEffectiveProviderName: (key: string | null | undefined) => string | null; @@ -121,8 +125,14 @@ const platformUtils: typeof import("./platform") = require("./platform"); const { inferContainerRuntime, isWsl, shouldPatchCoredns } = platformUtils; const { resolveOpenshell } = require("./resolve-openshell"); const credentials: typeof import("./credentials") = require("./credentials"); -const { prompt, ensureApiKey, getCredential, normalizeCredentialValue, saveCredential } = - credentials; +const { + prompt, + ensureApiKey, + getCredential, + normalizeCredentialValue, + saveCredential, + deleteCredential, +} = credentials; const registry: typeof import("./registry") = require("./registry"); const nim: typeof import("./nim") = require("./nim"); const onboardSession: typeof import("./onboard-session") = require("./onboard-session"); @@ -4352,7 +4362,10 @@ async function setupNim(gpu: ReturnType): Promise<{ nimContainer = null; } else { provider = "vllm-local"; - credentialEnv = "OPENAI_API_KEY"; + // Local NIM (vLLM under the hood) does not require a host API key — + // setupInference registers the gateway provider with an internal + // credential env (NEMOCLAW_VLLM_LOCAL_TOKEN). See GH #2519. + credentialEnv = null; endpointUrl = getLocalProviderBaseUrl(provider); if (!endpointUrl) { console.error(" Local NVIDIA NIM base URL could not be determined."); @@ -4362,7 +4375,7 @@ async function setupNim(gpu: ReturnType): Promise<{ "Local NVIDIA NIM", endpointUrl, requireValue(model, "Expected a Local NVIDIA NIM model after startup"), - credentialEnv, + null, ); if (validation.retry === "selection" || validation.retry === "model") { continue selectionLoop; @@ -4407,7 +4420,12 @@ async function setupNim(gpu: ReturnType): Promise<{ ); } provider = "ollama-local"; - credentialEnv = "OPENAI_API_KEY"; + // Local Ollama needs no user-supplied API key — the auth proxy uses + // an internal token (NEMOCLAW_OLLAMA_PROXY_TOKEN, set in setupInference). + // Leaving this null prevents the wizard from prompting for / caching + // OPENAI_API_KEY and prevents the rebuild preflight from requiring it. + // See GH #2519. + credentialEnv = null; endpointUrl = getLocalProviderBaseUrl(provider); if (!endpointUrl) { console.error(" Local Ollama base URL could not be determined."); @@ -4487,7 +4505,8 @@ async function setupNim(gpu: ReturnType): Promise<{ ` ✓ Using Ollama on localhost:${OLLAMA_PORT} (proxy on :${OLLAMA_PROXY_PORT})`, ); provider = "ollama-local"; - credentialEnv = "OPENAI_API_KEY"; + // See above ollama branch — internal proxy token, no user API key. + credentialEnv = null; endpointUrl = getLocalProviderBaseUrl(provider); if (!endpointUrl) { console.error(" Local Ollama base URL could not be determined."); @@ -4548,7 +4567,8 @@ async function setupNim(gpu: ReturnType): Promise<{ } else if (selected.key === "vllm") { console.log(` ✓ Using existing vLLM on localhost:${VLLM_PORT}`); provider = "vllm-local"; - credentialEnv = "OPENAI_API_KEY"; + // See NIM branch above — internal credential env, no user API key. + credentialEnv = null; endpointUrl = getLocalProviderBaseUrl(provider); if (!endpointUrl) { console.error(" Local vLLM base URL could not be determined."); @@ -4591,7 +4611,7 @@ async function setupNim(gpu: ReturnType): Promise<{ "Local vLLM", validationBaseUrl, requireValue(model, "Expected a detected vLLM model"), - credentialEnv, + null, ); if (validation.retry === "selection" || validation.retry === "model") { continue selectionLoop; @@ -4718,9 +4738,20 @@ async function setupInference( process.exit(1); } const baseUrl = getLocalProviderBaseUrl(provider); - const providerResult = upsertProvider("vllm-local", "openai", "OPENAI_API_KEY", baseUrl, { - OPENAI_API_KEY: "dummy", - }); + // Use a dedicated internal credential env so the gateway does not pick + // up the user's host OPENAI_API_KEY for local vLLM. vLLM does not enforce + // the bearer at runtime, but a dedicated env name prevents accidental + // hijacking. See GH #2519. + const providerResult = upsertProvider( + "vllm-local", + "openai", + VLLM_LOCAL_CREDENTIAL_ENV, + baseUrl, + { [VLLM_LOCAL_CREDENTIAL_ENV]: "dummy" }, + ); + // Prune any pre-fix OPENAI_API_KEY entry from credentials.json now that + // vllm-local is confirmed. + deleteCredential("OPENAI_API_KEY"); if (!providerResult.ok) { console.error(` ${providerResult.message}`); process.exit(providerResult.status || 1); @@ -4763,9 +4794,22 @@ async function setupInference( // Not persisted earlier in case the user backs out to a different provider. persistProxyToken(proxyToken); } - const providerResult = upsertProvider("ollama-local", "openai", "OPENAI_API_KEY", baseUrl, { - OPENAI_API_KEY: ollamaCredential, - }); + // Use a dedicated internal credential env (NEMOCLAW_OLLAMA_PROXY_TOKEN) + // so the gateway never reads the user's host OPENAI_API_KEY for local + // Ollama. GH #2519: a stale host OPENAI_API_KEY was leaking into the + // inference path and producing 401s. + const providerResult = upsertProvider( + "ollama-local", + "openai", + OLLAMA_PROXY_CREDENTIAL_ENV, + baseUrl, + { [OLLAMA_PROXY_CREDENTIAL_ENV]: ollamaCredential }, + ); + // Prune any pre-fix OPENAI_API_KEY entry from credentials.json now that + // ollama-local is confirmed. Without this, an invalid OpenAI key cached + // by an earlier onboard would still be hit by `getCredential` callers, + // and `unset OPENAI_API_KEY; nemoclaw onboard` would not clear it. + deleteCredential("OPENAI_API_KEY"); if (!providerResult.ok) { console.error(` ${providerResult.message}`); process.exit(providerResult.status || 1); diff --git a/src/nemoclaw.ts b/src/nemoclaw.ts index 3f137404164..e3a685713de 100644 --- a/src/nemoclaw.ts +++ b/src/nemoclaw.ts @@ -2663,6 +2663,25 @@ async function sandboxRebuild( } else { rebuildCredentialEnv = session?.credentialEnv || null; } + // Legacy migration: pre-fix local-inference sandboxes (GH #2519) recorded + // credentialEnv="OPENAI_API_KEY" in onboard-session.json even though the + // sandbox does not actually need a host OpenAI key (ollama-local uses an + // auth proxy with an internal token; vllm-local accepts a static dummy + // bearer). Treat the legacy value as null so rebuild does not demand a + // credential that was never actually used. + if ( + (session?.provider === "ollama-local" || session?.provider === "vllm-local") && + rebuildCredentialEnv === "OPENAI_API_KEY" + ) { + console.log( + ` ${D}Note: migrating ${session.provider} sandbox off OPENAI_API_KEY (GH #2519). ` + + `Local inference does not require a host API key.${R}`, + ); + log( + `Preflight: legacy ${session.provider} sandbox detected (credentialEnv=OPENAI_API_KEY) — clearing for rebuild`, + ); + rebuildCredentialEnv = null; + } if (rebuildCredentialEnv) { const credentialValue = getCredential(rebuildCredentialEnv); log( diff --git a/test/onboard-selection.test.ts b/test/onboard-selection.test.ts index dd7c14f969b..6160ead3526 100644 --- a/test/onboard-selection.test.ts +++ b/test/onboard-selection.test.ts @@ -663,6 +663,21 @@ const { setupNim } = require(${onboardPath}); const payload = JSON.parse(result.stdout.trim()); assert.equal(payload.result.provider, "ollama-local"); assert.equal(payload.result.preferredInferenceApi, "openai-completions"); + // GH #2519: ollama-local must not capture the host's OPENAI_API_KEY. + // credentialEnv should be null so the wizard summary shows + // "(not required for ollama-local)" and onboard-session.json does not + // record OPENAI_API_KEY (which would later trip the rebuild preflight). + assert.equal(payload.result.credentialEnv, null); + // credentials.json must not have been written with an OPENAI_API_KEY + // entry by the ollama-local path. + const credsPath = path.join(tmpDir, ".nemoclaw", "credentials.json"); + if (fs.existsSync(credsPath)) { + const creds = JSON.parse(fs.readFileSync(credsPath, "utf-8")); + assert.ok( + !Object.prototype.hasOwnProperty.call(creds, "OPENAI_API_KEY"), + "ollama-local onboard must not write OPENAI_API_KEY to credentials.json", + ); + } assert.ok( payload.lines.some((line: string) => line.includes("Loading Ollama model: nemotron-3-nano:30b"), diff --git a/test/rebuild-credential-preflight.test.ts b/test/rebuild-credential-preflight.test.ts index d3e3862eb62..ff22b5019f4 100644 --- a/test/rebuild-credential-preflight.test.ts +++ b/test/rebuild-credential-preflight.test.ts @@ -353,6 +353,36 @@ describe("Issue #2273: atomic rebuild", () => { }, ); + it.each([["ollama-local"], ["vllm-local"]])( + "migrates legacy %s sandbox off OPENAI_API_KEY (GH #2519)", + (provider) => { + // Pre-fix sandboxes recorded credentialEnv="OPENAI_API_KEY" even + // though local inference never actually needed it. After the fix, + // the wizard records null. Rebuild must accept the legacy value, + // print a one-time migration notice, and proceed even when no + // OPENAI_API_KEY exists in env or credentials.json. + const f = createFixture({ + provider, + credentialEnv: "OPENAI_API_KEY", + // no savedCredential — host has no OPENAI_API_KEY anywhere + }); + + const result = runRebuild(f); + const output = (result.stderr || "") + (result.stdout || ""); + + // Must NOT bail with the usual missing-credential failure + expect(output).not.toContain("preflight failed"); + expect(output).not.toContain("Missing credential: OPENAI_API_KEY"); + // Must surface the migration notice so testers know the legacy + // behaviour was intentionally bypassed + expect(output).toContain("GH #2519"); + expect(output).toContain(provider); + // Must continue into the backup step + expect(output).toContain("Backing up sandbox state"); + }, + 60_000, + ); + it( "preflight works for non-NVIDIA providers (OpenAI, Anthropic, etc.)", { timeout: 60_000 }, From b3f71639ff73b2c6f0928d1d0b3bf672f0ebc09b Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Mon, 27 Apr 2026 21:58:13 -0700 Subject: [PATCH 2/4] fix(onboard): also decouple sandbox-side config from OPENAI_API_KEY CodeRabbit review on #2580 caught two issues: 1. getProviderSelectionConfig() in inference-config.ts still mapped ollama-local and vllm-local to OPENAI_API_KEY. That value flows into /sandbox/.nemoclaw/config.json via agent-onboard.ts, so the in-sandbox OpenClaw could resurrect the legacy credential requirement even after the gateway-side fix. 2. deleteCredential("OPENAI_API_KEY") ran before upsertProvider was verified and before `inference set` completed. A failed onboard would silently nuke a previously-saved OpenAI key the user may still need for a different provider. Move the credential env definitions to inference-config.ts so the sandbox-side and gateway-side paths share a single source of truth, and move both deleteCredential calls to run last in the success path. Refs GH #2519 Signed-off-by: Aaron Erickson --- src/lib/inference-config.test.ts | 13 ++++++++++--- src/lib/inference-config.ts | 9 +++++++-- src/lib/onboard-providers.ts | 20 ++++++++++---------- src/lib/onboard.ts | 21 +++++++++++++-------- 4 files changed, 40 insertions(+), 23 deletions(-) diff --git a/src/lib/inference-config.test.ts b/src/lib/inference-config.test.ts index 51e340a1221..e7566405ffe 100644 --- a/src/lib/inference-config.test.ts +++ b/src/lib/inference-config.test.ts @@ -11,6 +11,8 @@ import { DEFAULT_ROUTE_PROFILE, INFERENCE_ROUTE_URL, MANAGED_PROVIDER_ID, + OLLAMA_LOCAL_CREDENTIAL_ENV, + VLLM_LOCAL_CREDENTIAL_ENV, getOpenClawPrimaryModel, getProviderSelectionConfig, parseGatewayInference, @@ -28,16 +30,19 @@ describe("inference selection config", () => { }); it("maps ollama-local to the sandbox inference route and default model", () => { + // Local Ollama uses a dedicated credential env so the sandbox-side + // config never points at OPENAI_API_KEY (GH #2519). expect(getProviderSelectionConfig("ollama-local")).toEqual({ endpointType: "custom", endpointUrl: INFERENCE_ROUTE_URL, ncpPartner: null, model: DEFAULT_OLLAMA_MODEL, profile: DEFAULT_ROUTE_PROFILE, - credentialEnv: DEFAULT_ROUTE_CREDENTIAL_ENV, + credentialEnv: OLLAMA_LOCAL_CREDENTIAL_ENV, provider: "ollama-local", providerLabel: "Local Ollama", }); + expect(OLLAMA_LOCAL_CREDENTIAL_ENV).not.toBe(DEFAULT_ROUTE_CREDENTIAL_ENV); }); it("maps nvidia-nim to the sandbox inference route", () => { @@ -92,17 +97,19 @@ describe("inference selection config", () => { providerLabel: "Other OpenAI-compatible endpoint", }), ); - // Full-object assertion for one local provider + // Full-object assertion for one local provider — uses dedicated + // credential env, not OPENAI_API_KEY (GH #2519). expect(getProviderSelectionConfig("vllm-local", "meta-llama")).toEqual({ endpointType: "custom", endpointUrl: INFERENCE_ROUTE_URL, ncpPartner: null, model: "meta-llama", profile: DEFAULT_ROUTE_PROFILE, - credentialEnv: DEFAULT_ROUTE_CREDENTIAL_ENV, + credentialEnv: VLLM_LOCAL_CREDENTIAL_ENV, provider: "vllm-local", providerLabel: "Local vLLM", }); + expect(VLLM_LOCAL_CREDENTIAL_ENV).not.toBe(DEFAULT_ROUTE_CREDENTIAL_ENV); }); it("returns null for unknown providers", () => { diff --git a/src/lib/inference-config.ts b/src/lib/inference-config.ts index 2ec9fc68b9d..7cc5243f4f8 100644 --- a/src/lib/inference-config.ts +++ b/src/lib/inference-config.ts @@ -19,6 +19,11 @@ export const CLOUD_MODEL_OPTIONS = [ ]; export const DEFAULT_ROUTE_PROFILE = "inference-local"; export const DEFAULT_ROUTE_CREDENTIAL_ENV = "OPENAI_API_KEY"; +// Dedicated credential env names for local inference. Decoupled from +// OPENAI_API_KEY so the sandbox-side OpenClaw and the host-side gateway +// never read the user's host OpenAI key for local providers. See GH #2519. +export const OLLAMA_LOCAL_CREDENTIAL_ENV = "NEMOCLAW_OLLAMA_PROXY_TOKEN"; +export const VLLM_LOCAL_CREDENTIAL_ENV = "NEMOCLAW_VLLM_LOCAL_TOKEN"; export const MANAGED_PROVIDER_ID = "inference"; export { DEFAULT_OLLAMA_MODEL }; @@ -98,14 +103,14 @@ export function getProviderSelectionConfig( return { ...base, model: model || "vllm-local", - credentialEnv: DEFAULT_ROUTE_CREDENTIAL_ENV, + credentialEnv: VLLM_LOCAL_CREDENTIAL_ENV, providerLabel: "Local vLLM", }; case "ollama-local": return { ...base, model: model || DEFAULT_OLLAMA_MODEL, - credentialEnv: DEFAULT_ROUTE_CREDENTIAL_ENV, + credentialEnv: OLLAMA_LOCAL_CREDENTIAL_ENV, providerLabel: "Local Ollama", }; default: diff --git a/src/lib/onboard-providers.ts b/src/lib/onboard-providers.ts index a0bf8f422fa..3fb065b9508 100644 --- a/src/lib/onboard-providers.ts +++ b/src/lib/onboard-providers.ts @@ -5,7 +5,11 @@ // Provider metadata, lookup helpers, and gateway provider CRUD. const { redact } = require("./runner"); -const { DEFAULT_CLOUD_MODEL } = require("./inference-config"); +const { + DEFAULT_CLOUD_MODEL, + OLLAMA_LOCAL_CREDENTIAL_ENV, + VLLM_LOCAL_CREDENTIAL_ENV, +} = require("./inference-config"); const { isSafeModelId } = require("./validation"); const { compactText } = require("./url-utils"); @@ -86,15 +90,11 @@ const REMOTE_PROVIDER_CONFIG = { // Providers that run on the host and need the local-inference policy preset. const LOCAL_INFERENCE_PROVIDERS = ["ollama-local", "vllm-local"]; -// Internal credential env names for local inference providers. Decoupled -// from OPENAI_API_KEY so the gateway plumbing for local Ollama / vLLM (incl. -// NIM, which is vLLM under the hood) never reads or caches the user's host -// OpenAI key. See GH #2519: a stale host OPENAI_API_KEY was leaking into -// inference.local and producing HTTP 401s on every prompt. vLLM-local does -// not enforce the bearer at the runtime, but using a dedicated env name -// prevents the same hijacking. -const OLLAMA_PROXY_CREDENTIAL_ENV = "NEMOCLAW_OLLAMA_PROXY_TOKEN"; -const VLLM_LOCAL_CREDENTIAL_ENV = "NEMOCLAW_VLLM_LOCAL_TOKEN"; +// Re-exported alias matching the existing onboard.ts call sites. The canonical +// definitions live in inference-config.ts so that getProviderSelectionConfig +// (which writes the sandbox-side config) and the gateway-registration path +// here stay in sync. See GH #2519. +const OLLAMA_PROXY_CREDENTIAL_ENV = OLLAMA_LOCAL_CREDENTIAL_ENV; const DISCORD_SNOWFLAKE_RE = /^[0-9]{17,19}$/; diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 2f1cd3c0714..814397d9667 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -4749,9 +4749,6 @@ async function setupInference( baseUrl, { [VLLM_LOCAL_CREDENTIAL_ENV]: "dummy" }, ); - // Prune any pre-fix OPENAI_API_KEY entry from credentials.json now that - // vllm-local is confirmed. - deleteCredential("OPENAI_API_KEY"); if (!providerResult.ok) { console.error(` ${providerResult.message}`); process.exit(providerResult.status || 1); @@ -4767,6 +4764,11 @@ async function setupInference( "--timeout", String(LOCAL_INFERENCE_TIMEOUT_SECS), ]); + // Prune any pre-fix OPENAI_API_KEY entry from credentials.json now that + // vllm-local is fully confirmed. Done last so a failed registration + // does not delete a credential the user may still need for a remote + // provider. + deleteCredential("OPENAI_API_KEY"); } else if (provider === "ollama-local") { const validation = validateLocalProvider(provider); if (!validation.ok) { @@ -4805,11 +4807,6 @@ async function setupInference( baseUrl, { [OLLAMA_PROXY_CREDENTIAL_ENV]: ollamaCredential }, ); - // Prune any pre-fix OPENAI_API_KEY entry from credentials.json now that - // ollama-local is confirmed. Without this, an invalid OpenAI key cached - // by an earlier onboard would still be hit by `getCredential` callers, - // and `unset OPENAI_API_KEY; nemoclaw onboard` would not clear it. - deleteCredential("OPENAI_API_KEY"); if (!providerResult.ok) { console.error(` ${providerResult.message}`); process.exit(providerResult.status || 1); @@ -4832,6 +4829,14 @@ async function setupInference( console.error(` ${probe.message}`); process.exit(1); } + // Prune any pre-fix OPENAI_API_KEY entry from credentials.json now that + // ollama-local is fully confirmed (provider registered, inference set, + // model warm). Done last so a failed onboard does not delete a credential + // the user may still need for a remote provider. Without this, an + // invalid OpenAI key cached by an earlier onboard would still be hit by + // `getCredential` callers, and `unset OPENAI_API_KEY; nemoclaw onboard` + // would not clear it. + deleteCredential("OPENAI_API_KEY"); } verifyInferenceRoute(provider, model); From 9d9359cc2eeda8a48534c777b203906a4290dfb1 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 28 Apr 2026 14:21:37 -0700 Subject: [PATCH 3/4] test(wsl): extend credential lint subprocess timeout --- test/no-direct-credential-env.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/no-direct-credential-env.test.ts b/test/no-direct-credential-env.test.ts index 7a5facf11aa..b2d0b600932 100644 --- a/test/no-direct-credential-env.test.ts +++ b/test/no-direct-credential-env.test.ts @@ -125,7 +125,7 @@ describe("ESLint rule: nemoclaw/no-direct-credential-env", () => { { cwd: repoRoot, encoding: "utf-8", - timeout: 30_000, + timeout: 60_000, }, ); const output = JSON.parse(result.stdout); From 87705afbabb98fb0fb2f2d1b1ed1d0985a891886 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Tue, 28 Apr 2026 14:21:28 -0700 Subject: [PATCH 4/4] fix(onboard): preserve saved OpenAI credentials for local providers --- src/lib/onboard.ts | 20 +++------- test/onboard.test.ts | 91 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+), 14 deletions(-) diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index bb74511eaa3..933f77acf8f 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -133,7 +133,6 @@ const { getCredential, normalizeCredentialValue, saveCredential, - deleteCredential, resolveProviderCredential, } = credentials; const registry: typeof import("./registry") = require("./registry"); @@ -4995,11 +4994,9 @@ async function setupInference( "--timeout", String(LOCAL_INFERENCE_TIMEOUT_SECS), ]); - // Prune any pre-fix OPENAI_API_KEY entry from credentials.json now that - // vllm-local is fully confirmed. Done last so a failed registration - // does not delete a credential the user may still need for a remote - // provider. - deleteCredential("OPENAI_API_KEY"); + // Do not mutate ~/.nemoclaw/credentials.json here: local vLLM now uses + // VLLM_LOCAL_CREDENTIAL_ENV, so any saved OPENAI_API_KEY remains available + // to unrelated OpenAI-backed sandboxes. } else if (provider === "ollama-local") { const validation = validateLocalProvider(provider); if (!validation.ok) { @@ -5060,14 +5057,9 @@ async function setupInference( console.error(` ${probe.message}`); process.exit(1); } - // Prune any pre-fix OPENAI_API_KEY entry from credentials.json now that - // ollama-local is fully confirmed (provider registered, inference set, - // model warm). Done last so a failed onboard does not delete a credential - // the user may still need for a remote provider. Without this, an - // invalid OpenAI key cached by an earlier onboard would still be hit by - // `getCredential` callers, and `unset OPENAI_API_KEY; nemoclaw onboard` - // would not clear it. - deleteCredential("OPENAI_API_KEY"); + // Do not mutate ~/.nemoclaw/credentials.json here: local Ollama now uses + // OLLAMA_PROXY_CREDENTIAL_ENV, so any saved OPENAI_API_KEY remains available + // to unrelated OpenAI-backed sandboxes. } verifyInferenceRoute(provider, model); diff --git a/test/onboard.test.ts b/test/onboard.test.ts index 570b5cdd47e..7a57f851c48 100644 --- a/test/onboard.test.ts +++ b/test/onboard.test.ts @@ -1973,6 +1973,97 @@ const { setupInference } = require(${onboardPath}); assert.match(commands[3].command, /inference set/); }); + it("does not delete saved OpenAI credentials when configuring local vLLM", () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-local-vllm-")); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "setup-local-vllm-check.js"); + const onboardPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "onboard.js")); + const runnerPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "runner.js")); + const registryPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "registry.js")); + const credentialsPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "credentials.js")); + const localInferencePath = JSON.stringify( + path.join(repoRoot, "dist", "lib", "local-inference.js"), + ); + + fs.mkdirSync(fakeBin, { recursive: true }); + fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { + mode: 0o755, + }); + + const script = String.raw` +const runner = require(${runnerPath}); +const registry = require(${registryPath}); +const credentials = require(${credentialsPath}); +const localInference = require(${localInferencePath}); +const _n = (c) => (Array.isArray(c) ? c.join(" ") : String(c)).replace(/'/g, ""); + +const commands = []; +runner.run = (command, opts = {}) => { + const cmd = _n(command); + commands.push({ command: cmd, env: opts.env || null }); + if (cmd.includes("provider get")) return { status: 1, stdout: "", stderr: "" }; + return { status: 0, stdout: "", stderr: "" }; +}; +runner.runCapture = (command) => { + const cmd = _n(command); + if (cmd.includes("inference") && cmd.includes("get")) { + return [ + "Gateway inference:", + "", + " Route: inference.local", + " Provider: vllm-local", + " Model: meta-llama", + " Version: 1", + ].join("\\n"); + } + return ""; +}; +registry.updateSandbox = () => true; +localInference.validateLocalProvider = () => ({ ok: true }); +localInference.getLocalProviderBaseUrl = () => "http://host.openshell.internal:8000/v1"; + +credentials.saveCredential("OPENAI_API_KEY", "sk-existing"); + +const { setupInference } = require(${onboardPath}); + +(async () => { + await setupInference("test-box", "meta-llama", "vllm-local"); + console.log(JSON.stringify({ + commands, + savedOpenAiKey: credentials.getCredential("OPENAI_API_KEY"), + })); +})().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 || ""}`, + }, + }); + + expect(result.status).toBe(0); + const payload = parseStdoutJson<{ commands: CommandEntry[]; savedOpenAiKey: string }>( + result.stdout, + ); + const providerCommand = payload.commands.find((entry) => + entry.command.includes("provider create"), + ); + assert.ok(providerCommand, "expected local vLLM provider create command"); + assert.match(providerCommand.command, /--credential NEMOCLAW_VLLM_LOCAL_TOKEN/); + assert.doesNotMatch(providerCommand.command, /--credential OPENAI_API_KEY/); + assert.equal(providerCommand.env?.NEMOCLAW_VLLM_LOCAL_TOKEN, "dummy"); + assert.equal(payload.savedOpenAiKey, "sk-existing"); + }); + it("detects when the live inference route already matches the requested provider and model", () => { const repoRoot = path.join(import.meta.dirname, ".."); const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-inference-ready-"));