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
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ config = { \
'agents': {'defaults': {'model': {'primary': primary_model_ref}}}, \
'models': {'mode': 'merge', 'providers': providers}, \
'channels': {'defaults': {'configWrites': False}}, \
'update': {'checkOnStart': False}, \
'gateway': { \
'mode': 'local', \
'controlUi': { \
Expand Down
30 changes: 16 additions & 14 deletions bin/lib/onboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ const {
getMemoryInfo,
planHostRemediation,
} = require("./preflight");
const { validateSandboxName } = require("./sandbox-names");

// Typed modules (compiled from src/lib/*.ts → dist/lib/*.js)
const gatewayState = require("../../dist/lib/gateway-state");
Expand Down Expand Up @@ -2403,20 +2404,19 @@ async function promptValidatedSandboxName() {
);
const sandboxName = (nameAnswer || "my-assistant").trim().toLowerCase();

// Validate: RFC 1123 subdomain — lowercase alphanumeric and hyphens,
// must start with a letter (not a digit) to satisfy Kubernetes naming.
if (/^[a-z]([a-z0-9-]*[a-z0-9])?$/.test(sandboxName)) {
return sandboxName;
}

console.error(` Invalid sandbox name: '${sandboxName}'`);
if (/^[0-9]/.test(sandboxName)) {
console.error(" Names must start with a letter, not a digit.");
} else {
console.error(" Names must be lowercase, contain only letters, numbers, and hyphens,");
console.error(" must start with a letter, and end with a letter or number.");
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 if (/^[0-9]/.test(sandboxName)) {
console.error(" Names must start with a letter, not a digit.");
} else {
console.error(" Names must be lowercase, contain only letters, numbers, and hyphens,");
console.error(" must start with a letter, 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 Down Expand Up @@ -2445,7 +2445,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
45 changes: 45 additions & 0 deletions bin/lib/sandbox-names.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// 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 (!/^[a-z](?:[a-z0-9-]*[a-z0-9])?$/.test(validName)) {
throw new Error(
`Invalid ${label}: '${validName}'. Must start with a letter and use lowercase letters, numbers, and internal hyphens only.`,
);
}
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 };
160 changes: 137 additions & 23 deletions bin/nemoclaw.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ const {
} = require("./lib/runner");
const { resolveOpenshell } = require("./lib/resolve-openshell");
const { startGatewayForRecovery } = require("./lib/onboard");
const { getCredential } = require("./lib/credentials");
const { getCredential, saveCredential } = require("./lib/credentials");
const registry = require("./lib/registry");
const nim = require("./lib/nim");
const policies = require("./lib/policies");
Expand All @@ -40,27 +40,12 @@ const { getVersion } = require("./lib/version");
const onboardSession = require("./lib/onboard-session");
const { parseLiveSandboxNames } = require("./lib/runtime-recovery");
const { NOTICE_ACCEPT_ENV, NOTICE_ACCEPT_FLAG } = require("./lib/usage-notice");
const { RESERVED_SANDBOX_NAMES, SANDBOX_ACTIONS } = require("./lib/sandbox-names");
const { executeDeploy } = require("../dist/lib/deploy");

// ── 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 @@ -831,14 +816,64 @@ async function deploy(instanceName) {
});
}

async function start() {
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 { startAll } = require("./lib/services");
const { defaultSandbox } = registry.listSandboxes();
const safeName =
defaultSandbox && /^[a-zA-Z0-9._-]+$/.test(defaultSandbox) ? defaultSandbox : null;
const { allowedChatIds, discoveryFlag } = getTelegramServiceEnv(discoveryMode);
if (allowedChatIds) {
process.env.ALLOWED_CHAT_IDS = allowedChatIds;
} else {
delete process.env.ALLOWED_CHAT_IDS;
}
process.env.NEMOCLAW_TELEGRAM_DISCOVERY = discoveryFlag;
await startAll({ sandboxName: safeName || undefined });
}

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.`);
}

function stop() {
const { stopAll } = require("./lib/services");
const { defaultSandbox } = registry.listSandboxes();
Expand All @@ -847,6 +882,67 @@ function stop() {
stopAll({ sandboxName: safeName || undefined });
}

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 { runDebug } = require("./lib/debug");
const opts = {};
Expand Down Expand Up @@ -1270,6 +1366,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 @@ -1281,9 +1378,10 @@ function help() {
nemoclaw deploy <instance> Deprecated Brev-specific bootstrap path

${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 @@ -1305,7 +1403,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 @@ -1316,7 +1416,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 @@ -1331,7 +1442,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 @@ -69,6 +69,9 @@ The legacy compatibility flow performs the following steps on the VM:

By default, the compatibility wrapper asks Brev to provision on `gcp`. Override this with `NEMOCLAW_BREV_PROVIDER` if you need a different Brev cloud provider.

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 @@ -56,7 +56,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 @@ -73,15 +85,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
Loading