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
10 changes: 9 additions & 1 deletion bin/lib/runner.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,4 +58,12 @@ function runCapture(cmd, opts = {}) {
}
}

module.exports = { ROOT, SCRIPTS, run, runCapture, runInteractive };
/**
* Shell-quote a value for safe interpolation into bash -c strings.
* Wraps in single quotes and escapes embedded single quotes.
*/
function shellQuote(value) {
return `'${String(value).replace(/'/g, `'\\''`)}'`;
}

module.exports = { ROOT, SCRIPTS, run, runCapture, runInteractive, shellQuote };
61 changes: 38 additions & 23 deletions bin/nemoclaw.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

const { execSync, spawnSync } = require("child_process");
const { execFileSync, spawnSync } = require("child_process");
const path = require("path");
const fs = require("fs");
const os = require("os");

const { ROOT, SCRIPTS, run, runCapture, runInteractive } = require("./lib/runner");
const { ROOT, SCRIPTS, run, runCapture, runInteractive, shellQuote } = require("./lib/runner");
const {
ensureApiKey,
ensureGithubToken,
Expand All @@ -28,10 +28,6 @@ const GLOBAL_COMMANDS = new Set([

const REMOTE_UNINSTALL_URL = "https://raw.githubusercontent.com/NVIDIA/NemoClaw/refs/heads/main/uninstall.sh";

function shellQuote(value) {
return `'${String(value).replace(/'/g, `'\\''`)}'`;
}

function resolveUninstallScript() {
const candidates = [
path.join(ROOT, "uninstall.sh"),
Expand Down Expand Up @@ -105,29 +101,43 @@ async function deploy(instanceName) {
if (isRepoPrivate("NVIDIA/OpenShell")) {
await ensureGithubToken();
}
const name = instanceName;
const name = instanceName.trim();

// Validate: RFC 1123 label — lowercase alphanumeric and hyphens,
// must start and end with alphanumeric, max 63 chars.
if (!name || name.length > 63 || !/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(name)) {
console.error(` Invalid instance name: '${name}'`);
console.error(" Names must be lowercase, contain only letters, numbers, and hyphens,");
console.error(" and must start and end with a letter or number (max 63 chars).");
process.exit(1);
}
const qname = shellQuote(name);

const gpu = process.env.NEMOCLAW_GPU || "a2-highgpu-1g:nvidia-tesla-a100:1";

console.log("");
console.log(` Deploying NemoClaw to Brev instance: ${name}`);
console.log("");

try {
execSync("which brev", { stdio: "ignore" });
execFileSync("which", ["brev"], { stdio: "ignore" });
} catch {
console.error("brev CLI not found. Install: https://brev.nvidia.com");
process.exit(1);
}

let exists = false;
try {
const out = execSync("brev ls 2>&1", { encoding: "utf-8" });
const out = execFileSync("brev", ["ls"], { encoding: "utf-8" });
exists = out.includes(name);
} catch {}
} catch (err) {
if (err.stdout && err.stdout.includes(name)) exists = true;
if (err.stderr && err.stderr.includes(name)) exists = true;
}

if (!exists) {
console.log(` Creating Brev instance '${name}' (${gpu})...`);
run(`brev create ${name} --gpu "${gpu}"`);
run(`brev create ${qname} --gpu ${shellQuote(gpu)}`);
} else {
console.log(` Brev instance '${name}' already exists.`);
}
Expand All @@ -137,7 +147,7 @@ async function deploy(instanceName) {
console.log(" Waiting for SSH...");
for (let i = 0; i < 60; i++) {
try {
execSync(`ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no ${name} 'echo ok' 2>/dev/null`, { encoding: "utf-8", stdio: "pipe" });
execFileSync("ssh", ["-o", "ConnectTimeout=5", "-o", "StrictHostKeyChecking=no", name, "echo", "ok"], { encoding: "utf-8", stdio: "ignore" });
break;
} catch {
if (i === 59) {
Expand All @@ -149,31 +159,36 @@ async function deploy(instanceName) {
}

console.log(" Syncing NemoClaw to VM...");
run(`ssh -o StrictHostKeyChecking=no -o LogLevel=ERROR ${name} '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" ${name}:/home/ubuntu/nemoclaw/`);
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=${process.env.NVIDIA_API_KEY}`];
const envLines = [`NVIDIA_API_KEY=${shellQuote(process.env.NVIDIA_API_KEY || "")}`];
const ghToken = process.env.GITHUB_TOKEN;
if (ghToken) envLines.push(`GITHUB_TOKEN=${ghToken}`);
if (ghToken) envLines.push(`GITHUB_TOKEN=${shellQuote(ghToken)}`);
const tgToken = getCredential("TELEGRAM_BOT_TOKEN");
if (tgToken) envLines.push(`TELEGRAM_BOT_TOKEN=${tgToken}`);
const envTmp = path.join(os.tmpdir(), `nemoclaw-env-${Date.now()}`);
if (tgToken) envLines.push(`TELEGRAM_BOT_TOKEN=${shellQuote(tgToken)}`);
const envDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-env-"));
const envTmp = path.join(envDir, "env");
fs.writeFileSync(envTmp, envLines.join("\n") + "\n", { mode: 0o600 });
run(`scp -q -o StrictHostKeyChecking=no -o LogLevel=ERROR "${envTmp}" ${name}:/home/ubuntu/nemoclaw/.env`);
fs.unlinkSync(envTmp);
try {
run(`scp -q -o StrictHostKeyChecking=no -o LogLevel=ERROR ${shellQuote(envTmp)} ${qname}:/home/ubuntu/nemoclaw/.env`);
} finally {
try { fs.unlinkSync(envTmp); } catch {}
try { fs.rmdirSync(envDir); } catch {}
}

console.log(" Running setup...");
runInteractive(`ssh -t -o StrictHostKeyChecking=no -o LogLevel=ERROR ${name} 'cd /home/ubuntu/nemoclaw && set -a && . .env && set +a && bash scripts/brev-setup.sh'`);
runInteractive(`ssh -t -o StrictHostKeyChecking=no -o LogLevel=ERROR ${qname} 'cd /home/ubuntu/nemoclaw && set -a && . .env && set +a && bash scripts/brev-setup.sh'`);

if (tgToken) {
console.log(" Starting services...");
run(`ssh -o StrictHostKeyChecking=no -o LogLevel=ERROR ${name} 'cd /home/ubuntu/nemoclaw && set -a && . .env && set +a && bash scripts/start-services.sh'`);
run(`ssh -o StrictHostKeyChecking=no -o LogLevel=ERROR ${qname} 'cd /home/ubuntu/nemoclaw && set -a && . .env && set +a && bash scripts/start-services.sh'`);
}

console.log("");
console.log(" Connecting to sandbox...");
console.log("");
runInteractive(`ssh -t -o StrictHostKeyChecking=no -o LogLevel=ERROR ${name} 'cd /home/ubuntu/nemoclaw && set -a && . .env && set +a && openshell sandbox connect nemoclaw'`);
runInteractive(`ssh -t -o StrictHostKeyChecking=no -o LogLevel=ERROR ${qname} 'cd /home/ubuntu/nemoclaw && set -a && . .env && set +a && openshell sandbox connect nemoclaw'`);
}

async function start() {
Expand Down
80 changes: 80 additions & 0 deletions test/deploy-instance-name.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
//
// Verify that deploy() validates and shell-quotes the instance name in
// commands to prevent command injection.

const { describe, it } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");

const ROOT = path.resolve(__dirname, "..");
const source = fs.readFileSync(path.join(ROOT, "bin/nemoclaw.js"), "utf-8");

// Extract the full deploy() function body using brace-depth counting
// so we don't stop at the first inner closing brace.
function extractDeploy(src) {
const start = src.indexOf("async function deploy(");
if (start === -1) return null;
const open = src.indexOf("{", start);
if (open === -1) return null;

let depth = 0;
let end = -1;
for (let i = open; i < src.length; i++) {
if (src[i] === "{") depth++;
if (src[i] === "}") depth--;
if (depth === 0) {
end = i + 1;
break;
}
}
if (end === -1) return null;
return src.slice(start, end);
}

const deployBody = extractDeploy(source);

describe("deploy instance name hardening", () => {
it("can extract deploy() function body", () => {
assert.ok(deployBody, "Could not find deploy() function");
});

it("enforces RFC 1123 validation inside deploy()", () => {
assert.match(
deployBody,
/\^\[a-z0-9\]/,
"deploy() must validate the instance name against RFC 1123 label rules"
);
});

it("enforces max 63 character limit", () => {
assert.match(
deployBody,
/length\s*>\s*63/,
"deploy() must enforce a 63-character limit on instance names"
);
});

it("uses shellQuote for instance name in shell commands", () => {
assert.ok(
deployBody.includes("qname = shellQuote(name)"),
"deploy() must create a shellQuoted name variable"
);
});

it("does not use execSync (prefer execFileSync)", () => {
assert.ok(
!deployBody.includes("execSync("),
"deploy() must use execFileSync instead of execSync"
);
});

it("shell-quotes env values", () => {
assert.ok(
deployBody.includes("shellQuote(process.env.NVIDIA_API_KEY"),
"NVIDIA_API_KEY must be shellQuoted in env file"
);
});
});