Skip to content
5 changes: 5 additions & 0 deletions bin/lib/chat-filter.js
Original file line number Diff line number Diff line change
@@ -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");
48 changes: 2 additions & 46 deletions bin/lib/resolve-openshell.js
Original file line number Diff line number Diff line change
@@ -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");
5 changes: 5 additions & 0 deletions bin/lib/version.js
Original file line number Diff line number Diff line change
@@ -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");
9 changes: 5 additions & 4 deletions bin/nemoclaw.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──────────────────────────────────────────────

Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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;
Expand Down
52 changes: 52 additions & 0 deletions nemoclaw/src/lib/chat-filter.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
26 changes: 26 additions & 0 deletions nemoclaw/src/lib/chat-filter.ts
Original file line number Diff line number Diff line change
@@ -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<string> | 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<string> | null): boolean {
if (allowed === null) return true;
return allowed.has(chatId);
}
94 changes: 94 additions & 0 deletions nemoclaw/src/lib/resolve-openshell.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
59 changes: 59 additions & 0 deletions nemoclaw/src/lib/resolve-openshell.ts
Original file line number Diff line number Diff line change
@@ -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;
}
53 changes: 53 additions & 0 deletions nemoclaw/src/lib/version.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>();

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");
});
});
Loading