diff --git a/bin/lib/credentials.js b/bin/lib/credentials.js index b48c73c4a..584960603 100644 --- a/bin/lib/credentials.js +++ b/bin/lib/credentials.js @@ -52,8 +52,7 @@ function prompt(question) { async function ensureApiKey() { let key = getCredential("NVIDIA_API_KEY"); if (key) { - process.env.NVIDIA_API_KEY = key; - return; + return key; } console.log(""); @@ -75,10 +74,10 @@ async function ensureApiKey() { } saveCredential("NVIDIA_API_KEY", key); - process.env.NVIDIA_API_KEY = key; console.log(""); console.log(" Key saved to ~/.nemoclaw/credentials.json (mode 600)"); console.log(""); + return key; } function isRepoPrivate(repo) { @@ -93,15 +92,13 @@ function isRepoPrivate(repo) { async function ensureGithubToken() { let token = getCredential("GITHUB_TOKEN"); if (token) { - process.env.GITHUB_TOKEN = token; - return; + return token; } try { token = execSync("gh auth token 2>/dev/null", { encoding: "utf-8" }).trim(); if (token) { - process.env.GITHUB_TOKEN = token; - return; + return token; } } catch {} @@ -122,10 +119,10 @@ async function ensureGithubToken() { } saveCredential("GITHUB_TOKEN", token); - process.env.GITHUB_TOKEN = token; console.log(""); console.log(" Token saved to ~/.nemoclaw/credentials.json (mode 600)"); console.log(""); + return token; } module.exports = { diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index ace8d6e97..8ee2e7eac 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -86,10 +86,10 @@ function hasStaleGateway(gwInfoOutput) { return typeof gwInfoOutput === "string" && gwInfoOutput.length > 0 && gwInfoOutput.includes("nemoclaw"); } -function streamSandboxCreate(command) { +function streamSandboxCreate(command, env = {}) { const child = spawn("bash", ["-lc", command], { cwd: ROOT, - env: process.env, + env: { ...process.env, ...env }, stdio: ["ignore", "pipe", "pipe"], }); @@ -555,17 +555,19 @@ async function createSandbox(gpu) { console.log(` Creating sandbox '${sandboxName}' (this takes a few minutes on first run)...`); const chatUiUrl = process.env.CHAT_UI_URL || 'http://127.0.0.1:18789'; - const envArgs = [`CHAT_UI_URL=${shellQuote(chatUiUrl)}`]; - if (process.env.NVIDIA_API_KEY) { - envArgs.push(`NVIDIA_API_KEY=${shellQuote(process.env.NVIDIA_API_KEY)}`); - } + const envVars = { CHAT_UI_URL: chatUiUrl }; + const envArgs = ["CHAT_UI_URL=$CHAT_UI_URL"]; + const discordToken = getCredential("DISCORD_BOT_TOKEN") || process.env.DISCORD_BOT_TOKEN; if (discordToken) { - envArgs.push(`DISCORD_BOT_TOKEN=${shellQuote(discordToken)}`); + envVars.DISCORD_BOT_TOKEN = discordToken; + envArgs.push("DISCORD_BOT_TOKEN=$DISCORD_BOT_TOKEN"); } + const slackToken = getCredential("SLACK_BOT_TOKEN") || process.env.SLACK_BOT_TOKEN; if (slackToken) { - envArgs.push(`SLACK_BOT_TOKEN=${shellQuote(slackToken)}`); + envVars.SLACK_BOT_TOKEN = slackToken; + envArgs.push("SLACK_BOT_TOKEN=$SLACK_BOT_TOKEN"); } // Run without piping through awk — the pipe masked non-zero exit codes @@ -573,7 +575,8 @@ async function createSandbox(gpu) { // command (awk, always 0) unless pipefail is set. Removing the pipe // lets the real exit code flow through to run(). const createResult = await streamSandboxCreate( - `openshell sandbox create ${createArgs.join(" ")} -- env ${envArgs.join(" ")} nemoclaw-start 2>&1` + `openshell sandbox create ${createArgs.join(" ")} -- env ${envArgs.join(" ")} nemoclaw-start 2>&1`, + envVars ); // Clean up build context regardless of outcome @@ -846,12 +849,15 @@ async function setupInference(sandboxName, model, provider) { if (provider === "nvidia-nim") { // Create nvidia-nim provider - run( - `openshell provider create --name nvidia-nim --type openai ` + - `--credential ${shellQuote("NVIDIA_API_KEY")} ` + - `--config "OPENAI_BASE_URL=https://integrate.api.nvidia.com/v1" 2>&1 || true`, - { ignoreError: true } - ); + const nvKey = getCredential("NVIDIA_API_KEY"); + if (nvKey) { + run( + `openshell provider create --name nvidia-nim --type openai ` + + `--credential ${shellQuote("NVIDIA_API_KEY")} ` + + `--config "OPENAI_BASE_URL=https://integrate.api.nvidia.com/v1" 2>&1 || true`, + { ignoreError: true, env: { ...process.env, NVIDIA_API_KEY: nvKey } } + ); + } run( `openshell inference set --no-verify --provider nvidia-nim --model ${shellQuote(model)} 2>/dev/null || true`, { ignoreError: true } diff --git a/bin/nemoclaw.js b/bin/nemoclaw.js index 4345d951a..f540951b7 100755 --- a/bin/nemoclaw.js +++ b/bin/nemoclaw.js @@ -89,15 +89,15 @@ async function setup() { console.log(" ⚠ `nemoclaw setup` is deprecated. Use `nemoclaw onboard` instead."); console.log(" Running legacy setup.sh for backwards compatibility..."); console.log(""); - await ensureApiKey(); + const key = await ensureApiKey(); const { defaultSandbox } = registry.listSandboxes(); const safeName = defaultSandbox && /^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(defaultSandbox) ? defaultSandbox : ""; - run(`bash "${SCRIPTS}/setup.sh" ${shellQuote(safeName)}`); + run(`bash "${SCRIPTS}/setup.sh" ${shellQuote(safeName)}`, { env: { ...process.env, NVIDIA_API_KEY: key } }); } async function setupSpark() { - await ensureApiKey(); - run(`sudo -E NVIDIA_API_KEY=${shellQuote(process.env.NVIDIA_API_KEY)} bash "${SCRIPTS}/setup-spark.sh"`); + const key = await ensureApiKey(); + run(`sudo -E bash "${SCRIPTS}/setup-spark.sh"`, { env: { ...process.env, NVIDIA_API_KEY: key } }); } async function deploy(instanceName) { diff --git a/nemoclaw/src/commands/migration-state.test.ts b/nemoclaw/src/commands/migration-state.test.ts index 8ba4e7e5d..d0167a7f0 100644 --- a/nemoclaw/src/commands/migration-state.test.ts +++ b/nemoclaw/src/commands/migration-state.test.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, it, expect, beforeEach, vi } from "vitest"; +import path from "node:path"; import type { PluginLogger } from "../index.js"; // --------------------------------------------------------------------------- @@ -28,12 +29,28 @@ function addSymlink(p: string): void { } vi.mock("node:fs", async (importOriginal) => { - const original = await importOriginal(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const original = (await importOriginal()) as any; return { ...original, existsSync: (p: string) => store.has(p), - mkdirSync: vi.fn((p: string) => { - addDir(p); + mkdirSync: vi.fn((p: string, options?: { recursive?: boolean }) => { + if (options?.recursive) { + let current = ""; + const parts = p.split("/"); + for (const part of parts) { + if (!part && !current) { + current = "/"; + continue; + } + current = path.posix.join(current, part); + if (!store.has(current)) { + addDir(current); + } + } + } else { + addDir(p); + } }), readFileSync: (p: string) => { const entry = store.get(p); @@ -43,14 +60,16 @@ vi.mock("node:fs", async (importOriginal) => { writeFileSync: vi.fn((p: string, data: string) => { store.set(p, { type: "file", content: data }); }), + chmodSync: vi.fn(), copyFileSync: vi.fn((src: string, dest: string) => { const entry = store.get(src); if (!entry) throw new Error(`ENOENT: ${src}`); store.set(dest, { ...entry }); }), cpSync: vi.fn((src: string, dest: string) => { - // Shallow copy: copy all entries whose path starts with src - for (const [k, v] of store) { + // Recursive copy: find all entries starting with src + const entries = [...store.entries()]; + for (const [k, v] of entries) { if (k === src || k.startsWith(src + "/")) { const relative = k.slice(src.length); store.set(dest + relative, { ...v }); @@ -59,7 +78,8 @@ vi.mock("node:fs", async (importOriginal) => { }), rmSync: vi.fn(), renameSync: vi.fn((oldPath: string, newPath: string) => { - for (const [k, v] of store) { + const entries = [...store.entries()]; + for (const [k, v] of entries) { if (k === oldPath || k.startsWith(oldPath + "/")) { const relative = k.slice(oldPath.length); store.set(newPath + relative, v); diff --git a/test/cli.test.js b/test/cli.test.js index 82dd5ee64..bcb8a21b8 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -76,7 +76,7 @@ describe("CLI dispatch", () => { expect(r.out.includes("Collecting diagnostics")).toBeTruthy(); expect(r.out.includes("System")).toBeTruthy(); expect(r.out.includes("Done")).toBeTruthy(); - }); + }, 15000); it("debug exits 1 on unknown option", () => { const r = run("debug --quik"); diff --git a/test/credential-isolation.test.js b/test/credential-isolation.test.js new file mode 100644 index 000000000..d0b90772a --- /dev/null +++ b/test/credential-isolation.test.js @@ -0,0 +1,32 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, it, expect } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; + +const ROOT = path.resolve(import.meta.dirname, ".."); +const CLI_PATH = path.join(ROOT, "bin/nemoclaw.js"); + +describe("credential-isolation (static analysis)", () => { + const content = fs.readFileSync(CLI_PATH, "utf-8"); + + it("ensures setup() spreads process.env when passing NVIDIA_API_KEY", () => { + // Look for: run(`bash "${SCRIPTS}/setup.sh" ...`, { env: { ...process.env, NVIDIA_API_KEY: key } }); + const match = content.match(/async function setup\(\) \{[\s\S]*?run\(`bash "\$\{SCRIPTS\}\/setup\.sh" \$\{shellQuote\(safeName\)\}`, \{\s*env:\s*\{\s*\.\.\.process\.env,\s*NVIDIA_API_KEY:\s*key\s*\}\s*\}\);/); + expect(match, "setup() should spread process.env and pass NVIDIA_API_KEY").not.toBeNull(); + }); + + it("ensures setupSpark() spreads process.env when passing NVIDIA_API_KEY", () => { + // Look for: run(`sudo -E bash "${SCRIPTS}/setup-spark.sh"`, { env: { ...process.env, NVIDIA_API_KEY: key } }); + const match = content.match(/async function setupSpark\(\) \{[\s\S]*?run\(`sudo -E bash "\$\{SCRIPTS\}\/setup-spark\.sh"`, \{\s*env:\s*\{\s*\.\.\.process\.env,\s*NVIDIA_API_KEY:\s*key\s*\}\s*\}\);/); + expect(match, "setupSpark() should spread process.env and pass NVIDIA_API_KEY").not.toBeNull(); + }); + + it("ensures deploy() is clean", () => { + // We reverted deploy() to main state, so it should not have the env.NVIDIA_API_KEY logic from #172. + // Instead, we verify it's back to using process.env (legacy) or just verify it doesn't have the #172 pattern. + const match = content.match(/const envLines = \[`NVIDIA_API_KEY=\$\{shellQuote\(env\.NVIDIA_API_KEY \|\| ""\)\}`\];/); + expect(match, "deploy() should NOT have the #172 pattern after revert").toBeNull(); + }); +});