Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
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
13 changes: 5 additions & 8 deletions bin/lib/credentials.js
Original file line number Diff line number Diff line change
Expand Up @@ -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("");
Expand All @@ -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) {
Expand All @@ -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 {}

Expand All @@ -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 = {
Expand Down
40 changes: 26 additions & 14 deletions bin/lib/onboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
});

Expand Down Expand Up @@ -533,25 +533,34 @@ 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 nvKey = getCredential("NVIDIA_API_KEY");
if (nvKey) {
envVars.NVIDIA_API_KEY = nvKey;
envArgs.push("NVIDIA_API_KEY=$NVIDIA_API_KEY");
}

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
// from openshell because bash returns the status of the last pipeline
// 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
Expand Down Expand Up @@ -806,12 +815,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 }
Expand Down
23 changes: 13 additions & 10 deletions bin/nemoclaw.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -110,9 +110,12 @@ async function deploy(instanceName) {
console.error(" nemoclaw deploy nemoclaw-test");
process.exit(1);
}
await ensureApiKey();

const env = { ...process.env };
env.NVIDIA_API_KEY = await ensureApiKey();

if (isRepoPrivate("NVIDIA/OpenShell")) {
await ensureGithubToken();
env.GITHUB_TOKEN = await ensureGithubToken();
}
validateName(instanceName, "instance name");
const name = instanceName;
Expand Down Expand Up @@ -141,12 +144,12 @@ async function deploy(instanceName) {

if (!exists) {
console.log(` Creating Brev instance '${name}' (${gpu})...`);
run(`brev create ${qname} --gpu ${shellQuote(gpu)}`);
run(`brev create ${qname} --gpu ${shellQuote(gpu)}`, { env });
} else {
console.log(` Brev instance '${name}' already exists.`);
}

run(`brev refresh`, { ignoreError: true });
run(`brev refresh`, { ignoreError: true, env });

process.stdout.write(` Waiting for SSH `);
for (let i = 0; i < 60; i++) {
Expand All @@ -169,8 +172,8 @@ async function deploy(instanceName) {
run(`ssh -o StrictHostKeyChecking=no -o LogLevel=ERROR ${qname} 'mkdir -p /home/ubuntu/nemoclaw'`);
run(`rsync -az --delete --exclude node_modules --exclude .git --exclude src -e "ssh -o StrictHostKeyChecking=no -o LogLevel=ERROR" "${ROOT}/scripts" "${ROOT}/Dockerfile" "${ROOT}/nemoclaw" "${ROOT}/nemoclaw-blueprint" "${ROOT}/bin" "${ROOT}/package.json" ${qname}:/home/ubuntu/nemoclaw/`);

const envLines = [`NVIDIA_API_KEY=${shellQuote(process.env.NVIDIA_API_KEY || "")}`];
const ghToken = process.env.GITHUB_TOKEN;
const envLines = [`NVIDIA_API_KEY=${shellQuote(env.NVIDIA_API_KEY || "")}`];
const ghToken = env.GITHUB_TOKEN;
if (ghToken) envLines.push(`GITHUB_TOKEN=${shellQuote(ghToken)}`);
const tgToken = getCredential("TELEGRAM_BOT_TOKEN");
if (tgToken) envLines.push(`TELEGRAM_BOT_TOKEN=${shellQuote(tgToken)}`);
Expand Down
32 changes: 26 additions & 6 deletions nemoclaw/src/commands/migration-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

// ---------------------------------------------------------------------------
Expand All @@ -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);
Expand All @@ -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 });
Expand All @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion test/cli.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
37 changes: 37 additions & 0 deletions test/credential-isolation.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// 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" ${shellQuote(safeName)}`, { env: { ...process.env, NVIDIA_API_KEY: key } });
const match = content.match(/async function setup\(\) \{[\s\S]*?run\(`bash "\$\{SCRIPTS\}\/setup\.sh" \$\{shellQuote\(safeName\)\}`, \{ env: \{ \.\.\.process\.env, NVIDIA_API_KEY: key \} \}\);/);
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"`, \{ env: \{ \.\.\.process\.env, NVIDIA_API_KEY: key \} \}\);/);
expect(match, "setupSpark() should spread process.env and pass NVIDIA_API_KEY").not.toBeNull();
});

it("ensures deploy() reads NVIDIA_API_KEY from local env object", () => {
// Look for: const envLines = [`NVIDIA_API_KEY=${shellQuote(env.NVIDIA_API_KEY || "")}`];
const match = content.match(/const envLines = \[`NVIDIA_API_KEY=\$\{shellQuote\(env\.NVIDIA_API_KEY \|\| ""\)\}`\];/);
expect(match, "deploy() should read NVIDIA_API_KEY from env.NVIDIA_API_KEY").not.toBeNull();
});

it("ensures deploy() reads GITHUB_TOKEN from local env object", () => {
// Look for: const ghToken = env.GITHUB_TOKEN;
const match = content.match(/const ghToken = env\.GITHUB_TOKEN;/);
expect(match, "deploy() should read GITHUB_TOKEN from env.GITHUB_TOKEN").not.toBeNull();
});
});
Loading