diff --git a/.github/workflows/codeql.yaml b/.github/workflows/codeql.yaml new file mode 100644 index 000000000..075a16d8b --- /dev/null +++ b/.github/workflows/codeql.yaml @@ -0,0 +1,45 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# CodeQL security scanning — runs on PRs and weekly schedule. + +name: codeql + +on: + pull_request: + types: [opened, synchronize, reopened] + schedule: + - cron: "0 6 * * 1" # Every Monday at 06:00 UTC + +permissions: + contents: read + security-events: write + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + analyze: + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + language: [javascript-typescript, python] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + + - name: Autobuild + uses: github/codeql-action/autobuild@v3 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 000000000..e99c6dbf4 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,116 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Automated release pipeline: runs tests, publishes to npm, creates GitHub Release +# with changelog generated from conventional commits. +# +# Triggered by pushing a semver tag (e.g., v0.2.0). + +name: release + +on: + push: + tags: + - "v*.*.*" + +permissions: + contents: write + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + + - name: Install root dependencies + run: npm install + + - name: Install and build TypeScript plugin + working-directory: nemoclaw + run: | + npm install + npm run build + + - name: Run unit tests + run: node --test test/*.test.js + + - name: Run TypeScript unit tests + working-directory: nemoclaw + run: npx vitest run + + publish-npm: + needs: test + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + registry-url: "https://registry.npmjs.org" + + - name: Install and build + run: | + npm install + cd nemoclaw && npm install && npm run build && cd .. + + - name: Publish to npm + run: npm publish --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + github-release: + needs: publish-npm + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Generate changelog from conventional commits + id: changelog + run: | + # Find the previous tag + PREV_TAG=$(git tag --sort=-version:refname | head -2 | tail -1) + if [ -z "$PREV_TAG" ] || [ "$PREV_TAG" = "${{ github.ref_name }}" ]; then + PREV_TAG=$(git rev-list --max-parents=0 HEAD) + fi + + echo "## What's Changed" > /tmp/changelog.md + echo "" >> /tmp/changelog.md + + # Group commits by type + for type_label in "feat:Features" "fix:Bug Fixes" "docs:Documentation" "chore:Maintenance" "refactor:Refactoring" "test:Tests" "ci:CI/CD" "perf:Performance"; do + type="${type_label%%:*}" + label="${type_label##*:}" + commits=$(git log "${PREV_TAG}..HEAD" --pretty=format:"- %s (%h)" --grep="^${type}" 2>/dev/null || true) + if [ -n "$commits" ]; then + echo "### ${label}" >> /tmp/changelog.md + echo "$commits" >> /tmp/changelog.md + echo "" >> /tmp/changelog.md + fi + done + + echo "**Full Changelog**: https://github.com/${{ github.repository }}/compare/${PREV_TAG}...${{ github.ref_name }}" >> /tmp/changelog.md + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + body_path: /tmp/changelog.md + draft: false + prerelease: ${{ contains(github.ref_name, '-') }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/bin/lib/credentials.js b/bin/lib/credentials.js index b48c73c4a..2ce07e4d5 100644 --- a/bin/lib/credentials.js +++ b/bin/lib/credentials.js @@ -4,7 +4,7 @@ const fs = require("fs"); const path = require("path"); const readline = require("readline"); -const { execSync } = require("child_process"); +const { execSync, spawnSync } = require("child_process"); const CREDS_DIR = path.join(process.env.HOME || "/tmp", ".nemoclaw"); const CREDS_FILE = path.join(CREDS_DIR, "credentials.json"); @@ -74,6 +74,18 @@ async function ensureApiKey() { process.exit(1); } + // Validate the key against the NVIDIA API before saving + console.log(" Validating API key..."); + const validation = validateApiKey(key); + if (validation.ok) { + console.log(" ✓ API key is valid"); + } else if (validation.fatal) { + console.error(` ✗ ${validation.message}`); + process.exit(1); + } else { + console.log(` ⓘ ${validation.message}`); + } + saveCredential("NVIDIA_API_KEY", key); process.env.NVIDIA_API_KEY = key; console.log(""); @@ -81,6 +93,58 @@ async function ensureApiKey() { console.log(""); } +/** + * Validate an NVIDIA API key by making a lightweight test request. + * Returns { ok, fatal, message } where: + * ok: true if the key is confirmed valid + * fatal: true if the key is definitively invalid (should not proceed) + * message: human-readable status + */ +function validateApiKey(key) { + try { + // Pass the auth header via stdin using -H @- so the API key + // does not appear in process arguments visible via ps aux. + const result = spawnSync( + "curl", + [ + "-sf", + "-o", "/dev/null", + "-w", "%{http_code}", + "-H", "@-", + "https://integrate.api.nvidia.com/v1/models", + ], + { + input: `Authorization: Bearer ${key}`, + encoding: "utf-8", + timeout: 15000, + stdio: ["pipe", "pipe", "pipe"], + } + ); + // Check for local spawn errors (curl missing, timeout) before inspecting stdout. + if (result.error) { + if (result.error.code === "ENOENT") { + return { ok: false, fatal: false, message: "Could not validate key (curl is not installed). Proceeding with saved key." }; + } + const reason = result.error.code === "ETIMEDOUT" ? "timed out" : result.error.message || "unknown error"; + return { ok: false, fatal: false, message: `Could not validate key (${reason}). Proceeding with saved key.` }; + } + const httpCode = (result.stdout || "").trim(); + if (httpCode === "200") { + return { ok: true, fatal: false, message: "API key validated successfully" }; + } + if (httpCode === "401" || httpCode === "403") { + return { ok: false, fatal: true, message: "API key is invalid or expired. Check https://build.nvidia.com/settings/api-keys" }; + } + if (httpCode === "000" || !httpCode) { + return { ok: false, fatal: false, message: "Could not reach NVIDIA API (network issue). Key format looks valid — proceeding." }; + } + return { ok: false, fatal: false, message: `Unexpected response (HTTP ${httpCode}). Key format looks valid — proceeding.` }; + } catch { + // Network failure — don't block onboarding, just warn + return { ok: false, fatal: false, message: "Could not validate key (network error). Proceeding with saved key." }; + } +} + function isRepoPrivate(repo) { try { const json = execSync(`gh api repos/${repo} --jq .private 2>/dev/null`, { encoding: "utf-8" }).trim(); @@ -138,4 +202,5 @@ module.exports = { ensureApiKey, ensureGithubToken, isRepoPrivate, + validateApiKey, }; diff --git a/bin/lib/logger.js b/bin/lib/logger.js new file mode 100644 index 000000000..c1500dac0 --- /dev/null +++ b/bin/lib/logger.js @@ -0,0 +1,32 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// Debug logger for verbose/diagnostic output. +// Usage: +// LOG_LEVEL=debug nemoclaw onboard +// nemoclaw --verbose onboard + +const LOG_LEVELS = { silent: 0, error: 1, warn: 2, info: 3, debug: 4 }; + +let currentLevel = LOG_LEVELS.info; + +function setLevel(level) { + const resolved = LOG_LEVELS[level]; + if (resolved !== undefined) { + currentLevel = resolved; + } +} + +function debug(...args) { + if (currentLevel >= LOG_LEVELS.debug) { + console.error(" [debug]", ...args); + } +} + +// Initialize from environment +const envLevel = (process.env.LOG_LEVEL || process.env.NEMOCLAW_LOG_LEVEL || "").toLowerCase(); +if (envLevel && LOG_LEVELS[envLevel] !== undefined) { + setLevel(envLevel); +} + +module.exports = { LOG_LEVELS, setLevel, debug }; diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index 252a303c8..db51f7833 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -533,18 +533,10 @@ 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'; + // NVIDIA_API_KEY, DISCORD_BOT_TOKEN, and SLACK_BOT_TOKEN are inherited + // via process.env instead of CLI args to avoid ps aux exposure (#325). + // nemoclaw-start.sh reads them from the environment. 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 discordToken = getCredential("DISCORD_BOT_TOKEN") || process.env.DISCORD_BOT_TOKEN; - if (discordToken) { - envArgs.push(`DISCORD_BOT_TOKEN=${shellQuote(discordToken)}`); - } - const slackToken = getCredential("SLACK_BOT_TOKEN") || process.env.SLACK_BOT_TOKEN; - if (slackToken) { - envArgs.push(`SLACK_BOT_TOKEN=${shellQuote(slackToken)}`); - } // Run without piping through awk — the pipe masked non-zero exit codes // from openshell because bash returns the status of the last pipeline @@ -805,12 +797,16 @@ async function setupInference(sandboxName, model, provider) { step(5, 7, "Setting up inference provider"); if (provider === "nvidia-nim") { - // Create nvidia-nim provider + // Pass credential via env var reference so the literal key value does not + // appear in the parent bash process args. The openshell child process will + // still receive the expanded value in its args (unavoidable without upstream + // --credential-file support), but the exposure window is limited to the + // short-lived openshell subprocess. See #325. run( `openshell provider create --name nvidia-nim --type openai ` + - `--credential ${shellQuote("NVIDIA_API_KEY=" + process.env.NVIDIA_API_KEY)} ` + + `--credential "NVIDIA_API_KEY=$NEMOCLAW_CRED" ` + `--config "OPENAI_BASE_URL=https://integrate.api.nvidia.com/v1" 2>&1 || true`, - { ignoreError: true } + { ignoreError: true, env: { NEMOCLAW_CRED: process.env.NVIDIA_API_KEY } } ); run( `openshell inference set --no-verify --provider nvidia-nim --model ${shellQuote(model)} 2>/dev/null || true`, @@ -1030,6 +1026,84 @@ function printDashboard(sandboxName, model, provider) { console.log(""); } +// ── State Machine Pipeline ──────────────────────────────────────── +// +// Each onboarding step is a { name, execute, rollback } object. +// On failure at step N, rollback runs steps N-1 through 0 in reverse. +// SIGINT/SIGTERM triggers the same rollback chain. + +class OnboardPipeline { + constructor() { + this.completedSteps = []; + this.aborted = false; + this._signalHandler = null; + } + + _registerSignalHandlers() { + // Use a synchronous handler to guarantee rollback completes before exit. + // All rollback actions use spawnSync, so no async work is needed. + this._signalHandler = () => { + if (this.aborted) return; // Prevent re-entry + this.aborted = true; + console.log(""); + console.log(" ⚠ Interrupted — cleaning up..."); + this._rollback(); + process.exit(130); + }; + process.on("SIGINT", this._signalHandler); + process.on("SIGTERM", this._signalHandler); + } + + _removeSignalHandlers() { + if (this._signalHandler) { + process.removeListener("SIGINT", this._signalHandler); + process.removeListener("SIGTERM", this._signalHandler); + this._signalHandler = null; + } + } + + // Synchronous rollback — safe to call from both signal handlers and catch + // blocks since all rollback actions use spawnSync (no async work). + _rollback() { + for (let i = this.completedSteps.length - 1; i >= 0; i--) { + const step = this.completedSteps[i]; + if (step.rollback) { + try { + console.log(` Rolling back: ${step.name}...`); + step.rollback(); + } catch (err) { + console.error(` [warn] Rollback failed for ${step.name}: ${err.message || err}`); + } + } + } + this.completedSteps = []; + } + + async run(steps) { + this._registerSignalHandlers(); + try { + for (const step of steps) { + if (this.aborted) break; + const result = await step.execute(); + this.completedSteps.push(step); + if (result !== undefined) { + step.result = result; + } + } + } catch (err) { + console.error(""); + console.error(` ✗ Onboarding failed at step: ${this.completedSteps.length + 1}`); + console.error(` ${err.message || err}`); + console.error(""); + console.error(" Cleaning up partial state..."); + this._rollback(); + this._removeSignalHandlers(); + throw err; + } + this._removeSignalHandlers(); + } +} + // ── Main ───────────────────────────────────────────────────────── async function onboard(opts = {}) { @@ -1040,14 +1114,101 @@ async function onboard(opts = {}) { if (isNonInteractive()) note(" (non-interactive mode)"); console.log(" ==================="); - const gpu = await preflight(); - await startGateway(gpu); - const sandboxName = await createSandbox(gpu); - const { model, provider } = await setupNim(sandboxName, gpu); - await setupInference(sandboxName, model, provider); - await setupOpenclaw(sandboxName, model, provider); - await setupPolicies(sandboxName); - printDashboard(sandboxName, model, provider); + // Shared state across steps — populated as each step completes. + // gatewayStarted / sandboxCreated track ownership so rollback only + // undoes resources THIS run created, not pre-existing ones. + const state = { + gpu: null, + sandboxName: null, + model: null, + provider: null, + gatewayStarted: false, + sandboxCreated: false, + }; + + const pipeline = new OnboardPipeline(); + + const steps = [ + { + name: "Preflight checks", + execute: async () => { state.gpu = await preflight(); }, + // Preflight is read-only — no rollback needed. + rollback: null, + }, + { + name: "Start gateway", + execute: async () => { + await startGateway(state.gpu); + state.gatewayStarted = true; + }, + rollback: () => { + if (state.gatewayStarted) { + run("openshell gateway destroy -g nemoclaw 2>/dev/null || true", { ignoreError: true }); + } + }, + }, + { + name: "Create sandbox", + execute: async () => { + // Snapshot ALL registry entries so we can compare by returned name + // (the user may type a different name than the env var default). + const allBefore = {}; + for (const sb of registry.listSandboxes().sandboxes) { + allBefore[sb.name] = sb.createdAt; + } + state.sandboxName = await createSandbox(state.gpu); + const after = registry.getSandbox(state.sandboxName); + // If the entry existed before with the same createdAt, the user + // chose "keep existing" — rollback must not destroy it. + const beforeCreatedAt = allBefore[state.sandboxName]; + state.sandboxCreated = !(beforeCreatedAt && after && beforeCreatedAt === after.createdAt); + }, + rollback: () => { + if (state.sandboxName && state.sandboxCreated) { + run(`openshell sandbox delete "${state.sandboxName}" 2>/dev/null || true`, { ignoreError: true }); + registry.removeSandbox(state.sandboxName); + } + }, + }, + { + name: "Configure inference (NIM)", + execute: async () => { + const result = await setupNim(state.sandboxName, state.gpu); + state.model = result.model; + state.provider = result.provider; + }, + rollback: () => { + if (state.sandboxName) { + nim.stopNimContainer(state.sandboxName); + } + }, + }, + { + name: "Set up inference provider", + execute: async () => { + await setupInference(state.sandboxName, state.model, state.provider); + }, + rollback: null, // Provider creation is idempotent + }, + { + name: "Set up OpenClaw", + execute: async () => { + await setupOpenclaw(state.sandboxName, state.model, state.provider); + }, + rollback: null, // Config inside sandbox is cleaned up by sandbox deletion + }, + { + name: "Apply policy presets", + execute: async () => { + await setupPolicies(state.sandboxName); + }, + rollback: null, // Policies are cleaned up by sandbox deletion + }, + ]; + + await pipeline.run(steps); + + printDashboard(state.sandboxName, state.model, state.provider); } module.exports = { @@ -1059,4 +1220,5 @@ module.exports = { onboard, setupNim, writeSandboxConfigSyncFile, + OnboardPipeline, }; diff --git a/bin/lib/registry.js b/bin/lib/registry.js index c42a44fdf..7ca14a730 100644 --- a/bin/lib/registry.js +++ b/bin/lib/registry.js @@ -13,7 +13,18 @@ function load() { if (fs.existsSync(REGISTRY_FILE)) { return JSON.parse(fs.readFileSync(REGISTRY_FILE, "utf-8")); } - } catch {} + } catch { + // Registry file exists but is corrupted — back it up and warn + const backupFile = REGISTRY_FILE + ".corrupt." + Date.now(); + try { + fs.copyFileSync(REGISTRY_FILE, backupFile); + } catch { + // Best-effort backup — if the copy fails (e.g., disk full), we still + // warn the user and reset the registry. The corrupt file remains in place. + } + console.error(` [warn] Registry file is corrupted. Backed up to: ${path.basename(backupFile)}`); + console.error(" [warn] Sandbox list has been reset. Run 'nemoclaw onboard' to recreate."); + } return { sandboxes: {}, defaultSandbox: null }; } diff --git a/bin/lib/runner.js b/bin/lib/runner.js index d0ca4ceea..9742ad904 100644 --- a/bin/lib/runner.js +++ b/bin/lib/runner.js @@ -4,6 +4,7 @@ const { execSync, spawnSync } = require("child_process"); const path = require("path"); const { detectDockerHost } = require("./platform"); +const logger = require("./logger"); const ROOT = path.resolve(__dirname, "..", ".."); const SCRIPTS = path.join(ROOT, "scripts"); @@ -13,7 +14,29 @@ if (dockerHost) { process.env.DOCKER_HOST = dockerHost.dockerHost; } +// Redact known secret patterns from command strings for logging. +// Patterns are created inside the function to avoid shared /g lastIndex state. +function redactSecrets(str) { + const patterns = [ + /NVIDIA_API_KEY=[^\s"']*/g, + /nvapi-[A-Za-z0-9_-]+/g, + /GITHUB_TOKEN=[^\s"']*/g, + /TELEGRAM_BOT_TOKEN=[^\s"']*/g, + /OPENAI_API_KEY=[^\s"']*/g, + ]; + let result = str; + for (const pattern of patterns) { + result = result.replace(pattern, (match) => { + const eqIdx = match.indexOf("="); + if (eqIdx > 0) return match.slice(0, eqIdx + 1) + "***"; + return match.slice(0, 8) + "***"; + }); + } + return result; +} + function run(cmd, opts = {}) { + logger.debug(`exec: ${redactSecrets(cmd.slice(0, 200))}`); const stdio = opts.stdio ?? ["ignore", "inherit", "inherit"]; const result = spawnSync("bash", ["-c", cmd], { ...opts, @@ -22,13 +45,14 @@ function run(cmd, opts = {}) { env: { ...process.env, ...opts.env }, }); if (result.status !== 0 && !opts.ignoreError) { - console.error(` Command failed (exit ${result.status}): ${cmd.slice(0, 80)}`); + console.error(` Command failed (exit ${result.status}): ${redactSecrets(cmd.slice(0, 80))}`); process.exit(result.status || 1); } return result; } function runInteractive(cmd, opts = {}) { + logger.debug(`exec (interactive): ${redactSecrets(cmd.slice(0, 200))}`); const stdio = opts.stdio ?? "inherit"; const result = spawnSync("bash", ["-c", cmd], { ...opts, @@ -37,13 +61,14 @@ function runInteractive(cmd, opts = {}) { env: { ...process.env, ...opts.env }, }); if (result.status !== 0 && !opts.ignoreError) { - console.error(` Command failed (exit ${result.status}): ${cmd.slice(0, 80)}`); + console.error(` Command failed (exit ${result.status}): ${redactSecrets(cmd.slice(0, 80))}`); process.exit(result.status || 1); } return result; } function runCapture(cmd, opts = {}) { + logger.debug(`capture: ${redactSecrets(cmd.slice(0, 200))}`); try { return execSync(cmd, { ...opts, @@ -85,4 +110,4 @@ function validateName(name, label = "name") { return name; } -module.exports = { ROOT, SCRIPTS, run, runCapture, runInteractive, shellQuote, validateName }; +module.exports = { ROOT, SCRIPTS, run, runCapture, runInteractive, shellQuote, validateName, redactSecrets }; diff --git a/bin/nemoclaw.js b/bin/nemoclaw.js index 2010cfeb2..ec9b40f81 100755 --- a/bin/nemoclaw.js +++ b/bin/nemoclaw.js @@ -20,6 +20,7 @@ const R = _useColor ? "\x1b[0m" : ""; const RD = _useColor ? "\x1b[1;31m" : ""; const YW = _useColor ? "\x1b[1;33m" : ""; +const logger = require("./lib/logger"); const { ROOT, SCRIPTS, run, runCapture, runInteractive, shellQuote, validateName } = require("./lib/runner"); const { ensureApiKey, @@ -31,6 +32,14 @@ const registry = require("./lib/registry"); const nim = require("./lib/nim"); const policies = require("./lib/policies"); +// ── Global flags ───────────────────────────────────────────────── + +// Parse --verbose / --debug from anywhere in argv before command dispatch. +// These are stripped from args below so commands don't see them as unknown flags. +if (process.argv.includes("--verbose") || process.argv.includes("--debug")) { + logger.setLevel("debug"); +} + // ── Global commands ────────────────────────────────────────────── const GLOBAL_COMMANDS = new Set([ @@ -97,7 +106,9 @@ async function setup() { async function setupSpark() { await ensureApiKey(); - run(`sudo -E NVIDIA_API_KEY=${shellQuote(process.env.NVIDIA_API_KEY)} bash "${SCRIPTS}/setup-spark.sh"`); + // Use sudo -E to preserve the parent environment (including NVIDIA_API_KEY) + // instead of passing the key as a CLI argument visible in ps aux (#325). + run(`sudo -E bash "${SCRIPTS}/setup-spark.sh"`); } async function deploy(instanceName) { @@ -432,6 +443,10 @@ function help() { --keep-openshell Leave the openshell binary installed --delete-models Remove NemoClaw-pulled Ollama models + ${G}Global Flags:${R} + --verbose, --debug Show detailed output for debugging + LOG_LEVEL=debug Same as --verbose (env var) + ${D}Powered by NVIDIA OpenShell · Nemotron · Agent Toolkit Credentials saved in ~/.nemoclaw/credentials.json (mode 600)${R} ${D}https://www.nvidia.com/nemoclaw${R} @@ -440,7 +455,8 @@ function help() { // ── Dispatch ───────────────────────────────────────────────────── -const [cmd, ...args] = process.argv.slice(2); +const GLOBAL_FLAGS = new Set(["--verbose", "--debug"]); +const [cmd, ...args] = process.argv.slice(2).filter((a) => !GLOBAL_FLAGS.has(a)); (async () => { // No command → help diff --git a/scripts/setup.sh b/scripts/setup.sh index 22b3ccfec..4e0c0cfe2 100755 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -130,6 +130,10 @@ fi info "Setting up inference providers..." # nvidia-nim (build.nvidia.com) +# Pass the credential value directly from the env var. The literal key +# still appears in the transient openshell child process args (unavoidable +# without upstream --credential-file support), but it no longer appears +# in shell history or parent process args. See #325. upsert_provider \ "nvidia-nim" \ "openai" \ @@ -190,9 +194,11 @@ rm -rf "$BUILD_CTX/nemoclaw/node_modules" # detect failures. The raw log is kept on failure for debugging. CREATE_LOG=$(mktemp /tmp/nemoclaw-create-XXXXXX.log) set +e +# NVIDIA_API_KEY is inherited from the parent process environment +# instead of being passed as a CLI argument visible in ps aux (#325). openshell sandbox create --from "$BUILD_CTX/Dockerfile" --name "$SANDBOX_NAME" \ --provider nvidia-nim \ - -- env NVIDIA_API_KEY="$NVIDIA_API_KEY" > "$CREATE_LOG" 2>&1 + -- nemoclaw-start > "$CREATE_LOG" 2>&1 CREATE_RC=$? set -e rm -rf "$BUILD_CTX" diff --git a/test/onboard-pipeline.test.js b/test/onboard-pipeline.test.js new file mode 100644 index 000000000..cc9181960 --- /dev/null +++ b/test/onboard-pipeline.test.js @@ -0,0 +1,93 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +const { describe, it } = require("node:test"); +const assert = require("node:assert/strict"); + +const { OnboardPipeline } = require("../bin/lib/onboard"); + +describe("OnboardPipeline", () => { + it("runs all steps in order when none fail", async () => { + const order = []; + const pipeline = new OnboardPipeline(); + await pipeline.run([ + { name: "A", execute: async () => { order.push("A"); }, rollback: null }, + { name: "B", execute: async () => { order.push("B"); }, rollback: null }, + { name: "C", execute: async () => { order.push("C"); }, rollback: null }, + ]); + assert.deepEqual(order, ["A", "B", "C"]); + }); + + it("rolls back completed steps in reverse when a step fails", async () => { + const order = []; + const pipeline = new OnboardPipeline(); + await assert.rejects( + () => pipeline.run([ + { name: "A", execute: async () => { order.push("exec-A"); }, rollback: () => { order.push("rollback-A"); } }, + { name: "B", execute: async () => { order.push("exec-B"); }, rollback: () => { order.push("rollback-B"); } }, + { name: "C", execute: async () => { throw new Error("step C failed"); }, rollback: () => { order.push("rollback-C"); } }, + ]), + { message: "step C failed" } + ); + // C's execute threw, so C was never pushed to completedSteps. + // Rollback should run B then A (reverse order), NOT C. + assert.deepEqual(order, ["exec-A", "exec-B", "rollback-B", "rollback-A"]); + }); + + it("does not run rollback for steps with null rollback", async () => { + const order = []; + const pipeline = new OnboardPipeline(); + await assert.rejects( + () => pipeline.run([ + { name: "A", execute: async () => { order.push("exec-A"); }, rollback: null }, + { name: "B", execute: async () => { order.push("exec-B"); }, rollback: () => { order.push("rollback-B"); } }, + { name: "C", execute: async () => { throw new Error("fail"); }, rollback: null }, + ]) + ); + // Only B has a rollback function + assert.deepEqual(order, ["exec-A", "exec-B", "rollback-B"]); + }); + + it("continues rollback even if a rollback function throws", async () => { + const order = []; + const pipeline = new OnboardPipeline(); + await assert.rejects( + () => pipeline.run([ + { name: "A", execute: async () => {}, rollback: () => { order.push("rollback-A"); } }, + { name: "B", execute: async () => {}, rollback: () => { throw new Error("rollback B broken"); } }, + { name: "C", execute: async () => { throw new Error("fail"); }, rollback: null }, + ]) + ); + // B's rollback throws but A's rollback should still run + assert.deepEqual(order, ["rollback-A"]); + }); + + it("clears completedSteps after rollback", async () => { + const pipeline = new OnboardPipeline(); + await assert.rejects( + () => pipeline.run([ + { name: "A", execute: async () => {}, rollback: () => {} }, + { name: "B", execute: async () => { throw new Error("fail"); }, rollback: null }, + ]) + ); + assert.equal(pipeline.completedSteps.length, 0); + }); + + it("removes signal handlers after successful run", async () => { + const pipeline = new OnboardPipeline(); + await pipeline.run([ + { name: "A", execute: async () => {}, rollback: null }, + ]); + assert.equal(pipeline._signalHandler, null, "signal handler should be cleaned up"); + }); + + it("removes signal handlers after failed run", async () => { + const pipeline = new OnboardPipeline(); + await assert.rejects( + () => pipeline.run([ + { name: "A", execute: async () => { throw new Error("fail"); }, rollback: null }, + ]) + ); + assert.equal(pipeline._signalHandler, null, "signal handler should be cleaned up"); + }); +}); diff --git a/test/policies.test.js b/test/policies.test.js index 040910bb7..6d9706672 100644 --- a/test/policies.test.js +++ b/test/policies.test.js @@ -70,6 +70,89 @@ describe("policies", () => { }); }); + describe("extractPresetEntries", () => { + it("extracts entries from a preset with network_policies key", () => { + const content = `preset: + name: test + description: "Test preset" + +network_policies: + test: + name: test + endpoints: + - host: example.com + port: 443`; + const entries = policies.extractPresetEntries(content); + assert.ok(entries); + assert.ok(entries.includes("test:")); + assert.ok(entries.includes("host: example.com")); + }); + + it("returns null when network_policies key is missing", () => { + const content = `preset: + name: test + description: "No policies here"`; + assert.equal(policies.extractPresetEntries(content), null); + }); + + it("handles preset with only network_policies section", () => { + const content = `network_policies: + minimal: + name: minimal`; + const entries = policies.extractPresetEntries(content); + assert.ok(entries); + assert.ok(entries.includes("minimal:")); + }); + + it("extracts entries from all real presets without error", () => { + for (const p of policies.listPresets()) { + const content = policies.loadPreset(p.name); + const entries = policies.extractPresetEntries(content); + assert.ok(entries, `${p.name} failed to extract entries`); + assert.ok(entries.length > 0, `${p.name} has empty entries`); + } + }); + }); + + describe("parseCurrentPolicy", () => { + it("strips metadata header before ---", () => { + const raw = `Version: 1 +Hash: abc123 +--- +version: 1 + +network_policies: + test: + name: test`; + const parsed = policies.parseCurrentPolicy(raw); + assert.ok(parsed.startsWith("version: 1")); + assert.ok(!parsed.includes("Hash:")); + }); + + it("returns raw content when no --- separator exists", () => { + const raw = `version: 1 +network_policies: + test: + name: test`; + assert.equal(policies.parseCurrentPolicy(raw), raw); + }); + + it("returns empty string for null input", () => { + assert.equal(policies.parseCurrentPolicy(null), ""); + }); + + it("returns empty string for empty input", () => { + assert.equal(policies.parseCurrentPolicy(""), ""); + }); + + it("handles --- at the very beginning", () => { + const raw = `--- +version: 1`; + const parsed = policies.parseCurrentPolicy(raw); + assert.equal(parsed, "version: 1"); + }); + }); + describe("buildPolicySetCommand", () => { it("shell-quotes sandbox name to prevent injection", () => { const cmd = policies.buildPolicySetCommand("/tmp/policy.yaml", "my-assistant"); @@ -119,5 +202,19 @@ describe("policies", () => { assert.ok(content.includes("network_policies:"), `${p.name} missing network_policies`); } }); + + it("every preset name in YAML matches its filename", () => { + for (const p of policies.listPresets()) { + const expectedName = p.file.replace(".yaml", ""); + assert.equal(p.name, expectedName, `${p.file}: YAML name '${p.name}' does not match filename`); + } + }); + }); + + describe("getAppliedPresets", () => { + it("returns empty array for nonexistent sandbox", () => { + const applied = policies.getAppliedPresets("no-such-sandbox"); + assert.deepEqual(applied, []); + }); }); }); diff --git a/test/runner-redact.test.js b/test/runner-redact.test.js new file mode 100644 index 000000000..ee450fdab --- /dev/null +++ b/test/runner-redact.test.js @@ -0,0 +1,70 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +const { describe, it } = require("node:test"); +const assert = require("node:assert/strict"); + +const { redactSecrets } = require("../bin/lib/runner"); + +describe("redactSecrets", () => { + it("redacts NVIDIA_API_KEY=value", () => { + const input = 'openshell provider create --credential "NVIDIA_API_KEY=nvapi-abc123XYZ"'; + const result = redactSecrets(input); + assert.ok(!result.includes("nvapi-abc123XYZ"), "key value must not appear"); + assert.ok(result.includes("NVIDIA_API_KEY=***"), "key name should remain with ***"); + }); + + it("redacts bare nvapi- tokens", () => { + const input = "Bearer nvapi-SomeSecretToken123"; + const result = redactSecrets(input); + assert.ok(!result.includes("nvapi-SomeSecretToken123"), "bare token must not appear"); + assert.ok(result.includes("nvapi-So***"), "prefix should remain with ***"); + }); + + it("redacts GITHUB_TOKEN=value", () => { + const input = "GITHUB_TOKEN=ghp_1234567890abcdef"; + const result = redactSecrets(input); + assert.ok(!result.includes("ghp_1234567890abcdef"), "token must not appear"); + assert.ok(result.includes("GITHUB_TOKEN=***")); + }); + + it("redacts TELEGRAM_BOT_TOKEN=value", () => { + const input = "TELEGRAM_BOT_TOKEN=123456:ABC-DEF"; + const result = redactSecrets(input); + assert.ok(!result.includes("123456:ABC-DEF")); + assert.ok(result.includes("TELEGRAM_BOT_TOKEN=***")); + }); + + it("redacts OPENAI_API_KEY=value", () => { + const input = "OPENAI_API_KEY=sk-proj-abc123"; + const result = redactSecrets(input); + assert.ok(!result.includes("sk-proj-abc123")); + assert.ok(result.includes("OPENAI_API_KEY=***")); + }); + + it("returns input unchanged when no secrets present", () => { + const input = "openshell sandbox create --name my-assistant"; + assert.equal(redactSecrets(input), input); + }); + + it("redacts multiple different secrets in one string", () => { + const input = 'NVIDIA_API_KEY=nvapi-secret GITHUB_TOKEN=ghp_token123'; + const result = redactSecrets(input); + assert.ok(!result.includes("nvapi-secret")); + assert.ok(!result.includes("ghp_token123")); + assert.ok(result.includes("NVIDIA_API_KEY=***")); + assert.ok(result.includes("GITHUB_TOKEN=***")); + }); + + it("handles empty string", () => { + assert.equal(redactSecrets(""), ""); + }); + + it("is safe to call multiple times consecutively", () => { + const input = "NVIDIA_API_KEY=nvapi-test123"; + const r1 = redactSecrets(input); + const r2 = redactSecrets(input); + assert.equal(r1, r2, "consecutive calls must produce identical results"); + assert.ok(!r1.includes("nvapi-test123")); + }); +}); diff --git a/test/validate-api-key.test.js b/test/validate-api-key.test.js new file mode 100644 index 000000000..c683352c3 --- /dev/null +++ b/test/validate-api-key.test.js @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +const { describe, it } = require("node:test"); +const assert = require("node:assert/strict"); + +const { validateApiKey } = require("../bin/lib/credentials"); + +describe("validateApiKey", () => { + // These tests call the real curl command against the real NVIDIA API. + // The /v1/models endpoint may return 200 even for invalid keys (public listing), + // so we test the return shape and invariants, not specific ok/fatal values. + + // @flaky — hits real NVIDIA API; outcome depends on network + endpoint behavior. + it("returns a well-formed result for any key", () => { + const result = validateApiKey("nvapi-INVALID_TEST_KEY_000000"); + assert.equal(typeof result.ok, "boolean"); + assert.equal(typeof result.fatal, "boolean"); + assert.ok("message" in result, "must have message field"); + }); + + it("always returns the { ok, fatal, message } shape", () => { + const result = validateApiKey("nvapi-test"); + assert.ok("ok" in result, "must have ok field"); + assert.ok("fatal" in result, "must have fatal field"); + assert.ok("message" in result, "must have message field"); + }); + + it("never returns fatal: true when ok: true", () => { + const result = validateApiKey("nvapi-anything"); + if (result.ok) { + assert.equal(result.fatal, false, "ok: true must have fatal: false"); + } + }); + + it("handles empty key without crashing", () => { + const result = validateApiKey(""); + assert.equal(typeof result.ok, "boolean"); + assert.equal(typeof result.fatal, "boolean"); + assert.ok("message" in result, "must have message field"); + }); +});