Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions bin/lib/resolve-openshell.js
Original file line number Diff line number Diff line change
@@ -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 };
5 changes: 4 additions & 1 deletion bin/nemoclaw.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
4 changes: 2 additions & 2 deletions scripts/start-services.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
9 changes: 8 additions & 1 deletion scripts/telegram-bridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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`;
Expand Down
119 changes: 119 additions & 0 deletions test/service-env.test.js
Original file line number Diff line number Diff line change
@@ -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");
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
});
Loading