Skip to content
Closed
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
23 changes: 14 additions & 9 deletions bin/lib/onboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ const nim = require("./nim");
const onboardSession = require("./onboard-session");
const policies = require("./policies");
const { checkPortAvailable, ensureSwap, getMemoryInfo } = require("./preflight");
const { validateSandboxName } = require("./sandbox-names");
function secureTempFile(prefix, ext = "") {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), `${prefix}-`));
return path.join(dir, `${prefix}${ext}`);
Expand Down Expand Up @@ -2278,16 +2279,18 @@ async function promptValidatedSandboxName() {
);
const sandboxName = (nameAnswer || "my-assistant").trim().toLowerCase();

// Validate: RFC 1123 subdomain — lowercase alphanumeric and hyphens,
// must start and end with alphanumeric (required by Kubernetes/OpenShell)
if (/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(sandboxName)) {
return sandboxName;
try {
return validateSandboxName(sandboxName);
} catch (err) {
console.error(` ${err.message}`);
if (/reserved by the CLI/.test(err.message)) {
console.error(" Choose a different name to avoid colliding with top-level commands.");
} else {
console.error(" Names must be lowercase, contain only letters, numbers, and hyphens,");
console.error(" and must start and end with a letter or number.");
}
}

console.error(` Invalid sandbox name: '${sandboxName}'`);
console.error(" Names must be lowercase, contain only letters, numbers, and hyphens,");
console.error(" and must start and end with a letter or number.");

// Non-interactive runs cannot re-prompt — abort so the caller can fix the
// NEMOCLAW_SANDBOX_NAME env var and retry.
if (isNonInteractive()) {
Expand All @@ -2310,7 +2313,9 @@ async function createSandbox(
) {
step(5, 7, "Creating sandbox");

const sandboxName = sandboxNameOverride || (await promptValidatedSandboxName());
const sandboxName = sandboxNameOverride
? validateSandboxName(String(sandboxNameOverride).trim().toLowerCase())
: await promptValidatedSandboxName();
const chatUiUrl = process.env.CHAT_UI_URL || `http://127.0.0.1:${CONTROL_UI_PORT}`;

// Reconcile local registry state with the live OpenShell gateway state.
Expand Down
40 changes: 40 additions & 0 deletions bin/lib/sandbox-names.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

const { validateName } = require("./runner");

const RESERVED_SANDBOX_NAMES = new Set([
"onboard",
"list",
"deploy",
"setup",
"setup-spark",
"start",
"telegram",
"stop",
"status",
"debug",
"uninstall",
"help",
]);

const SANDBOX_ACTIONS = new Set([
"connect",
"status",
"logs",
"policy-add",
"policy-list",
"destroy",
]);

function validateSandboxName(name, label = "sandbox name") {
const validName = validateName(name, label);
if (RESERVED_SANDBOX_NAMES.has(validName)) {
throw new Error(
`Invalid ${label}: '${validName}'. This name is reserved by the CLI. Use a different name, or target an existing sandbox with 'nemoclaw -- ${validName} <action>'.`,
);
}
return validName;
}

module.exports = { RESERVED_SANDBOX_NAMES, SANDBOX_ACTIONS, validateSandboxName };
164 changes: 140 additions & 24 deletions bin/nemoclaw.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const {
ensureApiKey,
ensureGithubToken,
getCredential,
saveCredential,
isRepoPrivate,
} = require("./lib/credentials");
const registry = require("./lib/registry");
Expand All @@ -44,26 +45,11 @@ const policies = require("./lib/policies");
const { parseGatewayInference } = require("./lib/inference-config");
const onboardSession = require("./lib/onboard-session");
const { parseLiveSandboxNames } = require("./lib/runtime-recovery");
const { RESERVED_SANDBOX_NAMES, SANDBOX_ACTIONS } = require("./lib/sandbox-names");

// ── Global commands ──────────────────────────────────────────────

const GLOBAL_COMMANDS = new Set([
"onboard",
"list",
"deploy",
"setup",
"setup-spark",
"start",
"stop",
"status",
"debug",
"uninstall",
"help",
"--help",
"-h",
"--version",
"-v",
]);
const GLOBAL_COMMANDS = new Set([...RESERVED_SANDBOX_NAMES, "--help", "-h", "--version", "-v"]);

const REMOTE_UNINSTALL_URL =
"https://raw.githubusercontent.com/NVIDIA/NemoClaw/refs/heads/main/uninstall.sh";
Expand Down Expand Up @@ -719,6 +705,9 @@ async function deploy(instanceName) {
if (ghToken) envLines.push(`GITHUB_TOKEN=${shellQuote(ghToken)}`);
const tgToken = getCredential("TELEGRAM_BOT_TOKEN");
if (tgToken) envLines.push(`TELEGRAM_BOT_TOKEN=${shellQuote(tgToken)}`);
const { allowedChatIds, discoveryFlag } = getTelegramServiceEnv();
envLines.push(`ALLOWED_CHAT_IDS=${shellQuote(allowedChatIds)}`);
envLines.push(`NEMOCLAW_TELEGRAM_DISCOVERY=${discoveryFlag}`);
const discordToken = getCredential("DISCORD_BOT_TOKEN");
if (discordToken) envLines.push(`DISCORD_BOT_TOKEN=${shellQuote(discordToken)}`);
const slackToken = getCredential("SLACK_BOT_TOKEN");
Expand Down Expand Up @@ -766,18 +755,127 @@ async function deploy(instanceName) {
);
}

async function start() {
function normalizeTelegramChatIds(rawValue) {
const chatIds = String(rawValue || "")
.split(/[,\s]+/)
.map((value) => value.trim())
.filter(Boolean);
if (chatIds.length === 0) {
throw new Error("At least one Telegram chat ID is required.");
}
for (const chatId of chatIds) {
if (!/^-?\d+$/.test(chatId)) {
throw new Error(`Invalid Telegram chat ID: ${chatId}`);
}
}
return [...new Set(chatIds)].join(",");
}

function getTelegramServiceEnv(discoveryMode = false) {
return {
allowedChatIds: getCredential("ALLOWED_CHAT_IDS") || "",
discoveryFlag: discoveryMode ? "1" : "0",
};
}

function rejectUnexpectedTelegramOperands(action, rest = []) {
if (rest.length === 0) return;
console.error(` Unknown telegram ${action} option(s): ${rest.join(", ")}`);
process.exit(1);
}

function printReservedSandboxHint(name, args = []) {
const suffix = args.length > 0 ? ` ${args.join(" ")}` : "";
console.error(` Sandbox '${name}' conflicts with a global command.`);
console.error(` Use 'nemoclaw -- ${name}${suffix}' to target the sandbox explicitly.`);
}

async function start(args = []) {
const supportedFlags = new Set(["--discover-chat-id"]);
const unknown = args.filter((arg) => !supportedFlags.has(arg));
if (unknown.length > 0) {
console.error(` Unknown start option(s): ${unknown.join(", ")}`);
process.exit(1);
}

const discoveryMode = args.includes("--discover-chat-id");
const { defaultSandbox } = registry.listSandboxes();
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"`);
const envAssignments = [];
if (safeName) envAssignments.push(`SANDBOX_NAME=${shellQuote(safeName)}`);
const { allowedChatIds, discoveryFlag } = getTelegramServiceEnv(discoveryMode);
envAssignments.push(`ALLOWED_CHAT_IDS=${shellQuote(allowedChatIds)}`);
envAssignments.push(`NEMOCLAW_TELEGRAM_DISCOVERY=${discoveryFlag}`);
const envPrefix = envAssignments.length > 0 ? `${envAssignments.join(" ")} ` : "";
run(`${envPrefix}bash "${SCRIPTS}/start-services.sh"`);
}

function stop() {
run(`bash "${SCRIPTS}/start-services.sh" --stop`);
}

function telegramHelp() {
console.log(`
${G}Telegram:${R}
nemoclaw telegram allow <chat-id[,chat-id...]> Save allowed Telegram chat IDs
nemoclaw telegram show Show saved Telegram chat IDs
nemoclaw telegram clear Remove the saved Telegram allowlist
nemoclaw telegram discover Start services in discovery-only mode

${D}Tip:${R} use ${B}nemoclaw start --discover-chat-id${R}${D} to reply with your chat ID
without forwarding messages to the agent.${R}
`);
}

async function telegramCommand(args = []) {
const [action, ...rest] = args;
switch (action) {
case undefined:
case "help":
case "--help":
case "-h":
telegramHelp();
return;
case "allow": {
let allowlist;
try {
allowlist = normalizeTelegramChatIds(rest.join(","));
} catch (err) {
console.error(` ${err.message}`);
process.exit(1);
}
saveCredential("ALLOWED_CHAT_IDS", allowlist);
console.log(` Saved Telegram allowlist: ${allowlist}`);
console.log(" Stored in ~/.nemoclaw/credentials.json (mode 600)");
return;
}
case "show": {
rejectUnexpectedTelegramOperands("show", rest);
const allowlist = getCredential("ALLOWED_CHAT_IDS");
if (!allowlist) {
console.log(" No Telegram allowlist configured.");
return;
}
console.log(` Telegram allowlist: ${allowlist}`);
return;
}
case "clear":
rejectUnexpectedTelegramOperands("clear", rest);
saveCredential("ALLOWED_CHAT_IDS", "");
console.log(" Cleared Telegram allowlist.");
return;
case "discover":
rejectUnexpectedTelegramOperands("discover", rest);
await start(["--discover-chat-id"]);
return;
default:
console.error(` Unknown telegram action: ${action}`);
console.error(" Valid actions: allow, show, clear, discover");
process.exit(1);
}
}

function debug(args) {
const result = spawnSync("bash", [path.join(SCRIPTS, "debug.sh"), ...args], {
stdio: "inherit",
Expand Down Expand Up @@ -1138,6 +1236,7 @@ function help() {
nemoclaw <name> status Sandbox health + NIM status
nemoclaw <name> logs ${D}[--follow]${R} Stream sandbox logs
nemoclaw <name> destroy Stop NIM + delete sandbox ${D}(--yes to skip prompt)${R}
nemoclaw -- <name> <action> Target a sandbox whose name matches a global command

${G}Policy Presets:${R}
nemoclaw <name> policy-add Add a network or filesystem policy preset
Expand All @@ -1147,9 +1246,10 @@ function help() {
nemoclaw deploy <instance> Deploy to a Brev VM and start services

${G}Services:${R}
nemoclaw start Start auxiliary services ${D}(Telegram, tunnel)${R}
nemoclaw start ${D}[--discover-chat-id]${R} Start auxiliary services ${D}(Telegram, tunnel)${R}
nemoclaw stop Stop all services
nemoclaw status Show sandbox list and service status
nemoclaw telegram [help] Manage Telegram allowlist + discovery mode

Troubleshooting:
nemoclaw debug [--quick] Collect diagnostics for bug reports
Expand All @@ -1171,7 +1271,9 @@ function help() {

// ── Dispatch ─────────────────────────────────────────────────────

const [cmd, ...args] = process.argv.slice(2);
const rawArgs = process.argv.slice(2);
const forceSandboxDispatch = rawArgs[0] === "--";
const [cmd, ...args] = forceSandboxDispatch ? rawArgs.slice(1) : rawArgs;

// eslint-disable-next-line complexity
(async () => {
Expand All @@ -1182,7 +1284,18 @@ const [cmd, ...args] = process.argv.slice(2);
}

// Global commands
if (GLOBAL_COMMANDS.has(cmd)) {
if (
!forceSandboxDispatch &&
GLOBAL_COMMANDS.has(cmd) &&
registry.getSandbox(cmd) &&
args[0] &&
SANDBOX_ACTIONS.has(args[0])
) {
printReservedSandboxHint(cmd, args);
process.exit(1);
}

if (!forceSandboxDispatch && GLOBAL_COMMANDS.has(cmd)) {
switch (cmd) {
case "onboard":
await onboard(args);
Expand All @@ -1197,7 +1310,10 @@ const [cmd, ...args] = process.argv.slice(2);
await deploy(args[0]);
break;
case "start":
await start();
await start(args);
break;
case "telegram":
await telegramCommand(args);
break;
case "stop":
stop();
Expand Down
3 changes: 3 additions & 0 deletions docs/deployment/deploy-to-remote-gpu.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ The deploy script performs the following steps on the VM:
3. Runs `nemoclaw onboard` (the setup wizard) to create the gateway, register providers, and launch the sandbox.
4. Starts auxiliary services, such as the Telegram bridge and cloudflared tunnel.

If you configured a Telegram bot token but not an allowlist yet, the bridge stays disabled.
Save `ALLOWED_CHAT_IDS` with `nemoclaw telegram allow <chat-id>` or run discovery mode with `nemoclaw start --discover-chat-id` to enable it.

## Connect to the Remote Sandbox

After deployment finishes, the deploy command opens an interactive shell inside the remote sandbox.
Expand Down
27 changes: 23 additions & 4 deletions docs/deployment/set-up-telegram-bridge.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,19 @@ The `start` command launches the following services:
- The Telegram bridge forwards messages between Telegram and the agent.
- The cloudflared tunnel provides external access to the sandbox.

The Telegram bridge starts only when the `TELEGRAM_BOT_TOKEN` environment variable is set.
The Telegram bridge starts only when the following are configured:

- `TELEGRAM_BOT_TOKEN`
- `NVIDIA_API_KEY`
- `ALLOWED_CHAT_IDS`

If you do not know your Telegram chat ID yet, start the bridge in discovery-only mode:

```console
$ nemoclaw start --discover-chat-id
```

Then send any message to the bot. The bridge replies with your chat ID and does not forward the message to the agent.

## Verify the Services

Expand All @@ -71,15 +83,22 @@ The output shows the status of all auxiliary services.
Open Telegram, find your bot, and send a message.
The bridge forwards the message to the OpenClaw agent inside the sandbox and returns the agent response.

## Restrict Access by Chat ID
## Allow Telegram Chats by Chat ID

To restrict which Telegram chats can interact with the agent, set the `ALLOWED_CHAT_IDS` environment variable to a comma-separated list of Telegram chat IDs:
Save the Telegram chat IDs allowed to interact with the agent:

```console
$ export ALLOWED_CHAT_IDS="123456789,987654321"
$ nemoclaw telegram allow 123456789,987654321
$ nemoclaw start
```

To inspect or clear the saved allowlist:

```console
$ nemoclaw telegram show
$ nemoclaw telegram clear
```

## Stop the Services

To stop the Telegram bridge and all other auxiliary services:
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ The following environment variables configure optional services and local access
| Variable | Purpose |
|---|---|
| `TELEGRAM_BOT_TOKEN` | Bot token for the Telegram bridge. |
| `ALLOWED_CHAT_IDS` | Comma-separated list of Telegram chat IDs allowed to message the agent. |
| `ALLOWED_CHAT_IDS` | Comma-separated list of Telegram chat IDs allowed to message the agent. Required for normal Telegram bridge forwarding. |
| `CHAT_UI_URL` | URL for the optional chat UI endpoint. |

For normal setup and reconfiguration, prefer `nemoclaw onboard` over editing these files by hand.
Loading