From 92732d0bfa63cfcc047c16cc4001d3a7eb5aeb5c Mon Sep 17 00:00:00 2001 From: 13ernkastel Date: Wed, 1 Apr 2026 09:48:58 +0800 Subject: [PATCH 1/3] fix: require Telegram chat allowlist --- bin/nemoclaw.js | 105 ++++++++++++++++++++-- docs/deployment/deploy-to-remote-gpu.md | 2 + docs/deployment/set-up-telegram-bridge.md | 27 +++++- docs/reference/architecture.md | 2 +- docs/reference/commands.md | 19 +++- scripts/start-services.sh | 37 +++++++- scripts/telegram-bridge.js | 85 ++++++++++++++---- test/cli.test.js | 82 +++++++++++++++++ test/service-env.test.js | 21 ++++- 9 files changed, 350 insertions(+), 30 deletions(-) diff --git a/bin/nemoclaw.js b/bin/nemoclaw.js index d19317c16..0691f3cb5 100755 --- a/bin/nemoclaw.js +++ b/bin/nemoclaw.js @@ -36,6 +36,7 @@ const { ensureApiKey, ensureGithubToken, getCredential, + saveCredential, isRepoPrivate, } = require("./lib/credentials"); const registry = require("./lib/registry"); @@ -54,6 +55,7 @@ const GLOBAL_COMMANDS = new Set([ "setup", "setup-spark", "start", + "telegram", "stop", "status", "debug", @@ -719,6 +721,8 @@ 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 = getCredential("ALLOWED_CHAT_IDS"); + if (allowedChatIds) envLines.push(`ALLOWED_CHAT_IDS=${shellQuote(allowedChatIds)}`); const discordToken = getCredential("DISCORD_BOT_TOKEN"); if (discordToken) envLines.push(`DISCORD_BOT_TOKEN=${shellQuote(discordToken)}`); const slackToken = getCredential("SLACK_BOT_TOKEN"); @@ -766,18 +770,105 @@ 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(","); +} + +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 = getCredential("ALLOWED_CHAT_IDS"); + if (allowedChatIds) envAssignments.push(`ALLOWED_CHAT_IDS=${shellQuote(allowedChatIds)}`); + if (discoveryMode) envAssignments.push("NEMOCLAW_TELEGRAM_DISCOVERY=1"); + 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 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": { + const allowlist = getCredential("ALLOWED_CHAT_IDS"); + if (!allowlist) { + console.log(" No Telegram allowlist configured."); + return; + } + console.log(` Telegram allowlist: ${allowlist}`); + return; + } + case "clear": + saveCredential("ALLOWED_CHAT_IDS", ""); + console.log(" Cleared Telegram allowlist."); + return; + case "discover": + 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", @@ -1147,9 +1238,10 @@ function help() { nemoclaw deploy 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 allow Save allowed Telegram chat IDs Troubleshooting: nemoclaw debug [--quick] Collect diagnostics for bug reports @@ -1197,7 +1289,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(); diff --git a/docs/deployment/deploy-to-remote-gpu.md b/docs/deployment/deploy-to-remote-gpu.md index cf2280cd0..e36f76e9d 100644 --- a/docs/deployment/deploy-to-remote-gpu.md +++ b/docs/deployment/deploy-to-remote-gpu.md @@ -63,6 +63,8 @@ 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 until you either save `ALLOWED_CHAT_IDS` with `nemoclaw telegram allow ` or run discovery mode with `nemoclaw start --discover-chat-id`. + ## Connect to the Remote Sandbox After deployment finishes, the deploy command opens an interactive shell inside the remote sandbox. diff --git a/docs/deployment/set-up-telegram-bridge.md b/docs/deployment/set-up-telegram-bridge.md index 5d85cb83f..4886bbbae 100644 --- a/docs/deployment/set-up-telegram-bridge.md +++ b/docs/deployment/set-up-telegram-bridge.md @@ -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 @@ -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: diff --git a/docs/reference/architecture.md b/docs/reference/architecture.md index ca6b8e993..705bfb341 100644 --- a/docs/reference/architecture.md +++ b/docs/reference/architecture.md @@ -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. diff --git a/docs/reference/commands.md b/docs/reference/commands.md index f83f7796f..848dbf3f2 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -173,7 +173,24 @@ Start auxiliary services, such as the Telegram bridge and cloudflared tunnel. $ nemoclaw start ``` -Requires `TELEGRAM_BOT_TOKEN` for the Telegram bridge. +Use discovery-only mode to have the bot reply with your Telegram chat ID without forwarding messages to the agent: + +```console +$ nemoclaw start --discover-chat-id +``` + +The Telegram bridge requires `TELEGRAM_BOT_TOKEN`, `NVIDIA_API_KEY`, and an `ALLOWED_CHAT_IDS` allowlist for normal operation. + +### `nemoclaw telegram` + +Manage the Telegram bridge allowlist. + +```console +$ nemoclaw telegram allow 123456789 +$ nemoclaw telegram show +$ nemoclaw telegram clear +$ nemoclaw telegram discover +``` ### `nemoclaw stop` diff --git a/scripts/start-services.sh b/scripts/start-services.sh index 0c64d1341..8c0549eee 100755 --- a/scripts/start-services.sh +++ b/scripts/start-services.sh @@ -123,12 +123,24 @@ do_stop() { } do_start() { + local telegram_status="not started (no token)" + if [ -z "${TELEGRAM_BOT_TOKEN:-}" ]; then warn "TELEGRAM_BOT_TOKEN not set — Telegram bridge will not start." warn "Create a bot via @BotFather on Telegram and set the token." + telegram_status="not started (no token)" elif [ -z "${NVIDIA_API_KEY:-}" ]; then warn "NVIDIA_API_KEY not set — Telegram bridge will not start." warn "Set NVIDIA_API_KEY if you want Telegram requests to reach inference." + telegram_status="not started (missing NVIDIA_API_KEY)" + elif [ -z "${ALLOWED_CHAT_IDS:-}" ] && [ "${NEMOCLAW_TELEGRAM_DISCOVERY:-0}" != "1" ]; then + warn "ALLOWED_CHAT_IDS not set — Telegram bridge will not start." + warn "Run 'nemoclaw start --discover-chat-id' to return your Telegram chat ID safely." + warn "Then run 'nemoclaw telegram allow ' and start services again." + telegram_status="not started (allowlist required)" + elif [ "${NEMOCLAW_TELEGRAM_DISCOVERY:-0}" = "1" ] && [ -z "${ALLOWED_CHAT_IDS:-}" ]; then + info "Telegram discovery mode enabled — messages will return their chat ID only." + telegram_status="discovery only" fi command -v node >/dev/null || fail "node not found. Install Node.js first." @@ -152,9 +164,14 @@ do_start() { mkdir -p "$PIDDIR" # Telegram bridge (only if token provided) - if [ -n "${TELEGRAM_BOT_TOKEN:-}" ] && [ -n "${NVIDIA_API_KEY:-}" ]; then + if [ -n "${TELEGRAM_BOT_TOKEN:-}" ] && [ -n "${NVIDIA_API_KEY:-}" ] && { [ -n "${ALLOWED_CHAT_IDS:-}" ] || [ "${NEMOCLAW_TELEGRAM_DISCOVERY:-0}" = "1" ]; }; then SANDBOX_NAME="$SANDBOX_NAME" start_service telegram-bridge \ node "$REPO_DIR/scripts/telegram-bridge.js" + if [ "${NEMOCLAW_TELEGRAM_DISCOVERY:-0}" = "1" ] && [ -z "${ALLOWED_CHAT_IDS:-}" ]; then + telegram_status="discovery only" + else + telegram_status="bridge running" + fi fi # 3. cloudflared tunnel @@ -194,9 +211,23 @@ do_start() { fi if is_running telegram-bridge; then - echo " │ Telegram: bridge running │" + if [ "${NEMOCLAW_TELEGRAM_DISCOVERY:-0}" = "1" ] && [ -z "${ALLOWED_CHAT_IDS:-}" ]; then + echo " │ Telegram: discovery only │" + else + echo " │ Telegram: bridge running │" + fi else - echo " │ Telegram: not started (no token) │" + case "$telegram_status" in + "not started (missing NVIDIA_API_KEY)") + echo " │ Telegram: not started (missing API key) │" + ;; + "not started (allowlist required)") + echo " │ Telegram: not started (allowlist required) │" + ;; + *) + echo " │ Telegram: not started (no token) │" + ;; + esac fi echo " │ │" diff --git a/scripts/telegram-bridge.js b/scripts/telegram-bridge.js index 27d5d7ba4..1cda99c6c 100755 --- a/scripts/telegram-bridge.js +++ b/scripts/telegram-bridge.js @@ -13,7 +13,8 @@ * TELEGRAM_BOT_TOKEN — from @BotFather * NVIDIA_API_KEY — for inference * SANDBOX_NAME — sandbox name (default: nemoclaw) - * ALLOWED_CHAT_IDS — comma-separated Telegram chat IDs to accept (optional, accepts all if unset) + * ALLOWED_CHAT_IDS — comma-separated Telegram chat IDs to accept + * NEMOCLAW_TELEGRAM_DISCOVERY=1 — reply with the sender chat ID instead of forwarding */ const https = require("https"); @@ -30,13 +31,32 @@ if (!OPENSHELL) { const TOKEN = process.env.TELEGRAM_BOT_TOKEN; const API_KEY = process.env.NVIDIA_API_KEY; const SANDBOX = process.env.SANDBOX_NAME || "nemoclaw"; -try { validateName(SANDBOX, "SANDBOX_NAME"); } catch (e) { console.error(e.message); process.exit(1); } +try { + validateName(SANDBOX, "SANDBOX_NAME"); +} catch (e) { + console.error(e.message); + process.exit(1); +} +const DISCOVERY_MODE = process.env.NEMOCLAW_TELEGRAM_DISCOVERY === "1"; const ALLOWED_CHATS = process.env.ALLOWED_CHAT_IDS - ? process.env.ALLOWED_CHAT_IDS.split(",").map((s) => s.trim()) - : null; - -if (!TOKEN) { console.error("TELEGRAM_BOT_TOKEN required"); process.exit(1); } -if (!API_KEY) { console.error("NVIDIA_API_KEY required"); process.exit(1); } + ? process.env.ALLOWED_CHAT_IDS.split(",") + .map((s) => s.trim()) + .filter(Boolean) + : []; +const DISCOVERY_ONLY = DISCOVERY_MODE && ALLOWED_CHATS.length === 0; + +if (!TOKEN) { + console.error("TELEGRAM_BOT_TOKEN required"); + process.exit(1); +} +if (!API_KEY) { + console.error("NVIDIA_API_KEY required"); + process.exit(1); +} +if (!DISCOVERY_ONLY && ALLOWED_CHATS.length === 0) { + console.error("ALLOWED_CHAT_IDS required unless NEMOCLAW_TELEGRAM_DISCOVERY=1"); + process.exit(1); +} let offset = 0; const activeSessions = new Map(); // chatId → message history @@ -61,7 +81,11 @@ function tgApi(method, body) { let buf = ""; res.on("data", (c) => (buf += c)); res.on("end", () => { - try { resolve(JSON.parse(buf)); } catch { resolve({ ok: false, error: buf }); } + try { + resolve(JSON.parse(buf)); + } catch { + resolve({ ok: false, error: buf }); + } }); }, ); @@ -98,7 +122,9 @@ async function sendTyping(chatId) { function runAgentInSandbox(message, sessionId) { return new Promise((resolve) => { - const sshConfig = execFileSync(OPENSHELL, ["sandbox", "ssh-config", SANDBOX], { encoding: "utf-8" }); + const sshConfig = execFileSync(OPENSHELL, ["sandbox", "ssh-config", SANDBOX], { + encoding: "utf-8", + }); // Write temp ssh config with unpredictable name const confDir = require("fs").mkdtempSync("/tmp/nemoclaw-tg-ssh-"); @@ -123,7 +149,12 @@ function runAgentInSandbox(message, sessionId) { proc.stderr.on("data", (d) => (stderr += d.toString())); proc.on("close", (code) => { - try { require("fs").unlinkSync(confPath); require("fs").rmdirSync(confDir); } catch { /* ignored */ } + try { + require("fs").unlinkSync(confPath); + require("fs").rmdirSync(confDir); + } catch { + /* ignored */ + } // Extract the actual agent response — skip setup lines const lines = stdout.split("\n"); @@ -174,14 +205,28 @@ async function poll() { const chatId = String(msg.chat.id); // Access control - if (ALLOWED_CHATS && !ALLOWED_CHATS.includes(chatId)) { + if (!DISCOVERY_ONLY && !ALLOWED_CHATS.includes(chatId)) { console.log(`[ignored] chat ${chatId} not in allowed list`); + await sendMessage( + chatId, + `This chat is not authorized.\n\nYour Telegram chat ID is \`${chatId}\`.\nAsk the operator to run \`nemoclaw telegram allow ${chatId}\`.`, + msg.message_id, + ); continue; } const userName = msg.from?.first_name || "someone"; console.log(`[${chatId}] ${userName}: ${msg.text}`); + if (DISCOVERY_ONLY) { + await sendMessage( + chatId, + `Discovery mode is enabled.\n\nYour Telegram chat ID is \`${chatId}\`.\nRun \`nemoclaw telegram allow ${chatId}\`, then restart the bridge with \`nemoclaw start\`.`, + msg.message_id, + ); + continue; + } + // Handle /start if (msg.text === "/start") { await sendMessage( @@ -207,7 +252,11 @@ async function poll() { const lastTime = lastMessageTime.get(chatId) || 0; if (now - lastTime < COOLDOWN_MS) { const wait = Math.ceil((COOLDOWN_MS - (now - lastTime)) / 1000); - await sendMessage(chatId, `Please wait ${wait}s before sending another message.`, msg.message_id); + await sendMessage( + chatId, + `Please wait ${wait}s before sending another message.`, + msg.message_id, + ); continue; } @@ -264,9 +313,15 @@ async function main() { console.log(" │ Sandbox: " + (SANDBOX + " ").slice(0, 40) + "│"); console.log(" │ Model: nvidia/nemotron-3-super-120b-a12b │"); console.log(" │ │"); - console.log(" │ Messages are forwarded to the OpenClaw agent │"); - console.log(" │ inside the sandbox. Run 'openshell term' in │"); - console.log(" │ another terminal to monitor + approve egress. │"); + if (DISCOVERY_ONLY) { + console.log(" │ Discovery mode: incoming messages return chat ID │"); + console.log(" │ only. Agent forwarding stays disabled until │"); + console.log(" │ ALLOWED_CHAT_IDS is configured. │"); + } else { + console.log(" │ Messages are forwarded to the OpenClaw agent │"); + console.log(" │ inside the sandbox. Run 'openshell term' in │"); + console.log(" │ another terminal to monitor + approve egress. │"); + } console.log(" └─────────────────────────────────────────────────────┘"); console.log(""); diff --git a/test/cli.test.js b/test/cli.test.js index 976999a00..32abbdec6 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -108,6 +108,88 @@ describe("CLI dispatch", () => { expect(fs.readFileSync(markerFile, "utf8")).toContain("start-services.sh"); }); + it("start forwards stored Telegram allowlist to start-services", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-start-allowlist-")); + const localBin = path.join(home, "bin"); + const registryDir = path.join(home, ".nemoclaw"); + const markerFile = path.join(home, "start-env"); + fs.mkdirSync(localBin, { recursive: true }); + fs.mkdirSync(registryDir, { recursive: true }); + fs.writeFileSync( + path.join(registryDir, "sandboxes.json"), + JSON.stringify({ + sandboxes: { + alpha: { + name: "alpha", + model: "test-model", + provider: "nvidia-prod", + gpuEnabled: false, + policies: [], + }, + }, + defaultSandbox: "alpha", + }), + { mode: 0o600 }, + ); + fs.writeFileSync( + path.join(localBin, "bash"), + [ + "#!/bin/sh", + `marker_file=${JSON.stringify(markerFile)}`, + 'printf \'SANDBOX_NAME=%s\\nALLOWED_CHAT_IDS=%s\\nNEMOCLAW_TELEGRAM_DISCOVERY=%s\\nARGS=%s\\n\' "$SANDBOX_NAME" "$ALLOWED_CHAT_IDS" "$NEMOCLAW_TELEGRAM_DISCOVERY" "$*" > "$marker_file"', + "exit 0", + ].join("\n"), + { mode: 0o755 }, + ); + + expect(runWithEnv("telegram allow 12345,67890", { HOME: home }).code).toBe(0); + + const r = runWithEnv("start", { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + }); + + expect(r.code).toBe(0); + const marker = fs.readFileSync(markerFile, "utf8"); + expect(marker).toContain("SANDBOX_NAME='alpha'"); + expect(marker).toContain("ALLOWED_CHAT_IDS='12345,67890'"); + expect(marker).toContain("ARGS="); + expect(marker).toContain("start-services.sh"); + }); + + it("start --discover-chat-id enables discovery mode", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-start-discovery-")); + const localBin = path.join(home, "bin"); + const markerFile = path.join(home, "start-env"); + fs.mkdirSync(localBin, { recursive: true }); + fs.writeFileSync( + path.join(localBin, "bash"), + [ + "#!/bin/sh", + `marker_file=${JSON.stringify(markerFile)}`, + 'printf \'NEMOCLAW_TELEGRAM_DISCOVERY=%s\\nARGS=%s\\n\' "$NEMOCLAW_TELEGRAM_DISCOVERY" "$*" > "$marker_file"', + "exit 0", + ].join("\n"), + { mode: 0o755 }, + ); + + const r = runWithEnv("start --discover-chat-id", { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + }); + + expect(r.code).toBe(0); + const marker = fs.readFileSync(markerFile, "utf8"); + expect(marker).toContain("NEMOCLAW_TELEGRAM_DISCOVERY=1"); + expect(marker).toContain("start-services.sh"); + }); + + it("telegram allow rejects invalid chat ids", () => { + const r = run("telegram allow not-a-chat-id"); + expect(r.code).toBe(1); + expect(r.out).toContain("Invalid Telegram chat ID"); + }); + it("unknown onboard option exits 1", () => { const r = run("onboard --non-interactiv"); expect(r.code).toBe(1); diff --git a/test/service-env.test.js b/test/service-env.test.js index 6b419898d..7af484f41 100644 --- a/test/service-env.test.js +++ b/test/service-env.test.js @@ -45,7 +45,26 @@ describe("service environment", () => { expect(result).not.toContain("NVIDIA_API_KEY required"); expect(result).toContain("NVIDIA_API_KEY not set"); - expect(result).toContain("Telegram: not started (no token)"); + expect(result).toContain("Telegram: not started (missing API key)"); + }); + + it("warns and skips Telegram bridge when allowlist is missing", () => { + const workspace = mkdtempSync(join(tmpdir(), "nemoclaw-services-missing-allowlist-")); + const result = execFileSync("bash", [scriptPath], { + encoding: "utf-8", + env: { + ...process.env, + NVIDIA_API_KEY: "nvapi-test", + TELEGRAM_BOT_TOKEN: "test-token", + ALLOWED_CHAT_IDS: "", + SANDBOX_NAME: "test-box", + TMPDIR: workspace, + }, + }); + + expect(result).toContain("ALLOWED_CHAT_IDS not set"); + expect(result).toContain("discover-chat-id"); + expect(result).toContain("Telegram: not started (allowlist required)"); }); }); From 51a5b66005877f4ed4da6cee58378e4e9e101766 Mon Sep 17 00:00:00 2001 From: 13ernkastel Date: Wed, 1 Apr 2026 11:01:11 +0800 Subject: [PATCH 2/3] fix telegram allowlist review feedback --- bin/lib/onboard.js | 23 ++-- bin/lib/sandbox-names.js | 40 +++++++ bin/nemoclaw.js | 73 ++++++++---- docs/deployment/deploy-to-remote-gpu.md | 3 +- docs/reference/commands.md | 7 ++ test/cli.test.js | 151 +++++++++++++++++++----- test/runner.test.js | 13 ++ 7 files changed, 242 insertions(+), 68 deletions(-) create mode 100644 bin/lib/sandbox-names.js diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index 25fd2fb5c..50c3410c5 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -46,6 +46,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}`); @@ -2266,16 +2267,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()) { @@ -2298,7 +2301,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. diff --git a/bin/lib/sandbox-names.js b/bin/lib/sandbox-names.js new file mode 100644 index 000000000..9b1838568 --- /dev/null +++ b/bin/lib/sandbox-names.js @@ -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} '.`, + ); + } + return validName; +} + +module.exports = { RESERVED_SANDBOX_NAMES, SANDBOX_ACTIONS, validateSandboxName }; diff --git a/bin/nemoclaw.js b/bin/nemoclaw.js index 0691f3cb5..90bf80fa0 100755 --- a/bin/nemoclaw.js +++ b/bin/nemoclaw.js @@ -45,27 +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", - "telegram", - "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"; @@ -721,8 +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 = getCredential("ALLOWED_CHAT_IDS"); - if (allowedChatIds) envLines.push(`ALLOWED_CHAT_IDS=${shellQuote(allowedChatIds)}`); + 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"); @@ -786,6 +771,25 @@ function normalizeTelegramChatIds(rawValue) { 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)); @@ -800,9 +804,9 @@ async function start(args = []) { defaultSandbox && /^[a-zA-Z0-9._-]+$/.test(defaultSandbox) ? defaultSandbox : null; const envAssignments = []; if (safeName) envAssignments.push(`SANDBOX_NAME=${shellQuote(safeName)}`); - const allowedChatIds = getCredential("ALLOWED_CHAT_IDS"); - if (allowedChatIds) envAssignments.push(`ALLOWED_CHAT_IDS=${shellQuote(allowedChatIds)}`); - if (discoveryMode) envAssignments.push("NEMOCLAW_TELEGRAM_DISCOVERY=1"); + 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"`); } @@ -847,6 +851,7 @@ async function telegramCommand(args = []) { return; } case "show": { + rejectUnexpectedTelegramOperands("show", rest); const allowlist = getCredential("ALLOWED_CHAT_IDS"); if (!allowlist) { console.log(" No Telegram allowlist configured."); @@ -856,10 +861,12 @@ async function telegramCommand(args = []) { 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: @@ -1229,6 +1236,7 @@ function help() { nemoclaw status Sandbox health + NIM status nemoclaw logs ${D}[--follow]${R} Stream sandbox logs nemoclaw destroy Stop NIM + delete sandbox ${D}(--yes to skip prompt)${R} + nemoclaw -- Target a sandbox whose name matches a global command ${G}Policy Presets:${R} nemoclaw policy-add Add a network or filesystem policy preset @@ -1241,7 +1249,7 @@ function help() { 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 allow Save allowed Telegram chat IDs + nemoclaw telegram [help] Manage Telegram allowlist + discovery mode Troubleshooting: nemoclaw debug [--quick] Collect diagnostics for bug reports @@ -1263,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 () => { @@ -1274,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); diff --git a/docs/deployment/deploy-to-remote-gpu.md b/docs/deployment/deploy-to-remote-gpu.md index e36f76e9d..71e46d8fc 100644 --- a/docs/deployment/deploy-to-remote-gpu.md +++ b/docs/deployment/deploy-to-remote-gpu.md @@ -63,7 +63,8 @@ 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 until you either save `ALLOWED_CHAT_IDS` with `nemoclaw telegram allow ` or run discovery mode with `nemoclaw start --discover-chat-id`. +If you configured a Telegram bot token but not an allowlist yet, the bridge stays disabled. +Save `ALLOWED_CHAT_IDS` with `nemoclaw telegram allow ` or run discovery mode with `nemoclaw start --discover-chat-id` to enable it. ## Connect to the Remote Sandbox diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 848dbf3f2..648925d91 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -185,6 +185,13 @@ The Telegram bridge requires `TELEGRAM_BOT_TOKEN`, `NVIDIA_API_KEY`, and an `ALL Manage the Telegram bridge allowlist. +| Subcommand | Description | +|------------|-------------| +| `allow ` | Save one or more Telegram chat IDs in the allowlist | +| `show` | Display the current Telegram allowlist | +| `clear` | Remove all saved Telegram chat IDs | +| `discover` | Start the bridge in discovery-only mode to reveal your chat ID | + ```console $ nemoclaw telegram allow 123456789 $ nemoclaw telegram show diff --git a/test/cli.test.js b/test/cli.test.js index 32abbdec6..43176798d 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -26,6 +26,26 @@ function runWithEnv(args, env = {}, timeout = 10000) { } } +function writeBashCaptureStub(localBin, markerFile) { + fs.writeFileSync( + path.join(localBin, "bash"), + [ + "#!/bin/sh", + `marker_file=${JSON.stringify(markerFile)}`, + 'if [ "${NEMOCLAW_BASH_STUB_CHILD:-}" = "1" ]; then', + ' printf \'SANDBOX_NAME=%s\\nALLOWED_CHAT_IDS=%s\\nNEMOCLAW_TELEGRAM_DISCOVERY=%s\\nARGS=%s\\n\' "$SANDBOX_NAME" "$ALLOWED_CHAT_IDS" "$NEMOCLAW_TELEGRAM_DISCOVERY" "$*" > "$marker_file"', + " exit 0", + "fi", + 'if [ "$1" = "-c" ]; then', + ' NEMOCLAW_BASH_STUB_CHILD=1 eval "$2"', + " exit $?", + "fi", + "exit 0", + ].join("\n"), + { mode: 0o755 }, + ); +} + describe("CLI dispatch", () => { it("help exits 0 and shows sections", () => { const r = run("help"); @@ -33,6 +53,8 @@ describe("CLI dispatch", () => { expect(r.out.includes("Getting Started")).toBeTruthy(); expect(r.out.includes("Sandbox Management")).toBeTruthy(); expect(r.out.includes("Policy Presets")).toBeTruthy(); + expect(r.out.includes("nemoclaw telegram [help]")).toBeTruthy(); + expect(r.out.includes("nemoclaw -- ")).toBeTruthy(); }); it("--help exits 0", () => { @@ -85,16 +107,7 @@ describe("CLI dispatch", () => { }), { mode: 0o600 }, ); - fs.writeFileSync( - path.join(localBin, "bash"), - [ - "#!/bin/sh", - `marker_file=${JSON.stringify(markerFile)}`, - 'printf \'%s\\n\' "$@" > "$marker_file"', - "exit 0", - ].join("\n"), - { mode: 0o755 }, - ); + writeBashCaptureStub(localBin, markerFile); const r = runWithEnv("start", { HOME: home, @@ -131,16 +144,7 @@ describe("CLI dispatch", () => { }), { mode: 0o600 }, ); - fs.writeFileSync( - path.join(localBin, "bash"), - [ - "#!/bin/sh", - `marker_file=${JSON.stringify(markerFile)}`, - 'printf \'SANDBOX_NAME=%s\\nALLOWED_CHAT_IDS=%s\\nNEMOCLAW_TELEGRAM_DISCOVERY=%s\\nARGS=%s\\n\' "$SANDBOX_NAME" "$ALLOWED_CHAT_IDS" "$NEMOCLAW_TELEGRAM_DISCOVERY" "$*" > "$marker_file"', - "exit 0", - ].join("\n"), - { mode: 0o755 }, - ); + writeBashCaptureStub(localBin, markerFile); expect(runWithEnv("telegram allow 12345,67890", { HOME: home }).code).toBe(0); @@ -151,27 +155,56 @@ describe("CLI dispatch", () => { expect(r.code).toBe(0); const marker = fs.readFileSync(markerFile, "utf8"); - expect(marker).toContain("SANDBOX_NAME='alpha'"); - expect(marker).toContain("ALLOWED_CHAT_IDS='12345,67890'"); + expect(marker).toContain("SANDBOX_NAME=alpha"); + expect(marker).toContain("ALLOWED_CHAT_IDS=12345,67890"); + expect(marker).toContain("NEMOCLAW_TELEGRAM_DISCOVERY=0"); expect(marker).toContain("ARGS="); expect(marker).toContain("start-services.sh"); }); - it("start --discover-chat-id enables discovery mode", () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-start-discovery-")); + it("start clears Telegram allowlist and discovery mode when unset", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-start-default-env-")); const localBin = path.join(home, "bin"); + const registryDir = path.join(home, ".nemoclaw"); const markerFile = path.join(home, "start-env"); fs.mkdirSync(localBin, { recursive: true }); + fs.mkdirSync(registryDir, { recursive: true }); fs.writeFileSync( - path.join(localBin, "bash"), - [ - "#!/bin/sh", - `marker_file=${JSON.stringify(markerFile)}`, - 'printf \'NEMOCLAW_TELEGRAM_DISCOVERY=%s\\nARGS=%s\\n\' "$NEMOCLAW_TELEGRAM_DISCOVERY" "$*" > "$marker_file"', - "exit 0", - ].join("\n"), - { mode: 0o755 }, + path.join(registryDir, "sandboxes.json"), + JSON.stringify({ + sandboxes: { + alpha: { + name: "alpha", + model: "test-model", + provider: "nvidia-prod", + gpuEnabled: false, + policies: [], + }, + }, + defaultSandbox: "alpha", + }), + { mode: 0o600 }, ); + writeBashCaptureStub(localBin, markerFile); + + const r = runWithEnv("start", { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + }); + + expect(r.code).toBe(0); + const marker = fs.readFileSync(markerFile, "utf8"); + expect(marker).toContain("ALLOWED_CHAT_IDS="); + expect(marker).toContain("NEMOCLAW_TELEGRAM_DISCOVERY=0"); + expect(marker).toContain("start-services.sh"); + }); + + it("start --discover-chat-id enables discovery mode", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-start-discovery-")); + const localBin = path.join(home, "bin"); + const markerFile = path.join(home, "start-env"); + fs.mkdirSync(localBin, { recursive: true }); + writeBashCaptureStub(localBin, markerFile); const r = runWithEnv("start --discover-chat-id", { HOME: home, @@ -190,6 +223,60 @@ describe("CLI dispatch", () => { expect(r.out).toContain("Invalid Telegram chat ID"); }); + for (const action of ["show", "clear", "discover"]) { + it(`telegram ${action} rejects extra operands`, () => { + const r = run(`telegram ${action} unexpected`); + expect(r.code).toBe(1); + expect(r.out).toContain(`Unknown telegram ${action} option(s): unexpected`); + }); + } + + it("supports sandbox escape hatch for names that collide with global commands", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-reserved-sandbox-")); + const localBin = path.join(home, "bin"); + const registryDir = path.join(home, ".nemoclaw"); + const markerFile = path.join(home, "openshell-args"); + fs.mkdirSync(localBin, { recursive: true }); + fs.mkdirSync(registryDir, { recursive: true }); + fs.writeFileSync( + path.join(registryDir, "sandboxes.json"), + JSON.stringify({ + sandboxes: { + telegram: { + name: "telegram", + model: "test-model", + provider: "nvidia-prod", + gpuEnabled: false, + policies: [], + }, + }, + defaultSandbox: "telegram", + }), + { mode: 0o600 }, + ); + fs.writeFileSync( + path.join(localBin, "openshell"), + [ + "#!/bin/sh", + `marker_file=${JSON.stringify(markerFile)}`, + 'printf \'%s\\n\' "$@" > "$marker_file"', + "exit 0", + ].join("\n"), + { mode: 0o755 }, + ); + + const r = runWithEnv("-- telegram connect", { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + }); + + expect(r.code).toBe(0); + const marker = fs.readFileSync(markerFile, "utf8"); + expect(marker).toContain("sandbox"); + expect(marker).toContain("connect"); + expect(marker).toContain("telegram"); + }); + it("unknown onboard option exits 1", () => { const r = run("onboard --non-interactiv"); expect(r.code).toBe(1); diff --git a/test/runner.test.js b/test/runner.test.js index 7ef659110..b6cbb7c8f 100644 --- a/test/runner.test.js +++ b/test/runner.test.js @@ -11,6 +11,7 @@ import { describe, expect, it } from "vitest"; import { runCapture } from "../bin/lib/runner"; const runnerPath = path.join(import.meta.dirname, "..", "bin", "lib", "runner"); +const sandboxNamesPath = path.join(import.meta.dirname, "..", "bin", "lib", "sandbox-names"); describe("runner helpers", () => { it("does not let child commands consume installer stdin", () => { @@ -170,6 +171,18 @@ describe("validateName", () => { }); }); +describe("validateSandboxName", () => { + it("rejects names reserved by the CLI", () => { + const { validateSandboxName } = require(sandboxNamesPath); + expect(() => validateSandboxName("telegram")).toThrow(/reserved by the CLI/); + }); + + it("accepts sandbox names that do not collide with commands", () => { + const { validateSandboxName } = require(sandboxNamesPath); + expect(validateSandboxName("my-sandbox")).toBe("my-sandbox"); + }); +}); + describe("regression guards", () => { it("nemoclaw.js does not use execSync", () => { const src = fs.readFileSync( From 37597ff83c9d3d2e2fec0ae7abb963a3d01ce6a6 Mon Sep 17 00:00:00 2001 From: 13ernkastel Date: Wed, 1 Apr 2026 11:29:23 +0800 Subject: [PATCH 3/3] test: isolate CLI start env in tests --- test/cli.test.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/test/cli.test.js b/test/cli.test.js index 43176798d..3c02959a8 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -114,6 +114,7 @@ describe("CLI dispatch", () => { PATH: `${localBin}:${process.env.PATH || ""}`, NVIDIA_API_KEY: "", TELEGRAM_BOT_TOKEN: "", + ALLOWED_CHAT_IDS: "", }); expect(r.code).toBe(0); @@ -151,6 +152,8 @@ describe("CLI dispatch", () => { const r = runWithEnv("start", { HOME: home, PATH: `${localBin}:${process.env.PATH || ""}`, + TELEGRAM_BOT_TOKEN: "", + ALLOWED_CHAT_IDS: "", }); expect(r.code).toBe(0); @@ -190,6 +193,8 @@ describe("CLI dispatch", () => { const r = runWithEnv("start", { HOME: home, PATH: `${localBin}:${process.env.PATH || ""}`, + TELEGRAM_BOT_TOKEN: "", + ALLOWED_CHAT_IDS: "", }); expect(r.code).toBe(0); @@ -209,6 +214,8 @@ describe("CLI dispatch", () => { const r = runWithEnv("start --discover-chat-id", { HOME: home, PATH: `${localBin}:${process.env.PATH || ""}`, + TELEGRAM_BOT_TOKEN: "", + ALLOWED_CHAT_IDS: "", }); expect(r.code).toBe(0);