diff --git a/bin/lib/nim.js b/bin/lib/nim.js index 6c4261a99..25c050097 100644 --- a/bin/lib/nim.js +++ b/bin/lib/nim.js @@ -5,6 +5,7 @@ const { run, runCapture, shellQuote } = require("./runner"); const nimImages = require("./nim-images.json"); +const UNIFIED_MEMORY_GPU_TAGS = ["GB10", "Thor", "Orin", "Xavier"]; function containerName(sandboxName) { return `nemoclaw-nim-${sandboxName}`; @@ -23,6 +24,10 @@ function listModels() { })); } +function canRunNimWithMemory(totalMemoryMB) { + return nimImages.models.some((m) => m.minGpuMemoryMB <= totalMemoryMB); +} + function detectGpu() { // Try NVIDIA first — query VRAM try { @@ -34,14 +39,12 @@ function detectGpu() { const perGpuMB = lines.map((l) => parseInt(l.trim(), 10)).filter((n) => !isNaN(n)); if (perGpuMB.length > 0) { const totalMemoryMB = perGpuMB.reduce((a, b) => a + b, 0); - // Only mark nimCapable if at least one NIM model fits in GPU VRAM - const canRunNim = nimImages.models.some((m) => m.minGpuMemoryMB <= totalMemoryMB); return { type: "nvidia", count: perGpuMB.length, totalMemoryMB, perGpuMB: perGpuMB[0], - nimCapable: canRunNim, + nimCapable: canRunNimWithMemory(totalMemoryMB), }; } } @@ -49,13 +52,19 @@ function detectGpu() { /* ignored */ } - // Fallback: DGX Spark (GB10) — VRAM not queryable due to unified memory architecture + // Fallback: unified-memory NVIDIA devices where discrete VRAM is not queryable. try { const nameOutput = runCapture("nvidia-smi --query-gpu=name --format=csv,noheader,nounits", { ignoreError: true, }); - if (nameOutput && nameOutput.includes("GB10")) { - // GB10 has 128GB unified memory shared with Grace CPU — use system RAM + const gpuNames = nameOutput + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + const unifiedGpuNames = gpuNames.filter((name) => + UNIFIED_MEMORY_GPU_TAGS.some((tag) => new RegExp(tag, "i").test(name)), + ); + if (unifiedGpuNames.length > 0) { let totalMemoryMB = 0; try { const memLine = runCapture("free -m | awk '/Mem:/ {print $2}'", { ignoreError: true }); @@ -63,13 +72,18 @@ function detectGpu() { } catch { /* ignored */ } + const count = unifiedGpuNames.length; + const perGpuMB = count > 0 ? Math.floor(totalMemoryMB / count) : totalMemoryMB; + const isSpark = unifiedGpuNames.some((name) => /GB10/i.test(name)); return { type: "nvidia", - count: 1, + name: unifiedGpuNames[0], + count, totalMemoryMB, - perGpuMB: totalMemoryMB, - nimCapable: true, - spark: true, + perGpuMB: perGpuMB || totalMemoryMB, + nimCapable: canRunNimWithMemory(totalMemoryMB), + unifiedMemory: true, + spark: isSpark, }; } } catch { @@ -232,6 +246,7 @@ module.exports = { containerName, getImageForModel, listModels, + canRunNimWithMemory, detectGpu, pullNimImage, startNimContainer, diff --git a/bin/lib/policies.js b/bin/lib/policies.js index a555d04bf..b5550f061 100644 --- a/bin/lib/policies.js +++ b/bin/lib/policies.js @@ -6,11 +6,11 @@ const fs = require("fs"); const path = require("path"); const os = require("os"); +const YAML = require("yaml"); const { ROOT, run, runCapture, shellQuote } = require("./runner"); const registry = require("./registry"); const PRESETS_DIR = path.join(ROOT, "nemoclaw-blueprint", "policies", "presets"); - function getOpenshellCommand() { const binary = process.env.NEMOCLAW_OPENSHELL_BIN; if (!binary) return "openshell"; @@ -76,8 +76,23 @@ function extractPresetEntries(presetContent) { function parseCurrentPolicy(raw) { if (!raw) return ""; const sep = raw.indexOf("---"); - if (sep === -1) return raw; - return raw.slice(sep + 3).trim(); + const candidate = (sep === -1 ? raw : raw.slice(sep + 3)).trim(); + if (!candidate) return ""; + if (/^(error|failed|invalid|warning|status)\b/i.test(candidate)) { + return ""; + } + if (!/^[a-z_][a-z0-9_]*\s*:/m.test(candidate)) { + return ""; + } + try { + const parsed = YAML.parse(candidate); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return ""; + } + } catch { + return ""; + } + return candidate; } /** @@ -104,16 +119,17 @@ function buildPolicyGetCommand(sandboxName) { * @returns {string} Merged YAML with version header when missing */ function mergePresetIntoPolicy(currentPolicy, presetEntries) { + const normalizedCurrentPolicy = parseCurrentPolicy(currentPolicy); if (!presetEntries) { - return currentPolicy || "version: 1\n\nnetwork_policies:\n"; + return normalizedCurrentPolicy || "version: 1\n\nnetwork_policies:\n"; } - if (!currentPolicy) { + if (!normalizedCurrentPolicy) { return "version: 1\n\nnetwork_policies:\n" + presetEntries; } let merged; - if (/^network_policies\s*:/m.test(currentPolicy)) { - const lines = currentPolicy.split("\n"); + if (/^network_policies\s*:/m.test(normalizedCurrentPolicy)) { + const lines = normalizedCurrentPolicy.split("\n"); const result = []; let inNetworkPolicies = false; let inserted = false; @@ -142,11 +158,11 @@ function mergePresetIntoPolicy(currentPolicy, presetEntries) { merged = result.join("\n"); } else { - merged = currentPolicy.trimEnd() + "\n\nnetwork_policies:\n" + presetEntries; + merged = normalizedCurrentPolicy.trimEnd() + "\n\nnetwork_policies:\n" + presetEntries; } if (!merged.trimStart().startsWith("version:")) { - merged = "version: 1\n" + merged; + merged = "version: 1\n\n" + merged; } return merged; } diff --git a/bin/nemoclaw.js b/bin/nemoclaw.js index 54eb722f8..d19317c16 100755 --- a/bin/nemoclaw.js +++ b/bin/nemoclaw.js @@ -69,6 +69,8 @@ const REMOTE_UNINSTALL_URL = "https://raw.githubusercontent.com/NVIDIA/NemoClaw/refs/heads/main/uninstall.sh"; let OPENSHELL_BIN = null; const MIN_LOGS_OPENSHELL_VERSION = "0.0.7"; +const NEMOCLAW_GATEWAY_NAME = "nemoclaw"; +const DASHBOARD_FORWARD_PORT = "18789"; function getOpenshellBinary() { if (!OPENSHELL_BIN) { @@ -108,6 +110,23 @@ function captureOpenshell(args, opts = {}) { }; } +function cleanupGatewayAfterLastSandbox() { + runOpenshell(["forward", "stop", DASHBOARD_FORWARD_PORT], { ignoreError: true }); + runOpenshell(["gateway", "destroy", "-g", NEMOCLAW_GATEWAY_NAME], { ignoreError: true }); + run( + `docker volume ls -q --filter "name=openshell-cluster-${NEMOCLAW_GATEWAY_NAME}" | grep . && docker volume ls -q --filter "name=openshell-cluster-${NEMOCLAW_GATEWAY_NAME}" | xargs docker volume rm || true`, + { ignoreError: true }, + ); +} + +function hasNoLiveSandboxes() { + const liveList = captureOpenshell(["sandbox", "list"], { ignoreError: true }); + if (liveList.status !== 0) { + return false; + } + return parseLiveSandboxNames(liveList.output).size === 0; +} + function parseVersionFromText(value = "") { const match = String(value || "").match(/([0-9]+\.[0-9]+\.[0-9]+)/); return match ? match[1] : null; @@ -748,7 +767,6 @@ async function deploy(instanceName) { } async function start() { - await ensureApiKey(); const { defaultSandbox } = registry.listSandboxes(); const safeName = defaultSandbox && /^[a-zA-Z0-9._-]+$/.test(defaultSandbox) ? defaultSandbox : null; @@ -1088,9 +1106,17 @@ async function sandboxDestroy(sandboxName, args = []) { else nim.stopNimContainer(sandboxName); console.log(` Deleting sandbox '${sandboxName}'...`); - runOpenshell(["sandbox", "delete", sandboxName], { ignoreError: true }); + const deleteResult = runOpenshell(["sandbox", "delete", sandboxName], { ignoreError: true }); - registry.removeSandbox(sandboxName); + const removed = registry.removeSandbox(sandboxName); + if ( + deleteResult.status === 0 && + removed && + registry.listSandboxes().sandboxes.length === 0 && + hasNoLiveSandboxes() + ) { + cleanupGatewayAfterLastSandbox(); + } console.log(` ${G}✓${R} Sandbox '${sandboxName}' destroyed`); } diff --git a/scripts/start-services.sh b/scripts/start-services.sh index 797052049..0c64d1341 100755 --- a/scripts/start-services.sh +++ b/scripts/start-services.sh @@ -123,11 +123,12 @@ do_stop() { } do_start() { - [ -n "${NVIDIA_API_KEY:-}" ] || fail "NVIDIA_API_KEY required" - if [ -z "${TELEGRAM_BOT_TOKEN:-}" ]; then warn "TELEGRAM_BOT_TOKEN not set — Telegram bridge will not start." warn "Create a bot via @BotFather on Telegram and set the token." + elif [ -z "${NVIDIA_API_KEY:-}" ]; then + warn "NVIDIA_API_KEY not set — Telegram bridge will not start." + warn "Set NVIDIA_API_KEY if you want Telegram requests to reach inference." fi command -v node >/dev/null || fail "node not found. Install Node.js first." @@ -151,7 +152,7 @@ do_start() { mkdir -p "$PIDDIR" # Telegram bridge (only if token provided) - if [ -n "${TELEGRAM_BOT_TOKEN:-}" ]; then + if [ -n "${TELEGRAM_BOT_TOKEN:-}" ] && [ -n "${NVIDIA_API_KEY:-}" ]; then SANDBOX_NAME="$SANDBOX_NAME" start_service telegram-bridge \ node "$REPO_DIR/scripts/telegram-bridge.js" fi diff --git a/test/cli.test.js b/test/cli.test.js index dd4d4972f..976999a00 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -62,6 +62,52 @@ describe("CLI dispatch", () => { expect(r.out.includes("No sandboxes")).toBeTruthy(); }); + it("start does not prompt for NVIDIA_API_KEY before launching local services", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-start-no-key-")); + const localBin = path.join(home, "bin"); + const registryDir = path.join(home, ".nemoclaw"); + const markerFile = path.join(home, "start-args"); + fs.mkdirSync(localBin, { recursive: true }); + fs.mkdirSync(registryDir, { recursive: true }); + fs.writeFileSync( + path.join(registryDir, "sandboxes.json"), + JSON.stringify({ + sandboxes: { + alpha: { + name: "alpha", + model: "test-model", + provider: "nvidia-prod", + gpuEnabled: false, + policies: [], + }, + }, + defaultSandbox: "alpha", + }), + { mode: 0o600 }, + ); + fs.writeFileSync( + path.join(localBin, "bash"), + [ + "#!/bin/sh", + `marker_file=${JSON.stringify(markerFile)}`, + 'printf \'%s\\n\' "$@" > "$marker_file"', + "exit 0", + ].join("\n"), + { mode: 0o755 }, + ); + + const r = runWithEnv("start", { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + NVIDIA_API_KEY: "", + TELEGRAM_BOT_TOKEN: "", + }); + + expect(r.code).toBe(0); + expect(r.out).not.toContain("NVIDIA API Key required"); + expect(fs.readFileSync(markerFile, "utf8")).toContain("start-services.sh"); + }); + it("unknown onboard option exits 1", () => { const r = run("onboard --non-interactiv"); expect(r.code).toBe(1); @@ -148,6 +194,204 @@ describe("CLI dispatch", () => { expect(fs.readFileSync(markerFile, "utf8")).not.toContain("--follow"); }); + it("destroys the gateway runtime when the last sandbox is removed", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-destroy-last-")); + const localBin = path.join(home, "bin"); + const registryDir = path.join(home, ".nemoclaw"); + const openshellLog = path.join(home, "openshell.log"); + const bashLog = path.join(home, "bash.log"); + fs.mkdirSync(localBin, { recursive: true }); + fs.mkdirSync(registryDir, { recursive: true }); + fs.writeFileSync( + path.join(registryDir, "sandboxes.json"), + JSON.stringify({ + sandboxes: { + alpha: { + name: "alpha", + model: "test-model", + provider: "nvidia-prod", + gpuEnabled: false, + policies: [], + }, + }, + defaultSandbox: "alpha", + }), + { mode: 0o600 }, + ); + fs.writeFileSync( + path.join(localBin, "openshell"), + [ + "#!/bin/sh", + `log_file=${JSON.stringify(openshellLog)}`, + 'if [ "$1" = "sandbox" ] && [ "$2" = "list" ]; then', + ' printf "NAME STATUS\\n" >> "$log_file"', + " exit 0", + "fi", + 'printf \'%s\\n\' "$*" >> "$log_file"', + "exit 0", + ].join("\n"), + { mode: 0o755 }, + ); + fs.writeFileSync( + path.join(localBin, "bash"), + [ + "#!/bin/sh", + `log_file=${JSON.stringify(bashLog)}`, + 'printf \'%s\\n\' "$*" >> "$log_file"', + "exit 0", + ].join("\n"), + { mode: 0o755 }, + ); + + const r = runWithEnv("alpha destroy --yes", { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + }); + + expect(r.code).toBe(0); + expect(fs.readFileSync(openshellLog, "utf8")).toContain("sandbox delete alpha"); + expect(fs.readFileSync(openshellLog, "utf8")).toContain("NAME STATUS"); + expect(fs.readFileSync(openshellLog, "utf8")).toContain("forward stop 18789"); + expect(fs.readFileSync(openshellLog, "utf8")).toContain("gateway destroy -g nemoclaw"); + expect(fs.readFileSync(bashLog, "utf8")).toContain("docker volume ls -q --filter"); + }); + + it("keeps the gateway runtime when other sandboxes still exist", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-destroy-shared-")); + const localBin = path.join(home, "bin"); + const registryDir = path.join(home, ".nemoclaw"); + const openshellLog = path.join(home, "openshell.log"); + const bashLog = path.join(home, "bash.log"); + fs.mkdirSync(localBin, { recursive: true }); + fs.mkdirSync(registryDir, { recursive: true }); + fs.writeFileSync( + path.join(registryDir, "sandboxes.json"), + JSON.stringify({ + sandboxes: { + alpha: { + name: "alpha", + model: "test-model", + provider: "nvidia-prod", + gpuEnabled: false, + policies: [], + }, + beta: { + name: "beta", + model: "test-model", + provider: "nvidia-prod", + gpuEnabled: false, + policies: [], + }, + }, + defaultSandbox: "alpha", + }), + { mode: 0o600 }, + ); + fs.writeFileSync( + path.join(localBin, "openshell"), + [ + "#!/bin/sh", + `log_file=${JSON.stringify(openshellLog)}`, + 'if [ "$1" = "sandbox" ] && [ "$2" = "list" ]; then', + ' printf "NAME STATUS\\nbeta Ready\\n" >> "$log_file"', + ' printf "NAME STATUS\\nbeta Ready\\n"', + " exit 0", + "fi", + 'printf \'%s\\n\' "$*" >> "$log_file"', + "exit 0", + ].join("\n"), + { mode: 0o755 }, + ); + fs.writeFileSync( + path.join(localBin, "bash"), + [ + "#!/bin/sh", + `log_file=${JSON.stringify(bashLog)}`, + 'printf \'%s\\n\' "$*" >> "$log_file"', + "exit 0", + ].join("\n"), + { mode: 0o755 }, + ); + + const r = runWithEnv("alpha destroy --yes", { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + }); + + expect(r.code).toBe(0); + expect(fs.readFileSync(openshellLog, "utf8")).toContain("sandbox delete alpha"); + expect(fs.readFileSync(openshellLog, "utf8")).not.toContain("forward stop 18789"); + expect(fs.readFileSync(openshellLog, "utf8")).not.toContain("gateway destroy -g nemoclaw"); + if (fs.existsSync(bashLog)) { + expect(fs.readFileSync(bashLog, "utf8")).not.toContain("docker volume ls -q --filter"); + } + }); + + it("keeps the gateway runtime when the live gateway still reports sandboxes", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-destroy-live-shared-")); + const localBin = path.join(home, "bin"); + const registryDir = path.join(home, ".nemoclaw"); + const openshellLog = path.join(home, "openshell.log"); + const bashLog = path.join(home, "bash.log"); + fs.mkdirSync(localBin, { recursive: true }); + fs.mkdirSync(registryDir, { recursive: true }); + fs.writeFileSync( + path.join(registryDir, "sandboxes.json"), + JSON.stringify({ + sandboxes: { + alpha: { + name: "alpha", + model: "test-model", + provider: "nvidia-prod", + gpuEnabled: false, + policies: [], + }, + }, + defaultSandbox: "alpha", + }), + { mode: 0o600 }, + ); + fs.writeFileSync( + path.join(localBin, "openshell"), + [ + "#!/bin/sh", + `log_file=${JSON.stringify(openshellLog)}`, + 'if [ "$1" = "sandbox" ] && [ "$2" = "list" ]; then', + ' printf "NAME STATUS\\nbeta Ready\\n" >> "$log_file"', + ' printf "NAME STATUS\\nbeta Ready\\n"', + " exit 0", + "fi", + 'printf \'%s\\n\' "$*" >> "$log_file"', + "exit 0", + ].join("\n"), + { mode: 0o755 }, + ); + fs.writeFileSync( + path.join(localBin, "bash"), + [ + "#!/bin/sh", + `log_file=${JSON.stringify(bashLog)}`, + 'printf \'%s\\n\' "$*" >> "$log_file"', + "exit 0", + ].join("\n"), + { mode: 0o755 }, + ); + + const r = runWithEnv("alpha destroy --yes", { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + }); + + expect(r.code).toBe(0); + expect(fs.readFileSync(openshellLog, "utf8")).toContain("sandbox delete alpha"); + expect(fs.readFileSync(openshellLog, "utf8")).toContain("beta Ready"); + expect(fs.readFileSync(openshellLog, "utf8")).not.toContain("forward stop 18789"); + expect(fs.readFileSync(openshellLog, "utf8")).not.toContain("gateway destroy -g nemoclaw"); + if (fs.existsSync(bashLog)) { + expect(fs.readFileSync(bashLog, "utf8")).not.toContain("docker volume ls -q --filter"); + } + }); + it("passes plain logs through without the tail flag", () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-logs-plain-")); const localBin = path.join(home, "bin"); diff --git a/test/nim.test.js b/test/nim.test.js index 71d62e8a5..44f613a55 100644 --- a/test/nim.test.js +++ b/test/nim.test.js @@ -88,6 +88,79 @@ describe("nim", () => { expect(gpu.name).toBeTruthy(); } }); + + it("detects GB10 unified-memory GPUs as Spark-capable NVIDIA devices", () => { + const runCapture = vi.fn((cmd) => { + if (cmd.includes("memory.total")) return ""; + if (cmd.includes("query-gpu=name")) return "NVIDIA GB10"; + if (cmd.includes("free -m")) return "131072"; + return ""; + }); + const { nimModule, restore } = loadNimWithMockedRunner(runCapture); + + try { + expect(nimModule.detectGpu()).toMatchObject({ + type: "nvidia", + name: "NVIDIA GB10", + count: 1, + totalMemoryMB: 131072, + perGpuMB: 131072, + nimCapable: true, + unifiedMemory: true, + spark: true, + }); + } finally { + restore(); + } + }); + + it("detects Orin unified-memory GPUs without marking them as Spark", () => { + const runCapture = vi.fn((cmd) => { + if (cmd.includes("memory.total")) return ""; + if (cmd.includes("query-gpu=name")) return "NVIDIA Jetson AGX Orin"; + if (cmd.includes("free -m")) return "32768"; + return ""; + }); + const { nimModule, restore } = loadNimWithMockedRunner(runCapture); + + try { + expect(nimModule.detectGpu()).toMatchObject({ + type: "nvidia", + name: "NVIDIA Jetson AGX Orin", + count: 1, + totalMemoryMB: 32768, + perGpuMB: 32768, + nimCapable: true, + unifiedMemory: true, + spark: false, + }); + } finally { + restore(); + } + }); + + it("marks low-memory unified-memory NVIDIA devices as not NIM-capable", () => { + const runCapture = vi.fn((cmd) => { + if (cmd.includes("memory.total")) return ""; + if (cmd.includes("query-gpu=name")) return "NVIDIA Xavier"; + if (cmd.includes("free -m")) return "4096"; + return ""; + }); + const { nimModule, restore } = loadNimWithMockedRunner(runCapture); + + try { + expect(nimModule.detectGpu()).toMatchObject({ + type: "nvidia", + name: "NVIDIA Xavier", + totalMemoryMB: 4096, + nimCapable: false, + unifiedMemory: true, + spark: false, + }); + } finally { + restore(); + } + }); }); describe("nimStatus", () => { diff --git a/test/policies.test.js b/test/policies.test.js index 960bf5aed..cd2840655 100644 --- a/test/policies.test.js +++ b/test/policies.test.js @@ -233,6 +233,21 @@ describe("policies", () => { const result = policies.parseCurrentPolicy(raw); expect(result).toBe("version: 1\nnetwork_policies: {}"); }); + + it("drops metadata-only or truncated policy reads", () => { + const raw = "Version: 3\nHash: abc123"; + expect(policies.parseCurrentPolicy(raw)).toBe(""); + }); + + it("drops non-policy error output instead of treating it as YAML", () => { + const raw = "Error: failed to parse sandbox policy YAML"; + expect(policies.parseCurrentPolicy(raw)).toBe(""); + }); + + it("drops syntactically invalid or truncated YAML bodies", () => { + const raw = "Version: 3\n---\nversion: 1\nnetwork_policies"; + expect(policies.parseCurrentPolicy(raw)).toBe(""); + }); }); describe("mergePresetIntoPolicy", () => { @@ -267,6 +282,18 @@ describe("policies", () => { expect(merged.startsWith("version: 1\n\nnetwork_policies:")).toBe(true); expect(merged).toContain("example.com"); }); + + it("rebuilds from a clean scaffold when current policy read is truncated", () => { + const merged = policies.mergePresetIntoPolicy("Version: 3\nHash: abc123", sampleEntries); + expect(merged).toBe( + "version: 1\n\nnetwork_policies:\n - host: example.com\n allow: true", + ); + }); + + it("adds a blank line after synthesized version headers", () => { + const merged = policies.mergePresetIntoPolicy("some_key:\n foo: bar", sampleEntries); + expect(merged.startsWith("version: 1\n\nsome_key:")).toBe(true); + }); }); describe("preset YAML schema", () => { diff --git a/test/service-env.test.js b/test/service-env.test.js index 0c5263b65..6b419898d 100644 --- a/test/service-env.test.js +++ b/test/service-env.test.js @@ -3,12 +3,52 @@ import { describe, it, expect } from "vitest"; import { execSync, execFileSync } from "node:child_process"; -import { writeFileSync, unlinkSync, readFileSync } from "node:fs"; +import { mkdtempSync, writeFileSync, unlinkSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { resolveOpenshell } from "../bin/lib/resolve-openshell"; describe("service environment", () => { + describe("start-services behavior", () => { + const scriptPath = join(import.meta.dirname, "../scripts/start-services.sh"); + + it("starts local-only services without NVIDIA_API_KEY", () => { + const workspace = mkdtempSync(join(tmpdir(), "nemoclaw-services-no-key-")); + const result = execFileSync("bash", [scriptPath], { + encoding: "utf-8", + env: { + ...process.env, + NVIDIA_API_KEY: "", + TELEGRAM_BOT_TOKEN: "", + SANDBOX_NAME: "test-box", + TMPDIR: workspace, + }, + }); + + expect(result).not.toContain("NVIDIA_API_KEY required"); + expect(result).toContain("TELEGRAM_BOT_TOKEN not set"); + expect(result).toContain("Telegram: not started (no token)"); + }); + + it("warns and skips Telegram bridge when token is set without NVIDIA_API_KEY", () => { + const workspace = mkdtempSync(join(tmpdir(), "nemoclaw-services-missing-key-")); + const result = execFileSync("bash", [scriptPath], { + encoding: "utf-8", + env: { + ...process.env, + NVIDIA_API_KEY: "", + TELEGRAM_BOT_TOKEN: "test-token", + SANDBOX_NAME: "test-box", + TMPDIR: workspace, + }, + }); + + expect(result).not.toContain("NVIDIA_API_KEY required"); + expect(result).toContain("NVIDIA_API_KEY not set"); + expect(result).toContain("Telegram: not started (no token)"); + }); + }); + describe("resolveOpenshell logic", () => { it("returns command -v result when absolute path", () => { expect(resolveOpenshell({ commandVResult: "/usr/bin/openshell" })).toBe("/usr/bin/openshell");