diff --git a/bin/lib/resolve-openshell.js b/bin/lib/resolve-openshell.js new file mode 100644 index 00000000000..6e89ee1f134 --- /dev/null +++ b/bin/lib/resolve-openshell.js @@ -0,0 +1,49 @@ +// 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 {} + } 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 }; diff --git a/bin/nemoclaw.js b/bin/nemoclaw.js index 07bb3d5b5a2..686a5dc31e0 100755 --- a/bin/nemoclaw.js +++ b/bin/nemoclaw.js @@ -134,7 +134,10 @@ async function deploy(instanceName) { async function start() { await ensureApiKey(); - run(`bash "${SCRIPTS}/start-services.sh"`); + const { defaultSandbox } = registry.listSandboxes(); + const safeName = defaultSandbox && /^[a-zA-Z0-9._-]+$/.test(defaultSandbox) ? defaultSandbox : null; + const sandboxEnv = safeName ? `SANDBOX_NAME="${safeName}"` : ""; + run(`${sandboxEnv} bash "${SCRIPTS}/start-services.sh"`); } function stop() { diff --git a/scripts/start-services.sh b/scripts/start-services.sh index 7c3bd30ecc8..cbce0f18359 100755 --- a/scripts/start-services.sh +++ b/scripts/start-services.sh @@ -19,7 +19,7 @@ REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" DASHBOARD_PORT="${DASHBOARD_PORT:-18789}" # ── Parse flags ────────────────────────────────────────────────── -SANDBOX_NAME="${NEMOCLAW_SANDBOX:-default}" +SANDBOX_NAME="${NEMOCLAW_SANDBOX:-${SANDBOX_NAME:-default}}" ACTION="start" while [ $# -gt 0 ]; do @@ -140,7 +140,7 @@ do_start() { # Telegram bridge (only if token provided) if [ -n "${TELEGRAM_BOT_TOKEN:-}" ]; then - start_service telegram-bridge \ + SANDBOX_NAME="$SANDBOX_NAME" start_service telegram-bridge \ node "$REPO_DIR/scripts/telegram-bridge.js" fi diff --git a/scripts/telegram-bridge.js b/scripts/telegram-bridge.js index 5d1af0be5ed..80a29069d8a 100755 --- a/scripts/telegram-bridge.js +++ b/scripts/telegram-bridge.js @@ -18,6 +18,13 @@ const https = require("https"); const { execSync, spawn } = require("child_process"); +const { resolveOpenshell } = require("../bin/lib/resolve-openshell"); + +const OPENSHELL = resolveOpenshell(); +if (!OPENSHELL) { + console.error("openshell not found on PATH or in common locations"); + process.exit(1); +} const TOKEN = process.env.TELEGRAM_BOT_TOKEN; const API_KEY = process.env.NVIDIA_API_KEY; @@ -85,7 +92,7 @@ async function sendTyping(chatId) { function runAgentInSandbox(message, sessionId) { return new Promise((resolve) => { - const sshConfig = execSync(`openshell sandbox ssh-config ${SANDBOX}`, { encoding: "utf-8" }); + const sshConfig = execSync(`"${OPENSHELL}" sandbox ssh-config "${SANDBOX}"`, { encoding: "utf-8" }); // Write temp ssh config const confPath = `/tmp/nemoclaw-tg-ssh-${sessionId}.conf`; diff --git a/test/service-env.test.js b/test/service-env.test.js new file mode 100644 index 00000000000..cd822085dc6 --- /dev/null +++ b/test/service-env.test.js @@ -0,0 +1,119 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +const { describe, it } = require("node:test"); +const assert = require("node:assert/strict"); +const { execSync } = require("child_process"); +const { resolveOpenshell } = require("../bin/lib/resolve-openshell"); + +describe("service environment", () => { + describe("resolveOpenshell logic", () => { + it("returns command -v result when absolute path", () => { + assert.equal( + resolveOpenshell({ commandVResult: "/usr/bin/openshell" }), + "/usr/bin/openshell" + ); + }); + + it("rejects non-absolute command -v result (alias)", () => { + assert.equal( + resolveOpenshell({ commandVResult: "openshell", checkExecutable: () => false }), + null + ); + }); + + it("rejects alias definition from command -v", () => { + assert.equal( + resolveOpenshell({ commandVResult: "alias openshell='echo pwned'", checkExecutable: () => false }), + null + ); + }); + + it("falls back to ~/.local/bin when command -v fails", () => { + assert.equal( + resolveOpenshell({ + commandVResult: null, + checkExecutable: (p) => p === "/fakehome/.local/bin/openshell", + home: "/fakehome", + }), + "/fakehome/.local/bin/openshell" + ); + }); + + it("falls back to /usr/local/bin", () => { + assert.equal( + resolveOpenshell({ + commandVResult: null, + checkExecutable: (p) => p === "/usr/local/bin/openshell", + }), + "/usr/local/bin/openshell" + ); + }); + + it("falls back to /usr/bin", () => { + assert.equal( + resolveOpenshell({ + commandVResult: null, + checkExecutable: (p) => p === "/usr/bin/openshell", + }), + "/usr/bin/openshell" + ); + }); + + it("prefers ~/.local/bin over /usr/local/bin", () => { + assert.equal( + resolveOpenshell({ + commandVResult: null, + checkExecutable: (p) => p === "/fakehome/.local/bin/openshell" || p === "/usr/local/bin/openshell", + home: "/fakehome", + }), + "/fakehome/.local/bin/openshell" + ); + }); + + it("returns null when openshell not found anywhere", () => { + assert.equal( + resolveOpenshell({ + commandVResult: null, + checkExecutable: () => false, + }), + null + ); + }); + }); + + describe("SANDBOX_NAME defaulting", () => { + it("start-services.sh preserves existing SANDBOX_NAME", () => { + const result = execSync( + 'bash -c \'SANDBOX_NAME="${NEMOCLAW_SANDBOX:-${SANDBOX_NAME:-default}}"; export SANDBOX_NAME; bash -c "echo \\$SANDBOX_NAME"\'', + { + encoding: "utf-8", + env: { ...process.env, NEMOCLAW_SANDBOX: "", SANDBOX_NAME: "my-box" }, + } + ).trim(); + assert.equal(result, "my-box"); + }); + + it("start-services.sh uses NEMOCLAW_SANDBOX over SANDBOX_NAME", () => { + const result = execSync( + 'bash -c \'SANDBOX_NAME="${NEMOCLAW_SANDBOX:-${SANDBOX_NAME:-default}}"; export SANDBOX_NAME; bash -c "echo \\$SANDBOX_NAME"\'', + { + encoding: "utf-8", + env: { ...process.env, NEMOCLAW_SANDBOX: "from-env", SANDBOX_NAME: "old" }, + } + ).trim(); + assert.equal(result, "from-env"); + }); + + it("start-services.sh falls back to default when both unset", () => { + const result = execSync( + 'bash -c \'SANDBOX_NAME="${NEMOCLAW_SANDBOX:-${SANDBOX_NAME:-default}}"; export SANDBOX_NAME; bash -c "echo \\$SANDBOX_NAME"\'', + { + encoding: "utf-8", + env: { ...process.env, NEMOCLAW_SANDBOX: "", SANDBOX_NAME: "" }, + } + ).trim(); + assert.equal(result, "default"); + }); + }); +});