Skip to content
Merged
13 changes: 10 additions & 3 deletions src/lib/inference-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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", () => {
Expand Down Expand Up @@ -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", () => {
Expand Down
9 changes: 7 additions & 2 deletions src/lib/inference-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand Down Expand Up @@ -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:
Expand Down
14 changes: 13 additions & 1 deletion src/lib/onboard-providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down Expand Up @@ -86,6 +90,12 @@ 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"];

// 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}$/;

// ── Provider label ───────────────────────────────────────────────
Expand Down Expand Up @@ -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,
Expand Down
70 changes: 56 additions & 14 deletions src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,8 @@ const {
GEMINI_ENDPOINT_URL,
REMOTE_PROVIDER_CONFIG,
LOCAL_INFERENCE_PROVIDERS,
OLLAMA_PROXY_CREDENTIAL_ENV,
VLLM_LOCAL_CREDENTIAL_ENV,
DISCORD_SNOWFLAKE_RE,
getProviderLabel,
getEffectiveProviderName,
Expand All @@ -109,6 +111,8 @@ const {
GEMINI_ENDPOINT_URL: string;
REMOTE_PROVIDER_CONFIG: Record<string, RemoteProviderConfigEntry>;
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;
Expand All @@ -123,8 +127,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, resolveProviderCredential } =
credentials;
const {
prompt,
ensureApiKey,
getCredential,
normalizeCredentialValue,
saveCredential,
resolveProviderCredential,
} = credentials;
const registry: typeof import("./registry") = require("./registry");
const nim: typeof import("./nim") = require("./nim");
const onboardSession: typeof import("./onboard-session") = require("./onboard-session");
Expand Down Expand Up @@ -4582,7 +4592,10 @@ async function setupNim(gpu: ReturnType<typeof nim.detectGpu>): 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.");
Expand All @@ -4592,7 +4605,7 @@ async function setupNim(gpu: ReturnType<typeof nim.detectGpu>): 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;
Expand Down Expand Up @@ -4637,7 +4650,12 @@ async function setupNim(gpu: ReturnType<typeof nim.detectGpu>): 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.");
Expand Down Expand Up @@ -4717,7 +4735,8 @@ async function setupNim(gpu: ReturnType<typeof nim.detectGpu>): 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.");
Expand Down Expand Up @@ -4778,7 +4797,8 @@ async function setupNim(gpu: ReturnType<typeof nim.detectGpu>): 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.");
Expand Down Expand Up @@ -4821,7 +4841,7 @@ async function setupNim(gpu: ReturnType<typeof nim.detectGpu>): Promise<{
"Local vLLM",
validationBaseUrl,
requireValue(model, "Expected a detected vLLM model"),
credentialEnv,
null,
);
if (validation.retry === "selection" || validation.retry === "model") {
continue selectionLoop;
Expand Down Expand Up @@ -4948,9 +4968,17 @@ 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" },
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (!providerResult.ok) {
console.error(` ${providerResult.message}`);
process.exit(providerResult.status || 1);
Expand All @@ -4966,6 +4994,9 @@ async function setupInference(
"--timeout",
String(LOCAL_INFERENCE_TIMEOUT_SECS),
]);
// 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) {
Expand Down Expand Up @@ -4993,9 +5024,17 @@ 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 },
);
if (!providerResult.ok) {
console.error(` ${providerResult.message}`);
process.exit(providerResult.status || 1);
Expand All @@ -5018,6 +5057,9 @@ async function setupInference(
console.error(` ${probe.message}`);
process.exit(1);
}
// 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);
Expand Down
19 changes: 19 additions & 0 deletions src/nemoclaw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2720,6 +2720,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(
Expand Down
2 changes: 1 addition & 1 deletion test/no-direct-credential-env.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
15 changes: 15 additions & 0 deletions test/onboard-selection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
Loading
Loading