From ef21237767b1cea30604eaccb8bb5b3f31a0eaa5 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Wed, 1 Apr 2026 23:27:57 -0700 Subject: [PATCH 1/5] refactor(cli): migrate remaining unblocked CJS modules to TypeScript Migrate resolve-openshell.js to TypeScript and create two new TS modules (version.ts, chat-filter.ts), completing the "everything not blocked is converted" goal for #1298 / #924 shell consolidation. For each module: - TS implementation in nemoclaw/src/lib/ with typed interfaces - Thin CJS re-export shim in bin/lib/ pointing to compiled dist/ - Co-located tests importing from ../../dist/lib/ for coverage attribution Consumers updated: - bin/nemoclaw.js uses getVersion() for --version and help output - scripts/telegram-bridge.js uses parseAllowedChatIds/isChatAllowed Closes #1298 Co-Authored-By: Claude Opus 4.6 (1M context) --- bin/lib/chat-filter.js | 5 ++ bin/lib/resolve-openshell.js | 48 +---------- bin/lib/version.js | 5 ++ bin/nemoclaw.js | 9 ++- nemoclaw/src/lib/chat-filter.test.ts | 52 ++++++++++++ nemoclaw/src/lib/chat-filter.ts | 26 ++++++ nemoclaw/src/lib/resolve-openshell.test.ts | 94 ++++++++++++++++++++++ nemoclaw/src/lib/resolve-openshell.ts | 59 ++++++++++++++ nemoclaw/src/lib/version.test.ts | 53 ++++++++++++ nemoclaw/src/lib/version.ts | 55 +++++++++++++ scripts/telegram-bridge.js | 7 +- 11 files changed, 359 insertions(+), 54 deletions(-) create mode 100644 bin/lib/chat-filter.js create mode 100644 bin/lib/version.js create mode 100644 nemoclaw/src/lib/chat-filter.test.ts create mode 100644 nemoclaw/src/lib/chat-filter.ts create mode 100644 nemoclaw/src/lib/resolve-openshell.test.ts create mode 100644 nemoclaw/src/lib/resolve-openshell.ts create mode 100644 nemoclaw/src/lib/version.test.ts create mode 100644 nemoclaw/src/lib/version.ts diff --git a/bin/lib/chat-filter.js b/bin/lib/chat-filter.js new file mode 100644 index 000000000..da015595b --- /dev/null +++ b/bin/lib/chat-filter.js @@ -0,0 +1,5 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Thin CJS shim — implementation lives in nemoclaw/src/lib/chat-filter.ts +module.exports = require("../../nemoclaw/dist/lib/chat-filter.js"); diff --git a/bin/lib/resolve-openshell.js b/bin/lib/resolve-openshell.js index 345e218e4..603a9bc02 100644 --- a/bin/lib/resolve-openshell.js +++ b/bin/lib/resolve-openshell.js @@ -1,49 +1,5 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -const { execSync } = require("child_process"); -const fs = require("fs"); - -/** - * Resolve the openshell binary path. - * - * Checks `command -v` first (must return an absolute path to prevent alias - * injection), then falls back to common installation directories. - * - * @param {object} [opts] DI overrides for testing - * @param {string|null} [opts.commandVResult] Mock result (undefined = run real command) - * @param {function} [opts.checkExecutable] (path) => boolean - * @param {string} [opts.home] HOME override - * @returns {string|null} Absolute path to openshell, or null if not found - */ -function resolveOpenshell(opts = {}) { - const home = opts.home ?? process.env.HOME; - - // Step 1: command -v - if (opts.commandVResult === undefined) { - try { - const found = execSync("command -v openshell", { encoding: "utf-8" }).trim(); - if (found.startsWith("/")) return found; - } catch { /* ignored */ } - } else if (opts.commandVResult && opts.commandVResult.startsWith("/")) { - return opts.commandVResult; - } - - // Step 2: fallback candidates - const checkExecutable = opts.checkExecutable || ((p) => { - try { fs.accessSync(p, fs.constants.X_OK); return true; } catch { return false; } - }); - - const candidates = [ - ...(home && home.startsWith("/") ? [`${home}/.local/bin/openshell`] : []), - "/usr/local/bin/openshell", - "/usr/bin/openshell", - ]; - for (const p of candidates) { - if (checkExecutable(p)) return p; - } - - return null; -} - -module.exports = { resolveOpenshell }; +// Thin CJS shim — implementation lives in nemoclaw/src/lib/resolve-openshell.ts +module.exports = require("../../nemoclaw/dist/lib/resolve-openshell.js"); diff --git a/bin/lib/version.js b/bin/lib/version.js new file mode 100644 index 000000000..b3dae1cd8 --- /dev/null +++ b/bin/lib/version.js @@ -0,0 +1,5 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Thin CJS shim — implementation lives in nemoclaw/src/lib/version.ts +module.exports = require("../../nemoclaw/dist/lib/version.js"); diff --git a/bin/nemoclaw.js b/bin/nemoclaw.js index 868b00b83..16200e912 100755 --- a/bin/nemoclaw.js +++ b/bin/nemoclaw.js @@ -31,6 +31,7 @@ const registry = require("./lib/registry"); const nim = require("./lib/nim"); const policies = require("./lib/policies"); const { parseGatewayInference } = require("./lib/inference-config"); +const { getVersion } = require("./lib/version"); // ── Global commands ────────────────────────────────────────────── @@ -399,9 +400,9 @@ async function sandboxDestroy(sandboxName, args = []) { // ── Help ───────────────────────────────────────────────────────── function help() { - const pkg = require(path.join(__dirname, "..", "package.json")); + const { version } = getVersion(); console.log(` - ${B}${G}NemoClaw${R} ${D}v${pkg.version}${R} + ${B}${G}NemoClaw${R} ${D}v${version}${R} ${D}Deploy more secure, always-on AI assistants with a single command.${R} ${G}Getting Started:${R} @@ -471,8 +472,8 @@ const [cmd, ...args] = process.argv.slice(2); case "list": listSandboxes(); break; case "--version": case "-v": { - const pkg = require(path.join(__dirname, "..", "package.json")); - console.log(`nemoclaw v${pkg.version}`); + const { version } = getVersion(); + console.log(`nemoclaw v${version}`); break; } default: help(); break; diff --git a/nemoclaw/src/lib/chat-filter.test.ts b/nemoclaw/src/lib/chat-filter.test.ts new file mode 100644 index 000000000..963d72651 --- /dev/null +++ b/nemoclaw/src/lib/chat-filter.test.ts @@ -0,0 +1,52 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, it, expect } from "vitest"; +import { parseAllowedChatIds, isChatAllowed } from "../../dist/lib/chat-filter.js"; + +describe("lib/chat-filter", () => { + describe("parseAllowedChatIds", () => { + it("returns null for undefined input", () => { + expect(parseAllowedChatIds(undefined)).toBeNull(); + }); + + it("returns null for empty string", () => { + expect(parseAllowedChatIds("")).toBeNull(); + }); + + it("returns null for whitespace-only string", () => { + expect(parseAllowedChatIds(" , , ")).toBeNull(); + }); + + it("parses single chat ID", () => { + const result = parseAllowedChatIds("12345"); + expect(result).toEqual(new Set(["12345"])); + }); + + it("parses comma-separated chat IDs with whitespace", () => { + const result = parseAllowedChatIds("111, 222 ,333"); + expect(result).toEqual(new Set(["111", "222", "333"])); + }); + + it("deduplicates repeated IDs", () => { + const result = parseAllowedChatIds("111,111,222"); + expect(result).toEqual(new Set(["111", "222"])); + }); + }); + + describe("isChatAllowed", () => { + it("allows all chats when allowed set is null", () => { + expect(isChatAllowed("999", null)).toBe(true); + }); + + it("allows chat in the allowed set", () => { + const allowed = new Set(["111", "222"]); + expect(isChatAllowed("111", allowed)).toBe(true); + }); + + it("rejects chat not in the allowed set", () => { + const allowed = new Set(["111", "222"]); + expect(isChatAllowed("999", allowed)).toBe(false); + }); + }); +}); diff --git a/nemoclaw/src/lib/chat-filter.ts b/nemoclaw/src/lib/chat-filter.ts new file mode 100644 index 000000000..e844b37f3 --- /dev/null +++ b/nemoclaw/src/lib/chat-filter.ts @@ -0,0 +1,26 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Parse a comma-separated list of allowed chat IDs into a Set. + * Returns null if the input is empty or undefined (meaning: accept all). + */ +export function parseAllowedChatIds(raw: string | undefined): Set | null { + if (!raw) return null; + const ids = raw + .split(",") + .map((s) => s.trim()) + .filter((s) => s.length > 0); + return ids.length > 0 ? new Set(ids) : null; +} + +/** + * Check whether a chat ID is allowed. + * + * When `allowed` is null every chat is accepted (open mode). + * Otherwise the chat ID must be in the allowed set. + */ +export function isChatAllowed(chatId: string, allowed: Set | null): boolean { + if (allowed === null) return true; + return allowed.has(chatId); +} diff --git a/nemoclaw/src/lib/resolve-openshell.test.ts b/nemoclaw/src/lib/resolve-openshell.test.ts new file mode 100644 index 000000000..8035365b0 --- /dev/null +++ b/nemoclaw/src/lib/resolve-openshell.test.ts @@ -0,0 +1,94 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, it, expect } from "vitest"; +import { resolveOpenshell } from "../../dist/lib/resolve-openshell.js"; + +describe("lib/resolve-openshell", () => { + it("returns command -v result when absolute path", () => { + expect(resolveOpenshell({ commandVResult: "/usr/bin/openshell" })).toBe("/usr/bin/openshell"); + }); + + it("rejects non-absolute command -v result (alias)", () => { + expect( + resolveOpenshell({ commandVResult: "openshell", checkExecutable: () => false }), + ).toBeNull(); + }); + + it("rejects alias definition from command -v", () => { + expect( + resolveOpenshell({ + commandVResult: "alias openshell='echo pwned'", + checkExecutable: () => false, + }), + ).toBeNull(); + }); + + it("falls back to ~/.local/bin when command -v fails", () => { + expect( + resolveOpenshell({ + commandVResult: null, + checkExecutable: (p) => p === "/fakehome/.local/bin/openshell", + home: "/fakehome", + }), + ).toBe("/fakehome/.local/bin/openshell"); + }); + + it("falls back to /usr/local/bin", () => { + expect( + resolveOpenshell({ + commandVResult: null, + checkExecutable: (p) => p === "/usr/local/bin/openshell", + }), + ).toBe("/usr/local/bin/openshell"); + }); + + it("falls back to /usr/bin", () => { + expect( + resolveOpenshell({ + commandVResult: null, + checkExecutable: (p) => p === "/usr/bin/openshell", + }), + ).toBe("/usr/bin/openshell"); + }); + + it("prefers ~/.local/bin over /usr/local/bin", () => { + expect( + resolveOpenshell({ + commandVResult: null, + checkExecutable: (p) => + p === "/fakehome/.local/bin/openshell" || p === "/usr/local/bin/openshell", + home: "/fakehome", + }), + ).toBe("/fakehome/.local/bin/openshell"); + }); + + it("returns null when openshell not found anywhere", () => { + expect( + resolveOpenshell({ + commandVResult: null, + checkExecutable: () => false, + }), + ).toBeNull(); + }); + + it("skips home candidate when home is not absolute", () => { + expect( + resolveOpenshell({ + commandVResult: null, + checkExecutable: () => false, + home: "relative/path", + }), + ).toBeNull(); + }); + + it("returns null for null commandVResult with no executable found", () => { + expect( + resolveOpenshell({ + commandVResult: null, + checkExecutable: () => false, + home: undefined, + }), + ).toBeNull(); + }); +}); diff --git a/nemoclaw/src/lib/resolve-openshell.ts b/nemoclaw/src/lib/resolve-openshell.ts new file mode 100644 index 000000000..b55fbfac8 --- /dev/null +++ b/nemoclaw/src/lib/resolve-openshell.ts @@ -0,0 +1,59 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { execSync } from "node:child_process"; +import { accessSync, constants } from "node:fs"; + +export interface ResolveOpenshellOptions { + /** Mock result for `command -v` (undefined = run real command). */ + commandVResult?: string | null; + /** Override executable check (default: fs.accessSync X_OK). */ + checkExecutable?: (path: string) => boolean; + /** HOME directory override. */ + home?: string; +} + +/** + * Resolve the openshell binary path. + * + * Checks `command -v` first (must return an absolute path to prevent alias + * injection), then falls back to common installation directories. + */ +export function resolveOpenshell(opts: ResolveOpenshellOptions = {}): string | null { + const home = opts.home ?? process.env.HOME; + + // Step 1: command -v + if (opts.commandVResult === undefined) { + try { + const found = execSync("command -v openshell", { encoding: "utf-8" }).trim(); + if (found.startsWith("/")) return found; + } catch { + /* ignored */ + } + } else if (opts.commandVResult?.startsWith("/")) { + return opts.commandVResult; + } + + // Step 2: fallback candidates + const checkExecutable = + opts.checkExecutable ?? + ((p: string): boolean => { + try { + accessSync(p, constants.X_OK); + return true; + } catch { + return false; + } + }); + + const candidates = [ + ...(home?.startsWith("/") ? [`${home}/.local/bin/openshell`] : []), + "/usr/local/bin/openshell", + "/usr/bin/openshell", + ]; + for (const p of candidates) { + if (checkExecutable(p)) return p; + } + + return null; +} diff --git a/nemoclaw/src/lib/version.test.ts b/nemoclaw/src/lib/version.test.ts new file mode 100644 index 000000000..fca84716b --- /dev/null +++ b/nemoclaw/src/lib/version.test.ts @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { getVersion } from "../../dist/lib/version.js"; + +const store = new Map(); + +vi.mock("node:fs", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + readFileSync: (p: string, _enc: string) => { + const content = store.get(p); + if (content === undefined) throw new Error(`ENOENT: ${p}`); + return content; + }, + }; +}); + +describe("lib/version", () => { + beforeEach(() => { + store.clear(); + }); + + it("reads version from package.json", () => { + store.set("/test-dir/package.json", JSON.stringify({ version: "1.2.3" })); + const info = getVersion({ packageDir: "/test-dir", gitDescribeResult: null }); + expect(info.version).toBe("1.2.3"); + expect(info.gitDescribe).toBeNull(); + expect(info.display).toBe("1.2.3"); + }); + + it("includes git describe when available", () => { + store.set("/test-dir/package.json", JSON.stringify({ version: "1.2.3" })); + const info = getVersion({ packageDir: "/test-dir", gitDescribeResult: "v1.2.3-5-gabcdef" }); + expect(info.version).toBe("1.2.3"); + expect(info.gitDescribe).toBe("v1.2.3-5-gabcdef"); + expect(info.display).toBe("1.2.3 (v1.2.3-5-gabcdef)"); + }); + + it("handles dirty git state", () => { + store.set("/test-dir/package.json", JSON.stringify({ version: "0.1.0" })); + const info = getVersion({ packageDir: "/test-dir", gitDescribeResult: "v0.1.0-dirty" }); + expect(info.display).toBe("0.1.0 (v0.1.0-dirty)"); + }); + + it("returns version without suffix when gitDescribe is null", () => { + store.set("/test-dir/package.json", JSON.stringify({ version: "2.0.0" })); + const info = getVersion({ packageDir: "/test-dir", gitDescribeResult: null }); + expect(info.display).toBe("2.0.0"); + }); +}); diff --git a/nemoclaw/src/lib/version.ts b/nemoclaw/src/lib/version.ts new file mode 100644 index 000000000..66325056c --- /dev/null +++ b/nemoclaw/src/lib/version.ts @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { execSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +export interface VersionInfo { + /** Semver from package.json (e.g. "0.1.0"). */ + version: string; + /** Git describe tag, or null if not in a git repo / git unavailable. */ + gitDescribe: string | null; + /** Display string: version + git describe suffix when available. */ + display: string; +} + +export interface VersionOptions { + /** Override the directory containing package.json. */ + packageDir?: string; + /** Mock git describe output (undefined = run real command). */ + gitDescribeResult?: string | null; +} + +/** + * Read the CLI version from package.json and optionally enrich with + * `git describe --tags --always --dirty` for dev builds. + */ +export function getVersion(opts: VersionOptions = {}): VersionInfo { + // Compiled location: nemoclaw/dist/lib/version.js → repo root is 3 levels up + const thisDir = dirname(fileURLToPath(import.meta.url)); + const packageDir = opts.packageDir ?? join(thisDir, "..", "..", ".."); + const raw = readFileSync(join(packageDir, "package.json"), "utf-8"); + const pkg = JSON.parse(raw) as { version: string }; + const version = pkg.version; + + let gitDescribe: string | null = null; + if (opts.gitDescribeResult !== undefined) { + gitDescribe = opts.gitDescribeResult; + } else { + try { + gitDescribe = execSync("git describe --tags --always --dirty", { + encoding: "utf-8", + cwd: packageDir, + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + } catch { + /* not in a git repo or git unavailable */ + } + } + + const display = gitDescribe ? `${version} (${gitDescribe})` : version; + + return { version, gitDescribe, display }; +} diff --git a/scripts/telegram-bridge.js b/scripts/telegram-bridge.js index c51a5529a..938144e06 100755 --- a/scripts/telegram-bridge.js +++ b/scripts/telegram-bridge.js @@ -20,6 +20,7 @@ const https = require("https"); const { execFileSync, spawn } = require("child_process"); const { resolveOpenshell } = require("../bin/lib/resolve-openshell"); const { shellQuote, validateName } = require("../bin/lib/runner"); +const { parseAllowedChatIds, isChatAllowed } = require("../bin/lib/chat-filter"); const OPENSHELL = resolveOpenshell(); if (!OPENSHELL) { @@ -31,9 +32,7 @@ const TOKEN = process.env.TELEGRAM_BOT_TOKEN; const API_KEY = process.env.NVIDIA_API_KEY; const SANDBOX = process.env.SANDBOX_NAME || "nemoclaw"; try { validateName(SANDBOX, "SANDBOX_NAME"); } catch (e) { console.error(e.message); process.exit(1); } -const ALLOWED_CHATS = process.env.ALLOWED_CHAT_IDS - ? process.env.ALLOWED_CHAT_IDS.split(",").map((s) => s.trim()) - : null; +const ALLOWED_CHATS = parseAllowedChatIds(process.env.ALLOWED_CHAT_IDS); if (!TOKEN) { console.error("TELEGRAM_BOT_TOKEN required"); process.exit(1); } if (!API_KEY) { console.error("NVIDIA_API_KEY required"); process.exit(1); } @@ -170,7 +169,7 @@ async function poll() { const chatId = String(msg.chat.id); // Access control - if (ALLOWED_CHATS && !ALLOWED_CHATS.includes(chatId)) { + if (!isChatAllowed(chatId, ALLOWED_CHATS)) { console.log(`[ignored] chat ${chatId} not in allowed list`); continue; } From 419068f94d6382ee6b700a83727bb9a4c7eb2856 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Wed, 1 Apr 2026 23:27:57 -0700 Subject: [PATCH 2/5] refactor(cli): migrate remaining unblocked CJS modules to TypeScript Migrate resolve-openshell.js to TypeScript and create two new TS modules (version.ts, chat-filter.ts), completing the "everything not blocked is converted" goal for #1298 / #924 shell consolidation. For each module: - TS implementation in nemoclaw/src/lib/ with typed interfaces - Thin CJS re-export shim in bin/lib/ pointing to compiled dist/ - Co-located tests importing from ../../dist/lib/ for coverage attribution Consumers updated: - bin/nemoclaw.js uses getVersion() for --version and help output - scripts/telegram-bridge.js uses parseAllowedChatIds/isChatAllowed Closes #1298 Co-Authored-By: Claude Opus 4.6 (1M context) --- bin/lib/chat-filter.js | 28 +- bin/lib/resolve-openshell.js | 57 +- bin/lib/version.js | 42 +- bin/nemoclaw.js | 956 ++------------------- nemoclaw/src/lib/chat-filter.test.ts | 52 ++ nemoclaw/src/lib/chat-filter.ts | 26 + nemoclaw/src/lib/resolve-openshell.test.ts | 94 ++ nemoclaw/src/lib/resolve-openshell.ts | 59 ++ nemoclaw/src/lib/version.test.ts | 53 ++ nemoclaw/src/lib/version.ts | 55 ++ scripts/telegram-bridge.js | 30 +- 11 files changed, 431 insertions(+), 1021 deletions(-) create mode 100644 nemoclaw/src/lib/chat-filter.test.ts create mode 100644 nemoclaw/src/lib/chat-filter.ts create mode 100644 nemoclaw/src/lib/resolve-openshell.test.ts create mode 100644 nemoclaw/src/lib/resolve-openshell.ts create mode 100644 nemoclaw/src/lib/version.test.ts create mode 100644 nemoclaw/src/lib/version.ts diff --git a/bin/lib/chat-filter.js b/bin/lib/chat-filter.js index 6a8f72382..da015595b 100644 --- a/bin/lib/chat-filter.js +++ b/bin/lib/chat-filter.js @@ -1,29 +1,5 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -/** - * Parse and filter Telegram chat IDs from the ALLOWED_CHAT_IDS env var. - * - * @param {string} [raw] - Comma-separated chat IDs (undefined = allow all) - * @returns {string[] | null} Array of allowed chat IDs, or null to allow all - */ -function parseAllowedChatIds(raw) { - if (!raw) return null; - return raw - .split(",") - .map((s) => s.trim()) - .filter(Boolean); -} - -/** - * Check whether a chat ID is allowed by the parsed allowlist. - * - * @param {string[] | null} allowedChats - Output of parseAllowedChatIds - * @param {string} chatId - The chat ID to check - * @returns {boolean} - */ -function isChatAllowed(allowedChats, chatId) { - return !allowedChats || allowedChats.includes(chatId); -} - -module.exports = { parseAllowedChatIds, isChatAllowed }; +// Thin CJS shim — implementation lives in nemoclaw/src/lib/chat-filter.ts +module.exports = require("../../nemoclaw/dist/lib/chat-filter.js"); diff --git a/bin/lib/resolve-openshell.js b/bin/lib/resolve-openshell.js index 1f80f8685..603a9bc02 100644 --- a/bin/lib/resolve-openshell.js +++ b/bin/lib/resolve-openshell.js @@ -1,58 +1,5 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -const { execSync } = require("child_process"); -const fs = require("fs"); - -/** - * Resolve the openshell binary path. - * - * Checks `command -v` first (must return an absolute path to prevent alias - * injection), then falls back to common installation directories. - * - * @param {object} [opts] DI overrides for testing - * @param {string|null} [opts.commandVResult] Mock result (undefined = run real command) - * @param {function} [opts.checkExecutable] (path) => boolean - * @param {string} [opts.home] HOME override - * @returns {string|null} Absolute path to openshell, or null if not found - */ -function resolveOpenshell(opts = {}) { - const home = opts.home ?? process.env.HOME; - - // Step 1: command -v - if (opts.commandVResult === undefined) { - try { - const found = execSync("command -v openshell", { encoding: "utf-8" }).trim(); - if (found.startsWith("/")) return found; - } catch { - /* ignored */ - } - } else if (opts.commandVResult && opts.commandVResult.startsWith("/")) { - return opts.commandVResult; - } - - // Step 2: fallback candidates - const checkExecutable = - opts.checkExecutable || - ((p) => { - try { - fs.accessSync(p, fs.constants.X_OK); - return true; - } catch { - return false; - } - }); - - const candidates = [ - ...(home && home.startsWith("/") ? [`${home}/.local/bin/openshell`] : []), - "/usr/local/bin/openshell", - "/usr/bin/openshell", - ]; - for (const p of candidates) { - if (checkExecutable(p)) return p; - } - - return null; -} - -module.exports = { resolveOpenshell }; +// Thin CJS shim — implementation lives in nemoclaw/src/lib/resolve-openshell.ts +module.exports = require("../../nemoclaw/dist/lib/resolve-openshell.js"); diff --git a/bin/lib/version.js b/bin/lib/version.js index 2aabb638d..b3dae1cd8 100644 --- a/bin/lib/version.js +++ b/bin/lib/version.js @@ -1,43 +1,5 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -/** - * Resolve the NemoClaw version from (in order): - * 1. `git describe --tags --match "v*"` — works in dev / source checkouts - * 2. `.version` file at repo root — stamped at publish time - * 3. `package.json` version — hard-coded fallback - */ - -const { execFileSync } = require("child_process"); -const path = require("path"); -const fs = require("fs"); - -const ROOT = path.resolve(__dirname, "..", ".."); - -function getVersion() { - // 1. Try git (available in dev clones and CI) - try { - const raw = execFileSync("git", ["describe", "--tags", "--match", "v*"], { - cwd: ROOT, - encoding: "utf-8", - stdio: ["ignore", "pipe", "ignore"], - }).trim(); - // raw looks like "v0.3.0" or "v0.3.0-4-gabcdef1" - if (raw) return raw.replace(/^v/, ""); - } catch { - // no git, or no matching tags — fall through - } - - // 2. Try .version file (stamped by prepublishOnly) - try { - const ver = fs.readFileSync(path.join(ROOT, ".version"), "utf-8").trim(); - if (ver) return ver; - } catch { - // not present — fall through - } - - // 3. Fallback to package.json - return require(path.join(ROOT, "package.json")).version; -} - -module.exports = { getVersion }; +// Thin CJS shim — implementation lives in nemoclaw/src/lib/version.ts +module.exports = require("../../nemoclaw/dist/lib/version.js"); diff --git a/bin/nemoclaw.js b/bin/nemoclaw.js index ba21a2aa4..16200e912 100755 --- a/bin/nemoclaw.js +++ b/bin/nemoclaw.js @@ -12,8 +12,7 @@ const os = require("os"); // Uses exact NVIDIA green #76B900 on truecolor terminals; 256-color otherwise. // --------------------------------------------------------------------------- const _useColor = !process.env.NO_COLOR && !!process.stdout.isTTY; -const _tc = - _useColor && (process.env.COLORTERM === "truecolor" || process.env.COLORTERM === "24bit"); +const _tc = _useColor && (process.env.COLORTERM === "truecolor" || process.env.COLORTERM === "24bit"); const G = _useColor ? (_tc ? "\x1b[38;2;118;185;0m" : "\x1b[38;5;148m") : ""; const B = _useColor ? "\x1b[1m" : ""; const D = _useColor ? "\x1b[2m" : ""; @@ -21,17 +20,7 @@ const R = _useColor ? "\x1b[0m" : ""; const _RD = _useColor ? "\x1b[1;31m" : ""; const YW = _useColor ? "\x1b[1;33m" : ""; -const { - ROOT, - SCRIPTS, - run, - runCapture: _runCapture, - runInteractive, - shellQuote, - validateName, -} = require("./lib/runner"); -const { resolveOpenshell } = require("./lib/resolve-openshell"); -const { startGatewayForRecovery } = require("./lib/onboard"); +const { ROOT, SCRIPTS, run, runCapture, runInteractive, shellQuote, validateName } = require("./lib/runner"); const { ensureApiKey, ensureGithubToken, @@ -43,558 +32,22 @@ const nim = require("./lib/nim"); const policies = require("./lib/policies"); const { parseGatewayInference } = require("./lib/inference-config"); const { getVersion } = require("./lib/version"); -const onboardSession = require("./lib/onboard-session"); -const { parseLiveSandboxNames } = require("./lib/runtime-recovery"); // ── Global commands ────────────────────────────────────────────── const GLOBAL_COMMANDS = new Set([ - "onboard", - "list", - "deploy", - "setup", - "setup-spark", - "start", - "stop", - "status", - "debug", - "uninstall", - "help", - "--help", - "-h", - "--version", - "-v", + "onboard", "list", "deploy", "setup", "setup-spark", + "start", "stop", "status", "debug", "uninstall", + "help", "--help", "-h", "--version", "-v", ]); -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) { - OPENSHELL_BIN = resolveOpenshell(); - } - if (!OPENSHELL_BIN) { - console.error("openshell CLI not found. Install OpenShell before using sandbox commands."); - process.exit(1); - } - return OPENSHELL_BIN; -} - -function runOpenshell(args, opts = {}) { - const result = spawnSync(getOpenshellBinary(), args, { - cwd: ROOT, - env: { ...process.env, ...opts.env }, - encoding: "utf-8", - stdio: opts.stdio ?? "inherit", - }); - if (result.status !== 0 && !opts.ignoreError) { - console.error(` Command failed (exit ${result.status}): openshell ${args.join(" ")}`); - process.exit(result.status || 1); - } - return result; -} - -function captureOpenshell(args, opts = {}) { - const result = spawnSync(getOpenshellBinary(), args, { - cwd: ROOT, - env: { ...process.env, ...opts.env }, - encoding: "utf-8", - stdio: ["ignore", "pipe", "pipe"], - }); - return { - status: result.status ?? 1, - output: `${result.stdout || ""}${opts.ignoreError ? "" : result.stderr || ""}`.trim(), - }; -} - -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 isMissingSandboxDeleteResult(output = "") { - return /\bNotFound\b|\bNot Found\b|sandbox not found|sandbox .* not found|sandbox .* not present|sandbox does not exist|no such sandbox/i.test( - stripAnsi(output), - ); -} - -function getSandboxDeleteOutcome(deleteResult) { - const output = `${deleteResult.stdout || ""}${deleteResult.stderr || ""}`.trim(); - return { - output, - alreadyGone: deleteResult.status !== 0 && isMissingSandboxDeleteResult(output), - }; -} - -function parseVersionFromText(value = "") { - const match = String(value || "").match(/([0-9]+\.[0-9]+\.[0-9]+)/); - return match ? match[1] : null; -} - -function versionGte(left = "0.0.0", right = "0.0.0") { - const lhs = String(left) - .split(".") - .map((part) => Number.parseInt(part, 10) || 0); - const rhs = String(right) - .split(".") - .map((part) => Number.parseInt(part, 10) || 0); - const length = Math.max(lhs.length, rhs.length); - for (let index = 0; index < length; index += 1) { - const a = lhs[index] || 0; - const b = rhs[index] || 0; - if (a > b) return true; - if (a < b) return false; - } - return true; -} - -function getInstalledOpenshellVersion() { - const versionResult = captureOpenshell(["--version"], { ignoreError: true }); - return parseVersionFromText(versionResult.output); -} - -function stripAnsi(value = "") { - // eslint-disable-next-line no-control-regex - return String(value).replace(/\x1b\[[0-9;]*m/g, ""); -} - -function buildRecoveredSandboxEntry(name, metadata = {}) { - return { - name, - model: metadata.model || null, - provider: metadata.provider || null, - gpuEnabled: metadata.gpuEnabled === true, - policies: Array.isArray(metadata.policies) - ? metadata.policies - : Array.isArray(metadata.policyPresets) - ? metadata.policyPresets - : [], - nimContainer: metadata.nimContainer || null, - }; -} - -function upsertRecoveredSandbox(name, metadata = {}) { - let validName; - try { - validName = validateName(name, "sandbox name"); - } catch { - return false; - } - - const entry = buildRecoveredSandboxEntry(validName, metadata); - if (registry.getSandbox(validName)) { - registry.updateSandbox(validName, entry); - return false; - } - registry.registerSandbox(entry); - return true; -} - -function shouldRecoverRegistryEntries(current, session, requestedSandboxName) { - const hasSessionSandbox = Boolean(session?.sandboxName); - const missingSessionSandbox = - hasSessionSandbox && !current.sandboxes.some((sandbox) => sandbox.name === session.sandboxName); - const missingRequestedSandbox = - Boolean(requestedSandboxName) && - !current.sandboxes.some((sandbox) => sandbox.name === requestedSandboxName); - const hasRecoverySeed = - current.sandboxes.length > 0 || hasSessionSandbox || Boolean(requestedSandboxName); - return { - missingRequestedSandbox, - shouldRecover: - hasRecoverySeed && - (current.sandboxes.length === 0 || missingRequestedSandbox || missingSessionSandbox), - }; -} - -function seedRecoveryMetadata(current, session, requestedSandboxName) { - const metadataByName = new Map(current.sandboxes.map((sandbox) => [sandbox.name, sandbox])); - let recoveredFromSession = false; - - if (!session?.sandboxName) { - return { metadataByName, recoveredFromSession }; - } - - metadataByName.set( - session.sandboxName, - buildRecoveredSandboxEntry(session.sandboxName, { - model: session.model || null, - provider: session.provider || null, - nimContainer: session.nimContainer || null, - policyPresets: session.policyPresets || null, - }), - ); - const sessionSandboxMissing = !current.sandboxes.some( - (sandbox) => sandbox.name === session.sandboxName, - ); - const shouldRecoverSessionSandbox = - current.sandboxes.length === 0 || - sessionSandboxMissing || - requestedSandboxName === session.sandboxName; - if (shouldRecoverSessionSandbox) { - recoveredFromSession = upsertRecoveredSandbox( - session.sandboxName, - metadataByName.get(session.sandboxName), - ); - } - return { metadataByName, recoveredFromSession }; -} - -async function recoverRegistryFromLiveGateway(metadataByName) { - if (!resolveOpenshell()) { - return 0; - } - const recovery = await recoverNamedGatewayRuntime(); - const canInspectLiveGateway = - recovery.recovered || - recovery.before?.state === "healthy_named" || - recovery.after?.state === "healthy_named"; - if (!canInspectLiveGateway) { - return 0; - } - - let recoveredFromGateway = 0; - const liveList = captureOpenshell(["sandbox", "list"], { ignoreError: true }); - const liveNames = Array.from(parseLiveSandboxNames(liveList.output)); - for (const name of liveNames) { - const metadata = metadataByName.get(name) || {}; - if (upsertRecoveredSandbox(name, metadata)) { - recoveredFromGateway += 1; - } - } - return recoveredFromGateway; -} - -function applyRecoveredDefault(currentDefaultSandbox, requestedSandboxName, session) { - const recovered = registry.listSandboxes(); - const preferredDefault = - requestedSandboxName || (!currentDefaultSandbox ? session?.sandboxName || null : null); - if ( - preferredDefault && - recovered.sandboxes.some((sandbox) => sandbox.name === preferredDefault) - ) { - registry.setDefault(preferredDefault); - } - return registry.listSandboxes(); -} - -async function recoverRegistryEntries({ requestedSandboxName = null } = {}) { - const current = registry.listSandboxes(); - const session = onboardSession.loadSession(); - const recoveryCheck = shouldRecoverRegistryEntries(current, session, requestedSandboxName); - if (!recoveryCheck.shouldRecover) { - return { ...current, recoveredFromSession: false, recoveredFromGateway: 0 }; - } - - const seeded = seedRecoveryMetadata(current, session, requestedSandboxName); - const shouldProbeLiveGateway = current.sandboxes.length > 0 || Boolean(session?.sandboxName); - const recoveredFromGateway = shouldProbeLiveGateway - ? await recoverRegistryFromLiveGateway(seeded.metadataByName) - : 0; - const recovered = applyRecoveredDefault(current.defaultSandbox, requestedSandboxName, session); - return { - ...recovered, - recoveredFromSession: seeded.recoveredFromSession, - recoveredFromGateway, - }; -} - -function hasNamedGateway(output = "") { - return stripAnsi(output).includes("Gateway: nemoclaw"); -} - -function getActiveGatewayName(output = "") { - const match = stripAnsi(output).match(/^\s*Gateway:\s+(.+?)\s*$/m); - return match ? match[1].trim() : ""; -} - -function getNamedGatewayLifecycleState() { - const status = captureOpenshell(["status"]); - const gatewayInfo = captureOpenshell(["gateway", "info", "-g", "nemoclaw"]); - const cleanStatus = stripAnsi(status.output); - const activeGateway = getActiveGatewayName(status.output); - const connected = /^\s*Status:\s*Connected\b/im.test(cleanStatus); - const named = hasNamedGateway(gatewayInfo.output); - const refusing = /Connection refused|client error \(Connect\)|tcp connect error/i.test( - cleanStatus, - ); - if (connected && activeGateway === "nemoclaw" && named) { - return { state: "healthy_named", status: status.output, gatewayInfo: gatewayInfo.output }; - } - if (activeGateway === "nemoclaw" && named && refusing) { - return { state: "named_unreachable", status: status.output, gatewayInfo: gatewayInfo.output }; - } - if (activeGateway === "nemoclaw" && named) { - return { state: "named_unhealthy", status: status.output, gatewayInfo: gatewayInfo.output }; - } - if (connected) { - return { state: "connected_other", status: status.output, gatewayInfo: gatewayInfo.output }; - } - return { state: "missing_named", status: status.output, gatewayInfo: gatewayInfo.output }; -} - -async function recoverNamedGatewayRuntime() { - const before = getNamedGatewayLifecycleState(); - if (before.state === "healthy_named") { - return { recovered: true, before, after: before, attempted: false }; - } - - runOpenshell(["gateway", "select", "nemoclaw"], { ignoreError: true }); - let after = getNamedGatewayLifecycleState(); - if (after.state === "healthy_named") { - process.env.OPENSHELL_GATEWAY = "nemoclaw"; - return { recovered: true, before, after, attempted: true, via: "select" }; - } - - const shouldStartGateway = [before.state, after.state].some((state) => - ["missing_named", "named_unhealthy", "named_unreachable", "connected_other"].includes(state), - ); - - if (shouldStartGateway) { - try { - await startGatewayForRecovery(); - } catch { - // Fall through to the lifecycle re-check below so we preserve the - // existing recovery result shape and emit the correct classification. - } - runOpenshell(["gateway", "select", "nemoclaw"], { ignoreError: true }); - after = getNamedGatewayLifecycleState(); - if (after.state === "healthy_named") { - process.env.OPENSHELL_GATEWAY = "nemoclaw"; - return { recovered: true, before, after, attempted: true, via: "start" }; - } - } - - return { recovered: false, before, after, attempted: true }; -} - -function getSandboxGatewayState(sandboxName) { - const result = captureOpenshell(["sandbox", "get", sandboxName]); - const output = result.output; - if (result.status === 0) { - return { state: "present", output }; - } - if (/\bNotFound\b|\bNot Found\b|sandbox not found/i.test(output)) { - return { state: "missing", output }; - } - if ( - /transport error|Connection refused|handshake verification failed|Missing gateway auth token|device identity required/i.test( - output, - ) - ) { - return { state: "gateway_error", output }; - } - return { state: "unknown_error", output }; -} - -function printGatewayLifecycleHint(output = "", sandboxName = "", writer = console.error) { - const cleanOutput = stripAnsi(output); - if (/No gateway configured/i.test(cleanOutput)) { - writer( - " The selected NemoClaw gateway is no longer configured or its metadata/runtime has been lost.", - ); - writer( - " Start the gateway again with `openshell gateway start --name nemoclaw` before expecting existing sandboxes to reconnect.", - ); - writer( - " If the gateway has to be rebuilt from scratch, recreate the affected sandbox afterward.", - ); - return; - } - if ( - /Connection refused|client error \(Connect\)|tcp connect error/i.test(cleanOutput) && - /Gateway:\s+nemoclaw/i.test(cleanOutput) - ) { - writer( - " The selected NemoClaw gateway exists in metadata, but its API is refusing connections after restart.", - ); - writer(" This usually means the gateway runtime did not come back cleanly after the restart."); - writer( - " Retry `openshell gateway start --name nemoclaw`; if it stays in this state, rebuild the gateway before expecting existing sandboxes to reconnect.", - ); - return; - } - if (/handshake verification failed/i.test(cleanOutput)) { - writer(" This looks like gateway identity drift after restart."); - writer( - " Existing sandboxes may still be recorded locally, but the current gateway no longer trusts their prior connection state.", - ); - writer( - " Try re-establishing the NemoClaw gateway/runtime first. If the sandbox is still unreachable, recreate just that sandbox with `nemoclaw onboard`.", - ); - return; - } - if (/Connection refused|transport error/i.test(cleanOutput)) { - writer( - ` The sandbox '${sandboxName}' may still exist, but the current gateway/runtime is not reachable.`, - ); - writer(" Check `openshell status`, verify the active gateway, and retry."); - return; - } - if (/Missing gateway auth token|device identity required/i.test(cleanOutput)) { - writer( - " The gateway is reachable, but the current auth or device identity state is not usable.", - ); - writer(" Verify the active gateway and retry after re-establishing the runtime."); - } -} - -// eslint-disable-next-line complexity -async function getReconciledSandboxGatewayState(sandboxName) { - let lookup = getSandboxGatewayState(sandboxName); - if (lookup.state === "present") { - return lookup; - } - if (lookup.state === "missing") { - return lookup; - } - - if (lookup.state === "gateway_error") { - const recovery = await recoverNamedGatewayRuntime(); - if (recovery.recovered) { - const retried = getSandboxGatewayState(sandboxName); - if (retried.state === "present" || retried.state === "missing") { - return { ...retried, recoveredGateway: true, recoveryVia: recovery.via || null }; - } - if (/handshake verification failed/i.test(retried.output)) { - return { - state: "identity_drift", - output: retried.output, - recoveredGateway: true, - recoveryVia: recovery.via || null, - }; - } - return { ...retried, recoveredGateway: true, recoveryVia: recovery.via || null }; - } - const latestLifecycle = getNamedGatewayLifecycleState(); - const latestStatus = stripAnsi(latestLifecycle.status || ""); - if (/No gateway configured/i.test(latestStatus)) { - return { - state: "gateway_missing_after_restart", - output: latestLifecycle.status || lookup.output, - }; - } - if ( - /Connection refused|client error \(Connect\)|tcp connect error/i.test(latestStatus) && - /Gateway:\s+nemoclaw/i.test(latestStatus) - ) { - return { - state: "gateway_unreachable_after_restart", - output: latestLifecycle.status || lookup.output, - }; - } - if ( - recovery.after?.state === "named_unreachable" || - recovery.before?.state === "named_unreachable" - ) { - return { - state: "gateway_unreachable_after_restart", - output: recovery.after?.status || recovery.before?.status || lookup.output, - }; - } - return { ...lookup, gatewayRecoveryFailed: true }; - } - - return lookup; -} - -async function ensureLiveSandboxOrExit(sandboxName) { - const lookup = await getReconciledSandboxGatewayState(sandboxName); - if (lookup.state === "present") { - return lookup; - } - if (lookup.state === "missing") { - registry.removeSandbox(sandboxName); - console.error(` Sandbox '${sandboxName}' is not present in the live OpenShell gateway.`); - console.error(" Removed stale local registry entry."); - console.error( - " Run `nemoclaw list` to confirm the remaining sandboxes, or `nemoclaw onboard` to create a new one.", - ); - process.exit(1); - } - if (lookup.state === "identity_drift") { - console.error( - ` Sandbox '${sandboxName}' is recorded locally, but the gateway trust material rotated after restart.`, - ); - if (lookup.output) { - console.error(lookup.output); - } - console.error( - " Existing sandbox connections cannot be reattached safely after this gateway identity change.", - ); - console.error( - " Recreate this sandbox with `nemoclaw onboard` once the gateway runtime is stable.", - ); - process.exit(1); - } - if (lookup.state === "gateway_unreachable_after_restart") { - console.error( - ` Sandbox '${sandboxName}' may still exist, but the selected NemoClaw gateway is still refusing connections after restart.`, - ); - if (lookup.output) { - console.error(lookup.output); - } - console.error( - " Retry `openshell gateway start --name nemoclaw` and verify `openshell status` is healthy before reconnecting.", - ); - console.error( - " If the gateway never becomes healthy, rebuild the gateway and then recreate the affected sandbox.", - ); - process.exit(1); - } - if (lookup.state === "gateway_missing_after_restart") { - console.error( - ` Sandbox '${sandboxName}' may still exist locally, but the NemoClaw gateway is no longer configured after restart/rebuild.`, - ); - if (lookup.output) { - console.error(lookup.output); - } - console.error( - " Start the gateway again with `openshell gateway start --name nemoclaw` before retrying.", - ); - console.error( - " If the gateway had to be rebuilt from scratch, recreate the affected sandbox afterward.", - ); - process.exit(1); - } - console.error(` Unable to verify sandbox '${sandboxName}' against the live OpenShell gateway.`); - if (lookup.output) { - console.error(lookup.output); - } - printGatewayLifecycleHint(lookup.output, sandboxName); - console.error(" Check `openshell status` and the active gateway, then retry."); - process.exit(1); -} - -function printOldLogsCompatibilityGuidance(installedVersion = null) { - const versionText = installedVersion ? ` (${installedVersion})` : ""; - console.error( - ` Installed OpenShell${versionText} is too old or incompatible with \`nemoclaw logs\`.`, - ); - console.error(` NemoClaw expects \`openshell logs \` and live streaming via \`--tail\`.`); - console.error( - " Upgrade OpenShell by rerunning `nemoclaw onboard`, or reinstall the OpenShell CLI and try again.", - ); -} +const REMOTE_UNINSTALL_URL = "https://raw.githubusercontent.com/NVIDIA/NemoClaw/refs/heads/main/uninstall.sh"; function resolveUninstallScript() { - const candidates = [path.join(ROOT, "uninstall.sh"), path.join(__dirname, "..", "uninstall.sh")]; + const candidates = [ + path.join(ROOT, "uninstall.sh"), + path.join(__dirname, "..", "uninstall.sh"), + ]; for (const candidate of candidates) { if (fs.existsSync(candidate)) { @@ -622,23 +75,26 @@ function exitWithSpawnResult(result) { async function onboard(args) { const { onboard: runOnboard } = require("./lib/onboard"); - const allowedArgs = new Set(["--non-interactive", "--resume"]); + const allowedArgs = new Set(["--non-interactive"]); const unknownArgs = args.filter((arg) => !allowedArgs.has(arg)); if (unknownArgs.length > 0) { console.error(` Unknown onboard option(s): ${unknownArgs.join(", ")}`); - console.error(" Usage: nemoclaw onboard [--non-interactive] [--resume]"); + console.error(" Usage: nemoclaw onboard [--non-interactive]"); process.exit(1); } const nonInteractive = args.includes("--non-interactive"); - const resume = args.includes("--resume"); - await runOnboard({ nonInteractive, resume }); + await runOnboard({ nonInteractive }); } -async function setup(args = []) { +async function setup() { console.log(""); console.log(" ⚠ `nemoclaw setup` is deprecated. Use `nemoclaw onboard` instead."); + console.log(" Running legacy setup.sh for backwards compatibility..."); console.log(""); - await onboard(args); + 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)}`); } async function setupSpark() { @@ -646,7 +102,6 @@ async function setupSpark() { run(`sudo bash "${SCRIPTS}/setup-spark.sh"`); } -// eslint-disable-next-line complexity async function deploy(instanceName) { if (!instanceName) { console.error(" Usage: nemoclaw deploy "); @@ -698,11 +153,7 @@ async function deploy(instanceName) { process.stdout.write(` Waiting for SSH `); for (let i = 0; i < 60; i++) { try { - execFileSync( - "ssh", - ["-o", "ConnectTimeout=5", "-o", "StrictHostKeyChecking=no", name, "echo", "ok"], - { encoding: "utf-8", stdio: "ignore" }, - ); + execFileSync("ssh", ["-o", "ConnectTimeout=5", "-o", "StrictHostKeyChecking=no", name, "echo", "ok"], { encoding: "utf-8", stdio: "ignore" }); process.stdout.write(` ${G}✓${R}\n`); break; } catch { @@ -717,12 +168,8 @@ async function deploy(instanceName) { } console.log(" Syncing NemoClaw to VM..."); - 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/`, - ); + 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; @@ -737,49 +184,31 @@ async function deploy(instanceName) { const envTmp = path.join(envDir, "env"); fs.writeFileSync(envTmp, envLines.join("\n") + "\n", { mode: 0o600 }); try { - run( - `scp -q -o StrictHostKeyChecking=no -o LogLevel=ERROR ${shellQuote(envTmp)} ${qname}:/home/ubuntu/nemoclaw/.env`, - ); - run( - `ssh -q -o StrictHostKeyChecking=no -o LogLevel=ERROR ${qname} 'chmod 600 /home/ubuntu/nemoclaw/.env'`, - ); + run(`scp -q -o StrictHostKeyChecking=no -o LogLevel=ERROR ${shellQuote(envTmp)} ${qname}:/home/ubuntu/nemoclaw/.env`); + run(`ssh -q -o StrictHostKeyChecking=no -o LogLevel=ERROR ${qname} 'chmod 600 /home/ubuntu/nemoclaw/.env'`); } finally { - try { - fs.unlinkSync(envTmp); - } catch { - /* ignored */ - } - try { - fs.rmdirSync(envDir); - } catch { - /* ignored */ - } + try { fs.unlinkSync(envTmp); } catch { /* ignored */ } + try { fs.rmdirSync(envDir); } catch { /* ignored */ } } console.log(" Running setup..."); - runInteractive( - `ssh -t -o StrictHostKeyChecking=no -o LogLevel=ERROR ${qname} '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 ${qname} '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 ${qname} '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() { + await ensureApiKey(); const { defaultSandbox } = registry.listSandboxes(); - const safeName = - defaultSandbox && /^[a-zA-Z0-9._-]+$/.test(defaultSandbox) ? defaultSandbox : null; + const safeName = defaultSandbox && /^[a-zA-Z0-9._-]+$/.test(defaultSandbox) ? defaultSandbox : null; const sandboxEnv = safeName ? `SANDBOX_NAME=${shellQuote(safeName)}` : ""; run(`${sandboxEnv} bash "${SCRIPTS}/start-services.sh"`); } @@ -812,33 +241,16 @@ function uninstall(args) { exitWithSpawnResult(result); } - // Download to file before execution — prevents partial-download execution. - // Upstream URL is a rolling release so SHA-256 pinning isn't practical. console.log(` Local uninstall script not found; falling back to ${REMOTE_UNINSTALL_URL}`); - const uninstallDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-uninstall-")); - const uninstallScript = path.join(uninstallDir, "uninstall.sh"); - let result; - let downloadFailed = false; - try { - try { - execFileSync("curl", ["-fsSL", REMOTE_UNINSTALL_URL, "-o", uninstallScript], { - stdio: "inherit", - }); - } catch { - console.error(` Failed to download uninstall script from ${REMOTE_UNINSTALL_URL}`); - downloadFailed = true; - } - if (!downloadFailed) { - result = spawnSync("bash", [uninstallScript, ...args], { - stdio: "inherit", - cwd: ROOT, - env: process.env, - }); - } - } finally { - fs.rmSync(uninstallDir, { recursive: true, force: true }); - } - if (downloadFailed) process.exit(1); + const forwardedArgs = args.map(shellQuote).join(" "); + const command = forwardedArgs.length > 0 + ? `curl -fsSL ${shellQuote(REMOTE_UNINSTALL_URL)} | bash -s -- ${forwardedArgs}` + : `curl -fsSL ${shellQuote(REMOTE_UNINSTALL_URL)} | bash`; + const result = spawnSync("bash", ["-c", command], { + stdio: "inherit", + cwd: ROOT, + env: process.env, + }); exitWithSpawnResult(result); } @@ -846,15 +258,12 @@ function showStatus() { // Show sandbox registry const { sandboxes, defaultSandbox } = registry.listSandboxes(); if (sandboxes.length > 0) { - const live = parseGatewayInference( - captureOpenshell(["inference", "get"], { ignoreError: true }).output, - ); console.log(""); console.log(" Sandboxes:"); for (const sb of sandboxes) { const def = sb.name === defaultSandbox ? " *" : ""; - const model = (live && live.model) || sb.model; - console.log(` ${sb.name}${def}${model ? ` (${model})` : ""}`); + const model = sb.model ? ` (${sb.model})` : ""; + console.log(` ${sb.name}${def}${model}`); } console.log(""); } @@ -863,47 +272,21 @@ function showStatus() { run(`bash "${SCRIPTS}/start-services.sh" --status`); } -async function listSandboxes() { - const recovery = await recoverRegistryEntries(); - const { sandboxes, defaultSandbox } = recovery; +function listSandboxes() { + const { sandboxes, defaultSandbox } = registry.listSandboxes(); if (sandboxes.length === 0) { console.log(""); - const session = onboardSession.loadSession(); - if (session?.sandboxName) { - console.log( - ` No sandboxes registered locally, but the last onboarded sandbox was '${session.sandboxName}'.`, - ); - console.log( - " Retry `nemoclaw connect` or `nemoclaw status` once the gateway/runtime is healthy.", - ); - } else { - console.log(" No sandboxes registered. Run `nemoclaw onboard` to get started."); - } + console.log(" No sandboxes registered. Run `nemoclaw onboard` to get started."); console.log(""); return; } - // Query live gateway inference once; prefer it over stale registry values. - const live = parseGatewayInference( - captureOpenshell(["inference", "get"], { ignoreError: true }).output, - ); - console.log(""); - if (recovery.recoveredFromSession) { - console.log(" Recovered sandbox inventory from the last onboard session."); - console.log(""); - } - if (recovery.recoveredFromGateway > 0) { - console.log( - ` Recovered ${recovery.recoveredFromGateway} sandbox entr${recovery.recoveredFromGateway === 1 ? "y" : "ies"} from the live OpenShell gateway.`, - ); - console.log(""); - } console.log(" Sandboxes:"); for (const sb of sandboxes) { const def = sb.name === defaultSandbox ? " *" : ""; - const model = (live && live.model) || sb.model || "unknown"; - const provider = (live && live.provider) || sb.provider || "unknown"; + const model = sb.model || "unknown"; + const provider = sb.provider || "unknown"; const gpu = sb.gpuEnabled ? "GPU" : "CPU"; const presets = sb.policies && sb.policies.length > 0 ? sb.policies.join(", ") : "none"; console.log(` ${sb.name}${def}`); @@ -916,21 +299,17 @@ async function listSandboxes() { // ── Sandbox-scoped actions ─────────────────────────────────────── -async function sandboxConnect(sandboxName) { - await ensureLiveSandboxOrExit(sandboxName); - const result = spawnSync(getOpenshellBinary(), ["sandbox", "connect", sandboxName], { - stdio: "inherit", - cwd: ROOT, - env: process.env, - }); - exitWithSpawnResult(result); +function sandboxConnect(sandboxName) { + const qn = shellQuote(sandboxName); + // Ensure port forward is alive before connecting + run(`openshell forward start --background 18789 ${qn} 2>/dev/null || true`, { ignoreError: true }); + runInteractive(`openshell sandbox connect ${qn}`); } -// eslint-disable-next-line complexity -async function sandboxStatus(sandboxName) { +function sandboxStatus(sandboxName) { const sb = registry.getSandbox(sandboxName); const live = parseGatewayInference( - captureOpenshell(["inference", "get"], { ignoreError: true }).output, + runCapture("openshell inference get 2>/dev/null", { ignoreError: true }) ); if (sb) { console.log(""); @@ -941,78 +320,12 @@ async function sandboxStatus(sandboxName) { console.log(` Policies: ${(sb.policies || []).join(", ") || "none"}`); } - const lookup = await getReconciledSandboxGatewayState(sandboxName); - if (lookup.state === "present") { - console.log(""); - if (lookup.recoveredGateway) { - console.log( - ` Recovered NemoClaw gateway runtime via ${lookup.recoveryVia || "gateway reattach"}.`, - ); - console.log(""); - } - console.log(lookup.output); - } else if (lookup.state === "missing") { - registry.removeSandbox(sandboxName); - console.log(""); - console.log(` Sandbox '${sandboxName}' is not present in the live OpenShell gateway.`); - console.log(" Removed stale local registry entry."); - } else if (lookup.state === "identity_drift") { - console.log(""); - console.log( - ` Sandbox '${sandboxName}' is recorded locally, but the gateway trust material rotated after restart.`, - ); - if (lookup.output) { - console.log(lookup.output); - } - console.log( - " Existing sandbox connections cannot be reattached safely after this gateway identity change.", - ); - console.log( - " Recreate this sandbox with `nemoclaw onboard` once the gateway runtime is stable.", - ); - } else if (lookup.state === "gateway_unreachable_after_restart") { - console.log(""); - console.log( - ` Sandbox '${sandboxName}' may still exist, but the selected NemoClaw gateway is still refusing connections after restart.`, - ); - if (lookup.output) { - console.log(lookup.output); - } - console.log( - " Retry `openshell gateway start --name nemoclaw` and verify `openshell status` is healthy before reconnecting.", - ); - console.log( - " If the gateway never becomes healthy, rebuild the gateway and then recreate the affected sandbox.", - ); - } else if (lookup.state === "gateway_missing_after_restart") { - console.log(""); - console.log( - ` Sandbox '${sandboxName}' may still exist locally, but the NemoClaw gateway is no longer configured after restart/rebuild.`, - ); - if (lookup.output) { - console.log(lookup.output); - } - console.log( - " Start the gateway again with `openshell gateway start --name nemoclaw` before retrying.", - ); - console.log( - " If the gateway had to be rebuilt from scratch, recreate the affected sandbox afterward.", - ); - } else { - console.log(""); - console.log(` Could not verify sandbox '${sandboxName}' against the live OpenShell gateway.`); - if (lookup.output) { - console.log(lookup.output); - } - printGatewayLifecycleHint(lookup.output, sandboxName, console.log); - } + // openshell info + run(`openshell sandbox get ${shellQuote(sandboxName)} 2>/dev/null || true`, { ignoreError: true }); // NIM health - const nimStat = - sb && sb.nimContainer ? nim.nimStatusByName(sb.nimContainer) : nim.nimStatus(sandboxName); - console.log( - ` NIM: ${nimStat.running ? `running (${nimStat.container})` : "not running"}`, - ); + const nimStat = sb && sb.nimContainer ? nim.nimStatusByName(sb.nimContainer) : nim.nimStatus(sandboxName); + console.log(` NIM: ${nimStat.running ? `running (${nimStat.container})` : "not running"}`); if (nimStat.running) { console.log(` Healthy: ${nimStat.healthy ? "yes" : "no"}`); } @@ -1020,46 +333,8 @@ async function sandboxStatus(sandboxName) { } function sandboxLogs(sandboxName, follow) { - const installedVersion = getInstalledOpenshellVersion(); - if (installedVersion && !versionGte(installedVersion, MIN_LOGS_OPENSHELL_VERSION)) { - printOldLogsCompatibilityGuidance(installedVersion); - process.exit(1); - } - - const args = ["logs", sandboxName]; - if (follow) args.push("--tail"); - const result = spawnSync(getOpenshellBinary(), args, { - cwd: ROOT, - env: process.env, - encoding: "utf-8", - stdio: follow ? ["ignore", "inherit", "pipe"] : ["ignore", "pipe", "pipe"], - }); - const stdout = String(result.stdout || ""); - const stderr = String(result.stderr || ""); - const combined = `${stdout}${stderr}`; - if (!follow && stdout) { - process.stdout.write(stdout); - } - if (result.status === 0) { - return; - } - if (stderr) { - process.stderr.write(stderr); - } - if ( - /unrecognized subcommand 'logs'|unexpected argument '--tail'|unexpected argument '--follow'/i.test( - combined, - ) || - (installedVersion && !versionGte(installedVersion, MIN_LOGS_OPENSHELL_VERSION)) - ) { - printOldLogsCompatibilityGuidance(installedVersion); - process.exit(1); - } - if (result.status === null || result.signal) { - exitWithSpawnResult(result); - } - console.error(` Command failed (exit ${result.status}): openshell ${args.join(" ")}`); - exitWithSpawnResult(result); + const followFlag = follow ? " --tail" : ""; + run(`openshell logs ${shellQuote(sandboxName)}${followFlag}`); } async function sandboxPolicyAdd(sandboxName) { @@ -1116,40 +391,18 @@ async function sandboxDestroy(sandboxName, args = []) { else nim.stopNimContainer(sandboxName); console.log(` Deleting sandbox '${sandboxName}'...`); - const deleteResult = runOpenshell(["sandbox", "delete", sandboxName], { - ignoreError: true, - stdio: ["ignore", "pipe", "pipe"], - }); - const { output: deleteOutput, alreadyGone } = getSandboxDeleteOutcome(deleteResult); - - if (deleteResult.status !== 0 && !alreadyGone) { - if (deleteOutput) { - console.error(` ${deleteOutput}`); - } - console.error(` Failed to destroy sandbox '${sandboxName}'.`); - process.exit(deleteResult.status || 1); - } + run(`openshell sandbox delete ${shellQuote(sandboxName)} 2>/dev/null || true`, { ignoreError: true }); - const removed = registry.removeSandbox(sandboxName); - if ( - (deleteResult.status === 0 || alreadyGone) && - removed && - registry.listSandboxes().sandboxes.length === 0 && - hasNoLiveSandboxes() - ) { - cleanupGatewayAfterLastSandbox(); - } - if (alreadyGone) { - console.log(` Sandbox '${sandboxName}' was already absent from the live gateway.`); - } + registry.removeSandbox(sandboxName); console.log(` ${G}✓${R} Sandbox '${sandboxName}' destroyed`); } // ── Help ───────────────────────────────────────────────────────── function help() { + const { version } = getVersion(); console.log(` - ${B}${G}NemoClaw${R} ${D}v${getVersion()}${R} + ${B}${G}NemoClaw${R} ${D}v${version}${R} ${D}Deploy more secure, always-on AI assistants with a single command.${R} ${G}Getting Started:${R} @@ -1197,7 +450,6 @@ function help() { const [cmd, ...args] = process.argv.slice(2); -// eslint-disable-next-line complexity (async () => { // No command → help if (!cmd || cmd === "help" || cmd === "--help" || cmd === "-h") { @@ -1208,44 +460,23 @@ const [cmd, ...args] = process.argv.slice(2); // Global commands if (GLOBAL_COMMANDS.has(cmd)) { switch (cmd) { - case "onboard": - await onboard(args); - break; - case "setup": - await setup(args); - break; - case "setup-spark": - await setupSpark(); - break; - case "deploy": - await deploy(args[0]); - break; - case "start": - await start(); - break; - case "stop": - stop(); - break; - case "status": - showStatus(); - break; - case "debug": - debug(args); - break; - case "uninstall": - uninstall(args); - break; - case "list": - await listSandboxes(); - break; + case "onboard": await onboard(args); break; + case "setup": await setup(); break; + case "setup-spark": await setupSpark(); break; + case "deploy": await deploy(args[0]); break; + case "start": await start(); break; + case "stop": stop(); break; + case "status": showStatus(); break; + case "debug": debug(args); break; + case "uninstall": uninstall(args); break; + case "list": listSandboxes(); break; case "--version": case "-v": { - console.log(`nemoclaw v${getVersion()}`); + const { version } = getVersion(); + console.log(`nemoclaw v${version}`); break; } - default: - help(); - break; + default: help(); break; } return; } @@ -1258,24 +489,12 @@ const [cmd, ...args] = process.argv.slice(2); const actionArgs = args.slice(1); switch (action) { - case "connect": - await sandboxConnect(cmd); - break; - case "status": - await sandboxStatus(cmd); - break; - case "logs": - sandboxLogs(cmd, actionArgs.includes("--follow")); - break; - case "policy-add": - await sandboxPolicyAdd(cmd); - break; - case "policy-list": - sandboxPolicyList(cmd); - break; - case "destroy": - await sandboxDestroy(cmd, actionArgs); - break; + case "connect": sandboxConnect(cmd); break; + case "status": sandboxStatus(cmd); break; + case "logs": sandboxLogs(cmd, actionArgs.includes("--follow")); break; + case "policy-add": await sandboxPolicyAdd(cmd); break; + case "policy-list": sandboxPolicyList(cmd); break; + case "destroy": await sandboxDestroy(cmd, actionArgs); break; default: console.error(` Unknown action: ${action}`); console.error(` Valid actions: connect, status, logs, policy-add, policy-list, destroy`); @@ -1284,15 +503,6 @@ const [cmd, ...args] = process.argv.slice(2); return; } - if (args[0] === "connect") { - validateName(cmd, "sandbox name"); - await recoverRegistryEntries({ requestedSandboxName: cmd }); - if (registry.getSandbox(cmd)) { - await sandboxConnect(cmd); - return; - } - } - // Unknown command — suggest console.error(` Unknown command: ${cmd}`); console.error(""); diff --git a/nemoclaw/src/lib/chat-filter.test.ts b/nemoclaw/src/lib/chat-filter.test.ts new file mode 100644 index 000000000..963d72651 --- /dev/null +++ b/nemoclaw/src/lib/chat-filter.test.ts @@ -0,0 +1,52 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, it, expect } from "vitest"; +import { parseAllowedChatIds, isChatAllowed } from "../../dist/lib/chat-filter.js"; + +describe("lib/chat-filter", () => { + describe("parseAllowedChatIds", () => { + it("returns null for undefined input", () => { + expect(parseAllowedChatIds(undefined)).toBeNull(); + }); + + it("returns null for empty string", () => { + expect(parseAllowedChatIds("")).toBeNull(); + }); + + it("returns null for whitespace-only string", () => { + expect(parseAllowedChatIds(" , , ")).toBeNull(); + }); + + it("parses single chat ID", () => { + const result = parseAllowedChatIds("12345"); + expect(result).toEqual(new Set(["12345"])); + }); + + it("parses comma-separated chat IDs with whitespace", () => { + const result = parseAllowedChatIds("111, 222 ,333"); + expect(result).toEqual(new Set(["111", "222", "333"])); + }); + + it("deduplicates repeated IDs", () => { + const result = parseAllowedChatIds("111,111,222"); + expect(result).toEqual(new Set(["111", "222"])); + }); + }); + + describe("isChatAllowed", () => { + it("allows all chats when allowed set is null", () => { + expect(isChatAllowed("999", null)).toBe(true); + }); + + it("allows chat in the allowed set", () => { + const allowed = new Set(["111", "222"]); + expect(isChatAllowed("111", allowed)).toBe(true); + }); + + it("rejects chat not in the allowed set", () => { + const allowed = new Set(["111", "222"]); + expect(isChatAllowed("999", allowed)).toBe(false); + }); + }); +}); diff --git a/nemoclaw/src/lib/chat-filter.ts b/nemoclaw/src/lib/chat-filter.ts new file mode 100644 index 000000000..e844b37f3 --- /dev/null +++ b/nemoclaw/src/lib/chat-filter.ts @@ -0,0 +1,26 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Parse a comma-separated list of allowed chat IDs into a Set. + * Returns null if the input is empty or undefined (meaning: accept all). + */ +export function parseAllowedChatIds(raw: string | undefined): Set | null { + if (!raw) return null; + const ids = raw + .split(",") + .map((s) => s.trim()) + .filter((s) => s.length > 0); + return ids.length > 0 ? new Set(ids) : null; +} + +/** + * Check whether a chat ID is allowed. + * + * When `allowed` is null every chat is accepted (open mode). + * Otherwise the chat ID must be in the allowed set. + */ +export function isChatAllowed(chatId: string, allowed: Set | null): boolean { + if (allowed === null) return true; + return allowed.has(chatId); +} diff --git a/nemoclaw/src/lib/resolve-openshell.test.ts b/nemoclaw/src/lib/resolve-openshell.test.ts new file mode 100644 index 000000000..8035365b0 --- /dev/null +++ b/nemoclaw/src/lib/resolve-openshell.test.ts @@ -0,0 +1,94 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, it, expect } from "vitest"; +import { resolveOpenshell } from "../../dist/lib/resolve-openshell.js"; + +describe("lib/resolve-openshell", () => { + it("returns command -v result when absolute path", () => { + expect(resolveOpenshell({ commandVResult: "/usr/bin/openshell" })).toBe("/usr/bin/openshell"); + }); + + it("rejects non-absolute command -v result (alias)", () => { + expect( + resolveOpenshell({ commandVResult: "openshell", checkExecutable: () => false }), + ).toBeNull(); + }); + + it("rejects alias definition from command -v", () => { + expect( + resolveOpenshell({ + commandVResult: "alias openshell='echo pwned'", + checkExecutable: () => false, + }), + ).toBeNull(); + }); + + it("falls back to ~/.local/bin when command -v fails", () => { + expect( + resolveOpenshell({ + commandVResult: null, + checkExecutable: (p) => p === "/fakehome/.local/bin/openshell", + home: "/fakehome", + }), + ).toBe("/fakehome/.local/bin/openshell"); + }); + + it("falls back to /usr/local/bin", () => { + expect( + resolveOpenshell({ + commandVResult: null, + checkExecutable: (p) => p === "/usr/local/bin/openshell", + }), + ).toBe("/usr/local/bin/openshell"); + }); + + it("falls back to /usr/bin", () => { + expect( + resolveOpenshell({ + commandVResult: null, + checkExecutable: (p) => p === "/usr/bin/openshell", + }), + ).toBe("/usr/bin/openshell"); + }); + + it("prefers ~/.local/bin over /usr/local/bin", () => { + expect( + resolveOpenshell({ + commandVResult: null, + checkExecutable: (p) => + p === "/fakehome/.local/bin/openshell" || p === "/usr/local/bin/openshell", + home: "/fakehome", + }), + ).toBe("/fakehome/.local/bin/openshell"); + }); + + it("returns null when openshell not found anywhere", () => { + expect( + resolveOpenshell({ + commandVResult: null, + checkExecutable: () => false, + }), + ).toBeNull(); + }); + + it("skips home candidate when home is not absolute", () => { + expect( + resolveOpenshell({ + commandVResult: null, + checkExecutable: () => false, + home: "relative/path", + }), + ).toBeNull(); + }); + + it("returns null for null commandVResult with no executable found", () => { + expect( + resolveOpenshell({ + commandVResult: null, + checkExecutable: () => false, + home: undefined, + }), + ).toBeNull(); + }); +}); diff --git a/nemoclaw/src/lib/resolve-openshell.ts b/nemoclaw/src/lib/resolve-openshell.ts new file mode 100644 index 000000000..b55fbfac8 --- /dev/null +++ b/nemoclaw/src/lib/resolve-openshell.ts @@ -0,0 +1,59 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { execSync } from "node:child_process"; +import { accessSync, constants } from "node:fs"; + +export interface ResolveOpenshellOptions { + /** Mock result for `command -v` (undefined = run real command). */ + commandVResult?: string | null; + /** Override executable check (default: fs.accessSync X_OK). */ + checkExecutable?: (path: string) => boolean; + /** HOME directory override. */ + home?: string; +} + +/** + * Resolve the openshell binary path. + * + * Checks `command -v` first (must return an absolute path to prevent alias + * injection), then falls back to common installation directories. + */ +export function resolveOpenshell(opts: ResolveOpenshellOptions = {}): string | null { + const home = opts.home ?? process.env.HOME; + + // Step 1: command -v + if (opts.commandVResult === undefined) { + try { + const found = execSync("command -v openshell", { encoding: "utf-8" }).trim(); + if (found.startsWith("/")) return found; + } catch { + /* ignored */ + } + } else if (opts.commandVResult?.startsWith("/")) { + return opts.commandVResult; + } + + // Step 2: fallback candidates + const checkExecutable = + opts.checkExecutable ?? + ((p: string): boolean => { + try { + accessSync(p, constants.X_OK); + return true; + } catch { + return false; + } + }); + + const candidates = [ + ...(home?.startsWith("/") ? [`${home}/.local/bin/openshell`] : []), + "/usr/local/bin/openshell", + "/usr/bin/openshell", + ]; + for (const p of candidates) { + if (checkExecutable(p)) return p; + } + + return null; +} diff --git a/nemoclaw/src/lib/version.test.ts b/nemoclaw/src/lib/version.test.ts new file mode 100644 index 000000000..fca84716b --- /dev/null +++ b/nemoclaw/src/lib/version.test.ts @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { getVersion } from "../../dist/lib/version.js"; + +const store = new Map(); + +vi.mock("node:fs", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + readFileSync: (p: string, _enc: string) => { + const content = store.get(p); + if (content === undefined) throw new Error(`ENOENT: ${p}`); + return content; + }, + }; +}); + +describe("lib/version", () => { + beforeEach(() => { + store.clear(); + }); + + it("reads version from package.json", () => { + store.set("/test-dir/package.json", JSON.stringify({ version: "1.2.3" })); + const info = getVersion({ packageDir: "/test-dir", gitDescribeResult: null }); + expect(info.version).toBe("1.2.3"); + expect(info.gitDescribe).toBeNull(); + expect(info.display).toBe("1.2.3"); + }); + + it("includes git describe when available", () => { + store.set("/test-dir/package.json", JSON.stringify({ version: "1.2.3" })); + const info = getVersion({ packageDir: "/test-dir", gitDescribeResult: "v1.2.3-5-gabcdef" }); + expect(info.version).toBe("1.2.3"); + expect(info.gitDescribe).toBe("v1.2.3-5-gabcdef"); + expect(info.display).toBe("1.2.3 (v1.2.3-5-gabcdef)"); + }); + + it("handles dirty git state", () => { + store.set("/test-dir/package.json", JSON.stringify({ version: "0.1.0" })); + const info = getVersion({ packageDir: "/test-dir", gitDescribeResult: "v0.1.0-dirty" }); + expect(info.display).toBe("0.1.0 (v0.1.0-dirty)"); + }); + + it("returns version without suffix when gitDescribe is null", () => { + store.set("/test-dir/package.json", JSON.stringify({ version: "2.0.0" })); + const info = getVersion({ packageDir: "/test-dir", gitDescribeResult: null }); + expect(info.display).toBe("2.0.0"); + }); +}); diff --git a/nemoclaw/src/lib/version.ts b/nemoclaw/src/lib/version.ts new file mode 100644 index 000000000..66325056c --- /dev/null +++ b/nemoclaw/src/lib/version.ts @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { execSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +export interface VersionInfo { + /** Semver from package.json (e.g. "0.1.0"). */ + version: string; + /** Git describe tag, or null if not in a git repo / git unavailable. */ + gitDescribe: string | null; + /** Display string: version + git describe suffix when available. */ + display: string; +} + +export interface VersionOptions { + /** Override the directory containing package.json. */ + packageDir?: string; + /** Mock git describe output (undefined = run real command). */ + gitDescribeResult?: string | null; +} + +/** + * Read the CLI version from package.json and optionally enrich with + * `git describe --tags --always --dirty` for dev builds. + */ +export function getVersion(opts: VersionOptions = {}): VersionInfo { + // Compiled location: nemoclaw/dist/lib/version.js → repo root is 3 levels up + const thisDir = dirname(fileURLToPath(import.meta.url)); + const packageDir = opts.packageDir ?? join(thisDir, "..", "..", ".."); + const raw = readFileSync(join(packageDir, "package.json"), "utf-8"); + const pkg = JSON.parse(raw) as { version: string }; + const version = pkg.version; + + let gitDescribe: string | null = null; + if (opts.gitDescribeResult !== undefined) { + gitDescribe = opts.gitDescribeResult; + } else { + try { + gitDescribe = execSync("git describe --tags --always --dirty", { + encoding: "utf-8", + cwd: packageDir, + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + } catch { + /* not in a git repo or git unavailable */ + } + } + + const display = gitDescribe ? `${version} (${gitDescribe})` : version; + + return { version, gitDescribe, display }; +} diff --git a/scripts/telegram-bridge.js b/scripts/telegram-bridge.js index 96a29fd88..938144e06 100755 --- a/scripts/telegram-bridge.js +++ b/scripts/telegram-bridge.js @@ -40,10 +40,6 @@ if (!API_KEY) { console.error("NVIDIA_API_KEY required"); process.exit(1); } let offset = 0; const activeSessions = new Map(); // chatId → message history -const COOLDOWN_MS = 5000; -const lastMessageTime = new Map(); -const busyChats = new Set(); - // ── Telegram API helpers ────────────────────────────────────────── function tgApi(method, body) { @@ -173,7 +169,7 @@ async function poll() { const chatId = String(msg.chat.id); // Access control - if (!isChatAllowed(ALLOWED_CHATS, chatId)) { + if (!isChatAllowed(chatId, ALLOWED_CHATS)) { console.log(`[ignored] chat ${chatId} not in allowed list`); continue; } @@ -201,24 +197,6 @@ async function poll() { continue; } - // Rate limiting: per-chat cooldown - const now = Date.now(); - const lastTime = lastMessageTime.get(chatId) || 0; - if (now - lastTime < COOLDOWN_MS) { - const wait = Math.ceil((COOLDOWN_MS - (now - lastTime)) / 1000); - await sendMessage(chatId, `Please wait ${wait}s before sending another message.`, msg.message_id); - continue; - } - - // Per-chat serialization: reject if this chat already has an active session - if (busyChats.has(chatId)) { - await sendMessage(chatId, "Still processing your previous message.", msg.message_id); - continue; - } - - lastMessageTime.set(chatId, now); - busyChats.add(chatId); - // Send typing indicator await sendTyping(chatId); @@ -233,8 +211,6 @@ async function poll() { } catch (err) { clearInterval(typingInterval); await sendMessage(chatId, `Error: ${err.message}`, msg.message_id); - } finally { - busyChats.delete(chatId); } } } @@ -242,8 +218,8 @@ async function poll() { console.error("Poll error:", err.message); } - // Continue polling (1s floor prevents tight-loop resource waste) - setTimeout(poll, 1000); + // Continue polling + setTimeout(poll, 100); } // ── Main ────────────────────────────────────────────────────────── From ee3e060a7956120e2c80e53a8f09064782b85f8b Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Thu, 2 Apr 2026 10:15:20 -0700 Subject: [PATCH 3/5] fix: move TS sources to src/lib/ and preserve existing APIs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review feedback from @cv and CodeRabbit: - Move TS files from nemoclaw/src/lib/ to src/lib/ (repo root), compiled via tsconfig.src.json to CJS in dist/lib/ - Preserve getVersion() string return type (matching existing API) - Preserve isChatAllowed(allowedChats, chatId) array-based signature - Update bin/lib/ shims to point to dist/lib/ (not nemoclaw/dist/) - Use platform-neutral paths in version tests (real temp dirs) - Restore bin/nemoclaw.js and telegram-bridge.js to main versions (no consumer changes needed — API is backward-compatible) Co-Authored-By: Claude Opus 4.6 (1M context) --- bin/lib/chat-filter.js | 6 +- bin/lib/resolve-openshell.js | 6 +- bin/lib/version.js | 6 +- bin/nemoclaw.js | 956 ++++++++++++++++-- nemoclaw/src/lib/chat-filter.ts | 26 - nemoclaw/src/lib/version.test.ts | 53 - nemoclaw/src/lib/version.ts | 55 - scripts/telegram-bridge.js | 30 +- {nemoclaw/src => src}/lib/chat-filter.test.ts | 27 +- src/lib/chat-filter.ts | 24 + .../src => src}/lib/resolve-openshell.test.ts | 2 +- .../src => src}/lib/resolve-openshell.ts | 0 src/lib/version.test.ts | 36 + src/lib/version.ts | 46 + 14 files changed, 1028 insertions(+), 245 deletions(-) delete mode 100644 nemoclaw/src/lib/chat-filter.ts delete mode 100644 nemoclaw/src/lib/version.test.ts delete mode 100644 nemoclaw/src/lib/version.ts rename {nemoclaw/src => src}/lib/chat-filter.test.ts (51%) create mode 100644 src/lib/chat-filter.ts rename {nemoclaw/src => src}/lib/resolve-openshell.test.ts (99%) rename {nemoclaw/src => src}/lib/resolve-openshell.ts (100%) create mode 100644 src/lib/version.test.ts create mode 100644 src/lib/version.ts diff --git a/bin/lib/chat-filter.js b/bin/lib/chat-filter.js index da015595b..b6f63bc46 100644 --- a/bin/lib/chat-filter.js +++ b/bin/lib/chat-filter.js @@ -1,5 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +// +// Thin re-export shim — the implementation lives in src/lib/chat-filter.ts, +// compiled to dist/lib/chat-filter.js. -// Thin CJS shim — implementation lives in nemoclaw/src/lib/chat-filter.ts -module.exports = require("../../nemoclaw/dist/lib/chat-filter.js"); +module.exports = require("../../dist/lib/chat-filter"); diff --git a/bin/lib/resolve-openshell.js b/bin/lib/resolve-openshell.js index 603a9bc02..69c633689 100644 --- a/bin/lib/resolve-openshell.js +++ b/bin/lib/resolve-openshell.js @@ -1,5 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +// +// Thin re-export shim — the implementation lives in src/lib/resolve-openshell.ts, +// compiled to dist/lib/resolve-openshell.js. -// Thin CJS shim — implementation lives in nemoclaw/src/lib/resolve-openshell.ts -module.exports = require("../../nemoclaw/dist/lib/resolve-openshell.js"); +module.exports = require("../../dist/lib/resolve-openshell"); diff --git a/bin/lib/version.js b/bin/lib/version.js index b3dae1cd8..eec57e81f 100644 --- a/bin/lib/version.js +++ b/bin/lib/version.js @@ -1,5 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +// +// Thin re-export shim — the implementation lives in src/lib/version.ts, +// compiled to dist/lib/version.js. -// Thin CJS shim — implementation lives in nemoclaw/src/lib/version.ts -module.exports = require("../../nemoclaw/dist/lib/version.js"); +module.exports = require("../../dist/lib/version"); diff --git a/bin/nemoclaw.js b/bin/nemoclaw.js index 16200e912..ba21a2aa4 100755 --- a/bin/nemoclaw.js +++ b/bin/nemoclaw.js @@ -12,7 +12,8 @@ const os = require("os"); // Uses exact NVIDIA green #76B900 on truecolor terminals; 256-color otherwise. // --------------------------------------------------------------------------- const _useColor = !process.env.NO_COLOR && !!process.stdout.isTTY; -const _tc = _useColor && (process.env.COLORTERM === "truecolor" || process.env.COLORTERM === "24bit"); +const _tc = + _useColor && (process.env.COLORTERM === "truecolor" || process.env.COLORTERM === "24bit"); const G = _useColor ? (_tc ? "\x1b[38;2;118;185;0m" : "\x1b[38;5;148m") : ""; const B = _useColor ? "\x1b[1m" : ""; const D = _useColor ? "\x1b[2m" : ""; @@ -20,7 +21,17 @@ const R = _useColor ? "\x1b[0m" : ""; const _RD = _useColor ? "\x1b[1;31m" : ""; const YW = _useColor ? "\x1b[1;33m" : ""; -const { ROOT, SCRIPTS, run, runCapture, runInteractive, shellQuote, validateName } = require("./lib/runner"); +const { + ROOT, + SCRIPTS, + run, + runCapture: _runCapture, + runInteractive, + shellQuote, + validateName, +} = require("./lib/runner"); +const { resolveOpenshell } = require("./lib/resolve-openshell"); +const { startGatewayForRecovery } = require("./lib/onboard"); const { ensureApiKey, ensureGithubToken, @@ -32,22 +43,558 @@ const nim = require("./lib/nim"); const policies = require("./lib/policies"); const { parseGatewayInference } = require("./lib/inference-config"); const { getVersion } = require("./lib/version"); +const onboardSession = require("./lib/onboard-session"); +const { parseLiveSandboxNames } = require("./lib/runtime-recovery"); // ── Global commands ────────────────────────────────────────────── const GLOBAL_COMMANDS = new Set([ - "onboard", "list", "deploy", "setup", "setup-spark", - "start", "stop", "status", "debug", "uninstall", - "help", "--help", "-h", "--version", "-v", + "onboard", + "list", + "deploy", + "setup", + "setup-spark", + "start", + "stop", + "status", + "debug", + "uninstall", + "help", + "--help", + "-h", + "--version", + "-v", ]); -const REMOTE_UNINSTALL_URL = "https://raw.githubusercontent.com/NVIDIA/NemoClaw/refs/heads/main/uninstall.sh"; +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) { + OPENSHELL_BIN = resolveOpenshell(); + } + if (!OPENSHELL_BIN) { + console.error("openshell CLI not found. Install OpenShell before using sandbox commands."); + process.exit(1); + } + return OPENSHELL_BIN; +} + +function runOpenshell(args, opts = {}) { + const result = spawnSync(getOpenshellBinary(), args, { + cwd: ROOT, + env: { ...process.env, ...opts.env }, + encoding: "utf-8", + stdio: opts.stdio ?? "inherit", + }); + if (result.status !== 0 && !opts.ignoreError) { + console.error(` Command failed (exit ${result.status}): openshell ${args.join(" ")}`); + process.exit(result.status || 1); + } + return result; +} + +function captureOpenshell(args, opts = {}) { + const result = spawnSync(getOpenshellBinary(), args, { + cwd: ROOT, + env: { ...process.env, ...opts.env }, + encoding: "utf-8", + stdio: ["ignore", "pipe", "pipe"], + }); + return { + status: result.status ?? 1, + output: `${result.stdout || ""}${opts.ignoreError ? "" : result.stderr || ""}`.trim(), + }; +} + +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 isMissingSandboxDeleteResult(output = "") { + return /\bNotFound\b|\bNot Found\b|sandbox not found|sandbox .* not found|sandbox .* not present|sandbox does not exist|no such sandbox/i.test( + stripAnsi(output), + ); +} + +function getSandboxDeleteOutcome(deleteResult) { + const output = `${deleteResult.stdout || ""}${deleteResult.stderr || ""}`.trim(); + return { + output, + alreadyGone: deleteResult.status !== 0 && isMissingSandboxDeleteResult(output), + }; +} + +function parseVersionFromText(value = "") { + const match = String(value || "").match(/([0-9]+\.[0-9]+\.[0-9]+)/); + return match ? match[1] : null; +} + +function versionGte(left = "0.0.0", right = "0.0.0") { + const lhs = String(left) + .split(".") + .map((part) => Number.parseInt(part, 10) || 0); + const rhs = String(right) + .split(".") + .map((part) => Number.parseInt(part, 10) || 0); + const length = Math.max(lhs.length, rhs.length); + for (let index = 0; index < length; index += 1) { + const a = lhs[index] || 0; + const b = rhs[index] || 0; + if (a > b) return true; + if (a < b) return false; + } + return true; +} + +function getInstalledOpenshellVersion() { + const versionResult = captureOpenshell(["--version"], { ignoreError: true }); + return parseVersionFromText(versionResult.output); +} + +function stripAnsi(value = "") { + // eslint-disable-next-line no-control-regex + return String(value).replace(/\x1b\[[0-9;]*m/g, ""); +} + +function buildRecoveredSandboxEntry(name, metadata = {}) { + return { + name, + model: metadata.model || null, + provider: metadata.provider || null, + gpuEnabled: metadata.gpuEnabled === true, + policies: Array.isArray(metadata.policies) + ? metadata.policies + : Array.isArray(metadata.policyPresets) + ? metadata.policyPresets + : [], + nimContainer: metadata.nimContainer || null, + }; +} + +function upsertRecoveredSandbox(name, metadata = {}) { + let validName; + try { + validName = validateName(name, "sandbox name"); + } catch { + return false; + } + + const entry = buildRecoveredSandboxEntry(validName, metadata); + if (registry.getSandbox(validName)) { + registry.updateSandbox(validName, entry); + return false; + } + registry.registerSandbox(entry); + return true; +} + +function shouldRecoverRegistryEntries(current, session, requestedSandboxName) { + const hasSessionSandbox = Boolean(session?.sandboxName); + const missingSessionSandbox = + hasSessionSandbox && !current.sandboxes.some((sandbox) => sandbox.name === session.sandboxName); + const missingRequestedSandbox = + Boolean(requestedSandboxName) && + !current.sandboxes.some((sandbox) => sandbox.name === requestedSandboxName); + const hasRecoverySeed = + current.sandboxes.length > 0 || hasSessionSandbox || Boolean(requestedSandboxName); + return { + missingRequestedSandbox, + shouldRecover: + hasRecoverySeed && + (current.sandboxes.length === 0 || missingRequestedSandbox || missingSessionSandbox), + }; +} + +function seedRecoveryMetadata(current, session, requestedSandboxName) { + const metadataByName = new Map(current.sandboxes.map((sandbox) => [sandbox.name, sandbox])); + let recoveredFromSession = false; + + if (!session?.sandboxName) { + return { metadataByName, recoveredFromSession }; + } + + metadataByName.set( + session.sandboxName, + buildRecoveredSandboxEntry(session.sandboxName, { + model: session.model || null, + provider: session.provider || null, + nimContainer: session.nimContainer || null, + policyPresets: session.policyPresets || null, + }), + ); + const sessionSandboxMissing = !current.sandboxes.some( + (sandbox) => sandbox.name === session.sandboxName, + ); + const shouldRecoverSessionSandbox = + current.sandboxes.length === 0 || + sessionSandboxMissing || + requestedSandboxName === session.sandboxName; + if (shouldRecoverSessionSandbox) { + recoveredFromSession = upsertRecoveredSandbox( + session.sandboxName, + metadataByName.get(session.sandboxName), + ); + } + return { metadataByName, recoveredFromSession }; +} + +async function recoverRegistryFromLiveGateway(metadataByName) { + if (!resolveOpenshell()) { + return 0; + } + const recovery = await recoverNamedGatewayRuntime(); + const canInspectLiveGateway = + recovery.recovered || + recovery.before?.state === "healthy_named" || + recovery.after?.state === "healthy_named"; + if (!canInspectLiveGateway) { + return 0; + } + + let recoveredFromGateway = 0; + const liveList = captureOpenshell(["sandbox", "list"], { ignoreError: true }); + const liveNames = Array.from(parseLiveSandboxNames(liveList.output)); + for (const name of liveNames) { + const metadata = metadataByName.get(name) || {}; + if (upsertRecoveredSandbox(name, metadata)) { + recoveredFromGateway += 1; + } + } + return recoveredFromGateway; +} + +function applyRecoveredDefault(currentDefaultSandbox, requestedSandboxName, session) { + const recovered = registry.listSandboxes(); + const preferredDefault = + requestedSandboxName || (!currentDefaultSandbox ? session?.sandboxName || null : null); + if ( + preferredDefault && + recovered.sandboxes.some((sandbox) => sandbox.name === preferredDefault) + ) { + registry.setDefault(preferredDefault); + } + return registry.listSandboxes(); +} + +async function recoverRegistryEntries({ requestedSandboxName = null } = {}) { + const current = registry.listSandboxes(); + const session = onboardSession.loadSession(); + const recoveryCheck = shouldRecoverRegistryEntries(current, session, requestedSandboxName); + if (!recoveryCheck.shouldRecover) { + return { ...current, recoveredFromSession: false, recoveredFromGateway: 0 }; + } + + const seeded = seedRecoveryMetadata(current, session, requestedSandboxName); + const shouldProbeLiveGateway = current.sandboxes.length > 0 || Boolean(session?.sandboxName); + const recoveredFromGateway = shouldProbeLiveGateway + ? await recoverRegistryFromLiveGateway(seeded.metadataByName) + : 0; + const recovered = applyRecoveredDefault(current.defaultSandbox, requestedSandboxName, session); + return { + ...recovered, + recoveredFromSession: seeded.recoveredFromSession, + recoveredFromGateway, + }; +} + +function hasNamedGateway(output = "") { + return stripAnsi(output).includes("Gateway: nemoclaw"); +} + +function getActiveGatewayName(output = "") { + const match = stripAnsi(output).match(/^\s*Gateway:\s+(.+?)\s*$/m); + return match ? match[1].trim() : ""; +} + +function getNamedGatewayLifecycleState() { + const status = captureOpenshell(["status"]); + const gatewayInfo = captureOpenshell(["gateway", "info", "-g", "nemoclaw"]); + const cleanStatus = stripAnsi(status.output); + const activeGateway = getActiveGatewayName(status.output); + const connected = /^\s*Status:\s*Connected\b/im.test(cleanStatus); + const named = hasNamedGateway(gatewayInfo.output); + const refusing = /Connection refused|client error \(Connect\)|tcp connect error/i.test( + cleanStatus, + ); + if (connected && activeGateway === "nemoclaw" && named) { + return { state: "healthy_named", status: status.output, gatewayInfo: gatewayInfo.output }; + } + if (activeGateway === "nemoclaw" && named && refusing) { + return { state: "named_unreachable", status: status.output, gatewayInfo: gatewayInfo.output }; + } + if (activeGateway === "nemoclaw" && named) { + return { state: "named_unhealthy", status: status.output, gatewayInfo: gatewayInfo.output }; + } + if (connected) { + return { state: "connected_other", status: status.output, gatewayInfo: gatewayInfo.output }; + } + return { state: "missing_named", status: status.output, gatewayInfo: gatewayInfo.output }; +} + +async function recoverNamedGatewayRuntime() { + const before = getNamedGatewayLifecycleState(); + if (before.state === "healthy_named") { + return { recovered: true, before, after: before, attempted: false }; + } + + runOpenshell(["gateway", "select", "nemoclaw"], { ignoreError: true }); + let after = getNamedGatewayLifecycleState(); + if (after.state === "healthy_named") { + process.env.OPENSHELL_GATEWAY = "nemoclaw"; + return { recovered: true, before, after, attempted: true, via: "select" }; + } + + const shouldStartGateway = [before.state, after.state].some((state) => + ["missing_named", "named_unhealthy", "named_unreachable", "connected_other"].includes(state), + ); + + if (shouldStartGateway) { + try { + await startGatewayForRecovery(); + } catch { + // Fall through to the lifecycle re-check below so we preserve the + // existing recovery result shape and emit the correct classification. + } + runOpenshell(["gateway", "select", "nemoclaw"], { ignoreError: true }); + after = getNamedGatewayLifecycleState(); + if (after.state === "healthy_named") { + process.env.OPENSHELL_GATEWAY = "nemoclaw"; + return { recovered: true, before, after, attempted: true, via: "start" }; + } + } + + return { recovered: false, before, after, attempted: true }; +} + +function getSandboxGatewayState(sandboxName) { + const result = captureOpenshell(["sandbox", "get", sandboxName]); + const output = result.output; + if (result.status === 0) { + return { state: "present", output }; + } + if (/\bNotFound\b|\bNot Found\b|sandbox not found/i.test(output)) { + return { state: "missing", output }; + } + if ( + /transport error|Connection refused|handshake verification failed|Missing gateway auth token|device identity required/i.test( + output, + ) + ) { + return { state: "gateway_error", output }; + } + return { state: "unknown_error", output }; +} + +function printGatewayLifecycleHint(output = "", sandboxName = "", writer = console.error) { + const cleanOutput = stripAnsi(output); + if (/No gateway configured/i.test(cleanOutput)) { + writer( + " The selected NemoClaw gateway is no longer configured or its metadata/runtime has been lost.", + ); + writer( + " Start the gateway again with `openshell gateway start --name nemoclaw` before expecting existing sandboxes to reconnect.", + ); + writer( + " If the gateway has to be rebuilt from scratch, recreate the affected sandbox afterward.", + ); + return; + } + if ( + /Connection refused|client error \(Connect\)|tcp connect error/i.test(cleanOutput) && + /Gateway:\s+nemoclaw/i.test(cleanOutput) + ) { + writer( + " The selected NemoClaw gateway exists in metadata, but its API is refusing connections after restart.", + ); + writer(" This usually means the gateway runtime did not come back cleanly after the restart."); + writer( + " Retry `openshell gateway start --name nemoclaw`; if it stays in this state, rebuild the gateway before expecting existing sandboxes to reconnect.", + ); + return; + } + if (/handshake verification failed/i.test(cleanOutput)) { + writer(" This looks like gateway identity drift after restart."); + writer( + " Existing sandboxes may still be recorded locally, but the current gateway no longer trusts their prior connection state.", + ); + writer( + " Try re-establishing the NemoClaw gateway/runtime first. If the sandbox is still unreachable, recreate just that sandbox with `nemoclaw onboard`.", + ); + return; + } + if (/Connection refused|transport error/i.test(cleanOutput)) { + writer( + ` The sandbox '${sandboxName}' may still exist, but the current gateway/runtime is not reachable.`, + ); + writer(" Check `openshell status`, verify the active gateway, and retry."); + return; + } + if (/Missing gateway auth token|device identity required/i.test(cleanOutput)) { + writer( + " The gateway is reachable, but the current auth or device identity state is not usable.", + ); + writer(" Verify the active gateway and retry after re-establishing the runtime."); + } +} + +// eslint-disable-next-line complexity +async function getReconciledSandboxGatewayState(sandboxName) { + let lookup = getSandboxGatewayState(sandboxName); + if (lookup.state === "present") { + return lookup; + } + if (lookup.state === "missing") { + return lookup; + } + + if (lookup.state === "gateway_error") { + const recovery = await recoverNamedGatewayRuntime(); + if (recovery.recovered) { + const retried = getSandboxGatewayState(sandboxName); + if (retried.state === "present" || retried.state === "missing") { + return { ...retried, recoveredGateway: true, recoveryVia: recovery.via || null }; + } + if (/handshake verification failed/i.test(retried.output)) { + return { + state: "identity_drift", + output: retried.output, + recoveredGateway: true, + recoveryVia: recovery.via || null, + }; + } + return { ...retried, recoveredGateway: true, recoveryVia: recovery.via || null }; + } + const latestLifecycle = getNamedGatewayLifecycleState(); + const latestStatus = stripAnsi(latestLifecycle.status || ""); + if (/No gateway configured/i.test(latestStatus)) { + return { + state: "gateway_missing_after_restart", + output: latestLifecycle.status || lookup.output, + }; + } + if ( + /Connection refused|client error \(Connect\)|tcp connect error/i.test(latestStatus) && + /Gateway:\s+nemoclaw/i.test(latestStatus) + ) { + return { + state: "gateway_unreachable_after_restart", + output: latestLifecycle.status || lookup.output, + }; + } + if ( + recovery.after?.state === "named_unreachable" || + recovery.before?.state === "named_unreachable" + ) { + return { + state: "gateway_unreachable_after_restart", + output: recovery.after?.status || recovery.before?.status || lookup.output, + }; + } + return { ...lookup, gatewayRecoveryFailed: true }; + } + + return lookup; +} + +async function ensureLiveSandboxOrExit(sandboxName) { + const lookup = await getReconciledSandboxGatewayState(sandboxName); + if (lookup.state === "present") { + return lookup; + } + if (lookup.state === "missing") { + registry.removeSandbox(sandboxName); + console.error(` Sandbox '${sandboxName}' is not present in the live OpenShell gateway.`); + console.error(" Removed stale local registry entry."); + console.error( + " Run `nemoclaw list` to confirm the remaining sandboxes, or `nemoclaw onboard` to create a new one.", + ); + process.exit(1); + } + if (lookup.state === "identity_drift") { + console.error( + ` Sandbox '${sandboxName}' is recorded locally, but the gateway trust material rotated after restart.`, + ); + if (lookup.output) { + console.error(lookup.output); + } + console.error( + " Existing sandbox connections cannot be reattached safely after this gateway identity change.", + ); + console.error( + " Recreate this sandbox with `nemoclaw onboard` once the gateway runtime is stable.", + ); + process.exit(1); + } + if (lookup.state === "gateway_unreachable_after_restart") { + console.error( + ` Sandbox '${sandboxName}' may still exist, but the selected NemoClaw gateway is still refusing connections after restart.`, + ); + if (lookup.output) { + console.error(lookup.output); + } + console.error( + " Retry `openshell gateway start --name nemoclaw` and verify `openshell status` is healthy before reconnecting.", + ); + console.error( + " If the gateway never becomes healthy, rebuild the gateway and then recreate the affected sandbox.", + ); + process.exit(1); + } + if (lookup.state === "gateway_missing_after_restart") { + console.error( + ` Sandbox '${sandboxName}' may still exist locally, but the NemoClaw gateway is no longer configured after restart/rebuild.`, + ); + if (lookup.output) { + console.error(lookup.output); + } + console.error( + " Start the gateway again with `openshell gateway start --name nemoclaw` before retrying.", + ); + console.error( + " If the gateway had to be rebuilt from scratch, recreate the affected sandbox afterward.", + ); + process.exit(1); + } + console.error(` Unable to verify sandbox '${sandboxName}' against the live OpenShell gateway.`); + if (lookup.output) { + console.error(lookup.output); + } + printGatewayLifecycleHint(lookup.output, sandboxName); + console.error(" Check `openshell status` and the active gateway, then retry."); + process.exit(1); +} + +function printOldLogsCompatibilityGuidance(installedVersion = null) { + const versionText = installedVersion ? ` (${installedVersion})` : ""; + console.error( + ` Installed OpenShell${versionText} is too old or incompatible with \`nemoclaw logs\`.`, + ); + console.error(` NemoClaw expects \`openshell logs \` and live streaming via \`--tail\`.`); + console.error( + " Upgrade OpenShell by rerunning `nemoclaw onboard`, or reinstall the OpenShell CLI and try again.", + ); +} function resolveUninstallScript() { - const candidates = [ - path.join(ROOT, "uninstall.sh"), - path.join(__dirname, "..", "uninstall.sh"), - ]; + const candidates = [path.join(ROOT, "uninstall.sh"), path.join(__dirname, "..", "uninstall.sh")]; for (const candidate of candidates) { if (fs.existsSync(candidate)) { @@ -75,26 +622,23 @@ function exitWithSpawnResult(result) { async function onboard(args) { const { onboard: runOnboard } = require("./lib/onboard"); - const allowedArgs = new Set(["--non-interactive"]); + const allowedArgs = new Set(["--non-interactive", "--resume"]); const unknownArgs = args.filter((arg) => !allowedArgs.has(arg)); if (unknownArgs.length > 0) { console.error(` Unknown onboard option(s): ${unknownArgs.join(", ")}`); - console.error(" Usage: nemoclaw onboard [--non-interactive]"); + console.error(" Usage: nemoclaw onboard [--non-interactive] [--resume]"); process.exit(1); } const nonInteractive = args.includes("--non-interactive"); - await runOnboard({ nonInteractive }); + const resume = args.includes("--resume"); + await runOnboard({ nonInteractive, resume }); } -async function setup() { +async function setup(args = []) { console.log(""); console.log(" ⚠ `nemoclaw setup` is deprecated. Use `nemoclaw onboard` instead."); - console.log(" Running legacy setup.sh for backwards compatibility..."); console.log(""); - 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)}`); + await onboard(args); } async function setupSpark() { @@ -102,6 +646,7 @@ async function setupSpark() { run(`sudo bash "${SCRIPTS}/setup-spark.sh"`); } +// eslint-disable-next-line complexity async function deploy(instanceName) { if (!instanceName) { console.error(" Usage: nemoclaw deploy "); @@ -153,7 +698,11 @@ async function deploy(instanceName) { process.stdout.write(` Waiting for SSH `); for (let i = 0; i < 60; i++) { try { - execFileSync("ssh", ["-o", "ConnectTimeout=5", "-o", "StrictHostKeyChecking=no", name, "echo", "ok"], { encoding: "utf-8", stdio: "ignore" }); + execFileSync( + "ssh", + ["-o", "ConnectTimeout=5", "-o", "StrictHostKeyChecking=no", name, "echo", "ok"], + { encoding: "utf-8", stdio: "ignore" }, + ); process.stdout.write(` ${G}✓${R}\n`); break; } catch { @@ -168,8 +717,12 @@ async function deploy(instanceName) { } console.log(" Syncing NemoClaw to VM..."); - 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/`); + 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; @@ -184,31 +737,49 @@ async function deploy(instanceName) { const envTmp = path.join(envDir, "env"); fs.writeFileSync(envTmp, envLines.join("\n") + "\n", { mode: 0o600 }); try { - run(`scp -q -o StrictHostKeyChecking=no -o LogLevel=ERROR ${shellQuote(envTmp)} ${qname}:/home/ubuntu/nemoclaw/.env`); - run(`ssh -q -o StrictHostKeyChecking=no -o LogLevel=ERROR ${qname} 'chmod 600 /home/ubuntu/nemoclaw/.env'`); + run( + `scp -q -o StrictHostKeyChecking=no -o LogLevel=ERROR ${shellQuote(envTmp)} ${qname}:/home/ubuntu/nemoclaw/.env`, + ); + run( + `ssh -q -o StrictHostKeyChecking=no -o LogLevel=ERROR ${qname} 'chmod 600 /home/ubuntu/nemoclaw/.env'`, + ); } finally { - try { fs.unlinkSync(envTmp); } catch { /* ignored */ } - try { fs.rmdirSync(envDir); } catch { /* ignored */ } + try { + fs.unlinkSync(envTmp); + } catch { + /* ignored */ + } + try { + fs.rmdirSync(envDir); + } catch { + /* ignored */ + } } console.log(" Running setup..."); - runInteractive(`ssh -t -o StrictHostKeyChecking=no -o LogLevel=ERROR ${qname} '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 ${qname} '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 ${qname} '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() { - await ensureApiKey(); const { defaultSandbox } = registry.listSandboxes(); - const safeName = defaultSandbox && /^[a-zA-Z0-9._-]+$/.test(defaultSandbox) ? defaultSandbox : null; + const safeName = + defaultSandbox && /^[a-zA-Z0-9._-]+$/.test(defaultSandbox) ? defaultSandbox : null; const sandboxEnv = safeName ? `SANDBOX_NAME=${shellQuote(safeName)}` : ""; run(`${sandboxEnv} bash "${SCRIPTS}/start-services.sh"`); } @@ -241,16 +812,33 @@ function uninstall(args) { exitWithSpawnResult(result); } + // Download to file before execution — prevents partial-download execution. + // Upstream URL is a rolling release so SHA-256 pinning isn't practical. console.log(` Local uninstall script not found; falling back to ${REMOTE_UNINSTALL_URL}`); - const forwardedArgs = args.map(shellQuote).join(" "); - const command = forwardedArgs.length > 0 - ? `curl -fsSL ${shellQuote(REMOTE_UNINSTALL_URL)} | bash -s -- ${forwardedArgs}` - : `curl -fsSL ${shellQuote(REMOTE_UNINSTALL_URL)} | bash`; - const result = spawnSync("bash", ["-c", command], { - stdio: "inherit", - cwd: ROOT, - env: process.env, - }); + const uninstallDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-uninstall-")); + const uninstallScript = path.join(uninstallDir, "uninstall.sh"); + let result; + let downloadFailed = false; + try { + try { + execFileSync("curl", ["-fsSL", REMOTE_UNINSTALL_URL, "-o", uninstallScript], { + stdio: "inherit", + }); + } catch { + console.error(` Failed to download uninstall script from ${REMOTE_UNINSTALL_URL}`); + downloadFailed = true; + } + if (!downloadFailed) { + result = spawnSync("bash", [uninstallScript, ...args], { + stdio: "inherit", + cwd: ROOT, + env: process.env, + }); + } + } finally { + fs.rmSync(uninstallDir, { recursive: true, force: true }); + } + if (downloadFailed) process.exit(1); exitWithSpawnResult(result); } @@ -258,12 +846,15 @@ function showStatus() { // Show sandbox registry const { sandboxes, defaultSandbox } = registry.listSandboxes(); if (sandboxes.length > 0) { + const live = parseGatewayInference( + captureOpenshell(["inference", "get"], { ignoreError: true }).output, + ); console.log(""); console.log(" Sandboxes:"); for (const sb of sandboxes) { const def = sb.name === defaultSandbox ? " *" : ""; - const model = sb.model ? ` (${sb.model})` : ""; - console.log(` ${sb.name}${def}${model}`); + const model = (live && live.model) || sb.model; + console.log(` ${sb.name}${def}${model ? ` (${model})` : ""}`); } console.log(""); } @@ -272,21 +863,47 @@ function showStatus() { run(`bash "${SCRIPTS}/start-services.sh" --status`); } -function listSandboxes() { - const { sandboxes, defaultSandbox } = registry.listSandboxes(); +async function listSandboxes() { + const recovery = await recoverRegistryEntries(); + const { sandboxes, defaultSandbox } = recovery; if (sandboxes.length === 0) { console.log(""); - console.log(" No sandboxes registered. Run `nemoclaw onboard` to get started."); + const session = onboardSession.loadSession(); + if (session?.sandboxName) { + console.log( + ` No sandboxes registered locally, but the last onboarded sandbox was '${session.sandboxName}'.`, + ); + console.log( + " Retry `nemoclaw connect` or `nemoclaw status` once the gateway/runtime is healthy.", + ); + } else { + console.log(" No sandboxes registered. Run `nemoclaw onboard` to get started."); + } console.log(""); return; } + // Query live gateway inference once; prefer it over stale registry values. + const live = parseGatewayInference( + captureOpenshell(["inference", "get"], { ignoreError: true }).output, + ); + console.log(""); + if (recovery.recoveredFromSession) { + console.log(" Recovered sandbox inventory from the last onboard session."); + console.log(""); + } + if (recovery.recoveredFromGateway > 0) { + console.log( + ` Recovered ${recovery.recoveredFromGateway} sandbox entr${recovery.recoveredFromGateway === 1 ? "y" : "ies"} from the live OpenShell gateway.`, + ); + console.log(""); + } console.log(" Sandboxes:"); for (const sb of sandboxes) { const def = sb.name === defaultSandbox ? " *" : ""; - const model = sb.model || "unknown"; - const provider = sb.provider || "unknown"; + const model = (live && live.model) || sb.model || "unknown"; + const provider = (live && live.provider) || sb.provider || "unknown"; const gpu = sb.gpuEnabled ? "GPU" : "CPU"; const presets = sb.policies && sb.policies.length > 0 ? sb.policies.join(", ") : "none"; console.log(` ${sb.name}${def}`); @@ -299,17 +916,21 @@ function listSandboxes() { // ── Sandbox-scoped actions ─────────────────────────────────────── -function sandboxConnect(sandboxName) { - const qn = shellQuote(sandboxName); - // Ensure port forward is alive before connecting - run(`openshell forward start --background 18789 ${qn} 2>/dev/null || true`, { ignoreError: true }); - runInteractive(`openshell sandbox connect ${qn}`); +async function sandboxConnect(sandboxName) { + await ensureLiveSandboxOrExit(sandboxName); + const result = spawnSync(getOpenshellBinary(), ["sandbox", "connect", sandboxName], { + stdio: "inherit", + cwd: ROOT, + env: process.env, + }); + exitWithSpawnResult(result); } -function sandboxStatus(sandboxName) { +// eslint-disable-next-line complexity +async function sandboxStatus(sandboxName) { const sb = registry.getSandbox(sandboxName); const live = parseGatewayInference( - runCapture("openshell inference get 2>/dev/null", { ignoreError: true }) + captureOpenshell(["inference", "get"], { ignoreError: true }).output, ); if (sb) { console.log(""); @@ -320,12 +941,78 @@ function sandboxStatus(sandboxName) { console.log(` Policies: ${(sb.policies || []).join(", ") || "none"}`); } - // openshell info - run(`openshell sandbox get ${shellQuote(sandboxName)} 2>/dev/null || true`, { ignoreError: true }); + const lookup = await getReconciledSandboxGatewayState(sandboxName); + if (lookup.state === "present") { + console.log(""); + if (lookup.recoveredGateway) { + console.log( + ` Recovered NemoClaw gateway runtime via ${lookup.recoveryVia || "gateway reattach"}.`, + ); + console.log(""); + } + console.log(lookup.output); + } else if (lookup.state === "missing") { + registry.removeSandbox(sandboxName); + console.log(""); + console.log(` Sandbox '${sandboxName}' is not present in the live OpenShell gateway.`); + console.log(" Removed stale local registry entry."); + } else if (lookup.state === "identity_drift") { + console.log(""); + console.log( + ` Sandbox '${sandboxName}' is recorded locally, but the gateway trust material rotated after restart.`, + ); + if (lookup.output) { + console.log(lookup.output); + } + console.log( + " Existing sandbox connections cannot be reattached safely after this gateway identity change.", + ); + console.log( + " Recreate this sandbox with `nemoclaw onboard` once the gateway runtime is stable.", + ); + } else if (lookup.state === "gateway_unreachable_after_restart") { + console.log(""); + console.log( + ` Sandbox '${sandboxName}' may still exist, but the selected NemoClaw gateway is still refusing connections after restart.`, + ); + if (lookup.output) { + console.log(lookup.output); + } + console.log( + " Retry `openshell gateway start --name nemoclaw` and verify `openshell status` is healthy before reconnecting.", + ); + console.log( + " If the gateway never becomes healthy, rebuild the gateway and then recreate the affected sandbox.", + ); + } else if (lookup.state === "gateway_missing_after_restart") { + console.log(""); + console.log( + ` Sandbox '${sandboxName}' may still exist locally, but the NemoClaw gateway is no longer configured after restart/rebuild.`, + ); + if (lookup.output) { + console.log(lookup.output); + } + console.log( + " Start the gateway again with `openshell gateway start --name nemoclaw` before retrying.", + ); + console.log( + " If the gateway had to be rebuilt from scratch, recreate the affected sandbox afterward.", + ); + } else { + console.log(""); + console.log(` Could not verify sandbox '${sandboxName}' against the live OpenShell gateway.`); + if (lookup.output) { + console.log(lookup.output); + } + printGatewayLifecycleHint(lookup.output, sandboxName, console.log); + } // NIM health - const nimStat = sb && sb.nimContainer ? nim.nimStatusByName(sb.nimContainer) : nim.nimStatus(sandboxName); - console.log(` NIM: ${nimStat.running ? `running (${nimStat.container})` : "not running"}`); + const nimStat = + sb && sb.nimContainer ? nim.nimStatusByName(sb.nimContainer) : nim.nimStatus(sandboxName); + console.log( + ` NIM: ${nimStat.running ? `running (${nimStat.container})` : "not running"}`, + ); if (nimStat.running) { console.log(` Healthy: ${nimStat.healthy ? "yes" : "no"}`); } @@ -333,8 +1020,46 @@ function sandboxStatus(sandboxName) { } function sandboxLogs(sandboxName, follow) { - const followFlag = follow ? " --tail" : ""; - run(`openshell logs ${shellQuote(sandboxName)}${followFlag}`); + const installedVersion = getInstalledOpenshellVersion(); + if (installedVersion && !versionGte(installedVersion, MIN_LOGS_OPENSHELL_VERSION)) { + printOldLogsCompatibilityGuidance(installedVersion); + process.exit(1); + } + + const args = ["logs", sandboxName]; + if (follow) args.push("--tail"); + const result = spawnSync(getOpenshellBinary(), args, { + cwd: ROOT, + env: process.env, + encoding: "utf-8", + stdio: follow ? ["ignore", "inherit", "pipe"] : ["ignore", "pipe", "pipe"], + }); + const stdout = String(result.stdout || ""); + const stderr = String(result.stderr || ""); + const combined = `${stdout}${stderr}`; + if (!follow && stdout) { + process.stdout.write(stdout); + } + if (result.status === 0) { + return; + } + if (stderr) { + process.stderr.write(stderr); + } + if ( + /unrecognized subcommand 'logs'|unexpected argument '--tail'|unexpected argument '--follow'/i.test( + combined, + ) || + (installedVersion && !versionGte(installedVersion, MIN_LOGS_OPENSHELL_VERSION)) + ) { + printOldLogsCompatibilityGuidance(installedVersion); + process.exit(1); + } + if (result.status === null || result.signal) { + exitWithSpawnResult(result); + } + console.error(` Command failed (exit ${result.status}): openshell ${args.join(" ")}`); + exitWithSpawnResult(result); } async function sandboxPolicyAdd(sandboxName) { @@ -391,18 +1116,40 @@ async function sandboxDestroy(sandboxName, args = []) { else nim.stopNimContainer(sandboxName); console.log(` Deleting sandbox '${sandboxName}'...`); - run(`openshell sandbox delete ${shellQuote(sandboxName)} 2>/dev/null || true`, { ignoreError: true }); + const deleteResult = runOpenshell(["sandbox", "delete", sandboxName], { + ignoreError: true, + stdio: ["ignore", "pipe", "pipe"], + }); + const { output: deleteOutput, alreadyGone } = getSandboxDeleteOutcome(deleteResult); + + if (deleteResult.status !== 0 && !alreadyGone) { + if (deleteOutput) { + console.error(` ${deleteOutput}`); + } + console.error(` Failed to destroy sandbox '${sandboxName}'.`); + process.exit(deleteResult.status || 1); + } - registry.removeSandbox(sandboxName); + const removed = registry.removeSandbox(sandboxName); + if ( + (deleteResult.status === 0 || alreadyGone) && + removed && + registry.listSandboxes().sandboxes.length === 0 && + hasNoLiveSandboxes() + ) { + cleanupGatewayAfterLastSandbox(); + } + if (alreadyGone) { + console.log(` Sandbox '${sandboxName}' was already absent from the live gateway.`); + } console.log(` ${G}✓${R} Sandbox '${sandboxName}' destroyed`); } // ── Help ───────────────────────────────────────────────────────── function help() { - const { version } = getVersion(); console.log(` - ${B}${G}NemoClaw${R} ${D}v${version}${R} + ${B}${G}NemoClaw${R} ${D}v${getVersion()}${R} ${D}Deploy more secure, always-on AI assistants with a single command.${R} ${G}Getting Started:${R} @@ -450,6 +1197,7 @@ function help() { const [cmd, ...args] = process.argv.slice(2); +// eslint-disable-next-line complexity (async () => { // No command → help if (!cmd || cmd === "help" || cmd === "--help" || cmd === "-h") { @@ -460,23 +1208,44 @@ const [cmd, ...args] = process.argv.slice(2); // Global commands if (GLOBAL_COMMANDS.has(cmd)) { switch (cmd) { - case "onboard": await onboard(args); break; - case "setup": await setup(); break; - case "setup-spark": await setupSpark(); break; - case "deploy": await deploy(args[0]); break; - case "start": await start(); break; - case "stop": stop(); break; - case "status": showStatus(); break; - case "debug": debug(args); break; - case "uninstall": uninstall(args); break; - case "list": listSandboxes(); break; + case "onboard": + await onboard(args); + break; + case "setup": + await setup(args); + break; + case "setup-spark": + await setupSpark(); + break; + case "deploy": + await deploy(args[0]); + break; + case "start": + await start(); + break; + case "stop": + stop(); + break; + case "status": + showStatus(); + break; + case "debug": + debug(args); + break; + case "uninstall": + uninstall(args); + break; + case "list": + await listSandboxes(); + break; case "--version": case "-v": { - const { version } = getVersion(); - console.log(`nemoclaw v${version}`); + console.log(`nemoclaw v${getVersion()}`); break; } - default: help(); break; + default: + help(); + break; } return; } @@ -489,12 +1258,24 @@ const [cmd, ...args] = process.argv.slice(2); const actionArgs = args.slice(1); switch (action) { - case "connect": sandboxConnect(cmd); break; - case "status": sandboxStatus(cmd); break; - case "logs": sandboxLogs(cmd, actionArgs.includes("--follow")); break; - case "policy-add": await sandboxPolicyAdd(cmd); break; - case "policy-list": sandboxPolicyList(cmd); break; - case "destroy": await sandboxDestroy(cmd, actionArgs); break; + case "connect": + await sandboxConnect(cmd); + break; + case "status": + await sandboxStatus(cmd); + break; + case "logs": + sandboxLogs(cmd, actionArgs.includes("--follow")); + break; + case "policy-add": + await sandboxPolicyAdd(cmd); + break; + case "policy-list": + sandboxPolicyList(cmd); + break; + case "destroy": + await sandboxDestroy(cmd, actionArgs); + break; default: console.error(` Unknown action: ${action}`); console.error(` Valid actions: connect, status, logs, policy-add, policy-list, destroy`); @@ -503,6 +1284,15 @@ const [cmd, ...args] = process.argv.slice(2); return; } + if (args[0] === "connect") { + validateName(cmd, "sandbox name"); + await recoverRegistryEntries({ requestedSandboxName: cmd }); + if (registry.getSandbox(cmd)) { + await sandboxConnect(cmd); + return; + } + } + // Unknown command — suggest console.error(` Unknown command: ${cmd}`); console.error(""); diff --git a/nemoclaw/src/lib/chat-filter.ts b/nemoclaw/src/lib/chat-filter.ts deleted file mode 100644 index e844b37f3..000000000 --- a/nemoclaw/src/lib/chat-filter.ts +++ /dev/null @@ -1,26 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -/** - * Parse a comma-separated list of allowed chat IDs into a Set. - * Returns null if the input is empty or undefined (meaning: accept all). - */ -export function parseAllowedChatIds(raw: string | undefined): Set | null { - if (!raw) return null; - const ids = raw - .split(",") - .map((s) => s.trim()) - .filter((s) => s.length > 0); - return ids.length > 0 ? new Set(ids) : null; -} - -/** - * Check whether a chat ID is allowed. - * - * When `allowed` is null every chat is accepted (open mode). - * Otherwise the chat ID must be in the allowed set. - */ -export function isChatAllowed(chatId: string, allowed: Set | null): boolean { - if (allowed === null) return true; - return allowed.has(chatId); -} diff --git a/nemoclaw/src/lib/version.test.ts b/nemoclaw/src/lib/version.test.ts deleted file mode 100644 index fca84716b..000000000 --- a/nemoclaw/src/lib/version.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { getVersion } from "../../dist/lib/version.js"; - -const store = new Map(); - -vi.mock("node:fs", async (importOriginal) => { - const original = await importOriginal(); - return { - ...original, - readFileSync: (p: string, _enc: string) => { - const content = store.get(p); - if (content === undefined) throw new Error(`ENOENT: ${p}`); - return content; - }, - }; -}); - -describe("lib/version", () => { - beforeEach(() => { - store.clear(); - }); - - it("reads version from package.json", () => { - store.set("/test-dir/package.json", JSON.stringify({ version: "1.2.3" })); - const info = getVersion({ packageDir: "/test-dir", gitDescribeResult: null }); - expect(info.version).toBe("1.2.3"); - expect(info.gitDescribe).toBeNull(); - expect(info.display).toBe("1.2.3"); - }); - - it("includes git describe when available", () => { - store.set("/test-dir/package.json", JSON.stringify({ version: "1.2.3" })); - const info = getVersion({ packageDir: "/test-dir", gitDescribeResult: "v1.2.3-5-gabcdef" }); - expect(info.version).toBe("1.2.3"); - expect(info.gitDescribe).toBe("v1.2.3-5-gabcdef"); - expect(info.display).toBe("1.2.3 (v1.2.3-5-gabcdef)"); - }); - - it("handles dirty git state", () => { - store.set("/test-dir/package.json", JSON.stringify({ version: "0.1.0" })); - const info = getVersion({ packageDir: "/test-dir", gitDescribeResult: "v0.1.0-dirty" }); - expect(info.display).toBe("0.1.0 (v0.1.0-dirty)"); - }); - - it("returns version without suffix when gitDescribe is null", () => { - store.set("/test-dir/package.json", JSON.stringify({ version: "2.0.0" })); - const info = getVersion({ packageDir: "/test-dir", gitDescribeResult: null }); - expect(info.display).toBe("2.0.0"); - }); -}); diff --git a/nemoclaw/src/lib/version.ts b/nemoclaw/src/lib/version.ts deleted file mode 100644 index 66325056c..000000000 --- a/nemoclaw/src/lib/version.ts +++ /dev/null @@ -1,55 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { execSync } from "node:child_process"; -import { readFileSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; - -export interface VersionInfo { - /** Semver from package.json (e.g. "0.1.0"). */ - version: string; - /** Git describe tag, or null if not in a git repo / git unavailable. */ - gitDescribe: string | null; - /** Display string: version + git describe suffix when available. */ - display: string; -} - -export interface VersionOptions { - /** Override the directory containing package.json. */ - packageDir?: string; - /** Mock git describe output (undefined = run real command). */ - gitDescribeResult?: string | null; -} - -/** - * Read the CLI version from package.json and optionally enrich with - * `git describe --tags --always --dirty` for dev builds. - */ -export function getVersion(opts: VersionOptions = {}): VersionInfo { - // Compiled location: nemoclaw/dist/lib/version.js → repo root is 3 levels up - const thisDir = dirname(fileURLToPath(import.meta.url)); - const packageDir = opts.packageDir ?? join(thisDir, "..", "..", ".."); - const raw = readFileSync(join(packageDir, "package.json"), "utf-8"); - const pkg = JSON.parse(raw) as { version: string }; - const version = pkg.version; - - let gitDescribe: string | null = null; - if (opts.gitDescribeResult !== undefined) { - gitDescribe = opts.gitDescribeResult; - } else { - try { - gitDescribe = execSync("git describe --tags --always --dirty", { - encoding: "utf-8", - cwd: packageDir, - stdio: ["ignore", "pipe", "ignore"], - }).trim(); - } catch { - /* not in a git repo or git unavailable */ - } - } - - const display = gitDescribe ? `${version} (${gitDescribe})` : version; - - return { version, gitDescribe, display }; -} diff --git a/scripts/telegram-bridge.js b/scripts/telegram-bridge.js index 938144e06..96a29fd88 100755 --- a/scripts/telegram-bridge.js +++ b/scripts/telegram-bridge.js @@ -40,6 +40,10 @@ if (!API_KEY) { console.error("NVIDIA_API_KEY required"); process.exit(1); } let offset = 0; const activeSessions = new Map(); // chatId → message history +const COOLDOWN_MS = 5000; +const lastMessageTime = new Map(); +const busyChats = new Set(); + // ── Telegram API helpers ────────────────────────────────────────── function tgApi(method, body) { @@ -169,7 +173,7 @@ async function poll() { const chatId = String(msg.chat.id); // Access control - if (!isChatAllowed(chatId, ALLOWED_CHATS)) { + if (!isChatAllowed(ALLOWED_CHATS, chatId)) { console.log(`[ignored] chat ${chatId} not in allowed list`); continue; } @@ -197,6 +201,24 @@ async function poll() { continue; } + // Rate limiting: per-chat cooldown + const now = Date.now(); + const lastTime = lastMessageTime.get(chatId) || 0; + if (now - lastTime < COOLDOWN_MS) { + const wait = Math.ceil((COOLDOWN_MS - (now - lastTime)) / 1000); + await sendMessage(chatId, `Please wait ${wait}s before sending another message.`, msg.message_id); + continue; + } + + // Per-chat serialization: reject if this chat already has an active session + if (busyChats.has(chatId)) { + await sendMessage(chatId, "Still processing your previous message.", msg.message_id); + continue; + } + + lastMessageTime.set(chatId, now); + busyChats.add(chatId); + // Send typing indicator await sendTyping(chatId); @@ -211,6 +233,8 @@ async function poll() { } catch (err) { clearInterval(typingInterval); await sendMessage(chatId, `Error: ${err.message}`, msg.message_id); + } finally { + busyChats.delete(chatId); } } } @@ -218,8 +242,8 @@ async function poll() { console.error("Poll error:", err.message); } - // Continue polling - setTimeout(poll, 100); + // Continue polling (1s floor prevents tight-loop resource waste) + setTimeout(poll, 1000); } // ── Main ────────────────────────────────────────────────────────── diff --git a/nemoclaw/src/lib/chat-filter.test.ts b/src/lib/chat-filter.test.ts similarity index 51% rename from nemoclaw/src/lib/chat-filter.test.ts rename to src/lib/chat-filter.test.ts index 963d72651..d70e5d054 100644 --- a/nemoclaw/src/lib/chat-filter.test.ts +++ b/src/lib/chat-filter.test.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, it, expect } from "vitest"; -import { parseAllowedChatIds, isChatAllowed } from "../../dist/lib/chat-filter.js"; +import { parseAllowedChatIds, isChatAllowed } from "../../dist/lib/chat-filter"; describe("lib/chat-filter", () => { describe("parseAllowedChatIds", () => { @@ -19,34 +19,25 @@ describe("lib/chat-filter", () => { }); it("parses single chat ID", () => { - const result = parseAllowedChatIds("12345"); - expect(result).toEqual(new Set(["12345"])); + expect(parseAllowedChatIds("12345")).toEqual(["12345"]); }); it("parses comma-separated chat IDs with whitespace", () => { - const result = parseAllowedChatIds("111, 222 ,333"); - expect(result).toEqual(new Set(["111", "222", "333"])); - }); - - it("deduplicates repeated IDs", () => { - const result = parseAllowedChatIds("111,111,222"); - expect(result).toEqual(new Set(["111", "222"])); + expect(parseAllowedChatIds("111, 222 ,333")).toEqual(["111", "222", "333"]); }); }); describe("isChatAllowed", () => { - it("allows all chats when allowed set is null", () => { - expect(isChatAllowed("999", null)).toBe(true); + it("allows all chats when allowed list is null", () => { + expect(isChatAllowed(null, "999")).toBe(true); }); - it("allows chat in the allowed set", () => { - const allowed = new Set(["111", "222"]); - expect(isChatAllowed("111", allowed)).toBe(true); + it("allows chat in the allowed list", () => { + expect(isChatAllowed(["111", "222"], "111")).toBe(true); }); - it("rejects chat not in the allowed set", () => { - const allowed = new Set(["111", "222"]); - expect(isChatAllowed("999", allowed)).toBe(false); + it("rejects chat not in the allowed list", () => { + expect(isChatAllowed(["111", "222"], "999")).toBe(false); }); }); }); diff --git a/src/lib/chat-filter.ts b/src/lib/chat-filter.ts new file mode 100644 index 000000000..dfbcbd3b5 --- /dev/null +++ b/src/lib/chat-filter.ts @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Parse a comma-separated list of allowed chat IDs. + * Returns null if the input is empty or undefined (meaning: accept all). + */ +export function parseAllowedChatIds(raw: string | undefined): string[] | null { + if (!raw) return null; + const ids = raw + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + return ids.length > 0 ? ids : null; +} + +/** + * Check whether a chat ID is allowed by the parsed allowlist. + * + * When `allowedChats` is null every chat is accepted (open mode). + */ +export function isChatAllowed(allowedChats: string[] | null, chatId: string): boolean { + return !allowedChats || allowedChats.includes(chatId); +} diff --git a/nemoclaw/src/lib/resolve-openshell.test.ts b/src/lib/resolve-openshell.test.ts similarity index 99% rename from nemoclaw/src/lib/resolve-openshell.test.ts rename to src/lib/resolve-openshell.test.ts index 8035365b0..e62391983 100644 --- a/nemoclaw/src/lib/resolve-openshell.test.ts +++ b/src/lib/resolve-openshell.test.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, it, expect } from "vitest"; -import { resolveOpenshell } from "../../dist/lib/resolve-openshell.js"; +import { resolveOpenshell } from "../../dist/lib/resolve-openshell"; describe("lib/resolve-openshell", () => { it("returns command -v result when absolute path", () => { diff --git a/nemoclaw/src/lib/resolve-openshell.ts b/src/lib/resolve-openshell.ts similarity index 100% rename from nemoclaw/src/lib/resolve-openshell.ts rename to src/lib/resolve-openshell.ts diff --git a/src/lib/version.test.ts b/src/lib/version.test.ts new file mode 100644 index 000000000..66bfbd577 --- /dev/null +++ b/src/lib/version.test.ts @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { getVersion } from "../../dist/lib/version"; + +describe("lib/version", () => { + let testDir: string; + + beforeAll(() => { + testDir = mkdtempSync(join(tmpdir(), "version-test-")); + writeFileSync(join(testDir, "package.json"), JSON.stringify({ version: "1.2.3" })); + }); + + afterAll(() => { + rmSync(testDir, { recursive: true, force: true }); + }); + + it("falls back to package.json version when no git and no .version", () => { + expect(getVersion({ rootDir: testDir })).toBe("1.2.3"); + }); + + it("prefers .version file over package.json", () => { + writeFileSync(join(testDir, ".version"), "0.5.0-rc1\n"); + const result = getVersion({ rootDir: testDir }); + expect(result).toBe("0.5.0-rc1"); + rmSync(join(testDir, ".version")); + }); + + it("returns a string", () => { + expect(typeof getVersion({ rootDir: testDir })).toBe("string"); + }); +}); diff --git a/src/lib/version.ts b/src/lib/version.ts new file mode 100644 index 000000000..5dd9ca00f --- /dev/null +++ b/src/lib/version.ts @@ -0,0 +1,46 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +export interface VersionOptions { + /** Override the repo root directory. */ + rootDir?: string; +} + +/** + * Resolve the NemoClaw version from (in order): + * 1. `git describe --tags --match "v*"` — works in dev / source checkouts + * 2. `.version` file at repo root — stamped at publish time + * 3. `package.json` version — hard-coded fallback + */ +export function getVersion(opts: VersionOptions = {}): string { + // Compiled location: dist/lib/version.js → repo root is 2 levels up + const root = opts.rootDir ?? join(__dirname, "..", ".."); + + // 1. Try git (available in dev clones and CI) + try { + const raw = execFileSync("git", ["describe", "--tags", "--match", "v*"], { + cwd: root, + encoding: "utf-8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + if (raw) return raw.replace(/^v/, ""); + } catch { + // no git, or no matching tags — fall through + } + + // 2. Try .version file (stamped by prepublishOnly) + const versionFile = join(root, ".version"); + if (existsSync(versionFile)) { + const ver = readFileSync(versionFile, "utf-8").trim(); + if (ver) return ver; + } + + // 3. Fallback to package.json + const raw = readFileSync(join(root, "package.json"), "utf-8"); + const pkg = JSON.parse(raw) as { version: string }; + return pkg.version; +} From 564d4dc5c8f79c98f30f0f63021cf2fef38a81a4 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Thu, 2 Apr 2026 10:21:21 -0700 Subject: [PATCH 4/5] fix: remove stale nemoclaw/src/lib/ files from merge Co-Authored-By: Claude Opus 4.6 (1M context) --- nemoclaw/src/lib/chat-filter.test.ts | 52 ------------ nemoclaw/src/lib/chat-filter.ts | 26 ------ nemoclaw/src/lib/resolve-openshell.test.ts | 94 ---------------------- nemoclaw/src/lib/resolve-openshell.ts | 59 -------------- nemoclaw/src/lib/version.test.ts | 53 ------------ nemoclaw/src/lib/version.ts | 55 ------------- 6 files changed, 339 deletions(-) delete mode 100644 nemoclaw/src/lib/chat-filter.test.ts delete mode 100644 nemoclaw/src/lib/chat-filter.ts delete mode 100644 nemoclaw/src/lib/resolve-openshell.test.ts delete mode 100644 nemoclaw/src/lib/resolve-openshell.ts delete mode 100644 nemoclaw/src/lib/version.test.ts delete mode 100644 nemoclaw/src/lib/version.ts diff --git a/nemoclaw/src/lib/chat-filter.test.ts b/nemoclaw/src/lib/chat-filter.test.ts deleted file mode 100644 index 963d72651..000000000 --- a/nemoclaw/src/lib/chat-filter.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { describe, it, expect } from "vitest"; -import { parseAllowedChatIds, isChatAllowed } from "../../dist/lib/chat-filter.js"; - -describe("lib/chat-filter", () => { - describe("parseAllowedChatIds", () => { - it("returns null for undefined input", () => { - expect(parseAllowedChatIds(undefined)).toBeNull(); - }); - - it("returns null for empty string", () => { - expect(parseAllowedChatIds("")).toBeNull(); - }); - - it("returns null for whitespace-only string", () => { - expect(parseAllowedChatIds(" , , ")).toBeNull(); - }); - - it("parses single chat ID", () => { - const result = parseAllowedChatIds("12345"); - expect(result).toEqual(new Set(["12345"])); - }); - - it("parses comma-separated chat IDs with whitespace", () => { - const result = parseAllowedChatIds("111, 222 ,333"); - expect(result).toEqual(new Set(["111", "222", "333"])); - }); - - it("deduplicates repeated IDs", () => { - const result = parseAllowedChatIds("111,111,222"); - expect(result).toEqual(new Set(["111", "222"])); - }); - }); - - describe("isChatAllowed", () => { - it("allows all chats when allowed set is null", () => { - expect(isChatAllowed("999", null)).toBe(true); - }); - - it("allows chat in the allowed set", () => { - const allowed = new Set(["111", "222"]); - expect(isChatAllowed("111", allowed)).toBe(true); - }); - - it("rejects chat not in the allowed set", () => { - const allowed = new Set(["111", "222"]); - expect(isChatAllowed("999", allowed)).toBe(false); - }); - }); -}); diff --git a/nemoclaw/src/lib/chat-filter.ts b/nemoclaw/src/lib/chat-filter.ts deleted file mode 100644 index e844b37f3..000000000 --- a/nemoclaw/src/lib/chat-filter.ts +++ /dev/null @@ -1,26 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -/** - * Parse a comma-separated list of allowed chat IDs into a Set. - * Returns null if the input is empty or undefined (meaning: accept all). - */ -export function parseAllowedChatIds(raw: string | undefined): Set | null { - if (!raw) return null; - const ids = raw - .split(",") - .map((s) => s.trim()) - .filter((s) => s.length > 0); - return ids.length > 0 ? new Set(ids) : null; -} - -/** - * Check whether a chat ID is allowed. - * - * When `allowed` is null every chat is accepted (open mode). - * Otherwise the chat ID must be in the allowed set. - */ -export function isChatAllowed(chatId: string, allowed: Set | null): boolean { - if (allowed === null) return true; - return allowed.has(chatId); -} diff --git a/nemoclaw/src/lib/resolve-openshell.test.ts b/nemoclaw/src/lib/resolve-openshell.test.ts deleted file mode 100644 index 8035365b0..000000000 --- a/nemoclaw/src/lib/resolve-openshell.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { describe, it, expect } from "vitest"; -import { resolveOpenshell } from "../../dist/lib/resolve-openshell.js"; - -describe("lib/resolve-openshell", () => { - it("returns command -v result when absolute path", () => { - expect(resolveOpenshell({ commandVResult: "/usr/bin/openshell" })).toBe("/usr/bin/openshell"); - }); - - it("rejects non-absolute command -v result (alias)", () => { - expect( - resolveOpenshell({ commandVResult: "openshell", checkExecutable: () => false }), - ).toBeNull(); - }); - - it("rejects alias definition from command -v", () => { - expect( - resolveOpenshell({ - commandVResult: "alias openshell='echo pwned'", - checkExecutable: () => false, - }), - ).toBeNull(); - }); - - it("falls back to ~/.local/bin when command -v fails", () => { - expect( - resolveOpenshell({ - commandVResult: null, - checkExecutable: (p) => p === "/fakehome/.local/bin/openshell", - home: "/fakehome", - }), - ).toBe("/fakehome/.local/bin/openshell"); - }); - - it("falls back to /usr/local/bin", () => { - expect( - resolveOpenshell({ - commandVResult: null, - checkExecutable: (p) => p === "/usr/local/bin/openshell", - }), - ).toBe("/usr/local/bin/openshell"); - }); - - it("falls back to /usr/bin", () => { - expect( - resolveOpenshell({ - commandVResult: null, - checkExecutable: (p) => p === "/usr/bin/openshell", - }), - ).toBe("/usr/bin/openshell"); - }); - - it("prefers ~/.local/bin over /usr/local/bin", () => { - expect( - resolveOpenshell({ - commandVResult: null, - checkExecutable: (p) => - p === "/fakehome/.local/bin/openshell" || p === "/usr/local/bin/openshell", - home: "/fakehome", - }), - ).toBe("/fakehome/.local/bin/openshell"); - }); - - it("returns null when openshell not found anywhere", () => { - expect( - resolveOpenshell({ - commandVResult: null, - checkExecutable: () => false, - }), - ).toBeNull(); - }); - - it("skips home candidate when home is not absolute", () => { - expect( - resolveOpenshell({ - commandVResult: null, - checkExecutable: () => false, - home: "relative/path", - }), - ).toBeNull(); - }); - - it("returns null for null commandVResult with no executable found", () => { - expect( - resolveOpenshell({ - commandVResult: null, - checkExecutable: () => false, - home: undefined, - }), - ).toBeNull(); - }); -}); diff --git a/nemoclaw/src/lib/resolve-openshell.ts b/nemoclaw/src/lib/resolve-openshell.ts deleted file mode 100644 index b55fbfac8..000000000 --- a/nemoclaw/src/lib/resolve-openshell.ts +++ /dev/null @@ -1,59 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { execSync } from "node:child_process"; -import { accessSync, constants } from "node:fs"; - -export interface ResolveOpenshellOptions { - /** Mock result for `command -v` (undefined = run real command). */ - commandVResult?: string | null; - /** Override executable check (default: fs.accessSync X_OK). */ - checkExecutable?: (path: string) => boolean; - /** HOME directory override. */ - home?: string; -} - -/** - * Resolve the openshell binary path. - * - * Checks `command -v` first (must return an absolute path to prevent alias - * injection), then falls back to common installation directories. - */ -export function resolveOpenshell(opts: ResolveOpenshellOptions = {}): string | null { - const home = opts.home ?? process.env.HOME; - - // Step 1: command -v - if (opts.commandVResult === undefined) { - try { - const found = execSync("command -v openshell", { encoding: "utf-8" }).trim(); - if (found.startsWith("/")) return found; - } catch { - /* ignored */ - } - } else if (opts.commandVResult?.startsWith("/")) { - return opts.commandVResult; - } - - // Step 2: fallback candidates - const checkExecutable = - opts.checkExecutable ?? - ((p: string): boolean => { - try { - accessSync(p, constants.X_OK); - return true; - } catch { - return false; - } - }); - - const candidates = [ - ...(home?.startsWith("/") ? [`${home}/.local/bin/openshell`] : []), - "/usr/local/bin/openshell", - "/usr/bin/openshell", - ]; - for (const p of candidates) { - if (checkExecutable(p)) return p; - } - - return null; -} diff --git a/nemoclaw/src/lib/version.test.ts b/nemoclaw/src/lib/version.test.ts deleted file mode 100644 index fca84716b..000000000 --- a/nemoclaw/src/lib/version.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { getVersion } from "../../dist/lib/version.js"; - -const store = new Map(); - -vi.mock("node:fs", async (importOriginal) => { - const original = await importOriginal(); - return { - ...original, - readFileSync: (p: string, _enc: string) => { - const content = store.get(p); - if (content === undefined) throw new Error(`ENOENT: ${p}`); - return content; - }, - }; -}); - -describe("lib/version", () => { - beforeEach(() => { - store.clear(); - }); - - it("reads version from package.json", () => { - store.set("/test-dir/package.json", JSON.stringify({ version: "1.2.3" })); - const info = getVersion({ packageDir: "/test-dir", gitDescribeResult: null }); - expect(info.version).toBe("1.2.3"); - expect(info.gitDescribe).toBeNull(); - expect(info.display).toBe("1.2.3"); - }); - - it("includes git describe when available", () => { - store.set("/test-dir/package.json", JSON.stringify({ version: "1.2.3" })); - const info = getVersion({ packageDir: "/test-dir", gitDescribeResult: "v1.2.3-5-gabcdef" }); - expect(info.version).toBe("1.2.3"); - expect(info.gitDescribe).toBe("v1.2.3-5-gabcdef"); - expect(info.display).toBe("1.2.3 (v1.2.3-5-gabcdef)"); - }); - - it("handles dirty git state", () => { - store.set("/test-dir/package.json", JSON.stringify({ version: "0.1.0" })); - const info = getVersion({ packageDir: "/test-dir", gitDescribeResult: "v0.1.0-dirty" }); - expect(info.display).toBe("0.1.0 (v0.1.0-dirty)"); - }); - - it("returns version without suffix when gitDescribe is null", () => { - store.set("/test-dir/package.json", JSON.stringify({ version: "2.0.0" })); - const info = getVersion({ packageDir: "/test-dir", gitDescribeResult: null }); - expect(info.display).toBe("2.0.0"); - }); -}); diff --git a/nemoclaw/src/lib/version.ts b/nemoclaw/src/lib/version.ts deleted file mode 100644 index 66325056c..000000000 --- a/nemoclaw/src/lib/version.ts +++ /dev/null @@ -1,55 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { execSync } from "node:child_process"; -import { readFileSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; - -export interface VersionInfo { - /** Semver from package.json (e.g. "0.1.0"). */ - version: string; - /** Git describe tag, or null if not in a git repo / git unavailable. */ - gitDescribe: string | null; - /** Display string: version + git describe suffix when available. */ - display: string; -} - -export interface VersionOptions { - /** Override the directory containing package.json. */ - packageDir?: string; - /** Mock git describe output (undefined = run real command). */ - gitDescribeResult?: string | null; -} - -/** - * Read the CLI version from package.json and optionally enrich with - * `git describe --tags --always --dirty` for dev builds. - */ -export function getVersion(opts: VersionOptions = {}): VersionInfo { - // Compiled location: nemoclaw/dist/lib/version.js → repo root is 3 levels up - const thisDir = dirname(fileURLToPath(import.meta.url)); - const packageDir = opts.packageDir ?? join(thisDir, "..", "..", ".."); - const raw = readFileSync(join(packageDir, "package.json"), "utf-8"); - const pkg = JSON.parse(raw) as { version: string }; - const version = pkg.version; - - let gitDescribe: string | null = null; - if (opts.gitDescribeResult !== undefined) { - gitDescribe = opts.gitDescribeResult; - } else { - try { - gitDescribe = execSync("git describe --tags --always --dirty", { - encoding: "utf-8", - cwd: packageDir, - stdio: ["ignore", "pipe", "ignore"], - }).trim(); - } catch { - /* not in a git repo or git unavailable */ - } - } - - const display = gitDescribe ? `${version} (${gitDescribe})` : version; - - return { version, gitDescribe, display }; -} From f66875dedb4c50046d595d8cc0a656fe6eb2ab18 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Thu, 2 Apr 2026 11:04:30 -0700 Subject: [PATCH 5/5] test: remove duplicate resolve-openshell test case The "null commandVResult with home: undefined" test was functionally identical to "returns null when openshell not found anywhere" since opts.home ?? process.env.HOME treats undefined identically. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/lib/resolve-openshell.test.ts | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/lib/resolve-openshell.test.ts b/src/lib/resolve-openshell.test.ts index e62391983..15b90cb8f 100644 --- a/src/lib/resolve-openshell.test.ts +++ b/src/lib/resolve-openshell.test.ts @@ -82,13 +82,4 @@ describe("lib/resolve-openshell", () => { ).toBeNull(); }); - it("returns null for null commandVResult with no executable found", () => { - expect( - resolveOpenshell({ - commandVResult: null, - checkExecutable: () => false, - home: undefined, - }), - ).toBeNull(); - }); });